@botdocs/cli 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -37
- package/dist/commands/backups.d.ts +4 -0
- package/dist/commands/backups.js +291 -0
- package/dist/commands/edit.js +16 -8
- package/dist/commands/install.d.ts +4 -0
- package/dist/commands/install.js +21 -3
- package/dist/commands/login.d.ts +7 -0
- package/dist/commands/login.js +240 -75
- package/dist/commands/sync.d.ts +16 -0
- package/dist/commands/sync.js +337 -25
- package/dist/commands/team.d.ts +2 -0
- package/dist/commands/team.js +251 -0
- package/dist/commands/undo.d.ts +19 -0
- package/dist/commands/undo.js +88 -0
- package/dist/commands/views/conflict-prompt.d.ts +24 -0
- package/dist/commands/views/conflict-prompt.js +19 -0
- package/dist/commands/views/login-app.d.ts +30 -0
- package/dist/commands/views/login-app.js +57 -0
- package/dist/commands/views/sync-app.d.ts +27 -0
- package/dist/commands/views/sync-app.js +147 -0
- package/dist/commands/views/sync-state.d.ts +84 -0
- package/dist/commands/views/sync-state.js +93 -0
- package/dist/commands/views/theme.d.ts +16 -0
- package/dist/commands/views/theme.js +16 -0
- package/dist/commands/whoami.js +13 -13
- package/dist/index.js +44 -38
- package/dist/lib/api.d.ts +2 -3
- package/dist/lib/api.js +14 -7
- package/dist/lib/auto-detect.js +46 -0
- package/dist/lib/backup.d.ts +121 -0
- package/dist/lib/backup.js +387 -0
- package/dist/lib/canonical.d.ts +1 -1
- package/dist/lib/canonical.js +43 -1
- package/dist/lib/config.d.ts +8 -1
- package/dist/lib/config.js +18 -9
- package/dist/lib/lockfile.d.ts +9 -0
- package/dist/lib/prompts.d.ts +10 -0
- package/dist/lib/prompts.js +36 -12
- package/package.json +27 -7
- package/templates/agents.md +60 -47
- package/templates/ecosystem-prompts/compile-antigravity.md +14 -0
- package/templates/ecosystem-prompts/compile-copilot.md +14 -0
- package/templates/ecosystem-prompts/compile-gemini.md +14 -0
- package/templates/ecosystem-prompts/compile-opencode.md +13 -0
- package/templates/ecosystem-prompts/compile-windsurf.md +13 -0
- package/dist/commands/check-updates.test.d.ts +0 -1
- package/dist/commands/check-updates.test.js +0 -128
- package/dist/commands/clone.d.ts +0 -3
- package/dist/commands/clone.js +0 -70
- package/dist/commands/compile.test.d.ts +0 -1
- package/dist/commands/compile.test.js +0 -110
- package/dist/commands/diff.d.ts +0 -3
- package/dist/commands/diff.js +0 -65
- package/dist/commands/edit.test.d.ts +0 -1
- package/dist/commands/edit.test.js +0 -102
- package/dist/commands/endorse.d.ts +0 -7
- package/dist/commands/endorse.js +0 -70
- package/dist/commands/ingest.test.d.ts +0 -1
- package/dist/commands/ingest.test.js +0 -109
- package/dist/commands/install.test.d.ts +0 -1
- package/dist/commands/install.test.js +0 -253
- package/dist/commands/list.test.d.ts +0 -1
- package/dist/commands/list.test.js +0 -51
- package/dist/commands/publish.test.d.ts +0 -1
- package/dist/commands/publish.test.js +0 -138
- package/dist/commands/pull.d.ts +0 -3
- package/dist/commands/pull.js +0 -78
- package/dist/commands/sync.test.d.ts +0 -1
- package/dist/commands/sync.test.js +0 -263
- package/dist/commands/uninstall.test.d.ts +0 -1
- package/dist/commands/uninstall.test.js +0 -67
- package/dist/lib/auto-detect.test.d.ts +0 -1
- package/dist/lib/auto-detect.test.js +0 -58
- package/dist/lib/canonical.test.d.ts +0 -1
- package/dist/lib/canonical.test.js +0 -48
- package/dist/lib/diff.test.d.ts +0 -1
- package/dist/lib/diff.test.js +0 -28
- package/dist/lib/library-sync.test.d.ts +0 -1
- package/dist/lib/library-sync.test.js +0 -63
- package/dist/lib/llm.test.d.ts +0 -1
- package/dist/lib/llm.test.js +0 -72
- package/dist/lib/lockfile.test.d.ts +0 -1
- package/dist/lib/lockfile.test.js +0 -99
- package/dist/lib/manifest.test.d.ts +0 -1
- package/dist/lib/manifest.test.js +0 -72
- package/dist/lib/shell-hook.test.d.ts +0 -1
- package/dist/lib/shell-hook.test.js +0 -68
- package/dist/test-utils.d.ts +0 -43
- package/dist/test-utils.js +0 -101
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** Only exported so tests can reset between cases. Do NOT call from production
|
|
2
|
+
* code — the whole point of the cache is that one CLI invocation = one folder. */
|
|
3
|
+
export declare function _resetRunTimestampForTests(): void;
|
|
4
|
+
export interface BackupResult {
|
|
5
|
+
/** Absolute path to where the backup landed (or would land, if !ok). */
|
|
6
|
+
dest: string;
|
|
7
|
+
/** Whether the backup succeeded. */
|
|
8
|
+
ok: boolean;
|
|
9
|
+
/** If !ok, the error message to surface to the user. */
|
|
10
|
+
error?: string;
|
|
11
|
+
}
|
|
12
|
+
/** A single file's record in a backup run's `manifest.json` sidecar.
|
|
13
|
+
*
|
|
14
|
+
* The manifest is the source of truth for what each backup file represents.
|
|
15
|
+
* Restoration MUST read from the manifest rather than path-decoding — the
|
|
16
|
+
* global-scope flattening (`/` → `_`) is lossy if filenames contain
|
|
17
|
+
* underscores. */
|
|
18
|
+
export interface BackupManifestEntry {
|
|
19
|
+
/** Original absolute path of the backed-up file. */
|
|
20
|
+
originalPath: string;
|
|
21
|
+
/** Absolute path to where the backup landed. */
|
|
22
|
+
backupPath: string;
|
|
23
|
+
/** "project" if the original was under projectRoot, else "global". */
|
|
24
|
+
scope: 'project' | 'global';
|
|
25
|
+
/** ISO timestamp of when this individual file was backed up. */
|
|
26
|
+
backedUpAt: string;
|
|
27
|
+
}
|
|
28
|
+
export interface BackupManifest {
|
|
29
|
+
/** ISO timestamp shared by all files in this run (matches the folder name). */
|
|
30
|
+
runTimestamp: string;
|
|
31
|
+
files: BackupManifestEntry[];
|
|
32
|
+
}
|
|
33
|
+
/** Compute the backup destination for an existing file without actually
|
|
34
|
+
* copying. Used by --dry-run to print "would back up" lines without touching
|
|
35
|
+
* disk. */
|
|
36
|
+
export declare function backupDestination(absolutePath: string, projectRoot: string, homeDir?: string): string;
|
|
37
|
+
/** Backup an existing file before it's about to be overwritten.
|
|
38
|
+
*
|
|
39
|
+
* - Project-scoped files (under `projectRoot`) → `<projectRoot>/.botdocs-backup/<ts>/<rel>`
|
|
40
|
+
* - Anything else (e.g. global skills under ~/.claude/skills) → `~/.botdocs/backup/<ts>/<absPath-with-/-as-_>`
|
|
41
|
+
*
|
|
42
|
+
* Also appends an entry to the run's `manifest.json` sidecar so `botdocs undo`
|
|
43
|
+
* and `botdocs backups restore` can recover unambiguously even when the
|
|
44
|
+
* flattened global filename is ambiguous.
|
|
45
|
+
*
|
|
46
|
+
* Returns `{ ok: false, error }` rather than throwing so callers can surface a
|
|
47
|
+
* warning and proceed with the overwrite. We never want a backup failure to
|
|
48
|
+
* block the underlying install/sync. */
|
|
49
|
+
export declare function backupFile(absolutePath: string, projectRoot: string, homeDir?: string): BackupResult;
|
|
50
|
+
/** Returns true if the given destination path appears in any install's
|
|
51
|
+
* `files[]` with a fingerprint matching the current on-disk content. That is,
|
|
52
|
+
* "this is a file we wrote and the user hasn't edited it since." Untracked
|
|
53
|
+
* files OR tracked-but-edited files both return false → backup required.
|
|
54
|
+
*
|
|
55
|
+
* The fingerprint check is what distinguishes "we wrote this and own it" from
|
|
56
|
+
* "we wrote this but the user has since edited it" — the latter still deserves
|
|
57
|
+
* a backup before we clobber the edits. */
|
|
58
|
+
export declare function isLockfileOwnedAndUnchanged(dest: string): boolean;
|
|
59
|
+
/** Summary of one backup run, suitable for `botdocs backups list` output. */
|
|
60
|
+
export interface BackupRunSummary {
|
|
61
|
+
/** ISO timestamp shared by every file in the run (matches the folder name). */
|
|
62
|
+
runTimestamp: string;
|
|
63
|
+
/** Where the manifest(s) for this run live: project, global, or both. */
|
|
64
|
+
scope: 'project' | 'global' | 'both';
|
|
65
|
+
/** Total files in the run, across both scopes if both exist. */
|
|
66
|
+
fileCount: number;
|
|
67
|
+
/** Primary run-root directory; the project one if both exist. */
|
|
68
|
+
rootPath: string;
|
|
69
|
+
}
|
|
70
|
+
/** List every backup run across both roots, newest first.
|
|
71
|
+
*
|
|
72
|
+
* Returns the union of timestamps in the project root and the global root.
|
|
73
|
+
* When a timestamp appears in both, it's reported once with `scope: 'both'`. */
|
|
74
|
+
export declare function listBackupRuns(projectRoot: string, homeDir?: string): BackupRunSummary[];
|
|
75
|
+
/** List every file in a specific backup run, across both manifests if present. */
|
|
76
|
+
export declare function listBackupFiles(runTimestamp: string, projectRoot: string, homeDir?: string): BackupManifestEntry[];
|
|
77
|
+
export interface RestoreOptions {
|
|
78
|
+
/** Optional subset filter — restore only entries whose `originalPath` ends
|
|
79
|
+
* with one of these relative paths. Useful for partial-restore from a
|
|
80
|
+
* larger run. */
|
|
81
|
+
files?: string[];
|
|
82
|
+
/** If true, don't write — just compute the plan. */
|
|
83
|
+
dryRun?: boolean;
|
|
84
|
+
/** If true, skip the "back up current state before restoring" step. The
|
|
85
|
+
* default is to back up first so `undo` is itself reversible. */
|
|
86
|
+
noBackup?: boolean;
|
|
87
|
+
}
|
|
88
|
+
export interface RestoreFailure {
|
|
89
|
+
entry: BackupManifestEntry;
|
|
90
|
+
error: string;
|
|
91
|
+
}
|
|
92
|
+
export interface RestoreResult {
|
|
93
|
+
/** Entries that were successfully restored (or would be, under --dry-run). */
|
|
94
|
+
restored: BackupManifestEntry[];
|
|
95
|
+
/** Entries we couldn't restore — backup file missing/unreadable etc.
|
|
96
|
+
* We skip these and continue with the rest of the run. */
|
|
97
|
+
failed: RestoreFailure[];
|
|
98
|
+
/** Original paths whose current state was backed up before the restore.
|
|
99
|
+
* Files that didn't exist at the original path are NOT included here —
|
|
100
|
+
* there was nothing to back up. */
|
|
101
|
+
preBackedUp: string[];
|
|
102
|
+
}
|
|
103
|
+
/** Restore every file recorded in a backup run's manifest to its original path.
|
|
104
|
+
*
|
|
105
|
+
* Before writing each restored file, the CURRENT contents at `originalPath` are
|
|
106
|
+
* themselves backed up under a NEW timestamp so `undo` is reversible — running
|
|
107
|
+
* `botdocs undo` twice in a row swaps the state back. Pass `noBackup: true` to
|
|
108
|
+
* skip the pre-backup (advanced). */
|
|
109
|
+
export declare function restoreBackup(runTimestamp: string, projectRoot: string, options?: RestoreOptions, homeDir?: string): RestoreResult;
|
|
110
|
+
export interface ClearOptions {
|
|
111
|
+
/** Drop runs whose timestamp is older than N days. If omitted, clear ALL runs. */
|
|
112
|
+
olderThanDays?: number;
|
|
113
|
+
/** If true, just compute the plan — don't delete anything. */
|
|
114
|
+
dryRun?: boolean;
|
|
115
|
+
}
|
|
116
|
+
export interface ClearResult {
|
|
117
|
+
cleared: BackupRunSummary[];
|
|
118
|
+
kept: BackupRunSummary[];
|
|
119
|
+
}
|
|
120
|
+
/** Clear backup runs, optionally filtered by age. */
|
|
121
|
+
export declare function clearBackups(projectRoot: string, options?: ClearOptions, homeDir?: string): ClearResult;
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fingerprintFile, loadLockfile } from './lockfile.js';
|
|
5
|
+
let runTimestamp = null;
|
|
6
|
+
/** A single timestamp per CLI invocation, lazily generated on first backup.
|
|
7
|
+
* Cached at the module level so multiple backups in the same `botdocs install`
|
|
8
|
+
* or `botdocs sync` run all land under the same `<ts>` folder, making them
|
|
9
|
+
* easy to inspect and restore as a unit. */
|
|
10
|
+
function getRunTimestamp() {
|
|
11
|
+
if (!runTimestamp) {
|
|
12
|
+
runTimestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
13
|
+
}
|
|
14
|
+
return runTimestamp;
|
|
15
|
+
}
|
|
16
|
+
/** Only exported so tests can reset between cases. Do NOT call from production
|
|
17
|
+
* code — the whole point of the cache is that one CLI invocation = one folder. */
|
|
18
|
+
export function _resetRunTimestampForTests() {
|
|
19
|
+
runTimestamp = null;
|
|
20
|
+
}
|
|
21
|
+
/** Resolve symlinks for the parent directory so comparisons hold up against
|
|
22
|
+
* platform quirks (macOS prefixes `/var/...` with `/private`, etc.). Falls
|
|
23
|
+
* back to the original path if the parent doesn't exist yet. */
|
|
24
|
+
function realParentResolve(p) {
|
|
25
|
+
const abs = path.resolve(p);
|
|
26
|
+
const parent = path.dirname(abs);
|
|
27
|
+
try {
|
|
28
|
+
const realParent = fs.realpathSync(parent);
|
|
29
|
+
return path.join(realParent, path.basename(abs));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return abs;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Determine whether a path is under projectRoot (project-scoped) or not. */
|
|
36
|
+
function isProjectScoped(absolutePath, projectRoot) {
|
|
37
|
+
const normProject = realParentResolve(projectRoot);
|
|
38
|
+
const normAbs = realParentResolve(absolutePath);
|
|
39
|
+
const rel = path.relative(normProject, normAbs);
|
|
40
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
41
|
+
}
|
|
42
|
+
/** Root directory for a backup run's folder. Either:
|
|
43
|
+
* - Project: `<projectRoot>/.botdocs-backup/<ts>`
|
|
44
|
+
* - Global: `<homeDir>/.botdocs/backup/<ts>` */
|
|
45
|
+
function backupRunRoot(scope, ts, projectRoot, homeDir) {
|
|
46
|
+
if (scope === 'project') {
|
|
47
|
+
return path.join(realParentResolve(projectRoot), '.botdocs-backup', ts);
|
|
48
|
+
}
|
|
49
|
+
return path.join(homeDir, '.botdocs', 'backup', ts);
|
|
50
|
+
}
|
|
51
|
+
/** Compute the backup destination for an existing file without actually
|
|
52
|
+
* copying. Used by --dry-run to print "would back up" lines without touching
|
|
53
|
+
* disk. */
|
|
54
|
+
export function backupDestination(absolutePath, projectRoot, homeDir = os.homedir()) {
|
|
55
|
+
const ts = getRunTimestamp();
|
|
56
|
+
const normAbs = realParentResolve(absolutePath);
|
|
57
|
+
if (isProjectScoped(absolutePath, projectRoot)) {
|
|
58
|
+
const normProject = realParentResolve(projectRoot);
|
|
59
|
+
const rel = path.relative(normProject, normAbs);
|
|
60
|
+
return path.join(normProject, '.botdocs-backup', ts, rel);
|
|
61
|
+
}
|
|
62
|
+
// Global-scoped: flatten the absolute path into a single filename by
|
|
63
|
+
// replacing path separators with `_`. Predictable, but lossy if filenames
|
|
64
|
+
// already contain `_` — the manifest.json sidecar is the source of truth
|
|
65
|
+
// for restoration.
|
|
66
|
+
const flattened = normAbs.replace(/[/\\]/g, '_');
|
|
67
|
+
return path.join(homeDir, '.botdocs', 'backup', ts, flattened);
|
|
68
|
+
}
|
|
69
|
+
/** Read a manifest.json from disk, returning a fresh empty manifest if the
|
|
70
|
+
* file doesn't exist or can't be parsed. */
|
|
71
|
+
function readManifest(manifestPath, ts) {
|
|
72
|
+
if (!fs.existsSync(manifestPath)) {
|
|
73
|
+
return { runTimestamp: ts, files: [] };
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
|
77
|
+
const parsed = JSON.parse(raw);
|
|
78
|
+
if (typeof parsed === 'object' &&
|
|
79
|
+
parsed !== null &&
|
|
80
|
+
typeof parsed.runTimestamp === 'string' &&
|
|
81
|
+
Array.isArray(parsed.files)) {
|
|
82
|
+
return parsed;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Fall through to a fresh manifest — better to lose one stale corrupt
|
|
87
|
+
// file than block all future backups in this folder.
|
|
88
|
+
}
|
|
89
|
+
return { runTimestamp: ts, files: [] };
|
|
90
|
+
}
|
|
91
|
+
/** Write the manifest atomically: write to `<path>.tmp` then rename. Protects
|
|
92
|
+
* against partial writes if the CLI is interrupted mid-backup. */
|
|
93
|
+
function writeManifestAtomic(manifestPath, manifest) {
|
|
94
|
+
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
|
|
95
|
+
const tmp = `${manifestPath}.tmp`;
|
|
96
|
+
fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2), 'utf-8');
|
|
97
|
+
fs.renameSync(tmp, manifestPath);
|
|
98
|
+
}
|
|
99
|
+
/** Append a single entry to the appropriate manifest.json for this run. */
|
|
100
|
+
function appendToManifest(entry, projectRoot, homeDir) {
|
|
101
|
+
const ts = getRunTimestamp();
|
|
102
|
+
const runRoot = backupRunRoot(entry.scope, ts, projectRoot, homeDir);
|
|
103
|
+
const manifestPath = path.join(runRoot, 'manifest.json');
|
|
104
|
+
const manifest = readManifest(manifestPath, ts);
|
|
105
|
+
manifest.files.push(entry);
|
|
106
|
+
writeManifestAtomic(manifestPath, manifest);
|
|
107
|
+
}
|
|
108
|
+
/** Backup an existing file before it's about to be overwritten.
|
|
109
|
+
*
|
|
110
|
+
* - Project-scoped files (under `projectRoot`) → `<projectRoot>/.botdocs-backup/<ts>/<rel>`
|
|
111
|
+
* - Anything else (e.g. global skills under ~/.claude/skills) → `~/.botdocs/backup/<ts>/<absPath-with-/-as-_>`
|
|
112
|
+
*
|
|
113
|
+
* Also appends an entry to the run's `manifest.json` sidecar so `botdocs undo`
|
|
114
|
+
* and `botdocs backups restore` can recover unambiguously even when the
|
|
115
|
+
* flattened global filename is ambiguous.
|
|
116
|
+
*
|
|
117
|
+
* Returns `{ ok: false, error }` rather than throwing so callers can surface a
|
|
118
|
+
* warning and proceed with the overwrite. We never want a backup failure to
|
|
119
|
+
* block the underlying install/sync. */
|
|
120
|
+
export function backupFile(absolutePath, projectRoot, homeDir = os.homedir()) {
|
|
121
|
+
const dest = backupDestination(absolutePath, projectRoot, homeDir);
|
|
122
|
+
// No-op if the source doesn't exist — nothing to back up. We return ok:true
|
|
123
|
+
// so callers don't print a spurious warning.
|
|
124
|
+
if (!fs.existsSync(absolutePath)) {
|
|
125
|
+
return { dest, ok: true };
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
129
|
+
fs.copyFileSync(absolutePath, dest);
|
|
130
|
+
// Record the canonical original→backup mapping in the run's manifest so
|
|
131
|
+
// restoration doesn't have to round-trip through the lossy flattening.
|
|
132
|
+
const scope = isProjectScoped(absolutePath, projectRoot)
|
|
133
|
+
? 'project'
|
|
134
|
+
: 'global';
|
|
135
|
+
appendToManifest({
|
|
136
|
+
originalPath: realParentResolve(absolutePath),
|
|
137
|
+
backupPath: dest,
|
|
138
|
+
scope,
|
|
139
|
+
backedUpAt: new Date().toISOString(),
|
|
140
|
+
}, projectRoot, homeDir);
|
|
141
|
+
return { dest, ok: true };
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
145
|
+
return { dest, ok: false, error: message };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** Returns true if the given destination path appears in any install's
|
|
149
|
+
* `files[]` with a fingerprint matching the current on-disk content. That is,
|
|
150
|
+
* "this is a file we wrote and the user hasn't edited it since." Untracked
|
|
151
|
+
* files OR tracked-but-edited files both return false → backup required.
|
|
152
|
+
*
|
|
153
|
+
* The fingerprint check is what distinguishes "we wrote this and own it" from
|
|
154
|
+
* "we wrote this but the user has since edited it" — the latter still deserves
|
|
155
|
+
* a backup before we clobber the edits. */
|
|
156
|
+
export function isLockfileOwnedAndUnchanged(dest) {
|
|
157
|
+
if (!fs.existsSync(dest))
|
|
158
|
+
return false;
|
|
159
|
+
const lf = loadLockfile();
|
|
160
|
+
let currentFp;
|
|
161
|
+
try {
|
|
162
|
+
currentFp = fingerprintFile(dest);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// If we can't read the file to fingerprint it, treat as unowned —
|
|
166
|
+
// caller will attempt a backup and that path will surface the error.
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
for (const install of lf.installs) {
|
|
170
|
+
for (const file of install.files) {
|
|
171
|
+
if (file.dest === dest && file.fingerprint === currentFp) {
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
/** Path to the project-scope backup root (the parent of all `<ts>` folders). */
|
|
179
|
+
function projectBackupRoot(projectRoot) {
|
|
180
|
+
return path.join(realParentResolve(projectRoot), '.botdocs-backup');
|
|
181
|
+
}
|
|
182
|
+
/** Path to the global-scope backup root. */
|
|
183
|
+
function globalBackupRoot(homeDir) {
|
|
184
|
+
return path.join(homeDir, '.botdocs', 'backup');
|
|
185
|
+
}
|
|
186
|
+
/** List every timestamp folder under a backup root, returning their basenames. */
|
|
187
|
+
function listTimestampsIn(root) {
|
|
188
|
+
if (!fs.existsSync(root))
|
|
189
|
+
return [];
|
|
190
|
+
let entries;
|
|
191
|
+
try {
|
|
192
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
return entries
|
|
198
|
+
.filter((e) => e.isDirectory())
|
|
199
|
+
.map((e) => e.name);
|
|
200
|
+
}
|
|
201
|
+
/** Load the manifest at `<runRoot>/manifest.json` if it exists. */
|
|
202
|
+
function loadManifest(runRoot) {
|
|
203
|
+
const manifestPath = path.join(runRoot, 'manifest.json');
|
|
204
|
+
if (!fs.existsSync(manifestPath))
|
|
205
|
+
return null;
|
|
206
|
+
try {
|
|
207
|
+
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
|
208
|
+
const parsed = JSON.parse(raw);
|
|
209
|
+
if (typeof parsed === 'object' &&
|
|
210
|
+
parsed !== null &&
|
|
211
|
+
typeof parsed.runTimestamp === 'string' &&
|
|
212
|
+
Array.isArray(parsed.files)) {
|
|
213
|
+
return parsed;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
/** List every backup run across both roots, newest first.
|
|
222
|
+
*
|
|
223
|
+
* Returns the union of timestamps in the project root and the global root.
|
|
224
|
+
* When a timestamp appears in both, it's reported once with `scope: 'both'`. */
|
|
225
|
+
export function listBackupRuns(projectRoot, homeDir = os.homedir()) {
|
|
226
|
+
const projectTs = new Set(listTimestampsIn(projectBackupRoot(projectRoot)));
|
|
227
|
+
const globalTs = new Set(listTimestampsIn(globalBackupRoot(homeDir)));
|
|
228
|
+
const all = new Set([...projectTs, ...globalTs]);
|
|
229
|
+
const summaries = [];
|
|
230
|
+
for (const ts of all) {
|
|
231
|
+
const inProject = projectTs.has(ts);
|
|
232
|
+
const inGlobal = globalTs.has(ts);
|
|
233
|
+
let scope;
|
|
234
|
+
let rootPath;
|
|
235
|
+
let fileCount;
|
|
236
|
+
if (inProject && inGlobal) {
|
|
237
|
+
scope = 'both';
|
|
238
|
+
rootPath = path.join(projectBackupRoot(projectRoot), ts);
|
|
239
|
+
const pm = loadManifest(path.join(projectBackupRoot(projectRoot), ts));
|
|
240
|
+
const gm = loadManifest(path.join(globalBackupRoot(homeDir), ts));
|
|
241
|
+
fileCount = (pm?.files.length ?? 0) + (gm?.files.length ?? 0);
|
|
242
|
+
}
|
|
243
|
+
else if (inProject) {
|
|
244
|
+
scope = 'project';
|
|
245
|
+
rootPath = path.join(projectBackupRoot(projectRoot), ts);
|
|
246
|
+
fileCount = loadManifest(rootPath)?.files.length ?? 0;
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
scope = 'global';
|
|
250
|
+
rootPath = path.join(globalBackupRoot(homeDir), ts);
|
|
251
|
+
fileCount = loadManifest(rootPath)?.files.length ?? 0;
|
|
252
|
+
}
|
|
253
|
+
summaries.push({ runTimestamp: ts, scope, fileCount, rootPath });
|
|
254
|
+
}
|
|
255
|
+
// ISO timestamp folder names sort lexicographically into chronological order.
|
|
256
|
+
summaries.sort((a, b) => (a.runTimestamp < b.runTimestamp ? 1 : -1));
|
|
257
|
+
return summaries;
|
|
258
|
+
}
|
|
259
|
+
/** List every file in a specific backup run, across both manifests if present. */
|
|
260
|
+
export function listBackupFiles(runTimestamp, projectRoot, homeDir = os.homedir()) {
|
|
261
|
+
const projectManifest = loadManifest(path.join(projectBackupRoot(projectRoot), runTimestamp));
|
|
262
|
+
const globalManifest = loadManifest(path.join(globalBackupRoot(homeDir), runTimestamp));
|
|
263
|
+
const entries = [];
|
|
264
|
+
if (projectManifest)
|
|
265
|
+
entries.push(...projectManifest.files);
|
|
266
|
+
if (globalManifest)
|
|
267
|
+
entries.push(...globalManifest.files);
|
|
268
|
+
return entries;
|
|
269
|
+
}
|
|
270
|
+
/** Match an entry against the user's `--files a,b,c` filter. We do a suffix
|
|
271
|
+
* match so the caller can pass `.cursor/rules/foo.mdc` without having to know
|
|
272
|
+
* the absolute path. Empty filter = match everything. */
|
|
273
|
+
function matchesFileFilter(entry, filters) {
|
|
274
|
+
if (!filters || filters.length === 0)
|
|
275
|
+
return true;
|
|
276
|
+
return filters.some((f) => entry.originalPath.endsWith(f));
|
|
277
|
+
}
|
|
278
|
+
/** Restore every file recorded in a backup run's manifest to its original path.
|
|
279
|
+
*
|
|
280
|
+
* Before writing each restored file, the CURRENT contents at `originalPath` are
|
|
281
|
+
* themselves backed up under a NEW timestamp so `undo` is reversible — running
|
|
282
|
+
* `botdocs undo` twice in a row swaps the state back. Pass `noBackup: true` to
|
|
283
|
+
* skip the pre-backup (advanced). */
|
|
284
|
+
export function restoreBackup(runTimestamp, projectRoot, options = {}, homeDir = os.homedir()) {
|
|
285
|
+
const all = listBackupFiles(runTimestamp, projectRoot, homeDir);
|
|
286
|
+
const entries = all.filter((e) => matchesFileFilter(e, options.files));
|
|
287
|
+
const restored = [];
|
|
288
|
+
const failed = [];
|
|
289
|
+
const preBackedUp = [];
|
|
290
|
+
for (const entry of entries) {
|
|
291
|
+
if (!fs.existsSync(entry.backupPath)) {
|
|
292
|
+
failed.push({ entry, error: `backup file missing: ${entry.backupPath}` });
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
let content;
|
|
296
|
+
try {
|
|
297
|
+
content = fs.readFileSync(entry.backupPath);
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
301
|
+
failed.push({ entry, error: `read failed: ${message}` });
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (options.dryRun) {
|
|
305
|
+
restored.push(entry);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
// Pre-backup the current state at originalPath so this restore is itself
|
|
309
|
+
// reversible. Skip if --no-backup, or if the file doesn't exist (nothing
|
|
310
|
+
// to back up). The pre-backup writes to a new run-timestamp folder.
|
|
311
|
+
if (!options.noBackup && fs.existsSync(entry.originalPath)) {
|
|
312
|
+
const result = backupFile(entry.originalPath, projectRoot, homeDir);
|
|
313
|
+
if (result.ok) {
|
|
314
|
+
preBackedUp.push(entry.originalPath);
|
|
315
|
+
}
|
|
316
|
+
// If pre-backup fails, we proceed with the restore — the user asked
|
|
317
|
+
// for the restore, not the pre-backup, and we don't want a permissions
|
|
318
|
+
// hiccup on the pre-backup to block recovery.
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
fs.mkdirSync(path.dirname(entry.originalPath), { recursive: true });
|
|
322
|
+
fs.writeFileSync(entry.originalPath, content);
|
|
323
|
+
restored.push(entry);
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
327
|
+
failed.push({ entry, error: `write failed: ${message}` });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return { restored, failed, preBackedUp };
|
|
331
|
+
}
|
|
332
|
+
/** Parse the folder name back into a real Date for `--older-than-days` math.
|
|
333
|
+
* The folder name is an ISO timestamp with `:` and `.` replaced by `-`, so
|
|
334
|
+
* undo: every `-` that comes after the first `T` gets turned back. */
|
|
335
|
+
function parseRunTimestamp(ts) {
|
|
336
|
+
// Format: 2026-05-13T12-34-56-789Z
|
|
337
|
+
const tIndex = ts.indexOf('T');
|
|
338
|
+
if (tIndex < 0)
|
|
339
|
+
return null;
|
|
340
|
+
const datePart = ts.slice(0, tIndex);
|
|
341
|
+
const timePart = ts.slice(tIndex + 1);
|
|
342
|
+
// timePart: 12-34-56-789Z → 12:34:56.789Z
|
|
343
|
+
const m = timePart.match(/^(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/);
|
|
344
|
+
if (!m)
|
|
345
|
+
return null;
|
|
346
|
+
const iso = `${datePart}T${m[1]}:${m[2]}:${m[3]}.${m[4]}Z`;
|
|
347
|
+
const d = new Date(iso);
|
|
348
|
+
if (Number.isNaN(d.getTime()))
|
|
349
|
+
return null;
|
|
350
|
+
return d;
|
|
351
|
+
}
|
|
352
|
+
/** Clear backup runs, optionally filtered by age. */
|
|
353
|
+
export function clearBackups(projectRoot, options = {}, homeDir = os.homedir()) {
|
|
354
|
+
const all = listBackupRuns(projectRoot, homeDir);
|
|
355
|
+
const cleared = [];
|
|
356
|
+
const kept = [];
|
|
357
|
+
const cutoff = options.olderThanDays !== undefined
|
|
358
|
+
? Date.now() - options.olderThanDays * 86_400_000
|
|
359
|
+
: null;
|
|
360
|
+
for (const run of all) {
|
|
361
|
+
let shouldClear = cutoff === null;
|
|
362
|
+
if (cutoff !== null) {
|
|
363
|
+
const runDate = parseRunTimestamp(run.runTimestamp);
|
|
364
|
+
shouldClear = runDate !== null && runDate.getTime() < cutoff;
|
|
365
|
+
}
|
|
366
|
+
if (shouldClear) {
|
|
367
|
+
if (!options.dryRun) {
|
|
368
|
+
// Remove BOTH the project-side and global-side folders for this
|
|
369
|
+
// timestamp if they exist. listBackupRuns merges them into a single
|
|
370
|
+
// summary, but the on-disk folders are separate.
|
|
371
|
+
const projectDir = path.join(projectBackupRoot(projectRoot), run.runTimestamp);
|
|
372
|
+
const globalDir = path.join(globalBackupRoot(homeDir), run.runTimestamp);
|
|
373
|
+
if (fs.existsSync(projectDir)) {
|
|
374
|
+
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
375
|
+
}
|
|
376
|
+
if (fs.existsSync(globalDir)) {
|
|
377
|
+
fs.rmSync(globalDir, { recursive: true, force: true });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
cleared.push(run);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
kept.push(run);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return { cleared, kept };
|
|
387
|
+
}
|
package/dist/lib/canonical.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type Ecosystem = 'claude' | 'claude-code' | 'cursor' | 'chatgpt' | 'codex';
|
|
1
|
+
export type Ecosystem = 'claude' | 'claude-code' | 'cursor' | 'chatgpt' | 'codex' | 'copilot' | 'antigravity' | 'gemini' | 'opencode' | 'windsurf';
|
|
2
2
|
export declare const SUPPORTED_ECOSYSTEMS: Ecosystem[];
|
|
3
3
|
export declare function autoDetectSourceEcosystem(skillRoot: string): Ecosystem | null;
|
|
4
4
|
export declare function ecosystemDestination(eco: Ecosystem, slug: string): string;
|
package/dist/lib/canonical.js
CHANGED
|
@@ -1,12 +1,31 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
export const SUPPORTED_ECOSYSTEMS = [
|
|
3
|
+
export const SUPPORTED_ECOSYSTEMS = [
|
|
4
|
+
'claude',
|
|
5
|
+
'claude-code',
|
|
6
|
+
'cursor',
|
|
7
|
+
'chatgpt',
|
|
8
|
+
'codex',
|
|
9
|
+
'copilot',
|
|
10
|
+
'antigravity',
|
|
11
|
+
'gemini',
|
|
12
|
+
'opencode',
|
|
13
|
+
'windsurf',
|
|
14
|
+
];
|
|
15
|
+
// Source-of-truth for each path is documented in `ecosystemDestination`
|
|
16
|
+
// below; this map only governs which on-disk directory we look at when
|
|
17
|
+
// auto-detecting the source ecosystem of a skill.
|
|
4
18
|
const ECOSYSTEM_FILE_GLOB = {
|
|
5
19
|
claude: ['claude/SKILL.md'],
|
|
6
20
|
'claude-code': ['claude-code/commands'],
|
|
7
21
|
cursor: ['cursor/rules'],
|
|
8
22
|
chatgpt: ['chatgpt'],
|
|
9
23
|
codex: ['codex'],
|
|
24
|
+
copilot: ['copilot/instructions'],
|
|
25
|
+
antigravity: ['antigravity/skills'],
|
|
26
|
+
gemini: ['gemini/instructions'],
|
|
27
|
+
opencode: ['opencode/instructions'],
|
|
28
|
+
windsurf: ['windsurf/rules'],
|
|
10
29
|
};
|
|
11
30
|
function readSize(filePath) {
|
|
12
31
|
if (!fs.existsSync(filePath))
|
|
@@ -50,6 +69,29 @@ export function ecosystemDestination(eco, slug) {
|
|
|
50
69
|
return `chatgpt/${slug}.md`;
|
|
51
70
|
case 'codex':
|
|
52
71
|
return `codex/${slug}.md`;
|
|
72
|
+
case 'copilot':
|
|
73
|
+
// GitHub Copilot custom instructions:
|
|
74
|
+
// https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot
|
|
75
|
+
return `copilot/instructions/${slug}.instructions.md`;
|
|
76
|
+
case 'antigravity':
|
|
77
|
+
// Google Antigravity skills live under the gemini config tree at
|
|
78
|
+
// ~/.gemini/antigravity/skills/<slug>.md — see install destination in
|
|
79
|
+
// `auto-detect.ts`. The source mirror is `antigravity/skills/<slug>.md`.
|
|
80
|
+
return `antigravity/skills/${slug}.md`;
|
|
81
|
+
case 'gemini':
|
|
82
|
+
// Gemini CLI reads markdown instructions from ~/.gemini/instructions/
|
|
83
|
+
// (https://github.com/google-gemini/gemini-cli). Source path mirrors
|
|
84
|
+
// the install destination.
|
|
85
|
+
return `gemini/instructions/${slug}.md`;
|
|
86
|
+
case 'opencode':
|
|
87
|
+
// OpenCode (SST) reads markdown instructions from
|
|
88
|
+
// ~/.config/opencode/instructions/ (https://github.com/sst/opencode).
|
|
89
|
+
return `opencode/instructions/${slug}.md`;
|
|
90
|
+
case 'windsurf':
|
|
91
|
+
// Windsurf (Codeium) project rules:
|
|
92
|
+
// https://docs.codeium.com/windsurf/cascade#windsurfrules — files live
|
|
93
|
+
// under .codeium/windsurf-rules/ in each project.
|
|
94
|
+
return `windsurf/rules/${slug}.md`;
|
|
53
95
|
}
|
|
54
96
|
}
|
|
55
97
|
export function readSourceContent(skillRoot, sourceEcosystem, slug) {
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
+
/** Persistent CLI credentials.
|
|
2
|
+
*
|
|
3
|
+
* `token` is an opaque api_tokens value (bd_<hex>) issued by the web app —
|
|
4
|
+
* either via /settings/tokens or via the browser-mediated /cli-auth flow.
|
|
5
|
+
* Earlier CLI versions stored a GitHub access token under `githubToken`; that
|
|
6
|
+
* field is gone and any stale auth.json file will be ignored (the server
|
|
7
|
+
* 401s and the user is told to run `botdocs login` again). */
|
|
1
8
|
interface AuthConfig {
|
|
2
|
-
|
|
9
|
+
token: string;
|
|
3
10
|
username: string;
|
|
4
11
|
displayName: string;
|
|
5
12
|
syncLibrary?: boolean;
|
package/dist/lib/config.js
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
// Resolved lazily so tests can swap os.homedir between cases without having
|
|
5
|
+
// to monkey-patch a captured constant.
|
|
6
|
+
function getConfigDir() {
|
|
7
|
+
return path.join(os.homedir(), '.botdocs');
|
|
8
|
+
}
|
|
9
|
+
function getAuthFile() {
|
|
10
|
+
return path.join(getConfigDir(), 'auth.json');
|
|
11
|
+
}
|
|
6
12
|
function ensureConfigDir() {
|
|
7
|
-
|
|
8
|
-
|
|
13
|
+
const dir = getConfigDir();
|
|
14
|
+
if (!fs.existsSync(dir)) {
|
|
15
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
9
16
|
}
|
|
10
17
|
}
|
|
11
18
|
export function saveAuth(config) {
|
|
12
19
|
ensureConfigDir();
|
|
13
|
-
fs.writeFileSync(
|
|
20
|
+
fs.writeFileSync(getAuthFile(), JSON.stringify(config, null, 2), 'utf-8');
|
|
14
21
|
}
|
|
15
22
|
export function loadAuth() {
|
|
16
|
-
|
|
23
|
+
const file = getAuthFile();
|
|
24
|
+
if (!fs.existsSync(file))
|
|
17
25
|
return null;
|
|
18
26
|
try {
|
|
19
|
-
const data = fs.readFileSync(
|
|
27
|
+
const data = fs.readFileSync(file, 'utf-8');
|
|
20
28
|
return JSON.parse(data);
|
|
21
29
|
}
|
|
22
30
|
catch {
|
|
@@ -24,7 +32,8 @@ export function loadAuth() {
|
|
|
24
32
|
}
|
|
25
33
|
}
|
|
26
34
|
export function clearAuth() {
|
|
27
|
-
|
|
28
|
-
|
|
35
|
+
const file = getAuthFile();
|
|
36
|
+
if (fs.existsSync(file)) {
|
|
37
|
+
fs.unlinkSync(file);
|
|
29
38
|
}
|
|
30
39
|
}
|
package/dist/lib/lockfile.d.ts
CHANGED
|
@@ -11,6 +11,15 @@ export interface InstalledRef {
|
|
|
11
11
|
files: InstalledFile[];
|
|
12
12
|
/** For bundles: the refs they expanded into. */
|
|
13
13
|
skills?: string[];
|
|
14
|
+
/** Where this install came from. Defaults to a personal install when absent.
|
|
15
|
+
* Used by `botdocs sync` to identify team-pinned skills (which are managed
|
|
16
|
+
* by the team and may be pinned to a specific version). */
|
|
17
|
+
source?: {
|
|
18
|
+
type: 'personal';
|
|
19
|
+
} | {
|
|
20
|
+
type: 'team';
|
|
21
|
+
slug: string;
|
|
22
|
+
};
|
|
14
23
|
}
|
|
15
24
|
export interface Lockfile {
|
|
16
25
|
version: 1;
|
package/dist/lib/prompts.d.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
export type CleanUpdateChoice = 'apply' | 'skip' | 'diff';
|
|
2
|
+
/** Prompt for a clean-update decision (upstream has new content; local is
|
|
3
|
+
* unchanged since last install). The "diff" option lets the user inspect the
|
|
4
|
+
* change before deciding; callers should loop on "diff" themselves. */
|
|
2
5
|
export declare function promptCleanUpdate(label: string): Promise<CleanUpdateChoice>;
|
|
3
6
|
export type ConflictChoice = 'skip' | 'overwrite';
|
|
7
|
+
/** Prompt for a conflict resolution when local has diverged from the
|
|
8
|
+
* lockfile fingerprint AND upstream has new content. Distinct from
|
|
9
|
+
* `promptCleanUpdate` because the user's edits are at risk; the destructive
|
|
10
|
+
* option ("overwrite") is gated behind `confirmOverwrite` below. */
|
|
4
11
|
export declare function promptConflict(label: string): Promise<ConflictChoice>;
|
|
12
|
+
/** Second confirmation for destructive overwrites — the user has already said
|
|
13
|
+
* "overwrite" via `promptConflict`, but we want a y/N before clobbering their
|
|
14
|
+
* edits. Defaults to false so a stray Enter doesn't blow away work. */
|
|
5
15
|
export declare function confirmOverwrite(label: string): Promise<boolean>;
|