nerdsystem 0.0.0-stage → 0.0.2
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/LICENSE +21 -0
- package/README.md +12 -2
- package/dist/api.d.ts +58 -0
- package/dist/api.js +59 -0
- package/dist/commands/agents.d.ts +9 -0
- package/dist/commands/agents.js +45 -0
- package/dist/commands/auth.d.ts +7 -0
- package/dist/commands/auth.js +25 -0
- package/dist/commands/billing.d.ts +14 -0
- package/dist/commands/billing.js +42 -0
- package/dist/commands/builds.d.ts +12 -0
- package/dist/commands/builds.js +40 -0
- package/dist/commands/check.d.ts +20 -0
- package/dist/commands/check.js +145 -0
- package/dist/commands/db.d.ts +9 -0
- package/dist/commands/db.js +32 -0
- package/dist/commands/deploy.d.ts +27 -0
- package/dist/commands/deploy.js +119 -0
- package/dist/commands/dev.d.ts +3 -0
- package/dist/commands/dev.js +38 -0
- package/dist/commands/doctor.d.ts +4 -0
- package/dist/commands/doctor.js +42 -0
- package/dist/commands/github.d.ts +17 -0
- package/dist/commands/github.js +49 -0
- package/dist/commands/init.d.ts +15 -0
- package/dist/commands/init.js +107 -0
- package/dist/commands/login.d.ts +7 -0
- package/dist/commands/login.js +21 -0
- package/dist/commands/ops.d.ts +29 -0
- package/dist/commands/ops.js +113 -0
- package/dist/commands/org.d.ts +47 -0
- package/dist/commands/org.js +121 -0
- package/dist/commands/verify.d.ts +24 -0
- package/dist/commands/verify.js +50 -0
- package/dist/config.d.ts +7 -0
- package/dist/config.js +22 -0
- package/dist/gates/attempts.d.ts +18 -0
- package/dist/gates/attempts.js +38 -0
- package/dist/gates/code.d.ts +15 -0
- package/dist/gates/code.js +179 -0
- package/dist/gates/fs.d.ts +7 -0
- package/dist/gates/fs.js +35 -0
- package/dist/gates/index.d.ts +26 -0
- package/dist/gates/index.js +68 -0
- package/dist/gates/rules.d.ts +19 -0
- package/dist/gates/rules.js +58 -0
- package/dist/gates/secrets.d.ts +11 -0
- package/dist/gates/secrets.js +98 -0
- package/dist/gates/suppressions.d.ts +2 -0
- package/dist/gates/suppressions.js +57 -0
- package/dist/gates/text.d.ts +31 -0
- package/dist/gates/text.js +72 -0
- package/dist/gates/tools.d.ts +5 -0
- package/dist/gates/tools.js +69 -0
- package/dist/gates/types.d.ts +72 -0
- package/dist/gates/types.js +18 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +268 -0
- package/dist/project.d.ts +11 -0
- package/dist/project.js +51 -0
- package/dist/util.d.ts +21 -0
- package/dist/util.js +39 -0
- package/package.json +55 -3
- package/rules/semgrep.yml +77 -0
- package/template/.claude/settings.json +12 -0
- package/template/.claude/skills/nerdsystem/SKILL.md +123 -0
- package/template/.cursor/mcp.json +8 -0
- package/template/.cursor/rules/nerdsystem.mdc +124 -0
- package/template/.gemini/settings.json +8 -0
- package/template/.github/copilot-instructions.md +120 -0
- package/template/.github/dependabot.yml +14 -0
- package/template/.mcp.json +8 -0
- package/template/.vscode/mcp.json +9 -0
- package/template/AGENTS.md +120 -0
- package/template/CLAUDE.md +32 -0
- package/template/GEMINI.md +120 -0
- package/template/drizzle.config.ts +7 -0
- package/template/gitignore +7 -0
- package/template/index.html +12 -0
- package/template/migrations/0000_init.sql +7 -0
- package/template/nerd.json +5 -0
- package/template/package.json +33 -0
- package/template/scripts/sync-agents.mjs +28 -0
- package/template/src/client/App.tsx +182 -0
- package/template/src/client/api.ts +42 -0
- package/template/src/client/main.tsx +10 -0
- package/template/src/client/styles.css +16 -0
- package/template/src/client/vite-env.d.ts +1 -0
- package/template/src/server/db/index.ts +7 -0
- package/template/src/server/db/schema.ts +14 -0
- package/template/src/server/index.ts +23 -0
- package/template/src/server/routes/todos.ts +57 -0
- package/template/tsconfig.client.json +18 -0
- package/template/tsconfig.json +4 -0
- package/template/tsconfig.worker.json +18 -0
- package/template/vite.config.ts +7 -0
- package/template/wrangler.jsonc +18 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ApiClient } from "../api.js";
|
|
4
|
+
import { findProjectRoot, projectPath, readNerdJson } from "../project.js";
|
|
5
|
+
import { CliError, log, printJson, runOrThrow, table } from "../util.js";
|
|
6
|
+
import { runChecks } from "./check.js";
|
|
7
|
+
import { BUILD_GATES, knownSecretsFrom, runGates, SOURCE_GATES } from "../gates/index.js";
|
|
8
|
+
import { readDevVars } from "../project.js";
|
|
9
|
+
const MODULE_EXT = new Set([".js", ".mjs", ".wasm"]);
|
|
10
|
+
function walk(dir, base = dir) {
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const entry of readdirSync(dir)) {
|
|
13
|
+
const full = path.join(dir, entry);
|
|
14
|
+
if (statSync(full).isDirectory())
|
|
15
|
+
out.push(...walk(full, base));
|
|
16
|
+
else
|
|
17
|
+
out.push(path.relative(base, full));
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
/** Locates the Vite build output: dist/<worker>/wrangler.json names the entry and the assets dir. */
|
|
22
|
+
export function readBuildOutput(root) {
|
|
23
|
+
const dist = path.join(root, "dist");
|
|
24
|
+
if (!existsSync(dist))
|
|
25
|
+
throw new CliError("No dist/ directory. Run `vite build` first.");
|
|
26
|
+
const workerDir = readdirSync(dist)
|
|
27
|
+
.map((d) => path.join(dist, d))
|
|
28
|
+
.find((d) => existsSync(path.join(d, "wrangler.json")));
|
|
29
|
+
if (!workerDir)
|
|
30
|
+
throw new CliError("No dist/*/wrangler.json found. Is @cloudflare/vite-plugin configured?");
|
|
31
|
+
const cfg = JSON.parse(readFileSync(path.join(workerDir, "wrangler.json"), "utf8"));
|
|
32
|
+
if (!cfg.main)
|
|
33
|
+
throw new CliError("Built wrangler.json has no main.");
|
|
34
|
+
const modules = walk(workerDir).filter((f) => MODULE_EXT.has(path.extname(f)) && !f.startsWith(".vite/"));
|
|
35
|
+
const assetsDir = cfg.assets?.directory ? path.resolve(workerDir, cfg.assets.directory) : undefined;
|
|
36
|
+
const assets = assetsDir && existsSync(assetsDir) ? walk(assetsDir).filter((f) => !f.startsWith(".") && !f.includes("/.") && path.basename(f) !== ".dev.vars") : [];
|
|
37
|
+
return {
|
|
38
|
+
workerDir,
|
|
39
|
+
main: cfg.main,
|
|
40
|
+
modules,
|
|
41
|
+
assetsDir,
|
|
42
|
+
assets,
|
|
43
|
+
compatibilityDate: cfg.compatibility_date,
|
|
44
|
+
compatibilityFlags: cfg.compatibility_flags ?? [],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export async function applyRemoteMigrations(root, api) {
|
|
48
|
+
const dir = path.join(root, "migrations");
|
|
49
|
+
if (!existsSync(dir))
|
|
50
|
+
return { applied: [], skipped: [] };
|
|
51
|
+
const migrations = readdirSync(dir)
|
|
52
|
+
.filter((f) => f.endsWith(".sql"))
|
|
53
|
+
.sort()
|
|
54
|
+
.map((name) => ({ name, sql: readFileSync(path.join(dir, name), "utf8") }));
|
|
55
|
+
if (migrations.length === 0)
|
|
56
|
+
return { applied: [], skipped: [] };
|
|
57
|
+
return api.post(projectPath(readNerdJson(root), "/db/migrations"), { migrations });
|
|
58
|
+
}
|
|
59
|
+
export async function deploy(opts) {
|
|
60
|
+
const root = findProjectRoot();
|
|
61
|
+
const nerd = readNerdJson(root);
|
|
62
|
+
const api = ApiClient.fromConfig();
|
|
63
|
+
const knownSecrets = knownSecretsFrom(readDevVars(root));
|
|
64
|
+
if (!opts["skip-check"]) {
|
|
65
|
+
const remote = await runChecks(root, { remote: true, typecheck: false });
|
|
66
|
+
if (!remote.ok) {
|
|
67
|
+
for (const p of remote.problems)
|
|
68
|
+
log.warn(p);
|
|
69
|
+
throw new CliError("Fix the problems above, then deploy again.", 2);
|
|
70
|
+
}
|
|
71
|
+
const { approvedSuppressions } = await import("./verify.js");
|
|
72
|
+
const gates = await runGates(SOURCE_GATES, { root, knownSecrets, approvedSuppressions: await approvedSuppressions(root), log: (l) => log.info(l) }, { stopOnFail: true });
|
|
73
|
+
if (!gates.ok)
|
|
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");
|
|
76
|
+
}
|
|
77
|
+
if (!opts["skip-build"]) {
|
|
78
|
+
log.step("Building with vite");
|
|
79
|
+
await runOrThrow("npx", ["vite", "build"], { cwd: root });
|
|
80
|
+
}
|
|
81
|
+
const build = readBuildOutput(root);
|
|
82
|
+
if (!opts["skip-check"]) {
|
|
83
|
+
const built = await runGates(BUILD_GATES, { root, build, knownSecrets, log: (l) => log.info(l) }, { stopOnFail: true });
|
|
84
|
+
if (!built.ok)
|
|
85
|
+
throw new CliError(`Blocked by: ${built.failed.map((g) => g.title).join(", ")}.`, 2);
|
|
86
|
+
}
|
|
87
|
+
log.step("Applying migrations to the production database");
|
|
88
|
+
const mig = await applyRemoteMigrations(root, api);
|
|
89
|
+
if (mig.applied.length)
|
|
90
|
+
log.ok(`Applied ${mig.applied.join(", ")}`);
|
|
91
|
+
else
|
|
92
|
+
log.info(" No new migrations");
|
|
93
|
+
log.step(`Uploading ${build.modules.length} module(s) and ${build.assets.length} asset(s)`);
|
|
94
|
+
const form = new FormData();
|
|
95
|
+
form.append("metadata", JSON.stringify({
|
|
96
|
+
mainModule: build.main,
|
|
97
|
+
...(build.compatibilityDate ? { compatibilityDate: build.compatibilityDate } : {}),
|
|
98
|
+
compatibilityFlags: build.compatibilityFlags,
|
|
99
|
+
...(opts.note ? { note: opts.note } : {}),
|
|
100
|
+
}));
|
|
101
|
+
for (const m of build.modules) {
|
|
102
|
+
form.append(`module:${m}`, new Blob([readFileSync(path.join(build.workerDir, m))]), m);
|
|
103
|
+
}
|
|
104
|
+
for (const a of build.assets) {
|
|
105
|
+
form.append(`asset:/${a.split(path.sep).join("/")}`, new Blob([readFileSync(path.join(build.assetsDir, a))]), path.basename(a));
|
|
106
|
+
}
|
|
107
|
+
const { deployment } = await api.post(projectPath(nerd, "/deployments"), form);
|
|
108
|
+
const { project } = await api.get(projectPath(nerd));
|
|
109
|
+
if (opts.json)
|
|
110
|
+
return printJson({ deployment, url: project.url, migrations: mig });
|
|
111
|
+
log.ok(`Deployed ${deployment.id} (${deployment.status}) → ${project.url}`);
|
|
112
|
+
}
|
|
113
|
+
export async function deployments(opts) {
|
|
114
|
+
const nerd = readNerdJson(findProjectRoot());
|
|
115
|
+
const r = await ApiClient.fromConfig().get(projectPath(nerd, `/deployments?limit=${Number(opts.limit) || 10}`));
|
|
116
|
+
if (opts.json)
|
|
117
|
+
return printJson(r);
|
|
118
|
+
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"]);
|
|
119
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ApiClient } from "../api.js";
|
|
4
|
+
import { findProjectRoot, projectPath, readDevVars, readNerdJson, writeDevVars } from "../project.js";
|
|
5
|
+
import { log, run, runOrThrow } from "../util.js";
|
|
6
|
+
/** Makes sure .dev.vars carries the platform bindings for local dev, minting a dev tenant secret once. */
|
|
7
|
+
export async function ensureDevVars(root) {
|
|
8
|
+
const nerd = readNerdJson(root);
|
|
9
|
+
const vars = readDevVars(root);
|
|
10
|
+
const api = ApiClient.fromConfig();
|
|
11
|
+
const project = (await api.get(projectPath(nerd))).project;
|
|
12
|
+
const authUrl = `${api.apiUrl}/auth/v1`;
|
|
13
|
+
const next = {};
|
|
14
|
+
if (vars.NERD_PROJECT_ID !== project.id)
|
|
15
|
+
next.NERD_PROJECT_ID = project.id;
|
|
16
|
+
if (vars.NERD_AUTH_URL !== authUrl)
|
|
17
|
+
next.NERD_AUTH_URL = authUrl;
|
|
18
|
+
if (!vars.NERD_AUTH_SECRET || vars.NERD_PROJECT_ID !== project.id) {
|
|
19
|
+
log.step("Minting a development tenant secret");
|
|
20
|
+
const r = await api.post(projectPath(nerd, "/auth/secrets"), { label: `dev-${process.env.USER ?? "local"}` });
|
|
21
|
+
next.NERD_AUTH_SECRET = r.secret.secret;
|
|
22
|
+
}
|
|
23
|
+
if (Object.keys(next).length) {
|
|
24
|
+
writeDevVars(root, next);
|
|
25
|
+
log.ok("Updated .dev.vars");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export async function dev(extra) {
|
|
29
|
+
const root = findProjectRoot();
|
|
30
|
+
await ensureDevVars(root);
|
|
31
|
+
if (existsSync(path.join(root, "migrations"))) {
|
|
32
|
+
log.step("Applying migrations to the local database");
|
|
33
|
+
await runOrThrow("npx", ["wrangler", "d1", "migrations", "apply", "DB", "--local"], { cwd: root });
|
|
34
|
+
}
|
|
35
|
+
log.step("Starting vite dev");
|
|
36
|
+
const code = await run("npx", ["vite", ...extra], { cwd: root });
|
|
37
|
+
process.exitCode = code;
|
|
38
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { exec, hasTool } from "../gates/code.js";
|
|
3
|
+
import { loadConfig } from "../config.js";
|
|
4
|
+
import { findProjectRoot } from "../project.js";
|
|
5
|
+
import { log, printJson } from "../util.js";
|
|
6
|
+
/** Checks the machine, not the app: the things that stop a newcomer before the first deploy. */
|
|
7
|
+
export async function doctor(opts) {
|
|
8
|
+
const checks = [];
|
|
9
|
+
const nodeMajor = Number(process.versions.node.split(".")[0]);
|
|
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." });
|
|
11
|
+
for (const [tool, fix] of [["pnpm", "npm install -g pnpm"], ["git", "Install git from https://git-scm.com"]]) {
|
|
12
|
+
const r = await exec(tool, ["--version"], process.cwd());
|
|
13
|
+
checks.push({ name: tool, ok: r.code === 0, detail: r.code === 0 ? r.stdout.trim().split("\n")[0] ?? "" : "not found", fix });
|
|
14
|
+
}
|
|
15
|
+
const gh = await exec("gh", ["auth", "status"], process.cwd());
|
|
16
|
+
checks.push({ name: "GitHub CLI signed in (optional)", ok: gh.code === 0, detail: gh.code === 0 ? "ok" : "not signed in", fix: "Only needed to create repos from the terminal: https://cli.github.com then `gh auth login`." });
|
|
17
|
+
const cfg = loadConfig();
|
|
18
|
+
checks.push({ name: "nerd login", ok: Boolean(cfg.token), detail: cfg.token ? cfg.apiUrl : "no token saved", fix: "Create a token in the dashboard (API tokens), then `nerd login --token nsk_...`." });
|
|
19
|
+
let root = null;
|
|
20
|
+
try {
|
|
21
|
+
root = findProjectRoot();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// not inside a project; fine
|
|
25
|
+
}
|
|
26
|
+
checks.push({ name: "Inside a nerdsystem project", ok: Boolean(root), detail: root ?? "no nerd.json found upward from here", fix: "Run `npx nerdsystem init my-app` or cd into an app." });
|
|
27
|
+
if (root) {
|
|
28
|
+
checks.push({ name: "Dependencies installed", ok: existsSync(`${root}/node_modules`), detail: existsSync(`${root}/node_modules`) ? "node_modules present" : "missing", fix: "Run `pnpm install` in the project." });
|
|
29
|
+
checks.push({ name: "Local secrets (.dev.vars)", ok: existsSync(`${root}/.dev.vars`), detail: existsSync(`${root}/.dev.vars`) ? "present" : "missing", fix: "`nerd dev` writes it on first run." });
|
|
30
|
+
}
|
|
31
|
+
for (const tool of ["gitleaks", "semgrep", "osv-scanner"]) {
|
|
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.` });
|
|
34
|
+
}
|
|
35
|
+
if (opts.json)
|
|
36
|
+
return printJson({ ok: checks.filter((c) => !c.name.includes("optional")).every((c) => c.ok), checks });
|
|
37
|
+
for (const c of checks) {
|
|
38
|
+
log.info(`${c.ok ? "✓" : c.name.includes("optional") ? "–" : "✗"} ${c.name}: ${c.detail}`);
|
|
39
|
+
if (!c.ok && c.fix && !c.name.includes("optional"))
|
|
40
|
+
log.info(` ${c.fix}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare function githubConnect(opts: {
|
|
2
|
+
json: boolean;
|
|
3
|
+
}): Promise<void>;
|
|
4
|
+
export declare function githubLink(fullName: string | undefined, opts: {
|
|
5
|
+
json: boolean;
|
|
6
|
+
branch?: string;
|
|
7
|
+
"root-dir"?: string;
|
|
8
|
+
"no-previews": boolean;
|
|
9
|
+
"require-pr": boolean;
|
|
10
|
+
installation?: string;
|
|
11
|
+
}): Promise<void>;
|
|
12
|
+
export declare function githubUnlink(opts: {
|
|
13
|
+
json: boolean;
|
|
14
|
+
}): Promise<void>;
|
|
15
|
+
export declare function githubStatus(opts: {
|
|
16
|
+
json: boolean;
|
|
17
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ApiClient } from "../api.js";
|
|
2
|
+
import { findProjectRoot, projectPath, readNerdJson } from "../project.js";
|
|
3
|
+
import { CliError, log, printJson, table } from "../util.js";
|
|
4
|
+
export async function githubConnect(opts) {
|
|
5
|
+
const nerd = readNerdJson(findProjectRoot());
|
|
6
|
+
const r = await ApiClient.fromConfig().post(`/v1/orgs/${nerd.org}/github/install-url`, {});
|
|
7
|
+
if (opts.json)
|
|
8
|
+
return printJson(r);
|
|
9
|
+
log.ok(`Open this link to install the nerdsystem GitHub App for org ${nerd.org} (valid 30 minutes):\n\n ${r.url}\n\nThen run: nerd github link owner/repo`);
|
|
10
|
+
}
|
|
11
|
+
export async function githubLink(fullName, opts) {
|
|
12
|
+
if (!fullName)
|
|
13
|
+
throw new CliError("Usage: nerd github link owner/repo [--branch main] [--root-dir apps/web] [--no-previews] [--require-pr]");
|
|
14
|
+
const nerd = readNerdJson(findProjectRoot());
|
|
15
|
+
const body = { fullName, previews: !opts["no-previews"], rootDir: opts["root-dir"] ?? "", requirePr: opts["require-pr"] };
|
|
16
|
+
if (opts.branch)
|
|
17
|
+
body.productionBranch = opts.branch;
|
|
18
|
+
if (opts.installation)
|
|
19
|
+
body.installationId = Number(opts.installation);
|
|
20
|
+
const r = await ApiClient.fromConfig().request("PUT", projectPath(nerd, "/repo"), body);
|
|
21
|
+
if (opts.json)
|
|
22
|
+
return printJson(r);
|
|
23
|
+
log.ok(`Linked ${r.repo.fullName}. Pushes to ${r.repo.productionBranch} deploy to production${r.repo.previews ? "; pull requests get previews" : ""}.`);
|
|
24
|
+
}
|
|
25
|
+
export async function githubUnlink(opts) {
|
|
26
|
+
const nerd = readNerdJson(findProjectRoot());
|
|
27
|
+
const r = await ApiClient.fromConfig().delete(projectPath(nerd, "/repo"));
|
|
28
|
+
if (opts.json)
|
|
29
|
+
return printJson(r);
|
|
30
|
+
log.ok(r.unlinked ? "Repository unlinked. Git deploys are off; `nerd deploy` still works." : "No repository was linked.");
|
|
31
|
+
}
|
|
32
|
+
export async function githubStatus(opts) {
|
|
33
|
+
const nerd = readNerdJson(findProjectRoot());
|
|
34
|
+
const api = ApiClient.fromConfig();
|
|
35
|
+
const [installs, repo] = await Promise.all([
|
|
36
|
+
api.get(`/v1/orgs/${nerd.org}/github/installations`),
|
|
37
|
+
api.get(projectPath(nerd, "/repo")),
|
|
38
|
+
]);
|
|
39
|
+
if (opts.json)
|
|
40
|
+
return printJson({ ...installs, ...repo });
|
|
41
|
+
if (installs.installations.length === 0)
|
|
42
|
+
log.warn("No GitHub installation for this org. Run `nerd github connect`.");
|
|
43
|
+
else
|
|
44
|
+
table(installs.installations.map((i) => ({ installation: i.installationId, account: i.accountLogin, suspended: i.suspendedAt ?? "" })), ["installation", "account", "suspended"]);
|
|
45
|
+
if (repo.repo)
|
|
46
|
+
log.info(`\nrepo: ${repo.repo.fullName} branch: ${repo.repo.productionBranch} root: ${repo.repo.rootDir || "."} previews: ${repo.repo.previews ? "on" : "off"}`);
|
|
47
|
+
else
|
|
48
|
+
log.info("\nNo repository linked to this project. Run `nerd github link owner/repo`.");
|
|
49
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Project } from "../api.js";
|
|
2
|
+
/** Creates the remote project and writes nerd.json. Shared by init and link. */
|
|
3
|
+
export declare function linkProject(root: string, args: {
|
|
4
|
+
org?: string;
|
|
5
|
+
slug: string;
|
|
6
|
+
name: string;
|
|
7
|
+
create: boolean;
|
|
8
|
+
}): Promise<Project>;
|
|
9
|
+
export declare function init(dir: string | undefined, args: {
|
|
10
|
+
org?: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
slug?: string;
|
|
13
|
+
describe?: string;
|
|
14
|
+
"no-create": boolean;
|
|
15
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { ApiClient } from "../api.js";
|
|
5
|
+
import { projectPath, writeNerdJson } from "../project.js";
|
|
6
|
+
import { CliError, log } from "../util.js";
|
|
7
|
+
const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,28}[a-z0-9])?$/;
|
|
8
|
+
function templateDir() {
|
|
9
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const candidates = [
|
|
11
|
+
process.env.NERD_TEMPLATE_DIR,
|
|
12
|
+
path.resolve(here, "../template"), // published package: dist/../template
|
|
13
|
+
path.resolve(here, "../../template"), // monorepo: packages/cli/dist/../../template
|
|
14
|
+
].filter((p) => Boolean(p));
|
|
15
|
+
const found = candidates.find((p) => existsSync(path.join(p, "nerd.json")));
|
|
16
|
+
if (!found)
|
|
17
|
+
throw new CliError(`Template not found. Looked in: ${candidates.join(", ")}`);
|
|
18
|
+
return found;
|
|
19
|
+
}
|
|
20
|
+
async function pickOrg(api, wanted) {
|
|
21
|
+
const me = await api.get("/v1/me");
|
|
22
|
+
if (wanted) {
|
|
23
|
+
if (!me.orgs.some((o) => o.slug === wanted))
|
|
24
|
+
throw new CliError(`You are not a member of org "${wanted}". Yours: ${me.orgs.map((o) => o.slug).join(", ")}`);
|
|
25
|
+
return wanted;
|
|
26
|
+
}
|
|
27
|
+
if (me.orgs.length === 1)
|
|
28
|
+
return me.orgs[0].slug;
|
|
29
|
+
if (me.orgs.length === 0)
|
|
30
|
+
throw new CliError("You have no orgs. Create one in the dashboard first.");
|
|
31
|
+
throw new CliError(`Pick an org with --org. Yours: ${me.orgs.map((o) => o.slug).join(", ")}`);
|
|
32
|
+
}
|
|
33
|
+
/** Creates the remote project and writes nerd.json. Shared by init and link. */
|
|
34
|
+
export async function linkProject(root, args) {
|
|
35
|
+
const api = ApiClient.fromConfig();
|
|
36
|
+
const org = await pickOrg(api, args.org);
|
|
37
|
+
const nerd = { org, project: args.slug };
|
|
38
|
+
let project;
|
|
39
|
+
try {
|
|
40
|
+
project = (await api.get(projectPath(nerd))).project;
|
|
41
|
+
log.ok(`Linked to existing project ${org}/${args.slug}`);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
if (!args.create || !(err instanceof CliError) || !err.message.startsWith("not_found"))
|
|
45
|
+
throw err;
|
|
46
|
+
log.step(`Creating project ${org}/${args.slug} (provisions a database and a bucket)`);
|
|
47
|
+
project = (await api.post(`/v1/orgs/${org}/projects`, { slug: args.slug, name: args.name })).project;
|
|
48
|
+
log.ok(`Created ${project.url}`);
|
|
49
|
+
}
|
|
50
|
+
writeNerdJson(root, nerd);
|
|
51
|
+
return project;
|
|
52
|
+
}
|
|
53
|
+
async function askDescription() {
|
|
54
|
+
if (!process.stdin.isTTY || process.env.CI)
|
|
55
|
+
return undefined;
|
|
56
|
+
const { createInterface } = await import("node:readline/promises");
|
|
57
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
58
|
+
try {
|
|
59
|
+
const answer = await rl.question("What should this app do? (one or two sentences; Enter to skip)\n> ");
|
|
60
|
+
return answer.trim() || undefined;
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
rl.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Records the idea where the agent reads first, so the first session starts from the person's goal. */
|
|
67
|
+
function seedDescription(root, name, description) {
|
|
68
|
+
const p = path.join(root, "CLAUDE.md");
|
|
69
|
+
const heading = `# ${name}\n\n${description ? `${description}\n\n` : "(Describe what this app does here; the agent starts from it.)\n\n"}`;
|
|
70
|
+
writeFileSync(p, heading + readFileSync(p, "utf8").replace(/^# This app runs on nerdsystem\n/, "## This app runs on nerdsystem\n"));
|
|
71
|
+
}
|
|
72
|
+
export async function init(dir, args) {
|
|
73
|
+
if (!dir)
|
|
74
|
+
throw new CliError("Usage: nerd init <directory> [--org slug] [--name 'My App'] [--slug my-app]");
|
|
75
|
+
const root = path.resolve(dir);
|
|
76
|
+
const slug = args.slug ?? path.basename(root).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30);
|
|
77
|
+
if (!SLUG_RE.test(slug))
|
|
78
|
+
throw new CliError(`"${slug}" is not a valid project slug (lowercase letters, digits, hyphens). Pass --slug.`);
|
|
79
|
+
const name = args.name ?? slug;
|
|
80
|
+
if (existsSync(root) && readdirSync(root).length > 0)
|
|
81
|
+
throw new CliError(`${root} is not empty.`);
|
|
82
|
+
const src = templateDir();
|
|
83
|
+
log.step(`Copying template into ${root}`);
|
|
84
|
+
mkdirSync(root, { recursive: true });
|
|
85
|
+
// Filter on the path inside the template: the installed template itself lives under node_modules/.
|
|
86
|
+
cpSync(src, root, { recursive: true, filter: (p) => !/(^|\/)(node_modules|dist|\.wrangler|\.dev\.vars.*|.*\.tsbuildinfo)(\/|$)/.test(path.relative(src, p)) });
|
|
87
|
+
// The published template carries ".gitignore" as "gitignore" (npm strips the dotted name from packages).
|
|
88
|
+
if (existsSync(path.join(root, "gitignore")) && !existsSync(path.join(root, ".gitignore")))
|
|
89
|
+
renameSync(path.join(root, "gitignore"), path.join(root, ".gitignore"));
|
|
90
|
+
// Rename the app and point the SDK dependency somewhere installable.
|
|
91
|
+
const pkgPath = path.join(root, "package.json");
|
|
92
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
93
|
+
pkg.name = slug;
|
|
94
|
+
const sdkPath = process.env.NERD_SDK_PATH;
|
|
95
|
+
pkg.dependencies["@nerdsystem/sdk"] = sdkPath ? `file:${path.resolve(sdkPath)}` : `^${SDK_VERSION}`;
|
|
96
|
+
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
97
|
+
for (const f of ["wrangler.jsonc", "index.html"]) {
|
|
98
|
+
const p = path.join(root, f);
|
|
99
|
+
writeFileSync(p, readFileSync(p, "utf8").replaceAll("nerd-app", slug));
|
|
100
|
+
}
|
|
101
|
+
const description = args.describe ?? (await askDescription());
|
|
102
|
+
seedDescription(root, name, description);
|
|
103
|
+
const project = await linkProject(root, { ...(args.org !== undefined ? { org: args.org } : {}), slug, name, create: !args["no-create"] });
|
|
104
|
+
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}`);
|
|
105
|
+
}
|
|
106
|
+
// Kept in sync with packages/sdk/package.json by the release script.
|
|
107
|
+
const SDK_VERSION = "0.0.2";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ApiClient } from "../api.js";
|
|
2
|
+
import { DEFAULT_API_URL, loadConfig, saveConfig } from "../config.js";
|
|
3
|
+
import { CliError, log, printJson } from "../util.js";
|
|
4
|
+
export async function login(args) {
|
|
5
|
+
const token = args.token ?? process.env.NERD_TOKEN;
|
|
6
|
+
if (!token)
|
|
7
|
+
throw new CliError("Pass --token nsk_... (get one from the dashboard or `POST /v1/tokens`).");
|
|
8
|
+
const apiUrl = (args["api-url"] ?? loadConfig().apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
|
|
9
|
+
const me = await new ApiClient(apiUrl, token).get("/v1/me");
|
|
10
|
+
const file = saveConfig({ apiUrl, token });
|
|
11
|
+
log.ok(`Logged in as ${me.user.email} (${apiUrl}). Saved to ${file}`);
|
|
12
|
+
}
|
|
13
|
+
export async function whoami(opts) {
|
|
14
|
+
const api = ApiClient.fromConfig();
|
|
15
|
+
const me = await api.get("/v1/me");
|
|
16
|
+
if (opts.json)
|
|
17
|
+
return printJson({ apiUrl: api.apiUrl, ...me });
|
|
18
|
+
log.info(`${me.user.name} <${me.user.email}> via ${api.apiUrl}`);
|
|
19
|
+
for (const o of me.orgs)
|
|
20
|
+
log.info(` org ${o.slug} (${o.role})`);
|
|
21
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Re-publish the previous production deployment, or a specific one. */
|
|
2
|
+
export declare function rollback(target: string | undefined, opts: {
|
|
3
|
+
json: boolean;
|
|
4
|
+
note?: string;
|
|
5
|
+
}): Promise<void>;
|
|
6
|
+
export declare function cron(schedule: string | undefined, opts: {
|
|
7
|
+
json: boolean;
|
|
8
|
+
}): Promise<void>;
|
|
9
|
+
export declare function transfer(toOrg: string | undefined, opts: {
|
|
10
|
+
json: boolean;
|
|
11
|
+
}): Promise<void>;
|
|
12
|
+
export declare function health(opts: {
|
|
13
|
+
json: boolean;
|
|
14
|
+
}): Promise<void>;
|
|
15
|
+
/** Operator only: who is waiting while sign-up is invite-only. */
|
|
16
|
+
export declare function waitlist(opts: {
|
|
17
|
+
json: boolean;
|
|
18
|
+
approve?: string;
|
|
19
|
+
}): Promise<void>;
|
|
20
|
+
export declare function secretList(opts: {
|
|
21
|
+
json: boolean;
|
|
22
|
+
}): Promise<void>;
|
|
23
|
+
/** Value from the argument, or from stdin when omitted, so keys stay out of shell history. */
|
|
24
|
+
export declare function secretSet(name: string | undefined, value: string | undefined, opts: {
|
|
25
|
+
json: boolean;
|
|
26
|
+
}): Promise<void>;
|
|
27
|
+
export declare function secretRemove(name: string | undefined, opts: {
|
|
28
|
+
json: boolean;
|
|
29
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { ApiClient } from "../api.js";
|
|
2
|
+
import { findProjectRoot, projectPath, readNerdJson } from "../project.js";
|
|
3
|
+
import { CliError, log, printJson, table } from "../util.js";
|
|
4
|
+
/** Re-publish the previous production deployment, or a specific one. */
|
|
5
|
+
export async function rollback(target, opts) {
|
|
6
|
+
const nerd = readNerdJson(findProjectRoot());
|
|
7
|
+
const api = ApiClient.fromConfig();
|
|
8
|
+
let id = target;
|
|
9
|
+
if (!id) {
|
|
10
|
+
const list = await api.get(projectPath(nerd, "/deployments?limit=20"));
|
|
11
|
+
const prod = list.deployments.filter((d) => d.environment === "production" && d.status === "live");
|
|
12
|
+
const previous = prod.find((d, i) => i > 0 && d.artifactStored);
|
|
13
|
+
if (!previous)
|
|
14
|
+
throw new CliError("No earlier production deployment with a stored bundle to roll back to.");
|
|
15
|
+
id = previous.id;
|
|
16
|
+
log.step(`Rolling back to ${id} (${previous.note ?? previous.commitSha?.slice(0, 7) ?? previous.createdAt})`);
|
|
17
|
+
}
|
|
18
|
+
const r = await api.post(projectPath(nerd, `/deployments/${id}/rollback`), opts.note ? { note: opts.note } : {});
|
|
19
|
+
if (opts.json)
|
|
20
|
+
return printJson(r);
|
|
21
|
+
log.ok(`Live again as ${r.deployment.id}. The database was not changed.`);
|
|
22
|
+
}
|
|
23
|
+
export async function cron(schedule, opts) {
|
|
24
|
+
const nerd = readNerdJson(findProjectRoot());
|
|
25
|
+
const api = ApiClient.fromConfig();
|
|
26
|
+
if (!schedule) {
|
|
27
|
+
const p = await api.get(projectPath(nerd));
|
|
28
|
+
if (opts.json)
|
|
29
|
+
return printJson(p.project);
|
|
30
|
+
return log.info(`schedule: ${p.project.cronSchedule}${p.project.lastCronAt ? ` last run: ${p.project.lastCronAt} (${p.project.lastCronStatus})` : ""}\nSet with: nerd cron hourly|daily|off`);
|
|
31
|
+
}
|
|
32
|
+
if (!["off", "hourly", "daily"].includes(schedule))
|
|
33
|
+
throw new CliError("Usage: nerd cron off|hourly|daily");
|
|
34
|
+
const r = await api.patch(projectPath(nerd, "/cron"), { schedule });
|
|
35
|
+
if (opts.json)
|
|
36
|
+
return printJson(r);
|
|
37
|
+
log.ok(schedule === "off" ? "Scheduled job turned off." : `The platform will POST /api/cron ${schedule}. Handle it with verifyCron() in src/server/index.ts.`);
|
|
38
|
+
}
|
|
39
|
+
export async function transfer(toOrg, opts) {
|
|
40
|
+
if (!toOrg)
|
|
41
|
+
throw new CliError("Usage: nerd transfer <org-slug>");
|
|
42
|
+
const root = findProjectRoot();
|
|
43
|
+
const nerd = readNerdJson(root);
|
|
44
|
+
const r = await ApiClient.fromConfig().post(projectPath(nerd, "/transfer"), { toOrg });
|
|
45
|
+
const { writeNerdJson } = await import("../project.js");
|
|
46
|
+
writeNerdJson(root, { org: r.org, project: r.project.slug });
|
|
47
|
+
if (opts.json)
|
|
48
|
+
return printJson(r);
|
|
49
|
+
log.ok(`Moved to ${r.org}. nerd.json updated; the URL ${r.project.url} is unchanged.`);
|
|
50
|
+
}
|
|
51
|
+
export async function health(opts) {
|
|
52
|
+
const r = await ApiClient.fromConfig().get("/health/deep");
|
|
53
|
+
if (opts.json)
|
|
54
|
+
return printJson(r);
|
|
55
|
+
for (const c of r.components)
|
|
56
|
+
log.info(`${c.ok ? "✓" : "✗"} ${c.name}: ${c.detail}`);
|
|
57
|
+
if (!r.ok)
|
|
58
|
+
throw new CliError("Platform health check has failures", 2);
|
|
59
|
+
}
|
|
60
|
+
/** Operator only: who is waiting while sign-up is invite-only. */
|
|
61
|
+
export async function waitlist(opts) {
|
|
62
|
+
const api = ApiClient.fromConfig();
|
|
63
|
+
if (opts.approve) {
|
|
64
|
+
const r = await api.post(`/v1/waitlist/${opts.approve}/approve`, {});
|
|
65
|
+
if (opts.json)
|
|
66
|
+
return printJson(r);
|
|
67
|
+
return log.ok(`Approved ${r.entry.email}. They can sign in with GitHub now.`);
|
|
68
|
+
}
|
|
69
|
+
const r = await api.get("/v1/waitlist");
|
|
70
|
+
if (opts.json)
|
|
71
|
+
return printJson(r);
|
|
72
|
+
log.info(`sign-up mode: ${r.signupMode}`);
|
|
73
|
+
if (!r.entries.length)
|
|
74
|
+
return log.info("Nobody waiting.");
|
|
75
|
+
table(r.entries.map((e) => ({ id: e.id, email: e.email, github: e.githubLogin ?? "", note: e.note ?? "", status: e.approvedAt ? "approved" : "waiting", asked: e.createdAt })), ["id", "email", "github", "note", "status", "asked"]);
|
|
76
|
+
}
|
|
77
|
+
export async function secretList(opts) {
|
|
78
|
+
const nerd = readNerdJson(findProjectRoot());
|
|
79
|
+
const r = await ApiClient.fromConfig().get(projectPath(nerd, "/secrets"));
|
|
80
|
+
if (opts.json)
|
|
81
|
+
return printJson(r);
|
|
82
|
+
if (!r.secrets.length)
|
|
83
|
+
return log.info("No app secrets yet. Set one with: nerd secret set STRIPE_SECRET_KEY");
|
|
84
|
+
table(r.secrets.map((s) => ({ name: s.name, updated: s.updatedAt })), ["name", "updated"]);
|
|
85
|
+
log.info(`\n${r.note}`);
|
|
86
|
+
}
|
|
87
|
+
/** Value from the argument, or from stdin when omitted, so keys stay out of shell history. */
|
|
88
|
+
export async function secretSet(name, value, opts) {
|
|
89
|
+
if (!name)
|
|
90
|
+
throw new CliError("Usage: nerd secret set NAME [value] (omit the value to read it from stdin)");
|
|
91
|
+
let v = value;
|
|
92
|
+
if (v === undefined) {
|
|
93
|
+
if (process.stdin.isTTY)
|
|
94
|
+
log.info(`Paste the value for ${name}, then Enter and Ctrl-D:`);
|
|
95
|
+
v = (await new Promise((resolve) => { let s = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (d) => (s += d)); process.stdin.on("end", () => resolve(s)); })).replace(/\r?\n$/, "");
|
|
96
|
+
}
|
|
97
|
+
if (!v)
|
|
98
|
+
throw new CliError("Empty value.");
|
|
99
|
+
const nerd = readNerdJson(findProjectRoot());
|
|
100
|
+
const r = await ApiClient.fromConfig().put(projectPath(nerd, `/secrets/${name}`), { value: v });
|
|
101
|
+
if (opts.json)
|
|
102
|
+
return printJson(r);
|
|
103
|
+
log.ok(`Set ${r.secret.name}. ${r.note} For local dev, add ${name}=... to .dev.vars (never commit it).`);
|
|
104
|
+
}
|
|
105
|
+
export async function secretRemove(name, opts) {
|
|
106
|
+
if (!name)
|
|
107
|
+
throw new CliError("Usage: nerd secret rm NAME");
|
|
108
|
+
const nerd = readNerdJson(findProjectRoot());
|
|
109
|
+
const r = await ApiClient.fromConfig().delete(projectPath(nerd, `/secrets/${name}`));
|
|
110
|
+
if (opts.json)
|
|
111
|
+
return printJson(r);
|
|
112
|
+
log.ok(`Removed ${name}. It disappears from the app on the next deploy.`);
|
|
113
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export declare function membersList(opts: {
|
|
2
|
+
json: boolean;
|
|
3
|
+
org?: string;
|
|
4
|
+
}): Promise<void>;
|
|
5
|
+
export declare function membersInvite(email: string | undefined, opts: {
|
|
6
|
+
json: boolean;
|
|
7
|
+
org?: string;
|
|
8
|
+
role?: string;
|
|
9
|
+
}): Promise<void>;
|
|
10
|
+
export declare function membersRemove(userId: string | undefined, opts: {
|
|
11
|
+
json: boolean;
|
|
12
|
+
org?: string;
|
|
13
|
+
}): Promise<void>;
|
|
14
|
+
export declare function domainsList(opts: {
|
|
15
|
+
json: boolean;
|
|
16
|
+
}): Promise<void>;
|
|
17
|
+
export declare function domainsAdd(hostname: string | undefined, opts: {
|
|
18
|
+
json: boolean;
|
|
19
|
+
}): Promise<void>;
|
|
20
|
+
export declare function domainsRemove(id: string | undefined, opts: {
|
|
21
|
+
json: boolean;
|
|
22
|
+
}): Promise<void>;
|
|
23
|
+
export declare function logs(opts: {
|
|
24
|
+
json: boolean;
|
|
25
|
+
since?: string;
|
|
26
|
+
limit?: string;
|
|
27
|
+
q?: string;
|
|
28
|
+
pr?: string;
|
|
29
|
+
}): Promise<void>;
|
|
30
|
+
export declare function auditExport(opts: {
|
|
31
|
+
json: boolean;
|
|
32
|
+
org?: string;
|
|
33
|
+
out?: string;
|
|
34
|
+
}): Promise<void>;
|
|
35
|
+
export declare function clientsList(opts: {
|
|
36
|
+
json: boolean;
|
|
37
|
+
org?: string;
|
|
38
|
+
}): Promise<void>;
|
|
39
|
+
export declare function clientsCreate(slug: string | undefined, opts: {
|
|
40
|
+
json: boolean;
|
|
41
|
+
org?: string;
|
|
42
|
+
name?: string;
|
|
43
|
+
}): Promise<void>;
|
|
44
|
+
export declare function clientsRelease(slug: string | undefined, opts: {
|
|
45
|
+
json: boolean;
|
|
46
|
+
org?: string;
|
|
47
|
+
}): Promise<void>;
|