@tablation/crew 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -211,7 +211,7 @@ crew pause|resume [route] [R] pause everything, or one role
|
|
|
211
211
|
crew log [route] tail the log
|
|
212
212
|
crew inbox [--member NAME] your tickets across every workspace
|
|
213
213
|
crew connect WS[/PROJECT] resolve a workspace/project's ids into the state tree
|
|
214
|
-
crew repos add ROUTE PATH attach a local checkout to a route in crew.yaml (--dry-run previews)
|
|
214
|
+
crew repos add ROUTE PATH attach a local checkout to a route in crew.yaml, creating its Repos row if missing (--dry-run previews)
|
|
215
215
|
crew repos list ROUTE the checkouts a route has, and the tracker Repos row each matches
|
|
216
216
|
crew install write and load this platform's scheduler unit
|
|
217
217
|
crew uninstall unload and remove it
|
package/dist/cli.js
CHANGED
|
@@ -576,7 +576,9 @@ var SHIP_KEYS = /* @__PURE__ */ new Set([
|
|
|
576
576
|
"streamRetentionDays",
|
|
577
577
|
"reposBasePath",
|
|
578
578
|
"relayHost",
|
|
579
|
-
"relayPort"
|
|
579
|
+
"relayPort",
|
|
580
|
+
"relayHttpPort",
|
|
581
|
+
"relayPublicDomain"
|
|
580
582
|
]);
|
|
581
583
|
var ROUTE_KEYS = /* @__PURE__ */ new Set([
|
|
582
584
|
"route",
|
|
@@ -907,8 +909,10 @@ Create the first of those:
|
|
|
907
909
|
maxConcurrentAgents,
|
|
908
910
|
streamRetentionDays,
|
|
909
911
|
relayHost: shipRaw.relayHost ? String(shipRaw.relayHost) : void 0,
|
|
910
|
-
// 2222 matches the relay's own RELAY_SSH_PORT default (
|
|
911
|
-
relayPort: shipRaw.relayPort !== void 0 ? Number(shipRaw.relayPort) : 2222
|
|
912
|
+
// 2222 matches the relay's own RELAY_SSH_PORT default (crew-relay README).
|
|
913
|
+
relayPort: shipRaw.relayPort !== void 0 ? Number(shipRaw.relayPort) : 2222,
|
|
914
|
+
relayHttpPort: shipRaw.relayHttpPort !== void 0 ? Number(shipRaw.relayHttpPort) : 443,
|
|
915
|
+
relayPublicDomain: shipRaw.relayPublicDomain ? String(shipRaw.relayPublicDomain) : void 0
|
|
912
916
|
},
|
|
913
917
|
routes,
|
|
914
918
|
crewHome,
|
|
@@ -1868,6 +1872,18 @@ var Tracker = class {
|
|
|
1868
1872
|
return void 0;
|
|
1869
1873
|
}
|
|
1870
1874
|
}
|
|
1875
|
+
/** Every `Projects` (area) row — what `crew repos add` offers when a new Repos row needs one. */
|
|
1876
|
+
async projectRows() {
|
|
1877
|
+
const model = this.route.resolved?.areaModelId;
|
|
1878
|
+
if (!model) return [];
|
|
1879
|
+
return this.client.records.list(model, { limit: 200 });
|
|
1880
|
+
}
|
|
1881
|
+
/** Create a `Repos` row. Throws when the workspace has no Repos table. */
|
|
1882
|
+
async createRepoRow(fields) {
|
|
1883
|
+
const model = this.route.resolved?.reposModelId;
|
|
1884
|
+
if (!model) throw new Error("this workspace has no Repos table");
|
|
1885
|
+
return this.client.records.create(model, fields);
|
|
1886
|
+
}
|
|
1871
1887
|
/**
|
|
1872
1888
|
* The Ships rows of this workspace, if it has a Ships table.
|
|
1873
1889
|
*
|
|
@@ -1944,7 +1960,7 @@ var Tracker = class {
|
|
|
1944
1960
|
}
|
|
1945
1961
|
/**
|
|
1946
1962
|
* Writes this ship's own tunnel state for Host Passengers (ISSUE-553):
|
|
1947
|
-
* `tunnel_status` (disconnected/connecting/connected)
|
|
1963
|
+
* `tunnel_status` (disconnected/connecting/connected), as
|
|
1948
1964
|
* the tunnel client (`tunnel.ts`) brings a workspace's container's tunnel
|
|
1949
1965
|
* up or down. A no-op, like every other `myShipRow`-backed write here, on
|
|
1950
1966
|
* a workspace with no Ships table or no row for this ship's name — the
|
|
@@ -2838,12 +2854,36 @@ function remoteBranchExists(cwd, remote, branch) {
|
|
|
2838
2854
|
return gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/remotes/${remote}/${branch}`]) !== null;
|
|
2839
2855
|
}
|
|
2840
2856
|
function findKeyInRange(cwd, key, range) {
|
|
2857
|
+
return keyCommitsInRange(cwd, key, range)[0] ?? null;
|
|
2858
|
+
}
|
|
2859
|
+
function keyCommitsInRange(cwd, key, range) {
|
|
2841
2860
|
const keys = Array.isArray(key) ? key : [key];
|
|
2842
2861
|
const escaped = keys.map((k) => k.replace(/[.[\]{}()*+?^$|\\]/g, "\\$&"));
|
|
2843
2862
|
const pattern = `(^|[^0-9A-Za-z_-])(${escaped.join("|")})([^0-9]|$)`;
|
|
2844
2863
|
const out = gitOk(cwd, ["log", range, "--format=%H %s", "--extended-regexp", `--grep=${pattern}`]);
|
|
2845
|
-
if (!out) return
|
|
2846
|
-
return out.split("\n")
|
|
2864
|
+
if (!out) return [];
|
|
2865
|
+
return out.split("\n").map((l) => l.split(" ")[0]).filter(Boolean);
|
|
2866
|
+
}
|
|
2867
|
+
function patchIdOf(cwd, from, to) {
|
|
2868
|
+
try {
|
|
2869
|
+
const diff = execFileSync("git", ["diff", from, to], { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
|
2870
|
+
if (!diff) return null;
|
|
2871
|
+
const out = execFileSync("git", ["patch-id", "--stable"], { cwd, encoding: "utf8", input: diff });
|
|
2872
|
+
return out.split(" ")[0]?.trim() || null;
|
|
2873
|
+
} catch {
|
|
2874
|
+
return null;
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
function findSquashOfBranch(cwd, key, branch, base, range) {
|
|
2878
|
+
const candidates = keyCommitsInRange(cwd, key, range);
|
|
2879
|
+
if (candidates.length === 0) return null;
|
|
2880
|
+
const mb = gitOk(cwd, ["merge-base", base, branch]);
|
|
2881
|
+
const branchPatch = mb ? patchIdOf(cwd, mb, branch) : null;
|
|
2882
|
+
for (const sha of candidates) {
|
|
2883
|
+
if (gitOk(cwd, ["diff", "--quiet", branch, sha]) !== null) return sha;
|
|
2884
|
+
if (branchPatch && gitOk(cwd, ["rev-parse", "--verify", "-q", `${sha}^`]) !== null && patchIdOf(cwd, `${sha}^`, sha) === branchPatch) return sha;
|
|
2885
|
+
}
|
|
2886
|
+
return null;
|
|
2847
2887
|
}
|
|
2848
2888
|
function isAncestor(cwd, ancestor, descendant) {
|
|
2849
2889
|
return gitOk(cwd, ["merge-base", "--is-ancestor", ancestor, descendant]) !== null;
|
|
@@ -3656,6 +3696,31 @@ function renderEnvironment(i) {
|
|
|
3656
3696
|
...epicsModelId ? [`- Epics: \`${epicsModelId}\``] : []
|
|
3657
3697
|
);
|
|
3658
3698
|
}
|
|
3699
|
+
const workspaceId = route.resolved?.workspaceId;
|
|
3700
|
+
if (workspaceId) {
|
|
3701
|
+
const base = route.baseUrl.replace(/\/+$/, "");
|
|
3702
|
+
lines.push(
|
|
3703
|
+
"",
|
|
3704
|
+
`- Workspace id: \`${workspaceId}\``,
|
|
3705
|
+
"",
|
|
3706
|
+
"## Attaching a file to a comment",
|
|
3707
|
+
"",
|
|
3708
|
+
"A screenshot or mockup goes on a comment's `attachments` field, which holds",
|
|
3709
|
+
"Media Library record ids, not URLs. Upload the file first, then put the",
|
|
3710
|
+
"returned `id` in the comment's `attachments` array:",
|
|
3711
|
+
"",
|
|
3712
|
+
"```sh",
|
|
3713
|
+
`curl -s -H "Authorization: Bearer $${API_KEY_VAR}" -H "User-Agent: ${i.userAgent}" \\`,
|
|
3714
|
+
` -X POST "${base}/api/workspaces/${workspaceId}/files" \\`,
|
|
3715
|
+
' -F "file=@shot.png;type=image/png" -F targetMediaLibrary=true',
|
|
3716
|
+
"```",
|
|
3717
|
+
"",
|
|
3718
|
+
"The response is JSON with `id` and `storageKey`. `targetMediaLibrary=true`",
|
|
3719
|
+
"means no field type id has to be looked up. If the upload fails, post the HTTP",
|
|
3720
|
+
"status and response body in your comment \u2014 do not write that uploads are",
|
|
3721
|
+
"impossible."
|
|
3722
|
+
);
|
|
3723
|
+
}
|
|
3659
3724
|
lines.push(
|
|
3660
3725
|
"",
|
|
3661
3726
|
"## What the columns are called here",
|
|
@@ -4763,6 +4828,25 @@ function planMerge(cwd, tickets, contract, lastReleased, branchFor = (t) => bran
|
|
|
4763
4828
|
skipReason: onMain ? "already-merged" : "never-built"
|
|
4764
4829
|
};
|
|
4765
4830
|
}
|
|
4831
|
+
const squash = findSquashOfBranch(
|
|
4832
|
+
cwd,
|
|
4833
|
+
referenceKeys(ticket),
|
|
4834
|
+
branch,
|
|
4835
|
+
base,
|
|
4836
|
+
lastReleased ? `${lastReleased}..${base}` : base
|
|
4837
|
+
);
|
|
4838
|
+
if (squash) {
|
|
4839
|
+
return {
|
|
4840
|
+
ticket,
|
|
4841
|
+
branch,
|
|
4842
|
+
entries: [],
|
|
4843
|
+
usedFallback: false,
|
|
4844
|
+
bump: "patch",
|
|
4845
|
+
majorRequested: false,
|
|
4846
|
+
skipReason: "already-merged",
|
|
4847
|
+
mergedSha: squash
|
|
4848
|
+
};
|
|
4849
|
+
}
|
|
4766
4850
|
const bodies = commitBodies(cwd, `${base}..${branch}`);
|
|
4767
4851
|
const entries = [...bodies.matchAll(CHANGELOG_LINE)].map((m) => m[1].trim()).filter(Boolean);
|
|
4768
4852
|
const usedFallback = entries.length === 0;
|
|
@@ -4904,6 +4988,8 @@ function summarizeOutcome(o, scope, hadTestHook) {
|
|
|
4904
4988
|
case "nothing merged and no release marker":
|
|
4905
4989
|
case "already contained in the base branch":
|
|
4906
4990
|
return { scope, tests: "skipped", outcome: "nothing", detail: o.stopped };
|
|
4991
|
+
case "setup failed":
|
|
4992
|
+
return { scope, tests: "skipped", outcome: "nothing", detail: "setup failed \u2014 not tested or deployed" };
|
|
4907
4993
|
case "build failed":
|
|
4908
4994
|
return { scope, tests, outcome: "build-failed", detail: "build failed \u2014 not deployed" };
|
|
4909
4995
|
case "deploy failed":
|
|
@@ -5203,9 +5289,12 @@ async function runRelease(o) {
|
|
|
5203
5289
|
const unbuildable = [];
|
|
5204
5290
|
let applied = 0;
|
|
5205
5291
|
for (const c of decision.merges) {
|
|
5206
|
-
if (!c.branch) {
|
|
5292
|
+
if (!c.branch || c.mergedSha) {
|
|
5207
5293
|
if (c.skipReason === "already-merged") {
|
|
5208
|
-
o.emit.emit(
|
|
5294
|
+
o.emit.emit(
|
|
5295
|
+
`verified and already merged${c.mergedSha ? ` (on the base as ${c.mergedSha.slice(0, 7)})` : ""}, waiting on a successful release`,
|
|
5296
|
+
{ ticket: c.ticket.issue_id }
|
|
5297
|
+
);
|
|
5209
5298
|
} else {
|
|
5210
5299
|
o.emit.emit("verified but has no branch and nothing on the base names it \u2014 nothing to merge", { ticket: c.ticket.issue_id });
|
|
5211
5300
|
unbuildable.push(c);
|
|
@@ -5262,7 +5351,19 @@ async function runRelease(o) {
|
|
|
5262
5351
|
o.emit.emit(
|
|
5263
5352
|
`${hookLabel(o.repo, "test")} skipped \u2014 release.mode is integrate with no deploy hook, so this "crew deploy" run has nothing to ship`
|
|
5264
5353
|
);
|
|
5265
|
-
}
|
|
5354
|
+
}
|
|
5355
|
+
const gatesNeedSetup = !o.skipTests && o.repo.hooks.test && !(o.isDeployCommand && nothingToDeploy) || o.repo.hooks.build;
|
|
5356
|
+
if (o.repo.hooks.setup && gatesNeedSetup) {
|
|
5357
|
+
if (o.dryRun) o.emit.emit(`would run setup: ${hookLabel(o.repo, "setup")}`);
|
|
5358
|
+
else {
|
|
5359
|
+
const r = await hook(o, "setup");
|
|
5360
|
+
if (r && r.code !== 0) {
|
|
5361
|
+
o.emit.error(`setup FAILED (exit ${r.code}) \u2014 not testing or deploying; the target stays on the previous release`);
|
|
5362
|
+
return { merged, conflicts, unbuildable, deployed: false, stopped: "setup failed", decision };
|
|
5363
|
+
}
|
|
5364
|
+
}
|
|
5365
|
+
}
|
|
5366
|
+
if (!(o.isDeployCommand && nothingToDeploy) && !o.skipTests && o.repo.hooks.test) {
|
|
5266
5367
|
if (o.dryRun) o.emit.emit(`would run the test gate: ${hookLabel(o.repo, "test")}`);
|
|
5267
5368
|
else {
|
|
5268
5369
|
let r = await hook(o, "test");
|
|
@@ -5281,6 +5382,7 @@ async function runRelease(o) {
|
|
|
5281
5382
|
if (majorRequested) {
|
|
5282
5383
|
o.emit.warn("a branch asked for a MAJOR bump \u2014 honoured as minor; major is the operator's call");
|
|
5283
5384
|
}
|
|
5385
|
+
const preBump = headSha(o.cwd);
|
|
5284
5386
|
const version = await bump(o, size) ?? void 0;
|
|
5285
5387
|
if (!version && o.dryRun && o.repo.hooks.bump) {
|
|
5286
5388
|
o.emit.emit("would then commit the version bump and changelog");
|
|
@@ -5303,6 +5405,10 @@ async function runRelease(o) {
|
|
|
5303
5405
|
}
|
|
5304
5406
|
if (r && r.code !== 0) {
|
|
5305
5407
|
o.emit.error("build failed \u2014 not deploying");
|
|
5408
|
+
if (version && !o.dryRun && headSha(o.cwd) !== preBump) {
|
|
5409
|
+
git(o.cwd, ["reset", "--hard", preBump]);
|
|
5410
|
+
o.emit.warn(`dropped the unreleased version commit for ${version} (reset ${o.repo.branch.base} to ${preBump.slice(0, 8)}) so a retry starts clean`);
|
|
5411
|
+
}
|
|
5306
5412
|
return { merged, conflicts, unbuildable, version, deployed: false, stopped: "build failed", decision };
|
|
5307
5413
|
}
|
|
5308
5414
|
}
|
|
@@ -5519,6 +5625,13 @@ function describeRelease(o, route) {
|
|
|
5519
5625
|
detail: "the target stays on the previous release"
|
|
5520
5626
|
};
|
|
5521
5627
|
}
|
|
5628
|
+
if (o.stopped === "setup failed") {
|
|
5629
|
+
return {
|
|
5630
|
+
level: "fail",
|
|
5631
|
+
headline: `${route}: release blocked \u2014 setup failed`,
|
|
5632
|
+
detail: "dependencies could not be installed in the release checkout; the target stays on the previous release"
|
|
5633
|
+
};
|
|
5634
|
+
}
|
|
5522
5635
|
if (o.stopped === "deploy failed") {
|
|
5523
5636
|
return { level: "fail", headline: `${route}: deploy FAILED`, detail: "nothing new is live" };
|
|
5524
5637
|
}
|
|
@@ -6912,7 +7025,7 @@ function planRepoAdd(i) {
|
|
|
6912
7025
|
throw new ReposError(`${target} is already configured on route ${i.route}`);
|
|
6913
7026
|
}
|
|
6914
7027
|
const matched = matchRepoName(i.origin, i.resolved);
|
|
6915
|
-
const name = matched ?? basename5(target);
|
|
7028
|
+
const name = matched ?? i.name ?? basename5(target);
|
|
6916
7029
|
let warning;
|
|
6917
7030
|
if (!matched) {
|
|
6918
7031
|
const known = Object.values(i.resolved?.repoNames ?? {});
|
|
@@ -6937,11 +7050,39 @@ function planRepoAdd(i) {
|
|
|
6937
7050
|
m.set("dir", target);
|
|
6938
7051
|
mode = "dir";
|
|
6939
7052
|
}
|
|
6940
|
-
return { text: String(doc), mode, name, block: renderBlock(m), warning };
|
|
7053
|
+
return { text: String(doc), mode, name, block: renderBlock(m), warning, unmatched: !matched };
|
|
6941
7054
|
}
|
|
6942
7055
|
function renderBlock(m) {
|
|
6943
7056
|
return stringify2([m.toJSON()]).trimEnd();
|
|
6944
7057
|
}
|
|
7058
|
+
var LOCAL_RELEASE_MODES = /* @__PURE__ */ new Set(["integrate", "external"]);
|
|
7059
|
+
function planRepoRow(i) {
|
|
7060
|
+
const remote = normalizeRemote(i.origin);
|
|
7061
|
+
if (!remote) throw new ReposError(`cannot derive an owner/repo from origin "${i.origin}"`);
|
|
7062
|
+
const mode = i.spec?.releaseMode;
|
|
7063
|
+
return {
|
|
7064
|
+
name: i.name,
|
|
7065
|
+
remote,
|
|
7066
|
+
platform: i.spec?.platform ?? "unix",
|
|
7067
|
+
release_mode: mode ? LOCAL_RELEASE_MODES.has(mode) ? "local" : mode : "local",
|
|
7068
|
+
ci_provider: i.spec?.ciProvider ?? "none",
|
|
7069
|
+
enabled: true,
|
|
7070
|
+
project_id: i.projectId
|
|
7071
|
+
};
|
|
7072
|
+
}
|
|
7073
|
+
function resolveRepoProject(projects, opt, routeAreaId) {
|
|
7074
|
+
if (opt) {
|
|
7075
|
+
const want = opt.toLowerCase();
|
|
7076
|
+
const hit = projects.find((p) => p.id === opt || p.name?.toLowerCase() === want);
|
|
7077
|
+
if (!hit) throw new ReposError(`no Projects row "${opt}" (have: ${projects.map((p) => p.name ?? p.id).join(", ") || "none"})`);
|
|
7078
|
+
return hit.id;
|
|
7079
|
+
}
|
|
7080
|
+
if (routeAreaId) return routeAreaId;
|
|
7081
|
+
if (projects.length === 1) return projects[0].id;
|
|
7082
|
+
throw new ReposError(
|
|
7083
|
+
`cannot tell which project the new Repos row belongs to \u2014 pass --project <name> (have: ${projects.map((p) => p.name ?? p.id).join(", ") || "none"}), or --no-create to skip creating it`
|
|
7084
|
+
);
|
|
7085
|
+
}
|
|
6945
7086
|
|
|
6946
7087
|
// src/doctor-fix.ts
|
|
6947
7088
|
import { parseDocument as parseDocument2 } from "yaml";
|
|
@@ -7299,12 +7440,24 @@ function syncAllPassengerCheckouts(cfg) {
|
|
|
7299
7440
|
// src/tunnel.ts
|
|
7300
7441
|
import { spawn as spawn3 } from "child_process";
|
|
7301
7442
|
import { existsSync as existsSync16, mkdirSync as mkdirSync8, readFileSync as readFileSync10, writeFileSync as writeFileSync7, unlinkSync as unlinkSync3, readdirSync as readdirSync3 } from "fs";
|
|
7443
|
+
import { randomInt } from "crypto";
|
|
7302
7444
|
import { join as join15 } from "path";
|
|
7303
7445
|
var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
|
|
7304
7446
|
var defaultSpawn = (cmd, args) => spawn3(cmd, args, DEFAULT_SPAWN_OPTIONS);
|
|
7305
|
-
var
|
|
7447
|
+
var SLUG_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
7448
|
+
var SLUG_LENGTH = 12;
|
|
7449
|
+
function generateTunnelSlug() {
|
|
7450
|
+
let out = "";
|
|
7451
|
+
for (let i = 0; i < SLUG_LENGTH; i++) out += SLUG_ALPHABET[randomInt(SLUG_ALPHABET.length)];
|
|
7452
|
+
return out;
|
|
7453
|
+
}
|
|
7306
7454
|
function bindAddrFor(o) {
|
|
7307
|
-
|
|
7455
|
+
const base = `workspace-${o.workspaceId}-slug-${o.slug}-key-${o.apiKey}`;
|
|
7456
|
+
return o.mcpSecret ? `${base}-secret-${o.mcpSecret}` : base;
|
|
7457
|
+
}
|
|
7458
|
+
function publicUrlFor(slug, relayHost, relayHttpPort, relayPublicDomain) {
|
|
7459
|
+
const domain = relayPublicDomain ?? relayHost.replace(/^.*@/, "");
|
|
7460
|
+
return `https://${slug}.${domain}${relayHttpPort === 443 ? "" : `:${relayHttpPort}`}/mcp`;
|
|
7308
7461
|
}
|
|
7309
7462
|
function sshArgsFor(o) {
|
|
7310
7463
|
return [
|
|
@@ -7372,6 +7525,13 @@ function writePersistedTunnel(stateDir, state) {
|
|
|
7372
7525
|
writeFileSync7(path, `${JSON.stringify(state, null, 2)}
|
|
7373
7526
|
`);
|
|
7374
7527
|
}
|
|
7528
|
+
function updatePersistedTunnel(stateDir, workspaceId, patch2) {
|
|
7529
|
+
const existing = readPersistedTunnel(stateDir, workspaceId);
|
|
7530
|
+
if (existing) writePersistedTunnel(stateDir, { ...existing, ...patch2 });
|
|
7531
|
+
}
|
|
7532
|
+
function rotatePersistedSlug(stateDir, workspaceId) {
|
|
7533
|
+
updatePersistedTunnel(stateDir, workspaceId, { slug: generateTunnelSlug() });
|
|
7534
|
+
}
|
|
7375
7535
|
function removePersistedTunnel(stateDir, workspaceId) {
|
|
7376
7536
|
const path = tunnelStatePath(stateDir, workspaceId);
|
|
7377
7537
|
try {
|
|
@@ -7393,11 +7553,13 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
|
|
|
7393
7553
|
const existing = readPersistedTunnel(stateDir, plan.workspaceId);
|
|
7394
7554
|
const currentSecret = mcpSecrets.get(plan.workspaceId);
|
|
7395
7555
|
const secretUnchanged = (existing?.mcpSecret ?? void 0) === (currentSecret ?? void 0);
|
|
7396
|
-
|
|
7556
|
+
const slug = existing?.slug ?? generateTunnelSlug();
|
|
7557
|
+
const slugUnchanged = existing?.liveSlug === slug;
|
|
7558
|
+
if (existing && alive(existing.pid) && existing.mountsHash === plan.mountsHash && secretUnchanged && slugUnchanged) {
|
|
7397
7559
|
if (existing.status === "connecting") {
|
|
7398
7560
|
const settled = { ...existing, status: "connected" };
|
|
7399
7561
|
writePersistedTunnel(stateDir, settled);
|
|
7400
|
-
deps.onStatus?.(plan.workspaceId, "connected");
|
|
7562
|
+
deps.onStatus?.(plan.workspaceId, "connected", settled);
|
|
7401
7563
|
}
|
|
7402
7564
|
continue;
|
|
7403
7565
|
}
|
|
@@ -7407,11 +7569,18 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
|
|
|
7407
7569
|
} catch {
|
|
7408
7570
|
}
|
|
7409
7571
|
}
|
|
7572
|
+
const apiKey = deps.apiKeys?.get(plan.workspaceId);
|
|
7573
|
+
if (!apiKey) {
|
|
7574
|
+
deps.onStatus?.(plan.workspaceId, "disconnected", existing);
|
|
7575
|
+
continue;
|
|
7576
|
+
}
|
|
7410
7577
|
let settledStatus = "connecting";
|
|
7411
7578
|
const tunnel = startTunnel({
|
|
7412
7579
|
relayHost,
|
|
7413
7580
|
relayPort,
|
|
7414
7581
|
workspaceId: plan.workspaceId,
|
|
7582
|
+
slug,
|
|
7583
|
+
apiKey,
|
|
7415
7584
|
localPort: plan.port,
|
|
7416
7585
|
privateKeyPath,
|
|
7417
7586
|
mcpSecret: currentSecret,
|
|
@@ -7420,16 +7589,21 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
|
|
|
7420
7589
|
settledStatus = s;
|
|
7421
7590
|
}
|
|
7422
7591
|
});
|
|
7423
|
-
|
|
7592
|
+
const started = {
|
|
7424
7593
|
pid: tunnel.pid ?? -1,
|
|
7425
7594
|
containerName: plan.containerName,
|
|
7426
7595
|
workspaceId: plan.workspaceId,
|
|
7427
7596
|
mountsHash: plan.mountsHash,
|
|
7428
7597
|
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7429
7598
|
status: settledStatus,
|
|
7430
|
-
mcpSecret: currentSecret
|
|
7431
|
-
|
|
7432
|
-
|
|
7599
|
+
mcpSecret: currentSecret,
|
|
7600
|
+
slug,
|
|
7601
|
+
liveSlug: slug,
|
|
7602
|
+
endpointId: existing?.endpointId,
|
|
7603
|
+
registeredKey: existing?.registeredKey
|
|
7604
|
+
};
|
|
7605
|
+
writePersistedTunnel(stateDir, started);
|
|
7606
|
+
deps.onStatus?.(plan.workspaceId, settledStatus, started);
|
|
7433
7607
|
}
|
|
7434
7608
|
for (const workspaceId of readAllPersistedWorkspaceIds(stateDir)) {
|
|
7435
7609
|
if (plannedIds.has(workspaceId)) continue;
|
|
@@ -7441,7 +7615,7 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
|
|
|
7441
7615
|
}
|
|
7442
7616
|
}
|
|
7443
7617
|
removePersistedTunnel(stateDir, workspaceId);
|
|
7444
|
-
deps.onStatus?.(workspaceId, "disconnected");
|
|
7618
|
+
deps.onStatus?.(workspaceId, "disconnected", existing);
|
|
7445
7619
|
}
|
|
7446
7620
|
}
|
|
7447
7621
|
function readAllPersistedWorkspaceIds(stateDir) {
|
|
@@ -7450,6 +7624,119 @@ function readAllPersistedWorkspaceIds(stateDir) {
|
|
|
7450
7624
|
return readdirSync3(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
|
|
7451
7625
|
}
|
|
7452
7626
|
|
|
7627
|
+
// src/mcp-endpoint-registry.ts
|
|
7628
|
+
import { createHash as createHash5 } from "crypto";
|
|
7629
|
+
function endpointsUrl(api, workspaceId, id) {
|
|
7630
|
+
const base = `${api.baseUrl.replace(/\/+$/, "")}/api/workspaces/${workspaceId}/mcp-endpoints`;
|
|
7631
|
+
return id ? `${base}/${id}` : base;
|
|
7632
|
+
}
|
|
7633
|
+
async function call(api, method, url, body) {
|
|
7634
|
+
const f = api.fetchFn ?? fetch;
|
|
7635
|
+
return f(url, {
|
|
7636
|
+
method,
|
|
7637
|
+
headers: {
|
|
7638
|
+
Authorization: `Bearer ${api.apiKey}`,
|
|
7639
|
+
"User-Agent": api.userAgent ?? "Mozilla/5.0 CrewAgent",
|
|
7640
|
+
...body !== void 0 ? { "Content-Type": "application/json" } : {}
|
|
7641
|
+
},
|
|
7642
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
7643
|
+
});
|
|
7644
|
+
}
|
|
7645
|
+
function bodyFor(d, forPatch) {
|
|
7646
|
+
const body = { label: d.label, url: d.url };
|
|
7647
|
+
if (d.mcpSecret) body.authorizationHeader = `Bearer ${d.mcpSecret}`;
|
|
7648
|
+
else if (forPatch) body.authorizationHeader = null;
|
|
7649
|
+
return body;
|
|
7650
|
+
}
|
|
7651
|
+
async function create(api, workspaceId, d) {
|
|
7652
|
+
const res = await call(api, "POST", endpointsUrl(api, workspaceId), bodyFor(d, false));
|
|
7653
|
+
if (res.status === 403) return { kind: "forbidden" };
|
|
7654
|
+
if (!res.ok) return { kind: "error", message: `register endpoint: ${res.status} ${res.statusText}` };
|
|
7655
|
+
const row = await res.json();
|
|
7656
|
+
if (!row.id) return { kind: "error", message: "register endpoint: response carried no id" };
|
|
7657
|
+
return { kind: "ok", endpointId: row.id, action: "created" };
|
|
7658
|
+
}
|
|
7659
|
+
async function registerEndpoint(api, workspaceId, storedId, d) {
|
|
7660
|
+
try {
|
|
7661
|
+
if (!storedId) return await create(api, workspaceId, d);
|
|
7662
|
+
const res = await call(api, "PATCH", endpointsUrl(api, workspaceId, storedId), bodyFor(d, true));
|
|
7663
|
+
if (res.status === 403) return { kind: "forbidden" };
|
|
7664
|
+
if (res.status === 404) {
|
|
7665
|
+
const made = await create(api, workspaceId, d);
|
|
7666
|
+
return made.kind === "ok" ? { ...made, action: "recreated" } : made;
|
|
7667
|
+
}
|
|
7668
|
+
if (!res.ok) return { kind: "error", message: `update endpoint: ${res.status} ${res.statusText}` };
|
|
7669
|
+
return { kind: "ok", endpointId: storedId, action: "updated" };
|
|
7670
|
+
} catch (e) {
|
|
7671
|
+
return { kind: "error", message: e.message };
|
|
7672
|
+
}
|
|
7673
|
+
}
|
|
7674
|
+
async function removeEndpoint(api, workspaceId, storedId) {
|
|
7675
|
+
try {
|
|
7676
|
+
const res = await call(api, "DELETE", endpointsUrl(api, workspaceId, storedId));
|
|
7677
|
+
if (res.status === 403) return { kind: "forbidden" };
|
|
7678
|
+
if (res.ok || res.status === 404) return { kind: "ok" };
|
|
7679
|
+
return { kind: "error", message: `remove endpoint: ${res.status} ${res.statusText}` };
|
|
7680
|
+
} catch (e) {
|
|
7681
|
+
return { kind: "error", message: e.message };
|
|
7682
|
+
}
|
|
7683
|
+
}
|
|
7684
|
+
async function probeEndpointAccess(api, workspaceId) {
|
|
7685
|
+
try {
|
|
7686
|
+
const res = await call(api, "GET", endpointsUrl(api, workspaceId));
|
|
7687
|
+
if (res.ok) return "ok";
|
|
7688
|
+
if (res.status === 403) return "forbidden";
|
|
7689
|
+
return { error: `${res.status} ${res.statusText}` };
|
|
7690
|
+
} catch (e) {
|
|
7691
|
+
return { error: e.message };
|
|
7692
|
+
}
|
|
7693
|
+
}
|
|
7694
|
+
var REGISTER_MCP_ENDPOINTS_HINT = "give this key's role the REGISTER_MCP_ENDPOINTS capability, e.g. via the group that holds the Crew project role";
|
|
7695
|
+
function registrationKey(url, mcpSecret) {
|
|
7696
|
+
return createHash5("sha256").update(`${url}
|
|
7697
|
+
${mcpSecret ?? ""}`).digest("hex").slice(0, 16);
|
|
7698
|
+
}
|
|
7699
|
+
async function reconcileEndpoint(workspaceId, d) {
|
|
7700
|
+
const st = readPersistedTunnel(d.stateDir, workspaceId);
|
|
7701
|
+
if (!st || st.status !== "connected" || !st.slug) return;
|
|
7702
|
+
const url = d.urlFor(st.slug);
|
|
7703
|
+
const key = registrationKey(url, st.mcpSecret);
|
|
7704
|
+
if (st.endpointId && st.registeredKey === key) return;
|
|
7705
|
+
const api = d.apiFor(workspaceId);
|
|
7706
|
+
if (!api) {
|
|
7707
|
+
d.warn(`no API key known for workspace ${workspaceId} \u2014 cannot register its MCP endpoint`);
|
|
7708
|
+
return;
|
|
7709
|
+
}
|
|
7710
|
+
const out = await registerEndpoint(api, workspaceId, st.endpointId, {
|
|
7711
|
+
label: `Ship: ${d.shipName}`,
|
|
7712
|
+
url,
|
|
7713
|
+
mcpSecret: st.mcpSecret
|
|
7714
|
+
});
|
|
7715
|
+
if (out.kind === "ok") {
|
|
7716
|
+
updatePersistedTunnel(d.stateDir, workspaceId, { endpointId: out.endpointId, registeredKey: key });
|
|
7717
|
+
} else if (out.kind === "forbidden") {
|
|
7718
|
+
d.warn(`could not register this ship's MCP endpoint in workspace ${workspaceId}: 403 \u2014 ${REGISTER_MCP_ENDPOINTS_HINT}`);
|
|
7719
|
+
} else {
|
|
7720
|
+
d.warn(`could not register this ship's MCP endpoint in workspace ${workspaceId}: ${out.message}`);
|
|
7721
|
+
}
|
|
7722
|
+
}
|
|
7723
|
+
async function unregisterEndpoint(workspaceId, info, d) {
|
|
7724
|
+
if (!info?.endpointId) return;
|
|
7725
|
+
const api = d.apiFor(workspaceId);
|
|
7726
|
+
if (!api) {
|
|
7727
|
+
d.warn(`no API key known for workspace ${workspaceId} \u2014 cannot remove its MCP endpoint`);
|
|
7728
|
+
return;
|
|
7729
|
+
}
|
|
7730
|
+
const out = await removeEndpoint(api, workspaceId, info.endpointId);
|
|
7731
|
+
if (out.kind === "ok") {
|
|
7732
|
+
updatePersistedTunnel(d.stateDir, workspaceId, { endpointId: void 0, registeredKey: void 0 });
|
|
7733
|
+
} else {
|
|
7734
|
+
d.warn(
|
|
7735
|
+
`could not remove this ship's MCP endpoint in workspace ${workspaceId}: ` + (out.kind === "forbidden" ? `403 \u2014 ${REGISTER_MCP_ENDPOINTS_HINT}` : out.message)
|
|
7736
|
+
);
|
|
7737
|
+
}
|
|
7738
|
+
}
|
|
7739
|
+
|
|
7453
7740
|
// src/passenger-sync-daemon.ts
|
|
7454
7741
|
import { createServer as createHttpServer } from "http";
|
|
7455
7742
|
import { spawn as spawn4 } from "child_process";
|
|
@@ -8049,6 +8336,35 @@ if (command === "passenger-sync-daemon") {
|
|
|
8049
8336
|
}, parseTicketNumber = function(input) {
|
|
8050
8337
|
const m = /^(?:[A-Za-z]+-)?(\d+)$/.exec(input.trim());
|
|
8051
8338
|
return m ? m[1] : null;
|
|
8339
|
+
}, passengerApiKeys = function(routes) {
|
|
8340
|
+
const keys = /* @__PURE__ */ new Map();
|
|
8341
|
+
for (const r of routes) {
|
|
8342
|
+
const ws = r.resolved?.workspaceId;
|
|
8343
|
+
if (!ws) continue;
|
|
8344
|
+
try {
|
|
8345
|
+
keys.set(ws, resolveApiKey(r));
|
|
8346
|
+
} catch {
|
|
8347
|
+
}
|
|
8348
|
+
}
|
|
8349
|
+
return keys;
|
|
8350
|
+
}, endpointApiFor = function(routes) {
|
|
8351
|
+
return (workspaceId) => {
|
|
8352
|
+
const r = routes.find((x) => x.resolved?.workspaceId === workspaceId);
|
|
8353
|
+
if (!r) return void 0;
|
|
8354
|
+
try {
|
|
8355
|
+
return { baseUrl: r.baseUrl || DEFAULT_BASE_URL, apiKey: resolveApiKey(r), userAgent: cfg.ship.userAgent };
|
|
8356
|
+
} catch {
|
|
8357
|
+
return void 0;
|
|
8358
|
+
}
|
|
8359
|
+
};
|
|
8360
|
+
}, endpointReconcileDeps = function(routes, relayHost) {
|
|
8361
|
+
return {
|
|
8362
|
+
stateDir: cfg.ship.stateDir,
|
|
8363
|
+
shipName: cfg.ship.name,
|
|
8364
|
+
apiFor: endpointApiFor(routes),
|
|
8365
|
+
urlFor: (slug) => publicUrlFor(slug, relayHost, cfg.ship.relayHttpPort, cfg.ship.relayPublicDomain),
|
|
8366
|
+
warn: (m) => emit.warn(m, { step: "passengers" })
|
|
8367
|
+
};
|
|
8052
8368
|
}, reportUnplaceable = function(c, unplaceable, verified, emit2) {
|
|
8053
8369
|
if (unplaceableReported.has(c.route)) return;
|
|
8054
8370
|
unplaceableReported.add(c.route);
|
|
@@ -8057,7 +8373,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
8057
8373
|
else emit2.emit(note.message, { step: "release" });
|
|
8058
8374
|
}
|
|
8059
8375
|
};
|
|
8060
|
-
requireArmed2 = requireArmed, parseTicketNumber2 = parseTicketNumber, reportUnplaceable2 = reportUnplaceable;
|
|
8376
|
+
requireArmed2 = requireArmed, parseTicketNumber2 = parseTicketNumber, passengerApiKeys2 = passengerApiKeys, endpointApiFor2 = endpointApiFor, endpointReconcileDeps2 = endpointReconcileDeps, reportUnplaceable2 = reportUnplaceable;
|
|
8061
8377
|
let cfg;
|
|
8062
8378
|
let firstRun;
|
|
8063
8379
|
try {
|
|
@@ -8162,13 +8478,24 @@ if (command === "passenger-sync-daemon") {
|
|
|
8162
8478
|
const { privateKeyPath } = ensureShipSshKeypair(cfg.ship.stateDir);
|
|
8163
8479
|
const plans = planContainers(cfg);
|
|
8164
8480
|
const mcpSecrets = new Map([...syncEndpoints].map(([workspaceId, e]) => [workspaceId, e.secret]));
|
|
8165
|
-
|
|
8166
|
-
|
|
8481
|
+
const relayHost = cfg.ship.relayHost;
|
|
8482
|
+
const reconcile = endpointReconcileDeps(passengerRoutes, relayHost);
|
|
8483
|
+
const pending = [];
|
|
8484
|
+
syncPassengerTunnels(plans, relayHost, cfg.ship.relayPort, privateKeyPath, cfg.ship.stateDir, mcpSecrets, {
|
|
8485
|
+
apiKeys: passengerApiKeys(passengerRoutes),
|
|
8486
|
+
onStatus: (workspaceId, status2, info) => {
|
|
8167
8487
|
const owningRoute = passengerRoutes.find((r) => r.resolved?.workspaceId === workspaceId);
|
|
8168
8488
|
if (!owningRoute) return;
|
|
8169
|
-
|
|
8489
|
+
pending.push(
|
|
8490
|
+
new Tracker(owningRoute, cfg.ship).updateTunnelState(cfg.ship.name, { tunnel_status: status2 }).catch((e) => {
|
|
8491
|
+
emit.warn(`could not write tunnel_status: ${e.message}`, { step: "passengers" });
|
|
8492
|
+
})
|
|
8493
|
+
);
|
|
8494
|
+
if (status2 === "disconnected") pending.push(unregisterEndpoint(workspaceId, info, reconcile));
|
|
8170
8495
|
}
|
|
8171
8496
|
});
|
|
8497
|
+
for (const plan of plans) pending.push(reconcileEndpoint(plan.workspaceId, reconcile));
|
|
8498
|
+
await Promise.all(pending);
|
|
8172
8499
|
} catch (e) {
|
|
8173
8500
|
emit.warn(`passenger tunnel sync failed: ${e.message}`, { step: "passengers" });
|
|
8174
8501
|
}
|
|
@@ -9232,7 +9559,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9232
9559
|
const sub = positional[1];
|
|
9233
9560
|
const routeName = positional[2];
|
|
9234
9561
|
const usage2 = () => {
|
|
9235
|
-
process.stderr.write("crew repos add <workspace/project> <path> [--dry-run]\ncrew repos list <workspace/project>\n");
|
|
9562
|
+
process.stderr.write("crew repos add <workspace/project> <path> [--name N] [--project P] [--dry-run] [--no-create]\ncrew repos list <workspace/project>\n");
|
|
9236
9563
|
process.exit(2);
|
|
9237
9564
|
};
|
|
9238
9565
|
if (sub !== "add" && sub !== "list" || !routeName || sub === "add" && !positional[3]) usage2();
|
|
@@ -9253,9 +9580,9 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9253
9580
|
if (repos.length === 0) process.stdout.write(`route ${routeName} has no checkout configured \u2014 \`crew repos add ${routeName} <path>\`
|
|
9254
9581
|
`);
|
|
9255
9582
|
for (const r of repos) {
|
|
9256
|
-
const
|
|
9257
|
-
const row = matchRepoName(
|
|
9258
|
-
const note = !existsSync18(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(
|
|
9583
|
+
const origin2 = originOf(r.dir);
|
|
9584
|
+
const row = matchRepoName(origin2, resolvedRemotes);
|
|
9585
|
+
const note = !existsSync18(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
|
|
9259
9586
|
process.stdout.write(`${r.name} ${r.dir} ${note}
|
|
9260
9587
|
`);
|
|
9261
9588
|
}
|
|
@@ -9264,22 +9591,60 @@ ${changed2.map((c) => ` - ${c}
|
|
|
9264
9591
|
const target = resolvePath(positional[3].replace(/^~(?=\/|$)/, homedir4()));
|
|
9265
9592
|
const top = existsSync18(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
|
|
9266
9593
|
if (!top) throw new ReposError(`${target} is not a git checkout`);
|
|
9594
|
+
const origin = originOf(top);
|
|
9267
9595
|
const plan = planRepoAdd({
|
|
9268
9596
|
text,
|
|
9269
9597
|
base,
|
|
9270
9598
|
route: routeName,
|
|
9271
9599
|
path: top,
|
|
9272
|
-
origin
|
|
9600
|
+
origin,
|
|
9273
9601
|
resolved: resolvedRemotes,
|
|
9602
|
+
name: value("name"),
|
|
9274
9603
|
nameForExisting: (d) => matchRepoName(originOf(d), resolvedRemotes)
|
|
9275
9604
|
});
|
|
9276
|
-
|
|
9605
|
+
let rowTracker;
|
|
9606
|
+
let rowPayload;
|
|
9607
|
+
if (plan.unmatched && origin && !flag("no-create")) {
|
|
9608
|
+
const planned = loadConfig(CREW_HOME, cfg.configFile, { text: plan.text, allowNoRoutes: true });
|
|
9609
|
+
const planRoute = planned.routes.find((r) => r.route === routeName);
|
|
9610
|
+
if (!planRoute) throw new ReposError(`route ${routeName} did not load from the planned config; use --no-create`);
|
|
9611
|
+
try {
|
|
9612
|
+
rowTracker = new Tracker(planRoute, cfg.ship);
|
|
9613
|
+
} catch (e) {
|
|
9614
|
+
if (e instanceof ConfigError) throw new ReposError(`${e.message}; or pass --no-create`);
|
|
9615
|
+
throw e;
|
|
9616
|
+
}
|
|
9617
|
+
const projectId = resolveRepoProject(await rowTracker.projectRows(), value("project"), planRoute.resolved?.areaId);
|
|
9618
|
+
let spec;
|
|
9619
|
+
try {
|
|
9620
|
+
const rc = loadRepoConfig(top);
|
|
9621
|
+
if (rc) spec = { platform: rc.platform, releaseMode: rc.release.mode, ciProvider: rc.release.ci.provider };
|
|
9622
|
+
} catch {
|
|
9623
|
+
}
|
|
9624
|
+
rowPayload = planRepoRow({ name: plan.name, origin, spec, projectId });
|
|
9625
|
+
} else if (plan.warning) {
|
|
9626
|
+
process.stderr.write(`crew repos: warning \u2014 ${plan.warning}
|
|
9277
9627
|
`);
|
|
9628
|
+
}
|
|
9278
9629
|
if (dryRun) {
|
|
9279
9630
|
process.stdout.write(`${plan.block}
|
|
9631
|
+
`);
|
|
9632
|
+
if (rowPayload) process.stdout.write(`would create Repos row: ${JSON.stringify(rowPayload)}
|
|
9280
9633
|
`);
|
|
9281
9634
|
break;
|
|
9282
9635
|
}
|
|
9636
|
+
if (rowTracker && rowPayload) {
|
|
9637
|
+
const created = await rowTracker.createRepoRow(rowPayload);
|
|
9638
|
+
process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
|
|
9639
|
+
`);
|
|
9640
|
+
if (existsSync18(resolvedFile)) {
|
|
9641
|
+
const raw = JSON.parse(readFileSync12(resolvedFile, "utf8"));
|
|
9642
|
+
raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
|
|
9643
|
+
raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
|
|
9644
|
+
writeFileSync9(resolvedFile, `${JSON.stringify(raw, null, 2)}
|
|
9645
|
+
`);
|
|
9646
|
+
}
|
|
9647
|
+
}
|
|
9283
9648
|
writeFileSync9(cfg.configFile, plan.text);
|
|
9284
9649
|
process.stdout.write(`added ${top} to ${routeName} as ${plan.mode === "dir" ? "dir" : `repos.${plan.name}`} in ${cfg.configFile}
|
|
9285
9650
|
`);
|
|
@@ -9406,7 +9771,7 @@ ${reconstruction.prompt}
|
|
|
9406
9771
|
}
|
|
9407
9772
|
let result;
|
|
9408
9773
|
try {
|
|
9409
|
-
result = await loginWithDeviceCode(baseUrl
|
|
9774
|
+
result = await loginWithDeviceCode(`${baseUrl}/api`, {
|
|
9410
9775
|
deviceName: `crew on ${hostname2()}`,
|
|
9411
9776
|
onCode: (info) => {
|
|
9412
9777
|
process.stderr.write(
|
|
@@ -9856,6 +10221,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
9856
10221
|
);
|
|
9857
10222
|
break;
|
|
9858
10223
|
}
|
|
10224
|
+
rotatePersistedSlug(cfg.ship.stateDir, workspaceId);
|
|
9859
10225
|
if (hasLiveTunnel) {
|
|
9860
10226
|
try {
|
|
9861
10227
|
process.kill(existing.pid, "SIGTERM");
|
|
@@ -9863,12 +10229,21 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
9863
10229
|
}
|
|
9864
10230
|
}
|
|
9865
10231
|
const { privateKeyPath } = ensureShipSshKeypair(cfg.ship.stateDir);
|
|
10232
|
+
const rotateReconcile = endpointReconcileDeps([route], cfg.ship.relayHost);
|
|
10233
|
+
const rotatePending = [];
|
|
9866
10234
|
syncPassengerTunnels(plans, cfg.ship.relayHost, cfg.ship.relayPort, privateKeyPath, cfg.ship.stateDir, /* @__PURE__ */ new Map(), {
|
|
9867
|
-
|
|
9868
|
-
|
|
10235
|
+
apiKeys: passengerApiKeys([route]),
|
|
10236
|
+
onStatus: (wsId, status2, info) => {
|
|
10237
|
+
rotatePending.push(
|
|
10238
|
+
new Tracker(route, cfg.ship).updateTunnelState(cfg.ship.name, { tunnel_status: status2 }).catch((e) => {
|
|
10239
|
+
emit.warn(`could not write tunnel_status: ${e.message}`, { step: "passengers" });
|
|
10240
|
+
})
|
|
10241
|
+
);
|
|
10242
|
+
if (status2 === "disconnected") rotatePending.push(unregisterEndpoint(wsId, info, rotateReconcile));
|
|
9869
10243
|
}
|
|
9870
10244
|
});
|
|
9871
|
-
|
|
10245
|
+
await Promise.all(rotatePending);
|
|
10246
|
+
emit.emit(`rotated the passenger tunnel for ${route.route} \u2014 it reconnects under a fresh slug and the registered endpoint follows on the next passengers cycle`);
|
|
9872
10247
|
break;
|
|
9873
10248
|
}
|
|
9874
10249
|
case "unassign": {
|
|
@@ -10197,6 +10572,23 @@ crew manifest: ${seats2.length ? seats2.map((c) => c.name).join(", ") : "non
|
|
|
10197
10572
|
" `ssh-keygen` is required to mint this ship's tunnel identity \u2014 install OpenSSH, then re-run `crew connect` to sync a key onto this workspace's Ships row.\n"
|
|
10198
10573
|
);
|
|
10199
10574
|
}
|
|
10575
|
+
if (cfg.ship.relayHost) {
|
|
10576
|
+
for (const r of passengerRoutes) {
|
|
10577
|
+
const ws = r.resolved?.workspaceId;
|
|
10578
|
+
const slug = ws ? readPersistedTunnel(cfg.ship.stateDir, ws)?.slug : void 0;
|
|
10579
|
+
if (!ws) continue;
|
|
10580
|
+
process.stdout.write(
|
|
10581
|
+
` ${r.route} url: ${slug ? publicUrlFor(slug, cfg.ship.relayHost, cfg.ship.relayHttpPort, cfg.ship.relayPublicDomain) : "not minted yet (first tunnel cycle creates it)"}
|
|
10582
|
+
`
|
|
10583
|
+
);
|
|
10584
|
+
const api = endpointApiFor([r])(ws);
|
|
10585
|
+
if (!api) continue;
|
|
10586
|
+
const probe = await probeEndpointAccess(api, ws);
|
|
10587
|
+
process.stdout.write(
|
|
10588
|
+
` ${r.route} mcp endpoints: ` + (probe === "ok" ? "OK (key may register its endpoint)" : probe === "forbidden" ? `403 \u2014 ${REGISTER_MCP_ENDPOINTS_HINT}` : `could not check (${probe.error})`) + "\n"
|
|
10589
|
+
);
|
|
10590
|
+
}
|
|
10591
|
+
}
|
|
10200
10592
|
for (const r of passengerRoutes) {
|
|
10201
10593
|
const included = passengerRepoTargets(r);
|
|
10202
10594
|
process.stdout.write(
|
|
@@ -10218,4 +10610,7 @@ crew manifest: ${seats2.length ? seats2.map((c) => c.name).join(", ") : "non
|
|
|
10218
10610
|
}
|
|
10219
10611
|
var requireArmed2;
|
|
10220
10612
|
var parseTicketNumber2;
|
|
10613
|
+
var passengerApiKeys2;
|
|
10614
|
+
var endpointApiFor2;
|
|
10615
|
+
var endpointReconcileDeps2;
|
|
10221
10616
|
var reportUnplaceable2;
|
package/package.json
CHANGED
|
@@ -388,7 +388,8 @@ if `report_type` is either of those.
|
|
|
388
388
|
lists, etc. when that's clearer than a plain sentence. If a screenshot
|
|
389
389
|
would help explain something (a UI verification result, a rendering
|
|
390
390
|
bug), attach it via the Comments table's `attachments` field rather than
|
|
391
|
-
just describing it in text.
|
|
391
|
+
just describing it in text. The Environment section's "Attaching a file to
|
|
392
|
+
a comment" recipe says how to upload the file and get the id to put there.
|
|
392
393
|
4a. **Look at the ticket's attachments before you start.** A ticket (and any
|
|
393
394
|
of its comments) may carry an `attachments` value: a list of Media Library
|
|
394
395
|
record ids, not URLs. A bare uuid array is not "nothing to see" — it is
|
|
@@ -57,10 +57,14 @@ stopping for this run, do the following instead:
|
|
|
57
57
|
deliberately left out. Attach images via the Comments table's `attachments`
|
|
58
58
|
field — a described mockup is not a shown one. Render your mockup to PNG
|
|
59
59
|
with Playwright if it only exists as markup, in whatever way fits this
|
|
60
|
-
repo's own front-end stack
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
repo's own front-end stack, then upload it with the recipe under
|
|
61
|
+
"Attaching a file to a comment" in the Environment section. The PNG
|
|
62
|
+
upload is a required part of a finished design pass; a text wireframe
|
|
63
|
+
alone does not satisfy this step. If a canvas Artifact published, include
|
|
64
|
+
its URL in the comment body too, but never *only* the URL — the Artifact
|
|
65
|
+
canvas is optional, the PNG is not. If the upload itself fails, post the
|
|
66
|
+
HTTP status and response body in the comment rather than a sentence
|
|
67
|
+
saying uploads are impossible.
|
|
64
68
|
- **3.4c — Hand it to the operator, always, one of two ways.** Every design
|
|
65
69
|
pass ends here — every time, not only when the direction is a product
|
|
66
70
|
call — but which of the two endings applies depends on whether you were
|
|
@@ -369,7 +369,8 @@ if `report_type` is either of those.
|
|
|
369
369
|
lists, etc. when that's clearer than a plain sentence. If a screenshot
|
|
370
370
|
would help explain something (a UI verification result, a rendering
|
|
371
371
|
bug), attach it via the Comments table's `attachments` field rather than
|
|
372
|
-
just describing it in text.
|
|
372
|
+
just describing it in text. The Environment section's "Attaching a file to
|
|
373
|
+
a comment" recipe says how to upload the file and get the id to put there.
|
|
373
374
|
4a. **Look at the ticket's attachments before you start.** A ticket (and any
|
|
374
375
|
of its comments) may carry an `attachments` value: a list of Media Library
|
|
375
376
|
record ids, not URLs. A bare uuid array is not "nothing to see" — it is
|