@forgeailab/create-spark 0.1.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.
- package/package.json +27 -0
- package/src/cli.ts +367 -0
- package/src/copy.ts +69 -0
- package/src/pack-registry.ts +71 -0
- package/src/paths.ts +57 -0
- package/src/picker.ts +194 -0
- package/src/preset.ts +35 -0
- package/src/prompts.ts +117 -0
- package/src/registry.ts +56 -0
- package/src/skills.ts +136 -0
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@forgeailab/create-spark",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Interactive scaffolder for spark projects with guided pack picker.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"bin": {
|
|
14
|
+
"create-spark": "./src/cli.ts"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@forgeailab/spark": "workspace:*",
|
|
18
|
+
"@forgeailab/spark-schema": "workspace:*",
|
|
19
|
+
"@clack/prompts": "latest",
|
|
20
|
+
"citty": "latest",
|
|
21
|
+
"picocolors": "latest"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"bun-types": "latest",
|
|
25
|
+
"typescript": "latest"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { access, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { cancel, confirm, intro, isCancel, outro, select, text } from '@clack/prompts';
|
|
6
|
+
import { runAdd } from '@forgeailab/spark/src/commands/add.ts';
|
|
7
|
+
import { defineCommand, runMain } from 'citty';
|
|
8
|
+
import pc from 'picocolors';
|
|
9
|
+
import { copyTemplate } from './copy.ts';
|
|
10
|
+
import { loadPackRegistry } from './pack-registry.ts';
|
|
11
|
+
import { findMonorepoRoot } from './paths.ts';
|
|
12
|
+
import {
|
|
13
|
+
filterAuthByDb,
|
|
14
|
+
filterSyncByDb,
|
|
15
|
+
groupByCategory,
|
|
16
|
+
orderedForCategory,
|
|
17
|
+
parsePacksFlag,
|
|
18
|
+
recommendedFor,
|
|
19
|
+
type DbPick,
|
|
20
|
+
type GroupedPacks,
|
|
21
|
+
type PickerCategory,
|
|
22
|
+
} from './picker.ts';
|
|
23
|
+
import { applyPreset } from './preset.ts';
|
|
24
|
+
import { promptCategory, promptConfirmPlan, promptMultiCategory } from './prompts.ts';
|
|
25
|
+
import { loadTemplateRegistry, type TemplateMetadata } from './registry.ts';
|
|
26
|
+
import { syncSkills } from './skills.ts';
|
|
27
|
+
|
|
28
|
+
export const PLANNED_TEMPLATE_MESSAGE = 'planned, not yet implemented';
|
|
29
|
+
|
|
30
|
+
type CreateAppArgs = {
|
|
31
|
+
appName?: unknown;
|
|
32
|
+
template?: unknown;
|
|
33
|
+
preset?: unknown;
|
|
34
|
+
packs?: unknown;
|
|
35
|
+
noPacks?: unknown;
|
|
36
|
+
'no-packs'?: unknown;
|
|
37
|
+
yes?: unknown;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type ScaffoldConfig = {
|
|
41
|
+
appName: string;
|
|
42
|
+
template: string;
|
|
43
|
+
createdAt: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function plannedTemplateMessage(templateName: string): string {
|
|
47
|
+
return `Template "${templateName}" is ${PLANNED_TEMPLATE_MESSAGE}. Use "nextjs" for v1 or see docs/spec/changes/add-scaffold-and-pack-registry-2026-05-21/design.md#decision-11.`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function asOptionalString(value: unknown): string | undefined {
|
|
51
|
+
if (typeof value !== 'string') {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const trimmed = value.trim();
|
|
56
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function asBoolean(value: unknown): boolean {
|
|
60
|
+
return value === true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function abortIfCancel<T>(value: T | symbol): T {
|
|
64
|
+
if (isCancel(value)) {
|
|
65
|
+
cancel('Operation cancelled.');
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isPacksFlagProvided(rawArgs: CreateAppArgs): boolean {
|
|
73
|
+
return rawArgs.packs !== undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isNoPacks(rawArgs: CreateAppArgs): boolean {
|
|
77
|
+
return asBoolean(rawArgs.noPacks) || asBoolean(rawArgs['no-packs']);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function exists(path: string): Promise<boolean> {
|
|
81
|
+
try {
|
|
82
|
+
await access(path);
|
|
83
|
+
return true;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function promptForAppName(): Promise<string> {
|
|
90
|
+
const answer = abortIfCancel(
|
|
91
|
+
await text({
|
|
92
|
+
message: 'App name',
|
|
93
|
+
placeholder: 'my-app',
|
|
94
|
+
validate(value) {
|
|
95
|
+
return value?.trim().length ? undefined : 'Enter an app name.';
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return answer.trim();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function formatTemplateLabel(template: TemplateMetadata): string {
|
|
104
|
+
return template.status === 'planned' ? `${template.name} (planned)` : template.name;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function promptForTemplate(templates: TemplateMetadata[]): Promise<string> {
|
|
108
|
+
const answer = abortIfCancel(
|
|
109
|
+
await select<string>({
|
|
110
|
+
message: 'Template',
|
|
111
|
+
options: templates.map((template) => ({
|
|
112
|
+
value: template.name,
|
|
113
|
+
label: formatTemplateLabel(template),
|
|
114
|
+
hint: template.description,
|
|
115
|
+
})),
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
return answer;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function promptForPreset(skipOptionalPrompts: boolean): Promise<string | undefined> {
|
|
123
|
+
if (skipOptionalPrompts) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const wantsPreset = abortIfCancel(
|
|
128
|
+
await confirm({
|
|
129
|
+
message: 'Apply a preset?',
|
|
130
|
+
initialValue: false,
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
if (!wantsPreset) {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const preset = abortIfCancel(
|
|
139
|
+
await text({
|
|
140
|
+
message: 'Preset name',
|
|
141
|
+
placeholder: 'saas-classic',
|
|
142
|
+
validate(value) {
|
|
143
|
+
return value?.trim().length ? undefined : 'Enter a preset name.';
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
return preset.trim();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function findTemplate(templates: TemplateMetadata[], templateName: string): TemplateMetadata {
|
|
152
|
+
const template = templates.find((candidate) => candidate.name === templateName);
|
|
153
|
+
if (template !== undefined) {
|
|
154
|
+
return template;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const registered = templates.map((candidate) => candidate.name).join(', ');
|
|
158
|
+
throw new Error(`Unknown template "${templateName}". Registered templates: ${registered}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function addPick(picks: string[], pick: string | undefined): void {
|
|
162
|
+
if (pick !== undefined) {
|
|
163
|
+
picks.push(pick);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function addPicks(picks: string[], selected: readonly string[] | undefined): void {
|
|
168
|
+
if (selected !== undefined) {
|
|
169
|
+
picks.push(...selected);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function collectInteractivePackPicks(groups: GroupedPacks): Promise<string[]> {
|
|
174
|
+
const picks: string[] = [];
|
|
175
|
+
|
|
176
|
+
const dbPick = (await promptCategory(
|
|
177
|
+
'db',
|
|
178
|
+
orderedForCategory('db', groups.db),
|
|
179
|
+
recommendedFor('db'),
|
|
180
|
+
)) as DbPick;
|
|
181
|
+
addPick(picks, dbPick);
|
|
182
|
+
|
|
183
|
+
if (dbPick !== undefined) {
|
|
184
|
+
const authPick = await promptCategory(
|
|
185
|
+
'auth',
|
|
186
|
+
filterAuthByDb(groups.auth, dbPick),
|
|
187
|
+
recommendedFor('auth', dbPick),
|
|
188
|
+
);
|
|
189
|
+
addPick(picks, authPick);
|
|
190
|
+
|
|
191
|
+
const syncPick = await promptCategory(
|
|
192
|
+
'sync',
|
|
193
|
+
filterSyncByDb(groups.sync, dbPick),
|
|
194
|
+
recommendedFor('sync', dbPick),
|
|
195
|
+
);
|
|
196
|
+
addPick(picks, syncPick);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
addPick(
|
|
200
|
+
picks,
|
|
201
|
+
await promptCategory(
|
|
202
|
+
'ui',
|
|
203
|
+
orderedForCategory('ui', groups.ui),
|
|
204
|
+
recommendedFor('ui', dbPick),
|
|
205
|
+
),
|
|
206
|
+
);
|
|
207
|
+
addPicks(picks, await promptMultiCategory('ai', orderedForCategory('ai', groups.ai)));
|
|
208
|
+
|
|
209
|
+
for (const category of ['email', 'analytics', 'deploy'] as const satisfies readonly PickerCategory[]) {
|
|
210
|
+
addPick(
|
|
211
|
+
picks,
|
|
212
|
+
await promptCategory(
|
|
213
|
+
category,
|
|
214
|
+
orderedForCategory(category, groups[category]),
|
|
215
|
+
recommendedFor(category, dbPick),
|
|
216
|
+
),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
addPicks(picks, await promptMultiCategory('infra', orderedForCategory('infra', groups.infra)));
|
|
221
|
+
addPicks(
|
|
222
|
+
picks,
|
|
223
|
+
await promptMultiCategory('testing', orderedForCategory('testing', groups.testing), [
|
|
224
|
+
'testing-playwright',
|
|
225
|
+
]),
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
return picks;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function recommendedYesPicks(): string[] {
|
|
232
|
+
return ['db-sqlite', 'auth-better-auth', 'testing-playwright'];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function collectPackPicks(rawArgs: CreateAppArgs, presetName: string | undefined): Promise<string[]> {
|
|
236
|
+
const yes = asBoolean(rawArgs.yes);
|
|
237
|
+
const noPacks = isNoPacks(rawArgs);
|
|
238
|
+
const packsFlagProvided = isPacksFlagProvided(rawArgs);
|
|
239
|
+
|
|
240
|
+
if (packsFlagProvided && noPacks) {
|
|
241
|
+
throw new Error('--packs cannot be combined with --no-packs.');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (presetName !== undefined) {
|
|
245
|
+
return [];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (noPacks) {
|
|
249
|
+
return [];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (packsFlagProvided) {
|
|
253
|
+
return parsePacksFlag(typeof rawArgs.packs === 'string' ? rawArgs.packs : '');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (yes) {
|
|
257
|
+
return recommendedYesPicks();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const packs = await loadPackRegistry();
|
|
261
|
+
return collectInteractivePackPicks(groupByCategory(packs));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function createApp(rawArgs: CreateAppArgs): Promise<void> {
|
|
265
|
+
intro(pc.cyan('create-spark'));
|
|
266
|
+
|
|
267
|
+
const templates = await loadTemplateRegistry();
|
|
268
|
+
const appName = asOptionalString(rawArgs.appName) ?? (await promptForAppName());
|
|
269
|
+
const requestedTemplate = asOptionalString(rawArgs.template) ?? (await promptForTemplate(templates));
|
|
270
|
+
const template = findTemplate(templates, requestedTemplate);
|
|
271
|
+
|
|
272
|
+
if (template.status === 'planned') {
|
|
273
|
+
throw new Error(plannedTemplateMessage(template.name));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const targetDir = resolve(process.cwd(), appName);
|
|
277
|
+
if (await exists(targetDir)) {
|
|
278
|
+
throw new Error(`Target directory already exists: ${targetDir}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const skipPresetPrompt =
|
|
282
|
+
asBoolean(rawArgs.yes) || isPacksFlagProvided(rawArgs) || isNoPacks(rawArgs);
|
|
283
|
+
const presetName =
|
|
284
|
+
asOptionalString(rawArgs.preset) ?? (await promptForPreset(skipPresetPrompt));
|
|
285
|
+
const createdAt = new Date().toISOString();
|
|
286
|
+
const vars: Record<string, string> = {
|
|
287
|
+
appName,
|
|
288
|
+
template: template.name,
|
|
289
|
+
preset: presetName ?? '',
|
|
290
|
+
createdAt,
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
await copyTemplate(template.name, targetDir, vars);
|
|
294
|
+
|
|
295
|
+
const config: ScaffoldConfig = {
|
|
296
|
+
appName,
|
|
297
|
+
template: template.name,
|
|
298
|
+
createdAt,
|
|
299
|
+
};
|
|
300
|
+
await writeFile(join(targetDir, 'spark.config.json'), `${JSON.stringify(config, null, 2)}\n`);
|
|
301
|
+
|
|
302
|
+
const monorepoRoot = findMonorepoRoot();
|
|
303
|
+
await syncSkills(targetDir, monorepoRoot);
|
|
304
|
+
|
|
305
|
+
const packPicks = await collectPackPicks(rawArgs, presetName);
|
|
306
|
+
if (presetName === undefined && !asBoolean(rawArgs.yes)) {
|
|
307
|
+
await promptConfirmPlan(packPicks);
|
|
308
|
+
}
|
|
309
|
+
if (presetName === undefined) {
|
|
310
|
+
if (packPicks.length > 0) {
|
|
311
|
+
await runAdd(packPicks, { projectRoot: targetDir, yes: true });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (presetName !== undefined) {
|
|
316
|
+
await applyPreset(targetDir, presetName, monorepoRoot);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
outro(pc.green(`Created ${appName}`));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const main = defineCommand({
|
|
323
|
+
meta: {
|
|
324
|
+
name: 'create-spark',
|
|
325
|
+
description: 'Create an AI-ready spark project.',
|
|
326
|
+
},
|
|
327
|
+
args: {
|
|
328
|
+
appName: {
|
|
329
|
+
type: 'positional',
|
|
330
|
+
description: 'Application name or target directory.',
|
|
331
|
+
required: false,
|
|
332
|
+
},
|
|
333
|
+
template: {
|
|
334
|
+
type: 'string',
|
|
335
|
+
description: 'Template name.',
|
|
336
|
+
required: false,
|
|
337
|
+
},
|
|
338
|
+
preset: {
|
|
339
|
+
type: 'string',
|
|
340
|
+
description: 'Preset name to apply after scaffolding.',
|
|
341
|
+
required: false,
|
|
342
|
+
},
|
|
343
|
+
packs: {
|
|
344
|
+
type: 'string',
|
|
345
|
+
description: 'Comma-separated pack names to install after scaffolding.',
|
|
346
|
+
required: false,
|
|
347
|
+
},
|
|
348
|
+
'no-packs': {
|
|
349
|
+
type: 'boolean',
|
|
350
|
+
description: 'Skip pack installation after scaffolding.',
|
|
351
|
+
required: false,
|
|
352
|
+
},
|
|
353
|
+
yes: {
|
|
354
|
+
type: 'boolean',
|
|
355
|
+
alias: 'y',
|
|
356
|
+
description: 'Skip optional prompts.',
|
|
357
|
+
required: false,
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
async run({ args }) {
|
|
361
|
+
await createApp(args);
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
if (import.meta.main) {
|
|
366
|
+
runMain(main);
|
|
367
|
+
}
|
package/src/copy.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir, readlink, symlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { getTemplatesDir } from './registry.ts';
|
|
4
|
+
|
|
5
|
+
const placeholderPattern = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g;
|
|
6
|
+
|
|
7
|
+
function applyTemplatePlaceholders(content: string, vars: Record<string, string>): string {
|
|
8
|
+
return content.replace(placeholderPattern, (placeholder, key: string) => {
|
|
9
|
+
return Object.prototype.hasOwnProperty.call(vars, key) ? vars[key] : placeholder;
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isBinaryContent(content: Buffer): boolean {
|
|
14
|
+
return content.includes(0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function copyTemplateDirectory(
|
|
18
|
+
sourceDir: string,
|
|
19
|
+
targetDir: string,
|
|
20
|
+
vars: Record<string, string>,
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
const entries = await readdir(sourceDir, { withFileTypes: true });
|
|
23
|
+
await mkdir(targetDir, { recursive: true });
|
|
24
|
+
|
|
25
|
+
await Promise.all(
|
|
26
|
+
entries.map(async (entry) => {
|
|
27
|
+
if (entry.name === 'template.toml') {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const sourcePath = join(sourceDir, entry.name);
|
|
32
|
+
const targetPath = join(targetDir, entry.name);
|
|
33
|
+
|
|
34
|
+
if (entry.isDirectory()) {
|
|
35
|
+
await copyTemplateDirectory(sourcePath, targetPath, vars);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (entry.isSymbolicLink()) {
|
|
40
|
+
const linkTarget = await readlink(sourcePath);
|
|
41
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
42
|
+
await symlink(linkTarget, targetPath);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!entry.isFile()) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const content = await readFile(sourcePath);
|
|
51
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
52
|
+
|
|
53
|
+
if (isBinaryContent(content)) {
|
|
54
|
+
await writeFile(targetPath, content);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
await writeFile(targetPath, applyTemplatePlaceholders(content.toString('utf8'), vars), 'utf8');
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function copyTemplate(
|
|
64
|
+
templateName: string,
|
|
65
|
+
targetDir: string,
|
|
66
|
+
vars: Record<string, string>,
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
await copyTemplateDirectory(join(getTemplatesDir(), templateName), targetDir, vars);
|
|
69
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parsePackToml, type ParseError, type RuntimePackageBlock } from '@forgeailab/spark-schema';
|
|
4
|
+
import { findMonorepoRoot } from './paths.ts';
|
|
5
|
+
import type { PickerPack } from './picker.ts';
|
|
6
|
+
|
|
7
|
+
export type PackMetadata = PickerPack;
|
|
8
|
+
|
|
9
|
+
export function getPacksDir(): string {
|
|
10
|
+
return process.env.CREATE_SPARK_PACKS_DIR ?? join(findMonorepoRoot(), 'packs');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function formatParseError(error: ParseError): string {
|
|
14
|
+
const issues = error.issues?.map((issue) => ` - ${issue}`).join('\n');
|
|
15
|
+
return issues === undefined ? error.message : `${error.message}\n${issues}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function readChildDirectories(root: string): Promise<string[]> {
|
|
19
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
20
|
+
return entries
|
|
21
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('_'))
|
|
22
|
+
.map((entry) => entry.name)
|
|
23
|
+
.sort();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function readToml(path: string): Promise<string | undefined> {
|
|
27
|
+
try {
|
|
28
|
+
return await readFile(path, 'utf8');
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function loadPackRegistry(packsDir: string = getPacksDir()): Promise<PackMetadata[]> {
|
|
38
|
+
const entries = await Promise.all(
|
|
39
|
+
(await readChildDirectories(packsDir)).map(async (dirName) => {
|
|
40
|
+
const manifestPath = join(packsDir, dirName, 'pack.toml');
|
|
41
|
+
const raw = await readToml(manifestPath);
|
|
42
|
+
if (raw === undefined) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const parsed = parsePackToml(raw);
|
|
47
|
+
|
|
48
|
+
if (!parsed.ok) {
|
|
49
|
+
throw new Error(`Invalid pack manifest at ${manifestPath}:\n${formatParseError(parsed.error)}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const manifest = parsed.data;
|
|
53
|
+
if (manifest.name !== dirName) {
|
|
54
|
+
throw new Error(`${manifestPath}: pack name "${manifest.name}" must match directory "${dirName}"`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
name: manifest.name,
|
|
59
|
+
category: manifest.category,
|
|
60
|
+
description: manifest.description ?? '',
|
|
61
|
+
provides: manifest.provides,
|
|
62
|
+
requires: manifest.requires,
|
|
63
|
+
runtimePackage: manifest.runtime_package as RuntimePackageBlock | undefined,
|
|
64
|
+
};
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return entries
|
|
69
|
+
.filter((pack): pack is PackMetadata => pack !== undefined)
|
|
70
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
71
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const monorepoRootError =
|
|
5
|
+
'Must be run from inside the spark monorepo or set SPARK_ROOT env var pointing to it';
|
|
6
|
+
|
|
7
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
8
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isDirectory(path: string): boolean {
|
|
12
|
+
try {
|
|
13
|
+
return statSync(path).isDirectory();
|
|
14
|
+
} catch (error) {
|
|
15
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
16
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function hasWorkspacePackageJson(dir: string): boolean {
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as unknown;
|
|
26
|
+
return isRecord(parsed) && Object.hasOwn(parsed, 'workspaces');
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isMonorepoRoot(dir: string): boolean {
|
|
36
|
+
return hasWorkspacePackageJson(dir) && isDirectory(join(dir, 'templates'));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function findMonorepoRoot(startDir: string = import.meta.dir): string {
|
|
40
|
+
const override = process.env.SPARK_ROOT?.trim();
|
|
41
|
+
if (override) {
|
|
42
|
+
return resolve(override);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let current = resolve(startDir);
|
|
46
|
+
while (true) {
|
|
47
|
+
if (isMonorepoRoot(current)) {
|
|
48
|
+
return current;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const parent = dirname(current);
|
|
52
|
+
if (parent === current) {
|
|
53
|
+
throw new Error(monorepoRootError);
|
|
54
|
+
}
|
|
55
|
+
current = parent;
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/picker.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
export type RuntimePackageMetadata = {
|
|
2
|
+
package: string;
|
|
3
|
+
version: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type PickerPack = {
|
|
7
|
+
name: string;
|
|
8
|
+
category: string;
|
|
9
|
+
description: string;
|
|
10
|
+
provides: readonly string[];
|
|
11
|
+
requires: readonly string[];
|
|
12
|
+
runtimePackage?: RuntimePackageMetadata;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type PickerCategory =
|
|
16
|
+
| 'db'
|
|
17
|
+
| 'auth'
|
|
18
|
+
| 'sync'
|
|
19
|
+
| 'ui'
|
|
20
|
+
| 'ai'
|
|
21
|
+
| 'email'
|
|
22
|
+
| 'analytics'
|
|
23
|
+
| 'deploy'
|
|
24
|
+
| 'infra'
|
|
25
|
+
| 'testing';
|
|
26
|
+
|
|
27
|
+
export type DbPick = 'db-sqlite' | 'db-postgres' | 'db-supabase' | undefined;
|
|
28
|
+
|
|
29
|
+
export type GroupedPacks = Record<PickerCategory, PickerPack[]>;
|
|
30
|
+
|
|
31
|
+
export const pickerCategories = [
|
|
32
|
+
'db',
|
|
33
|
+
'auth',
|
|
34
|
+
'sync',
|
|
35
|
+
'ui',
|
|
36
|
+
'ai',
|
|
37
|
+
'email',
|
|
38
|
+
'analytics',
|
|
39
|
+
'deploy',
|
|
40
|
+
'infra',
|
|
41
|
+
'testing',
|
|
42
|
+
] as const satisfies readonly PickerCategory[];
|
|
43
|
+
|
|
44
|
+
const categorySet = new Set<PickerCategory>(pickerCategories);
|
|
45
|
+
|
|
46
|
+
function emptyGroups(): GroupedPacks {
|
|
47
|
+
return {
|
|
48
|
+
db: [],
|
|
49
|
+
auth: [],
|
|
50
|
+
sync: [],
|
|
51
|
+
ui: [],
|
|
52
|
+
ai: [],
|
|
53
|
+
email: [],
|
|
54
|
+
analytics: [],
|
|
55
|
+
deploy: [],
|
|
56
|
+
infra: [],
|
|
57
|
+
testing: [],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pickerCategoryFor(pack: PickerPack): PickerCategory | undefined {
|
|
62
|
+
if (pack.name === 'sync-zero' || pack.provides.includes('sync')) {
|
|
63
|
+
return 'sync';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (categorySet.has(pack.category as PickerCategory)) {
|
|
67
|
+
return pack.category as PickerCategory;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function orderByName(packs: readonly PickerPack[], names: readonly string[]): PickerPack[] {
|
|
74
|
+
const byName = new Map(packs.map((pack) => [pack.name, pack]));
|
|
75
|
+
return names.flatMap((name) => {
|
|
76
|
+
const pack = byName.get(name);
|
|
77
|
+
return pack === undefined ? [] : [pack];
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function groupByCategory(packs: readonly PickerPack[]): GroupedPacks {
|
|
82
|
+
const groups = emptyGroups();
|
|
83
|
+
|
|
84
|
+
for (const pack of packs) {
|
|
85
|
+
const category = pickerCategoryFor(pack);
|
|
86
|
+
if (category !== undefined) {
|
|
87
|
+
groups[category].push(pack);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return groups;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function orderedForCategory(
|
|
95
|
+
category: PickerCategory,
|
|
96
|
+
packs: readonly PickerPack[],
|
|
97
|
+
): PickerPack[] {
|
|
98
|
+
switch (category) {
|
|
99
|
+
case 'db':
|
|
100
|
+
return orderByName(packs, ['db-sqlite', 'db-postgres', 'db-supabase']);
|
|
101
|
+
case 'auth':
|
|
102
|
+
return orderByName(packs, ['auth-better-auth', 'auth-better-auth-pg', 'auth-supabase']);
|
|
103
|
+
case 'sync':
|
|
104
|
+
return orderByName(packs, ['sync-zero']);
|
|
105
|
+
case 'ui':
|
|
106
|
+
return orderByName(packs, ['ui-shadcn']);
|
|
107
|
+
case 'ai':
|
|
108
|
+
return orderByName(packs, ['ai-anthropic', 'ai-openai']);
|
|
109
|
+
case 'email':
|
|
110
|
+
return orderByName(packs, ['email-resend']);
|
|
111
|
+
case 'analytics':
|
|
112
|
+
return orderByName(packs, ['analytics-posthog']);
|
|
113
|
+
case 'deploy':
|
|
114
|
+
return orderByName(packs, ['deploy-vercel']);
|
|
115
|
+
case 'infra':
|
|
116
|
+
return orderByName(packs, ['docker-compose-dev']);
|
|
117
|
+
case 'testing':
|
|
118
|
+
return orderByName(packs, ['testing-playwright']);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function filterAuthByDb(
|
|
123
|
+
authPacks: readonly PickerPack[],
|
|
124
|
+
dbPick: DbPick,
|
|
125
|
+
): PickerPack[] {
|
|
126
|
+
if (dbPick === 'db-sqlite') {
|
|
127
|
+
return orderByName(authPacks, ['auth-better-auth']);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (dbPick === 'db-postgres') {
|
|
131
|
+
return orderByName(authPacks, ['auth-better-auth-pg']);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (dbPick === 'db-supabase') {
|
|
135
|
+
return orderByName(authPacks, ['auth-supabase', 'auth-better-auth-pg']);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function filterSyncByDb(
|
|
142
|
+
syncPacks: readonly PickerPack[],
|
|
143
|
+
dbPick: DbPick,
|
|
144
|
+
): PickerPack[] {
|
|
145
|
+
if (dbPick === 'db-postgres' || dbPick === 'db-supabase') {
|
|
146
|
+
return orderByName(syncPacks, ['sync-zero']);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function recommendedFor(category: PickerCategory, dbPick?: DbPick): string | undefined {
|
|
153
|
+
switch (category) {
|
|
154
|
+
case 'db':
|
|
155
|
+
return 'db-sqlite';
|
|
156
|
+
case 'auth':
|
|
157
|
+
if (dbPick === 'db-sqlite') {
|
|
158
|
+
return 'auth-better-auth';
|
|
159
|
+
}
|
|
160
|
+
if (dbPick === 'db-postgres') {
|
|
161
|
+
return 'auth-better-auth-pg';
|
|
162
|
+
}
|
|
163
|
+
if (dbPick === 'db-supabase') {
|
|
164
|
+
return 'auth-supabase';
|
|
165
|
+
}
|
|
166
|
+
return undefined;
|
|
167
|
+
case 'sync':
|
|
168
|
+
return dbPick === 'db-postgres' ? 'sync-zero' : undefined;
|
|
169
|
+
case 'ui':
|
|
170
|
+
return 'ui-shadcn';
|
|
171
|
+
case 'email':
|
|
172
|
+
return 'email-resend';
|
|
173
|
+
case 'analytics':
|
|
174
|
+
return 'analytics-posthog';
|
|
175
|
+
case 'deploy':
|
|
176
|
+
return 'deploy-vercel';
|
|
177
|
+
case 'testing':
|
|
178
|
+
return 'testing-playwright';
|
|
179
|
+
case 'ai':
|
|
180
|
+
case 'infra':
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function parsePacksFlag(raw: string | undefined): string[] {
|
|
186
|
+
if (raw === undefined) {
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return raw
|
|
191
|
+
.split(',')
|
|
192
|
+
.map((pack) => pack.trim())
|
|
193
|
+
.filter((pack) => pack.length > 0);
|
|
194
|
+
}
|
package/src/preset.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
async function exists(path: string): Promise<boolean> {
|
|
5
|
+
try {
|
|
6
|
+
await access(path);
|
|
7
|
+
return true;
|
|
8
|
+
} catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function applyPreset(
|
|
14
|
+
targetDir: string,
|
|
15
|
+
presetName: string,
|
|
16
|
+
monorepoRoot: string,
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
const cliPath = join(monorepoRoot, 'packages', 'cli', 'src', 'cli.ts');
|
|
19
|
+
|
|
20
|
+
// TODO: Replace this subprocess fallback with a direct runPreset import once @forgeailab/spark exports it.
|
|
21
|
+
if (!(await exists(cliPath))) {
|
|
22
|
+
throw new Error(`Cannot apply preset "${presetName}" because packages/spark/src/cli.ts does not exist yet.`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const proc = Bun.spawn(['bun', '--bun', cliPath, 'preset', presetName], {
|
|
26
|
+
cwd: targetDir,
|
|
27
|
+
stdout: 'inherit',
|
|
28
|
+
stderr: 'inherit',
|
|
29
|
+
});
|
|
30
|
+
const exitCode = await proc.exited;
|
|
31
|
+
|
|
32
|
+
if (exitCode !== 0) {
|
|
33
|
+
throw new Error(`Preset "${presetName}" failed with exit code ${exitCode}.`);
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/prompts.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { cancel, confirm, isCancel, multiselect, select } from '@clack/prompts';
|
|
2
|
+
import pc from 'picocolors';
|
|
3
|
+
import type { PickerCategory, PickerPack } from './picker.ts';
|
|
4
|
+
|
|
5
|
+
const skipValue = '__skip__';
|
|
6
|
+
|
|
7
|
+
const categoryLabels: Record<PickerCategory, string> = {
|
|
8
|
+
db: 'Database',
|
|
9
|
+
auth: 'Auth',
|
|
10
|
+
sync: 'Sync',
|
|
11
|
+
ui: 'UI',
|
|
12
|
+
ai: 'AI',
|
|
13
|
+
email: 'Email',
|
|
14
|
+
analytics: 'Analytics',
|
|
15
|
+
deploy: 'Deploy',
|
|
16
|
+
infra: 'Infra',
|
|
17
|
+
testing: 'Testing',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function abortIfCancel<T>(value: T | symbol): T {
|
|
21
|
+
if (isCancel(value)) {
|
|
22
|
+
cancel('Operation cancelled.');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function optionLabel(pack: PickerPack, recommended: string | undefined): string {
|
|
30
|
+
return pack.name === recommended ? `${pack.name} (recommended)` : pack.name;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function promptCategory(
|
|
34
|
+
category: PickerCategory,
|
|
35
|
+
options: readonly PickerPack[],
|
|
36
|
+
recommended?: string,
|
|
37
|
+
): Promise<string | undefined> {
|
|
38
|
+
if (options.length === 0) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const answer = abortIfCancel(
|
|
43
|
+
await select<string>({
|
|
44
|
+
message: categoryLabels[category],
|
|
45
|
+
options: [
|
|
46
|
+
...options.map((pack) => ({
|
|
47
|
+
value: pack.name,
|
|
48
|
+
label: optionLabel(pack, recommended),
|
|
49
|
+
hint: pack.description,
|
|
50
|
+
})),
|
|
51
|
+
{
|
|
52
|
+
value: skipValue,
|
|
53
|
+
label: 'skip',
|
|
54
|
+
hint: `Do not install a ${categoryLabels[category].toLowerCase()} pack.`,
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
return answer === skipValue ? undefined : answer;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function promptMultiCategory(
|
|
64
|
+
category: PickerCategory,
|
|
65
|
+
options: readonly PickerPack[],
|
|
66
|
+
defaultChecked: readonly string[] = [],
|
|
67
|
+
): Promise<string[] | undefined> {
|
|
68
|
+
if (options.length === 0) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const answer = abortIfCancel(
|
|
73
|
+
await multiselect<string>({
|
|
74
|
+
message: categoryLabels[category],
|
|
75
|
+
options: options.map((pack) => ({
|
|
76
|
+
value: pack.name,
|
|
77
|
+
label: defaultChecked.includes(pack.name) ? `${pack.name} (recommended)` : pack.name,
|
|
78
|
+
hint: pack.description,
|
|
79
|
+
})),
|
|
80
|
+
initialValues: [...defaultChecked],
|
|
81
|
+
required: false,
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
return answer.length > 0 ? answer : undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function formatSummary(picks: readonly string[]): string {
|
|
89
|
+
const title = 'Pack plan';
|
|
90
|
+
const rows = picks.length > 0 ? picks.map((pick) => `- ${pick}`) : ['No packs selected.'];
|
|
91
|
+
const width = Math.max(title.length, ...rows.map((row) => row.length));
|
|
92
|
+
const border = `+${'-'.repeat(width + 2)}+`;
|
|
93
|
+
const line = (value: string): string => `| ${value.padEnd(width)} |`;
|
|
94
|
+
|
|
95
|
+
return [
|
|
96
|
+
pc.cyan(border),
|
|
97
|
+
pc.cyan(line(title)),
|
|
98
|
+
pc.cyan(border),
|
|
99
|
+
...rows.map((row) => pc.cyan(line(row))),
|
|
100
|
+
pc.cyan(border),
|
|
101
|
+
].join('\n');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function promptConfirmPlan(picks: readonly string[]): Promise<void> {
|
|
105
|
+
console.log(formatSummary(picks));
|
|
106
|
+
const accepted = abortIfCancel(
|
|
107
|
+
await confirm({
|
|
108
|
+
message: 'Looks good?',
|
|
109
|
+
initialValue: true,
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
if (!accepted) {
|
|
114
|
+
cancel('Cancelled');
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parseTemplateToml, type ParseError, type TemplateManifest } from '@forgeailab/spark-schema';
|
|
4
|
+
import { findMonorepoRoot } from './paths.ts';
|
|
5
|
+
|
|
6
|
+
export type TemplateMetadata = TemplateManifest;
|
|
7
|
+
|
|
8
|
+
export function getTemplatesDir(): string {
|
|
9
|
+
return process.env.CREATE_SPARK_TEMPLATES_DIR ?? join(findMonorepoRoot(), 'templates');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function formatParseError(error: ParseError): string {
|
|
13
|
+
const issues = error.issues?.map((issue) => ` - ${issue}`).join('\n');
|
|
14
|
+
return issues === undefined ? error.message : `${error.message}\n${issues}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function findTemplateManifests(dir: string): Promise<string[]> {
|
|
18
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
19
|
+
const nested = await Promise.all(
|
|
20
|
+
entries.map(async (entry) => {
|
|
21
|
+
const entryPath = join(dir, entry.name);
|
|
22
|
+
|
|
23
|
+
if (entry.isDirectory()) {
|
|
24
|
+
return findTemplateManifests(entryPath);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (entry.isFile() && entry.name === 'template.toml') {
|
|
28
|
+
return [entryPath];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return [];
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
return nested.flat();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function loadTemplateRegistry(
|
|
39
|
+
templatesDir: string = getTemplatesDir(),
|
|
40
|
+
): Promise<TemplateMetadata[]> {
|
|
41
|
+
const manifestPaths = await findTemplateManifests(templatesDir);
|
|
42
|
+
const templates = await Promise.all(
|
|
43
|
+
manifestPaths.map(async (manifestPath) => {
|
|
44
|
+
const raw = await readFile(manifestPath, 'utf8');
|
|
45
|
+
const parsed = parseTemplateToml(raw);
|
|
46
|
+
|
|
47
|
+
if (!parsed.ok) {
|
|
48
|
+
throw new Error(`Invalid template manifest at ${manifestPath}:\n${formatParseError(parsed.error)}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return parsed.data;
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return templates.sort((left, right) => left.name.localeCompare(right.name));
|
|
56
|
+
}
|
package/src/skills.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { access, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { findMonorepoRoot } from './paths.ts';
|
|
4
|
+
|
|
5
|
+
async function exists(path: string): Promise<boolean> {
|
|
6
|
+
try {
|
|
7
|
+
await access(path);
|
|
8
|
+
return true;
|
|
9
|
+
} catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function transformClaudeSkillForCodex(content: string): string {
|
|
15
|
+
if (!content.startsWith('---\n')) {
|
|
16
|
+
return content;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const endIndex = content.indexOf('\n---', 4);
|
|
20
|
+
if (endIndex === -1) {
|
|
21
|
+
return content;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const frontmatter = content.slice(4, endIndex).split('\n');
|
|
25
|
+
const filtered: string[] = [];
|
|
26
|
+
let skippingAllowedTools = false;
|
|
27
|
+
|
|
28
|
+
for (const line of frontmatter) {
|
|
29
|
+
if (line.startsWith('allowed-tools:')) {
|
|
30
|
+
skippingAllowedTools = true;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (skippingAllowedTools) {
|
|
35
|
+
if (line.trim() === '' || /^\s+-\s/.test(line)) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
skippingAllowedTools = false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
filtered.push(line);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return `---\n${filtered.join('\n').trimEnd()}\n---${content.slice(endIndex + 4)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function copyDirectory(
|
|
49
|
+
sourceDir: string,
|
|
50
|
+
targetDir: string,
|
|
51
|
+
transform?: (relativePath: string, content: Buffer) => Buffer | string,
|
|
52
|
+
relativeBase = '',
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
await mkdir(targetDir, { recursive: true });
|
|
55
|
+
|
|
56
|
+
const entries = await readdir(sourceDir, { withFileTypes: true });
|
|
57
|
+
await Promise.all(
|
|
58
|
+
entries.map(async (entry) => {
|
|
59
|
+
const sourcePath = join(sourceDir, entry.name);
|
|
60
|
+
const targetPath = join(targetDir, entry.name);
|
|
61
|
+
const relativePath = join(relativeBase, entry.name);
|
|
62
|
+
|
|
63
|
+
if (entry.isDirectory()) {
|
|
64
|
+
await copyDirectory(sourcePath, targetPath, transform, relativePath);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!entry.isFile()) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const content = await readFile(sourcePath);
|
|
73
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
74
|
+
await writeFile(targetPath, transform?.(relativePath, content) ?? content);
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function hasEntries(dir: string): Promise<boolean> {
|
|
80
|
+
try {
|
|
81
|
+
const entries = await readdir(dir);
|
|
82
|
+
return entries.length > 0;
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function tryRunSyncScript(monorepoRoot: string, targetDir: string): Promise<boolean> {
|
|
89
|
+
const scriptPath = join(monorepoRoot, 'scripts', 'sync-skills.ts');
|
|
90
|
+
if (!(await exists(scriptPath))) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const proc = Bun.spawn(['bun', 'run', scriptPath, targetDir], {
|
|
95
|
+
cwd: monorepoRoot,
|
|
96
|
+
stdout: 'pipe',
|
|
97
|
+
stderr: 'pipe',
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const [, , exitCode] = await Promise.all([
|
|
101
|
+
new Response(proc.stdout).text(),
|
|
102
|
+
new Response(proc.stderr).text(),
|
|
103
|
+
proc.exited,
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
return exitCode === 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function fallbackSyncCodexSkills(targetDir: string): Promise<void> {
|
|
110
|
+
const claudeSkillsDir = join(targetDir, '.claude', 'skills');
|
|
111
|
+
const codexSkillsDir = join(targetDir, '.codex', 'skills');
|
|
112
|
+
|
|
113
|
+
await rm(codexSkillsDir, { recursive: true, force: true });
|
|
114
|
+
await copyDirectory(claudeSkillsDir, codexSkillsDir, (relativePath, content) => {
|
|
115
|
+
if (!relativePath.endsWith('.md')) {
|
|
116
|
+
return content;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return transformClaudeSkillForCodex(content.toString('utf8'));
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function syncSkills(targetDir: string, monorepoRoot: string = findMonorepoRoot()): Promise<void> {
|
|
124
|
+
const canonicalSkillsDir = join(monorepoRoot, '.claude', 'skills');
|
|
125
|
+
const targetClaudeSkillsDir = join(targetDir, '.claude', 'skills');
|
|
126
|
+
const targetCodexSkillsDir = join(targetDir, '.codex', 'skills');
|
|
127
|
+
|
|
128
|
+
await mkdir(targetClaudeSkillsDir, { recursive: true });
|
|
129
|
+
await rm(join(targetClaudeSkillsDir, '.gitkeep'), { force: true });
|
|
130
|
+
await copyDirectory(canonicalSkillsDir, targetClaudeSkillsDir);
|
|
131
|
+
|
|
132
|
+
const scriptSucceeded = await tryRunSyncScript(monorepoRoot, targetDir);
|
|
133
|
+
if (!scriptSucceeded || !(await hasEntries(targetCodexSkillsDir))) {
|
|
134
|
+
await fallbackSyncCodexSkills(targetDir);
|
|
135
|
+
}
|
|
136
|
+
}
|