@mesh-tech/mesh-cli 0.28.0 → 0.29.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": "dd1a85628",
3
- "builtAt": "2026-09-17T22:16:24.040Z"
2
+ "commit": "0c55ee89e",
3
+ "builtAt": "2026-09-18T16:46:53.074Z"
4
4
  }
@@ -0,0 +1,28 @@
1
+ export interface ProbeResult {
2
+ findings: UnresolvedImport[];
3
+ unchecked: string | null;
4
+ }
5
+ export interface UnresolvedImport {
6
+ pkg: string;
7
+ specifier: string;
8
+ file: string;
9
+ importer: string;
10
+ via: string[];
11
+ fix: string;
12
+ }
13
+ export declare function stripComments(source: string): string;
14
+ export declare function moduleSpecifiers(source: string): string[];
15
+ export declare function splitSpecifier(specifier: string): {
16
+ pkg: string;
17
+ sub: string;
18
+ } | null;
19
+ export declare function findPackageDir(fromDir: string, pkg: string): string | null;
20
+ export declare function resolveSubpath(pkgDir: string, sub: string): string | null;
21
+ export declare function programEntry(appDir: string): string | null;
22
+ export declare function dependenciesInstalled(dir: string, stopAt?: string): boolean;
23
+ export interface ProbeOptions {
24
+ entry?: string;
25
+ label?: string;
26
+ root?: string;
27
+ }
28
+ export declare function resolveProgramImports(appDir: string, options?: ProbeOptions): ProbeResult;
@@ -0,0 +1,276 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { builtinModules } from "node:module";
4
+ import { parse as parseYaml } from "yaml";
5
+ const CONDITIONS = new Set(["source", "node", "import", "require", "module", "module-sync", "default"]);
6
+ const EXTENSIONS = ["", ".ts", ".tsx", ".js", ".mjs", ".cjs", "/index.ts", "/index.js", "/index.mjs"];
7
+ const SCOPE = "@mesh-tech/";
8
+ const FILE_BUDGET = 1500;
9
+ const BUILTINS = new Set(builtinModules);
10
+ export function stripComments(source) {
11
+ return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'`\\])\/\/[^\n]*/g, "$1");
12
+ }
13
+ export function moduleSpecifiers(source) {
14
+ const text = stripComments(source);
15
+ const out = [];
16
+ for (const m of text.matchAll(/(?<![.\w$])(?:import|export)\b([^;]*?)\bfrom\s*["']([^"']+)["']/g)) {
17
+ if (/^\s*type\s/.test(m[1] ?? ""))
18
+ continue;
19
+ out.push(m[2] ?? "");
20
+ }
21
+ for (const m of text.matchAll(/(?<![.\w$])import\s*["']([^"']+)["']/g))
22
+ out.push(m[1] ?? "");
23
+ for (const m of text.matchAll(/(?<![.\w$])import\s*\(\s*["']([^"']+)["']\s*\)/g))
24
+ out.push(m[1] ?? "");
25
+ for (const m of text.matchAll(/(?<![.\w$])require\s*\(\s*["']([^"']+)["']\s*\)/g))
26
+ out.push(m[1] ?? "");
27
+ return [...new Set(out)].filter(Boolean);
28
+ }
29
+ export function splitSpecifier(specifier) {
30
+ if (!specifier || specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("#"))
31
+ return null;
32
+ if (specifier.includes(":"))
33
+ return null;
34
+ if (BUILTINS.has(specifier))
35
+ return null;
36
+ const parts = specifier.split("/");
37
+ const depth = specifier.startsWith("@") ? 2 : 1;
38
+ if (parts.length < depth || parts.some((p) => p === ""))
39
+ return null;
40
+ const pkg = parts.slice(0, depth).join("/");
41
+ const rest = parts.slice(depth);
42
+ return { pkg, sub: rest.length > 0 ? `./${rest.join("/")}` : "." };
43
+ }
44
+ export function findPackageDir(fromDir, pkg) {
45
+ let dir = path.resolve(fromDir);
46
+ for (;;) {
47
+ const candidate = path.join(dir, "node_modules", ...pkg.split("/"));
48
+ if (fs.existsSync(path.join(candidate, "package.json"))) {
49
+ try {
50
+ return fs.realpathSync(candidate);
51
+ }
52
+ catch {
53
+ return candidate;
54
+ }
55
+ }
56
+ const parent = path.dirname(dir);
57
+ if (parent === dir)
58
+ return null;
59
+ dir = parent;
60
+ }
61
+ }
62
+ function conditionTargets(node, acc = []) {
63
+ if (typeof node === "string") {
64
+ acc.push(node);
65
+ return acc;
66
+ }
67
+ if (Array.isArray(node)) {
68
+ for (const entry of node)
69
+ conditionTargets(entry, acc);
70
+ return acc;
71
+ }
72
+ if (!node || typeof node !== "object")
73
+ return acc;
74
+ for (const [key, value] of Object.entries(node)) {
75
+ if (!CONDITIONS.has(key))
76
+ continue;
77
+ conditionTargets(value, acc);
78
+ }
79
+ return acc;
80
+ }
81
+ function exportTargets(exportsField, sub) {
82
+ if (typeof exportsField === "string")
83
+ return sub === "." ? [exportsField] : [];
84
+ if (Array.isArray(exportsField))
85
+ return exportsField.flatMap((entry) => exportTargets(entry, sub));
86
+ if (!exportsField || typeof exportsField !== "object")
87
+ return [];
88
+ const map = exportsField;
89
+ const keys = Object.keys(map);
90
+ if (keys.length > 0 && !keys.some((k) => k.startsWith(".")))
91
+ return sub === "." ? conditionTargets(map) : [];
92
+ if (sub in map)
93
+ return conditionTargets(map[sub]);
94
+ let best = [];
95
+ let bestPrefix = -1;
96
+ for (const key of keys) {
97
+ const star = key.indexOf("*");
98
+ if (star === -1)
99
+ continue;
100
+ const prefix = key.slice(0, star);
101
+ const suffix = key.slice(star + 1);
102
+ if (sub.length < prefix.length + suffix.length)
103
+ continue;
104
+ if (!sub.startsWith(prefix) || !sub.endsWith(suffix))
105
+ continue;
106
+ if (prefix.length <= bestPrefix)
107
+ continue;
108
+ const segment = sub.slice(prefix.length, sub.length - suffix.length);
109
+ bestPrefix = prefix.length;
110
+ best = conditionTargets(map[key]).map((target) => target.replaceAll("*", segment));
111
+ }
112
+ return best;
113
+ }
114
+ function isDirectory(p) {
115
+ try {
116
+ return fs.statSync(p).isDirectory();
117
+ }
118
+ catch {
119
+ return false;
120
+ }
121
+ }
122
+ function firstFile(base) {
123
+ for (const ext of EXTENSIONS) {
124
+ const candidate = base + ext;
125
+ try {
126
+ if (fs.statSync(candidate).isFile())
127
+ return candidate;
128
+ }
129
+ catch {
130
+ }
131
+ }
132
+ return null;
133
+ }
134
+ export function resolveSubpath(pkgDir, sub) {
135
+ let manifest = {};
136
+ try {
137
+ manifest = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf8"));
138
+ }
139
+ catch {
140
+ return null;
141
+ }
142
+ if (manifest.exports !== undefined) {
143
+ for (const target of exportTargets(manifest.exports, sub)) {
144
+ const file = firstFile(path.resolve(pkgDir, target));
145
+ if (file)
146
+ return file;
147
+ }
148
+ return null;
149
+ }
150
+ if (sub === ".")
151
+ return firstFile(path.resolve(pkgDir, manifest.main ?? "index.js"));
152
+ return firstFile(path.resolve(pkgDir, sub));
153
+ }
154
+ export function programEntry(appDir) {
155
+ const app = path.resolve(appDir);
156
+ const index = path.join(app, "index.ts");
157
+ if (fs.existsSync(index))
158
+ return index;
159
+ let main;
160
+ try {
161
+ main = parseYaml(fs.readFileSync(path.join(app, "Pulumi.yaml"), "utf8"))?.main;
162
+ }
163
+ catch {
164
+ return null;
165
+ }
166
+ if (typeof main !== "string" || !main)
167
+ return null;
168
+ const target = path.resolve(app, main);
169
+ return isDirectory(target) ? firstFile(path.join(target, "index")) : firstFile(target);
170
+ }
171
+ export function dependenciesInstalled(dir, stopAt) {
172
+ const root = stopAt === undefined ? null : path.resolve(stopAt);
173
+ let at = path.resolve(dir);
174
+ for (;;) {
175
+ if (fs.existsSync(path.join(at, "node_modules")))
176
+ return true;
177
+ if (at === root)
178
+ return false;
179
+ const parent = path.dirname(at);
180
+ if (parent === at)
181
+ return false;
182
+ at = parent;
183
+ }
184
+ }
185
+ function suggestedRange(appDir, parentDir) {
186
+ try {
187
+ const manifest = JSON.parse(fs.readFileSync(path.join(appDir, "package.json"), "utf8"));
188
+ for (const section of [manifest.dependencies, manifest.devDependencies]) {
189
+ for (const [name, range] of Object.entries(section ?? {})) {
190
+ if (name.startsWith(SCOPE) && range)
191
+ return range;
192
+ }
193
+ }
194
+ }
195
+ catch {
196
+ }
197
+ if (!parentDir)
198
+ return null;
199
+ try {
200
+ const version = JSON.parse(fs.readFileSync(path.join(parentDir, "package.json"), "utf8")).version;
201
+ return version ? `^${version}` : null;
202
+ }
203
+ catch {
204
+ return null;
205
+ }
206
+ }
207
+ function displayPath(app, visit) {
208
+ const within = path.relative(visit.pkg === null ? app : visit.pkgDir, visit.file).split(path.sep).join("/");
209
+ return visit.pkg === null ? within : `${visit.pkg}/${within}`;
210
+ }
211
+ export function resolveProgramImports(appDir, options = {}) {
212
+ const app = path.resolve(appDir);
213
+ const label = options.label ?? path.basename(app);
214
+ const entryFile = options.entry ? firstFile(path.resolve(app, options.entry)) : programEntry(app);
215
+ if (!dependenciesInstalled(app, options.root)) {
216
+ return { findings: [], unchecked: "dependencies are not installed (run the install, then re-run)" };
217
+ }
218
+ if (!entryFile) {
219
+ return { findings: [], unchecked: "no Pulumi program found (index.ts, or the file Pulumi.yaml's `main` names)" };
220
+ }
221
+ const findings = [];
222
+ const reported = new Set();
223
+ const seen = new Set([entryFile]);
224
+ const queue = [{ file: entryFile, pkgDir: app, pkg: null, via: [] }];
225
+ let budget = FILE_BUDGET;
226
+ while (queue.length > 0 && budget-- > 0) {
227
+ const here = queue.shift();
228
+ let source;
229
+ try {
230
+ source = fs.readFileSync(here.file, "utf8");
231
+ }
232
+ catch {
233
+ continue;
234
+ }
235
+ const fromDir = path.dirname(here.file);
236
+ for (const specifier of moduleSpecifiers(source)) {
237
+ if (specifier.startsWith(".")) {
238
+ if (here.pkg === null)
239
+ continue;
240
+ const next = firstFile(path.resolve(fromDir, specifier));
241
+ if (next && !seen.has(next) && next.startsWith(here.pkgDir)) {
242
+ seen.add(next);
243
+ queue.push({ ...here, file: next });
244
+ }
245
+ continue;
246
+ }
247
+ const split = splitSpecifier(specifier);
248
+ if (!split)
249
+ continue;
250
+ const inScope = split.pkg.startsWith(SCOPE);
251
+ if (here.pkg !== null && !inScope)
252
+ continue;
253
+ const pkgDir = findPackageDir(fromDir, split.pkg);
254
+ const target = pkgDir ? resolveSubpath(pkgDir, split.sub) : null;
255
+ if (!pkgDir || !target) {
256
+ if (reported.has(specifier))
257
+ continue;
258
+ reported.add(specifier);
259
+ const range = inScope ? suggestedRange(app, here.pkg === null ? null : here.pkgDir) : null;
260
+ const line = `"${split.pkg}": "${range ?? (inScope ? "<the version your other @mesh-tech packages use>" : "<the version you need>")}"`;
261
+ const fix = pkgDir
262
+ ? `${split.pkg} is installed but ships no ${split.sub} — the installed version predates the subpath; raise ${line} in ${label}/package.json`
263
+ : `add ${line} to ${label}/package.json dependencies`;
264
+ findings.push({ pkg: split.pkg, specifier, file: here.file, importer: displayPath(app, here), via: here.via, fix });
265
+ continue;
266
+ }
267
+ if (!inScope || seen.has(target))
268
+ continue;
269
+ seen.add(target);
270
+ queue.push({ file: target, pkgDir, pkg: split.pkg, via: [...here.via, specifier] });
271
+ }
272
+ }
273
+ findings.sort((a, b) => a.pkg.localeCompare(b.pkg) || a.specifier.localeCompare(b.specifier));
274
+ const unchecked = queue.length > 0 ? `the import graph exceeded ${FILE_BUDGET} files` : null;
275
+ return { findings, unchecked };
276
+ }
@@ -1,6 +1,7 @@
1
1
  import { Command } from "commander";
2
+ import { type ProbeResult } from "./app-check-resolve.js";
2
3
  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" | "DOCS_SITE_PUBLIC" | "DOCS_AUDIENCE_IMPLICIT" | "DOCS_AUDIENCE_UNGRANTABLE" | "DOCS_VENDOR_AUDIENCE_PARTNER";
4
+ export type AppCheckCode = "UNRESOLVABLE_IMPORT" | "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_PUBLIC" | "DOCS_AUDIENCE_IMPLICIT" | "DOCS_AUDIENCE_UNGRANTABLE" | "DOCS_VENDOR_AUDIENCE_PARTNER";
4
5
  export interface AppCheckRule {
5
6
  gate: string;
6
7
  title: string;
@@ -35,14 +36,16 @@ export declare function docsSiteFindings(text: string): Array<{
35
36
  }>;
36
37
  export declare function statedKind(decl: Pick<SurfaceDeclaration, "ctor">, args: Map<string, string>): string | undefined;
37
38
  export declare function listApps(root: string): string[];
38
- export declare function scanApp(root: string, appRel: string): Finding[];
39
+ export declare function probeResolution(root: string, appRel: string): ProbeResult;
40
+ export declare function scanApp(root: string, appRel: string, resolution?: ProbeResult): Finding[];
39
41
  export declare function scanRepo(root: string): Finding[];
40
42
  export interface AppCheckContext {
41
43
  root: string;
42
44
  app: string | null;
43
45
  findings: Finding[];
46
+ unchecked: string | null;
44
47
  }
45
- export declare function resultFor(code: AppCheckCode, findings: Finding[]): CheckResult;
48
+ export declare function resultFor(code: AppCheckCode, findings: Finding[], unchecked?: string | null): CheckResult;
46
49
  export declare function appContractChecks(scope: "app" | "repo"): Check<AppCheckContext>[];
47
50
  export interface AppCheckReport {
48
51
  root: string;
@@ -3,8 +3,17 @@ import * as path from "node:path";
3
3
  import { logError, logInfo } from "../utils/index.js";
4
4
  import { MeshCliError, emitJsonPayload } from "../utils/errors.js";
5
5
  import { resolveTargetRoot } from "./skills.js";
6
+ import { programEntry, resolveProgramImports } from "./app-check-resolve.js";
6
7
  import { ICONS, aggregateStatus, jsonReport, renderHuman, runChecks, } from "./dev-doctor.js";
7
8
  export const APP_CHECK_RULES = {
9
+ UNRESOLVABLE_IMPORT: {
10
+ gate: "0.6",
11
+ title: "The Pulumi program's imports resolve",
12
+ severity: "block",
13
+ remediation: "declare the package in apps/<app>/package.json dependencies, at the range the app's other @mesh-tech/* packages use, and re-install — " +
14
+ "an optional peer that nothing names installs silently and fails at `mesh deploy`",
15
+ scope: "app",
16
+ },
8
17
  UI_IN_API: {
9
18
  gate: "0.1",
10
19
  title: "UI is its own service",
@@ -610,7 +619,10 @@ export function listApps(root) {
610
619
  apps.push(".");
611
620
  return apps;
612
621
  }
613
- export function scanApp(root, appRel) {
622
+ export function probeResolution(root, appRel) {
623
+ return resolveProgramImports(path.resolve(root, appRel), { root, label: appRel === "." ? "this app" : appRel });
624
+ }
625
+ export function scanApp(root, appRel, resolution = probeResolution(root, appRel)) {
614
626
  const app = path.resolve(root, appRel);
615
627
  const index = path.join(app, "index.ts");
616
628
  const findings = [];
@@ -620,6 +632,12 @@ export function scanApp(root, appRel) {
620
632
  const hasProgram = fs.existsSync(index) || fs.existsSync(path.join(app, "Pulumi.yaml"));
621
633
  if (!hasProgram)
622
634
  return findings;
635
+ const entry = programEntry(app);
636
+ for (const bad of resolution.findings) {
637
+ const chain = [...bad.via, bad.specifier].join(" → ");
638
+ const at = rel(root, entry ?? index);
639
+ hit("UNRESOLVABLE_IMPORT", at, `${chain} does not resolve from this app (imported by ${bad.importer}) — ${bad.fix}`);
640
+ }
623
641
  if (fs.existsSync(index)) {
624
642
  const indexRel = rel(root, index);
625
643
  let servesHtml = false;
@@ -728,9 +746,12 @@ export function scanRepo(root) {
728
746
  }
729
747
  return findings;
730
748
  }
731
- export function resultFor(code, findings) {
749
+ export function resultFor(code, findings, unchecked = null) {
732
750
  const rule = APP_CHECK_RULES[code];
733
751
  const mine = findings.filter((f) => f.code === code);
752
+ if (code === "UNRESOLVABLE_IMPORT" && unchecked) {
753
+ return { status: "ok", summary: `gate ${rule.gate}: not checked — ${unchecked}` };
754
+ }
734
755
  if (mine.length === 0)
735
756
  return { status: "ok", summary: `gate ${rule.gate}: no ${code} finding` };
736
757
  return {
@@ -745,7 +766,7 @@ export function appContractChecks(scope) {
745
766
  id: code,
746
767
  title: `${APP_CHECK_RULES[code].title} (${APP_CHECK_RULES[code].gate})`,
747
768
  phases: ["ondemand", "preflight"],
748
- run: async (ctx) => resultFor(code, ctx.findings),
769
+ run: async (ctx) => resultFor(code, ctx.findings, ctx.unchecked),
749
770
  }));
750
771
  }
751
772
  export async function runAppCheck(root, apps) {
@@ -758,10 +779,12 @@ export async function runAppCheck(root, apps) {
758
779
  }
759
780
  const scopes = [];
760
781
  for (const app of targets) {
761
- const results = await runChecks({ root, app, findings: scanApp(root, app) }, "ondemand", appContractChecks("app"));
782
+ const resolution = probeResolution(root, app);
783
+ const ctx = { root, app, findings: scanApp(root, app, resolution), unchecked: resolution.unchecked };
784
+ const results = await runChecks(ctx, "ondemand", appContractChecks("app"));
762
785
  scopes.push({ scope: app, ...jsonReport(results) });
763
786
  }
764
- const repoResults = await runChecks({ root, app: null, findings: scanRepo(root) }, "ondemand", appContractChecks("repo"));
787
+ const repoResults = await runChecks({ root, app: null, findings: scanRepo(root), unchecked: null }, "ondemand", appContractChecks("repo"));
765
788
  scopes.push({ scope: "(repo)", ...jsonReport(repoResults) });
766
789
  return { root, status: aggregateStatus(scopes.map((s) => ({ status: s.status, summary: "" }))), scopes };
767
790
  }
@@ -787,7 +810,7 @@ export function registerAppCommands(program) {
787
810
  const app = program.command("app").description("Inspect a tenant app against the Mesh app contract");
788
811
  app
789
812
  .command("check")
790
- .description("Check tenant apps against the Mesh app contract (@mesh-tech/app-kit skills/apps/references/app-contract.md): a UI is its own service, the app registers with the Hub, sign-in goes through the platform proxy, logs go through @mesh-tech/logger, and people/roles/keys are surfaced through the Hub. Exit 1 on any blocking finding — the same gate mesh create-app, tenant CI and the platform reviewer run.")
813
+ .description("Check tenant apps against the Mesh app contract (@mesh-tech/app-kit skills/apps/references/app-contract.md): a UI is its own service, the app registers with the Hub, sign-in goes through the platform proxy, logs go through @mesh-tech/logger, people/roles/keys are surfaced through the Hub, and the Pulumi program's imports resolve from the app directory. Exit 1 on any blocking finding — the same gate mesh create-app, tenant CI and the platform reviewer run. The import gate reads the installed dependency tree, so run it after the install; with no node_modules it reports itself not checked rather than met.")
791
814
  .argument("[apps...]", "repo-relative app dirs to check (default: every apps/* with a Pulumi program)")
792
815
  .option("--root <dir>", "checkout to scan (default: the enclosing git root)")
793
816
  .option("--json", "machine-readable output (one report per app plus the repo-scoped checks)", false)
@@ -63,6 +63,9 @@ const api = new mesh.apps.Service("api", {
63
63
  description: "HTTP API for {{titleCase name}}",
64
64
  runtime: "node",
65
65
  src: "./api",
66
+ // Deploying from a machine without Docker (CI container, cloud IDE)?
67
+ // `buildMode: "remote"` builds the image inside the cluster instead —
68
+ // see the apps skill: primitives-api → RemoteBuildConfig.
66
69
  port: 3000,
67
70
  link: [
68
71
  {{#if database}}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mesh-tech/mesh-cli",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "CLI for Mesh platform development utilities",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -42,7 +42,7 @@
42
42
  },
43
43
  "devDependencies": {
44
44
  "@mesh-tech/agent-contracts": "0.2.0",
45
- "@mesh-tech/tsconfig": "2.18.0",
45
+ "@mesh-tech/tsconfig": "2.20.0",
46
46
  "@mesh-tech/workflow-viz": "0.3.0",
47
47
  "@tanstack/intent": "^0.3.6",
48
48
  "@types/node": "^20.0.0",
@@ -97,7 +97,7 @@ Top-level commands (each `register*` is wired in `src/program.ts`, which `bin/me
97
97
  | `mesh start` / `mesh stop` / `mesh status` | — | Full-local Mesh platform via docker compose (project `mesh-local`, assets ship in the CLI package): Postgres, Temporal (+UI), Zitadel, SpiceDB, ministack (local AWS fabric: SSM/Secrets/S3 registry; also seeds a general-purpose `mesh-local-data` bucket with sample data — see `docs/guides/local-dagster.md`), memcached, OpenSearch, a **local mailbox** (Mailpit — SMTP :1025 wired into Zitadel, inbox at http://localhost:8025, so user-activation and password-reset mail is testable locally instead of vanishing), **and the hosted observability backends** (OTel collector + Loki + Tempo + Prometheus — the Hub's logs/traces/metrics views run unchanged). Seeds tenant `local`/env `dev` on first boot and reconciles every Zitadel-provisioned app tenant into the registry. **No AWS creds, VPN, or Pulumi state needed — Docker only.** The **Hub is included by default** — three overlay services, `hub-api` + `hub-ui` + `hub-auth` (an oauth2-proxy; it is what binds the front-door port and proxies to `hub-ui`, so front-door 401s/redirect loops are in ITS logs, not `hub-api`'s) — the front door at http://localhost:9000 — `MESH_HUB_PORT=<port>` publishes it elsewhere when 9000 is taken; export it for `mesh status` too (images build once from the published @mesh-tech/hub tarball; needs `mesh registry login` the first time; `--no-hub` opts out). Auth contract: `--with-hub` refreshes to the latest published hub after preflighting the CodeArtifact token — with an expired/missing token it falls back to the already-built local images (warn + fixing command), or fails fast naming `mesh registry login --profile mesh-dev` when none exist; the default (no-flag) path with an expired/missing token starts hub-less and names the same fix. `--hub-from-source` sidesteps the registry entirely — it builds the Hub images from THIS checkout's `apps/hub`, which is the only way to run an unpublished Hub change in the containerized stack. `--with-idps` adds two mock identity providers (SAML + OIDC) and federates the local Zitadel with BOTH, which is how enterprise SSO gets exercised without a cloud tenant — the two-provider shape is deliberate, since the instance login policy carries a LIST of IdPs and the failure worth catching is a second federation evicting the first (`docs/guides/local-federation.md`). Its two host ports (4569 SAML, 5556 OIDC — 5556 is Dex's own default) join the start-up port preflight only when the flag is passed, and `mesh status` lists both mocks once they are running. Non-TTY runs (CI, agents, piped output) get an `[HH:MM:SS]` prefix on every step line plus a heartbeat during long fetches/builds — `MESH_LOG_TIMESTAMPS=1/0` forces the prefix on/off (default: on when stderr is not a TTY). | `start`: `--no-seed`, `--no-hub`, `--with-hub`, `--hub-from-source`, `--with-idps`, `--takeover`, `--skip-port-check`; `stop`: `--destroy` (drops volumes/seeds), `--force`; `status`: `--json` |
98
98
  | `mesh hub` | `dev` | One-command local Hub over a running dev-local stack. Launches the CURRENT-CODE Hub (apps/hub api+ui from a mesh-platform checkout) pointed at an existing `mesh dev` session — auto-assembles `HUB_TENANT`/`HUB_SCOPE_ENV`/`HUB_SCOPE_TENANTS` (session platform context), `TEMPORAL_ADDRESS` (session tunnel), `AWS_*` (session scratch profile), `DEV_USER_*`+`DEV_USER_TOKEN_URL` (session dev identity; starts its own token-server if the session predates it, so tokens stay fresh past ~1h). Refuses to launch with an empty tenant scope (which the Hub renders as silently blank). Runs in tmux session `mesh-hub-dev`. | `dev`: `--session <name>`, `--tenants <csv>`, `--port` (or `MESH_HUB_DEV_PORT`, default 9000; the containerized `mesh start` Hub uses the separate `MESH_HUB_PORT`), `--api-port <3002>`, `--platform-dir <dir>` (or `MESH_PLATFORM_DIR`), `--print-env`, `--kill` |
99
99
  | `mesh create-app` | — | Scaffold a tenant app. Composable mode (primitives) or legacy template mode. Interactive when TTY + missing flags. Run at the root of an **empty** `{tenant}-mesh-apps` git clone (a `.git` with no `package.json`/`apps/`/`tenants/` and no enclosing pnpm workspace), it first generates the repo-level shape — `pnpm-workspace.yaml` over `apps/*`, private root `package.json`, scoped-registry `.npmrc`, base `tsconfig.json`, `.gitignore`, `README.md`, `apps/` — then scaffolds the app. Never overwrites an existing file. | `--tenant`, `--name`, `--primitives <csv>` (`service,database,temporal,bucket`), `--template <workflow\|api-auth\|api-role-gating\|external-service>`, `--test` (writes to `tests/tenants/`) |
100
- | `mesh app` | `check [apps...]` | Checks tenant apps against the **Mesh app contract** (`@mesh-tech/app-kit#apps` → `references/app-contract.md`): a UI is its own Service (`UI_IN_API`, `NO_UI_SERVICE`), the app registers with the Hub (`NO_REGISTER`), sign-in goes through the platform proxy (`CUSTOM_SESSION_AUTH`), logs go through `@mesh-tech/logger` (`NON_PLATFORM_LOGGER`), and people/roles/keys are surfaced through the Hub (`NO_AUTHZ_POINTER`, `METADATA_UNPUBLISHED`, `IAC_GRANTS`, `PASSWORD_STORE`, `EMAIL_ALLOWLIST`, `USERS_TABLE`), and a docs site is read through the Hub rather than on a hostname of its own, with its readers stated and grantable (`DOCS_SITE_PUBLIC` — advisory while the hostname is deprecated — `DOCS_AUDIENCE_IMPLICIT`, `DOCS_AUDIENCE_UNGRANTABLE`, `DOCS_VENDOR_AUDIENCE_PARTNER` — a vendor surface's reference is never partner-readable); a tenant-local auth lib is advisory (`LOCAL_AUTH_LIB`), as is a pointer that names its own Zitadel project instead of binding the env with `zitadelAppProjectId` (`POINTER_PROJECT_OVERRIDE`). One check per code on the `mesh dev doctor` engine; exit 1 on any BLOCK. The SAME gate `mesh create-app` runs after scaffolding, tenant CI runs beside `mesh skills sync --check`, and the platform reviewer runs on a PR. Default: every `apps/*` with a Pulumi program. | `[apps...]` (repo-relative dirs), `--root <dir>`, `--json`, `--verbose` |
100
+ | `mesh app` | `check [apps...]` | Checks tenant apps against the **Mesh app contract** (`@mesh-tech/app-kit#apps` → `references/app-contract.md`): a UI is its own Service (`UI_IN_API`, `NO_UI_SERVICE`), the app registers with the Hub (`NO_REGISTER`), sign-in goes through the platform proxy (`CUSTOM_SESSION_AUTH`), logs go through `@mesh-tech/logger` (`NON_PLATFORM_LOGGER`), and people/roles/keys are surfaced through the Hub (`NO_AUTHZ_POINTER`, `METADATA_UNPUBLISHED`, `IAC_GRANTS`, `PASSWORD_STORE`, `EMAIL_ALLOWLIST`, `USERS_TABLE`), and a docs site is read through the Hub rather than on a hostname of its own, with its readers stated and grantable (`DOCS_SITE_PUBLIC` — advisory while the hostname is deprecated — `DOCS_AUDIENCE_IMPLICIT`, `DOCS_AUDIENCE_UNGRANTABLE`, `DOCS_VENDOR_AUDIENCE_PARTNER` — a vendor surface's reference is never partner-readable); a tenant-local auth lib is advisory (`LOCAL_AUTH_LIB`), as is a pointer that names its own Zitadel project instead of binding the env with `zitadelAppProjectId` (`POINTER_PROJECT_OVERRIDE`). Gate 0.6 additionally resolves the Pulumi program's imports against the installed tree (`UNRESOLVABLE_IMPORT`) — an optional peer nothing declares installs silently and `skipLibCheck` hides it, so the break otherwise waits for `mesh deploy`; with no `node_modules` the gate reports itself not checked rather than met. One check per code on the `mesh dev doctor` engine; exit 1 on any BLOCK. The SAME gate `mesh create-app` runs after scaffolding, tenant CI runs beside `mesh skills sync --check`, and the platform reviewer runs on a PR. Default: every `apps/*` with a Pulumi program. | `[apps...]` (repo-relative dirs), `--root <dir>`, `--json`, `--verbose` |
101
101
  | `mesh init` | (bare = wizard), `app-tenant`, `platform <tenant>` | **Bare `mesh init` is the guided setup wizard**: tenant (pre-filled from `mesh.json` / Pulumi `mesh:tenant`), **where the tenant runs — local-only, a deployed platform, or NOTHING YET (scaffold one; §1c)**, registry access (in-process `mesh registry login`; a 403 marks the step ✘ and the wizard continues), platform sign-in on the deployed branch only, repo bootstrap (empty clone, or an empty non-git folder the wizard offers to `git init`) or shape check, skills sync; writes `mesh.json` (`{tenant, platform}`) so `create-app` / `mesh dev` default `--tenant`; flags `--tenant --local\|--platform <env>\|--new-platform [env] --device --profile --skip-repo --yes --json`, plus the scaffold's own under `--new-platform` (`--region --domain --state-bucket --create-state-bucket/--no-create-state-bucket --dir`); exit 1 if any step failed, 130 on Ctrl-C. `platform <tenant>` **prompts** on a TTY for env/region/profile/state bucket (defaults on each) and for `--domain`, which has **no** default because a placeholder scaffolds an unvalidatable zone; it offers to create the Pulumi state bucket (versioned) and prints the deploy sequence including the manual NS delegation. `app-tenant` is the repo doctor (A1–A3): read-only check pipeline — CLI auth, registry access, public npm not hijacked, platform reachable (Hub API), app-tenant registration, deployer role, repo shape, agent skills — each failure names the exact `mesh` fix. **Registry access** is a live probe of the CodeArtifact token, not a grep of `~/.npmrc`: an expired token `fail`s (it used to `pass`), an unreachable registry `warn`s (a network fault is not an auth fault). **Public npm not hijacked** fails when `~/.npmrc` carries an unscoped `registry=<codeartifact>` line, which makes CodeArtifact your default registry — one `mesh registry login` fixes both. Exit 0 iff all pass; re-run any time. `--fix` applies developer-scope fixes (registry token, skills sync); operator actions stay remediations — and `--profile <p>` is the SSO profile `--fix` logs into CodeArtifact with, echoed back in the `fix:` command each check prints so the advertised command is the one that runs. | `app-tenant`: `--tenant <name>`, `--context <ctx>` (default `local`), `--hub-url <url>`, `--fix`, `--profile <sso>`, `--json` |
102
102
  | `mesh skills` | `sync` | Agent-skill distribution (D1/D2): installs the base building-with-Mesh skills into the repo's `.claude/skills/mesh-*/` (managed-marker files) and wires TanStack-Intent discovery for the pattern skills shipping inside `@mesh-tech/*` packages (`.intent/hooks/` gate + `.claude/settings.json` SessionStart hook + AGENTS.md fence — the PR #2356 mechanism, vendored). Idempotent; runs automatically after `mesh create-app`. | `sync`: `--check` (CI/doctor: exit 1 when missing/stale), `--root <path>` |
103
103
  | `mesh docs` | `build`, `portal`, `cli-reference` | `build` builds/validates an **app's** OpenAPI specs from `docs/docs.config.json` (file copy or SymXchange generation). `start`/`stop`/`list` are the simple front door: `start` serves the docs on loopback — DETACHED in a tmux session named `mesh-docs` by default (prints the bare URL once live; `stop` kills the session; `--foreground` or any non-TTY runs in the foreground) — serving the working tree in a mesh-platform checkout, or the published `@mesh-tech/docs` artifact fetched from the role-gated CodeArtifact registry anywhere else (docs version == the @mesh-tech/* baseline it describes; `-v` pins, default latest; cached under `~/.cache/mesh/docs/`). `list` shows the published docs versions. `portal` assembles the **Mesh developer portal** (docs.meshtech.io) from every `docs.json`-opted-in doc root in the repo — a directory publishes its markdown iff it holds a `docs.json`; route/title/order/nav derive from the tree and each file, so adding a doc is adding one file — then hands the assembled tree to Zudoku (`apps/docs/`): default runs `zudoku build`, `--serve` runs `zudoku dev`, `--assemble-only`/`-o` stop after assembly. Links between published files are rewritten to routes; links to unpublished repo files become blob links. Reserved directory names (`plans/`, `designs/`, `incidents/`, … the frozen list in `src/docs/schema.ts`) are excluded at any depth. `cli-reference` regenerates `docs/portal/generated/cli-reference.md` from THIS command tree, so it can't document a flag the binary lacks. `portal --check` is the CI gate (`pnpm check:docs-portal`): docs.json schema validation, reserved-segment fail-close, broken links, and CLI-reference staleness; `--diff-base <ref>` prints the publish-set diff for PR job summaries. Hidden commands and `--version` are excluded from the reference. | `build`: `-c/--config <path>` (default `docs/docs.config.json`); `portal`: `-o/--out <dir>`, `--assemble-only`, `--serve`, `-p/--port <n>` (default `3000`), `--check`, `--print-manifest`, `--diff-base <ref>`, `--manifest-out <path>`; `cli-reference`: `-o/--out <path>`, `--check`; `start`: `-v/--version <v>` (default: latest), `-p/--port <n>` (default `4400`; `0` is foreground-only), `--dev` (foreground HMR), `--foreground`; `stop`: no flags; `list`: no flags |
@@ -154,8 +154,14 @@ mesh init platform acme --domain a-domain-you-control.com # the only value wit
154
154
  - **The NS delegation is a manual step in the middle of the core deploy** —
155
155
  the new zone's NS records must be delegated from the parent, or the deploy
156
156
  waits on the certificate indefinitely. The printed Next block names it.
157
- - **The platform layer needs Docker** (it builds service images); the core
158
- layer does not. That is why a Docker-less container can deploy core and stop.
157
+ - **The platform layer builds service images with Docker by default**; the
158
+ core layer never needs it. On a Docker-less seat (a cloud IDE container,
159
+ some CI runners), set `mesh:buildMode: remote` in the platform stack config
160
+ — images then build inside the target cluster as rootless-BuildKit Jobs.
161
+ Three opt-ins still build locally and keep a Docker requirement:
162
+ `mesh:hub: { cdn: true }` (the UI's CDN asset build), `mesh:headscale` (the
163
+ VPN join broker image) and `mesh:packageRegistry` (the registry-auth broker
164
+ image, on unless `broker: false`).
159
165
  - Every answer is a flag, so the agent path is one line:
160
166
  `mesh init --tenant acme --new-platform dev --domain … --region … --state-bucket … --create-state-bucket --yes`
161
167
 
@@ -71,6 +71,16 @@ const meshPlatform = new mesh.layers.MeshPlatform("platform", {
71
71
  // DevBox - remote development instances (optional)
72
72
  // Config: mesh:devbox: { users: { <username>: { home: "/Users/<username>", ... } } }
73
73
  devbox: config.getObject("devbox") ?? false,
74
+ // Image build mode for the platform's mesh-built images (Temporal
75
+ // provisioner, custom server, Hub): "remote" builds them inside the target
76
+ // cluster as rootless BuildKit Jobs. Three opt-ins still build on the
77
+ // deploying machine and keep a Docker requirement there:
78
+ // `mesh:hub: { cdn: true }` (the UI's CDN asset build), `mesh:headscale`
79
+ // (the VPN join broker image) and `mesh:packageRegistry` (the registry-auth
80
+ // broker image, on unless `broker: false`).
81
+ // Config: mesh:buildMode: remote + mesh:remoteBuild: { bucket: ..., bucketRegion: ... }
82
+ buildMode: config.get<"local" | "remote">("buildMode"),
83
+ remoteBuild: config.getObject("remoteBuild"),
74
84
  });
75
85
 
76
86
  // ============================================================================