nerdsystem 0.0.6 → 0.0.8

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 CHANGED
@@ -11,3 +11,14 @@ nerd deploy # checks, builds, migrates, ships
11
11
  ```
12
12
 
13
13
  Every command takes `--json` for agents. `nerd help` lists them all.
14
+
15
+ More you will reach for:
16
+
17
+ ```sh
18
+ nerd db export # SQL dump of the production database to <slug>-<date>.sql (--out file.sql)
19
+ nerd delete --yes # take the app offline; data kept a week in the trash
20
+ nerd trash # deleted projects you can still restore
21
+ nerd trash restore <slug> # bring one back, then nerd deploy or roll back
22
+ ```
23
+
24
+ `nerd check` prints how many gates ran and which were skipped (semgrep and gitleaks are optional locally; git builds run them in the builder). Platform operators also have `nerd admin --help`.
package/dist/api.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { loadConfig } from "./config.js";
2
- import { CliError } from "./util.js";
2
+ import { CliError, NOT_LOGGED_IN } from "./util.js";
3
3
  export class ApiClient {
4
4
  apiUrl;
5
5
  token;
@@ -10,7 +10,7 @@ export class ApiClient {
10
10
  static fromConfig(opts = {}) {
11
11
  const cfg = loadConfig();
12
12
  if (opts.requireAuth !== false && !cfg.token) {
13
- throw new CliError("Not logged in. Run `nerd login` (or set NERD_TOKEN).");
13
+ throw new CliError(NOT_LOGGED_IN);
14
14
  }
15
15
  return new ApiClient(cfg.apiUrl, cfg.token);
16
16
  }
@@ -38,7 +38,7 @@ export class ApiClient {
38
38
  const err = json?.error;
39
39
  const details = err?.details ? `\n${JSON.stringify(err.details, null, 2)}` : "";
40
40
  // The message leads; the code and status follow for people who need them.
41
- throw new CliError(`${err?.message ?? "request failed"} [${err?.code ?? "error"} ${res.status}]${details}`);
41
+ throw new CliError(`${err?.message ?? "request failed"} [${err?.code ?? "error"} ${res.status}]${details}`, 1, { code: err?.code ?? "error", status: res.status });
42
42
  }
43
43
  return json;
44
44
  }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export declare function main(argv: string[]): Promise<void>;
2
+ /**
3
+ * Runs one command and turns any failure into a non-zero exit code. `process.exitCode` is set
4
+ * rather than calling `process.exit()`, so piped stdout (including `--json` output) is flushed
5
+ * before the process ends. Returns the exit code for tests.
6
+ */
7
+ export declare function runCli(argv: string[]): Promise<number>;
package/dist/cli.js ADDED
@@ -0,0 +1,311 @@
1
+ import { parseArgs } from "node:util";
2
+ import { authConfig, authSecret } from "./commands/auth.js";
3
+ import { check } from "./commands/check.js";
4
+ import { dbExport, dbMigrate, dbMigrations, dbQuery } from "./commands/db.js";
5
+ import { deploy, deployments } from "./commands/deploy.js";
6
+ import { dev } from "./commands/dev.js";
7
+ import { doctor } from "./commands/doctor.js";
8
+ import { cron, health, rollback, secretList, secretRemove, secretSet, transfer, waitlist } from "./commands/ops.js";
9
+ import { adminHelp, adminSuspend, adminUnsuspend } from "./commands/admin.js";
10
+ import { agentsSync } from "./commands/agents.js";
11
+ import { billingPortal, billingShow, billingUpgrade } from "./commands/billing.js";
12
+ import { auditExport, clientsCreate, clientsList, clientsRelease, domainsAdd, domainsList, domainsRemove, logs, membersInvite, membersList, membersRemove } from "./commands/org.js";
13
+ import { buildsList, buildsShow, buildsTrigger } from "./commands/builds.js";
14
+ import { githubConnect, githubLink, githubStatus, githubUnlink } from "./commands/github.js";
15
+ import { init, linkProject } from "./commands/init.js";
16
+ import { login, whoami } from "./commands/login.js";
17
+ import { deleteProject, trashList, trashRestore } from "./commands/trash.js";
18
+ import { findProjectRoot } from "./project.js";
19
+ import { CliError, log } from "./util.js";
20
+ const HELP = `nerd — deploy apps to nerdsystem
21
+
22
+ nerd login [--email you@x.com] Sign in or sign up: prints a link to approve in the browser (--email sends it; --token nsk_... for scripts)
23
+ nerd whoami Show the current user and orgs
24
+ nerd doctor Check Node, pnpm, git, login, and the project
25
+ nerd init <dir> [--org] [--name] [--describe "what it does"] Create an app and a project for it
26
+ nerd link --slug <project> [--org] Link this directory to an existing or new project
27
+ nerd dev [vite args] Run locally (writes .dev.vars, applies local migrations)
28
+ nerd check [--no-build] [--rule id] [--json] Run every gate a deploy runs; --json gives failures the agent can fix (alias: nerd verify)
29
+ nerd deploy [--note "what changed"] Check, build, migrate, upload. Prints the URL
30
+ nerd rollback [deploymentId] Re-publish the previous production bundle
31
+ nerd cron [off|hourly|daily] Scheduled job: the platform calls POST /api/cron
32
+ nerd transfer <org> Move this project to another org you administer
33
+ nerd delete [--slug p] [--org o] --yes Take the app offline; data kept a week in the trash
34
+ nerd trash [--org o] Deleted projects you can still restore
35
+ nerd trash restore <slug> [--org o] Bring a deleted project back (then deploy or roll back)
36
+ nerd health Platform component health (operators)
37
+ nerd waitlist [approve <id>] Who is waiting for access while sign-up is invite-only (operators)
38
+ nerd deployments List recent deployments
39
+ nerd db query "<sql>" Run SQL on the production database
40
+ nerd db migrate Apply pending migrations to production
41
+ nerd db migrations List applied migrations
42
+ nerd db export [--out file.sql] Download a SQL dump of the production database
43
+ nerd auth config [--roles a,b] [--default-role r] [--signup on|off]
44
+ nerd auth secret Mint a dev tenant secret into .dev.vars
45
+ nerd secret set NAME [value] App secret for production (c.env.NAME); value from stdin when omitted
46
+ nerd secret list | rm NAME Names only; values are never shown
47
+ nerd github connect Install the GitHub App for this org (prints a link)
48
+ nerd github link owner/repo [--branch main] [--root-dir dir] [--no-previews] [--require-pr] [--agent @claude]
49
+ nerd github status | unlink
50
+ nerd builds [--limit n] List git-triggered builds
51
+ nerd builds show <id> Build details and log
52
+ nerd builds trigger [--branch b] [--commit sha]
53
+ nerd agents sync Regenerate AGENTS.md, Cursor, Copilot, Gemini files from the skill
54
+ nerd billing [--org slug] Plan, limits, and usage for the org
55
+ nerd billing upgrade --plan pro|studio [--annual] Start checkout
56
+ nerd billing portal Manage cards, invoices, and plan
57
+ nerd members [invite <email> --role r | remove <userId>] Org members and invitations
58
+ nerd clients [create <slug> --name n | release <slug>] Studio: one org per client, billed through yours
59
+ nerd domains [add <hostname> | remove <id>] Custom domains for this project
60
+ nerd logs [--since 30m] [--limit 100] [--q text] [--pr n] Logs from the deployed app
61
+ nerd audit [--out file.csv] Export deployments and builds (Studio)
62
+
63
+ Every command accepts --json for machine-readable output.
64
+ Operator: nerd admin --help`;
65
+ const BOOL = { type: "boolean", default: false };
66
+ const STR = { type: "string" };
67
+ export async function main(argv) {
68
+ const [cmd, ...rest] = argv;
69
+ if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
70
+ console.log(HELP);
71
+ return;
72
+ }
73
+ const parse = (options, allowPositionals = true) => parseArgs({ args: rest, options: { json: BOOL, ...options }, allowPositionals, strict: cmd !== "dev" });
74
+ switch (cmd) {
75
+ case "login": {
76
+ const { values } = parse({ token: STR, "api-url": STR, email: STR });
77
+ return login(values);
78
+ }
79
+ case "doctor": {
80
+ const { values } = parse({});
81
+ return doctor({ json: Boolean(values.json) });
82
+ }
83
+ case "whoami": {
84
+ const { values } = parse({});
85
+ return whoami({ json: Boolean(values.json) });
86
+ }
87
+ case "init": {
88
+ const { values, positionals } = parse({ org: STR, name: STR, slug: STR, describe: STR, "no-create": BOOL });
89
+ return init(positionals[0], values);
90
+ }
91
+ case "link": {
92
+ const { values } = parse({ org: STR, slug: STR, name: STR, "no-create": BOOL });
93
+ if (!values.slug)
94
+ throw new CliError("Usage: nerd link --slug <project> [--org <org>] [--name 'My App']");
95
+ const root = (() => {
96
+ try {
97
+ return findProjectRoot();
98
+ }
99
+ catch {
100
+ return process.cwd();
101
+ }
102
+ })();
103
+ await linkProject(root, { ...(values.org ? { org: values.org } : {}), slug: values.slug, name: values.name ?? values.slug, create: !values["no-create"] });
104
+ return;
105
+ }
106
+ case "dev":
107
+ return dev(rest);
108
+ case "check":
109
+ case "verify": {
110
+ const { values } = parse({ "no-build": BOOL, "no-remote": BOOL, "no-typecheck": BOOL, rule: STR });
111
+ return check({ json: Boolean(values.json), "no-build": Boolean(values["no-build"]), "no-remote": Boolean(values["no-remote"]), ...(values.rule ? { rule: values.rule } : {}) });
112
+ }
113
+ case "deploy": {
114
+ const { values } = parse({ "skip-check": BOOL, "skip-build": BOOL, note: STR });
115
+ return deploy(values);
116
+ }
117
+ case "rollback": {
118
+ const { values, positionals } = parse({ note: STR });
119
+ return rollback(positionals[0], { json: Boolean(values.json), ...(values.note ? { note: values.note } : {}) });
120
+ }
121
+ case "cron": {
122
+ const { values, positionals } = parse({});
123
+ return cron(positionals[0], { json: Boolean(values.json) });
124
+ }
125
+ case "transfer": {
126
+ const { values, positionals } = parse({});
127
+ return transfer(positionals[0], { json: Boolean(values.json) });
128
+ }
129
+ case "delete": {
130
+ const { values } = parse({ org: STR, slug: STR, yes: BOOL, force: BOOL }, false);
131
+ return deleteProject({ json: Boolean(values.json), yes: Boolean(values.yes || values.force), ...(values.org ? { org: values.org } : {}), ...(values.slug ? { slug: values.slug } : {}) });
132
+ }
133
+ case "trash": {
134
+ const [sub, ...subRest] = rest;
135
+ const isSub = sub === "restore" || sub === "list";
136
+ const { values, positionals } = parseArgs({ args: isSub ? subRest : rest, options: { json: BOOL, org: STR }, allowPositionals: true });
137
+ const o = { json: Boolean(values.json), ...(values.org ? { org: values.org } : {}) };
138
+ if (sub === "restore")
139
+ return trashRestore(positionals[0], o);
140
+ return trashList(o);
141
+ }
142
+ case "health": {
143
+ const { values } = parse({});
144
+ return health({ json: Boolean(values.json) });
145
+ }
146
+ case "waitlist": {
147
+ const [sub, ...subRest] = rest;
148
+ const { values, positionals } = parseArgs({ args: sub === "approve" ? subRest : rest, options: { json: BOOL }, allowPositionals: true });
149
+ return waitlist({ json: Boolean(values.json), ...(sub === "approve" ? { approve: positionals[0] } : {}) });
150
+ }
151
+ case "deployments": {
152
+ const { values } = parse({ limit: { type: "string", default: "10" } });
153
+ return deployments(values);
154
+ }
155
+ case "db": {
156
+ const [sub, ...subRest] = rest;
157
+ const { values, positionals } = parseArgs({ args: subRest, options: { json: BOOL, out: STR }, allowPositionals: true });
158
+ if (sub === "query")
159
+ return dbQuery(positionals[0], { json: Boolean(values.json) });
160
+ if (sub === "migrate")
161
+ return dbMigrate({ json: Boolean(values.json) });
162
+ if (sub === "migrations")
163
+ return dbMigrations({ json: Boolean(values.json) });
164
+ if (sub === "export")
165
+ return dbExport({ json: Boolean(values.json), ...(values.out ? { out: values.out } : {}) });
166
+ throw new CliError("Usage: nerd db query|migrate|migrations|export [--out file.sql]");
167
+ }
168
+ case "auth": {
169
+ const [sub, ...subRest] = rest;
170
+ const { values } = parseArgs({ args: subRest, options: { json: BOOL, roles: STR, "default-role": STR, signup: STR }, allowPositionals: false });
171
+ if (sub === "config")
172
+ return authConfig(values);
173
+ if (sub === "secret")
174
+ return authSecret();
175
+ throw new CliError("Usage: nerd auth config|secret");
176
+ }
177
+ case "billing": {
178
+ const [sub, ...subRest] = rest;
179
+ const isSub = sub === "upgrade" || sub === "portal" || sub === "show";
180
+ const { values } = parseArgs({ args: isSub ? subRest : rest, options: { json: BOOL, org: STR, plan: STR, annual: BOOL }, allowPositionals: false });
181
+ const o = { json: Boolean(values.json), ...(values.org ? { org: values.org } : {}) };
182
+ if (sub === "upgrade")
183
+ return billingUpgrade({ ...o, ...(values.plan ? { plan: values.plan } : {}), annual: Boolean(values.annual) });
184
+ if (sub === "portal")
185
+ return billingPortal(o);
186
+ return billingShow(o);
187
+ }
188
+ case "members": {
189
+ const [sub, ...subRest] = rest;
190
+ const isSub = sub === "invite" || sub === "remove" || sub === "list";
191
+ const { values, positionals } = parseArgs({ args: isSub ? subRest : rest, options: { json: BOOL, org: STR, role: STR }, allowPositionals: true });
192
+ const o = { json: Boolean(values.json), ...(values.org ? { org: values.org } : {}) };
193
+ if (sub === "invite")
194
+ return membersInvite(positionals[0], { ...o, ...(values.role ? { role: values.role } : {}) });
195
+ if (sub === "remove")
196
+ return membersRemove(positionals[0], o);
197
+ return membersList(o);
198
+ }
199
+ case "clients": {
200
+ const [sub, ...subRest] = rest;
201
+ const isSub = sub === "create" || sub === "release" || sub === "list";
202
+ const { values, positionals } = parseArgs({ args: isSub ? subRest : rest, options: { json: BOOL, org: STR, name: STR }, allowPositionals: true });
203
+ const o = { json: Boolean(values.json), ...(values.org ? { org: values.org } : {}) };
204
+ if (sub === "create")
205
+ return clientsCreate(positionals[0], { ...o, ...(values.name ? { name: values.name } : {}) });
206
+ if (sub === "release")
207
+ return clientsRelease(positionals[0], o);
208
+ return clientsList(o);
209
+ }
210
+ case "secret": {
211
+ const [sub, ...subRest] = rest;
212
+ const { values, positionals } = parseArgs({ args: subRest, options: { json: BOOL, previews: BOOL }, allowPositionals: true });
213
+ const json = Boolean(values.json);
214
+ if (sub === "set")
215
+ return secretSet(positionals[0], positionals[1], { json, previews: Boolean(values.previews) });
216
+ if (sub === "rm" || sub === "remove" || sub === "delete")
217
+ return secretRemove(positionals[0], { json });
218
+ if (sub === "list" || sub === "ls" || sub === undefined)
219
+ return secretList({ json });
220
+ throw new CliError("Usage: nerd secret set NAME [value] [--previews] | list | rm NAME");
221
+ }
222
+ case "domains": {
223
+ const [sub, ...subRest] = rest;
224
+ const isSub = sub === "add" || sub === "remove" || sub === "list";
225
+ const { values, positionals } = parseArgs({ args: isSub ? subRest : rest, options: { json: BOOL }, allowPositionals: true });
226
+ const o = { json: Boolean(values.json) };
227
+ if (sub === "add")
228
+ return domainsAdd(positionals[0], o);
229
+ if (sub === "remove")
230
+ return domainsRemove(positionals[0], o);
231
+ return domainsList(o);
232
+ }
233
+ case "logs": {
234
+ const { values } = parse({ since: STR, limit: STR, q: STR, pr: STR });
235
+ return logs({ json: Boolean(values.json), ...(values.since ? { since: values.since } : {}), ...(values.limit ? { limit: values.limit } : {}), ...(values.q ? { q: values.q } : {}), ...(values.pr ? { pr: values.pr } : {}) });
236
+ }
237
+ case "audit": {
238
+ const { values } = parse({ org: STR, out: STR });
239
+ return auditExport({ json: Boolean(values.json), ...(values.org ? { org: values.org } : {}), ...(values.out ? { out: values.out } : {}) });
240
+ }
241
+ case "admin": {
242
+ const [sub, ...subRest] = rest;
243
+ if (!sub || sub === "--help" || sub === "-h" || sub === "help")
244
+ return adminHelp();
245
+ const { values } = parseArgs({ args: subRest, options: { json: BOOL, org: STR, reason: STR }, allowPositionals: false });
246
+ const o = { json: Boolean(values.json), ...(values.org ? { org: values.org } : {}) };
247
+ if (sub === "suspend")
248
+ return adminSuspend({ ...o, ...(values.reason ? { reason: values.reason } : {}) });
249
+ if (sub === "unsuspend")
250
+ return adminUnsuspend(o);
251
+ throw new CliError("Usage: nerd admin suspend|unsuspend (see `nerd admin --help`)");
252
+ }
253
+ case "agents": {
254
+ const [sub, ...subRest] = rest;
255
+ const { values } = parseArgs({ args: subRest, options: { json: BOOL }, allowPositionals: false });
256
+ if (sub === "sync")
257
+ return agentsSync({ json: Boolean(values.json) });
258
+ throw new CliError("Usage: nerd agents sync");
259
+ }
260
+ case "github": {
261
+ const [sub, ...subRest] = rest;
262
+ const { values, positionals } = parseArgs({ args: subRest, options: { json: BOOL, branch: STR, "root-dir": STR, "no-previews": BOOL, "require-pr": BOOL, installation: STR, agent: STR }, allowPositionals: true });
263
+ const json = Boolean(values.json);
264
+ if (sub === "connect")
265
+ return githubConnect({ json });
266
+ if (sub === "link")
267
+ return githubLink(positionals[0], { json, "no-previews": Boolean(values["no-previews"]), "require-pr": Boolean(values["require-pr"]), ...(values.agent ? { agent: String(values.agent) } : {}), ...(values.branch ? { branch: values.branch } : {}), ...(values["root-dir"] ? { "root-dir": values["root-dir"] } : {}), ...(values.installation ? { installation: values.installation } : {}) });
268
+ if (sub === "unlink")
269
+ return githubUnlink({ json });
270
+ if (sub === "status" || sub === undefined)
271
+ return githubStatus({ json });
272
+ throw new CliError("Usage: nerd github connect|link|unlink|status");
273
+ }
274
+ case "builds": {
275
+ const [sub, ...subRest] = rest;
276
+ const isSub = sub === "show" || sub === "trigger" || sub === "list";
277
+ const { values, positionals } = parseArgs({ args: isSub ? subRest : rest, options: { json: BOOL, limit: { type: "string", default: "10" }, branch: STR, commit: STR }, allowPositionals: true });
278
+ const json = Boolean(values.json);
279
+ if (sub === "show")
280
+ return buildsShow(positionals[0], { json });
281
+ if (sub === "trigger")
282
+ return buildsTrigger({ json, ...(values.branch ? { branch: values.branch } : {}), ...(values.commit ? { commit: values.commit } : {}) });
283
+ return buildsList({ json, limit: values.limit });
284
+ }
285
+ default:
286
+ throw new CliError(`Unknown command "${cmd}". Run \`nerd help\`.`);
287
+ }
288
+ }
289
+ /**
290
+ * Runs one command and turns any failure into a non-zero exit code. `process.exitCode` is set
291
+ * rather than calling `process.exit()`, so piped stdout (including `--json` output) is flushed
292
+ * before the process ends. Returns the exit code for tests.
293
+ */
294
+ export async function runCli(argv) {
295
+ let code = 0;
296
+ try {
297
+ await main(argv);
298
+ }
299
+ catch (err) {
300
+ if (err instanceof CliError) {
301
+ log.warn(err.message);
302
+ code = err.exitCode > 0 ? err.exitCode : 1;
303
+ }
304
+ else {
305
+ console.error(err);
306
+ code = 1;
307
+ }
308
+ }
309
+ process.exitCode = code;
310
+ return code;
311
+ }
@@ -0,0 +1,10 @@
1
+ export declare function adminHelp(): void;
2
+ export declare function adminSuspend(opts: {
3
+ json: boolean;
4
+ org?: string | undefined;
5
+ reason?: string | undefined;
6
+ }): Promise<void>;
7
+ export declare function adminUnsuspend(opts: {
8
+ json: boolean;
9
+ org?: string | undefined;
10
+ }): Promise<void>;
@@ -0,0 +1,36 @@
1
+ import { ApiClient } from "../api.js";
2
+ import { CliError, log, printJson } from "../util.js";
3
+ const ADMIN_HELP = `nerd admin — platform operator commands (anyone else gets "not found")
4
+
5
+ nerd admin suspend --org <slug> --reason "<text>" Suspend an org: apps offline, users and tokens locked
6
+ nerd admin unsuspend --org <slug> Lift the suspension`;
7
+ export function adminHelp() {
8
+ console.log(ADMIN_HELP);
9
+ }
10
+ const OPERATOR_ONLY = "Only the platform operator can do this.";
11
+ async function operatorPost(path, body) {
12
+ try {
13
+ return await ApiClient.fromConfig().post(path, body);
14
+ }
15
+ catch (err) {
16
+ if (err instanceof CliError && err.status === 404)
17
+ throw new CliError(OPERATOR_ONLY);
18
+ throw err;
19
+ }
20
+ }
21
+ export async function adminSuspend(opts) {
22
+ if (!opts.org || !opts.reason)
23
+ throw new CliError('Usage: nerd admin suspend --org <slug> --reason "<why>"');
24
+ const r = await operatorPost("/v1/admin/suspend", { orgSlug: opts.org, reason: opts.reason });
25
+ if (opts.json)
26
+ return printJson(r);
27
+ log.ok(`Suspended ${r.org} at ${r.suspendedAt}: ${r.projectsTrashed} project(s) trashed, ${r.usersSuspended} user(s) suspended, ${r.tokensRevoked} token(s) revoked.`);
28
+ }
29
+ export async function adminUnsuspend(opts) {
30
+ if (!opts.org)
31
+ throw new CliError("Usage: nerd admin unsuspend --org <slug>");
32
+ const r = await operatorPost("/v1/admin/unsuspend", { orgSlug: opts.org });
33
+ if (opts.json)
34
+ return printJson(r);
35
+ log.ok(`Unsuspended ${r.org}: ${r.usersRestored} user(s) restored. Trashed projects stay in the trash until restored with \`nerd trash restore\`.`);
36
+ }
@@ -4,6 +4,7 @@ import { ApiClient } from "../api.js";
4
4
  import { findProjectRoot, projectPath, readNerdJson } from "../project.js";
5
5
  import { CliError, log, run } from "../util.js";
6
6
  import { staleAgentFiles } from "./agents.js";
7
+ import { gateSummary, skippedGates } from "../gates/types.js";
7
8
  const ALLOWED_BINDINGS = new Set(["DB", "STORAGE", "ASSETS"]);
8
9
  /** Removes line and block comments and trailing commas from JSONC without touching string contents. */
9
10
  export function stripJsonComments(input) {
@@ -147,12 +148,12 @@ export async function check(opts) {
147
148
  const out = await runVerify(root, { withBuild: !opts["no-build"], stopOnFail: false, quiet: opts.json, onlyRules: opts.rule ? [opts.rule] : undefined });
148
149
  const failed = out.results.filter((r) => r.status === "fail").length;
149
150
  if (opts.json)
150
- process.stdout.write(`${JSON.stringify({ ok: out.ok && problems.length === 0, failures: out.failures, stop: out.stop, problems }, null, 2)}\n`);
151
+ process.stdout.write(`${JSON.stringify({ ok: out.ok && problems.length === 0, failures: out.failures, stop: out.stop, problems, skipped: skippedGates(out.results) }, null, 2)}\n`);
151
152
  else {
152
153
  for (const p of problems)
153
154
  log.warn(p);
154
155
  if (out.ok && problems.length === 0)
155
- log.ok("All gates passed");
156
+ log.ok(gateSummary(out.results));
156
157
  const human = out.failures.filter((f) => f.audience === "human" && f.severity === "block");
157
158
  if (human.length)
158
159
  log.warn(`${human.length} finding(s) need a person, not the agent: ${human.map((f) => f.rule_id).join(", ")}`);
@@ -7,3 +7,11 @@ export declare function dbMigrate(opts: {
7
7
  export declare function dbMigrations(opts: {
8
8
  json: boolean;
9
9
  }): Promise<void>;
10
+ /**
11
+ * `nerd db export`: asks the API for a SQL dump of the production database and downloads it. With
12
+ * --json the response is printed and nothing is downloaded, so scripts can fetch the URL themselves.
13
+ */
14
+ export declare function dbExport(opts: {
15
+ json: boolean;
16
+ out?: string | undefined;
17
+ }): Promise<void>;
@@ -30,3 +30,28 @@ export async function dbMigrations(opts) {
30
30
  return printJson(r);
31
31
  table(r.migrations, ["name", "appliedAt"]);
32
32
  }
33
+ /**
34
+ * `nerd db export`: asks the API for a SQL dump of the production database and downloads it. With
35
+ * --json the response is printed and nothing is downloaded, so scripts can fetch the URL themselves.
36
+ */
37
+ export async function dbExport(opts) {
38
+ const nerd = readNerdJson(findProjectRoot());
39
+ const r = await ApiClient.fromConfig().post(projectPath(nerd, "/db/export"), {});
40
+ if (opts.json)
41
+ return printJson(r);
42
+ if (r.url.startsWith("fake://")) {
43
+ log.info(`Export ready at ${r.url} (dev mode, nothing downloaded). ${r.note}`);
44
+ return;
45
+ }
46
+ const { writeFileSync } = await import("node:fs");
47
+ const path = await import("node:path");
48
+ const out = path.resolve(opts.out ?? `${nerd.project}-${new Date().toISOString().slice(0, 10)}.sql`);
49
+ const res = await fetch(r.url).catch((err) => {
50
+ throw new CliError(`Could not download the export: ${err.message}`);
51
+ });
52
+ if (!res.ok)
53
+ throw new CliError(`Download failed (${res.status}). The link may have expired${r.expiresAt ? ` at ${r.expiresAt}` : ""}; run \`nerd db export\` again.`);
54
+ writeFileSync(out, Buffer.from(await res.arrayBuffer()));
55
+ log.ok(`Wrote ${out}`);
56
+ log.info(r.note);
57
+ }
@@ -4,7 +4,7 @@ import { ApiClient } from "../api.js";
4
4
  import { findProjectRoot, projectPath, readNerdJson } from "../project.js";
5
5
  import { CliError, log, printJson, runOrThrow, table, dashboardUrl, ensureDeps } from "../util.js";
6
6
  import { runChecks } from "./check.js";
7
- import { BUILD_GATES, knownSecretsFrom, runGates, SOURCE_GATES } from "../gates/index.js";
7
+ import { BUILD_GATES, gateSummary, knownSecretsFrom, runGates, SOURCE_GATES } from "../gates/index.js";
8
8
  import { readDevVars } from "../project.js";
9
9
  const MODULE_EXT = new Set([".js", ".mjs", ".wasm"]);
10
10
  function walk(dir, base = dir) {
@@ -72,7 +72,7 @@ export async function deploy(opts) {
72
72
  const gates = await runGates(SOURCE_GATES, { root, knownSecrets, approvedSuppressions: await approvedSuppressions(root), log: (l) => log.info(l) }, { stopOnFail: true });
73
73
  if (!gates.ok)
74
74
  throw new CliError(`Blocked by: ${gates.failed.map((g) => g.title).join(", ")}. Fix the findings above, then deploy again.`, 2);
75
- log.ok("Gates passed");
75
+ log.ok(gateSummary(gates.results));
76
76
  }
77
77
  if (!opts["skip-build"]) {
78
78
  log.step("Building with vite");
@@ -30,7 +30,7 @@ export async function doctor(opts) {
30
30
  }
31
31
  for (const tool of ["gitleaks", "semgrep", "osv-scanner"]) {
32
32
  const ok = await hasTool(tool);
33
- checks.push({ name: `${tool} (optional)`, ok, detail: ok ? "installed" : "not installed; the builder runs it for you", fix: `Optional for \`nerd verify\` locally.` });
33
+ checks.push({ name: `${tool} (optional)`, ok, detail: ok ? "installed" : "not installed; git builds run it in the builder, but `nerd check` and `nerd deploy` from this machine skip it", fix: `Optional for \`nerd verify\` locally.` });
34
34
  }
35
35
  if (opts.json)
36
36
  return printJson({ ok: checks.filter((c) => !c.name.includes("optional")).every((c) => c.ok), checks });
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
3
  import path from "node:path";
3
4
  import { fileURLToPath } from "node:url";
@@ -41,7 +42,7 @@ export async function linkProject(root, args) {
41
42
  log.ok(`Linked to existing project ${org}/${args.slug}`);
42
43
  }
43
44
  catch (err) {
44
- if (!args.create || !(err instanceof CliError) || !err.message.startsWith("not_found"))
45
+ if (!args.create || !(err instanceof CliError) || err.code !== "not_found")
45
46
  throw err;
46
47
  log.step(`Creating project ${org}/${args.slug} (provisions a database and a bucket)`);
47
48
  project = (await api.post(`/v1/orgs/${org}/projects`, { slug: args.slug, name: args.name })).project;
@@ -69,6 +70,50 @@ function seedDescription(root, name, description) {
69
70
  const heading = `# ${name}\n\n${description ? `${description}\n\n` : "(Describe what this app does here; the agent starts from it.)\n\n"}`;
70
71
  writeFileSync(p, heading + readFileSync(p, "utf8").replace(/^# This app runs on nerdsystem\n/, "## This app runs on nerdsystem\n"));
71
72
  }
73
+ /** Ten lines a person can read before opening the agent. */
74
+ function writeReadme(root, name, description) {
75
+ const lines = [
76
+ `# ${name}`,
77
+ "",
78
+ description ?? "An app built with a coding agent and deployed on nerdsystem.",
79
+ "",
80
+ "- `nerd dev` runs it locally with real sign-in.",
81
+ "- `nerd check` runs every gate a deploy runs (secrets, dependencies, platform rules, types).",
82
+ "- `nerd deploy` checks, builds, migrates, and ships it; the URL is printed at the end.",
83
+ "",
84
+ "Guides for each coding agent: https://nerdsystem.com/guides",
85
+ "",
86
+ ];
87
+ writeFileSync(path.join(root, "README.md"), lines.join("\n"));
88
+ }
89
+ /**
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.
92
+ */
93
+ function initGitRepo(root) {
94
+ try {
95
+ try {
96
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: root, stdio: "ignore" });
97
+ return; // already a repository, or inside one
98
+ }
99
+ catch {
100
+ // not a repository yet
101
+ }
102
+ execFileSync("git", ["init", "-q"], { cwd: root, stdio: "ignore" });
103
+ execFileSync("git", ["add", "-A"], { cwd: root, stdio: "ignore" });
104
+ const commit = ["commit", "-q", "-m", "Start from the nerdsystem template"];
105
+ try {
106
+ execFileSync("git", commit, { cwd: root, stdio: "ignore" });
107
+ }
108
+ catch {
109
+ // No identity configured on this machine yet: commit under a placeholder rather than leave the tree uncommitted.
110
+ execFileSync("git", ["-c", "user.name=nerd", "-c", "user.email=nerd@nerdsystem.com", ...commit], { cwd: root, stdio: "ignore" });
111
+ }
112
+ }
113
+ catch {
114
+ // git missing or not configured: fine, the person can init later
115
+ }
116
+ }
72
117
  export async function init(dir, args) {
73
118
  if (!dir)
74
119
  throw new CliError("Usage: nerd init <directory> [--org slug] [--name 'My App'] [--slug my-app]");
@@ -98,6 +143,7 @@ export async function init(dir, args) {
98
143
  // The published template carries ".gitignore" as "gitignore" (npm strips the dotted name from packages).
99
144
  if (existsSync(path.join(root, "gitignore")) && !existsSync(path.join(root, ".gitignore")))
100
145
  renameSync(path.join(root, "gitignore"), path.join(root, ".gitignore"));
146
+ const description = args.describe ?? (await askDescription());
101
147
  // Rename the app and point the SDK dependency somewhere installable.
102
148
  const pkgPath = path.join(root, "package.json");
103
149
  const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
@@ -109,7 +155,12 @@ export async function init(dir, args) {
109
155
  const p = path.join(root, f);
110
156
  writeFileSync(p, readFileSync(p, "utf8").replaceAll("nerd-app", slug));
111
157
  }
112
- const description = args.describe ?? (await askDescription());
158
+ // The visible brand in the client: the display name, unless it would break JSX text.
159
+ const display = /[<>{}]/.test(name) ? slug : name;
160
+ const appTsx = path.join(root, "src/client/App.tsx");
161
+ if (existsSync(appTsx))
162
+ writeFileSync(appTsx, readFileSync(appTsx, "utf8").replaceAll("nerd-app", display));
163
+ writeReadme(root, name, description);
113
164
  seedDescription(root, name, description);
114
165
  let project;
115
166
  try {
@@ -119,19 +170,8 @@ export async function init(dir, args) {
119
170
  const reason = err instanceof Error ? err.message : String(err);
120
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}` : ""}`);
121
172
  }
122
- // A repository from the first minute: deploy-on-push and the agent's commits both expect one.
123
- try {
124
- const { execFileSync } = await import("node:child_process");
125
- if (!existsSync(path.join(root, ".git"))) {
126
- execFileSync("git", ["init", "-q"], { cwd: root, stdio: "ignore" });
127
- execFileSync("git", ["add", "-A"], { cwd: root, stdio: "ignore" });
128
- execFileSync("git", ["commit", "-q", "-m", "Start from the nerdsystem template"], { cwd: root, stdio: "ignore" });
129
- }
130
- }
131
- catch {
132
- // git missing or not configured: fine, the person can init later
133
- }
173
+ initGitRepo(root);
134
174
  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}`);
135
175
  }
136
176
  // Kept in sync with packages/sdk/package.json by the release script.
137
- const SDK_VERSION = "0.0.6";
177
+ const SDK_VERSION = "0.0.8";
@@ -76,7 +76,7 @@ export async function logs(opts) {
76
76
  if (!r.events.length)
77
77
  return log.info(`No log events for ${r.scriptName} in the last ${opts.since ?? "30m"}.`);
78
78
  for (const e of [...r.events].reverse())
79
- process.stdout.write(`${e.timestamp} ${e.level.padEnd(5)} ${e.context ? `${e.context} ` : ""}${e.message}\n`);
79
+ process.stdout.write(`${e.timestamp} ${e.level.padEnd(5)} ${e.context && e.context !== e.message ? `${e.context} ` : ""}${e.message}\n`);
80
80
  }
81
81
  export async function auditExport(opts) {
82
82
  const org = orgSlug(opts);
@@ -0,0 +1,17 @@
1
+ /** `nerd delete`: the app goes offline now; the API keeps the data for a grace period and can restore it. */
2
+ export declare function deleteProject(opts: {
3
+ json: boolean;
4
+ yes: boolean;
5
+ org?: string | undefined;
6
+ slug?: string | undefined;
7
+ }): Promise<void>;
8
+ /** `nerd trash`: deleted projects still inside their grace period. */
9
+ export declare function trashList(opts: {
10
+ json: boolean;
11
+ org?: string | undefined;
12
+ }): Promise<void>;
13
+ /** `nerd trash restore <slug>`: undo a delete. The app stays offline until the next deploy or rollback. */
14
+ export declare function trashRestore(slug: string | undefined, opts: {
15
+ json: boolean;
16
+ org?: string | undefined;
17
+ }): Promise<void>;