From 1c8f8c6b75db89687e3976aad00da1ddeded3ecc Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 7 Jan 2026 10:43:23 +0000 Subject: [PATCH 1/5] feat(pgpm): support non-pgpm templates in init command - Extend BoilerplateConfig with pgpm and requiresWorkspace fields - Update init command to respect these fields from .boilerplate.json - Allow modules to be created outside workspaces when requiresWorkspace is false - Skip pgpm.plan and .control file creation when pgpm is false - Maintain backward compatibility (defaults to pgpm=true, requiresWorkspace=true) --- pgpm/cli/src/commands/init/index.ts | 115 +++++++++++++++------- pgpm/core/src/core/boilerplate-scanner.ts | 13 ++- pgpm/core/src/core/boilerplate-types.ts | 21 ++-- pgpm/core/src/core/class/pgpm.ts | 15 ++- pgpm/core/src/core/template-scaffold.ts | 19 +++- 5 files changed, 129 insertions(+), 54 deletions(-) diff --git a/pgpm/cli/src/commands/init/index.ts b/pgpm/cli/src/commands/init/index.ts index 44374cc7f..d0e885993 100644 --- a/pgpm/cli/src/commands/init/index.ts +++ b/pgpm/cli/src/commands/init/index.ts @@ -117,7 +117,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 +125,8 @@ async function handleInit(argv: Partial>, prompter: Inquirer dir, noTty, cwd, + pgpm: inspection.config?.pgpm, + 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,8 @@ async function handleBoilerplateInit( dir: ctx.dir, noTty: ctx.noTty, cwd: ctx.cwd, + pgpm: inspection.config?.pgpm, + requiresWorkspace: inspection.config?.requiresWorkspace, }, true); } @@ -238,6 +242,10 @@ interface InitContext { dir?: string; noTty: boolean; cwd: string; + /** Whether this is a pgpm-managed template (creates pgpm.plan, .control files) */ + pgpm?: boolean; + /** Whether this template requires being inside a workspace */ + requiresWorkspace?: boolean; } async function handleWorkspaceInit( @@ -305,9 +313,15 @@ async function handleModuleInit( ctx: InitContext, wasExplicitModuleRequest: boolean = false ) { + // Determine if this template requires a workspace (defaults to true for backward compatibility) + const requiresWorkspace = ctx.requiresWorkspace ?? true; + // Determine if this is a pgpm-managed template (defaults to true for backward compatibility) + const isPgpmTemplate = ctx.pgpm ?? true; + const project = new PgpmPackage(ctx.cwd); - if (!project.workspacePath) { + // Only enforce workspace requirement if the template requires it + if (requiresWorkspace && !project.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, @@ -346,17 +360,15 @@ async function handleModuleInit( 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 +376,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 +389,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 +408,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.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 }); - const isRoot = path.resolve(project.getWorkspacePath()!) === path.resolve(ctx.cwd); - const modulePath = isRoot - ? path.join(ctx.cwd, 'packages', modName) - : path.join(ctx.cwd, modName); + 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 +465,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..52cac9cd7 100644 --- a/pgpm/core/src/core/boilerplate-scanner.ts +++ b/pgpm/core/src/core/boilerplate-scanner.ts @@ -74,11 +74,20 @@ 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'; + boilerplates.push({ name: entry.name, path: boilerplatePath, - type: config.type ?? 'module', - questions: config.questions + type, + pgpm: config.pgpm, + requiresWorkspace: config.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..984a7beba 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 from template-scaffold to avoid duplication +// The extended type in template-scaffold adds pgpm-specific fields to the genomic base type +export type { BoilerplateConfig } 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,11 @@ export interface ScannedBoilerplate { /** The full path to the boilerplate directory */ path: string; /** The type of boilerplate */ - type: 'workspace' | 'module'; + type: 'workspace' | 'module' | 'generic'; + /** Whether this is a pgpm-managed template */ + pgpm?: boolean; + /** Whether this template requires being inside a workspace */ + requiresWorkspace?: boolean; /** 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..d5d953b6e 100644 --- a/pgpm/core/src/core/template-scaffold.ts +++ b/pgpm/core/src/core/template-scaffold.ts @@ -1,8 +1,25 @@ 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'; +/** + * Extended BoilerplateConfig that adds pgpm-specific fields. + * These fields control whether pgpm-specific files are created and workspace requirements. + */ +export interface BoilerplateConfig extends GenomicBoilerplateConfig { + /** + * Whether this is a pgpm-managed template that creates pgpm.plan and .control files. + * Defaults to true for 'workspace' and 'module' types, false for 'generic'. + */ + pgpm?: boolean; + /** + * Whether this template requires being inside a pgpm workspace. + * Defaults to true for 'module' type, false for 'workspace' and 'generic'. + */ + requiresWorkspace?: boolean; +} + export interface InspectTemplateOptions { /** * The boilerplate path to inspect. When omitted, inspects the template From 2a32ed0e31c89347bf42d61e498f56f9fba58702 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 7 Jan 2026 12:27:30 +0000 Subject: [PATCH 2/5] refactor(pgpm): simplify to single requiresWorkspace field Replace pgpm + requiresWorkspace boolean fields with a single requiresWorkspace field that accepts workspace type strings: - 'pgpm': Requires pgpm workspace 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) - false: No workspace required, no pgpm files This simplifies the config and makes the behavior more intuitive. --- pgpm/cli/src/commands/init/index.ts | 26 ++++++++++--------- pgpm/core/src/core/boilerplate-scanner.ts | 16 ++++++++++-- pgpm/core/src/core/boilerplate-types.ts | 15 ++++++----- pgpm/core/src/core/template-scaffold.ts | 31 +++++++++++++++-------- 4 files changed, 57 insertions(+), 31 deletions(-) diff --git a/pgpm/cli/src/commands/init/index.ts b/pgpm/cli/src/commands/init/index.ts index d0e885993..6b5b6a19f 100644 --- a/pgpm/cli/src/commands/init/index.ts +++ b/pgpm/cli/src/commands/init/index.ts @@ -125,7 +125,6 @@ async function handleInit(argv: Partial>, prompter: Inquirer dir, noTty, cwd, - pgpm: inspection.config?.pgpm, requiresWorkspace: inspection.config?.requiresWorkspace, }, wasExplicitModuleRequest); } @@ -230,7 +229,6 @@ async function handleBoilerplateInit( dir: ctx.dir, noTty: ctx.noTty, cwd: ctx.cwd, - pgpm: inspection.config?.pgpm, requiresWorkspace: inspection.config?.requiresWorkspace, }, true); } @@ -242,10 +240,11 @@ interface InitContext { dir?: string; noTty: boolean; cwd: string; - /** Whether this is a pgpm-managed template (creates pgpm.plan, .control files) */ - pgpm?: boolean; - /** Whether this template requires being inside a workspace */ - requiresWorkspace?: boolean; + /** + * What type of workspace this template requires. + * 'pgpm' also indicates pgpm files should be created. + */ + requiresWorkspace?: 'pgpm' | 'pnpm' | 'lerna' | 'npm' | false; } async function handleWorkspaceInit( @@ -313,15 +312,18 @@ async function handleModuleInit( ctx: InitContext, wasExplicitModuleRequest: boolean = false ) { - // Determine if this template requires a workspace (defaults to true for backward compatibility) - const requiresWorkspace = ctx.requiresWorkspace ?? true; - // Determine if this is a pgpm-managed template (defaults to true for backward compatibility) - const isPgpmTemplate = ctx.pgpm ?? true; + // 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'; + // Whether any workspace is required + const requiresWorkspace = workspaceType !== false; const project = new PgpmPackage(ctx.cwd); - // Only enforce workspace requirement if the template requires it - if (requiresWorkspace && !project.workspacePath) { + // Only enforce pgpm workspace requirement if the template requires 'pgpm' workspace + // TODO: Add detection for pnpm, lerna, npm workspaces when those types are used + if (requiresWorkspace && workspaceType === 'pgpm' && !project.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, diff --git a/pgpm/core/src/core/boilerplate-scanner.ts b/pgpm/core/src/core/boilerplate-scanner.ts index 52cac9cd7..b320219cb 100644 --- a/pgpm/core/src/core/boilerplate-scanner.ts +++ b/pgpm/core/src/core/boilerplate-scanner.ts @@ -81,12 +81,24 @@ export function scanBoilerplates(baseDir: string): ScannedBoilerplate[] { ? (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, - pgpm: config.pgpm, - requiresWorkspace: config.requiresWorkspace, + 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 984a7beba..8abed89da 100644 --- a/pgpm/core/src/core/boilerplate-types.ts +++ b/pgpm/core/src/core/boilerplate-types.ts @@ -3,9 +3,9 @@ * These types support the `.boilerplate.json` and `.boilerplates.json` configuration files. */ -// Re-export BoilerplateConfig from template-scaffold to avoid duplication -// The extended type in template-scaffold adds pgpm-specific fields to the genomic base type -export type { BoilerplateConfig } from './template-scaffold'; +// 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. @@ -48,10 +48,11 @@ export interface ScannedBoilerplate { path: string; /** The type of boilerplate */ type: 'workspace' | 'module' | 'generic'; - /** Whether this is a pgpm-managed template */ - pgpm?: boolean; - /** Whether this template requires being inside a workspace */ - requiresWorkspace?: boolean; + /** + * 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/template-scaffold.ts b/pgpm/core/src/core/template-scaffold.ts index d5d953b6e..79a619512 100644 --- a/pgpm/core/src/core/template-scaffold.ts +++ b/pgpm/core/src/core/template-scaffold.ts @@ -4,20 +4,31 @@ import { TemplateScaffolder, BoilerplateConfig as GenomicBoilerplateConfig } fro import type { Inquirerer, Question } from 'inquirerer'; /** - * Extended BoilerplateConfig that adds pgpm-specific fields. - * These fields control whether pgpm-specific files are created and workspace requirements. + * 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 { - /** - * Whether this is a pgpm-managed template that creates pgpm.plan and .control files. - * Defaults to true for 'workspace' and 'module' types, false for 'generic'. - */ - pgpm?: boolean; /** - * Whether this template requires being inside a pgpm workspace. - * Defaults to true for 'module' type, false for 'workspace' and 'generic'. + * 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?: boolean; + requiresWorkspace?: WorkspaceType; } export interface InspectTemplateOptions { From 545aba1aedb2103e0f0d40df7eb120fb2e0adcd7 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 7 Jan 2026 13:26:38 +0000 Subject: [PATCH 3/5] feat(pgpm): add workspace detection for pnpm, lerna, npm workspaces Add helper functions to detect different workspace types: - findPnpmWorkspace: looks for pnpm-workspace.yaml - findLernaWorkspace: looks for lerna.json - findNpmWorkspace: looks for package.json with workspaces field Update handleModuleInit to use these detection functions based on the requiresWorkspace value from the template config. --- pgpm/cli/src/commands/init/index.ts | 167 +++++++++++++++++++++------- 1 file changed, 128 insertions(+), 39 deletions(-) diff --git a/pgpm/cli/src/commands/init/index.ts b/pgpm/cli/src/commands/init/index.ts index 6b5b6a19f..28fa24e34 100644 --- a/pgpm/cli/src/commands/init/index.ts +++ b/pgpm/cli/src/commands/init/index.ts @@ -16,11 +16,88 @@ import { CLIOptions, Inquirerer, OptionValue, Question, registerDefaultResolver const DEFAULT_MOTD = ` | _ _ - === |.===. '\\-//\` + === |.===. '\\-//\` (o o) {}o o{} (o o) ooO--(_)--Ooo-ooO--(_)--Ooo-ooO--(_)--Ooo- `; +/** + * Detect if we're inside a pnpm workspace by looking for pnpm-workspace.yaml + */ +function findPnpmWorkspace(startDir: string): string | null { + let currentDir = path.resolve(startDir); + const root = path.parse(currentDir).root; + + while (currentDir !== root) { + const workspaceFile = path.join(currentDir, 'pnpm-workspace.yaml'); + if (fs.existsSync(workspaceFile)) { + return currentDir; + } + currentDir = path.dirname(currentDir); + } + return null; +} + +/** + * Detect if we're inside a lerna workspace by looking for lerna.json + */ +function findLernaWorkspace(startDir: string): string | null { + let currentDir = path.resolve(startDir); + const root = path.parse(currentDir).root; + + while (currentDir !== root) { + const lernaFile = path.join(currentDir, 'lerna.json'); + if (fs.existsSync(lernaFile)) { + return currentDir; + } + currentDir = path.dirname(currentDir); + } + return null; +} + +/** + * Detect if we're inside an npm workspace by looking for package.json with workspaces field + */ +function findNpmWorkspace(startDir: string): string | null { + let currentDir = path.resolve(startDir); + 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 null; +} + +/** + * Find workspace path based on workspace type + */ +function findWorkspaceByType(startDir: string, workspaceType: 'pgpm' | 'pnpm' | 'lerna' | 'npm'): string | null { + switch (workspaceType) { + case 'pnpm': + return findPnpmWorkspace(startDir); + case 'lerna': + return findLernaWorkspace(startDir); + case 'npm': + return findNpmWorkspace(startDir); + case 'pgpm': + // pgpm workspace detection is handled by PgpmPackage.workspacePath + return null; + default: + return null; + } +} + export const createInitUsageText = (binaryName: string, productLabel?: string): string => { const displayName = productLabel ?? binaryName; @@ -316,50 +393,62 @@ async function handleModuleInit( const workspaceType = ctx.requiresWorkspace ?? 'pgpm'; // Whether this is a pgpm-managed template (creates pgpm.plan, .control files) const isPgpmTemplate = workspaceType === 'pgpm'; - // Whether any workspace is required - const requiresWorkspace = workspaceType !== false; const project = new PgpmPackage(ctx.cwd); - // Only enforce pgpm workspace requirement if the template requires 'pgpm' workspace - // TODO: Add detection for pnpm, lerna, npm workspaces when those types are used - if (requiresWorkspace && workspaceType === 'pgpm' && !project.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 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 | null = null; + let workspaceTypeName = ''; + + if (workspaceType === 'pgpm') { + workspacePath = project.workspacePath ?? null; + workspaceTypeName = 'PGPM'; + } else { + workspacePath = findWorkspaceByType(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({}); } // Only check workspace directory constraints if we're in a workspace From bb0cd97a30397c9601680986992372e33f40d2dc Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 7 Jan 2026 13:28:26 +0000 Subject: [PATCH 4/5] fix: restore original MOTD spacing --- pgpm/cli/src/commands/init/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pgpm/cli/src/commands/init/index.ts b/pgpm/cli/src/commands/init/index.ts index 28fa24e34..3c830ba41 100644 --- a/pgpm/cli/src/commands/init/index.ts +++ b/pgpm/cli/src/commands/init/index.ts @@ -16,7 +16,7 @@ import { CLIOptions, Inquirerer, OptionValue, Question, registerDefaultResolver const DEFAULT_MOTD = ` | _ _ - === |.===. '\\-//\` + === |.===. '\\-//\` (o o) {}o o{} (o o) ooO--(_)--Ooo-ooO--(_)--Ooo-ooO--(_)--Ooo- `; From 2c521a78c281020d3c14bfc8c61651cf619c5387 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 7 Jan 2026 15:28:09 +0000 Subject: [PATCH 5/5] refactor(pgpm): move workspace detection functions to @pgpmjs/env Move workspace detection functions from CLI to the env package for better reusability and consistency with existing resolvePgpmPath function: - Add resolvePnpmWorkspace (looks for pnpm-workspace.yaml) - Add resolveLernaWorkspace (looks for lerna.json) - Add resolveNpmWorkspace (looks for package.json with workspaces field) - Add resolveWorkspaceByType dispatcher function - Export WorkspaceType type Update CLI to import and use resolveWorkspaceByType from @pgpmjs/env. --- pgpm/cli/src/commands/init/index.ts | 84 ++--------------------------- pgpm/env/src/config.ts | 69 ++++++++++++++++++++++++ pgpm/env/src/index.ts | 12 ++++- 3 files changed, 84 insertions(+), 81 deletions(-) diff --git a/pgpm/cli/src/commands/init/index.ts b/pgpm/cli/src/commands/init/index.ts index 3c830ba41..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'; @@ -21,83 +22,6 @@ const DEFAULT_MOTD = ` ooO--(_)--Ooo-ooO--(_)--Ooo-ooO--(_)--Ooo- `; -/** - * Detect if we're inside a pnpm workspace by looking for pnpm-workspace.yaml - */ -function findPnpmWorkspace(startDir: string): string | null { - let currentDir = path.resolve(startDir); - const root = path.parse(currentDir).root; - - while (currentDir !== root) { - const workspaceFile = path.join(currentDir, 'pnpm-workspace.yaml'); - if (fs.existsSync(workspaceFile)) { - return currentDir; - } - currentDir = path.dirname(currentDir); - } - return null; -} - -/** - * Detect if we're inside a lerna workspace by looking for lerna.json - */ -function findLernaWorkspace(startDir: string): string | null { - let currentDir = path.resolve(startDir); - const root = path.parse(currentDir).root; - - while (currentDir !== root) { - const lernaFile = path.join(currentDir, 'lerna.json'); - if (fs.existsSync(lernaFile)) { - return currentDir; - } - currentDir = path.dirname(currentDir); - } - return null; -} - -/** - * Detect if we're inside an npm workspace by looking for package.json with workspaces field - */ -function findNpmWorkspace(startDir: string): string | null { - let currentDir = path.resolve(startDir); - 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 null; -} - -/** - * Find workspace path based on workspace type - */ -function findWorkspaceByType(startDir: string, workspaceType: 'pgpm' | 'pnpm' | 'lerna' | 'npm'): string | null { - switch (workspaceType) { - case 'pnpm': - return findPnpmWorkspace(startDir); - case 'lerna': - return findLernaWorkspace(startDir); - case 'npm': - return findNpmWorkspace(startDir); - case 'pgpm': - // pgpm workspace detection is handled by PgpmPackage.workspacePath - return null; - default: - return null; - } -} - export const createInitUsageText = (binaryName: string, productLabel?: string): string => { const displayName = productLabel ?? binaryName; @@ -398,14 +322,14 @@ async function handleModuleInit( // Check workspace requirement based on type (skip if workspaceType is false) if (workspaceType !== false) { - let workspacePath: string | null = null; + let workspacePath: string | undefined; let workspaceTypeName = ''; if (workspaceType === 'pgpm') { - workspacePath = project.workspacePath ?? null; + workspacePath = project.workspacePath; workspaceTypeName = 'PGPM'; } else { - workspacePath = findWorkspaceByType(ctx.cwd, workspaceType); + workspacePath = resolveWorkspaceByType(ctx.cwd, workspaceType); workspaceTypeName = workspaceType.toUpperCase(); } 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';