@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 # Rebind the project to this repository (owner only)
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`. An owner can run `nolto link --rebind` in the correct
57
- repository to change the binding, at most once every seven days.
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 readline from "readline/promises";
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 checkPlan(value, at, errors) {
549
- 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) {
550
555
  if (typeof value !== "string" || value.length === 0) {
551
- errors.push(`${at}.plan must be a non-empty string.`);
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 = readline.createInterface({ input: process.stdin, output: process.stdout });
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
- 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 : [];
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
- repoProject = await pickProject(
1309
- rl,
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 (existingBinding != null) {
1327
- process.stdout.write(`binding: kept ${bindingPath} (${existingBinding.projectId})
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 readline2 from "readline/promises";
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 = readline2.createInterface({
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 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
+ }
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 = /* @__PURE__ */ new Set();
1771
- for (const { roadmap } of roadmaps) {
1772
- for (const ref of collectPlanRefs(roadmap)) {
1773
- planAbsPaths.add(path11.join(args.root, ref.path));
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
- { 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
+ }
1783
1912
  )
1784
1913
  );
1785
1914
  }
1786
- return { results, planAbsPaths: [...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 realpath(process.argv[1] ?? "");
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 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.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "CLI for syncing repository roadmaps with Nolto.",
5
5
  "license": "MIT",
6
6
  "type": "module",