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.
- package/README.md +13 -2
- package/dist/api.d.ts +39 -0
- package/dist/api.js +54 -12
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +34 -5
- package/dist/commands/admin.js +1 -1
- package/dist/commands/deploy.d.ts +58 -5
- package/dist/commands/deploy.js +122 -30
- package/dist/commands/dev.js +4 -0
- package/dist/commands/doctor.d.ts +4 -0
- package/dist/commands/doctor.js +9 -2
- package/dist/commands/init.d.ts +7 -0
- package/dist/commands/init.js +15 -6
- package/dist/commands/login.d.ts +2 -1
- package/dist/commands/login.js +16 -8
- package/dist/commands/ops.d.ts +15 -0
- package/dist/commands/ops.js +7 -0
- package/dist/commands/pull.d.ts +31 -0
- package/dist/commands/pull.js +137 -0
- package/dist/commands/save.d.ts +27 -0
- package/dist/commands/save.js +67 -0
- package/dist/commands/source.d.ts +38 -0
- package/dist/commands/source.js +145 -0
- package/dist/commands/trash.d.ts +1 -1
- package/dist/commands/trash.js +2 -3
- package/dist/config.d.ts +2 -1
- package/dist/gates/text.d.ts +65 -0
- package/dist/gates/text.js +108 -0
- package/dist/snapshot.d.ts +161 -0
- package/dist/snapshot.js +896 -0
- package/package.json +3 -2
- package/template/.claude/skills/nerdsystem/SKILL.md +13 -5
- package/template/.cursor/rules/nerdsystem.mdc +14 -6
- package/template/.github/copilot-instructions.md +14 -6
- package/template/AGENTS.md +14 -6
- package/template/CLAUDE.md +2 -1
- package/template/GEMINI.md +14 -6
package/README.md
CHANGED
|
@@ -7,18 +7,29 @@ npx nerdsystem init my-app
|
|
|
7
7
|
cd my-app && pnpm install
|
|
8
8
|
nerd dev # run locally with real sign-in
|
|
9
9
|
nerd check # every gate a deploy runs: secrets, dependencies, policy, PII, types (alias: nerd verify)
|
|
10
|
-
nerd deploy # checks, builds, migrates, ships
|
|
10
|
+
nerd deploy # checks, builds, saves the code, migrates, ships
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
Every command takes `--json` for agents. `nerd help` lists them all.
|
|
14
14
|
|
|
15
|
+
Your code is kept with every deploy and every `nerd save` (and `nerd dev` saves on start), so a lost laptop does not lose the app. Work after the last save is only on your computer. GitHub stays optional.
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
nerd save # keep a copy of the code on nerdsystem without deploying
|
|
19
|
+
nerd pull <org>/<project> # get the newest saved copy back on any computer, then: pnpm install && nerd dev
|
|
20
|
+
nerd pull --deployment <id> # the code of one deployment (what a rollback went back to)
|
|
21
|
+
nerd pull --trashed <org>/<project> # the code of a deleted project while it is in the trash
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`nerd pull` verifies the download against its recorded hash, refuses to write into a non-empty folder without `--force`, and starts a git repository in the restored folder. `nerd deploy` stops when the live app was deployed from a copy of the code this folder never saw; `nerd pull` gets that copy, or `nerd deploy --force` replaces it.
|
|
25
|
+
|
|
15
26
|
More you will reach for:
|
|
16
27
|
|
|
17
28
|
```sh
|
|
18
29
|
nerd db export # SQL dump of the production database to <slug>-<date>.sql (--out file.sql)
|
|
19
30
|
nerd delete --yes # take the app offline; data kept a week in the trash
|
|
20
31
|
nerd trash # deleted projects you can still restore
|
|
21
|
-
nerd trash restore <slug> # bring one back
|
|
32
|
+
nerd trash restore <slug> # bring one back online from its last deployed version
|
|
22
33
|
```
|
|
23
34
|
|
|
24
35
|
`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.d.ts
CHANGED
|
@@ -12,7 +12,21 @@ export declare class ApiClient {
|
|
|
12
12
|
static fromConfig(opts?: {
|
|
13
13
|
requireAuth?: boolean;
|
|
14
14
|
}): ApiClient;
|
|
15
|
+
private authHeaders;
|
|
16
|
+
private send;
|
|
17
|
+
/** Turns a non-2xx response into a CliError carrying the API's code and status. */
|
|
18
|
+
private fail;
|
|
15
19
|
request<T>(method: string, path: string, body?: unknown | FormData): Promise<T>;
|
|
20
|
+
/**
|
|
21
|
+
* Posts raw bytes (a snapshot) with an explicit content type and length plus any extra headers.
|
|
22
|
+
* The response is JSON like every other route.
|
|
23
|
+
*/
|
|
24
|
+
postRaw<T>(path: string, body: Uint8Array, headers: Record<string, string>): Promise<T>;
|
|
25
|
+
/** GETs a binary body (a snapshot download) with the bearer token; errors are parsed like every other route. */
|
|
26
|
+
download(path: string): Promise<{
|
|
27
|
+
bytes: Buffer;
|
|
28
|
+
headers: Headers;
|
|
29
|
+
}>;
|
|
16
30
|
get<T>(path: string): Promise<T>;
|
|
17
31
|
post<T>(path: string, body?: unknown): Promise<T>;
|
|
18
32
|
put<T>(path: string, body: unknown): Promise<T>;
|
|
@@ -43,12 +57,37 @@ export interface Project {
|
|
|
43
57
|
export interface Deployment {
|
|
44
58
|
id: string;
|
|
45
59
|
status: "uploading" | "live" | "failed";
|
|
60
|
+
environment?: "production" | "preview";
|
|
61
|
+
note?: string | null;
|
|
62
|
+
commitSha?: string | null;
|
|
63
|
+
rolledBackFrom?: string | null;
|
|
64
|
+
artifactStored?: boolean;
|
|
46
65
|
moduleCount: number;
|
|
47
66
|
assetCount: number;
|
|
48
67
|
bundleBytes: number;
|
|
49
68
|
error: string | null;
|
|
50
69
|
createdAt: string;
|
|
51
70
|
finishedAt: string | null;
|
|
71
|
+
/** The snapshot of the code this deployment was made from; null when none was stored (older nerd, or a user-caused skip). */
|
|
72
|
+
sourceId?: string | null;
|
|
73
|
+
/** Why no snapshot was stored, when the person's own setup caused it (over the size cap, a preview build). */
|
|
74
|
+
sourceSkippedReason?: string | null;
|
|
75
|
+
}
|
|
76
|
+
/** A stored copy of the app's code (docs/source-snapshots.md). */
|
|
77
|
+
export interface Source {
|
|
78
|
+
id: string;
|
|
79
|
+
projectId: string;
|
|
80
|
+
deploymentId: string | null;
|
|
81
|
+
buildId: string | null;
|
|
82
|
+
kind: "deploy" | "save";
|
|
83
|
+
sha256: string;
|
|
84
|
+
treeHash: string;
|
|
85
|
+
bytes: number;
|
|
86
|
+
fileCount: number;
|
|
87
|
+
gitSha: string | null;
|
|
88
|
+
cliVersion: string | null;
|
|
89
|
+
createdBy: string | null;
|
|
90
|
+
createdAt: string;
|
|
52
91
|
}
|
|
53
92
|
export interface TenantConfig {
|
|
54
93
|
projectId: string;
|
package/dist/api.js
CHANGED
|
@@ -14,33 +14,75 @@ export class ApiClient {
|
|
|
14
14
|
}
|
|
15
15
|
return new ApiClient(cfg.apiUrl, cfg.token);
|
|
16
16
|
}
|
|
17
|
+
authHeaders() {
|
|
18
|
+
return this.token ? { authorization: `Bearer ${this.token}` } : {};
|
|
19
|
+
}
|
|
20
|
+
async send(path, init) {
|
|
21
|
+
return fetch(`${this.apiUrl}${path}`, init).catch((err) => {
|
|
22
|
+
throw new CliError(`Could not reach ${this.apiUrl}: ${err.message}`);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/** Turns a non-2xx response into a CliError carrying the API's code and status. */
|
|
26
|
+
async fail(res, text) {
|
|
27
|
+
let json = null;
|
|
28
|
+
try {
|
|
29
|
+
json = text ? JSON.parse(text) : null;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw new CliError(`API returned non-JSON (${res.status}): ${text.slice(0, 200)}`, 1, { status: res.status });
|
|
33
|
+
}
|
|
34
|
+
const err = json?.error;
|
|
35
|
+
const details = err?.details ? `\n${JSON.stringify(err.details, null, 2)}` : "";
|
|
36
|
+
// The message leads; the code and status follow for people who need them.
|
|
37
|
+
throw new CliError(`${err?.message ?? "request failed"} [${err?.code ?? "error"} ${res.status}]${details}`, 1, { code: err?.code ?? "error", status: res.status });
|
|
38
|
+
}
|
|
17
39
|
async request(method, path, body) {
|
|
18
40
|
const isForm = body instanceof FormData;
|
|
19
|
-
const res = await
|
|
41
|
+
const res = await this.send(path, {
|
|
20
42
|
method,
|
|
21
43
|
headers: {
|
|
22
|
-
...
|
|
44
|
+
...this.authHeaders(),
|
|
23
45
|
...(body !== undefined && !isForm ? { "content-type": "application/json" } : {}),
|
|
24
46
|
},
|
|
25
47
|
body: body === undefined ? null : isForm ? body : JSON.stringify(body),
|
|
26
|
-
}).catch((err) => {
|
|
27
|
-
throw new CliError(`Could not reach ${this.apiUrl}: ${err.message}`);
|
|
28
48
|
});
|
|
29
49
|
const text = await res.text();
|
|
30
|
-
|
|
50
|
+
if (!res.ok)
|
|
51
|
+
await this.fail(res, text);
|
|
31
52
|
try {
|
|
32
|
-
|
|
53
|
+
return (text ? JSON.parse(text) : null);
|
|
33
54
|
}
|
|
34
55
|
catch {
|
|
35
56
|
throw new CliError(`API returned non-JSON (${res.status}): ${text.slice(0, 200)}`);
|
|
36
57
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Posts raw bytes (a snapshot) with an explicit content type and length plus any extra headers.
|
|
61
|
+
* The response is JSON like every other route.
|
|
62
|
+
*/
|
|
63
|
+
async postRaw(path, body, headers) {
|
|
64
|
+
const res = await this.send(path, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
// fetch sets content-length from the byte length, which the API requires before it reads.
|
|
67
|
+
headers: { ...this.authHeaders(), ...headers },
|
|
68
|
+
body,
|
|
69
|
+
});
|
|
70
|
+
const text = await res.text();
|
|
71
|
+
if (!res.ok)
|
|
72
|
+
await this.fail(res, text);
|
|
73
|
+
try {
|
|
74
|
+
return (text ? JSON.parse(text) : null);
|
|
42
75
|
}
|
|
43
|
-
|
|
76
|
+
catch {
|
|
77
|
+
throw new CliError(`API returned non-JSON (${res.status}): ${text.slice(0, 200)}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** GETs a binary body (a snapshot download) with the bearer token; errors are parsed like every other route. */
|
|
81
|
+
async download(path) {
|
|
82
|
+
const res = await this.send(path, { method: "GET", headers: this.authHeaders() });
|
|
83
|
+
if (!res.ok)
|
|
84
|
+
await this.fail(res, await res.text());
|
|
85
|
+
return { bytes: Buffer.from(await res.arrayBuffer()), headers: res.headers };
|
|
44
86
|
}
|
|
45
87
|
get(path) {
|
|
46
88
|
return this.request("GET", path);
|
package/dist/cli.d.ts
CHANGED
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,9 @@ import { buildsList, buildsShow, buildsTrigger } from "./commands/builds.js";
|
|
|
14
14
|
import { githubConnect, githubLink, githubStatus, githubUnlink } from "./commands/github.js";
|
|
15
15
|
import { init, linkProject } from "./commands/init.js";
|
|
16
16
|
import { login, whoami } from "./commands/login.js";
|
|
17
|
+
import { pull } from "./commands/pull.js";
|
|
18
|
+
import { save } from "./commands/save.js";
|
|
19
|
+
import { cliVersion } from "./commands/source.js";
|
|
17
20
|
import { deleteProject, trashList, trashRestore } from "./commands/trash.js";
|
|
18
21
|
import { findProjectRoot } from "./project.js";
|
|
19
22
|
import { CliError, log } from "./util.js";
|
|
@@ -26,13 +29,16 @@ const HELP = `nerd: start, check, and deploy a web app on nerdsystem
|
|
|
26
29
|
nerd link --slug <project> [--org] Link this directory to an existing or new project
|
|
27
30
|
nerd dev [vite args] Run locally (writes .dev.vars, applies local migrations)
|
|
28
31
|
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"] [--skip-probe] Check, build, migrate, upload, probe the live URL. Prints the URL
|
|
30
|
-
nerd
|
|
32
|
+
nerd deploy [--note "what changed"] [--skip-probe] [--force] Check, build, save the code, migrate, upload, probe the live URL. Prints the URL
|
|
33
|
+
nerd save Keep a copy of the code on nerdsystem without deploying (nerd dev does this on start)
|
|
34
|
+
nerd pull [<org>/<project>] [--latest | --source <id> | --deployment <id>] [--trashed] [--dir <name>] [--force]
|
|
35
|
+
Get a saved copy of the code back onto this computer
|
|
36
|
+
nerd rollback [deploymentId] Re-publish the previous production bundle (its saved code comes with it)
|
|
31
37
|
nerd cron [off|hourly|daily] Scheduled job: the platform calls POST /api/cron
|
|
32
38
|
nerd transfer <org> Move this project to another org you administer
|
|
33
39
|
nerd delete [--slug p] [--org o] --yes Take the app offline; data kept a week in the trash
|
|
34
40
|
nerd trash [--org o] Deleted projects you can still restore
|
|
35
|
-
nerd trash restore <slug> [--org o] Bring a deleted project back
|
|
41
|
+
nerd trash restore <slug> [--org o] Bring a deleted project back online from its last version
|
|
36
42
|
nerd health Platform component health (operators)
|
|
37
43
|
nerd waitlist [approve <id>] Who is waiting for access while sign-up is invite-only (operators)
|
|
38
44
|
nerd deployments List recent deployments
|
|
@@ -60,8 +66,9 @@ const HELP = `nerd: start, check, and deploy a web app on nerdsystem
|
|
|
60
66
|
nerd logs [--since 30m] [--limit 100] [--q text] [--pr n] Logs from the deployed app
|
|
61
67
|
nerd audit [--out file.csv] Export deployments and builds (Studio)
|
|
62
68
|
|
|
63
|
-
Every command accepts --json for machine-readable output.
|
|
69
|
+
Every command accepts --json for machine-readable output. nerd --version prints the version.
|
|
64
70
|
Operator: nerd admin --help`;
|
|
71
|
+
export { cliVersion };
|
|
65
72
|
const BOOL = { type: "boolean", default: false };
|
|
66
73
|
const STR = { type: "string" };
|
|
67
74
|
export async function main(argv) {
|
|
@@ -70,6 +77,18 @@ export async function main(argv) {
|
|
|
70
77
|
console.log(HELP);
|
|
71
78
|
return;
|
|
72
79
|
}
|
|
80
|
+
if (cmd === "version" || cmd === "--version" || cmd === "-v") {
|
|
81
|
+
if (rest.includes("--json"))
|
|
82
|
+
console.log(JSON.stringify({ version: cliVersion() }));
|
|
83
|
+
else
|
|
84
|
+
console.log(cliVersion());
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// `nerd <command> --help` prints the help instead of "Unknown option"; admin keeps its own help text.
|
|
88
|
+
if (cmd !== "admin" && (rest.includes("--help") || rest.includes("-h"))) {
|
|
89
|
+
console.log(HELP);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
73
92
|
const parse = (options, allowPositionals = true) => parseArgs({ args: rest, options: { json: BOOL, ...options }, allowPositionals, strict: cmd !== "dev" });
|
|
74
93
|
switch (cmd) {
|
|
75
94
|
case "login": {
|
|
@@ -111,9 +130,19 @@ export async function main(argv) {
|
|
|
111
130
|
return check({ json: Boolean(values.json), "no-build": Boolean(values["no-build"]), "no-remote": Boolean(values["no-remote"]), ...(values.rule ? { rule: values.rule } : {}) });
|
|
112
131
|
}
|
|
113
132
|
case "deploy": {
|
|
114
|
-
const { values } = parse({ "skip-build": BOOL, "skip-probe": BOOL, note: STR });
|
|
133
|
+
const { values } = parse({ "skip-build": BOOL, "skip-probe": BOOL, force: BOOL, note: STR });
|
|
115
134
|
return deploy(values);
|
|
116
135
|
}
|
|
136
|
+
case "save": {
|
|
137
|
+
const { values } = parse({}, false);
|
|
138
|
+
return save({ json: Boolean(values.json) });
|
|
139
|
+
}
|
|
140
|
+
case "pull": {
|
|
141
|
+
const { values, positionals } = parse({ latest: BOOL, source: STR, deployment: STR, trashed: BOOL, dir: STR, force: BOOL });
|
|
142
|
+
if (positionals.length > 1)
|
|
143
|
+
throw new CliError("Usage: nerd pull [<org>/<project>] [--latest | --source <id> | --deployment <id>] [--trashed] [--dir <name>] [--force]");
|
|
144
|
+
return pull(positionals[0], { json: Boolean(values.json), latest: Boolean(values.latest), trashed: Boolean(values.trashed), force: Boolean(values.force), ...(values.source ? { source: values.source } : {}), ...(values.deployment ? { deployment: values.deployment } : {}), ...(values.dir ? { dir: values.dir } : {}) });
|
|
145
|
+
}
|
|
117
146
|
case "rollback": {
|
|
118
147
|
const { values, positionals } = parse({ note: STR });
|
|
119
148
|
return rollback(positionals[0], { json: Boolean(values.json), ...(values.note ? { note: values.note } : {}) });
|
package/dist/commands/admin.js
CHANGED
|
@@ -32,5 +32,5 @@ export async function adminUnsuspend(opts) {
|
|
|
32
32
|
const r = await operatorPost("/v1/admin/unsuspend", { orgSlug: opts.org });
|
|
33
33
|
if (opts.json)
|
|
34
34
|
return printJson(r);
|
|
35
|
-
log.ok(`Unsuspended ${r.org}: ${r.usersRestored} user(s) restored.
|
|
35
|
+
log.ok(`Unsuspended ${r.org}: ${r.usersRestored} user(s) restored. ${r.projectsKept} project(s) stay in the trash with a fresh grace period (destroyed after ${r.purgeAfter.slice(0, 10)}) until restored with \`nerd trash restore\`.`);
|
|
36
36
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { ApiClient } from "../api.js";
|
|
2
|
-
|
|
1
|
+
import { ApiClient, type Deployment, type Source } from "../api.js";
|
|
2
|
+
import { type NerdJson } from "../project.js";
|
|
3
|
+
import { type GateResult } from "../gates/index.js";
|
|
4
|
+
export interface BuildOutput {
|
|
3
5
|
workerDir: string;
|
|
4
6
|
main: string;
|
|
5
7
|
modules: string[];
|
|
@@ -14,14 +16,65 @@ export declare function applyRemoteMigrations(root: string, api: ApiClient): Pro
|
|
|
14
16
|
applied: string[];
|
|
15
17
|
skipped: string[];
|
|
16
18
|
}>;
|
|
17
|
-
export
|
|
19
|
+
export interface DeployOptions {
|
|
18
20
|
json: boolean;
|
|
19
21
|
"skip-build": boolean;
|
|
20
22
|
"skip-probe"?: boolean | undefined;
|
|
23
|
+
/** Skip the lineage guard: replace a live app deployed from another copy of the code. */
|
|
24
|
+
force?: boolean | undefined;
|
|
21
25
|
note?: string | undefined;
|
|
22
|
-
}
|
|
26
|
+
}
|
|
27
|
+
/** What the lineage guard found, for --json output and tests. */
|
|
28
|
+
export interface LineageCheck {
|
|
29
|
+
/** The newest live production deployment, when there is one. */
|
|
30
|
+
live: Deployment | null;
|
|
31
|
+
/** Why this copy may deploy over it. */
|
|
32
|
+
passed: "no-live-deployment" | "no-source" | "known-id" | "same-commit" | "forced";
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The lineage guard: the live app must come from this copy of the code, or the person must say so.
|
|
36
|
+
* A live production deployment with a snapshot this folder never deployed, saved, or pulled (and whose
|
|
37
|
+
* commit this folder's git history does not hold) stops the deploy and points at `nerd pull`.
|
|
38
|
+
*/
|
|
39
|
+
export declare function checkLineage(root: string, nerd: NerdJson, api: ApiClient, force: boolean): Promise<LineageCheck>;
|
|
40
|
+
/** The gate results the deploy reports with the row, the shape builds use, capped the way the API caps them. */
|
|
41
|
+
export declare function checksMetadata(results: GateResult[]): {
|
|
42
|
+
reportedBy: "cli";
|
|
43
|
+
cli: string;
|
|
44
|
+
results: GateResult[];
|
|
45
|
+
};
|
|
46
|
+
export interface ShipResult {
|
|
47
|
+
deployment: Deployment;
|
|
48
|
+
url: string;
|
|
49
|
+
migrations: {
|
|
50
|
+
applied: string[];
|
|
51
|
+
skipped: string[];
|
|
52
|
+
};
|
|
53
|
+
probes: GateResult;
|
|
54
|
+
/** The snapshot stored for this deploy, when one was. */
|
|
55
|
+
source: Source | null;
|
|
56
|
+
/** Why no snapshot was stored, when the folder itself was the reason. */
|
|
57
|
+
sourceSkipped: string | null;
|
|
58
|
+
lineage: LineageCheck;
|
|
59
|
+
warnings: string[];
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Everything after the gates, in the order the contract fixes: lineage guard, snapshot (pack and
|
|
63
|
+
* upload), migrations, the deploy itself with the snapshot id and the gate results, then the probe.
|
|
64
|
+
* An upload that fails for any reason but the folder's own size stops here with nothing deployed.
|
|
65
|
+
*/
|
|
66
|
+
export declare function shipBuild(args: {
|
|
67
|
+
root: string;
|
|
68
|
+
nerd: NerdJson;
|
|
69
|
+
api: ApiClient;
|
|
70
|
+
build: BuildOutput;
|
|
71
|
+
results: GateResult[];
|
|
72
|
+
opts: DeployOptions;
|
|
73
|
+
}): Promise<ShipResult>;
|
|
74
|
+
/** The one line about the code after a live deploy, keyed on what the API recorded, not on what we sent. */
|
|
75
|
+
export declare function sourceLine(r: Pick<ShipResult, "deployment" | "source" | "sourceSkipped">): string;
|
|
76
|
+
export declare function deploy(opts: DeployOptions): Promise<void>;
|
|
23
77
|
export declare function deployments(opts: {
|
|
24
78
|
json: boolean;
|
|
25
79
|
limit: string;
|
|
26
80
|
}): Promise<void>;
|
|
27
|
-
export {};
|
package/dist/commands/deploy.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { ApiClient } from "../api.js";
|
|
4
|
-
import { findProjectRoot, projectPath, readNerdJson } from "../project.js";
|
|
5
|
+
import { findProjectRoot, projectPath, readDevVars, readNerdJson } from "../project.js";
|
|
6
|
+
import { formatMb } from "../snapshot.js";
|
|
5
7
|
import { CliError, log, printJson, runOrThrow, table, dashboardUrl, ensureDeps } from "../util.js";
|
|
6
8
|
import { runChecks } from "./check.js";
|
|
9
|
+
import { appendLineage, cliVersion, LOCAL_ONLY_NOTE, packProject, readLineage, uploadSnapshot, uploadStopMessage } from "./source.js";
|
|
7
10
|
import { BUILD_GATES, gateSummary, knownSecretsFrom, probeDeployment, runGates, skippedProbe, SOURCE_GATES, waitForDeployment } from "../gates/index.js";
|
|
8
|
-
import { readDevVars } from "../project.js";
|
|
9
11
|
const MODULE_EXT = new Set([".js", ".mjs", ".wasm"]);
|
|
10
12
|
function walk(dir, base = dir) {
|
|
11
13
|
const out = [];
|
|
@@ -82,32 +84,77 @@ function printProbe(url, probes) {
|
|
|
82
84
|
for (const f of probes.findings)
|
|
83
85
|
log.info(` ${f.severity === "block" ? "✗" : "!"} ${f.rule_id}: ${f.risk}`);
|
|
84
86
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (!remote.ok) {
|
|
93
|
-
for (const p of remote.problems)
|
|
94
|
-
log.warn(p);
|
|
95
|
-
throw new CliError("Fix the problems above, then deploy again.", 2);
|
|
87
|
+
/** True when `sha` is a commit in the repository at `root`, so this copy descends from it. */
|
|
88
|
+
function hasCommit(root, sha) {
|
|
89
|
+
if (!/^[0-9a-f]{7,64}$/.test(sha) || !existsSync(path.join(root, ".git")))
|
|
90
|
+
return false;
|
|
91
|
+
try {
|
|
92
|
+
execFileSync("git", ["cat-file", "-e", `${sha}^{commit}`], { cwd: root, stdio: "ignore", windowsHide: true });
|
|
93
|
+
return true;
|
|
96
94
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
95
|
+
catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The lineage guard: the live app must come from this copy of the code, or the person must say so.
|
|
101
|
+
* A live production deployment with a snapshot this folder never deployed, saved, or pulled (and whose
|
|
102
|
+
* commit this folder's git history does not hold) stops the deploy and points at `nerd pull`.
|
|
103
|
+
*/
|
|
104
|
+
export async function checkLineage(root, nerd, api, force) {
|
|
105
|
+
const list = await api.get(projectPath(nerd, "/deployments?limit=100"));
|
|
106
|
+
const live = list.deployments.find((d) => d.environment === "production" && d.status === "live") ?? null;
|
|
107
|
+
if (!live)
|
|
108
|
+
return { live, passed: "no-live-deployment" };
|
|
109
|
+
if (!live.sourceId)
|
|
110
|
+
return { live, passed: "no-source" };
|
|
111
|
+
if (readLineage(root).includes(live.sourceId))
|
|
112
|
+
return { live, passed: "known-id" };
|
|
113
|
+
if (live.commitSha && hasCommit(root, live.commitSha))
|
|
114
|
+
return { live, passed: "same-commit" };
|
|
115
|
+
if (force)
|
|
116
|
+
return { live, passed: "forced" };
|
|
117
|
+
const date = live.createdAt.slice(0, 10);
|
|
118
|
+
throw new CliError(`The live app was deployed from another copy of the code on ${date}. Run \`nerd pull\` to work from that copy, or \`nerd deploy --force\` to replace it with this one.`, 2, { code: "lineage" });
|
|
119
|
+
}
|
|
120
|
+
const CHECKS_MAX = 30;
|
|
121
|
+
/** The gate results the deploy reports with the row, the shape builds use, capped the way the API caps them. */
|
|
122
|
+
export function checksMetadata(results) {
|
|
123
|
+
return {
|
|
124
|
+
reportedBy: "cli",
|
|
125
|
+
cli: cliVersion(),
|
|
126
|
+
results: results.slice(0, CHECKS_MAX).map((r) => ({ ...r, findings: r.findings.slice(0, CHECKS_MAX) })),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Everything after the gates, in the order the contract fixes: lineage guard, snapshot (pack and
|
|
131
|
+
* upload), migrations, the deploy itself with the snapshot id and the gate results, then the probe.
|
|
132
|
+
* An upload that fails for any reason but the folder's own size stops here with nothing deployed.
|
|
133
|
+
*/
|
|
134
|
+
export async function shipBuild(args) {
|
|
135
|
+
const { root, nerd, api, build, opts } = args;
|
|
136
|
+
const lineage = await checkLineage(root, nerd, api, Boolean(opts.force));
|
|
137
|
+
log.step("Saving a copy of the code with this deploy");
|
|
138
|
+
const pack = await packProject(root);
|
|
139
|
+
for (const w of pack.warnings)
|
|
140
|
+
log.warn(w);
|
|
141
|
+
let source = null;
|
|
142
|
+
let sourceSkipped = null;
|
|
143
|
+
if (pack.tooLarge) {
|
|
144
|
+
sourceSkipped = pack.tooLarge.message.replace(/^Code not saved: /, "").slice(0, 300);
|
|
145
|
+
log.warn(pack.tooLarge.message);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
try {
|
|
149
|
+
source = await uploadSnapshot(api, projectPath(nerd), pack, "deploy");
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
throw new CliError(uploadStopMessage(err), 1, { code: err instanceof CliError ? err.code : "upload_failed", status: err instanceof CliError ? err.status : undefined });
|
|
153
|
+
}
|
|
154
|
+
appendLineage(root, source.id);
|
|
155
|
+
// "Stored", not "saved": the one line that says "saved" is the final one, which also says the work after it is only on this computer (invariant 7).
|
|
156
|
+
log.ok(`Stored ${pack.fileCount} files (${formatMb(pack.bytes)}) as ${source.id}`);
|
|
106
157
|
}
|
|
107
|
-
const build = readBuildOutput(root);
|
|
108
|
-
const built = await runGates(BUILD_GATES, { root, build, knownSecrets, log: (l) => log.info(l) }, { stopOnFail: true });
|
|
109
|
-
if (!built.ok)
|
|
110
|
-
throw new CliError(`Blocked by: ${built.failed.map((g) => g.title).join(", ")}.`, 2);
|
|
111
158
|
log.step("Applying migrations to the production database");
|
|
112
159
|
const mig = await applyRemoteMigrations(root, api);
|
|
113
160
|
if (mig.applied.length)
|
|
@@ -121,6 +168,9 @@ export async function deploy(opts) {
|
|
|
121
168
|
...(build.compatibilityDate ? { compatibilityDate: build.compatibilityDate } : {}),
|
|
122
169
|
compatibilityFlags: build.compatibilityFlags,
|
|
123
170
|
...(opts.note ? { note: opts.note } : {}),
|
|
171
|
+
...(source ? { sourceId: source.id } : {}),
|
|
172
|
+
...(sourceSkipped ? { sourceSkipped } : {}),
|
|
173
|
+
checks: checksMetadata(args.results),
|
|
124
174
|
}));
|
|
125
175
|
for (const m of build.modules) {
|
|
126
176
|
form.append(`module:${m}`, new Blob([readFileSync(path.join(build.workerDir, m))]), m);
|
|
@@ -134,15 +184,57 @@ export async function deploy(opts) {
|
|
|
134
184
|
if (!opts["skip-probe"] && deployment.status === "live")
|
|
135
185
|
log.step(`Probing ${project.url}`);
|
|
136
186
|
const probes = await probeAfterDeploy(deployment, project.url, Boolean(opts["skip-probe"]));
|
|
187
|
+
return { deployment, url: project.url, migrations: mig, probes, source, sourceSkipped, lineage, warnings: pack.warnings };
|
|
188
|
+
}
|
|
189
|
+
/** The one line about the code after a live deploy, keyed on what the API recorded, not on what we sent. */
|
|
190
|
+
export function sourceLine(r) {
|
|
191
|
+
if (r.deployment.sourceId)
|
|
192
|
+
return `Code saved with this deploy: ${r.source?.fileCount ?? "the folder's"} files. ${LOCAL_ONLY_NOTE}`;
|
|
193
|
+
const reason = r.deployment.sourceSkippedReason ?? r.sourceSkipped ?? "the platform did not record a copy; update nerd with npm install -g nerdsystem@latest and deploy again";
|
|
194
|
+
return `Code NOT saved: ${reason.replace(/\.$/, "")}.`;
|
|
195
|
+
}
|
|
196
|
+
export async function deploy(opts) {
|
|
197
|
+
const root = findProjectRoot();
|
|
198
|
+
const nerd = readNerdJson(root);
|
|
199
|
+
const api = ApiClient.fromConfig();
|
|
200
|
+
const knownSecrets = knownSecretsFrom(readDevVars(root));
|
|
201
|
+
// Every deploy runs every gate. There is no flag to skip them: suppressions need a person's approval in the dashboard.
|
|
202
|
+
const remote = await runChecks(root, { remote: true, typecheck: false });
|
|
203
|
+
if (!remote.ok) {
|
|
204
|
+
for (const p of remote.problems)
|
|
205
|
+
log.warn(p);
|
|
206
|
+
throw new CliError("Fix the problems above, then deploy again.", 2);
|
|
207
|
+
}
|
|
208
|
+
const { approvedSuppressions } = await import("./verify.js");
|
|
209
|
+
const gates = await runGates(SOURCE_GATES, { root, knownSecrets, approvedSuppressions: await approvedSuppressions(root), log: (l) => log.info(l) }, { stopOnFail: true });
|
|
210
|
+
if (!gates.ok)
|
|
211
|
+
throw new CliError(`Blocked by: ${gates.failed.map((g) => g.title).join(", ")}. Fix the findings above, then deploy again.`, 2);
|
|
212
|
+
log.ok(gateSummary(gates.results));
|
|
213
|
+
if (!opts["skip-build"]) {
|
|
214
|
+
log.step("Building with vite");
|
|
215
|
+
ensureDeps(root);
|
|
216
|
+
await runOrThrow("npx", ["vite", "build"], { cwd: root, stdoutToStderr: true });
|
|
217
|
+
}
|
|
218
|
+
const build = readBuildOutput(root);
|
|
219
|
+
const built = await runGates(BUILD_GATES, { root, build, knownSecrets, log: (l) => log.info(l) }, { stopOnFail: true });
|
|
220
|
+
if (!built.ok)
|
|
221
|
+
throw new CliError(`Blocked by: ${built.failed.map((g) => g.title).join(", ")}.`, 2);
|
|
222
|
+
const r = await shipBuild({ root, nerd, api, build, results: [...gates.results, ...built.results], opts });
|
|
137
223
|
if (opts.json)
|
|
138
|
-
return printJson({ deployment, url:
|
|
139
|
-
printProbe(
|
|
140
|
-
|
|
224
|
+
return printJson({ deployment: r.deployment, url: r.url, migrations: r.migrations, probes: r.probes, source: r.source, sourceSkipped: r.sourceSkipped, lineage: r.lineage.passed, warnings: r.warnings });
|
|
225
|
+
printProbe(r.url, r.probes);
|
|
226
|
+
if (r.deployment.status === "live") {
|
|
227
|
+
if (r.deployment.sourceId)
|
|
228
|
+
log.ok(sourceLine(r));
|
|
229
|
+
else
|
|
230
|
+
log.warn(sourceLine(r));
|
|
231
|
+
}
|
|
232
|
+
log.info(`First time? Sign up on your app at ${r.url}, then make yourself admin in the dashboard's Auth tab: ${dashboardUrl(api.apiUrl)}/app/${nerd.org}/projects/${nerd.project}/auth`);
|
|
141
233
|
}
|
|
142
234
|
export async function deployments(opts) {
|
|
143
235
|
const nerd = readNerdJson(findProjectRoot());
|
|
144
236
|
const r = await ApiClient.fromConfig().get(projectPath(nerd, `/deployments?limit=${Number(opts.limit) || 10}`));
|
|
145
237
|
if (opts.json)
|
|
146
238
|
return printJson(r);
|
|
147
|
-
table(r.deployments.map((d) => ({ id: d.id, status: d.status, modules: d.moduleCount, assets: d.assetCount, kb: Math.round(d.bundleBytes / 1024), created: d.createdAt, error: d.error ?? "" })), ["id", "status", "modules", "assets", "kb", "created", "error"]);
|
|
239
|
+
table(r.deployments.map((d) => ({ id: d.id, status: d.status, code: d.sourceId ? "saved" : d.sourceSkippedReason ? "not saved" : "older nerd", modules: d.moduleCount, assets: d.assetCount, kb: Math.round(d.bundleBytes / 1024), created: d.createdAt, error: d.error ?? "" })), ["id", "status", "code", "modules", "assets", "kb", "created", "error"]);
|
|
148
240
|
}
|
package/dist/commands/dev.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { ApiClient } from "../api.js";
|
|
4
4
|
import { findProjectRoot, projectPath, readDevVars, readNerdJson, writeDevVars } from "../project.js";
|
|
5
5
|
import { log, run, runOrThrow, ensureDeps } from "../util.js";
|
|
6
|
+
import { silentSave } from "./save.js";
|
|
6
7
|
/** Makes sure .dev.vars carries the platform bindings for local dev, minting a dev tenant secret once. */
|
|
7
8
|
export async function ensureDevVars(root) {
|
|
8
9
|
const nerd = readNerdJson(root);
|
|
@@ -28,6 +29,9 @@ export async function ensureDevVars(root) {
|
|
|
28
29
|
export async function dev(extra) {
|
|
29
30
|
const root = findProjectRoot();
|
|
30
31
|
await ensureDevVars(root);
|
|
32
|
+
// A copy of the code goes to nerdsystem on every start, so a lost laptop never loses the app.
|
|
33
|
+
// Silent when nothing changed; a warning, never a stop, when it does not go through.
|
|
34
|
+
await silentSave(root);
|
|
31
35
|
if (existsSync(path.join(root, "migrations"))) {
|
|
32
36
|
log.step("Applying migrations to the local database");
|
|
33
37
|
await runOrThrow("npx", ["wrangler", "d1", "migrations", "apply", "DB", "--local"], { cwd: root });
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
/** The template's Vite needs Node 22.12 or newer (its engines field: ^20.19.0 || >=22.12.0); the CLI asks for 22, so 22.12 is the floor. */
|
|
2
|
+
export declare const NODE_FLOOR = "22.12";
|
|
3
|
+
/** Whether a Node version string ("22.11.0") meets the floor; a 22.0-22.11 machine only failed at `nerd deploy` before. */
|
|
4
|
+
export declare function nodeMeetsFloor(version: string, floor?: string): boolean;
|
|
1
5
|
/** Checks the machine, not the app: the things that stop a newcomer before the first deploy. */
|
|
2
6
|
export declare function doctor(opts: {
|
|
3
7
|
json: boolean;
|
package/dist/commands/doctor.js
CHANGED
|
@@ -3,11 +3,18 @@ import { exec, hasTool } from "../gates/code.js";
|
|
|
3
3
|
import { loadConfig } from "../config.js";
|
|
4
4
|
import { findProjectRoot } from "../project.js";
|
|
5
5
|
import { log, printJson } from "../util.js";
|
|
6
|
+
/** The template's Vite needs Node 22.12 or newer (its engines field: ^20.19.0 || >=22.12.0); the CLI asks for 22, so 22.12 is the floor. */
|
|
7
|
+
export const NODE_FLOOR = "22.12";
|
|
8
|
+
/** Whether a Node version string ("22.11.0") meets the floor; a 22.0-22.11 machine only failed at `nerd deploy` before. */
|
|
9
|
+
export function nodeMeetsFloor(version, floor = NODE_FLOOR) {
|
|
10
|
+
const [major = 0, minor = 0] = version.split(".").map(Number);
|
|
11
|
+
const [fMajor = 0, fMinor = 0] = floor.split(".").map(Number);
|
|
12
|
+
return major > fMajor || (major === fMajor && minor >= fMinor);
|
|
13
|
+
}
|
|
6
14
|
/** Checks the machine, not the app: the things that stop a newcomer before the first deploy. */
|
|
7
15
|
export async function doctor(opts) {
|
|
8
16
|
const checks = [];
|
|
9
|
-
|
|
10
|
-
checks.push({ name: "Node.js 22+", ok: nodeMajor >= 22, detail: `v${process.versions.node}`, fix: "Install Node 22 from https://nodejs.org or with your version manager." });
|
|
17
|
+
checks.push({ name: `Node.js ${NODE_FLOOR}+`, ok: nodeMeetsFloor(process.versions.node), detail: `v${process.versions.node}`, fix: `Install Node ${NODE_FLOOR} or newer from https://nodejs.org` });
|
|
11
18
|
for (const [tool, fix] of [["pnpm", "npm install -g pnpm"], ["git", "Install git from https://git-scm.com"]]) {
|
|
12
19
|
const r = await exec(tool, ["--version"], process.cwd());
|
|
13
20
|
checks.push({ name: tool, ok: r.code === 0, detail: r.code === 0 ? r.stdout.trim().split("\n")[0] ?? "" : "not found", fix });
|
package/dist/commands/init.d.ts
CHANGED
|
@@ -6,10 +6,17 @@ export declare function linkProject(root: string, args: {
|
|
|
6
6
|
name: string;
|
|
7
7
|
create: boolean;
|
|
8
8
|
}): Promise<Project>;
|
|
9
|
+
/**
|
|
10
|
+
* A repository from the first minute: deploy-on-push and the agent's commits both expect one. Skipped
|
|
11
|
+
* silently when git is missing, and when the folder already sits inside a repository. `nerd pull`
|
|
12
|
+
* uses it too, with its own commit message.
|
|
13
|
+
*/
|
|
14
|
+
export declare function initGitRepo(root: string, message?: string): void;
|
|
9
15
|
export declare function init(dir: string | undefined, args: {
|
|
10
16
|
org?: string;
|
|
11
17
|
name?: string;
|
|
12
18
|
slug?: string;
|
|
13
19
|
describe?: string;
|
|
14
20
|
"no-create": boolean;
|
|
21
|
+
json?: boolean;
|
|
15
22
|
}): Promise<void>;
|