@tablation/crew 0.50.3 → 0.51.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +138 -26
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -11281,6 +11281,7 @@ async function discover(o) {
11281
11281
  out.role = me.role;
11282
11282
  out.meEmail = me.email;
11283
11283
  out.meId = me.id;
11284
+ out.meName = me.name;
11284
11285
  } catch {
11285
11286
  }
11286
11287
  if (o.project) {
@@ -11504,18 +11505,72 @@ async function discover(o) {
11504
11505
  }
11505
11506
  }
11506
11507
  const seatIds = /* @__PURE__ */ new Set([...Object.values(out.seats), ...out.releaseSeat ? [out.releaseSeat] : []]);
11507
- out.holds = rows.filter((r) => !seatIds.has(r.id)).map((r) => ({ id: r.id, name: r.name ?? "" }));
11508
- if (out.holds.length === 0) {
11509
- problems.push("no non-seat Crew rows \u2014 which row is the operator?");
11510
- } else if (out.meEmail) {
11511
- const holdRows = rows.filter((r) => !seatIds.has(r.id));
11512
- const matches2 = holdRows.filter((r) => eq(r.email, out.meEmail));
11513
- if (matches2.length === 1) out.operator = matches2[0].id;
11508
+ const holdRows = rows.filter((r) => !seatIds.has(r.id));
11509
+ out.holds = holdRows.map((r) => ({ id: r.id, name: r.name ?? "", email: r.email, user_id: r.user_id }));
11510
+ out.crewModelId = out.models.crew;
11511
+ if (!out.meId) {
11512
+ if (out.holds.length === 0) problems.push("no non-seat Crew rows \u2014 which row is the operator?");
11513
+ else {
11514
+ const byEmail = out.meEmail ? holdRows.filter((r) => eq(r.email, out.meEmail)) : [];
11515
+ if (byEmail.length === 1) out.operator = byEmail[0].id;
11516
+ else if (out.holds.length === 1) out.operator = out.holds[0].id;
11517
+ }
11518
+ } else {
11519
+ const candidates = holdRows.filter((r) => !r.user_id || r.user_id === out.meId);
11520
+ const mine = holdRows.find((r) => r.user_id === out.meId);
11521
+ const byEmail = out.meEmail ? candidates.filter((r) => eq(r.email, out.meEmail)) : [];
11522
+ const only = holdRows.length === 1 && candidates.length === 1 ? candidates[0] : void 0;
11523
+ const pick = mine ?? (byEmail.length === 1 ? byEmail[0] : only);
11524
+ if (pick) {
11525
+ out.operator = pick.id;
11526
+ if (pick.user_id !== out.meId && o.ship) await linkOperatorRow(o, out, pick);
11527
+ } else if (o.ship && (!o.interactive || candidates.length === 0)) {
11528
+ await createOperatorRow(o, out);
11529
+ }
11514
11530
  }
11515
- if (!out.operator && out.holds.length === 1) out.operator = out.holds[0].id;
11516
11531
  }
11517
11532
  return out;
11518
11533
  }
11534
+ async function linkOperatorRow(o, found, row) {
11535
+ const name = row.name || row.id;
11536
+ if (!found.meId || !found.crewModelId) return;
11537
+ if (o.dryRun) {
11538
+ found.provisioning.push(`would link Crew row "${name}" to your account`);
11539
+ return;
11540
+ }
11541
+ const body = { user_id: found.meId };
11542
+ if (!row.email && found.meEmail) body.email = found.meEmail;
11543
+ try {
11544
+ await patch(o, `/data-models/${found.crewModelId}/records/${row.id}`, body);
11545
+ found.provisioning.push(`linked Crew row "${name}" to your account`);
11546
+ } catch (e) {
11547
+ if (!(e instanceof ConnectHttpError) || e.status !== 403) throw e;
11548
+ found.provisioning.push(
11549
+ `could not link your Crew row (no write access) \u2014 ask a workspace admin to set user_id on "${name}"`
11550
+ );
11551
+ }
11552
+ }
11553
+ async function createOperatorRow(o, found) {
11554
+ if (!found.meId || !found.crewModelId) return;
11555
+ const name = found.meName || found.meEmail || "Operator";
11556
+ if (o.dryRun) {
11557
+ found.provisioning.push(`would create Crew row "${name}" for you`);
11558
+ return;
11559
+ }
11560
+ try {
11561
+ const created = await post2(o, `/data-models/${found.crewModelId}/records`, {
11562
+ name,
11563
+ ...found.meEmail ? { email: found.meEmail } : {},
11564
+ user_id: found.meId
11565
+ });
11566
+ found.operator = created.id;
11567
+ found.holds.push({ id: created.id, name, email: found.meEmail, user_id: found.meId });
11568
+ found.provisioning.push(`created Crew row "${name}" for you`);
11569
+ } catch (e) {
11570
+ if (!(e instanceof ConnectHttpError) || e.status !== 403) throw e;
11571
+ found.provisioning.push("could not create your Crew row (no write access) \u2014 ask a workspace admin to add one with your user_id");
11572
+ }
11573
+ }
11519
11574
  function renderConnection(d, route, dir, opts = {}) {
11520
11575
  const apiKeyFileLine = opts.keychainBacked ? ` # apiKey: resolved automatically from the OS keychain (crew connect) \u2014 no apiKeyFile needed` : opts.apiKeyFile ? ` apiKeyFile: "${opts.apiKeyFile}"` : ` apiKeyFile: "REPLACE \u2014 a file holding this workspace's key"`;
11521
11576
  const apiKeyVarLine = opts.keychainBacked ? "" : opts.apiKeyVar ? `
@@ -11589,6 +11644,13 @@ async function resolveSeatKey(route, role, getStore = getSessionStore2) {
11589
11644
  return ship("OS keychain unavailable");
11590
11645
  }
11591
11646
  }
11647
+ function describeStoreError(e) {
11648
+ const msg = e instanceof Error ? e.message : String(e);
11649
+ if (/User interaction is not allowed/i.test(msg)) {
11650
+ return "keychain unavailable (User interaction is not allowed \u2014 run from a logged-in session, or `security unlock-keychain` first)";
11651
+ }
11652
+ return "keychain unavailable (could not write to the OS keychain)";
11653
+ }
11592
11654
  async function mintMissingSeatKeys(route, o) {
11593
11655
  const out = [];
11594
11656
  if (!route.resolved) return out;
@@ -11605,11 +11667,27 @@ async function mintMissingSeatKeys(route, o) {
11605
11667
  return targets.map(({ role }) => ({
11606
11668
  role,
11607
11669
  action: "failed",
11608
- detail: `OS keychain unavailable: ${e.message}`
11670
+ detail: describeStoreError(e)
11609
11671
  }));
11610
11672
  }
11611
11673
  const host = hostFromUrl2(route.baseUrl);
11612
11674
  const base = route.baseUrl.replace(/\/+$/, "");
11675
+ if (!o.dryRun) {
11676
+ const probeSlug = seatKeySlug(splitRoute(route.route)[0], "_probe");
11677
+ try {
11678
+ await store.set(host, probeSlug, {
11679
+ apiKey: "probe",
11680
+ shipId: "",
11681
+ workspaceId: resolved.workspaceId,
11682
+ identityId: "_probe",
11683
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
11684
+ });
11685
+ await store.clear(host, probeSlug);
11686
+ } catch (e) {
11687
+ const detail = `${describeStoreError(e)}; no keys minted`;
11688
+ return targets.map(({ role, agentId }) => agentId ? { role, action: "failed", detail } : { role, action: "no_agent" });
11689
+ }
11690
+ }
11613
11691
  for (const { role, agentId } of targets) {
11614
11692
  if (!agentId) {
11615
11693
  out.push({ role, action: "no_agent" });
@@ -11643,16 +11721,37 @@ async function mintMissingSeatKeys(route, o) {
11643
11721
  out.push({ role, action: "failed", detail: "response carried no key" });
11644
11722
  continue;
11645
11723
  }
11646
- await store.set(host, slug, {
11647
- apiKey: body.key,
11648
- shipId: o.shipName ?? "",
11649
- workspaceId: resolved.workspaceId,
11650
- identityId: agentId,
11651
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
11652
- });
11724
+ try {
11725
+ await store.set(host, slug, {
11726
+ apiKey: body.key,
11727
+ shipId: o.shipName ?? "",
11728
+ workspaceId: resolved.workspaceId,
11729
+ identityId: agentId,
11730
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
11731
+ });
11732
+ } catch (e) {
11733
+ let revoked = false;
11734
+ if (body.id) {
11735
+ try {
11736
+ const del2 = await doFetch(`${base}/api/workspaces/${resolved.workspaceId}/agents/${agentId}/api-keys/${body.id}`, {
11737
+ method: "DELETE",
11738
+ headers: { Authorization: `Bearer ${resolveApiKey(route)}`, "User-Agent": o.userAgent ?? "Mozilla/5.0 TablationCrewAgent/1.0" }
11739
+ });
11740
+ revoked = del2.ok;
11741
+ } catch {
11742
+ revoked = false;
11743
+ }
11744
+ }
11745
+ out.push({
11746
+ role,
11747
+ action: "failed",
11748
+ detail: revoked ? `minted but could not be stored (${describeStoreError(e)}); revoked` : `a key for ${role} is live on the server and not stored (${describeStoreError(e)}) \u2014 revoke it from that seat's Agents row`
11749
+ });
11750
+ continue;
11751
+ }
11653
11752
  out.push({ role, action: "minted" });
11654
11753
  } catch (e) {
11655
- out.push({ role, action: "failed", detail: e.message });
11754
+ out.push({ role, action: "failed", detail: /Command failed|security|sk_|-w /i.test(e.message) ? describeStoreError(e) : e.message });
11656
11755
  }
11657
11756
  }
11658
11757
  return out;
@@ -13466,20 +13565,25 @@ function terminalPrompter() {
13466
13565
  }
13467
13566
  };
13468
13567
  }
13469
- async function pickOperator(holds2) {
13568
+ async function pickOperator(holds2, me) {
13569
+ const choices = holds2.filter((h) => !h.user_id || h.user_id === me.id);
13570
+ const canCreate = !!me.id;
13470
13571
  process.stderr.write("\nWhich of these Crew rows is you?\n");
13471
- holds2.forEach((h, i) => process.stderr.write(` ${i + 1}. ${h.name || h.id}
13572
+ choices.forEach((h, i) => process.stderr.write(` ${i + 1}. ${h.name || h.id}
13472
13573
  `));
13574
+ if (canCreate) process.stderr.write(` 0. Create a new Crew row for ${me.name ?? ""} ${me.email ?? ""}`.trimEnd() + "\n");
13473
13575
  const rl = createInterface({ input: process.stdin, output: process.stderr });
13474
13576
  try {
13475
- const answer = (await rl.question("Enter a number (blank to skip): ")).trim();
13577
+ const fallback = canCreate && choices.length === 0 ? "0" : "";
13578
+ const answer = (await rl.question(`Enter a number (blank to ${fallback ? "create one" : "skip"}): `)).trim() || fallback;
13476
13579
  const n = Number(answer);
13477
- if (!answer || !Number.isInteger(n) || n < 1 || n > holds2.length) return void 0;
13478
- return holds2[n - 1].id;
13580
+ if (!answer || !Number.isInteger(n) || n < 0 || n > choices.length) return void 0;
13581
+ return n === 0 ? canCreate ? NEW_OPERATOR_ROW : void 0 : choices[n - 1].id;
13479
13582
  } finally {
13480
13583
  rl.close();
13481
13584
  }
13482
13585
  }
13586
+ var NEW_OPERATOR_ROW = "new";
13483
13587
  async function pickResolvedStatuses(unrecognized) {
13484
13588
  process.stderr.write(
13485
13589
  "\nThis workspace has status values the default contract doesn't know the meaning of.\nFor each: does it mean the ticket is DONE \u2014 resolved/terminal (shipped, closed, won't-fix) \u2014 so a ticket blocked by it should stop being blocked?\n"
@@ -15542,11 +15646,11 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
15542
15646
  sshPublicKey
15543
15647
  };
15544
15648
  const runConnect = async (ws, proj) => {
15545
- let found = await discover({ ...authOpts, workspace: ws, project: proj, area: value("area"), ship, dryRun });
15649
+ let found = await discover({ ...authOpts, workspace: ws, project: proj, area: value("area"), ship, dryRun, interactive: !!(process.stdin.isTTY && process.stdout.isTTY) });
15546
15650
  if (!found.projectId && found.offerTemplateInstall && process.stdin.isTTY && process.stdout.isTTY) {
15547
15651
  const installedSlug = await offerTemplateInstall(authOpts, found.workspaceId);
15548
15652
  if (installedSlug) {
15549
- found = await discover({ ...authOpts, workspace: ws, project: installedSlug, area: value("area"), ship, dryRun });
15653
+ found = await discover({ ...authOpts, workspace: ws, project: installedSlug, area: value("area"), ship, dryRun, interactive: !!(process.stdin.isTTY && process.stdout.isTTY) });
15550
15654
  }
15551
15655
  }
15552
15656
  if (!found.projectId) {
@@ -15572,7 +15676,15 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
15572
15676
  }
15573
15677
  let operator = found.operator;
15574
15678
  if (!operator && found.holds.length > 0 && process.stdin.isTTY && process.stdout.isTTY) {
15575
- operator = await pickOperator(found.holds);
15679
+ const picked = await pickOperator(found.holds, { id: found.meId, name: found.meName, email: found.meEmail });
15680
+ if (picked === NEW_OPERATOR_ROW) {
15681
+ await createOperatorRow({ ...authOpts, dryRun }, found);
15682
+ operator = found.operator;
15683
+ } else if (picked) {
15684
+ operator = picked;
15685
+ const row = found.holds.find((h) => h.id === picked);
15686
+ if (row && found.meId && row.user_id !== found.meId) await linkOperatorRow({ ...authOpts, dryRun }, found, row);
15687
+ }
15576
15688
  }
15577
15689
  let previous;
15578
15690
  const hadPreviousFile = existsSync26(resolvedPath);
@@ -16614,7 +16726,7 @@ crew manifest: ${seats2.length ? seats2.map((c) => c.name).join(", ") : "non
16614
16726
  process.exitCode = 1;
16615
16727
  } else if (!row.user_id) {
16616
16728
  process.stdout.write(
16617
- `operator identity: ERROR Crew row "${row.name ?? op}" has no user_id \u2014 their UI comments will read "(no identity)"; link it to the account they sign in with
16729
+ `operator identity: ERROR Crew row "${row.name ?? op}" has no user_id \u2014 their UI comments will read "(no identity)"; re-run \`crew connect\` to link it
16618
16730
  `
16619
16731
  );
16620
16732
  process.exitCode = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.50.3",
3
+ "version": "0.51.1",
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",