@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.
- package/README.md +12 -0
- package/dist/bin/mesh.js +554 -174
- package/dist/bin/mesh.js.map +2 -2
- package/dist/build-info.json +2 -2
- package/dist/src/commands/app-check.d.ts +16 -1
- package/dist/src/commands/app-check.js +260 -0
- package/dist/src/commands/create-app.d.ts +1 -0
- package/dist/src/commands/create-app.js +24 -9
- package/dist/src/commands/local/helpers.d.ts +18 -0
- package/dist/src/commands/local/helpers.js +96 -0
- package/dist/src/commands/local/index.d.ts +1 -0
- package/dist/src/commands/local/index.js +40 -3
- package/dist/src/commands/login.d.ts +1 -0
- package/dist/src/commands/login.js +8 -1
- package/dist/src/commands/temporal.js +11 -2
- package/dist/src/commands/temporal.js.map +1 -1
- package/fragments/agents/AGENTS.md.hbs +13 -0
- package/fragments/base/README.md.hbs +9 -0
- package/fragments/base/index.ts.hbs +27 -0
- package/fragments/service/api/openapi.json.hbs +42 -0
- package/fragments/service/docs/pages/getting-started.md.hbs +15 -0
- package/package.json +2 -2
- package/skills/core/SKILL.md +21 -4
package/dist/build-info.json
CHANGED
|
@@ -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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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 {
|
|
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
|
-
|
|
89
|
-
|
|
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(
|
|
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({
|
|
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(
|
|
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;
|