@m-kopa/launchpad-cli 0.39.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -19,7 +19,7 @@ var __toESM = (mod, isNodeMode, target) => {
19
19
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
20
20
 
21
21
  // src/version.ts
22
- var CLI_VERSION = "0.39.0";
22
+ var CLI_VERSION = "0.41.0";
23
23
 
24
24
  // src/config.ts
25
25
  import * as os from "node:os";
@@ -75,6 +75,28 @@ class TransportError extends Error {
75
75
  code = "transport_error";
76
76
  }
77
77
 
78
+ // src/report/fault-signal.ts
79
+ var last = null;
80
+ function recordFault(err) {
81
+ if (err === null || typeof err !== "object")
82
+ return;
83
+ const code = err.code;
84
+ if (typeof code !== "string")
85
+ return;
86
+ const status = err.status;
87
+ last = typeof status === "number" ? { code, status } : { code };
88
+ }
89
+ function recordedFault(err) {
90
+ recordFault(err);
91
+ return err;
92
+ }
93
+ function clearFault() {
94
+ last = null;
95
+ }
96
+ function peekFault() {
97
+ return last;
98
+ }
99
+
78
100
  // src/auth/flow.ts
79
101
  import { randomBytes as randomBytes4 } from "node:crypto";
80
102
 
@@ -838,7 +860,7 @@ async function apiJson(cfg, opts, fetcher = fetch) {
838
860
  try {
839
861
  parsed = await res.json();
840
862
  } catch (e) {
841
- throw new TransportError(`bot returned non-JSON body for ${opts.path}: ${describe7(e)}`);
863
+ throw recordedFault(new TransportError(`bot returned non-JSON body for ${opts.path}: ${describe7(e)}`));
842
864
  }
843
865
  return parsed;
844
866
  }
@@ -880,27 +902,28 @@ async function apiRaw(cfg, opts, fetcher = fetch) {
880
902
  try {
881
903
  res = await fetcher(url, init);
882
904
  } catch (e) {
883
- throw new TransportError(`network error calling ${url}: ${describe7(e)}`);
905
+ throw recordedFault(new TransportError(`network error calling ${url}: ${describe7(e)}`));
884
906
  }
885
907
  if (res.status === 401) {
886
908
  const detail = await peek(res);
887
- throw new UnauthenticatedError(`bot returned 401 for ${opts.path}: ${detail} — run \`launchpad login\``);
909
+ throw recordedFault(new UnauthenticatedError(`bot returned 401 for ${opts.path}: ${detail} — run \`launchpad login\``));
888
910
  }
889
911
  if (res.status === 403) {
890
912
  const detail = await peek(res);
891
- throw new ForbiddenError(`bot returned 403 for ${opts.path}: ${detail}`);
913
+ throw recordedFault(new ForbiddenError(`bot returned 403 for ${opts.path}: ${detail}`));
892
914
  }
893
915
  if (res.status === 404) {
894
916
  const detail = await peek(res);
895
- throw new NotFoundError(`bot returned 404 for ${opts.path}: ${detail}`);
917
+ throw recordedFault(new NotFoundError(`bot returned 404 for ${opts.path}: ${detail}`));
896
918
  }
897
919
  if (!res.ok) {
898
920
  if (opts.nonThrowingStatuses?.includes(res.status) === true) {
899
921
  return res;
900
922
  }
901
923
  const detail = await peek(res);
902
- throw new ApiError(`bot returned HTTP ${res.status} for ${opts.path}: ${detail}`, res.status);
924
+ throw recordedFault(new ApiError(`bot returned HTTP ${res.status} for ${opts.path}: ${detail}`, res.status));
903
925
  }
926
+ clearFault();
904
927
  return res;
905
928
  }
906
929
  async function peek(res) {
@@ -2183,6 +2206,9 @@ var AppBoundarySchema = z.object({
2183
2206
  exclude: z.array(z.string().min(1)).optional()
2184
2207
  }).strict();
2185
2208
  var ProductionEnvSchema = z.record(z.string().regex(ENV_NAME_REGEX, "production_env keys must be UPPER_SNAKE_CASE"), z.string());
2209
+ var VerifySchema = z.object({
2210
+ protected_path: z.string().min(1).regex(/^\//, "verify.protected_path must be a root-relative path beginning with '/'")
2211
+ }).strict();
2186
2212
  var ManifestSchema = z.object({
2187
2213
  apiVersion: z.literal("launchpad.m-kopa.us/v1alpha1"),
2188
2214
  kind: z.literal("App"),
@@ -2196,9 +2222,33 @@ var ManifestSchema = z.object({
2196
2222
  targets: z.array(TargetSchema).optional(),
2197
2223
  build: BuildSchema.optional(),
2198
2224
  production_env: ProductionEnvSchema.optional(),
2199
- app: AppBoundarySchema.optional()
2225
+ app: AppBoundarySchema.optional(),
2226
+ verify: VerifySchema.optional(),
2227
+ confine_origin: z.boolean().optional(),
2228
+ catalogue: z.object({ listed: z.boolean().default(true) }).strict().optional()
2200
2229
  }).strict().superRefine((m, ctx) => {
2201
2230
  const isContainer = m.deployment.type === "container";
2231
+ if (m.confine_origin !== undefined) {
2232
+ if (isContainer) {
2233
+ ctx.addIssue({
2234
+ code: z.ZodIssueCode.custom,
2235
+ path: ["confine_origin"],
2236
+ message: "confine_origin is only valid for gateway pages apps (static/react/react+api); container apps have no pages.dev surface."
2237
+ });
2238
+ } else if (m.auth === "access") {
2239
+ ctx.addIssue({
2240
+ code: z.ZodIssueCode.custom,
2241
+ path: ["confine_origin"],
2242
+ message: "confine_origin applies to gateway-fronted apps (auth: gateway); it has no meaning under Cloudflare Access."
2243
+ });
2244
+ } else if (m.deployment.type === "static" && m.confine_origin === false) {
2245
+ ctx.addIssue({
2246
+ code: z.ZodIssueCode.custom,
2247
+ path: ["confine_origin"],
2248
+ message: "confine_origin: false is not allowed for static apps — they have no verifier, so an unconfined apex would be world-readable (ADR 0031)."
2249
+ });
2250
+ }
2251
+ }
2202
2252
  if (isContainer && m.auth === "gateway") {
2203
2253
  ctx.addIssue({
2204
2254
  code: z.ZodIssueCode.custom,
@@ -2790,6 +2840,9 @@ var SpecSchema = z2.object({
2790
2840
  auth: z2.enum(AUTH_MODES).optional(),
2791
2841
  build: BuildSchema.optional(),
2792
2842
  app: AppBoundarySchema.optional(),
2843
+ verify: VerifySchema.optional(),
2844
+ confine_origin: z2.boolean().optional(),
2845
+ catalogue: z2.object({ listed: z2.boolean().default(true) }).strict().optional(),
2793
2846
  env_vars: z2.record(z2.string().regex(ENV_NAME_REGEX, "env_vars keys must be UPPER_SNAKE_CASE"), EnvVarSchema).optional(),
2794
2847
  containers: z2.array(ContainerSchema).min(1).optional(),
2795
2848
  secrets: SecretsSchema.optional(),
@@ -2812,6 +2865,27 @@ var SpecSchema = z2.object({
2812
2865
  message: "containers[] is required when appType is 'container'"
2813
2866
  });
2814
2867
  }
2868
+ if (s.confine_origin !== undefined) {
2869
+ if (isContainer) {
2870
+ ctx.addIssue({
2871
+ code: z2.ZodIssueCode.custom,
2872
+ path: ["confine_origin"],
2873
+ message: "confine_origin is only valid for gateway pages apps; container apps have no pages.dev surface."
2874
+ });
2875
+ } else if (s.auth === "access") {
2876
+ ctx.addIssue({
2877
+ code: z2.ZodIssueCode.custom,
2878
+ path: ["confine_origin"],
2879
+ message: "confine_origin applies to gateway-fronted apps (auth: gateway); it has no meaning under Cloudflare Access."
2880
+ });
2881
+ } else if (s.appType === "static" && s.confine_origin === false) {
2882
+ ctx.addIssue({
2883
+ code: z2.ZodIssueCode.custom,
2884
+ path: ["confine_origin"],
2885
+ message: "confine_origin: false is not allowed for static apps — an unconfined apex with no verifier is world-readable (ADR 0031)."
2886
+ });
2887
+ }
2888
+ }
2815
2889
  if (!isContainer && s.containers !== undefined) {
2816
2890
  ctx.addIssue({
2817
2891
  code: z2.ZodIssueCode.custom,
@@ -3320,9 +3394,45 @@ function emitSecretStep(lines, binding) {
3320
3394
  // ../launchpad-engine/dist/per-app-workspace.js
3321
3395
  var CF_ACCOUNT_ID = "c07cb17c9f742e8144c4828b3693ca65";
3322
3396
  var R2_S3_ENDPOINT = `https://${CF_ACCOUNT_ID}.r2.cloudflarestorage.com`;
3397
+ // ../launchpad-engine/dist/access-drift.js
3398
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3399
+ function canonicalUuid(s) {
3400
+ const t = s.trim();
3401
+ return UUID_RE.test(t) ? t.toLowerCase() : null;
3402
+ }
3403
+ function normalise(tokens, side, resolve6) {
3404
+ const uuids = new Set;
3405
+ const unresolved = [];
3406
+ for (const raw of tokens) {
3407
+ const direct = canonicalUuid(raw);
3408
+ const uuid = direct ?? (() => {
3409
+ const resolved = resolve6(raw);
3410
+ return resolved === null ? null : canonicalUuid(resolved);
3411
+ })();
3412
+ if (uuid === null) {
3413
+ unresolved.push({ side, token: raw });
3414
+ } else {
3415
+ uuids.add(uuid);
3416
+ }
3417
+ }
3418
+ return { uuids, unresolved };
3419
+ }
3420
+ function detectAccessDrift(declaredTokens, enforcedTokens, resolve6) {
3421
+ const declared = normalise(declaredTokens, "manifest", resolve6);
3422
+ const enforced = normalise(enforcedTokens, "enforced", resolve6);
3423
+ const manifestOnly = [...declared.uuids].filter((u) => !enforced.uuids.has(u)).sort();
3424
+ const liveOnly = [...enforced.uuids].filter((u) => !declared.uuids.has(u)).sort();
3425
+ const unresolved = [...declared.unresolved, ...enforced.unresolved];
3426
+ return {
3427
+ manifestOnly,
3428
+ liveOnly,
3429
+ unresolved,
3430
+ inSync: manifestOnly.length === 0 && liveOnly.length === 0 && unresolved.length === 0
3431
+ };
3432
+ }
3323
3433
  // ../launchpad-engine/dist/fleet-manifest.js
3324
3434
  import { z as z3 } from "zod";
3325
- var UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
3435
+ var UUID_RE2 = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
3326
3436
  var TF_RESIDENCIES = ["per-app-workspace", "main-tf"];
3327
3437
  var APP_SHAPES = [
3328
3438
  "pages",
@@ -3349,7 +3459,7 @@ var FLEET_TIERS = [
3349
3459
  "out-of-scope"
3350
3460
  ];
3351
3461
  var DEVICE_POSTURE = ["gateway-eligible", "retained-access"];
3352
- var GroupId = z3.string().regex(UUID_RE, "Entra group ids are emitted as canonical UUIDs in the JWT groups claim — display names will not match policies.");
3462
+ var GroupId = z3.string().regex(UUID_RE2, "Entra group ids are emitted as canonical UUIDs in the JWT groups claim — display names will not match policies.");
3353
3463
  var FleetAppSchema = z3.object({
3354
3464
  slug: z3.string().regex(SLUG_REGEX),
3355
3465
  residency: z3.enum(TF_RESIDENCIES),
@@ -3621,8 +3731,8 @@ async function bundleAndDeploy(args) {
3621
3731
  }
3622
3732
 
3623
3733
  // src/deploy/verify-deploy.ts
3624
- import { readFileSync as readFileSync7, existsSync as existsSync4 } from "node:fs";
3625
- import { join as join8 } from "node:path";
3734
+ import { readFileSync as readFileSync8, existsSync as existsSync4 } from "node:fs";
3735
+ import { join as join9 } from "node:path";
3626
3736
 
3627
3737
  // src/deploy/deployment-status.ts
3628
3738
  async function fetchStandingExceptions(cfg, slug, fetcher = fetch) {
@@ -3837,7 +3947,7 @@ function redactDiagnostic(text, maxLen = MAX_DIAGNOSTIC_LEN) {
3837
3947
  }
3838
3948
 
3839
3949
  // src/commands/status.ts
3840
- import { readFileSync as readFileSync6 } from "node:fs";
3950
+ import { readFileSync as readFileSync7 } from "node:fs";
3841
3951
 
3842
3952
  // src/deploy/manifest-state.ts
3843
3953
  async function fetchManifestState(cfg, slug, opts = {}, fetcher = fetch) {
@@ -3849,6 +3959,7 @@ async function fetchManifestState(cfg, slug, opts = {}, fetcher = fetch) {
3849
3959
  lastAppliedManifestSha: raw.lastAppliedManifestSha,
3850
3960
  appRepoHeadSha: raw.appRepoHeadSha ?? null,
3851
3961
  openPr: raw.openPr,
3962
+ enforcedGroups: raw.enforcedGroups ?? null,
3852
3963
  ...raw.manifestYaml !== undefined ? { manifestYaml: raw.manifestYaml } : {}
3853
3964
  };
3854
3965
  }
@@ -3893,6 +4004,110 @@ function inferSlug(opts) {
3893
4004
  return fromManifest ?? fromDir;
3894
4005
  }
3895
4006
 
4007
+ // src/groups/client.ts
4008
+ import { mkdirSync, readFileSync as readFileSync6, writeFileSync } from "node:fs";
4009
+ import { dirname as dirname5, join as join8 } from "node:path";
4010
+ var CACHE_TTL_MS = 60 * 60 * 1000;
4011
+ var CACHE_FILENAME = "groups.json";
4012
+ async function fetchGroups(cfg, opts = {}) {
4013
+ const now = opts.now ?? Date.now;
4014
+ const cachePath = join8(cfg.cacheDir, CACHE_FILENAME);
4015
+ if (opts.forceRefresh !== true) {
4016
+ const cached = readCache(cachePath);
4017
+ if (cached !== null) {
4018
+ const ageMs = now() - Date.parse(cached.fetchedAt);
4019
+ if (Number.isFinite(ageMs) && ageMs >= 0 && ageMs < CACHE_TTL_MS) {
4020
+ return {
4021
+ kind: "ok",
4022
+ source: "cache",
4023
+ fetchedAt: cached.fetchedAt,
4024
+ groups: cached.groups
4025
+ };
4026
+ }
4027
+ }
4028
+ }
4029
+ let response;
4030
+ try {
4031
+ response = await apiJson(cfg, { path: "/groups" }, opts.fetcher);
4032
+ } catch (e) {
4033
+ if (e instanceof UnauthenticatedError || e instanceof ForbiddenError) {
4034
+ throw e;
4035
+ }
4036
+ return { kind: "error", message: describe11(e) };
4037
+ }
4038
+ if (typeof response !== "object" || response === null || !Array.isArray(response.groups) || !response.groups.every(isEntraGroup)) {
4039
+ return {
4040
+ kind: "error",
4041
+ message: "bot returned a malformed groups response"
4042
+ };
4043
+ }
4044
+ const groups = response.groups;
4045
+ const fetchedAt = new Date(now()).toISOString();
4046
+ writeCache(cachePath, { fetchedAt, groups });
4047
+ return { kind: "ok", source: "fresh", fetchedAt, groups };
4048
+ }
4049
+ function readCache(path8) {
4050
+ let raw;
4051
+ try {
4052
+ raw = readFileSync6(path8, "utf8");
4053
+ } catch {
4054
+ return null;
4055
+ }
4056
+ let parsed;
4057
+ try {
4058
+ parsed = JSON.parse(raw);
4059
+ } catch {
4060
+ return null;
4061
+ }
4062
+ if (typeof parsed !== "object" || parsed === null)
4063
+ return null;
4064
+ const p = parsed;
4065
+ if (typeof p.fetchedAt !== "string")
4066
+ return null;
4067
+ if (!Array.isArray(p.groups))
4068
+ return null;
4069
+ for (const g of p.groups)
4070
+ if (!isEntraGroup(g))
4071
+ return null;
4072
+ return p;
4073
+ }
4074
+ function isEntraGroup(value) {
4075
+ if (typeof value !== "object" || value === null)
4076
+ return false;
4077
+ const g = value;
4078
+ return typeof g.id === "string" && typeof g.displayName === "string" && (typeof g.mailNickname === "string" || g.mailNickname === null);
4079
+ }
4080
+ function writeCache(path8, envelope) {
4081
+ try {
4082
+ mkdirSync(dirname5(path8), { recursive: true });
4083
+ writeFileSync(path8, JSON.stringify(envelope), "utf8");
4084
+ } catch {}
4085
+ }
4086
+ function describe11(e) {
4087
+ return e instanceof Error ? e.message : String(e);
4088
+ }
4089
+ var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4090
+ function isUuid2(value) {
4091
+ return UUID_PATTERN.test(value);
4092
+ }
4093
+ function findGroup(groups, nameOrId) {
4094
+ if (isUuid2(nameOrId)) {
4095
+ const lower = nameOrId.toLowerCase();
4096
+ const hit = groups.find((g) => g.id.toLowerCase() === lower);
4097
+ return hit ? { kind: "found", group: hit } : { kind: "not-found" };
4098
+ }
4099
+ const matches = groups.filter((g) => g.displayName === nameOrId || g.mailNickname !== null && g.mailNickname === nameOrId);
4100
+ if (matches.length === 0)
4101
+ return { kind: "not-found" };
4102
+ if (matches.length === 1)
4103
+ return { kind: "found", group: matches[0] };
4104
+ return { kind: "ambiguous", matches };
4105
+ }
4106
+ function searchGroups(groups, query) {
4107
+ const q = query.toLowerCase();
4108
+ return groups.filter((g) => g.displayName.toLowerCase().includes(q) || g.mailNickname !== null && g.mailNickname.toLowerCase().includes(q) || g.id.toLowerCase().includes(q));
4109
+ }
4110
+
3896
4111
  // src/watch/capabilities.ts
3897
4112
  function isUtf8(env) {
3898
4113
  const v = `${env.LC_ALL ?? ""}|${env.LC_CTYPE ?? ""}|${env.LANG ?? ""}`.toLowerCase();
@@ -4525,7 +4740,7 @@ function mapBotError(e, slug, io) {
4525
4740
  io.err(`launchpad status: ${e.message}`);
4526
4741
  return 2;
4527
4742
  }
4528
- io.err(`launchpad status failed: ${describe11(e)}`);
4743
+ io.err(`launchpad status failed: ${describe12(e)}`);
4529
4744
  return 2;
4530
4745
  }
4531
4746
  async function runStatus(args, io) {
@@ -4551,10 +4766,10 @@ async function runStatus(args, io) {
4551
4766
  }
4552
4767
  let localYaml = null;
4553
4768
  try {
4554
- localYaml = readFileSync6(parsed.file, "utf8");
4769
+ localYaml = readFileSync7(parsed.file, "utf8");
4555
4770
  } catch (e) {
4556
4771
  if (!isEnoent(e)) {
4557
- io.err(`launchpad status: cannot read local manifest at ${parsed.file}: ${describe11(e)}`);
4772
+ io.err(`launchpad status: cannot read local manifest at ${parsed.file}: ${describe12(e)}`);
4558
4773
  return 2;
4559
4774
  }
4560
4775
  }
@@ -4565,7 +4780,7 @@ async function runStatus(args, io) {
4565
4780
  const { parse: parseYaml4 } = await import("yaml");
4566
4781
  localObj = parseYaml4(localYaml);
4567
4782
  } catch (e) {
4568
- io.err(`launchpad status: ${parsed.file} is not valid YAML: ${describe11(e)}`);
4783
+ io.err(`launchpad status: ${parsed.file} is not valid YAML: ${describe12(e)}`);
4569
4784
  return 2;
4570
4785
  }
4571
4786
  const localParse = parseManifest(localObj);
@@ -4594,7 +4809,7 @@ async function runStatus(args, io) {
4594
4809
  if (e instanceof UnauthenticatedError || e instanceof ForbiddenError) {
4595
4810
  return mapBotError(e, parsed.slug, io);
4596
4811
  }
4597
- io.err(`launchpad status: live deployment state unavailable (${describe11(e)}) — ` + `the report below is from the platform manifest view only.`);
4812
+ io.err(`launchpad status: live deployment state unavailable (${describe12(e)}) — ` + `the report below is from the platform manifest view only.`);
4598
4813
  }
4599
4814
  let standingExceptions = null;
4600
4815
  try {
@@ -4603,7 +4818,7 @@ async function runStatus(args, io) {
4603
4818
  if (e instanceof UnauthenticatedError || e instanceof ForbiddenError) {
4604
4819
  return mapBotError(e, parsed.slug, io);
4605
4820
  }
4606
- io.err(`launchpad status: standing-exception inventory unavailable (${describe11(e)}).`);
4821
+ io.err(`launchpad status: standing-exception inventory unavailable (${describe12(e)}).`);
4607
4822
  }
4608
4823
  if (state.manifestYaml === null || state.manifestYaml === undefined) {
4609
4824
  const live = deployment?.liveDeployment ?? null;
@@ -4655,7 +4870,7 @@ async function runStatus(args, io) {
4655
4870
  const { parse: parseYaml4 } = await import("yaml");
4656
4871
  deployedObj = parseYaml4(state.manifestYaml);
4657
4872
  } catch (e) {
4658
- io.err(`launchpad status: deployed manifest at ${state.lastAppliedManifestSha} is not valid YAML: ${describe11(e)}`);
4873
+ io.err(`launchpad status: deployed manifest at ${state.lastAppliedManifestSha} is not valid YAML: ${describe12(e)}`);
4659
4874
  return 2;
4660
4875
  }
4661
4876
  const deployedParse = parseManifest(deployedObj);
@@ -4668,6 +4883,16 @@ async function runStatus(args, io) {
4668
4883
  }
4669
4884
  const deployed = deployedParse.manifest;
4670
4885
  const drift = computeDrift(local, deployed);
4886
+ let accessDrift;
4887
+ if (state.enforcedGroups !== null) {
4888
+ let resolver;
4889
+ try {
4890
+ resolver = await buildGroupResolver(cfg);
4891
+ } catch (e) {
4892
+ return mapBotError(e, parsed.slug, io);
4893
+ }
4894
+ accessDrift = detectAccessDrift(allowedEntraGroups(local.access), state.enforcedGroups, resolver);
4895
+ }
4671
4896
  const result = {
4672
4897
  state: drift.length === 0 ? "in_sync" : "drift",
4673
4898
  slug: parsed.slug,
@@ -4678,14 +4903,30 @@ async function runStatus(args, io) {
4678
4903
  driftFields: drift.map((d) => d.path),
4679
4904
  driftDetails: drift,
4680
4905
  ...deploymentKnown ? { deployment } : {},
4681
- ...standingExceptions !== null ? { standingExceptions } : {}
4906
+ ...standingExceptions !== null ? { standingExceptions } : {},
4907
+ ...accessDrift !== undefined ? { accessDrift } : {}
4682
4908
  };
4683
4909
  emit3(result, parsed.json, io);
4684
- if (result.state === "drift" && parsed.strict) {
4910
+ if (parsed.strict && (result.state === "drift" || accessDrift?.inSync === false)) {
4685
4911
  return 1;
4686
4912
  }
4687
4913
  return 0;
4688
4914
  }
4915
+ async function buildGroupResolver(cfg) {
4916
+ try {
4917
+ const res = await fetchGroups(cfg);
4918
+ if (res.kind !== "ok")
4919
+ return () => null;
4920
+ return (nameOrId) => {
4921
+ const hit = findGroup(res.groups, nameOrId);
4922
+ return hit.kind === "found" ? hit.group.id : null;
4923
+ };
4924
+ } catch (e) {
4925
+ if (e instanceof UnauthenticatedError || e instanceof ForbiddenError)
4926
+ throw e;
4927
+ return () => null;
4928
+ }
4929
+ }
4689
4930
  function computeDrift(local, deployed) {
4690
4931
  const diffs = [];
4691
4932
  const cmp = (path8, l, d) => {
@@ -4797,6 +5038,7 @@ function emit3(out, asJson, io) {
4797
5038
  case "in_sync":
4798
5039
  io.out(`${out.slug}: live, in sync` + (out.deployedSha ? ` (content @ ${out.deployedSha.slice(0, 7)})` : ""));
4799
5040
  surfaceHeadVsDeployed(out, io);
5041
+ surfaceAccessDrift(out, io);
4800
5042
  surfaceDeployment(out, io);
4801
5043
  surfaceExceptions(out, io);
4802
5044
  return;
@@ -4808,11 +5050,28 @@ function emit3(out, asJson, io) {
4808
5050
  io.out(` deployed: ${formatValue(d.deployed)}`);
4809
5051
  }
4810
5052
  surfaceHeadVsDeployed(out, io);
5053
+ surfaceAccessDrift(out, io);
4811
5054
  surfaceDeployment(out, io);
4812
5055
  surfaceExceptions(out, io);
4813
5056
  return;
4814
5057
  }
4815
5058
  }
5059
+ function surfaceAccessDrift(out, io) {
5060
+ const ad = out.accessDrift;
5061
+ if (ad === undefined || ad.inSync)
5062
+ return;
5063
+ io.out(" ⚠ ACCESS DRIFT — your declared groups are not what the gateway enforces:");
5064
+ if (ad.liveOnly.length > 0) {
5065
+ io.out(` enforced but NOT declared (over-grant — still reachable): ${ad.liveOnly.join(", ")}`);
5066
+ }
5067
+ if (ad.manifestOnly.length > 0) {
5068
+ io.out(` declared but NOT enforced (locked out despite the manifest): ${ad.manifestOnly.join(", ")}`);
5069
+ }
5070
+ for (const u of ad.unresolved) {
5071
+ io.out(` unresolved group (${u.side}): ${u.token}`);
5072
+ }
5073
+ io.out(" `launchpad deploy` will NOT fix this — access changes go via the gated TF route.");
5074
+ }
4816
5075
  function surfaceNoLocalManifestNote(out, io) {
4817
5076
  if (out.drift !== null)
4818
5077
  return;
@@ -5005,7 +5264,7 @@ function printUsage2(io) {
5005
5264
  ].join(`
5006
5265
  `));
5007
5266
  }
5008
- function describe11(e) {
5267
+ function describe12(e) {
5009
5268
  return e instanceof Error ? e.message : String(e);
5010
5269
  }
5011
5270
  function isEnoent(e) {
@@ -5016,10 +5275,10 @@ function isEnoent(e) {
5016
5275
  var VERIFY_MISMATCH_EXIT = 65;
5017
5276
  var NO_DEPLOYMENT_EXIT = 69;
5018
5277
  function readLocalEntrypoint(cwd) {
5019
- for (const rel of ["index.html", join8("static", "index.html")]) {
5020
- const p = join8(cwd, rel);
5278
+ for (const rel of ["index.html", join9("static", "index.html")]) {
5279
+ const p = join9(cwd, rel);
5021
5280
  if (existsSync4(p))
5022
- return new Uint8Array(readFileSync7(p));
5281
+ return new Uint8Array(readFileSync8(p));
5023
5282
  }
5024
5283
  return null;
5025
5284
  }
@@ -5253,7 +5512,7 @@ async function verifyFirstDeployServed(args, liveUrl, io, deps) {
5253
5512
 
5254
5513
  // src/commands/deploy.ts
5255
5514
  import { parse as parseYaml5 } from "yaml";
5256
- import { readFileSync as readFileSync9 } from "node:fs";
5515
+ import { readFileSync as readFileSync10 } from "node:fs";
5257
5516
 
5258
5517
  // src/deploy/git-files.ts
5259
5518
  import { spawn as spawn3 } from "node:child_process";
@@ -5694,15 +5953,15 @@ function parseDeployFlags(args) {
5694
5953
 
5695
5954
  // src/deploy/apply.ts
5696
5955
  import { existsSync as existsSync5, rmSync } from "node:fs";
5697
- import { resolve as resolvePath, join as join9 } from "node:path";
5956
+ import { resolve as resolvePath, join as join10 } from "node:path";
5698
5957
 
5699
5958
  // src/manifest/load.ts
5700
- import { readFileSync as readFileSync8 } from "node:fs";
5959
+ import { readFileSync as readFileSync9 } from "node:fs";
5701
5960
  import { parse as parseYaml4, YAMLParseError } from "yaml";
5702
5961
  function loadManifest(path8) {
5703
5962
  let raw;
5704
5963
  try {
5705
- raw = readFileSync8(path8, "utf8");
5964
+ raw = readFileSync9(path8, "utf8");
5706
5965
  } catch (err) {
5707
5966
  const e = err;
5708
5967
  if (e.code === "ENOENT") {
@@ -5871,7 +6130,7 @@ async function pollUntilApplied(args) {
5871
6130
  path: `/apps/${args.slug}/manifest/state`
5872
6131
  }, args.fetcher);
5873
6132
  } catch (e) {
5874
- args.io.err(`! state fetch failed (will retry): ${describe12(e)}`);
6133
+ args.io.err(`! state fetch failed (will retry): ${describe13(e)}`);
5875
6134
  await sleep(args.pollIntervalSec * 1000);
5876
6135
  continue;
5877
6136
  }
@@ -5924,14 +6183,14 @@ function sleep(ms) {
5924
6183
  return new Promise((res) => setTimeout(res, ms));
5925
6184
  }
5926
6185
  function deletePinIfPresent(cfg, slug, io) {
5927
- const pinPath = join9(cfg.stateDir, slug, "group.json");
6186
+ const pinPath = join10(cfg.stateDir, slug, "group.json");
5928
6187
  if (!existsSync5(pinPath))
5929
6188
  return;
5930
6189
  try {
5931
6190
  rmSync(pinPath, { force: true });
5932
6191
  io.out(` (cleaned up obsolete pin file ${pinPath})`);
5933
6192
  } catch (e) {
5934
- io.err(`! warning: failed to delete obsolete pin file ${pinPath}: ${describe12(e)}`);
6193
+ io.err(`! warning: failed to delete obsolete pin file ${pinPath}: ${describe13(e)}`);
5935
6194
  }
5936
6195
  }
5937
6196
  async function loadSlugForResume(opts, io) {
@@ -5955,7 +6214,7 @@ async function defaultPrompt(question) {
5955
6214
  rl.close();
5956
6215
  }
5957
6216
  }
5958
- function describe12(e) {
6217
+ function describe13(e) {
5959
6218
  return e instanceof Error ? e.message : String(e);
5960
6219
  }
5961
6220
  function mapHttpError(e, slug, io) {
@@ -5979,7 +6238,7 @@ function mapHttpError(e, slug, io) {
5979
6238
  io.err(`launchpad deploy --apply: ${e.message}`);
5980
6239
  return 1;
5981
6240
  }
5982
- io.err(`launchpad deploy --apply failed: ${describe12(e)}`);
6241
+ io.err(`launchpad deploy --apply failed: ${describe13(e)}`);
5983
6242
  return 2;
5984
6243
  }
5985
6244
  function renderManifestError(loaded, io) {
@@ -6021,7 +6280,7 @@ async function runDeployDryRun(opts, io, deps = {}) {
6021
6280
  try {
6022
6281
  manifestSha = (deps.gitHeadSha ?? defaultGitHeadSha)(process.cwd());
6023
6282
  } catch (e) {
6024
- const msg = `failed to resolve git HEAD in ${process.cwd()}: ${describe13(e)}`;
6283
+ const msg = `failed to resolve git HEAD in ${process.cwd()}: ${describe14(e)}`;
6025
6284
  if (opts.json) {
6026
6285
  io.out(JSON.stringify({ ok: false, kind: "git-error", message: msg }));
6027
6286
  } else {
@@ -6070,7 +6329,7 @@ function defaultGitHeadSha(cwd) {
6070
6329
  });
6071
6330
  return out.trim();
6072
6331
  }
6073
- function describe13(e) {
6332
+ function describe14(e) {
6074
6333
  return e instanceof Error ? e.message : String(e);
6075
6334
  }
6076
6335
  function mapHttpError2(e, slug, json, io) {
@@ -6454,7 +6713,7 @@ async function runDeploy(args, io) {
6454
6713
  const contentManifestPath = path8.join(process.cwd(), "launchpad.yaml");
6455
6714
  if (existsSync6(contentManifestPath)) {
6456
6715
  try {
6457
- contentManifestYaml = readFileSync9(contentManifestPath, "utf8");
6716
+ contentManifestYaml = readFileSync10(contentManifestPath, "utf8");
6458
6717
  } catch {
6459
6718
  contentManifestYaml = null;
6460
6719
  }
@@ -6528,7 +6787,7 @@ async function runDeploy(args, io) {
6528
6787
  io.err(`launchpad deploy: ${e.message}`);
6529
6788
  return 1;
6530
6789
  }
6531
- io.err(`launchpad deploy failed: ${describe14(e)}`);
6790
+ io.err(`launchpad deploy failed: ${describe15(e)}`);
6532
6791
  return 1;
6533
6792
  }
6534
6793
  }
@@ -6600,7 +6859,7 @@ function formatBytes2(n) {
6600
6859
  return `${(n / 1024).toFixed(1)}KB`;
6601
6860
  return `${(n / (1024 * 1024)).toFixed(1)}MB`;
6602
6861
  }
6603
- function describe14(e) {
6862
+ function describe15(e) {
6604
6863
  return e instanceof Error ? e.message : String(e);
6605
6864
  }
6606
6865
  function surfaceDeployExtras(body, io, slug, allowStale = false) {
@@ -6633,6 +6892,17 @@ function surfaceDeployExtras(body, io, slug, allowStale = false) {
6633
6892
  }
6634
6893
  io.out(` Full list: \`launchpad status ${slug}\`.`);
6635
6894
  }
6895
+ const adw = body.access_drift_warning;
6896
+ if (adw !== undefined) {
6897
+ io.err("warning: ACCESS DRIFT — declared groups don't match what the gateway enforces:");
6898
+ if (adw.liveOnly !== undefined && adw.liveOnly.length > 0) {
6899
+ io.err(` enforced but NOT declared (over-grant): ${adw.liveOnly.join(", ")}`);
6900
+ }
6901
+ if (adw.manifestOnly !== undefined && adw.manifestOnly.length > 0) {
6902
+ io.err(` declared but NOT enforced: ${adw.manifestOnly.join(", ")}`);
6903
+ }
6904
+ io.err(` reconcile access via the gated TF route — \`launchpad deploy\` can't (it ships content only).`);
6905
+ }
6636
6906
  }
6637
6907
  function isStaleBlock(body) {
6638
6908
  const code = body?.error;
@@ -6665,7 +6935,7 @@ async function runModelADeploy(args) {
6665
6935
  let slug;
6666
6936
  let appType = "static";
6667
6937
  try {
6668
- const manifestObj = parseYaml5(readFileSync9(manifestPath, "utf8"));
6938
+ const manifestObj = parseYaml5(readFileSync10(manifestPath, "utf8"));
6669
6939
  const metaSlug = resolveManifestSlug(manifestObj);
6670
6940
  if (metaSlug === null) {
6671
6941
  io.err(`launchpad deploy: launchpad.yaml is missing metadata.slug (v2) / metadata.name (v1). ` + `Run \`launchpad init\` again to regenerate the manifest.`);
@@ -6677,7 +6947,7 @@ async function runModelADeploy(args) {
6677
6947
  appType = dtype;
6678
6948
  }
6679
6949
  } catch (e) {
6680
- io.err(`launchpad deploy: failed to read ${manifestPath}: ${describe14(e)}`);
6950
+ io.err(`launchpad deploy: failed to read ${manifestPath}: ${describe15(e)}`);
6681
6951
  return 1;
6682
6952
  }
6683
6953
  const noVerify = args.argv.includes("--no-verify");
@@ -6707,7 +6977,7 @@ async function runModelADeploy(args) {
6707
6977
  try {
6708
6978
  cfg = loadConfig();
6709
6979
  } catch (e) {
6710
- io.err(`launchpad deploy: ${describe14(e)}`);
6980
+ io.err(`launchpad deploy: ${describe15(e)}`);
6711
6981
  return 1;
6712
6982
  }
6713
6983
  const deployBaseline = noVerify ? null : await readDeploymentBaseline(realCommitWaitDeps(cfg), slug);
@@ -6736,7 +7006,7 @@ async function runModelADeploy(args) {
6736
7006
  io.err(" run `launchpad login` to refresh your session.");
6737
7007
  return 1;
6738
7008
  }
6739
- io.err(`launchpad deploy: unexpected error: ${describe14(e)}`);
7009
+ io.err(`launchpad deploy: unexpected error: ${describe15(e)}`);
6740
7010
  return 1;
6741
7011
  }
6742
7012
  switch (result.kind) {
@@ -6769,6 +7039,23 @@ async function runModelADeploy(args) {
6769
7039
  surfaceStaleBlock(body, io);
6770
7040
  return 1;
6771
7041
  }
7042
+ if (result.status === 409 && errorCode === "access_drift") {
7043
+ io.err(`launchpad deploy: refused — ACCESS DRIFT on "${slug}".`);
7044
+ const liveOnly = Array.isArray(body.live_only) ? body.live_only : [];
7045
+ const manifestOnly = Array.isArray(body.manifest_only) ? body.manifest_only : [];
7046
+ if (liveOnly.length > 0) {
7047
+ io.err(` enforced but NOT declared (over-grant): ${liveOnly.map(String).join(", ")}`);
7048
+ }
7049
+ if (manifestOnly.length > 0) {
7050
+ io.err(` declared but NOT enforced: ${manifestOnly.map(String).join(", ")}`);
7051
+ }
7052
+ io.err("");
7053
+ io.err(" Your manifest's access groups don't match what the gateway enforces.");
7054
+ io.err(" `launchpad deploy` ships content only and cannot change access — reconcile");
7055
+ io.err(" via the gated TF route (see the access-change runbook), then redeploy.");
7056
+ io.err(" Nothing was committed by this attempt.");
7057
+ return 1;
7058
+ }
6772
7059
  io.err(`launchpad deploy: bot rejected the upload (HTTP ${result.status}, ${errorCode}).`);
6773
7060
  if (typeof body.message === "string") {
6774
7061
  io.err(` ${body.message}`);
@@ -6864,7 +7151,7 @@ async function runModelADeploy(args) {
6864
7151
  }
6865
7152
 
6866
7153
  // src/commands/redeploy.ts
6867
- import { existsSync as existsSync7, readFileSync as readFileSync10 } from "node:fs";
7154
+ import { existsSync as existsSync7, readFileSync as readFileSync11 } from "node:fs";
6868
7155
  import { resolve as resolvePath2 } from "node:path";
6869
7156
  import { parse as parseYaml6 } from "yaml";
6870
7157
  var SLUG_RE6 = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
@@ -6945,7 +7232,7 @@ function readLocalAppType(cwd, file) {
6945
7232
  if (!existsSync7(manifestPath))
6946
7233
  return null;
6947
7234
  try {
6948
- const obj = parseYaml6(readFileSync10(manifestPath, "utf8"));
7235
+ const obj = parseYaml6(readFileSync11(manifestPath, "utf8"));
6949
7236
  return coerceAppType(obj?.deployment?.type);
6950
7237
  } catch {
6951
7238
  return null;
@@ -7127,7 +7414,7 @@ async function runEnvvars(args, io) {
7127
7414
  io.err(`launchpad envvars: ${e.message}`);
7128
7415
  return 1;
7129
7416
  }
7130
- io.err(`launchpad envvars failed: ${describe15(e)}`);
7417
+ io.err(`launchpad envvars failed: ${describe16(e)}`);
7131
7418
  return 1;
7132
7419
  }
7133
7420
  }
@@ -7224,13 +7511,13 @@ function renderList(envVars, io) {
7224
7511
  io.out(fmt(row));
7225
7512
  }
7226
7513
  }
7227
- function describe15(e) {
7514
+ function describe16(e) {
7228
7515
  return e instanceof Error ? e.message : String(e);
7229
7516
  }
7230
7517
 
7231
7518
  // src/commands/generate.ts
7232
- import { mkdirSync, readFileSync as readFileSync11, writeFileSync } from "node:fs";
7233
- import { dirname as dirname5, resolve as resolve8, relative as relative3 } from "node:path";
7519
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync12, writeFileSync as writeFileSync2 } from "node:fs";
7520
+ import { dirname as dirname6, resolve as resolve8, relative as relative3 } from "node:path";
7234
7521
  var generateCommand = {
7235
7522
  name: "generate",
7236
7523
  summary: "emit derived artefacts (wrangler.toml, deploy.yml) from launchpad.yaml",
@@ -7248,7 +7535,7 @@ async function runGenerate(args, io) {
7248
7535
  if (result.kind !== "ok") {
7249
7536
  return flags.json ? renderManifestErrorJson(result, io) : renderManifestErrorHuman(result, io);
7250
7537
  }
7251
- const appRoot = dirname5(manifestPath);
7538
+ const appRoot = dirname6(manifestPath);
7252
7539
  const wranglerPath = resolve8(appRoot, "container", "wrangler.toml");
7253
7540
  const workflowPath = resolve8(appRoot, ".github", "workflows", "deploy.yml");
7254
7541
  const wranglerOut = generateWranglerToml(result.manifest);
@@ -7292,8 +7579,8 @@ function applyOne(artefact, path10, out, force) {
7292
7579
  };
7293
7580
  }
7294
7581
  try {
7295
- mkdirSync(dirname5(path10), { recursive: true });
7296
- writeFileSync(path10, out.content, "utf8");
7582
+ mkdirSync2(dirname6(path10), { recursive: true });
7583
+ writeFileSync2(path10, out.content, "utf8");
7297
7584
  return {
7298
7585
  artefact,
7299
7586
  action: {
@@ -7317,7 +7604,7 @@ function applyOne(artefact, path10, out, force) {
7317
7604
  }
7318
7605
  function readIfExists(path10) {
7319
7606
  try {
7320
- return { kind: "ok", content: readFileSync11(path10, "utf8") };
7607
+ return { kind: "ok", content: readFileSync12(path10, "utf8") };
7321
7608
  } catch (err) {
7322
7609
  const e = err;
7323
7610
  if (e.code === "ENOENT")
@@ -7527,110 +7814,6 @@ function parseFlags(args) {
7527
7814
  return { kind: "ok", file, dryRun, force, json };
7528
7815
  }
7529
7816
 
7530
- // src/groups/client.ts
7531
- import { mkdirSync as mkdirSync2, readFileSync as readFileSync12, writeFileSync as writeFileSync2 } from "node:fs";
7532
- import { dirname as dirname6, join as join11 } from "node:path";
7533
- var CACHE_TTL_MS = 60 * 60 * 1000;
7534
- var CACHE_FILENAME = "groups.json";
7535
- async function fetchGroups(cfg, opts = {}) {
7536
- const now = opts.now ?? Date.now;
7537
- const cachePath = join11(cfg.cacheDir, CACHE_FILENAME);
7538
- if (opts.forceRefresh !== true) {
7539
- const cached = readCache(cachePath);
7540
- if (cached !== null) {
7541
- const ageMs = now() - Date.parse(cached.fetchedAt);
7542
- if (Number.isFinite(ageMs) && ageMs >= 0 && ageMs < CACHE_TTL_MS) {
7543
- return {
7544
- kind: "ok",
7545
- source: "cache",
7546
- fetchedAt: cached.fetchedAt,
7547
- groups: cached.groups
7548
- };
7549
- }
7550
- }
7551
- }
7552
- let response;
7553
- try {
7554
- response = await apiJson(cfg, { path: "/groups" }, opts.fetcher);
7555
- } catch (e) {
7556
- if (e instanceof UnauthenticatedError || e instanceof ForbiddenError) {
7557
- throw e;
7558
- }
7559
- return { kind: "error", message: describe16(e) };
7560
- }
7561
- if (typeof response !== "object" || response === null || !Array.isArray(response.groups) || !response.groups.every(isEntraGroup)) {
7562
- return {
7563
- kind: "error",
7564
- message: "bot returned a malformed groups response"
7565
- };
7566
- }
7567
- const groups = response.groups;
7568
- const fetchedAt = new Date(now()).toISOString();
7569
- writeCache(cachePath, { fetchedAt, groups });
7570
- return { kind: "ok", source: "fresh", fetchedAt, groups };
7571
- }
7572
- function readCache(path10) {
7573
- let raw;
7574
- try {
7575
- raw = readFileSync12(path10, "utf8");
7576
- } catch {
7577
- return null;
7578
- }
7579
- let parsed;
7580
- try {
7581
- parsed = JSON.parse(raw);
7582
- } catch {
7583
- return null;
7584
- }
7585
- if (typeof parsed !== "object" || parsed === null)
7586
- return null;
7587
- const p = parsed;
7588
- if (typeof p.fetchedAt !== "string")
7589
- return null;
7590
- if (!Array.isArray(p.groups))
7591
- return null;
7592
- for (const g of p.groups)
7593
- if (!isEntraGroup(g))
7594
- return null;
7595
- return p;
7596
- }
7597
- function isEntraGroup(value) {
7598
- if (typeof value !== "object" || value === null)
7599
- return false;
7600
- const g = value;
7601
- return typeof g.id === "string" && typeof g.displayName === "string" && (typeof g.mailNickname === "string" || g.mailNickname === null);
7602
- }
7603
- function writeCache(path10, envelope) {
7604
- try {
7605
- mkdirSync2(dirname6(path10), { recursive: true });
7606
- writeFileSync2(path10, JSON.stringify(envelope), "utf8");
7607
- } catch {}
7608
- }
7609
- function describe16(e) {
7610
- return e instanceof Error ? e.message : String(e);
7611
- }
7612
- var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7613
- function isUuid(value) {
7614
- return UUID_PATTERN.test(value);
7615
- }
7616
- function findGroup(groups, nameOrId) {
7617
- if (isUuid(nameOrId)) {
7618
- const lower = nameOrId.toLowerCase();
7619
- const hit = groups.find((g) => g.id.toLowerCase() === lower);
7620
- return hit ? { kind: "found", group: hit } : { kind: "not-found" };
7621
- }
7622
- const matches = groups.filter((g) => g.displayName === nameOrId || g.mailNickname !== null && g.mailNickname === nameOrId);
7623
- if (matches.length === 0)
7624
- return { kind: "not-found" };
7625
- if (matches.length === 1)
7626
- return { kind: "found", group: matches[0] };
7627
- return { kind: "ambiguous", matches };
7628
- }
7629
- function searchGroups(groups, query) {
7630
- const q = query.toLowerCase();
7631
- return groups.filter((g) => g.displayName.toLowerCase().includes(q) || g.mailNickname !== null && g.mailNickname.toLowerCase().includes(q) || g.id.toLowerCase().includes(q));
7632
- }
7633
-
7634
7817
  // src/auth/jwt.ts
7635
7818
  class JwtParseError extends Error {
7636
7819
  code = "jwt_parse_error";
@@ -8048,7 +8231,7 @@ async function runResolve(args, io) {
8048
8231
  return 1;
8049
8232
  }
8050
8233
  io.out(hit.group.id);
8051
- if (!isUuid(key)) {
8234
+ if (!isUuid2(key)) {
8052
8235
  io.err(`# resolved "${key}" → ${hit.group.displayName}`);
8053
8236
  }
8054
8237
  return 0;
@@ -13720,6 +13903,52 @@ function printHelp6(io, commands) {
13720
13903
  io.out("silent until the grant session expires.");
13721
13904
  }
13722
13905
 
13906
+ // src/report/classify-fault.ts
13907
+ var PLATFORM_FAULT_EXIT_CODES = [69];
13908
+ function classifyFault(input3) {
13909
+ if (input3.exitCode === 0)
13910
+ return false;
13911
+ if (PLATFORM_FAULT_EXIT_CODES.includes(input3.exitCode))
13912
+ return true;
13913
+ const f = input3.lastFault;
13914
+ if (f === null)
13915
+ return false;
13916
+ if (f.code === "transport_error")
13917
+ return true;
13918
+ if (f.code === "api_error")
13919
+ return (f.status ?? 0) >= 500;
13920
+ return false;
13921
+ }
13922
+
13923
+ // src/report/report-nudge.ts
13924
+ function nudgeSuppressed(ctx) {
13925
+ if (ctx.env.CI)
13926
+ return true;
13927
+ if (!ctx.stderrIsTTY)
13928
+ return true;
13929
+ if (ctx.argv.includes("--json"))
13930
+ return true;
13931
+ const verb = ctx.argv[0];
13932
+ if (verb === "bug" || verb === "feature")
13933
+ return true;
13934
+ return false;
13935
+ }
13936
+ function printNudge(io) {
13937
+ io.err('This looks like a platform issue, not something you did — you can report it with `launchpad bug "<what you were trying to do>"` (the last command is attached automatically; don\'t paste secrets).');
13938
+ }
13939
+ function maybeReportNudge(io, ctx) {
13940
+ if (nudgeSuppressed(ctx))
13941
+ return;
13942
+ if (!classifyFault({ exitCode: ctx.exitCode, lastFault: ctx.lastFault }))
13943
+ return;
13944
+ printNudge(io);
13945
+ }
13946
+ function maybeCrashReportNudge(io, ctx) {
13947
+ if (nudgeSuppressed(ctx))
13948
+ return;
13949
+ printNudge(io);
13950
+ }
13951
+
13723
13952
  // src/cli.ts
13724
13953
  var io = {
13725
13954
  out: (line) => {
@@ -13737,6 +13966,13 @@ dispatch(args, io).then((code) => {
13737
13966
  const elapsedMs = Date.now() - startedAt;
13738
13967
  process.exitCode = code;
13739
13968
  notifyAfterCommand(io, args);
13969
+ maybeReportNudge(io, {
13970
+ argv: args,
13971
+ env: process.env,
13972
+ stderrIsTTY: Boolean(process.stderr.isTTY),
13973
+ exitCode: code,
13974
+ lastFault: peekFault()
13975
+ });
13740
13976
  maybeFirstRunNoticeReal(io, args);
13741
13977
  recordAfterCommandReal(args, code, elapsedMs);
13742
13978
  }).catch((err) => {
@@ -13745,6 +13981,11 @@ dispatch(args, io).then((code) => {
13745
13981
  process.stderr.write(`launchpad: unexpected error: ${msg}
13746
13982
  `);
13747
13983
  process.exitCode = 70;
13984
+ maybeCrashReportNudge(io, {
13985
+ argv: args,
13986
+ env: process.env,
13987
+ stderrIsTTY: Boolean(process.stderr.isTTY)
13988
+ });
13748
13989
  maybeFirstRunNoticeReal(io, args);
13749
13990
  recordAfterCommandReal(args, 70, elapsedMs);
13750
13991
  });