@mesh-tech/mesh-cli 0.27.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": "5b8435e57",
3
- "builtAt": "2026-09-17T20:26:45.360Z"
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",
@@ -23,7 +32,8 @@ export const APP_CHECK_RULES = {
23
32
  gate: "0.2",
24
33
  title: "App registers with the Hub",
25
34
  severity: "block",
26
- remediation: "end index.ts with `export const app = env.register({ description })`",
35
+ remediation: "end index.ts with `export const app = env.register({ displayName, description })` — " +
36
+ "one sentence a non-technical operator can act on; put the caveats in `operatorNote`",
27
37
  scope: "app",
28
38
  },
29
39
  CUSTOM_SESSION_AUTH: {
@@ -609,7 +619,10 @@ export function listApps(root) {
609
619
  apps.push(".");
610
620
  return apps;
611
621
  }
612
- 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)) {
613
626
  const app = path.resolve(root, appRel);
614
627
  const index = path.join(app, "index.ts");
615
628
  const findings = [];
@@ -619,6 +632,12 @@ export function scanApp(root, appRel) {
619
632
  const hasProgram = fs.existsSync(index) || fs.existsSync(path.join(app, "Pulumi.yaml"));
620
633
  if (!hasProgram)
621
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
+ }
622
641
  if (fs.existsSync(index)) {
623
642
  const indexRel = rel(root, index);
624
643
  let servesHtml = false;
@@ -727,9 +746,12 @@ export function scanRepo(root) {
727
746
  }
728
747
  return findings;
729
748
  }
730
- export function resultFor(code, findings) {
749
+ export function resultFor(code, findings, unchecked = null) {
731
750
  const rule = APP_CHECK_RULES[code];
732
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
+ }
733
755
  if (mine.length === 0)
734
756
  return { status: "ok", summary: `gate ${rule.gate}: no ${code} finding` };
735
757
  return {
@@ -744,7 +766,7 @@ export function appContractChecks(scope) {
744
766
  id: code,
745
767
  title: `${APP_CHECK_RULES[code].title} (${APP_CHECK_RULES[code].gate})`,
746
768
  phases: ["ondemand", "preflight"],
747
- run: async (ctx) => resultFor(code, ctx.findings),
769
+ run: async (ctx) => resultFor(code, ctx.findings, ctx.unchecked),
748
770
  }));
749
771
  }
750
772
  export async function runAppCheck(root, apps) {
@@ -757,10 +779,12 @@ export async function runAppCheck(root, apps) {
757
779
  }
758
780
  const scopes = [];
759
781
  for (const app of targets) {
760
- 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"));
761
785
  scopes.push({ scope: app, ...jsonReport(results) });
762
786
  }
763
- 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"));
764
788
  scopes.push({ scope: "(repo)", ...jsonReport(repoResults) });
765
789
  return { root, status: aggregateStatus(scopes.map((s) => ({ status: s.status, summary: "" }))), scopes };
766
790
  }
@@ -786,7 +810,7 @@ export function registerAppCommands(program) {
786
810
  const app = program.command("app").description("Inspect a tenant app against the Mesh app contract");
787
811
  app
788
812
  .command("check")
789
- .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.")
790
814
  .argument("[apps...]", "repo-relative app dirs to check (default: every apps/* with a Pulumi program)")
791
815
  .option("--root <dir>", "checkout to scan (default: the enclosing git root)")
792
816
  .option("--json", "machine-readable output (one report per app plus the repo-scoped checks)", false)
@@ -19,7 +19,9 @@ export interface HubDevEnvOptions {
19
19
  tenants?: string;
20
20
  uiPort: number;
21
21
  apiPort: number;
22
+ audience?: string;
22
23
  }
24
+ export declare function resolveHubAudience(raw: string | undefined): "operator" | "tenant";
23
25
  export interface AssembledHubEnv {
24
26
  apiEnv: Record<string, string>;
25
27
  uiEnv: Record<string, string>;
@@ -99,6 +99,11 @@ export function forwardedSessionVars(sessionEnv) {
99
99
  }
100
100
  return out;
101
101
  }
102
+ export function resolveHubAudience(raw) {
103
+ if (raw === undefined || raw === "")
104
+ return "operator";
105
+ return raw.trim().toLowerCase() === "operator" ? "operator" : "tenant";
106
+ }
102
107
  export function assembleHubEnv(session, sessionEnv, opts) {
103
108
  const warnings = [];
104
109
  const notes = [];
@@ -141,6 +146,7 @@ export function assembleHubEnv(session, sessionEnv, opts) {
141
146
  if (!temporalAddress) {
142
147
  throw new MeshCliError(`Dev session '${session.name}' exposes no Temporal tunnel — the Hub can't reach the stack's Temporal.`, { remediation: { command: "mesh dev # relaunch; the session records tunnels.temporal in its state" } });
143
148
  }
149
+ const audienceEnv = { HUB_AUDIENCE: resolveHubAudience(opts.audience) };
144
150
  const apiEnv = {
145
151
  ...forwarded,
146
152
  PORT: String(opts.apiPort),
@@ -148,6 +154,7 @@ export function assembleHubEnv(session, sessionEnv, opts) {
148
154
  HUB_TENANT: hubTenant,
149
155
  HUB_SCOPE_ENV: scopeEnv,
150
156
  HUB_SCOPE_TENANTS: scopeTenants.join(","),
157
+ ...audienceEnv,
151
158
  ...(isLocalPlatform ? localPlatformEnv(sessionEnv) : {}),
152
159
  };
153
160
  const uiEnv = {
@@ -157,6 +164,7 @@ export function assembleHubEnv(session, sessionEnv, opts) {
157
164
  HUB_TENANT: hubTenant,
158
165
  DEFAULT_ORG: scopeTenants[0],
159
166
  DEFAULT_ENV: scopeEnv,
167
+ ...audienceEnv,
160
168
  };
161
169
  return { apiEnv, uiEnv, scope: { hubTenant, scopeEnv, scopeTenants }, temporalAddress, warnings, notes };
162
170
  }
@@ -282,7 +290,12 @@ async function hubDevAction(opts) {
282
290
  throw new MeshCliError(`Dev session '${session.name}' has a state file but no live tmux session — its tunnels and token-server are gone.`, { remediation: { command: `mesh dev # from ${session.state.appRoot}` } });
283
291
  }
284
292
  const sessionEnv = readTmuxSessionEnv(session.name);
285
- const assembled = assembleHubEnv(session, sessionEnv, { tenants: opts.tenants, uiPort, apiPort });
293
+ const assembled = assembleHubEnv(session, sessionEnv, {
294
+ tenants: opts.tenants,
295
+ uiPort,
296
+ apiPort,
297
+ audience: opts.audience ?? process.env.HUB_AUDIENCE,
298
+ });
286
299
  if (opts.printEnv) {
287
300
  console.log(chalk.bold("\nhub-api env:"));
288
301
  for (const [k, v] of Object.entries(redactEnv(assembled.apiEnv)))
@@ -308,6 +321,7 @@ async function hubDevAction(opts) {
308
321
  logInfo(`Dev session : ${session.name} (${session.state.appRoot})`);
309
322
  logInfo(`Hub source : ${platformDir}`);
310
323
  logInfo(`Scope : HUB_TENANT=${assembled.scope.hubTenant} HUB_SCOPE_ENV=${assembled.scope.scopeEnv} HUB_SCOPE_TENANTS=${assembled.scope.scopeTenants.join(",")}`);
324
+ logInfo(`Audience : ${assembled.apiEnv.HUB_AUDIENCE} (--audience tenant for a client's view)`);
311
325
  logInfo(`Temporal : ${assembled.temporalAddress}`);
312
326
  for (const note of assembled.notes)
313
327
  logInfo(note);
@@ -372,6 +386,7 @@ export function registerHubCommands(program) {
372
386
  .description("Launch the current-code Hub (api + ui) against a running `mesh dev` session — env auto-assembled, zero hand-set vars")
373
387
  .option("--session <name>", "dev session to observe (default: auto-detect from cwd)")
374
388
  .option("--tenants <list>", "comma-separated HUB_SCOPE_TENANTS override (default: the session's tenant)")
389
+ .option("--audience <who>", "who the Hub renders for: `operator` (platform staff — the default here) or `tenant` (a client's operators). Anything else reads as `tenant`")
375
390
  .option("--port <port>", `Hub UI host port (default: $MESH_HUB_DEV_PORT or ${DEFAULT_HUB_UI_PORT})`)
376
391
  .option("--api-port <port>", `Hub API port (default: ${DEFAULT_HUB_API_PORT})`)
377
392
  .option("--platform-dir <dir>", "mesh-platform checkout to run the Hub from (default: $MESH_PLATFORM_DIR or walk up from cwd)")
@@ -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}}
@@ -145,7 +148,19 @@ new mesh.apps.ApiDocs("docs", {
145
148
  // =============================================================================
146
149
 
147
150
  export const app = env.register({
148
- description: "{{titleCase name}}",
151
+ // The line under this app's name on the Hub's Apps list, read by the
152
+ // client's own operators more often than by anyone on the team. One
153
+ // sentence in THEIR words — what it does, not how. No file paths, no ticket
154
+ // ids, no platform vocabulary; `operatorNote` below is where those go.
155
+ displayName: "{{titleCase name}}",
156
+ description: "{{titleCase name}}.",
157
+ // Engineer to engineer. A Hub deployed for a client never serializes this —
158
+ // the key is dropped from the response — so it is the right home for the
159
+ // caveat, the runbook pointer and the ticket.
160
+ // operatorNote: "Shares a database with the importer; see the runbook.",
161
+ //
162
+ // `visibility: "platform"` would hide this app from a client's Hub entirely.
163
+ // That is for platform components, not for an app a client operates.
149
164
  });
150
165
 
151
166
  // Exported so the bindings above are used, and because a stack's URLs are the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mesh-tech/mesh-cli",
3
- "version": "0.27.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.17.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
 
@@ -115,7 +115,11 @@ new mesh.auth.AppAuthzPointer("authz", { env, mode: "policy-engine", opsHubMetad
115
115
  // --- Outputs ---
116
116
 
117
117
  export const app = env.register({
118
- description: "{{titleCase name}} — platform-authenticated API (authn + authz)",
118
+ displayName: "{{titleCase name}}",
119
+ // What a client operator reads on the Apps list. One sentence, their words.
120
+ description: "Serves the {{titleCase name}} API to signed-in callers.",
121
+ // Engineer to engineer; a Hub deployed for a client never serializes it.
122
+ operatorNote: "Platform-authenticated API — authn (Zitadel JWT) plus authz (SpiceDB policy engine).",
119
123
  });
120
124
 
121
125
  /** The app's own Zitadel project id (grant people and API keys against this). */
@@ -107,7 +107,11 @@ new mesh.auth.AppAuthzPointer("authz", {
107
107
  });
108
108
 
109
109
  export const app = env.register({
110
- description: "{{titleCase name}} — role-gating API (roles enforced in-process from the Zitadel JWT)",
110
+ displayName: "{{titleCase name}}",
111
+ // What a client operator reads on the Apps list. One sentence, their words.
112
+ description: "Serves the {{titleCase name}} API, with access decided by each caller's role.",
113
+ // Engineer to engineer; a Hub deployed for a client never serializes it.
114
+ operatorNote: "Role-gating mode — roles are enforced in-process from the Zitadel JWT, no SpiceDB.",
111
115
  });
112
116
 
113
117
  /** The app's own Zitadel project id (grant API keys against this). */