gspec 1.11.0 → 1.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -34
- package/bin/gspec.js +765 -159
- package/commands/gspec.analyze.md +1 -1
- package/commands/gspec.architect.md +2 -2
- package/commands/gspec.feature.md +2 -2
- package/commands/gspec.implement.md +1 -1
- package/commands/gspec.migrate.md +13 -10
- package/commands/gspec.practices.md +2 -2
- package/commands/gspec.profile.md +2 -2
- package/commands/gspec.research.md +4 -4
- package/commands/gspec.stack.md +2 -2
- package/commands/gspec.style.md +2 -2
- package/dist/antigravity/gspec-analyze/SKILL.md +1 -1
- package/dist/antigravity/gspec-architect/SKILL.md +2 -2
- package/dist/antigravity/gspec-feature/SKILL.md +2 -2
- package/dist/antigravity/gspec-implement/SKILL.md +1 -1
- package/dist/antigravity/gspec-migrate/SKILL.md +13 -10
- package/dist/antigravity/gspec-practices/SKILL.md +2 -2
- package/dist/antigravity/gspec-profile/SKILL.md +2 -2
- package/dist/antigravity/gspec-research/SKILL.md +4 -4
- package/dist/antigravity/gspec-stack/SKILL.md +2 -2
- package/dist/antigravity/gspec-style/SKILL.md +2 -2
- package/dist/claude/gspec-analyze/SKILL.md +1 -1
- package/dist/claude/gspec-architect/SKILL.md +2 -2
- package/dist/claude/gspec-feature/SKILL.md +2 -2
- package/dist/claude/gspec-implement/SKILL.md +1 -1
- package/dist/claude/gspec-migrate/SKILL.md +13 -10
- package/dist/claude/gspec-practices/SKILL.md +2 -2
- package/dist/claude/gspec-profile/SKILL.md +2 -2
- package/dist/claude/gspec-research/SKILL.md +4 -4
- package/dist/claude/gspec-stack/SKILL.md +2 -2
- package/dist/claude/gspec-style/SKILL.md +2 -2
- package/dist/codex/gspec-analyze/SKILL.md +1 -1
- package/dist/codex/gspec-architect/SKILL.md +2 -2
- package/dist/codex/gspec-feature/SKILL.md +2 -2
- package/dist/codex/gspec-implement/SKILL.md +1 -1
- package/dist/codex/gspec-migrate/SKILL.md +13 -10
- package/dist/codex/gspec-practices/SKILL.md +2 -2
- package/dist/codex/gspec-profile/SKILL.md +2 -2
- package/dist/codex/gspec-research/SKILL.md +4 -4
- package/dist/codex/gspec-stack/SKILL.md +2 -2
- package/dist/codex/gspec-style/SKILL.md +2 -2
- package/dist/cursor/gspec-analyze.mdc +1 -1
- package/dist/cursor/gspec-architect.mdc +2 -2
- package/dist/cursor/gspec-feature.mdc +2 -2
- package/dist/cursor/gspec-implement.mdc +1 -1
- package/dist/cursor/gspec-migrate.mdc +13 -10
- package/dist/cursor/gspec-practices.mdc +2 -2
- package/dist/cursor/gspec-profile.mdc +2 -2
- package/dist/cursor/gspec-research.mdc +4 -4
- package/dist/cursor/gspec-stack.mdc +2 -2
- package/dist/cursor/gspec-style.mdc +2 -2
- package/dist/opencode/gspec-analyze/SKILL.md +1 -1
- package/dist/opencode/gspec-architect/SKILL.md +2 -2
- package/dist/opencode/gspec-feature/SKILL.md +2 -2
- package/dist/opencode/gspec-implement/SKILL.md +1 -1
- package/dist/opencode/gspec-migrate/SKILL.md +13 -10
- package/dist/opencode/gspec-practices/SKILL.md +2 -2
- package/dist/opencode/gspec-profile/SKILL.md +2 -2
- package/dist/opencode/gspec-research/SKILL.md +4 -4
- package/dist/opencode/gspec-stack/SKILL.md +2 -2
- package/dist/opencode/gspec-style/SKILL.md +2 -2
- package/package.json +1 -2
- package/templates/spec-sync.md +1 -1
- package/starters/features/about-page.md +0 -98
- package/starters/features/contact-form.md +0 -147
- package/starters/features/contact-page.md +0 -103
- package/starters/features/home-page.md +0 -103
- package/starters/features/responsive-navbar.md +0 -113
- package/starters/features/services-page.md +0 -103
- package/starters/features/site-footer.md +0 -121
- package/starters/features/theme-switcher.md +0 -124
- package/starters/practices/tdd-pipeline-first.md +0 -192
- package/starters/stacks/astro-tailwind-github-pages.md +0 -283
- package/starters/stacks/nextjs-supabase-vercel.md +0 -319
- package/starters/stacks/nextjs-vercel-typescript.md +0 -264
- package/starters/styles/clean-professional.md +0 -316
- package/starters/styles/dark-minimal-developer.md +0 -442
package/bin/gspec.js
CHANGED
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
import { program } from 'commander';
|
|
4
4
|
import { readdir, readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
|
5
|
-
import { join, dirname } from 'node:path';
|
|
5
|
+
import { join, dirname, basename } from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
7
8
|
import { createInterface } from 'node:readline';
|
|
8
9
|
import chalk from 'chalk';
|
|
9
10
|
|
|
10
11
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
12
|
const DIST_DIR = join(__dirname, '..', 'dist');
|
|
12
|
-
const STARTERS_DIR = join(__dirname, '..', 'starters');
|
|
13
13
|
const pkg = JSON.parse(await readFile(join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
14
|
+
const SPEC_VERSION = 'v1';
|
|
14
15
|
|
|
15
16
|
const BANNER = `
|
|
16
17
|
${chalk.cyan('╔══════════════════════════════════════════════╗')}
|
|
@@ -117,67 +118,6 @@ function promptConfirmNo(message) {
|
|
|
117
118
|
});
|
|
118
119
|
}
|
|
119
120
|
|
|
120
|
-
// --- Feature dependency map ---
|
|
121
|
-
// Maps feature slugs to their required feature dependencies (other feature slugs).
|
|
122
|
-
// This is used to auto-include dependencies when a user selects a feature.
|
|
123
|
-
const FEATURE_DEPENDENCIES = {
|
|
124
|
-
'home-page': [],
|
|
125
|
-
'about-page': ['home-page'],
|
|
126
|
-
'contact-page': ['home-page'],
|
|
127
|
-
'services-page': ['home-page'],
|
|
128
|
-
'responsive-navbar': ['home-page'],
|
|
129
|
-
'contact-form': ['contact-page'],
|
|
130
|
-
'site-footer': ['home-page', 'about-page', 'contact-page'],
|
|
131
|
-
'theme-switcher': ['home-page', 'about-page', 'contact-page'],
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
function resolveFeatureDependencies(selectedSlugs) {
|
|
135
|
-
const resolved = new Set(selectedSlugs);
|
|
136
|
-
let changed = true;
|
|
137
|
-
while (changed) {
|
|
138
|
-
changed = false;
|
|
139
|
-
for (const slug of [...resolved]) {
|
|
140
|
-
const deps = FEATURE_DEPENDENCIES[slug] || [];
|
|
141
|
-
for (const dep of deps) {
|
|
142
|
-
if (!resolved.has(dep)) {
|
|
143
|
-
resolved.add(dep);
|
|
144
|
-
changed = true;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
return [...resolved];
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// --- Starter template utilities ---
|
|
153
|
-
|
|
154
|
-
async function parseStarterDescription(filePath) {
|
|
155
|
-
const content = await readFile(filePath, 'utf-8');
|
|
156
|
-
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
157
|
-
if (!match) return '';
|
|
158
|
-
const descLine = match[1].split('\n').find((l) => l.startsWith('description:'));
|
|
159
|
-
return descLine ? descLine.replace(/^description:\s*/, '').trim() : '';
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
async function listStarterTemplates(category) {
|
|
163
|
-
const dir = join(STARTERS_DIR, category);
|
|
164
|
-
let entries;
|
|
165
|
-
try {
|
|
166
|
-
entries = await readdir(dir);
|
|
167
|
-
} catch (e) {
|
|
168
|
-
if (e.code === 'ENOENT') return [];
|
|
169
|
-
throw e;
|
|
170
|
-
}
|
|
171
|
-
const mdFiles = entries.filter((f) => f.endsWith('.md'));
|
|
172
|
-
const templates = [];
|
|
173
|
-
for (const f of mdFiles) {
|
|
174
|
-
const slug = f.replace(/\.md$/, '');
|
|
175
|
-
const description = await parseStarterDescription(join(dir, f));
|
|
176
|
-
templates.push({ slug, description });
|
|
177
|
-
}
|
|
178
|
-
return templates;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
121
|
function formatStarterName(slug) {
|
|
182
122
|
if (slug === '_none') return 'None';
|
|
183
123
|
return slug
|
|
@@ -186,10 +126,6 @@ function formatStarterName(slug) {
|
|
|
186
126
|
.join(' ');
|
|
187
127
|
}
|
|
188
128
|
|
|
189
|
-
function stampVersion(content) {
|
|
190
|
-
return content.replace(/^(---\s*\n[\s\S]*?)gspec-version:\s*.+/m, `$1gspec-version: ${pkg.version}`);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
129
|
function promptSelect(message, choices) {
|
|
194
130
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
195
131
|
console.log(chalk.bold(`\n${message}\n`));
|
|
@@ -239,74 +175,119 @@ function promptMultiSelect(message, choices) {
|
|
|
239
175
|
});
|
|
240
176
|
}
|
|
241
177
|
|
|
242
|
-
async function
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const stacks = await listStarterTemplates('stacks');
|
|
251
|
-
const styles = await listStarterTemplates('styles');
|
|
252
|
-
const features = await listStarterTemplates('features');
|
|
178
|
+
async function seedFromSavedSpecs(cwd) {
|
|
179
|
+
// Skip if gspec specs already exist in the project
|
|
180
|
+
try {
|
|
181
|
+
const existingFiles = await collectGspecFiles(join(cwd, 'gspec'));
|
|
182
|
+
if (existingFiles.length > 0) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
} catch {}
|
|
253
186
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
187
|
+
// Check if ~/.gspec/ has any saved specs or playbooks
|
|
188
|
+
const gspecHome = join(homedir(), '.gspec');
|
|
189
|
+
let hasPlaybooks = false;
|
|
190
|
+
let playbooks = [];
|
|
191
|
+
try {
|
|
192
|
+
const pbEntries = await readdir(join(gspecHome, 'playbooks'));
|
|
193
|
+
playbooks = pbEntries.filter((f) => f.endsWith('.md'));
|
|
194
|
+
hasPlaybooks = playbooks.length > 0;
|
|
195
|
+
} catch {}
|
|
258
196
|
|
|
259
|
-
|
|
197
|
+
let savedTypes = [];
|
|
198
|
+
try {
|
|
199
|
+
const entries = await readdir(gspecHome);
|
|
200
|
+
for (const entry of entries) {
|
|
201
|
+
if (entry === 'playbooks') continue;
|
|
202
|
+
try {
|
|
203
|
+
const info = await stat(join(gspecHome, entry));
|
|
204
|
+
if (info.isDirectory()) {
|
|
205
|
+
const files = await readdir(join(gspecHome, entry));
|
|
206
|
+
if (files.filter((f) => f.endsWith('.md')).length > 0) {
|
|
207
|
+
savedTypes.push(entry);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} catch {}
|
|
211
|
+
}
|
|
212
|
+
} catch {}
|
|
260
213
|
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
-
? (console.log(chalk.dim(`\n Using practice: ${formatStarterName(practices[0].slug)}`)), practices[0].slug)
|
|
264
|
-
: await promptSelect('Select a development practice', [...practices, NONE_OPTION]);
|
|
214
|
+
// Nothing saved — skip silently
|
|
215
|
+
if (!hasPlaybooks && savedTypes.length === 0) return;
|
|
265
216
|
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
217
|
+
const wantSaved = await promptConfirm(chalk.bold(' Would you like to start from saved specs in ~/.gspec/? [y/N]: '));
|
|
218
|
+
if (!wantSaved) {
|
|
219
|
+
console.log(chalk.dim('\n Skipped saved specs.\n'));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
269
222
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
223
|
+
// If playbooks exist, offer them first
|
|
224
|
+
if (hasPlaybooks) {
|
|
225
|
+
const playbookChoices = [];
|
|
226
|
+
for (const f of playbooks) {
|
|
227
|
+
const slug = f.replace(/\.md$/, '');
|
|
228
|
+
const content = await readFile(join(gspecHome, 'playbooks', f), 'utf-8');
|
|
229
|
+
const { fields } = parseFrontmatter(content);
|
|
230
|
+
playbookChoices.push({ slug, description: fields.description || '' });
|
|
231
|
+
}
|
|
273
232
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
selectedFeatures = await promptMultiSelect('Select features (optional)', [...features, NONE_OPTION]);
|
|
277
|
-
selectedFeatures = selectedFeatures.filter(f => f !== '_none');
|
|
278
|
-
}
|
|
233
|
+
const INDIVIDUAL_OPTION = { slug: '_individual', description: 'Pick individual specs instead' };
|
|
234
|
+
const selected = await promptSelect('Select a playbook', [...playbookChoices, INDIVIDUAL_OPTION]);
|
|
279
235
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const added = resolved.filter((f) => !selectedFeatures.includes(f));
|
|
284
|
-
if (added.length > 0) {
|
|
285
|
-
console.log(chalk.cyan(`\n Auto-including dependencies: ${added.map(formatStarterName).join(', ')}`));
|
|
286
|
-
selectedFeatures = resolved;
|
|
236
|
+
if (selected !== '_individual') {
|
|
237
|
+
await restorePlaybook(selected, cwd);
|
|
238
|
+
return;
|
|
287
239
|
}
|
|
288
240
|
}
|
|
289
241
|
|
|
290
|
-
//
|
|
242
|
+
// Individual spec selection from ~/.gspec/
|
|
243
|
+
const NONE_OPTION = { slug: '_none', description: 'Skip' };
|
|
291
244
|
const gspecDir = join(cwd, 'gspec');
|
|
292
245
|
const filesToWrite = [];
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
246
|
+
|
|
247
|
+
const CATEGORY_ORDER = [
|
|
248
|
+
{ type: 'profiles', label: 'Select a profile', dest: 'profile.md', mode: 'single' },
|
|
249
|
+
{ type: 'practices', label: 'Select practices', dest: 'practices.md', mode: 'single' },
|
|
250
|
+
{ type: 'stacks', label: 'Select a stack', dest: 'stack.md', mode: 'single' },
|
|
251
|
+
{ type: 'styles', label: 'Select a style', dest: 'style.md', mode: 'single' },
|
|
252
|
+
{ type: 'features', label: 'Select features (optional)', dest: null, mode: 'multi' },
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
for (const cat of CATEGORY_ORDER) {
|
|
256
|
+
if (!savedTypes.includes(cat.type)) continue;
|
|
257
|
+
|
|
258
|
+
const specs = await listSavedSpecs(cat.type);
|
|
259
|
+
if (specs.length === 0) continue;
|
|
260
|
+
|
|
261
|
+
if (cat.mode === 'single') {
|
|
262
|
+
const selected = specs.length === 1
|
|
263
|
+
? (console.log(chalk.dim(`\n Using ${cat.type}: ${formatStarterName(specs[0].slug)}`)), specs[0].slug)
|
|
264
|
+
: await promptSelect(cat.label, [...specs, NONE_OPTION]);
|
|
265
|
+
|
|
266
|
+
if (selected !== '_none') {
|
|
267
|
+
filesToWrite.push({
|
|
268
|
+
src: join(gspecHome, cat.type, `${selected}.md`),
|
|
269
|
+
dest: join(gspecDir, cat.dest),
|
|
270
|
+
label: `gspec/${cat.dest}`,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
let selectedSlugs = await promptMultiSelect(cat.label, specs);
|
|
275
|
+
for (const slug of selectedSlugs) {
|
|
276
|
+
filesToWrite.push({
|
|
277
|
+
src: join(gspecHome, cat.type, `${slug}.md`),
|
|
278
|
+
dest: join(gspecDir, 'features', `${slug}.md`),
|
|
279
|
+
label: `gspec/features/${slug}.md`,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
301
283
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
label: `gspec/features/${feature}.md`,
|
|
307
|
-
});
|
|
284
|
+
|
|
285
|
+
if (filesToWrite.length === 0) {
|
|
286
|
+
console.log(chalk.dim('\n No specs selected. You can define specs using gspec commands.\n'));
|
|
287
|
+
return;
|
|
308
288
|
}
|
|
309
289
|
|
|
290
|
+
// Check for existing files
|
|
310
291
|
const existingFiles = [];
|
|
311
292
|
for (const f of filesToWrite) {
|
|
312
293
|
try {
|
|
@@ -317,11 +298,6 @@ async function seedStarterTemplates(cwd) {
|
|
|
317
298
|
}
|
|
318
299
|
}
|
|
319
300
|
|
|
320
|
-
if (filesToWrite.length === 0) {
|
|
321
|
-
console.log(chalk.dim('\n No starter templates selected. You can define these files using gspec commands.\n'));
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
301
|
if (existingFiles.length > 0) {
|
|
326
302
|
console.log(chalk.yellow(`\n The following files already exist and will be overwritten:\n`));
|
|
327
303
|
for (const label of existingFiles) {
|
|
@@ -330,31 +306,36 @@ async function seedStarterTemplates(cwd) {
|
|
|
330
306
|
console.log();
|
|
331
307
|
const confirmed = await promptConfirm(chalk.bold(' Continue and overwrite? [y/N]: '));
|
|
332
308
|
if (!confirmed) {
|
|
333
|
-
console.log(chalk.dim('\n Skipped
|
|
309
|
+
console.log(chalk.dim('\n Skipped saved specs.\n'));
|
|
334
310
|
return;
|
|
335
311
|
}
|
|
336
312
|
}
|
|
337
313
|
|
|
338
|
-
// Copy files
|
|
339
|
-
console.log(chalk.bold('\n
|
|
314
|
+
// Copy files
|
|
315
|
+
console.log(chalk.bold('\n Restoring saved specs...\n'));
|
|
316
|
+
const outdated = [];
|
|
340
317
|
for (const f of filesToWrite) {
|
|
341
318
|
await mkdir(dirname(f.dest), { recursive: true });
|
|
342
319
|
const content = await readFile(f.src, 'utf-8');
|
|
343
|
-
await writeFile(f.dest,
|
|
320
|
+
await writeFile(f.dest, content, 'utf-8');
|
|
344
321
|
console.log(` ${chalk.green('+')} ${f.label}`);
|
|
322
|
+
|
|
323
|
+
const version = parseSpecVersion(content);
|
|
324
|
+
if (version && version !== SPEC_VERSION) {
|
|
325
|
+
outdated.push({ label: f.label, version });
|
|
326
|
+
}
|
|
345
327
|
}
|
|
346
328
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
console.log(`
|
|
329
|
+
console.log(chalk.green(`\n ✓ Restored ${filesToWrite.length} spec${filesToWrite.length === 1 ? '' : 's'} into gspec/\n`));
|
|
330
|
+
|
|
331
|
+
if (outdated.length > 0) {
|
|
332
|
+
console.log(chalk.yellow(' ⚠ The following restored specs are outdated:\n'));
|
|
333
|
+
for (const o of outdated) {
|
|
334
|
+
console.log(` ${chalk.yellow('!')} ${o.label} — version ${o.version} (current: ${SPEC_VERSION})`);
|
|
335
|
+
}
|
|
336
|
+
console.log();
|
|
337
|
+
console.log(chalk.yellow(` Run ${chalk.bold('/gspec-migrate')} to update them to the current format.\n`));
|
|
356
338
|
}
|
|
357
|
-
console.log();
|
|
358
339
|
}
|
|
359
340
|
|
|
360
341
|
async function findExistingFiles(target, cwd) {
|
|
@@ -568,11 +549,14 @@ const MIGRATE_COMMANDS = {
|
|
|
568
549
|
opencode: '/gspec-migrate',
|
|
569
550
|
};
|
|
570
551
|
|
|
571
|
-
function
|
|
552
|
+
function parseSpecVersion(content) {
|
|
572
553
|
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
573
554
|
if (!match) return null;
|
|
574
|
-
const
|
|
575
|
-
|
|
555
|
+
const newMatch = match[1].match(/^spec-version:\s*(.+)$/m);
|
|
556
|
+
if (newMatch) return newMatch[1].trim();
|
|
557
|
+
const oldMatch = match[1].match(/^gspec-version:\s*(.+)$/m);
|
|
558
|
+
if (oldMatch) return oldMatch[1].trim();
|
|
559
|
+
return null;
|
|
576
560
|
}
|
|
577
561
|
|
|
578
562
|
async function collectGspecFiles(gspecDir) {
|
|
@@ -617,8 +601,8 @@ async function checkGspecFiles(cwd, targetName) {
|
|
|
617
601
|
const outdated = [];
|
|
618
602
|
for (const file of files) {
|
|
619
603
|
const content = await readFile(file.path, 'utf-8');
|
|
620
|
-
const version =
|
|
621
|
-
if (version ===
|
|
604
|
+
const version = parseSpecVersion(content);
|
|
605
|
+
if (version === SPEC_VERSION) continue;
|
|
622
606
|
outdated.push({
|
|
623
607
|
label: file.label,
|
|
624
608
|
version,
|
|
@@ -630,8 +614,8 @@ async function checkGspecFiles(cwd, targetName) {
|
|
|
630
614
|
console.log(chalk.yellow(` Found existing gspec files that may need updating:\n`));
|
|
631
615
|
for (const file of outdated) {
|
|
632
616
|
const status = file.version
|
|
633
|
-
? `version ${file.version} (current: ${
|
|
634
|
-
: `no version (pre-${
|
|
617
|
+
? `version ${file.version} (current: ${SPEC_VERSION})`
|
|
618
|
+
: `no version (pre-${SPEC_VERSION})`;
|
|
635
619
|
console.log(` ${chalk.yellow('!')} ${file.label} — ${status}`);
|
|
636
620
|
}
|
|
637
621
|
console.log();
|
|
@@ -644,6 +628,600 @@ async function checkGspecFiles(cwd, targetName) {
|
|
|
644
628
|
console.log(` ${chalk.cyan(cmd)}\n`);
|
|
645
629
|
}
|
|
646
630
|
|
|
631
|
+
// --- Save / Restore ---
|
|
632
|
+
|
|
633
|
+
const GSPEC_HOME = join(homedir(), '.gspec');
|
|
634
|
+
|
|
635
|
+
// Map gspec/ file paths to save/restore type folders
|
|
636
|
+
const GSPEC_TYPE_MAP = {
|
|
637
|
+
'profile.md': 'profiles',
|
|
638
|
+
'stack.md': 'stacks',
|
|
639
|
+
'style.md': 'styles',
|
|
640
|
+
'practices.md': 'practices',
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
// Reverse: restore type folder → gspec/ destination filename
|
|
644
|
+
const RESTORE_DEST_MAP = {
|
|
645
|
+
profiles: 'profile.md',
|
|
646
|
+
stacks: 'stack.md',
|
|
647
|
+
styles: 'style.md',
|
|
648
|
+
practices: 'practices.md',
|
|
649
|
+
features: null, // features keep their own filename
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
function parseFrontmatter(content) {
|
|
653
|
+
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
654
|
+
if (!match) return { fields: {}, body: content };
|
|
655
|
+
const fields = {};
|
|
656
|
+
for (const line of match[1].split('\n')) {
|
|
657
|
+
const idx = line.indexOf(':');
|
|
658
|
+
if (idx > 0) {
|
|
659
|
+
fields[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return { fields, body: content.slice(match[0].length) };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function setFrontmatterField(content, key, value) {
|
|
666
|
+
const match = content.match(/^(---\s*\n)([\s\S]*?)(\n---)/);
|
|
667
|
+
if (!match) {
|
|
668
|
+
// No frontmatter — create one
|
|
669
|
+
return `---\n${key}: ${value}\n---\n${content}`;
|
|
670
|
+
}
|
|
671
|
+
const lines = match[2].split('\n');
|
|
672
|
+
const existing = lines.findIndex((l) => l.startsWith(`${key}:`));
|
|
673
|
+
if (existing >= 0) {
|
|
674
|
+
lines[existing] = `${key}: ${value}`;
|
|
675
|
+
} else {
|
|
676
|
+
// Insert name as first field
|
|
677
|
+
lines.unshift(`${key}: ${value}`);
|
|
678
|
+
}
|
|
679
|
+
return `${match[1]}${lines.join('\n')}${match[3]}${content.slice(match[0].length)}`;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function promptInput(message) {
|
|
683
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
684
|
+
return new Promise((resolve) => {
|
|
685
|
+
rl.question(message, (answer) => {
|
|
686
|
+
rl.close();
|
|
687
|
+
resolve(answer.trim());
|
|
688
|
+
});
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function collectSavableFiles(cwd) {
|
|
693
|
+
const gspecDir = join(cwd, 'gspec');
|
|
694
|
+
const files = [];
|
|
695
|
+
|
|
696
|
+
try {
|
|
697
|
+
await stat(gspecDir);
|
|
698
|
+
} catch (e) {
|
|
699
|
+
if (e.code === 'ENOENT') return files;
|
|
700
|
+
throw e;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Top-level spec files
|
|
704
|
+
const topEntries = await readdir(gspecDir);
|
|
705
|
+
for (const entry of topEntries) {
|
|
706
|
+
if (!entry.endsWith('.md') || entry.toLowerCase() === 'readme.md') continue;
|
|
707
|
+
const type = GSPEC_TYPE_MAP[entry];
|
|
708
|
+
if (!type) continue;
|
|
709
|
+
files.push({
|
|
710
|
+
path: join(gspecDir, entry),
|
|
711
|
+
type,
|
|
712
|
+
label: `gspec/${entry}`,
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Feature files
|
|
717
|
+
try {
|
|
718
|
+
const featureEntries = await readdir(join(gspecDir, 'features'));
|
|
719
|
+
for (const entry of featureEntries) {
|
|
720
|
+
if (!entry.endsWith('.md')) continue;
|
|
721
|
+
files.push({
|
|
722
|
+
path: join(gspecDir, 'features', entry),
|
|
723
|
+
type: 'features',
|
|
724
|
+
label: `gspec/features/${entry}`,
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
} catch (e) {
|
|
728
|
+
if (e.code !== 'ENOENT') throw e;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
return files;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function saveSpec(cwd) {
|
|
735
|
+
console.log(BANNER);
|
|
736
|
+
|
|
737
|
+
const files = await collectSavableFiles(cwd);
|
|
738
|
+
if (files.length === 0) {
|
|
739
|
+
console.error(chalk.red('\n No gspec files found in gspec/ directory.\n'));
|
|
740
|
+
process.exit(1);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Let user select which file to save
|
|
744
|
+
console.log(chalk.bold('\n Which spec would you like to save?\n'));
|
|
745
|
+
for (let i = 0; i < files.length; i++) {
|
|
746
|
+
const content = await readFile(files[i].path, 'utf-8');
|
|
747
|
+
const { fields } = parseFrontmatter(content);
|
|
748
|
+
const desc = fields.description ? ` — ${fields.description}` : '';
|
|
749
|
+
console.log(` ${chalk.cyan(String(i + 1))}) ${files[i].label}${chalk.dim(desc)}`);
|
|
750
|
+
}
|
|
751
|
+
console.log();
|
|
752
|
+
|
|
753
|
+
const answer = await promptInput(chalk.bold(` Select [1-${files.length}]: `));
|
|
754
|
+
const num = parseInt(answer, 10);
|
|
755
|
+
if (isNaN(num) || num < 1 || num > files.length) {
|
|
756
|
+
console.error(chalk.red(`\n Invalid selection: "${answer}"`));
|
|
757
|
+
process.exit(1);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const selected = files[num - 1];
|
|
761
|
+
|
|
762
|
+
// Prompt for name
|
|
763
|
+
const name = await promptInput(chalk.bold('\n Save name (no spaces, e.g. my-saas-stack): '));
|
|
764
|
+
if (!name) {
|
|
765
|
+
console.error(chalk.red('\n Name is required.'));
|
|
766
|
+
process.exit(1);
|
|
767
|
+
}
|
|
768
|
+
if (/\s/.test(name)) {
|
|
769
|
+
console.error(chalk.red('\n Name cannot contain spaces. Use hyphens instead (e.g. my-saas-stack).'));
|
|
770
|
+
process.exit(1);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Read content and update frontmatter with name
|
|
774
|
+
let content = await readFile(selected.path, 'utf-8');
|
|
775
|
+
content = setFrontmatterField(content, 'name', name);
|
|
776
|
+
|
|
777
|
+
// Ensure description exists
|
|
778
|
+
const { fields } = parseFrontmatter(content);
|
|
779
|
+
if (!fields.description) {
|
|
780
|
+
const desc = await promptInput(chalk.bold(' Description (short summary): '));
|
|
781
|
+
if (desc) {
|
|
782
|
+
content = setFrontmatterField(content, 'description', desc);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Write to ~/.gspec/{type}/{name}.md
|
|
787
|
+
const destDir = join(GSPEC_HOME, selected.type);
|
|
788
|
+
const destPath = join(destDir, `${name}.md`);
|
|
789
|
+
await mkdir(destDir, { recursive: true });
|
|
790
|
+
|
|
791
|
+
// Check if file already exists
|
|
792
|
+
try {
|
|
793
|
+
await stat(destPath);
|
|
794
|
+
const overwrite = await promptConfirm(chalk.yellow(`\n ${selected.type}/${name} already exists. Overwrite? [y/N]: `));
|
|
795
|
+
if (!overwrite) {
|
|
796
|
+
console.log(chalk.dim('\n Save cancelled.\n'));
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
} catch (e) {
|
|
800
|
+
if (e.code !== 'ENOENT') throw e;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Uncheck all implementation checkboxes so saved specs start fresh
|
|
804
|
+
content = content.replace(/- \[x\]/g, '- [ ]');
|
|
805
|
+
|
|
806
|
+
await writeFile(destPath, content, 'utf-8');
|
|
807
|
+
console.log(chalk.green(`\n ✓ Saved to ~/.gspec/${selected.type}/${name}.md\n`));
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
async function listSavedTypes() {
|
|
811
|
+
const types = [];
|
|
812
|
+
try {
|
|
813
|
+
const entries = await readdir(GSPEC_HOME);
|
|
814
|
+
for (const entry of entries) {
|
|
815
|
+
try {
|
|
816
|
+
const info = await stat(join(GSPEC_HOME, entry));
|
|
817
|
+
if (info.isDirectory()) {
|
|
818
|
+
const files = await readdir(join(GSPEC_HOME, entry));
|
|
819
|
+
const mdFiles = files.filter((f) => f.endsWith('.md'));
|
|
820
|
+
if (mdFiles.length > 0) types.push(entry);
|
|
821
|
+
}
|
|
822
|
+
} catch { /* skip */ }
|
|
823
|
+
}
|
|
824
|
+
} catch (e) {
|
|
825
|
+
if (e.code === 'ENOENT') return types;
|
|
826
|
+
throw e;
|
|
827
|
+
}
|
|
828
|
+
return types;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async function listSavedSpecs(type) {
|
|
832
|
+
const dir = join(GSPEC_HOME, type);
|
|
833
|
+
const entries = await readdir(dir);
|
|
834
|
+
const specs = [];
|
|
835
|
+
for (const entry of entries) {
|
|
836
|
+
if (!entry.endsWith('.md')) continue;
|
|
837
|
+
const content = await readFile(join(dir, entry), 'utf-8');
|
|
838
|
+
const { fields } = parseFrontmatter(content);
|
|
839
|
+
specs.push({
|
|
840
|
+
slug: entry.replace(/\.md$/, ''),
|
|
841
|
+
description: fields.description || '',
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
return specs;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
async function restoreSpec(specPath, cwd) {
|
|
848
|
+
console.log(BANNER);
|
|
849
|
+
|
|
850
|
+
if (specPath) {
|
|
851
|
+
// Direct restore: e.g. "stacks/web", "features/auth-flow", or "playbook/my-starter"
|
|
852
|
+
const parts = specPath.split('/');
|
|
853
|
+
if (parts.length !== 2) {
|
|
854
|
+
console.error(chalk.red(`\n Invalid format. Use: type/name (e.g. stacks/my-stack, playbook/my-starter)\n`));
|
|
855
|
+
process.exit(1);
|
|
856
|
+
}
|
|
857
|
+
const [type, name] = parts;
|
|
858
|
+
if (type === 'playbook' || type === 'playbooks') {
|
|
859
|
+
await restorePlaybook(name, cwd);
|
|
860
|
+
} else {
|
|
861
|
+
await restoreFile(type, name, cwd);
|
|
862
|
+
}
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Interactive: pick type, then file
|
|
867
|
+
const types = await listSavedTypes();
|
|
868
|
+
if (types.length === 0) {
|
|
869
|
+
console.error(chalk.red('\n No saved specs found in ~/.gspec/'));
|
|
870
|
+
console.error(chalk.dim(' Use "gspec save" to save specs first.\n'));
|
|
871
|
+
process.exit(1);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
console.log(chalk.bold('\n Select a spec type:\n'));
|
|
875
|
+
for (let i = 0; i < types.length; i++) {
|
|
876
|
+
console.log(` ${chalk.cyan(String(i + 1))}) ${types[i]}`);
|
|
877
|
+
}
|
|
878
|
+
console.log();
|
|
879
|
+
|
|
880
|
+
const typeAnswer = await promptInput(chalk.bold(` Select [1-${types.length}]: `));
|
|
881
|
+
const typeNum = parseInt(typeAnswer, 10);
|
|
882
|
+
if (isNaN(typeNum) || typeNum < 1 || typeNum > types.length) {
|
|
883
|
+
console.error(chalk.red(`\n Invalid selection: "${typeAnswer}"`));
|
|
884
|
+
process.exit(1);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const selectedType = types[typeNum - 1];
|
|
888
|
+
const specs = await listSavedSpecs(selectedType);
|
|
889
|
+
|
|
890
|
+
if (specs.length === 0) {
|
|
891
|
+
console.error(chalk.red(`\n No specs found in ~/.gspec/${selectedType}/\n`));
|
|
892
|
+
process.exit(1);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
console.log(chalk.bold(`\n Select a spec from ${selectedType}:\n`));
|
|
896
|
+
for (let i = 0; i < specs.length; i++) {
|
|
897
|
+
const desc = specs[i].description ? ` — ${specs[i].description}` : '';
|
|
898
|
+
console.log(` ${chalk.cyan(String(i + 1))}) ${specs[i].slug}${chalk.dim(desc)}`);
|
|
899
|
+
}
|
|
900
|
+
console.log();
|
|
901
|
+
|
|
902
|
+
const specAnswer = await promptInput(chalk.bold(` Select [1-${specs.length}]: `));
|
|
903
|
+
const specNum = parseInt(specAnswer, 10);
|
|
904
|
+
if (isNaN(specNum) || specNum < 1 || specNum > specs.length) {
|
|
905
|
+
console.error(chalk.red(`\n Invalid selection: "${specAnswer}"`));
|
|
906
|
+
process.exit(1);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
await restoreFile(selectedType, specs[specNum - 1].slug, cwd);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
async function restoreFile(type, name, cwd) {
|
|
913
|
+
const srcPath = join(GSPEC_HOME, type, `${name}.md`);
|
|
914
|
+
|
|
915
|
+
try {
|
|
916
|
+
await stat(srcPath);
|
|
917
|
+
} catch (e) {
|
|
918
|
+
if (e.code === 'ENOENT') {
|
|
919
|
+
console.error(chalk.red(`\n Not found: ~/.gspec/${type}/${name}.md\n`));
|
|
920
|
+
process.exit(1);
|
|
921
|
+
}
|
|
922
|
+
throw e;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const gspecDir = join(cwd, 'gspec');
|
|
926
|
+
let destPath;
|
|
927
|
+
|
|
928
|
+
if (type === 'features') {
|
|
929
|
+
destPath = join(gspecDir, 'features', `${name}.md`);
|
|
930
|
+
} else {
|
|
931
|
+
const destFile = RESTORE_DEST_MAP[type];
|
|
932
|
+
if (!destFile) {
|
|
933
|
+
console.error(chalk.red(`\n Unknown spec type: ${type}\n`));
|
|
934
|
+
process.exit(1);
|
|
935
|
+
}
|
|
936
|
+
destPath = join(gspecDir, destFile);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Check for existing file
|
|
940
|
+
try {
|
|
941
|
+
await stat(destPath);
|
|
942
|
+
const relPath = destPath.slice(cwd.length + 1);
|
|
943
|
+
const overwrite = await promptConfirm(chalk.yellow(`\n ${relPath} already exists. Overwrite? [y/N]: `));
|
|
944
|
+
if (!overwrite) {
|
|
945
|
+
console.log(chalk.dim('\n Restore cancelled.\n'));
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
} catch (e) {
|
|
949
|
+
if (e.code !== 'ENOENT') throw e;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
953
|
+
const content = await readFile(srcPath, 'utf-8');
|
|
954
|
+
await writeFile(destPath, content, 'utf-8');
|
|
955
|
+
|
|
956
|
+
const relDest = destPath.slice(cwd.length + 1);
|
|
957
|
+
console.log(chalk.green(`\n ✓ Restored ${type}/${name} → ${relDest}\n`));
|
|
958
|
+
|
|
959
|
+
const version = parseSpecVersion(content);
|
|
960
|
+
if (version && version !== SPEC_VERSION) {
|
|
961
|
+
console.log(chalk.yellow(` ⚠ ${relDest} is version ${version} (current: ${SPEC_VERSION})`));
|
|
962
|
+
console.log(chalk.yellow(` Run ${chalk.bold('/gspec-migrate')} to update it to the current format.\n`));
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// --- Playbooks ---
|
|
967
|
+
|
|
968
|
+
async function listSavedSpecsSafe(type) {
|
|
969
|
+
try {
|
|
970
|
+
return await listSavedSpecs(type);
|
|
971
|
+
} catch (e) {
|
|
972
|
+
if (e.code === 'ENOENT') return [];
|
|
973
|
+
throw e;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
async function createPlaybook() {
|
|
978
|
+
console.log(BANNER);
|
|
979
|
+
console.log(chalk.bold('\n Create a playbook\n'));
|
|
980
|
+
console.log(chalk.dim(' A playbook bundles saved specs so you can restore them all at once.\n'));
|
|
981
|
+
|
|
982
|
+
const NONE_OPTION = { slug: '_none', description: 'Skip' };
|
|
983
|
+
|
|
984
|
+
// --- Profile (0 or 1) ---
|
|
985
|
+
const profiles = await listSavedSpecsSafe('profiles');
|
|
986
|
+
let profile = null;
|
|
987
|
+
if (profiles.length > 0) {
|
|
988
|
+
profile = await promptSelect('Select a profile (or skip)', [...profiles, NONE_OPTION]);
|
|
989
|
+
if (profile === '_none') profile = null;
|
|
990
|
+
} else {
|
|
991
|
+
console.log(chalk.dim(' No saved profiles found — skipping.\n'));
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// --- Stack (0 or 1) ---
|
|
995
|
+
const stacks = await listSavedSpecsSafe('stacks');
|
|
996
|
+
let stack = null;
|
|
997
|
+
if (stacks.length > 0) {
|
|
998
|
+
stack = await promptSelect('Select a stack (or skip)', [...stacks, NONE_OPTION]);
|
|
999
|
+
if (stack === '_none') stack = null;
|
|
1000
|
+
} else {
|
|
1001
|
+
console.log(chalk.dim(' No saved stacks found — skipping.\n'));
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// --- Practices (0 or 1) ---
|
|
1005
|
+
const practices = await listSavedSpecsSafe('practices');
|
|
1006
|
+
let practice = null;
|
|
1007
|
+
if (practices.length > 0) {
|
|
1008
|
+
practice = await promptSelect('Select practices (or skip)', [...practices, NONE_OPTION]);
|
|
1009
|
+
if (practice === '_none') practice = null;
|
|
1010
|
+
} else {
|
|
1011
|
+
console.log(chalk.dim(' No saved practices found — skipping.\n'));
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// --- Style (0 or 1) ---
|
|
1015
|
+
const styles = await listSavedSpecsSafe('styles');
|
|
1016
|
+
let style = null;
|
|
1017
|
+
if (styles.length > 0) {
|
|
1018
|
+
style = await promptSelect('Select a style (or skip)', [...styles, NONE_OPTION]);
|
|
1019
|
+
if (style === '_none') style = null;
|
|
1020
|
+
} else {
|
|
1021
|
+
console.log(chalk.dim(' No saved styles found — skipping.\n'));
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// --- Features (0 to many) ---
|
|
1025
|
+
const features = await listSavedSpecsSafe('features');
|
|
1026
|
+
let selectedFeatures = [];
|
|
1027
|
+
if (features.length > 0) {
|
|
1028
|
+
selectedFeatures = await promptMultiSelect('Select features (optional)', features);
|
|
1029
|
+
} else {
|
|
1030
|
+
console.log(chalk.dim(' No saved features found — skipping.\n'));
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Check that at least one spec was selected
|
|
1034
|
+
if (!profile && !stack && !practice && !style && selectedFeatures.length === 0) {
|
|
1035
|
+
console.error(chalk.red('\n No specs selected. Playbook not created.\n'));
|
|
1036
|
+
process.exit(1);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Prompt for playbook name
|
|
1040
|
+
const name = await promptInput(chalk.bold('\n Playbook name (no spaces, e.g. my-saas-starter): '));
|
|
1041
|
+
if (!name) {
|
|
1042
|
+
console.error(chalk.red('\n Name is required.'));
|
|
1043
|
+
process.exit(1);
|
|
1044
|
+
}
|
|
1045
|
+
if (/\s/.test(name)) {
|
|
1046
|
+
console.error(chalk.red('\n Name cannot contain spaces. Use hyphens instead (e.g. my-saas-starter).'));
|
|
1047
|
+
process.exit(1);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Prompt for description
|
|
1051
|
+
const description = await promptInput(chalk.bold(' Description (short summary): '));
|
|
1052
|
+
|
|
1053
|
+
// Build playbook content
|
|
1054
|
+
const lines = ['---'];
|
|
1055
|
+
lines.push(`name: ${name}`);
|
|
1056
|
+
if (description) lines.push(`description: ${description}`);
|
|
1057
|
+
lines.push('---', '');
|
|
1058
|
+
if (profile) lines.push(`profile: ${profile}`);
|
|
1059
|
+
if (stack) lines.push(`stack: ${stack}`);
|
|
1060
|
+
if (practice) lines.push(`practices: ${practice}`);
|
|
1061
|
+
if (style) lines.push(`style: ${style}`);
|
|
1062
|
+
if (selectedFeatures.length > 0) {
|
|
1063
|
+
lines.push('features:');
|
|
1064
|
+
for (const f of selectedFeatures) {
|
|
1065
|
+
lines.push(` - ${f}`);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
lines.push('');
|
|
1069
|
+
|
|
1070
|
+
// Write playbook
|
|
1071
|
+
const destDir = join(GSPEC_HOME, 'playbooks');
|
|
1072
|
+
const destPath = join(destDir, `${name}.md`);
|
|
1073
|
+
await mkdir(destDir, { recursive: true });
|
|
1074
|
+
|
|
1075
|
+
try {
|
|
1076
|
+
await stat(destPath);
|
|
1077
|
+
const overwrite = await promptConfirm(chalk.yellow(`\n Playbook "${name}" already exists. Overwrite? [y/N]: `));
|
|
1078
|
+
if (!overwrite) {
|
|
1079
|
+
console.log(chalk.dim('\n Cancelled.\n'));
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
} catch (e) {
|
|
1083
|
+
if (e.code !== 'ENOENT') throw e;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
await writeFile(destPath, lines.join('\n'), 'utf-8');
|
|
1087
|
+
|
|
1088
|
+
// Summary
|
|
1089
|
+
console.log(chalk.green(`\n ✓ Playbook saved to ~/.gspec/playbooks/${name}.md\n`));
|
|
1090
|
+
console.log(chalk.bold(' Contents:'));
|
|
1091
|
+
if (profile) console.log(` Profile: ${formatStarterName(profile)}`);
|
|
1092
|
+
if (stack) console.log(` Stack: ${formatStarterName(stack)}`);
|
|
1093
|
+
if (practice) console.log(` Practices: ${formatStarterName(practice)}`);
|
|
1094
|
+
if (style) console.log(` Style: ${formatStarterName(style)}`);
|
|
1095
|
+
if (selectedFeatures.length > 0) {
|
|
1096
|
+
console.log(` Features: ${selectedFeatures.map(formatStarterName).join(', ')}`);
|
|
1097
|
+
}
|
|
1098
|
+
console.log();
|
|
1099
|
+
console.log(chalk.dim(` Restore with: gspec restore playbook/${name}\n`));
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function parsePlaybook(content) {
|
|
1103
|
+
const { fields } = parseFrontmatter(content);
|
|
1104
|
+
const body = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '');
|
|
1105
|
+
const result = { name: fields.name || '', description: fields.description || '' };
|
|
1106
|
+
|
|
1107
|
+
for (const line of body.split('\n')) {
|
|
1108
|
+
const match = line.match(/^(\w+):\s*(.+)$/);
|
|
1109
|
+
if (match) {
|
|
1110
|
+
const [, key, value] = match;
|
|
1111
|
+
if (key !== 'features') result[key] = value.trim();
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Parse features list
|
|
1116
|
+
const featuresMatch = body.match(/^features:\s*\n((?:\s+-\s+.+\n?)+)/m);
|
|
1117
|
+
if (featuresMatch) {
|
|
1118
|
+
result.features = featuresMatch[1]
|
|
1119
|
+
.split('\n')
|
|
1120
|
+
.map((l) => l.replace(/^\s+-\s+/, '').trim())
|
|
1121
|
+
.filter(Boolean);
|
|
1122
|
+
} else {
|
|
1123
|
+
result.features = [];
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
return result;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
async function restorePlaybook(name, cwd) {
|
|
1130
|
+
const srcPath = join(GSPEC_HOME, 'playbooks', `${name}.md`);
|
|
1131
|
+
|
|
1132
|
+
try {
|
|
1133
|
+
await stat(srcPath);
|
|
1134
|
+
} catch (e) {
|
|
1135
|
+
if (e.code === 'ENOENT') {
|
|
1136
|
+
console.error(chalk.red(`\n Not found: ~/.gspec/playbooks/${name}.md\n`));
|
|
1137
|
+
process.exit(1);
|
|
1138
|
+
}
|
|
1139
|
+
throw e;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
const content = await readFile(srcPath, 'utf-8');
|
|
1143
|
+
const playbook = parsePlaybook(content);
|
|
1144
|
+
|
|
1145
|
+
console.log(chalk.bold(`\n Restoring playbook: ${playbook.name || name}\n`));
|
|
1146
|
+
if (playbook.description) console.log(chalk.dim(` ${playbook.description}\n`));
|
|
1147
|
+
|
|
1148
|
+
const gspecDir = join(cwd, 'gspec');
|
|
1149
|
+
await mkdir(gspecDir, { recursive: true });
|
|
1150
|
+
|
|
1151
|
+
const restorations = [];
|
|
1152
|
+
if (playbook.profile) restorations.push({ type: 'profiles', slug: playbook.profile });
|
|
1153
|
+
if (playbook.stack) restorations.push({ type: 'stacks', slug: playbook.stack });
|
|
1154
|
+
if (playbook.practices) restorations.push({ type: 'practices', slug: playbook.practices });
|
|
1155
|
+
if (playbook.style) restorations.push({ type: 'styles', slug: playbook.style });
|
|
1156
|
+
for (const f of playbook.features) {
|
|
1157
|
+
restorations.push({ type: 'features', slug: f });
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
// Check for existing files
|
|
1161
|
+
const existing = [];
|
|
1162
|
+
for (const r of restorations) {
|
|
1163
|
+
const destFile = r.type === 'features' ? join('features', `${r.slug}.md`) : RESTORE_DEST_MAP[r.type];
|
|
1164
|
+
const destPath = join(gspecDir, destFile);
|
|
1165
|
+
try {
|
|
1166
|
+
await stat(destPath);
|
|
1167
|
+
existing.push(`gspec/${destFile}`);
|
|
1168
|
+
} catch (e) {
|
|
1169
|
+
if (e.code !== 'ENOENT') throw e;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
if (existing.length > 0) {
|
|
1174
|
+
console.log(chalk.yellow(' The following files already exist and will be overwritten:\n'));
|
|
1175
|
+
for (const label of existing) {
|
|
1176
|
+
console.log(` ${chalk.yellow('!')} ${label}`);
|
|
1177
|
+
}
|
|
1178
|
+
console.log();
|
|
1179
|
+
const confirmed = await promptConfirm(chalk.bold(' Continue and overwrite? [y/N]: '));
|
|
1180
|
+
if (!confirmed) {
|
|
1181
|
+
console.log(chalk.dim('\n Restore cancelled.\n'));
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Restore all specs
|
|
1187
|
+
const outdated = [];
|
|
1188
|
+
for (const r of restorations) {
|
|
1189
|
+
const srcFile = join(GSPEC_HOME, r.type, `${r.slug}.md`);
|
|
1190
|
+
try {
|
|
1191
|
+
await stat(srcFile);
|
|
1192
|
+
} catch (e) {
|
|
1193
|
+
if (e.code === 'ENOENT') {
|
|
1194
|
+
console.log(` ${chalk.yellow('!')} Skipped ${r.type}/${r.slug} — not found in ~/.gspec/`);
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
throw e;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
const destFile = r.type === 'features' ? join('features', `${r.slug}.md`) : RESTORE_DEST_MAP[r.type];
|
|
1201
|
+
const destPath = join(gspecDir, destFile);
|
|
1202
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
1203
|
+
const specContent = await readFile(srcFile, 'utf-8');
|
|
1204
|
+
await writeFile(destPath, specContent, 'utf-8');
|
|
1205
|
+
console.log(` ${chalk.green('+')} gspec/${destFile}`);
|
|
1206
|
+
|
|
1207
|
+
const version = parseSpecVersion(specContent);
|
|
1208
|
+
if (version && version !== SPEC_VERSION) {
|
|
1209
|
+
outdated.push({ label: `gspec/${destFile}`, version });
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
console.log(chalk.green(`\n ✓ Playbook "${playbook.name || name}" restored.\n`));
|
|
1214
|
+
|
|
1215
|
+
if (outdated.length > 0) {
|
|
1216
|
+
console.log(chalk.yellow(' ⚠ The following restored specs are outdated:\n'));
|
|
1217
|
+
for (const o of outdated) {
|
|
1218
|
+
console.log(` ${chalk.yellow('!')} ${o.label} — version ${o.version} (current: ${SPEC_VERSION})`);
|
|
1219
|
+
}
|
|
1220
|
+
console.log();
|
|
1221
|
+
console.log(chalk.yellow(` Run ${chalk.bold('/gspec-migrate')} to update them to the current format.\n`));
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
647
1225
|
program
|
|
648
1226
|
.name('gspec')
|
|
649
1227
|
.description('Install gspec specification commands')
|
|
@@ -660,25 +1238,53 @@ program
|
|
|
660
1238
|
|
|
661
1239
|
await install(targetName, process.cwd());
|
|
662
1240
|
|
|
663
|
-
await
|
|
1241
|
+
await seedFromSavedSpecs(process.cwd());
|
|
664
1242
|
|
|
665
1243
|
await installSpecSync(targetName, process.cwd());
|
|
666
1244
|
|
|
667
1245
|
await checkGspecFiles(process.cwd(), targetName);
|
|
668
1246
|
|
|
669
|
-
// Post-install: instruct user to generate profile.md
|
|
1247
|
+
// Post-install: instruct user to generate profile.md (only if it doesn't already exist)
|
|
1248
|
+
const profilePath = join(process.cwd(), 'gspec', 'profile.md');
|
|
1249
|
+
let profileExists = false;
|
|
1250
|
+
try { await stat(profilePath); profileExists = true; } catch {}
|
|
1251
|
+
|
|
670
1252
|
const targetLabel = TARGETS[targetName].label;
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
1253
|
+
if (!profileExists) {
|
|
1254
|
+
console.log();
|
|
1255
|
+
console.log(chalk.bold.cyan(' ═══ Next Step ═══════════════════════════════════════════════'));
|
|
1256
|
+
console.log();
|
|
1257
|
+
console.log(chalk.bold.white(' Generate your product profile before continuing.'));
|
|
1258
|
+
console.log();
|
|
1259
|
+
console.log(` Run ${chalk.bold.yellow('/gspec-profile')} in ${targetLabel} to create gspec/profile.md`);
|
|
1260
|
+
console.log(` — it defines what your product is, who it serves, and why it`);
|
|
1261
|
+
console.log(` exists. All other gspec commands use the profile as their foundation.`);
|
|
1262
|
+
console.log();
|
|
1263
|
+
console.log(chalk.bold.cyan(' ═════════════════════════════════════════════════════════════'));
|
|
1264
|
+
console.log();
|
|
1265
|
+
}
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1268
|
+
program
|
|
1269
|
+
.command('save')
|
|
1270
|
+
.description('Save a gspec spec to ~/.gspec for reuse across projects')
|
|
1271
|
+
.action(async () => {
|
|
1272
|
+
await saveSpec(process.cwd());
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
program
|
|
1276
|
+
.command('restore')
|
|
1277
|
+
.description('Restore a saved spec from ~/.gspec into the current project')
|
|
1278
|
+
.argument('[spec]', 'spec to restore (e.g. stacks/my-stack, playbook/my-starter)')
|
|
1279
|
+
.action(async (spec) => {
|
|
1280
|
+
await restoreSpec(spec, process.cwd());
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
program
|
|
1284
|
+
.command('playbook')
|
|
1285
|
+
.description('Create a playbook that bundles saved specs for quick project setup')
|
|
1286
|
+
.action(async () => {
|
|
1287
|
+
await createPlaybook();
|
|
682
1288
|
});
|
|
683
1289
|
|
|
684
1290
|
program.parse();
|