@mesh-tech/mesh-cli 0.25.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.
Files changed (63) hide show
  1. package/README.md +74 -246
  2. package/dist/bin/mesh.js +2179 -1328
  3. package/dist/bin/mesh.js.map +3 -3
  4. package/dist/build-info.json +2 -2
  5. package/dist/src/commands/db/axes.d.ts +19 -0
  6. package/dist/src/commands/db/axes.js +35 -0
  7. package/dist/src/commands/db/connect.d.ts +2 -4
  8. package/dist/src/commands/db/connect.js +4 -19
  9. package/dist/src/commands/db/credentials.d.ts +2 -5
  10. package/dist/src/commands/db/credentials.js +7 -23
  11. package/dist/src/commands/db/env.d.ts +2 -5
  12. package/dist/src/commands/db/env.js +7 -23
  13. package/dist/src/commands/db/exec.js +7 -24
  14. package/dist/src/commands/db/psql.d.ts +2 -5
  15. package/dist/src/commands/db/psql.js +8 -23
  16. package/dist/src/commands/init/platform-setup.d.ts +68 -0
  17. package/dist/src/commands/init/platform-setup.js +203 -0
  18. package/dist/src/commands/init/wizard.d.ts +20 -0
  19. package/dist/src/commands/init/wizard.js +89 -7
  20. package/dist/src/commands/init.js +42 -36
  21. package/dist/src/commands/local/index.d.ts +5 -3
  22. package/dist/src/commands/local/index.js +61 -23
  23. package/dist/src/commands/local/mock-idps.d.ts +48 -0
  24. package/dist/src/commands/local/mock-idps.js +119 -0
  25. package/dist/src/commands/local/seed-zitadel.d.ts +6 -0
  26. package/dist/src/commands/local/seed-zitadel.js +76 -0
  27. package/dist/src/commands/local/stack.d.ts +11 -3
  28. package/dist/src/commands/local/stack.js +29 -15
  29. package/dist/src/commands/login.d.ts +4 -0
  30. package/dist/src/commands/login.js +16 -2
  31. package/dist/src/commands/registry.d.ts +2 -2
  32. package/dist/src/commands/registry.js +11 -6
  33. package/dist/src/commands/secrets/context.d.ts +5 -0
  34. package/dist/src/commands/secrets/context.js +5 -0
  35. package/dist/src/commands/secrets/index.js +3 -0
  36. package/dist/src/commands/secrets/migrate-config.d.ts +1 -0
  37. package/dist/src/commands/secrets/migrate-config.js +2 -3
  38. package/dist/src/commands/secrets/reindex.d.ts +1 -0
  39. package/dist/src/commands/secrets/reindex.js +2 -2
  40. package/dist/src/commands/secrets/set.d.ts +1 -0
  41. package/dist/src/commands/secrets/set.js +2 -2
  42. package/dist/src/commands/temporal.js +35 -8
  43. package/dist/src/commands/temporal.js.map +2 -2
  44. package/dist/src/commands/tunnel/index.d.ts +8 -0
  45. package/dist/src/commands/tunnel/index.js +3 -3
  46. package/dist/src/index.js +324 -62
  47. package/dist/src/index.js.map +3 -3
  48. package/dist/src/utils/context.d.ts +6 -1
  49. package/dist/src/utils/context.js +302 -39
  50. package/dist/src/utils/registry-identity.d.ts +5 -0
  51. package/dist/src/utils/registry-identity.js +16 -0
  52. package/fragments/agents/AGENTS.md.hbs +10 -2
  53. package/fragments/base/index.ts.hbs +15 -5
  54. package/package.json +2 -2
  55. package/skills/core/SKILL.md +74 -7
  56. package/stack/docker-compose.idps.yml +104 -0
  57. package/stack/idps/dex.yaml +53 -0
  58. package/templates/api-auth/index.ts.hbs +5 -4
  59. package/templates/api-role-gating/index.ts.hbs +5 -4
  60. package/templates/external-service/index.ts.hbs +5 -4
  61. package/templates/platform-repo/README.md.hbs +17 -3
  62. package/templates/platform-repo/core/Pulumi.{{tenant}}-{{env}}.yaml.hbs +7 -0
  63. package/templates/workflow/index.ts.hbs +5 -4
@@ -1,10 +1,17 @@
1
1
  import { probeRegistryToken } from "../../utils/auth-preflight.js";
2
2
  import { type RegistryLoginOptions } from "../registry.js";
3
3
  import { type MeshJson } from "../../utils/mesh-json.js";
4
+ import { scaffoldPlatformRepo, type PlatformSetupFlags, type StateBucketDeps } from "./platform-setup.js";
4
5
  export interface WizardOptions {
5
6
  tenant?: string;
6
7
  local?: boolean;
7
8
  platform?: string;
9
+ newPlatform?: string | boolean;
10
+ region?: string;
11
+ domain?: string;
12
+ stateBucket?: string;
13
+ createStateBucket?: boolean;
14
+ dir?: string;
8
15
  device?: boolean;
9
16
  profile?: string;
10
17
  skipRepo?: boolean;
@@ -18,9 +25,15 @@ export interface StepResult {
18
25
  detail: string;
19
26
  fix?: string;
20
27
  blocking?: boolean;
28
+ next?: string[];
21
29
  }
22
30
  export type WizardMode = "local" | {
23
31
  platform: string;
32
+ } | {
33
+ newPlatform: PlatformSetupFlags;
34
+ };
35
+ export declare function isNewPlatform(mode: WizardMode): mode is {
36
+ newPlatform: PlatformSetupFlags;
24
37
  };
25
38
  export interface WizardCtx {
26
39
  cwd: string;
@@ -40,6 +53,8 @@ export interface WizardSeams {
40
53
  probe?: typeof probeRegistryToken;
41
54
  runRegistryLogin?: (opts: RegistryLoginOptions) => Promise<void>;
42
55
  gitInit?: (dir: string) => void;
56
+ scaffoldPlatform?: typeof scaffoldPlatformRepo;
57
+ stateBucket?: StateBucketDeps;
43
58
  }
44
59
  export interface WizardStep {
45
60
  name: "registry" | "platform" | "repo" | "skills";
@@ -72,7 +87,9 @@ export interface Prompts {
72
87
  }
73
88
  export declare const NO_SESSION_NO_TTY_MESSAGE: string;
74
89
  export declare const TENANT_REQUIRED_MESSAGE = "--tenant is required (no TTY for interactive mode)";
90
+ export declare const REGISTRY_WHY: string;
75
91
  export declare const INTERRUPTED_EXIT_CODE = 130;
92
+ export declare const PLATFORM_SETUP_LABEL = "Platform setup";
76
93
  export declare function wizardExitCode(steps: ReadonlyArray<Pick<StepResult, "status">>): number;
77
94
  export declare function wizardNextCommands(mode: WizardMode, steps?: ReadonlyArray<StepResult>): string[];
78
95
  export declare function stepBlocks(result: StepResult): boolean;
@@ -85,7 +102,9 @@ export declare function resolveAnswers(opts: WizardOptions, recorded: MeshJson |
85
102
  mode?: WizardMode;
86
103
  open: Array<"tenant" | "mode">;
87
104
  };
105
+ export declare function platformSetupFlags(opts: WizardOptions): PlatformSetupFlags;
88
106
  export declare const registryStep: WizardStep;
107
+ export declare const newPlatformStep: WizardStep;
89
108
  export type RepoKind = "platform-monorepo" | "empty-apps-repo" | "apps-repo" | "other-repo" | "empty-dir" | "no-repo";
90
109
  export declare function classifyRepo(cwd: string): {
91
110
  kind: RepoKind;
@@ -93,6 +112,7 @@ export declare function classifyRepo(cwd: string): {
93
112
  };
94
113
  export declare const INIT_REPO_FIX = "git init && mesh init";
95
114
  export declare const repoStep: WizardStep;
115
+ export declare const skillsStep: WizardStep;
96
116
  export declare const WIZARD_STEPS: readonly WizardStep[];
97
117
  export declare function runWizardSteps(ctx: WizardCtx, steps: readonly WizardStep[]): Promise<StepResult[]>;
98
118
  export declare function runInitWizard(opts: WizardOptions, deps?: {
@@ -12,10 +12,17 @@ import { discoverConfigForContext, isRemoteEnvironment, readCredentials, renderN
12
12
  import { bootstrapAppsRepo, isInsidePlatformMonorepo, shouldBootstrapAppsRepo } from "../create-app.js";
13
13
  import { resolveTargetRoot, syncSkills } from "../skills.js";
14
14
  import { detectLocalTenant } from "../local/dev-local.js";
15
+ import { PLATFORM_SETUP_DEFAULTS, ensureStateBucket, renderPlatformNextSteps, resolvePlatformSetup, scaffoldPlatformRepo, } from "./platform-setup.js";
16
+ export function isNewPlatform(mode) {
17
+ return typeof mode === "object" && "newPlatform" in mode;
18
+ }
15
19
  export const NO_SESSION_NO_TTY_MESSAGE = "No registry session and no terminal to sign in with.\n" +
16
20
  " On a headless box: mesh registry login --device (one-time, prints a code)\n" +
17
21
  " In CI with AWS creds: mesh registry login --ci";
18
22
  export const TENANT_REQUIRED_MESSAGE = "--tenant is required (no TTY for interactive mode)";
23
+ export const REGISTRY_WHY = "Package access — a sign-in to the Mesh package REGISTRY, which is what makes `@mesh-tech/*` installable.\n" +
24
+ " It is one global identity: no AWS account, no platform, no <tenant>.<env> context.\n" +
25
+ " (`mesh login <tenant>.<env>` is the other sign-in — that one is only for deploying to, or operating, a platform.)";
19
26
  export const INTERRUPTED_EXIT_CODE = 130;
20
27
  const STEP_LABELS = {
21
28
  registry: "Registry access",
@@ -23,10 +30,14 @@ const STEP_LABELS = {
23
30
  repo: "Repo",
24
31
  skills: "Agent skills",
25
32
  };
33
+ export const PLATFORM_SETUP_LABEL = "Platform setup";
26
34
  export function wizardExitCode(steps) {
27
35
  return steps.some((s) => s.status === "fail") ? 1 : 0;
28
36
  }
29
37
  export function wizardNextCommands(mode, steps = []) {
38
+ const owned = steps.find((s) => s.next?.length)?.next;
39
+ if (owned)
40
+ return owned;
30
41
  const repo = steps.find((s) => s.name === STEP_LABELS.repo);
31
42
  const first = repo && repo.status !== "pass" && repo.fix ? repo.fix : "mesh create-app";
32
43
  return mode === "local" ? [first] : [first, "mesh deploy up # from the app directory, once it exists"];
@@ -35,7 +46,11 @@ export function stepBlocks(result) {
35
46
  return result.status === "fail" && result.blocking !== false;
36
47
  }
37
48
  export function describeMode(mode) {
38
- return mode === "local" ? "local only" : `deployed platform (${mode.platform})`;
49
+ if (mode === "local")
50
+ return "local only";
51
+ if (isNewPlatform(mode))
52
+ return `a new platform (${mode.newPlatform.env ?? PLATFORM_SETUP_DEFAULTS.env})`;
53
+ return `deployed platform (${mode.platform})`;
39
54
  }
40
55
  export function renderWizardSummary(outcome, opts = {}) {
41
56
  const paint = opts.color === false ? { green: (s) => s, red: (s) => s, yellow: (s) => s, dim: (s) => s, cyan: (s) => s } : chalk;
@@ -65,10 +80,13 @@ export function resolveAnswers(opts, recorded, interactive) {
65
80
  open.push("tenant");
66
81
  }
67
82
  let mode;
68
- if (opts.local && opts.platform) {
69
- throw new MeshCliError("--local and --platform are mutually exclusive.");
83
+ const chosen = [opts.local && "--local", opts.platform && "--platform", opts.newPlatform && "--new-platform"].filter(Boolean);
84
+ if (chosen.length > 1) {
85
+ throw new MeshCliError(`${chosen.join(", ")} are mutually exclusive — a tenant runs in exactly one of those three places.`);
70
86
  }
71
- if (opts.platform)
87
+ if (opts.newPlatform)
88
+ mode = { newPlatform: platformSetupFlags(opts) };
89
+ else if (opts.platform)
72
90
  mode = { platform: opts.platform };
73
91
  else if (opts.local)
74
92
  mode = "local";
@@ -80,6 +98,18 @@ export function resolveAnswers(opts, recorded, interactive) {
80
98
  }
81
99
  return { tenant, mode, open };
82
100
  }
101
+ export function platformSetupFlags(opts) {
102
+ return {
103
+ env: typeof opts.newPlatform === "string" ? opts.newPlatform : undefined,
104
+ region: opts.region,
105
+ domain: opts.domain,
106
+ profile: opts.profile,
107
+ stateBucket: opts.stateBucket,
108
+ createStateBucket: opts.createStateBucket,
109
+ dir: opts.dir,
110
+ yes: opts.yes,
111
+ };
112
+ }
83
113
  export const registryStep = {
84
114
  name: "registry",
85
115
  async run(ctx) {
@@ -102,6 +132,7 @@ export const registryStep = {
102
132
  return { name, status: "fail", detail: NO_SESSION_NO_TTY_MESSAGE.split("\n")[0], fix: "mesh registry login --device", blocking: true };
103
133
  }
104
134
  try {
135
+ logInfo(REGISTRY_WHY);
105
136
  const login = ctx.seams?.runRegistryLogin ??
106
137
  (async (opts) => {
107
138
  const { runRegistryLogin } = await import("../registry.js");
@@ -132,10 +163,51 @@ export const registryStep = {
132
163
  return { name, status: "pass", detail: `Registry access OK (${session ? `signed in as ${session.email}` : "token written"})` };
133
164
  },
134
165
  };
166
+ export const newPlatformStep = {
167
+ name: "platform",
168
+ async run(ctx) {
169
+ const name = PLATFORM_SETUP_LABEL;
170
+ if (!isNewPlatform(ctx.mode))
171
+ return { name, status: "skip", detail: "no new platform requested" };
172
+ let setup;
173
+ try {
174
+ setup = await resolvePlatformSetup(ctx.tenant, { ...ctx.mode.newPlatform, yes: ctx.yes }, {
175
+ prompts: ctx.prompts,
176
+ interactive: ctx.interactive,
177
+ cwd: ctx.cwd,
178
+ });
179
+ }
180
+ catch (err) {
181
+ return {
182
+ name,
183
+ status: "fail",
184
+ detail: err.message.split("\n")[0],
185
+ fix: `mesh init --tenant ${ctx.tenant} --new-platform --domain <your-domain>`,
186
+ };
187
+ }
188
+ try {
189
+ await (ctx.seams?.scaffoldPlatform ?? scaffoldPlatformRepo)(setup);
190
+ }
191
+ catch (err) {
192
+ return { name, status: "fail", detail: `platform repo not scaffolded: ${err.message.split("\n")[0]}`, fix: `mesh init platform ${ctx.tenant} --dir <empty-dir>` };
193
+ }
194
+ const bucket = await ensureStateBucket(setup, ctx.seams?.stateBucket);
195
+ const next = renderPlatformNextSteps(setup, { bucket, relativeDir: path.relative(ctx.cwd, setup.dir) || "." });
196
+ return {
197
+ name,
198
+ status: setup.createStateBucket && !bucket.present ? "warn" : "pass",
199
+ detail: `platform repo scaffolded at ${setup.dir} (${setup.tenant}.${setup.env}, ${setup.region}, zone ${setup.env}.${setup.domain})` +
200
+ (setup.createStateBucket ? ` — state bucket: ${bucket.detail}` : ` — state bucket ${setup.stateBucket} not created (see Next)`),
201
+ next,
202
+ };
203
+ },
204
+ };
135
205
  const platformStep = {
136
206
  name: "platform",
137
207
  async run(ctx) {
138
208
  const name = STEP_LABELS.platform;
209
+ if (isNewPlatform(ctx.mode))
210
+ return newPlatformStep.run(ctx);
139
211
  if (!ctx.context)
140
212
  return { name, status: "skip", detail: "local only — no platform to sign in to" };
141
213
  const context = ctx.context;
@@ -192,6 +264,9 @@ export const repoStep = {
192
264
  const name = STEP_LABELS.repo;
193
265
  if (ctx.skipRepo)
194
266
  return { name, status: "skip", detail: "--skip-repo" };
267
+ if (isNewPlatform(ctx.mode)) {
268
+ return { name, status: "skip", detail: "setting up a platform — the apps repo comes after the first deploy (see Next)" };
269
+ }
195
270
  const { kind, root } = classifyRepo(ctx.cwd);
196
271
  switch (kind) {
197
272
  case "platform-monorepo":
@@ -259,12 +334,15 @@ export const repoStep = {
259
334
  }
260
335
  },
261
336
  };
262
- const skillsStep = {
337
+ export const skillsStep = {
263
338
  name: "skills",
264
339
  async run(ctx) {
265
340
  const name = STEP_LABELS.skills;
266
341
  if (ctx.skipRepo)
267
342
  return { name, status: "skip", detail: "--skip-repo" };
343
+ if (isNewPlatform(ctx.mode)) {
344
+ return { name, status: "skip", detail: "synced into the new platform repo by the scaffold" };
345
+ }
268
346
  const { kind, root } = classifyRepo(ctx.cwd);
269
347
  if (kind !== "apps-repo" && kind !== "empty-apps-repo") {
270
348
  return { name, status: "skip", detail: "no apps repo to install skills into" };
@@ -341,11 +419,15 @@ export async function runInitWizard(opts, deps = {}) {
341
419
  choices: [
342
420
  { name: "Local only (mesh start / mesh dev — no deployed platform yet)", value: "local" },
343
421
  { name: `A deployed Mesh platform (I have a ${tenant}.<env> context)`, value: "deployed" },
422
+ { name: "Nothing yet — set up a new Mesh platform in my AWS account", value: "new" },
344
423
  ],
345
424
  default: recorded && recorded.platform !== "local" ? "deployed" : "local",
346
425
  });
347
426
  if (choice === "local")
348
427
  mode = "local";
428
+ else if (choice === "new") {
429
+ mode = { newPlatform: platformSetupFlags({ ...opts, newPlatform: true }) };
430
+ }
349
431
  else {
350
432
  const env = await prompts.input({
351
433
  message: "Environment",
@@ -359,7 +441,7 @@ export async function runInitWizard(opts, deps = {}) {
359
441
  cwd,
360
442
  tenant,
361
443
  mode,
362
- context: mode === "local" ? null : `${tenant}.${mode.platform}`,
444
+ context: mode === "local" || isNewPlatform(mode) ? null : `${tenant}.${mode.platform}`,
363
445
  device: opts.device,
364
446
  profile: opts.profile,
365
447
  interactive,
@@ -372,7 +454,7 @@ export async function runInitWizard(opts, deps = {}) {
372
454
  logInfo(`Setting up tenant '${tenant}' (${describeMode(mode)})…`);
373
455
  const steps = await runWizardSteps(ctx, deps.steps ?? WIZARD_STEPS);
374
456
  const { kind, root } = classifyRepo(cwd);
375
- if (!opts.skipRepo && (kind === "apps-repo" || kind === "empty-apps-repo")) {
457
+ if (!opts.skipRepo && !isNewPlatform(mode) && (kind === "apps-repo" || kind === "empty-apps-repo")) {
376
458
  const written = writeMeshJson(root, { tenant, platform: mode === "local" ? "local" : mode.platform });
377
459
  logInfo(`Recorded tenant + mode in ${written}`);
378
460
  }
@@ -8,6 +8,14 @@ import { readCredentials } from "./login.js";
8
8
  import { resolveTargetRoot, syncSkills } from "./skills.js";
9
9
  import { detectLocalTenant } from "./local/dev-local.js";
10
10
  import { LOGIN_CONTEXT } from "./local/seed-zitadel.js";
11
+ import { PLATFORM_SETUP_DEFAULTS } from "./init/platform-setup.js";
12
+ async function setupPrompts() {
13
+ const [{ default: input }, { default: confirm }] = await Promise.all([
14
+ import("@inquirer/input"),
15
+ import("@inquirer/confirm"),
16
+ ]);
17
+ return { input: (args) => input(args), confirm: (args) => confirm(args) };
18
+ }
11
19
  const LOCAL_HUB_API = "http://localhost:4568";
12
20
  function isLocalContext(context) {
13
21
  return context === LOGIN_CONTEXT;
@@ -211,12 +219,19 @@ export function registerInitCommands(program) {
211
219
  const init = program
212
220
  .command("init")
213
221
  .enablePositionalOptions()
214
- .description("Set up this machine and repo for a tenant — a guided wizard with no subcommand (tenant, local or deployed, package-registry sign-in, repo, skills); `init platform` scaffolds a platform repo, `init app-tenant` is the apps-repo doctor")
222
+ .description("Set up this machine and repo for a tenant — a guided wizard with no subcommand. It starts from nothing: it asks where the tenant runs, and 'nowhere yet' is one of the answers (it scaffolds you a platform). Then package-registry sign-in, repo, skills. `init platform` is the same scaffold on its own; `init app-tenant` is the apps-repo doctor")
215
223
  .option("--tenant <name>", "app tenant this repo belongs to (prompted when omitted on a TTY)")
216
224
  .option("--local", "local only — mesh start / mesh dev, no deployed platform (the default)")
217
- .option("--platform <env>", "a deployed platform's environment; signs in to <tenant>.<env>")
225
+ .option("--platform <env>", "a deployed platform's environment; signs in to `<tenant>.<env>`")
226
+ .option("--new-platform [env]", `nothing deployed yet — scaffold a tenant platform repo for \`<tenant>.<env>\` (default env: ${PLATFORM_SETUP_DEFAULTS.env}) and print its deploy sequence`)
227
+ .option("--region <region>", `--new-platform: AWS region (default: ${PLATFORM_SETUP_DEFAULTS.region})`)
228
+ .option("--domain <domain>", "--new-platform: base public domain you control; the core stack creates the zone {env}.{domain}. No default — a placeholder scaffolds a zone that can never validate")
229
+ .option("--state-bucket <bucket>", "--new-platform: Pulumi state S3 bucket (default: {tenant}-mesh-platform-pulumi-state)")
230
+ .option("--create-state-bucket", "--new-platform: create the state bucket (versioned) if it does not exist")
231
+ .option("--no-create-state-bucket", "--new-platform: never create the state bucket; print the aws command instead")
232
+ .option("--dir <path>", "--new-platform: where to scaffold the platform repo (default: ./{tenant}-mesh-platform)")
218
233
  .option("--device", "device-code sign-in for the registry and platform steps (headless / SSH)")
219
- .option("--profile <aws-profile>", "deployers only: AWS profile that can read the registry (skips the broker)")
234
+ .option("--profile <aws-profile>", "AWS profile this machine uses: read the registry without the broker (deployers only), and the profile a --new-platform stack config references")
220
235
  .option("--skip-repo", "do not bootstrap, check or record anything in the current repo", false)
221
236
  .option("--yes", "accept every default and never prompt", false)
222
237
  .option("--json", "one JSON document on stdout ({tenant, mode, steps, next}); human output on stderr", false)
@@ -228,47 +243,38 @@ export function registerInitCommands(program) {
228
243
  });
229
244
  init
230
245
  .command("platform <tenant>")
231
- .description("Scaffold a tenant PLATFORM repo (core + platform Pulumi layers on @mesh-tech/infra-components) that deploys with `mesh deploy` unmodified — the proven mesh-sandbox shape, annotated.")
232
- .option("--env <env>", "environment the first stack targets", "dev")
233
- .option("--region <region>", "AWS region", "us-east-2")
234
- .option("--domain <domain>", "base public domain (ingress zone becomes {env}.{domain})", "example.com")
235
- .option("--profile <profile>", "AWS profile the stack config references", "default")
236
- .option("--state-bucket <bucket>", "Pulumi state S3 bucket (created by an operator)", undefined)
246
+ .description("Scaffold a tenant PLATFORM repo (core + platform Pulumi layers on @mesh-tech/infra-components) that deploys with `mesh deploy` unmodified — the proven mesh-sandbox shape, annotated. On a terminal it ASKS for the values that differ per install (env, region, domain, state bucket, AWS profile), each pre-filled with its default; every answer is also a flag.")
247
+ .option("--env <env>", `environment the first stack targets (default: ${PLATFORM_SETUP_DEFAULTS.env})`)
248
+ .option("--region <region>", `AWS region (default: ${PLATFORM_SETUP_DEFAULTS.region})`)
249
+ .option("--domain <domain>", "base public domain you control — the core stack creates the Route 53 zone {env}.{domain} and waits on its NS delegation. Required: it has no default, because a placeholder scaffolds a zone that can never validate")
250
+ .option("--profile <profile>", `AWS profile the stack config references (default: ${PLATFORM_SETUP_DEFAULTS.profile})`)
251
+ .option("--state-bucket <bucket>", "Pulumi state S3 bucket (default: {tenant}-mesh-platform-pulumi-state)")
252
+ .option("--create-state-bucket", "create the state bucket (versioned) if it does not exist — prompted on a terminal")
253
+ .option("--no-create-state-bucket", "never create the state bucket; print the aws command instead")
237
254
  .option("--dir <path>", "target directory (default: ./{tenant}-mesh-platform)")
255
+ .option("--yes", "accept every default and never prompt", false)
238
256
  .action(async (tenant, opts) => {
239
257
  if (!/^[a-z][a-z0-9-]*$/.test(tenant)) {
240
258
  throw new MeshCliError(`Invalid tenant name '${tenant}' (lowercase alphanumeric + dashes).`);
241
259
  }
242
- const targetDir = path.resolve(opts.dir ?? `${tenant}-mesh-platform`);
243
- if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length > 0) {
244
- throw new MeshCliError(`Target directory is not empty: ${targetDir}`, {
245
- remediation: { command: `mesh init platform ${tenant} --dir <empty-dir>` },
246
- });
247
- }
248
- const { copyTemplate } = await import("./create-app.js");
249
- const { findPackageRoot } = await import("./local/stack.js");
250
- const templateDir = path.join(findPackageRoot(), "templates", "platform-repo");
251
- const context = {
252
- tenant,
253
- env: opts.env,
254
- region: opts.region,
255
- domain: opts.domain,
256
- profile: opts.profile,
257
- stateBucket: opts.stateBucket ?? `${tenant}-mesh-platform-pulumi-state`,
258
- };
259
- fs.mkdirSync(targetDir, { recursive: true });
260
- copyTemplate(templateDir, targetDir, context);
261
- syncSkills(targetDir);
262
- logInfo(`Platform repo scaffolded at ${targetDir}`);
260
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
261
+ const { resolvePlatformSetup, scaffoldPlatformRepo, ensureStateBucket, renderPlatformNextSteps } = await import("./init/platform-setup.js");
262
+ const setup = await resolvePlatformSetup(tenant, opts, {
263
+ prompts: interactive && !opts.yes ? await setupPrompts() : undefined,
264
+ interactive,
265
+ });
266
+ await scaffoldPlatformRepo(setup);
267
+ logInfo(`Platform repo scaffolded at ${setup.dir}`);
268
+ const bucket = await ensureStateBucket(setup);
269
+ if (bucket.detail !== "not requested")
270
+ logInfo(`Pulumi state bucket: ${bucket.detail}`);
263
271
  console.log("");
264
272
  console.log("Next steps:");
265
- console.log(` cd ${path.relative(process.cwd(), targetDir) || "."}`);
266
- console.log(` mesh login ${tenant}.${opts.env} # or an operator context with access`);
267
- console.log(" pnpm install");
268
- console.log(" cd core && mesh deploy up");
269
- console.log(" cd ../platform && mesh deploy up");
273
+ for (const line of renderPlatformNextSteps(setup, { bucket, relativeDir: path.relative(process.cwd(), setup.dir) || "." })) {
274
+ console.log(line ? ` ${line}` : "");
275
+ }
270
276
  console.log("");
271
- logInfo(`Review the annotated stack configs (Pulumi.${tenant}-${opts.env}.yaml) before the first deploy.`);
277
+ logInfo(`Review the annotated stack configs (Pulumi.${tenant}-${setup.env}.yaml) before the first deploy.`);
272
278
  });
273
279
  init
274
280
  .command("app-tenant")
@@ -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[];