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.
- package/README.md +120 -24
- package/dist/adapters/hub-manager/credentials.js +58 -0
- package/dist/adapters/hub-manager/install.js +93 -14
- package/dist/adapters/hub-manager/port-probe.js +38 -0
- package/dist/adapters/hub-reassign.js +115 -0
- package/dist/adapters/package-versions.js +24 -0
- package/dist/adapters/setup/hub.js +1 -1
- package/dist/adapters/setup/json-writer.js +8 -3
- package/dist/cli.js +100 -20
- package/dist/domain/local-hub-model.js +40 -0
- package/dist/domain/nav-model.js +8 -0
- package/dist/domain/reassign-model.js +101 -0
- package/dist/domain/reassign-picker.js +258 -0
- package/dist/domain/setup-wizard.js +23 -4
- package/dist/domain/version-info.js +5 -0
- package/dist/ports/reassign-actions.js +1 -0
- package/dist/ui/app.js +9 -4
- package/dist/ui/components/table.js +2 -2
- package/dist/ui/layout.js +3 -2
- package/dist/ui/reassign-panel.js +36 -0
- package/dist/ui/setup/wizard-screen.js +31 -4
- package/dist/ui/tasks-screen.js +167 -10
- package/package.json +3 -3
- package/vendor/kankaku-pi/package.json +3 -3
|
@@ -59,10 +59,15 @@ export function backupOnce(filePath) {
|
|
|
59
59
|
if (sourceIsOwnerOnly)
|
|
60
60
|
tightenBestEffort(backupPath);
|
|
61
61
|
}
|
|
62
|
-
/**
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
334
|
-
*
|
|
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
|
|
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
|
|
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
|
+
}
|
package/dist/domain/nav-model.js
CHANGED
|
@@ -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
|
+
}
|