@specmarket/cli 0.0.5 → 0.0.6
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/{chunk-DLEMNRTH.js → chunk-OTXWWFAO.js} +24 -2
- package/dist/chunk-OTXWWFAO.js.map +1 -0
- package/dist/{config-OAU6SJLC.js → config-5JMI3YAR.js} +2 -2
- package/dist/index.js +1283 -389
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/init.test.ts +162 -23
- package/src/commands/init.ts +349 -17
- package/src/commands/issues.test.ts +8 -3
- package/src/commands/issues.ts +2 -9
- package/src/commands/login.ts +2 -6
- package/src/commands/publish.test.ts +14 -1
- package/src/commands/publish.ts +1 -0
- package/src/commands/run.test.ts +206 -0
- package/src/commands/run.ts +63 -3
- package/src/commands/validate.test.ts +83 -6
- package/src/commands/validate.ts +96 -114
- package/src/lib/format-detection.test.ts +4 -4
- package/src/lib/format-detection.ts +3 -3
- package/src/lib/meta-instructions.test.ts +340 -0
- package/src/lib/meta-instructions.ts +562 -0
- package/src/lib/ralph-loop.test.ts +404 -0
- package/src/lib/ralph-loop.ts +475 -98
- package/src/lib/telemetry.ts +5 -0
- package/dist/chunk-DLEMNRTH.js.map +0 -1
- /package/dist/{config-OAU6SJLC.js.map → config-5JMI3YAR.js.map} +0 -0
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { readFile, rm, access } from 'fs/promises';
|
|
2
|
+
import { readFile, rm, access, writeFile, mkdir } from 'fs/promises';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { tmpdir } from 'os';
|
|
5
5
|
import { randomUUID } from 'crypto';
|
|
@@ -29,6 +29,7 @@ vi.mock('inquirer', () => ({
|
|
|
29
29
|
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
30
30
|
|
|
31
31
|
import { handleInit } from './init.js';
|
|
32
|
+
import { SIDECAR_FILENAME } from '@specmarket/shared';
|
|
32
33
|
|
|
33
34
|
describe('handleInit', () => {
|
|
34
35
|
let tmpDir: string;
|
|
@@ -43,17 +44,21 @@ describe('handleInit', () => {
|
|
|
43
44
|
});
|
|
44
45
|
|
|
45
46
|
it('creates spec directory with all required files', async () => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
// Path provided + empty dir: first prompt "Create from scratch?", then full spec answers
|
|
48
|
+
mockPrompt
|
|
49
|
+
.mockResolvedValueOnce({ createNew: true })
|
|
50
|
+
.mockResolvedValueOnce({
|
|
51
|
+
name: 'test-spec',
|
|
52
|
+
displayName: 'Test Spec',
|
|
53
|
+
replacesSaas: '',
|
|
54
|
+
outputType: 'web-app',
|
|
55
|
+
primaryStack: 'nextjs-typescript',
|
|
56
|
+
});
|
|
53
57
|
|
|
54
58
|
await handleInit({ path: tmpDir });
|
|
55
59
|
|
|
56
|
-
// Verify all required files were created
|
|
60
|
+
// Verify all required files were created (specmarket.yaml is required for validate/publish)
|
|
61
|
+
await expect(access(join(tmpDir, SIDECAR_FILENAME))).resolves.toBeUndefined();
|
|
57
62
|
await expect(access(join(tmpDir, 'spec.yaml'))).resolves.toBeUndefined();
|
|
58
63
|
await expect(access(join(tmpDir, 'PROMPT.md'))).resolves.toBeUndefined();
|
|
59
64
|
await expect(access(join(tmpDir, 'SPEC.md'))).resolves.toBeUndefined();
|
|
@@ -67,13 +72,15 @@ describe('handleInit', () => {
|
|
|
67
72
|
});
|
|
68
73
|
|
|
69
74
|
it('writes correct spec.yaml content based on prompts', async () => {
|
|
70
|
-
mockPrompt
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
mockPrompt
|
|
76
|
+
.mockResolvedValueOnce({ createNew: true })
|
|
77
|
+
.mockResolvedValueOnce({
|
|
78
|
+
name: 'my-project',
|
|
79
|
+
displayName: 'My Project',
|
|
80
|
+
replacesSaas: 'Notion',
|
|
81
|
+
outputType: 'web-app',
|
|
82
|
+
primaryStack: 'nextjs-typescript',
|
|
83
|
+
});
|
|
77
84
|
|
|
78
85
|
await handleInit({ path: tmpDir });
|
|
79
86
|
|
|
@@ -86,13 +93,15 @@ describe('handleInit', () => {
|
|
|
86
93
|
});
|
|
87
94
|
|
|
88
95
|
it('shows next-steps instructions after creation', async () => {
|
|
89
|
-
mockPrompt
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
+
mockPrompt
|
|
97
|
+
.mockResolvedValueOnce({ createNew: true })
|
|
98
|
+
.mockResolvedValueOnce({
|
|
99
|
+
name: 'test-spec',
|
|
100
|
+
displayName: 'Test Spec',
|
|
101
|
+
replacesSaas: '',
|
|
102
|
+
outputType: 'cli-tool',
|
|
103
|
+
primaryStack: 'go',
|
|
104
|
+
});
|
|
96
105
|
|
|
97
106
|
await handleInit({ path: tmpDir });
|
|
98
107
|
|
|
@@ -103,4 +112,134 @@ describe('handleInit', () => {
|
|
|
103
112
|
expect.stringContaining('specmarket validate')
|
|
104
113
|
);
|
|
105
114
|
});
|
|
115
|
+
|
|
116
|
+
it('adds only specmarket.yaml when -p points at existing Spec Kit dir (no overwrites)', async () => {
|
|
117
|
+
await mkdir(tmpDir, { recursive: true });
|
|
118
|
+
await writeFile(join(tmpDir, 'spec.md'), '# My Spec Kit spec\nDo not overwrite.');
|
|
119
|
+
await writeFile(join(tmpDir, 'tasks.md'), '# Tasks');
|
|
120
|
+
|
|
121
|
+
mockPrompt.mockResolvedValue({
|
|
122
|
+
displayName: 'Spec Kit Spec',
|
|
123
|
+
description: 'A spec kit spec for the marketplace.',
|
|
124
|
+
replacesSaas: '',
|
|
125
|
+
outputType: 'web-app',
|
|
126
|
+
primaryStack: 'nextjs-typescript',
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await handleInit({ path: tmpDir });
|
|
130
|
+
|
|
131
|
+
await expect(access(join(tmpDir, SIDECAR_FILENAME))).resolves.toBeUndefined();
|
|
132
|
+
const sidecar = await readFile(join(tmpDir, SIDECAR_FILENAME), 'utf-8');
|
|
133
|
+
expect(sidecar).toContain('spec_format: speckit');
|
|
134
|
+
expect(sidecar).toContain('display_name: "Spec Kit Spec"');
|
|
135
|
+
const specMd = await readFile(join(tmpDir, 'spec.md'), 'utf-8');
|
|
136
|
+
expect(specMd).toBe('# My Spec Kit spec\nDo not overwrite.');
|
|
137
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Added specmarket.yaml'));
|
|
138
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('specmarket validate'));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('exits without overwriting when specmarket.yaml already exists in -p dir', async () => {
|
|
142
|
+
await mkdir(tmpDir, { recursive: true });
|
|
143
|
+
await writeFile(
|
|
144
|
+
join(tmpDir, SIDECAR_FILENAME),
|
|
145
|
+
'spec_format: speckit\ndisplay_name: X\ndescription: Already have a sidecar here.\noutput_type: web-app\nprimary_stack: nextjs-typescript\n'
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
await handleInit({ path: tmpDir });
|
|
149
|
+
|
|
150
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
151
|
+
expect.stringContaining('already exists')
|
|
152
|
+
);
|
|
153
|
+
const sidecar = await readFile(join(tmpDir, SIDECAR_FILENAME), 'utf-8');
|
|
154
|
+
expect(sidecar).toContain('Already have a sidecar here.');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// --from flag tests (import-only mode)
|
|
158
|
+
|
|
159
|
+
it('--from: adds specmarket.yaml sidecar to existing Spec Kit dir', async () => {
|
|
160
|
+
await mkdir(tmpDir, { recursive: true });
|
|
161
|
+
await writeFile(join(tmpDir, 'spec.md'), '# My Spec Kit spec');
|
|
162
|
+
await writeFile(join(tmpDir, 'tasks.md'), '# Tasks');
|
|
163
|
+
|
|
164
|
+
mockPrompt.mockResolvedValue({
|
|
165
|
+
displayName: 'Imported Spec',
|
|
166
|
+
description: 'An imported spec kit spec for testing.',
|
|
167
|
+
replacesSaas: '',
|
|
168
|
+
outputType: 'web-app',
|
|
169
|
+
primaryStack: 'nextjs-typescript',
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await handleInit({ from: tmpDir });
|
|
173
|
+
|
|
174
|
+
await expect(access(join(tmpDir, SIDECAR_FILENAME))).resolves.toBeUndefined();
|
|
175
|
+
const sidecar = await readFile(join(tmpDir, SIDECAR_FILENAME), 'utf-8');
|
|
176
|
+
expect(sidecar).toContain('spec_format: speckit');
|
|
177
|
+
expect(sidecar).toContain('display_name: "Imported Spec"');
|
|
178
|
+
// Original files must not be modified
|
|
179
|
+
const specMd = await readFile(join(tmpDir, 'spec.md'), 'utf-8');
|
|
180
|
+
expect(specMd).toBe('# My Spec Kit spec');
|
|
181
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Added specmarket.yaml'));
|
|
182
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('specmarket validate'));
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('--from: adds specmarket.yaml sidecar to BMAD dir', async () => {
|
|
186
|
+
await mkdir(tmpDir, { recursive: true });
|
|
187
|
+
await writeFile(join(tmpDir, 'prd.md'), '# PRD');
|
|
188
|
+
await writeFile(join(tmpDir, 'architecture.md'), '# Architecture');
|
|
189
|
+
|
|
190
|
+
mockPrompt.mockResolvedValue({
|
|
191
|
+
displayName: 'BMAD App',
|
|
192
|
+
description: 'A bmad formatted spec for the marketplace.',
|
|
193
|
+
replacesSaas: 'Jira',
|
|
194
|
+
outputType: 'web-app',
|
|
195
|
+
primaryStack: 'nextjs-typescript',
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await handleInit({ from: tmpDir });
|
|
199
|
+
|
|
200
|
+
const sidecar = await readFile(join(tmpDir, SIDECAR_FILENAME), 'utf-8');
|
|
201
|
+
expect(sidecar).toContain('spec_format: bmad');
|
|
202
|
+
expect(sidecar).toContain('display_name: "BMAD App"');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('--from: errors when directory does not exist', async () => {
|
|
206
|
+
const nonExistentDir = join(tmpDir, 'does-not-exist');
|
|
207
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
208
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((_code?: number | string | null) => { throw new Error('process.exit called'); });
|
|
209
|
+
|
|
210
|
+
await expect(handleInit({ from: nonExistentDir })).rejects.toThrow('process.exit called');
|
|
211
|
+
|
|
212
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Directory not found'));
|
|
213
|
+
exitSpy.mockRestore();
|
|
214
|
+
consoleErrorSpy.mockRestore();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('--from: errors when directory is empty', async () => {
|
|
218
|
+
await mkdir(tmpDir, { recursive: true });
|
|
219
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
220
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((_code?: number | string | null) => { throw new Error('process.exit called'); });
|
|
221
|
+
|
|
222
|
+
await expect(handleInit({ from: tmpDir })).rejects.toThrow('process.exit called');
|
|
223
|
+
|
|
224
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Directory is empty'));
|
|
225
|
+
exitSpy.mockRestore();
|
|
226
|
+
consoleErrorSpy.mockRestore();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('--from: warns and exits without overwriting when specmarket.yaml already exists', async () => {
|
|
230
|
+
await mkdir(tmpDir, { recursive: true });
|
|
231
|
+
await writeFile(join(tmpDir, 'spec.md'), '# Spec');
|
|
232
|
+
await writeFile(
|
|
233
|
+
join(tmpDir, SIDECAR_FILENAME),
|
|
234
|
+
'spec_format: speckit\ndisplay_name: Existing\ndescription: Already have sidecar.\noutput_type: web-app\nprimary_stack: nextjs-typescript\n'
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
await handleInit({ from: tmpDir });
|
|
238
|
+
|
|
239
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('already exists'));
|
|
240
|
+
const sidecar = await readFile(join(tmpDir, SIDECAR_FILENAME), 'utf-8');
|
|
241
|
+
expect(sidecar).toContain('Already have sidecar.');
|
|
242
|
+
// mockPrompt should NOT have been called (no prompts for metadata)
|
|
243
|
+
expect(mockPrompt).not.toHaveBeenCalled();
|
|
244
|
+
});
|
|
106
245
|
});
|
package/src/commands/init.ts
CHANGED
|
@@ -1,13 +1,54 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import ora from 'ora';
|
|
4
|
-
import { mkdir, writeFile } from 'fs/promises';
|
|
5
|
-
import { join, resolve } from 'path';
|
|
6
|
-
import { EXIT_CODES } from '@specmarket/shared';
|
|
4
|
+
import { mkdir, writeFile, readdir } from 'fs/promises';
|
|
5
|
+
import { join, resolve, basename } from 'path';
|
|
6
|
+
import { EXIT_CODES, SIDECAR_FILENAME } from '@specmarket/shared';
|
|
7
7
|
import createDebug from 'debug';
|
|
8
|
+
import { detectSpecFormat, fileExists, directoryExists } from '../lib/format-detection.js';
|
|
8
9
|
|
|
9
10
|
const debug = createDebug('specmarket:cli');
|
|
10
11
|
|
|
12
|
+
type MetadataData = {
|
|
13
|
+
displayName: string;
|
|
14
|
+
description: string;
|
|
15
|
+
replacesSaas?: string;
|
|
16
|
+
outputType: string;
|
|
17
|
+
primaryStack: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Build specmarket.yaml for any spec format (single source of truth for validate/publish) */
|
|
21
|
+
function buildSpecmarketYaml(data: MetadataData & { specFormat: string }): string {
|
|
22
|
+
return `# SpecMarket metadata (required for validate/publish)
|
|
23
|
+
# Single source of truth for format and marketplace fields.
|
|
24
|
+
# Your existing spec files (Spec Kit, BMAD, Ralph, etc.) are not modified.
|
|
25
|
+
|
|
26
|
+
spec_format: ${data.specFormat}
|
|
27
|
+
display_name: "${data.displayName.replace(/"/g, '\\"')}"
|
|
28
|
+
description: "${data.description.replace(/"/g, '\\"')}"
|
|
29
|
+
output_type: ${data.outputType}
|
|
30
|
+
primary_stack: ${data.primaryStack}
|
|
31
|
+
${data.replacesSaas ? `replaces_saas: "${data.replacesSaas.replace(/"/g, '\\"')}"` : '# replaces_saas: "ProductName"'}
|
|
32
|
+
# replaces_pricing: "$0-16/mo"
|
|
33
|
+
tags: []
|
|
34
|
+
estimated_tokens: 50000
|
|
35
|
+
estimated_cost_usd: 2.50
|
|
36
|
+
estimated_time_minutes: 30
|
|
37
|
+
`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** specmarket.yaml for new SpecMarket (classic) specs */
|
|
41
|
+
const SPECMARKET_YAML_TEMPLATE = (data: {
|
|
42
|
+
displayName: string;
|
|
43
|
+
replacesSaas?: string;
|
|
44
|
+
outputType: string;
|
|
45
|
+
primaryStack: string;
|
|
46
|
+
}) => buildSpecmarketYaml({
|
|
47
|
+
...data,
|
|
48
|
+
specFormat: 'specmarket',
|
|
49
|
+
description: `A ${data.outputType} spec${data.replacesSaas ? ` that replaces ${data.replacesSaas}` : ''}.`,
|
|
50
|
+
});
|
|
51
|
+
|
|
11
52
|
const SPEC_YAML_TEMPLATE = (data: {
|
|
12
53
|
name: string;
|
|
13
54
|
displayName: string;
|
|
@@ -162,12 +203,274 @@ const TASKS_MD_TEMPLATE = (displayName: string) => `# Tasks
|
|
|
162
203
|
## Discovered Issues
|
|
163
204
|
`;
|
|
164
205
|
|
|
206
|
+
/** Prompt for marketplace metadata only (used when adding sidecar to existing spec) */
|
|
207
|
+
async function promptMetadataOnly(defaultDisplayName?: string): Promise<MetadataData> {
|
|
208
|
+
const { default: inquirer } = await import('inquirer');
|
|
209
|
+
const answers = await inquirer.prompt<{
|
|
210
|
+
displayName: string;
|
|
211
|
+
description: string;
|
|
212
|
+
replacesSaas: string;
|
|
213
|
+
outputType: string;
|
|
214
|
+
primaryStack: string;
|
|
215
|
+
}>([
|
|
216
|
+
{
|
|
217
|
+
type: 'input',
|
|
218
|
+
name: 'displayName',
|
|
219
|
+
message: 'Display name for the marketplace:',
|
|
220
|
+
default: defaultDisplayName ?? 'My Spec',
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
type: 'input',
|
|
224
|
+
name: 'description',
|
|
225
|
+
message: 'Short description (min 10 characters):',
|
|
226
|
+
default: 'A spec ready to validate and publish on SpecMarket.',
|
|
227
|
+
validate: (v: string) => (v.length >= 10 ? true : 'Description must be at least 10 characters'),
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
type: 'input',
|
|
231
|
+
name: 'replacesSaas',
|
|
232
|
+
message: 'What SaaS product does this replace? (optional, Enter to skip):',
|
|
233
|
+
default: '',
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
type: 'list',
|
|
237
|
+
name: 'outputType',
|
|
238
|
+
message: 'Output type:',
|
|
239
|
+
choices: [
|
|
240
|
+
{ name: 'Web Application', value: 'web-app' },
|
|
241
|
+
{ name: 'CLI Tool', value: 'cli-tool' },
|
|
242
|
+
{ name: 'API Service', value: 'api-service' },
|
|
243
|
+
{ name: 'Library/Package', value: 'library' },
|
|
244
|
+
{ name: 'Mobile App', value: 'mobile-app' },
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
type: 'list',
|
|
249
|
+
name: 'primaryStack',
|
|
250
|
+
message: 'Primary stack:',
|
|
251
|
+
choices: [
|
|
252
|
+
{ name: 'Next.js + TypeScript', value: 'nextjs-typescript' },
|
|
253
|
+
{ name: 'Astro + TypeScript', value: 'astro-typescript' },
|
|
254
|
+
{ name: 'Python + FastAPI', value: 'python-fastapi' },
|
|
255
|
+
{ name: 'Go', value: 'go' },
|
|
256
|
+
{ name: 'Rust', value: 'rust' },
|
|
257
|
+
{ name: 'Other', value: 'other' },
|
|
258
|
+
],
|
|
259
|
+
},
|
|
260
|
+
]);
|
|
261
|
+
return {
|
|
262
|
+
displayName: answers.displayName,
|
|
263
|
+
description: answers.description,
|
|
264
|
+
replacesSaas: answers.replacesSaas || undefined,
|
|
265
|
+
outputType: answers.outputType,
|
|
266
|
+
primaryStack: answers.primaryStack,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Returns true if directory exists and has at least one file (not just subdirs). */
|
|
271
|
+
async function dirHasFiles(dir: string): Promise<boolean> {
|
|
272
|
+
try {
|
|
273
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
274
|
+
return entries.some((e) => e.isFile());
|
|
275
|
+
} catch {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
165
280
|
export async function handleInit(opts: {
|
|
166
281
|
name?: string;
|
|
167
282
|
path?: string;
|
|
283
|
+
from?: string;
|
|
168
284
|
}): Promise<void> {
|
|
169
285
|
const { default: inquirer } = await import('inquirer');
|
|
170
286
|
|
|
287
|
+
// --from: strict import-only mode. Errors if directory doesn't exist or has no spec files.
|
|
288
|
+
if (opts.from !== undefined && opts.from !== '') {
|
|
289
|
+
const targetDir = resolve(opts.from);
|
|
290
|
+
|
|
291
|
+
const dirExists = await directoryExists(targetDir);
|
|
292
|
+
if (!dirExists) {
|
|
293
|
+
console.error(chalk.red(`Directory not found: ${targetDir}`));
|
|
294
|
+
console.error(chalk.gray('--from requires an existing spec directory. Run specmarket init (no flags) to create a new spec from scratch.'));
|
|
295
|
+
process.exit(EXIT_CODES.INVALID_SPEC);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const hasAnyFiles = await dirHasFiles(targetDir);
|
|
299
|
+
if (!hasAnyFiles) {
|
|
300
|
+
console.error(chalk.red(`Directory is empty: ${targetDir}`));
|
|
301
|
+
console.error(chalk.gray('--from requires a directory with spec files (Spec Kit, BMAD, Ralph, or custom markdown). Run specmarket init to create a new spec.'));
|
|
302
|
+
process.exit(EXIT_CODES.INVALID_SPEC);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const sidecarPath = join(targetDir, SIDECAR_FILENAME);
|
|
306
|
+
if (await fileExists(sidecarPath)) {
|
|
307
|
+
console.log(chalk.yellow(`${SIDECAR_FILENAME} already exists in this directory.`));
|
|
308
|
+
console.log(chalk.gray('Run specmarket validate to check your spec, then specmarket publish to publish.'));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const detection = await detectSpecFormat(targetDir);
|
|
313
|
+
const formatLabel =
|
|
314
|
+
detection.format === 'specmarket'
|
|
315
|
+
? 'SpecMarket (spec.yaml + PROMPT.md + …)'
|
|
316
|
+
: detection.format === 'speckit'
|
|
317
|
+
? 'Spec Kit'
|
|
318
|
+
: detection.format === 'bmad'
|
|
319
|
+
? 'BMAD'
|
|
320
|
+
: detection.format === 'ralph'
|
|
321
|
+
? 'Ralph'
|
|
322
|
+
: 'custom markdown';
|
|
323
|
+
console.log(chalk.gray(`Detected ${formatLabel} spec. Adding SpecMarket metadata only; your files will not be modified.`));
|
|
324
|
+
console.log('');
|
|
325
|
+
|
|
326
|
+
const metadata = await promptMetadataOnly(basename(targetDir));
|
|
327
|
+
const yaml = buildSpecmarketYaml({ ...metadata, specFormat: detection.format });
|
|
328
|
+
await writeFile(sidecarPath, yaml);
|
|
329
|
+
|
|
330
|
+
console.log('');
|
|
331
|
+
console.log(chalk.green(`Added ${SIDECAR_FILENAME} to ${targetDir}`));
|
|
332
|
+
console.log('');
|
|
333
|
+
console.log(chalk.bold('Next steps:'));
|
|
334
|
+
console.log(` 1. Run ${chalk.cyan('specmarket validate')} to check your spec`);
|
|
335
|
+
console.log(` 2. Run ${chalk.cyan('specmarket publish')} to publish to the marketplace`);
|
|
336
|
+
console.log(` 3. Run ${chalk.cyan('specmarket run')} to execute the spec locally`);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// When -p/--path is set, we're initializing in an existing directory (safe add-sidecar flow)
|
|
341
|
+
if (opts.path !== undefined && opts.path !== '') {
|
|
342
|
+
const targetDir = resolve(opts.path);
|
|
343
|
+
await mkdir(targetDir, { recursive: true });
|
|
344
|
+
|
|
345
|
+
const sidecarPath = join(targetDir, SIDECAR_FILENAME);
|
|
346
|
+
if (await fileExists(sidecarPath)) {
|
|
347
|
+
console.log(chalk.yellow(`${SIDECAR_FILENAME} already exists in this directory.`));
|
|
348
|
+
console.log(chalk.gray('Run specmarket validate to check your spec, then specmarket publish to publish.'));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const detection = await detectSpecFormat(targetDir);
|
|
353
|
+
const hasAnyFiles = await dirHasFiles(targetDir);
|
|
354
|
+
|
|
355
|
+
if (hasAnyFiles && detection.format !== 'custom') {
|
|
356
|
+
// Recognized format (specmarket, speckit, bmad, ralph): add sidecar only, no overwrites
|
|
357
|
+
const formatLabel =
|
|
358
|
+
detection.format === 'specmarket'
|
|
359
|
+
? 'SpecMarket (spec.yaml + PROMPT.md + …)'
|
|
360
|
+
: detection.format === 'speckit'
|
|
361
|
+
? 'Spec Kit'
|
|
362
|
+
: detection.format === 'bmad'
|
|
363
|
+
? 'BMAD'
|
|
364
|
+
: detection.format === 'ralph'
|
|
365
|
+
? 'Ralph'
|
|
366
|
+
: detection.format;
|
|
367
|
+
console.log(chalk.gray(`Detected ${formatLabel} spec. Adding SpecMarket metadata only; your files will not be modified.`));
|
|
368
|
+
console.log('');
|
|
369
|
+
|
|
370
|
+
const metadata = await promptMetadataOnly(basename(targetDir));
|
|
371
|
+
const yaml = buildSpecmarketYaml({
|
|
372
|
+
...metadata,
|
|
373
|
+
specFormat: detection.format,
|
|
374
|
+
});
|
|
375
|
+
await writeFile(sidecarPath, yaml);
|
|
376
|
+
|
|
377
|
+
console.log('');
|
|
378
|
+
console.log(chalk.green(`Added ${SIDECAR_FILENAME} to ${targetDir}`));
|
|
379
|
+
console.log('');
|
|
380
|
+
console.log(chalk.bold('Next steps:'));
|
|
381
|
+
console.log(` 1. Run ${chalk.cyan('specmarket validate')} to check your spec`);
|
|
382
|
+
console.log(` 2. Run ${chalk.cyan('specmarket publish')} to publish to the marketplace`);
|
|
383
|
+
console.log(` 3. Run ${chalk.cyan('specmarket run')} to execute the spec locally`);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (hasAnyFiles && detection.format === 'custom') {
|
|
388
|
+
// Custom (e.g. random .md): still add sidecar so they can validate/publish
|
|
389
|
+
console.log(chalk.gray('Detected markdown spec. Adding SpecMarket metadata only; your files will not be modified.'));
|
|
390
|
+
console.log('');
|
|
391
|
+
const metadata = await promptMetadataOnly(basename(targetDir));
|
|
392
|
+
const yaml = buildSpecmarketYaml({
|
|
393
|
+
...metadata,
|
|
394
|
+
specFormat: 'custom',
|
|
395
|
+
});
|
|
396
|
+
await writeFile(sidecarPath, yaml);
|
|
397
|
+
console.log('');
|
|
398
|
+
console.log(chalk.green(`Added ${SIDECAR_FILENAME} to ${targetDir}`));
|
|
399
|
+
console.log('');
|
|
400
|
+
console.log(chalk.bold('Next steps:'));
|
|
401
|
+
console.log(` 1. Run ${chalk.cyan('specmarket validate')} to check your spec`);
|
|
402
|
+
console.log(` 2. Run ${chalk.cyan('specmarket publish')} to publish to the marketplace`);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Empty directory: offer to create from scratch
|
|
407
|
+
console.log(chalk.gray('Directory is empty or has no recognized spec format.'));
|
|
408
|
+
const { createNew } = await inquirer.prompt<{ createNew: boolean }>([
|
|
409
|
+
{
|
|
410
|
+
type: 'confirm',
|
|
411
|
+
name: 'createNew',
|
|
412
|
+
message: 'Create a new SpecMarket spec from scratch here?',
|
|
413
|
+
default: true,
|
|
414
|
+
},
|
|
415
|
+
]);
|
|
416
|
+
if (!createNew) {
|
|
417
|
+
console.log(chalk.gray('Exiting. Add spec files (e.g. Spec Kit, BMAD) and run specmarket init -p . again.'));
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const spinner = ora(`Creating spec at ${targetDir}...`).start();
|
|
421
|
+
const fullAnswers = await inquirer.prompt<{
|
|
422
|
+
name: string;
|
|
423
|
+
displayName: string;
|
|
424
|
+
replacesSaas: string;
|
|
425
|
+
outputType: string;
|
|
426
|
+
primaryStack: string;
|
|
427
|
+
}>([
|
|
428
|
+
{ type: 'input', name: 'name', message: 'Spec name (lowercase, hyphens only):', default: opts.name ?? (basename(targetDir) || 'my-spec'), validate: (v: string) => /^[a-z0-9-]+$/.test(v) || 'Must be lowercase alphanumeric with hyphens' },
|
|
429
|
+
{ type: 'input', name: 'displayName', message: 'Display name:', default: (a: { name: string }) => a.name.split('-').map((w) => w[0]?.toUpperCase() + w.slice(1)).join(' ') },
|
|
430
|
+
{ type: 'input', name: 'replacesSaas', message: 'What SaaS product does this replace? (optional):', default: '' },
|
|
431
|
+
{ type: 'list', name: 'outputType', message: 'Output type:', choices: [
|
|
432
|
+
{ name: 'Web Application', value: 'web-app' },
|
|
433
|
+
{ name: 'CLI Tool', value: 'cli-tool' },
|
|
434
|
+
{ name: 'API Service', value: 'api-service' },
|
|
435
|
+
{ name: 'Library/Package', value: 'library' },
|
|
436
|
+
{ name: 'Mobile App', value: 'mobile-app' },
|
|
437
|
+
]},
|
|
438
|
+
{ type: 'list', name: 'primaryStack', message: 'Primary stack:', choices: [
|
|
439
|
+
{ name: 'Next.js + TypeScript', value: 'nextjs-typescript' },
|
|
440
|
+
{ name: 'Astro + TypeScript', value: 'astro-typescript' },
|
|
441
|
+
{ name: 'Python + FastAPI', value: 'python-fastapi' },
|
|
442
|
+
{ name: 'Go', value: 'go' },
|
|
443
|
+
{ name: 'Rust', value: 'rust' },
|
|
444
|
+
{ name: 'Other', value: 'other' },
|
|
445
|
+
]},
|
|
446
|
+
]);
|
|
447
|
+
const data = {
|
|
448
|
+
name: fullAnswers.name,
|
|
449
|
+
displayName: fullAnswers.displayName,
|
|
450
|
+
replacesSaas: fullAnswers.replacesSaas || undefined,
|
|
451
|
+
outputType: fullAnswers.outputType,
|
|
452
|
+
primaryStack: fullAnswers.primaryStack,
|
|
453
|
+
};
|
|
454
|
+
await mkdir(join(targetDir, 'stdlib'), { recursive: true });
|
|
455
|
+
await Promise.all([
|
|
456
|
+
writeFile(sidecarPath, SPECMARKET_YAML_TEMPLATE(data)),
|
|
457
|
+
writeFile(join(targetDir, 'spec.yaml'), SPEC_YAML_TEMPLATE(data)),
|
|
458
|
+
writeFile(join(targetDir, 'PROMPT.md'), PROMPT_MD_TEMPLATE(data)),
|
|
459
|
+
writeFile(join(targetDir, 'SPEC.md'), SPEC_MD_TEMPLATE(data)),
|
|
460
|
+
writeFile(join(targetDir, 'SUCCESS_CRITERIA.md'), SUCCESS_CRITERIA_TEMPLATE),
|
|
461
|
+
writeFile(join(targetDir, 'stdlib', 'STACK.md'), STACK_MD_TEMPLATE(fullAnswers.primaryStack)),
|
|
462
|
+
writeFile(join(targetDir, 'TASKS.md'), TASKS_MD_TEMPLATE(fullAnswers.displayName)),
|
|
463
|
+
]);
|
|
464
|
+
spinner.succeed(chalk.green(`Spec created at ${targetDir}`));
|
|
465
|
+
console.log('');
|
|
466
|
+
console.log(chalk.bold('Next steps:'));
|
|
467
|
+
console.log(` 1. Edit ${chalk.cyan('SPEC.md')} with your application requirements`);
|
|
468
|
+
console.log(` 2. Edit ${chalk.cyan('SUCCESS_CRITERIA.md')} with pass/fail criteria`);
|
|
469
|
+
console.log(` 3. Run ${chalk.cyan('specmarket validate')} then ${chalk.cyan('specmarket run')}`);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// No path: create new directory (or add sidecar if that directory already exists with content)
|
|
171
474
|
const answers = await inquirer.prompt<{
|
|
172
475
|
name: string;
|
|
173
476
|
displayName: string;
|
|
@@ -187,11 +490,8 @@ export async function handleInit(opts: {
|
|
|
187
490
|
type: 'input',
|
|
188
491
|
name: 'displayName',
|
|
189
492
|
message: 'Display name:',
|
|
190
|
-
default: (
|
|
191
|
-
|
|
192
|
-
.split('-')
|
|
193
|
-
.map((w) => w[0]?.toUpperCase() + w.slice(1))
|
|
194
|
-
.join(' '),
|
|
493
|
+
default: (ans: { name: string }) =>
|
|
494
|
+
ans.name.split('-').map((w) => w[0]?.toUpperCase() + w.slice(1)).join(' '),
|
|
195
495
|
},
|
|
196
496
|
{
|
|
197
497
|
type: 'input',
|
|
@@ -226,14 +526,44 @@ export async function handleInit(opts: {
|
|
|
226
526
|
},
|
|
227
527
|
]);
|
|
228
528
|
|
|
229
|
-
const targetDir = resolve(
|
|
529
|
+
const targetDir = resolve(answers.name);
|
|
230
530
|
const spinner = ora(`Creating spec directory at ${targetDir}...`).start();
|
|
231
531
|
|
|
232
532
|
try {
|
|
233
|
-
// Create directory structure
|
|
234
533
|
await mkdir(targetDir, { recursive: true });
|
|
235
|
-
await mkdir(join(targetDir, 'stdlib'), { recursive: true });
|
|
236
534
|
|
|
535
|
+
const sidecarPath = join(targetDir, SIDECAR_FILENAME);
|
|
536
|
+
if (await fileExists(sidecarPath)) {
|
|
537
|
+
spinner.stop();
|
|
538
|
+
console.log(chalk.yellow(`${SIDECAR_FILENAME} already exists in ${targetDir}.`));
|
|
539
|
+
console.log(chalk.gray('Run specmarket validate to check your spec, then specmarket publish to publish.'));
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const hasFiles = await dirHasFiles(targetDir);
|
|
544
|
+
if (hasFiles) {
|
|
545
|
+
const detection = await detectSpecFormat(targetDir);
|
|
546
|
+
spinner.stop();
|
|
547
|
+
console.log(chalk.gray(`Directory already has files (detected: ${detection.format}). Adding ${SIDECAR_FILENAME} only; no files overwritten.`));
|
|
548
|
+
const metadata: MetadataData = {
|
|
549
|
+
displayName: answers.displayName,
|
|
550
|
+
description: `A ${answers.outputType} spec${answers.replacesSaas ? ` that replaces ${answers.replacesSaas}` : ''}.`,
|
|
551
|
+
replacesSaas: answers.replacesSaas || undefined,
|
|
552
|
+
outputType: answers.outputType,
|
|
553
|
+
primaryStack: answers.primaryStack,
|
|
554
|
+
};
|
|
555
|
+
const yaml = buildSpecmarketYaml({ ...metadata, specFormat: detection.format });
|
|
556
|
+
await writeFile(sidecarPath, yaml);
|
|
557
|
+
console.log('');
|
|
558
|
+
console.log(chalk.green(`Added ${SIDECAR_FILENAME} to ${targetDir}`));
|
|
559
|
+
console.log('');
|
|
560
|
+
console.log(chalk.bold('Next steps:'));
|
|
561
|
+
console.log(` 1. Run ${chalk.cyan('specmarket validate')} to check your spec`);
|
|
562
|
+
console.log(` 2. Run ${chalk.cyan('specmarket publish')} to publish to the marketplace`);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
await mkdir(join(targetDir, 'stdlib'), { recursive: true });
|
|
237
567
|
const data = {
|
|
238
568
|
name: answers.name,
|
|
239
569
|
displayName: answers.displayName,
|
|
@@ -242,8 +572,8 @@ export async function handleInit(opts: {
|
|
|
242
572
|
primaryStack: answers.primaryStack,
|
|
243
573
|
};
|
|
244
574
|
|
|
245
|
-
// Write all template files
|
|
246
575
|
await Promise.all([
|
|
576
|
+
writeFile(sidecarPath, SPECMARKET_YAML_TEMPLATE(data)),
|
|
247
577
|
writeFile(join(targetDir, 'spec.yaml'), SPEC_YAML_TEMPLATE(data)),
|
|
248
578
|
writeFile(join(targetDir, 'PROMPT.md'), PROMPT_MD_TEMPLATE(data)),
|
|
249
579
|
writeFile(join(targetDir, 'SPEC.md'), SPEC_MD_TEMPLATE(data)),
|
|
@@ -253,14 +583,13 @@ export async function handleInit(opts: {
|
|
|
253
583
|
]);
|
|
254
584
|
|
|
255
585
|
spinner.succeed(chalk.green(`Spec created at ${targetDir}`));
|
|
256
|
-
|
|
257
586
|
console.log('');
|
|
258
587
|
console.log(chalk.bold('Next steps:'));
|
|
259
588
|
console.log(` 1. ${chalk.cyan(`cd ${answers.name}`)}`);
|
|
260
589
|
console.log(` 2. Edit ${chalk.cyan('SPEC.md')} with your application requirements`);
|
|
261
590
|
console.log(` 3. Edit ${chalk.cyan('SUCCESS_CRITERIA.md')} with specific pass/fail criteria`);
|
|
262
|
-
console.log(` 4. Run ${chalk.cyan(
|
|
263
|
-
console.log(` 5. Run ${chalk.cyan(
|
|
591
|
+
console.log(` 4. Run ${chalk.cyan('specmarket validate')} to check your spec`);
|
|
592
|
+
console.log(` 5. Run ${chalk.cyan('specmarket run')} to execute the spec`);
|
|
264
593
|
} catch (err) {
|
|
265
594
|
spinner.fail(chalk.red(`Failed to create spec: ${(err as Error).message}`));
|
|
266
595
|
throw err;
|
|
@@ -269,9 +598,12 @@ export async function handleInit(opts: {
|
|
|
269
598
|
|
|
270
599
|
export function createInitCommand(): Command {
|
|
271
600
|
return new Command('init')
|
|
272
|
-
.description(
|
|
601
|
+
.description(
|
|
602
|
+
'Create a new SpecMarket spec or add specmarket.yaml to an existing spec (Spec Kit, BMAD, Ralph). Use -p . to init in current directory without overwriting existing files.'
|
|
603
|
+
)
|
|
273
604
|
.option('-n, --name <name>', 'Spec name (skip prompt)')
|
|
274
|
-
.option('-p, --path <path>', 'Target directory
|
|
605
|
+
.option('-p, --path <path>', 'Target directory (e.g. . or ./my-spec). When set, detects existing format and adds only specmarket.yaml if present; no files overwritten.')
|
|
606
|
+
.option('--from <path>', 'Import an existing spec directory (Spec Kit, BMAD, Ralph, or custom markdown). Detects format and adds specmarket.yaml metadata sidecar without modifying original files. Errors if the directory is missing or empty.')
|
|
275
607
|
.action(async (opts) => {
|
|
276
608
|
try {
|
|
277
609
|
await handleInit(opts);
|