@nolto/cli 0.5.0 → 0.6.1
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 +50 -77
- package/dist/index.js +1072 -1646
- package/dist/skill/roadmap-progress/SKILL.md +83 -0
- package/dist/skill/roadmap-progress/agents/openai.yaml +4 -0
- package/dist/skill/roadmap-progress/references/schema.md +81 -0
- package/dist/skill/roadmap-progress/scripts/roadmap.mjs +247 -0
- package/package.json +7 -7
package/dist/index.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { createRequire as createRequire2 } from "module";
|
|
5
|
-
import { fileURLToPath as
|
|
6
|
-
import
|
|
5
|
+
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
6
|
+
import path12 from "path";
|
|
7
7
|
import { CommanderError } from "commander";
|
|
8
8
|
|
|
9
9
|
// src/config.ts
|
|
@@ -67,18 +67,9 @@ function isNetworkError(err) {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
// src/constants.ts
|
|
70
|
-
var PLAN_STATUSES = ["not_started", "in_progress", "done", "discarded"];
|
|
71
|
-
var TEST_VERDICTS = ["passed", "failed", "skipped"];
|
|
72
|
-
var REVIEW_VERDICTS = ["go", "no_go"];
|
|
73
|
-
var PLAN_DOCUMENT_KINDS = ["plan", "final_report", "review_report", "test_report", "other"];
|
|
74
|
-
var PLAN_TITLE_MAX = 500;
|
|
75
|
-
var PLAN_CONTENT_MAX = 5e4;
|
|
76
|
-
var PHASES_MAX = 50;
|
|
77
70
|
var DOCUMENT_MAX_BYTES = 2 * 1024 * 1024;
|
|
78
|
-
var DOCUMENT_FILENAME_MAX = 255;
|
|
79
71
|
var DEFAULT_BASE_URL = "https://nolto.app";
|
|
80
72
|
var CLI_USER_AGENT_NAME = "nolto-cli";
|
|
81
|
-
var QUEUE_MAX_ENTRIES = 200;
|
|
82
73
|
|
|
83
74
|
// src/config.ts
|
|
84
75
|
var configSchema = z.object({
|
|
@@ -86,7 +77,10 @@ var configSchema = z.object({
|
|
|
86
77
|
baseUrl: z.string().url().optional(),
|
|
87
78
|
defaultProjectId: z.string().uuid().optional()
|
|
88
79
|
}).strict();
|
|
89
|
-
var repoBindingSchema = z.object({
|
|
80
|
+
var repoBindingSchema = z.object({
|
|
81
|
+
projectId: z.string().uuid(),
|
|
82
|
+
roadmapSlug: z.string().regex(/^[a-z0-9][a-z0-9._-]*$/).optional()
|
|
83
|
+
}).passthrough();
|
|
90
84
|
async function loadRepoBinding(filePath) {
|
|
91
85
|
let raw;
|
|
92
86
|
try {
|
|
@@ -135,8 +129,7 @@ function findRepoBindingFile(args) {
|
|
|
135
129
|
const startDir = cpd != null && cpd.length > 0 ? cpd : args.cwd;
|
|
136
130
|
return findNoltoJsonSync(startDir);
|
|
137
131
|
}
|
|
138
|
-
async function
|
|
139
|
-
const filePath = path.join(root, "nolto.json");
|
|
132
|
+
async function mergeJsonFile(filePath, patch) {
|
|
140
133
|
let existing = {};
|
|
141
134
|
try {
|
|
142
135
|
const raw = await readFile(filePath, "utf8");
|
|
@@ -154,11 +147,17 @@ async function writeRepoBinding(root, projectId) {
|
|
|
154
147
|
if (code !== "ENOENT") {
|
|
155
148
|
}
|
|
156
149
|
}
|
|
157
|
-
const merged = { ...existing,
|
|
150
|
+
const merged = { ...existing, ...patch };
|
|
158
151
|
const { chmod } = await import("fs/promises");
|
|
159
152
|
await writeFile(filePath, JSON.stringify(merged, null, 2) + "\n", { mode: 420 });
|
|
160
153
|
await chmod(filePath, 420);
|
|
161
154
|
}
|
|
155
|
+
async function writeRepoBinding(root, projectId) {
|
|
156
|
+
await mergeJsonFile(path.join(root, "nolto.json"), { projectId });
|
|
157
|
+
}
|
|
158
|
+
async function writeRoadmapSlug(root, slug) {
|
|
159
|
+
await mergeJsonFile(path.join(root, "nolto.json"), { roadmapSlug: slug });
|
|
160
|
+
}
|
|
162
161
|
function getConfigDir(env) {
|
|
163
162
|
const xdg = env["XDG_CONFIG_HOME"];
|
|
164
163
|
const base = xdg != null && xdg.length > 0 ? xdg : path.join(os.homedir(), ".config");
|
|
@@ -273,80 +272,49 @@ function maskToken(token) {
|
|
|
273
272
|
return "\u2026" + token.slice(-4);
|
|
274
273
|
}
|
|
275
274
|
|
|
276
|
-
// src/
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
};
|
|
299
|
-
const transport = new StreamableHTTPClientTransport(buildMcpEndpoint(baseUrl), {
|
|
300
|
-
requestInit: {
|
|
301
|
-
headers: { Authorization: `Bearer ${token}` }
|
|
302
|
-
},
|
|
303
|
-
fetch: capturingFetch
|
|
275
|
+
// src/http.ts
|
|
276
|
+
function createHttpClient(opts) {
|
|
277
|
+
const { baseUrl, version, token } = opts;
|
|
278
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
279
|
+
async function request(method, path13, body) {
|
|
280
|
+
if (!path13.startsWith("/api/")) {
|
|
281
|
+
throw new CliError(`HTTP client path must start with /api/, got: ${path13}`, 2);
|
|
282
|
+
}
|
|
283
|
+
const url = `${base}${path13}`;
|
|
284
|
+
const headers = {
|
|
285
|
+
"Content-Type": "application/json",
|
|
286
|
+
"User-Agent": `${CLI_USER_AGENT_NAME}/${version}`
|
|
287
|
+
};
|
|
288
|
+
if (token) {
|
|
289
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
290
|
+
}
|
|
291
|
+
let response;
|
|
292
|
+
try {
|
|
293
|
+
response = await fetch(url, {
|
|
294
|
+
method,
|
|
295
|
+
headers,
|
|
296
|
+
body: body === void 0 ? void 0 : JSON.stringify(body)
|
|
304
297
|
});
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const firstText = Array.isArray(content2) && content2.length > 0 ? content2[0]?.text : void 0;
|
|
312
|
-
throw new CliError(firstText ?? "Tool error", 1);
|
|
313
|
-
}
|
|
314
|
-
const content = result.content;
|
|
315
|
-
const firstItem = Array.isArray(content) && content.length > 0 ? content[0] : void 0;
|
|
316
|
-
const rawText = firstItem?.text;
|
|
317
|
-
if (rawText == null) {
|
|
318
|
-
return result;
|
|
319
|
-
}
|
|
320
|
-
try {
|
|
321
|
-
return JSON.parse(rawText);
|
|
322
|
-
} catch {
|
|
323
|
-
return rawText;
|
|
324
|
-
}
|
|
325
|
-
} catch (err) {
|
|
326
|
-
if (err instanceof CliError) {
|
|
327
|
-
throw err;
|
|
328
|
-
}
|
|
329
|
-
if (capture.lastNonOk != null) {
|
|
330
|
-
throw mapHttpStatusToCliError(
|
|
331
|
-
capture.lastNonOk.status,
|
|
332
|
-
capture.lastNonOk.retryAfter,
|
|
333
|
-
capture.lastNonOk.wwwAuthenticate
|
|
334
|
-
);
|
|
335
|
-
}
|
|
336
|
-
if (isNetworkError(err)) {
|
|
337
|
-
throw new CliError(`Cannot reach ${baseUrl}`, 5);
|
|
338
|
-
}
|
|
339
|
-
if (err instanceof Error && err.message) {
|
|
340
|
-
throw new CliError(err.message, 1);
|
|
341
|
-
}
|
|
342
|
-
throw new CliError(String(err), 1);
|
|
343
|
-
} finally {
|
|
344
|
-
try {
|
|
345
|
-
await client.close();
|
|
346
|
-
} catch {
|
|
347
|
-
}
|
|
298
|
+
} catch (err) {
|
|
299
|
+
if (isNetworkError(err)) {
|
|
300
|
+
throw new CliError(
|
|
301
|
+
`Network error contacting ${base}: ${err instanceof Error ? err.message : String(err)}`,
|
|
302
|
+
5
|
|
303
|
+
);
|
|
348
304
|
}
|
|
305
|
+
throw err;
|
|
349
306
|
}
|
|
307
|
+
if (!response.ok) {
|
|
308
|
+
const retryAfter = response.headers.get("retry-after") ?? void 0;
|
|
309
|
+
const wwwAuthenticate = response.headers.get("www-authenticate") ?? void 0;
|
|
310
|
+
throw mapHttpStatusToCliError(response.status, retryAfter, wwwAuthenticate);
|
|
311
|
+
}
|
|
312
|
+
return response.json();
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
get: (p) => request("GET", p),
|
|
316
|
+
post: (p, b) => request("POST", p, b),
|
|
317
|
+
put: (p, b) => request("PUT", p, b)
|
|
350
318
|
};
|
|
351
319
|
}
|
|
352
320
|
|
|
@@ -356,466 +324,620 @@ import { Command } from "commander";
|
|
|
356
324
|
// src/commands/init.ts
|
|
357
325
|
import readline from "readline/promises";
|
|
358
326
|
import { createRequire } from "module";
|
|
359
|
-
import { fileURLToPath } from "url";
|
|
360
|
-
import
|
|
327
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
328
|
+
import path6 from "path";
|
|
361
329
|
import fs from "fs";
|
|
362
330
|
|
|
363
|
-
// src/
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
331
|
+
// src/commands/link.ts
|
|
332
|
+
import path2 from "path";
|
|
333
|
+
import { statSync as statSync2 } from "fs";
|
|
334
|
+
|
|
335
|
+
// src/output.ts
|
|
336
|
+
function printResult(value, mode2, opts = {}) {
|
|
337
|
+
const out = opts.stream ?? process.stdout;
|
|
338
|
+
if (mode2 === "json") {
|
|
339
|
+
out.write(JSON.stringify(value, null, 2) + "\n");
|
|
340
|
+
} else {
|
|
341
|
+
out.write(formatValue(value) + "\n");
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
function printError(err, mode2) {
|
|
345
|
+
if (mode2 === "json") {
|
|
346
|
+
const envelope = {
|
|
347
|
+
error: {
|
|
348
|
+
message: err.message,
|
|
349
|
+
exitCode: err.exitCode,
|
|
350
|
+
...err.status != null ? { status: err.status } : {},
|
|
351
|
+
...err.hint != null ? { hint: err.hint } : {}
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
process.stderr.write(JSON.stringify(envelope, null, 2) + "\n");
|
|
355
|
+
} else {
|
|
356
|
+
process.stderr.write(`Error: ${err.message}
|
|
357
|
+
`);
|
|
358
|
+
if (err.hint != null) {
|
|
359
|
+
process.stderr.write(`Hint: ${err.hint}
|
|
360
|
+
`);
|
|
372
361
|
}
|
|
373
362
|
}
|
|
374
|
-
|
|
363
|
+
}
|
|
364
|
+
function formatValue(value) {
|
|
365
|
+
if (value === null || value === void 0) {
|
|
366
|
+
return "";
|
|
367
|
+
}
|
|
368
|
+
if (typeof value === "string") {
|
|
369
|
+
return value;
|
|
370
|
+
}
|
|
371
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
372
|
+
return String(value);
|
|
373
|
+
}
|
|
374
|
+
if (Array.isArray(value)) {
|
|
375
|
+
return value.map((v) => formatValue(v)).join("\n");
|
|
376
|
+
}
|
|
377
|
+
if (typeof value === "object") {
|
|
378
|
+
const obj = value;
|
|
379
|
+
const keys = Object.keys(obj);
|
|
380
|
+
const maxKeyLen = keys.reduce((m, k) => Math.max(m, k.length), 0);
|
|
381
|
+
return keys.map((k) => `${k.padEnd(maxKeyLen)}: ${formatValue(obj[k])}`).join("\n");
|
|
382
|
+
}
|
|
383
|
+
return JSON.stringify(value, null, 2);
|
|
375
384
|
}
|
|
376
385
|
|
|
377
|
-
// src/commands/
|
|
378
|
-
var
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
386
|
+
// src/commands/link.ts
|
|
387
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
388
|
+
function resolveStartDir(env, cwd) {
|
|
389
|
+
const cpd = env["CLAUDE_PROJECT_DIR"];
|
|
390
|
+
return cpd != null && cpd.length > 0 ? cpd : cwd;
|
|
391
|
+
}
|
|
392
|
+
function dirHasGit(dir) {
|
|
393
|
+
try {
|
|
394
|
+
statSync2(path2.join(dir, ".git"));
|
|
395
|
+
return true;
|
|
396
|
+
} catch {
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
function findRepoRoot(startDir, hasGit = dirHasGit) {
|
|
401
|
+
let current = startDir;
|
|
402
|
+
while (true) {
|
|
403
|
+
if (hasGit(current)) {
|
|
404
|
+
return { root: current, foundGit: true };
|
|
394
405
|
}
|
|
406
|
+
const parent = path2.dirname(current);
|
|
407
|
+
if (parent === current) break;
|
|
408
|
+
current = parent;
|
|
395
409
|
}
|
|
396
|
-
return
|
|
410
|
+
return { root: startDir, foundGit: false };
|
|
397
411
|
}
|
|
398
|
-
function
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
existing = await loadConfigFile(configPath);
|
|
405
|
-
} catch {
|
|
406
|
-
}
|
|
407
|
-
if (existing != null) {
|
|
408
|
-
const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
409
|
-
try {
|
|
410
|
-
const answer = await rl2.question(`Config already exists at ${configPath}. Overwrite? [y/N] `);
|
|
411
|
-
if (answer.trim().toLowerCase() !== "y") {
|
|
412
|
-
process.stdout.write("Cancelled.\n");
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
} finally {
|
|
416
|
-
rl2.close();
|
|
417
|
-
}
|
|
418
|
-
}
|
|
412
|
+
async function handleShow(deps, projectBindingPath, mode2) {
|
|
413
|
+
if (projectBindingPath == null) {
|
|
414
|
+
if (mode2 === "json") {
|
|
415
|
+
printResult({ bound: false, projectBindingPath: null }, mode2);
|
|
416
|
+
} else {
|
|
417
|
+
process.stdout.write("No nolto.json binding found in this directory tree.\n");
|
|
419
418
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
throw new CliError(`Token rejected by ${baseUrl}`, 3, "Check that your token is valid and has not been revoked.");
|
|
437
|
-
}
|
|
438
|
-
throw err;
|
|
439
|
-
}
|
|
440
|
-
let defaultProjectId;
|
|
441
|
-
let defaultProjectName;
|
|
442
|
-
if (projects.length > 0) {
|
|
443
|
-
process.stdout.write("\nProjects:\n");
|
|
444
|
-
projects.forEach((p, i) => {
|
|
445
|
-
process.stdout.write(` (${i + 1}) ${p.name} \u2014 ${p.id}
|
|
446
|
-
`);
|
|
447
|
-
});
|
|
448
|
-
const pick = await rl.question("Default project number (or skip): ");
|
|
449
|
-
const num = parseInt(pick.trim(), 10);
|
|
450
|
-
if (!isNaN(num) && num >= 1 && num <= projects.length) {
|
|
451
|
-
defaultProjectId = projects[num - 1].id;
|
|
452
|
-
defaultProjectName = projects[num - 1].name;
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
await saveConfigFile(configPath, {
|
|
456
|
-
token,
|
|
457
|
-
baseUrl: baseUrl !== DEFAULT_BASE_URL ? baseUrl : void 0,
|
|
458
|
-
defaultProjectId
|
|
459
|
-
});
|
|
460
|
-
const projectDisplay = defaultProjectId != null ? `${defaultProjectName ?? ""} (${defaultProjectId})` : "not set";
|
|
461
|
-
process.stdout.write(`
|
|
462
|
-
Saved ${configPath}
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const binding = await loadRepoBinding(projectBindingPath).catch((err) => {
|
|
422
|
+
if (err instanceof CliError) throw err;
|
|
423
|
+
throw new CliError(`Cannot read binding: ${String(err)}`, 2);
|
|
424
|
+
});
|
|
425
|
+
if (mode2 === "json") {
|
|
426
|
+
printResult({
|
|
427
|
+
bound: binding != null,
|
|
428
|
+
projectId: binding?.projectId ?? null,
|
|
429
|
+
projectBindingPath,
|
|
430
|
+
source: deps.settings.source.project === "repo" ? "repo" : "file"
|
|
431
|
+
}, mode2);
|
|
432
|
+
} else {
|
|
433
|
+
if (binding == null) {
|
|
434
|
+
process.stdout.write(`Binding file found at ${projectBindingPath} but could not be read.
|
|
463
435
|
`);
|
|
464
|
-
|
|
436
|
+
} else {
|
|
437
|
+
process.stdout.write(`Binding file : ${projectBindingPath}
|
|
465
438
|
`);
|
|
466
|
-
process.stdout.write(`
|
|
439
|
+
process.stdout.write(`projectId : ${binding.projectId}
|
|
467
440
|
`);
|
|
468
|
-
|
|
441
|
+
const active = deps.settings.source.project === "repo" ? "repo (active)" : "repo (not active \u2014 overridden)";
|
|
442
|
+
process.stdout.write(`source : ${active}
|
|
469
443
|
`);
|
|
470
|
-
} finally {
|
|
471
|
-
rl.close();
|
|
472
444
|
}
|
|
473
|
-
}
|
|
445
|
+
}
|
|
474
446
|
}
|
|
475
|
-
async function
|
|
476
|
-
const
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
return;
|
|
447
|
+
async function handleUnlink(projectBindingPath, mode2) {
|
|
448
|
+
const { readFile: readFile7, writeFile: writeFile6, chmod } = await import("fs/promises");
|
|
449
|
+
let existing = {};
|
|
450
|
+
try {
|
|
451
|
+
const raw = await readFile7(projectBindingPath, "utf8");
|
|
452
|
+
const parsed = JSON.parse(raw);
|
|
453
|
+
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
454
|
+
throw new CliError(
|
|
455
|
+
`Cannot unlink ${projectBindingPath}: file contains valid JSON but is not a plain object (got ${Array.isArray(parsed) ? "array" : String(parsed)}). Remove or fix the file manually.`,
|
|
456
|
+
2
|
|
457
|
+
);
|
|
487
458
|
}
|
|
488
|
-
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
const { baseUrl, version, token } = opts;
|
|
504
|
-
const base = baseUrl.replace(/\/+$/, "");
|
|
505
|
-
return {
|
|
506
|
-
async post(path9, body) {
|
|
507
|
-
if (!path9.startsWith("/api/")) {
|
|
508
|
-
throw new CliError(`HTTP client path must start with /api/, got: ${path9}`, 2);
|
|
509
|
-
}
|
|
510
|
-
const url = `${base}${path9}`;
|
|
511
|
-
const headers = {
|
|
512
|
-
"Content-Type": "application/json",
|
|
513
|
-
"User-Agent": `${CLI_USER_AGENT_NAME}/${version}`
|
|
514
|
-
};
|
|
515
|
-
if (token) {
|
|
516
|
-
headers["Authorization"] = `Bearer ${token}`;
|
|
517
|
-
}
|
|
518
|
-
let response;
|
|
519
|
-
try {
|
|
520
|
-
response = await fetch(url, {
|
|
521
|
-
method: "POST",
|
|
522
|
-
headers,
|
|
523
|
-
body: JSON.stringify(body)
|
|
524
|
-
});
|
|
525
|
-
} catch (err) {
|
|
526
|
-
if (isNetworkError(err)) {
|
|
527
|
-
throw new CliError(
|
|
528
|
-
`Network error contacting ${base}: ${err instanceof Error ? err.message : String(err)}`,
|
|
529
|
-
5
|
|
530
|
-
);
|
|
531
|
-
}
|
|
532
|
-
throw err;
|
|
533
|
-
}
|
|
534
|
-
if (!response.ok) {
|
|
535
|
-
const retryAfter = response.headers.get("retry-after") ?? void 0;
|
|
536
|
-
const wwwAuthenticate = response.headers.get("www-authenticate") ?? void 0;
|
|
537
|
-
throw mapHttpStatusToCliError(response.status, retryAfter, wwwAuthenticate);
|
|
538
|
-
}
|
|
539
|
-
return response.json();
|
|
540
|
-
}
|
|
541
|
-
};
|
|
459
|
+
existing = parsed;
|
|
460
|
+
} catch (err) {
|
|
461
|
+
if (err instanceof CliError) throw err;
|
|
462
|
+
throw new CliError(`Cannot read ${projectBindingPath}: ${String(err)}`, 2);
|
|
463
|
+
}
|
|
464
|
+
const { projectId: _removed, ...rest } = existing;
|
|
465
|
+
void _removed;
|
|
466
|
+
await writeFile6(projectBindingPath, JSON.stringify(rest, null, 2) + "\n", { mode: 420 });
|
|
467
|
+
await chmod(projectBindingPath, 420);
|
|
468
|
+
if (mode2 === "json") {
|
|
469
|
+
printResult({ unlinked: true, projectBindingPath }, mode2);
|
|
470
|
+
} else {
|
|
471
|
+
process.stdout.write(`Removed projectId from ${projectBindingPath}.
|
|
472
|
+
`);
|
|
473
|
+
}
|
|
542
474
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
475
|
+
async function performLink(deps, projectId, mode2) {
|
|
476
|
+
if (!UUID_RE.test(projectId)) {
|
|
477
|
+
throw new CliError(
|
|
478
|
+
`Invalid project ID: "${projectId}". Must be a UUID (e.g. 00000000-0000-0000-0000-000000000001).`,
|
|
479
|
+
2
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
const startDir = resolveStartDir(process.env, process.cwd());
|
|
483
|
+
const { root, foundGit } = findRepoRoot(startDir);
|
|
484
|
+
if (!foundGit) {
|
|
485
|
+
process.stderr.write(
|
|
486
|
+
`Warning: no .git directory found above ${startDir}. Writing nolto.json to current directory.
|
|
487
|
+
`
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
if (deps.settings.token != null) {
|
|
552
491
|
try {
|
|
553
|
-
|
|
554
|
-
|
|
492
|
+
const http = deps.http ?? createHttpClient({
|
|
493
|
+
baseUrl: deps.settings.baseUrl,
|
|
494
|
+
token: deps.settings.token,
|
|
495
|
+
version: deps.version
|
|
555
496
|
});
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
497
|
+
const result = await http.get(
|
|
498
|
+
"/api/projects"
|
|
499
|
+
);
|
|
500
|
+
const projects = Array.isArray(result.projects) ? result.projects : [];
|
|
501
|
+
if (!projects.some((p) => p.id === projectId)) {
|
|
502
|
+
process.stderr.write(
|
|
503
|
+
`Warning: project ${projectId} was not found in GET /api/projects.
|
|
504
|
+
Proceeding anyway \u2014 verify the ID is correct.
|
|
505
|
+
`
|
|
506
|
+
);
|
|
560
507
|
}
|
|
561
|
-
|
|
508
|
+
} catch {
|
|
509
|
+
process.stderr.write(
|
|
510
|
+
"Warning: could not verify project membership (offline or token issue). Proceeding anyway.\n"
|
|
511
|
+
);
|
|
562
512
|
}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
513
|
+
}
|
|
514
|
+
await writeRepoBinding(root, projectId);
|
|
515
|
+
const writtenPath = path2.join(root, "nolto.json");
|
|
516
|
+
if (mode2 === "json") {
|
|
517
|
+
printResult({ linked: true, projectId, projectBindingPath: writtenPath }, mode2);
|
|
518
|
+
} else {
|
|
519
|
+
process.stdout.write(
|
|
520
|
+
`Linked this repo to project ${projectId} (wrote ${writtenPath}).
|
|
521
|
+
Commit nolto.json to share the binding with your team.
|
|
522
|
+
`
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
function register(program, deps) {
|
|
527
|
+
const cmd = program.command("link [projectId]").description(
|
|
528
|
+
"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 --unlink Remove the projectId from nolto.json"
|
|
529
|
+
).option("--show", "Show the current repo binding (path + projectId + source)").option("--unlink", "Remove the projectId key from nolto.json");
|
|
530
|
+
cmd.action(async (projectId) => {
|
|
531
|
+
const { output } = deps;
|
|
532
|
+
const projectBindingPath = deps.projectBindingPath ?? null;
|
|
533
|
+
const mode2 = output.mode;
|
|
534
|
+
if (cmd.opts()["show"]) {
|
|
535
|
+
await handleShow(deps, projectBindingPath, mode2);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (cmd.opts()["unlink"]) {
|
|
539
|
+
if (projectBindingPath == null) {
|
|
540
|
+
throw new CliError("No nolto.json found in this directory tree. Nothing to unlink.", 2);
|
|
566
541
|
}
|
|
567
|
-
|
|
542
|
+
await handleUnlink(projectBindingPath, mode2);
|
|
543
|
+
return;
|
|
568
544
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
intervalSeconds += 5;
|
|
575
|
-
await sleep(intervalSeconds * 1e3);
|
|
576
|
-
break;
|
|
577
|
-
case "expired_token":
|
|
578
|
-
throw new CliError("Device code expired. Run `nolto login` again.", 2);
|
|
579
|
-
case "access_denied":
|
|
580
|
-
throw new CliError("\u30ED\u30B0\u30A4\u30F3\u304C\u62D2\u5426\u3055\u308C\u307E\u3057\u305F\u3002", 3);
|
|
581
|
-
case "token_cap_exceeded":
|
|
582
|
-
throw new CliError(
|
|
583
|
-
`\u30A2\u30AF\u30C6\u30A3\u30D6\u306A API token \u304C\u4E0A\u9650(20)\u3067\u3059\u3002${baseUrl}/settings/tokens \u3067\u4E0D\u8981\u306A\u30C8\u30FC\u30AF\u30F3\u3092\u5931\u52B9\u3057\u3066\u304B\u3089\u518D\u5B9F\u884C\u3057\u3066\u304F\u3060\u3055\u3044\u3002`,
|
|
584
|
-
2
|
|
585
|
-
);
|
|
586
|
-
default:
|
|
587
|
-
throw new CliError(`Unexpected poll response: ${resp.error}`, 5);
|
|
545
|
+
if (projectId == null || projectId.trim().length === 0) {
|
|
546
|
+
throw new CliError(
|
|
547
|
+
"Usage: nolto link <projectId> (provide a UUID)\nOr use --show to view the current binding, --unlink to remove it.",
|
|
548
|
+
2
|
|
549
|
+
);
|
|
588
550
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
}
|
|
592
|
-
function sleep(ms) {
|
|
593
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
551
|
+
await performLink(deps, projectId, mode2);
|
|
552
|
+
});
|
|
594
553
|
}
|
|
595
554
|
|
|
596
|
-
// src/
|
|
597
|
-
import {
|
|
598
|
-
import {
|
|
599
|
-
import { promisify } from "util";
|
|
600
|
-
import os2 from "os";
|
|
555
|
+
// src/skill-install.ts
|
|
556
|
+
import { cp, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2, rm } from "fs/promises";
|
|
557
|
+
import { existsSync } from "fs";
|
|
601
558
|
import path3 from "path";
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
async function
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
const code = err.code;
|
|
624
|
-
if (code === "ENOENT") return null;
|
|
625
|
-
return null;
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
async function injectCursor(opts) {
|
|
629
|
-
const configDir = opts.cursorConfigDir ?? process.env["CURSOR_CONFIG_DIR"] ?? path3.join(os2.homedir(), ".cursor");
|
|
630
|
-
const configPath = path3.join(configDir, "mcp.json");
|
|
631
|
-
const mcpUrl = `${opts.baseUrl.replace(/\/+$/, "")}/mcp`;
|
|
632
|
-
let existing = {};
|
|
633
|
-
const raw = await readFile2(configPath, "utf8").catch((err) => {
|
|
634
|
-
if (err.code === "ENOENT") return null;
|
|
635
|
-
return null;
|
|
636
|
-
});
|
|
637
|
-
if (raw !== null) {
|
|
559
|
+
import { fileURLToPath } from "url";
|
|
560
|
+
var __dirname = path3.dirname(fileURLToPath(import.meta.url));
|
|
561
|
+
function resolveSkillSourceDir() {
|
|
562
|
+
const candidates = [
|
|
563
|
+
path3.resolve(__dirname, "skill/roadmap-progress"),
|
|
564
|
+
// bundled: dist/skill/...
|
|
565
|
+
path3.resolve(__dirname, "../../../skills/roadmap-progress")
|
|
566
|
+
// source: <repo>/skills/...
|
|
567
|
+
];
|
|
568
|
+
for (const candidate of candidates) {
|
|
569
|
+
if (existsSync(path3.join(candidate, "SKILL.md"))) return candidate;
|
|
570
|
+
}
|
|
571
|
+
throw new Error("Bundled roadmap-progress skill not found. Reinstall @nolto/cli.");
|
|
572
|
+
}
|
|
573
|
+
var VERSION_MARKER = ".nolto-skill-version";
|
|
574
|
+
async function installSkill(args) {
|
|
575
|
+
const targetDir = path3.join(args.skillsParentDir, "roadmap-progress");
|
|
576
|
+
const markerPath = path3.join(targetDir, VERSION_MARKER);
|
|
577
|
+
const dirExists = existsSync(targetDir);
|
|
578
|
+
let installedVersion = null;
|
|
579
|
+
if (dirExists) {
|
|
638
580
|
try {
|
|
639
|
-
|
|
581
|
+
installedVersion = (await readFile2(markerPath, "utf8")).trim();
|
|
640
582
|
} catch {
|
|
641
|
-
|
|
642
|
-
process.stdout.write(
|
|
643
|
-
`Warning: ${configPath} contains invalid JSON. Backing up to ${backupPath}
|
|
644
|
-
`
|
|
645
|
-
);
|
|
646
|
-
await writeFile2(backupPath, raw, { mode: 384 }).catch(() => void 0);
|
|
647
|
-
existing = {};
|
|
583
|
+
installedVersion = null;
|
|
648
584
|
}
|
|
649
585
|
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
nolto: {
|
|
653
|
-
url: mcpUrl,
|
|
654
|
-
headers: {
|
|
655
|
-
Authorization: `Bearer ${opts.token}`
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
};
|
|
659
|
-
const newConfig = {
|
|
660
|
-
...existing,
|
|
661
|
-
mcpServers: updatedServers
|
|
662
|
-
};
|
|
663
|
-
await atomicWrite(configPath, JSON.stringify(newConfig, null, 2) + "\n", 384);
|
|
664
|
-
process.stdout.write(`Updated ${configPath} (cursor)
|
|
665
|
-
`);
|
|
666
|
-
}
|
|
667
|
-
async function injectClaude(opts) {
|
|
668
|
-
const mcpUrl = `${opts.baseUrl.replace(/\/+$/, "")}/mcp`;
|
|
669
|
-
const claudePath = await findOnPath("claude");
|
|
670
|
-
if (claudePath) {
|
|
671
|
-
const execFileAsync2 = getExecFileAsync();
|
|
672
|
-
await execFileAsync2(claudePath, ["mcp", "remove", "nolto", "--scope", "user"]).catch(
|
|
673
|
-
() => void 0
|
|
674
|
-
);
|
|
675
|
-
try {
|
|
676
|
-
const { stderr } = await execFileAsync2(claudePath, [
|
|
677
|
-
"mcp",
|
|
678
|
-
"add",
|
|
679
|
-
"nolto",
|
|
680
|
-
mcpUrl,
|
|
681
|
-
"--transport",
|
|
682
|
-
"http",
|
|
683
|
-
"--scope",
|
|
684
|
-
"user",
|
|
685
|
-
"--header",
|
|
686
|
-
`Authorization: Bearer ${opts.token}`
|
|
687
|
-
]);
|
|
688
|
-
if (stderr) {
|
|
689
|
-
process.stderr.write(`[claude mcp add] ${stderr}
|
|
690
|
-
`);
|
|
691
|
-
}
|
|
692
|
-
process.stdout.write("Registered nolto MCP server in Claude Code (user scope).\n");
|
|
693
|
-
process.stdout.write("Reconnect or restart Claude Code to use it.\n");
|
|
694
|
-
return;
|
|
695
|
-
} catch (err) {
|
|
696
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
697
|
-
process.stderr.write(
|
|
698
|
-
`Warning: claude mcp add failed (${msg}); writing project .mcp.json instead.
|
|
699
|
-
`
|
|
700
|
-
);
|
|
701
|
-
}
|
|
586
|
+
if (dirExists && installedVersion === args.version && args.force !== true) {
|
|
587
|
+
return { action: "skipped", targetDir };
|
|
702
588
|
}
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
...existing.mcpServers,
|
|
709
|
-
nolto: {
|
|
710
|
-
url: mcpUrl,
|
|
711
|
-
headers: {
|
|
712
|
-
Authorization: "Bearer ${NOLTO_MCP_TOKEN}"
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
};
|
|
716
|
-
const newConfig = {
|
|
717
|
-
...existing,
|
|
718
|
-
mcpServers: updatedServers
|
|
719
|
-
};
|
|
720
|
-
await atomicWrite(configPath, JSON.stringify(newConfig, null, 2) + "\n", 384);
|
|
721
|
-
const tokensUrl = `${opts.baseUrl.replace(/\/+$/, "")}/settings/tokens`;
|
|
722
|
-
process.stdout.write(
|
|
723
|
-
`Updated ${configPath} (claude project .mcp.json) \u2014 uses the NOLTO_MCP_TOKEN env var; no token is stored in the file, so it is safe to commit.
|
|
724
|
-
`
|
|
725
|
-
);
|
|
726
|
-
process.stdout.write("Set NOLTO_MCP_TOKEN in your environment before launching Claude Code, e.g.:\n");
|
|
727
|
-
process.stdout.write(" export NOLTO_MCP_TOKEN=<your token>\n");
|
|
728
|
-
process.stdout.write(`Your token is saved in your nolto config (from this login); or create one at ${tokensUrl}
|
|
729
|
-
`);
|
|
589
|
+
await rm(targetDir, { recursive: true, force: true });
|
|
590
|
+
await mkdir2(args.skillsParentDir, { recursive: true });
|
|
591
|
+
await cp(args.sourceDir, targetDir, { recursive: true });
|
|
592
|
+
await writeFile2(markerPath, args.version + "\n", "utf8");
|
|
593
|
+
return { action: dirExists ? "updated" : "installed", targetDir };
|
|
730
594
|
}
|
|
731
|
-
async function injectCodex(opts) {
|
|
732
|
-
const mcpUrl = `${opts.baseUrl.replace(/\/+$/, "")}/mcp`;
|
|
733
|
-
const codexPath = await findOnPath("codex");
|
|
734
|
-
if (!codexPath) {
|
|
735
|
-
process.stdout.write(
|
|
736
|
-
`
|
|
737
|
-
To add Nolto to Codex, run:
|
|
738
|
-
|
|
739
|
-
codex mcp add nolto --url ${mcpUrl} --bearer-token-env-var NOLTO_TOKEN
|
|
740
595
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
596
|
+
// src/registry.ts
|
|
597
|
+
import { readFile as readFile3, writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
|
|
598
|
+
import path4 from "path";
|
|
599
|
+
import { z as z2 } from "zod";
|
|
600
|
+
var registrySchema = z2.object({
|
|
601
|
+
schemaVersion: z2.literal(1),
|
|
602
|
+
repos: z2.array(z2.object({ root: z2.string().min(1) }).passthrough())
|
|
603
|
+
}).passthrough();
|
|
604
|
+
function getRegistryPath(env) {
|
|
605
|
+
return path4.join(getConfigDir(env), "registry.json");
|
|
606
|
+
}
|
|
607
|
+
async function loadRegistry(filePath) {
|
|
608
|
+
let raw;
|
|
746
609
|
try {
|
|
747
|
-
|
|
748
|
-
const { stderr } = await execFileAsync2(codexPath, [
|
|
749
|
-
"mcp",
|
|
750
|
-
"add",
|
|
751
|
-
"nolto",
|
|
752
|
-
"--url",
|
|
753
|
-
mcpUrl,
|
|
754
|
-
"--bearer-token-env-var",
|
|
755
|
-
"NOLTO_TOKEN"
|
|
756
|
-
]);
|
|
757
|
-
if (stderr) {
|
|
758
|
-
process.stderr.write(`[codex mcp add] ${stderr}
|
|
759
|
-
`);
|
|
760
|
-
}
|
|
761
|
-
process.stdout.write(`Registered nolto MCP server in Codex.
|
|
762
|
-
`);
|
|
763
|
-
process.stdout.write(
|
|
764
|
-
`Set NOLTO_TOKEN=${opts.token.slice(0, 8)}... in your environment (or shell profile).
|
|
765
|
-
`
|
|
766
|
-
);
|
|
610
|
+
raw = await readFile3(filePath, "utf8");
|
|
767
611
|
} catch (err) {
|
|
768
|
-
const
|
|
769
|
-
|
|
612
|
+
const code = err != null && typeof err === "object" && "code" in err ? err.code : "";
|
|
613
|
+
if (code === "ENOENT") {
|
|
614
|
+
return { schemaVersion: 1, repos: [] };
|
|
615
|
+
}
|
|
616
|
+
throw new CliError(`Cannot read registry: ${filePath}: ${String(err)}`, 2);
|
|
770
617
|
}
|
|
771
|
-
|
|
772
|
-
async function findOnPath(bin) {
|
|
618
|
+
let parsed;
|
|
773
619
|
try {
|
|
774
|
-
|
|
775
|
-
const { stdout } = await execFileAsync2(
|
|
776
|
-
process.platform === "win32" ? "where" : "which",
|
|
777
|
-
[bin]
|
|
778
|
-
);
|
|
779
|
-
const found = stdout.trim().split("\n")[0]?.trim();
|
|
780
|
-
return found && found.length > 0 ? found : null;
|
|
620
|
+
parsed = JSON.parse(raw);
|
|
781
621
|
} catch {
|
|
782
|
-
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
622
|
+
throw new CliError(`Malformed JSON in ${filePath}. Fix or remove the file.`, 2);
|
|
623
|
+
}
|
|
624
|
+
const result = registrySchema.safeParse(parsed);
|
|
625
|
+
if (!result.success) {
|
|
626
|
+
const issue = result.error.issues[0];
|
|
627
|
+
const fieldPath = issue?.path.join(".") ?? "";
|
|
628
|
+
throw new CliError(
|
|
629
|
+
`Invalid registry at ${filePath}: ${fieldPath.length > 0 ? `field "${fieldPath}" \u2014 ` : ""}${issue?.message ?? "validation failed"}`,
|
|
630
|
+
2
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
return result.data;
|
|
634
|
+
}
|
|
635
|
+
async function addRepoToRegistry(filePath, repoRoot) {
|
|
636
|
+
const registry = await loadRegistry(filePath);
|
|
637
|
+
if (registry.repos.some((repo) => repo.root === repoRoot)) {
|
|
638
|
+
return { added: false, registry };
|
|
797
639
|
}
|
|
640
|
+
const updated = { ...registry, repos: [...registry.repos, { root: repoRoot }] };
|
|
641
|
+
await mkdir3(path4.dirname(filePath), { recursive: true, mode: 448 });
|
|
642
|
+
await writeFile3(filePath, JSON.stringify(updated, null, 2) + "\n", "utf8");
|
|
643
|
+
return { added: true, registry: updated };
|
|
798
644
|
}
|
|
799
645
|
|
|
800
|
-
// src/
|
|
646
|
+
// src/roadmap-scaffold.ts
|
|
647
|
+
import { mkdir as mkdir4, writeFile as writeFile4 } from "fs/promises";
|
|
648
|
+
import { existsSync as existsSync2 } from "fs";
|
|
649
|
+
import path5 from "path";
|
|
650
|
+
function slugifyProjectId(name) {
|
|
651
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9.-]+/g, "-").replace(/^[^a-z0-9]+/, "").replace(/[-_.]+$/, "");
|
|
652
|
+
return slug.length > 0 ? slug : "project";
|
|
653
|
+
}
|
|
654
|
+
async function scaffoldRoadmap(args) {
|
|
655
|
+
const dir = path5.join(args.repoRoot, ".roadmap");
|
|
656
|
+
const filePath = path5.join(dir, "roadmap.json");
|
|
657
|
+
if (existsSync2(filePath)) {
|
|
658
|
+
return { created: false, path: filePath };
|
|
659
|
+
}
|
|
660
|
+
const repoBasename = path5.basename(args.repoRoot);
|
|
661
|
+
const roadmap = {
|
|
662
|
+
schemaVersion: 2,
|
|
663
|
+
project: {
|
|
664
|
+
id: slugifyProjectId(repoBasename),
|
|
665
|
+
name: args.projectName,
|
|
666
|
+
repository: repoBasename
|
|
667
|
+
},
|
|
668
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
669
|
+
currentTaskId: null,
|
|
670
|
+
summary: "",
|
|
671
|
+
phases: []
|
|
672
|
+
};
|
|
673
|
+
await mkdir4(dir, { recursive: true });
|
|
674
|
+
await writeFile4(filePath, JSON.stringify(roadmap, null, 2) + "\n", "utf8");
|
|
675
|
+
return { created: true, path: filePath };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// src/commands/init.ts
|
|
679
|
+
var __dirname2 = path6.dirname(fileURLToPath2(import.meta.url));
|
|
680
|
+
var _require = createRequire(import.meta.url);
|
|
681
|
+
function getCliVersion() {
|
|
682
|
+
const candidates = [
|
|
683
|
+
path6.resolve(__dirname2, "../package.json"),
|
|
684
|
+
// bundled: dist/../package.json
|
|
685
|
+
path6.resolve(__dirname2, "../../package.json")
|
|
686
|
+
// source: src/commands/../../package.json
|
|
687
|
+
];
|
|
688
|
+
for (const pkgPath of candidates) {
|
|
689
|
+
if (fs.existsSync(pkgPath)) {
|
|
690
|
+
try {
|
|
691
|
+
const pkg = _require(pkgPath);
|
|
692
|
+
return pkg.version ?? "0.0.0";
|
|
693
|
+
} catch {
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return "0.0.0";
|
|
698
|
+
}
|
|
801
699
|
function register2(program, deps) {
|
|
802
|
-
program.command("
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
2
|
|
810
|
-
);
|
|
700
|
+
program.command("init").description("Interactive setup: configure token, base URL, and default project.").option("--force", "Overwrite existing config without prompting").action(async (opts) => {
|
|
701
|
+
const configPath = deps.configPath;
|
|
702
|
+
if (!opts.force) {
|
|
703
|
+
let existing = null;
|
|
704
|
+
try {
|
|
705
|
+
existing = await loadConfigFile(configPath);
|
|
706
|
+
} catch {
|
|
811
707
|
}
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
let existing = null;
|
|
708
|
+
if (existing != null) {
|
|
709
|
+
const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
815
710
|
try {
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
711
|
+
const answer = await rl2.question(`Config already exists at ${configPath}. Overwrite? [y/N] `);
|
|
712
|
+
if (answer.trim().toLowerCase() !== "y") {
|
|
713
|
+
process.stdout.write("Cancelled.\n");
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
} finally {
|
|
717
|
+
rl2.close();
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
722
|
+
let token = "";
|
|
723
|
+
try {
|
|
724
|
+
const rawUrl = await rl.question(`Base URL [${DEFAULT_BASE_URL}]: `);
|
|
725
|
+
const baseUrl = rawUrl.trim() || DEFAULT_BASE_URL;
|
|
726
|
+
const currentToken = deps.settings.token;
|
|
727
|
+
const tokenPrompt = currentToken != null ? "API token [press Enter to keep current token]: " : "API token: ";
|
|
728
|
+
const enteredToken = await promptHidden(rl, tokenPrompt);
|
|
729
|
+
token = enteredToken.length > 0 ? enteredToken : currentToken ?? "";
|
|
730
|
+
if (token.length === 0) {
|
|
731
|
+
throw new CliError("Token is required.", 2);
|
|
732
|
+
}
|
|
733
|
+
const http = createHttpClient({
|
|
734
|
+
baseUrl,
|
|
735
|
+
token,
|
|
736
|
+
version: getCliVersion()
|
|
737
|
+
});
|
|
738
|
+
let projects = [];
|
|
739
|
+
try {
|
|
740
|
+
const result = await http.get("/api/projects");
|
|
741
|
+
projects = Array.isArray(result.projects) ? result.projects : [];
|
|
742
|
+
} catch (err) {
|
|
743
|
+
if (err instanceof CliError && err.exitCode === 3) {
|
|
744
|
+
throw new CliError(`Token rejected by ${baseUrl}`, 3, "Check that your token is valid and has not been revoked.");
|
|
745
|
+
}
|
|
746
|
+
throw err;
|
|
747
|
+
}
|
|
748
|
+
let defaultProjectId;
|
|
749
|
+
let defaultProjectName;
|
|
750
|
+
if (projects.length > 0) {
|
|
751
|
+
process.stdout.write("\nProjects:\n");
|
|
752
|
+
projects.forEach((p, i) => {
|
|
753
|
+
process.stdout.write(` (${i + 1}) ${p.name} \u2014 ${p.id}
|
|
754
|
+
`);
|
|
755
|
+
});
|
|
756
|
+
} else {
|
|
757
|
+
process.stdout.write("\nNo projects yet.\n");
|
|
758
|
+
}
|
|
759
|
+
const pick = await rl.question("Default project number, 'c' to create new (or Enter to skip): ");
|
|
760
|
+
const trimmedPick = pick.trim().toLowerCase();
|
|
761
|
+
if (trimmedPick === "c") {
|
|
762
|
+
const name = await rl.question("New project name: ");
|
|
763
|
+
if (name.trim().length === 0) {
|
|
764
|
+
throw new CliError("Project name is required.", 2);
|
|
765
|
+
}
|
|
766
|
+
const created = await http.post(
|
|
767
|
+
"/api/projects",
|
|
768
|
+
{ name: name.trim() }
|
|
769
|
+
);
|
|
770
|
+
if (created.project?.id == null) {
|
|
771
|
+
throw new CliError("Project API did not return a project id.", 2);
|
|
772
|
+
}
|
|
773
|
+
defaultProjectId = created.project.id;
|
|
774
|
+
defaultProjectName = created.project.name ?? name.trim();
|
|
775
|
+
process.stdout.write(`Created project ${defaultProjectName} (${defaultProjectId})
|
|
776
|
+
`);
|
|
777
|
+
} else {
|
|
778
|
+
const num = parseInt(trimmedPick, 10);
|
|
779
|
+
if (!isNaN(num) && num >= 1 && num <= projects.length) {
|
|
780
|
+
defaultProjectId = projects[num - 1].id;
|
|
781
|
+
defaultProjectName = projects[num - 1].name;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
await saveConfigFile(configPath, {
|
|
785
|
+
token,
|
|
786
|
+
baseUrl: baseUrl !== DEFAULT_BASE_URL ? baseUrl : void 0,
|
|
787
|
+
defaultProjectId
|
|
788
|
+
});
|
|
789
|
+
const projectDisplay = defaultProjectId != null ? `${defaultProjectName ?? ""} (${defaultProjectId})` : "not set";
|
|
790
|
+
process.stdout.write(`
|
|
791
|
+
Saved ${configPath}
|
|
792
|
+
`);
|
|
793
|
+
process.stdout.write(`baseUrl: ${baseUrl}
|
|
794
|
+
`);
|
|
795
|
+
process.stdout.write(`token: ${maskToken(token)} (verified)
|
|
796
|
+
`);
|
|
797
|
+
process.stdout.write(`defaultProject: ${projectDisplay}
|
|
798
|
+
`);
|
|
799
|
+
if (defaultProjectId != null) {
|
|
800
|
+
const startDir = resolveStartDir(process.env, process.cwd());
|
|
801
|
+
const { root, foundGit } = findRepoRoot(startDir);
|
|
802
|
+
if (foundGit) {
|
|
803
|
+
const setup = await rl.question(`
|
|
804
|
+
Set up this repository (${root}) for roadmap sync? [Y/n] `);
|
|
805
|
+
if (setup.trim().toLowerCase() !== "n") {
|
|
806
|
+
await writeRepoBinding(root, defaultProjectId);
|
|
807
|
+
process.stdout.write(`binding: wrote ${path6.join(root, "nolto.json")}
|
|
808
|
+
`);
|
|
809
|
+
const sourceDir = resolveSkillSourceDir();
|
|
810
|
+
const version = getCliVersion();
|
|
811
|
+
const claudeInstall = await installSkill({
|
|
812
|
+
skillsParentDir: path6.join(root, ".claude", "skills"),
|
|
813
|
+
sourceDir,
|
|
814
|
+
version
|
|
815
|
+
});
|
|
816
|
+
process.stdout.write(`skill (claude): ${claudeInstall.action} ${claudeInstall.targetDir}
|
|
817
|
+
`);
|
|
818
|
+
if (fs.existsSync(path6.join(root, ".agents")) || fs.existsSync(path6.join(root, ".codex"))) {
|
|
819
|
+
const agentsInstall = await installSkill({
|
|
820
|
+
skillsParentDir: path6.join(root, ".agents", "skills"),
|
|
821
|
+
sourceDir,
|
|
822
|
+
version
|
|
823
|
+
});
|
|
824
|
+
process.stdout.write(`skill (agents): ${agentsInstall.action} ${agentsInstall.targetDir}
|
|
825
|
+
`);
|
|
826
|
+
}
|
|
827
|
+
const scaffold = await scaffoldRoadmap({
|
|
828
|
+
repoRoot: root,
|
|
829
|
+
projectName: defaultProjectName ?? path6.basename(root)
|
|
830
|
+
});
|
|
831
|
+
process.stdout.write(
|
|
832
|
+
scaffold.created ? `roadmap: created ${scaffold.path}
|
|
833
|
+
` : `roadmap: exists ${scaffold.path}
|
|
834
|
+
`
|
|
835
|
+
);
|
|
836
|
+
const registryResult = await addRepoToRegistry(getRegistryPath(process.env), root);
|
|
837
|
+
process.stdout.write(
|
|
838
|
+
registryResult.added ? `watch registry: added ${root}
|
|
839
|
+
` : `watch registry: already registered
|
|
840
|
+
`
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
} else {
|
|
844
|
+
process.stdout.write("\nNo git repository found here \u2014 skipped repo setup. Run `nolto init` inside a repo to set up roadmap sync.\n");
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
} finally {
|
|
848
|
+
rl.close();
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
async function promptHidden(rl, prompt) {
|
|
853
|
+
const iface = rl;
|
|
854
|
+
const originalWrite = iface._writeToOutput?.bind(rl);
|
|
855
|
+
let muted = false;
|
|
856
|
+
iface._writeToOutput = (str) => {
|
|
857
|
+
if (muted) {
|
|
858
|
+
if (str !== "\r\n" && str !== "\n" && str !== "\r" && !str.startsWith("\x1B")) {
|
|
859
|
+
process.stdout.write("*");
|
|
860
|
+
} else {
|
|
861
|
+
originalWrite?.(str);
|
|
862
|
+
}
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
originalWrite?.(str);
|
|
866
|
+
};
|
|
867
|
+
muted = true;
|
|
868
|
+
const value = await rl.question(prompt);
|
|
869
|
+
muted = false;
|
|
870
|
+
process.stdout.write("\n");
|
|
871
|
+
iface._writeToOutput = originalWrite;
|
|
872
|
+
return value;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// src/commands/login.ts
|
|
876
|
+
import readline2 from "readline/promises";
|
|
877
|
+
|
|
878
|
+
// src/login-poll.ts
|
|
879
|
+
async function pollUntilToken(opts) {
|
|
880
|
+
const { http, deviceCode, expiresIn, baseUrl } = opts;
|
|
881
|
+
let intervalSeconds = opts.intervalSeconds;
|
|
882
|
+
const deadline = Date.now() + expiresIn * 1e3;
|
|
883
|
+
await sleep(intervalSeconds * 1e3);
|
|
884
|
+
while (Date.now() < deadline) {
|
|
885
|
+
let resp;
|
|
886
|
+
try {
|
|
887
|
+
resp = await http.post("/api/cli/auth/poll", {
|
|
888
|
+
device_code: deviceCode
|
|
889
|
+
});
|
|
890
|
+
} catch (err) {
|
|
891
|
+
if (err instanceof CliError && err.exitCode === 4) {
|
|
892
|
+
await sleep(intervalSeconds * 1e3);
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
throw err;
|
|
896
|
+
}
|
|
897
|
+
if (!resp.error) {
|
|
898
|
+
if (!resp.token) {
|
|
899
|
+
throw new CliError("Server returned success but no token.", 5);
|
|
900
|
+
}
|
|
901
|
+
return resp.token;
|
|
902
|
+
}
|
|
903
|
+
switch (resp.error) {
|
|
904
|
+
case "authorization_pending":
|
|
905
|
+
await sleep(intervalSeconds * 1e3);
|
|
906
|
+
break;
|
|
907
|
+
case "slow_down":
|
|
908
|
+
intervalSeconds += 5;
|
|
909
|
+
await sleep(intervalSeconds * 1e3);
|
|
910
|
+
break;
|
|
911
|
+
case "expired_token":
|
|
912
|
+
throw new CliError("Device code expired. Run `nolto login` again.", 2);
|
|
913
|
+
case "access_denied":
|
|
914
|
+
throw new CliError("\u30ED\u30B0\u30A4\u30F3\u304C\u62D2\u5426\u3055\u308C\u307E\u3057\u305F\u3002", 3);
|
|
915
|
+
case "token_cap_exceeded":
|
|
916
|
+
throw new CliError(
|
|
917
|
+
`\u30A2\u30AF\u30C6\u30A3\u30D6\u306A API token \u304C\u4E0A\u9650(20)\u3067\u3059\u3002${baseUrl}/settings/tokens \u3067\u4E0D\u8981\u306A\u30C8\u30FC\u30AF\u30F3\u3092\u5931\u52B9\u3057\u3066\u304B\u3089\u518D\u5B9F\u884C\u3057\u3066\u304F\u3060\u3055\u3044\u3002`,
|
|
918
|
+
2
|
|
919
|
+
);
|
|
920
|
+
default:
|
|
921
|
+
throw new CliError(`Unexpected poll response: ${resp.error}`, 5);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
throw new CliError("Login timed out. Run `nolto login` again.", 2);
|
|
925
|
+
}
|
|
926
|
+
function sleep(ms) {
|
|
927
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// src/commands/login.ts
|
|
931
|
+
function register3(program, deps) {
|
|
932
|
+
program.command("login").description("Authenticate via browser and save an API token to the config file.").option("--force", "Overwrite existing token without prompting").action(
|
|
933
|
+
async (opts) => {
|
|
934
|
+
const { configPath, settings, output } = deps;
|
|
935
|
+
if (!opts.force) {
|
|
936
|
+
let existing = null;
|
|
937
|
+
try {
|
|
938
|
+
existing = await loadConfigFile(configPath);
|
|
939
|
+
} catch {
|
|
940
|
+
}
|
|
819
941
|
if (existing?.token) {
|
|
820
942
|
const rl = readline2.createInterface({
|
|
821
943
|
input: process.stdin,
|
|
@@ -883,103 +1005,25 @@ function register2(program, deps) {
|
|
|
883
1005
|
process.stdout.write(`token: ${maskToken(token)}
|
|
884
1006
|
`);
|
|
885
1007
|
}
|
|
886
|
-
if (clientTarget) {
|
|
887
|
-
try {
|
|
888
|
-
await injectClient({
|
|
889
|
-
client: clientTarget,
|
|
890
|
-
token,
|
|
891
|
-
baseUrl: settings.baseUrl
|
|
892
|
-
});
|
|
893
|
-
} catch (err) {
|
|
894
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
895
|
-
process.stderr.write(
|
|
896
|
-
`Warning: failed to inject into ${clientTarget} config: ${msg}
|
|
897
|
-
`
|
|
898
|
-
);
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
1008
|
}
|
|
902
1009
|
);
|
|
903
1010
|
}
|
|
904
1011
|
|
|
905
|
-
// src/output.ts
|
|
906
|
-
function printResult(value, mode2, opts = {}) {
|
|
907
|
-
const out = opts.stream ?? process.stdout;
|
|
908
|
-
if (mode2 === "json") {
|
|
909
|
-
out.write(JSON.stringify(value, null, 2) + "\n");
|
|
910
|
-
} else {
|
|
911
|
-
out.write(formatValue(value) + "\n");
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
function printError(err, mode2) {
|
|
915
|
-
if (mode2 === "json") {
|
|
916
|
-
const envelope = {
|
|
917
|
-
error: {
|
|
918
|
-
message: err.message,
|
|
919
|
-
exitCode: err.exitCode,
|
|
920
|
-
...err.status != null ? { status: err.status } : {},
|
|
921
|
-
...err.hint != null ? { hint: err.hint } : {}
|
|
922
|
-
}
|
|
923
|
-
};
|
|
924
|
-
process.stderr.write(JSON.stringify(envelope, null, 2) + "\n");
|
|
925
|
-
} else {
|
|
926
|
-
process.stderr.write(`Error: ${err.message}
|
|
927
|
-
`);
|
|
928
|
-
if (err.hint != null) {
|
|
929
|
-
process.stderr.write(`Hint: ${err.hint}
|
|
930
|
-
`);
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
function formatTable(rows, columns) {
|
|
935
|
-
if (rows.length === 0) {
|
|
936
|
-
return "(empty)";
|
|
937
|
-
}
|
|
938
|
-
const maxKeyLen = columns.reduce((m, c) => Math.max(m, c.length), 0);
|
|
939
|
-
return rows.map(
|
|
940
|
-
(row) => columns.map((col) => {
|
|
941
|
-
const val = row[col] ?? "";
|
|
942
|
-
return ` ${col.padEnd(maxKeyLen)}: ${val}`;
|
|
943
|
-
}).join("\n")
|
|
944
|
-
).join("\n\n");
|
|
945
|
-
}
|
|
946
|
-
function formatRecord(record) {
|
|
947
|
-
const keys = Object.keys(record);
|
|
948
|
-
const maxKeyLen = keys.reduce((m, k) => Math.max(m, k.length), 0);
|
|
949
|
-
return keys.map((k) => `${k.padEnd(maxKeyLen)}: ${record[k] ?? ""}`).join("\n");
|
|
950
|
-
}
|
|
951
|
-
function formatValue(value) {
|
|
952
|
-
if (value === null || value === void 0) {
|
|
953
|
-
return "";
|
|
954
|
-
}
|
|
955
|
-
if (typeof value === "string") {
|
|
956
|
-
return value;
|
|
957
|
-
}
|
|
958
|
-
if (typeof value === "number" || typeof value === "boolean") {
|
|
959
|
-
return String(value);
|
|
960
|
-
}
|
|
961
|
-
if (Array.isArray(value)) {
|
|
962
|
-
return value.map((v) => formatValue(v)).join("\n");
|
|
963
|
-
}
|
|
964
|
-
if (typeof value === "object") {
|
|
965
|
-
const obj = value;
|
|
966
|
-
const keys = Object.keys(obj);
|
|
967
|
-
const maxKeyLen = keys.reduce((m, k) => Math.max(m, k.length), 0);
|
|
968
|
-
return keys.map((k) => `${k.padEnd(maxKeyLen)}: ${formatValue(obj[k])}`).join("\n");
|
|
969
|
-
}
|
|
970
|
-
return JSON.stringify(value, null, 2);
|
|
971
|
-
}
|
|
972
|
-
|
|
973
1012
|
// src/commands/whoami.ts
|
|
974
|
-
function
|
|
1013
|
+
function register4(program, deps) {
|
|
975
1014
|
program.command("whoami").description("Show the current authentication and configuration state.").action(async () => {
|
|
976
1015
|
const { settings, output, configPath } = deps;
|
|
977
1016
|
const mode2 = output.mode;
|
|
978
1017
|
let projectCount;
|
|
979
1018
|
if (settings.token != null) {
|
|
980
1019
|
try {
|
|
981
|
-
const
|
|
982
|
-
|
|
1020
|
+
const http = deps.http ?? createHttpClient({
|
|
1021
|
+
baseUrl: settings.baseUrl,
|
|
1022
|
+
token: settings.token,
|
|
1023
|
+
version: deps.version
|
|
1024
|
+
});
|
|
1025
|
+
const result = await http.get("/api/projects");
|
|
1026
|
+
projectCount = Array.isArray(result.projects) ? result.projects.length : 0;
|
|
983
1027
|
} catch {
|
|
984
1028
|
}
|
|
985
1029
|
}
|
|
@@ -1018,1087 +1062,469 @@ function register3(program, deps) {
|
|
|
1018
1062
|
});
|
|
1019
1063
|
}
|
|
1020
1064
|
|
|
1021
|
-
// src/commands/
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
throw new CliError(
|
|
1025
|
-
"No API token configured. Run `nolto init` or set NOLTO_TOKEN.",
|
|
1026
|
-
3
|
|
1027
|
-
);
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
|
-
function register4(program, deps) {
|
|
1031
|
-
const project = program.command("project").description("Manage projects.");
|
|
1032
|
-
project.command("list").description("List all projects.").action(async () => {
|
|
1033
|
-
assertToken(deps.settings.token);
|
|
1034
|
-
const result = await deps.caller.call("list_projects", {});
|
|
1035
|
-
if (deps.output.mode === "json") {
|
|
1036
|
-
printResult(result, "json");
|
|
1037
|
-
return;
|
|
1038
|
-
}
|
|
1039
|
-
const rows = unwrapList(result, "projects");
|
|
1040
|
-
const defaultId = deps.settings.defaultProjectId;
|
|
1041
|
-
const tableRows = rows.map((p) => ({
|
|
1042
|
-
id: p.id ?? "",
|
|
1043
|
-
name: p.name ?? "",
|
|
1044
|
-
role: p.role ?? "",
|
|
1045
|
-
default: p.id === defaultId ? "yes" : ""
|
|
1046
|
-
}));
|
|
1047
|
-
process.stdout.write(formatTable(tableRows, ["id", "name", "role", "default"]) + "\n");
|
|
1048
|
-
});
|
|
1049
|
-
project.command("register <name>").description("Register a new project.").option("--description <text>", "Project description").option("--repository-url <url>", "Repository URL").action(async (name, opts) => {
|
|
1050
|
-
assertToken(deps.settings.token);
|
|
1051
|
-
const args = { name };
|
|
1052
|
-
if (opts.description != null) args["description"] = opts.description;
|
|
1053
|
-
if (opts.repositoryUrl != null) args["repositoryUrl"] = opts.repositoryUrl;
|
|
1054
|
-
const result = await deps.caller.call("register_project", args);
|
|
1055
|
-
if (deps.output.mode === "json") {
|
|
1056
|
-
printResult(result, "json");
|
|
1057
|
-
return;
|
|
1058
|
-
}
|
|
1059
|
-
const r = result;
|
|
1060
|
-
const id = r?.id ?? "";
|
|
1061
|
-
const registeredName = r?.name ?? name;
|
|
1062
|
-
process.stdout.write(`Registered ${registeredName} (${id})
|
|
1063
|
-
`);
|
|
1064
|
-
});
|
|
1065
|
-
project.command("set-default <projectId>").description("Set the default project.").option("--local", "Write to local config file only (no MCP call)").action(async (projectId, opts) => {
|
|
1066
|
-
if (opts.local) {
|
|
1067
|
-
const existing = await loadConfigFile(deps.configPath).catch(() => null);
|
|
1068
|
-
await saveConfigFile(deps.configPath, { ...existing ?? {}, defaultProjectId: projectId });
|
|
1069
|
-
} else {
|
|
1070
|
-
assertToken(deps.settings.token);
|
|
1071
|
-
await deps.caller.call("set_default_project", { projectId });
|
|
1072
|
-
}
|
|
1073
|
-
process.stdout.write(`Default project set to ${projectId}.
|
|
1074
|
-
`);
|
|
1075
|
-
});
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
// src/commands/plan-register.ts
|
|
1079
|
-
import { execFile as execFile2 } from "child_process";
|
|
1080
|
-
import { promisify as promisify2 } from "util";
|
|
1081
|
-
import { z as z2 } from "zod";
|
|
1065
|
+
// src/commands/sync.ts
|
|
1066
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
1067
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1082
1068
|
|
|
1083
|
-
// src/
|
|
1084
|
-
import
|
|
1085
|
-
import path4 from "path";
|
|
1086
|
-
async function readPlanFile(filePath) {
|
|
1087
|
-
const absPath = path4.resolve(filePath);
|
|
1088
|
-
let content;
|
|
1089
|
-
try {
|
|
1090
|
-
content = await readFile3(absPath, "utf8");
|
|
1091
|
-
} catch (err) {
|
|
1092
|
-
const code = err != null && typeof err === "object" && "code" in err ? err.code : "";
|
|
1093
|
-
if (code === "ENOENT") {
|
|
1094
|
-
throw new CliError(`File not found: ${absPath}`, 2);
|
|
1095
|
-
}
|
|
1096
|
-
throw new CliError(`Cannot read file: ${absPath}: ${String(err)}`, 2);
|
|
1097
|
-
}
|
|
1098
|
-
const trimmed = content.trim();
|
|
1099
|
-
if (trimmed.length === 0) {
|
|
1100
|
-
throw new CliError(`Plan file is empty: ${absPath}`, 2);
|
|
1101
|
-
}
|
|
1102
|
-
if (trimmed.length > PLAN_CONTENT_MAX) {
|
|
1103
|
-
throw new CliError(
|
|
1104
|
-
`Plan file exceeds ${PLAN_CONTENT_MAX} characters (${trimmed.length}): ${absPath}`,
|
|
1105
|
-
2
|
|
1106
|
-
);
|
|
1107
|
-
}
|
|
1108
|
-
const stem = path4.basename(absPath, path4.extname(absPath));
|
|
1109
|
-
return { content: trimmed, sourcePath: absPath, titleFallback: stem };
|
|
1110
|
-
}
|
|
1111
|
-
async function readDocFile(filePath) {
|
|
1112
|
-
const absPath = path4.resolve(filePath);
|
|
1113
|
-
let fileSize;
|
|
1114
|
-
try {
|
|
1115
|
-
const info = await stat2(absPath);
|
|
1116
|
-
fileSize = info.size;
|
|
1117
|
-
} catch (err) {
|
|
1118
|
-
const code = err != null && typeof err === "object" && "code" in err ? err.code : "";
|
|
1119
|
-
if (code === "ENOENT") {
|
|
1120
|
-
throw new CliError(`File not found: ${absPath}`, 2);
|
|
1121
|
-
}
|
|
1122
|
-
throw new CliError(`Cannot stat file: ${absPath}: ${String(err)}`, 2);
|
|
1123
|
-
}
|
|
1124
|
-
if (fileSize > DOCUMENT_MAX_BYTES) {
|
|
1125
|
-
throw new CliError(`Document exceeds 2 MiB: ${absPath}`, 2);
|
|
1126
|
-
}
|
|
1127
|
-
let buf;
|
|
1128
|
-
try {
|
|
1129
|
-
buf = await readFile3(absPath);
|
|
1130
|
-
} catch (err) {
|
|
1131
|
-
throw new CliError(`Cannot read file: ${absPath}: ${String(err)}`, 2);
|
|
1132
|
-
}
|
|
1133
|
-
const isBinary = buf.includes(0) || !isUtf8RoundTrip(buf);
|
|
1134
|
-
const rawFilename = path4.basename(absPath);
|
|
1135
|
-
const filename = rawFilename.slice(0, DOCUMENT_FILENAME_MAX);
|
|
1136
|
-
if (isBinary) {
|
|
1137
|
-
return { content: buf.toString("base64"), encoding: "base64", filename };
|
|
1138
|
-
}
|
|
1139
|
-
return { content: buf.toString("utf8"), encoding: "utf8", filename };
|
|
1140
|
-
}
|
|
1141
|
-
function isUtf8RoundTrip(buf) {
|
|
1142
|
-
try {
|
|
1143
|
-
const str = buf.toString("utf8");
|
|
1144
|
-
const reEncoded = Buffer.from(str, "utf8");
|
|
1145
|
-
if (reEncoded.length !== buf.length) {
|
|
1146
|
-
return false;
|
|
1147
|
-
}
|
|
1148
|
-
for (let i = 0; i < buf.length; i++) {
|
|
1149
|
-
if (buf[i] !== reEncoded[i]) {
|
|
1150
|
-
return false;
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
return true;
|
|
1154
|
-
} catch {
|
|
1155
|
-
return false;
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
// src/markdown.ts
|
|
1160
|
-
function extractTitle(md) {
|
|
1161
|
-
const lines = md.split("\n");
|
|
1162
|
-
let inFence = false;
|
|
1163
|
-
let fenceChar = "";
|
|
1164
|
-
for (const line of lines) {
|
|
1165
|
-
const trimmed = line.trimStart();
|
|
1166
|
-
if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
|
|
1167
|
-
const ch = trimmed[0];
|
|
1168
|
-
if (!inFence) {
|
|
1169
|
-
inFence = true;
|
|
1170
|
-
fenceChar = ch;
|
|
1171
|
-
} else if (ch === fenceChar) {
|
|
1172
|
-
inFence = false;
|
|
1173
|
-
fenceChar = "";
|
|
1174
|
-
}
|
|
1175
|
-
continue;
|
|
1176
|
-
}
|
|
1177
|
-
if (inFence) {
|
|
1178
|
-
continue;
|
|
1179
|
-
}
|
|
1180
|
-
if (trimmed.startsWith("# ")) {
|
|
1181
|
-
const title = trimmed.slice(2).trim();
|
|
1182
|
-
if (title.length > 0) {
|
|
1183
|
-
return title;
|
|
1184
|
-
}
|
|
1185
|
-
}
|
|
1186
|
-
}
|
|
1187
|
-
return null;
|
|
1188
|
-
}
|
|
1069
|
+
// src/sync-repo.ts
|
|
1070
|
+
import path8 from "path";
|
|
1189
1071
|
|
|
1190
|
-
// src/
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
if (token == null) {
|
|
1194
|
-
throw new CliError(
|
|
1195
|
-
"No API token configured. Run `nolto init` or set NOLTO_TOKEN.",
|
|
1196
|
-
3
|
|
1197
|
-
);
|
|
1198
|
-
}
|
|
1199
|
-
}
|
|
1200
|
-
var phaseArraySchema = z2.array(
|
|
1201
|
-
z2.object({
|
|
1202
|
-
title: z2.string().min(1),
|
|
1203
|
-
content: z2.string().min(1),
|
|
1204
|
-
status: z2.string().optional(),
|
|
1205
|
-
plannedStartAt: z2.string().optional(),
|
|
1206
|
-
plannedEndAt: z2.string().optional()
|
|
1207
|
-
})
|
|
1208
|
-
).max(PHASES_MAX);
|
|
1209
|
-
function collect(val, prev) {
|
|
1210
|
-
return [...prev, val];
|
|
1211
|
-
}
|
|
1212
|
-
function resolveUrl(detailUrl, baseUrl) {
|
|
1213
|
-
if (detailUrl == null) return "";
|
|
1214
|
-
try {
|
|
1215
|
-
return new URL(detailUrl, baseUrl).href;
|
|
1216
|
-
} catch {
|
|
1217
|
-
return detailUrl;
|
|
1218
|
-
}
|
|
1219
|
-
}
|
|
1220
|
-
async function getGitContributor() {
|
|
1221
|
-
const result = {};
|
|
1222
|
-
try {
|
|
1223
|
-
const { stdout: name } = await execFileAsync("git", ["config", "user.name"], { timeout: 3e3 });
|
|
1224
|
-
const trimmed = name.trim();
|
|
1225
|
-
if (trimmed.length > 0) result["userName"] = trimmed;
|
|
1226
|
-
} catch {
|
|
1227
|
-
}
|
|
1228
|
-
try {
|
|
1229
|
-
const { stdout: email } = await execFileAsync("git", ["config", "user.email"], { timeout: 3e3 });
|
|
1230
|
-
const trimmed = email.trim();
|
|
1231
|
-
if (trimmed.length > 0) result["userEmail"] = trimmed;
|
|
1232
|
-
} catch {
|
|
1233
|
-
}
|
|
1234
|
-
return result;
|
|
1235
|
-
}
|
|
1236
|
-
function registerPlanRegisterSubcommand(plan, deps) {
|
|
1237
|
-
plan.command("register").description("Register a new plan from a file.").requiredOption("--file <path>", "Path to plan markdown file").option("--title <text>", "Plan title (default: first # heading or filename)").option("--status <status>", "Initial status").option("--planned-start <ISO>", "Planned start date (ISO 8601)").option("--planned-end <ISO>", "Planned end date (ISO 8601)").option("--phases <json>", "Phases JSON array").option("--doc <kind=path>", "Attach a document (repeatable; e.g. --doc plan=report.md)", collect, []).option("--source-url <url>", "Source URL").option("--source-hash <hex>", "Source content hash").option("--no-git", "Skip git author detection").action(async (opts) => {
|
|
1238
|
-
assertToken2(deps.settings.token);
|
|
1239
|
-
if (opts.status != null && !PLAN_STATUSES.includes(opts.status)) {
|
|
1240
|
-
throw new CliError(
|
|
1241
|
-
`Invalid status "${opts.status}". Valid values: ${PLAN_STATUSES.join(", ")}`,
|
|
1242
|
-
2
|
|
1243
|
-
);
|
|
1244
|
-
}
|
|
1245
|
-
const { content, sourcePath, titleFallback } = await readPlanFile(opts.file);
|
|
1246
|
-
const title = opts.title ?? extractTitle(content) ?? titleFallback;
|
|
1247
|
-
if (title.length > PLAN_TITLE_MAX) {
|
|
1248
|
-
throw new CliError(
|
|
1249
|
-
`Plan title is too long (${title.length} chars, max ${PLAN_TITLE_MAX}).`,
|
|
1250
|
-
2
|
|
1251
|
-
);
|
|
1252
|
-
}
|
|
1253
|
-
let parsedPhases;
|
|
1254
|
-
if (opts.phases != null) {
|
|
1255
|
-
try {
|
|
1256
|
-
parsedPhases = JSON.parse(opts.phases);
|
|
1257
|
-
} catch (err) {
|
|
1258
|
-
throw new CliError(
|
|
1259
|
-
`Invalid --phases JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
1260
|
-
2
|
|
1261
|
-
);
|
|
1262
|
-
}
|
|
1263
|
-
const phaseResult = phaseArraySchema.safeParse(parsedPhases);
|
|
1264
|
-
if (!phaseResult.success) {
|
|
1265
|
-
const issue = phaseResult.error.issues[0];
|
|
1266
|
-
if (issue?.code === "too_big" && issue.path.length === 0) {
|
|
1267
|
-
throw new CliError(`Too many phases (max ${PHASES_MAX}).`, 2);
|
|
1268
|
-
}
|
|
1269
|
-
const fieldPath = issue?.path.join(".") ?? "";
|
|
1270
|
-
const fieldDesc = fieldPath.length > 0 ? `field "${fieldPath}"` : "unknown field";
|
|
1271
|
-
throw new CliError(`Invalid --phases JSON: ${fieldDesc}`, 2);
|
|
1272
|
-
}
|
|
1273
|
-
parsedPhases = phaseResult.data;
|
|
1274
|
-
}
|
|
1275
|
-
if (opts.doc.length > 10) {
|
|
1276
|
-
throw new CliError("Too many documents (max 10).", 2);
|
|
1277
|
-
}
|
|
1278
|
-
const documents = [];
|
|
1279
|
-
for (const docSpec of opts.doc) {
|
|
1280
|
-
const match = /^([a-z_]+)=(.+)$/.exec(docSpec);
|
|
1281
|
-
if (match == null) {
|
|
1282
|
-
throw new CliError(`Invalid --doc format "${docSpec}". Expected kind=path.`, 2);
|
|
1283
|
-
}
|
|
1284
|
-
const kind = match[1];
|
|
1285
|
-
const docPath = match[2];
|
|
1286
|
-
if (!PLAN_DOCUMENT_KINDS.includes(kind)) {
|
|
1287
|
-
throw new CliError(
|
|
1288
|
-
`Invalid document kind "${kind}". Valid values: ${PLAN_DOCUMENT_KINDS.join(", ")}`,
|
|
1289
|
-
2
|
|
1290
|
-
);
|
|
1291
|
-
}
|
|
1292
|
-
const { content: docContent, encoding, filename } = await readDocFile(docPath);
|
|
1293
|
-
documents.push({ kind, filename, content: docContent, encoding });
|
|
1294
|
-
}
|
|
1295
|
-
const source = { kind: "file", path: sourcePath };
|
|
1296
|
-
if (opts.sourceUrl != null) source["url"] = opts.sourceUrl;
|
|
1297
|
-
if (opts.sourceHash != null) source["hash"] = opts.sourceHash;
|
|
1298
|
-
const git = opts.git !== false ? await getGitContributor() : {};
|
|
1299
|
-
const planPayload = {
|
|
1300
|
-
title,
|
|
1301
|
-
content,
|
|
1302
|
-
...opts.status != null ? { status: opts.status } : {},
|
|
1303
|
-
...opts.plannedStart != null ? { plannedStartAt: opts.plannedStart } : {},
|
|
1304
|
-
...opts.plannedEnd != null ? { plannedEndAt: opts.plannedEnd } : {},
|
|
1305
|
-
...parsedPhases != null ? { phases: parsedPhases } : {},
|
|
1306
|
-
...documents.length > 0 ? { documents } : {}
|
|
1307
|
-
};
|
|
1308
|
-
const mcpArgs = { plan: planPayload, source, git };
|
|
1309
|
-
if (deps.settings.defaultProjectId != null) {
|
|
1310
|
-
mcpArgs["projectId"] = deps.settings.defaultProjectId;
|
|
1311
|
-
}
|
|
1312
|
-
const result = await deps.caller.call("register_plan", mcpArgs);
|
|
1313
|
-
if (deps.output.mode === "json") {
|
|
1314
|
-
printResult(result, "json");
|
|
1315
|
-
return;
|
|
1316
|
-
}
|
|
1317
|
-
const r = result;
|
|
1318
|
-
const detailUrl = resolveUrl(r?.detailUrl, deps.settings.baseUrl);
|
|
1319
|
-
process.stdout.write(
|
|
1320
|
-
formatRecord({
|
|
1321
|
-
planId: r?.planId ?? "",
|
|
1322
|
-
transformStatus: r?.transformStatus ?? "",
|
|
1323
|
-
url: detailUrl
|
|
1324
|
-
}) + "\n"
|
|
1325
|
-
);
|
|
1326
|
-
});
|
|
1327
|
-
}
|
|
1072
|
+
// src/sync-core.ts
|
|
1073
|
+
import { createHash } from "crypto";
|
|
1074
|
+
import path7 from "path";
|
|
1328
1075
|
|
|
1329
|
-
// src/
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
}
|
|
1338
|
-
function
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
const
|
|
1386
|
-
|
|
1387
|
-
if (
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
);
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1076
|
+
// ../roadmap-schema/src/index.ts
|
|
1077
|
+
var STATUSES = /* @__PURE__ */ new Set(["todo", "in-progress", "done", "blocked"]);
|
|
1078
|
+
var ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
|
1079
|
+
var ALLOWED_KEYS = {
|
|
1080
|
+
roadmap: /* @__PURE__ */ new Set(["schemaVersion", "project", "updatedAt", "currentTaskId", "summary", "phases"]),
|
|
1081
|
+
project: /* @__PURE__ */ new Set(["id", "name", "repository"]),
|
|
1082
|
+
phase: /* @__PURE__ */ new Set(["id", "title", "status", "plan", "tasks"]),
|
|
1083
|
+
task: /* @__PURE__ */ new Set(["id", "title", "status", "startedAt", "completedAt", "note", "dependsOn", "plan"])
|
|
1084
|
+
};
|
|
1085
|
+
function isRecord(value) {
|
|
1086
|
+
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
1087
|
+
}
|
|
1088
|
+
function checkKeys(value, allowed, at, errors) {
|
|
1089
|
+
if (!isRecord(value)) return;
|
|
1090
|
+
for (const key of Object.keys(value)) {
|
|
1091
|
+
if (!allowed.has(key)) errors.push(`${at} contains unsupported property "${key}".`);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
function checkPlan(value, at, errors) {
|
|
1095
|
+
if (value === void 0) return;
|
|
1096
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
1097
|
+
errors.push(`${at}.plan must be a non-empty string.`);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
function derivePhaseStatus(phase) {
|
|
1101
|
+
if (phase.tasks.length > 0 && phase.tasks.every((task) => task.status === "done")) return "done";
|
|
1102
|
+
if (phase.tasks.some((task) => task.status === "in-progress")) return "in-progress";
|
|
1103
|
+
if (phase.tasks.some((task) => task.status === "blocked")) return "blocked";
|
|
1104
|
+
if (phase.tasks.some((task) => task.status === "done")) return "in-progress";
|
|
1105
|
+
return "todo";
|
|
1106
|
+
}
|
|
1107
|
+
function validateRoadmap(value) {
|
|
1108
|
+
const errors = [];
|
|
1109
|
+
const warnings = [];
|
|
1110
|
+
if (!isRecord(value)) return { errors: ["Root must be an object."], warnings };
|
|
1111
|
+
checkKeys(value, ALLOWED_KEYS.roadmap, "roadmap", errors);
|
|
1112
|
+
if (value["schemaVersion"] !== 1 && value["schemaVersion"] !== 2) {
|
|
1113
|
+
errors.push("schemaVersion must be 1 or 2.");
|
|
1114
|
+
} else if (value["schemaVersion"] === 1) {
|
|
1115
|
+
warnings.push("schemaVersion 1 is legacy; the next roadmap-progress mutation migrates this file to 2.");
|
|
1116
|
+
}
|
|
1117
|
+
const project = value["project"];
|
|
1118
|
+
checkKeys(project, ALLOWED_KEYS.project, "project", errors);
|
|
1119
|
+
const projectRecord = isRecord(project) ? project : {};
|
|
1120
|
+
if (!ID_PATTERN.test(String(projectRecord["id"] ?? ""))) errors.push("project.id is invalid.");
|
|
1121
|
+
if (projectRecord["name"] == null || projectRecord["name"] === "") errors.push("project.name is required.");
|
|
1122
|
+
if (projectRecord["repository"] == null || projectRecord["repository"] === "") errors.push("project.repository is required.");
|
|
1123
|
+
if (Number.isNaN(Date.parse(String(value["updatedAt"])))) errors.push("updatedAt must be a valid date-time.");
|
|
1124
|
+
if (typeof value["summary"] !== "string") errors.push("summary must be a string.");
|
|
1125
|
+
if (!Array.isArray(value["phases"])) errors.push("phases must be an array.");
|
|
1126
|
+
const allIds = /* @__PURE__ */ new Set();
|
|
1127
|
+
const tasks = /* @__PURE__ */ new Map();
|
|
1128
|
+
const phases = Array.isArray(value["phases"]) ? value["phases"] : [];
|
|
1129
|
+
for (const [phaseIndex, rawPhase] of phases.entries()) {
|
|
1130
|
+
const at = `phases[${phaseIndex}]`;
|
|
1131
|
+
checkKeys(rawPhase, ALLOWED_KEYS.phase, at, errors);
|
|
1132
|
+
const phase = isRecord(rawPhase) ? rawPhase : {};
|
|
1133
|
+
const phaseId = String(phase["id"] ?? "");
|
|
1134
|
+
if (!ID_PATTERN.test(phaseId)) errors.push(`${at}.id is invalid.`);
|
|
1135
|
+
if (allIds.has(phaseId)) errors.push(`Duplicate id "${phaseId}".`);
|
|
1136
|
+
allIds.add(phaseId);
|
|
1137
|
+
if (phase["title"] == null || phase["title"] === "") errors.push(`${at}.title is required.`);
|
|
1138
|
+
if (!STATUSES.has(String(phase["status"]))) errors.push(`${at}.status is invalid.`);
|
|
1139
|
+
checkPlan(phase["plan"], at, errors);
|
|
1140
|
+
if (!Array.isArray(phase["tasks"])) errors.push(`${at}.tasks must be an array.`);
|
|
1141
|
+
const rawTasks = Array.isArray(phase["tasks"]) ? phase["tasks"] : [];
|
|
1142
|
+
for (const [taskIndex, rawTask] of rawTasks.entries()) {
|
|
1143
|
+
const taskAt = `${at}.tasks[${taskIndex}]`;
|
|
1144
|
+
checkKeys(rawTask, ALLOWED_KEYS.task, taskAt, errors);
|
|
1145
|
+
const task = isRecord(rawTask) ? rawTask : {};
|
|
1146
|
+
const taskId = String(task["id"] ?? "");
|
|
1147
|
+
if (!ID_PATTERN.test(taskId)) errors.push(`${taskAt}.id is invalid.`);
|
|
1148
|
+
if (allIds.has(taskId)) errors.push(`Duplicate id "${taskId}".`);
|
|
1149
|
+
allIds.add(taskId);
|
|
1150
|
+
tasks.set(taskId, task);
|
|
1151
|
+
if (task["title"] == null || task["title"] === "") errors.push(`${taskAt}.title is required.`);
|
|
1152
|
+
if (!STATUSES.has(String(task["status"]))) errors.push(`${taskAt}.status is invalid.`);
|
|
1153
|
+
checkPlan(task["plan"], taskAt, errors);
|
|
1154
|
+
if (task["status"] === "done" && task["completedAt"] == null) warnings.push(`${taskId} is done without completedAt.`);
|
|
1155
|
+
if (task["status"] === "in-progress" && task["startedAt"] == null) warnings.push(`${taskId} is in-progress without startedAt.`);
|
|
1156
|
+
if (task["dependsOn"] !== void 0 && !Array.isArray(task["dependsOn"])) errors.push(`${taskAt}.dependsOn must be an array.`);
|
|
1157
|
+
}
|
|
1158
|
+
if (Array.isArray(phase["tasks"]) && STATUSES.has(String(phase["status"]))) {
|
|
1159
|
+
const expected = derivePhaseStatus({ ...phase, tasks: rawTasks });
|
|
1160
|
+
if (phase["status"] !== expected) warnings.push(`${phaseId} status is ${String(phase["status"])}; task states derive ${expected}.`);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
for (const task of tasks.values()) {
|
|
1164
|
+
for (const dependency of task.dependsOn ?? []) {
|
|
1165
|
+
if (!tasks.has(dependency)) warnings.push(`${task.id} depends on unknown task ${dependency}.`);
|
|
1166
|
+
if (task.id === dependency) errors.push(`${task.id} cannot depend on itself.`);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
const currentTaskId = value["currentTaskId"];
|
|
1170
|
+
if (currentTaskId !== null && currentTaskId !== void 0) {
|
|
1171
|
+
const current = tasks.get(String(currentTaskId));
|
|
1172
|
+
if (current == null) errors.push(`currentTaskId ${String(currentTaskId)} does not exist.`);
|
|
1173
|
+
else if (current.status !== "in-progress") warnings.push(`currentTaskId ${String(currentTaskId)} is not in-progress.`);
|
|
1174
|
+
}
|
|
1175
|
+
return { errors, warnings };
|
|
1176
|
+
}
|
|
1177
|
+
function deriveTaskStats(roadmap) {
|
|
1178
|
+
let done = 0;
|
|
1179
|
+
let blocked = 0;
|
|
1180
|
+
let inProgress = 0;
|
|
1181
|
+
let todo = 0;
|
|
1182
|
+
for (const phase of roadmap.phases) {
|
|
1183
|
+
for (const task of phase.tasks) {
|
|
1184
|
+
if (task.status === "done") done += 1;
|
|
1185
|
+
else if (task.status === "blocked") blocked += 1;
|
|
1186
|
+
else if (task.status === "in-progress") inProgress += 1;
|
|
1187
|
+
else todo += 1;
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
const total = done + blocked + inProgress + todo;
|
|
1191
|
+
return {
|
|
1192
|
+
total,
|
|
1193
|
+
done,
|
|
1194
|
+
blocked,
|
|
1195
|
+
inProgress,
|
|
1196
|
+
todo,
|
|
1197
|
+
progressPct: total > 0 ? Math.round(done / total * 100) : 0
|
|
1198
|
+
};
|
|
1415
1199
|
}
|
|
1416
1200
|
|
|
1417
|
-
// src/
|
|
1418
|
-
function
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
2
|
|
1434
|
-
);
|
|
1435
|
-
}
|
|
1436
|
-
const args = {
|
|
1437
|
-
planId,
|
|
1438
|
-
phaseId,
|
|
1439
|
-
status
|
|
1440
|
-
};
|
|
1441
|
-
if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
|
|
1442
|
-
if (opts.message != null) args["message"] = opts.message;
|
|
1443
|
-
const result = await deps.caller.call("update_phase_status", args);
|
|
1444
|
-
if (deps.output.mode === "json") {
|
|
1445
|
-
printResult(result, "json");
|
|
1446
|
-
return;
|
|
1447
|
-
}
|
|
1448
|
-
const r = result;
|
|
1449
|
-
const planStatus = r?.planStatus ?? "";
|
|
1450
|
-
process.stdout.write(`Phase ${phaseId} \u2192 ${status} (plan now ${planStatus}).
|
|
1451
|
-
`);
|
|
1452
|
-
});
|
|
1453
|
-
phase.command("test <planId> <phaseId> <verdict>").description("Record a phase test result.").option("--round <n>", "Test round number (positive integer)").option("--summary <text>", "Test summary").action(async (planId, phaseId, verdict, opts) => {
|
|
1454
|
-
assertToken4(deps.settings.token);
|
|
1455
|
-
if (!TEST_VERDICTS.includes(verdict)) {
|
|
1456
|
-
throw new CliError(
|
|
1457
|
-
`Invalid verdict "${verdict}". Valid values: ${TEST_VERDICTS.join(", ")}`,
|
|
1458
|
-
2
|
|
1459
|
-
);
|
|
1460
|
-
}
|
|
1461
|
-
let round;
|
|
1462
|
-
if (opts.round != null) {
|
|
1463
|
-
if (!/^[1-9]\d*$/.test(opts.round)) {
|
|
1464
|
-
throw new CliError(`--round must be a positive integer, got "${opts.round}"`, 2);
|
|
1465
|
-
}
|
|
1466
|
-
const parsed = Number(opts.round);
|
|
1467
|
-
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
1468
|
-
throw new CliError(`--round must be a positive integer, got "${opts.round}"`, 2);
|
|
1201
|
+
// src/sync-core.ts
|
|
1202
|
+
function sha256Hex(content) {
|
|
1203
|
+
return "sha256:" + createHash("sha256").update(content, "utf8").digest("hex");
|
|
1204
|
+
}
|
|
1205
|
+
function collectPlanRefs(roadmap) {
|
|
1206
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1207
|
+
const refs = [];
|
|
1208
|
+
for (const phase of roadmap.phases) {
|
|
1209
|
+
if (phase.plan != null && !seen.has(phase.plan)) {
|
|
1210
|
+
seen.add(phase.plan);
|
|
1211
|
+
refs.push({ path: phase.plan, ownerId: phase.id });
|
|
1212
|
+
}
|
|
1213
|
+
for (const task of phase.tasks) {
|
|
1214
|
+
if (task.plan != null && !seen.has(task.plan)) {
|
|
1215
|
+
seen.add(task.plan);
|
|
1216
|
+
refs.push({ path: task.plan, ownerId: task.id });
|
|
1469
1217
|
}
|
|
1470
|
-
round = parsed;
|
|
1471
|
-
}
|
|
1472
|
-
const args = {
|
|
1473
|
-
planId,
|
|
1474
|
-
phaseId,
|
|
1475
|
-
verdict
|
|
1476
|
-
};
|
|
1477
|
-
if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
|
|
1478
|
-
if (round != null) args["round"] = round;
|
|
1479
|
-
if (opts.summary != null) args["summary"] = opts.summary;
|
|
1480
|
-
const result = await deps.caller.call("record_phase_test_result", args);
|
|
1481
|
-
if (deps.output.mode === "json") {
|
|
1482
|
-
printResult(result, "json");
|
|
1483
|
-
return;
|
|
1484
|
-
}
|
|
1485
|
-
const roundDisplay = round != null ? String(round) : "\u2014";
|
|
1486
|
-
process.stdout.write(`Recorded ${verdict} (round ${roundDisplay}).
|
|
1487
|
-
`);
|
|
1488
|
-
});
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
// src/commands/doc.ts
|
|
1492
|
-
function assertToken5(token) {
|
|
1493
|
-
if (token == null) {
|
|
1494
|
-
throw new CliError(
|
|
1495
|
-
"No API token configured. Run `nolto init` or set NOLTO_TOKEN.",
|
|
1496
|
-
3
|
|
1497
|
-
);
|
|
1498
|
-
}
|
|
1499
|
-
}
|
|
1500
|
-
function register7(program, deps) {
|
|
1501
|
-
const doc = program.command("doc").description("Manage plan documents.");
|
|
1502
|
-
doc.command("upload <planId>").description("Upload a document to a plan.").requiredOption("--file <path>", "Path to document file").requiredOption("--kind <kind>", `Document kind (${PLAN_DOCUMENT_KINDS.join(", ")})`).option("--phase <phaseId>", "Associate with a specific phase").option("--filename <name>", "Override filename").action(async (planId, opts) => {
|
|
1503
|
-
assertToken5(deps.settings.token);
|
|
1504
|
-
if (!PLAN_DOCUMENT_KINDS.includes(opts.kind)) {
|
|
1505
|
-
throw new CliError(
|
|
1506
|
-
`Invalid kind "${opts.kind}". Valid values: ${PLAN_DOCUMENT_KINDS.join(", ")}`,
|
|
1507
|
-
2
|
|
1508
|
-
);
|
|
1509
|
-
}
|
|
1510
|
-
const { content, encoding, filename: autoFilename } = await readDocFile(opts.file);
|
|
1511
|
-
const filename = opts.filename != null ? opts.filename.slice(0, DOCUMENT_FILENAME_MAX) : autoFilename;
|
|
1512
|
-
const args = {
|
|
1513
|
-
planId,
|
|
1514
|
-
kind: opts.kind,
|
|
1515
|
-
filename,
|
|
1516
|
-
content,
|
|
1517
|
-
encoding
|
|
1518
|
-
};
|
|
1519
|
-
if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
|
|
1520
|
-
if (opts.phase != null) args["phaseId"] = opts.phase;
|
|
1521
|
-
const result = await deps.caller.call("upload_plan_document", args);
|
|
1522
|
-
if (deps.output.mode === "json") {
|
|
1523
|
-
printResult(result, "json");
|
|
1524
|
-
return;
|
|
1525
|
-
}
|
|
1526
|
-
const byteCount = encoding === "base64" ? Math.floor(content.length * 3 / 4) : Buffer.byteLength(content, "utf8");
|
|
1527
|
-
process.stdout.write(`Uploaded ${filename} (${encoding}, ${byteCount} bytes).
|
|
1528
|
-
`);
|
|
1529
|
-
});
|
|
1530
|
-
}
|
|
1531
|
-
|
|
1532
|
-
// src/queue-file.ts
|
|
1533
|
-
import { openSync, closeSync, writeFileSync, unlinkSync, renameSync, statSync as statSync2 } from "fs";
|
|
1534
|
-
import { readFile as readFile4, writeFile as writeFile3, mkdir as mkdir3, unlink as unlink2, appendFile } from "fs/promises";
|
|
1535
|
-
import path5 from "path";
|
|
1536
|
-
import crypto from "crypto";
|
|
1537
|
-
function queueFilePath(projectDir) {
|
|
1538
|
-
return path5.join(projectDir, ".nolto", "pending.jsonl");
|
|
1539
|
-
}
|
|
1540
|
-
function lockFilePath(projectDir) {
|
|
1541
|
-
return path5.join(projectDir, ".nolto", "flush.lock");
|
|
1542
|
-
}
|
|
1543
|
-
function logFilePath(projectDir) {
|
|
1544
|
-
return path5.join(projectDir, ".nolto", "flush.log");
|
|
1545
|
-
}
|
|
1546
|
-
function resolveQueueDir(inputs) {
|
|
1547
|
-
if (inputs.flagDir != null) return inputs.flagDir;
|
|
1548
|
-
if (inputs.env["NOLTO_QUEUE_DIR"]) return inputs.env["NOLTO_QUEUE_DIR"];
|
|
1549
|
-
if (inputs.env["CLAUDE_PROJECT_DIR"]) return inputs.env["CLAUDE_PROJECT_DIR"];
|
|
1550
|
-
return findAncestorWithMarker(inputs.cwd);
|
|
1551
|
-
}
|
|
1552
|
-
function findAncestorWithMarker(startDir) {
|
|
1553
|
-
let current = startDir;
|
|
1554
|
-
while (true) {
|
|
1555
|
-
if (hasMarkerSync(current, ".git") || hasMarkerSync(current, ".nolto")) {
|
|
1556
|
-
return current;
|
|
1557
1218
|
}
|
|
1558
|
-
const parent = path5.dirname(current);
|
|
1559
|
-
if (parent === current) break;
|
|
1560
|
-
current = parent;
|
|
1561
|
-
}
|
|
1562
|
-
return startDir;
|
|
1563
|
-
}
|
|
1564
|
-
function hasMarkerSync(dir, marker) {
|
|
1565
|
-
try {
|
|
1566
|
-
statSync2(path5.join(dir, marker));
|
|
1567
|
-
return true;
|
|
1568
|
-
} catch {
|
|
1569
|
-
return false;
|
|
1570
1219
|
}
|
|
1220
|
+
return refs;
|
|
1571
1221
|
}
|
|
1572
|
-
async function
|
|
1573
|
-
const filePath =
|
|
1222
|
+
async function loadValidRoadmap(repoRoot, readFile7) {
|
|
1223
|
+
const filePath = path7.join(repoRoot, ".roadmap", "roadmap.json");
|
|
1574
1224
|
let raw;
|
|
1575
1225
|
try {
|
|
1576
|
-
raw = await
|
|
1577
|
-
} catch {
|
|
1578
|
-
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
if (!trimmed) continue;
|
|
1584
|
-
try {
|
|
1585
|
-
const obj = JSON.parse(trimmed);
|
|
1586
|
-
entries.push(obj);
|
|
1587
|
-
} catch {
|
|
1588
|
-
await appendLog(projectDir, "warn", `malformed queue line skipped: ${trimmed.slice(0, 80)}`);
|
|
1589
|
-
}
|
|
1590
|
-
}
|
|
1591
|
-
return entries;
|
|
1592
|
-
}
|
|
1593
|
-
async function appendEntry(projectDir, entry) {
|
|
1594
|
-
const noltoDir = path5.join(projectDir, ".nolto");
|
|
1595
|
-
await mkdir3(noltoDir, { recursive: true, mode: 448 });
|
|
1596
|
-
const existing = await readQueue(projectDir);
|
|
1597
|
-
if (existing.length >= QUEUE_MAX_ENTRIES) {
|
|
1598
|
-
throw new CliError(
|
|
1599
|
-
`Queue is full (${QUEUE_MAX_ENTRIES} entries). Flush before adding more.`,
|
|
1600
|
-
2
|
|
1601
|
-
);
|
|
1602
|
-
}
|
|
1603
|
-
const newEntry = {
|
|
1604
|
-
id: crypto.randomUUID(),
|
|
1605
|
-
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1606
|
-
tool: entry.tool,
|
|
1607
|
-
args: { ...entry.args }
|
|
1608
|
-
};
|
|
1609
|
-
const line = JSON.stringify(newEntry) + "\n";
|
|
1610
|
-
try {
|
|
1611
|
-
await appendFile(queueFilePath(projectDir), line, "utf8");
|
|
1612
|
-
} catch (err) {
|
|
1613
|
-
if (err instanceof CliError) throw err;
|
|
1614
|
-
throw new CliError(
|
|
1615
|
-
`Failed to write queue entry: ${err.message ?? String(err)}`,
|
|
1616
|
-
2
|
|
1617
|
-
);
|
|
1618
|
-
}
|
|
1619
|
-
return newEntry;
|
|
1620
|
-
}
|
|
1621
|
-
async function atomicRewriteQueue(projectDir, entries) {
|
|
1622
|
-
const filePath = queueFilePath(projectDir);
|
|
1623
|
-
if (entries.length === 0) {
|
|
1624
|
-
try {
|
|
1625
|
-
await unlink2(filePath);
|
|
1626
|
-
} catch (err) {
|
|
1627
|
-
const code = err.code;
|
|
1628
|
-
if (code !== "ENOENT") throw err;
|
|
1629
|
-
}
|
|
1630
|
-
return;
|
|
1631
|
-
}
|
|
1632
|
-
const noltoDir = path5.join(projectDir, ".nolto");
|
|
1633
|
-
await mkdir3(noltoDir, { recursive: true });
|
|
1634
|
-
const tmpPath = filePath + ".tmp";
|
|
1635
|
-
const content = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
1636
|
-
await writeFile3(tmpPath, content, "utf8");
|
|
1637
|
-
renameSync(tmpPath, filePath);
|
|
1638
|
-
}
|
|
1639
|
-
async function acquireLock(projectDir) {
|
|
1640
|
-
const noltoDir = path5.join(projectDir, ".nolto");
|
|
1641
|
-
await mkdir3(noltoDir, { recursive: true });
|
|
1642
|
-
const lockPath = lockFilePath(projectDir);
|
|
1643
|
-
return tryAcquire(lockPath);
|
|
1644
|
-
}
|
|
1645
|
-
async function tryAcquire(lockPath) {
|
|
1646
|
-
try {
|
|
1647
|
-
const fd = openSync(lockPath, "wx");
|
|
1648
|
-
writeFileSync(fd, String(process.pid));
|
|
1649
|
-
closeSync(fd);
|
|
1650
|
-
return makeLockHandle(lockPath);
|
|
1651
|
-
} catch (err) {
|
|
1652
|
-
if (err.code !== "EEXIST") throw err;
|
|
1653
|
-
}
|
|
1654
|
-
let pidStr;
|
|
1655
|
-
try {
|
|
1656
|
-
pidStr = await readFile4(lockPath, "utf8");
|
|
1657
|
-
} catch {
|
|
1658
|
-
try {
|
|
1659
|
-
unlinkSync(lockPath);
|
|
1660
|
-
} catch {
|
|
1661
|
-
}
|
|
1662
|
-
return tryAcquire(lockPath);
|
|
1663
|
-
}
|
|
1664
|
-
const pid = parseInt(pidStr, 10);
|
|
1665
|
-
if (isNaN(pid)) {
|
|
1666
|
-
try {
|
|
1667
|
-
unlinkSync(lockPath);
|
|
1668
|
-
} catch {
|
|
1669
|
-
}
|
|
1670
|
-
return tryAcquire(lockPath);
|
|
1671
|
-
}
|
|
1672
|
-
try {
|
|
1673
|
-
process.kill(pid, 0);
|
|
1674
|
-
return null;
|
|
1675
|
-
} catch (sigErr) {
|
|
1676
|
-
if (sigErr.code === "ESRCH") {
|
|
1677
|
-
try {
|
|
1678
|
-
unlinkSync(lockPath);
|
|
1679
|
-
} catch {
|
|
1680
|
-
}
|
|
1681
|
-
return tryAcquire(lockPath);
|
|
1682
|
-
}
|
|
1683
|
-
return null;
|
|
1684
|
-
}
|
|
1685
|
-
}
|
|
1686
|
-
function makeLockHandle(lockPath) {
|
|
1687
|
-
let released = false;
|
|
1688
|
-
return {
|
|
1689
|
-
async release() {
|
|
1690
|
-
if (released) return;
|
|
1691
|
-
released = true;
|
|
1692
|
-
try {
|
|
1693
|
-
unlinkSync(lockPath);
|
|
1694
|
-
} catch {
|
|
1695
|
-
}
|
|
1696
|
-
}
|
|
1697
|
-
};
|
|
1698
|
-
}
|
|
1699
|
-
async function appendLog(projectDir, level, message) {
|
|
1700
|
-
try {
|
|
1701
|
-
const noltoDir = path5.join(projectDir, ".nolto");
|
|
1702
|
-
await mkdir3(noltoDir, { recursive: true });
|
|
1703
|
-
const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level}] ${message}
|
|
1704
|
-
`;
|
|
1705
|
-
await appendFile(logFilePath(projectDir), line, "utf8");
|
|
1226
|
+
raw = await readFile7(filePath);
|
|
1227
|
+
} catch {
|
|
1228
|
+
throw new CliError(`No roadmap found at ${filePath}. Run \`nolto init\` first.`, 2);
|
|
1229
|
+
}
|
|
1230
|
+
let parsed;
|
|
1231
|
+
try {
|
|
1232
|
+
parsed = JSON.parse(raw);
|
|
1706
1233
|
} catch {
|
|
1234
|
+
throw new CliError(`Malformed JSON in ${filePath}.`, 2);
|
|
1235
|
+
}
|
|
1236
|
+
const { errors } = validateRoadmap(parsed);
|
|
1237
|
+
if (errors.length > 0) {
|
|
1238
|
+
throw new CliError(
|
|
1239
|
+
`roadmap.json failed validation \u2014 not syncing:
|
|
1240
|
+
${errors.join("\n ")}`,
|
|
1241
|
+
2
|
|
1242
|
+
);
|
|
1707
1243
|
}
|
|
1244
|
+
return parsed;
|
|
1708
1245
|
}
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
withQueueDir(
|
|
1717
|
-
queue.command("phase-status <planId> <phaseId> <status>").description("Queue a phase status update (offline, no token required).").option("--message <text>", "Optional message")
|
|
1718
|
-
).action(
|
|
1719
|
-
async (planId, phaseId, status, opts) => {
|
|
1720
|
-
if (!PLAN_STATUSES.includes(status)) {
|
|
1721
|
-
throw new CliError(
|
|
1722
|
-
`Invalid status "${status}". Valid values: ${PLAN_STATUSES.join(", ")}`,
|
|
1723
|
-
2
|
|
1724
|
-
);
|
|
1725
|
-
}
|
|
1726
|
-
const projectDir = resolveQueueDir({
|
|
1727
|
-
flagDir: opts.queueDir,
|
|
1728
|
-
env: process.env,
|
|
1729
|
-
cwd: process.cwd()
|
|
1730
|
-
});
|
|
1731
|
-
const args = {
|
|
1732
|
-
planId,
|
|
1733
|
-
phaseId,
|
|
1734
|
-
status
|
|
1735
|
-
};
|
|
1736
|
-
if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
|
|
1737
|
-
if (opts.message != null) args["message"] = opts.message;
|
|
1738
|
-
const entry = await appendEntry(projectDir, { tool: "update_phase_status", args });
|
|
1739
|
-
if (deps.output.mode === "json") {
|
|
1740
|
-
printResult({ queued: true, id: entry.id }, "json");
|
|
1741
|
-
return;
|
|
1742
|
-
}
|
|
1743
|
-
process.stdout.write(`Queued update_phase_status.
|
|
1744
|
-
`);
|
|
1745
|
-
}
|
|
1746
|
-
);
|
|
1747
|
-
withQueueDir(
|
|
1748
|
-
queue.command("phase-test <planId> <phaseId> <verdict>").description("Queue a phase test result (offline, no token required).").option("--round <n>", "Test round number (positive integer)").option("--summary <text>", "Test summary")
|
|
1749
|
-
).action(
|
|
1750
|
-
async (planId, phaseId, verdict, opts) => {
|
|
1751
|
-
if (!TEST_VERDICTS.includes(verdict)) {
|
|
1752
|
-
throw new CliError(
|
|
1753
|
-
`Invalid verdict "${verdict}". Valid values: ${TEST_VERDICTS.join(", ")}`,
|
|
1754
|
-
2
|
|
1755
|
-
);
|
|
1756
|
-
}
|
|
1757
|
-
let round;
|
|
1758
|
-
if (opts.round != null) {
|
|
1759
|
-
if (!/^[1-9]\d*$/.test(opts.round)) {
|
|
1760
|
-
throw new CliError(`--round must be a positive integer, got "${opts.round}"`, 2);
|
|
1761
|
-
}
|
|
1762
|
-
const parsed = Number(opts.round);
|
|
1763
|
-
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
1764
|
-
throw new CliError(`--round must be a positive integer, got "${opts.round}"`, 2);
|
|
1765
|
-
}
|
|
1766
|
-
round = parsed;
|
|
1767
|
-
}
|
|
1768
|
-
const projectDir = resolveQueueDir({
|
|
1769
|
-
flagDir: opts.queueDir,
|
|
1770
|
-
env: process.env,
|
|
1771
|
-
cwd: process.cwd()
|
|
1772
|
-
});
|
|
1773
|
-
const args = {
|
|
1774
|
-
planId,
|
|
1775
|
-
phaseId,
|
|
1776
|
-
verdict
|
|
1777
|
-
};
|
|
1778
|
-
if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
|
|
1779
|
-
if (round != null) args["round"] = round;
|
|
1780
|
-
if (opts.summary != null) args["summary"] = opts.summary;
|
|
1781
|
-
const entry = await appendEntry(projectDir, { tool: "record_phase_test_result", args });
|
|
1782
|
-
if (deps.output.mode === "json") {
|
|
1783
|
-
printResult({ queued: true, id: entry.id }, "json");
|
|
1784
|
-
return;
|
|
1785
|
-
}
|
|
1786
|
-
process.stdout.write(`Queued record_phase_test_result.
|
|
1787
|
-
`);
|
|
1788
|
-
}
|
|
1789
|
-
);
|
|
1790
|
-
withQueueDir(
|
|
1791
|
-
queue.command("plan-status <planId> <status>").description("Queue a plan status update (offline, no token required).").option("--message <text>", "Optional message")
|
|
1792
|
-
).action(
|
|
1793
|
-
async (planId, status, opts) => {
|
|
1794
|
-
if (!PLAN_STATUSES.includes(status)) {
|
|
1795
|
-
throw new CliError(
|
|
1796
|
-
`Invalid status "${status}". Valid values: ${PLAN_STATUSES.join(", ")}`,
|
|
1797
|
-
2
|
|
1798
|
-
);
|
|
1799
|
-
}
|
|
1800
|
-
const projectDir = resolveQueueDir({
|
|
1801
|
-
flagDir: opts.queueDir,
|
|
1802
|
-
env: process.env,
|
|
1803
|
-
cwd: process.cwd()
|
|
1804
|
-
});
|
|
1805
|
-
const args = {
|
|
1806
|
-
planId,
|
|
1807
|
-
status
|
|
1808
|
-
};
|
|
1809
|
-
if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
|
|
1810
|
-
if (opts.message != null) args["message"] = opts.message;
|
|
1811
|
-
const entry = await appendEntry(projectDir, { tool: "update_plan_status", args });
|
|
1812
|
-
if (deps.output.mode === "json") {
|
|
1813
|
-
printResult({ queued: true, id: entry.id }, "json");
|
|
1814
|
-
return;
|
|
1815
|
-
}
|
|
1816
|
-
process.stdout.write(`Queued update_plan_status.
|
|
1817
|
-
`);
|
|
1246
|
+
async function buildSyncBody(args) {
|
|
1247
|
+
const planDocuments = [];
|
|
1248
|
+
for (const ref of collectPlanRefs(args.roadmap)) {
|
|
1249
|
+
const absolute = path7.join(args.repoRoot, ref.path);
|
|
1250
|
+
if (!args.deps.fileExists(absolute)) {
|
|
1251
|
+
args.deps.warn(`plan file not found, skipping: ${ref.path}`);
|
|
1252
|
+
continue;
|
|
1818
1253
|
}
|
|
1254
|
+
const content = await args.deps.readFile(absolute);
|
|
1255
|
+
planDocuments.push({
|
|
1256
|
+
path: ref.path,
|
|
1257
|
+
taskId: ref.ownerId,
|
|
1258
|
+
content,
|
|
1259
|
+
contentHash: sha256Hex(content)
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
return { roadmap: args.roadmap, planDocuments };
|
|
1263
|
+
}
|
|
1264
|
+
async function runSync(args, deps) {
|
|
1265
|
+
const body = await buildSyncBody({
|
|
1266
|
+
roadmap: args.roadmap,
|
|
1267
|
+
repoRoot: args.repoRoot,
|
|
1268
|
+
deps
|
|
1269
|
+
});
|
|
1270
|
+
const response = await deps.http.put(
|
|
1271
|
+
`/api/projects/${args.projectId}/roadmaps/${args.slug}`,
|
|
1272
|
+
body
|
|
1819
1273
|
);
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
async (planId, verdict, opts) => {
|
|
1824
|
-
if (!REVIEW_VERDICTS.includes(verdict)) {
|
|
1825
|
-
throw new CliError(
|
|
1826
|
-
`Invalid verdict "${verdict}". Valid values: ${REVIEW_VERDICTS.join(", ")}`,
|
|
1827
|
-
2
|
|
1828
|
-
);
|
|
1829
|
-
}
|
|
1830
|
-
const projectDir = resolveQueueDir({
|
|
1831
|
-
flagDir: opts.queueDir,
|
|
1832
|
-
env: process.env,
|
|
1833
|
-
cwd: process.cwd()
|
|
1834
|
-
});
|
|
1835
|
-
const args = {
|
|
1836
|
-
planId,
|
|
1837
|
-
verdict
|
|
1838
|
-
};
|
|
1839
|
-
if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
|
|
1840
|
-
if (opts.summary != null) args["summary"] = opts.summary;
|
|
1841
|
-
const entry = await appendEntry(projectDir, { tool: "record_plan_review", args });
|
|
1842
|
-
if (deps.output.mode === "json") {
|
|
1843
|
-
printResult({ queued: true, id: entry.id }, "json");
|
|
1844
|
-
return;
|
|
1845
|
-
}
|
|
1846
|
-
process.stdout.write(`Queued record_plan_review.
|
|
1847
|
-
`);
|
|
1848
|
-
}
|
|
1274
|
+
const stats = deriveTaskStats(args.roadmap);
|
|
1275
|
+
deps.log(
|
|
1276
|
+
`Synced ${args.slug}: ${stats.done}/${stats.total} done \u2014 docs upserted ${response.documents.upserted}, unchanged ${response.documents.unchanged}, deleted ${response.documents.deleted}`
|
|
1849
1277
|
);
|
|
1278
|
+
return response;
|
|
1850
1279
|
}
|
|
1851
1280
|
|
|
1852
|
-
// src/
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
const
|
|
1856
|
-
const
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1281
|
+
// src/sync-repo.ts
|
|
1282
|
+
async function syncRepo(args, io) {
|
|
1283
|
+
const bindingPath = path8.join(args.root, "nolto.json");
|
|
1284
|
+
const binding = await loadRepoBinding(bindingPath);
|
|
1285
|
+
const projectId = binding?.projectId ?? args.defaultProjectId;
|
|
1286
|
+
if (projectId == null) {
|
|
1287
|
+
throw new CliError("No project binding. Run `nolto init` or `nolto link <projectId>`.", 2);
|
|
1288
|
+
}
|
|
1289
|
+
let slug = binding?.roadmapSlug;
|
|
1290
|
+
if (slug == null) {
|
|
1291
|
+
slug = slugifyProjectId(path8.basename(args.root));
|
|
1292
|
+
await writeRoadmapSlug(args.root, slug);
|
|
1293
|
+
io.log(`roadmapSlug not set \u2014 derived "${slug}" and saved to nolto.json`);
|
|
1294
|
+
}
|
|
1295
|
+
const roadmap = await loadValidRoadmap(args.root, io.readFile);
|
|
1296
|
+
const response = await runSync(
|
|
1297
|
+
{ repoRoot: args.root, projectId, slug, roadmap },
|
|
1298
|
+
{ http: io.http, readFile: io.readFile, fileExists: io.fileExists, log: io.log, warn: io.warn }
|
|
1864
1299
|
);
|
|
1865
|
-
|
|
1866
|
-
return
|
|
1300
|
+
const planAbsPaths = collectPlanRefs(roadmap).map((ref) => path8.join(args.root, ref.path));
|
|
1301
|
+
return { ...response, planAbsPaths };
|
|
1867
1302
|
}
|
|
1868
1303
|
|
|
1869
|
-
// src/commands/
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
if (lock === null) {
|
|
1873
|
-
process.stdout.write("Another flush is running.\n");
|
|
1874
|
-
return { flushed: 0, remaining: 0, failed: 0 };
|
|
1875
|
-
}
|
|
1876
|
-
try {
|
|
1877
|
-
const entries = await readQueue(projectDir);
|
|
1878
|
-
if (entries.length === 0) {
|
|
1879
|
-
process.stdout.write("Nothing to flush.\n");
|
|
1880
|
-
return { flushed: 0, remaining: 0, failed: 0 };
|
|
1881
|
-
}
|
|
1304
|
+
// src/commands/sync.ts
|
|
1305
|
+
function register5(program, deps) {
|
|
1306
|
+
program.command("sync").description("Push .roadmap/roadmap.json and linked plan documents to Nolto (idempotent full upsert).").action(async () => {
|
|
1882
1307
|
if (deps.settings.token == null) {
|
|
1883
|
-
|
|
1884
|
-
return { flushed: 0, remaining: entries.length, failed: 0 };
|
|
1308
|
+
throw new CliError("Not authenticated. Run `nolto login` or set NOLTO_TOKEN.", 3);
|
|
1885
1309
|
}
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
await deps.caller.call(entry.tool, entry.args);
|
|
1891
|
-
} catch (err) {
|
|
1892
|
-
const cliErr = err instanceof CliError ? err : new CliError(String(err), 5);
|
|
1893
|
-
await appendLog(
|
|
1894
|
-
projectDir,
|
|
1895
|
-
"error",
|
|
1896
|
-
`${entry.tool} id=${entry.id} exit=${cliErr.exitCode} ${cliErr.message}`
|
|
1897
|
-
);
|
|
1898
|
-
return { flushed, remaining: remaining.length, failed: 1 };
|
|
1899
|
-
}
|
|
1900
|
-
flushed++;
|
|
1901
|
-
remaining = remaining.slice(1);
|
|
1902
|
-
await atomicRewriteQueue(projectDir, remaining);
|
|
1310
|
+
const startDir = resolveStartDir(process.env, process.cwd());
|
|
1311
|
+
const { root, foundGit } = findRepoRoot(startDir);
|
|
1312
|
+
if (!foundGit) {
|
|
1313
|
+
throw new CliError("No git repository found. Run inside a repo set up with `nolto init`.", 2);
|
|
1903
1314
|
}
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
}
|
|
1909
|
-
function register8(program, deps) {
|
|
1910
|
-
program.command("flush").description("Flush the pending queue, sending each entry to the Nolto MCP server.").option("--detach", "Spawn a detached background worker and exit immediately.").option("--queue-dir <path>", "Override project directory for queue files").action(async (opts) => {
|
|
1911
|
-
const projectDir = resolveQueueDir({
|
|
1912
|
-
flagDir: opts.queueDir,
|
|
1913
|
-
env: process.env,
|
|
1914
|
-
cwd: process.cwd()
|
|
1315
|
+
const http = createHttpClient({
|
|
1316
|
+
baseUrl: deps.settings.baseUrl,
|
|
1317
|
+
version: deps.version,
|
|
1318
|
+
token: deps.settings.token
|
|
1915
1319
|
});
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
process.
|
|
1924
|
-
} else {
|
|
1925
|
-
if (summary.flushed > 0 || summary.failed > 0) {
|
|
1926
|
-
process.stdout.write(
|
|
1927
|
-
`Flushed ${summary.flushed}, remaining ${summary.remaining}, failed ${summary.failed}.
|
|
1928
|
-
`
|
|
1929
|
-
);
|
|
1930
|
-
}
|
|
1320
|
+
const response = await syncRepo(
|
|
1321
|
+
{ root, defaultProjectId: deps.settings.defaultProjectId },
|
|
1322
|
+
{
|
|
1323
|
+
readFile: (p) => readFile4(p, "utf8"),
|
|
1324
|
+
fileExists: (p) => existsSync3(p),
|
|
1325
|
+
http,
|
|
1326
|
+
log: (line) => process.stdout.write(line + "\n"),
|
|
1327
|
+
warn: (line) => process.stderr.write("Warning: " + line + "\n")
|
|
1931
1328
|
}
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
`);
|
|
1329
|
+
);
|
|
1330
|
+
if (deps.output.mode === "json") {
|
|
1331
|
+
const { planAbsPaths: _planAbsPaths, ...publicResponse } = response;
|
|
1332
|
+
printResult(publicResponse, "json");
|
|
1937
1333
|
}
|
|
1938
1334
|
});
|
|
1939
1335
|
}
|
|
1940
1336
|
|
|
1941
|
-
// src/commands/
|
|
1942
|
-
import
|
|
1943
|
-
import {
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
if (
|
|
1961
|
-
|
|
1962
|
-
}
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
printResult({ bound: false, projectBindingPath: null }, mode2);
|
|
1973
|
-
} else {
|
|
1974
|
-
process.stdout.write("No nolto.json binding found in this directory tree.\n");
|
|
1975
|
-
}
|
|
1976
|
-
return;
|
|
1977
|
-
}
|
|
1978
|
-
const binding = await loadRepoBinding(projectBindingPath).catch((err) => {
|
|
1979
|
-
if (err instanceof CliError) throw err;
|
|
1980
|
-
throw new CliError(`Cannot read binding: ${String(err)}`, 2);
|
|
1981
|
-
});
|
|
1982
|
-
if (mode2 === "json") {
|
|
1983
|
-
printResult({
|
|
1984
|
-
bound: binding != null,
|
|
1985
|
-
projectId: binding?.projectId ?? null,
|
|
1986
|
-
projectBindingPath,
|
|
1987
|
-
source: deps.settings.source.project === "repo" ? "repo" : "file"
|
|
1988
|
-
}, mode2);
|
|
1989
|
-
} else {
|
|
1990
|
-
if (binding == null) {
|
|
1991
|
-
process.stdout.write(`Binding file found at ${projectBindingPath} but could not be read.
|
|
1992
|
-
`);
|
|
1993
|
-
} else {
|
|
1994
|
-
process.stdout.write(`Binding file : ${projectBindingPath}
|
|
1995
|
-
`);
|
|
1996
|
-
process.stdout.write(`projectId : ${binding.projectId}
|
|
1997
|
-
`);
|
|
1998
|
-
const active = deps.settings.source.project === "repo" ? "repo (active)" : "repo (not active \u2014 overridden)";
|
|
1999
|
-
process.stdout.write(`source : ${active}
|
|
2000
|
-
`);
|
|
1337
|
+
// src/commands/watch.ts
|
|
1338
|
+
import { readFile as readFile5 } from "fs/promises";
|
|
1339
|
+
import { existsSync as existsSync4 } from "fs";
|
|
1340
|
+
import path10 from "path";
|
|
1341
|
+
import chokidar from "chokidar";
|
|
1342
|
+
|
|
1343
|
+
// src/watch-core.ts
|
|
1344
|
+
var RepoWatch = class {
|
|
1345
|
+
constructor(root, deps) {
|
|
1346
|
+
this.root = root;
|
|
1347
|
+
this.deps = deps;
|
|
1348
|
+
}
|
|
1349
|
+
root;
|
|
1350
|
+
deps;
|
|
1351
|
+
timer = null;
|
|
1352
|
+
running = false;
|
|
1353
|
+
pending = false;
|
|
1354
|
+
watchedPlans = [];
|
|
1355
|
+
handleEvent(_filePath) {
|
|
1356
|
+
if (this.timer != null) {
|
|
1357
|
+
this.deps.clearTimeoutFn(this.timer);
|
|
1358
|
+
}
|
|
1359
|
+
this.timer = this.deps.setTimeoutFn(() => {
|
|
1360
|
+
this.timer = null;
|
|
1361
|
+
void this.flush();
|
|
1362
|
+
}, this.deps.debounceMs);
|
|
1363
|
+
}
|
|
1364
|
+
async flush() {
|
|
1365
|
+
if (this.running) {
|
|
1366
|
+
this.pending = true;
|
|
1367
|
+
return;
|
|
2001
1368
|
}
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
const parsed = JSON.parse(raw);
|
|
2010
|
-
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2011
|
-
throw new CliError(
|
|
2012
|
-
`Cannot unlink ${projectBindingPath}: file contains valid JSON but is not a plain object (got ${Array.isArray(parsed) ? "array" : String(parsed)}). Remove or fix the file manually.`,
|
|
2013
|
-
2
|
|
1369
|
+
this.running = true;
|
|
1370
|
+
try {
|
|
1371
|
+
const result = await this.deps.sync();
|
|
1372
|
+
this.rearmPlanWatches(result.planAbsPaths);
|
|
1373
|
+
} catch (err) {
|
|
1374
|
+
this.deps.warn(
|
|
1375
|
+
`[${this.root}] sync failed: ${err instanceof Error ? err.message : String(err)}`
|
|
2014
1376
|
);
|
|
1377
|
+
} finally {
|
|
1378
|
+
this.running = false;
|
|
1379
|
+
if (this.pending) {
|
|
1380
|
+
this.pending = false;
|
|
1381
|
+
void this.flush();
|
|
1382
|
+
}
|
|
2015
1383
|
}
|
|
2016
|
-
existing = parsed;
|
|
2017
|
-
} catch (err) {
|
|
2018
|
-
if (err instanceof CliError) throw err;
|
|
2019
|
-
throw new CliError(`Cannot read ${projectBindingPath}: ${String(err)}`, 2);
|
|
2020
|
-
}
|
|
2021
|
-
const { projectId: _removed, ...rest } = existing;
|
|
2022
|
-
void _removed;
|
|
2023
|
-
await writeFile5(projectBindingPath, JSON.stringify(rest, null, 2) + "\n", { mode: 420 });
|
|
2024
|
-
await chmod(projectBindingPath, 420);
|
|
2025
|
-
if (mode2 === "json") {
|
|
2026
|
-
printResult({ unlinked: true, projectBindingPath }, mode2);
|
|
2027
|
-
} else {
|
|
2028
|
-
process.stdout.write(`Removed projectId from ${projectBindingPath}.
|
|
2029
|
-
`);
|
|
2030
1384
|
}
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
);
|
|
1385
|
+
rearmPlanWatches(next) {
|
|
1386
|
+
const current = new Set(this.watchedPlans);
|
|
1387
|
+
const nextSet = new Set(next);
|
|
1388
|
+
const toAdd = next.filter((p) => !current.has(p));
|
|
1389
|
+
const toRemove = this.watchedPlans.filter((p) => !nextSet.has(p));
|
|
1390
|
+
if (toRemove.length > 0) this.deps.watcher.unwatch(toRemove);
|
|
1391
|
+
if (toAdd.length > 0) this.deps.watcher.add(toAdd);
|
|
1392
|
+
this.watchedPlans = next;
|
|
2038
1393
|
}
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
1394
|
+
};
|
|
1395
|
+
|
|
1396
|
+
// src/service-install.ts
|
|
1397
|
+
import path9 from "path";
|
|
1398
|
+
import os2 from "os";
|
|
1399
|
+
function buildUnitFile(args) {
|
|
1400
|
+
return [
|
|
1401
|
+
"[Unit]",
|
|
1402
|
+
"Description=Nolto roadmap watch (auto-sync roadmap.json to Nolto)",
|
|
1403
|
+
"After=network-online.target",
|
|
1404
|
+
"",
|
|
1405
|
+
"[Service]",
|
|
1406
|
+
`ExecStart=${args.nodePath} ${args.scriptPath} watch`,
|
|
1407
|
+
"Restart=on-failure",
|
|
1408
|
+
"RestartSec=5",
|
|
1409
|
+
"",
|
|
1410
|
+
"[Install]",
|
|
1411
|
+
"WantedBy=default.target",
|
|
1412
|
+
""
|
|
1413
|
+
].join("\n");
|
|
1414
|
+
}
|
|
1415
|
+
function getUnitPath(env) {
|
|
1416
|
+
const xdg = env["XDG_CONFIG_HOME"];
|
|
1417
|
+
const base = xdg != null && xdg.length > 0 ? xdg : path9.join(os2.homedir(), ".config");
|
|
1418
|
+
return path9.join(base, "systemd", "user", "nolto-watch.service");
|
|
1419
|
+
}
|
|
1420
|
+
async function installServiceWith(deps) {
|
|
1421
|
+
const unitPath = getUnitPath(deps.env);
|
|
1422
|
+
await deps.mkdir(path9.dirname(unitPath));
|
|
1423
|
+
await deps.writeFile(unitPath, buildUnitFile({ nodePath: deps.nodePath, scriptPath: deps.scriptPath }));
|
|
1424
|
+
deps.log(`Wrote ${unitPath}`);
|
|
1425
|
+
const reload = await deps.exec(["systemctl", "--user", "daemon-reload"]);
|
|
1426
|
+
const enable = reload.code === 0 ? await deps.exec(["systemctl", "--user", "enable", "--now", "nolto-watch"]) : reload;
|
|
1427
|
+
if (reload.code !== 0 || enable.code !== 0) {
|
|
1428
|
+
deps.warn(
|
|
1429
|
+
"Could not activate the service automatically. Run manually:\n systemctl --user daemon-reload\n systemctl --user enable --now nolto-watch"
|
|
2045
1430
|
);
|
|
1431
|
+
return;
|
|
2046
1432
|
}
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
1433
|
+
deps.log("Service nolto-watch enabled and started. Logs: journalctl --user -u nolto-watch -f");
|
|
1434
|
+
}
|
|
1435
|
+
async function installService() {
|
|
1436
|
+
const { writeFile: writeFile6, mkdir: mkdir6 } = await import("fs/promises");
|
|
1437
|
+
const { execFile } = await import("child_process");
|
|
1438
|
+
const { promisify } = await import("util");
|
|
1439
|
+
const execFileAsync = promisify(execFile);
|
|
1440
|
+
await installServiceWith({
|
|
1441
|
+
env: process.env,
|
|
1442
|
+
nodePath: process.execPath,
|
|
1443
|
+
scriptPath: path9.resolve(process.argv[1] ?? ""),
|
|
1444
|
+
writeFile: (p, content) => writeFile6(p, content, "utf8"),
|
|
1445
|
+
mkdir: async (p) => {
|
|
1446
|
+
await mkdir6(p, { recursive: true });
|
|
1447
|
+
},
|
|
1448
|
+
exec: async (cmd) => {
|
|
1449
|
+
try {
|
|
1450
|
+
await execFileAsync(cmd[0], cmd.slice(1));
|
|
1451
|
+
return { code: 0, stderr: "" };
|
|
1452
|
+
} catch (err) {
|
|
1453
|
+
const e = err;
|
|
1454
|
+
return { code: e.code ?? 1, stderr: e.stderr ?? String(err) };
|
|
2057
1455
|
}
|
|
2058
|
-
}
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
}
|
|
2063
|
-
}
|
|
2064
|
-
await writeRepoBinding(root, projectId);
|
|
2065
|
-
const writtenPath = path6.join(root, "nolto.json");
|
|
2066
|
-
if (mode2 === "json") {
|
|
2067
|
-
printResult({ linked: true, projectId, projectBindingPath: writtenPath }, mode2);
|
|
2068
|
-
} else {
|
|
2069
|
-
process.stdout.write(
|
|
2070
|
-
`Linked this repo to project ${projectId} (wrote ${writtenPath}).
|
|
2071
|
-
Commit nolto.json to share the binding with your team.
|
|
2072
|
-
`
|
|
2073
|
-
);
|
|
2074
|
-
}
|
|
1456
|
+
},
|
|
1457
|
+
log: (line) => process.stdout.write(line + "\n"),
|
|
1458
|
+
warn: (line) => process.stderr.write("Warning: " + line + "\n")
|
|
1459
|
+
});
|
|
2075
1460
|
}
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
const projectBindingPath = deps.projectBindingPath ?? null;
|
|
2083
|
-
const mode2 = output.mode;
|
|
2084
|
-
if (cmd.opts()["show"]) {
|
|
2085
|
-
await handleShow(deps, projectBindingPath, mode2);
|
|
1461
|
+
|
|
1462
|
+
// src/commands/watch.ts
|
|
1463
|
+
function register6(program, deps) {
|
|
1464
|
+
program.command("watch").description("Watch every registered repository's roadmap + plan files and sync on change.").option("--debounce <ms>", "Debounce window in milliseconds", "2000").option("--install-service", "Install and enable a systemd user unit (nolto-watch) instead of watching").action(async (opts) => {
|
|
1465
|
+
if (opts.installService) {
|
|
1466
|
+
await installService();
|
|
2086
1467
|
return;
|
|
2087
1468
|
}
|
|
2088
|
-
if (
|
|
2089
|
-
|
|
2090
|
-
throw new CliError("No nolto.json found in this directory tree. Nothing to unlink.", 2);
|
|
2091
|
-
}
|
|
2092
|
-
await handleUnlink(projectBindingPath, mode2);
|
|
2093
|
-
return;
|
|
1469
|
+
if (deps.settings.token == null) {
|
|
1470
|
+
throw new CliError("Not authenticated. Run `nolto login` or set NOLTO_TOKEN.", 3);
|
|
2094
1471
|
}
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
2
|
|
2099
|
-
);
|
|
1472
|
+
const debounceMs = Number.parseInt(opts.debounce, 10);
|
|
1473
|
+
if (Number.isNaN(debounceMs) || debounceMs < 0) {
|
|
1474
|
+
throw new CliError("--debounce must be a non-negative integer.", 2);
|
|
2100
1475
|
}
|
|
2101
|
-
await
|
|
1476
|
+
const registry = await loadRegistry(getRegistryPath(process.env));
|
|
1477
|
+
const roots = registry.repos.map((r) => r.root).filter((root) => {
|
|
1478
|
+
if (existsSync4(root)) return true;
|
|
1479
|
+
process.stderr.write(`Warning: registered repo no longer exists, skipping: ${root}
|
|
1480
|
+
`);
|
|
1481
|
+
return false;
|
|
1482
|
+
});
|
|
1483
|
+
if (roots.length === 0) {
|
|
1484
|
+
throw new CliError("No watchable repositories in the registry. Run `nolto init` in a repo first.", 2);
|
|
1485
|
+
}
|
|
1486
|
+
const http = createHttpClient({
|
|
1487
|
+
baseUrl: deps.settings.baseUrl,
|
|
1488
|
+
version: deps.version,
|
|
1489
|
+
token: deps.settings.token
|
|
1490
|
+
});
|
|
1491
|
+
const watchers = [];
|
|
1492
|
+
for (const root of roots) {
|
|
1493
|
+
const roadmapPath = path10.join(root, ".roadmap", "roadmap.json");
|
|
1494
|
+
const watcher = chokidar.watch([roadmapPath], { ignoreInitial: true });
|
|
1495
|
+
watchers.push(watcher);
|
|
1496
|
+
const repoWatch = new RepoWatch(root, {
|
|
1497
|
+
sync: () => syncRepo(
|
|
1498
|
+
{ root, defaultProjectId: deps.settings.defaultProjectId },
|
|
1499
|
+
{
|
|
1500
|
+
readFile: (p) => readFile5(p, "utf8"),
|
|
1501
|
+
fileExists: (p) => existsSync4(p),
|
|
1502
|
+
http,
|
|
1503
|
+
log: (line) => process.stdout.write(`[${path10.basename(root)}] ${line}
|
|
1504
|
+
`),
|
|
1505
|
+
warn: (line) => process.stderr.write(`Warning: [${path10.basename(root)}] ${line}
|
|
1506
|
+
`)
|
|
1507
|
+
}
|
|
1508
|
+
),
|
|
1509
|
+
watcher,
|
|
1510
|
+
debounceMs,
|
|
1511
|
+
setTimeoutFn: (fn, ms) => setTimeout(fn, ms),
|
|
1512
|
+
clearTimeoutFn: (t) => clearTimeout(t),
|
|
1513
|
+
log: (line) => process.stdout.write(line + "\n"),
|
|
1514
|
+
warn: (line) => process.stderr.write(line + "\n")
|
|
1515
|
+
});
|
|
1516
|
+
watcher.on("all", (_event, filePath) => repoWatch.handleEvent(filePath));
|
|
1517
|
+
void repoWatch.flush();
|
|
1518
|
+
}
|
|
1519
|
+
process.stdout.write(`Watching ${roots.length} repositories (debounce ${debounceMs}ms). Ctrl-C to stop.
|
|
1520
|
+
`);
|
|
1521
|
+
await new Promise((resolve) => {
|
|
1522
|
+
const stop = () => {
|
|
1523
|
+
void Promise.all(watchers.map((w) => w.close())).then(() => resolve());
|
|
1524
|
+
};
|
|
1525
|
+
process.once("SIGINT", stop);
|
|
1526
|
+
process.once("SIGTERM", stop);
|
|
1527
|
+
});
|
|
2102
1528
|
});
|
|
2103
1529
|
}
|
|
2104
1530
|
|
|
@@ -2109,24 +1535,20 @@ function stripCommanderErrorPrefix(msg) {
|
|
|
2109
1535
|
function buildProgram(deps) {
|
|
2110
1536
|
const writeErr = (_msg) => {
|
|
2111
1537
|
};
|
|
2112
|
-
const program = new Command("nolto").version(deps.version, "-V, --version", "Print version number").exitOverride().configureOutput({ writeErr }).description("Nolto CLI \u2014
|
|
2113
|
-
register(program, deps);
|
|
1538
|
+
const program = new Command("nolto").version(deps.version, "-V, --version", "Print version number").exitOverride().configureOutput({ writeErr }).description("Nolto CLI \u2014 sync repository roadmaps with Nolto.").option("--token <value>", "API token (overrides env/file)").option("--base-url <url>", "Nolto base URL (default: https://nolto.app)").option("--project <projectId>", "Default project ID").option("--json", "Output as JSON");
|
|
2114
1539
|
register2(program, deps);
|
|
2115
1540
|
register3(program, deps);
|
|
2116
1541
|
register4(program, deps);
|
|
1542
|
+
register(program, deps);
|
|
2117
1543
|
register5(program, deps);
|
|
2118
1544
|
register6(program, deps);
|
|
2119
|
-
register7(program, deps);
|
|
2120
|
-
registerQueue(program, deps);
|
|
2121
|
-
register8(program, deps);
|
|
2122
|
-
register9(program, deps);
|
|
2123
1545
|
return program;
|
|
2124
1546
|
}
|
|
2125
1547
|
|
|
2126
1548
|
// src/update-notifier.ts
|
|
2127
|
-
import { readFile as
|
|
1549
|
+
import { readFile as readFile6, writeFile as writeFile5, mkdir as mkdir5 } from "fs/promises";
|
|
2128
1550
|
import https from "https";
|
|
2129
|
-
import
|
|
1551
|
+
import path11 from "path";
|
|
2130
1552
|
var PACKAGE = "@nolto/cli";
|
|
2131
1553
|
var CACHE_FILE = "update-check.json";
|
|
2132
1554
|
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
@@ -2184,16 +1606,16 @@ function fetchLatestFromRegistry() {
|
|
|
2184
1606
|
async function refreshCache(cachePath, now, fetchLatest) {
|
|
2185
1607
|
const latest = await fetchLatest();
|
|
2186
1608
|
if (!latest) return;
|
|
2187
|
-
await
|
|
1609
|
+
await mkdir5(path11.dirname(cachePath), { recursive: true }).catch(() => void 0);
|
|
2188
1610
|
const payload = { checkedAt: now, latest };
|
|
2189
|
-
await
|
|
1611
|
+
await writeFile5(cachePath, JSON.stringify(payload), { mode: 384 }).catch(() => void 0);
|
|
2190
1612
|
}
|
|
2191
1613
|
async function checkForUpdate(opts) {
|
|
2192
1614
|
if (isDisabled(opts.env)) return null;
|
|
2193
|
-
const cachePath =
|
|
1615
|
+
const cachePath = path11.join(opts.configDir, CACHE_FILE);
|
|
2194
1616
|
let cache = {};
|
|
2195
1617
|
try {
|
|
2196
|
-
cache = JSON.parse(await
|
|
1618
|
+
cache = JSON.parse(await readFile6(cachePath, "utf8"));
|
|
2197
1619
|
} catch {
|
|
2198
1620
|
}
|
|
2199
1621
|
if (typeof cache.checkedAt !== "number" || opts.now - cache.checkedAt > CACHE_TTL_MS) {
|
|
@@ -2221,11 +1643,11 @@ async function notifyUpdate(opts) {
|
|
|
2221
1643
|
}
|
|
2222
1644
|
|
|
2223
1645
|
// src/index.ts
|
|
2224
|
-
var
|
|
1646
|
+
var __dirname3 = path12.dirname(fileURLToPath3(import.meta.url));
|
|
2225
1647
|
var require2 = createRequire2(import.meta.url);
|
|
2226
1648
|
function getVersion() {
|
|
2227
1649
|
try {
|
|
2228
|
-
const pkgPath =
|
|
1650
|
+
const pkgPath = path12.resolve(__dirname3, "../package.json");
|
|
2229
1651
|
const pkg = require2(pkgPath);
|
|
2230
1652
|
return pkg.version ?? "0.0.0";
|
|
2231
1653
|
} catch {
|
|
@@ -2274,15 +1696,19 @@ async function main() {
|
|
|
2274
1696
|
repoBinding
|
|
2275
1697
|
});
|
|
2276
1698
|
const version = getVersion();
|
|
2277
|
-
const
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
1699
|
+
const http = createHttpClient({
|
|
1700
|
+
baseUrl: settings.baseUrl,
|
|
1701
|
+
version,
|
|
1702
|
+
token: settings.token
|
|
1703
|
+
});
|
|
1704
|
+
const program = buildProgram({
|
|
1705
|
+
http,
|
|
1706
|
+
settings,
|
|
1707
|
+
output: { mode },
|
|
1708
|
+
version,
|
|
1709
|
+
configPath,
|
|
1710
|
+
projectBindingPath
|
|
1711
|
+
});
|
|
2286
1712
|
await program.parseAsync(process.argv);
|
|
2287
1713
|
await notifyUpdate({ current: version, env: process.env, isJson: mode === "json", now: Date.now() });
|
|
2288
1714
|
}
|