-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathindex.ts
More file actions
688 lines (608 loc) · 21.6 KB
/
index.ts
File metadata and controls
688 lines (608 loc) · 21.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import {
BoilerplateSkill,
DEFAULT_TEMPLATE_REPO,
DEFAULT_TEMPLATE_TOOL_NAME,
inspectTemplate,
PgpmPackage,
resolveBoilerplateBaseDir,
scaffoldTemplate,
scanBoilerplates,
sluggify,
} from '@pgpmjs/core';
import { resolveWorkspaceByType } from '@pgpmjs/env';
import { errors } from '@pgpmjs/types';
import { CLIOptions, Inquirerer, OptionValue, Question, registerDefaultResolver } from 'inquirerer';
const DEFAULT_MOTD = `
| _ _
=== |.===. '\\-//\`
(o o) {}o o{} (o o)
ooO--(_)--Ooo-ooO--(_)--Ooo-ooO--(_)--Ooo-
`;
export const createInitUsageText = (binaryName: string, productLabel?: string): string => {
const displayName = productLabel ?? binaryName;
return `
Init Command:
${binaryName} init [OPTIONS] [<fromPath>]
Initialize ${displayName} from a template. The template's type (workspace/module)
determines the behavior automatically.
Options:
--help, -h Show this help message
--cwd <directory> Working directory (default: current directory)
--repo <repo> Template repo (default: https://github.com/constructive-io/pgpm-boilerplates.git)
--from-branch <branch> Branch/tag to use when cloning repo
--dir <variant> Template variant directory (e.g., supabase, drizzle)
--template, -t <path> Full template path (e.g., pnpm/module) - combines dir and fromPath
--boilerplate Prompt to select from available boilerplates
--create-workspace, -w Create a workspace first, then create the module inside it
Examples:
${binaryName} init Initialize new module (default)
${binaryName} init workspace Initialize new workspace
${binaryName} init module Initialize new module explicitly
${binaryName} init workspace --dir <variant> Use variant templates
${binaryName} init --template pnpm/module Use full template path (dir + type)
${binaryName} init --boilerplate Select from available boilerplates
${binaryName} init --repo owner/repo Use templates from GitHub repository
${binaryName} init --repo owner/repo --from-branch develop Use specific branch
${binaryName} init --dir pnpm -w Create pnpm workspace + module in one command
`;
};
export default async (
argv: Partial<Record<string, any>>,
prompter: Inquirerer,
_options: CLIOptions
) => {
// Show usage if explicitly requested
if (argv.help || argv.h) {
console.log(createInitUsageText('pgpm'));
process.exit(0);
}
return handleInit(argv, prompter);
};
async function handleInit(argv: Partial<Record<string, any>>, prompter: Inquirerer) {
const { cwd = process.cwd() } = argv;
const templateRepo = (argv.repo as string) ?? DEFAULT_TEMPLATE_REPO;
const branch = argv.fromBranch as string | undefined;
const noTty = Boolean((argv as any).noTty || argv['no-tty'] || argv.tty === false || process.env.CI === 'true');
const useBoilerplatePrompt = Boolean(argv.boilerplate);
const createWorkspace = Boolean(argv.createWorkspace || argv['create-workspace'] || argv.w);
// Get fromPath from first positional arg
const positionalFromPath = argv._?.[0] as string | undefined;
// Handle --template flag: parses "dir/fromPath" format and extracts both components
// When --template is provided, it takes precedence over --dir and positional fromPath
const templateArg = argv.template as string | undefined;
let dir = argv.dir as string | undefined;
let templateFromPath: string | undefined;
if (templateArg) {
// Parse template path like "pnpm/module" into dir="pnpm" and fromPath="module"
const slashIndex = templateArg.indexOf('/');
if (slashIndex > 0) {
dir = templateArg.substring(0, slashIndex);
templateFromPath = templateArg.substring(slashIndex + 1);
} else {
// No slash - treat the whole thing as fromPath (e.g., --template workspace)
templateFromPath = templateArg;
}
}
// Handle --boilerplate flag: separate path from regular init
if (useBoilerplatePrompt) {
return handleBoilerplateInit(argv, prompter, {
positionalFromPath,
templateRepo,
branch,
dir,
noTty,
cwd,
});
}
// Regular init path: --template takes precedence, then positional arg, then default to 'module'
const fromPath = templateFromPath || positionalFromPath || 'module';
// Track if user explicitly requested module (e.g., `pgpm init module` or `--template pnpm/module`)
const wasExplicitModuleRequest = positionalFromPath === 'module' || templateFromPath === 'module';
// Inspect the template to get its type
const inspection = inspectTemplate({
fromPath,
templateRepo,
branch,
dir,
toolName: DEFAULT_TEMPLATE_TOOL_NAME,
cwd,
});
const templateType = inspection.config?.type;
// Branch based on template type
if (templateType === 'workspace') {
return handleWorkspaceInit(argv, prompter, {
fromPath,
templateRepo,
branch,
dir,
noTty,
cwd,
});
}
// Default to module init (for 'module' type, 'generic' type, or unknown types)
return handleModuleInit(argv, prompter, {
fromPath,
templateRepo,
branch,
dir,
noTty,
cwd,
requiresWorkspace: inspection.config?.requiresWorkspace,
createWorkspace,
}, wasExplicitModuleRequest);
}
interface BoilerplateInitContext {
positionalFromPath?: string;
templateRepo: string;
branch?: string;
dir?: string;
noTty: boolean;
cwd: string;
}
async function handleBoilerplateInit(
argv: Partial<Record<string, any>>,
prompter: Inquirerer,
ctx: BoilerplateInitContext
) {
let fromPath: string;
if (ctx.positionalFromPath) {
// If a positional fromPath was provided with --boilerplate, use it directly
fromPath = ctx.positionalFromPath;
} else {
// No positional arg: prompt user to select from available boilerplates
if (ctx.noTty) {
throw new Error(
'Cannot use --boilerplate without a <fromPath> argument in non-interactive mode. ' +
'Please specify a boilerplate explicitly, e.g., `pgpm init workspace --boilerplate`'
);
}
// Inspect without fromPath to get the template directory for scanning
const initialInspection = inspectTemplate({
templateRepo: ctx.templateRepo,
branch: ctx.branch,
dir: ctx.dir,
toolName: DEFAULT_TEMPLATE_TOOL_NAME,
cwd: ctx.cwd,
});
// Resolve the base directory for scanning boilerplates:
// - If --dir is specified, use the resolvedTemplatePath (bypasses .boilerplates.json)
// - Otherwise, use .boilerplates.json's dir (defaults to repo root if missing)
const baseDir = ctx.dir
? initialInspection.resolvedTemplatePath
: resolveBoilerplateBaseDir(initialInspection.templateDir);
const boilerplates = scanBoilerplates(baseDir);
if (boilerplates.length === 0) {
throw new Error(
`No boilerplates found in the template repository.\n` +
`Checked directory: ${baseDir}\n` +
`Make sure the repository contains boilerplate directories with .boilerplate.json files.`
);
}
const boilerplateQuestion: Question[] = [
{
name: 'selectedBoilerplate',
message: 'Select a boilerplate',
type: 'autocomplete',
options: boilerplates.map((bp) => bp.name),
required: true,
},
];
const boilerplateAnswer = await prompter.prompt(argv, boilerplateQuestion);
fromPath = boilerplateAnswer.selectedBoilerplate;
}
// Inspect the selected template to get its type
const inspection = inspectTemplate({
fromPath,
templateRepo: ctx.templateRepo,
branch: ctx.branch,
dir: ctx.dir,
toolName: DEFAULT_TEMPLATE_TOOL_NAME,
cwd: ctx.cwd,
});
const templateType = inspection.config?.type;
// Branch based on template type
if (templateType === 'workspace') {
return handleWorkspaceInit(argv, prompter, {
fromPath,
templateRepo: ctx.templateRepo,
branch: ctx.branch,
dir: ctx.dir,
noTty: ctx.noTty,
cwd: ctx.cwd,
});
}
// Default to module init (for 'module' type, 'generic' type, or unknown types)
// When using --boilerplate, user made an explicit choice, so treat as explicit request
return handleModuleInit(argv, prompter, {
fromPath,
templateRepo: ctx.templateRepo,
branch: ctx.branch,
dir: ctx.dir,
noTty: ctx.noTty,
cwd: ctx.cwd,
requiresWorkspace: inspection.config?.requiresWorkspace,
}, true);
}
interface InitContext {
fromPath: string;
templateRepo: string;
branch?: string;
dir?: string;
noTty: boolean;
cwd: string;
/**
* What type of workspace this template requires.
* 'pgpm' also indicates pgpm files should be created.
*/
requiresWorkspace?: 'pgpm' | 'pnpm' | 'lerna' | 'npm' | false;
/**
* If true, create a workspace first, then create the module inside it.
*/
createWorkspace?: boolean;
}
function installSkills(skills: BoilerplateSkill[], cwd: string): void {
const failed: string[] = [];
for (const entry of skills) {
const source = entry.source.includes('://')
? entry.source
: `https://github.com/${entry.source}`;
for (const skill of entry.skills) {
const cmd = `npx --yes skills add ${source} --skill ${skill} --yes`;
try {
execSync(cmd, {
cwd,
stdio: ['pipe', 'inherit', 'inherit'],
timeout: 120_000,
});
} catch {
failed.push(` npx skills add ${source} --skill ${skill}`);
}
}
}
if (failed.length > 0) {
process.stdout.write('\n⚠️ Some skills could not be installed automatically.\n');
process.stdout.write('Run the following commands manually:\n\n');
for (const cmd of failed) {
process.stdout.write(`${cmd}\n`);
}
process.stdout.write('\n');
}
}
async function handleWorkspaceInit(
argv: Partial<Record<string, any>>,
prompter: Inquirerer,
ctx: InitContext
) {
const workspaceQuestions: Question[] = [
{
name: 'name',
message: 'Enter workspace name',
required: true,
type: 'text'
}
];
const answers = await prompter.prompt(argv, workspaceQuestions);
const targetPath = path.join(ctx.cwd, sluggify(answers.name));
// Register workspace.dirname resolver so boilerplate templates can use it via defaultFrom/setFrom
const dirName = path.basename(targetPath);
registerDefaultResolver('workspace.dirname', () => dirName);
await scaffoldTemplate({
fromPath: ctx.fromPath,
outputDir: targetPath,
templateRepo: ctx.templateRepo,
branch: ctx.branch,
dir: ctx.dir,
answers: {
...argv,
...answers,
workspaceName: answers.name
},
toolName: DEFAULT_TEMPLATE_TOOL_NAME,
noTty: ctx.noTty,
cwd: ctx.cwd,
prompter
});
// Check for .motd file and print it, or use default ASCII art
const motdPath = path.join(targetPath, '.motd');
let motd = DEFAULT_MOTD;
if (fs.existsSync(motdPath)) {
try {
motd = fs.readFileSync(motdPath, 'utf8');
fs.unlinkSync(motdPath);
} catch {
// Ignore errors reading/deleting .motd
}
}
process.stdout.write(motd);
if (!motd.endsWith('\n')) {
process.stdout.write('\n');
}
// Install skills declared in .boilerplate.json
const templateInfo = inspectTemplate({
fromPath: ctx.fromPath,
templateRepo: ctx.templateRepo,
branch: ctx.branch,
dir: ctx.dir,
cwd: ctx.cwd,
});
if (templateInfo.config?.skills?.length) {
process.stdout.write('\n📦 Installing skills...\n\n');
installSkills(templateInfo.config.skills, targetPath);
}
const relPath = path.relative(process.cwd(), targetPath);
process.stdout.write(`\n✨ Enjoy!\n\ncd ./${relPath}\n`);
return { ...argv, ...answers, cwd: targetPath };
}
interface WorkspaceTemplateConfig {
repo: string;
branch?: string;
dir?: string;
}
function resolveWorkspaceTemplateRepo(options: {
templateRepo: string;
branch?: string;
dir?: string;
workspaceType: 'pgpm' | 'pnpm';
cwd: string;
}): WorkspaceTemplateConfig {
const { templateRepo, branch, dir, workspaceType, cwd } = options;
// Determine the dir to use for workspace template
// If dir is specified, use it; otherwise use the workspaceType as the dir variant
const workspaceDir = dir || workspaceType;
// Try to find workspace template in the specified repo
try {
const inspection = inspectTemplate({
fromPath: 'workspace',
templateRepo,
branch,
dir: workspaceDir,
toolName: DEFAULT_TEMPLATE_TOOL_NAME,
cwd,
});
// If we found a valid workspace template, use the specified repo
if (inspection.config?.type === 'workspace') {
return {
repo: templateRepo,
branch,
dir: workspaceDir,
};
}
} catch {
// Template not found in specified repo, fall through to default
}
// Fall back to default template repo
// Use the workspaceType as the dir variant (pgpm or pnpm)
return {
repo: DEFAULT_TEMPLATE_REPO,
branch: undefined, // Use default branch for default repo
dir: workspaceType,
};
}
async function handleModuleInit(
argv: Partial<Record<string, any>>,
prompter: Inquirerer,
ctx: InitContext,
wasExplicitModuleRequest: boolean = false
) {
// Determine workspace requirement (defaults to 'pgpm' for backward compatibility)
const workspaceType = ctx.requiresWorkspace ?? 'pgpm';
// Whether this is a pgpm-managed template (creates pgpm.plan, .control files)
const isPgpmTemplate = workspaceType === 'pgpm';
let project = new PgpmPackage(ctx.cwd);
// Track resolved workspace path for both pgpm and non-pgpm workspace types
let resolvedWorkspacePath: string | undefined;
let workspaceTypeName = '';
// Check workspace requirement based on type (skip if workspaceType is false)
if (workspaceType !== false) {
if (workspaceType === 'pgpm') {
resolvedWorkspacePath = project.workspacePath;
workspaceTypeName = 'PGPM';
} else {
resolvedWorkspacePath = resolveWorkspaceByType(ctx.cwd, workspaceType);
workspaceTypeName = workspaceType.toUpperCase();
}
if (!resolvedWorkspacePath) {
const noTty = Boolean((argv as any).noTty || argv['no-tty'] || argv.tty === false || process.env.CI === 'true');
// Handle --create-workspace flag: create workspace first, then module
if (ctx.createWorkspace && (workspaceType === 'pgpm' || workspaceType === 'pnpm')) {
// Resolve workspace template repo with fallback to default
const workspaceTemplateConfig = resolveWorkspaceTemplateRepo({
templateRepo: ctx.templateRepo,
branch: ctx.branch,
dir: ctx.dir,
workspaceType,
cwd: ctx.cwd,
});
// Create workspace first
const workspaceResult = await handleWorkspaceInit(argv, prompter, {
fromPath: 'workspace',
templateRepo: workspaceTemplateConfig.repo,
branch: workspaceTemplateConfig.branch,
dir: workspaceTemplateConfig.dir,
noTty: ctx.noTty,
cwd: ctx.cwd,
});
// Update context to point to new workspace and continue with module creation
const newCwd = workspaceResult.cwd as string;
project = new PgpmPackage(newCwd);
// Re-resolve workspace path with new cwd
if (workspaceType === 'pgpm') {
resolvedWorkspacePath = project.workspacePath;
} else {
resolvedWorkspacePath = resolveWorkspaceByType(newCwd, workspaceType);
}
// Update ctx for module creation
ctx.cwd = newCwd;
// Continue to module creation below (don't return here)
} else {
// If user explicitly requested module init or we're in non-interactive mode,
// just show the error with helpful guidance
if (wasExplicitModuleRequest || noTty) {
process.stderr.write(`Not inside a ${workspaceTypeName} workspace.\n`);
throw errors.NOT_IN_WORKSPACE({});
}
// Offer to create a workspace for pgpm templates or when --dir is specified
// (when --dir is specified, we know which workspace variant to use)
if (workspaceType === 'pgpm' || ctx.dir) {
const recoveryQuestion: Question[] = [
{
name: 'workspace',
alias: 'W',
message: `You are not inside a ${workspaceTypeName} workspace. Would you like to create a new workspace instead?`,
type: 'confirm',
required: true,
},
];
const { workspace } = await prompter.prompt(argv, recoveryQuestion);
if (workspace) {
return handleWorkspaceInit(argv, prompter, {
fromPath: 'workspace',
templateRepo: ctx.templateRepo,
branch: ctx.branch,
dir: ctx.dir,
noTty: ctx.noTty,
cwd: ctx.cwd,
});
}
}
// User declined or non-pgpm workspace type without --dir, show the error
process.stderr.write(`Not inside a ${workspaceTypeName} workspace.\n`);
throw errors.NOT_IN_WORKSPACE({});
}
}
}
// Only check workspace directory constraints if we're in a workspace
if (project.workspacePath) {
if (!project.isInsideAllowedDirs(ctx.cwd) && !project.isInWorkspace() && !project.isParentOfAllowedDirs(ctx.cwd)) {
process.stderr.write('You must be inside the workspace root or a parent directory of modules (like packages/).\n');
throw errors.NOT_IN_WORKSPACE_MODULE({});
}
}
// Build questions based on whether this is a pgpm template
const moduleQuestions: Question[] = [
{
name: 'moduleName',
message: 'Enter the module name',
required: true,
type: 'text',
},
];
// Only ask for extensions if this is a pgpm template
if (isPgpmTemplate && project.workspacePath) {
const availExtensions = await project.getAvailableModules();
moduleQuestions.push({
name: 'extensions',
message: 'Which extensions?',
options: availExtensions,
type: 'checkbox',
allowCustomOptions: true,
required: true,
default: ['plpgsql', 'uuid-ossp'],
});
}
const answers = await prompter.prompt(argv, moduleQuestions);
const modName = sluggify(answers.moduleName);
const extensions = isPgpmTemplate && answers.extensions
? answers.extensions
.filter((opt: OptionValue) => opt.selected)
.map((opt: OptionValue) => opt.name)
: [];
const templateAnswers = {
...argv,
...answers,
moduleName: modName,
packageIdentifier: (argv as any).packageIdentifier || modName
};
// Determine output path based on whether we're in a workspace
let modulePath: string;
if (project.workspacePath) {
// PGPM workspace - use workspace-aware initModule
await project.initModule({
name: modName,
description: answers.description || modName,
author: answers.author || modName,
extensions,
templateRepo: ctx.templateRepo,
templatePath: ctx.fromPath,
branch: ctx.branch,
dir: ctx.dir,
toolName: DEFAULT_TEMPLATE_TOOL_NAME,
answers: templateAnswers,
noTty: ctx.noTty,
pgpm: isPgpmTemplate,
prompter,
});
const isRoot = path.resolve(project.workspacePath) === path.resolve(ctx.cwd);
modulePath = isRoot
? path.join(ctx.cwd, 'packages', modName)
: path.join(ctx.cwd, modName);
} else if (resolvedWorkspacePath && workspaceType !== false) {
// Non-pgpm workspace (pnpm, lerna, npm) - scaffold to packages/ directory
const isRoot = path.resolve(resolvedWorkspacePath) === path.resolve(ctx.cwd);
modulePath = isRoot
? path.join(ctx.cwd, 'packages', modName)
: path.join(ctx.cwd, modName);
fs.mkdirSync(modulePath, { recursive: true });
await scaffoldTemplate({
fromPath: ctx.fromPath,
outputDir: modulePath,
templateRepo: ctx.templateRepo,
branch: ctx.branch,
dir: ctx.dir,
answers: templateAnswers,
noTty: ctx.noTty,
toolName: DEFAULT_TEMPLATE_TOOL_NAME,
cwd: ctx.cwd,
prompter
});
} else {
// Not in any workspace (requiresWorkspace: false) - scaffold to current directory
modulePath = path.join(ctx.cwd, modName);
fs.mkdirSync(modulePath, { recursive: true });
await scaffoldTemplate({
fromPath: ctx.fromPath,
outputDir: modulePath,
templateRepo: ctx.templateRepo,
branch: ctx.branch,
dir: ctx.dir,
answers: templateAnswers,
noTty: ctx.noTty,
toolName: DEFAULT_TEMPLATE_TOOL_NAME,
cwd: ctx.cwd,
prompter
});
}
const motdPath = path.join(modulePath, '.motd');
let motd = DEFAULT_MOTD;
if (fs.existsSync(motdPath)) {
try {
motd = fs.readFileSync(motdPath, 'utf8');
fs.unlinkSync(motdPath);
} catch {
// Ignore errors reading/deleting .motd
}
}
process.stdout.write(motd);
if (!motd.endsWith('\n')) {
process.stdout.write('\n');
}
// Install skills declared in .boilerplate.json
const moduleTemplateInfo = inspectTemplate({
fromPath: ctx.fromPath,
templateRepo: ctx.templateRepo,
branch: ctx.branch,
dir: ctx.dir,
cwd: ctx.cwd,
});
if (moduleTemplateInfo.config?.skills?.length) {
const skillsCwd = project.workspacePath || modulePath;
process.stdout.write('\n📦 Installing skills...\n\n');
installSkills(moduleTemplateInfo.config.skills, skillsCwd);
}
const relPath = path.relative(process.cwd(), modulePath);
process.stdout.write(`\n✨ Enjoy!\n\ncd ./${relPath}\n`);
return { ...argv, ...answers };
}