@popoverai/dotrequirements 0.27.4 → 0.28.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.
Files changed (50) hide show
  1. package/README.md +24 -20
  2. package/dist/cli.js +34 -12
  3. package/dist/commands/aliases.d.ts +26 -0
  4. package/dist/commands/aliases.js +31 -0
  5. package/dist/commands/diff.d.ts +14 -0
  6. package/dist/commands/diff.js +62 -0
  7. package/dist/commands/init.js +5 -5
  8. package/dist/commands/link.d.ts +1 -1
  9. package/dist/commands/link.js +10 -7
  10. package/dist/commands/sync-common.d.ts +21 -0
  11. package/dist/commands/sync-common.js +24 -0
  12. package/dist/commands/sync.d.ts +26 -0
  13. package/dist/commands/sync.js +182 -0
  14. package/dist/convex.d.ts +1 -1
  15. package/dist/convex.js +2 -2
  16. package/dist/push/core.d.ts +18 -116
  17. package/dist/push/core.js +16 -267
  18. package/dist/push/index.d.ts +3 -3
  19. package/dist/push/index.js +4 -4
  20. package/dist/requirements/style-guide.js +3 -3
  21. package/dist/schema/schemas.js +1 -1
  22. package/dist/sync/compare.d.ts +21 -0
  23. package/dist/sync/compare.js +285 -0
  24. package/dist/sync/execute.d.ts +48 -0
  25. package/dist/sync/execute.js +186 -0
  26. package/dist/sync/index.d.ts +21 -0
  27. package/dist/sync/index.js +52 -0
  28. package/dist/sync/local-files.d.ts +26 -0
  29. package/dist/sync/local-files.js +91 -0
  30. package/dist/sync/plan.d.ts +39 -0
  31. package/dist/sync/plan.js +90 -0
  32. package/dist/sync/render.d.ts +17 -0
  33. package/dist/sync/render.js +46 -0
  34. package/dist/sync/segment.d.ts +40 -0
  35. package/dist/sync/segment.js +76 -0
  36. package/dist/sync/snapshot.d.ts +30 -0
  37. package/dist/sync/snapshot.js +118 -0
  38. package/dist/sync/types.d.ts +82 -0
  39. package/dist/sync/types.js +12 -0
  40. package/dist/templates/context-file-section.md +2 -1
  41. package/dist/templates/requirements-readme.js +3 -4
  42. package/dist/templates/requirements-readme.ts +3 -4
  43. package/dist/templates/skills/codebase-to-spec/SKILL.md +2 -2
  44. package/dist/utils/project-settings.d.ts +8 -2
  45. package/dist/utils/project-settings.js +47 -24
  46. package/package.json +1 -1
  47. package/dist/commands/pull.d.ts +0 -8
  48. package/dist/commands/pull.js +0 -230
  49. package/dist/commands/push.d.ts +0 -6
  50. package/dist/commands/push.js +0 -244
@@ -6,8 +6,7 @@ export function generateRequirementsReadme() {
6
6
 
7
7
  This directory contains requirements for the project. Requirements can be:
8
8
  - Hand-authored locally in Markdown files
9
- - Generated via \`dotrequirements pull\` from dot•requirements cloud
10
- - Synced bidirectionally with \`dotrequirements push\`
9
+ - Synced bidirectionally with dot•requirements cloud via \`dotrequirements sync\`
11
10
 
12
11
  ## File Format
13
12
 
@@ -21,8 +20,8 @@ Requirements use the \`*.requirements.md\` naming pattern. See the [Requirements
21
20
 
22
21
  ## Commands
23
22
 
24
- - \`dotrequirements pull\` - Sync requirements from cloud
25
- - \`dotrequirements push\` - Push local requirements to cloud
23
+ - \`dotrequirements sync\` - Reconcile requirements with the cloud (both directions)
24
+ - \`dotrequirements diff\` - Show how the repo and the cloud differ (read-only)
26
25
  - \`dotrequirements validate\` - Validate requirement files
27
26
 
28
27
  Learn more: https://dotrequirements.io
@@ -6,8 +6,7 @@ export function generateRequirementsReadme(): string {
6
6
 
7
7
  This directory contains requirements for the project. Requirements can be:
8
8
  - Hand-authored locally in Markdown files
9
- - Generated via \`dotrequirements pull\` from dot•requirements cloud
10
- - Synced bidirectionally with \`dotrequirements push\`
9
+ - Synced bidirectionally with dot•requirements cloud via \`dotrequirements sync\`
11
10
 
12
11
  ## File Format
13
12
 
@@ -21,8 +20,8 @@ Requirements use the \`*.requirements.md\` naming pattern. See the [Requirements
21
20
 
22
21
  ## Commands
23
22
 
24
- - \`dotrequirements pull\` - Sync requirements from cloud
25
- - \`dotrequirements push\` - Push local requirements to cloud
23
+ - \`dotrequirements sync\` - Reconcile requirements with the cloud (both directions)
24
+ - \`dotrequirements diff\` - Show how the repo and the cloud differ (read-only)
26
25
  - \`dotrequirements validate\` - Validate requirement files
27
26
 
28
27
  Learn more: https://dotrequirements.io
@@ -92,8 +92,8 @@ The spec is written; this step is about what the user does with it. End the run
92
92
 
93
93
  1. If no cloud credentials exist, run `{{DOTREQ_CLI}} link --yes --json` via Bash. The user completes login in the browser; everything else is yours.
94
94
  2. If link exits with code 2, its JSON is a `decision_needed` — multiple teams, existing projects, or a plan at its project limit. Relay the options conversationally (they are the user's choice, not yours), then retry link with the chosen option's `retryFlag` appended.
95
- 3. Once linked, run `{{DOTREQ_CLI}} push --yes`. Present each document's web URL from the output as the place the user's team reviews it.
96
- 4. Hand over the share mechanics from link's JSON, matched to the teammate's surface: the `inviteUrl` for a teammate who will review in the web platform, and the `sharePullCommand` for a teammate working in their IDE. If link omitted one (e.g. the user isn't a team admin), hand over what's there without apology.
95
+ 3. Once linked, run `{{DOTREQ_CLI}} sync --repo-contributes --yes`. Present each document's web URL from the output as the place the user's team reviews it.
96
+ 4. Hand over the share mechanics from link's JSON, matched to the teammate's surface: the `inviteUrl` for a teammate who will review in the web platform, and the `shareSyncCommand` for a teammate working in their IDE. If link omitted one (e.g. the user isn't a team admin), hand over what's there without apology.
97
97
  5. With the spec in the cloud, offer to set up the assistant you are running as to work from the spec going forward — framed as that outcome ("I can work from this spec in future sessions"), not as configuration. On acceptance, run `{{DOTREQ_CLI}} ai-setup --assistant <id>` with the identifier for this assistant (e.g. `claude-code`).
98
98
 
99
99
  If the user declines the team-review offer, note that it stands for later and don't repeat the pitch.
@@ -41,7 +41,6 @@ export interface ProjectInfo {
41
41
  * Returns undefined if either is missing.
42
42
  */
43
43
  export declare function getCredentialsFromEnv(): ProjectSettings | undefined;
44
- export declare function setAuthFromEnv(enabled: boolean): void;
45
44
  /**
46
45
  * Find the project root by walking up from startDir looking for .requirements/ folder
47
46
  * Returns the directory containing .requirements/, or undefined if not found
@@ -66,8 +65,15 @@ export declare function writeProjectSettings(projectRoot: string, settings: Proj
66
65
  * Returns undefined if no project found
67
66
  */
68
67
  export declare function getProjectInfo(startDir?: string): ProjectInfo | undefined;
68
+ export declare function setPreferEnvCredentials(enabled: boolean): void;
69
69
  /**
70
- * Get project credentials, throwing helpful error if not available
70
+ * Get project credentials, throwing a helpful error if not available.
71
+ *
72
+ * AUTHZ-6: the settings file wins when present; the environment is the fallback
73
+ * when no settings file is on disk. A local checkout's binding is never
74
+ * overridden by an ambient environment (AUTHZ-6.0), while a CI checkout with no
75
+ * settings file authenticates from DOTREQ_PROJECT_ID / DOTREQ_PROJECT_SECRET
76
+ * (AUTHZ-6.1) — no flag required.
71
77
  */
72
78
  export declare function getProjectCredentials(startDir?: string): ProjectSettings;
73
79
  //# sourceMappingURL=project-settings.d.ts.map
@@ -18,19 +18,6 @@ export function getCredentialsFromEnv() {
18
18
  }
19
19
  return undefined;
20
20
  }
21
- /**
22
- * AUTHZ-6: explicit CI/CD credential injection. Set by the global
23
- * --auth-from-env flag (cli.ts preAction hook); when enabled,
24
- * getProjectCredentials reads DOTREQ_PROJECT_ID / DOTREQ_PROJECT_SECRET
25
- * instead of file-based discovery. Without the flag those variables are
26
- * ignored entirely — the explicit opt-in prevents credential conflicts
27
- * between local and CI environments. (Previously the retired MCP server's
28
- * --auth-from-env; ported to the CLI with identical semantics.)
29
- */
30
- let authFromEnv = false;
31
- export function setAuthFromEnv(enabled) {
32
- authFromEnv = enabled;
33
- }
34
21
  /**
35
22
  * Find the project root by walking up from startDir looking for .requirements/ folder
36
23
  * Returns the directory containing .requirements/, or undefined if not found
@@ -167,25 +154,61 @@ export function getProjectInfo(startDir = process.cwd()) {
167
154
  };
168
155
  }
169
156
  /**
170
- * Get project credentials, throwing helpful error if not available
157
+ * SYNC-ALIAS-1.3: the retired `--auth-from-env` global flag survives as a
158
+ * deprecated alias for the commands already in users' CI (the doc-site taught
159
+ * `dotreq --auth-from-env push`). When set, environment credentials replace
160
+ * file discovery entirely — the flag's original semantics — so an old
161
+ * invocation behaves identically.
162
+ */
163
+ let preferEnvCredentials = false;
164
+ export function setPreferEnvCredentials(enabled) {
165
+ preferEnvCredentials = enabled;
166
+ }
167
+ /**
168
+ * Get project credentials, throwing a helpful error if not available.
169
+ *
170
+ * AUTHZ-6: the settings file wins when present; the environment is the fallback
171
+ * when no settings file is on disk. A local checkout's binding is never
172
+ * overridden by an ambient environment (AUTHZ-6.0), while a CI checkout with no
173
+ * settings file authenticates from DOTREQ_PROJECT_ID / DOTREQ_PROJECT_SECRET
174
+ * (AUTHZ-6.1) — no flag required.
171
175
  */
172
176
  export function getProjectCredentials(startDir = process.cwd()) {
173
- // AUTHZ-6.0 / 6.1: with the explicit flag, env credentials replace
174
- // file-based discovery entirely
175
- if (authFromEnv) {
177
+ // SYNC-ALIAS-1.3: deprecated --auth-from-env keeps its original semantics —
178
+ // env replaces file discovery entirely.
179
+ if (preferEnvCredentials) {
176
180
  const envCredentials = getCredentialsFromEnv();
177
181
  if (!envCredentials) {
178
- throw new Error("--auth-from-env requires both DOTREQ_PROJECT_ID and DOTREQ_PROJECT_SECRET environment variables to be set.");
182
+ throw new Error("Both DOTREQ_PROJECT_ID and DOTREQ_PROJECT_SECRET must be set to authenticate from the environment.");
179
183
  }
180
184
  return envCredentials;
181
185
  }
182
- const info = getProjectInfo(startDir);
183
- if (!info) {
184
- throw new Error('No dotrequirements project found. Run "dotrequirements init" to create one.');
186
+ // File wins: a present, valid settings file is authoritative. readProjectSettings
187
+ // throws on a present-but-invalid file, surfacing it rather than silently
188
+ // falling through to the environment.
189
+ const root = findProjectRoot(startDir);
190
+ if (root) {
191
+ const settings = readProjectSettings(root);
192
+ if (settings)
193
+ return settings;
194
+ }
195
+ // No settings file on disk — fall back to the environment (AUTHZ-6.1).
196
+ const envCredentials = getCredentialsFromEnv();
197
+ if (envCredentials) {
198
+ // AUTHZ-6.1: announce the fallback, on stderr so it never pollutes stdout
199
+ // that a caller may be parsing.
200
+ process.stderr.write("Authenticating from the DOTREQ_PROJECT_ID / DOTREQ_PROJECT_SECRET environment variables.\n");
201
+ return envCredentials;
185
202
  }
186
- if (!info.credentials) {
187
- throw new Error('Project is not connected to cloud. Run "dotrequirements link" to connect.');
203
+ // AUTHZ-6.2: exactly one variable set is a misconfiguration, not a silent
204
+ // no-op — name both.
205
+ if (process.env.DOTREQ_PROJECT_ID || process.env.DOTREQ_PROJECT_SECRET) {
206
+ throw new Error("Both DOTREQ_PROJECT_ID and DOTREQ_PROJECT_SECRET must be set to authenticate from the environment.");
207
+ }
208
+ // AUTHZ-6.3 / AUTHZ-2: neither a settings file nor environment credentials.
209
+ if (!root) {
210
+ throw new Error('No dotrequirements project found. Run "dotrequirements init" to create one.');
188
211
  }
189
- return info.credentials;
212
+ throw new Error("Cloud features require authentication. Run 'dotrequirements link'.");
190
213
  }
191
214
  //# sourceMappingURL=project-settings.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@popoverai/dotrequirements",
3
- "version": "0.27.4",
3
+ "version": "0.28.0",
4
4
  "description": "Requirements tracking CLI and test harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,8 +0,0 @@
1
- interface PullOptions {
2
- project?: string;
3
- document?: string;
4
- share?: string;
5
- }
6
- export declare function pullCommand(options: PullOptions): Promise<void>;
7
- export {};
8
- //# sourceMappingURL=pull.d.ts.map
@@ -1,230 +0,0 @@
1
- import * as fs from "node:fs";
2
- import * as path from "node:path";
3
- import { ConvexHttpClient } from "convex/browser";
4
- import { getConvexUrl } from "../config.js";
5
- import { api } from "../convex.js";
6
- import { findRequirementsFiles } from "../requirements/index.js";
7
- import { buildRequirementsFile } from "../schema/index.js";
8
- import { extractFrontmatterBlock } from "../schema/parser-core.js";
9
- import { brand } from "../utils/brand.js";
10
- import { getProjectCredentials } from "../utils/project-settings.js";
11
- export async function pullCommand(options) {
12
- // Connect to Convex
13
- const convexUrl = getConvexUrl();
14
- const client = new ConvexHttpClient(convexUrl);
15
- let documents;
16
- let projectName;
17
- let isShareTokenFlow = false;
18
- // SHARE-TOKEN-CLI-1: Handle share token flow (no project config needed)
19
- if (options.share) {
20
- isShareTokenFlow = true;
21
- console.log(`Pulling requirements with share token...`);
22
- try {
23
- // SHARE-TOKEN-CLI-1.1: Pass only projectSecret, no projectSlug
24
- // The authzQueryReadOnly wrapper will resolve project from the token
25
- const result = await client.query(api.documents.queries.exportProjectForCli, {
26
- projectAuth: {
27
- projectSecret: options.share,
28
- },
29
- });
30
- documents = result.documents;
31
- projectName = result.projectName;
32
- // SHARE-TOKEN-CLI-1.2: Display project name in output
33
- console.log(`\nProject: "${projectName}"`);
34
- }
35
- catch (error) {
36
- // SHARE-TOKEN-CLI-2: Handle invalid/revoked tokens gracefully.
37
- // Production redacts ConvexError messages to "Server Error" but
38
- // preserves error.data — check both, or the friendly message only
39
- // ever appears against dev deployments.
40
- const data = error.data;
41
- const errorMessage = error instanceof Error ? error.message : String(error);
42
- if (data?.message === "Invalid share token" ||
43
- errorMessage.includes("Invalid share token")) {
44
- throw new Error("Invalid share token. The token may be incorrect or has been revoked.\n" +
45
- "Please request a new share token from your team.");
46
- }
47
- throw error;
48
- }
49
- }
50
- else {
51
- console.log(`Syncing requirements from ${brand} cloud...`);
52
- // Get project credentials from .requirements/project-settings.json
53
- // This throws helpful errors if project not found or not connected to cloud
54
- const credentials = getProjectCredentials();
55
- // Allow --project flag to override, but still need secret from settings
56
- const projectId = options.project ?? credentials.projectId;
57
- const projectSecret = credentials.projectSecret;
58
- // Fetch documents
59
- if (options.document) {
60
- // Fetch specific document
61
- const doc = await client.query(api.documents.queries.exportForCli, {
62
- projectAuth: {
63
- projectSlug: projectId,
64
- projectSecret,
65
- },
66
- target: { type: "document", id: options.document },
67
- documentId: options.document,
68
- });
69
- documents = doc ? [doc] : [];
70
- }
71
- else if (projectId) {
72
- // Fetch all documents for project
73
- // SYNC-ARCH-3.1: Use projectSlug (not projectId) since env stores the slug
74
- const result = await client.query(api.documents.queries.exportProjectForCli, {
75
- projectAuth: {
76
- projectSlug: projectId,
77
- projectSecret,
78
- },
79
- });
80
- documents = result.documents;
81
- projectName = result.projectName;
82
- }
83
- else {
84
- throw new Error("Either --project or --document must be specified");
85
- }
86
- }
87
- if (documents.length === 0) {
88
- console.log("No documents found.");
89
- return;
90
- }
91
- // Ensure .requirements/ directory exists (for new documents)
92
- const requirementsDir = path.join(process.cwd(), ".requirements");
93
- if (!fs.existsSync(requirementsDir)) {
94
- fs.mkdirSync(requirementsDir, { recursive: true });
95
- }
96
- // Build index of existing files by document ID (search entire workspace)
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());
101
- // Write each document as a Markdown file
102
- for (const doc of documents) {
103
- // Check if an existing file has this document ID
104
- const existingFilePath = existingFilesByDocId.get(doc.documentId);
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);
125
- const fileName = path.basename(filePath);
126
- // IMPORT-1: carry the CTS run marker forward from the existing local
127
- // file — pull rebuilds frontmatter from cloud data, and silently dropping
128
- // the marker would strip a committed spec's import credential before a
129
- // teammate ever pushes it
130
- let existingCtsRun;
131
- if (existingFilePath && fs.existsSync(existingFilePath)) {
132
- const match = fs
133
- .readFileSync(existingFilePath, "utf-8")
134
- .match(/^ctsRun: (.+)$/m);
135
- existingCtsRun = match?.[1].trim();
136
- }
137
- // Build metadata with pulledAt for conflict detection
138
- // SYNC-CLI-EDIT-2: pulledAt is compared with cloud updatedAt during push
139
- const metadata = {
140
- pulledAt: new Date().toISOString(),
141
- version: doc.version,
142
- ...(existingCtsRun ? { ctsRun: existingCtsRun } : {}),
143
- document: {
144
- id: doc.documentId,
145
- title: doc.title,
146
- defaultPrefix: doc.defaultPrefix,
147
- },
148
- };
149
- // SYNC-ARCH-1: Use markdownContent directly from cloud (source of truth)
150
- // No need to rebuild from requirements - just combine with frontmatter
151
- const fileContent = buildRequirementsFile(metadata, doc.markdownContent);
152
- fs.writeFileSync(filePath, fileContent, "utf-8");
153
- console.log(`✓ Synced: ${fileName} (version ${doc.version})`);
154
- }
155
- console.log(`\nSuccessfully synced ${documents.length} document(s) to .requirements/`);
156
- // SHARE-TOKEN-CLI-3: Show next steps for share token users
157
- if (isShareTokenFlow) {
158
- console.log("\n─────────────────────────────────────────────────────────");
159
- console.log("Next steps to get full access (push, coverage):");
160
- console.log(" Run: dotrequirements link");
161
- console.log("─────────────────────────────────────────────────────────");
162
- }
163
- }
164
- /**
165
- * Sanitize document title for use as filename
166
- */
167
- function sanitizeFileName(title) {
168
- return title
169
- .toLowerCase()
170
- .replace(/[^a-z0-9]+/g, "-")
171
- .replace(/^-+|-+$/g, "");
172
- }
173
- /**
174
- * Build an index mapping document IDs to their file paths.
175
- * Scans existing .requirements.md files across the entire workspace and extracts document.id from frontmatter.
176
- * SYNC-DISCOVERY-2.0: Uses the same discovery logic as push and MCP.
177
- */
178
- async function buildDocumentIdIndex(workspaceRoot) {
179
- const index = new Map();
180
- // Use the same file discovery as MCP and push
181
- const allFiles = await findRequirementsFiles(workspaceRoot);
182
- for (const filePath of allFiles) {
183
- const docId = extractDocumentIdFromFile(filePath);
184
- if (docId) {
185
- index.set(docId, filePath);
186
- }
187
- }
188
- return index;
189
- }
190
- /**
191
- * Extract document.id from a requirements file's frontmatter.
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.
196
- */
197
- function extractDocumentIdFromFile(filePath) {
198
- try {
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
- }
208
- // Match document.id in YAML frontmatter - handles both inline and nested formats
209
- // Inline: document: { id: "abc123", ... }
210
- // Nested (id: can appear at any position within the indented document block):
211
- // document:
212
- // title: Some Title
213
- // defaultPrefix: REQ
214
- // id: abc123
215
- const inlineMatch = content.match(/document:\s*\{[^}]*id:\s*["']?([^"',}\s]+)/);
216
- if (inlineMatch) {
217
- return inlineMatch[1];
218
- }
219
- // Match id: anywhere within the document block (allows other properties before it)
220
- const nestedMatch = content.match(/document:\s*\n(?:[ \t]+[^\n]*\n)*?[ \t]+id:\s*["']?([^"'\s\n]+)/);
221
- if (nestedMatch) {
222
- return nestedMatch[1];
223
- }
224
- return undefined;
225
- }
226
- catch {
227
- return undefined;
228
- }
229
- }
230
- //# sourceMappingURL=pull.js.map
@@ -1,6 +0,0 @@
1
- interface PushOptions {
2
- yes?: boolean;
3
- }
4
- export declare function pushCommand(file: string | undefined, options: PushOptions): Promise<void>;
5
- export {};
6
- //# sourceMappingURL=push.d.ts.map