@mesh-tech/mesh-cli 0.44.0 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/{mesh.auth-preflight-JTUNS42O.js → mesh.auth-preflight-3ASNYA3I.js} +1 -1
- package/dist/bin/{mesh.chunk-QE5LUIHN.js → mesh.chunk-252LTIHZ.js} +2 -2
- package/dist/bin/{mesh.chunk-TYM27CO3.js → mesh.chunk-4Y3JGAYZ.js} +1 -1
- package/dist/bin/{mesh.chunk-OQA2UBOD.js → mesh.chunk-6OGUV2DU.js} +1 -1
- package/dist/bin/{mesh.chunk-XOCERV6F.js → mesh.chunk-G4TCVQLL.js} +556 -110
- package/dist/bin/mesh.chunk-G4TCVQLL.js.map +6 -0
- package/dist/bin/{mesh.chunk-ZNM3DDAM.js → mesh.chunk-HPLMVIBO.js} +1 -1
- package/dist/bin/{mesh.chunk-QUKWWFZI.js → mesh.chunk-JSY6THRF.js} +1 -1
- package/dist/bin/{mesh.chunk-PANTMVC3.js → mesh.chunk-NN3LTNUM.js} +3 -3
- package/dist/bin/{mesh.chunk-HB2IZKLP.js → mesh.chunk-SFIJZF3S.js} +786 -276
- package/dist/bin/{mesh.chunk-HB2IZKLP.js.map → mesh.chunk-SFIJZF3S.js.map} +3 -3
- package/dist/bin/{mesh.chunk-HC7V75J3.js → mesh.chunk-UJAPUO2W.js} +1 -1
- package/dist/bin/{mesh.create-app-DVMZDEQP.js → mesh.create-app-BHB3BGH7.js} +3 -3
- package/dist/bin/{mesh.dev-token-server-4N6HGQAF.js → mesh.dev-token-server-6TNWX5TY.js} +2 -2
- package/dist/bin/mesh.js +9 -9
- package/dist/bin/{mesh.login-Q6R5GMVM.js → mesh.login-4O5PMS25.js} +2 -2
- package/dist/bin/{mesh.platform-setup-YK6KSOP7.js → mesh.platform-setup-MTY6ICAO.js} +1 -1
- package/dist/bin/{mesh.program-NOBVA3JS.js → mesh.program-CMOLQWZI.js} +9 -9
- package/dist/bin/{mesh.registry-KA3CNNPZ.js → mesh.registry-BDBH6DRP.js} +4 -4
- package/dist/bin/{mesh.registry-identity-DSTAHXUS.js → mesh.registry-identity-QOGDEMZO.js} +3 -3
- package/dist/bin/{mesh.review-command-3ZXPS6Y3.js → mesh.review-command-E7V2L5AM.js} +3 -3
- package/dist/bin/{mesh.start-JDSYIRZI.js → mesh.start-3MUKXN2U.js} +2 -2
- package/dist/bin/{mesh.wizard-QGWPV4XN.js → mesh.wizard-EH3SQEHW.js} +7 -7
- package/dist/build-info.json +2 -2
- package/dist/src/commands/dev.d.ts +1 -0
- package/dist/src/commands/dev.js +57 -1
- package/dist/src/commands/temporal.js +146 -42
- package/dist/src/commands/temporal.js.map +3 -3
- package/dist/src/commands/workstation.d.ts +3 -0
- package/dist/src/commands/workstation.js +395 -0
- package/dist/src/program.js +2 -0
- package/dist/src/utils/temporal-inspect.d.ts +17 -0
- package/dist/src/utils/temporal-inspect.js +71 -0
- package/dist/src/utils/workstation-machine.d.ts +54 -0
- package/dist/src/utils/workstation-machine.js +282 -0
- package/dist/src/utils/workstation-routes.d.ts +77 -0
- package/dist/src/utils/workstation-routes.js +180 -0
- package/fragments/base/README.md.hbs +3 -0
- package/package.json +2 -2
- package/skills/mesh/SKILL.md +3 -2
- package/skills/mesh/references/worktrees.md +4 -1
- package/dist/bin/mesh.chunk-XOCERV6F.js.map +0 -6
- /package/dist/bin/{mesh.auth-preflight-JTUNS42O.js.map → mesh.auth-preflight-3ASNYA3I.js.map} +0 -0
- /package/dist/bin/{mesh.chunk-QE5LUIHN.js.map → mesh.chunk-252LTIHZ.js.map} +0 -0
- /package/dist/bin/{mesh.chunk-TYM27CO3.js.map → mesh.chunk-4Y3JGAYZ.js.map} +0 -0
- /package/dist/bin/{mesh.chunk-OQA2UBOD.js.map → mesh.chunk-6OGUV2DU.js.map} +0 -0
- /package/dist/bin/{mesh.chunk-ZNM3DDAM.js.map → mesh.chunk-HPLMVIBO.js.map} +0 -0
- /package/dist/bin/{mesh.chunk-QUKWWFZI.js.map → mesh.chunk-JSY6THRF.js.map} +0 -0
- /package/dist/bin/{mesh.chunk-PANTMVC3.js.map → mesh.chunk-NN3LTNUM.js.map} +0 -0
- /package/dist/bin/{mesh.chunk-HC7V75J3.js.map → mesh.chunk-UJAPUO2W.js.map} +0 -0
- /package/dist/bin/{mesh.create-app-DVMZDEQP.js.map → mesh.create-app-BHB3BGH7.js.map} +0 -0
- /package/dist/bin/{mesh.dev-token-server-4N6HGQAF.js.map → mesh.dev-token-server-6TNWX5TY.js.map} +0 -0
- /package/dist/bin/{mesh.login-Q6R5GMVM.js.map → mesh.login-4O5PMS25.js.map} +0 -0
- /package/dist/bin/{mesh.platform-setup-YK6KSOP7.js.map → mesh.platform-setup-MTY6ICAO.js.map} +0 -0
- /package/dist/bin/{mesh.program-NOBVA3JS.js.map → mesh.program-CMOLQWZI.js.map} +0 -0
- /package/dist/bin/{mesh.registry-KA3CNNPZ.js.map → mesh.registry-BDBH6DRP.js.map} +0 -0
- /package/dist/bin/{mesh.registry-identity-DSTAHXUS.js.map → mesh.registry-identity-QOGDEMZO.js.map} +0 -0
- /package/dist/bin/{mesh.review-command-3ZXPS6Y3.js.map → mesh.review-command-E7V2L5AM.js.map} +0 -0
- /package/dist/bin/{mesh.start-JDSYIRZI.js.map → mesh.start-3MUKXN2U.js.map} +0 -0
- /package/dist/bin/{mesh.wizard-QGWPV4XN.js.map → mesh.wizard-EH3SQEHW.js.map} +0 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { X509Certificate } from "node:crypto";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { logInfo } from "./log.js";
|
|
7
|
+
import { readWorkstationIdentity, workstationDir } from "./workstation-routes.js";
|
|
8
|
+
export function readCredentialProcess(configText, profile) {
|
|
9
|
+
let inSection = false;
|
|
10
|
+
for (const line of configText.split("\n")) {
|
|
11
|
+
const header = /^\s*\[(.+)\]\s*$/.exec(line);
|
|
12
|
+
if (header) {
|
|
13
|
+
inSection = header[1].trim() === `profile ${profile}` || header[1].trim() === profile;
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
const kv = /^\s*credential_process\s*=\s*(.+?)\s*$/.exec(line);
|
|
17
|
+
if (inSection && kv)
|
|
18
|
+
return kv[1];
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
export function credentialProgram(command) {
|
|
23
|
+
return command.split(/\s+/)[0];
|
|
24
|
+
}
|
|
25
|
+
export function credentialProcessProblem(command, deps) {
|
|
26
|
+
if (!command)
|
|
27
|
+
return "the profile has no credential_process (run mesh login <platform> --export --role <roleArn>)";
|
|
28
|
+
const program = credentialProgram(command);
|
|
29
|
+
if (!path.isAbsolute(program))
|
|
30
|
+
return `its program "${program}" is not an absolute path`;
|
|
31
|
+
if (!deps.exists(program))
|
|
32
|
+
return `its program ${program} does not exist`;
|
|
33
|
+
if (deps.isInsideGitTree(program)) {
|
|
34
|
+
return `its program ${program} is inside a git checkout, and stops working when that checkout is removed`;
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
export function isInsideGitTree(p) {
|
|
39
|
+
let dir = path.dirname(path.resolve(p));
|
|
40
|
+
for (;;) {
|
|
41
|
+
if (fs.existsSync(path.join(dir, ".git")))
|
|
42
|
+
return true;
|
|
43
|
+
const parent = path.dirname(dir);
|
|
44
|
+
if (parent === dir)
|
|
45
|
+
return false;
|
|
46
|
+
dir = parent;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function rewriteCredentialProgram(configText, profile, cli) {
|
|
50
|
+
let inSection = false;
|
|
51
|
+
let rewritten = false;
|
|
52
|
+
const lines = configText.split("\n").map((line) => {
|
|
53
|
+
const header = /^\s*\[(.+)\]\s*$/.exec(line);
|
|
54
|
+
if (header) {
|
|
55
|
+
inSection = header[1].trim() === `profile ${profile}` || header[1].trim() === profile;
|
|
56
|
+
return line;
|
|
57
|
+
}
|
|
58
|
+
const kv = /^(\s*credential_process\s*=\s*)(\S+)(.*)$/.exec(line);
|
|
59
|
+
if (inSection && kv) {
|
|
60
|
+
rewritten = true;
|
|
61
|
+
return `${kv[1]}${cli}${kv[3]}`;
|
|
62
|
+
}
|
|
63
|
+
return line;
|
|
64
|
+
});
|
|
65
|
+
if (!rewritten)
|
|
66
|
+
throw new Error(`no credential_process in [profile ${profile}]`);
|
|
67
|
+
return lines.join("\n");
|
|
68
|
+
}
|
|
69
|
+
export const LAUNCH_AGENT_LABEL = "tech.mesh.workstation-proxy";
|
|
70
|
+
export const LAUNCH_AGENTS_MACOS_ONLY = "LaunchAgents are macOS-only; run mesh workstation proxy in the foreground (or under your own service manager)";
|
|
71
|
+
export function launchAgentsSupported(platform = process.platform) {
|
|
72
|
+
return platform === "darwin";
|
|
73
|
+
}
|
|
74
|
+
export function launchAgentSearchPath(installerPath, execPath) {
|
|
75
|
+
const entries = [
|
|
76
|
+
...(installerPath ?? "").split(":"),
|
|
77
|
+
path.dirname(execPath),
|
|
78
|
+
"/usr/local/bin",
|
|
79
|
+
"/usr/bin",
|
|
80
|
+
"/bin",
|
|
81
|
+
"/usr/sbin",
|
|
82
|
+
"/sbin",
|
|
83
|
+
].filter((p) => path.isAbsolute(p) && !p.split(path.sep).includes("node_modules"));
|
|
84
|
+
return [...new Set(entries)].join(":");
|
|
85
|
+
}
|
|
86
|
+
function xml(s) {
|
|
87
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
88
|
+
}
|
|
89
|
+
export function renderLaunchAgent(args) {
|
|
90
|
+
const program = [args.caddy, "run", "--watch", "--config", args.caddyfile, "--adapter", "caddyfile"];
|
|
91
|
+
const env = Object.entries(args.env)
|
|
92
|
+
.map(([k, v]) => `\t\t<key>${xml(k)}</key>\n\t\t<string>${xml(v)}</string>`)
|
|
93
|
+
.join("\n");
|
|
94
|
+
return [
|
|
95
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
96
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
97
|
+
'<plist version="1.0">',
|
|
98
|
+
"<dict>",
|
|
99
|
+
`\t<key>Label</key>\n\t<string>${LAUNCH_AGENT_LABEL}</string>`,
|
|
100
|
+
`\t<key>ProgramArguments</key>\n\t<array>\n${program.map((a) => `\t\t<string>${xml(a)}</string>`).join("\n")}\n\t</array>`,
|
|
101
|
+
`\t<key>WorkingDirectory</key>\n\t<string>${xml(args.dir)}</string>`,
|
|
102
|
+
`\t<key>EnvironmentVariables</key>\n\t<dict>\n${env}\n\t</dict>`,
|
|
103
|
+
"\t<key>RunAtLoad</key>\n\t<true/>",
|
|
104
|
+
"\t<key>KeepAlive</key>\n\t<true/>",
|
|
105
|
+
`\t<key>StandardOutPath</key>\n\t<string>${xml(path.join(args.dir, "proxy.log"))}</string>`,
|
|
106
|
+
`\t<key>StandardErrorPath</key>\n\t<string>${xml(path.join(args.dir, "proxy.log"))}</string>`,
|
|
107
|
+
"</dict>",
|
|
108
|
+
"</plist>",
|
|
109
|
+
"",
|
|
110
|
+
].join("\n");
|
|
111
|
+
}
|
|
112
|
+
export function certificateExpiry(dir, domain) {
|
|
113
|
+
const certs = path.join(dir, "caddy", "certificates");
|
|
114
|
+
let best;
|
|
115
|
+
const walk = (d) => {
|
|
116
|
+
let entries;
|
|
117
|
+
try {
|
|
118
|
+
entries = fs.readdirSync(d, { withFileTypes: true });
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
for (const e of entries) {
|
|
124
|
+
const p = path.join(d, e.name);
|
|
125
|
+
if (e.isDirectory())
|
|
126
|
+
walk(p);
|
|
127
|
+
else if (e.name === `wildcard_.${domain}.crt`) {
|
|
128
|
+
const expiry = new Date(new X509Certificate(fs.readFileSync(p)).validTo);
|
|
129
|
+
if (!best || expiry > best)
|
|
130
|
+
best = expiry;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
walk(certs);
|
|
135
|
+
return best;
|
|
136
|
+
}
|
|
137
|
+
export function workstationWarnings(args) {
|
|
138
|
+
const warnings = [];
|
|
139
|
+
if (args.certExpiry) {
|
|
140
|
+
const days = Math.floor((args.certExpiry.getTime() - args.now.getTime()) / 86_400_000);
|
|
141
|
+
if (days < 14)
|
|
142
|
+
warnings.push(`The certificate expires in ${days} day(s) and has not renewed: check proxy.log.`);
|
|
143
|
+
}
|
|
144
|
+
if (args.credentialProblem)
|
|
145
|
+
warnings.push(`The proxy's AWS profile will not renew the certificate: ${args.credentialProblem}.`);
|
|
146
|
+
else if (!args.credentialOk) {
|
|
147
|
+
warnings.push("The proxy's AWS profile yields no credentials (mesh login expired?): renewals fail until you log in again.");
|
|
148
|
+
}
|
|
149
|
+
return warnings;
|
|
150
|
+
}
|
|
151
|
+
export function discoveryRecordName(name, platform) {
|
|
152
|
+
return `_mesh-workstation.${name}.${platform.replace(/\.$/, "")}`.toLowerCase();
|
|
153
|
+
}
|
|
154
|
+
export function parseDiscoveryRecord(text) {
|
|
155
|
+
const fields = new Map();
|
|
156
|
+
for (const part of text.trim().split(/\s+/)) {
|
|
157
|
+
const eq = part.indexOf("=");
|
|
158
|
+
if (eq > 0)
|
|
159
|
+
fields.set(part.slice(0, eq), part.slice(eq + 1));
|
|
160
|
+
}
|
|
161
|
+
if (fields.get("v") !== "1")
|
|
162
|
+
return `unknown discovery record version (${fields.get("v") ?? "none"})`;
|
|
163
|
+
const roleArn = fields.get("role") ?? "";
|
|
164
|
+
const zoneId = fields.get("zone") ?? "";
|
|
165
|
+
const region = fields.get("region") ?? "";
|
|
166
|
+
if (!/^arn:aws:iam::\d{12}:role\/[\w+=,.@/-]+$/.test(roleArn))
|
|
167
|
+
return `"${roleArn}" is not an IAM role ARN`;
|
|
168
|
+
if (!/^Z[A-Z0-9]{1,32}$/.test(zoneId))
|
|
169
|
+
return `"${zoneId}" is not a Route 53 zone id`;
|
|
170
|
+
if (!/^[a-z]{2}(-[a-z]+)+-\d$/.test(region))
|
|
171
|
+
return `"${region}" is not an AWS region`;
|
|
172
|
+
return { roleArn, zoneId, region };
|
|
173
|
+
}
|
|
174
|
+
export function caddyDownloadUrl(platform, arch) {
|
|
175
|
+
const goArch = arch === "x64" ? "amd64" : arch;
|
|
176
|
+
return `https://caddyserver.com/api/download?os=${platform}&arch=${goArch}&p=${encodeURIComponent("github.com/caddy-dns/route53")}`;
|
|
177
|
+
}
|
|
178
|
+
export function caddyHasRoute53(listModulesOutput) {
|
|
179
|
+
return listModulesOutput.split("\n").some((l) => l.trim() === "dns.providers.route53");
|
|
180
|
+
}
|
|
181
|
+
export function shimFallsBackToGlobal(shimText) {
|
|
182
|
+
return shimText.includes("canonical launcher shim") && shimText.includes("Not in a workspace: exec the next");
|
|
183
|
+
}
|
|
184
|
+
export function zoneCandidates(platform) {
|
|
185
|
+
const labels = platform.replace(/\.$/, "").toLowerCase().split(".");
|
|
186
|
+
return labels.slice(0, -1).map((_, i) => labels.slice(i).join("."));
|
|
187
|
+
}
|
|
188
|
+
export const RENEWAL_OVERDUE_DAYS = 28;
|
|
189
|
+
export function healthCheckDue(stamp, now) {
|
|
190
|
+
if (!stamp)
|
|
191
|
+
return true;
|
|
192
|
+
const at = Date.parse(stamp.checkedAt);
|
|
193
|
+
return !Number.isFinite(at) || now.getTime() - at >= 86_400_000;
|
|
194
|
+
}
|
|
195
|
+
export function loginContextOf(command) {
|
|
196
|
+
return command ? /\slogin\s+("?)([^\s"]+)\1/.exec(command)?.[2] : undefined;
|
|
197
|
+
}
|
|
198
|
+
export function renewalWarnings(args) {
|
|
199
|
+
const days = args.certExpiry ? Math.floor((args.certExpiry.getTime() - args.now.getTime()) / 86_400_000) : undefined;
|
|
200
|
+
const expires = days === undefined ? "" : ` (certificate expires in ${days} day${days === 1 ? "" : "s"})`;
|
|
201
|
+
const login = args.loginContext ? `mesh login ${args.loginContext}` : "the profile's mesh login";
|
|
202
|
+
if (!args.credentialOk)
|
|
203
|
+
return [`Workstation certificate renewal is failing: run ${login}${expires}`];
|
|
204
|
+
if (days !== undefined && days < RENEWAL_OVERDUE_DAYS) {
|
|
205
|
+
return [`Workstation certificate has not renewed${expires}: check ~/.config/mesh/workstation/proxy.log`];
|
|
206
|
+
}
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
const STATIC_AWS_VARS = ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN"];
|
|
210
|
+
export function proxyEnv(base, proxy) {
|
|
211
|
+
const env = { ...base };
|
|
212
|
+
for (const key of STATIC_AWS_VARS)
|
|
213
|
+
delete env[key];
|
|
214
|
+
env.AWS_REGION = proxy.region;
|
|
215
|
+
if (proxy.awsProfile)
|
|
216
|
+
env.AWS_PROFILE = proxy.awsProfile;
|
|
217
|
+
if (proxy.awsConfigFile)
|
|
218
|
+
env.AWS_CONFIG_FILE = proxy.awsConfigFile;
|
|
219
|
+
return env;
|
|
220
|
+
}
|
|
221
|
+
export function awsConfigFileOf(proxy) {
|
|
222
|
+
return proxy.awsConfigFile ?? path.join(os.homedir(), ".aws", "config");
|
|
223
|
+
}
|
|
224
|
+
export function profileYieldsCredentials(proxy, timeoutMs = 60_000) {
|
|
225
|
+
if (!proxy.awsProfile)
|
|
226
|
+
return true;
|
|
227
|
+
const command = readCredentialProcess(fs.readFileSync(awsConfigFileOf(proxy), "utf8"), proxy.awsProfile);
|
|
228
|
+
if (!command)
|
|
229
|
+
return false;
|
|
230
|
+
try {
|
|
231
|
+
const out = execFileSync("/bin/sh", ["-c", command], {
|
|
232
|
+
env: proxyEnv(process.env, proxy),
|
|
233
|
+
cwd: os.homedir(),
|
|
234
|
+
timeout: timeoutMs,
|
|
235
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
236
|
+
});
|
|
237
|
+
return typeof JSON.parse(out.toString()).AccessKeyId === "string";
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
export function workstationRenewalWarnings(now = new Date()) {
|
|
244
|
+
try {
|
|
245
|
+
const dir = workstationDir();
|
|
246
|
+
const identity = readWorkstationIdentity(dir);
|
|
247
|
+
if (!identity?.proxy)
|
|
248
|
+
return [];
|
|
249
|
+
const proxy = identity.proxy;
|
|
250
|
+
const stampFile = path.join(dir, "health.json");
|
|
251
|
+
let stamp;
|
|
252
|
+
try {
|
|
253
|
+
stamp = JSON.parse(fs.readFileSync(stampFile, "utf8"));
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
stamp = undefined;
|
|
257
|
+
}
|
|
258
|
+
if (healthCheckDue(stamp, now)) {
|
|
259
|
+
logInfo("Checking the workstation's certificate credentials…");
|
|
260
|
+
stamp = { checkedAt: now.toISOString(), credentialOk: profileYieldsCredentials(proxy, 20_000) };
|
|
261
|
+
fs.writeFileSync(stampFile, JSON.stringify(stamp) + "\n");
|
|
262
|
+
}
|
|
263
|
+
let command;
|
|
264
|
+
if (proxy.awsProfile) {
|
|
265
|
+
try {
|
|
266
|
+
command = readCredentialProcess(fs.readFileSync(awsConfigFileOf(proxy), "utf8"), proxy.awsProfile);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
command = undefined;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return renewalWarnings({
|
|
273
|
+
now,
|
|
274
|
+
certExpiry: certificateExpiry(dir, identity.domain),
|
|
275
|
+
credentialOk: stamp.credentialOk,
|
|
276
|
+
loginContext: loginContextOf(command),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return [];
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export interface WorkstationIdentity {
|
|
2
|
+
name: string;
|
|
3
|
+
domain: string;
|
|
4
|
+
proxy?: WorkstationProxyConfig;
|
|
5
|
+
}
|
|
6
|
+
export interface WorkstationProxyConfig {
|
|
7
|
+
zoneId: string;
|
|
8
|
+
region: string;
|
|
9
|
+
awsProfile?: string;
|
|
10
|
+
awsConfigFile?: string;
|
|
11
|
+
caddy?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface WorkstationRoute {
|
|
14
|
+
hostname: string;
|
|
15
|
+
port: number;
|
|
16
|
+
session?: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function workstationDir(): string;
|
|
19
|
+
export declare function readWorkstationIdentity(dir?: string): WorkstationIdentity | undefined;
|
|
20
|
+
export declare function serviceRouteLabel(args: {
|
|
21
|
+
app: string;
|
|
22
|
+
service: string;
|
|
23
|
+
route?: string | false;
|
|
24
|
+
}): string | undefined;
|
|
25
|
+
export declare function worktreeRouteSuffix(wt: {
|
|
26
|
+
isPrimary: boolean;
|
|
27
|
+
slug: string;
|
|
28
|
+
}, explicit?: string): string;
|
|
29
|
+
export declare function sessionRouteLabel(label: string, suffix: string): string;
|
|
30
|
+
export declare function planSessionRoutes(args: {
|
|
31
|
+
identity: WorkstationIdentity;
|
|
32
|
+
app: string;
|
|
33
|
+
services: Record<string, {
|
|
34
|
+
port: number;
|
|
35
|
+
command?: string[];
|
|
36
|
+
route?: string | false;
|
|
37
|
+
}>;
|
|
38
|
+
suffix: string;
|
|
39
|
+
}): Array<{
|
|
40
|
+
service: string;
|
|
41
|
+
hostname: string;
|
|
42
|
+
port: number;
|
|
43
|
+
}>;
|
|
44
|
+
export declare function renderRouteFile(route: WorkstationRoute): string;
|
|
45
|
+
export declare function parseRouteFile(text: string): WorkstationRoute | undefined;
|
|
46
|
+
export declare function listRoutes(dir?: string): Array<WorkstationRoute & {
|
|
47
|
+
file: string;
|
|
48
|
+
}>;
|
|
49
|
+
export declare function removeSessionRoutes(session: string, dir?: string): string[];
|
|
50
|
+
export interface SessionRouteResult {
|
|
51
|
+
urls: Record<string, string>;
|
|
52
|
+
written: Array<{
|
|
53
|
+
service: string;
|
|
54
|
+
hostname: string;
|
|
55
|
+
port: number;
|
|
56
|
+
}>;
|
|
57
|
+
conflicts: Array<{
|
|
58
|
+
service: string;
|
|
59
|
+
hostname: string;
|
|
60
|
+
port: number;
|
|
61
|
+
heldBy: string;
|
|
62
|
+
heldPort: number;
|
|
63
|
+
}>;
|
|
64
|
+
}
|
|
65
|
+
export declare function writeSessionRoutes(session: string, planned: Array<{
|
|
66
|
+
service: string;
|
|
67
|
+
hostname: string;
|
|
68
|
+
port: number;
|
|
69
|
+
}>, dir?: string): SessionRouteResult;
|
|
70
|
+
export declare function renderWorkstationCaddyfile(identity: WorkstationIdentity & {
|
|
71
|
+
proxy: WorkstationProxyConfig;
|
|
72
|
+
}, dir: string): string;
|
|
73
|
+
export declare function usableRouteUrls(planned: Array<{
|
|
74
|
+
service: string;
|
|
75
|
+
hostname: string;
|
|
76
|
+
port: number;
|
|
77
|
+
}>, dir?: string): Record<string, string>;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
const LABEL_MAX = 63;
|
|
5
|
+
const SESSION_MARKER = "# mesh dev session: ";
|
|
6
|
+
export function workstationDir() {
|
|
7
|
+
return process.env.MESH_WORKSTATION_DIR || path.join(os.homedir(), ".config", "mesh", "workstation");
|
|
8
|
+
}
|
|
9
|
+
export function readWorkstationIdentity(dir = workstationDir()) {
|
|
10
|
+
const file = path.join(dir, "workstation.json");
|
|
11
|
+
let raw;
|
|
12
|
+
try {
|
|
13
|
+
raw = fs.readFileSync(file, "utf8");
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
const identity = JSON.parse(raw);
|
|
19
|
+
if (typeof identity.name !== "string" || typeof identity.domain !== "string" || !identity.domain.startsWith(`${identity.name}.`)) {
|
|
20
|
+
throw new Error(`${file}: needs a name and a domain under it, e.g. {"name":"mbp","domain":"mbp.example.com"}`);
|
|
21
|
+
}
|
|
22
|
+
return identity;
|
|
23
|
+
}
|
|
24
|
+
function toLabel(raw) {
|
|
25
|
+
return raw
|
|
26
|
+
.toLowerCase()
|
|
27
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
28
|
+
.replace(/-{2,}/g, "-")
|
|
29
|
+
.replace(/^-+|-+$/g, "");
|
|
30
|
+
}
|
|
31
|
+
export function serviceRouteLabel(args) {
|
|
32
|
+
if (args.route === false)
|
|
33
|
+
return undefined;
|
|
34
|
+
const named = args.service === args.app || args.service.startsWith(`${args.app}-`);
|
|
35
|
+
return toLabel(args.route ?? (named ? args.service : `${args.app}-${args.service}`)) || undefined;
|
|
36
|
+
}
|
|
37
|
+
export function worktreeRouteSuffix(wt, explicit) {
|
|
38
|
+
if (explicit !== undefined)
|
|
39
|
+
return toLabel(explicit);
|
|
40
|
+
if (wt.isPrimary)
|
|
41
|
+
return "";
|
|
42
|
+
const short = wt.slug
|
|
43
|
+
.replace(/^(feature|feat|fix|chore|bugfix|hotfix|refactor|lab|spike)-/, "")
|
|
44
|
+
.replace(/(-[0-9a-f]{8,})+$/, "");
|
|
45
|
+
return toLabel(short) || toLabel(wt.slug);
|
|
46
|
+
}
|
|
47
|
+
export function sessionRouteLabel(label, suffix) {
|
|
48
|
+
if (!suffix)
|
|
49
|
+
return label.slice(0, LABEL_MAX).replace(/-+$/, "");
|
|
50
|
+
const room = LABEL_MAX - label.length - 1;
|
|
51
|
+
if (room < 1)
|
|
52
|
+
return label.slice(0, LABEL_MAX).replace(/-+$/, "");
|
|
53
|
+
return `${label}-${suffix.slice(0, room).replace(/-+$/, "")}`;
|
|
54
|
+
}
|
|
55
|
+
export function planSessionRoutes(args) {
|
|
56
|
+
const routes = [];
|
|
57
|
+
for (const [service, s] of Object.entries(args.services)) {
|
|
58
|
+
if (!s.port || !s.command?.length)
|
|
59
|
+
continue;
|
|
60
|
+
const label = serviceRouteLabel({ app: args.app, service, route: s.route });
|
|
61
|
+
if (!label)
|
|
62
|
+
continue;
|
|
63
|
+
routes.push({ service, hostname: `${sessionRouteLabel(label, args.suffix)}.${args.identity.domain}`, port: s.port });
|
|
64
|
+
}
|
|
65
|
+
return routes;
|
|
66
|
+
}
|
|
67
|
+
export function renderRouteFile(route) {
|
|
68
|
+
const matcher = `route-${route.hostname.replaceAll(".", "_")}`;
|
|
69
|
+
return [
|
|
70
|
+
route.session
|
|
71
|
+
? `${SESSION_MARKER}${route.session}`
|
|
72
|
+
: "# Written by WorkstationRoute (Pulumi). Removed on destroy; edits are overwritten on up.",
|
|
73
|
+
`@${matcher} host ${route.hostname}`,
|
|
74
|
+
`handle @${matcher} {`,
|
|
75
|
+
`\treverse_proxy 127.0.0.1:${route.port}`,
|
|
76
|
+
"}",
|
|
77
|
+
"",
|
|
78
|
+
].join("\n");
|
|
79
|
+
}
|
|
80
|
+
export function parseRouteFile(text) {
|
|
81
|
+
const hostname = /^@\S+ host (\S+)$/m.exec(text)?.[1];
|
|
82
|
+
const port = Number(/reverse_proxy 127\.0\.0\.1:(\d+)/.exec(text)?.[1]);
|
|
83
|
+
if (!hostname || !port)
|
|
84
|
+
return undefined;
|
|
85
|
+
const session = text.split("\n").find((l) => l.startsWith(SESSION_MARKER))?.slice(SESSION_MARKER.length).trim();
|
|
86
|
+
return { hostname, port, ...(session ? { session } : {}) };
|
|
87
|
+
}
|
|
88
|
+
function routesDir(dir) {
|
|
89
|
+
return path.join(dir, "routes");
|
|
90
|
+
}
|
|
91
|
+
export function listRoutes(dir = workstationDir()) {
|
|
92
|
+
let names;
|
|
93
|
+
try {
|
|
94
|
+
names = fs.readdirSync(routesDir(dir)).filter((n) => n.endsWith(".caddy"));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
return names.flatMap((name) => {
|
|
100
|
+
const file = path.join(routesDir(dir), name);
|
|
101
|
+
const route = parseRouteFile(fs.readFileSync(file, "utf8"));
|
|
102
|
+
return route ? [{ ...route, file }] : [];
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
export function removeSessionRoutes(session, dir = workstationDir()) {
|
|
106
|
+
const removed = [];
|
|
107
|
+
for (const route of listRoutes(dir)) {
|
|
108
|
+
if (route.session !== session)
|
|
109
|
+
continue;
|
|
110
|
+
fs.rmSync(route.file, { force: true });
|
|
111
|
+
removed.push(route.hostname);
|
|
112
|
+
}
|
|
113
|
+
return removed;
|
|
114
|
+
}
|
|
115
|
+
export function writeSessionRoutes(session, planned, dir = workstationDir()) {
|
|
116
|
+
removeSessionRoutes(session, dir);
|
|
117
|
+
const held = new Map(listRoutes(dir).map((r) => [r.hostname, r]));
|
|
118
|
+
const result = { urls: {}, written: [], conflicts: [] };
|
|
119
|
+
fs.mkdirSync(routesDir(dir), { recursive: true });
|
|
120
|
+
for (const route of planned) {
|
|
121
|
+
const other = held.get(route.hostname);
|
|
122
|
+
if (other) {
|
|
123
|
+
if (other.port === route.port)
|
|
124
|
+
result.urls[route.service] = `https://${route.hostname}`;
|
|
125
|
+
else
|
|
126
|
+
result.conflicts.push({
|
|
127
|
+
...route,
|
|
128
|
+
heldBy: other.session ? `session ${other.session}` : "the stack",
|
|
129
|
+
heldPort: other.port,
|
|
130
|
+
});
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const file = path.join(routesDir(dir), `${route.hostname}.caddy`);
|
|
134
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
135
|
+
fs.writeFileSync(tmp, renderRouteFile({ hostname: route.hostname, port: route.port, session }));
|
|
136
|
+
fs.renameSync(tmp, file);
|
|
137
|
+
result.written.push(route);
|
|
138
|
+
result.urls[route.service] = `https://${route.hostname}`;
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
export function renderWorkstationCaddyfile(identity, dir) {
|
|
143
|
+
return [
|
|
144
|
+
"# Written by `mesh workstation init`. Routes live in routes/; this file rarely changes.",
|
|
145
|
+
"{",
|
|
146
|
+
"\tadmin 127.0.0.1:2119",
|
|
147
|
+
`\tstorage file_system ${path.join(dir, "caddy")}`,
|
|
148
|
+
"\tskip_install_trust",
|
|
149
|
+
"\tauto_https disable_redirects",
|
|
150
|
+
"\tservers {",
|
|
151
|
+
"\t\tprotocols h1 h2",
|
|
152
|
+
"\t}",
|
|
153
|
+
"}",
|
|
154
|
+
"",
|
|
155
|
+
`*.${identity.domain}:443 {`,
|
|
156
|
+
"\ttls {",
|
|
157
|
+
"\t\tdns route53 {",
|
|
158
|
+
`\t\t\thosted_zone_id ${identity.proxy.zoneId}`,
|
|
159
|
+
`\t\t\tregion ${identity.proxy.region}`,
|
|
160
|
+
"\t\t\twait_for_route53_sync true",
|
|
161
|
+
"\t\t}",
|
|
162
|
+
"\t\tpropagation_delay 60s",
|
|
163
|
+
"\t\tpropagation_timeout 5m",
|
|
164
|
+
"\t}",
|
|
165
|
+
"\t@offnet not remote_ip 100.64.0.0/10 fd7a:115c:a1e0::/48 127.0.0.0/8 ::1",
|
|
166
|
+
"\thandle @offnet {",
|
|
167
|
+
"\t\tabort",
|
|
168
|
+
"\t}",
|
|
169
|
+
`\timport ${path.join(routesDir(dir), "*.caddy")}`,
|
|
170
|
+
"\thandle {",
|
|
171
|
+
'\t\trespond "No service is routed at {host}" 404',
|
|
172
|
+
"\t}",
|
|
173
|
+
"}",
|
|
174
|
+
"",
|
|
175
|
+
].join("\n");
|
|
176
|
+
}
|
|
177
|
+
export function usableRouteUrls(planned, dir = workstationDir()) {
|
|
178
|
+
const byHost = new Map(listRoutes(dir).map((r) => [r.hostname, r]));
|
|
179
|
+
return Object.fromEntries(planned.filter((p) => byHost.get(p.hostname)?.port === p.port).map((p) => [p.service, `https://${p.hostname}`]));
|
|
180
|
+
}
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
A Mesh app for tenant `{{tenant}}`. The Pulumi program is `index.ts`; run it
|
|
4
4
|
locally with `mesh dev`, deploy it with `mesh deploy up`.
|
|
5
|
+
|
|
6
|
+
On a machine set up with `mesh workstation init`, `mesh dev` serves each service
|
|
7
|
+
at its own HTTPS name; `route: false` on a Service leaves it out.
|
|
5
8
|
{{#if service}}
|
|
6
9
|
|
|
7
10
|
The API's docs are `api/openapi.json` and the guides in `docs/pages/`, arranged
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mesh-tech/mesh-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.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.7.0",
|
|
45
|
-
"@mesh-tech/tsconfig": "2.
|
|
45
|
+
"@mesh-tech/tsconfig": "2.63.0",
|
|
46
46
|
"@mesh-tech/workflow-viz": "0.3.4",
|
|
47
47
|
"@tanstack/intent": "^0.3.6",
|
|
48
48
|
"@types/node": "^20.0.0",
|
package/skills/mesh/SKILL.md
CHANGED
|
@@ -175,7 +175,8 @@ Top-level commands (each `register*` is wired in `src/program.ts`, which `bin/me
|
|
|
175
175
|
| `mesh registry` | `login [context]`, `logout`, `status`, `publish` | CodeArtifact auth → refreshes `~/.npmrc` token (12h) + adds `@mesh-tech` scope to project `.npmrc` + **strips an unscoped CodeArtifact `registry=` line from `~/.npmrc`** (after writing `~/.npmrc.bak-<ts>`) — that line is what a raw CodeArtifact sign-in *without* `--namespace @mesh-tech` leaves behind, and it makes CodeArtifact your DEFAULT registry so every *public* install 401s at the 12h expiry. Skipped under `--ci`, where the unscoped login is deliberate. `status` probes the registry rather than grepping the file: expired/missing token or a hijack exits 1, an unreachable registry warns and exits 0. **Step 0 is the Zitadel-gated broker:** the registry is ONE global identity (`utils/registry-identity.ts`, seeded from the compiled-in first-party alias table, session cached under the reserved key `registry`), so no context is needed — `mesh registry login` signs in to the registry's issuer if nothing is cached (browser; `--device` when headless; reuses a `mesh.dev` session on the same issuer) and POSTs the id token to the platform's `registry-auth` service, which returns a CodeArtifact read token minted with its OWN AWS identity — so this path needs no AWS account, CLI or profile at all. It writes the same two `~/.npmrc` lines the AWS path does. Falls back to the AWS chain when the platform publishes no broker, when the broker is unreachable, or with `--no-broker`; a 403 (a valid Mesh account without the `registry:read` role) is fatal and does **not** fall through. `--publish` and `--profile` deliberately skip the broker — both are AWS-identity paths. `--profile mesh-dev` auto-runs the AWS SSO login when the session is stale. `mesh login` never touches the registry (it prints a one-line pointer to `mesh registry login` when the machine has no registry access); `logout` clears the registry session only. A `[context]` on the read path is ignored with a notice; it still selects the SSM export for `--publish`. `login --publish` assumes the registry's publisher role (from `--role` or the registry SSM export); `publish` pushes package snapshots. | `login`: `--device`, `--no-broker`, `--profile <sso>`, `--role <arn>`, `--ci`, `--skip-npmrc`, `--publish`; `publish`: `--snapshot` |
|
|
176
176
|
| `mesh db` | `connect`, `credentials`(alias `creds`), `env`, `psql`, `exec <command...>` | Access tenant DBs through an SSM bastion tunnel. `env` prints `export`s for `eval`; `exec` runs a command with `DATABASE_URL` injected. Outside an app directory pass `-t` and `-e` together — detection fails closed. | `-t/--tenant`, `-e/--env`, `-a/--app`, `--app-tenant`, `--app-stage`; `exec` also: `--secret <arn>`, `--ssl <require\|no-verify\|disable>`, `--port <n>` |
|
|
177
177
|
| `mesh deploy ...` | passthrough | Runs `pulumi` with the stack's `mesh:deployerRole` assumed. Unknown opts/args pass through; defaults to `up` if no subcommand. | `-s/--stack <name>` (stripped before pulumi `up`); everything else → pulumi (`preview`, `up --yes`, `destroy`, `stack output …`) |
|
|
178
|
-
| `mesh dev` | `logs <service>`, `restart <service>`, `list`, `test-user [name]` | Two modes. **Tethered** (deployed stack): reads the Pulumi stack dev output, opens tunnels, injects Secrets Manager secrets; needs VPN/AWS creds. **Local** (`--local`, auto when the app has no `Pulumi.yaml`): synthesizes services from the app layout (subdirs with a `dev` script, plus a synthesized `dg dev` webserver when the app root is a Dagster workspace — `dg.toml`, or `[tool.dg]` in `pyproject.toml`; such an app runs with zero package.json services — see `docs/guides/local-dagster.md`) and wires them to the `mesh start` platform (Temporal `local-dev`, app DB, Zitadel, ministack via `AWS_ENDPOINT_URL`) — zero AWS/VPN/Pulumi. `--externals` (local mode; `--mock` is an alias) also realizes the app's declared external services (package.json → `mesh.externals`; legacy `mesh.mocks` still read). Each declaration runs in one of three MODES — `mock` (the service is emulated: `openapi` → a Prism mock server from a spec, or `src` → a mock process), `local` (a local version of the real service: a `compose` file + published `port`, e.g. a vendor-shaped DB — up `--wait` before the app's services; a realization another checkout already started is **adopted** instead of failing — declared port already served **by a container this compose file defines** → adopt as-is (a publisher the file doesn't define — a native install or unrelated container — is a hard **conflict**, never adopted, since credentials would seed against the wrong service); a stopped foreign container holding the compose `container_name` → `docker start` + adopt — so startup order across checkouts doesn't matter; adopted externals stay out of session state and `--kill` tears down only externals this session created, volumes kept), `remote` (connect to the ACTUAL service: inline vendor `credentials`, or the credentials the app's tenant environment is configured with via `remote.env`). A declaration may carry SEVERAL realizations; `mode` picks the default and `--externals name=mode` overrides it per run (`--externals plaid-db=remote,plaid`). Remote externals get a blackbox uptime probe against their real endpoint. Failing to realize a remote external is fatal only when you named it; a bare `--externals` warns and keeps going, so the local loop still works with no AWS identity. Every realization seeds its ExternalService credential secret (`{{url}}`/`{{host}}`/`{{port}}` from the stand-in's endpoint, `{{env:VAR}}` from the shell) so `resolveCredentials()` runs the deployed code path against the stand-in. Runners: tmux (default) or `--runner docker` (CI/headless — services run as a docker compose project with host networking; parity `--status`/`--kill`/`logs`/`restart`). | root: `--local`, `--externals [name[=mode],…]` (alias `--mock`), `--runner <tmux\|docker>`, `--app <path>`, `--stage`, `--headless`, `--kill`, `--status`, `--json`, `--session <name>`, `--profile <sso>`; `logs`: `--tail <n>`; `test-user`: `--tenant/--env/--region` |
|
|
178
|
+
| `mesh dev` | `logs <service>`, `restart <service>`, `list`, `test-user [name]` | Two modes. **Tethered** (deployed stack): reads the Pulumi stack dev output, opens tunnels, injects Secrets Manager secrets; needs VPN/AWS creds. **Local** (`--local`, auto when the app has no `Pulumi.yaml`): synthesizes services from the app layout (subdirs with a `dev` script, plus a synthesized `dg dev` webserver when the app root is a Dagster workspace — `dg.toml`, or `[tool.dg]` in `pyproject.toml`; such an app runs with zero package.json services — see `docs/guides/local-dagster.md`) and wires them to the `mesh start` platform (Temporal `local-dev`, app DB, Zitadel, ministack via `AWS_ENDPOINT_URL`) — zero AWS/VPN/Pulumi. `--externals` (local mode; `--mock` is an alias) also realizes the app's declared external services (package.json → `mesh.externals`; legacy `mesh.mocks` still read). Each declaration runs in one of three MODES — `mock` (the service is emulated: `openapi` → a Prism mock server from a spec, or `src` → a mock process), `local` (a local version of the real service: a `compose` file + published `port`, e.g. a vendor-shaped DB — up `--wait` before the app's services; a realization another checkout already started is **adopted** instead of failing — declared port already served **by a container this compose file defines** → adopt as-is (a publisher the file doesn't define — a native install or unrelated container — is a hard **conflict**, never adopted, since credentials would seed against the wrong service); a stopped foreign container holding the compose `container_name` → `docker start` + adopt — so startup order across checkouts doesn't matter; adopted externals stay out of session state and `--kill` tears down only externals this session created, volumes kept), `remote` (connect to the ACTUAL service: inline vendor `credentials`, or the credentials the app's tenant environment is configured with via `remote.env`). A declaration may carry SEVERAL realizations; `mode` picks the default and `--externals name=mode` overrides it per run (`--externals plaid-db=remote,plaid`). Remote externals get a blackbox uptime probe against their real endpoint. Failing to realize a remote external is fatal only when you named it; a bare `--externals` warns and keeps going, so the local loop still works with no AWS identity. Every realization seeds its ExternalService credential secret (`{{url}}`/`{{host}}`/`{{port}}` from the stand-in's endpoint, `{{env:VAR}}` from the shell) so `resolveCredentials()` runs the deployed code path against the stand-in. Runners: tmux (default) or `--runner docker` (CI/headless — services run as a docker compose project with host networking; parity `--status`/`--kill`/`logs`/`restart`). **On a workstation** (a machine set up with `mesh workstation init`), each service with a command and a port is also served at `https://<app>-<service>[-<worktree>].<machine domain>` through the machine's proxy, gets that URL as `MESH_DEV_PUBLIC_URL`, and loses its route on `--kill`; `--route-suffix` picks the worktree suffix, and a Service's `route: false` leaves it out. At launch it warns when the machine's certificate renewal will fail (no credentials from the renewal profile, or fewer than 28 days left). | root: `--local`, `--externals [name[=mode],…]` (alias `--mock`), `--runner <tmux\|docker>`, `--app <path>`, `--stage`, `--headless`, `--kill`, `--status`, `--json`, `--session <name>`, `--profile <sso>`, `--route-suffix <name>`; `logs`: `--tail <n>`; `test-user`: `--tenant/--env/--region` |
|
|
179
|
+
| `mesh workstation` | `init`, `proxy`, `status`, `routes` | Opt-in HTTPS names on a developer's own machine for the services it runs: nothing happens without `~/.config/mesh/workstation/workstation.json`. `init` makes the machine a workstation — given `--name` and `--platform`, it discovers the Route 53 zone, region and certificate role from the public TXT record `_mesh-workstation.<name>.<platform>`, writes its own renewal profile, reuses or downloads a Caddy with the Route 53 DNS module, and signs in once. `proxy` runs that Caddy in the foreground (a wildcard certificate by ACME DNS-01, every route file imported, only tailnet and loopback clients accepted); `--install` runs it as a macOS LaunchAgent, `--uninstall` removes it, `--print` shows the Caddyfile and command. `status` checks the proxy, the certificate and the renewal credentials. `routes` lists the names served; `--prune` drops routes of `mesh dev` sessions that are gone. | `init`: `--name <host>`, `--platform <domain>` (or `--domain`/`--zone-id`/`--region`), `--aws-profile`, `--aws-config-file`, `--caddy <path>`, `--cli <path>`, `--force`; `proxy`: `--install`, `--uninstall`, `--print`; `routes`: `--prune`, `--json` |
|
|
179
180
|
| `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` |
|
|
180
181
|
| `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-<api-port>`, so several local Hubs coexist once each is given an API port of its own (both ports default the same in every worktree). | `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` (or `MESH_HUB_DEV_API_PORT`, default 3002; also names the tmux session), `--platform-dir <dir>` (or `MESH_PLATFORM_DIR`), `--print-env`, `--kill` |
|
|
181
182
|
| `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,events,email`), `--template <workflow\|api-auth\|api-role-gating\|external-service>`, `--test` (writes to `tests/tenants/`) |
|
|
@@ -194,7 +195,7 @@ Top-level commands (each `register*` is wired in `src/program.ts`, which `bin/me
|
|
|
194
195
|
| `mesh workflow` | `extract-ir <path>`, `inventory <path>`, `lint-process <path>` | `extract-ir`: parse Temporal workflow TS source → WorkflowIR graph (nodes+edges); prints JSON, uploads to S3, or writes the same files to a directory (so a package can ship IR it extracted at build time). A directory's `*.test.ts`/`*.spec.ts`/`*.d.ts` files are skipped. `inventory`: the as-built `{workflowType, commands, queries, activities}` the process lint diffs against. `lint-process`: parse + conformance-lint a process artifact against that inventory; exits 1 on any error-severity finding. | `extract-ir`: `--app <name>`, `--upload <bucket>`, `--out <dir>` (`--upload` and `--out` each need `--app`); `extract-ir`/`lint-process`: `--process <path>` (else a sibling `process/*.process.json` is discovered); `lint-process`: `--predicates <csv>`, `--selectors <csv>` — the CLI cannot execute the worker's registries, so without a flag those findings are skipped (a note is printed) while every other rule still runs |
|
|
195
196
|
| `mesh site` | `publish <name> <dir>`, `versions <name>`, `rollback <name> <version>` | Publish a built directory as an OAuth-protected static site served by Studio, content-addressed and incremental: `publish` walks and hashes the tree, uploads only the blobs the site does not already hold, then commits a version and points the site at it; `versions` lists what has been published, newest first; `rollback` points the site back at an earlier version. Three stateless calls against the agent-api, so a run that dies partway leaves nothing to clean up. | all: `--target <name>`, `--api-url <url>`, `--context <ctx>`; `publish`: `--version-id <id>`, `--no-activate` (publish without pointing the site at it) |
|
|
196
197
|
| `mesh vcs` | `clone <repo> [dest]`, `get <repo> <path>`, `propose`, `rm <repo> <paths...>`, `proposals`/`show`/`diff`/`approve`/`reject`/`comment`/`request-changes`, `drafts <list\|show\|create\|submit\|discard>` | Versioned content repos (`mesh.vcs`). Repos are real git repos over smart HTTP but **read-only** — every change goes through a proposal. `clone` wires the bearer auth header into the clone; `propose` submits the working tree (binary content is base64-encoded from the bytes, not the extension, so images survive intact); **`rm` proposes deletions of files or folders with no clone at all**, and `--dry-run` lists what would go without proposing anything. | all: `--url <base>`, `--token <t>`, `--context <ctx>`; `propose`: `-m/--message`, `--revise <id>`, `--merge-parent <sha>`; `rm`: `-m/--message`, `--dry-run` |
|
|
197
|
-
| `mesh temporal` | `describe <wfId> [runId]`, `history <wfId> [runId]`, `recover-conversation <wfId> [runId]`, `capture-history <wfId> [runId]` | Inspect Temporal workflows for the current app/stack; reconstruct an agent conversation transcript from durable history when the worker can't replay it; capture a full history to a local replay fixture. | shared: `--stack`, `--address`, `--namespace`; `history`: `-n/--limit <200>`, `-f/--follow`, `--no-compact`, `-p/--show-payloads` (decrypts via `TEMPORAL_ENCODING_KEY` from the K8s secret); `recover-conversation`: `--out <path>`, `--json`, `--snapshot` (structured `{conversationId,messages,artifacts,focus}` blob); `capture-history`: `--out <path>` (default `~/.mesh/replay-histories/<wfId>.json`, do not commit) | <!-- skill-lint-disable-next-line — row documents the mesh-owned kubectl wrappers; the backticked `aws eks get-token` describes what the CLI runs internally, not an operator step -->
|
|
198
|
+
| `mesh temporal` | `list`, `describe <wfId> [runId]`, `history <wfId> [runId]`, `recover-conversation <wfId> [runId]`, `capture-history <wfId> [runId]` | Inspect Temporal workflows for the current app/stack: `list` finds them through visibility (by type, status, parent, or any visibility query), and `history` names each event from the Temporal SDK's own event enum; reconstruct an agent conversation transcript from durable history when the worker can't replay it; capture a full history to a local replay fixture. | shared: `--stack`, `--address`, `--namespace`; `list`: `--type <workflowType>`, `--status <status>`, `--parent <workflowId>`, `--query <visibilityQuery>` (AND-ed with the flags), `-n/--limit <20>`, `--json`; `history`: `-n/--limit <200>`, `-f/--follow`, `--no-compact`, `-p/--show-payloads` (decrypts via `TEMPORAL_ENCODING_KEY` from the K8s secret); `recover-conversation`: `--out <path>`, `--json`, `--snapshot` (structured `{conversationId,messages,artifacts,focus}` blob); `capture-history`: `--out <path>` (default `~/.mesh/replay-histories/<wfId>.json`, do not commit) | <!-- skill-lint-disable-next-line — row documents the mesh-owned kubectl wrappers; the backticked `aws eks get-token` describes what the CLI runs internally, not an operator step -->
|
|
198
199
|
| `mesh kubectl` / `mesh logs` / `mesh exec` | `kubectl [args…]`, `logs [service\|deployment/x\|k=v]`, `exec <service> -- <cmd>` | Cluster access for a **deployed** app — debug CrashLoops/logs without broader creds. Assumes the app's `mesh:deployerRole`, builds a session kubeconfig from the platform `eks` SSM export (`aws eks get-token` — no `eks:DescribeCluster` / `~/.kube/config` needed), shells out to kubectl. Defaults the namespace to the app's own (deployer RBAC is tenant-namespace-scoped). **Use `--stack <name>` to target the deployed stack (e.g. `dev`, not `dev-local`).** | `--stack <name>`; `logs`: `-f/--follow`, `--tail <200>`, `-c/--container`, `--previous`; `exec`: `-c/--container` |
|
|
199
200
|
|
|
200
201
|
## Core workflows
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Run the **same plain `mesh dev`** from each worktree's app dir — there is nothing special to
|
|
4
4
|
configure. `mesh dev` derives a deterministic token from the worktree path and threads it through
|
|
5
|
-
the tmux session name, the tmpdir session-state + env-file dir and the Temporal task-queue suffix
|
|
5
|
+
the tmux session name, the tmpdir session-state + env-file dir and the Temporal task-queue suffix —
|
|
6
|
+
and, on a workstation (`mesh workstation init`), the HTTPS route names, which become
|
|
7
|
+
`<app>-<service>-<worktree>` (the worktree's slug without its branch-type prefix or trailing hash;
|
|
8
|
+
`--route-suffix <name>` overrides it; the primary checkout gets no suffix) — so
|
|
6
9
|
two worktrees don't kill each other's session — as long as their directory basenames differ, since the
|
|
7
10
|
session name carries the worktree's sanitized basename, not the hash. The reserved **service-port
|
|
8
11
|
block is derived from (worktree, app)**, not the worktree alone — a repo can hold several apps whose
|