@nolto/cli 0.7.1 → 0.8.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.
Files changed (3) hide show
  1. package/README.md +10 -2
  2. package/dist/index.js +520 -291
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -42,12 +42,20 @@ nolto whoami # Show resolved auth/config state and project count
42
42
 
43
43
  ```bash
44
44
  nolto link <projectId> # Write or update nolto.json at the repository root
45
- nolto link --show # Show the current binding and its source
45
+ nolto link --show # Show the current binding, source, and repository identity
46
+ nolto link --rebind # Rebind the project to this repository (owner only)
46
47
  nolto link --unlink # Remove projectId from nolto.json
47
48
  ```
48
49
 
49
50
  Commit `nolto.json` so everyone working in the repository targets the same Nolto
50
- project.
51
+ project. A Nolto project is bound to the first repository identity it syncs from.
52
+ The identity is the normalized `origin` remote, or a machine ID plus path when no
53
+ remote is available. Syncing from a different repository fails with
54
+ `409 repo_mismatch`. An owner can run `nolto link --rebind` in the correct
55
+ repository to change the binding, at most once every seven days.
56
+
57
+ CLI versions older than 0.8.0 do not send a repository identity and receive
58
+ `426`; run `nolto update` before syncing.
51
59
 
52
60
  ### Roadmap Sync
53
61
 
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // src/index.ts
4
4
  import { createRequire as createRequire3 } from "module";
5
5
  import { fileURLToPath as fileURLToPath4 } from "url";
6
- import path13 from "path";
6
+ import path15 from "path";
7
7
  import { CommanderError } from "commander";
8
8
 
9
9
  // src/config.ts
@@ -36,6 +36,38 @@ function mapHttpStatusToCliError(status, opts = {}) {
36
36
  status
37
37
  );
38
38
  }
39
+ if (status === 426) {
40
+ return new CliError(
41
+ opts.serverMessage ?? "Nolto CLI is outdated.",
42
+ 2,
43
+ "Run `nolto update`, then retry.",
44
+ status
45
+ );
46
+ }
47
+ if (status === 409 && opts.serverError === "repo_unbound") {
48
+ return new CliError(
49
+ opts.serverMessage ?? "This project is not bound to a repository.",
50
+ 2,
51
+ "Ask the project owner to run nolto sync in the correct repository first.",
52
+ status
53
+ );
54
+ }
55
+ if (status === 409 && opts.serverError === "repo_mismatch") {
56
+ return new CliError(
57
+ opts.serverMessage ?? "This project is bound to a different repository.",
58
+ 2,
59
+ "Owner: run `nolto link --rebind` inside the repository that should own this project.",
60
+ status
61
+ );
62
+ }
63
+ if (status === 429 && opts.serverError === "rebind_cooldown") {
64
+ return new CliError(
65
+ opts.serverMessage ?? "Rebind cooldown active.",
66
+ 4,
67
+ void 0,
68
+ status
69
+ );
70
+ }
39
71
  if (status === 402) {
40
72
  const hint = opts.upgradeUrl != null ? `Upgrade: ${opts.upgradeUrl}` : void 0;
41
73
  return new CliError(
@@ -157,9 +189,9 @@ async function mergeJsonFile(filePath, patch) {
157
189
  }
158
190
  }
159
191
  const merged = { ...existing, ...patch };
160
- const { chmod } = await import("fs/promises");
192
+ const { chmod: chmod2 } = await import("fs/promises");
161
193
  await writeFile(filePath, JSON.stringify(merged, null, 2) + "\n", { mode: 420 });
162
- await chmod(filePath, 420);
194
+ await chmod2(filePath, 420);
163
195
  }
164
196
  async function writeRepoBinding(root, projectId) {
165
197
  await mergeJsonFile(path.join(root, "nolto.json"), { projectId });
@@ -282,11 +314,11 @@ function maskToken(token) {
282
314
  function createHttpClient(opts) {
283
315
  const { baseUrl, version, token } = opts;
284
316
  const base = baseUrl.replace(/\/+$/, "");
285
- async function request(method, path14, body) {
286
- if (!path14.startsWith("/api/")) {
287
- throw new CliError(`HTTP client path must start with /api/, got: ${path14}`, 2);
317
+ async function request(method, path16, body) {
318
+ if (!path16.startsWith("/api/")) {
319
+ throw new CliError(`HTTP client path must start with /api/, got: ${path16}`, 2);
288
320
  }
289
- const url = `${base}${path14}`;
321
+ const url = `${base}${path16}`;
290
322
  const headers = {
291
323
  "Content-Type": "application/json",
292
324
  "User-Agent": `${CLI_USER_AGENT_NAME}/${version}`
@@ -345,11 +377,11 @@ import { Command } from "commander";
345
377
  import readline from "readline/promises";
346
378
  import { createRequire } from "module";
347
379
  import { fileURLToPath as fileURLToPath2 } from "url";
348
- import path6 from "path";
380
+ import path8 from "path";
349
381
  import fs from "fs";
350
382
 
351
383
  // src/commands/link.ts
352
- import path2 from "path";
384
+ import path5 from "path";
353
385
  import { statSync as statSync2 } from "fs";
354
386
 
355
387
  // src/output.ts
@@ -403,6 +435,316 @@ function formatValue(value) {
403
435
  return JSON.stringify(value, null, 2);
404
436
  }
405
437
 
438
+ // src/registry.ts
439
+ import { readFile as readFile2, writeFile as writeFile2, mkdir as mkdir2, rename, unlink } from "fs/promises";
440
+ import { randomUUID } from "crypto";
441
+ import path2 from "path";
442
+ import { z as z2 } from "zod";
443
+ var registrySchema = z2.object({
444
+ schemaVersion: z2.literal(1),
445
+ repos: z2.array(z2.object({ root: z2.string().min(1) }).passthrough())
446
+ }).passthrough();
447
+ function getRegistryPath(env) {
448
+ return path2.join(getConfigDir(env), "registry.json");
449
+ }
450
+ async function loadRegistry(filePath) {
451
+ let raw;
452
+ try {
453
+ raw = await readFile2(filePath, "utf8");
454
+ } catch (err) {
455
+ const code = err != null && typeof err === "object" && "code" in err ? err.code : "";
456
+ if (code === "ENOENT") {
457
+ return { schemaVersion: 1, repos: [] };
458
+ }
459
+ throw new CliError(`Cannot read registry: ${filePath}: ${String(err)}`, 2);
460
+ }
461
+ let parsed;
462
+ try {
463
+ parsed = JSON.parse(raw);
464
+ } catch {
465
+ throw new CliError(`Malformed JSON in ${filePath}. Fix or remove the file.`, 2);
466
+ }
467
+ const result = registrySchema.safeParse(parsed);
468
+ if (!result.success) {
469
+ const issue = result.error.issues[0];
470
+ const fieldPath = issue?.path.join(".") ?? "";
471
+ throw new CliError(
472
+ `Invalid registry at ${filePath}: ${fieldPath.length > 0 ? `field "${fieldPath}" \u2014 ` : ""}${issue?.message ?? "validation failed"}`,
473
+ 2
474
+ );
475
+ }
476
+ return result.data;
477
+ }
478
+ async function addRepoToRegistry(filePath, repoRoot) {
479
+ const registry = await loadRegistry(filePath);
480
+ if (registry.repos.some((repo) => repo.root === repoRoot)) {
481
+ return { added: false, registry };
482
+ }
483
+ const updated = { ...registry, repos: [...registry.repos, { root: repoRoot }] };
484
+ await mkdir2(path2.dirname(filePath), { recursive: true, mode: 448 });
485
+ const tempPath = `${filePath}.tmp-${process.pid}-${randomUUID()}`;
486
+ try {
487
+ await writeFile2(tempPath, JSON.stringify(updated, null, 2) + "\n", "utf8");
488
+ await rename(tempPath, filePath);
489
+ } catch (err) {
490
+ await unlink(tempPath).catch(() => void 0);
491
+ throw err;
492
+ }
493
+ return { added: true, registry: updated };
494
+ }
495
+
496
+ // src/repo-identity.ts
497
+ import { execFile } from "child_process";
498
+ import { realpathSync } from "fs";
499
+ import path4 from "path";
500
+
501
+ // ../roadmap-schema/src/repo-identity.ts
502
+ var HOSTED_LOWERCASE_PATH = /* @__PURE__ */ new Set(["github.com", "gitlab.com", "bitbucket.org"]);
503
+ function normalizeRemote(raw) {
504
+ let s = raw.trim();
505
+ if (s.length === 0) return null;
506
+ if (s.startsWith("file://") || s.startsWith("/") || s.startsWith(".") || /^[A-Za-z]:[\\/]/.test(s)) return null;
507
+ const scheme = /^[a-z][a-z0-9+.-]*:\/\//i.exec(s);
508
+ const hadScheme = scheme !== null;
509
+ if (scheme !== null) s = s.slice(scheme[0].length);
510
+ const authorityEnd = s.indexOf("/");
511
+ const lastAt = s.lastIndexOf("@", authorityEnd === -1 ? s.length - 1 : authorityEnd - 1);
512
+ if (lastAt !== -1) s = s.slice(lastAt + 1);
513
+ const scpLike = hadScheme ? null : /^([^/:]+):(.+)$/.exec(s);
514
+ if (scpLike !== null) {
515
+ s = `${scpLike[1]}/${scpLike[2]}`;
516
+ }
517
+ const firstSlash = s.indexOf("/");
518
+ if (firstSlash <= 0) return null;
519
+ let host = s.slice(0, firstSlash).toLowerCase();
520
+ let path16 = s.slice(firstSlash + 1);
521
+ if (hadScheme) host = host.replace(/:\d+$/, "");
522
+ path16 = path16.replace(/\/+/g, "/").replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/+$/, "");
523
+ if (path16.length === 0) return null;
524
+ if (HOSTED_LOWERCASE_PATH.has(host)) path16 = path16.toLowerCase();
525
+ return `${host}/${path16}`;
526
+ }
527
+
528
+ // ../roadmap-schema/src/index.ts
529
+ var STATUSES = /* @__PURE__ */ new Set(["todo", "in-progress", "done", "blocked"]);
530
+ var ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
531
+ var ALLOWED_KEYS = {
532
+ roadmap: /* @__PURE__ */ new Set(["schemaVersion", "project", "updatedAt", "currentTaskId", "summary", "phases"]),
533
+ project: /* @__PURE__ */ new Set(["id", "name", "repository"]),
534
+ phase: /* @__PURE__ */ new Set(["id", "title", "status", "plan", "tasks"]),
535
+ task: /* @__PURE__ */ new Set(["id", "title", "status", "startedAt", "completedAt", "note", "dependsOn", "plan"])
536
+ };
537
+ function isRecord(value) {
538
+ return value != null && typeof value === "object" && !Array.isArray(value);
539
+ }
540
+ function checkKeys(value, allowed, at, errors) {
541
+ if (!isRecord(value)) return;
542
+ for (const key of Object.keys(value)) {
543
+ if (!allowed.has(key)) errors.push(`${at} contains unsupported property "${key}".`);
544
+ }
545
+ }
546
+ function checkPlan(value, at, errors) {
547
+ if (value === void 0) return;
548
+ if (typeof value !== "string" || value.length === 0) {
549
+ errors.push(`${at}.plan must be a non-empty string.`);
550
+ }
551
+ }
552
+ function derivePhaseStatus(phase) {
553
+ if (phase.tasks.length > 0 && phase.tasks.every((task) => task.status === "done")) return "done";
554
+ if (phase.tasks.some((task) => task.status === "in-progress")) return "in-progress";
555
+ if (phase.tasks.some((task) => task.status === "blocked")) return "blocked";
556
+ if (phase.tasks.some((task) => task.status === "done")) return "in-progress";
557
+ return "todo";
558
+ }
559
+ function validateRoadmap(value) {
560
+ const errors = [];
561
+ const warnings = [];
562
+ if (!isRecord(value)) return { errors: ["Root must be an object."], warnings };
563
+ checkKeys(value, ALLOWED_KEYS.roadmap, "roadmap", errors);
564
+ if (value["schemaVersion"] !== 1 && value["schemaVersion"] !== 2) {
565
+ errors.push("schemaVersion must be 1 or 2.");
566
+ } else if (value["schemaVersion"] === 1) {
567
+ warnings.push("schemaVersion 1 is legacy; the next roadmap-progress mutation migrates this file to 2.");
568
+ }
569
+ const project = value["project"];
570
+ checkKeys(project, ALLOWED_KEYS.project, "project", errors);
571
+ const projectRecord = isRecord(project) ? project : {};
572
+ if (!ID_PATTERN.test(String(projectRecord["id"] ?? ""))) errors.push("project.id is invalid.");
573
+ if (projectRecord["name"] == null || projectRecord["name"] === "") errors.push("project.name is required.");
574
+ if (projectRecord["repository"] == null || projectRecord["repository"] === "") errors.push("project.repository is required.");
575
+ if (Number.isNaN(Date.parse(String(value["updatedAt"])))) errors.push("updatedAt must be a valid date-time.");
576
+ if (typeof value["summary"] !== "string") errors.push("summary must be a string.");
577
+ if (!Array.isArray(value["phases"])) errors.push("phases must be an array.");
578
+ const allIds = /* @__PURE__ */ new Set();
579
+ const tasks = /* @__PURE__ */ new Map();
580
+ const phases = Array.isArray(value["phases"]) ? value["phases"] : [];
581
+ for (const [phaseIndex, rawPhase] of phases.entries()) {
582
+ const at = `phases[${phaseIndex}]`;
583
+ checkKeys(rawPhase, ALLOWED_KEYS.phase, at, errors);
584
+ const phase = isRecord(rawPhase) ? rawPhase : {};
585
+ const phaseId = String(phase["id"] ?? "");
586
+ if (!ID_PATTERN.test(phaseId)) errors.push(`${at}.id is invalid.`);
587
+ if (allIds.has(phaseId)) errors.push(`Duplicate id "${phaseId}".`);
588
+ allIds.add(phaseId);
589
+ if (phase["title"] == null || phase["title"] === "") errors.push(`${at}.title is required.`);
590
+ if (!STATUSES.has(String(phase["status"]))) errors.push(`${at}.status is invalid.`);
591
+ checkPlan(phase["plan"], at, errors);
592
+ if (!Array.isArray(phase["tasks"])) errors.push(`${at}.tasks must be an array.`);
593
+ const rawTasks = Array.isArray(phase["tasks"]) ? phase["tasks"] : [];
594
+ for (const [taskIndex, rawTask] of rawTasks.entries()) {
595
+ const taskAt = `${at}.tasks[${taskIndex}]`;
596
+ checkKeys(rawTask, ALLOWED_KEYS.task, taskAt, errors);
597
+ const task = isRecord(rawTask) ? rawTask : {};
598
+ const taskId = String(task["id"] ?? "");
599
+ if (!ID_PATTERN.test(taskId)) errors.push(`${taskAt}.id is invalid.`);
600
+ if (allIds.has(taskId)) errors.push(`Duplicate id "${taskId}".`);
601
+ allIds.add(taskId);
602
+ tasks.set(taskId, task);
603
+ if (task["title"] == null || task["title"] === "") errors.push(`${taskAt}.title is required.`);
604
+ if (!STATUSES.has(String(task["status"]))) errors.push(`${taskAt}.status is invalid.`);
605
+ checkPlan(task["plan"], taskAt, errors);
606
+ if (task["status"] === "done" && task["completedAt"] == null) warnings.push(`${taskId} is done without completedAt.`);
607
+ if (task["status"] === "in-progress" && task["startedAt"] == null) warnings.push(`${taskId} is in-progress without startedAt.`);
608
+ if (task["dependsOn"] !== void 0 && !Array.isArray(task["dependsOn"])) errors.push(`${taskAt}.dependsOn must be an array.`);
609
+ }
610
+ if (Array.isArray(phase["tasks"]) && STATUSES.has(String(phase["status"]))) {
611
+ const expected = derivePhaseStatus({ ...phase, tasks: rawTasks });
612
+ if (phase["status"] !== expected) warnings.push(`${phaseId} status is ${String(phase["status"])}; task states derive ${expected}.`);
613
+ }
614
+ }
615
+ for (const task of tasks.values()) {
616
+ for (const dependency of task.dependsOn ?? []) {
617
+ if (!tasks.has(dependency)) warnings.push(`${task.id} depends on unknown task ${dependency}.`);
618
+ if (task.id === dependency) errors.push(`${task.id} cannot depend on itself.`);
619
+ }
620
+ }
621
+ const currentTaskId = value["currentTaskId"];
622
+ if (currentTaskId !== null && currentTaskId !== void 0) {
623
+ const current = tasks.get(String(currentTaskId));
624
+ if (current == null) errors.push(`currentTaskId ${String(currentTaskId)} does not exist.`);
625
+ else if (current.status !== "in-progress") warnings.push(`currentTaskId ${String(currentTaskId)} is not in-progress.`);
626
+ }
627
+ return { errors, warnings };
628
+ }
629
+ function deriveTaskStats(roadmap) {
630
+ let done = 0;
631
+ let blocked = 0;
632
+ let inProgress = 0;
633
+ let todo = 0;
634
+ for (const phase of roadmap.phases) {
635
+ for (const task of phase.tasks) {
636
+ if (task.status === "done") done += 1;
637
+ else if (task.status === "blocked") blocked += 1;
638
+ else if (task.status === "in-progress") inProgress += 1;
639
+ else todo += 1;
640
+ }
641
+ }
642
+ const total = done + blocked + inProgress + todo;
643
+ return {
644
+ total,
645
+ done,
646
+ blocked,
647
+ inProgress,
648
+ todo,
649
+ progressPct: total > 0 ? Math.round(done / total * 100) : 0
650
+ };
651
+ }
652
+
653
+ // src/machine-id.ts
654
+ import { chmod, mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
655
+ import { randomUUID as randomUUID2 } from "crypto";
656
+ import path3 from "path";
657
+ var inFlightByConfigDir = /* @__PURE__ */ new Map();
658
+ function parseMachineId(raw, file) {
659
+ let parsed;
660
+ try {
661
+ parsed = JSON.parse(raw);
662
+ } catch {
663
+ throw new CliError(`Malformed JSON in ${file}. Remove the file to regenerate it.`, 2);
664
+ }
665
+ const id = parsed?.machineId;
666
+ if (typeof id !== "string" || id.length === 0) {
667
+ throw new CliError(`Invalid ${file}: missing machineId. Remove the file to regenerate it.`, 2);
668
+ }
669
+ return id;
670
+ }
671
+ async function loadOrCreate(configDir) {
672
+ const file = path3.join(configDir, "machine.json");
673
+ let raw = null;
674
+ try {
675
+ raw = await readFile3(file, "utf8");
676
+ } catch (err) {
677
+ const code = err.code;
678
+ if (code !== "ENOENT") {
679
+ throw new CliError(`Cannot read ${file}: ${String(err)}`, 2);
680
+ }
681
+ }
682
+ if (raw !== null) {
683
+ return parseMachineId(raw, file);
684
+ }
685
+ const machineId = randomUUID2();
686
+ await mkdir3(configDir, { recursive: true, mode: 448 });
687
+ try {
688
+ await writeFile3(file, `${JSON.stringify({ machineId }, null, 2)}
689
+ `, {
690
+ flag: "wx",
691
+ mode: 384
692
+ });
693
+ } catch (err) {
694
+ if (err.code === "EEXIST") {
695
+ return parseMachineId(await readFile3(file, "utf8"), file);
696
+ }
697
+ throw new CliError(`Cannot write ${file}: ${String(err)}`, 2);
698
+ }
699
+ await chmod(file, 384);
700
+ return machineId;
701
+ }
702
+ function loadOrCreateMachineId(configDir) {
703
+ const key = path3.resolve(configDir);
704
+ const existing = inFlightByConfigDir.get(key);
705
+ if (existing !== void 0) return existing;
706
+ const pending = loadOrCreate(key).finally(() => {
707
+ if (inFlightByConfigDir.get(key) === pending) inFlightByConfigDir.delete(key);
708
+ });
709
+ inFlightByConfigDir.set(key, pending);
710
+ return pending;
711
+ }
712
+
713
+ // src/repo-identity.ts
714
+ function defaultGetRemoteUrl(root) {
715
+ return new Promise((resolve) => {
716
+ execFile(
717
+ "git",
718
+ ["-C", root, "remote", "get-url", "origin"],
719
+ { timeout: 5e3 },
720
+ (err, stdout) => {
721
+ if (err) return resolve(null);
722
+ const line = stdout.split(/\r?\n/)[0]?.trim() ?? "";
723
+ resolve(line.length > 0 ? line : null);
724
+ }
725
+ );
726
+ });
727
+ }
728
+ async function resolveRepoIdentity(root, deps) {
729
+ const remote = await deps.getRemoteUrl(root);
730
+ const normalized = remote === null ? null : normalizeRemote(remote);
731
+ if (normalized !== null) return { kind: "remote", value: normalized };
732
+ const machineId = await deps.machineId();
733
+ let canonicalRoot;
734
+ try {
735
+ canonicalRoot = realpathSync(root);
736
+ } catch {
737
+ canonicalRoot = path4.resolve(root);
738
+ }
739
+ return { kind: "local", value: `${machineId}:${canonicalRoot}` };
740
+ }
741
+ function makeRepoIdentityResolver(deps) {
742
+ return deps.repoIdentity ?? ((root) => resolveRepoIdentity(root, {
743
+ getRemoteUrl: defaultGetRemoteUrl,
744
+ machineId: () => loadOrCreateMachineId(getConfigDir(process.env))
745
+ }));
746
+ }
747
+
406
748
  // src/commands/link.ts
407
749
  var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
408
750
  function resolveStartDir(env, cwd) {
@@ -411,7 +753,7 @@ function resolveStartDir(env, cwd) {
411
753
  }
412
754
  function dirHasGit(dir) {
413
755
  try {
414
- statSync2(path2.join(dir, ".git"));
756
+ statSync2(path5.join(dir, ".git"));
415
757
  return true;
416
758
  } catch {
417
759
  return false;
@@ -423,18 +765,23 @@ function findRepoRoot(startDir, hasGit = dirHasGit) {
423
765
  if (hasGit(current)) {
424
766
  return { root: current, foundGit: true };
425
767
  }
426
- const parent = path2.dirname(current);
768
+ const parent = path5.dirname(current);
427
769
  if (parent === current) break;
428
770
  current = parent;
429
771
  }
430
772
  return { root: startDir, foundGit: false };
431
773
  }
432
774
  async function handleShow(deps, projectBindingPath, mode2) {
775
+ const startDir = resolveStartDir(process.env, process.cwd());
776
+ const root = findRepoRoot(startDir).root;
777
+ const repoIdentity = await makeRepoIdentityResolver(deps)(root);
433
778
  if (projectBindingPath == null) {
434
779
  if (mode2 === "json") {
435
- printResult({ bound: false, projectBindingPath: null }, mode2);
780
+ printResult({ bound: false, projectBindingPath: null, repoIdentity }, mode2);
436
781
  } else {
437
782
  process.stdout.write("No nolto.json binding found in this directory tree.\n");
783
+ process.stdout.write(`repoIdentity : ${repoIdentity.kind}:${repoIdentity.value}
784
+ `);
438
785
  }
439
786
  return;
440
787
  }
@@ -447,7 +794,8 @@ async function handleShow(deps, projectBindingPath, mode2) {
447
794
  bound: binding != null,
448
795
  projectId: binding?.projectId ?? null,
449
796
  projectBindingPath,
450
- source: deps.settings.source.project === "repo" ? "repo" : "file"
797
+ source: deps.settings.source.project === "repo" ? "repo" : "file",
798
+ repoIdentity
451
799
  }, mode2);
452
800
  } else {
453
801
  if (binding == null) {
@@ -460,15 +808,33 @@ async function handleShow(deps, projectBindingPath, mode2) {
460
808
  `);
461
809
  const active = deps.settings.source.project === "repo" ? "repo (active)" : "repo (not active \u2014 overridden)";
462
810
  process.stdout.write(`source : ${active}
811
+ `);
812
+ process.stdout.write(`repoIdentity : ${repoIdentity.kind}:${repoIdentity.value}
463
813
  `);
464
814
  }
465
815
  }
466
816
  }
817
+ async function handleRebind(deps, projectId, root, mode2) {
818
+ if (!UUID_RE.test(projectId)) {
819
+ throw new CliError(
820
+ `Invalid project ID: "${projectId}". Must be a UUID (e.g. 00000000-0000-0000-0000-000000000001).`,
821
+ 2
822
+ );
823
+ }
824
+ const repoIdentity = await makeRepoIdentityResolver(deps)(root);
825
+ await deps.http.post(`/api/projects/${projectId}/repo-binding`, { repoIdentity });
826
+ if (mode2 === "json") {
827
+ printResult({ rebound: true, projectId, repoIdentity }, mode2);
828
+ } else {
829
+ process.stdout.write(`Rebound project ${projectId} to ${repoIdentity.kind}:${repoIdentity.value}.
830
+ `);
831
+ }
832
+ }
467
833
  async function handleUnlink(projectBindingPath, mode2) {
468
- const { readFile: readFile7, writeFile: writeFile6, chmod } = await import("fs/promises");
834
+ const { readFile: readFile8, writeFile: writeFile7, chmod: chmod2 } = await import("fs/promises");
469
835
  let existing = {};
470
836
  try {
471
- const raw = await readFile7(projectBindingPath, "utf8");
837
+ const raw = await readFile8(projectBindingPath, "utf8");
472
838
  const parsed = JSON.parse(raw);
473
839
  if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
474
840
  throw new CliError(
@@ -483,8 +849,8 @@ async function handleUnlink(projectBindingPath, mode2) {
483
849
  }
484
850
  const { projectId: _removed, ...rest } = existing;
485
851
  void _removed;
486
- await writeFile6(projectBindingPath, JSON.stringify(rest, null, 2) + "\n", { mode: 420 });
487
- await chmod(projectBindingPath, 420);
852
+ await writeFile7(projectBindingPath, JSON.stringify(rest, null, 2) + "\n", { mode: 420 });
853
+ await chmod2(projectBindingPath, 420);
488
854
  if (mode2 === "json") {
489
855
  printResult({ unlinked: true, projectBindingPath }, mode2);
490
856
  } else {
@@ -538,21 +904,38 @@ Proceeding anyway \u2014 verify the ID is correct.
538
904
  );
539
905
  }
540
906
  await writeRepoBinding(root, projectId);
541
- const writtenPath = path2.join(root, "nolto.json");
907
+ const writtenPath = path5.join(root, "nolto.json");
908
+ let registryAdded = false;
909
+ try {
910
+ registryAdded = (await addRepoToRegistry(getRegistryPath(process.env), root)).added;
911
+ } catch (err) {
912
+ const message = err instanceof Error ? err.message : String(err);
913
+ process.stderr.write(`Warning: could not update watch registry: ${message}
914
+ `);
915
+ }
542
916
  if (mode2 === "json") {
543
- printResult({ linked: true, projectId, projectBindingPath: writtenPath }, mode2);
917
+ printResult({
918
+ linked: true,
919
+ projectId,
920
+ projectBindingPath: writtenPath,
921
+ registryAdded
922
+ }, mode2);
544
923
  } else {
545
924
  process.stdout.write(
546
925
  `Linked this repo to project ${projectId} (wrote ${writtenPath}).
547
926
  Commit nolto.json to share the binding with your team.
548
927
  `
549
928
  );
929
+ if (registryAdded) {
930
+ process.stdout.write(`watch registry: added ${root}
931
+ `);
932
+ }
550
933
  }
551
934
  }
552
935
  function register(program, deps) {
553
936
  const cmd = program.command("link [projectId]").description(
554
- "Bind this repository to a Nolto project.\nWrites nolto.json at the repo root. Commit it to share the binding with your team.\n\nExamples:\n nolto link <uuid> Write / update nolto.json\n nolto link --show Show the current binding\n nolto link --unlink Remove the projectId from nolto.json"
555
- ).option("--show", "Show the current repo binding (path + projectId + source)").option("--unlink", "Remove the projectId key from nolto.json");
937
+ "Bind this repository to a Nolto project.\nWrites nolto.json at the repo root. Commit it to share the binding with your team.\n\nExamples:\n nolto link <uuid> Write / update nolto.json\n nolto link --show Show the current binding\n nolto link --rebind Rebind the project to this repository\n nolto link --unlink Remove the projectId from nolto.json"
938
+ ).option("--show", "Show the current repo binding (path + projectId + source)").option("--rebind", "Rebind the project to this repository (owner only, once per 7 days)").option("--unlink", "Remove the projectId key from nolto.json");
556
939
  cmd.action(async (projectId) => {
557
940
  const { output } = deps;
558
941
  const projectBindingPath = deps.repoBinding?.path ?? deps.projectBindingPath ?? null;
@@ -572,12 +955,23 @@ function register(program, deps) {
572
955
  await handleUnlink(projectBindingPath, mode2);
573
956
  return;
574
957
  }
958
+ if (cmd.opts()["rebind"]) {
959
+ const boundProjectId = deps.repoBinding?.binding?.projectId;
960
+ const effectiveProjectId = projectId ?? boundProjectId;
961
+ if (effectiveProjectId == null) {
962
+ throw new CliError("No project binding. Run `nolto link <projectId>` first.", 2);
963
+ }
964
+ const startDir = resolveStartDir(process.env, process.cwd());
965
+ const root = findRepoRoot(startDir).root;
966
+ await handleRebind(deps, effectiveProjectId, root, mode2);
967
+ return;
968
+ }
575
969
  if (projectId == null || projectId.trim().length === 0) {
576
970
  if (bindingError != null) {
577
971
  throw invalidBindingError(projectBindingPath, bindingError);
578
972
  }
579
973
  throw new CliError(
580
- "Usage: nolto link <projectId> (provide a UUID)\nOr use --show to view the current binding, --unlink to remove it.",
974
+ "Usage: nolto link <projectId> (provide a UUID)\nOr use --show to view the current binding, --rebind to rebind it, --unlink to remove it.",
581
975
  2
582
976
  );
583
977
  }
@@ -592,32 +986,32 @@ function invalidBindingError(projectBindingPath, error) {
592
986
  }
593
987
 
594
988
  // src/skill-install.ts
595
- import { cp, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2, rm } from "fs/promises";
989
+ import { cp, mkdir as mkdir4, readFile as readFile4, writeFile as writeFile4, rm } from "fs/promises";
596
990
  import { existsSync } from "fs";
597
- import path3 from "path";
991
+ import path6 from "path";
598
992
  import { fileURLToPath } from "url";
599
- var __dirname = path3.dirname(fileURLToPath(import.meta.url));
993
+ var __dirname = path6.dirname(fileURLToPath(import.meta.url));
600
994
  function resolveSkillSourceDir() {
601
995
  const candidates = [
602
- path3.resolve(__dirname, "skill/roadmap-progress"),
996
+ path6.resolve(__dirname, "skill/roadmap-progress"),
603
997
  // bundled: dist/skill/...
604
- path3.resolve(__dirname, "../../../skills/roadmap-progress")
998
+ path6.resolve(__dirname, "../../../skills/roadmap-progress")
605
999
  // source: <repo>/skills/...
606
1000
  ];
607
1001
  for (const candidate of candidates) {
608
- if (existsSync(path3.join(candidate, "SKILL.md"))) return candidate;
1002
+ if (existsSync(path6.join(candidate, "SKILL.md"))) return candidate;
609
1003
  }
610
1004
  throw new Error("Bundled roadmap-progress skill not found. Reinstall @nolto/cli.");
611
1005
  }
612
1006
  var VERSION_MARKER = ".nolto-skill-version";
613
1007
  async function installSkill(args) {
614
- const targetDir = path3.join(args.skillsParentDir, "roadmap-progress");
615
- const markerPath = path3.join(targetDir, VERSION_MARKER);
1008
+ const targetDir = path6.join(args.skillsParentDir, "roadmap-progress");
1009
+ const markerPath = path6.join(targetDir, VERSION_MARKER);
616
1010
  const dirExists = existsSync(targetDir);
617
1011
  let installedVersion = null;
618
1012
  if (dirExists) {
619
1013
  try {
620
- installedVersion = (await readFile2(markerPath, "utf8")).trim();
1014
+ installedVersion = (await readFile4(markerPath, "utf8")).trim();
621
1015
  } catch {
622
1016
  installedVersion = null;
623
1017
  }
@@ -626,79 +1020,29 @@ async function installSkill(args) {
626
1020
  return { action: "skipped", targetDir };
627
1021
  }
628
1022
  await rm(targetDir, { recursive: true, force: true });
629
- await mkdir2(args.skillsParentDir, { recursive: true });
1023
+ await mkdir4(args.skillsParentDir, { recursive: true });
630
1024
  await cp(args.sourceDir, targetDir, { recursive: true });
631
- await writeFile2(markerPath, args.version + "\n", "utf8");
1025
+ await writeFile4(markerPath, args.version + "\n", "utf8");
632
1026
  return { action: dirExists ? "updated" : "installed", targetDir };
633
1027
  }
634
1028
 
635
- // src/registry.ts
636
- import { readFile as readFile3, writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
637
- import path4 from "path";
638
- import { z as z2 } from "zod";
639
- var registrySchema = z2.object({
640
- schemaVersion: z2.literal(1),
641
- repos: z2.array(z2.object({ root: z2.string().min(1) }).passthrough())
642
- }).passthrough();
643
- function getRegistryPath(env) {
644
- return path4.join(getConfigDir(env), "registry.json");
645
- }
646
- async function loadRegistry(filePath) {
647
- let raw;
648
- try {
649
- raw = await readFile3(filePath, "utf8");
650
- } catch (err) {
651
- const code = err != null && typeof err === "object" && "code" in err ? err.code : "";
652
- if (code === "ENOENT") {
653
- return { schemaVersion: 1, repos: [] };
654
- }
655
- throw new CliError(`Cannot read registry: ${filePath}: ${String(err)}`, 2);
656
- }
657
- let parsed;
658
- try {
659
- parsed = JSON.parse(raw);
660
- } catch {
661
- throw new CliError(`Malformed JSON in ${filePath}. Fix or remove the file.`, 2);
662
- }
663
- const result = registrySchema.safeParse(parsed);
664
- if (!result.success) {
665
- const issue = result.error.issues[0];
666
- const fieldPath = issue?.path.join(".") ?? "";
667
- throw new CliError(
668
- `Invalid registry at ${filePath}: ${fieldPath.length > 0 ? `field "${fieldPath}" \u2014 ` : ""}${issue?.message ?? "validation failed"}`,
669
- 2
670
- );
671
- }
672
- return result.data;
673
- }
674
- async function addRepoToRegistry(filePath, repoRoot) {
675
- const registry = await loadRegistry(filePath);
676
- if (registry.repos.some((repo) => repo.root === repoRoot)) {
677
- return { added: false, registry };
678
- }
679
- const updated = { ...registry, repos: [...registry.repos, { root: repoRoot }] };
680
- await mkdir3(path4.dirname(filePath), { recursive: true, mode: 448 });
681
- await writeFile3(filePath, JSON.stringify(updated, null, 2) + "\n", "utf8");
682
- return { added: true, registry: updated };
683
- }
684
-
685
1029
  // src/roadmap-scaffold.ts
686
- import { mkdir as mkdir4, readdir, writeFile as writeFile4 } from "fs/promises";
1030
+ import { mkdir as mkdir5, readdir, writeFile as writeFile5 } from "fs/promises";
687
1031
  import { existsSync as existsSync2 } from "fs";
688
- import path5 from "path";
1032
+ import path7 from "path";
689
1033
  function slugifyProjectId(name) {
690
1034
  const slug = name.toLowerCase().replace(/[^a-z0-9.-]+/g, "-").replace(/^[^a-z0-9]+/, "").replace(/[-_.]+$/, "");
691
1035
  return slug.length > 0 ? slug : "project";
692
1036
  }
693
1037
  async function scaffoldRoadmap(args) {
694
- const repoBasename = path5.basename(args.repoRoot);
695
- const dir = path5.join(args.repoRoot, ".nolto", "roadmaps");
696
- const filePath = path5.join(dir, `${slugifyProjectId(repoBasename)}.json`);
697
- const legacyPath = path5.join(args.repoRoot, ".roadmap", "roadmap.json");
1038
+ const repoBasename = path7.basename(args.repoRoot);
1039
+ const dir = path7.join(args.repoRoot, ".nolto", "roadmaps");
1040
+ const filePath = path7.join(dir, `${slugifyProjectId(repoBasename)}.json`);
1041
+ const legacyPath = path7.join(args.repoRoot, ".roadmap", "roadmap.json");
698
1042
  try {
699
1043
  const existing = (await readdir(dir)).sort();
700
1044
  if (existing.length > 0) {
701
- return { created: false, path: path5.join(dir, existing[0]) };
1045
+ return { created: false, path: path7.join(dir, existing[0]) };
702
1046
  }
703
1047
  } catch (err) {
704
1048
  if (err == null || typeof err !== "object" || !("code" in err) || err.code !== "ENOENT") {
@@ -720,19 +1064,19 @@ async function scaffoldRoadmap(args) {
720
1064
  summary: "",
721
1065
  phases: []
722
1066
  };
723
- await mkdir4(dir, { recursive: true });
724
- await writeFile4(filePath, JSON.stringify(roadmap, null, 2) + "\n", "utf8");
1067
+ await mkdir5(dir, { recursive: true });
1068
+ await writeFile5(filePath, JSON.stringify(roadmap, null, 2) + "\n", "utf8");
725
1069
  return { created: true, path: filePath };
726
1070
  }
727
1071
 
728
1072
  // src/commands/init.ts
729
- var __dirname2 = path6.dirname(fileURLToPath2(import.meta.url));
1073
+ var __dirname2 = path8.dirname(fileURLToPath2(import.meta.url));
730
1074
  var _require = createRequire(import.meta.url);
731
1075
  function getCliVersion() {
732
1076
  const candidates = [
733
- path6.resolve(__dirname2, "../package.json"),
1077
+ path8.resolve(__dirname2, "../package.json"),
734
1078
  // bundled: dist/../package.json
735
- path6.resolve(__dirname2, "../../package.json")
1079
+ path8.resolve(__dirname2, "../../package.json")
736
1080
  // source: src/commands/../../package.json
737
1081
  ];
738
1082
  for (const pkgPath of candidates) {
@@ -860,20 +1204,21 @@ Set up this repository (${root}) for roadmap sync? [Y/n] `);
860
1204
  );
861
1205
  }
862
1206
  await writeRepoBinding(root, defaultProjectId);
863
- process.stdout.write(`binding: wrote ${path6.join(root, "nolto.json")}
1207
+ process.stdout.write(`binding: wrote ${path8.join(root, "nolto.json")}
864
1208
  `);
865
1209
  const sourceDir = resolveSkillSourceDir();
866
1210
  const version = getCliVersion();
867
1211
  const claudeInstall = await installSkill({
868
- skillsParentDir: path6.join(root, ".claude", "skills"),
1212
+ skillsParentDir: path8.join(root, ".claude", "skills"),
869
1213
  sourceDir,
870
1214
  version
871
1215
  });
872
1216
  process.stdout.write(`skill (claude): ${claudeInstall.action} ${claudeInstall.targetDir}
873
1217
  `);
874
- if (fs.existsSync(path6.join(root, ".agents")) || fs.existsSync(path6.join(root, ".codex"))) {
1218
+ const usesAgentsTooling = fs.existsSync(path8.join(root, ".agents")) || fs.existsSync(path8.join(root, ".codex")) || fs.existsSync(path8.join(root, "AGENTS.md"));
1219
+ if (usesAgentsTooling) {
875
1220
  const agentsInstall = await installSkill({
876
- skillsParentDir: path6.join(root, ".agents", "skills"),
1221
+ skillsParentDir: path8.join(root, ".agents", "skills"),
877
1222
  sourceDir,
878
1223
  version
879
1224
  });
@@ -882,7 +1227,7 @@ Set up this repository (${root}) for roadmap sync? [Y/n] `);
882
1227
  }
883
1228
  const scaffold = await scaffoldRoadmap({
884
1229
  repoRoot: root,
885
- projectName: defaultProjectName ?? path6.basename(root)
1230
+ projectName: defaultProjectName ?? path8.basename(root)
886
1231
  });
887
1232
  process.stdout.write(
888
1233
  scaffold.created ? `roadmap: created ${scaffold.path}
@@ -1119,142 +1464,15 @@ function register4(program, deps) {
1119
1464
  }
1120
1465
 
1121
1466
  // src/commands/sync.ts
1122
- import { copyFile, mkdir as mkdir5, readFile as readFile4, readdir as readdir2, rename, rmdir, unlink } from "fs/promises";
1467
+ import { copyFile, mkdir as mkdir6, readFile as readFile5, readdir as readdir2, rename as rename2, rmdir, unlink as unlink2 } from "fs/promises";
1123
1468
  import { existsSync as existsSync3 } from "fs";
1124
1469
 
1125
1470
  // src/sync-repo.ts
1126
- import path8 from "path";
1471
+ import path10 from "path";
1127
1472
 
1128
1473
  // src/sync-core.ts
1129
1474
  import { createHash } from "crypto";
1130
- import path7 from "path";
1131
-
1132
- // ../roadmap-schema/src/index.ts
1133
- var STATUSES = /* @__PURE__ */ new Set(["todo", "in-progress", "done", "blocked"]);
1134
- var ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
1135
- var ALLOWED_KEYS = {
1136
- roadmap: /* @__PURE__ */ new Set(["schemaVersion", "project", "updatedAt", "currentTaskId", "summary", "phases"]),
1137
- project: /* @__PURE__ */ new Set(["id", "name", "repository"]),
1138
- phase: /* @__PURE__ */ new Set(["id", "title", "status", "plan", "tasks"]),
1139
- task: /* @__PURE__ */ new Set(["id", "title", "status", "startedAt", "completedAt", "note", "dependsOn", "plan"])
1140
- };
1141
- function isRecord(value) {
1142
- return value != null && typeof value === "object" && !Array.isArray(value);
1143
- }
1144
- function checkKeys(value, allowed, at, errors) {
1145
- if (!isRecord(value)) return;
1146
- for (const key of Object.keys(value)) {
1147
- if (!allowed.has(key)) errors.push(`${at} contains unsupported property "${key}".`);
1148
- }
1149
- }
1150
- function checkPlan(value, at, errors) {
1151
- if (value === void 0) return;
1152
- if (typeof value !== "string" || value.length === 0) {
1153
- errors.push(`${at}.plan must be a non-empty string.`);
1154
- }
1155
- }
1156
- function derivePhaseStatus(phase) {
1157
- if (phase.tasks.length > 0 && phase.tasks.every((task) => task.status === "done")) return "done";
1158
- if (phase.tasks.some((task) => task.status === "in-progress")) return "in-progress";
1159
- if (phase.tasks.some((task) => task.status === "blocked")) return "blocked";
1160
- if (phase.tasks.some((task) => task.status === "done")) return "in-progress";
1161
- return "todo";
1162
- }
1163
- function validateRoadmap(value) {
1164
- const errors = [];
1165
- const warnings = [];
1166
- if (!isRecord(value)) return { errors: ["Root must be an object."], warnings };
1167
- checkKeys(value, ALLOWED_KEYS.roadmap, "roadmap", errors);
1168
- if (value["schemaVersion"] !== 1 && value["schemaVersion"] !== 2) {
1169
- errors.push("schemaVersion must be 1 or 2.");
1170
- } else if (value["schemaVersion"] === 1) {
1171
- warnings.push("schemaVersion 1 is legacy; the next roadmap-progress mutation migrates this file to 2.");
1172
- }
1173
- const project = value["project"];
1174
- checkKeys(project, ALLOWED_KEYS.project, "project", errors);
1175
- const projectRecord = isRecord(project) ? project : {};
1176
- if (!ID_PATTERN.test(String(projectRecord["id"] ?? ""))) errors.push("project.id is invalid.");
1177
- if (projectRecord["name"] == null || projectRecord["name"] === "") errors.push("project.name is required.");
1178
- if (projectRecord["repository"] == null || projectRecord["repository"] === "") errors.push("project.repository is required.");
1179
- if (Number.isNaN(Date.parse(String(value["updatedAt"])))) errors.push("updatedAt must be a valid date-time.");
1180
- if (typeof value["summary"] !== "string") errors.push("summary must be a string.");
1181
- if (!Array.isArray(value["phases"])) errors.push("phases must be an array.");
1182
- const allIds = /* @__PURE__ */ new Set();
1183
- const tasks = /* @__PURE__ */ new Map();
1184
- const phases = Array.isArray(value["phases"]) ? value["phases"] : [];
1185
- for (const [phaseIndex, rawPhase] of phases.entries()) {
1186
- const at = `phases[${phaseIndex}]`;
1187
- checkKeys(rawPhase, ALLOWED_KEYS.phase, at, errors);
1188
- const phase = isRecord(rawPhase) ? rawPhase : {};
1189
- const phaseId = String(phase["id"] ?? "");
1190
- if (!ID_PATTERN.test(phaseId)) errors.push(`${at}.id is invalid.`);
1191
- if (allIds.has(phaseId)) errors.push(`Duplicate id "${phaseId}".`);
1192
- allIds.add(phaseId);
1193
- if (phase["title"] == null || phase["title"] === "") errors.push(`${at}.title is required.`);
1194
- if (!STATUSES.has(String(phase["status"]))) errors.push(`${at}.status is invalid.`);
1195
- checkPlan(phase["plan"], at, errors);
1196
- if (!Array.isArray(phase["tasks"])) errors.push(`${at}.tasks must be an array.`);
1197
- const rawTasks = Array.isArray(phase["tasks"]) ? phase["tasks"] : [];
1198
- for (const [taskIndex, rawTask] of rawTasks.entries()) {
1199
- const taskAt = `${at}.tasks[${taskIndex}]`;
1200
- checkKeys(rawTask, ALLOWED_KEYS.task, taskAt, errors);
1201
- const task = isRecord(rawTask) ? rawTask : {};
1202
- const taskId = String(task["id"] ?? "");
1203
- if (!ID_PATTERN.test(taskId)) errors.push(`${taskAt}.id is invalid.`);
1204
- if (allIds.has(taskId)) errors.push(`Duplicate id "${taskId}".`);
1205
- allIds.add(taskId);
1206
- tasks.set(taskId, task);
1207
- if (task["title"] == null || task["title"] === "") errors.push(`${taskAt}.title is required.`);
1208
- if (!STATUSES.has(String(task["status"]))) errors.push(`${taskAt}.status is invalid.`);
1209
- checkPlan(task["plan"], taskAt, errors);
1210
- if (task["status"] === "done" && task["completedAt"] == null) warnings.push(`${taskId} is done without completedAt.`);
1211
- if (task["status"] === "in-progress" && task["startedAt"] == null) warnings.push(`${taskId} is in-progress without startedAt.`);
1212
- if (task["dependsOn"] !== void 0 && !Array.isArray(task["dependsOn"])) errors.push(`${taskAt}.dependsOn must be an array.`);
1213
- }
1214
- if (Array.isArray(phase["tasks"]) && STATUSES.has(String(phase["status"]))) {
1215
- const expected = derivePhaseStatus({ ...phase, tasks: rawTasks });
1216
- if (phase["status"] !== expected) warnings.push(`${phaseId} status is ${String(phase["status"])}; task states derive ${expected}.`);
1217
- }
1218
- }
1219
- for (const task of tasks.values()) {
1220
- for (const dependency of task.dependsOn ?? []) {
1221
- if (!tasks.has(dependency)) warnings.push(`${task.id} depends on unknown task ${dependency}.`);
1222
- if (task.id === dependency) errors.push(`${task.id} cannot depend on itself.`);
1223
- }
1224
- }
1225
- const currentTaskId = value["currentTaskId"];
1226
- if (currentTaskId !== null && currentTaskId !== void 0) {
1227
- const current = tasks.get(String(currentTaskId));
1228
- if (current == null) errors.push(`currentTaskId ${String(currentTaskId)} does not exist.`);
1229
- else if (current.status !== "in-progress") warnings.push(`currentTaskId ${String(currentTaskId)} is not in-progress.`);
1230
- }
1231
- return { errors, warnings };
1232
- }
1233
- function deriveTaskStats(roadmap) {
1234
- let done = 0;
1235
- let blocked = 0;
1236
- let inProgress = 0;
1237
- let todo = 0;
1238
- for (const phase of roadmap.phases) {
1239
- for (const task of phase.tasks) {
1240
- if (task.status === "done") done += 1;
1241
- else if (task.status === "blocked") blocked += 1;
1242
- else if (task.status === "in-progress") inProgress += 1;
1243
- else todo += 1;
1244
- }
1245
- }
1246
- const total = done + blocked + inProgress + todo;
1247
- return {
1248
- total,
1249
- done,
1250
- blocked,
1251
- inProgress,
1252
- todo,
1253
- progressPct: total > 0 ? Math.round(done / total * 100) : 0
1254
- };
1255
- }
1256
-
1257
- // src/sync-core.ts
1475
+ import path9 from "path";
1258
1476
  function sha256Hex(content) {
1259
1477
  return "sha256:" + createHash("sha256").update(content, "utf8").digest("hex");
1260
1478
  }
@@ -1275,10 +1493,10 @@ function collectPlanRefs(roadmap) {
1275
1493
  }
1276
1494
  return refs;
1277
1495
  }
1278
- async function loadValidRoadmap(filePath, readFile7) {
1496
+ async function loadValidRoadmap(filePath, readFile8) {
1279
1497
  let raw;
1280
1498
  try {
1281
- raw = await readFile7(filePath);
1499
+ raw = await readFile8(filePath);
1282
1500
  } catch {
1283
1501
  throw new CliError(`No roadmap found at ${filePath}. Run \`nolto init\` first.`, 2);
1284
1502
  }
@@ -1301,7 +1519,7 @@ async function loadValidRoadmap(filePath, readFile7) {
1301
1519
  async function buildSyncBody(args) {
1302
1520
  const planDocuments = [];
1303
1521
  for (const ref of collectPlanRefs(args.roadmap)) {
1304
- const absolute = path7.join(args.repoRoot, ref.path);
1522
+ const absolute = path9.join(args.repoRoot, ref.path);
1305
1523
  if (!args.deps.fileExists(absolute)) {
1306
1524
  args.deps.warn(`plan file not found, skipping: ${ref.path}`);
1307
1525
  continue;
@@ -1314,12 +1532,13 @@ async function buildSyncBody(args) {
1314
1532
  contentHash: sha256Hex(content)
1315
1533
  });
1316
1534
  }
1317
- return { roadmap: args.roadmap, planDocuments };
1535
+ return { roadmap: args.roadmap, planDocuments, repoIdentity: args.repoIdentity };
1318
1536
  }
1319
1537
  async function runSync(args, deps) {
1320
1538
  const body = await buildSyncBody({
1321
1539
  roadmap: args.roadmap,
1322
1540
  repoRoot: args.repoRoot,
1541
+ repoIdentity: args.repoIdentity,
1323
1542
  deps
1324
1543
  });
1325
1544
  const response = await deps.http.put(
@@ -1347,7 +1566,7 @@ async function listRoadmapFiles(roadmapsDir, io) {
1347
1566
  }
1348
1567
  }
1349
1568
  async function migrateLegacyRoadmap(args) {
1350
- const targetPath = path8.join(args.roadmapsDir, `${args.slug}.json`);
1569
+ const targetPath = path10.join(args.roadmapsDir, `${args.slug}.json`);
1351
1570
  await args.io.mkdir(args.roadmapsDir);
1352
1571
  try {
1353
1572
  await args.io.rename(args.legacyPath, targetPath);
@@ -1356,7 +1575,7 @@ async function migrateLegacyRoadmap(args) {
1356
1575
  await args.io.unlink(args.legacyPath);
1357
1576
  }
1358
1577
  try {
1359
- await args.io.rmdir(path8.dirname(args.legacyPath));
1578
+ await args.io.rmdir(path10.dirname(args.legacyPath));
1360
1579
  } catch {
1361
1580
  }
1362
1581
  args.io.log(
@@ -1365,14 +1584,14 @@ async function migrateLegacyRoadmap(args) {
1365
1584
  return `${args.slug}.json`;
1366
1585
  }
1367
1586
  async function syncRepo(args, io) {
1368
- const bindingPath = path8.join(args.root, "nolto.json");
1587
+ const bindingPath = path10.join(args.root, "nolto.json");
1369
1588
  const binding = await loadRepoBinding(bindingPath);
1370
1589
  const projectId = binding?.projectId ?? args.defaultProjectId;
1371
1590
  if (projectId == null) {
1372
1591
  throw new CliError("No project binding. Run `nolto init` or `nolto link <projectId>`.", 2);
1373
1592
  }
1374
- const roadmapsDir = path8.join(args.root, ".nolto", "roadmaps");
1375
- const legacyPath = path8.join(args.root, ".roadmap", "roadmap.json");
1593
+ const roadmapsDir = path10.join(args.root, ".nolto", "roadmaps");
1594
+ const legacyPath = path10.join(args.root, ".roadmap", "roadmap.json");
1376
1595
  let roadmapFiles = await listRoadmapFiles(roadmapsDir, io);
1377
1596
  if (roadmapFiles.length > 0) {
1378
1597
  if (io.fileExists(legacyPath)) {
@@ -1381,7 +1600,7 @@ async function syncRepo(args, io) {
1381
1600
  );
1382
1601
  }
1383
1602
  } else if (io.fileExists(legacyPath)) {
1384
- const migrationSlug = binding?.roadmapSlug ?? slugifyProjectId(path8.basename(args.root));
1603
+ const migrationSlug = binding?.roadmapSlug ?? slugifyProjectId(path10.basename(args.root));
1385
1604
  roadmapFiles = [
1386
1605
  await migrateLegacyRoadmap({
1387
1606
  slug: migrationSlug,
@@ -1405,21 +1624,22 @@ async function syncRepo(args, io) {
1405
1624
  2
1406
1625
  );
1407
1626
  }
1408
- const filePath = path8.join(roadmapsDir, fileName);
1627
+ const filePath = path10.join(roadmapsDir, fileName);
1409
1628
  return { slug, roadmap: await loadValidRoadmap(filePath, io.readFile) };
1410
1629
  })
1411
1630
  );
1412
1631
  const planAbsPaths = /* @__PURE__ */ new Set();
1413
1632
  for (const { roadmap } of roadmaps) {
1414
1633
  for (const ref of collectPlanRefs(roadmap)) {
1415
- planAbsPaths.add(path8.join(args.root, ref.path));
1634
+ planAbsPaths.add(path10.join(args.root, ref.path));
1416
1635
  }
1417
1636
  }
1418
1637
  const results = [];
1638
+ const repoIdentity = await io.repoIdentity(args.root);
1419
1639
  for (const { slug, roadmap } of roadmaps) {
1420
1640
  results.push(
1421
1641
  await runSync(
1422
- { repoRoot: args.root, projectId, slug, roadmap },
1642
+ { repoRoot: args.root, projectId, slug, roadmap, repoIdentity },
1423
1643
  { http: io.http, readFile: io.readFile, fileExists: io.fileExists, log: io.log, warn: io.warn }
1424
1644
  )
1425
1645
  );
@@ -1438,38 +1658,46 @@ function register5(program, deps) {
1438
1658
  if (!foundGit) {
1439
1659
  throw new CliError("No git repository found. Run inside a repo set up with `nolto init`.", 2);
1440
1660
  }
1441
- const http = createHttpClient({
1442
- baseUrl: deps.settings.baseUrl,
1443
- version: deps.version,
1444
- token: deps.settings.token
1445
- });
1661
+ const http = deps.http;
1446
1662
  const response = await syncRepo(
1447
1663
  { root, defaultProjectId: deps.settings.defaultProjectId },
1448
1664
  {
1449
- readFile: (p) => readFile4(p, "utf8"),
1665
+ readFile: (p) => readFile5(p, "utf8"),
1450
1666
  fileExists: (p) => existsSync3(p),
1451
1667
  listDir: (p) => readdir2(p),
1452
- rename,
1668
+ rename: rename2,
1453
1669
  copyFile,
1454
- mkdir: (p) => mkdir5(p, { recursive: true }).then(() => void 0),
1455
- unlink,
1670
+ mkdir: (p) => mkdir6(p, { recursive: true }).then(() => void 0),
1671
+ unlink: unlink2,
1456
1672
  rmdir,
1673
+ repoIdentity: makeRepoIdentityResolver(deps),
1457
1674
  http,
1458
1675
  log: (line) => process.stdout.write(line + "\n"),
1459
1676
  warn: (line) => process.stderr.write("Warning: " + line + "\n")
1460
1677
  }
1461
1678
  );
1679
+ let registryAdded = false;
1680
+ try {
1681
+ registryAdded = (await addRepoToRegistry(getRegistryPath(process.env), root)).added;
1682
+ } catch (err) {
1683
+ const message = err instanceof Error ? err.message : String(err);
1684
+ process.stderr.write(`Warning: could not update watch registry: ${message}
1685
+ `);
1686
+ }
1462
1687
  if (deps.output.mode === "json") {
1463
1688
  const { planAbsPaths: _planAbsPaths, ...publicResponse } = response;
1464
- printResult(publicResponse, "json");
1689
+ printResult({ ...publicResponse, registryAdded }, "json");
1690
+ } else if (registryAdded) {
1691
+ process.stdout.write(`watch registry: added ${root}
1692
+ `);
1465
1693
  }
1466
1694
  });
1467
1695
  }
1468
1696
 
1469
1697
  // src/commands/watch.ts
1470
- import { copyFile as copyFile2, mkdir as mkdir6, readFile as readFile5, readdir as readdir3, rename as rename2, rmdir as rmdir2, unlink as unlink2 } from "fs/promises";
1698
+ import { copyFile as copyFile2, mkdir as mkdir7, readFile as readFile6, readdir as readdir3, rename as rename3, rmdir as rmdir2, unlink as unlink3 } from "fs/promises";
1471
1699
  import { existsSync as existsSync4 } from "fs";
1472
- import path10 from "path";
1700
+ import path12 from "path";
1473
1701
  import chokidar from "chokidar";
1474
1702
 
1475
1703
  // src/watch-core.ts
@@ -1575,7 +1803,7 @@ var RepoWatch = class {
1575
1803
  };
1576
1804
 
1577
1805
  // src/service-install.ts
1578
- import path9 from "path";
1806
+ import path11 from "path";
1579
1807
  import os2 from "os";
1580
1808
  function buildUnitFile(args) {
1581
1809
  return [
@@ -1595,8 +1823,8 @@ function buildUnitFile(args) {
1595
1823
  }
1596
1824
  function getUnitPath(env) {
1597
1825
  const xdg = env["XDG_CONFIG_HOME"];
1598
- const base = xdg != null && xdg.length > 0 ? xdg : path9.join(os2.homedir(), ".config");
1599
- return path9.join(base, "systemd", "user", "nolto-watch.service");
1826
+ const base = xdg != null && xdg.length > 0 ? xdg : path11.join(os2.homedir(), ".config");
1827
+ return path11.join(base, "systemd", "user", "nolto-watch.service");
1600
1828
  }
1601
1829
  async function installServiceWith(deps) {
1602
1830
  if (deps.platform !== "linux") {
@@ -1607,7 +1835,7 @@ async function installServiceWith(deps) {
1607
1835
  );
1608
1836
  }
1609
1837
  const unitPath = getUnitPath(deps.env);
1610
- await deps.mkdir(path9.dirname(unitPath));
1838
+ await deps.mkdir(path11.dirname(unitPath));
1611
1839
  await deps.writeFile(unitPath, buildUnitFile({ nodePath: deps.nodePath, scriptPath: deps.scriptPath }));
1612
1840
  deps.log(`Wrote ${unitPath}`);
1613
1841
  const reload = await deps.exec(["systemctl", "--user", "daemon-reload"]);
@@ -1621,18 +1849,18 @@ async function installServiceWith(deps) {
1621
1849
  deps.log("Service nolto-watch enabled and started. Logs: journalctl --user -u nolto-watch -f");
1622
1850
  }
1623
1851
  async function installService() {
1624
- const { writeFile: writeFile6, mkdir: mkdir8 } = await import("fs/promises");
1625
- const { execFile: execFile2 } = await import("child_process");
1852
+ const { writeFile: writeFile7, mkdir: mkdir9 } = await import("fs/promises");
1853
+ const { execFile: execFile3 } = await import("child_process");
1626
1854
  const { promisify: promisify2 } = await import("util");
1627
- const execFileAsync = promisify2(execFile2);
1855
+ const execFileAsync = promisify2(execFile3);
1628
1856
  await installServiceWith({
1629
1857
  platform: process.platform,
1630
1858
  env: process.env,
1631
1859
  nodePath: process.execPath,
1632
- scriptPath: path9.resolve(process.argv[1] ?? ""),
1633
- writeFile: (p, content) => writeFile6(p, content, "utf8"),
1860
+ scriptPath: path11.resolve(process.argv[1] ?? ""),
1861
+ writeFile: (p, content) => writeFile7(p, content, "utf8"),
1634
1862
  mkdir: async (p) => {
1635
- await mkdir8(p, { recursive: true });
1863
+ await mkdir9(p, { recursive: true });
1636
1864
  },
1637
1865
  exec: async (cmd) => {
1638
1866
  try {
@@ -1686,14 +1914,14 @@ async function uninstallServiceWith(deps) {
1686
1914
  deps.log("Service nolto-watch stopped, disabled, and removed.");
1687
1915
  }
1688
1916
  async function uninstallService() {
1689
- const { unlink: unlink3 } = await import("fs/promises");
1690
- const { execFile: execFile2 } = await import("child_process");
1917
+ const { unlink: unlink4 } = await import("fs/promises");
1918
+ const { execFile: execFile3 } = await import("child_process");
1691
1919
  const { promisify: promisify2 } = await import("util");
1692
- const execFileAsync = promisify2(execFile2);
1920
+ const execFileAsync = promisify2(execFile3);
1693
1921
  await uninstallServiceWith({
1694
1922
  platform: process.platform,
1695
1923
  env: process.env,
1696
- unlink: unlink3,
1924
+ unlink: unlink4,
1697
1925
  exec: async (cmd) => {
1698
1926
  try {
1699
1927
  await execFileAsync(cmd[0], cmd.slice(1));
@@ -1746,25 +1974,26 @@ function register6(program, deps) {
1746
1974
  token: deps.settings.token
1747
1975
  });
1748
1976
  const startRepo = (root) => {
1749
- const roadmapsPath = path10.join(root, ".nolto", "roadmaps");
1750
- const legacyRoadmapPath = path10.join(root, ".roadmap", "roadmap.json");
1977
+ const roadmapsPath = path12.join(root, ".nolto", "roadmaps");
1978
+ const legacyRoadmapPath = path12.join(root, ".roadmap", "roadmap.json");
1751
1979
  const watcher = chokidar.watch([roadmapsPath, legacyRoadmapPath], { ignoreInitial: true });
1752
1980
  const repoWatch = new RepoWatch(root, {
1753
1981
  sync: () => syncRepo(
1754
1982
  { root, defaultProjectId: deps.settings.defaultProjectId },
1755
1983
  {
1756
- readFile: (p) => readFile5(p, "utf8"),
1984
+ readFile: (p) => readFile6(p, "utf8"),
1757
1985
  fileExists: (p) => existsSync4(p),
1758
1986
  listDir: (p) => readdir3(p),
1759
- rename: rename2,
1987
+ rename: rename3,
1760
1988
  copyFile: copyFile2,
1761
- mkdir: (p) => mkdir6(p, { recursive: true }).then(() => void 0),
1762
- unlink: unlink2,
1989
+ mkdir: (p) => mkdir7(p, { recursive: true }).then(() => void 0),
1990
+ unlink: unlink3,
1763
1991
  rmdir: rmdir2,
1992
+ repoIdentity: makeRepoIdentityResolver(deps),
1764
1993
  http,
1765
- log: (line) => process.stdout.write(`[${path10.basename(root)}] ${line}
1994
+ log: (line) => process.stdout.write(`[${path12.basename(root)}] ${line}
1766
1995
  `),
1767
- warn: (line) => process.stderr.write(`Warning: [${path10.basename(root)}] ${line}
1996
+ warn: (line) => process.stderr.write(`Warning: [${path12.basename(root)}] ${line}
1768
1997
  `)
1769
1998
  }
1770
1999
  ),
@@ -1820,18 +2049,18 @@ function register6(program, deps) {
1820
2049
  }
1821
2050
 
1822
2051
  // src/update-cli.ts
1823
- import { execFile } from "child_process";
2052
+ import { execFile as execFile2 } from "child_process";
1824
2053
  import { existsSync as existsSync5 } from "fs";
1825
2054
  import { realpath } from "fs/promises";
1826
2055
  import { createRequire as createRequire2 } from "module";
1827
- import path12 from "path";
2056
+ import path14 from "path";
1828
2057
  import { fileURLToPath as fileURLToPath3 } from "url";
1829
2058
  import { promisify } from "util";
1830
2059
 
1831
2060
  // src/update-notifier.ts
1832
- import { readFile as readFile6, writeFile as writeFile5, mkdir as mkdir7 } from "fs/promises";
2061
+ import { readFile as readFile7, writeFile as writeFile6, mkdir as mkdir8 } from "fs/promises";
1833
2062
  import https from "https";
1834
- import path11 from "path";
2063
+ import path13 from "path";
1835
2064
  var PACKAGE = "@nolto/cli";
1836
2065
  var CACHE_FILE = "update-check.json";
1837
2066
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
@@ -1889,9 +2118,9 @@ function fetchLatestFromRegistry(timeoutMs = REQUEST_TIMEOUT_MS, opts = {}) {
1889
2118
  });
1890
2119
  }
1891
2120
  async function writeUpdateCache(cachePath, now, latest) {
1892
- await mkdir7(path11.dirname(cachePath), { recursive: true });
2121
+ await mkdir8(path13.dirname(cachePath), { recursive: true });
1893
2122
  const payload = { checkedAt: now, latest };
1894
- await writeFile5(cachePath, JSON.stringify(payload), { mode: 384 });
2123
+ await writeFile6(cachePath, JSON.stringify(payload), { mode: 384 });
1895
2124
  }
1896
2125
  async function refreshCache(cachePath, now, fetchLatest) {
1897
2126
  const latest = await fetchLatest();
@@ -1900,10 +2129,10 @@ async function refreshCache(cachePath, now, fetchLatest) {
1900
2129
  }
1901
2130
  async function checkForUpdate(opts) {
1902
2131
  if (isDisabled(opts.env)) return null;
1903
- const cachePath = path11.join(opts.configDir, CACHE_FILE);
2132
+ const cachePath = path13.join(opts.configDir, CACHE_FILE);
1904
2133
  let cache = {};
1905
2134
  try {
1906
- cache = JSON.parse(await readFile6(cachePath, "utf8"));
2135
+ cache = JSON.parse(await readFile7(cachePath, "utf8"));
1907
2136
  } catch {
1908
2137
  }
1909
2138
  if (typeof cache.checkedAt !== "number" || opts.now - cache.checkedAt > CACHE_TTL_MS) {
@@ -2008,7 +2237,7 @@ async function updateCliWith(deps) {
2008
2237
  }
2009
2238
  deps.log(`Updated ${PACKAGE2} ${deps.currentVersion} \u2192 ${latest}.`);
2010
2239
  await deps.writeCache(
2011
- path12.join(deps.configDir, UPDATE_CACHE_FILE),
2240
+ path14.join(deps.configDir, UPDATE_CACHE_FILE),
2012
2241
  deps.now,
2013
2242
  latest
2014
2243
  ).catch(() => void 0);
@@ -2039,17 +2268,17 @@ async function updateCliWith(deps) {
2039
2268
  };
2040
2269
  }
2041
2270
  function getCurrentVersion() {
2042
- const dirname = path12.dirname(fileURLToPath3(import.meta.url));
2271
+ const dirname = path14.dirname(fileURLToPath3(import.meta.url));
2043
2272
  const require3 = createRequire2(import.meta.url);
2044
2273
  try {
2045
- const pkg = require3(path12.resolve(dirname, "../package.json"));
2274
+ const pkg = require3(path14.resolve(dirname, "../package.json"));
2046
2275
  return pkg.version ?? "0.0.0";
2047
2276
  } catch {
2048
2277
  return "0.0.0";
2049
2278
  }
2050
2279
  }
2051
2280
  async function updateCli(opts = {}) {
2052
- const execFileAsync = promisify(execFile);
2281
+ const execFileAsync = promisify(execFile2);
2053
2282
  const scriptPath = await realpath(process.argv[1] ?? "");
2054
2283
  return updateCliWith({
2055
2284
  currentVersion: getCurrentVersion(),
@@ -2117,11 +2346,11 @@ function buildProgram(deps) {
2117
2346
  }
2118
2347
 
2119
2348
  // src/index.ts
2120
- var __dirname3 = path13.dirname(fileURLToPath4(import.meta.url));
2349
+ var __dirname3 = path15.dirname(fileURLToPath4(import.meta.url));
2121
2350
  var require2 = createRequire3(import.meta.url);
2122
2351
  function getVersion() {
2123
2352
  try {
2124
- const pkgPath = path13.resolve(__dirname3, "../package.json");
2353
+ const pkgPath = path15.resolve(__dirname3, "../package.json");
2125
2354
  const pkg = require2(pkgPath);
2126
2355
  return pkg.version ?? "0.0.0";
2127
2356
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nolto/cli",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "CLI for syncing repository roadmaps with Nolto.",
5
5
  "license": "MIT",
6
6
  "type": "module",