@manifest-network/manifest-agent-core 0.14.0 → 0.16.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.
Files changed (62) hide show
  1. package/dist/close-lease.d.ts.map +1 -1
  2. package/dist/close-lease.js +15 -3
  3. package/dist/close-lease.js.map +1 -1
  4. package/dist/deploy-app.d.ts +2 -2
  5. package/dist/deploy-app.d.ts.map +1 -1
  6. package/dist/deploy-app.js +84 -37
  7. package/dist/deploy-app.js.map +1 -1
  8. package/dist/deploy-app.test-d.d.ts +1 -0
  9. package/dist/deploy-app.test-d.js +11 -0
  10. package/dist/deploy-app.test-d.js.map +1 -0
  11. package/dist/guarded-fetch.d.ts +2 -0
  12. package/dist/guarded-fetch.js +2 -0
  13. package/dist/index.d.ts +2 -3
  14. package/dist/index.js +1 -2
  15. package/dist/internals/cancellation.d.ts +57 -0
  16. package/dist/internals/cancellation.d.ts.map +1 -0
  17. package/dist/internals/cancellation.js +79 -0
  18. package/dist/internals/cancellation.js.map +1 -0
  19. package/dist/internals/inspect-image.js +1 -1
  20. package/dist/internals/inspect-image.js.map +1 -1
  21. package/dist/internals/render-intent-recap.d.ts +13 -11
  22. package/dist/internals/render-intent-recap.d.ts.map +1 -1
  23. package/dist/internals/render-intent-recap.js +5 -4
  24. package/dist/internals/render-intent-recap.js.map +1 -1
  25. package/dist/internals/spec-normalize.d.ts +34 -28
  26. package/dist/internals/spec-normalize.d.ts.map +1 -1
  27. package/dist/internals/spec-normalize.js +28 -22
  28. package/dist/internals/spec-normalize.js.map +1 -1
  29. package/dist/manage-domain.d.ts.map +1 -1
  30. package/dist/manage-domain.js +34 -8
  31. package/dist/manage-domain.js.map +1 -1
  32. package/dist/node_modules/@vitest/pretty-format/dist/index.js +888 -0
  33. package/dist/node_modules/@vitest/pretty-format/dist/index.js.map +1 -0
  34. package/dist/node_modules/@vitest/runner/dist/chunk-artifact.js +1500 -0
  35. package/dist/node_modules/@vitest/runner/dist/chunk-artifact.js.map +1 -0
  36. package/dist/node_modules/@vitest/runner/dist/index.js +1 -0
  37. package/dist/node_modules/@vitest/runner/dist/utils.js +1 -0
  38. package/dist/node_modules/@vitest/utils/dist/chunk-pathe.M-eThtNZ.js +82 -0
  39. package/dist/node_modules/@vitest/utils/dist/chunk-pathe.M-eThtNZ.js.map +1 -0
  40. package/dist/node_modules/@vitest/utils/dist/display.js +558 -0
  41. package/dist/node_modules/@vitest/utils/dist/display.js.map +1 -0
  42. package/dist/node_modules/@vitest/utils/dist/helpers.js +68 -0
  43. package/dist/node_modules/@vitest/utils/dist/helpers.js.map +1 -0
  44. package/dist/node_modules/@vitest/utils/dist/source-map.js +95 -0
  45. package/dist/node_modules/@vitest/utils/dist/source-map.js.map +1 -0
  46. package/dist/node_modules/@vitest/utils/dist/timers.js +20 -0
  47. package/dist/node_modules/@vitest/utils/dist/timers.js.map +1 -0
  48. package/dist/node_modules/tinyrainbow/dist/index.js +86 -0
  49. package/dist/node_modules/tinyrainbow/dist/index.js.map +1 -0
  50. package/dist/node_modules/vite/dist/node/module-runner.js +22 -0
  51. package/dist/node_modules/vite/dist/node/module-runner.js.map +1 -0
  52. package/dist/node_modules/vitest/dist/index.js +6 -0
  53. package/dist/troubleshoot.d.ts.map +1 -1
  54. package/dist/troubleshoot.js +11 -1
  55. package/dist/troubleshoot.js.map +1 -1
  56. package/dist/types.d.ts +30 -51
  57. package/dist/types.d.ts.map +1 -1
  58. package/package.json +15 -7
  59. package/dist/internals/build-fred-input.d.ts +0 -46
  60. package/dist/internals/build-fred-input.d.ts.map +0 -1
  61. package/dist/internals/build-fred-input.js +0 -160
  62. package/dist/internals/build-fred-input.js.map +0 -1
@@ -0,0 +1,79 @@
1
+ import { ManifestMCPError, ManifestMCPErrorCode, resolveCallSignal } from "@manifest-network/manifest-mcp-core";
2
+ //#region src/internals/cancellation.ts
3
+ /**
4
+ * Build the structured cancellation error for an aborted/timed-out PRE-broadcast
5
+ * await (or a stopped-awaiting read). `OPERATION_CANCELLED` is non-retryable and
6
+ * keeps the abort path consistent with the SDK error model; the original
7
+ * `AbortError`/`TimeoutError` reason is preserved in the message + details.
8
+ *
9
+ * - `broadcasts: true` → mutating flows (deploy / domain-set / lease-close):
10
+ * the abort happened before any tx was sent.
11
+ * - `broadcasts: false` → read-only flows (troubleshoot / domain-lookup): there
12
+ * is no broadcast to reference; we merely stopped awaiting the query.
13
+ */
14
+ function cancelledError(reason, opLabel, broadcasts) {
15
+ const detail = reason instanceof Error ? reason.message : String(reason);
16
+ const message = broadcasts ? `${opLabel} was cancelled before broadcast (${detail}); no transaction was sent.` : `${opLabel} was cancelled (${detail}).`;
17
+ return new ManifestMCPError(ManifestMCPErrorCode.OPERATION_CANCELLED, message, { reason });
18
+ }
19
+ /**
20
+ * Race a pending promise against an `AbortSignal`. Copies the executor + swallow
21
+ * shape from core's `internals/tx-confirmation.ts:withTxConfirmation`: the losing
22
+ * branch's eventual rejection is swallowed (no unhandled rejection) and the abort
23
+ * listener is added `{ once: true }` and removed in `.finally`. The rejection
24
+ * error is produced by the injected `makeError`, so the primitive stays
25
+ * operation-agnostic. Manifestjs queries take no `AbortSignal`, so for read-only
26
+ * callers this does NOT cancel the RPC — it only stops AWAITING it.
27
+ */
28
+ function raceAbort(promise, signal, makeError) {
29
+ if (signal.aborted) {
30
+ promise.catch(() => {});
31
+ return Promise.reject(makeError(signal.reason));
32
+ }
33
+ promise.catch(() => {});
34
+ return new Promise((resolve, reject) => {
35
+ const onAbort = () => reject(makeError(signal.reason));
36
+ signal.addEventListener("abort", onAbort, { once: true });
37
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
38
+ });
39
+ }
40
+ /**
41
+ * Build the per-call cancellation seam: captures the resolved signal, a once-guard
42
+ * for the terminal `cancelled` progress event, and the operation label /
43
+ * broadcast-ness for the error message. PURE at construction — call
44
+ * `throwIfCancelled()` explicitly for the already-aborted short-circuit.
45
+ */
46
+ function makeCancellationScope(args) {
47
+ const { opts, onProgress, opLabel, broadcasts } = args;
48
+ const signal = resolveCallSignal(opts);
49
+ const makeError = (reason) => cancelledError(reason, opLabel, broadcasts);
50
+ let cancelledEmitted = false;
51
+ const cancelOnAbort = (reason) => {
52
+ if (!cancelledEmitted) {
53
+ cancelledEmitted = true;
54
+ onProgress?.({ kind: "cancelled" });
55
+ }
56
+ throw makeError(reason);
57
+ };
58
+ const throwIfCancelled = () => {
59
+ if (signal?.aborted) cancelOnAbort(signal.reason);
60
+ };
61
+ const race = async (p) => {
62
+ if (signal === void 0) return p;
63
+ try {
64
+ return await raceAbort(p, signal, makeError);
65
+ } catch (err) {
66
+ if (err instanceof ManifestMCPError && err.code === ManifestMCPErrorCode.OPERATION_CANCELLED && signal.aborted) cancelOnAbort(signal.reason);
67
+ throw err;
68
+ }
69
+ };
70
+ return {
71
+ signal,
72
+ throwIfCancelled,
73
+ race
74
+ };
75
+ }
76
+ //#endregion
77
+ export { cancelledError, makeCancellationScope, raceAbort };
78
+
79
+ //# sourceMappingURL=cancellation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cancellation.js","names":[],"sources":["../../src/internals/cancellation.ts"],"sourcesContent":["import {\n ManifestMCPError,\n ManifestMCPErrorCode,\n resolveCallSignal,\n} from '@manifest-network/manifest-mcp-core';\nimport type { ProgressEvent } from '../types.js';\n\n/**\n * Build the structured cancellation error for an aborted/timed-out PRE-broadcast\n * await (or a stopped-awaiting read). `OPERATION_CANCELLED` is non-retryable and\n * keeps the abort path consistent with the SDK error model; the original\n * `AbortError`/`TimeoutError` reason is preserved in the message + details.\n *\n * - `broadcasts: true` → mutating flows (deploy / domain-set / lease-close):\n * the abort happened before any tx was sent.\n * - `broadcasts: false` → read-only flows (troubleshoot / domain-lookup): there\n * is no broadcast to reference; we merely stopped awaiting the query.\n */\nexport function cancelledError(\n reason: unknown,\n opLabel: string,\n broadcasts: boolean,\n): ManifestMCPError {\n const detail = reason instanceof Error ? reason.message : String(reason);\n const message = broadcasts\n ? `${opLabel} was cancelled before broadcast (${detail}); no transaction was sent.`\n : `${opLabel} was cancelled (${detail}).`;\n return new ManifestMCPError(\n ManifestMCPErrorCode.OPERATION_CANCELLED,\n message,\n {\n reason,\n },\n );\n}\n\n/**\n * Race a pending promise against an `AbortSignal`. Copies the executor + swallow\n * shape from core's `internals/tx-confirmation.ts:withTxConfirmation`: the losing\n * branch's eventual rejection is swallowed (no unhandled rejection) and the abort\n * listener is added `{ once: true }` and removed in `.finally`. The rejection\n * error is produced by the injected `makeError`, so the primitive stays\n * operation-agnostic. Manifestjs queries take no `AbortSignal`, so for read-only\n * callers this does NOT cancel the RPC — it only stops AWAITING it.\n */\nexport function raceAbort<T>(\n promise: Promise<T>,\n signal: AbortSignal,\n makeError: (reason: unknown) => ManifestMCPError,\n): Promise<T> {\n if (signal.aborted) {\n promise.catch(() => {}); // swallow the loser even on the already-aborted path\n return Promise.reject(makeError(signal.reason));\n }\n promise.catch(() => {}); // swallow the losing branch's eventual rejection\n return new Promise<T>((resolve, reject) => {\n const onAbort = (): void => reject(makeError(signal.reason));\n signal.addEventListener('abort', onAbort, { once: true });\n promise\n .then(resolve, reject)\n .finally(() => signal.removeEventListener('abort', onAbort));\n });\n}\n\n/** Per-call cancellation seam shared by all four agent-core orchestrators. */\nexport interface CancellationScope {\n /** Effective signal (caller `signal` composed with `timeout`), or `undefined`. */\n signal: AbortSignal | undefined;\n /** Throw `OPERATION_CANCELLED` (emitting `cancelled` once) if already aborted. */\n throwIfCancelled: () => void;\n /**\n * Race a pre-broadcast callback or a read query against the signal. A no-op\n * passthrough when no signal is present. On abort it emits `cancelled` once\n * and throws `OPERATION_CANCELLED`.\n */\n race: <T>(p: Promise<T>) => Promise<T>;\n}\n\n/**\n * Build the per-call cancellation seam: captures the resolved signal, a once-guard\n * for the terminal `cancelled` progress event, and the operation label /\n * broadcast-ness for the error message. PURE at construction — call\n * `throwIfCancelled()` explicitly for the already-aborted short-circuit.\n */\nexport function makeCancellationScope(args: {\n opts: { signal?: AbortSignal; timeout?: number };\n onProgress: ((event: ProgressEvent) => void) | undefined;\n opLabel: string;\n broadcasts: boolean;\n}): CancellationScope {\n const { opts, onProgress, opLabel, broadcasts } = args;\n const signal = resolveCallSignal(opts);\n const makeError = (reason: unknown): ManifestMCPError =>\n cancelledError(reason, opLabel, broadcasts);\n let cancelledEmitted = false;\n const cancelOnAbort = (reason: unknown): never => {\n if (!cancelledEmitted) {\n cancelledEmitted = true;\n onProgress?.({ kind: 'cancelled' });\n }\n throw makeError(reason);\n };\n const throwIfCancelled = (): void => {\n if (signal?.aborted) cancelOnAbort(signal.reason);\n };\n const race = async <T>(p: Promise<T>): Promise<T> => {\n if (signal === undefined) return p;\n try {\n return await raceAbort(p, signal, makeError);\n } catch (err) {\n if (\n err instanceof ManifestMCPError &&\n err.code === ManifestMCPErrorCode.OPERATION_CANCELLED &&\n signal.aborted\n ) {\n cancelOnAbort(signal.reason);\n }\n throw err;\n }\n };\n return { signal, throwIfCancelled, race };\n}\n"],"mappings":";;;;;;;;;;;;;AAkBA,SAAgB,eACd,QACA,SACA,YACkB;CAClB,MAAM,SAAS,kBAAkB,QAAQ,OAAO,UAAU,OAAO,MAAM;CACvE,MAAM,UAAU,aACZ,GAAG,QAAQ,mCAAmC,OAAO,+BACrD,GAAG,QAAQ,kBAAkB,OAAO;CACxC,OAAO,IAAI,iBACT,qBAAqB,qBACrB,SACA,EACE,OACF,CACF;AACF;;;;;;;;;;AAWA,SAAgB,UACd,SACA,QACA,WACY;CACZ,IAAI,OAAO,SAAS;EAClB,QAAQ,YAAY,CAAC,CAAC;EACtB,OAAO,QAAQ,OAAO,UAAU,OAAO,MAAM,CAAC;CAChD;CACA,QAAQ,YAAY,CAAC,CAAC;CACtB,OAAO,IAAI,SAAY,SAAS,WAAW;EACzC,MAAM,gBAAsB,OAAO,UAAU,OAAO,MAAM,CAAC;EAC3D,OAAO,iBAAiB,SAAS,SAAS,EAAE,MAAM,KAAK,CAAC;EACxD,QACG,KAAK,SAAS,MAAM,EACpB,cAAc,OAAO,oBAAoB,SAAS,OAAO,CAAC;CAC/D,CAAC;AACH;;;;;;;AAsBA,SAAgB,sBAAsB,MAKhB;CACpB,MAAM,EAAE,MAAM,YAAY,SAAS,eAAe;CAClD,MAAM,SAAS,kBAAkB,IAAI;CACrC,MAAM,aAAa,WACjB,eAAe,QAAQ,SAAS,UAAU;CAC5C,IAAI,mBAAmB;CACvB,MAAM,iBAAiB,WAA2B;EAChD,IAAI,CAAC,kBAAkB;GACrB,mBAAmB;GACnB,aAAa,EAAE,MAAM,YAAY,CAAC;EACpC;EACA,MAAM,UAAU,MAAM;CACxB;CACA,MAAM,yBAA+B;EACnC,IAAI,QAAQ,SAAS,cAAc,OAAO,MAAM;CAClD;CACA,MAAM,OAAO,OAAU,MAA8B;EACnD,IAAI,WAAW,KAAA,GAAW,OAAO;EACjC,IAAI;GACF,OAAO,MAAM,UAAU,GAAG,QAAQ,SAAS;EAC7C,SAAS,KAAK;GACZ,IACE,eAAe,oBACf,IAAI,SAAS,qBAAqB,uBAClC,OAAO,SAEP,cAAc,OAAO,MAAM;GAE7B,MAAM;EACR;CACF;CACA,OAAO;EAAE;EAAQ;EAAkB;CAAK;AAC1C"}
@@ -23,7 +23,7 @@ import { createGuardedFetch } from "./guarded-fetch.js";
23
23
  *
24
24
  * ## Security — SSRF (production callers MUST read)
25
25
  *
26
- * `imageRef` is user-controlled (it comes from `DeploySpec.image`).
26
+ * `imageRef` is user-controlled (it comes from `AppDeploySpec.image`).
27
27
  * Without an SSRF guard, an image ref like `169.254.169.254:80/foo:bar`
28
28
  * (cloud-metadata) or `127.0.0.1:6379/foo:bar` (local Redis) would
29
29
  * cause this function to probe internal services on the host. The CJS
@@ -1 +1 @@
1
- {"version":3,"file":"inspect-image.js","names":[],"sources":["../../src/internals/inspect-image.ts"],"sourcesContent":["import { createGuardedFetch } from './guarded-fetch.js';\n\n/**\n * Inspect a public container image via the OCI Distribution API. Returns\n * the manifest digest, exposed ports, image defaults (env / cmd /\n * entrypoint / user / workingDir), healthcheck, labels, volumes, and a\n * heuristic `suggestedTmpfs` list for known-good Fred image families.\n *\n * HTTPS requests go through `opts.fetch`, defaulting to `createGuardedFetch()`\n * (DIY undici Dispatcher + RFC-cited block ranges + IPv4-mapped IPv6\n * normalization — see `guarded-fetch.ts` for the design).\n *\n * **Fail-soft contract:** returns `null` on every non-fatal failure mode:\n * - 401 / 403 (private registry / auth required)\n * - 429 (Docker Hub rate-limit)\n * - OCI grammar violation in the `imageRef`\n * - Manifest body exceeding the 10 MiB cap\n * - Request timeout (10s)\n * - Unparseable manifest / blob JSON\n * - SSRF block (default fetch refuses RFC 1918 / loopback / etc.)\n * Callers treat `null` as \"no info, ask the user\".\n * Diagnostics flow through `opts.logger` instead of stderr.\n *\n * ## Security — SSRF (production callers MUST read)\n *\n * `imageRef` is user-controlled (it comes from `DeploySpec.image`).\n * Without an SSRF guard, an image ref like `169.254.169.254:80/foo:bar`\n * (cloud-metadata) or `127.0.0.1:6379/foo:bar` (local Redis) would\n * cause this function to probe internal services on the host. The CJS\n * blocks this via its SSRF-aware HTTPS agent; the TS port delegates to\n * the caller's `opts.fetch`, defaulting to `createGuardedFetch()` which\n * blocks at connect time.\n *\n * Opt-out-of-safety semantics (parent's PR-2 directive): the default\n * `opts.fetch = createGuardedFetch()` is safe by construction. Callers\n * pass their own `opts.fetch` ONLY for tests (canned responses) or\n * unusual production cases (e.g. a trusted private registry on an RFC\n * 1918 IP, after explicit allow-listing). See `createGuardedFetch`'s\n * JSDoc for the production-guard contract.\n */\n\nconst ACCEPT_MANIFEST = [\n 'application/vnd.oci.image.index.v1+json',\n 'application/vnd.oci.image.manifest.v1+json',\n 'application/vnd.docker.distribution.manifest.list.v2+json',\n 'application/vnd.docker.distribution.manifest.v2+json',\n].join(', ');\n\n// OCI Distribution Spec v1.1 grammar for URL-interpolated fields. Validate\n// BEFORE the URL is constructed; the `imageRef` flag is user-controlled,\n// so a malformed input like `foo/bar:..%2F..%2Fconfig` must be rejected\n// here rather than forwarded to the registry.\nconst OCI_NAME_COMPONENT = /^[a-z0-9]+(?:(?:\\.|_|__|-+)[a-z0-9]+)*$/;\nconst OCI_TAG = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/;\nconst OCI_DIGEST = /^sha256:[0-9a-f]{64}$/;\n\n// Body-size cap (10 MiB). Real-world configs are <100 KB; even JVM-rich\n// images rarely exceed a few MB. Anything over 10 MiB indicates a hostile\n// or buggy registry; abort rather than risk OOM.\nconst MAX_BODY_BYTES = 10 * 1024 * 1024;\n\n// Request timeout — 10s. Registry queries should be fast; longer waits\n// indicate a hung registry.\nconst REQUEST_TIMEOUT_MS = 10_000;\n\n// Heuristic table: image base name or resolved Cmd/Entrypoint contains\n// one of these tokens → suggest the corresponding tmpfs paths. Order:\n// longer/more-specific tokens first when there's ambiguity.\nconst TMPFS_HINTS: ReadonlyArray<{\n readonly match: string;\n readonly paths: readonly string[];\n}> = [\n { match: 'wordpress', paths: ['/run/lock', '/var/run/apache2'] },\n { match: 'mariadb', paths: ['/run/mysqld'] },\n { match: 'postgres', paths: ['/var/run/postgresql'] },\n { match: 'mysql', paths: ['/var/run/mysqld'] },\n { match: 'nginx', paths: ['/var/cache/nginx', '/var/run'] },\n];\n\nexport interface ImageInfo {\n image: string;\n digest: string | null;\n ports: string[];\n env: Record<string, string>;\n cmd: string[] | null;\n entrypoint: string[] | null;\n user: string;\n workingDir: string;\n healthcheck: Record<string, unknown> | null;\n labels: Record<string, string> | null;\n volumes: Record<string, unknown> | null;\n suggestedTmpfs: string[];\n}\n\nexport interface InspectImageOptions {\n /**\n * HTTP client. **Production callers SHOULD use the default** (which is\n * `createGuardedFetch()`, blocking RFC 1918 / loopback / link-local /\n * metadata at connect time). Tests pass canned implementations.\n * Browser/Deno consumers pass their own SSRF-guarded fetch since\n * `createGuardedFetch()` throws on non-Node runtimes.\n */\n fetch?: typeof fetch;\n /** Sink for fail-soft diagnostics. Defaults to `console.warn`. */\n logger?: (reason: string) => void;\n}\n\nconst defaultLogger: (reason: string) => void = (reason) => {\n console.warn(reason);\n};\n\ninterface ParsedRef {\n registry: string;\n name: string;\n tag: string | null;\n digest: string | null;\n}\n\nexport async function inspectImage(\n imageRef: string,\n opts: InspectImageOptions = {},\n): Promise<ImageInfo | null> {\n const logger = opts.logger ?? defaultLogger;\n const fetchImpl: typeof fetch = opts.fetch ?? createDefaultGuardedFetch();\n\n let parsed: ParsedRef;\n try {\n parsed = parseRef(imageRef);\n } catch (err) {\n logger(\n `inspect-image: ${err instanceof Error ? err.message : String(err)}`,\n );\n return null;\n }\n\n const ref = parsed.digest ?? parsed.tag ?? 'latest';\n try {\n let authHeader: string | null = null;\n if (parsed.registry === 'docker.io') {\n const token = await getDockerHubToken(parsed.name, fetchImpl);\n authHeader = `Bearer ${token}`;\n }\n\n // Step 1: fetch manifest (may be an index → pick platform → refetch).\n let manifestRes = await fetchManifest(\n parsed.registry,\n parsed.name,\n ref,\n authHeader,\n fetchImpl,\n );\n if (\n manifestRes.contentType.includes('manifest.list') ||\n manifestRes.contentType.includes('image.index') ||\n isManifestIndex(manifestRes.manifest)\n ) {\n const child = pickPlatformManifest(manifestRes.manifest);\n if (!child || typeof child.digest !== 'string') {\n throw new Error('multi-arch index has no usable child manifest');\n }\n manifestRes = await fetchManifest(\n parsed.registry,\n parsed.name,\n child.digest,\n authHeader,\n fetchImpl,\n );\n }\n\n // Step 2: fetch the config blob — the actual image config lives there.\n const config = manifestRes.manifest.config as\n | { digest?: unknown }\n | undefined;\n if (!config || typeof config.digest !== 'string') {\n throw new Error('manifest has no config descriptor');\n }\n const configBlob = await fetchBlobJson(\n parsed.registry,\n parsed.name,\n config.digest,\n authHeader,\n fetchImpl,\n );\n const c = (configBlob.config ?? {}) as Record<string, unknown>;\n\n const out: ImageInfo = {\n image: `${parsed.registry}/${parsed.name}${parsed.digest ? '@' + parsed.digest : ':' + (parsed.tag ?? 'latest')}`,\n digest: manifestRes.digest ?? parsed.digest ?? null,\n ports: pickPorts(c.ExposedPorts),\n env: parseEnv(c.Env),\n cmd: Array.isArray(c.Cmd) ? (c.Cmd as string[]) : null,\n entrypoint: Array.isArray(c.Entrypoint)\n ? (c.Entrypoint as string[])\n : null,\n user: typeof c.User === 'string' ? c.User : '',\n workingDir: typeof c.WorkingDir === 'string' ? c.WorkingDir : '',\n healthcheck:\n c.Healthcheck !== null &&\n typeof c.Healthcheck === 'object' &&\n !Array.isArray(c.Healthcheck)\n ? (c.Healthcheck as Record<string, unknown>)\n : null,\n labels:\n c.Labels !== null &&\n typeof c.Labels === 'object' &&\n !Array.isArray(c.Labels)\n ? (c.Labels as Record<string, string>)\n : null,\n volumes:\n c.Volumes !== null &&\n typeof c.Volumes === 'object' &&\n !Array.isArray(c.Volumes)\n ? (c.Volumes as Record<string, unknown>)\n : null,\n suggestedTmpfs: [],\n };\n out.suggestedTmpfs = suggestedTmpfsFor(parsed.name, [\n ...(out.cmd ?? []),\n ...(out.entrypoint ?? []),\n ]);\n\n return out;\n } catch (err) {\n logger(`inspect-image: ${formatErrorChain(err)}`);\n return null;\n }\n}\n\n/**\n * Walk an Error's `cause` chain and join all message strings. undici wraps\n * connection errors (including SSRF blocks from our custom Dispatcher) in\n * a fetch-side TypeError with the underlying cause nested via `.cause`.\n * Surfacing the chain in the logger gives the user the real reason (e.g.,\n * \"SSRF blocked: 127.0.0.1 ... loopback\") instead of an opaque\n * \"fetch failed\".\n */\nfunction formatErrorChain(err: unknown): string {\n const parts: string[] = [];\n let current: unknown = err;\n let depth = 0;\n // Defensive bound — sane Error chains are <5 levels; cap at 10 to avoid\n // pathological cycles.\n while (current !== null && current !== undefined && depth < 10) {\n if (current instanceof Error) {\n parts.push(current.message);\n current = (current as Error & { cause?: unknown }).cause;\n } else {\n parts.push(String(current));\n current = undefined;\n }\n depth += 1;\n }\n return parts.join(' | ');\n}\n\nlet cachedDefaultFetch: typeof fetch | undefined;\nfunction createDefaultGuardedFetch(): typeof fetch {\n if (!cachedDefaultFetch) {\n cachedDefaultFetch = createGuardedFetch();\n }\n return cachedDefaultFetch;\n}\n\nfunction parseRef(ref: string): ParsedRef {\n // \"<reg>/<name>@sha256:<digest>\" | \"<reg>/<name>:<tag>\" | \"<name>\" | \"<name>:<tag>\"\n let registry = 'docker.io';\n let name: string;\n let tag: string | null = null;\n let digest: string | null = null;\n\n let rest = ref;\n const atIdx = rest.indexOf('@');\n if (atIdx >= 0) {\n digest = rest.slice(atIdx + 1);\n rest = rest.slice(0, atIdx);\n }\n\n // Detect registry segment: head before first `/` is a registry only if\n // it has a `.` or `:` (port) or is `localhost`.\n const firstSlash = rest.indexOf('/');\n if (firstSlash > 0) {\n const head = rest.slice(0, firstSlash);\n if (head === 'localhost' || head.includes('.') || head.includes(':')) {\n registry = head;\n rest = rest.slice(firstSlash + 1);\n }\n }\n\n if (!digest) {\n const colonIdx = rest.lastIndexOf(':');\n if (colonIdx >= 0) {\n tag = rest.slice(colonIdx + 1);\n name = rest.slice(0, colonIdx);\n } else {\n name = rest;\n tag = 'latest';\n }\n } else {\n name = rest;\n }\n\n // Docker Hub library prefix for single-segment names (\"nginx\" → \"library/nginx\").\n if (registry === 'docker.io' && !name.includes('/')) {\n name = `library/${name}`;\n }\n\n // Validate URL-interpolated fields against OCI Distribution Spec grammar\n // BEFORE the URL is constructed. The ref strings reach the user via\n // `DeploySpec.image`, so malformed input must be rejected here.\n for (const component of name.split('/')) {\n if (!OCI_NAME_COMPONENT.test(component)) {\n throw new Error(`invalid name component \"${component}\" in image ref`);\n }\n }\n if (tag !== null && !OCI_TAG.test(tag)) {\n throw new Error(`invalid tag \"${tag}\" in image ref`);\n }\n if (digest !== null && !OCI_DIGEST.test(digest)) {\n throw new Error(\n `invalid digest \"${digest}\" in image ref (expected sha256:<64-hex>)`,\n );\n }\n\n return { registry, name, tag, digest };\n}\n\nfunction registryHost(registry: string): string {\n // Docker Hub's image API lives at registry-1.docker.io even though the\n // canonical \"registry\" name is docker.io.\n return registry === 'docker.io' ? 'registry-1.docker.io' : registry;\n}\n\nasync function getDockerHubToken(\n name: string,\n fetchImpl: typeof fetch,\n): Promise<string> {\n // Docker Hub requires anonymous access still go through a token grant.\n // Surface 429 specifically — anonymous pulls are rate-limited per-IP\n // and a 60-min wait fixes it. Without this special case the user sees\n // the same fail-soft `null` outcome as a hard 401 with no signal that\n // the situation is temporary.\n const res = await capturingFetch(\n `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${name}:pull`,\n {},\n fetchImpl,\n );\n if (res.status === 429) {\n throw new Error(\n 'Docker Hub token: HTTP 429 (anonymous pulls rate-limited per-IP; retry after ~60 min, or authenticate)',\n );\n }\n if (res.status !== 200) {\n throw new Error(`Docker Hub token: HTTP ${res.status}`);\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(res.body);\n } catch {\n throw new Error('Docker Hub token: invalid JSON');\n }\n if (\n parsed === null ||\n typeof parsed !== 'object' ||\n typeof (parsed as { token?: unknown }).token !== 'string'\n ) {\n throw new Error('Docker Hub token: missing `token` in response');\n }\n return (parsed as { token: string }).token;\n}\n\nasync function fetchManifest(\n registry: string,\n name: string,\n ref: string,\n authHeader: string | null,\n fetchImpl: typeof fetch,\n): Promise<{\n manifest: Record<string, unknown>;\n contentType: string;\n digest: string | null;\n}> {\n const host = registryHost(registry);\n const url = `https://${host}/v2/${name}/manifests/${ref}`;\n const headers: Record<string, string> = { Accept: ACCEPT_MANIFEST };\n if (authHeader) headers.Authorization = authHeader;\n const res = await capturingFetch(url, { headers }, fetchImpl);\n if (res.status === 401 || res.status === 403) {\n throw new Error(\n `registry returned ${res.status} on manifest fetch (auth required? private registry?)`,\n );\n }\n if (res.status === 404) {\n // Digest-pinned refs use `@sha256:...`; tag refs use `:tag`. Pick the\n // right separator so the error message doesn't show\n // `registry/name:sha256:...` mistakenly.\n const sep = ref.startsWith('sha256:') ? '@' : ':';\n throw new Error(`image not found: ${registry}/${name}${sep}${ref}`);\n }\n if (res.status !== 200) {\n throw new Error(`registry returned ${res.status} on manifest fetch`);\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(res.body);\n } catch {\n throw new Error('manifest is not valid JSON');\n }\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error('manifest is not a JSON object');\n }\n return {\n manifest: parsed as Record<string, unknown>,\n contentType: res.headers.get('content-type') ?? '',\n digest: res.headers.get('docker-content-digest'),\n };\n}\n\nasync function fetchBlobJson(\n registry: string,\n name: string,\n digest: string,\n authHeader: string | null,\n fetchImpl: typeof fetch,\n): Promise<{ config?: unknown }> {\n const host = registryHost(registry);\n const url = `https://${host}/v2/${name}/blobs/${digest}`;\n const headers: Record<string, string> = {};\n if (authHeader) headers.Authorization = authHeader;\n // undici fetch follows redirects by default; registries 307 → CDN.\n const res = await capturingFetch(url, { headers }, fetchImpl);\n if (res.status !== 200) {\n throw new Error(`registry returned ${res.status} on blob fetch`);\n }\n try {\n return JSON.parse(res.body) as { config?: unknown };\n } catch {\n throw new Error('blob is not valid JSON');\n }\n}\n\ninterface CapturedResponse {\n status: number;\n headers: Headers;\n body: string;\n}\n\n/**\n * Wrap fetch with `AbortSignal.timeout(REQUEST_TIMEOUT_MS)` and a streamed\n * body-size cap. Throws on overflow, timeout, or read error so the outer\n * try/catch produces the fail-soft `null` return.\n */\nasync function capturingFetch(\n url: string,\n init: RequestInit,\n fetchImpl: typeof fetch,\n): Promise<CapturedResponse> {\n const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);\n let response: Response;\n try {\n response = await fetchImpl(url, { ...init, signal });\n } catch (err) {\n if (err instanceof Error && err.name === 'TimeoutError') {\n throw new Error(`request timeout on ${url}`);\n }\n throw err;\n }\n // Stream the body with a manual chunk-accumulation cap. Avoids the\n // unbounded `await response.text()` path that would let a hostile\n // registry exhaust memory.\n const reader = response.body?.getReader();\n if (!reader) {\n return { status: response.status, headers: response.headers, body: '' };\n }\n const chunks: Uint8Array[] = [];\n let totalBytes = 0;\n const decoder = new TextDecoder();\n let body = '';\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n if (value) {\n totalBytes += value.length;\n if (totalBytes > MAX_BODY_BYTES) {\n await reader.cancel();\n throw new Error(\n `response body exceeded ${MAX_BODY_BYTES} bytes (cap) on ${url}`,\n );\n }\n chunks.push(value);\n }\n }\n body = decoder.decode(concatUint8Arrays(chunks));\n } finally {\n reader.releaseLock();\n }\n return { status: response.status, headers: response.headers, body };\n}\n\nfunction concatUint8Arrays(chunks: Uint8Array[]): Uint8Array {\n if (chunks.length === 0) return new Uint8Array(0);\n if (chunks.length === 1) {\n const only = chunks[0];\n if (only !== undefined) return only;\n }\n let total = 0;\n for (const c of chunks) total += c.length;\n const out = new Uint8Array(total);\n let offset = 0;\n for (const c of chunks) {\n out.set(c, offset);\n offset += c.length;\n }\n return out;\n}\n\nfunction pickPorts(exposedPorts: unknown): string[] {\n if (\n exposedPorts === null ||\n typeof exposedPorts !== 'object' ||\n Array.isArray(exposedPorts)\n ) {\n return [];\n }\n return Object.keys(exposedPorts as Record<string, unknown>).sort();\n}\n\nfunction parseEnv(env: unknown): Record<string, string> {\n if (!Array.isArray(env)) return {};\n const out: Record<string, string> = {};\n for (const kv of env) {\n if (typeof kv !== 'string') continue;\n const i = kv.indexOf('=');\n if (i > 0) {\n const key = kv.slice(0, i);\n const value = kv.slice(i + 1);\n out[key] = value;\n } else {\n out[kv] = '';\n }\n }\n return out;\n}\n\nfunction isManifestIndex(m: Record<string, unknown>): boolean {\n return Array.isArray(m.manifests);\n}\n\nfunction pickPlatformManifest(\n index: Record<string, unknown>,\n): Record<string, unknown> | null {\n const list = index.manifests;\n if (!Array.isArray(list)) return null;\n const linuxAmd64 = list.find(\n (m): m is Record<string, unknown> =>\n m !== null &&\n typeof m === 'object' &&\n (m as { platform?: unknown }).platform !== null &&\n typeof (m as { platform?: unknown }).platform === 'object' &&\n (m as { platform: { os?: unknown } }).platform.os === 'linux' &&\n (m as { platform: { architecture?: unknown } }).platform.architecture ===\n 'amd64',\n );\n if (linuxAmd64) return linuxAmd64;\n // Fall back to first entry.\n const first = list[0];\n if (first !== null && typeof first === 'object' && !Array.isArray(first)) {\n return first as Record<string, unknown>;\n }\n return null;\n}\n\nfunction suggestedTmpfsFor(\n name: string,\n cmdAndEntrypoint: ReadonlyArray<string>,\n): string[] {\n const haystack = [name, ...cmdAndEntrypoint].join(' ').toLowerCase();\n for (const hint of TMPFS_HINTS) {\n if (haystack.includes(hint.match)) return [...hint.paths];\n }\n return [];\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"inspect-image.js","names":[],"sources":["../../src/internals/inspect-image.ts"],"sourcesContent":["import { createGuardedFetch } from './guarded-fetch.js';\n\n/**\n * Inspect a public container image via the OCI Distribution API. Returns\n * the manifest digest, exposed ports, image defaults (env / cmd /\n * entrypoint / user / workingDir), healthcheck, labels, volumes, and a\n * heuristic `suggestedTmpfs` list for known-good Fred image families.\n *\n * HTTPS requests go through `opts.fetch`, defaulting to `createGuardedFetch()`\n * (DIY undici Dispatcher + RFC-cited block ranges + IPv4-mapped IPv6\n * normalization — see `guarded-fetch.ts` for the design).\n *\n * **Fail-soft contract:** returns `null` on every non-fatal failure mode:\n * - 401 / 403 (private registry / auth required)\n * - 429 (Docker Hub rate-limit)\n * - OCI grammar violation in the `imageRef`\n * - Manifest body exceeding the 10 MiB cap\n * - Request timeout (10s)\n * - Unparseable manifest / blob JSON\n * - SSRF block (default fetch refuses RFC 1918 / loopback / etc.)\n * Callers treat `null` as \"no info, ask the user\".\n * Diagnostics flow through `opts.logger` instead of stderr.\n *\n * ## Security — SSRF (production callers MUST read)\n *\n * `imageRef` is user-controlled (it comes from `AppDeploySpec.image`).\n * Without an SSRF guard, an image ref like `169.254.169.254:80/foo:bar`\n * (cloud-metadata) or `127.0.0.1:6379/foo:bar` (local Redis) would\n * cause this function to probe internal services on the host. The CJS\n * blocks this via its SSRF-aware HTTPS agent; the TS port delegates to\n * the caller's `opts.fetch`, defaulting to `createGuardedFetch()` which\n * blocks at connect time.\n *\n * Opt-out-of-safety semantics (parent's PR-2 directive): the default\n * `opts.fetch = createGuardedFetch()` is safe by construction. Callers\n * pass their own `opts.fetch` ONLY for tests (canned responses) or\n * unusual production cases (e.g. a trusted private registry on an RFC\n * 1918 IP, after explicit allow-listing). See `createGuardedFetch`'s\n * JSDoc for the production-guard contract.\n */\n\nconst ACCEPT_MANIFEST = [\n 'application/vnd.oci.image.index.v1+json',\n 'application/vnd.oci.image.manifest.v1+json',\n 'application/vnd.docker.distribution.manifest.list.v2+json',\n 'application/vnd.docker.distribution.manifest.v2+json',\n].join(', ');\n\n// OCI Distribution Spec v1.1 grammar for URL-interpolated fields. Validate\n// BEFORE the URL is constructed; the `imageRef` flag is user-controlled,\n// so a malformed input like `foo/bar:..%2F..%2Fconfig` must be rejected\n// here rather than forwarded to the registry.\nconst OCI_NAME_COMPONENT = /^[a-z0-9]+(?:(?:\\.|_|__|-+)[a-z0-9]+)*$/;\nconst OCI_TAG = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/;\nconst OCI_DIGEST = /^sha256:[0-9a-f]{64}$/;\n\n// Body-size cap (10 MiB). Real-world configs are <100 KB; even JVM-rich\n// images rarely exceed a few MB. Anything over 10 MiB indicates a hostile\n// or buggy registry; abort rather than risk OOM.\nconst MAX_BODY_BYTES = 10 * 1024 * 1024;\n\n// Request timeout — 10s. Registry queries should be fast; longer waits\n// indicate a hung registry.\nconst REQUEST_TIMEOUT_MS = 10_000;\n\n// Heuristic table: image base name or resolved Cmd/Entrypoint contains\n// one of these tokens → suggest the corresponding tmpfs paths. Order:\n// longer/more-specific tokens first when there's ambiguity.\nconst TMPFS_HINTS: ReadonlyArray<{\n readonly match: string;\n readonly paths: readonly string[];\n}> = [\n { match: 'wordpress', paths: ['/run/lock', '/var/run/apache2'] },\n { match: 'mariadb', paths: ['/run/mysqld'] },\n { match: 'postgres', paths: ['/var/run/postgresql'] },\n { match: 'mysql', paths: ['/var/run/mysqld'] },\n { match: 'nginx', paths: ['/var/cache/nginx', '/var/run'] },\n];\n\nexport interface ImageInfo {\n image: string;\n digest: string | null;\n ports: string[];\n env: Record<string, string>;\n cmd: string[] | null;\n entrypoint: string[] | null;\n user: string;\n workingDir: string;\n healthcheck: Record<string, unknown> | null;\n labels: Record<string, string> | null;\n volumes: Record<string, unknown> | null;\n suggestedTmpfs: string[];\n}\n\nexport interface InspectImageOptions {\n /**\n * HTTP client. **Production callers SHOULD use the default** (which is\n * `createGuardedFetch()`, blocking RFC 1918 / loopback / link-local /\n * metadata at connect time). Tests pass canned implementations.\n * Browser/Deno consumers pass their own SSRF-guarded fetch since\n * `createGuardedFetch()` throws on non-Node runtimes.\n */\n fetch?: typeof fetch;\n /** Sink for fail-soft diagnostics. Defaults to `console.warn`. */\n logger?: (reason: string) => void;\n}\n\nconst defaultLogger: (reason: string) => void = (reason) => {\n console.warn(reason);\n};\n\ninterface ParsedRef {\n registry: string;\n name: string;\n tag: string | null;\n digest: string | null;\n}\n\nexport async function inspectImage(\n imageRef: string,\n opts: InspectImageOptions = {},\n): Promise<ImageInfo | null> {\n const logger = opts.logger ?? defaultLogger;\n const fetchImpl: typeof fetch = opts.fetch ?? createDefaultGuardedFetch();\n\n let parsed: ParsedRef;\n try {\n parsed = parseRef(imageRef);\n } catch (err) {\n logger(\n `inspect-image: ${err instanceof Error ? err.message : String(err)}`,\n );\n return null;\n }\n\n const ref = parsed.digest ?? parsed.tag ?? 'latest';\n try {\n let authHeader: string | null = null;\n if (parsed.registry === 'docker.io') {\n const token = await getDockerHubToken(parsed.name, fetchImpl);\n authHeader = `Bearer ${token}`;\n }\n\n // Step 1: fetch manifest (may be an index → pick platform → refetch).\n let manifestRes = await fetchManifest(\n parsed.registry,\n parsed.name,\n ref,\n authHeader,\n fetchImpl,\n );\n if (\n manifestRes.contentType.includes('manifest.list') ||\n manifestRes.contentType.includes('image.index') ||\n isManifestIndex(manifestRes.manifest)\n ) {\n const child = pickPlatformManifest(manifestRes.manifest);\n if (!child || typeof child.digest !== 'string') {\n throw new Error('multi-arch index has no usable child manifest');\n }\n manifestRes = await fetchManifest(\n parsed.registry,\n parsed.name,\n child.digest,\n authHeader,\n fetchImpl,\n );\n }\n\n // Step 2: fetch the config blob — the actual image config lives there.\n const config = manifestRes.manifest.config as\n | { digest?: unknown }\n | undefined;\n if (!config || typeof config.digest !== 'string') {\n throw new Error('manifest has no config descriptor');\n }\n const configBlob = await fetchBlobJson(\n parsed.registry,\n parsed.name,\n config.digest,\n authHeader,\n fetchImpl,\n );\n const c = (configBlob.config ?? {}) as Record<string, unknown>;\n\n const out: ImageInfo = {\n image: `${parsed.registry}/${parsed.name}${parsed.digest ? '@' + parsed.digest : ':' + (parsed.tag ?? 'latest')}`,\n digest: manifestRes.digest ?? parsed.digest ?? null,\n ports: pickPorts(c.ExposedPorts),\n env: parseEnv(c.Env),\n cmd: Array.isArray(c.Cmd) ? (c.Cmd as string[]) : null,\n entrypoint: Array.isArray(c.Entrypoint)\n ? (c.Entrypoint as string[])\n : null,\n user: typeof c.User === 'string' ? c.User : '',\n workingDir: typeof c.WorkingDir === 'string' ? c.WorkingDir : '',\n healthcheck:\n c.Healthcheck !== null &&\n typeof c.Healthcheck === 'object' &&\n !Array.isArray(c.Healthcheck)\n ? (c.Healthcheck as Record<string, unknown>)\n : null,\n labels:\n c.Labels !== null &&\n typeof c.Labels === 'object' &&\n !Array.isArray(c.Labels)\n ? (c.Labels as Record<string, string>)\n : null,\n volumes:\n c.Volumes !== null &&\n typeof c.Volumes === 'object' &&\n !Array.isArray(c.Volumes)\n ? (c.Volumes as Record<string, unknown>)\n : null,\n suggestedTmpfs: [],\n };\n out.suggestedTmpfs = suggestedTmpfsFor(parsed.name, [\n ...(out.cmd ?? []),\n ...(out.entrypoint ?? []),\n ]);\n\n return out;\n } catch (err) {\n logger(`inspect-image: ${formatErrorChain(err)}`);\n return null;\n }\n}\n\n/**\n * Walk an Error's `cause` chain and join all message strings. undici wraps\n * connection errors (including SSRF blocks from our custom Dispatcher) in\n * a fetch-side TypeError with the underlying cause nested via `.cause`.\n * Surfacing the chain in the logger gives the user the real reason (e.g.,\n * \"SSRF blocked: 127.0.0.1 ... loopback\") instead of an opaque\n * \"fetch failed\".\n */\nfunction formatErrorChain(err: unknown): string {\n const parts: string[] = [];\n let current: unknown = err;\n let depth = 0;\n // Defensive bound — sane Error chains are <5 levels; cap at 10 to avoid\n // pathological cycles.\n while (current !== null && current !== undefined && depth < 10) {\n if (current instanceof Error) {\n parts.push(current.message);\n current = (current as Error & { cause?: unknown }).cause;\n } else {\n parts.push(String(current));\n current = undefined;\n }\n depth += 1;\n }\n return parts.join(' | ');\n}\n\nlet cachedDefaultFetch: typeof fetch | undefined;\nfunction createDefaultGuardedFetch(): typeof fetch {\n if (!cachedDefaultFetch) {\n cachedDefaultFetch = createGuardedFetch();\n }\n return cachedDefaultFetch;\n}\n\nfunction parseRef(ref: string): ParsedRef {\n // \"<reg>/<name>@sha256:<digest>\" | \"<reg>/<name>:<tag>\" | \"<name>\" | \"<name>:<tag>\"\n let registry = 'docker.io';\n let name: string;\n let tag: string | null = null;\n let digest: string | null = null;\n\n let rest = ref;\n const atIdx = rest.indexOf('@');\n if (atIdx >= 0) {\n digest = rest.slice(atIdx + 1);\n rest = rest.slice(0, atIdx);\n }\n\n // Detect registry segment: head before first `/` is a registry only if\n // it has a `.` or `:` (port) or is `localhost`.\n const firstSlash = rest.indexOf('/');\n if (firstSlash > 0) {\n const head = rest.slice(0, firstSlash);\n if (head === 'localhost' || head.includes('.') || head.includes(':')) {\n registry = head;\n rest = rest.slice(firstSlash + 1);\n }\n }\n\n if (!digest) {\n const colonIdx = rest.lastIndexOf(':');\n if (colonIdx >= 0) {\n tag = rest.slice(colonIdx + 1);\n name = rest.slice(0, colonIdx);\n } else {\n name = rest;\n tag = 'latest';\n }\n } else {\n name = rest;\n }\n\n // Docker Hub library prefix for single-segment names (\"nginx\" → \"library/nginx\").\n if (registry === 'docker.io' && !name.includes('/')) {\n name = `library/${name}`;\n }\n\n // Validate URL-interpolated fields against OCI Distribution Spec grammar\n // BEFORE the URL is constructed. The ref strings reach the user via\n // `AppDeploySpec.image`, so malformed input must be rejected here.\n for (const component of name.split('/')) {\n if (!OCI_NAME_COMPONENT.test(component)) {\n throw new Error(`invalid name component \"${component}\" in image ref`);\n }\n }\n if (tag !== null && !OCI_TAG.test(tag)) {\n throw new Error(`invalid tag \"${tag}\" in image ref`);\n }\n if (digest !== null && !OCI_DIGEST.test(digest)) {\n throw new Error(\n `invalid digest \"${digest}\" in image ref (expected sha256:<64-hex>)`,\n );\n }\n\n return { registry, name, tag, digest };\n}\n\nfunction registryHost(registry: string): string {\n // Docker Hub's image API lives at registry-1.docker.io even though the\n // canonical \"registry\" name is docker.io.\n return registry === 'docker.io' ? 'registry-1.docker.io' : registry;\n}\n\nasync function getDockerHubToken(\n name: string,\n fetchImpl: typeof fetch,\n): Promise<string> {\n // Docker Hub requires anonymous access still go through a token grant.\n // Surface 429 specifically — anonymous pulls are rate-limited per-IP\n // and a 60-min wait fixes it. Without this special case the user sees\n // the same fail-soft `null` outcome as a hard 401 with no signal that\n // the situation is temporary.\n const res = await capturingFetch(\n `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${name}:pull`,\n {},\n fetchImpl,\n );\n if (res.status === 429) {\n throw new Error(\n 'Docker Hub token: HTTP 429 (anonymous pulls rate-limited per-IP; retry after ~60 min, or authenticate)',\n );\n }\n if (res.status !== 200) {\n throw new Error(`Docker Hub token: HTTP ${res.status}`);\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(res.body);\n } catch {\n throw new Error('Docker Hub token: invalid JSON');\n }\n if (\n parsed === null ||\n typeof parsed !== 'object' ||\n typeof (parsed as { token?: unknown }).token !== 'string'\n ) {\n throw new Error('Docker Hub token: missing `token` in response');\n }\n return (parsed as { token: string }).token;\n}\n\nasync function fetchManifest(\n registry: string,\n name: string,\n ref: string,\n authHeader: string | null,\n fetchImpl: typeof fetch,\n): Promise<{\n manifest: Record<string, unknown>;\n contentType: string;\n digest: string | null;\n}> {\n const host = registryHost(registry);\n const url = `https://${host}/v2/${name}/manifests/${ref}`;\n const headers: Record<string, string> = { Accept: ACCEPT_MANIFEST };\n if (authHeader) headers.Authorization = authHeader;\n const res = await capturingFetch(url, { headers }, fetchImpl);\n if (res.status === 401 || res.status === 403) {\n throw new Error(\n `registry returned ${res.status} on manifest fetch (auth required? private registry?)`,\n );\n }\n if (res.status === 404) {\n // Digest-pinned refs use `@sha256:...`; tag refs use `:tag`. Pick the\n // right separator so the error message doesn't show\n // `registry/name:sha256:...` mistakenly.\n const sep = ref.startsWith('sha256:') ? '@' : ':';\n throw new Error(`image not found: ${registry}/${name}${sep}${ref}`);\n }\n if (res.status !== 200) {\n throw new Error(`registry returned ${res.status} on manifest fetch`);\n }\n let parsed: unknown;\n try {\n parsed = JSON.parse(res.body);\n } catch {\n throw new Error('manifest is not valid JSON');\n }\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {\n throw new Error('manifest is not a JSON object');\n }\n return {\n manifest: parsed as Record<string, unknown>,\n contentType: res.headers.get('content-type') ?? '',\n digest: res.headers.get('docker-content-digest'),\n };\n}\n\nasync function fetchBlobJson(\n registry: string,\n name: string,\n digest: string,\n authHeader: string | null,\n fetchImpl: typeof fetch,\n): Promise<{ config?: unknown }> {\n const host = registryHost(registry);\n const url = `https://${host}/v2/${name}/blobs/${digest}`;\n const headers: Record<string, string> = {};\n if (authHeader) headers.Authorization = authHeader;\n // undici fetch follows redirects by default; registries 307 → CDN.\n const res = await capturingFetch(url, { headers }, fetchImpl);\n if (res.status !== 200) {\n throw new Error(`registry returned ${res.status} on blob fetch`);\n }\n try {\n return JSON.parse(res.body) as { config?: unknown };\n } catch {\n throw new Error('blob is not valid JSON');\n }\n}\n\ninterface CapturedResponse {\n status: number;\n headers: Headers;\n body: string;\n}\n\n/**\n * Wrap fetch with `AbortSignal.timeout(REQUEST_TIMEOUT_MS)` and a streamed\n * body-size cap. Throws on overflow, timeout, or read error so the outer\n * try/catch produces the fail-soft `null` return.\n */\nasync function capturingFetch(\n url: string,\n init: RequestInit,\n fetchImpl: typeof fetch,\n): Promise<CapturedResponse> {\n const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);\n let response: Response;\n try {\n response = await fetchImpl(url, { ...init, signal });\n } catch (err) {\n if (err instanceof Error && err.name === 'TimeoutError') {\n throw new Error(`request timeout on ${url}`);\n }\n throw err;\n }\n // Stream the body with a manual chunk-accumulation cap. Avoids the\n // unbounded `await response.text()` path that would let a hostile\n // registry exhaust memory.\n const reader = response.body?.getReader();\n if (!reader) {\n return { status: response.status, headers: response.headers, body: '' };\n }\n const chunks: Uint8Array[] = [];\n let totalBytes = 0;\n const decoder = new TextDecoder();\n let body = '';\n try {\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n if (value) {\n totalBytes += value.length;\n if (totalBytes > MAX_BODY_BYTES) {\n await reader.cancel();\n throw new Error(\n `response body exceeded ${MAX_BODY_BYTES} bytes (cap) on ${url}`,\n );\n }\n chunks.push(value);\n }\n }\n body = decoder.decode(concatUint8Arrays(chunks));\n } finally {\n reader.releaseLock();\n }\n return { status: response.status, headers: response.headers, body };\n}\n\nfunction concatUint8Arrays(chunks: Uint8Array[]): Uint8Array {\n if (chunks.length === 0) return new Uint8Array(0);\n if (chunks.length === 1) {\n const only = chunks[0];\n if (only !== undefined) return only;\n }\n let total = 0;\n for (const c of chunks) total += c.length;\n const out = new Uint8Array(total);\n let offset = 0;\n for (const c of chunks) {\n out.set(c, offset);\n offset += c.length;\n }\n return out;\n}\n\nfunction pickPorts(exposedPorts: unknown): string[] {\n if (\n exposedPorts === null ||\n typeof exposedPorts !== 'object' ||\n Array.isArray(exposedPorts)\n ) {\n return [];\n }\n return Object.keys(exposedPorts as Record<string, unknown>).sort();\n}\n\nfunction parseEnv(env: unknown): Record<string, string> {\n if (!Array.isArray(env)) return {};\n const out: Record<string, string> = {};\n for (const kv of env) {\n if (typeof kv !== 'string') continue;\n const i = kv.indexOf('=');\n if (i > 0) {\n const key = kv.slice(0, i);\n const value = kv.slice(i + 1);\n out[key] = value;\n } else {\n out[kv] = '';\n }\n }\n return out;\n}\n\nfunction isManifestIndex(m: Record<string, unknown>): boolean {\n return Array.isArray(m.manifests);\n}\n\nfunction pickPlatformManifest(\n index: Record<string, unknown>,\n): Record<string, unknown> | null {\n const list = index.manifests;\n if (!Array.isArray(list)) return null;\n const linuxAmd64 = list.find(\n (m): m is Record<string, unknown> =>\n m !== null &&\n typeof m === 'object' &&\n (m as { platform?: unknown }).platform !== null &&\n typeof (m as { platform?: unknown }).platform === 'object' &&\n (m as { platform: { os?: unknown } }).platform.os === 'linux' &&\n (m as { platform: { architecture?: unknown } }).platform.architecture ===\n 'amd64',\n );\n if (linuxAmd64) return linuxAmd64;\n // Fall back to first entry.\n const first = list[0];\n if (first !== null && typeof first === 'object' && !Array.isArray(first)) {\n return first as Record<string, unknown>;\n }\n return null;\n}\n\nfunction suggestedTmpfsFor(\n name: string,\n cmdAndEntrypoint: ReadonlyArray<string>,\n): string[] {\n const haystack = [name, ...cmdAndEntrypoint].join(' ').toLowerCase();\n for (const hint of TMPFS_HINTS) {\n if (haystack.includes(hint.match)) return [...hint.paths];\n }\n return [];\n}\n"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import { DeploySpec } from "../types.js";
1
+ import { AppDeploySpec } from "../types.js";
2
2
 
3
3
  //#region src/internals/render-intent-recap.d.ts
4
4
  /**
@@ -20,20 +20,22 @@ import { DeploySpec } from "../types.js";
20
20
  * surfaced; only keys appear. Mirrors `summarizeSpec`'s contract. FQDNs are
21
21
  * not secrets so `customDomain` is surfaced verbatim.
22
22
  *
23
- * **Port-shape handling:** the CJS supports two runtime shapes for ports:
24
- * - Legacy single-service: `port: number` → renders one ingress=true entry.
25
- * - Services-map: `ports: Record<portKey, { ingress?: boolean }>` → one
26
- * entry per port-key with the declared ingress flag (default false).
23
+ * **Port-shape handling:** two runtime shapes for ports:
24
+ * - Single-service: `port: number` → renders one ingress=true entry.
25
+ * - Stack service: `ServiceConfig.ports: Record<portKey, {}>` (canonical
26
+ * map, e.g. `{ '80/tcp': {} }`) → one entry per port-key (ingress
27
+ * default false). The recap renders the map KEY string (`'80/tcp'`),
28
+ * not a bare number.
27
29
  *
28
- * The frozen TS contract narrows `ServiceDef.ports` to `number[]` for the
29
- * common case; this port also handles the historical Record shape at runtime
30
- * (matching `summarizeSpec`'s defensive widening) so callers passing
31
- * unknown-typed input from JSON.parse don't silently drop ports.
30
+ * `extractPorts` also handles the historical `number[]` Record-or-array
31
+ * shapes at runtime (matching `summarizeSpec`'s defensive widening) so
32
+ * callers passing unknown-typed input from JSON.parse don't silently drop
33
+ * ports.
32
34
  */
33
35
  /** Render output is a multi-paragraph plain-text block, ready to print verbatim. */
34
36
  interface RenderIntentRecapInput {
35
- /** The structured deploy spec (frozen `DeploySpec` shape). */
36
- spec: DeploySpec;
37
+ /** The structured deploy spec (canonical `AppDeploySpec` shape). */
38
+ spec: AppDeploySpec;
37
39
  /** Active chain — drives the mainnet permanence warning. */
38
40
  activeChain: 'testnet' | 'mainnet';
39
41
  }
@@ -1 +1 @@
1
- {"version":3,"file":"render-intent-recap.d.ts","names":[],"sources":["../../src/internals/render-intent-recap.ts"],"mappings":";;;;;AAuCA;;;;;;;;AAIa;AAgBb;;;;AAA+D;;;;;;;;;;;;;;;UApB9C,sBAAA;;EAEf,IAAA,EAAM,UAAU;;EAEhB,WAAA;AAAA;AAAA,iBAgBc,iBAAA,CAAkB,KAA6B,EAAtB,sBAAsB"}
1
+ {"version":3,"file":"render-intent-recap.d.ts","names":[],"sources":["../../src/internals/render-intent-recap.ts"],"mappings":";;;;;AAoCA;;;;;;;;AAIa;AAgBb;;;;AAA+D;;;;;;;;;;;;;;;;;UApB9C,sBAAA;;EAEf,IAAA,EAAM,aAAa;;EAEnB,WAAA;AAAA;AAAA,iBAgBc,iBAAA,CAAkB,KAA6B,EAAtB,sBAAsB"}
@@ -25,10 +25,11 @@ function projectServices(spec) {
25
25
  });
26
26
  }
27
27
  /**
28
- * Services-map shape: `{ "80": { ingress?: boolean }, "9090": { ... } }`.
29
- * Ingress flag may be absent default `false` matches Fred's cluster-private
30
- * default. Also handles the typed `number[]` shape (frozen `ServiceDef.ports`)
31
- * by treating each entry as ingress=false (services-map default).
28
+ * Services-map shape: `{ "80/tcp": { ingress?: boolean }, "9090": { ... } }`
29
+ * (`ServiceConfig.ports` is a `Record<string, …>` port map). Ingress flag may
30
+ * be absent default `false` matches Fred's cluster-private default. Also
31
+ * tolerates a legacy `number[]` shape defensively by treating each entry as
32
+ * ingress=false (services-map default).
32
33
  */
33
34
  function extractPorts(ports) {
34
35
  if (Array.isArray(ports)) return ports.filter((p) => typeof p === "number").map((p) => ({
@@ -1 +1 @@
1
- {"version":3,"file":"render-intent-recap.js","names":[],"sources":["../../src/internals/render-intent-recap.ts"],"sourcesContent":["import type {\n DeploySpec,\n ServiceDef,\n SingleServiceSpec,\n StackSpec,\n} from '../types.js';\nimport { isStackSpec, normalizeServices } from './spec-normalize.js';\n\n/**\n * Render the structural portion of the intent-recap block shown to the user\n * before any chain round-trips in the deploy-app orchestrator.\n *\n * The 4 deterministic items the recap covers:\n *\n * 1. Deployment surface (service count + per-service `name — image`)\n * 2. Connectivity (per-port ingress posture)\n * 3. Redacted sensitive-key inventory (env / label keys only; never values)\n * 4. Custom-domain + dual-tx clarifier + mainnet warning (when applicable)\n *\n * The 2 LLM-judgment items (\"what you provided vs auto-detected\", \"heads-up:\n * obvious gaps\") stay in prose — the orchestrator appends them between the\n * deterministic block and the `AskUserQuestion` prompt.\n *\n * **Sensitive-value posture:** env values and label values are NEVER\n * surfaced; only keys appear. Mirrors `summarizeSpec`'s contract. FQDNs are\n * not secrets so `customDomain` is surfaced verbatim.\n *\n * **Port-shape handling:** the CJS supports two runtime shapes for ports:\n * - Legacy single-service: `port: number` → renders one ingress=true entry.\n * - Services-map: `ports: Record<portKey, { ingress?: boolean }>` → one\n * entry per port-key with the declared ingress flag (default false).\n *\n * The frozen TS contract narrows `ServiceDef.ports` to `number[]` for the\n * common case; this port also handles the historical Record shape at runtime\n * (matching `summarizeSpec`'s defensive widening) so callers passing\n * unknown-typed input from JSON.parse don't silently drop ports.\n */\n\n/** Render output is a multi-paragraph plain-text block, ready to print verbatim. */\nexport interface RenderIntentRecapInput {\n /** The structured deploy spec (frozen `DeploySpec` shape). */\n spec: DeploySpec;\n /** Active chain — drives the mainnet permanence warning. */\n activeChain: 'testnet' | 'mainnet';\n}\n\ninterface NormalizedService {\n /** `null` for legacy single-service; the services-map key for stack leases. */\n name: string | null;\n /** Image string. Falls back to `(unknown image)` when missing. */\n image: string;\n /** Per-port ingress posture, in declaration order. */\n ports: { port: string; ingress: boolean }[];\n /** Sorted env keys (values redacted). */\n envKeys: string[];\n /** Sorted label keys (values redacted). */\n labelKeys: string[];\n}\n\nexport function renderIntentRecap(input: RenderIntentRecapInput): string {\n if (input.activeChain !== 'testnet' && input.activeChain !== 'mainnet') {\n throw new TypeError(\n `renderIntentRecap: activeChain must be \"testnet\" or \"mainnet\"; got \"${String(input.activeChain)}\"`,\n );\n }\n\n const services = projectServices(input.spec);\n\n const blocks: string[] = [\n renderServiceList(services, input.activeChain),\n renderConnectivity(services),\n renderRedactedInventory(services),\n ];\n const domainBlock = renderCustomDomain(input.spec, input.activeChain);\n if (domainBlock !== null) {\n blocks.push(domainBlock);\n }\n\n return blocks.join('\\n\\n');\n}\n\nfunction projectServices(spec: DeploySpec): NormalizedService[] {\n return normalizeServices(spec).map(({ name, raw }): NormalizedService => {\n const rawRecord = raw as unknown as Record<string, unknown>;\n const image =\n typeof rawRecord.image === 'string' && rawRecord.image.length > 0\n ? rawRecord.image\n : '(unknown image)';\n const ports =\n name === null\n ? extractPortsLegacy((raw as SingleServiceSpec).port)\n : extractPorts((raw as ServiceDef).ports);\n return {\n name,\n image,\n ports,\n envKeys: extractKeys(rawRecord.env),\n labelKeys: extractKeys(rawRecord.labels),\n };\n });\n}\n\n/**\n * Services-map shape: `{ \"80\": { ingress?: boolean }, \"9090\": { ... } }`.\n * Ingress flag may be absent — default `false` matches Fred's cluster-private\n * default. Also handles the typed `number[]` shape (frozen `ServiceDef.ports`)\n * by treating each entry as ingress=false (services-map default).\n */\nfunction extractPorts(ports: unknown): { port: string; ingress: boolean }[] {\n if (Array.isArray(ports)) {\n return ports\n .filter((p): p is number => typeof p === 'number')\n .map((p) => ({ port: String(p), ingress: false }));\n }\n if (ports !== null && typeof ports === 'object') {\n return Object.entries(ports as Record<string, unknown>).map(\n ([port, cfg]) => ({\n port,\n ingress: !!(\n cfg !== null &&\n typeof cfg === 'object' &&\n (cfg as { ingress?: unknown }).ingress\n ),\n }),\n );\n }\n return [];\n}\n\n/**\n * Legacy single-service shape: bare `port: number`. Fred treats this as\n * ingress=true by default — that's the whole point of the simplified\n * shape.\n *\n * Also handles the `number[]` form (the frozen-contract array form):\n * returns one `{ port, ingress: true }` entry per array element, each\n * with `ingress: true` matching the single-service convention. Returns\n * `[]` for any other value (undefined, non-number scalar, non-array\n * object).\n *\n * M2 fix: prior JSDoc incorrectly stated \"Returns `[]` for any other\n * value (including `number[]`...)\" — empirically wrong per the\n * `Array.isArray(port)` branch below.\n */\nfunction extractPortsLegacy(\n port: number | number[] | undefined,\n): { port: string; ingress: boolean }[] {\n if (typeof port === 'number') {\n return [{ port: String(port), ingress: true }];\n }\n if (Array.isArray(port)) {\n return port\n .filter((p): p is number => typeof p === 'number')\n .map((p) => ({ port: String(p), ingress: true }));\n }\n return [];\n}\n\nfunction extractKeys(obj: unknown): string[] {\n if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return [];\n return Object.keys(obj as Record<string, unknown>).sort();\n}\n\nfunction renderServiceList(\n services: NormalizedService[],\n activeChain: 'testnet' | 'mainnet',\n): string {\n const count = services.length;\n const noun = count === 1 ? 'service' : 'services';\n const lines = [`Deploying ${count} ${noun} on ${activeChain}:`];\n for (const svc of services) {\n const prefix = svc.name === null ? '' : `${svc.name} — `;\n lines.push(` - ${prefix}${svc.image}`);\n }\n return lines.join('\\n');\n}\n\nfunction renderConnectivity(services: NormalizedService[]): string {\n const lines = ['Connectivity:'];\n let total = 0;\n for (const svc of services) {\n if (svc.ports.length === 0) continue;\n for (const p of svc.ports) {\n total += 1;\n const prefix =\n svc.name === null ? `port ${p.port}` : `${svc.name} port ${p.port}`;\n const reach = p.ingress\n ? \"publicly reachable via the provider's HTTPS subdomain\"\n : 'internal only (cluster-private)';\n lines.push(` - ${prefix}: ${reach}`);\n }\n }\n if (total === 0) {\n lines.push(\n ' (no ports declared — the deployment will not expose any network surface)',\n );\n }\n return lines.join('\\n');\n}\n\nfunction renderRedactedInventory(services: NormalizedService[]): string {\n // Always render the section header even if everything is empty — the user\n // should know we'd have shown values if there were any. This is also\n // documentation of the redaction discipline.\n const lines = [\n 'Sensitive values are redacted in this recap (keys only, never values):',\n ];\n let anything = false;\n for (const svc of services) {\n const prefix = svc.name === null ? 'this service' : svc.name;\n const parts: string[] = [];\n if (svc.envKeys.length > 0) {\n anything = true;\n parts.push(`env keys [${svc.envKeys.join(', ')}]`);\n }\n if (svc.labelKeys.length > 0) {\n anything = true;\n parts.push(`label keys [${svc.labelKeys.join(', ')}]`);\n }\n if (parts.length === 0) {\n lines.push(` - ${prefix}: no env or labels supplied`);\n } else {\n lines.push(` - ${prefix}: ${parts.join('; ')}`);\n }\n }\n if (!anything) {\n lines.push(\n ' - (no env or labels supplied across any service — nothing to redact)',\n );\n }\n return lines.join('\\n');\n}\n\nfunction renderCustomDomain(\n spec: DeploySpec,\n activeChain: 'testnet' | 'mainnet',\n): string | null {\n const customDomain = (spec as { customDomain?: unknown }).customDomain;\n if (typeof customDomain !== 'string' || customDomain.length === 0) {\n return null;\n }\n // `serviceName` is only legal on StackSpec; for SingleServiceSpec the\n // single service implicitly receives the domain.\n const serviceName = isStackSpec(spec)\n ? (spec as StackSpec).serviceName\n : undefined;\n const target =\n typeof serviceName === 'string' && serviceName.length > 0\n ? `service ${serviceName}`\n : 'single-service lease';\n const lines = [`Custom domain: ${customDomain} → ${target}`];\n lines.push('');\n lines.push(\n 'Note: when a custom domain is set, deploy_app broadcasts TWO billing\\n' +\n 'transactions atomically: create-lease AND set-item-custom-domain. The\\n' +\n 'single permission prompt that fires later covers BOTH; this textual\\n' +\n 'recap is your per-tx review.',\n );\n if (activeChain === 'mainnet') {\n lines.push('');\n lines.push(\n `Mainnet warning: this transaction permanently associates ${customDomain}\\n` +\n 'with this lease on-chain until you --clear it via\\n' +\n '/manifest-agent:manage-domain or close the lease. FQDN squatting is\\n' +\n 'irreversible.',\n );\n }\n return lines.join('\\n');\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"render-intent-recap.js","names":[],"sources":["../../src/internals/render-intent-recap.ts"],"sourcesContent":["import type { AppDeploySpec, ServiceConfig } from '../types.js';\nimport { isStackSpec, normalizeServices } from './spec-normalize.js';\n\n/**\n * Render the structural portion of the intent-recap block shown to the user\n * before any chain round-trips in the deploy-app orchestrator.\n *\n * The 4 deterministic items the recap covers:\n *\n * 1. Deployment surface (service count + per-service `name — image`)\n * 2. Connectivity (per-port ingress posture)\n * 3. Redacted sensitive-key inventory (env / label keys only; never values)\n * 4. Custom-domain + dual-tx clarifier + mainnet warning (when applicable)\n *\n * The 2 LLM-judgment items (\"what you provided vs auto-detected\", \"heads-up:\n * obvious gaps\") stay in prose — the orchestrator appends them between the\n * deterministic block and the `AskUserQuestion` prompt.\n *\n * **Sensitive-value posture:** env values and label values are NEVER\n * surfaced; only keys appear. Mirrors `summarizeSpec`'s contract. FQDNs are\n * not secrets so `customDomain` is surfaced verbatim.\n *\n * **Port-shape handling:** two runtime shapes for ports:\n * - Single-service: `port: number` → renders one ingress=true entry.\n * - Stack service: `ServiceConfig.ports: Record<portKey, {}>` (canonical\n * map, e.g. `{ '80/tcp': {} }`) → one entry per port-key (ingress\n * default false). The recap renders the map KEY string (`'80/tcp'`),\n * not a bare number.\n *\n * `extractPorts` also handles the historical `number[]` Record-or-array\n * shapes at runtime (matching `summarizeSpec`'s defensive widening) so\n * callers passing unknown-typed input from JSON.parse don't silently drop\n * ports.\n */\n\n/** Render output is a multi-paragraph plain-text block, ready to print verbatim. */\nexport interface RenderIntentRecapInput {\n /** The structured deploy spec (canonical `AppDeploySpec` shape). */\n spec: AppDeploySpec;\n /** Active chain — drives the mainnet permanence warning. */\n activeChain: 'testnet' | 'mainnet';\n}\n\ninterface NormalizedService {\n /** `null` for legacy single-service; the services-map key for stack leases. */\n name: string | null;\n /** Image string. Falls back to `(unknown image)` when missing. */\n image: string;\n /** Per-port ingress posture, in declaration order. */\n ports: { port: string; ingress: boolean }[];\n /** Sorted env keys (values redacted). */\n envKeys: string[];\n /** Sorted label keys (values redacted). */\n labelKeys: string[];\n}\n\nexport function renderIntentRecap(input: RenderIntentRecapInput): string {\n if (input.activeChain !== 'testnet' && input.activeChain !== 'mainnet') {\n throw new TypeError(\n `renderIntentRecap: activeChain must be \"testnet\" or \"mainnet\"; got \"${String(input.activeChain)}\"`,\n );\n }\n\n const services = projectServices(input.spec);\n\n const blocks: string[] = [\n renderServiceList(services, input.activeChain),\n renderConnectivity(services),\n renderRedactedInventory(services),\n ];\n const domainBlock = renderCustomDomain(input.spec, input.activeChain);\n if (domainBlock !== null) {\n blocks.push(domainBlock);\n }\n\n return blocks.join('\\n\\n');\n}\n\nfunction projectServices(spec: AppDeploySpec): NormalizedService[] {\n return normalizeServices(spec).map(({ name, raw }): NormalizedService => {\n const rawRecord = raw as unknown as Record<string, unknown>;\n const image =\n typeof rawRecord.image === 'string' && rawRecord.image.length > 0\n ? rawRecord.image\n : '(unknown image)';\n const ports =\n name === null\n ? extractPortsLegacy((raw as AppDeploySpec).port)\n : extractPorts((raw as ServiceConfig).ports);\n return {\n name,\n image,\n ports,\n envKeys: extractKeys(rawRecord.env),\n labelKeys: extractKeys(rawRecord.labels),\n };\n });\n}\n\n/**\n * Services-map shape: `{ \"80/tcp\": { ingress?: boolean }, \"9090\": { ... } }`\n * (`ServiceConfig.ports` is a `Record<string, …>` port map). Ingress flag may\n * be absent — default `false` matches Fred's cluster-private default. Also\n * tolerates a legacy `number[]` shape defensively by treating each entry as\n * ingress=false (services-map default).\n */\nfunction extractPorts(ports: unknown): { port: string; ingress: boolean }[] {\n if (Array.isArray(ports)) {\n return ports\n .filter((p): p is number => typeof p === 'number')\n .map((p) => ({ port: String(p), ingress: false }));\n }\n if (ports !== null && typeof ports === 'object') {\n return Object.entries(ports as Record<string, unknown>).map(\n ([port, cfg]) => ({\n port,\n ingress: !!(\n cfg !== null &&\n typeof cfg === 'object' &&\n (cfg as { ingress?: unknown }).ingress\n ),\n }),\n );\n }\n return [];\n}\n\n/**\n * Legacy single-service shape: bare `port: number`. Fred treats this as\n * ingress=true by default — that's the whole point of the simplified\n * shape.\n *\n * Also handles the `number[]` form (the frozen-contract array form):\n * returns one `{ port, ingress: true }` entry per array element, each\n * with `ingress: true` matching the single-service convention. Returns\n * `[]` for any other value (undefined, non-number scalar, non-array\n * object).\n *\n * M2 fix: prior JSDoc incorrectly stated \"Returns `[]` for any other\n * value (including `number[]`...)\" — empirically wrong per the\n * `Array.isArray(port)` branch below.\n */\nfunction extractPortsLegacy(\n port: number | number[] | undefined,\n): { port: string; ingress: boolean }[] {\n if (typeof port === 'number') {\n return [{ port: String(port), ingress: true }];\n }\n if (Array.isArray(port)) {\n return port\n .filter((p): p is number => typeof p === 'number')\n .map((p) => ({ port: String(p), ingress: true }));\n }\n return [];\n}\n\nfunction extractKeys(obj: unknown): string[] {\n if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return [];\n return Object.keys(obj as Record<string, unknown>).sort();\n}\n\nfunction renderServiceList(\n services: NormalizedService[],\n activeChain: 'testnet' | 'mainnet',\n): string {\n const count = services.length;\n const noun = count === 1 ? 'service' : 'services';\n const lines = [`Deploying ${count} ${noun} on ${activeChain}:`];\n for (const svc of services) {\n const prefix = svc.name === null ? '' : `${svc.name} — `;\n lines.push(` - ${prefix}${svc.image}`);\n }\n return lines.join('\\n');\n}\n\nfunction renderConnectivity(services: NormalizedService[]): string {\n const lines = ['Connectivity:'];\n let total = 0;\n for (const svc of services) {\n if (svc.ports.length === 0) continue;\n for (const p of svc.ports) {\n total += 1;\n const prefix =\n svc.name === null ? `port ${p.port}` : `${svc.name} port ${p.port}`;\n const reach = p.ingress\n ? \"publicly reachable via the provider's HTTPS subdomain\"\n : 'internal only (cluster-private)';\n lines.push(` - ${prefix}: ${reach}`);\n }\n }\n if (total === 0) {\n lines.push(\n ' (no ports declared — the deployment will not expose any network surface)',\n );\n }\n return lines.join('\\n');\n}\n\nfunction renderRedactedInventory(services: NormalizedService[]): string {\n // Always render the section header even if everything is empty — the user\n // should know we'd have shown values if there were any. This is also\n // documentation of the redaction discipline.\n const lines = [\n 'Sensitive values are redacted in this recap (keys only, never values):',\n ];\n let anything = false;\n for (const svc of services) {\n const prefix = svc.name === null ? 'this service' : svc.name;\n const parts: string[] = [];\n if (svc.envKeys.length > 0) {\n anything = true;\n parts.push(`env keys [${svc.envKeys.join(', ')}]`);\n }\n if (svc.labelKeys.length > 0) {\n anything = true;\n parts.push(`label keys [${svc.labelKeys.join(', ')}]`);\n }\n if (parts.length === 0) {\n lines.push(` - ${prefix}: no env or labels supplied`);\n } else {\n lines.push(` - ${prefix}: ${parts.join('; ')}`);\n }\n }\n if (!anything) {\n lines.push(\n ' - (no env or labels supplied across any service — nothing to redact)',\n );\n }\n return lines.join('\\n');\n}\n\nfunction renderCustomDomain(\n spec: AppDeploySpec,\n activeChain: 'testnet' | 'mainnet',\n): string | null {\n const customDomain = spec.customDomain;\n if (typeof customDomain !== 'string' || customDomain.length === 0) {\n return null;\n }\n // `serviceName` only disambiguates a stack service; a single-service\n // spec's single service implicitly receives the domain.\n const serviceName = isStackSpec(spec) ? spec.serviceName : undefined;\n const target =\n typeof serviceName === 'string' && serviceName.length > 0\n ? `service ${serviceName}`\n : 'single-service lease';\n const lines = [`Custom domain: ${customDomain} → ${target}`];\n lines.push('');\n lines.push(\n 'Note: when a custom domain is set, deploy_app broadcasts TWO billing\\n' +\n 'transactions atomically: create-lease AND set-item-custom-domain. The\\n' +\n 'single permission prompt that fires later covers BOTH; this textual\\n' +\n 'recap is your per-tx review.',\n );\n if (activeChain === 'mainnet') {\n lines.push('');\n lines.push(\n `Mainnet warning: this transaction permanently associates ${customDomain}\\n` +\n 'with this lease on-chain until you --clear it via\\n' +\n '/manifest-agent:manage-domain or close the lease. FQDN squatting is\\n' +\n 'irreversible.',\n );\n }\n return lines.join('\\n');\n}\n"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import { DeploySpec, ServiceDef, SingleServiceSpec, SpecSummary, StackSpec } from "../types.js";
1
+ import { AppDeploySpec, ServiceConfig, SpecSummary } from "../types.js";
2
2
 
3
3
  //#region src/internals/spec-normalize.d.ts
4
4
  /**
@@ -6,37 +6,42 @@ import { DeploySpec, ServiceDef, SingleServiceSpec, SpecSummary, StackSpec } fro
6
6
  * `firstImage`, `normalizeServices`, `summarizeSpec`, and `validateSpec`
7
7
  * (the latter surfaces pre-broadcast shape violations).
8
8
  *
9
- * Two spec shapes are supported (defined in `types.ts`; `size?` added in
10
- * ENG-275):
11
- * - **services-map (StackSpec)** — `{ services: { <name>: ServiceDef }, customDomain?, serviceName?, size? }`
12
- * - **legacy single-service (SingleServiceSpec)** — `{ image, port?, env?, customDomain?, size? }`
9
+ * The single canonical spec type (`AppDeploySpec`, ENG-310) covers two
10
+ * shapes, discriminated by the presence of `services`:
11
+ * - **stack** — `{ services: { <name>: ServiceConfig }, customDomain?, serviceName?, size, ... }`
12
+ * - **single-service** — `{ image, port?, env?, customDomain?, size, ... }`
13
13
  *
14
14
  * `normalizeServices` collapses the two shapes into a single iterable form
15
15
  * so callers (Plan summary, manifest builder, etc.) walk one structure
16
16
  * regardless of which form the user passed.
17
17
  *
18
- * Validation: `validateSpec` throws a plain `TypeError` on shape violations
19
- * agent-core has no workspace dep on `@manifest-network/manifest-mcp-core`
20
- * in PR 1/2 (per parent's REV 1), so `ManifestMCPError` isn't available
21
- * here. PR 3's high-level `deployApp` re-wraps `TypeError` into
22
- * `ManifestMCPError(INVALID_CONFIG)` at the public-API boundary.
18
+ * Validation: `validateSpec` stays a `void` `TypeError`-thrower on shape
19
+ * violations. The high-level `deployApp` entry wrappers
20
+ * (`deploy-app.ts`) map that `TypeError` to
21
+ * `ManifestMCPError(INVALID_CONFIG)` at the public-API boundary, so this
22
+ * helper does not depend on core's error type directly.
23
23
  */
24
24
  /**
25
- * True when `spec` uses the services-map shape (StackSpec). Mirrors
26
- * `_spec.cjs#isStack`: `services` is a non-null, non-array object.
25
+ * True when `spec` uses the services-map (stack) shape. Discriminates on
26
+ * the canonical `services` key being present and defined (ENG-310). The
27
+ * non-null-object / non-array guard is retained as defense-in-depth so an
28
+ * `unknown`-cast caller smuggling `services: null` / `[]` / `'str'` is NOT
29
+ * mistaken for a stack (which would crash `Object.entries` downstream).
27
30
  */
28
- declare function isStackSpec(spec: DeploySpec | null | undefined): spec is StackSpec;
31
+ declare function isStackSpec(spec: unknown): spec is AppDeploySpec & {
32
+ services: Record<string, ServiceConfig>;
33
+ };
29
34
  /**
30
35
  * Return the canonical first image string for a spec. For legacy single-
31
36
  * service: `spec.image`. For stack: the first non-empty `image` in
32
37
  * `Object.values(spec.services)`. Returns `null` when neither shape
33
38
  * carries an image (or `spec` is malformed).
34
39
  */
35
- declare function firstImage(spec: DeploySpec | null | undefined): string | null;
40
+ declare function firstImage(spec: unknown): string | null;
36
41
  /**
37
42
  * Walk a spec as `[{name, raw}]` where:
38
43
  * - `name === null` for legacy single-service (only one entry, raw is the spec itself).
39
- * - `name === <key>` for each services-map entry; `raw` is the per-service ServiceDef.
44
+ * - `name === <key>` for each services-map entry; `raw` is the per-service ServiceConfig.
40
45
  *
41
46
  * Stable iteration order matches `Object.entries` (insertion order in v8/modern engines).
42
47
  */
@@ -44,30 +49,31 @@ interface NormalizedService {
44
49
  /** `null` for legacy single-service; the services-map key for stack leases. */
45
50
  name: string | null;
46
51
  /** The per-service object exactly as the spec stores it. No field projection. */
47
- raw: ServiceDef | SingleServiceSpec;
52
+ raw: ServiceConfig | AppDeploySpec;
48
53
  }
49
- declare function normalizeServices(spec: DeploySpec | null | undefined): NormalizedService[];
54
+ declare function normalizeServices(spec: unknown): NormalizedService[];
50
55
  /**
51
56
  * Produce the frozen `SpecSummary` shape for inclusion in the `Plan`
52
57
  * (camelCase fields: `serviceCount`, etc.).
53
58
  *
54
59
  * Port count rules:
55
- * - SingleServiceSpec `port: number` → +1 port.
56
- * - SingleServiceSpec `port: number[]` → +length ports.
57
- * - ServiceDef `ports: number[]` (per type) → +length ports.
58
- * - ServiceDef `ports` shaped as a Record (older codepath) → +key count.
60
+ * - single-service `port: number` → +1 port.
61
+ * - `ServiceConfig.ports` map (canonical `{ '<port>/<proto>': {} }`)
62
+ * +key count.
63
+ * - A bare `port`/`ports` array smuggled in by an `unknown`-cast caller
64
+ * is still counted defensively (+1 / +length) below.
59
65
  *
60
66
  * Env key uniqueness is computed across services (one `env_keys` set
61
67
  * spans the whole spec); `envCount` is the size of that set; `envKeys`
62
68
  * is sorted ascending.
63
69
  */
64
- declare function summarizeSpec(spec: DeploySpec): SpecSummary;
70
+ declare function summarizeSpec(spec: unknown): SpecSummary;
65
71
  /**
66
- * Validate a `DeploySpec` shape pre-broadcast. Throws `TypeError` on the
67
- * first violation. The frozen type union (`SingleServiceSpec | StackSpec`)
68
- * already enforces most structural rules at compile time; this runtime
69
- * check defends against `unknown`-cast callers and `JSON.parse`-decoded
70
- * inputs.
72
+ * Validate an `AppDeploySpec` shape pre-broadcast. Throws `TypeError` on
73
+ * the first violation. The canonical type already enforces most
74
+ * structural rules at compile time; this runtime check defends against
75
+ * `unknown`-cast callers and `JSON.parse`-decoded inputs (and the
76
+ * image-XOR-services invariant the type cannot express).
71
77
  *
72
78
  * Rules (mirror fred's `deployApp.ts` input validation):
73
79
  * - `spec` must be a non-null object.
@@ -79,7 +85,7 @@ declare function summarizeSpec(spec: DeploySpec): SpecSummary;
79
85
  * The high-level `deployApp` in PR 3 layers domain checks on top
80
86
  * (`customDomain` shape, `serviceName` membership, etc.).
81
87
  */
82
- declare function validateSpec(spec: DeploySpec | null | undefined): void;
88
+ declare function validateSpec(spec: unknown): void;
83
89
  //#endregion
84
90
  export { NormalizedService, firstImage, isStackSpec, normalizeServices, summarizeSpec, validateSpec };
85
91
  //# sourceMappingURL=spec-normalize.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"spec-normalize.d.ts","names":[],"sources":["../../src/internals/spec-normalize.ts"],"mappings":";;;;;AAiCA;;;;;;;;;AAEoB;AAiBpB;;;;AAA8D;AAyB9D;;;;;;;iBA5CgB,WAAA,CACd,IAAA,EAAM,UAAA,sBACL,IAAA,IAAQ,SAAS;;AA8CiB;AAGrC;;;;iBAhCgB,UAAA,CAAW,IAAmC,EAA7B,UAAU;;;;AAkCvB;AA6BpB;;;UAtCiB,iBAAA;EAsCmB;EApClC,IAAA;EAoC+C;EAlC/C,GAAA,EAAK,UAAA,GAAa,iBAAiB;AAAA;AAAA,iBAGrB,iBAAA,CACd,IAAA,EAAM,UAAA,sBACL,iBAAiB;;;;AAwF4C;;;;;;;;;;;iBA3DhD,aAAA,CAAc,IAAA,EAAM,UAAA,GAAa,WAAW;;;;;;;;;;;;;;;;;;iBA2D5C,YAAA,CAAa,IAAmC,EAA7B,UAAU"}
1
+ {"version":3,"file":"spec-normalize.d.ts","names":[],"sources":["../../src/internals/spec-normalize.ts"],"mappings":";;;;;AA8BA;;;;;;;;;;;;;;;AAEmE;AAmBnE;;;;AAAwC;AAyBxC;;;;iBA9CgB,WAAA,CACd,IAAA,YACC,IAAA,IAAQ,aAAA;EAAkB,QAAA,EAAU,MAAA,SAAe,aAAA;AAAA;;;AAgDlB;AAGpC;;;iBAhCgB,UAAA,CAAW,IAAa;AAgC2B;AA8BnE;;;;AAAyD;AA2DzD;AAzFmE,UAPlD,iBAAA;;EAEf,IAAA;EA8FwC;EA5FxC,GAAA,EAAK,aAAA,GAAgB,aAAa;AAAA;AAAA,iBAGpB,iBAAA,CAAkB,IAAA,YAAgB,iBAAiB;;;;;;;;;;;;;;;;iBA8BnD,aAAA,CAAc,IAAA,YAAgB,WAAW;;;;;;;;;;;;;;;;;;iBA2DzC,YAAA,CAAa,IAAa"}
@@ -4,28 +4,33 @@
4
4
  * `firstImage`, `normalizeServices`, `summarizeSpec`, and `validateSpec`
5
5
  * (the latter surfaces pre-broadcast shape violations).
6
6
  *
7
- * Two spec shapes are supported (defined in `types.ts`; `size?` added in
8
- * ENG-275):
9
- * - **services-map (StackSpec)** — `{ services: { <name>: ServiceDef }, customDomain?, serviceName?, size? }`
10
- * - **legacy single-service (SingleServiceSpec)** — `{ image, port?, env?, customDomain?, size? }`
7
+ * The single canonical spec type (`AppDeploySpec`, ENG-310) covers two
8
+ * shapes, discriminated by the presence of `services`:
9
+ * - **stack** — `{ services: { <name>: ServiceConfig }, customDomain?, serviceName?, size, ... }`
10
+ * - **single-service** — `{ image, port?, env?, customDomain?, size, ... }`
11
11
  *
12
12
  * `normalizeServices` collapses the two shapes into a single iterable form
13
13
  * so callers (Plan summary, manifest builder, etc.) walk one structure
14
14
  * regardless of which form the user passed.
15
15
  *
16
- * Validation: `validateSpec` throws a plain `TypeError` on shape violations
17
- * agent-core has no workspace dep on `@manifest-network/manifest-mcp-core`
18
- * in PR 1/2 (per parent's REV 1), so `ManifestMCPError` isn't available
19
- * here. PR 3's high-level `deployApp` re-wraps `TypeError` into
20
- * `ManifestMCPError(INVALID_CONFIG)` at the public-API boundary.
16
+ * Validation: `validateSpec` stays a `void` `TypeError`-thrower on shape
17
+ * violations. The high-level `deployApp` entry wrappers
18
+ * (`deploy-app.ts`) map that `TypeError` to
19
+ * `ManifestMCPError(INVALID_CONFIG)` at the public-API boundary, so this
20
+ * helper does not depend on core's error type directly.
21
21
  */
22
22
  /**
23
- * True when `spec` uses the services-map shape (StackSpec). Mirrors
24
- * `_spec.cjs#isStack`: `services` is a non-null, non-array object.
23
+ * True when `spec` uses the services-map (stack) shape. Discriminates on
24
+ * the canonical `services` key being present and defined (ENG-310). The
25
+ * non-null-object / non-array guard is retained as defense-in-depth so an
26
+ * `unknown`-cast caller smuggling `services: null` / `[]` / `'str'` is NOT
27
+ * mistaken for a stack (which would crash `Object.entries` downstream).
25
28
  */
26
29
  function isStackSpec(spec) {
27
30
  if (spec === null || spec === void 0 || typeof spec !== "object") return false;
28
- const services = spec.services;
31
+ const record = spec;
32
+ if (!("services" in record) || record.services === void 0) return false;
33
+ const services = record.services;
29
34
  return services !== null && typeof services === "object" && !Array.isArray(services);
30
35
  }
31
36
  /**
@@ -61,10 +66,11 @@ function normalizeServices(spec) {
61
66
  * (camelCase fields: `serviceCount`, etc.).
62
67
  *
63
68
  * Port count rules:
64
- * - SingleServiceSpec `port: number` → +1 port.
65
- * - SingleServiceSpec `port: number[]` → +length ports.
66
- * - ServiceDef `ports: number[]` (per type) → +length ports.
67
- * - ServiceDef `ports` shaped as a Record (older codepath) → +key count.
69
+ * - single-service `port: number` → +1 port.
70
+ * - `ServiceConfig.ports` map (canonical `{ '<port>/<proto>': {} }`)
71
+ * +key count.
72
+ * - A bare `port`/`ports` array smuggled in by an `unknown`-cast caller
73
+ * is still counted defensively (+1 / +length) below.
68
74
  *
69
75
  * Env key uniqueness is computed across services (one `env_keys` set
70
76
  * spans the whole spec); `envCount` is the size of that set; `envKeys`
@@ -99,11 +105,11 @@ function summarizeSpec(spec) {
99
105
  };
100
106
  }
101
107
  /**
102
- * Validate a `DeploySpec` shape pre-broadcast. Throws `TypeError` on the
103
- * first violation. The frozen type union (`SingleServiceSpec | StackSpec`)
104
- * already enforces most structural rules at compile time; this runtime
105
- * check defends against `unknown`-cast callers and `JSON.parse`-decoded
106
- * inputs.
108
+ * Validate an `AppDeploySpec` shape pre-broadcast. Throws `TypeError` on
109
+ * the first violation. The canonical type already enforces most
110
+ * structural rules at compile time; this runtime check defends against
111
+ * `unknown`-cast callers and `JSON.parse`-decoded inputs (and the
112
+ * image-XOR-services invariant the type cannot express).
107
113
  *
108
114
  * Rules (mirror fred's `deployApp.ts` input validation):
109
115
  * - `spec` must be a non-null object.
@@ -123,7 +129,7 @@ function validateSpec(spec) {
123
129
  if (hasImageKey && hasServicesKey) throw new TypeError("validateSpec: spec has both `image` and `services` keys; these are mutually exclusive (regardless of value validity)");
124
130
  const hasImage = typeof record.image === "string" && record.image.length > 0;
125
131
  const hasServices = isStackSpec(spec);
126
- if (!hasImage && !hasServices) throw new TypeError("validateSpec: spec must declare either `image` (SingleServiceSpec) or `services` (StackSpec)");
132
+ if (!hasImage && !hasServices) throw new TypeError("validateSpec: spec must declare either `image` (single-service) or `services` (stack)");
127
133
  if ("customDomain" in record) {
128
134
  const cd = record.customDomain;
129
135
  if (cd !== void 0) {