@mesh-tech/mesh-cli 0.26.0 → 0.27.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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "8c1e961c4",
3
- "builtAt": "2026-09-17T07:12:18.568Z"
2
+ "commit": "5b8435e57",
3
+ "builtAt": "2026-09-17T20:26:45.360Z"
4
4
  }
@@ -1,10 +1,12 @@
1
1
  import { Command } from "commander";
2
- import { type ServiceStatus, type StackEndpoint } from "./stack.js";
2
+ import { type OverlayState, type ServiceStatus, type StackEndpoint } from "./stack.js";
3
3
  export interface WaitForStackIo {
4
4
  probe: (endpoint: StackEndpoint) => Promise<boolean>;
5
5
  services: () => ServiceStatus[];
6
- overlayServices: () => ReadonlySet<string>;
6
+ overlays: () => readonly OverlayState[];
7
7
  }
8
- export declare function waitForStack(io?: WaitForStackIo): Promise<void>;
8
+ export declare function waitForStack(opts: {
9
+ idpsStarted: boolean;
10
+ }, io?: WaitForStackIo): Promise<void>;
9
11
  export declare function startHereLines(hubRunning: boolean, up?: ReadonlyMap<string, boolean>): string[];
10
12
  export declare function registerLocalCommands(program: Command): void;
@@ -3,12 +3,13 @@ import * as path from "path";
3
3
  import chalk from "chalk";
4
4
  import { logError, logInfo, logSuccess, logWarn } from "../../utils/log.js";
5
5
  import { MeshCliError } from "../../utils/errors.js";
6
- import { compose, COMPOSE_PROJECT, composeStreamed, crashedServices, DEFAULT_HUB_PORT, ensureDockerAvailable, findPackageRoot, hubApiRunning, hubEndpoints, hubOverlayRunning, hubOverlayServices, hubPort, ONE_SHOT_SERVICES, probeEndpoint, STACK_ENDPOINTS, stackOwnedElsewhere, stackServices, } from "./stack.js";
6
+ import { compose, COMPOSE_PROJECT, composeStreamed, crashedServices, DEFAULT_HUB_PORT, ensureDockerAvailable, findPackageRoot, hubApiRunning, hubEndpoints, hubOverlayServices, hubPort, idpsOverlayServices, ONE_SHOT_SERVICES, overlayRunning, probeEndpoint, STACK_ENDPOINTS, stackOwnedElsewhere, stackServices, } from "./stack.js";
7
7
  import { probeRegistryToken, REGISTRY_LOGIN_FIX } from "../../utils/auth-preflight.js";
8
8
  import { ensureHubAuthImage, ensureHubImages, buildHubImagesFromSource, readHubCompiledAuthz, hasRegistryAuth, localHubVersion, planWithHubRefresh, } from "./hub-local.js";
9
9
  import { reconcileRegistryFromZitadel } from "./auth-provision.js";
10
10
  import { LOCAL_AWS_CONFIG, localAwsEnv, seedLocalPlatform, verifyFabric } from "./seed.js";
11
- import { LOGIN_CONTEXT, readHubAuth, seedZitadel, TEST_USERS } from "./seed-zitadel.js";
11
+ import { LOGIN_CONTEXT, readHubAuth, seedMockIdps, seedZitadel, TEST_USERS } from "./seed-zitadel.js";
12
+ import { idpEndpoints, mockIdpComposeEnv, MOCK_OIDC_PORT, MOCK_SAML_PORT } from "./mock-idps.js";
12
13
  import { publishHubAuthzCatalog } from "./seed-hub-catalog.js";
13
14
  import { describePortConflicts, findPortConflicts, hostPortsOf } from "./helpers.js";
14
15
  const WAIT_TIMEOUT_MS = 180_000;
@@ -20,10 +21,13 @@ function crashError(crashed) {
20
21
  },
21
22
  });
22
23
  }
23
- export async function waitForStack(io = {
24
+ export async function waitForStack(opts, io = {
24
25
  probe: probeEndpoint,
25
26
  services: stackServices,
26
- overlayServices: hubOverlayServices,
27
+ overlays: () => [
28
+ { services: hubOverlayServices(), started: false },
29
+ { services: idpsOverlayServices(), started: opts.idpsStarted },
30
+ ],
27
31
  }) {
28
32
  const deadline = Date.now() + WAIT_TIMEOUT_MS;
29
33
  let pending = STACK_ENDPOINTS.filter((e) => e.probe.kind !== "none");
@@ -39,10 +43,7 @@ export async function waitForStack(io = {
39
43
  }
40
44
  }
41
45
  pending = stillDown;
42
- const crashed = crashedServices(io.services(), {
43
- overlayServices: io.overlayServices(),
44
- overlayStarted: false,
45
- });
46
+ const crashed = crashedServices(io.services(), { overlays: io.overlays() });
46
47
  if (crashed.length > 0)
47
48
  throw crashError(crashed);
48
49
  if (pending.length === 0)
@@ -112,6 +113,11 @@ function portMoveHints() {
112
113
  Number(hubPort()),
113
114
  "MESH_HUB_PORT=<free port> mesh start (or mesh start --no-hub, which starts everything else)",
114
115
  ],
116
+ [MOCK_SAML_PORT, "mesh start (without --with-idps — the mock identity providers are opt-in)"],
117
+ [
118
+ MOCK_OIDC_PORT,
119
+ "mesh start (without --with-idps; 5556 is Dex's own default port, so a Dex of your own is the usual holder)",
120
+ ],
115
121
  ]);
116
122
  }
117
123
  function repoRootForHubSource() {
@@ -133,6 +139,7 @@ export function registerLocalCommands(program) {
133
139
  .option("--no-hub", "start without the Hub (API + UI)")
134
140
  .option("--with-hub", "force-refresh the Hub to the latest published @mesh-tech/hub (the Hub is included by default when its images exist or registry auth is available)")
135
141
  .option("--hub-from-source", "build the Hub images from THIS checkout's apps/hub instead of the published tarball (the only way to exercise a Hub change locally)")
142
+ .option("--with-idps", "also run mock SAML and OIDC identity providers, and federate the local Zitadel with both (for testing enterprise SSO without a cloud tenant or an IdP admin)", false)
136
143
  .option("--takeover", "re-own a stack that another checkout started (may recreate shared containers with THIS checkout's config)", false)
137
144
  .option("--skip-port-check", "start even though a host port the stack wants is already held (the service that loses the bind will be broken — use when the holder is something you cannot stop)", false)
138
145
  .action(async (opts) => {
@@ -151,7 +158,11 @@ export function registerLocalCommands(program) {
151
158
  logWarn(`Taking over the running stack (previously driven from ${foreign}).`);
152
159
  }
153
160
  if (!opts.skipPortCheck && !stackServices().some((s) => s.state === "running")) {
154
- const ports = hostPortsOf([...STACK_ENDPOINTS, ...(opts.hub ? hubEndpoints() : [])]);
161
+ const ports = hostPortsOf([
162
+ ...STACK_ENDPOINTS,
163
+ ...(opts.hub ? hubEndpoints() : []),
164
+ ...(opts.withIdps ? idpEndpoints() : []),
165
+ ]);
155
166
  const conflicts = await findPortConflicts(ports, `${COMPOSE_PROJECT}-`);
156
167
  if (conflicts.length > 0) {
157
168
  throw new MeshCliError(describePortConflicts(conflicts, portMoveHints()), {
@@ -197,9 +208,10 @@ export function registerLocalCommands(program) {
197
208
  }
198
209
  }
199
210
  logInfo("Starting the local Mesh platform (docker compose project: mesh-local)…");
200
- await composeStreamed(["up", "-d"]);
211
+ const idpEnv = opts.withIdps ? mockIdpComposeEnv() : undefined;
212
+ await composeStreamed(["up", "-d"], { idps: opts.withIdps, env: idpEnv });
201
213
  compose(["restart", "prometheus", "blackbox-exporter"], { inherit: true });
202
- await waitForStack();
214
+ await waitForStack({ idpsStarted: opts.withIdps });
203
215
  let hubAuth = readHubAuth();
204
216
  if (opts.seed) {
205
217
  logInfo("Seeding local platform (tenant registry, artifacts bucket)…");
@@ -207,6 +219,15 @@ export function registerLocalCommands(program) {
207
219
  logInfo("Seeding Zitadel (Platform project, Mesh CLI app, Hub auth, test users)…");
208
220
  const zitadel = await seedZitadel(LOCAL_AWS_CONFIG);
209
221
  hubAuth = zitadel.hubAuth;
222
+ if (opts.withIdps) {
223
+ logInfo("Federating local Zitadel with the mock SAML and OIDC identity providers…");
224
+ await seedMockIdps();
225
+ logInfo(`Mock SAML IdP: http://localhost:${MOCK_SAML_PORT} · ` +
226
+ `mock OIDC IdP: http://localhost:${MOCK_OIDC_PORT}/dex · ` +
227
+ "sign in at http://localhost:8080 and both federation buttons are offered");
228
+ logInfo("Both are listed in the Zitadel console under Settings → Identity Providers (owner: Instance): " +
229
+ "http://localhost:8080/ui/console/org-settings?id=idp");
230
+ }
210
231
  if (hubVersion) {
211
232
  const compiled = readHubCompiledAuthz(hubVersion);
212
233
  if (compiled) {
@@ -246,19 +267,23 @@ export function registerLocalCommands(program) {
246
267
  hubEnv.MESH_HUB_OAUTH2_CLIENT_SECRET = hubAuth.clientSecret;
247
268
  hubEnv.MESH_HUB_OAUTH2_COOKIE_SECRET = hubAuth.cookieSecret;
248
269
  }
249
- await composeStreamed(["up", "-d"], { hub: true, env: hubEnv });
270
+ await composeStreamed(["up", "-d"], {
271
+ hub: true,
272
+ idps: opts.withIdps,
273
+ env: { ...idpEnv, ...hubEnv },
274
+ });
250
275
  if (opts.seed) {
251
276
  compose(["restart", "hub-api"], { hub: true, env: hubEnv });
252
277
  }
253
- const overlayServices = hubOverlayServices();
278
+ const overlays = [
279
+ { services: hubOverlayServices(), started: true },
280
+ { services: idpsOverlayServices(), started: opts.withIdps },
281
+ ];
254
282
  for (const endpoint of hubEndpoints()) {
255
283
  logInfo(`Waiting for ${endpoint.label}…`);
256
284
  let up = await probeEndpoint(endpoint);
257
285
  for (let i = 0; i < 60 && !up; i++) {
258
- const crashed = crashedServices(stackServices(), {
259
- overlayServices,
260
- overlayStarted: true,
261
- });
286
+ const crashed = crashedServices(stackServices(), { overlays });
262
287
  if (crashed.length > 0)
263
288
  throw crashError(crashed);
264
289
  await new Promise((resolve) => setTimeout(resolve, 2000));
@@ -310,11 +335,20 @@ export function registerLocalCommands(program) {
310
335
  remediation: { command: "mesh start" },
311
336
  });
312
337
  }
313
- const overlayServices = hubOverlayServices();
314
- const overlayStarted = hubOverlayRunning(services, overlayServices);
315
- const notStarted = (s) => !overlayStarted && overlayServices.has(s.name) && s.state === "exited";
338
+ const hubOverlay = hubOverlayServices();
339
+ const idpsOverlay = idpsOverlayServices();
340
+ const idpsStarted = overlayRunning(services, idpsOverlay);
341
+ const overlays = [
342
+ { services: hubOverlay, started: overlayRunning(services, hubOverlay) },
343
+ { services: idpsOverlay, started: idpsStarted },
344
+ ];
345
+ const notStarted = (s) => s.state === "exited" && overlays.some((o) => !o.started && o.services.has(s.name));
316
346
  const hasHub = hubApiRunning(services);
317
- const endpoints = hasHub ? [...STACK_ENDPOINTS, ...hubEndpoints()] : STACK_ENDPOINTS;
347
+ const endpoints = [
348
+ ...STACK_ENDPOINTS,
349
+ ...(hasHub ? hubEndpoints() : []),
350
+ ...(idpsStarted ? idpEndpoints() : []),
351
+ ];
318
352
  const probes = await Promise.all(endpoints.map(async (endpoint) => ({
319
353
  service: endpoint.service,
320
354
  label: endpoint.label,
@@ -337,6 +371,7 @@ export function registerLocalCommands(program) {
337
371
  services,
338
372
  endpoints: probes,
339
373
  fabric,
374
+ mockIdps: idpsStarted,
340
375
  awsEnv: localAwsEnv(),
341
376
  }));
342
377
  if (fabric && !fabric.ok)
@@ -352,7 +387,7 @@ export function registerLocalCommands(program) {
352
387
  const idle = notStarted(service);
353
388
  const icon = ok || oneShotDone ? chalk.green("●") : idle ? chalk.dim("○") : chalk.red("●");
354
389
  const health = service.health ? ` (${service.health})` : "";
355
- const note = idle ? chalk.dim(" (Hub not started this run)") : "";
390
+ const note = idle ? chalk.dim(" (not started this run)") : "";
356
391
  console.log(` ${icon} ${service.name.padEnd(22)} ${service.state}${health}${note}`);
357
392
  }
358
393
  console.log("");
@@ -366,8 +401,11 @@ export function registerLocalCommands(program) {
366
401
  const detail = fabric.ok ? "8 KB Advanced-tier round-trip OK" : fabric.error;
367
402
  console.log(` ${icon} ${"AWS fabric check".padEnd(34)} ${chalk.dim(detail ?? "")}`);
368
403
  }
404
+ if (!idpsStarted) {
405
+ console.log(` ${chalk.dim("○")} ${"Mock IdPs (SAML + OIDC)".padEnd(34)} ${chalk.dim("not started — mesh start --with-idps")}`);
406
+ }
369
407
  const down = probes.filter((p) => !p.up);
370
- const crashed = crashedServices(services, { overlayServices, overlayStarted });
408
+ const crashed = crashedServices(services, { overlays });
371
409
  if (fabric && !fabric.ok)
372
410
  process.exitCode = 1;
373
411
  if (down.length > 0 || crashed.length > 0) {
@@ -0,0 +1,48 @@
1
+ import type { StackEndpoint } from "./stack.js";
2
+ export declare const MOCK_SAML_PORT = 4569;
3
+ export declare const MOCK_OIDC_PORT = 5556;
4
+ export declare const idpEndpoints: () => StackEndpoint[];
5
+ export declare const MOCK_SAML_IDP_NAME = "Mock SAML (local)";
6
+ export declare const MOCK_OIDC_IDP_NAME = "Mock OIDC (local)";
7
+ export declare const mockSamlMetadataUrl: () => string;
8
+ export declare const mockOidcEndpoints: () => {
9
+ authorization: string;
10
+ token: string;
11
+ user: string;
12
+ };
13
+ export declare function ensureMockSamlSigningKey(): {
14
+ publicKeyB64: string;
15
+ privateKeyB64: string;
16
+ };
17
+ export declare const mockIdpComposeEnv: () => Record<string, string>;
18
+ export declare const buildMockSamlIdpBody: (metadataXml: string) => {
19
+ name: string;
20
+ metadataXml: string;
21
+ binding: string;
22
+ withSignedRequest: boolean;
23
+ providerOptions: {
24
+ isLinkingAllowed: boolean;
25
+ isCreationAllowed: boolean;
26
+ isAutoCreation: boolean;
27
+ isAutoUpdate: boolean;
28
+ autoLinking: string;
29
+ };
30
+ };
31
+ export declare const buildMockOauthIdpBody: () => {
32
+ name: string;
33
+ clientId: string;
34
+ clientSecret: string;
35
+ authorizationEndpoint: string;
36
+ tokenEndpoint: string;
37
+ userEndpoint: string;
38
+ scopes: string[];
39
+ idAttribute: string;
40
+ providerOptions: {
41
+ isLinkingAllowed: boolean;
42
+ isCreationAllowed: boolean;
43
+ isAutoCreation: boolean;
44
+ isAutoUpdate: boolean;
45
+ autoLinking: string;
46
+ };
47
+ };
48
+ export declare const missingLoginPolicyIdps: (attached: readonly string[], wanted: readonly string[]) => string[];
@@ -0,0 +1,119 @@
1
+ import { execFileSync } from "child_process";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import { meshCacheDir } from "../../utils/cache-home.js";
5
+ import { MeshCliError } from "../../utils/errors.js";
6
+ export const MOCK_SAML_PORT = 4569;
7
+ export const MOCK_OIDC_PORT = 5556;
8
+ const MOCK_OIDC_SERVICE = "mock-oidc";
9
+ export const idpEndpoints = () => [
10
+ {
11
+ service: "mock-saml",
12
+ label: "Mock SAML IdP",
13
+ url: `http://localhost:${MOCK_SAML_PORT}`,
14
+ probe: { kind: "http", url: mockSamlMetadataUrl() },
15
+ hint: "sign in as any email you type; federation metadata at /api/saml/metadata",
16
+ },
17
+ {
18
+ service: MOCK_OIDC_SERVICE,
19
+ label: "Mock OIDC IdP",
20
+ url: `http://localhost:${MOCK_OIDC_PORT}/dex`,
21
+ probe: {
22
+ kind: "http",
23
+ url: `http://localhost:${MOCK_OIDC_PORT}/dex/.well-known/openid-configuration`,
24
+ },
25
+ hint: "dev@local.mesh or ops@local.mesh / password",
26
+ },
27
+ ];
28
+ export const MOCK_SAML_IDP_NAME = "Mock SAML (local)";
29
+ export const MOCK_OIDC_IDP_NAME = "Mock OIDC (local)";
30
+ const MOCK_OIDC_CLIENT_ID = "mesh-local";
31
+ const MOCK_OIDC_CLIENT_SECRET = "mesh-local-secret";
32
+ export const mockSamlMetadataUrl = () => `http://localhost:${MOCK_SAML_PORT}/api/saml/metadata`;
33
+ export const mockOidcEndpoints = () => ({
34
+ authorization: `http://localhost:${MOCK_OIDC_PORT}/dex/auth`,
35
+ token: `http://${MOCK_OIDC_SERVICE}:${MOCK_OIDC_PORT}/dex/token`,
36
+ user: `http://${MOCK_OIDC_SERVICE}:${MOCK_OIDC_PORT}/dex/userinfo`,
37
+ });
38
+ const keyDir = () => path.join(meshCacheDir("mesh-local"), "mock-idp");
39
+ export function ensureMockSamlSigningKey() {
40
+ const dir = keyDir();
41
+ const certPath = path.join(dir, "cert.pem");
42
+ const keyPath = path.join(dir, "key.pem");
43
+ if (!fs.existsSync(certPath) || !fs.existsSync(keyPath)) {
44
+ fs.mkdirSync(dir, { recursive: true });
45
+ try {
46
+ execFileSync("openssl", [
47
+ "req",
48
+ "-x509",
49
+ "-newkey",
50
+ "rsa:2048",
51
+ "-keyout",
52
+ keyPath,
53
+ "-out",
54
+ certPath,
55
+ "-days",
56
+ "3650",
57
+ "-nodes",
58
+ "-subj",
59
+ "/CN=mesh-local-mock-saml",
60
+ ], { stdio: "ignore" });
61
+ }
62
+ catch (cause) {
63
+ throw new MeshCliError("Could not generate the mock SAML signing key.", {
64
+ remediation: {
65
+ command: "openssl version # openssl must be on PATH for --with-idps",
66
+ },
67
+ cause,
68
+ });
69
+ }
70
+ }
71
+ return {
72
+ publicKeyB64: fs.readFileSync(certPath).toString("base64"),
73
+ privateKeyB64: fs.readFileSync(keyPath).toString("base64"),
74
+ };
75
+ }
76
+ export const mockIdpComposeEnv = () => {
77
+ const { publicKeyB64, privateKeyB64 } = ensureMockSamlSigningKey();
78
+ return {
79
+ MESH_MOCK_SAML_PUBLIC_KEY: publicKeyB64,
80
+ MESH_MOCK_SAML_PRIVATE_KEY: privateKeyB64,
81
+ };
82
+ };
83
+ export const buildMockSamlIdpBody = (metadataXml) => ({
84
+ name: MOCK_SAML_IDP_NAME,
85
+ metadataXml: Buffer.from(metadataXml, "utf-8").toString("base64"),
86
+ binding: "SAML_BINDING_POST",
87
+ withSignedRequest: true,
88
+ providerOptions: {
89
+ isLinkingAllowed: true,
90
+ isCreationAllowed: true,
91
+ isAutoCreation: true,
92
+ isAutoUpdate: true,
93
+ autoLinking: "AUTO_LINKING_OPTION_EMAIL",
94
+ },
95
+ });
96
+ export const buildMockOauthIdpBody = () => {
97
+ const endpoints = mockOidcEndpoints();
98
+ return {
99
+ name: MOCK_OIDC_IDP_NAME,
100
+ clientId: MOCK_OIDC_CLIENT_ID,
101
+ clientSecret: MOCK_OIDC_CLIENT_SECRET,
102
+ authorizationEndpoint: endpoints.authorization,
103
+ tokenEndpoint: endpoints.token,
104
+ userEndpoint: endpoints.user,
105
+ scopes: ["openid", "profile", "email"],
106
+ idAttribute: "sub",
107
+ providerOptions: {
108
+ isLinkingAllowed: true,
109
+ isCreationAllowed: true,
110
+ isAutoCreation: true,
111
+ isAutoUpdate: true,
112
+ autoLinking: "AUTO_LINKING_OPTION_EMAIL",
113
+ },
114
+ };
115
+ };
116
+ export const missingLoginPolicyIdps = (attached, wanted) => {
117
+ const have = new Set(attached);
118
+ return [...new Set(wanted)].filter((id) => !have.has(id));
119
+ };
@@ -73,4 +73,10 @@ export interface ZitadelSeedResult {
73
73
  hubAuth: HubAuthConfig;
74
74
  }
75
75
  export declare function seedZitadel(awsConfig: object): Promise<ZitadelSeedResult>;
76
+ export declare const IDP_SEARCH_PATH = "/admin/v1/idps/templates/_search";
77
+ export declare const pickIdpIdByName: (searchResult: any, name: string) => string | undefined;
78
+ export declare function seedMockIdps(): Promise<{
79
+ samlIdpId: string;
80
+ oidcIdpId: string;
81
+ }>;
76
82
  export {};
@@ -8,6 +8,7 @@ import { writeContextConfig } from "../login.js";
8
8
  import { compose, hubPort, DEFAULT_HUB_PORT } from "./stack.js";
9
9
  import { LOCAL_TENANT, LOCAL_ENV } from "./seed.js";
10
10
  import { meshCacheDir } from "../../utils/cache-home.js";
11
+ import { buildMockOauthIdpBody, buildMockSamlIdpBody, missingLoginPolicyIdps, MOCK_OIDC_IDP_NAME, MOCK_SAML_IDP_NAME, mockSamlMetadataUrl, } from "./mock-idps.js";
11
12
  export const ZITADEL_ISSUER = "http://localhost:8080";
12
13
  export const LOGIN_CONTEXT = "local";
13
14
  export const PLATFORM_ORG = "mesh";
@@ -441,3 +442,78 @@ export async function seedZitadel(awsConfig) {
441
442
  logSuccess(`Login context '${LOGIN_CONTEXT}' configured → try: mesh login ${LOGIN_CONTEXT}`);
442
443
  return { projectId, cliClientId, hubAuth };
443
444
  }
445
+ async function fetchMockSamlMetadata() {
446
+ const url = mockSamlMetadataUrl();
447
+ let lastError = "no attempt made";
448
+ for (let attempt = 0; attempt < 30; attempt++) {
449
+ try {
450
+ const res = await fetch(url, { signal: AbortSignal.timeout(5_000) });
451
+ const body = await res.text();
452
+ if (res.ok && body.includes("EntityDescriptor")) {
453
+ if (/<ds:X509Certificate\s*\/>|<ds:X509Certificate>\s*<\/ds:X509Certificate>/.test(body)) {
454
+ throw new MeshCliError("The mock SAML IdP served metadata with an empty signing certificate.", {
455
+ remediation: {
456
+ command: "rm -rf ~/.cache/mesh/mesh-local/mock-idp && mesh stop && mesh start --with-idps",
457
+ },
458
+ });
459
+ }
460
+ return body;
461
+ }
462
+ lastError = `HTTP ${res.status}`;
463
+ }
464
+ catch (cause) {
465
+ if (cause instanceof MeshCliError)
466
+ throw cause;
467
+ lastError = String(cause);
468
+ }
469
+ await new Promise((resolve) => setTimeout(resolve, 2_000));
470
+ }
471
+ throw new MeshCliError(`Could not read mock SAML metadata from ${url} (${lastError}).`, {
472
+ remediation: { command: "mesh status # is mock-saml running?" },
473
+ });
474
+ }
475
+ async function attachedLoginPolicyIdps(pat) {
476
+ const res = await api(pat, "POST", "/admin/v1/policies/login/idps/_search", {});
477
+ return (res?.result ?? []).map((idp) => String(idp.idpId ?? idp.id)).filter(Boolean);
478
+ }
479
+ export const IDP_SEARCH_PATH = "/admin/v1/idps/templates/_search";
480
+ export const pickIdpIdByName = (searchResult, name) => {
481
+ const hit = (searchResult?.result ?? []).find((idp) => idp?.name === name);
482
+ return hit?.id ? String(hit.id) : undefined;
483
+ };
484
+ async function findIdpByName(pat, name) {
485
+ return pickIdpIdByName(await api(pat, "POST", IDP_SEARCH_PATH, {}), name);
486
+ }
487
+ export async function seedMockIdps() {
488
+ const pat = readSeederPat();
489
+ const metadataXml = await fetchMockSamlMetadata();
490
+ let samlIdpId = await findIdpByName(pat, MOCK_SAML_IDP_NAME);
491
+ if (!samlIdpId) {
492
+ const created = await api(pat, "POST", "/admin/v1/idps/saml", buildMockSamlIdpBody(metadataXml));
493
+ samlIdpId = String(created.id);
494
+ }
495
+ logSuccess(`Mock SAML IdP ready (${MOCK_SAML_IDP_NAME}, id ${samlIdpId})`);
496
+ let oidcIdpId = await findIdpByName(pat, MOCK_OIDC_IDP_NAME);
497
+ if (!oidcIdpId) {
498
+ const created = await api(pat, "POST", "/admin/v1/idps/oauth", buildMockOauthIdpBody());
499
+ oidcIdpId = String(created.id);
500
+ }
501
+ logSuccess(`Mock OIDC IdP ready (${MOCK_OIDC_IDP_NAME}, id ${oidcIdpId})`);
502
+ const policy = await api(pat, "GET", "/admin/v1/policies/login");
503
+ if (policy?.policy?.allowExternalIdp !== true) {
504
+ await api(pat, "PUT", "/admin/v1/policies/login", {
505
+ ...policy.policy,
506
+ allowExternalIdp: true,
507
+ });
508
+ logInfo("Instance login policy: allowExternalIdp turned on (federation buttons were hidden)");
509
+ }
510
+ const attached = await attachedLoginPolicyIdps(pat);
511
+ const toAttach = missingLoginPolicyIdps(attached, [samlIdpId, oidcIdpId]);
512
+ for (const idpId of toAttach) {
513
+ await api(pat, "POST", "/admin/v1/policies/login/idps", { idpId, ownerType: "IDP_OWNER_TYPE_SYSTEM" });
514
+ }
515
+ const finalIdps = await attachedLoginPolicyIdps(pat);
516
+ logSuccess(`Login policy carries ${finalIdps.length} federation(s) — both mocks attached, ` +
517
+ "which is the state a login-policy change has to preserve");
518
+ return { samlIdpId, oidcIdpId };
519
+ }
@@ -29,20 +29,25 @@ export declare function hubPort(): string;
29
29
  export declare function hubEndpoints(): StackEndpoint[];
30
30
  export declare function findPackageRoot(startDir?: string): string;
31
31
  export declare function stackDir(): string;
32
+ export declare const BASE_COMPOSE_FILE = "docker-compose.yml";
32
33
  export declare const HUB_OVERLAY_FILE = "docker-compose.hub.yml";
34
+ export declare const IDPS_OVERLAY_FILE = "docker-compose.idps.yml";
33
35
  export declare function parseComposeServiceNames(yamlText: string): string[];
34
36
  export declare function hubOverlayServices(): ReadonlySet<string>;
37
+ export declare function idpsOverlayServices(): ReadonlySet<string>;
35
38
  export declare function stackOwnedElsewhere(): string | undefined;
36
39
  export declare function ensureDockerAvailable(): void;
37
40
  export declare function compose(args: string[], opts?: {
38
41
  inherit?: boolean;
39
42
  hub?: boolean;
43
+ idps?: boolean;
40
44
  env?: Record<string, string>;
41
45
  }): string;
42
46
  export declare function summarizeComposeFailure(output: string): string;
43
47
  export declare function composeLogPath(op: string): string;
44
48
  export declare function composeStreamed(args: string[], opts?: {
45
49
  hub?: boolean;
50
+ idps?: boolean;
46
51
  env?: Record<string, string>;
47
52
  }): Promise<void>;
48
53
  export interface ServiceStatus {
@@ -53,10 +58,13 @@ export interface ServiceStatus {
53
58
  export declare function parseComposePs(output: string): ServiceStatus[];
54
59
  export declare function stackServices(): ServiceStatus[];
55
60
  export declare const ONE_SHOT_SERVICES: Set<string>;
61
+ export interface OverlayState {
62
+ services: ReadonlySet<string>;
63
+ started: boolean;
64
+ }
56
65
  export declare function crashedServices(services: ServiceStatus[], opts: {
57
- overlayServices: ReadonlySet<string>;
58
- overlayStarted: boolean;
66
+ overlays: readonly OverlayState[];
59
67
  }): ServiceStatus[];
60
- export declare function hubOverlayRunning(services: ServiceStatus[], overlayServices: ReadonlySet<string>): boolean;
68
+ export declare function overlayRunning(services: ServiceStatus[], overlayServices: ReadonlySet<string>): boolean;
61
69
  export declare function hubApiRunning(services: ServiceStatus[]): boolean;
62
70
  export declare function probeEndpoint(endpoint: StackEndpoint): Promise<boolean>;
@@ -189,23 +189,34 @@ export function stackDir() {
189
189
  }
190
190
  return dir;
191
191
  }
192
+ export const BASE_COMPOSE_FILE = "docker-compose.yml";
192
193
  export const HUB_OVERLAY_FILE = "docker-compose.hub.yml";
194
+ export const IDPS_OVERLAY_FILE = "docker-compose.idps.yml";
193
195
  export function parseComposeServiceNames(yamlText) {
194
196
  const doc = parseYaml(yamlText);
195
197
  return Object.keys(doc?.services ?? {});
196
198
  }
197
- let hubOverlayServicesCache;
198
- export function hubOverlayServices() {
199
- if (!hubOverlayServicesCache) {
200
- const file = path.join(stackDir(), HUB_OVERLAY_FILE);
201
- if (!fs.existsSync(file)) {
202
- throw new MeshCliError(`Local stack assets are incomplete: ${file} is missing.`, {
203
- remediation: { command: "npm install -g @mesh-tech/mesh-cli" },
204
- });
205
- }
206
- hubOverlayServicesCache = new Set(parseComposeServiceNames(fs.readFileSync(file, "utf-8")));
199
+ const overlayServicesCache = new Map();
200
+ function overlayServicesOf(overlayFile) {
201
+ const cached = overlayServicesCache.get(overlayFile);
202
+ if (cached)
203
+ return cached;
204
+ const file = path.join(stackDir(), overlayFile);
205
+ if (!fs.existsSync(file)) {
206
+ throw new MeshCliError(`Local stack assets are incomplete: ${file} is missing.`, {
207
+ remediation: { command: "npm install -g @mesh-tech/mesh-cli" },
208
+ });
207
209
  }
208
- return hubOverlayServicesCache;
210
+ const base = new Set(parseComposeServiceNames(fs.readFileSync(path.join(stackDir(), BASE_COMPOSE_FILE), "utf-8")));
211
+ const names = new Set(parseComposeServiceNames(fs.readFileSync(file, "utf-8")).filter((n) => !base.has(n)));
212
+ overlayServicesCache.set(overlayFile, names);
213
+ return names;
214
+ }
215
+ export function hubOverlayServices() {
216
+ return overlayServicesOf(HUB_OVERLAY_FILE);
217
+ }
218
+ export function idpsOverlayServices() {
219
+ return overlayServicesOf(IDPS_OVERLAY_FILE);
209
220
  }
210
221
  export function stackOwnedElsewhere() {
211
222
  try {
@@ -244,6 +255,8 @@ export function compose(args, opts = {}) {
244
255
  const files = ["-f", path.join(dir, "docker-compose.yml")];
245
256
  if (opts.hub)
246
257
  files.push("-f", path.join(dir, "docker-compose.hub.yml"));
258
+ if (opts.idps)
259
+ files.push("-f", path.join(dir, IDPS_OVERLAY_FILE));
247
260
  const fullArgs = ["compose", "-p", COMPOSE_PROJECT, ...files, ...args];
248
261
  return execFileSync("docker", fullArgs, {
249
262
  cwd: dir,
@@ -276,6 +289,8 @@ export async function composeStreamed(args, opts = {}) {
276
289
  const files = ["-f", path.join(dir, "docker-compose.yml")];
277
290
  if (opts.hub)
278
291
  files.push("-f", path.join(dir, "docker-compose.hub.yml"));
292
+ if (opts.idps)
293
+ files.push("-f", path.join(dir, IDPS_OVERLAY_FILE));
279
294
  const fullArgs = ["compose", "-p", COMPOSE_PROJECT, ...files, ...args];
280
295
  const env = {
281
296
  ...process.env,
@@ -362,11 +377,10 @@ export function stackServices() {
362
377
  }
363
378
  export const ONE_SHOT_SERVICES = new Set(["spicedb-migrate", "zitadel-machinekey-init"]);
364
379
  export function crashedServices(services, opts) {
365
- return services.filter((s) => s.state === "exited" &&
366
- !ONE_SHOT_SERVICES.has(s.name) &&
367
- (opts.overlayStarted || !opts.overlayServices.has(s.name)));
380
+ const dormant = new Set(opts.overlays.filter((o) => !o.started).flatMap((o) => [...o.services]));
381
+ return services.filter((s) => s.state === "exited" && !ONE_SHOT_SERVICES.has(s.name) && !dormant.has(s.name));
368
382
  }
369
- export function hubOverlayRunning(services, overlayServices) {
383
+ export function overlayRunning(services, overlayServices) {
370
384
  return services.some((s) => overlayServices.has(s.name) && s.state === "running");
371
385
  }
372
386
  export function hubApiRunning(services) {
@@ -604,6 +604,15 @@ var init_src = __esm({
604
604
  }
605
605
  });
606
606
 
607
+ // libs/mesh-cli/src/commands/local/mock-idps.ts
608
+ var init_mock_idps = __esm({
609
+ "libs/mesh-cli/src/commands/local/mock-idps.ts"() {
610
+ "use strict";
611
+ init_cache_home();
612
+ init_errors();
613
+ }
614
+ });
615
+
607
616
  // libs/mesh-cli/src/commands/local/seed-zitadel.ts
608
617
  var HUB_DEFAULT_REDIRECT_URI, HUB_ROLES, ZITADEL_SSM_PARAM, TEST_USERS_SSM_PREFIX;
609
618
  var init_seed_zitadel = __esm({
@@ -616,6 +625,7 @@ var init_seed_zitadel = __esm({
616
625
  init_stack();
617
626
  init_seed();
618
627
  init_cache_home();
628
+ init_mock_idps();
619
629
  HUB_DEFAULT_REDIRECT_URI = `http://localhost:${DEFAULT_HUB_PORT}/oauth2/callback`;
620
630
  HUB_ROLES = hubOperatorRoleKeys([LOCAL_TENANT]);
621
631
  ZITADEL_SSM_PARAM = `/mesh-platform/${LOCAL_TENANT}/${LOCAL_ENV}/platform/zitadel`;