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