@m-kopa/launchpad-cli 0.41.1 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,57 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
  This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html);
7
7
  pre-1.0 minor bumps may carry breaking changes per ADR 0005.
8
8
 
9
+ ## 0.43.0 — 2026-07-05
10
+
11
+ **`launchpad deploy` now shows a pre-deploy config diff (PS-1920).** Before a
12
+ content deploy sends your bundle, the CLI prints a git-style, key-level diff of
13
+ how your local `launchpad.yaml` differs from the **currently deployed**
14
+ manifest — added / removed / changed keys, with `old → new` for changed
15
+ scalars — so a config change (intended or accidental) is visible up front. When
16
+ your manifest already matches what's deployed, it says so in one line.
17
+
18
+ - **Advisory only:** the diff never prompts, never blocks, and never changes the
19
+ deploy's exit code — a manifest edit is usually intentional. The commit
20
+ lost-update guard (stale-clone) and the declared-vs-enforced ACCESS DRIFT
21
+ warning are unchanged and stay separate; this covers the manifest-config axis
22
+ neither did.
23
+ - **Secret-safe:** values under secret-shaped keys (env-var values, tokens) are
24
+ masked, never printed. Redaction is applied before the diff is computed.
25
+ - **Fail-open:** if the deployed manifest can't be read (older bot, first deploy
26
+ with nothing deployed yet, transient error) the check prints a one-line
27
+ "skipping" note and the deploy proceeds unchanged.
28
+ - **In-sync check is semantic:** key order, comments, whitespace, and the
29
+ singular-vs-plural access-group form don't register as changes; group lists
30
+ diff by set membership.
31
+
32
+ ## 0.42.0 — 2026-06-30
33
+
34
+ **`launchpad validate --strict-groups` and `launchpad groups resolve`/`show`
35
+ now resolve Entra groups against the whole tenant (PS-1859).** They used to
36
+ check only the enterprise-app-**assigned** set, so a valid *unassigned* group
37
+ (e.g. `G_information_security`) was falsely reported as not-found — the durable
38
+ fix for the discoverability gap the 0.41.1 docs worked around. The resolution
39
+ runs bot-side via a new employee-gated `POST /groups/resolve` (rate-limited,
40
+ audited; the CLI never holds Graph credentials), reusing the same resolver
41
+ `launchpad deploy` uses.
42
+
43
+ - **Behavioural change (CI note):** `validate --strict-groups` now exits `0`
44
+ for a valid unassigned group (it used to fail). Exit-code *values* are
45
+ unchanged; only the verdict flips. A reference still fails for an
46
+ **unknown**, **ineligible** (distribution list / hidden-membership), or
47
+ **ambiguous** group — ambiguous now prints the candidate UUIDs.
48
+ - **Ambiguity fix (also affects deploy):** a tenant displayName matching more
49
+ than one group is now reported as ambiguous with candidate UUIDs instead of
50
+ silently resolving to the first match.
51
+ - `launchpad groups list`/`search` remain the assigned-app shortlist, now
52
+ clearly labelled ("unlisted ≠ unusable — `launchpad groups resolve
53
+ <name|uuid>` checks any tenant group").
54
+ - Skills: the interim 0.41.1 "use `deploy --dry-run` because the others lie"
55
+ caveats are removed (the others no longer lie). `launchpad-content-pr` and
56
+ `launchpad-deploy` now cross-link the self-serve **`launchpad deploy
57
+ --apply`** route for changing a live app's access group — never "raise a
58
+ request with the platform team."
59
+
9
60
  ## 0.41.1 — 2026-06-30
10
61
 
11
62
  Docs & skills correctness fix: **any Entra group in the tenant can gate an
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.41.1";
22
+ var CLI_VERSION = "0.43.0";
23
23
 
24
24
  // src/config.ts
25
25
  import * as os from "node:os";
@@ -4086,6 +4086,57 @@ function writeCache(path8, envelope) {
4086
4086
  function describe11(e) {
4087
4087
  return e instanceof Error ? e.message : String(e);
4088
4088
  }
4089
+ async function resolveGroupRefsTenant(cfg, refs, opts = {}) {
4090
+ let res;
4091
+ try {
4092
+ res = await apiRaw(cfg, {
4093
+ method: "POST",
4094
+ path: "/groups/resolve",
4095
+ jsonBody: { refs },
4096
+ nonThrowingStatuses: [400, 429, 503]
4097
+ }, opts.fetcher);
4098
+ } catch (e) {
4099
+ if (e instanceof UnauthenticatedError || e instanceof ForbiddenError)
4100
+ throw e;
4101
+ return { kind: "error", message: describe11(e) };
4102
+ }
4103
+ let data;
4104
+ try {
4105
+ data = await res.json();
4106
+ } catch (e) {
4107
+ return { kind: "error", message: `bot returned a non-JSON resolve response: ${describe11(e)}` };
4108
+ }
4109
+ if (res.status !== 200) {
4110
+ const env = data;
4111
+ const msg = typeof env?.message === "string" ? env.message : typeof env?.error === "string" ? env.error : `bot returned HTTP ${res.status}`;
4112
+ return { kind: "error", message: msg };
4113
+ }
4114
+ const body = data;
4115
+ switch (body.kind) {
4116
+ case "ok":
4117
+ if (!Array.isArray(body.groups))
4118
+ break;
4119
+ return { kind: "ok", groups: body.groups };
4120
+ case "unresolved":
4121
+ if (!Array.isArray(body.refs))
4122
+ break;
4123
+ return {
4124
+ kind: "unresolved",
4125
+ refs: body.refs,
4126
+ message: typeof body.message === "string" ? body.message : ""
4127
+ };
4128
+ case "ambiguous":
4129
+ if (typeof body.ref !== "string" || !Array.isArray(body.candidates))
4130
+ break;
4131
+ return {
4132
+ kind: "ambiguous",
4133
+ ref: body.ref,
4134
+ candidates: body.candidates,
4135
+ message: typeof body.message === "string" ? body.message : ""
4136
+ };
4137
+ }
4138
+ return { kind: "error", message: "bot returned a malformed resolve response" };
4139
+ }
4089
4140
  var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4090
4141
  function isUuid2(value) {
4091
4142
  return UUID_PATTERN.test(value);
@@ -5511,7 +5562,7 @@ async function verifyFirstDeployServed(args, liveUrl, io, deps) {
5511
5562
  }
5512
5563
 
5513
5564
  // src/commands/deploy.ts
5514
- import { parse as parseYaml5 } from "yaml";
5565
+ import { parse as parseYaml6 } from "yaml";
5515
5566
  import { readFileSync as readFileSync10 } from "node:fs";
5516
5567
 
5517
5568
  // src/deploy/git-files.ts
@@ -5568,6 +5619,178 @@ function runGit2(sp, cwd, args) {
5568
5619
  });
5569
5620
  }
5570
5621
 
5622
+ // src/deploy/manifest-diff.ts
5623
+ import { parse as parseYaml4 } from "yaml";
5624
+ var MASK = "«redacted»";
5625
+ var SECRET_KEY_SEGMENT = /(secret|token|api[_-]?key|access[_-]?key|private[_-]?key|secret[_-]?key|password|passwd|pwd|authorization|auth[_-]?token|bearer|client[_-]?secret|session|credential|value)/i;
5626
+ function isSecretPath(path8) {
5627
+ const last2 = path8.split(".").pop() ?? path8;
5628
+ return SECRET_KEY_SEGMENT.test(last2);
5629
+ }
5630
+ function normalize(v) {
5631
+ if (Array.isArray(v)) {
5632
+ const items = v.map(normalize);
5633
+ const allScalar = items.every((x) => x === null || typeof x !== "object");
5634
+ return allScalar ? [...items].sort((a, b) => String(a).localeCompare(String(b))) : items;
5635
+ }
5636
+ if (v !== null && typeof v === "object") {
5637
+ const obj = v;
5638
+ const out = {};
5639
+ for (const [k, val] of Object.entries(obj)) {
5640
+ if (k === "allowed_entra_group" && typeof val === "string") {
5641
+ out.allowed_entra_groups = normalize([val]);
5642
+ } else {
5643
+ out[k] = normalize(val);
5644
+ }
5645
+ }
5646
+ return out;
5647
+ }
5648
+ return v;
5649
+ }
5650
+ function stableStringify(v) {
5651
+ if (Array.isArray(v))
5652
+ return `[${v.map(stableStringify).join(",")}]`;
5653
+ if (v !== null && typeof v === "object") {
5654
+ const obj = v;
5655
+ return `{${Object.keys(obj).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
5656
+ }
5657
+ return JSON.stringify(v ?? null);
5658
+ }
5659
+ function isScalarSet(v) {
5660
+ return typeof v === "object" && v !== null && "__set" in v;
5661
+ }
5662
+ function flatten(v, prefix, out) {
5663
+ if (Array.isArray(v)) {
5664
+ if (v.length === 0) {
5665
+ out.set(prefix, "[]");
5666
+ return;
5667
+ }
5668
+ const allScalar = v.every((x) => x === null || typeof x !== "object");
5669
+ if (allScalar) {
5670
+ out.set(prefix, { __set: v.map((x) => String(x)) });
5671
+ return;
5672
+ }
5673
+ v.forEach((item, i) => flatten(item, prefix === "" ? String(i) : `${prefix}.${i}`, out));
5674
+ return;
5675
+ }
5676
+ if (v !== null && typeof v === "object") {
5677
+ const entries = Object.entries(v);
5678
+ if (entries.length === 0) {
5679
+ out.set(prefix, "{}");
5680
+ return;
5681
+ }
5682
+ for (const [k, val] of entries) {
5683
+ flatten(val, prefix === "" ? k : `${prefix}.${k}`, out);
5684
+ }
5685
+ return;
5686
+ }
5687
+ out.set(prefix, v);
5688
+ }
5689
+ function renderValue(path8, value) {
5690
+ if (isSecretPath(path8))
5691
+ return MASK;
5692
+ if (value === null)
5693
+ return "null";
5694
+ const raw = isScalarSet(value) ? renderScalar(value) : String(value);
5695
+ const scrubbed = redactDiagnostic(raw, 120);
5696
+ return scrubbed === "" ? MASK : scrubbed;
5697
+ }
5698
+ function renderScalar(value) {
5699
+ return isScalarSet(value) ? `[${[...value.__set].sort().join(",")}]` : String(value);
5700
+ }
5701
+ function pushSideChange(changes, path8, kind, value) {
5702
+ if (isScalarSet(value)) {
5703
+ for (const m of value.__set) {
5704
+ changes.push(kind === "added" ? { path: `${path8}[]`, kind, after: renderValue(path8, m) } : { path: `${path8}[]`, kind, before: renderValue(path8, m) });
5705
+ }
5706
+ return;
5707
+ }
5708
+ changes.push(kind === "added" ? { path: path8, kind, after: renderValue(path8, value) } : { path: path8, kind, before: renderValue(path8, value) });
5709
+ }
5710
+ function diffManifests(localYaml, deployedYaml) {
5711
+ let local;
5712
+ let deployed;
5713
+ try {
5714
+ local = normalize(parseYaml4(localYaml));
5715
+ } catch (e) {
5716
+ return {
5717
+ inSync: false,
5718
+ changes: [],
5719
+ parseError: `local launchpad.yaml did not parse: ${e.message}`
5720
+ };
5721
+ }
5722
+ try {
5723
+ deployed = normalize(parseYaml4(deployedYaml));
5724
+ } catch (e) {
5725
+ return {
5726
+ inSync: false,
5727
+ changes: [],
5728
+ parseError: `deployed manifest did not parse: ${e.message}`
5729
+ };
5730
+ }
5731
+ if (stableStringify(local) === stableStringify(deployed)) {
5732
+ return { inSync: true, changes: [], parseError: null };
5733
+ }
5734
+ const localFlat = new Map;
5735
+ const deployedFlat = new Map;
5736
+ flatten(local, "", localFlat);
5737
+ flatten(deployed, "", deployedFlat);
5738
+ const paths = [
5739
+ ...new Set([...localFlat.keys(), ...deployedFlat.keys()])
5740
+ ].sort();
5741
+ const changes = [];
5742
+ for (const p of paths) {
5743
+ const hasLocal = localFlat.has(p);
5744
+ const hasDeployed = deployedFlat.has(p);
5745
+ const localVal = localFlat.get(p);
5746
+ const deployedVal = deployedFlat.get(p);
5747
+ if (isScalarSet(localVal) && isScalarSet(deployedVal)) {
5748
+ const local2 = new Set(localVal.__set);
5749
+ const deployed2 = new Set(deployedVal.__set);
5750
+ for (const m of localVal.__set) {
5751
+ if (!deployed2.has(m)) {
5752
+ changes.push({ path: `${p}[]`, kind: "added", after: renderValue(p, m) });
5753
+ }
5754
+ }
5755
+ for (const m of deployedVal.__set) {
5756
+ if (!local2.has(m)) {
5757
+ changes.push({ path: `${p}[]`, kind: "removed", before: renderValue(p, m) });
5758
+ }
5759
+ }
5760
+ continue;
5761
+ }
5762
+ if (hasLocal && !hasDeployed) {
5763
+ pushSideChange(changes, p, "added", localVal);
5764
+ } else if (!hasLocal && hasDeployed) {
5765
+ pushSideChange(changes, p, "removed", deployedVal);
5766
+ } else if (renderScalar(localVal) !== renderScalar(deployedVal)) {
5767
+ changes.push({
5768
+ path: p,
5769
+ kind: "changed",
5770
+ before: renderValue(p, deployedVal),
5771
+ after: renderValue(p, localVal)
5772
+ });
5773
+ }
5774
+ }
5775
+ return { inSync: false, changes, parseError: null };
5776
+ }
5777
+ function renderManifestDiffLines(diff, max = 20) {
5778
+ const lines = [];
5779
+ for (const c of diff.changes.slice(0, max)) {
5780
+ if (c.kind === "added") {
5781
+ lines.push(` + ${c.path}: ${c.after}`);
5782
+ } else if (c.kind === "removed") {
5783
+ lines.push(` - ${c.path}: ${c.before}`);
5784
+ } else {
5785
+ lines.push(` ~ ${c.path}: ${c.before} → ${c.after}`);
5786
+ }
5787
+ }
5788
+ if (diff.changes.length > max) {
5789
+ lines.push(` … and ${diff.changes.length - max} more`);
5790
+ }
5791
+ return lines;
5792
+ }
5793
+
5571
5794
  // src/commands/deploy-flags.ts
5572
5795
  var SLUG_RE4 = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
5573
5796
  var GROUP_KEY_RE2 = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -5957,7 +6180,7 @@ import { resolve as resolvePath, join as join10 } from "node:path";
5957
6180
 
5958
6181
  // src/manifest/load.ts
5959
6182
  import { readFileSync as readFileSync9 } from "node:fs";
5960
- import { parse as parseYaml4, YAMLParseError } from "yaml";
6183
+ import { parse as parseYaml5, YAMLParseError } from "yaml";
5961
6184
  function loadManifest(path8) {
5962
6185
  let raw;
5963
6186
  try {
@@ -5974,7 +6197,7 @@ function loadManifest(path8) {
5974
6197
  function parseManifest2(yamlText, path8) {
5975
6198
  let parsed;
5976
6199
  try {
5977
- parsed = parseYaml4(yamlText);
6200
+ parsed = parseYaml5(yamlText);
5978
6201
  } catch (err) {
5979
6202
  const message = err instanceof YAMLParseError ? err.message : err.message ?? String(err);
5980
6203
  return { kind: "yaml-parse-error", path: path8, message };
@@ -6506,12 +6729,25 @@ async function runResume(slug, io) {
6506
6729
  io.err(`launchpad deploy --resume: workflow at stage "${body.currentStage}" is not resumable. ${body.message}`);
6507
6730
  return exitCodeForStage(body.currentStage);
6508
6731
  }
6732
+ if (body !== null && body.error === "not_refirable") {
6733
+ io.err(`launchpad deploy --resume: ${body.message}`);
6734
+ return 1;
6735
+ }
6736
+ const msg = body?.message;
6737
+ if (typeof msg === "string" && msg.length > 0) {
6738
+ io.err(`launchpad deploy --resume: ${msg}`);
6739
+ return 1;
6740
+ }
6509
6741
  io.err(`launchpad deploy --resume: bot returned 409 without parseable body`);
6510
6742
  return 1;
6511
6743
  }
6512
6744
  const parsed = await res.json();
6513
- if (parsed.refiring === true) {
6514
- io.out(`Workflow may resume from stage "${parsed.fromStage}" — next stage: "${parsed.nextStage}". Re-run \`launchpad deploy --resume ${slug}\` if the workflow doesn't pick up within a minute.`);
6745
+ if (parsed.refiring === true && parsed.alreadyRunning === true) {
6746
+ io.out(`A provisioning run for "${slug}" is already in flight leaving it to finish. Watch \`launchpad status ${slug}\`.`);
6747
+ } else if (parsed.refiring === true) {
6748
+ const stageHint = parsed.fromStage !== undefined ? ` from stage "${parsed.fromStage}"` : "";
6749
+ const viaHint = parsed.via !== undefined ? ` (${parsed.via})` : "";
6750
+ io.out(`Re-driving provisioning for "${slug}"${stageHint}${viaHint}. Watch \`launchpad status ${slug}\`; every stage is idempotent, so it picks up where it left off.`);
6515
6751
  } else if (parsed.idempotent === true) {
6516
6752
  io.out(`Workflow already at \`done\` for "${slug}" — nothing to resume.`);
6517
6753
  } else {
@@ -6930,12 +7166,57 @@ function surfaceStaleBlock(body, io) {
6930
7166
  io.err(` launchpad deploy --override-stale ${head.slice(0, 8)}`);
6931
7167
  }
6932
7168
  }
7169
+ var MANIFEST_DIFF_READ_TIMEOUT_MS = 5000;
7170
+ function withTimeout(p, ms) {
7171
+ return new Promise((resolve8, reject) => {
7172
+ const timer = setTimeout(() => reject(new Error(`manifest read timed out after ${ms}ms`)), ms);
7173
+ p.then((v) => {
7174
+ clearTimeout(timer);
7175
+ resolve8(v);
7176
+ }, (e) => {
7177
+ clearTimeout(timer);
7178
+ reject(e instanceof Error ? e : new Error(String(e)));
7179
+ });
7180
+ });
7181
+ }
7182
+ async function surfaceManifestDiff(cfg, slug, manifestPath, io) {
7183
+ let localYaml;
7184
+ try {
7185
+ localYaml = readFileSync10(manifestPath, "utf8");
7186
+ } catch {
7187
+ return;
7188
+ }
7189
+ let deployedYaml;
7190
+ try {
7191
+ const state = await withTimeout(fetchManifestState(cfg, slug, { includeManifest: true }), MANIFEST_DIFF_READ_TIMEOUT_MS);
7192
+ deployedYaml = state.manifestYaml ?? null;
7193
+ } catch {
7194
+ io.out(" config diff: could not read the deployed manifest — skipping (does not affect this deploy).");
7195
+ return;
7196
+ }
7197
+ if (deployedYaml === null || deployedYaml.trim() === "") {
7198
+ io.out(" config diff: nothing deployed yet to compare against — skipping.");
7199
+ return;
7200
+ }
7201
+ const diff = diffManifests(localYaml, deployedYaml);
7202
+ if (diff.parseError !== null) {
7203
+ io.out(` config diff: skipped (${diff.parseError}).`);
7204
+ return;
7205
+ }
7206
+ if (diff.inSync) {
7207
+ io.out(" config: launchpad.yaml matches the deployed manifest (in sync).");
7208
+ return;
7209
+ }
7210
+ io.out(" config diff — local launchpad.yaml vs the DEPLOYED manifest (this file, not gateway enforcement):");
7211
+ for (const line of renderManifestDiffLines(diff))
7212
+ io.out(line);
7213
+ }
6933
7214
  async function runModelADeploy(args) {
6934
7215
  const { cwd, manifestPath, io } = args;
6935
7216
  let slug;
6936
7217
  let appType = "static";
6937
7218
  try {
6938
- const manifestObj = parseYaml5(readFileSync10(manifestPath, "utf8"));
7219
+ const manifestObj = parseYaml6(readFileSync10(manifestPath, "utf8"));
6939
7220
  const metaSlug = resolveManifestSlug(manifestObj);
6940
7221
  if (metaSlug === null) {
6941
7222
  io.err(`launchpad deploy: launchpad.yaml is missing metadata.slug (v2) / metadata.name (v1). ` + `Run \`launchpad init\` again to regenerate the manifest.`);
@@ -6980,6 +7261,9 @@ async function runModelADeploy(args) {
6980
7261
  io.err(`launchpad deploy: ${describe15(e)}`);
6981
7262
  return 1;
6982
7263
  }
7264
+ try {
7265
+ await surfaceManifestDiff(cfg, slug, manifestPath, io);
7266
+ } catch {}
6983
7267
  const deployBaseline = noVerify ? null : await readDeploymentBaseline(realCommitWaitDeps(cfg), slug);
6984
7268
  let result;
6985
7269
  try {
@@ -7153,7 +7437,7 @@ async function runModelADeploy(args) {
7153
7437
  // src/commands/redeploy.ts
7154
7438
  import { existsSync as existsSync7, readFileSync as readFileSync11 } from "node:fs";
7155
7439
  import { resolve as resolvePath2 } from "node:path";
7156
- import { parse as parseYaml6 } from "yaml";
7440
+ import { parse as parseYaml7 } from "yaml";
7157
7441
  var SLUG_RE6 = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
7158
7442
  var DEFAULT_VERIFY_TIMEOUT_MS = 180000;
7159
7443
  var redeployCommand = {
@@ -7232,7 +7516,7 @@ function readLocalAppType(cwd, file) {
7232
7516
  if (!existsSync7(manifestPath))
7233
7517
  return null;
7234
7518
  try {
7235
- const obj = parseYaml6(readFileSync11(manifestPath, "utf8"));
7519
+ const obj = parseYaml7(readFileSync11(manifestPath, "utf8"));
7236
7520
  return coerceAppType(obj?.deployment?.type);
7237
7521
  } catch {
7238
7522
  return null;
@@ -8059,6 +8343,91 @@ async function loadGroups(io, refresh, json) {
8059
8343
  return { kind: "error", code: 2 };
8060
8344
  }
8061
8345
  }
8346
+ async function lookupTenantAware(assigned, key) {
8347
+ const hit = findGroup(assigned, key);
8348
+ if (hit.kind === "found") {
8349
+ return {
8350
+ kind: "found",
8351
+ group: {
8352
+ id: hit.group.id,
8353
+ displayName: hit.group.displayName,
8354
+ mailNickname: hit.group.mailNickname
8355
+ }
8356
+ };
8357
+ }
8358
+ if (hit.kind === "ambiguous") {
8359
+ return {
8360
+ kind: "ambiguous",
8361
+ matches: hit.matches.map((g) => ({ id: g.id, displayName: g.displayName }))
8362
+ };
8363
+ }
8364
+ try {
8365
+ const r = await resolveGroupRefsTenant(loadConfig(), [key]);
8366
+ switch (r.kind) {
8367
+ case "ok": {
8368
+ const g = r.groups[0];
8369
+ if (g === undefined)
8370
+ return { kind: "not-found" };
8371
+ return { kind: "found", group: { id: g.id, displayName: g.displayName, mailNickname: null } };
8372
+ }
8373
+ case "ambiguous":
8374
+ return {
8375
+ kind: "ambiguous",
8376
+ matches: r.candidates.map((c) => ({ id: c.id, displayName: c.displayName }))
8377
+ };
8378
+ case "unresolved": {
8379
+ const reason = r.refs[0]?.reason;
8380
+ if (reason === "unverified") {
8381
+ return { kind: "error", message: r.message };
8382
+ }
8383
+ if (reason === "ineligible") {
8384
+ return { kind: "ineligible", message: r.message };
8385
+ }
8386
+ return { kind: "not-found" };
8387
+ }
8388
+ case "error":
8389
+ return { kind: "error", message: r.message };
8390
+ }
8391
+ } catch (e) {
8392
+ if (e instanceof UnauthenticatedError)
8393
+ return { kind: "unauthenticated", message: e.message };
8394
+ if (e instanceof ForbiddenError)
8395
+ return { kind: "forbidden", message: e.message };
8396
+ return { kind: "error", message: describe19(e) };
8397
+ }
8398
+ }
8399
+ function renderLookupFailure(io, json, hit) {
8400
+ if (hit.kind === "unauthenticated") {
8401
+ if (json) {
8402
+ io.out(JSON.stringify({ ok: false, kind: "unauthenticated", message: hit.message }));
8403
+ } else {
8404
+ io.err(hit.message);
8405
+ io.err("Run `launchpad login` to start a session.");
8406
+ }
8407
+ return 3;
8408
+ }
8409
+ if (hit.kind === "forbidden") {
8410
+ if (json) {
8411
+ io.out(JSON.stringify({ ok: false, kind: "forbidden", message: hit.message }));
8412
+ } else {
8413
+ io.err(hit.message);
8414
+ }
8415
+ return 3;
8416
+ }
8417
+ if (hit.kind === "error") {
8418
+ renderFetchError(io, hit.message, json);
8419
+ return 2;
8420
+ }
8421
+ if (hit.kind === "ineligible") {
8422
+ if (json) {
8423
+ io.out(JSON.stringify({ ok: false, kind: "ineligible", message: hit.message }));
8424
+ } else {
8425
+ io.err(hit.message);
8426
+ }
8427
+ return 1;
8428
+ }
8429
+ return null;
8430
+ }
8062
8431
  function renderFetchError(io, message, json) {
8063
8432
  if (json) {
8064
8433
  io.out(JSON.stringify({ ok: false, kind: "fetch-error", message }));
@@ -8105,11 +8474,14 @@ async function runList(args, io) {
8105
8474
  return 0;
8106
8475
  }
8107
8476
  if (result.groups.length === 0) {
8108
- io.out("(no groups assigned to the Launchpad enterprise app yet — talk to platform-team)");
8477
+ io.out("(no groups assigned to the Launchpad enterprise app yet)");
8478
+ io.out("Unlisted ≠ unusable: any tenant group is still usable by name or UUID — " + "`launchpad groups resolve <name|uuid>` checks one.");
8109
8479
  return 0;
8110
8480
  }
8111
8481
  io.out(`${result.groups.length} group${result.groups.length === 1 ? "" : "s"} (source: ${result.source}, fetched: ${result.fetchedAt})`);
8112
8482
  renderTable2(io, result.groups);
8483
+ io.out("");
8484
+ io.out("This is the assigned-app shortlist — any tenant group is still usable by " + "name or UUID, even if it isn't listed here (`launchpad groups resolve <name|uuid>`).");
8113
8485
  return 0;
8114
8486
  }
8115
8487
  async function runSearch(args, io) {
@@ -8138,7 +8510,8 @@ async function runSearch(args, io) {
8138
8510
  return 0;
8139
8511
  }
8140
8512
  if (matches.length === 0) {
8141
- io.out(`no matches for "${query}"`);
8513
+ io.out(`no matches for "${query}" in the assigned-app shortlist`);
8514
+ io.out("That shortlist isn't the whole tenant — if you know the exact name or UUID, " + "`launchpad groups resolve <name|uuid>` checks any tenant group.");
8142
8515
  return 0;
8143
8516
  }
8144
8517
  io.out(`${matches.length} match${matches.length === 1 ? "" : "es"} for "${query}":`);
@@ -8160,7 +8533,13 @@ async function runShow(args, io) {
8160
8533
  const load = await loadGroups(io, parsed.flags.refresh, parsed.flags.json);
8161
8534
  if (load.kind === "error")
8162
8535
  return load.code;
8163
- const hit = findGroup(load.result.groups, key);
8536
+ const hit = await lookupTenantAware(load.result.groups, key);
8537
+ const failure = renderLookupFailure(io, parsed.flags.json, hit);
8538
+ if (failure !== null)
8539
+ return failure;
8540
+ if (hit.kind !== "found" && hit.kind !== "ambiguous" && hit.kind !== "not-found") {
8541
+ return 2;
8542
+ }
8164
8543
  if (parsed.flags.json) {
8165
8544
  if (hit.kind === "not-found") {
8166
8545
  io.out(JSON.stringify({ ok: false, kind: "not-found", key }));
@@ -8175,7 +8554,7 @@ async function runShow(args, io) {
8175
8554
  }
8176
8555
  if (hit.kind === "not-found") {
8177
8556
  io.err(`no group matched "${key}"`);
8178
- io.err(`Run \`launchpad groups list\` to see every visible group.`);
8557
+ io.err(`Not in the assigned shortlist? Any tenant group is still usable — check the exact name/UUID.`);
8179
8558
  return 1;
8180
8559
  }
8181
8560
  if (hit.kind === "ambiguous") {
@@ -8206,7 +8585,13 @@ async function runResolve(args, io) {
8206
8585
  const load = await loadGroups(io, parsed.flags.refresh, parsed.flags.json);
8207
8586
  if (load.kind === "error")
8208
8587
  return load.code;
8209
- const hit = findGroup(load.result.groups, key);
8588
+ const hit = await lookupTenantAware(load.result.groups, key);
8589
+ const failure = renderLookupFailure(io, parsed.flags.json, hit);
8590
+ if (failure !== null)
8591
+ return failure;
8592
+ if (hit.kind !== "found" && hit.kind !== "ambiguous" && hit.kind !== "not-found") {
8593
+ return 2;
8594
+ }
8210
8595
  if (parsed.flags.json) {
8211
8596
  if (hit.kind === "not-found") {
8212
8597
  io.out(JSON.stringify({ ok: false, kind: "not-found", key }));
@@ -11078,7 +11463,7 @@ function renderManifestError5(result, io) {
11078
11463
  // src/secrets/set.ts
11079
11464
  import { existsSync as existsSync12, readFileSync as readFileSync17 } from "node:fs";
11080
11465
  import { resolve as resolvePath6 } from "node:path";
11081
- import { parse as parseYaml7 } from "yaml";
11466
+ import { parse as parseYaml8 } from "yaml";
11082
11467
  var CELL_LABEL2 = {
11083
11468
  present: "PRESENT",
11084
11469
  missing: "MISSING",
@@ -11093,7 +11478,7 @@ function loadSet(fleetFile, setName, io) {
11093
11478
  }
11094
11479
  let obj;
11095
11480
  try {
11096
- obj = parseYaml7(readFileSync17(path13, "utf8"));
11481
+ obj = parseYaml8(readFileSync17(path13, "utf8"));
11097
11482
  } catch (e) {
11098
11483
  io.err(`✗ ${path13}`);
11099
11484
  io.err(` YAML parse error: ${e instanceof Error ? e.message : String(e)}`);
@@ -12395,27 +12780,33 @@ function checkStaticServeDir(manifestPath, manifest) {
12395
12780
  var DECLARE_APP_HINT = "no app: boundary declared — deploys bundle the whole working tree minus the platform deny-list. " + "Declare app: (root / include / exclude) in launchpad.yaml — see schema/README.md.";
12396
12781
  async function checkGroup(allowedGroup, cfg) {
12397
12782
  try {
12398
- const r = await fetchGroups(cfg);
12399
- if (r.kind === "error") {
12400
- return { kind: "network", message: r.message };
12401
- }
12402
- const hit = findGroup(r.groups, allowedGroup);
12403
- if (hit.kind === "found") {
12404
- return {
12405
- kind: "ok",
12406
- key: allowedGroup,
12407
- resolvedUuid: hit.group.id,
12408
- resolvedName: hit.group.displayName
12409
- };
12410
- }
12411
- if (hit.kind === "ambiguous") {
12412
- return {
12413
- kind: "ambiguous",
12414
- key: allowedGroup,
12415
- matches: hit.matches.map((g) => ({ id: g.id, displayName: g.displayName }))
12416
- };
12783
+ const r = await resolveGroupRefsTenant(cfg, [allowedGroup]);
12784
+ switch (r.kind) {
12785
+ case "ok": {
12786
+ const g = r.groups[0];
12787
+ if (g === undefined)
12788
+ return { kind: "not-found", key: allowedGroup };
12789
+ return {
12790
+ kind: "ok",
12791
+ key: allowedGroup,
12792
+ resolvedUuid: g.id,
12793
+ resolvedName: g.displayName
12794
+ };
12795
+ }
12796
+ case "ambiguous":
12797
+ return {
12798
+ kind: "ambiguous",
12799
+ key: allowedGroup,
12800
+ matches: r.candidates.map((c) => ({ id: c.id, displayName: c.displayName }))
12801
+ };
12802
+ case "unresolved":
12803
+ if (r.refs[0]?.reason === "unverified") {
12804
+ return { kind: "network", message: r.message };
12805
+ }
12806
+ return { kind: "not-found", key: allowedGroup };
12807
+ case "error":
12808
+ return { kind: "network", message: r.message };
12417
12809
  }
12418
- return { kind: "not-found", key: allowedGroup };
12419
12810
  } catch (e) {
12420
12811
  if (e instanceof UnauthenticatedError) {
12421
12812
  return { kind: "unauthenticated", message: e.message };
@@ -1 +1 @@
1
- {"version":3,"file":"deploy-modes.d.ts","sourceRoot":"","sources":["../../src/commands/deploy-modes.ts"],"names":[],"mappings":"AA8BA,OAAO,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AA0B3C;;;;;;;GAOG;AACH,wBAAsB,UAAU,CAC9B,IAAI,EAAE,MAAM,EACZ,EAAE,EAAE,KAAK,GACR,OAAO,CAAC,QAAQ,CAAC,CAwBnB;AAED;;;;;;;GAOG;AACH,wBAAsB,SAAS,CAC7B,IAAI,EAAE,MAAM,EACZ,EAAE,EAAE,KAAK,GACR,OAAO,CAAC,QAAQ,CAAC,CAwCnB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,MAAM,CAC1B,IAAI,EAAE;IACJ,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IACpC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,aAAa,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1C,QAAQ,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;CACxC,EACD,EAAE,EAAE,KAAK;AACT;;;;;GAKG;AACH,aAAa,GAAE;IACb,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3C,GACL,OAAO,CAAC,QAAQ,CAAC,CAmFnB"}
1
+ {"version":3,"file":"deploy-modes.d.ts","sourceRoot":"","sources":["../../src/commands/deploy-modes.ts"],"names":[],"mappings":"AA8BA,OAAO,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAwC3C;;;;;;;GAOG;AACH,wBAAsB,UAAU,CAC9B,IAAI,EAAE,MAAM,EACZ,EAAE,EAAE,KAAK,GACR,OAAO,CAAC,QAAQ,CAAC,CAwBnB;AAED;;;;;;;GAOG;AACH,wBAAsB,SAAS,CAC7B,IAAI,EAAE,MAAM,EACZ,EAAE,EAAE,KAAK,GACR,OAAO,CAAC,QAAQ,CAAC,CAiEnB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,MAAM,CAC1B,IAAI,EAAE;IACJ,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IACpC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,aAAa,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1C,QAAQ,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;CACxC,EACD,EAAE,EAAE,KAAK;AACT;;;;;GAKG;AACH,aAAa,GAAE;IACb,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3C,GACL,OAAO,CAAC,QAAQ,CAAC,CAmFnB"}
@@ -1 +1 @@
1
- {"version":3,"file":"deploy.d.ts","sourceRoot":"","sources":["../../src/commands/deploy.ts"],"names":[],"mappings":"AAmEA,OAAO,EAGL,4BAA4B,EAC7B,MAAM,uBAAuB,CAAC;AAM/B,OAAO,EAAa,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AACnF,OAAO,KAAK,EAAS,OAAO,EAAY,MAAM,kBAAkB,CAAC;AAKjE,OAAO,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,CAAC;AAEjD,eAAO,MAAM,aAAa,EAAE,OAI3B,CAAC;AAEF,UAAU,UAAU;IAClB,6BAA6B;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC;AAmQD;;;;;;GAMG;AACH,wBAAgB,WAAW,IAAI,MAAM,CAoBpC;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,UAAU,GAAG,IAAI,CAwBpE;AAkMD,2BAA2B;AAC3B,OAAO,EAAE,4BAA4B,EAAE,CAAC"}
1
+ {"version":3,"file":"deploy.d.ts","sourceRoot":"","sources":["../../src/commands/deploy.ts"],"names":[],"mappings":"AAqEA,OAAO,EAGL,4BAA4B,EAC7B,MAAM,uBAAuB,CAAC;AAM/B,OAAO,EAAa,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,iBAAiB,CAAC;AACnF,OAAO,KAAK,EAAS,OAAO,EAAY,MAAM,kBAAkB,CAAC;AAKjE,OAAO,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,CAAC;AAEjD,eAAO,MAAM,aAAa,EAAE,OAI3B,CAAC;AAEF,UAAU,UAAU;IAClB,6BAA6B;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACjC;AAmQD;;;;;;GAMG;AACH,wBAAgB,WAAW,IAAI,MAAM,CAoBpC;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,UAAU,GAAG,IAAI,CAwBpE;AAkMD,2BAA2B;AAC3B,OAAO,EAAE,4BAA4B,EAAE,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"groups.d.ts","sourceRoot":"","sources":["../../src/commands/groups.ts"],"names":[],"mappings":"AAoCA,OAAO,KAAK,EAAS,OAAO,EAAY,MAAM,kBAAkB,CAAC;AAMjE,eAAO,MAAM,aAAa,EAAE,OAI3B,CAAC"}
1
+ {"version":3,"file":"groups.d.ts","sourceRoot":"","sources":["../../src/commands/groups.ts"],"names":[],"mappings":"AAqCA,OAAO,KAAK,EAAS,OAAO,EAAY,MAAM,kBAAkB,CAAC;AAMjE,eAAO,MAAM,aAAa,EAAE,OAI3B,CAAC"}
@@ -0,0 +1,30 @@
1
+ export type ChangeKind = "added" | "removed" | "changed";
2
+ export interface ManifestKeyChange {
3
+ /** Dotted key path, e.g. `access.allowed_entra_groups.0`. */
4
+ readonly path: string;
5
+ readonly kind: ChangeKind;
6
+ /** Redacted rendering of the DEPLOYED value (present for removed/changed). */
7
+ readonly before?: string;
8
+ /** Redacted rendering of the LOCAL value (present for added/changed). */
9
+ readonly after?: string;
10
+ }
11
+ export interface ManifestDiff {
12
+ /** True when the parsed + normalised documents are equal. */
13
+ readonly inSync: boolean;
14
+ readonly changes: readonly ManifestKeyChange[];
15
+ /** Set when a document could not be parsed as YAML — the caller fails open. */
16
+ readonly parseError: string | null;
17
+ }
18
+ /**
19
+ * Diff the local `launchpad.yaml` against the deployed manifest body. Both are
20
+ * raw YAML strings. On a parse failure of either side, returns `parseError` set
21
+ * and no changes — the caller treats that as "could not determine" and proceeds
22
+ * (fail open, AC4).
23
+ */
24
+ export declare function diffManifests(localYaml: string, deployedYaml: string): ManifestDiff;
25
+ /**
26
+ * Render the diff as terminal lines (added `+`, removed `-`, changed `~`),
27
+ * capped at `max` entries. Returns `[]` when there is nothing to show.
28
+ */
29
+ export declare function renderManifestDiffLines(diff: ManifestDiff, max?: number): string[];
30
+ //# sourceMappingURL=manifest-diff.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest-diff.d.ts","sourceRoot":"","sources":["../../src/deploy/manifest-diff.ts"],"names":[],"mappings":"AAqBA,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,SAAS,GAAG,SAAS,CAAC;AAEzD,MAAM,WAAW,iBAAiB;IAChC,6DAA6D;IAC7D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAC1B,8EAA8E;IAC9E,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,yEAAyE;IACzE,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,YAAY;IAC3B,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE,SAAS,iBAAiB,EAAE,CAAC;IAC/C,+EAA+E;IAC/E,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAiJD;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,MAAM,GACnB,YAAY,CAyEd;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,YAAY,EAClB,GAAG,SAAK,GACP,MAAM,EAAE,CAeV"}
@@ -1 +1 @@
1
- {"version":3,"file":"status-polling.d.ts","sourceRoot":"","sources":["../../src/deploy/status-polling.ts"],"names":[],"mappings":"AA8BA,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,MAAM,WAAW,GACnB;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAC1B;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACzB;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACpD;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;CAAE,GAC5B;IAAE,QAAQ,CAAC,IAAI,EAAE,iBAAiB,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEnE,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,WAAW,EAAE,MAAM,OAAO,CAAC,kBAAkB,CAAC,CAAC;IACxD,QAAQ,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9C,QAAQ,CAAC,GAAG,EAAE,MAAM,MAAM,CAAC;IAC3B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CAC3C;AAKD;;;;;;;;;GASG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,WAAW,CAAC,CA8B5E"}
1
+ {"version":3,"file":"status-polling.d.ts","sourceRoot":"","sources":["../../src/deploy/status-polling.ts"],"names":[],"mappings":"AA8BA,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,MAAM,WAAW,GACnB;IAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;CAAE,GAC1B;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACzB;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACpD;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;CAAE,GAC5B;IAAE,QAAQ,CAAC,IAAI,EAAE,iBAAiB,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEnE,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,WAAW,EAAE,MAAM,OAAO,CAAC,kBAAkB,CAAC,CAAC;IACxD,QAAQ,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9C,QAAQ,CAAC,GAAG,EAAE,MAAM,MAAM,CAAC;IAC3B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CAC3C;AAKD;;;;;;;;;GASG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,WAAW,CAAC,CA4B5E"}
@@ -35,6 +35,46 @@ export type FetchGroupsResult = {
35
35
  * message.
36
36
  */
37
37
  export declare function fetchGroups(cfg: CliConfig, opts?: FetchGroupsOptions): Promise<FetchGroupsResult>;
38
+ /** A bot-resolved group descriptor. The tenant resolver canonicalises to
39
+ * `{ id, displayName }` only — `mailNickname` is not carried back. */
40
+ export interface ResolvedGroupLite {
41
+ readonly id: string;
42
+ readonly displayName: string;
43
+ }
44
+ /** One unresolved reference, mirroring the bot's `UnresolvedRef`. */
45
+ export interface TenantUnresolvedRef {
46
+ readonly ref: string;
47
+ readonly reason: "unknown" | "ineligible" | "unverified";
48
+ readonly ineligibleKind?: "distribution" | "hidden-membership";
49
+ readonly group?: ResolvedGroupLite;
50
+ }
51
+ export type TenantResolveResult = {
52
+ kind: "ok";
53
+ groups: readonly ResolvedGroupLite[];
54
+ } | {
55
+ kind: "unresolved";
56
+ refs: readonly TenantUnresolvedRef[];
57
+ message: string;
58
+ } | {
59
+ kind: "ambiguous";
60
+ ref: string;
61
+ candidates: readonly ResolvedGroupLite[];
62
+ message: string;
63
+ } | {
64
+ kind: "error";
65
+ message: string;
66
+ };
67
+ interface ResolveOptions {
68
+ readonly fetcher?: typeof fetch;
69
+ }
70
+ /**
71
+ * Resolve group references against the whole tenant via the bot's
72
+ * `POST /groups/resolve`. Resolution OUTCOMES (ok / unresolved / ambiguous)
73
+ * come back as a 200 with a discriminated body; a non-200 means the call
74
+ * could not run (rate-limit, Graph down, bad request) and is surfaced as
75
+ * `kind: "error"`. 401/403 rethrow as typed errors (auth/permission).
76
+ */
77
+ export declare function resolveGroupRefsTenant(cfg: CliConfig, refs: readonly string[], opts?: ResolveOptions): Promise<TenantResolveResult>;
38
78
  /** True for a canonical Entra Object ID UUID. */
39
79
  export declare function isUuid(value: string): boolean;
40
80
  /**
@@ -59,4 +99,5 @@ export declare function findGroup(groups: readonly EntraGroup[], nameOrId: strin
59
99
  * `mailNickname`, and `id`. Used by `launchpad groups search`.
60
100
  */
61
101
  export declare function searchGroups(groups: readonly EntraGroup[], query: string): readonly EntraGroup[];
102
+ export {};
62
103
  //# sourceMappingURL=client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/groups/client.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAI9C;gDACgD;AAChD,MAAM,WAAW,UAAU;IACzB,uDAAuD;IACvD,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,6DAA6D;IAC7D,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,gEAAgE;IAChE,QAAQ,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CACtC;AAYD,oDAAoD;AACpD,eAAO,MAAM,YAAY,QAAkB,CAAC;AAK5C,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,YAAY,CAAC,EAAE,OAAO,CAAC;IAChC,gCAAgC;IAChC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IAC5B,oCAAoC;IACpC,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;CACjC;AAED,MAAM,MAAM,iBAAiB,GACzB;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,OAAO,GAAG,OAAO,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,SAAS,UAAU,EAAE,CAAA;CAAE,GAC3F;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEvC;;;;;;GAMG;AACH,wBAAsB,WAAW,CAC/B,GAAG,EAAE,SAAS,EACd,IAAI,GAAE,kBAAuB,GAC5B,OAAO,CAAC,iBAAiB,CAAC,CA0D5B;AA8DD,iDAAiD;AACjD,wBAAgB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAE7C;AAED;;;;;;;GAOG;AACH,wBAAgB,SAAS,CACvB,MAAM,EAAE,SAAS,UAAU,EAAE,EAC7B,QAAQ,EAAE,MAAM,GACf;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,GACnC;IAAE,IAAI,EAAE,WAAW,CAAA;CAAE,GACrB;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,OAAO,EAAE,SAAS,UAAU,EAAE,CAAA;CAAE,CAcxD;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,SAAS,UAAU,EAAE,EAC7B,KAAK,EAAE,MAAM,GACZ,SAAS,UAAU,EAAE,CAQvB"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/groups/client.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAI9C;gDACgD;AAChD,MAAM,WAAW,UAAU;IACzB,uDAAuD;IACvD,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,6DAA6D;IAC7D,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,gEAAgE;IAChE,QAAQ,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CACtC;AAYD,oDAAoD;AACpD,eAAO,MAAM,YAAY,QAAkB,CAAC;AAK5C,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,YAAY,CAAC,EAAE,OAAO,CAAC;IAChC,gCAAgC;IAChC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IAC5B,oCAAoC;IACpC,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;CACjC;AAED,MAAM,MAAM,iBAAiB,GACzB;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,OAAO,GAAG,OAAO,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,SAAS,UAAU,EAAE,CAAA;CAAE,GAC3F;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEvC;;;;;;GAMG;AACH,wBAAsB,WAAW,CAC/B,GAAG,EAAE,SAAS,EACd,IAAI,GAAE,kBAAuB,GAC5B,OAAO,CAAC,iBAAiB,CAAC,CA0D5B;AAiED;uEACuE;AACvE,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED,qEAAqE;AACrE,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,EAAE,SAAS,GAAG,YAAY,GAAG,YAAY,CAAC;IACzD,QAAQ,CAAC,cAAc,CAAC,EAAE,cAAc,GAAG,mBAAmB,CAAC;IAC/D,QAAQ,CAAC,KAAK,CAAC,EAAE,iBAAiB,CAAC;CACpC;AAED,MAAM,MAAM,mBAAmB,GAC3B;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,SAAS,iBAAiB,EAAE,CAAA;CAAE,GACpD;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,IAAI,EAAE,SAAS,mBAAmB,EAAE,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAC7E;IACE,IAAI,EAAE,WAAW,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,EAAE,SAAS,iBAAiB,EAAE,CAAC;IACzC,OAAO,EAAE,MAAM,CAAC;CACjB,GACD;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEvC,UAAU,cAAc;IACtB,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;CACjC;AAED;;;;;;GAMG;AACH,wBAAsB,sBAAsB,CAC1C,GAAG,EAAE,SAAS,EACd,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,IAAI,GAAE,cAAmB,GACxB,OAAO,CAAC,mBAAmB,CAAC,CAiE9B;AASD,iDAAiD;AACjD,wBAAgB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAE7C;AAED;;;;;;;GAOG;AACH,wBAAgB,SAAS,CACvB,MAAM,EAAE,SAAS,UAAU,EAAE,EAC7B,QAAQ,EAAE,MAAM,GACf;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,UAAU,CAAA;CAAE,GACnC;IAAE,IAAI,EAAE,WAAW,CAAA;CAAE,GACrB;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,OAAO,EAAE,SAAS,UAAU,EAAE,CAAA;CAAE,CAcxD;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,SAAS,UAAU,EAAE,EAC7B,KAAK,EAAE,MAAM,GACZ,SAAS,UAAU,EAAE,CAQvB"}
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const CLI_VERSION = "0.41.1";
1
+ export declare const CLI_VERSION = "0.43.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m-kopa/launchpad-cli",
3
- "version": "0.41.1",
3
+ "version": "0.43.0",
4
4
  "description": "Launchpad CLI — clone / deploy / review / merge against Launchpad-managed apps. Talks to the portal-bot endpoints (SCOPE-M-760 / T4).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: launchpad-content-pr
3
3
  description: Push a content change to a Launchpad app via `launchpad deploy` and verify it shipped via `launchpad status`. Covers the post-first-deploy iteration loop (edit → deploy → verify) — subsequent deploys commit directly to the app repo's main and the Pages build runs asynchronously, so verification is its own step. Use when someone says "push a content change", "ship an update", "/launchpad-content-pr", "verify my deploy", or after `/launchpad-deploy` reports `done` and they want to follow up with an edit.
4
- version: 0.41.1
4
+ version: 0.43.0
5
5
  ---
6
6
 
7
7
  <!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->
@@ -185,10 +185,21 @@ launchpad plan
185
185
 
186
186
  Both are offline by default (no bot). `launchpad validate
187
187
  --strict-groups` opts in to an online check that resolves
188
- `access.allowed_entra_group` (needs a session). Note it resolves only
189
- against the groups **assigned** to the Launchpad enterprise app, so it
190
- falsely fails a valid *unassigned* tenant group — pre-check those with
191
- `launchpad deploy --dry-run` instead.
188
+ `access.allowed_entra_group(s)` (needs a session). As of PS-1859 it
189
+ resolves against the **whole tenant**, so a valid *unassigned* tenant
190
+ group passes (no need to use `deploy --dry-run` as a workaround).
191
+
192
+ > **Changing the access group is NOT a content deploy.** Editing
193
+ > `access.allowed_entra_groups` and running a plain `launchpad deploy`
194
+ > ships content only — it does **not** reconcile the gateway's access
195
+ > policy, so the change won't take effect (and is refused once the
196
+ > access-drift gate is enforced). To change a live app's groups, the
197
+ > user self-serves the **gated Terraform route**: `launchpad deploy
198
+ > --apply` (preview with `--dry-run`, confirm with `launchpad status`).
199
+ > Do **not** tell the user to raise a request with the platform team —
200
+ > `--apply` IS the self-serve route. See `/launchpad-status` and the
201
+ > [auth & groups](https://get.launchpad.m-kopa.us/docs/concepts/auth-groups#changing-access-on-an-existing-app)
202
+ > doc.
192
203
 
193
204
  ## Verify the deploy
194
205
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: launchpad-deploy
3
3
  description: Walk a Launchpad user through deploying an app from their local working directory (Model A — `launchpad init` + `launchpad deploy`). Wraps the CLI verbs end-to-end: detects the app shape, scaffolds `launchpad.yaml`, resolves the allowed Entra group via `launchpad groups`, bundles the CWD via `launchpad deploy`, and watches the rollout via `launchpad status`. Use when someone says "deploy a new app", "ship my app to Launchpad", "/launchpad-deploy", "I have an app locally — get it on Launchpad", or any variant. Resume/abandon for legacy in-flight provisioning is at the bottom.
4
- version: 0.41.1
4
+ version: 0.43.0
5
5
  ---
6
6
 
7
7
  <!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->
@@ -201,17 +201,17 @@ launchpad groups show <name> # UUID + displayName + mailNickname
201
201
  launchpad groups resolve <name> # just the Entra Object-ID UUID (script-friendly)
202
202
  ```
203
203
 
204
- **Important — `groups` only sees the *assigned* set.** All four
205
- helpers (and `launchpad validate --strict-groups`, §A.4) list only the
206
- groups **assigned to the Launchpad enterprise application** in Entra.
207
- That is a discovery convenience, **not** the set of usable groups.
208
- Since PS-1733 (live 2026-06-25) an app can be gated on **any group in
209
- the tenant** — the group does **not** have to be assigned to the
210
- Launchpad app first. So if the user wants a group that does not appear
211
- in `groups list` (e.g. `G_information_security`), that is **fine**:
212
- pass it anyway, by display name or UUID. Do **not** tell the user to
213
- ask IT to assign the group to the enterprise app — that requirement
214
- was retired.
204
+ **Important — `list`/`search` are the *assigned* shortlist; `resolve`
205
+ resolves the whole tenant.** `launchpad groups list` / `search` show
206
+ only the groups **assigned to the Launchpad enterprise application**
207
+ a discovery convenience, **not** the set of usable groups. Since
208
+ PS-1733 an app can be gated on **any group in the tenant** — the group
209
+ does **not** have to be assigned first. So if the user wants a group
210
+ that does not appear in `groups list` (e.g. `G_information_security`),
211
+ that is **fine**: pass it anyway, by display name or UUID. Do **not**
212
+ tell the user to ask IT to assign the group that requirement was
213
+ retired. `launchpad groups resolve <name|uuid>` resolves any tenant
214
+ group, assigned or not (PS-1859).
215
215
 
216
216
  When the user picks a group, pass either the **displayName** or the
217
217
  **UUID** to `launchpad init --group <…>` — the CLI accepts both and
@@ -227,13 +227,18 @@ group is accepted. Members who do not carry the group as a token claim
227
227
  are authorised at runtime via a Microsoft Graph membership check
228
228
  (employees only — guests are refused).
229
229
 
230
- **Pre-checking a group reference.** Because `groups` and
231
- `--strict-groups` are assigned-only, they **falsely reject** a valid
232
- unassigned group. To pre-check any tenant group without mutating
233
- anything, run `launchpad deploy --dry-run` it resolves against the
234
- whole tenant exactly as the real submit does. Treat `--dry-run` (not
235
- `--strict-groups`) as the source of truth for "will this group
236
- resolve?".
230
+ **Pre-checking a group reference.** `launchpad validate
231
+ --strict-groups`, `launchpad groups resolve <name|uuid>`, and
232
+ `launchpad deploy --dry-run` all resolve against the **whole tenant**
233
+ (PS-1859), so any of them correctly accepts a valid unassigned group.
234
+ Use whichever fits; `--dry-run` additionally previews the Terraform.
235
+
236
+ This `--group` flow sets the group at **first deploy**. Changing the
237
+ group on an **existing** app is a different path — a plain `launchpad
238
+ deploy` ships content only and won't move the gateway policy. The
239
+ self-serve fix is `launchpad deploy --apply` (the gated Terraform
240
+ route); see `/launchpad-content-pr`. Never route the user to the
241
+ platform team for a group change.
237
242
 
238
243
  If `launchpad groups list` fails with:
239
244
 
@@ -267,13 +272,13 @@ problems. Doesn't talk to the bot by default. Useful when the user
267
272
  wants a second look before paying for an upload round-trip.
268
273
 
269
274
  Add `--strict-groups` to *additionally* resolve the manifest's
270
- allowed Entra group online against the bot's group list it catches
271
- typo'd or renamed group names before deploy time. This mode needs a
272
- session and the network (exit 1 = group not found / ambiguous,
273
- 2 = network error, 3 = no valid session). **Caveat:** it checks only
274
- the **assigned** set, so it will falsely fail a valid *unassigned*
275
- tenant group for those, pre-check with `launchpad deploy --dry-run`
276
- instead (see §A.3).
275
+ allowed Entra group online it catches typo'd / renamed / ineligible
276
+ / ambiguous group references before deploy time. As of PS-1859 it
277
+ resolves against the **whole tenant** (like the real submit), so a
278
+ valid *unassigned* group passes. This mode needs a session and the
279
+ network (exit 1 = group not found / ineligible / ambiguous,
280
+ 2 = network error, 3 = no valid session **or the caller is not an
281
+ employee** the tenant resolve is employee-gated).
277
282
 
278
283
  ```bash
279
284
  launchpad plan
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: launchpad-deploy-status
3
3
  description: Show the current provisioning stage + failure reason for a Launchpad app via `launchpad status` (Model A drift + deployment_verified) and `launchpad apps` (lifecycle bucket), or watch provisioning live with `launchpad watch`. Renders the M-892 stage trace for in-flight provisioning, and is the canonical home for `launchpad recover` (repair a terminal-failed app record that is actually live). Use when someone says "what's the status of demo-X", "/launchpad-deploy-status", "is my deploy stuck", "watch my deploy go live", "watch provisioning", "my app says failed but it's serving", or after `/launchpad-deploy` reports a non-`done` terminal stage.
4
- version: 0.41.1
4
+ version: 0.43.0
5
5
  ---
6
6
 
7
7
  <!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: launchpad-destroy
3
3
  description: Tear down a Launchpad app end-to-end via `launchpad destroy` — Cloudflare Pages project, edge-auth wiring (gateway KV/audience entries, or the Access app for `auth: access` apps), custom hostname, platform-repo TF, and the app repo (archive-renamed). Owner-only verb with a two-step destructive confirmation. Use when someone says "destroy this app", "/launchpad-destroy", "tear down `<slug>`", "delete the app", or asks to clean up a smoke-test / orphan / retired app.
4
- version: 0.41.1
4
+ version: 0.43.0
5
5
  ---
6
6
 
7
7
  <!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: launchpad-identity
3
3
  description: Teach an app author how to use the signed-in user's identity inside a Launchpad app — read the gateway-forwarded X-Launchpad-User-Assertion in a Pages Function, VERIFY it with @m-kopa/platform-auth (fail-closed), and show who's logged in (sub/email/name). Use when someone says "who is logged in", "show the current user", "get the user's email in my app", "auth in my launchpad app", "read the user identity", "/launchpad-identity", or is wiring up an /api/me for a gateway-fronted app.
4
- version: 0.41.1
4
+ version: 0.43.0
5
5
  ---
6
6
 
7
7
  <!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: launchpad-onboard
3
3
  description: One-time setup for the Launchpad CLI + Claude Code skill bundle. Verifies the `launchpad` CLI is installed and current, runs `launchpad whoami` to confirm the session is fresh, and checks the bundled skills are installed and in lock-step with the CLI. Idempotent — safe to re-run any time. Use when someone says "set me up for Launchpad", "I just got a new machine and want to use Launchpad", "/launchpad-onboard", or any of the other launchpad-* skills fails on a prereq check.
4
- version: 0.41.1
4
+ version: 0.43.0
5
5
  ---
6
6
 
7
7
  <!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: launchpad-report
3
3
  description: File a bug report or feature request to the Launchpad team's tracker from the CLI. Use when someone reports something broken, hits an error in a launchpad command, or wishes a feature existed — e.g. "this is broken", "report a bug", "can you file that", "I wish launchpad could…", "/launchpad-bug", "/launchpad-feature". Always confirm and show exactly what you'll send before filing; never file silently.
4
- version: 0.41.1
4
+ version: 0.43.0
5
5
  ---
6
6
 
7
7
  <!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: launchpad-status
3
3
  description: Show whether a Launchpad app's local launchpad.yaml matches what's deployed, and read the deployed manifest. Wraps `launchpad pull` (fetch deployed YAML) and `launchpad status` (drift report). Use when someone says "is my app in sync", "what's deployed", "show drift", "/launchpad-status", "/launchpad-pull", or after `launchpad deploy` to verify the change landed.
4
- version: 0.41.1
4
+ version: 0.43.0
5
5
  ---
6
6
 
7
7
  <!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->