@m-kopa/launchpad-cli 0.48.0 → 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 +32 -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 +738 -168
  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 +3 -0
  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 +10 -14
  29. package/skills/launchpad-deploy/SKILL.md +19 -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 +11 -9
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.48.0";
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;
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 });
935
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,9 +1999,105 @@ 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 : {})
1775
2003
  };
1776
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
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);
2100
+ }
1777
2101
 
1778
2102
  // src/bundle/cwd-walker.ts
1779
2103
  import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
@@ -2441,6 +2765,14 @@ function parseManifest(input) {
2441
2765
  }))
2442
2766
  };
2443
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
+ }
2444
2776
  // ../launchpad-engine/dist/app-boundary.js
2445
2777
  var INCLUDE_EVERYTHING = ["**"];
2446
2778
  var COMMON_INCLUDE = [
@@ -3397,42 +3729,23 @@ function emitSecretStep(lines, binding) {
3397
3729
  var CF_ACCOUNT_ID = "c07cb17c9f742e8144c4828b3693ca65";
3398
3730
  var R2_S3_ENDPOINT = `https://${CF_ACCOUNT_ID}.r2.cloudflarestorage.com`;
3399
3731
  // ../launchpad-engine/dist/access-drift.js
3400
- var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
3401
3732
  function isUuid(s) {
3402
- return UUID_RE.test(s.trim());
3733
+ return parseCanonicalGroupId(s.trim()).kind === "ok";
3403
3734
  }
3404
3735
  function canonicalUuid(s) {
3405
- const t = s.trim();
3406
- return UUID_RE.test(t) ? t.toLowerCase() : null;
3407
- }
3408
- function normalise(tokens, side, resolve6) {
3409
- const uuids = new Set;
3410
- const unresolved = [];
3411
- for (const raw of tokens) {
3412
- const direct = canonicalUuid(raw);
3413
- const uuid = direct ?? (() => {
3414
- const resolved = resolve6(raw);
3415
- return resolved === null ? null : canonicalUuid(resolved);
3416
- })();
3417
- if (uuid === null) {
3418
- unresolved.push({ side, token: raw });
3419
- } else {
3420
- uuids.add(uuid);
3421
- }
3422
- }
3423
- return { uuids, unresolved };
3424
- }
3425
- function detectAccessDrift(declaredTokens, enforcedTokens, resolve6) {
3426
- const declared = normalise(declaredTokens, "manifest", resolve6);
3427
- const enforced = normalise(enforcedTokens, "enforced", resolve6);
3428
- const manifestOnly = [...declared.uuids].filter((u) => !enforced.uuids.has(u)).sort();
3429
- const liveOnly = [...enforced.uuids].filter((u) => !declared.uuids.has(u)).sort();
3430
- const unresolved = [...declared.unresolved, ...enforced.unresolved];
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();
3431
3744
  return {
3432
3745
  manifestOnly,
3433
3746
  liveOnly,
3434
- unresolved,
3435
- inSync: manifestOnly.length === 0 && liveOnly.length === 0 && unresolved.length === 0
3747
+ unresolved: [],
3748
+ inSync: manifestOnly.length === 0 && liveOnly.length === 0
3436
3749
  };
3437
3750
  }
3438
3751
  // ../launchpad-engine/dist/broad-groups.js
@@ -3460,7 +3773,7 @@ assertBroadGroupsWellFormed();
3460
3773
  var BROAD_UUIDS = new Set(BROAD_GROUPS.map((g) => canonicalUuid(g.uuid)).filter((u) => u !== null));
3461
3774
  // ../launchpad-engine/dist/fleet-manifest.js
3462
3775
  import { z as z3 } from "zod";
3463
- 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}$/;
3464
3777
  var TF_RESIDENCIES = ["per-app-workspace", "main-tf"];
3465
3778
  var APP_SHAPES = [
3466
3779
  "pages",
@@ -3487,7 +3800,7 @@ var FLEET_TIERS = [
3487
3800
  "out-of-scope"
3488
3801
  ];
3489
3802
  var DEVICE_POSTURE = ["gateway-eligible", "retained-access"];
3490
- 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.");
3491
3804
  var FleetAppSchema = z3.object({
3492
3805
  slug: z3.string().regex(SLUG_REGEX),
3493
3806
  residency: z3.enum(TF_RESIDENCIES),
@@ -4122,23 +4435,30 @@ async function resolveGroupRefsTenant(cfg, refs, opts = {}) {
4122
4435
  method: "POST",
4123
4436
  path: "/groups/resolve",
4124
4437
  jsonBody: { refs },
4125
- nonThrowingStatuses: [400, 429, 503]
4438
+ nonThrowingStatuses: [400, 409, 422, 429, 503]
4126
4439
  }, opts.fetcher);
4127
4440
  } catch (e) {
4128
4441
  if (e instanceof UnauthenticatedError || e instanceof ForbiddenError)
4129
4442
  throw e;
4130
4443
  return { kind: "error", message: describe11(e) };
4131
4444
  }
4445
+ const text = await res.text();
4132
4446
  let data;
4133
4447
  try {
4134
- data = await res.json();
4135
- } catch (e) {
4136
- 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 };
4137
4455
  }
4138
4456
  if (res.status !== 200) {
4139
- const env = data;
4140
- const msg = typeof env?.message === "string" ? env.message : typeof env?.error === "string" ? env.error : `bot returned HTTP ${res.status}`;
4141
- 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 };
4142
4462
  }
4143
4463
  const body = data;
4144
4464
  switch (body.kind) {
@@ -4170,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
4170
4490
  function isUuid2(value) {
4171
4491
  return UUID_PATTERN.test(value);
4172
4492
  }
4173
- function findGroup(groups, nameOrId) {
4174
- if (isUuid2(nameOrId)) {
4175
- const lower = nameOrId.toLowerCase();
4176
- const hit = groups.find((g) => g.id.toLowerCase() === lower);
4177
- return hit ? { kind: "found", group: hit } : { kind: "not-found" };
4178
- }
4179
- const matches = groups.filter((g) => g.displayName === nameOrId || g.mailNickname !== null && g.mailNickname === nameOrId);
4180
- if (matches.length === 0)
4181
- return { kind: "not-found" };
4182
- if (matches.length === 1)
4183
- return { kind: "found", group: matches[0] };
4184
- return { kind: "ambiguous", matches };
4185
- }
4186
4493
  function searchGroups(groups, query) {
4187
4494
  const q = query.toLowerCase();
4188
4495
  return groups.filter((g) => g.displayName.toLowerCase().includes(q) || g.mailNickname !== null && g.mailNickname.toLowerCase().includes(q) || g.id.toLowerCase().includes(q));
@@ -4962,17 +5269,48 @@ async function runStatus(args, io) {
4962
5269
  return 2;
4963
5270
  }
4964
5271
  const deployed = deployedParse.manifest;
4965
- 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
+ });
4966
5294
  let accessDrift;
4967
5295
  if (state.enforcedGroups !== null) {
4968
- let resolver;
4969
- try {
4970
- resolver = await buildGroupResolver(cfg);
4971
- } catch (e) {
4972
- 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);
4973
5304
  }
4974
- accessDrift = detectAccessDrift(allowedEntraGroups(local.access), state.enforcedGroups, resolver);
5305
+ accessDrift = detectCanonicalAccessDrift(localGroups.ids, enforcedGroups.ids);
4975
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);
5312
+ }
5313
+ const groupIssues = assignmentCheck.issues;
4976
5314
  const result = {
4977
5315
  state: drift.length === 0 ? "in_sync" : "drift",
4978
5316
  slug: parsed.slug,
@@ -4984,30 +5322,154 @@ async function runStatus(args, io) {
4984
5322
  driftDetails: drift,
4985
5323
  ...deploymentKnown ? { deployment } : {},
4986
5324
  ...standingExceptions !== null ? { standingExceptions } : {},
4987
- ...accessDrift !== undefined ? { accessDrift } : {}
5325
+ ...accessDrift !== undefined ? { accessDrift } : {},
5326
+ ...groupIssues.length === 0 ? {} : { groupIssues }
4988
5327
  };
4989
5328
  emit3(result, parsed.json, io);
4990
- if (parsed.strict && (result.state === "drift" || accessDrift?.inSync === false)) {
5329
+ if (parsed.strict && (result.state === "drift" || accessDrift?.inSync === false || groupIssues.length > 0)) {
4991
5330
  return 1;
4992
5331
  }
4993
5332
  return 0;
4994
5333
  }
4995
- 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;
5346
+ try {
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))
5387
+ };
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;
5419
+ }
5420
+ async function findAssignmentIssues(cfg, local, deployed, deployedSha) {
5421
+ const refs = [
5422
+ ...local?.refs ?? [],
5423
+ ...deployed?.refs ?? []
5424
+ ];
5425
+ let assigned;
4996
5426
  try {
4997
- const res = await fetchGroups(cfg);
4998
- if (res.kind !== "ok")
4999
- return () => null;
5000
- return (nameOrId) => {
5001
- const hit = findGroup(res.groups, nameOrId);
5002
- return hit.kind === "found" ? hit.group.id : null;
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)
5003
5446
  };
5004
- } catch (e) {
5005
- if (e instanceof UnauthenticatedError || e instanceof ForbiddenError)
5006
- throw e;
5007
- return () => null;
5008
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
+ };
5009
5471
  }
5010
- function computeDrift(local, deployed) {
5472
+ function computeDrift(local, deployed, canonicalGroups) {
5011
5473
  const diffs = [];
5012
5474
  const cmp = (path8, l, d) => {
5013
5475
  const li = l ?? null;
@@ -5027,7 +5489,7 @@ function computeDrift(local, deployed) {
5027
5489
  cmp("metadata.owner", local.metadata.owner, deployed.metadata.owner);
5028
5490
  cmp("metadata.description", local.metadata.description, deployed.metadata.description);
5029
5491
  cmp("deployment.type", local.deployment.type, deployed.deployment.type);
5030
- 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(", "));
5031
5493
  cmp("hostnames[0]", local.hostnames?.[0], deployed.hostnames?.[0]);
5032
5494
  cmp("build.command", local.build?.command, deployed.build?.command);
5033
5495
  cmp("build.destination_dir", local.build?.destination_dir, deployed.build?.destination_dir);
@@ -5119,6 +5581,7 @@ function emit3(out, asJson, io) {
5119
5581
  io.out(`${out.slug}: live, in sync` + (out.deployedSha ? ` (content @ ${out.deployedSha.slice(0, 7)})` : ""));
5120
5582
  surfaceHeadVsDeployed(out, io);
5121
5583
  surfaceAccessDrift(out, io);
5584
+ surfaceGroupIssues(out, io);
5122
5585
  surfaceDeployment(out, io);
5123
5586
  surfaceExceptions(out, io);
5124
5587
  return;
@@ -5131,6 +5594,7 @@ function emit3(out, asJson, io) {
5131
5594
  }
5132
5595
  surfaceHeadVsDeployed(out, io);
5133
5596
  surfaceAccessDrift(out, io);
5597
+ surfaceGroupIssues(out, io);
5134
5598
  surfaceDeployment(out, io);
5135
5599
  surfaceExceptions(out, io);
5136
5600
  return;
@@ -5150,8 +5614,14 @@ function surfaceAccessDrift(out, io) {
5150
5614
  for (const u of ad.unresolved) {
5151
5615
  io.out(` unresolved group (${u.side}): ${u.token}`);
5152
5616
  }
5153
- io.out(" `launchpad deploy` will NOT fix this run `launchpad deploy --apply` to");
5154
- 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
+ }
5155
5625
  }
5156
5626
  function surfaceNoLocalManifestNote(out, io) {
5157
5627
  if (out.drift !== null)
@@ -5826,8 +6296,8 @@ function renderManifestDiffLines(diff, max = 20) {
5826
6296
 
5827
6297
  // src/commands/deploy-flags.ts
5828
6298
  var SLUG_RE4 = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
5829
- var GROUP_KEY_RE2 = /^[A-Za-z_][A-Za-z0-9_]*$/;
5830
- 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;
5831
6301
  function parseDeployFlags(args) {
5832
6302
  let mode = "content";
5833
6303
  let slug = null;
@@ -5961,7 +6431,7 @@ function parseDeployFlags(args) {
5961
6431
  const v = args[i + 1];
5962
6432
  if (v === undefined)
5963
6433
  return `missing value for ${a}`;
5964
- if (!GROUP_KEY_RE2.test(v) && !GROUP_UUID_RE2.test(v)) {
6434
+ if (!GROUP_KEY_RE.test(v) && !GROUP_UUID_RE.test(v)) {
5965
6435
  return `invalid --allowed-group "${v}" — expected a group key (HCL identifier, e.g. G_Product_Security) or an Entra Object-ID UUID`;
5966
6436
  }
5967
6437
  allowedGroups.push(v);
@@ -6319,6 +6789,14 @@ async function runDeployApply(opts, io, deps = {}) {
6319
6789
  return mapHttpError(e, slug, io);
6320
6790
  }
6321
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
+ }
6322
6800
  io.out(`Manifest pinned @ ${manifestSha.slice(0, 7)} (managed repo provenance).`);
6323
6801
  for (const w of plan.warnings)
6324
6802
  io.err(`! ${w}`);
@@ -6339,6 +6817,9 @@ async function runDeployApply(opts, io, deps = {}) {
6339
6817
  return 0;
6340
6818
  }
6341
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
+ }
6342
6823
  let apply;
6343
6824
  try {
6344
6825
  apply = await apiJson(cfg, {
@@ -6349,6 +6830,14 @@ async function runDeployApply(opts, io, deps = {}) {
6349
6830
  } catch (e) {
6350
6831
  return mapHttpError(e, slug, io);
6351
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
+ }
6352
6841
  io.out("");
6353
6842
  io.out(`PR opened: ${apply.prUrl}`);
6354
6843
  io.out(`Branch: ${apply.branch}`);
@@ -6368,8 +6857,13 @@ async function runDeployApply(opts, io, deps = {}) {
6368
6857
  pollIntervalSec,
6369
6858
  timeoutMs
6370
6859
  });
6371
- if (outcome.kind !== "applied")
6372
- 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
+ }
6373
6867
  deletePinIfPresent(cfg, slug, io);
6374
6868
  io.out("");
6375
6869
  io.out(`✓ Applied ${slug}. (See ${apply.prUrl} for the merged PR.)`);
@@ -6407,10 +6901,21 @@ async function pollUntilApplied(args) {
6407
6901
  mode = "legacy";
6408
6902
  continue;
6409
6903
  }
6904
+ if (e instanceof ApiError && e.retryable === false) {
6905
+ breakDots();
6906
+ return { kind: "failed", reason: e.message };
6907
+ }
6410
6908
  args.io.err(`! apply-status fetch failed (will retry): ${describe13(e)}`);
6411
6909
  await sleep(args.pollIntervalSec * 1000);
6412
6910
  continue;
6413
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
+ }
6414
6919
  if (status.state === "applied") {
6415
6920
  breakDots();
6416
6921
  return { kind: "applied" };
@@ -6426,7 +6931,7 @@ async function pollUntilApplied(args) {
6426
6931
  if (status.state !== lastRendered) {
6427
6932
  breakDots();
6428
6933
  if (status.state === "waiting-approval") {
6429
- 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})`);
6430
6935
  } else {
6431
6936
  args.io.out(`⚙ PR merged — waiting for the app's terraform apply${status.checkName !== undefined ? ` ('${status.checkName}')` : ""} …`);
6432
6937
  }
@@ -6497,6 +7002,7 @@ function outcomeToExit(outcome, prUrl, io) {
6497
7002
  }
6498
7003
  return 4;
6499
7004
  case "transport":
7005
+ err(`! apply status failed: ${outcome.reason}`);
6500
7006
  return 2;
6501
7007
  }
6502
7008
  }
@@ -6604,20 +7110,42 @@ function renderAccessDriftSummary(slug, body) {
6604
7110
  return lines;
6605
7111
  }
6606
7112
  async function handleAccessDrift(params) {
6607
- const { slug, manifestPath, body, io, deps } = params;
6608
- for (const line of renderAccessDriftSummary(slug, body))
6609
- io.err(line);
7113
+ const { slug, manifestPath, body, io, deps, outcome } = params;
7114
+ if (outcome === undefined) {
7115
+ for (const line of renderAccessDriftSummary(slug, body))
7116
+ io.err(line);
7117
+ } else {
7118
+ io.out("");
7119
+ io.out(`Content committed at ${outcome.manifest_sha}; access now reconciles from that exact commit.`);
7120
+ }
6610
7121
  io.out("");
6611
- io.out("Reconciling access automatically via the gated apply PR (no second command needed) …");
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 …`);
6612
7123
  let applied = false;
6613
- const exit = await deps.runApply({ file: manifestPath, platformRepo: null, rePin: false, yes: true }, 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: () => {
6614
7139
  applied = true;
6615
7140
  } });
6616
7141
  if (applied) {
6617
7142
  io.out("");
6618
- 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}.`);
6619
7144
  return exit;
6620
7145
  }
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.");
7148
+ }
6621
7149
  return exit === 0 ? 1 : exit;
6622
7150
  }
6623
7151
 
@@ -7275,22 +7803,20 @@ function surfaceDeployExtras(body, io, slug, allowStale = false) {
7275
7803
  }
7276
7804
  }
7277
7805
  function isStaleBlock(body) {
7278
- const code = body?.error;
7279
- return code === "stale_overlap" || code === "stale_unverifiable";
7806
+ return body.error === "stale_overlap" || body.error === "stale_unverifiable";
7280
7807
  }
7281
7808
  function surfaceStaleBlock(body, io) {
7282
- const code = String(body.error);
7283
- const head = typeof body.head_sha === "string" ? body.head_sha : null;
7284
- if (code === "stale_overlap" && Array.isArray(body.paths)) {
7809
+ const head = body.head_sha ?? null;
7810
+ if (body.error === "stale_overlap" && body.paths !== undefined) {
7285
7811
  io.err(`launchpad deploy: BLOCKED — this deploy would roll back ${body.paths.length} file(s) a teammate changed on main since you cloned:`);
7286
- for (const p of body.paths.slice(0, 20)) {
7287
- io.err(` - ${String(p)}`);
7812
+ for (const path9 of body.paths.slice(0, 20)) {
7813
+ io.err(` - ${path9}`);
7288
7814
  }
7289
7815
  if (body.paths.length > 20) {
7290
7816
  io.err(` … and ${body.paths.length - 20} more`);
7291
7817
  }
7292
7818
  } else {
7293
- 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.`);
7294
7820
  }
7295
7821
  io.err("");
7296
7822
  io.err(" To recover (after reconciling the listed file(s) with main):");
@@ -7456,7 +7982,7 @@ async function runModelADeploy(args) {
7456
7982
  return 1;
7457
7983
  case "upload-error": {
7458
7984
  const body = result.body;
7459
- const errorCode = typeof body.error === "string" ? body.error : "unknown_error";
7985
+ const errorCode = body.error;
7460
7986
  if (result.status === 409 && errorCode === "slug_in_flight") {
7461
7987
  io.err(`launchpad deploy: slug "${slug}" is already provisioning.`);
7462
7988
  if (typeof body.message === "string") {
@@ -7485,10 +8011,39 @@ async function runModelADeploy(args) {
7485
8011
  deps: { runApply: runDeployApply }
7486
8012
  });
7487
8013
  }
7488
- io.err(`launchpad deploy: bot rejected the upload (HTTP ${result.status}, ${errorCode}).`);
7489
- if (typeof body.message === "string") {
7490
- io.err(` ${body.message}`);
7491
- }
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)}`);
7492
8047
  if (Array.isArray(body.violations)) {
7493
8048
  io.err(" AC8 violations:");
7494
8049
  for (const v of body.violations) {
@@ -7550,6 +8105,18 @@ async function runModelADeploy(args) {
7550
8105
  return 1;
7551
8106
  }
7552
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
+ }
7553
8120
  io.out(`✓ Bundle accepted — committed as ${success.commit_sha.slice(0, 8)} on ${success.repo}`);
7554
8121
  io.out(` ${result.fileCount} files (${formatBytes2(result.compressedBytes)} gzipped)`);
7555
8122
  if (result.workerScript !== null) {
@@ -7580,6 +8147,20 @@ async function runModelADeploy(args) {
7580
8147
  }
7581
8148
  }
7582
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
+ }
7583
8164
 
7584
8165
  // src/commands/redeploy.ts
7585
8166
  import { existsSync as existsSync7, readFileSync as readFileSync11 } from "node:fs";
@@ -8490,24 +9071,7 @@ async function loadGroups(io, refresh, json) {
8490
9071
  return { kind: "error", code: 2 };
8491
9072
  }
8492
9073
  }
8493
- async function lookupTenantAware(assigned, key) {
8494
- const hit = findGroup(assigned, key);
8495
- if (hit.kind === "found") {
8496
- return {
8497
- kind: "found",
8498
- group: {
8499
- id: hit.group.id,
8500
- displayName: hit.group.displayName,
8501
- mailNickname: hit.group.mailNickname
8502
- }
8503
- };
8504
- }
8505
- if (hit.kind === "ambiguous") {
8506
- return {
8507
- kind: "ambiguous",
8508
- matches: hit.matches.map((g) => ({ id: g.id, displayName: g.displayName }))
8509
- };
8510
- }
9074
+ async function lookupTenantWide(key) {
8511
9075
  try {
8512
9076
  const r = await resolveGroupRefsTenant(loadConfig(), [key]);
8513
9077
  switch (r.kind) {
@@ -8533,7 +9097,7 @@ async function lookupTenantAware(assigned, key) {
8533
9097
  return { kind: "not-found" };
8534
9098
  }
8535
9099
  case "error":
8536
- return { kind: "error", message: r.message };
9100
+ return { kind: "error", message: r.message, ...r.envelope === undefined ? {} : { envelope: r.envelope } };
8537
9101
  }
8538
9102
  } catch (e) {
8539
9103
  if (e instanceof UnauthenticatedError)
@@ -8562,7 +9126,15 @@ function renderLookupFailure(io, json, hit) {
8562
9126
  return 3;
8563
9127
  }
8564
9128
  if (hit.kind === "error") {
8565
- 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
+ }
8566
9138
  return 2;
8567
9139
  }
8568
9140
  if (hit.kind === "ineligible") {
@@ -8677,10 +9249,7 @@ async function runShow(args, io) {
8677
9249
  return 64;
8678
9250
  }
8679
9251
  const key = parsed.flags.positional[0];
8680
- const load = await loadGroups(io, parsed.flags.refresh, parsed.flags.json);
8681
- if (load.kind === "error")
8682
- return load.code;
8683
- const hit = await lookupTenantAware(load.result.groups, key);
9252
+ const hit = await lookupTenantWide(key);
8684
9253
  const failure = renderLookupFailure(io, parsed.flags.json, hit);
8685
9254
  if (failure !== null)
8686
9255
  return failure;
@@ -8729,10 +9298,7 @@ async function runResolve(args, io) {
8729
9298
  return 64;
8730
9299
  }
8731
9300
  const key = parsed.flags.positional[0];
8732
- const load = await loadGroups(io, parsed.flags.refresh, parsed.flags.json);
8733
- if (load.kind === "error")
8734
- return load.code;
8735
- const hit = await lookupTenantAware(load.result.groups, key);
9301
+ const hit = await lookupTenantWide(key);
8736
9302
  const failure = renderLookupFailure(io, parsed.flags.json, hit);
8737
9303
  if (failure !== null)
8738
9304
  return failure;
@@ -13008,7 +13574,11 @@ async function checkGroup(allowedGroup, cfg) {
13008
13574
  }
13009
13575
  return { kind: "not-found", key: allowedGroup };
13010
13576
  case "error":
13011
- return { kind: "network", message: r.message };
13577
+ return {
13578
+ kind: "network",
13579
+ message: r.message,
13580
+ ...r.envelope === undefined ? {} : { envelope: r.envelope }
13581
+ };
13012
13582
  }
13013
13583
  } catch (e) {
13014
13584
  if (e instanceof UnauthenticatedError) {
@@ -13083,7 +13653,7 @@ function renderHumanOk(result, group, boundary, io) {
13083
13653
  io.err(` The session is valid but lacks permission for /groups; talk to the platform team.`);
13084
13654
  return 3;
13085
13655
  case "network":
13086
- 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)}`);
13087
13657
  return 2;
13088
13658
  }
13089
13659
  }
@@ -13159,11 +13729,11 @@ function renderJsonOk(result, group, boundary, io) {
13159
13729
  }));
13160
13730
  return 3;
13161
13731
  case "network":
13162
- io.out(JSON.stringify({
13732
+ io.out(JSON.stringify(group.envelope === undefined ? {
13163
13733
  ok: false,
13164
13734
  ...base,
13165
13735
  group: { kind: "network", message: group.message }
13166
- }));
13736
+ } : { ok: false, ...base, ...errorEnvelopeJson(group.envelope) }));
13167
13737
  return 2;
13168
13738
  }
13169
13739
  }