@m-kopa/launchpad-cli 0.47.2 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/bundle/orchestrate.d.ts +2 -2
  3. package/dist/bundle/orchestrate.d.ts.map +1 -1
  4. package/dist/bundle/upload.d.ts +26 -6
  5. package/dist/bundle/upload.d.ts.map +1 -1
  6. package/dist/cli.js +781 -228
  7. package/dist/commands/create.d.ts.map +1 -1
  8. package/dist/commands/deploy.d.ts.map +1 -1
  9. package/dist/commands/groups.d.ts.map +1 -1
  10. package/dist/commands/status.d.ts +17 -2
  11. package/dist/commands/status.d.ts.map +1 -1
  12. package/dist/commands/validate.d.ts.map +1 -1
  13. package/dist/deploy/access-drift-offer.d.ts +15 -16
  14. package/dist/deploy/access-drift-offer.d.ts.map +1 -1
  15. package/dist/deploy/apply.d.ts +8 -0
  16. package/dist/deploy/apply.d.ts.map +1 -1
  17. package/dist/groups/client.d.ts +3 -0
  18. package/dist/groups/client.d.ts.map +1 -1
  19. package/dist/http/api-client.d.ts +1 -1
  20. package/dist/http/api-client.d.ts.map +1 -1
  21. package/dist/http/error-envelope.d.ts +43 -0
  22. package/dist/http/error-envelope.d.ts.map +1 -0
  23. package/dist/http/errors.d.ts +15 -5
  24. package/dist/http/errors.d.ts.map +1 -1
  25. package/dist/report/fault-signal.d.ts.map +1 -1
  26. package/dist/version.d.ts +1 -1
  27. package/package.json +1 -1
  28. package/skills/launchpad-content-pr/SKILL.md +11 -14
  29. package/skills/launchpad-deploy/SKILL.md +21 -28
  30. package/skills/launchpad-deploy-status/SKILL.md +1 -1
  31. package/skills/launchpad-destroy/SKILL.md +1 -1
  32. package/skills/launchpad-identity/SKILL.md +1 -1
  33. package/skills/launchpad-onboard/SKILL.md +1 -1
  34. package/skills/launchpad-report/SKILL.md +1 -1
  35. package/skills/launchpad-status/SKILL.md +15 -14
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.47.2";
22
+ var CLI_VERSION = "0.49.0";
23
23
 
24
24
  // src/config.ts
25
25
  import * as os from "node:os";
@@ -48,6 +48,159 @@ function loadConfig(env = process.env) {
48
48
  };
49
49
  }
50
50
 
51
+ // src/http/error-envelope.ts
52
+ var MAX_TEXT_CHARS = 16384;
53
+ var MAX_PATH_CHARS = 1024;
54
+ var MAX_ARRAY_ITEMS = 100;
55
+ function decodeErrorEnvelope(input, context) {
56
+ const body = isRecord(input) ? input : {};
57
+ const omissions = [];
58
+ const endpoint = boundedOptionalString(body.endpoint, "endpoint", omissions) ?? context.endpoint;
59
+ const legacyError = boundedOptionalString(body.error, "error", omissions);
60
+ const code = boundedOptionalString(body.code, "code", omissions) ?? legacyError ?? "api_error";
61
+ const message = boundedOptionalString(body.message, "message", omissions) ?? `bot returned HTTP ${context.status}`;
62
+ const source = boundedOptionalString(body.source, "source", omissions);
63
+ const sourceSha = nullableBoundedString(body.source_sha ?? body.sourceSha, "source_sha", omissions);
64
+ const manifestSha = boundedOptionalString(body.manifest_sha ?? body.manifestSha ?? body.commit_sha ?? body.commitSha, "manifest_sha", omissions);
65
+ return {
66
+ status: context.status,
67
+ endpoint,
68
+ code,
69
+ message,
70
+ ...legacyError === undefined ? {} : { legacyError },
71
+ ...source === undefined ? {} : { source },
72
+ ...sourceSha === undefined ? {} : { sourceSha },
73
+ ...manifestSha === undefined ? {} : { manifestSha },
74
+ offendingRefs: boundedStringArray(body.offending_refs ?? body.offendingRefs, "offending_refs", omissions),
75
+ retryable: typeof body.retryable === "boolean" ? body.retryable : null,
76
+ issues: boundedIssues(body.issues, omissions),
77
+ candidates: boundedCandidates(body.candidates, omissions),
78
+ omissions
79
+ };
80
+ }
81
+ function decodeErrorText(text, context) {
82
+ try {
83
+ return decodeErrorEnvelope(JSON.parse(text), context);
84
+ } catch {
85
+ return {
86
+ status: context.status,
87
+ endpoint: context.endpoint,
88
+ code: "bad_response",
89
+ message: `bot returned a non-JSON response (${text.length} characters); ` + "the unstructured body was omitted",
90
+ offendingRefs: [],
91
+ retryable: null,
92
+ issues: [],
93
+ candidates: [],
94
+ omissions: ["response body omitted because it was not structured JSON"]
95
+ };
96
+ }
97
+ }
98
+ function formatErrorEnvelope(envelope) {
99
+ const lines = [
100
+ `bot returned HTTP ${envelope.status} for ${envelope.endpoint} ` + `[${envelope.code}]: ${envelope.message}`
101
+ ];
102
+ if (envelope.source !== undefined || envelope.sourceSha !== undefined) {
103
+ lines.push(`source: ${envelope.source ?? "unspecified"}` + (envelope.sourceSha === undefined ? "" : ` @ ${envelope.sourceSha ?? "none"}`));
104
+ }
105
+ if (envelope.manifestSha !== undefined) {
106
+ lines.push(`manifest SHA: ${envelope.manifestSha}`);
107
+ }
108
+ if (envelope.offendingRefs.length > 0) {
109
+ lines.push(`offending refs: ${envelope.offendingRefs.join(", ")}`);
110
+ }
111
+ for (const issue of envelope.issues) {
112
+ lines.push(`issue: ${issue.path}: ${issue.message}` + (issue.code === undefined ? "" : ` [${issue.code}]`));
113
+ }
114
+ for (const omission of envelope.omissions)
115
+ lines.push(`omission: ${omission}`);
116
+ if (envelope.retryable !== null) {
117
+ lines.push(`retryable: ${String(envelope.retryable)}`);
118
+ }
119
+ return lines.join(`
120
+ `);
121
+ }
122
+ function errorEnvelopeJson(envelope) {
123
+ return {
124
+ error: {
125
+ status: envelope.status,
126
+ endpoint: envelope.endpoint,
127
+ code: envelope.code,
128
+ message: envelope.message,
129
+ ...envelope.legacyError === undefined ? {} : { legacy_error: envelope.legacyError },
130
+ ...envelope.source === undefined ? {} : { source: envelope.source },
131
+ ...envelope.sourceSha === undefined ? {} : { source_sha: envelope.sourceSha },
132
+ ...envelope.manifestSha === undefined ? {} : { manifest_sha: envelope.manifestSha },
133
+ offending_refs: envelope.offendingRefs,
134
+ retryable: envelope.retryable,
135
+ issues: envelope.issues,
136
+ candidates: envelope.candidates,
137
+ omissions: envelope.omissions
138
+ }
139
+ };
140
+ }
141
+ function boundedOptionalString(value, field, omissions) {
142
+ if (typeof value !== "string")
143
+ return;
144
+ return boundString(value, field, MAX_TEXT_CHARS, omissions);
145
+ }
146
+ function nullableBoundedString(value, field, omissions) {
147
+ if (value === null)
148
+ return null;
149
+ return boundedOptionalString(value, field, omissions);
150
+ }
151
+ function boundString(value, field, maxChars, omissions) {
152
+ if (value.length <= maxChars)
153
+ return value;
154
+ const omitted = value.length - maxChars;
155
+ omissions.push(`${field}: ${omitted} trailing characters omitted`);
156
+ return `${value.slice(0, maxChars)} [${omitted} trailing characters omitted]`;
157
+ }
158
+ function boundedStringArray(value, field, omissions) {
159
+ if (!Array.isArray(value))
160
+ return [];
161
+ const kept = value.slice(0, MAX_ARRAY_ITEMS).map((entry, index) => boundString(String(entry), `${field}[${index}]`, MAX_TEXT_CHARS, omissions));
162
+ if (value.length > MAX_ARRAY_ITEMS) {
163
+ omissions.push(`${field}: ${value.length - MAX_ARRAY_ITEMS} entries omitted`);
164
+ }
165
+ return kept;
166
+ }
167
+ function boundedIssues(value, omissions) {
168
+ if (!Array.isArray(value))
169
+ return [];
170
+ const issues = value.slice(0, MAX_ARRAY_ITEMS).flatMap((entry, index) => {
171
+ if (!isRecord(entry))
172
+ return [];
173
+ return [{
174
+ path: boundString(typeof entry.path === "string" ? entry.path : "(unspecified)", `issues[${index}].path`, MAX_PATH_CHARS, omissions),
175
+ message: boundString(typeof entry.message === "string" ? entry.message : String(entry.message ?? ""), `issues[${index}].message`, MAX_TEXT_CHARS, omissions),
176
+ ...typeof entry.code === "string" ? { code: boundString(entry.code, `issues[${index}].code`, MAX_PATH_CHARS, omissions) } : {}
177
+ }];
178
+ });
179
+ if (value.length > MAX_ARRAY_ITEMS) {
180
+ omissions.push(`issues: ${value.length - MAX_ARRAY_ITEMS} entries omitted`);
181
+ }
182
+ return issues;
183
+ }
184
+ function boundedCandidates(value, omissions) {
185
+ if (!Array.isArray(value))
186
+ return [];
187
+ const candidates = value.slice(0, MAX_ARRAY_ITEMS).flatMap((entry, index) => {
188
+ if (!isRecord(entry) || typeof entry.id !== "string")
189
+ return [];
190
+ return [{
191
+ id: boundString(entry.id, `candidates[${index}].id`, MAX_PATH_CHARS, omissions),
192
+ displayName: boundString(typeof entry.displayName === "string" ? entry.displayName : "", `candidates[${index}].displayName`, MAX_TEXT_CHARS, omissions)
193
+ }];
194
+ });
195
+ if (value.length > MAX_ARRAY_ITEMS) {
196
+ omissions.push(`candidates: ${value.length - MAX_ARRAY_ITEMS} entries omitted`);
197
+ }
198
+ return candidates;
199
+ }
200
+ function isRecord(value) {
201
+ return typeof value === "object" && value !== null && !Array.isArray(value);
202
+ }
203
+
51
204
  // src/http/errors.ts
52
205
  class UnauthenticatedError extends Error {
53
206
  code = "unauthenticated";
@@ -62,12 +215,37 @@ class NotFoundError extends Error {
62
215
  }
63
216
 
64
217
  class ApiError extends Error {
65
- code = "api_error";
218
+ faultCode = "api_error";
219
+ code;
66
220
  status;
67
- constructor(message, status) {
68
- super(message);
221
+ endpoint;
222
+ source;
223
+ sourceSha;
224
+ manifestSha;
225
+ offendingRefs;
226
+ retryable;
227
+ issues;
228
+ candidates;
229
+ omissions;
230
+ envelope;
231
+ constructor(envelope) {
232
+ super(formatErrorEnvelope(envelope));
69
233
  this.name = "ApiError";
70
- this.status = status;
234
+ this.code = envelope.code;
235
+ this.status = envelope.status;
236
+ this.endpoint = envelope.endpoint;
237
+ if (envelope.source !== undefined)
238
+ this.source = envelope.source;
239
+ if (envelope.sourceSha !== undefined)
240
+ this.sourceSha = envelope.sourceSha;
241
+ if (envelope.manifestSha !== undefined)
242
+ this.manifestSha = envelope.manifestSha;
243
+ this.offendingRefs = envelope.offendingRefs;
244
+ this.retryable = envelope.retryable;
245
+ this.issues = envelope.issues;
246
+ this.candidates = envelope.candidates;
247
+ this.omissions = envelope.omissions;
248
+ this.envelope = envelope;
71
249
  }
72
250
  }
73
251
 
@@ -80,7 +258,7 @@ var last = null;
80
258
  function recordFault(err) {
81
259
  if (err === null || typeof err !== "object")
82
260
  return;
83
- const code = err.code;
261
+ const code = err.faultCode ?? err.code;
84
262
  if (typeof code !== "string")
85
263
  return;
86
264
  const status = err.status;
@@ -854,6 +1032,7 @@ function buildAuthorizationUrl(params) {
854
1032
  }
855
1033
 
856
1034
  // src/http/api-client.ts
1035
+ var MAX_ERROR_BODY_BYTES = 256 * 1024;
857
1036
  async function apiJson(cfg, opts, fetcher = fetch) {
858
1037
  const res = await apiRaw(cfg, opts, fetcher);
859
1038
  let parsed;
@@ -905,34 +1084,75 @@ async function apiRaw(cfg, opts, fetcher = fetch) {
905
1084
  throw recordedFault(new TransportError(`network error calling ${url}: ${describe7(e)}`));
906
1085
  }
907
1086
  if (res.status === 401) {
908
- const detail = await peek(res);
1087
+ const detail = await errorDetail(res, opts.path);
909
1088
  throw recordedFault(new UnauthenticatedError(`bot returned 401 for ${opts.path}: ${detail} — run \`launchpad login\``));
910
1089
  }
911
1090
  if (res.status === 403) {
912
- const detail = await peek(res);
1091
+ const detail = await errorDetail(res, opts.path);
913
1092
  throw recordedFault(new ForbiddenError(`bot returned 403 for ${opts.path}: ${detail}`));
914
1093
  }
915
1094
  if (res.status === 404) {
916
- const detail = await peek(res);
1095
+ const detail = await errorDetail(res, opts.path);
917
1096
  throw recordedFault(new NotFoundError(`bot returned 404 for ${opts.path}: ${detail}`));
918
1097
  }
919
1098
  if (!res.ok) {
920
1099
  if (opts.nonThrowingStatuses?.includes(res.status) === true) {
921
1100
  return res;
922
1101
  }
923
- const detail = await peek(res);
924
- throw recordedFault(new ApiError(`bot returned HTTP ${res.status} for ${opts.path}: ${detail}`, res.status));
1102
+ throw recordedFault(new ApiError(await readErrorEnvelope(res, opts.path)));
925
1103
  }
926
1104
  clearFault();
927
1105
  return res;
928
1106
  }
929
- async function peek(res) {
1107
+ async function readErrorText(res) {
1108
+ const reader = res.body?.getReader();
1109
+ if (reader === undefined)
1110
+ return { kind: "text", text: "" };
1111
+ const chunks = [];
1112
+ let byteLength = 0;
930
1113
  try {
931
- const text = await res.text();
932
- return text.slice(0, 200);
1114
+ while (true) {
1115
+ const { done, value } = await reader.read();
1116
+ if (done)
1117
+ break;
1118
+ byteLength += value.byteLength;
1119
+ if (byteLength > MAX_ERROR_BODY_BYTES) {
1120
+ try {
1121
+ await reader.cancel();
1122
+ } catch {}
1123
+ return { kind: "omitted" };
1124
+ }
1125
+ chunks.push(value);
1126
+ }
933
1127
  } catch {
934
- return "";
1128
+ return { kind: "text", text: "" };
1129
+ } finally {
1130
+ reader.releaseLock();
1131
+ }
1132
+ const bytes = new Uint8Array(byteLength);
1133
+ let offset = 0;
1134
+ for (const chunk of chunks) {
1135
+ bytes.set(chunk, offset);
1136
+ offset += chunk.byteLength;
935
1137
  }
1138
+ return { kind: "text", text: new TextDecoder().decode(bytes) };
1139
+ }
1140
+ async function readErrorEnvelope(res, endpoint) {
1141
+ const body = await readErrorText(res);
1142
+ if (body.kind === "text") {
1143
+ return decodeErrorText(body.text, { status: res.status, endpoint });
1144
+ }
1145
+ const omission = `response body omitted because it exceeded ${MAX_ERROR_BODY_BYTES} bytes`;
1146
+ return {
1147
+ ...decodeErrorEnvelope({
1148
+ code: "response_body_too_large",
1149
+ message: `bot returned a response body larger than ${MAX_ERROR_BODY_BYTES} bytes; ` + "the body was omitted"
1150
+ }, { status: res.status, endpoint }),
1151
+ omissions: [omission]
1152
+ };
1153
+ }
1154
+ async function errorDetail(res, endpoint) {
1155
+ return formatErrorEnvelope(await readErrorEnvelope(res, endpoint));
936
1156
  }
937
1157
  function describe7(e) {
938
1158
  return e instanceof Error ? e.message : String(e);
@@ -1404,8 +1624,6 @@ var createCommand = {
1404
1624
  };
1405
1625
  var APP_TYPES = ["static", "react", "react+api", "container"];
1406
1626
  var SLUG_RE2 = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
1407
- var GROUP_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
1408
- var GROUP_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1409
1627
  async function runCreate(args, io) {
1410
1628
  const parsed = parseArgs(args);
1411
1629
  if (typeof parsed === "string") {
@@ -1513,8 +1731,8 @@ function parseArgs(args) {
1513
1731
  return "missing required flag --allowed-group (pass at least one)";
1514
1732
  }
1515
1733
  for (const g of allowedGroups) {
1516
- if (!GROUP_KEY_RE.test(g) && !GROUP_UUID_RE.test(g)) {
1517
- return `invalid --allowed-group "${g}" expected a group key (HCL identifier, e.g. G_Product_Security) or an Entra Object-ID UUID`;
1734
+ if (g.trim().length === 0) {
1735
+ return "invalid --allowed-group: display name or Entra Object-ID UUID must be non-empty";
1518
1736
  }
1519
1737
  }
1520
1738
  return {
@@ -1531,15 +1749,14 @@ function printUsage(io) {
1531
1749
  " --slug <slug>",
1532
1750
  " --display-name <name>",
1533
1751
  ` --app-type <${APP_TYPES.join("|")}>`,
1534
- " --allowed-group <G_KEY> [--allowed-group <G_KEY> ...]",
1752
+ " --allowed-group <name-or-uuid> [--allowed-group <name-or-uuid> ...]",
1535
1753
  " [--description <text>]",
1536
1754
  "",
1537
1755
  " slugs are kebab-case (3–58 chars); they become the",
1538
1756
  " <slug>.launchpad.m-kopa.us hostname.",
1539
1757
  "",
1540
- " --allowed-group values are `local.entra_groups` map KEYS",
1541
- " from terraform/envs/prod/locals.tf (e.g. G_Product_Security),",
1542
- " NOT raw Entra UUIDs."
1758
+ " --allowed-group accepts an exact Entra display name or Object-ID UUID.",
1759
+ " The server resolves identity and enforces the app's auth-mode rules."
1543
1760
  ].join(`
1544
1761
  `));
1545
1762
  }
@@ -1707,12 +1924,13 @@ function isErrno3(e) {
1707
1924
  }
1708
1925
 
1709
1926
  // src/bundle/upload.ts
1927
+ var ACCESS_RECONCILE_EXACT_SHA_V1 = "access-reconcile-exact-sha-v1";
1710
1928
  async function uploadBundle(cfg, slug, manifestYaml, bundleTarGz, workerArtifact, baseSha, overrideStale) {
1711
1929
  const formData = new FormData;
1712
1930
  formData.append("manifest", manifestYaml);
1713
1931
  const buf = new ArrayBuffer(bundleTarGz.byteLength);
1714
1932
  new Uint8Array(buf).set(bundleTarGz);
1715
- formData.append("bundle", new Blob([buf], { type: "application/gzip" }), "bundle.tar.gz");
1933
+ formData.append("bundle", new Blob([new Uint8Array(buf)], { type: "application/gzip" }), "bundle.tar.gz");
1716
1934
  if (workerArtifact !== undefined) {
1717
1935
  const descriptor = {
1718
1936
  script: workerArtifact.script,
@@ -1729,7 +1947,8 @@ async function uploadBundle(cfg, slug, manifestYaml, bundleTarGz, workerArtifact
1729
1947
  const { accessToken } = await getValidAccessToken(cfg.sessionPath);
1730
1948
  const headers = {
1731
1949
  authorization: `Bearer ${accessToken}`,
1732
- accept: "application/json"
1950
+ accept: "application/json",
1951
+ "x-launchpad-capabilities": ACCESS_RECONCILE_EXACT_SHA_V1
1733
1952
  };
1734
1953
  if (baseSha)
1735
1954
  headers["x-launchpad-base-sha"] = baseSha;
@@ -1739,13 +1958,15 @@ async function uploadBundle(cfg, slug, manifestYaml, bundleTarGz, workerArtifact
1739
1958
  try {
1740
1959
  res = await fetch(url, { method: "POST", headers, body: formData });
1741
1960
  } catch (e) {
1961
+ const envelope = decodeErrorEnvelope({
1962
+ error: "transport_failed",
1963
+ message: `network error contacting bot: ${e.message}`,
1964
+ retryable: true
1965
+ }, { status: 0, endpoint: url });
1742
1966
  return {
1743
1967
  kind: "error",
1744
1968
  status: 0,
1745
- response: {
1746
- error: "transport_failed",
1747
- message: `network error contacting bot: ${e.message}`
1748
- }
1969
+ response: deployBundleError(envelope, {})
1749
1970
  };
1750
1971
  }
1751
1972
  const text = await res.text();
@@ -1756,14 +1977,21 @@ async function uploadBundle(cfg, slug, manifestYaml, bundleTarGz, workerArtifact
1756
1977
  return {
1757
1978
  kind: "error",
1758
1979
  status: res.status,
1759
- response: {
1760
- error: "bad_response",
1761
- message: `bot returned non-JSON body (HTTP ${res.status}): ${text.slice(0, 200)}`
1762
- }
1980
+ response: deployBundleError(decodeErrorText(text, { status: res.status, endpoint: url }), {})
1763
1981
  };
1764
1982
  }
1765
1983
  if (res.status === 202) {
1766
- return { kind: "ok", response: body };
1984
+ const parsed = parse202(body, slug);
1985
+ if (parsed !== null)
1986
+ return { kind: "ok", response: parsed };
1987
+ return {
1988
+ kind: "error",
1989
+ status: res.status,
1990
+ response: deployBundleError(decodeErrorEnvelope({
1991
+ error: "bad_response",
1992
+ message: "bot returned a malformed HTTP 202 deploy response"
1993
+ }, { status: res.status, endpoint: url }), {})
1994
+ };
1767
1995
  }
1768
1996
  if (res.status === 200 && typeof body === "object" && body !== null && body.outcome === "nothing-to-deploy") {
1769
1997
  return { kind: "ok", response: body };
@@ -1771,8 +1999,104 @@ async function uploadBundle(cfg, slug, manifestYaml, bundleTarGz, workerArtifact
1771
1999
  return {
1772
2000
  kind: "error",
1773
2001
  status: res.status,
1774
- response: body
2002
+ response: deployBundleError(decodeErrorEnvelope(body, { status: res.status, endpoint: url }), isRecord2(body) ? body : {})
2003
+ };
2004
+ }
2005
+ function parse202(body, fallbackSlug) {
2006
+ if (!isRecord2(body))
2007
+ return null;
2008
+ if (body.status === "accepted") {
2009
+ if (typeof body.commit_sha !== "string" || typeof body.repo !== "string") {
2010
+ return null;
2011
+ }
2012
+ const reconcileRaw = body.reconcile ?? body.access_reconcile;
2013
+ const reconcile = reconcileRaw === undefined ? undefined : parseReconcile(reconcileRaw);
2014
+ if (reconcile === null)
2015
+ return null;
2016
+ const accepted = {
2017
+ ...body,
2018
+ slug: typeof body.slug === "string" ? body.slug : fallbackSlug,
2019
+ message: typeof body.message === "string" ? body.message : "bundle committed; deployment is pending"
2020
+ };
2021
+ return reconcile === undefined ? accepted : { ...accepted, reconcile };
2022
+ }
2023
+ if (body.status === "provisioning_started") {
2024
+ if (typeof body.slug !== "string" || typeof body.submissionId !== "string" || typeof body.appType !== "string") {
2025
+ return null;
2026
+ }
2027
+ return {
2028
+ ...body,
2029
+ message: typeof body.message === "string" ? body.message : "provisioning started"
2030
+ };
2031
+ }
2032
+ return null;
2033
+ }
2034
+ function parseReconcile(value) {
2035
+ if (!isRecord2(value))
2036
+ return null;
2037
+ if (typeof value.id !== "string" || !isReconcileState(value.state) || typeof value.manifest_sha !== "string") {
2038
+ return null;
2039
+ }
2040
+ const pr = value.pr === undefined ? undefined : parsePr(value.pr);
2041
+ if (pr === null)
2042
+ return null;
2043
+ const outcome = {
2044
+ id: value.id,
2045
+ state: value.state,
2046
+ manifest_sha: value.manifest_sha
1775
2047
  };
2048
+ return pr === undefined ? outcome : { ...outcome, pr };
2049
+ }
2050
+ function parsePr(value) {
2051
+ if (!isRecord2(value))
2052
+ return null;
2053
+ const headSha = value.head_sha ?? value.headSha;
2054
+ if (typeof value.number !== "number" || !Number.isInteger(value.number) || typeof value.url !== "string" || typeof headSha !== "string") {
2055
+ return null;
2056
+ }
2057
+ return { number: value.number, url: value.url, head_sha: headSha };
2058
+ }
2059
+ function deployBundleError(envelope, body) {
2060
+ const pr = parsePr(body.pr);
2061
+ const violations = readViolations(body.violations);
2062
+ const findings = readFindings(body.findings);
2063
+ const liveOnly = readStringArray(body.live_only);
2064
+ const manifestOnly = readStringArray(body.manifest_only);
2065
+ const paths = readStringArray(body.paths);
2066
+ const reconcileId = body.reconcileId ?? body.reconcile_id;
2067
+ return {
2068
+ ...envelope,
2069
+ error: envelope.legacyError ?? envelope.code,
2070
+ ...violations === undefined ? {} : { violations },
2071
+ ...findings === undefined ? {} : { findings },
2072
+ ...typeof reconcileId === "string" ? { reconcileId } : {},
2073
+ ...isReconcileState(body.state) ? { state: body.state } : {},
2074
+ ...pr === null ? {} : { pr },
2075
+ ...liveOnly === undefined ? {} : { live_only: liveOnly },
2076
+ ...manifestOnly === undefined ? {} : { manifest_only: manifestOnly },
2077
+ ...typeof body.head_sha === "string" ? { head_sha: body.head_sha } : {},
2078
+ ...paths === undefined ? {} : { paths },
2079
+ ...typeof body.reason === "string" ? { reason: body.reason } : {}
2080
+ };
2081
+ }
2082
+ function readStringArray(value) {
2083
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string") ? value : undefined;
2084
+ }
2085
+ function readViolations(value) {
2086
+ if (!Array.isArray(value))
2087
+ return;
2088
+ return value.flatMap((entry) => isRecord2(entry) && typeof entry.path === "string" && typeof entry.rule === "string" && typeof entry.message === "string" ? [{ path: entry.path, rule: entry.rule, message: entry.message }] : []);
2089
+ }
2090
+ function readFindings(value) {
2091
+ if (!Array.isArray(value))
2092
+ return;
2093
+ return value.flatMap((entry) => isRecord2(entry) && typeof entry.path === "string" && typeof entry.rule === "string" ? [{ path: entry.path, rule: entry.rule }] : []);
2094
+ }
2095
+ function isReconcileState(value) {
2096
+ return value === "prepared" || value === "committed" || value === "pr_open" || value === "applied" || value === "aborted";
2097
+ }
2098
+ function isRecord2(value) {
2099
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1776
2100
  }
1777
2101
 
1778
2102
  // src/bundle/cwd-walker.ts
@@ -2225,7 +2549,8 @@ var ManifestSchema = z.object({
2225
2549
  app: AppBoundarySchema.optional(),
2226
2550
  verify: VerifySchema.optional(),
2227
2551
  confine_origin: z.boolean().optional(),
2228
- catalogue: z.object({ listed: z.boolean().default(true) }).strict().optional()
2552
+ catalogue: z.object({ listed: z.boolean().default(true) }).strict().optional(),
2553
+ sensitive: z.boolean().optional()
2229
2554
  }).strict().superRefine((m, ctx) => {
2230
2555
  const isContainer = m.deployment.type === "container";
2231
2556
  if (m.confine_origin !== undefined) {
@@ -2440,6 +2765,14 @@ function parseManifest(input) {
2440
2765
  }))
2441
2766
  };
2442
2767
  }
2768
+ // ../launchpad-engine/dist/canonical-group-id.js
2769
+ var CANONICAL_GROUP_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2770
+ function parseCanonicalGroupId(value) {
2771
+ if (!CANONICAL_GROUP_ID_PATTERN.test(value)) {
2772
+ return { kind: "invalid", value };
2773
+ }
2774
+ return { kind: "ok", id: value.toLowerCase() };
2775
+ }
2443
2776
  // ../launchpad-engine/dist/app-boundary.js
2444
2777
  var INCLUDE_EVERYTHING = ["**"];
2445
2778
  var COMMON_INCLUDE = [
@@ -2842,6 +3175,7 @@ var SpecSchema = z2.object({
2842
3175
  app: AppBoundarySchema.optional(),
2843
3176
  verify: VerifySchema.optional(),
2844
3177
  confine_origin: z2.boolean().optional(),
3178
+ sensitive: z2.boolean().optional(),
2845
3179
  catalogue: z2.object({ listed: z2.boolean().default(true) }).strict().optional(),
2846
3180
  env_vars: z2.record(z2.string().regex(ENV_NAME_REGEX, "env_vars keys must be UPPER_SNAKE_CASE"), EnvVarSchema).optional(),
2847
3181
  containers: z2.array(ContainerSchema).min(1).optional(),
@@ -3395,44 +3729,51 @@ function emitSecretStep(lines, binding) {
3395
3729
  var CF_ACCOUNT_ID = "c07cb17c9f742e8144c4828b3693ca65";
3396
3730
  var R2_S3_ENDPOINT = `https://${CF_ACCOUNT_ID}.r2.cloudflarestorage.com`;
3397
3731
  // ../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 };
3732
+ function isUuid(s) {
3733
+ return parseCanonicalGroupId(s.trim()).kind === "ok";
3419
3734
  }
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];
3735
+ function canonicalUuid(s) {
3736
+ const parsed = parseCanonicalGroupId(s.trim());
3737
+ return parsed.kind === "ok" ? parsed.id : null;
3738
+ }
3739
+ function detectCanonicalAccessDrift(declaredIds, enforcedIds) {
3740
+ const declared = new Set(declaredIds);
3741
+ const enforced = new Set(enforcedIds);
3742
+ const manifestOnly = [...declared].filter((id) => !enforced.has(id)).sort();
3743
+ const liveOnly = [...enforced].filter((id) => !declared.has(id)).sort();
3426
3744
  return {
3427
3745
  manifestOnly,
3428
3746
  liveOnly,
3429
- unresolved,
3430
- inSync: manifestOnly.length === 0 && liveOnly.length === 0 && unresolved.length === 0
3747
+ unresolved: [],
3748
+ inSync: manifestOnly.length === 0 && liveOnly.length === 0
3431
3749
  };
3432
3750
  }
3751
+ // ../launchpad-engine/dist/broad-groups.js
3752
+ var BROAD_GROUPS = [
3753
+ {
3754
+ uuid: "caf78902-35dd-4833-88ce-8f067eda6036",
3755
+ name: "S_ALL_Employees",
3756
+ note: "All employees — the broadest grant on the tenant; assigned to the Launchpad enterprise app. Granting this reaches the entire company."
3757
+ }
3758
+ ];
3759
+ function assertBroadGroupsWellFormed(list = BROAD_GROUPS) {
3760
+ const seen = new Set;
3761
+ for (const g of list) {
3762
+ const key = canonicalUuid(g.uuid);
3763
+ if (key === null || !isUuid(g.uuid)) {
3764
+ throw new Error(`broad-groups: entry '${g.name}' has a malformed UUID: '${g.uuid}'`);
3765
+ }
3766
+ if (seen.has(key)) {
3767
+ throw new Error(`broad-groups: duplicate UUID for '${g.name}': '${g.uuid}'`);
3768
+ }
3769
+ seen.add(key);
3770
+ }
3771
+ }
3772
+ assertBroadGroupsWellFormed();
3773
+ var BROAD_UUIDS = new Set(BROAD_GROUPS.map((g) => canonicalUuid(g.uuid)).filter((u) => u !== null));
3433
3774
  // ../launchpad-engine/dist/fleet-manifest.js
3434
3775
  import { z as z3 } from "zod";
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}$/;
3776
+ 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}$/;
3436
3777
  var TF_RESIDENCIES = ["per-app-workspace", "main-tf"];
3437
3778
  var APP_SHAPES = [
3438
3779
  "pages",
@@ -3459,7 +3800,7 @@ var FLEET_TIERS = [
3459
3800
  "out-of-scope"
3460
3801
  ];
3461
3802
  var DEVICE_POSTURE = ["gateway-eligible", "retained-access"];
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.");
3803
+ 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.");
3463
3804
  var FleetAppSchema = z3.object({
3464
3805
  slug: z3.string().regex(SLUG_REGEX),
3465
3806
  residency: z3.enum(TF_RESIDENCIES),
@@ -4094,23 +4435,30 @@ async function resolveGroupRefsTenant(cfg, refs, opts = {}) {
4094
4435
  method: "POST",
4095
4436
  path: "/groups/resolve",
4096
4437
  jsonBody: { refs },
4097
- nonThrowingStatuses: [400, 429, 503]
4438
+ nonThrowingStatuses: [400, 409, 422, 429, 503]
4098
4439
  }, opts.fetcher);
4099
4440
  } catch (e) {
4100
4441
  if (e instanceof UnauthenticatedError || e instanceof ForbiddenError)
4101
4442
  throw e;
4102
4443
  return { kind: "error", message: describe11(e) };
4103
4444
  }
4445
+ const text = await res.text();
4104
4446
  let data;
4105
4447
  try {
4106
- data = await res.json();
4107
- } catch (e) {
4108
- return { kind: "error", message: `bot returned a non-JSON resolve response: ${describe11(e)}` };
4448
+ data = JSON.parse(text);
4449
+ } catch {
4450
+ const envelope = decodeErrorText(text, {
4451
+ status: res.status,
4452
+ endpoint: "/groups/resolve"
4453
+ });
4454
+ return { kind: "error", message: envelope.message, envelope };
4109
4455
  }
4110
4456
  if (res.status !== 200) {
4111
- const env = data;
4112
- const msg = typeof env?.message === "string" ? env.message : typeof env?.error === "string" ? env.error : `bot returned HTTP ${res.status}`;
4113
- return { kind: "error", message: msg };
4457
+ const envelope = decodeErrorEnvelope(data, {
4458
+ status: res.status,
4459
+ endpoint: "/groups/resolve"
4460
+ });
4461
+ return { kind: "error", message: envelope.message, envelope };
4114
4462
  }
4115
4463
  const body = data;
4116
4464
  switch (body.kind) {
@@ -4142,19 +4490,6 @@ var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12
4142
4490
  function isUuid2(value) {
4143
4491
  return UUID_PATTERN.test(value);
4144
4492
  }
4145
- function findGroup(groups, nameOrId) {
4146
- if (isUuid2(nameOrId)) {
4147
- const lower = nameOrId.toLowerCase();
4148
- const hit = groups.find((g) => g.id.toLowerCase() === lower);
4149
- return hit ? { kind: "found", group: hit } : { kind: "not-found" };
4150
- }
4151
- const matches = groups.filter((g) => g.displayName === nameOrId || g.mailNickname !== null && g.mailNickname === nameOrId);
4152
- if (matches.length === 0)
4153
- return { kind: "not-found" };
4154
- if (matches.length === 1)
4155
- return { kind: "found", group: matches[0] };
4156
- return { kind: "ambiguous", matches };
4157
- }
4158
4493
  function searchGroups(groups, query) {
4159
4494
  const q = query.toLowerCase();
4160
4495
  return groups.filter((g) => g.displayName.toLowerCase().includes(q) || g.mailNickname !== null && g.mailNickname.toLowerCase().includes(q) || g.id.toLowerCase().includes(q));
@@ -4934,17 +5269,48 @@ async function runStatus(args, io) {
4934
5269
  return 2;
4935
5270
  }
4936
5271
  const deployed = deployedParse.manifest;
4937
- const drift = computeDrift(local, deployed);
5272
+ const localGroups = await resolveStatusGroups({
5273
+ cfg,
5274
+ refs: allowedEntraGroups(local.access),
5275
+ source: "local manifest",
5276
+ manifestSha: null
5277
+ });
5278
+ if (localGroups.kind === "error") {
5279
+ return renderStatusGroupError(localGroups.envelope, parsed.json, io);
5280
+ }
5281
+ const deployedGroups = await resolveStatusGroups({
5282
+ cfg,
5283
+ refs: allowedEntraGroups(deployed.access),
5284
+ source: "deployed manifest",
5285
+ manifestSha: state.lastAppliedManifestSha
5286
+ });
5287
+ if (deployedGroups.kind === "error") {
5288
+ return renderStatusGroupError(deployedGroups.envelope, parsed.json, io);
5289
+ }
5290
+ const drift = computeDrift(local, deployed, {
5291
+ local: localGroups.ids,
5292
+ deployed: deployedGroups.ids
5293
+ });
4938
5294
  let accessDrift;
4939
5295
  if (state.enforcedGroups !== null) {
4940
- let resolver;
4941
- try {
4942
- resolver = await buildGroupResolver(cfg);
4943
- } catch (e) {
4944
- return mapBotError(e, parsed.slug, io);
5296
+ const enforcedGroups = await resolveStatusGroups({
5297
+ cfg,
5298
+ refs: state.enforcedGroups,
5299
+ source: "enforced access",
5300
+ manifestSha: state.lastAppliedManifestSha
5301
+ });
5302
+ if (enforcedGroups.kind === "error") {
5303
+ return renderStatusGroupError(enforcedGroups.envelope, parsed.json, io);
4945
5304
  }
4946
- accessDrift = detectAccessDrift(allowedEntraGroups(local.access), state.enforcedGroups, resolver);
5305
+ accessDrift = detectCanonicalAccessDrift(localGroups.ids, enforcedGroups.ids);
5306
+ }
5307
+ const localAssignmentGroups = (local.auth ?? "access") === "access" ? localGroups : null;
5308
+ const deployedAssignmentGroups = (deployed.auth ?? "access") === "access" ? deployedGroups : null;
5309
+ const assignmentCheck = localAssignmentGroups === null && deployedAssignmentGroups === null ? { kind: "ok", issues: [] } : await findAssignmentIssues(cfg, localAssignmentGroups, deployedAssignmentGroups, state.lastAppliedManifestSha);
5310
+ if (assignmentCheck.kind === "error") {
5311
+ return renderStatusGroupError(assignmentCheck.envelope, parsed.json, io);
4947
5312
  }
5313
+ const groupIssues = assignmentCheck.issues;
4948
5314
  const result = {
4949
5315
  state: drift.length === 0 ? "in_sync" : "drift",
4950
5316
  slug: parsed.slug,
@@ -4956,30 +5322,154 @@ async function runStatus(args, io) {
4956
5322
  driftDetails: drift,
4957
5323
  ...deploymentKnown ? { deployment } : {},
4958
5324
  ...standingExceptions !== null ? { standingExceptions } : {},
4959
- ...accessDrift !== undefined ? { accessDrift } : {}
5325
+ ...accessDrift !== undefined ? { accessDrift } : {},
5326
+ ...groupIssues.length === 0 ? {} : { groupIssues }
4960
5327
  };
4961
5328
  emit3(result, parsed.json, io);
4962
- if (parsed.strict && (result.state === "drift" || accessDrift?.inSync === false)) {
5329
+ if (parsed.strict && (result.state === "drift" || accessDrift?.inSync === false || groupIssues.length > 0)) {
4963
5330
  return 1;
4964
5331
  }
4965
5332
  return 0;
4966
5333
  }
4967
- async function buildGroupResolver(cfg) {
5334
+ async function resolveStatusGroups(args) {
5335
+ const direct = args.refs.map((ref) => parseCanonicalGroupId(ref));
5336
+ if (direct.every((result2) => result2.kind === "ok")) {
5337
+ const ids = direct.map((result2) => result2.id);
5338
+ return {
5339
+ kind: "ok",
5340
+ refs: args.refs,
5341
+ ids,
5342
+ groups: ids.map((id) => ({ id, displayName: id }))
5343
+ };
5344
+ }
5345
+ let result;
4968
5346
  try {
4969
- const res = await fetchGroups(cfg);
4970
- if (res.kind !== "ok")
4971
- return () => null;
4972
- return (nameOrId) => {
4973
- const hit = findGroup(res.groups, nameOrId);
4974
- return hit.kind === "found" ? hit.group.id : null;
5347
+ result = await resolveGroupRefsTenant(args.cfg, args.refs);
5348
+ } catch (error) {
5349
+ const code = error instanceof UnauthenticatedError ? "unauthenticated" : error instanceof ForbiddenError ? "forbidden" : "group_resolution_failed";
5350
+ const status = error instanceof UnauthenticatedError ? 401 : error instanceof ForbiddenError ? 403 : 503;
5351
+ return {
5352
+ kind: "error",
5353
+ envelope: sourceEnvelope(args, code, describe12(error), args.refs, [], status)
5354
+ };
5355
+ }
5356
+ if (result.kind === "ok") {
5357
+ if (result.groups.length === args.refs.length) {
5358
+ const parsed = result.groups.map((group) => parseCanonicalGroupId(group.id));
5359
+ if (parsed.every((entry) => entry.kind === "ok")) {
5360
+ const groups = result.groups.map((group, index) => ({
5361
+ id: parsed[index].id,
5362
+ displayName: group.displayName
5363
+ }));
5364
+ return {
5365
+ kind: "ok",
5366
+ refs: args.refs,
5367
+ ids: groups.map((group) => group.id),
5368
+ groups
5369
+ };
5370
+ }
5371
+ }
5372
+ return {
5373
+ kind: "error",
5374
+ envelope: sourceEnvelope(args, "bad_response", "tenant group resolver returned a malformed canonical projection", args.refs)
5375
+ };
5376
+ }
5377
+ if (result.kind === "ambiguous") {
5378
+ return {
5379
+ kind: "error",
5380
+ envelope: sourceEnvelope(args, "group_ambiguous", result.message || `group '${result.ref}' is ambiguous`, [result.ref], result.candidates)
5381
+ };
5382
+ }
5383
+ if (result.kind === "unresolved") {
5384
+ return {
5385
+ kind: "error",
5386
+ envelope: sourceEnvelope(args, result.refs.some((ref) => ref.reason === "unverified") ? "group_unverified" : "group_unresolved", result.message || "one or more group references could not be resolved", result.refs.map((ref) => ref.ref))
4975
5387
  };
4976
- } catch (e) {
4977
- if (e instanceof UnauthenticatedError || e instanceof ForbiddenError)
4978
- throw e;
4979
- return () => null;
4980
5388
  }
5389
+ return {
5390
+ kind: "error",
5391
+ envelope: result.envelope === undefined ? sourceEnvelope(args, "group_resolution_failed", result.message, args.refs) : {
5392
+ ...result.envelope,
5393
+ source: args.source,
5394
+ sourceSha: args.manifestSha,
5395
+ ...args.manifestSha === null ? {} : { manifestSha: args.manifestSha }
5396
+ }
5397
+ };
5398
+ }
5399
+ function sourceEnvelope(args, code, message, offendingRefs, candidates = [], status = 422) {
5400
+ return decodeErrorEnvelope({
5401
+ error: "group_resolution_failed",
5402
+ code,
5403
+ message,
5404
+ source: args.source,
5405
+ source_sha: args.manifestSha,
5406
+ manifest_sha: args.manifestSha,
5407
+ offending_refs: offendingRefs,
5408
+ retryable: code === "group_unverified" || code === "group_resolution_failed",
5409
+ candidates
5410
+ }, { status, endpoint: "/groups/resolve" });
5411
+ }
5412
+ function renderStatusGroupError(envelope, json, io) {
5413
+ if (json) {
5414
+ io.out(JSON.stringify(errorEnvelopeJson(envelope), null, 2));
5415
+ } else {
5416
+ io.err(`launchpad status: ${formatErrorEnvelope(envelope)}`);
5417
+ }
5418
+ return 2;
4981
5419
  }
4982
- function computeDrift(local, deployed) {
5420
+ async function findAssignmentIssues(cfg, local, deployed, deployedSha) {
5421
+ const refs = [
5422
+ ...local?.refs ?? [],
5423
+ ...deployed?.refs ?? []
5424
+ ];
5425
+ let assigned;
5426
+ try {
5427
+ assigned = await fetchGroups(cfg, { forceRefresh: true });
5428
+ } catch (error) {
5429
+ const code = error instanceof UnauthenticatedError ? "unauthenticated" : error instanceof ForbiddenError ? "forbidden" : "group_assignment_lookup_failed";
5430
+ const status = error instanceof UnauthenticatedError ? 401 : error instanceof ForbiddenError ? 403 : 503;
5431
+ return {
5432
+ kind: "error",
5433
+ envelope: sourceEnvelope({
5434
+ source: "enterprise-app group assignments",
5435
+ manifestSha: deployedSha
5436
+ }, code, describe12(error), refs, [], status)
5437
+ };
5438
+ }
5439
+ if (assigned.kind !== "ok") {
5440
+ return {
5441
+ kind: "error",
5442
+ envelope: sourceEnvelope({
5443
+ source: "enterprise-app group assignments",
5444
+ manifestSha: deployedSha
5445
+ }, "group_assignment_lookup_failed", assigned.message, refs, [], 503)
5446
+ };
5447
+ }
5448
+ const assignedIds = new Set(assigned.groups.map((group) => group.id.toLowerCase()));
5449
+ const issue = (source, projection, manifestSha) => {
5450
+ const missing = projection.groups.flatMap((group, index) => assignedIds.has(group.id) ? [] : [{ ref: projection.refs[index] ?? group.id, group }]);
5451
+ if (missing.length === 0)
5452
+ return [];
5453
+ return [{
5454
+ code: "group_not_assigned",
5455
+ source,
5456
+ manifestSha,
5457
+ offendingRefs: missing.map((entry) => entry.ref),
5458
+ candidates: missing.map((entry) => ({
5459
+ id: entry.group.id,
5460
+ displayName: entry.group.displayName
5461
+ }))
5462
+ }];
5463
+ };
5464
+ return {
5465
+ kind: "ok",
5466
+ issues: [
5467
+ ...local === null ? [] : issue("local", local, null),
5468
+ ...deployed === null ? [] : issue("deployed", deployed, deployedSha)
5469
+ ]
5470
+ };
5471
+ }
5472
+ function computeDrift(local, deployed, canonicalGroups) {
4983
5473
  const diffs = [];
4984
5474
  const cmp = (path8, l, d) => {
4985
5475
  const li = l ?? null;
@@ -4999,7 +5489,7 @@ function computeDrift(local, deployed) {
4999
5489
  cmp("metadata.owner", local.metadata.owner, deployed.metadata.owner);
5000
5490
  cmp("metadata.description", local.metadata.description, deployed.metadata.description);
5001
5491
  cmp("deployment.type", local.deployment.type, deployed.deployment.type);
5002
- cmp("access.allowed_entra_groups", allowedEntraGroups(local.access).join(", "), allowedEntraGroups(deployed.access).join(", "));
5492
+ cmp("access.allowed_entra_groups", (canonicalGroups?.local ?? allowedEntraGroups(local.access)).join(", "), (canonicalGroups?.deployed ?? allowedEntraGroups(deployed.access)).join(", "));
5003
5493
  cmp("hostnames[0]", local.hostnames?.[0], deployed.hostnames?.[0]);
5004
5494
  cmp("build.command", local.build?.command, deployed.build?.command);
5005
5495
  cmp("build.destination_dir", local.build?.destination_dir, deployed.build?.destination_dir);
@@ -5091,6 +5581,7 @@ function emit3(out, asJson, io) {
5091
5581
  io.out(`${out.slug}: live, in sync` + (out.deployedSha ? ` (content @ ${out.deployedSha.slice(0, 7)})` : ""));
5092
5582
  surfaceHeadVsDeployed(out, io);
5093
5583
  surfaceAccessDrift(out, io);
5584
+ surfaceGroupIssues(out, io);
5094
5585
  surfaceDeployment(out, io);
5095
5586
  surfaceExceptions(out, io);
5096
5587
  return;
@@ -5103,6 +5594,7 @@ function emit3(out, asJson, io) {
5103
5594
  }
5104
5595
  surfaceHeadVsDeployed(out, io);
5105
5596
  surfaceAccessDrift(out, io);
5597
+ surfaceGroupIssues(out, io);
5106
5598
  surfaceDeployment(out, io);
5107
5599
  surfaceExceptions(out, io);
5108
5600
  return;
@@ -5122,8 +5614,14 @@ function surfaceAccessDrift(out, io) {
5122
5614
  for (const u of ad.unresolved) {
5123
5615
  io.out(` unresolved group (${u.side}): ${u.token}`);
5124
5616
  }
5125
- io.out(" `launchpad deploy` will NOT fix this run `launchpad deploy --apply` to");
5126
- io.out(" reconcile access through the gated TF route.");
5617
+ io.out(" `launchpad deploy` reconciles this from the exact content commit through the gated TF route.");
5618
+ }
5619
+ function surfaceGroupIssues(out, io) {
5620
+ for (const issue of out.groupIssues ?? []) {
5621
+ const source = issue.source === "deployed" && issue.manifestSha !== null ? `deployed manifest @ ${issue.manifestSha}` : "local manifest";
5622
+ io.out(` group_not_assigned (${source}): ${issue.offendingRefs.join(", ")}`);
5623
+ io.out(" auth: access requires enterprise-app assignment so Entra emits the groups claim.");
5624
+ }
5127
5625
  }
5128
5626
  function surfaceNoLocalManifestNote(out, io) {
5129
5627
  if (out.drift !== null)
@@ -5798,8 +6296,8 @@ function renderManifestDiffLines(diff, max = 20) {
5798
6296
 
5799
6297
  // src/commands/deploy-flags.ts
5800
6298
  var SLUG_RE4 = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
5801
- var GROUP_KEY_RE2 = /^[A-Za-z_][A-Za-z0-9_]*$/;
5802
- var GROUP_UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6299
+ var GROUP_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
6300
+ var GROUP_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
5803
6301
  function parseDeployFlags(args) {
5804
6302
  let mode = "content";
5805
6303
  let slug = null;
@@ -5933,7 +6431,7 @@ function parseDeployFlags(args) {
5933
6431
  const v = args[i + 1];
5934
6432
  if (v === undefined)
5935
6433
  return `missing value for ${a}`;
5936
- if (!GROUP_KEY_RE2.test(v) && !GROUP_UUID_RE2.test(v)) {
6434
+ if (!GROUP_KEY_RE.test(v) && !GROUP_UUID_RE.test(v)) {
5937
6435
  return `invalid --allowed-group "${v}" — expected a group key (HCL identifier, e.g. G_Product_Security) or an Entra Object-ID UUID`;
5938
6436
  }
5939
6437
  allowedGroups.push(v);
@@ -6291,6 +6789,14 @@ async function runDeployApply(opts, io, deps = {}) {
6291
6789
  return mapHttpError(e, slug, io);
6292
6790
  }
6293
6791
  const manifestSha = plan.manifestSha;
6792
+ if (!MANIFEST_SHA_REGEX.test(manifestSha)) {
6793
+ io.err(`launchpad deploy --apply: protocol failure from /apps/${slug}/manifest/plan: ` + `invalid manifest SHA '${String(manifestSha)}'`);
6794
+ return 2;
6795
+ }
6796
+ if (opts.atSha !== undefined && manifestSha !== opts.atSha) {
6797
+ io.err(`launchpad deploy --apply: protocol failure from /apps/${slug}/manifest/plan: ` + `requested exact manifest SHA ${opts.atSha}, received ${manifestSha}`);
6798
+ return 2;
6799
+ }
6294
6800
  io.out(`Manifest pinned @ ${manifestSha.slice(0, 7)} (managed repo provenance).`);
6295
6801
  for (const w of plan.warnings)
6296
6802
  io.err(`! ${w}`);
@@ -6311,6 +6817,9 @@ async function runDeployApply(opts, io, deps = {}) {
6311
6817
  return 0;
6312
6818
  }
6313
6819
  }
6820
+ if (opts.preopenedPr !== undefined) {
6821
+ io.out(`Server already opened exact-SHA apply PR #${opts.preopenedPr.number}; ` + "confirming it idempotently before polling.");
6822
+ }
6314
6823
  let apply;
6315
6824
  try {
6316
6825
  apply = await apiJson(cfg, {
@@ -6321,6 +6830,14 @@ async function runDeployApply(opts, io, deps = {}) {
6321
6830
  } catch (e) {
6322
6831
  return mapHttpError(e, slug, io);
6323
6832
  }
6833
+ if (apply.manifestSha !== manifestSha) {
6834
+ io.err(`launchpad deploy --apply: protocol failure from /apps/${slug}/manifest/apply: ` + `requested ${manifestSha}, received ${String(apply.manifestSha)}`);
6835
+ return 2;
6836
+ }
6837
+ if (opts.preopenedPr !== undefined && apply.prNumber !== opts.preopenedPr.number) {
6838
+ io.err(`launchpad deploy --apply: protocol failure from /apps/${slug}/manifest/apply: ` + `server-preopened PR #${opts.preopenedPr.number}, received #${apply.prNumber}`);
6839
+ return 2;
6840
+ }
6324
6841
  io.out("");
6325
6842
  io.out(`PR opened: ${apply.prUrl}`);
6326
6843
  io.out(`Branch: ${apply.branch}`);
@@ -6340,8 +6857,13 @@ async function runDeployApply(opts, io, deps = {}) {
6340
6857
  pollIntervalSec,
6341
6858
  timeoutMs
6342
6859
  });
6343
- if (outcome.kind !== "applied")
6344
- return outcomeToExit(outcome, apply.prUrl, io);
6860
+ if (outcome.kind !== "applied") {
6861
+ const exit = outcomeToExit(outcome, apply.prUrl, io);
6862
+ if (outcome.kind === "timed-out" && opts.serverRecoveryActive === true) {
6863
+ io.err(" The CLI stopped waiting, but server recovery remains active for this exact commit.");
6864
+ }
6865
+ return exit;
6866
+ }
6345
6867
  deletePinIfPresent(cfg, slug, io);
6346
6868
  io.out("");
6347
6869
  io.out(`✓ Applied ${slug}. (See ${apply.prUrl} for the merged PR.)`);
@@ -6379,10 +6901,21 @@ async function pollUntilApplied(args) {
6379
6901
  mode = "legacy";
6380
6902
  continue;
6381
6903
  }
6904
+ if (e instanceof ApiError && e.retryable === false) {
6905
+ breakDots();
6906
+ return { kind: "failed", reason: e.message };
6907
+ }
6382
6908
  args.io.err(`! apply-status fetch failed (will retry): ${describe13(e)}`);
6383
6909
  await sleep(args.pollIntervalSec * 1000);
6384
6910
  continue;
6385
6911
  }
6912
+ if (status.manifestSha !== args.manifestSha) {
6913
+ breakDots();
6914
+ return {
6915
+ kind: "transport",
6916
+ reason: `protocol failure from /apps/${args.slug}/manifest/apply-status: ` + `requested ${args.manifestSha}, received ${status.manifestSha}`
6917
+ };
6918
+ }
6386
6919
  if (status.state === "applied") {
6387
6920
  breakDots();
6388
6921
  return { kind: "applied" };
@@ -6398,7 +6931,7 @@ async function pollUntilApplied(args) {
6398
6931
  if (status.state !== lastRendered) {
6399
6932
  breakDots();
6400
6933
  if (status.state === "waiting-approval") {
6401
- args.io.out(`⏳ waiting for approval/CI — access-bearing changes need the 'allowed-groups-change: true' label from someone other than the PR author (${status.prUrl})`);
6934
+ args.io.out(`⏳ waiting for CI — the apply PR auto-merges when required checks pass (${status.prUrl})`);
6402
6935
  } else {
6403
6936
  args.io.out(`⚙ PR merged — waiting for the app's terraform apply${status.checkName !== undefined ? ` ('${status.checkName}')` : ""} …`);
6404
6937
  }
@@ -6469,6 +7002,7 @@ function outcomeToExit(outcome, prUrl, io) {
6469
7002
  }
6470
7003
  return 4;
6471
7004
  case "transport":
7005
+ err(`! apply status failed: ${outcome.reason}`);
6472
7006
  return 2;
6473
7007
  }
6474
7008
  }
@@ -6561,76 +7095,58 @@ function renderManifestError(loaded, io) {
6561
7095
  // src/deploy/access-drift-offer.ts
6562
7096
  var asStrings = (v) => Array.isArray(v) ? v.map((x) => String(x)) : [];
6563
7097
  function renderAccessDriftSummary(slug, body) {
6564
- const lines = [`launchpad deploy: refused ACCESS DRIFT on "${slug}".`];
7098
+ const lines = [`launchpad deploy: access change detected on "${slug}".`];
6565
7099
  const liveOnly = asStrings(body.live_only);
6566
7100
  const manifestOnly = asStrings(body.manifest_only);
6567
7101
  if (liveOnly.length > 0) {
6568
- lines.push(` enforced but NOT declared (over-grant): ${liveOnly.join(", ")}`);
7102
+ lines.push(` enforced but NOT declared (will be removed): ${liveOnly.join(", ")}`);
6569
7103
  }
6570
7104
  if (manifestOnly.length > 0) {
6571
- lines.push(` declared but NOT enforced: ${manifestOnly.join(", ")}`);
7105
+ lines.push(` declared but NOT enforced (will be granted): ${manifestOnly.join(", ")}`);
6572
7106
  }
6573
7107
  lines.push("");
6574
- lines.push(" Your manifest's access groups don't match what the gateway enforces.");
6575
- lines.push(" `launchpad deploy` ships content only and cannot change access.");
7108
+ lines.push(" Your manifest's access groups differ from what the gateway enforces");
7109
+ lines.push(" reconciling automatically via the gated Terraform apply (no second command needed).");
6576
7110
  return lines;
6577
7111
  }
6578
- function renderManualApplyGuidance() {
6579
- return [
6580
- " Run `launchpad deploy --apply` to reconcile — it opens the gated TF PR",
6581
- " (an access-widening change needs the allowed-groups-change label from a",
6582
- " second person) and waits for the apply verdict. Then redeploy your content.",
6583
- " Nothing was committed by this attempt."
6584
- ];
6585
- }
6586
- function isYes(answer) {
6587
- const a = answer.trim().toLowerCase();
6588
- return a === "y" || a === "yes";
6589
- }
6590
7112
  async function handleAccessDrift(params) {
6591
- const { slug, manifestPath, body, io, deps } = params;
6592
- for (const line of renderAccessDriftSummary(slug, body))
6593
- io.err(line);
6594
- if (!deps.interactive) {
6595
- for (const line of renderManualApplyGuidance())
7113
+ const { slug, manifestPath, body, io, deps, outcome } = params;
7114
+ if (outcome === undefined) {
7115
+ for (const line of renderAccessDriftSummary(slug, body))
6596
7116
  io.err(line);
6597
- return 1;
6598
- }
6599
- io.err("");
6600
- const answer = await deps.prompt("Reconcile access now via the gated apply PR? You'll review the exact plan next. [y/N] ");
6601
- if (!isYes(answer)) {
6602
- io.err("");
6603
- io.err("Skipped — no access change was made.");
6604
- for (const line of renderManualApplyGuidance())
6605
- io.err(line);
6606
- return 1;
7117
+ } else {
7118
+ io.out("");
7119
+ io.out(`Content committed at ${outcome.manifest_sha}; access now reconciles from that exact commit.`);
6607
7120
  }
6608
7121
  io.out("");
6609
- io.out("Reconciling access via `launchpad deploy --apply` …");
7122
+ io.out(outcome?.pr === undefined ? "Reconciling access automatically via the gated apply PR …" : `Waiting for server-preopened access PR #${outcome.pr.number} to apply …`);
6610
7123
  let applied = false;
6611
- const exit = await deps.runApply({ file: manifestPath, platformRepo: null, rePin: false, yes: false }, io, { onApplied: () => {
7124
+ const exit = await deps.runApply({
7125
+ file: manifestPath,
7126
+ platformRepo: null,
7127
+ rePin: false,
7128
+ yes: true,
7129
+ ...outcome === undefined ? {} : { atSha: outcome.manifest_sha },
7130
+ ...outcome?.pr === undefined ? {} : {
7131
+ preopenedPr: {
7132
+ number: outcome.pr.number,
7133
+ url: outcome.pr.url,
7134
+ headSha: outcome.pr.head_sha
7135
+ }
7136
+ },
7137
+ ...outcome === undefined ? {} : { serverRecoveryActive: true }
7138
+ }, io, { onApplied: () => {
6612
7139
  applied = true;
6613
7140
  } });
6614
7141
  if (applied) {
6615
7142
  io.out("");
6616
- io.out("✓ Access reconciled. Re-run `launchpad deploy` to ship your content change.");
7143
+ io.out(outcome === undefined ? "✓ Access reconciled." : `✓ Access applied from committed manifest ${outcome.manifest_sha}.`);
6617
7144
  return exit;
6618
7145
  }
6619
- return exit === 0 ? 1 : exit;
6620
- }
6621
-
6622
- // src/io/prompt.ts
6623
- function isInteractive() {
6624
- return Boolean(process.stdin.isTTY);
6625
- }
6626
- async function defaultPrompt2(question) {
6627
- const { createInterface } = await import("node:readline/promises");
6628
- const rl = createInterface({ input: process.stdin, output: process.stdout });
6629
- try {
6630
- return await rl.question(question);
6631
- } finally {
6632
- rl.close();
7146
+ if (outcome !== undefined && exit === 4) {
7147
+ io.err("Access is not yet applied. The CLI timed out, but server recovery remains active for the committed exact SHA.");
6633
7148
  }
7149
+ return exit === 0 ? 1 : exit;
6634
7150
  }
6635
7151
 
6636
7152
  // src/deploy/dry-run.ts
@@ -7188,9 +7704,8 @@ function deployUsage() {
7188
7704
  ` + `manifest-driven dry-run (read-only preview):
7189
7705
  ` + ` launchpad deploy --dry-run [--file <manifest>] [--json]
7190
7706
  ` + `
7191
- ` + `manifest-driven apply (runs server-side via portal-bot):
7192
- ` + ` launchpad deploy --apply [--file <manifest>] [--at <sha>] [--re-pin]
7193
- ` + " [--yes] [--resume-pr <n>] [--timeout-minutes <n>]";
7707
+ ` + "access changes reconcile automatically (ADR 0034): plain `launchpad deploy`\n" + `detects a group change vs the deployed manifest and applies it via the gated
7708
+ ` + "TF route — no separate verb. (`--apply` remains a hidden alias for one release.)";
7194
7709
  }
7195
7710
  function parseArgs3(args) {
7196
7711
  let message = null;
@@ -7284,26 +7799,24 @@ function surfaceDeployExtras(body, io, slug, allowStale = false) {
7284
7799
  if (adw.manifestOnly !== undefined && adw.manifestOnly.length > 0) {
7285
7800
  io.err(` declared but NOT enforced: ${adw.manifestOnly.join(", ")}`);
7286
7801
  }
7287
- io.err(` reconcile with \`launchpad deploy --apply\` (the gated TF route) \`launchpad deploy\` can't (it ships content only).`);
7802
+ io.err(` re-run \`launchpad deploy\` to reconcile — it applies an access change automatically via the gated TF route (ADR 0034); no separate verb needed.`);
7288
7803
  }
7289
7804
  }
7290
7805
  function isStaleBlock(body) {
7291
- const code = body?.error;
7292
- return code === "stale_overlap" || code === "stale_unverifiable";
7806
+ return body.error === "stale_overlap" || body.error === "stale_unverifiable";
7293
7807
  }
7294
7808
  function surfaceStaleBlock(body, io) {
7295
- const code = String(body.error);
7296
- const head = typeof body.head_sha === "string" ? body.head_sha : null;
7297
- if (code === "stale_overlap" && Array.isArray(body.paths)) {
7809
+ const head = body.head_sha ?? null;
7810
+ if (body.error === "stale_overlap" && body.paths !== undefined) {
7298
7811
  io.err(`launchpad deploy: BLOCKED — this deploy would roll back ${body.paths.length} file(s) a teammate changed on main since you cloned:`);
7299
- for (const p of body.paths.slice(0, 20)) {
7300
- io.err(` - ${String(p)}`);
7812
+ for (const path9 of body.paths.slice(0, 20)) {
7813
+ io.err(` - ${path9}`);
7301
7814
  }
7302
7815
  if (body.paths.length > 20) {
7303
7816
  io.err(` … and ${body.paths.length - 20} more`);
7304
7817
  }
7305
7818
  } else {
7306
- 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.`);
7819
+ io.err(`launchpad deploy: BLOCKED — main moved since you cloned and the overlap cannot be proven` + (body.reason === undefined ? "" : ` (${body.reason})`) + `; treating as unsafe.`);
7307
7820
  }
7308
7821
  io.err("");
7309
7822
  io.err(" To recover (after reconciling the listed file(s) with main):");
@@ -7469,7 +7982,7 @@ async function runModelADeploy(args) {
7469
7982
  return 1;
7470
7983
  case "upload-error": {
7471
7984
  const body = result.body;
7472
- const errorCode = typeof body.error === "string" ? body.error : "unknown_error";
7985
+ const errorCode = body.error;
7473
7986
  if (result.status === 409 && errorCode === "slug_in_flight") {
7474
7987
  io.err(`launchpad deploy: slug "${slug}" is already provisioning.`);
7475
7988
  if (typeof body.message === "string") {
@@ -7495,17 +8008,42 @@ async function runModelADeploy(args) {
7495
8008
  manifestPath,
7496
8009
  body,
7497
8010
  io,
7498
- deps: {
7499
- interactive: isInteractive(),
7500
- prompt: defaultPrompt2,
7501
- runApply: runDeployApply
7502
- }
8011
+ deps: { runApply: runDeployApply }
7503
8012
  });
7504
8013
  }
7505
- io.err(`launchpad deploy: bot rejected the upload (HTTP ${result.status}, ${errorCode}).`);
7506
- if (typeof body.message === "string") {
7507
- io.err(` ${body.message}`);
7508
- }
8014
+ if (errorCode === "reconcile_pending") {
8015
+ const outcome = pendingReconcileOutcome(body);
8016
+ if (outcome === null) {
8017
+ io.err(`launchpad deploy: ${formatErrorEnvelope(body)}`);
8018
+ io.err(" The CLI cannot identify the commit yet. Server recovery remains active; this command exits non-zero.");
8019
+ return 1;
8020
+ }
8021
+ await advanceBaseShaMarker(cwd, outcome.manifest_sha, io);
8022
+ const accessExit = await handleAccessDrift({
8023
+ slug,
8024
+ manifestPath,
8025
+ body,
8026
+ outcome,
8027
+ io,
8028
+ deps: { runApply: runDeployApply }
8029
+ });
8030
+ if (accessExit !== 0)
8031
+ return accessExit;
8032
+ io.out("");
8033
+ io.out("Committed; build pending - Cloudflare Pages is building this deploy now.");
8034
+ if (noVerify)
8035
+ return 0;
8036
+ return runDeployVerification({
8037
+ slug,
8038
+ cwd,
8039
+ appType,
8040
+ shippedSha: outcome.manifest_sha,
8041
+ baseline: deployBaseline,
8042
+ registrationBoundMs,
8043
+ timeoutMs: verifyTimeoutMs
8044
+ }, io, realVerifyDeployDeps(cfg));
8045
+ }
8046
+ io.err(`launchpad deploy: ${formatErrorEnvelope(body)}`);
7509
8047
  if (Array.isArray(body.violations)) {
7510
8048
  io.err(" AC8 violations:");
7511
8049
  for (const v of body.violations) {
@@ -7567,6 +8105,18 @@ async function runModelADeploy(args) {
7567
8105
  return 1;
7568
8106
  }
7569
8107
  await advanceBaseShaMarker(cwd, success.commit_sha, io);
8108
+ if (success.reconcile !== undefined) {
8109
+ const accessExit = await handleAccessDrift({
8110
+ slug,
8111
+ manifestPath,
8112
+ body: {},
8113
+ outcome: success.reconcile,
8114
+ io,
8115
+ deps: { runApply: runDeployApply }
8116
+ });
8117
+ if (accessExit !== 0)
8118
+ return accessExit;
8119
+ }
7570
8120
  io.out(`✓ Bundle accepted — committed as ${success.commit_sha.slice(0, 8)} on ${success.repo}`);
7571
8121
  io.out(` ${result.fileCount} files (${formatBytes2(result.compressedBytes)} gzipped)`);
7572
8122
  if (result.workerScript !== null) {
@@ -7597,6 +8147,20 @@ async function runModelADeploy(args) {
7597
8147
  }
7598
8148
  }
7599
8149
  }
8150
+ function pendingReconcileOutcome(body) {
8151
+ const manifestSha = body.manifestSha;
8152
+ const id = body.reconcileId;
8153
+ const state = body.state;
8154
+ if (typeof manifestSha !== "string" || !/^[0-9a-f]{40}$/.test(manifestSha) || typeof id !== "string" || !(state === "prepared" || state === "committed" || state === "pr_open" || state === "applied" || state === "aborted")) {
8155
+ return null;
8156
+ }
8157
+ return {
8158
+ id,
8159
+ state,
8160
+ manifest_sha: manifestSha,
8161
+ ...body.pr === undefined ? {} : { pr: body.pr }
8162
+ };
8163
+ }
7600
8164
 
7601
8165
  // src/commands/redeploy.ts
7602
8166
  import { existsSync as existsSync7, readFileSync as readFileSync11 } from "node:fs";
@@ -8507,24 +9071,7 @@ async function loadGroups(io, refresh, json) {
8507
9071
  return { kind: "error", code: 2 };
8508
9072
  }
8509
9073
  }
8510
- async function lookupTenantAware(assigned, key) {
8511
- const hit = findGroup(assigned, key);
8512
- if (hit.kind === "found") {
8513
- return {
8514
- kind: "found",
8515
- group: {
8516
- id: hit.group.id,
8517
- displayName: hit.group.displayName,
8518
- mailNickname: hit.group.mailNickname
8519
- }
8520
- };
8521
- }
8522
- if (hit.kind === "ambiguous") {
8523
- return {
8524
- kind: "ambiguous",
8525
- matches: hit.matches.map((g) => ({ id: g.id, displayName: g.displayName }))
8526
- };
8527
- }
9074
+ async function lookupTenantWide(key) {
8528
9075
  try {
8529
9076
  const r = await resolveGroupRefsTenant(loadConfig(), [key]);
8530
9077
  switch (r.kind) {
@@ -8550,7 +9097,7 @@ async function lookupTenantAware(assigned, key) {
8550
9097
  return { kind: "not-found" };
8551
9098
  }
8552
9099
  case "error":
8553
- return { kind: "error", message: r.message };
9100
+ return { kind: "error", message: r.message, ...r.envelope === undefined ? {} : { envelope: r.envelope } };
8554
9101
  }
8555
9102
  } catch (e) {
8556
9103
  if (e instanceof UnauthenticatedError)
@@ -8579,7 +9126,15 @@ function renderLookupFailure(io, json, hit) {
8579
9126
  return 3;
8580
9127
  }
8581
9128
  if (hit.kind === "error") {
8582
- renderFetchError(io, hit.message, json);
9129
+ if (hit.envelope !== undefined) {
9130
+ if (json) {
9131
+ io.out(JSON.stringify(errorEnvelopeJson(hit.envelope)));
9132
+ } else {
9133
+ io.err(`launchpad groups: ${formatErrorEnvelope(hit.envelope)}`);
9134
+ }
9135
+ } else {
9136
+ renderFetchError(io, hit.message, json);
9137
+ }
8583
9138
  return 2;
8584
9139
  }
8585
9140
  if (hit.kind === "ineligible") {
@@ -8694,10 +9249,7 @@ async function runShow(args, io) {
8694
9249
  return 64;
8695
9250
  }
8696
9251
  const key = parsed.flags.positional[0];
8697
- const load = await loadGroups(io, parsed.flags.refresh, parsed.flags.json);
8698
- if (load.kind === "error")
8699
- return load.code;
8700
- const hit = await lookupTenantAware(load.result.groups, key);
9252
+ const hit = await lookupTenantWide(key);
8701
9253
  const failure = renderLookupFailure(io, parsed.flags.json, hit);
8702
9254
  if (failure !== null)
8703
9255
  return failure;
@@ -8746,10 +9298,7 @@ async function runResolve(args, io) {
8746
9298
  return 64;
8747
9299
  }
8748
9300
  const key = parsed.flags.positional[0];
8749
- const load = await loadGroups(io, parsed.flags.refresh, parsed.flags.json);
8750
- if (load.kind === "error")
8751
- return load.code;
8752
- const hit = await lookupTenantAware(load.result.groups, key);
9301
+ const hit = await lookupTenantWide(key);
8753
9302
  const failure = renderLookupFailure(io, parsed.flags.json, hit);
8754
9303
  if (failure !== null)
8755
9304
  return failure;
@@ -8969,7 +9518,7 @@ var SLUG_REGEX3 = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
8969
9518
  var initCommand = {
8970
9519
  name: "init",
8971
9520
  summary: "scaffold a new launchpad.yaml in the current directory",
8972
- run: (args, io) => runInit(args, io, defaultPrompt3)
9521
+ run: (args, io) => runInit(args, io, defaultPrompt2)
8973
9522
  };
8974
9523
  async function runInit(args, io, prompt) {
8975
9524
  const parsed = parseArgs6(args);
@@ -9459,7 +10008,7 @@ function ensureGitignoreEntries(path10, entries) {
9459
10008
  }
9460
10009
  return added;
9461
10010
  }
9462
- async function defaultPrompt3(question, fallback) {
10011
+ async function defaultPrompt2(question, fallback) {
9463
10012
  const rl = createInterface({ input: process.stdin, output: process.stdout });
9464
10013
  try {
9465
10014
  const suffix = fallback !== undefined ? ` [${fallback}]` : "";
@@ -10140,7 +10689,7 @@ import { stdin as input, stdout as output } from "node:process";
10140
10689
  var destroyCommand = {
10141
10690
  name: "destroy",
10142
10691
  summary: "tear down a Launchpad app (owner-only, destructive)",
10143
- run: (args, io) => runDestroy(args, io, defaultPrompt4, defaultIsTty)
10692
+ run: (args, io) => runDestroy(args, io, defaultPrompt3, defaultIsTty)
10144
10693
  };
10145
10694
  var SLUG_RE10 = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
10146
10695
  var SLUG_MIN_LENGTH = 3;
@@ -10372,7 +10921,7 @@ function renderBotError2(status, env, slug, io) {
10372
10921
  io.err(`launchpad destroy: bot returned HTTP ${status} (error=${errorCode}): ${message}`);
10373
10922
  }
10374
10923
  }
10375
- async function defaultPrompt4(question) {
10924
+ async function defaultPrompt3(question) {
10376
10925
  const rl = createInterface2({ input, output });
10377
10926
  try {
10378
10927
  return await rl.question(question);
@@ -11041,7 +11590,7 @@ async function runRollback(opts, io, deps = {}) {
11041
11590
  io.out(`Rollback target: ${verifiedSha.slice(0, 12)} (${manifestRelpath})`);
11042
11591
  renderDiff(current, parsed.manifest, io);
11043
11592
  if (!opts.yes) {
11044
- const prompt = deps.prompt ?? defaultPrompt5;
11593
+ const prompt = deps.prompt ?? defaultPrompt4;
11045
11594
  if (!process.stdin.isTTY && deps.prompt === undefined) {
11046
11595
  io.err("launchpad rollback: refusing to rollback non-interactively without --yes.");
11047
11596
  return 64;
@@ -11129,7 +11678,7 @@ function formatHostnames(hs) {
11129
11678
  return hs[0];
11130
11679
  return `[${hs.join(", ")}]`;
11131
11680
  }
11132
- async function defaultPrompt5(question) {
11681
+ async function defaultPrompt4(question) {
11133
11682
  const rl = createInterface3({ input: process.stdin, output: process.stderr });
11134
11683
  try {
11135
11684
  return await rl.question(question);
@@ -11436,7 +11985,7 @@ async function offerRedeploy(slug, opts, io, deps) {
11436
11985
  const isTty = (deps.isTty ?? (() => process.stdin.isTTY === true))();
11437
11986
  let doRedeploy = wantsAuto;
11438
11987
  if (!wantsAuto && isTty) {
11439
- const prompt = deps.prompt ?? defaultPrompt6;
11988
+ const prompt = deps.prompt ?? defaultPrompt5;
11440
11989
  let answer;
11441
11990
  try {
11442
11991
  answer = (await prompt(`Redeploy ${slug} now to activate? [y/N] `)).trim().toLowerCase();
@@ -11457,7 +12006,7 @@ async function offerRedeploy(slug, opts, io, deps) {
11457
12006
  };
11458
12007
  return run({ slug, file: opts.file ?? null, verify: true, verifyTimeoutMs: 180000 }, io, rdeps);
11459
12008
  }
11460
- async function defaultPrompt6(question) {
12009
+ async function defaultPrompt5(question) {
11461
12010
  const rl = createInterface4({ input: input2, output: output2 });
11462
12011
  try {
11463
12012
  return await rl.question(question);
@@ -13025,7 +13574,11 @@ async function checkGroup(allowedGroup, cfg) {
13025
13574
  }
13026
13575
  return { kind: "not-found", key: allowedGroup };
13027
13576
  case "error":
13028
- return { kind: "network", message: r.message };
13577
+ return {
13578
+ kind: "network",
13579
+ message: r.message,
13580
+ ...r.envelope === undefined ? {} : { envelope: r.envelope }
13581
+ };
13029
13582
  }
13030
13583
  } catch (e) {
13031
13584
  if (e instanceof UnauthenticatedError) {
@@ -13100,7 +13653,7 @@ function renderHumanOk(result, group, boundary, io) {
13100
13653
  io.err(` The session is valid but lacks permission for /groups; talk to the platform team.`);
13101
13654
  return 3;
13102
13655
  case "network":
13103
- io.err(`✗ --strict-groups failed to reach the bot: ${group.message}`);
13656
+ io.err(group.envelope === undefined ? `✗ --strict-groups failed to reach the bot: ${group.message}` : `✗ --strict-groups: ${formatErrorEnvelope(group.envelope)}`);
13104
13657
  return 2;
13105
13658
  }
13106
13659
  }
@@ -13176,11 +13729,11 @@ function renderJsonOk(result, group, boundary, io) {
13176
13729
  }));
13177
13730
  return 3;
13178
13731
  case "network":
13179
- io.out(JSON.stringify({
13732
+ io.out(JSON.stringify(group.envelope === undefined ? {
13180
13733
  ok: false,
13181
13734
  ...base,
13182
13735
  group: { kind: "network", message: group.message }
13183
- }));
13736
+ } : { ok: false, ...base, ...errorEnvelopeJson(group.envelope) }));
13184
13737
  return 2;
13185
13738
  }
13186
13739
  }