@wujiland/cli 0.0.0-stage → 1.0.1

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.
@@ -0,0 +1,289 @@
1
+ // inca ci deploy: the deploy half of app CI (08-ci.md §6 steps 2–6). Reads the artifact the check job
2
+ // built, selects secrets by declared name, asks GitHub for an OIDC token, POSTs to the broker's /deploy.
3
+ // Secret values come only from INCA_SECRETS_JSON and are never printed (INCA-10, INCA-23).
4
+ import { createHash } from "node:crypto";
5
+ import { appendFile, lstat, readdir, readFile } from "node:fs/promises";
6
+ import { extname, join, resolve, sep } from "node:path";
7
+ import blake3 from "blake3-wasm";
8
+ import { parseConfig, plan } from "@wujiland/config";
9
+ import { CONFIG_FILE, formatErrors } from "../app.js";
10
+ import { CliError, EXIT, envFlag, str, usage } from "../context.js";
11
+ import { prefixFor } from "../github.js";
12
+ import { parseHttpsUrl } from "../store.js";
13
+ export const OIDC_AUDIENCE = "inca-deploy";
14
+ // The broker's limit for the whole request (05-broker.md §5); checked here before an OIDC token is asked for.
15
+ const MAX_BODY_BYTES = 20 * 1024 * 1024;
16
+ // Wrangler's module types (05-broker.md §5); its default module rules map these extensions.
17
+ const MODULE_TYPES = {
18
+ ".js": "application/javascript+module",
19
+ ".mjs": "application/javascript+module",
20
+ ".cjs": "application/javascript",
21
+ ".wasm": "application/wasm",
22
+ ".map": "application/source-map",
23
+ ".txt": "text/plain",
24
+ ".html": "text/plain",
25
+ ".sql": "text/plain",
26
+ ".bin": "application/octet-stream",
27
+ };
28
+ const ASSET_TYPES = {
29
+ ".html": "text/html; charset=utf-8",
30
+ ".htm": "text/html; charset=utf-8",
31
+ ".css": "text/css; charset=utf-8",
32
+ ".js": "text/javascript; charset=utf-8",
33
+ ".mjs": "text/javascript; charset=utf-8",
34
+ ".json": "application/json",
35
+ ".txt": "text/plain; charset=utf-8",
36
+ ".md": "text/markdown; charset=utf-8",
37
+ ".csv": "text/csv; charset=utf-8",
38
+ ".xml": "application/xml",
39
+ ".svg": "image/svg+xml",
40
+ ".png": "image/png",
41
+ ".jpg": "image/jpeg",
42
+ ".jpeg": "image/jpeg",
43
+ ".gif": "image/gif",
44
+ ".webp": "image/webp",
45
+ ".avif": "image/avif",
46
+ ".ico": "image/x-icon",
47
+ ".woff": "font/woff",
48
+ ".woff2": "font/woff2",
49
+ ".pdf": "application/pdf",
50
+ ".wasm": "application/wasm",
51
+ ".webmanifest": "application/manifest+json",
52
+ ".mp3": "audio/mpeg",
53
+ ".mp4": "video/mp4",
54
+ ".webm": "video/webm",
55
+ };
56
+ async function walk(root) {
57
+ const out = [];
58
+ for (const entry of await readdir(root, { recursive: true })) {
59
+ const st = await lstat(join(root, entry));
60
+ if (st.isFile())
61
+ out.push(entry);
62
+ }
63
+ return out.sort();
64
+ }
65
+ async function readTrimmed(dir, name) {
66
+ try {
67
+ return (await readFile(join(dir, name), "utf8")).trim();
68
+ }
69
+ catch {
70
+ throw new CliError(EXIT.NOT_FOUND, "ARTIFACT_INCOMPLETE", `The artifact has no ${name}.`, "The check job must write it (08-ci.md §5 step 9).");
71
+ }
72
+ }
73
+ /** Declared names only, this environment's prefix only; empty values are listed as missing (08-ci.md §6 step 3). */
74
+ export function selectSecrets(declared, env, raw) {
75
+ let all = {};
76
+ if (raw !== undefined && raw.trim() !== "") {
77
+ let parsed;
78
+ try {
79
+ parsed = JSON.parse(raw);
80
+ }
81
+ catch {
82
+ throw new CliError(EXIT.REFUSED, "SECRETS_JSON_INVALID", "INCA_SECRETS_JSON is not JSON.");
83
+ }
84
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
85
+ throw new CliError(EXIT.REFUSED, "SECRETS_JSON_INVALID", "INCA_SECRETS_JSON is not a JSON object.");
86
+ all = parsed;
87
+ }
88
+ const secrets = {};
89
+ const missing = [];
90
+ for (const name of declared) {
91
+ const key = `${prefixFor(env)}${name}`;
92
+ const value = Object.hasOwn(all, key) ? all[key] : undefined;
93
+ if (typeof value === "string" && value !== "")
94
+ secrets[name] = value;
95
+ else
96
+ missing.push(name);
97
+ }
98
+ return { secrets, missing };
99
+ }
100
+ async function oidcToken(ctx) {
101
+ const url = ctx.deps.env.ACTIONS_ID_TOKEN_REQUEST_URL;
102
+ const bearer = ctx.deps.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
103
+ if (!url || !bearer) {
104
+ throw new CliError(EXIT.AUTH, "OIDC_UNAVAILABLE", "GitHub offered no OIDC token request.", "The calling workflow needs permissions: id-token: write.");
105
+ }
106
+ const u = new URL(url);
107
+ u.searchParams.set("audience", OIDC_AUDIENCE);
108
+ let res;
109
+ try {
110
+ res = await ctx.deps.fetch(u, { redirect: "error", headers: { authorization: `bearer ${bearer}`, accept: "application/json" } });
111
+ }
112
+ catch (e) {
113
+ throw new CliError(EXIT.OUTSIDE, "OIDC_FAILED", `The OIDC token request failed: ${e.message}.`);
114
+ }
115
+ const body = (await res.json().catch(() => null));
116
+ if (!res.ok || typeof body?.value !== "string")
117
+ throw new CliError(EXIT.OUTSIDE, "OIDC_FAILED", `GitHub answered the OIDC token request with ${res.status}.`);
118
+ return body.value;
119
+ }
120
+ const names = (xs) => (xs && xs.length ? xs.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join(", ") : "none");
121
+ /** Pending steps first, then the result (08-ci.md §6 step 5, §10 line 10). Names only, never values. */
122
+ export function summary(env, r, pending) {
123
+ const lines = [];
124
+ if (pending.length) {
125
+ lines.push(`## PENDING — ${pending.length} step(s) still to do`, "");
126
+ for (const p of pending)
127
+ lines.push(`- **${p.kind}**: ${p.message}${p.next ? ` — next: \`${p.next}\`` : ""}`);
128
+ lines.push("");
129
+ }
130
+ if (!r.ok) {
131
+ lines.push(`## Deploy to ${env} refused`, "", `- code: \`${r.code ?? "UNKNOWN"}\``, `- message: ${r.message ?? ""}`, `- next: ${r.next ?? "none"}`, "");
132
+ return lines.join("\n");
133
+ }
134
+ lines.push(`## Deployed ${r.worker} (${env})`, "", "| | |", "|---|---|", `| Worker | ${r.worker} |`, `| Version | ${r.version_id} |`, `| URL | ${r.url} |`, `| Migrations applied | ${names(r.migrations_applied)} |`, `| Secrets written | ${names(r.secrets_written)} |`, `| Secrets pruned | ${names(r.secrets_pruned)} |`, `| Secrets missing | ${names(r.secrets_missing)} |`, `| Storage created | ${names(r.storage_created)} |`, `| Storage detached | ${names(r.storage_detached)} |`, "");
135
+ return lines.join("\n");
136
+ }
137
+ export async function ciDeploy(ctx) {
138
+ const { deps } = ctx;
139
+ const env = envFlag(ctx, { required: true });
140
+ const artifact = str(ctx.flags, "artifact");
141
+ if (artifact === undefined)
142
+ throw usage("Give --artifact <dir>.");
143
+ const rawBroker = str(ctx.flags, "broker");
144
+ if (rawBroker === undefined)
145
+ throw usage("Give --broker https://….");
146
+ const broker = parseHttpsUrl(rawBroker, "--broker");
147
+ const dir = resolve(deps.cwd, artifact);
148
+ const configText = await readFile(join(dir, CONFIG_FILE), "utf8").catch(() => {
149
+ throw new CliError(EXIT.NOT_FOUND, "ARTIFACT_INCOMPLETE", `The artifact has no ${CONFIG_FILE}.`);
150
+ });
151
+ let migrationNames = [];
152
+ try {
153
+ migrationNames = (await readdir(join(dir, "migrations"))).filter((f) => f.endsWith(".sql")).sort();
154
+ }
155
+ catch (e) {
156
+ if (e.code !== "ENOENT")
157
+ throw e;
158
+ }
159
+ const parsed = parseConfig(configText, { migrationFiles: migrationNames });
160
+ if (!parsed.ok)
161
+ throw new CliError(EXIT.REFUSED, "CONFIG_INVALID", `The artifact's config is invalid:\n ${formatErrors(parsed.errors).join("\n ")}`, null, { errors: parsed.errors });
162
+ const config = parsed.value;
163
+ const planSha = await readTrimmed(dir, "plan_sha256");
164
+ const rendererVersion = await readTrimmed(dir, "renderer_version");
165
+ const mainModule = await readTrimmed(dir, "main_module");
166
+ const sha = deps.env.GITHUB_SHA;
167
+ const runId = deps.env.GITHUB_RUN_ID;
168
+ const runAttempt = deps.env.GITHUB_RUN_ATTEMPT;
169
+ if (!sha || !runId || !runAttempt)
170
+ throw usage("GITHUB_SHA, GITHUB_RUN_ID and GITHUB_RUN_ATTEMPT are not set: inca ci deploy runs inside GitHub Actions.");
171
+ const form = new FormData();
172
+ // A file part keeps the bytes; a string part would have its line ends turned into CRLF.
173
+ form.append("config", new Blob([configText], { type: "application/yaml" }), CONFIG_FILE);
174
+ form.append("env", env);
175
+ form.append("plan_sha256", planSha);
176
+ form.append("renderer_version", rendererVersion);
177
+ form.append("main_module", mainModule);
178
+ form.append("git", JSON.stringify({ sha, run_id: runId, run_attempt: runAttempt }));
179
+ const distDir = join(dir, "dist");
180
+ const modules = [];
181
+ for (const file of await walk(distDir).catch(() => [])) {
182
+ const name = file.split(sep).join("/");
183
+ // Wrangler writes a README.md of its own into --outdir; it is not a module.
184
+ if (name === "README.md")
185
+ continue;
186
+ const type = MODULE_TYPES[extname(name).toLowerCase()];
187
+ if (!type)
188
+ throw new CliError(EXIT.REFUSED, "MODULE_TYPE_UNKNOWN", `dist/${name} has no Worker module type.`, "Only .js .mjs .cjs .wasm .map .txt .html .sql .bin are sent.");
189
+ form.append("modules", new File([await readFile(join(distDir, file))], name, { type }));
190
+ modules.push(name);
191
+ }
192
+ if (!modules.length)
193
+ throw new CliError(EXIT.NOT_FOUND, "ARTIFACT_INCOMPLETE", "The artifact's dist/ holds no module.");
194
+ if (!modules.includes(mainModule))
195
+ throw new CliError(EXIT.REFUSED, "MAIN_MODULE_MISSING", `main_module ${mainModule} is not in dist/.`);
196
+ const migrations = [];
197
+ for (const name of migrationNames) {
198
+ const sql = await readFile(join(dir, "migrations", name), "utf8");
199
+ migrations.push({ name, sha256: createHash("sha256").update(sql, "utf8").digest("hex"), sql });
200
+ }
201
+ if (migrations.length)
202
+ form.append("migrations", JSON.stringify(migrations));
203
+ const assetsPlan = plan(config, env, { domain: "localhost", team: "" }).assets;
204
+ const assetPaths = [];
205
+ if (assetsPlan) {
206
+ const root = join(dir, assetsPlan.directory);
207
+ let files;
208
+ try {
209
+ files = await walk(root);
210
+ }
211
+ catch {
212
+ throw new CliError(EXIT.REFUSED, "ASSETS_MISSING", `The config declares assets in ${assetsPlan.directory}/, and the artifact has no such directory.`);
213
+ }
214
+ const manifest = {};
215
+ const parts = [];
216
+ for (const file of files) {
217
+ const path = `/${file.split(sep).join("/")}`;
218
+ // Wrangler's own metafiles; Inca does not apply _headers or _redirects, so it refuses them rather than drop them (P-10).
219
+ if (path === "/.assetsignore")
220
+ throw new CliError(EXIT.REFUSED, "ASSETS_IGNORE_UNSUPPORTED", ".assetsignore is not supported; keep ignored files out of the assets directory.");
221
+ if (path === "/_headers" || path === "/_redirects")
222
+ throw new CliError(EXIT.REFUSED, "ASSETS_META_UNSUPPORTED", `${path.slice(1)} is not applied by Inca; set headers and redirects in the Worker.`);
223
+ const bytes = await readFile(join(root, file));
224
+ if (bytes.byteLength > MAX_BODY_BYTES)
225
+ throw new CliError(EXIT.REFUSED, "BODY_TOO_LARGE", `${path} alone is over 20 MiB; media belongs in R2.`);
226
+ // Wrangler's asset hash: BLAKE3 over base64 content + extension, first 32 hex (cli.js hashFile).
227
+ const hash = blake3.hash(bytes.toString("base64") + extname(file).slice(1)).toString("hex").slice(0, 32);
228
+ manifest[path] = { hash, size: bytes.byteLength };
229
+ parts.push({ path, bytes });
230
+ }
231
+ form.append("assets_manifest", JSON.stringify(manifest));
232
+ for (const p of parts) {
233
+ form.append("asset", new File([p.bytes], p.path, { type: ASSET_TYPES[extname(p.path).toLowerCase()] ?? "" }));
234
+ assetPaths.push(p.path);
235
+ }
236
+ }
237
+ const { secrets, missing } = selectSecrets(config.secrets ?? [], env, deps.env.INCA_SECRETS_JSON);
238
+ if (Object.keys(secrets).length)
239
+ form.append("secrets", JSON.stringify(secrets));
240
+ for (const name of missing)
241
+ ctx.warn(`${name}: no ${prefixFor(env)}${name} value; not sent.`);
242
+ const total = [...form.values()].reduce((n, v) => n + (typeof v === "string" ? new TextEncoder().encode(v).byteLength : v.size), 0);
243
+ if (total > MAX_BODY_BYTES) {
244
+ throw new CliError(EXIT.REFUSED, "BODY_TOO_LARGE", `The deploy is ${(total / 1048576).toFixed(1)} MiB; the broker takes at most 20 MiB.`, "Move media to R2.");
245
+ }
246
+ const token = await oidcToken(ctx);
247
+ ctx.progress(`POST ${broker}/deploy: ${modules.length} module(s), ${assetPaths.length} asset(s), ${migrations.length} migration(s), secrets ${Object.keys(secrets).sort().join(", ") || "none"}.`);
248
+ let res;
249
+ try {
250
+ res = await deps.fetch(`${broker}/deploy`, { method: "POST", redirect: "error", headers: { authorization: `Bearer ${token}` }, body: form });
251
+ }
252
+ catch (e) {
253
+ throw new CliError(EXIT.OUTSIDE, "BROKER_UNREACHABLE", `The broker did not answer: ${e.message}.`);
254
+ }
255
+ const body = (await res.json().catch(() => null));
256
+ if (body === null)
257
+ throw new CliError(EXIT.OUTSIDE, "BROKER_NOT_JSON", `The broker answered ${res.status} without JSON.`);
258
+ const pending = [...(body.pending ?? [])];
259
+ const missingAll = [...new Set([...missing, ...(body.secrets_missing ?? [])])].sort();
260
+ for (const name of missingAll) {
261
+ pending.push({ kind: "secret-missing", message: `${name} has no ${prefixFor(env)}${name} value; the Worker keeps its old value or has none.`, next: `inca env push --env ${env}, then deploy again` });
262
+ }
263
+ const md = summary(env, body.ok ? { ...body, secrets_missing: missingAll } : body, pending);
264
+ const summaryFile = deps.env.GITHUB_STEP_SUMMARY;
265
+ if (summaryFile)
266
+ await appendFile(summaryFile, `${md}\n`);
267
+ if (!body.ok) {
268
+ throw new CliError(res.status >= 500 ? EXIT.OUTSIDE : EXIT.REFUSED, body.code ?? `HTTP_${res.status}`, body.message ?? `The broker refused (${res.status}).`, body.next ?? null);
269
+ }
270
+ return {
271
+ data: {
272
+ worker: body.worker,
273
+ version_id: body.version_id,
274
+ url: body.url,
275
+ migrations_applied: body.migrations_applied ?? [],
276
+ secrets_written: body.secrets_written ?? [],
277
+ secrets_missing: missingAll,
278
+ secrets_pruned: body.secrets_pruned ?? [],
279
+ storage_created: body.storage_created ?? [],
280
+ storage_detached: body.storage_detached ?? [],
281
+ modules,
282
+ assets: assetPaths,
283
+ },
284
+ human: md.split("\n"),
285
+ pending,
286
+ // A success with pending steps is green in CI; the list is at the top of the summary (08-ci.md §6 step 6).
287
+ exit: EXIT.OK,
288
+ };
289
+ }
@@ -0,0 +1,8 @@
1
+ import type { Ctx, Outcome } from "../context.js";
2
+ export declare const SLUG: RegExp;
3
+ export declare const TEMPLATE = "inca-app-template";
4
+ /** The SSH alias of inca-library/docs/REPOS.md, github.com-<account>; the alias sets the SSH user. */
5
+ export declare const remoteUrl: (owner: string, repo: string) => string;
6
+ export declare function checkSlug(slug: string): void;
7
+ export declare function parseStorage(raw: string | undefined): Record<string, string[]>;
8
+ export declare function create(ctx: Ctx): Promise<Outcome>;
@@ -0,0 +1,154 @@
1
+ // inca create: validate, repo from the template, register and provision, push main and dev (06-cli.md §4, §8).
2
+ // Resumable: every step first looks for its own result.
3
+ import { access, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { parseConfig, reservedReason } from "@wujiland/config";
6
+ import { parseDocument } from "yaml";
7
+ import { CONFIG_FILE, formatErrors } from "../app.js";
8
+ import { CliError, EXIT, str, usage } from "../context.js";
9
+ import { appRepo, firstLine, ghFor, git, gitOk } from "../github.js";
10
+ import { managerClient } from "../manager.js";
11
+ export const SLUG = /^[a-z0-9]([a-z0-9-]{0,40}[a-z0-9])?$/;
12
+ export const TEMPLATE = "inca-app-template";
13
+ const BINDING = /^[A-Z][A-Z0-9_]{0,62}$/;
14
+ /** The SSH alias of inca-library/docs/REPOS.md, github.com-<account>; the alias sets the SSH user. */
15
+ export const remoteUrl = (owner, repo) => `github.com-${owner}:${owner}/${repo}.git`;
16
+ export function checkSlug(slug) {
17
+ if (!SLUG.test(slug) || slug.endsWith("-uat")) {
18
+ throw new CliError(EXIT.REFUSED, "SLUG_INVALID", `${JSON.stringify(slug)} is not a valid slug: lower-case letters, digits and hyphens, 1–42 characters, never ending -uat.`);
19
+ }
20
+ const reserved = reservedReason(slug);
21
+ if (reserved)
22
+ throw new CliError(EXIT.REFUSED, "SLUG_RESERVED", `${reserved}.`, "Choose another slug.");
23
+ }
24
+ export function parseStorage(raw) {
25
+ const out = {};
26
+ if (raw === undefined || raw === "")
27
+ return out;
28
+ for (const item of raw.split(",")) {
29
+ const [kind, binding, ...rest] = item.trim().split(":");
30
+ if (rest.length || !kind || !binding || !["d1", "kv", "r2"].includes(kind) || !BINDING.test(binding)) {
31
+ throw usage(`--storage takes kind:BINDING pairs (d1, kv, r2), for example d1:DB,r2:MEDIA; not ${JSON.stringify(item)}.`);
32
+ }
33
+ (out[kind] ??= []).push(binding);
34
+ }
35
+ return out;
36
+ }
37
+ async function exists(path) {
38
+ try {
39
+ await access(path);
40
+ return true;
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ /** null when the repo does not exist; refuses one that was not made from the template (§8). */
47
+ async function findRepo(gh, repo) {
48
+ const full = `${gh.owner}/${repo}`;
49
+ const view = await gh.run(["repo", "view", full, "--json", "name,templateRepository"]);
50
+ if (view.code !== 0) {
51
+ if (/could not resolve to a repository/i.test(view.stderr))
52
+ return null;
53
+ throw new CliError(EXIT.OUTSIDE, "GH_FAILED", `gh repo view ${full} exited ${view.code}: ${firstLine(view.stderr)}`);
54
+ }
55
+ const info = JSON.parse(view.stdout);
56
+ const t = info.templateRepository;
57
+ if (!t || t.name !== TEMPLATE || t.owner?.login !== gh.owner) {
58
+ throw new CliError(EXIT.REFUSED, "REPO_NOT_OURS", `${full} exists but was not made from ${gh.owner}/${TEMPLATE}; inca create never scaffolds over it.`, "Pick another slug, or remove that repository yourself.");
59
+ }
60
+ return { id: await repoId(gh, repo) };
61
+ }
62
+ async function repoId(gh, repo) {
63
+ const r = await gh.ok(["api", `repos/${gh.owner}/${repo}`, "--jq", ".id"]);
64
+ const id = r.stdout.trim();
65
+ if (!/^[0-9]+$/.test(id))
66
+ throw new CliError(EXIT.OUTSIDE, "GH_FAILED", `gh api did not return a numeric id for ${repo}.`);
67
+ return id;
68
+ }
69
+ export async function create(ctx) {
70
+ const { deps } = ctx;
71
+ const slug = ctx.positionals[0];
72
+ checkSlug(slug);
73
+ const description = str(ctx.flags, "description");
74
+ if (description === undefined || description.trim() === "")
75
+ throw usage('Give --description "…".');
76
+ const storage = parseStorage(str(ctx.flags, "storage"));
77
+ const m = await managerClient(deps);
78
+ const gh = await ghFor(deps, m.config.github_user);
79
+ const repo = appRepo(slug);
80
+ const full = `${gh.owner}/${repo}`;
81
+ const steps = [];
82
+ let found = await findRepo(gh, repo);
83
+ const today = new Date(deps.now()).toISOString().slice(0, 10);
84
+ const probe = `version: 1\nname: ${slug}\ndescription: ${JSON.stringify(description)}\ncompatibility_date: "${today}"\n`;
85
+ await m.call("POST", "/api/v1/apps", { slug, description, repository: full, repository_id: found?.id ?? "0", config: probe, dry_run: true });
86
+ if (!found) {
87
+ ctx.progress(`Creating ${full} from ${gh.owner}/${TEMPLATE}.`);
88
+ await gh.ok(["repo", "create", full, "--private", "--template", `${gh.owner}/${TEMPLATE}`, "--description", description]);
89
+ found = { id: await repoId(gh, repo) };
90
+ steps.push("repo created");
91
+ }
92
+ const dir = join(deps.cwd, repo);
93
+ if (await exists(dir)) {
94
+ const origin = await git(deps, dir, ["config", "--get", "remote.origin.url"]);
95
+ if (origin.code !== 0 || origin.stdout.trim() !== remoteUrl(gh.owner, repo)) {
96
+ throw new CliError(EXIT.REFUSED, "DIR_NOT_CLONE", `${dir} exists and is not a clone of ${full}.`, "Run inca create from another directory.");
97
+ }
98
+ }
99
+ else {
100
+ // GitHub fills a repo from a template a few seconds after creating it.
101
+ for (let attempt = 1;; attempt++) {
102
+ await gitOk(deps, deps.cwd, ["clone", remoteUrl(gh.owner, repo), repo]);
103
+ if (await exists(join(dir, CONFIG_FILE)))
104
+ break;
105
+ await rm(dir, { recursive: true, force: true });
106
+ if (attempt === 5)
107
+ throw new CliError(EXIT.OUTSIDE, "TEMPLATE_NOT_READY", `${full} still has no ${CONFIG_FILE} after ${attempt} clones.`, `Run inca create ${slug} again in a minute.`);
108
+ await deps.sleep(3000);
109
+ }
110
+ steps.push("cloned");
111
+ }
112
+ const configPath = join(dir, CONFIG_FILE);
113
+ const doc = parseDocument(await readFile(configPath, "utf8"), { uniqueKeys: true });
114
+ if (doc.get("name") !== slug) {
115
+ doc.set("name", slug);
116
+ doc.set("description", description);
117
+ if (Object.keys(storage).length)
118
+ doc.set("storage", doc.createNode(storage));
119
+ const text = String(doc);
120
+ const parsed = parseConfig(text);
121
+ if (!parsed.ok)
122
+ throw new CliError(EXIT.REFUSED, "CONFIG_INVALID", `The edited config is invalid:\n ${formatErrors(parsed.errors).join("\n ")}`, null, { errors: parsed.errors });
123
+ await writeFile(configPath, text);
124
+ const pkgPath = join(dir, "package.json");
125
+ if (await exists(pkgPath)) {
126
+ const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
127
+ pkg.name = repo;
128
+ await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
129
+ }
130
+ await gitOk(deps, dir, ["add", "--", CONFIG_FILE, ...((await exists(pkgPath)) ? ["package.json"] : [])]);
131
+ await gitOk(deps, dir, ["commit", "-m", `inca create ${slug}`]);
132
+ steps.push("config written");
133
+ }
134
+ const configText = await readFile(configPath, "utf8");
135
+ ctx.progress("Registering and provisioning with the manager.");
136
+ const reg = await m.call("POST", "/api/v1/apps", { slug, description, repository: full, repository_id: found.id, config: configText });
137
+ if (reg.status === "failed") {
138
+ throw new CliError(EXIT.OUTSIDE, "PROVISION_FAILED", `Provisioning ${slug} stopped: ${JSON.stringify(reg.failure)}.`, `inca create ${slug} again resumes from the failed step.`, { failure: reg.failure });
139
+ }
140
+ // Local main (the template plus the config commit), never HEAD: a resumed run cannot ship local dev.
141
+ // Not forced, so it only ever fast-forwards.
142
+ await gitOk(deps, dir, ["push", "origin", "refs/heads/main:refs/heads/main"]);
143
+ const dev = await gitOk(deps, dir, ["ls-remote", "--heads", "origin", "refs/heads/dev"]);
144
+ if (dev.stdout.trim() === "") {
145
+ await gitOk(deps, dir, ["push", "origin", "HEAD:refs/heads/dev"]);
146
+ steps.push("dev pushed");
147
+ }
148
+ await git(deps, dir, ["switch", "-c", "dev", "--track", "origin/dev"]);
149
+ const hosts = { prod: `${slug}.${m.domain}`, uat: `${slug}-uat.${m.domain}` };
150
+ return {
151
+ data: { slug, repository: full, repository_id: found.id, directory: dir, status: reg.status ?? null, hosts, steps },
152
+ human: [`${slug}: ${full}, ${reg.status ?? "registered"}.`, `Production: https://${hosts.prod}`, `UAT: https://${hosts.uat}`, `Next: cd ${repo} && npm install && inca dev`],
153
+ };
154
+ }
@@ -0,0 +1,5 @@
1
+ import type { Ctx, Outcome } from "../context.js";
2
+ export declare function dev(ctx: Ctx): Promise<Outcome>;
3
+ export declare function configCheck(ctx: Ctx): Promise<Outcome>;
4
+ export declare function configRender(ctx: Ctx): Promise<Outcome>;
5
+ export declare function envAdd(ctx: Ctx): Promise<Outcome>;
@@ -0,0 +1,185 @@
1
+ // inca dev, inca config check|render, inca env add: the renderer run locally (02-renderer.md §3, §8).
2
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { join, relative, resolve } from "node:path";
4
+ import { RENDERER_VERSION, parseConfig, plan, planHash, toWranglerJsonc } from "@wujiland/config";
5
+ import { isSeq, parseDocument } from "yaml";
6
+ import { CONFIG_FILE, formatErrors, isTracked, loadApp, migrationFiles, readText, requireIgnored } from "../app.js";
7
+ import { INSTALL, checkTool } from "../auth.js";
8
+ import { CliError, EXIT, str, usage } from "../context.js";
9
+ import { managerClient } from "../manager.js";
10
+ import { parseHttpsUrl, parseManagerUrl, readCliConfig } from "../store.js";
11
+ const WRANGLER = "wrangler.jsonc";
12
+ const LOCAL_FACTS = { domain: "localhost", team: "local" };
13
+ const HOSTNAME = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
14
+ async function exists(path) {
15
+ try {
16
+ await access(path);
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ /** The domain is in no public package (INCA-33c): from --domain, else from the manager URL saved at login. */
24
+ async function domainFor(ctx) {
25
+ const flag = str(ctx.flags, "domain");
26
+ if (flag !== undefined) {
27
+ if (!HOSTNAME.test(flag))
28
+ throw usage(`--domain ${JSON.stringify(flag)} is not a domain name.`);
29
+ return flag;
30
+ }
31
+ const c = await readCliConfig(ctx.deps);
32
+ if (c)
33
+ return parseManagerUrl(c.manager_url).domain;
34
+ throw usage("A build render needs the platform domain.", "Pass --domain <domain>, or run inca login first.");
35
+ }
36
+ export async function dev(ctx) {
37
+ const { deps } = ctx;
38
+ if (ctx.passthrough.some((a) => a === "--remote" || a.startsWith("--remote=") || a === "-r")) {
39
+ throw new CliError(EXIT.REFUSED, "REMOTE_REFUSED", "inca dev runs locally only; --remote would bind real resources.", "Deploy to UAT with inca deploy --env uat.");
40
+ }
41
+ const app = await loadApp(deps.cwd);
42
+ await requireIgnored(deps, deps.cwd, WRANGLER);
43
+ const p = plan(app.config, "local", LOCAL_FACTS);
44
+ await writeFile(join(deps.cwd, WRANGLER), toWranglerJsonc(p));
45
+ const local = join(deps.cwd, "node_modules", ".bin", "wrangler");
46
+ const wrangler = (await exists(local)) ? local : "wrangler";
47
+ await checkTool(deps, wrangler, ["--version"], INSTALL.wrangler);
48
+ ctx.progress(`Rendered ${WRANGLER} (local); starting wrangler dev.`);
49
+ const r = await deps.run(wrangler, ["dev", "-c", WRANGLER, ...ctx.passthrough], { cwd: deps.cwd, inherit: true });
50
+ return {
51
+ data: { worker: p.worker, wrangler_exit: r.code },
52
+ human: [`wrangler dev exited ${r.code}.`],
53
+ exit: r.code === 0 ? EXIT.OK : EXIT.OUTSIDE,
54
+ };
55
+ }
56
+ /** A wrangler.jsonc on disk must equal what this config renders in some mode (02-renderer.md §8). */
57
+ function renders(config) {
58
+ return [toWranglerJsonc(plan(config, "local", LOCAL_FACTS)), toWranglerJsonc(plan(config, "uat", LOCAL_FACTS)), toWranglerJsonc(plan(config, "prod", LOCAL_FACTS))];
59
+ }
60
+ export async function configCheck(ctx) {
61
+ const { deps } = ctx;
62
+ const text = await readText(join(deps.cwd, CONFIG_FILE), CONFIG_FILE);
63
+ const migrations = await migrationFiles(deps.cwd);
64
+ const parsed = parseConfig(text, { migrationFiles: migrations });
65
+ const errors = parsed.ok ? [] : [...parsed.errors];
66
+ if (await isTracked(deps, deps.cwd, WRANGLER)) {
67
+ errors.push({ code: "WRANGLER_TRACKED", path: "", message: `${WRANGLER} is tracked by git; it is rendered and must never be committed (INCA-07). git rm --cached ${WRANGLER}` });
68
+ }
69
+ const onDisk = join(deps.cwd, WRANGLER);
70
+ if (parsed.ok && (await exists(onDisk)) && !renders(parsed.value).includes(await readFile(onDisk, "utf8"))) {
71
+ errors.push({ code: "WRANGLER_HAND_EDITED", path: "", message: `${WRANGLER} differs from a fresh render; delete it and let inca dev or CI render it.` });
72
+ }
73
+ const data = { file: CONFIG_FILE, errors };
74
+ const human = errors.length ? formatErrors(errors) : [`${CONFIG_FILE}: valid.`];
75
+ const pending = [];
76
+ if (ctx.flags.drift && parsed.ok) {
77
+ const m = await managerClient(deps);
78
+ const drift = [];
79
+ for (const env of ["uat", "prod"]) {
80
+ const local = planHash(plan(parsed.value, env, { domain: m.domain, team: "" }));
81
+ const r = await m.call("GET", `/api/v1/apps/${parsed.value.name}/deploys?env=${env}`);
82
+ const last = r.deploys.find((d) => d.result === "ok") ?? null;
83
+ const deployed = last?.plan_sha256 ?? null;
84
+ drift.push({ env, plan_sha256: local, deployed_plan_sha256: deployed, deployed_at: last?.at ?? null, drift: deployed !== local });
85
+ human.push(deployed === local ? `${env}: deployed config matches.` : `${env}: DRIFT — this config is not what was last deployed (${deployed ?? "never deployed"}).`);
86
+ }
87
+ data.drift = drift;
88
+ }
89
+ if (ctx.flags.contract && parsed.ok) {
90
+ const raw = str(ctx.flags, "broker") ?? (await readCliConfig(deps))?.broker_url;
91
+ if (raw === undefined)
92
+ throw usage("--contract needs the broker's URL.", "Pass --broker https://…, or save it with inca login --broker.");
93
+ const broker = parseHttpsUrl(raw, "--broker");
94
+ let contract;
95
+ try {
96
+ const res = await deps.fetch(`${broker}/contract`);
97
+ contract = (await res.json());
98
+ }
99
+ catch (e) {
100
+ throw new CliError(EXIT.OUTSIDE, "BROKER_UNREACHABLE", `The broker's /contract could not be read: ${e.message}.`);
101
+ }
102
+ if (!Array.isArray(contract.passthrough_keys))
103
+ throw new CliError(EXIT.OUTSIDE, "BROKER_SHAPE", "The broker's /contract has no passthrough_keys list.");
104
+ const allowed = new Set(contract.passthrough_keys);
105
+ for (const key of Object.keys(parsed.value.wrangler ?? {})) {
106
+ if (!allowed.has(key))
107
+ errors.push({ code: "CONTRACT_KEY_UNKNOWN", path: `/wrangler/${key}`, message: `The deployed broker (renderer ${String(contract.renderer_version)}) does not list ${key}; deploy the platform first.` });
108
+ }
109
+ data.contract = { broker, renderer_version: contract.renderer_version };
110
+ if (errors.length)
111
+ human.splice(0, human.length, ...formatErrors(errors));
112
+ }
113
+ if (errors.length) {
114
+ throw new CliError(EXIT.REFUSED, errors[0].code, `${errors.length} problem(s) in ${CONFIG_FILE}:\n ${formatErrors(errors).join("\n ")}`, null, { errors });
115
+ }
116
+ return { data, human, pending };
117
+ }
118
+ export async function configRender(ctx) {
119
+ const { deps } = ctx;
120
+ const mode = str(ctx.flags, "mode") ?? "local";
121
+ if (mode !== "local" && mode !== "build")
122
+ throw usage("--mode is local or build.");
123
+ const envRaw = str(ctx.flags, "env");
124
+ if (mode === "local" && envRaw !== undefined)
125
+ throw usage("--env applies to --mode build only; local takes the UAT shape.");
126
+ if (mode === "build" && envRaw !== "uat" && envRaw !== "prod")
127
+ throw usage("--mode build needs --env uat or --env prod.");
128
+ const app = await loadApp(deps.cwd);
129
+ const facts = mode === "local" ? LOCAL_FACTS : { domain: await domainFor(ctx), team: "" };
130
+ const p = plan(app.config, mode === "local" ? "local" : envRaw, facts);
131
+ const out = resolve(deps.cwd, str(ctx.flags, "out") ?? (mode === "build" ? ".inca" : "."));
132
+ await mkdir(out, { recursive: true });
133
+ const rendered = join(out, WRANGLER);
134
+ const rel = relative(deps.cwd, rendered);
135
+ if (!rel.startsWith(".."))
136
+ await requireIgnored(deps, deps.cwd, rel);
137
+ await writeFile(rendered, toWranglerJsonc(p));
138
+ const hash = planHash(p);
139
+ const files = [rendered];
140
+ if (mode === "build") {
141
+ await writeFile(join(out, "plan_sha256"), hash);
142
+ await writeFile(join(out, "renderer_version"), RENDERER_VERSION);
143
+ files.push(join(out, "plan_sha256"), join(out, "renderer_version"));
144
+ }
145
+ return {
146
+ data: { mode, env: p.env, worker: p.worker, plan_sha256: hash, renderer_version: RENDERER_VERSION, files },
147
+ human: [`Rendered ${rel} (${mode}, ${p.env}).`, `plan_sha256 ${hash}`, `renderer ${RENDERER_VERSION}`],
148
+ };
149
+ }
150
+ const ENV_NAME = /^(?!INCA_|CF_|CLOUDFLARE_|GITHUB_|UAT_|PROD_)[A-Z][A-Z0-9_]{0,62}$/;
151
+ export async function envAdd(ctx) {
152
+ const name = ctx.positionals[0];
153
+ if (!ENV_NAME.test(name))
154
+ throw new CliError(EXIT.REFUSED, "SECRET_NAME_INVALID", `${name} is not a valid secret name (A–Z, 0–9, _; no INCA_, CF_, CLOUDFLARE_, GITHUB_, UAT_, PROD_ prefix).`);
155
+ const path = join(ctx.deps.cwd, CONFIG_FILE);
156
+ const before = await readText(path, CONFIG_FILE);
157
+ // Edits the YAML document, not the text, so comments and key order stay (06-cli.md §10 line 9).
158
+ const doc = parseDocument(before, { uniqueKeys: true });
159
+ if (doc.errors.length)
160
+ throw new CliError(EXIT.REFUSED, "CONFIG_PARSE", `${CONFIG_FILE} does not parse: ${doc.errors[0].message.split("\n")[0]}`);
161
+ const current = doc.get("secrets");
162
+ let added = true;
163
+ if (current === undefined || current === null) {
164
+ doc.set("secrets", doc.createNode([name]));
165
+ }
166
+ else if (isSeq(current)) {
167
+ if (current.items.some((i) => (typeof i === "object" && i !== null && "value" in i ? i.value : i) === name))
168
+ added = false;
169
+ else
170
+ current.add(doc.createNode(name));
171
+ }
172
+ else {
173
+ throw new CliError(EXIT.REFUSED, "CONFIG_SHAPE", "secrets: in the config is not a list.");
174
+ }
175
+ const after = String(doc);
176
+ const parsed = parseConfig(after, { migrationFiles: await migrationFiles(ctx.deps.cwd) });
177
+ if (!parsed.ok)
178
+ throw new CliError(EXIT.REFUSED, parsed.errors[0].code, `With ${name} the config would be invalid:\n ${formatErrors(parsed.errors).join("\n ")}`, null, { errors: parsed.errors });
179
+ if (added)
180
+ await writeFile(path, after);
181
+ return {
182
+ data: { name, added, secrets: parsed.value.secrets ?? [] },
183
+ human: [added ? `Declared ${name} in ${CONFIG_FILE}.` : `${name} was already declared.`, "Next: put its value in .env.inca.uat / .env.inca.prod, then inca env push --env <env>."],
184
+ };
185
+ }
@@ -0,0 +1,3 @@
1
+ import type { Ctx, Outcome } from "../context.js";
2
+ export declare function login(ctx: Ctx): Promise<Outcome>;
3
+ export declare function whoami(ctx: Ctx): Promise<Outcome>;