Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 126 additions & 70 deletions pgpm/cli/src/commands/init/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
scanBoilerplates,
sluggify,
} from '@pgpmjs/core';
import { resolveWorkspaceByType } from '@pgpmjs/env';
import { errors } from '@pgpmjs/types';
import { CLIOptions, Inquirerer, OptionValue, Question, registerDefaultResolver } from 'inquirerer';

Expand Down Expand Up @@ -117,14 +118,15 @@ async function handleInit(argv: Partial<Record<string, any>>, prompter: Inquirer
});
}

// Default to module init (for 'module' type or unknown types)
// 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,
}, wasExplicitModuleRequest);
}

Expand Down Expand Up @@ -219,7 +221,7 @@ async function handleBoilerplateInit(
});
}

// Default to module init (for 'module' type or unknown types)
// 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,
Expand All @@ -228,6 +230,7 @@ async function handleBoilerplateInit(
dir: ctx.dir,
noTty: ctx.noTty,
cwd: ctx.cwd,
requiresWorkspace: inspection.config?.requiresWorkspace,
}, true);
}

Expand All @@ -238,6 +241,11 @@ interface InitContext {
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;
}

async function handleWorkspaceInit(
Expand Down Expand Up @@ -305,82 +313,108 @@ async function handleModuleInit(
ctx: InitContext,
wasExplicitModuleRequest: boolean = false
) {
const project = new PgpmPackage(ctx.cwd);
// 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';

if (!project.workspacePath) {
const noTty = Boolean((argv as any).noTty || argv['no-tty'] || process.env.CI === 'true');
const project = new PgpmPackage(ctx.cwd);

// 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 PGPM workspace.\n');
throw errors.NOT_IN_WORKSPACE({});
// Check workspace requirement based on type (skip if workspaceType is false)
if (workspaceType !== false) {
let workspacePath: string | undefined;
let workspaceTypeName = '';

if (workspaceType === 'pgpm') {
workspacePath = project.workspacePath;
workspaceTypeName = 'PGPM';
} else {
workspacePath = resolveWorkspaceByType(ctx.cwd, workspaceType);
workspaceTypeName = workspaceType.toUpperCase();
}

// Offer to create a workspace instead
const recoveryQuestion: Question[] = [
{
name: 'workspace',
alias: 'w',
message: 'You are not inside a PGPM 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,
});
if (!workspacePath) {
const noTty = Boolean((argv as any).noTty || argv['no-tty'] || process.env.CI === 'true');

// 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({});
}

// Only offer to create a workspace for pgpm templates
if (workspaceType === 'pgpm') {
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, show the error
process.stderr.write(`Not inside a ${workspaceTypeName} workspace.\n`);
throw errors.NOT_IN_WORKSPACE({});
}

// User declined, show the error
process.stderr.write('Not inside a PGPM workspace.\n');
throw errors.NOT_IN_WORKSPACE({});
}

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({});
// 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({});
}
}

const availExtensions = await project.getAvailableModules();

// Note: moduleName is needed here before scaffolding because initModule creates
// the directory first, then scaffolds. The boilerplate's ____moduleName____ question
// gets skipped because the answer is already passed through. So users only see it
// once, but the definition exists in two places for this architectural reason.
// 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 = answers.extensions
.filter((opt: OptionValue) => opt.selected)
.map((opt: OptionValue) => opt.name);
const extensions = isPgpmTemplate && answers.extensions
? answers.extensions
.filter((opt: OptionValue) => opt.selected)
.map((opt: OptionValue) => opt.name)
: [];

const templateAnswers = {
...argv,
Expand All @@ -389,24 +423,47 @@ async function handleModuleInit(
packageIdentifier: (argv as any).packageIdentifier || modName
};

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
});
// Determine output path based on whether we're in a workspace
let modulePath: string;
if (project.workspacePath) {
// 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,
});

const isRoot = path.resolve(project.getWorkspacePath()!) === path.resolve(ctx.cwd);
const modulePath = isRoot
? path.join(ctx.cwd, 'packages', modName)
: path.join(ctx.cwd, modName);
const isRoot = path.resolve(project.workspacePath) === path.resolve(ctx.cwd);
modulePath = isRoot
? path.join(ctx.cwd, 'packages', modName)
: path.join(ctx.cwd, modName);
} else {
// Not in a workspace - scaffold directly 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;
Expand All @@ -423,8 +480,7 @@ async function handleModuleInit(
process.stdout.write('\n');
}

const relPath = isRoot ? `packages/${modName}` : modName;
process.stdout.write(`\n✨ Enjoy!\n\ncd ./${relPath}\n`);
process.stdout.write(`\n✨ Enjoy!\n\ncd ./${modName}\n`);

return { ...argv, ...answers };
}
25 changes: 23 additions & 2 deletions pgpm/core/src/core/boilerplate-scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,32 @@ export function scanBoilerplates(baseDir: string): ScannedBoilerplate[] {
const config = readBoilerplateConfig(boilerplatePath);

if (config) {
// Validate and normalize the type field
const validTypes = ['workspace', 'module', 'generic'] as const;
const configType = config.type as string;
const type: 'workspace' | 'module' | 'generic' = validTypes.includes(configType as any)
? (configType as 'workspace' | 'module' | 'generic')
: 'module';

// Validate and normalize requiresWorkspace field
const validWorkspaceTypes = ['pgpm', 'pnpm', 'lerna', 'npm'] as const;
let requiresWorkspace: ScannedBoilerplate['requiresWorkspace'];
if (config.requiresWorkspace === false) {
requiresWorkspace = false;
} else if (typeof config.requiresWorkspace === 'string' &&
validWorkspaceTypes.includes(config.requiresWorkspace as any)) {
requiresWorkspace = config.requiresWorkspace as 'pgpm' | 'pnpm' | 'lerna' | 'npm';
} else {
// Default: 'pgpm' for module type (backward compatibility), undefined for others
requiresWorkspace = type === 'module' ? 'pgpm' : undefined;
}

boilerplates.push({
name: entry.name,
path: boilerplatePath,
type: config.type ?? 'module',
questions: config.questions
type,
requiresWorkspace,
questions: config.questions as ScannedBoilerplate['questions']
});
}
}
Expand Down
22 changes: 10 additions & 12 deletions pgpm/core/src/core/boilerplate-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
* These types support the `.boilerplate.json` and `.boilerplates.json` configuration files.
*/

// Re-export BoilerplateConfig and WorkspaceType from template-scaffold to avoid duplication
// The extended type in template-scaffold adds workspace requirement field to the genomic base type
export type { BoilerplateConfig, WorkspaceType } from './template-scaffold';

/**
* A question to prompt the user during template scaffolding.
*/
Expand All @@ -25,17 +29,6 @@ export interface BoilerplateQuestion {
setFrom?: string;
}

/**
* Configuration for a single boilerplate template.
* Stored in `.boilerplate.json` within each template directory.
*/
export interface BoilerplateConfig {
/** The type of boilerplate: workspace or module */
type: 'workspace' | 'module';
/** Questions to prompt the user during scaffolding */
questions?: BoilerplateQuestion[];
}

/**
* Root configuration for a boilerplates repository.
* Stored in `.boilerplates.json` at the repository root.
Expand All @@ -54,7 +47,12 @@ export interface ScannedBoilerplate {
/** The full path to the boilerplate directory */
path: string;
/** The type of boilerplate */
type: 'workspace' | 'module';
type: 'workspace' | 'module' | 'generic';
/**
* What type of workspace this template requires.
* 'pgpm' also indicates pgpm files should be created.
*/
requiresWorkspace?: 'pgpm' | 'pnpm' | 'lerna' | 'npm' | false;
/** Questions from the boilerplate config */
questions?: BoilerplateQuestion[];
}
Loading