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