@mesh-tech/mesh-cli 0.24.1 → 0.26.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 (55) hide show
  1. package/README.md +74 -246
  2. package/dist/bin/mesh.js +1451 -847
  3. package/dist/bin/mesh.js.map +3 -3
  4. package/dist/build-info.json +2 -2
  5. package/dist/src/commands/app-check.d.ts +2 -1
  6. package/dist/src/commands/app-check.js +27 -8
  7. package/dist/src/commands/db/axes.d.ts +19 -0
  8. package/dist/src/commands/db/axes.js +35 -0
  9. package/dist/src/commands/db/connect.d.ts +2 -4
  10. package/dist/src/commands/db/connect.js +4 -19
  11. package/dist/src/commands/db/credentials.d.ts +2 -5
  12. package/dist/src/commands/db/credentials.js +7 -23
  13. package/dist/src/commands/db/env.d.ts +2 -5
  14. package/dist/src/commands/db/env.js +7 -23
  15. package/dist/src/commands/db/exec.js +7 -24
  16. package/dist/src/commands/db/psql.d.ts +2 -5
  17. package/dist/src/commands/db/psql.js +8 -23
  18. package/dist/src/commands/init/platform-setup.d.ts +68 -0
  19. package/dist/src/commands/init/platform-setup.js +203 -0
  20. package/dist/src/commands/init/wizard.d.ts +20 -0
  21. package/dist/src/commands/init/wizard.js +89 -7
  22. package/dist/src/commands/init.js +42 -36
  23. package/dist/src/commands/login.d.ts +4 -0
  24. package/dist/src/commands/login.js +16 -2
  25. package/dist/src/commands/registry.d.ts +2 -2
  26. package/dist/src/commands/registry.js +11 -6
  27. package/dist/src/commands/secrets/context.d.ts +5 -0
  28. package/dist/src/commands/secrets/context.js +5 -0
  29. package/dist/src/commands/secrets/index.js +3 -0
  30. package/dist/src/commands/secrets/migrate-config.d.ts +1 -0
  31. package/dist/src/commands/secrets/migrate-config.js +2 -3
  32. package/dist/src/commands/secrets/reindex.d.ts +1 -0
  33. package/dist/src/commands/secrets/reindex.js +2 -2
  34. package/dist/src/commands/secrets/set.d.ts +1 -0
  35. package/dist/src/commands/secrets/set.js +2 -2
  36. package/dist/src/commands/temporal.js +34 -10
  37. package/dist/src/commands/temporal.js.map +2 -2
  38. package/dist/src/commands/tunnel/index.d.ts +8 -0
  39. package/dist/src/commands/tunnel/index.js +3 -3
  40. package/dist/src/index.js +324 -62
  41. package/dist/src/index.js.map +3 -3
  42. package/dist/src/utils/context.d.ts +6 -1
  43. package/dist/src/utils/context.js +302 -39
  44. package/dist/src/utils/registry-identity.d.ts +5 -0
  45. package/dist/src/utils/registry-identity.js +16 -0
  46. package/fragments/agents/AGENTS.md.hbs +14 -2
  47. package/fragments/base/index.ts.hbs +15 -5
  48. package/package.json +2 -2
  49. package/skills/core/SKILL.md +74 -7
  50. package/templates/api-auth/index.ts.hbs +5 -4
  51. package/templates/api-role-gating/index.ts.hbs +5 -4
  52. package/templates/external-service/index.ts.hbs +5 -4
  53. package/templates/platform-repo/README.md.hbs +17 -3
  54. package/templates/platform-repo/core/Pulumi.{{tenant}}-{{env}}.yaml.hbs +7 -0
  55. package/templates/workflow/index.ts.hbs +5 -4
@@ -0,0 +1,203 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { execFileSync } from "child_process";
4
+ import { MeshCliError } from "../../utils/errors.js";
5
+ export const PLATFORM_SETUP_DEFAULTS = {
6
+ env: "dev",
7
+ region: "us-east-2",
8
+ profile: "default",
9
+ };
10
+ export const DOMAIN_REQUIRED_MESSAGE = "--domain is required: the core stack creates the public Route 53 zone <env>.<domain>, " +
11
+ "and a placeholder domain scaffolds a zone that can never validate (the deploy stalls on the ACM certificate).";
12
+ export function defaultStateBucket(tenant) {
13
+ return `${tenant}-mesh-platform-pulumi-state`;
14
+ }
15
+ export function defaultPlatformDir(tenant) {
16
+ return `${tenant}-mesh-platform`;
17
+ }
18
+ export function validateEnv(value) {
19
+ return /^[a-z][a-z0-9-]*$/.test(value.trim())
20
+ ? true
21
+ : "lowercase letters, digits and dashes, starting with a letter";
22
+ }
23
+ export function validateRegion(value) {
24
+ return /^[a-z]{2}(-[a-z]+)+-\d$/.test(value.trim()) ? true : "an AWS region, e.g. us-east-2 or eu-west-1";
25
+ }
26
+ export function validateDomain(value) {
27
+ const domain = value.trim().toLowerCase().replace(/\.$/, "");
28
+ if (!domain)
29
+ return "a public domain you control, e.g. q2poc.pub";
30
+ if (!/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/.test(domain)) {
31
+ return "a public domain you control, e.g. q2poc.pub";
32
+ }
33
+ if (/(^|\.)(example\.(com|org|net)|invalid|localhost|test)$/.test(domain)) {
34
+ return `${domain} is a placeholder — the deploy stalls on an ACM certificate for a zone nobody can delegate. Use a domain you control.`;
35
+ }
36
+ return true;
37
+ }
38
+ export function validateStateBucket(value) {
39
+ const bucket = value.trim();
40
+ return /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket) && !bucket.includes("..")
41
+ ? true
42
+ : "3–63 characters: lowercase letters, digits, dots and dashes, starting and ending alphanumeric";
43
+ }
44
+ export function resolveTargetDir(tenant, flags, cwd) {
45
+ const dir = path.resolve(cwd, flags.dir ?? defaultPlatformDir(tenant));
46
+ if (fs.existsSync(dir) && fs.readdirSync(dir).length > 0) {
47
+ throw new MeshCliError(`Target directory is not empty: ${dir}`, {
48
+ remediation: { command: `mesh init platform ${tenant} --dir <empty-dir>` },
49
+ });
50
+ }
51
+ return dir;
52
+ }
53
+ export async function resolvePlatformSetup(tenant, flags, deps) {
54
+ const cwd = deps.cwd ?? process.cwd();
55
+ const dir = resolveTargetDir(tenant, flags, cwd);
56
+ const ask = deps.interactive && !flags.yes ? deps.prompts : undefined;
57
+ const env = flags.env ?? (ask ? await ask.input({ message: "Environment", default: PLATFORM_SETUP_DEFAULTS.env, validate: validateEnv }) : PLATFORM_SETUP_DEFAULTS.env);
58
+ if (validateEnv(env) !== true)
59
+ throw new MeshCliError(`Invalid environment '${env}' — ${validateEnv(env)}.`);
60
+ const region = flags.region ??
61
+ (ask
62
+ ? await ask.input({ message: "AWS region", default: PLATFORM_SETUP_DEFAULTS.region, validate: validateRegion })
63
+ : PLATFORM_SETUP_DEFAULTS.region);
64
+ if (validateRegion(region) !== true)
65
+ throw new MeshCliError(`Invalid region '${region}' — ${validateRegion(region)}.`);
66
+ let domain = flags.domain;
67
+ if (domain === undefined) {
68
+ if (!ask)
69
+ throw new MeshCliError(DOMAIN_REQUIRED_MESSAGE, { remediation: { command: `mesh init platform ${tenant} --domain <your-domain>` } });
70
+ domain = await ask.input({
71
+ message: `Public base domain (the core stack creates the Route 53 zone ${env}.<domain>; its NS records must be delegated from the parent before the deploy can finish)`,
72
+ validate: validateDomain,
73
+ });
74
+ }
75
+ const domainCheck = validateDomain(domain);
76
+ if (domainCheck !== true)
77
+ throw new MeshCliError(`Invalid --domain '${domain}' — ${domainCheck}`);
78
+ domain = domain.trim().toLowerCase().replace(/\.$/, "");
79
+ const profile = flags.profile ??
80
+ (ask
81
+ ? await ask.input({ message: "AWS profile the stack config references", default: PLATFORM_SETUP_DEFAULTS.profile })
82
+ : PLATFORM_SETUP_DEFAULTS.profile);
83
+ const bucketDefault = defaultStateBucket(tenant);
84
+ const stateBucket = flags.stateBucket ??
85
+ (ask
86
+ ? await ask.input({ message: "Pulumi state S3 bucket (holds this platform's state — created below if it does not exist)", default: bucketDefault, validate: validateStateBucket })
87
+ : bucketDefault);
88
+ const bucketCheck = validateStateBucket(stateBucket);
89
+ if (bucketCheck !== true)
90
+ throw new MeshCliError(`Invalid --state-bucket '${stateBucket}' — ${bucketCheck}`);
91
+ let createStateBucket = flags.createStateBucket ?? false;
92
+ if (flags.createStateBucket === undefined && ask) {
93
+ createStateBucket = await ask.confirm({
94
+ message: `Create s3://${stateBucket} in ${region} now (versioned), if it does not exist?`,
95
+ default: true,
96
+ });
97
+ }
98
+ return {
99
+ tenant,
100
+ env,
101
+ region,
102
+ domain,
103
+ profile,
104
+ stateBucket: stateBucket.trim(),
105
+ dir,
106
+ createStateBucket,
107
+ };
108
+ }
109
+ export function stateBucketCreateCommand(setup) {
110
+ const location = setup.region === "us-east-1" ? "" : ` --create-bucket-configuration LocationConstraint=${setup.region}`;
111
+ return `aws s3api create-bucket --bucket ${setup.stateBucket} --region ${setup.region}${location} --profile ${setup.profile}`;
112
+ }
113
+ function awsJson(args, profile) {
114
+ return execFileSync("aws", [...args, "--profile", profile], {
115
+ encoding: "utf8",
116
+ stdio: ["ignore", "pipe", "pipe"],
117
+ });
118
+ }
119
+ export const realStateBucketDeps = {
120
+ headBucket(setup) {
121
+ try {
122
+ awsJson(["s3api", "head-bucket", "--bucket", setup.stateBucket, "--region", setup.region], setup.profile);
123
+ return true;
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ },
129
+ createBucket(setup) {
130
+ const args = ["s3api", "create-bucket", "--bucket", setup.stateBucket, "--region", setup.region];
131
+ if (setup.region !== "us-east-1")
132
+ args.push("--create-bucket-configuration", `LocationConstraint=${setup.region}`);
133
+ awsJson(args, setup.profile);
134
+ },
135
+ enableVersioning(setup) {
136
+ awsJson(["s3api", "put-bucket-versioning", "--bucket", setup.stateBucket, "--region", setup.region, "--versioning-configuration", "Status=Enabled"], setup.profile);
137
+ },
138
+ };
139
+ export async function ensureStateBucket(setup, deps = realStateBucketDeps) {
140
+ if (!setup.createStateBucket) {
141
+ return { created: false, present: false, detail: "not requested" };
142
+ }
143
+ try {
144
+ if (deps.headBucket(setup)) {
145
+ return { created: false, present: true, detail: `s3://${setup.stateBucket} already exists` };
146
+ }
147
+ }
148
+ catch {
149
+ }
150
+ try {
151
+ deps.createBucket(setup);
152
+ }
153
+ catch (err) {
154
+ const why = errorDetail(err);
155
+ return { created: false, present: false, detail: `could not create s3://${setup.stateBucket} — ${why}` };
156
+ }
157
+ try {
158
+ deps.enableVersioning(setup);
159
+ return { created: true, present: true, detail: `created s3://${setup.stateBucket} in ${setup.region} (versioning on)` };
160
+ }
161
+ catch (err) {
162
+ return { created: true, present: true, detail: `created s3://${setup.stateBucket} in ${setup.region}, but versioning could not be enabled — ${errorDetail(err)}` };
163
+ }
164
+ }
165
+ function errorDetail(err) {
166
+ const stderr = err && typeof err === "object" && "stderr" in err ? String(err.stderr ?? "") : "";
167
+ const text = stderr.trim() || (err instanceof Error ? err.message : String(err));
168
+ return text.split("\n").filter(Boolean).slice(-1)[0] ?? "unknown error";
169
+ }
170
+ export function exportCredentialsCommand(profile) {
171
+ return (`eval "$(aws configure export-credentials --profile ${profile} --format env)"` +
172
+ " # mesh deploy reads AWS_ACCESS_KEY_ID; a platform stack carries no mesh:deployerRole");
173
+ }
174
+ export function renderPlatformNextSteps(setup, opts = {}) {
175
+ const dir = opts.relativeDir ?? setup.dir;
176
+ const steps = [];
177
+ if (!opts.bucket?.present) {
178
+ steps.push(`${stateBucketCreateCommand(setup)} # the Pulumi state bucket, once`);
179
+ }
180
+ steps.push(`cd ${dir}`, "pnpm install", `aws sso login --profile ${setup.profile} # if this profile is SSO`, exportCredentialsCommand(setup.profile), `cd core && mesh deploy up # the VPC, EKS, RDS and the public zone ${setup.env}.${setup.domain}`, `# → then delegate the NS records of ${setup.env}.${setup.domain} from ${setup.domain}; the deploy waits on the ACM certificate until you do`, "cd ../platform && mesh deploy up # Temporal, Zitadel, the Hub (needs Docker — it builds service images)", `mesh login ${setup.tenant}.${setup.env} # your platform sign-in, once it is up`, "", "Then, in your apps repo, for the hello-world app:", ` mesh init --tenant ${setup.tenant} --platform ${setup.env}`, " mesh create-app --name hello --primitives service", " mesh deploy up # from the app directory");
181
+ return steps;
182
+ }
183
+ export async function scaffoldPlatformRepo(setup) {
184
+ if (fs.existsSync(setup.dir) && fs.readdirSync(setup.dir).length > 0) {
185
+ throw new MeshCliError(`Target directory is not empty: ${setup.dir}`, {
186
+ remediation: { command: `mesh init platform ${setup.tenant} --dir <empty-dir>` },
187
+ });
188
+ }
189
+ const { copyTemplate } = await import("../create-app.js");
190
+ const { findPackageRoot } = await import("../local/stack.js");
191
+ const { syncSkills } = await import("../skills.js");
192
+ const templateDir = path.join(findPackageRoot(), "templates", "platform-repo");
193
+ fs.mkdirSync(setup.dir, { recursive: true });
194
+ copyTemplate(templateDir, setup.dir, {
195
+ tenant: setup.tenant,
196
+ env: setup.env,
197
+ region: setup.region,
198
+ domain: setup.domain,
199
+ profile: setup.profile,
200
+ stateBucket: setup.stateBucket,
201
+ });
202
+ syncSkills(setup.dir);
203
+ }
@@ -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")
@@ -50,6 +50,10 @@ export declare function registerLoginCommand(program: Command): void;
50
50
  export declare const RESERVED_REGISTRY_CONTEXT = "registry";
51
51
  export declare const NO_REGISTRY_DEPRECATED = "--no-registry is deprecated and ignored: mesh login no longer touches the registry.";
52
52
  export declare const REGISTRY_SEPARATE_HINT = "Package registry access is separate from platform sign-in \u2014 run: mesh registry login";
53
+ export declare function shorthandContext(context: string): {
54
+ tenant: string;
55
+ env: string;
56
+ } | null;
53
57
  export declare function renderNoConfigHelp(context: string): string;
54
58
  export declare function tokenStillValid(expiresAt: string, marginMs?: number, now?: number): boolean;
55
59
  declare function getValidToken(context: string, opts?: {
@@ -622,7 +622,7 @@ async function showStatus(context, config) {
622
622
  export function registerLoginCommand(program) {
623
623
  program
624
624
  .command("login")
625
- .description("Authenticate with Zitadel (OIDC PKCE or Device Code)")
625
+ .description("PLATFORM access: sign in to a deployed Mesh platform (`<tenant>.<env>`) — what `mesh deploy`, `mesh stack`, the Hub and the VPN use. It never touches your ~/.npmrc, and you do not need it to install packages: that is `mesh registry login`. OIDC PKCE, or device code with --device.")
626
626
  .argument("<context>", 'Platform context (e.g., "mesh.dev")')
627
627
  .option("--status", "Show current authentication status — the Zitadel session if one is cached, otherwise the AWS identity the credential chain resolves (SSO profile or key pair). Exits non-zero only when neither works.")
628
628
  .option("--device", "Force device code flow (no callback server needed)")
@@ -668,7 +668,7 @@ export function registerLoginCommand(program) {
668
668
  });
669
669
  program
670
670
  .command("logout")
671
- .description("Clear cached Zitadel credentials")
671
+ .description("Clear a platform context's cached credentials (the package-registry session under `mesh registry` is untouched)")
672
672
  .argument("<context>", 'Platform context (e.g., "mesh.dev")')
673
673
  .action((context) => {
674
674
  refuseReservedRegistryContext(context, "logout");
@@ -698,8 +698,20 @@ async function hintRegistryIfMissing() {
698
698
  catch {
699
699
  }
700
700
  }
701
+ export function shorthandContext(context) {
702
+ const parts = context.split(".");
703
+ if (parts.length !== 2)
704
+ return null;
705
+ const [tenant, env] = parts;
706
+ return tenant && env ? { tenant, env } : null;
707
+ }
701
708
  export function renderNoConfigHelp(context) {
702
709
  const pinned = firstPartyDomainFor(context);
710
+ const shorthand = shorthandContext(context);
711
+ const createOne = shorthand
712
+ ? `mesh init --tenant ${shorthand.tenant} --new-platform ${shorthand.env}`
713
+ : "mesh init --tenant <tenant> --new-platform <env>";
714
+ const scaffoldOne = `mesh init platform ${shorthand?.tenant ?? "<tenant>"} --domain <a domain you control>`;
703
715
  return (`No configuration found for "${context}".\n\n` +
704
716
  `Tried:\n` +
705
717
  ` 1. SSM: /mesh-platform/${context.split(".")[0]}/${context.split(".")[1]}/platform/zitadel\n` +
@@ -709,6 +721,8 @@ export function renderNoConfigHelp(context) {
709
721
  : ` 2. HTTPS: https://cli.${context}/.well-known/mesh.json\n` +
710
722
  ` (only attempted for a full domain, or a known first-party context)\n\n`) +
711
723
  `To fix:\n` +
724
+ ` - No platform deployed yet? Create one: ${createOne}\n` +
725
+ ` (or the scaffold on its own: ${scaffoldOne})\n` +
712
726
  ` - Use the platform's full domain: mesh login <env>.<tenant>.meshtech.io\n` +
713
727
  ` - Or, if you have AWS access, ensure SSO is active: aws sso login --profile <profile>\n` +
714
728
  ` - Or add config manually to ${CONFIG_FILE}:\n\n` +