@nolto/cli 0.8.1 → 0.10.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 +39 -4
  2. package/dist/index.js +804 -118
  3. package/package.json +1 -1
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
@@ -56,7 +56,7 @@ function mapHttpStatusToCliError(status, opts = {}) {
56
56
  return new CliError(
57
57
  opts.serverMessage ?? "This project is bound to a different repository.",
58
58
  2,
59
- "Owner: run `nolto link --rebind` inside the repository that should own this project.",
59
+ "Check that nolto.json points to the project for this repository (nolto link --show). Owner: run `nolto link --rebind` inside the repository that should own this project.",
60
60
  status
61
61
  );
62
62
  }
@@ -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}`
@@ -374,14 +374,15 @@ function createHttpClient(opts) {
374
374
  import { Command } from "commander";
375
375
 
376
376
  // src/commands/init.ts
377
- import readline from "readline/promises";
377
+ import readline2 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
385
+ import readline from "readline/promises";
385
386
  import os2 from "os";
386
387
  import path5 from "path";
387
388
  import { statSync as statSync2 } from "fs";
@@ -519,12 +520,12 @@ function normalizeRemote(raw) {
519
520
  const firstSlash = s.indexOf("/");
520
521
  if (firstSlash <= 0) return null;
521
522
  let host = s.slice(0, firstSlash).toLowerCase();
522
- let path16 = s.slice(firstSlash + 1);
523
+ let path20 = s.slice(firstSlash + 1);
523
524
  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}`;
525
+ path20 = path20.replace(/\/+/g, "/").replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "").replace(/\/+$/, "");
526
+ if (path20.length === 0) return null;
527
+ if (HOSTED_LOWERCASE_PATH.has(host)) path20 = path20.toLowerCase();
528
+ return `${host}/${path20}`;
528
529
  }
529
530
 
530
531
  // ../roadmap-schema/src/index.ts
@@ -819,14 +820,51 @@ async function handleShow(deps, projectBindingPath, mode2) {
819
820
  }
820
821
  }
821
822
  }
822
- async function handleRebind(deps, projectId, root, mode2) {
823
+ async function handleRebind(deps, projectId, root, mode2, yes) {
823
824
  if (!UUID_RE.test(projectId)) {
824
825
  throw new CliError(
825
826
  `Invalid project ID: "${projectId}". Must be a UUID (e.g. 00000000-0000-0000-0000-000000000001).`,
826
827
  2
827
828
  );
828
829
  }
830
+ const result = await deps.http.get("/api/projects");
831
+ const projects = Array.isArray(result.projects) ? result.projects : [];
832
+ const project = projects.find((candidate) => candidate.id === projectId);
833
+ if (project == null) {
834
+ throw new CliError(
835
+ `Project ${projectId} is not in your accessible projects. Check nolto.json (nolto link --show) before rebinding.`,
836
+ 2
837
+ );
838
+ }
829
839
  const repoIdentity = await makeRepoIdentityResolver(deps)(root);
840
+ if (mode2 !== "json") {
841
+ process.stdout.write(`Project : ${project.name} (${project.id})
842
+ `);
843
+ process.stdout.write(`Bound to : ${project.repoIdentity ?? "not bound"}
844
+ `);
845
+ process.stdout.write(`Rebind to : ${repoIdentity.kind}:${repoIdentity.value}
846
+ `);
847
+ }
848
+ if (!yes) {
849
+ if (mode2 === "json") {
850
+ throw new CliError(
851
+ "--rebind requires confirmation. Pass --yes to skip the prompt in --json mode.",
852
+ 2
853
+ );
854
+ }
855
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
856
+ let confirmed = false;
857
+ try {
858
+ const answer = await rl.question(`Rebind "${project.name}" to this repository? [y/N] `);
859
+ confirmed = answer.trim().toLowerCase() === "y";
860
+ } finally {
861
+ rl.close();
862
+ }
863
+ if (!confirmed) {
864
+ process.stdout.write("Aborted.\n");
865
+ return;
866
+ }
867
+ }
830
868
  await deps.http.post(`/api/projects/${projectId}/repo-binding`, { repoIdentity });
831
869
  if (mode2 === "json") {
832
870
  printResult({ rebound: true, projectId, repoIdentity }, mode2);
@@ -836,10 +874,10 @@ async function handleRebind(deps, projectId, root, mode2) {
836
874
  }
837
875
  }
838
876
  async function handleUnlink(projectBindingPath, mode2) {
839
- const { readFile: readFile8, writeFile: writeFile7, chmod: chmod2 } = await import("fs/promises");
877
+ const { readFile: readFile12, writeFile: writeFile10, chmod: chmod2 } = await import("fs/promises");
840
878
  let existing = {};
841
879
  try {
842
- const raw = await readFile8(projectBindingPath, "utf8");
880
+ const raw = await readFile12(projectBindingPath, "utf8");
843
881
  const parsed = JSON.parse(raw);
844
882
  if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
845
883
  throw new CliError(
@@ -854,7 +892,7 @@ async function handleUnlink(projectBindingPath, mode2) {
854
892
  }
855
893
  const { projectId: _removed, ...rest } = existing;
856
894
  void _removed;
857
- await writeFile7(projectBindingPath, JSON.stringify(rest, null, 2) + "\n", { mode: 420 });
895
+ await writeFile10(projectBindingPath, JSON.stringify(rest, null, 2) + "\n", { mode: 420 });
858
896
  await chmod2(projectBindingPath, 420);
859
897
  if (mode2 === "json") {
860
898
  printResult({ unlinked: true, projectBindingPath }, mode2);
@@ -945,7 +983,7 @@ Commit nolto.json to share the binding with your team.
945
983
  function register(program, deps) {
946
984
  const cmd = program.command("link [projectId]").description(
947
985
  "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"
948
- ).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");
986
+ ).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("-y, --yes", "Skip the rebind confirmation prompt").option("--unlink", "Remove the projectId key from nolto.json");
949
987
  cmd.action(async (projectId) => {
950
988
  const { output } = deps;
951
989
  const projectBindingPath = deps.repoBinding?.path ?? deps.projectBindingPath ?? null;
@@ -973,7 +1011,7 @@ function register(program, deps) {
973
1011
  }
974
1012
  const startDir = resolveStartDir(process.env, process.cwd());
975
1013
  const root = findRepoRoot(startDir).root;
976
- await handleRebind(deps, effectiveProjectId, root, mode2);
1014
+ await handleRebind(deps, effectiveProjectId, root, mode2, cmd.opts()["yes"] === true);
977
1015
  return;
978
1016
  }
979
1017
  if (projectId == null || projectId.trim().length === 0) {
@@ -1103,14 +1141,55 @@ async function scaffoldRoadmap(args) {
1103
1141
  return { created: true, path: filePath };
1104
1142
  }
1105
1143
 
1144
+ // src/git-merge-driver.ts
1145
+ import { execFile as execFile2 } from "child_process";
1146
+ import { readFile as readFile5, writeFile as writeFile6 } from "fs/promises";
1147
+ import path8 from "path";
1148
+ import { promisify } from "util";
1149
+ var ATTRIBUTE_LINE = ".nolto/roadmaps/*.json merge=nolto-roadmap";
1150
+ var DRIVER_COMMAND = "nolto merge-file %A %B --base %O";
1151
+ async function ensureGitAttributes(root) {
1152
+ const filePath = path8.join(root, ".gitattributes");
1153
+ let content = "";
1154
+ try {
1155
+ content = await readFile5(filePath, "utf8");
1156
+ } catch (err) {
1157
+ if (err.code !== "ENOENT") throw err;
1158
+ }
1159
+ if (content.split(/\r?\n/).includes(ATTRIBUTE_LINE)) return "exists";
1160
+ const separator = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
1161
+ await writeFile6(filePath, content + separator + ATTRIBUTE_LINE + "\n", "utf8");
1162
+ return "added";
1163
+ }
1164
+ async function configureMergeDriver(root, exec = async (file, args, options) => {
1165
+ const execFileAsync = promisify(execFile2);
1166
+ await execFileAsync(file, args, options);
1167
+ }) {
1168
+ try {
1169
+ await exec(
1170
+ "git",
1171
+ ["config", "--local", "merge.nolto-roadmap.name", "Nolto roadmap merge"],
1172
+ { cwd: root }
1173
+ );
1174
+ await exec(
1175
+ "git",
1176
+ ["config", "--local", "merge.nolto-roadmap.driver", DRIVER_COMMAND],
1177
+ { cwd: root }
1178
+ );
1179
+ return "configured";
1180
+ } catch {
1181
+ return "skipped";
1182
+ }
1183
+ }
1184
+
1106
1185
  // src/commands/init.ts
1107
- var __dirname2 = path8.dirname(fileURLToPath2(import.meta.url));
1186
+ var __dirname2 = path9.dirname(fileURLToPath2(import.meta.url));
1108
1187
  var _require = createRequire(import.meta.url);
1109
1188
  function getCliVersion() {
1110
1189
  const candidates = [
1111
- path8.resolve(__dirname2, "../package.json"),
1190
+ path9.resolve(__dirname2, "../package.json"),
1112
1191
  // bundled: dist/../package.json
1113
- path8.resolve(__dirname2, "../../package.json")
1192
+ path9.resolve(__dirname2, "../../package.json")
1114
1193
  // source: src/commands/../../package.json
1115
1194
  ];
1116
1195
  for (const pkgPath of candidates) {
@@ -1162,10 +1241,27 @@ async function pickProject(rl, http, projects, promptText) {
1162
1241
  }
1163
1242
  return void 0;
1164
1243
  }
1244
+ async function selectRepoProject(rl, http, projects, defaultProjectId) {
1245
+ const defaultProject = defaultProjectId == null ? void 0 : projects.find((project) => project.id === defaultProjectId);
1246
+ if (defaultProject != null) {
1247
+ const useDefault = await rl.question(
1248
+ `Use "${defaultProject.name}" (${defaultProject.id}) for this repository? [Y/n] `
1249
+ );
1250
+ if (useDefault.trim().toLowerCase() !== "n") {
1251
+ return defaultProject;
1252
+ }
1253
+ }
1254
+ return pickProject(
1255
+ rl,
1256
+ http,
1257
+ projects,
1258
+ "Project number for this repository, 'c' to create new (or Enter to skip): "
1259
+ );
1260
+ }
1165
1261
  function register2(program, deps) {
1166
1262
  program.command("init").description("Interactive setup: configure token, base URL, and default project.").option("--force", "Overwrite existing config without prompting").action(async (opts) => {
1167
1263
  const configPath = deps.configPath;
1168
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1264
+ const rl = readline2.createInterface({ input: process.stdin, output: process.stdout });
1169
1265
  try {
1170
1266
  let configureGlobal = opts.force === true;
1171
1267
  if (!configureGlobal) {
@@ -1221,6 +1317,9 @@ function register2(program, deps) {
1221
1317
  );
1222
1318
  defaultProjectId = selected?.id;
1223
1319
  defaultProjectName = selected?.name;
1320
+ if (selected != null && !projects.some((project) => project.id === selected.id)) {
1321
+ projects = [...projects, selected];
1322
+ }
1224
1323
  await saveConfigFile(configPath, {
1225
1324
  token,
1226
1325
  baseUrl: baseUrl !== DEFAULT_BASE_URL ? baseUrl : void 0,
@@ -1251,25 +1350,41 @@ Saved ${configPath}
1251
1350
  }
1252
1351
  if (isHomeDirectory(root)) {
1253
1352
  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.
1353
+ `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
1354
  `
1256
1355
  );
1257
1356
  return;
1258
1357
  }
1259
1358
  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;
1261
- if (repoProject == null) {
1262
- http ??= createHttpClient({ baseUrl, token, version: getCliVersion() });
1263
- if (projects == null) {
1264
- const result = await http.get("/api/projects");
1265
- projects = Array.isArray(result.projects) ? result.projects : [];
1359
+ const repoHttp = http ?? deps.http;
1360
+ if (projects == null) {
1361
+ const result = await repoHttp.get("/api/projects");
1362
+ projects = Array.isArray(result.projects) ? result.projects : [];
1363
+ }
1364
+ let keepExistingBinding = false;
1365
+ let repoProject;
1366
+ if (existingBinding != null) {
1367
+ const boundProject = projects.find(
1368
+ (project) => project.id === existingBinding.projectId
1369
+ );
1370
+ if (boundProject != null) {
1371
+ process.stdout.write(
1372
+ `Existing binding: nolto.json \u2192 "${boundProject.name}" (${boundProject.id})
1373
+ `
1374
+ );
1375
+ } else {
1376
+ process.stderr.write(
1377
+ `Warning: nolto.json points to project ${existingBinding.projectId}, which is not in your accessible projects.
1378
+ `
1379
+ );
1266
1380
  }
1267
- repoProject = await pickProject(
1268
- rl,
1269
- http,
1270
- projects,
1271
- "Project number for this repository, 'c' to create new (or Enter to skip): "
1381
+ const keep = await rl.question(
1382
+ boundProject != null ? "Keep this binding? [Y/n] " : "Keep this binding? [y/N] "
1272
1383
  );
1384
+ keepExistingBinding = boundProject != null ? keep.trim().toLowerCase() !== "n" : keep.trim().toLowerCase() === "y";
1385
+ repoProject = keepExistingBinding ? boundProject ?? { id: existingBinding.projectId, name: path9.basename(root) } : await selectRepoProject(rl, repoHttp, projects, defaultProjectId);
1386
+ } else {
1387
+ repoProject = await selectRepoProject(rl, repoHttp, projects, defaultProjectId);
1273
1388
  }
1274
1389
  if (repoProject == null) {
1275
1390
  process.stdout.write("Skipped repo setup (no project selected).\n");
@@ -1281,9 +1396,9 @@ Set up this repository (${root}) for roadmap sync? [Y/n] `);
1281
1396
  process.stdout.write("Skipped repo setup.\n");
1282
1397
  return;
1283
1398
  }
1284
- const bindingPath = deps.repoBinding?.path ?? path8.join(root, "nolto.json");
1285
- if (existingBinding != null) {
1286
- process.stdout.write(`binding: kept ${bindingPath} (${existingBinding.projectId})
1399
+ const bindingPath = deps.repoBinding?.path ?? path9.join(root, "nolto.json");
1400
+ if (keepExistingBinding) {
1401
+ process.stdout.write(`binding: kept ${bindingPath} (${repoProject.id})
1287
1402
  `);
1288
1403
  } else {
1289
1404
  if (deps.repoBinding?.error != null) {
@@ -1293,22 +1408,22 @@ Set up this repository (${root}) for roadmap sync? [Y/n] `);
1293
1408
  );
1294
1409
  }
1295
1410
  await writeRepoBinding(root, repoProject.id);
1296
- process.stdout.write(`binding: wrote ${path8.join(root, "nolto.json")}
1411
+ process.stdout.write(`binding: wrote ${path9.join(root, "nolto.json")}
1297
1412
  `);
1298
1413
  }
1299
1414
  const sourceDir = resolveSkillSourceDir();
1300
1415
  const version = getCliVersion();
1301
1416
  const claudeInstall = await installSkill({
1302
- skillsParentDir: path8.join(root, ".claude", "skills"),
1417
+ skillsParentDir: path9.join(root, ".claude", "skills"),
1303
1418
  sourceDir,
1304
1419
  version
1305
1420
  });
1306
1421
  process.stdout.write(`skill (claude): ${claudeInstall.action} ${claudeInstall.targetDir}
1307
1422
  `);
1308
- const usesAgentsTooling = fs.existsSync(path8.join(root, ".agents")) || fs.existsSync(path8.join(root, ".codex")) || fs.existsSync(path8.join(root, "AGENTS.md"));
1423
+ const usesAgentsTooling = fs.existsSync(path9.join(root, ".agents")) || fs.existsSync(path9.join(root, ".codex")) || fs.existsSync(path9.join(root, "AGENTS.md"));
1309
1424
  if (usesAgentsTooling) {
1310
1425
  const agentsInstall = await installSkill({
1311
- skillsParentDir: path8.join(root, ".agents", "skills"),
1426
+ skillsParentDir: path9.join(root, ".agents", "skills"),
1312
1427
  sourceDir,
1313
1428
  version
1314
1429
  });
@@ -1330,6 +1445,13 @@ Set up this repository (${root}) for roadmap sync? [Y/n] `);
1330
1445
  ` : `watch registry: already registered
1331
1446
  `
1332
1447
  );
1448
+ const attributesResult = await ensureGitAttributes(root);
1449
+ process.stdout.write(`gitattributes: ${attributesResult} .gitattributes
1450
+ `);
1451
+ const mergeDriverResult = await configureMergeDriver(root, deps.gitMergeExec);
1452
+ process.stdout.write(
1453
+ mergeDriverResult === "configured" ? "merge driver: configured\n" : "merge driver: skipped (git unavailable)\n"
1454
+ );
1333
1455
  } finally {
1334
1456
  rl.close();
1335
1457
  }
@@ -1359,7 +1481,7 @@ async function promptHidden(rl, prompt) {
1359
1481
  }
1360
1482
 
1361
1483
  // src/commands/login.ts
1362
- import readline2 from "readline/promises";
1484
+ import readline3 from "readline/promises";
1363
1485
 
1364
1486
  // src/login-poll.ts
1365
1487
  async function pollUntilToken(opts) {
@@ -1425,7 +1547,7 @@ function register3(program, deps) {
1425
1547
  } catch {
1426
1548
  }
1427
1549
  if (existing?.token) {
1428
- const rl = readline2.createInterface({
1550
+ const rl = readline3.createInterface({
1429
1551
  input: process.stdin,
1430
1552
  output: process.stdout
1431
1553
  });
@@ -1549,15 +1671,15 @@ function register4(program, deps) {
1549
1671
  }
1550
1672
 
1551
1673
  // 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";
1674
+ import { copyFile, mkdir as mkdir6, readFile as readFile6, readdir as readdir2, rename as rename2, rmdir, unlink as unlink2 } from "fs/promises";
1553
1675
  import { existsSync as existsSync3 } from "fs";
1554
1676
 
1555
1677
  // src/sync-repo.ts
1556
- import path10 from "path";
1678
+ import path11 from "path";
1557
1679
 
1558
1680
  // src/sync-core.ts
1559
1681
  import { createHash } from "crypto";
1560
- import path9 from "path";
1682
+ import path10 from "path";
1561
1683
  function sha256Hex(content) {
1562
1684
  return "sha256:" + createHash("sha256").update(content, "utf8").digest("hex");
1563
1685
  }
@@ -1578,10 +1700,10 @@ function collectPlanRefs(roadmap) {
1578
1700
  }
1579
1701
  return refs;
1580
1702
  }
1581
- async function loadValidRoadmap(filePath, readFile8) {
1703
+ async function loadValidRoadmap(filePath, readFile12) {
1582
1704
  let raw;
1583
1705
  try {
1584
- raw = await readFile8(filePath);
1706
+ raw = await readFile12(filePath);
1585
1707
  } catch {
1586
1708
  throw new CliError(`No roadmap found at ${filePath}. Run \`nolto init\` first.`, 2);
1587
1709
  }
@@ -1604,7 +1726,7 @@ async function loadValidRoadmap(filePath, readFile8) {
1604
1726
  async function buildSyncBody(args) {
1605
1727
  const planDocuments = [];
1606
1728
  for (const ref of collectPlanRefs(args.roadmap)) {
1607
- const absolute = path9.join(args.repoRoot, ref.path);
1729
+ const absolute = path10.join(args.repoRoot, ref.path);
1608
1730
  if (!args.deps.fileExists(absolute)) {
1609
1731
  args.deps.warn(`plan file not found, skipping: ${ref.path}`);
1610
1732
  continue;
@@ -1651,7 +1773,7 @@ async function listRoadmapFiles(roadmapsDir, io) {
1651
1773
  }
1652
1774
  }
1653
1775
  async function migrateLegacyRoadmap(args) {
1654
- const targetPath = path10.join(args.roadmapsDir, `${args.slug}.json`);
1776
+ const targetPath = path11.join(args.roadmapsDir, `${args.slug}.json`);
1655
1777
  await args.io.mkdir(args.roadmapsDir);
1656
1778
  try {
1657
1779
  await args.io.rename(args.legacyPath, targetPath);
@@ -1660,7 +1782,7 @@ async function migrateLegacyRoadmap(args) {
1660
1782
  await args.io.unlink(args.legacyPath);
1661
1783
  }
1662
1784
  try {
1663
- await args.io.rmdir(path10.dirname(args.legacyPath));
1785
+ await args.io.rmdir(path11.dirname(args.legacyPath));
1664
1786
  } catch {
1665
1787
  }
1666
1788
  args.io.log(
@@ -1669,14 +1791,14 @@ async function migrateLegacyRoadmap(args) {
1669
1791
  return `${args.slug}.json`;
1670
1792
  }
1671
1793
  async function syncRepo(args, io) {
1672
- const bindingPath = path10.join(args.root, "nolto.json");
1794
+ const bindingPath = path11.join(args.root, "nolto.json");
1673
1795
  const binding = await loadRepoBinding(bindingPath);
1674
1796
  const projectId = binding?.projectId ?? args.defaultProjectId;
1675
1797
  if (projectId == null) {
1676
1798
  throw new CliError("No project binding. Run `nolto init` or `nolto link <projectId>`.", 2);
1677
1799
  }
1678
- const roadmapsDir = path10.join(args.root, ".nolto", "roadmaps");
1679
- const legacyPath = path10.join(args.root, ".roadmap", "roadmap.json");
1800
+ const roadmapsDir = path11.join(args.root, ".nolto", "roadmaps");
1801
+ const legacyPath = path11.join(args.root, ".roadmap", "roadmap.json");
1680
1802
  let roadmapFiles = await listRoadmapFiles(roadmapsDir, io);
1681
1803
  if (roadmapFiles.length > 0) {
1682
1804
  if (io.fileExists(legacyPath)) {
@@ -1691,7 +1813,7 @@ async function syncRepo(args, io) {
1691
1813
  );
1692
1814
  return { results: [], planAbsPaths: [] };
1693
1815
  }
1694
- const migrationSlug = binding?.roadmapSlug ?? slugifyProjectId(path10.basename(args.root));
1816
+ const migrationSlug = binding?.roadmapSlug ?? slugifyProjectId(path11.basename(args.root));
1695
1817
  roadmapFiles = [
1696
1818
  await migrateLegacyRoadmap({
1697
1819
  slug: migrationSlug,
@@ -1715,14 +1837,14 @@ async function syncRepo(args, io) {
1715
1837
  2
1716
1838
  );
1717
1839
  }
1718
- const filePath = path10.join(roadmapsDir, fileName);
1840
+ const filePath = path11.join(roadmapsDir, fileName);
1719
1841
  return { slug, roadmap: await loadValidRoadmap(filePath, io.readFile) };
1720
1842
  })
1721
1843
  );
1722
1844
  const planAbsPaths = /* @__PURE__ */ new Set();
1723
1845
  for (const { roadmap } of roadmaps) {
1724
1846
  for (const ref of collectPlanRefs(roadmap)) {
1725
- planAbsPaths.add(path10.join(args.root, ref.path));
1847
+ planAbsPaths.add(path11.join(args.root, ref.path));
1726
1848
  }
1727
1849
  }
1728
1850
  const results = [];
@@ -1756,7 +1878,7 @@ function register5(program, deps) {
1756
1878
  const response = await syncRepo(
1757
1879
  { root, defaultProjectId: deps.settings.defaultProjectId, migrateLegacy: true },
1758
1880
  {
1759
- readFile: (p) => readFile5(p, "utf8"),
1881
+ readFile: (p) => readFile6(p, "utf8"),
1760
1882
  fileExists: (p) => existsSync3(p),
1761
1883
  listDir: (p) => readdir2(p),
1762
1884
  rename: rename2,
@@ -1800,10 +1922,531 @@ function register5(program, deps) {
1800
1922
  });
1801
1923
  }
1802
1924
 
1925
+ // src/commands/diff.ts
1926
+ import { readFile as readFile7 } from "fs/promises";
1927
+ import path13 from "path";
1928
+
1929
+ // src/roadmap-diff.ts
1930
+ function diffRoadmaps(local, server) {
1931
+ if (local === null && server === null) return [];
1932
+ if (local === null) return [{ kind: "meta", field: "roadmap", side: "server" }];
1933
+ if (server === null) return [{ kind: "meta", field: "roadmap", side: "local" }];
1934
+ const entries = [];
1935
+ const serverPhases = new Map(server.phases.map((phase) => [phase.id, phase]));
1936
+ for (const localPhase of local.phases) {
1937
+ const serverPhase = serverPhases.get(localPhase.id);
1938
+ if (!serverPhase) {
1939
+ entries.push({
1940
+ kind: "phase-removed",
1941
+ id: localPhase.id,
1942
+ title: localPhase.title,
1943
+ side: "local"
1944
+ });
1945
+ continue;
1946
+ }
1947
+ const serverTasks = new Map(serverPhase.tasks.map((task) => [task.id, task]));
1948
+ for (const localTask of localPhase.tasks) {
1949
+ const serverTask = serverTasks.get(localTask.id);
1950
+ if (!serverTask) {
1951
+ entries.push({
1952
+ kind: "task-removed",
1953
+ id: localTask.id,
1954
+ title: localTask.title,
1955
+ side: "local"
1956
+ });
1957
+ } else if (localTask.status !== serverTask.status) {
1958
+ entries.push({
1959
+ kind: "task-status",
1960
+ id: localTask.id,
1961
+ title: localTask.title,
1962
+ local: localTask.status,
1963
+ server: serverTask.status
1964
+ });
1965
+ }
1966
+ }
1967
+ const localTaskIds = new Set(localPhase.tasks.map((task) => task.id));
1968
+ for (const serverTask of serverPhase.tasks) {
1969
+ if (!localTaskIds.has(serverTask.id)) {
1970
+ entries.push({
1971
+ kind: "task-added",
1972
+ id: serverTask.id,
1973
+ title: serverTask.title,
1974
+ side: "server"
1975
+ });
1976
+ }
1977
+ }
1978
+ }
1979
+ const localPhaseIds = new Set(local.phases.map((phase) => phase.id));
1980
+ for (const serverPhase of server.phases) {
1981
+ if (!localPhaseIds.has(serverPhase.id)) {
1982
+ entries.push({
1983
+ kind: "phase-added",
1984
+ id: serverPhase.id,
1985
+ title: serverPhase.title,
1986
+ side: "server"
1987
+ });
1988
+ }
1989
+ }
1990
+ const meta = [
1991
+ { field: "updatedAt", local: local.updatedAt, server: server.updatedAt },
1992
+ {
1993
+ field: "currentTaskId",
1994
+ local: local.currentTaskId ?? null,
1995
+ server: server.currentTaskId ?? null
1996
+ },
1997
+ { field: "summary", local: local.summary, server: server.summary }
1998
+ ];
1999
+ for (const item of meta) {
2000
+ if (item.local !== item.server) entries.push({ kind: "meta", ...item });
2001
+ }
2002
+ return entries;
2003
+ }
2004
+ function truncate(value) {
2005
+ if (value == null) return "none";
2006
+ const singleLine = value.replace(/\s+/g, " ");
2007
+ return singleLine.length <= 60 ? singleLine : singleLine.slice(0, 57) + "...";
2008
+ }
2009
+ function formatRoadmapDiff(slug, entries) {
2010
+ if (entries.length === 0) return [];
2011
+ const lines = [`${slug}:`];
2012
+ for (const entry of entries) {
2013
+ if (entry.kind === "task-status") {
2014
+ lines.push(` ${entry.id}: ${entry.local} (local) != ${entry.server} (server)`);
2015
+ } else if (entry.kind === "task-added" || entry.kind === "task-removed") {
2016
+ lines.push(` ${entry.side} only task ${entry.id} "${truncate(entry.title)}"`);
2017
+ } else if (entry.kind === "phase-added" || entry.kind === "phase-removed") {
2018
+ lines.push(` ${entry.side} only phase ${entry.id} "${truncate(entry.title)}"`);
2019
+ } else if (entry.field === "roadmap") {
2020
+ lines.push(` ${entry.side} only roadmap`);
2021
+ } else {
2022
+ lines.push(
2023
+ ` ${entry.field}: ${truncate(entry.local)} (local) != ${truncate(entry.server)} (server)`
2024
+ );
2025
+ }
2026
+ }
2027
+ return lines;
2028
+ }
2029
+
2030
+ // src/roadmap-read.ts
2031
+ import { readdir as readdir3 } from "fs/promises";
2032
+ import path12 from "path";
2033
+ async function resolveRoadmapReadContext(deps) {
2034
+ if (deps.settings.token == null) {
2035
+ throw new CliError("Not authenticated. Run `nolto login` or set NOLTO_TOKEN.", 3);
2036
+ }
2037
+ const startDir = resolveStartDir(process.env, process.cwd());
2038
+ const { root, foundGit } = findRepoRoot(startDir);
2039
+ if (!foundGit) {
2040
+ throw new CliError("No git repository found. Run inside a repo set up with `nolto init`.", 2);
2041
+ }
2042
+ const binding = await loadRepoBinding(path12.join(root, "nolto.json"));
2043
+ const projectId = binding?.projectId ?? deps.settings.defaultProjectId;
2044
+ if (projectId == null) {
2045
+ throw new CliError("No project binding. Run `nolto init` or `nolto link <projectId>`.", 2);
2046
+ }
2047
+ return { root, projectId };
2048
+ }
2049
+ async function listLocalRoadmapSlugs(root) {
2050
+ try {
2051
+ const entries = await readdir3(path12.join(root, ".nolto", "roadmaps"));
2052
+ return entries.filter((entry) => entry.endsWith(".json")).map((entry) => entry.slice(0, -".json".length)).sort();
2053
+ } catch (err) {
2054
+ if (err != null && typeof err === "object" && "code" in err && err.code === "ENOENT") {
2055
+ return [];
2056
+ }
2057
+ throw err;
2058
+ }
2059
+ }
2060
+
2061
+ // src/commands/diff.ts
2062
+ function register6(program, deps) {
2063
+ program.command("diff [slug]").description("Compare local roadmaps with the server without changing either side.").action(async (requestedSlug) => {
2064
+ const { root, projectId } = await resolveRoadmapReadContext(deps);
2065
+ const list = await deps.http.get(
2066
+ `/api/projects/${projectId}/roadmaps`
2067
+ );
2068
+ const serverBySlug = new Map(list.roadmaps.map((roadmap) => [roadmap.slug, roadmap]));
2069
+ const localSlugs = await listLocalRoadmapSlugs(root);
2070
+ const localSlugSet = new Set(localSlugs);
2071
+ const allSlugs = [.../* @__PURE__ */ new Set([...localSlugs, ...serverBySlug.keys()])].sort();
2072
+ if (requestedSlug != null && !allSlugs.includes(requestedSlug)) {
2073
+ throw new CliError(
2074
+ `Roadmap "${requestedSlug}" was not found locally or on the server.`,
2075
+ 2
2076
+ );
2077
+ }
2078
+ const targetSlugs = requestedSlug == null ? allSlugs : [requestedSlug];
2079
+ let differing = 0;
2080
+ for (const slug of targetSlugs) {
2081
+ let local = null;
2082
+ if (localSlugSet.has(slug)) {
2083
+ const localPath = path13.join(root, ".nolto", "roadmaps", `${slug}.json`);
2084
+ try {
2085
+ local = await loadValidRoadmap(localPath, (filePath) => readFile7(filePath, "utf8"));
2086
+ } catch (err) {
2087
+ const message = err instanceof Error ? err.message : String(err);
2088
+ process.stderr.write(`Warning: could not diff ${slug}.json: ${message}
2089
+ `);
2090
+ process.stdout.write(`${slug}:
2091
+ local roadmap is present but invalid
2092
+ `);
2093
+ differing += 1;
2094
+ continue;
2095
+ }
2096
+ }
2097
+ let server = null;
2098
+ if (serverBySlug.has(slug) && local !== null) {
2099
+ const response = await deps.http.get(
2100
+ `/api/projects/${projectId}/roadmaps/${encodeURIComponent(slug)}`
2101
+ );
2102
+ const validation = validateRoadmap(response.roadmap);
2103
+ if (validation.errors.length > 0) {
2104
+ throw new CliError(
2105
+ `Server roadmap "${slug}" failed validation: ${validation.errors.join("; ")}`,
2106
+ 5
2107
+ );
2108
+ }
2109
+ server = response.roadmap;
2110
+ }
2111
+ const entries = local === null && serverBySlug.has(slug) ? [{ kind: "meta", field: "roadmap", side: "server" }] : diffRoadmaps(local, server);
2112
+ if (entries.length === 0) continue;
2113
+ differing += 1;
2114
+ process.stdout.write(formatRoadmapDiff(slug, entries).join("\n") + "\n");
2115
+ }
2116
+ if (differing === 0) {
2117
+ process.stdout.write("Up to date with the server.\n");
2118
+ process.exitCode = 0;
2119
+ } else {
2120
+ process.stdout.write(`${differing} roadmap(s) differ
2121
+ `);
2122
+ process.exitCode = 1;
2123
+ }
2124
+ });
2125
+ }
2126
+
2127
+ // src/commands/pull.ts
2128
+ import { mkdir as mkdir7, readFile as readFile8, writeFile as writeFile7 } from "fs/promises";
2129
+ import path14 from "path";
2130
+
2131
+ // src/roadmap-merge.ts
2132
+ import { isDeepStrictEqual } from "util";
2133
+ function taskLocations(roadmap) {
2134
+ const result = /* @__PURE__ */ new Map();
2135
+ for (const phase of roadmap?.phases ?? []) {
2136
+ for (const task of phase.tasks) result.set(task.id, { task, phaseId: phase.id });
2137
+ }
2138
+ return result;
2139
+ }
2140
+ function chooseField(args) {
2141
+ if (args.baseExists && isDeepStrictEqual(args.ours, args.base)) return args.theirs;
2142
+ if (args.baseExists && isDeepStrictEqual(args.theirs, args.base)) return args.ours;
2143
+ if (isDeepStrictEqual(args.ours, args.theirs)) return args.ours;
2144
+ return args.newer === "ours" ? args.ours : args.theirs;
2145
+ }
2146
+ function chooseStatus(base, ours, theirs) {
2147
+ if (base !== void 0) {
2148
+ const oursChanged = ours.status !== base.status;
2149
+ const theirsChanged = theirs.status !== base.status;
2150
+ if (!oursChanged) return theirs.status;
2151
+ if (!theirsChanged) return ours.status;
2152
+ }
2153
+ const candidates = [ours, theirs];
2154
+ if (candidates.some((task) => task.status === "done" && task.completedAt != null)) {
2155
+ return "done";
2156
+ }
2157
+ for (const status of ["blocked", "in-progress", "todo"]) {
2158
+ if (candidates.some((task) => task.status === status)) return status;
2159
+ }
2160
+ return base?.status !== "done" ? base?.status ?? "todo" : "todo";
2161
+ }
2162
+ function earliest(values) {
2163
+ return values.filter((value) => value != null).sort((a, b) => Date.parse(a) - Date.parse(b))[0];
2164
+ }
2165
+ function latest(values) {
2166
+ return values.filter((value) => value != null).sort((a, b) => Date.parse(b) - Date.parse(a))[0];
2167
+ }
2168
+ function assignOptional(target, key, value) {
2169
+ if (value !== void 0) Object.assign(target, { [key]: value });
2170
+ }
2171
+ function cloneSingleTask(task) {
2172
+ const cloned = { ...task };
2173
+ if (task.dependsOn !== void 0) cloned.dependsOn = [...task.dependsOn];
2174
+ if (cloned.status !== "done") delete cloned.completedAt;
2175
+ return cloned;
2176
+ }
2177
+ function mergeTask(args) {
2178
+ const baseExists = args.base !== void 0;
2179
+ const status = chooseStatus(args.base, args.ours, args.theirs);
2180
+ const merged = {
2181
+ id: args.ours.id,
2182
+ title: chooseField({
2183
+ baseExists,
2184
+ base: args.base?.title,
2185
+ ours: args.ours.title,
2186
+ theirs: args.theirs.title,
2187
+ newer: args.newer
2188
+ }),
2189
+ status
2190
+ };
2191
+ assignOptional(
2192
+ merged,
2193
+ "startedAt",
2194
+ earliest([args.base?.startedAt, args.ours.startedAt, args.theirs.startedAt])
2195
+ );
2196
+ if (status === "done") {
2197
+ assignOptional(
2198
+ merged,
2199
+ "completedAt",
2200
+ latest([args.base?.completedAt, args.ours.completedAt, args.theirs.completedAt])
2201
+ );
2202
+ }
2203
+ assignOptional(merged, "note", chooseField({
2204
+ baseExists,
2205
+ base: args.base?.note,
2206
+ ours: args.ours.note,
2207
+ theirs: args.theirs.note,
2208
+ newer: args.newer
2209
+ }));
2210
+ assignOptional(merged, "plan", chooseField({
2211
+ baseExists,
2212
+ base: args.base?.plan,
2213
+ ours: args.ours.plan,
2214
+ theirs: args.theirs.plan,
2215
+ newer: args.newer
2216
+ }));
2217
+ const dependsOn = [
2218
+ ...args.ours.dependsOn ?? [],
2219
+ ...args.theirs.dependsOn ?? []
2220
+ ].filter((id, index, values) => values.indexOf(id) === index);
2221
+ if (dependsOn.length > 0) merged.dependsOn = dependsOn;
2222
+ return merged;
2223
+ }
2224
+ function shouldKeep(base, ours, theirs) {
2225
+ if (base === void 0) return ours !== void 0 || theirs !== void 0;
2226
+ if (ours === void 0 && theirs === void 0) return false;
2227
+ if (ours === void 0) return !isDeepStrictEqual(theirs, base);
2228
+ if (theirs === void 0) return !isDeepStrictEqual(ours, base);
2229
+ return true;
2230
+ }
2231
+ function orderTasks(args) {
2232
+ const oursPhase = args.ours.phases.find((phase) => phase.id === args.phaseId);
2233
+ const theirsPhase = args.theirs.phases.find((phase) => phase.id === args.phaseId);
2234
+ const ordered = (oursPhase?.tasks ?? []).map((task) => task.id).filter((id) => args.taskIds.has(id));
2235
+ let nearestCommon = null;
2236
+ let insertAfter = null;
2237
+ for (const task of theirsPhase?.tasks ?? []) {
2238
+ if (!args.taskIds.has(task.id)) continue;
2239
+ if (args.oursLocations.has(task.id)) {
2240
+ if (ordered.includes(task.id)) {
2241
+ nearestCommon = task.id;
2242
+ insertAfter = task.id;
2243
+ }
2244
+ continue;
2245
+ }
2246
+ if (nearestCommon === null || insertAfter === null) {
2247
+ ordered.push(task.id);
2248
+ continue;
2249
+ }
2250
+ const index = ordered.indexOf(insertAfter);
2251
+ ordered.splice(index + 1, 0, task.id);
2252
+ insertAfter = task.id;
2253
+ }
2254
+ return ordered;
2255
+ }
2256
+ function mergeRoadmaps(args) {
2257
+ const newer = Date.parse(args.theirs.updatedAt) > Date.parse(args.ours.updatedAt) ? "theirs" : "ours";
2258
+ const newerRoadmap = newer === "ours" ? args.ours : args.theirs;
2259
+ const olderRoadmap = newer === "ours" ? args.theirs : args.ours;
2260
+ const basePhases = new Map((args.base?.phases ?? []).map((phase) => [phase.id, phase]));
2261
+ const oursPhases = new Map(args.ours.phases.map((phase) => [phase.id, phase]));
2262
+ const theirsPhases = new Map(args.theirs.phases.map((phase) => [phase.id, phase]));
2263
+ const baseLocations = taskLocations(args.base);
2264
+ const oursLocations = taskLocations(args.ours);
2265
+ const theirsLocations = taskLocations(args.theirs);
2266
+ const keptTasks = /* @__PURE__ */ new Map();
2267
+ const targetPhaseByTask = /* @__PURE__ */ new Map();
2268
+ const taskIds = /* @__PURE__ */ new Set([
2269
+ ...baseLocations.keys(),
2270
+ ...oursLocations.keys(),
2271
+ ...theirsLocations.keys()
2272
+ ]);
2273
+ for (const taskId of taskIds) {
2274
+ const baseLocation = baseLocations.get(taskId);
2275
+ const oursLocation = oursLocations.get(taskId);
2276
+ const theirsLocation = theirsLocations.get(taskId);
2277
+ if (!shouldKeep(baseLocation, oursLocation, theirsLocation)) continue;
2278
+ const task = oursLocation !== void 0 && theirsLocation !== void 0 ? mergeTask({
2279
+ base: baseLocation?.task,
2280
+ ours: oursLocation.task,
2281
+ theirs: theirsLocation.task,
2282
+ newer
2283
+ }) : cloneSingleTask((oursLocation ?? theirsLocation).task);
2284
+ keptTasks.set(taskId, task);
2285
+ targetPhaseByTask.set(taskId, (oursLocation ?? theirsLocation).phaseId);
2286
+ }
2287
+ const keptPhaseIds = /* @__PURE__ */ new Set();
2288
+ const allPhaseIds = /* @__PURE__ */ new Set([
2289
+ ...basePhases.keys(),
2290
+ ...oursPhases.keys(),
2291
+ ...theirsPhases.keys()
2292
+ ]);
2293
+ for (const phaseId of allPhaseIds) {
2294
+ if (shouldKeep(basePhases.get(phaseId), oursPhases.get(phaseId), theirsPhases.get(phaseId))) {
2295
+ keptPhaseIds.add(phaseId);
2296
+ }
2297
+ }
2298
+ const phaseOrder = [
2299
+ ...args.ours.phases.map((phase) => phase.id).filter((id) => keptPhaseIds.has(id)),
2300
+ ...args.theirs.phases.map((phase) => phase.id).filter((id) => keptPhaseIds.has(id) && !oursPhases.has(id))
2301
+ ];
2302
+ const phases = phaseOrder.map((phaseId) => {
2303
+ const base = basePhases.get(phaseId);
2304
+ const ours = oursPhases.get(phaseId);
2305
+ const theirs = theirsPhases.get(phaseId);
2306
+ const source = ours ?? theirs;
2307
+ const title = ours !== void 0 && theirs !== void 0 ? chooseField({
2308
+ baseExists: base !== void 0,
2309
+ base: base?.title,
2310
+ ours: ours.title,
2311
+ theirs: theirs.title,
2312
+ newer
2313
+ }) : source.title;
2314
+ const plan = ours !== void 0 && theirs !== void 0 ? chooseField({
2315
+ baseExists: base !== void 0,
2316
+ base: base?.plan,
2317
+ ours: ours.plan,
2318
+ theirs: theirs.plan,
2319
+ newer
2320
+ }) : source.plan;
2321
+ const phaseTaskIds = new Set(
2322
+ [...keptTasks.keys()].filter((taskId) => targetPhaseByTask.get(taskId) === phaseId)
2323
+ );
2324
+ const tasks = orderTasks({
2325
+ phaseId,
2326
+ taskIds: phaseTaskIds,
2327
+ ours: args.ours,
2328
+ theirs: args.theirs,
2329
+ oursLocations
2330
+ }).map((taskId) => keptTasks.get(taskId));
2331
+ const phase = { id: phaseId, title, status: "todo", tasks };
2332
+ if (plan !== void 0) phase.plan = plan;
2333
+ phase.status = derivePhaseStatus(phase);
2334
+ return phase;
2335
+ });
2336
+ const mergedTasks = new Map(
2337
+ phases.flatMap((phase) => phase.tasks.map((task) => [task.id, task]))
2338
+ );
2339
+ const validCurrentTask = (roadmap) => {
2340
+ const id = roadmap.currentTaskId ?? null;
2341
+ return id != null && mergedTasks.get(id)?.status === "in-progress" ? id : null;
2342
+ };
2343
+ const merged = {
2344
+ schemaVersion: 2,
2345
+ project: { ...newerRoadmap.project },
2346
+ updatedAt: newerRoadmap.updatedAt,
2347
+ currentTaskId: validCurrentTask(newerRoadmap) ?? validCurrentTask(olderRoadmap),
2348
+ summary: newerRoadmap.summary,
2349
+ phases
2350
+ };
2351
+ const validation = validateRoadmap(merged);
2352
+ if (validation.errors.length > 0) {
2353
+ throw new CliError(
2354
+ `Merged roadmap failed validation:
2355
+ ${validation.errors.join("\n ")}`,
2356
+ 1
2357
+ );
2358
+ }
2359
+ return merged;
2360
+ }
2361
+
2362
+ // src/commands/pull.ts
2363
+ async function readExisting(filePath) {
2364
+ try {
2365
+ return await readFile8(filePath, "utf8");
2366
+ } catch (err) {
2367
+ if (err != null && typeof err === "object" && "code" in err && err.code === "ENOENT") {
2368
+ return null;
2369
+ }
2370
+ throw err;
2371
+ }
2372
+ }
2373
+ function register7(program, deps) {
2374
+ program.command("pull [slug]").description(
2375
+ "Pull server roadmaps, optionally merging them with valid local copies."
2376
+ ).option("--merge", "Structurally merge server and local roadmap changes").action(async (requestedSlug, opts) => {
2377
+ const { root, projectId } = await resolveRoadmapReadContext(deps);
2378
+ const list = await deps.http.get(
2379
+ `/api/projects/${projectId}/roadmaps`
2380
+ );
2381
+ const serverBySlug = new Map(list.roadmaps.map((roadmap) => [roadmap.slug, roadmap]));
2382
+ if (requestedSlug != null && !serverBySlug.has(requestedSlug)) {
2383
+ throw new CliError(`Roadmap "${requestedSlug}" was not found on the server.`, 2);
2384
+ }
2385
+ const targets = requestedSlug == null ? [...serverBySlug.values()] : [serverBySlug.get(requestedSlug)];
2386
+ const roadmapsDir = path14.join(root, ".nolto", "roadmaps");
2387
+ for (const summary of targets) {
2388
+ const response = await deps.http.get(
2389
+ `/api/projects/${projectId}/roadmaps/${encodeURIComponent(summary.slug)}`
2390
+ );
2391
+ const validation = validateRoadmap(response.roadmap);
2392
+ if (validation.errors.length > 0) {
2393
+ throw new CliError(
2394
+ `Server roadmap "${summary.slug}" failed validation: ${validation.errors.join("; ")}`,
2395
+ 5
2396
+ );
2397
+ }
2398
+ const filePath = path14.join(roadmapsDir, `${summary.slug}.json`);
2399
+ const existing = await readExisting(filePath);
2400
+ let roadmap = response.roadmap;
2401
+ let outputVerb = "pulled";
2402
+ if (opts.merge === true && existing !== null) {
2403
+ let local;
2404
+ try {
2405
+ local = JSON.parse(existing);
2406
+ } catch {
2407
+ process.stderr.write(
2408
+ `Warning: skipped ${summary.slug}.json because the local roadmap contains malformed JSON.
2409
+ `
2410
+ );
2411
+ continue;
2412
+ }
2413
+ const localValidation = validateRoadmap(local);
2414
+ if (localValidation.errors.length > 0) {
2415
+ process.stderr.write(
2416
+ `Warning: skipped ${summary.slug}.json because the local roadmap is invalid: ${localValidation.errors.join("; ")}
2417
+ `
2418
+ );
2419
+ continue;
2420
+ }
2421
+ roadmap = mergeRoadmaps({
2422
+ base: null,
2423
+ ours: local,
2424
+ theirs: response.roadmap
2425
+ });
2426
+ outputVerb = "merged";
2427
+ }
2428
+ const serialized = JSON.stringify(roadmap, null, 2) + "\n";
2429
+ if (existing === serialized) {
2430
+ process.stdout.write(`unchanged ${summary.slug}.json
2431
+ `);
2432
+ continue;
2433
+ }
2434
+ await mkdir7(roadmapsDir, { recursive: true });
2435
+ await writeFile7(filePath, serialized, "utf8");
2436
+ process.stdout.write(
2437
+ outputVerb === "merged" ? `merged ${summary.slug}.json
2438
+ ` : `pulled ${summary.slug}.json (${summary.taskDone}/${summary.taskTotal} done)
2439
+ `
2440
+ );
2441
+ }
2442
+ process.stdout.write("Review with git diff before committing.\n");
2443
+ });
2444
+ }
2445
+
1803
2446
  // 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";
2447
+ 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
2448
  import { existsSync as existsSync4 } from "fs";
1806
- import path12 from "path";
2449
+ import path16 from "path";
1807
2450
  import chokidar from "chokidar";
1808
2451
 
1809
2452
  // src/watch-core.ts
@@ -1909,7 +2552,7 @@ var RepoWatch = class {
1909
2552
  };
1910
2553
 
1911
2554
  // src/service-install.ts
1912
- import path11 from "path";
2555
+ import path15 from "path";
1913
2556
  import os4 from "os";
1914
2557
  function buildUnitFile(args) {
1915
2558
  return [
@@ -1929,8 +2572,8 @@ function buildUnitFile(args) {
1929
2572
  }
1930
2573
  function getUnitPath(env) {
1931
2574
  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");
2575
+ const base = xdg != null && xdg.length > 0 ? xdg : path15.join(os4.homedir(), ".config");
2576
+ return path15.join(base, "systemd", "user", "nolto-watch.service");
1934
2577
  }
1935
2578
  async function installServiceWith(deps) {
1936
2579
  if (deps.platform !== "linux") {
@@ -1941,7 +2584,7 @@ async function installServiceWith(deps) {
1941
2584
  );
1942
2585
  }
1943
2586
  const unitPath = getUnitPath(deps.env);
1944
- await deps.mkdir(path11.dirname(unitPath));
2587
+ await deps.mkdir(path15.dirname(unitPath));
1945
2588
  await deps.writeFile(unitPath, buildUnitFile({ nodePath: deps.nodePath, scriptPath: deps.scriptPath }));
1946
2589
  deps.log(`Wrote ${unitPath}`);
1947
2590
  const reload = await deps.exec(["systemctl", "--user", "daemon-reload"]);
@@ -1955,18 +2598,18 @@ async function installServiceWith(deps) {
1955
2598
  deps.log("Service nolto-watch enabled and started. Logs: journalctl --user -u nolto-watch -f");
1956
2599
  }
1957
2600
  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);
2601
+ const { writeFile: writeFile10, mkdir: mkdir10 } = await import("fs/promises");
2602
+ const { execFile: execFile4 } = await import("child_process");
2603
+ const { promisify: promisify3 } = await import("util");
2604
+ const execFileAsync = promisify3(execFile4);
1962
2605
  await installServiceWith({
1963
2606
  platform: process.platform,
1964
2607
  env: process.env,
1965
2608
  nodePath: process.execPath,
1966
- scriptPath: path11.resolve(process.argv[1] ?? ""),
1967
- writeFile: (p, content) => writeFile7(p, content, "utf8"),
2609
+ scriptPath: path15.resolve(process.argv[1] ?? ""),
2610
+ writeFile: (p, content) => writeFile10(p, content, "utf8"),
1968
2611
  mkdir: async (p) => {
1969
- await mkdir9(p, { recursive: true });
2612
+ await mkdir10(p, { recursive: true });
1970
2613
  },
1971
2614
  exec: async (cmd) => {
1972
2615
  try {
@@ -2021,9 +2664,9 @@ async function uninstallServiceWith(deps) {
2021
2664
  }
2022
2665
  async function uninstallService() {
2023
2666
  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);
2667
+ const { execFile: execFile4 } = await import("child_process");
2668
+ const { promisify: promisify3 } = await import("util");
2669
+ const execFileAsync = promisify3(execFile4);
2027
2670
  await uninstallServiceWith({
2028
2671
  platform: process.platform,
2029
2672
  env: process.env,
@@ -2043,7 +2686,7 @@ async function uninstallService() {
2043
2686
  }
2044
2687
 
2045
2688
  // src/commands/watch.ts
2046
- function register6(program, deps) {
2689
+ function register8(program, deps) {
2047
2690
  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
2691
  if (opts.installService && opts.uninstallService) {
2049
2692
  throw new CliError("--install-service and --uninstall-service cannot be used together.", 2);
@@ -2080,10 +2723,10 @@ function register6(program, deps) {
2080
2723
  token: deps.settings.token
2081
2724
  });
2082
2725
  const startRepo = (root) => {
2083
- const roadmapsPath = path12.join(root, ".nolto", "roadmaps");
2084
- const legacyRoadmapPath = path12.join(root, ".roadmap", "roadmap.json");
2726
+ const roadmapsPath = path16.join(root, ".nolto", "roadmaps");
2727
+ const legacyRoadmapPath = path16.join(root, ".roadmap", "roadmap.json");
2085
2728
  const warn = (line) => {
2086
- process.stderr.write(`Warning: [${path12.basename(root)}] ${line}
2729
+ process.stderr.write(`Warning: [${path16.basename(root)}] ${line}
2087
2730
  `);
2088
2731
  };
2089
2732
  const watcher = chokidar.watch([roadmapsPath, legacyRoadmapPath], { ignoreInitial: true });
@@ -2092,17 +2735,17 @@ function register6(program, deps) {
2092
2735
  // #316: watch must warn about legacy roadmaps without migrating them.
2093
2736
  { root, defaultProjectId: deps.settings.defaultProjectId, migrateLegacy: false },
2094
2737
  {
2095
- readFile: (p) => readFile6(p, "utf8"),
2738
+ readFile: (p) => readFile9(p, "utf8"),
2096
2739
  fileExists: (p) => existsSync4(p),
2097
- listDir: (p) => readdir3(p),
2740
+ listDir: (p) => readdir4(p),
2098
2741
  rename: rename3,
2099
2742
  copyFile: copyFile2,
2100
- mkdir: (p) => mkdir7(p, { recursive: true }).then(() => void 0),
2743
+ mkdir: (p) => mkdir8(p, { recursive: true }).then(() => void 0),
2101
2744
  unlink: unlink3,
2102
2745
  rmdir: rmdir2,
2103
2746
  repoIdentity: makeRepoIdentityResolver(deps),
2104
2747
  http,
2105
- log: (line) => process.stdout.write(`[${path12.basename(root)}] ${line}
2748
+ log: (line) => process.stdout.write(`[${path16.basename(root)}] ${line}
2106
2749
  `),
2107
2750
  warn
2108
2751
  }
@@ -2165,25 +2808,25 @@ function register6(program, deps) {
2165
2808
  }
2166
2809
 
2167
2810
  // src/update-cli.ts
2168
- import { execFile as execFile2 } from "child_process";
2811
+ import { execFile as execFile3 } from "child_process";
2169
2812
  import { existsSync as existsSync5 } from "fs";
2170
2813
  import { realpath } from "fs/promises";
2171
2814
  import { createRequire as createRequire2 } from "module";
2172
- import path14 from "path";
2815
+ import path18 from "path";
2173
2816
  import { fileURLToPath as fileURLToPath3 } from "url";
2174
- import { promisify } from "util";
2817
+ import { promisify as promisify2 } from "util";
2175
2818
 
2176
2819
  // src/update-notifier.ts
2177
- import { readFile as readFile7, writeFile as writeFile6, mkdir as mkdir8 } from "fs/promises";
2820
+ import { readFile as readFile10, writeFile as writeFile8, mkdir as mkdir9 } from "fs/promises";
2178
2821
  import https from "https";
2179
- import path13 from "path";
2822
+ import path17 from "path";
2180
2823
  var PACKAGE = "@nolto/cli";
2181
2824
  var CACHE_FILE = "update-check.json";
2182
2825
  var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
2183
2826
  var REQUEST_TIMEOUT_MS = 2e3;
2184
- function isNewerVersion(latest, current) {
2827
+ function isNewerVersion(latest2, current) {
2185
2828
  const parts = (v) => v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
2186
- const a = parts(latest);
2829
+ const a = parts(latest2);
2187
2830
  const b = parts(current);
2188
2831
  for (let i = 0; i < 3; i++) {
2189
2832
  const x = a[i] ?? 0;
@@ -2192,9 +2835,9 @@ function isNewerVersion(latest, current) {
2192
2835
  }
2193
2836
  return false;
2194
2837
  }
2195
- function formatUpdateNotice(latest, current) {
2838
+ function formatUpdateNotice(latest2, current) {
2196
2839
  return `
2197
- Update available: ${current} \u2192 ${latest} \xB7 run \`nolto update\`
2840
+ Update available: ${current} \u2192 ${latest2} \xB7 run \`nolto update\`
2198
2841
  `;
2199
2842
  }
2200
2843
  function isDisabled(env) {
@@ -2233,22 +2876,22 @@ function fetchLatestFromRegistry(timeoutMs = REQUEST_TIMEOUT_MS, opts = {}) {
2233
2876
  req.on("error", () => resolve(null));
2234
2877
  });
2235
2878
  }
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 });
2879
+ async function writeUpdateCache(cachePath, now, latest2) {
2880
+ await mkdir9(path17.dirname(cachePath), { recursive: true });
2881
+ const payload = { checkedAt: now, latest: latest2 };
2882
+ await writeFile8(cachePath, JSON.stringify(payload), { mode: 384 });
2240
2883
  }
2241
2884
  async function refreshCache(cachePath, now, fetchLatest) {
2242
- const latest = await fetchLatest();
2243
- if (!latest) return;
2244
- await writeUpdateCache(cachePath, now, latest).catch(() => void 0);
2885
+ const latest2 = await fetchLatest();
2886
+ if (!latest2) return;
2887
+ await writeUpdateCache(cachePath, now, latest2).catch(() => void 0);
2245
2888
  }
2246
2889
  async function checkForUpdate(opts) {
2247
2890
  if (isDisabled(opts.env)) return null;
2248
- const cachePath = path13.join(opts.configDir, CACHE_FILE);
2891
+ const cachePath = path17.join(opts.configDir, CACHE_FILE);
2249
2892
  let cache = {};
2250
2893
  try {
2251
- cache = JSON.parse(await readFile7(cachePath, "utf8"));
2894
+ cache = JSON.parse(await readFile10(cachePath, "utf8"));
2252
2895
  } catch {
2253
2896
  }
2254
2897
  if (typeof cache.checkedAt !== "number" || opts.now - cache.checkedAt > CACHE_TTL_MS) {
@@ -2264,13 +2907,13 @@ async function checkForUpdate(opts) {
2264
2907
  async function notifyUpdate(opts) {
2265
2908
  try {
2266
2909
  if (opts.isJson || !process.stderr.isTTY) return;
2267
- const latest = await checkForUpdate({
2910
+ const latest2 = await checkForUpdate({
2268
2911
  current: opts.current,
2269
2912
  configDir: getConfigDir(opts.env),
2270
2913
  env: opts.env,
2271
2914
  now: opts.now
2272
2915
  });
2273
- if (latest) process.stderr.write(formatUpdateNotice(latest, opts.current));
2916
+ if (latest2) process.stderr.write(formatUpdateNotice(latest2, opts.current));
2274
2917
  } catch {
2275
2918
  }
2276
2919
  }
@@ -2321,14 +2964,14 @@ async function updateCliWith(deps) {
2321
2964
  if (!isInsideGlobalPackage(deps.scriptPath, globalRoot, deps.platform)) {
2322
2965
  throw notGlobalError(deps.scriptPath);
2323
2966
  }
2324
- const latest = await deps.fetchLatest();
2325
- if (latest == null) {
2967
+ const latest2 = await deps.fetchLatest();
2968
+ if (latest2 == null) {
2326
2969
  throw new CliError(
2327
2970
  "Could not reach the npm registry to check for the latest @nolto/cli version.",
2328
2971
  5
2329
2972
  );
2330
2973
  }
2331
- if (!isNewerVersion(latest, deps.currentVersion)) {
2974
+ if (!isNewerVersion(latest2, deps.currentVersion)) {
2332
2975
  deps.log(`Already up to date (${PACKAGE2} ${deps.currentVersion}).`);
2333
2976
  return {
2334
2977
  status: "up-to-date",
@@ -2341,7 +2984,7 @@ async function updateCliWith(deps) {
2341
2984
  "npm",
2342
2985
  "install",
2343
2986
  "-g",
2344
- `${PACKAGE2}@${latest}`
2987
+ `${PACKAGE2}@${latest2}`
2345
2988
  ]);
2346
2989
  if (installResult.code !== 0) {
2347
2990
  const detail = stderrTail(installResult.stderr);
@@ -2351,11 +2994,11 @@ async function updateCliWith(deps) {
2351
2994
  /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
2995
  );
2353
2996
  }
2354
- deps.log(`Updated ${PACKAGE2} ${deps.currentVersion} \u2192 ${latest}.`);
2997
+ deps.log(`Updated ${PACKAGE2} ${deps.currentVersion} \u2192 ${latest2}.`);
2355
2998
  await deps.writeCache(
2356
- path14.join(deps.configDir, UPDATE_CACHE_FILE),
2999
+ path18.join(deps.configDir, UPDATE_CACHE_FILE),
2357
3000
  deps.now,
2358
- latest
3001
+ latest2
2359
3002
  ).catch(() => void 0);
2360
3003
  let watchService = "not-installed";
2361
3004
  const unitPath = getUnitPath(deps.env);
@@ -2379,22 +3022,22 @@ async function updateCliWith(deps) {
2379
3022
  return {
2380
3023
  status: "updated",
2381
3024
  from: deps.currentVersion,
2382
- to: latest,
3025
+ to: latest2,
2383
3026
  watchService
2384
3027
  };
2385
3028
  }
2386
3029
  function getCurrentVersion() {
2387
- const dirname = path14.dirname(fileURLToPath3(import.meta.url));
3030
+ const dirname = path18.dirname(fileURLToPath3(import.meta.url));
2388
3031
  const require3 = createRequire2(import.meta.url);
2389
3032
  try {
2390
- const pkg = require3(path14.resolve(dirname, "../package.json"));
3033
+ const pkg = require3(path18.resolve(dirname, "../package.json"));
2391
3034
  return pkg.version ?? "0.0.0";
2392
3035
  } catch {
2393
3036
  return "0.0.0";
2394
3037
  }
2395
3038
  }
2396
3039
  async function updateCli(opts = {}) {
2397
- const execFileAsync = promisify(execFile2);
3040
+ const execFileAsync = promisify2(execFile3);
2398
3041
  const scriptPath = await realpath(process.argv[1] ?? "");
2399
3042
  return updateCliWith({
2400
3043
  currentVersion: getCurrentVersion(),
@@ -2426,7 +3069,7 @@ async function updateCli(opts = {}) {
2426
3069
  }
2427
3070
 
2428
3071
  // src/commands/update.ts
2429
- function register7(program, deps) {
3072
+ function register9(program, deps) {
2430
3073
  program.command("update").description("Update @nolto/cli to the latest version and restart the watch service if installed").action(async () => {
2431
3074
  const mode2 = deps.output.mode;
2432
3075
  const result = await updateCli({ quiet: mode2 === "json" });
@@ -2436,6 +3079,46 @@ function register7(program, deps) {
2436
3079
  });
2437
3080
  }
2438
3081
 
3082
+ // src/commands/merge-file.ts
3083
+ import { readFile as readFile11, writeFile as writeFile9 } from "fs/promises";
3084
+ async function readRoadmap(filePath) {
3085
+ let parsed;
3086
+ try {
3087
+ parsed = JSON.parse(await readFile11(filePath, "utf8"));
3088
+ } catch (err) {
3089
+ const message = err instanceof SyntaxError ? "malformed JSON" : String(err);
3090
+ throw new CliError(`${filePath}: ${message}`, 1);
3091
+ }
3092
+ const validation = validateRoadmap(parsed);
3093
+ if (validation.errors.length > 0) {
3094
+ throw new CliError(
3095
+ `${filePath} failed validation:
3096
+ ${validation.errors.join("\n ")}`,
3097
+ 1
3098
+ );
3099
+ }
3100
+ return parsed;
3101
+ }
3102
+ function register10(program) {
3103
+ program.command("merge-file <ours> <theirs>").description(
3104
+ 'Structurally merge roadmap files for Git.\ngit config merge.nolto-roadmap.driver "nolto merge-file %A %B --base %O"'
3105
+ ).option("--base <path>", "Common ancestor roadmap file").option("--output <path>", "Write the result here (defaults to <ours>)").action(async (oursPath, theirsPath, opts) => {
3106
+ const ours = await readRoadmap(oursPath);
3107
+ const theirs = await readRoadmap(theirsPath);
3108
+ const base = opts.base == null ? null : await readRoadmap(opts.base);
3109
+ const merged = mergeRoadmaps({ base, ours, theirs });
3110
+ await writeFile9(
3111
+ opts.output ?? oursPath,
3112
+ JSON.stringify(merged, null, 2) + "\n",
3113
+ "utf8"
3114
+ );
3115
+ const taskCount = merged.phases.reduce((total, phase) => total + phase.tasks.length, 0);
3116
+ process.stderr.write(`merged roadmap (${taskCount} tasks)
3117
+ `);
3118
+ process.exitCode = 0;
3119
+ });
3120
+ }
3121
+
2439
3122
  // src/program.ts
2440
3123
  function stripCommanderErrorPrefix(msg) {
2441
3124
  return msg.startsWith("error: ") ? msg.slice("error: ".length) : msg;
@@ -2451,9 +3134,12 @@ function buildProgram(deps) {
2451
3134
  register5(program, deps);
2452
3135
  register6(program, deps);
2453
3136
  register7(program, deps);
3137
+ register8(program, deps);
3138
+ register9(program, deps);
3139
+ register10(program);
2454
3140
  program.hook("preAction", (_thisCommand, actionCommand) => {
2455
3141
  const bindingError = deps.repoBinding?.error;
2456
- const bindingExemptCommands = ["init", "link", "update"];
3142
+ const bindingExemptCommands = ["init", "link", "update", "merge-file"];
2457
3143
  if (bindingError != null && !bindingExemptCommands.includes(actionCommand.name())) {
2458
3144
  throw bindingError;
2459
3145
  }
@@ -2462,11 +3148,11 @@ function buildProgram(deps) {
2462
3148
  }
2463
3149
 
2464
3150
  // src/index.ts
2465
- var __dirname3 = path15.dirname(fileURLToPath4(import.meta.url));
3151
+ var __dirname3 = path19.dirname(fileURLToPath4(import.meta.url));
2466
3152
  var require2 = createRequire3(import.meta.url);
2467
3153
  function getVersion() {
2468
3154
  try {
2469
- const pkgPath = path15.resolve(__dirname3, "../package.json");
3155
+ const pkgPath = path19.resolve(__dirname3, "../package.json");
2470
3156
  const pkg = require2(pkgPath);
2471
3157
  return pkg.version ?? "0.0.0";
2472
3158
  } catch {