nerdsystem 0.0.10 → 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.
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { ApiClient } from "../api.js";
6
6
  import { projectPath, writeNerdJson } from "../project.js";
7
- import { CliError, log } from "../util.js";
7
+ import { CliError, log, printJson } from "../util.js";
8
8
  const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,28}[a-z0-9])?$/;
9
9
  function templateDir() {
10
10
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -88,9 +88,10 @@ function writeReadme(root, name, description) {
88
88
  }
89
89
  /**
90
90
  * A repository from the first minute: deploy-on-push and the agent's commits both expect one. Skipped
91
- * silently when git is missing, and when the folder already sits inside a repository.
91
+ * silently when git is missing, and when the folder already sits inside a repository. `nerd pull`
92
+ * uses it too, with its own commit message.
92
93
  */
93
- function initGitRepo(root) {
94
+ export function initGitRepo(root, message = "Start from the nerdsystem template") {
94
95
  try {
95
96
  try {
96
97
  execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: root, stdio: "ignore" });
@@ -101,7 +102,7 @@ function initGitRepo(root) {
101
102
  }
102
103
  execFileSync("git", ["init", "-q"], { cwd: root, stdio: "ignore" });
103
104
  execFileSync("git", ["add", "-A"], { cwd: root, stdio: "ignore" });
104
- const commit = ["commit", "-q", "-m", "Start from the nerdsystem template"];
105
+ const commit = ["commit", "-q", "-m", message];
105
106
  try {
106
107
  execFileSync("git", commit, { cwd: root, stdio: "ignore" });
107
108
  }
@@ -168,10 +169,18 @@ export async function init(dir, args) {
168
169
  }
169
170
  catch (err) {
170
171
  const reason = err instanceof Error ? err.message : String(err);
171
- throw new CliError(`The folder is ready but the project was not created: ${reason}\nFix that, then run: cd ${dir} && nerd link --slug ${slug}${args.org ? ` --org ${args.org}` : ""}`);
172
+ const code = err instanceof CliError && err.code ? err.code : "error";
173
+ // A plan limit is not something to fix in the folder: the API's message says how to free a slot.
174
+ const link = `cd ${dir} && nerd link --slug ${slug}${args.org ? ` --org ${args.org}` : ""}`;
175
+ const next = code === "plan_limit" ? `When a slot is free, run: ${link}` : `Fix that, then run: ${link}`;
176
+ const message = `The folder is ready but the project was not created: ${reason}`;
177
+ // Same pattern as `nerd check --json`: the JSON goes to stdout, the exit code stays non-zero.
178
+ if (args.json)
179
+ printJson({ error: { code, message }, dir, slug, next });
180
+ throw new CliError(`${message}\n${next}`, 1, { code });
172
181
  }
173
182
  initGitRepo(root);
174
183
  log.ok(`Ready. Next:\n cd ${dir}\n pnpm install\n nerd dev\n\nThen open the folder in your coding agent and ask it to build the first feature; the nerdsystem skill walks it through.\nDeploys go to ${project.url}`);
175
184
  }
176
185
  // Kept in sync with packages/sdk/package.json by the release script.
177
- const SDK_VERSION = "0.0.10";
186
+ const SDK_VERSION = "0.0.12";
@@ -2,7 +2,8 @@
2
2
  * `nerd login` with no token starts a device login: prints a link, the person approves it in the
3
3
  * dashboard (or from the email when --email is given), and the token arrives here. `--token` still
4
4
  * works for scripts. A pending login is remembered, so running `nerd login` again after approving
5
- * picks it up instead of starting over.
5
+ * picks it up instead of starting over. `--email` with a different address than the pending login's
6
+ * starts a fresh one, so a typo does not lock the person out until the code expires.
6
7
  */
7
8
  export declare function login(args: {
8
9
  token?: string;
@@ -8,7 +8,8 @@ const WAIT_S = 90;
8
8
  * `nerd login` with no token starts a device login: prints a link, the person approves it in the
9
9
  * dashboard (or from the email when --email is given), and the token arrives here. `--token` still
10
10
  * works for scripts. A pending login is remembered, so running `nerd login` again after approving
11
- * picks it up instead of starting over.
11
+ * picks it up instead of starting over. `--email` with a different address than the pending login's
12
+ * starts a fresh one, so a typo does not lock the person out until the code expires.
12
13
  */
13
14
  export async function login(args) {
14
15
  const cfg = loadConfig();
@@ -17,23 +18,30 @@ export async function login(args) {
17
18
  if (token)
18
19
  return finish(apiUrl, token, args.json);
19
20
  const anon = new ApiClient(apiUrl, undefined);
20
- let pending = cfg.pendingLogin && cfg.pendingLogin.apiUrl === apiUrl && cfg.pendingLogin.expiresAt > Date.now() ? cfg.pendingLogin : undefined;
21
+ const email = args.email?.trim().toLowerCase() || undefined;
22
+ const saved = cfg.pendingLogin;
23
+ // Resume only a login for this API that has not expired and, when --email is given, that went to the same address.
24
+ const resumable = saved && saved.apiUrl === apiUrl && saved.expiresAt > Date.now() && (!email || saved.email === email);
25
+ let pending = resumable ? saved : undefined;
21
26
  if (!pending) {
22
27
  const client = `nerd CLI on ${os.hostname()} (${os.platform()})`;
23
- const start = await anon.post("/session/device", { client, ...(args.email ? { email: args.email } : {}) });
24
- pending = { apiUrl, deviceCode: start.deviceCode, userCode: start.userCode, verificationUrl: start.verificationUrl, interval: start.interval, expiresAt: Date.now() + start.expiresIn * 1000 };
28
+ const start = await anon.post("/session/device", { client, ...(email ? { email } : {}) });
29
+ pending = { apiUrl, deviceCode: start.deviceCode, userCode: start.userCode, verificationUrl: start.verificationUrl, interval: start.interval, expiresAt: Date.now() + start.expiresIn * 1000, ...(email ? { email } : {}) };
25
30
  saveConfig({ ...cfg, pendingLogin: pending });
26
31
  if (args.json)
27
32
  printJson({ status: "pending", userCode: start.userCode, verificationUrl: start.verificationUrl, expiresIn: start.expiresIn });
28
33
  else {
29
- log.info(`Open this link in your browser and approve code ${start.userCode}:\n\n ${start.verificationUrl}\n`);
30
- if (args.email)
31
- log.info(`An email went to ${args.email}: the link signs you in (creating your account if you are new) and opens a page where you press Approve.`);
34
+ if (email) {
35
+ log.info(`An email went to ${email}. Click the link in it (check spam if nothing arrives within a minute), then press Approve on the page it opens. The link works once and expires in 15 minutes. If it never arrives, open ${start.verificationUrl} and sign in there.`);
36
+ }
37
+ else {
38
+ log.info(`Open this link in your browser and approve code ${start.userCode}:\n\n ${start.verificationUrl}\n`);
39
+ }
32
40
  log.info("Waiting for approval…");
33
41
  }
34
42
  }
35
43
  else if (!args.json) {
36
- log.info(`Still waiting for code ${pending.userCode} to be approved at:\n\n ${pending.verificationUrl}\n`);
44
+ log.info(`Still waiting for code ${pending.userCode} to be approved at:\n\n ${pending.verificationUrl}\n\n(run nerd login --email <address> with a different address to start over)`);
37
45
  }
38
46
  const deadline = Math.min(Date.now() + WAIT_S * 1000, pending.expiresAt);
39
47
  while (Date.now() < deadline) {
@@ -1,8 +1,22 @@
1
+ interface Deployment {
2
+ id: string;
3
+ status: string;
4
+ environment: string;
5
+ note: string | null;
6
+ commitSha: string | null;
7
+ createdAt: string;
8
+ artifactStored: boolean;
9
+ rolledBackFrom: string | null;
10
+ sourceId?: string | null;
11
+ sourceSkippedReason?: string | null;
12
+ }
1
13
  /** Re-publish the previous production deployment, or a specific one. */
2
14
  export declare function rollback(target: string | undefined, opts: {
3
15
  json: boolean;
4
16
  note?: string;
5
17
  }): Promise<void>;
18
+ /** The folder did not move with the rollback; say so, and how to get the rolled-back code when it was saved. */
19
+ export declare function rollbackFolderNote(deployment: Pick<Deployment, "id" | "sourceId" | "sourceSkippedReason">, project: string): string;
6
20
  export declare function cron(schedule: string | undefined, opts: {
7
21
  json: boolean;
8
22
  }): Promise<void>;
@@ -28,3 +42,4 @@ export declare function secretSet(name: string | undefined, value: string | unde
28
42
  export declare function secretRemove(name: string | undefined, opts: {
29
43
  json: boolean;
30
44
  }): Promise<void>;
45
+ export {};
@@ -19,6 +19,13 @@ export async function rollback(target, opts) {
19
19
  if (opts.json)
20
20
  return printJson(r);
21
21
  log.ok(`Live again as ${r.deployment.id}. The database was not changed.`);
22
+ log.info(rollbackFolderNote(r.deployment, nerd.project));
23
+ }
24
+ /** The folder did not move with the rollback; say so, and how to get the rolled-back code when it was saved. */
25
+ export function rollbackFolderNote(deployment, project) {
26
+ if (deployment.sourceId)
27
+ return `The folder on this computer still has the newer code. To work from the version you rolled back to, run: nerd pull --deployment ${deployment.id} --dir ${project}-rolled-back`;
28
+ return `The folder on this computer still has the newer code. The version you rolled back to has no saved code (${deployment.sourceSkippedReason ?? "it was deployed with an older nerd"}), so nerd pull has nothing to give back for it.`;
22
29
  }
23
30
  export async function cron(schedule, opts) {
24
31
  const nerd = readNerdJson(findProjectRoot());
@@ -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
  }
package/dist/config.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export interface GlobalConfig {
2
2
  apiUrl: string;
3
3
  token?: string;
4
- /** A device login that was started but not yet approved; `nerd login` resumes it. */
4
+ /** A device login that was started but not yet approved; `nerd login` resumes it. `email` is the address the link went to, so `--email` with another address starts over. */
5
5
  pendingLogin?: {
6
6
  apiUrl: string;
7
7
  deviceCode: string;
@@ -9,6 +9,7 @@ export interface GlobalConfig {
9
9
  verificationUrl: string;
10
10
  interval: number;
11
11
  expiresAt: number;
12
+ email?: string;
12
13
  };
13
14
  }
14
15
  export declare const DEFAULT_API_URL = "https://api.nerdsystem.com";