@m-kopa/launchpad-cli 0.52.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/auth/discovery.d.ts.map +1 -1
  3. package/dist/auth/gateway-flow.d.ts.map +1 -1
  4. package/dist/auth/registration.d.ts.map +1 -1
  5. package/dist/auth/token.d.ts.map +1 -1
  6. package/dist/bundle/cwd-walker.d.ts.map +1 -1
  7. package/dist/bundle/orchestrate.d.ts +2 -0
  8. package/dist/bundle/orchestrate.d.ts.map +1 -1
  9. package/dist/bundle/upload.d.ts +6 -1
  10. package/dist/bundle/upload.d.ts.map +1 -1
  11. package/dist/cli.js +906 -186
  12. package/dist/clone/base-sha-marker.d.ts +12 -3
  13. package/dist/clone/base-sha-marker.d.ts.map +1 -1
  14. package/dist/clone/tar-extract.d.ts +2 -0
  15. package/dist/clone/tar-extract.d.ts.map +1 -1
  16. package/dist/commands/deploy-flags.d.ts +2 -0
  17. package/dist/commands/deploy-flags.d.ts.map +1 -1
  18. package/dist/commands/deploy.d.ts.map +1 -1
  19. package/dist/commands/pull.d.ts +2 -0
  20. package/dist/commands/pull.d.ts.map +1 -1
  21. package/dist/dispatcher.d.ts +5 -1
  22. package/dist/dispatcher.d.ts.map +1 -1
  23. package/dist/http/api-client.d.ts.map +1 -1
  24. package/dist/http/errors.d.ts +11 -0
  25. package/dist/http/errors.d.ts.map +1 -1
  26. package/dist/http/preflight.d.ts +7 -0
  27. package/dist/http/preflight.d.ts.map +1 -0
  28. package/dist/http/version-headers.d.ts +10 -0
  29. package/dist/http/version-headers.d.ts.map +1 -0
  30. package/dist/pull/source-sync.d.ts +11 -0
  31. package/dist/pull/source-sync.d.ts.map +1 -0
  32. package/dist/version.d.ts +1 -1
  33. package/package.json +4 -1
  34. package/skills/launchpad-content-pr/SKILL.md +3 -3
  35. package/skills/launchpad-deploy/SKILL.md +8 -1
  36. package/skills/launchpad-deploy-status/SKILL.md +3 -3
  37. package/skills/launchpad-destroy/SKILL.md +2 -2
  38. package/skills/launchpad-identity/SKILL.md +1 -1
  39. package/skills/launchpad-onboard/SKILL.md +6 -1
  40. package/skills/launchpad-report/SKILL.md +1 -1
  41. package/skills/launchpad-status/SKILL.md +20 -13
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.52.0";
22
+ var CLI_VERSION = "0.54.0";
23
23
 
24
24
  // src/config.ts
25
25
  import * as os from "node:os";
@@ -213,6 +213,22 @@ class ForbiddenError extends Error {
213
213
  class NotFoundError extends Error {
214
214
  code = "not_found";
215
215
  }
216
+ var EXIT_CLI_UPGRADE_REQUIRED = 42;
217
+
218
+ class UpgradeRequiredError extends Error {
219
+ code = "cli_upgrade_required";
220
+ minimum;
221
+ yours;
222
+ constructor(minimum, yours) {
223
+ super(upgradeRequiredMessage(minimum, yours));
224
+ this.name = "UpgradeRequiredError";
225
+ this.minimum = minimum;
226
+ this.yours = yours;
227
+ }
228
+ }
229
+ function upgradeRequiredMessage(minimum, yours) {
230
+ return `you're on ${yours}; minimum supported is ${minimum} - run launchpad update`;
231
+ }
216
232
 
217
233
  class ApiError extends Error {
218
234
  faultCode = "api_error";
@@ -363,6 +379,21 @@ function escapeHtml(s) {
363
379
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
364
380
  }
365
381
 
382
+ // src/http/version-headers.ts
383
+ var CLI_VERSION_HEADER = "x-launchpad-cli-version";
384
+ function cliVersionHeaders(headers = {}) {
385
+ const result = { ...headers };
386
+ for (const key of Object.keys(result)) {
387
+ const lower = key.toLowerCase();
388
+ if (lower === CLI_VERSION_HEADER || lower === "user-agent") {
389
+ delete result[key];
390
+ }
391
+ }
392
+ result[CLI_VERSION_HEADER] = CLI_VERSION;
393
+ result["user-agent"] = `launchpad-cli/${CLI_VERSION}`;
394
+ return result;
395
+ }
396
+
366
397
  // src/auth/discovery.ts
367
398
  class DiscoveryError extends Error {
368
399
  code = "discovery_error";
@@ -401,7 +432,10 @@ async function discoverOauthEndpoints(botUrl, fetcher = fetch) {
401
432
  async function fetchJson(fetcher, url) {
402
433
  let res;
403
434
  try {
404
- res = await fetcher(url, { method: "GET" });
435
+ res = await fetcher(url, {
436
+ method: "GET",
437
+ headers: cliVersionHeaders()
438
+ });
405
439
  } catch (e) {
406
440
  throw new DiscoveryError(`discovery: network error fetching ${url}: ${describe(e)}`);
407
441
  }
@@ -471,10 +505,10 @@ async function registerClient(registrationEndpoint, redirectUri, fetcher = fetch
471
505
  try {
472
506
  res = await fetcher(registrationEndpoint, {
473
507
  method: "POST",
474
- headers: {
508
+ headers: cliVersionHeaders({
475
509
  "content-type": "application/json",
476
510
  accept: "application/json"
477
- },
511
+ }),
478
512
  body: JSON.stringify(body)
479
513
  });
480
514
  } catch (e) {
@@ -535,10 +569,10 @@ async function postTokenForm(tokenEndpoint, form, fetcher) {
535
569
  try {
536
570
  res = await fetcher(tokenEndpoint, {
537
571
  method: "POST",
538
- headers: {
572
+ headers: cliVersionHeaders({
539
573
  "content-type": "application/x-www-form-urlencoded",
540
574
  accept: "application/json"
541
- },
575
+ }),
542
576
  body: form.toString()
543
577
  });
544
578
  } catch (e) {
@@ -805,7 +839,9 @@ async function revokeGatewaySession(params, fetcher = fetch) {
805
839
  try {
806
840
  res = await fetcher(url, {
807
841
  method: "POST",
808
- headers: { "content-type": "application/x-www-form-urlencoded" },
842
+ headers: cliVersionHeaders({
843
+ "content-type": "application/x-www-form-urlencoded"
844
+ }),
809
845
  body: new URLSearchParams({ refresh_token: params.refreshToken }).toString()
810
846
  });
811
847
  } catch (e) {
@@ -823,7 +859,10 @@ async function probeGateway(gatewayUrl, fetcher) {
823
859
  const url = `${gatewayUrl}${CLI_SESSION_AUTH_PATH}`;
824
860
  let res;
825
861
  try {
826
- res = await fetcher(url, { method: "GET" });
862
+ res = await fetcher(url, {
863
+ method: "GET",
864
+ headers: cliVersionHeaders()
865
+ });
827
866
  } catch (e) {
828
867
  throw new GatewayUnavailableError(`auth gateway unreachable at ${gatewayUrl}: ${describe5(e)}`);
829
868
  }
@@ -840,10 +879,10 @@ async function postSessionTokenForm(gatewayUrl, form, fetcher) {
840
879
  try {
841
880
  res = await fetcher(url, {
842
881
  method: "POST",
843
- headers: {
882
+ headers: cliVersionHeaders({
844
883
  "content-type": "application/x-www-form-urlencoded",
845
884
  accept: "application/json"
846
- },
885
+ }),
847
886
  body: form.toString()
848
887
  });
849
888
  } catch (e) {
@@ -1054,12 +1093,11 @@ async function apiRaw(cfg, opts, fetcher = fetch) {
1054
1093
  }
1055
1094
  throw e;
1056
1095
  }
1057
- const headers = {
1096
+ const headers = cliVersionHeaders({
1058
1097
  accept: "application/json",
1059
1098
  ...opts.headers,
1060
- authorization: `Bearer ${token}`,
1061
- "user-agent": "launchpad-cli/0.x"
1062
- };
1099
+ authorization: `Bearer ${token}`
1100
+ });
1063
1101
  for (const k of Object.keys(headers)) {
1064
1102
  if (k !== "authorization" && k.toLowerCase() === "authorization") {
1065
1103
  delete headers[k];
@@ -1083,6 +1121,9 @@ async function apiRaw(cfg, opts, fetcher = fetch) {
1083
1121
  } catch (e) {
1084
1122
  throw recordedFault(new TransportError(`network error calling ${url}: ${describe7(e)}`));
1085
1123
  }
1124
+ if (res.status === 426) {
1125
+ throw recordedFault(await decodeUpgradeRequired(res, opts.path));
1126
+ }
1086
1127
  if (res.status === 401) {
1087
1128
  const detail = await errorDetail(res, opts.path);
1088
1129
  throw recordedFault(new UnauthenticatedError(`bot returned 401 for ${opts.path}: ${detail} — run \`launchpad login\``));
@@ -1104,6 +1145,18 @@ async function apiRaw(cfg, opts, fetcher = fetch) {
1104
1145
  clearFault();
1105
1146
  return res;
1106
1147
  }
1148
+ async function decodeUpgradeRequired(response, endpoint) {
1149
+ const body = await readErrorText(response);
1150
+ if (body.kind === "text") {
1151
+ try {
1152
+ const parsed = JSON.parse(body.text);
1153
+ if (typeof parsed.minimum === "string" && typeof parsed.yours === "string") {
1154
+ return new UpgradeRequiredError(parsed.minimum, parsed.yours);
1155
+ }
1156
+ } catch {}
1157
+ }
1158
+ return new ApiError(decodeErrorEnvelope({ code: "cli_upgrade_required", message: "CLI upgrade required" }, { status: 426, endpoint }));
1159
+ }
1107
1160
  async function readErrorText(res) {
1108
1161
  const reader = res.body?.getReader();
1109
1162
  if (reader === undefined)
@@ -1342,7 +1395,7 @@ async function extractToDir(body, destDir, opts = {}) {
1342
1395
  const absDest = path3.resolve(destDir);
1343
1396
  try {
1344
1397
  const existing = await fs2.readdir(absDest);
1345
- if (existing.length > 0) {
1398
+ if (existing.length > 0 && opts.allowNonEmpty !== true) {
1346
1399
  throw new DestinationNotEmptyError(`destination ${absDest} is not empty (${existing.length} entries); refusing to extract`);
1347
1400
  }
1348
1401
  } catch (e) {
@@ -1470,8 +1523,18 @@ async function writeBaseShaMarker(cwd, sha) {
1470
1523
  await ensureExcluded(cwd);
1471
1524
  const dir = path4.resolve(cwd, MARKER_DIR);
1472
1525
  await fs3.mkdir(dir, { recursive: true });
1473
- await fs3.writeFile(path4.resolve(cwd, MARKER_RELPATH), `${sha}
1474
- `, "utf8");
1526
+ const marker = path4.resolve(cwd, MARKER_RELPATH);
1527
+ const temporary = `${marker}.tmp-${process.pid}-${crypto.randomUUID()}`;
1528
+ try {
1529
+ await fs3.writeFile(temporary, `${sha}
1530
+ `, { encoding: "utf8", mode: 384 });
1531
+ await fs3.rename(temporary, marker);
1532
+ } catch (error) {
1533
+ await fs3.rm(temporary, { force: true }).catch(() => {
1534
+ return;
1535
+ });
1536
+ throw error;
1537
+ }
1475
1538
  }
1476
1539
  async function readBaseShaMarker(cwd) {
1477
1540
  let raw;
@@ -1483,6 +1546,18 @@ async function readBaseShaMarker(cwd) {
1483
1546
  const sha = raw.trim();
1484
1547
  return isValidBaseSha(sha) ? sha : null;
1485
1548
  }
1549
+ async function readBaseShaMarkerState(cwd) {
1550
+ let raw;
1551
+ try {
1552
+ raw = await fs3.readFile(path4.resolve(cwd, MARKER_RELPATH), "utf8");
1553
+ } catch (error) {
1554
+ if (isErrno3(error) && error.code === "ENOENT")
1555
+ return { kind: "missing" };
1556
+ return { kind: "malformed" };
1557
+ }
1558
+ const sha = raw.trim();
1559
+ return isValidBaseSha(sha) ? { kind: "valid", sha } : { kind: "malformed" };
1560
+ }
1486
1561
  async function ensureExcluded(cwd) {
1487
1562
  const excludePath = path4.resolve(cwd, EXCLUDE_RELPATH);
1488
1563
  let current = "";
@@ -1501,6 +1576,9 @@ async function ensureExcluded(cwd) {
1501
1576
  await fs3.writeFile(excludePath, `${current}${sep}${EXCLUDE_LINE}
1502
1577
  `, "utf8");
1503
1578
  }
1579
+ function isErrno3(error) {
1580
+ return error instanceof Error && "code" in error;
1581
+ }
1504
1582
 
1505
1583
  // src/commands/clone.ts
1506
1584
  var cloneCommand = {
@@ -1525,12 +1603,7 @@ async function runClone(args, io) {
1525
1603
  try {
1526
1604
  cfg = loadConfig();
1527
1605
  destDir = path5.resolve(process.cwd(), `launchpad-app-${slug}`);
1528
- try {
1529
- await fs4.access(destDir);
1530
- destPreExisted = true;
1531
- } catch {
1532
- destPreExisted = false;
1533
- }
1606
+ destPreExisted = await pathExists(destDir);
1534
1607
  io.out(`Cloning ${slug} into ${destDir} …`);
1535
1608
  const res = await apiRaw(cfg, { path: `/apps/${slug}/source-bundle` });
1536
1609
  if (res.body === null) {
@@ -1538,67 +1611,93 @@ async function runClone(args, io) {
1538
1611
  return 1;
1539
1612
  }
1540
1613
  const headerSha = res.headers.get(BASE_SHA_HEADER);
1541
- const baseSha = headerSha !== null && isValidBaseSha(headerSha) ? headerSha : null;
1614
+ if (headerSha === null || !isValidBaseSha(headerSha)) {
1615
+ io.err("launchpad clone: bot did not return trustworthy source provenance; no files were extracted.");
1616
+ io.err(" Retry later or ask the Launchpad team to verify the bot version.");
1617
+ return 1;
1618
+ }
1619
+ const baseSha = headerSha;
1542
1620
  const stats = await extractToDir(res.body, destDir, { stripComponents: 1 });
1543
1621
  await initGitRepo({
1544
1622
  cwd: destDir,
1545
1623
  initialCommitMessage: `Initial baseline from launchpad clone ${slug}`
1546
1624
  });
1547
- let stamped = false;
1548
- if (baseSha !== null) {
1549
- try {
1550
- await writeBaseShaMarker(destDir, baseSha);
1551
- stamped = true;
1552
- } catch (e) {
1553
- io.err(`launchpad clone: warning — could not record base revision: ${describe9(e)}`);
1554
- io.err("(the clone is usable, but deploy cannot detect divergence)");
1555
- }
1556
- }
1625
+ const markerFailure = await persistCloneMarker({
1626
+ slug,
1627
+ destDir,
1628
+ destPreExisted,
1629
+ baseSha,
1630
+ io
1631
+ });
1632
+ if (markerFailure !== null)
1633
+ return markerFailure;
1557
1634
  io.out("");
1558
1635
  io.out(`Cloned ${stats.fileCount} files (${formatBytes(stats.byteCount)}) into ${destDir}`);
1559
- if (stamped && baseSha !== null) {
1560
- io.out(`Base revision ${baseSha.slice(0, 12)} recorded — \`launchpad deploy\` will warn if main moves past it.`);
1561
- }
1636
+ io.out(`Base revision ${baseSha.slice(0, 12)} recorded - \`launchpad pull\` can refresh this clone safely.`);
1562
1637
  io.out(`No remote configured — use \`launchpad deploy\` to ship changes back.`);
1563
1638
  return 0;
1564
- } catch (e) {
1565
- if (e instanceof UnauthenticatedError) {
1566
- io.err(e.message);
1567
- return 1;
1568
- }
1569
- if (e instanceof ForbiddenError) {
1570
- io.err(e.message);
1571
- return 1;
1572
- }
1573
- if (e instanceof NotFoundError) {
1574
- io.err(`launchpad clone: app "${slug}" not found (or you have no access)`);
1575
- return 1;
1576
- }
1577
- if (e instanceof DestinationNotEmptyError) {
1578
- io.err(`launchpad clone: ${e.message}`);
1579
- io.err("(move or delete the existing directory and re-run)");
1580
- return 1;
1581
- }
1582
- if (e instanceof TarballParseError) {
1583
- io.err(`launchpad clone: malformed tarball from bot: ${e.message}`);
1584
- if (!destPreExisted)
1585
- await cleanupBestEffort(slug);
1586
- return 1;
1587
- }
1588
- if (e instanceof PathTraversalError) {
1589
- io.err(`launchpad clone: refusing to extract — ${e.message}`);
1590
- if (!destPreExisted)
1591
- await cleanupBestEffort(slug);
1592
- return 1;
1593
- }
1594
- if (e instanceof GitInitError) {
1595
- io.err(`launchpad clone: ${e.message}`);
1596
- return 1;
1639
+ } catch (error) {
1640
+ return handleCloneError(error, slug, destPreExisted, io);
1641
+ }
1642
+ }
1643
+ async function persistCloneMarker(args) {
1644
+ try {
1645
+ await writeBaseShaMarker(args.destDir, args.baseSha);
1646
+ return null;
1647
+ } catch (error) {
1648
+ args.io.err(`launchpad clone: could not record source provenance: ${describe9(error)}`);
1649
+ if (args.destPreExisted) {
1650
+ args.io.err(` Remove the generated files from ${args.destDir}, then retry.`);
1651
+ } else {
1652
+ await cleanupBestEffort(args.slug);
1653
+ args.io.err(" The incomplete clone was removed. Retry the command.");
1597
1654
  }
1598
- io.err(`launchpad clone failed: ${describe9(e)}`);
1599
1655
  return 1;
1600
1656
  }
1601
1657
  }
1658
+ async function handleCloneError(error, slug, destPreExisted, io) {
1659
+ if (isCloneAuthError(error)) {
1660
+ io.err(error.message);
1661
+ } else if (error instanceof NotFoundError) {
1662
+ io.err(`launchpad clone: app "${slug}" not found (or you have no access)`);
1663
+ } else if (error instanceof DestinationNotEmptyError) {
1664
+ io.err(`launchpad clone: ${error.message}`);
1665
+ io.err("(move or delete the existing directory and re-run)");
1666
+ } else if (error instanceof TarballParseError) {
1667
+ io.err(`launchpad clone: malformed tarball from bot: ${error.message}`);
1668
+ if (!destPreExisted)
1669
+ await cleanupBestEffort(slug);
1670
+ } else if (error instanceof PathTraversalError) {
1671
+ io.err(`launchpad clone: refusing to extract - ${error.message}`);
1672
+ if (!destPreExisted)
1673
+ await cleanupBestEffort(slug);
1674
+ } else if (error instanceof GitInitError) {
1675
+ await handleGitInitError(error, slug, destPreExisted, io);
1676
+ } else {
1677
+ io.err(`launchpad clone failed: ${describe9(error)}`);
1678
+ }
1679
+ return 1;
1680
+ }
1681
+ async function handleGitInitError(error, slug, destPreExisted, io) {
1682
+ io.err(`launchpad clone: ${error.message}`);
1683
+ if (destPreExisted)
1684
+ return;
1685
+ await cleanupBestEffort(slug);
1686
+ io.err(" The incomplete clone was removed. Retry the command.");
1687
+ }
1688
+ function isCloneAuthError(error) {
1689
+ return error instanceof UnauthenticatedError || error instanceof ForbiddenError;
1690
+ }
1691
+ async function pathExists(target) {
1692
+ try {
1693
+ await fs4.lstat(target);
1694
+ return true;
1695
+ } catch (error) {
1696
+ if (isErrno4(error) && error.code === "ENOENT")
1697
+ return false;
1698
+ throw error;
1699
+ }
1700
+ }
1602
1701
  async function cleanupBestEffort(slug) {
1603
1702
  const dir = path5.resolve(process.cwd(), `launchpad-app-${slug}`);
1604
1703
  await fs4.rm(dir, { recursive: true, force: true }).catch(() => {
@@ -1615,6 +1714,9 @@ function formatBytes(n) {
1615
1714
  function describe9(e) {
1616
1715
  return e instanceof Error ? e.message : String(e);
1617
1716
  }
1717
+ function isErrno4(error) {
1718
+ return error instanceof Error && "code" in error;
1719
+ }
1618
1720
 
1619
1721
  // src/commands/create.ts
1620
1722
  var createCommand = {
@@ -1794,7 +1896,7 @@ async function packTarGz(cwd, files) {
1794
1896
  try {
1795
1897
  stat = await fs5.lstat(abs);
1796
1898
  } catch (e) {
1797
- if (isErrno3(e) && e.code === "ENOENT")
1899
+ if (isErrno5(e) && e.code === "ENOENT")
1798
1900
  continue;
1799
1901
  throw e;
1800
1902
  }
@@ -1919,13 +2021,14 @@ function findSplit(p, enc) {
1919
2021
  }
1920
2022
  return null;
1921
2023
  }
1922
- function isErrno3(e) {
2024
+ function isErrno5(e) {
1923
2025
  return e instanceof Error && typeof e.code === "string";
1924
2026
  }
1925
2027
 
1926
2028
  // src/bundle/upload.ts
1927
2029
  var ACCESS_RECONCILE_EXACT_SHA_V1 = "access-reconcile-exact-sha-v1";
1928
- async function uploadBundle(cfg, slug, manifestYaml, bundleTarGz, workerArtifact, baseSha, overrideStale) {
2030
+ var SOURCE_PROVENANCE_V1 = "source-provenance-v1";
2031
+ async function uploadBundle(cfg, slug, manifestYaml, bundleTarGz, workerArtifact, baseSha, overrideStale, allowUnguarded = false) {
1929
2032
  const formData = new FormData;
1930
2033
  formData.append("manifest", manifestYaml);
1931
2034
  const buf = new ArrayBuffer(bundleTarGz.byteLength);
@@ -1945,15 +2048,17 @@ async function uploadBundle(cfg, slug, manifestYaml, bundleTarGz, workerArtifact
1945
2048
  }
1946
2049
  const url = `${cfg.botUrl}/apps/${slug}/deploy/bundle`;
1947
2050
  const { accessToken } = await getValidAccessToken(cfg.sessionPath);
1948
- const headers = {
2051
+ const headers = cliVersionHeaders({
1949
2052
  authorization: `Bearer ${accessToken}`,
1950
2053
  accept: "application/json",
1951
- "x-launchpad-capabilities": ACCESS_RECONCILE_EXACT_SHA_V1
1952
- };
2054
+ "x-launchpad-capabilities": `${ACCESS_RECONCILE_EXACT_SHA_V1},${SOURCE_PROVENANCE_V1}`
2055
+ });
1953
2056
  if (baseSha)
1954
2057
  headers["x-launchpad-base-sha"] = baseSha;
1955
2058
  if (overrideStale)
1956
2059
  headers["x-launchpad-stale-override"] = overrideStale;
2060
+ if (allowUnguarded)
2061
+ headers["x-launchpad-allow-unguarded"] = "true";
1957
2062
  let res;
1958
2063
  try {
1959
2064
  res = await fetch(url, { method: "POST", headers, body: formData });
@@ -2076,7 +2181,9 @@ function deployBundleError(envelope, body) {
2076
2181
  ...manifestOnly === undefined ? {} : { manifest_only: manifestOnly },
2077
2182
  ...typeof body.head_sha === "string" ? { head_sha: body.head_sha } : {},
2078
2183
  ...paths === undefined ? {} : { paths },
2079
- ...typeof body.reason === "string" ? { reason: body.reason } : {}
2184
+ ...typeof body.reason === "string" ? { reason: body.reason } : {},
2185
+ ...typeof body.minimum === "string" ? { minimum: body.minimum } : {},
2186
+ ...typeof body.yours === "string" ? { yours: body.yours } : {}
2080
2187
  };
2081
2188
  }
2082
2189
  function readStringArray(value) {
@@ -2104,6 +2211,7 @@ import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
2104
2211
  import { join as join4, relative as relative2, sep } from "node:path";
2105
2212
  var DEFAULT_IGNORE = [
2106
2213
  ".git",
2214
+ ".launchpad",
2107
2215
  "node_modules",
2108
2216
  "dist",
2109
2217
  "build",
@@ -2467,9 +2575,9 @@ var AccessSchema = z.object({
2467
2575
  allowed_entra_group: z.string().min(1).optional(),
2468
2576
  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(),
2469
2577
  session_duration: z.string().regex(SESSION_DURATION_REGEX, "must match Go duration format, e.g. 24h / 30m / 60s").optional()
2470
- }).strict().superRefine((access2, ctx) => {
2471
- const hasSingular = access2.allowed_entra_group !== undefined;
2472
- const hasPlural = access2.allowed_entra_groups !== undefined;
2578
+ }).strict().superRefine((access, ctx) => {
2579
+ const hasSingular = access.allowed_entra_group !== undefined;
2580
+ const hasPlural = access.allowed_entra_groups !== undefined;
2473
2581
  if (!hasSingular && !hasPlural) {
2474
2582
  ctx.addIssue({
2475
2583
  code: z.ZodIssueCode.custom,
@@ -2485,11 +2593,11 @@ var AccessSchema = z.object({
2485
2593
  });
2486
2594
  }
2487
2595
  });
2488
- function allowedEntraGroups(access2) {
2489
- if (access2.allowed_entra_groups !== undefined) {
2490
- return access2.allowed_entra_groups;
2596
+ function allowedEntraGroups(access) {
2597
+ if (access.allowed_entra_groups !== undefined) {
2598
+ return access.allowed_entra_groups;
2491
2599
  }
2492
- return [access2.allowed_entra_group];
2600
+ return [access.allowed_entra_group];
2493
2601
  }
2494
2602
  var ContainerSchema = z.object({
2495
2603
  name: z.string().min(2).max(32).regex(SLUG_REGEX, "container name must be lowercase letters, digits, and hyphens"),
@@ -2957,6 +3065,13 @@ function checkDenyList(path7) {
2957
3065
  if (segments.includes(".claude")) {
2958
3066
  return { path: path7, rule: "claude-dir", message: `'${path7}' is under a .claude/ directory (AI-workspace state) — never shippable` };
2959
3067
  }
3068
+ if (segments.includes(".launchpad")) {
3069
+ return {
3070
+ path: path7,
3071
+ rule: "launchpad-dir",
3072
+ message: `'${path7}' is under .launchpad/ (clone-local state) - never shippable`
3073
+ };
3074
+ }
2960
3075
  if (segments.includes(".git")) {
2961
3076
  return { path: path7, rule: "git-dir", message: `'${path7}' is under .git/ — never shippable` };
2962
3077
  }
@@ -4006,7 +4121,7 @@ ${lines}`
4006
4121
 
4007
4122
  // src/bundle/orchestrate.ts
4008
4123
  async function bundleAndDeploy(args) {
4009
- const { cfg, cwd, slug, overrideStale } = args;
4124
+ const { cfg, cwd, slug, overrideStale, allowUnguarded = false } = args;
4010
4125
  const manifestPath = join7(cwd, "launchpad.yaml");
4011
4126
  let manifestYaml;
4012
4127
  try {
@@ -4052,7 +4167,7 @@ async function bundleAndDeploy(args) {
4052
4167
  }
4053
4168
  const workerArtifact = workerBuild.kind === "ok" ? workerBuild.artifact : undefined;
4054
4169
  const baseSha = await readBaseShaMarker(cwd);
4055
- const uploadResult = await uploadBundle(cfg, slug, manifestYaml, packResult.bytes, workerArtifact, baseSha, overrideStale);
4170
+ const uploadResult = await uploadBundle(cfg, slug, manifestYaml, packResult.bytes, workerArtifact, baseSha, overrideStale, allowUnguarded);
4056
4171
  if (uploadResult.kind !== "ok") {
4057
4172
  return {
4058
4173
  kind: "upload-error",
@@ -6319,6 +6434,7 @@ function parseDeployFlags(args) {
6319
6434
  let allowStale = false;
6320
6435
  let rebaseOntoHead = false;
6321
6436
  let overrideStale = null;
6437
+ let allowUnguarded = false;
6322
6438
  let i = 0;
6323
6439
  while (i < args.length) {
6324
6440
  const a = args[i] ?? "";
@@ -6451,6 +6567,11 @@ function parseDeployFlags(args) {
6451
6567
  i += 1;
6452
6568
  continue;
6453
6569
  }
6570
+ if (a === "--allow-unguarded") {
6571
+ allowUnguarded = true;
6572
+ i += 1;
6573
+ continue;
6574
+ }
6454
6575
  if (a === "--rebase-onto-head") {
6455
6576
  rebaseOntoHead = true;
6456
6577
  i += 1;
@@ -6532,8 +6653,8 @@ function parseDeployFlags(args) {
6532
6653
  if (modeFlagsSeen > 1) {
6533
6654
  return "--new, --resume, --abandon, --dry-run, and --apply are mutually exclusive";
6534
6655
  }
6535
- if (mode !== "content" && (allowStale || rebaseOntoHead || overrideStale !== null)) {
6536
- return "--allow-stale / --rebase-onto-head / --override-stale are only valid for a content deploy";
6656
+ if (mode !== "content" && (allowStale || rebaseOntoHead || overrideStale !== null || allowUnguarded)) {
6657
+ return "--allow-stale / --rebase-onto-head / --override-stale / --allow-unguarded are only valid for a content deploy";
6537
6658
  }
6538
6659
  if (rebaseOntoHead && overrideStale !== null) {
6539
6660
  return "--rebase-onto-head and --override-stale are mutually exclusive — re-stamp to head, or override in place, not both";
@@ -6626,7 +6747,14 @@ function parseDeployFlags(args) {
6626
6747
  return `invalid slug "${slug}" — expected ${SLUG_RE4.source}`;
6627
6748
  }
6628
6749
  return {
6629
- mode: { kind: "content", slug, allowStale, rebaseOntoHead, overrideStale },
6750
+ mode: {
6751
+ kind: "content",
6752
+ slug,
6753
+ allowStale,
6754
+ rebaseOntoHead,
6755
+ overrideStale,
6756
+ allowUnguarded
6757
+ },
6630
6758
  message,
6631
6759
  timeoutSeconds
6632
6760
  };
@@ -7599,7 +7727,12 @@ async function runDeploy(args, io) {
7599
7727
  const cwd = process.cwd();
7600
7728
  const manifestPath = path8.join(cwd, "launchpad.yaml");
7601
7729
  if (existsSync6(manifestPath)) {
7602
- const contentMode = flags.mode.kind === "content" ? flags.mode : { allowStale: false, rebaseOntoHead: false, overrideStale: null };
7730
+ const contentMode = flags.mode.kind === "content" ? flags.mode : {
7731
+ allowStale: false,
7732
+ rebaseOntoHead: false,
7733
+ overrideStale: null,
7734
+ allowUnguarded: false
7735
+ };
7603
7736
  return runModelADeploy({
7604
7737
  cwd,
7605
7738
  manifestPath,
@@ -7607,9 +7740,14 @@ async function runDeploy(args, io) {
7607
7740
  io,
7608
7741
  allowStale: contentMode.allowStale,
7609
7742
  rebaseOntoHead: contentMode.rebaseOntoHead,
7610
- overrideStale: contentMode.overrideStale
7743
+ overrideStale: contentMode.overrideStale,
7744
+ allowUnguarded: contentMode.allowUnguarded
7611
7745
  });
7612
7746
  }
7747
+ if (flags.mode.kind === "content" && flags.mode.allowUnguarded) {
7748
+ io.err("launchpad deploy: --allow-unguarded requires launchpad.yaml (Model A); legacy content deploys do not support this override.");
7749
+ return 64;
7750
+ }
7613
7751
  const parsed = parseArgs3(args);
7614
7752
  if (parsed === null) {
7615
7753
  io.err("launchpad deploy: malformed argv (run with --help)");
@@ -7690,6 +7828,10 @@ async function runDeploy(args, io) {
7690
7828
  }
7691
7829
  return 0;
7692
7830
  } catch (e) {
7831
+ if (e instanceof UpgradeRequiredError) {
7832
+ io.err(e.message);
7833
+ return EXIT_CLI_UPGRADE_REQUIRED;
7834
+ }
7693
7835
  if (e instanceof UnauthenticatedError) {
7694
7836
  io.err(e.message);
7695
7837
  return 1;
@@ -7723,7 +7865,7 @@ async function runDeploy(args, io) {
7723
7865
  }
7724
7866
  }
7725
7867
  function deployUsage() {
7726
- return `usage: launchpad deploy [--message <text>] [--slug <slug>]
7868
+ return `usage: launchpad deploy [--message <text>] [--slug <slug>] [--allow-unguarded]
7727
7869
  ` + ` (slug comes from launchpad.yaml at cwd when present — Model A —
7728
7870
  ` + " else from the current directory's `launchpad-app-<slug>` suffix)\n" + `
7729
7871
  ` + `provisioning modes:
@@ -7851,8 +7993,9 @@ function surfaceStaleBlock(body, io) {
7851
7993
  io.err(`launchpad deploy: BLOCKED — main moved since you cloned and the overlap cannot be proven` + (body.reason === undefined ? "" : ` (${body.reason})`) + `; treating as unsafe.`);
7852
7994
  }
7853
7995
  io.err("");
7854
- io.err(" To recover (after reconciling the listed file(s) with main):");
7855
- io.err(" launchpad deploy --rebase-onto-head");
7996
+ io.err(" To recover safely:");
7997
+ io.err(" launchpad pull");
7998
+ io.err(" launchpad deploy");
7856
7999
  if (head !== null) {
7857
8000
  io.err(" Or, to deliberately overwrite them in place:");
7858
8001
  io.err(` launchpad deploy --override-stale ${head.slice(0, 8)}`);
@@ -7970,12 +8113,26 @@ async function runModelADeploy(args) {
7970
8113
  const deployBaseline = noVerify ? null : await readDeploymentBaseline(realCommitWaitDeps(cfg), slug);
7971
8114
  let result;
7972
8115
  try {
8116
+ if (args.allowUnguarded) {
8117
+ io.err("warning: --allow-unguarded disables clone staleness protection for this deploy.");
8118
+ }
7973
8119
  result = await bundleAndDeploy({
7974
8120
  cwd,
7975
8121
  cfg,
7976
8122
  slug,
7977
- overrideStale: args.overrideStale
8123
+ overrideStale: args.overrideStale,
8124
+ allowUnguarded: args.allowUnguarded
7978
8125
  });
8126
+ if (result.kind === "upload-error" && result.status === 409 && result.body.error === "main_advanced") {
8127
+ io.out("Managed main advanced during validation; recomputing and retrying once ...");
8128
+ result = await bundleAndDeploy({
8129
+ cwd,
8130
+ cfg,
8131
+ slug,
8132
+ overrideStale: args.overrideStale,
8133
+ allowUnguarded: args.allowUnguarded
8134
+ });
8135
+ }
7979
8136
  if (args.rebaseOntoHead && result.kind === "upload-error" && result.status === 409 && isStaleBlock(result.body)) {
7980
8137
  const head = result.body.head_sha;
7981
8138
  if (typeof head === "string" && isValidBaseSha(head)) {
@@ -7983,11 +8140,15 @@ async function runModelADeploy(args) {
7983
8140
  io.out(`Re-stamped base revision to current head ${head.slice(0, 12)}; retrying deploy …`);
7984
8141
  result = await bundleAndDeploy({ cwd, cfg, slug });
7985
8142
  } else {
7986
- io.err("launchpad deploy: --rebase-onto-head could not read a head SHA from the block; re-clone the app instead.");
8143
+ io.err("launchpad deploy: --rebase-onto-head could not read a head SHA from the block; run `launchpad pull` and retry.");
7987
8144
  return 1;
7988
8145
  }
7989
8146
  }
7990
8147
  } catch (e) {
8148
+ if (e instanceof UpgradeRequiredError) {
8149
+ io.err(e.message);
8150
+ return EXIT_CLI_UPGRADE_REQUIRED;
8151
+ }
7991
8152
  if (e instanceof UnauthenticatedError) {
7992
8153
  io.err(`launchpad deploy: ${e.message}`);
7993
8154
  io.err(" run `launchpad login` to refresh your session.");
@@ -8015,6 +8176,10 @@ async function runModelADeploy(args) {
8015
8176
  case "upload-error": {
8016
8177
  const body = result.body;
8017
8178
  const errorCode = body.error;
8179
+ if (result.status === 426 && typeof body.minimum === "string" && typeof body.yours === "string") {
8180
+ io.err(upgradeRequiredMessage(body.minimum, body.yours));
8181
+ return EXIT_CLI_UPGRADE_REQUIRED;
8182
+ }
8018
8183
  if (result.status === 409 && errorCode === "slug_in_flight") {
8019
8184
  io.err(`launchpad deploy: slug "${slug}" is already provisioning.`);
8020
8185
  if (typeof body.message === "string") {
@@ -8034,6 +8199,12 @@ async function runModelADeploy(args) {
8034
8199
  surfaceStaleBlock(body, io);
8035
8200
  return 1;
8036
8201
  }
8202
+ if (result.status === 409 && errorCode === "untrusted_base") {
8203
+ io.err("launchpad deploy: this working tree has no trusted source baseline.");
8204
+ io.err(" Run `launchpad pull --bootstrap` to establish provenance for an unchanged legacy clone,");
8205
+ io.err(" use a fresh Launchpad clone, or pass --allow-unguarded only for an intentional non-clone deploy.");
8206
+ return 1;
8207
+ }
8037
8208
  if (result.status === 409 && errorCode === "access_drift") {
8038
8209
  return handleAccessDrift({
8039
8210
  slug,
@@ -9950,16 +10121,16 @@ function buildManifest(inputs, detected) {
9950
10121
  metadata.description = inputs.description;
9951
10122
  }
9952
10123
  const deployment = { type: inputs.type };
9953
- const access2 = inputs.groups.length === 1 ? { allowed_entra_group: inputs.groups[0] } : { allowed_entra_groups: [...inputs.groups] };
10124
+ const access = inputs.groups.length === 1 ? { allowed_entra_group: inputs.groups[0] } : { allowed_entra_groups: [...inputs.groups] };
9954
10125
  if (inputs.sessionDuration !== undefined && inputs.sessionDuration !== "") {
9955
- access2.session_duration = inputs.sessionDuration;
10126
+ access.session_duration = inputs.sessionDuration;
9956
10127
  }
9957
10128
  const manifest = {
9958
10129
  apiVersion: "launchpad.m-kopa.us/v1alpha1",
9959
10130
  kind: "App",
9960
10131
  metadata,
9961
10132
  deployment,
9962
- access: access2
10133
+ access
9963
10134
  };
9964
10135
  const resolvedAuth = inputs.auth ?? (inputs.type === "container" ? "access" : "gateway");
9965
10136
  if (resolvedAuth === "gateway") {
@@ -11007,10 +11178,474 @@ async function fetchManifestStatus(cfg, slug, fetcher = fetch) {
11007
11178
  return apiJson(cfg, { path: path12 }, fetcher);
11008
11179
  }
11009
11180
 
11181
+ // src/pull/source-sync.ts
11182
+ import * as fs6 from "node:fs/promises";
11183
+ import * as path12 from "node:path";
11184
+ import { constants as fsConstants } from "node:fs";
11185
+ import { createHash as createHash3 } from "node:crypto";
11186
+ import { execFile } from "node:child_process";
11187
+ var CONFLICT_REPORT = `${MARKER_DIR}/pull-conflicts.json`;
11188
+ var CONFLICT_DIR = `${MARKER_DIR}/pull-conflicts`;
11189
+ var DELETE_NOTE = new TextEncoder().encode(`Upstream deleted this file.
11190
+ `);
11191
+ var UNSUPPORTED_NOTE = new TextEncoder().encode(`Upstream or baseline uses a Git entry type that launchpad pull cannot reconcile.
11192
+ `);
11193
+ async function runSourceSync(args) {
11194
+ await assertSafeCloneRoot(args.cwd);
11195
+ const marker = await readBaseShaMarkerState(args.cwd);
11196
+ if (marker.kind !== "valid") {
11197
+ if (args.bootstrap === true)
11198
+ return bootstrapProvenance(args);
11199
+ args.io.err(`launchpad pull: this directory has ${marker.kind} source provenance and cannot be reconciled safely.`);
11200
+ args.io.err(" Preserve this directory, then clone the app into a separate empty parent directory to recover a trusted baseline.");
11201
+ return 1;
11202
+ }
11203
+ const compare = await apiJson(args.cfg, {
11204
+ path: `/apps/${args.slug}/source/compare?base=${marker.sha}`
11205
+ });
11206
+ if (compare.baseSha !== marker.sha) {
11207
+ throw new Error("source compare returned a different base SHA");
11208
+ }
11209
+ const paths = [
11210
+ ...new Set([
11211
+ ...compare.changed,
11212
+ ...compare.deleted,
11213
+ ...compare.unsupported ?? []
11214
+ ])
11215
+ ].sort();
11216
+ if (paths.length === 0) {
11217
+ await cleanupConflictState(args.cwd);
11218
+ await writeBaseShaMarker(args.cwd, compare.headSha);
11219
+ args.io.out(`Already current at ${compare.headSha.slice(0, 12)}.`);
11220
+ return 0;
11221
+ }
11222
+ const stageRoot = path12.resolve(args.cwd, MARKER_DIR, `pull-stage-${process.pid}-${crypto.randomUUID()}`);
11223
+ const baseDir = path12.join(stageRoot, "base");
11224
+ const headDir = path12.join(stageRoot, "head");
11225
+ await fs6.mkdir(baseDir, { recursive: true });
11226
+ await fs6.mkdir(headDir, { recursive: true });
11227
+ try {
11228
+ await Promise.all([
11229
+ fetchSnapshot(args.cfg, args.slug, compare.baseSha, baseDir),
11230
+ fetchSnapshot(args.cfg, args.slug, compare.headSha, headDir)
11231
+ ]);
11232
+ return await reconcile({ ...args, compare, paths, baseDir, headDir });
11233
+ } finally {
11234
+ await fs6.rm(stageRoot, { recursive: true, force: true }).catch(() => {
11235
+ return;
11236
+ });
11237
+ }
11238
+ }
11239
+ async function fetchSnapshot(cfg, slug, sha, destination) {
11240
+ await fetchSnapshotRef(cfg, slug, sha, destination, sha);
11241
+ }
11242
+ async function fetchSnapshotRef(cfg, slug, ref, destination, expectedSha) {
11243
+ const response = await apiRaw(cfg, {
11244
+ path: `/apps/${slug}/source-bundle?at=${ref}`
11245
+ });
11246
+ const pinnedSha = response.headers.get(BASE_SHA_HEADER);
11247
+ if (pinnedSha === null || !/^[0-9a-f]{40}$/.test(pinnedSha) || expectedSha !== undefined && pinnedSha !== expectedSha || response.body === null) {
11248
+ throw new Error(`source snapshot provenance mismatch at ${ref}`);
11249
+ }
11250
+ await extractToDir(response.body, destination, {
11251
+ stripComponents: 1,
11252
+ allowNonEmpty: true
11253
+ });
11254
+ return pinnedSha;
11255
+ }
11256
+ async function bootstrapProvenance(args) {
11257
+ let status;
11258
+ let tracked;
11259
+ try {
11260
+ status = await gitOutput(args.cwd, [
11261
+ "status",
11262
+ "--porcelain=v1",
11263
+ "-z",
11264
+ "--untracked-files=all"
11265
+ ]);
11266
+ tracked = splitNull(await gitOutput(args.cwd, ["ls-files", "-z"]));
11267
+ } catch {
11268
+ args.io.err("launchpad pull: --bootstrap requires a legacy Launchpad clone with its synthetic Git baseline.");
11269
+ return 1;
11270
+ }
11271
+ if (status.length > 0) {
11272
+ args.io.err("launchpad pull: --bootstrap refused because the legacy clone has local changes or untracked files.");
11273
+ args.io.err(" Preserve this directory and obtain a separate current clone for comparison.");
11274
+ return 1;
11275
+ }
11276
+ const stageRoot = path12.resolve(args.cwd, MARKER_DIR, `pull-bootstrap-${process.pid}-${crypto.randomUUID()}`);
11277
+ await fs6.mkdir(stageRoot, { recursive: true });
11278
+ try {
11279
+ const headSha = await fetchSnapshotRef(args.cfg, args.slug, "main", stageRoot);
11280
+ const headPaths = await listFiles(stageRoot);
11281
+ if (!await treesEqual(args.cwd, tracked, stageRoot, headPaths)) {
11282
+ args.io.err("launchpad pull: --bootstrap could not prove this legacy baseline matches current managed main.");
11283
+ args.io.err(" The directory was not changed. Preserve it and obtain a separate current clone.");
11284
+ return 1;
11285
+ }
11286
+ await writeBaseShaMarker(args.cwd, headSha);
11287
+ args.io.out(`Trusted source provenance established at ${headSha.slice(0, 12)}.`);
11288
+ return 0;
11289
+ } finally {
11290
+ await fs6.rm(stageRoot, { recursive: true, force: true }).catch(() => {
11291
+ return;
11292
+ });
11293
+ }
11294
+ }
11295
+ async function treesEqual(cwd, tracked, headDir, headPaths) {
11296
+ if (tracked.length !== headPaths.length)
11297
+ return false;
11298
+ const expected = new Set(headPaths);
11299
+ for (const relative5 of tracked) {
11300
+ if (!expected.has(relative5))
11301
+ return false;
11302
+ if (!equal(await readEntry(path12.resolve(cwd, relative5)), await readEntry(path12.resolve(headDir, relative5)))) {
11303
+ return false;
11304
+ }
11305
+ }
11306
+ return true;
11307
+ }
11308
+ async function listFiles(root, relative5 = "") {
11309
+ const files = [];
11310
+ const entries = await fs6.readdir(path12.resolve(root, relative5), {
11311
+ withFileTypes: true
11312
+ });
11313
+ for (const entry of entries) {
11314
+ const child = relative5.length === 0 ? entry.name : `${relative5}/${entry.name}`;
11315
+ if (entry.isDirectory())
11316
+ files.push(...await listFiles(root, child));
11317
+ else if (entry.isFile())
11318
+ files.push(child);
11319
+ }
11320
+ return files.sort();
11321
+ }
11322
+ function gitOutput(cwd, args) {
11323
+ return new Promise((resolve12, reject) => {
11324
+ execFile("git", [...args], { cwd, encoding: "utf8" }, (error, stdout) => {
11325
+ if (error !== null)
11326
+ reject(error);
11327
+ else
11328
+ resolve12(stdout);
11329
+ });
11330
+ });
11331
+ }
11332
+ function splitNull(value) {
11333
+ return value.split("\x00").filter((entry) => entry.length > 0).sort();
11334
+ }
11335
+ async function reconcile(args) {
11336
+ const removals = await cleanAncestorRemovals(args);
11337
+ const planned = await Promise.all(args.paths.map((relative5) => planPath(args, relative5, removals)));
11338
+ const conflicts = planned.flatMap((entry) => entry.conflict === undefined ? [] : [entry.conflict]);
11339
+ if (conflicts.length > 0) {
11340
+ const report = {
11341
+ version: 1,
11342
+ slug: args.slug,
11343
+ baseSha: args.compare.baseSha,
11344
+ headSha: args.compare.headSha,
11345
+ conflicts
11346
+ };
11347
+ await writeJsonAtomic(args.cwd, CONFLICT_REPORT, report);
11348
+ await resetConflictArtifacts(args.cwd);
11349
+ for (const entry of planned) {
11350
+ if (entry.conflict !== undefined) {
11351
+ await writeConflictArtifact(args.cwd, entry);
11352
+ }
11353
+ }
11354
+ }
11355
+ let applied = 0;
11356
+ for (const entry of planned) {
11357
+ if (!entry.apply)
11358
+ continue;
11359
+ await applyUpstream(args.cwd, entry);
11360
+ applied += 1;
11361
+ }
11362
+ if (conflicts.length > 0) {
11363
+ args.io.err(`launchpad pull: ${conflicts.length} conflict(s); ${applied} clean upstream change(s) applied.`);
11364
+ for (const conflict of conflicts.slice(0, 20)) {
11365
+ args.io.err(` - ${conflict.path}: ${conflict.reason}`);
11366
+ args.io.err(` upstream copy: ${conflict.artifact}`);
11367
+ }
11368
+ if (conflicts.length > 20) {
11369
+ args.io.err(` ... and ${conflicts.length - 20} more in ${CONFLICT_REPORT}`);
11370
+ }
11371
+ args.io.err("Resolve each original path using its upstream artifact, then run `launchpad pull` again.");
11372
+ return 1;
11373
+ }
11374
+ await cleanupConflictState(args.cwd);
11375
+ await writeBaseShaMarker(args.cwd, args.compare.headSha);
11376
+ args.io.out(`Updated ${applied} path(s) to managed main ${args.compare.headSha.slice(0, 12)}; local-only work was preserved.`);
11377
+ return 0;
11378
+ }
11379
+ async function cleanAncestorRemovals(args) {
11380
+ const removals = new Set;
11381
+ for (const relative5 of args.compare.deleted) {
11382
+ assertSafePath(relative5);
11383
+ const checked = await checkParents(args.cwd, relative5, removals);
11384
+ if (checked.kind === "blocked")
11385
+ continue;
11386
+ const base = await readEntry(path12.resolve(args.baseDir, relative5));
11387
+ const local = checked.projectedMissing ? { kind: "missing" } : await readEntry(checked.target);
11388
+ if (local.kind !== "missing" && cleanLocal(base, local))
11389
+ removals.add(relative5);
11390
+ }
11391
+ return removals;
11392
+ }
11393
+ async function planPath(args, relative5, removals) {
11394
+ assertSafePath(relative5);
11395
+ const checked = await checkParents(args.cwd, relative5, removals);
11396
+ const base = await readEntry(path12.resolve(args.baseDir, relative5));
11397
+ const upstreamDeleted = args.compare.deleted.includes(relative5);
11398
+ const head = upstreamDeleted ? { kind: "missing" } : await readEntry(path12.resolve(args.headDir, relative5));
11399
+ const unsupported = (args.compare.unsupported ?? []).includes(relative5);
11400
+ if (checked.kind === "blocked") {
11401
+ return conflictPlan(relative5, checked.target, { kind: "missing" }, head, upstreamDeleted, checked.reason);
11402
+ }
11403
+ const local = checked.projectedMissing ? { kind: "missing" } : await readEntry(checked.target);
11404
+ if (unsupported) {
11405
+ return conflictPlan(relative5, checked.target, local, head, false, "upstream or baseline path uses an unsupported Git entry type");
11406
+ }
11407
+ if (alreadyResolved(local, head, upstreamDeleted)) {
11408
+ return { relative: relative5, target: checked.target, local, head, upstreamDeleted, apply: false };
11409
+ }
11410
+ if (unsupportedHead(head, upstreamDeleted)) {
11411
+ return conflictPlan(relative5, checked.target, local, head, true, "unsupported upstream file type");
11412
+ }
11413
+ if (cleanLocal(base, local)) {
11414
+ return { relative: relative5, target: checked.target, local, head, upstreamDeleted, apply: true };
11415
+ }
11416
+ return conflictPlan(relative5, checked.target, local, head, upstreamDeleted, conflictReason(base, local, upstreamDeleted));
11417
+ }
11418
+ function conflictPlan(relative5, target, local, head, upstreamDeleted, reason) {
11419
+ const suffix = upstreamDeleted ? ".upstream-deleted" : ".upstream";
11420
+ const digest = createHash3("sha256").update(relative5).digest("hex");
11421
+ return {
11422
+ relative: relative5,
11423
+ target,
11424
+ local,
11425
+ head,
11426
+ upstreamDeleted,
11427
+ apply: false,
11428
+ conflict: {
11429
+ path: relative5,
11430
+ reason,
11431
+ artifact: `${CONFLICT_DIR}/${digest}${suffix}`,
11432
+ upstreamDeleted
11433
+ }
11434
+ };
11435
+ }
11436
+ function alreadyResolved(local, head, upstreamDeleted) {
11437
+ return equal(local, head) || upstreamDeleted && local.kind === "missing";
11438
+ }
11439
+ function unsupportedHead(head, upstreamDeleted) {
11440
+ return head.kind === "other" || !upstreamDeleted && head.kind === "missing";
11441
+ }
11442
+ function cleanLocal(base, local) {
11443
+ return equal(local, base) || base.kind === "missing" && local.kind === "missing";
11444
+ }
11445
+ async function applyUpstream(cwd, entry) {
11446
+ const checked = await checkParents(cwd, entry.relative, new Set);
11447
+ if (checked.kind === "blocked")
11448
+ throw new Error(checked.reason);
11449
+ if (entry.upstreamDeleted) {
11450
+ if (entry.local.kind !== "missing")
11451
+ await fs6.unlink(checked.target);
11452
+ return;
11453
+ }
11454
+ if (entry.head.kind === "file") {
11455
+ await writeAtomic(cwd, entry.relative, entry.head.bytes, entry.head.mode);
11456
+ }
11457
+ }
11458
+ function conflictReason(base, local, upstreamDeleted) {
11459
+ if (local.kind === "other")
11460
+ return "local path is not a regular file";
11461
+ if (base.kind === "missing")
11462
+ return "local untracked file collides with an upstream addition";
11463
+ if (local.kind === "missing")
11464
+ return "local deletion conflicts with an upstream change";
11465
+ if (upstreamDeleted)
11466
+ return "locally modified file was deleted upstream";
11467
+ return "local and upstream changes differ";
11468
+ }
11469
+ async function writeConflictArtifact(cwd, entry) {
11470
+ if (entry.conflict === undefined)
11471
+ return;
11472
+ const bytes = entry.head.kind === "file" ? entry.head.bytes : entry.upstreamDeleted ? DELETE_NOTE : UNSUPPORTED_NOTE;
11473
+ await writeAtomic(cwd, entry.conflict.artifact, bytes, entry.head.kind === "file" ? entry.head.mode : 420, true);
11474
+ }
11475
+ async function readEntry(file) {
11476
+ const initial = await lstatIfPresent(file);
11477
+ if (initial === null)
11478
+ return { kind: "missing" };
11479
+ if (!initial.isFile() || initial.isSymbolicLink())
11480
+ return { kind: "other" };
11481
+ const opened = await openWithoutFollowing(file);
11482
+ if (opened.kind !== "open")
11483
+ return opened;
11484
+ try {
11485
+ const stat = await opened.handle.stat();
11486
+ if (!stat.isFile())
11487
+ return { kind: "other" };
11488
+ return {
11489
+ kind: "file",
11490
+ bytes: new Uint8Array(await opened.handle.readFile()),
11491
+ mode: stat.mode & 511
11492
+ };
11493
+ } finally {
11494
+ await opened.handle.close();
11495
+ }
11496
+ }
11497
+ async function lstatIfPresent(file) {
11498
+ try {
11499
+ return await fs6.lstat(file);
11500
+ } catch (error) {
11501
+ if (isErrno6(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) {
11502
+ return null;
11503
+ }
11504
+ throw error;
11505
+ }
11506
+ }
11507
+ async function openWithoutFollowing(file) {
11508
+ try {
11509
+ return {
11510
+ kind: "open",
11511
+ handle: await fs6.open(file, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW)
11512
+ };
11513
+ } catch (error) {
11514
+ if (isErrno6(error) && error.code === "ENOENT")
11515
+ return { kind: "missing" };
11516
+ if (isErrno6(error) && error.code === "ELOOP")
11517
+ return { kind: "other" };
11518
+ throw error;
11519
+ }
11520
+ }
11521
+ function equal(left, right) {
11522
+ if (left.kind !== right.kind)
11523
+ return false;
11524
+ if (left.kind !== "file" || right.kind !== "file")
11525
+ return true;
11526
+ if (left.mode !== right.mode || left.bytes.length !== right.bytes.length)
11527
+ return false;
11528
+ return left.bytes.every((byte, index) => byte === right.bytes[index]);
11529
+ }
11530
+ async function writeAtomic(cwd, relative5, bytes, mode, allowMetadata = false) {
11531
+ const checked = await checkParents(cwd, relative5, new Set, allowMetadata);
11532
+ if (checked.kind === "blocked")
11533
+ throw new Error(checked.reason);
11534
+ const file = checked.target;
11535
+ await fs6.mkdir(path12.dirname(file), { recursive: true });
11536
+ const rechecked = await checkParents(cwd, relative5, new Set, allowMetadata);
11537
+ if (rechecked.kind === "blocked")
11538
+ throw new Error(rechecked.reason);
11539
+ const temporary = `${file}.launchpad-tmp-${process.pid}-${crypto.randomUUID()}`;
11540
+ try {
11541
+ await fs6.writeFile(temporary, bytes, { mode });
11542
+ await fs6.chmod(temporary, mode);
11543
+ await fs6.rename(temporary, file);
11544
+ } catch (error) {
11545
+ await fs6.rm(temporary, { force: true }).catch(() => {
11546
+ return;
11547
+ });
11548
+ throw error;
11549
+ }
11550
+ }
11551
+ async function writeJsonAtomic(cwd, relative5, value) {
11552
+ await writeAtomic(cwd, relative5, new TextEncoder().encode(`${JSON.stringify(value, null, 2)}
11553
+ `), 384, true);
11554
+ }
11555
+ async function resetConflictArtifacts(cwd) {
11556
+ const checked = await checkParents(cwd, CONFLICT_DIR, new Set, true);
11557
+ if (checked.kind === "blocked")
11558
+ throw new Error(checked.reason);
11559
+ await fs6.rm(checked.target, { recursive: true, force: true });
11560
+ }
11561
+ async function cleanupConflictState(cwd) {
11562
+ await resetConflictArtifacts(cwd);
11563
+ const report = await checkParents(cwd, CONFLICT_REPORT, new Set, true);
11564
+ if (report.kind === "blocked")
11565
+ throw new Error(report.reason);
11566
+ await fs6.rm(report.target, { force: true });
11567
+ }
11568
+ function assertSafePath(relative5) {
11569
+ if (!isSafeSourcePath(relative5)) {
11570
+ throw new Error(`unsafe source path returned by bot: ${relative5}`);
11571
+ }
11572
+ }
11573
+ function isSafeSourcePath(relative5) {
11574
+ return isSafeRelativePath(relative5) && !relative5.startsWith(`${MARKER_DIR}/`);
11575
+ }
11576
+ function isSafeRelativePath(relative5) {
11577
+ const normalized = path12.posix.normalize(relative5);
11578
+ return relative5.length > 0 && !relative5.includes("\\") && !path12.isAbsolute(relative5) && normalized === relative5 && normalized !== ".." && !normalized.startsWith("../");
11579
+ }
11580
+ function resolveInsideClone(cwd, relative5) {
11581
+ const root = path12.resolve(cwd);
11582
+ const target = path12.resolve(root, relative5);
11583
+ const fromRoot = path12.relative(root, target);
11584
+ if (fromRoot.length === 0 || path12.isAbsolute(fromRoot) || fromRoot === ".." || fromRoot.startsWith(`..${path12.sep}`)) {
11585
+ throw new Error(`unsafe source path returned by bot: ${relative5}`);
11586
+ }
11587
+ return target;
11588
+ }
11589
+ async function checkParents(cwd, relative5, projectedRemovals, allowMetadata = false) {
11590
+ if (allowMetadata) {
11591
+ if (!isSafeRelativePath(relative5)) {
11592
+ throw new Error(`unsafe metadata path: ${relative5}`);
11593
+ }
11594
+ } else {
11595
+ assertSafePath(relative5);
11596
+ }
11597
+ const target = resolveInsideClone(cwd, relative5);
11598
+ const components = relative5.split("/");
11599
+ let current = path12.resolve(cwd);
11600
+ for (let index = 0;index < components.length - 1; index += 1) {
11601
+ current = path12.join(current, components[index]);
11602
+ const ancestor = components.slice(0, index + 1).join("/");
11603
+ const stat = await lstatIfPresent(current);
11604
+ if (stat === null)
11605
+ return { kind: "safe", target, projectedMissing: true };
11606
+ if (projectedRemovals.has(ancestor)) {
11607
+ return { kind: "safe", target, projectedMissing: true };
11608
+ }
11609
+ if (stat.isSymbolicLink()) {
11610
+ return {
11611
+ kind: "blocked",
11612
+ target,
11613
+ reason: `local parent is a symbolic link: ${ancestor}`
11614
+ };
11615
+ }
11616
+ if (!stat.isDirectory()) {
11617
+ return {
11618
+ kind: "blocked",
11619
+ target,
11620
+ reason: `local parent is not a directory: ${ancestor}`
11621
+ };
11622
+ }
11623
+ }
11624
+ return { kind: "safe", target, projectedMissing: false };
11625
+ }
11626
+ async function assertSafeCloneRoot(cwd) {
11627
+ const root = await fs6.lstat(path12.resolve(cwd));
11628
+ if (!root.isDirectory() || root.isSymbolicLink()) {
11629
+ throw new Error("launchpad pull requires a real clone directory");
11630
+ }
11631
+ try {
11632
+ const metadata = await fs6.lstat(path12.resolve(cwd, MARKER_DIR));
11633
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
11634
+ throw new Error("launchpad pull refuses a non-directory or symlinked .launchpad path");
11635
+ }
11636
+ } catch (error) {
11637
+ if (!isErrno6(error) || error.code !== "ENOENT")
11638
+ throw error;
11639
+ }
11640
+ }
11641
+ function isErrno6(error) {
11642
+ return error instanceof Error && "code" in error;
11643
+ }
11644
+
11010
11645
  // src/commands/pull.ts
11011
11646
  var pullCommand = {
11012
11647
  name: "pull",
11013
- summary: "read the deployed launchpad.yaml for an app",
11648
+ summary: "sync upstream source into a clone without overwriting local work",
11014
11649
  run: runPull
11015
11650
  };
11016
11651
  var SLUG_RE11 = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
@@ -11023,6 +11658,15 @@ async function runPull(args, io) {
11023
11658
  }
11024
11659
  try {
11025
11660
  const cfg = loadConfig();
11661
+ if (!parsed.manifest) {
11662
+ return await runSourceSync({
11663
+ cfg,
11664
+ cwd: process.cwd(),
11665
+ slug: parsed.slug,
11666
+ io,
11667
+ bootstrap: parsed.bootstrap
11668
+ });
11669
+ }
11026
11670
  if (parsed.status) {
11027
11671
  const noStatusMsg = `launchpad pull: no status block deployed for "${parsed.slug}" yet — ` + `the bot writes it after the first apply.`;
11028
11672
  let res;
@@ -11105,6 +11749,8 @@ function parseArgs10(args, cwd = process.cwd(), warn) {
11105
11749
  let slug = null;
11106
11750
  let out = null;
11107
11751
  let status = false;
11752
+ let manifest = false;
11753
+ let bootstrap = false;
11108
11754
  let i = 0;
11109
11755
  while (i < args.length) {
11110
11756
  const a = args[i] ?? "";
@@ -11113,6 +11759,16 @@ function parseArgs10(args, cwd = process.cwd(), warn) {
11113
11759
  i += 1;
11114
11760
  continue;
11115
11761
  }
11762
+ if (a === "--manifest") {
11763
+ manifest = true;
11764
+ i += 1;
11765
+ continue;
11766
+ }
11767
+ if (a === "--bootstrap") {
11768
+ bootstrap = true;
11769
+ i += 1;
11770
+ continue;
11771
+ }
11116
11772
  if (a === "--slug") {
11117
11773
  const v = args[i + 1];
11118
11774
  if (v === undefined)
@@ -11151,23 +11807,38 @@ function parseArgs10(args, cwd = process.cwd(), warn) {
11151
11807
  if (!SLUG_RE11.test(slug)) {
11152
11808
  return `invalid slug "${slug}" — expected ${SLUG_RE11.source}`;
11153
11809
  }
11154
- return { slug, out, status };
11810
+ const modeError = validateModeFlags({ manifest, status, out, bootstrap });
11811
+ if (modeError !== null)
11812
+ return modeError;
11813
+ return { slug, out, status, manifest, bootstrap };
11814
+ }
11815
+ function validateModeFlags(args) {
11816
+ if (!args.manifest && args.status)
11817
+ return "--status requires --manifest";
11818
+ if (!args.manifest && args.out !== null)
11819
+ return "--out requires --manifest";
11820
+ if (args.manifest && args.bootstrap) {
11821
+ return "--bootstrap cannot be combined with --manifest";
11822
+ }
11823
+ return null;
11155
11824
  }
11156
11825
  function printUsage5(io) {
11157
11826
  io.err([
11158
- "usage: launchpad pull [<slug>] [--slug <slug>] [--out <path>] [--status]",
11827
+ "usage: launchpad pull [<slug>] [--slug <slug>] [--bootstrap]",
11828
+ " launchpad pull --manifest [<slug>] [--out <path>] [--status]",
11159
11829
  "",
11160
- " Reads the deployed launchpad.yaml for an app via the bot.",
11161
- " No local platform-repo or terraform required.",
11830
+ " By default, safely syncs managed source into the current clone.",
11831
+ " --manifest keeps the previous deployed-manifest read behavior.",
11162
11832
  "",
11163
11833
  " With no slug, it is inferred from the local launchpad.yaml's",
11164
11834
  " declared slug first, then from a launchpad-app-<slug>/",
11165
11835
  " directory name. Explicit --slug or positional override.",
11166
11836
  "",
11167
- " --status read the role-redacted status block (what your",
11837
+ " --status with --manifest, read the role-redacted status block",
11838
+ " (what your",
11168
11839
  " role may see) instead of the spec manifest.",
11169
11840
  "",
11170
- " Without --out, the output is written to stdout."
11841
+ " In manifest mode, omitting --out writes YAML to stdout."
11171
11842
  ].join(`
11172
11843
  `));
11173
11844
  }
@@ -11436,7 +12107,7 @@ var watchCommand = {
11436
12107
  };
11437
12108
 
11438
12109
  // src/commands/review.ts
11439
- import * as path12 from "node:path";
12110
+ import * as path13 from "node:path";
11440
12111
  var reviewCommand = {
11441
12112
  name: "review",
11442
12113
  summary: "show the review state for a PR (slug-scoped)",
@@ -11456,7 +12127,7 @@ async function runReview(args, io) {
11456
12127
  } else {
11457
12128
  const inferred = inferSlug({ cwd: process.cwd(), warn: (l) => io.err(l) });
11458
12129
  if (inferred === null) {
11459
- io.err(`launchpad review: could not infer slug from cwd (${path12.basename(process.cwd())});
12130
+ io.err(`launchpad review: could not infer slug from cwd (${path13.basename(process.cwd())});
11460
12131
  ` + ` pass --slug <slug>, or cd into a directory named launchpad-app-<slug>.`);
11461
12132
  return 64;
11462
12133
  }
@@ -11713,16 +12384,16 @@ async function runRollback(opts, io, deps = {}) {
11713
12384
  yes: true
11714
12385
  }, io, applyDeps);
11715
12386
  }
11716
- function readCurrentManifest(path13) {
11717
- if (!existsSync10(path13))
12387
+ function readCurrentManifest(path14) {
12388
+ if (!existsSync10(path14))
11718
12389
  return null;
11719
12390
  let raw;
11720
12391
  try {
11721
- raw = readFileSync15(path13, "utf8");
12392
+ raw = readFileSync15(path14, "utf8");
11722
12393
  } catch {
11723
12394
  return null;
11724
12395
  }
11725
- const parsed = parseManifest2(raw, path13);
12396
+ const parsed = parseManifest2(raw, path14);
11726
12397
  if (parsed.kind !== "ok")
11727
12398
  return null;
11728
12399
  return summarise(parsed.manifest);
@@ -12272,23 +12943,23 @@ var CELL_LABEL2 = {
12272
12943
  not_deployed: "NOT_DEPLOYED"
12273
12944
  };
12274
12945
  function loadSet(fleetFile, setName, io) {
12275
- const path13 = resolvePath6(process.cwd(), fleetFile ?? "fleet-secret-sets.yaml");
12276
- if (!existsSync12(path13)) {
12277
- io.err(`✗ ${path13}`);
12946
+ const path14 = resolvePath6(process.cwd(), fleetFile ?? "fleet-secret-sets.yaml");
12947
+ if (!existsSync12(path14)) {
12948
+ io.err(`✗ ${path14}`);
12278
12949
  io.err(" fleet-secret-sets.yaml not found. Run from the platform repo root or pass --fleet-file.");
12279
12950
  return 2;
12280
12951
  }
12281
12952
  let obj;
12282
12953
  try {
12283
- obj = parseYaml8(readFileSync17(path13, "utf8"));
12954
+ obj = parseYaml8(readFileSync17(path14, "utf8"));
12284
12955
  } catch (e) {
12285
- io.err(`✗ ${path13}`);
12956
+ io.err(`✗ ${path14}`);
12286
12957
  io.err(` YAML parse error: ${e instanceof Error ? e.message : String(e)}`);
12287
12958
  return 1;
12288
12959
  }
12289
12960
  const parsed = parseFleetSecretSets(obj);
12290
12961
  if (!parsed.ok) {
12291
- io.err(`✗ ${path13}`);
12962
+ io.err(`✗ ${path14}`);
12292
12963
  io.err(` ${parsed.issues.length} schema issue(s):`);
12293
12964
  for (const i of parsed.issues)
12294
12965
  io.err(` ${i.path}: ${i.message}`);
@@ -12297,7 +12968,7 @@ function loadSet(fleetFile, setName, io) {
12297
12968
  const set = parsed.manifest.secretSets.find((s) => s.name === setName);
12298
12969
  if (set === undefined) {
12299
12970
  const names = parsed.manifest.secretSets.map((s) => s.name).join(", ");
12300
- io.err(`✗ secret-set "${setName}" not found in ${path13}`);
12971
+ io.err(`✗ secret-set "${setName}" not found in ${path14}`);
12301
12972
  io.err(` declared sets: ${names}`);
12302
12973
  return 1;
12303
12974
  }
@@ -12517,7 +13188,7 @@ function setPushExit(e) {
12517
13188
 
12518
13189
  // src/commands/secrets-template.ts
12519
13190
  import { existsSync as existsSync13, writeFileSync as writeFileSync6 } from "node:fs";
12520
- import { resolve as resolve11 } from "node:path";
13191
+ import { resolve as resolve12 } from "node:path";
12521
13192
  async function runSecretsTemplate(args, io) {
12522
13193
  const flags = parseFlags4(args);
12523
13194
  if (flags.kind === "usage-error") {
@@ -12525,7 +13196,7 @@ async function runSecretsTemplate(args, io) {
12525
13196
  io.err("Usage: launchpad secrets template [--file <path>] [--out <path>] " + "[--stdout] [--force] [--include-platform-managed]");
12526
13197
  return 64;
12527
13198
  }
12528
- const manifestPath = resolve11(process.cwd(), flags.file ?? "launchpad.yaml");
13199
+ const manifestPath = resolve12(process.cwd(), flags.file ?? "launchpad.yaml");
12529
13200
  const result = loadManifest(manifestPath);
12530
13201
  const renderResult = renderManifest(result, io);
12531
13202
  if (renderResult.kind !== "ok") {
@@ -12539,7 +13210,7 @@ async function runSecretsTemplate(args, io) {
12539
13210
  }
12540
13211
  return 0;
12541
13212
  }
12542
- const outPath = resolve11(process.cwd(), flags.out);
13213
+ const outPath = resolve12(process.cwd(), flags.out);
12543
13214
  if (existsSync13(outPath) && !flags.force) {
12544
13215
  io.err(`launchpad secrets template: ${outPath} already exists`);
12545
13216
  io.err("Pass --force to overwrite, or --stdout to print without writing.");
@@ -12836,16 +13507,16 @@ function printHelp3(io) {
12836
13507
 
12837
13508
  // src/commands/skills.ts
12838
13509
  import { fileURLToPath } from "node:url";
12839
- import { dirname as dirname8, join as join14, resolve as resolve12 } from "node:path";
12840
- import { promises as fs6, existsSync as existsSync14 } from "node:fs";
13510
+ import { dirname as dirname9, join as join15, resolve as resolve13 } from "node:path";
13511
+ import { promises as fs7, existsSync as existsSync14 } from "node:fs";
12841
13512
 
12842
13513
  // src/skills-bundle.ts
12843
13514
  import { homedir as homedir2 } from "node:os";
12844
- import { join as join13 } from "node:path";
13515
+ import { join as join14 } from "node:path";
12845
13516
  import { readFileSync as readFileSync18, readdirSync as readdirSync2 } from "node:fs";
12846
13517
  var SKILL_PREFIX = "launchpad-";
12847
13518
  function skillsTargetDir() {
12848
- return process.env.LAUNCHPAD_SKILLS_TARGET_DIR ?? join13(homedir2(), ".claude", "skills");
13519
+ return process.env.LAUNCHPAD_SKILLS_TARGET_DIR ?? join14(homedir2(), ".claude", "skills");
12849
13520
  }
12850
13521
  function readInstalledSkills() {
12851
13522
  try {
@@ -12855,15 +13526,15 @@ function readInstalledSkills() {
12855
13526
  return { present: false, version: null };
12856
13527
  return {
12857
13528
  present: true,
12858
- version: readSkillVersion(join13(dir, bundle.name, "SKILL.md"))
13529
+ version: readSkillVersion(join14(dir, bundle.name, "SKILL.md"))
12859
13530
  };
12860
13531
  } catch {
12861
13532
  return { present: false, version: null };
12862
13533
  }
12863
13534
  }
12864
- function readSkillVersion(path13) {
13535
+ function readSkillVersion(path14) {
12865
13536
  try {
12866
- const text = readFileSync18(path13, "utf8");
13537
+ const text = readFileSync18(path14, "utf8");
12867
13538
  const fenceEnd = text.indexOf(`
12868
13539
  ---`, 4);
12869
13540
  const front = fenceEnd === -1 ? text.slice(0, 1024) : text.slice(0, fenceEnd);
@@ -12940,13 +13611,13 @@ function resolveInstallEnv() {
12940
13611
  return { bundleDir, userSkillsDir };
12941
13612
  }
12942
13613
  function defaultBundleDir() {
12943
- const here = dirname8(fileURLToPath(import.meta.url));
13614
+ const here = dirname9(fileURLToPath(import.meta.url));
12944
13615
  const candidates = [
12945
- resolve12(here, "..", "skills"),
12946
- resolve12(here, "..", "..", "skills")
13616
+ resolve13(here, "..", "skills"),
13617
+ resolve13(here, "..", "..", "skills")
12947
13618
  ];
12948
13619
  for (const c of candidates) {
12949
- if (existsSync14(join14(c, "launchpad-onboard", "SKILL.md"))) {
13620
+ if (existsSync14(join15(c, "launchpad-onboard", "SKILL.md"))) {
12950
13621
  return c;
12951
13622
  }
12952
13623
  }
@@ -12962,19 +13633,19 @@ async function doInstall(io) {
12962
13633
  return 1;
12963
13634
  }
12964
13635
  if (!await isDir(env.userSkillsDir)) {
12965
- await fs6.mkdir(env.userSkillsDir, { recursive: true });
13636
+ await fs7.mkdir(env.userSkillsDir, { recursive: true });
12966
13637
  io.out(`Created ${env.userSkillsDir}.`);
12967
13638
  }
12968
13639
  let installed = 0;
12969
13640
  for (const skill of BUNDLED_SKILLS) {
12970
- const src = join14(env.bundleDir, skill);
13641
+ const src = join15(env.bundleDir, skill);
12971
13642
  if (!await isDir(src)) {
12972
13643
  io.err(`launchpad skills install: bundled skill "${skill}" missing from ${env.bundleDir} — package is incomplete.`);
12973
13644
  return 1;
12974
13645
  }
12975
- const dest = join14(env.userSkillsDir, skill);
12976
- await fs6.rm(dest, { recursive: true, force: true });
12977
- await fs6.cp(src, dest, { recursive: true });
13646
+ const dest = join15(env.userSkillsDir, skill);
13647
+ await fs7.rm(dest, { recursive: true, force: true });
13648
+ await fs7.cp(src, dest, { recursive: true });
12978
13649
  installed++;
12979
13650
  io.out(`✓ ${skill} → ${dest}`);
12980
13651
  }
@@ -12990,15 +13661,15 @@ async function doUninstall(io) {
12990
13661
  io.out(`launchpad skills uninstall: ${env.userSkillsDir} does not exist — nothing to do.`);
12991
13662
  return 0;
12992
13663
  }
12993
- const entries = await fs6.readdir(env.userSkillsDir, { withFileTypes: true });
13664
+ const entries = await fs7.readdir(env.userSkillsDir, { withFileTypes: true });
12994
13665
  let removed = 0;
12995
13666
  for (const entry of entries) {
12996
13667
  if (!entry.isDirectory())
12997
13668
  continue;
12998
13669
  if (!isBundleManaged(entry.name))
12999
13670
  continue;
13000
- const target = join14(env.userSkillsDir, entry.name);
13001
- await fs6.rm(target, { recursive: true, force: true });
13671
+ const target = join15(env.userSkillsDir, entry.name);
13672
+ await fs7.rm(target, { recursive: true, force: true });
13002
13673
  removed++;
13003
13674
  io.out(`✗ removed ${target}`);
13004
13675
  }
@@ -13012,7 +13683,7 @@ async function doList(io) {
13012
13683
  io.out("(none — ~/.claude/skills/ does not exist)");
13013
13684
  return 0;
13014
13685
  }
13015
- const entries = await fs6.readdir(env.userSkillsDir, { withFileTypes: true });
13686
+ const entries = await fs7.readdir(env.userSkillsDir, { withFileTypes: true });
13016
13687
  const managedDirs = entries.filter((e) => e.isDirectory() && isBundleManaged(e.name)).map((e) => e.name).sort();
13017
13688
  if (managedDirs.length === 0) {
13018
13689
  io.out("(none — run `launchpad skills install`)");
@@ -13020,16 +13691,16 @@ async function doList(io) {
13020
13691
  }
13021
13692
  const width = managedDirs.reduce((n, s) => Math.max(n, s.length), 0);
13022
13693
  for (const name of managedDirs) {
13023
- const skillFile = join14(env.userSkillsDir, name, "SKILL.md");
13694
+ const skillFile = join15(env.userSkillsDir, name, "SKILL.md");
13024
13695
  const version = await readVersion(skillFile);
13025
13696
  io.out(` ${name.padEnd(width + 2)}${version ?? "(no version)"}`);
13026
13697
  }
13027
13698
  return 0;
13028
13699
  }
13029
- async function readVersion(path13) {
13700
+ async function readVersion(path14) {
13030
13701
  let text;
13031
13702
  try {
13032
- text = await fs6.readFile(path13, "utf8");
13703
+ text = await fs7.readFile(path14, "utf8");
13033
13704
  } catch {
13034
13705
  return null;
13035
13706
  }
@@ -13039,9 +13710,9 @@ async function readVersion(path13) {
13039
13710
  const m = /^version:\s*(.+?)\s*$/m.exec(front);
13040
13711
  return m === null ? null : m[1] ?? null;
13041
13712
  }
13042
- async function isDir(path13) {
13713
+ async function isDir(path14) {
13043
13714
  try {
13044
- const stat = await fs6.stat(path13);
13715
+ const stat = await fs7.stat(path14);
13045
13716
  return stat.isDirectory();
13046
13717
  } catch {
13047
13718
  return false;
@@ -13052,16 +13723,16 @@ function describe29(e) {
13052
13723
  }
13053
13724
 
13054
13725
  // src/commands/update.ts
13055
- import { execFile, spawn as spawn5 } from "node:child_process";
13726
+ import { execFile as execFile2, spawn as spawn5 } from "node:child_process";
13056
13727
  import { promisify } from "node:util";
13057
13728
  import { fileURLToPath as fileURLToPath2 } from "node:url";
13058
- import { dirname as dirname9, resolve as resolve13, relative as relative4, isAbsolute as isAbsolute2, join as join15 } from "node:path";
13729
+ import { dirname as dirname10, resolve as resolve14, relative as relative5, isAbsolute as isAbsolute3, join as join16 } from "node:path";
13059
13730
  import { homedir as homedir3, tmpdir } from "node:os";
13060
13731
  import { readFileSync as readFileSync19, mkdtempSync, writeFileSync as writeFileSync7, rmSync as rmSync2 } from "node:fs";
13061
13732
 
13062
13733
  // src/commands/channel-auth.ts
13063
13734
  import { createServer as createServer2 } from "node:http";
13064
- import { createHash as createHash3, randomBytes as randomBytes5 } from "node:crypto";
13735
+ import { createHash as createHash4, randomBytes as randomBytes5 } from "node:crypto";
13065
13736
  var CHANNEL_BASE = "https://get.launchpad.m-kopa.us";
13066
13737
  var CLI_AUTH_URL = `${CHANNEL_BASE}/__cli_auth`;
13067
13738
  var CLI_TOKEN_URL = `${CHANNEL_BASE}/__cli_token`;
@@ -13072,7 +13743,7 @@ function base64url(b) {
13072
13743
  }
13073
13744
  function pkcePair() {
13074
13745
  const verifier = base64url(randomBytes5(32));
13075
- const challenge = base64url(createHash3("sha256").update(verifier).digest());
13746
+ const challenge = base64url(createHash4("sha256").update(verifier).digest());
13076
13747
  return { verifier, challenge };
13077
13748
  }
13078
13749
  async function startLoopback(state, timeoutMs) {
@@ -13100,9 +13771,9 @@ async function startLoopback(state, timeoutMs) {
13100
13771
  resolveCode(code);
13101
13772
  }
13102
13773
  });
13103
- const bound = await new Promise((resolve13) => {
13104
- server.once("error", () => resolve13(false));
13105
- server.listen(0, "127.0.0.1", () => resolve13(true));
13774
+ const bound = await new Promise((resolve14) => {
13775
+ server.once("error", () => resolve14(false));
13776
+ server.listen(0, "127.0.0.1", () => resolve14(true));
13106
13777
  });
13107
13778
  if (!bound)
13108
13779
  return null;
@@ -13173,12 +13844,12 @@ async function runChannelLoopbackUpdate(deps) {
13173
13844
  }
13174
13845
 
13175
13846
  // src/commands/update.ts
13176
- var execFileAsync = promisify(execFile);
13847
+ var execFileAsync = promisify(execFile2);
13177
13848
  var PKG = "@m-kopa/launchpad-cli";
13178
13849
  var REGISTRY = "https://registry.npmjs.org";
13179
13850
  var CHANNEL_VERSION_URL = "https://get.launchpad.m-kopa.us/version.json";
13180
13851
  var CHANNEL_INSTALL_URL = "https://get.launchpad.m-kopa.us";
13181
- var CHANNEL_MARKER = join15(homedir3(), ".launchpad", "channel");
13852
+ var CHANNEL_MARKER = join16(homedir3(), ".launchpad", "channel");
13182
13853
  var EXIT_UPDATE_AVAILABLE = 10;
13183
13854
  var UPGRADE_ARGS = {
13184
13855
  npm: ["install", "-g", `${PKG}@latest`],
@@ -13307,8 +13978,8 @@ async function openSystemBrowser(url) {
13307
13978
  await execFileAsync(opener, [url]);
13308
13979
  }
13309
13980
  async function runInstallerScript(script) {
13310
- const dir = mkdtempSync(join15(tmpdir(), "launchpad-update-"));
13311
- const file = join15(dir, "install.sh");
13981
+ const dir = mkdtempSync(join16(tmpdir(), "launchpad-update-"));
13982
+ const file = join16(dir, "install.sh");
13312
13983
  try {
13313
13984
  writeFileSync7(file, script, { mode: 448 });
13314
13985
  return await new Promise((resolvePromise) => {
@@ -13395,7 +14066,7 @@ function errStderr(e) {
13395
14066
  return "";
13396
14067
  }
13397
14068
  async function detectPackageManager2() {
13398
- const pkgRoot = resolve13(dirname9(fileURLToPath2(import.meta.url)), "..", "..");
14069
+ const pkgRoot = resolve14(dirname10(fileURLToPath2(import.meta.url)), "..", "..");
13399
14070
  const candidates = [];
13400
14071
  const npmRoot = await pmRoot("npm");
13401
14072
  if (npmRoot !== null)
@@ -13403,7 +14074,7 @@ async function detectPackageManager2() {
13403
14074
  const pnpmRoot = await pmRoot("pnpm");
13404
14075
  if (pnpmRoot !== null)
13405
14076
  candidates.push(["pnpm", pnpmRoot]);
13406
- candidates.push(["bun", resolve13(homedir3(), ".bun/install/global/node_modules")]);
14077
+ candidates.push(["bun", resolve14(homedir3(), ".bun/install/global/node_modules")]);
13407
14078
  const matches = candidates.filter(([, root]) => pathContains(root, pkgRoot));
13408
14079
  return matches.length === 1 ? matches[0][0] : null;
13409
14080
  }
@@ -13417,8 +14088,8 @@ async function pmRoot(pm) {
13417
14088
  }
13418
14089
  }
13419
14090
  function pathContains(root, child) {
13420
- const rel = relative4(root, child);
13421
- return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
14091
+ const rel = relative5(root, child);
14092
+ return rel === "" || !rel.startsWith("..") && !isAbsolute3(rel);
13422
14093
  }
13423
14094
  function runUpgrade(pm) {
13424
14095
  return new Promise((resolvePromise) => {
@@ -13541,7 +14212,7 @@ function printHelp5(io) {
13541
14212
 
13542
14213
  // src/commands/validate.ts
13543
14214
  import { existsSync as existsSync15, readFileSync as readFileSync20, readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
13544
- import { dirname as dirname10, resolve as resolve14 } from "node:path";
14215
+ import { dirname as dirname11, resolve as resolve15 } from "node:path";
13545
14216
  var validateCommand = {
13546
14217
  name: "validate",
13547
14218
  summary: "validate launchpad.yaml against the v1alpha1 schema",
@@ -13555,13 +14226,13 @@ async function runValidate(args, io) {
13555
14226
  return 64;
13556
14227
  }
13557
14228
  const { file, json, strictGroups } = parseResult;
13558
- const path13 = resolve14(process.cwd(), file ?? "launchpad.yaml");
13559
- const result = loadManifest(path13);
14229
+ const path14 = resolve15(process.cwd(), file ?? "launchpad.yaml");
14230
+ const result = loadManifest(path14);
13560
14231
  if (result.kind !== "ok") {
13561
14232
  return json ? renderJsonError(result, io) : renderHumanError(result, io);
13562
14233
  }
13563
- const boundary = checkBoundary(path13, result.manifest.app !== undefined);
13564
- const serveErrors = checkStaticServeDir(path13, result.manifest);
14234
+ const boundary = checkBoundary(path14, result.manifest.app !== undefined);
14235
+ const serveErrors = checkStaticServeDir(path14, result.manifest);
13565
14236
  if (serveErrors.length > 0) {
13566
14237
  boundary.errors = [...boundary.errors, ...serveErrors];
13567
14238
  }
@@ -13569,7 +14240,7 @@ async function runValidate(args, io) {
13569
14240
  return json ? renderJsonOk(result, groupCheck, boundary, io) : renderHumanOk(result, groupCheck, boundary, io);
13570
14241
  }
13571
14242
  function checkBoundary(manifestPath, declared) {
13572
- const dir = dirname10(manifestPath);
14243
+ const dir = dirname11(manifestPath);
13573
14244
  let files;
13574
14245
  try {
13575
14246
  files = walkCwd(dir).files;
@@ -13601,8 +14272,8 @@ function checkStaticServeDir(manifestPath, manifest) {
13601
14272
  `static app destination_dir '${serveDir}' is unsafe — use a confined relative subdirectory (e.g. '${STATIC_SERVE_DIR}'). ` + `Serving '.' or an absolute/parent path would publish files outside your app's content.`
13602
14273
  ];
13603
14274
  }
13604
- const dir = dirname10(manifestPath);
13605
- const abs = resolve14(dir, serveDir);
14275
+ const dir = dirname11(manifestPath);
14276
+ const abs = resolve15(dir, serveDir);
13606
14277
  if (!existsSync15(abs)) {
13607
14278
  return [
13608
14279
  `static app serves '${serveDir}/' but that directory does not exist — nothing would be served. ` + `Create ${serveDir}/index.html (or run \`launchpad init\` to scaffold it).`
@@ -13628,7 +14299,7 @@ function checkStaticServeDir(manifestPath, manifest) {
13628
14299
  `static app's served directory '${serveDir}/' is empty — the deploy would serve an empty site.`
13629
14300
  ];
13630
14301
  }
13631
- if (!existsSync15(resolve14(abs, "index.html"))) {
14302
+ if (!existsSync15(resolve15(abs, "index.html"))) {
13632
14303
  return [
13633
14304
  `static app's served directory '${serveDir}/' has no index.html — the deploy would serve a directory with no entrypoint.`
13634
14305
  ];
@@ -14497,7 +15168,7 @@ var listOwnersCommand = makeListOwnersCommand();
14497
15168
  // src/update-notifier.ts
14498
15169
  import { spawn as spawn6 } from "node:child_process";
14499
15170
  import { homedir as homedir4 } from "node:os";
14500
- import { join as join16 } from "node:path";
15171
+ import { join as join17 } from "node:path";
14501
15172
  import {
14502
15173
  existsSync as existsSync16,
14503
15174
  mkdirSync as mkdirSync4,
@@ -14507,7 +15178,7 @@ import {
14507
15178
  var INTERNAL_REFRESH_VERB = "__refresh-update-cache";
14508
15179
  var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
14509
15180
  var OPT_OUT_ENV = "LAUNCHPAD_NO_UPDATE_NOTIFIER";
14510
- var CACHE_FILE = join16(homedir4(), ".launchpad", "update-check.json");
15181
+ var CACHE_FILE = join17(homedir4(), ".launchpad", "update-check.json");
14511
15182
  function readCache2() {
14512
15183
  try {
14513
15184
  const raw = JSON.parse(readFileSync21(CACHE_FILE, "utf8"));
@@ -14523,12 +15194,12 @@ function readCache2() {
14523
15194
  }
14524
15195
  function writeCache2(state) {
14525
15196
  try {
14526
- mkdirSync4(join16(homedir4(), ".launchpad"), { recursive: true });
15197
+ mkdirSync4(join17(homedir4(), ".launchpad"), { recursive: true });
14527
15198
  writeFileSync8(CACHE_FILE, `${JSON.stringify(state)}
14528
15199
  `, { mode: 384 });
14529
15200
  } catch {}
14530
15201
  }
14531
- var SKILLS_HINT_MARKER = join16(homedir4(), ".launchpad", "skills-hint-shown");
15202
+ var SKILLS_HINT_MARKER = join17(homedir4(), ".launchpad", "skills-hint-shown");
14532
15203
  function absentHintShown() {
14533
15204
  try {
14534
15205
  return existsSync16(SKILLS_HINT_MARKER);
@@ -14538,7 +15209,7 @@ function absentHintShown() {
14538
15209
  }
14539
15210
  function markAbsentHintShown() {
14540
15211
  try {
14541
- mkdirSync4(join16(homedir4(), ".launchpad"), { recursive: true });
15212
+ mkdirSync4(join17(homedir4(), ".launchpad"), { recursive: true });
14542
15213
  writeFileSync8(SKILLS_HINT_MARKER, `${Date.now()}
14543
15214
  `, { mode: 384 });
14544
15215
  } catch {}
@@ -14641,7 +15312,7 @@ var refreshUpdateCacheCommand = {
14641
15312
  // src/telemetry.ts
14642
15313
  import { spawn as spawn7 } from "node:child_process";
14643
15314
  import { homedir as homedir5 } from "node:os";
14644
- import { join as join17 } from "node:path";
15315
+ import { join as join18 } from "node:path";
14645
15316
  import { existsSync as existsSync17, mkdirSync as mkdirSync5, readFileSync as readFileSync22, writeFileSync as writeFileSync9 } from "node:fs";
14646
15317
  import { randomUUID } from "node:crypto";
14647
15318
  var INTERNAL_EMIT_VERB = "__emit-telemetry";
@@ -14650,8 +15321,8 @@ var POSTHOG_PROJECT_KEY = "phc_CYaCuETanWc36TMTiB7cdkdnmPPhFZCkDGmWGLaNkXnb";
14650
15321
  var CAPTURE_PATH = "/i/v0/e/";
14651
15322
  var SEND_TIMEOUT_MS = 3000;
14652
15323
  var APP = "launchpad-cli";
14653
- var DEVICE_ID_FILE = join17(homedir5(), ".launchpad", "telemetry-id.json");
14654
- var FIRST_RUN_MARKER = join17(homedir5(), ".launchpad", "telemetry-notice-shown");
15324
+ var DEVICE_ID_FILE = join18(homedir5(), ".launchpad", "telemetry-id.json");
15325
+ var FIRST_RUN_MARKER = join18(homedir5(), ".launchpad", "telemetry-notice-shown");
14655
15326
  function buildEvent(ctx, identity, nowMs) {
14656
15327
  const properties = {
14657
15328
  app: APP,
@@ -14725,7 +15396,7 @@ function getOrCreateDeviceId() {
14725
15396
  } catch {}
14726
15397
  const id = randomUUID();
14727
15398
  try {
14728
- mkdirSync5(join17(homedir5(), ".launchpad"), { recursive: true });
15399
+ mkdirSync5(join18(homedir5(), ".launchpad"), { recursive: true });
14729
15400
  writeFileSync9(DEVICE_ID_FILE, `${JSON.stringify({ deviceId: id })}
14730
15401
  `, {
14731
15402
  mode: 384
@@ -14803,7 +15474,7 @@ function firstRunNoticeShown() {
14803
15474
  }
14804
15475
  function markFirstRunNotice() {
14805
15476
  try {
14806
- mkdirSync5(join17(homedir5(), ".launchpad"), { recursive: true });
15477
+ mkdirSync5(join18(homedir5(), ".launchpad"), { recursive: true });
14807
15478
  writeFileSync9(FIRST_RUN_MARKER, `${Date.now()}
14808
15479
  `, { mode: 384 });
14809
15480
  } catch {}
@@ -14871,14 +15542,14 @@ import { platform } from "node:os";
14871
15542
  // src/report/breadcrumb.ts
14872
15543
  import { writeFileSync as writeFileSync10, readFileSync as readFileSync23, mkdirSync as mkdirSync6 } from "node:fs";
14873
15544
  import { homedir as homedir6 } from "node:os";
14874
- import { join as join18, dirname as dirname11 } from "node:path";
15545
+ import { join as join19, dirname as dirname12 } from "node:path";
14875
15546
  function breadcrumbPath() {
14876
- return join18(homedir6(), ".launchpad", "last-run.json");
15547
+ return join19(homedir6(), ".launchpad", "last-run.json");
14877
15548
  }
14878
15549
  function writeBreadcrumb(verb, exit) {
14879
15550
  try {
14880
15551
  const p = breadcrumbPath();
14881
- mkdirSync6(dirname11(p), { recursive: true });
15552
+ mkdirSync6(dirname12(p), { recursive: true });
14882
15553
  writeFileSync10(p, JSON.stringify({ verb, exit, ts: Date.now() }), "utf8");
14883
15554
  } catch {}
14884
15555
  }
@@ -15038,6 +15709,12 @@ function makeReportCommand(kind, deps = realDeps5()) {
15038
15709
  };
15039
15710
  }
15040
15711
 
15712
+ // src/http/preflight.ts
15713
+ async function preflightMinimumCliVersion(config, fetcher = fetch) {
15714
+ const detachedFetcher = fetcher;
15715
+ await apiRaw(config, { method: "POST", path: "/cli/preflight" }, detachedFetcher);
15716
+ }
15717
+
15041
15718
  // src/dispatcher.ts
15042
15719
  var COMMANDS = [
15043
15720
  loginCommand,
@@ -15077,7 +15754,7 @@ var COMMANDS = [
15077
15754
  refreshUpdateCacheCommand,
15078
15755
  emitTelemetryCommand
15079
15756
  ];
15080
- async function dispatch(argv, io, commands = COMMANDS) {
15757
+ async function dispatch(argv, io, commands = COMMANDS, deps = {}) {
15081
15758
  if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") {
15082
15759
  printHelp6(io, commands);
15083
15760
  return 0;
@@ -15095,12 +15772,55 @@ async function dispatch(argv, io, commands = COMMANDS) {
15095
15772
  io.err("Run `launchpad --help` for the list of available commands.");
15096
15773
  return 64;
15097
15774
  }
15775
+ const preflightExit = await runCliFloorPreflight(verb, rest, io, deps);
15776
+ if (preflightExit !== null)
15777
+ return preflightExit;
15098
15778
  const code = await cmd.run(rest, io);
15099
15779
  if (!cmd.hidden && verb !== "bug" && verb !== "feature") {
15100
15780
  writeBreadcrumb(verb, code);
15101
15781
  }
15102
15782
  return code;
15103
15783
  }
15784
+ async function runCliFloorPreflight(verb, args, io, deps) {
15785
+ if (!requiresCliFloorPreflight(verb, args))
15786
+ return null;
15787
+ try {
15788
+ const preflight = deps.preflight ?? preflightMinimumCliVersion;
15789
+ await preflight(loadConfig());
15790
+ return null;
15791
+ } catch (error) {
15792
+ if (error instanceof UpgradeRequiredError) {
15793
+ io.err(error.message);
15794
+ return EXIT_CLI_UPGRADE_REQUIRED;
15795
+ }
15796
+ io.err(`launchpad ${verb}: version preflight failed: ${error instanceof Error ? error.message : String(error)}`);
15797
+ return 1;
15798
+ }
15799
+ }
15800
+ function requiresCliFloorPreflight(verb, args) {
15801
+ if (args.includes("--help") || args.includes("-h"))
15802
+ return false;
15803
+ if ([
15804
+ "create",
15805
+ "deploy",
15806
+ "redeploy",
15807
+ "merge",
15808
+ "destroy",
15809
+ "recover",
15810
+ "rollback",
15811
+ "grant-editor",
15812
+ "revoke-editor",
15813
+ "add-owner",
15814
+ "remove-owner"
15815
+ ].includes(verb)) {
15816
+ return true;
15817
+ }
15818
+ if (verb === "envvars")
15819
+ return args[0] === "set" || args[0] === "rm";
15820
+ if (verb === "secrets")
15821
+ return args[0] === "push";
15822
+ return false;
15823
+ }
15104
15824
  function printHelp6(io, commands) {
15105
15825
  io.out("launchpad — manage Launchpad-deployed apps from the command line.");
15106
15826
  io.out("");