kankaku 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -59,10 +59,15 @@ export function backupOnce(filePath) {
59
59
  if (sourceIsOwnerOnly)
60
60
  tightenBestEffort(backupPath);
61
61
  }
62
- /** Write `value` as 2-space-indented JSON to `filePath` via tmp file + rename, creating the parent directory when missing. */
63
- export function writeJsonAtomic(filePath, value, dirMode) {
62
+ /**
63
+ * Write `value` as 2-space-indented JSON to `filePath` via tmp file + rename,
64
+ * creating the parent directory when missing. With `fileMode` the temp file
65
+ * is created with that mode from the first byte, so a secret is never
66
+ * readable through a wider mode, not even for the instant before a chmod.
67
+ */
68
+ export function writeJsonAtomic(filePath, value, dirMode, fileMode) {
64
69
  mkdirSync(dirname(filePath), dirMode !== undefined ? { recursive: true, mode: dirMode } : { recursive: true });
65
70
  const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
66
- writeFileSync(tmp, JSON.stringify(value, null, 2));
71
+ writeFileSync(tmp, JSON.stringify(value, null, 2), fileMode !== undefined ? { mode: fileMode } : {});
67
72
  renameSync(tmp, filePath);
68
73
  }
package/dist/cli.js CHANGED
@@ -11,8 +11,11 @@ import { buildSyncStatusLines, formatCatalogRefreshLines, formatSyncSummaryLines
11
11
  import { readTuiConfig } from "./adapters/tui-config.js";
12
12
  import { discoverProjects } from "./adapters/project-discovery.js";
13
13
  import { readProjectRecords } from "./adapters/worklog-reader.js";
14
+ import { createReassignActions } from "./adapters/hub-reassign.js";
14
15
  import { computeProjectSyncStatus, createCatalog, refreshCatalog as refreshCatalogAdapter, resolveHub, syncProject } from "./adapters/hub.js";
15
16
  import { readOwnVersion } from "./adapters/app-info.js";
17
+ import { readCarriedVersions } from "./adapters/package-versions.js";
18
+ import { formatVersionLines } from "./domain/version-info.js";
16
19
  import { buildCatalogModel } from "./domain/catalog-model.js";
17
20
  import { buildTodayRows, formatTodayLines } from "./domain/today-model.js";
18
21
  import { buildDashboardModel } from "./domain/dashboard-model.js";
@@ -21,6 +24,7 @@ import { buildTasksModel } from "./domain/tasks-model.js";
21
24
  import { buildSyncRows } from "./domain/sync-model.js";
22
25
  import { DEFAULT_THEME, ThemeProvider, resolveTheme } from "./ui/theme.js";
23
26
  import { detectAgents, formatDoctorLines, formatSetupPlanLines, planSetup } from "./domain/setup-plan.js";
27
+ import { parseHubPort } from "./domain/setup-wizard.js";
24
28
  import { readAgentFacts } from "./adapters/setup/agents.js";
25
29
  import { addKankakuPackage, removeKankakuPackage } from "./adapters/setup/pi.js";
26
30
  import { removeClaudeIntegration, writeClaudeIntegration } from "./adapters/setup/claude.js";
@@ -31,11 +35,13 @@ import { tuiConfigPath, writeTuiConfig } from "./adapters/setup/tui-config.js";
31
35
  import { installLocalHub } from "./adapters/setup/local-hub.js";
32
36
  import { createChildProcessRunner } from "./adapters/setup/child-process-runner.js";
33
37
  import { createReadlinePrompter } from "./adapters/setup/readline-prompter.js";
34
- import { hubLayout, parseHubConfig } from "./domain/local-hub-model.js";
38
+ import { describeSyncTarget, hubLayout, parseHubConfig } from "./domain/local-hub-model.js";
35
39
  import { hubLogs, hubStatus, installHub, startHub, stopHub, upgradeHub } from "./adapters/hub-manager/install.js";
36
40
  import { isAlive, readPid, startDetached } from "./adapters/hub-manager/process.js";
37
41
  import { locateHubPackage } from "./adapters/hub-manager/package.js";
38
- const USAGE = "usage: kankaku [today|tasks [--all]|catalog [refresh]|sync [status|all] [--project <dir>]|setup [--yes] [--dry-run] [--from-checkout <dir>] [--claude-plugin-dir <dir>]|doctor|hub install [--port N] [--owner-email E] [--owner-password P]|hub start|hub stop|hub status|hub upgrade|hub logs [-n N]] [--roots a,b] [--theme name]\n";
42
+ import { useLocalHub } from "./adapters/hub-manager/credentials.js";
43
+ import { firstFreePort, isPortFree, realPortBinder } from "./adapters/hub-manager/port-probe.js";
44
+ const USAGE = "usage: kankaku [--version|-v|version|today|tasks [--all]|catalog [refresh]|sync [status|all] [--project <dir>]|setup [--yes] [--dry-run] [--from-checkout <dir>] [--claude-plugin-dir <dir>]|doctor|hub install [--port N] [--owner-email E] [--owner-password P]|hub use|hub start|hub stop|hub status|hub upgrade|hub logs [-n N]] [--roots a,b] [--theme name]\n";
39
45
  /** Load today's model for `roots`: discover projects, read their worklogs, build rows. */
40
46
  export function loadToday(roots) {
41
47
  const projects = discoverProjects(roots);
@@ -267,19 +273,23 @@ async function runDoctorCommand(deps, claudePluginOverride) {
267
273
  * with no network call — the hub's health is checked interactively, from
268
274
  * the wizard's own Hub step, never upfront.
269
275
  */
270
- function gatherWizardFacts(deps, claudePluginOverride) {
276
+ export function gatherWizardFacts(deps, claudePluginOverride) {
271
277
  const agentFacts = readAgentFacts(deps.homeDir, claudePluginOverride);
272
278
  const hubResolution = resolveHub({ env: deps.env ?? {}, homeDir: () => deps.homeDir });
273
279
  const credPath = credentialsPath(deps.homeDir);
274
- const hub = hubResolution.ok
275
- ? {
276
- credentialsPresent: true,
277
- url: hubResolution.credentials.url,
278
- email: hubResolution.credentials.email,
279
- password: hubResolution.credentials.password,
280
- credentialsPath: credPath,
281
- }
282
- : { credentialsPresent: false, url: undefined, email: undefined, password: undefined, credentialsPath: credPath };
280
+ const localPort = localHubPort(deps);
281
+ const hub = {
282
+ ...(hubResolution.ok
283
+ ? {
284
+ credentialsPresent: true,
285
+ url: hubResolution.credentials.url,
286
+ email: hubResolution.credentials.email,
287
+ password: hubResolution.credentials.password,
288
+ credentialsPath: credPath,
289
+ }
290
+ : { credentialsPresent: false, url: undefined, email: undefined, password: undefined, credentialsPath: credPath }),
291
+ ...(localPort !== undefined ? { localPort } : {}),
292
+ };
283
293
  const tuiPath = tuiConfigPath(deps.homeDir);
284
294
  const roots = {
285
295
  current: existsSync(tuiPath) ? readTuiConfig(deps.homeDir, deps.cwd).roots : undefined,
@@ -303,6 +313,7 @@ function buildHubManagerDeps(deps) {
303
313
  platform: process.platform,
304
314
  arch: process.arch,
305
315
  isAlive: (pid) => isAlive(pid),
316
+ portBinder: realPortBinder,
306
317
  ...deps.hubManager,
307
318
  };
308
319
  }
@@ -330,8 +341,10 @@ async function performLocalHubInstallFromCheckout(checkout, deps) {
330
341
  * `--claude-plugin-dir`/`KANKAKU_CLAUDE_PLUGIN_DIR` — see
331
342
  * `runCli`/`resolveClaudePluginOverride`), never from wizard state.
332
343
  * `install-local-hub` runs the real `hub-manager/install.ts#installHub`
333
- * (which writes `accounts.json` and `~/.kankaku/credentials.json`
334
- * itself), reporting every install step in the result detail. Never
344
+ * (which writes `accounts.json` and `service.json`, and
345
+ * `~/.kankaku/credentials.json` only when none exists or it already
346
+ * points at the local hub), reporting every install step in the result
347
+ * detail - the `sync credentials` step with its own explanation. Never
335
348
  * throws: any adapter failure becomes an `"error"` outcome instead.
336
349
  */
337
350
  async function applyWizardAction(action, state, deps, claudePluginOverride) {
@@ -363,8 +376,8 @@ async function applyWizardAction(action, state, deps, claudePluginOverride) {
363
376
  return { action, outcome: result.changed ? "wrote" : "unchanged" };
364
377
  }
365
378
  case "install-local-hub": {
366
- const report = await installHub({ ownerEmail: state.hub.ownerEmail, ownerPassword: state.hub.ownerPassword }, buildHubManagerDeps(deps));
367
- const detail = report.steps.map((step) => `${step.step}: ${step.outcome}`).join("; ");
379
+ const report = await installHub({ port: parseHubPort(state.hub.port), ownerEmail: state.hub.ownerEmail, ownerPassword: state.hub.ownerPassword }, buildHubManagerDeps(deps));
380
+ const detail = report.steps.map((step) => `${step.step}: ${step.outcome}${step.step === "sync credentials" && step.detail ? ` (${step.detail})` : ""}`).join("; ");
368
381
  if (!report.ok)
369
382
  return { action, outcome: "error", detail };
370
383
  return { action, outcome: "started", detail: `${detail} — running at ${report.url}` };
@@ -401,11 +414,17 @@ function announceCommands(deps, result) {
401
414
  for (const file of result.foreign)
402
415
  deps.stdout(`skipped ${file} (not a kankaku command)`);
403
416
  }
417
+ /** The binder for the wizard's port probe: the real one, unless a test injected another through `CliDeps.hubManager`. */
418
+ function portBinderFor(deps) {
419
+ return deps.hubManager?.portBinder ?? realPortBinder;
420
+ }
404
421
  /** Build the setup wizard's `WizardActions` for the interactive app: every write goes through the same real `adapters/setup/*` writers `kankaku setup --yes` uses. Used only by `renderApp`. */
405
422
  export function buildWizardActions(deps, claudePluginOverride) {
406
423
  return {
407
424
  apply: (action, state) => applyWizardAction(action, state, deps, claudePluginOverride),
408
425
  checkHealth: (url) => checkHubHealth(url, { fetch: deps.fetch }),
426
+ isPortFree: (port) => isPortFree(port, portBinderFor(deps)),
427
+ suggestPort: (from) => firstFreePort(from, 20, portBinderFor(deps)),
409
428
  };
410
429
  }
411
430
  /** `--claude-plugin-dir <dir>`'s value when given, else `KANKAKU_CLAUDE_PLUGIN_DIR`; `undefined` resolves to the bundled `kankaku-claude` package (see `adapters/setup/claude-plugin.ts#locateClaudePlugin`). */
@@ -444,6 +463,28 @@ function formatHubStatusLine(status, pocketbaseVersion, pbDataBytes) {
444
463
  const pbPart = pocketbaseVersion ? ` (PocketBase ${pocketbaseVersion})` : "";
445
464
  return `local hub: ${label}${status.version ? ` ${status.version}` : ""}${pbPart} at ${status.url} · pb_data ${formatBytes(pbDataBytes)}`;
446
465
  }
466
+ /** The URL this machine's sync resolves to (`resolveHub`: environment first, then `~/.kankaku/credentials.json`), or `undefined` when none is configured. */
467
+ function syncCredentialsUrl(deps) {
468
+ const hub = resolveHub({ env: deps.env ?? {}, homeDir: () => deps.homeDir });
469
+ return hub.ok ? hub.credentials.url : undefined;
470
+ }
471
+ /** The installed local hub's port from `hub.json`, or `undefined` when it is not installed (or `hub.json` is unreadable). */
472
+ function localHubPort(deps) {
473
+ const layout = hubLayout(deps.homeDir);
474
+ if (!existsSync(layout.hubJson))
475
+ return undefined;
476
+ try {
477
+ return parseHubConfig(JSON.parse(readFileSync(layout.hubJson, "utf8"))).port;
478
+ }
479
+ catch {
480
+ return undefined;
481
+ }
482
+ }
483
+ /** The installed local hub's URL, or `undefined` when it is not installed. */
484
+ function localHubUrl(deps) {
485
+ const port = localHubPort(deps);
486
+ return port === undefined ? undefined : `http://127.0.0.1:${port}`;
487
+ }
447
488
  /** `-n <count>`'s value, defaulting to 50 when absent or not a positive integer. */
448
489
  function logCountFlag(args) {
449
490
  const index = args.indexOf("-n");
@@ -454,12 +495,13 @@ function flagValue(args, flag) {
454
495
  const index = args.indexOf(flag);
455
496
  return index === -1 ? undefined : args[index + 1];
456
497
  }
498
+ /** `--port`'s value: `undefined` when the flag is absent, `NaN` when it is present but not a whole number between 1 and 65535. */
457
499
  function portFlag(args) {
458
500
  const raw = flagValue(args, "--port");
459
501
  if (raw === undefined)
460
- return undefined;
502
+ return args.includes("--port") ? Number.NaN : undefined;
461
503
  const value = Number(raw);
462
- return Number.isInteger(value) && value > 0 ? value : undefined;
504
+ return Number.isInteger(value) && value >= 1 && value <= 65535 ? value : Number.NaN;
463
505
  }
464
506
  /** Print every `HubActionReport` step, one per line (`<step>: <outcome> (<detail>)`), and the resulting URL when present. */
465
507
  function printHubReport(deps, report) {
@@ -482,6 +524,12 @@ async function runHubCommand(args, deps) {
482
524
  const [sub, ...rest] = args;
483
525
  const hubDeps = buildHubManagerDeps(deps);
484
526
  if (sub === "install") {
527
+ const port = portFlag(rest);
528
+ if (Number.isNaN(port)) {
529
+ deps.stderr("kankaku hub install: --port must be a whole number between 1 and 65535\n");
530
+ deps.exit(1);
531
+ return;
532
+ }
485
533
  let ownerEmail = flagValue(rest, "--owner-email");
486
534
  let ownerPassword = flagValue(rest, "--owner-password");
487
535
  if ((ownerEmail === undefined || ownerPassword === undefined) && (deps.isTTY?.() ?? false) && deps.prompter) {
@@ -493,7 +541,7 @@ async function runHubCommand(args, deps) {
493
541
  deps.exit(1);
494
542
  return;
495
543
  }
496
- const report = await installHub({ port: portFlag(rest), ownerEmail, ownerPassword }, hubDeps);
544
+ const report = await installHub({ port, ownerEmail, ownerPassword }, hubDeps);
497
545
  printHubReport(deps, report);
498
546
  if (!report.ok)
499
547
  deps.exit(1);
@@ -524,6 +572,19 @@ async function runHubCommand(args, deps) {
524
572
  }
525
573
  }
526
574
  deps.stdout(formatHubStatusLine(status, pocketbaseVersion, dirSizeBytes(layout.pbData)));
575
+ deps.stdout(`sync: ${describeSyncTarget(syncCredentialsUrl(deps), localHubUrl(deps))}`);
576
+ return;
577
+ }
578
+ if (sub === "use") {
579
+ const result = useLocalHub(deps.homeDir);
580
+ if (!result.ok) {
581
+ deps.stderr(`kankaku hub use: ${result.error}\n`);
582
+ deps.exit(1);
583
+ return;
584
+ }
585
+ deps.stdout(result.changed
586
+ ? `sync now points at ${result.url} (was ${result.previousUrl ?? "not configured"})`
587
+ : `sync credentials: unchanged (already points at ${result.url})`);
527
588
  return;
528
589
  }
529
590
  if (sub === "upgrade") {
@@ -708,6 +769,10 @@ export async function runCli(argv, deps) {
708
769
  deps.renderApp(roots, theme, { startInWizard });
709
770
  return;
710
771
  }
772
+ if (command === "--version" || command === "-v" || command === "version") {
773
+ deps.stdout(formatVersionLines(readOwnVersion(), readCarriedVersions(deps.resolvePackage)).join("\n"));
774
+ return;
775
+ }
711
776
  if (command === "today") {
712
777
  const model = loadToday(roots);
713
778
  deps.stdout(formatTodayLines(model.rows, model.total).join("\n"));
@@ -759,6 +824,21 @@ function catalogScreenDeps(deps) {
759
824
  refresh: async () => buildCatalogModel(await refreshCatalogAdapter(catalog), now()),
760
825
  };
761
826
  }
827
+ /**
828
+ * Build the Tasks screen's reassignment deps for the interactive app; used
829
+ * only by `renderApp`. Without hub credentials, `prepare` reports why (the
830
+ * screen shows it in the footer and opens nothing) and `apply` is never
831
+ * reached.
832
+ */
833
+ export function reassignScreenDeps(deps) {
834
+ const hub = resolveHub({ env: deps.env ?? {}, homeDir: () => deps.homeDir });
835
+ if (!hub.ok) {
836
+ const reason = hub.reason;
837
+ return { prepare: async () => ({ ok: false, message: reason }), apply: async () => [] };
838
+ }
839
+ const { now, fetch: fetchOverride } = envDeps(deps);
840
+ return createReassignActions(hub.credentials, { homeDir: () => deps.homeDir, now, ...(fetchOverride ? { fetch: fetchOverride } : {}) });
841
+ }
762
842
  /** Build the Sync screen's `load`/`syncOne`/`syncAll` deps for the interactive app; used only by `renderApp`. */
763
843
  function syncScreenDeps(deps, roots) {
764
844
  const hub = resolveHub({ env: deps.env ?? {}, homeDir: () => deps.homeDir });
@@ -892,7 +972,7 @@ if (isMain) {
892
972
  isTTY: () => process.stdout.isTTY === true,
893
973
  prompter: createReadlinePrompter(process.stdin, process.stdout),
894
974
  renderApp: (roots, theme, options) => {
895
- render(_jsx(ThemeProvider, { theme: theme, children: _jsx(App, { roots: roots, version: readOwnVersion(), loadToday: () => loadDashboard(roots, realDeps), loadTasks: (options) => loadTasks(roots, options), catalog: catalogScreenDeps(realDeps), sync: syncScreenDeps(realDeps, roots), dashboardActions: dashboardActionsDeps(realDeps, roots), wizard: {
975
+ render(_jsx(ThemeProvider, { theme: theme, children: _jsx(App, { roots: roots, version: readOwnVersion(), loadToday: () => loadDashboard(roots, realDeps), loadTasks: (options) => loadTasks(roots, options), catalog: catalogScreenDeps(realDeps), sync: syncScreenDeps(realDeps, roots), reassign: reassignScreenDeps(realDeps), dashboardActions: dashboardActionsDeps(realDeps, roots), wizard: {
896
976
  facts: gatherWizardFacts(realDeps, options?.claudePluginDir ?? process.env["KANKAKU_CLAUDE_PLUGIN_DIR"]),
897
977
  actions: buildWizardActions(realDeps, options?.claudePluginDir ?? process.env["KANKAKU_CLAUDE_PLUGIN_DIR"]),
898
978
  }, startInWizard: options?.startInWizard }) }), { alternateScreen: true, exitOnCtrlC: true });
@@ -101,6 +101,7 @@ export function hubLayout(homeDir) {
101
101
  currentFile: joinPath(root, "current"),
102
102
  hubJson: joinPath(root, "hub.json"),
103
103
  accountsJson: joinPath(root, "accounts.json"),
104
+ serviceJson: joinPath(root, "service.json"),
104
105
  pidFile: joinPath(root, "pid"),
105
106
  logFile: joinPath(root, "hub.log"),
106
107
  };
@@ -177,3 +178,42 @@ export function generatePassword(randomBytes, length = 24) {
177
178
  }
178
179
  return password;
179
180
  }
181
+ /** Narrows `json` to a `ServiceAccount` (three non-empty strings), or `undefined` when it is anything else. */
182
+ export function parseServiceAccount(json) {
183
+ if (!json || typeof json !== "object" || Array.isArray(json))
184
+ return undefined;
185
+ const record = json;
186
+ const { url, email, password } = record;
187
+ if (typeof url !== "string" || url === "" || typeof email !== "string" || email === "" || typeof password !== "string" || password === "")
188
+ return undefined;
189
+ return { url, email, password };
190
+ }
191
+ function hostAndPort(url) {
192
+ try {
193
+ const parsed = new URL(url);
194
+ const host = parsed.hostname === "localhost" ? "127.0.0.1" : parsed.hostname;
195
+ return `${parsed.protocol}//${host}:${parsed.port}`;
196
+ }
197
+ catch {
198
+ return undefined;
199
+ }
200
+ }
201
+ /** Whether two hub URLs name the same scheme, host and port (`localhost` and `127.0.0.1` are the same host; a trailing slash or path is ignored). An unparsable URL never matches. */
202
+ export function sameHubUrl(a, b) {
203
+ const left = hostAndPort(a);
204
+ return left !== undefined && left === hostAndPort(b);
205
+ }
206
+ /** Install may write `~/.kankaku/credentials.json` only when none exists, or when it already points at this local hub. */
207
+ export function shouldWriteCredentials(existingUrl, localUrl) {
208
+ return existingUrl === undefined || sameHubUrl(existingUrl, localUrl);
209
+ }
210
+ /** The `sync credentials` step's detail when install leaves `credentials.json` alone. */
211
+ export function credentialsKeptDetail(existingUrl) {
212
+ return `this machine syncs to ${existingUrl}; run 'kankaku hub use' to switch to the local hub`;
213
+ }
214
+ /** Where this machine's sync points, for `kankaku hub status`: `local hub`, the other hub's URL, or `not configured`. */
215
+ export function describeSyncTarget(credentialsUrl, localUrl) {
216
+ if (credentialsUrl === undefined)
217
+ return "not configured";
218
+ return localUrl !== undefined && sameHubUrl(credentialsUrl, localUrl) ? "local hub" : credentialsUrl;
219
+ }
@@ -27,6 +27,14 @@ export function clearProjectFilter(state) {
27
27
  void projectFilter;
28
28
  return rest;
29
29
  }
30
+ /** Mark or clear the active screen's modal flow; clearing removes the key, so a state without a modal equals the plain one. */
31
+ export function setModal(state, open) {
32
+ if (open)
33
+ return { ...state, modal: true };
34
+ const { modal, ...rest } = state;
35
+ void modal;
36
+ return rest;
37
+ }
30
38
  /** Focus the main content zone (e.g. `enter`/`→`/`Tab` from the sidebar). */
31
39
  export function focusMain(state) {
32
40
  return { ...state, focus: "main" };
@@ -0,0 +1,101 @@
1
+ /** Why a task is left out of a reassignment plan. User-facing text, shown verbatim. */
2
+ export const NOT_ON_HUB = "not on the hub yet — sync first";
3
+ export const ALREADY_ASSIGNED = "already assigned";
4
+ /** How the catalog's unassigned client is named in every user-facing string. */
5
+ export const UNASSIGNED_LABEL = "unassigned";
6
+ function unassignedClient(clients) {
7
+ return clients.find((client) => client.unassigned === true);
8
+ }
9
+ /**
10
+ * `Client`, `Client · Project` or `Client · Project › Task`, by names; the
11
+ * unassigned client reads `unassigned`, a relation the catalog no longer
12
+ * knows reads `unknown …`, never an id.
13
+ */
14
+ export function describeAssignment(assignment, catalog) {
15
+ const client = catalog.clients.find((candidate) => candidate.id === assignment.clientId);
16
+ const clientText = assignment.clientId === "" ? "no client" : client === undefined ? "unknown client" : client.unassigned === true ? UNASSIGNED_LABEL : client.name;
17
+ if (assignment.projectId === "")
18
+ return clientText;
19
+ const project = catalog.projects.find((candidate) => candidate.id === assignment.projectId);
20
+ const projectText = project?.name ?? "unknown project";
21
+ if (assignment.hubTaskId === "")
22
+ return `${clientText} · ${projectText}`;
23
+ const task = catalog.tasks.find((candidate) => candidate.id === assignment.hubTaskId);
24
+ return `${clientText} · ${projectText} › ${task?.title ?? "unknown task"}`;
25
+ }
26
+ /** The targets whose hub row exists and sits on the catalog's unassigned client: what `M` may touch. */
27
+ export function eligibleForBulk(targets, rows, catalog) {
28
+ const unassigned = unassignedClient(catalog.clients);
29
+ if (unassigned === undefined)
30
+ return [];
31
+ return targets.filter((target) => rows.get(target.taskId)?.clientId === unassigned.id);
32
+ }
33
+ /** Validate `selection` against the catalog; returns the error message, or `undefined` when it is consistent. */
34
+ function validateSelection(selection, catalog) {
35
+ const client = catalog.clients.find((candidate) => candidate.id === selection.clientId);
36
+ if (client === undefined)
37
+ return "unknown client";
38
+ if (!client.active)
39
+ return `client "${client.name}" is not active`;
40
+ const projectId = selection.projectId ?? "";
41
+ const hubTaskId = selection.hubTaskId ?? "";
42
+ if (projectId === "") {
43
+ return hubTaskId === "" ? undefined : "a task needs a project";
44
+ }
45
+ const project = catalog.projects.find((candidate) => candidate.id === projectId);
46
+ if (project === undefined)
47
+ return "unknown project";
48
+ if (!project.active)
49
+ return `project "${project.name}" is not active`;
50
+ if (project.clientId !== client.id)
51
+ return `project "${project.name}" does not belong to client "${client.name}"`;
52
+ if (hubTaskId === "")
53
+ return undefined;
54
+ const task = catalog.tasks.find((candidate) => candidate.id === hubTaskId);
55
+ if (task === undefined)
56
+ return "unknown task";
57
+ if (task.projectId !== project.id)
58
+ return `task "${task.title}" does not belong to project "${project.name}"`;
59
+ if (task.status === "done")
60
+ return `task "${task.title}" is done`;
61
+ return undefined;
62
+ }
63
+ /**
64
+ * Turn a destination into one plan line per asked task. The selection is
65
+ * validated first and rejects the whole plan, so an inconsistent choice
66
+ * never yields a payload. A task with no hub row is skipped (sync first);
67
+ * in `bulk` mode a row that is not on the unassigned client is skipped
68
+ * (`already assigned`); a row that already has exactly the destination is
69
+ * `unchanged`; the rest are `reassign` lines carrying the relation payload.
70
+ * Pure: no request is made here.
71
+ */
72
+ export function planReassignment(input) {
73
+ const { rows, tasks, catalog, selection, mode } = input;
74
+ const error = validateSelection(selection, catalog);
75
+ if (error !== undefined)
76
+ return { ok: false, error };
77
+ const payload = { client: selection.clientId, project: selection.projectId ?? "", task: selection.hubTaskId ?? "" };
78
+ const destination = describeAssignment({ clientId: payload.client, projectId: payload.project, hubTaskId: payload.task }, catalog);
79
+ const unassigned = unassignedClient(catalog.clients);
80
+ const lines = tasks.map((target) => {
81
+ const row = rows.get(target.taskId);
82
+ if (row === undefined)
83
+ return { kind: "skipped", taskId: target.taskId, label: target.label, reason: NOT_ON_HUB };
84
+ if (mode === "bulk" && (unassigned === undefined || row.clientId !== unassigned.id)) {
85
+ return { kind: "skipped", taskId: target.taskId, label: target.label, reason: ALREADY_ASSIGNED };
86
+ }
87
+ if (row.clientId === payload.client && row.projectId === payload.project && row.hubTaskId === payload.task) {
88
+ return { kind: "unchanged", taskId: target.taskId, label: target.label, at: destination };
89
+ }
90
+ return {
91
+ kind: "reassign",
92
+ taskId: target.taskId,
93
+ label: target.label,
94
+ rowId: row.rowId,
95
+ from: describeAssignment(row, catalog),
96
+ to: destination,
97
+ payload: { ...payload },
98
+ };
99
+ });
100
+ return { ok: true, plan: { mode, destination, lines } };
101
+ }