diff --git a/pgpm/cli/__tests__/init.boilerplate.test.ts b/pgpm/cli/__tests__/init.boilerplate.test.ts new file mode 100644 index 000000000..8137d9a59 --- /dev/null +++ b/pgpm/cli/__tests__/init.boilerplate.test.ts @@ -0,0 +1,98 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { DEFAULT_TEMPLATE_REPO, PGLITE_TEMPLATE_REPO } from '@pgpmjs/core'; + +import { + persistBoilerplateSource, + readBoilerplateSource, + resolveInitTemplateRepo, +} from '../src/commands/init/boilerplate'; + +describe('resolveInitTemplateRepo', () => { + it('defaults to the pgpm boilerplates repo (not explicit)', () => { + expect(resolveInitTemplateRepo({})).toEqual({ + templateRepo: DEFAULT_TEMPLATE_REPO, + repoWasExplicit: false, + }); + }); + + it('maps --pglite to the pglite boilerplates repo (explicit)', () => { + expect(resolveInitTemplateRepo({ pglite: true })).toEqual({ + templateRepo: PGLITE_TEMPLATE_REPO, + repoWasExplicit: true, + }); + }); + + it('lets --repo win over --pglite', () => { + const custom = 'https://github.com/acme/my-boilerplates.git'; + expect(resolveInitTemplateRepo({ repo: custom, pglite: true })).toEqual({ + templateRepo: custom, + repoWasExplicit: true, + }); + }); +}); + +describe('persist/read boilerplate source', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgpm-boilerplate-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('records the source into an existing pgpm.json and reads it back', () => { + fs.writeFileSync( + path.join(dir, 'pgpm.json'), + `${JSON.stringify({ packages: ['packages/*'] }, null, 2)}\n` + ); + + persistBoilerplateSource(dir, { repo: PGLITE_TEMPLATE_REPO }); + + const config = JSON.parse(fs.readFileSync(path.join(dir, 'pgpm.json'), 'utf8')); + expect(config.packages).toEqual(['packages/*']); + expect(config.boilerplates).toEqual({ repo: PGLITE_TEMPLATE_REPO }); + + expect(readBoilerplateSource(dir)).toEqual({ + repo: PGLITE_TEMPLATE_REPO, + branch: undefined, + dir: undefined, + }); + }); + + it('persists optional branch and dir when provided', () => { + fs.writeFileSync( + path.join(dir, 'pgpm.json'), + `${JSON.stringify({ packages: ['packages/*'] }, null, 2)}\n` + ); + + persistBoilerplateSource(dir, { + repo: PGLITE_TEMPLATE_REPO, + branch: 'main', + dir: 'pglite', + }); + + expect(readBoilerplateSource(dir)).toEqual({ + repo: PGLITE_TEMPLATE_REPO, + branch: 'main', + dir: 'pglite', + }); + }); + + it('is a no-op when there is no pgpm.json (does not create one)', () => { + persistBoilerplateSource(dir, { repo: PGLITE_TEMPLATE_REPO }); + expect(fs.existsSync(path.join(dir, 'pgpm.json'))).toBe(false); + }); + + it('returns undefined when the workspace has no recorded source', () => { + fs.writeFileSync( + path.join(dir, 'pgpm.json'), + `${JSON.stringify({ packages: ['packages/*'] }, null, 2)}\n` + ); + expect(readBoilerplateSource(dir)).toBeUndefined(); + }); +}); diff --git a/pgpm/cli/__tests__/init.test.ts b/pgpm/cli/__tests__/init.test.ts index 4d2f1805a..b68b237b3 100644 --- a/pgpm/cli/__tests__/init.test.ts +++ b/pgpm/cli/__tests__/init.test.ts @@ -2,14 +2,15 @@ jest.setTimeout(60000); process.env.PGPM_SKIP_UPDATE_CHECK = 'true'; process.env.PGPM_SKIP_SKILL_INSTALL = 'true'; -import { PgpmPackage } from '@pgpmjs/core'; -import { existsSync } from 'fs'; +import { PgpmPackage, PGLITE_TEMPLATE_REPO } from '@pgpmjs/core'; +import { existsSync, readFileSync } from 'fs'; import { sync as glob } from 'glob'; import { Inquirerer, ParsedArgs } from 'inquirerer'; import * as path from 'path'; import { commands } from '../src/commands'; import { + addInitDefaults, setupTests, TestEnvironment, TestFixture, @@ -630,4 +631,75 @@ describe('cmds:init', () => { 120000 ); }); + + describe('--pglite flag', () => { + const shouldSkipGitHubTests = process.env.CI === 'true' && !process.env.ALLOW_NETWORK_TESTS; + + (shouldSkipGitHubTests ? it.skip : it)( + 'records the pglite repo on the workspace and inherits it for modules', + async () => { + const { mockInput, mockOutput } = environment; + const prompter = new Inquirerer({ + input: mockInput, + output: mockOutput, + noTty: true + }); + + const wsName = 'ws-pglite'; + const wsRoot = path.join(fixture.tempDir, wsName); + + // 1) Create a pglite workspace. --pglite must NOT be overridden by a + // default repo, so build argv directly (withInitDefaults injects --repo). + await commands(addInitDefaults({ + _: ['init', 'workspace'], + cwd: fixture.tempDir, + name: wsName, + workspace: true, + pglite: true, + fromBranch: 'main' + }), prompter, { + noTty: true, + input: mockInput, + output: mockOutput, + version: '1.0.0', + minimistOpts: {} + }); + + // Workspace records the pglite boilerplate source so modules inherit it. + const wsConfig = JSON.parse( + readFileSync(path.join(wsRoot, 'pgpm.json'), 'utf8') + ); + expect(wsConfig.boilerplates?.repo).toBe(PGLITE_TEMPLATE_REPO); + + // 2) A bare `pgpm init` (no --pglite) inside the workspace inherits it. + const modName = 'pglite-mod'; + await commands(addInitDefaults({ + _: ['init'], + cwd: wsRoot, + moduleName: modName, + name: modName, + extensions: ['plpgsql'], + fromBranch: 'main' + }), prompter, { + noTty: true, + input: mockInput, + output: mockOutput, + version: '1.0.0', + minimistOpts: {} + }); + + const modDir = path.join(wsRoot, 'packages', modName); + expect(existsSync(path.join(modDir, 'pgpm.plan'))).toBe(true); + + // The inherited pglite module template swaps in pglite-test. + const modPkg = JSON.parse( + readFileSync(path.join(modDir, 'package.json'), 'utf8') + ); + const devDeps = modPkg.devDependencies ?? {}; + expect(devDeps['pglite-test']).toBeDefined(); + expect(devDeps['pgsql-test']).toBeUndefined(); + }, + 180000 + ); + }); }); diff --git a/pgpm/cli/src/commands/init/boilerplate.ts b/pgpm/cli/src/commands/init/boilerplate.ts new file mode 100644 index 000000000..967aa68a8 --- /dev/null +++ b/pgpm/cli/src/commands/init/boilerplate.ts @@ -0,0 +1,78 @@ +import fs from 'fs'; +import path from 'path'; + +import { + DEFAULT_TEMPLATE_REPO, + PGLITE_TEMPLATE_REPO, + PgpmPackage, +} from '@pgpmjs/core'; + +/** + * A template/boilerplate source, recorded on a workspace so that modules + * created inside it inherit the same repo without re-specifying flags. + */ +export interface BoilerplateSource { + repo: string; + branch?: string; + dir?: string; +} + +/** + * Resolve the template repo for an `init` invocation from its flags. + * + * Precedence: explicit `--repo` wins, then `--pglite` (sugar for the pglite + * boilerplates repo), otherwise the default repo. + */ +export function resolveInitTemplateRepo(flags: { + repo?: unknown; + pglite?: boolean; +}): { templateRepo: string; repoWasExplicit: boolean } { + const pglite = Boolean(flags.pglite); + const repoWasExplicit = typeof flags.repo === 'string' || pglite; + const templateRepo = + typeof flags.repo === 'string' + ? flags.repo + : pglite + ? PGLITE_TEMPLATE_REPO + : DEFAULT_TEMPLATE_REPO; + return { templateRepo, repoWasExplicit }; +} + +/** + * Record the boilerplate source on a freshly scaffolded workspace's `pgpm.json` + * so that `pgpm init` (module) inside it inherits the same template repo. + * Only writes into an existing pgpm.json (pgpm.config.js workspaces are skipped). + */ +export function persistBoilerplateSource( + workspaceDir: string, + source: BoilerplateSource +): void { + const configPath = path.join(workspaceDir, 'pgpm.json'); + if (!fs.existsSync(configPath)) return; + + try { + const raw = fs.readFileSync(configPath, 'utf8'); + const config = JSON.parse(raw); + config.boilerplates = { + repo: source.repo, + ...(source.branch ? { branch: source.branch } : {}), + ...(source.dir ? { dir: source.dir } : {}), + }; + fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); + } catch { + // Non-fatal: inheritance is a convenience, not a correctness requirement. + } +} + +/** + * Read a previously recorded boilerplate source from the enclosing workspace's + * config (see {@link persistBoilerplateSource}). + */ +export function readBoilerplateSource(cwd: string): BoilerplateSource | undefined { + const project = new PgpmPackage(cwd); + const recorded = project.config?.boilerplates; + if (recorded?.repo) { + return { repo: recorded.repo, branch: recorded.branch, dir: recorded.dir }; + } + return undefined; +} diff --git a/pgpm/cli/src/commands/init/index.ts b/pgpm/cli/src/commands/init/index.ts index ae3037f57..9387b2b3a 100644 --- a/pgpm/cli/src/commands/init/index.ts +++ b/pgpm/cli/src/commands/init/index.ts @@ -18,6 +18,12 @@ import { resolveWorkspaceByType } from '@pgpmjs/env'; import { errors } from '@pgpmjs/types'; import { CLIOptions, Inquirerer, OptionValue, Question, registerDefaultResolver } from 'inquirerer'; +import { + persistBoilerplateSource, + readBoilerplateSource, + resolveInitTemplateRepo, +} from './boilerplate'; + const DEFAULT_MOTD = ` | _ _ === |.===. '\\-//\` @@ -40,6 +46,9 @@ Options: --help, -h Show this help message --cwd Working directory (default: current directory) --repo Template repo (default: https://github.com/constructive-io/pgpm-boilerplates.git) + --pglite Use the PGlite boilerplates (in-process WASM Postgres, no server/Docker). + Sugar for --repo https://github.com/constructive-io/pglite-boilerplates.git. + Recorded on the workspace so later \`init\` calls inherit it automatically. --from-branch Branch/tag to use when cloning repo --dir Template variant directory (e.g., supabase, drizzle) --template, -t Full template path (e.g., pnpm/module) - combines dir and fromPath @@ -57,6 +66,8 @@ Examples: ${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 + ${binaryName} init workspace --pglite Create a PGlite (in-process) workspace + ${binaryName} init --pglite Create a PGlite module (inherited automatically inside a --pglite workspace) `; }; @@ -76,7 +87,13 @@ export default async ( async function handleInit(argv: Partial>, prompter: Inquirerer) { const { cwd = process.cwd() } = argv; - const templateRepo = (argv.repo as string) ?? DEFAULT_TEMPLATE_REPO; + // `--pglite` is sugar for the pglite boilerplates repo; `--repo` overrides it. + // `repoWasExplicit` means a module created inside a workspace should NOT + // inherit the workspace's recorded repo (the user pinned one). + const { templateRepo, repoWasExplicit } = resolveInitTemplateRepo({ + repo: argv.repo, + pglite: Boolean(argv.pglite), + }); 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); @@ -144,6 +161,7 @@ async function handleInit(argv: Partial>, prompter: Inquirer noTty, cwd, useNpxSkills, + repoWasExplicit, }); } @@ -158,6 +176,7 @@ async function handleInit(argv: Partial>, prompter: Inquirer requiresWorkspace: inspection.config?.requiresWorkspace, createWorkspace, useNpxSkills, + repoWasExplicit, }, wasExplicitModuleRequest); } @@ -288,6 +307,12 @@ interface InitContext { * If true, use npx skills CLI instead of built-in shallow clone. */ useNpxSkills?: boolean; + /** + * True when the user pinned a template source explicitly (`--repo`/`--pglite`). + * When false, a module created inside a workspace inherits the workspace's + * recorded boilerplate repo (see `PgpmWorkspaceConfig.boilerplates`). + */ + repoWasExplicit?: boolean; } function installSkills(skills: BoilerplateSkill[], cwd: string, useNpxSkills: boolean): void { @@ -393,6 +418,17 @@ async function handleWorkspaceInit( prompter }); + // Record a non-default template source on the workspace so that modules + // created later inside it (`pgpm init`) inherit the same boilerplate repo + // without re-specifying `--pglite`/`--repo`. + if (ctx.templateRepo !== DEFAULT_TEMPLATE_REPO) { + persistBoilerplateSource(targetPath, { + repo: ctx.templateRepo, + branch: ctx.branch, + dir: ctx.dir, + }); + } + // Check for .motd file and print it, or use default ASCII art const motdPath = path.join(targetPath, '.motd'); let motd = DEFAULT_MOTD; @@ -443,9 +479,14 @@ function resolveWorkspaceTemplateRepo(options: { }): 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; + // Determine the dir to use for workspace template. + // - Explicit --dir always wins. + // - For the default repo (which holds several families), fall back to the + // workspaceType (pgpm/pnpm) as the variant dir. + // - For a custom/single-family repo (e.g. --pglite), leave dir undefined so + // the repo's own .boilerplates.json resolves the family (e.g. `pglite/`). + const isDefaultRepo = templateRepo === DEFAULT_TEMPLATE_REPO; + const workspaceDir = dir || (isDefaultRepo ? workspaceType : undefined); // Try to find workspace template in the specified repo try { @@ -485,6 +526,18 @@ async function handleModuleInit( ctx: InitContext, wasExplicitModuleRequest: boolean = false ) { + // Inherit the boilerplate source recorded on the enclosing workspace (e.g. by + // `pgpm init workspace --pglite`) unless the user pinned one explicitly. This + // makes a bare `pgpm init` inside a pglite workspace scaffold pglite modules. + if (!ctx.repoWasExplicit) { + const inherited = readBoilerplateSource(ctx.cwd); + if (inherited) { + ctx.templateRepo = inherited.repo; + ctx.branch = inherited.branch; + ctx.dir = inherited.dir; + } + } + // 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) @@ -528,6 +581,7 @@ async function handleModuleInit( dir: workspaceTemplateConfig.dir, noTty: ctx.noTty, cwd: ctx.cwd, + repoWasExplicit: ctx.repoWasExplicit, }); // Update context to point to new workspace and continue with module creation @@ -576,6 +630,7 @@ async function handleModuleInit( dir: ctx.dir, noTty: ctx.noTty, cwd: ctx.cwd, + repoWasExplicit: ctx.repoWasExplicit, }); } } diff --git a/pgpm/core/src/core/template-scaffold.ts b/pgpm/core/src/core/template-scaffold.ts index a298dc64e..d7ff32acd 100644 --- a/pgpm/core/src/core/template-scaffold.ts +++ b/pgpm/core/src/core/template-scaffold.ts @@ -101,6 +101,8 @@ export interface ScaffoldTemplateResult { export const DEFAULT_TEMPLATE_REPO = 'https://github.com/constructive-io/pgpm-boilerplates.git'; +export const PGLITE_TEMPLATE_REPO = + 'https://github.com/constructive-io/pglite-boilerplates.git'; export const DEFAULT_TEMPLATE_TTL_MS = 1 * 24 * 60 * 60 * 1000; // 1 day export const DEFAULT_TEMPLATE_TOOL_NAME = 'pgpm'; diff --git a/pgpm/types/src/pgpm.ts b/pgpm/types/src/pgpm.ts index 865281341..ab9a08024 100644 --- a/pgpm/types/src/pgpm.ts +++ b/pgpm/types/src/pgpm.ts @@ -209,6 +209,20 @@ export interface PgpmWorkspaceConfig { }; /** Deployment configuration for the workspace */ deployment?: Omit; + /** + * Template source recorded at scaffold time when the workspace is created + * from a non-default boilerplate repo (e.g. via `pgpm init workspace --pglite` + * or `--repo`). `pgpm init` reads this so modules created inside the workspace + * inherit the same boilerplate source without re-specifying the flag. + */ + boilerplates?: { + /** Template repository URL */ + repo: string; + /** Branch/tag to clone */ + branch?: string; + /** Template variant directory */ + dir?: string; + }; } /**