@nolto/cli 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -77
- package/dist/index.js +1177 -1649
- 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,464 +324,622 @@ 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(path8, body) {
|
|
507
|
-
if (!path8.startsWith("/api/")) {
|
|
508
|
-
throw new CliError(`HTTP client path must start with /api/, got: ${path8}`, 2);
|
|
509
|
-
}
|
|
510
|
-
const url = `${base}${path8}`;
|
|
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 ${opts.token}`
|
|
713
|
-
},
|
|
714
|
-
env: {
|
|
715
|
-
NOLTO_MCP_TOKEN: opts.token
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
};
|
|
719
|
-
const newConfig = {
|
|
720
|
-
...existing,
|
|
721
|
-
mcpServers: updatedServers
|
|
722
|
-
};
|
|
723
|
-
await atomicWrite(configPath, JSON.stringify(newConfig, null, 2) + "\n", 384);
|
|
724
|
-
process.stdout.write(`Updated ${configPath} (claude project .mcp.json)
|
|
725
|
-
`);
|
|
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 };
|
|
726
594
|
}
|
|
727
|
-
async function injectCodex(opts) {
|
|
728
|
-
const mcpUrl = `${opts.baseUrl.replace(/\/+$/, "")}/mcp`;
|
|
729
|
-
const codexPath = await findOnPath("codex");
|
|
730
|
-
if (!codexPath) {
|
|
731
|
-
process.stdout.write(
|
|
732
|
-
`
|
|
733
|
-
To add Nolto to Codex, run:
|
|
734
|
-
|
|
735
|
-
codex mcp add nolto --url ${mcpUrl} --bearer-token-env-var NOLTO_TOKEN
|
|
736
595
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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;
|
|
742
609
|
try {
|
|
743
|
-
|
|
744
|
-
const { stderr } = await execFileAsync2(codexPath, [
|
|
745
|
-
"mcp",
|
|
746
|
-
"add",
|
|
747
|
-
"nolto",
|
|
748
|
-
"--url",
|
|
749
|
-
mcpUrl,
|
|
750
|
-
"--bearer-token-env-var",
|
|
751
|
-
"NOLTO_TOKEN"
|
|
752
|
-
]);
|
|
753
|
-
if (stderr) {
|
|
754
|
-
process.stderr.write(`[codex mcp add] ${stderr}
|
|
755
|
-
`);
|
|
756
|
-
}
|
|
757
|
-
process.stdout.write(`Registered nolto MCP server in Codex.
|
|
758
|
-
`);
|
|
759
|
-
process.stdout.write(
|
|
760
|
-
`Set NOLTO_TOKEN=${opts.token.slice(0, 8)}... in your environment (or shell profile).
|
|
761
|
-
`
|
|
762
|
-
);
|
|
610
|
+
raw = await readFile3(filePath, "utf8");
|
|
763
611
|
} catch (err) {
|
|
764
|
-
const
|
|
765
|
-
|
|
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);
|
|
766
617
|
}
|
|
767
|
-
|
|
768
|
-
async function findOnPath(bin) {
|
|
618
|
+
let parsed;
|
|
769
619
|
try {
|
|
770
|
-
|
|
771
|
-
const { stdout } = await execFileAsync2(
|
|
772
|
-
process.platform === "win32" ? "where" : "which",
|
|
773
|
-
[bin]
|
|
774
|
-
);
|
|
775
|
-
const found = stdout.trim().split("\n")[0]?.trim();
|
|
776
|
-
return found && found.length > 0 ? found : null;
|
|
620
|
+
parsed = JSON.parse(raw);
|
|
777
621
|
} catch {
|
|
778
|
-
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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 };
|
|
793
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 };
|
|
794
644
|
}
|
|
795
645
|
|
|
796
|
-
// 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
|
+
}
|
|
797
699
|
function register2(program, deps) {
|
|
798
|
-
program.command("
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
2
|
|
806
|
-
);
|
|
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 {
|
|
807
707
|
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
let existing = null;
|
|
708
|
+
if (existing != null) {
|
|
709
|
+
const rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
811
710
|
try {
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
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
|
+
}
|
|
941
|
+
if (existing?.token) {
|
|
942
|
+
const rl = readline2.createInterface({
|
|
817
943
|
input: process.stdin,
|
|
818
944
|
output: process.stdout
|
|
819
945
|
});
|
|
@@ -879,103 +1005,25 @@ function register2(program, deps) {
|
|
|
879
1005
|
process.stdout.write(`token: ${maskToken(token)}
|
|
880
1006
|
`);
|
|
881
1007
|
}
|
|
882
|
-
if (clientTarget) {
|
|
883
|
-
try {
|
|
884
|
-
await injectClient({
|
|
885
|
-
client: clientTarget,
|
|
886
|
-
token,
|
|
887
|
-
baseUrl: settings.baseUrl
|
|
888
|
-
});
|
|
889
|
-
} catch (err) {
|
|
890
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
891
|
-
process.stderr.write(
|
|
892
|
-
`Warning: failed to inject into ${clientTarget} config: ${msg}
|
|
893
|
-
`
|
|
894
|
-
);
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
1008
|
}
|
|
898
1009
|
);
|
|
899
1010
|
}
|
|
900
1011
|
|
|
901
|
-
// src/output.ts
|
|
902
|
-
function printResult(value, mode2, opts = {}) {
|
|
903
|
-
const out = opts.stream ?? process.stdout;
|
|
904
|
-
if (mode2 === "json") {
|
|
905
|
-
out.write(JSON.stringify(value, null, 2) + "\n");
|
|
906
|
-
} else {
|
|
907
|
-
out.write(formatValue(value) + "\n");
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
function printError(err, mode2) {
|
|
911
|
-
if (mode2 === "json") {
|
|
912
|
-
const envelope = {
|
|
913
|
-
error: {
|
|
914
|
-
message: err.message,
|
|
915
|
-
exitCode: err.exitCode,
|
|
916
|
-
...err.status != null ? { status: err.status } : {},
|
|
917
|
-
...err.hint != null ? { hint: err.hint } : {}
|
|
918
|
-
}
|
|
919
|
-
};
|
|
920
|
-
process.stderr.write(JSON.stringify(envelope, null, 2) + "\n");
|
|
921
|
-
} else {
|
|
922
|
-
process.stderr.write(`Error: ${err.message}
|
|
923
|
-
`);
|
|
924
|
-
if (err.hint != null) {
|
|
925
|
-
process.stderr.write(`Hint: ${err.hint}
|
|
926
|
-
`);
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
function formatTable(rows, columns) {
|
|
931
|
-
if (rows.length === 0) {
|
|
932
|
-
return "(empty)";
|
|
933
|
-
}
|
|
934
|
-
const maxKeyLen = columns.reduce((m, c) => Math.max(m, c.length), 0);
|
|
935
|
-
return rows.map(
|
|
936
|
-
(row) => columns.map((col) => {
|
|
937
|
-
const val = row[col] ?? "";
|
|
938
|
-
return ` ${col.padEnd(maxKeyLen)}: ${val}`;
|
|
939
|
-
}).join("\n")
|
|
940
|
-
).join("\n\n");
|
|
941
|
-
}
|
|
942
|
-
function formatRecord(record) {
|
|
943
|
-
const keys = Object.keys(record);
|
|
944
|
-
const maxKeyLen = keys.reduce((m, k) => Math.max(m, k.length), 0);
|
|
945
|
-
return keys.map((k) => `${k.padEnd(maxKeyLen)}: ${record[k] ?? ""}`).join("\n");
|
|
946
|
-
}
|
|
947
|
-
function formatValue(value) {
|
|
948
|
-
if (value === null || value === void 0) {
|
|
949
|
-
return "";
|
|
950
|
-
}
|
|
951
|
-
if (typeof value === "string") {
|
|
952
|
-
return value;
|
|
953
|
-
}
|
|
954
|
-
if (typeof value === "number" || typeof value === "boolean") {
|
|
955
|
-
return String(value);
|
|
956
|
-
}
|
|
957
|
-
if (Array.isArray(value)) {
|
|
958
|
-
return value.map((v) => formatValue(v)).join("\n");
|
|
959
|
-
}
|
|
960
|
-
if (typeof value === "object") {
|
|
961
|
-
const obj = value;
|
|
962
|
-
const keys = Object.keys(obj);
|
|
963
|
-
const maxKeyLen = keys.reduce((m, k) => Math.max(m, k.length), 0);
|
|
964
|
-
return keys.map((k) => `${k.padEnd(maxKeyLen)}: ${formatValue(obj[k])}`).join("\n");
|
|
965
|
-
}
|
|
966
|
-
return JSON.stringify(value, null, 2);
|
|
967
|
-
}
|
|
968
|
-
|
|
969
1012
|
// src/commands/whoami.ts
|
|
970
|
-
function
|
|
1013
|
+
function register4(program, deps) {
|
|
971
1014
|
program.command("whoami").description("Show the current authentication and configuration state.").action(async () => {
|
|
972
1015
|
const { settings, output, configPath } = deps;
|
|
973
1016
|
const mode2 = output.mode;
|
|
974
1017
|
let projectCount;
|
|
975
1018
|
if (settings.token != null) {
|
|
976
1019
|
try {
|
|
977
|
-
const
|
|
978
|
-
|
|
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;
|
|
979
1027
|
} catch {
|
|
980
1028
|
}
|
|
981
1029
|
}
|
|
@@ -1014,1087 +1062,469 @@ function register3(program, deps) {
|
|
|
1014
1062
|
});
|
|
1015
1063
|
}
|
|
1016
1064
|
|
|
1017
|
-
// src/commands/
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
throw new CliError(
|
|
1021
|
-
"No API token configured. Run `nolto init` or set NOLTO_TOKEN.",
|
|
1022
|
-
3
|
|
1023
|
-
);
|
|
1024
|
-
}
|
|
1025
|
-
}
|
|
1026
|
-
function register4(program, deps) {
|
|
1027
|
-
const project = program.command("project").description("Manage projects.");
|
|
1028
|
-
project.command("list").description("List all projects.").action(async () => {
|
|
1029
|
-
assertToken(deps.settings.token);
|
|
1030
|
-
const result = await deps.caller.call("list_projects", {});
|
|
1031
|
-
if (deps.output.mode === "json") {
|
|
1032
|
-
printResult(result, "json");
|
|
1033
|
-
return;
|
|
1034
|
-
}
|
|
1035
|
-
const rows = unwrapList(result, "projects");
|
|
1036
|
-
const defaultId = deps.settings.defaultProjectId;
|
|
1037
|
-
const tableRows = rows.map((p) => ({
|
|
1038
|
-
id: p.id ?? "",
|
|
1039
|
-
name: p.name ?? "",
|
|
1040
|
-
role: p.role ?? "",
|
|
1041
|
-
default: p.id === defaultId ? "yes" : ""
|
|
1042
|
-
}));
|
|
1043
|
-
process.stdout.write(formatTable(tableRows, ["id", "name", "role", "default"]) + "\n");
|
|
1044
|
-
});
|
|
1045
|
-
project.command("register <name>").description("Register a new project.").option("--description <text>", "Project description").option("--repository-url <url>", "Repository URL").action(async (name, opts) => {
|
|
1046
|
-
assertToken(deps.settings.token);
|
|
1047
|
-
const args = { name };
|
|
1048
|
-
if (opts.description != null) args["description"] = opts.description;
|
|
1049
|
-
if (opts.repositoryUrl != null) args["repositoryUrl"] = opts.repositoryUrl;
|
|
1050
|
-
const result = await deps.caller.call("register_project", args);
|
|
1051
|
-
if (deps.output.mode === "json") {
|
|
1052
|
-
printResult(result, "json");
|
|
1053
|
-
return;
|
|
1054
|
-
}
|
|
1055
|
-
const r = result;
|
|
1056
|
-
const id = r?.id ?? "";
|
|
1057
|
-
const registeredName = r?.name ?? name;
|
|
1058
|
-
process.stdout.write(`Registered ${registeredName} (${id})
|
|
1059
|
-
`);
|
|
1060
|
-
});
|
|
1061
|
-
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) => {
|
|
1062
|
-
if (opts.local) {
|
|
1063
|
-
const existing = await loadConfigFile(deps.configPath).catch(() => null);
|
|
1064
|
-
await saveConfigFile(deps.configPath, { ...existing ?? {}, defaultProjectId: projectId });
|
|
1065
|
-
} else {
|
|
1066
|
-
assertToken(deps.settings.token);
|
|
1067
|
-
await deps.caller.call("set_default_project", { projectId });
|
|
1068
|
-
}
|
|
1069
|
-
process.stdout.write(`Default project set to ${projectId}.
|
|
1070
|
-
`);
|
|
1071
|
-
});
|
|
1072
|
-
}
|
|
1065
|
+
// src/commands/sync.ts
|
|
1066
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
1067
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1073
1068
|
|
|
1074
|
-
// src/
|
|
1075
|
-
import
|
|
1076
|
-
import { promisify as promisify2 } from "util";
|
|
1077
|
-
import { z as z2 } from "zod";
|
|
1069
|
+
// src/sync-repo.ts
|
|
1070
|
+
import path8 from "path";
|
|
1078
1071
|
|
|
1079
|
-
// src/
|
|
1080
|
-
import {
|
|
1081
|
-
import
|
|
1082
|
-
async function readPlanFile(filePath) {
|
|
1083
|
-
const absPath = path4.resolve(filePath);
|
|
1084
|
-
let content;
|
|
1085
|
-
try {
|
|
1086
|
-
content = await readFile3(absPath, "utf8");
|
|
1087
|
-
} catch (err) {
|
|
1088
|
-
const code = err != null && typeof err === "object" && "code" in err ? err.code : "";
|
|
1089
|
-
if (code === "ENOENT") {
|
|
1090
|
-
throw new CliError(`File not found: ${absPath}`, 2);
|
|
1091
|
-
}
|
|
1092
|
-
throw new CliError(`Cannot read file: ${absPath}: ${String(err)}`, 2);
|
|
1093
|
-
}
|
|
1094
|
-
const trimmed = content.trim();
|
|
1095
|
-
if (trimmed.length === 0) {
|
|
1096
|
-
throw new CliError(`Plan file is empty: ${absPath}`, 2);
|
|
1097
|
-
}
|
|
1098
|
-
if (trimmed.length > PLAN_CONTENT_MAX) {
|
|
1099
|
-
throw new CliError(
|
|
1100
|
-
`Plan file exceeds ${PLAN_CONTENT_MAX} characters (${trimmed.length}): ${absPath}`,
|
|
1101
|
-
2
|
|
1102
|
-
);
|
|
1103
|
-
}
|
|
1104
|
-
const stem = path4.basename(absPath, path4.extname(absPath));
|
|
1105
|
-
return { content: trimmed, sourcePath: absPath, titleFallback: stem };
|
|
1106
|
-
}
|
|
1107
|
-
async function readDocFile(filePath) {
|
|
1108
|
-
const absPath = path4.resolve(filePath);
|
|
1109
|
-
let fileSize;
|
|
1110
|
-
try {
|
|
1111
|
-
const info = await stat2(absPath);
|
|
1112
|
-
fileSize = info.size;
|
|
1113
|
-
} catch (err) {
|
|
1114
|
-
const code = err != null && typeof err === "object" && "code" in err ? err.code : "";
|
|
1115
|
-
if (code === "ENOENT") {
|
|
1116
|
-
throw new CliError(`File not found: ${absPath}`, 2);
|
|
1117
|
-
}
|
|
1118
|
-
throw new CliError(`Cannot stat file: ${absPath}: ${String(err)}`, 2);
|
|
1119
|
-
}
|
|
1120
|
-
if (fileSize > DOCUMENT_MAX_BYTES) {
|
|
1121
|
-
throw new CliError(`Document exceeds 2 MiB: ${absPath}`, 2);
|
|
1122
|
-
}
|
|
1123
|
-
let buf;
|
|
1124
|
-
try {
|
|
1125
|
-
buf = await readFile3(absPath);
|
|
1126
|
-
} catch (err) {
|
|
1127
|
-
throw new CliError(`Cannot read file: ${absPath}: ${String(err)}`, 2);
|
|
1128
|
-
}
|
|
1129
|
-
const isBinary = buf.includes(0) || !isUtf8RoundTrip(buf);
|
|
1130
|
-
const rawFilename = path4.basename(absPath);
|
|
1131
|
-
const filename = rawFilename.slice(0, DOCUMENT_FILENAME_MAX);
|
|
1132
|
-
if (isBinary) {
|
|
1133
|
-
return { content: buf.toString("base64"), encoding: "base64", filename };
|
|
1134
|
-
}
|
|
1135
|
-
return { content: buf.toString("utf8"), encoding: "utf8", filename };
|
|
1136
|
-
}
|
|
1137
|
-
function isUtf8RoundTrip(buf) {
|
|
1138
|
-
try {
|
|
1139
|
-
const str = buf.toString("utf8");
|
|
1140
|
-
const reEncoded = Buffer.from(str, "utf8");
|
|
1141
|
-
if (reEncoded.length !== buf.length) {
|
|
1142
|
-
return false;
|
|
1143
|
-
}
|
|
1144
|
-
for (let i = 0; i < buf.length; i++) {
|
|
1145
|
-
if (buf[i] !== reEncoded[i]) {
|
|
1146
|
-
return false;
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
return true;
|
|
1150
|
-
} catch {
|
|
1151
|
-
return false;
|
|
1152
|
-
}
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
// src/markdown.ts
|
|
1156
|
-
function extractTitle(md) {
|
|
1157
|
-
const lines = md.split("\n");
|
|
1158
|
-
let inFence = false;
|
|
1159
|
-
let fenceChar = "";
|
|
1160
|
-
for (const line of lines) {
|
|
1161
|
-
const trimmed = line.trimStart();
|
|
1162
|
-
if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) {
|
|
1163
|
-
const ch = trimmed[0];
|
|
1164
|
-
if (!inFence) {
|
|
1165
|
-
inFence = true;
|
|
1166
|
-
fenceChar = ch;
|
|
1167
|
-
} else if (ch === fenceChar) {
|
|
1168
|
-
inFence = false;
|
|
1169
|
-
fenceChar = "";
|
|
1170
|
-
}
|
|
1171
|
-
continue;
|
|
1172
|
-
}
|
|
1173
|
-
if (inFence) {
|
|
1174
|
-
continue;
|
|
1175
|
-
}
|
|
1176
|
-
if (trimmed.startsWith("# ")) {
|
|
1177
|
-
const title = trimmed.slice(2).trim();
|
|
1178
|
-
if (title.length > 0) {
|
|
1179
|
-
return title;
|
|
1180
|
-
}
|
|
1181
|
-
}
|
|
1182
|
-
}
|
|
1183
|
-
return null;
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
// src/commands/plan-register.ts
|
|
1187
|
-
var execFileAsync = promisify2(execFile2);
|
|
1188
|
-
function assertToken2(token) {
|
|
1189
|
-
if (token == null) {
|
|
1190
|
-
throw new CliError(
|
|
1191
|
-
"No API token configured. Run `nolto init` or set NOLTO_TOKEN.",
|
|
1192
|
-
3
|
|
1193
|
-
);
|
|
1194
|
-
}
|
|
1195
|
-
}
|
|
1196
|
-
var phaseArraySchema = z2.array(
|
|
1197
|
-
z2.object({
|
|
1198
|
-
title: z2.string().min(1),
|
|
1199
|
-
content: z2.string().min(1),
|
|
1200
|
-
status: z2.string().optional(),
|
|
1201
|
-
plannedStartAt: z2.string().optional(),
|
|
1202
|
-
plannedEndAt: z2.string().optional()
|
|
1203
|
-
})
|
|
1204
|
-
).max(PHASES_MAX);
|
|
1205
|
-
function collect(val, prev) {
|
|
1206
|
-
return [...prev, val];
|
|
1207
|
-
}
|
|
1208
|
-
function resolveUrl(detailUrl, baseUrl) {
|
|
1209
|
-
if (detailUrl == null) return "";
|
|
1210
|
-
try {
|
|
1211
|
-
return new URL(detailUrl, baseUrl).href;
|
|
1212
|
-
} catch {
|
|
1213
|
-
return detailUrl;
|
|
1214
|
-
}
|
|
1215
|
-
}
|
|
1216
|
-
async function getGitContributor() {
|
|
1217
|
-
const result = {};
|
|
1218
|
-
try {
|
|
1219
|
-
const { stdout: name } = await execFileAsync("git", ["config", "user.name"], { timeout: 3e3 });
|
|
1220
|
-
const trimmed = name.trim();
|
|
1221
|
-
if (trimmed.length > 0) result["userName"] = trimmed;
|
|
1222
|
-
} catch {
|
|
1223
|
-
}
|
|
1224
|
-
try {
|
|
1225
|
-
const { stdout: email } = await execFileAsync("git", ["config", "user.email"], { timeout: 3e3 });
|
|
1226
|
-
const trimmed = email.trim();
|
|
1227
|
-
if (trimmed.length > 0) result["userEmail"] = trimmed;
|
|
1228
|
-
} catch {
|
|
1229
|
-
}
|
|
1230
|
-
return result;
|
|
1231
|
-
}
|
|
1232
|
-
function registerPlanRegisterSubcommand(plan, deps) {
|
|
1233
|
-
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) => {
|
|
1234
|
-
assertToken2(deps.settings.token);
|
|
1235
|
-
if (opts.status != null && !PLAN_STATUSES.includes(opts.status)) {
|
|
1236
|
-
throw new CliError(
|
|
1237
|
-
`Invalid status "${opts.status}". Valid values: ${PLAN_STATUSES.join(", ")}`,
|
|
1238
|
-
2
|
|
1239
|
-
);
|
|
1240
|
-
}
|
|
1241
|
-
const { content, sourcePath, titleFallback } = await readPlanFile(opts.file);
|
|
1242
|
-
const title = opts.title ?? extractTitle(content) ?? titleFallback;
|
|
1243
|
-
if (title.length > PLAN_TITLE_MAX) {
|
|
1244
|
-
throw new CliError(
|
|
1245
|
-
`Plan title is too long (${title.length} chars, max ${PLAN_TITLE_MAX}).`,
|
|
1246
|
-
2
|
|
1247
|
-
);
|
|
1248
|
-
}
|
|
1249
|
-
let parsedPhases;
|
|
1250
|
-
if (opts.phases != null) {
|
|
1251
|
-
try {
|
|
1252
|
-
parsedPhases = JSON.parse(opts.phases);
|
|
1253
|
-
} catch (err) {
|
|
1254
|
-
throw new CliError(
|
|
1255
|
-
`Invalid --phases JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
1256
|
-
2
|
|
1257
|
-
);
|
|
1258
|
-
}
|
|
1259
|
-
const phaseResult = phaseArraySchema.safeParse(parsedPhases);
|
|
1260
|
-
if (!phaseResult.success) {
|
|
1261
|
-
const issue = phaseResult.error.issues[0];
|
|
1262
|
-
if (issue?.code === "too_big" && issue.path.length === 0) {
|
|
1263
|
-
throw new CliError(`Too many phases (max ${PHASES_MAX}).`, 2);
|
|
1264
|
-
}
|
|
1265
|
-
const fieldPath = issue?.path.join(".") ?? "";
|
|
1266
|
-
const fieldDesc = fieldPath.length > 0 ? `field "${fieldPath}"` : "unknown field";
|
|
1267
|
-
throw new CliError(`Invalid --phases JSON: ${fieldDesc}`, 2);
|
|
1268
|
-
}
|
|
1269
|
-
parsedPhases = phaseResult.data;
|
|
1270
|
-
}
|
|
1271
|
-
if (opts.doc.length > 10) {
|
|
1272
|
-
throw new CliError("Too many documents (max 10).", 2);
|
|
1273
|
-
}
|
|
1274
|
-
const documents = [];
|
|
1275
|
-
for (const docSpec of opts.doc) {
|
|
1276
|
-
const match = /^([a-z_]+)=(.+)$/.exec(docSpec);
|
|
1277
|
-
if (match == null) {
|
|
1278
|
-
throw new CliError(`Invalid --doc format "${docSpec}". Expected kind=path.`, 2);
|
|
1279
|
-
}
|
|
1280
|
-
const kind = match[1];
|
|
1281
|
-
const docPath = match[2];
|
|
1282
|
-
if (!PLAN_DOCUMENT_KINDS.includes(kind)) {
|
|
1283
|
-
throw new CliError(
|
|
1284
|
-
`Invalid document kind "${kind}". Valid values: ${PLAN_DOCUMENT_KINDS.join(", ")}`,
|
|
1285
|
-
2
|
|
1286
|
-
);
|
|
1287
|
-
}
|
|
1288
|
-
const { content: docContent, encoding, filename } = await readDocFile(docPath);
|
|
1289
|
-
documents.push({ kind, filename, content: docContent, encoding });
|
|
1290
|
-
}
|
|
1291
|
-
const source = { kind: "file", path: sourcePath };
|
|
1292
|
-
if (opts.sourceUrl != null) source["url"] = opts.sourceUrl;
|
|
1293
|
-
if (opts.sourceHash != null) source["hash"] = opts.sourceHash;
|
|
1294
|
-
const git = opts.git !== false ? await getGitContributor() : {};
|
|
1295
|
-
const planPayload = {
|
|
1296
|
-
title,
|
|
1297
|
-
content,
|
|
1298
|
-
...opts.status != null ? { status: opts.status } : {},
|
|
1299
|
-
...opts.plannedStart != null ? { plannedStartAt: opts.plannedStart } : {},
|
|
1300
|
-
...opts.plannedEnd != null ? { plannedEndAt: opts.plannedEnd } : {},
|
|
1301
|
-
...parsedPhases != null ? { phases: parsedPhases } : {},
|
|
1302
|
-
...documents.length > 0 ? { documents } : {}
|
|
1303
|
-
};
|
|
1304
|
-
const mcpArgs = { plan: planPayload, source, git };
|
|
1305
|
-
if (deps.settings.defaultProjectId != null) {
|
|
1306
|
-
mcpArgs["projectId"] = deps.settings.defaultProjectId;
|
|
1307
|
-
}
|
|
1308
|
-
const result = await deps.caller.call("register_plan", mcpArgs);
|
|
1309
|
-
if (deps.output.mode === "json") {
|
|
1310
|
-
printResult(result, "json");
|
|
1311
|
-
return;
|
|
1312
|
-
}
|
|
1313
|
-
const r = result;
|
|
1314
|
-
const detailUrl = resolveUrl(r?.detailUrl, deps.settings.baseUrl);
|
|
1315
|
-
process.stdout.write(
|
|
1316
|
-
formatRecord({
|
|
1317
|
-
planId: r?.planId ?? "",
|
|
1318
|
-
transformStatus: r?.transformStatus ?? "",
|
|
1319
|
-
url: detailUrl
|
|
1320
|
-
}) + "\n"
|
|
1321
|
-
);
|
|
1322
|
-
});
|
|
1323
|
-
}
|
|
1324
|
-
|
|
1325
|
-
// src/commands/plan.ts
|
|
1326
|
-
function assertToken3(token) {
|
|
1327
|
-
if (token == null) {
|
|
1328
|
-
throw new CliError(
|
|
1329
|
-
"No API token configured. Run `nolto init` or set NOLTO_TOKEN.",
|
|
1330
|
-
3
|
|
1331
|
-
);
|
|
1332
|
-
}
|
|
1333
|
-
}
|
|
1334
|
-
function register5(program, deps) {
|
|
1335
|
-
const plan = program.command("plan").description("Manage plans.");
|
|
1336
|
-
plan.command("list").description("List plans.").option("--status <status>", "Filter by status").action(async (opts) => {
|
|
1337
|
-
assertToken3(deps.settings.token);
|
|
1338
|
-
if (opts.status != null && !PLAN_STATUSES.includes(opts.status)) {
|
|
1339
|
-
throw new CliError(
|
|
1340
|
-
`Invalid status "${opts.status}". Valid values: ${PLAN_STATUSES.join(", ")}`,
|
|
1341
|
-
2
|
|
1342
|
-
);
|
|
1343
|
-
}
|
|
1344
|
-
const args = {};
|
|
1345
|
-
if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
|
|
1346
|
-
if (opts.status != null) args["status"] = opts.status;
|
|
1347
|
-
const result = await deps.caller.call("list_plans", args);
|
|
1348
|
-
if (deps.output.mode === "json") {
|
|
1349
|
-
printResult(result, "json");
|
|
1350
|
-
return;
|
|
1351
|
-
}
|
|
1352
|
-
const rows = unwrapList(result, "plans");
|
|
1353
|
-
const tableRows = rows.map((p) => ({
|
|
1354
|
-
id: p.id ?? "",
|
|
1355
|
-
title: p.display_title ?? p.raw_title ?? p.title ?? "",
|
|
1356
|
-
status: p.status ?? "",
|
|
1357
|
-
createdAt: p.created_at ?? p.createdAt ?? ""
|
|
1358
|
-
}));
|
|
1359
|
-
process.stdout.write(formatTable(tableRows, ["id", "title", "status", "createdAt"]) + "\n");
|
|
1360
|
-
});
|
|
1361
|
-
plan.command("get <planId>").description("Get plan details.").action(async (planId) => {
|
|
1362
|
-
assertToken3(deps.settings.token);
|
|
1363
|
-
const args = { planId };
|
|
1364
|
-
if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
|
|
1365
|
-
const result = await deps.caller.call("get_plan", args);
|
|
1366
|
-
if (deps.output.mode === "json") {
|
|
1367
|
-
printResult(result, "json");
|
|
1368
|
-
return;
|
|
1369
|
-
}
|
|
1370
|
-
printResult(result, "human");
|
|
1371
|
-
});
|
|
1372
|
-
registerPlanRegisterSubcommand(plan, deps);
|
|
1373
|
-
plan.command("status <planId> <status>").description("Update plan status.").option("--message <text>", "Optional message").action(async (planId, status, opts) => {
|
|
1374
|
-
assertToken3(deps.settings.token);
|
|
1375
|
-
if (!PLAN_STATUSES.includes(status)) {
|
|
1376
|
-
throw new CliError(
|
|
1377
|
-
`Invalid status "${status}". Valid values: ${PLAN_STATUSES.join(", ")}`,
|
|
1378
|
-
2
|
|
1379
|
-
);
|
|
1380
|
-
}
|
|
1381
|
-
const args = { planId, status };
|
|
1382
|
-
if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
|
|
1383
|
-
if (opts.message != null) args["message"] = opts.message;
|
|
1384
|
-
const result = await deps.caller.call("update_plan_status", args);
|
|
1385
|
-
if (deps.output.mode === "json") {
|
|
1386
|
-
printResult(result, "json");
|
|
1387
|
-
return;
|
|
1388
|
-
}
|
|
1389
|
-
process.stdout.write(`Plan ${planId} \u2192 ${status}.
|
|
1390
|
-
`);
|
|
1391
|
-
});
|
|
1392
|
-
plan.command("review <planId> <verdict>").description("Record a plan review.").option("--summary <text>", "Review summary").action(async (planId, verdict, opts) => {
|
|
1393
|
-
assertToken3(deps.settings.token);
|
|
1394
|
-
if (!REVIEW_VERDICTS.includes(verdict)) {
|
|
1395
|
-
throw new CliError(
|
|
1396
|
-
`Invalid verdict "${verdict}". Valid values: ${REVIEW_VERDICTS.join(", ")}`,
|
|
1397
|
-
2
|
|
1398
|
-
);
|
|
1399
|
-
}
|
|
1400
|
-
const args = { planId, verdict };
|
|
1401
|
-
if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
|
|
1402
|
-
if (opts.summary != null) args["summary"] = opts.summary;
|
|
1403
|
-
const result = await deps.caller.call("record_plan_review", args);
|
|
1404
|
-
if (deps.output.mode === "json") {
|
|
1405
|
-
printResult(result, "json");
|
|
1406
|
-
return;
|
|
1407
|
-
}
|
|
1408
|
-
process.stdout.write(`Review recorded: ${verdict}.
|
|
1409
|
-
`);
|
|
1410
|
-
});
|
|
1411
|
-
}
|
|
1412
|
-
|
|
1413
|
-
// src/commands/phase.ts
|
|
1414
|
-
function assertToken4(token) {
|
|
1415
|
-
if (token == null) {
|
|
1416
|
-
throw new CliError(
|
|
1417
|
-
"No API token configured. Run `nolto init` or set NOLTO_TOKEN.",
|
|
1418
|
-
3
|
|
1419
|
-
);
|
|
1420
|
-
}
|
|
1421
|
-
}
|
|
1422
|
-
function register6(program, deps) {
|
|
1423
|
-
const phase = program.command("phase").description("Manage plan phases.");
|
|
1424
|
-
phase.command("status <planId> <phaseId> <status>").description("Update a phase status.").option("--message <text>", "Optional message").action(async (planId, phaseId, status, opts) => {
|
|
1425
|
-
assertToken4(deps.settings.token);
|
|
1426
|
-
if (!PLAN_STATUSES.includes(status)) {
|
|
1427
|
-
throw new CliError(
|
|
1428
|
-
`Invalid status "${status}". Valid values: ${PLAN_STATUSES.join(", ")}`,
|
|
1429
|
-
2
|
|
1430
|
-
);
|
|
1431
|
-
}
|
|
1432
|
-
const args = {
|
|
1433
|
-
planId,
|
|
1434
|
-
phaseId,
|
|
1435
|
-
status
|
|
1436
|
-
};
|
|
1437
|
-
if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
|
|
1438
|
-
if (opts.message != null) args["message"] = opts.message;
|
|
1439
|
-
const result = await deps.caller.call("update_phase_status", args);
|
|
1440
|
-
if (deps.output.mode === "json") {
|
|
1441
|
-
printResult(result, "json");
|
|
1442
|
-
return;
|
|
1443
|
-
}
|
|
1444
|
-
const r = result;
|
|
1445
|
-
const planStatus = r?.planStatus ?? "";
|
|
1446
|
-
process.stdout.write(`Phase ${phaseId} \u2192 ${status} (plan now ${planStatus}).
|
|
1447
|
-
`);
|
|
1448
|
-
});
|
|
1449
|
-
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) => {
|
|
1450
|
-
assertToken4(deps.settings.token);
|
|
1451
|
-
if (!TEST_VERDICTS.includes(verdict)) {
|
|
1452
|
-
throw new CliError(
|
|
1453
|
-
`Invalid verdict "${verdict}". Valid values: ${TEST_VERDICTS.join(", ")}`,
|
|
1454
|
-
2
|
|
1455
|
-
);
|
|
1456
|
-
}
|
|
1457
|
-
let round;
|
|
1458
|
-
if (opts.round != null) {
|
|
1459
|
-
if (!/^[1-9]\d*$/.test(opts.round)) {
|
|
1460
|
-
throw new CliError(`--round must be a positive integer, got "${opts.round}"`, 2);
|
|
1461
|
-
}
|
|
1462
|
-
const parsed = Number(opts.round);
|
|
1463
|
-
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
1464
|
-
throw new CliError(`--round must be a positive integer, got "${opts.round}"`, 2);
|
|
1465
|
-
}
|
|
1466
|
-
round = parsed;
|
|
1467
|
-
}
|
|
1468
|
-
const args = {
|
|
1469
|
-
planId,
|
|
1470
|
-
phaseId,
|
|
1471
|
-
verdict
|
|
1472
|
-
};
|
|
1473
|
-
if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
|
|
1474
|
-
if (round != null) args["round"] = round;
|
|
1475
|
-
if (opts.summary != null) args["summary"] = opts.summary;
|
|
1476
|
-
const result = await deps.caller.call("record_phase_test_result", args);
|
|
1477
|
-
if (deps.output.mode === "json") {
|
|
1478
|
-
printResult(result, "json");
|
|
1479
|
-
return;
|
|
1480
|
-
}
|
|
1481
|
-
const roundDisplay = round != null ? String(round) : "\u2014";
|
|
1482
|
-
process.stdout.write(`Recorded ${verdict} (round ${roundDisplay}).
|
|
1483
|
-
`);
|
|
1484
|
-
});
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
|
-
// src/commands/doc.ts
|
|
1488
|
-
function assertToken5(token) {
|
|
1489
|
-
if (token == null) {
|
|
1490
|
-
throw new CliError(
|
|
1491
|
-
"No API token configured. Run `nolto init` or set NOLTO_TOKEN.",
|
|
1492
|
-
3
|
|
1493
|
-
);
|
|
1494
|
-
}
|
|
1495
|
-
}
|
|
1496
|
-
function register7(program, deps) {
|
|
1497
|
-
const doc = program.command("doc").description("Manage plan documents.");
|
|
1498
|
-
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) => {
|
|
1499
|
-
assertToken5(deps.settings.token);
|
|
1500
|
-
if (!PLAN_DOCUMENT_KINDS.includes(opts.kind)) {
|
|
1501
|
-
throw new CliError(
|
|
1502
|
-
`Invalid kind "${opts.kind}". Valid values: ${PLAN_DOCUMENT_KINDS.join(", ")}`,
|
|
1503
|
-
2
|
|
1504
|
-
);
|
|
1505
|
-
}
|
|
1506
|
-
const { content, encoding, filename: autoFilename } = await readDocFile(opts.file);
|
|
1507
|
-
const filename = opts.filename != null ? opts.filename.slice(0, DOCUMENT_FILENAME_MAX) : autoFilename;
|
|
1508
|
-
const args = {
|
|
1509
|
-
planId,
|
|
1510
|
-
kind: opts.kind,
|
|
1511
|
-
filename,
|
|
1512
|
-
content,
|
|
1513
|
-
encoding
|
|
1514
|
-
};
|
|
1515
|
-
if (deps.settings.defaultProjectId != null) args["projectId"] = deps.settings.defaultProjectId;
|
|
1516
|
-
if (opts.phase != null) args["phaseId"] = opts.phase;
|
|
1517
|
-
const result = await deps.caller.call("upload_plan_document", args);
|
|
1518
|
-
if (deps.output.mode === "json") {
|
|
1519
|
-
printResult(result, "json");
|
|
1520
|
-
return;
|
|
1521
|
-
}
|
|
1522
|
-
const byteCount = encoding === "base64" ? Math.floor(content.length * 3 / 4) : Buffer.byteLength(content, "utf8");
|
|
1523
|
-
process.stdout.write(`Uploaded ${filename} (${encoding}, ${byteCount} bytes).
|
|
1524
|
-
`);
|
|
1525
|
-
});
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
|
-
// src/queue-file.ts
|
|
1529
|
-
import { openSync, closeSync, writeFileSync, unlinkSync, renameSync, statSync as statSync2 } from "fs";
|
|
1530
|
-
import { readFile as readFile4, writeFile as writeFile3, mkdir as mkdir3, unlink as unlink2, appendFile } from "fs/promises";
|
|
1531
|
-
import path5 from "path";
|
|
1532
|
-
import crypto from "crypto";
|
|
1533
|
-
function queueFilePath(projectDir) {
|
|
1534
|
-
return path5.join(projectDir, ".nolto", "pending.jsonl");
|
|
1535
|
-
}
|
|
1536
|
-
function lockFilePath(projectDir) {
|
|
1537
|
-
return path5.join(projectDir, ".nolto", "flush.lock");
|
|
1538
|
-
}
|
|
1539
|
-
function logFilePath(projectDir) {
|
|
1540
|
-
return path5.join(projectDir, ".nolto", "flush.log");
|
|
1541
|
-
}
|
|
1542
|
-
function resolveQueueDir(inputs) {
|
|
1543
|
-
if (inputs.flagDir != null) return inputs.flagDir;
|
|
1544
|
-
if (inputs.env["NOLTO_QUEUE_DIR"]) return inputs.env["NOLTO_QUEUE_DIR"];
|
|
1545
|
-
if (inputs.env["CLAUDE_PROJECT_DIR"]) return inputs.env["CLAUDE_PROJECT_DIR"];
|
|
1546
|
-
return findAncestorWithMarker(inputs.cwd);
|
|
1547
|
-
}
|
|
1548
|
-
function findAncestorWithMarker(startDir) {
|
|
1549
|
-
let current = startDir;
|
|
1550
|
-
while (true) {
|
|
1551
|
-
if (hasMarkerSync(current, ".git") || hasMarkerSync(current, ".nolto")) {
|
|
1552
|
-
return current;
|
|
1553
|
-
}
|
|
1554
|
-
const parent = path5.dirname(current);
|
|
1555
|
-
if (parent === current) break;
|
|
1556
|
-
current = parent;
|
|
1557
|
-
}
|
|
1558
|
-
return startDir;
|
|
1559
|
-
}
|
|
1560
|
-
function hasMarkerSync(dir, marker) {
|
|
1561
|
-
try {
|
|
1562
|
-
statSync2(path5.join(dir, marker));
|
|
1563
|
-
return true;
|
|
1564
|
-
} catch {
|
|
1565
|
-
return false;
|
|
1566
|
-
}
|
|
1567
|
-
}
|
|
1568
|
-
async function readQueue(projectDir) {
|
|
1569
|
-
const filePath = queueFilePath(projectDir);
|
|
1570
|
-
let raw;
|
|
1571
|
-
try {
|
|
1572
|
-
raw = await readFile4(filePath, "utf8");
|
|
1573
|
-
} catch {
|
|
1574
|
-
return [];
|
|
1575
|
-
}
|
|
1576
|
-
const entries = [];
|
|
1577
|
-
for (const line of raw.split("\n")) {
|
|
1578
|
-
const trimmed = line.trim();
|
|
1579
|
-
if (!trimmed) continue;
|
|
1580
|
-
try {
|
|
1581
|
-
const obj = JSON.parse(trimmed);
|
|
1582
|
-
entries.push(obj);
|
|
1583
|
-
} catch {
|
|
1584
|
-
await appendLog(projectDir, "warn", `malformed queue line skipped: ${trimmed.slice(0, 80)}`);
|
|
1585
|
-
}
|
|
1586
|
-
}
|
|
1587
|
-
return entries;
|
|
1588
|
-
}
|
|
1589
|
-
async function appendEntry(projectDir, entry) {
|
|
1590
|
-
const noltoDir = path5.join(projectDir, ".nolto");
|
|
1591
|
-
await mkdir3(noltoDir, { recursive: true, mode: 448 });
|
|
1592
|
-
const existing = await readQueue(projectDir);
|
|
1593
|
-
if (existing.length >= QUEUE_MAX_ENTRIES) {
|
|
1594
|
-
throw new CliError(
|
|
1595
|
-
`Queue is full (${QUEUE_MAX_ENTRIES} entries). Flush before adding more.`,
|
|
1596
|
-
2
|
|
1597
|
-
);
|
|
1598
|
-
}
|
|
1599
|
-
const newEntry = {
|
|
1600
|
-
id: crypto.randomUUID(),
|
|
1601
|
-
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1602
|
-
tool: entry.tool,
|
|
1603
|
-
args: { ...entry.args }
|
|
1604
|
-
};
|
|
1605
|
-
const line = JSON.stringify(newEntry) + "\n";
|
|
1606
|
-
try {
|
|
1607
|
-
await appendFile(queueFilePath(projectDir), line, "utf8");
|
|
1608
|
-
} catch (err) {
|
|
1609
|
-
if (err instanceof CliError) throw err;
|
|
1610
|
-
throw new CliError(
|
|
1611
|
-
`Failed to write queue entry: ${err.message ?? String(err)}`,
|
|
1612
|
-
2
|
|
1613
|
-
);
|
|
1614
|
-
}
|
|
1615
|
-
return newEntry;
|
|
1616
|
-
}
|
|
1617
|
-
async function atomicRewriteQueue(projectDir, entries) {
|
|
1618
|
-
const filePath = queueFilePath(projectDir);
|
|
1619
|
-
if (entries.length === 0) {
|
|
1620
|
-
try {
|
|
1621
|
-
await unlink2(filePath);
|
|
1622
|
-
} catch (err) {
|
|
1623
|
-
const code = err.code;
|
|
1624
|
-
if (code !== "ENOENT") throw err;
|
|
1625
|
-
}
|
|
1626
|
-
return;
|
|
1627
|
-
}
|
|
1628
|
-
const noltoDir = path5.join(projectDir, ".nolto");
|
|
1629
|
-
await mkdir3(noltoDir, { recursive: true });
|
|
1630
|
-
const tmpPath = filePath + ".tmp";
|
|
1631
|
-
const content = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
1632
|
-
await writeFile3(tmpPath, content, "utf8");
|
|
1633
|
-
renameSync(tmpPath, filePath);
|
|
1634
|
-
}
|
|
1635
|
-
async function acquireLock(projectDir) {
|
|
1636
|
-
const noltoDir = path5.join(projectDir, ".nolto");
|
|
1637
|
-
await mkdir3(noltoDir, { recursive: true });
|
|
1638
|
-
const lockPath = lockFilePath(projectDir);
|
|
1639
|
-
return tryAcquire(lockPath);
|
|
1640
|
-
}
|
|
1641
|
-
async function tryAcquire(lockPath) {
|
|
1642
|
-
try {
|
|
1643
|
-
const fd = openSync(lockPath, "wx");
|
|
1644
|
-
writeFileSync(fd, String(process.pid));
|
|
1645
|
-
closeSync(fd);
|
|
1646
|
-
return makeLockHandle(lockPath);
|
|
1647
|
-
} catch (err) {
|
|
1648
|
-
if (err.code !== "EEXIST") throw err;
|
|
1649
|
-
}
|
|
1650
|
-
let pidStr;
|
|
1651
|
-
try {
|
|
1652
|
-
pidStr = await readFile4(lockPath, "utf8");
|
|
1653
|
-
} catch {
|
|
1654
|
-
try {
|
|
1655
|
-
unlinkSync(lockPath);
|
|
1656
|
-
} catch {
|
|
1657
|
-
}
|
|
1658
|
-
return tryAcquire(lockPath);
|
|
1659
|
-
}
|
|
1660
|
-
const pid = parseInt(pidStr, 10);
|
|
1661
|
-
if (isNaN(pid)) {
|
|
1662
|
-
try {
|
|
1663
|
-
unlinkSync(lockPath);
|
|
1664
|
-
} catch {
|
|
1665
|
-
}
|
|
1666
|
-
return tryAcquire(lockPath);
|
|
1667
|
-
}
|
|
1668
|
-
try {
|
|
1669
|
-
process.kill(pid, 0);
|
|
1670
|
-
return null;
|
|
1671
|
-
} catch (sigErr) {
|
|
1672
|
-
if (sigErr.code === "ESRCH") {
|
|
1673
|
-
try {
|
|
1674
|
-
unlinkSync(lockPath);
|
|
1675
|
-
} catch {
|
|
1676
|
-
}
|
|
1677
|
-
return tryAcquire(lockPath);
|
|
1678
|
-
}
|
|
1679
|
-
return null;
|
|
1680
|
-
}
|
|
1681
|
-
}
|
|
1682
|
-
function makeLockHandle(lockPath) {
|
|
1683
|
-
let released = false;
|
|
1684
|
-
return {
|
|
1685
|
-
async release() {
|
|
1686
|
-
if (released) return;
|
|
1687
|
-
released = true;
|
|
1688
|
-
try {
|
|
1689
|
-
unlinkSync(lockPath);
|
|
1690
|
-
} catch {
|
|
1691
|
-
}
|
|
1692
|
-
}
|
|
1693
|
-
};
|
|
1694
|
-
}
|
|
1695
|
-
async function appendLog(projectDir, level, message) {
|
|
1696
|
-
try {
|
|
1697
|
-
const noltoDir = path5.join(projectDir, ".nolto");
|
|
1698
|
-
await mkdir3(noltoDir, { recursive: true });
|
|
1699
|
-
const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level}] ${message}
|
|
1700
|
-
`;
|
|
1701
|
-
await appendFile(logFilePath(projectDir), line, "utf8");
|
|
1702
|
-
} catch {
|
|
1703
|
-
}
|
|
1704
|
-
}
|
|
1072
|
+
// src/sync-core.ts
|
|
1073
|
+
import { createHash } from "crypto";
|
|
1074
|
+
import path7 from "path";
|
|
1705
1075
|
|
|
1706
|
-
// src/
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
)
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
}
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
if (
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
}
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
if (
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
}
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
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
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
|
|
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 });
|
|
1841
1217
|
}
|
|
1842
|
-
process.stdout.write(`Queued record_plan_review.
|
|
1843
|
-
`);
|
|
1844
1218
|
}
|
|
1219
|
+
}
|
|
1220
|
+
return refs;
|
|
1221
|
+
}
|
|
1222
|
+
async function loadValidRoadmap(repoRoot, readFile7) {
|
|
1223
|
+
const filePath = path7.join(repoRoot, ".roadmap", "roadmap.json");
|
|
1224
|
+
let raw;
|
|
1225
|
+
try {
|
|
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);
|
|
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
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
return parsed;
|
|
1245
|
+
}
|
|
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;
|
|
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
|
|
1845
1273
|
);
|
|
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}`
|
|
1277
|
+
);
|
|
1278
|
+
return response;
|
|
1846
1279
|
}
|
|
1847
1280
|
|
|
1848
|
-
// src/
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
const
|
|
1852
|
-
const
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
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 }
|
|
1860
1299
|
);
|
|
1861
|
-
|
|
1862
|
-
return
|
|
1300
|
+
const planAbsPaths = collectPlanRefs(roadmap).map((ref) => path8.join(args.root, ref.path));
|
|
1301
|
+
return { ...response, planAbsPaths };
|
|
1863
1302
|
}
|
|
1864
1303
|
|
|
1865
|
-
// src/commands/
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
if (lock === null) {
|
|
1869
|
-
process.stdout.write("Another flush is running.\n");
|
|
1870
|
-
return { flushed: 0, remaining: 0, failed: 0 };
|
|
1871
|
-
}
|
|
1872
|
-
try {
|
|
1873
|
-
const entries = await readQueue(projectDir);
|
|
1874
|
-
if (entries.length === 0) {
|
|
1875
|
-
process.stdout.write("Nothing to flush.\n");
|
|
1876
|
-
return { flushed: 0, remaining: 0, failed: 0 };
|
|
1877
|
-
}
|
|
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 () => {
|
|
1878
1307
|
if (deps.settings.token == null) {
|
|
1879
|
-
|
|
1880
|
-
return { flushed: 0, remaining: entries.length, failed: 0 };
|
|
1308
|
+
throw new CliError("Not authenticated. Run `nolto login` or set NOLTO_TOKEN.", 3);
|
|
1881
1309
|
}
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
await deps.caller.call(entry.tool, entry.args);
|
|
1887
|
-
} catch (err) {
|
|
1888
|
-
const cliErr = err instanceof CliError ? err : new CliError(String(err), 5);
|
|
1889
|
-
await appendLog(
|
|
1890
|
-
projectDir,
|
|
1891
|
-
"error",
|
|
1892
|
-
`${entry.tool} id=${entry.id} exit=${cliErr.exitCode} ${cliErr.message}`
|
|
1893
|
-
);
|
|
1894
|
-
return { flushed, remaining: remaining.length, failed: 1 };
|
|
1895
|
-
}
|
|
1896
|
-
flushed++;
|
|
1897
|
-
remaining = remaining.slice(1);
|
|
1898
|
-
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);
|
|
1899
1314
|
}
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
}
|
|
1905
|
-
function register8(program, deps) {
|
|
1906
|
-
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) => {
|
|
1907
|
-
const projectDir = resolveQueueDir({
|
|
1908
|
-
flagDir: opts.queueDir,
|
|
1909
|
-
env: process.env,
|
|
1910
|
-
cwd: process.cwd()
|
|
1315
|
+
const http = createHttpClient({
|
|
1316
|
+
baseUrl: deps.settings.baseUrl,
|
|
1317
|
+
version: deps.version,
|
|
1318
|
+
token: deps.settings.token
|
|
1911
1319
|
});
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
process.
|
|
1920
|
-
} else {
|
|
1921
|
-
if (summary.flushed > 0 || summary.failed > 0) {
|
|
1922
|
-
process.stdout.write(
|
|
1923
|
-
`Flushed ${summary.flushed}, remaining ${summary.remaining}, failed ${summary.failed}.
|
|
1924
|
-
`
|
|
1925
|
-
);
|
|
1926
|
-
}
|
|
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")
|
|
1927
1328
|
}
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
`);
|
|
1329
|
+
);
|
|
1330
|
+
if (deps.output.mode === "json") {
|
|
1331
|
+
const { planAbsPaths: _planAbsPaths, ...publicResponse } = response;
|
|
1332
|
+
printResult(publicResponse, "json");
|
|
1933
1333
|
}
|
|
1934
1334
|
});
|
|
1935
1335
|
}
|
|
1936
1336
|
|
|
1937
|
-
// src/commands/
|
|
1938
|
-
import
|
|
1939
|
-
import {
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
if (
|
|
1957
|
-
|
|
1958
|
-
}
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
printResult({ bound: false, projectBindingPath: null }, mode2);
|
|
1969
|
-
} else {
|
|
1970
|
-
process.stdout.write("No nolto.json binding found in this directory tree.\n");
|
|
1971
|
-
}
|
|
1972
|
-
return;
|
|
1973
|
-
}
|
|
1974
|
-
const binding = await loadRepoBinding(projectBindingPath).catch((err) => {
|
|
1975
|
-
if (err instanceof CliError) throw err;
|
|
1976
|
-
throw new CliError(`Cannot read binding: ${String(err)}`, 2);
|
|
1977
|
-
});
|
|
1978
|
-
if (mode2 === "json") {
|
|
1979
|
-
printResult({
|
|
1980
|
-
bound: binding != null,
|
|
1981
|
-
projectId: binding?.projectId ?? null,
|
|
1982
|
-
projectBindingPath,
|
|
1983
|
-
source: deps.settings.source.project === "repo" ? "repo" : "file"
|
|
1984
|
-
}, mode2);
|
|
1985
|
-
} else {
|
|
1986
|
-
if (binding == null) {
|
|
1987
|
-
process.stdout.write(`Binding file found at ${projectBindingPath} but could not be read.
|
|
1988
|
-
`);
|
|
1989
|
-
} else {
|
|
1990
|
-
process.stdout.write(`Binding file : ${projectBindingPath}
|
|
1991
|
-
`);
|
|
1992
|
-
process.stdout.write(`projectId : ${binding.projectId}
|
|
1993
|
-
`);
|
|
1994
|
-
const active = deps.settings.source.project === "repo" ? "repo (active)" : "repo (not active \u2014 overridden)";
|
|
1995
|
-
process.stdout.write(`source : ${active}
|
|
1996
|
-
`);
|
|
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;
|
|
1997
1368
|
}
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
const parsed = JSON.parse(raw);
|
|
2006
|
-
if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
2007
|
-
throw new CliError(
|
|
2008
|
-
`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.`,
|
|
2009
|
-
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)}`
|
|
2010
1376
|
);
|
|
1377
|
+
} finally {
|
|
1378
|
+
this.running = false;
|
|
1379
|
+
if (this.pending) {
|
|
1380
|
+
this.pending = false;
|
|
1381
|
+
void this.flush();
|
|
1382
|
+
}
|
|
2011
1383
|
}
|
|
2012
|
-
existing = parsed;
|
|
2013
|
-
} catch (err) {
|
|
2014
|
-
if (err instanceof CliError) throw err;
|
|
2015
|
-
throw new CliError(`Cannot read ${projectBindingPath}: ${String(err)}`, 2);
|
|
2016
|
-
}
|
|
2017
|
-
const { projectId: _removed, ...rest } = existing;
|
|
2018
|
-
void _removed;
|
|
2019
|
-
await writeFile4(projectBindingPath, JSON.stringify(rest, null, 2) + "\n", { mode: 420 });
|
|
2020
|
-
await chmod(projectBindingPath, 420);
|
|
2021
|
-
if (mode2 === "json") {
|
|
2022
|
-
printResult({ unlinked: true, projectBindingPath }, mode2);
|
|
2023
|
-
} else {
|
|
2024
|
-
process.stdout.write(`Removed projectId from ${projectBindingPath}.
|
|
2025
|
-
`);
|
|
2026
1384
|
}
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
);
|
|
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;
|
|
2034
1393
|
}
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
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"
|
|
2041
1430
|
);
|
|
1431
|
+
return;
|
|
2042
1432
|
}
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
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) };
|
|
2053
1455
|
}
|
|
2054
|
-
}
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
}
|
|
2059
|
-
}
|
|
2060
|
-
await writeRepoBinding(root, projectId);
|
|
2061
|
-
const writtenPath = path6.join(root, "nolto.json");
|
|
2062
|
-
if (mode2 === "json") {
|
|
2063
|
-
printResult({ linked: true, projectId, projectBindingPath: writtenPath }, mode2);
|
|
2064
|
-
} else {
|
|
2065
|
-
process.stdout.write(
|
|
2066
|
-
`Linked this repo to project ${projectId} (wrote ${writtenPath}).
|
|
2067
|
-
Commit nolto.json to share the binding with your team.
|
|
2068
|
-
`
|
|
2069
|
-
);
|
|
2070
|
-
}
|
|
1456
|
+
},
|
|
1457
|
+
log: (line) => process.stdout.write(line + "\n"),
|
|
1458
|
+
warn: (line) => process.stderr.write("Warning: " + line + "\n")
|
|
1459
|
+
});
|
|
2071
1460
|
}
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
const projectBindingPath = deps.projectBindingPath ?? null;
|
|
2079
|
-
const mode2 = output.mode;
|
|
2080
|
-
if (cmd.opts()["show"]) {
|
|
2081
|
-
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();
|
|
2082
1467
|
return;
|
|
2083
1468
|
}
|
|
2084
|
-
if (
|
|
2085
|
-
|
|
2086
|
-
throw new CliError("No nolto.json found in this directory tree. Nothing to unlink.", 2);
|
|
2087
|
-
}
|
|
2088
|
-
await handleUnlink(projectBindingPath, mode2);
|
|
2089
|
-
return;
|
|
1469
|
+
if (deps.settings.token == null) {
|
|
1470
|
+
throw new CliError("Not authenticated. Run `nolto login` or set NOLTO_TOKEN.", 3);
|
|
2090
1471
|
}
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
2
|
|
2095
|
-
);
|
|
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);
|
|
2096
1475
|
}
|
|
2097
|
-
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
|
+
});
|
|
2098
1528
|
});
|
|
2099
1529
|
}
|
|
2100
1530
|
|
|
@@ -2105,26 +1535,119 @@ function stripCommanderErrorPrefix(msg) {
|
|
|
2105
1535
|
function buildProgram(deps) {
|
|
2106
1536
|
const writeErr = (_msg) => {
|
|
2107
1537
|
};
|
|
2108
|
-
const program = new Command("nolto").version(deps.version, "-V, --version", "Print version number").exitOverride().configureOutput({ writeErr }).description("Nolto CLI \u2014
|
|
2109
|
-
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");
|
|
2110
1539
|
register2(program, deps);
|
|
2111
1540
|
register3(program, deps);
|
|
2112
1541
|
register4(program, deps);
|
|
1542
|
+
register(program, deps);
|
|
2113
1543
|
register5(program, deps);
|
|
2114
1544
|
register6(program, deps);
|
|
2115
|
-
register7(program, deps);
|
|
2116
|
-
registerQueue(program, deps);
|
|
2117
|
-
register8(program, deps);
|
|
2118
|
-
register9(program, deps);
|
|
2119
1545
|
return program;
|
|
2120
1546
|
}
|
|
2121
1547
|
|
|
1548
|
+
// src/update-notifier.ts
|
|
1549
|
+
import { readFile as readFile6, writeFile as writeFile5, mkdir as mkdir5 } from "fs/promises";
|
|
1550
|
+
import https from "https";
|
|
1551
|
+
import path11 from "path";
|
|
1552
|
+
var PACKAGE = "@nolto/cli";
|
|
1553
|
+
var CACHE_FILE = "update-check.json";
|
|
1554
|
+
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
1555
|
+
var REQUEST_TIMEOUT_MS = 2e3;
|
|
1556
|
+
function isNewerVersion(latest, current) {
|
|
1557
|
+
const parts = (v) => v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
1558
|
+
const a = parts(latest);
|
|
1559
|
+
const b = parts(current);
|
|
1560
|
+
for (let i = 0; i < 3; i++) {
|
|
1561
|
+
const x = a[i] ?? 0;
|
|
1562
|
+
const y = b[i] ?? 0;
|
|
1563
|
+
if (x !== y) return x > y;
|
|
1564
|
+
}
|
|
1565
|
+
return false;
|
|
1566
|
+
}
|
|
1567
|
+
function formatUpdateNotice(latest, current) {
|
|
1568
|
+
return `
|
|
1569
|
+
Update available: ${current} \u2192 ${latest} \xB7 npm i -g @nolto/cli@latest
|
|
1570
|
+
`;
|
|
1571
|
+
}
|
|
1572
|
+
function isDisabled(env) {
|
|
1573
|
+
return env["NO_UPDATE_NOTIFIER"] === "1" || env["NODE_ENV"] === "test" || Boolean(env["CI"]);
|
|
1574
|
+
}
|
|
1575
|
+
function fetchLatestFromRegistry() {
|
|
1576
|
+
return new Promise((resolve) => {
|
|
1577
|
+
const req = https.get(
|
|
1578
|
+
`https://registry.npmjs.org/${PACKAGE}/latest`,
|
|
1579
|
+
{ timeout: REQUEST_TIMEOUT_MS, headers: { accept: "application/json" } },
|
|
1580
|
+
(res) => {
|
|
1581
|
+
if (res.statusCode !== 200) {
|
|
1582
|
+
res.resume();
|
|
1583
|
+
resolve(null);
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
let data = "";
|
|
1587
|
+
res.on("data", (chunk) => data += chunk);
|
|
1588
|
+
res.on("end", () => {
|
|
1589
|
+
try {
|
|
1590
|
+
const v = JSON.parse(data).version;
|
|
1591
|
+
resolve(typeof v === "string" ? v : null);
|
|
1592
|
+
} catch {
|
|
1593
|
+
resolve(null);
|
|
1594
|
+
}
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
);
|
|
1598
|
+
req.on("socket", (s) => s.unref());
|
|
1599
|
+
req.on("timeout", () => {
|
|
1600
|
+
req.destroy();
|
|
1601
|
+
resolve(null);
|
|
1602
|
+
});
|
|
1603
|
+
req.on("error", () => resolve(null));
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
async function refreshCache(cachePath, now, fetchLatest) {
|
|
1607
|
+
const latest = await fetchLatest();
|
|
1608
|
+
if (!latest) return;
|
|
1609
|
+
await mkdir5(path11.dirname(cachePath), { recursive: true }).catch(() => void 0);
|
|
1610
|
+
const payload = { checkedAt: now, latest };
|
|
1611
|
+
await writeFile5(cachePath, JSON.stringify(payload), { mode: 384 }).catch(() => void 0);
|
|
1612
|
+
}
|
|
1613
|
+
async function checkForUpdate(opts) {
|
|
1614
|
+
if (isDisabled(opts.env)) return null;
|
|
1615
|
+
const cachePath = path11.join(opts.configDir, CACHE_FILE);
|
|
1616
|
+
let cache = {};
|
|
1617
|
+
try {
|
|
1618
|
+
cache = JSON.parse(await readFile6(cachePath, "utf8"));
|
|
1619
|
+
} catch {
|
|
1620
|
+
}
|
|
1621
|
+
if (typeof cache.checkedAt !== "number" || opts.now - cache.checkedAt > CACHE_TTL_MS) {
|
|
1622
|
+
void refreshCache(cachePath, opts.now, opts.fetchLatest ?? fetchLatestFromRegistry).catch(
|
|
1623
|
+
() => void 0
|
|
1624
|
+
);
|
|
1625
|
+
}
|
|
1626
|
+
if (typeof cache.latest === "string" && isNewerVersion(cache.latest, opts.current)) {
|
|
1627
|
+
return cache.latest;
|
|
1628
|
+
}
|
|
1629
|
+
return null;
|
|
1630
|
+
}
|
|
1631
|
+
async function notifyUpdate(opts) {
|
|
1632
|
+
try {
|
|
1633
|
+
if (opts.isJson || !process.stderr.isTTY) return;
|
|
1634
|
+
const latest = await checkForUpdate({
|
|
1635
|
+
current: opts.current,
|
|
1636
|
+
configDir: getConfigDir(opts.env),
|
|
1637
|
+
env: opts.env,
|
|
1638
|
+
now: opts.now
|
|
1639
|
+
});
|
|
1640
|
+
if (latest) process.stderr.write(formatUpdateNotice(latest, opts.current));
|
|
1641
|
+
} catch {
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
|
|
2122
1645
|
// src/index.ts
|
|
2123
|
-
var
|
|
1646
|
+
var __dirname3 = path12.dirname(fileURLToPath3(import.meta.url));
|
|
2124
1647
|
var require2 = createRequire2(import.meta.url);
|
|
2125
1648
|
function getVersion() {
|
|
2126
1649
|
try {
|
|
2127
|
-
const pkgPath =
|
|
1650
|
+
const pkgPath = path12.resolve(__dirname3, "../package.json");
|
|
2128
1651
|
const pkg = require2(pkgPath);
|
|
2129
1652
|
return pkg.version ?? "0.0.0";
|
|
2130
1653
|
} catch {
|
|
@@ -2173,16 +1696,21 @@ async function main() {
|
|
|
2173
1696
|
repoBinding
|
|
2174
1697
|
});
|
|
2175
1698
|
const version = getVersion();
|
|
2176
|
-
const
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
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
|
+
});
|
|
2185
1712
|
await program.parseAsync(process.argv);
|
|
1713
|
+
await notifyUpdate({ current: version, env: process.env, isJson: mode === "json", now: Date.now() });
|
|
2186
1714
|
}
|
|
2187
1715
|
function extractFlag(argv, name) {
|
|
2188
1716
|
const idx = argv.indexOf(name);
|