@nolto/cli 0.10.0 → 0.12.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
|
@@ -79,18 +79,25 @@ paths, and sends an idempotent full upsert to the bound project. A legacy
|
|
|
79
79
|
`.roadmap/roadmap.json` is migrated by `nolto sync` when no canonical roadmap
|
|
80
80
|
exists; `nolto watch` only warns and never migrates.
|
|
81
81
|
|
|
82
|
+
Timestamp fields (`updatedAt`, `startedAt`, and `completedAt`) must be RFC 3339 with a timezone offset (`2026-09-02T10:00:00+09:00` or `2026-09-02T01:00:00Z`); offset-less and date-only values are rejected. `dependsOn` items must be unique strings matching `^[a-z0-9][a-z0-9._-]*$`, and names, titles, and IDs must be actual strings.
|
|
83
|
+
|
|
84
|
+
The shared proxy rejects a sync request body over 10 MB with HTTP `413`. Free
|
|
85
|
+
plan documents are already limited to 2 MB per roadmap before that shared cap.
|
|
86
|
+
|
|
82
87
|
`watch` uses the repository registry maintained by `nolto init`. Missing registered
|
|
83
88
|
repositories are skipped with a warning.
|
|
84
89
|
The CLI warns when an installed `roadmap-progress` skill version differs from the
|
|
85
90
|
CLI version; run `nolto init` in that repository to refresh it.
|
|
86
91
|
|
|
87
|
-
Use `nolto diff [slug]` before pushing to
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
Use `nolto diff [slug]` before pushing to compare task status changes, added or
|
|
93
|
+
removed tasks and phases, and roadmap `updatedAt`, `currentTaskId`, and `summary`.
|
|
94
|
+
It does not compare `title`, `note`, `plan`, `dependsOn`, project metadata, or
|
|
95
|
+
ordering; use `git diff` for those. It exits with code 1 when differences are
|
|
96
|
+
found and does not change either side. `nolto pull [slug]` downloads all server
|
|
97
|
+
roadmaps, or one named roadmap, and intentionally overwrites the corresponding
|
|
98
|
+
local files after validation. It never deletes local-only roadmaps. Add `--merge`
|
|
99
|
+
to preserve valid local changes using a deterministic structural merge, and review
|
|
100
|
+
the result with `git diff` before committing.
|
|
94
101
|
|
|
95
102
|
## Merging roadmap conflicts
|
|
96
103
|
|
|
@@ -109,6 +116,32 @@ again for an existing repository. Git invokes the underlying command as:
|
|
|
109
116
|
nolto merge-file <ours> <theirs> --base <ancestor>
|
|
110
117
|
```
|
|
111
118
|
|
|
119
|
+
### Silent resolution rules
|
|
120
|
+
|
|
121
|
+
The merge driver emits no conflict markers and exits `0` unless the merged
|
|
122
|
+
output fails validation, in which case it exits `1`. Resolution is deterministic:
|
|
123
|
+
|
|
124
|
+
- For fields changed on both sides, the side with the newer `updatedAt` wins.
|
|
125
|
+
- `currentTaskId` comes from the newer side while it still points to an
|
|
126
|
+
`in-progress` task; otherwise the older side's value is used if it still
|
|
127
|
+
points to an `in-progress` task, and it is omitted when neither does.
|
|
128
|
+
- Task status priority is `done` (with `completedAt`) > `blocked` >
|
|
129
|
+
`in-progress` > `todo`.
|
|
130
|
+
- `startedAt` keeps the earliest value, `completedAt` keeps the latest value,
|
|
131
|
+
and `dependsOn` is a deduplicated union.
|
|
132
|
+
- A deletion is honored only when both sides deleted the item.
|
|
133
|
+
- Phase order is ours first, followed by phases found only in theirs; phase
|
|
134
|
+
status is re-derived from the merged tasks.
|
|
135
|
+
|
|
136
|
+
Full diff coverage and interactive conflict display are tracked in
|
|
137
|
+
[#475](https://github.com/uruca-kk/nolto/issues/475).
|
|
138
|
+
|
|
139
|
+
### Legacy roadmap schema v1
|
|
140
|
+
|
|
141
|
+
Schema v1 remains accepted with a warning and has no sunset date. The next
|
|
142
|
+
`roadmap-progress` mutation upgrades it to v2 automatically. This compatibility
|
|
143
|
+
policy remains in place through CLI 1.0 and will be reconsidered then.
|
|
144
|
+
|
|
112
145
|
The result is written to `<ours>` as required by Git. Use
|
|
113
146
|
`nolto pull --merge [slug]` when the server has changes that should be combined
|
|
114
147
|
with a valid local roadmap before syncing.
|
|
@@ -177,7 +210,7 @@ Errors are written to stderr:
|
|
|
177
210
|
| 1 | Roadmap differences found (`nolto diff`) or unexpected command failure |
|
|
178
211
|
| 2 | Local input or file validation error |
|
|
179
212
|
| 3 | Authentication or authorization error |
|
|
180
|
-
| 4 | Rate limit response |
|
|
213
|
+
| 4 | Rate limit response, or plan / quota limit (HTTP 402) |
|
|
181
214
|
| 5 | Network, DNS, or server error |
|
|
182
215
|
|
|
183
216
|
## License
|
package/dist/index.js
CHANGED
|
@@ -531,6 +531,7 @@ function normalizeRemote(raw) {
|
|
|
531
531
|
// ../roadmap-schema/src/index.ts
|
|
532
532
|
var STATUSES = /* @__PURE__ */ new Set(["todo", "in-progress", "done", "blocked"]);
|
|
533
533
|
var ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
|
534
|
+
var DATE_TIME_PATTERN = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:[Zz]|([+-])(\d{2}):(\d{2}))$/;
|
|
534
535
|
var ALLOWED_KEYS = {
|
|
535
536
|
roadmap: /* @__PURE__ */ new Set(["schemaVersion", "project", "updatedAt", "currentTaskId", "summary", "phases"]),
|
|
536
537
|
project: /* @__PURE__ */ new Set(["id", "name", "repository"]),
|
|
@@ -546,11 +547,58 @@ function checkKeys(value, allowed, at, errors) {
|
|
|
546
547
|
if (!allowed.has(key)) errors.push(`${at} contains unsupported property "${key}".`);
|
|
547
548
|
}
|
|
548
549
|
}
|
|
549
|
-
function
|
|
550
|
-
if (value ===
|
|
550
|
+
function isValidPlanPath(value) {
|
|
551
|
+
if (typeof value !== "string" || value.length === 0) return false;
|
|
552
|
+
if (value.includes("\\") || value.startsWith("/") || /^[A-Za-z]:/.test(value)) return false;
|
|
553
|
+
return value.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..");
|
|
554
|
+
}
|
|
555
|
+
function daysInMonth(year, month) {
|
|
556
|
+
if (month === 2) {
|
|
557
|
+
const leap = year % 4 === 0 && year % 100 !== 0 || year % 400 === 0;
|
|
558
|
+
return leap ? 29 : 28;
|
|
559
|
+
}
|
|
560
|
+
return [4, 6, 9, 11].includes(month) ? 30 : 31;
|
|
561
|
+
}
|
|
562
|
+
function isValidDateTime(value) {
|
|
563
|
+
if (typeof value !== "string") return false;
|
|
564
|
+
const m = DATE_TIME_PATTERN.exec(value);
|
|
565
|
+
if (m === null) return false;
|
|
566
|
+
const year = Number(m[1]);
|
|
567
|
+
const month = Number(m[2]);
|
|
568
|
+
const day = Number(m[3]);
|
|
569
|
+
const hour = Number(m[4]);
|
|
570
|
+
const minute = Number(m[5]);
|
|
571
|
+
const second = Number(m[6]);
|
|
572
|
+
if (month < 1 || month > 12) return false;
|
|
573
|
+
if (day < 1 || day > daysInMonth(year, month)) return false;
|
|
574
|
+
if (hour > 23 || minute > 59 || second > 59) return false;
|
|
575
|
+
if (m[7] !== void 0) {
|
|
576
|
+
const offsetHour = Number(m[8]);
|
|
577
|
+
const offsetMinute = Number(m[9]);
|
|
578
|
+
if (offsetHour > 23 || offsetMinute > 59) return false;
|
|
579
|
+
}
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
function validateDateTime(value, at) {
|
|
583
|
+
if (!isValidDateTime(value)) {
|
|
584
|
+
return [`${at} must be an RFC 3339 date-time with timezone offset, e.g. 2026-09-02T10:00:00+09:00.`];
|
|
585
|
+
}
|
|
586
|
+
return [];
|
|
587
|
+
}
|
|
588
|
+
function validatePlanPath(value, at) {
|
|
551
589
|
if (typeof value !== "string" || value.length === 0) {
|
|
552
|
-
|
|
590
|
+
return [`${at} must be a non-empty string.`];
|
|
591
|
+
}
|
|
592
|
+
if (!isValidPlanPath(value)) {
|
|
593
|
+
return [
|
|
594
|
+
`${at} must be a repository-relative path using '/' separators (no absolute paths, '\\', '.' or '..' segments).`
|
|
595
|
+
];
|
|
553
596
|
}
|
|
597
|
+
return [];
|
|
598
|
+
}
|
|
599
|
+
function checkPlan(value, at, errors) {
|
|
600
|
+
if (value === void 0) return;
|
|
601
|
+
errors.push(...validatePlanPath(value, `${at}.plan`));
|
|
554
602
|
}
|
|
555
603
|
function derivePhaseStatus(phase) {
|
|
556
604
|
if (phase.tasks.length > 0 && phase.tasks.every((task) => task.status === "done")) return "done";
|
|
@@ -572,10 +620,10 @@ function validateRoadmap(value) {
|
|
|
572
620
|
const project = value["project"];
|
|
573
621
|
checkKeys(project, ALLOWED_KEYS.project, "project", errors);
|
|
574
622
|
const projectRecord = isRecord(project) ? project : {};
|
|
575
|
-
if (!ID_PATTERN.test(
|
|
576
|
-
if (projectRecord["name"]
|
|
577
|
-
if (projectRecord["repository"]
|
|
578
|
-
|
|
623
|
+
if (typeof projectRecord["id"] !== "string" || !ID_PATTERN.test(projectRecord["id"])) errors.push("project.id is invalid.");
|
|
624
|
+
if (typeof projectRecord["name"] !== "string" || projectRecord["name"].length === 0) errors.push("project.name must be a non-empty string.");
|
|
625
|
+
if (typeof projectRecord["repository"] !== "string" || projectRecord["repository"].length === 0) errors.push("project.repository must be a non-empty string.");
|
|
626
|
+
errors.push(...validateDateTime(value["updatedAt"], "updatedAt"));
|
|
579
627
|
if (typeof value["summary"] !== "string") errors.push("summary must be a string.");
|
|
580
628
|
if (!Array.isArray(value["phases"])) errors.push("phases must be an array.");
|
|
581
629
|
const allIds = /* @__PURE__ */ new Set();
|
|
@@ -585,11 +633,14 @@ function validateRoadmap(value) {
|
|
|
585
633
|
const at = `phases[${phaseIndex}]`;
|
|
586
634
|
checkKeys(rawPhase, ALLOWED_KEYS.phase, at, errors);
|
|
587
635
|
const phase = isRecord(rawPhase) ? rawPhase : {};
|
|
588
|
-
const phaseId =
|
|
589
|
-
if (!ID_PATTERN.test(
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
636
|
+
const phaseId = typeof phase["id"] === "string" ? phase["id"] : "";
|
|
637
|
+
if (typeof phase["id"] !== "string" || !ID_PATTERN.test(phase["id"])) {
|
|
638
|
+
errors.push(`${at}.id is invalid.`);
|
|
639
|
+
} else {
|
|
640
|
+
if (allIds.has(phaseId)) errors.push(`Duplicate id "${phaseId}".`);
|
|
641
|
+
allIds.add(phaseId);
|
|
642
|
+
}
|
|
643
|
+
if (typeof phase["title"] !== "string" || phase["title"].length === 0) errors.push(`${at}.title must be a non-empty string.`);
|
|
593
644
|
if (!STATUSES.has(String(phase["status"]))) errors.push(`${at}.status is invalid.`);
|
|
594
645
|
checkPlan(phase["plan"], at, errors);
|
|
595
646
|
if (!Array.isArray(phase["tasks"])) errors.push(`${at}.tasks must be an array.`);
|
|
@@ -598,34 +649,64 @@ function validateRoadmap(value) {
|
|
|
598
649
|
const taskAt = `${at}.tasks[${taskIndex}]`;
|
|
599
650
|
checkKeys(rawTask, ALLOWED_KEYS.task, taskAt, errors);
|
|
600
651
|
const task = isRecord(rawTask) ? rawTask : {};
|
|
601
|
-
const taskId =
|
|
602
|
-
if (!ID_PATTERN.test(
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
652
|
+
const taskId = typeof task["id"] === "string" ? task["id"] : "";
|
|
653
|
+
if (typeof task["id"] !== "string" || !ID_PATTERN.test(task["id"])) {
|
|
654
|
+
errors.push(`${taskAt}.id is invalid.`);
|
|
655
|
+
} else {
|
|
656
|
+
if (allIds.has(taskId)) errors.push(`Duplicate id "${taskId}".`);
|
|
657
|
+
allIds.add(taskId);
|
|
658
|
+
tasks.set(taskId, task);
|
|
659
|
+
}
|
|
660
|
+
if (typeof task["title"] !== "string" || task["title"].length === 0) errors.push(`${taskAt}.title must be a non-empty string.`);
|
|
607
661
|
if (!STATUSES.has(String(task["status"]))) errors.push(`${taskAt}.status is invalid.`);
|
|
608
662
|
checkPlan(task["plan"], taskAt, errors);
|
|
663
|
+
if (task["startedAt"] !== void 0 && task["startedAt"] !== null) {
|
|
664
|
+
errors.push(...validateDateTime(task["startedAt"], `${taskAt}.startedAt`));
|
|
665
|
+
}
|
|
666
|
+
if (task["completedAt"] !== void 0 && task["completedAt"] !== null) {
|
|
667
|
+
errors.push(...validateDateTime(task["completedAt"], `${taskAt}.completedAt`));
|
|
668
|
+
}
|
|
609
669
|
if (task["status"] === "done" && task["completedAt"] == null) warnings.push(`${taskId} is done without completedAt.`);
|
|
610
670
|
if (task["status"] === "in-progress" && task["startedAt"] == null) warnings.push(`${taskId} is in-progress without startedAt.`);
|
|
611
|
-
if (task["dependsOn"] !== void 0 && !Array.isArray(task["dependsOn"]))
|
|
671
|
+
if (task["dependsOn"] !== void 0 && !Array.isArray(task["dependsOn"])) {
|
|
672
|
+
errors.push(`${taskAt}.dependsOn must be an array.`);
|
|
673
|
+
} else if (Array.isArray(task["dependsOn"])) {
|
|
674
|
+
const seenDeps = /* @__PURE__ */ new Set();
|
|
675
|
+
for (const [i, dep] of task["dependsOn"].entries()) {
|
|
676
|
+
if (typeof dep !== "string") {
|
|
677
|
+
errors.push(`${taskAt}.dependsOn[${i}] must be a string.`);
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
if (!ID_PATTERN.test(dep)) {
|
|
681
|
+
errors.push(`${taskAt}.dependsOn[${i}] is invalid.`);
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
if (seenDeps.has(dep)) errors.push(`${taskAt}.dependsOn contains duplicate id "${dep}".`);
|
|
685
|
+
seenDeps.add(dep);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
612
688
|
}
|
|
613
|
-
if (Array.isArray(phase["tasks"]) && STATUSES.has(String(phase["status"]))) {
|
|
689
|
+
if (Array.isArray(phase["tasks"]) && rawTasks.every(isRecord) && STATUSES.has(String(phase["status"]))) {
|
|
614
690
|
const expected = derivePhaseStatus({ ...phase, tasks: rawTasks });
|
|
615
691
|
if (phase["status"] !== expected) warnings.push(`${phaseId} status is ${String(phase["status"])}; task states derive ${expected}.`);
|
|
616
692
|
}
|
|
617
693
|
}
|
|
618
694
|
for (const task of tasks.values()) {
|
|
619
695
|
for (const dependency of task.dependsOn ?? []) {
|
|
696
|
+
if (typeof dependency !== "string") continue;
|
|
620
697
|
if (!tasks.has(dependency)) warnings.push(`${task.id} depends on unknown task ${dependency}.`);
|
|
621
698
|
if (task.id === dependency) errors.push(`${task.id} cannot depend on itself.`);
|
|
622
699
|
}
|
|
623
700
|
}
|
|
624
701
|
const currentTaskId = value["currentTaskId"];
|
|
625
702
|
if (currentTaskId !== null && currentTaskId !== void 0) {
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
else
|
|
703
|
+
if (typeof currentTaskId !== "string") {
|
|
704
|
+
errors.push("currentTaskId must be a string id or null.");
|
|
705
|
+
} else {
|
|
706
|
+
const current = tasks.get(currentTaskId);
|
|
707
|
+
if (current == null) errors.push(`currentTaskId ${String(currentTaskId)} does not exist.`);
|
|
708
|
+
else if (current.status !== "in-progress") warnings.push(`currentTaskId ${String(currentTaskId)} is not in-progress.`);
|
|
709
|
+
}
|
|
629
710
|
}
|
|
630
711
|
return { errors, warnings };
|
|
631
712
|
}
|
|
@@ -1671,7 +1752,7 @@ function register4(program, deps) {
|
|
|
1671
1752
|
}
|
|
1672
1753
|
|
|
1673
1754
|
// 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";
|
|
1755
|
+
import { copyFile, mkdir as mkdir6, readFile as readFile6, readdir as readdir2, realpath, rename as rename2, rmdir, unlink as unlink2 } from "fs/promises";
|
|
1675
1756
|
import { existsSync as existsSync3 } from "fs";
|
|
1676
1757
|
|
|
1677
1758
|
// src/sync-repo.ts
|
|
@@ -1726,11 +1807,31 @@ async function loadValidRoadmap(filePath, readFile12) {
|
|
|
1726
1807
|
async function buildSyncBody(args) {
|
|
1727
1808
|
const planDocuments = [];
|
|
1728
1809
|
for (const ref of collectPlanRefs(args.roadmap)) {
|
|
1729
|
-
const
|
|
1810
|
+
const root = path10.resolve(args.repoRoot);
|
|
1811
|
+
const absolute = path10.resolve(root, ref.path);
|
|
1812
|
+
if (absolute !== root && !absolute.startsWith(root + path10.sep)) {
|
|
1813
|
+
args.deps.warn(`plan path outside repository root, skipping: ${ref.path}`);
|
|
1814
|
+
continue;
|
|
1815
|
+
}
|
|
1730
1816
|
if (!args.deps.fileExists(absolute)) {
|
|
1731
1817
|
args.deps.warn(`plan file not found, skipping: ${ref.path}`);
|
|
1732
1818
|
continue;
|
|
1733
1819
|
}
|
|
1820
|
+
if (args.deps.realpath !== void 0) {
|
|
1821
|
+
let real;
|
|
1822
|
+
let realRoot;
|
|
1823
|
+
try {
|
|
1824
|
+
real = await args.deps.realpath(absolute);
|
|
1825
|
+
realRoot = await args.deps.realpath(root);
|
|
1826
|
+
} catch {
|
|
1827
|
+
args.deps.warn(`plan file not found, skipping: ${ref.path}`);
|
|
1828
|
+
continue;
|
|
1829
|
+
}
|
|
1830
|
+
if (real !== realRoot && !real.startsWith(realRoot + path10.sep)) {
|
|
1831
|
+
args.deps.warn(`symlink escapes repository root, skipping: ${ref.path}`);
|
|
1832
|
+
continue;
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1734
1835
|
const content = await args.deps.readFile(absolute);
|
|
1735
1836
|
planDocuments.push({
|
|
1736
1837
|
path: ref.path,
|
|
@@ -1761,6 +1862,21 @@ async function runSync(args, deps) {
|
|
|
1761
1862
|
|
|
1762
1863
|
// src/sync-repo.ts
|
|
1763
1864
|
var ROADMAP_SLUG_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
|
1865
|
+
function collectContainedPlanAbsPaths(roadmaps, root, warn) {
|
|
1866
|
+
const planAbsPaths = /* @__PURE__ */ new Set();
|
|
1867
|
+
const resolvedRoot = path11.resolve(root);
|
|
1868
|
+
for (const roadmap of roadmaps) {
|
|
1869
|
+
for (const ref of collectPlanRefs(roadmap)) {
|
|
1870
|
+
const absolute = path11.resolve(resolvedRoot, ref.path);
|
|
1871
|
+
if (absolute !== resolvedRoot && !absolute.startsWith(resolvedRoot + path11.sep)) {
|
|
1872
|
+
warn(`chokidar target outside repo, skipping: ${ref.path}`);
|
|
1873
|
+
continue;
|
|
1874
|
+
}
|
|
1875
|
+
planAbsPaths.add(absolute);
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
return [...planAbsPaths];
|
|
1879
|
+
}
|
|
1764
1880
|
async function listRoadmapFiles(roadmapsDir, io) {
|
|
1765
1881
|
try {
|
|
1766
1882
|
const entries = await io.listDir(roadmapsDir);
|
|
@@ -1841,23 +1957,29 @@ async function syncRepo(args, io) {
|
|
|
1841
1957
|
return { slug, roadmap: await loadValidRoadmap(filePath, io.readFile) };
|
|
1842
1958
|
})
|
|
1843
1959
|
);
|
|
1844
|
-
const planAbsPaths =
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
}
|
|
1960
|
+
const planAbsPaths = collectContainedPlanAbsPaths(
|
|
1961
|
+
roadmaps.map(({ roadmap }) => roadmap),
|
|
1962
|
+
args.root,
|
|
1963
|
+
io.warn
|
|
1964
|
+
);
|
|
1850
1965
|
const results = [];
|
|
1851
1966
|
const repoIdentity = await io.repoIdentity(args.root);
|
|
1852
1967
|
for (const { slug, roadmap } of roadmaps) {
|
|
1853
1968
|
results.push(
|
|
1854
1969
|
await runSync(
|
|
1855
1970
|
{ repoRoot: args.root, projectId, slug, roadmap, repoIdentity },
|
|
1856
|
-
{
|
|
1971
|
+
{
|
|
1972
|
+
http: io.http,
|
|
1973
|
+
readFile: io.readFile,
|
|
1974
|
+
fileExists: io.fileExists,
|
|
1975
|
+
realpath: io.realpath,
|
|
1976
|
+
log: io.log,
|
|
1977
|
+
warn: io.warn
|
|
1978
|
+
}
|
|
1857
1979
|
)
|
|
1858
1980
|
);
|
|
1859
1981
|
}
|
|
1860
|
-
return { results, planAbsPaths
|
|
1982
|
+
return { results, planAbsPaths };
|
|
1861
1983
|
}
|
|
1862
1984
|
|
|
1863
1985
|
// src/commands/sync.ts
|
|
@@ -1879,6 +2001,7 @@ function register5(program, deps) {
|
|
|
1879
2001
|
{ root, defaultProjectId: deps.settings.defaultProjectId, migrateLegacy: true },
|
|
1880
2002
|
{
|
|
1881
2003
|
readFile: (p) => readFile6(p, "utf8"),
|
|
2004
|
+
realpath: (p) => realpath(p),
|
|
1882
2005
|
fileExists: (p) => existsSync3(p),
|
|
1883
2006
|
listDir: (p) => readdir2(p),
|
|
1884
2007
|
rename: rename2,
|
|
@@ -2444,7 +2567,7 @@ function register7(program, deps) {
|
|
|
2444
2567
|
}
|
|
2445
2568
|
|
|
2446
2569
|
// 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";
|
|
2570
|
+
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
2571
|
import { existsSync as existsSync4 } from "fs";
|
|
2449
2572
|
import path16 from "path";
|
|
2450
2573
|
import chokidar from "chokidar";
|
|
@@ -2736,6 +2859,7 @@ function register8(program, deps) {
|
|
|
2736
2859
|
{ root, defaultProjectId: deps.settings.defaultProjectId, migrateLegacy: false },
|
|
2737
2860
|
{
|
|
2738
2861
|
readFile: (p) => readFile9(p, "utf8"),
|
|
2862
|
+
realpath: (p) => realpath2(p),
|
|
2739
2863
|
fileExists: (p) => existsSync4(p),
|
|
2740
2864
|
listDir: (p) => readdir4(p),
|
|
2741
2865
|
rename: rename3,
|
|
@@ -2810,7 +2934,7 @@ function register8(program, deps) {
|
|
|
2810
2934
|
// src/update-cli.ts
|
|
2811
2935
|
import { execFile as execFile3 } from "child_process";
|
|
2812
2936
|
import { existsSync as existsSync5 } from "fs";
|
|
2813
|
-
import { realpath } from "fs/promises";
|
|
2937
|
+
import { realpath as realpath3 } from "fs/promises";
|
|
2814
2938
|
import { createRequire as createRequire2 } from "module";
|
|
2815
2939
|
import path18 from "path";
|
|
2816
2940
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
@@ -3038,7 +3162,7 @@ function getCurrentVersion() {
|
|
|
3038
3162
|
}
|
|
3039
3163
|
async function updateCli(opts = {}) {
|
|
3040
3164
|
const execFileAsync = promisify2(execFile3);
|
|
3041
|
-
const scriptPath = await
|
|
3165
|
+
const scriptPath = await realpath3(process.argv[1] ?? "");
|
|
3042
3166
|
return updateCliWith({
|
|
3043
3167
|
currentVersion: getCurrentVersion(),
|
|
3044
3168
|
scriptPath,
|
|
@@ -3059,7 +3183,7 @@ async function updateCli(opts = {}) {
|
|
|
3059
3183
|
};
|
|
3060
3184
|
}
|
|
3061
3185
|
},
|
|
3062
|
-
realpath,
|
|
3186
|
+
realpath: realpath3,
|
|
3063
3187
|
fetchLatest: () => fetchLatestFromRegistry(UPDATE_REQUEST_TIMEOUT_MS, UPDATE_FETCH_OPTIONS),
|
|
3064
3188
|
writeCache: writeUpdateCache,
|
|
3065
3189
|
unitExists: existsSync5,
|
|
@@ -78,6 +78,7 @@ Use direct JSON edits to add, remove, rename, or reorder phases and tasks, then
|
|
|
78
78
|
- Preserve task IDs across title changes so `dependsOn` remains stable.
|
|
79
79
|
- Keep exactly one `currentTaskId`; it must point to an `in-progress` task. Use `null` when nothing is active.
|
|
80
80
|
- Require `completedAt` for done tasks and `startedAt` for in-progress tasks.
|
|
81
|
+
- Timestamps must be RFC 3339 with a timezone offset (`2026-09-02T10:00:00+09:00` or `2026-09-02T01:00:00Z`; never `2026-09-02T10:00:00`); `dependsOn` items must be plain task ID strings and unique.
|
|
81
82
|
- Keep notes factual and current; replace stale implementation notes instead of accumulating a log.
|
|
82
83
|
- Never rewrite repository history or source code solely to make the roadmap appear complete.
|
|
83
84
|
- Finish every mutation by running `validate` and reporting the task status changed.
|
|
@@ -54,19 +54,19 @@ Use this reference when creating or structurally editing `.nolto/roadmaps/<slug>
|
|
|
54
54
|
- `project.id`: Stable lowercase identifier matching `^[a-z0-9][a-z0-9._-]*$`.
|
|
55
55
|
- `project.name`: Display name.
|
|
56
56
|
- `project.repository`: Absolute repository path when known.
|
|
57
|
-
- `updatedAt`:
|
|
57
|
+
- `updatedAt`: RFC 3339 date-time with a timezone offset (for example, `2026-07-20T14:30:00+09:00` or `2026-07-20T05:30:00Z`). Offset-less values (`2026-07-20T14:30:00`) and date-only values (`2026-07-20`) are rejected.
|
|
58
58
|
- `currentTaskId`: One active task ID or `null`.
|
|
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
|
|
66
|
-
- `startedAt`: Required in practice for `in-progress` tasks.
|
|
67
|
-
- `completedAt`: Required in practice for `done` tasks.
|
|
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
|
+
- `startedAt`: Required in practice for `in-progress` tasks. Uses the same RFC 3339 date-time format as `updatedAt`.
|
|
67
|
+
- `completedAt`: Required in practice for `done` tasks. Uses the same RFC 3339 date-time format as `updatedAt`.
|
|
68
68
|
- `note`: Current implementation detail or blocker. Omit when it adds no value.
|
|
69
|
-
- `dependsOn`: IDs of prerequisite tasks in the same roadmap.
|
|
69
|
+
- `dependsOn`: IDs of prerequisite tasks in the same roadmap. Each item must be a string matching `^[a-z0-9][a-z0-9._-]*$`; duplicates are rejected.
|
|
70
70
|
|
|
71
71
|
## Phase status derivation
|
|
72
72
|
|
|
@@ -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(" ")}`);
|