nerdsystem 0.0.11 → 0.0.13

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.
@@ -17,7 +17,7 @@ export declare function domainsList(opts: {
17
17
  export declare function domainsAdd(hostname: string | undefined, opts: {
18
18
  json: boolean;
19
19
  }): Promise<void>;
20
- export declare function domainsRemove(id: string | undefined, opts: {
20
+ export declare function domainsRemove(idOrHostname: string | undefined, opts: {
21
21
  json: boolean;
22
22
  }): Promise<void>;
23
23
  export declare function logs(opts: {
@@ -54,11 +54,21 @@ export async function domainsAdd(hostname, opts) {
54
54
  return printJson(r);
55
55
  log.ok(`Added ${r.domain.hostname}. Create this DNS record, then check with \`nerd domains\`:\n\n ${r.domain.hostname} CNAME ${r.cnameTarget}`);
56
56
  }
57
- export async function domainsRemove(id, opts) {
58
- if (!id)
59
- throw new CliError("Usage: nerd domains remove <domainId>");
57
+ export async function domainsRemove(idOrHostname, opts) {
58
+ if (!idOrHostname)
59
+ throw new CliError("Usage: nerd domains remove <hostname or domain id>");
60
60
  const nerd = readNerdJson(findProjectRoot());
61
- const r = await ApiClient.fromConfig().delete(projectPath(nerd, `/domains/${id}`));
61
+ const api = ApiClient.fromConfig();
62
+ // People (and agents) pass the hostname they added; the API deletes by id, so look it up.
63
+ let id = idOrHostname;
64
+ if (!idOrHostname.startsWith("dom_")) {
65
+ const { domains } = await api.get(projectPath(nerd, "/domains"));
66
+ const match = domains.find((d) => d.hostname === idOrHostname.toLowerCase());
67
+ if (!match)
68
+ throw new CliError(`No custom domain ${idOrHostname} on this project. \`nerd domains\` lists them.`);
69
+ id = match.id;
70
+ }
71
+ const r = await api.delete(projectPath(nerd, `/domains/${id}`));
62
72
  if (opts.json)
63
73
  return printJson(r);
64
74
  log.ok("Removed.");
@@ -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
+ }
@@ -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 app stays offline until the next deploy or rollback. */
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;
@@ -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 app stays offline until the next deploy or rollback. */
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
  }
@@ -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;