@m-kopa/launchpad-cli 0.63.0 → 0.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -19,7 +19,7 @@ var __toESM = (mod, isNodeMode, target) => {
19
19
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
20
20
 
21
21
  // src/version.ts
22
- var CLI_VERSION = "0.63.0";
22
+ var CLI_VERSION = "0.65.0";
23
23
 
24
24
  // src/config.ts
25
25
  import { readFileSync } from "node:fs";
@@ -3363,7 +3363,10 @@ var SpecSchema = z3.object({
3363
3363
  env_vars: z3.record(z3.string().regex(ENV_NAME_REGEX, "env_vars keys must be UPPER_SNAKE_CASE"), EnvVarSchema).optional(),
3364
3364
  containers: z3.array(ContainerSchema).min(1).optional(),
3365
3365
  secrets: SecretsSchema.optional(),
3366
- fronted_by_concierge: z3.boolean().optional()
3366
+ fronted_by_concierge: z3.boolean().optional(),
3367
+ bindings: z3.object({
3368
+ d1: z3.string().min(1).max(64).regex(ENV_NAME_REGEX, "bindings.d1 must be an UPPER_SNAKE_CASE binding name").optional()
3369
+ }).strict().optional()
3367
3370
  }).strict().superRefine((s, ctx) => {
3368
3371
  if (s.access.identity_types !== undefined && (s.auth ?? "access") !== "gateway") {
3369
3372
  ctx.addIssue({
@@ -3432,14 +3435,27 @@ var SpecSchema = z3.object({
3432
3435
  message: "confine_origin has no meaning with origin: dispatch — there is no public pages.dev apex to confine. Remove it."
3433
3436
  });
3434
3437
  }
3435
- for (const [name, envVar] of Object.entries(s.env_vars ?? {})) {
3436
- if (envVar.type === "secret_text") {
3437
- ctx.addIssue({
3438
- code: z3.ZodIssueCode.custom,
3439
- path: ["env_vars", name],
3440
- message: "secret_text env vars are not supported with origin: dispatch until PS-2740 (secrets on v2) provides their runtime consumer."
3441
- });
3442
- }
3438
+ }
3439
+ if (s.bindings?.d1 !== undefined) {
3440
+ if (s.origin !== "dispatch") {
3441
+ ctx.addIssue({
3442
+ code: z3.ZodIssueCode.custom,
3443
+ path: ["bindings", "d1"],
3444
+ message: "bindings.d1 requires origin: dispatch. A pages-origin app declares its database with a v1 pages target d1_binding."
3445
+ });
3446
+ } else if (s.appType !== "react+api") {
3447
+ ctx.addIssue({
3448
+ code: z3.ZodIssueCode.custom,
3449
+ path: ["bindings", "d1"],
3450
+ message: `bindings.d1 requires appType 'react+api' (current appType: '${s.appType}'); only a react+api app ships the Worker code that can read a database.`
3451
+ });
3452
+ }
3453
+ if (s.env_vars?.[s.bindings.d1] !== undefined) {
3454
+ ctx.addIssue({
3455
+ code: z3.ZodIssueCode.custom,
3456
+ path: ["env_vars", s.bindings.d1],
3457
+ message: `env var '${s.bindings.d1}' has the same name as bindings.d1. Rename one of them.`
3458
+ });
3443
3459
  }
3444
3460
  }
3445
3461
  if (!isContainer && s.containers !== undefined) {
@@ -4449,7 +4465,7 @@ function deploymentAuthIssue(appType, auth) {
4449
4465
  function newAppOrigin(appType, requested) {
4450
4466
  if (appType === "container")
4451
4467
  return;
4452
- return requested ?? (appType === "react+api" ? "pages" : "dispatch");
4468
+ return requested ?? "dispatch";
4453
4469
  }
4454
4470
  // src/bundle/boundary.ts
4455
4471
  var MAX_CONTENT_PROBE_BYTES = 1024 * 1024;
@@ -8620,24 +8636,32 @@ function rejectUnsupportedCliRuntime(args) {
8620
8636
  }
8621
8637
 
8622
8638
  // src/commands/migrate.ts
8623
- import { closeSync, existsSync as existsSync7, lstatSync as lstatSync4, mkdirSync as mkdirSync2, mkdtempSync, openSync, readFileSync as readFileSync11, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
8639
+ import { closeSync, existsSync as existsSync7, lstatSync as lstatSync4, mkdirSync as mkdirSync2, mkdtempSync, openSync, readdirSync as readdirSync2, readFileSync as readFileSync11, renameSync, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
8624
8640
  import path8 from "node:path";
8625
8641
  import { stringify } from "yaml";
8626
8642
 
8627
8643
  // src/manifest/migrate-dispatch.ts
8628
- function dispatchManifest(manifest) {
8644
+ var PLATFORM_ENV_NAMES = new Set([
8645
+ "GATEWAY_ISSUER",
8646
+ "GATEWAY_AUD",
8647
+ "GATEWAY_JWKS_URL",
8648
+ "CF_ACCESS_TEAM_DOMAIN",
8649
+ "CF_ACCESS_AUD",
8650
+ "JWKS_URL"
8651
+ ]);
8652
+ function dispatchManifest(manifest, wranglerD1) {
8629
8653
  const v2 = manifest.apiVersion === "launchpad.m-kopa.us/v2";
8630
8654
  const source = v2 ? manifest.spec : manifest;
8631
8655
  const appType = v2 ? manifest.spec.appType : manifest.deployment.type;
8632
- if (appType !== "static" && appType !== "react") {
8633
- throw new Error("Only static and React apps can use this converter. Pages Functions (react+api) need a compiled Worker build before switching to dispatch; containers use their own runtime.");
8634
- }
8635
- if (source.secrets !== undefined || source.containers !== undefined) {
8636
- throw new Error("Secret and container bindings cannot be converted automatically.");
8656
+ if (appType === "container" || source.containers !== undefined) {
8657
+ throw new Error("Containers use their own runtime and cannot convert to dispatch.");
8637
8658
  }
8638
8659
  if (!v2)
8639
8660
  assertConvertibleV1(manifest);
8640
- const spec = v2 ? { ...manifest.spec } : {
8661
+ const d1 = d1Binding(v2 ? manifest.spec.bindings?.d1 : pagesTargetD1(manifest), wranglerD1);
8662
+ const plainEnv = v2 ? manifest.spec.env_vars : plainTextEnv(manifest.production_env);
8663
+ const envVars = { ...plainEnv, ...secretEnv(source.secrets, plainEnv) };
8664
+ const spec = v2 ? { ...manifest.spec, secrets: undefined } : {
8641
8665
  appType,
8642
8666
  display: {
8643
8667
  name: manifest.metadata.name,
@@ -8655,16 +8679,20 @@ function dispatchManifest(manifest) {
8655
8679
  app: manifest.app,
8656
8680
  verify: manifest.verify,
8657
8681
  sensitive: manifest.sensitive,
8658
- catalogue: manifest.catalogue,
8659
- ...manifest.production_env === undefined ? {} : {
8660
- env_vars: Object.fromEntries(Object.entries(manifest.production_env).map(([name, value]) => [name, { type: "plain_text", value }]))
8661
- }
8682
+ catalogue: manifest.catalogue
8662
8683
  };
8663
8684
  const candidate = {
8664
8685
  apiVersion: "launchpad.m-kopa.us/v2",
8665
8686
  kind: "App",
8666
8687
  metadata: v2 ? manifest.metadata : { slug: manifest.metadata.name, schemaVersion: 2 },
8667
- spec: { ...spec, origin: "dispatch", auth: "gateway", confine_origin: undefined }
8688
+ spec: {
8689
+ ...spec,
8690
+ ...d1 === undefined ? {} : { bindings: { d1 } },
8691
+ env_vars: Object.keys(envVars).length === 0 ? undefined : envVars,
8692
+ origin: "dispatch",
8693
+ auth: "gateway",
8694
+ confine_origin: undefined
8695
+ }
8668
8696
  };
8669
8697
  const parsed = parseManifestV2(candidate);
8670
8698
  if (parsed.kind !== "ok") {
@@ -8675,7 +8703,7 @@ function dispatchManifest(manifest) {
8675
8703
  function assertConvertibleV1(manifest) {
8676
8704
  const unsupported2 = [
8677
8705
  manifest.hostnames !== undefined && "hostnames",
8678
- manifest.targets !== undefined && "targets",
8706
+ manifest.targets !== undefined && pagesTargetD1(manifest) === undefined && "targets",
8679
8707
  manifest.access.session_duration !== undefined && "access.session_duration",
8680
8708
  manifest.deployment.runtime !== undefined && "deployment.runtime"
8681
8709
  ].filter(Boolean);
@@ -8683,12 +8711,224 @@ function assertConvertibleV1(manifest) {
8683
8711
  throw new Error(`Cannot preserve ${unsupported2.join(", ")} on dispatch. Review these settings with the platform team before migrating.`);
8684
8712
  }
8685
8713
  }
8714
+ function d1Binding(declared, wranglerD1) {
8715
+ if (declared !== undefined && wranglerD1 !== undefined && declared !== wranglerD1) {
8716
+ throw new Error(`The manifest binds D1 as ${declared} but the wrangler config binds ${wranglerD1}. Use one name for both.`);
8717
+ }
8718
+ return declared ?? wranglerD1;
8719
+ }
8720
+ function plainTextEnv(env) {
8721
+ if (env === undefined)
8722
+ return;
8723
+ return Object.fromEntries(Object.entries(env).map(([name, value]) => [name, { type: "plain_text", value }]));
8724
+ }
8725
+ function secretEnv(secrets, env) {
8726
+ const out = {};
8727
+ for (const binding of secrets?.bindings ?? []) {
8728
+ const refusal = secretRefusal(binding, env);
8729
+ if (refusal !== undefined)
8730
+ throw new Error(`Secret ${binding.name} ${refusal}`);
8731
+ out[binding.name] = { type: "secret_text", secret_ref: binding.name };
8732
+ }
8733
+ return out;
8734
+ }
8735
+ function secretRefusal(binding, env) {
8736
+ const targets = binding.targets === undefined || binding.targets === "all" ? [] : binding.targets;
8737
+ const checks = [
8738
+ [binding.source !== "env-file", "is platform-managed and cannot be converted automatically. Ask the platform team to migrate it."],
8739
+ [targets.some((target) => target !== "primary"), "targets another surface, which dispatch does not have."],
8740
+ [PLATFORM_ENV_NAMES.has(binding.name), "is set by the platform on dispatch apps. Rename it before converting."],
8741
+ [env?.[binding.name] !== undefined, "is also declared as an environment variable. Keep one."]
8742
+ ];
8743
+ return checks.find(([refused]) => refused)?.[1];
8744
+ }
8745
+ function pagesTargetD1(manifest) {
8746
+ const [target, ...rest] = manifest.targets ?? [];
8747
+ if (rest.length > 0 || target?.kind !== "pages")
8748
+ return;
8749
+ return target.d1_binding;
8750
+ }
8751
+
8752
+ // src/manifest/wrangler-config.ts
8753
+ var ALLOWED_KEYS = new Set([
8754
+ "name",
8755
+ "compatibility_date",
8756
+ "compatibility_flags",
8757
+ "pages_build_output_dir",
8758
+ "d1_databases"
8759
+ ]);
8760
+ var PLATFORM_FLAGS = new Set(["nodejs_compat"]);
8761
+
8762
+ class UnsupportedWranglerConfigError extends Error {
8763
+ }
8764
+ function wranglerD1Binding(file, text) {
8765
+ const toml = file.endsWith(".toml");
8766
+ const config = toml ? readToml(file, text) : readJsonc(file, text);
8767
+ for (const key of Object.keys(config)) {
8768
+ if (!ALLOWED_KEYS.has(key) && (toml || key !== "$schema"))
8769
+ throw unsupportedKey(file, key);
8770
+ }
8771
+ if (config.compatibility_flags !== undefined) {
8772
+ const extra = asArray(file, "compatibility_flags", config.compatibility_flags).find((flag) => !PLATFORM_FLAGS.has(String(flag)));
8773
+ if (extra !== undefined) {
8774
+ throw new UnsupportedWranglerConfigError(`${file} sets compatibility flag \`${String(extra)}\`; dispatch runs with nodejs_compat only`);
8775
+ }
8776
+ }
8777
+ if (config.d1_databases === undefined)
8778
+ return;
8779
+ const databases = asArray(file, "d1_databases", config.d1_databases);
8780
+ if (databases.length !== 1) {
8781
+ throw new UnsupportedWranglerConfigError(`${file} declares ${databases.length} \`d1_databases\` entries; dispatch binds exactly one D1 database`);
8782
+ }
8783
+ const binding = databases[0]?.binding;
8784
+ if (typeof binding !== "string" || binding === "") {
8785
+ throw new UnsupportedWranglerConfigError(`${file} has a \`d1_databases\` entry without a binding name`);
8786
+ }
8787
+ return binding;
8788
+ }
8789
+ function unsupportedKey(file, key) {
8790
+ return new UnsupportedWranglerConfigError(`${file} sets \`${key}\`, which dispatch cannot preserve`);
8791
+ }
8792
+ function asArray(file, key, value) {
8793
+ if (!Array.isArray(value))
8794
+ throw new UnsupportedWranglerConfigError(`${file} sets \`${key}\` to something other than a list`);
8795
+ return value;
8796
+ }
8797
+ function readJsonc(file, text) {
8798
+ const stripped = stripOutsideStrings(text, '"', (at) => {
8799
+ if (text.startsWith("//", at))
8800
+ return lineEnd(text, at) - at;
8801
+ if (!text.startsWith("/*", at))
8802
+ return 0;
8803
+ const end = text.indexOf("*/", at + 2);
8804
+ return (end === -1 ? text.length : end + 2) - at;
8805
+ }).replace(/,(\s*[}\]])/g, "$1");
8806
+ let parsed;
8807
+ try {
8808
+ parsed = JSON.parse(stripped);
8809
+ } catch {
8810
+ throw new UnsupportedWranglerConfigError(`${file} could not be read as JSON`);
8811
+ }
8812
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
8813
+ throw new UnsupportedWranglerConfigError(`${file} is not a JSON object`);
8814
+ }
8815
+ return parsed;
8816
+ }
8817
+ function readToml(file, text) {
8818
+ const config = {};
8819
+ const databases = [];
8820
+ let table = config;
8821
+ for (const line of logicalTomlLines(text)) {
8822
+ if (line.startsWith("[")) {
8823
+ table = d1Table(file, line);
8824
+ databases.push(table);
8825
+ } else {
8826
+ assign(file, table, line, table === config);
8827
+ }
8828
+ }
8829
+ if (databases.length === 0)
8830
+ return config;
8831
+ if (config.d1_databases !== undefined)
8832
+ throw new UnsupportedWranglerConfigError(`${file} declares d1_databases twice`);
8833
+ return { ...config, d1_databases: databases };
8834
+ }
8835
+ function d1Table(file, line) {
8836
+ const name = /^\[\[?\s*([^\]]*?)\s*\]\]?$/.exec(line)?.[1] ?? line;
8837
+ if (!line.startsWith("[[") || name !== "d1_databases")
8838
+ throw unsupportedKey(file, topKey(name));
8839
+ return {};
8840
+ }
8841
+ function assign(file, table, line, topLevel) {
8842
+ const assignment = /^([\w\-."]+)\s*=\s*(.*)$/.exec(line);
8843
+ if (assignment === null)
8844
+ throw new UnsupportedWranglerConfigError(`${file} could not be read as TOML near \`${line.slice(0, 40)}\``);
8845
+ const key = topKey(assignment[1]);
8846
+ if (topLevel && assignment[1].includes("."))
8847
+ throw unsupportedKey(file, key);
8848
+ table[key] = tomlValue(assignment[2].trim());
8849
+ }
8850
+ function topKey(dotted) {
8851
+ return dotted.split(".")[0].replace(/"/g, "").trim();
8852
+ }
8853
+ function tomlValue(raw) {
8854
+ const quoted = /^"((?:[^"\\]|\\.)*)"$|^'([^']*)'$/.exec(raw);
8855
+ if (quoted !== null)
8856
+ return quoted[1] ?? quoted[2];
8857
+ if (!raw.startsWith("["))
8858
+ return raw;
8859
+ const inner = raw.slice(1, -1);
8860
+ if (inner.includes("{")) {
8861
+ return [...inner.matchAll(/\{([^}]*)\}/g)].map((table) => ({
8862
+ binding: /(?:^|[\s,])binding\s*=\s*["']([^"']*)["']/.exec(table[1])?.[1]
8863
+ }));
8864
+ }
8865
+ return [...inner.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g)].map((item) => item[1] ?? item[2]);
8866
+ }
8867
+ function logicalTomlLines(text) {
8868
+ const stripped = stripOutsideStrings(text, `"'`, (at) => text[at] === "#" ? lineEnd(text, at) - at : 0);
8869
+ const lines = [];
8870
+ let pending = "";
8871
+ for (const raw of stripped.split(/\r?\n/)) {
8872
+ pending = `${pending} ${raw.trim()}`.trim();
8873
+ if (pending === "" || openBrackets(pending) > 0)
8874
+ continue;
8875
+ lines.push(pending);
8876
+ pending = "";
8877
+ }
8878
+ if (pending !== "")
8879
+ lines.push(pending);
8880
+ return lines;
8881
+ }
8882
+ function lineEnd(text, at) {
8883
+ const end = text.indexOf(`
8884
+ `, at);
8885
+ return end === -1 ? text.length : end;
8886
+ }
8887
+ function openBrackets(line) {
8888
+ if (/^\[\[?[^\]=]*\]\]?$/.test(line))
8889
+ return 0;
8890
+ let open = 0;
8891
+ stripOutsideStrings(line, `"'`, (at) => {
8892
+ if (line[at] === "[" || line[at] === "{")
8893
+ open++;
8894
+ if (line[at] === "]" || line[at] === "}")
8895
+ open--;
8896
+ return 0;
8897
+ });
8898
+ return open;
8899
+ }
8900
+ function stripOutsideStrings(text, quotes, skip) {
8901
+ let out = "";
8902
+ for (let i = 0;i < text.length; ) {
8903
+ if (quotes.includes(text[i])) {
8904
+ const end = stringEnd(text, i);
8905
+ out += text.slice(i, end);
8906
+ i = end;
8907
+ continue;
8908
+ }
8909
+ const skipped = skip(i);
8910
+ if (skipped === 0)
8911
+ out += text[i];
8912
+ i += Math.max(skipped, 1);
8913
+ }
8914
+ return out;
8915
+ }
8916
+ function stringEnd(text, start) {
8917
+ const quote = text[start];
8918
+ for (let i = start + 1;i < text.length; i++) {
8919
+ if (text[i] === "\\" && quote === '"')
8920
+ i++;
8921
+ else if (text[i] === quote)
8922
+ return i + 1;
8923
+ }
8924
+ return text.length;
8925
+ }
8686
8926
 
8687
8927
  // src/commands/migrate.ts
8688
8928
  var USAGE = "usage: launchpad migrate --origin dispatch [--dry-run]";
8689
8929
  var migrateCommand = {
8690
8930
  name: "migrate",
8691
- summary: "convert an unprovisioned static or React app to Workers dispatch",
8931
+ summary: "convert an unprovisioned static, React or React API app to Workers dispatch",
8692
8932
  run: runMigrate
8693
8933
  };
8694
8934
  async function runMigrate(args, io) {
@@ -8711,6 +8951,9 @@ async function runMigrate(args, io) {
8711
8951
  io.out(`${result.slug}: launchpad.yaml now uses v2 Workers dispatch with gateway authentication.`);
8712
8952
  io.out(`Audience, owner and application settings are preserved. Original: ${BACKUP_PATH}`);
8713
8953
  io.out("Run `launchpad deploy`. No Pages project is needed.");
8954
+ const next = secretsNextStep(result.secrets);
8955
+ if (next !== null)
8956
+ io.out(next);
8714
8957
  }
8715
8958
  return 0;
8716
8959
  } catch (error) {
@@ -8719,6 +8962,11 @@ async function runMigrate(args, io) {
8719
8962
  }
8720
8963
  }
8721
8964
  var BACKUP_PATH = ".launchpad/manifest-before-dispatch.yaml";
8965
+ function secretsNextStep(secrets) {
8966
+ if (secrets.length === 0)
8967
+ return null;
8968
+ return `Secrets (${secrets.join(", ")}) carry over as secret_text env vars. Once \`launchpad status\` shows the first deploy delivered, run \`launchpad secrets push\` to set them.`;
8969
+ }
8722
8970
  async function convertToDispatch(cwd, dryRun) {
8723
8971
  const file = path8.join(cwd, "launchpad.yaml");
8724
8972
  if (!lstatSync4(file).isFile())
@@ -8730,18 +8978,38 @@ async function convertToDispatch(cwd, dryRun) {
8730
8978
  const manifest = loaded.manifest;
8731
8979
  if (manifest.apiVersion === "launchpad.m-kopa.us/v2" && manifest.spec.origin === "dispatch")
8732
8980
  return { kind: "already" };
8733
- const converted = dispatchManifest(manifest);
8734
- assertNoCustomRuntime(cwd, converted);
8735
- const slug = converted.metadata.slug;
8981
+ const slug = manifest.apiVersion === "launchpad.m-kopa.us/v2" ? manifest.metadata.slug : manifest.metadata.name;
8982
+ let converted;
8983
+ try {
8984
+ converted = convertLocally(cwd, manifest);
8985
+ } catch (error) {
8986
+ await refuseIfRegistered(slug);
8987
+ throw error;
8988
+ }
8736
8989
  await assertUnregistered(slug);
8990
+ const secrets = Object.entries(converted.spec.env_vars ?? {}).filter(([, value]) => value.type === "secret_text").map(([name]) => name);
8737
8991
  if (dryRun)
8738
- return { kind: "eligible", slug };
8992
+ return { kind: "eligible", slug, secrets };
8739
8993
  try {
8740
8994
  saveMigration(file, original, converted);
8741
8995
  } catch (error) {
8742
8996
  throw new MigrationSaveError(error);
8743
8997
  }
8744
- return { kind: "converted", slug };
8998
+ return { kind: "converted", slug, secrets };
8999
+ }
9000
+ function convertLocally(cwd, manifest) {
9001
+ const v2 = manifest.apiVersion === "launchpad.m-kopa.us/v2";
9002
+ const spec = v2 ? manifest.spec : manifest;
9003
+ const appType = v2 ? manifest.spec.appType : manifest.deployment.type;
9004
+ return dispatchManifest(manifest, inspectRuntime(cwd, appType, spec.app?.root, spec.build));
9005
+ }
9006
+ async function refuseIfRegistered(slug) {
9007
+ try {
9008
+ await assertUnregistered(slug);
9009
+ } catch (error) {
9010
+ if (error instanceof RegisteredAppError)
9011
+ throw error;
9012
+ }
8745
9013
  }
8746
9014
  async function assertUnregistered(slug) {
8747
9015
  try {
@@ -8773,23 +9041,54 @@ class RegisteredAppError extends Error {
8773
9041
  super("This app is already registered. This command only converts apps that have never provisioned; ask the platform team to migrate an existing app.");
8774
9042
  }
8775
9043
  }
8776
- function assertNoCustomRuntime(cwd, manifest) {
8777
- const appRoot = path8.resolve(cwd, manifest.spec.app?.root ?? ".");
8778
- const buildRoot = path8.resolve(appRoot, manifest.spec.build?.root_dir ?? ".");
8779
- const roots = new Set([
9044
+ var WRANGLER_FILES = ["wrangler.toml", "wrangler.json", "wrangler.jsonc"];
9045
+ function inspectRuntime(cwd, appType, appRootDir, build) {
9046
+ const d1 = new Set;
9047
+ for (const root of runtimeRoots(cwd, appRootDir, build)) {
9048
+ assertNoUncompiledCode(root, appType);
9049
+ for (const binding2 of wranglerD1Bindings(cwd, root))
9050
+ d1.add(binding2);
9051
+ }
9052
+ if (d1.size > 1)
9053
+ throw new Error(`The wrangler configs bind D1 under different names (${[...d1].join(", ")}). Use one name.`);
9054
+ const [binding] = d1;
9055
+ if (binding !== undefined && appType !== "react+api") {
9056
+ throw new Error("The wrangler config declares a D1 database, which only react+api apps can use on dispatch.");
9057
+ }
9058
+ return binding;
9059
+ }
9060
+ function runtimeRoots(cwd, appRootDir, build) {
9061
+ const appRoot = path8.resolve(cwd, appRootDir ?? ".");
9062
+ const buildRoot = path8.resolve(appRoot, build?.root_dir ?? ".");
9063
+ return new Set([
8780
9064
  cwd,
8781
9065
  appRoot,
8782
9066
  buildRoot,
8783
- path8.resolve(cwd, manifest.spec.build?.root_dir ?? "."),
8784
- path8.resolve(buildRoot, manifest.spec.build?.destination_dir ?? "dist")
9067
+ path8.resolve(cwd, build?.root_dir ?? "."),
9068
+ path8.resolve(buildRoot, build?.destination_dir ?? "dist")
8785
9069
  ]);
8786
- for (const root of roots) {
8787
- for (const name of ["functions", "_worker.js", "wrangler.toml", "wrangler.json", "wrangler.jsonc"]) {
8788
- if (existsSync7(path8.join(root, name))) {
8789
- throw new Error(`${name} needs a runtime review before conversion. Pages Functions and custom Worker bindings must not be dropped. Ask the platform team to review this app.`);
8790
- }
9070
+ }
9071
+ function assertNoUncompiledCode(root, appType) {
9072
+ const api = appType === "react+api";
9073
+ for (const name of api ? ["_worker.js"] : ["_worker.js", "functions"]) {
9074
+ if (existsSync7(path8.join(root, name))) {
9075
+ throw new Error(`${name} needs a runtime review before conversion. Pages Functions and custom Worker code must not be dropped. Ask the platform team to review this app.`);
8791
9076
  }
8792
9077
  }
9078
+ if (api)
9079
+ assertWorkerRoutesFit(root);
9080
+ }
9081
+ function wranglerD1Bindings(cwd, root) {
9082
+ return WRANGLER_FILES.map((name) => path8.join(root, name)).filter((file) => existsSync7(file)).map((file) => wranglerD1Binding(path8.relative(cwd, file), readFileSync11(file, "utf8"))).filter((binding) => binding !== undefined);
9083
+ }
9084
+ function assertWorkerRoutesFit(root) {
9085
+ const functions = path8.join(root, "functions");
9086
+ if (!existsSync7(functions) || !lstatSync4(functions).isDirectory())
9087
+ return;
9088
+ const catchAll = readdirSync2(functions).find((name) => /^_middleware\.[cm]?[jt]sx?$/.test(name) || name.startsWith("["));
9089
+ if (catchAll !== undefined) {
9090
+ throw new Error(`functions/${catchAll} would run the Worker for every path, including static assets, which dispatch does not support. Move it under a prefix such as functions/api/.`);
9091
+ }
8793
9092
  }
8794
9093
  function saveMigration(file, original, converted) {
8795
9094
  if (readFileSync11(file, "utf8") !== original)
@@ -9602,24 +9901,12 @@ function isIneligible(e) {
9602
9901
  return !(e instanceof RegistryUnavailableError || e instanceof MigrationSaveError || e instanceof UpgradeRequiredError || e instanceof UnauthenticatedError);
9603
9902
  }
9604
9903
  async function defaultToDispatch(cwd, manifestPath, io) {
9605
- let raw;
9606
- try {
9607
- raw = parseYaml6(readFileSync12(manifestPath, "utf8"));
9608
- } catch {
9609
- return null;
9610
- }
9611
- const isV22 = raw?.apiVersion === "launchpad.m-kopa.us/v2";
9612
- const appType = isV22 ? raw?.spec?.appType : raw?.deployment?.type;
9613
- const origin = isV22 ? raw?.spec?.origin : raw?.origin;
9614
- const auth = isV22 ? raw?.spec?.auth : raw?.auth;
9615
- if (appType !== "static" && appType !== "react" || origin !== undefined || auth !== "gateway")
9904
+ if (!impliesPagesForGateway(manifestPath))
9616
9905
  return null;
9617
9906
  try {
9618
- if ((await convertToDispatch(cwd, false)).kind === "converted") {
9619
- io.out("New apps deploy on Workers dispatch by default, so launchpad.yaml was converted (no Pages project needed).");
9620
- io.out(` Audience, owner and build settings are preserved. Original: ${BACKUP_PATH}`);
9621
- io.out(" To stay on Pages instead, set `spec.origin: pages` and deploy again.");
9622
- }
9907
+ const result = await convertToDispatch(cwd, false);
9908
+ if (result.kind === "converted")
9909
+ reportConversion(io, result.secrets);
9623
9910
  return null;
9624
9911
  } catch (e) {
9625
9912
  if (e instanceof RegisteredAppError)
@@ -9629,6 +9916,30 @@ async function defaultToDispatch(cwd, manifestPath, io) {
9629
9916
  return `Not converted to Workers dispatch: ${describe17(e)}`;
9630
9917
  }
9631
9918
  }
9919
+ var CONVERTIBLE_APP_TYPES = new Set(["static", "react", "react+api"]);
9920
+ function impliesPagesForGateway(manifestPath) {
9921
+ const fields = originFields(manifestPath);
9922
+ return CONVERTIBLE_APP_TYPES.has(fields.appType) && fields.origin === undefined && fields.auth === "gateway";
9923
+ }
9924
+ function originFields(manifestPath) {
9925
+ let raw;
9926
+ try {
9927
+ raw = parseYaml6(readFileSync12(manifestPath, "utf8")) ?? {};
9928
+ } catch {
9929
+ return {};
9930
+ }
9931
+ if (raw.apiVersion === "launchpad.m-kopa.us/v2")
9932
+ return raw.spec ?? {};
9933
+ return { origin: raw.origin, auth: raw.auth, appType: raw.deployment?.type };
9934
+ }
9935
+ function reportConversion(io, secrets) {
9936
+ io.out("New apps deploy on Workers dispatch by default, so launchpad.yaml was converted (no Pages project needed).");
9937
+ io.out(` Audience, owner and build settings are preserved. Original: ${BACKUP_PATH}`);
9938
+ io.out(` To stay on Pages instead, restore ${BACKUP_PATH} as launchpad.yaml, set \`origin: pages\` (v1) or \`spec.origin: pages\` (v2) and deploy again.`);
9939
+ const next = secretsNextStep(secrets);
9940
+ if (next !== null)
9941
+ io.out(` ${next}`);
9942
+ }
9632
9943
  function declaredOriginOf(isV22, origin) {
9633
9944
  if (!isV22)
9634
9945
  return "pages";
@@ -11084,10 +11395,6 @@ async function runInit(args, io, prompt, identity = () => apiJson(loadConfig(),
11084
11395
  return 64;
11085
11396
  }
11086
11397
  const dispatch = newAppOrigin(fillResult.inputs.type, inputs.origin) === "dispatch";
11087
- if (dispatch && fillResult.inputs.type === "react+api") {
11088
- io.err("launchpad init: API starter dispatch is not available yet; use --origin pages");
11089
- return 64;
11090
- }
11091
11398
  if (dispatch && (inputs.hostnames.length > 0 || inputs.sessionDuration !== undefined)) {
11092
11399
  io.err("launchpad init: dispatch does not support --hostname or --session-duration; use --origin pages for a Pages manifest");
11093
11400
  return 64;
@@ -11159,11 +11466,10 @@ async function runInit(args, io, prompt, identity = () => apiJson(loadConfig(),
11159
11466
  io.out("");
11160
11467
  io.out("Next steps:");
11161
11468
  io.out(" launchpad validate # confirm the manifest");
11162
- if (!dispatch)
11163
- io.out(" launchpad secrets template # scaffold .env.example");
11469
+ io.out(" launchpad secrets template # scaffold .env.example");
11164
11470
  io.out(" launchpad deploy # build and deploy the app");
11165
11471
  if (dispatch)
11166
- io.out(" Dispatch currently supports plain-text environment variables, but not secret bindings.");
11472
+ io.out(" Push secrets with `launchpad secrets push` once the first deploy has been delivered.");
11167
11473
  return 0;
11168
11474
  }
11169
11475
  function parseArgs6(args) {
@@ -11215,11 +11521,11 @@ function parseArgs6(args) {
11215
11521
  i++;
11216
11522
  return next;
11217
11523
  };
11218
- const setFromNext = (assign) => {
11524
+ const setFromNext = (assign2) => {
11219
11525
  const got = needsValue();
11220
11526
  if (typeof got !== "string")
11221
11527
  return got;
11222
- assign(got);
11528
+ assign2(got);
11223
11529
  return;
11224
11530
  };
11225
11531
  if (a === "--name") {
@@ -12096,6 +12402,20 @@ function describe25(e) {
12096
12402
 
12097
12403
  // src/commands/plan.ts
12098
12404
  import { resolve as resolve12 } from "node:path";
12405
+
12406
+ // src/manifest/container-runtime-warnings.ts
12407
+ var CONTAINER_RUNTIME_GATE_WARNING = "Container apps are gated by the platform runtime switch; `launchpad deploy` may return " + "503 container_runtime_off until the platform team enables your app. See docs/concepts/containers.md.";
12408
+ var RUNTIME_ACA_RESERVED_WARNING = "deployment.runtime: aca is reserved and currently ignored — the app runs on cloudflare-containers " + "regardless. See docs/concepts/containers.md.";
12409
+ function containerRuntimeWarnings(view) {
12410
+ if (view.deploymentType !== "container")
12411
+ return [];
12412
+ const warnings = [CONTAINER_RUNTIME_GATE_WARNING];
12413
+ if (view.runtime === "aca")
12414
+ warnings.push(RUNTIME_ACA_RESERVED_WARNING);
12415
+ return warnings;
12416
+ }
12417
+
12418
+ // src/commands/plan.ts
12099
12419
  var planCommand = {
12100
12420
  name: "plan",
12101
12421
  summary: "summarise what the manifest would deploy (offline)",
@@ -12127,7 +12447,15 @@ function renderJson(result, io) {
12127
12447
  switch (result.kind) {
12128
12448
  case "ok": {
12129
12449
  const plan = derivePlan(result.manifest);
12130
- io.out(JSON.stringify({ ok: true, ...plan }));
12450
+ const warnings = containerRuntimeWarnings({
12451
+ deploymentType: plan.desired.app.deploymentType,
12452
+ runtime: plan.desired.app.runtime
12453
+ });
12454
+ io.out(JSON.stringify({
12455
+ ok: true,
12456
+ ...plan,
12457
+ ...warnings.length > 0 ? { warnings } : {}
12458
+ }));
12131
12459
  return 0;
12132
12460
  }
12133
12461
  case "file-not-found":
@@ -12237,6 +12565,12 @@ function renderPlan(m, io) {
12237
12565
  }
12238
12566
  io.out("");
12239
12567
  }
12568
+ for (const w of containerRuntimeWarnings({
12569
+ deploymentType: desired.app.deploymentType,
12570
+ runtime: desired.app.runtime
12571
+ })) {
12572
+ io.out(`⚠ ${w}`);
12573
+ }
12240
12574
  const totalBindings = desired.secrets.envFile.length + desired.secrets.platformManaged.length;
12241
12575
  io.out(`Secrets (${totalBindings})`);
12242
12576
  if (totalBindings === 0) {
@@ -13974,6 +14308,22 @@ function parseEnv(text) {
13974
14308
  return { entries, invalidNames, malformedLines };
13975
14309
  }
13976
14310
 
14311
+ // src/secrets/declared.ts
14312
+ function declaredSecrets(loaded) {
14313
+ if (loaded.schema === "launchpad.m-kopa.us/v1alpha1") {
14314
+ const m2 = loaded.manifest;
14315
+ return { slug: m2.metadata.name, apiVersion: m2.apiVersion, bindings: m2.secrets?.bindings ?? [] };
14316
+ }
14317
+ const m = loaded.manifest;
14318
+ const envVars = m.spec.appType === "container" ? {} : m.spec.env_vars ?? {};
14319
+ const fromEnvVars = Object.entries(envVars).filter(([, v]) => v.type === "secret_text").map(([name]) => ({ name, source: "env-file" }));
14320
+ return {
14321
+ slug: m.metadata.slug,
14322
+ apiVersion: m.apiVersion,
14323
+ bindings: [...m.spec.secrets?.bindings ?? [], ...fromEnvVars]
14324
+ };
14325
+ }
14326
+
13977
14327
  // src/secrets/push.ts
13978
14328
  async function runSecretsPush(opts, io, deps = {}) {
13979
14329
  const cfg = deps.config ?? loadConfig();
@@ -13983,14 +14333,10 @@ async function runSecretsPush(opts, io, deps = {}) {
13983
14333
  if (loaded.kind !== "ok") {
13984
14334
  return renderManifestError4(loaded, io);
13985
14335
  }
13986
- const v2Refusal = refuseV2Manifest(loaded, "launchpad secrets push", io);
13987
- if (v2Refusal !== null)
13988
- return v2Refusal;
13989
- const manifest = loaded.manifest;
13990
- const bindings = manifest.secrets?.bindings ?? [];
14336
+ const { slug, bindings } = declaredSecrets(loaded);
13991
14337
  const envFileBindings = bindings.filter((b) => b.source === "env-file");
13992
14338
  if (envFileBindings.length === 0) {
13993
- io.out("No `env-file` secret bindings declared in the manifest.");
14339
+ io.out("No secrets to push: the manifest declares no `env-file` bindings or `secret_text` env vars.");
13994
14340
  io.out("(platform-managed bindings are pushed via the platform-team ops path.)");
13995
14341
  return 0;
13996
14342
  }
@@ -14069,11 +14415,11 @@ async function runSecretsPush(opts, io, deps = {}) {
14069
14415
  io.out("Re-run without --dry-run to push.");
14070
14416
  return 0;
14071
14417
  }
14072
- const slug = manifest.metadata.name;
14073
14418
  let pushed = 0;
14074
14419
  let partial = 0;
14075
14420
  let anyPagesUpdated = false;
14076
14421
  let anyWorkerUpdated = false;
14422
+ let anyDispatchUpdated = false;
14077
14423
  for (const b of envFileBindings) {
14078
14424
  const value = parsed.entries.get(b.name);
14079
14425
  io.out(`Pushing ${b.name} …`);
@@ -14094,6 +14440,11 @@ async function runSecretsPush(opts, io, deps = {}) {
14094
14440
  io.err(`✗ secrets push ${b.name}: invalid bot response (results must be an array)`);
14095
14441
  return 2;
14096
14442
  }
14443
+ if (resp.error === "dispatch_script_not_delivered") {
14444
+ io.err(`✗ secrets push ${b.name}: ${resp.message ?? "the dispatch script is not delivered yet"}`);
14445
+ io.err(`${pushed} of ${envFileBindings.length} pushed before failure.`);
14446
+ return 1;
14447
+ }
14097
14448
  const results = resp.results ?? [];
14098
14449
  for (const r of results) {
14099
14450
  if (r.status !== "updated")
@@ -14102,6 +14453,8 @@ async function runSecretsPush(opts, io, deps = {}) {
14102
14453
  anyPagesUpdated = true;
14103
14454
  else if (r.kind === "worker")
14104
14455
  anyWorkerUpdated = true;
14456
+ else if (r.kind === "dispatch")
14457
+ anyDispatchUpdated = true;
14105
14458
  }
14106
14459
  const notUpdated = results.filter((r) => r.status !== "updated");
14107
14460
  if (resp.updated === true && notUpdated.length === 0) {
@@ -14125,7 +14478,9 @@ async function runSecretsPush(opts, io, deps = {}) {
14125
14478
  if (anyPagesUpdated) {
14126
14479
  return offerRedeploy(slug, opts, io, deps);
14127
14480
  }
14128
- if (anyWorkerUpdated) {
14481
+ if (anyDispatchUpdated) {
14482
+ io.out("Set on the app's dispatch Worker and live now - no redeploy needed.");
14483
+ } else if (anyWorkerUpdated) {
14129
14484
  io.out("These secrets bind on the app's next invocation — no redeploy needed.");
14130
14485
  }
14131
14486
  return 0;
@@ -14194,20 +14549,6 @@ function mapPushError(e, slug, name, io) {
14194
14549
  function describe30(e) {
14195
14550
  return e instanceof Error ? e.message : String(e);
14196
14551
  }
14197
- function refuseV2Manifest(loaded, command, io) {
14198
- if (loaded.schema !== "launchpad.m-kopa.us/v2")
14199
- return null;
14200
- const origin = loaded.manifest.spec?.origin;
14201
- if (origin === "dispatch") {
14202
- io.err(`${command}: this is a dispatch-origin (v2) app.`);
14203
- const outcome = command.endsWith("push") ? "nothing was pushed" : "nothing was read";
14204
- io.err(` Secrets are not delivered to the dispatch namespace yet (PS-2740); ${outcome}.`);
14205
- return 1;
14206
- }
14207
- io.err(`${command}: this is a launchpad.m-kopa.us/v2 manifest.`);
14208
- io.err(" This command reads v1alpha1 `secrets.bindings`; v2 declares secrets as `spec.env_vars` `secret_text` entries, which it does not push yet.");
14209
- return 1;
14210
- }
14211
14552
  function renderManifestError4(result, io) {
14212
14553
  switch (result.kind) {
14213
14554
  case "ok":
@@ -14248,17 +14589,12 @@ async function runSecretsStatus(opts, io, deps = {}) {
14248
14589
  const loaded = loadManifestAnyVersion(manifestPath);
14249
14590
  if (loaded.kind !== "ok")
14250
14591
  return renderManifestError5(loaded, io);
14251
- const v2Refusal = refuseV2Manifest(loaded, "launchpad secrets status", io);
14252
- if (v2Refusal !== null)
14253
- return v2Refusal;
14254
- const manifest = loaded.manifest;
14255
- const slug = manifest.metadata.name;
14592
+ const { slug, bindings } = declaredSecrets(loaded);
14256
14593
  if (opts.slug != null && opts.slug !== slug) {
14257
14594
  io.err(`launchpad secrets status: slug "${opts.slug}" does not match this directory's ` + `manifest (metadata.name: "${slug}").`);
14258
14595
  io.err(" secrets status audits the manifest in the current directory — omit the slug, " + "or run from the app's directory.");
14259
14596
  return 2;
14260
14597
  }
14261
- const bindings = manifest.secrets?.bindings ?? [];
14262
14598
  if (bindings.length === 0) {
14263
14599
  io.out("No secret bindings declared in the manifest.");
14264
14600
  return 0;
@@ -14608,12 +14944,12 @@ async function runSecretsTemplate(args, io) {
14608
14944
  return 64;
14609
14945
  }
14610
14946
  const manifestPath = resolve14(process.cwd(), flags.file ?? "launchpad.yaml");
14611
- const result = loadManifest(manifestPath);
14612
- const renderResult = renderManifest(result, io);
14947
+ const renderResult = renderManifest(loadManifestAnyVersion(manifestPath), io);
14613
14948
  if (renderResult.kind !== "ok") {
14614
14949
  return renderResult.code;
14615
14950
  }
14616
- const body = buildTemplate(renderResult.manifest, flags.includePlatformManaged);
14951
+ const declared = renderResult.declared;
14952
+ const body = buildTemplate(declared, flags.includePlatformManaged);
14617
14953
  if (flags.toStdout) {
14618
14954
  for (const line of body.split(`
14619
14955
  `)) {
@@ -14636,14 +14972,14 @@ async function runSecretsTemplate(args, io) {
14636
14972
  return 2;
14637
14973
  }
14638
14974
  io.out(`✓ wrote ${outPath}`);
14639
- const bindings = result.kind === "ok" ? countBindings(result.manifest) : { total: 0, written: 0 };
14640
- io.out(` ${bindings.written} binding${bindings.written === 1 ? "" : "s"} scaffolded`);
14975
+ const written = declared.bindings.filter((b) => b.source === "env-file").length;
14976
+ io.out(` ${written} binding${written === 1 ? "" : "s"} scaffolded`);
14641
14977
  return 0;
14642
14978
  }
14643
14979
  function renderManifest(result, io) {
14644
14980
  switch (result.kind) {
14645
14981
  case "ok":
14646
- return { kind: "ok", manifest: result.manifest };
14982
+ return { kind: "ok", declared: declaredSecrets(result) };
14647
14983
  case "file-not-found":
14648
14984
  io.err(`launchpad secrets template: ${result.path}`);
14649
14985
  io.err(" manifest not found");
@@ -14665,19 +15001,19 @@ function renderManifest(result, io) {
14665
15001
  return { kind: "fail", code: 1 };
14666
15002
  }
14667
15003
  }
14668
- function buildTemplate(manifest, includePlatformManaged) {
14669
- const bindings = manifest.secrets?.bindings ?? [];
15004
+ function buildTemplate(declared, includePlatformManaged) {
15005
+ const { bindings } = declared;
14670
15006
  const lines = [];
14671
15007
  lines.push("# Auto-generated by `launchpad secrets template` — do not commit values.");
14672
- lines.push(`# Source: launchpad.yaml (apiVersion: ${manifest.apiVersion})`);
14673
- lines.push(`# App: ${manifest.metadata.name}`);
15008
+ lines.push(`# Source: launchpad.yaml (apiVersion: ${declared.apiVersion})`);
15009
+ lines.push(`# App: ${declared.slug}`);
14674
15010
  lines.push("#");
14675
15011
  lines.push("# Fill in values, then run `launchpad secrets push`.");
14676
15012
  lines.push("# This file is meant to be committed; the real `.env` must be gitignored.");
14677
15013
  lines.push("");
14678
15014
  if (bindings.length === 0) {
14679
15015
  lines.push("# No secret bindings declared in launchpad.yaml.");
14680
- lines.push("# Add entries under `secrets.bindings[]` and re-run this command.");
15016
+ lines.push("# Add entries under `secrets.bindings[]` (v1alpha1) or `spec.env_vars` with " + "`type: secret_text` (v2) and re-run this command.");
14681
15017
  lines.push("");
14682
15018
  return lines.join(`
14683
15019
  `);
@@ -14711,11 +15047,6 @@ function buildTemplate(manifest, includePlatformManaged) {
14711
15047
  return lines.join(`
14712
15048
  `);
14713
15049
  }
14714
- function countBindings(manifest) {
14715
- const bindings = manifest.secrets?.bindings ?? [];
14716
- const written = bindings.filter((b) => b.source === "env-file").length;
14717
- return { total: bindings.length, written };
14718
- }
14719
15050
  function parseFlags4(args) {
14720
15051
  let file;
14721
15052
  let out = ".env.example";
@@ -14924,7 +15255,7 @@ import { promises as fs7, existsSync as existsSync16 } from "node:fs";
14924
15255
  // src/skills-bundle.ts
14925
15256
  import { homedir as homedir2 } from "node:os";
14926
15257
  import { join as join15 } from "node:path";
14927
- import { readFileSync as readFileSync20, readdirSync as readdirSync2 } from "node:fs";
15258
+ import { readFileSync as readFileSync20, readdirSync as readdirSync3 } from "node:fs";
14928
15259
  var SKILL_PREFIX = "launchpad-";
14929
15260
  function skillsTargetDir() {
14930
15261
  return process.env.LAUNCHPAD_SKILLS_TARGET_DIR ?? join15(homedir2(), ".claude", "skills");
@@ -14932,7 +15263,7 @@ function skillsTargetDir() {
14932
15263
  function readInstalledSkills() {
14933
15264
  try {
14934
15265
  const dir = skillsTargetDir();
14935
- const bundle = readdirSync2(dir, { withFileTypes: true }).find((e) => e.isDirectory() && e.name.startsWith(SKILL_PREFIX));
15266
+ const bundle = readdirSync3(dir, { withFileTypes: true }).find((e) => e.isDirectory() && e.name.startsWith(SKILL_PREFIX));
14936
15267
  if (!bundle)
14937
15268
  return { present: false, version: null };
14938
15269
  return {
@@ -15622,7 +15953,7 @@ function printHelp5(io) {
15622
15953
  }
15623
15954
 
15624
15955
  // src/commands/validate.ts
15625
- import { existsSync as existsSync17, readFileSync as readFileSync22, readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
15956
+ import { existsSync as existsSync17, readFileSync as readFileSync22, readdirSync as readdirSync4, statSync as statSync2 } from "node:fs";
15626
15957
  import { dirname as dirname12, resolve as resolve17 } from "node:path";
15627
15958
  var validateCommand = {
15628
15959
  name: "validate",
@@ -15713,7 +16044,7 @@ function checkStaticServeDir(manifestPath, manifest) {
15713
16044
  }
15714
16045
  let entries;
15715
16046
  try {
15716
- entries = readdirSync3(abs);
16047
+ entries = readdirSync4(abs);
15717
16048
  } catch (e) {
15718
16049
  return [`failed to read static served directory '${serveDir}/': ${describe32(e)}`];
15719
16050
  }
@@ -15803,6 +16134,9 @@ function renderHumanOk(result, group, boundary, io) {
15803
16134
  if (!boundary.declared) {
15804
16135
  io.out(`⚠ ${DECLARE_APP_HINT}`);
15805
16136
  }
16137
+ for (const w of containerRuntimeWarnings(view)) {
16138
+ io.out(`⚠ ${w}`);
16139
+ }
15806
16140
  switch (group.kind) {
15807
16141
  case "skipped":
15808
16142
  return 0;
@@ -15869,6 +16203,7 @@ function renderHumanError(result, io) {
15869
16203
  }
15870
16204
  function renderJsonOk(result, group, boundary, io) {
15871
16205
  const view = manifestView(result.manifest);
16206
+ const runtimeWarnings = containerRuntimeWarnings(view);
15872
16207
  const base = {
15873
16208
  manifest_valid: true,
15874
16209
  runtime_supported: true,
@@ -15878,7 +16213,8 @@ function renderJsonOk(result, group, boundary, io) {
15878
16213
  declared: boundary.declared,
15879
16214
  errors: boundary.errors,
15880
16215
  warnings: boundary.warnings
15881
- }
16216
+ },
16217
+ ...runtimeWarnings.length > 0 ? { warnings: runtimeWarnings } : {}
15882
16218
  };
15883
16219
  if (boundary.errors.length > 0) {
15884
16220
  io.out(JSON.stringify({ ok: false, ...base }));
@@ -15967,7 +16303,8 @@ function manifestView(manifest) {
15967
16303
  access: manifest.spec.access,
15968
16304
  buildCommand: manifest.spec.build?.command,
15969
16305
  destinationDir: manifest.spec.build?.destination_dir,
15970
- appDeclared: manifest.spec.app !== undefined
16306
+ appDeclared: manifest.spec.app !== undefined,
16307
+ runtime: undefined
15971
16308
  };
15972
16309
  }
15973
16310
  return {
@@ -15976,7 +16313,8 @@ function manifestView(manifest) {
15976
16313
  access: manifest.access,
15977
16314
  buildCommand: manifest.build?.command,
15978
16315
  destinationDir: manifest.build?.destination_dir,
15979
- appDeclared: manifest.app !== undefined
16316
+ appDeclared: manifest.app !== undefined,
16317
+ runtime: manifest.deployment.runtime
15980
16318
  };
15981
16319
  }
15982
16320
  function parseArgs13(args) {