@lucassantana/sharekit
Advanced tools
+4
-0
@@ -13,2 +13,6 @@ import { Dirs } from './paths.js'; | ||
| export type Status = 'new' | 'changed' | 'same'; | ||
| export declare function parseApplied(raw: string): Array<{ | ||
| dest: string; | ||
| status: Status; | ||
| }>; | ||
| export declare function readMetadata(backupDir: string): { | ||
@@ -15,0 +19,0 @@ sourceVersion?: string; |
+29
-19
| import * as fs from 'node:fs'; | ||
| import * as path from 'node:path'; | ||
| import { STATE, cp, DEFAULT_DIRS } from './paths.js'; | ||
| // Helper: Parse and validate applied.json | ||
| export function parseApplied(raw) { | ||
| let parsed; | ||
| try { | ||
| parsed = JSON.parse(raw); | ||
| } | ||
| catch { | ||
| throw new Error('applied.json is not valid JSON'); | ||
| } | ||
| if (!Array.isArray(parsed)) { | ||
| throw new Error('applied.json must be an array'); | ||
| } | ||
| for (let i = 0; i < parsed.length; i++) { | ||
| const item = parsed[i]; | ||
| if (typeof item !== 'object' || item === null) { | ||
| throw new Error(`applied.json[${i}] is not an object`); | ||
| } | ||
| const { dest, status } = item; | ||
| if (typeof dest !== 'string') { | ||
| throw new Error(`applied.json[${i}].dest must be a string, got ${typeof dest}`); | ||
| } | ||
| if (typeof status !== 'string') { | ||
| throw new Error(`applied.json[${i}].status must be a string, got ${typeof status}`); | ||
| } | ||
| } | ||
| return parsed; | ||
| } | ||
| // Helper: read metadata.json from backup directory | ||
@@ -76,21 +103,4 @@ // Returns {sourceVersion?, sourceCommit?} or {} if missing/unparseable | ||
| try { | ||
| const rawData = JSON.parse(fs.readFileSync(appliedPath, 'utf8')); | ||
| // Validate that it's an array | ||
| if (!Array.isArray(rawData)) { | ||
| throw new Error('applied.json must be an array'); | ||
| } | ||
| // Validate array elements have required shape | ||
| applied = rawData.map((item, index) => { | ||
| if (typeof item !== 'object' || item === null) { | ||
| throw new Error(`applied.json[${index}] is not an object`); | ||
| } | ||
| const { dest, status } = item; | ||
| if (typeof dest !== 'string') { | ||
| throw new Error(`applied.json[${index}].dest must be a string, got ${typeof dest}`); | ||
| } | ||
| if (typeof status !== 'string') { | ||
| throw new Error(`applied.json[${index}].status must be a string, got ${typeof status}`); | ||
| } | ||
| return { dest, status: status }; | ||
| }); | ||
| const rawData = fs.readFileSync(appliedPath, 'utf8'); | ||
| applied = parseApplied(rawData); | ||
| } | ||
@@ -97,0 +107,0 @@ catch (error) { |
@@ -6,6 +6,7 @@ import { Dirs } from './paths.js'; | ||
| dryRun?: boolean; | ||
| additive?: boolean; | ||
| } | ||
| export declare function confirm(q: string, autoYes?: boolean): Promise<boolean>; | ||
| export declare function search(query?: string): Promise<void>; | ||
| export declare function updateApply(user: string, includeHooks?: boolean, dirs?: Dirs): { | ||
| export declare function updateApply(user: string, includeHooks?: boolean, dirs?: Dirs, additive?: boolean): { | ||
| filesWritten: number; | ||
@@ -12,0 +13,0 @@ backupDir: string; |
+140
-134
@@ -9,3 +9,3 @@ import * as fs from 'node:fs'; | ||
| import { plan, printPlan, isExecutable, applyProfile } from './plan.js'; | ||
| import { restoreBackup } from './backup.js'; | ||
| import { restoreBackup, parseApplied } from './backup.js'; | ||
| import { recordInstall, readInstalled, isImmutableRef } from './state.js'; | ||
@@ -28,10 +28,27 @@ import { scanForSecrets, printAndGateFindings } from './scanner.js'; | ||
| export async function search(query) { | ||
| const q = encodeURIComponent(`sharekit-profile in:name${query ? ` ${query}` : ''}`); | ||
| const q = encodeURIComponent(`sharekit-profile in:name${query ? ` ${query}` : ''} is:archived:false`); | ||
| const url = `https://api.github.com/search/repositories?q=${q}&sort=stars&per_page=30`; | ||
| let data; | ||
| try { | ||
| const headers = { | ||
| 'User-Agent': 'sharekit-cli', | ||
| Accept: 'application/vnd.github+json', | ||
| }; | ||
| // Add GITHUB_TOKEN if available for higher rate limits (5000 req/hr instead of 60) | ||
| if (process.env.GITHUB_TOKEN) { | ||
| headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`; | ||
| } | ||
| const res = await fetch(url, { | ||
| headers: { 'User-Agent': 'sharekit-cli', Accept: 'application/vnd.github+json' }, | ||
| headers, | ||
| signal: AbortSignal.timeout(15_000), // don't hang forever on a stalled GitHub response | ||
| }); | ||
| // Handle rate limit 403 | ||
| if (res.status === 403) { | ||
| const remaining = res.headers.get('X-RateLimit-Remaining'); | ||
| const resetStr = res.headers.get('X-RateLimit-Reset'); | ||
| if (remaining === '0' && resetStr) { | ||
| const resetTime = new Date(parseInt(resetStr) * 1000).toLocaleString(); | ||
| throw new Error(`GitHub rate limit exceeded — resets at ${resetTime}. Set GITHUB_TOKEN env var for 5000 req/hr.`); | ||
| } | ||
| } | ||
| if (!res.ok) | ||
@@ -61,3 +78,3 @@ throw new Error(`GitHub API ${res.status}`); | ||
| // Core update logic without interactive prompts (for testing and direct apply) | ||
| export function updateApply(user, includeHooks = false, dirs = DEFAULT_DIRS) { | ||
| export function updateApply(user, includeHooks = false, dirs = DEFAULT_DIRS, additive = false) { | ||
| // Get the install record for this user | ||
@@ -88,8 +105,14 @@ const installed = readInstalled(dirs); | ||
| printPlan(files, manifest); | ||
| const todo = files.filter((f) => f.status !== 'same' && !isExecutable(f, includeHooks)); | ||
| const todo = files.filter((f) => (additive ? f.status === 'new' : f.status !== 'same') && !isExecutable(f, includeHooks)); | ||
| if (!todo.length) { | ||
| console.log(kleur.dim('\n Already up to date.\n')); | ||
| console.log(kleur.dim(additive ? '\n No new files to add.\n' : '\n Already up to date.\n')); | ||
| return { filesWritten: 0, backupDir: '' }; | ||
| } | ||
| const { backupDir, filesWritten } = applyProfile(files, user, includeHooks, dirs); | ||
| if (additive) { | ||
| const skipped = files.filter((f) => f.status === 'changed' && !isExecutable(f, includeHooks)); | ||
| if (skipped.length) { | ||
| console.log(kleur.dim(`\n = ${skipped.length} locally-modified file(s) preserved (additive mode)`)); | ||
| } | ||
| } | ||
| const { backupDir, filesWritten } = applyProfile(todo, user, includeHooks, dirs); | ||
| // Update the install record with the new commit and timestamp | ||
@@ -101,99 +124,114 @@ recordInstall(user, dir, ref, manifest.version, dirs); | ||
| export async function update(user, opts, dirs = DEFAULT_DIRS) { | ||
| const includeHooks = opts?.includeHooks ?? false; | ||
| const yes = opts?.yes ?? false; | ||
| const dryRun = opts?.dryRun ?? false; | ||
| // Get the install record for this user | ||
| const installed = readInstalled(dirs); | ||
| const record = installed[user]; | ||
| if (!record) { | ||
| throw new Error(`not installed — run 'sharekit install ${user}' first`); | ||
| } | ||
| const ref = record.ref; | ||
| // If ref is immutable (pinned to a tag or commit), don't update | ||
| if (isImmutableRef(ref)) { | ||
| console.log(kleur.yellow(`\n pinned to ${ref} — nothing to update\n`)); | ||
| return; | ||
| } | ||
| // Ref is mutable (branch or HEAD), so fetch the latest | ||
| // Use injected cache root for offline testability | ||
| const cacheRoot = path.join(dirs.state, 'profiles'); | ||
| const fetchDir = fetchProfile(user, ref, 'https://github.com', cacheRoot); | ||
| const manifest = readManifest(fetchDir); | ||
| // Compute roots relative to injected home for testability | ||
| const roots = { | ||
| claude: path.join(dirs.home, '.claude'), | ||
| cursor: path.join(dirs.home, '.cursor'), | ||
| shared: dirs.home, | ||
| }; | ||
| const files = plan(fetchDir, roots); | ||
| printPlan(files, manifest); | ||
| const todo = files.filter((f) => f.status !== 'same' && !isExecutable(f, includeHooks)); | ||
| if (!todo.length) | ||
| return void console.log(kleur.dim('\n Already up to date.\n')); | ||
| // If hooks present and not explicitly included, warn | ||
| const hasHooks = files.some((f) => isExecutable(f, false)); | ||
| if (hasHooks && !includeHooks) { | ||
| console.log(kleur.yellow(`\n ⚠ This profile's settings.json contains hooks that run shell commands.`)); | ||
| } | ||
| // If hooks present and user wants to include them, ask for explicit confirm | ||
| if (hasHooks && includeHooks) { | ||
| if (!(await confirm(`This profile's settings.json contains hooks that run shell commands. Update with it?`, yes))) { | ||
| try { | ||
| const includeHooks = opts?.includeHooks ?? false; | ||
| const yes = opts?.yes ?? false; | ||
| const dryRun = opts?.dryRun ?? false; | ||
| const additive = opts?.additive ?? false; | ||
| // Get the install record for this user | ||
| const installed = readInstalled(dirs); | ||
| const record = installed[user]; | ||
| if (!record) { | ||
| throw new Error(`not installed — run 'sharekit install ${user}' first`); | ||
| } | ||
| const ref = record.ref; | ||
| // If ref is immutable (pinned to a tag or commit), don't update | ||
| if (isImmutableRef(ref)) { | ||
| console.log(kleur.yellow(`\n pinned to ${ref} — nothing to update\n`)); | ||
| return; | ||
| } | ||
| // Ref is mutable (branch or HEAD), so fetch the latest | ||
| // Use injected cache root for offline testability | ||
| const cacheRoot = path.join(dirs.state, 'profiles'); | ||
| const fetchDir = fetchProfile(user, ref, 'https://github.com', cacheRoot); | ||
| const manifest = readManifest(fetchDir); | ||
| // Compute roots relative to injected home for testability | ||
| const roots = { | ||
| claude: path.join(dirs.home, '.claude'), | ||
| cursor: path.join(dirs.home, '.cursor'), | ||
| shared: dirs.home, | ||
| }; | ||
| const files = plan(fetchDir, roots); | ||
| printPlan(files, manifest); | ||
| const todo = files.filter((f) => (additive ? f.status === 'new' : f.status !== 'same') && !isExecutable(f, includeHooks)); | ||
| if (!todo.length) | ||
| return void console.log(kleur.dim(additive ? '\n No new files to add.\n' : '\n Already up to date.\n')); | ||
| if (additive) { | ||
| const skipped = files.filter((f) => f.status === 'changed' && !isExecutable(f, includeHooks)); | ||
| if (skipped.length) { | ||
| console.log(kleur.dim(`\n = ${skipped.length} locally-modified file(s) preserved (additive mode)`)); | ||
| } | ||
| } | ||
| // If hooks present and not explicitly included, warn | ||
| const hasHooks = files.some((f) => isExecutable(f, false)); | ||
| if (hasHooks && !includeHooks) { | ||
| console.log(kleur.yellow(`\n ⚠ This profile's settings.json contains hooks that run shell commands.`)); | ||
| } | ||
| // If hooks present and user wants to include them, ask for explicit confirm | ||
| if (hasHooks && includeHooks) { | ||
| if (!(await confirm(`This profile's settings.json contains hooks that run shell commands. Update with it?`, yes))) { | ||
| return void console.log(kleur.dim('\n Aborted.\n')); | ||
| } | ||
| } | ||
| if (!(await confirm(`Apply ${todo.length} change(s)?`, yes))) | ||
| return void console.log(kleur.dim('\n Aborted.\n')); | ||
| if (dryRun) { | ||
| // For dry-run, just count files | ||
| const filesWritten = todo.length; | ||
| console.log(kleur.cyan(`\n (dry-run — no files written)`)); | ||
| console.log(kleur.green(`\n ✓ Would update ${filesWritten} file(s).`)); | ||
| console.log(); | ||
| return; | ||
| } | ||
| const { backupDir, filesWritten } = updateApply(user, includeHooks, dirs, additive); | ||
| console.log(kleur.green(`\n ✓ Updated ${filesWritten} file(s).`) + | ||
| kleur.dim(` Backup: ${tildify(backupDir)}`)); | ||
| console.log(kleur.dim(` Undo: sharekit rollback ${user}\n`)); | ||
| } | ||
| if (!(await confirm(`Apply ${todo.length} change(s)?`, yes))) | ||
| return void console.log(kleur.dim('\n Aborted.\n')); | ||
| if (dryRun) { | ||
| // For dry-run, just count files | ||
| const filesWritten = todo.length; | ||
| console.log(kleur.cyan(`\n (dry-run — no files written)`)); | ||
| console.log(kleur.green(`\n ✓ Would update ${filesWritten} file(s).`)); | ||
| console.log(); | ||
| return; | ||
| finally { | ||
| } | ||
| const { backupDir, filesWritten } = updateApply(user, includeHooks, dirs); | ||
| console.log(kleur.green(`\n ✓ Updated ${filesWritten} file(s).`) + | ||
| kleur.dim(` Backup: ${tildify(backupDir)}`)); | ||
| console.log(kleur.dim(` Undo: sharekit rollback ${user}\n`)); | ||
| } | ||
| export async function install(user, opts) { | ||
| const includeHooks = opts?.includeHooks ?? false; | ||
| const { user: userName, ref: userRef } = parseUserRef(user); | ||
| const yes = opts?.yes ?? false; | ||
| const dryRun = opts?.dryRun ?? false; | ||
| const dir = fetchProfile(userName, userRef); | ||
| const manifest = readManifest(dir); | ||
| const files = plan(dir); | ||
| console.log(); | ||
| printPlan(files, manifest); | ||
| const todo = files.filter((f) => f.status !== 'same' && !isExecutable(f, includeHooks)); | ||
| if (!todo.length) | ||
| return void console.log(kleur.dim('\n Already up to date.\n')); | ||
| // If hooks present and not explicitly included, warn | ||
| const hasHooks = files.some((f) => isExecutable(f, false)); | ||
| if (hasHooks && !includeHooks) { | ||
| console.log(kleur.yellow(`\n ⚠ This profile's settings.json contains hooks that run shell commands.`)); | ||
| } | ||
| // If hooks present and user wants to include them, ask for explicit confirm | ||
| if (hasHooks && includeHooks) { | ||
| if (!(await confirm(`This profile's settings.json contains hooks that run shell commands. Install it?`, yes))) { | ||
| try { | ||
| const includeHooks = opts?.includeHooks ?? false; | ||
| const { user: userName, ref: userRef } = parseUserRef(user); | ||
| const yes = opts?.yes ?? false; | ||
| const dryRun = opts?.dryRun ?? false; | ||
| const dir = fetchProfile(userName, userRef); | ||
| const manifest = readManifest(dir); | ||
| const files = plan(dir); | ||
| console.log(); | ||
| printPlan(files, manifest); | ||
| const todo = files.filter((f) => f.status !== 'same' && !isExecutable(f, includeHooks)); | ||
| if (!todo.length) | ||
| return void console.log(kleur.dim('\n Already up to date.\n')); | ||
| // If hooks present and not explicitly included, warn | ||
| const hasHooks = files.some((f) => isExecutable(f, false)); | ||
| if (hasHooks && !includeHooks) { | ||
| console.log(kleur.yellow(`\n ⚠ This profile's settings.json contains hooks that run shell commands.`)); | ||
| } | ||
| // If hooks present and user wants to include them, ask for explicit confirm | ||
| if (hasHooks && includeHooks) { | ||
| if (!(await confirm(`This profile's settings.json contains hooks that run shell commands. Install it?`, yes))) { | ||
| return void console.log(kleur.dim('\n Aborted.\n')); | ||
| } | ||
| } | ||
| if (!(await confirm(`Apply ${todo.length} change(s)?`, yes))) | ||
| return void console.log(kleur.dim('\n Aborted.\n')); | ||
| const { backupDir, filesWritten } = applyProfile(files, userName, includeHooks, DEFAULT_DIRS, dryRun); | ||
| // Only record install if not a dry-run | ||
| if (!dryRun) { | ||
| recordInstall(userName, dir, userRef ?? 'HEAD', manifest.version); | ||
| } | ||
| if (dryRun) { | ||
| console.log(kleur.cyan(`\n (dry-run — no files written)`)); | ||
| } | ||
| console.log(kleur.green(`\n ✓ Applied ${filesWritten} file(s).`) + | ||
| (dryRun ? '' : kleur.dim(` Backup: ${tildify(backupDir)}`))); | ||
| if (!dryRun) { | ||
| console.log(kleur.dim(` Undo: sharekit rollback ${userName}`)); | ||
| } | ||
| console.log(); | ||
| } | ||
| if (!(await confirm(`Apply ${todo.length} change(s)?`, yes))) | ||
| return void console.log(kleur.dim('\n Aborted.\n')); | ||
| const { backupDir, filesWritten } = applyProfile(files, userName, includeHooks, DEFAULT_DIRS, dryRun); | ||
| // Only record install if not a dry-run | ||
| if (!dryRun) { | ||
| recordInstall(userName, dir, userRef ?? 'HEAD', manifest.version); | ||
| finally { | ||
| } | ||
| if (dryRun) { | ||
| console.log(kleur.cyan(`\n (dry-run — no files written)`)); | ||
| } | ||
| console.log(kleur.green(`\n ✓ Applied ${filesWritten} file(s).`) + | ||
| (dryRun ? '' : kleur.dim(` Backup: ${tildify(backupDir)}`))); | ||
| if (!dryRun) { | ||
| console.log(kleur.dim(` Undo: sharekit rollback ${userName}`)); | ||
| } | ||
| console.log(); | ||
| } | ||
@@ -256,21 +294,4 @@ export async function preview(user) { | ||
| try { | ||
| const rawData = JSON.parse(fs.readFileSync(appliedPath, 'utf8')); | ||
| // Validate that it's an array | ||
| if (!Array.isArray(rawData)) { | ||
| throw new Error('applied.json must be an array'); | ||
| } | ||
| // Validate array elements have required shape | ||
| applied = rawData.map((item, index) => { | ||
| if (typeof item !== 'object' || item === null) { | ||
| throw new Error(`applied.json[${index}] is not an object`); | ||
| } | ||
| const { dest, status } = item; | ||
| if (typeof dest !== 'string') { | ||
| throw new Error(`applied.json[${index}].dest must be a string, got ${typeof dest}`); | ||
| } | ||
| if (typeof status !== 'string') { | ||
| throw new Error(`applied.json[${index}].status must be a string, got ${typeof status}`); | ||
| } | ||
| return { dest, status }; | ||
| }); | ||
| const rawData = fs.readFileSync(appliedPath, 'utf8'); | ||
| applied = parseApplied(rawData); | ||
| } | ||
@@ -338,21 +359,4 @@ catch (error) { | ||
| try { | ||
| const rawData = JSON.parse(fs.readFileSync(appliedPath, 'utf8')); | ||
| // Validate that it's an array | ||
| if (!Array.isArray(rawData)) { | ||
| throw new Error('applied.json must be an array'); | ||
| } | ||
| // Validate array elements have required shape | ||
| applied = rawData.map((item, index) => { | ||
| if (typeof item !== 'object' || item === null) { | ||
| throw new Error(`applied.json[${index}] is not an object`); | ||
| } | ||
| const { dest, status } = item; | ||
| if (typeof dest !== 'string') { | ||
| throw new Error(`applied.json[${index}].dest must be a string, got ${typeof dest}`); | ||
| } | ||
| if (typeof status !== 'string') { | ||
| throw new Error(`applied.json[${index}].status must be a string, got ${typeof status}`); | ||
| } | ||
| return { dest, status }; | ||
| }); | ||
| const rawData = fs.readFileSync(appliedPath, 'utf8'); | ||
| applied = parseApplied(rawData); | ||
| } | ||
@@ -407,6 +411,8 @@ catch (error) { | ||
| } | ||
| // Remove user from installed.json | ||
| // Remove user from installed.json atomically (#122) | ||
| delete installed[user]; | ||
| const stateFile = path.join(dirs.state, 'installed.json'); | ||
| fs.writeFileSync(stateFile, JSON.stringify(installed, null, 2)); | ||
| const tmp = stateFile + '.tmp'; | ||
| fs.writeFileSync(tmp, JSON.stringify(installed, null, 2)); | ||
| fs.renameSync(tmp, stateFile); | ||
| const summary = `${toRemove.length} file(s) removed${toRestore.length > 0 ? `, ${toRestore.length} restored` : ''}`; | ||
@@ -413,0 +419,0 @@ console.log(kleur.green(`\n ✓ Uninstalled ${user}. ${summary}`)); |
+34
-4
@@ -5,2 +5,3 @@ import * as fs from 'node:fs'; | ||
| import { parse as parseToml } from 'smol-toml'; | ||
| import kleur from 'kleur'; | ||
| import { STATE, MAX_MANIFEST_BYTES } from './paths.js'; | ||
@@ -26,5 +27,8 @@ export function parseUserRef(user) { | ||
| // Validate userName | ||
| if (!userName || userName.trim() === '') { | ||
| if (!userName || userName.trim() === '' || /^@+$/.test(userName)) { | ||
| throw new Error(`Invalid user reference '${user}': missing GitHub username before '@'`); | ||
| } | ||
| if (userName.includes('@')) { | ||
| throw new Error(`Invalid user reference '${user}': username cannot contain '@'`); | ||
| } | ||
| if (userName.includes('..')) { | ||
@@ -53,3 +57,4 @@ throw new Error(`Invalid user '${userName}': username cannot contain '..'`); | ||
| const resolvedDir = path.resolve(dir); | ||
| if (!resolvedDir.startsWith(resolvedCache + path.sep)) { | ||
| const rel = path.relative(resolvedCache, resolvedDir); | ||
| if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { | ||
| throw new Error(`Invalid profile path: cache escape detected for '${cacheKey}'`); | ||
@@ -64,3 +69,12 @@ } | ||
| } | ||
| catch { | ||
| catch (e) { | ||
| // Check if the cache dir is corrupted | ||
| try { | ||
| execFileSync('git', ['rev-parse', '--git-dir'], { cwd: dir }); | ||
| } | ||
| catch { | ||
| // Cache dir is corrupt — clean it up | ||
| fs.rmSync(dir, { recursive: true, force: true }); | ||
| console.log(`Profile cache corrupted — cleaned up, retry to re-clone`); | ||
| } | ||
| // ponytail: refresh is best-effort — offline / no-remote / detached HEAD falls back to the cached copy | ||
@@ -87,2 +101,13 @@ } | ||
| catch (e) { | ||
| // Check if the cache dir is corrupted and clean it up | ||
| if (fs.existsSync(dir)) { | ||
| try { | ||
| execFileSync('git', ['rev-parse', '--git-dir'], { cwd: dir }); | ||
| } | ||
| catch { | ||
| // Cache dir is corrupt — clean it up | ||
| fs.rmSync(dir, { recursive: true, force: true }); | ||
| console.log(`Profile cache corrupted — cleaned up, retry to re-clone`); | ||
| } | ||
| } | ||
| if (e.code === 'ENOENT') { | ||
@@ -120,7 +145,12 @@ throw new Error('git not found — install git to use sharekit (https://git-scm.com)'); | ||
| const profile = (parsed.profile ?? {}); | ||
| const version = profile.version; | ||
| // Warn if version is not semver format | ||
| if (version && !/^\d+\.\d+\.\d+/.test(version)) { | ||
| console.warn(kleur.yellow(`Warning: profile version "${version}" is not semver — treating as-is`)); | ||
| } | ||
| return { | ||
| name: profile.name ?? 'unknown', | ||
| version: profile.version, | ||
| version, | ||
| description: profile.description, | ||
| }; | ||
| } |
+4
-1
@@ -9,3 +9,3 @@ #!/usr/bin/env node | ||
| const STATE = path.join(HOME, '.sharekit'); | ||
| const VERSION = '0.4.0'; | ||
| const VERSION = '0.5.0'; | ||
| const USAGE = `${kleur.bold('sharekit')} v${VERSION} — share your AI coding setup | ||
@@ -28,2 +28,3 @@ | ||
| --include-hooks also apply settings.json with shell hooks | ||
| --additive only add new files; preserve local edits | ||
| ${kleur.cyan('rollback')} <user> [opts] restore the last backup | ||
@@ -89,2 +90,4 @@ --yes auto-approve prompts | ||
| opts.includeHooks = true; | ||
| if (flags.includes('--additive')) | ||
| opts.additive = true; | ||
| await update(user, opts); | ||
@@ -91,0 +94,0 @@ return; |
+1
-1
@@ -10,3 +10,3 @@ export declare const HOME: string; | ||
| export declare const DEFAULT_DIRS: Dirs; | ||
| export declare const tildify: (p: string) => string; | ||
| export declare const tildify: (p: string, maxLen?: number) => string; | ||
| export declare function cp(src: string, dest: string): void; | ||
@@ -13,0 +13,0 @@ export interface WalkResult { |
+15
-5
@@ -16,7 +16,10 @@ import * as fs from 'node:fs'; | ||
| export const DEFAULT_DIRS = { home: HOME, state: STATE }; | ||
| export const tildify = (p) => { | ||
| if (!p.startsWith(HOME)) | ||
| return p; | ||
| // Normalize separators to forward slashes for cross-platform display | ||
| return '~' + p.slice(HOME.length).split(path.sep).join('/'); | ||
| export const tildify = (p, maxLen) => { | ||
| const result = p.startsWith(HOME) ? '~' + p.slice(HOME.length).split(path.sep).join('/') : p; | ||
| if (maxLen && result.length > maxLen && p.startsWith(HOME)) { | ||
| const half = Math.floor((maxLen - 3) / 2); | ||
| const end = result.length - (maxLen - 3 - half); | ||
| return result.slice(0, half) + '...' + result.slice(end); | ||
| } | ||
| return result; | ||
| }; | ||
@@ -31,3 +34,10 @@ // copy a file, preserving its mode (e.g. a skill's executable toggle.sh) | ||
| const skippedSymlinks = []; | ||
| const visited = new Set(); | ||
| const traverse = (currentDir) => { | ||
| const realDir = fs.realpathSync(currentDir); | ||
| if (visited.has(realDir)) { | ||
| console.warn(` Warning: symlink loop detected at ${currentDir} — skipping`); | ||
| return; | ||
| } | ||
| visited.add(realDir); | ||
| for (const e of fs.readdirSync(currentDir, { withFileTypes: true })) { | ||
@@ -34,0 +44,0 @@ const p = path.join(currentDir, e.name); |
+1
-1
@@ -28,3 +28,3 @@ import * as fs from 'node:fs'; | ||
| } | ||
| return files; | ||
| return files.filter((f) => f.status !== 'same'); | ||
| } | ||
@@ -31,0 +31,0 @@ function classify(src, dest) { |
+2
-2
@@ -113,4 +113,4 @@ import kleur from 'kleur'; | ||
| } | ||
| // Rule 7: Bearer JWT (HIGH) — eyJ prefix identifies base64-encoded JSON header (JWT) | ||
| const bearerMatch = /Bearer eyJ[A-Za-z0-9._\-]{17,}/.exec(line); | ||
| // Rule 7: Bearer JWT (HIGH) — require 2 dots to match JWT structure (header.payload.signature) | ||
| const bearerMatch = /Bearer eyJ[A-Za-z0-9._\-]*\.[A-Za-z0-9._\-]+\.[A-Za-z0-9._\-]+/.exec(line); | ||
| if (bearerMatch) { | ||
@@ -117,0 +117,0 @@ const preview = truncatePreview(line, bearerMatch.index, 0, 30, false); |
@@ -8,3 +8,3 @@ export { HOME, STATE, MAX_MANIFEST_BYTES, ROOTS, DEFAULT_DIRS, tildify, cp, walk, walkWithSymlinks, } from './paths.js'; | ||
| export type { Status, PlanFile } from './plan.js'; | ||
| export { listBackups, pruneBackups, restoreBackup, restoreBackupInternal, restoreBackupToStamp, readMetadata, writeMetadata, } from './backup.js'; | ||
| export { listBackups, pruneBackups, restoreBackup, restoreBackupInternal, restoreBackupToStamp, readMetadata, writeMetadata, parseApplied, } from './backup.js'; | ||
| export type { BackupInfo, RestoreMetadata } from './backup.js'; | ||
@@ -11,0 +11,0 @@ export { recordInstall, readInstalled, list, isImmutableRef } from './state.js'; |
+1
-1
@@ -11,3 +11,3 @@ // Barrel file: re-exports the entire public API for backward compatibility | ||
| // From backup.ts | ||
| export { listBackups, pruneBackups, restoreBackup, restoreBackupInternal, restoreBackupToStamp, readMetadata, writeMetadata, } from './backup.js'; | ||
| export { listBackups, pruneBackups, restoreBackup, restoreBackupInternal, restoreBackupToStamp, readMetadata, writeMetadata, parseApplied, } from './backup.js'; | ||
| // From state.ts | ||
@@ -14,0 +14,0 @@ export { recordInstall, readInstalled, list, isImmutableRef } from './state.js'; |
+2
-0
@@ -13,1 +13,3 @@ import { Dirs } from './paths.js'; | ||
| export declare function isImmutableRef(ref: string): boolean; | ||
| export declare function acquireLock(lockPath?: string): void; | ||
| export declare function releaseLock(lockPath?: string): void; |
+47
-34
@@ -6,3 +6,2 @@ import * as fs from 'node:fs'; | ||
| import { DEFAULT_DIRS } from './paths.js'; | ||
| // Record an installation: resolve commit SHA, write to state file (keyed by user) | ||
| export function recordInstall(user, profileDir, ref, version, dirs = DEFAULT_DIRS) { | ||
@@ -17,13 +16,4 @@ let commit = null; | ||
| } | ||
| catch { | ||
| // best-effort: if rev-parse fails (offline cache, odd state), record null | ||
| } | ||
| const record = { | ||
| user, | ||
| ref, | ||
| commit, | ||
| version, | ||
| appliedAt: new Date().toISOString(), | ||
| }; | ||
| // Read existing state or create empty map | ||
| catch { } | ||
| const record = { user, ref, commit, version, appliedAt: new Date().toISOString() }; | ||
| const stateFile = path.join(dirs.state, 'installed.json'); | ||
@@ -35,15 +25,10 @@ let installed = {}; | ||
| } | ||
| catch { | ||
| // corrupt state file; overwrite with fresh state | ||
| installed = {}; | ||
| } | ||
| catch { } | ||
| } | ||
| // Update or insert this user's record | ||
| installed[user] = record; | ||
| // Write back | ||
| fs.mkdirSync(dirs.state, { recursive: true }); | ||
| fs.writeFileSync(stateFile, JSON.stringify(installed, null, 2)); | ||
| const tmp = stateFile + '.tmp'; | ||
| fs.writeFileSync(tmp, JSON.stringify(installed, null, 2)); | ||
| fs.renameSync(tmp, stateFile); | ||
| } | ||
| // Read the install state file (returns a map keyed by user, or empty object if not found) | ||
| // If the file exists but is corrupt, warn to stderr to help the user diagnose the issue. | ||
| export function readInstalled(dirs = DEFAULT_DIRS) { | ||
@@ -54,11 +39,14 @@ const stateFile = path.join(dirs.state, 'installed.json'); | ||
| try { | ||
| return JSON.parse(fs.readFileSync(stateFile, 'utf8')); | ||
| const parsed = JSON.parse(fs.readFileSync(stateFile, 'utf8')); | ||
| if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { | ||
| console.warn(kleur.yellow(' Warning: installed.json is corrupt — resetting to empty')); | ||
| return {}; | ||
| } | ||
| return parsed; | ||
| } | ||
| catch (e) { | ||
| console.error(`${kleur.yellow('⚠ install state is corrupt')} — ${stateFile}\n` + | ||
| ` ${kleur.dim(`Reset: rm ${stateFile}`)} to rebuild from scratch.`); | ||
| console.error(`${kleur.yellow('⚠ install state is corrupt')} — ${stateFile}\n ${kleur.dim(`Reset: rm ${stateFile}`)} to rebuild from scratch.`); | ||
| return {}; | ||
| } | ||
| } | ||
| // List installed profiles with version, short commit SHA, and applied date | ||
| export function list(dirs = DEFAULT_DIRS) { | ||
@@ -74,3 +62,2 @@ const installed = readInstalled(dirs); | ||
| const shortSha = record.commit ? record.commit.slice(0, 7) : '?'; | ||
| // Guard against invalid appliedAt timestamps | ||
| let dateStr = '(unknown)'; | ||
@@ -92,16 +79,42 @@ if (record.appliedAt) { | ||
| } | ||
| // Check if a ref is an immutable ref (tag or commit hash). | ||
| // Only return true for HIGH-CONFIDENCE immutable cases. Bias toward mutable for ambiguous names | ||
| // because the best-effort git pull (from #52a) harmlessly no-ops on detached checkouts. | ||
| // Wrongly skipping an update to a real branch (the current risk) is worse than attempting | ||
| // a redundant pull on a tag (which fails harmlessly). | ||
| export function isImmutableRef(ref) { | ||
| // Hex commit SHA (7-40 hex chars, any case) is definitely immutable | ||
| if (/^[a-fA-F0-9]{7,40}$/.test(ref)) | ||
| return true; | ||
| // Anchored dotted semver: v1.0.0, 1.2, v2.0, etc. Excludes v2-wip, v3-feature, release-2 | ||
| if (/^v?\d+\.\d+(\.\d+)*$/.test(ref)) | ||
| return true; | ||
| // Everything else (branches, loose tags, ambiguous names) → mutable. Pull is safe; no-ops on detached. | ||
| return false; | ||
| } | ||
| const LOCK_FILE = path.join(DEFAULT_DIRS.state, '.lock'); | ||
| export function acquireLock(lockPath = LOCK_FILE) { | ||
| const pid = process.pid.toString(); | ||
| if (fs.existsSync(lockPath)) { | ||
| try { | ||
| const existingPid = fs.readFileSync(lockPath, 'utf8').trim(); | ||
| try { | ||
| fs.statSync(`/proc/${existingPid}`); | ||
| console.error(`Another sharekit operation is running (PID ${existingPid}). Try again in a moment.`); | ||
| process.exit(1); | ||
| } | ||
| catch { | ||
| try { | ||
| fs.unlinkSync(lockPath); | ||
| } | ||
| catch { } | ||
| } | ||
| } | ||
| catch { | ||
| try { | ||
| fs.unlinkSync(lockPath); | ||
| } | ||
| catch { } | ||
| } | ||
| } | ||
| fs.mkdirSync(path.dirname(lockPath), { recursive: true }); | ||
| fs.writeFileSync(lockPath, pid); | ||
| } | ||
| export function releaseLock(lockPath = LOCK_FILE) { | ||
| try { | ||
| fs.unlinkSync(lockPath); | ||
| } | ||
| catch { } | ||
| } |
+1
-1
| { | ||
| "name": "@lucassantana/sharekit", | ||
| "version": "0.4.0", | ||
| "version": "0.5.0", | ||
| "description": "Share your AI coding setup with anyone — one command to install, one to rollback.", | ||
@@ -5,0 +5,0 @@ "keywords": [ |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
80704
3.79%1723
4.81%3
-25%10
25%