🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@lucassantana/sharekit

Package Overview
Dependencies
Maintainers
1
Versions
7
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@lucassantana/sharekit - npm Package Compare versions

Comparing version
0.3.0
to
0.4.0
+25
dist/backup.d.ts
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;
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;
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;
};
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;
};
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[];
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;
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>;
#!/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;

@@ -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';

@@ -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';
{
"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": {

# sharekit
![ShareKit banner](https://i.ibb.co/NgdM23Kz/sharekit-card.png)
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 @@