@m-kopa/launchpad-cli 0.40.0 → 0.41.1
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 +79 -0
- package/dist/cli.js +313 -153
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/status.d.ts +8 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/deploy/manifest-state.d.ts +7 -0
- package/dist/deploy/manifest-state.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/package.json +3 -1
- package/skills/launchpad-content-pr/SKILL.md +10 -2
- package/skills/launchpad-deploy/SKILL.md +50 -11
- package/skills/launchpad-deploy-status/SKILL.md +1 -1
- package/skills/launchpad-destroy/SKILL.md +1 -1
- package/skills/launchpad-identity/SKILL.md +1 -1
- package/skills/launchpad-onboard/SKILL.md +1 -1
- package/skills/launchpad-report/SKILL.md +1 -1
- package/skills/launchpad-status/SKILL.md +10 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,85 @@ 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.41.1 — 2026-06-30
|
|
10
|
+
|
|
11
|
+
Docs & skills correctness fix: **any Entra group in the tenant can gate an
|
|
12
|
+
app — it does not need to be assigned to the Launchpad enterprise
|
|
13
|
+
application** (PS-1733, live 2026-06-25). The docs and bundled skills still
|
|
14
|
+
described the retired "assigned-only" rule, which led to users being wrongly
|
|
15
|
+
told to ask IT to assign a group (e.g. `G_information_security`) before they
|
|
16
|
+
could use it. Corrected across the auth concept, manifest reference,
|
|
17
|
+
`first-app`/`auth` guides, and the `validate` / `groups` / `deploy` command
|
|
18
|
+
pages, and in the `launchpad-deploy` / `launchpad-content-pr` skills. Key
|
|
19
|
+
clarification: `launchpad groups` and `launchpad validate --strict-groups`
|
|
20
|
+
resolve only against the **assigned** set and will **falsely reject** a valid
|
|
21
|
+
unassigned group — use **`launchpad deploy --dry-run`** to pre-check any
|
|
22
|
+
tenant group (it resolves against the whole tenant, exactly as the real
|
|
23
|
+
submit). A reference is rejected only when the group is unknown, ineligible (a
|
|
24
|
+
distribution list / hidden-membership group), or an ambiguous display name.
|
|
25
|
+
|
|
26
|
+
## 0.41.0 — 2026-06-28
|
|
27
|
+
|
|
28
|
+
Docs & skills: documented the **app catalogue** — a new
|
|
29
|
+
[`concepts/catalogue`](./docs/concepts/catalogue.md) page (listed by default,
|
|
30
|
+
opt out with `catalogue: { listed: false }`, and why **listing is not access**),
|
|
31
|
+
plus a **runtime-vs-administrative access** section (owner / co-owner / editor)
|
|
32
|
+
in the auth concept. Bundled skills refreshed (synced in place by
|
|
33
|
+
`launchpad update`): the `launchpad-status` skill now points access-drift
|
|
34
|
+
reconciliation at `launchpad deploy --apply`, `launchpad-deploy` notes the
|
|
35
|
+
catalogue opt-out at scaffold time, and `launchpad-content-pr` surfaces
|
|
36
|
+
`redeploy` as a general verb (activate a pushed secret on a Pages tier / force a
|
|
37
|
+
clean rebuild).
|
|
38
|
+
|
|
39
|
+
Feature (PS-1755 / PS-1762, T3): **`launchpad deploy` now refuses to ship
|
|
40
|
+
content when a gateway app's declared access groups don't match what the gateway
|
|
41
|
+
enforces** — the bot checks this server-side (unbypassable) on every deploy to
|
|
42
|
+
an existing gateway app and rejects with a clear message naming both directions
|
|
43
|
+
(over-grant + declared-not-enforced). `launchpad deploy` ships content only and
|
|
44
|
+
can't reconcile access; change it via the gated Terraform route, then redeploy.
|
|
45
|
+
The refuse is gated by a **default-OFF** server-side kill-switch, so the
|
|
46
|
+
operational rollout controls go-live and benign pre-existing drift can't break
|
|
47
|
+
deploys on day one; while OFF, drift is surfaced as a warning instead. See
|
|
48
|
+
[`deploy`](./docs/commands/deploy.md).
|
|
49
|
+
|
|
50
|
+
Feature (PS-1755 / PS-1763, T4): **`launchpad status` now reports access
|
|
51
|
+
drift** for gateway apps — whether the gateway actually enforces the Entra
|
|
52
|
+
groups your manifest declares. Because changing `access.allowed_entra_groups`
|
|
53
|
+
and re-running `launchpad deploy` ships content only (it does not re-apply the
|
|
54
|
+
gateway policy), the declared and enforced allow-lists can silently diverge.
|
|
55
|
+
|
|
56
|
+
- Surfaces both directions: **enforced but not declared** (an over-grant — still
|
|
57
|
+
reachable) and **declared but not enforced** (locked out despite the manifest).
|
|
58
|
+
Group names and UUIDs for the same group are treated as equal.
|
|
59
|
+
- `--strict` now exits `1` on access drift as well as manifest drift; `--json`
|
|
60
|
+
carries an `accessDrift` object. Non-gateway (Cloudflare Access) apps and
|
|
61
|
+
older bots show no access-drift line.
|
|
62
|
+
- A fleet-wide audit across all gateway apps lives at
|
|
63
|
+
`scripts/fleet/access-drift-audit.mjs` (`--list` for the offline app
|
|
64
|
+
enumeration). See [`status`](./docs/commands/status.md).
|
|
65
|
+
|
|
66
|
+
Feature (sp-bx7q2m, PS-1572 / PS-1584): the platform now runs a **server-side
|
|
67
|
+
anonymous-reachability check** at deploy time and blocks a deploy whose
|
|
68
|
+
`pages.dev` origin serves content/data to unauthenticated callers — closing the
|
|
69
|
+
class behind the dsr-photo-watch leak at the deploy layer.
|
|
70
|
+
|
|
71
|
+
- New optional manifest key **`verify.protected_path`** (`react`/`react+api`):
|
|
72
|
+
declare a representative protected route (e.g. `/api/health`) the deploy gate
|
|
73
|
+
probes anonymously and requires to return `401`/`403`, not `2xx`. `static`
|
|
74
|
+
apps need no declaration — their whole origin is probed. See the
|
|
75
|
+
[`verify`](./docs/manifest/index.md#verify-deploy-gate-checks) reference.
|
|
76
|
+
- `launchpad validate` accepts and shape-checks the new `verify` block
|
|
77
|
+
(root-relative path required). Backward-compatible — omitting it is valid.
|
|
78
|
+
- Enforcement is server-side in portal-bot; this CLI release only adds the
|
|
79
|
+
manifest field + validation. Version bump deferred to merge.
|
|
80
|
+
- New optional manifest key **`confine_origin`** (boolean, gateway pages apps):
|
|
81
|
+
opt a `react`/`react+api` app's `<id>.pages.dev` apex into service-token
|
|
82
|
+
confinement, once it verifies the gateway's `X-Launchpad-User-Assertion`
|
|
83
|
+
header. Default unchanged (verifier apps unconfined, static always confined);
|
|
84
|
+
`false` is rejected on static apps. See the
|
|
85
|
+
[`confine_origin`](./docs/manifest/index.md#confine_origin-apex-confinement)
|
|
86
|
+
reference (PS-1572 / ADR 0031).
|
|
87
|
+
|
|
9
88
|
## 0.40.0 — 2026-06-21
|
|
10
89
|
|
|
11
90
|
Feature (sp-rptndg, PS-1601): `launchpad` now nudges you toward `launchpad bug`
|
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.
|
|
22
|
+
var CLI_VERSION = "0.41.1";
|
|
23
23
|
|
|
24
24
|
// src/config.ts
|
|
25
25
|
import * as os from "node:os";
|
|
@@ -2206,6 +2206,9 @@ var AppBoundarySchema = z.object({
|
|
|
2206
2206
|
exclude: z.array(z.string().min(1)).optional()
|
|
2207
2207
|
}).strict();
|
|
2208
2208
|
var ProductionEnvSchema = z.record(z.string().regex(ENV_NAME_REGEX, "production_env keys must be UPPER_SNAKE_CASE"), z.string());
|
|
2209
|
+
var VerifySchema = z.object({
|
|
2210
|
+
protected_path: z.string().min(1).regex(/^\//, "verify.protected_path must be a root-relative path beginning with '/'")
|
|
2211
|
+
}).strict();
|
|
2209
2212
|
var ManifestSchema = z.object({
|
|
2210
2213
|
apiVersion: z.literal("launchpad.m-kopa.us/v1alpha1"),
|
|
2211
2214
|
kind: z.literal("App"),
|
|
@@ -2219,9 +2222,33 @@ var ManifestSchema = z.object({
|
|
|
2219
2222
|
targets: z.array(TargetSchema).optional(),
|
|
2220
2223
|
build: BuildSchema.optional(),
|
|
2221
2224
|
production_env: ProductionEnvSchema.optional(),
|
|
2222
|
-
app: AppBoundarySchema.optional()
|
|
2225
|
+
app: AppBoundarySchema.optional(),
|
|
2226
|
+
verify: VerifySchema.optional(),
|
|
2227
|
+
confine_origin: z.boolean().optional(),
|
|
2228
|
+
catalogue: z.object({ listed: z.boolean().default(true) }).strict().optional()
|
|
2223
2229
|
}).strict().superRefine((m, ctx) => {
|
|
2224
2230
|
const isContainer = m.deployment.type === "container";
|
|
2231
|
+
if (m.confine_origin !== undefined) {
|
|
2232
|
+
if (isContainer) {
|
|
2233
|
+
ctx.addIssue({
|
|
2234
|
+
code: z.ZodIssueCode.custom,
|
|
2235
|
+
path: ["confine_origin"],
|
|
2236
|
+
message: "confine_origin is only valid for gateway pages apps (static/react/react+api); container apps have no pages.dev surface."
|
|
2237
|
+
});
|
|
2238
|
+
} else if (m.auth === "access") {
|
|
2239
|
+
ctx.addIssue({
|
|
2240
|
+
code: z.ZodIssueCode.custom,
|
|
2241
|
+
path: ["confine_origin"],
|
|
2242
|
+
message: "confine_origin applies to gateway-fronted apps (auth: gateway); it has no meaning under Cloudflare Access."
|
|
2243
|
+
});
|
|
2244
|
+
} else if (m.deployment.type === "static" && m.confine_origin === false) {
|
|
2245
|
+
ctx.addIssue({
|
|
2246
|
+
code: z.ZodIssueCode.custom,
|
|
2247
|
+
path: ["confine_origin"],
|
|
2248
|
+
message: "confine_origin: false is not allowed for static apps — they have no verifier, so an unconfined apex would be world-readable (ADR 0031)."
|
|
2249
|
+
});
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2225
2252
|
if (isContainer && m.auth === "gateway") {
|
|
2226
2253
|
ctx.addIssue({
|
|
2227
2254
|
code: z.ZodIssueCode.custom,
|
|
@@ -2813,6 +2840,9 @@ var SpecSchema = z2.object({
|
|
|
2813
2840
|
auth: z2.enum(AUTH_MODES).optional(),
|
|
2814
2841
|
build: BuildSchema.optional(),
|
|
2815
2842
|
app: AppBoundarySchema.optional(),
|
|
2843
|
+
verify: VerifySchema.optional(),
|
|
2844
|
+
confine_origin: z2.boolean().optional(),
|
|
2845
|
+
catalogue: z2.object({ listed: z2.boolean().default(true) }).strict().optional(),
|
|
2816
2846
|
env_vars: z2.record(z2.string().regex(ENV_NAME_REGEX, "env_vars keys must be UPPER_SNAKE_CASE"), EnvVarSchema).optional(),
|
|
2817
2847
|
containers: z2.array(ContainerSchema).min(1).optional(),
|
|
2818
2848
|
secrets: SecretsSchema.optional(),
|
|
@@ -2835,6 +2865,27 @@ var SpecSchema = z2.object({
|
|
|
2835
2865
|
message: "containers[] is required when appType is 'container'"
|
|
2836
2866
|
});
|
|
2837
2867
|
}
|
|
2868
|
+
if (s.confine_origin !== undefined) {
|
|
2869
|
+
if (isContainer) {
|
|
2870
|
+
ctx.addIssue({
|
|
2871
|
+
code: z2.ZodIssueCode.custom,
|
|
2872
|
+
path: ["confine_origin"],
|
|
2873
|
+
message: "confine_origin is only valid for gateway pages apps; container apps have no pages.dev surface."
|
|
2874
|
+
});
|
|
2875
|
+
} else if (s.auth === "access") {
|
|
2876
|
+
ctx.addIssue({
|
|
2877
|
+
code: z2.ZodIssueCode.custom,
|
|
2878
|
+
path: ["confine_origin"],
|
|
2879
|
+
message: "confine_origin applies to gateway-fronted apps (auth: gateway); it has no meaning under Cloudflare Access."
|
|
2880
|
+
});
|
|
2881
|
+
} else if (s.appType === "static" && s.confine_origin === false) {
|
|
2882
|
+
ctx.addIssue({
|
|
2883
|
+
code: z2.ZodIssueCode.custom,
|
|
2884
|
+
path: ["confine_origin"],
|
|
2885
|
+
message: "confine_origin: false is not allowed for static apps — an unconfined apex with no verifier is world-readable (ADR 0031)."
|
|
2886
|
+
});
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2838
2889
|
if (!isContainer && s.containers !== undefined) {
|
|
2839
2890
|
ctx.addIssue({
|
|
2840
2891
|
code: z2.ZodIssueCode.custom,
|
|
@@ -3343,9 +3394,45 @@ function emitSecretStep(lines, binding) {
|
|
|
3343
3394
|
// ../launchpad-engine/dist/per-app-workspace.js
|
|
3344
3395
|
var CF_ACCOUNT_ID = "c07cb17c9f742e8144c4828b3693ca65";
|
|
3345
3396
|
var R2_S3_ENDPOINT = `https://${CF_ACCOUNT_ID}.r2.cloudflarestorage.com`;
|
|
3397
|
+
// ../launchpad-engine/dist/access-drift.js
|
|
3398
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3399
|
+
function canonicalUuid(s) {
|
|
3400
|
+
const t = s.trim();
|
|
3401
|
+
return UUID_RE.test(t) ? t.toLowerCase() : null;
|
|
3402
|
+
}
|
|
3403
|
+
function normalise(tokens, side, resolve6) {
|
|
3404
|
+
const uuids = new Set;
|
|
3405
|
+
const unresolved = [];
|
|
3406
|
+
for (const raw of tokens) {
|
|
3407
|
+
const direct = canonicalUuid(raw);
|
|
3408
|
+
const uuid = direct ?? (() => {
|
|
3409
|
+
const resolved = resolve6(raw);
|
|
3410
|
+
return resolved === null ? null : canonicalUuid(resolved);
|
|
3411
|
+
})();
|
|
3412
|
+
if (uuid === null) {
|
|
3413
|
+
unresolved.push({ side, token: raw });
|
|
3414
|
+
} else {
|
|
3415
|
+
uuids.add(uuid);
|
|
3416
|
+
}
|
|
3417
|
+
}
|
|
3418
|
+
return { uuids, unresolved };
|
|
3419
|
+
}
|
|
3420
|
+
function detectAccessDrift(declaredTokens, enforcedTokens, resolve6) {
|
|
3421
|
+
const declared = normalise(declaredTokens, "manifest", resolve6);
|
|
3422
|
+
const enforced = normalise(enforcedTokens, "enforced", resolve6);
|
|
3423
|
+
const manifestOnly = [...declared.uuids].filter((u) => !enforced.uuids.has(u)).sort();
|
|
3424
|
+
const liveOnly = [...enforced.uuids].filter((u) => !declared.uuids.has(u)).sort();
|
|
3425
|
+
const unresolved = [...declared.unresolved, ...enforced.unresolved];
|
|
3426
|
+
return {
|
|
3427
|
+
manifestOnly,
|
|
3428
|
+
liveOnly,
|
|
3429
|
+
unresolved,
|
|
3430
|
+
inSync: manifestOnly.length === 0 && liveOnly.length === 0 && unresolved.length === 0
|
|
3431
|
+
};
|
|
3432
|
+
}
|
|
3346
3433
|
// ../launchpad-engine/dist/fleet-manifest.js
|
|
3347
3434
|
import { z as z3 } from "zod";
|
|
3348
|
-
var
|
|
3435
|
+
var UUID_RE2 = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
3349
3436
|
var TF_RESIDENCIES = ["per-app-workspace", "main-tf"];
|
|
3350
3437
|
var APP_SHAPES = [
|
|
3351
3438
|
"pages",
|
|
@@ -3372,7 +3459,7 @@ var FLEET_TIERS = [
|
|
|
3372
3459
|
"out-of-scope"
|
|
3373
3460
|
];
|
|
3374
3461
|
var DEVICE_POSTURE = ["gateway-eligible", "retained-access"];
|
|
3375
|
-
var GroupId = z3.string().regex(
|
|
3462
|
+
var GroupId = z3.string().regex(UUID_RE2, "Entra group ids are emitted as canonical UUIDs in the JWT groups claim — display names will not match policies.");
|
|
3376
3463
|
var FleetAppSchema = z3.object({
|
|
3377
3464
|
slug: z3.string().regex(SLUG_REGEX),
|
|
3378
3465
|
residency: z3.enum(TF_RESIDENCIES),
|
|
@@ -3644,8 +3731,8 @@ async function bundleAndDeploy(args) {
|
|
|
3644
3731
|
}
|
|
3645
3732
|
|
|
3646
3733
|
// src/deploy/verify-deploy.ts
|
|
3647
|
-
import { readFileSync as
|
|
3648
|
-
import { join as
|
|
3734
|
+
import { readFileSync as readFileSync8, existsSync as existsSync4 } from "node:fs";
|
|
3735
|
+
import { join as join9 } from "node:path";
|
|
3649
3736
|
|
|
3650
3737
|
// src/deploy/deployment-status.ts
|
|
3651
3738
|
async function fetchStandingExceptions(cfg, slug, fetcher = fetch) {
|
|
@@ -3860,7 +3947,7 @@ function redactDiagnostic(text, maxLen = MAX_DIAGNOSTIC_LEN) {
|
|
|
3860
3947
|
}
|
|
3861
3948
|
|
|
3862
3949
|
// src/commands/status.ts
|
|
3863
|
-
import { readFileSync as
|
|
3950
|
+
import { readFileSync as readFileSync7 } from "node:fs";
|
|
3864
3951
|
|
|
3865
3952
|
// src/deploy/manifest-state.ts
|
|
3866
3953
|
async function fetchManifestState(cfg, slug, opts = {}, fetcher = fetch) {
|
|
@@ -3872,6 +3959,7 @@ async function fetchManifestState(cfg, slug, opts = {}, fetcher = fetch) {
|
|
|
3872
3959
|
lastAppliedManifestSha: raw.lastAppliedManifestSha,
|
|
3873
3960
|
appRepoHeadSha: raw.appRepoHeadSha ?? null,
|
|
3874
3961
|
openPr: raw.openPr,
|
|
3962
|
+
enforcedGroups: raw.enforcedGroups ?? null,
|
|
3875
3963
|
...raw.manifestYaml !== undefined ? { manifestYaml: raw.manifestYaml } : {}
|
|
3876
3964
|
};
|
|
3877
3965
|
}
|
|
@@ -3916,6 +4004,110 @@ function inferSlug(opts) {
|
|
|
3916
4004
|
return fromManifest ?? fromDir;
|
|
3917
4005
|
}
|
|
3918
4006
|
|
|
4007
|
+
// src/groups/client.ts
|
|
4008
|
+
import { mkdirSync, readFileSync as readFileSync6, writeFileSync } from "node:fs";
|
|
4009
|
+
import { dirname as dirname5, join as join8 } from "node:path";
|
|
4010
|
+
var CACHE_TTL_MS = 60 * 60 * 1000;
|
|
4011
|
+
var CACHE_FILENAME = "groups.json";
|
|
4012
|
+
async function fetchGroups(cfg, opts = {}) {
|
|
4013
|
+
const now = opts.now ?? Date.now;
|
|
4014
|
+
const cachePath = join8(cfg.cacheDir, CACHE_FILENAME);
|
|
4015
|
+
if (opts.forceRefresh !== true) {
|
|
4016
|
+
const cached = readCache(cachePath);
|
|
4017
|
+
if (cached !== null) {
|
|
4018
|
+
const ageMs = now() - Date.parse(cached.fetchedAt);
|
|
4019
|
+
if (Number.isFinite(ageMs) && ageMs >= 0 && ageMs < CACHE_TTL_MS) {
|
|
4020
|
+
return {
|
|
4021
|
+
kind: "ok",
|
|
4022
|
+
source: "cache",
|
|
4023
|
+
fetchedAt: cached.fetchedAt,
|
|
4024
|
+
groups: cached.groups
|
|
4025
|
+
};
|
|
4026
|
+
}
|
|
4027
|
+
}
|
|
4028
|
+
}
|
|
4029
|
+
let response;
|
|
4030
|
+
try {
|
|
4031
|
+
response = await apiJson(cfg, { path: "/groups" }, opts.fetcher);
|
|
4032
|
+
} catch (e) {
|
|
4033
|
+
if (e instanceof UnauthenticatedError || e instanceof ForbiddenError) {
|
|
4034
|
+
throw e;
|
|
4035
|
+
}
|
|
4036
|
+
return { kind: "error", message: describe11(e) };
|
|
4037
|
+
}
|
|
4038
|
+
if (typeof response !== "object" || response === null || !Array.isArray(response.groups) || !response.groups.every(isEntraGroup)) {
|
|
4039
|
+
return {
|
|
4040
|
+
kind: "error",
|
|
4041
|
+
message: "bot returned a malformed groups response"
|
|
4042
|
+
};
|
|
4043
|
+
}
|
|
4044
|
+
const groups = response.groups;
|
|
4045
|
+
const fetchedAt = new Date(now()).toISOString();
|
|
4046
|
+
writeCache(cachePath, { fetchedAt, groups });
|
|
4047
|
+
return { kind: "ok", source: "fresh", fetchedAt, groups };
|
|
4048
|
+
}
|
|
4049
|
+
function readCache(path8) {
|
|
4050
|
+
let raw;
|
|
4051
|
+
try {
|
|
4052
|
+
raw = readFileSync6(path8, "utf8");
|
|
4053
|
+
} catch {
|
|
4054
|
+
return null;
|
|
4055
|
+
}
|
|
4056
|
+
let parsed;
|
|
4057
|
+
try {
|
|
4058
|
+
parsed = JSON.parse(raw);
|
|
4059
|
+
} catch {
|
|
4060
|
+
return null;
|
|
4061
|
+
}
|
|
4062
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
4063
|
+
return null;
|
|
4064
|
+
const p = parsed;
|
|
4065
|
+
if (typeof p.fetchedAt !== "string")
|
|
4066
|
+
return null;
|
|
4067
|
+
if (!Array.isArray(p.groups))
|
|
4068
|
+
return null;
|
|
4069
|
+
for (const g of p.groups)
|
|
4070
|
+
if (!isEntraGroup(g))
|
|
4071
|
+
return null;
|
|
4072
|
+
return p;
|
|
4073
|
+
}
|
|
4074
|
+
function isEntraGroup(value) {
|
|
4075
|
+
if (typeof value !== "object" || value === null)
|
|
4076
|
+
return false;
|
|
4077
|
+
const g = value;
|
|
4078
|
+
return typeof g.id === "string" && typeof g.displayName === "string" && (typeof g.mailNickname === "string" || g.mailNickname === null);
|
|
4079
|
+
}
|
|
4080
|
+
function writeCache(path8, envelope) {
|
|
4081
|
+
try {
|
|
4082
|
+
mkdirSync(dirname5(path8), { recursive: true });
|
|
4083
|
+
writeFileSync(path8, JSON.stringify(envelope), "utf8");
|
|
4084
|
+
} catch {}
|
|
4085
|
+
}
|
|
4086
|
+
function describe11(e) {
|
|
4087
|
+
return e instanceof Error ? e.message : String(e);
|
|
4088
|
+
}
|
|
4089
|
+
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
|
+
function isUuid2(value) {
|
|
4091
|
+
return UUID_PATTERN.test(value);
|
|
4092
|
+
}
|
|
4093
|
+
function findGroup(groups, nameOrId) {
|
|
4094
|
+
if (isUuid2(nameOrId)) {
|
|
4095
|
+
const lower = nameOrId.toLowerCase();
|
|
4096
|
+
const hit = groups.find((g) => g.id.toLowerCase() === lower);
|
|
4097
|
+
return hit ? { kind: "found", group: hit } : { kind: "not-found" };
|
|
4098
|
+
}
|
|
4099
|
+
const matches = groups.filter((g) => g.displayName === nameOrId || g.mailNickname !== null && g.mailNickname === nameOrId);
|
|
4100
|
+
if (matches.length === 0)
|
|
4101
|
+
return { kind: "not-found" };
|
|
4102
|
+
if (matches.length === 1)
|
|
4103
|
+
return { kind: "found", group: matches[0] };
|
|
4104
|
+
return { kind: "ambiguous", matches };
|
|
4105
|
+
}
|
|
4106
|
+
function searchGroups(groups, query) {
|
|
4107
|
+
const q = query.toLowerCase();
|
|
4108
|
+
return groups.filter((g) => g.displayName.toLowerCase().includes(q) || g.mailNickname !== null && g.mailNickname.toLowerCase().includes(q) || g.id.toLowerCase().includes(q));
|
|
4109
|
+
}
|
|
4110
|
+
|
|
3919
4111
|
// src/watch/capabilities.ts
|
|
3920
4112
|
function isUtf8(env) {
|
|
3921
4113
|
const v = `${env.LC_ALL ?? ""}|${env.LC_CTYPE ?? ""}|${env.LANG ?? ""}`.toLowerCase();
|
|
@@ -4548,7 +4740,7 @@ function mapBotError(e, slug, io) {
|
|
|
4548
4740
|
io.err(`launchpad status: ${e.message}`);
|
|
4549
4741
|
return 2;
|
|
4550
4742
|
}
|
|
4551
|
-
io.err(`launchpad status failed: ${
|
|
4743
|
+
io.err(`launchpad status failed: ${describe12(e)}`);
|
|
4552
4744
|
return 2;
|
|
4553
4745
|
}
|
|
4554
4746
|
async function runStatus(args, io) {
|
|
@@ -4574,10 +4766,10 @@ async function runStatus(args, io) {
|
|
|
4574
4766
|
}
|
|
4575
4767
|
let localYaml = null;
|
|
4576
4768
|
try {
|
|
4577
|
-
localYaml =
|
|
4769
|
+
localYaml = readFileSync7(parsed.file, "utf8");
|
|
4578
4770
|
} catch (e) {
|
|
4579
4771
|
if (!isEnoent(e)) {
|
|
4580
|
-
io.err(`launchpad status: cannot read local manifest at ${parsed.file}: ${
|
|
4772
|
+
io.err(`launchpad status: cannot read local manifest at ${parsed.file}: ${describe12(e)}`);
|
|
4581
4773
|
return 2;
|
|
4582
4774
|
}
|
|
4583
4775
|
}
|
|
@@ -4588,7 +4780,7 @@ async function runStatus(args, io) {
|
|
|
4588
4780
|
const { parse: parseYaml4 } = await import("yaml");
|
|
4589
4781
|
localObj = parseYaml4(localYaml);
|
|
4590
4782
|
} catch (e) {
|
|
4591
|
-
io.err(`launchpad status: ${parsed.file} is not valid YAML: ${
|
|
4783
|
+
io.err(`launchpad status: ${parsed.file} is not valid YAML: ${describe12(e)}`);
|
|
4592
4784
|
return 2;
|
|
4593
4785
|
}
|
|
4594
4786
|
const localParse = parseManifest(localObj);
|
|
@@ -4617,7 +4809,7 @@ async function runStatus(args, io) {
|
|
|
4617
4809
|
if (e instanceof UnauthenticatedError || e instanceof ForbiddenError) {
|
|
4618
4810
|
return mapBotError(e, parsed.slug, io);
|
|
4619
4811
|
}
|
|
4620
|
-
io.err(`launchpad status: live deployment state unavailable (${
|
|
4812
|
+
io.err(`launchpad status: live deployment state unavailable (${describe12(e)}) — ` + `the report below is from the platform manifest view only.`);
|
|
4621
4813
|
}
|
|
4622
4814
|
let standingExceptions = null;
|
|
4623
4815
|
try {
|
|
@@ -4626,7 +4818,7 @@ async function runStatus(args, io) {
|
|
|
4626
4818
|
if (e instanceof UnauthenticatedError || e instanceof ForbiddenError) {
|
|
4627
4819
|
return mapBotError(e, parsed.slug, io);
|
|
4628
4820
|
}
|
|
4629
|
-
io.err(`launchpad status: standing-exception inventory unavailable (${
|
|
4821
|
+
io.err(`launchpad status: standing-exception inventory unavailable (${describe12(e)}).`);
|
|
4630
4822
|
}
|
|
4631
4823
|
if (state.manifestYaml === null || state.manifestYaml === undefined) {
|
|
4632
4824
|
const live = deployment?.liveDeployment ?? null;
|
|
@@ -4678,7 +4870,7 @@ async function runStatus(args, io) {
|
|
|
4678
4870
|
const { parse: parseYaml4 } = await import("yaml");
|
|
4679
4871
|
deployedObj = parseYaml4(state.manifestYaml);
|
|
4680
4872
|
} catch (e) {
|
|
4681
|
-
io.err(`launchpad status: deployed manifest at ${state.lastAppliedManifestSha} is not valid YAML: ${
|
|
4873
|
+
io.err(`launchpad status: deployed manifest at ${state.lastAppliedManifestSha} is not valid YAML: ${describe12(e)}`);
|
|
4682
4874
|
return 2;
|
|
4683
4875
|
}
|
|
4684
4876
|
const deployedParse = parseManifest(deployedObj);
|
|
@@ -4691,6 +4883,16 @@ async function runStatus(args, io) {
|
|
|
4691
4883
|
}
|
|
4692
4884
|
const deployed = deployedParse.manifest;
|
|
4693
4885
|
const drift = computeDrift(local, deployed);
|
|
4886
|
+
let accessDrift;
|
|
4887
|
+
if (state.enforcedGroups !== null) {
|
|
4888
|
+
let resolver;
|
|
4889
|
+
try {
|
|
4890
|
+
resolver = await buildGroupResolver(cfg);
|
|
4891
|
+
} catch (e) {
|
|
4892
|
+
return mapBotError(e, parsed.slug, io);
|
|
4893
|
+
}
|
|
4894
|
+
accessDrift = detectAccessDrift(allowedEntraGroups(local.access), state.enforcedGroups, resolver);
|
|
4895
|
+
}
|
|
4694
4896
|
const result = {
|
|
4695
4897
|
state: drift.length === 0 ? "in_sync" : "drift",
|
|
4696
4898
|
slug: parsed.slug,
|
|
@@ -4701,14 +4903,30 @@ async function runStatus(args, io) {
|
|
|
4701
4903
|
driftFields: drift.map((d) => d.path),
|
|
4702
4904
|
driftDetails: drift,
|
|
4703
4905
|
...deploymentKnown ? { deployment } : {},
|
|
4704
|
-
...standingExceptions !== null ? { standingExceptions } : {}
|
|
4906
|
+
...standingExceptions !== null ? { standingExceptions } : {},
|
|
4907
|
+
...accessDrift !== undefined ? { accessDrift } : {}
|
|
4705
4908
|
};
|
|
4706
4909
|
emit3(result, parsed.json, io);
|
|
4707
|
-
if (result.state === "drift"
|
|
4910
|
+
if (parsed.strict && (result.state === "drift" || accessDrift?.inSync === false)) {
|
|
4708
4911
|
return 1;
|
|
4709
4912
|
}
|
|
4710
4913
|
return 0;
|
|
4711
4914
|
}
|
|
4915
|
+
async function buildGroupResolver(cfg) {
|
|
4916
|
+
try {
|
|
4917
|
+
const res = await fetchGroups(cfg);
|
|
4918
|
+
if (res.kind !== "ok")
|
|
4919
|
+
return () => null;
|
|
4920
|
+
return (nameOrId) => {
|
|
4921
|
+
const hit = findGroup(res.groups, nameOrId);
|
|
4922
|
+
return hit.kind === "found" ? hit.group.id : null;
|
|
4923
|
+
};
|
|
4924
|
+
} catch (e) {
|
|
4925
|
+
if (e instanceof UnauthenticatedError || e instanceof ForbiddenError)
|
|
4926
|
+
throw e;
|
|
4927
|
+
return () => null;
|
|
4928
|
+
}
|
|
4929
|
+
}
|
|
4712
4930
|
function computeDrift(local, deployed) {
|
|
4713
4931
|
const diffs = [];
|
|
4714
4932
|
const cmp = (path8, l, d) => {
|
|
@@ -4820,6 +5038,7 @@ function emit3(out, asJson, io) {
|
|
|
4820
5038
|
case "in_sync":
|
|
4821
5039
|
io.out(`${out.slug}: live, in sync` + (out.deployedSha ? ` (content @ ${out.deployedSha.slice(0, 7)})` : ""));
|
|
4822
5040
|
surfaceHeadVsDeployed(out, io);
|
|
5041
|
+
surfaceAccessDrift(out, io);
|
|
4823
5042
|
surfaceDeployment(out, io);
|
|
4824
5043
|
surfaceExceptions(out, io);
|
|
4825
5044
|
return;
|
|
@@ -4831,11 +5050,28 @@ function emit3(out, asJson, io) {
|
|
|
4831
5050
|
io.out(` deployed: ${formatValue(d.deployed)}`);
|
|
4832
5051
|
}
|
|
4833
5052
|
surfaceHeadVsDeployed(out, io);
|
|
5053
|
+
surfaceAccessDrift(out, io);
|
|
4834
5054
|
surfaceDeployment(out, io);
|
|
4835
5055
|
surfaceExceptions(out, io);
|
|
4836
5056
|
return;
|
|
4837
5057
|
}
|
|
4838
5058
|
}
|
|
5059
|
+
function surfaceAccessDrift(out, io) {
|
|
5060
|
+
const ad = out.accessDrift;
|
|
5061
|
+
if (ad === undefined || ad.inSync)
|
|
5062
|
+
return;
|
|
5063
|
+
io.out(" ⚠ ACCESS DRIFT — your declared groups are not what the gateway enforces:");
|
|
5064
|
+
if (ad.liveOnly.length > 0) {
|
|
5065
|
+
io.out(` enforced but NOT declared (over-grant — still reachable): ${ad.liveOnly.join(", ")}`);
|
|
5066
|
+
}
|
|
5067
|
+
if (ad.manifestOnly.length > 0) {
|
|
5068
|
+
io.out(` declared but NOT enforced (locked out despite the manifest): ${ad.manifestOnly.join(", ")}`);
|
|
5069
|
+
}
|
|
5070
|
+
for (const u of ad.unresolved) {
|
|
5071
|
+
io.out(` unresolved group (${u.side}): ${u.token}`);
|
|
5072
|
+
}
|
|
5073
|
+
io.out(" `launchpad deploy` will NOT fix this — access changes go via the gated TF route.");
|
|
5074
|
+
}
|
|
4839
5075
|
function surfaceNoLocalManifestNote(out, io) {
|
|
4840
5076
|
if (out.drift !== null)
|
|
4841
5077
|
return;
|
|
@@ -5028,7 +5264,7 @@ function printUsage2(io) {
|
|
|
5028
5264
|
].join(`
|
|
5029
5265
|
`));
|
|
5030
5266
|
}
|
|
5031
|
-
function
|
|
5267
|
+
function describe12(e) {
|
|
5032
5268
|
return e instanceof Error ? e.message : String(e);
|
|
5033
5269
|
}
|
|
5034
5270
|
function isEnoent(e) {
|
|
@@ -5039,10 +5275,10 @@ function isEnoent(e) {
|
|
|
5039
5275
|
var VERIFY_MISMATCH_EXIT = 65;
|
|
5040
5276
|
var NO_DEPLOYMENT_EXIT = 69;
|
|
5041
5277
|
function readLocalEntrypoint(cwd) {
|
|
5042
|
-
for (const rel of ["index.html",
|
|
5043
|
-
const p =
|
|
5278
|
+
for (const rel of ["index.html", join9("static", "index.html")]) {
|
|
5279
|
+
const p = join9(cwd, rel);
|
|
5044
5280
|
if (existsSync4(p))
|
|
5045
|
-
return new Uint8Array(
|
|
5281
|
+
return new Uint8Array(readFileSync8(p));
|
|
5046
5282
|
}
|
|
5047
5283
|
return null;
|
|
5048
5284
|
}
|
|
@@ -5276,7 +5512,7 @@ async function verifyFirstDeployServed(args, liveUrl, io, deps) {
|
|
|
5276
5512
|
|
|
5277
5513
|
// src/commands/deploy.ts
|
|
5278
5514
|
import { parse as parseYaml5 } from "yaml";
|
|
5279
|
-
import { readFileSync as
|
|
5515
|
+
import { readFileSync as readFileSync10 } from "node:fs";
|
|
5280
5516
|
|
|
5281
5517
|
// src/deploy/git-files.ts
|
|
5282
5518
|
import { spawn as spawn3 } from "node:child_process";
|
|
@@ -5717,15 +5953,15 @@ function parseDeployFlags(args) {
|
|
|
5717
5953
|
|
|
5718
5954
|
// src/deploy/apply.ts
|
|
5719
5955
|
import { existsSync as existsSync5, rmSync } from "node:fs";
|
|
5720
|
-
import { resolve as resolvePath, join as
|
|
5956
|
+
import { resolve as resolvePath, join as join10 } from "node:path";
|
|
5721
5957
|
|
|
5722
5958
|
// src/manifest/load.ts
|
|
5723
|
-
import { readFileSync as
|
|
5959
|
+
import { readFileSync as readFileSync9 } from "node:fs";
|
|
5724
5960
|
import { parse as parseYaml4, YAMLParseError } from "yaml";
|
|
5725
5961
|
function loadManifest(path8) {
|
|
5726
5962
|
let raw;
|
|
5727
5963
|
try {
|
|
5728
|
-
raw =
|
|
5964
|
+
raw = readFileSync9(path8, "utf8");
|
|
5729
5965
|
} catch (err) {
|
|
5730
5966
|
const e = err;
|
|
5731
5967
|
if (e.code === "ENOENT") {
|
|
@@ -5894,7 +6130,7 @@ async function pollUntilApplied(args) {
|
|
|
5894
6130
|
path: `/apps/${args.slug}/manifest/state`
|
|
5895
6131
|
}, args.fetcher);
|
|
5896
6132
|
} catch (e) {
|
|
5897
|
-
args.io.err(`! state fetch failed (will retry): ${
|
|
6133
|
+
args.io.err(`! state fetch failed (will retry): ${describe13(e)}`);
|
|
5898
6134
|
await sleep(args.pollIntervalSec * 1000);
|
|
5899
6135
|
continue;
|
|
5900
6136
|
}
|
|
@@ -5947,14 +6183,14 @@ function sleep(ms) {
|
|
|
5947
6183
|
return new Promise((res) => setTimeout(res, ms));
|
|
5948
6184
|
}
|
|
5949
6185
|
function deletePinIfPresent(cfg, slug, io) {
|
|
5950
|
-
const pinPath =
|
|
6186
|
+
const pinPath = join10(cfg.stateDir, slug, "group.json");
|
|
5951
6187
|
if (!existsSync5(pinPath))
|
|
5952
6188
|
return;
|
|
5953
6189
|
try {
|
|
5954
6190
|
rmSync(pinPath, { force: true });
|
|
5955
6191
|
io.out(` (cleaned up obsolete pin file ${pinPath})`);
|
|
5956
6192
|
} catch (e) {
|
|
5957
|
-
io.err(`! warning: failed to delete obsolete pin file ${pinPath}: ${
|
|
6193
|
+
io.err(`! warning: failed to delete obsolete pin file ${pinPath}: ${describe13(e)}`);
|
|
5958
6194
|
}
|
|
5959
6195
|
}
|
|
5960
6196
|
async function loadSlugForResume(opts, io) {
|
|
@@ -5978,7 +6214,7 @@ async function defaultPrompt(question) {
|
|
|
5978
6214
|
rl.close();
|
|
5979
6215
|
}
|
|
5980
6216
|
}
|
|
5981
|
-
function
|
|
6217
|
+
function describe13(e) {
|
|
5982
6218
|
return e instanceof Error ? e.message : String(e);
|
|
5983
6219
|
}
|
|
5984
6220
|
function mapHttpError(e, slug, io) {
|
|
@@ -6002,7 +6238,7 @@ function mapHttpError(e, slug, io) {
|
|
|
6002
6238
|
io.err(`launchpad deploy --apply: ${e.message}`);
|
|
6003
6239
|
return 1;
|
|
6004
6240
|
}
|
|
6005
|
-
io.err(`launchpad deploy --apply failed: ${
|
|
6241
|
+
io.err(`launchpad deploy --apply failed: ${describe13(e)}`);
|
|
6006
6242
|
return 2;
|
|
6007
6243
|
}
|
|
6008
6244
|
function renderManifestError(loaded, io) {
|
|
@@ -6044,7 +6280,7 @@ async function runDeployDryRun(opts, io, deps = {}) {
|
|
|
6044
6280
|
try {
|
|
6045
6281
|
manifestSha = (deps.gitHeadSha ?? defaultGitHeadSha)(process.cwd());
|
|
6046
6282
|
} catch (e) {
|
|
6047
|
-
const msg = `failed to resolve git HEAD in ${process.cwd()}: ${
|
|
6283
|
+
const msg = `failed to resolve git HEAD in ${process.cwd()}: ${describe14(e)}`;
|
|
6048
6284
|
if (opts.json) {
|
|
6049
6285
|
io.out(JSON.stringify({ ok: false, kind: "git-error", message: msg }));
|
|
6050
6286
|
} else {
|
|
@@ -6093,7 +6329,7 @@ function defaultGitHeadSha(cwd) {
|
|
|
6093
6329
|
});
|
|
6094
6330
|
return out.trim();
|
|
6095
6331
|
}
|
|
6096
|
-
function
|
|
6332
|
+
function describe14(e) {
|
|
6097
6333
|
return e instanceof Error ? e.message : String(e);
|
|
6098
6334
|
}
|
|
6099
6335
|
function mapHttpError2(e, slug, json, io) {
|
|
@@ -6477,7 +6713,7 @@ async function runDeploy(args, io) {
|
|
|
6477
6713
|
const contentManifestPath = path8.join(process.cwd(), "launchpad.yaml");
|
|
6478
6714
|
if (existsSync6(contentManifestPath)) {
|
|
6479
6715
|
try {
|
|
6480
|
-
contentManifestYaml =
|
|
6716
|
+
contentManifestYaml = readFileSync10(contentManifestPath, "utf8");
|
|
6481
6717
|
} catch {
|
|
6482
6718
|
contentManifestYaml = null;
|
|
6483
6719
|
}
|
|
@@ -6551,7 +6787,7 @@ async function runDeploy(args, io) {
|
|
|
6551
6787
|
io.err(`launchpad deploy: ${e.message}`);
|
|
6552
6788
|
return 1;
|
|
6553
6789
|
}
|
|
6554
|
-
io.err(`launchpad deploy failed: ${
|
|
6790
|
+
io.err(`launchpad deploy failed: ${describe15(e)}`);
|
|
6555
6791
|
return 1;
|
|
6556
6792
|
}
|
|
6557
6793
|
}
|
|
@@ -6623,7 +6859,7 @@ function formatBytes2(n) {
|
|
|
6623
6859
|
return `${(n / 1024).toFixed(1)}KB`;
|
|
6624
6860
|
return `${(n / (1024 * 1024)).toFixed(1)}MB`;
|
|
6625
6861
|
}
|
|
6626
|
-
function
|
|
6862
|
+
function describe15(e) {
|
|
6627
6863
|
return e instanceof Error ? e.message : String(e);
|
|
6628
6864
|
}
|
|
6629
6865
|
function surfaceDeployExtras(body, io, slug, allowStale = false) {
|
|
@@ -6656,6 +6892,17 @@ function surfaceDeployExtras(body, io, slug, allowStale = false) {
|
|
|
6656
6892
|
}
|
|
6657
6893
|
io.out(` Full list: \`launchpad status ${slug}\`.`);
|
|
6658
6894
|
}
|
|
6895
|
+
const adw = body.access_drift_warning;
|
|
6896
|
+
if (adw !== undefined) {
|
|
6897
|
+
io.err("warning: ACCESS DRIFT — declared groups don't match what the gateway enforces:");
|
|
6898
|
+
if (adw.liveOnly !== undefined && adw.liveOnly.length > 0) {
|
|
6899
|
+
io.err(` enforced but NOT declared (over-grant): ${adw.liveOnly.join(", ")}`);
|
|
6900
|
+
}
|
|
6901
|
+
if (adw.manifestOnly !== undefined && adw.manifestOnly.length > 0) {
|
|
6902
|
+
io.err(` declared but NOT enforced: ${adw.manifestOnly.join(", ")}`);
|
|
6903
|
+
}
|
|
6904
|
+
io.err(` reconcile access via the gated TF route — \`launchpad deploy\` can't (it ships content only).`);
|
|
6905
|
+
}
|
|
6659
6906
|
}
|
|
6660
6907
|
function isStaleBlock(body) {
|
|
6661
6908
|
const code = body?.error;
|
|
@@ -6688,7 +6935,7 @@ async function runModelADeploy(args) {
|
|
|
6688
6935
|
let slug;
|
|
6689
6936
|
let appType = "static";
|
|
6690
6937
|
try {
|
|
6691
|
-
const manifestObj = parseYaml5(
|
|
6938
|
+
const manifestObj = parseYaml5(readFileSync10(manifestPath, "utf8"));
|
|
6692
6939
|
const metaSlug = resolveManifestSlug(manifestObj);
|
|
6693
6940
|
if (metaSlug === null) {
|
|
6694
6941
|
io.err(`launchpad deploy: launchpad.yaml is missing metadata.slug (v2) / metadata.name (v1). ` + `Run \`launchpad init\` again to regenerate the manifest.`);
|
|
@@ -6700,7 +6947,7 @@ async function runModelADeploy(args) {
|
|
|
6700
6947
|
appType = dtype;
|
|
6701
6948
|
}
|
|
6702
6949
|
} catch (e) {
|
|
6703
|
-
io.err(`launchpad deploy: failed to read ${manifestPath}: ${
|
|
6950
|
+
io.err(`launchpad deploy: failed to read ${manifestPath}: ${describe15(e)}`);
|
|
6704
6951
|
return 1;
|
|
6705
6952
|
}
|
|
6706
6953
|
const noVerify = args.argv.includes("--no-verify");
|
|
@@ -6730,7 +6977,7 @@ async function runModelADeploy(args) {
|
|
|
6730
6977
|
try {
|
|
6731
6978
|
cfg = loadConfig();
|
|
6732
6979
|
} catch (e) {
|
|
6733
|
-
io.err(`launchpad deploy: ${
|
|
6980
|
+
io.err(`launchpad deploy: ${describe15(e)}`);
|
|
6734
6981
|
return 1;
|
|
6735
6982
|
}
|
|
6736
6983
|
const deployBaseline = noVerify ? null : await readDeploymentBaseline(realCommitWaitDeps(cfg), slug);
|
|
@@ -6759,7 +7006,7 @@ async function runModelADeploy(args) {
|
|
|
6759
7006
|
io.err(" run `launchpad login` to refresh your session.");
|
|
6760
7007
|
return 1;
|
|
6761
7008
|
}
|
|
6762
|
-
io.err(`launchpad deploy: unexpected error: ${
|
|
7009
|
+
io.err(`launchpad deploy: unexpected error: ${describe15(e)}`);
|
|
6763
7010
|
return 1;
|
|
6764
7011
|
}
|
|
6765
7012
|
switch (result.kind) {
|
|
@@ -6792,6 +7039,23 @@ async function runModelADeploy(args) {
|
|
|
6792
7039
|
surfaceStaleBlock(body, io);
|
|
6793
7040
|
return 1;
|
|
6794
7041
|
}
|
|
7042
|
+
if (result.status === 409 && errorCode === "access_drift") {
|
|
7043
|
+
io.err(`launchpad deploy: refused — ACCESS DRIFT on "${slug}".`);
|
|
7044
|
+
const liveOnly = Array.isArray(body.live_only) ? body.live_only : [];
|
|
7045
|
+
const manifestOnly = Array.isArray(body.manifest_only) ? body.manifest_only : [];
|
|
7046
|
+
if (liveOnly.length > 0) {
|
|
7047
|
+
io.err(` enforced but NOT declared (over-grant): ${liveOnly.map(String).join(", ")}`);
|
|
7048
|
+
}
|
|
7049
|
+
if (manifestOnly.length > 0) {
|
|
7050
|
+
io.err(` declared but NOT enforced: ${manifestOnly.map(String).join(", ")}`);
|
|
7051
|
+
}
|
|
7052
|
+
io.err("");
|
|
7053
|
+
io.err(" Your manifest's access groups don't match what the gateway enforces.");
|
|
7054
|
+
io.err(" `launchpad deploy` ships content only and cannot change access — reconcile");
|
|
7055
|
+
io.err(" via the gated TF route (see the access-change runbook), then redeploy.");
|
|
7056
|
+
io.err(" Nothing was committed by this attempt.");
|
|
7057
|
+
return 1;
|
|
7058
|
+
}
|
|
6795
7059
|
io.err(`launchpad deploy: bot rejected the upload (HTTP ${result.status}, ${errorCode}).`);
|
|
6796
7060
|
if (typeof body.message === "string") {
|
|
6797
7061
|
io.err(` ${body.message}`);
|
|
@@ -6887,7 +7151,7 @@ async function runModelADeploy(args) {
|
|
|
6887
7151
|
}
|
|
6888
7152
|
|
|
6889
7153
|
// src/commands/redeploy.ts
|
|
6890
|
-
import { existsSync as existsSync7, readFileSync as
|
|
7154
|
+
import { existsSync as existsSync7, readFileSync as readFileSync11 } from "node:fs";
|
|
6891
7155
|
import { resolve as resolvePath2 } from "node:path";
|
|
6892
7156
|
import { parse as parseYaml6 } from "yaml";
|
|
6893
7157
|
var SLUG_RE6 = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
|
|
@@ -6968,7 +7232,7 @@ function readLocalAppType(cwd, file) {
|
|
|
6968
7232
|
if (!existsSync7(manifestPath))
|
|
6969
7233
|
return null;
|
|
6970
7234
|
try {
|
|
6971
|
-
const obj = parseYaml6(
|
|
7235
|
+
const obj = parseYaml6(readFileSync11(manifestPath, "utf8"));
|
|
6972
7236
|
return coerceAppType(obj?.deployment?.type);
|
|
6973
7237
|
} catch {
|
|
6974
7238
|
return null;
|
|
@@ -7150,7 +7414,7 @@ async function runEnvvars(args, io) {
|
|
|
7150
7414
|
io.err(`launchpad envvars: ${e.message}`);
|
|
7151
7415
|
return 1;
|
|
7152
7416
|
}
|
|
7153
|
-
io.err(`launchpad envvars failed: ${
|
|
7417
|
+
io.err(`launchpad envvars failed: ${describe16(e)}`);
|
|
7154
7418
|
return 1;
|
|
7155
7419
|
}
|
|
7156
7420
|
}
|
|
@@ -7247,13 +7511,13 @@ function renderList(envVars, io) {
|
|
|
7247
7511
|
io.out(fmt(row));
|
|
7248
7512
|
}
|
|
7249
7513
|
}
|
|
7250
|
-
function
|
|
7514
|
+
function describe16(e) {
|
|
7251
7515
|
return e instanceof Error ? e.message : String(e);
|
|
7252
7516
|
}
|
|
7253
7517
|
|
|
7254
7518
|
// src/commands/generate.ts
|
|
7255
|
-
import { mkdirSync, readFileSync as
|
|
7256
|
-
import { dirname as
|
|
7519
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync12, writeFileSync as writeFileSync2 } from "node:fs";
|
|
7520
|
+
import { dirname as dirname6, resolve as resolve8, relative as relative3 } from "node:path";
|
|
7257
7521
|
var generateCommand = {
|
|
7258
7522
|
name: "generate",
|
|
7259
7523
|
summary: "emit derived artefacts (wrangler.toml, deploy.yml) from launchpad.yaml",
|
|
@@ -7271,7 +7535,7 @@ async function runGenerate(args, io) {
|
|
|
7271
7535
|
if (result.kind !== "ok") {
|
|
7272
7536
|
return flags.json ? renderManifestErrorJson(result, io) : renderManifestErrorHuman(result, io);
|
|
7273
7537
|
}
|
|
7274
|
-
const appRoot =
|
|
7538
|
+
const appRoot = dirname6(manifestPath);
|
|
7275
7539
|
const wranglerPath = resolve8(appRoot, "container", "wrangler.toml");
|
|
7276
7540
|
const workflowPath = resolve8(appRoot, ".github", "workflows", "deploy.yml");
|
|
7277
7541
|
const wranglerOut = generateWranglerToml(result.manifest);
|
|
@@ -7315,8 +7579,8 @@ function applyOne(artefact, path10, out, force) {
|
|
|
7315
7579
|
};
|
|
7316
7580
|
}
|
|
7317
7581
|
try {
|
|
7318
|
-
|
|
7319
|
-
|
|
7582
|
+
mkdirSync2(dirname6(path10), { recursive: true });
|
|
7583
|
+
writeFileSync2(path10, out.content, "utf8");
|
|
7320
7584
|
return {
|
|
7321
7585
|
artefact,
|
|
7322
7586
|
action: {
|
|
@@ -7340,7 +7604,7 @@ function applyOne(artefact, path10, out, force) {
|
|
|
7340
7604
|
}
|
|
7341
7605
|
function readIfExists(path10) {
|
|
7342
7606
|
try {
|
|
7343
|
-
return { kind: "ok", content:
|
|
7607
|
+
return { kind: "ok", content: readFileSync12(path10, "utf8") };
|
|
7344
7608
|
} catch (err) {
|
|
7345
7609
|
const e = err;
|
|
7346
7610
|
if (e.code === "ENOENT")
|
|
@@ -7550,110 +7814,6 @@ function parseFlags(args) {
|
|
|
7550
7814
|
return { kind: "ok", file, dryRun, force, json };
|
|
7551
7815
|
}
|
|
7552
7816
|
|
|
7553
|
-
// src/groups/client.ts
|
|
7554
|
-
import { mkdirSync as mkdirSync2, readFileSync as readFileSync12, writeFileSync as writeFileSync2 } from "node:fs";
|
|
7555
|
-
import { dirname as dirname6, join as join11 } from "node:path";
|
|
7556
|
-
var CACHE_TTL_MS = 60 * 60 * 1000;
|
|
7557
|
-
var CACHE_FILENAME = "groups.json";
|
|
7558
|
-
async function fetchGroups(cfg, opts = {}) {
|
|
7559
|
-
const now = opts.now ?? Date.now;
|
|
7560
|
-
const cachePath = join11(cfg.cacheDir, CACHE_FILENAME);
|
|
7561
|
-
if (opts.forceRefresh !== true) {
|
|
7562
|
-
const cached = readCache(cachePath);
|
|
7563
|
-
if (cached !== null) {
|
|
7564
|
-
const ageMs = now() - Date.parse(cached.fetchedAt);
|
|
7565
|
-
if (Number.isFinite(ageMs) && ageMs >= 0 && ageMs < CACHE_TTL_MS) {
|
|
7566
|
-
return {
|
|
7567
|
-
kind: "ok",
|
|
7568
|
-
source: "cache",
|
|
7569
|
-
fetchedAt: cached.fetchedAt,
|
|
7570
|
-
groups: cached.groups
|
|
7571
|
-
};
|
|
7572
|
-
}
|
|
7573
|
-
}
|
|
7574
|
-
}
|
|
7575
|
-
let response;
|
|
7576
|
-
try {
|
|
7577
|
-
response = await apiJson(cfg, { path: "/groups" }, opts.fetcher);
|
|
7578
|
-
} catch (e) {
|
|
7579
|
-
if (e instanceof UnauthenticatedError || e instanceof ForbiddenError) {
|
|
7580
|
-
throw e;
|
|
7581
|
-
}
|
|
7582
|
-
return { kind: "error", message: describe16(e) };
|
|
7583
|
-
}
|
|
7584
|
-
if (typeof response !== "object" || response === null || !Array.isArray(response.groups) || !response.groups.every(isEntraGroup)) {
|
|
7585
|
-
return {
|
|
7586
|
-
kind: "error",
|
|
7587
|
-
message: "bot returned a malformed groups response"
|
|
7588
|
-
};
|
|
7589
|
-
}
|
|
7590
|
-
const groups = response.groups;
|
|
7591
|
-
const fetchedAt = new Date(now()).toISOString();
|
|
7592
|
-
writeCache(cachePath, { fetchedAt, groups });
|
|
7593
|
-
return { kind: "ok", source: "fresh", fetchedAt, groups };
|
|
7594
|
-
}
|
|
7595
|
-
function readCache(path10) {
|
|
7596
|
-
let raw;
|
|
7597
|
-
try {
|
|
7598
|
-
raw = readFileSync12(path10, "utf8");
|
|
7599
|
-
} catch {
|
|
7600
|
-
return null;
|
|
7601
|
-
}
|
|
7602
|
-
let parsed;
|
|
7603
|
-
try {
|
|
7604
|
-
parsed = JSON.parse(raw);
|
|
7605
|
-
} catch {
|
|
7606
|
-
return null;
|
|
7607
|
-
}
|
|
7608
|
-
if (typeof parsed !== "object" || parsed === null)
|
|
7609
|
-
return null;
|
|
7610
|
-
const p = parsed;
|
|
7611
|
-
if (typeof p.fetchedAt !== "string")
|
|
7612
|
-
return null;
|
|
7613
|
-
if (!Array.isArray(p.groups))
|
|
7614
|
-
return null;
|
|
7615
|
-
for (const g of p.groups)
|
|
7616
|
-
if (!isEntraGroup(g))
|
|
7617
|
-
return null;
|
|
7618
|
-
return p;
|
|
7619
|
-
}
|
|
7620
|
-
function isEntraGroup(value) {
|
|
7621
|
-
if (typeof value !== "object" || value === null)
|
|
7622
|
-
return false;
|
|
7623
|
-
const g = value;
|
|
7624
|
-
return typeof g.id === "string" && typeof g.displayName === "string" && (typeof g.mailNickname === "string" || g.mailNickname === null);
|
|
7625
|
-
}
|
|
7626
|
-
function writeCache(path10, envelope) {
|
|
7627
|
-
try {
|
|
7628
|
-
mkdirSync2(dirname6(path10), { recursive: true });
|
|
7629
|
-
writeFileSync2(path10, JSON.stringify(envelope), "utf8");
|
|
7630
|
-
} catch {}
|
|
7631
|
-
}
|
|
7632
|
-
function describe16(e) {
|
|
7633
|
-
return e instanceof Error ? e.message : String(e);
|
|
7634
|
-
}
|
|
7635
|
-
var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
7636
|
-
function isUuid(value) {
|
|
7637
|
-
return UUID_PATTERN.test(value);
|
|
7638
|
-
}
|
|
7639
|
-
function findGroup(groups, nameOrId) {
|
|
7640
|
-
if (isUuid(nameOrId)) {
|
|
7641
|
-
const lower = nameOrId.toLowerCase();
|
|
7642
|
-
const hit = groups.find((g) => g.id.toLowerCase() === lower);
|
|
7643
|
-
return hit ? { kind: "found", group: hit } : { kind: "not-found" };
|
|
7644
|
-
}
|
|
7645
|
-
const matches = groups.filter((g) => g.displayName === nameOrId || g.mailNickname !== null && g.mailNickname === nameOrId);
|
|
7646
|
-
if (matches.length === 0)
|
|
7647
|
-
return { kind: "not-found" };
|
|
7648
|
-
if (matches.length === 1)
|
|
7649
|
-
return { kind: "found", group: matches[0] };
|
|
7650
|
-
return { kind: "ambiguous", matches };
|
|
7651
|
-
}
|
|
7652
|
-
function searchGroups(groups, query) {
|
|
7653
|
-
const q = query.toLowerCase();
|
|
7654
|
-
return groups.filter((g) => g.displayName.toLowerCase().includes(q) || g.mailNickname !== null && g.mailNickname.toLowerCase().includes(q) || g.id.toLowerCase().includes(q));
|
|
7655
|
-
}
|
|
7656
|
-
|
|
7657
7817
|
// src/auth/jwt.ts
|
|
7658
7818
|
class JwtParseError extends Error {
|
|
7659
7819
|
code = "jwt_parse_error";
|
|
@@ -8071,7 +8231,7 @@ async function runResolve(args, io) {
|
|
|
8071
8231
|
return 1;
|
|
8072
8232
|
}
|
|
8073
8233
|
io.out(hit.group.id);
|
|
8074
|
-
if (!
|
|
8234
|
+
if (!isUuid2(key)) {
|
|
8075
8235
|
io.err(`# resolved "${key}" → ${hit.group.displayName}`);
|
|
8076
8236
|
}
|
|
8077
8237
|
return 0;
|
|
@@ -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;
|
|
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,6 +1,6 @@
|
|
|
1
1
|
import { type CliConfig } from "../config.js";
|
|
2
2
|
import { type DeploymentStatusView, type StandingExceptionView } from "../deploy/deployment-status.js";
|
|
3
|
-
import { type Manifest } from "@m-kopa/launchpad-engine";
|
|
3
|
+
import { type Manifest, type AccessDrift } from "@m-kopa/launchpad-engine";
|
|
4
4
|
import type { Command } from "../dispatcher.js";
|
|
5
5
|
export declare const statusCommand: Command;
|
|
6
6
|
interface StatusArgs {
|
|
@@ -78,6 +78,13 @@ export interface StatusJsonOutput {
|
|
|
78
78
|
* policy violations in content already live on managed main.
|
|
79
79
|
* Absent when the bot pre-dates the endpoint. */
|
|
80
80
|
readonly standingExceptions?: readonly StandingExceptionView[];
|
|
81
|
+
/** PS-1755 / PS-1763 (T4): access drift between the LOCAL declared groups
|
|
82
|
+
* (`access.allowed_entra_groups`) and the ENFORCED gateway-KV allow-list
|
|
83
|
+
* the bot read from the app's TF. Distinct from the content-drift
|
|
84
|
+
* `access.allowed_entra_groups` row (which is local-vs-deployed *manifest*):
|
|
85
|
+
* this is "what I declare" vs "what the gateway actually enforces".
|
|
86
|
+
* Absent when not a gateway-KV app (`enforcedGroups` null / old bot). */
|
|
87
|
+
readonly accessDrift?: AccessDrift;
|
|
81
88
|
}
|
|
82
89
|
/** Fetch the app's provisioning lifecycle (sp-st5hw9). Returns null when the
|
|
83
90
|
* bot doesn't recognise the slug OR predates the endpoint — BOTH surface as a
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../../src/commands/status.ts"],"names":[],"mappings":"AA0CA,OAAO,EAAc,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAG1D,OAAO,EAGL,KAAK,oBAAoB,EACzB,KAAK,qBAAqB,EAC3B,MAAM,gCAAgC,CAAC;AASxC,OAAO,
|
|
1
|
+
{"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../../src/commands/status.ts"],"names":[],"mappings":"AA0CA,OAAO,EAAc,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAG1D,OAAO,EAGL,KAAK,oBAAoB,EACzB,KAAK,qBAAqB,EAC3B,MAAM,gCAAgC,CAAC;AASxC,OAAO,EAIL,KAAK,QAAQ,EACb,KAAK,WAAW,EAEjB,MAAM,0BAA0B,CAAC;AAIlC,OAAO,KAAK,EAAS,OAAO,EAAY,MAAM,kBAAkB,CAAC;AAEjE,eAAO,MAAM,aAAa,EAAE,OAI3B,CAAC;AAEF,UAAU,UAAU;IAClB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,8EAA8E;IAC9E,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;CACzB;AAID;;+EAE+E;AAC/E,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EACV,cAAc,GACd,MAAM,GACN,QAAQ,GACR,YAAY,GACZ,WAAW,GACX,gBAAgB,CAAC;IACrB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,QAAQ,CAAC,EAAE;QAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;KAAE,CAAC;IACtG,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;CACnC;AAED;;;uEAGuE;AACvE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,KAAK,EACV,cAAc,GACd,qBAAqB,GACrB,iBAAiB;IACnB;;wDAEoD;OAClD,wBAAwB,GACxB,SAAS,GACT,OAAO;IACT;;;;;;2EAMuE;OACrE,oBAAoB,GACpB,sBAAsB,GACtB,YAAY,GACZ,WAAW,GACX,gBAAgB,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;sEAGkE;IAClE,QAAQ,CAAC,KAAK,CAAC,EAAE,IAAI,CAAC;IACtB,0EAA0E;IAC1E,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,sDAAsD;IACtD,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtC,6DAA6D;IAC7D,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtC,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IACpC,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAC;IAC5B,QAAQ,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IACrC,oFAAoF;IACpF,QAAQ,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;IACxC,4EAA4E;IAC5E,QAAQ,CAAC,YAAY,EAAE,aAAa,CAAC;QACnC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;QACtB,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;QACxB,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;KAC5B,CAAC,CAAC;IACH;;;wCAGoC;IACpC,QAAQ,CAAC,UAAU,CAAC,EAAE,oBAAoB,GAAG,IAAI,CAAC;IAClD;;sDAEkD;IAClD,QAAQ,CAAC,kBAAkB,CAAC,EAAE,SAAS,qBAAqB,EAAE,CAAC;IAC/D;;;;;8EAK0E;IAC1E,QAAQ,CAAC,WAAW,CAAC,EAAE,WAAW,CAAC;CACpC;AAED;;;8EAG8E;AAC9E,wBAAsB,cAAc,CAClC,GAAG,EAAE,SAAS,EACd,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAO/B;AAkVD;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAC1B,KAAK,EAAE,QAAQ,EACf,QAAQ,EAAE,QAAQ,GACjB,aAAa,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAC;IAAC,QAAQ,EAAE,OAAO,CAAA;CAAE,CAAC,CA2DpE;AAgUD;0BAC0B;AAC1B,wBAAgB,SAAS,CACvB,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,GAAG,GAAE,MAAsB,EAC3B,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GAC5B,UAAU,GAAG,MAAM,CA2ErB"}
|
|
@@ -9,6 +9,13 @@ export interface ManifestStateResponse {
|
|
|
9
9
|
readonly url: string;
|
|
10
10
|
readonly branch: string;
|
|
11
11
|
} | null;
|
|
12
|
+
/** PS-1763 (T4): the ENFORCED gateway allow-list — the per-app
|
|
13
|
+
* `<slug>_policy` KV `groups` array, as raw tokens (UUIDs +
|
|
14
|
+
* `entra_groups.<Name>` refs) parsed from the app's TF source. `null` for
|
|
15
|
+
* an app with no `<slug>_policy` block (a CF Access app / the gateway —
|
|
16
|
+
* a different enforcement mechanism, out of scope for gateway-KV drift).
|
|
17
|
+
* Older bots (pre-PS-1763) omit it → normalised to `null`. */
|
|
18
|
+
readonly enforcedGroups: readonly string[] | null;
|
|
12
19
|
readonly manifestYaml?: string | null;
|
|
13
20
|
}
|
|
14
21
|
/** Fetch `/apps/<slug>/manifest/state`. With `includeManifest: true`,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"manifest-state.d.ts","sourceRoot":"","sources":["../../src/deploy/manifest-state.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,sBAAsB,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/C,QAAQ,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IACvC,QAAQ,CAAC,MAAM,EAAE;QACf,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;QACxB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;QACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;KACzB,GAAG,IAAI,CAAC;IACT,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvC;
|
|
1
|
+
{"version":3,"file":"manifest-state.d.ts","sourceRoot":"","sources":["../../src/deploy/manifest-state.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,QAAQ,CAAC,sBAAsB,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/C,QAAQ,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IACvC,QAAQ,CAAC,MAAM,EAAE;QACf,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;QACxB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;QACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;KACzB,GAAG,IAAI,CAAC;IACT;;;;;mEAK+D;IAC/D,QAAQ,CAAC,cAAc,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAAC;IAClD,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvC;AAeD;;gCAEgC;AAChC,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,SAAS,EACd,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE;IAAE,eAAe,CAAC,EAAE,OAAO,CAAA;CAAO,EACxC,OAAO,GAAE,OAAO,KAAa,GAC5B,OAAO,CAAC,qBAAqB,CAAC,CAgBhC"}
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "0.
|
|
1
|
+
export declare const CLI_VERSION = "0.41.1";
|
|
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.
|
|
3
|
+
"version": "0.41.1",
|
|
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": {
|
|
@@ -49,6 +49,8 @@
|
|
|
49
49
|
"test:watch": "vitest",
|
|
50
50
|
"lint": "eslint src tests",
|
|
51
51
|
"typecheck": "tsc --noEmit",
|
|
52
|
+
"typecheck:catalogue": "tsc -p channel/catalogue/tsconfig.json --noEmit",
|
|
53
|
+
"build:catalogue": "esbuild channel/catalogue/src/main.ts --bundle --format=esm --minify --target=es2020 --legal-comments=none --outfile=channel/catalogue/dist/app.js",
|
|
52
54
|
"check:version-sync": "bash scripts/check-version-sync.sh",
|
|
53
55
|
"check:runtime-deps": "node scripts/check-runtime-deps.mjs",
|
|
54
56
|
"check:installer-syntax": "bash scripts/check-installer-syntax.sh",
|
|
@@ -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.
|
|
4
|
+
version: 0.41.1
|
|
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,10 @@ 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` (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.
|
|
189
192
|
|
|
190
193
|
## Verify the deploy
|
|
191
194
|
|
|
@@ -248,6 +251,11 @@ Once you've shipped first content, the daily-use verbs are:
|
|
|
248
251
|
currently-deployed `launchpad.yaml`.
|
|
249
252
|
- **`launchpad deploy`** — bundle the working tree; the bot commits
|
|
250
253
|
it directly to the app repo's `main`.
|
|
254
|
+
- **`launchpad redeploy <slug>`** — re-run a Pages build of the
|
|
255
|
+
current commit *without* changing content. Use it to **activate a
|
|
256
|
+
secret you just pushed** (Cloudflare binds env only on a new build,
|
|
257
|
+
so `secrets push` alone won't take effect on a Pages tier) or to
|
|
258
|
+
force a clean rebuild. See `/commands/redeploy`.
|
|
251
259
|
- **`launchpad envvars`** — list / set / remove non-secret
|
|
252
260
|
production env vars.
|
|
253
261
|
- **`launchpad secrets template`** — emit `.env.example` from the
|
|
@@ -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.
|
|
4
|
+
version: 0.41.1
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
<!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->
|
|
@@ -130,6 +130,16 @@ This is interactive by default. It will ask for:
|
|
|
130
130
|
layouts; the user overrides if needed.
|
|
131
131
|
- **allowed_entra_groups** — one or more, comma-separated; see §A.3.
|
|
132
132
|
|
|
133
|
+
By default the app is **listed in the app catalogue**
|
|
134
|
+
(get.launchpad/catalogue — the employee-facing app directory). Listing
|
|
135
|
+
is discovery only and grants no one access. To keep an internal-only
|
|
136
|
+
app out of it, add to `launchpad.yaml`:
|
|
137
|
+
|
|
138
|
+
```yaml
|
|
139
|
+
catalogue:
|
|
140
|
+
listed: false
|
|
141
|
+
```
|
|
142
|
+
|
|
133
143
|
Non-interactive form for scripting:
|
|
134
144
|
|
|
135
145
|
```bash
|
|
@@ -185,20 +195,45 @@ The CLI resolves Entra groups via the bot's `/groups` endpoint
|
|
|
185
195
|
endpoint directly. Two helpers:
|
|
186
196
|
|
|
187
197
|
```bash
|
|
188
|
-
launchpad groups list #
|
|
198
|
+
launchpad groups list # groups ASSIGNED to the Launchpad app in Entra
|
|
189
199
|
launchpad groups search <query> # fuzzy match by name / nickname / id
|
|
190
200
|
launchpad groups show <name> # UUID + displayName + mailNickname
|
|
191
201
|
launchpad groups resolve <name> # just the Entra Object-ID UUID (script-friendly)
|
|
192
202
|
```
|
|
193
203
|
|
|
194
|
-
`groups
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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.
|
|
198
215
|
|
|
199
216
|
When the user picks a group, pass either the **displayName** or the
|
|
200
217
|
**UUID** to `launchpad init --group <…>` — the CLI accepts both and
|
|
201
|
-
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.** 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?".
|
|
202
237
|
|
|
203
238
|
If `launchpad groups list` fails with:
|
|
204
239
|
|
|
@@ -212,9 +247,10 @@ If `launchpad groups list` fails with:
|
|
|
212
247
|
`appRoleAssignedTo` assignment list (missing admin consent on the
|
|
213
248
|
Graph application permission), or Graph is unreachable. Surface
|
|
214
249
|
the error body.
|
|
215
|
-
- empty list → no groups are assigned to the Launchpad
|
|
216
|
-
|
|
217
|
-
|
|
250
|
+
- empty list → no groups are **assigned** to the Launchpad app. This
|
|
251
|
+
does **not** block you — an unassigned tenant group can still be
|
|
252
|
+
used by name or UUID (above); the assigned set is just a discovery
|
|
253
|
+
shortlist.
|
|
218
254
|
|
|
219
255
|
Use `launchpad groups whoami` to remind the user which groups
|
|
220
256
|
**they** are currently a member of — handy when an app is gated and
|
|
@@ -234,7 +270,10 @@ Add `--strict-groups` to *additionally* resolve the manifest's
|
|
|
234
270
|
allowed Entra group online against the bot's group list — it catches
|
|
235
271
|
typo'd or renamed group names before deploy time. This mode needs a
|
|
236
272
|
session and the network (exit 1 = group not found / ambiguous,
|
|
237
|
-
2 = network error, 3 = no valid session).
|
|
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).
|
|
238
277
|
|
|
239
278
|
```bash
|
|
240
279
|
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.
|
|
4
|
+
version: 0.41.1
|
|
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.
|
|
4
|
+
version: 0.41.1
|
|
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.
|
|
4
|
+
version: 0.41.1
|
|
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.
|
|
4
|
+
version: 0.41.1
|
|
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.
|
|
4
|
+
version: 0.41.1
|
|
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.
|
|
4
|
+
version: 0.41.1
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
<!-- BEGIN shell-contract (managed by scripts/sync-skill-contract.sh — edit skills/_partials/shell-contract.md) -->
|
|
@@ -336,6 +336,15 @@ Group binding changed in your local manifest. This is the
|
|
|
336
336
|
`allowed-groups-change: true` label from a non-author human reviewer
|
|
337
337
|
(AC-S4) before it can auto-merge.
|
|
338
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.
|
|
347
|
+
|
|
339
348
|
## Related skills
|
|
340
349
|
|
|
341
350
|
- **`/launchpad-deploy`** — provision a new app (`launchpad init`
|