@nolto/cli 0.8.1 → 0.9.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 +33 -1
  2. package/dist/index.js +710 -98
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -63,6 +63,9 @@ CLI versions older than 0.8.0 do not send a repository identity and receive
63
63
 
64
64
  ```bash
65
65
  nolto sync # Push roadmap JSON files and linked plan Markdown
66
+ nolto diff [slug] # Compare local and server roadmaps without changing them
67
+ nolto pull [slug] # Overwrite local roadmap files from the server
68
+ nolto pull --merge [slug] # Structurally merge server and local roadmaps
66
69
  nolto watch [--debounce <ms>] # Watch registered repositories and auto-sync
67
70
  nolto watch --install-service # Install the Linux systemd user service
68
71
  ```
@@ -78,6 +81,35 @@ repositories are skipped with a warning.
78
81
  The CLI warns when an installed `roadmap-progress` skill version differs from the
79
82
  CLI version; run `nolto init` in that repository to refresh it.
80
83
 
84
+ Use `nolto diff [slug]` before pushing to review structural task, phase, and
85
+ metadata differences. It exits with code 1 when differences are found and does
86
+ not change either side. `nolto pull [slug]` downloads all server roadmaps, or one
87
+ named roadmap, and intentionally overwrites the corresponding local files after
88
+ validation. It never deletes local-only roadmaps. Add `--merge` to preserve valid
89
+ local changes using a deterministic structural merge, and review the result with
90
+ `git diff` before committing.
91
+
92
+ ## Merging roadmap conflicts
93
+
94
+ Roadmap files use Nolto's structural Git merge driver. The repository attribute
95
+ and local Git configuration are:
96
+
97
+ ```text
98
+ .nolto/roadmaps/*.json merge=nolto-roadmap
99
+ git config --local merge.nolto-roadmap.driver "nolto merge-file %A %B --base %O"
100
+ ```
101
+
102
+ `nolto init` sets up both entries for the current repository and is safe to run
103
+ again for an existing repository. Git invokes the underlying command as:
104
+
105
+ ```bash
106
+ nolto merge-file <ours> <theirs> --base <ancestor>
107
+ ```
108
+
109
+ The result is written to `<ours>` as required by Git. Use
110
+ `nolto pull --merge [slug]` when the server has changes that should be combined
111
+ with a valid local roadmap before syncing.
112
+
81
113
  ## Configuration
82
114
 
83
115
  The config file is `~/.config/nolto/config.json` (or
@@ -139,7 +171,7 @@ Errors are written to stderr:
139
171
  | Code | Meaning |
140
172
  |---|---|
141
173
  | 0 | Success |
142
- | 1 | Unexpected command failure |
174
+ | 1 | Roadmap differences found (`nolto diff`) or unexpected command failure |
143
175
  | 2 | Local input or file validation error |
144
176
  | 3 | Authentication or authorization error |
145
177
  | 4 | Rate limit response |
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 path15 from "path";
6
+ import path19 from "path";
7
7
  import { CommanderError } from "commander";
8
8
 
9
9
  // src/config.ts
@@ -314,11 +314,11 @@ function maskToken(token) {
314
314
  function createHttpClient(opts) {
315
315
  const { baseUrl, version, token } = opts;
316
316
  const base = baseUrl.replace(/\/+$/, "");
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);
317
+ async function request(method, path20, body) {
318
+ if (!path20.startsWith("/api/")) {
319
+ throw new CliError(`HTTP client path must start with /api/, got: ${path20}`, 2);
320
320
  }
321
- const url = `${base}${path16}`;
321
+ const url = `${base}${path20}`;
322
322
  const headers = {
323
323
  "Content-Type": "application/json",
324
324
  "User-Agent": `${CLI_USER_AGENT_NAME}/${version}`
@@ -378,7 +378,7 @@ import readline from "readline/promises";
378
378
  import { createRequire } from "module";
379
379
  import { fileURLToPath as fileURLToPath2 } from "url";
380
380
  import os3 from "os";
381
- import path8 from "path";
381
+ import path9 from "path";
382
382
  import fs from "fs";
383
383
 
384
384
  // src/commands/link.ts
@@ -519,12 +519,12 @@ function normalizeRemote(raw) {
519
519
  const firstSlash = s.indexOf("/");
520
520
  if (firstSlash <= 0) return null;
521
521
  let host = s.slice(0, firstSlash).toLowerCase();
522
- let path16 = s.slice(firstSlash + 1);
522
+ let path20 = s.slice(firstSlash + 1);
523
523
  if (hadScheme) host = host.replace(/:\d+$/, "");
524
- path16 = path16.replace(/\/+/g, "/").replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/+$/, "");
525
- if (path16.length === 0) return null;
526
- if (HOSTED_LOWERCASE_PATH.has(host)) path16 = path16.toLowerCase();
527
- return `${host}/${path16}`;
524
+ path20 = path20.replace(/\/+/g, "/").replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/+$/, "");
525
+ if (path20.length === 0) return null;
526
+ if (HOSTED_LOWERCASE_PATH.has(host)) path20 = path20.toLowerCase();
527
+ return `${host}/${path20}`;
528
528
  }
529
529
 
530
530
  // ../roadmap-schema/src/index.ts
@@ -836,10 +836,10 @@ async function handleRebind(deps, projectId, root, mode2) {
836
836
  }
837
837
  }
838
838
  async function handleUnlink(projectBindingPath, mode2) {
839
- const { readFile: readFile8, writeFile: writeFile7, chmod: chmod2 } = await import("fs/promises");
839
+ const { readFile: readFile12, writeFile: writeFile10, chmod: chmod2 } = await import("fs/promises");
840
840
  let existing = {};
841
841
  try {
842
- const raw = await readFile8(projectBindingPath, "utf8");
842
+ const raw = await readFile12(projectBindingPath, "utf8");
843
843
  const parsed = JSON.parse(raw);
844
844
  if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
845
845
  throw new CliError(
@@ -854,7 +854,7 @@ async function handleUnlink(projectBindingPath, mode2) {
854
854
  }
855
855
  const { projectId: _removed, ...rest } = existing;
856
856
  void _removed;
857
- await writeFile7(projectBindingPath, JSON.stringify(rest, null, 2) + "\n", { mode: 420 });
857
+ await writeFile10(projectBindingPath, JSON.stringify(rest, null, 2) + "\n", { mode: 420 });
858
858
  await chmod2(projectBindingPath, 420);
859
859
  if (mode2 === "json") {
860
860
  printResult({ unlinked: true, projectBindingPath }, mode2);
@@ -1103,14 +1103,55 @@ async function scaffoldRoadmap(args) {
1103
1103
  return { created: true, path: filePath };
1104
1104
  }
1105
1105
 
1106
+ // src/git-merge-driver.ts
1107
+ import { execFile as execFile2 } from "child_process";
1108
+ import { readFile as readFile5, writeFile as writeFile6 } from "fs/promises";
1109
+ import path8 from "path";
1110
+ import { promisify } from "util";
1111
+ var ATTRIBUTE_LINE = ".nolto/roadmaps/*.json merge=nolto-roadmap";
1112
+ var DRIVER_COMMAND = "nolto merge-file %A %B --base %O";
1113
+ async function ensureGitAttributes(root) {
1114
+ const filePath = path8.join(root, ".gitattributes");
1115
+ let content = "";
1116
+ try {
1117
+ content = await readFile5(filePath, "utf8");
1118
+ } catch (err) {
1119
+ if (err.code !== "ENOENT") throw err;
1120
+ }
1121
+ if (content.split(/\r?\n/).includes(ATTRIBUTE_LINE)) return "exists";
1122
+ const separator = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
1123
+ await writeFile6(filePath, content + separator + ATTRIBUTE_LINE + "\n", "utf8");
1124
+ return "added";
1125
+ }
1126
+ async function configureMergeDriver(root, exec = async (file, args, options) => {
1127
+ const execFileAsync = promisify(execFile2);
1128
+ await execFileAsync(file, args, options);
1129
+ }) {
1130
+ try {
1131
+ await exec(
1132
+ "git",
1133
+ ["config", "--local", "merge.nolto-roadmap.name", "Nolto roadmap merge"],
1134
+ { cwd: root }
1135
+ );
1136
+ await exec(
1137
+ "git",
1138
+ ["config", "--local", "merge.nolto-roadmap.driver", DRIVER_COMMAND],
1139
+ { cwd: root }
1140
+ );
1141
+ return "configured";
1142
+ } catch {
1143
+ return "skipped";
1144
+ }
1145
+ }
1146
+
1106
1147
  // src/commands/init.ts
1107
- var __dirname2 = path8.dirname(fileURLToPath2(import.meta.url));
1148
+ var __dirname2 = path9.dirname(fileURLToPath2(import.meta.url));
1108
1149
  var _require = createRequire(import.meta.url);
1109
1150
  function getCliVersion() {
1110
1151
  const candidates = [
1111
- path8.resolve(__dirname2, "../package.json"),
1152
+ path9.resolve(__dirname2, "../package.json"),
1112
1153
  // bundled: dist/../package.json
1113
- path8.resolve(__dirname2, "../../package.json")
1154
+ path9.resolve(__dirname2, "../../package.json")
1114
1155
  // source: src/commands/../../package.json
1115
1156
  ];
1116
1157
  for (const pkgPath of candidates) {
@@ -1251,13 +1292,13 @@ Saved ${configPath}
1251
1292
  }
1252
1293
  if (isHomeDirectory(root)) {
1253
1294
  process.stderr.write(
1254
- `Refusing to set up your home directory as a repository root (found ${path8.join(os3.homedir(), ".git")}). Run nolto init inside a project repository.
1295
+ `Refusing to set up your home directory as a repository root (found ${path9.join(os3.homedir(), ".git")}). Run nolto init inside a project repository.
1255
1296
  `
1256
1297
  );
1257
1298
  return;
1258
1299
  }
1259
1300
  const existingBinding = deps.repoBinding?.error == null ? deps.repoBinding?.binding ?? null : null;
1260
- let repoProject = existingBinding != null ? { id: existingBinding.projectId, name: path8.basename(root) } : defaultProjectId != null ? { id: defaultProjectId, name: defaultProjectName ?? path8.basename(root) } : void 0;
1301
+ let repoProject = existingBinding != null ? { id: existingBinding.projectId, name: path9.basename(root) } : defaultProjectId != null ? { id: defaultProjectId, name: defaultProjectName ?? path9.basename(root) } : void 0;
1261
1302
  if (repoProject == null) {
1262
1303
  http ??= createHttpClient({ baseUrl, token, version: getCliVersion() });
1263
1304
  if (projects == null) {
@@ -1281,7 +1322,7 @@ Set up this repository (${root}) for roadmap sync? [Y/n] `);
1281
1322
  process.stdout.write("Skipped repo setup.\n");
1282
1323
  return;
1283
1324
  }
1284
- const bindingPath = deps.repoBinding?.path ?? path8.join(root, "nolto.json");
1325
+ const bindingPath = deps.repoBinding?.path ?? path9.join(root, "nolto.json");
1285
1326
  if (existingBinding != null) {
1286
1327
  process.stdout.write(`binding: kept ${bindingPath} (${existingBinding.projectId})
1287
1328
  `);
@@ -1293,22 +1334,22 @@ Set up this repository (${root}) for roadmap sync? [Y/n] `);
1293
1334
  );
1294
1335
  }
1295
1336
  await writeRepoBinding(root, repoProject.id);
1296
- process.stdout.write(`binding: wrote ${path8.join(root, "nolto.json")}
1337
+ process.stdout.write(`binding: wrote ${path9.join(root, "nolto.json")}
1297
1338
  `);
1298
1339
  }
1299
1340
  const sourceDir = resolveSkillSourceDir();
1300
1341
  const version = getCliVersion();
1301
1342
  const claudeInstall = await installSkill({
1302
- skillsParentDir: path8.join(root, ".claude", "skills"),
1343
+ skillsParentDir: path9.join(root, ".claude", "skills"),
1303
1344
  sourceDir,
1304
1345
  version
1305
1346
  });
1306
1347
  process.stdout.write(`skill (claude): ${claudeInstall.action} ${claudeInstall.targetDir}
1307
1348
  `);
1308
- const usesAgentsTooling = fs.existsSync(path8.join(root, ".agents")) || fs.existsSync(path8.join(root, ".codex")) || fs.existsSync(path8.join(root, "AGENTS.md"));
1349
+ const usesAgentsTooling = fs.existsSync(path9.join(root, ".agents")) || fs.existsSync(path9.join(root, ".codex")) || fs.existsSync(path9.join(root, "AGENTS.md"));
1309
1350
  if (usesAgentsTooling) {
1310
1351
  const agentsInstall = await installSkill({
1311
- skillsParentDir: path8.join(root, ".agents", "skills"),
1352
+ skillsParentDir: path9.join(root, ".agents", "skills"),
1312
1353
  sourceDir,
1313
1354
  version
1314
1355
  });
@@ -1330,6 +1371,13 @@ Set up this repository (${root}) for roadmap sync? [Y/n] `);
1330
1371
  ` : `watch registry: already registered
1331
1372
  `
1332
1373
  );
1374
+ const attributesResult = await ensureGitAttributes(root);
1375
+ process.stdout.write(`gitattributes: ${attributesResult} .gitattributes
1376
+ `);
1377
+ const mergeDriverResult = await configureMergeDriver(root, deps.gitMergeExec);
1378
+ process.stdout.write(
1379
+ mergeDriverResult === "configured" ? "merge driver: configured\n" : "merge driver: skipped (git unavailable)\n"
1380
+ );
1333
1381
  } finally {
1334
1382
  rl.close();
1335
1383
  }
@@ -1549,15 +1597,15 @@ function register4(program, deps) {
1549
1597
  }
1550
1598
 
1551
1599
  // src/commands/sync.ts
1552
- import { copyFile, mkdir as mkdir6, readFile as readFile5, readdir as readdir2, rename as rename2, rmdir, unlink as unlink2 } from "fs/promises";
1600
+ import { copyFile, mkdir as mkdir6, readFile as readFile6, readdir as readdir2, rename as rename2, rmdir, unlink as unlink2 } from "fs/promises";
1553
1601
  import { existsSync as existsSync3 } from "fs";
1554
1602
 
1555
1603
  // src/sync-repo.ts
1556
- import path10 from "path";
1604
+ import path11 from "path";
1557
1605
 
1558
1606
  // src/sync-core.ts
1559
1607
  import { createHash } from "crypto";
1560
- import path9 from "path";
1608
+ import path10 from "path";
1561
1609
  function sha256Hex(content) {
1562
1610
  return "sha256:" + createHash("sha256").update(content, "utf8").digest("hex");
1563
1611
  }
@@ -1578,10 +1626,10 @@ function collectPlanRefs(roadmap) {
1578
1626
  }
1579
1627
  return refs;
1580
1628
  }
1581
- async function loadValidRoadmap(filePath, readFile8) {
1629
+ async function loadValidRoadmap(filePath, readFile12) {
1582
1630
  let raw;
1583
1631
  try {
1584
- raw = await readFile8(filePath);
1632
+ raw = await readFile12(filePath);
1585
1633
  } catch {
1586
1634
  throw new CliError(`No roadmap found at ${filePath}. Run \`nolto init\` first.`, 2);
1587
1635
  }
@@ -1604,7 +1652,7 @@ async function loadValidRoadmap(filePath, readFile8) {
1604
1652
  async function buildSyncBody(args) {
1605
1653
  const planDocuments = [];
1606
1654
  for (const ref of collectPlanRefs(args.roadmap)) {
1607
- const absolute = path9.join(args.repoRoot, ref.path);
1655
+ const absolute = path10.join(args.repoRoot, ref.path);
1608
1656
  if (!args.deps.fileExists(absolute)) {
1609
1657
  args.deps.warn(`plan file not found, skipping: ${ref.path}`);
1610
1658
  continue;
@@ -1651,7 +1699,7 @@ async function listRoadmapFiles(roadmapsDir, io) {
1651
1699
  }
1652
1700
  }
1653
1701
  async function migrateLegacyRoadmap(args) {
1654
- const targetPath = path10.join(args.roadmapsDir, `${args.slug}.json`);
1702
+ const targetPath = path11.join(args.roadmapsDir, `${args.slug}.json`);
1655
1703
  await args.io.mkdir(args.roadmapsDir);
1656
1704
  try {
1657
1705
  await args.io.rename(args.legacyPath, targetPath);
@@ -1660,7 +1708,7 @@ async function migrateLegacyRoadmap(args) {
1660
1708
  await args.io.unlink(args.legacyPath);
1661
1709
  }
1662
1710
  try {
1663
- await args.io.rmdir(path10.dirname(args.legacyPath));
1711
+ await args.io.rmdir(path11.dirname(args.legacyPath));
1664
1712
  } catch {
1665
1713
  }
1666
1714
  args.io.log(
@@ -1669,14 +1717,14 @@ async function migrateLegacyRoadmap(args) {
1669
1717
  return `${args.slug}.json`;
1670
1718
  }
1671
1719
  async function syncRepo(args, io) {
1672
- const bindingPath = path10.join(args.root, "nolto.json");
1720
+ const bindingPath = path11.join(args.root, "nolto.json");
1673
1721
  const binding = await loadRepoBinding(bindingPath);
1674
1722
  const projectId = binding?.projectId ?? args.defaultProjectId;
1675
1723
  if (projectId == null) {
1676
1724
  throw new CliError("No project binding. Run `nolto init` or `nolto link <projectId>`.", 2);
1677
1725
  }
1678
- const roadmapsDir = path10.join(args.root, ".nolto", "roadmaps");
1679
- const legacyPath = path10.join(args.root, ".roadmap", "roadmap.json");
1726
+ const roadmapsDir = path11.join(args.root, ".nolto", "roadmaps");
1727
+ const legacyPath = path11.join(args.root, ".roadmap", "roadmap.json");
1680
1728
  let roadmapFiles = await listRoadmapFiles(roadmapsDir, io);
1681
1729
  if (roadmapFiles.length > 0) {
1682
1730
  if (io.fileExists(legacyPath)) {
@@ -1691,7 +1739,7 @@ async function syncRepo(args, io) {
1691
1739
  );
1692
1740
  return { results: [], planAbsPaths: [] };
1693
1741
  }
1694
- const migrationSlug = binding?.roadmapSlug ?? slugifyProjectId(path10.basename(args.root));
1742
+ const migrationSlug = binding?.roadmapSlug ?? slugifyProjectId(path11.basename(args.root));
1695
1743
  roadmapFiles = [
1696
1744
  await migrateLegacyRoadmap({
1697
1745
  slug: migrationSlug,
@@ -1715,14 +1763,14 @@ async function syncRepo(args, io) {
1715
1763
  2
1716
1764
  );
1717
1765
  }
1718
- const filePath = path10.join(roadmapsDir, fileName);
1766
+ const filePath = path11.join(roadmapsDir, fileName);
1719
1767
  return { slug, roadmap: await loadValidRoadmap(filePath, io.readFile) };
1720
1768
  })
1721
1769
  );
1722
1770
  const planAbsPaths = /* @__PURE__ */ new Set();
1723
1771
  for (const { roadmap } of roadmaps) {
1724
1772
  for (const ref of collectPlanRefs(roadmap)) {
1725
- planAbsPaths.add(path10.join(args.root, ref.path));
1773
+ planAbsPaths.add(path11.join(args.root, ref.path));
1726
1774
  }
1727
1775
  }
1728
1776
  const results = [];
@@ -1756,7 +1804,7 @@ function register5(program, deps) {
1756
1804
  const response = await syncRepo(
1757
1805
  { root, defaultProjectId: deps.settings.defaultProjectId, migrateLegacy: true },
1758
1806
  {
1759
- readFile: (p) => readFile5(p, "utf8"),
1807
+ readFile: (p) => readFile6(p, "utf8"),
1760
1808
  fileExists: (p) => existsSync3(p),
1761
1809
  listDir: (p) => readdir2(p),
1762
1810
  rename: rename2,
@@ -1800,10 +1848,531 @@ function register5(program, deps) {
1800
1848
  });
1801
1849
  }
1802
1850
 
1851
+ // src/commands/diff.ts
1852
+ import { readFile as readFile7 } from "fs/promises";
1853
+ import path13 from "path";
1854
+
1855
+ // src/roadmap-diff.ts
1856
+ function diffRoadmaps(local, server) {
1857
+ if (local === null && server === null) return [];
1858
+ if (local === null) return [{ kind: "meta", field: "roadmap", side: "server" }];
1859
+ if (server === null) return [{ kind: "meta", field: "roadmap", side: "local" }];
1860
+ const entries = [];
1861
+ const serverPhases = new Map(server.phases.map((phase) => [phase.id, phase]));
1862
+ for (const localPhase of local.phases) {
1863
+ const serverPhase = serverPhases.get(localPhase.id);
1864
+ if (!serverPhase) {
1865
+ entries.push({
1866
+ kind: "phase-removed",
1867
+ id: localPhase.id,
1868
+ title: localPhase.title,
1869
+ side: "local"
1870
+ });
1871
+ continue;
1872
+ }
1873
+ const serverTasks = new Map(serverPhase.tasks.map((task) => [task.id, task]));
1874
+ for (const localTask of localPhase.tasks) {
1875
+ const serverTask = serverTasks.get(localTask.id);
1876
+ if (!serverTask) {
1877
+ entries.push({
1878
+ kind: "task-removed",
1879
+ id: localTask.id,
1880
+ title: localTask.title,
1881
+ side: "local"
1882
+ });
1883
+ } else if (localTask.status !== serverTask.status) {
1884
+ entries.push({
1885
+ kind: "task-status",
1886
+ id: localTask.id,
1887
+ title: localTask.title,
1888
+ local: localTask.status,
1889
+ server: serverTask.status
1890
+ });
1891
+ }
1892
+ }
1893
+ const localTaskIds = new Set(localPhase.tasks.map((task) => task.id));
1894
+ for (const serverTask of serverPhase.tasks) {
1895
+ if (!localTaskIds.has(serverTask.id)) {
1896
+ entries.push({
1897
+ kind: "task-added",
1898
+ id: serverTask.id,
1899
+ title: serverTask.title,
1900
+ side: "server"
1901
+ });
1902
+ }
1903
+ }
1904
+ }
1905
+ const localPhaseIds = new Set(local.phases.map((phase) => phase.id));
1906
+ for (const serverPhase of server.phases) {
1907
+ if (!localPhaseIds.has(serverPhase.id)) {
1908
+ entries.push({
1909
+ kind: "phase-added",
1910
+ id: serverPhase.id,
1911
+ title: serverPhase.title,
1912
+ side: "server"
1913
+ });
1914
+ }
1915
+ }
1916
+ const meta = [
1917
+ { field: "updatedAt", local: local.updatedAt, server: server.updatedAt },
1918
+ {
1919
+ field: "currentTaskId",
1920
+ local: local.currentTaskId ?? null,
1921
+ server: server.currentTaskId ?? null
1922
+ },
1923
+ { field: "summary", local: local.summary, server: server.summary }
1924
+ ];
1925
+ for (const item of meta) {
1926
+ if (item.local !== item.server) entries.push({ kind: "meta", ...item });
1927
+ }
1928
+ return entries;
1929
+ }
1930
+ function truncate(value) {
1931
+ if (value == null) return "none";
1932
+ const singleLine = value.replace(/\s+/g, " ");
1933
+ return singleLine.length <= 60 ? singleLine : singleLine.slice(0, 57) + "...";
1934
+ }
1935
+ function formatRoadmapDiff(slug, entries) {
1936
+ if (entries.length === 0) return [];
1937
+ const lines = [`${slug}:`];
1938
+ for (const entry of entries) {
1939
+ if (entry.kind === "task-status") {
1940
+ lines.push(` ${entry.id}: ${entry.local} (local) != ${entry.server} (server)`);
1941
+ } else if (entry.kind === "task-added" || entry.kind === "task-removed") {
1942
+ lines.push(` ${entry.side} only task ${entry.id} "${truncate(entry.title)}"`);
1943
+ } else if (entry.kind === "phase-added" || entry.kind === "phase-removed") {
1944
+ lines.push(` ${entry.side} only phase ${entry.id} "${truncate(entry.title)}"`);
1945
+ } else if (entry.field === "roadmap") {
1946
+ lines.push(` ${entry.side} only roadmap`);
1947
+ } else {
1948
+ lines.push(
1949
+ ` ${entry.field}: ${truncate(entry.local)} (local) != ${truncate(entry.server)} (server)`
1950
+ );
1951
+ }
1952
+ }
1953
+ return lines;
1954
+ }
1955
+
1956
+ // src/roadmap-read.ts
1957
+ import { readdir as readdir3 } from "fs/promises";
1958
+ import path12 from "path";
1959
+ async function resolveRoadmapReadContext(deps) {
1960
+ if (deps.settings.token == null) {
1961
+ throw new CliError("Not authenticated. Run `nolto login` or set NOLTO_TOKEN.", 3);
1962
+ }
1963
+ const startDir = resolveStartDir(process.env, process.cwd());
1964
+ const { root, foundGit } = findRepoRoot(startDir);
1965
+ if (!foundGit) {
1966
+ throw new CliError("No git repository found. Run inside a repo set up with `nolto init`.", 2);
1967
+ }
1968
+ const binding = await loadRepoBinding(path12.join(root, "nolto.json"));
1969
+ const projectId = binding?.projectId ?? deps.settings.defaultProjectId;
1970
+ if (projectId == null) {
1971
+ throw new CliError("No project binding. Run `nolto init` or `nolto link <projectId>`.", 2);
1972
+ }
1973
+ return { root, projectId };
1974
+ }
1975
+ async function listLocalRoadmapSlugs(root) {
1976
+ try {
1977
+ const entries = await readdir3(path12.join(root, ".nolto", "roadmaps"));
1978
+ return entries.filter((entry) => entry.endsWith(".json")).map((entry) => entry.slice(0, -".json".length)).sort();
1979
+ } catch (err) {
1980
+ if (err != null && typeof err === "object" && "code" in err && err.code === "ENOENT") {
1981
+ return [];
1982
+ }
1983
+ throw err;
1984
+ }
1985
+ }
1986
+
1987
+ // src/commands/diff.ts
1988
+ function register6(program, deps) {
1989
+ program.command("diff [slug]").description("Compare local roadmaps with the server without changing either side.").action(async (requestedSlug) => {
1990
+ const { root, projectId } = await resolveRoadmapReadContext(deps);
1991
+ const list = await deps.http.get(
1992
+ `/api/projects/${projectId}/roadmaps`
1993
+ );
1994
+ const serverBySlug = new Map(list.roadmaps.map((roadmap) => [roadmap.slug, roadmap]));
1995
+ const localSlugs = await listLocalRoadmapSlugs(root);
1996
+ const localSlugSet = new Set(localSlugs);
1997
+ const allSlugs = [.../* @__PURE__ */ new Set([...localSlugs, ...serverBySlug.keys()])].sort();
1998
+ if (requestedSlug != null && !allSlugs.includes(requestedSlug)) {
1999
+ throw new CliError(
2000
+ `Roadmap "${requestedSlug}" was not found locally or on the server.`,
2001
+ 2
2002
+ );
2003
+ }
2004
+ const targetSlugs = requestedSlug == null ? allSlugs : [requestedSlug];
2005
+ let differing = 0;
2006
+ for (const slug of targetSlugs) {
2007
+ let local = null;
2008
+ if (localSlugSet.has(slug)) {
2009
+ const localPath = path13.join(root, ".nolto", "roadmaps", `${slug}.json`);
2010
+ try {
2011
+ local = await loadValidRoadmap(localPath, (filePath) => readFile7(filePath, "utf8"));
2012
+ } catch (err) {
2013
+ const message = err instanceof Error ? err.message : String(err);
2014
+ process.stderr.write(`Warning: could not diff ${slug}.json: ${message}
2015
+ `);
2016
+ process.stdout.write(`${slug}:
2017
+ local roadmap is present but invalid
2018
+ `);
2019
+ differing += 1;
2020
+ continue;
2021
+ }
2022
+ }
2023
+ let server = null;
2024
+ if (serverBySlug.has(slug) && local !== null) {
2025
+ const response = await deps.http.get(
2026
+ `/api/projects/${projectId}/roadmaps/${encodeURIComponent(slug)}`
2027
+ );
2028
+ const validation = validateRoadmap(response.roadmap);
2029
+ if (validation.errors.length > 0) {
2030
+ throw new CliError(
2031
+ `Server roadmap "${slug}" failed validation: ${validation.errors.join("; ")}`,
2032
+ 5
2033
+ );
2034
+ }
2035
+ server = response.roadmap;
2036
+ }
2037
+ const entries = local === null && serverBySlug.has(slug) ? [{ kind: "meta", field: "roadmap", side: "server" }] : diffRoadmaps(local, server);
2038
+ if (entries.length === 0) continue;
2039
+ differing += 1;
2040
+ process.stdout.write(formatRoadmapDiff(slug, entries).join("\n") + "\n");
2041
+ }
2042
+ if (differing === 0) {
2043
+ process.stdout.write("Up to date with the server.\n");
2044
+ process.exitCode = 0;
2045
+ } else {
2046
+ process.stdout.write(`${differing} roadmap(s) differ
2047
+ `);
2048
+ process.exitCode = 1;
2049
+ }
2050
+ });
2051
+ }
2052
+
2053
+ // src/commands/pull.ts
2054
+ import { mkdir as mkdir7, readFile as readFile8, writeFile as writeFile7 } from "fs/promises";
2055
+ import path14 from "path";
2056
+
2057
+ // src/roadmap-merge.ts
2058
+ import { isDeepStrictEqual } from "util";
2059
+ function taskLocations(roadmap) {
2060
+ const result = /* @__PURE__ */ new Map();
2061
+ for (const phase of roadmap?.phases ?? []) {
2062
+ for (const task of phase.tasks) result.set(task.id, { task, phaseId: phase.id });
2063
+ }
2064
+ return result;
2065
+ }
2066
+ function chooseField(args) {
2067
+ if (args.baseExists && isDeepStrictEqual(args.ours, args.base)) return args.theirs;
2068
+ if (args.baseExists && isDeepStrictEqual(args.theirs, args.base)) return args.ours;
2069
+ if (isDeepStrictEqual(args.ours, args.theirs)) return args.ours;
2070
+ return args.newer === "ours" ? args.ours : args.theirs;
2071
+ }
2072
+ function chooseStatus(base, ours, theirs) {
2073
+ if (base !== void 0) {
2074
+ const oursChanged = ours.status !== base.status;
2075
+ const theirsChanged = theirs.status !== base.status;
2076
+ if (!oursChanged) return theirs.status;
2077
+ if (!theirsChanged) return ours.status;
2078
+ }
2079
+ const candidates = [ours, theirs];
2080
+ if (candidates.some((task) => task.status === "done" && task.completedAt != null)) {
2081
+ return "done";
2082
+ }
2083
+ for (const status of ["blocked", "in-progress", "todo"]) {
2084
+ if (candidates.some((task) => task.status === status)) return status;
2085
+ }
2086
+ return base?.status !== "done" ? base?.status ?? "todo" : "todo";
2087
+ }
2088
+ function earliest(values) {
2089
+ return values.filter((value) => value != null).sort((a, b) => Date.parse(a) - Date.parse(b))[0];
2090
+ }
2091
+ function latest(values) {
2092
+ return values.filter((value) => value != null).sort((a, b) => Date.parse(b) - Date.parse(a))[0];
2093
+ }
2094
+ function assignOptional(target, key, value) {
2095
+ if (value !== void 0) Object.assign(target, { [key]: value });
2096
+ }
2097
+ function cloneSingleTask(task) {
2098
+ const cloned = { ...task };
2099
+ if (task.dependsOn !== void 0) cloned.dependsOn = [...task.dependsOn];
2100
+ if (cloned.status !== "done") delete cloned.completedAt;
2101
+ return cloned;
2102
+ }
2103
+ function mergeTask(args) {
2104
+ const baseExists = args.base !== void 0;
2105
+ const status = chooseStatus(args.base, args.ours, args.theirs);
2106
+ const merged = {
2107
+ id: args.ours.id,
2108
+ title: chooseField({
2109
+ baseExists,
2110
+ base: args.base?.title,
2111
+ ours: args.ours.title,
2112
+ theirs: args.theirs.title,
2113
+ newer: args.newer
2114
+ }),
2115
+ status
2116
+ };
2117
+ assignOptional(
2118
+ merged,
2119
+ "startedAt",
2120
+ earliest([args.base?.startedAt, args.ours.startedAt, args.theirs.startedAt])
2121
+ );
2122
+ if (status === "done") {
2123
+ assignOptional(
2124
+ merged,
2125
+ "completedAt",
2126
+ latest([args.base?.completedAt, args.ours.completedAt, args.theirs.completedAt])
2127
+ );
2128
+ }
2129
+ assignOptional(merged, "note", chooseField({
2130
+ baseExists,
2131
+ base: args.base?.note,
2132
+ ours: args.ours.note,
2133
+ theirs: args.theirs.note,
2134
+ newer: args.newer
2135
+ }));
2136
+ assignOptional(merged, "plan", chooseField({
2137
+ baseExists,
2138
+ base: args.base?.plan,
2139
+ ours: args.ours.plan,
2140
+ theirs: args.theirs.plan,
2141
+ newer: args.newer
2142
+ }));
2143
+ const dependsOn = [
2144
+ ...args.ours.dependsOn ?? [],
2145
+ ...args.theirs.dependsOn ?? []
2146
+ ].filter((id, index, values) => values.indexOf(id) === index);
2147
+ if (dependsOn.length > 0) merged.dependsOn = dependsOn;
2148
+ return merged;
2149
+ }
2150
+ function shouldKeep(base, ours, theirs) {
2151
+ if (base === void 0) return ours !== void 0 || theirs !== void 0;
2152
+ if (ours === void 0 && theirs === void 0) return false;
2153
+ if (ours === void 0) return !isDeepStrictEqual(theirs, base);
2154
+ if (theirs === void 0) return !isDeepStrictEqual(ours, base);
2155
+ return true;
2156
+ }
2157
+ function orderTasks(args) {
2158
+ const oursPhase = args.ours.phases.find((phase) => phase.id === args.phaseId);
2159
+ const theirsPhase = args.theirs.phases.find((phase) => phase.id === args.phaseId);
2160
+ const ordered = (oursPhase?.tasks ?? []).map((task) => task.id).filter((id) => args.taskIds.has(id));
2161
+ let nearestCommon = null;
2162
+ let insertAfter = null;
2163
+ for (const task of theirsPhase?.tasks ?? []) {
2164
+ if (!args.taskIds.has(task.id)) continue;
2165
+ if (args.oursLocations.has(task.id)) {
2166
+ if (ordered.includes(task.id)) {
2167
+ nearestCommon = task.id;
2168
+ insertAfter = task.id;
2169
+ }
2170
+ continue;
2171
+ }
2172
+ if (nearestCommon === null || insertAfter === null) {
2173
+ ordered.push(task.id);
2174
+ continue;
2175
+ }
2176
+ const index = ordered.indexOf(insertAfter);
2177
+ ordered.splice(index + 1, 0, task.id);
2178
+ insertAfter = task.id;
2179
+ }
2180
+ return ordered;
2181
+ }
2182
+ function mergeRoadmaps(args) {
2183
+ const newer = Date.parse(args.theirs.updatedAt) > Date.parse(args.ours.updatedAt) ? "theirs" : "ours";
2184
+ const newerRoadmap = newer === "ours" ? args.ours : args.theirs;
2185
+ const olderRoadmap = newer === "ours" ? args.theirs : args.ours;
2186
+ const basePhases = new Map((args.base?.phases ?? []).map((phase) => [phase.id, phase]));
2187
+ const oursPhases = new Map(args.ours.phases.map((phase) => [phase.id, phase]));
2188
+ const theirsPhases = new Map(args.theirs.phases.map((phase) => [phase.id, phase]));
2189
+ const baseLocations = taskLocations(args.base);
2190
+ const oursLocations = taskLocations(args.ours);
2191
+ const theirsLocations = taskLocations(args.theirs);
2192
+ const keptTasks = /* @__PURE__ */ new Map();
2193
+ const targetPhaseByTask = /* @__PURE__ */ new Map();
2194
+ const taskIds = /* @__PURE__ */ new Set([
2195
+ ...baseLocations.keys(),
2196
+ ...oursLocations.keys(),
2197
+ ...theirsLocations.keys()
2198
+ ]);
2199
+ for (const taskId of taskIds) {
2200
+ const baseLocation = baseLocations.get(taskId);
2201
+ const oursLocation = oursLocations.get(taskId);
2202
+ const theirsLocation = theirsLocations.get(taskId);
2203
+ if (!shouldKeep(baseLocation, oursLocation, theirsLocation)) continue;
2204
+ const task = oursLocation !== void 0 && theirsLocation !== void 0 ? mergeTask({
2205
+ base: baseLocation?.task,
2206
+ ours: oursLocation.task,
2207
+ theirs: theirsLocation.task,
2208
+ newer
2209
+ }) : cloneSingleTask((oursLocation ?? theirsLocation).task);
2210
+ keptTasks.set(taskId, task);
2211
+ targetPhaseByTask.set(taskId, (oursLocation ?? theirsLocation).phaseId);
2212
+ }
2213
+ const keptPhaseIds = /* @__PURE__ */ new Set();
2214
+ const allPhaseIds = /* @__PURE__ */ new Set([
2215
+ ...basePhases.keys(),
2216
+ ...oursPhases.keys(),
2217
+ ...theirsPhases.keys()
2218
+ ]);
2219
+ for (const phaseId of allPhaseIds) {
2220
+ if (shouldKeep(basePhases.get(phaseId), oursPhases.get(phaseId), theirsPhases.get(phaseId))) {
2221
+ keptPhaseIds.add(phaseId);
2222
+ }
2223
+ }
2224
+ const phaseOrder = [
2225
+ ...args.ours.phases.map((phase) => phase.id).filter((id) => keptPhaseIds.has(id)),
2226
+ ...args.theirs.phases.map((phase) => phase.id).filter((id) => keptPhaseIds.has(id) && !oursPhases.has(id))
2227
+ ];
2228
+ const phases = phaseOrder.map((phaseId) => {
2229
+ const base = basePhases.get(phaseId);
2230
+ const ours = oursPhases.get(phaseId);
2231
+ const theirs = theirsPhases.get(phaseId);
2232
+ const source = ours ?? theirs;
2233
+ const title = ours !== void 0 && theirs !== void 0 ? chooseField({
2234
+ baseExists: base !== void 0,
2235
+ base: base?.title,
2236
+ ours: ours.title,
2237
+ theirs: theirs.title,
2238
+ newer
2239
+ }) : source.title;
2240
+ const plan = ours !== void 0 && theirs !== void 0 ? chooseField({
2241
+ baseExists: base !== void 0,
2242
+ base: base?.plan,
2243
+ ours: ours.plan,
2244
+ theirs: theirs.plan,
2245
+ newer
2246
+ }) : source.plan;
2247
+ const phaseTaskIds = new Set(
2248
+ [...keptTasks.keys()].filter((taskId) => targetPhaseByTask.get(taskId) === phaseId)
2249
+ );
2250
+ const tasks = orderTasks({
2251
+ phaseId,
2252
+ taskIds: phaseTaskIds,
2253
+ ours: args.ours,
2254
+ theirs: args.theirs,
2255
+ oursLocations
2256
+ }).map((taskId) => keptTasks.get(taskId));
2257
+ const phase = { id: phaseId, title, status: "todo", tasks };
2258
+ if (plan !== void 0) phase.plan = plan;
2259
+ phase.status = derivePhaseStatus(phase);
2260
+ return phase;
2261
+ });
2262
+ const mergedTasks = new Map(
2263
+ phases.flatMap((phase) => phase.tasks.map((task) => [task.id, task]))
2264
+ );
2265
+ const validCurrentTask = (roadmap) => {
2266
+ const id = roadmap.currentTaskId ?? null;
2267
+ return id != null && mergedTasks.get(id)?.status === "in-progress" ? id : null;
2268
+ };
2269
+ const merged = {
2270
+ schemaVersion: 2,
2271
+ project: { ...newerRoadmap.project },
2272
+ updatedAt: newerRoadmap.updatedAt,
2273
+ currentTaskId: validCurrentTask(newerRoadmap) ?? validCurrentTask(olderRoadmap),
2274
+ summary: newerRoadmap.summary,
2275
+ phases
2276
+ };
2277
+ const validation = validateRoadmap(merged);
2278
+ if (validation.errors.length > 0) {
2279
+ throw new CliError(
2280
+ `Merged roadmap failed validation:
2281
+ ${validation.errors.join("\n ")}`,
2282
+ 1
2283
+ );
2284
+ }
2285
+ return merged;
2286
+ }
2287
+
2288
+ // src/commands/pull.ts
2289
+ async function readExisting(filePath) {
2290
+ try {
2291
+ return await readFile8(filePath, "utf8");
2292
+ } catch (err) {
2293
+ if (err != null && typeof err === "object" && "code" in err && err.code === "ENOENT") {
2294
+ return null;
2295
+ }
2296
+ throw err;
2297
+ }
2298
+ }
2299
+ function register7(program, deps) {
2300
+ program.command("pull [slug]").description(
2301
+ "Pull server roadmaps, optionally merging them with valid local copies."
2302
+ ).option("--merge", "Structurally merge server and local roadmap changes").action(async (requestedSlug, opts) => {
2303
+ const { root, projectId } = await resolveRoadmapReadContext(deps);
2304
+ const list = await deps.http.get(
2305
+ `/api/projects/${projectId}/roadmaps`
2306
+ );
2307
+ const serverBySlug = new Map(list.roadmaps.map((roadmap) => [roadmap.slug, roadmap]));
2308
+ if (requestedSlug != null && !serverBySlug.has(requestedSlug)) {
2309
+ throw new CliError(`Roadmap "${requestedSlug}" was not found on the server.`, 2);
2310
+ }
2311
+ const targets = requestedSlug == null ? [...serverBySlug.values()] : [serverBySlug.get(requestedSlug)];
2312
+ const roadmapsDir = path14.join(root, ".nolto", "roadmaps");
2313
+ for (const summary of targets) {
2314
+ const response = await deps.http.get(
2315
+ `/api/projects/${projectId}/roadmaps/${encodeURIComponent(summary.slug)}`
2316
+ );
2317
+ const validation = validateRoadmap(response.roadmap);
2318
+ if (validation.errors.length > 0) {
2319
+ throw new CliError(
2320
+ `Server roadmap "${summary.slug}" failed validation: ${validation.errors.join("; ")}`,
2321
+ 5
2322
+ );
2323
+ }
2324
+ const filePath = path14.join(roadmapsDir, `${summary.slug}.json`);
2325
+ const existing = await readExisting(filePath);
2326
+ let roadmap = response.roadmap;
2327
+ let outputVerb = "pulled";
2328
+ if (opts.merge === true && existing !== null) {
2329
+ let local;
2330
+ try {
2331
+ local = JSON.parse(existing);
2332
+ } catch {
2333
+ process.stderr.write(
2334
+ `Warning: skipped ${summary.slug}.json because the local roadmap contains malformed JSON.
2335
+ `
2336
+ );
2337
+ continue;
2338
+ }
2339
+ const localValidation = validateRoadmap(local);
2340
+ if (localValidation.errors.length > 0) {
2341
+ process.stderr.write(
2342
+ `Warning: skipped ${summary.slug}.json because the local roadmap is invalid: ${localValidation.errors.join("; ")}
2343
+ `
2344
+ );
2345
+ continue;
2346
+ }
2347
+ roadmap = mergeRoadmaps({
2348
+ base: null,
2349
+ ours: local,
2350
+ theirs: response.roadmap
2351
+ });
2352
+ outputVerb = "merged";
2353
+ }
2354
+ const serialized = JSON.stringify(roadmap, null, 2) + "\n";
2355
+ if (existing === serialized) {
2356
+ process.stdout.write(`unchanged ${summary.slug}.json
2357
+ `);
2358
+ continue;
2359
+ }
2360
+ await mkdir7(roadmapsDir, { recursive: true });
2361
+ await writeFile7(filePath, serialized, "utf8");
2362
+ process.stdout.write(
2363
+ outputVerb === "merged" ? `merged ${summary.slug}.json
2364
+ ` : `pulled ${summary.slug}.json (${summary.taskDone}/${summary.taskTotal} done)
2365
+ `
2366
+ );
2367
+ }
2368
+ process.stdout.write("Review with git diff before committing.\n");
2369
+ });
2370
+ }
2371
+
1803
2372
  // src/commands/watch.ts
1804
- 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";
2373
+ import { copyFile as copyFile2, mkdir as mkdir8, readFile as readFile9, readdir as readdir4, rename as rename3, rmdir as rmdir2, unlink as unlink3 } from "fs/promises";
1805
2374
  import { existsSync as existsSync4 } from "fs";
1806
- import path12 from "path";
2375
+ import path16 from "path";
1807
2376
  import chokidar from "chokidar";
1808
2377
 
1809
2378
  // src/watch-core.ts
@@ -1909,7 +2478,7 @@ var RepoWatch = class {
1909
2478
  };
1910
2479
 
1911
2480
  // src/service-install.ts
1912
- import path11 from "path";
2481
+ import path15 from "path";
1913
2482
  import os4 from "os";
1914
2483
  function buildUnitFile(args) {
1915
2484
  return [
@@ -1929,8 +2498,8 @@ function buildUnitFile(args) {
1929
2498
  }
1930
2499
  function getUnitPath(env) {
1931
2500
  const xdg = env["XDG_CONFIG_HOME"];
1932
- const base = xdg != null && xdg.length > 0 ? xdg : path11.join(os4.homedir(), ".config");
1933
- return path11.join(base, "systemd", "user", "nolto-watch.service");
2501
+ const base = xdg != null && xdg.length > 0 ? xdg : path15.join(os4.homedir(), ".config");
2502
+ return path15.join(base, "systemd", "user", "nolto-watch.service");
1934
2503
  }
1935
2504
  async function installServiceWith(deps) {
1936
2505
  if (deps.platform !== "linux") {
@@ -1941,7 +2510,7 @@ async function installServiceWith(deps) {
1941
2510
  );
1942
2511
  }
1943
2512
  const unitPath = getUnitPath(deps.env);
1944
- await deps.mkdir(path11.dirname(unitPath));
2513
+ await deps.mkdir(path15.dirname(unitPath));
1945
2514
  await deps.writeFile(unitPath, buildUnitFile({ nodePath: deps.nodePath, scriptPath: deps.scriptPath }));
1946
2515
  deps.log(`Wrote ${unitPath}`);
1947
2516
  const reload = await deps.exec(["systemctl", "--user", "daemon-reload"]);
@@ -1955,18 +2524,18 @@ async function installServiceWith(deps) {
1955
2524
  deps.log("Service nolto-watch enabled and started. Logs: journalctl --user -u nolto-watch -f");
1956
2525
  }
1957
2526
  async function installService() {
1958
- const { writeFile: writeFile7, mkdir: mkdir9 } = await import("fs/promises");
1959
- const { execFile: execFile3 } = await import("child_process");
1960
- const { promisify: promisify2 } = await import("util");
1961
- const execFileAsync = promisify2(execFile3);
2527
+ const { writeFile: writeFile10, mkdir: mkdir10 } = await import("fs/promises");
2528
+ const { execFile: execFile4 } = await import("child_process");
2529
+ const { promisify: promisify3 } = await import("util");
2530
+ const execFileAsync = promisify3(execFile4);
1962
2531
  await installServiceWith({
1963
2532
  platform: process.platform,
1964
2533
  env: process.env,
1965
2534
  nodePath: process.execPath,
1966
- scriptPath: path11.resolve(process.argv[1] ?? ""),
1967
- writeFile: (p, content) => writeFile7(p, content, "utf8"),
2535
+ scriptPath: path15.resolve(process.argv[1] ?? ""),
2536
+ writeFile: (p, content) => writeFile10(p, content, "utf8"),
1968
2537
  mkdir: async (p) => {
1969
- await mkdir9(p, { recursive: true });
2538
+ await mkdir10(p, { recursive: true });
1970
2539
  },
1971
2540
  exec: async (cmd) => {
1972
2541
  try {
@@ -2021,9 +2590,9 @@ async function uninstallServiceWith(deps) {
2021
2590
  }
2022
2591
  async function uninstallService() {
2023
2592
  const { unlink: unlink4 } = await import("fs/promises");
2024
- const { execFile: execFile3 } = await import("child_process");
2025
- const { promisify: promisify2 } = await import("util");
2026
- const execFileAsync = promisify2(execFile3);
2593
+ const { execFile: execFile4 } = await import("child_process");
2594
+ const { promisify: promisify3 } = await import("util");
2595
+ const execFileAsync = promisify3(execFile4);
2027
2596
  await uninstallServiceWith({
2028
2597
  platform: process.platform,
2029
2598
  env: process.env,
@@ -2043,7 +2612,7 @@ async function uninstallService() {
2043
2612
  }
2044
2613
 
2045
2614
  // src/commands/watch.ts
2046
- function register6(program, deps) {
2615
+ function register8(program, deps) {
2047
2616
  program.command("watch").description("Watch every registered repository's roadmap + plan files and sync on change.").option("--debounce <ms>", "Debounce window in milliseconds", "2000").option("--install-service", "Install and enable a systemd user unit (nolto-watch) instead of watching").option("--uninstall-service", "Stop and remove the systemd user unit (nolto-watch) instead of watching").action(async (opts) => {
2048
2617
  if (opts.installService && opts.uninstallService) {
2049
2618
  throw new CliError("--install-service and --uninstall-service cannot be used together.", 2);
@@ -2080,10 +2649,10 @@ function register6(program, deps) {
2080
2649
  token: deps.settings.token
2081
2650
  });
2082
2651
  const startRepo = (root) => {
2083
- const roadmapsPath = path12.join(root, ".nolto", "roadmaps");
2084
- const legacyRoadmapPath = path12.join(root, ".roadmap", "roadmap.json");
2652
+ const roadmapsPath = path16.join(root, ".nolto", "roadmaps");
2653
+ const legacyRoadmapPath = path16.join(root, ".roadmap", "roadmap.json");
2085
2654
  const warn = (line) => {
2086
- process.stderr.write(`Warning: [${path12.basename(root)}] ${line}
2655
+ process.stderr.write(`Warning: [${path16.basename(root)}] ${line}
2087
2656
  `);
2088
2657
  };
2089
2658
  const watcher = chokidar.watch([roadmapsPath, legacyRoadmapPath], { ignoreInitial: true });
@@ -2092,17 +2661,17 @@ function register6(program, deps) {
2092
2661
  // #316: watch must warn about legacy roadmaps without migrating them.
2093
2662
  { root, defaultProjectId: deps.settings.defaultProjectId, migrateLegacy: false },
2094
2663
  {
2095
- readFile: (p) => readFile6(p, "utf8"),
2664
+ readFile: (p) => readFile9(p, "utf8"),
2096
2665
  fileExists: (p) => existsSync4(p),
2097
- listDir: (p) => readdir3(p),
2666
+ listDir: (p) => readdir4(p),
2098
2667
  rename: rename3,
2099
2668
  copyFile: copyFile2,
2100
- mkdir: (p) => mkdir7(p, { recursive: true }).then(() => void 0),
2669
+ mkdir: (p) => mkdir8(p, { recursive: true }).then(() => void 0),
2101
2670
  unlink: unlink3,
2102
2671
  rmdir: rmdir2,
2103
2672
  repoIdentity: makeRepoIdentityResolver(deps),
2104
2673
  http,
2105
- log: (line) => process.stdout.write(`[${path12.basename(root)}] ${line}
2674
+ log: (line) => process.stdout.write(`[${path16.basename(root)}] ${line}
2106
2675
  `),
2107
2676
  warn
2108
2677
  }
@@ -2165,25 +2734,25 @@ function register6(program, deps) {
2165
2734
  }
2166
2735
 
2167
2736
  // src/update-cli.ts
2168
- import { execFile as execFile2 } from "child_process";
2737
+ import { execFile as execFile3 } from "child_process";
2169
2738
  import { existsSync as existsSync5 } from "fs";
2170
2739
  import { realpath } from "fs/promises";
2171
2740
  import { createRequire as createRequire2 } from "module";
2172
- import path14 from "path";
2741
+ import path18 from "path";
2173
2742
  import { fileURLToPath as fileURLToPath3 } from "url";
2174
- import { promisify } from "util";
2743
+ import { promisify as promisify2 } from "util";
2175
2744
 
2176
2745
  // src/update-notifier.ts
2177
- import { readFile as readFile7, writeFile as writeFile6, mkdir as mkdir8 } from "fs/promises";
2746
+ import { readFile as readFile10, writeFile as writeFile8, mkdir as mkdir9 } from "fs/promises";
2178
2747
  import https from "https";
2179
- import path13 from "path";
2748
+ import path17 from "path";
2180
2749
  var PACKAGE = "@nolto/cli";
2181
2750
  var CACHE_FILE = "update-check.json";
2182
2751
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
2183
2752
  var REQUEST_TIMEOUT_MS = 2e3;
2184
- function isNewerVersion(latest, current) {
2753
+ function isNewerVersion(latest2, current) {
2185
2754
  const parts = (v) => v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
2186
- const a = parts(latest);
2755
+ const a = parts(latest2);
2187
2756
  const b = parts(current);
2188
2757
  for (let i = 0; i < 3; i++) {
2189
2758
  const x = a[i] ?? 0;
@@ -2192,9 +2761,9 @@ function isNewerVersion(latest, current) {
2192
2761
  }
2193
2762
  return false;
2194
2763
  }
2195
- function formatUpdateNotice(latest, current) {
2764
+ function formatUpdateNotice(latest2, current) {
2196
2765
  return `
2197
- Update available: ${current} \u2192 ${latest} \xB7 run \`nolto update\`
2766
+ Update available: ${current} \u2192 ${latest2} \xB7 run \`nolto update\`
2198
2767
  `;
2199
2768
  }
2200
2769
  function isDisabled(env) {
@@ -2233,22 +2802,22 @@ function fetchLatestFromRegistry(timeoutMs = REQUEST_TIMEOUT_MS, opts = {}) {
2233
2802
  req.on("error", () => resolve(null));
2234
2803
  });
2235
2804
  }
2236
- async function writeUpdateCache(cachePath, now, latest) {
2237
- await mkdir8(path13.dirname(cachePath), { recursive: true });
2238
- const payload = { checkedAt: now, latest };
2239
- await writeFile6(cachePath, JSON.stringify(payload), { mode: 384 });
2805
+ async function writeUpdateCache(cachePath, now, latest2) {
2806
+ await mkdir9(path17.dirname(cachePath), { recursive: true });
2807
+ const payload = { checkedAt: now, latest: latest2 };
2808
+ await writeFile8(cachePath, JSON.stringify(payload), { mode: 384 });
2240
2809
  }
2241
2810
  async function refreshCache(cachePath, now, fetchLatest) {
2242
- const latest = await fetchLatest();
2243
- if (!latest) return;
2244
- await writeUpdateCache(cachePath, now, latest).catch(() => void 0);
2811
+ const latest2 = await fetchLatest();
2812
+ if (!latest2) return;
2813
+ await writeUpdateCache(cachePath, now, latest2).catch(() => void 0);
2245
2814
  }
2246
2815
  async function checkForUpdate(opts) {
2247
2816
  if (isDisabled(opts.env)) return null;
2248
- const cachePath = path13.join(opts.configDir, CACHE_FILE);
2817
+ const cachePath = path17.join(opts.configDir, CACHE_FILE);
2249
2818
  let cache = {};
2250
2819
  try {
2251
- cache = JSON.parse(await readFile7(cachePath, "utf8"));
2820
+ cache = JSON.parse(await readFile10(cachePath, "utf8"));
2252
2821
  } catch {
2253
2822
  }
2254
2823
  if (typeof cache.checkedAt !== "number" || opts.now - cache.checkedAt > CACHE_TTL_MS) {
@@ -2264,13 +2833,13 @@ async function checkForUpdate(opts) {
2264
2833
  async function notifyUpdate(opts) {
2265
2834
  try {
2266
2835
  if (opts.isJson || !process.stderr.isTTY) return;
2267
- const latest = await checkForUpdate({
2836
+ const latest2 = await checkForUpdate({
2268
2837
  current: opts.current,
2269
2838
  configDir: getConfigDir(opts.env),
2270
2839
  env: opts.env,
2271
2840
  now: opts.now
2272
2841
  });
2273
- if (latest) process.stderr.write(formatUpdateNotice(latest, opts.current));
2842
+ if (latest2) process.stderr.write(formatUpdateNotice(latest2, opts.current));
2274
2843
  } catch {
2275
2844
  }
2276
2845
  }
@@ -2321,14 +2890,14 @@ async function updateCliWith(deps) {
2321
2890
  if (!isInsideGlobalPackage(deps.scriptPath, globalRoot, deps.platform)) {
2322
2891
  throw notGlobalError(deps.scriptPath);
2323
2892
  }
2324
- const latest = await deps.fetchLatest();
2325
- if (latest == null) {
2893
+ const latest2 = await deps.fetchLatest();
2894
+ if (latest2 == null) {
2326
2895
  throw new CliError(
2327
2896
  "Could not reach the npm registry to check for the latest @nolto/cli version.",
2328
2897
  5
2329
2898
  );
2330
2899
  }
2331
- if (!isNewerVersion(latest, deps.currentVersion)) {
2900
+ if (!isNewerVersion(latest2, deps.currentVersion)) {
2332
2901
  deps.log(`Already up to date (${PACKAGE2} ${deps.currentVersion}).`);
2333
2902
  return {
2334
2903
  status: "up-to-date",
@@ -2341,7 +2910,7 @@ async function updateCliWith(deps) {
2341
2910
  "npm",
2342
2911
  "install",
2343
2912
  "-g",
2344
- `${PACKAGE2}@${latest}`
2913
+ `${PACKAGE2}@${latest2}`
2345
2914
  ]);
2346
2915
  if (installResult.code !== 0) {
2347
2916
  const detail = stderrTail(installResult.stderr);
@@ -2351,11 +2920,11 @@ async function updateCliWith(deps) {
2351
2920
  /EACCES|permission denied/i.test(installResult.stderr) ? "Permission denied \u2014 check your npm global prefix (npm config get prefix) or re-run with elevated permissions." : void 0
2352
2921
  );
2353
2922
  }
2354
- deps.log(`Updated ${PACKAGE2} ${deps.currentVersion} \u2192 ${latest}.`);
2923
+ deps.log(`Updated ${PACKAGE2} ${deps.currentVersion} \u2192 ${latest2}.`);
2355
2924
  await deps.writeCache(
2356
- path14.join(deps.configDir, UPDATE_CACHE_FILE),
2925
+ path18.join(deps.configDir, UPDATE_CACHE_FILE),
2357
2926
  deps.now,
2358
- latest
2927
+ latest2
2359
2928
  ).catch(() => void 0);
2360
2929
  let watchService = "not-installed";
2361
2930
  const unitPath = getUnitPath(deps.env);
@@ -2379,22 +2948,22 @@ async function updateCliWith(deps) {
2379
2948
  return {
2380
2949
  status: "updated",
2381
2950
  from: deps.currentVersion,
2382
- to: latest,
2951
+ to: latest2,
2383
2952
  watchService
2384
2953
  };
2385
2954
  }
2386
2955
  function getCurrentVersion() {
2387
- const dirname = path14.dirname(fileURLToPath3(import.meta.url));
2956
+ const dirname = path18.dirname(fileURLToPath3(import.meta.url));
2388
2957
  const require3 = createRequire2(import.meta.url);
2389
2958
  try {
2390
- const pkg = require3(path14.resolve(dirname, "../package.json"));
2959
+ const pkg = require3(path18.resolve(dirname, "../package.json"));
2391
2960
  return pkg.version ?? "0.0.0";
2392
2961
  } catch {
2393
2962
  return "0.0.0";
2394
2963
  }
2395
2964
  }
2396
2965
  async function updateCli(opts = {}) {
2397
- const execFileAsync = promisify(execFile2);
2966
+ const execFileAsync = promisify2(execFile3);
2398
2967
  const scriptPath = await realpath(process.argv[1] ?? "");
2399
2968
  return updateCliWith({
2400
2969
  currentVersion: getCurrentVersion(),
@@ -2426,7 +2995,7 @@ async function updateCli(opts = {}) {
2426
2995
  }
2427
2996
 
2428
2997
  // src/commands/update.ts
2429
- function register7(program, deps) {
2998
+ function register9(program, deps) {
2430
2999
  program.command("update").description("Update @nolto/cli to the latest version and restart the watch service if installed").action(async () => {
2431
3000
  const mode2 = deps.output.mode;
2432
3001
  const result = await updateCli({ quiet: mode2 === "json" });
@@ -2436,6 +3005,46 @@ function register7(program, deps) {
2436
3005
  });
2437
3006
  }
2438
3007
 
3008
+ // src/commands/merge-file.ts
3009
+ import { readFile as readFile11, writeFile as writeFile9 } from "fs/promises";
3010
+ async function readRoadmap(filePath) {
3011
+ let parsed;
3012
+ try {
3013
+ parsed = JSON.parse(await readFile11(filePath, "utf8"));
3014
+ } catch (err) {
3015
+ const message = err instanceof SyntaxError ? "malformed JSON" : String(err);
3016
+ throw new CliError(`${filePath}: ${message}`, 1);
3017
+ }
3018
+ const validation = validateRoadmap(parsed);
3019
+ if (validation.errors.length > 0) {
3020
+ throw new CliError(
3021
+ `${filePath} failed validation:
3022
+ ${validation.errors.join("\n ")}`,
3023
+ 1
3024
+ );
3025
+ }
3026
+ return parsed;
3027
+ }
3028
+ function register10(program) {
3029
+ program.command("merge-file <ours> <theirs>").description(
3030
+ 'Structurally merge roadmap files for Git.\ngit config merge.nolto-roadmap.driver "nolto merge-file %A %B --base %O"'
3031
+ ).option("--base <path>", "Common ancestor roadmap file").option("--output <path>", "Write the result here (defaults to <ours>)").action(async (oursPath, theirsPath, opts) => {
3032
+ const ours = await readRoadmap(oursPath);
3033
+ const theirs = await readRoadmap(theirsPath);
3034
+ const base = opts.base == null ? null : await readRoadmap(opts.base);
3035
+ const merged = mergeRoadmaps({ base, ours, theirs });
3036
+ await writeFile9(
3037
+ opts.output ?? oursPath,
3038
+ JSON.stringify(merged, null, 2) + "\n",
3039
+ "utf8"
3040
+ );
3041
+ const taskCount = merged.phases.reduce((total, phase) => total + phase.tasks.length, 0);
3042
+ process.stderr.write(`merged roadmap (${taskCount} tasks)
3043
+ `);
3044
+ process.exitCode = 0;
3045
+ });
3046
+ }
3047
+
2439
3048
  // src/program.ts
2440
3049
  function stripCommanderErrorPrefix(msg) {
2441
3050
  return msg.startsWith("error: ") ? msg.slice("error: ".length) : msg;
@@ -2451,9 +3060,12 @@ function buildProgram(deps) {
2451
3060
  register5(program, deps);
2452
3061
  register6(program, deps);
2453
3062
  register7(program, deps);
3063
+ register8(program, deps);
3064
+ register9(program, deps);
3065
+ register10(program);
2454
3066
  program.hook("preAction", (_thisCommand, actionCommand) => {
2455
3067
  const bindingError = deps.repoBinding?.error;
2456
- const bindingExemptCommands = ["init", "link", "update"];
3068
+ const bindingExemptCommands = ["init", "link", "update", "merge-file"];
2457
3069
  if (bindingError != null && !bindingExemptCommands.includes(actionCommand.name())) {
2458
3070
  throw bindingError;
2459
3071
  }
@@ -2462,11 +3074,11 @@ function buildProgram(deps) {
2462
3074
  }
2463
3075
 
2464
3076
  // src/index.ts
2465
- var __dirname3 = path15.dirname(fileURLToPath4(import.meta.url));
3077
+ var __dirname3 = path19.dirname(fileURLToPath4(import.meta.url));
2466
3078
  var require2 = createRequire3(import.meta.url);
2467
3079
  function getVersion() {
2468
3080
  try {
2469
- const pkgPath = path15.resolve(__dirname3, "../package.json");
3081
+ const pkgPath = path19.resolve(__dirname3, "../package.json");
2470
3082
  const pkg = require2(pkgPath);
2471
3083
  return pkg.version ?? "0.0.0";
2472
3084
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nolto/cli",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "CLI for syncing repository roadmaps with Nolto.",
5
5
  "license": "MIT",
6
6
  "type": "module",