nerdsystem 0.0.5 → 0.0.7
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 +12 -1
- package/dist/api.js +4 -3
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +311 -0
- package/dist/commands/admin.d.ts +10 -0
- package/dist/commands/admin.js +36 -0
- package/dist/commands/check.d.ts +6 -1
- package/dist/commands/check.js +28 -10
- package/dist/commands/db.d.ts +8 -0
- package/dist/commands/db.js +25 -0
- package/dist/commands/deploy.js +5 -3
- package/dist/commands/dev.js +2 -1
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/github.d.ts +1 -0
- package/dist/commands/github.js +2 -2
- package/dist/commands/init.js +76 -6
- package/dist/commands/login.js +1 -1
- package/dist/commands/ops.d.ts +1 -0
- package/dist/commands/ops.js +3 -3
- package/dist/commands/org.js +1 -1
- package/dist/commands/trash.d.ts +17 -0
- package/dist/commands/trash.js +84 -0
- package/dist/commands/verify.js +3 -3
- package/dist/gates/code.d.ts +4 -1
- package/dist/gates/code.js +8 -5
- package/dist/gates/fs.js +1 -1
- package/dist/gates/index.d.ts +2 -1
- package/dist/gates/index.js +1 -0
- package/dist/gates/rules.js +6 -3
- package/dist/gates/secrets.d.ts +5 -0
- package/dist/gates/secrets.js +50 -10
- package/dist/gates/text.d.ts +39 -0
- package/dist/gates/text.js +82 -2
- package/dist/gates/types.d.ts +12 -0
- package/dist/gates/types.js +18 -0
- package/dist/index.js +2 -267
- package/dist/util.d.ts +14 -1
- package/dist/util.js +20 -1
- package/package.json +1 -1
- package/template/.claude/skills/nerdsystem/SKILL.md +12 -5
- package/template/.cursor/rules/nerdsystem.mdc +13 -6
- package/template/.github/copilot-instructions.md +13 -6
- package/template/AGENTS.md +13 -6
- package/template/GEMINI.md +13 -6
- package/template/package.json +6 -0
- package/template/src/client/auth.tsx +1 -1
package/dist/gates/text.js
CHANGED
|
@@ -19,13 +19,34 @@ export const SECRET_PATTERNS = [
|
|
|
19
19
|
{ rule: "private-key", re: /-----BEGIN (RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY( BLOCK)?-----/g },
|
|
20
20
|
{ rule: "jwt", re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g },
|
|
21
21
|
{ rule: "nerdsystem-token", re: /\bn(sk|as|bt|rt|ss|pr|iv)_[a-z0-9]{40}\b/g },
|
|
22
|
-
{ rule: "db-url-with-password", re: /\b(postgres|postgresql|mysql|mongodb(\+srv)?|redis):\/\/[^:\s/]+:[^@\s/]{4,}
|
|
22
|
+
{ rule: "db-url-with-password", re: /\b(postgres|postgresql|mysql|mongodb(\+srv)?|redis):\/\/[^:\s/]+:[^@\s/]{4,}@[^/\s:?"'`]+/g },
|
|
23
23
|
{ rule: "generic-assignment", re: /\b(api[_-]?key|secret|password|passwd|token|client[_-]?secret)\b\s*[:=]\s*["'`]([A-Za-z0-9+/_=.-]{24,})["'`]/gi },
|
|
24
24
|
];
|
|
25
25
|
const ALLOW = [/example/i, /placeholder/i, /your[-_ ]?key/i, /xxxx/i, /\$\{/, /process\.env/, /c\.env\./, /import\.meta\.env/, /nsk_\.\.\./];
|
|
26
26
|
export function isAllowedLine(line) {
|
|
27
27
|
return ALLOW.some((a) => a.test(line));
|
|
28
28
|
}
|
|
29
|
+
/** Files that exist to show the shape of a secret, not to hold one. Findings in them warn instead of block. */
|
|
30
|
+
export const EXAMPLE_FILE = /(\.(example|sample|template)|(^|\/)\.env\.(example|sample))$/i;
|
|
31
|
+
export function isExampleFile(file) {
|
|
32
|
+
return EXAMPLE_FILE.test(file);
|
|
33
|
+
}
|
|
34
|
+
/** Passwords nobody would use for real: the defaults every tutorial and docker-compose file carries. */
|
|
35
|
+
const PLACEHOLDER_PASSWORDS = new Set(["root", "password", "pass", "secret", "changeme", "example", "test", "your-password", "your_password", "yourpassword", "postgres", "mysql", "admin"]);
|
|
36
|
+
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0", "host.docker.internal"]);
|
|
37
|
+
const PLACEHOLDER_VALUE = /xxx|your[_-]|example|changeme|placeholder|<|>|\.\.\./i;
|
|
38
|
+
/** True when a matched credential is clearly a stand-in: a placeholder token, or a database URL with a default password or a local host. */
|
|
39
|
+
export function isPlaceholderValue(rule, value) {
|
|
40
|
+
if (rule === "db-url-with-password") {
|
|
41
|
+
const m = /:\/\/([^:]+):([^@]+)@([^/\s:?"'`]+)/.exec(value);
|
|
42
|
+
if (!m)
|
|
43
|
+
return false;
|
|
44
|
+
const password = m[2].toLowerCase();
|
|
45
|
+
const host = m[3].toLowerCase();
|
|
46
|
+
return PLACEHOLDER_PASSWORDS.has(password) || LOCAL_HOSTS.has(host) || PLACEHOLDER_VALUE.test(password);
|
|
47
|
+
}
|
|
48
|
+
return PLACEHOLDER_VALUE.test(value);
|
|
49
|
+
}
|
|
29
50
|
/** Type and last four characters only: "sk_live_****4f2a". Never the value. */
|
|
30
51
|
export function mask(match) {
|
|
31
52
|
const compact = match.replace(/\s+/g, "");
|
|
@@ -55,7 +76,8 @@ export function findSecrets(text, file, limit = 50) {
|
|
|
55
76
|
continue;
|
|
56
77
|
hitLines.add(line);
|
|
57
78
|
const matched = p.rule === "generic-assignment" ? (m[2] ?? m[0]) : m[0];
|
|
58
|
-
|
|
79
|
+
const downgrade = isExampleFile(file) ? "example file" : p.rule !== "private-key" && isPlaceholderValue(p.rule, matched) ? "placeholder value" : undefined;
|
|
80
|
+
hits.push({ rule: p.rule, file, line, masked: p.rule === "private-key" ? "private key" : mask(matched), ...(downgrade ? { downgrade } : {}) });
|
|
59
81
|
if (hits.length >= limit)
|
|
60
82
|
return hits;
|
|
61
83
|
}
|
|
@@ -70,3 +92,61 @@ export const ENV_FILES = /(^|\/)(\.env(\.[A-Za-z0-9_-]+)?|\.dev\.vars(\.[A-Za-z0
|
|
|
70
92
|
/** Source files worth scanning for text patterns; everything else is skipped as binary or generated. */
|
|
71
93
|
export const TEXT_FILE = /(\.(ts|tsx|js|jsx|mjs|cjs|json|jsonc|toml|yaml|yml|env|vars|md|html|css|sql|py|rb|go|rs|java|kt|swift|php|sh|txt|cfg|ini|conf|xml)|(^|\/)(README|LICENSE|Dockerfile|Makefile|Procfile|\.gitignore|\.npmrc))$/i;
|
|
72
94
|
export const SKIP_DIR = /(^|\/)(node_modules|dist|build|\.git|\.wrangler|\.next|\.nuxt|coverage|vendor|target)(\/|$)/;
|
|
95
|
+
export function normalizeSeverity(raw) {
|
|
96
|
+
const s = (raw ?? "").trim().toLowerCase();
|
|
97
|
+
if (s === "critical")
|
|
98
|
+
return "critical";
|
|
99
|
+
if (s === "high")
|
|
100
|
+
return "high";
|
|
101
|
+
if (s === "moderate" || s === "medium")
|
|
102
|
+
return "moderate";
|
|
103
|
+
if (s === "low" || s === "info" || s === "informational")
|
|
104
|
+
return "low";
|
|
105
|
+
return "unknown";
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Severity of one OSV record: `database_specific.severity` (what GHSA publishes: CRITICAL, HIGH,
|
|
109
|
+
* MODERATE, LOW), else any `affected[].ecosystem_specific.severity`, else the CVSS 3.x base score
|
|
110
|
+
* computed from the vector in `severity[]`. The OSV batch query (`/v1/querybatch`) returns only ids;
|
|
111
|
+
* fetch `/v1/vulns/{id}` to get these fields.
|
|
112
|
+
*/
|
|
113
|
+
export function osvSeverity(v) {
|
|
114
|
+
const direct = normalizeSeverity(v.database_specific?.severity);
|
|
115
|
+
if (direct !== "unknown")
|
|
116
|
+
return direct;
|
|
117
|
+
for (const a of v.affected ?? []) {
|
|
118
|
+
const s = normalizeSeverity(a.ecosystem_specific?.severity);
|
|
119
|
+
if (s !== "unknown")
|
|
120
|
+
return s;
|
|
121
|
+
}
|
|
122
|
+
for (const s of v.severity ?? []) {
|
|
123
|
+
if (/^CVSS_V3/i.test(s.type ?? "") && s.score) {
|
|
124
|
+
const score = cvss3BaseScore(s.score);
|
|
125
|
+
if (score !== null)
|
|
126
|
+
return score >= 9 ? "critical" : score >= 7 ? "high" : score >= 4 ? "moderate" : score > 0 ? "low" : "unknown";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return "unknown";
|
|
130
|
+
}
|
|
131
|
+
/** CVSS v3.0/3.1 base score from a vector string, or null when the vector is malformed. */
|
|
132
|
+
export function cvss3BaseScore(vector) {
|
|
133
|
+
const parts = Object.fromEntries(vector.split("/").slice(1).map((kv) => kv.split(":")));
|
|
134
|
+
const AV = { N: 0.85, A: 0.62, L: 0.55, P: 0.2 }[parts.AV ?? ""];
|
|
135
|
+
const AC = { L: 0.77, H: 0.44 }[parts.AC ?? ""];
|
|
136
|
+
const UI = { N: 0.85, R: 0.62 }[parts.UI ?? ""];
|
|
137
|
+
const CIA = { H: 0.56, L: 0.22, N: 0 };
|
|
138
|
+
const C = CIA[(parts.C ?? "")];
|
|
139
|
+
const I = CIA[(parts.I ?? "")];
|
|
140
|
+
const A = CIA[(parts.A ?? "")];
|
|
141
|
+
const scopeChanged = parts.S === "C";
|
|
142
|
+
const PR = scopeChanged ? { N: 0.85, L: 0.68, H: 0.5 }[parts.PR ?? ""] : { N: 0.85, L: 0.62, H: 0.27 }[parts.PR ?? ""];
|
|
143
|
+
if ([AV, AC, UI, C, I, A, PR].some((x) => x === undefined) || (parts.S !== "C" && parts.S !== "U"))
|
|
144
|
+
return null;
|
|
145
|
+
const iss = 1 - (1 - C) * (1 - I) * (1 - A);
|
|
146
|
+
const impact = scopeChanged ? 7.52 * (iss - 0.029) - 3.25 * Math.pow(iss - 0.02, 15) : 6.42 * iss;
|
|
147
|
+
const exploitability = 8.22 * AV * AC * PR * UI;
|
|
148
|
+
if (impact <= 0)
|
|
149
|
+
return 0;
|
|
150
|
+
const roundUp = (x) => Math.ceil(x * 10 - 1e-9) / 10;
|
|
151
|
+
return scopeChanged ? roundUp(Math.min(1.08 * (impact + exploitability), 10)) : roundUp(Math.min(impact + exploitability, 10));
|
|
152
|
+
}
|
package/dist/gates/types.d.ts
CHANGED
|
@@ -70,3 +70,15 @@ export declare function result(gate: Gate, status: GateStatus, summary: string,
|
|
|
70
70
|
/** Status from findings: any block = fail, any warn = warn, else pass. */
|
|
71
71
|
export declare function verdict(findings: Finding[]): GateStatus;
|
|
72
72
|
export declare function counts(findings: Finding[]): string;
|
|
73
|
+
export interface SkippedGate {
|
|
74
|
+
name: string;
|
|
75
|
+
title: string;
|
|
76
|
+
reason: string;
|
|
77
|
+
}
|
|
78
|
+
/** Gates that did not run this time (a scanner is not installed, no build output), so "passed" is never claimed for them. */
|
|
79
|
+
export declare function skippedGates(results: GateResult[]): SkippedGate[];
|
|
80
|
+
/**
|
|
81
|
+
* One honest line: "7 gates passed, 2 skipped (semgrep, gitleaks not installed; install them or deploy from git so the builder runs them)".
|
|
82
|
+
* Never "All gates passed" when something did not run.
|
|
83
|
+
*/
|
|
84
|
+
export declare function gateSummary(results: GateResult[]): string;
|
package/dist/gates/types.js
CHANGED
|
@@ -16,3 +16,21 @@ export function counts(findings) {
|
|
|
16
16
|
return "No findings";
|
|
17
17
|
return [b ? `${b} blocking` : "", w ? `${w} advisory` : "", findings.length - b - w ? `${findings.length - b - w} informational` : ""].filter(Boolean).join(", ");
|
|
18
18
|
}
|
|
19
|
+
/** Gates that did not run this time (a scanner is not installed, no build output), so "passed" is never claimed for them. */
|
|
20
|
+
export function skippedGates(results) {
|
|
21
|
+
return results.filter((r) => r.status === "skip").map((r) => ({ name: r.name, title: r.title, reason: r.summary }));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* One honest line: "7 gates passed, 2 skipped (semgrep, gitleaks not installed; install them or deploy from git so the builder runs them)".
|
|
25
|
+
* Never "All gates passed" when something did not run.
|
|
26
|
+
*/
|
|
27
|
+
export function gateSummary(results) {
|
|
28
|
+
const skipped = skippedGates(results);
|
|
29
|
+
const passed = results.filter((r) => r.status === "pass" || r.status === "warn").length;
|
|
30
|
+
if (!skipped.length)
|
|
31
|
+
return `All ${passed} gates passed`;
|
|
32
|
+
const tools = skipped.filter((s) => /not installed/.test(s.reason)).map((s) => s.name);
|
|
33
|
+
const other = skipped.filter((s) => !/not installed/.test(s.reason)).map((s) => `${s.name}: ${s.reason}`);
|
|
34
|
+
const why = [tools.length ? `${tools.join(", ")} not installed; install them or deploy from git so the builder runs them` : "", ...other].filter(Boolean).join("; ");
|
|
35
|
+
return `${passed} gate${passed === 1 ? "" : "s"} passed, ${skipped.length} skipped (${why})`;
|
|
36
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,268 +1,3 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
import { check } from "./commands/check.js";
|
|
5
|
-
import { dbMigrate, dbMigrations, dbQuery } from "./commands/db.js";
|
|
6
|
-
import { deploy, deployments } from "./commands/deploy.js";
|
|
7
|
-
import { dev } from "./commands/dev.js";
|
|
8
|
-
import { doctor } from "./commands/doctor.js";
|
|
9
|
-
import { cron, health, rollback, secretList, secretRemove, secretSet, transfer, waitlist } from "./commands/ops.js";
|
|
10
|
-
import { verify } from "./commands/verify.js";
|
|
11
|
-
import { agentsSync } from "./commands/agents.js";
|
|
12
|
-
import { billingPortal, billingShow, billingUpgrade } from "./commands/billing.js";
|
|
13
|
-
import { auditExport, clientsCreate, clientsList, clientsRelease, domainsAdd, domainsList, domainsRemove, logs, membersInvite, membersList, membersRemove } from "./commands/org.js";
|
|
14
|
-
import { buildsList, buildsShow, buildsTrigger } from "./commands/builds.js";
|
|
15
|
-
import { githubConnect, githubLink, githubStatus, githubUnlink } from "./commands/github.js";
|
|
16
|
-
import { init, linkProject } from "./commands/init.js";
|
|
17
|
-
import { login, whoami } from "./commands/login.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: prints a link to approve in the browser (or --token nsk_...)
|
|
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 Verify platform rules and typecheck
|
|
29
|
-
nerd verify [--no-build] [--rule id] [--json] Run the security gates; --json gives failures the agent can fix
|
|
30
|
-
nerd deploy [--note "what changed"] Check, build, migrate, upload. Prints the URL
|
|
31
|
-
nerd rollback [deploymentId] Re-publish the previous production bundle
|
|
32
|
-
nerd cron [off|hourly|daily] Scheduled job: the platform calls POST /api/cron
|
|
33
|
-
nerd transfer <org> Move this project to another org you administer
|
|
34
|
-
nerd health Platform component health (operators)
|
|
35
|
-
nerd waitlist [approve <id>] Who is waiting for access while sign-up is invite-only (operators)
|
|
36
|
-
nerd deployments List recent deployments
|
|
37
|
-
nerd db query "<sql>" Run SQL on the production database
|
|
38
|
-
nerd db migrate Apply pending migrations to production
|
|
39
|
-
nerd db migrations List applied migrations
|
|
40
|
-
nerd auth config [--roles a,b] [--default-role r] [--signup on|off]
|
|
41
|
-
nerd auth secret Mint a dev tenant secret into .dev.vars
|
|
42
|
-
nerd secret set NAME [value] App secret for production (c.env.NAME); value from stdin when omitted
|
|
43
|
-
nerd secret list | rm NAME Names only; values are never shown
|
|
44
|
-
nerd github connect Install the GitHub App for this org (prints a link)
|
|
45
|
-
nerd github link owner/repo [--branch main] [--root-dir dir] [--no-previews] [--require-pr]
|
|
46
|
-
nerd github status | unlink
|
|
47
|
-
nerd builds [--limit n] List git-triggered builds
|
|
48
|
-
nerd builds show <id> Build details and log
|
|
49
|
-
nerd builds trigger [--branch b] [--commit sha]
|
|
50
|
-
nerd agents sync Regenerate AGENTS.md, Cursor, Copilot, Gemini files from the skill
|
|
51
|
-
nerd billing [--org slug] Plan, limits, and usage for the org
|
|
52
|
-
nerd billing upgrade --plan pro|studio [--annual] Start checkout
|
|
53
|
-
nerd billing portal Manage cards, invoices, and plan
|
|
54
|
-
nerd members [invite <email> --role r | remove <userId>] Org members and invitations
|
|
55
|
-
nerd clients [create <slug> --name n | release <slug>] Studio: one org per client, billed through yours
|
|
56
|
-
nerd domains [add <hostname> | remove <id>] Custom domains for this project
|
|
57
|
-
nerd logs [--since 30m] [--limit 100] [--q text] [--pr n] Logs from the deployed app
|
|
58
|
-
nerd audit [--out file.csv] Export deployments and builds (Studio)
|
|
59
|
-
|
|
60
|
-
Every command accepts --json for machine-readable output.`;
|
|
61
|
-
const BOOL = { type: "boolean", default: false };
|
|
62
|
-
const STR = { type: "string" };
|
|
63
|
-
async function main(argv) {
|
|
64
|
-
const [cmd, ...rest] = argv;
|
|
65
|
-
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
|
|
66
|
-
console.log(HELP);
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
const parse = (options, allowPositionals = true) => parseArgs({ args: rest, options: { json: BOOL, ...options }, allowPositionals, strict: cmd !== "dev" });
|
|
70
|
-
switch (cmd) {
|
|
71
|
-
case "login": {
|
|
72
|
-
const { values } = parse({ token: STR, "api-url": STR, email: STR });
|
|
73
|
-
return login(values);
|
|
74
|
-
}
|
|
75
|
-
case "doctor": {
|
|
76
|
-
const { values } = parse({});
|
|
77
|
-
return doctor({ json: Boolean(values.json) });
|
|
78
|
-
}
|
|
79
|
-
case "whoami": {
|
|
80
|
-
const { values } = parse({});
|
|
81
|
-
return whoami({ json: Boolean(values.json) });
|
|
82
|
-
}
|
|
83
|
-
case "init": {
|
|
84
|
-
const { values, positionals } = parse({ org: STR, name: STR, slug: STR, describe: STR, "no-create": BOOL });
|
|
85
|
-
return init(positionals[0], values);
|
|
86
|
-
}
|
|
87
|
-
case "link": {
|
|
88
|
-
const { values } = parse({ org: STR, slug: STR, name: STR, "no-create": BOOL });
|
|
89
|
-
if (!values.slug)
|
|
90
|
-
throw new CliError("Usage: nerd link --slug <project> [--org <org>] [--name 'My App']");
|
|
91
|
-
const root = (() => {
|
|
92
|
-
try {
|
|
93
|
-
return findProjectRoot();
|
|
94
|
-
}
|
|
95
|
-
catch {
|
|
96
|
-
return process.cwd();
|
|
97
|
-
}
|
|
98
|
-
})();
|
|
99
|
-
await linkProject(root, { ...(values.org ? { org: values.org } : {}), slug: values.slug, name: values.name ?? values.slug, create: !values["no-create"] });
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
case "dev":
|
|
103
|
-
return dev(rest);
|
|
104
|
-
case "check": {
|
|
105
|
-
const { values } = parse({ "no-typecheck": BOOL, "no-remote": BOOL });
|
|
106
|
-
return check(values);
|
|
107
|
-
}
|
|
108
|
-
case "verify": {
|
|
109
|
-
const { values } = parse({ "no-build": BOOL, rule: STR });
|
|
110
|
-
return verify({ json: Boolean(values.json), "no-build": Boolean(values["no-build"]), ...(values.rule ? { rule: values.rule } : {}) });
|
|
111
|
-
}
|
|
112
|
-
case "deploy": {
|
|
113
|
-
const { values } = parse({ "skip-check": BOOL, "skip-build": BOOL, note: STR });
|
|
114
|
-
return deploy(values);
|
|
115
|
-
}
|
|
116
|
-
case "rollback": {
|
|
117
|
-
const { values, positionals } = parse({ note: STR });
|
|
118
|
-
return rollback(positionals[0], { json: Boolean(values.json), ...(values.note ? { note: values.note } : {}) });
|
|
119
|
-
}
|
|
120
|
-
case "cron": {
|
|
121
|
-
const { values, positionals } = parse({});
|
|
122
|
-
return cron(positionals[0], { json: Boolean(values.json) });
|
|
123
|
-
}
|
|
124
|
-
case "transfer": {
|
|
125
|
-
const { values, positionals } = parse({});
|
|
126
|
-
return transfer(positionals[0], { json: Boolean(values.json) });
|
|
127
|
-
}
|
|
128
|
-
case "health": {
|
|
129
|
-
const { values } = parse({});
|
|
130
|
-
return health({ json: Boolean(values.json) });
|
|
131
|
-
}
|
|
132
|
-
case "waitlist": {
|
|
133
|
-
const [sub, ...subRest] = rest;
|
|
134
|
-
const { values, positionals } = parseArgs({ args: sub === "approve" ? subRest : rest, options: { json: BOOL }, allowPositionals: true });
|
|
135
|
-
return waitlist({ json: Boolean(values.json), ...(sub === "approve" ? { approve: positionals[0] } : {}) });
|
|
136
|
-
}
|
|
137
|
-
case "deployments": {
|
|
138
|
-
const { values } = parse({ limit: { type: "string", default: "10" } });
|
|
139
|
-
return deployments(values);
|
|
140
|
-
}
|
|
141
|
-
case "db": {
|
|
142
|
-
const [sub, ...subRest] = rest;
|
|
143
|
-
const { values, positionals } = parseArgs({ args: subRest, options: { json: BOOL }, allowPositionals: true });
|
|
144
|
-
if (sub === "query")
|
|
145
|
-
return dbQuery(positionals[0], { json: Boolean(values.json) });
|
|
146
|
-
if (sub === "migrate")
|
|
147
|
-
return dbMigrate({ json: Boolean(values.json) });
|
|
148
|
-
if (sub === "migrations")
|
|
149
|
-
return dbMigrations({ json: Boolean(values.json) });
|
|
150
|
-
throw new CliError("Usage: nerd db query|migrate|migrations");
|
|
151
|
-
}
|
|
152
|
-
case "auth": {
|
|
153
|
-
const [sub, ...subRest] = rest;
|
|
154
|
-
const { values } = parseArgs({ args: subRest, options: { json: BOOL, roles: STR, "default-role": STR, signup: STR }, allowPositionals: false });
|
|
155
|
-
if (sub === "config")
|
|
156
|
-
return authConfig(values);
|
|
157
|
-
if (sub === "secret")
|
|
158
|
-
return authSecret();
|
|
159
|
-
throw new CliError("Usage: nerd auth config|secret");
|
|
160
|
-
}
|
|
161
|
-
case "billing": {
|
|
162
|
-
const [sub, ...subRest] = rest;
|
|
163
|
-
const isSub = sub === "upgrade" || sub === "portal" || sub === "show";
|
|
164
|
-
const { values } = parseArgs({ args: isSub ? subRest : rest, options: { json: BOOL, org: STR, plan: STR, annual: BOOL }, allowPositionals: false });
|
|
165
|
-
const o = { json: Boolean(values.json), ...(values.org ? { org: values.org } : {}) };
|
|
166
|
-
if (sub === "upgrade")
|
|
167
|
-
return billingUpgrade({ ...o, ...(values.plan ? { plan: values.plan } : {}), annual: Boolean(values.annual) });
|
|
168
|
-
if (sub === "portal")
|
|
169
|
-
return billingPortal(o);
|
|
170
|
-
return billingShow(o);
|
|
171
|
-
}
|
|
172
|
-
case "members": {
|
|
173
|
-
const [sub, ...subRest] = rest;
|
|
174
|
-
const isSub = sub === "invite" || sub === "remove" || sub === "list";
|
|
175
|
-
const { values, positionals } = parseArgs({ args: isSub ? subRest : rest, options: { json: BOOL, org: STR, role: STR }, allowPositionals: true });
|
|
176
|
-
const o = { json: Boolean(values.json), ...(values.org ? { org: values.org } : {}) };
|
|
177
|
-
if (sub === "invite")
|
|
178
|
-
return membersInvite(positionals[0], { ...o, ...(values.role ? { role: values.role } : {}) });
|
|
179
|
-
if (sub === "remove")
|
|
180
|
-
return membersRemove(positionals[0], o);
|
|
181
|
-
return membersList(o);
|
|
182
|
-
}
|
|
183
|
-
case "clients": {
|
|
184
|
-
const [sub, ...subRest] = rest;
|
|
185
|
-
const isSub = sub === "create" || sub === "release" || sub === "list";
|
|
186
|
-
const { values, positionals } = parseArgs({ args: isSub ? subRest : rest, options: { json: BOOL, org: STR, name: STR }, allowPositionals: true });
|
|
187
|
-
const o = { json: Boolean(values.json), ...(values.org ? { org: values.org } : {}) };
|
|
188
|
-
if (sub === "create")
|
|
189
|
-
return clientsCreate(positionals[0], { ...o, ...(values.name ? { name: values.name } : {}) });
|
|
190
|
-
if (sub === "release")
|
|
191
|
-
return clientsRelease(positionals[0], o);
|
|
192
|
-
return clientsList(o);
|
|
193
|
-
}
|
|
194
|
-
case "secret": {
|
|
195
|
-
const [sub, ...subRest] = rest;
|
|
196
|
-
const { values, positionals } = parseArgs({ args: subRest, options: { json: BOOL }, allowPositionals: true });
|
|
197
|
-
const json = Boolean(values.json);
|
|
198
|
-
if (sub === "set")
|
|
199
|
-
return secretSet(positionals[0], positionals[1], { json });
|
|
200
|
-
if (sub === "rm" || sub === "remove" || sub === "delete")
|
|
201
|
-
return secretRemove(positionals[0], { json });
|
|
202
|
-
if (sub === "list" || sub === "ls" || sub === undefined)
|
|
203
|
-
return secretList({ json });
|
|
204
|
-
throw new CliError("Usage: nerd secret set NAME [value] | list | rm NAME");
|
|
205
|
-
}
|
|
206
|
-
case "domains": {
|
|
207
|
-
const [sub, ...subRest] = rest;
|
|
208
|
-
const isSub = sub === "add" || sub === "remove" || sub === "list";
|
|
209
|
-
const { values, positionals } = parseArgs({ args: isSub ? subRest : rest, options: { json: BOOL }, allowPositionals: true });
|
|
210
|
-
const o = { json: Boolean(values.json) };
|
|
211
|
-
if (sub === "add")
|
|
212
|
-
return domainsAdd(positionals[0], o);
|
|
213
|
-
if (sub === "remove")
|
|
214
|
-
return domainsRemove(positionals[0], o);
|
|
215
|
-
return domainsList(o);
|
|
216
|
-
}
|
|
217
|
-
case "logs": {
|
|
218
|
-
const { values } = parse({ since: STR, limit: STR, q: STR, pr: STR });
|
|
219
|
-
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 } : {}) });
|
|
220
|
-
}
|
|
221
|
-
case "audit": {
|
|
222
|
-
const { values } = parse({ org: STR, out: STR });
|
|
223
|
-
return auditExport({ json: Boolean(values.json), ...(values.org ? { org: values.org } : {}), ...(values.out ? { out: values.out } : {}) });
|
|
224
|
-
}
|
|
225
|
-
case "agents": {
|
|
226
|
-
const [sub, ...subRest] = rest;
|
|
227
|
-
const { values } = parseArgs({ args: subRest, options: { json: BOOL }, allowPositionals: false });
|
|
228
|
-
if (sub === "sync")
|
|
229
|
-
return agentsSync({ json: Boolean(values.json) });
|
|
230
|
-
throw new CliError("Usage: nerd agents sync");
|
|
231
|
-
}
|
|
232
|
-
case "github": {
|
|
233
|
-
const [sub, ...subRest] = rest;
|
|
234
|
-
const { values, positionals } = parseArgs({ args: subRest, options: { json: BOOL, branch: STR, "root-dir": STR, "no-previews": BOOL, "require-pr": BOOL, installation: STR }, allowPositionals: true });
|
|
235
|
-
const json = Boolean(values.json);
|
|
236
|
-
if (sub === "connect")
|
|
237
|
-
return githubConnect({ json });
|
|
238
|
-
if (sub === "link")
|
|
239
|
-
return githubLink(positionals[0], { json, "no-previews": Boolean(values["no-previews"]), "require-pr": Boolean(values["require-pr"]), ...(values.branch ? { branch: values.branch } : {}), ...(values["root-dir"] ? { "root-dir": values["root-dir"] } : {}), ...(values.installation ? { installation: values.installation } : {}) });
|
|
240
|
-
if (sub === "unlink")
|
|
241
|
-
return githubUnlink({ json });
|
|
242
|
-
if (sub === "status" || sub === undefined)
|
|
243
|
-
return githubStatus({ json });
|
|
244
|
-
throw new CliError("Usage: nerd github connect|link|unlink|status");
|
|
245
|
-
}
|
|
246
|
-
case "builds": {
|
|
247
|
-
const [sub, ...subRest] = rest;
|
|
248
|
-
const isSub = sub === "show" || sub === "trigger" || sub === "list";
|
|
249
|
-
const { values, positionals } = parseArgs({ args: isSub ? subRest : rest, options: { json: BOOL, limit: { type: "string", default: "10" }, branch: STR, commit: STR }, allowPositionals: true });
|
|
250
|
-
const json = Boolean(values.json);
|
|
251
|
-
if (sub === "show")
|
|
252
|
-
return buildsShow(positionals[0], { json });
|
|
253
|
-
if (sub === "trigger")
|
|
254
|
-
return buildsTrigger({ json, ...(values.branch ? { branch: values.branch } : {}), ...(values.commit ? { commit: values.commit } : {}) });
|
|
255
|
-
return buildsList({ json, limit: values.limit });
|
|
256
|
-
}
|
|
257
|
-
default:
|
|
258
|
-
throw new CliError(`Unknown command "${cmd}". Run \`nerd help\`.`);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
main(process.argv.slice(2)).catch((err) => {
|
|
262
|
-
if (err instanceof CliError) {
|
|
263
|
-
log.warn(err.message);
|
|
264
|
-
process.exit(err.exitCode);
|
|
265
|
-
}
|
|
266
|
-
console.error(err);
|
|
267
|
-
process.exit(1);
|
|
268
|
-
});
|
|
2
|
+
import { runCli } from "./cli.js";
|
|
3
|
+
await runCli(process.argv.slice(2));
|
package/dist/util.d.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
export declare class CliError extends Error {
|
|
2
2
|
readonly exitCode: number;
|
|
3
|
-
|
|
3
|
+
/** The API's error code (e.g. "not_found") when the error came from a response; callers branch on this, never on the message. */
|
|
4
|
+
readonly code: string | undefined;
|
|
5
|
+
/** The HTTP status when the error came from a response. */
|
|
6
|
+
readonly status: number | undefined;
|
|
7
|
+
constructor(message: string, exitCode?: number, extra?: {
|
|
8
|
+
code?: string | undefined;
|
|
9
|
+
status?: number | undefined;
|
|
10
|
+
});
|
|
4
11
|
}
|
|
12
|
+
/** The one sentence every unauthenticated command prints, with both ways in. */
|
|
13
|
+
export declare const NOT_LOGGED_IN = "Not logged in. Run `nerd login --email you@example.com` (one click in the email signs you in and creates your account the first time), or set NERD_TOKEN to an API token for scripts.";
|
|
5
14
|
export declare const log: {
|
|
6
15
|
info: (msg: string) => void;
|
|
7
16
|
step: (msg: string) => void;
|
|
@@ -19,3 +28,7 @@ export declare function runOrThrow(cmd: string, args: string[], opts?: {
|
|
|
19
28
|
}): Promise<void>;
|
|
20
29
|
export declare function printJson(value: unknown): void;
|
|
21
30
|
export declare function table(rows: Record<string, unknown>[], columns: string[]): void;
|
|
31
|
+
/** The dashboard that goes with an API origin (https://api.nerdsystem.com -> https://nerdsystem.com). */
|
|
32
|
+
export declare function dashboardUrl(apiUrl: string): string;
|
|
33
|
+
/** Stops with one sentence instead of npx noise when `pnpm install` has not been run. */
|
|
34
|
+
export declare function ensureDeps(root: string): void;
|
package/dist/util.js
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
2
4
|
export class CliError extends Error {
|
|
3
5
|
exitCode;
|
|
4
|
-
|
|
6
|
+
/** The API's error code (e.g. "not_found") when the error came from a response; callers branch on this, never on the message. */
|
|
7
|
+
code;
|
|
8
|
+
/** The HTTP status when the error came from a response. */
|
|
9
|
+
status;
|
|
10
|
+
constructor(message, exitCode = 1, extra = {}) {
|
|
5
11
|
super(message);
|
|
6
12
|
this.exitCode = exitCode;
|
|
13
|
+
this.code = extra.code;
|
|
14
|
+
this.status = extra.status;
|
|
7
15
|
}
|
|
8
16
|
}
|
|
17
|
+
/** The one sentence every unauthenticated command prints, with both ways in. */
|
|
18
|
+
export const NOT_LOGGED_IN = "Not logged in. Run `nerd login --email you@example.com` (one click in the email signs you in and creates your account the first time), or set NERD_TOKEN to an API token for scripts.";
|
|
9
19
|
export const log = {
|
|
10
20
|
info: (msg) => console.error(`${msg}`),
|
|
11
21
|
step: (msg) => console.error(`\x1b[36m›\x1b[0m ${msg}`),
|
|
@@ -37,3 +47,12 @@ export function table(rows, columns) {
|
|
|
37
47
|
for (const r of rows)
|
|
38
48
|
process.stdout.write(`${line(columns.map((c) => String(r[c] ?? "")))}\n`);
|
|
39
49
|
}
|
|
50
|
+
/** The dashboard that goes with an API origin (https://api.nerdsystem.com -> https://nerdsystem.com). */
|
|
51
|
+
export function dashboardUrl(apiUrl) {
|
|
52
|
+
return apiUrl.replace(/\/$/, "").replace(/^https?:\/\/api\./, "https://");
|
|
53
|
+
}
|
|
54
|
+
/** Stops with one sentence instead of npx noise when `pnpm install` has not been run. */
|
|
55
|
+
export function ensureDeps(root) {
|
|
56
|
+
if (!existsSync(path.join(root, "node_modules")))
|
|
57
|
+
throw new CliError(`Dependencies are not installed. Run \`pnpm install\` in ${root} first (install pnpm with \`npm install -g pnpm\` if it is missing).`);
|
|
58
|
+
}
|
package/package.json
CHANGED
|
@@ -15,7 +15,7 @@ If this project still has the template's todo app (the `todos` table and `src/se
|
|
|
15
15
|
2. Say what you will build first, in plain words: the one table and the one screen that make the idea real. Keep the first feature small enough to deploy within the session.
|
|
16
16
|
3. Replace the todo example with that feature: schema, migration (`pnpm db:generate`), a router under `/api/...`, and the client screen. The template is a normal website: a public homepage at `/`, auth pages at `/login`, `/signup`, `/forgot-password`, `/reset-password`, and a signed-in area. Reshape it to the idea (a public landing page with sign-in in the header, a members-only area, an admin section) rather than forcing a login wall; the forms already work.
|
|
17
17
|
4. Run `nerd check`, then `nerd dev`, and tell the person to open the local URL, create an account, and try it. Fix what they report.
|
|
18
|
-
5. Run `nerd deploy` and give them the live URL. Explain in two lines that every deploy is checked for leaked secrets and vulnerable dependencies, and that they can run `nerd
|
|
18
|
+
5. Run `nerd deploy` and give them the live URL. Tell them to sign up on their own app first, then make themselves admin in the dashboard's Auth tab (the URL is printed after the deploy). Explain in two lines that every deploy is checked for leaked secrets and vulnerable dependencies, and that they can run `nerd check` any time.
|
|
19
19
|
6. Offer the next steps once, briefly: link the GitHub repo for previews on pull requests (`nerd github connect`), add a custom domain, or invite a teammate from the dashboard.
|
|
20
20
|
|
|
21
21
|
Throughout, show commands rather than describing them, and never ask the person to edit config files; the platform owns `wrangler.jsonc` and `nerd.json`.
|
|
@@ -28,11 +28,11 @@ Throughout, show commands rather than describing them, and never ask the person
|
|
|
28
28
|
4. `nerd deploy` when it is ready. It runs the checks, builds, applies pending migrations, uploads, and prints the URL.
|
|
29
29
|
5. If something is wrong after deploying: `nerd logs` shows recent output from the deployed app (`--q` to search, `--pr 12` for a preview), `nerd deployments` shows status and errors, `nerd db query "..."` inspects production data.
|
|
30
30
|
|
|
31
|
-
Run `nerd check --json` when you want a machine-readable list of problems.
|
|
31
|
+
Run `nerd check --json` when you want a machine-readable list of problems. Its summary says how many gates ran and which were skipped (`skipped` in the JSON): semgrep and gitleaks only run locally when installed; git builds always run them in the builder.
|
|
32
32
|
|
|
33
33
|
## Deploying from GitHub
|
|
34
34
|
|
|
35
|
-
Once the repo is linked (`nerd github connect`, then `nerd github link owner/repo`), every push to the production branch builds and deploys automatically, and every pull request gets a preview at its own URL, posted as a PR comment. `nerd builds` lists builds; `nerd builds show <id>` prints the log when one fails. A failing build reports the same `nerd check` problems you would see locally, so fix and push again.
|
|
35
|
+
Once the repo is linked (`nerd github connect`, then `nerd github link owner/repo`), every push to the production branch builds and deploys automatically (for accounts younger than seven days, counted from the nerdsystem account's creation, git builds are deferred: deploy with `nerd deploy` from the machine until then), and every pull request gets a preview at its own URL, posted as a PR comment. `nerd builds` lists builds; `nerd builds show <id>` prints the log when one fails. A failing build reports the same `nerd check` problems you would see locally, so fix and push again. Every preview gets its own database and bucket, and pending migrations are applied to it on each preview build, so a PR that changes the schema can be tried end to end before it merges.
|
|
36
36
|
|
|
37
37
|
## Where things go
|
|
38
38
|
|
|
@@ -82,6 +82,7 @@ Then in `src/server/index.ts`: `app.route("/api/notes", notes);`
|
|
|
82
82
|
- Schema changes are migrations. Edit `schema.ts`, run `pnpm db:generate`, commit the new file in `migrations/`. Never hand-write SQL into production; `nerd deploy` applies migrations in name order and records which ran.
|
|
83
83
|
- Migrations are forward-only. To undo, write another migration.
|
|
84
84
|
- Prefer small, additive migrations. SQLite cannot alter columns in place.
|
|
85
|
+
- `nerd db export` downloads a SQL dump of the production database (`<slug>-<date>.sql`, or `--out file.sql`); use it before a risky migration or when the person wants their data.
|
|
85
86
|
|
|
86
87
|
## Errors
|
|
87
88
|
|
|
@@ -99,7 +100,9 @@ The loop: run `run_gates`, fix every failure with `audience: "agent"`, run `run_
|
|
|
99
100
|
- `audience: "human"` means a person must act: a secret that was committed must be rotated at the provider before you remove it; an ignore comment, ignore file, or gate setting you added is a suppression that only a person can approve in the dashboard. Never add `nosemgrep`, `gitleaks:allow`, `nerd-ignore`, or a `gates` key in `nerd.json` to make a finding go away.
|
|
100
101
|
- When `stop` is non-empty, the same rule has failed three runs in a row. Stop, summarize what you tried in plain words, and hand it to the person.
|
|
101
102
|
|
|
102
|
-
|
|
103
|
+
When a git build fails after a push, the same findings are posted back to the repository for you: as a comment on the pull request, or as a GitHub issue titled "nerdsystem refused <sha>" for a push to the production branch (`nerd builds show <id>` prints them too). Treat that text as the task: apply each fix, run the gates until clean, commit naming the rule ids, push. The issue closes itself when the next deploy succeeds. If the repository is linked with `--agent @claude` (or another handle), the post starts with that mention so a cloud agent is summoned without anyone pasting anything.
|
|
104
|
+
|
|
105
|
+
Without MCP, `nerd check --json` returns the same shape, and `nerd check --rule <rule_id>` runs one rule (`nerd verify` is the same command). Secrets are reported masked (`sk_live_****4f2a`); never print a credential yourself. Keep secrets in `.dev.vars` locally and in platform settings in production; never prefix a secret with `VITE_`, because those values are compiled into the public client bundle.
|
|
103
106
|
|
|
104
107
|
## What `nerd check` rejects
|
|
105
108
|
|
|
@@ -112,13 +115,17 @@ Without MCP, `nerd verify --json` returns the same shape, and `nerd verify --rul
|
|
|
112
115
|
## Scheduled jobs and email
|
|
113
116
|
|
|
114
117
|
- **Scheduled jobs**: put the work in the `POST /api/cron` handler (already in `src/server/index.ts`, guarded by `verifyCron()`), then turn the schedule on with `nerd cron hourly` or `nerd cron daily`. The platform calls it; the app never needs its own timer.
|
|
115
|
-
- **Secrets** (Stripe keys, API tokens): never in code or in the client. Locally they live in `.dev.vars` (gitignored). For production, ask the person to run `nerd secret set NAME` and paste the value; it reaches the app as `c.env.NAME` on the next deploy. `nerd secret list` shows names only. Do not put a real secret value in any command you run yourself.
|
|
118
|
+
- **Secrets** (Stripe keys, API tokens): never in code or in the client. Locally they live in `.dev.vars` (gitignored). For production, ask the person to run `nerd secret set NAME` and paste the value; it reaches the app as `c.env.NAME` on the next deploy. Pull request previews do not get it unless it was set with `--previews` (use that only for keys that are safe in a preview, such as a test-mode key). `nerd secret list` shows names only. Do not put a real secret value in any command you run yourself.
|
|
116
119
|
- **Email**: `sendEmail(c.env, { to, subject, text, html? })` from `@nerdsystem/sdk` sends through the platform. It is limited per day by plan (50 on Free, 2,000 on Pro at $39 a month, 10,000 on Studio), so use it for transactional mail such as receipts and notifications, not bulk. Plans can be paid monthly or yearly at ten times the monthly price.
|
|
117
120
|
|
|
118
121
|
## Debugging production
|
|
119
122
|
|
|
120
123
|
When something is wrong on the deployed app: `nerd logs --since 1h` (add `--q "text"` to search, `--pr 12` for a preview) shows errors with the request that caused them. Reproduce locally with `nerd dev` using the same input, fix it, run `nerd verify`, and deploy or open a pull request. If a deploy made things worse, `nerd rollback` puts the previous production bundle back without touching the database. Write a one-line note on every deploy (`nerd deploy --note "fix: todos query filtered by user"`); the dashboard shows these as a changelog people can read.
|
|
121
124
|
|
|
125
|
+
## Deleting a project
|
|
126
|
+
|
|
127
|
+
`nerd delete --yes` takes the app offline at once and moves the project to the trash; its database and files are kept for seven days (the command prints the date), then destroyed. `nerd trash` lists what can still be restored and `nerd trash restore <slug>` brings one back (the app stays offline until the next `nerd deploy` or a rollback). Never run `nerd delete` unless the person asked for it in so many words.
|
|
128
|
+
|
|
122
129
|
## Not available yet
|
|
123
130
|
|
|
124
131
|
Queues, Durable Objects, and log streaming. If the task needs one, say so instead of working around it with an unsupported binding.
|