akm-cli 0.10.261005-beta → 0.10.261007-beta

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.
Files changed (55) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/STABILITY.md +9 -0
  3. package/dist/assets/ui/app.js +91 -0
  4. package/dist/assets/ui/asset.js +67 -0
  5. package/dist/assets/ui/bundles.js +80 -0
  6. package/dist/assets/ui/changes.js +32 -0
  7. package/dist/assets/ui/charts.js +93 -0
  8. package/dist/assets/ui/config.js +69 -0
  9. package/dist/assets/ui/diff-view.js +202 -0
  10. package/dist/assets/ui/diff.js +261 -0
  11. package/dist/assets/ui/index.html +30 -0
  12. package/dist/assets/ui/lib.js +238 -0
  13. package/dist/assets/ui/markdown.js +103 -0
  14. package/dist/assets/ui/overview.js +145 -0
  15. package/dist/assets/ui/proposal-actions.js +273 -0
  16. package/dist/assets/ui/proposals.js +219 -0
  17. package/dist/assets/ui/search.js +62 -0
  18. package/dist/assets/ui/styles.css +1415 -0
  19. package/dist/cli.js +3 -0
  20. package/dist/commands/health/checks.js +5 -3
  21. package/dist/commands/health/egress.js +5 -1
  22. package/dist/commands/health/engine-usage.js +2 -2
  23. package/dist/commands/health/llm-usage.js +2 -2
  24. package/dist/commands/health/windows.js +5 -5
  25. package/dist/commands/health.js +40 -8
  26. package/dist/commands/improve/reflect-noise.js +4 -0
  27. package/dist/commands/improve/reflect.js +14 -5
  28. package/dist/commands/metrics/metrics-cli.js +3 -3
  29. package/dist/commands/proposal/propose-cli.js +5 -0
  30. package/dist/commands/proposal/propose.js +6 -2
  31. package/dist/commands/proposal/repository.js +34 -29
  32. package/dist/commands/read/search.js +2 -1
  33. package/dist/commands/read/show.js +18 -12
  34. package/dist/commands/sources/source-clone.js +123 -11
  35. package/dist/commands/ui/api.js +556 -0
  36. package/dist/commands/ui/git-read.js +101 -0
  37. package/dist/commands/ui/open-browser.js +30 -0
  38. package/dist/commands/ui/proposal-actions.js +179 -0
  39. package/dist/commands/ui/redact.js +88 -0
  40. package/dist/commands/ui/server.js +352 -0
  41. package/dist/commands/ui/ui-cli.js +62 -0
  42. package/dist/core/asset/resolve-ref.js +7 -3
  43. package/dist/indexer/indexer.js +14 -2
  44. package/dist/indexer/search/db-search.js +7 -3
  45. package/dist/output/format-exempt.js +3 -0
  46. package/dist/storage/repositories/improve-runs-repository.js +6 -0
  47. package/dist/storage/repositories/index-db.js +10 -2
  48. package/dist/storage/repositories/metrics-repository.js +17 -0
  49. package/docs/architecture/0.10-process-rules.md +3 -4
  50. package/docs/architecture/improvement.md +2 -1
  51. package/docs/architecture/internals/improve-workflow.md +2 -2
  52. package/docs/reference/README.md +1 -0
  53. package/docs/reference/cli.md +68 -0
  54. package/docs/reference/ui-api.md +576 -0
  55. package/package.json +1 -1
@@ -0,0 +1,179 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * The only writes `akm ui` makes: accept and reject a proposal, and accept
6
+ * several at once. Each handler calls the function `akm proposal accept` /
7
+ * `reject` calls, with the arguments the CLI passes, so a UI decision is
8
+ * recorded exactly like a CLI one (same state.db rows, events and commit).
9
+ *
10
+ * The server decides whether a request may reach this module (token header,
11
+ * Origin, content type, body size); see `server.ts`. Everything else in the UI
12
+ * stays read-only and lives in `api.ts`.
13
+ */
14
+ import { AkmError, TransientError, UsageError } from "../../core/errors.js";
15
+ import { acquireAssetMutationLease } from "../../indexer/index-writer-lock.js";
16
+ import { akmProposalAccept, akmProposalAcceptMany, akmProposalReject, } from "../proposal/proposal.js";
17
+ import { proposalSummary } from "./api.js";
18
+ import { readBundleGitStatus } from "./git-read.js";
19
+ export const MAX_REJECT_REASON = 2000;
20
+ export const MAX_BATCH_IDS = 200;
21
+ /** The three POST paths the UI may write through; `null` for anything else. */
22
+ export function matchWriteRoute(pathname) {
23
+ if (pathname === "/api/proposals/accept")
24
+ return { kind: "accept-many" };
25
+ const match = /^\/api\/proposals\/([^/]+)\/(accept|reject)$/.exec(pathname);
26
+ if (!match)
27
+ return null;
28
+ let id;
29
+ try {
30
+ id = decodeURIComponent(match[1]);
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ return { kind: match[2], id };
36
+ }
37
+ // ── Errors ───────────────────────────────────────────────────────────────────
38
+ const STALE_MESSAGE = /target (?:changed after|was created after) proposal|^Retire proposal \S+ (?:target \(.*\) no longer exists|is stale )/;
39
+ const NOT_PENDING_MESSAGE = /is not pending \(current status/;
40
+ const LEASE_MESSAGE = /timed out waiting for asset mutation lease/;
41
+ const NOT_FOUND_MESSAGE = /^Proposal ".*" not found|^No proposal found for ref/;
42
+ /** A stable code for an error the accept path reports only as text (a failed id inside a batch). */
43
+ function codeForMessage(message) {
44
+ if (STALE_MESSAGE.test(message))
45
+ return "PROPOSAL_STALE";
46
+ if (NOT_PENDING_MESSAGE.test(message))
47
+ return "PROPOSAL_NOT_PENDING";
48
+ if (LEASE_MESSAGE.test(message))
49
+ return "ASSET_MUTATION_LEASE_HELD";
50
+ if (NOT_FOUND_MESSAGE.test(message))
51
+ return "PROPOSAL_NOT_FOUND";
52
+ return null;
53
+ }
54
+ /** HTTP status and code for an error thrown by an accept or reject: 409 stale, not pending or lease held; 404; 400. */
55
+ export function classifyProposalError(error) {
56
+ const message = error instanceof Error ? error.message : String(error);
57
+ const byMessage = codeForMessage(message);
58
+ if (error instanceof AkmError) {
59
+ if (byMessage === "PROPOSAL_STALE" || byMessage === "PROPOSAL_NOT_PENDING") {
60
+ return { status: 409, code: byMessage, message };
61
+ }
62
+ switch (error.kind) {
63
+ case "transient":
64
+ return { status: 409, code: error.code, message };
65
+ case "not-found":
66
+ return { status: 404, code: error.code, message };
67
+ case "usage":
68
+ return { status: 400, code: error.code, message };
69
+ default:
70
+ return { status: 500, code: error.code, message };
71
+ }
72
+ }
73
+ if (byMessage === "ASSET_MUTATION_LEASE_HELD")
74
+ return { status: 409, code: byMessage, message };
75
+ return { status: 500, code: "INTERNAL_ERROR", message };
76
+ }
77
+ // ── Input ────────────────────────────────────────────────────────────────────
78
+ function bodyObject(body) {
79
+ if (body === undefined || body === null)
80
+ return {};
81
+ if (typeof body !== "object" || Array.isArray(body)) {
82
+ throw new UsageError("The request body must be a JSON object.", "INVALID_JSON_ARGUMENT");
83
+ }
84
+ return body;
85
+ }
86
+ function rejectReason(body) {
87
+ const reason = bodyObject(body).reason;
88
+ if (typeof reason !== "string" || reason.trim() === "") {
89
+ throw new UsageError('A rejection needs a reason: {"reason": "..."}.', "MISSING_REQUIRED_ARGUMENT");
90
+ }
91
+ const trimmed = reason.trim();
92
+ if (trimmed.length > MAX_REJECT_REASON) {
93
+ throw new UsageError(`The reason is too long (${trimmed.length} characters; at most ${MAX_REJECT_REASON}).`);
94
+ }
95
+ return trimmed;
96
+ }
97
+ function batchIds(body) {
98
+ const ids = bodyObject(body).ids;
99
+ if (!Array.isArray(ids) || ids.length === 0 || ids.length > MAX_BATCH_IDS) {
100
+ throw new UsageError(`"ids" must be a list of 1 to ${MAX_BATCH_IDS} proposal ids.`, "MISSING_REQUIRED_ARGUMENT");
101
+ }
102
+ for (const id of ids) {
103
+ if (typeof id !== "string" || id.trim() === "" || id.length > 200) {
104
+ throw new UsageError('Every entry of "ids" must be a non-empty proposal id string.');
105
+ }
106
+ }
107
+ return [...new Set(ids.map((id) => id.trim()))];
108
+ }
109
+ // ── Execution ────────────────────────────────────────────────────────────────
110
+ /**
111
+ * Fail fast when another writer holds the asset-mutation lease. The accept and
112
+ * reject functions would wait up to ten minutes for it, which in a browser is a
113
+ * hung button; the probe turns a held lease into an immediate, retryable 409.
114
+ */
115
+ async function assertWriteLeaseFree(purpose) {
116
+ const lease = await acquireAssetMutationLease({ mode: "try", purpose });
117
+ if (!lease) {
118
+ throw new TransientError("Another akm process or request is writing to the bundle right now. Try again in a moment.", "ASSET_MUTATION_LEASE_HELD");
119
+ }
120
+ lease.release();
121
+ }
122
+ /**
123
+ * The commit an accept just made, if any. The accept functions do not return it,
124
+ * so read the head commit of each accepted proposal's repository and keep one
125
+ * that carries akm's accept subject and is newer than the request. `null` for a
126
+ * bundle that is not a git repository (nothing was committed).
127
+ */
128
+ async function acceptCommit(accepted, startedMs) {
129
+ const roots = new Set();
130
+ for (const result of accepted) {
131
+ const root = result.proposal.acceptedTarget?.root;
132
+ if (root)
133
+ roots.add(root);
134
+ }
135
+ for (const root of roots) {
136
+ const last = (await readBundleGitStatus(root))?.lastCommit;
137
+ if (!last || !/^akm (?:proposal )?accept:/.test(last.subject))
138
+ continue;
139
+ if (Date.parse(last.time) < startedMs - 2000)
140
+ continue;
141
+ return { ...last, repo: root };
142
+ }
143
+ return null;
144
+ }
145
+ function acceptedView(result) {
146
+ return { ...result, proposal: proposalSummary(result.proposal) };
147
+ }
148
+ function failedView(failure) {
149
+ return { ...failure, code: codeForMessage(failure.error) ?? "ACCEPT_FAILED" };
150
+ }
151
+ /** Run one of the three write routes; errors propagate for the server to classify with {@link classifyProposalError}. */
152
+ export async function handleWriteRequest(route, body) {
153
+ const startedMs = Date.now();
154
+ switch (route.kind) {
155
+ case "accept": {
156
+ await assertWriteLeaseFree("ui-proposal-accept");
157
+ const result = await akmProposalAccept({ id: route.id });
158
+ return { ...acceptedView(result), commit: await acceptCommit([result], startedMs) };
159
+ }
160
+ case "reject": {
161
+ const reason = rejectReason(body);
162
+ await assertWriteLeaseFree("ui-proposal-reject");
163
+ const result = await akmProposalReject({ id: route.id, reason });
164
+ return { ...result, proposal: proposalSummary(result.proposal) };
165
+ }
166
+ case "accept-many": {
167
+ const ids = batchIds(body);
168
+ await assertWriteLeaseFree("ui-proposal-accept");
169
+ const results = await akmProposalAcceptMany({ ids });
170
+ const accepted = results.filter((r) => r.ok);
171
+ return {
172
+ accepted: accepted.length,
173
+ failed: results.length - accepted.length,
174
+ results: results.map((r) => (r.ok ? acceptedView(r) : failedView(r))),
175
+ commit: accepted.length > 0 ? await acceptCommit(accepted, startedMs) : null,
176
+ };
177
+ }
178
+ }
179
+ }
@@ -0,0 +1,88 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Redaction for `akm ui`'s config view. Starts from `listConfig` (which already
6
+ * drops literal `apiKey` values and normalises registry URLs) and then masks
7
+ * anything secret-like by key name, because the UI shows config the CLI's
8
+ * `config list` never had to defend against a browser leaking.
9
+ *
10
+ * Symbolic references (`$VAR`, `${VAR}`, `secret://name`) are kept: they say
11
+ * where a credential comes from without being one.
12
+ */
13
+ const REDACTED = "[redacted]";
14
+ /** Key names whose string values are credentials, or sit in a bag of them. */
15
+ const SECRET_KEY = /(api[-_]?key|token|password|passwd|secret|credential|authorization|bearer|private[-_]?key)/i;
16
+ /** A whole subtree of values (`env`, `secrets`) that is data, not settings. */
17
+ const SECRET_BAG_KEY = /^(env|secrets?)$/i;
18
+ const SYMBOLIC_REFERENCE = /^(\$[A-Za-z_][A-Za-z0-9_]*|\$\{[A-Za-z_][A-Za-z0-9_]*\}|secret:\/\/.+)$/;
19
+ /** Query parameters that carry a credential in a URL. */
20
+ const SECRET_QUERY_PARAM = /^(api[-_]?key|key|token|access[-_]?token|auth|password|secret|sig|signature)$/i;
21
+ /** `scheme://user:pass@host/?token=x` with the credentials masked. Anything that is not a URL is returned as is. */
22
+ export function redactUrlCredentials(value) {
23
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(value))
24
+ return value;
25
+ try {
26
+ const url = new URL(value);
27
+ let changed = false;
28
+ if (url.username || url.password) {
29
+ url.username = "";
30
+ url.password = "";
31
+ changed = true;
32
+ }
33
+ for (const key of [...url.searchParams.keys()]) {
34
+ if (SECRET_QUERY_PARAM.test(key)) {
35
+ url.searchParams.set(key, REDACTED);
36
+ changed = true;
37
+ }
38
+ }
39
+ return changed ? url.toString() : value;
40
+ }
41
+ catch {
42
+ return value;
43
+ }
44
+ }
45
+ /** `text` with the credentials in every URL it contains masked (`llm default: https://host/?token=x`). */
46
+ export function redactUrlsInText(text) {
47
+ return text.includes("://") ? text.replace(/https?:\/\/[^\s"'<>)\]]+/gi, (url) => redactUrlCredentials(url)) : text;
48
+ }
49
+ /**
50
+ * Deep copy of an API response with the credentials in any URL masked. The
51
+ * last line of defence for every endpoint: health and metrics evidence quotes
52
+ * configured endpoints verbatim, and a configured URL can carry a token.
53
+ */
54
+ export function scrubUrlCredentials(value) {
55
+ if (typeof value === "string")
56
+ return redactUrlsInText(value);
57
+ if (Array.isArray(value))
58
+ return value.map(scrubUrlCredentials);
59
+ if (!value || typeof value !== "object")
60
+ return value;
61
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, scrubUrlCredentials(child)]));
62
+ }
63
+ /** Deep copy of `value` with every secret-like string masked. */
64
+ export function redactForUi(value, inSecretBag = false) {
65
+ if (Array.isArray(value))
66
+ return value.map((child) => redactForUi(child, inSecretBag));
67
+ if (typeof value === "string") {
68
+ if (SYMBOLIC_REFERENCE.test(value))
69
+ return value;
70
+ return inSecretBag ? REDACTED : redactUrlCredentials(value);
71
+ }
72
+ if (!value || typeof value !== "object")
73
+ return inSecretBag && value !== null ? REDACTED : value;
74
+ const result = {};
75
+ for (const [key, child] of Object.entries(value)) {
76
+ if (typeof child === "string" && SECRET_KEY.test(key) && !SYMBOLIC_REFERENCE.test(child)) {
77
+ result[key] = REDACTED;
78
+ }
79
+ else {
80
+ result[key] = redactForUi(child, inSecretBag || SECRET_BAG_KEY.test(key));
81
+ }
82
+ }
83
+ return result;
84
+ }
85
+ /** Whether an environment variable name looks like it holds a credential. */
86
+ export function isSecretEnvName(name) {
87
+ return SECRET_KEY.test(name);
88
+ }
@@ -0,0 +1,352 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * The HTTP server behind `akm ui`: `node:http` so one implementation serves
6
+ * Bun, Node and the compiled binary alike.
7
+ *
8
+ * Trust model: the server is for the person at this machine. It binds loopback
9
+ * by default, every `/api/` request must carry the per-launch token, and the
10
+ * `Host` header must be the address the server is bound to, which stops a web
11
+ * page from reaching it through DNS rebinding. Static UI files are served
12
+ * without the token and embed no data. Only GET (and HEAD) is accepted, except
13
+ * for the three proposal writes (accept, reject, accept several): a POST to one
14
+ * of those must carry the token in the `X-Akm-Ui-Token` header (never the
15
+ * query string), a JSON content type and an `Origin` that is this server's own,
16
+ * so another web page cannot make the browser send one.
17
+ */
18
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
19
+ import fs from "node:fs";
20
+ import http from "node:http";
21
+ import path from "node:path";
22
+ import appJs from "../../assets/ui/app.js" with { type: "text" };
23
+ import assetJs from "../../assets/ui/asset.js" with { type: "text" };
24
+ import bundlesJs from "../../assets/ui/bundles.js" with { type: "text" };
25
+ import changesJs from "../../assets/ui/changes.js" with { type: "text" };
26
+ import chartsJs from "../../assets/ui/charts.js" with { type: "text" };
27
+ import configJs from "../../assets/ui/config.js" with { type: "text" };
28
+ import diffJs from "../../assets/ui/diff.js" with { type: "text" };
29
+ import diffViewJs from "../../assets/ui/diff-view.js" with { type: "text" };
30
+ import indexHtml from "../../assets/ui/index.html" with { type: "text" };
31
+ import libJs from "../../assets/ui/lib.js" with { type: "text" };
32
+ import markdownJs from "../../assets/ui/markdown.js" with { type: "text" };
33
+ import overviewJs from "../../assets/ui/overview.js" with { type: "text" };
34
+ import proposalActionsJs from "../../assets/ui/proposal-actions.js" with { type: "text" };
35
+ import proposalsJs from "../../assets/ui/proposals.js" with { type: "text" };
36
+ import searchJs from "../../assets/ui/search.js" with { type: "text" };
37
+ import stylesCss from "../../assets/ui/styles.css" with { type: "text" };
38
+ import { AkmError } from "../../core/errors.js";
39
+ import { getDirname } from "../../runtime.js";
40
+ import { handleApiRequest, NO_SUCH_ROUTE } from "./api.js";
41
+ import { classifyProposalError, handleWriteRequest, matchWriteRoute } from "./proposal-actions.js";
42
+ import { scrubUrlCredentials } from "./redact.js";
43
+ export const TOKEN_HEADER = "x-akm-ui-token";
44
+ /** Largest request body a write route reads. */
45
+ export const MAX_BODY_BYTES = 64 * 1024;
46
+ const UI_DIR = path.join(getDirname(import.meta.url), "../../assets/ui");
47
+ /**
48
+ * The UI files embedded at build time. Disk stays primary (an edit shows
49
+ * without a rebuild, and files added beside these are served from disk), the
50
+ * embedded copy covers the compiled standalone binary, where `assets/ui` is not
51
+ * on any real filesystem. Same arrangement as the HTML report templates in
52
+ * `src/output/html-render.ts`. A file the standalone binary must serve has to be
53
+ * imported above and listed here.
54
+ */
55
+ export const EMBEDDED_UI = {
56
+ "index.html": indexHtml,
57
+ "app.js": appJs,
58
+ "asset.js": assetJs,
59
+ "bundles.js": bundlesJs,
60
+ "changes.js": changesJs,
61
+ "charts.js": chartsJs,
62
+ "config.js": configJs,
63
+ "diff-view.js": diffViewJs,
64
+ "diff.js": diffJs,
65
+ "lib.js": libJs,
66
+ "markdown.js": markdownJs,
67
+ "overview.js": overviewJs,
68
+ "proposal-actions.js": proposalActionsJs,
69
+ "proposals.js": proposalsJs,
70
+ "search.js": searchJs,
71
+ "styles.css": stylesCss,
72
+ };
73
+ const CONTENT_TYPES = {
74
+ ".html": "text/html; charset=utf-8",
75
+ ".js": "text/javascript; charset=utf-8",
76
+ ".mjs": "text/javascript; charset=utf-8",
77
+ ".css": "text/css; charset=utf-8",
78
+ ".json": "application/json; charset=utf-8",
79
+ ".svg": "image/svg+xml",
80
+ ".png": "image/png",
81
+ ".ico": "image/x-icon",
82
+ ".woff2": "font/woff2",
83
+ ".txt": "text/plain; charset=utf-8",
84
+ };
85
+ /**
86
+ * Same-origin only: the UI is plain files with no network access of its own
87
+ * (nothing is fetched at runtime). Inline styles are allowed so a chart can set
88
+ * a computed colour; inline scripts are not.
89
+ */
90
+ const STATIC_CSP = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; " +
91
+ "connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
92
+ const SECURITY_HEADERS = {
93
+ "Cache-Control": "no-store",
94
+ "X-Content-Type-Options": "nosniff",
95
+ "Referrer-Policy": "no-referrer",
96
+ };
97
+ /** Whether `host` binds every interface, so the `Host` header can be any address of this machine. */
98
+ function isWildcardHost(host) {
99
+ return host === "0.0.0.0" || host === "::" || host === "[::]";
100
+ }
101
+ function isLoopbackHost(host) {
102
+ return host === "localhost" || host === "::1" || host === "[::1]" || /^127\./.test(host);
103
+ }
104
+ /** `host` as it appears in a URL or a `Host` header (IPv6 literals in brackets). */
105
+ function urlHost(host) {
106
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
107
+ }
108
+ export function isLoopbackBind(host) {
109
+ return isLoopbackHost(host);
110
+ }
111
+ function hostAllowed(header, host, port) {
112
+ if (!header)
113
+ return false;
114
+ const given = header.toLowerCase();
115
+ if (given === `${urlHost(host).toLowerCase()}:${port}`)
116
+ return true;
117
+ if (!isWildcardHost(host))
118
+ return false;
119
+ // A wildcard bind is reached by whatever address the machine has; DNS names stay refused (rebinding uses names).
120
+ const match = /^(\[[0-9a-f:.]+\]|\d{1,3}(?:\.\d{1,3}){3}):(\d+)$/.exec(given);
121
+ return match !== null && Number(match[2]) === port;
122
+ }
123
+ function tokenMatches(given, expected) {
124
+ if (!given)
125
+ return false;
126
+ // Hash both so the comparison is constant-time whatever the lengths.
127
+ return timingSafeEqual(createHash("sha256").update(given).digest(), createHash("sha256").update(expected).digest());
128
+ }
129
+ function sendJson(req, res, status, body, extra = {}) {
130
+ const payload = JSON.stringify(body, (_key, value) => (typeof value === "bigint" ? Number(value) : value));
131
+ res.writeHead(status, {
132
+ ...SECURITY_HEADERS,
133
+ ...extra,
134
+ "Content-Type": "application/json; charset=utf-8",
135
+ "Content-Length": Buffer.byteLength(payload),
136
+ });
137
+ res.end(req.method === "HEAD" ? undefined : payload);
138
+ }
139
+ function sendError(req, res, status, code, error, extra = {}) {
140
+ sendJson(req, res, status, { ok: false, error, code }, extra);
141
+ }
142
+ /** Whether `origin` is this server's own origin: the validated `Host` over plain http. */
143
+ function originAllowed(origin, hostHeader) {
144
+ if (!origin || !hostHeader)
145
+ return false;
146
+ return origin.toLowerCase() === `http://${hostHeader.toLowerCase()}`;
147
+ }
148
+ function isJsonContentType(header) {
149
+ return (header ?? "").split(";")[0]?.trim().toLowerCase() === "application/json";
150
+ }
151
+ /** The request body as text, or `null` when it is larger than {@link MAX_BODY_BYTES}. */
152
+ function readBody(req) {
153
+ return new Promise((resolve, reject) => {
154
+ const declared = Number(req.headers["content-length"]);
155
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
156
+ resolve(null);
157
+ return;
158
+ }
159
+ const chunks = [];
160
+ let size = 0;
161
+ let tooLarge = false;
162
+ req.on("data", (chunk) => {
163
+ size += chunk.length;
164
+ if (size > MAX_BODY_BYTES)
165
+ tooLarge = true;
166
+ else
167
+ chunks.push(chunk);
168
+ });
169
+ req.on("end", () => resolve(tooLarge ? null : Buffer.concat(chunks).toString("utf8")));
170
+ req.on("error", reject);
171
+ });
172
+ }
173
+ /** HTTP status for an error thrown by an API handler: the CLI's error classes, mapped. */
174
+ function statusFor(error) {
175
+ if (!(error instanceof AkmError))
176
+ return 500;
177
+ switch (error.kind) {
178
+ case "usage":
179
+ return 400;
180
+ case "not-found":
181
+ return 404;
182
+ case "transient":
183
+ return 503;
184
+ default:
185
+ return 500;
186
+ }
187
+ }
188
+ /** The file `urlPath` names under the UI directory, or the embedded copy; `null` when there is none. */
189
+ function readUiAsset(urlPath) {
190
+ let relative;
191
+ try {
192
+ relative = decodeURIComponent(urlPath).replace(/^\/+/, "");
193
+ }
194
+ catch {
195
+ return null;
196
+ }
197
+ if (relative === "")
198
+ relative = "index.html";
199
+ if (relative.includes("\0") || relative.includes("\\"))
200
+ return null;
201
+ const resolved = path.resolve(UI_DIR, relative);
202
+ const inside = path.relative(UI_DIR, resolved);
203
+ if (inside === "" || inside.startsWith("..") || path.isAbsolute(inside))
204
+ return null;
205
+ const type = CONTENT_TYPES[path.extname(resolved).toLowerCase()];
206
+ if (!type)
207
+ return null;
208
+ try {
209
+ if (fs.statSync(resolved).isFile())
210
+ return { body: fs.readFileSync(resolved), type };
211
+ }
212
+ catch {
213
+ // Not on disk: fall back to the embedded copy below.
214
+ }
215
+ const embedded = EMBEDDED_UI[inside.split(path.sep).join("/")];
216
+ return embedded === undefined ? null : { body: embedded, type };
217
+ }
218
+ export async function startUiServer(options = {}) {
219
+ const host = options.host ?? "127.0.0.1";
220
+ const token = options.token ?? randomBytes(24).toString("base64url");
221
+ const startedAt = new Date().toISOString();
222
+ // Filled in once the socket is listening; requests cannot arrive before that.
223
+ let info = { startedAt, host, port: 0 };
224
+ const server = http.createServer((req, res) => {
225
+ void handle(req, res).catch((error) => {
226
+ if (res.headersSent) {
227
+ res.destroy();
228
+ return;
229
+ }
230
+ sendError(req, res, 500, "INTERNAL_ERROR", error instanceof Error ? error.message : String(error));
231
+ });
232
+ });
233
+ async function handle(req, res) {
234
+ if (!hostAllowed(req.headers.host, host, info.port)) {
235
+ sendError(req, res, 403, "HOST_NOT_ALLOWED", "The Host header does not match the address akm ui is bound to.");
236
+ return;
237
+ }
238
+ const url = new URL(req.url ?? "/", "http://akm-ui.invalid");
239
+ const isApi = url.pathname === "/api" || url.pathname.startsWith("/api/");
240
+ const writeRoute = req.method === "POST" ? matchWriteRoute(url.pathname) : null;
241
+ if (writeRoute) {
242
+ await handleWrite(req, res, writeRoute);
243
+ return;
244
+ }
245
+ // Only a GET of a static file is open; the API, and anything that is not a GET, needs the token.
246
+ const readMethod = req.method === "GET" || req.method === "HEAD";
247
+ if (isApi || !readMethod) {
248
+ const given = req.headers[TOKEN_HEADER];
249
+ const provided = (Array.isArray(given) ? given[0] : given) ?? url.searchParams.get("token");
250
+ if (!tokenMatches(provided, token)) {
251
+ sendError(req, res, 401, "UNAUTHORIZED", "Missing or invalid session token.", {
252
+ "WWW-Authenticate": "X-Akm-Ui-Token",
253
+ });
254
+ return;
255
+ }
256
+ }
257
+ if (!readMethod) {
258
+ sendError(req, res, 405, "METHOD_NOT_ALLOWED", "Only GET is accepted here. akm ui writes only through POST /api/proposals/:id/accept, /reject and /api/proposals/accept.", {
259
+ Allow: "GET, HEAD",
260
+ });
261
+ return;
262
+ }
263
+ if (isApi) {
264
+ try {
265
+ const data = await handleApiRequest(url.pathname, url.searchParams, info);
266
+ if (data === NO_SUCH_ROUTE) {
267
+ sendError(req, res, 404, "NOT_FOUND", `No such API route: ${url.pathname}`);
268
+ return;
269
+ }
270
+ sendJson(req, res, 200, { ok: true, data: scrubUrlCredentials(data) });
271
+ }
272
+ catch (error) {
273
+ const code = error instanceof AkmError ? error.code : "INTERNAL_ERROR";
274
+ sendError(req, res, statusFor(error), code, error instanceof Error ? error.message : String(error));
275
+ }
276
+ return;
277
+ }
278
+ const asset = readUiAsset(url.pathname);
279
+ if (!asset) {
280
+ sendError(req, res, 404, "NOT_FOUND", `No such file: ${url.pathname}`);
281
+ return;
282
+ }
283
+ res.writeHead(200, {
284
+ ...SECURITY_HEADERS,
285
+ "Content-Type": asset.type,
286
+ "Content-Length": Buffer.byteLength(asset.body),
287
+ "Content-Security-Policy": STATIC_CSP,
288
+ });
289
+ res.end(req.method === "HEAD" ? undefined : asset.body);
290
+ }
291
+ /** The three proposal writes. Every check runs before the body is read. */
292
+ async function handleWrite(req, res, route) {
293
+ // The header only: a token in the URL ends up in history and logs, and a link can carry it.
294
+ const given = req.headers[TOKEN_HEADER];
295
+ if (!tokenMatches(Array.isArray(given) ? given[0] : given, token)) {
296
+ sendError(req, res, 401, "UNAUTHORIZED", "Writes need the session token in the X-Akm-Ui-Token header.", {
297
+ "WWW-Authenticate": "X-Akm-Ui-Token",
298
+ });
299
+ return;
300
+ }
301
+ if (!originAllowed(req.headers.origin, req.headers.host)) {
302
+ sendError(req, res, 403, "ORIGIN_NOT_ALLOWED", "The Origin header must be the address akm ui is serving.");
303
+ return;
304
+ }
305
+ if (!isJsonContentType(req.headers["content-type"])) {
306
+ sendError(req, res, 415, "UNSUPPORTED_MEDIA_TYPE", "Writes must be sent as Content-Type: application/json.");
307
+ return;
308
+ }
309
+ const text = await readBody(req);
310
+ if (text === null) {
311
+ sendError(req, res, 413, "BODY_TOO_LARGE", `The request body is larger than ${MAX_BODY_BYTES} bytes.`, {
312
+ Connection: "close",
313
+ });
314
+ return;
315
+ }
316
+ let body;
317
+ try {
318
+ body = text.trim() === "" ? undefined : JSON.parse(text);
319
+ }
320
+ catch {
321
+ sendError(req, res, 400, "INVALID_JSON_ARGUMENT", "The request body is not valid JSON.");
322
+ return;
323
+ }
324
+ try {
325
+ const data = await handleWriteRequest(route, body);
326
+ sendJson(req, res, 200, { ok: true, data: scrubUrlCredentials(data) });
327
+ }
328
+ catch (error) {
329
+ const { status, code, message } = classifyProposalError(error);
330
+ sendError(req, res, status, code, message);
331
+ }
332
+ }
333
+ await new Promise((resolve, reject) => {
334
+ server.once("error", reject);
335
+ server.listen(options.port ?? 0, host, () => {
336
+ server.off("error", reject);
337
+ resolve();
338
+ });
339
+ });
340
+ const port = server.address().port;
341
+ info = { startedAt, host, port };
342
+ return {
343
+ ...info,
344
+ token,
345
+ url: `http://${urlHost(host)}:${port}/?token=${token}`,
346
+ close: () => new Promise((resolve) => {
347
+ // Keep-alive sockets would hold close() open until they time out.
348
+ server.closeAllConnections?.();
349
+ server.close(() => resolve());
350
+ }),
351
+ };
352
+ }
@@ -0,0 +1,62 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * `akm ui` — a local web UI, read-only apart from accepting and rejecting proposals, over the same data the CLI reports:
6
+ * health and metrics, bundles, config, the proposal queue, recent changes and
7
+ * search. See `docs/reference/ui-api.md` for the API the page reads.
8
+ */
9
+ import { defineJsonCommand } from "../../cli/shared.js";
10
+ import { UsageError } from "../../core/errors.js";
11
+ import { info, warn } from "../../core/warn.js";
12
+ import { openBrowser } from "./open-browser.js";
13
+ import { isLoopbackBind, startUiServer } from "./server.js";
14
+ function parsePort(raw) {
15
+ if (raw === undefined || raw.trim() === "")
16
+ return 0;
17
+ const trimmed = raw.trim();
18
+ if (!/^\d+$/.test(trimmed) || Number(trimmed) > 65535) {
19
+ throw new UsageError(`Invalid --port value: "${raw}". Must be a port number from 0 to 65535.`, "INVALID_FLAG_VALUE");
20
+ }
21
+ return Number(trimmed);
22
+ }
23
+ export const uiCommand = defineJsonCommand({
24
+ meta: {
25
+ name: "ui",
26
+ description: "Experimental: serve a local web UI (health, bundles, config, proposals, recent changes, search) and open it in your browser. It is read-only except that you can accept and reject proposals in it. The URL carries a per-launch token. Press Ctrl-C to stop.",
27
+ },
28
+ args: {
29
+ port: { type: "string", description: "Port to listen on (default: a random free port)" },
30
+ host: { type: "string", description: "Address to bind (default 127.0.0.1; anything else exposes the stash)" },
31
+ // citty treats `--no-open` as the negation of `open`; declare the positive flag (see `akm health --probe`).
32
+ open: {
33
+ type: "boolean",
34
+ default: true,
35
+ description: "Open the UI in your default browser (on by default).",
36
+ negativeDescription: "Print the URL without opening a browser.",
37
+ },
38
+ },
39
+ async run({ args }) {
40
+ const host = args.host?.trim() || "127.0.0.1";
41
+ const server = await startUiServer({ host, port: parsePort(args.port) });
42
+ if (!isLoopbackBind(host)) {
43
+ warn(`[ui] Listening on ${host}, not just this machine: anyone who can reach it and has the token can read your stash, config and proposals. Keep the URL private.`);
44
+ }
45
+ // The URL is the command's output, so it goes to stdout like any other result.
46
+ process.stdout.write(`akm ui: ${server.url}\n`);
47
+ info("Press Ctrl-C to stop.");
48
+ if (args.open !== false && !(await openBrowser(server.url))) {
49
+ info("Could not open a browser; open the URL above yourself.");
50
+ }
51
+ await new Promise((resolve) => {
52
+ const stop = () => {
53
+ process.off("SIGINT", stop);
54
+ process.off("SIGTERM", stop);
55
+ resolve();
56
+ };
57
+ process.once("SIGINT", stop);
58
+ process.once("SIGTERM", stop);
59
+ });
60
+ await server.close();
61
+ },
62
+ });