@mesh-tech/mesh-cli 0.20.4 → 0.22.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": "e2a82625e",
3
- "builtAt": "2026-09-11T22:29:18.194Z"
2
+ "commit": "33b979d17",
3
+ "builtAt": "2026-09-13T19:59:04.523Z"
4
4
  }
@@ -1,6 +1,6 @@
1
1
  import { Command } from "commander";
2
2
  import { jsonReport, type Check, type CheckResult, type CheckStatus } from "./dev-doctor.js";
3
- export type AppCheckCode = "UI_IN_API" | "NO_UI_SERVICE" | "NO_REGISTER" | "CUSTOM_SESSION_AUTH" | "NON_PLATFORM_LOGGER" | "LOCAL_AUTH_LIB" | "NO_AUTHZ_POINTER" | "METADATA_UNPUBLISHED" | "IAC_GRANTS" | "POINTER_PROJECT_OVERRIDE" | "PASSWORD_STORE" | "EMAIL_ALLOWLIST" | "USERS_TABLE";
3
+ export type AppCheckCode = "UI_IN_API" | "NO_UI_SERVICE" | "NO_REGISTER" | "CUSTOM_SESSION_AUTH" | "NON_PLATFORM_LOGGER" | "LOCAL_AUTH_LIB" | "NO_AUTHZ_POINTER" | "METADATA_UNPUBLISHED" | "IAC_GRANTS" | "POINTER_PROJECT_OVERRIDE" | "PASSWORD_STORE" | "EMAIL_ALLOWLIST" | "USERS_TABLE" | "DOCS_SITE_UNAUTHED" | "DOCS_AUDIENCE_IMPLICIT";
4
4
  export interface AppCheckRule {
5
5
  gate: string;
6
6
  title: string;
@@ -17,6 +17,21 @@ export interface Finding {
17
17
  }
18
18
  export declare function srcFiles(dir: string): string[];
19
19
  export declare function isUiDir(name: string): boolean;
20
+ export declare function balancedSpan(text: string, open: number): string;
21
+ export declare function splitTopLevel(inner: string): string[];
22
+ export declare function stripTrailingComments(value: string): string;
23
+ export declare function topLevelProperties(objectText: string): Map<string, string>;
24
+ export interface SurfaceDeclaration {
25
+ ctor: string;
26
+ body: string;
27
+ line: number;
28
+ }
29
+ export declare function surfaceDeclarations(text: string, pattern?: RegExp): SurfaceDeclaration[];
30
+ export declare function docsSiteFindings(text: string): Array<{
31
+ code: AppCheckCode;
32
+ line: number;
33
+ why: string;
34
+ }>;
20
35
  export declare function listApps(root: string): string[];
21
36
  export declare function scanApp(root: string, appRel: string): Finding[];
22
37
  export declare function scanRepo(root: string): Finding[];
@@ -96,6 +96,20 @@ export const APP_CHECK_RULES = {
96
96
  remediation: "drop the table; people and roles live in Zitadel and are administered from the Hub",
97
97
  scope: "app",
98
98
  },
99
+ DOCS_SITE_UNAUTHED: {
100
+ gate: "0.3",
101
+ title: "Docs site behind the platform sign-in",
102
+ severity: "block",
103
+ remediation: 'on an API surface: docs: { auth: { clientId, clientSecret } } (or a client on surfaces.http.auth), or opt out with docs: { site: false }; on a mesh.apps.ApiDocs: auth: { provider: "mesh", clientId, clientSecret }, or drop the ingress (an in-cluster site is reachable through mesh dev)',
104
+ scope: "app",
105
+ },
106
+ DOCS_AUDIENCE_IMPLICIT: {
107
+ gate: "H",
108
+ title: "Docs audience stated, not derived",
109
+ severity: "block",
110
+ remediation: 'declare the surface as mesh.apps.AppApiSurface or VendorApiSurface (or pass kind: "app" | "vendor"), or state docs: { audience: { roles: […] } }',
111
+ scope: "app",
112
+ },
99
113
  };
100
114
  export const APP_CHECK_CODES = Object.keys(APP_CHECK_RULES);
101
115
  const SRC_EXT = new Set([".ts", ".tsx", ".js", ".mjs", ".jsx"]);
@@ -227,6 +241,250 @@ const RE_POINTER_OVERRIDE = /\bzitadel: *\{\s*projectId\b/;
227
241
  const RE_PASSWORD = /(^|[^a-z])(bcrypt|argon2)|password_hash|passwordHash|hashPassword|temporaryPassword.*(send|mail)|(send|mail).*temporaryPassword/;
228
242
  const RE_ALLOWLIST = /ADMIN_EMAILS|ALLOWED_EMAILS|allowedEmails|adminEmails/;
229
243
  const RE_USERS_TABLE = /create table (if not exists )?"?(users|roles|user_roles|permissions)"?/i;
244
+ const RE_SURFACE_NEW = /new\s+mesh\.apps\.(ApiSurface|AppApiSurface|VendorApiSurface|Integration)\s*\(/g;
245
+ const RE_APIDOCS_NEW = /new\s+mesh\.apps\.ApiDocs\s*\(/g;
246
+ const DOCS_SITE_FIELDS = ["pages", "playground", "audience", "basePath", "ingress", "auth"];
247
+ const RE_DEV_BYPASS = /^["']dev-bypass["']$/;
248
+ export function balancedSpan(text, open) {
249
+ const closers = { "(": ")", "{": "}", "[": "]" };
250
+ const stack = [];
251
+ let quote;
252
+ for (let i = open; i < text.length; i++) {
253
+ const ch = text[i];
254
+ if (quote) {
255
+ if (ch === "\\")
256
+ i++;
257
+ else if (ch === quote)
258
+ quote = undefined;
259
+ continue;
260
+ }
261
+ if (ch === '"' || ch === "'" || ch === "`") {
262
+ quote = ch;
263
+ continue;
264
+ }
265
+ if (ch === "/" && text[i + 1] === "/") {
266
+ const eol = text.indexOf("\n", i);
267
+ if (eol === -1)
268
+ return "";
269
+ i = eol;
270
+ continue;
271
+ }
272
+ if (ch === "/" && text[i + 1] === "*") {
273
+ const end = text.indexOf("*/", i + 2);
274
+ if (end === -1)
275
+ return "";
276
+ i = end + 1;
277
+ continue;
278
+ }
279
+ if (closers[ch])
280
+ stack.push(closers[ch]);
281
+ else if (ch === ")" || ch === "}" || ch === "]") {
282
+ if (stack.pop() !== ch)
283
+ return "";
284
+ if (stack.length === 0)
285
+ return text.slice(open, i + 1);
286
+ }
287
+ }
288
+ return "";
289
+ }
290
+ export function splitTopLevel(inner) {
291
+ const parts = [];
292
+ let depth = 0;
293
+ let quote;
294
+ let start = 0;
295
+ for (let i = 0; i < inner.length; i++) {
296
+ const ch = inner[i];
297
+ if (quote) {
298
+ if (ch === "\\")
299
+ i++;
300
+ else if (ch === quote)
301
+ quote = undefined;
302
+ continue;
303
+ }
304
+ if (ch === '"' || ch === "'" || ch === "`") {
305
+ quote = ch;
306
+ continue;
307
+ }
308
+ if (ch === "/" && inner[i + 1] === "/") {
309
+ const eol = inner.indexOf("\n", i);
310
+ if (eol === -1)
311
+ break;
312
+ i = eol;
313
+ continue;
314
+ }
315
+ if (ch === "/" && inner[i + 1] === "*") {
316
+ const end = inner.indexOf("*/", i + 2);
317
+ if (end === -1)
318
+ break;
319
+ i = end + 1;
320
+ continue;
321
+ }
322
+ if (ch === "(" || ch === "{" || ch === "[")
323
+ depth++;
324
+ else if (ch === ")" || ch === "}" || ch === "]")
325
+ depth--;
326
+ else if (ch === "," && depth === 0) {
327
+ parts.push(inner.slice(start, i));
328
+ start = i + 1;
329
+ }
330
+ }
331
+ const last = inner.slice(start);
332
+ if (last.trim())
333
+ parts.push(last);
334
+ return parts;
335
+ }
336
+ const RE_PROPERTY = /^(?:"([^"]+)"|'([^']+)'|([A-Za-z_$][\w$]*))\s*(?::\s*([\s\S]*))?$/;
337
+ function stripLeadingComments(entry) {
338
+ let text = entry;
339
+ for (;;) {
340
+ const next = text.replace(/^\s*(?:\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)/, "");
341
+ if (next === text)
342
+ return text;
343
+ text = next;
344
+ }
345
+ }
346
+ export function stripTrailingComments(value) {
347
+ let out = "";
348
+ let depth = 0;
349
+ let quote;
350
+ for (let i = 0; i < value.length; i++) {
351
+ const ch = value[i];
352
+ if (quote) {
353
+ out += ch;
354
+ if (ch === "\\") {
355
+ out += value[i + 1] ?? "";
356
+ i++;
357
+ }
358
+ else if (ch === quote)
359
+ quote = undefined;
360
+ continue;
361
+ }
362
+ if (ch === '"' || ch === "'" || ch === "`") {
363
+ quote = ch;
364
+ out += ch;
365
+ continue;
366
+ }
367
+ if (depth === 0 && ch === "/" && value[i + 1] === "/") {
368
+ const eol = value.indexOf("\n", i);
369
+ if (eol === -1)
370
+ break;
371
+ i = eol;
372
+ out += "\n";
373
+ continue;
374
+ }
375
+ if (depth === 0 && ch === "/" && value[i + 1] === "*") {
376
+ const end = value.indexOf("*/", i + 2);
377
+ if (end === -1)
378
+ break;
379
+ i = end + 1;
380
+ continue;
381
+ }
382
+ if (ch === "(" || ch === "{" || ch === "[")
383
+ depth++;
384
+ else if (ch === ")" || ch === "}" || ch === "]")
385
+ depth--;
386
+ out += ch;
387
+ }
388
+ return out.trim();
389
+ }
390
+ export function topLevelProperties(objectText) {
391
+ const props = new Map();
392
+ if (!objectText.startsWith("{") || !objectText.endsWith("}"))
393
+ return props;
394
+ for (const entry of splitTopLevel(objectText.slice(1, -1))) {
395
+ const text = stripLeadingComments(entry).trim();
396
+ if (!text || text.startsWith("..."))
397
+ continue;
398
+ const m = RE_PROPERTY.exec(text);
399
+ if (!m)
400
+ continue;
401
+ const key = m[1] ?? m[2] ?? m[3];
402
+ props.set(key, stripTrailingComments(m[4] ?? key));
403
+ }
404
+ return props;
405
+ }
406
+ function objectLiteralOf(value, program) {
407
+ if (!value)
408
+ return undefined;
409
+ if (value.startsWith("{"))
410
+ return balancedSpan(value, 0) || undefined;
411
+ const id = /^[A-Za-z_$][\w$]*$/.exec(value)?.[0];
412
+ if (!id)
413
+ return undefined;
414
+ const decl = new RegExp(`\\bconst\\s+${id}\\s*(?::[^=]+)?=\\s*\\{`).exec(program);
415
+ return decl ? balancedSpan(program, decl.index + decl[0].length - 1) || undefined : undefined;
416
+ }
417
+ function optionsObject(body) {
418
+ for (const arg of splitTopLevel(body.slice(1, -1))) {
419
+ const trimmed = arg.trim();
420
+ if (trimmed.startsWith("{"))
421
+ return balancedSpan(trimmed, 0) || undefined;
422
+ }
423
+ return undefined;
424
+ }
425
+ export function surfaceDeclarations(text, pattern = RE_SURFACE_NEW) {
426
+ const out = [];
427
+ for (const m of text.matchAll(pattern)) {
428
+ const body = balancedSpan(text, m.index + m[0].length - 1);
429
+ if (!body)
430
+ continue;
431
+ out.push({ ctor: m[1] ?? "ApiDocs", body, line: text.slice(0, m.index).split("\n").length });
432
+ }
433
+ return out;
434
+ }
435
+ function isClientAuth(value) {
436
+ return value !== undefined && value !== "undefined" && !RE_DEV_BYPASS.test(value);
437
+ }
438
+ export function docsSiteFindings(text) {
439
+ const findings = [];
440
+ for (const decl of surfaceDeclarations(text)) {
441
+ const options = optionsObject(decl.body);
442
+ if (!options)
443
+ continue;
444
+ const args = topLevelProperties(options);
445
+ const docsText = objectLiteralOf(args.get("docs"), text);
446
+ if (!docsText)
447
+ continue;
448
+ const docs = topLevelProperties(docsText);
449
+ if (docs.get("site") === "false")
450
+ continue;
451
+ if (docs.get("site") !== "true" && !DOCS_SITE_FIELDS.some((f) => docs.has(f)))
452
+ continue;
453
+ const httpText = objectLiteralOf(topLevelProperties(objectLiteralOf(args.get("surfaces"), text) ?? "{}").get("http"), text);
454
+ const hasClient = isClientAuth(docs.get("auth")) || isClientAuth(topLevelProperties(httpText ?? "{}").get("auth"));
455
+ if (!hasClient) {
456
+ findings.push({
457
+ code: "DOCS_SITE_UNAUTHED",
458
+ line: decl.line,
459
+ why: `mesh.apps.${decl.ctor} declares a docs site with no OIDC client — the site is served behind the platform sign-in proxy, which needs one (docs.auth, or a client on surfaces.http.auth)`,
460
+ });
461
+ }
462
+ const kindStated = decl.ctor === "AppApiSurface" || decl.ctor === "VendorApiSurface" || args.has("kind");
463
+ if (!kindStated && !docs.has("audience")) {
464
+ findings.push({
465
+ code: "DOCS_AUDIENCE_IMPLICIT",
466
+ line: decl.line,
467
+ why: `mesh.apps.${decl.ctor} declares a docs site but neither its kind nor its audience — who may read it would fall out of the name heuristic, and a vendor surface must never default to partner-readable that way`,
468
+ });
469
+ }
470
+ }
471
+ for (const decl of surfaceDeclarations(text, RE_APIDOCS_NEW)) {
472
+ const options = optionsObject(decl.body);
473
+ if (!options)
474
+ continue;
475
+ const args = topLevelProperties(options);
476
+ if (!args.has("ingress") || args.get("ingress") === "undefined")
477
+ continue;
478
+ if (isClientAuth(args.get("auth")))
479
+ continue;
480
+ findings.push({
481
+ code: "DOCS_SITE_UNAUTHED",
482
+ line: decl.line,
483
+ why: `mesh.apps.ApiDocs is published on an ingress with no auth — the site (reference and playground) is reachable by anyone; put the platform sign-in proxy in front of it, or drop the ingress`,
484
+ });
485
+ }
486
+ return findings;
487
+ }
230
488
  export function listApps(root) {
231
489
  const apps = [];
232
490
  const appsDir = path.join(root, "apps");
@@ -281,6 +539,8 @@ export function scanApp(root, appRel) {
281
539
  if (!/env\.register\(|\.annotate\(/.test(readText(index))) {
282
540
  hit("NO_REGISTER", indexRel, "never calls env.register({…}) — the Hub cannot list this app");
283
541
  }
542
+ for (const f of docsSiteFindings(readText(index)))
543
+ hit(f.code, `${indexRel}:${f.line}`, f.why);
284
544
  for (const svc of serviceDirs(app)) {
285
545
  for (const f of srcFiles(svc)) {
286
546
  const text = readText(f);
@@ -39,4 +39,5 @@ export declare function generateComposableApp(appDir: string, context: {
39
39
  export declare function registerCreateAppCommand(program: Command): void;
40
40
  export declare const NO_REGISTRY_ACCESS_MESSAGE: string;
41
41
  export declare function ensureRegistryAccess(cwd: string, options: Pick<CreateAppOptions, "skipRegistryCheck">, preflight?: () => Promise<RegistryPreflight>): Promise<void>;
42
+ export declare function composableNextSteps(appDir: string, platform: string | null): string[];
42
43
  export {};
@@ -575,13 +575,28 @@ function printComposableNextSteps(appDir, context) {
575
575
  console.log(" api/src/storage.ts S3 helpers");
576
576
  }
577
577
  console.log("");
578
- console.log("Next steps:");
579
- console.log(` cd ${appDir}`);
580
- console.log(" pnpm install");
581
- console.log(" mesh skills sync # re-run once deps are installed: picks up package skills + Intent");
582
- console.log(" pnpm install # again only if skills sync reports it added @tanstack/intent");
583
- console.log(" mesh stack init # personal dev stack (deploy: false)");
584
- console.log(" mesh deploy up --yes # deploy via the stack's deployer role");
585
- console.log(" mesh dev # run locally");
586
- console.log("");
578
+ for (const line of composableNextSteps(appDir, findMeshJson(process.cwd())?.data.platform ?? null)) {
579
+ console.log(line);
580
+ }
581
+ }
582
+ export function composableNextSteps(appDir, platform) {
583
+ const common = [
584
+ "Next steps:",
585
+ ` cd ${appDir}`,
586
+ " pnpm install",
587
+ " mesh skills sync # re-run once deps are installed: picks up package skills + Intent",
588
+ " pnpm install # again only if skills sync reports it added @tanstack/intent",
589
+ ];
590
+ const local = [
591
+ " mesh start # the local Mesh platform (once; Docker — from anywhere)",
592
+ " mesh dev # run the app against it",
593
+ ];
594
+ const deploy = [
595
+ " mesh stack init # personal dev stack (deploy: false)",
596
+ " mesh deploy up --yes # deploy via the stack's deployer role",
597
+ ];
598
+ if (platform === null || platform === "local") {
599
+ return [...common, ...local, "", "When you deploy to a Mesh platform:", ...deploy];
600
+ }
601
+ return [...common, ...deploy, " mesh dev # run locally against the platform"];
587
602
  }
@@ -1,3 +1,4 @@
1
+ import type { StackEndpoint } from "./stack.js";
1
2
  export interface SecretsSender {
2
3
  send(command: unknown): Promise<unknown>;
3
4
  }
@@ -6,3 +7,20 @@ export declare function probeTcp(port: number, opts?: {
6
7
  host?: string;
7
8
  timeoutMs?: number;
8
9
  }): Promise<boolean>;
10
+ export declare function hostPortsOf(endpoints: ReadonlyArray<Pick<StackEndpoint, "url" | "probe">>): number[];
11
+ export interface PortHolder {
12
+ kind: "container" | "process";
13
+ name: string;
14
+ }
15
+ export interface PortConflict {
16
+ port: number;
17
+ holder: PortHolder | null;
18
+ }
19
+ export declare function parseDockerPsPorts(output: string, ignorePrefix: string): Map<number, string>;
20
+ export interface PortConflictIo {
21
+ dockerPs: () => string;
22
+ processOn: (port: number) => string | null;
23
+ listening: (port: number) => Promise<boolean>;
24
+ }
25
+ export declare function findPortConflicts(ports: readonly number[], ignorePrefix: string, io?: PortConflictIo): Promise<PortConflict[]>;
26
+ export declare function describePortConflicts(conflicts: readonly PortConflict[], moveHints?: ReadonlyMap<number, string>): string;
@@ -1,4 +1,5 @@
1
1
  import * as net from "net";
2
+ import { execFileSync } from "child_process";
2
3
  import { LOCAL_AWS_CONFIG } from "./seed.js";
3
4
  export async function upsertLocalSecret(secretId, value, client) {
4
5
  const { SecretsManagerClient, CreateSecretCommand, PutSecretValueCommand } = await import("@aws-sdk/client-secrets-manager");
@@ -31,3 +32,98 @@ export function probeTcp(port, opts = {}) {
31
32
  });
32
33
  });
33
34
  }
35
+ export function hostPortsOf(endpoints) {
36
+ const ports = new Set();
37
+ const fromUrl = (raw) => {
38
+ try {
39
+ const u = new URL(raw.includes("://") ? raw : `tcp://${raw}`);
40
+ const port = Number(u.port || (u.protocol === "https:" ? 443 : u.protocol === "http:" ? 80 : NaN));
41
+ if (Number.isInteger(port) && port > 0)
42
+ ports.add(port);
43
+ }
44
+ catch {
45
+ }
46
+ };
47
+ for (const e of endpoints) {
48
+ if (e.probe.kind === "tcp")
49
+ ports.add(e.probe.port);
50
+ else if (e.probe.kind === "http")
51
+ fromUrl(e.probe.url);
52
+ fromUrl(e.url);
53
+ }
54
+ return [...ports].sort((a, b) => a - b);
55
+ }
56
+ export function parseDockerPsPorts(output, ignorePrefix) {
57
+ const held = new Map();
58
+ for (const line of output.split("\n")) {
59
+ const tab = line.indexOf("\t");
60
+ if (tab < 0)
61
+ continue;
62
+ const name = line.slice(0, tab).trim();
63
+ if (!name || name.startsWith(ignorePrefix))
64
+ continue;
65
+ for (const m of line.slice(tab + 1).matchAll(/:(\d+)->\d+\/(?:tcp|udp)/g)) {
66
+ const port = Number(m[1]);
67
+ if (!held.has(port))
68
+ held.set(port, name);
69
+ }
70
+ }
71
+ return held;
72
+ }
73
+ const defaultPortConflictIo = {
74
+ dockerPs: () => {
75
+ try {
76
+ return execFileSync("docker", ["ps", "--format", "{{.Names}}\t{{.Ports}}"], {
77
+ encoding: "utf-8",
78
+ stdio: ["ignore", "pipe", "ignore"],
79
+ });
80
+ }
81
+ catch {
82
+ return "";
83
+ }
84
+ },
85
+ processOn: (port) => {
86
+ try {
87
+ const out = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fc"], {
88
+ encoding: "utf-8",
89
+ stdio: ["ignore", "pipe", "ignore"],
90
+ });
91
+ const cmd = out.split("\n").find((l) => l.startsWith("c"));
92
+ return cmd ? cmd.slice(1) : null;
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ },
98
+ listening: (port) => probeTcp(port, { timeoutMs: 300 }),
99
+ };
100
+ export async function findPortConflicts(ports, ignorePrefix, io = defaultPortConflictIo) {
101
+ const unique = [...new Set(ports)].sort((a, b) => a - b);
102
+ const bound = (await Promise.all(unique.map(async (port) => ((await io.listening(port)) ? port : null)))).filter((p) => p !== null);
103
+ if (bound.length === 0)
104
+ return [];
105
+ const containers = parseDockerPsPorts(io.dockerPs(), ignorePrefix);
106
+ return bound.map((port) => {
107
+ const container = containers.get(port);
108
+ if (container)
109
+ return { port, holder: { kind: "container", name: container } };
110
+ const process = io.processOn(port);
111
+ return { port, holder: process ? { kind: "process", name: process } : null };
112
+ });
113
+ }
114
+ export function describePortConflicts(conflicts, moveHints = new Map()) {
115
+ const lines = conflicts.flatMap(({ port, holder }) => {
116
+ const remedy = holder?.kind === "container"
117
+ ? `docker stop ${holder.name}`
118
+ : `lsof -nP -iTCP:${port} -sTCP:LISTEN, then stop what it names`;
119
+ const what = holder ? `held by ${holder.kind} ${holder.name}` : "held by something lsof would not name";
120
+ const move = moveHints.get(port);
121
+ return move
122
+ ? [` ${String(port).padEnd(5)} ${what.padEnd(38)} (${remedy})`, ` ${"".padEnd(5)} or: ${move}`]
123
+ : [` ${String(port).padEnd(5)} ${what.padEnd(38)} (${remedy})`];
124
+ });
125
+ return (`Cannot start the local platform — ${conflicts.length === 1 ? "a host port it needs is" : "host ports it needs are"} already in use:\n` +
126
+ lines.join("\n") +
127
+ '\nStop what holds the port (or move it), then run mesh start again. A port lost this way does not fail as "port in use": the container never joins the network and a later service reports an unrelated error.' +
128
+ "\nIf the port is held by something you cannot stop and the stack can live without it: mesh start --skip-port-check");
129
+ }
@@ -6,4 +6,5 @@ export interface WaitForStackIo {
6
6
  overlayServices: () => ReadonlySet<string>;
7
7
  }
8
8
  export declare function waitForStack(io?: WaitForStackIo): Promise<void>;
9
+ export declare function startHereLines(hubRunning: boolean, up?: ReadonlyMap<string, boolean>): string[];
9
10
  export declare function registerLocalCommands(program: Command): void;
@@ -3,13 +3,14 @@ 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_PROJECT, DEFAULT_HUB_PORT, ONE_SHOT_SERVICES, STACK_ENDPOINTS, compose, composeStreamed, crashedServices, ensureDockerAvailable, findPackageRoot, hubApiRunning, hubEndpoints, hubOverlayRunning, hubOverlayServices, hubPort, probeEndpoint, stackOwnedElsewhere, stackServices, } from "./stack.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";
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
11
  import { LOGIN_CONTEXT, readHubAuth, seedZitadel, TEST_USERS } from "./seed-zitadel.js";
12
12
  import { publishHubAuthzCatalog } from "./seed-hub-catalog.js";
13
+ import { describePortConflicts, findPortConflicts, hostPortsOf } from "./helpers.js";
13
14
  const WAIT_TIMEOUT_MS = 180_000;
14
15
  const WAIT_POLL_MS = 3_000;
15
16
  function crashError(crashed) {
@@ -85,8 +86,33 @@ function printEndpoints(hubRunning) {
85
86
  console.log(chalk.bold("Login"));
86
87
  console.log(` mesh login ${LOGIN_CONTEXT}`);
87
88
  console.log(` ${chalk.dim(`test users: ${TEST_USERS.map((u) => u.email).join(", ")} (password: ${TEST_USERS[0].password})`)}`);
88
- console.log("");
89
- logInfo("Next: `mesh dev` in an apps repo wires services to this stack automatically.");
89
+ printStartHere(hubRunning);
90
+ }
91
+ export function startHereLines(hubRunning, up) {
92
+ const dev = TEST_USERS[0];
93
+ const mailbox = STACK_ENDPOINTS.find((e) => e.service === "mailpit");
94
+ const flag = (service) => up?.get(service) === false ? chalk.red(" (not responding — see Endpoints below)") : "";
95
+ const lines = ["", chalk.bold.cyan("★ Start here")];
96
+ if (hubRunning) {
97
+ lines.push(` ${"Hub".padEnd(10)} ${chalk.bold.cyan(`http://localhost:${hubPort()}`)}${flag("hub-ui")}`, ` ${"".padEnd(10)} ${chalk.dim(`sign in as ${dev.email} / ${dev.password} — an account already exists; do not Register`)}`);
98
+ }
99
+ if (mailbox) {
100
+ lines.push(` ${"Mailbox".padEnd(10)} ${chalk.cyan(mailbox.url)}${flag("mailpit")}`, ` ${"".padEnd(10)} ${chalk.dim("every email the local platform sends (sign-up, verification, password reset) lands here — nothing leaves your machine")}`);
101
+ }
102
+ lines.push(` ${"Your app".padEnd(10)} ${chalk.cyan("cd apps/<name> && mesh dev")} ${chalk.dim("no app yet? mesh create-app")}`, ` ${"Check-up".padEnd(10)} ${chalk.cyan("mesh status")}`, "");
103
+ return lines;
104
+ }
105
+ function printStartHere(hubRunning, up) {
106
+ for (const line of startHereLines(hubRunning, up))
107
+ console.log(line);
108
+ }
109
+ function portMoveHints() {
110
+ return new Map([
111
+ [
112
+ Number(hubPort()),
113
+ "MESH_HUB_PORT=<free port> mesh start (or mesh start --no-hub, which starts everything else)",
114
+ ],
115
+ ]);
90
116
  }
91
117
  function repoRootForHubSource() {
92
118
  for (let dir = process.cwd();;) {
@@ -108,6 +134,7 @@ export function registerLocalCommands(program) {
108
134
  .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)")
109
135
  .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)")
110
136
  .option("--takeover", "re-own a stack that another checkout started (may recreate shared containers with THIS checkout's config)", false)
137
+ .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)
111
138
  .action(async (opts) => {
112
139
  ensureDockerAvailable();
113
140
  const foreign = stackOwnedElsewhere();
@@ -123,6 +150,15 @@ export function registerLocalCommands(program) {
123
150
  if (foreign && opts.takeover) {
124
151
  logWarn(`Taking over the running stack (previously driven from ${foreign}).`);
125
152
  }
153
+ if (!opts.skipPortCheck && !stackServices().some((s) => s.state === "running")) {
154
+ const ports = hostPortsOf([...STACK_ENDPOINTS, ...(opts.hub ? hubEndpoints() : [])]);
155
+ const conflicts = await findPortConflicts(ports, `${COMPOSE_PROJECT}-`);
156
+ if (conflicts.length > 0) {
157
+ throw new MeshCliError(describePortConflicts(conflicts, portMoveHints()), {
158
+ remediation: { command: "mesh start # once the port is free" },
159
+ });
160
+ }
161
+ }
126
162
  let hubVersion;
127
163
  if (opts.hub) {
128
164
  if (opts.hubFromSource) {
@@ -307,6 +343,7 @@ export function registerLocalCommands(program) {
307
343
  process.exitCode = 1;
308
344
  return;
309
345
  }
346
+ printStartHere(hasHub, new Map(probes.map((p) => [p.service, p.up])));
310
347
  console.log(chalk.bold("Containers"));
311
348
  for (const service of services) {
312
349
  const ok = service.state === "running" &&
@@ -35,6 +35,7 @@ export declare function atomicWriteFileSync(path: string, data: string, mode: nu
35
35
  declare function clearCredentials(context: string): void;
36
36
  declare function decodeJwtPayload(token: string): Record<string, unknown>;
37
37
  export declare function deviceLoginWithReissue(attempt: (codeNumber: number) => Promise<"success" | "expired">, maxCodes?: number): Promise<void>;
38
+ export declare function loginTimeoutMessage(context: string): string;
38
39
  export declare function isRemoteEnvironment(): boolean;
39
40
  export interface StatusLine {
40
41
  level: "success" | "info" | "warn";
@@ -362,7 +362,7 @@ function login(context, config) {
362
362
  });
363
363
  timeoutId = setTimeout(() => {
364
364
  teardown();
365
- reject(new Error("Login timed out (2 minutes)"));
365
+ reject(new Error(loginTimeoutMessage(context)));
366
366
  }, 120_000);
367
367
  });
368
368
  }
@@ -465,6 +465,13 @@ async function attemptDeviceCode(context, config) {
465
465
  }
466
466
  return "expired";
467
467
  }
468
+ export function loginTimeoutMessage(context) {
469
+ const base = "Login timed out (2 minutes)";
470
+ if (context !== "local")
471
+ return base;
472
+ return (`${base}. The local platform already has a signed-up user — sign in as dev@local.mesh / LocalDev1! instead of registering. ` +
473
+ "If you did register, its confirmation email is in the local mailbox at http://localhost:8025; confirm it there and run the command again.");
474
+ }
468
475
  export function isRemoteEnvironment() {
469
476
  if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES)
470
477
  return true;
@@ -532,7 +532,11 @@ var init_src = __esm({
532
532
  description: z.string().optional(),
533
533
  surfaces: apiSurfaceSchema,
534
534
  credentials: z.object({ keyedBy: z.string().nullish() }).optional(),
535
- docs: z.object({ url: z.string().min(1), contentHash: z.string().min(1) }),
535
+ docs: z.object({
536
+ url: z.string().min(1),
537
+ contentHash: z.string().min(1),
538
+ siteUrl: z.string().min(1).optional()
539
+ }),
536
540
  enabledOps: z.array(z.string()),
537
541
  // The consumer schema strips unknown keys, so a producer-side field that is
538
542
  // not mirrored here never reaches a consumer — mirror every addition.
@@ -1083,7 +1087,7 @@ ${url}`);
1083
1087
  });
1084
1088
  timeoutId = setTimeout(() => {
1085
1089
  teardown();
1086
- reject(new Error("Login timed out (2 minutes)"));
1090
+ reject(new Error(loginTimeoutMessage(context)));
1087
1091
  }, 12e4);
1088
1092
  });
1089
1093
  }
@@ -1187,6 +1191,11 @@ async function attemptDeviceCode(context, config) {
1187
1191
  }
1188
1192
  return "expired";
1189
1193
  }
1194
+ function loginTimeoutMessage(context) {
1195
+ const base = "Login timed out (2 minutes)";
1196
+ if (context !== "local") return base;
1197
+ return `${base}. The local platform already has a signed-up user \u2014 sign in as dev@local.mesh / LocalDev1! instead of registering. If you did register, its confirmation email is in the local mailbox at http://localhost:8025; confirm it there and run the command again.`;
1198
+ }
1190
1199
  function isRemoteEnvironment() {
1191
1200
  if (process.env.REMOTE_CONTAINERS || process.env.CODESPACES) return true;
1192
1201
  if (fs2.existsSync("/.dockerenv")) return true;