@nolto/cli 0.10.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/dist/index.js
CHANGED
|
@@ -546,11 +546,25 @@ function checkKeys(value, allowed, at, errors) {
|
|
|
546
546
|
if (!allowed.has(key)) errors.push(`${at} contains unsupported property "${key}".`);
|
|
547
547
|
}
|
|
548
548
|
}
|
|
549
|
-
function
|
|
550
|
-
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) {
|
|
551
555
|
if (typeof value !== "string" || value.length === 0) {
|
|
552
|
-
|
|
556
|
+
return [`${at} must be a non-empty string.`];
|
|
557
|
+
}
|
|
558
|
+
if (!isValidPlanPath(value)) {
|
|
559
|
+
return [
|
|
560
|
+
`${at} must be a repository-relative path using '/' separators (no absolute paths, '\\', '.' or '..' segments).`
|
|
561
|
+
];
|
|
553
562
|
}
|
|
563
|
+
return [];
|
|
564
|
+
}
|
|
565
|
+
function checkPlan(value, at, errors) {
|
|
566
|
+
if (value === void 0) return;
|
|
567
|
+
errors.push(...validatePlanPath(value, `${at}.plan`));
|
|
554
568
|
}
|
|
555
569
|
function derivePhaseStatus(phase) {
|
|
556
570
|
if (phase.tasks.length > 0 && phase.tasks.every((task) => task.status === "done")) return "done";
|
|
@@ -1671,7 +1685,7 @@ function register4(program, deps) {
|
|
|
1671
1685
|
}
|
|
1672
1686
|
|
|
1673
1687
|
// src/commands/sync.ts
|
|
1674
|
-
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";
|
|
1675
1689
|
import { existsSync as existsSync3 } from "fs";
|
|
1676
1690
|
|
|
1677
1691
|
// src/sync-repo.ts
|
|
@@ -1726,11 +1740,31 @@ async function loadValidRoadmap(filePath, readFile12) {
|
|
|
1726
1740
|
async function buildSyncBody(args) {
|
|
1727
1741
|
const planDocuments = [];
|
|
1728
1742
|
for (const ref of collectPlanRefs(args.roadmap)) {
|
|
1729
|
-
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
|
+
}
|
|
1730
1749
|
if (!args.deps.fileExists(absolute)) {
|
|
1731
1750
|
args.deps.warn(`plan file not found, skipping: ${ref.path}`);
|
|
1732
1751
|
continue;
|
|
1733
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
|
+
}
|
|
1734
1768
|
const content = await args.deps.readFile(absolute);
|
|
1735
1769
|
planDocuments.push({
|
|
1736
1770
|
path: ref.path,
|
|
@@ -1761,6 +1795,21 @@ async function runSync(args, deps) {
|
|
|
1761
1795
|
|
|
1762
1796
|
// src/sync-repo.ts
|
|
1763
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
|
+
}
|
|
1764
1813
|
async function listRoadmapFiles(roadmapsDir, io) {
|
|
1765
1814
|
try {
|
|
1766
1815
|
const entries = await io.listDir(roadmapsDir);
|
|
@@ -1841,23 +1890,29 @@ async function syncRepo(args, io) {
|
|
|
1841
1890
|
return { slug, roadmap: await loadValidRoadmap(filePath, io.readFile) };
|
|
1842
1891
|
})
|
|
1843
1892
|
);
|
|
1844
|
-
const planAbsPaths =
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
}
|
|
1893
|
+
const planAbsPaths = collectContainedPlanAbsPaths(
|
|
1894
|
+
roadmaps.map(({ roadmap }) => roadmap),
|
|
1895
|
+
args.root,
|
|
1896
|
+
io.warn
|
|
1897
|
+
);
|
|
1850
1898
|
const results = [];
|
|
1851
1899
|
const repoIdentity = await io.repoIdentity(args.root);
|
|
1852
1900
|
for (const { slug, roadmap } of roadmaps) {
|
|
1853
1901
|
results.push(
|
|
1854
1902
|
await runSync(
|
|
1855
1903
|
{ repoRoot: args.root, projectId, slug, roadmap, repoIdentity },
|
|
1856
|
-
{
|
|
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
|
+
}
|
|
1857
1912
|
)
|
|
1858
1913
|
);
|
|
1859
1914
|
}
|
|
1860
|
-
return { results, planAbsPaths
|
|
1915
|
+
return { results, planAbsPaths };
|
|
1861
1916
|
}
|
|
1862
1917
|
|
|
1863
1918
|
// src/commands/sync.ts
|
|
@@ -1879,6 +1934,7 @@ function register5(program, deps) {
|
|
|
1879
1934
|
{ root, defaultProjectId: deps.settings.defaultProjectId, migrateLegacy: true },
|
|
1880
1935
|
{
|
|
1881
1936
|
readFile: (p) => readFile6(p, "utf8"),
|
|
1937
|
+
realpath: (p) => realpath(p),
|
|
1882
1938
|
fileExists: (p) => existsSync3(p),
|
|
1883
1939
|
listDir: (p) => readdir2(p),
|
|
1884
1940
|
rename: rename2,
|
|
@@ -2444,7 +2500,7 @@ function register7(program, deps) {
|
|
|
2444
2500
|
}
|
|
2445
2501
|
|
|
2446
2502
|
// src/commands/watch.ts
|
|
2447
|
-
import { copyFile as copyFile2, mkdir as mkdir8, readFile as readFile9, readdir as readdir4, rename as rename3, rmdir as rmdir2, unlink as unlink3 } from "fs/promises";
|
|
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";
|
|
2448
2504
|
import { existsSync as existsSync4 } from "fs";
|
|
2449
2505
|
import path16 from "path";
|
|
2450
2506
|
import chokidar from "chokidar";
|
|
@@ -2736,6 +2792,7 @@ function register8(program, deps) {
|
|
|
2736
2792
|
{ root, defaultProjectId: deps.settings.defaultProjectId, migrateLegacy: false },
|
|
2737
2793
|
{
|
|
2738
2794
|
readFile: (p) => readFile9(p, "utf8"),
|
|
2795
|
+
realpath: (p) => realpath2(p),
|
|
2739
2796
|
fileExists: (p) => existsSync4(p),
|
|
2740
2797
|
listDir: (p) => readdir4(p),
|
|
2741
2798
|
rename: rename3,
|
|
@@ -2810,7 +2867,7 @@ function register8(program, deps) {
|
|
|
2810
2867
|
// src/update-cli.ts
|
|
2811
2868
|
import { execFile as execFile3 } from "child_process";
|
|
2812
2869
|
import { existsSync as existsSync5 } from "fs";
|
|
2813
|
-
import { realpath } from "fs/promises";
|
|
2870
|
+
import { realpath as realpath3 } from "fs/promises";
|
|
2814
2871
|
import { createRequire as createRequire2 } from "module";
|
|
2815
2872
|
import path18 from "path";
|
|
2816
2873
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
@@ -3038,7 +3095,7 @@ function getCurrentVersion() {
|
|
|
3038
3095
|
}
|
|
3039
3096
|
async function updateCli(opts = {}) {
|
|
3040
3097
|
const execFileAsync = promisify2(execFile3);
|
|
3041
|
-
const scriptPath = await
|
|
3098
|
+
const scriptPath = await realpath3(process.argv[1] ?? "");
|
|
3042
3099
|
return updateCliWith({
|
|
3043
3100
|
currentVersion: getCurrentVersion(),
|
|
3044
3101
|
scriptPath,
|
|
@@ -3059,7 +3116,7 @@ async function updateCli(opts = {}) {
|
|
|
3059
3116
|
};
|
|
3060
3117
|
}
|
|
3061
3118
|
},
|
|
3062
|
-
realpath,
|
|
3119
|
+
realpath: realpath3,
|
|
3063
3120
|
fetchLatest: () => fetchLatestFromRegistry(UPDATE_REQUEST_TIMEOUT_MS, UPDATE_FETCH_OPTIONS),
|
|
3064
3121
|
writeCache: writeUpdateCache,
|
|
3065
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(" ")}`);
|