@nolto/cli 0.8.0 → 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 +39 -2
- package/dist/index.js +964 -236
- 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
|
|
@@ -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}`
|
|
@@ -377,10 +377,12 @@ import { Command } from "commander";
|
|
|
377
377
|
import readline from "readline/promises";
|
|
378
378
|
import { createRequire } from "module";
|
|
379
379
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
380
|
-
import
|
|
380
|
+
import os3 from "os";
|
|
381
|
+
import path9 from "path";
|
|
381
382
|
import fs from "fs";
|
|
382
383
|
|
|
383
384
|
// src/commands/link.ts
|
|
385
|
+
import os2 from "os";
|
|
384
386
|
import path5 from "path";
|
|
385
387
|
import { statSync as statSync2 } from "fs";
|
|
386
388
|
|
|
@@ -517,12 +519,12 @@ function normalizeRemote(raw) {
|
|
|
517
519
|
const firstSlash = s.indexOf("/");
|
|
518
520
|
if (firstSlash <= 0) return null;
|
|
519
521
|
let host = s.slice(0, firstSlash).toLowerCase();
|
|
520
|
-
let
|
|
522
|
+
let path20 = s.slice(firstSlash + 1);
|
|
521
523
|
if (hadScheme) host = host.replace(/:\d+$/, "");
|
|
522
|
-
|
|
523
|
-
if (
|
|
524
|
-
if (HOSTED_LOWERCASE_PATH.has(host))
|
|
525
|
-
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}`;
|
|
526
528
|
}
|
|
527
529
|
|
|
528
530
|
// ../roadmap-schema/src/index.ts
|
|
@@ -771,6 +773,9 @@ function findRepoRoot(startDir, hasGit = dirHasGit) {
|
|
|
771
773
|
}
|
|
772
774
|
return { root: startDir, foundGit: false };
|
|
773
775
|
}
|
|
776
|
+
function isHomeDirectory(dir, home = os2.homedir()) {
|
|
777
|
+
return path5.resolve(dir) === path5.resolve(home);
|
|
778
|
+
}
|
|
774
779
|
async function handleShow(deps, projectBindingPath, mode2) {
|
|
775
780
|
const startDir = resolveStartDir(process.env, process.cwd());
|
|
776
781
|
const root = findRepoRoot(startDir).root;
|
|
@@ -831,10 +836,10 @@ async function handleRebind(deps, projectId, root, mode2) {
|
|
|
831
836
|
}
|
|
832
837
|
}
|
|
833
838
|
async function handleUnlink(projectBindingPath, mode2) {
|
|
834
|
-
const { readFile:
|
|
839
|
+
const { readFile: readFile12, writeFile: writeFile10, chmod: chmod2 } = await import("fs/promises");
|
|
835
840
|
let existing = {};
|
|
836
841
|
try {
|
|
837
|
-
const raw = await
|
|
842
|
+
const raw = await readFile12(projectBindingPath, "utf8");
|
|
838
843
|
const parsed = JSON.parse(raw);
|
|
839
844
|
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
840
845
|
throw new CliError(
|
|
@@ -849,7 +854,7 @@ async function handleUnlink(projectBindingPath, mode2) {
|
|
|
849
854
|
}
|
|
850
855
|
const { projectId: _removed, ...rest } = existing;
|
|
851
856
|
void _removed;
|
|
852
|
-
await
|
|
857
|
+
await writeFile10(projectBindingPath, JSON.stringify(rest, null, 2) + "\n", { mode: 420 });
|
|
853
858
|
await chmod2(projectBindingPath, 420);
|
|
854
859
|
if (mode2 === "json") {
|
|
855
860
|
printResult({ unlinked: true, projectBindingPath }, mode2);
|
|
@@ -906,12 +911,17 @@ Proceeding anyway \u2014 verify the ID is correct.
|
|
|
906
911
|
await writeRepoBinding(root, projectId);
|
|
907
912
|
const writtenPath = path5.join(root, "nolto.json");
|
|
908
913
|
let registryAdded = false;
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
+
if (isHomeDirectory(root)) {
|
|
915
|
+
process.stderr.write(`Warning: refusing to add home directory ${root} to the watch registry.
|
|
916
|
+
`);
|
|
917
|
+
} else {
|
|
918
|
+
try {
|
|
919
|
+
registryAdded = (await addRepoToRegistry(getRegistryPath(process.env), root)).added;
|
|
920
|
+
} catch (err) {
|
|
921
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
922
|
+
process.stderr.write(`Warning: could not update watch registry: ${message}
|
|
914
923
|
`);
|
|
924
|
+
}
|
|
915
925
|
}
|
|
916
926
|
if (mode2 === "json") {
|
|
917
927
|
printResult({
|
|
@@ -1004,6 +1014,30 @@ function resolveSkillSourceDir() {
|
|
|
1004
1014
|
throw new Error("Bundled roadmap-progress skill not found. Reinstall @nolto/cli.");
|
|
1005
1015
|
}
|
|
1006
1016
|
var VERSION_MARKER = ".nolto-skill-version";
|
|
1017
|
+
async function checkSkillVersionDrift(repoRoot, cliVersion) {
|
|
1018
|
+
const skillDirs = [
|
|
1019
|
+
path6.join(repoRoot, ".claude", "skills", "roadmap-progress"),
|
|
1020
|
+
path6.join(repoRoot, ".agents", "skills", "roadmap-progress")
|
|
1021
|
+
];
|
|
1022
|
+
const drifted = [];
|
|
1023
|
+
for (const dir of skillDirs) {
|
|
1024
|
+
if (!existsSync(dir)) continue;
|
|
1025
|
+
let installed;
|
|
1026
|
+
try {
|
|
1027
|
+
const marker = (await readFile4(path6.join(dir, VERSION_MARKER), "utf8")).trim();
|
|
1028
|
+
installed = marker.length > 0 ? marker : null;
|
|
1029
|
+
} catch {
|
|
1030
|
+
installed = null;
|
|
1031
|
+
}
|
|
1032
|
+
if (installed !== cliVersion) drifted.push({ dir, installed });
|
|
1033
|
+
}
|
|
1034
|
+
return drifted;
|
|
1035
|
+
}
|
|
1036
|
+
function formatSkillVersionDriftWarning(repoRoot, cliVersion, drifted) {
|
|
1037
|
+
const dirs = drifted.map(({ dir }) => path6.relative(repoRoot, dir).split(path6.sep).join("/")).join(", ");
|
|
1038
|
+
const installed = [...new Set(drifted.map((entry) => entry.installed ?? "unknown"))].join(", ");
|
|
1039
|
+
return `roadmap-progress skill is outdated in ${dirs} (installed ${installed}, CLI ${cliVersion}). Run \`nolto init\` in this repo to update it.`;
|
|
1040
|
+
}
|
|
1007
1041
|
async function installSkill(args) {
|
|
1008
1042
|
const targetDir = path6.join(args.skillsParentDir, "roadmap-progress");
|
|
1009
1043
|
const markerPath = path6.join(targetDir, VERSION_MARKER);
|
|
@@ -1069,14 +1103,55 @@ async function scaffoldRoadmap(args) {
|
|
|
1069
1103
|
return { created: true, path: filePath };
|
|
1070
1104
|
}
|
|
1071
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
|
+
|
|
1072
1147
|
// src/commands/init.ts
|
|
1073
|
-
var __dirname2 =
|
|
1148
|
+
var __dirname2 = path9.dirname(fileURLToPath2(import.meta.url));
|
|
1074
1149
|
var _require = createRequire(import.meta.url);
|
|
1075
1150
|
function getCliVersion() {
|
|
1076
1151
|
const candidates = [
|
|
1077
|
-
|
|
1152
|
+
path9.resolve(__dirname2, "../package.json"),
|
|
1078
1153
|
// bundled: dist/../package.json
|
|
1079
|
-
|
|
1154
|
+
path9.resolve(__dirname2, "../../package.json")
|
|
1080
1155
|
// source: src/commands/../../package.json
|
|
1081
1156
|
];
|
|
1082
1157
|
for (const pkgPath of candidates) {
|
|
@@ -1090,161 +1165,219 @@ function getCliVersion() {
|
|
|
1090
1165
|
}
|
|
1091
1166
|
return "0.0.0";
|
|
1092
1167
|
}
|
|
1168
|
+
async function pickProject(rl, http, projects, promptText) {
|
|
1169
|
+
if (projects.length > 0) {
|
|
1170
|
+
process.stdout.write("\nProjects:\n");
|
|
1171
|
+
projects.forEach((project, index) => {
|
|
1172
|
+
process.stdout.write(` (${index + 1}) ${project.name} \u2014 ${project.id}
|
|
1173
|
+
`);
|
|
1174
|
+
});
|
|
1175
|
+
} else {
|
|
1176
|
+
process.stdout.write("\nNo projects yet.\n");
|
|
1177
|
+
}
|
|
1178
|
+
const pick = await rl.question(promptText);
|
|
1179
|
+
const trimmedPick = pick.trim().toLowerCase();
|
|
1180
|
+
if (trimmedPick === "c") {
|
|
1181
|
+
const name = await rl.question("New project name: ");
|
|
1182
|
+
if (name.trim().length === 0) {
|
|
1183
|
+
throw new CliError("Project name is required.", 2);
|
|
1184
|
+
}
|
|
1185
|
+
const created = await http.post(
|
|
1186
|
+
"/api/projects",
|
|
1187
|
+
{ name: name.trim() }
|
|
1188
|
+
);
|
|
1189
|
+
if (created.project?.id == null) {
|
|
1190
|
+
throw new CliError("Project API did not return a project id.", 2);
|
|
1191
|
+
}
|
|
1192
|
+
const project = {
|
|
1193
|
+
id: created.project.id,
|
|
1194
|
+
name: created.project.name ?? name.trim()
|
|
1195
|
+
};
|
|
1196
|
+
process.stdout.write(`Created project ${project.name} (${project.id})
|
|
1197
|
+
`);
|
|
1198
|
+
return project;
|
|
1199
|
+
}
|
|
1200
|
+
const num = parseInt(trimmedPick, 10);
|
|
1201
|
+
if (!isNaN(num) && num >= 1 && num <= projects.length) {
|
|
1202
|
+
return projects[num - 1];
|
|
1203
|
+
}
|
|
1204
|
+
return void 0;
|
|
1205
|
+
}
|
|
1093
1206
|
function register2(program, deps) {
|
|
1094
1207
|
program.command("init").description("Interactive setup: configure token, base URL, and default project.").option("--force", "Overwrite existing config without prompting").action(async (opts) => {
|
|
1095
1208
|
const configPath = deps.configPath;
|
|
1096
|
-
if (!opts.force) {
|
|
1097
|
-
let existing = null;
|
|
1098
|
-
try {
|
|
1099
|
-
existing = await loadConfigFile(configPath);
|
|
1100
|
-
} catch {
|
|
1101
|
-
}
|
|
1102
|
-
if (existing != null) {
|
|
1103
|
-
const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1104
|
-
try {
|
|
1105
|
-
const answer = await rl2.question(`Config already exists at ${configPath}. Overwrite? [y/N] `);
|
|
1106
|
-
if (answer.trim().toLowerCase() !== "y") {
|
|
1107
|
-
process.stdout.write("Cancelled.\n");
|
|
1108
|
-
return;
|
|
1109
|
-
}
|
|
1110
|
-
} finally {
|
|
1111
|
-
rl2.close();
|
|
1112
|
-
}
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
1209
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1116
|
-
let token = "";
|
|
1117
1210
|
try {
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
let projects = [];
|
|
1133
|
-
try {
|
|
1134
|
-
const result = await http.get("/api/projects");
|
|
1135
|
-
projects = Array.isArray(result.projects) ? result.projects : [];
|
|
1136
|
-
} catch (err) {
|
|
1137
|
-
if (err instanceof CliError && err.exitCode === 3) {
|
|
1138
|
-
throw new CliError(`Token rejected by ${baseUrl}`, 3, "Check that your token is valid and has not been revoked.");
|
|
1211
|
+
let configureGlobal = opts.force === true;
|
|
1212
|
+
if (!configureGlobal) {
|
|
1213
|
+
let existing = null;
|
|
1214
|
+
try {
|
|
1215
|
+
existing = await loadConfigFile(configPath);
|
|
1216
|
+
} catch {
|
|
1217
|
+
}
|
|
1218
|
+
if (existing == null || deps.settings.token == null) {
|
|
1219
|
+
configureGlobal = true;
|
|
1220
|
+
} else {
|
|
1221
|
+
const answer = await rl.question(
|
|
1222
|
+
`Config already exists at ${configPath}. Reconfigure token and base URL? [y/N] `
|
|
1223
|
+
);
|
|
1224
|
+
configureGlobal = answer.trim().toLowerCase() === "y";
|
|
1139
1225
|
}
|
|
1140
|
-
throw err;
|
|
1141
1226
|
}
|
|
1142
|
-
let
|
|
1227
|
+
let token = deps.settings.token ?? "";
|
|
1228
|
+
let baseUrl = deps.settings.baseUrl;
|
|
1229
|
+
let defaultProjectId = deps.settings.defaultProjectId;
|
|
1143
1230
|
let defaultProjectName;
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1231
|
+
let http;
|
|
1232
|
+
let projects;
|
|
1233
|
+
if (configureGlobal) {
|
|
1234
|
+
const rawUrl = await rl.question(`Base URL [${DEFAULT_BASE_URL}]: `);
|
|
1235
|
+
baseUrl = rawUrl.trim() || DEFAULT_BASE_URL;
|
|
1236
|
+
const currentToken = deps.settings.token;
|
|
1237
|
+
const tokenPrompt = currentToken != null ? "API token [press Enter to keep current token]: " : "API token: ";
|
|
1238
|
+
const enteredToken = await promptHidden(rl, tokenPrompt);
|
|
1239
|
+
token = enteredToken.length > 0 ? enteredToken : currentToken ?? "";
|
|
1240
|
+
if (token.length === 0) {
|
|
1241
|
+
throw new CliError("Token is required.", 2);
|
|
1242
|
+
}
|
|
1243
|
+
http = createHttpClient({
|
|
1244
|
+
baseUrl,
|
|
1245
|
+
token,
|
|
1246
|
+
version: getCliVersion()
|
|
1149
1247
|
});
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
throw new CliError("Project name is required.", 2);
|
|
1248
|
+
try {
|
|
1249
|
+
const result = await http.get("/api/projects");
|
|
1250
|
+
projects = Array.isArray(result.projects) ? result.projects : [];
|
|
1251
|
+
} catch (err) {
|
|
1252
|
+
if (err instanceof CliError && err.exitCode === 3) {
|
|
1253
|
+
throw new CliError(`Token rejected by ${baseUrl}`, 3, "Check that your token is valid and has not been revoked.");
|
|
1254
|
+
}
|
|
1255
|
+
throw err;
|
|
1159
1256
|
}
|
|
1160
|
-
const
|
|
1161
|
-
|
|
1162
|
-
|
|
1257
|
+
const selected = await pickProject(
|
|
1258
|
+
rl,
|
|
1259
|
+
http,
|
|
1260
|
+
projects,
|
|
1261
|
+
"Default project number, 'c' to create new (or Enter to skip): "
|
|
1163
1262
|
);
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
if (!isNaN(num) && num >= 1 && num <= projects.length) {
|
|
1174
|
-
defaultProjectId = projects[num - 1].id;
|
|
1175
|
-
defaultProjectName = projects[num - 1].name;
|
|
1176
|
-
}
|
|
1177
|
-
}
|
|
1178
|
-
await saveConfigFile(configPath, {
|
|
1179
|
-
token,
|
|
1180
|
-
baseUrl: baseUrl !== DEFAULT_BASE_URL ? baseUrl : void 0,
|
|
1181
|
-
defaultProjectId
|
|
1182
|
-
});
|
|
1183
|
-
const projectDisplay = defaultProjectId != null ? `${defaultProjectName ?? ""} (${defaultProjectId})` : "not set";
|
|
1184
|
-
process.stdout.write(`
|
|
1263
|
+
defaultProjectId = selected?.id;
|
|
1264
|
+
defaultProjectName = selected?.name;
|
|
1265
|
+
await saveConfigFile(configPath, {
|
|
1266
|
+
token,
|
|
1267
|
+
baseUrl: baseUrl !== DEFAULT_BASE_URL ? baseUrl : void 0,
|
|
1268
|
+
defaultProjectId
|
|
1269
|
+
});
|
|
1270
|
+
const projectDisplay = defaultProjectId != null ? `${defaultProjectName ?? ""} (${defaultProjectId})` : "not set";
|
|
1271
|
+
process.stdout.write(`
|
|
1185
1272
|
Saved ${configPath}
|
|
1186
1273
|
`);
|
|
1187
|
-
|
|
1274
|
+
process.stdout.write(`baseUrl: ${baseUrl}
|
|
1188
1275
|
`);
|
|
1189
|
-
|
|
1276
|
+
process.stdout.write(`token: ${maskToken(token)} (verified)
|
|
1190
1277
|
`);
|
|
1191
|
-
|
|
1278
|
+
process.stdout.write(`defaultProject: ${projectDisplay}
|
|
1192
1279
|
`);
|
|
1193
|
-
|
|
1194
|
-
const
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1280
|
+
} else {
|
|
1281
|
+
const projectDisplay = defaultProjectId ?? "not set";
|
|
1282
|
+
process.stdout.write(
|
|
1283
|
+
`Using existing config: baseUrl=${baseUrl}, token=${maskToken(token)}, defaultProject=${projectDisplay}
|
|
1284
|
+
`
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
const startDir = resolveStartDir(process.env, process.cwd());
|
|
1288
|
+
const { root, foundGit } = findRepoRoot(startDir);
|
|
1289
|
+
if (!foundGit) {
|
|
1290
|
+
process.stdout.write("\nNo git repository found here \u2014 skipped repo setup. Run `nolto init` inside a repo to set up roadmap sync.\n");
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
if (isHomeDirectory(root)) {
|
|
1294
|
+
process.stderr.write(
|
|
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.
|
|
1296
|
+
`
|
|
1297
|
+
);
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
const existingBinding = deps.repoBinding?.error == null ? deps.repoBinding?.binding ?? null : null;
|
|
1301
|
+
let repoProject = existingBinding != null ? { id: existingBinding.projectId, name: path9.basename(root) } : defaultProjectId != null ? { id: defaultProjectId, name: defaultProjectName ?? path9.basename(root) } : void 0;
|
|
1302
|
+
if (repoProject == null) {
|
|
1303
|
+
http ??= createHttpClient({ baseUrl, token, version: getCliVersion() });
|
|
1304
|
+
if (projects == null) {
|
|
1305
|
+
const result = await http.get("/api/projects");
|
|
1306
|
+
projects = Array.isArray(result.projects) ? result.projects : [];
|
|
1307
|
+
}
|
|
1308
|
+
repoProject = await pickProject(
|
|
1309
|
+
rl,
|
|
1310
|
+
http,
|
|
1311
|
+
projects,
|
|
1312
|
+
"Project number for this repository, 'c' to create new (or Enter to skip): "
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1315
|
+
if (repoProject == null) {
|
|
1316
|
+
process.stdout.write("Skipped repo setup (no project selected).\n");
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
const setup = await rl.question(`
|
|
1198
1320
|
Set up this repository (${root}) for roadmap sync? [Y/n] `);
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1321
|
+
if (setup.trim().toLowerCase() === "n") {
|
|
1322
|
+
process.stdout.write("Skipped repo setup.\n");
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
const bindingPath = deps.repoBinding?.path ?? path9.join(root, "nolto.json");
|
|
1326
|
+
if (existingBinding != null) {
|
|
1327
|
+
process.stdout.write(`binding: kept ${bindingPath} (${existingBinding.projectId})
|
|
1328
|
+
`);
|
|
1329
|
+
} else {
|
|
1330
|
+
if (deps.repoBinding?.error != null) {
|
|
1331
|
+
process.stderr.write(
|
|
1332
|
+
`Warning: existing nolto.json is invalid (${deps.repoBinding.error.message}). Overwriting it to repair.
|
|
1203
1333
|
`
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
await writeRepoBinding(root, repoProject.id);
|
|
1337
|
+
process.stdout.write(`binding: wrote ${path9.join(root, "nolto.json")}
|
|
1208
1338
|
`);
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1339
|
+
}
|
|
1340
|
+
const sourceDir = resolveSkillSourceDir();
|
|
1341
|
+
const version = getCliVersion();
|
|
1342
|
+
const claudeInstall = await installSkill({
|
|
1343
|
+
skillsParentDir: path9.join(root, ".claude", "skills"),
|
|
1344
|
+
sourceDir,
|
|
1345
|
+
version
|
|
1346
|
+
});
|
|
1347
|
+
process.stdout.write(`skill (claude): ${claudeInstall.action} ${claudeInstall.targetDir}
|
|
1217
1348
|
`);
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1349
|
+
const usesAgentsTooling = fs.existsSync(path9.join(root, ".agents")) || fs.existsSync(path9.join(root, ".codex")) || fs.existsSync(path9.join(root, "AGENTS.md"));
|
|
1350
|
+
if (usesAgentsTooling) {
|
|
1351
|
+
const agentsInstall = await installSkill({
|
|
1352
|
+
skillsParentDir: path9.join(root, ".agents", "skills"),
|
|
1353
|
+
sourceDir,
|
|
1354
|
+
version
|
|
1355
|
+
});
|
|
1356
|
+
process.stdout.write(`skill (agents): ${agentsInstall.action} ${agentsInstall.targetDir}
|
|
1226
1357
|
`);
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1358
|
+
}
|
|
1359
|
+
const scaffold = await scaffoldRoadmap({
|
|
1360
|
+
repoRoot: root,
|
|
1361
|
+
projectName: repoProject.name
|
|
1362
|
+
});
|
|
1363
|
+
process.stdout.write(
|
|
1364
|
+
scaffold.created ? `roadmap: created ${scaffold.path}
|
|
1234
1365
|
` : `roadmap: exists ${scaffold.path}
|
|
1235
1366
|
`
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1367
|
+
);
|
|
1368
|
+
const registryResult = await addRepoToRegistry(getRegistryPath(process.env), root);
|
|
1369
|
+
process.stdout.write(
|
|
1370
|
+
registryResult.added ? `watch registry: added ${root}
|
|
1240
1371
|
` : `watch registry: already registered
|
|
1241
1372
|
`
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
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
|
+
);
|
|
1248
1381
|
} finally {
|
|
1249
1382
|
rl.close();
|
|
1250
1383
|
}
|
|
@@ -1464,15 +1597,15 @@ function register4(program, deps) {
|
|
|
1464
1597
|
}
|
|
1465
1598
|
|
|
1466
1599
|
// src/commands/sync.ts
|
|
1467
|
-
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";
|
|
1468
1601
|
import { existsSync as existsSync3 } from "fs";
|
|
1469
1602
|
|
|
1470
1603
|
// src/sync-repo.ts
|
|
1471
|
-
import
|
|
1604
|
+
import path11 from "path";
|
|
1472
1605
|
|
|
1473
1606
|
// src/sync-core.ts
|
|
1474
1607
|
import { createHash } from "crypto";
|
|
1475
|
-
import
|
|
1608
|
+
import path10 from "path";
|
|
1476
1609
|
function sha256Hex(content) {
|
|
1477
1610
|
return "sha256:" + createHash("sha256").update(content, "utf8").digest("hex");
|
|
1478
1611
|
}
|
|
@@ -1493,10 +1626,10 @@ function collectPlanRefs(roadmap) {
|
|
|
1493
1626
|
}
|
|
1494
1627
|
return refs;
|
|
1495
1628
|
}
|
|
1496
|
-
async function loadValidRoadmap(filePath,
|
|
1629
|
+
async function loadValidRoadmap(filePath, readFile12) {
|
|
1497
1630
|
let raw;
|
|
1498
1631
|
try {
|
|
1499
|
-
raw = await
|
|
1632
|
+
raw = await readFile12(filePath);
|
|
1500
1633
|
} catch {
|
|
1501
1634
|
throw new CliError(`No roadmap found at ${filePath}. Run \`nolto init\` first.`, 2);
|
|
1502
1635
|
}
|
|
@@ -1519,7 +1652,7 @@ async function loadValidRoadmap(filePath, readFile8) {
|
|
|
1519
1652
|
async function buildSyncBody(args) {
|
|
1520
1653
|
const planDocuments = [];
|
|
1521
1654
|
for (const ref of collectPlanRefs(args.roadmap)) {
|
|
1522
|
-
const absolute =
|
|
1655
|
+
const absolute = path10.join(args.repoRoot, ref.path);
|
|
1523
1656
|
if (!args.deps.fileExists(absolute)) {
|
|
1524
1657
|
args.deps.warn(`plan file not found, skipping: ${ref.path}`);
|
|
1525
1658
|
continue;
|
|
@@ -1566,7 +1699,7 @@ async function listRoadmapFiles(roadmapsDir, io) {
|
|
|
1566
1699
|
}
|
|
1567
1700
|
}
|
|
1568
1701
|
async function migrateLegacyRoadmap(args) {
|
|
1569
|
-
const targetPath =
|
|
1702
|
+
const targetPath = path11.join(args.roadmapsDir, `${args.slug}.json`);
|
|
1570
1703
|
await args.io.mkdir(args.roadmapsDir);
|
|
1571
1704
|
try {
|
|
1572
1705
|
await args.io.rename(args.legacyPath, targetPath);
|
|
@@ -1575,7 +1708,7 @@ async function migrateLegacyRoadmap(args) {
|
|
|
1575
1708
|
await args.io.unlink(args.legacyPath);
|
|
1576
1709
|
}
|
|
1577
1710
|
try {
|
|
1578
|
-
await args.io.rmdir(
|
|
1711
|
+
await args.io.rmdir(path11.dirname(args.legacyPath));
|
|
1579
1712
|
} catch {
|
|
1580
1713
|
}
|
|
1581
1714
|
args.io.log(
|
|
@@ -1584,14 +1717,14 @@ async function migrateLegacyRoadmap(args) {
|
|
|
1584
1717
|
return `${args.slug}.json`;
|
|
1585
1718
|
}
|
|
1586
1719
|
async function syncRepo(args, io) {
|
|
1587
|
-
const bindingPath =
|
|
1720
|
+
const bindingPath = path11.join(args.root, "nolto.json");
|
|
1588
1721
|
const binding = await loadRepoBinding(bindingPath);
|
|
1589
1722
|
const projectId = binding?.projectId ?? args.defaultProjectId;
|
|
1590
1723
|
if (projectId == null) {
|
|
1591
1724
|
throw new CliError("No project binding. Run `nolto init` or `nolto link <projectId>`.", 2);
|
|
1592
1725
|
}
|
|
1593
|
-
const roadmapsDir =
|
|
1594
|
-
const legacyPath =
|
|
1726
|
+
const roadmapsDir = path11.join(args.root, ".nolto", "roadmaps");
|
|
1727
|
+
const legacyPath = path11.join(args.root, ".roadmap", "roadmap.json");
|
|
1595
1728
|
let roadmapFiles = await listRoadmapFiles(roadmapsDir, io);
|
|
1596
1729
|
if (roadmapFiles.length > 0) {
|
|
1597
1730
|
if (io.fileExists(legacyPath)) {
|
|
@@ -1600,7 +1733,13 @@ async function syncRepo(args, io) {
|
|
|
1600
1733
|
);
|
|
1601
1734
|
}
|
|
1602
1735
|
} else if (io.fileExists(legacyPath)) {
|
|
1603
|
-
|
|
1736
|
+
if (args.migrateLegacy !== true) {
|
|
1737
|
+
io.warn(
|
|
1738
|
+
"legacy .roadmap/roadmap.json found \u2014 run `nolto sync` once to migrate it to .nolto/roadmaps/ (watch does not migrate automatically)"
|
|
1739
|
+
);
|
|
1740
|
+
return { results: [], planAbsPaths: [] };
|
|
1741
|
+
}
|
|
1742
|
+
const migrationSlug = binding?.roadmapSlug ?? slugifyProjectId(path11.basename(args.root));
|
|
1604
1743
|
roadmapFiles = [
|
|
1605
1744
|
await migrateLegacyRoadmap({
|
|
1606
1745
|
slug: migrationSlug,
|
|
@@ -1624,14 +1763,14 @@ async function syncRepo(args, io) {
|
|
|
1624
1763
|
2
|
|
1625
1764
|
);
|
|
1626
1765
|
}
|
|
1627
|
-
const filePath =
|
|
1766
|
+
const filePath = path11.join(roadmapsDir, fileName);
|
|
1628
1767
|
return { slug, roadmap: await loadValidRoadmap(filePath, io.readFile) };
|
|
1629
1768
|
})
|
|
1630
1769
|
);
|
|
1631
1770
|
const planAbsPaths = /* @__PURE__ */ new Set();
|
|
1632
1771
|
for (const { roadmap } of roadmaps) {
|
|
1633
1772
|
for (const ref of collectPlanRefs(roadmap)) {
|
|
1634
|
-
planAbsPaths.add(
|
|
1773
|
+
planAbsPaths.add(path11.join(args.root, ref.path));
|
|
1635
1774
|
}
|
|
1636
1775
|
}
|
|
1637
1776
|
const results = [];
|
|
@@ -1659,10 +1798,13 @@ function register5(program, deps) {
|
|
|
1659
1798
|
throw new CliError("No git repository found. Run inside a repo set up with `nolto init`.", 2);
|
|
1660
1799
|
}
|
|
1661
1800
|
const http = deps.http;
|
|
1801
|
+
const warn = (line) => {
|
|
1802
|
+
process.stderr.write("Warning: " + line + "\n");
|
|
1803
|
+
};
|
|
1662
1804
|
const response = await syncRepo(
|
|
1663
|
-
{ root, defaultProjectId: deps.settings.defaultProjectId },
|
|
1805
|
+
{ root, defaultProjectId: deps.settings.defaultProjectId, migrateLegacy: true },
|
|
1664
1806
|
{
|
|
1665
|
-
readFile: (p) =>
|
|
1807
|
+
readFile: (p) => readFile6(p, "utf8"),
|
|
1666
1808
|
fileExists: (p) => existsSync3(p),
|
|
1667
1809
|
listDir: (p) => readdir2(p),
|
|
1668
1810
|
rename: rename2,
|
|
@@ -1673,16 +1815,28 @@ function register5(program, deps) {
|
|
|
1673
1815
|
repoIdentity: makeRepoIdentityResolver(deps),
|
|
1674
1816
|
http,
|
|
1675
1817
|
log: (line) => process.stdout.write(line + "\n"),
|
|
1676
|
-
warn
|
|
1818
|
+
warn
|
|
1677
1819
|
}
|
|
1678
1820
|
);
|
|
1679
|
-
let registryAdded = false;
|
|
1680
1821
|
try {
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1822
|
+
const drifted = await checkSkillVersionDrift(root, deps.version);
|
|
1823
|
+
if (drifted.length > 0) {
|
|
1824
|
+
warn(formatSkillVersionDriftWarning(root, deps.version, drifted));
|
|
1825
|
+
}
|
|
1826
|
+
} catch {
|
|
1827
|
+
}
|
|
1828
|
+
let registryAdded = false;
|
|
1829
|
+
if (isHomeDirectory(root)) {
|
|
1830
|
+
process.stderr.write(`Warning: refusing to add home directory ${root} to the watch registry.
|
|
1685
1831
|
`);
|
|
1832
|
+
} else {
|
|
1833
|
+
try {
|
|
1834
|
+
registryAdded = (await addRepoToRegistry(getRegistryPath(process.env), root)).added;
|
|
1835
|
+
} catch (err) {
|
|
1836
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1837
|
+
process.stderr.write(`Warning: could not update watch registry: ${message}
|
|
1838
|
+
`);
|
|
1839
|
+
}
|
|
1686
1840
|
}
|
|
1687
1841
|
if (deps.output.mode === "json") {
|
|
1688
1842
|
const { planAbsPaths: _planAbsPaths, ...publicResponse } = response;
|
|
@@ -1694,10 +1848,531 @@ function register5(program, deps) {
|
|
|
1694
1848
|
});
|
|
1695
1849
|
}
|
|
1696
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
|
+
|
|
1697
2372
|
// src/commands/watch.ts
|
|
1698
|
-
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";
|
|
1699
2374
|
import { existsSync as existsSync4 } from "fs";
|
|
1700
|
-
import
|
|
2375
|
+
import path16 from "path";
|
|
1701
2376
|
import chokidar from "chokidar";
|
|
1702
2377
|
|
|
1703
2378
|
// src/watch-core.ts
|
|
@@ -1803,8 +2478,8 @@ var RepoWatch = class {
|
|
|
1803
2478
|
};
|
|
1804
2479
|
|
|
1805
2480
|
// src/service-install.ts
|
|
1806
|
-
import
|
|
1807
|
-
import
|
|
2481
|
+
import path15 from "path";
|
|
2482
|
+
import os4 from "os";
|
|
1808
2483
|
function buildUnitFile(args) {
|
|
1809
2484
|
return [
|
|
1810
2485
|
"[Unit]",
|
|
@@ -1823,8 +2498,8 @@ function buildUnitFile(args) {
|
|
|
1823
2498
|
}
|
|
1824
2499
|
function getUnitPath(env) {
|
|
1825
2500
|
const xdg = env["XDG_CONFIG_HOME"];
|
|
1826
|
-
const base = xdg != null && xdg.length > 0 ? xdg :
|
|
1827
|
-
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");
|
|
1828
2503
|
}
|
|
1829
2504
|
async function installServiceWith(deps) {
|
|
1830
2505
|
if (deps.platform !== "linux") {
|
|
@@ -1835,7 +2510,7 @@ async function installServiceWith(deps) {
|
|
|
1835
2510
|
);
|
|
1836
2511
|
}
|
|
1837
2512
|
const unitPath = getUnitPath(deps.env);
|
|
1838
|
-
await deps.mkdir(
|
|
2513
|
+
await deps.mkdir(path15.dirname(unitPath));
|
|
1839
2514
|
await deps.writeFile(unitPath, buildUnitFile({ nodePath: deps.nodePath, scriptPath: deps.scriptPath }));
|
|
1840
2515
|
deps.log(`Wrote ${unitPath}`);
|
|
1841
2516
|
const reload = await deps.exec(["systemctl", "--user", "daemon-reload"]);
|
|
@@ -1849,18 +2524,18 @@ async function installServiceWith(deps) {
|
|
|
1849
2524
|
deps.log("Service nolto-watch enabled and started. Logs: journalctl --user -u nolto-watch -f");
|
|
1850
2525
|
}
|
|
1851
2526
|
async function installService() {
|
|
1852
|
-
const { writeFile:
|
|
1853
|
-
const { execFile:
|
|
1854
|
-
const { promisify:
|
|
1855
|
-
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);
|
|
1856
2531
|
await installServiceWith({
|
|
1857
2532
|
platform: process.platform,
|
|
1858
2533
|
env: process.env,
|
|
1859
2534
|
nodePath: process.execPath,
|
|
1860
|
-
scriptPath:
|
|
1861
|
-
writeFile: (p, content) =>
|
|
2535
|
+
scriptPath: path15.resolve(process.argv[1] ?? ""),
|
|
2536
|
+
writeFile: (p, content) => writeFile10(p, content, "utf8"),
|
|
1862
2537
|
mkdir: async (p) => {
|
|
1863
|
-
await
|
|
2538
|
+
await mkdir10(p, { recursive: true });
|
|
1864
2539
|
},
|
|
1865
2540
|
exec: async (cmd) => {
|
|
1866
2541
|
try {
|
|
@@ -1915,9 +2590,9 @@ async function uninstallServiceWith(deps) {
|
|
|
1915
2590
|
}
|
|
1916
2591
|
async function uninstallService() {
|
|
1917
2592
|
const { unlink: unlink4 } = await import("fs/promises");
|
|
1918
|
-
const { execFile:
|
|
1919
|
-
const { promisify:
|
|
1920
|
-
const execFileAsync =
|
|
2593
|
+
const { execFile: execFile4 } = await import("child_process");
|
|
2594
|
+
const { promisify: promisify3 } = await import("util");
|
|
2595
|
+
const execFileAsync = promisify3(execFile4);
|
|
1921
2596
|
await uninstallServiceWith({
|
|
1922
2597
|
platform: process.platform,
|
|
1923
2598
|
env: process.env,
|
|
@@ -1937,7 +2612,7 @@ async function uninstallService() {
|
|
|
1937
2612
|
}
|
|
1938
2613
|
|
|
1939
2614
|
// src/commands/watch.ts
|
|
1940
|
-
function
|
|
2615
|
+
function register8(program, deps) {
|
|
1941
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) => {
|
|
1942
2617
|
if (opts.installService && opts.uninstallService) {
|
|
1943
2618
|
throw new CliError("--install-service and --uninstall-service cannot be used together.", 2);
|
|
@@ -1974,27 +2649,31 @@ function register6(program, deps) {
|
|
|
1974
2649
|
token: deps.settings.token
|
|
1975
2650
|
});
|
|
1976
2651
|
const startRepo = (root) => {
|
|
1977
|
-
const roadmapsPath =
|
|
1978
|
-
const legacyRoadmapPath =
|
|
2652
|
+
const roadmapsPath = path16.join(root, ".nolto", "roadmaps");
|
|
2653
|
+
const legacyRoadmapPath = path16.join(root, ".roadmap", "roadmap.json");
|
|
2654
|
+
const warn = (line) => {
|
|
2655
|
+
process.stderr.write(`Warning: [${path16.basename(root)}] ${line}
|
|
2656
|
+
`);
|
|
2657
|
+
};
|
|
1979
2658
|
const watcher = chokidar.watch([roadmapsPath, legacyRoadmapPath], { ignoreInitial: true });
|
|
1980
2659
|
const repoWatch = new RepoWatch(root, {
|
|
1981
2660
|
sync: () => syncRepo(
|
|
1982
|
-
|
|
2661
|
+
// #316: watch must warn about legacy roadmaps without migrating them.
|
|
2662
|
+
{ root, defaultProjectId: deps.settings.defaultProjectId, migrateLegacy: false },
|
|
1983
2663
|
{
|
|
1984
|
-
readFile: (p) =>
|
|
2664
|
+
readFile: (p) => readFile9(p, "utf8"),
|
|
1985
2665
|
fileExists: (p) => existsSync4(p),
|
|
1986
|
-
listDir: (p) =>
|
|
2666
|
+
listDir: (p) => readdir4(p),
|
|
1987
2667
|
rename: rename3,
|
|
1988
2668
|
copyFile: copyFile2,
|
|
1989
|
-
mkdir: (p) =>
|
|
2669
|
+
mkdir: (p) => mkdir8(p, { recursive: true }).then(() => void 0),
|
|
1990
2670
|
unlink: unlink3,
|
|
1991
2671
|
rmdir: rmdir2,
|
|
1992
2672
|
repoIdentity: makeRepoIdentityResolver(deps),
|
|
1993
2673
|
http,
|
|
1994
|
-
log: (line) => process.stdout.write(`[${
|
|
2674
|
+
log: (line) => process.stdout.write(`[${path16.basename(root)}] ${line}
|
|
1995
2675
|
`),
|
|
1996
|
-
warn
|
|
1997
|
-
`)
|
|
2676
|
+
warn
|
|
1998
2677
|
}
|
|
1999
2678
|
),
|
|
2000
2679
|
watcher,
|
|
@@ -2005,6 +2684,12 @@ function register6(program, deps) {
|
|
|
2005
2684
|
warn: (line) => process.stderr.write(line + "\n")
|
|
2006
2685
|
});
|
|
2007
2686
|
watcher.on("all", (_event, filePath) => repoWatch.handleEvent(filePath));
|
|
2687
|
+
void checkSkillVersionDrift(root, deps.version).then((drifted) => {
|
|
2688
|
+
if (drifted.length > 0) {
|
|
2689
|
+
warn(formatSkillVersionDriftWarning(root, deps.version, drifted));
|
|
2690
|
+
}
|
|
2691
|
+
}).catch(() => {
|
|
2692
|
+
});
|
|
2008
2693
|
void repoWatch.flush();
|
|
2009
2694
|
return { stop: () => watcher.close() };
|
|
2010
2695
|
};
|
|
@@ -2049,25 +2734,25 @@ function register6(program, deps) {
|
|
|
2049
2734
|
}
|
|
2050
2735
|
|
|
2051
2736
|
// src/update-cli.ts
|
|
2052
|
-
import { execFile as
|
|
2737
|
+
import { execFile as execFile3 } from "child_process";
|
|
2053
2738
|
import { existsSync as existsSync5 } from "fs";
|
|
2054
2739
|
import { realpath } from "fs/promises";
|
|
2055
2740
|
import { createRequire as createRequire2 } from "module";
|
|
2056
|
-
import
|
|
2741
|
+
import path18 from "path";
|
|
2057
2742
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
2058
|
-
import { promisify } from "util";
|
|
2743
|
+
import { promisify as promisify2 } from "util";
|
|
2059
2744
|
|
|
2060
2745
|
// src/update-notifier.ts
|
|
2061
|
-
import { readFile as
|
|
2746
|
+
import { readFile as readFile10, writeFile as writeFile8, mkdir as mkdir9 } from "fs/promises";
|
|
2062
2747
|
import https from "https";
|
|
2063
|
-
import
|
|
2748
|
+
import path17 from "path";
|
|
2064
2749
|
var PACKAGE = "@nolto/cli";
|
|
2065
2750
|
var CACHE_FILE = "update-check.json";
|
|
2066
2751
|
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
2067
2752
|
var REQUEST_TIMEOUT_MS = 2e3;
|
|
2068
|
-
function isNewerVersion(
|
|
2753
|
+
function isNewerVersion(latest2, current) {
|
|
2069
2754
|
const parts = (v) => v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
2070
|
-
const a = parts(
|
|
2755
|
+
const a = parts(latest2);
|
|
2071
2756
|
const b = parts(current);
|
|
2072
2757
|
for (let i = 0; i < 3; i++) {
|
|
2073
2758
|
const x = a[i] ?? 0;
|
|
@@ -2076,9 +2761,9 @@ function isNewerVersion(latest, current) {
|
|
|
2076
2761
|
}
|
|
2077
2762
|
return false;
|
|
2078
2763
|
}
|
|
2079
|
-
function formatUpdateNotice(
|
|
2764
|
+
function formatUpdateNotice(latest2, current) {
|
|
2080
2765
|
return `
|
|
2081
|
-
Update available: ${current} \u2192 ${
|
|
2766
|
+
Update available: ${current} \u2192 ${latest2} \xB7 run \`nolto update\`
|
|
2082
2767
|
`;
|
|
2083
2768
|
}
|
|
2084
2769
|
function isDisabled(env) {
|
|
@@ -2117,22 +2802,22 @@ function fetchLatestFromRegistry(timeoutMs = REQUEST_TIMEOUT_MS, opts = {}) {
|
|
|
2117
2802
|
req.on("error", () => resolve(null));
|
|
2118
2803
|
});
|
|
2119
2804
|
}
|
|
2120
|
-
async function writeUpdateCache(cachePath, now,
|
|
2121
|
-
await
|
|
2122
|
-
const payload = { checkedAt: now, latest };
|
|
2123
|
-
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 });
|
|
2124
2809
|
}
|
|
2125
2810
|
async function refreshCache(cachePath, now, fetchLatest) {
|
|
2126
|
-
const
|
|
2127
|
-
if (!
|
|
2128
|
-
await writeUpdateCache(cachePath, now,
|
|
2811
|
+
const latest2 = await fetchLatest();
|
|
2812
|
+
if (!latest2) return;
|
|
2813
|
+
await writeUpdateCache(cachePath, now, latest2).catch(() => void 0);
|
|
2129
2814
|
}
|
|
2130
2815
|
async function checkForUpdate(opts) {
|
|
2131
2816
|
if (isDisabled(opts.env)) return null;
|
|
2132
|
-
const cachePath =
|
|
2817
|
+
const cachePath = path17.join(opts.configDir, CACHE_FILE);
|
|
2133
2818
|
let cache = {};
|
|
2134
2819
|
try {
|
|
2135
|
-
cache = JSON.parse(await
|
|
2820
|
+
cache = JSON.parse(await readFile10(cachePath, "utf8"));
|
|
2136
2821
|
} catch {
|
|
2137
2822
|
}
|
|
2138
2823
|
if (typeof cache.checkedAt !== "number" || opts.now - cache.checkedAt > CACHE_TTL_MS) {
|
|
@@ -2148,13 +2833,13 @@ async function checkForUpdate(opts) {
|
|
|
2148
2833
|
async function notifyUpdate(opts) {
|
|
2149
2834
|
try {
|
|
2150
2835
|
if (opts.isJson || !process.stderr.isTTY) return;
|
|
2151
|
-
const
|
|
2836
|
+
const latest2 = await checkForUpdate({
|
|
2152
2837
|
current: opts.current,
|
|
2153
2838
|
configDir: getConfigDir(opts.env),
|
|
2154
2839
|
env: opts.env,
|
|
2155
2840
|
now: opts.now
|
|
2156
2841
|
});
|
|
2157
|
-
if (
|
|
2842
|
+
if (latest2) process.stderr.write(formatUpdateNotice(latest2, opts.current));
|
|
2158
2843
|
} catch {
|
|
2159
2844
|
}
|
|
2160
2845
|
}
|
|
@@ -2205,14 +2890,14 @@ async function updateCliWith(deps) {
|
|
|
2205
2890
|
if (!isInsideGlobalPackage(deps.scriptPath, globalRoot, deps.platform)) {
|
|
2206
2891
|
throw notGlobalError(deps.scriptPath);
|
|
2207
2892
|
}
|
|
2208
|
-
const
|
|
2209
|
-
if (
|
|
2893
|
+
const latest2 = await deps.fetchLatest();
|
|
2894
|
+
if (latest2 == null) {
|
|
2210
2895
|
throw new CliError(
|
|
2211
2896
|
"Could not reach the npm registry to check for the latest @nolto/cli version.",
|
|
2212
2897
|
5
|
|
2213
2898
|
);
|
|
2214
2899
|
}
|
|
2215
|
-
if (!isNewerVersion(
|
|
2900
|
+
if (!isNewerVersion(latest2, deps.currentVersion)) {
|
|
2216
2901
|
deps.log(`Already up to date (${PACKAGE2} ${deps.currentVersion}).`);
|
|
2217
2902
|
return {
|
|
2218
2903
|
status: "up-to-date",
|
|
@@ -2225,7 +2910,7 @@ async function updateCliWith(deps) {
|
|
|
2225
2910
|
"npm",
|
|
2226
2911
|
"install",
|
|
2227
2912
|
"-g",
|
|
2228
|
-
`${PACKAGE2}@${
|
|
2913
|
+
`${PACKAGE2}@${latest2}`
|
|
2229
2914
|
]);
|
|
2230
2915
|
if (installResult.code !== 0) {
|
|
2231
2916
|
const detail = stderrTail(installResult.stderr);
|
|
@@ -2235,11 +2920,11 @@ async function updateCliWith(deps) {
|
|
|
2235
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
|
|
2236
2921
|
);
|
|
2237
2922
|
}
|
|
2238
|
-
deps.log(`Updated ${PACKAGE2} ${deps.currentVersion} \u2192 ${
|
|
2923
|
+
deps.log(`Updated ${PACKAGE2} ${deps.currentVersion} \u2192 ${latest2}.`);
|
|
2239
2924
|
await deps.writeCache(
|
|
2240
|
-
|
|
2925
|
+
path18.join(deps.configDir, UPDATE_CACHE_FILE),
|
|
2241
2926
|
deps.now,
|
|
2242
|
-
|
|
2927
|
+
latest2
|
|
2243
2928
|
).catch(() => void 0);
|
|
2244
2929
|
let watchService = "not-installed";
|
|
2245
2930
|
const unitPath = getUnitPath(deps.env);
|
|
@@ -2263,22 +2948,22 @@ async function updateCliWith(deps) {
|
|
|
2263
2948
|
return {
|
|
2264
2949
|
status: "updated",
|
|
2265
2950
|
from: deps.currentVersion,
|
|
2266
|
-
to:
|
|
2951
|
+
to: latest2,
|
|
2267
2952
|
watchService
|
|
2268
2953
|
};
|
|
2269
2954
|
}
|
|
2270
2955
|
function getCurrentVersion() {
|
|
2271
|
-
const dirname =
|
|
2956
|
+
const dirname = path18.dirname(fileURLToPath3(import.meta.url));
|
|
2272
2957
|
const require3 = createRequire2(import.meta.url);
|
|
2273
2958
|
try {
|
|
2274
|
-
const pkg = require3(
|
|
2959
|
+
const pkg = require3(path18.resolve(dirname, "../package.json"));
|
|
2275
2960
|
return pkg.version ?? "0.0.0";
|
|
2276
2961
|
} catch {
|
|
2277
2962
|
return "0.0.0";
|
|
2278
2963
|
}
|
|
2279
2964
|
}
|
|
2280
2965
|
async function updateCli(opts = {}) {
|
|
2281
|
-
const execFileAsync =
|
|
2966
|
+
const execFileAsync = promisify2(execFile3);
|
|
2282
2967
|
const scriptPath = await realpath(process.argv[1] ?? "");
|
|
2283
2968
|
return updateCliWith({
|
|
2284
2969
|
currentVersion: getCurrentVersion(),
|
|
@@ -2310,7 +2995,7 @@ async function updateCli(opts = {}) {
|
|
|
2310
2995
|
}
|
|
2311
2996
|
|
|
2312
2997
|
// src/commands/update.ts
|
|
2313
|
-
function
|
|
2998
|
+
function register9(program, deps) {
|
|
2314
2999
|
program.command("update").description("Update @nolto/cli to the latest version and restart the watch service if installed").action(async () => {
|
|
2315
3000
|
const mode2 = deps.output.mode;
|
|
2316
3001
|
const result = await updateCli({ quiet: mode2 === "json" });
|
|
@@ -2320,6 +3005,46 @@ function register7(program, deps) {
|
|
|
2320
3005
|
});
|
|
2321
3006
|
}
|
|
2322
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
|
+
|
|
2323
3048
|
// src/program.ts
|
|
2324
3049
|
function stripCommanderErrorPrefix(msg) {
|
|
2325
3050
|
return msg.startsWith("error: ") ? msg.slice("error: ".length) : msg;
|
|
@@ -2335,9 +3060,12 @@ function buildProgram(deps) {
|
|
|
2335
3060
|
register5(program, deps);
|
|
2336
3061
|
register6(program, deps);
|
|
2337
3062
|
register7(program, deps);
|
|
3063
|
+
register8(program, deps);
|
|
3064
|
+
register9(program, deps);
|
|
3065
|
+
register10(program);
|
|
2338
3066
|
program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
2339
3067
|
const bindingError = deps.repoBinding?.error;
|
|
2340
|
-
const bindingExemptCommands = ["init", "link", "update"];
|
|
3068
|
+
const bindingExemptCommands = ["init", "link", "update", "merge-file"];
|
|
2341
3069
|
if (bindingError != null && !bindingExemptCommands.includes(actionCommand.name())) {
|
|
2342
3070
|
throw bindingError;
|
|
2343
3071
|
}
|
|
@@ -2346,11 +3074,11 @@ function buildProgram(deps) {
|
|
|
2346
3074
|
}
|
|
2347
3075
|
|
|
2348
3076
|
// src/index.ts
|
|
2349
|
-
var __dirname3 =
|
|
3077
|
+
var __dirname3 = path19.dirname(fileURLToPath4(import.meta.url));
|
|
2350
3078
|
var require2 = createRequire3(import.meta.url);
|
|
2351
3079
|
function getVersion() {
|
|
2352
3080
|
try {
|
|
2353
|
-
const pkgPath =
|
|
3081
|
+
const pkgPath = path19.resolve(__dirname3, "../package.json");
|
|
2354
3082
|
const pkg = require2(pkgPath);
|
|
2355
3083
|
return pkg.version ?? "0.0.0";
|
|
2356
3084
|
} catch {
|