skillcellar 0.3.0 → 0.6.1
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 +26 -2
- package/package.json +2 -2
- package/skill/SKILL.md +18 -9
- package/src/backup.mjs +609 -0
- package/src/check.mjs +59 -0
- package/src/cli.mjs +185 -22
- package/src/config.mjs +1 -1
- package/src/files.mjs +13 -7
- package/src/mcp.mjs +1 -1
- package/src/plan.mjs +36 -11
- package/src/playbook.mjs +164 -0
- package/src/project.mjs +15 -0
- package/src/restore.mjs +352 -0
- package/src/scan.mjs +31 -15
- package/src/secrets.mjs +75 -0
package/src/backup.mjs
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { lstat, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, extname, join, relative } from "node:path";
|
|
5
|
+
import { configPath } from "./config.mjs";
|
|
6
|
+
import { markdownFiles } from "./files.mjs";
|
|
7
|
+
import { sessionFolderName } from "./places.mjs";
|
|
8
|
+
import { projectIdentity, projectSlug } from "./plan.mjs";
|
|
9
|
+
import { skillFolders } from "./scan.mjs";
|
|
10
|
+
import { findSecret } from "./secrets.mjs";
|
|
11
|
+
|
|
12
|
+
// A backup holds text files of these kinds, each under this size; folders of installed packages,
|
|
13
|
+
// caches and build output are left out, and the CLI lists what it left out.
|
|
14
|
+
export const FILE_BYTES_MAX = 1024 * 1024;
|
|
15
|
+
// A backup holds at most this many files and bytes, read into memory before the push.
|
|
16
|
+
export const FILES_MAX = 20_000;
|
|
17
|
+
export const BYTES_MAX = 256 * 1024 * 1024;
|
|
18
|
+
export const SKIPPED_FOLDERS = new Set([
|
|
19
|
+
"node_modules", ".venv", "venv", "env", "__pycache__", ".git", ".cache", "dist", "build", "ms-playwright",
|
|
20
|
+
".next", ".turbo", ".nuxt", ".svelte-kit", "target", "vendor", "coverage", ".tox", ".mypy_cache", ".pytest_cache", ".ruff_cache", ".gradle",
|
|
21
|
+
]);
|
|
22
|
+
const TEXT_EXTENSIONS = new Set(
|
|
23
|
+
"md mdx txt rst json jsonc json5 yaml yml toml ini cfg conf properties xml html htm css scss sass less svg js mjs cjs jsx ts mts cts tsx py pyi rb php go rs java kt kts scala swift c h cc cpp hpp cs sh bash zsh fish ps1 psm1 bat cmd sql graphql gql proto csv tsv lock".split(
|
|
24
|
+
" ",
|
|
25
|
+
),
|
|
26
|
+
);
|
|
27
|
+
const TEXT_NAMES = new Set(["Makefile", "Dockerfile", "LICENSE", "LICENCE", "README", "CHANGELOG", "NOTICE", "AUTHORS", "CODEOWNERS", "Procfile", ".gitignore", ".gitattributes", ".editorconfig"]);
|
|
28
|
+
const DEPTH_MAX = 32;
|
|
29
|
+
// A name the sync sends, and so a folder a backup names: the same shape, so a restore can match them.
|
|
30
|
+
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$/;
|
|
31
|
+
const GITHUB_NAME_RE = /^[A-Za-z0-9_.-]{1,100}$/;
|
|
32
|
+
|
|
33
|
+
/** Where GitHub is reached: its API to ask whether a repository is public, and its git host to clone from. Stand-ins are read only under NODE_ENV=test. */
|
|
34
|
+
const testing = () => process.env.NODE_ENV === "test";
|
|
35
|
+
const githubApi = () => (testing() && process.env.SKILLCELLAR_GITHUB_API?.replace(/\/$/, "")) || "https://api.github.com";
|
|
36
|
+
const githubGit = () => (testing() && process.env.SKILLCELLAR_GITHUB_GIT?.replace(/\/$/, "")) || "https://github.com";
|
|
37
|
+
|
|
38
|
+
/** The clone of a backup repository, beside the config: readable by its owner only. */
|
|
39
|
+
export const clonePath = (owner, repo) => join(dirname(configPath()), "backups", owner, repo);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A repository as `--repo` names it: owner/repo, its https address, or its git@github.com address,
|
|
43
|
+
* which chooses SSH. Null for anything else. The name is kept lowercase, as the site keeps it.
|
|
44
|
+
*/
|
|
45
|
+
export function parseBackupRepo(value, ssh = false) {
|
|
46
|
+
const text = String(value ?? "").trim();
|
|
47
|
+
const match = /^(?:https:\/\/(?:www\.)?github\.com\/|(git@github\.com:))?([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(text);
|
|
48
|
+
if (!match) return null;
|
|
49
|
+
const [, scp, owner, repo] = match;
|
|
50
|
+
if (![owner, repo].every((name) => GITHUB_NAME_RE.test(name) && name !== "." && name !== "..")) return null;
|
|
51
|
+
const name = `${owner.toLowerCase()}/${repo.toLowerCase()}`;
|
|
52
|
+
const useSsh = ssh || scp !== undefined;
|
|
53
|
+
return { owner: owner.toLowerCase(), repo: repo.toLowerCase(), name, ssh: useSsh, url: useSsh ? `git@github.com:${owner}/${repo}.git` : `${githubGit()}/${owner}/${repo}.git` };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Refuses a repository that answers an anonymous request, since that makes it public, and one
|
|
58
|
+
* GitHub would not say is private. The request carries no token, so it sees what anyone sees.
|
|
59
|
+
*/
|
|
60
|
+
export async function refusePublic(repo, version) {
|
|
61
|
+
let response;
|
|
62
|
+
try {
|
|
63
|
+
response = await fetch(`${githubApi()}/repos/${repo.owner}/${repo.repo}`, {
|
|
64
|
+
headers: { accept: "application/vnd.github+json", "user-agent": `skillcellar/${version}` },
|
|
65
|
+
redirect: "manual",
|
|
66
|
+
signal: AbortSignal.timeout(15_000),
|
|
67
|
+
});
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw new Error(`Could not ask GitHub whether ${repo.name} is private: ${error?.cause?.code ?? error?.name ?? "network error"}. Nothing was pushed.`);
|
|
70
|
+
}
|
|
71
|
+
if (response.status === 404) {
|
|
72
|
+
// GitHub answers a private repository with its own "Not Found" body; a proxy or a captive portal answering 404 does not pass.
|
|
73
|
+
const body = await response.json().catch(() => null);
|
|
74
|
+
if (body && typeof body.message === "string" && /not found/i.test(body.message)) return;
|
|
75
|
+
throw new Error(`GitHub did not answer as GitHub does about ${repo.name}. Check the network, then try again. Nothing was pushed.`);
|
|
76
|
+
}
|
|
77
|
+
if (response.status === 200) {
|
|
78
|
+
throw new Error(`${repo.name} is public on GitHub, and a backup holds your memory files and configuration: back up to a private repository you own. Nothing was pushed.`);
|
|
79
|
+
}
|
|
80
|
+
throw new Error(`GitHub did not say whether ${repo.name} is private (it answered ${response.status}). Try again later. Nothing was pushed.`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Runs git, answering its output; on failure, an error with git's own first line of complaint. */
|
|
84
|
+
export function git(args, { cwd, timeout = 120_000 } = {}) {
|
|
85
|
+
return new Promise((done, fail) => {
|
|
86
|
+
execFile("git", args, { cwd, timeout, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, NoDefaultCurrentDirectoryInExePath: "1" } }, (error, stdout, stderr) => {
|
|
87
|
+
if (!error) return done(stdout);
|
|
88
|
+
const line = String(stderr || error.message)
|
|
89
|
+
.split("\n")
|
|
90
|
+
.map((text) => text.trim())
|
|
91
|
+
.filter((text) => text && !text.startsWith("hint:"))
|
|
92
|
+
.at(-1);
|
|
93
|
+
fail(new Error(`git ${args[0]} failed: ${line ?? "unknown error"}`));
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Runs git and answers its output as bytes, for a blob. */
|
|
99
|
+
export function gitBuffer(args, { cwd, timeout = 120_000 } = {}) {
|
|
100
|
+
return new Promise((done, fail) => {
|
|
101
|
+
execFile("git", args, { cwd, timeout, maxBuffer: 64 * 1024 * 1024, encoding: "buffer", env: { ...process.env, NoDefaultCurrentDirectoryInExePath: "1" } }, (error, stdout, stderr) => {
|
|
102
|
+
if (!error) return done(stdout);
|
|
103
|
+
fail(new Error(`git ${args[0]} failed: ${String(stderr).trim().split("\n").at(-1) || "unknown error"}`));
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const isTextName = (name) => TEXT_NAMES.has(name) || TEXT_EXTENSIONS.has(extname(name).slice(1).toLowerCase());
|
|
109
|
+
const isBinary = (content) => content.subarray(0, 8192).includes(0);
|
|
110
|
+
const size = (bytes) => (bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.ceil(bytes / 1024)} KB`);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The files of a folder a backup takes, each with its path under `into` and its bytes, and what it
|
|
114
|
+
* left out: skipped folders at any depth, links, files over the size cap, files of other kinds and
|
|
115
|
+
* binary files. Only regular files are read.
|
|
116
|
+
*/
|
|
117
|
+
/** Counts what a backup has read so far, and stops it past the caps. */
|
|
118
|
+
export const newBudget = () => ({ files: 0, bytes: 0 });
|
|
119
|
+
function spend(budget, bytes) {
|
|
120
|
+
budget.files += 1;
|
|
121
|
+
budget.bytes += bytes;
|
|
122
|
+
if (budget.files > FILES_MAX || budget.bytes > BYTES_MAX) {
|
|
123
|
+
throw new Error(`This computer's Claude Code folders hold more than ${FILES_MAX.toLocaleString("en")} files or ${BYTES_MAX / 1024 / 1024} MB of text to back up. Move what is not Claude Code's out of its skills folders, then try again. Nothing was pushed.`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function collectFolder(dir, into, { tracked = null, root = null, budget = newBudget() } = {}, depth = 0, files = [], skipped = []) {
|
|
128
|
+
let entries;
|
|
129
|
+
try {
|
|
130
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
131
|
+
} catch (error) {
|
|
132
|
+
skipped.push({ path: dir, reason: `could not be read (${error?.code ?? "error"})` });
|
|
133
|
+
return { files, skipped };
|
|
134
|
+
}
|
|
135
|
+
for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
|
136
|
+
const path = join(dir, entry.name);
|
|
137
|
+
const target = `${into}/${entry.name}`;
|
|
138
|
+
if (entry.isSymbolicLink()) {
|
|
139
|
+
skipped.push({ path, reason: "a link" });
|
|
140
|
+
} else if (entry.isDirectory()) {
|
|
141
|
+
if (SKIPPED_FOLDERS.has(entry.name)) skipped.push({ path, reason: "a folder backups leave out" });
|
|
142
|
+
else if (depth >= DEPTH_MAX) skipped.push({ path, reason: "too deep" });
|
|
143
|
+
else await collectFolder(path, target, { tracked, root, budget }, depth + 1, files, skipped);
|
|
144
|
+
} else if (entry.isFile()) {
|
|
145
|
+
if (tracked && root && tracked.has(relative(root, path).split("\\").join("/"))) continue;
|
|
146
|
+
const info = await stat(path).catch(() => null);
|
|
147
|
+
if (!info) continue;
|
|
148
|
+
if (info.size > FILE_BYTES_MAX) skipped.push({ path, reason: `over 1 MB (${size(info.size)})` });
|
|
149
|
+
else if (!isTextName(entry.name)) skipped.push({ path, reason: "not a kind backups take" });
|
|
150
|
+
else {
|
|
151
|
+
const content = await readFile(path).catch(() => null);
|
|
152
|
+
if (!content) skipped.push({ path, reason: "could not be read" });
|
|
153
|
+
else if (isBinary(content)) skipped.push({ path, reason: "not text" });
|
|
154
|
+
else {
|
|
155
|
+
spend(budget, content.length);
|
|
156
|
+
files.push({ path: target, source: path, content, executable: (info.mode & 0o111) !== 0 });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { files, skipped };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── MCP config without its secrets ──────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
const WORD_RE = /^(?:[a-z]{1,8}|v\d{1,3})$/;
|
|
167
|
+
const SECRET_NAME_RE = /key|token|secret|pass|auth|credential|cookie|session|private/i;
|
|
168
|
+
// A value with letters and digits and no spaces, long enough to be a key, as tokens are.
|
|
169
|
+
const SECRET_VALUE_RE = /^(?=.{16,})(?=.*[A-Za-z])(?=.*\d)[A-Za-z0-9_\-+/=.:]+$/;
|
|
170
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
171
|
+
const PACKAGE_RE = /^@?[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)?(?:@[A-Za-z0-9._+-]+|==?[A-Za-z0-9._+-]+)?$/;
|
|
172
|
+
const PATH_RE = /^(?:\.{0,2}\/|~|[A-Za-z]:[\\/])/;
|
|
173
|
+
const SCHEME_RE = /^[a-z][a-z0-9+.-]{0,30}:\/\//i;
|
|
174
|
+
// Flags whose next argument is a header or an environment assignment, as mcp-remote and runners take them.
|
|
175
|
+
const HEADER_FLAGS = new Set(["-H", "--header"]);
|
|
176
|
+
const ENV_FLAGS = new Set(["-e", "--env"]);
|
|
177
|
+
|
|
178
|
+
export const placeholder = (name) => `\${${String(name).toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "VALUE"}}`;
|
|
179
|
+
|
|
180
|
+
/** An address with its credentials, its query's values and the parts of its path that are not short words replaced by placeholders, whatever its scheme. */
|
|
181
|
+
export function sanitizeUrl(value) {
|
|
182
|
+
let url;
|
|
183
|
+
try {
|
|
184
|
+
url = new URL(value);
|
|
185
|
+
} catch {
|
|
186
|
+
return sanitizeText(value);
|
|
187
|
+
}
|
|
188
|
+
const path = url.pathname
|
|
189
|
+
.split("/")
|
|
190
|
+
.map((segment, index) => (segment === "" || WORD_RE.test(segment) ? segment : placeholder(`path_${index}`)))
|
|
191
|
+
.join("/");
|
|
192
|
+
const query = [...url.searchParams.keys()].map((key) => `${encodeURIComponent(key)}=${placeholder(key)}`).join("&");
|
|
193
|
+
const auth = url.username || url.password ? "${USERNAME}:${PASSWORD}@" : "";
|
|
194
|
+
return `${url.protocol}//${auth}${url.host}${path}${query ? `?${query}` : ""}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// A package is written lowercase, as npm and PyPI name them, with a separator in any long name; a
|
|
198
|
+
// mixed-case value, a long run with no separator or a hexadecimal one of that shape is a key.
|
|
199
|
+
const packageLike = (value) => PACKAGE_RE.test(value) && value === value.toLowerCase() && !/^[0-9a-f]{20,}$/.test(value) && (value.length < 20 || /[-./@]/.test(value));
|
|
200
|
+
const looksSecret = (value) => typeof value === "string" && (UUID_RE.test(value) || (SECRET_VALUE_RE.test(value) && !packageLike(value) && !PATH_RE.test(value) && !SCHEME_RE.test(value)));
|
|
201
|
+
|
|
202
|
+
/** A header's value as the backup keeps it: `Name: ${NAME}`, keeping a Bearer or Basic scheme. */
|
|
203
|
+
function sanitizeHeader(text) {
|
|
204
|
+
const match = /^([A-Za-z][A-Za-z0-9-]{0,80}):\s*(.*)$/s.exec(text);
|
|
205
|
+
if (!match) return placeholder("header");
|
|
206
|
+
const [, name, value] = match;
|
|
207
|
+
const scheme = /^(Bearer|Basic)\s/i.exec(value)?.[1];
|
|
208
|
+
return `${name}: ${scheme ? `${scheme} ` : ""}${placeholder(name)}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** An environment assignment as the backup keeps it: `KEY=${KEY}`. */
|
|
212
|
+
const sanitizeAssignment = (text) => text.replace(/^([A-Za-z_][A-Za-z0-9_]{0,80})=.*$/s, (_, key) => `${key}=${placeholder(key)}`);
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Free text, such as a script an argument holds: credentials in any address, bearer or basic
|
|
216
|
+
* credentials, and KEY=value assignments replaced in place.
|
|
217
|
+
*/
|
|
218
|
+
export function sanitizeText(text) {
|
|
219
|
+
return text
|
|
220
|
+
.replace(/([a-z][a-z0-9+.-]{0,30}:\/\/)([^\s/@:'"]{1,100}):(?!\$\{)[^\s/@'"]{1,200}@/gi, "$1${USERNAME}:${PASSWORD}@")
|
|
221
|
+
.replace(/\b(Bearer|Basic)\s+(?!\$\{)[A-Za-z0-9_\-./+=]{8,2000}/g, "$1 ${TOKEN}")
|
|
222
|
+
.replace(/(^|[\s'"])([A-Z][A-Z0-9_]{1,80})=(?!\$\{)[^\s'"]{1,500}/g, (_, before, key) => `${before}${key}=${placeholder(key)}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* A server's arguments with what looks like a key replaced: a header's or an environment
|
|
227
|
+
* assignment's value, a value after or inside a flag named for a key, an address's credentials, a
|
|
228
|
+
* token-shaped value, and a script's credentials.
|
|
229
|
+
*/
|
|
230
|
+
function sanitizeArgs(args) {
|
|
231
|
+
const out = [];
|
|
232
|
+
let after = null;
|
|
233
|
+
for (const arg of args) {
|
|
234
|
+
if (typeof arg !== "string") {
|
|
235
|
+
out.push(arg);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (after) {
|
|
239
|
+
out.push(HEADER_FLAGS.has(after) ? sanitizeHeader(arg) : ENV_FLAGS.has(after) ? sanitizeAssignment(arg) : placeholder(after));
|
|
240
|
+
after = null;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const flag = /^(-{1,2}[A-Za-z][\w-]*)(?:=(.*))?$/s.exec(arg);
|
|
244
|
+
if (flag) {
|
|
245
|
+
const [, name, value] = flag;
|
|
246
|
+
if (value === undefined) {
|
|
247
|
+
if (SECRET_NAME_RE.test(name) || HEADER_FLAGS.has(name) || ENV_FLAGS.has(name)) after = name;
|
|
248
|
+
out.push(arg);
|
|
249
|
+
} else if (HEADER_FLAGS.has(name)) out.push(`${name}=${sanitizeHeader(value)}`);
|
|
250
|
+
else if (ENV_FLAGS.has(name)) out.push(`${name}=${sanitizeAssignment(value)}`);
|
|
251
|
+
else if (SECRET_NAME_RE.test(name) || looksSecret(value)) out.push(`${name}=${placeholder(name)}`);
|
|
252
|
+
else if (SCHEME_RE.test(value)) out.push(`${name}=${sanitizeUrl(value)}`);
|
|
253
|
+
else out.push(`${name}=${sanitizeText(value)}`);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (/^[A-Za-z_][A-Za-z0-9_]{0,80}=/.test(arg) && !/\s/.test(arg)) out.push(sanitizeAssignment(arg));
|
|
257
|
+
else if (SCHEME_RE.test(arg)) out.push(sanitizeUrl(arg));
|
|
258
|
+
else if (looksSecret(arg)) out.push(placeholder(`arg_${out.length}`));
|
|
259
|
+
else out.push(sanitizeText(arg));
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* An MCP server's config as the backup keeps it: every `env` and header value a placeholder named
|
|
266
|
+
* after its key, its address without credentials or a query's values, its arguments and command
|
|
267
|
+
* without what looks like a key, and no `headersHelper`, which is a command that fetches a
|
|
268
|
+
* credential. Other fields stay as they are.
|
|
269
|
+
*/
|
|
270
|
+
export function sanitizeServer(server) {
|
|
271
|
+
if (typeof server !== "object" || server === null) return server;
|
|
272
|
+
const out = { ...server };
|
|
273
|
+
if (out.env && typeof out.env === "object") out.env = Object.fromEntries(Object.keys(out.env).map((key) => [key, placeholder(key)]));
|
|
274
|
+
if (out.headers && typeof out.headers === "object") {
|
|
275
|
+
out.headers = Object.fromEntries(
|
|
276
|
+
Object.entries(out.headers).map(([key, value]) => [key, typeof value === "string" && /^Bearer\s/i.test(value) ? `Bearer ${placeholder(key)}` : placeholder(key)]),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if (typeof out.url === "string") out.url = sanitizeUrl(out.url);
|
|
280
|
+
if (typeof out.command === "string") out.command = sanitizeText(out.command);
|
|
281
|
+
if (Array.isArray(out.args)) out.args = sanitizeArgs(out.args);
|
|
282
|
+
if ("headersHelper" in out) out.headersHelper = placeholder("headers_helper");
|
|
283
|
+
return out;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const sanitizeServers = (servers) => Object.fromEntries(Object.entries(servers ?? {}).map(([name, server]) => [name, sanitizeServer(server)]));
|
|
287
|
+
|
|
288
|
+
// ── What a device backs up ──────────────────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
const text = (path, source, content) => ({ path, source, content: Buffer.from(content), executable: false });
|
|
291
|
+
const manifestFile = (base, manifest) => text(`${base}/manifest.json`, "manifest", `${JSON.stringify(manifest, null, 2)}\n`);
|
|
292
|
+
|
|
293
|
+
/** The `.md` files of a folder, for the backup, as `<into>/<path>`; a folder's absence is nothing to back up, one that cannot be read is named. */
|
|
294
|
+
async function markdownInto(dir, into, own, skipped) {
|
|
295
|
+
const files = [];
|
|
296
|
+
let found;
|
|
297
|
+
try {
|
|
298
|
+
found = await markdownFiles(dir, true);
|
|
299
|
+
} catch (error) {
|
|
300
|
+
skipped.push({ path: dir, reason: `could not be read (${error.message})` });
|
|
301
|
+
return files;
|
|
302
|
+
}
|
|
303
|
+
for (const file of found) {
|
|
304
|
+
if (own && own.tracked.has(relative(own.root, file.path).split("\\").join("/"))) continue;
|
|
305
|
+
const info = await stat(file.path).catch(() => null);
|
|
306
|
+
if (!info) continue;
|
|
307
|
+
if (info.size > FILE_BYTES_MAX) {
|
|
308
|
+
skipped.push({ path: file.path, reason: `over 1 MB (${size(info.size)})` });
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
files.push({ path: `${into}/${file.name}`, source: file.path, content: await readFile(file.path), executable: false });
|
|
312
|
+
}
|
|
313
|
+
return files;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function oneFile(path, into, own, skipped) {
|
|
317
|
+
if (own && own.tracked.has(relative(own.root, path).split("\\").join("/"))) return [];
|
|
318
|
+
const info = await stat(path).catch(() => null);
|
|
319
|
+
if (!info?.isFile()) return [];
|
|
320
|
+
if (info.size > FILE_BYTES_MAX) {
|
|
321
|
+
skipped.push({ path, reason: `over 1 MB (${size(info.size)})` });
|
|
322
|
+
return [];
|
|
323
|
+
}
|
|
324
|
+
return [{ path: into, source: path, content: await readFile(path), executable: false }];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** The skill folders of a skills directory, each collected under `<into>/<name>`; a name the sync cannot send is left out. */
|
|
328
|
+
async function skillsInto(dir, into, own, skipped, budget) {
|
|
329
|
+
const files = [];
|
|
330
|
+
let folders;
|
|
331
|
+
try {
|
|
332
|
+
folders = await skillFolders(dir);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
skipped.push({ path: dir, reason: `could not be read (${error.message})` });
|
|
335
|
+
return files;
|
|
336
|
+
}
|
|
337
|
+
for (const folder of folders) {
|
|
338
|
+
if (!NAME_RE.test(folder.name)) {
|
|
339
|
+
skipped.push({ path: folder.path, reason: "named in a way the sync cannot send" });
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (own && own.tracked.has(relative(own.root, join(folder.path, "SKILL.md")).split("\\").join("/"))) continue;
|
|
343
|
+
await collectFolder(folder.path, `${into}/${folder.name}`, { ...own, budget }, 0, files, skipped);
|
|
344
|
+
}
|
|
345
|
+
return files;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Everything the device backs up: under `devices/<device>/global`, its skills' files, CLAUDE.md,
|
|
350
|
+
* rules, agents and MCP servers; under `devices/<device>/projects/<project>`, the same for each
|
|
351
|
+
* project, with AGENTS.md and the session memory Claude Code keeps for it, leaving out what the
|
|
352
|
+
* project's own git tracks. The manifest names the device and each project, so a restore finds them.
|
|
353
|
+
*/
|
|
354
|
+
export async function collectBackup({ device, claudeDir, claudeJson, projects, trackedOf, version }) {
|
|
355
|
+
const files = [];
|
|
356
|
+
const skipped = [];
|
|
357
|
+
const budget = newBudget();
|
|
358
|
+
const base = `devices/${device.slug}`;
|
|
359
|
+
files.push(...(await skillsInto(join(claudeDir, "skills"), `${base}/global/skills`, null, skipped, budget)));
|
|
360
|
+
files.push(...(await oneFile(join(claudeDir, "CLAUDE.md"), `${base}/global/CLAUDE.md`, null, skipped)));
|
|
361
|
+
files.push(...(await markdownInto(join(claudeDir, "rules"), `${base}/global/rules`, null, skipped)));
|
|
362
|
+
files.push(...(await markdownInto(join(claudeDir, "agents"), `${base}/global/agents`, null, skipped)));
|
|
363
|
+
const globalServers = claudeJson?.mcpServers && typeof claudeJson.mcpServers === "object" ? sanitizeServers(claudeJson.mcpServers) : {};
|
|
364
|
+
if (Object.keys(globalServers).length) files.push(text(`${base}/global/mcp.json`, "~/.claude.json (mcpServers)", `${JSON.stringify({ mcpServers: globalServers }, null, 2)}\n`));
|
|
365
|
+
|
|
366
|
+
const places = [{ folder: "global", files: files.length }];
|
|
367
|
+
// Every checkout of one project, such as two clones of a repository, goes in the project's one
|
|
368
|
+
// folder, as the site merges them into one project. Another project whose name makes the same
|
|
369
|
+
// folder name is left out.
|
|
370
|
+
const groups = new Map();
|
|
371
|
+
for (const project of projects) {
|
|
372
|
+
const slug = projectSlug(project, device.slug);
|
|
373
|
+
if (!slug) continue;
|
|
374
|
+
const group = groups.get(slug);
|
|
375
|
+
if (!group) groups.set(slug, [project]);
|
|
376
|
+
else if (projectIdentity(group[0]) === projectIdentity(project)) group.push(project);
|
|
377
|
+
else skipped.push({ path: project.root, reason: `a folder of the same name, ${slug}, is backed up already` });
|
|
378
|
+
}
|
|
379
|
+
for (const [slug, group] of groups) {
|
|
380
|
+
// In the order the sync sends them, which the site merges them in: by name, then as found.
|
|
381
|
+
const checkouts = [...group].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
382
|
+
const into = `${base}/projects/${slug}`;
|
|
383
|
+
const before = files.length;
|
|
384
|
+
// The first checkout's item of a name wins, as on the site: a file by its path, a skill by its whole folder.
|
|
385
|
+
const item = (path) => (path.startsWith(`${into}/skills/`) ? path.split("/", into.split("/").length + 2).join("/") : path);
|
|
386
|
+
const taken = new Set();
|
|
387
|
+
const shared = new Map();
|
|
388
|
+
const local = new Map();
|
|
389
|
+
const sharedSources = [];
|
|
390
|
+
for (const project of checkouts) {
|
|
391
|
+
const own = { root: project.root, tracked: await trackedOf(project.root) };
|
|
392
|
+
const found = [
|
|
393
|
+
...(await skillsInto(join(project.root, ".claude", "skills"), `${into}/skills`, own, skipped, budget)),
|
|
394
|
+
...(await oneFile(join(project.root, "CLAUDE.md"), `${into}/CLAUDE.md`, own, skipped)),
|
|
395
|
+
...(await oneFile(join(project.root, "AGENTS.md"), `${into}/AGENTS.md`, own, skipped)),
|
|
396
|
+
...(await markdownInto(join(project.root, ".claude", "rules"), `${into}/rules`, own, skipped)),
|
|
397
|
+
...(await markdownInto(join(project.root, ".claude", "agents"), `${into}/agents`, own, skipped)),
|
|
398
|
+
];
|
|
399
|
+
const seen = new Set();
|
|
400
|
+
for (const folder of new Set([project.root, ...(project.folders ?? [])])) {
|
|
401
|
+
for (const file of await markdownInto(join(claudeDir, "projects", sessionFolderName(folder), "memory"), `${into}/memory`, null, skipped)) {
|
|
402
|
+
if (seen.has(file.path)) continue;
|
|
403
|
+
seen.add(file.path);
|
|
404
|
+
found.push(file);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const lost = new Map();
|
|
408
|
+
for (const file of found) {
|
|
409
|
+
const key = item(file.path);
|
|
410
|
+
if (!taken.has(key)) files.push(file);
|
|
411
|
+
else if (!lost.has(key)) lost.set(key, key.startsWith(`${into}/skills/`) ? join(project.root, ".claude", "skills", key.slice(`${into}/skills/`.length)) : file.source);
|
|
412
|
+
}
|
|
413
|
+
for (const path of lost.values()) skipped.push({ path, reason: `another checkout of ${project.name} has one of the same name, backed up instead, as the site keeps one` });
|
|
414
|
+
for (const file of found) taken.add(item(file.path));
|
|
415
|
+
if (!own.tracked.has(".mcp.json")) {
|
|
416
|
+
const mcpJson = await readFile(join(project.root, ".mcp.json"), "utf8").catch(() => null);
|
|
417
|
+
let servers = null;
|
|
418
|
+
try {
|
|
419
|
+
servers = mcpJson === null ? null : JSON.parse(mcpJson)?.mcpServers;
|
|
420
|
+
} catch {
|
|
421
|
+
skipped.push({ path: join(project.root, ".mcp.json"), reason: "not valid JSON" });
|
|
422
|
+
}
|
|
423
|
+
if (servers && typeof servers === "object" && Object.keys(servers).length) {
|
|
424
|
+
sharedSources.push(join(project.root, ".mcp.json"));
|
|
425
|
+
for (const [name, server] of Object.entries(sanitizeServers(servers))) if (!shared.has(name)) shared.set(name, server);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
const ownLocal = {};
|
|
429
|
+
for (const folder of [project.root, ...(project.folders ?? [])]) Object.assign(ownLocal, sanitizeServers(claudeJson?.projects?.[folder]?.mcpServers));
|
|
430
|
+
for (const [name, server] of Object.entries(ownLocal)) if (!local.has(name)) local.set(name, server);
|
|
431
|
+
}
|
|
432
|
+
if (shared.size) files.push({ ...text(`${into}/mcp.json`, sharedSources.join(", "), `${JSON.stringify({ mcpServers: Object.fromEntries(shared) }, null, 2)}\n`), sources: sharedSources });
|
|
433
|
+
if (local.size) files.push(text(`${into}/mcp.local.json`, `~/.claude.json (projects, mcpServers)`, `${JSON.stringify({ mcpServers: Object.fromEntries(local) }, null, 2)}\n`));
|
|
434
|
+
const [project] = checkouts;
|
|
435
|
+
places.push({ folder: `projects/${slug}`, name: project.name, repo: project.repo ?? null, remote: Boolean(project.remote), files: files.length - before });
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// No time in the manifest: the commit carries it, and an unchanged computer then commits nothing.
|
|
439
|
+
const manifest = { skillcellar: version, device: { id: device.id, slug: device.slug, name: device.name }, places };
|
|
440
|
+
files.push(manifestFile(base, manifest));
|
|
441
|
+
return { files, skipped, manifest };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// How many findings a stopped backup lists.
|
|
445
|
+
const FINDINGS_MAX = 20;
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The files of a backup that hold what looks like a secret, each with the line, up to FINDINGS_MAX,
|
|
449
|
+
* and those that pass despite one, each with the hash of what passed: a file the user allows by its
|
|
450
|
+
* path, whole or from a folder's start, and one an earlier backup let through holding these bytes.
|
|
451
|
+
*/
|
|
452
|
+
export function findSecretsIn(files, allowed = [], remembered = []) {
|
|
453
|
+
const findings = [];
|
|
454
|
+
const passed = [];
|
|
455
|
+
for (const file of files) {
|
|
456
|
+
const found = findSecret(file.content.toString("utf8"));
|
|
457
|
+
if (!found) continue;
|
|
458
|
+
const sha256 = createHash("sha256").update(file.content).digest("hex");
|
|
459
|
+
// A merged file, such as the mcp.json of several checkouts, is allowed by any file it came from.
|
|
460
|
+
const names = file.sources ?? [file.source];
|
|
461
|
+
if (allowed.some((path) => names.some((name) => name === path || name.endsWith(`/${path}`))) || remembered.some((entry) => entry?.file === file.source && entry.sha256 === sha256)) {
|
|
462
|
+
passed.push({ file: file.source, path: file.path, sha256, line: found.line, kind: found.kind });
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
findings.push({ ...found, file: file.source });
|
|
466
|
+
if (findings.length >= FINDINGS_MAX) break;
|
|
467
|
+
}
|
|
468
|
+
return { findings, passed };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Writes the backup's files into the clone, replacing the device's folder and any older folder of the same device under another slug. */
|
|
472
|
+
export async function writeBackup(clone, files, deviceId, deviceSlug) {
|
|
473
|
+
const devices = join(clone, "devices");
|
|
474
|
+
// A link in the clone would carry a delete or a write outside it: the repository is refused as it is.
|
|
475
|
+
const linked = (info) => info?.isSymbolicLink();
|
|
476
|
+
if (linked(await lstat(devices).catch(() => null))) throw new Error("The backup repository holds a link at devices; remove it there first. Nothing was pushed.");
|
|
477
|
+
for (const name of await readdir(devices).catch(() => [])) {
|
|
478
|
+
if (linked(await lstat(join(devices, name)).catch(() => null))) throw new Error(`The backup repository holds a link at devices/${name}; remove it there first. Nothing was pushed.`);
|
|
479
|
+
const manifest = await readFile(join(devices, name, "manifest.json"), "utf8").catch(() => null);
|
|
480
|
+
let id = null;
|
|
481
|
+
try {
|
|
482
|
+
id = manifest === null ? null : JSON.parse(manifest)?.device?.id;
|
|
483
|
+
} catch {
|
|
484
|
+
// A folder whose manifest cannot be read is another device's, or nobody's: left alone.
|
|
485
|
+
}
|
|
486
|
+
if (name === deviceSlug || (id && id === deviceId)) await rm(join(devices, name), { recursive: true, force: true });
|
|
487
|
+
}
|
|
488
|
+
for (const file of files) {
|
|
489
|
+
const path = join(clone, ...file.path.split("/"));
|
|
490
|
+
// The paths were built here from checked names; this is the last guard before a write.
|
|
491
|
+
if (relative(clone, path).startsWith("..")) throw new Error(`The backup names a file outside its folder: ${file.path}.`);
|
|
492
|
+
await mkdir(dirname(path), { recursive: true });
|
|
493
|
+
await writeFile(path, file.content, { mode: file.executable ? 0o755 : 0o644 });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** The clone's current branch, or null in an empty repository with no commit yet. */
|
|
498
|
+
export async function currentBranch(clone) {
|
|
499
|
+
const ref = await git(["symbolic-ref", "--short", "-q", "HEAD"], { cwd: clone }).catch(() => "");
|
|
500
|
+
const head = await git(["rev-parse", "--verify", "-q", "HEAD"], { cwd: clone }).catch(() => "");
|
|
501
|
+
return head.trim() ? ref.trim() || null : null;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* The clone of the backup repository, made if missing, else brought to the remote's state: the
|
|
506
|
+
* clone is the tool's own scratch area, so what a failed run left in its tree is dropped, a commit a
|
|
507
|
+
* failed push left behind is rebased, and an empty remote is not pulled from.
|
|
508
|
+
*/
|
|
509
|
+
export async function ensureClone(repo) {
|
|
510
|
+
const clone = clonePath(repo.owner, repo.repo);
|
|
511
|
+
const there = await stat(join(clone, ".git")).catch(() => null);
|
|
512
|
+
if (!there?.isDirectory()) {
|
|
513
|
+
await mkdir(dirname(clone), { recursive: true, mode: 0o700 });
|
|
514
|
+
await git(["clone", "--quiet", repo.url, clone], { timeout: 600_000 });
|
|
515
|
+
return clone;
|
|
516
|
+
}
|
|
517
|
+
await git(["remote", "set-url", "origin", repo.url], { cwd: clone });
|
|
518
|
+
await git(["fetch", "--quiet", "origin"], { cwd: clone, timeout: 600_000 });
|
|
519
|
+
const branch = await currentBranch(clone);
|
|
520
|
+
if (branch) {
|
|
521
|
+
await git(["reset", "--hard", "--quiet"], { cwd: clone });
|
|
522
|
+
await git(["clean", "-fdq", "--", "devices"], { cwd: clone });
|
|
523
|
+
const heads = (await git(["ls-remote", "--heads", "origin"], { cwd: clone, timeout: 120_000 })).trim();
|
|
524
|
+
if (heads) await git(["pull", "--rebase", "--quiet", "origin", branch], { cwd: clone, timeout: 600_000 });
|
|
525
|
+
}
|
|
526
|
+
return clone;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* A bare mirror of the backup repository for `restore`, beside the clone and never checked out, so
|
|
531
|
+
* a link in the repository is never followed; fetched to the remote's state on every call.
|
|
532
|
+
*/
|
|
533
|
+
export async function ensureMirror(repo) {
|
|
534
|
+
const mirror = `${clonePath(repo.owner, repo.repo)}.git`;
|
|
535
|
+
const there = await stat(join(mirror, "HEAD")).catch(() => null);
|
|
536
|
+
if (!there?.isFile()) {
|
|
537
|
+
await mkdir(dirname(mirror), { recursive: true, mode: 0o700 });
|
|
538
|
+
await git(["clone", "--quiet", "--bare", repo.url, mirror], { timeout: 600_000 });
|
|
539
|
+
return mirror;
|
|
540
|
+
}
|
|
541
|
+
await git(["--git-dir", mirror, "remote", "set-url", "origin", repo.url]);
|
|
542
|
+
await git(["--git-dir", mirror, "fetch", "--quiet", "origin", "+refs/heads/*:refs/heads/*"], { timeout: 600_000 });
|
|
543
|
+
return mirror;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** What was left out, as lines a person reads: folders and files by reason, each named. */
|
|
547
|
+
export function describeSkipped(skipped) {
|
|
548
|
+
if (!skipped.length) return [];
|
|
549
|
+
const lines = ["Left out:"];
|
|
550
|
+
for (const { path, reason } of skipped) lines.push(` ${path}: ${reason}`);
|
|
551
|
+
return lines;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
555
|
+
|
|
556
|
+
/** The files let through despite a finding, as lines a person reads, so an allowance is never silent. */
|
|
557
|
+
function describePassed(passed) {
|
|
558
|
+
if (!passed.length) return [];
|
|
559
|
+
return ["Let through as you allowed them:", ...passed.map(({ file, line, kind }) => ` ${file}:${line} looks like ${kind}`)];
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Backs up the device: collects its files, stops on anything that looks like a secret, refuses a
|
|
564
|
+
* public repository, then commits and pushes with the user's own git. Answers what to print and the
|
|
565
|
+
* commit to report, or null when nothing changed.
|
|
566
|
+
*/
|
|
567
|
+
export async function runBackup({ repo, device, claudeDir, claudeJson, projects, trackedOf, version, dryRun, allowed = [], remembered = [], print, onPushed = async () => {} }) {
|
|
568
|
+
const collected = await collectBackup({ device, claudeDir, claudeJson, projects, trackedOf, version });
|
|
569
|
+
const { skipped } = collected;
|
|
570
|
+
const { findings, passed } = findSecretsIn(collected.files, allowed, remembered);
|
|
571
|
+
// What passed despite a finding is named in the manifest with the hash of what passed, so a
|
|
572
|
+
// restore copies that and reads any other copy of the file again.
|
|
573
|
+
const base = `devices/${device.slug}`;
|
|
574
|
+
const allowedFiles = passed.map(({ path, sha256 }) => ({ path: path.slice(base.length + 1), sha256 })).sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
575
|
+
const manifest = allowedFiles.length ? { ...collected.manifest, allowed: allowedFiles } : collected.manifest;
|
|
576
|
+
const files = allowedFiles.length ? collected.files.map((file) => (file.path === `${base}/manifest.json` ? manifestFile(base, manifest) : file)) : collected.files;
|
|
577
|
+
if (findings.length) {
|
|
578
|
+
const lines = findings.map((found) => ` ${found.file}:${found.line} looks like ${found.kind}`);
|
|
579
|
+
throw new Error(
|
|
580
|
+
`Backup stopped: ${plural(findings.length, "file")} ${findings.length === 1 ? "holds" : "hold"} what looks like a secret${findings.length >= FINDINGS_MAX ? " (the first 20)" : ""}:\n${lines.join("\n")}\nNothing was pushed. Take each out of its file on this computer, or keep the file out of Claude Code's folders, then run skillcellar backup again; for a file you have read and know holds no secret, run it with --allow <path> for that file.`,
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
const bytes = files.reduce((total, file) => total + file.content.length, 0);
|
|
584
|
+
const summary = `${plural(files.length, "file")} (${size(bytes)}): ${manifest.places.map((place) => `${place.folder} ${place.files}`).join(", ")}`;
|
|
585
|
+
if (dryRun) {
|
|
586
|
+
print(`Would back up ${device.name} to ${repo.name}, ${summary}.`);
|
|
587
|
+
for (const line of [...describeSkipped(skipped), ...describePassed(passed)]) print(line);
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
await refusePublic(repo, version);
|
|
591
|
+
const clone = await ensureClone(repo);
|
|
592
|
+
await writeBackup(clone, files, device.id, device.slug);
|
|
593
|
+
await git(["add", "-A", "-f", "--", "devices"], { cwd: clone });
|
|
594
|
+
const changed = (await git(["status", "--porcelain", "--", "devices"], { cwd: clone })).trim();
|
|
595
|
+
let commit = null;
|
|
596
|
+
if (changed) {
|
|
597
|
+
await git(["commit", "--quiet", "-m", `Back up ${device.name} (${plural(files.length, "file")})`], { cwd: clone });
|
|
598
|
+
}
|
|
599
|
+
// Pushed on every run: a commit a failed push left behind goes now, and the commit reported is one the remote has.
|
|
600
|
+
if (await currentBranch(clone)) {
|
|
601
|
+
await git(["push", "--quiet", "-u", "origin", "HEAD"], { cwd: clone, timeout: 600_000 });
|
|
602
|
+
commit = (await git(["rev-parse", "HEAD"], { cwd: clone })).trim();
|
|
603
|
+
await onPushed(commit, passed);
|
|
604
|
+
}
|
|
605
|
+
if (changed) print(`Backed up ${device.name} to ${repo.name} at ${commit.slice(0, 7)}, ${summary}.`);
|
|
606
|
+
else print(`Nothing changed since the last backup of ${device.name} in ${repo.name}${commit ? ` (${commit.slice(0, 7)})` : ""}.`);
|
|
607
|
+
for (const line of [...describeSkipped(skipped), ...describePassed(passed)]) print(line);
|
|
608
|
+
return commit;
|
|
609
|
+
}
|