@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.
package/README.md CHANGED
@@ -1,3 +1,13 @@
1
- # Temporary Holding Version
1
+ # cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Built** (A6.1). Spec: `docs/specs/06-cli.md`. Change: `openspec/changes/archive/2026-11-15-build-cli/`.
4
+
5
+ `inca`: the command line for the whole life of an app, published as `@wujiland/cli`.
6
+
7
+ - `npm run build -w cli` compiles `src/` to `dist/`; `bin` is `dist/bin.js` (plain `node`).
8
+ - `npm test -w cli` covers every acceptance line of `06-cli.md` §10 and `08-ci.md` §10 lines 7, 8, 10,
9
+ with fakes for `gh`, `cloudflared`, the keychain, the manager and the broker. No token, no network.
10
+ - Line 11 also runs `inca-infra`'s `check_gate` when a clone with its venv is found
11
+ (`INCA_INFRA_DIR`, default `../inca-infra`); otherwise it is skipped.
12
+ - On Man Hay's machine: `npm link -w cli`, then
13
+ `inca login --manager https://manager.<domain> --github-user <account>`.
package/dist/app.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { Config, ConfigError } from "@wujiland/config";
2
+ import type { Deps } from "./context.js";
3
+ export declare const CONFIG_FILE = "inca.config.yaml";
4
+ export declare function readText(path: string, what: string): Promise<string>;
5
+ export declare function migrationFiles(dir: string): Promise<string[]>;
6
+ export declare function formatErrors(errors: ConfigError[]): string[];
7
+ export type LoadedApp = {
8
+ dir: string;
9
+ text: string;
10
+ config: Config;
11
+ migrations: string[];
12
+ };
13
+ export declare function loadApp(dir: string): Promise<LoadedApp>;
14
+ /** A rendered wrangler.jsonc must never be committable (INCA-07). */
15
+ export declare function requireIgnored(deps: Deps, dir: string, file: string): Promise<void>;
16
+ export declare function isTracked(deps: Deps, dir: string, file: string): Promise<boolean>;
package/dist/app.js ADDED
@@ -0,0 +1,59 @@
1
+ // The app repo on disk: its config, its migrations, and the renderer run over them (02-renderer.md §3).
2
+ import { readdir, readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { parseConfig } from "@wujiland/config";
5
+ import { CliError, EXIT } from "./context.js";
6
+ import { git } from "./github.js";
7
+ export const CONFIG_FILE = "inca.config.yaml";
8
+ export async function readText(path, what) {
9
+ try {
10
+ return await readFile(path, "utf8");
11
+ }
12
+ catch (e) {
13
+ if (e.code === "ENOENT")
14
+ throw new CliError(EXIT.NOT_FOUND, "FILE_NOT_FOUND", `${what} not found: ${path}.`);
15
+ throw e;
16
+ }
17
+ }
18
+ export async function migrationFiles(dir) {
19
+ try {
20
+ return (await readdir(join(dir, "migrations"))).filter((f) => f.endsWith(".sql")).sort();
21
+ }
22
+ catch (e) {
23
+ if (e.code === "ENOENT")
24
+ return [];
25
+ throw e;
26
+ }
27
+ }
28
+ export function formatErrors(errors) {
29
+ return errors.map((e) => `${e.code} ${e.path || "/"} ${e.message}`);
30
+ }
31
+ export async function loadApp(dir) {
32
+ const text = await readText(join(dir, CONFIG_FILE), CONFIG_FILE);
33
+ const migrations = await migrationFiles(dir);
34
+ const parsed = parseConfig(text, { migrationFiles: migrations });
35
+ if (!parsed.ok) {
36
+ throw new CliError(EXIT.REFUSED, "CONFIG_INVALID", `${CONFIG_FILE} has ${parsed.errors.length} error(s):\n ${formatErrors(parsed.errors).join("\n ")}`, "inca config check", {
37
+ errors: parsed.errors,
38
+ });
39
+ }
40
+ return { dir, text, config: parsed.value, migrations };
41
+ }
42
+ /** A rendered wrangler.jsonc must never be committable (INCA-07). */
43
+ export async function requireIgnored(deps, dir, file) {
44
+ const r = await git(deps, dir, ["check-ignore", "-q", "--", file]);
45
+ if (r.code === 0)
46
+ return;
47
+ if (r.code === 1) {
48
+ throw new CliError(EXIT.REFUSED, "RENDER_NOT_IGNORED", `${file} is not git-ignored here; a rendered file must never be committed (INCA-07).`, `Add ${file} to .gitignore.`);
49
+ }
50
+ throw new CliError(EXIT.REFUSED, "GIT_REPO_REQUIRED", `${dir} is not inside a git work tree.`, "Run this in the app's repository.");
51
+ }
52
+ export async function isTracked(deps, dir, file) {
53
+ const r = await git(deps, dir, ["ls-files", "--error-unmatch", "--", file]);
54
+ if (r.code === 0)
55
+ return true;
56
+ if (r.code === 1)
57
+ return false;
58
+ throw new CliError(EXIT.REFUSED, "GIT_REPO_REQUIRED", `${dir} is not inside a git work tree.`, "Run this in the app's repository.");
59
+ }
package/dist/auth.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { Deps } from "./context.js";
2
+ export type TokenInfo = {
3
+ exp: number | null;
4
+ };
5
+ /** Decodes (does not verify: the manager does) to read the expiry and refuse what is not a JWT. */
6
+ export declare function readJwt(token: string): TokenInfo;
7
+ export declare function checkTool(deps: Deps, tool: string, args: string[], install: string): Promise<void>;
8
+ export declare const INSTALL: {
9
+ cloudflared: string;
10
+ gh: string;
11
+ git: string;
12
+ wrangler: string;
13
+ security: string;
14
+ };
15
+ /** `cloudflared access login`, then `cloudflared access token`; login's own output (which holds the token) is discarded. */
16
+ export declare function fetchAccessToken(deps: Deps, appUrl: string): Promise<string>;
17
+ /** Through `security -i` so the token travels on stdin, not in the process list. */
18
+ export declare function storeToken(deps: Deps, account: string, token: string): Promise<void>;
19
+ export declare function loadToken(deps: Deps, account: string): Promise<string>;
package/dist/auth.js ADDED
@@ -0,0 +1,81 @@
1
+ import { CliError, EXIT } from "./context.js";
2
+ const SERVICE = "inca";
3
+ const JWT_PART = /^[A-Za-z0-9_-]+$/;
4
+ /** Decodes (does not verify: the manager does) to read the expiry and refuse what is not a JWT. */
5
+ export function readJwt(token) {
6
+ const parts = token.split(".");
7
+ if (parts.length !== 3 || !parts.every((p) => JWT_PART.test(p))) {
8
+ throw new CliError(EXIT.OUTSIDE, "TOKEN_MALFORMED", "cloudflared did not return an Access token.");
9
+ }
10
+ let payload;
11
+ try {
12
+ payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
13
+ }
14
+ catch {
15
+ throw new CliError(EXIT.OUTSIDE, "TOKEN_MALFORMED", "The Access token's payload is not JSON.");
16
+ }
17
+ const exp = payload.exp;
18
+ return { exp: typeof exp === "number" ? exp : null };
19
+ }
20
+ export async function checkTool(deps, tool, args, install) {
21
+ try {
22
+ await deps.run(tool, args);
23
+ }
24
+ catch (e) {
25
+ if (e instanceof CliError && e.code === "TOOL_MISSING")
26
+ throw new CliError(EXIT.OUTSIDE, "TOOL_MISSING", `${tool} is not installed.`, install);
27
+ throw e;
28
+ }
29
+ }
30
+ export const INSTALL = {
31
+ cloudflared: "brew install cloudflared",
32
+ gh: "brew install gh, then gh auth login for the account you named at inca login",
33
+ git: "xcode-select --install (or brew install git)",
34
+ wrangler: "npm ci in the app repo (wrangler is a dev dependency)",
35
+ security: "the macOS security tool ships with macOS",
36
+ };
37
+ /** `cloudflared access login`, then `cloudflared access token`; login's own output (which holds the token) is discarded. */
38
+ export async function fetchAccessToken(deps, appUrl) {
39
+ await checkTool(deps, "cloudflared", ["--version"], INSTALL.cloudflared);
40
+ const login = await deps.run("cloudflared", ["access", "login", appUrl]);
41
+ if (login.code !== 0)
42
+ throw new CliError(EXIT.OUTSIDE, "CLOUDFLARED_FAILED", `cloudflared access login exited ${login.code}.`, "Finish the browser sign-in, then run inca login again.");
43
+ const r = await deps.run("cloudflared", ["access", "token", `-app=${appUrl}`]);
44
+ if (r.code !== 0)
45
+ throw new CliError(EXIT.OUTSIDE, "CLOUDFLARED_FAILED", `cloudflared access token exited ${r.code}.`);
46
+ const token = r.stdout.trim();
47
+ readJwt(token);
48
+ return token;
49
+ }
50
+ function keychainUnsupported() {
51
+ return new CliError(EXIT.AUTH, "KEYCHAIN_UNSUPPORTED", "The OS keychain is supported on macOS only.", "Set INCA_TOKEN in the environment from `cloudflared access token -app=<manager URL>`.");
52
+ }
53
+ /** Through `security -i` so the token travels on stdin, not in the process list. */
54
+ export async function storeToken(deps, account, token) {
55
+ if (deps.platform !== "darwin")
56
+ throw keychainUnsupported();
57
+ readJwt(token);
58
+ await checkTool(deps, "security", ["-h"], INSTALL.security);
59
+ const r = await deps.run("security", ["-i"], { input: `add-generic-password -U -s ${SERVICE} -a ${account} -w "${token}"\n` });
60
+ if (r.code !== 0)
61
+ throw new CliError(EXIT.OUTSIDE, "KEYCHAIN_FAILED", `security exited ${r.code} while storing the token.`);
62
+ }
63
+ export async function loadToken(deps, account) {
64
+ const fromEnv = deps.env.INCA_TOKEN;
65
+ let token;
66
+ if (fromEnv !== undefined && fromEnv !== "") {
67
+ token = fromEnv.trim();
68
+ }
69
+ else {
70
+ if (deps.platform !== "darwin")
71
+ throw keychainUnsupported();
72
+ const r = await deps.run("security", ["find-generic-password", "-s", SERVICE, "-a", account, "-w"]);
73
+ if (r.code !== 0)
74
+ throw new CliError(EXIT.AUTH, "NOT_LOGGED_IN", "No Access token in the keychain.", "inca login");
75
+ token = r.stdout.trim();
76
+ }
77
+ const { exp } = readJwt(token);
78
+ if (exp !== null && exp * 1000 <= deps.now())
79
+ throw new CliError(EXIT.AUTH, "TOKEN_EXPIRED", "The Access session has expired.", "inca login");
80
+ return token;
81
+ }
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "./main.js";
3
+ import { nodeDeps } from "./node.js";
4
+ process.exitCode = await main(process.argv.slice(2), nodeDeps());
@@ -0,0 +1,14 @@
1
+ import type { Ctx, Outcome } from "../context.js";
2
+ export type Declared = {
3
+ type: "private" | "named" | "public";
4
+ emails: string[];
5
+ bypass_paths: string[];
6
+ };
7
+ /** The module calls of inca-infra/docs/access-model.md §4.4 for one app; null for a plain private app. */
8
+ export declare function renderTerraform(slug: string, d: Declared): string | null;
9
+ export declare function renderRecord(slug: string, domain: string, d: Declared): string;
10
+ /** Missing, dirty or behind its remote → refused (06-cli.md §8). */
11
+ export declare function checkInfra(ctx: Ctx, infraDir: string): Promise<string>;
12
+ export declare function accessRequest(ctx: Ctx): Promise<Outcome>;
13
+ export declare function decommission(ctx: Ctx): Promise<Outcome>;
14
+ export declare function purge(ctx: Ctx): Promise<Outcome>;
@@ -0,0 +1,201 @@
1
+ // inca access request, decommission, purge: the manager's part, then the local inca-infra clone or gh.
2
+ // Writes Terraform and commits it on a branch; never pushes main, never applies (INCA-11, 06-cli.md §4).
3
+ import { access as fsAccess, mkdir, realpath, rm, writeFile } from "node:fs/promises";
4
+ import { join, resolve } from "node:path";
5
+ import { loadApp } from "../app.js";
6
+ import { CliError, EXIT, confirmTyped, list, str, usage } from "../context.js";
7
+ import { appRepo, firstLine, ghFor, git, gitOk } from "../github.js";
8
+ import { managerClient } from "../manager.js";
9
+ import { requireCliConfig } from "../store.js";
10
+ import { pendingFrom } from "./ops.js";
11
+ import { SLUG } from "./create.js";
12
+ async function exists(path) {
13
+ try {
14
+ await fsAccess(path);
15
+ return true;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ const sorted = (xs) => [...xs].sort();
22
+ const same = (a, b) => JSON.stringify(sorted(a)) === JSON.stringify(sorted(b));
23
+ /** One literal HCL string; anything that could interpolate or escape is refused, not escaped. */
24
+ function hclString(s) {
25
+ if (/[\\"\n\r]|\$\{|%\{/.test(s))
26
+ throw new CliError(EXIT.REFUSED, "ACCESS_VALUE_INVALID", `${JSON.stringify(s)} cannot be written into Terraform.`);
27
+ return `"${s}"`;
28
+ }
29
+ const hclList = (xs) => `[${sorted(xs).map(hclString).join(", ")}]`;
30
+ /** The module calls of inca-infra/docs/access-model.md §4.4 for one app; null for a plain private app. */
31
+ export function renderTerraform(slug, d) {
32
+ const id = slug.replace(/-/g, "_");
33
+ const blocks = [];
34
+ const call = (suffix, module, extra) => [`module "${id}_${suffix}" {`, ` source = "../modules/${module}"`, ` gate = var.gate`, ` slug = ${hclString(slug)}`, ...extra, "}"].join("\n");
35
+ if (d.type === "named")
36
+ blocks.push(call("named", "named", [` emails = ${hclList(d.emails)}`]));
37
+ if (d.type === "public")
38
+ blocks.push(call("public", "public", []));
39
+ if (d.type !== "public" && d.bypass_paths.length)
40
+ blocks.push(call("bypass", "path-bypass", [` paths = ${hclList(d.bypass_paths)}`]));
41
+ if (!blocks.length)
42
+ return null;
43
+ return `# Written by inca access request from ${slug}'s inca.config.yaml. Not applied until Man Hay applies it.\n${blocks.join("\n\n")}\n`;
44
+ }
45
+ export function renderRecord(slug, domain, d) {
46
+ return [
47
+ `# terraform/apps/${slug}.access.yaml — what the app's inca.config.yaml declares. Not applied state.`,
48
+ `slug: ${slug}`,
49
+ `host: ${slug}.${domain}`,
50
+ `type: ${d.type}`,
51
+ `emails: ${JSON.stringify(d.type === "named" ? sorted(d.emails) : [])}`,
52
+ `bypass_paths: ${JSON.stringify(sorted(d.bypass_paths))}`,
53
+ "",
54
+ ].join("\n");
55
+ }
56
+ /** Missing, dirty or behind its remote → refused (06-cli.md §8). */
57
+ export async function checkInfra(ctx, infraDir) {
58
+ const { deps } = ctx;
59
+ const dir = resolve(deps.cwd, infraDir);
60
+ if (!(await exists(join(dir, "terraform", "modules", "named"))) || !(await exists(join(dir, "terraform", "apps")))) {
61
+ throw new CliError(EXIT.REFUSED, "INFRA_MISSING", `No inca-infra clone at ${dir}.`, "Clone inca-infra there, pass --infra <dir>, or save it with inca login --infra <dir>.");
62
+ }
63
+ const top = await git(deps, dir, ["rev-parse", "--show-toplevel"]);
64
+ if (top.code !== 0 || (await realpath(top.stdout.trim())) !== (await realpath(dir)))
65
+ throw new CliError(EXIT.REFUSED, "INFRA_MISSING", `${dir} is not the top of a git clone.`);
66
+ const dirty = await gitOk(deps, dir, ["status", "--porcelain"]);
67
+ if (dirty.stdout.trim() !== "")
68
+ throw new CliError(EXIT.REFUSED, "INFRA_DIRTY", `The inca-infra clone at ${dir} has uncommitted changes.`, "Commit or discard them first.");
69
+ await gitOk(deps, dir, ["fetch", "origin"]);
70
+ const behind = await git(deps, dir, ["rev-list", "--count", "HEAD..@{upstream}"]);
71
+ if (behind.code !== 0)
72
+ throw new CliError(EXIT.REFUSED, "INFRA_NO_UPSTREAM", `The inca-infra clone's branch has no upstream: ${firstLine(behind.stderr)}`, "git switch main");
73
+ if (Number(behind.stdout.trim()) > 0)
74
+ throw new CliError(EXIT.REFUSED, "INFRA_BEHIND", `The inca-infra clone is ${behind.stdout.trim()} commit(s) behind its remote.`, "git pull --ff-only");
75
+ return dir;
76
+ }
77
+ async function infraDirFor(ctx) {
78
+ const flag = str(ctx.flags, "infra");
79
+ if (flag !== undefined)
80
+ return flag;
81
+ return (await requireCliConfig(ctx.deps)).infra_dir ?? join("..", "inca-infra");
82
+ }
83
+ /** A new branch from origin/main, the files written or removed, one commit. Returns null when nothing changed. */
84
+ async function commitOnBranch(deps, dir, slug, write, message) {
85
+ const branch = `access/${slug}-${Math.floor(deps.now() / 1000)}`;
86
+ await gitOk(deps, dir, ["switch", "-c", branch, "--no-track", "refs/remotes/origin/main"]);
87
+ const appsDir = join(dir, "terraform", "apps");
88
+ await mkdir(appsDir, { recursive: true });
89
+ for (const [name, content] of Object.entries(write)) {
90
+ const path = join(appsDir, name);
91
+ if (content === null)
92
+ await rm(path, { force: true });
93
+ else
94
+ await writeFile(path, content);
95
+ }
96
+ await gitOk(deps, dir, ["add", "-A", "--", "terraform/apps"]);
97
+ const staged = await git(deps, dir, ["diff", "--cached", "--quiet"]);
98
+ if (staged.code === 0) {
99
+ await gitOk(deps, dir, ["switch", "-"]);
100
+ await gitOk(deps, dir, ["branch", "-D", branch]);
101
+ return null;
102
+ }
103
+ await gitOk(deps, dir, ["commit", "-m", message]);
104
+ return { branch, sha: (await gitOk(deps, dir, ["rev-parse", "HEAD"])).stdout.trim() };
105
+ }
106
+ /** inca-infra's own gate check, when its venv is there; a refusal undoes the commit. */
107
+ async function runGateCheck(ctx, dir) {
108
+ const python = join(dir, "scripts", ".venv", "bin", "python");
109
+ if (!(await exists(python))) {
110
+ ctx.warn("inca-infra's check_gate was not run here (no scripts/.venv); scripts/plan.sh runs it.");
111
+ return "not-run";
112
+ }
113
+ const r = await ctx.deps.run(python, [join(dir, "scripts", "check_gate.py"), "--root", dir]);
114
+ if (r.code !== 0) {
115
+ await gitOk(ctx.deps, dir, ["reset", "--hard", "HEAD~1"]);
116
+ throw new CliError(EXIT.REFUSED, "GATE_CHECK_REFUSED", `inca-infra's check_gate refused the change:\n${r.stdout.trim()}`, "Nothing was kept; fix the config and run again.");
117
+ }
118
+ return "passed";
119
+ }
120
+ export async function accessRequest(ctx) {
121
+ const slug = ctx.positionals[0];
122
+ const app = await loadApp(ctx.deps.cwd);
123
+ if (app.config.name !== slug)
124
+ throw new CliError(EXIT.REFUSED, "SLUG_MISMATCH", `This directory's config is ${app.config.name}, not ${slug}.`, `Run it in inca-app-${slug}.`);
125
+ const a = app.config.access ?? {};
126
+ const config = { type: a.type ?? "private", emails: a.emails ?? [], bypass_paths: a.bypass_paths ?? [] };
127
+ const named = list(ctx.flags, "named");
128
+ const kinds = [ctx.flags.public === true && "public", ctx.flags.private === true && "private", named.length > 0 && "named"].filter(Boolean);
129
+ if (kinds.length > 1)
130
+ throw usage("Give one of --public, --private, --named.");
131
+ const asked = kinds[0];
132
+ // The request must agree with the config; the config is edited first (06-cli.md §5).
133
+ const disagree = (what, want) => new CliError(EXIT.REFUSED, "ACCESS_REQUEST_DISAGREES", `The request asks for ${what}, but inca.config.yaml declares ${want}.`, "Edit access: in inca.config.yaml first, deploy it, then run inca access request again.", { config: config });
134
+ if (asked !== undefined && asked !== config.type)
135
+ throw disagree(`type ${asked}`, `type ${config.type}`);
136
+ if (named.length && !same(named, config.emails))
137
+ throw disagree(`emails ${named.join(", ")}`, `emails ${config.emails.join(", ") || "(none)"}`);
138
+ const bypass = list(ctx.flags, "bypass");
139
+ if (bypass.length && !same(bypass, config.bypass_paths))
140
+ throw disagree(`bypass ${bypass.join(", ")}`, `bypass ${config.bypass_paths.join(", ") || "(none)"}`);
141
+ const dir = await checkInfra(ctx, await infraDirFor(ctx));
142
+ const m = await managerClient(ctx.deps);
143
+ const tf = renderTerraform(slug, config);
144
+ const tfPath = join(dir, "terraform", "apps", `${slug}.tf`);
145
+ if (tf === null && !(await exists(tfPath))) {
146
+ return { data: { slug, declared: config, written: [] }, human: [`${slug} is private with no bypass paths: the gate covers it; nothing to request.`] };
147
+ }
148
+ const r = await m.call("POST", `/api/v1/apps/${slug}/access/requests`, {});
149
+ const live = r.declared;
150
+ if ((live.type ?? "private") !== config.type || !same(live.emails ?? [], config.emails) || !same(live.bypass_paths ?? [], config.bypass_paths)) {
151
+ throw new CliError(EXIT.REFUSED, "ACCESS_NOT_DEPLOYED", `Production still declares ${JSON.stringify(live)}; this config declares ${JSON.stringify(config)}.`, "Promote the config to production first (inca promote --wait).");
152
+ }
153
+ const files = tf === null ? { [`${slug}.tf`]: null, [`${slug}.access.yaml`]: null } : { [`${slug}.tf`]: tf, [`${slug}.access.yaml`]: renderRecord(slug, m.domain, config) };
154
+ const commit = await commitOnBranch(ctx.deps, dir, slug, files, `Access for ${slug}: ${config.type}${config.bypass_paths.length ? ` + bypass ${sorted(config.bypass_paths).join(" ")}` : ""}`);
155
+ const gate = commit ? await runGateCheck(ctx, dir) : "not-run";
156
+ const pending = [
157
+ { kind: "access-apply", message: `Access for ${slug}.${m.domain} is declared ${config.type} and is not applied until Man Hay applies it (INCA-11).`, next: `cd ${dir} && scripts/plan.sh; Man Hay reviews and runs scripts/apply.sh` },
158
+ ];
159
+ return {
160
+ data: { slug, declared: config, pending_id: r.pending_id, infra: dir, branch: commit?.branch ?? null, commit: commit?.sha ?? null, files: Object.keys(files).map((f) => `terraform/apps/${f}`), check_gate: gate },
161
+ human: [commit ? `Committed ${Object.keys(files).join(" and ")} on ${commit.branch} in ${dir}.` : "inca-infra already says this; nothing new to commit.", `check_gate: ${gate}.`],
162
+ pending,
163
+ };
164
+ }
165
+ export async function decommission(ctx) {
166
+ const slug = ctx.positionals[0];
167
+ if (!SLUG.test(slug))
168
+ throw usage(`${JSON.stringify(slug)} is not an app slug.`);
169
+ const dir = await checkInfra(ctx, await infraDirFor(ctx));
170
+ await confirmTyped(ctx, slug);
171
+ const m = await managerClient(ctx.deps);
172
+ const r = await m.call("POST", `/api/v1/apps/${slug}/decommission`, { confirm: slug });
173
+ const files = [`${slug}.tf`, `${slug}.access.yaml`];
174
+ const present = (await Promise.all(files.map((f) => exists(join(dir, "terraform", "apps", f))))).some(Boolean);
175
+ const commit = present ? await commitOnBranch(ctx.deps, dir, slug, Object.fromEntries(files.map((f) => [f, null])), `Remove Access for ${slug} (decommissioned)`) : null;
176
+ const pending = pendingFrom(r.pending);
177
+ if (commit)
178
+ pending.push({ kind: "access-removal", message: `The Access removal for ${slug} is committed on ${commit.branch}, not applied.`, next: `cd ${dir} && scripts/plan.sh; Man Hay applies it` });
179
+ return {
180
+ data: { slug, removed: r.removed, kept: r.kept, infra_branch: commit?.branch ?? null },
181
+ human: [`Decommissioned ${slug}: ${r.removed.join(", ") || "nothing live"}.`, `Kept (purge deletes after archiving): ${r.kept.map((k) => `${k.kind} ${k.name}`).join(", ") || "none"}.`],
182
+ pending,
183
+ };
184
+ }
185
+ export async function purge(ctx) {
186
+ const slug = ctx.positionals[0];
187
+ if (!SLUG.test(slug))
188
+ throw usage(`${JSON.stringify(slug)} is not an app slug.`);
189
+ await confirmTyped(ctx, `purge ${slug}`);
190
+ const m = await managerClient(ctx.deps);
191
+ const r = await m.call("POST", `/api/v1/apps/${slug}/purge`, { confirm: `purge ${slug}` });
192
+ if (!r.done)
193
+ throw new CliError(EXIT.OUTSIDE, "PURGE_INCOMPLETE", "The purge did not finish; nothing listed as kept was deleted.", r.next, { results: r.results });
194
+ const gh = await ghFor(ctx.deps, m.config.github_user);
195
+ const full = `${gh.owner}/${appRepo(slug)}`;
196
+ const view = await gh.ok(["repo", "view", full, "--json", "isArchived"]);
197
+ const archived = JSON.parse(view.stdout).isArchived === true;
198
+ if (!archived)
199
+ await gh.ok(["repo", "archive", full, "--yes"]);
200
+ return { data: { slug, results: r.results, repository: full, archived: true }, human: [`Purged ${slug}; ${full} archived.`] };
201
+ }
@@ -0,0 +1,27 @@
1
+ import type { Ctx, Outcome, Pending } from "../context.js";
2
+ export declare const OIDC_AUDIENCE = "inca-deploy";
3
+ /** Declared names only, this environment's prefix only; empty values are listed as missing (08-ci.md §6 step 3). */
4
+ export declare function selectSecrets(declared: string[], env: "uat" | "prod", raw: string | undefined): {
5
+ secrets: Record<string, string>;
6
+ missing: string[];
7
+ };
8
+ type BrokerResult = {
9
+ ok: boolean;
10
+ code?: string;
11
+ message?: string;
12
+ next?: string | null;
13
+ worker?: string;
14
+ version_id?: string;
15
+ url?: string;
16
+ migrations_applied?: string[];
17
+ secrets_written?: string[];
18
+ secrets_missing?: string[];
19
+ secrets_pruned?: string[];
20
+ storage_created?: unknown[];
21
+ storage_detached?: unknown[];
22
+ pending?: Pending[];
23
+ };
24
+ /** Pending steps first, then the result (08-ci.md §6 step 5, §10 line 10). Names only, never values. */
25
+ export declare function summary(env: string, r: BrokerResult, pending: Pending[]): string;
26
+ export declare function ciDeploy(ctx: Ctx): Promise<Outcome>;
27
+ export {};