@m-kopa/launchpad-cli 0.23.0 → 0.25.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.23.0";
22
+ var CLI_VERSION = "0.25.0";
23
23
 
24
24
  // src/config.ts
25
25
  import * as os from "node:os";
@@ -1219,12 +1219,12 @@ function describe9(e) {
1219
1219
  }
1220
1220
 
1221
1221
  // src/commands/deploy.ts
1222
- import { existsSync as existsSync3 } from "node:fs";
1222
+ import { existsSync as existsSync4 } from "node:fs";
1223
1223
  import * as path6 from "node:path";
1224
1224
 
1225
1225
  // src/bundle/orchestrate.ts
1226
- import { readFileSync as readFileSync3 } from "node:fs";
1227
- import { join as join5 } from "node:path";
1226
+ import { readFileSync as readFileSync4 } from "node:fs";
1227
+ import { join as join6 } from "node:path";
1228
1228
 
1229
1229
  // src/deploy/tar-pack.ts
1230
1230
  import * as fs4 from "node:fs/promises";
@@ -1432,6 +1432,9 @@ async function uploadBundle(cfg, slug, manifestYaml, bundleTarGz, workerArtifact
1432
1432
  if (res.status === 202) {
1433
1433
  return { kind: "ok", response: body };
1434
1434
  }
1435
+ if (res.status === 200 && typeof body === "object" && body !== null && body.outcome === "nothing-to-deploy") {
1436
+ return { kind: "ok", response: body };
1437
+ }
1435
1438
  return {
1436
1439
  kind: "error",
1437
1440
  status: res.status,
@@ -1777,741 +1780,656 @@ async function buildWorkerArtifact(cwd, manifestYaml, walkFiles) {
1777
1780
  };
1778
1781
  }
1779
1782
 
1780
- // src/bundle/orchestrate.ts
1781
- async function bundleAndDeploy(args) {
1782
- const { cfg, cwd, slug } = args;
1783
- const manifestPath = join5(cwd, "launchpad.yaml");
1784
- let manifestYaml;
1785
- try {
1786
- manifestYaml = readFileSync3(manifestPath, "utf8");
1787
- } catch (e) {
1788
- return {
1789
- kind: "no-manifest",
1790
- message: `no launchpad.yaml at ${manifestPath} — run \`launchpad init\` from inside your app directory first. ` + `(${e.message})`
1791
- };
1783
+ // src/bundle/boundary.ts
1784
+ import { existsSync as existsSync2, lstatSync as lstatSync2, readFileSync as readFileSync3 } from "node:fs";
1785
+ import { join as join5 } from "node:path";
1786
+ import { parse as parseYaml2 } from "yaml";
1787
+
1788
+ // ../launchpad-engine/dist/schema.js
1789
+ import { z } from "zod";
1790
+ var APP_TYPES2 = ["static", "react", "react+api", "container"];
1791
+ var AUTH_MODES = ["access", "gateway"];
1792
+ var RUNTIMES = ["cloudflare-containers", "aca"];
1793
+ var SECRET_SOURCES = ["env-file", "platform-managed"];
1794
+ var TARGET_KINDS = ["pages", "worker"];
1795
+ var PRIMARY_TARGET = "primary";
1796
+ var SLUG_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
1797
+ var ENV_NAME_REGEX = /^[A-Z][A-Z0-9_]*$/;
1798
+ var SESSION_DURATION_REGEX = /^[0-9]+(s|m|h)$/;
1799
+ var MetadataSchema = z.object({
1800
+ name: z.string().min(2).max(63).regex(SLUG_REGEX, "must be lowercase letters, digits, and hyphens; start/end alphanumeric"),
1801
+ team: z.string().min(1),
1802
+ owner: z.string().email(),
1803
+ description: z.string().max(280).optional()
1804
+ }).strict();
1805
+ var DeploymentSchema = z.object({
1806
+ type: z.enum(APP_TYPES2),
1807
+ runtime: z.enum(RUNTIMES).optional()
1808
+ }).strict();
1809
+ var AccessSchema = z.object({
1810
+ allowed_entra_group: z.string().min(1).optional(),
1811
+ allowed_entra_groups: z.array(z.string().min(1)).min(1).refine((arr) => new Set(arr).size === arr.length, { message: "allowed_entra_groups must not contain duplicates" }).optional(),
1812
+ session_duration: z.string().regex(SESSION_DURATION_REGEX, "must match Go duration format, e.g. 24h / 30m / 60s").optional()
1813
+ }).strict().superRefine((access2, ctx) => {
1814
+ const hasSingular = access2.allowed_entra_group !== undefined;
1815
+ const hasPlural = access2.allowed_entra_groups !== undefined;
1816
+ if (!hasSingular && !hasPlural) {
1817
+ ctx.addIssue({
1818
+ code: z.ZodIssueCode.custom,
1819
+ message: "either `allowed_entra_group` (singular, legacy) or `allowed_entra_groups` (plural, preferred) must be set",
1820
+ path: ["allowed_entra_groups"]
1821
+ });
1792
1822
  }
1793
- let walk;
1794
- try {
1795
- walk = walkCwd(cwd);
1796
- } catch (e) {
1797
- if (e instanceof WalkError) {
1798
- return { kind: "walk-error", message: e.message };
1799
- }
1800
- return {
1801
- kind: "walk-error",
1802
- message: `unexpected walk failure: ${e.message}`
1803
- };
1823
+ if (hasSingular && hasPlural) {
1824
+ ctx.addIssue({
1825
+ code: z.ZodIssueCode.custom,
1826
+ message: "`allowed_entra_group` (singular) and `allowed_entra_groups` (plural) are mutually exclusive; use the plural form",
1827
+ path: ["allowed_entra_groups"]
1828
+ });
1804
1829
  }
1805
- let packResult;
1806
- try {
1807
- packResult = await packTarGz(cwd, walk.files);
1808
- } catch (e) {
1809
- if (e instanceof TarPackError) {
1810
- return { kind: "pack-error", message: e.message };
1811
- }
1812
- return {
1813
- kind: "pack-error",
1814
- message: `unexpected pack failure: ${e.message}`
1815
- };
1830
+ });
1831
+ function allowedEntraGroups(access2) {
1832
+ if (access2.allowed_entra_groups !== undefined) {
1833
+ return access2.allowed_entra_groups;
1816
1834
  }
1817
- const workerBuild = await buildWorkerArtifact(cwd, manifestYaml, walk.files);
1818
- if (workerBuild.kind === "error") {
1819
- return { kind: "worker-build-error", message: workerBuild.message };
1835
+ return [access2.allowed_entra_group];
1836
+ }
1837
+ var ContainerSchema = z.object({
1838
+ name: z.string().min(2).max(32).regex(SLUG_REGEX, "container name must be lowercase letters, digits, and hyphens"),
1839
+ image: z.string().min(1),
1840
+ port: z.number().int().min(1).max(65535),
1841
+ ingress: z.boolean().optional(),
1842
+ healthcheck: z.string().default("/health"),
1843
+ env: z.array(z.string().regex(ENV_NAME_REGEX, "env entries must be UPPER_SNAKE_CASE")).optional(),
1844
+ max_instances: z.number().int().min(1).max(100).optional()
1845
+ }).strict();
1846
+ var TargetSchema = z.object({
1847
+ kind: z.enum(TARGET_KINDS),
1848
+ script: z.string().min(2).max(63).regex(SLUG_REGEX, "target script must be lowercase letters, digits, and hyphens").optional(),
1849
+ schedule: z.array(z.string().min(1).max(120)).min(1).max(20).optional(),
1850
+ d1_binding: z.string().min(1).max(64).regex(ENV_NAME_REGEX, "d1_binding must be an UPPER_SNAKE_CASE binding name").optional()
1851
+ }).strict();
1852
+ var SecretBindingSchema = z.object({
1853
+ name: z.string().min(1).max(64).regex(ENV_NAME_REGEX, "secret binding name must be UPPER_SNAKE_CASE"),
1854
+ source: z.enum(SECRET_SOURCES),
1855
+ description: z.string().max(200).optional(),
1856
+ targets: z.union([z.literal("all"), z.array(z.string().min(1)).min(1)]).optional()
1857
+ }).strict();
1858
+ var SecretsSchema = z.object({
1859
+ bindings: z.array(SecretBindingSchema).optional()
1860
+ }).strict();
1861
+ var BuildSchema = z.object({
1862
+ command: z.string().min(1),
1863
+ destination_dir: z.string().min(1),
1864
+ root_dir: z.string()
1865
+ }).strict();
1866
+ var AppBoundarySchema = z.object({
1867
+ root: z.string().min(1).optional(),
1868
+ include: z.array(z.string().min(1)).min(1).optional(),
1869
+ exclude: z.array(z.string().min(1)).optional()
1870
+ }).strict();
1871
+ var ProductionEnvSchema = z.record(z.string().regex(ENV_NAME_REGEX, "production_env keys must be UPPER_SNAKE_CASE"), z.string());
1872
+ var ManifestSchema = z.object({
1873
+ apiVersion: z.literal("launchpad.m-kopa.us/v1alpha1"),
1874
+ kind: z.literal("App"),
1875
+ metadata: MetadataSchema,
1876
+ deployment: DeploymentSchema,
1877
+ access: AccessSchema,
1878
+ auth: z.enum(AUTH_MODES).optional(),
1879
+ hostnames: z.array(z.string().min(1)).optional(),
1880
+ containers: z.array(ContainerSchema).min(1).optional(),
1881
+ secrets: SecretsSchema.optional(),
1882
+ targets: z.array(TargetSchema).optional(),
1883
+ build: BuildSchema.optional(),
1884
+ production_env: ProductionEnvSchema.optional(),
1885
+ app: AppBoundarySchema.optional()
1886
+ }).strict().superRefine((m, ctx) => {
1887
+ const isContainer = m.deployment.type === "container";
1888
+ if (isContainer && m.auth === "gateway") {
1889
+ ctx.addIssue({
1890
+ code: z.ZodIssueCode.custom,
1891
+ path: ["auth"],
1892
+ message: "auth: gateway is only supported for pages-app shapes (static/react/react+api); container apps stay on Cloudflare Access (ADR 0016)."
1893
+ });
1820
1894
  }
1821
- const workerArtifact = workerBuild.kind === "ok" ? workerBuild.artifact : undefined;
1822
- const uploadResult = await uploadBundle(cfg, slug, manifestYaml, packResult.bytes, workerArtifact);
1823
- if (uploadResult.kind !== "ok") {
1824
- return {
1825
- kind: "upload-error",
1826
- status: uploadResult.status,
1827
- body: uploadResult.response
1828
- };
1895
+ if (isContainer && m.containers === undefined) {
1896
+ ctx.addIssue({
1897
+ code: z.ZodIssueCode.custom,
1898
+ path: ["containers"],
1899
+ message: "containers[] is required when deployment.type is 'container'"
1900
+ });
1829
1901
  }
1830
- return {
1831
- kind: "ok",
1832
- result: uploadResult.response,
1833
- walk,
1834
- fileCount: packResult.fileCount,
1835
- compressedBytes: packResult.bytes.byteLength,
1836
- workerScript: workerArtifact?.script ?? null
1837
- };
1838
- }
1839
-
1840
- // src/commands/deploy.ts
1841
- import { parse as parseYaml3 } from "yaml";
1842
- import { readFileSync as readFileSync5 } from "node:fs";
1843
-
1844
- // src/deploy/git-files.ts
1845
- import { spawn as spawn3 } from "node:child_process";
1846
-
1847
- class GitFilesError extends Error {
1848
- code = "git_files_error";
1849
- }
1850
- var PROTECTED_PATHS = new Set([
1851
- ".github/workflows/launchpad-review.yml",
1852
- ".github/workflows/ci.yml",
1853
- "wrangler.toml",
1854
- ".github/scripts/_gha-cmd.mjs",
1855
- ".github/scripts/parse-eslint.mjs",
1856
- ".github/scripts/parse-tsc.mjs",
1857
- ".github/scripts/parse-vitest.mjs",
1858
- ".github/scripts/parse-gitleaks.mjs",
1859
- ".github/scripts/parse-bun-audit.mjs"
1860
- ]);
1861
- async function listDeployFiles(opts) {
1862
- const sp = opts.spawner ?? spawn3;
1863
- const stdout = await runGit2(sp, opts.cwd, [
1864
- "ls-files",
1865
- "-c",
1866
- "-o",
1867
- "--exclude-standard",
1868
- "-z"
1869
- ]);
1870
- const paths = stdout.split("\x00").filter((p) => p.length > 0 && !PROTECTED_PATHS.has(p));
1871
- return [...paths].sort();
1872
- }
1873
- function runGit2(sp, cwd, args) {
1874
- return new Promise((resolve5, reject) => {
1875
- const spawnOpts = { cwd, stdio: ["ignore", "pipe", "pipe"] };
1876
- const child = sp("git", [...args], spawnOpts);
1877
- let stdout = "";
1878
- let stderr = "";
1879
- child.stdout?.on("data", (chunk) => {
1880
- stdout += chunk.toString();
1902
+ if (!isContainer && m.containers !== undefined) {
1903
+ ctx.addIssue({
1904
+ code: z.ZodIssueCode.custom,
1905
+ path: ["containers"],
1906
+ message: `containers[] is only allowed when deployment.type is 'container' (current type: '${m.deployment.type}')`
1881
1907
  });
1882
- child.stderr?.on("data", (chunk) => {
1883
- stderr += chunk.toString();
1908
+ }
1909
+ if (!isContainer && m.deployment.runtime !== undefined) {
1910
+ ctx.addIssue({
1911
+ code: z.ZodIssueCode.custom,
1912
+ path: ["deployment", "runtime"],
1913
+ message: "deployment.runtime is only meaningful when deployment.type is 'container'"
1884
1914
  });
1885
- child.once("error", (err) => {
1886
- reject(new GitFilesError(`could not run \`git ${args.join(" ")}\`: ${err.message} (is git installed?)`));
1915
+ }
1916
+ if (isContainer && m.build !== undefined) {
1917
+ ctx.addIssue({
1918
+ code: z.ZodIssueCode.custom,
1919
+ path: ["build"],
1920
+ message: "build is only allowed for pages-app deployments (static/react/react+api). Container builds live in the Dockerfile."
1887
1921
  });
1888
- child.once("close", (code) => {
1889
- if (code === 0) {
1890
- resolve5(stdout);
1891
- return;
1892
- }
1893
- reject(new GitFilesError(`\`git ${args.join(" ")}\` exited ${code}: ${stderr.trim()}`));
1922
+ }
1923
+ if (isContainer && m.production_env !== undefined) {
1924
+ ctx.addIssue({
1925
+ code: z.ZodIssueCode.custom,
1926
+ path: ["production_env"],
1927
+ message: "production_env is only allowed for pages-app deployments. Container env vars use containers[].env (names only) + the bot's secrets pipeline for values."
1894
1928
  });
1895
- });
1896
- }
1897
-
1898
- // src/commands/deploy-flags.ts
1899
- var SLUG_RE3 = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
1900
- var GROUP_KEY_RE2 = /^[A-Za-z_][A-Za-z0-9_]*$/;
1901
- var GROUP_UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1902
- function parseDeployFlags(args) {
1903
- let mode = "content";
1904
- let slug = null;
1905
- let displayName = null;
1906
- let description = null;
1907
- let appType = null;
1908
- const allowedGroups = [];
1909
- let message = null;
1910
- let timeoutSeconds = null;
1911
- let manifestFile = null;
1912
- let dryRunJson = false;
1913
- let platformRepo = null;
1914
- let rePin = false;
1915
- let yes = false;
1916
- let resumePrNumber = null;
1917
- let timeoutMinutes = null;
1918
- let modeFlagsSeen = 0;
1919
- let i = 0;
1920
- while (i < args.length) {
1921
- const a = args[i] ?? "";
1922
- if (a === "--new") {
1923
- mode = "new";
1924
- modeFlagsSeen += 1;
1925
- i += 1;
1926
- continue;
1927
- }
1928
- if (a === "--dry-run") {
1929
- mode = "dry-run";
1930
- modeFlagsSeen += 1;
1931
- i += 1;
1932
- continue;
1929
+ }
1930
+ if (isContainer && m.containers !== undefined && m.containers.length > 1) {
1931
+ const ingressContainers = m.containers.filter((c) => c.ingress === true);
1932
+ if (ingressContainers.length !== 1) {
1933
+ ctx.addIssue({
1934
+ code: z.ZodIssueCode.custom,
1935
+ path: ["containers"],
1936
+ message: `multi-container apps must mark exactly one container ingress: true (found ${ingressContainers.length})`
1937
+ });
1933
1938
  }
1934
- if (a === "--apply") {
1935
- mode = "apply";
1936
- modeFlagsSeen += 1;
1937
- i += 1;
1938
- continue;
1939
+ }
1940
+ if (isContainer && m.containers !== undefined) {
1941
+ const declaredBindings = new Set((m.secrets?.bindings ?? []).map((b) => b.name));
1942
+ for (let i = 0;i < m.containers.length; i++) {
1943
+ const c = m.containers[i];
1944
+ for (const envName of c.env ?? []) {
1945
+ if (!declaredBindings.has(envName)) {
1946
+ ctx.addIssue({
1947
+ code: z.ZodIssueCode.custom,
1948
+ path: ["containers", i, "env"],
1949
+ message: `env reference '${envName}' has no matching entry in secrets.bindings[]`
1950
+ });
1951
+ }
1952
+ }
1939
1953
  }
1940
- if (a === "--platform-repo") {
1941
- const v = args[i + 1];
1942
- if (v === undefined || v.trim().length === 0) {
1943
- return `missing value for ${a}`;
1954
+ }
1955
+ if (isContainer && m.containers !== undefined) {
1956
+ const seen = new Set;
1957
+ for (let i = 0;i < m.containers.length; i++) {
1958
+ const name = m.containers[i]?.name;
1959
+ if (name === undefined)
1960
+ continue;
1961
+ if (seen.has(name)) {
1962
+ ctx.addIssue({
1963
+ code: z.ZodIssueCode.custom,
1964
+ path: ["containers", i, "name"],
1965
+ message: `duplicate container name '${name}' — names become DO class identifiers and must be unique`
1966
+ });
1944
1967
  }
1945
- platformRepo = v.trim();
1946
- i += 2;
1947
- continue;
1968
+ seen.add(name);
1948
1969
  }
1949
- if (a === "--re-pin") {
1950
- rePin = true;
1951
- i += 1;
1952
- continue;
1970
+ }
1971
+ const bindings = m.secrets?.bindings ?? [];
1972
+ if (bindings.length > 0) {
1973
+ const seen = new Set;
1974
+ for (let i = 0;i < bindings.length; i++) {
1975
+ const name = bindings[i]?.name;
1976
+ if (name === undefined)
1977
+ continue;
1978
+ if (seen.has(name)) {
1979
+ ctx.addIssue({
1980
+ code: z.ZodIssueCode.custom,
1981
+ path: ["secrets", "bindings", i, "name"],
1982
+ message: `duplicate secret binding name '${name}' — names become env-var keys and must be unique`
1983
+ });
1984
+ }
1985
+ seen.add(name);
1953
1986
  }
1954
- if (a === "--yes" || a === "-y") {
1955
- yes = true;
1956
- i += 1;
1957
- continue;
1987
+ }
1988
+ const targets = m.targets ?? [];
1989
+ for (let i = 0;i < targets.length; i++) {
1990
+ const t = targets[i];
1991
+ if (t.kind === "worker" && t.script === undefined) {
1992
+ ctx.addIssue({
1993
+ code: z.ZodIssueCode.custom,
1994
+ path: ["targets", i, "script"],
1995
+ message: "a worker target must declare a script (the sibling Workers script name)"
1996
+ });
1958
1997
  }
1959
- if (a === "--resume") {
1960
- const v = args[i + 1];
1961
- if (v === undefined)
1962
- return `missing value for ${a}`;
1963
- mode = "resume";
1964
- slug = v;
1965
- modeFlagsSeen += 1;
1966
- i += 2;
1967
- continue;
1998
+ if (t.kind === "pages" && t.script !== undefined) {
1999
+ ctx.addIssue({
2000
+ code: z.ZodIssueCode.custom,
2001
+ path: ["targets", i, "script"],
2002
+ message: "a pages target must not declare a script — the primary Pages project is the app slug"
2003
+ });
1968
2004
  }
1969
- if (a === "--abandon") {
1970
- const v = args[i + 1];
1971
- if (v === undefined)
1972
- return `missing value for ${a}`;
1973
- mode = "abandon";
1974
- slug = v;
1975
- modeFlagsSeen += 1;
1976
- i += 2;
1977
- continue;
2005
+ if (t.kind === "pages" && t.schedule !== undefined) {
2006
+ ctx.addIssue({
2007
+ code: z.ZodIssueCode.custom,
2008
+ path: ["targets", i, "schedule"],
2009
+ message: "schedule is only valid on a worker target (the cron deploy contract — ADR 0022)"
2010
+ });
1978
2011
  }
1979
- if (a === "--file") {
1980
- const v = args[i + 1];
1981
- if (v === undefined)
1982
- return `missing value for ${a}`;
1983
- manifestFile = v;
1984
- i += 2;
2012
+ }
2013
+ const seenScripts = new Set;
2014
+ let pagesTargets = 0;
2015
+ for (let i = 0;i < targets.length; i++) {
2016
+ const t = targets[i];
2017
+ if (t.kind === "pages") {
2018
+ pagesTargets++;
2019
+ if (pagesTargets > 1) {
2020
+ ctx.addIssue({
2021
+ code: z.ZodIssueCode.custom,
2022
+ path: ["targets", i, "kind"],
2023
+ message: "at most one pages target may be declared (the primary Pages project is singular)"
2024
+ });
2025
+ }
1985
2026
  continue;
1986
2027
  }
1987
- if (a === "--json") {
1988
- dryRunJson = true;
1989
- i += 1;
2028
+ if (t.script === undefined)
1990
2029
  continue;
1991
- }
1992
- if (a === "--slug") {
1993
- const v = args[i + 1];
1994
- if (v === undefined)
1995
- return `missing value for ${a}`;
1996
- slug = v;
1997
- i += 2;
2030
+ if (t.script === PRIMARY_TARGET || t.script === "pages") {
2031
+ ctx.addIssue({
2032
+ code: z.ZodIssueCode.custom,
2033
+ path: ["targets", i, "script"],
2034
+ message: `worker target script '${t.script}' is a reserved selector token — use a different script name`
2035
+ });
1998
2036
  continue;
1999
2037
  }
2000
- if (a === "--display-name" || a === "--name") {
2001
- const v = args[i + 1];
2002
- if (v === undefined)
2003
- return `missing value for ${a}`;
2004
- displayName = v;
2005
- i += 2;
2006
- continue;
2038
+ if (seenScripts.has(t.script)) {
2039
+ ctx.addIssue({
2040
+ code: z.ZodIssueCode.custom,
2041
+ path: ["targets", i, "script"],
2042
+ message: `duplicate worker target script '${t.script}' — target scripts must be unique`
2043
+ });
2007
2044
  }
2008
- if (a === "--description") {
2009
- const v = args[i + 1];
2010
- if (v === undefined)
2011
- return `missing value for ${a}`;
2012
- description = v;
2013
- i += 2;
2045
+ seenScripts.add(t.script);
2046
+ }
2047
+ const validRefs = new Set([PRIMARY_TARGET, ...seenScripts]);
2048
+ if (pagesTargets > 0)
2049
+ validRefs.add("pages");
2050
+ for (let bi = 0;bi < bindings.length; bi++) {
2051
+ const b = bindings[bi];
2052
+ const sel = b?.targets;
2053
+ if (sel === undefined || sel === "all")
2014
2054
  continue;
2015
- }
2016
- if (a === "--app-type" || a === "--type") {
2017
- const v = args[i + 1];
2018
- if (v === undefined)
2019
- return `missing value for ${a}`;
2020
- if (!APP_TYPES.includes(v)) {
2021
- return `invalid --app-type "${v}" — must be one of ${APP_TYPES.join(", ")}`;
2055
+ for (const ref of sel) {
2056
+ if (!validRefs.has(ref)) {
2057
+ ctx.addIssue({
2058
+ code: z.ZodIssueCode.custom,
2059
+ path: ["secrets", "bindings", bi, "targets"],
2060
+ message: `secret binding '${b?.name}' references unknown target '${ref}' — declare it in targets[] or use "primary"`
2061
+ });
2022
2062
  }
2023
- appType = v;
2024
- i += 2;
2025
- continue;
2026
2063
  }
2027
- if (a === "--allowed-group" || a === "--group") {
2028
- const v = args[i + 1];
2029
- if (v === undefined)
2030
- return `missing value for ${a}`;
2031
- if (!GROUP_KEY_RE2.test(v) && !GROUP_UUID_RE2.test(v)) {
2032
- return `invalid --allowed-group "${v}" expected a group key (HCL identifier, e.g. G_Product_Security) or an Entra Object-ID UUID`;
2033
- }
2034
- allowedGroups.push(v);
2035
- i += 2;
2036
- continue;
2064
+ }
2065
+ });
2066
+ function parseManifest(input) {
2067
+ const result = ManifestSchema.safeParse(input);
2068
+ if (result.success) {
2069
+ return { kind: "ok", manifest: result.data };
2070
+ }
2071
+ return {
2072
+ kind: "schema-error",
2073
+ issues: result.error.issues.map((issue) => ({
2074
+ path: issue.path.map(String).join(".") || "(root)",
2075
+ message: issue.message
2076
+ }))
2077
+ };
2078
+ }
2079
+ // ../launchpad-engine/dist/app-boundary.js
2080
+ var INCLUDE_EVERYTHING = ["**"];
2081
+ var COMMON_INCLUDE = [
2082
+ "launchpad.yaml",
2083
+ ".gitignore",
2084
+ ".launchpadignore",
2085
+ "package.json",
2086
+ "package-lock.json",
2087
+ "bun.lock",
2088
+ "bun.lockb",
2089
+ "yarn.lock",
2090
+ "pnpm-lock.yaml",
2091
+ "tsconfig.json",
2092
+ "tsconfig.*.json",
2093
+ "README.md",
2094
+ "LICENSE",
2095
+ "index.html",
2096
+ "vite.config.*",
2097
+ "src/**",
2098
+ "public/**",
2099
+ "worker/**"
2100
+ ];
2101
+ function defaultIncludeForAppType(appType) {
2102
+ switch (appType) {
2103
+ case "static":
2104
+ return [...COMMON_INCLUDE, "static/**", "assets/**", "css/**", "js/**", "images/**", "*.html", "*.css", "*.js"];
2105
+ case "react":
2106
+ return COMMON_INCLUDE;
2107
+ case "react+api":
2108
+ return [...COMMON_INCLUDE, "functions/**"];
2109
+ case "container":
2110
+ return [...COMMON_INCLUDE, "container/**", "Dockerfile", "Dockerfile.*", "wrangler.toml"];
2111
+ default:
2112
+ return INCLUDE_EVERYTHING;
2113
+ }
2114
+ }
2115
+ function resolveAppBoundary(input) {
2116
+ if (input.app === undefined) {
2117
+ return { root: ".", include: INCLUDE_EVERYTHING, exclude: [], source: "inferred" };
2118
+ }
2119
+ return {
2120
+ root: normaliseRoot(input.app.root ?? "."),
2121
+ include: input.app.include ?? defaultIncludeForAppType(input.appType),
2122
+ exclude: input.app.exclude ?? [],
2123
+ source: "declared"
2124
+ };
2125
+ }
2126
+ function normaliseRoot(root) {
2127
+ let r = root.replace(/\\/g, "/").trim();
2128
+ if (r.startsWith("./"))
2129
+ r = r.slice(2);
2130
+ while (r.endsWith("/"))
2131
+ r = r.slice(0, -1);
2132
+ return r === "" ? "." : r;
2133
+ }
2134
+ var APP_TYPE_SET = new Set(["static", "react", "react+api", "container"]);
2135
+ function extractBoundaryInput(parsedManifest) {
2136
+ const m = asRecord(parsedManifest);
2137
+ if (m === undefined) {
2138
+ return { kind: "ok", appType: undefined, app: undefined, build: undefined };
2139
+ }
2140
+ const spec = asRecord(m["spec"]);
2141
+ const v2 = spec !== undefined;
2142
+ const rawType = v2 ? spec["appType"] : asRecord(m["deployment"])?.["type"];
2143
+ const appType = typeof rawType === "string" && APP_TYPE_SET.has(rawType) ? rawType : undefined;
2144
+ const rawApp = v2 ? spec["app"] : m["app"];
2145
+ let app;
2146
+ if (rawApp !== undefined) {
2147
+ const parsed = AppBoundarySchema.safeParse(rawApp);
2148
+ if (!parsed.success) {
2149
+ return {
2150
+ kind: "schema-error",
2151
+ issues: parsed.error.issues.map((i) => ({
2152
+ path: `${v2 ? "spec." : ""}app${i.path.length > 0 ? "." + i.path.map(String).join(".") : ""}`,
2153
+ message: i.message
2154
+ }))
2155
+ };
2037
2156
  }
2038
- if (a === "--message" || a === "-m") {
2039
- const v = args[i + 1];
2040
- if (v === undefined)
2041
- return `missing value for ${a}`;
2042
- message = v;
2043
- i += 2;
2044
- continue;
2045
- }
2046
- if (a === "--timeout-seconds") {
2047
- const v = args[i + 1];
2048
- if (v === undefined)
2049
- return `missing value for ${a}`;
2050
- const n = Number(v);
2051
- if (!Number.isFinite(n) || n <= 0 || Math.floor(n) !== n) {
2052
- return `invalid --timeout-seconds "${v}" — expected positive integer`;
2053
- }
2054
- timeoutSeconds = n;
2055
- i += 2;
2056
- continue;
2057
- }
2058
- if (a === "--timeout-minutes") {
2059
- const v = args[i + 1];
2060
- if (v === undefined)
2061
- return `missing value for ${a}`;
2062
- const n = Number(v);
2063
- if (!Number.isFinite(n) || n <= 0 || Math.floor(n) !== n) {
2064
- return `invalid --timeout-minutes "${v}" — expected positive integer`;
2065
- }
2066
- timeoutMinutes = n;
2067
- i += 2;
2157
+ app = parsed.data;
2158
+ }
2159
+ const rawBuild = asRecord(v2 ? spec["build"] : m["build"]);
2160
+ const build = rawBuild !== undefined && typeof rawBuild["command"] === "string" && typeof rawBuild["root_dir"] === "string" ? { command: rawBuild["command"], root_dir: rawBuild["root_dir"] } : undefined;
2161
+ return { kind: "ok", appType, app, build };
2162
+ }
2163
+ function asRecord(v) {
2164
+ return v !== null && typeof v === "object" && !Array.isArray(v) ? v : undefined;
2165
+ }
2166
+ function parseLaunchpadIgnore(content) {
2167
+ const out = [];
2168
+ for (const raw of content.split(/\r?\n/)) {
2169
+ const line = raw.trim();
2170
+ if (line === "" || line.startsWith("#") || line.startsWith("!"))
2068
2171
  continue;
2069
- }
2070
- if (a === "--resume-pr") {
2071
- const v = args[i + 1];
2072
- if (v === undefined)
2073
- return `missing value for ${a}`;
2074
- const n = Number(v);
2075
- if (!Number.isFinite(n) || n <= 0 || Math.floor(n) !== n) {
2076
- return `invalid --resume-pr "${v}" — expected positive integer PR number`;
2172
+ out.push(line);
2173
+ }
2174
+ return out;
2175
+ }
2176
+ function globToRegExp2(glob) {
2177
+ let re = "";
2178
+ let i = 0;
2179
+ while (i < glob.length) {
2180
+ const ch = glob[i];
2181
+ if (ch === "*") {
2182
+ if (glob[i + 1] === "*") {
2183
+ if (glob[i + 2] === "/") {
2184
+ re += "(?:.*/)?";
2185
+ i += 3;
2186
+ } else {
2187
+ re += ".*";
2188
+ i += 2;
2189
+ }
2190
+ } else {
2191
+ re += "[^/]*";
2192
+ i += 1;
2077
2193
  }
2078
- resumePrNumber = n;
2079
- i += 2;
2080
- continue;
2194
+ } else if (ch === "?") {
2195
+ re += "[^/]";
2196
+ i += 1;
2197
+ } else if (ch === "/" || /[A-Za-z0-9_-]/.test(ch)) {
2198
+ re += ch;
2199
+ i += 1;
2200
+ } else {
2201
+ re += `\\${ch}`;
2202
+ i += 1;
2081
2203
  }
2082
- return `unknown argument "${a}"`;
2083
- }
2084
- if (modeFlagsSeen > 1) {
2085
- return "--new, --resume, --abandon, --dry-run, and --apply are mutually exclusive";
2086
- }
2087
- if (mode === "dry-run") {
2088
- if (slug !== null)
2089
- return "--dry-run does not accept --slug (slug is read from launchpad.yaml)";
2090
- if (displayName !== null)
2091
- return "--dry-run does not accept --display-name";
2092
- if (description !== null)
2093
- return "--dry-run does not accept --description";
2094
- if (appType !== null)
2095
- return "--dry-run does not accept --app-type";
2096
- if (allowedGroups.length > 0)
2097
- return "--dry-run does not accept --allowed-group (read from launchpad.yaml)";
2098
- if (message !== null)
2099
- return "--dry-run does not accept --message";
2100
- if (timeoutSeconds !== null)
2101
- return "--dry-run does not accept --timeout-seconds";
2102
- if (platformRepo !== null)
2103
- return "--dry-run does not accept --platform-repo (apply-only)";
2104
- if (rePin)
2105
- return "--dry-run does not accept --re-pin (apply-only)";
2106
- if (yes)
2107
- return "--dry-run does not accept --yes (apply-only)";
2108
- return {
2109
- mode: { kind: "dry-run", file: manifestFile, json: dryRunJson },
2110
- message: null,
2111
- timeoutSeconds: null
2112
- };
2113
2204
  }
2114
- if (mode === "apply") {
2115
- if (slug !== null)
2116
- return "--apply does not accept --slug (slug is read from launchpad.yaml)";
2117
- if (displayName !== null)
2118
- return "--apply does not accept --display-name";
2119
- if (description !== null)
2120
- return "--apply does not accept --description";
2121
- if (appType !== null)
2122
- return "--apply does not accept --app-type";
2123
- if (allowedGroups.length > 0)
2124
- return "--apply does not accept --allowed-group (read from launchpad.yaml)";
2125
- if (message !== null)
2126
- return "--apply does not accept --message";
2127
- if (timeoutSeconds !== null)
2128
- return "--apply does not accept --timeout-seconds (use --timeout-minutes instead — apply polling is multi-minute)";
2129
- if (dryRunJson)
2130
- return "--apply does not accept --json";
2131
- return {
2132
- mode: {
2133
- kind: "apply",
2134
- file: manifestFile,
2135
- platformRepo,
2136
- rePin,
2137
- yes,
2138
- resumePrNumber,
2139
- timeoutMinutes
2140
- },
2141
- message: null,
2142
- timeoutSeconds: null
2143
- };
2205
+ return new RegExp(`^${re}$`);
2206
+ }
2207
+ var GLOB_CHARS = /[*?]/;
2208
+ function matchContractPattern(pattern, relPath) {
2209
+ const p = pattern.replace(/\/+$/, "");
2210
+ if (!GLOB_CHARS.test(p)) {
2211
+ return relPath === p || relPath.startsWith(`${p}/`);
2144
2212
  }
2145
- if (resumePrNumber !== null) {
2146
- return "--resume-pr is only valid with --apply";
2213
+ return globToRegExp2(p).test(relPath);
2214
+ }
2215
+ function matchIgnorePattern(pattern, relPath) {
2216
+ let p = pattern;
2217
+ const dirOnly = p.endsWith("/");
2218
+ if (dirOnly)
2219
+ p = p.replace(/\/+$/, "");
2220
+ const anchored = p.startsWith("/");
2221
+ if (anchored)
2222
+ p = p.slice(1);
2223
+ if (!anchored && !p.includes("/")) {
2224
+ const re2 = globToRegExp2(p);
2225
+ const segments = relPath.split("/");
2226
+ const candidates = dirOnly ? segments.slice(0, -1) : segments;
2227
+ return candidates.some((seg) => re2.test(seg));
2147
2228
  }
2148
- if (timeoutMinutes !== null) {
2149
- return "--timeout-minutes is only valid with --apply";
2229
+ if (!GLOB_CHARS.test(p)) {
2230
+ return !dirOnly && relPath === p || relPath.startsWith(`${p}/`);
2150
2231
  }
2151
- if (manifestFile !== null) {
2152
- return "--file is only valid with --dry-run or --apply";
2232
+ const re = globToRegExp2(p);
2233
+ if (dirOnly)
2234
+ return re.test(relPath.split("/").slice(0, -1).join("/"));
2235
+ return re.test(relPath) || re.test(relPath.split("/").slice(0, -1).join("/"));
2236
+ }
2237
+ var PLATFORM_SEEDED_GITHUB_PATHS = new Set([
2238
+ ".github/workflows/launchpad-review.yml",
2239
+ ".github/workflows/ci.yml",
2240
+ ".github/scripts/_gha-cmd.mjs",
2241
+ ".github/scripts/parse-eslint.mjs",
2242
+ ".github/scripts/parse-tsc.mjs",
2243
+ ".github/scripts/parse-vitest.mjs",
2244
+ ".github/scripts/parse-gitleaks.mjs",
2245
+ ".github/scripts/parse-bun-audit.mjs"
2246
+ ]);
2247
+ function checkDenyList(path6) {
2248
+ const segments = path6.split("/");
2249
+ const basename = segments[segments.length - 1];
2250
+ const isExample = basename.endsWith(".example");
2251
+ if (basename.startsWith(".env") && !isExample) {
2252
+ return { path: path6, rule: "env-file", message: `'${path6}' is an environment file (.env*) — never shippable` };
2153
2253
  }
2154
- if (dryRunJson) {
2155
- return "--json is only valid with --dry-run";
2254
+ if (basename === ".npmrc") {
2255
+ return { path: path6, rule: "npmrc", message: `'${path6}' (.npmrc) may carry registry auth — never shippable` };
2156
2256
  }
2157
- if (platformRepo !== null) {
2158
- return "--platform-repo is only valid with --apply";
2257
+ if (basename.startsWith(".dev.vars") && !isExample) {
2258
+ return { path: path6, rule: "dev-vars", message: `'${path6}' is a wrangler dev-vars file — never shippable` };
2159
2259
  }
2160
- if (rePin) {
2161
- return "--re-pin is only valid with --apply";
2260
+ if (segments.includes(".claude")) {
2261
+ return { path: path6, rule: "claude-dir", message: `'${path6}' is under a .claude/ directory (AI-workspace state) — never shippable` };
2162
2262
  }
2163
- if (yes) {
2164
- return "--yes is only valid with --apply";
2263
+ if (segments.includes(".git")) {
2264
+ return { path: path6, rule: "git-dir", message: `'${path6}' is under .git/ never shippable` };
2165
2265
  }
2166
- if (mode === "content") {
2167
- if (slug !== null && !SLUG_RE3.test(slug)) {
2168
- return `invalid slug "${slug}" — expected ${SLUG_RE3.source}`;
2169
- }
2266
+ if (segments[0] === ".github") {
2267
+ if (PLATFORM_SEEDED_GITHUB_PATHS.has(path6))
2268
+ return "seeded";
2170
2269
  return {
2171
- mode: { kind: "content", slug },
2172
- message,
2173
- timeoutSeconds
2270
+ path: path6,
2271
+ rule: "github-dir",
2272
+ message: `'${path6}' — repo-root .github/ is platform-owned (workflows execute under the M-KOPA Actions identity); developer-supplied .github/** is not shippable`
2174
2273
  };
2175
2274
  }
2176
- if (mode === "resume" || mode === "abandon") {
2177
- if (slug === null || !SLUG_RE3.test(slug)) {
2178
- return `--${mode} requires a valid slug (kebab-case)`;
2275
+ return null;
2276
+ }
2277
+ function checkSecretShape(path6, readFileContent) {
2278
+ const segments = path6.split("/");
2279
+ const basename = segments[segments.length - 1];
2280
+ const deny = (what) => ({
2281
+ path: path6,
2282
+ rule: "secret-shape",
2283
+ message: `'${path6}' looks like ${what} — never shippable; if this is a false positive, rename the file`
2284
+ });
2285
+ if (basename.endsWith(".pem"))
2286
+ return deny("PEM key material (*.pem)");
2287
+ if (basename.endsWith(".key"))
2288
+ return deny("private key material (*.key)");
2289
+ if (basename.startsWith("id_rsa"))
2290
+ return deny("an SSH key (id_rsa*)");
2291
+ if (basename.endsWith(".p12") || basename.endsWith(".pfx")) {
2292
+ return deny("a PKCS#12 key store (*.p12 / *.pfx)");
2293
+ }
2294
+ if (basename === "credentials.json")
2295
+ return deny("a credentials file (credentials.json)");
2296
+ if (segments.includes(".aws"))
2297
+ return deny("AWS credentials (.aws/**)");
2298
+ if (basename.endsWith(".json") && readFileContent !== undefined) {
2299
+ const content = readFileContent(path6);
2300
+ if (content !== undefined && /"private_key"\s*:/.test(content)) {
2301
+ return deny('service-account-shaped JSON (carries a "private_key" field)');
2179
2302
  }
2180
- if (displayName !== null || appType !== null || allowedGroups.length > 0) {
2181
- return `--${mode} does not accept create-only flags (--display-name / --app-type / --allowed-group)`;
2303
+ }
2304
+ return null;
2305
+ }
2306
+ var ALWAYS_SHIP = "launchpad.yaml";
2307
+ function applyAppBoundary(contract, files, options = {}) {
2308
+ const ignorePatterns = options.launchpadIgnore ?? [];
2309
+ const included = [];
2310
+ const excluded = [];
2311
+ const denied = [];
2312
+ const rootPrefix = contract.root === "." ? "" : `${contract.root}/`;
2313
+ for (const path6 of [...files].sort()) {
2314
+ if (path6 !== ALWAYS_SHIP) {
2315
+ if (rootPrefix !== "" && !path6.startsWith(rootPrefix)) {
2316
+ excluded.push({ path: path6, reason: "outside-root" });
2317
+ continue;
2318
+ }
2319
+ const rel = rootPrefix === "" ? path6 : path6.slice(rootPrefix.length);
2320
+ if (!contract.include.some((p) => matchContractPattern(p, rel))) {
2321
+ excluded.push({ path: path6, reason: "not-included" });
2322
+ continue;
2323
+ }
2324
+ if (contract.exclude.some((p) => matchContractPattern(p, rel))) {
2325
+ excluded.push({ path: path6, reason: "exclude" });
2326
+ continue;
2327
+ }
2328
+ if (ignorePatterns.some((p) => matchIgnorePattern(p, path6))) {
2329
+ excluded.push({ path: path6, reason: "launchpadignore" });
2330
+ continue;
2331
+ }
2182
2332
  }
2183
- if (message !== null) {
2184
- return `--${mode} does not accept --message`;
2333
+ const denial = checkDenyList(path6);
2334
+ if (denial === "seeded") {
2335
+ excluded.push({ path: path6, reason: "platform-seeded" });
2336
+ continue;
2185
2337
  }
2186
- return {
2187
- mode: { kind: mode, slug },
2188
- message: null,
2189
- timeoutSeconds
2190
- };
2191
- }
2192
- if (slug === null)
2193
- return "--new requires --slug";
2194
- if (!SLUG_RE3.test(slug))
2195
- return `invalid slug "${slug}" — expected ${SLUG_RE3.source}`;
2196
- if (slug.length < 3 || slug.length > 58) {
2197
- return `slug length out of bounds (3–58 chars): "${slug}"`;
2198
- }
2199
- if (displayName === null || displayName.length === 0) {
2200
- return "--new requires --display-name";
2201
- }
2202
- if (appType === null) {
2203
- return `--new requires --app-type (one of ${APP_TYPES.join(", ")})`;
2204
- }
2205
- if (allowedGroups.length === 0) {
2206
- return "--new requires at least one --allowed-group";
2338
+ if (denial !== null) {
2339
+ denied.push(denial);
2340
+ continue;
2341
+ }
2342
+ const secret = checkSecretShape(path6, options.readFileContent);
2343
+ if (secret !== null) {
2344
+ denied.push(secret);
2345
+ continue;
2346
+ }
2347
+ included.push(path6);
2207
2348
  }
2208
- return {
2209
- mode: {
2210
- kind: "new",
2211
- slug,
2212
- displayName,
2213
- description,
2214
- appType,
2215
- allowedGroups
2216
- },
2217
- message,
2218
- timeoutSeconds
2219
- };
2349
+ return { files: included, excluded, denied };
2220
2350
  }
2221
-
2222
- // src/deploy/apply.ts
2223
- import { existsSync as existsSync2, rmSync } from "node:fs";
2224
- import { resolve as resolvePath, join as join6 } from "node:path";
2225
- import { execFileSync } from "node:child_process";
2226
-
2227
- // src/manifest/load.ts
2228
- import { readFileSync as readFileSync4 } from "node:fs";
2229
- import { parse as parseYaml2, YAMLParseError } from "yaml";
2230
-
2231
- // ../launchpad-engine/dist/schema.js
2232
- import { z } from "zod";
2233
- var APP_TYPES2 = ["static", "react", "react+api", "container"];
2234
- var AUTH_MODES = ["access", "gateway"];
2235
- var RUNTIMES = ["cloudflare-containers", "aca"];
2236
- var SECRET_SOURCES = ["env-file", "platform-managed"];
2237
- var TARGET_KINDS = ["pages", "worker"];
2238
- var PRIMARY_TARGET = "primary";
2239
- var SLUG_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
2240
- var ENV_NAME_REGEX = /^[A-Z][A-Z0-9_]*$/;
2241
- var SESSION_DURATION_REGEX = /^[0-9]+(s|m|h)$/;
2242
- var MetadataSchema = z.object({
2243
- name: z.string().min(2).max(63).regex(SLUG_REGEX, "must be lowercase letters, digits, and hyphens; start/end alphanumeric"),
2244
- team: z.string().min(1),
2245
- owner: z.string().email(),
2246
- description: z.string().max(280).optional()
2247
- }).strict();
2248
- var DeploymentSchema = z.object({
2249
- type: z.enum(APP_TYPES2),
2250
- runtime: z.enum(RUNTIMES).optional()
2251
- }).strict();
2252
- var AccessSchema = z.object({
2253
- allowed_entra_group: z.string().min(1).optional(),
2254
- allowed_entra_groups: z.array(z.string().min(1)).min(1).refine((arr) => new Set(arr).size === arr.length, { message: "allowed_entra_groups must not contain duplicates" }).optional(),
2255
- session_duration: z.string().regex(SESSION_DURATION_REGEX, "must match Go duration format, e.g. 24h / 30m / 60s").optional()
2256
- }).strict().superRefine((access2, ctx) => {
2257
- const hasSingular = access2.allowed_entra_group !== undefined;
2258
- const hasPlural = access2.allowed_entra_groups !== undefined;
2259
- if (!hasSingular && !hasPlural) {
2260
- ctx.addIssue({
2261
- code: z.ZodIssueCode.custom,
2262
- message: "either `allowed_entra_group` (singular, legacy) or `allowed_entra_groups` (plural, preferred) must be set",
2263
- path: ["allowed_entra_groups"]
2351
+ var PLAIN_PATH = /^(\.\/)?[A-Za-z0-9_@][A-Za-z0-9_@.\/-]*$/;
2352
+ var OPAQUE_SEGMENT = /[$`(){}<]/;
2353
+ function verifyBuildInputs(build, files) {
2354
+ if (build === undefined)
2355
+ return [];
2356
+ const issues = [];
2357
+ const fileSet = new Set(files);
2358
+ const dirSet = new Set;
2359
+ for (const f of files) {
2360
+ const parts = f.split("/");
2361
+ for (let i = 1;i < parts.length; i++) {
2362
+ dirSet.add(parts.slice(0, i).join("/"));
2363
+ }
2364
+ }
2365
+ const exists = (p) => fileSet.has(p) || dirSet.has(p);
2366
+ const rootDir = normaliseRoot(build.root_dir === "" ? "." : build.root_dir);
2367
+ if (rootDir !== "." && !dirSet.has(rootDir)) {
2368
+ issues.push({
2369
+ input: rootDir,
2370
+ message: `build.root_dir '${rootDir}' does not exist in the bundle — the build would run in a missing directory`
2264
2371
  });
2372
+ return issues;
2265
2373
  }
2266
- if (hasSingular && hasPlural) {
2267
- ctx.addIssue({
2268
- code: z.ZodIssueCode.custom,
2269
- message: "`allowed_entra_group` (singular) and `allowed_entra_groups` (plural) are mutually exclusive; use the plural form",
2270
- path: ["allowed_entra_groups"]
2271
- });
2272
- }
2273
- });
2274
- function allowedEntraGroups(access2) {
2275
- if (access2.allowed_entra_groups !== undefined) {
2276
- return access2.allowed_entra_groups;
2277
- }
2278
- return [access2.allowed_entra_group];
2279
- }
2280
- var ContainerSchema = z.object({
2281
- name: z.string().min(2).max(32).regex(SLUG_REGEX, "container name must be lowercase letters, digits, and hyphens"),
2282
- image: z.string().min(1),
2283
- port: z.number().int().min(1).max(65535),
2284
- ingress: z.boolean().optional(),
2285
- healthcheck: z.string().default("/health"),
2286
- env: z.array(z.string().regex(ENV_NAME_REGEX, "env entries must be UPPER_SNAKE_CASE")).optional(),
2287
- max_instances: z.number().int().min(1).max(100).optional()
2288
- }).strict();
2289
- var TargetSchema = z.object({
2290
- kind: z.enum(TARGET_KINDS),
2291
- script: z.string().min(2).max(63).regex(SLUG_REGEX, "target script must be lowercase letters, digits, and hyphens").optional(),
2292
- schedule: z.array(z.string().min(1).max(120)).min(1).max(20).optional(),
2293
- d1_binding: z.string().min(1).max(64).regex(ENV_NAME_REGEX, "d1_binding must be an UPPER_SNAKE_CASE binding name").optional()
2294
- }).strict();
2295
- var SecretBindingSchema = z.object({
2296
- name: z.string().min(1).max(64).regex(ENV_NAME_REGEX, "secret binding name must be UPPER_SNAKE_CASE"),
2297
- source: z.enum(SECRET_SOURCES),
2298
- description: z.string().max(200).optional(),
2299
- targets: z.union([z.literal("all"), z.array(z.string().min(1)).min(1)]).optional()
2300
- }).strict();
2301
- var SecretsSchema = z.object({
2302
- bindings: z.array(SecretBindingSchema).optional()
2303
- }).strict();
2304
- var BuildSchema = z.object({
2305
- command: z.string().min(1),
2306
- destination_dir: z.string().min(1),
2307
- root_dir: z.string()
2308
- }).strict();
2309
- var ProductionEnvSchema = z.record(z.string().regex(ENV_NAME_REGEX, "production_env keys must be UPPER_SNAKE_CASE"), z.string());
2310
- var ManifestSchema = z.object({
2311
- apiVersion: z.literal("launchpad.m-kopa.us/v1alpha1"),
2312
- kind: z.literal("App"),
2313
- metadata: MetadataSchema,
2314
- deployment: DeploymentSchema,
2315
- access: AccessSchema,
2316
- auth: z.enum(AUTH_MODES).optional(),
2317
- hostnames: z.array(z.string().min(1)).optional(),
2318
- containers: z.array(ContainerSchema).min(1).optional(),
2319
- secrets: SecretsSchema.optional(),
2320
- targets: z.array(TargetSchema).optional(),
2321
- build: BuildSchema.optional(),
2322
- production_env: ProductionEnvSchema.optional()
2323
- }).strict().superRefine((m, ctx) => {
2324
- const isContainer = m.deployment.type === "container";
2325
- if (isContainer && m.auth === "gateway") {
2326
- ctx.addIssue({
2327
- code: z.ZodIssueCode.custom,
2328
- path: ["auth"],
2329
- message: "auth: gateway is only supported for pages-app shapes (static/react/react+api); container apps stay on Cloudflare Access (ADR 0016)."
2330
- });
2331
- }
2332
- if (isContainer && m.containers === undefined) {
2333
- ctx.addIssue({
2334
- code: z.ZodIssueCode.custom,
2335
- path: ["containers"],
2336
- message: "containers[] is required when deployment.type is 'container'"
2337
- });
2338
- }
2339
- if (!isContainer && m.containers !== undefined) {
2340
- ctx.addIssue({
2341
- code: z.ZodIssueCode.custom,
2342
- path: ["containers"],
2343
- message: `containers[] is only allowed when deployment.type is 'container' (current type: '${m.deployment.type}')`
2344
- });
2345
- }
2346
- if (!isContainer && m.deployment.runtime !== undefined) {
2347
- ctx.addIssue({
2348
- code: z.ZodIssueCode.custom,
2349
- path: ["deployment", "runtime"],
2350
- message: "deployment.runtime is only meaningful when deployment.type is 'container'"
2351
- });
2352
- }
2353
- if (isContainer && m.build !== undefined) {
2354
- ctx.addIssue({
2355
- code: z.ZodIssueCode.custom,
2356
- path: ["build"],
2357
- message: "build is only allowed for pages-app deployments (static/react/react+api). Container builds live in the Dockerfile."
2358
- });
2359
- }
2360
- if (isContainer && m.production_env !== undefined) {
2361
- ctx.addIssue({
2362
- code: z.ZodIssueCode.custom,
2363
- path: ["production_env"],
2364
- message: "production_env is only allowed for pages-app deployments. Container env vars use containers[].env (names only) + the bot's secrets pipeline for values."
2365
- });
2366
- }
2367
- if (isContainer && m.containers !== undefined && m.containers.length > 1) {
2368
- const ingressContainers = m.containers.filter((c) => c.ingress === true);
2369
- if (ingressContainers.length !== 1) {
2370
- ctx.addIssue({
2371
- code: z.ZodIssueCode.custom,
2372
- path: ["containers"],
2373
- message: `multi-container apps must mark exactly one container ingress: true (found ${ingressContainers.length})`
2374
- });
2375
- }
2376
- }
2377
- if (isContainer && m.containers !== undefined) {
2378
- const declaredBindings = new Set((m.secrets?.bindings ?? []).map((b) => b.name));
2379
- for (let i = 0;i < m.containers.length; i++) {
2380
- const c = m.containers[i];
2381
- for (const envName of c.env ?? []) {
2382
- if (!declaredBindings.has(envName)) {
2383
- ctx.addIssue({
2384
- code: z.ZodIssueCode.custom,
2385
- path: ["containers", i, "env"],
2386
- message: `env reference '${envName}' has no matching entry in secrets.bindings[]`
2387
- });
2388
- }
2389
- }
2390
- }
2391
- }
2392
- if (isContainer && m.containers !== undefined) {
2393
- const seen = new Set;
2394
- for (let i = 0;i < m.containers.length; i++) {
2395
- const name = m.containers[i]?.name;
2396
- if (name === undefined)
2397
- continue;
2398
- if (seen.has(name)) {
2399
- ctx.addIssue({
2400
- code: z.ZodIssueCode.custom,
2401
- path: ["containers", i, "name"],
2402
- message: `duplicate container name '${name}' — names become DO class identifiers and must be unique`
2403
- });
2404
- }
2405
- seen.add(name);
2406
- }
2407
- }
2408
- const bindings = m.secrets?.bindings ?? [];
2409
- if (bindings.length > 0) {
2410
- const seen = new Set;
2411
- for (let i = 0;i < bindings.length; i++) {
2412
- const name = bindings[i]?.name;
2413
- if (name === undefined)
2374
+ let cwd = rootDir === "." ? "" : rootDir;
2375
+ const resolve5 = (p) => {
2376
+ const cleaned = p.startsWith("./") ? p.slice(2) : p;
2377
+ const trimmed = cleaned.replace(/\/+$/, "");
2378
+ return cwd === "" ? trimmed : `${cwd}/${trimmed}`;
2379
+ };
2380
+ const segments = build.command.split(/&&|\|\||[;|\n]/);
2381
+ for (const segment of segments) {
2382
+ if (cwd === null)
2383
+ break;
2384
+ const s = segment.trim();
2385
+ if (s === "" || OPAQUE_SEGMENT.test(s))
2386
+ continue;
2387
+ const tokens = s.split(/\s+/);
2388
+ const cmd = tokens[0];
2389
+ if (cmd === "cd") {
2390
+ const target = tokens[1];
2391
+ if (target === undefined || !PLAIN_PATH.test(target)) {
2392
+ cwd = null;
2414
2393
  continue;
2415
- if (seen.has(name)) {
2416
- ctx.addIssue({
2417
- code: z.ZodIssueCode.custom,
2418
- path: ["secrets", "bindings", i, "name"],
2419
- message: `duplicate secret binding name '${name}' — names become env-var keys and must be unique`
2420
- });
2421
2394
  }
2422
- seen.add(name);
2423
- }
2424
- }
2425
- const targets = m.targets ?? [];
2426
- for (let i = 0;i < targets.length; i++) {
2427
- const t = targets[i];
2428
- if (t.kind === "worker" && t.script === undefined) {
2429
- ctx.addIssue({
2430
- code: z.ZodIssueCode.custom,
2431
- path: ["targets", i, "script"],
2432
- message: "a worker target must declare a script (the sibling Workers script name)"
2433
- });
2434
- }
2435
- if (t.kind === "pages" && t.script !== undefined) {
2436
- ctx.addIssue({
2437
- code: z.ZodIssueCode.custom,
2438
- path: ["targets", i, "script"],
2439
- message: "a pages target must not declare a script — the primary Pages project is the app slug"
2440
- });
2441
- }
2442
- if (t.kind === "pages" && t.schedule !== undefined) {
2443
- ctx.addIssue({
2444
- code: z.ZodIssueCode.custom,
2445
- path: ["targets", i, "schedule"],
2446
- message: "schedule is only valid on a worker target (the cron deploy contract — ADR 0022)"
2447
- });
2448
- }
2449
- }
2450
- const seenScripts = new Set;
2451
- let pagesTargets = 0;
2452
- for (let i = 0;i < targets.length; i++) {
2453
- const t = targets[i];
2454
- if (t.kind === "pages") {
2455
- pagesTargets++;
2456
- if (pagesTargets > 1) {
2457
- ctx.addIssue({
2458
- code: z.ZodIssueCode.custom,
2459
- path: ["targets", i, "kind"],
2460
- message: "at most one pages target may be declared (the primary Pages project is singular)"
2395
+ const resolved = resolve5(target);
2396
+ if (!dirSet.has(resolved)) {
2397
+ issues.push({
2398
+ input: target,
2399
+ message: `build command segment '${s}' changes into '${resolved}', which does not exist in the bundle`
2461
2400
  });
2401
+ cwd = null;
2402
+ continue;
2462
2403
  }
2404
+ cwd = resolved;
2463
2405
  continue;
2464
2406
  }
2465
- if (t.script === undefined)
2466
- continue;
2467
- if (t.script === PRIMARY_TARGET || t.script === "pages") {
2468
- ctx.addIssue({
2469
- code: z.ZodIssueCode.custom,
2470
- path: ["targets", i, "script"],
2471
- message: `worker target script '${t.script}' is a reserved selector token — use a different script name`
2472
- });
2473
- continue;
2474
- }
2475
- if (seenScripts.has(t.script)) {
2476
- ctx.addIssue({
2477
- code: z.ZodIssueCode.custom,
2478
- path: ["targets", i, "script"],
2479
- message: `duplicate worker target script '${t.script}' — target scripts must be unique`
2480
- });
2481
- }
2482
- seenScripts.add(t.script);
2483
- }
2484
- const validRefs = new Set([PRIMARY_TARGET, ...seenScripts]);
2485
- if (pagesTargets > 0)
2486
- validRefs.add("pages");
2487
- for (let bi = 0;bi < bindings.length; bi++) {
2488
- const b = bindings[bi];
2489
- const sel = b?.targets;
2490
- if (sel === undefined || sel === "all")
2491
- continue;
2492
- for (const ref of sel) {
2493
- if (!validRefs.has(ref)) {
2494
- ctx.addIssue({
2495
- code: z.ZodIssueCode.custom,
2496
- path: ["secrets", "bindings", bi, "targets"],
2497
- message: `secret binding '${b?.name}' references unknown target '${ref}' — declare it in targets[] or use "primary"`
2498
- });
2407
+ if (cmd === "cp" || cmd === "cat") {
2408
+ const rawArgs = [];
2409
+ for (const t of tokens.slice(1)) {
2410
+ if (t === ">" || t.startsWith(">"))
2411
+ break;
2412
+ if (t.startsWith("-"))
2413
+ continue;
2414
+ rawArgs.push(t);
2415
+ }
2416
+ const sources = cmd === "cp" ? rawArgs.slice(0, -1) : rawArgs;
2417
+ if (cmd === "cp" && rawArgs.length < 2)
2418
+ continue;
2419
+ for (const src of sources) {
2420
+ if (!PLAIN_PATH.test(src))
2421
+ continue;
2422
+ const resolved = resolve5(src);
2423
+ if (!exists(resolved)) {
2424
+ issues.push({
2425
+ input: src,
2426
+ message: `build command references '${src}' (resolved: '${resolved}'), which does not exist in the bundle — did the content move? (cf. sp-reloc1)`
2427
+ });
2428
+ }
2499
2429
  }
2500
2430
  }
2501
2431
  }
2502
- });
2503
- function parseManifest(input) {
2504
- const result = ManifestSchema.safeParse(input);
2505
- if (result.success) {
2506
- return { kind: "ok", manifest: result.data };
2507
- }
2508
- return {
2509
- kind: "schema-error",
2510
- issues: result.error.issues.map((issue) => ({
2511
- path: issue.path.map(String).join(".") || "(root)",
2512
- message: issue.message
2513
- }))
2514
- };
2432
+ return issues;
2515
2433
  }
2516
2434
  // ../launchpad-engine/dist/status-author.js
2517
2435
  var STATUS_STATUS_KEYS = new Set([
@@ -2557,6 +2475,7 @@ var SpecSchema = z2.object({
2557
2475
  }).strict(),
2558
2476
  auth: z2.enum(AUTH_MODES).optional(),
2559
2477
  build: BuildSchema.optional(),
2478
+ app: AppBoundarySchema.optional(),
2560
2479
  env_vars: z2.record(z2.string().regex(ENV_NAME_REGEX, "env_vars keys must be UPPER_SNAKE_CASE"), EnvVarSchema).optional(),
2561
2480
  containers: z2.array(ContainerSchema).min(1).optional(),
2562
2481
  secrets: SecretsSchema.optional(),
@@ -3151,101 +3070,650 @@ var FleetManifestSchema = z3.object({
3151
3070
  });
3152
3071
  }
3153
3072
  }
3154
- });
3155
- // ../launchpad-engine/dist/fleet-secret-sets.js
3156
- import { z as z4 } from "zod";
3157
- var SET_NAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
3158
- var Slug = z4.string().regex(SLUG_REGEX, "member must be a lowercase-kebab app slug");
3159
- var SecretName = z4.string().regex(ENV_NAME_REGEX, "secret name must be UPPER_SNAKE_CASE");
3160
- var SecretSetExcludeSchema = z4.object({
3161
- member: Slug,
3162
- secret: SecretName
3163
- }).strict();
3164
- var SecretSetSchema = z4.object({
3165
- name: z4.string().min(1).max(64).regex(SET_NAME_REGEX, "set name must be lowercase-kebab"),
3166
- secrets: z4.array(SecretName).min(1),
3167
- members: z4.array(Slug).min(1),
3168
- exclude: z4.array(SecretSetExcludeSchema).optional()
3169
- }).strict();
3170
- var FleetSecretSetsSchema = z4.object({
3171
- schemaVersion: z4.literal(1),
3172
- secretSets: z4.array(SecretSetSchema).min(1)
3173
- }).strict().superRefine((m, ctx) => {
3174
- const seenSets = new Set;
3175
- for (let i = 0;i < m.secretSets.length; i++) {
3176
- const set = m.secretSets[i];
3177
- if (seenSets.has(set.name)) {
3178
- ctx.addIssue({
3179
- code: "custom",
3180
- message: `duplicate secretSet name "${set.name}" — each set name appears once`,
3181
- path: ["secretSets", i, "name"]
3182
- });
3183
- }
3184
- seenSets.add(set.name);
3185
- const seenMembers = new Set;
3186
- for (const member of set.members) {
3187
- if (seenMembers.has(member)) {
3188
- ctx.addIssue({
3189
- code: "custom",
3190
- message: `duplicate member "${member}" in set "${set.name}"`,
3191
- path: ["secretSets", i, "members"]
3192
- });
3193
- }
3194
- seenMembers.add(member);
3073
+ });
3074
+ // ../launchpad-engine/dist/fleet-secret-sets.js
3075
+ import { z as z4 } from "zod";
3076
+ var SET_NAME_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
3077
+ var Slug = z4.string().regex(SLUG_REGEX, "member must be a lowercase-kebab app slug");
3078
+ var SecretName = z4.string().regex(ENV_NAME_REGEX, "secret name must be UPPER_SNAKE_CASE");
3079
+ var SecretSetExcludeSchema = z4.object({
3080
+ member: Slug,
3081
+ secret: SecretName
3082
+ }).strict();
3083
+ var SecretSetSchema = z4.object({
3084
+ name: z4.string().min(1).max(64).regex(SET_NAME_REGEX, "set name must be lowercase-kebab"),
3085
+ secrets: z4.array(SecretName).min(1),
3086
+ members: z4.array(Slug).min(1),
3087
+ exclude: z4.array(SecretSetExcludeSchema).optional()
3088
+ }).strict();
3089
+ var FleetSecretSetsSchema = z4.object({
3090
+ schemaVersion: z4.literal(1),
3091
+ secretSets: z4.array(SecretSetSchema).min(1)
3092
+ }).strict().superRefine((m, ctx) => {
3093
+ const seenSets = new Set;
3094
+ for (let i = 0;i < m.secretSets.length; i++) {
3095
+ const set = m.secretSets[i];
3096
+ if (seenSets.has(set.name)) {
3097
+ ctx.addIssue({
3098
+ code: "custom",
3099
+ message: `duplicate secretSet name "${set.name}" — each set name appears once`,
3100
+ path: ["secretSets", i, "name"]
3101
+ });
3102
+ }
3103
+ seenSets.add(set.name);
3104
+ const seenMembers = new Set;
3105
+ for (const member of set.members) {
3106
+ if (seenMembers.has(member)) {
3107
+ ctx.addIssue({
3108
+ code: "custom",
3109
+ message: `duplicate member "${member}" in set "${set.name}"`,
3110
+ path: ["secretSets", i, "members"]
3111
+ });
3112
+ }
3113
+ seenMembers.add(member);
3114
+ }
3115
+ const seenSecrets = new Set;
3116
+ for (const secret of set.secrets) {
3117
+ if (seenSecrets.has(secret)) {
3118
+ ctx.addIssue({
3119
+ code: "custom",
3120
+ message: `duplicate secret "${secret}" in set "${set.name}"`,
3121
+ path: ["secretSets", i, "secrets"]
3122
+ });
3123
+ }
3124
+ seenSecrets.add(secret);
3125
+ }
3126
+ for (let j = 0;j < (set.exclude ?? []).length; j++) {
3127
+ const ex = set.exclude[j];
3128
+ if (!seenMembers.has(ex.member)) {
3129
+ ctx.addIssue({
3130
+ code: "custom",
3131
+ message: `exclude references member "${ex.member}" which is not in set "${set.name}".members`,
3132
+ path: ["secretSets", i, "exclude", j, "member"]
3133
+ });
3134
+ }
3135
+ if (!seenSecrets.has(ex.secret)) {
3136
+ ctx.addIssue({
3137
+ code: "custom",
3138
+ message: `exclude references secret "${ex.secret}" which is not in set "${set.name}".secrets`,
3139
+ path: ["secretSets", i, "exclude", j, "secret"]
3140
+ });
3141
+ }
3142
+ }
3143
+ }
3144
+ });
3145
+ function parseFleetSecretSets(input) {
3146
+ const res = FleetSecretSetsSchema.safeParse(input);
3147
+ if (res.success)
3148
+ return { ok: true, manifest: res.data };
3149
+ return {
3150
+ ok: false,
3151
+ issues: res.error.issues.map((i) => ({
3152
+ path: i.path.join("."),
3153
+ message: i.message
3154
+ }))
3155
+ };
3156
+ }
3157
+ function membersForSecret(set, secret) {
3158
+ const excluded = new Set((set.exclude ?? []).filter((e) => e.secret === secret).map((e) => e.member));
3159
+ return set.members.filter((m) => !excluded.has(m));
3160
+ }
3161
+ // ../launchpad-engine/dist/redact-status.js
3162
+ var UNSAFE_KEY = new Set(["__proto__", "prototype", "constructor"]);
3163
+ // src/bundle/boundary.ts
3164
+ var MAX_CONTENT_PROBE_BYTES = 1024 * 1024;
3165
+ function applyBoundaryToFiles(args) {
3166
+ const { cwd, manifestYaml, files } = args;
3167
+ let parsed;
3168
+ if (manifestYaml !== null) {
3169
+ try {
3170
+ parsed = parseYaml2(manifestYaml);
3171
+ } catch {
3172
+ parsed = undefined;
3173
+ }
3174
+ }
3175
+ const input = extractBoundaryInput(parsed);
3176
+ if (input.kind === "schema-error") {
3177
+ const detail = input.issues.map((i) => `${i.path}: ${i.message}`).join("; ");
3178
+ return { kind: "error", message: `launchpad.yaml app: block is invalid — ${detail}` };
3179
+ }
3180
+ const contract = resolveAppBoundary({ appType: input.appType, app: input.app });
3181
+ let launchpadIgnore = [];
3182
+ const ignorePath = join5(cwd, ".launchpadignore");
3183
+ if (existsSync2(ignorePath)) {
3184
+ try {
3185
+ launchpadIgnore = parseLaunchpadIgnore(readFileSync3(ignorePath, "utf8"));
3186
+ } catch {}
3187
+ }
3188
+ const result = applyAppBoundary(contract, files, {
3189
+ launchpadIgnore,
3190
+ readFileContent: (path6) => {
3191
+ try {
3192
+ const abs = join5(cwd, path6);
3193
+ const stat = lstatSync2(abs);
3194
+ if (!stat.isFile() || stat.size > MAX_CONTENT_PROBE_BYTES)
3195
+ return;
3196
+ return readFileSync3(abs, "utf8");
3197
+ } catch {
3198
+ return;
3199
+ }
3200
+ }
3201
+ });
3202
+ const warnings = [];
3203
+ if (result.denied.length > 0) {
3204
+ if (contract.source === "declared") {
3205
+ const lines = result.denied.map((d) => ` - ${d.message}`).join(`
3206
+ `);
3207
+ return {
3208
+ kind: "error",
3209
+ message: `bundle blocked — ${result.denied.length} file(s) matched by app.include can never ship:
3210
+ ${lines}
3211
+ ` + ` Remove the file(s) or narrow app.include / add app.exclude in launchpad.yaml.`
3212
+ };
3213
+ }
3214
+ for (const d of result.denied) {
3215
+ warnings.push(`stripped ${d.path} — ${d.message}`);
3216
+ }
3217
+ }
3218
+ if (result.files.length === 0) {
3219
+ return {
3220
+ kind: "error",
3221
+ message: "the app boundary excluded every file — nothing to deploy. " + "Check app.root / app.include in launchpad.yaml."
3222
+ };
3223
+ }
3224
+ const buildIssues = verifyBuildInputs(input.build, result.files);
3225
+ if (buildIssues.length > 0) {
3226
+ const lines = buildIssues.map((i) => ` - ${i.message}`).join(`
3227
+ `);
3228
+ return {
3229
+ kind: "error",
3230
+ message: `build-inputs check failed — the build command references missing inputs:
3231
+ ${lines}`
3232
+ };
3233
+ }
3234
+ return {
3235
+ kind: "ok",
3236
+ files: result.files,
3237
+ warnings,
3238
+ inferred: contract.source === "inferred"
3239
+ };
3240
+ }
3241
+
3242
+ // src/bundle/orchestrate.ts
3243
+ async function bundleAndDeploy(args) {
3244
+ const { cfg, cwd, slug } = args;
3245
+ const manifestPath = join6(cwd, "launchpad.yaml");
3246
+ let manifestYaml;
3247
+ try {
3248
+ manifestYaml = readFileSync4(manifestPath, "utf8");
3249
+ } catch (e) {
3250
+ return {
3251
+ kind: "no-manifest",
3252
+ message: `no launchpad.yaml at ${manifestPath} — run \`launchpad init\` from inside your app directory first. ` + `(${e.message})`
3253
+ };
3254
+ }
3255
+ let walk;
3256
+ try {
3257
+ walk = walkCwd(cwd);
3258
+ } catch (e) {
3259
+ if (e instanceof WalkError) {
3260
+ return { kind: "walk-error", message: e.message };
3261
+ }
3262
+ return {
3263
+ kind: "walk-error",
3264
+ message: `unexpected walk failure: ${e.message}`
3265
+ };
3266
+ }
3267
+ const boundary = applyBoundaryToFiles({ cwd, manifestYaml, files: walk.files });
3268
+ if (boundary.kind === "error") {
3269
+ return { kind: "boundary-error", message: boundary.message };
3270
+ }
3271
+ const bundleFiles = boundary.files;
3272
+ let packResult;
3273
+ try {
3274
+ packResult = await packTarGz(cwd, bundleFiles);
3275
+ } catch (e) {
3276
+ if (e instanceof TarPackError) {
3277
+ return { kind: "pack-error", message: e.message };
3278
+ }
3279
+ return {
3280
+ kind: "pack-error",
3281
+ message: `unexpected pack failure: ${e.message}`
3282
+ };
3283
+ }
3284
+ const workerBuild = await buildWorkerArtifact(cwd, manifestYaml, bundleFiles);
3285
+ if (workerBuild.kind === "error") {
3286
+ return { kind: "worker-build-error", message: workerBuild.message };
3287
+ }
3288
+ const workerArtifact = workerBuild.kind === "ok" ? workerBuild.artifact : undefined;
3289
+ const uploadResult = await uploadBundle(cfg, slug, manifestYaml, packResult.bytes, workerArtifact);
3290
+ if (uploadResult.kind !== "ok") {
3291
+ return {
3292
+ kind: "upload-error",
3293
+ status: uploadResult.status,
3294
+ body: uploadResult.response
3295
+ };
3296
+ }
3297
+ return {
3298
+ kind: "ok",
3299
+ result: uploadResult.response,
3300
+ walk,
3301
+ fileCount: packResult.fileCount,
3302
+ compressedBytes: packResult.bytes.byteLength,
3303
+ workerScript: workerArtifact?.script ?? null,
3304
+ boundaryWarnings: boundary.warnings
3305
+ };
3306
+ }
3307
+
3308
+ // src/commands/deploy.ts
3309
+ import { parse as parseYaml4 } from "yaml";
3310
+ import { readFileSync as readFileSync6 } from "node:fs";
3311
+
3312
+ // src/deploy/git-files.ts
3313
+ import { spawn as spawn3 } from "node:child_process";
3314
+
3315
+ class GitFilesError extends Error {
3316
+ code = "git_files_error";
3317
+ }
3318
+ var PROTECTED_PATHS = new Set([
3319
+ ".github/workflows/launchpad-review.yml",
3320
+ ".github/workflows/ci.yml",
3321
+ "wrangler.toml",
3322
+ ".github/scripts/_gha-cmd.mjs",
3323
+ ".github/scripts/parse-eslint.mjs",
3324
+ ".github/scripts/parse-tsc.mjs",
3325
+ ".github/scripts/parse-vitest.mjs",
3326
+ ".github/scripts/parse-gitleaks.mjs",
3327
+ ".github/scripts/parse-bun-audit.mjs"
3328
+ ]);
3329
+ async function listDeployFiles(opts) {
3330
+ const sp = opts.spawner ?? spawn3;
3331
+ const stdout = await runGit2(sp, opts.cwd, [
3332
+ "ls-files",
3333
+ "-c",
3334
+ "-o",
3335
+ "--exclude-standard",
3336
+ "-z"
3337
+ ]);
3338
+ const paths = stdout.split("\x00").filter((p) => p.length > 0 && !PROTECTED_PATHS.has(p));
3339
+ return [...paths].sort();
3340
+ }
3341
+ function runGit2(sp, cwd, args) {
3342
+ return new Promise((resolve5, reject) => {
3343
+ const spawnOpts = { cwd, stdio: ["ignore", "pipe", "pipe"] };
3344
+ const child = sp("git", [...args], spawnOpts);
3345
+ let stdout = "";
3346
+ let stderr = "";
3347
+ child.stdout?.on("data", (chunk) => {
3348
+ stdout += chunk.toString();
3349
+ });
3350
+ child.stderr?.on("data", (chunk) => {
3351
+ stderr += chunk.toString();
3352
+ });
3353
+ child.once("error", (err) => {
3354
+ reject(new GitFilesError(`could not run \`git ${args.join(" ")}\`: ${err.message} (is git installed?)`));
3355
+ });
3356
+ child.once("close", (code) => {
3357
+ if (code === 0) {
3358
+ resolve5(stdout);
3359
+ return;
3360
+ }
3361
+ reject(new GitFilesError(`\`git ${args.join(" ")}\` exited ${code}: ${stderr.trim()}`));
3362
+ });
3363
+ });
3364
+ }
3365
+
3366
+ // src/commands/deploy-flags.ts
3367
+ var SLUG_RE3 = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
3368
+ var GROUP_KEY_RE2 = /^[A-Za-z_][A-Za-z0-9_]*$/;
3369
+ var GROUP_UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3370
+ function parseDeployFlags(args) {
3371
+ let mode = "content";
3372
+ let slug = null;
3373
+ let displayName = null;
3374
+ let description = null;
3375
+ let appType = null;
3376
+ const allowedGroups = [];
3377
+ let message = null;
3378
+ let timeoutSeconds = null;
3379
+ let manifestFile = null;
3380
+ let dryRunJson = false;
3381
+ let platformRepo = null;
3382
+ let rePin = false;
3383
+ let yes = false;
3384
+ let resumePrNumber = null;
3385
+ let timeoutMinutes = null;
3386
+ let atSha = null;
3387
+ let modeFlagsSeen = 0;
3388
+ let i = 0;
3389
+ while (i < args.length) {
3390
+ const a = args[i] ?? "";
3391
+ if (a === "--new") {
3392
+ mode = "new";
3393
+ modeFlagsSeen += 1;
3394
+ i += 1;
3395
+ continue;
3396
+ }
3397
+ if (a === "--dry-run") {
3398
+ mode = "dry-run";
3399
+ modeFlagsSeen += 1;
3400
+ i += 1;
3401
+ continue;
3402
+ }
3403
+ if (a === "--apply") {
3404
+ mode = "apply";
3405
+ modeFlagsSeen += 1;
3406
+ i += 1;
3407
+ continue;
3408
+ }
3409
+ if (a === "--platform-repo") {
3410
+ const v = args[i + 1];
3411
+ if (v === undefined || v.trim().length === 0) {
3412
+ return `missing value for ${a}`;
3413
+ }
3414
+ platformRepo = v.trim();
3415
+ i += 2;
3416
+ continue;
3417
+ }
3418
+ if (a === "--re-pin") {
3419
+ rePin = true;
3420
+ i += 1;
3421
+ continue;
3422
+ }
3423
+ if (a === "--yes" || a === "-y") {
3424
+ yes = true;
3425
+ i += 1;
3426
+ continue;
3427
+ }
3428
+ if (a === "--resume") {
3429
+ const v = args[i + 1];
3430
+ if (v === undefined)
3431
+ return `missing value for ${a}`;
3432
+ mode = "resume";
3433
+ slug = v;
3434
+ modeFlagsSeen += 1;
3435
+ i += 2;
3436
+ continue;
3437
+ }
3438
+ if (a === "--abandon") {
3439
+ const v = args[i + 1];
3440
+ if (v === undefined)
3441
+ return `missing value for ${a}`;
3442
+ mode = "abandon";
3443
+ slug = v;
3444
+ modeFlagsSeen += 1;
3445
+ i += 2;
3446
+ continue;
3447
+ }
3448
+ if (a === "--file") {
3449
+ const v = args[i + 1];
3450
+ if (v === undefined)
3451
+ return `missing value for ${a}`;
3452
+ manifestFile = v;
3453
+ i += 2;
3454
+ continue;
3455
+ }
3456
+ if (a === "--json") {
3457
+ dryRunJson = true;
3458
+ i += 1;
3459
+ continue;
3460
+ }
3461
+ if (a === "--slug") {
3462
+ const v = args[i + 1];
3463
+ if (v === undefined)
3464
+ return `missing value for ${a}`;
3465
+ slug = v;
3466
+ i += 2;
3467
+ continue;
3468
+ }
3469
+ if (a === "--display-name" || a === "--name") {
3470
+ const v = args[i + 1];
3471
+ if (v === undefined)
3472
+ return `missing value for ${a}`;
3473
+ displayName = v;
3474
+ i += 2;
3475
+ continue;
3476
+ }
3477
+ if (a === "--description") {
3478
+ const v = args[i + 1];
3479
+ if (v === undefined)
3480
+ return `missing value for ${a}`;
3481
+ description = v;
3482
+ i += 2;
3483
+ continue;
3484
+ }
3485
+ if (a === "--app-type" || a === "--type") {
3486
+ const v = args[i + 1];
3487
+ if (v === undefined)
3488
+ return `missing value for ${a}`;
3489
+ if (!APP_TYPES.includes(v)) {
3490
+ return `invalid --app-type "${v}" — must be one of ${APP_TYPES.join(", ")}`;
3491
+ }
3492
+ appType = v;
3493
+ i += 2;
3494
+ continue;
3495
+ }
3496
+ if (a === "--allowed-group" || a === "--group") {
3497
+ const v = args[i + 1];
3498
+ if (v === undefined)
3499
+ return `missing value for ${a}`;
3500
+ if (!GROUP_KEY_RE2.test(v) && !GROUP_UUID_RE2.test(v)) {
3501
+ return `invalid --allowed-group "${v}" — expected a group key (HCL identifier, e.g. G_Product_Security) or an Entra Object-ID UUID`;
3502
+ }
3503
+ allowedGroups.push(v);
3504
+ i += 2;
3505
+ continue;
3506
+ }
3507
+ if (a === "--message" || a === "-m") {
3508
+ const v = args[i + 1];
3509
+ if (v === undefined)
3510
+ return `missing value for ${a}`;
3511
+ message = v;
3512
+ i += 2;
3513
+ continue;
3514
+ }
3515
+ if (a === "--timeout-seconds") {
3516
+ const v = args[i + 1];
3517
+ if (v === undefined)
3518
+ return `missing value for ${a}`;
3519
+ const n = Number(v);
3520
+ if (!Number.isFinite(n) || n <= 0 || Math.floor(n) !== n) {
3521
+ return `invalid --timeout-seconds "${v}" — expected positive integer`;
3522
+ }
3523
+ timeoutSeconds = n;
3524
+ i += 2;
3525
+ continue;
3526
+ }
3527
+ if (a === "--timeout-minutes") {
3528
+ const v = args[i + 1];
3529
+ if (v === undefined)
3530
+ return `missing value for ${a}`;
3531
+ const n = Number(v);
3532
+ if (!Number.isFinite(n) || n <= 0 || Math.floor(n) !== n) {
3533
+ return `invalid --timeout-minutes "${v}" — expected positive integer`;
3534
+ }
3535
+ timeoutMinutes = n;
3536
+ i += 2;
3537
+ continue;
3538
+ }
3539
+ if (a === "--at") {
3540
+ const v = args[i + 1];
3541
+ if (v === undefined)
3542
+ return `missing value for ${a}`;
3543
+ if (!/^[0-9a-f]{40}$/.test(v)) {
3544
+ return `invalid --at "${v}" — expected a 40-char lowercase hex git SHA from the MANAGED repo`;
3545
+ }
3546
+ atSha = v;
3547
+ i += 2;
3548
+ continue;
3549
+ }
3550
+ if (a === "--resume-pr") {
3551
+ const v = args[i + 1];
3552
+ if (v === undefined)
3553
+ return `missing value for ${a}`;
3554
+ const n = Number(v);
3555
+ if (!Number.isFinite(n) || n <= 0 || Math.floor(n) !== n) {
3556
+ return `invalid --resume-pr "${v}" — expected positive integer PR number`;
3557
+ }
3558
+ resumePrNumber = n;
3559
+ i += 2;
3560
+ continue;
3561
+ }
3562
+ return `unknown argument "${a}"`;
3563
+ }
3564
+ if (modeFlagsSeen > 1) {
3565
+ return "--new, --resume, --abandon, --dry-run, and --apply are mutually exclusive";
3566
+ }
3567
+ if (mode === "dry-run") {
3568
+ if (slug !== null)
3569
+ return "--dry-run does not accept --slug (slug is read from launchpad.yaml)";
3570
+ if (displayName !== null)
3571
+ return "--dry-run does not accept --display-name";
3572
+ if (description !== null)
3573
+ return "--dry-run does not accept --description";
3574
+ if (appType !== null)
3575
+ return "--dry-run does not accept --app-type";
3576
+ if (allowedGroups.length > 0)
3577
+ return "--dry-run does not accept --allowed-group (read from launchpad.yaml)";
3578
+ if (message !== null)
3579
+ return "--dry-run does not accept --message";
3580
+ if (timeoutSeconds !== null)
3581
+ return "--dry-run does not accept --timeout-seconds";
3582
+ if (platformRepo !== null)
3583
+ return "--dry-run does not accept --platform-repo (apply-only)";
3584
+ if (rePin)
3585
+ return "--dry-run does not accept --re-pin (apply-only)";
3586
+ if (yes)
3587
+ return "--dry-run does not accept --yes (apply-only)";
3588
+ return {
3589
+ mode: { kind: "dry-run", file: manifestFile, json: dryRunJson },
3590
+ message: null,
3591
+ timeoutSeconds: null
3592
+ };
3593
+ }
3594
+ if (mode === "apply") {
3595
+ if (slug !== null)
3596
+ return "--apply does not accept --slug (slug is read from launchpad.yaml)";
3597
+ if (displayName !== null)
3598
+ return "--apply does not accept --display-name";
3599
+ if (description !== null)
3600
+ return "--apply does not accept --description";
3601
+ if (appType !== null)
3602
+ return "--apply does not accept --app-type";
3603
+ if (allowedGroups.length > 0)
3604
+ return "--apply does not accept --allowed-group (read from launchpad.yaml)";
3605
+ if (message !== null)
3606
+ return "--apply does not accept --message";
3607
+ if (timeoutSeconds !== null)
3608
+ return "--apply does not accept --timeout-seconds (use --timeout-minutes instead — apply polling is multi-minute)";
3609
+ if (dryRunJson)
3610
+ return "--apply does not accept --json";
3611
+ return {
3612
+ mode: {
3613
+ kind: "apply",
3614
+ file: manifestFile,
3615
+ platformRepo,
3616
+ rePin,
3617
+ yes,
3618
+ resumePrNumber,
3619
+ atSha,
3620
+ timeoutMinutes
3621
+ },
3622
+ message: null,
3623
+ timeoutSeconds: null
3624
+ };
3625
+ }
3626
+ if (resumePrNumber !== null) {
3627
+ return "--resume-pr is only valid with --apply";
3628
+ }
3629
+ if (atSha !== null) {
3630
+ return "--at is only valid with --apply";
3631
+ }
3632
+ if (timeoutMinutes !== null) {
3633
+ return "--timeout-minutes is only valid with --apply";
3634
+ }
3635
+ if (manifestFile !== null) {
3636
+ return "--file is only valid with --dry-run or --apply";
3637
+ }
3638
+ if (dryRunJson) {
3639
+ return "--json is only valid with --dry-run";
3640
+ }
3641
+ if (platformRepo !== null) {
3642
+ return "--platform-repo is only valid with --apply";
3643
+ }
3644
+ if (rePin) {
3645
+ return "--re-pin is only valid with --apply";
3646
+ }
3647
+ if (yes) {
3648
+ return "--yes is only valid with --apply";
3649
+ }
3650
+ if (mode === "content") {
3651
+ if (slug !== null && !SLUG_RE3.test(slug)) {
3652
+ return `invalid slug "${slug}" — expected ${SLUG_RE3.source}`;
3653
+ }
3654
+ return {
3655
+ mode: { kind: "content", slug },
3656
+ message,
3657
+ timeoutSeconds
3658
+ };
3659
+ }
3660
+ if (mode === "resume" || mode === "abandon") {
3661
+ if (slug === null || !SLUG_RE3.test(slug)) {
3662
+ return `--${mode} requires a valid slug (kebab-case)`;
3195
3663
  }
3196
- const seenSecrets = new Set;
3197
- for (const secret of set.secrets) {
3198
- if (seenSecrets.has(secret)) {
3199
- ctx.addIssue({
3200
- code: "custom",
3201
- message: `duplicate secret "${secret}" in set "${set.name}"`,
3202
- path: ["secretSets", i, "secrets"]
3203
- });
3204
- }
3205
- seenSecrets.add(secret);
3664
+ if (displayName !== null || appType !== null || allowedGroups.length > 0) {
3665
+ return `--${mode} does not accept create-only flags (--display-name / --app-type / --allowed-group)`;
3206
3666
  }
3207
- for (let j = 0;j < (set.exclude ?? []).length; j++) {
3208
- const ex = set.exclude[j];
3209
- if (!seenMembers.has(ex.member)) {
3210
- ctx.addIssue({
3211
- code: "custom",
3212
- message: `exclude references member "${ex.member}" which is not in set "${set.name}".members`,
3213
- path: ["secretSets", i, "exclude", j, "member"]
3214
- });
3215
- }
3216
- if (!seenSecrets.has(ex.secret)) {
3217
- ctx.addIssue({
3218
- code: "custom",
3219
- message: `exclude references secret "${ex.secret}" which is not in set "${set.name}".secrets`,
3220
- path: ["secretSets", i, "exclude", j, "secret"]
3221
- });
3222
- }
3667
+ if (message !== null) {
3668
+ return `--${mode} does not accept --message`;
3223
3669
  }
3670
+ return {
3671
+ mode: { kind: mode, slug },
3672
+ message: null,
3673
+ timeoutSeconds
3674
+ };
3675
+ }
3676
+ if (slug === null)
3677
+ return "--new requires --slug";
3678
+ if (!SLUG_RE3.test(slug))
3679
+ return `invalid slug "${slug}" — expected ${SLUG_RE3.source}`;
3680
+ if (slug.length < 3 || slug.length > 58) {
3681
+ return `slug length out of bounds (3–58 chars): "${slug}"`;
3682
+ }
3683
+ if (displayName === null || displayName.length === 0) {
3684
+ return "--new requires --display-name";
3685
+ }
3686
+ if (appType === null) {
3687
+ return `--new requires --app-type (one of ${APP_TYPES.join(", ")})`;
3688
+ }
3689
+ if (allowedGroups.length === 0) {
3690
+ return "--new requires at least one --allowed-group";
3224
3691
  }
3225
- });
3226
- function parseFleetSecretSets(input) {
3227
- const res = FleetSecretSetsSchema.safeParse(input);
3228
- if (res.success)
3229
- return { ok: true, manifest: res.data };
3230
3692
  return {
3231
- ok: false,
3232
- issues: res.error.issues.map((i) => ({
3233
- path: i.path.join("."),
3234
- message: i.message
3235
- }))
3693
+ mode: {
3694
+ kind: "new",
3695
+ slug,
3696
+ displayName,
3697
+ description,
3698
+ appType,
3699
+ allowedGroups
3700
+ },
3701
+ message,
3702
+ timeoutSeconds
3236
3703
  };
3237
3704
  }
3238
- function membersForSecret(set, secret) {
3239
- const excluded = new Set((set.exclude ?? []).filter((e) => e.secret === secret).map((e) => e.member));
3240
- return set.members.filter((m) => !excluded.has(m));
3241
- }
3242
- // ../launchpad-engine/dist/redact-status.js
3243
- var UNSAFE_KEY = new Set(["__proto__", "prototype", "constructor"]);
3705
+
3706
+ // src/deploy/apply.ts
3707
+ import { existsSync as existsSync3, rmSync } from "node:fs";
3708
+ import { resolve as resolvePath, join as join7 } from "node:path";
3709
+
3244
3710
  // src/manifest/load.ts
3711
+ import { readFileSync as readFileSync5 } from "node:fs";
3712
+ import { parse as parseYaml3, YAMLParseError } from "yaml";
3245
3713
  function loadManifest(path6) {
3246
3714
  let raw;
3247
3715
  try {
3248
- raw = readFileSync4(path6, "utf8");
3716
+ raw = readFileSync5(path6, "utf8");
3249
3717
  } catch (err) {
3250
3718
  const e = err;
3251
3719
  if (e.code === "ENOENT") {
@@ -3258,7 +3726,7 @@ function loadManifest(path6) {
3258
3726
  function parseManifest2(yamlText, path6) {
3259
3727
  let parsed;
3260
3728
  try {
3261
- parsed = parseYaml2(yamlText);
3729
+ parsed = parseYaml3(yamlText);
3262
3730
  } catch (err) {
3263
3731
  const message = err instanceof YAMLParseError ? err.message : err.message ?? String(err);
3264
3732
  return { kind: "yaml-parse-error", path: path6, message };
@@ -3330,29 +3798,23 @@ async function runDeployApply(opts, io, deps = {}) {
3330
3798
  io.err(`launchpad deploy --apply: manifest's metadata.name '${slug}' is not a valid slug`);
3331
3799
  return 1;
3332
3800
  }
3333
- let manifestSha;
3334
- try {
3335
- manifestSha = (deps.gitHeadSha ?? defaultGitHeadSha)(process.cwd());
3336
- } catch (e) {
3337
- io.err(`launchpad deploy --apply: failed to resolve git HEAD in ${process.cwd()}: ${describe10(e)}`);
3338
- io.err(" Apply needs a clean commit so portal-bot can fetch launchpad.yaml at that sha.");
3339
- return 2;
3340
- }
3341
- if (!MANIFEST_SHA_REGEX.test(manifestSha)) {
3342
- io.err(`launchpad deploy --apply: git HEAD returned an unexpected sha shape: '${manifestSha}'`);
3343
- return 2;
3801
+ if (opts.atSha !== undefined && !MANIFEST_SHA_REGEX.test(opts.atSha)) {
3802
+ io.err(`launchpad deploy --apply: --at expects a 40-char lowercase hex git SHA, got '${opts.atSha}'`);
3803
+ return 64;
3344
3804
  }
3345
- io.out(`Planning apply for ${slug} @ ${manifestSha.slice(0, 7)} …`);
3805
+ io.out(opts.atSha !== undefined ? `Planning apply for ${slug} @ ${opts.atSha.slice(0, 7)} (managed repo) …` : `Planning apply for ${slug} (bot resolves managed main HEAD) …`);
3346
3806
  let plan;
3347
3807
  try {
3348
3808
  plan = await apiJson(cfg, {
3349
3809
  method: "POST",
3350
3810
  path: `/apps/${slug}/manifest/plan`,
3351
- jsonBody: { manifestSha }
3811
+ jsonBody: opts.atSha !== undefined ? { manifestSha: opts.atSha } : {}
3352
3812
  }, fetcher);
3353
3813
  } catch (e) {
3354
3814
  return mapHttpError(e, slug, io);
3355
3815
  }
3816
+ const manifestSha = plan.manifestSha;
3817
+ io.out(`Manifest pinned @ ${manifestSha.slice(0, 7)} (managed repo provenance).`);
3356
3818
  for (const w of plan.warnings)
3357
3819
  io.err(`! ${w}`);
3358
3820
  io.out("");
@@ -3472,17 +3934,9 @@ function sleep(ms) {
3472
3934
  return Promise.resolve();
3473
3935
  return new Promise((res) => setTimeout(res, ms));
3474
3936
  }
3475
- function defaultGitHeadSha(cwd) {
3476
- const out = execFileSync("git", ["rev-parse", "HEAD"], {
3477
- cwd,
3478
- encoding: "utf8",
3479
- stdio: ["ignore", "pipe", "pipe"]
3480
- });
3481
- return out.trim();
3482
- }
3483
3937
  function deletePinIfPresent(cfg, slug, io) {
3484
- const pinPath = join6(cfg.stateDir, slug, "group.json");
3485
- if (!existsSync2(pinPath))
3938
+ const pinPath = join7(cfg.stateDir, slug, "group.json");
3939
+ if (!existsSync3(pinPath))
3486
3940
  return;
3487
3941
  try {
3488
3942
  rmSync(pinPath, { force: true });
@@ -3562,7 +4016,7 @@ function renderManifestError(loaded, io) {
3562
4016
  }
3563
4017
 
3564
4018
  // src/deploy/dry-run.ts
3565
- import { execFileSync as execFileSync2 } from "node:child_process";
4019
+ import { execFileSync } from "node:child_process";
3566
4020
  import { resolve as resolve5 } from "node:path";
3567
4021
  var MANIFEST_SHA_REGEX2 = /^[0-9a-f]{40}$/;
3568
4022
  async function runDeployDryRun(opts, io, deps = {}) {
@@ -3576,7 +4030,7 @@ async function runDeployDryRun(opts, io, deps = {}) {
3576
4030
  const slug = loaded.manifest.metadata.name;
3577
4031
  let manifestSha;
3578
4032
  try {
3579
- manifestSha = (deps.gitHeadSha ?? defaultGitHeadSha2)(process.cwd());
4033
+ manifestSha = (deps.gitHeadSha ?? defaultGitHeadSha)(process.cwd());
3580
4034
  } catch (e) {
3581
4035
  const msg = `failed to resolve git HEAD in ${process.cwd()}: ${describe11(e)}`;
3582
4036
  if (opts.json) {
@@ -3619,8 +4073,8 @@ async function runDeployDryRun(opts, io, deps = {}) {
3619
4073
  }
3620
4074
  return 0;
3621
4075
  }
3622
- function defaultGitHeadSha2(cwd) {
3623
- const out = execFileSync2("git", ["rev-parse", "HEAD"], {
4076
+ function defaultGitHeadSha(cwd) {
4077
+ const out = execFileSync("git", ["rev-parse", "HEAD"], {
3624
4078
  cwd,
3625
4079
  encoding: "utf8",
3626
4080
  stdio: ["ignore", "pipe", "pipe"]
@@ -3952,6 +4406,7 @@ async function runDeploy(args, io) {
3952
4406
  platformRepo: flags.mode.platformRepo,
3953
4407
  rePin: flags.mode.rePin,
3954
4408
  yes: flags.mode.yes,
4409
+ ...flags.mode.atSha !== null ? { atSha: flags.mode.atSha } : {},
3955
4410
  ...flags.mode.resumePrNumber !== null ? { resumePrNumber: flags.mode.resumePrNumber } : {},
3956
4411
  ...flags.mode.timeoutMinutes !== null ? { timeoutMinutes: flags.mode.timeoutMinutes } : {}
3957
4412
  }, io);
@@ -3974,7 +4429,7 @@ async function runDeploy(args, io) {
3974
4429
  }
3975
4430
  const cwd = process.cwd();
3976
4431
  const manifestPath = path6.join(cwd, "launchpad.yaml");
3977
- if (existsSync3(manifestPath)) {
4432
+ if (existsSync4(manifestPath)) {
3978
4433
  return runModelADeploy({ cwd, manifestPath, argv: args, io });
3979
4434
  }
3980
4435
  const parsed = parseArgs2(args);
@@ -4001,12 +4456,34 @@ async function runDeploy(args, io) {
4001
4456
  try {
4002
4457
  const cfg = loadConfig();
4003
4458
  io.out(`Packaging working tree for ${slug} …`);
4004
- const files = await listDeployFiles({ cwd: process.cwd() });
4005
- if (files.length === 0) {
4459
+ const rawFiles = await listDeployFiles({ cwd: process.cwd() });
4460
+ if (rawFiles.length === 0) {
4006
4461
  io.err("launchpad deploy: no files to deploy (git ls-files returned empty).");
4007
4462
  io.err(" did you run this from inside the cloned working tree?");
4008
4463
  return 1;
4009
4464
  }
4465
+ let contentManifestYaml = null;
4466
+ const contentManifestPath = path6.join(process.cwd(), "launchpad.yaml");
4467
+ if (existsSync4(contentManifestPath)) {
4468
+ try {
4469
+ contentManifestYaml = readFileSync6(contentManifestPath, "utf8");
4470
+ } catch {
4471
+ contentManifestYaml = null;
4472
+ }
4473
+ }
4474
+ const boundary = applyBoundaryToFiles({
4475
+ cwd: process.cwd(),
4476
+ manifestYaml: contentManifestYaml,
4477
+ files: rawFiles
4478
+ });
4479
+ if (boundary.kind === "error") {
4480
+ io.err(`launchpad deploy: ${boundary.message}`);
4481
+ return 1;
4482
+ }
4483
+ for (const w of boundary.warnings) {
4484
+ io.err(`warning: ${w}`);
4485
+ }
4486
+ const files = boundary.files;
4010
4487
  const packed = await packTarGz(process.cwd(), files);
4011
4488
  io.out(`Packed ${packed.fileCount} files (${formatBytes2(packed.uncompressedBytes)}` + ` uncompressed, ${formatBytes2(packed.bytes.length)} gzipped)`);
4012
4489
  io.out(`Uploading to ${cfg.botUrl}/apps/${slug}/deploy-pr …`);
@@ -4026,6 +4503,9 @@ async function runDeploy(args, io) {
4026
4503
  io.out(`PR opened: ${response.reviewUrl}`);
4027
4504
  io.out(`Branch: ${response.branch}`);
4028
4505
  io.out(`Number: #${response.prNumber}`);
4506
+ io.out("");
4507
+ io.out(`Review from the CLI: launchpad review ${response.prNumber} --slug ${slug}`);
4508
+ io.out(`Merge when approved: launchpad merge ${response.prNumber} --slug ${slug}`);
4029
4509
  if (response.bootstrapWorkflow === true) {
4030
4510
  io.out("");
4031
4511
  io.out("(first deploy: included canonical launchpad-review.yml workflow file)");
@@ -4122,6 +4602,28 @@ function formatBytes2(n) {
4122
4602
  function describe12(e) {
4123
4603
  return e instanceof Error ? e.message : String(e);
4124
4604
  }
4605
+ function surfaceDeployExtras(body, io, slug) {
4606
+ if (body.boundary_stripped !== undefined && body.boundary_stripped.length > 0) {
4607
+ io.err(`warning: the bot stripped ${body.boundary_stripped.length} never-shippable file(s) server-side:`);
4608
+ for (const p of body.boundary_stripped.slice(0, 10)) {
4609
+ io.err(` - ${p}`);
4610
+ }
4611
+ if (body.boundary_stripped.length > 10) {
4612
+ io.err(` … and ${body.boundary_stripped.length - 10} more`);
4613
+ }
4614
+ }
4615
+ const se = body.standing_exceptions;
4616
+ if (se !== undefined && se.count > 0) {
4617
+ io.out(` ${se.count} standing policy exception(s) observed in content already live on main (non-blocking):`);
4618
+ for (const e of se.entries.slice(0, 5)) {
4619
+ io.out(` - ${e.path} [${e.rule}]`);
4620
+ }
4621
+ if (se.entries.length > 5) {
4622
+ io.out(` … and ${se.entries.length - 5} more`);
4623
+ }
4624
+ io.out(` Full list: \`launchpad status ${slug}\`.`);
4625
+ }
4626
+ }
4125
4627
  function resolveManifestSlug(parsed) {
4126
4628
  if (parsed === null || typeof parsed !== "object" || typeof parsed.metadata !== "object" || parsed.metadata === null) {
4127
4629
  return null;
@@ -4137,7 +4639,7 @@ async function runModelADeploy(args) {
4137
4639
  const { cwd, manifestPath, io } = args;
4138
4640
  let slug;
4139
4641
  try {
4140
- const metaSlug = resolveManifestSlug(parseYaml3(readFileSync5(manifestPath, "utf8")));
4642
+ const metaSlug = resolveManifestSlug(parseYaml4(readFileSync6(manifestPath, "utf8")));
4141
4643
  if (metaSlug === null) {
4142
4644
  io.err(`launchpad deploy: launchpad.yaml is missing metadata.slug (v2) / metadata.name (v1). ` + `Run \`launchpad init\` again to regenerate the manifest.`);
4143
4645
  return 64;
@@ -4178,6 +4680,9 @@ async function runModelADeploy(args) {
4178
4680
  case "walk-error":
4179
4681
  io.err(`launchpad deploy: walk failed — ${result.message}`);
4180
4682
  return 1;
4683
+ case "boundary-error":
4684
+ io.err(`launchpad deploy: ${result.message}`);
4685
+ return 1;
4181
4686
  case "pack-error":
4182
4687
  io.err(`launchpad deploy: pack failed — ${result.message}`);
4183
4688
  return 1;
@@ -4213,10 +4718,22 @@ async function runModelADeploy(args) {
4213
4718
  return 1;
4214
4719
  }
4215
4720
  case "ok": {
4216
- if (result.result.status === "provisioning_started") {
4217
- const submissionId = typeof result.result.submissionId === "string" ? result.result.submissionId : "(missing)";
4218
- const appType = typeof result.result.appType === "string" ? result.result.appType : "(missing)";
4219
- const message = typeof result.result.message === "string" ? result.result.message : "Bot accepted the upload and started provisioning.";
4721
+ for (const w of result.boundaryWarnings) {
4722
+ io.err(`warning: ${w}`);
4723
+ }
4724
+ const success = result.result;
4725
+ if ("outcome" in success) {
4726
+ io.out("Nothing to deploy — your app is already live at this content.");
4727
+ if (typeof success.head_sha === "string") {
4728
+ io.out(` (managed main @ ${success.head_sha.slice(0, 8)} matches the bundle)`);
4729
+ }
4730
+ surfaceDeployExtras(success, io, slug);
4731
+ return 0;
4732
+ }
4733
+ if (success.status === "provisioning_started") {
4734
+ const submissionId = typeof success.submissionId === "string" ? success.submissionId : "(missing)";
4735
+ const appType = typeof success.appType === "string" ? success.appType : "(missing)";
4736
+ const message = typeof success.message === "string" ? success.message : "Bot accepted the upload and started provisioning.";
4220
4737
  io.out(`✓ First-time deploy — provisioning workflow started for ${slug}`);
4221
4738
  io.out(` submission: ${submissionId}`);
4222
4739
  io.out(` appType: ${appType}`);
@@ -4228,12 +4745,12 @@ async function runModelADeploy(args) {
4228
4745
  io.out(` launchpad deploy # re-run once lifecycle is "live"`);
4229
4746
  return 0;
4230
4747
  }
4231
- if (typeof result.result.commit_sha !== "string" || typeof result.result.repo !== "string") {
4748
+ if (typeof success.commit_sha !== "string" || typeof success.repo !== "string") {
4232
4749
  io.err(`launchpad deploy: bot returned an unexpected 202 body — missing commit_sha or repo.`);
4233
- io.err(` got: ${JSON.stringify(result.result)}`);
4750
+ io.err(` got: ${JSON.stringify(success)}`);
4234
4751
  return 1;
4235
4752
  }
4236
- io.out(`✓ Bundle accepted — committed as ${result.result.commit_sha.slice(0, 8)} on ${result.result.repo}`);
4753
+ io.out(`✓ Bundle accepted — committed as ${success.commit_sha.slice(0, 8)} on ${success.repo}`);
4237
4754
  io.out(` ${result.fileCount} files (${formatBytes2(result.compressedBytes)} gzipped)`);
4238
4755
  if (result.workerScript !== null) {
4239
4756
  io.out(` cron Worker '${result.workerScript}' bundled + shipped (the bot deploys it after the commit)`);
@@ -4241,13 +4758,13 @@ async function runModelADeploy(args) {
4241
4758
  if (result.walk.skipped.length > 0) {
4242
4759
  io.out(` ${result.walk.skipped.length} entries skipped (.gitignore / default-ignore / symlink)`);
4243
4760
  }
4244
- if (typeof result.result.message === "string") {
4245
- io.out(` ${result.result.message}`);
4761
+ if (typeof success.message === "string") {
4762
+ io.out(` ${success.message}`);
4246
4763
  }
4764
+ surfaceDeployExtras(success, io, slug);
4247
4765
  io.out("");
4248
- io.out("Next steps:");
4249
- io.out(` launchpad status ${slug} # poll for the live URL`);
4250
- io.out(` launchpad apps # see lifecycle`);
4766
+ io.out("Committed; build pending — Cloudflare Pages is building this deploy now.");
4767
+ io.out(`Run \`launchpad status ${slug}\` to confirm the build outcome (success / failure + log excerpt).`);
4251
4768
  return 0;
4252
4769
  }
4253
4770
  }
@@ -4450,7 +4967,7 @@ function describe13(e) {
4450
4967
  }
4451
4968
 
4452
4969
  // src/commands/generate.ts
4453
- import { mkdirSync, readFileSync as readFileSync6, writeFileSync } from "node:fs";
4970
+ import { mkdirSync, readFileSync as readFileSync7, writeFileSync } from "node:fs";
4454
4971
  import { dirname as dirname4, resolve as resolve6, relative as relative3 } from "node:path";
4455
4972
  var generateCommand = {
4456
4973
  name: "generate",
@@ -4538,7 +5055,7 @@ function applyOne(artefact, path8, out, force) {
4538
5055
  }
4539
5056
  function readIfExists(path8) {
4540
5057
  try {
4541
- return { kind: "ok", content: readFileSync6(path8, "utf8") };
5058
+ return { kind: "ok", content: readFileSync7(path8, "utf8") };
4542
5059
  } catch (err) {
4543
5060
  const e = err;
4544
5061
  if (e.code === "ENOENT")
@@ -4749,13 +5266,13 @@ function parseFlags(args) {
4749
5266
  }
4750
5267
 
4751
5268
  // src/groups/client.ts
4752
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "node:fs";
4753
- import { dirname as dirname5, join as join8 } from "node:path";
5269
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync8, writeFileSync as writeFileSync2 } from "node:fs";
5270
+ import { dirname as dirname5, join as join9 } from "node:path";
4754
5271
  var CACHE_TTL_MS = 60 * 60 * 1000;
4755
5272
  var CACHE_FILENAME = "groups.json";
4756
5273
  async function fetchGroups(cfg, opts = {}) {
4757
5274
  const now = opts.now ?? Date.now;
4758
- const cachePath = join8(cfg.cacheDir, CACHE_FILENAME);
5275
+ const cachePath = join9(cfg.cacheDir, CACHE_FILENAME);
4759
5276
  if (opts.forceRefresh !== true) {
4760
5277
  const cached = readCache(cachePath);
4761
5278
  if (cached !== null) {
@@ -4793,7 +5310,7 @@ async function fetchGroups(cfg, opts = {}) {
4793
5310
  function readCache(path8) {
4794
5311
  let raw;
4795
5312
  try {
4796
- raw = readFileSync7(path8, "utf8");
5313
+ raw = readFileSync8(path8, "utf8");
4797
5314
  } catch {
4798
5315
  return null;
4799
5316
  }
@@ -5294,17 +5811,17 @@ function describe17(e) {
5294
5811
 
5295
5812
  // src/commands/init.ts
5296
5813
  import { createInterface } from "node:readline/promises";
5297
- import { existsSync as existsSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync3 } from "node:fs";
5814
+ import { existsSync as existsSync6, readFileSync as readFileSync10, writeFileSync as writeFileSync3 } from "node:fs";
5298
5815
  import { resolve as resolve7 } from "node:path";
5299
5816
  import { stringify as yamlStringify } from "yaml";
5300
5817
 
5301
5818
  // src/detect/index.ts
5302
- import { existsSync as existsSync4, readFileSync as readFileSync8, statSync } from "node:fs";
5303
- import { join as join9 } from "node:path";
5819
+ import { existsSync as existsSync5, readFileSync as readFileSync9, statSync } from "node:fs";
5820
+ import { join as join10 } from "node:path";
5304
5821
  function detectAppShape(cwd) {
5305
- const hasPackageJson = existsSync4(join9(cwd, "package.json"));
5306
- const hasAnyLockfile = LOCKFILES.some(({ file }) => existsSync4(join9(cwd, file)));
5307
- const hasViteConfig = VITE_CONFIG_NAMES.some((n) => existsSync4(join9(cwd, n)));
5822
+ const hasPackageJson = existsSync5(join10(cwd, "package.json"));
5823
+ const hasAnyLockfile = LOCKFILES.some(({ file }) => existsSync5(join10(cwd, file)));
5824
+ const hasViteConfig = VITE_CONFIG_NAMES.some((n) => existsSync5(join10(cwd, n)));
5308
5825
  if (!hasPackageJson && !hasAnyLockfile && !hasViteConfig) {
5309
5826
  return {
5310
5827
  kind: "not-applicable",
@@ -5350,7 +5867,7 @@ var LOCKFILES = [
5350
5867
  { file: "yarn.lock", pm: "yarn" }
5351
5868
  ];
5352
5869
  function detectPackageManager(cwd) {
5353
- const present = LOCKFILES.filter(({ file }) => existsSync4(join9(cwd, file)));
5870
+ const present = LOCKFILES.filter(({ file }) => existsSync5(join10(cwd, file)));
5354
5871
  if (present.length === 0) {
5355
5872
  return {
5356
5873
  kind: "ambiguous",
@@ -5372,8 +5889,8 @@ function detectPackageManager(cwd) {
5372
5889
  }
5373
5890
  function detectVitePresence(cwd) {
5374
5891
  for (const name of VITE_CONFIG_NAMES) {
5375
- const p = join9(cwd, name);
5376
- if (existsSync4(p)) {
5892
+ const p = join10(cwd, name);
5893
+ if (existsSync5(p)) {
5377
5894
  return { kind: "ok", value: { path: p } };
5378
5895
  }
5379
5896
  }
@@ -5383,9 +5900,9 @@ function detectVitePresence(cwd) {
5383
5900
  };
5384
5901
  }
5385
5902
  function detectAppType(cwd) {
5386
- const fnDir = join9(cwd, "functions");
5903
+ const fnDir = join10(cwd, "functions");
5387
5904
  let hasFunctionsDir = false;
5388
- if (existsSync4(fnDir)) {
5905
+ if (existsSync5(fnDir)) {
5389
5906
  try {
5390
5907
  hasFunctionsDir = statSync(fnDir).isDirectory();
5391
5908
  } catch {
@@ -5395,8 +5912,8 @@ function detectAppType(cwd) {
5395
5912
  return { kind: "ok", value: hasFunctionsDir ? "react+api" : "react" };
5396
5913
  }
5397
5914
  function detectBuildCommand(cwd, pm) {
5398
- const pkgJsonPath = join9(cwd, "package.json");
5399
- if (!existsSync4(pkgJsonPath)) {
5915
+ const pkgJsonPath = join10(cwd, "package.json");
5916
+ if (!existsSync5(pkgJsonPath)) {
5400
5917
  return {
5401
5918
  kind: "ambiguous",
5402
5919
  reason: "no package.json at repo root. Run your package manager's `init` first."
@@ -5404,7 +5921,7 @@ function detectBuildCommand(cwd, pm) {
5404
5921
  }
5405
5922
  let pkgJson;
5406
5923
  try {
5407
- pkgJson = JSON.parse(readFileSync8(pkgJsonPath, "utf8"));
5924
+ pkgJson = JSON.parse(readFileSync9(pkgJsonPath, "utf8"));
5408
5925
  } catch (e) {
5409
5926
  return {
5410
5927
  kind: "ambiguous",
@@ -5439,7 +5956,7 @@ var OUT_DIR_REGEX = /\bbuild\s*:\s*\{[^{}]*?\boutDir\s*:\s*['"]([^'"]+)['"]/s;
5439
5956
  function detectDestinationDir(cwd, vite) {
5440
5957
  let text;
5441
5958
  try {
5442
- text = readFileSync8(vite.path, "utf8");
5959
+ text = readFileSync9(vite.path, "utf8");
5443
5960
  } catch (e) {
5444
5961
  return {
5445
5962
  kind: "ambiguous",
@@ -5469,7 +5986,7 @@ async function runInit(args, io, prompt) {
5469
5986
  }
5470
5987
  const { inputs, options } = parsed;
5471
5988
  const outPath = resolve7(process.cwd(), options.out);
5472
- if (existsSync5(outPath) && !options.force) {
5989
+ if (existsSync6(outPath) && !options.force) {
5473
5990
  io.err(`launchpad init: ${outPath} already exists`);
5474
5991
  io.err("Pass --force to overwrite.");
5475
5992
  return 64;
@@ -5876,6 +6393,10 @@ function buildManifest(inputs, detected) {
5876
6393
  }
5877
6394
  ];
5878
6395
  }
6396
+ manifest.app = {
6397
+ root: ".",
6398
+ include: [...defaultIncludeForAppType(inputs.type)]
6399
+ };
5879
6400
  return manifest;
5880
6401
  }
5881
6402
  function renderYaml(manifest) {
@@ -5883,8 +6404,8 @@ function renderYaml(manifest) {
5883
6404
  }
5884
6405
  function ensureGitignoreEntries(path8, entries) {
5885
6406
  let current = "";
5886
- if (existsSync5(path8)) {
5887
- current = readFileSync9(path8, "utf8");
6407
+ if (existsSync6(path8)) {
6408
+ current = readFileSync10(path8, "utf8");
5888
6409
  }
5889
6410
  const lines = current.split(/\r?\n/);
5890
6411
  const present = new Set(lines.map((l) => l.trim()));
@@ -6016,7 +6537,7 @@ async function runLogs(args, io) {
6016
6537
  if (r.deployments.length === 0) {
6017
6538
  io.out("");
6018
6539
  io.out("(note: this command shows deploy history, not runtime logs —");
6019
- io.out(" see the portal app detail page for the link to Cf dashboard logs)");
6540
+ io.out(` run \`launchpad status ${slug}\` for the latest build outcome)`);
6020
6541
  }
6021
6542
  return 0;
6022
6543
  } catch (e) {
@@ -6184,6 +6705,8 @@ async function runMerge(args, io) {
6184
6705
  if (body.selfMerge) {
6185
6706
  io.out("Note: self-merge (last commit author == caller)");
6186
6707
  }
6708
+ io.out("");
6709
+ io.out(`Merged; build pending — run \`launchpad status ${slug}\` to confirm the build outcome.`);
6187
6710
  return 0;
6188
6711
  }
6189
6712
  let env = null;
@@ -6296,7 +6819,7 @@ function renderBotError(status, env, io, prNumber = null) {
6296
6819
  io.err(`launchpad merge: GitHub upstream error (${detail ?? code}).`);
6297
6820
  return;
6298
6821
  case "merge_unprocessable":
6299
- io.err(`launchpad merge: GitHub refused the merge (422): ${detail ?? "see GitHub PR page for details"}`);
6822
+ io.err(`launchpad merge: GitHub refused the merge (422): ${detail ?? (prNumber !== null ? `run \`launchpad review ${prNumber}\` for details` : "run `launchpad review <prNumber>` for details")}`);
6300
6823
  return;
6301
6824
  default:
6302
6825
  io.err(`launchpad merge: bot returned ${status} ${code}${detail !== undefined ? `: ${detail}` : ""}`);
@@ -6783,6 +7306,39 @@ async function fetchManifestStatus(cfg, slug, fetcher = fetch) {
6783
7306
  return apiJson(cfg, { path: path10 }, fetcher);
6784
7307
  }
6785
7308
 
7309
+ // src/deploy/deployment-status.ts
7310
+ async function fetchStandingExceptions(cfg, slug, fetcher = fetch) {
7311
+ let raw;
7312
+ try {
7313
+ raw = await apiJson(cfg, { path: `/apps/${encodeURIComponent(slug)}/exceptions` }, fetcher);
7314
+ } catch (e) {
7315
+ if (e instanceof NotFoundError)
7316
+ return null;
7317
+ throw e;
7318
+ }
7319
+ if (!Array.isArray(raw.exceptions))
7320
+ return null;
7321
+ return raw.exceptions.filter((e) => typeof e === "object" && e !== null && typeof e.path === "string" && typeof e.rule === "string" && typeof e.detectedAt === "string" && typeof e.deployRef === "string");
7322
+ }
7323
+ async function fetchDeploymentStatus(cfg, slug, fetcher = fetch) {
7324
+ let raw;
7325
+ try {
7326
+ raw = await apiJson(cfg, { path: `/apps/${encodeURIComponent(slug)}/deployment-status` }, fetcher);
7327
+ } catch (e) {
7328
+ if (e instanceof NotFoundError)
7329
+ return null;
7330
+ throw e;
7331
+ }
7332
+ if (typeof raw.supported !== "boolean")
7333
+ return null;
7334
+ return {
7335
+ slug: typeof raw.slug === "string" ? raw.slug : slug,
7336
+ supported: raw.supported,
7337
+ liveDeployment: raw.liveDeployment ?? null,
7338
+ lastSuccessfulDeployment: raw.lastSuccessfulDeployment ?? null
7339
+ };
7340
+ }
7341
+
6786
7342
  // src/commands/pull.ts
6787
7343
  var pullCommand = {
6788
7344
  name: "pull",
@@ -6828,6 +7384,12 @@ async function runPull(args, io) {
6828
7384
  includeManifest: true
6829
7385
  });
6830
7386
  if (state.manifestYaml === null || state.manifestYaml === undefined) {
7387
+ const live = await fetchLiveDeploymentBestEffort(cfg, parsed.slug);
7388
+ if (live?.liveDeployment != null && (live.liveDeployment.buildStatus === "success" || live.lastSuccessfulDeployment != null)) {
7389
+ io.err(`launchpad pull: no platform-tracked manifest for "${parsed.slug}", ` + `but the app HAS live content (last deployment ${live.liveDeployment.createdOn} ` + `via ${live.liveDeployment.trigger === "git-push" ? "git push" : live.liveDeployment.trigger}, ` + `build ${live.liveDeployment.buildStatus}).`);
7390
+ io.err(` This app deploys outside \`launchpad deploy\`, so there is no manifest to pull. ` + `Run \`launchpad status ${parsed.slug}\` for the live deployment detail.`);
7391
+ return 1;
7392
+ }
6831
7393
  io.err(`launchpad pull: no deployed manifest for "${parsed.slug}" yet — ` + `run \`launchpad deploy\` first, or check \`launchpad status\` for the apply state.`);
6832
7394
  return 1;
6833
7395
  }
@@ -6864,6 +7426,13 @@ async function runPull(args, io) {
6864
7426
  return 1;
6865
7427
  }
6866
7428
  }
7429
+ async function fetchLiveDeploymentBestEffort(cfg, slug) {
7430
+ try {
7431
+ return await fetchDeploymentStatus(cfg, slug);
7432
+ } catch {
7433
+ return null;
7434
+ }
7435
+ }
6867
7436
  function parseArgs8(args, cwd = process.cwd()) {
6868
7437
  let slug = null;
6869
7438
  let out = null;
@@ -6938,7 +7507,7 @@ function describe23(e) {
6938
7507
  }
6939
7508
 
6940
7509
  // src/commands/status.ts
6941
- import { readFileSync as readFileSync10 } from "node:fs";
7510
+ import { readFileSync as readFileSync11 } from "node:fs";
6942
7511
  var statusCommand = {
6943
7512
  name: "status",
6944
7513
  summary: "show drift between local launchpad.yaml and deployed state",
@@ -6995,7 +7564,7 @@ async function runStatus(args, io) {
6995
7564
  }
6996
7565
  let localYaml;
6997
7566
  try {
6998
- localYaml = readFileSync10(parsed.file, "utf8");
7567
+ localYaml = readFileSync11(parsed.file, "utf8");
6999
7568
  } catch (e) {
7000
7569
  io.err(`launchpad status: cannot read local manifest at ${parsed.file}: ${describe24(e)}`);
7001
7570
  if (lifecycle !== null && lifecycle.state === "live") {
@@ -7005,8 +7574,8 @@ async function runStatus(args, io) {
7005
7574
  }
7006
7575
  let localObj;
7007
7576
  try {
7008
- const { parse: parseYaml4 } = await import("yaml");
7009
- localObj = parseYaml4(localYaml);
7577
+ const { parse: parseYaml5 } = await import("yaml");
7578
+ localObj = parseYaml5(localYaml);
7010
7579
  } catch (e) {
7011
7580
  io.err(`launchpad status: ${parsed.file} is not valid YAML: ${describe24(e)}`);
7012
7581
  return 2;
@@ -7027,25 +7596,49 @@ async function runStatus(args, io) {
7027
7596
  } catch (e) {
7028
7597
  return mapBotError(e, parsed.slug, io);
7029
7598
  }
7599
+ let deployment = null;
7600
+ let deploymentKnown = false;
7601
+ try {
7602
+ deployment = await fetchDeploymentStatus(cfg, parsed.slug);
7603
+ deploymentKnown = deployment !== null;
7604
+ } catch (e) {
7605
+ if (e instanceof UnauthenticatedError || e instanceof ForbiddenError) {
7606
+ return mapBotError(e, parsed.slug, io);
7607
+ }
7608
+ io.err(`launchpad status: live deployment state unavailable (${describe24(e)}) — ` + `the report below is from the platform manifest view only.`);
7609
+ }
7610
+ let standingExceptions = null;
7611
+ try {
7612
+ standingExceptions = await fetchStandingExceptions(cfg, parsed.slug);
7613
+ } catch (e) {
7614
+ if (e instanceof UnauthenticatedError || e instanceof ForbiddenError) {
7615
+ return mapBotError(e, parsed.slug, io);
7616
+ }
7617
+ io.err(`launchpad status: standing-exception inventory unavailable (${describe24(e)}).`);
7618
+ }
7030
7619
  if (state.manifestYaml === null || state.manifestYaml === undefined) {
7620
+ const live = deployment?.liveDeployment ?? null;
7621
+ const contentIsLive = live !== null && (live.buildStatus === "success" || deployment?.lastSuccessfulDeployment != null);
7031
7622
  const liveButEmpty = lifecycle !== null && lifecycle.state === "live";
7032
7623
  const out = {
7033
- state: liveButEmpty ? "live_no_content" : "no_deployed_manifest",
7624
+ state: contentIsLive ? "live_content_untracked" : liveButEmpty ? "live_no_content" : "no_deployed_manifest",
7034
7625
  slug: parsed.slug,
7035
7626
  deployedSha: state.lastAppliedManifestSha,
7036
7627
  headSha: state.appRepoHeadSha,
7037
7628
  hasOpenPr: state.openPr !== null,
7038
7629
  openPrNumber: state.openPr?.number ?? null,
7039
7630
  driftFields: [],
7040
- driftDetails: []
7631
+ driftDetails: [],
7632
+ ...deploymentKnown ? { deployment } : {},
7633
+ ...standingExceptions !== null ? { standingExceptions } : {}
7041
7634
  };
7042
7635
  emit3(out, parsed.json, io);
7043
7636
  return 0;
7044
7637
  }
7045
7638
  let deployedObj;
7046
7639
  try {
7047
- const { parse: parseYaml4 } = await import("yaml");
7048
- deployedObj = parseYaml4(state.manifestYaml);
7640
+ const { parse: parseYaml5 } = await import("yaml");
7641
+ deployedObj = parseYaml5(state.manifestYaml);
7049
7642
  } catch (e) {
7050
7643
  io.err(`launchpad status: deployed manifest at ${state.lastAppliedManifestSha} is not valid YAML: ${describe24(e)}`);
7051
7644
  return 2;
@@ -7068,7 +7661,9 @@ async function runStatus(args, io) {
7068
7661
  hasOpenPr: state.openPr !== null,
7069
7662
  openPrNumber: state.openPr?.number ?? null,
7070
7663
  driftFields: drift.map((d) => d.path),
7071
- driftDetails: drift
7664
+ driftDetails: drift,
7665
+ ...deploymentKnown ? { deployment } : {},
7666
+ ...standingExceptions !== null ? { standingExceptions } : {}
7072
7667
  };
7073
7668
  emit3(result, parsed.json, io);
7074
7669
  if (result.state === "drift" && parsed.strict) {
@@ -7154,14 +7749,26 @@ function emit3(out, asJson, io) {
7154
7749
  case "live_no_content":
7155
7750
  io.out(`${out.slug}: live — no content deployed yet. Run \`launchpad deploy\`.`);
7156
7751
  surfaceHeadVsDeployed(out, io);
7752
+ surfaceDeployment(out, io);
7753
+ surfaceExceptions(out, io);
7157
7754
  return;
7158
7755
  case "no_deployed_manifest":
7159
7756
  io.out(`${out.slug}: no deployed manifest yet — run \`launchpad deploy\`.`);
7160
7757
  surfaceHeadVsDeployed(out, io);
7758
+ surfaceDeployment(out, io);
7759
+ surfaceExceptions(out, io);
7760
+ return;
7761
+ case "live_content_untracked":
7762
+ io.out(`${out.slug}: live — content deployed via ` + `${triggerLabel(out.deployment?.liveDeployment?.trigger)} (no platform-tracked manifest; this app deploys outside \`launchpad deploy\`).`);
7763
+ surfaceHeadVsDeployed(out, io);
7764
+ surfaceDeployment(out, io);
7765
+ surfaceExceptions(out, io);
7161
7766
  return;
7162
7767
  case "in_sync":
7163
7768
  io.out(`${out.slug}: live, in sync` + (out.deployedSha ? ` (content @ ${out.deployedSha.slice(0, 7)})` : ""));
7164
7769
  surfaceHeadVsDeployed(out, io);
7770
+ surfaceDeployment(out, io);
7771
+ surfaceExceptions(out, io);
7165
7772
  return;
7166
7773
  case "drift":
7167
7774
  io.out(`${out.slug}: live, drift: ${out.driftFields.join(", ")}`);
@@ -7171,8 +7778,66 @@ function emit3(out, asJson, io) {
7171
7778
  io.out(` deployed: ${formatValue(d.deployed)}`);
7172
7779
  }
7173
7780
  surfaceHeadVsDeployed(out, io);
7781
+ surfaceDeployment(out, io);
7782
+ surfaceExceptions(out, io);
7783
+ return;
7784
+ }
7785
+ }
7786
+ function triggerLabel(trigger) {
7787
+ if (trigger === "git-push")
7788
+ return "git push";
7789
+ if (trigger === "bot")
7790
+ return "launchpad deploy (bot)";
7791
+ return "an unknown mechanism";
7792
+ }
7793
+ function surfaceDeployment(out, io) {
7794
+ const dep = out.deployment;
7795
+ if (dep === undefined || dep === null || !dep.supported)
7796
+ return;
7797
+ const live = dep.liveDeployment;
7798
+ if (live === null) {
7799
+ io.out(" last deployment: none — Cloudflare Pages has no production deployment yet.");
7800
+ return;
7801
+ }
7802
+ const commit = live.commit !== undefined ? `, commit ${live.commit.hash.slice(0, 7)}` : "";
7803
+ const via = triggerLabel(live.trigger);
7804
+ switch (live.buildStatus) {
7805
+ case "success":
7806
+ io.out(` last deployment: build success (${via}${commit}) at ${live.createdOn}`);
7807
+ return;
7808
+ case "in_progress":
7809
+ io.out(` last deployment: build IN PROGRESS (${via}${commit}, started ${live.createdOn})`);
7810
+ io.out(" re-run `launchpad status` shortly to confirm the build outcome.");
7811
+ return;
7812
+ case "failure": {
7813
+ const stage = live.failedStage !== undefined ? ` at stage "${live.failedStage}"` : "";
7814
+ io.out(` last deployment: build FAILED${stage} (${via}${commit}) at ${live.createdOn}`);
7815
+ if (live.logExcerpt !== undefined && live.logExcerpt.length > 0) {
7816
+ io.out(" build log (excerpt):");
7817
+ for (const line of live.logExcerpt.split(`
7818
+ `)) {
7819
+ io.out(` ${line}`);
7820
+ }
7821
+ }
7822
+ if (dep.lastSuccessfulDeployment !== null) {
7823
+ io.out(` serving: previous successful deployment from ${dep.lastSuccessfulDeployment.createdOn}`);
7824
+ } else {
7825
+ io.out(" serving: nothing — no successful deployment exists yet.");
7826
+ }
7827
+ io.out(" next: fix the build locally (check build.command / build.root_dir in launchpad.yaml), then run `launchpad deploy`.");
7174
7828
  return;
7829
+ }
7830
+ }
7831
+ }
7832
+ function surfaceExceptions(out, io) {
7833
+ const exceptions = out.standingExceptions;
7834
+ if (exceptions === undefined || exceptions.length === 0)
7835
+ return;
7836
+ io.out(` standing exceptions: ${exceptions.length} policy violation(s) in content already live on main (non-blocking):`);
7837
+ for (const e of exceptions) {
7838
+ io.out(` - ${e.path} [${e.rule}]`);
7175
7839
  }
7840
+ io.out(" these never block a deploy that doesn't change them, but they are never grandfathered silently — clean them up in a future deploy.");
7176
7841
  }
7177
7842
  function surfaceHeadVsDeployed(out, io) {
7178
7843
  if (out.headSha !== null && out.deployedSha !== null && out.headSha !== out.deployedSha) {
@@ -7451,7 +8116,7 @@ function describe25(e) {
7451
8116
  }
7452
8117
 
7453
8118
  // src/deploy/rollback.ts
7454
- import { existsSync as existsSync6, readFileSync as readFileSync11, writeFileSync as writeFileSync5 } from "node:fs";
8119
+ import { existsSync as existsSync7, readFileSync as readFileSync12, writeFileSync as writeFileSync5 } from "node:fs";
7455
8120
  import { resolve as resolvePath2 } from "node:path";
7456
8121
  import { createInterface as createInterface3 } from "node:readline/promises";
7457
8122
 
@@ -7571,15 +8236,16 @@ async function runRollback(opts, io, deps = {}) {
7571
8236
  file: opts.file,
7572
8237
  platformRepo: null,
7573
8238
  rePin: false,
8239
+ atSha: verifiedSha,
7574
8240
  yes: true
7575
8241
  }, io, applyDeps);
7576
8242
  }
7577
8243
  function readCurrentManifest(path11) {
7578
- if (!existsSync6(path11))
8244
+ if (!existsSync7(path11))
7579
8245
  return null;
7580
8246
  let raw;
7581
8247
  try {
7582
- raw = readFileSync11(path11, "utf8");
8248
+ raw = readFileSync12(path11, "utf8");
7583
8249
  } catch {
7584
8250
  return null;
7585
8251
  }
@@ -7735,7 +8401,7 @@ function parseArgs11(args) {
7735
8401
  }
7736
8402
 
7737
8403
  // src/secrets/push.ts
7738
- import { existsSync as existsSync7, readFileSync as readFileSync12 } from "node:fs";
8404
+ import { existsSync as existsSync8, readFileSync as readFileSync13 } from "node:fs";
7739
8405
  import { resolve as resolvePath3 } from "node:path";
7740
8406
 
7741
8407
  // src/secrets/env-parse.ts
@@ -7790,14 +8456,14 @@ async function runSecretsPush(opts, io, deps = {}) {
7790
8456
  return 0;
7791
8457
  }
7792
8458
  const envPath = resolvePath3(process.cwd(), opts.env ?? ".env");
7793
- if (!existsSync7(envPath)) {
8459
+ if (!existsSync8(envPath)) {
7794
8460
  io.err(`launchpad secrets push: ${envPath}`);
7795
8461
  io.err(" .env file not found. Run `launchpad secrets template` to scaffold one.");
7796
8462
  return 2;
7797
8463
  }
7798
8464
  let envText;
7799
8465
  try {
7800
- envText = readFileSync12(envPath, "utf8");
8466
+ envText = readFileSync13(envPath, "utf8");
7801
8467
  } catch (e) {
7802
8468
  io.err(`launchpad secrets push: failed to read ${envPath}: ${describe27(e)}`);
7803
8469
  return 2;
@@ -8069,9 +8735,9 @@ function renderManifestError5(result, io) {
8069
8735
  }
8070
8736
 
8071
8737
  // src/secrets/set.ts
8072
- import { existsSync as existsSync8, readFileSync as readFileSync13 } from "node:fs";
8738
+ import { existsSync as existsSync9, readFileSync as readFileSync14 } from "node:fs";
8073
8739
  import { resolve as resolvePath5 } from "node:path";
8074
- import { parse as parseYaml4 } from "yaml";
8740
+ import { parse as parseYaml5 } from "yaml";
8075
8741
  var CELL_LABEL2 = {
8076
8742
  present: "PRESENT",
8077
8743
  missing: "MISSING",
@@ -8079,14 +8745,14 @@ var CELL_LABEL2 = {
8079
8745
  };
8080
8746
  function loadSet(fleetFile, setName, io) {
8081
8747
  const path11 = resolvePath5(process.cwd(), fleetFile ?? "fleet-secret-sets.yaml");
8082
- if (!existsSync8(path11)) {
8748
+ if (!existsSync9(path11)) {
8083
8749
  io.err(`✗ ${path11}`);
8084
8750
  io.err(" fleet-secret-sets.yaml not found. Run from the platform repo root or pass --fleet-file.");
8085
8751
  return 2;
8086
8752
  }
8087
8753
  let obj;
8088
8754
  try {
8089
- obj = parseYaml4(readFileSync13(path11, "utf8"));
8755
+ obj = parseYaml5(readFileSync14(path11, "utf8"));
8090
8756
  } catch (e) {
8091
8757
  io.err(`✗ ${path11}`);
8092
8758
  io.err(` YAML parse error: ${e instanceof Error ? e.message : String(e)}`);
@@ -8171,14 +8837,14 @@ async function runSecretsPushSet(opts, io, deps = {}) {
8171
8837
  if (typeof set === "number")
8172
8838
  return set;
8173
8839
  const envPath = resolvePath5(process.cwd(), opts.env ?? ".env");
8174
- if (!existsSync8(envPath)) {
8840
+ if (!existsSync9(envPath)) {
8175
8841
  io.err(`launchpad secrets push --set: ${envPath} not found.`);
8176
8842
  io.err(` Create a .env carrying the set's secrets: ${set.secrets.join(", ")}`);
8177
8843
  return 2;
8178
8844
  }
8179
8845
  let envText;
8180
8846
  try {
8181
- envText = readFileSync13(envPath, "utf8");
8847
+ envText = readFileSync14(envPath, "utf8");
8182
8848
  } catch (e) {
8183
8849
  io.err(`launchpad secrets push --set: failed to read ${envPath}: ${e instanceof Error ? e.message : String(e)}`);
8184
8850
  return 2;
@@ -8322,7 +8988,7 @@ function setPushExit(e) {
8322
8988
  }
8323
8989
 
8324
8990
  // src/commands/secrets-template.ts
8325
- import { existsSync as existsSync9, writeFileSync as writeFileSync6 } from "node:fs";
8991
+ import { existsSync as existsSync10, writeFileSync as writeFileSync6 } from "node:fs";
8326
8992
  import { resolve as resolve9 } from "node:path";
8327
8993
  async function runSecretsTemplate(args, io) {
8328
8994
  const flags = parseFlags4(args);
@@ -8346,7 +9012,7 @@ async function runSecretsTemplate(args, io) {
8346
9012
  return 0;
8347
9013
  }
8348
9014
  const outPath = resolve9(process.cwd(), flags.out);
8349
- if (existsSync9(outPath) && !flags.force) {
9015
+ if (existsSync10(outPath) && !flags.force) {
8350
9016
  io.err(`launchpad secrets template: ${outPath} already exists`);
8351
9017
  io.err("Pass --force to overwrite, or --stdout to print without writing.");
8352
9018
  return 64;
@@ -8634,8 +9300,8 @@ function printHelp2(io) {
8634
9300
 
8635
9301
  // src/commands/skills.ts
8636
9302
  import { fileURLToPath } from "node:url";
8637
- import { dirname as dirname6, join as join10, resolve as resolve10 } from "node:path";
8638
- import { promises as fs5, existsSync as existsSync10 } from "node:fs";
9303
+ import { dirname as dirname6, join as join11, resolve as resolve10 } from "node:path";
9304
+ import { promises as fs5, existsSync as existsSync11 } from "node:fs";
8639
9305
  import { homedir as homedir2 } from "node:os";
8640
9306
  var BUNDLE_PREFIX = "launchpad-";
8641
9307
  var BUNDLED_SKILLS = [
@@ -8696,7 +9362,7 @@ function printHelp3(io) {
8696
9362
  }
8697
9363
  function resolveInstallEnv() {
8698
9364
  const bundleDir = process.env.LAUNCHPAD_SKILLS_BUNDLE_DIR ?? defaultBundleDir();
8699
- const userSkillsDir = process.env.LAUNCHPAD_SKILLS_TARGET_DIR ?? join10(homedir2(), ".claude", "skills");
9365
+ const userSkillsDir = process.env.LAUNCHPAD_SKILLS_TARGET_DIR ?? join11(homedir2(), ".claude", "skills");
8700
9366
  return { bundleDir, userSkillsDir };
8701
9367
  }
8702
9368
  function defaultBundleDir() {
@@ -8706,7 +9372,7 @@ function defaultBundleDir() {
8706
9372
  resolve10(here, "..", "..", "skills")
8707
9373
  ];
8708
9374
  for (const c of candidates) {
8709
- if (existsSync10(join10(c, "launchpad-onboard", "SKILL.md"))) {
9375
+ if (existsSync11(join11(c, "launchpad-onboard", "SKILL.md"))) {
8710
9376
  return c;
8711
9377
  }
8712
9378
  }
@@ -8727,12 +9393,12 @@ async function doInstall(io) {
8727
9393
  }
8728
9394
  let installed = 0;
8729
9395
  for (const skill of BUNDLED_SKILLS) {
8730
- const src = join10(env.bundleDir, skill);
9396
+ const src = join11(env.bundleDir, skill);
8731
9397
  if (!await isDir(src)) {
8732
9398
  io.err(`launchpad skills install: bundled skill "${skill}" missing from ${env.bundleDir} — package is incomplete.`);
8733
9399
  return 1;
8734
9400
  }
8735
- const dest = join10(env.userSkillsDir, skill);
9401
+ const dest = join11(env.userSkillsDir, skill);
8736
9402
  await fs5.rm(dest, { recursive: true, force: true });
8737
9403
  await fs5.cp(src, dest, { recursive: true });
8738
9404
  installed++;
@@ -8757,7 +9423,7 @@ async function doUninstall(io) {
8757
9423
  continue;
8758
9424
  if (!isBundleManaged(entry.name))
8759
9425
  continue;
8760
- const target = join10(env.userSkillsDir, entry.name);
9426
+ const target = join11(env.userSkillsDir, entry.name);
8761
9427
  await fs5.rm(target, { recursive: true, force: true });
8762
9428
  removed++;
8763
9429
  io.out(`✗ removed ${target}`);
@@ -8780,7 +9446,7 @@ async function doList(io) {
8780
9446
  }
8781
9447
  const width = managedDirs.reduce((n, s) => Math.max(n, s.length), 0);
8782
9448
  for (const name of managedDirs) {
8783
- const skillFile = join10(env.userSkillsDir, name, "SKILL.md");
9449
+ const skillFile = join11(env.userSkillsDir, name, "SKILL.md");
8784
9450
  const version = await readVersion(skillFile);
8785
9451
  io.out(` ${name.padEnd(width + 2)}${version ?? "(no version)"}`);
8786
9452
  }
@@ -8815,9 +9481,9 @@ function describe28(e) {
8815
9481
  import { execFile, spawn as spawn5 } from "node:child_process";
8816
9482
  import { promisify } from "node:util";
8817
9483
  import { fileURLToPath as fileURLToPath2 } from "node:url";
8818
- import { dirname as dirname7, resolve as resolve11, relative as relative4, isAbsolute as isAbsolute2, join as join11 } from "node:path";
9484
+ import { dirname as dirname7, resolve as resolve11, relative as relative4, isAbsolute as isAbsolute2, join as join12 } from "node:path";
8819
9485
  import { homedir as homedir3, tmpdir } from "node:os";
8820
- import { readFileSync as readFileSync14, mkdtempSync, writeFileSync as writeFileSync7, rmSync as rmSync2 } from "node:fs";
9486
+ import { readFileSync as readFileSync15, mkdtempSync, writeFileSync as writeFileSync7, rmSync as rmSync2 } from "node:fs";
8821
9487
 
8822
9488
  // src/commands/channel-auth.ts
8823
9489
  import { createServer as createServer2 } from "node:http";
@@ -8938,7 +9604,7 @@ var PKG = "@m-kopa/launchpad-cli";
8938
9604
  var REGISTRY = "https://registry.npmjs.org";
8939
9605
  var CHANNEL_VERSION_URL = "https://get.launchpad.m-kopa.us/version.json";
8940
9606
  var CHANNEL_INSTALL_URL = "https://get.launchpad.m-kopa.us";
8941
- var CHANNEL_MARKER = join11(homedir3(), ".launchpad", "channel");
9607
+ var CHANNEL_MARKER = join12(homedir3(), ".launchpad", "channel");
8942
9608
  var EXIT_UPDATE_AVAILABLE = 10;
8943
9609
  var UPGRADE_ARGS = {
8944
9610
  npm: ["install", "-g", `${PKG}@latest`],
@@ -9021,8 +9687,8 @@ async function openSystemBrowser(url) {
9021
9687
  await execFileAsync(opener, [url]);
9022
9688
  }
9023
9689
  async function runInstallerScript(script) {
9024
- const dir = mkdtempSync(join11(tmpdir(), "launchpad-update-"));
9025
- const file = join11(dir, "install.sh");
9690
+ const dir = mkdtempSync(join12(tmpdir(), "launchpad-update-"));
9691
+ const file = join12(dir, "install.sh");
9026
9692
  try {
9027
9693
  writeFileSync7(file, script, { mode: 448 });
9028
9694
  return await new Promise((resolvePromise) => {
@@ -9046,7 +9712,7 @@ function resolveLatestVersion() {
9046
9712
  }
9047
9713
  function detectInstallChannel() {
9048
9714
  try {
9049
- return readFileSync14(CHANNEL_MARKER, "utf8").trim() === "platform" ? "platform" : "github";
9715
+ return readFileSync15(CHANNEL_MARKER, "utf8").trim() === "platform" ? "platform" : "github";
9050
9716
  } catch {
9051
9717
  return "github";
9052
9718
  }
@@ -9253,7 +9919,8 @@ function printHelp4(io) {
9253
9919
  }
9254
9920
 
9255
9921
  // src/commands/validate.ts
9256
- import { resolve as resolve12 } from "node:path";
9922
+ import { readFileSync as readFileSync16 } from "node:fs";
9923
+ import { dirname as dirname8, resolve as resolve12 } from "node:path";
9257
9924
  var validateCommand = {
9258
9925
  name: "validate",
9259
9926
  summary: "validate launchpad.yaml against the v1alpha1 schema",
@@ -9272,9 +9939,31 @@ async function runValidate(args, io) {
9272
9939
  if (result.kind !== "ok") {
9273
9940
  return json ? renderJsonError(result, io) : renderHumanError(result, io);
9274
9941
  }
9942
+ const boundary = checkBoundary(path11, result.manifest.app !== undefined);
9275
9943
  const groupCheck = strictGroups ? await checkGroups(allowedEntraGroups(result.manifest.access)) : { kind: "skipped" };
9276
- return json ? renderJsonOk(result, groupCheck, io) : renderHumanOk(result, groupCheck, io);
9944
+ return json ? renderJsonOk(result, groupCheck, boundary, io) : renderHumanOk(result, groupCheck, boundary, io);
9945
+ }
9946
+ function checkBoundary(manifestPath, declared) {
9947
+ const dir = dirname8(manifestPath);
9948
+ let files;
9949
+ try {
9950
+ files = walkCwd(dir).files;
9951
+ } catch {
9952
+ return { declared, errors: [], warnings: [] };
9953
+ }
9954
+ let manifestYaml;
9955
+ try {
9956
+ manifestYaml = readFileSync16(manifestPath, "utf8");
9957
+ } catch {
9958
+ manifestYaml = null;
9959
+ }
9960
+ const r = applyBoundaryToFiles({ cwd: dir, manifestYaml, files });
9961
+ if (r.kind === "error") {
9962
+ return { declared, errors: [r.message], warnings: [] };
9963
+ }
9964
+ return { declared, errors: [], warnings: r.warnings };
9277
9965
  }
9966
+ var DECLARE_APP_HINT = "no app: boundary declared — deploys bundle the whole working tree minus the platform deny-list. " + "Declare app: (root / include / exclude) in launchpad.yaml — see schema/README.md.";
9278
9967
  async function checkGroup(allowedGroup, cfg) {
9279
9968
  try {
9280
9969
  const r = await fetchGroups(cfg);
@@ -9321,10 +10010,22 @@ async function checkGroups(groups) {
9321
10010
  const ok = perGroup.every((r) => r.kind === "ok");
9322
10011
  return ok ? { kind: "all-ok", perGroup } : { kind: "some-failed", perGroup };
9323
10012
  }
9324
- function renderHumanOk(result, group, io) {
10013
+ function renderHumanOk(result, group, boundary, io) {
9325
10014
  io.out(`✓ ${result.manifest.metadata.name} — manifest is valid`);
9326
10015
  io.out(` apiVersion: launchpad.m-kopa.us/v1alpha1`);
9327
10016
  io.out(` type: ${result.manifest.deployment.type}`);
10017
+ if (boundary.errors.length > 0) {
10018
+ for (const e of boundary.errors) {
10019
+ io.err(`✗ app boundary: ${e}`);
10020
+ }
10021
+ return 1;
10022
+ }
10023
+ for (const w of boundary.warnings) {
10024
+ io.out(`⚠ app boundary: ${w}`);
10025
+ }
10026
+ if (!boundary.declared) {
10027
+ io.out(`⚠ ${DECLARE_APP_HINT}`);
10028
+ }
9328
10029
  switch (group.kind) {
9329
10030
  case "skipped":
9330
10031
  return 0;
@@ -9388,11 +10089,20 @@ function renderHumanError(result, io) {
9388
10089
  return 1;
9389
10090
  }
9390
10091
  }
9391
- function renderJsonOk(result, group, io) {
10092
+ function renderJsonOk(result, group, boundary, io) {
9392
10093
  const base = {
9393
10094
  name: result.manifest.metadata.name,
9394
- deploymentType: result.manifest.deployment.type
10095
+ deploymentType: result.manifest.deployment.type,
10096
+ appBoundary: {
10097
+ declared: boundary.declared,
10098
+ errors: boundary.errors,
10099
+ warnings: boundary.warnings
10100
+ }
9395
10101
  };
10102
+ if (boundary.errors.length > 0) {
10103
+ io.out(JSON.stringify({ ok: false, ...base }));
10104
+ return 1;
10105
+ }
9396
10106
  switch (group.kind) {
9397
10107
  case "skipped":
9398
10108
  io.out(JSON.stringify({ ok: true, ...base }));
@@ -9557,15 +10267,15 @@ function describe30(e) {
9557
10267
  // src/update-notifier.ts
9558
10268
  import { spawn as spawn6 } from "node:child_process";
9559
10269
  import { homedir as homedir4 } from "node:os";
9560
- import { join as join12 } from "node:path";
9561
- import { mkdirSync as mkdirSync3, readFileSync as readFileSync15, writeFileSync as writeFileSync8 } from "node:fs";
10270
+ import { join as join13 } from "node:path";
10271
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync17, writeFileSync as writeFileSync8 } from "node:fs";
9562
10272
  var INTERNAL_REFRESH_VERB = "__refresh-update-cache";
9563
10273
  var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
9564
10274
  var OPT_OUT_ENV = "LAUNCHPAD_NO_UPDATE_NOTIFIER";
9565
- var CACHE_FILE = join12(homedir4(), ".launchpad", "update-check.json");
10275
+ var CACHE_FILE = join13(homedir4(), ".launchpad", "update-check.json");
9566
10276
  function readCache2() {
9567
10277
  try {
9568
- const raw = JSON.parse(readFileSync15(CACHE_FILE, "utf8"));
10278
+ const raw = JSON.parse(readFileSync17(CACHE_FILE, "utf8"));
9569
10279
  if (typeof raw === "object" && raw !== null && typeof raw.checkedAt === "number") {
9570
10280
  const latest = raw.latest;
9571
10281
  return {
@@ -9578,7 +10288,7 @@ function readCache2() {
9578
10288
  }
9579
10289
  function writeCache2(state) {
9580
10290
  try {
9581
- mkdirSync3(join12(homedir4(), ".launchpad"), { recursive: true });
10291
+ mkdirSync3(join13(homedir4(), ".launchpad"), { recursive: true });
9582
10292
  writeFileSync8(CACHE_FILE, `${JSON.stringify(state)}
9583
10293
  `, { mode: 384 });
9584
10294
  } catch {}