@wujiland/cli 0.0.0-stage → 1.0.0

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,59 @@
1
+ // inca login, inca whoami (06-cli.md §3).
2
+ import { fetchAccessToken, loadToken, readJwt, storeToken } from "../auth.js";
3
+ import { CliError, EXIT, str, usage } from "../context.js";
4
+ import { managerAccount, managerClient } from "../manager.js";
5
+ import { GITHUB_USER, configPath, parseHttpsUrl, parseManagerUrl, readCliConfig, writeCliConfig } from "../store.js";
6
+ import { CLI_VERSION } from "../version.js";
7
+ import { SLUG } from "./create.js";
8
+ const iso = (exp) => (exp === null ? null : new Date(exp * 1000).toISOString());
9
+ export async function login(ctx) {
10
+ const { deps } = ctx;
11
+ const existing = await readCliConfig(deps);
12
+ const managerFlag = str(ctx.flags, "manager");
13
+ if (managerFlag === undefined && !existing)
14
+ throw usage("The first login names the manager.", "inca login --manager https://manager.<domain> --github-user <account>");
15
+ const { url, domain } = parseManagerUrl(managerFlag ?? existing.manager_url);
16
+ let githubUser = str(ctx.flags, "github-user") ?? existing?.github_user;
17
+ if (githubUser === undefined) {
18
+ const answer = await deps.prompt("GitHub account that owns the app repos: ");
19
+ if (answer === null)
20
+ throw usage("Name the GitHub account.", "inca login --github-user <account>");
21
+ githubUser = answer.trim();
22
+ }
23
+ if (!GITHUB_USER.test(githubUser))
24
+ throw usage(`${JSON.stringify(githubUser)} is not a GitHub account name.`);
25
+ const next = { ...(existing ?? {}), manager_url: url, github_user: githubUser };
26
+ const broker = str(ctx.flags, "broker");
27
+ if (broker !== undefined)
28
+ next.broker_url = parseHttpsUrl(broker, "--broker");
29
+ const infra = str(ctx.flags, "infra");
30
+ if (infra !== undefined)
31
+ next.infra_dir = infra;
32
+ const app = str(ctx.flags, "app");
33
+ if (app !== undefined && !SLUG.test(app))
34
+ throw usage(`${JSON.stringify(app)} is not an app slug.`);
35
+ const target = app === undefined ? url : `https://${app}.${domain}`;
36
+ // The keychain entry names the host, so a token is only ever loaded for the host it was issued for.
37
+ const account = `access:${new URL(target).hostname}`;
38
+ ctx.progress(`Signing in to ${target} through cloudflared (a browser opens).`);
39
+ const token = await fetchAccessToken(deps, target);
40
+ await storeToken(deps, account, token);
41
+ await writeCliConfig(deps, next);
42
+ const { exp } = readJwt(token);
43
+ return {
44
+ data: { manager: url, github_user: githubUser, signed_in_to: target, keychain: { service: "inca", account }, token_expires_at: iso(exp), cli_config: configPath(deps.home) },
45
+ human: [`Signed in to ${target}; token in the keychain (inca/${account}), expires ${iso(exp) ?? "unknown"}.`, `Manager: ${url}. GitHub account: ${githubUser}.`],
46
+ };
47
+ }
48
+ export async function whoami(ctx) {
49
+ const m = await managerClient(ctx.deps);
50
+ const who = await m.call("GET", "/api/v1/whoami");
51
+ const health = await m.call("GET", "/healthz");
52
+ const { exp } = readJwt(await loadToken(ctx.deps, managerAccount(m.url)));
53
+ if (typeof who.email !== "string")
54
+ throw new CliError(EXIT.OUTSIDE, "MANAGER_SHAPE", "whoami returned no email.");
55
+ return {
56
+ data: { email: who.email, token_expires_at: iso(exp), cli_version: CLI_VERSION, manager: m.url, manager_version: health.version, registry_schema: health.registry_schema },
57
+ human: [`${who.email} (token expires ${iso(exp) ?? "unknown"})`, `cli ${CLI_VERSION}, manager ${health.version} at ${m.url}`],
58
+ };
59
+ }
@@ -0,0 +1,21 @@
1
+ import type { Ctx, Outcome, Pending } from "../context.js";
2
+ import type { ManagerClient } from "../manager.js";
3
+ export type PendingRow = {
4
+ id: number;
5
+ app_slug: string;
6
+ worker_name: string | null;
7
+ kind: string;
8
+ detail: string;
9
+ opened_at: string;
10
+ };
11
+ export declare function pendingFrom(rows: PendingRow[]): Pending[];
12
+ /** The slug from the argument, else from inca.config.yaml in this directory. */
13
+ export declare function slugFrom(ctx: Ctx): Promise<string>;
14
+ export declare function ls(ctx: Ctx): Promise<Outcome>;
15
+ /** Declared secret names with no `<PREFIX>_<NAME>` GitHub secret, per environment (06-cli.md §5 warnings). */
16
+ export declare function secretsWithoutValue(ctx: Ctx, m: ManagerClient, slug: string, declared: string[], envs: ("uat" | "prod")[]): Promise<Pending[]>;
17
+ export declare function status(ctx: Ctx): Promise<Outcome>;
18
+ export declare function versions(ctx: Ctx): Promise<Outcome>;
19
+ export declare function rollback(ctx: Ctx): Promise<Outcome>;
20
+ export declare function logs(ctx: Ctx): Promise<Outcome>;
21
+ export declare function accessShow(ctx: Ctx): Promise<Outcome>;
@@ -0,0 +1,178 @@
1
+ // Commands that only read or act through the manager: ls, status, versions, rollback, logs, access show.
2
+ import { access as fsAccess } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { plan, planHash } from "@wujiland/config";
5
+ import { CONFIG_FILE, loadApp } from "../app.js";
6
+ import { CliError, EXIT, confirmPlain, envFlag, str, usage } from "../context.js";
7
+ import { appRepo, ghFor, prefixFor, secretNames } from "../github.js";
8
+ import { managerClient } from "../manager.js";
9
+ import { SLUG } from "./create.js";
10
+ const NEXT = {
11
+ "access-apply": (r) => `Man Hay applies the Access change in inca-infra (inca access request ${r.app_slug} writes it), then inca status ${r.app_slug}`,
12
+ "access-removal": () => "Man Hay applies the Access removal in inca-infra",
13
+ "secret-missing": (r) => `inca env push --env ${r.worker_name?.endsWith("-uat") ? "uat" : "prod"}, then inca deploy`,
14
+ "migration-approval": (r) => `Approve the migration through the manager (POST /api/v1/apps/${r.app_slug}/migrations/approvals), then deploy again`,
15
+ "redeploy-for-audience": (r) => `inca deploy --env ${r.worker_name?.endsWith("-uat") ? "uat" : "prod"}`,
16
+ };
17
+ export function pendingFrom(rows) {
18
+ return rows.map((r) => {
19
+ let detail = r.detail;
20
+ try {
21
+ detail = JSON.parse(r.detail);
22
+ }
23
+ catch {
24
+ // detail stays the raw text
25
+ }
26
+ const d = detail;
27
+ const next = typeof d?.next === "string" ? d.next : (NEXT[r.kind]?.(r) ?? null);
28
+ const message = typeof d?.message === "string" ? d.message : `${r.kind} on ${r.worker_name ?? r.app_slug}: ${typeof r.detail === "string" ? r.detail : JSON.stringify(r.detail)}`;
29
+ return { kind: r.kind, message, next };
30
+ });
31
+ }
32
+ /** The slug from the argument, else from inca.config.yaml in this directory. */
33
+ export async function slugFrom(ctx) {
34
+ const given = ctx.positionals[0];
35
+ if (given !== undefined) {
36
+ if (!SLUG.test(given))
37
+ throw usage(`${JSON.stringify(given)} is not an app slug.`);
38
+ return given;
39
+ }
40
+ try {
41
+ await fsAccess(join(ctx.deps.cwd, CONFIG_FILE));
42
+ }
43
+ catch {
44
+ throw usage("Name the app, or run this in its repository.");
45
+ }
46
+ return (await loadApp(ctx.deps.cwd)).config.name;
47
+ }
48
+ export async function ls(ctx) {
49
+ const m = await managerClient(ctx.deps);
50
+ const r = await m.call("GET", "/api/v1/apps");
51
+ return {
52
+ data: { apps: r.apps },
53
+ human: r.apps.length ? r.apps.map((a) => `${a.slug.padEnd(24)} ${a.status.padEnd(14)} pending ${a.pending} ${a.description}`) : ["No apps."],
54
+ };
55
+ }
56
+ /** Declared secret names with no `<PREFIX>_<NAME>` GitHub secret, per environment (06-cli.md §5 warnings). */
57
+ export async function secretsWithoutValue(ctx, m, slug, declared, envs) {
58
+ if (!declared.length)
59
+ return [];
60
+ const gh = await ghFor(ctx.deps, m.config.github_user);
61
+ const have = await secretNames(gh, appRepo(slug));
62
+ const out = [];
63
+ for (const env of envs) {
64
+ for (const name of declared) {
65
+ if (!have.has(`${prefixFor(env)}${name}`)) {
66
+ out.push({ kind: "secret-no-github-value", message: `${name} is declared but ${prefixFor(env)}${name} has no GitHub secret; the ${env} deploy will not write it.`, next: `inca env push --env ${env}` });
67
+ }
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+ export async function status(ctx) {
73
+ const slug = await slugFrom(ctx);
74
+ const m = await managerClient(ctx.deps);
75
+ const r = await m.call("GET", `/api/v1/apps/${slug}`);
76
+ const local = ctx.positionals[0] === undefined ? await loadApp(ctx.deps.cwd) : null;
77
+ const human = [`${slug}: ${String(r.app.status)}`];
78
+ const drift = {};
79
+ for (const w of r.workers) {
80
+ const live = (w.live_versions ?? []).filter((v) => v.percentage > 0).map((v) => v.version_id);
81
+ const lastOk = r.deploys.find((d) => d.worker_name === w.name && d.result === "ok");
82
+ if (local)
83
+ drift[w.env] = lastOk ? lastOk.plan_sha256 !== planHash(plan(local.config, w.env, { domain: m.domain, team: "" })) : null;
84
+ human.push(` ${w.name} (${w.hostname})`, ` live ${live.join(", ") || "none"}; last deploy ${w.last_deploy_at ?? "never"}${w.rollback_floor ? `; rollback floor ${w.rollback_floor}` : ""}`, ` access declared ${w.access.declared}, applied ${w.access.applied?.mode ?? w.access.applied?.error ?? "unknown"}${w.access.audience_stale ? " (AUDIENCE_STALE)" : ""}`, ` storage ${w.resources.map((x) => `${x.kind}:${x.binding}=${x.status}`).join(", ") || "none"}`, ...(drift[w.env] ? [" DRIFT: this config differs from the last deploy"] : []));
85
+ }
86
+ const pending = pendingFrom(r.pending);
87
+ if (local)
88
+ pending.push(...(await secretsWithoutValue(ctx, m, slug, local.config.secrets ?? [], ["uat", "prod"])));
89
+ return { data: { slug, app: r.app, workers: r.workers, deploys: r.deploys, drift: local ? drift : null }, human, pending };
90
+ }
91
+ export async function versions(ctx) {
92
+ const slug = await slugFrom(ctx);
93
+ const env = envFlag(ctx, { required: false });
94
+ const m = await managerClient(ctx.deps);
95
+ const r = await m.call("GET", `/api/v1/apps/${slug}/versions?env=${env}`);
96
+ return {
97
+ data: { slug, env, ...strip(r) },
98
+ human: [
99
+ `${r.worker}: live ${r.live.join(", ") || "none"}${r.floor ? `; floor ${r.floor.version_id}` : ""}`,
100
+ ...r.versions.map((v) => ` ${r.live.includes(v.id) ? "*" : " "} ${v.id} ${v.created_on ?? ""}${r.floor?.version_id === v.id ? " (floor)" : ""}`),
101
+ ],
102
+ };
103
+ }
104
+ const strip = (r) => {
105
+ const { ok: _ok, ...rest } = r;
106
+ return rest;
107
+ };
108
+ export async function rollback(ctx) {
109
+ const slug = await slugFrom(ctx);
110
+ const env = envFlag(ctx, { required: true });
111
+ const m = await managerClient(ctx.deps);
112
+ let target = str(ctx.flags, "to");
113
+ if (target === undefined) {
114
+ const r = await m.call("GET", `/api/v1/apps/${slug}/versions?env=${env}`);
115
+ const sorted = [...r.versions].sort((a, b) => (b.created_on ?? "").localeCompare(a.created_on ?? ""));
116
+ const at = sorted.findIndex((v) => v.id === r.live[0]);
117
+ const prev = at >= 0 ? sorted[at + 1] : undefined;
118
+ if (!prev)
119
+ throw new CliError(EXIT.NOT_FOUND, "NO_PREVIOUS_VERSION", `${r.worker} has no version before the live one.`);
120
+ target = prev.id;
121
+ }
122
+ await confirmPlain(ctx, `Roll ${slug} (${env}) back to ${target}?`);
123
+ const r = await m.call("POST", `/api/v1/apps/${slug}/rollback`, { env, version_id: target });
124
+ return { data: { slug, env, worker: r.worker, live: r.live, note: r.note }, human: [`${r.worker} now serves ${r.live}.`, ...(r.note ? [r.note] : [])] };
125
+ }
126
+ export async function logs(ctx) {
127
+ const slug = ctx.positionals[0];
128
+ if (!SLUG.test(slug))
129
+ throw usage(`${JSON.stringify(slug)} is not an app slug.`);
130
+ const env = envFlag(ctx, { required: false });
131
+ const follow = ctx.flags.follow === true;
132
+ if (follow && ctx.json)
133
+ throw usage("--follow streams; --json prints one document. Use one.");
134
+ const interval = Number(str(ctx.flags, "interval") ?? "15");
135
+ if (!Number.isFinite(interval) || interval < 5)
136
+ throw usage("--interval is at least 5 seconds.");
137
+ const m = await managerClient(ctx.deps);
138
+ const q = (since) => {
139
+ const p = new URLSearchParams({ env });
140
+ for (const k of ["since", "until", "level", "request"]) {
141
+ const v = k === "since" && since ? since : str(ctx.flags, k);
142
+ if (v !== undefined)
143
+ p.set(k, v);
144
+ }
145
+ return `/api/v1/apps/${slug}/logs?${p}`;
146
+ };
147
+ const first = await m.call("GET", q());
148
+ if (!follow) {
149
+ return { data: { slug, env, worker: first.worker, since: first.since, until: first.until, events: first.events }, human: first.events.map((e) => JSON.stringify(e)) };
150
+ }
151
+ for (const e of first.events)
152
+ ctx.deps.stdout(`${JSON.stringify(e)}\n`);
153
+ let since = first.until;
154
+ for (;;) {
155
+ await ctx.deps.sleep(interval * 1000);
156
+ const next = await m.call("GET", q(since));
157
+ for (const e of next.events)
158
+ ctx.deps.stdout(`${JSON.stringify(e)}\n`);
159
+ since = next.until;
160
+ }
161
+ }
162
+ export async function accessShow(ctx) {
163
+ const slug = await slugFrom(ctx);
164
+ const m = await managerClient(ctx.deps);
165
+ const r = await m.call("GET", `/api/v1/apps/${slug}/access`);
166
+ const pending = pendingFrom(r.pending);
167
+ const human = [];
168
+ for (const w of r.workers) {
169
+ const differs = w.declared !== w.applied.mode;
170
+ human.push(`${w.host}: declared ${w.declared}, applied ${w.applied.mode}${w.applied.bypass_paths.length ? ` (bypass ${w.applied.bypass_paths.join(", ")})` : ""}${differs ? " DIFFERS" : ""}`);
171
+ if (w.audience_stale)
172
+ pending.push({ kind: "AUDIENCE_STALE", message: `${w.worker} carries an old Access audience.`, next: `inca deploy --env ${w.worker.endsWith("-uat") ? "uat" : "prod"}` });
173
+ if (differs && !r.pending.some((p) => p.kind === "access-apply" && p.worker_name === w.worker)) {
174
+ pending.push({ kind: "access-differs", message: `${w.host} is declared ${w.declared} but ${w.applied.mode} is applied.`, next: `inca access request ${slug}` });
175
+ }
176
+ }
177
+ return { data: { slug, workers: r.workers }, human, pending };
178
+ }
@@ -0,0 +1,7 @@
1
+ import type { Ctx, Outcome } from "../context.js";
2
+ export declare function deploy(ctx: Ctx): Promise<Outcome>;
3
+ export declare function promote(ctx: Ctx): Promise<Outcome>;
4
+ /** NAME=value lines; `#` comments; optional quotes. Values are never echoed, even in an error. */
5
+ export declare function parseEnvFile(text: string): Map<string, string>;
6
+ export declare function envPush(ctx: Ctx): Promise<Outcome>;
7
+ export declare function envLs(ctx: Ctx): Promise<Outcome>;
@@ -0,0 +1,182 @@
1
+ // inca deploy, promote, env push, env ls: Man Hay's own gh and git (INCA-27, 06-cli.md §4).
2
+ import { readFile } from "node:fs/promises";
3
+ import { basename, isAbsolute, relative, resolve, sep } from "node:path";
4
+ import { loadApp, requireIgnored } from "../app.js";
5
+ import { CliError, EXIT, envFlag, str } from "../context.js";
6
+ import { appRepo, ghFor, git, gitOk, prefixFor, secretNames } from "../github.js";
7
+ import { managerClient } from "../manager.js";
8
+ import { requireCliConfig } from "../store.js";
9
+ import { pendingFrom, secretsWithoutValue } from "./ops.js";
10
+ const WORKFLOW = "ci.yml";
11
+ const branchFor = (env) => (env === "uat" ? "dev" : "main");
12
+ const workerFor = (slug, env) => (env === "uat" ? `${slug}-uat` : slug);
13
+ const DOCS_ONLY = (path) => path.endsWith(".md") || path.startsWith("docs/") || path.startsWith("openspec/");
14
+ async function findRun(ctx, gh, full, branch, event, since, sha) {
15
+ for (let attempt = 0; attempt < 20; attempt++) {
16
+ const r = await gh.ok(["run", "list", "-R", full, "--workflow", WORKFLOW, "--branch", branch, "--event", event, "--limit", "10", "--json", "databaseId,headSha,createdAt,url"]);
17
+ const runs = JSON.parse(r.stdout);
18
+ // A minute of slack for the gap between this machine's clock and GitHub's.
19
+ const hit = runs.find((x) => Date.parse(x.createdAt) >= since - 60_000 && (sha === null || x.headSha === sha));
20
+ if (hit)
21
+ return hit;
22
+ await ctx.deps.sleep(3000);
23
+ }
24
+ throw new CliError(EXIT.OUTSIDE, "RUN_NOT_FOUND", `No ${WORKFLOW} run on ${branch} appeared in a minute.`, `gh run list -R ${full}`);
25
+ }
26
+ /** Waits for the run, then reads what the manager recorded: pending steps make exit 6 (06-cli.md §7). */
27
+ async function waitAndReport(ctx, gh, m, slug, env, run) {
28
+ const full = `${gh.owner}/${appRepo(slug)}`;
29
+ ctx.progress(`Watching ${run.url}`);
30
+ const watch = await gh.run(["run", "watch", String(run.databaseId), "-R", full, "--exit-status"], { inherit: !ctx.json });
31
+ if (watch.code !== 0) {
32
+ throw new CliError(EXIT.REFUSED, "CI_FAILED", `The ${WORKFLOW} run failed: a check, or the broker refused the deploy.`, `gh run view ${run.databaseId} -R ${full} --log-failed (the job summary has the broker's code)`, { run: run.url });
33
+ }
34
+ const worker = workerFor(slug, env);
35
+ const deploys = await m.call("GET", `/api/v1/apps/${slug}/deploys?env=${env}`);
36
+ const recorded = deploys.deploys.find((d) => d.sha === run.headSha && d.result === "ok") ?? null;
37
+ const app = await m.call("GET", `/api/v1/apps/${slug}`);
38
+ const pending = pendingFrom(app.pending.filter((p) => p.worker_name === null || p.worker_name === worker));
39
+ if (!recorded) {
40
+ pending.push({ kind: "not-deployed", message: `The run passed but no deploy of ${run.headSha.slice(0, 12)} to ${worker} is recorded (a docs-only change skips the deploy).`, next: `Check the job summary at ${run.url}` });
41
+ }
42
+ return { data: { run: run.url, sha: run.headSha, worker, version_id: recorded?.version_id ?? null }, pending };
43
+ }
44
+ export async function deploy(ctx) {
45
+ const env = envFlag(ctx, { required: true });
46
+ const app = await loadApp(ctx.deps.cwd);
47
+ const slug = app.config.name;
48
+ const m = await managerClient(ctx.deps);
49
+ const gh = await ghFor(ctx.deps, m.config.github_user);
50
+ const full = `${gh.owner}/${appRepo(slug)}`;
51
+ const pending = await secretsWithoutValue(ctx, m, slug, app.config.secrets ?? [], [env]);
52
+ for (const p of pending)
53
+ ctx.warn(p.message);
54
+ const branch = branchFor(env);
55
+ const started = ctx.deps.now();
56
+ await gh.ok(["workflow", "run", WORKFLOW, "--ref", branch, "-R", full]);
57
+ if (!ctx.flags.wait) {
58
+ return { data: { slug, env, branch, started: true }, human: [`Started ${WORKFLOW} on ${branch} for ${workerFor(slug, env)}.`, "inca deploy --wait shows the result."], pending };
59
+ }
60
+ const run = await findRun(ctx, gh, full, branch, "workflow_dispatch", started, null);
61
+ const r = await waitAndReport(ctx, gh, m, slug, env, run);
62
+ return { data: { slug, env, branch, ...r.data }, human: [`Deployed ${r.data.worker} (${String(r.data.version_id ?? "no version")}).`], pending: [...pending, ...r.pending] };
63
+ }
64
+ export async function promote(ctx) {
65
+ const { deps } = ctx;
66
+ const app = await loadApp(deps.cwd);
67
+ const slug = app.config.name;
68
+ await gitOk(deps, deps.cwd, ["fetch", "origin"]);
69
+ const anc = await git(deps, deps.cwd, ["merge-base", "--is-ancestor", "refs/remotes/origin/main", "refs/remotes/origin/dev"]);
70
+ if (anc.code === 1) {
71
+ throw new CliError(EXIT.REFUSED, "MAIN_NOT_ANCESTOR", "main has commits that dev lacks; promote only fast-forwards and never force-pushes.", "Bring those commits into dev (merge main into dev, push dev), then promote.");
72
+ }
73
+ if (anc.code !== 0)
74
+ throw new CliError(EXIT.OUTSIDE, "GIT_FAILED", "git could not compare origin/main and origin/dev.");
75
+ const sha = (await gitOk(deps, deps.cwd, ["rev-parse", "refs/remotes/origin/dev"])).stdout.trim();
76
+ const mainSha = (await gitOk(deps, deps.cwd, ["rev-parse", "refs/remotes/origin/main"])).stdout.trim();
77
+ if (sha === mainSha)
78
+ return { data: { slug, promoted: false, sha }, human: ["main already equals dev; nothing to promote."] };
79
+ const changed = (await gitOk(deps, deps.cwd, ["diff", "--name-only", mainSha, sha])).stdout.split("\n").filter(Boolean);
80
+ if (changed.length && changed.every(DOCS_ONLY))
81
+ ctx.warn("Only *.md, docs/** or openspec/** changed: CI checks this push but does not deploy it.");
82
+ const started = deps.now();
83
+ await gitOk(deps, deps.cwd, ["push", "origin", "refs/remotes/origin/dev:refs/heads/main"]);
84
+ if (!ctx.flags.wait)
85
+ return { data: { slug, promoted: true, sha }, human: [`Promoted ${sha.slice(0, 12)} to main.`, "CI deploys it to production; inca status shows the result."] };
86
+ const m = await managerClient(deps);
87
+ const gh = await ghFor(deps, m.config.github_user);
88
+ const run = await findRun(ctx, gh, `${gh.owner}/${appRepo(slug)}`, "main", "push", started, sha);
89
+ const r = await waitAndReport(ctx, gh, m, slug, "prod", run);
90
+ return { data: { slug, promoted: true, ...r.data }, human: [`Promoted and deployed ${r.data.worker} (${String(r.data.version_id ?? "no version")}).`], pending: r.pending };
91
+ }
92
+ /** NAME=value lines; `#` comments; optional quotes. Values are never echoed, even in an error. */
93
+ export function parseEnvFile(text) {
94
+ const out = new Map();
95
+ text.split(/\r?\n/).forEach((raw, i) => {
96
+ const line = raw.trim();
97
+ if (line === "" || line.startsWith("#"))
98
+ return;
99
+ const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/.exec(line);
100
+ if (!m)
101
+ throw new CliError(EXIT.REFUSED, "ENV_FILE_INVALID", `Line ${i + 1} of the values file is not NAME=value.`);
102
+ let value = m[2].trim();
103
+ const q = value[0];
104
+ if ((q === '"' || q === "'") && value.length >= 2 && value.endsWith(q)) {
105
+ value = q === '"' ? value.slice(1, -1).replace(/\\n/g, "\n").replace(/\\"/g, '"').replace(/\\\\/g, "\\") : value.slice(1, -1);
106
+ }
107
+ else if (q === '"' || q === "'") {
108
+ throw new CliError(EXIT.REFUSED, "ENV_FILE_INVALID", `Line ${i + 1} of the values file has an unclosed quote.`);
109
+ }
110
+ if (out.has(m[1]))
111
+ throw new CliError(EXIT.REFUSED, "ENV_FILE_INVALID", `${m[1]} appears twice in the values file.`);
112
+ out.set(m[1], value);
113
+ });
114
+ return out;
115
+ }
116
+ export async function envPush(ctx) {
117
+ const { deps } = ctx;
118
+ const env = envFlag(ctx, { required: true });
119
+ const app = await loadApp(deps.cwd);
120
+ const file = resolve(deps.cwd, str(ctx.flags, "from") ?? `.env.inca.${env}`);
121
+ const rel = relative(deps.cwd, file);
122
+ const outside = rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
123
+ if (!outside)
124
+ await requireIgnored(deps, deps.cwd, rel);
125
+ const other = env === "prod" ? "uat" : "prod";
126
+ if (basename(file) === `.env.inca.${other}`) {
127
+ throw new CliError(EXIT.REFUSED, "ENV_FILE_MISMATCH", `${rel} holds ${other} values; refusing to store them as ${env === "prod" ? "PROD_" : "UAT_"}*.`);
128
+ }
129
+ let text;
130
+ try {
131
+ text = await readFile(file, "utf8");
132
+ }
133
+ catch {
134
+ throw new CliError(EXIT.NOT_FOUND, "FILE_NOT_FOUND", `No values file at ${rel}.`, `Create ${rel} (git-ignored) with NAME=value lines.`);
135
+ }
136
+ const values = parseEnvFile(text);
137
+ const declared = app.config.secrets ?? [];
138
+ for (const name of values.keys())
139
+ if (!declared.includes(name))
140
+ ctx.warn(`${name} is in ${rel} but not declared; not sent (inca env add ${name} declares it).`);
141
+ const c = await requireCliConfig(deps);
142
+ const gh = await ghFor(deps, c.github_user);
143
+ const full = `${gh.owner}/${appRepo(app.config.name)}`;
144
+ const set = [];
145
+ const missing = [];
146
+ for (const name of declared) {
147
+ const value = values.get(name);
148
+ if (value === undefined || value === "") {
149
+ missing.push(name);
150
+ continue;
151
+ }
152
+ await gh.ok(["secret", "set", `${prefixFor(env)}${name}`, "-R", full], { input: value });
153
+ set.push(`${prefixFor(env)}${name}`);
154
+ }
155
+ ctx.warn("Values are in GitHub; the Worker still has the old ones until you deploy.");
156
+ const pending = missing.map((n) => ({ kind: "secret-no-value", message: `${n} is declared but has no value in ${rel}.`, next: `Add ${n}=… to ${rel}, then inca env push --env ${env}` }));
157
+ return { data: { env, repository: full, set, missing }, human: [`Set ${set.length} secret(s) on ${full}: ${set.join(", ") || "none"}.`, `Deploy to apply: inca deploy --env ${env}`], pending };
158
+ }
159
+ export async function envLs(ctx) {
160
+ const env = envFlag(ctx, { required: false });
161
+ const app = await loadApp(ctx.deps.cwd);
162
+ const slug = app.config.name;
163
+ const m = await managerClient(ctx.deps);
164
+ const gh = await ghFor(ctx.deps, m.config.github_user);
165
+ const have = await secretNames(gh, appRepo(slug));
166
+ const last = await m.call("GET", `/api/v1/apps/${slug}/env?env=${env}`);
167
+ const declared = app.config.secrets ?? [];
168
+ const rows = declared.map((name) => ({ name, github: have.has(`${prefixFor(env)}${name}`), written_at_last_deploy: last.written.includes(name) }));
169
+ const pending = rows
170
+ .filter((r) => !r.github)
171
+ .map((r) => ({ kind: "secret-no-github-value", message: `${r.name} has no ${prefixFor(env)}${r.name} GitHub secret.`, next: `inca env push --env ${env}` }));
172
+ const stale = last.written.filter((n) => !declared.includes(n));
173
+ return {
174
+ data: { env, worker: last.worker, secrets: rows, written_not_declared: stale },
175
+ human: [
176
+ `${last.worker}:`,
177
+ ...rows.map((r) => ` ${r.name.padEnd(28)} github ${r.github ? "yes" : "NO "} written at last deploy ${r.written_at_last_deploy ? "yes" : "no"}`),
178
+ ...(stale.length ? [` Written at the last deploy but no longer declared (the next deploy prunes them): ${stale.join(", ")}`] : []),
179
+ ],
180
+ pending,
181
+ };
182
+ }
@@ -0,0 +1,83 @@
1
+ export declare const EXIT: {
2
+ readonly OK: 0;
3
+ readonly UNEXPECTED: 1;
4
+ readonly USAGE: 2;
5
+ readonly AUTH: 3;
6
+ readonly NOT_FOUND: 4;
7
+ readonly REFUSED: 5;
8
+ readonly PENDING: 6;
9
+ readonly OUTSIDE: 7;
10
+ };
11
+ export type ExitCode = (typeof EXIT)[keyof typeof EXIT];
12
+ export declare class CliError extends Error {
13
+ readonly exit: ExitCode;
14
+ readonly code: string;
15
+ readonly next: string | null;
16
+ readonly detail: Record<string, unknown>;
17
+ constructor(exit: ExitCode, code: string, message: string, next?: string | null, detail?: Record<string, unknown>);
18
+ }
19
+ export declare const usage: (message: string, next?: string | null) => CliError;
20
+ export type RunOptions = {
21
+ cwd?: string;
22
+ /** The child's whole environment; omitted means the parent's. */
23
+ env?: Record<string, string | undefined>;
24
+ /** Written to the child's stdin; never on the command line. */
25
+ input?: string;
26
+ /** Hand the terminal to the child (wrangler dev, gh run watch). */
27
+ inherit?: boolean;
28
+ };
29
+ export type RunResult = {
30
+ code: number;
31
+ stdout: string;
32
+ stderr: string;
33
+ };
34
+ /** Always an argument array, never a shell string. Throws TOOL_MISSING (exit 7) when the program is absent. */
35
+ export type Runner = (cmd: string, args: string[], opts?: RunOptions) => Promise<RunResult>;
36
+ export type Deps = {
37
+ env: Readonly<Record<string, string | undefined>>;
38
+ cwd: string;
39
+ home: string;
40
+ platform: NodeJS.Platform;
41
+ stdout: (text: string) => void;
42
+ stderr: (text: string) => void;
43
+ run: Runner;
44
+ fetch: typeof fetch;
45
+ /** A line typed at the terminal, or null when there is no terminal to ask. */
46
+ prompt: (question: string) => Promise<string | null>;
47
+ sleep: (ms: number) => Promise<void>;
48
+ now: () => number;
49
+ };
50
+ export type Pending = {
51
+ kind: string;
52
+ message: string;
53
+ next: string | null;
54
+ };
55
+ /** What a command hands back; main prints it as one JSON document or as human lines with PENDING last. */
56
+ export type Outcome = {
57
+ data: Record<string, unknown>;
58
+ human: string[];
59
+ pending?: Pending[];
60
+ /** Overrides the default (6 when pending is non-empty, else 0). */
61
+ exit?: ExitCode;
62
+ };
63
+ export type Flags = Record<string, string | boolean | string[] | undefined>;
64
+ export type Ctx = {
65
+ deps: Deps;
66
+ flags: Flags;
67
+ positionals: string[];
68
+ /** Arguments after `--` (inca dev). */
69
+ passthrough: string[];
70
+ json: boolean;
71
+ yes: boolean;
72
+ warn: (line: string) => void;
73
+ progress: (line: string) => void;
74
+ };
75
+ export declare function str(flags: Flags, name: string): string | undefined;
76
+ export declare function list(flags: Flags, name: string): string[];
77
+ export declare function envFlag(ctx: Ctx, opts: {
78
+ required: boolean;
79
+ }): "uat" | "prod";
80
+ /** A plain yes/no question; --yes answers it. Without a terminal and without --yes, refused. */
81
+ export declare function confirmPlain(ctx: Ctx, question: string): Promise<void>;
82
+ /** A typed confirmation; --yes never skips it (06-cli.md §10 line 12). */
83
+ export declare function confirmTyped(ctx: Ctx, expected: string): Promise<void>;
@@ -0,0 +1,63 @@
1
+ // What every command gets: the outside world as injectable dependencies, and the exit codes of 06-cli.md §7.
2
+ export const EXIT = {
3
+ OK: 0,
4
+ UNEXPECTED: 1,
5
+ USAGE: 2,
6
+ AUTH: 3,
7
+ NOT_FOUND: 4,
8
+ REFUSED: 5,
9
+ PENDING: 6,
10
+ OUTSIDE: 7,
11
+ };
12
+ export class CliError extends Error {
13
+ exit;
14
+ code;
15
+ next;
16
+ detail;
17
+ constructor(exit, code, message, next = null, detail = {}) {
18
+ super(message);
19
+ this.exit = exit;
20
+ this.code = code;
21
+ this.next = next;
22
+ this.detail = detail;
23
+ }
24
+ }
25
+ export const usage = (message, next = null) => new CliError(EXIT.USAGE, "USAGE", message, next);
26
+ export function str(flags, name) {
27
+ const v = flags[name];
28
+ return typeof v === "string" ? v : undefined;
29
+ }
30
+ export function list(flags, name) {
31
+ const v = flags[name];
32
+ return Array.isArray(v) ? v : typeof v === "string" ? [v] : [];
33
+ }
34
+ export function envFlag(ctx, opts) {
35
+ const v = str(ctx.flags, "env");
36
+ if (v === undefined) {
37
+ if (opts.required)
38
+ throw usage("Say which environment: --env uat or --env prod.");
39
+ return "uat";
40
+ }
41
+ if (v !== "uat" && v !== "prod")
42
+ throw usage(`--env must be uat or prod, not ${JSON.stringify(v)}.`);
43
+ return v;
44
+ }
45
+ /** A plain yes/no question; --yes answers it. Without a terminal and without --yes, refused. */
46
+ export async function confirmPlain(ctx, question) {
47
+ if (ctx.yes)
48
+ return;
49
+ const answer = await ctx.deps.prompt(`${question} [y/N] `);
50
+ if (answer === null)
51
+ throw new CliError(EXIT.REFUSED, "CONFIRMATION_REQUIRED", `${question} Not confirmed.`, "Run it again with --yes, or at a terminal.");
52
+ if (!/^y(es)?$/i.test(answer.trim()))
53
+ throw new CliError(EXIT.REFUSED, "CONFIRMATION_REFUSED", "Not confirmed; nothing was done.");
54
+ }
55
+ /** A typed confirmation; --yes never skips it (06-cli.md §10 line 12). */
56
+ export async function confirmTyped(ctx, expected) {
57
+ const answer = await ctx.deps.prompt(`Type "${expected}" to confirm: `);
58
+ if (answer === null) {
59
+ throw new CliError(EXIT.REFUSED, "CONFIRMATION_REQUIRED", `This needs the typed confirmation "${expected}" at a terminal; --yes does not skip it.`);
60
+ }
61
+ if (answer.trim() !== expected)
62
+ throw new CliError(EXIT.REFUSED, "CONFIRMATION_MISMATCH", `You typed ${JSON.stringify(answer.trim())}, not "${expected}"; nothing was done.`);
63
+ }
@@ -0,0 +1,16 @@
1
+ import type { Deps, RunOptions, RunResult } from "./context.js";
2
+ export type Gh = {
3
+ owner: string;
4
+ /** `gh <args>`; the caller names the repository explicitly (`-R`, a positional or an api path). */
5
+ run: (args: string[], opts?: Omit<RunOptions, "env">) => Promise<RunResult>;
6
+ /** Same, but a non-zero exit is an outside failure (7). */
7
+ ok: (args: string[], opts?: Omit<RunOptions, "env">) => Promise<RunResult>;
8
+ };
9
+ export declare const appRepo: (slug: string) => string;
10
+ export declare function ghFor(deps: Deps, user: string): Promise<Gh>;
11
+ export declare const firstLine: (s: string) => string;
12
+ export declare function git(deps: Deps, cwd: string, args: string[], opts?: Omit<RunOptions, "cwd">): Promise<RunResult>;
13
+ export declare function gitOk(deps: Deps, cwd: string, args: string[], opts?: Omit<RunOptions, "cwd">): Promise<RunResult>;
14
+ /** Declared names present as `<PREFIX>_<NAME>` repository secrets. */
15
+ export declare function secretNames(gh: Gh, repo: string): Promise<Set<string>>;
16
+ export declare const prefixFor: (env: "uat" | "prod") => "UAT_" | "PROD_";