@nolto/cli 0.6.0 → 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 `.
|
|
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
|
|
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 `.
|
|
61
|
-
|
|
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
|
|
5
|
-
import { fileURLToPath as
|
|
6
|
-
import
|
|
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,
|
|
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
|
-
|
|
50
|
+
opts.serverMessage ?? opts.serverError ?? "Forbidden",
|
|
42
51
|
3,
|
|
43
|
-
"
|
|
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,
|
|
280
|
-
if (!
|
|
281
|
-
throw new CliError(`HTTP client path must start with /api/, got: ${
|
|
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}${
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
io.
|
|
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
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
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 =
|
|
1301
|
-
|
|
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 .
|
|
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:
|
|
1437
|
-
const { execFile } = await import("child_process");
|
|
1438
|
-
const { promisify } = await import("util");
|
|
1439
|
-
const execFileAsync =
|
|
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
|
|
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
|
|
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
|
|
1492
|
-
|
|
1493
|
-
const
|
|
1494
|
-
const watcher = chokidar.watch([
|
|
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
|
-
|
|
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/
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
}
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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 =
|
|
1647
|
-
var require2 =
|
|
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 =
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
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 `.
|
|
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 `.
|
|
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 `.
|
|
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 `.
|
|
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,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
|
-
|
|
34
|
+
directories.push(directory);
|
|
35
|
+
const roadmapsDirectory = path.join(directory, ".nolto", "roadmaps");
|
|
33
36
|
try {
|
|
34
|
-
await
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
if (parent === directory) break;
|
|
39
|
-
directory = parent;
|
|
64
|
+
// Continue walking upward.
|
|
40
65
|
}
|
|
41
66
|
}
|
|
42
|
-
throw new Error("No .
|
|
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 =
|
|
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.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "CLI for syncing repository roadmaps with Nolto.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -36,12 +36,12 @@
|
|
|
36
36
|
"prepublishOnly": "npm run typecheck && npm test && npm run build"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@nolto/roadmap-schema": "0.1.0",
|
|
40
39
|
"chokidar": "^4.0.3",
|
|
41
40
|
"commander": "^14.0.0",
|
|
42
41
|
"zod": "^3.24.1"
|
|
43
42
|
},
|
|
44
43
|
"devDependencies": {
|
|
44
|
+
"@nolto/roadmap-schema": "0.1.0",
|
|
45
45
|
"@types/node": "^22.0.0",
|
|
46
46
|
"tsup": "^8.3.5",
|
|
47
47
|
"typescript": "^5.7.2",
|