@popoverai/dotrequirements 0.26.1 → 0.26.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/codebase-to-spec/present.js +4 -5
- package/dist/codebase-to-spec/validate.js +3 -2
- package/dist/commands/acceptance-test.js +4 -2
- package/dist/commands/ai-setup.js +97 -59
- package/dist/commands/get.js +6 -2
- package/dist/commands/init.js +7 -5
- package/dist/commands/link-resolution.d.ts +3 -1
- package/dist/commands/link-resolution.js +4 -2
- package/dist/commands/pull.js +36 -3
- package/dist/commands/push.js +54 -16
- package/dist/commands/report.js +18 -3
- package/dist/commands/review-test.js +16 -8
- package/dist/commands/tests-for.js +13 -13
- package/dist/commands/validate.js +14 -14
- package/dist/harness/cache.d.ts +19 -3
- package/dist/harness/cache.js +38 -12
- package/dist/harness/finalize.js +33 -1
- package/dist/harness/index.js +16 -9
- package/dist/harness/requirementsLoader.js +12 -0
- package/dist/harness/tracking.d.ts +17 -2
- package/dist/harness/tracking.js +83 -9
- package/dist/mcp/handlers/authoring.js +13 -4
- package/dist/mcp/handlers/get.js +7 -3
- package/dist/mcp/handlers/push.js +59 -13
- package/dist/mcp/handlers/review.d.ts +1 -0
- package/dist/mcp/handlers/review.js +58 -15
- package/dist/mcp/handlers/test-mapping.js +47 -12
- package/dist/mcp/handlers/types.d.ts +14 -0
- package/dist/mcp/handlers/types.js +27 -0
- package/dist/mcp/index.js +4 -0
- package/dist/push/core.d.ts +50 -0
- package/dist/push/core.js +149 -11
- package/dist/push/index.d.ts +1 -1
- package/dist/push/index.js +1 -1
- package/dist/requirements/cloud-coverage.d.ts +12 -2
- package/dist/requirements/cloud-coverage.js +30 -3
- package/dist/requirements/grep.d.ts +7 -2
- package/dist/requirements/grep.js +75 -47
- package/dist/schema/builder.d.ts +1 -1
- package/dist/schema/builder.js +13 -0
- package/dist/schema/conversions.d.ts +7 -2
- package/dist/schema/conversions.js +13 -4
- package/dist/schema/parser-core.d.ts +28 -0
- package/dist/schema/parser-core.js +80 -9
- package/dist/schema/parser.d.ts +8 -26
- package/dist/schema/parser.js +23 -251
- package/dist/schema/resolver.js +18 -8
- package/dist/utils/env.js +17 -1
- package/dist/utils/oauth-flow.js +8 -0
- package/dist/utils/project-settings.d.ts +4 -0
- package/dist/utils/project-settings.js +14 -1
- package/package.json +1 -1
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { dirname, join } from "node:path";
|
|
18
18
|
import { parseRequirementsFile } from "../schema/parser.js";
|
|
19
|
+
import { stripFrontmatterBlock } from "../schema/parser-core.js";
|
|
19
20
|
import { generateRunMarker } from "../schema/run-marker.js";
|
|
20
21
|
import { sanitizeAreaName } from "./area-name.js";
|
|
21
22
|
import { promptOverwriteChoice } from "./interactive.js";
|
|
@@ -187,11 +188,9 @@ async function resolveOverwriteAction(policy, path) {
|
|
|
187
188
|
*/
|
|
188
189
|
function mergeContent(existingPath, newContent) {
|
|
189
190
|
const existing = readFileSync(existingPath, "utf-8").trimEnd();
|
|
190
|
-
// Strip frontmatter from newContent (it would conflict with existing
|
|
191
|
-
|
|
192
|
-
const body =
|
|
193
|
-
? newContent.slice(fmMatch[0].length).trimStart()
|
|
194
|
-
: newContent;
|
|
191
|
+
// Strip frontmatter from newContent (it would conflict with existing
|
|
192
|
+
// frontmatter) via the shared CRLF-normalizing helper.
|
|
193
|
+
const body = stripFrontmatterBlock(newContent).trimStart();
|
|
195
194
|
return [
|
|
196
195
|
existing,
|
|
197
196
|
"",
|
|
@@ -87,8 +87,9 @@ function describeValidationError(err) {
|
|
|
87
87
|
const parts = [err.message];
|
|
88
88
|
let cursor = err.cause;
|
|
89
89
|
while (cursor instanceof Error) {
|
|
90
|
-
// Don't repeat the same message twice if the wrapper just rethrew
|
|
91
|
-
|
|
90
|
+
// Don't repeat the same message twice if the wrapper just rethrew
|
|
91
|
+
// (or already embedded the inner message in its own).
|
|
92
|
+
if (cursor.message && !parts[parts.length - 1].includes(cursor.message)) {
|
|
92
93
|
parts.push(cursor.message);
|
|
93
94
|
}
|
|
94
95
|
cursor = cursor.cause;
|
|
@@ -127,8 +127,10 @@ export async function acceptanceTestCommand(requirementKey, url, options) {
|
|
|
127
127
|
else {
|
|
128
128
|
displayResults(requirementTree, results);
|
|
129
129
|
}
|
|
130
|
-
// 11. Exit with appropriate code
|
|
131
|
-
|
|
130
|
+
// 11. Exit with appropriate code — computed over the requirement tree so
|
|
131
|
+
// a requirement with a missing result counts as not passing, agreeing
|
|
132
|
+
// with the displayed counts (ACCEPTANCE-1.0/.1/.2)
|
|
133
|
+
const failedCount = requirementTree.filter((_, i) => results[i]?.status !== "passed").length;
|
|
132
134
|
if (failedCount > 0) {
|
|
133
135
|
process.exit(1);
|
|
134
136
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { execSync } from "node:child_process";
|
|
1
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import prompts from "prompts";
|
|
6
6
|
import { brand } from "../utils/brand.js";
|
|
7
7
|
import { appendOrUpdateSection, buildNoGitRepoMessage, findDotrequirementsSection, findGitRoot, getContextFileName, } from "../utils/context-file.js";
|
|
8
|
+
import { findProjectRoot, readProjectSettings, } from "../utils/project-settings.js";
|
|
8
9
|
import { loadTemplate } from "../utils/templates.js";
|
|
9
10
|
/**
|
|
10
11
|
* Load the context file section template
|
|
@@ -12,6 +13,56 @@ import { loadTemplate } from "../utils/templates.js";
|
|
|
12
13
|
function loadContextFileSection() {
|
|
13
14
|
return loadTemplate("context-file-section.md");
|
|
14
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Read an assistant's existing MCP config file (AISETUP-2).
|
|
18
|
+
* Returns {} when the file is absent or empty. Returns null when the file
|
|
19
|
+
* exists but is unusable — unreadable (AISETUP-2.2), invalid JSON
|
|
20
|
+
* (AISETUP-2.1), or a non-object root (AISETUP-2.3). In every null case the
|
|
21
|
+
* path has already been shown to the user and the caller must stop without
|
|
22
|
+
* writing; rewriting would destroy every other configured server.
|
|
23
|
+
*/
|
|
24
|
+
function readExistingMcpConfig(configPath) {
|
|
25
|
+
const stopWithoutWriting = (reason) => {
|
|
26
|
+
console.error(`❌ ${reason}`);
|
|
27
|
+
console.log(" Nothing was changed. Fix (or remove) the file, then re-run ai-setup:");
|
|
28
|
+
console.log(` ${configPath}\n`);
|
|
29
|
+
return null;
|
|
30
|
+
};
|
|
31
|
+
if (!existsSync(configPath)) {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
let existingContent;
|
|
35
|
+
try {
|
|
36
|
+
existingContent = readFileSync(configPath, "utf-8");
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return stopWithoutWriting("Existing config file could not be read.");
|
|
40
|
+
}
|
|
41
|
+
if (!existingContent.trim()) {
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
let parsed;
|
|
45
|
+
try {
|
|
46
|
+
parsed = JSON.parse(existingContent);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return stopWithoutWriting("Existing config file contains invalid JSON.");
|
|
50
|
+
}
|
|
51
|
+
// AISETUP-2.3: a non-object root (e.g. a top-level array) would swallow the
|
|
52
|
+
// mcpServers assignment and serialize back without it — success would be
|
|
53
|
+
// reported while nothing was configured
|
|
54
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
55
|
+
return stopWithoutWriting("Existing config file is not a JSON object.");
|
|
56
|
+
}
|
|
57
|
+
// AISETUP-2.3: same trap one level down — an array-valued mcpServers takes
|
|
58
|
+
// the server entry as an expando property that JSON.stringify drops
|
|
59
|
+
const mcpServers = parsed.mcpServers;
|
|
60
|
+
if (mcpServers != null &&
|
|
61
|
+
(typeof mcpServers !== "object" || Array.isArray(mcpServers))) {
|
|
62
|
+
return stopWithoutWriting('Existing config file has an "mcpServers" field that is not a JSON object.');
|
|
63
|
+
}
|
|
64
|
+
return parsed;
|
|
65
|
+
}
|
|
15
66
|
/**
|
|
16
67
|
* Install workflow guidance to the appropriate context file for a platform.
|
|
17
68
|
* With `yes` (the non-interactive --assistant path, AISETUP-1.0), both
|
|
@@ -166,10 +217,18 @@ async function dispatchSetup(client, yes = false) {
|
|
|
166
217
|
}
|
|
167
218
|
async function setupClaudeCode(yes = false) {
|
|
168
219
|
console.log("\n📦 Configuring MCP for Claude Code...\n");
|
|
169
|
-
|
|
220
|
+
// Register via an args array so no shell quoting is involved: cmd.exe does
|
|
221
|
+
// not honor single quotes, so a shell-quoted JSON string breaks on Windows.
|
|
222
|
+
const mcpServerJson = JSON.stringify({
|
|
223
|
+
type: "stdio",
|
|
224
|
+
command: "npx",
|
|
225
|
+
args: ["-y", "@popoverai/dotrequirements", "mcp"],
|
|
226
|
+
});
|
|
227
|
+
// Shell-safe on every platform (no quoting needed anywhere)
|
|
228
|
+
const manualCommand = "claude mcp add dotrequirements -- npx -y @popoverai/dotrequirements mcp";
|
|
170
229
|
let mcpConfigured = false;
|
|
171
230
|
try {
|
|
172
|
-
|
|
231
|
+
execFileSync("claude", ["mcp", "add-json", "dotrequirements", mcpServerJson], { stdio: "pipe" });
|
|
173
232
|
console.log("✅ MCP configured successfully for Claude Code!");
|
|
174
233
|
mcpConfigured = true;
|
|
175
234
|
}
|
|
@@ -185,7 +244,7 @@ async function setupClaudeCode(yes = false) {
|
|
|
185
244
|
else {
|
|
186
245
|
console.error("❌ Failed to configure MCP automatically.");
|
|
187
246
|
console.log("\nYou can manually run:");
|
|
188
|
-
console.log(` ${
|
|
247
|
+
console.log(` ${manualCommand}`);
|
|
189
248
|
if (stderr) {
|
|
190
249
|
console.log(`\nError: ${stderr}`);
|
|
191
250
|
}
|
|
@@ -224,24 +283,17 @@ async function setupClaudeDesktop() {
|
|
|
224
283
|
REQUIREMENTS_DIR: process.cwd(),
|
|
225
284
|
},
|
|
226
285
|
};
|
|
286
|
+
// AISETUP-2.1–2.3: bail out before touching anything if the config is unusable
|
|
287
|
+
const config = readExistingMcpConfig(configPath);
|
|
288
|
+
if (config === null) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
227
291
|
try {
|
|
228
292
|
if (!existsSync(configDir)) {
|
|
229
293
|
console.log("Creating Claude Desktop config directory...");
|
|
230
294
|
mkdirSync(configDir, { recursive: true });
|
|
231
295
|
}
|
|
232
|
-
|
|
233
|
-
if (existsSync(configPath)) {
|
|
234
|
-
try {
|
|
235
|
-
const existingContent = readFileSync(configPath, "utf-8");
|
|
236
|
-
if (existingContent.trim()) {
|
|
237
|
-
config = JSON.parse(existingContent);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
catch {
|
|
241
|
-
console.log("⚠️ Existing config file is invalid, creating fresh config...");
|
|
242
|
-
config = {};
|
|
243
|
-
}
|
|
244
|
-
}
|
|
296
|
+
// AISETUP-2.0: other configured servers survive; only ours is (re)set
|
|
245
297
|
if (!config.mcpServers) {
|
|
246
298
|
config.mcpServers = {};
|
|
247
299
|
}
|
|
@@ -271,30 +323,26 @@ async function setupClaudeDesktop() {
|
|
|
271
323
|
}
|
|
272
324
|
async function setupCursor(yes = false) {
|
|
273
325
|
console.log("\n📦 Configuring MCP for Cursor (project-specific)...\n");
|
|
274
|
-
|
|
275
|
-
|
|
326
|
+
// Cursor loads .cursor/mcp.json from the project (git) root — match the
|
|
327
|
+
// context-file placement, falling back to cwd outside a repository
|
|
328
|
+
const projectDir = (await findGitRoot()) ?? process.cwd();
|
|
329
|
+
const configDir = join(projectDir, ".cursor");
|
|
330
|
+
const configPath = join(configDir, "mcp.json");
|
|
276
331
|
const mcpServerConfig = {
|
|
277
332
|
command: "npx",
|
|
278
333
|
args: ["-y", "@popoverai/dotrequirements", "mcp"],
|
|
279
334
|
};
|
|
335
|
+
// AISETUP-2.1–2.3: bail out before touching anything if the config is unusable
|
|
336
|
+
const config = readExistingMcpConfig(configPath);
|
|
337
|
+
if (config === null) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
280
340
|
try {
|
|
281
341
|
if (!existsSync(configDir)) {
|
|
282
342
|
console.log("Creating Cursor config directory...");
|
|
283
343
|
mkdirSync(configDir, { recursive: true });
|
|
284
344
|
}
|
|
285
|
-
|
|
286
|
-
if (existsSync(configPath)) {
|
|
287
|
-
try {
|
|
288
|
-
const existingContent = readFileSync(configPath, "utf-8");
|
|
289
|
-
if (existingContent.trim()) {
|
|
290
|
-
config = JSON.parse(existingContent);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
catch {
|
|
294
|
-
console.log("⚠️ Existing config file is invalid, creating fresh config...");
|
|
295
|
-
config = {};
|
|
296
|
-
}
|
|
297
|
-
}
|
|
345
|
+
// AISETUP-2.0: other configured servers survive; only ours is (re)set
|
|
298
346
|
if (!config.mcpServers) {
|
|
299
347
|
config.mcpServers = {};
|
|
300
348
|
}
|
|
@@ -324,45 +372,33 @@ async function setupAntigravity(yes = false) {
|
|
|
324
372
|
console.log("\n📦 Configuring MCP for Google Antigravity...\n");
|
|
325
373
|
const configPath = join(homedir(), ".gemini", "antigravity", "mcp_config.json");
|
|
326
374
|
const configDir = join(homedir(), ".gemini", "antigravity");
|
|
327
|
-
//
|
|
328
|
-
|
|
375
|
+
// AISETUP-3.0: detect the project the way the CLI defines projects —
|
|
376
|
+
// .requirements/project-settings.json at the project root
|
|
329
377
|
let projectId = null;
|
|
330
378
|
let projectPath = null;
|
|
331
|
-
|
|
379
|
+
const projectRoot = findProjectRoot();
|
|
380
|
+
if (projectRoot) {
|
|
332
381
|
try {
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
if (trimmed.startsWith("DOTREQUIREMENTS_PROJECT_ID=")) {
|
|
338
|
-
projectId = trimmed.split("=")[1]?.trim().replace(/['"]/g, "");
|
|
339
|
-
projectPath = process.cwd();
|
|
340
|
-
break;
|
|
341
|
-
}
|
|
382
|
+
const settings = readProjectSettings(projectRoot);
|
|
383
|
+
if (settings) {
|
|
384
|
+
projectId = settings.projectId;
|
|
385
|
+
projectPath = projectRoot;
|
|
342
386
|
}
|
|
343
387
|
}
|
|
344
388
|
catch {
|
|
345
|
-
//
|
|
389
|
+
// Settings file unreadable/invalid — continue with global setup
|
|
346
390
|
}
|
|
347
391
|
}
|
|
392
|
+
// AISETUP-2.1–2.3: bail out before touching anything if the config is unusable
|
|
393
|
+
const config = readExistingMcpConfig(configPath);
|
|
394
|
+
if (config === null) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
348
397
|
try {
|
|
349
398
|
if (!existsSync(configDir)) {
|
|
350
399
|
console.log("Creating Antigravity config directory...");
|
|
351
400
|
mkdirSync(configDir, { recursive: true });
|
|
352
401
|
}
|
|
353
|
-
let config = {};
|
|
354
|
-
if (existsSync(configPath)) {
|
|
355
|
-
try {
|
|
356
|
-
const existingContent = readFileSync(configPath, "utf-8");
|
|
357
|
-
if (existingContent.trim()) {
|
|
358
|
-
config = JSON.parse(existingContent);
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
catch {
|
|
362
|
-
console.log("⚠️ Existing config file is invalid, creating fresh config...");
|
|
363
|
-
config = {};
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
402
|
if (!config.mcpServers) {
|
|
367
403
|
config.mcpServers = {};
|
|
368
404
|
}
|
|
@@ -385,7 +421,9 @@ async function setupAntigravity(yes = false) {
|
|
|
385
421
|
console.log(` Path: ${projectPath}\n`);
|
|
386
422
|
}
|
|
387
423
|
else {
|
|
388
|
-
|
|
424
|
+
// AISETUP-3.1: outside any initialized project — skip the per-project
|
|
425
|
+
// step and say so
|
|
426
|
+
console.log("ℹ️ No project found (no .requirements/project-settings.json in this directory or its parents)");
|
|
389
427
|
console.log(" Setting up global MCP server only.\n");
|
|
390
428
|
}
|
|
391
429
|
writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
|
package/dist/commands/get.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { glob } from "glob";
|
|
2
|
-
import { formatRequirementTree,
|
|
2
|
+
import { formatRequirementTree, loadAllRequirements, } from "../requirements/index.js";
|
|
3
3
|
import { findFilesWithRequirement, findTestCodeForRequirement, } from "../requirements/testCodeExtractor.js";
|
|
4
4
|
export async function getCommand(id) {
|
|
5
5
|
const workspaceRoot = process.cwd();
|
|
6
6
|
const { flattened } = await loadAllRequirements(workspaceRoot);
|
|
7
|
-
|
|
7
|
+
// Match the node itself plus all of its descendants by id prefix (mirrors
|
|
8
|
+
// the #46 MCP get fix). The shared getRequirementTree only matches on
|
|
9
|
+
// rootId/id equality, which drops grandchildren (e.g. REQ-123.0.0) when a
|
|
10
|
+
// child id (REQ-123.0) is requested.
|
|
11
|
+
const tree = flattened.filter((r) => r.id === id || r.id.startsWith(`${id}.`));
|
|
8
12
|
// CLI-FIND-GET-1.2: missing ID → error + non-zero exit
|
|
9
13
|
if (tree.length === 0) {
|
|
10
14
|
throw new Error(`Requirement "${id}" not found`);
|
package/dist/commands/init.js
CHANGED
|
@@ -147,11 +147,10 @@ async function authenticatedFlow(cwd, options) {
|
|
|
147
147
|
}
|
|
148
148
|
if (projectChoice === "create") {
|
|
149
149
|
await createNewProjectFlow(cwd, client, selectedTeam, options);
|
|
150
|
+
return true;
|
|
150
151
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
return true;
|
|
152
|
+
// INIT-12: a cancelled project selection means init did not complete
|
|
153
|
+
return await connectExistingProjectFlow(cwd, client, selectedTeam);
|
|
155
154
|
}
|
|
156
155
|
/**
|
|
157
156
|
* INIT-18: Prompt user to select a team after OAuth
|
|
@@ -268,13 +267,15 @@ async function createNewProjectFlow(cwd, client, team, options) {
|
|
|
268
267
|
}
|
|
269
268
|
/**
|
|
270
269
|
* INIT-6, INIT-16: Connect to an existing project
|
|
270
|
+
* Returns true if connected successfully, false if the user cancelled
|
|
271
|
+
* (INIT-12: cancellation must not read as a successful init)
|
|
271
272
|
*/
|
|
272
273
|
async function connectExistingProjectFlow(cwd, client, team) {
|
|
273
274
|
// INIT-6.0, INIT-16.0: Select project from team
|
|
274
275
|
const selectedProject = await selectProject(client, team.teamId);
|
|
275
276
|
if (!selectedProject) {
|
|
276
277
|
console.log("Initialization cancelled.");
|
|
277
|
-
return;
|
|
278
|
+
return false;
|
|
278
279
|
}
|
|
279
280
|
// AUTHZ-3: Prompt for secret expiry (paid tier only, if creating new secret)
|
|
280
281
|
const expiryDays = await promptExpiryDays(team.tier);
|
|
@@ -306,6 +307,7 @@ async function connectExistingProjectFlow(cwd, client, team) {
|
|
|
306
307
|
console.log("\nNext steps:");
|
|
307
308
|
console.log(" 1. Review the requirements in .requirements/");
|
|
308
309
|
console.log(" 2. Add the test harness to your test setup (see README)");
|
|
310
|
+
return true;
|
|
309
311
|
}
|
|
310
312
|
/**
|
|
311
313
|
* MEMBERSHIP-18: Invite flow - join a team using an invite token
|
|
@@ -29,7 +29,9 @@ export interface LinkFlags {
|
|
|
29
29
|
json?: boolean;
|
|
30
30
|
}
|
|
31
31
|
/**
|
|
32
|
-
* LINK-12.3: any deviation flag implies non-interactive mode.
|
|
32
|
+
* LINK-12.3: any deviation flag implies non-interactive mode. The JSON
|
|
33
|
+
* output flag does too — machine-readable output only exists on the
|
|
34
|
+
* non-interactive path, so `link --json` alone must not run interactively.
|
|
33
35
|
*/
|
|
34
36
|
export declare function isNonInteractive(flags: LinkFlags): boolean;
|
|
35
37
|
export declare function hasCapacity(team: TeamOption): boolean;
|
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
* assistant needs to ask the user and retry with a deviation flag.
|
|
7
7
|
*/
|
|
8
8
|
/**
|
|
9
|
-
* LINK-12.3: any deviation flag implies non-interactive mode.
|
|
9
|
+
* LINK-12.3: any deviation flag implies non-interactive mode. The JSON
|
|
10
|
+
* output flag does too — machine-readable output only exists on the
|
|
11
|
+
* non-interactive path, so `link --json` alone must not run interactively.
|
|
10
12
|
*/
|
|
11
13
|
export function isNonInteractive(flags) {
|
|
12
|
-
return Boolean(flags.yes || flags.team || flags.connect || flags.create);
|
|
14
|
+
return Boolean(flags.yes || flags.team || flags.connect || flags.create || flags.json);
|
|
13
15
|
}
|
|
14
16
|
export function hasCapacity(team) {
|
|
15
17
|
return team.projectLimit === -1 || team.projectCount < team.projectLimit;
|
package/dist/commands/pull.js
CHANGED
|
@@ -5,6 +5,7 @@ import { getConvexUrl } from "../config.js";
|
|
|
5
5
|
import { api } from "../convex.js";
|
|
6
6
|
import { findRequirementsFiles } from "../requirements/index.js";
|
|
7
7
|
import { buildRequirementsFile } from "../schema/index.js";
|
|
8
|
+
import { extractFrontmatterBlock } from "../schema/parser-core.js";
|
|
8
9
|
import { brand } from "../utils/brand.js";
|
|
9
10
|
import { getProjectCredentials } from "../utils/project-settings.js";
|
|
10
11
|
export async function pullCommand(options) {
|
|
@@ -94,12 +95,33 @@ export async function pullCommand(options) {
|
|
|
94
95
|
}
|
|
95
96
|
// Build index of existing files by document ID (search entire workspace)
|
|
96
97
|
const existingFilesByDocId = await buildDocumentIdIndex(process.cwd());
|
|
98
|
+
// SYNC-WEB-CREATE-2.0: track paths claimed during this pull so colliding
|
|
99
|
+
// titles don't silently overwrite each other within one operation
|
|
100
|
+
const usedPaths = new Set(existingFilesByDocId.values());
|
|
97
101
|
// Write each document as a Markdown file
|
|
98
102
|
for (const doc of documents) {
|
|
99
103
|
// Check if an existing file has this document ID
|
|
100
104
|
const existingFilePath = existingFilesByDocId.get(doc.documentId);
|
|
101
|
-
|
|
102
|
-
|
|
105
|
+
let filePath;
|
|
106
|
+
if (existingFilePath) {
|
|
107
|
+
filePath = existingFilePath;
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
// SYNC-WEB-CREATE-2.1: an all-symbols title sanitizes to nothing —
|
|
111
|
+
// fall back to the document ID rather than a hidden ".requirements.md"
|
|
112
|
+
const baseName = sanitizeFileName(doc.title) || doc.documentId;
|
|
113
|
+
let candidate = path.join(requirementsDir, `${baseName}.requirements.md`);
|
|
114
|
+
// SYNC-WEB-CREATE-2.0: disambiguate later collisions with a numeric
|
|
115
|
+
// suffix; also avoid clobbering an on-disk file that belongs to a
|
|
116
|
+
// different (or no) document
|
|
117
|
+
let suffix = 2;
|
|
118
|
+
while (usedPaths.has(candidate) || fs.existsSync(candidate)) {
|
|
119
|
+
candidate = path.join(requirementsDir, `${baseName}-${suffix}.requirements.md`);
|
|
120
|
+
suffix++;
|
|
121
|
+
}
|
|
122
|
+
filePath = candidate;
|
|
123
|
+
}
|
|
124
|
+
usedPaths.add(filePath);
|
|
103
125
|
const fileName = path.basename(filePath);
|
|
104
126
|
// IMPORT-1: carry the CTS run marker forward from the existing local
|
|
105
127
|
// file — pull rebuilds frontmatter from cloud data, and silently dropping
|
|
@@ -168,10 +190,21 @@ async function buildDocumentIdIndex(workspaceRoot) {
|
|
|
168
190
|
/**
|
|
169
191
|
* Extract document.id from a requirements file's frontmatter.
|
|
170
192
|
* Returns undefined if the file can't be read or doesn't have a document ID.
|
|
193
|
+
* SYNC-DISCOVERY-3: only the leading YAML frontmatter block is consulted —
|
|
194
|
+
* a document id quoted in body prose or a fenced example must never mark
|
|
195
|
+
* the file as owning that document.
|
|
171
196
|
*/
|
|
172
197
|
function extractDocumentIdFromFile(filePath) {
|
|
173
198
|
try {
|
|
174
|
-
const
|
|
199
|
+
const fileContent = fs.readFileSync(filePath, "utf-8");
|
|
200
|
+
// Isolate the leading ---...--- frontmatter block; no frontmatter means
|
|
201
|
+
// the file is unlinked to any cloud document (SYNC-DISCOVERY-3.2).
|
|
202
|
+
// Shared helper normalizes CRLF (SYNC-FORMAT-1) so a Windows-saved file
|
|
203
|
+
// keeps matching its document and is updated in place (SYNC-DISCOVERY-2.1).
|
|
204
|
+
const content = extractFrontmatterBlock(fileContent);
|
|
205
|
+
if (content === undefined) {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
175
208
|
// Match document.id in YAML frontmatter - handles both inline and nested formats
|
|
176
209
|
// Inline: document: { id: "abc123", ... }
|
|
177
210
|
// Nested (id: can appear at any position within the indented document block):
|
package/dist/commands/push.js
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import * as readline from "node:readline";
|
|
4
4
|
import { getConvexUrl } from "../config.js";
|
|
5
|
-
import { dryRunPush, executePush,
|
|
5
|
+
import { dryRunPush, executePush, parseFilesForPushIndividually, } from "../push/index.js";
|
|
6
6
|
import { findRequirementsFiles } from "../requirements/index.js";
|
|
7
7
|
import { brand } from "../utils/brand.js";
|
|
8
8
|
import { getProjectCredentials } from "../utils/project-settings.js";
|
|
@@ -30,20 +30,30 @@ export async function pushCommand(file, options) {
|
|
|
30
30
|
return;
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
|
-
// Parse
|
|
33
|
+
// Parse local files. SYNC-FAIL-4: one invalid file is skipped while the
|
|
34
|
+
// rest of the batch still pushes — same helper as the MCP push handler.
|
|
34
35
|
console.log("Parsing local requirements...");
|
|
35
|
-
const { parsedFiles, totalRequirements } =
|
|
36
|
+
const { parsedFiles, totalRequirements, parseFailures } = parseFilesForPushIndividually(filesToPush);
|
|
36
37
|
// Log file parsing progress
|
|
37
38
|
for (const file of parsedFiles) {
|
|
38
39
|
const fileName = path.basename(file.filePath);
|
|
39
40
|
const doc = file.metadata.document;
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
console.log(` Reading ${fileName}...`);
|
|
42
|
+
// DOC-HEADER-11.4: parseFilesForPush mutates the metadata when it infers
|
|
43
|
+
// the prefix, so the flag it returns is the only record of the inference
|
|
44
|
+
if (doc?.defaultPrefix && file.inferredDefaultPrefix) {
|
|
42
45
|
console.log(` Inferred defaultPrefix "${doc.defaultPrefix}" from first requirement`);
|
|
43
46
|
}
|
|
44
|
-
|
|
45
|
-
|
|
47
|
+
}
|
|
48
|
+
// SYNC-FAIL-4.1: every file in the push failed to parse — report each
|
|
49
|
+
// skipped file, then fail honestly instead of dry-running nothing
|
|
50
|
+
if (parsedFiles.length === 0) {
|
|
51
|
+
for (const failure of parseFailures) {
|
|
52
|
+
console.log(` ✗ Skipped ${path.basename(failure.filePath)}: ${failure.error}`);
|
|
46
53
|
}
|
|
54
|
+
console.log("\nNo valid documents to push.");
|
|
55
|
+
process.exitCode = 1;
|
|
56
|
+
return;
|
|
47
57
|
}
|
|
48
58
|
console.log(`\nFound ${totalRequirements} requirement(s) in ${parsedFiles.length} document(s).\n`);
|
|
49
59
|
// Build credentials
|
|
@@ -56,13 +66,19 @@ export async function pushCommand(file, options) {
|
|
|
56
66
|
console.log("Validating documents...");
|
|
57
67
|
const dryRunResult = await dryRunPush(parsedFiles, credentials);
|
|
58
68
|
// Display unified summary
|
|
59
|
-
displayDryRunSummary(dryRunResult);
|
|
69
|
+
displayDryRunSummary(dryRunResult, parseFailures);
|
|
60
70
|
// Check if there's anything to push
|
|
61
71
|
const pushableCount = dryRunResult.updates.length +
|
|
62
72
|
dryRunResult.creates.length +
|
|
63
73
|
dryRunResult.notFound.length;
|
|
64
74
|
if (pushableCount === 0) {
|
|
65
75
|
console.log("No valid documents to push.");
|
|
76
|
+
// SYNC-FAIL-4.1: every file in the push was invalid — whether it failed
|
|
77
|
+
// local parse or dry-run validation — so the command must fail honestly,
|
|
78
|
+
// matching the MCP handler's merged rule
|
|
79
|
+
if (parseFailures.length > 0 || dryRunResult.invalid.length > 0) {
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
}
|
|
66
82
|
return;
|
|
67
83
|
}
|
|
68
84
|
// Single confirmation prompt
|
|
@@ -88,8 +104,9 @@ export async function pushCommand(file, options) {
|
|
|
88
104
|
]) {
|
|
89
105
|
const fileName = path.basename(file.filePath);
|
|
90
106
|
const isCreate = dryResult.action === "create" || dryResult.action === "not_found";
|
|
91
|
-
// Check if this file had an error
|
|
92
|
-
|
|
107
|
+
// Check if this file had an error — match by full path, since two pushed
|
|
108
|
+
// files can share a basename
|
|
109
|
+
const error = result.errors.find((e) => e.filePath === file.filePath);
|
|
93
110
|
if (error) {
|
|
94
111
|
console.log(` ✗ Failed: ${fileName} - ${error.error}`);
|
|
95
112
|
}
|
|
@@ -100,6 +117,16 @@ export async function pushCommand(file, options) {
|
|
|
100
117
|
console.log(` ✓ Updated: ${fileName}`);
|
|
101
118
|
}
|
|
102
119
|
}
|
|
120
|
+
// SYNC-FAIL-2.1: the cloud save succeeded but the local file couldn't be
|
|
121
|
+
// updated — name the file, say the cloud is fine, and give the recovery
|
|
122
|
+
// step that re-links the file instead of minting a duplicate on retry
|
|
123
|
+
if (result.writeBackWarnings?.length > 0) {
|
|
124
|
+
console.log();
|
|
125
|
+
for (const warning of result.writeBackWarnings) {
|
|
126
|
+
console.log(`⚠ ${warning.fileName}: saved to the cloud, but the local file could not be updated (${warning.error}). ` +
|
|
127
|
+
`To avoid creating a duplicate, add "id: ${warning.documentId}" under "document:" in the frontmatter of ${warning.filePath}, then push again.`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
103
130
|
// IMPORT-3: marker failures are loud but never fatal
|
|
104
131
|
if (result.importWarnings.length > 0) {
|
|
105
132
|
console.log();
|
|
@@ -148,7 +175,7 @@ export async function pushCommand(file, options) {
|
|
|
148
175
|
/**
|
|
149
176
|
* Display the dry run summary.
|
|
150
177
|
*/
|
|
151
|
-
function displayDryRunSummary(dryRunResult) {
|
|
178
|
+
function displayDryRunSummary(dryRunResult, parseFailures = []) {
|
|
152
179
|
const { updates, creates, notFound, invalid, conflicts } = dryRunResult;
|
|
153
180
|
console.log("\n=== Push Summary ===\n");
|
|
154
181
|
if (updates.length > 0) {
|
|
@@ -179,11 +206,22 @@ function displayDryRunSummary(dryRunResult) {
|
|
|
179
206
|
}
|
|
180
207
|
console.log();
|
|
181
208
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
209
|
+
// SYNC-FAIL-4.0: local parse failures surface alongside dry-run invalids,
|
|
210
|
+
// each named by file, while the rest of the batch proceeds
|
|
211
|
+
const skipped = [
|
|
212
|
+
...parseFailures.map((failure) => ({
|
|
213
|
+
fileName: path.basename(failure.filePath),
|
|
214
|
+
error: failure.error,
|
|
215
|
+
})),
|
|
216
|
+
...invalid.map(({ file, result }) => ({
|
|
217
|
+
fileName: path.basename(file.filePath),
|
|
218
|
+
error: result.error,
|
|
219
|
+
})),
|
|
220
|
+
];
|
|
221
|
+
if (skipped.length > 0) {
|
|
222
|
+
console.log(`Skipped - invalid files (${skipped.length}):`);
|
|
223
|
+
for (const { fileName, error } of skipped) {
|
|
224
|
+
console.log(` ✗ ${fileName}: ${error}`);
|
|
187
225
|
}
|
|
188
226
|
console.log();
|
|
189
227
|
}
|
package/dist/commands/report.js
CHANGED
|
@@ -36,8 +36,16 @@ export async function reportCommand(options) {
|
|
|
36
36
|
`(${error instanceof Error ? error.message : String(error)})`);
|
|
37
37
|
}
|
|
38
38
|
if (options.requirement) {
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
// REPORT-CLOUD-1.7: --branch/--since apply to the requirement-scoped
|
|
40
|
+
// output too, not just the project-wide report
|
|
41
|
+
const record = await getRequirementCoverage(options.requirement, projectId, projectSecret, CONVEX_URL, {
|
|
42
|
+
branch: options.branch,
|
|
43
|
+
sinceTimestamp: options.since,
|
|
44
|
+
});
|
|
45
|
+
console.log(printCloudRequirement(record, format, {
|
|
46
|
+
branch: options.branch,
|
|
47
|
+
since: options.since,
|
|
48
|
+
}));
|
|
41
49
|
return;
|
|
42
50
|
}
|
|
43
51
|
const record = await getProjectCoverage(projectId, projectSecret, CONVEX_URL, {
|
|
@@ -100,11 +108,18 @@ function formatLocalMarkdown(report) {
|
|
|
100
108
|
}
|
|
101
109
|
return out;
|
|
102
110
|
}
|
|
103
|
-
function printCloudRequirement(record, format) {
|
|
111
|
+
function printCloudRequirement(record, format, filters) {
|
|
104
112
|
if (format === "json") {
|
|
105
113
|
return JSON.stringify({ source: "cloud", requirement: record }, null, 2);
|
|
106
114
|
}
|
|
107
115
|
if (!record.lastTestedAt) {
|
|
116
|
+
// REPORT-CLOUD-1.7: with filters in play, an empty record means nothing
|
|
117
|
+
// matched them — say so rather than implying the requirement was never
|
|
118
|
+
// tested at all
|
|
119
|
+
const filterNote = formatCloudFilters(filters);
|
|
120
|
+
if (filterNote) {
|
|
121
|
+
return `\n${record.requirementKey}: no coverage records match the requested filters (${filterNote.replace(/^Filters: /, "")}).\n`;
|
|
122
|
+
}
|
|
108
123
|
return `\n${record.requirementKey}: never tested on the cloud-persisted record.\n`;
|
|
109
124
|
}
|
|
110
125
|
const lastTested = new Date(record.lastTestedAt).toISOString();
|