@nolto/cli 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ nolto whoami # Show resolved auth/config state and project count
|
|
|
45
45
|
```bash
|
|
46
46
|
nolto link <projectId> # Write or update nolto.json at the repository root
|
|
47
47
|
nolto link --show # Show the current binding, source, and repository identity
|
|
48
|
-
nolto link --rebind
|
|
48
|
+
nolto link --rebind [--yes] # Rebind the project to this repository (owner only)
|
|
49
49
|
nolto link --unlink # Remove projectId from nolto.json
|
|
50
50
|
```
|
|
51
51
|
|
|
@@ -53,8 +53,11 @@ Commit `nolto.json` so everyone working in the repository targets the same Nolto
|
|
|
53
53
|
project. A Nolto project is bound to the first repository identity it syncs from.
|
|
54
54
|
The identity is the normalized `origin` remote, or a machine ID plus path when no
|
|
55
55
|
remote is available. Syncing from a different repository fails with
|
|
56
|
-
`409 repo_mismatch`.
|
|
57
|
-
|
|
56
|
+
`409 repo_mismatch`. Before rebinding, run `nolto link --show` and confirm that
|
|
57
|
+
the project ID is intended for this repository. An owner can then run
|
|
58
|
+
`nolto link --rebind` in the correct repository to change the binding, at most
|
|
59
|
+
once every seven days. The command shows the project name and asks for
|
|
60
|
+
confirmation; pass `--yes` (`-y`) to skip the prompt.
|
|
58
61
|
|
|
59
62
|
CLI versions older than 0.8.0 do not send a repository identity and receive
|
|
60
63
|
`426`; run `nolto update` before syncing.
|
package/dist/index.js
CHANGED
|
@@ -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
|
}
|
|
@@ -374,7 +374,7 @@ 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";
|
|
@@ -382,6 +382,7 @@ 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";
|
|
@@ -545,11 +546,25 @@ function checkKeys(value, allowed, at, errors) {
|
|
|
545
546
|
if (!allowed.has(key)) errors.push(`${at} contains unsupported property "${key}".`);
|
|
546
547
|
}
|
|
547
548
|
}
|
|
548
|
-
function
|
|
549
|
-
if (value ===
|
|
549
|
+
function isValidPlanPath(value) {
|
|
550
|
+
if (typeof value !== "string" || value.length === 0) return false;
|
|
551
|
+
if (value.includes("\\") || value.startsWith("/") || /^[A-Za-z]:/.test(value)) return false;
|
|
552
|
+
return value.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..");
|
|
553
|
+
}
|
|
554
|
+
function validatePlanPath(value, at) {
|
|
550
555
|
if (typeof value !== "string" || value.length === 0) {
|
|
551
|
-
|
|
556
|
+
return [`${at} must be a non-empty string.`];
|
|
552
557
|
}
|
|
558
|
+
if (!isValidPlanPath(value)) {
|
|
559
|
+
return [
|
|
560
|
+
`${at} must be a repository-relative path using '/' separators (no absolute paths, '\\', '.' or '..' segments).`
|
|
561
|
+
];
|
|
562
|
+
}
|
|
563
|
+
return [];
|
|
564
|
+
}
|
|
565
|
+
function checkPlan(value, at, errors) {
|
|
566
|
+
if (value === void 0) return;
|
|
567
|
+
errors.push(...validatePlanPath(value, `${at}.plan`));
|
|
553
568
|
}
|
|
554
569
|
function derivePhaseStatus(phase) {
|
|
555
570
|
if (phase.tasks.length > 0 && phase.tasks.every((task) => task.status === "done")) return "done";
|
|
@@ -819,14 +834,51 @@ async function handleShow(deps, projectBindingPath, mode2) {
|
|
|
819
834
|
}
|
|
820
835
|
}
|
|
821
836
|
}
|
|
822
|
-
async function handleRebind(deps, projectId, root, mode2) {
|
|
837
|
+
async function handleRebind(deps, projectId, root, mode2, yes) {
|
|
823
838
|
if (!UUID_RE.test(projectId)) {
|
|
824
839
|
throw new CliError(
|
|
825
840
|
`Invalid project ID: "${projectId}". Must be a UUID (e.g. 00000000-0000-0000-0000-000000000001).`,
|
|
826
841
|
2
|
|
827
842
|
);
|
|
828
843
|
}
|
|
844
|
+
const result = await deps.http.get("/api/projects");
|
|
845
|
+
const projects = Array.isArray(result.projects) ? result.projects : [];
|
|
846
|
+
const project = projects.find((candidate) => candidate.id === projectId);
|
|
847
|
+
if (project == null) {
|
|
848
|
+
throw new CliError(
|
|
849
|
+
`Project ${projectId} is not in your accessible projects. Check nolto.json (nolto link --show) before rebinding.`,
|
|
850
|
+
2
|
|
851
|
+
);
|
|
852
|
+
}
|
|
829
853
|
const repoIdentity = await makeRepoIdentityResolver(deps)(root);
|
|
854
|
+
if (mode2 !== "json") {
|
|
855
|
+
process.stdout.write(`Project : ${project.name} (${project.id})
|
|
856
|
+
`);
|
|
857
|
+
process.stdout.write(`Bound to : ${project.repoIdentity ?? "not bound"}
|
|
858
|
+
`);
|
|
859
|
+
process.stdout.write(`Rebind to : ${repoIdentity.kind}:${repoIdentity.value}
|
|
860
|
+
`);
|
|
861
|
+
}
|
|
862
|
+
if (!yes) {
|
|
863
|
+
if (mode2 === "json") {
|
|
864
|
+
throw new CliError(
|
|
865
|
+
"--rebind requires confirmation. Pass --yes to skip the prompt in --json mode.",
|
|
866
|
+
2
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
870
|
+
let confirmed = false;
|
|
871
|
+
try {
|
|
872
|
+
const answer = await rl.question(`Rebind "${project.name}" to this repository? [y/N] `);
|
|
873
|
+
confirmed = answer.trim().toLowerCase() === "y";
|
|
874
|
+
} finally {
|
|
875
|
+
rl.close();
|
|
876
|
+
}
|
|
877
|
+
if (!confirmed) {
|
|
878
|
+
process.stdout.write("Aborted.\n");
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
830
882
|
await deps.http.post(`/api/projects/${projectId}/repo-binding`, { repoIdentity });
|
|
831
883
|
if (mode2 === "json") {
|
|
832
884
|
printResult({ rebound: true, projectId, repoIdentity }, mode2);
|
|
@@ -945,7 +997,7 @@ Commit nolto.json to share the binding with your team.
|
|
|
945
997
|
function register(program, deps) {
|
|
946
998
|
const cmd = program.command("link [projectId]").description(
|
|
947
999
|
"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");
|
|
1000
|
+
).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
1001
|
cmd.action(async (projectId) => {
|
|
950
1002
|
const { output } = deps;
|
|
951
1003
|
const projectBindingPath = deps.repoBinding?.path ?? deps.projectBindingPath ?? null;
|
|
@@ -973,7 +1025,7 @@ function register(program, deps) {
|
|
|
973
1025
|
}
|
|
974
1026
|
const startDir = resolveStartDir(process.env, process.cwd());
|
|
975
1027
|
const root = findRepoRoot(startDir).root;
|
|
976
|
-
await handleRebind(deps, effectiveProjectId, root, mode2);
|
|
1028
|
+
await handleRebind(deps, effectiveProjectId, root, mode2, cmd.opts()["yes"] === true);
|
|
977
1029
|
return;
|
|
978
1030
|
}
|
|
979
1031
|
if (projectId == null || projectId.trim().length === 0) {
|
|
@@ -1203,10 +1255,27 @@ async function pickProject(rl, http, projects, promptText) {
|
|
|
1203
1255
|
}
|
|
1204
1256
|
return void 0;
|
|
1205
1257
|
}
|
|
1258
|
+
async function selectRepoProject(rl, http, projects, defaultProjectId) {
|
|
1259
|
+
const defaultProject = defaultProjectId == null ? void 0 : projects.find((project) => project.id === defaultProjectId);
|
|
1260
|
+
if (defaultProject != null) {
|
|
1261
|
+
const useDefault = await rl.question(
|
|
1262
|
+
`Use "${defaultProject.name}" (${defaultProject.id}) for this repository? [Y/n] `
|
|
1263
|
+
);
|
|
1264
|
+
if (useDefault.trim().toLowerCase() !== "n") {
|
|
1265
|
+
return defaultProject;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
return pickProject(
|
|
1269
|
+
rl,
|
|
1270
|
+
http,
|
|
1271
|
+
projects,
|
|
1272
|
+
"Project number for this repository, 'c' to create new (or Enter to skip): "
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1206
1275
|
function register2(program, deps) {
|
|
1207
1276
|
program.command("init").description("Interactive setup: configure token, base URL, and default project.").option("--force", "Overwrite existing config without prompting").action(async (opts) => {
|
|
1208
1277
|
const configPath = deps.configPath;
|
|
1209
|
-
const rl =
|
|
1278
|
+
const rl = readline2.createInterface({ input: process.stdin, output: process.stdout });
|
|
1210
1279
|
try {
|
|
1211
1280
|
let configureGlobal = opts.force === true;
|
|
1212
1281
|
if (!configureGlobal) {
|
|
@@ -1262,6 +1331,9 @@ function register2(program, deps) {
|
|
|
1262
1331
|
);
|
|
1263
1332
|
defaultProjectId = selected?.id;
|
|
1264
1333
|
defaultProjectName = selected?.name;
|
|
1334
|
+
if (selected != null && !projects.some((project) => project.id === selected.id)) {
|
|
1335
|
+
projects = [...projects, selected];
|
|
1336
|
+
}
|
|
1265
1337
|
await saveConfigFile(configPath, {
|
|
1266
1338
|
token,
|
|
1267
1339
|
baseUrl: baseUrl !== DEFAULT_BASE_URL ? baseUrl : void 0,
|
|
@@ -1298,19 +1370,35 @@ Saved ${configPath}
|
|
|
1298
1370
|
return;
|
|
1299
1371
|
}
|
|
1300
1372
|
const existingBinding = deps.repoBinding?.error == null ? deps.repoBinding?.binding ?? null : null;
|
|
1301
|
-
|
|
1302
|
-
if (
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1373
|
+
const repoHttp = http ?? deps.http;
|
|
1374
|
+
if (projects == null) {
|
|
1375
|
+
const result = await repoHttp.get("/api/projects");
|
|
1376
|
+
projects = Array.isArray(result.projects) ? result.projects : [];
|
|
1377
|
+
}
|
|
1378
|
+
let keepExistingBinding = false;
|
|
1379
|
+
let repoProject;
|
|
1380
|
+
if (existingBinding != null) {
|
|
1381
|
+
const boundProject = projects.find(
|
|
1382
|
+
(project) => project.id === existingBinding.projectId
|
|
1383
|
+
);
|
|
1384
|
+
if (boundProject != null) {
|
|
1385
|
+
process.stdout.write(
|
|
1386
|
+
`Existing binding: nolto.json \u2192 "${boundProject.name}" (${boundProject.id})
|
|
1387
|
+
`
|
|
1388
|
+
);
|
|
1389
|
+
} else {
|
|
1390
|
+
process.stderr.write(
|
|
1391
|
+
`Warning: nolto.json points to project ${existingBinding.projectId}, which is not in your accessible projects.
|
|
1392
|
+
`
|
|
1393
|
+
);
|
|
1307
1394
|
}
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
http,
|
|
1311
|
-
projects,
|
|
1312
|
-
"Project number for this repository, 'c' to create new (or Enter to skip): "
|
|
1395
|
+
const keep = await rl.question(
|
|
1396
|
+
boundProject != null ? "Keep this binding? [Y/n] " : "Keep this binding? [y/N] "
|
|
1313
1397
|
);
|
|
1398
|
+
keepExistingBinding = boundProject != null ? keep.trim().toLowerCase() !== "n" : keep.trim().toLowerCase() === "y";
|
|
1399
|
+
repoProject = keepExistingBinding ? boundProject ?? { id: existingBinding.projectId, name: path9.basename(root) } : await selectRepoProject(rl, repoHttp, projects, defaultProjectId);
|
|
1400
|
+
} else {
|
|
1401
|
+
repoProject = await selectRepoProject(rl, repoHttp, projects, defaultProjectId);
|
|
1314
1402
|
}
|
|
1315
1403
|
if (repoProject == null) {
|
|
1316
1404
|
process.stdout.write("Skipped repo setup (no project selected).\n");
|
|
@@ -1323,8 +1411,8 @@ Set up this repository (${root}) for roadmap sync? [Y/n] `);
|
|
|
1323
1411
|
return;
|
|
1324
1412
|
}
|
|
1325
1413
|
const bindingPath = deps.repoBinding?.path ?? path9.join(root, "nolto.json");
|
|
1326
|
-
if (
|
|
1327
|
-
process.stdout.write(`binding: kept ${bindingPath} (${
|
|
1414
|
+
if (keepExistingBinding) {
|
|
1415
|
+
process.stdout.write(`binding: kept ${bindingPath} (${repoProject.id})
|
|
1328
1416
|
`);
|
|
1329
1417
|
} else {
|
|
1330
1418
|
if (deps.repoBinding?.error != null) {
|
|
@@ -1407,7 +1495,7 @@ async function promptHidden(rl, prompt) {
|
|
|
1407
1495
|
}
|
|
1408
1496
|
|
|
1409
1497
|
// src/commands/login.ts
|
|
1410
|
-
import
|
|
1498
|
+
import readline3 from "readline/promises";
|
|
1411
1499
|
|
|
1412
1500
|
// src/login-poll.ts
|
|
1413
1501
|
async function pollUntilToken(opts) {
|
|
@@ -1473,7 +1561,7 @@ function register3(program, deps) {
|
|
|
1473
1561
|
} catch {
|
|
1474
1562
|
}
|
|
1475
1563
|
if (existing?.token) {
|
|
1476
|
-
const rl =
|
|
1564
|
+
const rl = readline3.createInterface({
|
|
1477
1565
|
input: process.stdin,
|
|
1478
1566
|
output: process.stdout
|
|
1479
1567
|
});
|
|
@@ -1597,7 +1685,7 @@ function register4(program, deps) {
|
|
|
1597
1685
|
}
|
|
1598
1686
|
|
|
1599
1687
|
// src/commands/sync.ts
|
|
1600
|
-
import { copyFile, mkdir as mkdir6, readFile as readFile6, readdir as readdir2, rename as rename2, rmdir, unlink as unlink2 } from "fs/promises";
|
|
1688
|
+
import { copyFile, mkdir as mkdir6, readFile as readFile6, readdir as readdir2, realpath, rename as rename2, rmdir, unlink as unlink2 } from "fs/promises";
|
|
1601
1689
|
import { existsSync as existsSync3 } from "fs";
|
|
1602
1690
|
|
|
1603
1691
|
// src/sync-repo.ts
|
|
@@ -1652,11 +1740,31 @@ async function loadValidRoadmap(filePath, readFile12) {
|
|
|
1652
1740
|
async function buildSyncBody(args) {
|
|
1653
1741
|
const planDocuments = [];
|
|
1654
1742
|
for (const ref of collectPlanRefs(args.roadmap)) {
|
|
1655
|
-
const
|
|
1743
|
+
const root = path10.resolve(args.repoRoot);
|
|
1744
|
+
const absolute = path10.resolve(root, ref.path);
|
|
1745
|
+
if (absolute !== root && !absolute.startsWith(root + path10.sep)) {
|
|
1746
|
+
args.deps.warn(`plan path outside repository root, skipping: ${ref.path}`);
|
|
1747
|
+
continue;
|
|
1748
|
+
}
|
|
1656
1749
|
if (!args.deps.fileExists(absolute)) {
|
|
1657
1750
|
args.deps.warn(`plan file not found, skipping: ${ref.path}`);
|
|
1658
1751
|
continue;
|
|
1659
1752
|
}
|
|
1753
|
+
if (args.deps.realpath !== void 0) {
|
|
1754
|
+
let real;
|
|
1755
|
+
let realRoot;
|
|
1756
|
+
try {
|
|
1757
|
+
real = await args.deps.realpath(absolute);
|
|
1758
|
+
realRoot = await args.deps.realpath(root);
|
|
1759
|
+
} catch {
|
|
1760
|
+
args.deps.warn(`plan file not found, skipping: ${ref.path}`);
|
|
1761
|
+
continue;
|
|
1762
|
+
}
|
|
1763
|
+
if (real !== realRoot && !real.startsWith(realRoot + path10.sep)) {
|
|
1764
|
+
args.deps.warn(`symlink escapes repository root, skipping: ${ref.path}`);
|
|
1765
|
+
continue;
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1660
1768
|
const content = await args.deps.readFile(absolute);
|
|
1661
1769
|
planDocuments.push({
|
|
1662
1770
|
path: ref.path,
|
|
@@ -1687,6 +1795,21 @@ async function runSync(args, deps) {
|
|
|
1687
1795
|
|
|
1688
1796
|
// src/sync-repo.ts
|
|
1689
1797
|
var ROADMAP_SLUG_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
|
1798
|
+
function collectContainedPlanAbsPaths(roadmaps, root, warn) {
|
|
1799
|
+
const planAbsPaths = /* @__PURE__ */ new Set();
|
|
1800
|
+
const resolvedRoot = path11.resolve(root);
|
|
1801
|
+
for (const roadmap of roadmaps) {
|
|
1802
|
+
for (const ref of collectPlanRefs(roadmap)) {
|
|
1803
|
+
const absolute = path11.resolve(resolvedRoot, ref.path);
|
|
1804
|
+
if (absolute !== resolvedRoot && !absolute.startsWith(resolvedRoot + path11.sep)) {
|
|
1805
|
+
warn(`chokidar target outside repo, skipping: ${ref.path}`);
|
|
1806
|
+
continue;
|
|
1807
|
+
}
|
|
1808
|
+
planAbsPaths.add(absolute);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
return [...planAbsPaths];
|
|
1812
|
+
}
|
|
1690
1813
|
async function listRoadmapFiles(roadmapsDir, io) {
|
|
1691
1814
|
try {
|
|
1692
1815
|
const entries = await io.listDir(roadmapsDir);
|
|
@@ -1767,23 +1890,29 @@ async function syncRepo(args, io) {
|
|
|
1767
1890
|
return { slug, roadmap: await loadValidRoadmap(filePath, io.readFile) };
|
|
1768
1891
|
})
|
|
1769
1892
|
);
|
|
1770
|
-
const planAbsPaths =
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
}
|
|
1893
|
+
const planAbsPaths = collectContainedPlanAbsPaths(
|
|
1894
|
+
roadmaps.map(({ roadmap }) => roadmap),
|
|
1895
|
+
args.root,
|
|
1896
|
+
io.warn
|
|
1897
|
+
);
|
|
1776
1898
|
const results = [];
|
|
1777
1899
|
const repoIdentity = await io.repoIdentity(args.root);
|
|
1778
1900
|
for (const { slug, roadmap } of roadmaps) {
|
|
1779
1901
|
results.push(
|
|
1780
1902
|
await runSync(
|
|
1781
1903
|
{ repoRoot: args.root, projectId, slug, roadmap, repoIdentity },
|
|
1782
|
-
{
|
|
1904
|
+
{
|
|
1905
|
+
http: io.http,
|
|
1906
|
+
readFile: io.readFile,
|
|
1907
|
+
fileExists: io.fileExists,
|
|
1908
|
+
realpath: io.realpath,
|
|
1909
|
+
log: io.log,
|
|
1910
|
+
warn: io.warn
|
|
1911
|
+
}
|
|
1783
1912
|
)
|
|
1784
1913
|
);
|
|
1785
1914
|
}
|
|
1786
|
-
return { results, planAbsPaths
|
|
1915
|
+
return { results, planAbsPaths };
|
|
1787
1916
|
}
|
|
1788
1917
|
|
|
1789
1918
|
// src/commands/sync.ts
|
|
@@ -1805,6 +1934,7 @@ function register5(program, deps) {
|
|
|
1805
1934
|
{ root, defaultProjectId: deps.settings.defaultProjectId, migrateLegacy: true },
|
|
1806
1935
|
{
|
|
1807
1936
|
readFile: (p) => readFile6(p, "utf8"),
|
|
1937
|
+
realpath: (p) => realpath(p),
|
|
1808
1938
|
fileExists: (p) => existsSync3(p),
|
|
1809
1939
|
listDir: (p) => readdir2(p),
|
|
1810
1940
|
rename: rename2,
|
|
@@ -2370,7 +2500,7 @@ function register7(program, deps) {
|
|
|
2370
2500
|
}
|
|
2371
2501
|
|
|
2372
2502
|
// src/commands/watch.ts
|
|
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";
|
|
2503
|
+
import { copyFile as copyFile2, mkdir as mkdir8, readFile as readFile9, readdir as readdir4, realpath as realpath2, rename as rename3, rmdir as rmdir2, unlink as unlink3 } from "fs/promises";
|
|
2374
2504
|
import { existsSync as existsSync4 } from "fs";
|
|
2375
2505
|
import path16 from "path";
|
|
2376
2506
|
import chokidar from "chokidar";
|
|
@@ -2662,6 +2792,7 @@ function register8(program, deps) {
|
|
|
2662
2792
|
{ root, defaultProjectId: deps.settings.defaultProjectId, migrateLegacy: false },
|
|
2663
2793
|
{
|
|
2664
2794
|
readFile: (p) => readFile9(p, "utf8"),
|
|
2795
|
+
realpath: (p) => realpath2(p),
|
|
2665
2796
|
fileExists: (p) => existsSync4(p),
|
|
2666
2797
|
listDir: (p) => readdir4(p),
|
|
2667
2798
|
rename: rename3,
|
|
@@ -2736,7 +2867,7 @@ function register8(program, deps) {
|
|
|
2736
2867
|
// src/update-cli.ts
|
|
2737
2868
|
import { execFile as execFile3 } from "child_process";
|
|
2738
2869
|
import { existsSync as existsSync5 } from "fs";
|
|
2739
|
-
import { realpath } from "fs/promises";
|
|
2870
|
+
import { realpath as realpath3 } from "fs/promises";
|
|
2740
2871
|
import { createRequire as createRequire2 } from "module";
|
|
2741
2872
|
import path18 from "path";
|
|
2742
2873
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
@@ -2964,7 +3095,7 @@ function getCurrentVersion() {
|
|
|
2964
3095
|
}
|
|
2965
3096
|
async function updateCli(opts = {}) {
|
|
2966
3097
|
const execFileAsync = promisify2(execFile3);
|
|
2967
|
-
const scriptPath = await
|
|
3098
|
+
const scriptPath = await realpath3(process.argv[1] ?? "");
|
|
2968
3099
|
return updateCliWith({
|
|
2969
3100
|
currentVersion: getCurrentVersion(),
|
|
2970
3101
|
scriptPath,
|
|
@@ -2985,7 +3116,7 @@ async function updateCli(opts = {}) {
|
|
|
2985
3116
|
};
|
|
2986
3117
|
}
|
|
2987
3118
|
},
|
|
2988
|
-
realpath,
|
|
3119
|
+
realpath: realpath3,
|
|
2989
3120
|
fetchLatest: () => fetchLatestFromRegistry(UPDATE_REQUEST_TIMEOUT_MS, UPDATE_FETCH_OPTIONS),
|
|
2990
3121
|
writeCache: writeUpdateCache,
|
|
2991
3122
|
unitExists: existsSync5,
|
|
@@ -59,10 +59,10 @@ Use this reference when creating or structurally editing `.nolto/roadmaps/<slug>
|
|
|
59
59
|
- `summary`: One or two current sentences; do not use as a changelog.
|
|
60
60
|
- `phases`: Ordered delivery phases.
|
|
61
61
|
- `phase.status`: `todo`, `in-progress`, `done`, or `blocked`.
|
|
62
|
-
- `phase.plan`: Optional repository-relative path to a
|
|
62
|
+
- `phase.plan`: Optional repository-relative path (`/`-separated) to a Markdown plan document, for example `docs/plans/auth.md`. Absolute paths, backslashes, and `.`/`..` segments are rejected.
|
|
63
63
|
- `tasks`: Ordered work items. Each must be small enough to verify clearly.
|
|
64
64
|
- `task.status`: `todo`, `in-progress`, `done`, or `blocked`.
|
|
65
|
-
- `task.plan`: Optional repository-relative path to a
|
|
65
|
+
- `task.plan`: Optional repository-relative path (`/`-separated) to a Markdown plan document, for example `docs/plans/auth.md`. Absolute paths, backslashes, and `.`/`..` segments are rejected.
|
|
66
66
|
- `startedAt`: Required in practice for `in-progress` tasks.
|
|
67
67
|
- `completedAt`: Required in practice for `done` tasks.
|
|
68
68
|
- `note`: Current implementation detail or blocker. Omit when it adds no value.
|
|
@@ -10,6 +10,14 @@ const ALLOWED_KEYS = {
|
|
|
10
10
|
phase: new Set(["id", "title", "status", "plan", "tasks"]),
|
|
11
11
|
task: new Set(["id", "title", "status", "startedAt", "completedAt", "note", "dependsOn", "plan"])
|
|
12
12
|
};
|
|
13
|
+
const PLAN_PATH_ERROR = "plan --path must be a repository-relative path using '/' separators (no absolute paths, '\\', '.' or '..' segments).";
|
|
14
|
+
|
|
15
|
+
// Keep in sync with packages/roadmap-schema/src/index.ts isValidPlanPath.
|
|
16
|
+
function isValidPlanPath(value) {
|
|
17
|
+
if (typeof value !== "string" || value.length === 0) return false;
|
|
18
|
+
if (value.includes("\\") || value.startsWith("/") || /^[A-Za-z]:/.test(value)) return false;
|
|
19
|
+
return value.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..");
|
|
20
|
+
}
|
|
13
21
|
|
|
14
22
|
function parseArguments(argv) {
|
|
15
23
|
const options = { file: null, note: undefined, summary: undefined, text: undefined, path: undefined, positional: [] };
|
|
@@ -258,15 +266,20 @@ try {
|
|
|
258
266
|
} else if (command === "plan") {
|
|
259
267
|
if (!taskId) throw new Error("plan requires a task id.");
|
|
260
268
|
if (!options.path) throw new Error("plan requires --path <repo-relative-md-path>.");
|
|
269
|
+
if (!isValidPlanPath(options.path)) throw new Error(PLAN_PATH_ERROR);
|
|
270
|
+
const repoRoot = path.resolve(repoRootForRoadmap(filePath));
|
|
271
|
+
const resolvedPlanPath = path.resolve(repoRoot, options.path);
|
|
272
|
+
if (resolvedPlanPath !== repoRoot && !resolvedPlanPath.startsWith(repoRoot + path.sep)) {
|
|
273
|
+
throw new Error(PLAN_PATH_ERROR);
|
|
274
|
+
}
|
|
261
275
|
const { task } = findTask(roadmap, taskId);
|
|
262
276
|
task.plan = options.path;
|
|
263
277
|
roadmap.schemaVersion = 2;
|
|
264
278
|
roadmap.updatedAt = localIsoNow();
|
|
265
|
-
const repoRoot = repoRootForRoadmap(filePath);
|
|
266
279
|
try {
|
|
267
|
-
await access(
|
|
280
|
+
await access(resolvedPlanPath);
|
|
268
281
|
} catch {
|
|
269
|
-
console.warn(`WARN plan file not found at ${
|
|
282
|
+
console.warn(`WARN plan file not found at ${resolvedPlanPath}`);
|
|
270
283
|
}
|
|
271
284
|
const result = validate(roadmap);
|
|
272
285
|
if (result.errors.length) throw new Error(`Mutation failed validation: ${result.errors.join(" ")}`);
|