@tablation/crew 0.72.0 → 0.72.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.
Files changed (2) hide show
  1. package/dist/cli.js +95 -34
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2587,7 +2587,9 @@ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome, t
2587
2587
  }
2588
2588
  }
2589
2589
  const hasRepos = Object.keys(repoDirs).length > 0;
2590
- const dir = c?.dir ? expand(String(c.dir), base) : hasRepos ? Object.values(repoDirs)[0] : missing.req(void 0, `${where}.dir (or ${where}.repos)`) ?? "";
2590
+ const reposBasePath = expand(String(c?.reposBasePath ?? raw.ship?.reposBasePath ?? DEFAULT_REPOS_BASE_PATH), base);
2591
+ const dirIsCloneBase = !c?.dir && !hasRepos && c?.repos && typeof c.repos === "object" && !Array.isArray(c.repos);
2592
+ const dir = c?.dir ? expand(String(c.dir), base) : hasRepos ? Object.values(repoDirs)[0] : dirIsCloneBase ? join7(reposBasePath, splitRoute(route)[0]) : missing.req(void 0, `${where}.dir (or ${where}.repos)`) ?? "";
2591
2593
  const weight = c?.weight === void 0 ? void 0 : Number(c.weight);
2592
2594
  if (weight !== void 0 && !(Number.isFinite(weight) && weight > 0)) {
2593
2595
  missing.add(`${where}.weight must be a positive number (got "${c?.weight}")`);
@@ -2597,8 +2599,9 @@ function parseOneRoute(c, where, file, base, stateDir, raw, missing, crewHome, t
2597
2599
  enabled: c?.enabled === true,
2598
2600
  area: c?.area,
2599
2601
  dir,
2602
+ ...dirIsCloneBase ? { dirIsCloneBase: true } : {},
2600
2603
  repos: repoDirs,
2601
- reposBasePath: expand(String(c?.reposBasePath ?? raw.ship?.reposBasePath ?? DEFAULT_REPOS_BASE_PATH), base),
2604
+ reposBasePath,
2602
2605
  repoOverrides,
2603
2606
  worktreePrefix: c?.worktreePrefix ? String(c.worktreePrefix) : void 0,
2604
2607
  ...toolchain !== void 0 ? { toolchain } : {},
@@ -2656,7 +2659,7 @@ function dirForRepo(r, repoId) {
2656
2659
  }
2657
2660
  function repoTargetFor(r, repoId) {
2658
2661
  const names = r.resolved?.repoNames;
2659
- if (!repoId || !names) return Object.keys(r.repos).length ? null : { name: basename3(r.dir), dir: r.dir };
2662
+ if (!repoId || !names) return Object.keys(r.repos).length || r.dirIsCloneBase ? null : { name: basename3(r.dir), dir: r.dir };
2660
2663
  const name = names[repoId];
2661
2664
  if (!name) return null;
2662
2665
  return reposOf(r).find((t) => t.name === name) ?? null;
@@ -2747,6 +2750,15 @@ function reposOf(r) {
2747
2750
  const named = Object.entries(r.repos);
2748
2751
  if (named.length > 0) return named.map(([name, dir]) => ({ name, dir }));
2749
2752
  const trackerNames = r.resolved?.repoNames ? Object.values(r.resolved.repoNames) : [];
2753
+ if (r.dirIsCloneBase) {
2754
+ const [workspace2] = splitRoute(r.route);
2755
+ const remotes2 = r.resolved?.repoRemotes ?? {};
2756
+ return Object.entries(r.resolved?.repoNames ?? {}).map(([id, name]) => ({
2757
+ name,
2758
+ dir: defaultRepoDir(r.reposBasePath, workspace2, name),
2759
+ remote: remotes2[id]
2760
+ }));
2761
+ }
2750
2762
  if (trackerNames.length <= 1) return [{ name: basename3(r.dir), dir: r.dir }];
2751
2763
  const remotes = r.resolved?.repoRemotes ?? {};
2752
2764
  const remoteByName = {};
@@ -8632,6 +8644,7 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
8632
8644
  "key",
8633
8645
  "level",
8634
8646
  "limit",
8647
+ "machine-id",
8635
8648
  "max-roles",
8636
8649
  "mcp",
8637
8650
  "member",
@@ -8644,6 +8657,7 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
8644
8657
  "ship-name",
8645
8658
  "tail",
8646
8659
  "ticket",
8660
+ "toolchain",
8647
8661
  "workspace",
8648
8662
  "workspace-id",
8649
8663
  "write"
@@ -8798,40 +8812,47 @@ function readPointers(stateDir) {
8798
8812
  try {
8799
8813
  const parsed = JSON.parse(readFileSync16(lastSessionFile(stateDir), "utf8"));
8800
8814
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
8801
- return Object.fromEntries(Object.entries(parsed).filter((e) => typeof e[1] === "string"));
8815
+ const out = {};
8816
+ for (const [host, v] of Object.entries(parsed)) {
8817
+ const slugs = (Array.isArray(v) ? v : [v]).filter((x) => typeof x === "string" && x !== "");
8818
+ if (slugs.length) out[host] = slugs;
8819
+ }
8820
+ return out;
8802
8821
  } catch {
8803
8822
  return {};
8804
8823
  }
8805
8824
  }
8825
+ function writePointers(stateDir, pointers) {
8826
+ const file = lastSessionFile(stateDir);
8827
+ mkdirSync10(dirname8(file), { recursive: true });
8828
+ writeFileSync11(file, `${JSON.stringify(pointers, null, 2)}
8829
+ `, { mode: 384 });
8830
+ }
8806
8831
  function rememberSession(stateDir, host, workspaceSlug) {
8807
8832
  try {
8808
- const file = lastSessionFile(stateDir);
8809
- mkdirSync10(dirname8(file), { recursive: true });
8810
- writeFileSync11(file, `${JSON.stringify({ ...readPointers(stateDir), [host]: workspaceSlug }, null, 2)}
8811
- `, { mode: 384 });
8833
+ const all = readPointers(stateDir);
8834
+ writePointers(stateDir, { ...all, [host]: [...(all[host] ?? []).filter((w) => w !== workspaceSlug), workspaceSlug] });
8812
8835
  } catch {
8813
8836
  }
8814
8837
  }
8815
- async function resumeKeychainSession(o) {
8816
- const workspace = readPointers(o.stateDir)[o.host];
8817
- if (!workspace) return void 0;
8818
- try {
8819
- const stored = await o.store.get(o.host, workspace);
8820
- if (!stored?.apiKey) return void 0;
8821
- if (!await o.verify(stored.apiKey)) return void 0;
8822
- return { workspace, apiKey: stored.apiKey };
8823
- } catch {
8824
- return void 0;
8838
+ async function resumableSessions(o) {
8839
+ const out = [];
8840
+ for (const workspace of readPointers(o.stateDir)[o.host] ?? []) {
8841
+ try {
8842
+ const stored = await o.store.get(o.host, workspace);
8843
+ if (stored?.apiKey && await o.verify(stored.apiKey)) out.push({ workspace, apiKey: stored.apiKey });
8844
+ } catch {
8845
+ }
8825
8846
  }
8847
+ return out;
8826
8848
  }
8827
8849
  function rememberedSessions(stateDir) {
8828
- return Object.entries(readPointers(stateDir)).map(([host, workspace]) => ({ host, workspace }));
8850
+ return Object.entries(readPointers(stateDir)).flatMap(([host, ws]) => ws.map((workspace) => ({ host, workspace })));
8829
8851
  }
8830
8852
  function forgetSession(stateDir, host) {
8831
8853
  try {
8832
8854
  const { [host]: _gone, ...rest } = readPointers(stateDir);
8833
- if (Object.keys(rest).length) writeFileSync11(lastSessionFile(stateDir), `${JSON.stringify(rest, null, 2)}
8834
- `, { mode: 384 });
8855
+ if (Object.keys(rest).length) writePointers(stateDir, rest);
8835
8856
  else rmSync10(lastSessionFile(stateDir), { force: true });
8836
8857
  } catch {
8837
8858
  }
@@ -13392,7 +13413,7 @@ function renderConnection(d, route, dir, opts = {}) {
13392
13413
  return ` - route: ${route}
13393
13414
  enabled: false # arm it deliberately, once doctor is green
13394
13415
  ${opts.area ? ` area: "${opts.area}"
13395
- ` : ""} dir: "${dir}"
13416
+ ` : ""} ${dir === null ? "repos: {} # `crew repos clone` / `crew repos add` fill this in" : `dir: "${dir}"`}
13396
13417
  # worktreePrefix: omitted \u2014 each repo derives its own from its checkout's
13397
13418
  # directory name (ISSUE-400). Only set this if every repo this route
13398
13419
  # serves genuinely wants the SAME prefix, which is rare once an area has
@@ -13937,6 +13958,15 @@ function configuredRepos(text, route, base) {
13937
13958
  const dir = m.get("dir");
13938
13959
  return dir ? [{ name: basename8(String(dir)), dir: expandDir(String(dir), base) }] : [];
13939
13960
  }
13961
+ function cloneBaseOf(text, route, base, isCheckout) {
13962
+ const m = routeMapOf(parseDocument2(text), route);
13963
+ const repos = m.get("repos", true);
13964
+ if (isMap(repos) && repos.items.length > 0) return null;
13965
+ const dir = m.get("dir");
13966
+ if (!dir) return null;
13967
+ const abs = expandDir(String(dir), base);
13968
+ return isCheckout(abs) ? null : abs;
13969
+ }
13940
13970
  function routeSetsHooks(text, route, name) {
13941
13971
  const m = routeMapOf(parseDocument2(text), route);
13942
13972
  const repos = m.get("repos", true);
@@ -13947,7 +13977,8 @@ function routeSetsHooks(text, route, name) {
13947
13977
  return isMap(hooks) && hooks.items.length > 0;
13948
13978
  }
13949
13979
  function listRepoRows(i) {
13950
- const checkouts = configuredRepos(i.text, i.route, i.base);
13980
+ const cloneBase = i.isCheckout ? cloneBaseOf(i.text, i.route, i.base, i.isCheckout) : null;
13981
+ const checkouts = configuredRepos(i.text, i.route, i.base).filter((c) => c.dir !== cloneBase);
13951
13982
  const claimed = /* @__PURE__ */ new Set();
13952
13983
  const hooksSourceOf = (c) => i.exists(c.dir) && i.repoHasHooks(c.dir) ? "repo" : routeSetsHooks(i.text, i.route, c.name) ? "route" : "none";
13953
13984
  const originFor = (c, remote) => {
@@ -13997,8 +14028,19 @@ function planRepoAdd(i) {
13997
14028
  const doc = parseDocument2(i.text);
13998
14029
  if (doc.errors.length > 0) throw new ReposError(`config is not valid YAML \u2014 ${doc.errors[0].message}`);
13999
14030
  const m = routeMapOf(doc, i.route);
14000
- const existing = configuredRepos(i.text, i.route, i.base);
14031
+ let existing = configuredRepos(i.text, i.route, i.base);
14001
14032
  const target = expandDir(i.path, i.base);
14033
+ let droppedDir;
14034
+ const placeholder = m.get("dir");
14035
+ const reposNow = m.get("repos", true);
14036
+ if (i.isCheckout && placeholder && !(isMap(reposNow) && reposNow.items.length > 0)) {
14037
+ const placeholderDir = expandDir(String(placeholder), i.base);
14038
+ if (placeholderDir !== target && !i.isCheckout(placeholderDir)) {
14039
+ droppedDir = placeholderDir;
14040
+ m.delete("dir");
14041
+ existing = [];
14042
+ }
14043
+ }
14002
14044
  if (existing.some((e) => e.dir === target)) {
14003
14045
  throw new ReposError(`${target} is already configured on route ${i.route}`);
14004
14046
  }
@@ -14024,11 +14066,14 @@ function planRepoAdd(i) {
14024
14066
  pair.key = doc.createNode("repos");
14025
14067
  pair.value = doc.createNode({ [oldName]: oldDir, [name]: target });
14026
14068
  mode = "repos";
14069
+ } else if (droppedDir) {
14070
+ m.set("repos", doc.createNode({ [name]: target }));
14071
+ mode = "repos";
14027
14072
  } else {
14028
14073
  m.set("dir", target);
14029
14074
  mode = "dir";
14030
14075
  }
14031
- return { text: String(doc), mode, name, block: renderBlock(m), warning, unmatched: !matched };
14076
+ return { text: String(doc), mode, name, block: renderBlock(m), warning, unmatched: !matched, droppedDir };
14032
14077
  }
14033
14078
  function planRepoRemove(text, base, route, path) {
14034
14079
  const doc = parseDocument2(text);
@@ -14118,7 +14163,7 @@ function planRepoClone(i) {
14118
14163
  const ship = doc.get("ship", true);
14119
14164
  const basePath = String(m.get("reposBasePath") ?? (isMap(ship) ? ship.get("reposBasePath") : void 0) ?? "~/Crew");
14120
14165
  const target = i.dir ? expandDir(i.dir, i.base) : join26(expandDir(basePath, i.base), i.route.split("/")[0], name);
14121
- const add = planRepoAdd({ text: i.text, base: i.base, route: i.route, path: target, origin: remote, resolved: i.resolved, name });
14166
+ const add = planRepoAdd({ text: i.text, base: i.base, route: i.route, path: target, origin: remote, resolved: i.resolved, name, isCheckout: i.isCheckout });
14122
14167
  return { ...add, remote, target };
14123
14168
  }
14124
14169
 
@@ -18409,6 +18454,7 @@ ${changed2.map((c) => ` - ${c}
18409
18454
  const originOf = (dir) => gitOk(dir, ["remote", "get-url", "origin"]) ?? void 0;
18410
18455
  const text = configText;
18411
18456
  const base = dirname20(cfg.configFile);
18457
+ const isCheckout = (d) => existsSync34(`${d}/.git`);
18412
18458
  try {
18413
18459
  if (sub === "list") {
18414
18460
  if (flag("json")) {
@@ -18419,6 +18465,7 @@ ${changed2.map((c) => ` - ${c}
18419
18465
  resolved: resolvedRemotes,
18420
18466
  originOf,
18421
18467
  exists: (d) => existsSync34(d),
18468
+ isCheckout,
18422
18469
  repoHasHooks: (d) => REPO_CONFIG_FILENAMES.some((f) => {
18423
18470
  try {
18424
18471
  return Object.keys(parseRepoConfig(readFileSync28(resolvePath(d, f), "utf8"), f).hooks).length > 0;
@@ -18427,11 +18474,12 @@ ${changed2.map((c) => ` - ${c}
18427
18474
  }
18428
18475
  })
18429
18476
  });
18430
- process.stdout.write(`${JSON.stringify({ route: routeName, repos: rows })}
18477
+ process.stdout.write(`${JSON.stringify({ route: routeName, repos: rows, cloneBase: cloneBaseOf(text, routeName, base, isCheckout) })}
18431
18478
  `);
18432
18479
  break;
18433
18480
  }
18434
- const repos = configuredRepos(text, routeName, base);
18481
+ const cloneBase = cloneBaseOf(text, routeName, base, isCheckout);
18482
+ const repos = configuredRepos(text, routeName, base).filter((r) => r.dir !== cloneBase);
18435
18483
  if (repos.length === 0) process.stdout.write(`route ${routeName} has no checkout configured \u2014 \`crew repos add ${routeName} <path>\`
18436
18484
  `);
18437
18485
  for (const r of repos) {
@@ -18448,7 +18496,7 @@ ${changed2.map((c) => ` - ${c}
18448
18496
  const say = (event, extra, human) => process.stdout.write(json ? `${JSON.stringify({ event, ...extra })}
18449
18497
  ` : `${human}
18450
18498
  `);
18451
- const plan2 = planRepoClone({ text, base, route: routeName, name: addName, resolved: resolvedRemotes, dir: value("dir") });
18499
+ const plan2 = planRepoClone({ text, base, route: routeName, name: addName, resolved: resolvedRemotes, dir: value("dir"), isCheckout });
18452
18500
  if (existsSync34(plan2.target) && (existsSync34(`${plan2.target}/.git`) || readdirSync14(plan2.target).length > 0)) {
18453
18501
  throw new ReposError(
18454
18502
  existsSync34(`${plan2.target}/.git`) ? `${plan2.target} is already a checkout \u2014 attach it with \`crew repos add ${routeName} ${plan2.name} ${plan2.target}\`` : `${plan2.target} exists and is not empty \u2014 pass --dir to clone somewhere else`
@@ -18470,6 +18518,8 @@ ${changed2.map((c) => ` - ${c}
18470
18518
  crew.yaml was not changed`);
18471
18519
  }
18472
18520
  writeFileSync22(cfg.configFile, plan2.text);
18521
+ if (plan2.droppedDir) process.stderr.write(`crew repos: dropped dir: ${plan2.droppedDir} from route ${routeName} \u2014 it is not a checkout (the clone base); checkouts now live under repos:
18522
+ `);
18473
18523
  say("attached", { route: routeName, name: plan2.name, dir: plan2.target, mode: plan2.mode }, `cloned and attached ${plan2.target} to ${routeName} as ${plan2.mode === "dir" ? "dir" : `repos.${plan2.name}`} in ${cfg.configFile}`);
18474
18524
  break;
18475
18525
  }
@@ -18485,8 +18535,11 @@ ${changed2.map((c) => ` - ${c}
18485
18535
  origin,
18486
18536
  resolved: resolvedRemotes,
18487
18537
  name: value("name") ?? addName,
18488
- nameForExisting: (d) => matchRepoName(originOf(d), resolvedRemotes)
18538
+ nameForExisting: (d) => matchRepoName(originOf(d), resolvedRemotes),
18539
+ isCheckout
18489
18540
  });
18541
+ if (plan.droppedDir) process.stderr.write(`crew repos: dropped dir: ${plan.droppedDir} from route ${routeName} \u2014 it is not a checkout (the clone base); checkouts now live under repos:
18542
+ `);
18490
18543
  let rowTracker;
18491
18544
  let rowPayload;
18492
18545
  if (plan.unmatched && origin && !flag("no-create")) {
@@ -18686,8 +18739,8 @@ ${reconstruction.prompt}
18686
18739
  }
18687
18740
  }
18688
18741
  let signInSource = apiKey ? "key" : "device";
18689
- if (!apiKey && !workspace && dryRun && store) {
18690
- const resumed = await resumeKeychainSession({
18742
+ if (!apiKey && !workspace && store) {
18743
+ const resumable = await resumableSessions({
18691
18744
  store,
18692
18745
  stateDir: cfg.ship.stateDir,
18693
18746
  host: hostFromUrl4(baseUrl),
@@ -18700,6 +18753,14 @@ ${reconstruction.prompt}
18700
18753
  }
18701
18754
  }
18702
18755
  });
18756
+ if (resumable.length > 1 && connectEvents.json) {
18757
+ stopForChoice(
18758
+ "workspace",
18759
+ `${resumable.length} workspaces have a remembered sign-in on ${hostFromUrl4(baseUrl)} \u2014 which one?`,
18760
+ resumable.map((r) => ({ value: r.workspace, label: r.workspace }))
18761
+ );
18762
+ }
18763
+ const resumed = resumable.length === 1 ? resumable[0] : void 0;
18703
18764
  if (resumed) {
18704
18765
  apiKey = resumed.apiKey;
18705
18766
  workspace = resumed.workspace;
@@ -19095,11 +19156,11 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
19095
19156
  const keyPersisted = keychainBacked || apiKeyFile !== void 0;
19096
19157
  const wizard = firstRun;
19097
19158
  const writeFirstRun = wizard !== void 0 && !dryRun && keyPersisted;
19098
- const routeDir = value("dir") ?? (writeFirstRun ? join35(DEFAULT_REPOS_BASE_PATH, found.workspaceSlug) : void 0);
19159
+ const routeDir = value("dir");
19099
19160
  const routeBlock = renderConnection(
19100
19161
  found,
19101
19162
  route2,
19102
- routeDir ?? "REPLACE \u2014 the local checkout this route works",
19163
+ routeDir ?? (writeFirstRun ? null : "REPLACE \u2014 the local checkout this route works"),
19103
19164
  {
19104
19165
  area: value("area"),
19105
19166
  apiKeyFile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.72.0",
3
+ "version": "0.72.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",