@m-kopa/launchpad-cli 0.27.1 → 0.27.3

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.27.1";
22
+ var CLI_VERSION = "0.27.3";
23
23
 
24
24
  // src/config.ts
25
25
  import * as os from "node:os";
@@ -994,8 +994,8 @@ function describe8(e) {
994
994
  }
995
995
 
996
996
  // src/commands/clone.ts
997
- import * as path4 from "node:path";
998
- import * as fs3 from "node:fs/promises";
997
+ import * as path5 from "node:path";
998
+ import * as fs4 from "node:fs/promises";
999
999
 
1000
1000
  // src/clone/tar-extract.ts
1001
1001
  import * as fs2 from "node:fs/promises";
@@ -1208,6 +1208,57 @@ function runGit(sp, cwd, args) {
1208
1208
  });
1209
1209
  }
1210
1210
 
1211
+ // src/clone/base-sha-marker.ts
1212
+ import * as path4 from "node:path";
1213
+ import * as fs3 from "node:fs/promises";
1214
+ var BASE_SHA_HEADER = "x-launchpad-base-sha";
1215
+ var MARKER_DIR = ".launchpad";
1216
+ var MARKER_RELPATH = `${MARKER_DIR}/base-sha`;
1217
+ var EXCLUDE_RELPATH = path4.join(".git", "info", "exclude");
1218
+ var EXCLUDE_LINE = `${MARKER_DIR}/`;
1219
+ var SHA_RE = /^[0-9a-f]{40}$/;
1220
+ function isValidBaseSha(value) {
1221
+ return SHA_RE.test(value);
1222
+ }
1223
+ async function writeBaseShaMarker(cwd, sha) {
1224
+ if (!isValidBaseSha(sha)) {
1225
+ throw new Error(`refusing to stamp a non-SHA base marker: "${sha}"`);
1226
+ }
1227
+ await ensureExcluded(cwd);
1228
+ const dir = path4.resolve(cwd, MARKER_DIR);
1229
+ await fs3.mkdir(dir, { recursive: true });
1230
+ await fs3.writeFile(path4.resolve(cwd, MARKER_RELPATH), `${sha}
1231
+ `, "utf8");
1232
+ }
1233
+ async function readBaseShaMarker(cwd) {
1234
+ let raw;
1235
+ try {
1236
+ raw = await fs3.readFile(path4.resolve(cwd, MARKER_RELPATH), "utf8");
1237
+ } catch {
1238
+ return null;
1239
+ }
1240
+ const sha = raw.trim();
1241
+ return isValidBaseSha(sha) ? sha : null;
1242
+ }
1243
+ async function ensureExcluded(cwd) {
1244
+ const excludePath = path4.resolve(cwd, EXCLUDE_RELPATH);
1245
+ let current = "";
1246
+ try {
1247
+ current = await fs3.readFile(excludePath, "utf8");
1248
+ } catch {
1249
+ await fs3.mkdir(path4.dirname(excludePath), { recursive: true });
1250
+ }
1251
+ const already = current.split(`
1252
+ `).some((line) => line.trim() === EXCLUDE_LINE);
1253
+ if (already)
1254
+ return;
1255
+ const sep = current.length > 0 && !current.endsWith(`
1256
+ `) ? `
1257
+ ` : "";
1258
+ await fs3.writeFile(excludePath, `${current}${sep}${EXCLUDE_LINE}
1259
+ `, "utf8");
1260
+ }
1261
+
1211
1262
  // src/commands/clone.ts
1212
1263
  var cloneCommand = {
1213
1264
  name: "clone",
@@ -1230,9 +1281,9 @@ async function runClone(args, io) {
1230
1281
  let destPreExisted = false;
1231
1282
  try {
1232
1283
  cfg = loadConfig();
1233
- destDir = path4.resolve(process.cwd(), `launchpad-app-${slug}`);
1284
+ destDir = path5.resolve(process.cwd(), `launchpad-app-${slug}`);
1234
1285
  try {
1235
- await fs3.access(destDir);
1286
+ await fs4.access(destDir);
1236
1287
  destPreExisted = true;
1237
1288
  } catch {
1238
1289
  destPreExisted = false;
@@ -1243,13 +1294,28 @@ async function runClone(args, io) {
1243
1294
  io.err(`launchpad clone: bot returned no body for /apps/${slug}/source-bundle`);
1244
1295
  return 1;
1245
1296
  }
1297
+ const headerSha = res.headers.get(BASE_SHA_HEADER);
1298
+ const baseSha = headerSha !== null && isValidBaseSha(headerSha) ? headerSha : null;
1246
1299
  const stats = await extractToDir(res.body, destDir, { stripComponents: 1 });
1247
1300
  await initGitRepo({
1248
1301
  cwd: destDir,
1249
1302
  initialCommitMessage: `Initial baseline from launchpad clone ${slug}`
1250
1303
  });
1304
+ let stamped = false;
1305
+ if (baseSha !== null) {
1306
+ try {
1307
+ await writeBaseShaMarker(destDir, baseSha);
1308
+ stamped = true;
1309
+ } catch (e) {
1310
+ io.err(`launchpad clone: warning — could not record base revision: ${describe9(e)}`);
1311
+ io.err("(the clone is usable, but deploy cannot detect divergence)");
1312
+ }
1313
+ }
1251
1314
  io.out("");
1252
1315
  io.out(`Cloned ${stats.fileCount} files (${formatBytes(stats.byteCount)}) into ${destDir}`);
1316
+ if (stamped && baseSha !== null) {
1317
+ io.out(`Base revision ${baseSha.slice(0, 12)} recorded — \`launchpad deploy\` will warn if main moves past it.`);
1318
+ }
1253
1319
  io.out(`No remote configured — use \`launchpad deploy\` to ship changes back.`);
1254
1320
  return 0;
1255
1321
  } catch (e) {
@@ -1291,8 +1357,8 @@ async function runClone(args, io) {
1291
1357
  }
1292
1358
  }
1293
1359
  async function cleanupBestEffort(slug) {
1294
- const dir = path4.resolve(process.cwd(), `launchpad-app-${slug}`);
1295
- await fs3.rm(dir, { recursive: true, force: true }).catch(() => {
1360
+ const dir = path5.resolve(process.cwd(), `launchpad-app-${slug}`);
1361
+ await fs4.rm(dir, { recursive: true, force: true }).catch(() => {
1296
1362
  return;
1297
1363
  });
1298
1364
  }
@@ -1460,15 +1526,15 @@ function describe10(e) {
1460
1526
 
1461
1527
  // src/commands/deploy.ts
1462
1528
  import { existsSync as existsSync5 } from "node:fs";
1463
- import * as path7 from "node:path";
1529
+ import * as path8 from "node:path";
1464
1530
 
1465
1531
  // src/bundle/orchestrate.ts
1466
1532
  import { readFileSync as readFileSync4 } from "node:fs";
1467
- import { join as join6 } from "node:path";
1533
+ import { join as join7 } from "node:path";
1468
1534
 
1469
1535
  // src/deploy/tar-pack.ts
1470
- import * as fs4 from "node:fs/promises";
1471
- import * as path5 from "node:path";
1536
+ import * as fs5 from "node:fs/promises";
1537
+ import * as path6 from "node:path";
1472
1538
  var MAX_COMPRESSED_TARBALL_BYTES = 50 * 1024 * 1024;
1473
1539
  var MAX_FILE_BYTES = 5 * 1024 * 1024;
1474
1540
 
@@ -1483,10 +1549,10 @@ async function packTarGz(cwd, files) {
1483
1549
  for (const rel of files) {
1484
1550
  if (rel.length === 0)
1485
1551
  continue;
1486
- const abs = path5.join(cwd, rel);
1552
+ const abs = path6.join(cwd, rel);
1487
1553
  let stat;
1488
1554
  try {
1489
- stat = await fs4.lstat(abs);
1555
+ stat = await fs5.lstat(abs);
1490
1556
  } catch (e) {
1491
1557
  if (isErrno3(e) && e.code === "ENOENT")
1492
1558
  continue;
@@ -1499,7 +1565,7 @@ async function packTarGz(cwd, files) {
1499
1565
  if (stat.size > MAX_FILE_BYTES) {
1500
1566
  throw new TarPackError(`file exceeds ${MAX_FILE_BYTES} bytes: ${rel} (${stat.size} bytes)`);
1501
1567
  }
1502
- const bytes = await fs4.readFile(abs);
1568
+ const bytes = await fs5.readFile(abs);
1503
1569
  const mode = (stat.mode & 64) !== 0 ? 493 : 420;
1504
1570
  blocks.push(buildHeader({ path: rel, size: bytes.length, mode }, enc));
1505
1571
  blocks.push(bytes);
@@ -1618,7 +1684,7 @@ function isErrno3(e) {
1618
1684
  }
1619
1685
 
1620
1686
  // src/bundle/upload.ts
1621
- async function uploadBundle(cfg, slug, manifestYaml, bundleTarGz, workerArtifact) {
1687
+ async function uploadBundle(cfg, slug, manifestYaml, bundleTarGz, workerArtifact, baseSha, overrideStale) {
1622
1688
  const formData = new FormData;
1623
1689
  formData.append("manifest", manifestYaml);
1624
1690
  const buf = new ArrayBuffer(bundleTarGz.byteLength);
@@ -1642,6 +1708,10 @@ async function uploadBundle(cfg, slug, manifestYaml, bundleTarGz, workerArtifact
1642
1708
  authorization: `Bearer ${accessToken}`,
1643
1709
  accept: "application/json"
1644
1710
  };
1711
+ if (baseSha)
1712
+ headers["x-launchpad-base-sha"] = baseSha;
1713
+ if (overrideStale)
1714
+ headers["x-launchpad-stale-override"] = overrideStale;
1645
1715
  let res;
1646
1716
  try {
1647
1717
  res = await fetch(url, { method: "POST", headers, body: formData });
@@ -1684,7 +1754,7 @@ async function uploadBundle(cfg, slug, manifestYaml, bundleTarGz, workerArtifact
1684
1754
 
1685
1755
  // src/bundle/cwd-walker.ts
1686
1756
  import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
1687
- import { join as join3, relative as relative2, sep } from "node:path";
1757
+ import { join as join4, relative as relative2, sep } from "node:path";
1688
1758
  var DEFAULT_IGNORE = [
1689
1759
  ".git",
1690
1760
  "node_modules",
@@ -1733,7 +1803,7 @@ function walkCwd(cwd, options = {}) {
1733
1803
  return { files, skipped };
1734
1804
  }
1735
1805
  function walkInto(cwd, relDir, ignore, out, skipped, maxFiles) {
1736
- const absDir = relDir === "" ? cwd : join3(cwd, relDir);
1806
+ const absDir = relDir === "" ? cwd : join4(cwd, relDir);
1737
1807
  let entries;
1738
1808
  try {
1739
1809
  entries = readdirSync(absDir);
@@ -1746,7 +1816,7 @@ function walkInto(cwd, relDir, ignore, out, skipped, maxFiles) {
1746
1816
  }
1747
1817
  for (const name of entries) {
1748
1818
  const relPath = relDir === "" ? name : `${relDir}/${name}`;
1749
- const absPath = join3(absDir, name);
1819
+ const absPath = join4(absDir, name);
1750
1820
  if (DEFAULT_IGNORE_SET.has(name)) {
1751
1821
  skipped.push({ path: relPath, reason: "default-ignore" });
1752
1822
  continue;
@@ -1782,7 +1852,7 @@ function walkInto(cwd, relDir, ignore, out, skipped, maxFiles) {
1782
1852
  }
1783
1853
  }
1784
1854
  function loadIgnore(cwd) {
1785
- const file = join3(cwd, ".gitignore");
1855
+ const file = join4(cwd, ".gitignore");
1786
1856
  if (!existsSync(file)) {
1787
1857
  return { matches: () => false };
1788
1858
  }
@@ -1863,7 +1933,7 @@ function globToRegExp(glob) {
1863
1933
 
1864
1934
  // src/bundle/cron-bundle.ts
1865
1935
  import { readFileSync as readFileSync2 } from "node:fs";
1866
- import { dirname as dirname3, join as join4, resolve as resolve4 } from "node:path";
1936
+ import { dirname as dirname4, join as join5, resolve as resolve5 } from "node:path";
1867
1937
  import { parse as parseYaml } from "yaml";
1868
1938
  import { build as esbuild } from "esbuild";
1869
1939
  function parseWranglerTopLevel(tomlText) {
@@ -1939,7 +2009,7 @@ function locateWorkerConfig(cwd, walkFiles, script) {
1939
2009
  for (const rel of walkFiles) {
1940
2010
  if (!rel.endsWith("wrangler.toml"))
1941
2011
  continue;
1942
- const abs = resolve4(cwd, rel);
2012
+ const abs = resolve5(cwd, rel);
1943
2013
  let text;
1944
2014
  try {
1945
2015
  text = readFileSync2(abs, "utf8");
@@ -1951,8 +2021,8 @@ function locateWorkerConfig(cwd, walkFiles, script) {
1951
2021
  continue;
1952
2022
  if (cfg.main === undefined || cfg.main === "")
1953
2023
  continue;
1954
- const workerDir = dirname3(abs);
1955
- return { entryAbs: join4(workerDir, cfg.main), workerDir, cfg };
2024
+ const workerDir = dirname4(abs);
2025
+ return { entryAbs: join5(workerDir, cfg.main), workerDir, cfg };
1956
2026
  }
1957
2027
  return null;
1958
2028
  }
@@ -2022,7 +2092,7 @@ async function buildWorkerArtifact(cwd, manifestYaml, walkFiles) {
2022
2092
 
2023
2093
  // src/bundle/boundary.ts
2024
2094
  import { existsSync as existsSync2, lstatSync as lstatSync2, readFileSync as readFileSync3 } from "node:fs";
2025
- import { join as join5 } from "node:path";
2095
+ import { join as join6 } from "node:path";
2026
2096
  import { parse as parseYaml2 } from "yaml";
2027
2097
 
2028
2098
  // ../launchpad-engine/dist/schema.js
@@ -2484,43 +2554,43 @@ var PLATFORM_SEEDED_GITHUB_PATHS = new Set([
2484
2554
  ".github/scripts/parse-gitleaks.mjs",
2485
2555
  ".github/scripts/parse-bun-audit.mjs"
2486
2556
  ]);
2487
- function checkDenyList(path6) {
2488
- const segments = path6.split("/");
2557
+ function checkDenyList(path7) {
2558
+ const segments = path7.split("/");
2489
2559
  const basename = segments[segments.length - 1];
2490
2560
  const isExample = basename.endsWith(".example");
2491
2561
  if (basename.startsWith(".env") && !isExample) {
2492
- return { path: path6, rule: "env-file", message: `'${path6}' is an environment file (.env*) — never shippable` };
2562
+ return { path: path7, rule: "env-file", message: `'${path7}' is an environment file (.env*) — never shippable` };
2493
2563
  }
2494
2564
  if (basename === ".npmrc") {
2495
- return { path: path6, rule: "npmrc", message: `'${path6}' (.npmrc) may carry registry auth — never shippable` };
2565
+ return { path: path7, rule: "npmrc", message: `'${path7}' (.npmrc) may carry registry auth — never shippable` };
2496
2566
  }
2497
2567
  if (basename.startsWith(".dev.vars") && !isExample) {
2498
- return { path: path6, rule: "dev-vars", message: `'${path6}' is a wrangler dev-vars file — never shippable` };
2568
+ return { path: path7, rule: "dev-vars", message: `'${path7}' is a wrangler dev-vars file — never shippable` };
2499
2569
  }
2500
2570
  if (segments.includes(".claude")) {
2501
- return { path: path6, rule: "claude-dir", message: `'${path6}' is under a .claude/ directory (AI-workspace state) — never shippable` };
2571
+ return { path: path7, rule: "claude-dir", message: `'${path7}' is under a .claude/ directory (AI-workspace state) — never shippable` };
2502
2572
  }
2503
2573
  if (segments.includes(".git")) {
2504
- return { path: path6, rule: "git-dir", message: `'${path6}' is under .git/ — never shippable` };
2574
+ return { path: path7, rule: "git-dir", message: `'${path7}' is under .git/ — never shippable` };
2505
2575
  }
2506
2576
  if (segments[0] === ".github") {
2507
- if (PLATFORM_SEEDED_GITHUB_PATHS.has(path6))
2577
+ if (PLATFORM_SEEDED_GITHUB_PATHS.has(path7))
2508
2578
  return "seeded";
2509
2579
  return {
2510
- path: path6,
2580
+ path: path7,
2511
2581
  rule: "github-dir",
2512
- message: `'${path6}' — repo-root .github/ is platform-owned (workflows execute under the M-KOPA Actions identity); developer-supplied .github/** is not shippable`
2582
+ message: `'${path7}' — repo-root .github/ is platform-owned (workflows execute under the M-KOPA Actions identity); developer-supplied .github/** is not shippable`
2513
2583
  };
2514
2584
  }
2515
2585
  return null;
2516
2586
  }
2517
- function checkSecretShape(path6, readFileContent) {
2518
- const segments = path6.split("/");
2587
+ function checkSecretShape(path7, readFileContent) {
2588
+ const segments = path7.split("/");
2519
2589
  const basename = segments[segments.length - 1];
2520
2590
  const deny = (what) => ({
2521
- path: path6,
2591
+ path: path7,
2522
2592
  rule: "secret-shape",
2523
- message: `'${path6}' looks like ${what} — never shippable; if this is a false positive, rename the file`
2593
+ message: `'${path7}' looks like ${what} — never shippable; if this is a false positive, rename the file`
2524
2594
  });
2525
2595
  if (basename.endsWith(".pem"))
2526
2596
  return deny("PEM key material (*.pem)");
@@ -2536,7 +2606,7 @@ function checkSecretShape(path6, readFileContent) {
2536
2606
  if (segments.includes(".aws"))
2537
2607
  return deny("AWS credentials (.aws/**)");
2538
2608
  if (basename.endsWith(".json") && readFileContent !== undefined) {
2539
- const content = readFileContent(path6);
2609
+ const content = readFileContent(path7);
2540
2610
  if (content !== undefined && /"private_key"\s*:/.test(content)) {
2541
2611
  return deny('service-account-shaped JSON (carries a "private_key" field)');
2542
2612
  }
@@ -2550,41 +2620,41 @@ function applyAppBoundary(contract, files, options = {}) {
2550
2620
  const excluded = [];
2551
2621
  const denied = [];
2552
2622
  const rootPrefix = contract.root === "." ? "" : `${contract.root}/`;
2553
- for (const path6 of [...files].sort()) {
2554
- if (path6 !== ALWAYS_SHIP) {
2555
- if (rootPrefix !== "" && !path6.startsWith(rootPrefix)) {
2556
- excluded.push({ path: path6, reason: "outside-root" });
2623
+ for (const path7 of [...files].sort()) {
2624
+ if (path7 !== ALWAYS_SHIP) {
2625
+ if (rootPrefix !== "" && !path7.startsWith(rootPrefix)) {
2626
+ excluded.push({ path: path7, reason: "outside-root" });
2557
2627
  continue;
2558
2628
  }
2559
- const rel = rootPrefix === "" ? path6 : path6.slice(rootPrefix.length);
2629
+ const rel = rootPrefix === "" ? path7 : path7.slice(rootPrefix.length);
2560
2630
  if (!contract.include.some((p) => matchContractPattern(p, rel))) {
2561
- excluded.push({ path: path6, reason: "not-included" });
2631
+ excluded.push({ path: path7, reason: "not-included" });
2562
2632
  continue;
2563
2633
  }
2564
2634
  if (contract.exclude.some((p) => matchContractPattern(p, rel))) {
2565
- excluded.push({ path: path6, reason: "exclude" });
2635
+ excluded.push({ path: path7, reason: "exclude" });
2566
2636
  continue;
2567
2637
  }
2568
- if (ignorePatterns.some((p) => matchIgnorePattern(p, path6))) {
2569
- excluded.push({ path: path6, reason: "launchpadignore" });
2638
+ if (ignorePatterns.some((p) => matchIgnorePattern(p, path7))) {
2639
+ excluded.push({ path: path7, reason: "launchpadignore" });
2570
2640
  continue;
2571
2641
  }
2572
2642
  }
2573
- const denial = checkDenyList(path6);
2643
+ const denial = checkDenyList(path7);
2574
2644
  if (denial === "seeded") {
2575
- excluded.push({ path: path6, reason: "platform-seeded" });
2645
+ excluded.push({ path: path7, reason: "platform-seeded" });
2576
2646
  continue;
2577
2647
  }
2578
2648
  if (denial !== null) {
2579
2649
  denied.push(denial);
2580
2650
  continue;
2581
2651
  }
2582
- const secret = checkSecretShape(path6, options.readFileContent);
2652
+ const secret = checkSecretShape(path7, options.readFileContent);
2583
2653
  if (secret !== null) {
2584
2654
  denied.push(secret);
2585
2655
  continue;
2586
2656
  }
2587
- included.push(path6);
2657
+ included.push(path7);
2588
2658
  }
2589
2659
  return { files: included, excluded, denied };
2590
2660
  }
@@ -2612,7 +2682,7 @@ function verifyBuildInputs(build, files) {
2612
2682
  return issues;
2613
2683
  }
2614
2684
  let cwd = rootDir === "." ? "" : rootDir;
2615
- const resolve5 = (p) => {
2685
+ const resolve6 = (p) => {
2616
2686
  const cleaned = p.startsWith("./") ? p.slice(2) : p;
2617
2687
  const trimmed = cleaned.replace(/\/+$/, "");
2618
2688
  return cwd === "" ? trimmed : `${cwd}/${trimmed}`;
@@ -2632,7 +2702,7 @@ function verifyBuildInputs(build, files) {
2632
2702
  cwd = null;
2633
2703
  continue;
2634
2704
  }
2635
- const resolved = resolve5(target);
2705
+ const resolved = resolve6(target);
2636
2706
  if (!dirSet.has(resolved)) {
2637
2707
  issues.push({
2638
2708
  input: target,
@@ -2659,7 +2729,7 @@ function verifyBuildInputs(build, files) {
2659
2729
  for (const src of sources) {
2660
2730
  if (!PLAIN_PATH.test(src))
2661
2731
  continue;
2662
- const resolved = resolve5(src);
2732
+ const resolved = resolve6(src);
2663
2733
  if (!exists(resolved)) {
2664
2734
  issues.push({
2665
2735
  input: src,
@@ -3419,7 +3489,7 @@ function applyBoundaryToFiles(args) {
3419
3489
  }
3420
3490
  const contract = resolveAppBoundary({ appType: input.appType, app: input.app });
3421
3491
  let launchpadIgnore = [];
3422
- const ignorePath = join5(cwd, ".launchpadignore");
3492
+ const ignorePath = join6(cwd, ".launchpadignore");
3423
3493
  if (existsSync2(ignorePath)) {
3424
3494
  try {
3425
3495
  launchpadIgnore = parseLaunchpadIgnore(readFileSync3(ignorePath, "utf8"));
@@ -3427,9 +3497,9 @@ function applyBoundaryToFiles(args) {
3427
3497
  }
3428
3498
  const result = applyAppBoundary(contract, files, {
3429
3499
  launchpadIgnore,
3430
- readFileContent: (path6) => {
3500
+ readFileContent: (path7) => {
3431
3501
  try {
3432
- const abs = join5(cwd, path6);
3502
+ const abs = join6(cwd, path7);
3433
3503
  const stat = lstatSync2(abs);
3434
3504
  if (!stat.isFile() || stat.size > MAX_CONTENT_PROBE_BYTES)
3435
3505
  return;
@@ -3481,8 +3551,8 @@ ${lines}`
3481
3551
 
3482
3552
  // src/bundle/orchestrate.ts
3483
3553
  async function bundleAndDeploy(args) {
3484
- const { cfg, cwd, slug } = args;
3485
- const manifestPath = join6(cwd, "launchpad.yaml");
3554
+ const { cfg, cwd, slug, overrideStale } = args;
3555
+ const manifestPath = join7(cwd, "launchpad.yaml");
3486
3556
  let manifestYaml;
3487
3557
  try {
3488
3558
  manifestYaml = readFileSync4(manifestPath, "utf8");
@@ -3526,7 +3596,8 @@ async function bundleAndDeploy(args) {
3526
3596
  return { kind: "worker-build-error", message: workerBuild.message };
3527
3597
  }
3528
3598
  const workerArtifact = workerBuild.kind === "ok" ? workerBuild.artifact : undefined;
3529
- const uploadResult = await uploadBundle(cfg, slug, manifestYaml, packResult.bytes, workerArtifact);
3599
+ const baseSha = await readBaseShaMarker(cwd);
3600
+ const uploadResult = await uploadBundle(cfg, slug, manifestYaml, packResult.bytes, workerArtifact, baseSha, overrideStale);
3530
3601
  if (uploadResult.kind !== "ok") {
3531
3602
  return {
3532
3603
  kind: "upload-error",
@@ -3579,7 +3650,7 @@ async function listDeployFiles(opts) {
3579
3650
  return [...paths].sort();
3580
3651
  }
3581
3652
  function runGit2(sp, cwd, args) {
3582
- return new Promise((resolve5, reject) => {
3653
+ return new Promise((resolve6, reject) => {
3583
3654
  const spawnOpts = { cwd, stdio: ["ignore", "pipe", "pipe"] };
3584
3655
  const child = sp("git", [...args], spawnOpts);
3585
3656
  let stdout = "";
@@ -3595,7 +3666,7 @@ function runGit2(sp, cwd, args) {
3595
3666
  });
3596
3667
  child.once("close", (code) => {
3597
3668
  if (code === 0) {
3598
- resolve5(stdout);
3669
+ resolve6(stdout);
3599
3670
  return;
3600
3671
  }
3601
3672
  reject(new GitFilesError(`\`git ${args.join(" ")}\` exited ${code}: ${stderr.trim()}`));
@@ -3625,6 +3696,9 @@ function parseDeployFlags(args) {
3625
3696
  let timeoutMinutes = null;
3626
3697
  let atSha = null;
3627
3698
  let modeFlagsSeen = 0;
3699
+ let allowStale = false;
3700
+ let rebaseOntoHead = false;
3701
+ let overrideStale = null;
3628
3702
  let i = 0;
3629
3703
  while (i < args.length) {
3630
3704
  const a = args[i] ?? "";
@@ -3752,6 +3826,27 @@ function parseDeployFlags(args) {
3752
3826
  i += 2;
3753
3827
  continue;
3754
3828
  }
3829
+ if (a === "--allow-stale") {
3830
+ allowStale = true;
3831
+ i += 1;
3832
+ continue;
3833
+ }
3834
+ if (a === "--rebase-onto-head") {
3835
+ rebaseOntoHead = true;
3836
+ i += 1;
3837
+ continue;
3838
+ }
3839
+ if (a === "--override-stale") {
3840
+ const v = args[i + 1];
3841
+ if (v === undefined)
3842
+ return `missing value for ${a}`;
3843
+ if (v.length < 7) {
3844
+ return `invalid --override-stale "${v}" — echo at least the first 7 chars of the head SHA from the block message`;
3845
+ }
3846
+ overrideStale = v;
3847
+ i += 2;
3848
+ continue;
3849
+ }
3755
3850
  if (a === "--timeout-seconds") {
3756
3851
  const v = args[i + 1];
3757
3852
  if (v === undefined)
@@ -3804,6 +3899,12 @@ function parseDeployFlags(args) {
3804
3899
  if (modeFlagsSeen > 1) {
3805
3900
  return "--new, --resume, --abandon, --dry-run, and --apply are mutually exclusive";
3806
3901
  }
3902
+ if (mode !== "content" && (allowStale || rebaseOntoHead || overrideStale !== null)) {
3903
+ return "--allow-stale / --rebase-onto-head / --override-stale are only valid for a content deploy";
3904
+ }
3905
+ if (rebaseOntoHead && overrideStale !== null) {
3906
+ return "--rebase-onto-head and --override-stale are mutually exclusive — re-stamp to head, or override in place, not both";
3907
+ }
3807
3908
  if (mode === "dry-run") {
3808
3909
  if (slug !== null)
3809
3910
  return "--dry-run does not accept --slug (slug is read from launchpad.yaml)";
@@ -3892,7 +3993,7 @@ function parseDeployFlags(args) {
3892
3993
  return `invalid slug "${slug}" — expected ${SLUG_RE3.source}`;
3893
3994
  }
3894
3995
  return {
3895
- mode: { kind: "content", slug },
3996
+ mode: { kind: "content", slug, allowStale, rebaseOntoHead, overrideStale },
3896
3997
  message,
3897
3998
  timeoutSeconds
3898
3999
  };
@@ -3945,31 +4046,31 @@ function parseDeployFlags(args) {
3945
4046
 
3946
4047
  // src/deploy/apply.ts
3947
4048
  import { existsSync as existsSync3, rmSync } from "node:fs";
3948
- import { resolve as resolvePath, join as join7 } from "node:path";
4049
+ import { resolve as resolvePath, join as join8 } from "node:path";
3949
4050
 
3950
4051
  // src/manifest/load.ts
3951
4052
  import { readFileSync as readFileSync5 } from "node:fs";
3952
4053
  import { parse as parseYaml3, YAMLParseError } from "yaml";
3953
- function loadManifest(path6) {
4054
+ function loadManifest(path7) {
3954
4055
  let raw;
3955
4056
  try {
3956
- raw = readFileSync5(path6, "utf8");
4057
+ raw = readFileSync5(path7, "utf8");
3957
4058
  } catch (err) {
3958
4059
  const e = err;
3959
4060
  if (e.code === "ENOENT") {
3960
- return { kind: "file-not-found", path: path6 };
4061
+ return { kind: "file-not-found", path: path7 };
3961
4062
  }
3962
- return { kind: "read-error", path: path6, message: e.message ?? String(err) };
4063
+ return { kind: "read-error", path: path7, message: e.message ?? String(err) };
3963
4064
  }
3964
- return parseManifest2(raw, path6);
4065
+ return parseManifest2(raw, path7);
3965
4066
  }
3966
- function parseManifest2(yamlText, path6) {
4067
+ function parseManifest2(yamlText, path7) {
3967
4068
  let parsed;
3968
4069
  try {
3969
4070
  parsed = parseYaml3(yamlText);
3970
4071
  } catch (err) {
3971
4072
  const message = err instanceof YAMLParseError ? err.message : err.message ?? String(err);
3972
- return { kind: "yaml-parse-error", path: path6, message };
4073
+ return { kind: "yaml-parse-error", path: path7, message };
3973
4074
  }
3974
4075
  const result = ManifestSchema.safeParse(parsed);
3975
4076
  if (result.success) {
@@ -3977,7 +4078,7 @@ function parseManifest2(yamlText, path6) {
3977
4078
  }
3978
4079
  return {
3979
4080
  kind: "schema-error",
3980
- path: path6,
4081
+ path: path7,
3981
4082
  issues: formatIssues(result.error)
3982
4083
  };
3983
4084
  }
@@ -4175,7 +4276,7 @@ function sleep(ms) {
4175
4276
  return new Promise((res) => setTimeout(res, ms));
4176
4277
  }
4177
4278
  function deletePinIfPresent(cfg, slug, io) {
4178
- const pinPath = join7(cfg.stateDir, slug, "group.json");
4279
+ const pinPath = join8(cfg.stateDir, slug, "group.json");
4179
4280
  if (!existsSync3(pinPath))
4180
4281
  return;
4181
4282
  try {
@@ -4257,12 +4358,12 @@ function renderManifestError(loaded, io) {
4257
4358
 
4258
4359
  // src/deploy/dry-run.ts
4259
4360
  import { execFileSync } from "node:child_process";
4260
- import { resolve as resolve5 } from "node:path";
4361
+ import { resolve as resolve6 } from "node:path";
4261
4362
  var MANIFEST_SHA_REGEX2 = /^[0-9a-f]{40}$/;
4262
4363
  async function runDeployDryRun(opts, io, deps = {}) {
4263
4364
  const cfg = deps.config ?? loadConfig();
4264
4365
  const fetcher = deps.fetcher ?? fetch;
4265
- const manifestPath = resolve5(process.cwd(), opts.file ?? "launchpad.yaml");
4366
+ const manifestPath = resolve6(process.cwd(), opts.file ?? "launchpad.yaml");
4266
4367
  const loaded = loadManifest(manifestPath);
4267
4368
  if (loaded.kind !== "ok") {
4268
4369
  return renderManifestError2(loaded, opts.json, io);
@@ -4608,12 +4709,12 @@ function handleNetworkError(e, io, slug, verb) {
4608
4709
  }
4609
4710
 
4610
4711
  // src/commands/infer-slug.ts
4611
- import * as path6 from "node:path";
4712
+ import * as path7 from "node:path";
4612
4713
  import { existsSync as existsSync4, readFileSync as readFileSync6 } from "node:fs";
4613
4714
  import { parse as parseYaml4 } from "yaml";
4614
4715
  var DIRNAME_RE = /^launchpad-app-([a-z0-9][a-z0-9-]*[a-z0-9])$/;
4615
4716
  function inferSlugFromCwd(cwd) {
4616
- const base = path6.basename(cwd);
4717
+ const base = path7.basename(cwd);
4617
4718
  const m = base.match(DIRNAME_RE);
4618
4719
  return m === null ? null : m[1];
4619
4720
  }
@@ -4638,11 +4739,11 @@ function inferSlugFromManifestFile(manifestPath) {
4638
4739
  }
4639
4740
  }
4640
4741
  function inferSlug(opts) {
4641
- const manifestPath = path6.resolve(opts.cwd, opts.file ?? "launchpad.yaml");
4742
+ const manifestPath = path7.resolve(opts.cwd, opts.file ?? "launchpad.yaml");
4642
4743
  const fromManifest = inferSlugFromManifestFile(manifestPath);
4643
4744
  const fromDir = inferSlugFromCwd(opts.cwd);
4644
4745
  if (fromManifest !== null && fromDir !== null && fromManifest !== fromDir) {
4645
- opts.warn?.(`note: directory name suggests "${fromDir}" but ${path6.basename(manifestPath)} ` + `declares "${fromManifest}" — using the manifest. ` + `(Pass <slug> or --slug to override.)`);
4746
+ opts.warn?.(`note: directory name suggests "${fromDir}" but ${path7.basename(manifestPath)} ` + `declares "${fromManifest}" — using the manifest. ` + `(Pass <slug> or --slug to override.)`);
4646
4747
  }
4647
4748
  return fromManifest ?? fromDir;
4648
4749
  }
@@ -4698,9 +4799,18 @@ async function runDeploy(args, io) {
4698
4799
  }, io);
4699
4800
  }
4700
4801
  const cwd = process.cwd();
4701
- const manifestPath = path7.join(cwd, "launchpad.yaml");
4802
+ const manifestPath = path8.join(cwd, "launchpad.yaml");
4702
4803
  if (existsSync5(manifestPath)) {
4703
- return runModelADeploy({ cwd, manifestPath, argv: args, io });
4804
+ const contentMode = flags.mode.kind === "content" ? flags.mode : { allowStale: false, rebaseOntoHead: false, overrideStale: null };
4805
+ return runModelADeploy({
4806
+ cwd,
4807
+ manifestPath,
4808
+ argv: args,
4809
+ io,
4810
+ allowStale: contentMode.allowStale,
4811
+ rebaseOntoHead: contentMode.rebaseOntoHead,
4812
+ overrideStale: contentMode.overrideStale
4813
+ });
4704
4814
  }
4705
4815
  const parsed = parseArgs2(args);
4706
4816
  if (parsed === null) {
@@ -4713,7 +4823,7 @@ async function runDeploy(args, io) {
4713
4823
  } else {
4714
4824
  const inferred = inferSlug({ cwd: process.cwd(), warn: (l) => io.err(l) });
4715
4825
  if (inferred === null) {
4716
- io.err(`launchpad deploy: could not infer slug from cwd (${path7.basename(process.cwd())});
4826
+ io.err(`launchpad deploy: could not infer slug from cwd (${path8.basename(process.cwd())});
4717
4827
  ` + ` pass --slug <slug>, or cd into a directory named launchpad-app-<slug>.`);
4718
4828
  return 64;
4719
4829
  }
@@ -4733,7 +4843,7 @@ async function runDeploy(args, io) {
4733
4843
  return 1;
4734
4844
  }
4735
4845
  let contentManifestYaml = null;
4736
- const contentManifestPath = path7.join(process.cwd(), "launchpad.yaml");
4846
+ const contentManifestPath = path8.join(process.cwd(), "launchpad.yaml");
4737
4847
  if (existsSync5(contentManifestPath)) {
4738
4848
  try {
4739
4849
  contentManifestYaml = readFileSync7(contentManifestPath, "utf8");
@@ -4885,7 +4995,16 @@ function formatBytes2(n) {
4885
4995
  function describe13(e) {
4886
4996
  return e instanceof Error ? e.message : String(e);
4887
4997
  }
4888
- function surfaceDeployExtras(body, io, slug) {
4998
+ function surfaceDeployExtras(body, io, slug, allowStale = false) {
4999
+ const st = body.staleness;
5000
+ if (st !== undefined && !allowStale) {
5001
+ if (st.kind === "stale") {
5002
+ io.err(`warning: main has moved since you cloned (base ${st.base_sha.slice(0, 8)} → head ${(st.head_sha ?? "").slice(0, 8)}).`);
5003
+ io.err(` A teammate may have shipped changes you don't have. Review \`launchpad status ${slug}\` before relying on this deploy.`);
5004
+ } else {
5005
+ io.err(`warning: could not verify whether main has moved since you cloned (base ${st.base_sha.slice(0, 8)}); proceeding.`);
5006
+ }
5007
+ }
4889
5008
  if (body.boundary_stripped !== undefined && body.boundary_stripped.length > 0) {
4890
5009
  io.err(`warning: the bot stripped ${body.boundary_stripped.length} never-shippable file(s) server-side:`);
4891
5010
  for (const p of body.boundary_stripped.slice(0, 10)) {
@@ -4907,6 +5026,32 @@ function surfaceDeployExtras(body, io, slug) {
4907
5026
  io.out(` Full list: \`launchpad status ${slug}\`.`);
4908
5027
  }
4909
5028
  }
5029
+ function isStaleBlock(body) {
5030
+ const code = body?.error;
5031
+ return code === "stale_overlap" || code === "stale_unverifiable";
5032
+ }
5033
+ function surfaceStaleBlock(body, io) {
5034
+ const code = String(body.error);
5035
+ const head = typeof body.head_sha === "string" ? body.head_sha : null;
5036
+ if (code === "stale_overlap" && Array.isArray(body.paths)) {
5037
+ io.err(`launchpad deploy: BLOCKED — this deploy would roll back ${body.paths.length} file(s) a teammate changed on main since you cloned:`);
5038
+ for (const p of body.paths.slice(0, 20)) {
5039
+ io.err(` - ${String(p)}`);
5040
+ }
5041
+ if (body.paths.length > 20) {
5042
+ io.err(` … and ${body.paths.length - 20} more`);
5043
+ }
5044
+ } else {
5045
+ io.err(`launchpad deploy: BLOCKED — main moved since you cloned and the overlap cannot be proven` + (typeof body.reason === "string" ? ` (${body.reason})` : "") + `; treating as unsafe.`);
5046
+ }
5047
+ io.err("");
5048
+ io.err(" To recover (after reconciling the listed file(s) with main):");
5049
+ io.err(" launchpad deploy --rebase-onto-head");
5050
+ if (head !== null) {
5051
+ io.err(" Or, to deliberately overwrite them in place:");
5052
+ io.err(` launchpad deploy --override-stale ${head.slice(0, 8)}`);
5053
+ }
5054
+ }
4910
5055
  async function runModelADeploy(args) {
4911
5056
  const { cwd, manifestPath, io } = args;
4912
5057
  let slug;
@@ -4935,7 +5080,23 @@ async function runModelADeploy(args) {
4935
5080
  }
4936
5081
  let result;
4937
5082
  try {
4938
- result = await bundleAndDeploy({ cfg, cwd, slug });
5083
+ result = await bundleAndDeploy({
5084
+ cwd,
5085
+ cfg,
5086
+ slug,
5087
+ overrideStale: args.overrideStale
5088
+ });
5089
+ if (args.rebaseOntoHead && result.kind === "upload-error" && result.status === 409 && isStaleBlock(result.body)) {
5090
+ const head = result.body.head_sha;
5091
+ if (typeof head === "string" && isValidBaseSha(head)) {
5092
+ await writeBaseShaMarker(cwd, head);
5093
+ io.out(`Re-stamped base revision to current head ${head.slice(0, 12)}; retrying deploy …`);
5094
+ result = await bundleAndDeploy({ cwd, cfg, slug });
5095
+ } else {
5096
+ io.err("launchpad deploy: --rebase-onto-head could not read a head SHA from the block; re-clone the app instead.");
5097
+ return 1;
5098
+ }
5099
+ }
4939
5100
  } catch (e) {
4940
5101
  if (e instanceof UnauthenticatedError) {
4941
5102
  io.err(`launchpad deploy: ${e.message}`);
@@ -4971,6 +5132,10 @@ async function runModelADeploy(args) {
4971
5132
  }
4972
5133
  return 1;
4973
5134
  }
5135
+ if (result.status === 409 && isStaleBlock(body)) {
5136
+ surfaceStaleBlock(body, io);
5137
+ return 1;
5138
+ }
4974
5139
  io.err(`launchpad deploy: bot rejected the upload (HTTP ${result.status}, ${errorCode}).`);
4975
5140
  if (typeof body.message === "string") {
4976
5141
  io.err(` ${body.message}`);
@@ -5004,7 +5169,7 @@ async function runModelADeploy(args) {
5004
5169
  if (typeof success.head_sha === "string") {
5005
5170
  io.out(` (managed main @ ${success.head_sha.slice(0, 8)} matches the bundle)`);
5006
5171
  }
5007
- surfaceDeployExtras(success, io, slug);
5172
+ surfaceDeployExtras(success, io, slug, args.allowStale);
5008
5173
  return 0;
5009
5174
  }
5010
5175
  if (success.status === "provisioning_started") {
@@ -5041,7 +5206,7 @@ async function runModelADeploy(args) {
5041
5206
  if (typeof success.message === "string") {
5042
5207
  io.out(` ${success.message}`);
5043
5208
  }
5044
- surfaceDeployExtras(success, io, slug);
5209
+ surfaceDeployExtras(success, io, slug, args.allowStale);
5045
5210
  io.out("");
5046
5211
  io.out("Committed; build pending — Cloudflare Pages is building this deploy now.");
5047
5212
  io.out(`Run \`launchpad status ${slug}\` to confirm the build outcome (success / failure + log excerpt).`);
@@ -5051,7 +5216,7 @@ async function runModelADeploy(args) {
5051
5216
  }
5052
5217
 
5053
5218
  // src/commands/envvars.ts
5054
- import * as path8 from "node:path";
5219
+ import * as path9 from "node:path";
5055
5220
  var envvarsCommand = {
5056
5221
  name: "envvars",
5057
5222
  summary: "list / set / remove production env vars (slug-scoped)",
@@ -5071,7 +5236,7 @@ async function runEnvvars(args, io) {
5071
5236
  } else {
5072
5237
  const inferred = inferSlug({ cwd: process.cwd(), warn: (l) => io.err(l) });
5073
5238
  if (inferred === null) {
5074
- io.err(`launchpad envvars: could not infer slug from cwd (${path8.basename(process.cwd())});
5239
+ io.err(`launchpad envvars: could not infer slug from cwd (${path9.basename(process.cwd())});
5075
5240
  ` + ` pass --slug <slug>, or cd into a directory named launchpad-app-<slug>.`);
5076
5241
  return 64;
5077
5242
  }
@@ -5242,7 +5407,7 @@ function describe14(e) {
5242
5407
 
5243
5408
  // src/commands/generate.ts
5244
5409
  import { mkdirSync, readFileSync as readFileSync8, writeFileSync } from "node:fs";
5245
- import { dirname as dirname4, resolve as resolve7, relative as relative3 } from "node:path";
5410
+ import { dirname as dirname5, resolve as resolve8, relative as relative3 } from "node:path";
5246
5411
  var generateCommand = {
5247
5412
  name: "generate",
5248
5413
  summary: "emit derived artefacts (wrangler.toml, deploy.yml) from launchpad.yaml",
@@ -5255,14 +5420,14 @@ async function runGenerate(args, io) {
5255
5420
  io.err("Usage: launchpad generate [--file <path>] [--dry-run] [--force] [--json]");
5256
5421
  return 64;
5257
5422
  }
5258
- const manifestPath = resolve7(process.cwd(), flags.file ?? "launchpad.yaml");
5423
+ const manifestPath = resolve8(process.cwd(), flags.file ?? "launchpad.yaml");
5259
5424
  const result = loadManifest(manifestPath);
5260
5425
  if (result.kind !== "ok") {
5261
5426
  return flags.json ? renderManifestErrorJson(result, io) : renderManifestErrorHuman(result, io);
5262
5427
  }
5263
- const appRoot = dirname4(manifestPath);
5264
- const wranglerPath = resolve7(appRoot, "container", "wrangler.toml");
5265
- const workflowPath = resolve7(appRoot, ".github", "workflows", "deploy.yml");
5428
+ const appRoot = dirname5(manifestPath);
5429
+ const wranglerPath = resolve8(appRoot, "container", "wrangler.toml");
5430
+ const workflowPath = resolve8(appRoot, ".github", "workflows", "deploy.yml");
5266
5431
  const wranglerOut = generateWranglerToml(result.manifest);
5267
5432
  const workflowOut = generateGithubDeployWorkflow(result.manifest);
5268
5433
  if (flags.dryRun) {
@@ -5277,7 +5442,7 @@ async function runGenerate(args, io) {
5277
5442
  ];
5278
5443
  return flags.json ? renderApplyJson(reports, appRoot, io) : renderApplyHuman(reports, appRoot, io);
5279
5444
  }
5280
- function applyOne(artefact, path9, out, force) {
5445
+ function applyOne(artefact, path10, out, force) {
5281
5446
  if (out.kind === "not-applicable") {
5282
5447
  return {
5283
5448
  artefact,
@@ -5285,32 +5450,32 @@ function applyOne(artefact, path9, out, force) {
5285
5450
  warnings: []
5286
5451
  };
5287
5452
  }
5288
- const existing = readIfExists(path9);
5453
+ const existing = readIfExists(path10);
5289
5454
  if (existing.kind === "read-error") {
5290
5455
  return {
5291
5456
  artefact,
5292
- action: { kind: "write-error", path: path9, message: existing.message },
5457
+ action: { kind: "write-error", path: path10, message: existing.message },
5293
5458
  warnings: out.warnings
5294
5459
  };
5295
5460
  }
5296
5461
  if (existing.kind === "ok" && existing.content === out.content) {
5297
- return { artefact, action: { kind: "unchanged", path: path9 }, warnings: out.warnings };
5462
+ return { artefact, action: { kind: "unchanged", path: path10 }, warnings: out.warnings };
5298
5463
  }
5299
5464
  if (existing.kind === "ok" && existing.content !== out.content && !force) {
5300
5465
  return {
5301
5466
  artefact,
5302
- action: { kind: "would-overwrite", path: path9 },
5467
+ action: { kind: "would-overwrite", path: path10 },
5303
5468
  warnings: out.warnings
5304
5469
  };
5305
5470
  }
5306
5471
  try {
5307
- mkdirSync(dirname4(path9), { recursive: true });
5308
- writeFileSync(path9, out.content, "utf8");
5472
+ mkdirSync(dirname5(path10), { recursive: true });
5473
+ writeFileSync(path10, out.content, "utf8");
5309
5474
  return {
5310
5475
  artefact,
5311
5476
  action: {
5312
5477
  kind: "written",
5313
- path: path9,
5478
+ path: path10,
5314
5479
  bytes: Buffer.byteLength(out.content, "utf8")
5315
5480
  },
5316
5481
  warnings: out.warnings
@@ -5320,16 +5485,16 @@ function applyOne(artefact, path9, out, force) {
5320
5485
  artefact,
5321
5486
  action: {
5322
5487
  kind: "write-error",
5323
- path: path9,
5488
+ path: path10,
5324
5489
  message: err.message ?? String(err)
5325
5490
  },
5326
5491
  warnings: out.warnings
5327
5492
  };
5328
5493
  }
5329
5494
  }
5330
- function readIfExists(path9) {
5495
+ function readIfExists(path10) {
5331
5496
  try {
5332
- return { kind: "ok", content: readFileSync8(path9, "utf8") };
5497
+ return { kind: "ok", content: readFileSync8(path10, "utf8") };
5333
5498
  } catch (err) {
5334
5499
  const e = err;
5335
5500
  if (e.code === "ENOENT")
@@ -5541,12 +5706,12 @@ function parseFlags(args) {
5541
5706
 
5542
5707
  // src/groups/client.ts
5543
5708
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync9, writeFileSync as writeFileSync2 } from "node:fs";
5544
- import { dirname as dirname5, join as join9 } from "node:path";
5709
+ import { dirname as dirname6, join as join10 } from "node:path";
5545
5710
  var CACHE_TTL_MS = 60 * 60 * 1000;
5546
5711
  var CACHE_FILENAME = "groups.json";
5547
5712
  async function fetchGroups(cfg, opts = {}) {
5548
5713
  const now = opts.now ?? Date.now;
5549
- const cachePath = join9(cfg.cacheDir, CACHE_FILENAME);
5714
+ const cachePath = join10(cfg.cacheDir, CACHE_FILENAME);
5550
5715
  if (opts.forceRefresh !== true) {
5551
5716
  const cached = readCache(cachePath);
5552
5717
  if (cached !== null) {
@@ -5581,10 +5746,10 @@ async function fetchGroups(cfg, opts = {}) {
5581
5746
  writeCache(cachePath, { fetchedAt, groups });
5582
5747
  return { kind: "ok", source: "fresh", fetchedAt, groups };
5583
5748
  }
5584
- function readCache(path9) {
5749
+ function readCache(path10) {
5585
5750
  let raw;
5586
5751
  try {
5587
- raw = readFileSync9(path9, "utf8");
5752
+ raw = readFileSync9(path10, "utf8");
5588
5753
  } catch {
5589
5754
  return null;
5590
5755
  }
@@ -5612,10 +5777,10 @@ function isEntraGroup(value) {
5612
5777
  const g = value;
5613
5778
  return typeof g.id === "string" && typeof g.displayName === "string" && (typeof g.mailNickname === "string" || g.mailNickname === null);
5614
5779
  }
5615
- function writeCache(path9, envelope) {
5780
+ function writeCache(path10, envelope) {
5616
5781
  try {
5617
- mkdirSync2(dirname5(path9), { recursive: true });
5618
- writeFileSync2(path9, JSON.stringify(envelope), "utf8");
5782
+ mkdirSync2(dirname6(path10), { recursive: true });
5783
+ writeFileSync2(path10, JSON.stringify(envelope), "utf8");
5619
5784
  } catch {}
5620
5785
  }
5621
5786
  function describe15(e) {
@@ -6086,16 +6251,16 @@ function describe18(e) {
6086
6251
  // src/commands/init.ts
6087
6252
  import { createInterface } from "node:readline/promises";
6088
6253
  import { existsSync as existsSync7, readFileSync as readFileSync11, writeFileSync as writeFileSync3 } from "node:fs";
6089
- import { resolve as resolve8 } from "node:path";
6254
+ import { resolve as resolve9 } from "node:path";
6090
6255
  import { stringify as yamlStringify } from "yaml";
6091
6256
 
6092
6257
  // src/detect/index.ts
6093
6258
  import { existsSync as existsSync6, readFileSync as readFileSync10, statSync } from "node:fs";
6094
- import { join as join10 } from "node:path";
6259
+ import { join as join11 } from "node:path";
6095
6260
  function detectAppShape(cwd) {
6096
- const hasPackageJson = existsSync6(join10(cwd, "package.json"));
6097
- const hasAnyLockfile = LOCKFILES.some(({ file }) => existsSync6(join10(cwd, file)));
6098
- const hasViteConfig = VITE_CONFIG_NAMES.some((n) => existsSync6(join10(cwd, n)));
6261
+ const hasPackageJson = existsSync6(join11(cwd, "package.json"));
6262
+ const hasAnyLockfile = LOCKFILES.some(({ file }) => existsSync6(join11(cwd, file)));
6263
+ const hasViteConfig = VITE_CONFIG_NAMES.some((n) => existsSync6(join11(cwd, n)));
6099
6264
  if (!hasPackageJson && !hasAnyLockfile && !hasViteConfig) {
6100
6265
  return {
6101
6266
  kind: "not-applicable",
@@ -6141,7 +6306,7 @@ var LOCKFILES = [
6141
6306
  { file: "yarn.lock", pm: "yarn" }
6142
6307
  ];
6143
6308
  function detectPackageManager(cwd) {
6144
- const present = LOCKFILES.filter(({ file }) => existsSync6(join10(cwd, file)));
6309
+ const present = LOCKFILES.filter(({ file }) => existsSync6(join11(cwd, file)));
6145
6310
  if (present.length === 0) {
6146
6311
  return {
6147
6312
  kind: "ambiguous",
@@ -6163,7 +6328,7 @@ function detectPackageManager(cwd) {
6163
6328
  }
6164
6329
  function detectVitePresence(cwd) {
6165
6330
  for (const name of VITE_CONFIG_NAMES) {
6166
- const p = join10(cwd, name);
6331
+ const p = join11(cwd, name);
6167
6332
  if (existsSync6(p)) {
6168
6333
  return { kind: "ok", value: { path: p } };
6169
6334
  }
@@ -6174,7 +6339,7 @@ function detectVitePresence(cwd) {
6174
6339
  };
6175
6340
  }
6176
6341
  function detectAppType(cwd) {
6177
- const fnDir = join10(cwd, "functions");
6342
+ const fnDir = join11(cwd, "functions");
6178
6343
  let hasFunctionsDir = false;
6179
6344
  if (existsSync6(fnDir)) {
6180
6345
  try {
@@ -6186,7 +6351,7 @@ function detectAppType(cwd) {
6186
6351
  return { kind: "ok", value: hasFunctionsDir ? "react+api" : "react" };
6187
6352
  }
6188
6353
  function detectBuildCommand(cwd, pm) {
6189
- const pkgJsonPath = join10(cwd, "package.json");
6354
+ const pkgJsonPath = join11(cwd, "package.json");
6190
6355
  if (!existsSync6(pkgJsonPath)) {
6191
6356
  return {
6192
6357
  kind: "ambiguous",
@@ -6259,7 +6424,7 @@ async function runInit(args, io, prompt) {
6259
6424
  return 64;
6260
6425
  }
6261
6426
  const { inputs, options } = parsed;
6262
- const outPath = resolve8(process.cwd(), options.out);
6427
+ const outPath = resolve9(process.cwd(), options.out);
6263
6428
  if (existsSync7(outPath) && !options.force) {
6264
6429
  io.err(`launchpad init: ${outPath} already exists`);
6265
6430
  io.err("Pass --force to overwrite.");
@@ -6307,7 +6472,7 @@ async function runInit(args, io, prompt) {
6307
6472
  }
6308
6473
  io.out(`✓ wrote ${outPath}`);
6309
6474
  if (options.gitignore) {
6310
- const gitignorePath = resolve8(process.cwd(), ".gitignore");
6475
+ const gitignorePath = resolve9(process.cwd(), ".gitignore");
6311
6476
  try {
6312
6477
  const changed = ensureGitignoreEntries(gitignorePath, [".env", ".env.local"]);
6313
6478
  if (changed.length > 0) {
@@ -6676,10 +6841,10 @@ function buildManifest(inputs, detected) {
6676
6841
  function renderYaml(manifest) {
6677
6842
  return yamlStringify(manifest, { lineWidth: 0 });
6678
6843
  }
6679
- function ensureGitignoreEntries(path9, entries) {
6844
+ function ensureGitignoreEntries(path10, entries) {
6680
6845
  let current = "";
6681
- if (existsSync7(path9)) {
6682
- current = readFileSync11(path9, "utf8");
6846
+ if (existsSync7(path10)) {
6847
+ current = readFileSync11(path10, "utf8");
6683
6848
  }
6684
6849
  const lines = current.split(/\r?\n/);
6685
6850
  const present = new Set(lines.map((l) => l.trim()));
@@ -6697,7 +6862,7 @@ function ensureGitignoreEntries(path9, entries) {
6697
6862
  }
6698
6863
  }
6699
6864
  if (added.length > 0) {
6700
- writeFileSync3(path9, out, { encoding: "utf8" });
6865
+ writeFileSync3(path10, out, { encoding: "utf8" });
6701
6866
  }
6702
6867
  return added;
6703
6868
  }
@@ -6724,7 +6889,11 @@ function makeLoginCommand(deps = REAL_DEPS) {
6724
6889
  run: (args, io) => runLogin(args, io, deps)
6725
6890
  };
6726
6891
  }
6727
- async function runLogin(_args, io, deps) {
6892
+ async function runLogin(args, io, deps) {
6893
+ if (args.includes("--help") || args.includes("-h")) {
6894
+ printLoginHelp(io);
6895
+ return 0;
6896
+ }
6728
6897
  try {
6729
6898
  const cfg = loadConfig();
6730
6899
  if (cfg.authLegacy) {
@@ -6775,6 +6944,27 @@ async function runLegacyLogin(io, botUrl, sessionPath, deps) {
6775
6944
  io.out(`Access token expires in ~${expiresIn}s; refreshes silently.`);
6776
6945
  return 0;
6777
6946
  }
6947
+ function printLoginHelp(io) {
6948
+ io.out("launchpad login — authenticate and store a session.");
6949
+ io.out("");
6950
+ io.out("Usage:");
6951
+ io.out(" launchpad login Sign in via the browser and store a session");
6952
+ io.out("");
6953
+ io.out("Opens your browser to sign in with your M-KOPA Microsoft account");
6954
+ io.out("through the Launchpad auth gateway (loopback PKCE), then writes the");
6955
+ io.out("session to ~/.launchpad/session.json. The short-lived access token");
6956
+ io.out("refreshes silently as you use the CLI.");
6957
+ io.out("");
6958
+ io.out("Environment:");
6959
+ io.out(" LAUNCHPAD_AUTH_LEGACY=1 Force the legacy Cloudflare Access flow");
6960
+ io.out(" (deprecated; removed when the dual-auth");
6961
+ io.out(" window closes)");
6962
+ io.out(" LAUNCHPAD_AUTH_GATEWAY_URL Override the gateway base URL (testing)");
6963
+ io.out("");
6964
+ io.out("Exit codes:");
6965
+ io.out(" 0 signed in / session stored");
6966
+ io.out(" 1 login failed (network, browser, or gateway error)");
6967
+ }
6778
6968
  function describe19(e) {
6779
6969
  return e instanceof Error ? e.message : String(e);
6780
6970
  }
@@ -6789,7 +6979,11 @@ function makeLogoutCommand(deps = REAL_DEPS2) {
6789
6979
  run: (args, io) => runLogout(args, io, deps)
6790
6980
  };
6791
6981
  }
6792
- async function runLogout(_args, io, deps) {
6982
+ async function runLogout(args, io, deps) {
6983
+ if (args.includes("--help") || args.includes("-h")) {
6984
+ printLogoutHelp(io);
6985
+ return 0;
6986
+ }
6793
6987
  try {
6794
6988
  const cfg = loadConfig();
6795
6989
  let session = null;
@@ -6819,12 +7013,28 @@ async function runLogout(_args, io, deps) {
6819
7013
  return 1;
6820
7014
  }
6821
7015
  }
7016
+ function printLogoutHelp(io) {
7017
+ io.out("launchpad logout — revoke the session server-side and clear it locally.");
7018
+ io.out("");
7019
+ io.out("Usage:");
7020
+ io.out(" launchpad logout Revoke server-side, then clear the local session");
7021
+ io.out("");
7022
+ io.out("Gateway (v2) sessions are revoked server-side (the refresh token dies");
7023
+ io.out("immediately; any in-flight access token expires within ~15 minutes),");
7024
+ io.out("then the local ~/.launchpad/session.json is cleared. Logout also works");
7025
+ io.out("offline: if the gateway is unreachable the local session is cleared");
7026
+ io.out("anyway with a warning, and the exit code stays 0.");
7027
+ io.out("");
7028
+ io.out("Exit codes:");
7029
+ io.out(" 0 logged out (or already logged out)");
7030
+ io.out(" 1 could not clear the local session (file IO / config error)");
7031
+ }
6822
7032
  function describe20(e) {
6823
7033
  return e instanceof Error ? e.message : String(e);
6824
7034
  }
6825
7035
 
6826
7036
  // src/commands/logs.ts
6827
- import * as path9 from "node:path";
7037
+ import * as path10 from "node:path";
6828
7038
  var logsCommand = {
6829
7039
  name: "logs",
6830
7040
  summary: "show recent Pages deployment history (slug-scoped)",
@@ -6846,7 +7056,7 @@ async function runLogs(args, io) {
6846
7056
  } else {
6847
7057
  const inferred = inferSlug({ cwd: process.cwd(), warn: (l) => io.err(l) });
6848
7058
  if (inferred === null) {
6849
- io.err(`launchpad logs: could not infer slug from cwd (${path9.basename(process.cwd())});
7059
+ io.err(`launchpad logs: could not infer slug from cwd (${path10.basename(process.cwd())});
6850
7060
  ` + ` pass --slug <slug>, or cd into a directory named launchpad-app-<slug>.`);
6851
7061
  return 64;
6852
7062
  }
@@ -6974,7 +7184,7 @@ function describe21(e) {
6974
7184
  }
6975
7185
 
6976
7186
  // src/commands/merge.ts
6977
- import * as path10 from "node:path";
7187
+ import * as path11 from "node:path";
6978
7188
  var mergeCommand = {
6979
7189
  name: "merge",
6980
7190
  summary: "squash-merge a review-passed PR (slug-scoped)",
@@ -6995,7 +7205,7 @@ async function runMerge(args, io) {
6995
7205
  } else {
6996
7206
  const inferred = inferSlug({ cwd: process.cwd(), warn: (l) => io.err(l) });
6997
7207
  if (inferred === null) {
6998
- io.err(`launchpad merge: could not infer slug from cwd (${path10.basename(process.cwd())});
7208
+ io.err(`launchpad merge: could not infer slug from cwd (${path11.basename(process.cwd())});
6999
7209
  ` + ` pass --slug <slug>, or cd into a directory named launchpad-app-<slug>.`);
7000
7210
  return 64;
7001
7211
  }
@@ -7147,7 +7357,7 @@ function describe22(e) {
7147
7357
  }
7148
7358
 
7149
7359
  // src/commands/plan.ts
7150
- import { resolve as resolve9 } from "node:path";
7360
+ import { resolve as resolve10 } from "node:path";
7151
7361
  var planCommand = {
7152
7362
  name: "plan",
7153
7363
  summary: "summarise what the manifest would deploy (offline)",
@@ -7160,7 +7370,7 @@ async function runPlan(args, io) {
7160
7370
  io.err("Usage: launchpad plan [--file <path>] [--json]");
7161
7371
  return 64;
7162
7372
  }
7163
- const manifestPath = resolve9(process.cwd(), flags.file ?? "launchpad.yaml");
7373
+ const manifestPath = resolve10(process.cwd(), flags.file ?? "launchpad.yaml");
7164
7374
  const result = loadManifest(manifestPath);
7165
7375
  return flags.json ? renderJson(result, io) : renderHuman(result, io);
7166
7376
  }
@@ -7605,8 +7815,8 @@ import { stringify as stringifyYaml } from "yaml";
7605
7815
 
7606
7816
  // src/deploy/manifest-state.ts
7607
7817
  async function fetchManifestState(cfg, slug, opts = {}, fetcher = fetch) {
7608
- const path11 = opts.includeManifest === true ? `/apps/${encodeURIComponent(slug)}/manifest/state?include=manifest` : `/apps/${encodeURIComponent(slug)}/manifest/state`;
7609
- const raw = await apiJson(cfg, { path: path11 }, fetcher);
7818
+ const path12 = opts.includeManifest === true ? `/apps/${encodeURIComponent(slug)}/manifest/state?include=manifest` : `/apps/${encodeURIComponent(slug)}/manifest/state`;
7819
+ const raw = await apiJson(cfg, { path: path12 }, fetcher);
7610
7820
  return {
7611
7821
  slug: raw.slug,
7612
7822
  hasAppFile: raw.hasAppFile,
@@ -7619,8 +7829,8 @@ async function fetchManifestState(cfg, slug, opts = {}, fetcher = fetch) {
7619
7829
 
7620
7830
  // src/deploy/manifest-status.ts
7621
7831
  async function fetchManifestStatus(cfg, slug, fetcher = fetch) {
7622
- const path11 = `/apps/${encodeURIComponent(slug)}/manifest/status`;
7623
- return apiJson(cfg, { path: path11 }, fetcher);
7832
+ const path12 = `/apps/${encodeURIComponent(slug)}/manifest/status`;
7833
+ return apiJson(cfg, { path: path12 }, fetcher);
7624
7834
  }
7625
7835
 
7626
7836
  // src/deploy/deployment-status.ts
@@ -8188,17 +8398,17 @@ async function runStatus(args, io) {
8188
8398
  }
8189
8399
  function computeDrift(local, deployed) {
8190
8400
  const diffs = [];
8191
- const cmp = (path11, l, d) => {
8401
+ const cmp = (path12, l, d) => {
8192
8402
  const li = l ?? null;
8193
8403
  const di = d ?? null;
8194
8404
  if (typeof li === "object" || typeof di === "object") {
8195
8405
  if (JSON.stringify(li) !== JSON.stringify(di)) {
8196
- diffs.push({ path: path11, local: l, deployed: d });
8406
+ diffs.push({ path: path12, local: l, deployed: d });
8197
8407
  }
8198
8408
  return;
8199
8409
  }
8200
8410
  if (li !== di) {
8201
- diffs.push({ path: path11, local: l, deployed: d });
8411
+ diffs.push({ path: path12, local: l, deployed: d });
8202
8412
  }
8203
8413
  };
8204
8414
  cmp("metadata.name", local.metadata.name, deployed.metadata.name);
@@ -8502,7 +8712,7 @@ function isEnoent(e) {
8502
8712
  }
8503
8713
 
8504
8714
  // src/commands/review.ts
8505
- import * as path11 from "node:path";
8715
+ import * as path12 from "node:path";
8506
8716
  var reviewCommand = {
8507
8717
  name: "review",
8508
8718
  summary: "show the review state for a PR (slug-scoped)",
@@ -8522,7 +8732,7 @@ async function runReview(args, io) {
8522
8732
  } else {
8523
8733
  const inferred = inferSlug({ cwd: process.cwd(), warn: (l) => io.err(l) });
8524
8734
  if (inferred === null) {
8525
- io.err(`launchpad review: could not infer slug from cwd (${path11.basename(process.cwd())});
8735
+ io.err(`launchpad review: could not infer slug from cwd (${path12.basename(process.cwd())});
8526
8736
  ` + ` pass --slug <slug>, or cd into a directory named launchpad-app-<slug>.`);
8527
8737
  return 64;
8528
8738
  }
@@ -8779,16 +8989,16 @@ async function runRollback(opts, io, deps = {}) {
8779
8989
  yes: true
8780
8990
  }, io, applyDeps);
8781
8991
  }
8782
- function readCurrentManifest(path12) {
8783
- if (!existsSync8(path12))
8992
+ function readCurrentManifest(path13) {
8993
+ if (!existsSync8(path13))
8784
8994
  return null;
8785
8995
  let raw;
8786
8996
  try {
8787
- raw = readFileSync13(path12, "utf8");
8997
+ raw = readFileSync13(path13, "utf8");
8788
8998
  } catch {
8789
8999
  return null;
8790
9000
  }
8791
- const parsed = parseManifest2(raw, path12);
9001
+ const parsed = parseManifest2(raw, path13);
8792
9002
  if (parsed.kind !== "ok")
8793
9003
  return null;
8794
9004
  return summarise(parsed.manifest);
@@ -9283,23 +9493,23 @@ var CELL_LABEL2 = {
9283
9493
  not_deployed: "NOT_DEPLOYED"
9284
9494
  };
9285
9495
  function loadSet(fleetFile, setName, io) {
9286
- const path12 = resolvePath5(process.cwd(), fleetFile ?? "fleet-secret-sets.yaml");
9287
- if (!existsSync10(path12)) {
9288
- io.err(`✗ ${path12}`);
9496
+ const path13 = resolvePath5(process.cwd(), fleetFile ?? "fleet-secret-sets.yaml");
9497
+ if (!existsSync10(path13)) {
9498
+ io.err(`✗ ${path13}`);
9289
9499
  io.err(" fleet-secret-sets.yaml not found. Run from the platform repo root or pass --fleet-file.");
9290
9500
  return 2;
9291
9501
  }
9292
9502
  let obj;
9293
9503
  try {
9294
- obj = parseYaml6(readFileSync15(path12, "utf8"));
9504
+ obj = parseYaml6(readFileSync15(path13, "utf8"));
9295
9505
  } catch (e) {
9296
- io.err(`✗ ${path12}`);
9506
+ io.err(`✗ ${path13}`);
9297
9507
  io.err(` YAML parse error: ${e instanceof Error ? e.message : String(e)}`);
9298
9508
  return 1;
9299
9509
  }
9300
9510
  const parsed = parseFleetSecretSets(obj);
9301
9511
  if (!parsed.ok) {
9302
- io.err(`✗ ${path12}`);
9512
+ io.err(`✗ ${path13}`);
9303
9513
  io.err(` ${parsed.issues.length} schema issue(s):`);
9304
9514
  for (const i of parsed.issues)
9305
9515
  io.err(` ${i.path}: ${i.message}`);
@@ -9308,7 +9518,7 @@ function loadSet(fleetFile, setName, io) {
9308
9518
  const set = parsed.manifest.secretSets.find((s) => s.name === setName);
9309
9519
  if (set === undefined) {
9310
9520
  const names = parsed.manifest.secretSets.map((s) => s.name).join(", ");
9311
- io.err(`✗ secret-set "${setName}" not found in ${path12}`);
9521
+ io.err(`✗ secret-set "${setName}" not found in ${path13}`);
9312
9522
  io.err(` declared sets: ${names}`);
9313
9523
  return 1;
9314
9524
  }
@@ -9528,7 +9738,7 @@ function setPushExit(e) {
9528
9738
 
9529
9739
  // src/commands/secrets-template.ts
9530
9740
  import { existsSync as existsSync11, writeFileSync as writeFileSync6 } from "node:fs";
9531
- import { resolve as resolve10 } from "node:path";
9741
+ import { resolve as resolve11 } from "node:path";
9532
9742
  async function runSecretsTemplate(args, io) {
9533
9743
  const flags = parseFlags4(args);
9534
9744
  if (flags.kind === "usage-error") {
@@ -9536,7 +9746,7 @@ async function runSecretsTemplate(args, io) {
9536
9746
  io.err("Usage: launchpad secrets template [--file <path>] [--out <path>] " + "[--stdout] [--force] [--include-platform-managed]");
9537
9747
  return 64;
9538
9748
  }
9539
- const manifestPath = resolve10(process.cwd(), flags.file ?? "launchpad.yaml");
9749
+ const manifestPath = resolve11(process.cwd(), flags.file ?? "launchpad.yaml");
9540
9750
  const result = loadManifest(manifestPath);
9541
9751
  const renderResult = renderManifest(result, io);
9542
9752
  if (renderResult.kind !== "ok") {
@@ -9550,7 +9760,7 @@ async function runSecretsTemplate(args, io) {
9550
9760
  }
9551
9761
  return 0;
9552
9762
  }
9553
- const outPath = resolve10(process.cwd(), flags.out);
9763
+ const outPath = resolve11(process.cwd(), flags.out);
9554
9764
  if (existsSync11(outPath) && !flags.force) {
9555
9765
  io.err(`launchpad secrets template: ${outPath} already exists`);
9556
9766
  io.err("Pass --force to overwrite, or --stdout to print without writing.");
@@ -9839,8 +10049,8 @@ function printHelp2(io) {
9839
10049
 
9840
10050
  // src/commands/skills.ts
9841
10051
  import { fileURLToPath } from "node:url";
9842
- import { dirname as dirname6, join as join11, resolve as resolve11 } from "node:path";
9843
- import { promises as fs5, existsSync as existsSync12 } from "node:fs";
10052
+ import { dirname as dirname7, join as join12, resolve as resolve12 } from "node:path";
10053
+ import { promises as fs6, existsSync as existsSync12 } from "node:fs";
9844
10054
  import { homedir as homedir2 } from "node:os";
9845
10055
  var BUNDLE_PREFIX = "launchpad-";
9846
10056
  var BUNDLED_SKILLS = [
@@ -9901,17 +10111,17 @@ function printHelp3(io) {
9901
10111
  }
9902
10112
  function resolveInstallEnv() {
9903
10113
  const bundleDir = process.env.LAUNCHPAD_SKILLS_BUNDLE_DIR ?? defaultBundleDir();
9904
- const userSkillsDir = process.env.LAUNCHPAD_SKILLS_TARGET_DIR ?? join11(homedir2(), ".claude", "skills");
10114
+ const userSkillsDir = process.env.LAUNCHPAD_SKILLS_TARGET_DIR ?? join12(homedir2(), ".claude", "skills");
9905
10115
  return { bundleDir, userSkillsDir };
9906
10116
  }
9907
10117
  function defaultBundleDir() {
9908
- const here = dirname6(fileURLToPath(import.meta.url));
10118
+ const here = dirname7(fileURLToPath(import.meta.url));
9909
10119
  const candidates = [
9910
- resolve11(here, "..", "skills"),
9911
- resolve11(here, "..", "..", "skills")
10120
+ resolve12(here, "..", "skills"),
10121
+ resolve12(here, "..", "..", "skills")
9912
10122
  ];
9913
10123
  for (const c of candidates) {
9914
- if (existsSync12(join11(c, "launchpad-onboard", "SKILL.md"))) {
10124
+ if (existsSync12(join12(c, "launchpad-onboard", "SKILL.md"))) {
9915
10125
  return c;
9916
10126
  }
9917
10127
  }
@@ -9927,19 +10137,19 @@ async function doInstall(io) {
9927
10137
  return 1;
9928
10138
  }
9929
10139
  if (!await isDir(env.userSkillsDir)) {
9930
- await fs5.mkdir(env.userSkillsDir, { recursive: true });
10140
+ await fs6.mkdir(env.userSkillsDir, { recursive: true });
9931
10141
  io.out(`Created ${env.userSkillsDir}.`);
9932
10142
  }
9933
10143
  let installed = 0;
9934
10144
  for (const skill of BUNDLED_SKILLS) {
9935
- const src = join11(env.bundleDir, skill);
10145
+ const src = join12(env.bundleDir, skill);
9936
10146
  if (!await isDir(src)) {
9937
10147
  io.err(`launchpad skills install: bundled skill "${skill}" missing from ${env.bundleDir} — package is incomplete.`);
9938
10148
  return 1;
9939
10149
  }
9940
- const dest = join11(env.userSkillsDir, skill);
9941
- await fs5.rm(dest, { recursive: true, force: true });
9942
- await fs5.cp(src, dest, { recursive: true });
10150
+ const dest = join12(env.userSkillsDir, skill);
10151
+ await fs6.rm(dest, { recursive: true, force: true });
10152
+ await fs6.cp(src, dest, { recursive: true });
9943
10153
  installed++;
9944
10154
  io.out(`✓ ${skill} → ${dest}`);
9945
10155
  }
@@ -9955,15 +10165,15 @@ async function doUninstall(io) {
9955
10165
  io.out(`launchpad skills uninstall: ${env.userSkillsDir} does not exist — nothing to do.`);
9956
10166
  return 0;
9957
10167
  }
9958
- const entries = await fs5.readdir(env.userSkillsDir, { withFileTypes: true });
10168
+ const entries = await fs6.readdir(env.userSkillsDir, { withFileTypes: true });
9959
10169
  let removed = 0;
9960
10170
  for (const entry of entries) {
9961
10171
  if (!entry.isDirectory())
9962
10172
  continue;
9963
10173
  if (!isBundleManaged(entry.name))
9964
10174
  continue;
9965
- const target = join11(env.userSkillsDir, entry.name);
9966
- await fs5.rm(target, { recursive: true, force: true });
10175
+ const target = join12(env.userSkillsDir, entry.name);
10176
+ await fs6.rm(target, { recursive: true, force: true });
9967
10177
  removed++;
9968
10178
  io.out(`✗ removed ${target}`);
9969
10179
  }
@@ -9977,7 +10187,7 @@ async function doList(io) {
9977
10187
  io.out("(none — ~/.claude/skills/ does not exist)");
9978
10188
  return 0;
9979
10189
  }
9980
- const entries = await fs5.readdir(env.userSkillsDir, { withFileTypes: true });
10190
+ const entries = await fs6.readdir(env.userSkillsDir, { withFileTypes: true });
9981
10191
  const managedDirs = entries.filter((e) => e.isDirectory() && isBundleManaged(e.name)).map((e) => e.name).sort();
9982
10192
  if (managedDirs.length === 0) {
9983
10193
  io.out("(none — run `launchpad skills install`)");
@@ -9985,16 +10195,16 @@ async function doList(io) {
9985
10195
  }
9986
10196
  const width = managedDirs.reduce((n, s) => Math.max(n, s.length), 0);
9987
10197
  for (const name of managedDirs) {
9988
- const skillFile = join11(env.userSkillsDir, name, "SKILL.md");
10198
+ const skillFile = join12(env.userSkillsDir, name, "SKILL.md");
9989
10199
  const version = await readVersion(skillFile);
9990
10200
  io.out(` ${name.padEnd(width + 2)}${version ?? "(no version)"}`);
9991
10201
  }
9992
10202
  return 0;
9993
10203
  }
9994
- async function readVersion(path12) {
10204
+ async function readVersion(path13) {
9995
10205
  let text;
9996
10206
  try {
9997
- text = await fs5.readFile(path12, "utf8");
10207
+ text = await fs6.readFile(path13, "utf8");
9998
10208
  } catch {
9999
10209
  return null;
10000
10210
  }
@@ -10004,9 +10214,9 @@ async function readVersion(path12) {
10004
10214
  const m = /^version:\s*(.+?)\s*$/m.exec(front);
10005
10215
  return m === null ? null : m[1] ?? null;
10006
10216
  }
10007
- async function isDir(path12) {
10217
+ async function isDir(path13) {
10008
10218
  try {
10009
- const stat = await fs5.stat(path12);
10219
+ const stat = await fs6.stat(path13);
10010
10220
  return stat.isDirectory();
10011
10221
  } catch {
10012
10222
  return false;
@@ -10020,7 +10230,7 @@ function describe29(e) {
10020
10230
  import { execFile, spawn as spawn5 } from "node:child_process";
10021
10231
  import { promisify } from "node:util";
10022
10232
  import { fileURLToPath as fileURLToPath2 } from "node:url";
10023
- import { dirname as dirname7, resolve as resolve12, relative as relative4, isAbsolute as isAbsolute2, join as join12 } from "node:path";
10233
+ import { dirname as dirname8, resolve as resolve13, relative as relative4, isAbsolute as isAbsolute2, join as join13 } from "node:path";
10024
10234
  import { homedir as homedir3, tmpdir } from "node:os";
10025
10235
  import { readFileSync as readFileSync16, mkdtempSync, writeFileSync as writeFileSync7, rmSync as rmSync2 } from "node:fs";
10026
10236
 
@@ -10065,9 +10275,9 @@ async function startLoopback(state, timeoutMs) {
10065
10275
  resolveCode(code);
10066
10276
  }
10067
10277
  });
10068
- const bound = await new Promise((resolve12) => {
10069
- server.once("error", () => resolve12(false));
10070
- server.listen(0, "127.0.0.1", () => resolve12(true));
10278
+ const bound = await new Promise((resolve13) => {
10279
+ server.once("error", () => resolve13(false));
10280
+ server.listen(0, "127.0.0.1", () => resolve13(true));
10071
10281
  });
10072
10282
  if (!bound)
10073
10283
  return null;
@@ -10143,7 +10353,7 @@ var PKG = "@m-kopa/launchpad-cli";
10143
10353
  var REGISTRY = "https://registry.npmjs.org";
10144
10354
  var CHANNEL_VERSION_URL = "https://get.launchpad.m-kopa.us/version.json";
10145
10355
  var CHANNEL_INSTALL_URL = "https://get.launchpad.m-kopa.us";
10146
- var CHANNEL_MARKER = join12(homedir3(), ".launchpad", "channel");
10356
+ var CHANNEL_MARKER = join13(homedir3(), ".launchpad", "channel");
10147
10357
  var EXIT_UPDATE_AVAILABLE = 10;
10148
10358
  var UPGRADE_ARGS = {
10149
10359
  npm: ["install", "-g", `${PKG}@latest`],
@@ -10210,6 +10420,7 @@ function compareVersions(a, b) {
10210
10420
  }
10211
10421
  return 0;
10212
10422
  }
10423
+ var NOTIFIER_OPT_OUT_ENV = "LAUNCHPAD_NO_UPDATE_NOTIFIER";
10213
10424
  function defaultDeps() {
10214
10425
  const channel = detectInstallChannel();
10215
10426
  return {
@@ -10218,16 +10429,42 @@ function defaultDeps() {
10218
10429
  fetchLatestVersion: channel === "platform" ? fetchLatestFromChannel : fetchLatestVersion,
10219
10430
  detectPm: detectPackageManager2,
10220
10431
  runUpgrade,
10221
- runChannelInstaller
10432
+ runChannelInstaller,
10433
+ syncSkills: runSkillsSync
10222
10434
  };
10223
10435
  }
10436
+ async function runSkillsSync() {
10437
+ const cliPath = process.argv[1];
10438
+ if (!cliPath)
10439
+ return { ok: false };
10440
+ return await new Promise((resolvePromise) => {
10441
+ try {
10442
+ const child = spawn5(process.execPath, [cliPath, "skills", "update"], {
10443
+ stdio: "ignore",
10444
+ env: { ...process.env, [NOTIFIER_OPT_OUT_ENV]: "1" }
10445
+ });
10446
+ child.on("error", () => resolvePromise({ ok: false }));
10447
+ child.on("close", (code) => resolvePromise({ ok: code === 0 }));
10448
+ } catch {
10449
+ resolvePromise({ ok: false });
10450
+ }
10451
+ });
10452
+ }
10453
+ async function syncSkillsAfterUpgrade(io, deps) {
10454
+ const result = await deps.syncSkills();
10455
+ if (result.ok) {
10456
+ io.out("✓ Claude Code skill bundle re-synced to the new version.");
10457
+ } else {
10458
+ io.err("Note: couldn't auto-sync the skill bundle — run `launchpad skills update` to finish.");
10459
+ }
10460
+ }
10224
10461
  async function openSystemBrowser(url) {
10225
10462
  const opener = process.platform === "darwin" ? "open" : "xdg-open";
10226
10463
  await execFileAsync(opener, [url]);
10227
10464
  }
10228
10465
  async function runInstallerScript(script) {
10229
- const dir = mkdtempSync(join12(tmpdir(), "launchpad-update-"));
10230
- const file = join12(dir, "install.sh");
10466
+ const dir = mkdtempSync(join13(tmpdir(), "launchpad-update-"));
10467
+ const file = join13(dir, "install.sh");
10231
10468
  try {
10232
10469
  writeFileSync7(file, script, { mode: 448 });
10233
10470
  return await new Promise((resolvePromise) => {
@@ -10314,7 +10551,7 @@ function errStderr(e) {
10314
10551
  return "";
10315
10552
  }
10316
10553
  async function detectPackageManager2() {
10317
- const pkgRoot = resolve12(dirname7(fileURLToPath2(import.meta.url)), "..", "..");
10554
+ const pkgRoot = resolve13(dirname8(fileURLToPath2(import.meta.url)), "..", "..");
10318
10555
  const candidates = [];
10319
10556
  const npmRoot = await pmRoot("npm");
10320
10557
  if (npmRoot !== null)
@@ -10322,7 +10559,7 @@ async function detectPackageManager2() {
10322
10559
  const pnpmRoot = await pmRoot("pnpm");
10323
10560
  if (pnpmRoot !== null)
10324
10561
  candidates.push(["pnpm", pnpmRoot]);
10325
- candidates.push(["bun", resolve12(homedir3(), ".bun/install/global/node_modules")]);
10562
+ candidates.push(["bun", resolve13(homedir3(), ".bun/install/global/node_modules")]);
10326
10563
  const matches = candidates.filter(([, root]) => pathContains(root, pkgRoot));
10327
10564
  return matches.length === 1 ? matches[0][0] : null;
10328
10565
  }
@@ -10394,6 +10631,7 @@ async function runUpdate(args, io, deps) {
10394
10631
  io.out(`✓ launchpad-cli updated to ${latest.version}.`);
10395
10632
  io.out("If `launchpad --version` still shows the old version, open a new");
10396
10633
  io.out("shell (or check for a PATH-shadowing bun/pnpm global install).");
10634
+ await syncSkillsAfterUpgrade(io, deps);
10397
10635
  return 0;
10398
10636
  }
10399
10637
  io.err("");
@@ -10430,8 +10668,7 @@ async function runUpdate(args, io, deps) {
10430
10668
  }
10431
10669
  io.out("");
10432
10670
  io.out(`✓ launchpad-cli updated to ${latest.version}.`);
10433
- io.out("Next: run `launchpad skills update` to re-sync the Claude Code skill bundle");
10434
- io.out("(the CLI and the skill bundle are version-locked together).");
10671
+ await syncSkillsAfterUpgrade(io, deps);
10435
10672
  return 0;
10436
10673
  }
10437
10674
  function printHelp4(io) {
@@ -10459,7 +10696,7 @@ function printHelp4(io) {
10459
10696
 
10460
10697
  // src/commands/validate.ts
10461
10698
  import { readFileSync as readFileSync17 } from "node:fs";
10462
- import { dirname as dirname8, resolve as resolve13 } from "node:path";
10699
+ import { dirname as dirname9, resolve as resolve14 } from "node:path";
10463
10700
  var validateCommand = {
10464
10701
  name: "validate",
10465
10702
  summary: "validate launchpad.yaml against the v1alpha1 schema",
@@ -10473,17 +10710,17 @@ async function runValidate(args, io) {
10473
10710
  return 64;
10474
10711
  }
10475
10712
  const { file, json, strictGroups } = parseResult;
10476
- const path12 = resolve13(process.cwd(), file ?? "launchpad.yaml");
10477
- const result = loadManifest(path12);
10713
+ const path13 = resolve14(process.cwd(), file ?? "launchpad.yaml");
10714
+ const result = loadManifest(path13);
10478
10715
  if (result.kind !== "ok") {
10479
10716
  return json ? renderJsonError(result, io) : renderHumanError(result, io);
10480
10717
  }
10481
- const boundary = checkBoundary(path12, result.manifest.app !== undefined);
10718
+ const boundary = checkBoundary(path13, result.manifest.app !== undefined);
10482
10719
  const groupCheck = strictGroups ? await checkGroups(allowedEntraGroups(result.manifest.access)) : { kind: "skipped" };
10483
10720
  return json ? renderJsonOk(result, groupCheck, boundary, io) : renderHumanOk(result, groupCheck, boundary, io);
10484
10721
  }
10485
10722
  function checkBoundary(manifestPath, declared) {
10486
- const dir = dirname8(manifestPath);
10723
+ const dir = dirname9(manifestPath);
10487
10724
  let files;
10488
10725
  try {
10489
10726
  files = walkCwd(dir).files;
@@ -10806,12 +11043,18 @@ function describe31(e) {
10806
11043
  // src/update-notifier.ts
10807
11044
  import { spawn as spawn6 } from "node:child_process";
10808
11045
  import { homedir as homedir4 } from "node:os";
10809
- import { join as join13 } from "node:path";
10810
- import { mkdirSync as mkdirSync3, readFileSync as readFileSync18, writeFileSync as writeFileSync8 } from "node:fs";
11046
+ import { join as join14 } from "node:path";
11047
+ import {
11048
+ existsSync as existsSync13,
11049
+ mkdirSync as mkdirSync3,
11050
+ readFileSync as readFileSync18,
11051
+ readdirSync as readdirSync2,
11052
+ writeFileSync as writeFileSync8
11053
+ } from "node:fs";
10811
11054
  var INTERNAL_REFRESH_VERB = "__refresh-update-cache";
10812
11055
  var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
10813
11056
  var OPT_OUT_ENV = "LAUNCHPAD_NO_UPDATE_NOTIFIER";
10814
- var CACHE_FILE = join13(homedir4(), ".launchpad", "update-check.json");
11057
+ var CACHE_FILE = join14(homedir4(), ".launchpad", "update-check.json");
10815
11058
  function readCache2() {
10816
11059
  try {
10817
11060
  const raw = JSON.parse(readFileSync18(CACHE_FILE, "utf8"));
@@ -10827,11 +11070,53 @@ function readCache2() {
10827
11070
  }
10828
11071
  function writeCache2(state) {
10829
11072
  try {
10830
- mkdirSync3(join13(homedir4(), ".launchpad"), { recursive: true });
11073
+ mkdirSync3(join14(homedir4(), ".launchpad"), { recursive: true });
10831
11074
  writeFileSync8(CACHE_FILE, `${JSON.stringify(state)}
10832
11075
  `, { mode: 384 });
10833
11076
  } catch {}
10834
11077
  }
11078
+ var SKILL_PREFIX = "launchpad-";
11079
+ var SKILLS_HINT_MARKER = join14(homedir4(), ".launchpad", "skills-hint-shown");
11080
+ function skillsTargetDir() {
11081
+ return process.env.LAUNCHPAD_SKILLS_TARGET_DIR ?? join14(homedir4(), ".claude", "skills");
11082
+ }
11083
+ function readInstalledSkills() {
11084
+ try {
11085
+ const dir = skillsTargetDir();
11086
+ const bundle = readdirSync2(dir, { withFileTypes: true }).find((e) => e.isDirectory() && e.name.startsWith(SKILL_PREFIX));
11087
+ if (!bundle)
11088
+ return { present: false, version: null };
11089
+ return { present: true, version: readSkillVersion(join14(dir, bundle.name, "SKILL.md")) };
11090
+ } catch {
11091
+ return { present: false, version: null };
11092
+ }
11093
+ }
11094
+ function readSkillVersion(path13) {
11095
+ try {
11096
+ const text = readFileSync18(path13, "utf8");
11097
+ const fenceEnd = text.indexOf(`
11098
+ ---`, 4);
11099
+ const front = fenceEnd === -1 ? text.slice(0, 1024) : text.slice(0, fenceEnd);
11100
+ const m = /^version:\s*(.+?)\s*$/m.exec(front);
11101
+ return m === null ? null : m[1] ?? null;
11102
+ } catch {
11103
+ return null;
11104
+ }
11105
+ }
11106
+ function absentHintShown() {
11107
+ try {
11108
+ return existsSync13(SKILLS_HINT_MARKER);
11109
+ } catch {
11110
+ return false;
11111
+ }
11112
+ }
11113
+ function markAbsentHintShown() {
11114
+ try {
11115
+ mkdirSync3(join14(homedir4(), ".launchpad"), { recursive: true });
11116
+ writeFileSync8(SKILLS_HINT_MARKER, `${Date.now()}
11117
+ `, { mode: 384 });
11118
+ } catch {}
11119
+ }
10835
11120
  function isSuppressed(ctx) {
10836
11121
  if (ctx.env[OPT_OUT_ENV])
10837
11122
  return true;
@@ -10850,6 +11135,12 @@ function noticeLine(current, latest, channel) {
10850
11135
  const how = channel === "platform" ? `re-run the installer at ${CHANNEL_INSTALL_URL}` : "run `launchpad update`";
10851
11136
  return `▲ launchpad ${current} → ${latest} available — ${how} (silence with ${OPT_OUT_ENV}=1).`;
10852
11137
  }
11138
+ function skillsDriftLine(bundleVersion, cliVersion) {
11139
+ return `▲ launchpad skill bundle ${bundleVersion} is behind the CLI ${cliVersion} — run \`launchpad skills update\` (silence with ${OPT_OUT_ENV}=1).`;
11140
+ }
11141
+ function skillsAbsentLine() {
11142
+ return `▲ launchpad Claude Code skills aren't installed — run \`launchpad skills install\` to add them (silence with ${OPT_OUT_ENV}=1).`;
11143
+ }
10853
11144
  function maybeNotify(io, ctx, deps) {
10854
11145
  if (isSuppressed(ctx))
10855
11146
  return;
@@ -10857,6 +11148,15 @@ function maybeNotify(io, ctx, deps) {
10857
11148
  if (cache?.latest && compareVersions(cache.latest, deps.cliVersion) === 1) {
10858
11149
  io.err(noticeLine(deps.cliVersion, cache.latest, deps.channel()));
10859
11150
  }
11151
+ const skills = deps.readInstalledSkills();
11152
+ if (!skills.present) {
11153
+ if (!deps.absentHintShown()) {
11154
+ io.err(skillsAbsentLine());
11155
+ deps.markAbsentHintShown();
11156
+ }
11157
+ } else if (skills.version && compareVersions(deps.cliVersion, skills.version) === 1) {
11158
+ io.err(skillsDriftLine(skills.version, deps.cliVersion));
11159
+ }
10860
11160
  if (cache === null || deps.now() - cache.checkedAt > CHECK_INTERVAL_MS) {
10861
11161
  deps.spawnRefresh();
10862
11162
  }
@@ -10880,7 +11180,10 @@ function notifyAfterCommand(io, argv, cliPath = process.argv[1]) {
10880
11180
  channel: detectInstallChannel,
10881
11181
  readCache: readCache2,
10882
11182
  now: Date.now,
10883
- spawnRefresh: () => spawnDetachedRefresh(cliPath)
11183
+ spawnRefresh: () => spawnDetachedRefresh(cliPath),
11184
+ readInstalledSkills,
11185
+ absentHintShown,
11186
+ markAbsentHintShown
10884
11187
  });
10885
11188
  } catch {}
10886
11189
  }