@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 checkPlan(value, at, errors) {
550
- if (value === void 0) return;
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
- errors.push(`${at}.plan must be a non-empty string.`);
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 absolute = path10.join(args.repoRoot, ref.path);
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 = /* @__PURE__ */ new Set();
1845
- for (const { roadmap } of roadmaps) {
1846
- for (const ref of collectPlanRefs(roadmap)) {
1847
- planAbsPaths.add(path11.join(args.root, ref.path));
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
- { http: io.http, readFile: io.readFile, fileExists: io.fileExists, log: io.log, warn: io.warn }
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: [...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 realpath(process.argv[1] ?? "");
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 markdown plan document.
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 markdown plan document.
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(path.join(repoRoot, options.path));
280
+ await access(resolvedPlanPath);
268
281
  } catch {
269
- console.warn(`WARN plan file not found at ${path.join(repoRoot, options.path)}`);
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(" ")}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nolto/cli",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "CLI for syncing repository roadmaps with Nolto.",
5
5
  "license": "MIT",
6
6
  "type": "module",