@m-kopa/launchpad-cli 0.47.2 → 0.48.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,31 @@ 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.48.0 — 2026-07-08
10
+
11
+ **One-command access reconcile — `launchpad deploy` now applies access changes
12
+ automatically (PS-2117 / ADR 0034).** Plain `launchpad deploy` reconciles both
13
+ content and access in one command: when it detects that your manifest's
14
+ `allowed_entra_groups` differ from what the gateway enforces, it no longer
15
+ stops at a message or asks — it routes straight into the gated apply flow
16
+ (opens the bot's `[launchpad] apply <slug>` PR, which auto-merges on its
17
+ non-bypassable automated checks). No `--apply` verb, no y/N prompt, no human
18
+ approval. This supersedes the 0.47 interactive inline offer (PS-2071); `--apply`
19
+ remains a **hidden alias for one release** and will be removed.
20
+
21
+ The platform no longer **blocks** an access change — it is self-serve and
22
+ owner-accountable (ADR 0034). A *dangerous* shape (a widening on a `sensitive`
23
+ app, or a broad-group grant like `S_ALL_Employees`) fires an **after-the-fact,
24
+ non-blocking** Product Security alert instead. The former two-person
25
+ `allowed-groups-change` label gate is **retired**; the byte-equal validator +
26
+ per-app `tf plan` are the non-bypassable trust root.
27
+
28
+ New: `launchpad.yaml` supports a self-declared `sensitive: true` flag (drives
29
+ the alert). To revert a bad access change fast, `launchpad rollback <prior-sha>`
30
+ re-applies the previous manifest through the same gated flow. The honest-exit
31
+ behaviour from 0.47.1 is preserved (a non-applied reconcile never reports
32
+ success).
33
+
9
34
  ## 0.47.2 — 2026-07-08
10
35
 
11
36
  **Fix: `launchpad deploy` no longer leaves you falsely "stale" after your own
package/dist/cli.js CHANGED
@@ -19,7 +19,7 @@ var __toESM = (mod, isNodeMode, target) => {
19
19
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
20
20
 
21
21
  // src/version.ts
22
- var CLI_VERSION = "0.47.2";
22
+ var CLI_VERSION = "0.48.0";
23
23
 
24
24
  // src/config.ts
25
25
  import * as os from "node:os";
@@ -2225,7 +2225,8 @@ var ManifestSchema = z.object({
2225
2225
  app: AppBoundarySchema.optional(),
2226
2226
  verify: VerifySchema.optional(),
2227
2227
  confine_origin: z.boolean().optional(),
2228
- catalogue: z.object({ listed: z.boolean().default(true) }).strict().optional()
2228
+ catalogue: z.object({ listed: z.boolean().default(true) }).strict().optional(),
2229
+ sensitive: z.boolean().optional()
2229
2230
  }).strict().superRefine((m, ctx) => {
2230
2231
  const isContainer = m.deployment.type === "container";
2231
2232
  if (m.confine_origin !== undefined) {
@@ -2842,6 +2843,7 @@ var SpecSchema = z2.object({
2842
2843
  app: AppBoundarySchema.optional(),
2843
2844
  verify: VerifySchema.optional(),
2844
2845
  confine_origin: z2.boolean().optional(),
2846
+ sensitive: z2.boolean().optional(),
2845
2847
  catalogue: z2.object({ listed: z2.boolean().default(true) }).strict().optional(),
2846
2848
  env_vars: z2.record(z2.string().regex(ENV_NAME_REGEX, "env_vars keys must be UPPER_SNAKE_CASE"), EnvVarSchema).optional(),
2847
2849
  containers: z2.array(ContainerSchema).min(1).optional(),
@@ -3396,6 +3398,9 @@ var CF_ACCOUNT_ID = "c07cb17c9f742e8144c4828b3693ca65";
3396
3398
  var R2_S3_ENDPOINT = `https://${CF_ACCOUNT_ID}.r2.cloudflarestorage.com`;
3397
3399
  // ../launchpad-engine/dist/access-drift.js
3398
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
+ function isUuid(s) {
3402
+ return UUID_RE.test(s.trim());
3403
+ }
3399
3404
  function canonicalUuid(s) {
3400
3405
  const t = s.trim();
3401
3406
  return UUID_RE.test(t) ? t.toLowerCase() : null;
@@ -3430,6 +3435,29 @@ function detectAccessDrift(declaredTokens, enforcedTokens, resolve6) {
3430
3435
  inSync: manifestOnly.length === 0 && liveOnly.length === 0 && unresolved.length === 0
3431
3436
  };
3432
3437
  }
3438
+ // ../launchpad-engine/dist/broad-groups.js
3439
+ var BROAD_GROUPS = [
3440
+ {
3441
+ uuid: "caf78902-35dd-4833-88ce-8f067eda6036",
3442
+ name: "S_ALL_Employees",
3443
+ note: "All employees — the broadest grant on the tenant; assigned to the Launchpad enterprise app. Granting this reaches the entire company."
3444
+ }
3445
+ ];
3446
+ function assertBroadGroupsWellFormed(list = BROAD_GROUPS) {
3447
+ const seen = new Set;
3448
+ for (const g of list) {
3449
+ const key = canonicalUuid(g.uuid);
3450
+ if (key === null || !isUuid(g.uuid)) {
3451
+ throw new Error(`broad-groups: entry '${g.name}' has a malformed UUID: '${g.uuid}'`);
3452
+ }
3453
+ if (seen.has(key)) {
3454
+ throw new Error(`broad-groups: duplicate UUID for '${g.name}': '${g.uuid}'`);
3455
+ }
3456
+ seen.add(key);
3457
+ }
3458
+ }
3459
+ assertBroadGroupsWellFormed();
3460
+ var BROAD_UUIDS = new Set(BROAD_GROUPS.map((g) => canonicalUuid(g.uuid)).filter((u) => u !== null));
3433
3461
  // ../launchpad-engine/dist/fleet-manifest.js
3434
3462
  import { z as z3 } from "zod";
3435
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}$/;
@@ -6561,54 +6589,28 @@ function renderManifestError(loaded, io) {
6561
6589
  // src/deploy/access-drift-offer.ts
6562
6590
  var asStrings = (v) => Array.isArray(v) ? v.map((x) => String(x)) : [];
6563
6591
  function renderAccessDriftSummary(slug, body) {
6564
- const lines = [`launchpad deploy: refused ACCESS DRIFT on "${slug}".`];
6592
+ const lines = [`launchpad deploy: access change detected on "${slug}".`];
6565
6593
  const liveOnly = asStrings(body.live_only);
6566
6594
  const manifestOnly = asStrings(body.manifest_only);
6567
6595
  if (liveOnly.length > 0) {
6568
- lines.push(` enforced but NOT declared (over-grant): ${liveOnly.join(", ")}`);
6596
+ lines.push(` enforced but NOT declared (will be removed): ${liveOnly.join(", ")}`);
6569
6597
  }
6570
6598
  if (manifestOnly.length > 0) {
6571
- lines.push(` declared but NOT enforced: ${manifestOnly.join(", ")}`);
6599
+ lines.push(` declared but NOT enforced (will be granted): ${manifestOnly.join(", ")}`);
6572
6600
  }
6573
6601
  lines.push("");
6574
- lines.push(" Your manifest's access groups don't match what the gateway enforces.");
6575
- lines.push(" `launchpad deploy` ships content only and cannot change access.");
6602
+ lines.push(" Your manifest's access groups differ from what the gateway enforces");
6603
+ lines.push(" reconciling automatically via the gated Terraform apply (no second command needed).");
6576
6604
  return lines;
6577
6605
  }
6578
- function renderManualApplyGuidance() {
6579
- return [
6580
- " Run `launchpad deploy --apply` to reconcile — it opens the gated TF PR",
6581
- " (an access-widening change needs the allowed-groups-change label from a",
6582
- " second person) and waits for the apply verdict. Then redeploy your content.",
6583
- " Nothing was committed by this attempt."
6584
- ];
6585
- }
6586
- function isYes(answer) {
6587
- const a = answer.trim().toLowerCase();
6588
- return a === "y" || a === "yes";
6589
- }
6590
6606
  async function handleAccessDrift(params) {
6591
6607
  const { slug, manifestPath, body, io, deps } = params;
6592
6608
  for (const line of renderAccessDriftSummary(slug, body))
6593
6609
  io.err(line);
6594
- if (!deps.interactive) {
6595
- for (const line of renderManualApplyGuidance())
6596
- io.err(line);
6597
- return 1;
6598
- }
6599
- io.err("");
6600
- const answer = await deps.prompt("Reconcile access now via the gated apply PR? You'll review the exact plan next. [y/N] ");
6601
- if (!isYes(answer)) {
6602
- io.err("");
6603
- io.err("Skipped — no access change was made.");
6604
- for (const line of renderManualApplyGuidance())
6605
- io.err(line);
6606
- return 1;
6607
- }
6608
6610
  io.out("");
6609
- io.out("Reconciling access via `launchpad deploy --apply` …");
6611
+ io.out("Reconciling access automatically via the gated apply PR (no second command needed) …");
6610
6612
  let applied = false;
6611
- const exit = await deps.runApply({ file: manifestPath, platformRepo: null, rePin: false, yes: false }, io, { onApplied: () => {
6613
+ const exit = await deps.runApply({ file: manifestPath, platformRepo: null, rePin: false, yes: true }, io, { onApplied: () => {
6612
6614
  applied = true;
6613
6615
  } });
6614
6616
  if (applied) {
@@ -6619,20 +6621,6 @@ async function handleAccessDrift(params) {
6619
6621
  return exit === 0 ? 1 : exit;
6620
6622
  }
6621
6623
 
6622
- // src/io/prompt.ts
6623
- function isInteractive() {
6624
- return Boolean(process.stdin.isTTY);
6625
- }
6626
- async function defaultPrompt2(question) {
6627
- const { createInterface } = await import("node:readline/promises");
6628
- const rl = createInterface({ input: process.stdin, output: process.stdout });
6629
- try {
6630
- return await rl.question(question);
6631
- } finally {
6632
- rl.close();
6633
- }
6634
- }
6635
-
6636
6624
  // src/deploy/dry-run.ts
6637
6625
  import { execFileSync } from "node:child_process";
6638
6626
  import { resolve as resolve7 } from "node:path";
@@ -7188,9 +7176,8 @@ function deployUsage() {
7188
7176
  ` + `manifest-driven dry-run (read-only preview):
7189
7177
  ` + ` launchpad deploy --dry-run [--file <manifest>] [--json]
7190
7178
  ` + `
7191
- ` + `manifest-driven apply (runs server-side via portal-bot):
7192
- ` + ` launchpad deploy --apply [--file <manifest>] [--at <sha>] [--re-pin]
7193
- ` + " [--yes] [--resume-pr <n>] [--timeout-minutes <n>]";
7179
+ ` + "access changes reconcile automatically (ADR 0034): plain `launchpad deploy`\n" + `detects a group change vs the deployed manifest and applies it via the gated
7180
+ ` + "TF route — no separate verb. (`--apply` remains a hidden alias for one release.)";
7194
7181
  }
7195
7182
  function parseArgs3(args) {
7196
7183
  let message = null;
@@ -7284,7 +7271,7 @@ function surfaceDeployExtras(body, io, slug, allowStale = false) {
7284
7271
  if (adw.manifestOnly !== undefined && adw.manifestOnly.length > 0) {
7285
7272
  io.err(` declared but NOT enforced: ${adw.manifestOnly.join(", ")}`);
7286
7273
  }
7287
- io.err(` reconcile with \`launchpad deploy --apply\` (the gated TF route) \`launchpad deploy\` can't (it ships content only).`);
7274
+ io.err(` re-run \`launchpad deploy\` to reconcile — it applies an access change automatically via the gated TF route (ADR 0034); no separate verb needed.`);
7288
7275
  }
7289
7276
  }
7290
7277
  function isStaleBlock(body) {
@@ -7495,11 +7482,7 @@ async function runModelADeploy(args) {
7495
7482
  manifestPath,
7496
7483
  body,
7497
7484
  io,
7498
- deps: {
7499
- interactive: isInteractive(),
7500
- prompt: defaultPrompt2,
7501
- runApply: runDeployApply
7502
- }
7485
+ deps: { runApply: runDeployApply }
7503
7486
  });
7504
7487
  }
7505
7488
  io.err(`launchpad deploy: bot rejected the upload (HTTP ${result.status}, ${errorCode}).`);
@@ -8969,7 +8952,7 @@ var SLUG_REGEX3 = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
8969
8952
  var initCommand = {
8970
8953
  name: "init",
8971
8954
  summary: "scaffold a new launchpad.yaml in the current directory",
8972
- run: (args, io) => runInit(args, io, defaultPrompt3)
8955
+ run: (args, io) => runInit(args, io, defaultPrompt2)
8973
8956
  };
8974
8957
  async function runInit(args, io, prompt) {
8975
8958
  const parsed = parseArgs6(args);
@@ -9459,7 +9442,7 @@ function ensureGitignoreEntries(path10, entries) {
9459
9442
  }
9460
9443
  return added;
9461
9444
  }
9462
- async function defaultPrompt3(question, fallback) {
9445
+ async function defaultPrompt2(question, fallback) {
9463
9446
  const rl = createInterface({ input: process.stdin, output: process.stdout });
9464
9447
  try {
9465
9448
  const suffix = fallback !== undefined ? ` [${fallback}]` : "";
@@ -10140,7 +10123,7 @@ import { stdin as input, stdout as output } from "node:process";
10140
10123
  var destroyCommand = {
10141
10124
  name: "destroy",
10142
10125
  summary: "tear down a Launchpad app (owner-only, destructive)",
10143
- run: (args, io) => runDestroy(args, io, defaultPrompt4, defaultIsTty)
10126
+ run: (args, io) => runDestroy(args, io, defaultPrompt3, defaultIsTty)
10144
10127
  };
10145
10128
  var SLUG_RE10 = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
10146
10129
  var SLUG_MIN_LENGTH = 3;
@@ -10372,7 +10355,7 @@ function renderBotError2(status, env, slug, io) {
10372
10355
  io.err(`launchpad destroy: bot returned HTTP ${status} (error=${errorCode}): ${message}`);
10373
10356
  }
10374
10357
  }
10375
- async function defaultPrompt4(question) {
10358
+ async function defaultPrompt3(question) {
10376
10359
  const rl = createInterface2({ input, output });
10377
10360
  try {
10378
10361
  return await rl.question(question);
@@ -11041,7 +11024,7 @@ async function runRollback(opts, io, deps = {}) {
11041
11024
  io.out(`Rollback target: ${verifiedSha.slice(0, 12)} (${manifestRelpath})`);
11042
11025
  renderDiff(current, parsed.manifest, io);
11043
11026
  if (!opts.yes) {
11044
- const prompt = deps.prompt ?? defaultPrompt5;
11027
+ const prompt = deps.prompt ?? defaultPrompt4;
11045
11028
  if (!process.stdin.isTTY && deps.prompt === undefined) {
11046
11029
  io.err("launchpad rollback: refusing to rollback non-interactively without --yes.");
11047
11030
  return 64;
@@ -11129,7 +11112,7 @@ function formatHostnames(hs) {
11129
11112
  return hs[0];
11130
11113
  return `[${hs.join(", ")}]`;
11131
11114
  }
11132
- async function defaultPrompt5(question) {
11115
+ async function defaultPrompt4(question) {
11133
11116
  const rl = createInterface3({ input: process.stdin, output: process.stderr });
11134
11117
  try {
11135
11118
  return await rl.question(question);
@@ -11436,7 +11419,7 @@ async function offerRedeploy(slug, opts, io, deps) {
11436
11419
  const isTty = (deps.isTty ?? (() => process.stdin.isTTY === true))();
11437
11420
  let doRedeploy = wantsAuto;
11438
11421
  if (!wantsAuto && isTty) {
11439
- const prompt = deps.prompt ?? defaultPrompt6;
11422
+ const prompt = deps.prompt ?? defaultPrompt5;
11440
11423
  let answer;
11441
11424
  try {
11442
11425
  answer = (await prompt(`Redeploy ${slug} now to activate? [y/N] `)).trim().toLowerCase();
@@ -11457,7 +11440,7 @@ async function offerRedeploy(slug, opts, io, deps) {
11457
11440
  };
11458
11441
  return run({ slug, file: opts.file ?? null, verify: true, verifyTimeoutMs: 180000 }, io, rdeps);
11459
11442
  }
11460
- async function defaultPrompt6(question) {
11443
+ async function defaultPrompt5(question) {
11461
11444
  const rl = createInterface4({ input: input2, output: output2 });
11462
11445
  try {
11463
11446
  return await rl.question(question);
@@ -1 +1 @@
1
- {"version":3,"file":"deploy.d.ts","sourceRoot":"","sources":["../../src/commands/deploy.ts"],"names":[],"mappings":"AAsEA,OAAO,EAGL,4BAA4B,EAC7B,MAAM,uBAAuB,CAAC;AAQ/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":"AAsEA,OAAO,EAGL,4BAA4B,EAC7B,MAAM,uBAAuB,CAAC;AAO/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"}
@@ -6,35 +6,31 @@ export interface AccessDriftBody {
6
6
  readonly manifest_only?: unknown;
7
7
  }
8
8
  export interface AccessDriftDeps {
9
- /** True when we may prompt (stdin is a TTY). */
10
- readonly interactive: boolean;
11
- /** Ask a yes/no question; resolves the raw typed line. */
12
- readonly prompt: (question: string) => Promise<string>;
13
9
  /**
14
10
  * Run the gated apply flow. Defaults to `runDeployApply` at the call site.
15
11
  * The third arg carries an `onApplied` hook the flow fires only when a
16
12
  * terraform apply actually completes — so we never claim success when the
17
- * operator reviews the plan and aborts (both paths exit 0).
13
+ * apply reaches a non-applied terminal state (both paths can exit 0).
18
14
  */
19
15
  readonly runApply: (opts: ApplyOptions, io: CliIo, deps?: {
20
16
  readonly onApplied?: () => void;
21
17
  }) => Promise<ExitCode>;
22
18
  }
23
19
  /**
24
- * The drift summary — printed in every case (interactive or not). Names both
25
- * directions of the divergence so the operator can see exactly what would
26
- * change before deciding.
20
+ * The drift summary — printed before the automatic reconcile. Names both
21
+ * directions of the divergence so the operator can see exactly what is about
22
+ * to change.
27
23
  */
28
24
  export declare function renderAccessDriftSummary(slug: string, body: AccessDriftBody): string[];
29
25
  /**
30
- * The manual fallback guidance printed when the session is non-interactive,
31
- * or when the operator declines the inline offer.
32
- */
33
- export declare function renderManualApplyGuidance(): string[];
34
- /**
35
- * Surface an access-drift refusal and, when interactive, offer to reconcile
36
- * inline via the gated apply flow. Returns the exit code the deploy command
37
- * should return.
26
+ * Surface an access-drift refusal and reconcile it automatically via the gated
27
+ * apply flow. Returns the exit code the deploy command should return.
28
+ *
29
+ * Honest exit (carried forward from the 0.47.1 fix): the apply flow exits 0
30
+ * both when a terraform apply actually completes AND when it reaches a
31
+ * non-applied terminal state without changing anything, so we only report
32
+ * "reconciled" and only return the apply's 0 when `onApplied` fired. A bare
33
+ * 0 with nothing applied would be false success a script/CI would observe.
38
34
  */
39
35
  export declare function handleAccessDrift(params: {
40
36
  readonly slug: string;
@@ -1 +1 @@
1
- {"version":3,"file":"access-drift-offer.d.ts","sourceRoot":"","sources":["../../src/deploy/access-drift-offer.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE/C,8EAA8E;AAC9E,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;CAClC;AAED,MAAM,WAAW,eAAe;IAC9B,gDAAgD;IAChD,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;IAC9B,0DAA0D;IAC1D,QAAQ,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACvD;;;;;OAKG;IACH,QAAQ,CAAC,QAAQ,EAAE,CACjB,IAAI,EAAE,YAAY,EAClB,EAAE,EAAE,KAAK,EACT,IAAI,CAAC,EAAE;QAAE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,IAAI,CAAA;KAAE,KACvC,OAAO,CAAC,QAAQ,CAAC,CAAC;CACxB;AAKD;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,eAAe,GACpB,MAAM,EAAE,CAoBV;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,IAAI,MAAM,EAAE,CAOpD;AAQD;;;;GAIG;AACH,wBAAsB,iBAAiB,CAAC,MAAM,EAAE;IAC9C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAC/B,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;CAChC,GAAG,OAAO,CAAC,QAAQ,CAAC,CAoDpB"}
1
+ {"version":3,"file":"access-drift-offer.d.ts","sourceRoot":"","sources":["../../src/deploy/access-drift-offer.ts"],"names":[],"mappings":"AAwBA,OAAO,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AACxD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE/C,8EAA8E;AAC9E,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;CAClC;AAED,MAAM,WAAW,eAAe;IAC9B;;;;;OAKG;IACH,QAAQ,CAAC,QAAQ,EAAE,CACjB,IAAI,EAAE,YAAY,EAClB,EAAE,EAAE,KAAK,EACT,IAAI,CAAC,EAAE;QAAE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,IAAI,CAAA;KAAE,KACvC,OAAO,CAAC,QAAQ,CAAC,CAAC;CACxB;AAKD;;;;GAIG;AACH,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,eAAe,GACpB,MAAM,EAAE,CAoBV;AAED;;;;;;;;;GASG;AACH,wBAAsB,iBAAiB,CAAC,MAAM,EAAE;IAC9C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAC/B,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IACnB,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;CAChC,GAAG,OAAO,CAAC,QAAQ,CAAC,CA+BpB"}
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const CLI_VERSION = "0.47.2";
1
+ export declare const CLI_VERSION = "0.48.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.47.2",
3
+ "version": "0.48.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.47.2
4
+ version: 0.48.0
5
5
  ---
6
6
 
7
7
  <!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->
@@ -189,19 +189,20 @@ Both are offline by default (no bot). `launchpad validate
189
189
  resolves against the **whole tenant**, so a valid *unassigned* tenant
190
190
  group passes (no need to use `deploy --dry-run` as a workaround).
191
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). When that refusal happens in an
197
- > **interactive terminal**, the CLI now **offers to reconcile inline**
198
- > answering `y` routes straight into the gated apply flow (which shows
199
- > the plan and confirms again before opening the PR). To change a live
200
- > app's groups the user self-serves the **gated Terraform route**:
201
- > `launchpad deploy --apply` (preview with `--dry-run`, confirm with
202
- > `launchpad status`) or just accept the inline offer. Do **not** tell
203
- > the user to raise a request with the platform team `--apply` IS the
204
- > self-serve route. See `/launchpad-status` and the
192
+ > **Changing the access group needs no special command (PS-2117 / ADR
193
+ > 0034).** Edit `access.allowed_entra_groups` and run a plain
194
+ > `launchpad deploy`: it detects the group change and **reconciles
195
+ > access automatically** via the gated apply PR, which auto-merges on
196
+ > its automated checks (byte-equal validator + `tf plan`) with no
197
+ > `--apply` verb, no prompt, and no human approval. The content bundle
198
+ > is refused only for that one deploy (access reconciles first); re-run
199
+ > `launchpad deploy` afterwards to ship content. The platform does not
200
+ > block the change a dangerous shape (a widening on a `sensitive` app,
201
+ > or a broad-group grant) fires an after-the-fact Product Security
202
+ > alert. Preview with `--dry-run`; undo a bad change with
203
+ > `launchpad rollback <prior-sha>`. Do **not** tell the user to raise a
204
+ > request with the platform team — it is fully self-serve. See
205
+ > `/launchpad-status` and the
205
206
  > [auth & groups](https://get.launchpad.m-kopa.us/docs/concepts/auth-groups#changing-access-on-an-existing-app)
206
207
  > doc.
207
208
 
@@ -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.47.2
4
+ version: 0.48.0
5
5
  ---
6
6
 
7
7
  <!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->
@@ -234,16 +234,18 @@ are authorised at runtime via a Microsoft Graph membership check
234
234
  Use whichever fits; `--dry-run` additionally previews the Terraform.
235
235
 
236
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. When a
239
- plain deploy detects access drift in an **interactive terminal** it now
240
- **offers to reconcile inline** (answer `y` to route straight into the
241
- gated apply flow, which shows the plan and confirms again before it
242
- opens the PR); in a non-interactive session it prints the manual
243
- `launchpad deploy --apply` route and exits non-zero. Either way the fix
244
- is the gated Terraform apply see `/launchpad-content-pr`. Never route
245
- the user to the platform team for a group change, and never tell them
246
- access drift is unfixable: the self-serve reconcile is one keystroke.
237
+ group on an **existing** app needs no different command: a plain
238
+ `launchpad deploy` **reconciles access automatically** (PS-2117 / ADR
239
+ 0034). When it detects that the manifest's groups differ from what the
240
+ gateway enforces, it routes straight into the gated apply flow — opens
241
+ the bot's `[launchpad] apply <slug>` PR, which **auto-merges on its
242
+ automated checks** (byte-equal validator + `tf plan`) with no `--apply`
243
+ verb, no prompt, and no human approval. Once access is in sync, re-run
244
+ `launchpad deploy` to ship your content. The platform does not block an
245
+ access change; a dangerous shape (a widening on a `sensitive` app, or a
246
+ broad-group grant) fires an after-the-fact Product Security alert. Never
247
+ route the user to the platform team for a group change, and never tell
248
+ them access drift is unfixable — it just reconciles.
247
249
 
248
250
  If `launchpad groups list` fails with:
249
251
 
@@ -377,8 +379,8 @@ Flags — there is nothing useful to pass on the Model A path:
377
379
  - `--message` is sent as a request header on the legacy path only,
378
380
  and the bot currently **ignores** it — don't offer it as a
379
381
  change-log mechanism.
380
- - `--file <path>` is valid only with the `--dry-run` / `--apply`
381
- manifest modes, not with a bundle deploy.
382
+ - `--file <path>` is valid only with the `--dry-run` manifest preview
383
+ (and the hidden `--apply` alias), not with a bundle deploy.
382
384
 
383
385
  ### A.6 Terminal handling
384
386
 
@@ -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.47.2
4
+ version: 0.48.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.47.2
4
+ version: 0.48.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.47.2
4
+ version: 0.48.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.47.2
4
+ version: 0.48.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.47.2
4
+ version: 0.48.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.47.2
4
+ version: 0.48.0
5
5
  ---
6
6
 
7
7
  <!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->
@@ -331,19 +331,18 @@ which the bot handles.)
331
331
  ### "drift: access.allowed_entra_groups"
332
332
 
333
333
  Group binding changed in your local manifest. This is the
334
- **load-bearing** drift — it controls who can access the app. After
335
- `launchpad deploy`, the resulting PR will require the
336
- `allowed-groups-change: true` label from a non-author human reviewer
337
- (AC-S4) before it can auto-merge.
338
-
339
- Reconcile it with **`launchpad deploy --apply`** a plain
340
- `launchpad deploy` ships content only and does **not** re-apply the
341
- gateway's access policy. On a gateway app where enforcement is on, the
342
- bot **refuses** a content deploy while the manifest's groups diverge
343
- from what the gateway actually enforces (so access changes can't be
344
- silently dropped); `--apply` is the gated route that reconciles the
345
- enforced KV allow-list to the manifest. `launchpad status --strict`
346
- exits non-zero on any drift, so it can gate CI.
334
+ **load-bearing** drift — it controls who can access the app.
335
+
336
+ Reconcile it by just running **`launchpad deploy`** (PS-2117 / ADR
337
+ 0034): a plain deploy detects the group divergence and reconciles it
338
+ automatically via the gated apply PR, which auto-merges on its
339
+ automated checks (byte-equal validator + `tf plan`) with no `--apply`
340
+ verb, no prompt, and no human approval. The platform does not block the
341
+ change; a dangerous shape (a widening on a `sensitive` app, or a
342
+ broad-group grant) fires an after-the-fact Product Security alert. To
343
+ undo a bad change fast, `launchpad rollback <prior-sha>`.
344
+ `launchpad status --strict` exits non-zero on any drift, so it can gate
345
+ CI.
347
346
 
348
347
  ## Related skills
349
348