diff --git a/pgpm/cli/src/commands/init/index.ts b/pgpm/cli/src/commands/init/index.ts index 44374cc7f..7a1a37447 100644 --- a/pgpm/cli/src/commands/init/index.ts +++ b/pgpm/cli/src/commands/init/index.ts @@ -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'; @@ -117,7 +118,7 @@ async function handleInit(argv: Partial>, 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, @@ -125,6 +126,7 @@ async function handleInit(argv: Partial>, prompter: Inquirer dir, noTty, cwd, + requiresWorkspace: inspection.config?.requiresWorkspace, }, wasExplicitModuleRequest); } @@ -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, @@ -228,6 +230,7 @@ async function handleBoilerplateInit( dir: ctx.dir, noTty: ctx.noTty, cwd: ctx.cwd, + requiresWorkspace: inspection.config?.requiresWorkspace, }, true); } @@ -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( @@ -305,58 +313,77 @@ 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', @@ -364,7 +391,12 @@ async function handleModuleInit( 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, @@ -372,15 +404,17 @@ async function handleModuleInit( 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, @@ -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; @@ -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 }; } diff --git a/pgpm/core/src/core/boilerplate-scanner.ts b/pgpm/core/src/core/boilerplate-scanner.ts index dd5326b29..b320219cb 100644 --- a/pgpm/core/src/core/boilerplate-scanner.ts +++ b/pgpm/core/src/core/boilerplate-scanner.ts @@ -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'] }); } } diff --git a/pgpm/core/src/core/boilerplate-types.ts b/pgpm/core/src/core/boilerplate-types.ts index 170f116ab..8abed89da 100644 --- a/pgpm/core/src/core/boilerplate-types.ts +++ b/pgpm/core/src/core/boilerplate-types.ts @@ -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. */ @@ -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. @@ -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[]; } diff --git a/pgpm/core/src/core/class/pgpm.ts b/pgpm/core/src/core/class/pgpm.ts index 790f34d04..2482855a9 100644 --- a/pgpm/core/src/core/class/pgpm.ts +++ b/pgpm/core/src/core/class/pgpm.ts @@ -124,6 +124,11 @@ export interface InitModuleOptions { * Must be an absolute path or relative to workspace root. */ outputDir?: string; + /** + * Whether this is a pgpm-managed module that should create pgpm.plan and .control files. + * Defaults to true for backward compatibility. + */ + pgpm?: boolean; } export class PgpmPackage { @@ -436,6 +441,9 @@ export class PgpmPackage { async initModule(options: InitModuleOptions): Promise { this.ensureWorkspace(); + // Determine if this is a pgpm-managed module (defaults to true for backward compatibility) + const isPgpmModule = options.pgpm ?? true; + // If outputDir is provided, use it directly instead of createModuleDirectory let targetPath: string; if (options.outputDir) { @@ -471,8 +479,11 @@ export class PgpmPackage { cwd: this.cwd }); - this.initModuleSqitch(options.name, targetPath); - writeExtensions(targetPath, options.extensions); + // Only create pgpm files (pgpm.plan, .control, deploy/revert/verify dirs) for pgpm-managed modules + if (isPgpmModule) { + this.initModuleSqitch(options.name, targetPath); + writeExtensions(targetPath, options.extensions); + } } // ──────────────── Dependency Analysis ──────────────── diff --git a/pgpm/core/src/core/template-scaffold.ts b/pgpm/core/src/core/template-scaffold.ts index f9ce38e4b..79a619512 100644 --- a/pgpm/core/src/core/template-scaffold.ts +++ b/pgpm/core/src/core/template-scaffold.ts @@ -1,8 +1,36 @@ import os from 'os'; import path from 'path'; -import { TemplateScaffolder, BoilerplateConfig } from 'genomic'; +import { TemplateScaffolder, BoilerplateConfig as GenomicBoilerplateConfig } from 'genomic'; import type { Inquirerer, Question } from 'inquirerer'; +/** + * Supported workspace types for template requirements. + * - 'pgpm': Requires pgpm workspace (pgpm.json/pgpm.config.js) and creates pgpm.plan/.control files + * - 'pnpm': Requires pnpm workspace (pnpm-workspace.yaml) + * - 'lerna': Requires lerna workspace (lerna.json) + * - 'npm': Requires npm workspace (package.json with workspaces field) + * - false: No workspace required, can be scaffolded anywhere + */ +export type WorkspaceType = 'pgpm' | 'pnpm' | 'lerna' | 'npm' | false; + +/** + * Extended BoilerplateConfig that adds workspace requirement field. + * This field controls both workspace detection and whether pgpm-specific files are created. + */ +export interface BoilerplateConfig extends GenomicBoilerplateConfig { + /** + * Specifies what type of workspace this template requires. + * - 'pgpm': Requires pgpm workspace AND creates pgpm.plan/.control files + * - 'pnpm': Requires pnpm workspace (pnpm-workspace.yaml), no pgpm files + * - 'lerna': Requires lerna workspace (lerna.json), no pgpm files + * - 'npm': Requires npm workspace (package.json with workspaces), no pgpm files + * - false: No workspace required, no pgpm files + * + * Defaults to 'pgpm' for 'module' type (backward compatibility), false for others. + */ + requiresWorkspace?: WorkspaceType; +} + export interface InspectTemplateOptions { /** * The boilerplate path to inspect. When omitted, inspects the template diff --git a/pgpm/env/src/config.ts b/pgpm/env/src/config.ts index 686a92837..e414e257f 100644 --- a/pgpm/env/src/config.ts +++ b/pgpm/env/src/config.ts @@ -78,3 +78,72 @@ export const resolvePgpmPath = (cwd: string = process.cwd()): string | undefined return undefined; }; + +/** + * Resolve the path to a pnpm workspace by finding pnpm-workspace.yaml + */ +export const resolvePnpmWorkspace = (cwd: string = process.cwd()): string | undefined => { + try { + return walkUp(cwd, 'pnpm-workspace.yaml'); + } catch { + return undefined; + } +}; + +/** + * Resolve the path to a lerna workspace by finding lerna.json + */ +export const resolveLernaWorkspace = (cwd: string = process.cwd()): string | undefined => { + try { + return walkUp(cwd, 'lerna.json'); + } catch { + return undefined; + } +}; + +/** + * Resolve the path to an npm workspace by finding package.json with workspaces field + */ +export const resolveNpmWorkspace = (cwd: string = process.cwd()): string | undefined => { + let currentDir = path.resolve(cwd); + const root = path.parse(currentDir).root; + + while (currentDir !== root) { + const packageJsonPath = path.join(currentDir, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + if (packageJson.workspaces) { + return currentDir; + } + } catch { + // Ignore JSON parse errors + } + } + currentDir = path.dirname(currentDir); + } + return undefined; +}; + +export type WorkspaceType = 'pgpm' | 'pnpm' | 'lerna' | 'npm'; + +/** + * Resolve workspace path based on workspace type + */ +export const resolveWorkspaceByType = ( + cwd: string, + workspaceType: WorkspaceType +): string | undefined => { + switch (workspaceType) { + case 'pgpm': + return resolvePgpmPath(cwd); + case 'pnpm': + return resolvePnpmWorkspace(cwd); + case 'lerna': + return resolveLernaWorkspace(cwd); + case 'npm': + return resolveNpmWorkspace(cwd); + default: + return undefined; + } +}; diff --git a/pgpm/env/src/index.ts b/pgpm/env/src/index.ts index bf9f3f74c..7a5473ec8 100644 --- a/pgpm/env/src/index.ts +++ b/pgpm/env/src/index.ts @@ -1,5 +1,15 @@ export { getEnvOptions, getConnEnvOptions, getDeploymentEnvOptions } from './merge'; -export { loadConfigSync, loadConfigSyncFromDir, loadConfigFileSync, resolvePgpmPath } from './config'; +export { + loadConfigSync, + loadConfigSyncFromDir, + loadConfigFileSync, + resolvePgpmPath, + resolvePnpmWorkspace, + resolveLernaWorkspace, + resolveNpmWorkspace, + resolveWorkspaceByType +} from './config'; +export type { WorkspaceType } from './config'; export { getEnvVars, getNodeEnv, parseEnvBoolean } from './env'; export { walkUp, mergeArraysUnique } from './utils';