@tablation/crew 0.1.0 → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brad Choate
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -4,6 +4,10 @@ A standing team of headless agents that picks work off a
4
4
  [Tablation](https://tablation.com) board, does it on a machine you control,
5
5
  and reports back on the board.
6
6
 
7
+ **Documentation: <https://crew.tablation.dev>** — start with
8
+ [Getting started](https://crew.tablation.dev/getting-started). The source is in
9
+ `docs/`.
10
+
7
11
  Each **seat** is a row in the board's Crew table that happens to be a robot,
8
12
  with a brief (`prompts/<promptSet>/`, see "Prompt sets" below) and a slice
9
13
  of the queue. A seat is named in that table and answers to that name, in the
@@ -207,7 +211,7 @@ crew pause|resume [route] [R] pause everything, or one role
207
211
  crew log [route] tail the log
208
212
  crew inbox [--member NAME] your tickets across every workspace
209
213
  crew connect WS[/PROJECT] resolve a workspace/project's ids into the state tree
210
- 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)
211
215
  crew repos list ROUTE the checkouts a route has, and the tracker Repos row each matches
212
216
  crew install write and load this platform's scheduler unit
213
217
  crew uninstall unload and remove it
package/bin/crew ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // crew — the runner. See src/cli.ts.
3
+ //
4
+ // Runs the built output when it exists, and falls back to running TypeScript
5
+ // directly so a checkout works without a build step. The bash runner this
6
+ // replaces is kept as bin/crew-legacy.sh until cutover is done.
7
+ import { existsSync } from 'node:fs';
8
+ import { dirname, join } from 'node:path';
9
+ import { fileURLToPath, pathToFileURL } from 'node:url';
10
+
11
+ const here = dirname(fileURLToPath(import.meta.url));
12
+ const built = join(here, '..', 'dist', 'cli.js');
13
+ const source = join(here, '..', 'src', 'cli.ts');
14
+
15
+ // CREW_FROM_SOURCE=1 skips the bundle even when one exists. crew's own test
16
+ // suite sets it on every CLI it spawns (test/helpers/crew-bin.ts): dist/ is
17
+ // rebuilt by the release's build hook, which runs AFTER the test gate, so
18
+ // without this the gate exercises the previous release's bundle (CREW-988).
19
+ const fromSource = process.env.CREW_FROM_SOURCE === '1' && existsSync(source);
20
+
21
+ if (existsSync(built) && !fromSource) {
22
+ await import(pathToFileURL(built).href);
23
+ } else if (existsSync(source)) {
24
+ // Node >=22.18 strips types natively; older needs --experimental-strip-types.
25
+ await import(pathToFileURL(source).href);
26
+ } else {
27
+ console.error('crew: no dist/cli.js and no src/cli.ts — is this a complete checkout?');
28
+ process.exit(1);
29
+ }
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 (docs/RELAY.md).
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) and `mcp_url`, as
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 null;
2846
- return out.split("\n")[0]?.split(" ")[0] ?? null;
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("verified and already merged, waiting on a successful release", { ticket: c.ticket.issue_id });
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
- } else if (!o.skipTests && o.repo.hooks.test) {
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 SECRET_MARKER = "-secret-";
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
- return o.mcpSecret ? `workspace-${o.workspaceId}${SECRET_MARKER}${o.mcpSecret}` : `workspace-${o.workspaceId}`;
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
- if (existing && alive(existing.pid) && existing.mountsHash === plan.mountsHash && secretUnchanged) {
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
- writePersistedTunnel(stateDir, {
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
- deps.onStatus?.(plan.workspaceId, settledStatus);
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
- syncPassengerTunnels(plans, cfg.ship.relayHost, cfg.ship.relayPort, privateKeyPath, cfg.ship.stateDir, mcpSecrets, {
8166
- onStatus: (workspaceId, status2) => {
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
- new Tracker(owningRoute, cfg.ship).updateTunnelState(cfg.ship.name, { tunnel_status: status2 }).catch((e) => emit.warn(`could not write tunnel_status: ${e.message}`, { step: "passengers" }));
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 origin = originOf(r.dir);
9257
- const row = matchRepoName(origin, resolvedRemotes);
9258
- const note = !existsSync18(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin) ?? "this checkout (no origin remote)"}`;
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: originOf(top),
9600
+ origin,
9273
9601
  resolved: resolvedRemotes,
9602
+ name: value("name"),
9274
9603
  nameForExisting: (d) => matchRepoName(originOf(d), resolvedRemotes)
9275
9604
  });
9276
- if (plan.warning) process.stderr.write(`crew repos: warning \u2014 ${plan.warning}
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
- onStatus: (wsId, status2) => {
9868
- new Tracker(route, cfg.ship).updateTunnelState(cfg.ship.name, { tunnel_status: status2 }).catch((e) => emit.warn(`could not write tunnel_status: ${e.message}`, { step: "passengers" }));
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
- emit.emit(`rotated the passenger tunnel for ${route.route} \u2014 a fresh public URL will be minted on reconnect`);
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
@@ -1,9 +1,17 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "A standing team of headless agents that picks work off a Tablation board, does it on a machine you control, and reports back.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/rightdesign/crew.git"
10
+ },
11
+ "homepage": "https://github.com/rightdesign/crew#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/rightdesign/crew/issues"
14
+ },
7
15
  "publishConfig": {
8
16
  "access": "public"
9
17
  },
@@ -15,9 +23,11 @@
15
23
  },
16
24
  "files": [
17
25
  "dist",
26
+ "bin",
18
27
  "prompts",
19
28
  "crew.example.yaml",
20
- "README.md"
29
+ "README.md",
30
+ "LICENSE"
21
31
  ],
22
32
  "dependencies": {
23
33
  "@modelcontextprotocol/sdk": "^1.30.0",
@@ -30,13 +40,16 @@
30
40
  "@types/node": "^22.10.0",
31
41
  "tsup": "^8.3.5",
32
42
  "tsx": "^4.23.12",
33
- "typescript": "^5.7.3"
43
+ "typescript": "^5.7.3",
44
+ "vitepress": "^1.6.4"
34
45
  },
35
46
  "scripts": {
36
47
  "build": "tsup src/cli.ts --format esm --clean",
37
48
  "build:binaries": "scripts/build-binaries.sh",
38
49
  "dev": "tsx src/cli.ts",
39
50
  "test": "node --test --experimental-strip-types test/*.test.ts",
40
- "typecheck": "tsc --noEmit"
51
+ "typecheck": "tsc --noEmit",
52
+ "docs:dev": "vitepress dev docs",
53
+ "docs:build": "vitepress build docs"
41
54
  }
42
55
  }
@@ -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. If a canvas Artifact published, include its
61
- URL in the comment body too, but never *only* the URL: this session is
62
- headless and Artifact publishing may be unavailable, so the screenshots
63
- are the deliverable that has to work either way.
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