@lucassantana/sharekit
Advanced tools
| import { Dirs } from './paths.js'; | ||
| export interface BackupInfo { | ||
| stamp: string; | ||
| fileCount: number; | ||
| } | ||
| export interface RestoreMetadata { | ||
| filesRestored: number; | ||
| filesRemoved: number; | ||
| sourceVersion?: string; | ||
| sourceCommit?: string | null; | ||
| } | ||
| export type Status = 'new' | 'changed' | 'same'; | ||
| export declare function readMetadata(backupDir: string): { | ||
| sourceVersion?: string; | ||
| sourceCommit?: string | null; | ||
| }; | ||
| export declare function writeMetadata(backupDir: string, metadata: { | ||
| sourceVersion?: string; | ||
| sourceCommit?: string | null; | ||
| }): void; | ||
| export declare function pruneBackups(user: string, state?: string): void; | ||
| export declare function listBackups(user: string, state?: string): BackupInfo[]; | ||
| export declare function restoreBackupInternal(user: string, backupDir: string, dirs?: Dirs): RestoreMetadata; | ||
| export declare function restoreBackupToStamp(user: string, stamp: string, dirs?: Dirs): void; | ||
| export declare function restoreBackup(user: string, dirs?: Dirs): RestoreMetadata; |
+151
| import * as fs from 'node:fs'; | ||
| import * as path from 'node:path'; | ||
| import { STATE, cp, DEFAULT_DIRS } from './paths.js'; | ||
| // Helper: read metadata.json from backup directory | ||
| // Returns {sourceVersion?, sourceCommit?} or {} if missing/unparseable | ||
| export function readMetadata(backupDir) { | ||
| const metadataPath = path.join(backupDir, 'metadata.json'); | ||
| if (!fs.existsSync(metadataPath)) { | ||
| return {}; | ||
| } | ||
| try { | ||
| const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); | ||
| return { | ||
| sourceVersion: metadata.sourceVersion, | ||
| sourceCommit: metadata.sourceCommit, | ||
| }; | ||
| } | ||
| catch { | ||
| // If metadata can't be read, return empty object | ||
| return {}; | ||
| } | ||
| } | ||
| // Helper: write metadata.json to backup directory | ||
| // Only writes if metadata has keys | ||
| export function writeMetadata(backupDir, metadata) { | ||
| if (Object.keys(metadata).length === 0) { | ||
| return; | ||
| } | ||
| fs.writeFileSync(path.join(backupDir, 'metadata.json'), JSON.stringify(metadata, null, 2)); | ||
| } | ||
| // Prune backups, keeping only the most recent 5 | ||
| export function pruneBackups(user, state = STATE) { | ||
| const root = path.join(state, 'backups'); | ||
| if (!fs.existsSync(root)) | ||
| return; | ||
| const dirs = fs | ||
| .readdirSync(root) | ||
| .filter((e) => e.startsWith(user + '-')) | ||
| .sort(); | ||
| if (dirs.length > 5) { | ||
| const toRemove = dirs.slice(0, dirs.length - 5); | ||
| for (const dir of toRemove) { | ||
| fs.rmSync(path.join(root, dir), { recursive: true, force: true }); | ||
| } | ||
| } | ||
| } | ||
| export function listBackups(user, state = STATE) { | ||
| const root = path.join(state, 'backups'); | ||
| if (!fs.existsSync(root)) | ||
| return []; | ||
| const dirs = fs | ||
| .readdirSync(root) | ||
| .filter((e) => e.startsWith(user + '-')) | ||
| .sort(); | ||
| return dirs | ||
| .reverse() // reverse to get newest first | ||
| .map((dir) => { | ||
| const stamp = dir.slice(user.length + 1); | ||
| const appliedPath = path.join(root, dir, 'applied.json'); | ||
| let fileCount = 0; | ||
| try { | ||
| const applied = JSON.parse(fs.readFileSync(appliedPath, 'utf8')); | ||
| fileCount = applied.length; | ||
| } | ||
| catch { | ||
| // If we can't read applied.json, default to 0 | ||
| } | ||
| return { stamp, fileCount }; | ||
| }); | ||
| } | ||
| export function restoreBackupInternal(user, backupDir, dirs = DEFAULT_DIRS) { | ||
| // Read and parse applied.json with safe error handling | ||
| let applied; | ||
| const appliedPath = path.join(backupDir, 'applied.json'); | ||
| 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 }; | ||
| }); | ||
| } | ||
| catch (error) { | ||
| const msg = error instanceof Error ? error.message : String(error); | ||
| throw new Error(`Backup data is corrupt or unreadable: ${msg}`); | ||
| } | ||
| let filesRestored = 0; | ||
| let filesRemoved = 0; | ||
| // Resolve home directory once for bounds checking | ||
| const resolvedHome = path.resolve(dirs.home); | ||
| for (const a of applied) { | ||
| // Bounds-check: ensure dest is within dirs.home | ||
| const resolvedDest = path.resolve(a.dest); | ||
| if (!resolvedDest.startsWith(resolvedHome + path.sep)) { | ||
| console.warn(`Skipping out-of-bounds entry: ${a.dest}`); | ||
| continue; | ||
| } | ||
| if (a.status === 'new') { | ||
| fs.rmSync(resolvedDest, { force: true }); // ponytail: leaves empty parent dirs; harmless | ||
| filesRemoved++; | ||
| } | ||
| else { | ||
| const src = path.join(backupDir, path.relative(resolvedHome, resolvedDest)); | ||
| if (fs.existsSync(src)) { | ||
| fs.mkdirSync(path.dirname(resolvedDest), { recursive: true }); // dest dir may have been removed since install | ||
| cp(src, resolvedDest); | ||
| filesRestored++; | ||
| } | ||
| } | ||
| } | ||
| // Load metadata (source version/commit) if available | ||
| const sourceBackupMetadata = readMetadata(backupDir); | ||
| const sourceVersion = sourceBackupMetadata.sourceVersion; | ||
| const sourceCommit = sourceBackupMetadata.sourceCommit; | ||
| return { filesRestored, filesRemoved, sourceVersion, sourceCommit }; | ||
| } | ||
| export function restoreBackupToStamp(user, stamp, dirs = DEFAULT_DIRS) { | ||
| const root = path.join(dirs.state, 'backups'); | ||
| const backupDir = path.join(root, `${user}-${stamp}`); | ||
| if (!fs.existsSync(backupDir)) { | ||
| throw new Error(`No backup found for ${user} with stamp ${stamp}.`); | ||
| } | ||
| restoreBackupInternal(user, backupDir, dirs); | ||
| } | ||
| export function restoreBackup(user, dirs = DEFAULT_DIRS) { | ||
| const root = path.join(dirs.state, 'backups'); | ||
| const last = fs.existsSync(root) | ||
| ? fs | ||
| .readdirSync(root) | ||
| .filter((e) => e.startsWith(user + '-')) | ||
| .sort() | ||
| .pop() | ||
| : undefined; | ||
| if (!last) | ||
| throw new Error(`No backup for ${user}.`); | ||
| const dir = path.join(root, last); | ||
| return restoreBackupInternal(user, dir, dirs); | ||
| } |
| import { Dirs } from './paths.js'; | ||
| export interface InstallOpts { | ||
| includeHooks?: boolean; | ||
| yes?: boolean; | ||
| dryRun?: 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): { | ||
| filesWritten: number; | ||
| backupDir: string; | ||
| }; | ||
| export declare function update(user: string, opts?: InstallOpts, dirs?: Dirs): Promise<void>; | ||
| export declare function install(user: string, opts?: InstallOpts): Promise<void>; | ||
| export declare function preview(user: string): Promise<void>; | ||
| export declare function inspect(user: string): Promise<void>; | ||
| export declare function rollback(user: string, opts?: InstallOpts): Promise<void>; | ||
| export declare function uninstall(user: string, dirs?: Dirs, force?: boolean): Promise<void>; | ||
| export declare function scan(dir?: string, force?: boolean): Promise<void>; | ||
| export declare function init(profileDir: string, skillNames?: string[], sourceRoot?: string, force?: boolean): void; |
+517
| import * as fs from 'node:fs'; | ||
| import * as path from 'node:path'; | ||
| import * as os from 'node:os'; | ||
| import * as readline from 'node:readline/promises'; | ||
| import { stdin as input, stdout as output } from 'node:process'; | ||
| import kleur from 'kleur'; | ||
| import { parseUserRef, fetchProfile, readManifest } from './fetch.js'; | ||
| import { plan, printPlan, isExecutable, applyProfile } from './plan.js'; | ||
| import { restoreBackup } from './backup.js'; | ||
| import { recordInstall, readInstalled, isImmutableRef } from './state.js'; | ||
| import { scanForSecrets, printAndGateFindings } from './scanner.js'; | ||
| import { walk, tildify, DEFAULT_DIRS, cp } from './paths.js'; | ||
| export async function confirm(q, autoYes = false) { | ||
| if (autoYes) | ||
| return true; | ||
| const rl = readline.createInterface({ input, output }); | ||
| try { | ||
| const a = await rl.question(kleur.bold(` ${q} (y/N) `)); | ||
| return a.trim().toLowerCase() === 'y'; | ||
| } | ||
| finally { | ||
| rl.close(); // always release the interface, even if question() rejects (EOF/piped stdin) | ||
| } | ||
| } | ||
| // Discover published profiles: GitHub IS the registry — search for repos named "sharekit-profile". | ||
| export async function search(query) { | ||
| const q = encodeURIComponent(`sharekit-profile in:name${query ? ` ${query}` : ''}`); | ||
| const url = `https://api.github.com/search/repositories?q=${q}&sort=stars&per_page=30`; | ||
| let data; | ||
| try { | ||
| const res = await fetch(url, { | ||
| headers: { 'User-Agent': 'sharekit-cli', Accept: 'application/vnd.github+json' }, | ||
| signal: AbortSignal.timeout(15_000), // don't hang forever on a stalled GitHub response | ||
| }); | ||
| if (!res.ok) | ||
| throw new Error(`GitHub API ${res.status}`); | ||
| data = (await res.json()); | ||
| } | ||
| catch (e) { | ||
| throw new Error(`search failed: ${e.message}`); | ||
| } | ||
| const profiles = (data.items ?? []).filter((r) => r.name === 'sharekit-profile'); | ||
| if (!profiles.length) { | ||
| console.log(kleur.dim(`\n No profiles found${query ? ` for "${query}"` : ''}. Publish yours: a repo named "sharekit-profile".\n`)); | ||
| return; | ||
| } | ||
| console.log(kleur.bold(`\n ${profiles.length} profile(s)${query ? ` matching "${query}"` : ''} (showing up to 30):\n`)); | ||
| for (const r of profiles) { | ||
| const owner = r.owner?.login ?? '?'; | ||
| const stars = r.stargazers_count || 0; | ||
| console.log(` ${kleur.cyan(owner)}${stars ? kleur.dim(` ★${stars}`) : ''}`); | ||
| if (r.description) | ||
| console.log(kleur.dim(` ${r.description}`)); | ||
| console.log(kleur.dim(` → sharekit install ${owner}`)); | ||
| } | ||
| console.log(); | ||
| } | ||
| // Core update logic without interactive prompts (for testing and direct apply) | ||
| export function updateApply(user, includeHooks = false, dirs = DEFAULT_DIRS) { | ||
| // 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 { filesWritten: 0, backupDir: '' }; | ||
| } | ||
| // 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 dir = fetchProfile(user, ref, 'https://github.com', cacheRoot); | ||
| const manifest = readManifest(dir); | ||
| // 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(dir, roots); | ||
| printPlan(files, manifest); | ||
| const todo = files.filter((f) => f.status !== 'same' && !isExecutable(f, includeHooks)); | ||
| if (!todo.length) { | ||
| console.log(kleur.dim('\n Already up to date.\n')); | ||
| return { filesWritten: 0, backupDir: '' }; | ||
| } | ||
| const { backupDir, filesWritten } = applyProfile(files, user, includeHooks, dirs); | ||
| // Update the install record with the new commit and timestamp | ||
| recordInstall(user, dir, ref, manifest.version, dirs); | ||
| return { filesWritten, backupDir }; | ||
| } | ||
| // Update an installed profile to the latest version (with interactive prompts) | ||
| 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))) { | ||
| 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); | ||
| 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))) { | ||
| 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(); | ||
| } | ||
| export async function preview(user) { | ||
| const { user: userName, ref: userRef } = parseUserRef(user); | ||
| const dir = fetchProfile(userName, userRef); | ||
| console.log(); | ||
| printPlan(plan(dir), readManifest(dir)); | ||
| console.log(); | ||
| } | ||
| export async function inspect(user) { | ||
| const { user: userName, ref: userRef } = parseUserRef(user); | ||
| const dir = fetchProfile(userName, userRef); | ||
| const manifest = readManifest(dir); | ||
| const files = plan(dir); | ||
| console.log(); | ||
| console.log(kleur.bold(`Profile: ${manifest.name}${manifest.version ? ' v' + manifest.version : ''}`)); | ||
| if (manifest.description) | ||
| console.log(kleur.dim(' ' + manifest.description)); | ||
| if (files.length === 0) { | ||
| console.log(kleur.dim('\n (empty profile)\n')); | ||
| return; | ||
| } | ||
| // Group files by tool | ||
| const byTool = {}; | ||
| for (const f of files) { | ||
| if (!byTool[f.tool]) | ||
| byTool[f.tool] = []; | ||
| byTool[f.tool].push(f.rel); | ||
| } | ||
| // Print file tree grouped by tool | ||
| for (const tool of Object.keys(byTool).sort()) { | ||
| console.log(kleur.cyan(`\n ${tool}/`)); | ||
| for (const rel of byTool[tool].sort()) { | ||
| console.log(` ${rel}`); | ||
| } | ||
| } | ||
| console.log(); | ||
| } | ||
| export async function rollback(user, opts) { | ||
| const HOME = os.homedir(); | ||
| const STATE = path.join(HOME, '.sharekit'); | ||
| const yes = opts?.yes ?? false; | ||
| const dryRun = opts?.dryRun ?? false; | ||
| const root = path.join(STATE, 'backups'); | ||
| const last = fs.existsSync(root) | ||
| ? fs | ||
| .readdirSync(root) | ||
| .filter((e) => e.startsWith(user + '-')) | ||
| .sort() | ||
| .pop() | ||
| : undefined; | ||
| if (!last) | ||
| return void console.log(kleur.yellow(`No backup for ${user}.`)); | ||
| const dir = path.join(root, last); | ||
| // Read and parse applied.json with safe error handling | ||
| let applied; | ||
| const appliedPath = path.join(dir, 'applied.json'); | ||
| 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 }; | ||
| }); | ||
| } | ||
| catch (error) { | ||
| const msg = error instanceof Error ? error.message : String(error); | ||
| throw new Error(`Backup data is corrupt or unreadable: ${msg}`); | ||
| } | ||
| let versionStr = ''; | ||
| const metadataPath = path.join(dir, 'metadata.json'); | ||
| if (fs.existsSync(metadataPath)) { | ||
| try { | ||
| const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); | ||
| if (metadata.sourceVersion) | ||
| versionStr = ` (v${metadata.sourceVersion})`; | ||
| } | ||
| catch { | ||
| // If metadata can't be read, just continue without version info | ||
| } | ||
| } | ||
| console.log(kleur.bold(`\n Rollback ${user}${versionStr} (${applied.length} file(s))\n`)); | ||
| if (!(await confirm('Restore?', yes))) | ||
| return void console.log(kleur.dim('\n Aborted.\n')); | ||
| if (dryRun) { | ||
| console.log(kleur.cyan(`\n (dry-run — no files restored)`)); | ||
| console.log(kleur.green(`\n ✓ Would restore ${applied.length} file(s).`)); | ||
| console.log(); | ||
| return; | ||
| } | ||
| const metadata = restoreBackup(user); | ||
| const summary = `${metadata.filesRestored} file(s) restored${metadata.filesRemoved > 0 ? `, ${metadata.filesRemoved} removed` : ''}`; | ||
| // Handle null sourceCommit (offline cache case) | ||
| let versionSuffix = ''; | ||
| if (metadata.sourceCommit === null) { | ||
| versionSuffix = ' — from offline cache, exact version unknown'; | ||
| } | ||
| else if (metadata.sourceVersion) { | ||
| versionSuffix = ` (reverted to v${metadata.sourceVersion})`; | ||
| } | ||
| console.log(kleur.green(`\n ✓ ${summary}${versionSuffix}`)); | ||
| console.log(); | ||
| } | ||
| export async function uninstall(user, dirs = DEFAULT_DIRS, force = false) { | ||
| const installed = readInstalled(dirs); | ||
| const record = installed[user]; | ||
| if (!record) { | ||
| throw new Error(`${user} is not installed.`); | ||
| } | ||
| // Find the latest backup for this user | ||
| const root = path.join(dirs.state, 'backups'); | ||
| const last = fs.existsSync(root) | ||
| ? fs | ||
| .readdirSync(root) | ||
| .filter((e) => e.startsWith(user + '-')) | ||
| .sort() | ||
| .pop() | ||
| : undefined; | ||
| if (!last) { | ||
| throw new Error(`No backup found for ${user}. Cannot uninstall without restore information.`); | ||
| } | ||
| const backupDir = path.join(root, last); | ||
| // Read and parse applied.json with safe error handling | ||
| let applied; | ||
| const appliedPath = path.join(backupDir, 'applied.json'); | ||
| 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 }; | ||
| }); | ||
| } | ||
| catch (error) { | ||
| const msg = error instanceof Error ? error.message : String(error); | ||
| throw new Error(`Backup data is corrupt or unreadable: ${msg}`); | ||
| } | ||
| // Print what will be removed/restored | ||
| const toRemove = applied.filter((a) => a.status === 'new'); | ||
| const toRestore = applied.filter((a) => a.status === 'changed'); | ||
| console.log(); | ||
| console.log(kleur.bold(` Uninstall ${user}${record.version ? ` (v${record.version})` : ''}\n`)); | ||
| if (toRemove.length > 0) { | ||
| console.log(kleur.red(` - remove (${toRemove.length})`)); | ||
| for (const a of toRemove) { | ||
| console.log(kleur.red(` ${tildify(a.dest)}`)); | ||
| } | ||
| } | ||
| if (toRestore.length > 0) { | ||
| console.log(kleur.yellow(`\n ~ restore (${toRestore.length})`)); | ||
| for (const a of toRestore) { | ||
| console.log(kleur.yellow(` ${tildify(a.dest)}`)); | ||
| } | ||
| } | ||
| console.log(); | ||
| if (!force && !(await confirm(`Remove ${user}?`))) { | ||
| return void console.log(kleur.dim('\n Aborted.\n')); | ||
| } | ||
| // Resolve home directory once for bounds checking | ||
| const resolvedHome = path.resolve(dirs.home); | ||
| // Execute the uninstall: reverse all changes | ||
| for (const a of applied) { | ||
| // Bounds-check: ensure dest is within dirs.home | ||
| const resolvedDest = path.resolve(a.dest); | ||
| if (!resolvedDest.startsWith(resolvedHome + path.sep)) { | ||
| console.warn(`Skipping out-of-bounds entry: ${a.dest}`); | ||
| continue; | ||
| } | ||
| if (a.status === 'new') { | ||
| // File was added by the profile — remove it | ||
| fs.rmSync(resolvedDest, { force: true }); | ||
| } | ||
| else if (a.status === 'changed') { | ||
| // File was changed — restore from backup | ||
| const src = path.join(backupDir, path.relative(resolvedHome, resolvedDest)); | ||
| if (fs.existsSync(src)) { | ||
| fs.mkdirSync(path.dirname(resolvedDest), { recursive: true }); | ||
| cp(src, resolvedDest); | ||
| } | ||
| } | ||
| } | ||
| // Remove user from installed.json | ||
| delete installed[user]; | ||
| const stateFile = path.join(dirs.state, 'installed.json'); | ||
| fs.writeFileSync(stateFile, JSON.stringify(installed, null, 2)); | ||
| const summary = `${toRemove.length} file(s) removed${toRestore.length > 0 ? `, ${toRestore.length} restored` : ''}`; | ||
| console.log(kleur.green(`\n ✓ Uninstalled ${user}. ${summary}`)); | ||
| console.log(); | ||
| } | ||
| export async function scan(dir, force = false) { | ||
| const profileDir = dir ?? './sharekit-profile'; | ||
| // Check if profile directory exists | ||
| if (!fs.existsSync(profileDir)) { | ||
| throw new Error(`No profile at ${profileDir} — run 'sharekit init' first to create a profile directory.`); | ||
| } | ||
| console.log(); | ||
| // Walk the directory and scan all files for secrets | ||
| const allFindings = []; | ||
| const files = walk(profileDir); | ||
| for (const file of files) { | ||
| let content; | ||
| const relPath = path.relative(profileDir, file); | ||
| try { | ||
| content = fs.readFileSync(file, 'utf8'); | ||
| } | ||
| catch (e) { | ||
| const code = e.code ?? 'UNKNOWN'; | ||
| console.log(kleur.yellow(` ~ Skipped ${relPath}: ${code}`)); | ||
| continue; | ||
| } | ||
| const findings = scanForSecrets(content, relPath); | ||
| allFindings.push(...findings); | ||
| } | ||
| // Print findings and apply gate logic (shared with init) | ||
| printAndGateFindings(allFindings, force); | ||
| } | ||
| export function init(profileDir, skillNames = [], sourceRoot = os.homedir(), force = false) { | ||
| // Check if profileDir already exists | ||
| if (fs.existsSync(profileDir)) { | ||
| if (!force) { | ||
| throw new Error(`Profile directory already exists: ${profileDir}. Use --force to overwrite.`); | ||
| } | ||
| console.warn(kleur.yellow(` Overwriting existing ${path.basename(profileDir)}/ ...`)); | ||
| fs.rmSync(profileDir, { recursive: true, force: true }); | ||
| } | ||
| const username = os.userInfo().username; | ||
| const profileRoot = path.join(profileDir); | ||
| fs.mkdirSync(profileRoot, { recursive: true }); | ||
| const allFindings = []; | ||
| // 1. Create sharekit.toml | ||
| const tomlContent = `[profile] | ||
| name = "${username}" | ||
| version = "0.1.0" | ||
| description = "My AI coding setup" | ||
| `; | ||
| fs.writeFileSync(path.join(profileRoot, 'sharekit.toml'), tomlContent); | ||
| console.log(kleur.green(` + ${tildify(path.join(profileRoot, 'sharekit.toml'))}`)); | ||
| // 2. Copy CLAUDE.md from source root if it exists | ||
| const sourceClaude = path.join(sourceRoot, '.claude', 'CLAUDE.md'); | ||
| const destClaude = path.join(profileRoot, 'claude', 'CLAUDE.md'); | ||
| fs.mkdirSync(path.dirname(destClaude), { recursive: true }); | ||
| if (fs.existsSync(sourceClaude)) { | ||
| cp(sourceClaude, destClaude); | ||
| console.log(kleur.green(` + ${tildify(destClaude)}`)); | ||
| const content = fs.readFileSync(destClaude, 'utf8'); | ||
| const findings = scanForSecrets(content, tildify(destClaude)); | ||
| allFindings.push(...findings); | ||
| } | ||
| else { | ||
| fs.writeFileSync(destClaude, '# My AI coding instructions\n'); | ||
| console.log(kleur.green(` + ${tildify(destClaude)} (placeholder)`)); | ||
| } | ||
| // 3. Scaffold cursor/ directory | ||
| const destCursorRules = path.join(profileRoot, 'cursor', '.cursorrules'); | ||
| fs.mkdirSync(path.dirname(destCursorRules), { recursive: true }); | ||
| const sourceCursorRules = path.join(sourceRoot, '.cursor', '.cursorrules'); | ||
| if (fs.existsSync(sourceCursorRules)) { | ||
| cp(sourceCursorRules, destCursorRules); | ||
| console.log(kleur.green(` + ${tildify(destCursorRules)}`)); | ||
| const content = fs.readFileSync(destCursorRules, 'utf8'); | ||
| const findings = scanForSecrets(content, tildify(destCursorRules)); | ||
| allFindings.push(...findings); | ||
| } | ||
| else { | ||
| fs.writeFileSync(destCursorRules, '# Cursor IDE rules\n'); | ||
| console.log(kleur.green(` + ${tildify(destCursorRules)} (placeholder)`)); | ||
| } | ||
| // 4. Scaffold shared/ directory with .gitkeep | ||
| const destShared = path.join(profileRoot, 'shared'); | ||
| fs.mkdirSync(destShared, { recursive: true }); | ||
| fs.writeFileSync(path.join(destShared, '.gitkeep'), ''); | ||
| console.log(kleur.green(` + ${tildify(destShared)}/`)); | ||
| // 5. Copy skills if specified | ||
| let skillCount = 0; | ||
| for (const skillName of skillNames) { | ||
| const sourceSkill = path.join(sourceRoot, '.claude', 'skills', skillName); | ||
| if (!fs.existsSync(sourceSkill)) { | ||
| console.log(kleur.yellow(` ~ skill '${skillName}' not found at ${tildify(sourceSkill)}`)); | ||
| continue; | ||
| } | ||
| const destSkillBase = path.join(profileRoot, 'claude', 'skills', skillName); | ||
| fs.mkdirSync(destSkillBase, { recursive: true }); | ||
| for (const file of walk(sourceSkill)) { | ||
| const rel = path.relative(sourceSkill, file); | ||
| const dest = path.join(destSkillBase, rel); | ||
| fs.mkdirSync(path.dirname(dest), { recursive: true }); | ||
| cp(file, dest); | ||
| console.log(kleur.green(` + ${tildify(dest)}`)); | ||
| const content = fs.readFileSync(dest, 'utf8'); | ||
| const findings = scanForSecrets(content, tildify(dest)); | ||
| allFindings.push(...findings); | ||
| skillCount++; | ||
| } | ||
| } | ||
| console.log(kleur.green(`\n ✓ Created profile at ${tildify(profileRoot)}` + | ||
| ` (sharekit.toml, CLAUDE.md, cursor/, shared/${skillCount > 0 ? `, ${skillCount} skill file(s)` : ''})`)); | ||
| // Print findings and apply gate logic (shared with scan) | ||
| printAndGateFindings(allFindings, force); | ||
| } |
| export declare function parseUserRef(user: string): { | ||
| user: string; | ||
| ref?: string; | ||
| }; | ||
| export declare function fetchProfile(user: string, ref?: string, baseUrl?: string, cacheRoot?: string): string; | ||
| export declare function readManifest(profileDir: string): { | ||
| name: string; | ||
| version?: string; | ||
| description?: string; | ||
| }; |
+120
| import * as fs from 'node:fs'; | ||
| import * as path from 'node:path'; | ||
| import { execFileSync } from 'node:child_process'; | ||
| import { parse as parseToml } from 'smol-toml'; | ||
| import { STATE, MAX_MANIFEST_BYTES } from './paths.js'; | ||
| export function parseUserRef(user) { | ||
| if (!user || user.trim() === '') { | ||
| throw new Error('Invalid user reference: username cannot be empty'); | ||
| } | ||
| if (!user.includes('@')) { | ||
| // No @: simple username, validate it | ||
| if (user.includes('..')) { | ||
| throw new Error(`Invalid user '${user}': username cannot contain '..'`); | ||
| } | ||
| if (user.includes('/')) { | ||
| throw new Error(`Invalid user '${user}': username cannot contain path separators`); | ||
| } | ||
| return { user, ref: undefined }; | ||
| } | ||
| // Contains @: split and validate both parts | ||
| const atIndex = user.lastIndexOf('@'); | ||
| const userName = user.slice(0, atIndex); | ||
| const ref = user.slice(atIndex + 1); | ||
| // Validate userName | ||
| if (!userName || userName.trim() === '') { | ||
| throw new Error(`Invalid user reference '${user}': missing GitHub username before '@'`); | ||
| } | ||
| if (userName.includes('..')) { | ||
| throw new Error(`Invalid user '${userName}': username cannot contain '..'`); | ||
| } | ||
| if (userName.includes('/')) { | ||
| throw new Error(`Invalid user '${userName}': username cannot contain path separators`); | ||
| } | ||
| // Validate ref | ||
| if (!ref || ref.trim() === '') { | ||
| throw new Error(`Invalid user reference '${user}': ref cannot be empty after '@'`); | ||
| } | ||
| if (ref.includes('..')) { | ||
| throw new Error(`Invalid ref '${ref}': ref cannot contain '..'`); | ||
| } | ||
| return { user: userName, ref }; | ||
| } | ||
| // ponytail: profile lives at github.com/<user>/sharekit-profile — one convention, not a search | ||
| export function fetchProfile(user, ref, baseUrl = 'https://github.com', cacheRoot = path.join(STATE, 'profiles')) { | ||
| // Cache key: <user> for HEAD, <user>@<ref> for a pinned ref | ||
| const cacheKey = ref ? `${user}@${ref}` : user; | ||
| const dir = path.join(cacheRoot, cacheKey); | ||
| // Defense-in-depth: verify cache path doesn't escape cacheRoot | ||
| const resolvedCache = path.resolve(cacheRoot); | ||
| const resolvedDir = path.resolve(dir); | ||
| if (!resolvedDir.startsWith(resolvedCache + path.sep)) { | ||
| throw new Error(`Invalid profile path: cache escape detected for '${cacheKey}'`); | ||
| } | ||
| if (fs.existsSync(dir)) { | ||
| // Attempt a best-effort pull to refresh the cached checkout. | ||
| // For mutable refs (branches), this pulls the latest. For immutable refs (tags/SHAs), | ||
| // the pull harmlessly fails and we fall back to the cached copy. | ||
| try { | ||
| execFileSync('git', ['-C', dir, 'pull', '--ff-only'], { stdio: 'pipe', timeout: 30_000 }); | ||
| } | ||
| catch { | ||
| // ponytail: refresh is best-effort — offline / no-remote / detached HEAD falls back to the cached copy | ||
| } | ||
| return dir; | ||
| } | ||
| fs.mkdirSync(path.dirname(dir), { recursive: true }); | ||
| const url = `${baseUrl}/${user}/sharekit-profile`; | ||
| try { | ||
| if (ref) { | ||
| execFileSync('git', ['clone', '--depth', '1', '--branch', ref, '--', url, dir], { | ||
| stdio: 'pipe', | ||
| timeout: 30_000, | ||
| }); | ||
| } | ||
| else { | ||
| execFileSync('git', ['clone', '--depth', '1', '--', url, dir], { | ||
| stdio: 'pipe', | ||
| timeout: 30_000, | ||
| }); | ||
| } | ||
| } | ||
| catch (e) { | ||
| if (e.code === 'ENOENT') { | ||
| throw new Error('git not found — install git to use sharekit (https://git-scm.com)'); | ||
| } | ||
| if (e.killed) { | ||
| throw new Error(`Timed out fetching ${user}'s profile (network too slow or repo too large)`); | ||
| } | ||
| const errOut = (e.stderr?.toString() ?? '') + (e.message ?? ''); | ||
| if (ref && errOut.includes('not found')) { | ||
| throw new Error(`ref '${ref}' not found in ${user}'s profile`); | ||
| } | ||
| throw new Error(`No profile at ${url}\n` + | ||
| ` Publish yours: a repo named "sharekit-profile" with a sharekit.toml`); | ||
| } | ||
| return dir; | ||
| } | ||
| export function readManifest(profileDir) { | ||
| const p = path.join(profileDir, 'sharekit.toml'); | ||
| if (!fs.existsSync(p)) | ||
| throw new Error(`Not a sharekit profile (no sharekit.toml in ${profileDir})`); | ||
| // Defence-in-depth: reject oversized manifests before parsing to prevent DoS | ||
| const stat = fs.statSync(p); | ||
| if (stat.size > MAX_MANIFEST_BYTES) { | ||
| throw new Error(`Invalid sharekit.toml: manifest too large (${(stat.size / 1024).toFixed(1)} KB, max ${(MAX_MANIFEST_BYTES / 1024).toFixed(0)} KB)`); | ||
| } | ||
| let parsed; | ||
| try { | ||
| parsed = parseToml(fs.readFileSync(p, 'utf8')); | ||
| } | ||
| catch (e) { | ||
| throw new Error(`Invalid sharekit.toml: ${e.message}`); | ||
| } | ||
| const profile = (parsed.profile ?? {}); | ||
| return { | ||
| name: profile.name ?? 'unknown', | ||
| version: profile.version, | ||
| description: profile.description, | ||
| }; | ||
| } |
| export declare const HOME: string; | ||
| export declare const STATE: string; | ||
| export declare const MAX_MANIFEST_BYTES: number; | ||
| export declare const ROOTS: Record<string, string>; | ||
| export type Dirs = { | ||
| home: string; | ||
| state: string; | ||
| }; | ||
| export declare const DEFAULT_DIRS: Dirs; | ||
| export declare const tildify: (p: string) => string; | ||
| export declare function cp(src: string, dest: string): void; | ||
| export interface WalkResult { | ||
| files: string[]; | ||
| skippedSymlinks: string[]; | ||
| } | ||
| export declare function walkWithSymlinks(dir: string): WalkResult; | ||
| export declare function walk(dir: string): string[]; |
| import * as fs from 'node:fs'; | ||
| import * as path from 'node:path'; | ||
| import * as os from 'node:os'; | ||
| export const HOME = os.homedir(); | ||
| export const STATE = path.join(HOME, '.sharekit'); | ||
| // Defence-in-depth: size cap for sharekit.toml to prevent DoS on pathological input. | ||
| // Typical manifests are a few KB; 512 KB is a generous cap that catches obvious abuses. | ||
| export const MAX_MANIFEST_BYTES = 512 * 1024; // 512 KB | ||
| // profile/<tool>/** mirrors into these roots — one rule, not a filename allowlist | ||
| export const ROOTS = { | ||
| claude: path.join(HOME, '.claude'), | ||
| cursor: path.join(HOME, '.cursor'), | ||
| shared: HOME, | ||
| }; | ||
| 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('/'); | ||
| }; | ||
| // copy a file, preserving its mode (e.g. a skill's executable toggle.sh) | ||
| export function cp(src, dest) { | ||
| fs.copyFileSync(src, dest); | ||
| fs.chmodSync(dest, fs.statSync(src).mode); | ||
| } | ||
| export function walkWithSymlinks(dir) { | ||
| const files = []; | ||
| const skippedSymlinks = []; | ||
| const traverse = (currentDir) => { | ||
| for (const e of fs.readdirSync(currentDir, { withFileTypes: true })) { | ||
| const p = path.join(currentDir, e.name); | ||
| if (e.isSymbolicLink()) { | ||
| skippedSymlinks.push(p); | ||
| } | ||
| else if (e.isDirectory()) { | ||
| traverse(p); | ||
| } | ||
| else { | ||
| files.push(p); | ||
| } | ||
| } | ||
| }; | ||
| traverse(dir); | ||
| return { files, skippedSymlinks }; | ||
| } | ||
| export function walk(dir) { | ||
| return walkWithSymlinks(dir).files; | ||
| } |
| import { Dirs } from './paths.js'; | ||
| import { readManifest } from './fetch.js'; | ||
| export type Status = 'new' | 'changed' | 'same'; | ||
| export interface PlanFile { | ||
| tool: string; | ||
| src: string; | ||
| dest: string; | ||
| rel: string; | ||
| status: Status; | ||
| } | ||
| export declare function plan(profileDir: string, roots?: Record<string, string>): PlanFile[]; | ||
| export declare const isExecutable: (f: PlanFile, includeHooks?: boolean) => boolean; | ||
| export declare function printPlan(files: PlanFile[], manifest: ReturnType<typeof readManifest>): void; | ||
| export declare function writeAtomic(files: PlanFile[], backupDir: string, user: string, includeHooks?: boolean, dirs?: Dirs): number; | ||
| export declare function backup(files: PlanFile[], user: string, includeHooks?: boolean, dirs?: Dirs): string; | ||
| export declare function applyProfile(files: PlanFile[], user: string, includeHooks?: boolean, dirs?: Dirs, dryRun?: boolean): { | ||
| backupDir: string; | ||
| filesWritten: number; | ||
| }; |
+136
| import * as fs from 'node:fs'; | ||
| import * as path from 'node:path'; | ||
| import kleur from 'kleur'; | ||
| import { ROOTS, tildify, cp, walkWithSymlinks, DEFAULT_DIRS } from './paths.js'; | ||
| import { restoreBackupInternal, pruneBackups, writeMetadata } from './backup.js'; | ||
| import { readInstalled } from './state.js'; | ||
| // Track skipped symlinks for the current plan | ||
| let currentPlanSkippedSymlinks = []; | ||
| export function plan(profileDir, roots = ROOTS) { | ||
| const files = []; | ||
| currentPlanSkippedSymlinks = []; // Reset for this plan | ||
| for (const [tool, root] of Object.entries(roots)) { | ||
| const base = path.join(profileDir, tool); | ||
| if (!fs.existsSync(base)) | ||
| continue; | ||
| const walkResult = walkWithSymlinks(base); | ||
| for (const src of walkResult.files) { | ||
| const rel = path.relative(base, src); | ||
| const dest = path.join(root, rel); | ||
| files.push({ tool, src, dest, rel, status: classify(src, dest) }); | ||
| } | ||
| // Collect skipped symlinks for this tool | ||
| for (const symlink of walkResult.skippedSymlinks) { | ||
| const rel = path.relative(base, symlink); | ||
| currentPlanSkippedSymlinks.push(path.join(root, rel)); | ||
| } | ||
| } | ||
| return files; | ||
| } | ||
| function classify(src, dest) { | ||
| if (!fs.existsSync(dest)) | ||
| return 'new'; | ||
| try { | ||
| const srcBuf = fs.readFileSync(src); | ||
| const destBuf = fs.readFileSync(dest); | ||
| return srcBuf.equals(destBuf) ? 'same' : 'changed'; | ||
| } | ||
| catch { | ||
| // If either file is unreadable (e.g., permission denied), treat as 'changed' to be conservative. | ||
| // This prevents a file unreadable mid-operation from crashing the plan. | ||
| return 'changed'; | ||
| } | ||
| } | ||
| // ponytail: settings.json carries hooks (arbitrary shell). v1 never auto-installs it. | ||
| // add `--include-hooks` when someone actually asks. | ||
| export const isExecutable = (f, includeHooks = false) => !includeHooks && f.tool === 'claude' && path.basename(f.dest) === 'settings.json'; | ||
| export function printPlan(files, manifest) { | ||
| console.log(kleur.bold(`Profile: ${manifest.name}${manifest.version ? ' v' + manifest.version : ''}`)); | ||
| if (manifest.description) | ||
| console.log(kleur.dim(' ' + manifest.description)); | ||
| const show = (s, label, c) => { | ||
| const g = files.filter((f) => f.status === s); | ||
| if (!g.length) | ||
| return; | ||
| console.log(c(`\n ${label} (${g.length})`)); | ||
| for (const f of g) | ||
| console.log(c(` ${tildify(f.dest)}`)); | ||
| }; | ||
| show('new', '+ new', kleur.green); | ||
| show('changed', '~ changed', kleur.yellow); | ||
| const same = files.filter((f) => f.status === 'same').length; | ||
| if (same) | ||
| console.log(kleur.dim(`\n = ${same} unchanged`)); | ||
| if (files.some((f) => isExecutable(f))) | ||
| console.log(kleur.yellow(`\n ⚠ settings.json present — contains hooks; skipped. Merge manually.`)); | ||
| } | ||
| function write(files, includeHooks = false) { | ||
| let n = 0; | ||
| for (const f of files) { | ||
| if (f.status === 'same' || isExecutable(f, includeHooks)) | ||
| continue; | ||
| fs.mkdirSync(path.dirname(f.dest), { recursive: true }); | ||
| cp(f.src, f.dest); | ||
| n++; | ||
| } | ||
| return n; | ||
| } | ||
| export function writeAtomic(files, backupDir, user, includeHooks = false, dirs = DEFAULT_DIRS) { | ||
| let n = 0; | ||
| const applied = files.filter((f) => f.status !== 'same' && !isExecutable(f, includeHooks)); | ||
| try { | ||
| for (const f of applied) { | ||
| fs.mkdirSync(path.dirname(f.dest), { recursive: true }); | ||
| cp(f.src, f.dest); | ||
| n++; | ||
| } | ||
| return n; | ||
| } | ||
| catch (e) { | ||
| // Write failed mid-way: restore from backup and rethrow | ||
| try { | ||
| restoreBackupInternal(user, backupDir, dirs); | ||
| } | ||
| catch (restoreErr) { | ||
| // Log the restore failure but don't mask the original error | ||
| console.error(`Failed to restore from backup after write error: ${restoreErr.message}`); | ||
| } | ||
| throw e; | ||
| } | ||
| } | ||
| export function backup(files, user, includeHooks = false, dirs = DEFAULT_DIRS) { | ||
| const stamp = new Date().toISOString().replace(/[:.]/g, '-'); | ||
| const dir = path.join(dirs.state, 'backups', `${user}-${stamp}`); | ||
| const applied = files.filter((f) => f.status !== 'same' && !isExecutable(f, includeHooks)); | ||
| fs.mkdirSync(dir, { recursive: true }); | ||
| for (const f of applied.filter((f) => f.status === 'changed')) { | ||
| const t = path.join(dir, path.relative(dirs.home, f.dest)); | ||
| fs.mkdirSync(path.dirname(t), { recursive: true }); | ||
| cp(f.dest, t); | ||
| } | ||
| // Capture source version/commit from install state | ||
| const installed = readInstalled(dirs); | ||
| const sourceVersion = installed[user]?.version; | ||
| const sourceCommit = installed[user]?.commit; | ||
| fs.writeFileSync(path.join(dir, 'applied.json'), JSON.stringify(applied.map((f) => ({ dest: f.dest, status: f.status })), null, 2)); | ||
| // Write metadata with source version/commit if available | ||
| const metadata = {}; | ||
| if (sourceVersion !== undefined) | ||
| metadata.sourceVersion = sourceVersion; | ||
| if (sourceCommit !== undefined) | ||
| metadata.sourceCommit = sourceCommit; | ||
| writeMetadata(dir, metadata); | ||
| return dir; | ||
| } | ||
| // Exported pure functions for testability | ||
| export function applyProfile(files, user, includeHooks = false, dirs = DEFAULT_DIRS, dryRun = false) { | ||
| if (dryRun) { | ||
| // In dry-run, just count files without writing anything | ||
| const filesWritten = files.filter((f) => f.status !== 'same' && !isExecutable(f, includeHooks)).length; | ||
| return { backupDir: '', filesWritten }; | ||
| } | ||
| const backupDir = backup(files, user, includeHooks, dirs); | ||
| const filesWritten = writeAtomic(files, backupDir, user, includeHooks, dirs); | ||
| pruneBackups(user, dirs.state); | ||
| return { backupDir, filesWritten }; | ||
| } |
| export interface Finding { | ||
| rule: string; | ||
| file?: string; | ||
| line: number; | ||
| preview: string; | ||
| severity: 'high' | 'medium' | 'low'; | ||
| } | ||
| export declare function truncatePreview(line: string, idx: number, contextBefore?: number, maxLen?: number, addLeadingEllipsis?: boolean): string; | ||
| export declare function printAndGateFindings(findings: Finding[], force?: boolean): void; | ||
| export declare function scanForSecrets(content: string, fileLabel?: string): Finding[]; |
+168
| import kleur from 'kleur'; | ||
| // Construct a preview string from a line, given match index and window params. | ||
| // Adds leading/trailing ellipsis when substring is clipped. | ||
| export function truncatePreview(line, idx, contextBefore = 5, maxLen = 40, addLeadingEllipsis = true) { | ||
| const start = Math.max(0, idx - contextBefore); | ||
| const end = Math.min(line.length, idx + maxLen); | ||
| const leading = addLeadingEllipsis && start > 0 ? '…' : ''; | ||
| const trailing = end < line.length ? '…' : ''; | ||
| return leading + line.substring(start, end) + trailing; | ||
| } | ||
| // Shared helper: print findings and apply gate logic (high-severity blocks unless force=true) | ||
| export function printAndGateFindings(findings, force = false) { | ||
| if (findings.length === 0) { | ||
| console.log(kleur.green(' ✓ No secrets detected.\n')); | ||
| return; | ||
| } | ||
| // Print warnings if secrets found | ||
| console.log(kleur.yellow(`\n ⚠ Secret patterns detected:`)); | ||
| for (const finding of findings) { | ||
| console.log(kleur.yellow(` ${finding.file}:${finding.line} [${finding.rule}] ${finding.preview}`)); | ||
| } | ||
| console.log(kleur.yellow(`\n ⚠ Review and redact secrets before pushing to a public repository.\n`)); | ||
| // Gate: block export if high-severity findings and no --force | ||
| const highSeverityFindings = findings.filter((f) => f.severity === 'high'); | ||
| if (highSeverityFindings.length > 0 && !force) { | ||
| throw new Error(`Secrets export blocked: ${highSeverityFindings.length} high-severity finding(s) detected. ` + | ||
| `Review and remove secrets, or re-run with --force to override.`); | ||
| } | ||
| } | ||
| export function scanForSecrets(content, fileLabel) { | ||
| const findings = []; | ||
| const lines = content.split('\n'); | ||
| for (let i = 0; i < lines.length; i++) { | ||
| const line = lines[i]; | ||
| const lineNum = i + 1; | ||
| // Rule 1: Private key blocks (HIGH) | ||
| if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(line)) { | ||
| const preview = line.substring(0, 40) + (line.length > 40 ? '…' : ''); | ||
| findings.push({ | ||
| rule: 'Private Key Block', | ||
| file: fileLabel, | ||
| line: lineNum, | ||
| preview, | ||
| severity: 'high', | ||
| }); | ||
| continue; | ||
| } | ||
| // Rule 2: AWS access key (HIGH) | ||
| const awsMatch = /AKIA[0-9A-Z]{16}/.exec(line); | ||
| if (awsMatch) { | ||
| const preview = truncatePreview(line, awsMatch.index, 5, 20); | ||
| findings.push({ | ||
| rule: 'AWS Access Key ID', | ||
| file: fileLabel, | ||
| line: lineNum, | ||
| preview, | ||
| severity: 'high', | ||
| }); | ||
| continue; | ||
| } | ||
| // Rule 3: GitHub PAT ghp_ format (HIGH) — at least 20 chars after ghp_ | ||
| const githubGhpMatch = /ghp_[A-Za-z0-9_]{20,}/.exec(line); | ||
| if (githubGhpMatch) { | ||
| const preview = truncatePreview(line, githubGhpMatch.index, 5, 40); | ||
| findings.push({ | ||
| rule: 'GitHub Personal Access Token', | ||
| file: fileLabel, | ||
| line: lineNum, | ||
| preview, | ||
| severity: 'high', | ||
| }); | ||
| continue; | ||
| } | ||
| // Rule 4: GitHub PAT github_pat_ format (HIGH) — at least 20 chars after prefix | ||
| const githubPatMatch = /github_pat_[A-Za-z0-9_]{20,}/.exec(line); | ||
| if (githubPatMatch) { | ||
| const preview = truncatePreview(line, githubPatMatch.index, 5, 40); | ||
| findings.push({ | ||
| rule: 'GitHub Personal Access Token', | ||
| file: fileLabel, | ||
| line: lineNum, | ||
| preview, | ||
| severity: 'high', | ||
| }); | ||
| continue; | ||
| } | ||
| // Rule 5: Slack tokens (HIGH) | ||
| const slackMatch = /xox[baprs]-[A-Za-z0-9-]{10,}/.exec(line); | ||
| if (slackMatch) { | ||
| const preview = truncatePreview(line, slackMatch.index, 5, 40); | ||
| findings.push({ | ||
| rule: 'Slack Token', | ||
| file: fileLabel, | ||
| line: lineNum, | ||
| preview, | ||
| severity: 'high', | ||
| }); | ||
| continue; | ||
| } | ||
| // Rule 6: Google API keys AIza format (HIGH) | ||
| const googleMatch = /AIza[0-9A-Za-z\-_]{35}/.exec(line); | ||
| if (googleMatch) { | ||
| const preview = truncatePreview(line, googleMatch.index, 5, 40); | ||
| findings.push({ | ||
| rule: 'Google API Key', | ||
| file: fileLabel, | ||
| line: lineNum, | ||
| preview, | ||
| severity: 'high', | ||
| }); | ||
| continue; | ||
| } | ||
| // Rule 7: Bearer JWT (HIGH) — eyJ prefix identifies base64-encoded JSON header (JWT) | ||
| const bearerMatch = /Bearer eyJ[A-Za-z0-9._\-]{17,}/.exec(line); | ||
| if (bearerMatch) { | ||
| const preview = truncatePreview(line, bearerMatch.index, 0, 30, false); | ||
| findings.push({ | ||
| rule: 'Bearer Token', | ||
| file: fileLabel, | ||
| line: lineNum, | ||
| preview, | ||
| severity: 'high', | ||
| }); | ||
| continue; | ||
| } | ||
| // Rule 8: Home directory path leak (LOW) | ||
| const homeDirMatch = /(\/Users\/[a-zA-Z0-9_-]+|\/home\/[a-zA-Z0-9_-]+)/.exec(line); | ||
| if (homeDirMatch) { | ||
| const preview = truncatePreview(line, homeDirMatch.index, 5, 40); | ||
| findings.push({ | ||
| rule: 'Home Directory Path Leak', | ||
| file: fileLabel, | ||
| line: lineNum, | ||
| preview, | ||
| severity: 'low', | ||
| }); | ||
| continue; | ||
| } | ||
| // Rule 9: Generic KEY=value with sensitive key names, including export prefix (MEDIUM) | ||
| // Match both "KEY=value" and "export KEY=value" | ||
| const envMatch = /(?:^|\s)(?:export\s+)?([A-Z_]+?)=(.*)$/.exec(line); | ||
| if (envMatch) { | ||
| const keyName = envMatch[1].toUpperCase(); | ||
| const value = envMatch[2]; | ||
| // Check if key name contains sensitive keywords | ||
| if (/(SECRET|TOKEN|PASSWORD|API_KEY|APIKEY|ACCESS_KEY)/i.test(keyName)) { | ||
| // Ignore if value is empty or a placeholder | ||
| const placeholders = ['""', "''", 'xxx', '<', 'changeme', 'your-', 'your_']; | ||
| const isPlaceholder = value === '' || | ||
| value === '""' || | ||
| value === "''" || | ||
| placeholders.some((p) => value.startsWith(p)); | ||
| if (!isPlaceholder) { | ||
| const preview = value.substring(0, 8) + (value.length > 8 ? '…' : ''); | ||
| findings.push({ | ||
| rule: 'Env Var: Sensitive Key', | ||
| file: fileLabel, | ||
| line: lineNum, | ||
| preview: `${keyName}=${preview}`, | ||
| severity: 'medium', | ||
| }); | ||
| continue; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return findings; | ||
| } |
| import { Dirs } from './paths.js'; | ||
| export interface InstallRecord { | ||
| user: string; | ||
| ref: string; | ||
| commit: string | null; | ||
| version?: string; | ||
| appliedAt: string; | ||
| } | ||
| export declare function recordInstall(user: string, profileDir: string, ref: string, version: string | undefined, dirs?: Dirs): void; | ||
| export declare function readInstalled(dirs?: Dirs): Record<string, InstallRecord>; | ||
| export declare function list(dirs?: Dirs): void; | ||
| export declare function isImmutableRef(ref: string): boolean; |
+101
| import * as fs from 'node:fs'; | ||
| import * as path from 'node:path'; | ||
| import { execFileSync } from 'node:child_process'; | ||
| import kleur from 'kleur'; | ||
| 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) { | ||
| let commit = null; | ||
| try { | ||
| commit = execFileSync('git', ['-C', profileDir, 'rev-parse', 'HEAD'], { | ||
| stdio: 'pipe', | ||
| encoding: 'utf8', | ||
| timeout: 30_000, | ||
| }).trim(); | ||
| } | ||
| 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 | ||
| const stateFile = path.join(dirs.state, 'installed.json'); | ||
| let installed = {}; | ||
| if (fs.existsSync(stateFile)) { | ||
| try { | ||
| installed = JSON.parse(fs.readFileSync(stateFile, 'utf8')); | ||
| } | ||
| catch { | ||
| // corrupt state file; overwrite with fresh state | ||
| installed = {}; | ||
| } | ||
| } | ||
| // 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)); | ||
| } | ||
| // 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) { | ||
| const stateFile = path.join(dirs.state, 'installed.json'); | ||
| if (!fs.existsSync(stateFile)) | ||
| return {}; | ||
| try { | ||
| return JSON.parse(fs.readFileSync(stateFile, 'utf8')); | ||
| } | ||
| catch (e) { | ||
| 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) { | ||
| const installed = readInstalled(dirs); | ||
| const records = Object.values(installed); | ||
| if (records.length === 0) { | ||
| console.log(kleur.dim('\n Nothing installed yet.\n')); | ||
| return; | ||
| } | ||
| console.log(kleur.bold(`\n Installed profiles:\n`)); | ||
| for (const record of records) { | ||
| const shortSha = record.commit ? record.commit.slice(0, 7) : '?'; | ||
| // Guard against invalid appliedAt timestamps | ||
| let dateStr = '(unknown)'; | ||
| if (record.appliedAt) { | ||
| const date = new Date(record.appliedAt); | ||
| if (!Number.isNaN(date.getTime())) { | ||
| dateStr = date.toLocaleDateString('en-US', { | ||
| year: 'numeric', | ||
| month: 'short', | ||
| day: 'numeric', | ||
| }); | ||
| } | ||
| } | ||
| const version = record.version ?? '(no version)'; | ||
| console.log(` ${kleur.cyan(`${record.user}@${record.ref}`)} ${version} ${kleur.dim(shortSha)} ${kleur.dim(dateStr)}`); | ||
| } | ||
| console.log(); | ||
| } | ||
| // 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; | ||
| } |
+1
-1
| #!/usr/bin/env node | ||
| export {}; | ||
| export declare function main(argv?: string[]): Promise<undefined>; |
+219
-14
| #!/usr/bin/env node | ||
| import kleur from 'kleur'; | ||
| import { install, preview, rollback, init, search } from './sharekit.js'; | ||
| const VERSION = '0.3.0'; | ||
| import * as fs from 'node:fs'; | ||
| import * as path from 'node:path'; | ||
| import * as os from 'node:os'; | ||
| import { install, preview, inspect, rollback, init, search, scan, listBackups, restoreBackupToStamp, confirm, list, update, uninstall, } from './sharekit.js'; | ||
| const HOME = os.homedir(); | ||
| const STATE = path.join(HOME, '.sharekit'); | ||
| const VERSION = '0.4.0'; | ||
| const USAGE = `${kleur.bold('sharekit')} v${VERSION} — share your AI coding setup | ||
| ${kleur.cyan('init')} [skill...] scaffold a publishable profile in ./sharekit-profile | ||
| ${kleur.cyan('install')} <user>[@<ref>] [opts] fetch, preview, apply a profile | ||
| --include-hooks also install settings.json with shell hooks | ||
| ${kleur.cyan('preview')} <user>[@<ref>] show changes, apply nothing | ||
| ${kleur.cyan('rollback')} <user> restore the last backup | ||
| ${kleur.cyan('search')} [query] discover published profiles on GitHub | ||
| ${kleur.cyan('init')} [skill...] scaffold a publishable profile in ./sharekit-profile | ||
| --force overwrite existing dir; override secret blocking | ||
| ${kleur.cyan('scan')} [dir] scan an existing profile for secrets | ||
| --force exit 0 even if high-severity findings detected | ||
| ${kleur.cyan('install')} <user>[@<ref>] [opts] fetch, preview, apply a profile | ||
| --include-hooks also install settings.json with shell hooks | ||
| --yes auto-approve prompts | ||
| --dry-run show changes without writing files | ||
| ${kleur.cyan('preview')} <user>[@<ref>] show changes, apply nothing | ||
| ${kleur.cyan('inspect')} <user>[@<ref>] view profile contents before installing | ||
| ${kleur.cyan('list')} show installed profiles & versions | ||
| ${kleur.cyan('update')} <user> [opts] refresh a profile to latest with diff | ||
| --yes auto-approve prompts | ||
| --dry-run show changes without writing files | ||
| --include-hooks also apply settings.json with shell hooks | ||
| ${kleur.cyan('rollback')} <user> [opts] restore the last backup | ||
| --yes auto-approve prompts | ||
| --dry-run show what would be restored | ||
| --list list available backups | ||
| --to <stamp> restore a specific backup by timestamp | ||
| ${kleur.cyan('uninstall')} <user> [opts] clean removal of an installed profile | ||
| --yes skip the confirmation prompt | ||
| ${kleur.cyan('search')} [query] discover published profiles on GitHub | ||
@@ -17,6 +39,5 @@ Publish yours: a GitHub repo named ${kleur.cyan('sharekit-profile')} with a ${kleur.cyan('sharekit.toml')}. | ||
| `; | ||
| const cmds = { install, preview, rollback }; | ||
| const argv = process.argv.slice(2); | ||
| const [cmd, ...rest] = argv; | ||
| async function main() { | ||
| const cmds = { install, preview, inspect, rollback }; | ||
| export async function main(argv = process.argv.slice(2)) { | ||
| const [cmd, ...rest] = argv; | ||
| if (!cmd || cmd === '-h' || cmd === '--help') | ||
@@ -28,3 +49,6 @@ return void console.log(USAGE); | ||
| console.log(); | ||
| init('./sharekit-profile', rest); | ||
| const flags = rest.filter((x) => x.startsWith('--')); | ||
| const skillNames = rest.filter((x) => !x.startsWith('--')); | ||
| const force = flags.includes('--force'); | ||
| init('./sharekit-profile', skillNames, undefined, force); | ||
| return; | ||
@@ -36,2 +60,176 @@ } | ||
| } | ||
| if (cmd === 'scan') { | ||
| console.log(); | ||
| // Positional arg (optional directory) separated from flags by -- prefix | ||
| const flags = rest.filter((x) => x.startsWith('--')); | ||
| const dir = rest.find((x) => !x.startsWith('--')); | ||
| const force = flags.includes('--force'); | ||
| await scan(dir, force); | ||
| return; | ||
| } | ||
| if (cmd === 'list') { | ||
| console.log(); | ||
| list(); | ||
| return; | ||
| } | ||
| if (cmd === 'update') { | ||
| console.log(); | ||
| const flags = rest.filter((x) => x.startsWith('--')); | ||
| const user = rest.find((x) => !x.startsWith('--')); | ||
| if (!user) { | ||
| console.error(kleur.red('usage: sharekit update <user>')); | ||
| process.exit(1); | ||
| } | ||
| const opts = {}; | ||
| if (flags.includes('--yes')) | ||
| opts.yes = true; | ||
| if (flags.includes('--dry-run')) | ||
| opts.dryRun = true; | ||
| if (flags.includes('--include-hooks')) | ||
| opts.includeHooks = true; | ||
| await update(user, opts); | ||
| return; | ||
| } | ||
| if (cmd === 'rollback') { | ||
| console.log(); | ||
| // Parse flags and positional user argument | ||
| let i = 0; | ||
| let user; | ||
| let listMode = false; | ||
| let toStamp; | ||
| let yes = false; | ||
| let dryRun = false; | ||
| while (i < rest.length) { | ||
| const arg = rest[i]; | ||
| if (arg === '--list') { | ||
| listMode = true; | ||
| i++; | ||
| } | ||
| else if (arg === '--yes') { | ||
| yes = true; | ||
| i++; | ||
| } | ||
| else if (arg === '--dry-run') { | ||
| dryRun = true; | ||
| i++; | ||
| } | ||
| else if (arg === '--to') { | ||
| if (i + 1 >= rest.length) { | ||
| console.error(kleur.red('error: --to requires a stamp argument')); | ||
| process.exit(1); | ||
| } | ||
| toStamp = rest[i + 1]; | ||
| i += 2; | ||
| } | ||
| else if (!arg.startsWith('--')) { | ||
| user = arg; | ||
| i++; | ||
| } | ||
| else { | ||
| console.error(kleur.red(`unknown flag: ${arg}`)); | ||
| process.exit(1); | ||
| } | ||
| } | ||
| if (!user) { | ||
| console.error(kleur.red('usage: sharekit rollback <user> [--list] [--to <stamp>]')); | ||
| process.exit(1); | ||
| } | ||
| if (listMode) { | ||
| const backups = listBackups(user); | ||
| if (backups.length === 0) { | ||
| console.log(kleur.yellow(` No backups found for ${user}.\n`)); | ||
| return; | ||
| } | ||
| console.log(kleur.bold(`\n Backups for ${user} (newest first):\n`)); | ||
| for (let idx = 0; idx < backups.length; idx++) { | ||
| const b = backups[idx]; | ||
| // Format timestamp for display: convert ISO-like format to readable date | ||
| const dateStr = b.stamp | ||
| .replace(/([0-9]{4})-([0-9]{2})-([0-9]{2})T/, '$1-$2-$3 ') | ||
| .replace(/T/g, ' ') | ||
| .replace(/-/g, ':') | ||
| .replace(/([0-9]{2}):([0-9]{2}):([0-9]{2}):/, '$1:$2:$3 '); | ||
| console.log(kleur.dim(` ${idx + 1}. ${dateStr}`)); | ||
| console.log(kleur.dim(` ${b.fileCount} file(s) — stamp: ${b.stamp}`)); | ||
| } | ||
| console.log(); | ||
| return; | ||
| } | ||
| if (toStamp) { | ||
| const backupDir = path.join(STATE, 'backups', `${user}-${toStamp}`); | ||
| if (!fs.existsSync(backupDir)) { | ||
| console.error(kleur.red(`No backup found for ${user} with stamp ${toStamp}.`)); | ||
| process.exit(1); | ||
| } | ||
| // Read applied.json to show what will be restored | ||
| const appliedPath = path.join(backupDir, 'applied.json'); | ||
| let applied = []; | ||
| try { | ||
| applied = JSON.parse(fs.readFileSync(appliedPath, 'utf8')); | ||
| } | ||
| catch { | ||
| console.error(kleur.red(`Could not read backup metadata at ${appliedPath}`)); | ||
| process.exit(1); | ||
| } | ||
| // Read version and commit from metadata if available | ||
| let versionStr = ''; | ||
| let sourceCommit; | ||
| const metadataPath = path.join(backupDir, 'metadata.json'); | ||
| if (fs.existsSync(metadataPath)) { | ||
| try { | ||
| const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); | ||
| if (metadata.sourceVersion) | ||
| versionStr = ` (v${metadata.sourceVersion})`; | ||
| sourceCommit = metadata.sourceCommit; | ||
| } | ||
| catch { | ||
| // If metadata can't be read, just continue without version info | ||
| } | ||
| } | ||
| console.log(kleur.bold(`\n Restore ${user}${versionStr} (${applied.length} file(s))\n`)); | ||
| if (dryRun) { | ||
| console.log(kleur.cyan(` (dry-run — ${applied.length} file(s) would be restored, nothing written)\n`)); | ||
| return; | ||
| } | ||
| if (!(await confirm('Restore?', yes))) { | ||
| console.log(kleur.dim('\n Aborted.\n')); | ||
| return; | ||
| } | ||
| restoreBackupToStamp(user, toStamp); | ||
| const filesRestored = applied.filter((a) => a.status === 'changed').length; | ||
| const filesRemoved = applied.filter((a) => a.status === 'new').length; | ||
| const summary = `${filesRestored} file(s) restored${filesRemoved > 0 ? `, ${filesRemoved} removed` : ''}`; | ||
| // Handle null sourceCommit (offline cache case) | ||
| let versionSuffix = ''; | ||
| if (sourceCommit === null) { | ||
| versionSuffix = ' — from offline cache, exact version unknown'; | ||
| } | ||
| else if (versionStr) { | ||
| versionSuffix = ` (reverted to v${versionStr.slice(4, -1)})`; | ||
| } | ||
| console.log(kleur.green(`\n ✓ ${summary}${versionSuffix}`)); | ||
| console.log(); | ||
| return; | ||
| } | ||
| // Default: restore latest | ||
| const flags = rest.filter((x) => x.startsWith('--')); | ||
| const opts = {}; | ||
| if (flags.includes('--yes')) | ||
| opts.yes = true; | ||
| if (flags.includes('--dry-run')) | ||
| opts.dryRun = true; | ||
| await rollback(user, opts); | ||
| return; | ||
| } | ||
| if (cmd === 'uninstall') { | ||
| console.log(); | ||
| const flags = rest.filter((x) => x.startsWith('--')); | ||
| const user = rest.find((x) => !x.startsWith('--')); | ||
| if (!user) { | ||
| console.error(kleur.red('usage: sharekit uninstall <user>')); | ||
| process.exit(1); | ||
| } | ||
| await uninstall(user, undefined, flags.includes('--yes') || flags.includes('--force')); | ||
| return; | ||
| } | ||
| const fn = cmds[cmd]; | ||
@@ -46,3 +244,3 @@ if (!fn) { | ||
| if (!arg) { | ||
| const showRef = cmd === 'install' || cmd === 'preview'; | ||
| const showRef = cmd === 'install' || cmd === 'preview' || cmd === 'inspect'; | ||
| console.error(kleur.red(`usage: sharekit ${cmd} <user>${showRef ? '[@<ref>]' : ''}`)); | ||
@@ -52,2 +250,9 @@ process.exit(1); | ||
| const opts = {}; | ||
| if ((cmd === 'install' || cmd === 'update' || cmd === 'rollback') && flags.includes('--yes')) { | ||
| opts.yes = true; | ||
| } | ||
| if ((cmd === 'install' || cmd === 'update' || cmd === 'rollback') && | ||
| flags.includes('--dry-run')) { | ||
| opts.dryRun = true; | ||
| } | ||
| if (cmd === 'install' && flags.includes('--include-hooks')) { | ||
@@ -54,0 +259,0 @@ opts.includeHooks = true; |
+13
-35
@@ -1,35 +0,13 @@ | ||
| type Status = 'new' | 'changed' | 'same'; | ||
| interface PlanFile { | ||
| tool: string; | ||
| src: string; | ||
| dest: string; | ||
| rel: string; | ||
| status: Status; | ||
| } | ||
| type Dirs = { | ||
| home: string; | ||
| state: string; | ||
| }; | ||
| export interface InstallOpts { | ||
| includeHooks?: boolean; | ||
| } | ||
| export declare function fetchProfile(user: string, ref?: string, baseUrl?: string, cacheRoot?: string): string; | ||
| export declare function readManifest(profileDir: string): { | ||
| name: string; | ||
| version?: string; | ||
| description?: string; | ||
| }; | ||
| export declare function search(query?: string): Promise<void>; | ||
| export declare function plan(profileDir: string, roots?: Record<string, string>): PlanFile[]; | ||
| export declare function printPlan(files: PlanFile[], manifest: ReturnType<typeof readManifest>): void; | ||
| export declare function pruneBackups(user: string, state?: string): void; | ||
| export declare function applyProfile(files: PlanFile[], user: string, includeHooks?: boolean, dirs?: Dirs): { | ||
| backupDir: string; | ||
| filesWritten: number; | ||
| }; | ||
| export declare function restoreBackup(user: string, dirs?: Dirs): void; | ||
| export declare function install(user: string, opts?: InstallOpts): Promise<void>; | ||
| export declare function preview(user: string): Promise<void>; | ||
| export declare function rollback(user: string): Promise<void>; | ||
| export declare function init(profileDir: string, skillNames?: string[], sourceRoot?: string): void; | ||
| export {}; | ||
| export { HOME, STATE, MAX_MANIFEST_BYTES, ROOTS, DEFAULT_DIRS, tildify, cp, walk, walkWithSymlinks, } from './paths.js'; | ||
| export type { Dirs, WalkResult } from './paths.js'; | ||
| export { scanForSecrets, truncatePreview, printAndGateFindings } from './scanner.js'; | ||
| export type { Finding } from './scanner.js'; | ||
| export { parseUserRef, fetchProfile, readManifest } from './fetch.js'; | ||
| export { plan, printPlan, isExecutable, applyProfile } from './plan.js'; | ||
| export type { Status, PlanFile } from './plan.js'; | ||
| export { listBackups, pruneBackups, restoreBackup, restoreBackupInternal, restoreBackupToStamp, readMetadata, writeMetadata, } from './backup.js'; | ||
| export type { BackupInfo, RestoreMetadata } from './backup.js'; | ||
| export { recordInstall, readInstalled, list, isImmutableRef } from './state.js'; | ||
| export type { InstallRecord } from './state.js'; | ||
| export { confirm, search, updateApply, update, install, preview, inspect, rollback, uninstall, scan, init, } from './commands.js'; | ||
| export type { InstallOpts } from './commands.js'; |
+15
-367
@@ -1,367 +0,15 @@ | ||
| import * as fs from 'node:fs'; | ||
| import * as path from 'node:path'; | ||
| import * as os from 'node:os'; | ||
| import * as readline from 'node:readline/promises'; | ||
| import { stdin as input, stdout as output } from 'node:process'; | ||
| import { execFileSync } from 'node:child_process'; | ||
| import TOML from '@iarna/toml'; | ||
| import kleur from 'kleur'; | ||
| const HOME = os.homedir(); | ||
| const STATE = path.join(HOME, '.sharekit'); | ||
| // profile/<tool>/** mirrors into these roots — one rule, not a filename allowlist | ||
| const ROOTS = { | ||
| claude: path.join(HOME, '.claude'), | ||
| cursor: path.join(HOME, '.cursor'), | ||
| shared: HOME, | ||
| }; | ||
| const DEFAULT_DIRS = { home: HOME, state: STATE }; | ||
| const tildify = (p) => (p.startsWith(HOME) ? '~' + p.slice(HOME.length) : p); | ||
| function walk(dir) { | ||
| return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => { | ||
| if (e.isSymbolicLink()) | ||
| return []; // skip symlinks: don't follow into arbitrary files; dir-links would EISDIR on copy | ||
| const p = path.join(dir, e.name); | ||
| return e.isDirectory() ? walk(p) : [p]; | ||
| }); | ||
| } | ||
| // copy a file, preserving its mode (e.g. a skill's executable toggle.sh) | ||
| function cp(src, dest) { | ||
| fs.copyFileSync(src, dest); | ||
| fs.chmodSync(dest, fs.statSync(src).mode); | ||
| } | ||
| // ponytail: profile lives at github.com/<user>/sharekit-profile — one convention, not a search | ||
| export function fetchProfile(user, ref, baseUrl = 'https://github.com', cacheRoot = path.join(STATE, 'profiles')) { | ||
| // Cache key: <user> for HEAD, <user>@<ref> for a pinned ref | ||
| const cacheKey = ref ? `${user}@${ref}` : user; | ||
| const dir = path.join(cacheRoot, cacheKey); | ||
| if (fs.existsSync(dir)) { | ||
| // If a ref is specified, don't pull (it's a detached pinned checkout). Just reuse. | ||
| if (!ref) { | ||
| try { | ||
| execFileSync('git', ['-C', dir, 'pull', '--ff-only'], { stdio: 'pipe' }); | ||
| } | ||
| catch { | ||
| // ponytail: refresh is best-effort — offline / no-remote falls back to the cached copy | ||
| } | ||
| } | ||
| return dir; | ||
| } | ||
| fs.mkdirSync(path.dirname(dir), { recursive: true }); | ||
| const url = `${baseUrl}/${user}/sharekit-profile`; | ||
| try { | ||
| if (ref) { | ||
| execFileSync('git', ['clone', '--depth', '1', '--branch', ref, '--', url, dir], { | ||
| stdio: 'pipe', | ||
| }); | ||
| } | ||
| else { | ||
| execFileSync('git', ['clone', '--depth', '1', '--', url, dir], { stdio: 'pipe' }); | ||
| } | ||
| } | ||
| catch (e) { | ||
| if (e.code === 'ENOENT') { | ||
| throw new Error('git not found — install git to use sharekit (https://git-scm.com)'); | ||
| } | ||
| const errOut = (e.stderr?.toString() ?? '') + (e.message ?? ''); | ||
| if (ref && errOut.includes('not found')) { | ||
| throw new Error(`ref '${ref}' not found in ${user}'s profile`); | ||
| } | ||
| throw new Error(`No profile at ${url}\n` + | ||
| ` Publish yours: a repo named "sharekit-profile" with a sharekit.toml`); | ||
| } | ||
| return dir; | ||
| } | ||
| export function readManifest(profileDir) { | ||
| const p = path.join(profileDir, 'sharekit.toml'); | ||
| if (!fs.existsSync(p)) | ||
| throw new Error(`Not a sharekit profile (no sharekit.toml in ${profileDir})`); | ||
| let parsed; | ||
| try { | ||
| parsed = TOML.parse(fs.readFileSync(p, 'utf8')); | ||
| } | ||
| catch (e) { | ||
| throw new Error(`Invalid sharekit.toml: ${e.message}`); | ||
| } | ||
| const profile = (parsed.profile ?? {}); | ||
| return { | ||
| name: profile.name ?? 'unknown', | ||
| version: profile.version, | ||
| description: profile.description, | ||
| }; | ||
| } | ||
| // Discover published profiles: GitHub IS the registry — search for repos named "sharekit-profile". | ||
| export async function search(query) { | ||
| const q = encodeURIComponent(`sharekit-profile in:name${query ? ` ${query}` : ''}`); | ||
| const url = `https://api.github.com/search/repositories?q=${q}&sort=stars&per_page=30`; | ||
| let data; | ||
| try { | ||
| const res = await fetch(url, { | ||
| headers: { 'User-Agent': 'sharekit-cli', Accept: 'application/vnd.github+json' }, | ||
| }); | ||
| if (!res.ok) | ||
| throw new Error(`GitHub API ${res.status}`); | ||
| data = (await res.json()); | ||
| } | ||
| catch (e) { | ||
| throw new Error(`search failed: ${e.message}`); | ||
| } | ||
| const profiles = (data.items ?? []).filter((r) => r.name === 'sharekit-profile'); | ||
| if (!profiles.length) { | ||
| console.log(kleur.dim(`\n No profiles found${query ? ` for "${query}"` : ''}. Publish yours: a repo named "sharekit-profile".\n`)); | ||
| return; | ||
| } | ||
| console.log(kleur.bold(`\n ${profiles.length} profile(s)${query ? ` matching "${query}"` : ''}:\n`)); | ||
| for (const r of profiles) { | ||
| const owner = r.owner?.login ?? '?'; | ||
| const stars = r.stargazers_count || 0; | ||
| console.log(` ${kleur.cyan(owner)}${stars ? kleur.dim(` ★${stars}`) : ''}`); | ||
| if (r.description) | ||
| console.log(kleur.dim(` ${r.description}`)); | ||
| console.log(kleur.dim(` → sharekit install ${owner}`)); | ||
| } | ||
| console.log(); | ||
| } | ||
| export function plan(profileDir, roots = ROOTS) { | ||
| const files = []; | ||
| for (const [tool, root] of Object.entries(roots)) { | ||
| const base = path.join(profileDir, tool); | ||
| if (!fs.existsSync(base)) | ||
| continue; | ||
| for (const src of walk(base)) { | ||
| const rel = path.relative(base, src); | ||
| const dest = path.join(root, rel); | ||
| files.push({ tool, src, dest, rel, status: classify(src, dest) }); | ||
| } | ||
| } | ||
| return files; | ||
| } | ||
| function classify(src, dest) { | ||
| if (!fs.existsSync(dest)) | ||
| return 'new'; | ||
| return fs.readFileSync(src).equals(fs.readFileSync(dest)) ? 'same' : 'changed'; | ||
| } | ||
| // ponytail: settings.json carries hooks (arbitrary shell). v1 never auto-installs it. | ||
| // add `--include-hooks` when someone actually asks. | ||
| const isExecutable = (f, includeHooks = false) => !includeHooks && f.tool === 'claude' && path.basename(f.dest) === 'settings.json'; | ||
| export function printPlan(files, manifest) { | ||
| console.log(kleur.bold(`Profile: ${manifest.name}${manifest.version ? ' v' + manifest.version : ''}`)); | ||
| if (manifest.description) | ||
| console.log(kleur.dim(' ' + manifest.description)); | ||
| const show = (s, label, c) => { | ||
| const g = files.filter((f) => f.status === s); | ||
| if (!g.length) | ||
| return; | ||
| console.log(c(`\n ${label} (${g.length})`)); | ||
| for (const f of g) | ||
| console.log(c(` ${tildify(f.dest)}`)); | ||
| }; | ||
| show('new', '+ new', kleur.green); | ||
| show('changed', '~ changed', kleur.yellow); | ||
| const same = files.filter((f) => f.status === 'same').length; | ||
| if (same) | ||
| console.log(kleur.dim(`\n = ${same} unchanged`)); | ||
| if (files.some((f) => isExecutable(f))) | ||
| console.log(kleur.yellow(`\n ⚠ settings.json present — contains hooks; skipped. Merge manually.`)); | ||
| } | ||
| async function confirm(q) { | ||
| const rl = readline.createInterface({ input, output }); | ||
| const a = await rl.question(kleur.bold(` ${q} (y/N) `)); | ||
| rl.close(); | ||
| return a.trim().toLowerCase() === 'y'; | ||
| } | ||
| // Prune backups, keeping only the most recent 5 | ||
| export function pruneBackups(user, state = STATE) { | ||
| const root = path.join(state, 'backups'); | ||
| if (!fs.existsSync(root)) | ||
| return; | ||
| const dirs = fs | ||
| .readdirSync(root) | ||
| .filter((e) => e.startsWith(user + '-')) | ||
| .sort(); | ||
| if (dirs.length > 5) { | ||
| const toRemove = dirs.slice(0, dirs.length - 5); | ||
| for (const dir of toRemove) { | ||
| fs.rmSync(path.join(root, dir), { recursive: true, force: true }); | ||
| } | ||
| } | ||
| } | ||
| function backup(files, user, includeHooks = false, dirs = DEFAULT_DIRS) { | ||
| const stamp = new Date().toISOString().replace(/[:.]/g, '-'); | ||
| const dir = path.join(dirs.state, 'backups', `${user}-${stamp}`); | ||
| const applied = files.filter((f) => f.status !== 'same' && !isExecutable(f, includeHooks)); | ||
| fs.mkdirSync(dir, { recursive: true }); | ||
| for (const f of applied.filter((f) => f.status === 'changed')) { | ||
| const t = path.join(dir, path.relative(dirs.home, f.dest)); | ||
| fs.mkdirSync(path.dirname(t), { recursive: true }); | ||
| cp(f.dest, t); | ||
| } | ||
| fs.writeFileSync(path.join(dir, 'applied.json'), JSON.stringify(applied.map((f) => ({ dest: f.dest, status: f.status })), null, 2)); | ||
| return dir; | ||
| } | ||
| function write(files, includeHooks = false) { | ||
| let n = 0; | ||
| for (const f of files) { | ||
| if (f.status === 'same' || isExecutable(f, includeHooks)) | ||
| continue; | ||
| fs.mkdirSync(path.dirname(f.dest), { recursive: true }); | ||
| cp(f.src, f.dest); | ||
| n++; | ||
| } | ||
| return n; | ||
| } | ||
| // Exported pure functions for testability | ||
| export function applyProfile(files, user, includeHooks = false, dirs = DEFAULT_DIRS) { | ||
| const backupDir = backup(files, user, includeHooks, dirs); | ||
| const filesWritten = write(files, includeHooks); | ||
| pruneBackups(user, dirs.state); | ||
| return { backupDir, filesWritten }; | ||
| } | ||
| export function restoreBackup(user, dirs = DEFAULT_DIRS) { | ||
| const root = path.join(dirs.state, 'backups'); | ||
| const last = fs.existsSync(root) | ||
| ? fs | ||
| .readdirSync(root) | ||
| .filter((e) => e.startsWith(user + '-')) | ||
| .sort() | ||
| .pop() | ||
| : undefined; | ||
| if (!last) | ||
| throw new Error(`No backup for ${user}.`); | ||
| const dir = path.join(root, last); | ||
| const applied = JSON.parse(fs.readFileSync(path.join(dir, 'applied.json'), 'utf8')); | ||
| for (const a of applied) { | ||
| if (a.status === 'new') | ||
| fs.rmSync(a.dest, { force: true }); // ponytail: leaves empty parent dirs; harmless | ||
| else { | ||
| const src = path.join(dir, path.relative(dirs.home, a.dest)); | ||
| if (fs.existsSync(src)) { | ||
| fs.mkdirSync(path.dirname(a.dest), { recursive: true }); // dest dir may have been removed since install | ||
| cp(src, a.dest); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| export async function install(user, opts) { | ||
| const includeHooks = opts?.includeHooks ?? false; | ||
| const userRef = user.includes('@') ? user.split('@').reverse()[0] : undefined; | ||
| const userName = userRef ? user.slice(0, user.lastIndexOf('@')) : user; | ||
| 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?`))) { | ||
| return void console.log(kleur.dim('\n Aborted.\n')); | ||
| } | ||
| } | ||
| if (!(await confirm(`Apply ${todo.length} change(s)?`))) | ||
| return void console.log(kleur.dim('\n Aborted.\n')); | ||
| const { backupDir, filesWritten } = applyProfile(files, userName, includeHooks); | ||
| console.log(kleur.green(`\n ✓ Applied ${filesWritten} file(s).`) + | ||
| kleur.dim(` Backup: ${tildify(backupDir)}`)); | ||
| console.log(kleur.dim(` Undo: sharekit rollback ${userName}\n`)); | ||
| } | ||
| export async function preview(user) { | ||
| const userRef = user.includes('@') ? user.split('@').reverse()[0] : undefined; | ||
| const userName = userRef ? user.slice(0, user.lastIndexOf('@')) : user; | ||
| const dir = fetchProfile(userName, userRef); | ||
| console.log(); | ||
| printPlan(plan(dir), readManifest(dir)); | ||
| console.log(); | ||
| } | ||
| export async function rollback(user) { | ||
| const root = path.join(STATE, 'backups'); | ||
| const last = fs.existsSync(root) | ||
| ? fs | ||
| .readdirSync(root) | ||
| .filter((e) => e.startsWith(user + '-')) | ||
| .sort() | ||
| .pop() | ||
| : undefined; | ||
| if (!last) | ||
| return void console.log(kleur.yellow(`No backup for ${user}.`)); | ||
| const dir = path.join(root, last); | ||
| const applied = JSON.parse(fs.readFileSync(path.join(dir, 'applied.json'), 'utf8')); | ||
| console.log(kleur.bold(`\n Rollback ${user} → ${tildify(dir)} (${applied.length} file(s))\n`)); | ||
| if (!(await confirm('Restore?'))) | ||
| return void console.log(kleur.dim('\n Aborted.\n')); | ||
| restoreBackup(user); | ||
| console.log(kleur.green('\n ✓ Restored.\n')); | ||
| } | ||
| export function init(profileDir, skillNames = [], sourceRoot = HOME) { | ||
| // Check if profileDir already exists | ||
| if (fs.existsSync(profileDir)) { | ||
| throw new Error(`Profile directory already exists: ${profileDir}`); | ||
| } | ||
| const username = os.userInfo().username; | ||
| const profileRoot = path.join(profileDir); | ||
| fs.mkdirSync(profileRoot, { recursive: true }); | ||
| // 1. Create sharekit.toml | ||
| const tomlContent = `[profile] | ||
| name = "${username}" | ||
| version = "0.1.0" | ||
| description = "My AI coding setup" | ||
| `; | ||
| fs.writeFileSync(path.join(profileRoot, 'sharekit.toml'), tomlContent); | ||
| console.log(kleur.green(` + ${tildify(path.join(profileRoot, 'sharekit.toml'))}`)); | ||
| // 2. Copy CLAUDE.md from source root if it exists | ||
| const sourceClaude = path.join(sourceRoot, '.claude', 'CLAUDE.md'); | ||
| const destClaude = path.join(profileRoot, 'claude', 'CLAUDE.md'); | ||
| fs.mkdirSync(path.dirname(destClaude), { recursive: true }); | ||
| if (fs.existsSync(sourceClaude)) { | ||
| cp(sourceClaude, destClaude); | ||
| console.log(kleur.green(` + ${tildify(destClaude)}`)); | ||
| } | ||
| else { | ||
| fs.writeFileSync(destClaude, '# My AI coding instructions\n'); | ||
| console.log(kleur.green(` + ${tildify(destClaude)} (placeholder)`)); | ||
| } | ||
| // 3. Scaffold cursor/ directory | ||
| const destCursorRules = path.join(profileRoot, 'cursor', '.cursorrules'); | ||
| fs.mkdirSync(path.dirname(destCursorRules), { recursive: true }); | ||
| const sourceCursorRules = path.join(sourceRoot, '.cursor', '.cursorrules'); | ||
| if (fs.existsSync(sourceCursorRules)) { | ||
| cp(sourceCursorRules, destCursorRules); | ||
| console.log(kleur.green(` + ${tildify(destCursorRules)}`)); | ||
| } | ||
| else { | ||
| fs.writeFileSync(destCursorRules, '# Cursor IDE rules\n'); | ||
| console.log(kleur.green(` + ${tildify(destCursorRules)} (placeholder)`)); | ||
| } | ||
| // 4. Scaffold shared/ directory with .gitkeep | ||
| const destShared = path.join(profileRoot, 'shared'); | ||
| fs.mkdirSync(destShared, { recursive: true }); | ||
| fs.writeFileSync(path.join(destShared, '.gitkeep'), ''); | ||
| console.log(kleur.green(` + ${tildify(destShared)}/`)); | ||
| // 5. Copy skills if specified | ||
| let skillCount = 0; | ||
| for (const skillName of skillNames) { | ||
| const sourceSkill = path.join(sourceRoot, '.claude', 'skills', skillName); | ||
| if (!fs.existsSync(sourceSkill)) { | ||
| console.log(kleur.yellow(` ~ skill '${skillName}' not found at ${tildify(sourceSkill)}`)); | ||
| continue; | ||
| } | ||
| const destSkillBase = path.join(profileRoot, 'claude', 'skills', skillName); | ||
| fs.mkdirSync(destSkillBase, { recursive: true }); | ||
| for (const file of walk(sourceSkill)) { | ||
| const rel = path.relative(sourceSkill, file); | ||
| const dest = path.join(destSkillBase, rel); | ||
| fs.mkdirSync(path.dirname(dest), { recursive: true }); | ||
| cp(file, dest); | ||
| console.log(kleur.green(` + ${tildify(dest)}`)); | ||
| skillCount++; | ||
| } | ||
| } | ||
| console.log(kleur.green(`\n ✓ Created profile at ${tildify(profileRoot)}` + | ||
| ` (sharekit.toml, CLAUDE.md, cursor/, shared/${skillCount > 0 ? `, ${skillCount} skill file(s)` : ''})`)); | ||
| } | ||
| // Barrel file: re-exports the entire public API for backward compatibility | ||
| // From paths.ts | ||
| export { HOME, STATE, MAX_MANIFEST_BYTES, ROOTS, DEFAULT_DIRS, tildify, cp, walk, walkWithSymlinks, } from './paths.js'; | ||
| // From scanner.ts | ||
| export { scanForSecrets, truncatePreview, printAndGateFindings } from './scanner.js'; | ||
| // From fetch.ts | ||
| export { parseUserRef, fetchProfile, readManifest } from './fetch.js'; | ||
| // From plan.ts | ||
| export { plan, printPlan, isExecutable, applyProfile } from './plan.js'; | ||
| // From backup.ts | ||
| export { listBackups, pruneBackups, restoreBackup, restoreBackupInternal, restoreBackupToStamp, readMetadata, writeMetadata, } from './backup.js'; | ||
| // From state.ts | ||
| export { recordInstall, readInstalled, list, isImmutableRef } from './state.js'; | ||
| // From commands.ts | ||
| export { confirm, search, updateApply, update, install, preview, inspect, rollback, uninstall, scan, init, } from './commands.js'; |
+5
-5
| { | ||
| "name": "@lucassantana/sharekit", | ||
| "version": "0.3.0", | ||
| "version": "0.4.0", | ||
| "description": "Share your AI coding setup with anyone — one command to install, one to rollback.", | ||
@@ -36,10 +36,10 @@ "keywords": [ | ||
| "dependencies": { | ||
| "@iarna/toml": "^2.2.5", | ||
| "kleur": "^4.1.5" | ||
| "kleur": "^4.1.5", | ||
| "smol-toml": "^1.7.0" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/node": "^22.0.0", | ||
| "@types/node": "^26.0.1", | ||
| "prettier": "^3.3.0", | ||
| "tsx": "^4.19.0", | ||
| "typescript": "^5.7.0" | ||
| "typescript": "^6.0.3" | ||
| }, | ||
@@ -46,0 +46,0 @@ "engines": { |
+47
-0
| # sharekit | ||
|  | ||
| Share your AI coding setup — CLAUDE.md, skills, cursorrules, and dotfiles — with anyone. One command to install, one to rollback. | ||
@@ -40,2 +42,27 @@ | ||
| ## Manage installed profiles | ||
| List all installed profiles with version, commit, and date applied: | ||
| ```bash | ||
| npx @lucassantana/sharekit list | ||
| ``` | ||
| Output: | ||
| ``` | ||
| Installed profiles: | ||
| user1@HEAD v1.0.2 abc1234 Jun 15, 2025 | ||
| user2@v1.0 (no version) ? Jun 1, 2025 | ||
| ``` | ||
| Update an installed profile (tracked profiles only; pinned refs are no-ops): | ||
| ```bash | ||
| npx @lucassantana/sharekit update <github-user> | ||
| ``` | ||
| Shows what changed, asks for confirmation, and applies. Pinned refs (tags, commit SHAs) do not update. | ||
| ### Hooks | ||
@@ -74,2 +101,20 @@ | ||
| ### Safety | ||
| Before pushing your profile to a public GitHub repo, use `sharekit scan` to check for secrets: | ||
| ```bash | ||
| npx @lucassantana/sharekit scan ./sharekit-profile | ||
| ``` | ||
| The scanner detects private keys, AWS/GitHub/Slack/Google API tokens, bearer tokens, sensitive environment variables (`SECRET|TOKEN|PASSWORD|API_KEY|APIKEY|ACCESS_KEY`), and home-path leaks. High-severity findings (keys/tokens) **block the `init` and `scan` commands with a non-zero exit**. Medium/low findings (e.g., env-var names, paths) warn only. | ||
| Override a block with `--force` if you've manually reviewed and redacted the findings: | ||
| ```bash | ||
| npx @lucassantana/sharekit scan ./sharekit-profile --force | ||
| ``` | ||
| Always review the profile and `.gitignore` sensitive files before `git push`. The scanner is best-effort; you are responsible for ensuring no real secrets escape. | ||
| ## Security | ||
@@ -81,2 +126,4 @@ | ||
| See [SECURITY.md](SECURITY.md) for the full trust model, path assumptions, and responsible disclosure policy. | ||
| ## Status | ||
@@ -83,0 +130,0 @@ |
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.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
77757
210.8%21
200%1644
258.95%130
56.63%8
300%4
100%+ Added
+ Added
- Removed
- Removed