@mesh-tech/mesh-cli 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +74 -246
  2. package/dist/bin/mesh.js +2179 -1328
  3. package/dist/bin/mesh.js.map +3 -3
  4. package/dist/build-info.json +2 -2
  5. package/dist/src/commands/db/axes.d.ts +19 -0
  6. package/dist/src/commands/db/axes.js +35 -0
  7. package/dist/src/commands/db/connect.d.ts +2 -4
  8. package/dist/src/commands/db/connect.js +4 -19
  9. package/dist/src/commands/db/credentials.d.ts +2 -5
  10. package/dist/src/commands/db/credentials.js +7 -23
  11. package/dist/src/commands/db/env.d.ts +2 -5
  12. package/dist/src/commands/db/env.js +7 -23
  13. package/dist/src/commands/db/exec.js +7 -24
  14. package/dist/src/commands/db/psql.d.ts +2 -5
  15. package/dist/src/commands/db/psql.js +8 -23
  16. package/dist/src/commands/init/platform-setup.d.ts +68 -0
  17. package/dist/src/commands/init/platform-setup.js +203 -0
  18. package/dist/src/commands/init/wizard.d.ts +20 -0
  19. package/dist/src/commands/init/wizard.js +89 -7
  20. package/dist/src/commands/init.js +42 -36
  21. package/dist/src/commands/local/index.d.ts +5 -3
  22. package/dist/src/commands/local/index.js +61 -23
  23. package/dist/src/commands/local/mock-idps.d.ts +48 -0
  24. package/dist/src/commands/local/mock-idps.js +119 -0
  25. package/dist/src/commands/local/seed-zitadel.d.ts +6 -0
  26. package/dist/src/commands/local/seed-zitadel.js +76 -0
  27. package/dist/src/commands/local/stack.d.ts +11 -3
  28. package/dist/src/commands/local/stack.js +29 -15
  29. package/dist/src/commands/login.d.ts +4 -0
  30. package/dist/src/commands/login.js +16 -2
  31. package/dist/src/commands/registry.d.ts +2 -2
  32. package/dist/src/commands/registry.js +11 -6
  33. package/dist/src/commands/secrets/context.d.ts +5 -0
  34. package/dist/src/commands/secrets/context.js +5 -0
  35. package/dist/src/commands/secrets/index.js +3 -0
  36. package/dist/src/commands/secrets/migrate-config.d.ts +1 -0
  37. package/dist/src/commands/secrets/migrate-config.js +2 -3
  38. package/dist/src/commands/secrets/reindex.d.ts +1 -0
  39. package/dist/src/commands/secrets/reindex.js +2 -2
  40. package/dist/src/commands/secrets/set.d.ts +1 -0
  41. package/dist/src/commands/secrets/set.js +2 -2
  42. package/dist/src/commands/temporal.js +35 -8
  43. package/dist/src/commands/temporal.js.map +2 -2
  44. package/dist/src/commands/tunnel/index.d.ts +8 -0
  45. package/dist/src/commands/tunnel/index.js +3 -3
  46. package/dist/src/index.js +324 -62
  47. package/dist/src/index.js.map +3 -3
  48. package/dist/src/utils/context.d.ts +6 -1
  49. package/dist/src/utils/context.js +302 -39
  50. package/dist/src/utils/registry-identity.d.ts +5 -0
  51. package/dist/src/utils/registry-identity.js +16 -0
  52. package/fragments/agents/AGENTS.md.hbs +10 -2
  53. package/fragments/base/index.ts.hbs +15 -5
  54. package/package.json +2 -2
  55. package/skills/core/SKILL.md +74 -7
  56. package/stack/docker-compose.idps.yml +104 -0
  57. package/stack/idps/dex.yaml +53 -0
  58. package/templates/api-auth/index.ts.hbs +5 -4
  59. package/templates/api-role-gating/index.ts.hbs +5 -4
  60. package/templates/external-service/index.ts.hbs +5 -4
  61. package/templates/platform-repo/README.md.hbs +17 -3
  62. package/templates/platform-repo/core/Pulumi.{{tenant}}-{{env}}.yaml.hbs +7 -0
  63. package/templates/workflow/index.ts.hbs +5 -4
@@ -1,89 +1,352 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import { logInfo } from "./log.js";
4
- function findFileUpward(filename, startDir = process.cwd()) {
3
+ import { MeshCliError } from "./errors.js";
4
+ import { logInfo, logWarn } from "./log.js";
5
+ import { findMeshJson } from "./mesh-json.js";
6
+ function findFileUpwardInRepo(filename, startDir = process.cwd()) {
5
7
  let currentDir = startDir;
6
8
  const root = path.parse(currentDir).root;
7
- while (currentDir !== root) {
9
+ for (;;) {
8
10
  const filePath = path.join(currentDir, filename);
9
11
  if (fs.existsSync(filePath)) {
10
12
  return filePath;
11
13
  }
14
+ if (fs.existsSync(path.join(currentDir, ".git")) ||
15
+ fs.existsSync(path.join(currentDir, "pnpm-workspace.yaml")) ||
16
+ currentDir === root) {
17
+ return undefined;
18
+ }
19
+ currentDir = path.dirname(currentDir);
20
+ }
21
+ }
22
+ function findRepoRoot(startDir) {
23
+ let currentDir = startDir;
24
+ const root = path.parse(currentDir).root;
25
+ for (;;) {
26
+ if (fs.existsSync(path.join(currentDir, ".git")) ||
27
+ fs.existsSync(path.join(currentDir, "pnpm-workspace.yaml"))) {
28
+ return currentDir;
29
+ }
30
+ if (currentDir === root) {
31
+ return undefined;
32
+ }
12
33
  currentDir = path.dirname(currentDir);
13
34
  }
35
+ }
36
+ function stripComments(source) {
37
+ let out = "";
38
+ for (let i = 0; i < source.length; i++) {
39
+ const ch = source[i];
40
+ if (ch === '"' || ch === "'" || ch === "`") {
41
+ const start = i;
42
+ i++;
43
+ while (i < source.length && source[i] !== ch) {
44
+ if (source[i] === "\\")
45
+ i++;
46
+ i++;
47
+ }
48
+ out += source.slice(start, i + 1);
49
+ }
50
+ else if (ch === "/" && source[i + 1] === "/") {
51
+ while (i < source.length && source[i] !== "\n")
52
+ i++;
53
+ i--;
54
+ }
55
+ else if (ch === "/" && source[i + 1] === "*") {
56
+ i += 2;
57
+ while (i < source.length && !(source[i] === "*" && source[i + 1] === "/"))
58
+ i++;
59
+ i++;
60
+ }
61
+ else {
62
+ out += ch;
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+ function findExportedConfigStart(source) {
68
+ const declMatch = /export\s+(const\s+config\b|default\b)/.exec(source);
69
+ if (!declMatch)
70
+ return undefined;
71
+ const start = source.indexOf("{", declMatch.index);
72
+ if (start === -1)
73
+ return undefined;
74
+ const gap = source.slice(declMatch.index + declMatch[0].length, start);
75
+ if (!/^[\s=]*(:[^;{]*)?$/.test(gap))
76
+ return undefined;
77
+ return start;
78
+ }
79
+ function scanDepth1(source, start, visit) {
80
+ let depth = 0;
81
+ for (let i = start; i < source.length; i++) {
82
+ const ch = source[i];
83
+ if (ch === '"' || ch === "'" || ch === "`") {
84
+ i++;
85
+ while (i < source.length && source[i] !== ch) {
86
+ if (source[i] === "\\")
87
+ i++;
88
+ i++;
89
+ }
90
+ }
91
+ else if (ch === "{") {
92
+ depth++;
93
+ }
94
+ else if (ch === "}") {
95
+ depth--;
96
+ if (depth === 0)
97
+ return undefined;
98
+ }
99
+ else if (depth === 1 && !/[\w$.]/.test(source[i - 1] ?? "")) {
100
+ const found = visit(i);
101
+ if (found !== undefined)
102
+ return found;
103
+ }
104
+ }
105
+ return undefined;
106
+ }
107
+ function extractConfigTenant(rawSource) {
108
+ const source = stripComments(rawSource);
109
+ const start = findExportedConfigStart(source);
110
+ if (start === undefined)
111
+ return undefined;
112
+ const tenantRe = /tenant\s*:\s*(["'])([^"'\n]+)\1/y;
113
+ return scanDepth1(source, start, (i) => {
114
+ tenantRe.lastIndex = i;
115
+ return tenantRe.exec(source)?.[2];
116
+ });
117
+ }
118
+ function matchBlock(source, open) {
119
+ let depth = 0;
120
+ for (let i = open; i < source.length; i++) {
121
+ const ch = source[i];
122
+ if (ch === '"' || ch === "'" || ch === "`") {
123
+ i++;
124
+ while (i < source.length && source[i] !== ch) {
125
+ if (source[i] === "\\")
126
+ i++;
127
+ i++;
128
+ }
129
+ }
130
+ else if (ch === "{") {
131
+ depth++;
132
+ }
133
+ else if (ch === "}") {
134
+ depth--;
135
+ if (depth === 0)
136
+ return source.slice(open, i + 1);
137
+ }
138
+ }
14
139
  return undefined;
15
140
  }
16
- export function detectContext(stageArg) {
141
+ function extractExportedConfigSource(cleanedSource) {
142
+ const start = findExportedConfigStart(cleanedSource);
143
+ return start === undefined ? undefined : matchBlock(cleanedSource, start);
144
+ }
145
+ function extractPlatformBlock(cleanedSource) {
146
+ const start = findExportedConfigStart(cleanedSource);
147
+ if (start === undefined)
148
+ return undefined;
149
+ const platformRe = /platform\s*:\s*\{/y;
150
+ let open;
151
+ scanDepth1(cleanedSource, start, (i) => {
152
+ platformRe.lastIndex = i;
153
+ if (platformRe.exec(cleanedSource)) {
154
+ open = platformRe.lastIndex - 1;
155
+ return true;
156
+ }
157
+ return undefined;
158
+ });
159
+ if (open === undefined)
160
+ return undefined;
161
+ return matchBlock(cleanedSource, open);
162
+ }
163
+ function pickStackFile(files, hint, explicitStack) {
164
+ if (explicitStack !== undefined) {
165
+ const wanted = `Pulumi.${explicitStack}.yaml`;
166
+ return { file: files.includes(wanted) ? wanted : undefined, qualified: true };
167
+ }
168
+ if (hint) {
169
+ const wanted = `Pulumi.${hint}.yaml`;
170
+ if (files.includes(wanted))
171
+ return { file: wanted, qualified: true };
172
+ }
173
+ const stageOf = (f) => f.slice("Pulumi.".length, -".yaml".length);
174
+ const byBaseFirst = [...files].sort((a, b) => stageOf(a).length - stageOf(b).length || a.localeCompare(b));
175
+ return { file: byBaseFirst[0], qualified: false };
176
+ }
177
+ function tenantNotDeclared(cwd, found) {
178
+ const looked = found.stackFile
179
+ ? `${found.stackFile} in ${cwd} does not declare mesh:tenant`
180
+ : found.hasProjectFile
181
+ ? `${cwd} has a Pulumi.yaml but no Pulumi.<stack>.yaml to read mesh:tenant from`
182
+ : `${cwd} is not a Pulumi project directory (no Pulumi.yaml)`;
183
+ return new MeshCliError(`No tenant declared: ${looked}, and MESH_TENANT is not set. ` +
184
+ `There is no default tenant — "mesh" is the live hub and is never assumed. ` +
185
+ `Run from a directory whose Pulumi.<stack>.yaml declares mesh:tenant, set MESH_TENANT, ` +
186
+ `or pass --tenant where the command accepts one.`, {
187
+ remediation: {
188
+ command: "MESH_TENANT=<tenant> mesh … # or --tenant <tenant> where accepted, or run from a directory whose Pulumi.<stack>.yaml declares mesh:tenant",
189
+ },
190
+ });
191
+ }
192
+ export function detectContext(stageArg, options = {}) {
17
193
  let tenant;
18
194
  let stage;
19
195
  const cwd = process.cwd();
196
+ const stageHint = stageArg || process.env.MESH_STAGE || process.env.SST_STAGE;
197
+ let hasProjectFile = false;
198
+ let stackFile;
199
+ let stackFilesHere = [];
200
+ let namedStackMissing = false;
20
201
  if (fs.existsSync("Pulumi.yaml")) {
21
- const files = fs.readdirSync(".").filter((f) => f.startsWith("Pulumi.") && f.endsWith(".yaml") && f !== "Pulumi.yaml");
22
- const configFile = files[0];
23
- if (configFile) {
24
- const content = fs.readFileSync(configFile, "utf-8");
25
- const tenantMatch = content.match(/^\s*mesh:tenant:\s*["']?([^"'\n]+)["']?/m);
202
+ hasProjectFile = true;
203
+ const files = fs
204
+ .readdirSync(".")
205
+ .filter((f) => f.startsWith("Pulumi.") && f.endsWith(".yaml") && f !== "Pulumi.yaml")
206
+ .sort();
207
+ const picked = pickStackFile(files, stageHint, options.stack);
208
+ stackFile = picked.file;
209
+ namedStackMissing = options.stack !== undefined && picked.file === undefined;
210
+ stackFilesHere = files;
211
+ if (!picked.qualified && files.length > 1) {
212
+ const lengths = files.map((f) => f.length - "Pulumi..yaml".length).sort((a, b) => a - b);
213
+ if (lengths[0] === lengths[1]) {
214
+ logWarn(`Several Pulumi stack configs here (${files.join(", ")}); using ${stackFile}. ` +
215
+ `Pass --stack to choose one.`);
216
+ }
217
+ }
218
+ if (stackFile) {
219
+ const content = fs.readFileSync(stackFile, "utf-8");
220
+ const tenantMatch = content.match(/^\s*mesh:tenant:\s*["']?([^"'#\n]+)["']?/m);
26
221
  if (tenantMatch?.[1]) {
27
222
  tenant = tenantMatch[1].trim();
28
223
  }
29
- const stageMatch = configFile.match(/Pulumi\.(.+)\.yaml/);
224
+ const stageMatch = stackFile.match(/Pulumi\.(.+)\.yaml/);
30
225
  if (stageMatch?.[1]) {
31
226
  stage = stageMatch[1];
32
227
  }
33
228
  }
34
229
  }
35
- const sstConfigPath = findFileUpward("sst.config.ts");
230
+ const sstConfigPath = findFileUpwardInRepo("sst.config.ts");
36
231
  let sstDir;
37
232
  if (sstConfigPath) {
38
233
  sstDir = path.dirname(sstConfigPath);
39
- const parentConfigPath = path.join(sstDir, "..", "config.ts");
40
- if (fs.existsSync(parentConfigPath)) {
41
- const configContent = fs.readFileSync(parentConfigPath, "utf-8");
42
- const tenantMatch = configContent.match(/tenant:\s*["']([^"']+)["']/);
43
- if (tenantMatch) {
44
- tenant = tenantMatch[1];
45
- }
46
- }
47
234
  const sstStagePath = path.join(sstDir, ".sst", "stage");
48
235
  if (fs.existsSync(sstStagePath)) {
49
236
  stage = fs.readFileSync(sstStagePath, "utf-8").trim();
50
237
  }
51
238
  }
52
- if (!tenant) {
53
- const pathMatch = cwd.match(/tenants\/([^/]+)/);
239
+ const repoRoot = findRepoRoot(cwd);
240
+ const parentConfigPath = (sstDir
241
+ ? [path.join(sstDir, "config.ts"), path.join(sstDir, "..", "config.ts")]
242
+ : hasProjectFile
243
+ ? [path.resolve(cwd, "..", "config.ts")]
244
+ : [])
245
+ .filter((p) => repoRoot !== undefined && !path.relative(repoRoot, p).startsWith(".."))
246
+ .find((p) => fs.existsSync(p));
247
+ const parentConfig = parentConfigPath ? fs.readFileSync(parentConfigPath, "utf-8") : undefined;
248
+ if (parentConfig && !tenant) {
249
+ tenant = extractConfigTenant(parentConfig);
250
+ }
251
+ if (!tenant && repoRoot) {
252
+ const pathMatch = path.relative(repoRoot, cwd).match(/(?:^|\/)tenants\/([^/]+)/);
54
253
  if (pathMatch) {
55
254
  tenant = pathMatch[1];
56
255
  }
57
256
  }
58
- stage = stageArg || process.env.MESH_STAGE || process.env.SST_STAGE || stage || "dev";
59
- tenant = process.env.MESH_TENANT || tenant || "mesh";
257
+ if (!tenant) {
258
+ tenant = findMeshJson(cwd)?.data.tenant;
259
+ }
260
+ stage = stageHint || stage || "dev";
261
+ const detectedTenant = tenant;
262
+ tenant = options.tenant || process.env.MESH_TENANT || tenant;
263
+ if (!tenant) {
264
+ throw tenantNotDeclared(cwd, { stackFile, hasProjectFile });
265
+ }
266
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(tenant)) {
267
+ throw new MeshCliError(`Invalid tenant "${tenant}": expected lowercase alphanumerics and hyphens. ` +
268
+ `This usually means a config file was misparsed.`, { remediation: { command: "mesh <command> --tenant <tenant>" } });
269
+ }
270
+ const overrideSource = options.tenant ? "--tenant" : "MESH_TENANT";
271
+ const tenantOverridden = detectedTenant !== undefined && detectedTenant !== tenant;
272
+ if (tenantOverridden) {
273
+ logWarn(`${overrideSource}=${tenant} overrides the tenant detected here (${detectedTenant}); ` +
274
+ `paths will use ${tenant} while implicit AWS credentials still come from ` +
275
+ `the ${detectedTenant} app's deployer role`);
276
+ }
60
277
  let platformEnv = stage;
61
- let platformEnvMap = {};
278
+ const platformEnvMap = Object.create(null);
62
279
  let defaultPlatformEnv;
63
- const configPath = sstDir ? path.join(sstDir, "..", "config.ts") : "../config.ts";
64
- if (fs.existsSync(configPath)) {
65
- const configContent = fs.readFileSync(configPath, "utf-8");
66
- const mapMatch = configContent.match(/platformEnvMap:\s*\{([^}]+)\}/);
67
- if (mapMatch?.[1]) {
68
- const entries = mapMatch[1].matchAll(/(\w+):\s*["']([^"']+)["']/g);
280
+ if (parentConfig) {
281
+ const cleaned = stripComments(parentConfig);
282
+ const platformScope = extractPlatformBlock(cleaned);
283
+ const legacyScope = extractExportedConfigSource(cleaned);
284
+ const mapSource = (scope, key) => {
285
+ if (!scope)
286
+ return undefined;
287
+ const at = scope.search(new RegExp(`${key}\\s*:\\s*\\{`));
288
+ if (at === -1)
289
+ return undefined;
290
+ return matchBlock(scope, scope.indexOf("{", at));
291
+ };
292
+ const mapBody = mapSource(platformScope, "envMap") ?? mapSource(legacyScope, "platformEnvMap");
293
+ if (mapBody) {
294
+ const entries = mapBody.matchAll(/(?:"([^"]+)"|'([^']+)'|([\w-]+))\s*:\s*["']([^"']+)["']/g);
69
295
  for (const entry of entries) {
70
- const key = entry[1];
71
- const value = entry[2];
296
+ const key = entry[1] ?? entry[2] ?? entry[3];
297
+ const value = entry[4];
72
298
  if (key && value) {
73
299
  platformEnvMap[key] = value;
74
300
  }
75
301
  }
76
302
  }
77
- const defaultMatch = configContent.match(/defaultPlatformEnv:\s*["']([^"']+)["']/);
303
+ const defaultMatch = platformScope?.match(/defaultEnv:\s*["']([^"']+)["']/) ??
304
+ legacyScope?.match(/defaultPlatformEnv:\s*["']([^"']+)["']/);
78
305
  if (defaultMatch?.[1]) {
79
306
  defaultPlatformEnv = defaultMatch[1];
80
307
  }
81
308
  }
82
- const tenantPrefix = `${tenant}-`;
83
- const parsedEnv = stage.startsWith(tenantPrefix)
84
- ? stage.slice(tenantPrefix.length)
85
- : stage;
86
- platformEnv = platformEnvMap[parsedEnv] ?? platformEnvMap[stage] ?? defaultPlatformEnv ?? parsedEnv;
309
+ const tenantPrefix = [detectedTenant, tenant]
310
+ .filter((t) => Boolean(t))
311
+ .map((t) => `${t}-`)
312
+ .sort((a, b) => b.length - a.length)
313
+ .find((p) => stage.startsWith(p));
314
+ const parsedEnv = tenantPrefix ? stage.slice(tenantPrefix.length) : stage;
315
+ const mapApplies = !tenantOverridden;
316
+ if (tenantOverridden && (Object.keys(platformEnvMap).length > 0 || defaultPlatformEnv)) {
317
+ logWarn(`${overrideSource}=${tenant} also leaves the ${detectedTenant} config's envMap ` +
318
+ `unapplied — it maps ${detectedTenant}'s stages, not ${tenant}'s — so the ` +
319
+ `platform env is the stage itself ("${parsedEnv}"). Pass -e/--env where the ` +
320
+ `command takes one, or run from the ${tenant} app's own directory.`);
321
+ }
322
+ const mapped = mapApplies ? (platformEnvMap[stage] ?? platformEnvMap[parsedEnv]) : undefined;
323
+ if (mapped) {
324
+ platformEnv = mapped;
325
+ }
326
+ else if (mapApplies && defaultPlatformEnv) {
327
+ if (namedStackMissing) {
328
+ const known = [
329
+ ...stackFilesHere.map((f) => f.slice("Pulumi.".length, -".yaml".length)),
330
+ ...Object.keys(platformEnvMap),
331
+ ].filter((v, i, a) => a.indexOf(v) === i);
332
+ throw new MeshCliError(`No such stack "${options.stack}": ${cwd} has no Pulumi.${options.stack}.yaml, ` +
333
+ `and the app's config.ts does not map it to a platform env — it would ` +
334
+ `address defaultEnv "${defaultPlatformEnv}", not the stack you asked for.`, {
335
+ remediation: {
336
+ command: `mesh <command> --stack <${known.join("|") || "stack"}>`,
337
+ },
338
+ });
339
+ }
340
+ platformEnv = defaultPlatformEnv;
341
+ if (Object.keys(platformEnvMap).length > 0) {
342
+ logWarn(`Stage "${stage}" is not in the config's envMap ` +
343
+ `(${Object.keys(platformEnvMap).join(", ")}); using defaultEnv ` +
344
+ `"${defaultPlatformEnv}".`);
345
+ }
346
+ }
347
+ else {
348
+ platformEnv = parsedEnv;
349
+ }
87
350
  logInfo(`Stage: ${stage}, Platform: ${platformEnv}, Tenant: ${tenant}`);
88
- return { tenant, stage, platformEnv };
351
+ return { tenant, stage, platformEnv, detectedTenant };
89
352
  }
@@ -45,6 +45,11 @@ export declare function ensureRegistrySession(opts: {
45
45
  interactive: boolean;
46
46
  }): Promise<RegistrySessionResult>;
47
47
  export declare function registryTokenRoles(idToken: string | null | undefined): string[];
48
+ export interface RegistryAccountState {
49
+ email?: string;
50
+ emailVerified?: boolean;
51
+ }
52
+ export declare function registryAccountState(idToken: string | null | undefined): RegistryAccountState;
48
53
  export interface RegistryRemint {
49
54
  reminted: boolean;
50
55
  rolesChanged: boolean;
@@ -138,6 +138,22 @@ export function registryTokenRoles(idToken) {
138
138
  return [];
139
139
  }
140
140
  }
141
+ export function registryAccountState(idToken) {
142
+ if (!idToken)
143
+ return {};
144
+ let claims = null;
145
+ try {
146
+ claims = decodeJwtPayload(idToken);
147
+ }
148
+ catch {
149
+ return {};
150
+ }
151
+ if (!claims)
152
+ return {};
153
+ const email = typeof claims.email === "string" ? claims.email : undefined;
154
+ const verified = claims.email_verified;
155
+ return { email, emailVerified: typeof verified === "boolean" ? verified : undefined };
156
+ }
141
157
  export async function refreshRegistrySession(context) {
142
158
  const before = registryTokenRoles(readCredentials(context)?.idToken);
143
159
  const idToken = await forceRefreshToken(context);
@@ -27,8 +27,16 @@ readers the app's `docs` role admits. A site declared on a
27
27
  `mesh.apps.VendorApiSurface` (a third-party core's raw reference) is read
28
28
  through the same door by holders of the Hub's `vendor-docs` add-on only —
29
29
  never by a partner — and declaring `audience: { roles: [] }` on one fails
30
- the deploy. `mesh app check` flags a docs site put on an ingress
31
- (`DOCS_SITE_PUBLIC`). Keep the spec in step with the routes in
30
+ the deploy. Add `actAs: true` on an app surface to let Hub operators execute
31
+ requests against the deployed API as one of the app's API keys from the Docs
32
+ tab (the Hub's act-as playground) — the surface then verifies the Hub's
33
+ signed assertion and runs the request as the key. `mesh app check` flags a
34
+ docs site put on an ingress (`DOCS_SITE_PUBLIC`). A vendor surface's site is
35
+ built from the enabled-ops cut of the core's spec, not the whole document, and
36
+ a spec over 1 MiB is split into per-entity variants at build time
37
+ (`docs.split: { above }` moves the threshold, `false` turns it off) — an app's
38
+ own spec is already the shape it serves and gets neither. Keep the spec in
39
+ step with the routes in
32
40
  `api/src/index.ts` — the site renders what the file says. Keep pages `.md`,
33
41
  not `.mdx`: a site an API surface declares is served by the Hub under its own
34
42
  origin, which carries data and never code, so an `.mdx` page fails that
@@ -75,10 +75,10 @@ const api = new mesh.apps.Service("api", {
75
75
  env.temporal!.link({ environmentPrefix: "TEMPORAL", taskQueue: TASK_QUEUE }),
76
76
  {{/if}}
77
77
  ],
78
- ingress: env.buildIngress({
79
- subdomain: "{{name}}",
80
- healthCheckPath: "/health",
81
- }),
78
+ ingress: env.buildIngress({ subdomain: "{{name}}" }),
79
+ // Drives readiness, liveness and the Hub uptime sampler on BOTH ingress kinds.
80
+ // `ingress.healthCheckPath` sets the ALB target group's path only, and a
81
+ // gateway (HTTPRoute) tenant has no target group — so the path belongs here.
82
82
  healthCheck: "/health",
83
83
  replicas: deploy ? 2 : 1,
84
84
  resources: { cpu: "100m", memory: "128Mi" },
@@ -113,7 +113,17 @@ const worker = new mesh.apps.TemporalWorker("worker", {
113
113
  // it from Access → People; that same role is what the Hub reads when a
114
114
  // partner holding the Hub's `docs:<tenant>` role opens this app's Docs tab,
115
115
  // and the app then appears in their Apps list. `mesh app check` flags a docs
116
- // site put on an ingress instead (`DOCS_SITE_PUBLIC`).
116
+ // site put on an ingress instead (`DOCS_SITE_PUBLIC`). Declaring `actAs: true`
117
+ // on that same surface opens the Hub's act-as playground: an operator picks
118
+ // one of this app's API keys on the Docs tab and the Hub executes the request
119
+ // against the deployed API as that key, over the cluster network, with a
120
+ // signed assertion the surface verifies — the Hub never holds the key's secret.
121
+ // Whichever of those the surface declares, the platform bounds them at the
122
+ // workload: each workload it publishes an in-cluster address for allows the
123
+ // Hub on the ports it published and refuses it on every other one, so a
124
+ // registry entry cannot walk the Hub onto a port the surface never opened.
125
+ // Nothing here declares that — it rides the surface (`hubDialPolicy`, on by
126
+ // default wherever the namespace is in the service mesh).
117
127
  // =============================================================================
118
128
 
119
129
  new mesh.apps.ApiDocs("docs", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mesh-tech/mesh-cli",
3
- "version": "0.25.0",
3
+ "version": "0.27.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.14.0",
45
+ "@mesh-tech/tsconfig": "2.17.0",
46
46
  "@mesh-tech/workflow-viz": "0.3.0",
47
47
  "@tanstack/intent": "^0.3.6",
48
48
  "@types/node": "^20.0.0",