nerdsystem 0.0.11 → 0.0.12
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 +13 -2
- package/dist/api.d.ts +39 -0
- package/dist/api.js +54 -12
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +21 -9
- package/dist/commands/admin.js +1 -1
- package/dist/commands/deploy.d.ts +58 -5
- package/dist/commands/deploy.js +122 -30
- package/dist/commands/dev.js +4 -0
- package/dist/commands/init.d.ts +6 -0
- package/dist/commands/init.js +5 -4
- package/dist/commands/ops.d.ts +15 -0
- package/dist/commands/ops.js +7 -0
- package/dist/commands/pull.d.ts +31 -0
- package/dist/commands/pull.js +137 -0
- package/dist/commands/save.d.ts +27 -0
- package/dist/commands/save.js +67 -0
- package/dist/commands/source.d.ts +38 -0
- package/dist/commands/source.js +145 -0
- package/dist/commands/trash.d.ts +1 -1
- package/dist/commands/trash.js +2 -3
- package/dist/gates/text.d.ts +65 -0
- package/dist/gates/text.js +108 -0
- package/dist/snapshot.d.ts +161 -0
- package/dist/snapshot.js +896 -0
- package/package.json +2 -1
- package/template/.claude/skills/nerdsystem/SKILL.md +10 -2
- package/template/.cursor/rules/nerdsystem.mdc +11 -3
- package/template/.github/copilot-instructions.md +11 -3
- package/template/AGENTS.md +11 -3
- package/template/CLAUDE.md +2 -1
- package/template/GEMINI.md +11 -3
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { ApiClient, type Source } from "../api.js";
|
|
2
|
+
import { type NerdJson } from "../project.js";
|
|
3
|
+
export interface PullOptions {
|
|
4
|
+
json: boolean;
|
|
5
|
+
latest?: boolean | undefined;
|
|
6
|
+
source?: string | undefined;
|
|
7
|
+
deployment?: string | undefined;
|
|
8
|
+
trashed?: boolean | undefined;
|
|
9
|
+
dir?: string | undefined;
|
|
10
|
+
force?: boolean | undefined;
|
|
11
|
+
}
|
|
12
|
+
/** `org/project` from the command line, or null when nothing was given. */
|
|
13
|
+
export declare function parseTarget(arg: string | undefined): NerdJson | null;
|
|
14
|
+
export interface PullOutcome {
|
|
15
|
+
restored: number;
|
|
16
|
+
dir: string;
|
|
17
|
+
source: Source | null;
|
|
18
|
+
sourceId: string;
|
|
19
|
+
sha256: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Downloads one snapshot, verifies its hash against the row (or the download header), extracts it
|
|
23
|
+
* with every hardening rule, and leaves a folder a person can work in: a git repository with one
|
|
24
|
+
* commit, nerd.json, and the lineage file. Nothing is written when the hash does not match.
|
|
25
|
+
*/
|
|
26
|
+
export declare function restoreSnapshot(api: ApiClient, nerd: NerdJson, dir: string, opts: {
|
|
27
|
+
trashed?: boolean | undefined;
|
|
28
|
+
force?: boolean | undefined;
|
|
29
|
+
} & Pick<PullOptions, "latest" | "source" | "deployment">): Promise<PullOutcome>;
|
|
30
|
+
/** `nerd pull`: get a saved copy of the code back onto this computer. */
|
|
31
|
+
export declare function pull(target: string | undefined, opts: PullOptions): Promise<void>;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { ApiClient } from "../api.js";
|
|
5
|
+
import { findProjectRoot, readNerdJson, writeNerdJson } from "../project.js";
|
|
6
|
+
import { extractSnapshot, SnapshotError } from "../snapshot.js";
|
|
7
|
+
import { CliError, log, printJson } from "../util.js";
|
|
8
|
+
import { initGitRepo } from "./init.js";
|
|
9
|
+
import { appendLineage } from "./source.js";
|
|
10
|
+
const USAGE = "Usage: nerd pull <org>/<project> [--latest | --source <id> | --deployment <id>] [--trashed] [--dir <name>] [--force]";
|
|
11
|
+
const SLUG = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
12
|
+
/** `org/project` from the command line, or null when nothing was given. */
|
|
13
|
+
export function parseTarget(arg) {
|
|
14
|
+
if (arg === undefined)
|
|
15
|
+
return null;
|
|
16
|
+
const m = /^([^/\s]+)\/([^/\s]+)$/.exec(arg.trim());
|
|
17
|
+
if (!m || !SLUG.test(m[1]) || !SLUG.test(m[2]))
|
|
18
|
+
throw new CliError(`"${arg}" is not an org/project pair. ${USAGE}`);
|
|
19
|
+
return { org: m[1], project: m[2] };
|
|
20
|
+
}
|
|
21
|
+
/** The project to pull: the positional, else nerd.json above cwd, else a question on a TTY. */
|
|
22
|
+
async function resolveTarget(arg, api, trashed) {
|
|
23
|
+
const given = parseTarget(arg);
|
|
24
|
+
if (given)
|
|
25
|
+
return { ...given, defaultDir: path.resolve(given.project) };
|
|
26
|
+
try {
|
|
27
|
+
const root = findProjectRoot();
|
|
28
|
+
const nerd = readNerdJson(root);
|
|
29
|
+
return { ...nerd, defaultDir: root };
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// not inside a project
|
|
33
|
+
}
|
|
34
|
+
const me = await api.get("/v1/me");
|
|
35
|
+
const choices = [];
|
|
36
|
+
for (const org of me.orgs) {
|
|
37
|
+
const r = trashed ? await api.get(`/v1/orgs/${org.slug}/trash`) : await api.get(`/v1/orgs/${org.slug}/projects`);
|
|
38
|
+
for (const p of r.projects)
|
|
39
|
+
choices.push({ org: org.slug, project: p.slug, name: p.name });
|
|
40
|
+
}
|
|
41
|
+
if (choices.length === 0)
|
|
42
|
+
throw new CliError(trashed ? "Nothing in the trash. Drop --trashed to pull a live project." : "Your account has no projects yet. Create one with `nerd init <dir>`.");
|
|
43
|
+
if (!process.stdin.isTTY || process.env.CI) {
|
|
44
|
+
const list = choices.map((c) => `${c.org}/${c.project}`).join(", ");
|
|
45
|
+
throw new CliError(`Name the project: nerd pull <org>/<project>. Yours: ${list}`);
|
|
46
|
+
}
|
|
47
|
+
const { createInterface } = await import("node:readline/promises");
|
|
48
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
49
|
+
try {
|
|
50
|
+
process.stderr.write(`Which project?\n${choices.map((c, i) => ` ${i + 1}. ${c.org}/${c.project} ${c.name}`).join("\n")}\n`);
|
|
51
|
+
const answer = (await rl.question(`Number (1-${choices.length}): `)).trim();
|
|
52
|
+
const n = Number(answer);
|
|
53
|
+
const pick = Number.isInteger(n) && n >= 1 && n <= choices.length ? choices[n - 1] : choices.find((c) => `${c.org}/${c.project}` === answer);
|
|
54
|
+
if (!pick)
|
|
55
|
+
throw new CliError(`Not a choice. ${USAGE}`);
|
|
56
|
+
return { org: pick.org, project: pick.project, defaultDir: path.resolve(pick.project) };
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
rl.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Picks the snapshot row: --source, --deployment (through its sourceId), or the newest. */
|
|
63
|
+
async function pickSource(api, base, nerd, opts) {
|
|
64
|
+
const given = [opts.source, opts.deployment, opts.latest].filter(Boolean).length;
|
|
65
|
+
if (given > 1)
|
|
66
|
+
throw new CliError(`Pick one of --latest, --source, or --deployment. ${USAGE}`);
|
|
67
|
+
if (opts.deployment) {
|
|
68
|
+
if (opts.trashed)
|
|
69
|
+
throw new CliError("--deployment does not work with --trashed; use --source <id> or --latest for a trashed project.");
|
|
70
|
+
const list = await api.get(`${base}/deployments?limit=100`);
|
|
71
|
+
const d = list.deployments.find((x) => x.id === opts.deployment);
|
|
72
|
+
if (!d)
|
|
73
|
+
throw new CliError(`Deployment ${opts.deployment} was not found among the last 100 of ${nerd.org}/${nerd.project}.`);
|
|
74
|
+
if (!d.sourceId)
|
|
75
|
+
throw new CliError(`Deployment ${d.id} has no saved code${d.sourceSkippedReason ? ` (${d.sourceSkippedReason})` : " (it was deployed with an older nerd)"}. Pick another with nerd deployments, or use --latest.`);
|
|
76
|
+
const sources = await api.get(`${base}/sources?limit=100`);
|
|
77
|
+
return { source: sources.sources.find((s) => s.id === d.sourceId) ?? null, id: d.sourceId };
|
|
78
|
+
}
|
|
79
|
+
if (opts.source) {
|
|
80
|
+
const sources = await api.get(`${base}/sources?limit=100`);
|
|
81
|
+
return { source: sources.sources.find((s) => s.id === opts.source) ?? null, id: opts.source };
|
|
82
|
+
}
|
|
83
|
+
const r = await api.get(`${base}/sources?limit=1`);
|
|
84
|
+
const newest = r.sources[0];
|
|
85
|
+
if (!newest)
|
|
86
|
+
throw new CliError(`No saved code for ${nerd.org}/${nerd.project} yet. Deploy it or run nerd save first.`);
|
|
87
|
+
return { source: newest, id: newest.id };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Downloads one snapshot, verifies its hash against the row (or the download header), extracts it
|
|
91
|
+
* with every hardening rule, and leaves a folder a person can work in: a git repository with one
|
|
92
|
+
* commit, nerd.json, and the lineage file. Nothing is written when the hash does not match.
|
|
93
|
+
*/
|
|
94
|
+
export async function restoreSnapshot(api, nerd, dir, opts) {
|
|
95
|
+
const base = opts.trashed ? `/v1/orgs/${nerd.org}/trash/${nerd.project}` : `/v1/orgs/${nerd.org}/projects/${nerd.project}`;
|
|
96
|
+
const picked = await pickSource(api, base, nerd, { json: false, ...opts });
|
|
97
|
+
const target = path.resolve(dir);
|
|
98
|
+
// Refuse early, before the download, so a full folder costs nothing.
|
|
99
|
+
if (existsSync(target) && readdirSync(target).length > 0 && !opts.force)
|
|
100
|
+
throw new CliError(`${target} is not empty. Use --force to write into it anyway; files with the same names are replaced, other files stay.`, 1, { code: "not_empty" });
|
|
101
|
+
const { bytes, headers } = await api.download(`${base}/sources/${picked.id}/download`);
|
|
102
|
+
const expected = picked.source?.sha256 ?? headers.get("x-nerd-source-sha256");
|
|
103
|
+
if (!expected || !/^[0-9a-f]{64}$/.test(expected))
|
|
104
|
+
throw new CliError("The API did not say what hash the saved copy should have. Nothing was written. Update nerd and try again.");
|
|
105
|
+
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
106
|
+
if (actual !== expected)
|
|
107
|
+
throw new CliError("The downloaded copy did not match its recorded hash. Nothing was written. Try again in a minute.", 1, { code: "hash_mismatch" });
|
|
108
|
+
let restored;
|
|
109
|
+
try {
|
|
110
|
+
restored = extractSnapshot(bytes, target, { force: Boolean(opts.force) }).files;
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
if (err instanceof SnapshotError && err.code === "not_empty")
|
|
114
|
+
throw new CliError(err.message, 1, { code: "not_empty" });
|
|
115
|
+
if (err instanceof SnapshotError)
|
|
116
|
+
throw new CliError(`The saved copy failed a safety check and was not restored: ${err.message}`, 1, { code: err.code });
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
if (!existsSync(path.join(target, "nerd.json")))
|
|
120
|
+
writeNerdJson(target, { org: nerd.org, project: nerd.project });
|
|
121
|
+
if (!existsSync(path.join(target, ".git")))
|
|
122
|
+
initGitRepo(target, `Restore from nerdsystem snapshot ${picked.id}`);
|
|
123
|
+
appendLineage(target, picked.id);
|
|
124
|
+
return { restored, dir: target, source: picked.source, sourceId: picked.id, sha256: actual };
|
|
125
|
+
}
|
|
126
|
+
/** `nerd pull`: get a saved copy of the code back onto this computer. */
|
|
127
|
+
export async function pull(target, opts) {
|
|
128
|
+
const api = ApiClient.fromConfig();
|
|
129
|
+
const resolved = await resolveTarget(target, api, Boolean(opts.trashed));
|
|
130
|
+
const dir = opts.dir ? path.resolve(opts.dir) : resolved.defaultDir;
|
|
131
|
+
const nerd = { org: resolved.org, project: resolved.project };
|
|
132
|
+
const out = await restoreSnapshot(api, nerd, dir, opts);
|
|
133
|
+
const shown = path.relative(process.cwd(), out.dir) || ".";
|
|
134
|
+
if (opts.json)
|
|
135
|
+
return printJson({ ...out, org: nerd.org, project: nerd.project, next: `cd ${shown} && pnpm install && nerd dev` });
|
|
136
|
+
log.ok(`Restored ${out.restored} files to ${shown}. Next: cd ${shown} && pnpm install && nerd dev`);
|
|
137
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ApiClient, type Source } from "../api.js";
|
|
2
|
+
import { type NerdJson } from "../project.js";
|
|
3
|
+
export interface SaveOutcome {
|
|
4
|
+
/** True when a new snapshot was stored; false when the newest one already had this tree. */
|
|
5
|
+
saved: boolean;
|
|
6
|
+
source: Source;
|
|
7
|
+
fileCount: number;
|
|
8
|
+
bytes: number;
|
|
9
|
+
treeHash: string;
|
|
10
|
+
warnings: string[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Packs the folder and stores it as a `save` snapshot unless the newest stored snapshot already has
|
|
14
|
+
* the same tree. Throws CliError when the folder is over a cap (nothing to save) or the API refuses.
|
|
15
|
+
*/
|
|
16
|
+
export declare function saveSnapshot(root: string, nerd: NerdJson, api: ApiClient, opts?: {
|
|
17
|
+
quiet?: boolean;
|
|
18
|
+
}): Promise<SaveOutcome>;
|
|
19
|
+
/** `nerd save`: keep the code on nerdsystem without deploying it. */
|
|
20
|
+
export declare function save(opts: {
|
|
21
|
+
json: boolean;
|
|
22
|
+
}): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* The save `nerd dev` runs on start: silent when nothing changed, one warning when it did not go
|
|
25
|
+
* through, never fatal. A person starting their app should never be stopped by the backup.
|
|
26
|
+
*/
|
|
27
|
+
export declare function silentSave(root: string): Promise<void>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { ApiClient } from "../api.js";
|
|
2
|
+
import { findProjectRoot, projectPath, readNerdJson } from "../project.js";
|
|
3
|
+
import { CliError, log, printJson } from "../util.js";
|
|
4
|
+
import { appendLineage, LOCAL_ONLY_NOTE, packProject, printPackWarnings, secretWarnings, uploadSnapshot } from "./source.js";
|
|
5
|
+
/**
|
|
6
|
+
* Packs the folder and stores it as a `save` snapshot unless the newest stored snapshot already has
|
|
7
|
+
* the same tree. Throws CliError when the folder is over a cap (nothing to save) or the API refuses.
|
|
8
|
+
*/
|
|
9
|
+
export async function saveSnapshot(root, nerd, api, opts = {}) {
|
|
10
|
+
const pack = await packProject(root, "save");
|
|
11
|
+
if (!opts.quiet)
|
|
12
|
+
printPackWarnings(pack);
|
|
13
|
+
if (pack.tooLarge)
|
|
14
|
+
throw new CliError(pack.tooLarge.message, 1, { code: "too_large" });
|
|
15
|
+
if (!opts.quiet)
|
|
16
|
+
for (const w of secretWarnings(root, pack))
|
|
17
|
+
log.warn(w);
|
|
18
|
+
const base = projectPath(nerd);
|
|
19
|
+
const latest = await api.get(`${base}/sources/latest?treeHash=${pack.treeHash}`);
|
|
20
|
+
if (latest.same && latest.source) {
|
|
21
|
+
appendLineage(root, latest.source.id);
|
|
22
|
+
return { saved: false, source: latest.source, fileCount: pack.fileCount, bytes: pack.bytes, treeHash: pack.treeHash, warnings: pack.warnings };
|
|
23
|
+
}
|
|
24
|
+
const source = await uploadSnapshot(api, base, pack, "save");
|
|
25
|
+
appendLineage(root, source.id);
|
|
26
|
+
return { saved: true, source, fileCount: pack.fileCount, bytes: pack.bytes, treeHash: pack.treeHash, warnings: pack.warnings };
|
|
27
|
+
}
|
|
28
|
+
/** `nerd save`: keep the code on nerdsystem without deploying it. */
|
|
29
|
+
export async function save(opts) {
|
|
30
|
+
const root = findProjectRoot();
|
|
31
|
+
const nerd = readNerdJson(root);
|
|
32
|
+
const api = ApiClient.fromConfig();
|
|
33
|
+
const out = await saveSnapshot(root, nerd, api, { quiet: opts.json });
|
|
34
|
+
if (opts.json)
|
|
35
|
+
return printJson({ saved: out.saved, source: out.source, fileCount: out.fileCount, bytes: out.bytes, treeHash: out.treeHash, warnings: out.warnings, pull: `nerd pull ${nerd.org}/${nerd.project}` });
|
|
36
|
+
if (!out.saved) {
|
|
37
|
+
log.ok("Nothing changed since the last save.");
|
|
38
|
+
log.info(`Get it back on any computer with: nerd pull ${nerd.org}/${nerd.project}`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
log.ok(`Saved ${out.fileCount} files. Get it back on any computer with: nerd pull ${nerd.org}/${nerd.project}`);
|
|
42
|
+
log.info(LOCAL_ONLY_NOTE);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The save `nerd dev` runs on start: silent when nothing changed, one warning when it did not go
|
|
46
|
+
* through, never fatal. A person starting their app should never be stopped by the backup.
|
|
47
|
+
*/
|
|
48
|
+
export async function silentSave(root) {
|
|
49
|
+
try {
|
|
50
|
+
const nerd = readNerdJson(root);
|
|
51
|
+
const api = ApiClient.fromConfig();
|
|
52
|
+
const out = await saveSnapshot(root, nerd, api, { quiet: true });
|
|
53
|
+
if (out.saved)
|
|
54
|
+
log.ok(`Saved ${out.fileCount} files to nerdsystem. ${LOCAL_ONLY_NOTE}`);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
const reason = err instanceof Error ? err.message.replace(/\s+\[[a-z_]+ \d{3}\]$/, "") : String(err);
|
|
58
|
+
const goesOn = "nerd dev continues; run nerd save later to keep a copy.";
|
|
59
|
+
if (/^Code not saved/.test(reason))
|
|
60
|
+
log.warn(reason);
|
|
61
|
+
// A pack failure already reads "Your code could not be saved (...)." in full; only the next step is added.
|
|
62
|
+
else if (/^Your code could not be saved/.test(reason))
|
|
63
|
+
log.warn(`${reason} ${goesOn}`);
|
|
64
|
+
else
|
|
65
|
+
log.warn(`Code not saved: ${reason}. ${goesOn}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ApiClient, Source } from "../api.js";
|
|
2
|
+
import { type PackOk, type PackResult } from "../snapshot.js";
|
|
3
|
+
/** The published version, read from package.json so a release bump is the only place it changes. */
|
|
4
|
+
export declare function cliVersion(): string;
|
|
5
|
+
/** Invariant 7: every surface that says "saved" also says this, in one short sentence. */
|
|
6
|
+
export declare const LOCAL_ONLY_NOTE = "Work after this point is only on this computer until the next deploy or nerd save.";
|
|
7
|
+
/** Snapshot ids this copy of the code has deployed, saved, or pulled; newest last. Empty when unknown. */
|
|
8
|
+
export declare function readLineage(root: string): string[];
|
|
9
|
+
/** Records a snapshot id for the lineage guard, keeping the newest 50. Never throws: the deploy already happened. */
|
|
10
|
+
export declare function appendLineage(root: string, id: string): void;
|
|
11
|
+
/**
|
|
12
|
+
* The sentence for a pack that did not go through (a file nerd could not read, one that vanished
|
|
13
|
+
* between listing and hashing, a second change under the retry). On the deploy path it also says
|
|
14
|
+
* nothing was deployed; on the save path there is no deploy to mention.
|
|
15
|
+
*/
|
|
16
|
+
export declare function packStopMessage(err: unknown, purpose: "deploy" | "save"): string;
|
|
17
|
+
/**
|
|
18
|
+
* Packs the folder, trying once more when a file changed under the first attempt (an editor save
|
|
19
|
+
* mid-pack). Any other failure to read the folder becomes a CliError with the sentence for `purpose`,
|
|
20
|
+
* never a raw Node error.
|
|
21
|
+
*/
|
|
22
|
+
export declare function packProject(root: string, purpose?: "deploy" | "save"): Promise<PackResult>;
|
|
23
|
+
/**
|
|
24
|
+
* POST .../sources with the headers the API verifies the bytes against. Only machine values travel as
|
|
25
|
+
* headers: a header value must be Latin-1 with no line breaks, so anything a person typed (the deploy
|
|
26
|
+
* note) goes in the JSON metadata of the deploy itself.
|
|
27
|
+
*/
|
|
28
|
+
export declare function uploadSnapshot(api: ApiClient, basePath: string, pack: PackOk, kind: "deploy" | "save"): Promise<Source>;
|
|
29
|
+
/** One line for an upload that did not go through, in the spec's words. The deploy stops; nothing was published. */
|
|
30
|
+
export declare function uploadStopMessage(err: unknown): string;
|
|
31
|
+
/** Prints the selection warnings (forced ignored files, oversized files left out). */
|
|
32
|
+
export declare function printPackWarnings(pack: PackResult): void;
|
|
33
|
+
/**
|
|
34
|
+
* Credential-shaped values in the text files a snapshot holds. A warning, never a block: `nerd deploy`
|
|
35
|
+
* already refuses a committed secret, and a save must not lose work over a key the agent is about to
|
|
36
|
+
* fix. Values are never printed; the mask carries the type and the last four characters.
|
|
37
|
+
*/
|
|
38
|
+
export declare function secretWarnings(root: string, pack: PackResult): string[];
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { findSecrets, TEXT_FILE } from "../gates/text.js";
|
|
5
|
+
import { packSnapshot, SnapshotError } from "../snapshot.js";
|
|
6
|
+
import { CliError, log } from "../util.js";
|
|
7
|
+
/*
|
|
8
|
+
* Shared pieces of the source-snapshot commands (docs/source-snapshots.md): packing with one retry,
|
|
9
|
+
* the upload with its headers, the lineage file, and the sentences every surface prints the same way.
|
|
10
|
+
*/
|
|
11
|
+
/** The published version, read from package.json so a release bump is the only place it changes. */
|
|
12
|
+
export function cliVersion() {
|
|
13
|
+
return createRequire(import.meta.url)("../../package.json").version;
|
|
14
|
+
}
|
|
15
|
+
/** Invariant 7: every surface that says "saved" also says this, in one short sentence. */
|
|
16
|
+
export const LOCAL_ONLY_NOTE = "Work after this point is only on this computer until the next deploy or nerd save.";
|
|
17
|
+
// ---------------------------------------------------------------------------------------------
|
|
18
|
+
// Lineage: the snapshot ids this folder deployed, saved, or pulled
|
|
19
|
+
// ---------------------------------------------------------------------------------------------
|
|
20
|
+
const LINEAGE_KEEP = 50;
|
|
21
|
+
function lineageFile(root) {
|
|
22
|
+
return path.join(root, ".nerd", "lineage.json");
|
|
23
|
+
}
|
|
24
|
+
/** Snapshot ids this copy of the code has deployed, saved, or pulled; newest last. Empty when unknown. */
|
|
25
|
+
export function readLineage(root) {
|
|
26
|
+
try {
|
|
27
|
+
const raw = JSON.parse(readFileSync(lineageFile(root), "utf8"));
|
|
28
|
+
return Array.isArray(raw.ids) ? raw.ids.filter((x) => typeof x === "string") : [];
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Records a snapshot id for the lineage guard, keeping the newest 50. Never throws: the deploy already happened. */
|
|
35
|
+
export function appendLineage(root, id) {
|
|
36
|
+
try {
|
|
37
|
+
const ids = readLineage(root).filter((x) => x !== id);
|
|
38
|
+
ids.push(id);
|
|
39
|
+
const file = lineageFile(root);
|
|
40
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
41
|
+
writeFileSync(file, `${JSON.stringify({ ids: ids.slice(-LINEAGE_KEEP) }, null, 2)}\n`);
|
|
42
|
+
// .nerd/ holds machine state (attempt counts, lineage), never code: keep it out of the repository.
|
|
43
|
+
const gi = path.join(root, ".gitignore");
|
|
44
|
+
if (existsSync(gi) && !readFileSync(gi, "utf8").includes(".nerd/"))
|
|
45
|
+
writeFileSync(gi, `${readFileSync(gi, "utf8").replace(/\n?$/, "\n")}.nerd/\n`);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// best effort
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// ---------------------------------------------------------------------------------------------
|
|
52
|
+
// Pack and upload
|
|
53
|
+
// ---------------------------------------------------------------------------------------------
|
|
54
|
+
/**
|
|
55
|
+
* The sentence for a pack that did not go through (a file nerd could not read, one that vanished
|
|
56
|
+
* between listing and hashing, a second change under the retry). On the deploy path it also says
|
|
57
|
+
* nothing was deployed; on the save path there is no deploy to mention.
|
|
58
|
+
*/
|
|
59
|
+
export function packStopMessage(err, purpose) {
|
|
60
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
61
|
+
return purpose === "deploy" ? `Your code could not be saved (${reason}). Nothing was deployed.` : `Your code could not be saved (${reason}).`;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Packs the folder, trying once more when a file changed under the first attempt (an editor save
|
|
65
|
+
* mid-pack). Any other failure to read the folder becomes a CliError with the sentence for `purpose`,
|
|
66
|
+
* never a raw Node error.
|
|
67
|
+
*/
|
|
68
|
+
export async function packProject(root, purpose = "deploy") {
|
|
69
|
+
const opts = { cli: cliVersion() };
|
|
70
|
+
try {
|
|
71
|
+
try {
|
|
72
|
+
return await packSnapshot(root, opts);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
if (err instanceof SnapshotError && err.code === "changed")
|
|
76
|
+
return await packSnapshot(root, opts);
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
throw new CliError(packStopMessage(err, purpose), 1, { code: "pack_failed" });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* POST .../sources with the headers the API verifies the bytes against. Only machine values travel as
|
|
86
|
+
* headers: a header value must be Latin-1 with no line breaks, so anything a person typed (the deploy
|
|
87
|
+
* note) goes in the JSON metadata of the deploy itself.
|
|
88
|
+
*/
|
|
89
|
+
export async function uploadSnapshot(api, basePath, pack, kind) {
|
|
90
|
+
const headers = {
|
|
91
|
+
"content-type": "application/gzip",
|
|
92
|
+
"x-nerd-source-kind": kind,
|
|
93
|
+
"x-nerd-source-sha256": pack.sha256,
|
|
94
|
+
"x-nerd-tree-hash": pack.treeHash,
|
|
95
|
+
"x-nerd-file-count": String(pack.fileCount),
|
|
96
|
+
"x-nerd-cli-version": pack.manifest.cli,
|
|
97
|
+
...(pack.manifest.git ? { "x-nerd-git-sha": pack.manifest.git.sha } : {}),
|
|
98
|
+
};
|
|
99
|
+
const r = await api.postRaw(`${basePath}/sources`, pack.gz, headers);
|
|
100
|
+
if (!r?.source?.id)
|
|
101
|
+
throw new CliError("The API accepted the upload but did not return a snapshot id.", 1, { code: "storage_unavailable", status: 502 });
|
|
102
|
+
return r.source;
|
|
103
|
+
}
|
|
104
|
+
/** One line for an upload that did not go through, in the spec's words. The deploy stops; nothing was published. */
|
|
105
|
+
export function uploadStopMessage(err) {
|
|
106
|
+
const reason = err instanceof Error ? err.message.replace(/\s+\[[a-z_]+ \d{3}\]$/, "") : String(err);
|
|
107
|
+
const status = err instanceof CliError ? err.status : undefined;
|
|
108
|
+
const infra = status === undefined || status >= 500 || status === 429;
|
|
109
|
+
const next = infra ? "Try again in a minute." : "Update nerd with `npm install -g nerdsystem@latest` and try again; if it keeps happening, report it.";
|
|
110
|
+
return `Your code could not be saved (${reason}). Nothing was deployed. ${next}`;
|
|
111
|
+
}
|
|
112
|
+
/** Prints the selection warnings (forced ignored files, oversized files left out). */
|
|
113
|
+
export function printPackWarnings(pack) {
|
|
114
|
+
for (const w of pack.warnings)
|
|
115
|
+
log.warn(w);
|
|
116
|
+
}
|
|
117
|
+
const SECRET_SCAN_MAX_BYTES = 1024 * 1024;
|
|
118
|
+
const SECRET_WARNING_LIMIT = 10;
|
|
119
|
+
/**
|
|
120
|
+
* Credential-shaped values in the text files a snapshot holds. A warning, never a block: `nerd deploy`
|
|
121
|
+
* already refuses a committed secret, and a save must not lose work over a key the agent is about to
|
|
122
|
+
* fix. Values are never printed; the mask carries the type and the last four characters.
|
|
123
|
+
*/
|
|
124
|
+
export function secretWarnings(root, pack) {
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const f of pack.manifest.files) {
|
|
127
|
+
if (!TEXT_FILE.test(f.path) || f.size > SECRET_SCAN_MAX_BYTES)
|
|
128
|
+
continue;
|
|
129
|
+
let text;
|
|
130
|
+
try {
|
|
131
|
+
text = readFileSync(path.join(root, ...f.path.split("/")), "utf8");
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
for (const hit of findSecrets(text, f.path, 5)) {
|
|
137
|
+
if (hit.downgrade)
|
|
138
|
+
continue;
|
|
139
|
+
out.push(`${hit.file}:${hit.line} looks like a ${hit.rule.replace(/-/g, " ")} (${hit.masked}). It is in the saved copy; keep secrets in \`nerd secret set\` and rotate this one if it is real.`);
|
|
140
|
+
if (out.length >= SECRET_WARNING_LIMIT)
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
package/dist/commands/trash.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export declare function trashList(opts: {
|
|
|
10
10
|
json: boolean;
|
|
11
11
|
org?: string | undefined;
|
|
12
12
|
}): Promise<void>;
|
|
13
|
-
/** `nerd trash restore <slug>`: undo a delete. The
|
|
13
|
+
/** `nerd trash restore <slug>`: undo a delete. The API re-publishes the last live version when it has one stored. */
|
|
14
14
|
export declare function trashRestore(slug: string | undefined, opts: {
|
|
15
15
|
json: boolean;
|
|
16
16
|
org?: string | undefined;
|
package/dist/commands/trash.js
CHANGED
|
@@ -71,7 +71,7 @@ export async function trashList(opts) {
|
|
|
71
71
|
table(r.projects.map((p) => ({ slug: p.slug, name: p.name, purgeAfter: p.purgeAfter.slice(0, 10) })), ["slug", "name", "purgeAfter"]);
|
|
72
72
|
log.info(`Restore one with: nerd trash restore <slug>${opts.org ? ` --org ${org}` : ""}`);
|
|
73
73
|
}
|
|
74
|
-
/** `nerd trash restore <slug>`: undo a delete. The
|
|
74
|
+
/** `nerd trash restore <slug>`: undo a delete. The API re-publishes the last live version when it has one stored. */
|
|
75
75
|
export async function trashRestore(slug, opts) {
|
|
76
76
|
if (!slug)
|
|
77
77
|
throw new CliError("Usage: nerd trash restore <slug> [--org <org>]");
|
|
@@ -79,6 +79,5 @@ export async function trashRestore(slug, opts) {
|
|
|
79
79
|
const r = await ApiClient.fromConfig().post(`/v1/orgs/${t.org}/trash/${t.project}`, {});
|
|
80
80
|
if (opts.json)
|
|
81
81
|
return printJson(r);
|
|
82
|
-
log.ok(`Restored ${t.org}/${r.project.slug}
|
|
83
|
-
log.info(r.next);
|
|
82
|
+
log.ok(`Restored ${t.org}/${r.project.slug}. ${r.next}`);
|
|
84
83
|
}
|
package/dist/gates/text.d.ts
CHANGED
|
@@ -68,3 +68,68 @@ export interface OsvVulnerability {
|
|
|
68
68
|
export declare function osvSeverity(v: OsvVulnerability): AdvisorySeverity;
|
|
69
69
|
/** CVSS v3.0/3.1 base score from a vector string, or null when the vector is malformed. */
|
|
70
70
|
export declare function cvss3BaseScore(vector: string): number | null;
|
|
71
|
+
/** Name of the first entry in every snapshot tar. */
|
|
72
|
+
export declare const SNAPSHOT_MANIFEST = ".nerd-snapshot.json";
|
|
73
|
+
/** Most files one snapshot may hold. */
|
|
74
|
+
export declare const MAX_SOURCE_FILES = 20000;
|
|
75
|
+
/** Largest compressed snapshot accepted, in bytes (25 MB). */
|
|
76
|
+
export declare const MAX_SOURCE_BYTES: number;
|
|
77
|
+
/** Largest uncompressed tar stream accepted, in bytes (200 MB). */
|
|
78
|
+
export declare const MAX_SOURCE_UNPACKED: number;
|
|
79
|
+
/** A single file over this is left out of the snapshot with a warning (50 MB). */
|
|
80
|
+
export declare const MAX_SNAPSHOT_FILE_BYTES: number;
|
|
81
|
+
/** A PAX or GNU long-name header larger than this is refused; real ones are a few hundred bytes. */
|
|
82
|
+
export declare const MAX_SNAPSHOT_HEADER_BYTES: number;
|
|
83
|
+
/** Longest entry name accepted, in UTF-16 code units. */
|
|
84
|
+
export declare const MAX_SNAPSHOT_NAME_LENGTH = 4096;
|
|
85
|
+
export interface SnapshotFile {
|
|
86
|
+
/** Posix path relative to the app root. */
|
|
87
|
+
path: string;
|
|
88
|
+
size: number;
|
|
89
|
+
/** Hex sha256 of the file content. */
|
|
90
|
+
sha256: string;
|
|
91
|
+
/** 0o644 (420) or 0o755 (493). */
|
|
92
|
+
mode: number;
|
|
93
|
+
}
|
|
94
|
+
export interface SnapshotManifest {
|
|
95
|
+
version: 1;
|
|
96
|
+
createdAt: string;
|
|
97
|
+
cli: string;
|
|
98
|
+
node: string;
|
|
99
|
+
git: {
|
|
100
|
+
sha: string;
|
|
101
|
+
branch: string;
|
|
102
|
+
} | null;
|
|
103
|
+
files: SnapshotFile[];
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Never in a snapshot, whatever git or the include list says: repository internals, build output,
|
|
107
|
+
* caches, local databases, env files, keys, package-manager credentials, and agent chat history.
|
|
108
|
+
* Matched against the posix path relative to the app root at any depth.
|
|
109
|
+
*/
|
|
110
|
+
export declare const SNAPSHOT_EXCLUDE: RegExp[];
|
|
111
|
+
export declare function isSnapshotExcluded(posixPath: string): boolean;
|
|
112
|
+
/** Directories at the app root whose whole tree a rebuild needs even when .gitignore hides it. */
|
|
113
|
+
export declare const SNAPSHOT_INCLUDE_DIRS: string[];
|
|
114
|
+
/**
|
|
115
|
+
* Files a rebuild needs; when .gitignore hides one it still goes into the snapshot, with a warning.
|
|
116
|
+
* Root-level config files and the trees in SNAPSHOT_INCLUDE_DIRS. The hard exclude still wins.
|
|
117
|
+
*/
|
|
118
|
+
export declare const SNAPSHOT_INCLUDE: RegExp[];
|
|
119
|
+
export declare function isSnapshotForced(posixPath: string): boolean;
|
|
120
|
+
/** True when an ignored directory may hold forced files, so a walker should still descend into it. */
|
|
121
|
+
export declare function isSnapshotForcedDir(posixDir: string): boolean;
|
|
122
|
+
/** The warning printed for every ignored file the snapshot keeps anyway. */
|
|
123
|
+
export declare function snapshotForcedWarning(posixPath: string): string;
|
|
124
|
+
/** Tar entry types a snapshot may hold: regular files, directories, PAX headers, GNU long names. */
|
|
125
|
+
export declare const SNAPSHOT_ENTRY_TYPES: Set<string>;
|
|
126
|
+
/**
|
|
127
|
+
* Checks one tar entry against the snapshot rules. Returns null when it passes, else a short reason.
|
|
128
|
+
* The same check runs on the control plane while the upload streams and in the CLI before a file
|
|
129
|
+
* is written on restore. `type` is the one-character typeflag ("" counts as "\0").
|
|
130
|
+
*/
|
|
131
|
+
export declare function validateSnapshotEntry(entry: {
|
|
132
|
+
name: string;
|
|
133
|
+
type: string;
|
|
134
|
+
size: number;
|
|
135
|
+
}): string | null;
|
package/dist/gates/text.js
CHANGED
|
@@ -150,3 +150,111 @@ export function cvss3BaseScore(vector) {
|
|
|
150
150
|
const roundUp = (x) => Math.ceil(x * 10 - 1e-9) / 10;
|
|
151
151
|
return scopeChanged ? roundUp(Math.min(1.08 * (impact + exploitability), 10)) : roundUp(Math.min(impact + exploitability, 10));
|
|
152
152
|
}
|
|
153
|
+
/*
|
|
154
|
+
* Source snapshots (docs/source-snapshots.md). The CLI packs them, the control plane validates
|
|
155
|
+
* them while streaming, and both read the lists below, so a rule added here holds on both sides.
|
|
156
|
+
*/
|
|
157
|
+
/** Name of the first entry in every snapshot tar. */
|
|
158
|
+
export const SNAPSHOT_MANIFEST = ".nerd-snapshot.json";
|
|
159
|
+
/** Most files one snapshot may hold. */
|
|
160
|
+
export const MAX_SOURCE_FILES = 20_000;
|
|
161
|
+
/** Largest compressed snapshot accepted, in bytes (25 MB). */
|
|
162
|
+
export const MAX_SOURCE_BYTES = 25 * 1024 * 1024;
|
|
163
|
+
/** Largest uncompressed tar stream accepted, in bytes (200 MB). */
|
|
164
|
+
export const MAX_SOURCE_UNPACKED = 200 * 1024 * 1024;
|
|
165
|
+
/** A single file over this is left out of the snapshot with a warning (50 MB). */
|
|
166
|
+
export const MAX_SNAPSHOT_FILE_BYTES = 50 * 1024 * 1024;
|
|
167
|
+
/** A PAX or GNU long-name header larger than this is refused; real ones are a few hundred bytes. */
|
|
168
|
+
export const MAX_SNAPSHOT_HEADER_BYTES = 1024 * 1024;
|
|
169
|
+
/** Longest entry name accepted, in UTF-16 code units. */
|
|
170
|
+
export const MAX_SNAPSHOT_NAME_LENGTH = 4096;
|
|
171
|
+
/**
|
|
172
|
+
* Never in a snapshot, whatever git or the include list says: repository internals, build output,
|
|
173
|
+
* caches, local databases, env files, keys, package-manager credentials, and agent chat history.
|
|
174
|
+
* Matched against the posix path relative to the app root at any depth.
|
|
175
|
+
*/
|
|
176
|
+
export const SNAPSHOT_EXCLUDE = [
|
|
177
|
+
/(^|\/)(\.git|node_modules|dist|\.wrangler|\.vite|coverage|\.nerd|\.turbo|\.cache|\.aws|\.ssh|\.specstory)(\/|$)/,
|
|
178
|
+
/(^|\/)\.aider[^/]*(\/|$)/,
|
|
179
|
+
/(^|\/)(\.DS_Store|Thumbs\.db|\.npmrc|\.yarnrc\.yml|\.netrc)$/i,
|
|
180
|
+
ENV_FILES,
|
|
181
|
+
/\.(pem|key|p12|pfx|jks|keystore|sqlite|sqlite3|db|db-journal|log)$/i,
|
|
182
|
+
/(^|\/)(id_rsa|id_ed25519)[^/]*$/,
|
|
183
|
+
/(^|\/)\.claude\/settings\.local\.json$/,
|
|
184
|
+
/^\.nerd-snapshot\.json$/,
|
|
185
|
+
];
|
|
186
|
+
export function isSnapshotExcluded(posixPath) {
|
|
187
|
+
return SNAPSHOT_EXCLUDE.some((re) => re.test(posixPath));
|
|
188
|
+
}
|
|
189
|
+
/** Directories at the app root whose whole tree a rebuild needs even when .gitignore hides it. */
|
|
190
|
+
export const SNAPSHOT_INCLUDE_DIRS = ["migrations", "src", "public", ".claude", ".cursor", ".github"];
|
|
191
|
+
/**
|
|
192
|
+
* Files a rebuild needs; when .gitignore hides one it still goes into the snapshot, with a warning.
|
|
193
|
+
* Root-level config files and the trees in SNAPSHOT_INCLUDE_DIRS. The hard exclude still wins.
|
|
194
|
+
*/
|
|
195
|
+
export const SNAPSHOT_INCLUDE = [
|
|
196
|
+
/^(package\.json|pnpm-lock\.yaml|package-lock\.json|yarn\.lock|nerd\.json|wrangler\.json[^/]*|vite\.config\.[^/]+|tsconfig[^/]*\.json|drizzle\.config\.[^/]+|index\.html|CLAUDE\.md|AGENTS\.md|GEMINI\.md|\.mcp\.json|README\.md|\.gitignore)$/,
|
|
197
|
+
new RegExp(`^(${SNAPSHOT_INCLUDE_DIRS.map((d) => d.replace(/\./g, "\\.")).join("|")})/`),
|
|
198
|
+
];
|
|
199
|
+
export function isSnapshotForced(posixPath) {
|
|
200
|
+
return !isSnapshotExcluded(posixPath) && SNAPSHOT_INCLUDE.some((re) => re.test(posixPath));
|
|
201
|
+
}
|
|
202
|
+
/** True when an ignored directory may hold forced files, so a walker should still descend into it. */
|
|
203
|
+
export function isSnapshotForcedDir(posixDir) {
|
|
204
|
+
const dir = posixDir.replace(/\/+$/, "");
|
|
205
|
+
if (dir === "" || isSnapshotExcluded(`${dir}/`))
|
|
206
|
+
return false;
|
|
207
|
+
return SNAPSHOT_INCLUDE_DIRS.some((d) => dir === d || dir.startsWith(`${d}/`));
|
|
208
|
+
}
|
|
209
|
+
/** The warning printed for every ignored file the snapshot keeps anyway. */
|
|
210
|
+
export function snapshotForcedWarning(posixPath) {
|
|
211
|
+
return `\`${posixPath}\` is in .gitignore but a rebuild needs it; it is in the saved copy`;
|
|
212
|
+
}
|
|
213
|
+
/** Tar entry types a snapshot may hold: regular files, directories, PAX headers, GNU long names. */
|
|
214
|
+
export const SNAPSHOT_ENTRY_TYPES = new Set(["0", "\0", "5", "x", "L"]);
|
|
215
|
+
const TYPE_NAMES = { "1": "hard link", "2": "symbolic link", "3": "character device", "4": "block device", "6": "fifo", "7": "contiguous file", g: "global PAX header", K: "GNU long link", S: "sparse file" };
|
|
216
|
+
function shortName(name) {
|
|
217
|
+
const clean = name.replace(/[^\x20-\x7e]/g, "?");
|
|
218
|
+
return clean.length > 120 ? `${clean.slice(0, 117)}...` : clean;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Checks one tar entry against the snapshot rules. Returns null when it passes, else a short reason.
|
|
222
|
+
* The same check runs on the control plane while the upload streams and in the CLI before a file
|
|
223
|
+
* is written on restore. `type` is the one-character typeflag ("" counts as "\0").
|
|
224
|
+
*/
|
|
225
|
+
export function validateSnapshotEntry(entry) {
|
|
226
|
+
const type = entry.type === "" ? "\0" : entry.type;
|
|
227
|
+
const name = entry.name;
|
|
228
|
+
if (!SNAPSHOT_ENTRY_TYPES.has(type)) {
|
|
229
|
+
const what = TYPE_NAMES[type] ?? `type ${JSON.stringify(type)}`;
|
|
230
|
+
return `entry "${shortName(name)}" is a ${what}; only regular files are allowed`;
|
|
231
|
+
}
|
|
232
|
+
if (!Number.isSafeInteger(entry.size) || entry.size < 0)
|
|
233
|
+
return `entry "${shortName(name)}" has an invalid size`;
|
|
234
|
+
if ((type === "0" || type === "\0") && entry.size > MAX_SNAPSHOT_FILE_BYTES)
|
|
235
|
+
return `entry "${shortName(name)}" is larger than ${MAX_SNAPSHOT_FILE_BYTES / (1024 * 1024)} MB`;
|
|
236
|
+
if ((type === "x" || type === "L") && entry.size > MAX_SNAPSHOT_HEADER_BYTES)
|
|
237
|
+
return `entry "${shortName(name)}" has an oversized ${type === "x" ? "PAX" : "long-name"} header`;
|
|
238
|
+
if (name.length === 0)
|
|
239
|
+
return "an entry has no name";
|
|
240
|
+
if (name.length > MAX_SNAPSHOT_NAME_LENGTH)
|
|
241
|
+
return `entry "${shortName(name)}" has a name longer than ${MAX_SNAPSHOT_NAME_LENGTH} characters`;
|
|
242
|
+
if (name.includes("\0"))
|
|
243
|
+
return `entry "${shortName(name)}" has a NUL byte in its name`;
|
|
244
|
+
if (name.includes("\\"))
|
|
245
|
+
return `entry "${shortName(name)}" has a backslash in its name; paths must use /`;
|
|
246
|
+
if (name.startsWith("/"))
|
|
247
|
+
return `entry "${shortName(name)}" is an absolute path`;
|
|
248
|
+
const relative = type === "5" && name.endsWith("/") ? name.slice(0, -1) : name;
|
|
249
|
+
for (const segment of relative.split("/")) {
|
|
250
|
+
if (segment === "")
|
|
251
|
+
return `entry "${shortName(name)}" has an empty path segment`;
|
|
252
|
+
if (segment === ".")
|
|
253
|
+
return `entry "${shortName(name)}" has a "." path segment`;
|
|
254
|
+
if (segment === "..")
|
|
255
|
+
return `entry "${shortName(name)}" has a ".." path segment`;
|
|
256
|
+
if (segment === ".git")
|
|
257
|
+
return `entry "${shortName(name)}" is inside a .git directory`;
|
|
258
|
+
}
|
|
259
|
+
return null;
|
|
260
|
+
}
|