@m-kopa/launchpad-cli 0.41.0 → 0.42.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,51 @@ 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.42.0 — 2026-06-30
10
+
11
+ **`launchpad validate --strict-groups` and `launchpad groups resolve`/`show`
12
+ now resolve Entra groups against the whole tenant (PS-1859).** They used to
13
+ check only the enterprise-app-**assigned** set, so a valid *unassigned* group
14
+ (e.g. `G_information_security`) was falsely reported as not-found — the durable
15
+ fix for the discoverability gap the 0.41.1 docs worked around. The resolution
16
+ runs bot-side via a new employee-gated `POST /groups/resolve` (rate-limited,
17
+ audited; the CLI never holds Graph credentials), reusing the same resolver
18
+ `launchpad deploy` uses.
19
+
20
+ - **Behavioural change (CI note):** `validate --strict-groups` now exits `0`
21
+ for a valid unassigned group (it used to fail). Exit-code *values* are
22
+ unchanged; only the verdict flips. A reference still fails for an
23
+ **unknown**, **ineligible** (distribution list / hidden-membership), or
24
+ **ambiguous** group — ambiguous now prints the candidate UUIDs.
25
+ - **Ambiguity fix (also affects deploy):** a tenant displayName matching more
26
+ than one group is now reported as ambiguous with candidate UUIDs instead of
27
+ silently resolving to the first match.
28
+ - `launchpad groups list`/`search` remain the assigned-app shortlist, now
29
+ clearly labelled ("unlisted ≠ unusable — `launchpad groups resolve
30
+ <name|uuid>` checks any tenant group").
31
+ - Skills: the interim 0.41.1 "use `deploy --dry-run` because the others lie"
32
+ caveats are removed (the others no longer lie). `launchpad-content-pr` and
33
+ `launchpad-deploy` now cross-link the self-serve **`launchpad deploy
34
+ --apply`** route for changing a live app's access group — never "raise a
35
+ request with the platform team."
36
+
37
+ ## 0.41.1 — 2026-06-30
38
+
39
+ Docs & skills correctness fix: **any Entra group in the tenant can gate an
40
+ app — it does not need to be assigned to the Launchpad enterprise
41
+ application** (PS-1733, live 2026-06-25). The docs and bundled skills still
42
+ described the retired "assigned-only" rule, which led to users being wrongly
43
+ told to ask IT to assign a group (e.g. `G_information_security`) before they
44
+ could use it. Corrected across the auth concept, manifest reference,
45
+ `first-app`/`auth` guides, and the `validate` / `groups` / `deploy` command
46
+ pages, and in the `launchpad-deploy` / `launchpad-content-pr` skills. Key
47
+ clarification: `launchpad groups` and `launchpad validate --strict-groups`
48
+ resolve only against the **assigned** set and will **falsely reject** a valid
49
+ unassigned group — use **`launchpad deploy --dry-run`** to pre-check any
50
+ tenant group (it resolves against the whole tenant, exactly as the real
51
+ submit). A reference is rejected only when the group is unknown, ineligible (a
52
+ distribution list / hidden-membership group), or an ambiguous display name.
53
+
9
54
  ## 0.41.0 — 2026-06-28
10
55
 
11
56
  Docs & skills: documented the **app catalogue** — a new
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.0";
22
+ var CLI_VERSION = "0.42.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);
@@ -8059,6 +8110,91 @@ async function loadGroups(io, refresh, json) {
8059
8110
  return { kind: "error", code: 2 };
8060
8111
  }
8061
8112
  }
8113
+ async function lookupTenantAware(assigned, key) {
8114
+ const hit = findGroup(assigned, key);
8115
+ if (hit.kind === "found") {
8116
+ return {
8117
+ kind: "found",
8118
+ group: {
8119
+ id: hit.group.id,
8120
+ displayName: hit.group.displayName,
8121
+ mailNickname: hit.group.mailNickname
8122
+ }
8123
+ };
8124
+ }
8125
+ if (hit.kind === "ambiguous") {
8126
+ return {
8127
+ kind: "ambiguous",
8128
+ matches: hit.matches.map((g) => ({ id: g.id, displayName: g.displayName }))
8129
+ };
8130
+ }
8131
+ try {
8132
+ const r = await resolveGroupRefsTenant(loadConfig(), [key]);
8133
+ switch (r.kind) {
8134
+ case "ok": {
8135
+ const g = r.groups[0];
8136
+ if (g === undefined)
8137
+ return { kind: "not-found" };
8138
+ return { kind: "found", group: { id: g.id, displayName: g.displayName, mailNickname: null } };
8139
+ }
8140
+ case "ambiguous":
8141
+ return {
8142
+ kind: "ambiguous",
8143
+ matches: r.candidates.map((c) => ({ id: c.id, displayName: c.displayName }))
8144
+ };
8145
+ case "unresolved": {
8146
+ const reason = r.refs[0]?.reason;
8147
+ if (reason === "unverified") {
8148
+ return { kind: "error", message: r.message };
8149
+ }
8150
+ if (reason === "ineligible") {
8151
+ return { kind: "ineligible", message: r.message };
8152
+ }
8153
+ return { kind: "not-found" };
8154
+ }
8155
+ case "error":
8156
+ return { kind: "error", message: r.message };
8157
+ }
8158
+ } catch (e) {
8159
+ if (e instanceof UnauthenticatedError)
8160
+ return { kind: "unauthenticated", message: e.message };
8161
+ if (e instanceof ForbiddenError)
8162
+ return { kind: "forbidden", message: e.message };
8163
+ return { kind: "error", message: describe19(e) };
8164
+ }
8165
+ }
8166
+ function renderLookupFailure(io, json, hit) {
8167
+ if (hit.kind === "unauthenticated") {
8168
+ if (json) {
8169
+ io.out(JSON.stringify({ ok: false, kind: "unauthenticated", message: hit.message }));
8170
+ } else {
8171
+ io.err(hit.message);
8172
+ io.err("Run `launchpad login` to start a session.");
8173
+ }
8174
+ return 3;
8175
+ }
8176
+ if (hit.kind === "forbidden") {
8177
+ if (json) {
8178
+ io.out(JSON.stringify({ ok: false, kind: "forbidden", message: hit.message }));
8179
+ } else {
8180
+ io.err(hit.message);
8181
+ }
8182
+ return 3;
8183
+ }
8184
+ if (hit.kind === "error") {
8185
+ renderFetchError(io, hit.message, json);
8186
+ return 2;
8187
+ }
8188
+ if (hit.kind === "ineligible") {
8189
+ if (json) {
8190
+ io.out(JSON.stringify({ ok: false, kind: "ineligible", message: hit.message }));
8191
+ } else {
8192
+ io.err(hit.message);
8193
+ }
8194
+ return 1;
8195
+ }
8196
+ return null;
8197
+ }
8062
8198
  function renderFetchError(io, message, json) {
8063
8199
  if (json) {
8064
8200
  io.out(JSON.stringify({ ok: false, kind: "fetch-error", message }));
@@ -8105,11 +8241,14 @@ async function runList(args, io) {
8105
8241
  return 0;
8106
8242
  }
8107
8243
  if (result.groups.length === 0) {
8108
- io.out("(no groups assigned to the Launchpad enterprise app yet — talk to platform-team)");
8244
+ io.out("(no groups assigned to the Launchpad enterprise app yet)");
8245
+ io.out("Unlisted ≠ unusable: any tenant group is still usable by name or UUID — " + "`launchpad groups resolve <name|uuid>` checks one.");
8109
8246
  return 0;
8110
8247
  }
8111
8248
  io.out(`${result.groups.length} group${result.groups.length === 1 ? "" : "s"} (source: ${result.source}, fetched: ${result.fetchedAt})`);
8112
8249
  renderTable2(io, result.groups);
8250
+ io.out("");
8251
+ 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
8252
  return 0;
8114
8253
  }
8115
8254
  async function runSearch(args, io) {
@@ -8138,7 +8277,8 @@ async function runSearch(args, io) {
8138
8277
  return 0;
8139
8278
  }
8140
8279
  if (matches.length === 0) {
8141
- io.out(`no matches for "${query}"`);
8280
+ io.out(`no matches for "${query}" in the assigned-app shortlist`);
8281
+ 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
8282
  return 0;
8143
8283
  }
8144
8284
  io.out(`${matches.length} match${matches.length === 1 ? "" : "es"} for "${query}":`);
@@ -8160,7 +8300,13 @@ async function runShow(args, io) {
8160
8300
  const load = await loadGroups(io, parsed.flags.refresh, parsed.flags.json);
8161
8301
  if (load.kind === "error")
8162
8302
  return load.code;
8163
- const hit = findGroup(load.result.groups, key);
8303
+ const hit = await lookupTenantAware(load.result.groups, key);
8304
+ const failure = renderLookupFailure(io, parsed.flags.json, hit);
8305
+ if (failure !== null)
8306
+ return failure;
8307
+ if (hit.kind !== "found" && hit.kind !== "ambiguous" && hit.kind !== "not-found") {
8308
+ return 2;
8309
+ }
8164
8310
  if (parsed.flags.json) {
8165
8311
  if (hit.kind === "not-found") {
8166
8312
  io.out(JSON.stringify({ ok: false, kind: "not-found", key }));
@@ -8175,7 +8321,7 @@ async function runShow(args, io) {
8175
8321
  }
8176
8322
  if (hit.kind === "not-found") {
8177
8323
  io.err(`no group matched "${key}"`);
8178
- io.err(`Run \`launchpad groups list\` to see every visible group.`);
8324
+ io.err(`Not in the assigned shortlist? Any tenant group is still usable — check the exact name/UUID.`);
8179
8325
  return 1;
8180
8326
  }
8181
8327
  if (hit.kind === "ambiguous") {
@@ -8206,7 +8352,13 @@ async function runResolve(args, io) {
8206
8352
  const load = await loadGroups(io, parsed.flags.refresh, parsed.flags.json);
8207
8353
  if (load.kind === "error")
8208
8354
  return load.code;
8209
- const hit = findGroup(load.result.groups, key);
8355
+ const hit = await lookupTenantAware(load.result.groups, key);
8356
+ const failure = renderLookupFailure(io, parsed.flags.json, hit);
8357
+ if (failure !== null)
8358
+ return failure;
8359
+ if (hit.kind !== "found" && hit.kind !== "ambiguous" && hit.kind !== "not-found") {
8360
+ return 2;
8361
+ }
8210
8362
  if (parsed.flags.json) {
8211
8363
  if (hit.kind === "not-found") {
8212
8364
  io.out(JSON.stringify({ ok: false, kind: "not-found", key }));
@@ -12395,27 +12547,33 @@ function checkStaticServeDir(manifestPath, manifest) {
12395
12547
  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
12548
  async function checkGroup(allowedGroup, cfg) {
12397
12549
  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
- };
12550
+ const r = await resolveGroupRefsTenant(cfg, [allowedGroup]);
12551
+ switch (r.kind) {
12552
+ case "ok": {
12553
+ const g = r.groups[0];
12554
+ if (g === undefined)
12555
+ return { kind: "not-found", key: allowedGroup };
12556
+ return {
12557
+ kind: "ok",
12558
+ key: allowedGroup,
12559
+ resolvedUuid: g.id,
12560
+ resolvedName: g.displayName
12561
+ };
12562
+ }
12563
+ case "ambiguous":
12564
+ return {
12565
+ kind: "ambiguous",
12566
+ key: allowedGroup,
12567
+ matches: r.candidates.map((c) => ({ id: c.id, displayName: c.displayName }))
12568
+ };
12569
+ case "unresolved":
12570
+ if (r.refs[0]?.reason === "unverified") {
12571
+ return { kind: "network", message: r.message };
12572
+ }
12573
+ return { kind: "not-found", key: allowedGroup };
12574
+ case "error":
12575
+ return { kind: "network", message: r.message };
12417
12576
  }
12418
- return { kind: "not-found", key: allowedGroup };
12419
12577
  } catch (e) {
12420
12578
  if (e instanceof UnauthenticatedError) {
12421
12579
  return { kind: "unauthenticated", message: e.message };
@@ -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"}
@@ -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":"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"}
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const CLI_VERSION = "0.41.0";
1
+ export declare const CLI_VERSION = "0.42.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.0",
3
+ "version": "0.42.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.0
4
+ version: 0.42.0
5
5
  ---
6
6
 
7
7
  <!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->
@@ -185,7 +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).
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.
189
203
 
190
204
  ## Verify the deploy
191
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.0
4
+ version: 0.42.0
5
5
  ---
6
6
 
7
7
  <!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->
@@ -195,20 +195,50 @@ The CLI resolves Entra groups via the bot's `/groups` endpoint
195
195
  endpoint directly. Two helpers:
196
196
 
197
197
  ```bash
198
- launchpad groups list # every group assigned to the Launchpad app in Entra
198
+ launchpad groups list # groups ASSIGNED to the Launchpad app in Entra
199
199
  launchpad groups search <query> # fuzzy match by name / nickname / id
200
200
  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
- `groups list` is **not** the whole tenant and not caller-scoped: it
205
- lists the groups **assigned to the Launchpad enterprise application**
206
- in Entra — the only groups that can actually grant sign-in to a
207
- deployed app.
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).
208
215
 
209
216
  When the user picks a group, pass either the **displayName** or the
210
217
  **UUID** to `launchpad init --group <…>` — the CLI accepts both and
211
- the bot canonicalises to the UUID.
218
+ the bot canonicalises to the UUID. Prefer the **UUID** for a group
219
+ that is not in `groups list`, or whenever a display name could be
220
+ ambiguous.
221
+
222
+ A group reference is rejected at deploy only when it is **unknown** (no
223
+ such group anywhere in the tenant), **ineligible** (a pure distribution
224
+ list, or a hidden-membership group the platform cannot evaluate), or an
225
+ **ambiguous** display name (use the UUID). An unassigned-but-valid
226
+ group is accepted. Members who do not carry the group as a token claim
227
+ are authorised at runtime via a Microsoft Graph membership check
228
+ (employees only — guests are refused).
229
+
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.
212
242
 
213
243
  If `launchpad groups list` fails with:
214
244
 
@@ -222,9 +252,10 @@ If `launchpad groups list` fails with:
222
252
  `appRoleAssignedTo` assignment list (missing admin consent on the
223
253
  Graph application permission), or Graph is unreachable. Surface
224
254
  the error body.
225
- - empty list → no groups are assigned to the Launchpad enterprise
226
- app in Entra. A group must be **assigned to the app** before it
227
- can gate anything membership alone is not enough.
255
+ - empty list → no groups are **assigned** to the Launchpad app. This
256
+ does **not** block you an unassigned tenant group can still be
257
+ used by name or UUID (above); the assigned set is just a discovery
258
+ shortlist.
228
259
 
229
260
  Use `launchpad groups whoami` to remind the user which groups
230
261
  **they** are currently a member of — handy when an app is gated and
@@ -241,10 +272,13 @@ problems. Doesn't talk to the bot by default. Useful when the user
241
272
  wants a second look before paying for an upload round-trip.
242
273
 
243
274
  Add `--strict-groups` to *additionally* resolve the manifest's
244
- allowed Entra group online against the bot's group list it catches
245
- typo'd or renamed group names before deploy time. This mode needs a
246
- session and the network (exit 1 = group not found / ambiguous,
247
- 2 = network error, 3 = no valid session).
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).
248
282
 
249
283
  ```bash
250
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.0
4
+ version: 0.42.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.0
4
+ version: 0.42.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.0
4
+ version: 0.42.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.0
4
+ version: 0.42.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.0
4
+ version: 0.42.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.0
4
+ version: 0.42.0
5
5
  ---
6
6
 
7
7
  <!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->