@popoverai/dotrequirements 0.24.3 → 0.26.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 (75) hide show
  1. package/README.md +7 -8
  2. package/dist/cli.js +8 -1
  3. package/dist/codebase-to-spec/dispatch.d.ts +60 -14
  4. package/dist/codebase-to-spec/dispatch.js +381 -15
  5. package/dist/codebase-to-spec/pack.d.ts +7 -0
  6. package/dist/codebase-to-spec/pack.js +29 -8
  7. package/dist/codebase-to-spec/present.d.ts +9 -0
  8. package/dist/codebase-to-spec/present.js +23 -2
  9. package/dist/codebase-to-spec/prompts/editor.d.ts +1 -1
  10. package/dist/codebase-to-spec/prompts/editor.js +1 -1
  11. package/dist/codebase-to-spec/prompts/specifier.d.ts +1 -1
  12. package/dist/codebase-to-spec/prompts/specifier.js +3 -2
  13. package/dist/codebase-to-spec/schemas.d.ts +153 -0
  14. package/dist/codebase-to-spec/schemas.js +111 -0
  15. package/dist/codebase-to-spec/skill-install.d.ts +42 -29
  16. package/dist/codebase-to-spec/skill-install.js +122 -112
  17. package/dist/codebase-to-spec/version-check.d.ts +31 -0
  18. package/dist/codebase-to-spec/version-check.js +56 -0
  19. package/dist/commands/ai-setup.d.ts +12 -1
  20. package/dist/commands/ai-setup.js +65 -33
  21. package/dist/commands/codebase-to-spec/dispatch-context.d.ts +2 -5
  22. package/dist/commands/codebase-to-spec/dispatch-context.js +2 -5
  23. package/dist/commands/codebase-to-spec/dispatch-editor.d.ts +0 -1
  24. package/dist/commands/codebase-to-spec/dispatch-editor.js +0 -1
  25. package/dist/commands/codebase-to-spec/dispatch-planner.d.ts +0 -1
  26. package/dist/commands/codebase-to-spec/dispatch-planner.js +0 -1
  27. package/dist/commands/codebase-to-spec/dispatch-spec.d.ts +3 -6
  28. package/dist/commands/codebase-to-spec/dispatch-spec.js +3 -6
  29. package/dist/commands/codebase-to-spec/index.js +3 -2
  30. package/dist/commands/codebase-to-spec/pack.d.ts +9 -0
  31. package/dist/commands/codebase-to-spec/pack.js +23 -3
  32. package/dist/commands/codebase-to-spec/skill-install.js +2 -9
  33. package/dist/commands/init.js +6 -1
  34. package/dist/commands/link-resolution.d.ts +79 -0
  35. package/dist/commands/link-resolution.js +141 -0
  36. package/dist/commands/link.d.ts +14 -4
  37. package/dist/commands/link.js +369 -16
  38. package/dist/commands/pull.js +19 -2
  39. package/dist/commands/push.js +36 -2
  40. package/dist/convex.d.ts +5 -3
  41. package/dist/convex.js +5 -3
  42. package/dist/harness/cache.d.ts +0 -14
  43. package/dist/harness/cache.js +1 -41
  44. package/dist/harness/finalize.js +2 -2
  45. package/dist/harness/prepare.js +1 -3
  46. package/dist/harness/requirementsLoader.d.ts +3 -3
  47. package/dist/harness/requirementsLoader.js +13 -8
  48. package/dist/mcp/handlers/authoring.d.ts +5 -5
  49. package/dist/mcp/handlers/authoring.js +9 -9
  50. package/dist/mcp/handlers/push.d.ts +2 -2
  51. package/dist/mcp/handlers/push.js +36 -3
  52. package/dist/mcp/handlers/review.d.ts +4 -4
  53. package/dist/mcp/handlers/review.js +4 -4
  54. package/dist/mcp/handlers/search.d.ts +1 -1
  55. package/dist/mcp/handlers/search.js +1 -1
  56. package/dist/mcp/index.js +29 -0
  57. package/dist/push/core.d.ts +18 -0
  58. package/dist/push/core.js +70 -3
  59. package/dist/push/index.d.ts +1 -1
  60. package/dist/push/index.js +1 -1
  61. package/dist/schema/parser-core.js +5 -1
  62. package/dist/schema/parser.js +5 -1
  63. package/dist/schema/run-marker.d.ts +38 -0
  64. package/dist/schema/run-marker.js +138 -0
  65. package/dist/schema/schemas.d.ts +12 -0
  66. package/dist/schema/schemas.js +1 -0
  67. package/dist/templates/agents/cts-worker.md +3 -3
  68. package/dist/templates/skills/codebase-to-spec/SKILL.md +56 -158
  69. package/dist/templates/workflows/specify-codebase.js +374 -0
  70. package/dist/utils/own-package.d.ts +10 -0
  71. package/dist/utils/own-package.js +13 -0
  72. package/dist/utils/project-selector.d.ts +5 -0
  73. package/dist/utils/project-selector.js +4 -0
  74. package/package.json +3 -3
  75. package/dist/templates/hooks/cts-worker-persona.sh +0 -76
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Pure decision logic for `dotrequirements link`'s non-interactive mode
3
+ * (LINK-10/11/12). No I/O: the link command gathers state (teams, projects)
4
+ * and these functions decide what to do — use a project, create one, rename,
5
+ * or stop with a decision-needed result that carries everything an AI
6
+ * assistant needs to ask the user and retry with a deviation flag.
7
+ */
8
+ /**
9
+ * LINK-12.3: any deviation flag implies non-interactive mode.
10
+ */
11
+ export function isNonInteractive(flags) {
12
+ return Boolean(flags.yes || flags.team || flags.connect || flags.create);
13
+ }
14
+ export function hasCapacity(team) {
15
+ return team.projectLimit === -1 || team.projectCount < team.projectLimit;
16
+ }
17
+ /**
18
+ * LINK-10.1, LINK-11.0, LINK-12.0, LINK-12.4
19
+ */
20
+ export function resolveTeam(teams, flags) {
21
+ if (flags.team) {
22
+ const wanted = flags.team.toLowerCase();
23
+ const match = teams.find((t) => t.teamId === flags.team || t.name.toLowerCase() === wanted);
24
+ if (match)
25
+ return { kind: "use", team: match };
26
+ return {
27
+ kind: "no-match",
28
+ flag: "team",
29
+ provided: flags.team,
30
+ alternatives: teams.map((t) => `${t.name} (${t.teamId})`),
31
+ };
32
+ }
33
+ if (teams.length === 1) {
34
+ return { kind: "use", team: teams[0] };
35
+ }
36
+ return {
37
+ kind: "decision",
38
+ decision: "team",
39
+ message: "You belong to multiple teams — which should this project join?",
40
+ options: teams.map((t) => ({
41
+ label: `${t.name} (${t.tierName}, ${formatCapacity(t)})`,
42
+ retryFlag: `--team ${t.teamId}`,
43
+ })),
44
+ };
45
+ }
46
+ /**
47
+ * LINK-10.2/10.3 (default-project rule), LINK-11.1/11.2 (decision menus),
48
+ * LINK-12.1/12.2/12.4 (deviation flags).
49
+ */
50
+ export function resolveProject(projects, team, flags) {
51
+ if (flags.connect) {
52
+ const match = projects.find((p) => p.projectSlug === flags.connect);
53
+ if (match)
54
+ return { kind: "connect", project: match, rename: false };
55
+ return {
56
+ kind: "no-match",
57
+ flag: "connect",
58
+ provided: flags.connect,
59
+ alternatives: projects
60
+ .filter((p) => p.projectSlug)
61
+ .map((p) => `${p.projectName} (${p.projectSlug})`),
62
+ };
63
+ }
64
+ if (flags.create) {
65
+ if (hasCapacity(team))
66
+ return { kind: "create" };
67
+ return atCapacityDecision(projects, team);
68
+ }
69
+ if (projects.length === 0) {
70
+ // A decision with a single possible answer is not a decision: create.
71
+ if (hasCapacity(team))
72
+ return { kind: "create" };
73
+ return atCapacityDecision(projects, team);
74
+ }
75
+ if (projects.length === 1 && projects[0].isDefault) {
76
+ // LINK-10.2: rename the untouched default project to match this repo;
77
+ // LINK-10.3: if the user has put content in it, connect without renaming
78
+ return {
79
+ kind: "connect",
80
+ project: projects[0],
81
+ rename: !projects[0].hasUserContent,
82
+ };
83
+ }
84
+ // LINK-11.1: existing projects the user chose to create — their call
85
+ return {
86
+ kind: "decision",
87
+ decision: "project",
88
+ message: "This team already has projects — connect to one, or create a new one?",
89
+ options: [
90
+ ...projects.map((p) => ({
91
+ label: `${p.projectName}${p.hasUserContent ? "" : " (empty)"}`,
92
+ retryFlag: p.projectSlug ? `--connect ${p.projectSlug}` : undefined,
93
+ })),
94
+ ...(hasCapacity(team)
95
+ ? [{ label: "Create a new project", retryFlag: "--create" }]
96
+ : []),
97
+ ],
98
+ };
99
+ }
100
+ /**
101
+ * LINK-11.2: creating is required (or requested) but the team is at its
102
+ * project limit. None of the remedies is a plain link retry flag.
103
+ */
104
+ function atCapacityDecision(projects, team) {
105
+ return {
106
+ kind: "decision",
107
+ decision: "capacity",
108
+ message: `Team "${team.name}" is at its ${team.tierName} project limit (${formatCapacity(team)}).`,
109
+ options: [
110
+ ...projects
111
+ .filter((p) => p.projectSlug)
112
+ .map((p) => ({
113
+ label: `Connect to existing project ${p.projectName}`,
114
+ retryFlag: `--connect ${p.projectSlug}`,
115
+ })),
116
+ { label: "Delete a project you no longer need (in the web app)" },
117
+ { label: "Upgrade the team for more projects" },
118
+ ],
119
+ };
120
+ }
121
+ function formatCapacity(team) {
122
+ return team.projectLimit === -1
123
+ ? `${team.projectCount}/unlimited projects`
124
+ : `${team.projectCount}/${team.projectLimit} projects`;
125
+ }
126
+ /**
127
+ * Prompt-free project naming for non-interactive mode (LINK-9.0): explicit
128
+ * flag, then package metadata, then directory name, then a fixed fallback —
129
+ * never a prompt.
130
+ */
131
+ export function resolveProjectName(flags, packageName, directoryName) {
132
+ if (flags.name)
133
+ return flags.name;
134
+ if (packageName)
135
+ return packageName;
136
+ if (directoryName && directoryName !== "/" && directoryName !== ".") {
137
+ return directoryName;
138
+ }
139
+ return "my-project";
140
+ }
141
+ //# sourceMappingURL=link-resolution.js.map
@@ -1,14 +1,24 @@
1
+ import { type LinkFlags } from "./link-resolution.js";
2
+ export interface LinkCommandOptions extends LinkFlags {
3
+ }
1
4
  /**
2
- * Link command - connect or reconnect local project to cloud.
5
+ * Link command - connect or reconnect local project to cloud, creating the
6
+ * cloud project when needed. Link owns the "upgrade local to cloud"
7
+ * transition.
3
8
  *
4
9
  * Requirements:
5
10
  * - LINK-1: Requires existing .requirements/ folder
6
11
  * - LINK-2: Offers refresh when credentials exist
7
12
  * - LINK-3: Uses inline OAuth (no persistent tokens)
8
- * - LINK-4, LINK-5: Team and project selection
13
+ * - LINK-4, LINK-5: Team and project selection (including the create option)
9
14
  * - LINK-6: Saves credentials to project-settings.json
10
- * - LINK-7: Optionally pulls requirements
15
+ * - LINK-7: Optionally pulls requirements (interactive only)
11
16
  * - LINK-8: Handles network errors gracefully
17
+ * - LINK-9: Creates the cloud project when chosen
18
+ * - LINK-10/11/12: Non-interactive mode for AI-driven setup
19
+ * - LINK-13: Non-interactive success includes share artifacts (team-invite
20
+ * URL + read-only pull command) so one "yes" puts the spec in front of
21
+ * the team
12
22
  */
13
- export declare function linkCommand(): Promise<void>;
23
+ export declare function linkCommand(options?: LinkCommandOptions): Promise<void>;
14
24
  //# sourceMappingURL=link.d.ts.map
@@ -1,25 +1,314 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
1
3
  import { ConvexHttpClient } from "convex/browser";
2
4
  import { getConvexUrl } from "../config.js";
5
+ import { api } from "../convex.js";
6
+ import { WEB_APP_URL } from "../push/index.js";
3
7
  import { brand } from "../utils/brand.js";
4
8
  import { ensureGitignore } from "../utils/gitignore.js";
5
9
  import { executeOAuthFlow } from "../utils/oauth-flow.js";
6
- import { getOrCreateProjectSecret, promptExpiryDays, selectProject, selectTeam, } from "../utils/project-selector.js";
10
+ import { getOrCreateProjectSecret, promptExpiryDays, selectTeam, } from "../utils/project-selector.js";
7
11
  import { findProjectRoot, readProjectSettings, writeProjectSettings, } from "../utils/project-settings.js";
8
12
  import { promptChoice, promptConfirm } from "../utils/prompts.js";
13
+ import { hasCapacity, isNonInteractive, resolveProject, resolveProjectName, resolveTeam, } from "./link-resolution.js";
9
14
  import { pullCommand } from "./pull.js";
10
15
  /**
11
- * Link command - connect or reconnect local project to cloud.
16
+ * Link command - connect or reconnect local project to cloud, creating the
17
+ * cloud project when needed. Link owns the "upgrade local to cloud"
18
+ * transition.
12
19
  *
13
20
  * Requirements:
14
21
  * - LINK-1: Requires existing .requirements/ folder
15
22
  * - LINK-2: Offers refresh when credentials exist
16
23
  * - LINK-3: Uses inline OAuth (no persistent tokens)
17
- * - LINK-4, LINK-5: Team and project selection
24
+ * - LINK-4, LINK-5: Team and project selection (including the create option)
18
25
  * - LINK-6: Saves credentials to project-settings.json
19
- * - LINK-7: Optionally pulls requirements
26
+ * - LINK-7: Optionally pulls requirements (interactive only)
20
27
  * - LINK-8: Handles network errors gracefully
28
+ * - LINK-9: Creates the cloud project when chosen
29
+ * - LINK-10/11/12: Non-interactive mode for AI-driven setup
30
+ * - LINK-13: Non-interactive success includes share artifacts (team-invite
31
+ * URL + read-only pull command) so one "yes" puts the spec in front of
32
+ * the team
21
33
  */
22
- export async function linkCommand() {
34
+ export async function linkCommand(options = {}) {
35
+ if (isNonInteractive(options)) {
36
+ await runNonInteractiveLink(options);
37
+ return;
38
+ }
39
+ await runInteractiveLink(options);
40
+ }
41
+ const EXIT_DECISION_NEEDED = 2;
42
+ async function runNonInteractiveLink(options) {
43
+ // LINK-10.5/11.3: in JSON mode the single stdout line is the result object;
44
+ // every informational message (including the OAuth flow's, and the browser
45
+ // fallback URL) goes to stderr so the output stays machine-parseable.
46
+ const originalLog = console.log;
47
+ if (options.json) {
48
+ console.log = (...args) => console.error(...args);
49
+ }
50
+ const finish = (outcome) => {
51
+ console.log = originalLog;
52
+ if (options.json) {
53
+ originalLog(JSON.stringify(outcome));
54
+ }
55
+ else if (outcome.status === "decision_needed") {
56
+ console.log(`\n${outcome.message}`);
57
+ for (const opt of outcome.options) {
58
+ console.log(` - ${opt.label}${opt.retryFlag ? ` (retry with: dotrequirements link ${opt.retryFlag})` : ""}`);
59
+ }
60
+ }
61
+ else if (outcome.status === "error") {
62
+ console.error(`\n✗ ${outcome.message}`);
63
+ }
64
+ else {
65
+ console.log(`\n✓ Linked to project: ${outcome.projectName}${outcome.renamed ? " (renamed to match this repository)" : ""}`);
66
+ console.log(` Project ID: ${outcome.projectSlug}`);
67
+ // LINK-13: hand over the share mechanics, one per recipient surface
68
+ if (outcome.inviteUrl) {
69
+ console.log(` Invite a teammate (web): ${outcome.inviteUrl}`);
70
+ }
71
+ if (outcome.sharePullCommand) {
72
+ console.log(` Share read-only to an IDE: ${outcome.sharePullCommand}`);
73
+ }
74
+ }
75
+ if (outcome.status === "decision_needed") {
76
+ process.exitCode = EXIT_DECISION_NEEDED;
77
+ }
78
+ else if (outcome.status === "error") {
79
+ process.exitCode = 1;
80
+ }
81
+ };
82
+ try {
83
+ // LINK-1: same precondition as interactive
84
+ const cwd = process.cwd();
85
+ const projectRoot = findProjectRoot(cwd);
86
+ if (!projectRoot || projectRoot !== cwd) {
87
+ finish({
88
+ status: "error",
89
+ message: 'No .requirements/ folder found. Run "dotrequirements init" to initialize a project first.',
90
+ });
91
+ return;
92
+ }
93
+ // LINK-10.6: existing credentials default to refresh-same-project
94
+ let refreshTargetSlug;
95
+ try {
96
+ const existing = readProjectSettings(projectRoot);
97
+ refreshTargetSlug = existing?.projectId;
98
+ }
99
+ catch {
100
+ refreshTargetSlug = undefined;
101
+ }
102
+ // LINK-3 / LINK-10.7: browser auth is the only human step
103
+ const oauthResult = await executeOAuthFlow();
104
+ const client = new ConvexHttpClient(getConvexUrl());
105
+ client.setAuth(oauthResult.accessToken);
106
+ await client.mutation(api.teams.mutations.ensureDefaultTeam, {});
107
+ const teams = (await client.query(api.teams.queries.listForUserWithUsage));
108
+ if (teams.length === 0) {
109
+ finish({
110
+ status: "error",
111
+ message: "You are not a member of any teams. Create one at https://app.dotrequirements.io/teams/new and retry.",
112
+ });
113
+ return;
114
+ }
115
+ // LINK-10.6: refresh path — find the already-linked project. A deviation
116
+ // flag (--team/--connect/--create, LINK-12) overrides the refresh default
117
+ // and falls through to normal resolution. Settings store the raw
118
+ // projectId when a project has no slug, so match either.
119
+ if (refreshTargetSlug &&
120
+ !options.connect &&
121
+ !options.create &&
122
+ !options.team) {
123
+ for (const team of teams) {
124
+ const projects = await listProjects(client, team.teamId);
125
+ const match = projects.find((p) => p.projectSlug === refreshTargetSlug ||
126
+ p.projectId === refreshTargetSlug);
127
+ if (match) {
128
+ const settingsPath = await connectToProject(client, projectRoot, match.projectId, refreshTargetSlug);
129
+ // LINK-13.2: ensureShareToken is idempotent, so a refresh hands
130
+ // back the same share command rather than minting a new token
131
+ const artifacts = await mintShareArtifacts(client, team.teamId, match.projectId);
132
+ finish({
133
+ status: "linked",
134
+ projectSlug: refreshTargetSlug,
135
+ projectName: match.projectName,
136
+ renamed: false,
137
+ settingsPath,
138
+ ...artifacts,
139
+ });
140
+ return;
141
+ }
142
+ }
143
+ finish({
144
+ status: "error",
145
+ message: `Currently-linked project "${refreshTargetSlug}" was not found in your teams. Pass --connect <slug> to link a different project.`,
146
+ });
147
+ return;
148
+ }
149
+ const teamResolution = resolveTeam(teams, options);
150
+ if (teamResolution.kind === "no-match") {
151
+ finish({
152
+ status: "error",
153
+ message: `No team matched "${teamResolution.provided}". Your teams: ${teamResolution.alternatives.join(", ")}`,
154
+ });
155
+ return;
156
+ }
157
+ if (teamResolution.kind === "decision") {
158
+ const { kind: _kind, ...decision } = teamResolution;
159
+ finish({ status: "decision_needed", ...decision });
160
+ return;
161
+ }
162
+ const team = teamResolution.team;
163
+ const projects = await listProjects(client, team.teamId);
164
+ const projectResolution = resolveProject(projects, team, options);
165
+ if (projectResolution.kind === "no-match") {
166
+ finish({
167
+ status: "error",
168
+ message: `No project matched "${projectResolution.provided}". Projects in ${team.name}: ${projectResolution.alternatives.join(", ") || "(none)"}`,
169
+ });
170
+ return;
171
+ }
172
+ if (projectResolution.kind === "decision") {
173
+ const { kind: _kind, ...decision } = projectResolution;
174
+ finish({ status: "decision_needed", ...decision });
175
+ return;
176
+ }
177
+ if (projectResolution.kind === "create") {
178
+ // LINK-9 via LINK-12.2 (or zero-projects default)
179
+ const name = resolveProjectName(options, readPackageName(cwd), path.basename(cwd));
180
+ const created = (await client.mutation(api.projects.mutations.create, {
181
+ name,
182
+ teamId: team.teamId,
183
+ }));
184
+ const settingsPath = await connectToProject(client, projectRoot, created.projectId, created.slug);
185
+ const artifacts = await mintShareArtifacts(client, team.teamId, created.projectId);
186
+ finish({
187
+ status: "linked",
188
+ projectSlug: created.slug,
189
+ projectName: name,
190
+ renamed: false,
191
+ settingsPath,
192
+ ...artifacts,
193
+ });
194
+ return;
195
+ }
196
+ // connect (possibly renaming the empty default project, LINK-10.2)
197
+ let projectName = projectResolution.project.projectName;
198
+ let renamed = false;
199
+ if (projectResolution.rename) {
200
+ const newName = resolveProjectName(options, readPackageName(cwd), path.basename(cwd));
201
+ try {
202
+ await client.mutation(api.projects.mutations.update, {
203
+ target: {
204
+ type: "project",
205
+ // biome-ignore lint/suspicious/noExplicitAny: CLI workspace doesn't import convex's branded Id type; the runtime value is a plain string the server validates.
206
+ id: projectResolution.project.projectId,
207
+ },
208
+ name: newName,
209
+ });
210
+ projectName = newName;
211
+ renamed = true;
212
+ }
213
+ catch {
214
+ // LINK-10.9: renaming is admin-gated server-side — a non-admin member
215
+ // (e.g. invited via init --invite) still gets connected, just without
216
+ // the rename
217
+ console.error(`Note: couldn't rename the default project to "${newName}" (team admin required) — connected without renaming.`);
218
+ }
219
+ }
220
+ const slug = projectResolution.project.projectSlug ??
221
+ projectResolution.project.projectId;
222
+ const settingsPath = await connectToProject(client, projectRoot, projectResolution.project.projectId, slug);
223
+ const artifacts = await mintShareArtifacts(client, team.teamId, projectResolution.project.projectId);
224
+ finish({
225
+ status: "linked",
226
+ projectSlug: projectResolution.project.projectSlug,
227
+ projectName,
228
+ renamed,
229
+ settingsPath,
230
+ ...artifacts,
231
+ });
232
+ }
233
+ catch (error) {
234
+ finish({
235
+ status: "error",
236
+ message: error instanceof Error ? error.message : String(error),
237
+ });
238
+ }
239
+ finally {
240
+ console.log = originalLog;
241
+ }
242
+ }
243
+ /**
244
+ * LINK-13: mint the artifacts that put the spec in front of the team — a
245
+ * single-use team-invite URL (web reviewers) and a read-only pull command
246
+ * (IDE teammates). Either mint may fail without failing the connection
247
+ * (LINK-13.3): createInvite is admin-gated, so non-admin members link
248
+ * successfully and the invite URL is simply omitted.
249
+ */
250
+ async function mintShareArtifacts(client, teamId, projectId) {
251
+ const artifacts = {};
252
+ try {
253
+ const invite = (await client.mutation(api.teamInvites.mutations.createInvite,
254
+ // biome-ignore lint/suspicious/noExplicitAny: CLI workspace doesn't import convex's branded Id type; the runtime value is a plain string the server validates.
255
+ { target: { type: "team", id: teamId } }));
256
+ artifacts.inviteUrl = `${WEB_APP_URL}/invite/${invite.token}`;
257
+ }
258
+ catch {
259
+ // LINK-13.3: admin-gated — omit the invite URL for non-admin members
260
+ }
261
+ try {
262
+ const share = (await client.mutation(api.projectSecrets.mutations.ensureShareToken,
263
+ // biome-ignore lint/suspicious/noExplicitAny: CLI workspace doesn't import convex's branded Id type; the runtime value is a plain string the server validates.
264
+ { target: { type: "project", id: projectId } }));
265
+ artifacts.sharePullCommand = `npx -y @popoverai/dotrequirements pull --share ${share.token}`;
266
+ }
267
+ catch {
268
+ // LINK-13.3: connection already succeeded — omit rather than fail
269
+ }
270
+ return artifacts;
271
+ }
272
+ async function listProjects(client, teamId) {
273
+ const projects = (await client.query(api.projectSecrets.queries.listOwnSecretsForTeam,
274
+ // biome-ignore lint/suspicious/noExplicitAny: CLI workspace doesn't import convex's branded Id type; the runtime value is a plain string the server validates.
275
+ { target: { type: "team", id: teamId } }));
276
+ return projects.map((p) => ({
277
+ projectId: p.projectId,
278
+ projectName: p.projectName,
279
+ projectSlug: p.projectSlug,
280
+ isDefault: p.isDefault ?? false,
281
+ // Older servers don't report content state; treat unknown as content so
282
+ // we never rename something we can't vouch for
283
+ hasUserContent: p.hasUserContent ?? true,
284
+ }));
285
+ }
286
+ /**
287
+ * Mint (or fetch) a secret and write credentials (LINK-6). Returns the
288
+ * settings file path. Non-interactive: tier-default expiry, no prompts.
289
+ */
290
+ async function connectToProject(client, projectRoot, projectId, slug) {
291
+ const secretResult = await getOrCreateProjectSecret(client, projectId, undefined);
292
+ writeProjectSettings(projectRoot, {
293
+ projectId: slug,
294
+ projectSecret: secretResult.secret,
295
+ });
296
+ ensureGitignore(projectRoot);
297
+ return path.join(projectRoot, ".requirements", "project-settings.json");
298
+ }
299
+ function readPackageName(cwd) {
300
+ try {
301
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf-8"));
302
+ return typeof pkg.name === "string" ? pkg.name : undefined;
303
+ }
304
+ catch {
305
+ return undefined;
306
+ }
307
+ }
308
+ // ============================================================================
309
+ // Interactive mode
310
+ // ============================================================================
311
+ async function runInteractiveLink(options) {
23
312
  try {
24
313
  console.log(`Linking to ${brand} cloud...\n`);
25
314
  const cwd = process.cwd();
@@ -65,19 +354,38 @@ export async function linkCommand() {
65
354
  console.log("Link cancelled.");
66
355
  return;
67
356
  }
68
- // LINK-5: Select project
69
- const selectedProject = await selectProject(client, selectedTeam.teamId);
70
- if (!selectedProject) {
71
- // selectProject already shows message about no projects
357
+ // LINK-5: Select project — or create one (LINK-5.1, LINK-9)
358
+ const selection = await selectProjectOrCreate(client, selectedTeam);
359
+ if (selection === null) {
360
+ console.log("Link cancelled.");
72
361
  return;
73
362
  }
363
+ let projectId;
364
+ let projectName;
365
+ let projectSlugOrId;
366
+ if (selection === "create") {
367
+ // LINK-9: create with the detected default name (-n/--name overrides)
368
+ const defaultName = resolveProjectName(options, readPackageName(cwd), path.basename(cwd));
369
+ console.log(`\nCreating project "${defaultName}"...`);
370
+ const created = (await client.mutation(api.projects.mutations.create, {
371
+ name: defaultName,
372
+ teamId: selectedTeam.teamId,
373
+ }));
374
+ projectId = created.projectId;
375
+ projectName = defaultName;
376
+ projectSlugOrId = created.slug;
377
+ console.log(`✓ Created project: ${created.slug}`);
378
+ }
379
+ else {
380
+ projectId = selection.projectId;
381
+ projectName = selection.projectName;
382
+ projectSlugOrId = selection.projectSlug || selection.projectId;
383
+ }
74
384
  // AUTHZ-3: Prompt for secret expiry (paid tier only)
75
385
  const expiryDays = await promptExpiryDays(selectedTeam.tier);
76
386
  // LINK-5.3: Get or create secret for this project
77
387
  console.log("\nGetting project credentials...");
78
- const secretResult = await getOrCreateProjectSecret(client, selectedProject.projectId, expiryDays);
79
- const projectId = selectedProject.projectSlug || selectedProject.projectId;
80
- const secret = secretResult.secret;
388
+ const secretResult = await getOrCreateProjectSecret(client, projectId, expiryDays);
81
389
  if (secretResult.created) {
82
390
  console.log("✓ Created new project secret");
83
391
  }
@@ -85,18 +393,21 @@ export async function linkCommand() {
85
393
  console.log("✓ Retrieved existing project secret");
86
394
  }
87
395
  // LINK-6: Write credentials to project-settings.json
88
- writeProjectSettings(projectRoot, { projectId, projectSecret: secret });
396
+ writeProjectSettings(projectRoot, {
397
+ projectId: projectSlugOrId,
398
+ projectSecret: secretResult.secret,
399
+ });
89
400
  ensureGitignore(projectRoot);
90
401
  console.log("✓ Wrote credentials to .requirements/project-settings.json");
91
402
  // LINK-7: Optionally pull requirements
92
403
  const shouldPull = await promptConfirm("Pull requirements from cloud now?", true);
93
404
  if (shouldPull) {
94
405
  console.log("\nPulling requirements...");
95
- await pullCommand({ project: projectId });
406
+ await pullCommand({ project: projectSlugOrId });
96
407
  }
97
408
  // LINK-7.2: Success message
98
- console.log(`\n✓ Successfully linked to project: ${selectedProject.projectName}`);
99
- console.log(` Project ID: ${projectId}`);
409
+ console.log(`\n✓ Successfully linked to project: ${projectName}`);
410
+ console.log(` Project ID: ${projectSlugOrId}`);
100
411
  console.log("\nDocumentation: https://docs.dotrequirements.io/getting-started");
101
412
  }
102
413
  catch (error) {
@@ -109,6 +420,48 @@ export async function linkCommand() {
109
420
  process.exit(1);
110
421
  }
111
422
  }
423
+ /**
424
+ * LINK-5: project selection including the create option (LINK-5.1) when the
425
+ * team has capacity. Returns the chosen project, "create", or null (cancel).
426
+ */
427
+ async function selectProjectOrCreate(client, team) {
428
+ console.log("\nFetching projects...\n");
429
+ const projects = await listProjects(client, team.teamId);
430
+ const teamOption = {
431
+ teamId: team.teamId,
432
+ name: team.name ?? "this team",
433
+ tier: team.tier,
434
+ tierName: team.tierName ?? team.tier,
435
+ projectCount: team.projectCount ?? projects.length,
436
+ projectLimit: team.projectLimit ?? -1,
437
+ };
438
+ const canCreate = hasCapacity(teamOption);
439
+ if (projects.length === 0) {
440
+ if (!canCreate) {
441
+ // LINK-5.2: connect is impossible; the remedies live elsewhere
442
+ console.log("This team has no projects and is at its project limit.");
443
+ console.log("Delete a project you no longer need (in the web app) or upgrade the team, then retry.\n");
444
+ return null;
445
+ }
446
+ const confirmed = await promptConfirm("This team has no projects. Create a new one?", true);
447
+ return confirmed ? "create" : null;
448
+ }
449
+ const choices = projects.map((p) => ({
450
+ title: p.projectSlug
451
+ ? `${p.projectName} (${p.projectSlug})`
452
+ : p.projectName,
453
+ value: p.projectId,
454
+ }));
455
+ if (canCreate) {
456
+ choices.push({ title: "Create a new project", value: "CREATE_NEW" });
457
+ }
458
+ const selected = await promptChoice("Select a project:", choices);
459
+ if (!selected)
460
+ return null;
461
+ if (selected === "CREATE_NEW")
462
+ return "create";
463
+ return projects.find((p) => p.projectId === selected) ?? null;
464
+ }
112
465
  /**
113
466
  * LINK-2: Handle existing credentials - offer refresh or connect to different project
114
467
  * Returns true if user wants to continue (either refresh or different project)
@@ -32,9 +32,14 @@ export async function pullCommand(options) {
32
32
  console.log(`\nProject: "${projectName}"`);
33
33
  }
34
34
  catch (error) {
35
- // SHARE-TOKEN-CLI-2: Handle invalid/revoked tokens gracefully
35
+ // SHARE-TOKEN-CLI-2: Handle invalid/revoked tokens gracefully.
36
+ // Production redacts ConvexError messages to "Server Error" but
37
+ // preserves error.data — check both, or the friendly message only
38
+ // ever appears against dev deployments.
39
+ const data = error.data;
36
40
  const errorMessage = error instanceof Error ? error.message : String(error);
37
- if (errorMessage.includes("Invalid share token")) {
41
+ if (data?.message === "Invalid share token" ||
42
+ errorMessage.includes("Invalid share token")) {
38
43
  throw new Error("Invalid share token. The token may be incorrect or has been revoked.\n" +
39
44
  "Please request a new share token from your team.");
40
45
  }
@@ -96,11 +101,23 @@ export async function pullCommand(options) {
96
101
  const filePath = existingFilePath ??
97
102
  path.join(requirementsDir, `${sanitizeFileName(doc.title)}.requirements.md`);
98
103
  const fileName = path.basename(filePath);
104
+ // IMPORT-1: carry the CTS run marker forward from the existing local
105
+ // file — pull rebuilds frontmatter from cloud data, and silently dropping
106
+ // the marker would strip a committed spec's import credential before a
107
+ // teammate ever pushes it
108
+ let existingCtsRun;
109
+ if (existingFilePath && fs.existsSync(existingFilePath)) {
110
+ const match = fs
111
+ .readFileSync(existingFilePath, "utf-8")
112
+ .match(/^ctsRun: (.+)$/m);
113
+ existingCtsRun = match?.[1].trim();
114
+ }
99
115
  // Build metadata with pulledAt for conflict detection
100
116
  // SYNC-CLI-EDIT-2: pulledAt is compared with cloud updatedAt during push
101
117
  const metadata = {
102
118
  pulledAt: new Date().toISOString(),
103
119
  version: doc.version,
120
+ ...(existingCtsRun ? { ctsRun: existingCtsRun } : {}),
104
121
  document: {
105
122
  id: doc.documentId,
106
123
  title: doc.title,