@nolto/cli 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,7 +20,7 @@ nolto init
20
20
 
21
21
  `nolto init` configures authentication, selects or creates a project, writes the
22
22
  repo-local `nolto.json` binding, installs the bundled `roadmap-progress` skill,
23
- and creates `.roadmap/roadmap.json` when needed.
23
+ and creates `.nolto/roadmaps/<slug>.json` when needed.
24
24
 
25
25
  For browser-based authentication without the rest of the repository setup:
26
26
 
@@ -52,13 +52,15 @@ project.
52
52
  ### Roadmap Sync
53
53
 
54
54
  ```bash
55
- nolto sync # Push roadmap.json and linked plan Markdown
55
+ nolto sync # Push roadmap JSON files and linked plan Markdown
56
56
  nolto watch [--debounce <ms>] # Watch registered repositories and auto-sync
57
57
  nolto watch --install-service # Install the Linux systemd user service
58
58
  ```
59
59
 
60
- `sync` reads `.roadmap/roadmap.json`, validates schema v2, follows any linked plan
61
- Markdown paths, and sends an idempotent full upsert to the bound project.
60
+ `sync` reads every `.nolto/roadmaps/*.json` file in filename order, uses each
61
+ filename stem as its slug, validates schema v2, follows any linked plan Markdown
62
+ paths, and sends an idempotent full upsert to the bound project. A legacy
63
+ `.roadmap/roadmap.json` is migrated automatically when no canonical roadmap exists.
62
64
 
63
65
  `watch` uses the repository registry maintained by `nolto init`. Missing registered
64
66
  repositories are skipped with a warning.
@@ -88,11 +90,13 @@ Repository binding (`nolto.json`):
88
90
 
89
91
  ```json
90
92
  {
91
- "projectId": "00000000-0000-0000-0000-000000000001",
92
- "roadmapSlug": "my-repository"
93
+ "projectId": "00000000-0000-0000-0000-000000000001"
93
94
  }
94
95
  ```
95
96
 
97
+ The optional legacy `roadmapSlug` field is read only when naming an automatically
98
+ migrated `.roadmap/roadmap.json`; canonical roadmap slugs come from filenames.
99
+
96
100
  Precedence: CLI flags > environment variables > `nolto.json` > config file > defaults.
97
101
 
98
102
  ## JSON Output
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { createRequire as createRequire2 } from "module";
5
- import { fileURLToPath as fileURLToPath3 } from "url";
6
- import path12 from "path";
4
+ import { createRequire as createRequire3 } from "module";
5
+ import { fileURLToPath as fileURLToPath4 } from "url";
6
+ import path13 from "path";
7
7
  import { CommanderError } from "commander";
8
8
 
9
9
  // src/config.ts
@@ -26,9 +26,9 @@ var CliError = class extends Error {
26
26
  hint;
27
27
  status;
28
28
  };
29
- function mapHttpStatusToCliError(status, retryAfter, wwwAuthenticate) {
29
+ function mapHttpStatusToCliError(status, opts = {}) {
30
30
  if (status === 401) {
31
- const detail = wwwAuthenticate ? ` (${wwwAuthenticate})` : "";
31
+ const detail = opts.wwwAuthenticate ? ` (${opts.wwwAuthenticate})` : "";
32
32
  return new CliError(
33
33
  `Unauthorized${detail}`,
34
34
  3,
@@ -36,16 +36,25 @@ function mapHttpStatusToCliError(status, retryAfter, wwwAuthenticate) {
36
36
  status
37
37
  );
38
38
  }
39
+ if (status === 402) {
40
+ const hint = opts.upgradeUrl != null ? `Upgrade: ${opts.upgradeUrl}` : void 0;
41
+ return new CliError(
42
+ opts.serverMessage ?? opts.serverError ?? "Payment Required",
43
+ 4,
44
+ hint,
45
+ status
46
+ );
47
+ }
39
48
  if (status === 403) {
40
49
  return new CliError(
41
- "Forbidden \u2014 insufficient scope",
50
+ opts.serverMessage ?? opts.serverError ?? "Forbidden",
42
51
  3,
43
- "Ensure your API token has mcp:read and mcp:write scopes.",
52
+ "Check that your token belongs to a member of this project (`nolto whoami`).",
44
53
  status
45
54
  );
46
55
  }
47
56
  if (status === 429) {
48
- const waitMsg = retryAfter ? ` Retry-After: ${retryAfter}s` : "";
57
+ const waitMsg = opts.retryAfter ? ` Retry-After: ${opts.retryAfter}s` : "";
49
58
  return new CliError(`Rate limit exceeded.${waitMsg}`, 4, void 0, status);
50
59
  }
51
60
  return new CliError(`Server returned HTTP ${status}`, 5, void 0, status);
@@ -155,9 +164,6 @@ async function mergeJsonFile(filePath, patch) {
155
164
  async function writeRepoBinding(root, projectId) {
156
165
  await mergeJsonFile(path.join(root, "nolto.json"), { projectId });
157
166
  }
158
- async function writeRoadmapSlug(root, slug) {
159
- await mergeJsonFile(path.join(root, "nolto.json"), { roadmapSlug: slug });
160
- }
161
167
  function getConfigDir(env) {
162
168
  const xdg = env["XDG_CONFIG_HOME"];
163
169
  const base = xdg != null && xdg.length > 0 ? xdg : path.join(os.homedir(), ".config");
@@ -276,11 +282,11 @@ function maskToken(token) {
276
282
  function createHttpClient(opts) {
277
283
  const { baseUrl, version, token } = opts;
278
284
  const base = baseUrl.replace(/\/+$/, "");
279
- async function request(method, path13, body) {
280
- if (!path13.startsWith("/api/")) {
281
- throw new CliError(`HTTP client path must start with /api/, got: ${path13}`, 2);
285
+ async function request(method, path14, body) {
286
+ if (!path14.startsWith("/api/")) {
287
+ throw new CliError(`HTTP client path must start with /api/, got: ${path14}`, 2);
282
288
  }
283
- const url = `${base}${path13}`;
289
+ const url = `${base}${path14}`;
284
290
  const headers = {
285
291
  "Content-Type": "application/json",
286
292
  "User-Agent": `${CLI_USER_AGENT_NAME}/${version}`
@@ -307,7 +313,21 @@ function createHttpClient(opts) {
307
313
  if (!response.ok) {
308
314
  const retryAfter = response.headers.get("retry-after") ?? void 0;
309
315
  const wwwAuthenticate = response.headers.get("www-authenticate") ?? void 0;
310
- throw mapHttpStatusToCliError(response.status, retryAfter, wwwAuthenticate);
316
+ let errorBody;
317
+ try {
318
+ const parsed = await response.json();
319
+ if (parsed != null && typeof parsed === "object" && !Array.isArray(parsed)) {
320
+ errorBody = parsed;
321
+ }
322
+ } catch {
323
+ }
324
+ throw mapHttpStatusToCliError(response.status, {
325
+ retryAfter,
326
+ wwwAuthenticate,
327
+ serverError: typeof errorBody?.["error"] === "string" ? errorBody["error"] : void 0,
328
+ serverMessage: typeof errorBody?.["message"] === "string" ? errorBody["message"] : void 0,
329
+ upgradeUrl: typeof errorBody?.["upgradeUrl"] === "string" ? errorBody["upgradeUrl"] : void 0
330
+ });
311
331
  }
312
332
  return response.json();
313
333
  }
@@ -418,7 +438,7 @@ async function handleShow(deps, projectBindingPath, mode2) {
418
438
  }
419
439
  return;
420
440
  }
421
- const binding = await loadRepoBinding(projectBindingPath).catch((err) => {
441
+ const binding = deps.repoBinding != null ? deps.repoBinding.binding : await loadRepoBinding(projectBindingPath).catch((err) => {
422
442
  if (err instanceof CliError) throw err;
423
443
  throw new CliError(`Cannot read binding: ${String(err)}`, 2);
424
444
  });
@@ -511,6 +531,12 @@ Proceeding anyway \u2014 verify the ID is correct.
511
531
  );
512
532
  }
513
533
  }
534
+ if (deps.repoBinding?.error != null) {
535
+ process.stderr.write(
536
+ `Warning: existing nolto.json is invalid (${deps.repoBinding.error.message}). Overwriting it to repair.
537
+ `
538
+ );
539
+ }
514
540
  await writeRepoBinding(root, projectId);
515
541
  const writtenPath = path2.join(root, "nolto.json");
516
542
  if (mode2 === "json") {
@@ -529,9 +555,13 @@ function register(program, deps) {
529
555
  ).option("--show", "Show the current repo binding (path + projectId + source)").option("--unlink", "Remove the projectId key from nolto.json");
530
556
  cmd.action(async (projectId) => {
531
557
  const { output } = deps;
532
- const projectBindingPath = deps.projectBindingPath ?? null;
558
+ const projectBindingPath = deps.repoBinding?.path ?? deps.projectBindingPath ?? null;
559
+ const bindingError = deps.repoBinding?.error ?? null;
533
560
  const mode2 = output.mode;
534
561
  if (cmd.opts()["show"]) {
562
+ if (bindingError != null) {
563
+ throw invalidBindingError(projectBindingPath, bindingError);
564
+ }
535
565
  await handleShow(deps, projectBindingPath, mode2);
536
566
  return;
537
567
  }
@@ -543,6 +573,9 @@ function register(program, deps) {
543
573
  return;
544
574
  }
545
575
  if (projectId == null || projectId.trim().length === 0) {
576
+ if (bindingError != null) {
577
+ throw invalidBindingError(projectBindingPath, bindingError);
578
+ }
546
579
  throw new CliError(
547
580
  "Usage: nolto link <projectId> (provide a UUID)\nOr use --show to view the current binding, --unlink to remove it.",
548
581
  2
@@ -551,6 +584,12 @@ function register(program, deps) {
551
584
  await performLink(deps, projectId, mode2);
552
585
  });
553
586
  }
587
+ function invalidBindingError(projectBindingPath, error) {
588
+ return new CliError(
589
+ `Invalid nolto.json at ${projectBindingPath ?? "unknown path"}: ${error.message}. Repair it with \`nolto link <projectId>\` or \`nolto init\`.`,
590
+ 2
591
+ );
592
+ }
554
593
 
555
594
  // src/skill-install.ts
556
595
  import { cp, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2, rm } from "fs/promises";
@@ -644,7 +683,7 @@ async function addRepoToRegistry(filePath, repoRoot) {
644
683
  }
645
684
 
646
685
  // src/roadmap-scaffold.ts
647
- import { mkdir as mkdir4, writeFile as writeFile4 } from "fs/promises";
686
+ import { mkdir as mkdir4, readdir, writeFile as writeFile4 } from "fs/promises";
648
687
  import { existsSync as existsSync2 } from "fs";
649
688
  import path5 from "path";
650
689
  function slugifyProjectId(name) {
@@ -652,12 +691,23 @@ function slugifyProjectId(name) {
652
691
  return slug.length > 0 ? slug : "project";
653
692
  }
654
693
  async function scaffoldRoadmap(args) {
655
- const dir = path5.join(args.repoRoot, ".roadmap");
656
- const filePath = path5.join(dir, "roadmap.json");
657
- if (existsSync2(filePath)) {
658
- return { created: false, path: filePath };
659
- }
660
694
  const repoBasename = path5.basename(args.repoRoot);
695
+ const dir = path5.join(args.repoRoot, ".nolto", "roadmaps");
696
+ const filePath = path5.join(dir, `${slugifyProjectId(repoBasename)}.json`);
697
+ const legacyPath = path5.join(args.repoRoot, ".roadmap", "roadmap.json");
698
+ try {
699
+ const existing = (await readdir(dir)).sort();
700
+ if (existing.length > 0) {
701
+ return { created: false, path: path5.join(dir, existing[0]) };
702
+ }
703
+ } catch (err) {
704
+ if (err == null || typeof err !== "object" || !("code" in err) || err.code !== "ENOENT") {
705
+ throw err;
706
+ }
707
+ }
708
+ if (existsSync2(legacyPath)) {
709
+ return { created: false, path: legacyPath };
710
+ }
661
711
  const roadmap = {
662
712
  schemaVersion: 2,
663
713
  project: {
@@ -803,6 +853,12 @@ Saved ${configPath}
803
853
  const setup = await rl.question(`
804
854
  Set up this repository (${root}) for roadmap sync? [Y/n] `);
805
855
  if (setup.trim().toLowerCase() !== "n") {
856
+ if (deps.repoBinding?.error != null) {
857
+ process.stderr.write(
858
+ `Warning: existing nolto.json is invalid (${deps.repoBinding.error.message}). Overwriting it to repair.
859
+ `
860
+ );
861
+ }
806
862
  await writeRepoBinding(root, defaultProjectId);
807
863
  process.stdout.write(`binding: wrote ${path6.join(root, "nolto.json")}
808
864
  `);
@@ -1027,7 +1083,7 @@ function register4(program, deps) {
1027
1083
  } catch {
1028
1084
  }
1029
1085
  }
1030
- const projectBindingPath = deps.projectBindingPath ?? null;
1086
+ const projectBindingPath = deps.repoBinding?.path ?? deps.projectBindingPath ?? null;
1031
1087
  if (mode2 === "json") {
1032
1088
  printResult(
1033
1089
  {
@@ -1063,7 +1119,7 @@ function register4(program, deps) {
1063
1119
  }
1064
1120
 
1065
1121
  // src/commands/sync.ts
1066
- import { readFile as readFile4 } from "fs/promises";
1122
+ import { copyFile, mkdir as mkdir5, readFile as readFile4, readdir as readdir2, rename, rmdir, unlink } from "fs/promises";
1067
1123
  import { existsSync as existsSync3 } from "fs";
1068
1124
 
1069
1125
  // src/sync-repo.ts
@@ -1219,8 +1275,7 @@ function collectPlanRefs(roadmap) {
1219
1275
  }
1220
1276
  return refs;
1221
1277
  }
1222
- async function loadValidRoadmap(repoRoot, readFile7) {
1223
- const filePath = path7.join(repoRoot, ".roadmap", "roadmap.json");
1278
+ async function loadValidRoadmap(filePath, readFile7) {
1224
1279
  let raw;
1225
1280
  try {
1226
1281
  raw = await readFile7(filePath);
@@ -1236,7 +1291,7 @@ async function loadValidRoadmap(repoRoot, readFile7) {
1236
1291
  const { errors } = validateRoadmap(parsed);
1237
1292
  if (errors.length > 0) {
1238
1293
  throw new CliError(
1239
- `roadmap.json failed validation \u2014 not syncing:
1294
+ `${filePath} failed validation \u2014 not syncing:
1240
1295
  ${errors.join("\n ")}`,
1241
1296
  2
1242
1297
  );
@@ -1279,6 +1334,36 @@ async function runSync(args, deps) {
1279
1334
  }
1280
1335
 
1281
1336
  // src/sync-repo.ts
1337
+ var ROADMAP_SLUG_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
1338
+ async function listRoadmapFiles(roadmapsDir, io) {
1339
+ try {
1340
+ const entries = await io.listDir(roadmapsDir);
1341
+ return entries.filter((entry) => entry.endsWith(".json")).sort();
1342
+ } catch (err) {
1343
+ if (err != null && typeof err === "object" && "code" in err && err.code === "ENOENT") {
1344
+ return [];
1345
+ }
1346
+ throw err;
1347
+ }
1348
+ }
1349
+ async function migrateLegacyRoadmap(args) {
1350
+ const targetPath = path8.join(args.roadmapsDir, `${args.slug}.json`);
1351
+ await args.io.mkdir(args.roadmapsDir);
1352
+ try {
1353
+ await args.io.rename(args.legacyPath, targetPath);
1354
+ } catch {
1355
+ await args.io.copyFile(args.legacyPath, targetPath);
1356
+ await args.io.unlink(args.legacyPath);
1357
+ }
1358
+ try {
1359
+ await args.io.rmdir(path8.dirname(args.legacyPath));
1360
+ } catch {
1361
+ }
1362
+ args.io.log(
1363
+ `migrated .roadmap/roadmap.json \u2192 .nolto/roadmaps/${args.slug}.json \u2014 commit this change (ensure .nolto/ is not gitignored)`
1364
+ );
1365
+ return `${args.slug}.json`;
1366
+ }
1282
1367
  async function syncRepo(args, io) {
1283
1368
  const bindingPath = path8.join(args.root, "nolto.json");
1284
1369
  const binding = await loadRepoBinding(bindingPath);
@@ -1286,24 +1371,65 @@ async function syncRepo(args, io) {
1286
1371
  if (projectId == null) {
1287
1372
  throw new CliError("No project binding. Run `nolto init` or `nolto link <projectId>`.", 2);
1288
1373
  }
1289
- let slug = binding?.roadmapSlug;
1290
- if (slug == null) {
1291
- slug = slugifyProjectId(path8.basename(args.root));
1292
- await writeRoadmapSlug(args.root, slug);
1293
- io.log(`roadmapSlug not set \u2014 derived "${slug}" and saved to nolto.json`);
1374
+ const roadmapsDir = path8.join(args.root, ".nolto", "roadmaps");
1375
+ const legacyPath = path8.join(args.root, ".roadmap", "roadmap.json");
1376
+ let roadmapFiles = await listRoadmapFiles(roadmapsDir, io);
1377
+ if (roadmapFiles.length > 0) {
1378
+ if (io.fileExists(legacyPath)) {
1379
+ io.warn(
1380
+ "legacy .roadmap/roadmap.json is ignored because .nolto/roadmaps/ already contains roadmap files"
1381
+ );
1382
+ }
1383
+ } else if (io.fileExists(legacyPath)) {
1384
+ const migrationSlug = binding?.roadmapSlug ?? slugifyProjectId(path8.basename(args.root));
1385
+ roadmapFiles = [
1386
+ await migrateLegacyRoadmap({
1387
+ slug: migrationSlug,
1388
+ legacyPath,
1389
+ roadmapsDir,
1390
+ io
1391
+ })
1392
+ ];
1393
+ } else {
1394
+ throw new CliError(
1395
+ `No roadmap found in ${roadmapsDir}. Run \`nolto init\` to create one.`,
1396
+ 2
1397
+ );
1294
1398
  }
1295
- const roadmap = await loadValidRoadmap(args.root, io.readFile);
1296
- const response = await runSync(
1297
- { repoRoot: args.root, projectId, slug, roadmap },
1298
- { http: io.http, readFile: io.readFile, fileExists: io.fileExists, log: io.log, warn: io.warn }
1399
+ const roadmaps = await Promise.all(
1400
+ roadmapFiles.map(async (fileName) => {
1401
+ const slug = fileName.slice(0, -".json".length);
1402
+ if (!ROADMAP_SLUG_PATTERN.test(slug)) {
1403
+ throw new CliError(
1404
+ `Invalid roadmap filename "${fileName}": slug must match ^[a-z0-9][a-z0-9._-]*$.`,
1405
+ 2
1406
+ );
1407
+ }
1408
+ const filePath = path8.join(roadmapsDir, fileName);
1409
+ return { slug, roadmap: await loadValidRoadmap(filePath, io.readFile) };
1410
+ })
1299
1411
  );
1300
- const planAbsPaths = collectPlanRefs(roadmap).map((ref) => path8.join(args.root, ref.path));
1301
- return { ...response, planAbsPaths };
1412
+ const planAbsPaths = /* @__PURE__ */ new Set();
1413
+ for (const { roadmap } of roadmaps) {
1414
+ for (const ref of collectPlanRefs(roadmap)) {
1415
+ planAbsPaths.add(path8.join(args.root, ref.path));
1416
+ }
1417
+ }
1418
+ const results = [];
1419
+ for (const { slug, roadmap } of roadmaps) {
1420
+ results.push(
1421
+ await runSync(
1422
+ { repoRoot: args.root, projectId, slug, roadmap },
1423
+ { http: io.http, readFile: io.readFile, fileExists: io.fileExists, log: io.log, warn: io.warn }
1424
+ )
1425
+ );
1426
+ }
1427
+ return { results, planAbsPaths: [...planAbsPaths] };
1302
1428
  }
1303
1429
 
1304
1430
  // src/commands/sync.ts
1305
1431
  function register5(program, deps) {
1306
- program.command("sync").description("Push .roadmap/roadmap.json and linked plan documents to Nolto (idempotent full upsert).").action(async () => {
1432
+ program.command("sync").description("Push .nolto/roadmaps/*.json and linked plan documents to Nolto (idempotent full upsert).").action(async () => {
1307
1433
  if (deps.settings.token == null) {
1308
1434
  throw new CliError("Not authenticated. Run `nolto login` or set NOLTO_TOKEN.", 3);
1309
1435
  }
@@ -1322,6 +1448,12 @@ function register5(program, deps) {
1322
1448
  {
1323
1449
  readFile: (p) => readFile4(p, "utf8"),
1324
1450
  fileExists: (p) => existsSync3(p),
1451
+ listDir: (p) => readdir2(p),
1452
+ rename,
1453
+ copyFile,
1454
+ mkdir: (p) => mkdir5(p, { recursive: true }).then(() => void 0),
1455
+ unlink,
1456
+ rmdir,
1325
1457
  http,
1326
1458
  log: (line) => process.stdout.write(line + "\n"),
1327
1459
  warn: (line) => process.stderr.write("Warning: " + line + "\n")
@@ -1335,12 +1467,61 @@ function register5(program, deps) {
1335
1467
  }
1336
1468
 
1337
1469
  // src/commands/watch.ts
1338
- import { readFile as readFile5 } from "fs/promises";
1470
+ import { copyFile as copyFile2, mkdir as mkdir6, readFile as readFile5, readdir as readdir3, rename as rename2, rmdir as rmdir2, unlink as unlink2 } from "fs/promises";
1339
1471
  import { existsSync as existsSync4 } from "fs";
1340
1472
  import path10 from "path";
1341
1473
  import chokidar from "chokidar";
1342
1474
 
1343
1475
  // src/watch-core.ts
1476
+ var WatchSet = class {
1477
+ constructor(deps) {
1478
+ this.deps = deps;
1479
+ }
1480
+ deps;
1481
+ handles = /* @__PURE__ */ new Map();
1482
+ async reconcile(roots) {
1483
+ const nextRoots = new Set(roots);
1484
+ for (const [root, handle] of this.handles) {
1485
+ if (nextRoots.has(root)) continue;
1486
+ try {
1487
+ await handle.stop();
1488
+ } catch (err) {
1489
+ this.deps.log(
1490
+ `Failed to stop watching ${root}: ${err instanceof Error ? err.message : String(err)}`
1491
+ );
1492
+ }
1493
+ this.handles.delete(root);
1494
+ this.deps.log(`Stopped watching ${root}`);
1495
+ }
1496
+ for (const root of nextRoots) {
1497
+ if (this.handles.has(root)) continue;
1498
+ this.handles.set(root, this.deps.start(root));
1499
+ this.deps.log(`Watching ${root}`);
1500
+ }
1501
+ }
1502
+ async stopAll() {
1503
+ await this.reconcile([]);
1504
+ }
1505
+ size() {
1506
+ return this.handles.size;
1507
+ }
1508
+ };
1509
+ async function reloadRoots(deps) {
1510
+ let registry;
1511
+ try {
1512
+ registry = await deps.load();
1513
+ } catch (err) {
1514
+ deps.warn(
1515
+ `registry reload failed: ${err instanceof Error ? err.message : String(err)} \u2014 keeping current watch set`
1516
+ );
1517
+ return null;
1518
+ }
1519
+ return registry.repos.map((repo) => repo.root).filter((root) => {
1520
+ if (deps.exists(root)) return true;
1521
+ deps.warn(`registered repo no longer exists, skipping: ${root}`);
1522
+ return false;
1523
+ });
1524
+ }
1344
1525
  var RepoWatch = class {
1345
1526
  constructor(root, deps) {
1346
1527
  this.root = root;
@@ -1418,6 +1599,13 @@ function getUnitPath(env) {
1418
1599
  return path9.join(base, "systemd", "user", "nolto-watch.service");
1419
1600
  }
1420
1601
  async function installServiceWith(deps) {
1602
+ if (deps.platform !== "linux") {
1603
+ throw new CliError(
1604
+ "--install-service requires systemd and is only supported on Linux.",
1605
+ 2,
1606
+ "Run `nolto watch` manually in a terminal instead."
1607
+ );
1608
+ }
1421
1609
  const unitPath = getUnitPath(deps.env);
1422
1610
  await deps.mkdir(path9.dirname(unitPath));
1423
1611
  await deps.writeFile(unitPath, buildUnitFile({ nodePath: deps.nodePath, scriptPath: deps.scriptPath }));
@@ -1433,17 +1621,18 @@ async function installServiceWith(deps) {
1433
1621
  deps.log("Service nolto-watch enabled and started. Logs: journalctl --user -u nolto-watch -f");
1434
1622
  }
1435
1623
  async function installService() {
1436
- const { writeFile: writeFile6, mkdir: mkdir6 } = await import("fs/promises");
1437
- const { execFile } = await import("child_process");
1438
- const { promisify } = await import("util");
1439
- const execFileAsync = promisify(execFile);
1624
+ const { writeFile: writeFile6, mkdir: mkdir8 } = await import("fs/promises");
1625
+ const { execFile: execFile2 } = await import("child_process");
1626
+ const { promisify: promisify2 } = await import("util");
1627
+ const execFileAsync = promisify2(execFile2);
1440
1628
  await installServiceWith({
1629
+ platform: process.platform,
1441
1630
  env: process.env,
1442
1631
  nodePath: process.execPath,
1443
1632
  scriptPath: path9.resolve(process.argv[1] ?? ""),
1444
1633
  writeFile: (p, content) => writeFile6(p, content, "utf8"),
1445
1634
  mkdir: async (p) => {
1446
- await mkdir6(p, { recursive: true });
1635
+ await mkdir8(p, { recursive: true });
1447
1636
  },
1448
1637
  exec: async (cmd) => {
1449
1638
  try {
@@ -1458,14 +1647,81 @@ async function installService() {
1458
1647
  warn: (line) => process.stderr.write("Warning: " + line + "\n")
1459
1648
  });
1460
1649
  }
1650
+ async function uninstallServiceWith(deps) {
1651
+ if (deps.platform !== "linux") {
1652
+ throw new CliError(
1653
+ "--uninstall-service requires systemd and is only supported on Linux.",
1654
+ 2
1655
+ );
1656
+ }
1657
+ const unitPath = getUnitPath(deps.env);
1658
+ const disable = await deps.exec(["systemctl", "--user", "disable", "--now", "nolto-watch"]);
1659
+ let unlinkError;
1660
+ try {
1661
+ await deps.unlink(unitPath);
1662
+ deps.log(`Removed ${unitPath}`);
1663
+ } catch (err) {
1664
+ if (err.code === "ENOENT") {
1665
+ deps.log(`Unit file not found at ${unitPath}; nothing to remove.`);
1666
+ } else {
1667
+ unlinkError = err;
1668
+ }
1669
+ }
1670
+ const reload = await deps.exec(["systemctl", "--user", "daemon-reload"]);
1671
+ const systemctlFailed = disable.code !== 0 || reload.code !== 0;
1672
+ if (systemctlFailed) {
1673
+ deps.warn(
1674
+ `Could not deactivate the service automatically. Run manually:
1675
+ systemctl --user disable --now nolto-watch
1676
+ rm -f "${unitPath}"
1677
+ systemctl --user daemon-reload`
1678
+ );
1679
+ }
1680
+ if (unlinkError != null) {
1681
+ throw unlinkError;
1682
+ }
1683
+ if (systemctlFailed) {
1684
+ return;
1685
+ }
1686
+ deps.log("Service nolto-watch stopped, disabled, and removed.");
1687
+ }
1688
+ async function uninstallService() {
1689
+ const { unlink: unlink3 } = await import("fs/promises");
1690
+ const { execFile: execFile2 } = await import("child_process");
1691
+ const { promisify: promisify2 } = await import("util");
1692
+ const execFileAsync = promisify2(execFile2);
1693
+ await uninstallServiceWith({
1694
+ platform: process.platform,
1695
+ env: process.env,
1696
+ unlink: unlink3,
1697
+ exec: async (cmd) => {
1698
+ try {
1699
+ await execFileAsync(cmd[0], cmd.slice(1));
1700
+ return { code: 0, stderr: "" };
1701
+ } catch (err) {
1702
+ const e = err;
1703
+ return { code: e.code ?? 1, stderr: e.stderr ?? String(err) };
1704
+ }
1705
+ },
1706
+ log: (line) => process.stdout.write(line + "\n"),
1707
+ warn: (line) => process.stderr.write("Warning: " + line + "\n")
1708
+ });
1709
+ }
1461
1710
 
1462
1711
  // src/commands/watch.ts
1463
1712
  function register6(program, deps) {
1464
- 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").action(async (opts) => {
1713
+ 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) => {
1714
+ if (opts.installService && opts.uninstallService) {
1715
+ throw new CliError("--install-service and --uninstall-service cannot be used together.", 2);
1716
+ }
1465
1717
  if (opts.installService) {
1466
1718
  await installService();
1467
1719
  return;
1468
1720
  }
1721
+ if (opts.uninstallService) {
1722
+ await uninstallService();
1723
+ return;
1724
+ }
1469
1725
  if (deps.settings.token == null) {
1470
1726
  throw new CliError("Not authenticated. Run `nolto login` or set NOLTO_TOKEN.", 3);
1471
1727
  }
@@ -1473,7 +1729,8 @@ function register6(program, deps) {
1473
1729
  if (Number.isNaN(debounceMs) || debounceMs < 0) {
1474
1730
  throw new CliError("--debounce must be a non-negative integer.", 2);
1475
1731
  }
1476
- const registry = await loadRegistry(getRegistryPath(process.env));
1732
+ const registryPath = getRegistryPath(process.env);
1733
+ const registry = await loadRegistry(registryPath);
1477
1734
  const roots = registry.repos.map((r) => r.root).filter((root) => {
1478
1735
  if (existsSync4(root)) return true;
1479
1736
  process.stderr.write(`Warning: registered repo no longer exists, skipping: ${root}
@@ -1488,17 +1745,22 @@ function register6(program, deps) {
1488
1745
  version: deps.version,
1489
1746
  token: deps.settings.token
1490
1747
  });
1491
- const watchers = [];
1492
- for (const root of roots) {
1493
- const roadmapPath = path10.join(root, ".roadmap", "roadmap.json");
1494
- const watcher = chokidar.watch([roadmapPath], { ignoreInitial: true });
1495
- watchers.push(watcher);
1748
+ const startRepo = (root) => {
1749
+ const roadmapsPath = path10.join(root, ".nolto", "roadmaps");
1750
+ const legacyRoadmapPath = path10.join(root, ".roadmap", "roadmap.json");
1751
+ const watcher = chokidar.watch([roadmapsPath, legacyRoadmapPath], { ignoreInitial: true });
1496
1752
  const repoWatch = new RepoWatch(root, {
1497
1753
  sync: () => syncRepo(
1498
1754
  { root, defaultProjectId: deps.settings.defaultProjectId },
1499
1755
  {
1500
1756
  readFile: (p) => readFile5(p, "utf8"),
1501
1757
  fileExists: (p) => existsSync4(p),
1758
+ listDir: (p) => readdir3(p),
1759
+ rename: rename2,
1760
+ copyFile: copyFile2,
1761
+ mkdir: (p) => mkdir6(p, { recursive: true }).then(() => void 0),
1762
+ unlink: unlink2,
1763
+ rmdir: rmdir2,
1502
1764
  http,
1503
1765
  log: (line) => process.stdout.write(`[${path10.basename(root)}] ${line}
1504
1766
  `),
@@ -1515,12 +1777,41 @@ function register6(program, deps) {
1515
1777
  });
1516
1778
  watcher.on("all", (_event, filePath) => repoWatch.handleEvent(filePath));
1517
1779
  void repoWatch.flush();
1518
- }
1780
+ return { stop: () => watcher.close() };
1781
+ };
1782
+ const watchSet = new WatchSet({
1783
+ start: startRepo,
1784
+ log: (line) => process.stdout.write(line + "\n")
1785
+ });
1786
+ await watchSet.reconcile(roots);
1787
+ const registryWatcher = chokidar.watch(registryPath, { ignoreInitial: true });
1788
+ let registryReloadTimer = null;
1789
+ registryWatcher.on("all", () => {
1790
+ if (registryReloadTimer != null) clearTimeout(registryReloadTimer);
1791
+ registryReloadTimer = setTimeout(() => {
1792
+ registryReloadTimer = null;
1793
+ void reloadRoots({
1794
+ load: () => loadRegistry(registryPath),
1795
+ exists: (root) => existsSync4(root),
1796
+ warn: (line) => process.stderr.write(`Warning: ${line}
1797
+ `)
1798
+ }).then(async (newRoots) => {
1799
+ if (newRoots == null) return;
1800
+ await watchSet.reconcile(newRoots);
1801
+ if (newRoots.length === 0) {
1802
+ process.stderr.write(
1803
+ "Warning: registry is empty \u2014 watching 0 repositories until repos are registered\n"
1804
+ );
1805
+ }
1806
+ });
1807
+ }, 500);
1808
+ });
1519
1809
  process.stdout.write(`Watching ${roots.length} repositories (debounce ${debounceMs}ms). Ctrl-C to stop.
1520
1810
  `);
1521
1811
  await new Promise((resolve) => {
1522
1812
  const stop = () => {
1523
- void Promise.all(watchers.map((w) => w.close())).then(() => resolve());
1813
+ if (registryReloadTimer != null) clearTimeout(registryReloadTimer);
1814
+ void Promise.all([registryWatcher.close(), watchSet.stopAll()]).then(() => resolve());
1524
1815
  };
1525
1816
  process.once("SIGINT", stop);
1526
1817
  process.once("SIGTERM", stop);
@@ -1528,25 +1819,17 @@ function register6(program, deps) {
1528
1819
  });
1529
1820
  }
1530
1821
 
1531
- // src/program.ts
1532
- function stripCommanderErrorPrefix(msg) {
1533
- return msg.startsWith("error: ") ? msg.slice("error: ".length) : msg;
1534
- }
1535
- function buildProgram(deps) {
1536
- const writeErr = (_msg) => {
1537
- };
1538
- const program = new Command("nolto").version(deps.version, "-V, --version", "Print version number").exitOverride().configureOutput({ writeErr }).description("Nolto CLI \u2014 sync repository roadmaps with Nolto.").option("--token <value>", "API token (overrides env/file)").option("--base-url <url>", "Nolto base URL (default: https://nolto.app)").option("--project <projectId>", "Default project ID").option("--json", "Output as JSON");
1539
- register2(program, deps);
1540
- register3(program, deps);
1541
- register4(program, deps);
1542
- register(program, deps);
1543
- register5(program, deps);
1544
- register6(program, deps);
1545
- return program;
1546
- }
1822
+ // src/update-cli.ts
1823
+ import { execFile } from "child_process";
1824
+ import { existsSync as existsSync5 } from "fs";
1825
+ import { realpath } from "fs/promises";
1826
+ import { createRequire as createRequire2 } from "module";
1827
+ import path12 from "path";
1828
+ import { fileURLToPath as fileURLToPath3 } from "url";
1829
+ import { promisify } from "util";
1547
1830
 
1548
1831
  // src/update-notifier.ts
1549
- import { readFile as readFile6, writeFile as writeFile5, mkdir as mkdir5 } from "fs/promises";
1832
+ import { readFile as readFile6, writeFile as writeFile5, mkdir as mkdir7 } from "fs/promises";
1550
1833
  import https from "https";
1551
1834
  import path11 from "path";
1552
1835
  var PACKAGE = "@nolto/cli";
@@ -1566,17 +1849,17 @@ function isNewerVersion(latest, current) {
1566
1849
  }
1567
1850
  function formatUpdateNotice(latest, current) {
1568
1851
  return `
1569
- Update available: ${current} \u2192 ${latest} \xB7 npm i -g @nolto/cli@latest
1852
+ Update available: ${current} \u2192 ${latest} \xB7 run \`nolto update\`
1570
1853
  `;
1571
1854
  }
1572
1855
  function isDisabled(env) {
1573
1856
  return env["NO_UPDATE_NOTIFIER"] === "1" || env["NODE_ENV"] === "test" || Boolean(env["CI"]);
1574
1857
  }
1575
- function fetchLatestFromRegistry() {
1858
+ function fetchLatestFromRegistry(timeoutMs = REQUEST_TIMEOUT_MS) {
1576
1859
  return new Promise((resolve) => {
1577
1860
  const req = https.get(
1578
1861
  `https://registry.npmjs.org/${PACKAGE}/latest`,
1579
- { timeout: REQUEST_TIMEOUT_MS, headers: { accept: "application/json" } },
1862
+ { timeout: timeoutMs, headers: { accept: "application/json" } },
1580
1863
  (res) => {
1581
1864
  if (res.statusCode !== 200) {
1582
1865
  res.resume();
@@ -1603,12 +1886,15 @@ function fetchLatestFromRegistry() {
1603
1886
  req.on("error", () => resolve(null));
1604
1887
  });
1605
1888
  }
1889
+ async function writeUpdateCache(cachePath, now, latest) {
1890
+ await mkdir7(path11.dirname(cachePath), { recursive: true });
1891
+ const payload = { checkedAt: now, latest };
1892
+ await writeFile5(cachePath, JSON.stringify(payload), { mode: 384 });
1893
+ }
1606
1894
  async function refreshCache(cachePath, now, fetchLatest) {
1607
1895
  const latest = await fetchLatest();
1608
1896
  if (!latest) return;
1609
- await mkdir5(path11.dirname(cachePath), { recursive: true }).catch(() => void 0);
1610
- const payload = { checkedAt: now, latest };
1611
- await writeFile5(cachePath, JSON.stringify(payload), { mode: 384 }).catch(() => void 0);
1897
+ await writeUpdateCache(cachePath, now, latest).catch(() => void 0);
1612
1898
  }
1613
1899
  async function checkForUpdate(opts) {
1614
1900
  if (isDisabled(opts.env)) return null;
@@ -1642,12 +1928,197 @@ async function notifyUpdate(opts) {
1642
1928
  }
1643
1929
  }
1644
1930
 
1931
+ // src/update-cli.ts
1932
+ var PACKAGE2 = "@nolto/cli";
1933
+ var UPDATE_CACHE_FILE = "update-check.json";
1934
+ var UPDATE_REQUEST_TIMEOUT_MS = 1e4;
1935
+ function isNpxPath(scriptPath) {
1936
+ return /[\\/]_npx[\\/]/.test(scriptPath);
1937
+ }
1938
+ function isInsideGlobalPackage(scriptPath, globalRoot, platform) {
1939
+ if (globalRoot.length === 0) return false;
1940
+ const normalize = (value) => value.replaceAll("\\", "/").replace(/\/+$/, "");
1941
+ const normalizedScript = normalize(scriptPath);
1942
+ const packagePrefix = `${normalize(globalRoot)}/${PACKAGE2}/`;
1943
+ if (platform === "win32") {
1944
+ return normalizedScript.toLowerCase().startsWith(packagePrefix.toLowerCase());
1945
+ }
1946
+ return normalizedScript.startsWith(packagePrefix);
1947
+ }
1948
+ function notGlobalError(scriptPath) {
1949
+ return new CliError(
1950
+ `${PACKAGE2} is not installed as an npm global package (running from ${scriptPath}).`,
1951
+ 2,
1952
+ `Update it the same way it was installed, or install globally: npm i -g ${PACKAGE2}`
1953
+ );
1954
+ }
1955
+ function stderrTail(stderr) {
1956
+ return stderr.trim().slice(-500);
1957
+ }
1958
+ async function updateCliWith(deps) {
1959
+ if (isNpxPath(deps.scriptPath)) {
1960
+ throw new CliError(
1961
+ `Cannot self-update an npx invocation. Install globally: npm i -g ${PACKAGE2}`,
1962
+ 2
1963
+ );
1964
+ }
1965
+ const rootResult = await deps.exec(["npm", "root", "-g"]);
1966
+ const reportedRoot = rootResult.stdout.trim();
1967
+ let globalRoot;
1968
+ try {
1969
+ globalRoot = rootResult.code === 0 && reportedRoot.length > 0 ? await deps.realpath(reportedRoot) : "";
1970
+ } catch {
1971
+ globalRoot = "";
1972
+ }
1973
+ if (!isInsideGlobalPackage(deps.scriptPath, globalRoot, deps.platform)) {
1974
+ throw notGlobalError(deps.scriptPath);
1975
+ }
1976
+ const latest = await deps.fetchLatest();
1977
+ if (latest == null) {
1978
+ throw new CliError(
1979
+ "Could not reach the npm registry to check for the latest @nolto/cli version.",
1980
+ 5
1981
+ );
1982
+ }
1983
+ if (!isNewerVersion(latest, deps.currentVersion)) {
1984
+ deps.log(`Already up to date (${PACKAGE2} ${deps.currentVersion}).`);
1985
+ return {
1986
+ status: "up-to-date",
1987
+ from: deps.currentVersion,
1988
+ to: deps.currentVersion,
1989
+ watchService: "not-installed"
1990
+ };
1991
+ }
1992
+ const installResult = await deps.exec([
1993
+ "npm",
1994
+ "install",
1995
+ "-g",
1996
+ `${PACKAGE2}@${latest}`
1997
+ ]);
1998
+ if (installResult.code !== 0) {
1999
+ const detail = stderrTail(installResult.stderr);
2000
+ throw new CliError(
2001
+ `Could not update ${PACKAGE2}${detail.length > 0 ? `: ${detail}` : "."}`,
2002
+ 5,
2003
+ /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
2004
+ );
2005
+ }
2006
+ deps.log(`Updated ${PACKAGE2} ${deps.currentVersion} \u2192 ${latest}.`);
2007
+ await deps.writeCache(
2008
+ path12.join(deps.configDir, UPDATE_CACHE_FILE),
2009
+ deps.now,
2010
+ latest
2011
+ ).catch(() => void 0);
2012
+ let watchService = "not-installed";
2013
+ const unitPath = getUnitPath(deps.env);
2014
+ if (deps.platform === "linux" && deps.unitExists(unitPath)) {
2015
+ const restartResult = await deps.exec([
2016
+ "systemctl",
2017
+ "--user",
2018
+ "restart",
2019
+ "nolto-watch"
2020
+ ]);
2021
+ if (restartResult.code === 0) {
2022
+ watchService = "restarted";
2023
+ deps.log("Restarted nolto-watch service.");
2024
+ } else {
2025
+ watchService = "restart-failed";
2026
+ deps.warn(
2027
+ "Could not restart nolto-watch automatically. Run manually:\n systemctl --user restart nolto-watch"
2028
+ );
2029
+ }
2030
+ }
2031
+ return {
2032
+ status: "updated",
2033
+ from: deps.currentVersion,
2034
+ to: latest,
2035
+ watchService
2036
+ };
2037
+ }
2038
+ function getCurrentVersion() {
2039
+ const dirname = path12.dirname(fileURLToPath3(import.meta.url));
2040
+ const require3 = createRequire2(import.meta.url);
2041
+ try {
2042
+ const pkg = require3(path12.resolve(dirname, "../package.json"));
2043
+ return pkg.version ?? "0.0.0";
2044
+ } catch {
2045
+ return "0.0.0";
2046
+ }
2047
+ }
2048
+ async function updateCli(opts = {}) {
2049
+ const execFileAsync = promisify(execFile);
2050
+ const scriptPath = await realpath(process.argv[1] ?? "");
2051
+ return updateCliWith({
2052
+ currentVersion: getCurrentVersion(),
2053
+ scriptPath,
2054
+ platform: process.platform,
2055
+ env: process.env,
2056
+ configDir: getConfigDir(process.env),
2057
+ now: Date.now(),
2058
+ exec: async (cmd) => {
2059
+ try {
2060
+ const result = await execFileAsync(cmd[0], cmd.slice(1), { encoding: "utf8" });
2061
+ return { code: 0, stdout: String(result.stdout), stderr: String(result.stderr) };
2062
+ } catch (err) {
2063
+ const e = err;
2064
+ return {
2065
+ code: typeof e.code === "number" ? e.code : 1,
2066
+ stdout: e.stdout ?? "",
2067
+ stderr: e.stderr ?? String(err)
2068
+ };
2069
+ }
2070
+ },
2071
+ realpath,
2072
+ fetchLatest: () => fetchLatestFromRegistry(UPDATE_REQUEST_TIMEOUT_MS),
2073
+ writeCache: writeUpdateCache,
2074
+ unitExists: existsSync5,
2075
+ log: opts.quiet === true ? () => void 0 : (line) => process.stdout.write(line + "\n"),
2076
+ warn: (line) => process.stderr.write("Warning: " + line + "\n")
2077
+ });
2078
+ }
2079
+
2080
+ // src/commands/update.ts
2081
+ function register7(program, deps) {
2082
+ program.command("update").description("Update @nolto/cli to the latest version and restart the watch service if installed").action(async () => {
2083
+ const mode2 = deps.output.mode;
2084
+ const result = await updateCli({ quiet: mode2 === "json" });
2085
+ if (mode2 === "json") {
2086
+ printResult(result, mode2);
2087
+ }
2088
+ });
2089
+ }
2090
+
2091
+ // src/program.ts
2092
+ function stripCommanderErrorPrefix(msg) {
2093
+ return msg.startsWith("error: ") ? msg.slice("error: ".length) : msg;
2094
+ }
2095
+ function buildProgram(deps) {
2096
+ const writeErr = (_msg) => {
2097
+ };
2098
+ const program = new Command("nolto").version(deps.version, "-V, --version", "Print version number").exitOverride().configureOutput({ writeErr }).description("Nolto CLI \u2014 sync repository roadmaps with Nolto.").option("--token <value>", "API token (overrides env/file)").option("--base-url <url>", "Nolto base URL (default: https://nolto.app)").option("--project <projectId>", "Default project ID").option("--json", "Output as JSON");
2099
+ register2(program, deps);
2100
+ register3(program, deps);
2101
+ register4(program, deps);
2102
+ register(program, deps);
2103
+ register5(program, deps);
2104
+ register6(program, deps);
2105
+ register7(program, deps);
2106
+ program.hook("preAction", (_thisCommand, actionCommand) => {
2107
+ const bindingError = deps.repoBinding?.error;
2108
+ const bindingExemptCommands = ["init", "link", "update"];
2109
+ if (bindingError != null && !bindingExemptCommands.includes(actionCommand.name())) {
2110
+ throw bindingError;
2111
+ }
2112
+ });
2113
+ return program;
2114
+ }
2115
+
1645
2116
  // src/index.ts
1646
- var __dirname3 = path12.dirname(fileURLToPath3(import.meta.url));
1647
- var require2 = createRequire2(import.meta.url);
2117
+ var __dirname3 = path13.dirname(fileURLToPath4(import.meta.url));
2118
+ var require2 = createRequire3(import.meta.url);
1648
2119
  function getVersion() {
1649
2120
  try {
1650
- const pkgPath = path12.resolve(__dirname3, "../package.json");
2121
+ const pkgPath = path13.resolve(__dirname3, "../package.json");
1651
2122
  const pkg = require2(pkgPath);
1652
2123
  return pkg.version ?? "0.0.0";
1653
2124
  } catch {
@@ -1681,19 +2152,21 @@ async function main() {
1681
2152
  }
1682
2153
  }
1683
2154
  const projectBindingPath = findRepoBindingFile({ env: process.env, cwd: process.cwd() });
1684
- const repoBinding = projectBindingPath != null ? await loadRepoBinding(projectBindingPath).catch((err) => {
2155
+ const repoBinding = projectBindingPath != null ? await loadRepoBinding(projectBindingPath).then((binding) => ({
2156
+ binding,
2157
+ path: projectBindingPath,
2158
+ error: null
2159
+ })).catch((err) => {
1685
2160
  if (err instanceof CliError) {
1686
- printError(err, mode);
1687
- process.exitCode = err.exitCode;
1688
- process.exit();
2161
+ return { binding: null, path: projectBindingPath, error: err };
1689
2162
  }
1690
2163
  throw err;
1691
- }) : null;
2164
+ }) : { binding: null, path: null, error: null };
1692
2165
  const settings = resolveSettings({
1693
2166
  flags: { token: flagToken, baseUrl: flagBaseUrl, project: flagProject },
1694
2167
  env: process.env,
1695
2168
  file: configFile,
1696
- repoBinding
2169
+ repoBinding: repoBinding.binding
1697
2170
  });
1698
2171
  const version = getVersion();
1699
2172
  const http = createHttpClient({
@@ -1707,7 +2180,7 @@ async function main() {
1707
2180
  output: { mode },
1708
2181
  version,
1709
2182
  configPath,
1710
- projectBindingPath
2183
+ repoBinding
1711
2184
  });
1712
2185
  await program.parseAsync(process.argv);
1713
2186
  await notifyUpdate({ current: version, env: process.env, isJson: mode === "json", now: Date.now() });
@@ -1,16 +1,16 @@
1
1
  ---
2
2
  name: roadmap-progress
3
- description: Maintain a local project's `.roadmap/roadmap.json` as work starts, completes, becomes blocked, or is replanned. Use when Codex or Claude plans project phases and tasks, begins or finishes an implementation task, reports project progress, records a blocker, or needs to keep the Roadmap Viewer current. Do not mark work done without verification. Attach plan documents to tasks with the plan command.
3
+ description: Maintain a local project's `.nolto/roadmaps/<slug>.json` as work starts, completes, becomes blocked, or is replanned. Use when Codex or Claude plans project phases and tasks, begins or finishes an implementation task, reports project progress, records a blocker, or needs to keep the Roadmap Viewer current. Do not mark work done without verification. Attach plan documents to tasks with the plan command.
4
4
  ---
5
5
 
6
6
  # Roadmap Progress
7
7
 
8
- Keep `.roadmap/roadmap.json` synchronized with verified repository work. Treat the file as the source of truth for progress reporting.
8
+ Keep `.nolto/roadmaps/<slug>.json` synchronized with verified repository work. Treat the file as the source of truth for progress reporting.
9
9
 
10
10
  ## Resolve the roadmap
11
11
 
12
12
  1. Use the roadmap named by the user when provided.
13
- 2. Otherwise locate `.roadmap/roadmap.json` from the current directory upward.
13
+ 2. Otherwise locate `.nolto/roadmaps/*.json` from the current directory upward. If exactly one exists, use it. If multiple exist, ask the user to choose one and pass it with `--file <path>`.
14
14
  3. If no roadmap exists and the user asked to plan or initialize the project, create one using [references/schema.md](references/schema.md). Otherwise report that it is missing; do not invent project scope.
15
15
  4. Read [references/schema.md](references/schema.md) before creating phases or tasks, changing IDs, or repairing validation errors.
16
16
 
@@ -40,7 +40,7 @@ Edit phases and tasks directly while preserving stable IDs. Add dependencies onl
40
40
 
41
41
  When an implementation plan exists as a markdown file in the repository,
42
42
  link it to its task: `roadmap.mjs plan <task-id> --path docs/plans/feature.md`.
43
- Paths are repository-relative (the repository root is the parent of `.roadmap/`).
43
+ Paths are repository-relative (the repository root is the parent of `.nolto/`).
44
44
  The command warns if the file does not exist yet; create the plan file first
45
45
  when possible.
46
46
 
@@ -1,6 +1,6 @@
1
1
  # Roadmap schema
2
2
 
3
- Use this reference when creating or structurally editing `.roadmap/roadmap.json`.
3
+ Use this reference when creating or structurally editing `.nolto/roadmaps/<slug>.json`.
4
4
 
5
5
  ## Shape
6
6
 
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { access, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { access, readFile, readdir, rename, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
5
  const STATUSES = new Set(["todo", "in-progress", "done", "blocked"]);
@@ -27,19 +27,53 @@ function parseArguments(argv) {
27
27
 
28
28
  async function findRoadmap(explicitPath) {
29
29
  if (explicitPath) return path.resolve(explicitPath);
30
+
31
+ const directories = [];
30
32
  let directory = process.cwd();
31
33
  while (true) {
32
- const candidate = path.join(directory, ".roadmap", "roadmap.json");
34
+ directories.push(directory);
35
+ const roadmapsDirectory = path.join(directory, ".nolto", "roadmaps");
33
36
  try {
34
- await access(candidate);
35
- return candidate;
37
+ const candidates = (await readdir(roadmapsDirectory, { withFileTypes: true }))
38
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
39
+ .map((entry) => path.join(roadmapsDirectory, entry.name))
40
+ .sort();
41
+ if (candidates.length === 1) return candidates[0];
42
+ if (candidates.length > 1) {
43
+ throw new Error(
44
+ `Multiple roadmaps found; pass --file <path> to choose one:\n ${candidates.join("\n ")}`
45
+ );
46
+ }
47
+ } catch (error) {
48
+ if (error?.code !== "ENOENT") throw error;
49
+ }
50
+ const parent = path.dirname(directory);
51
+ if (parent === directory) break;
52
+ directory = parent;
53
+ }
54
+
55
+ for (const candidateRoot of directories) {
56
+ const legacyCandidate = path.join(candidateRoot, ".roadmap", "roadmap.json");
57
+ try {
58
+ await access(legacyCandidate);
59
+ console.warn(
60
+ `WARN deprecated roadmap path ${legacyCandidate}; migrate it to .nolto/roadmaps/<slug>.json`
61
+ );
62
+ return legacyCandidate;
36
63
  } catch {
37
- const parent = path.dirname(directory);
38
- if (parent === directory) break;
39
- directory = parent;
64
+ // Continue walking upward.
40
65
  }
41
66
  }
42
- throw new Error("No .roadmap/roadmap.json found. Pass --file <path>.");
67
+ throw new Error("No .nolto/roadmaps/*.json found. Pass --file <path>.");
68
+ }
69
+
70
+ function repoRootForRoadmap(filePath) {
71
+ const parent = path.dirname(filePath);
72
+ if (path.basename(parent) === "roadmaps" && path.basename(path.dirname(parent)) === ".nolto") {
73
+ return path.dirname(path.dirname(parent));
74
+ }
75
+ if (path.basename(parent) === ".roadmap") return path.dirname(parent);
76
+ return path.dirname(filePath);
43
77
  }
44
78
 
45
79
  function localIsoNow() {
@@ -228,7 +262,7 @@ try {
228
262
  task.plan = options.path;
229
263
  roadmap.schemaVersion = 2;
230
264
  roadmap.updatedAt = localIsoNow();
231
- const repoRoot = path.dirname(path.dirname(filePath));
265
+ const repoRoot = repoRootForRoadmap(filePath);
232
266
  try {
233
267
  await access(path.join(repoRoot, options.path));
234
268
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nolto/cli",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "CLI for syncing repository roadmaps with Nolto.",
5
5
  "license": "MIT",
6
6
  "type": "module",