diff --git a/.agents/skills/pgpm/references/cli.md b/.agents/skills/pgpm/references/cli.md index 92f19feed..1b4fda32c 100644 --- a/.agents/skills/pgpm/references/cli.md +++ b/.agents/skills/pgpm/references/cli.md @@ -175,6 +175,30 @@ Non-interactive init requires every question to be answered by flags; see `--name --fullName --email --username --repoName --license`, plus module `--moduleName --packageIdentifier --moduleDesc --access`. +### Workspace Inspection + +**pgpm ls** — List the pgpm modules in the current workspace + +```bash +# Human-readable listing +pgpm ls + +# Names or workspace-relative paths, one per line +pgpm ls --names +pgpm ls --paths + +# JSON output for scripts and CI +pgpm ls --json +pgpm ls --paths --json +``` + +For CI package matrices, use the workspace-relative paths directly: + +```yaml +- id: list + run: echo "packages=$(pnpm exec pgpm ls --paths --json)" >> "$GITHUB_OUTPUT" +``` + ### Change Management **pgpm add** — Add a new database change diff --git a/pgpm/cli/__tests__/ls.test.ts b/pgpm/cli/__tests__/ls.test.ts new file mode 100644 index 000000000..93a465e30 --- /dev/null +++ b/pgpm/cli/__tests__/ls.test.ts @@ -0,0 +1,177 @@ +import fs from 'fs'; +import path from 'path'; + +import { TestFixture } from '../test-utils'; + +describe('pgpm ls', () => { + let fixture: TestFixture; + + beforeEach(() => { + fixture = new TestFixture(); + }); + + afterEach(() => { + fixture.cleanup(); + }); + + const createWorkspace = (modules: Array<{ + name: string; + version: string; + requires?: string[]; + }> = []) => { + const workspaceDir = path.join(fixture.tempDir, 'workspace'); + fs.mkdirSync(path.join(workspaceDir, 'packages'), { recursive: true }); + fs.writeFileSync( + path.join(workspaceDir, 'pgpm.json'), + JSON.stringify({ packages: ['packages/*'] }, null, 2) + ); + + for (const module of modules) { + const moduleDir = path.join(workspaceDir, 'packages', module.name); + fs.mkdirSync(moduleDir, { recursive: true }); + fs.writeFileSync( + path.join(moduleDir, 'pgpm.plan'), + '%syntax-version=1.0.0\n' + ); + fs.writeFileSync( + path.join(moduleDir, `${module.name}.control`), + [ + `# ${module.name} extension`, + `default_version = '${module.version}'`, + module.requires && module.requires.length > 0 + ? `requires = '${module.requires.join(',')}'` + : '' + ].filter(Boolean).join('\n') + '\n' + ); + } + + return workspaceDir; + }; + + const runLs = async (argv: Record) => { + const output: string[] = []; + const write = jest.spyOn(process.stdout, 'write').mockImplementation((chunk: any) => { + output.push(String(chunk)); + return true; + }); + try { + await fixture.runCmd(argv); + } finally { + write.mockRestore(); + } + return output.join(''); + }; + + it('prints module objects as JSON sorted by name', async () => { + const workspaceDir = createWorkspace([ + { name: 'b', version: '2.0.0', requires: ['plpgsql'] }, + { name: 'a', version: '1.0.0' } + ]); + + const output = await runLs({ + _: ['ls'], + cwd: workspaceDir, + json: true + }); + + expect(output).toBe(`[ + { + "name": "a", + "version": "1.0.0", + "path": "packages/a", + "requires": [] + }, + { + "name": "b", + "version": "2.0.0", + "path": "packages/b", + "requires": [ + "plpgsql" + ] + } +] +`); + }); + + it('prints sorted names as a single-line JSON array', async () => { + const workspaceDir = createWorkspace([ + { name: 'b', version: '2.0.0' }, + { name: 'a', version: '1.0.0' } + ]); + + const output = await runLs({ + _: ['ls'], + cwd: workspaceDir, + names: true, + json: true + }); + + expect(output).toBe('["a","b"]'); + }); + + it('prints workspace-relative paths as a single-line JSON array', async () => { + const workspaceDir = createWorkspace([ + { name: 'b', version: '2.0.0' }, + { name: 'a', version: '1.0.0' } + ]); + + const output = await runLs({ + _: ['ls'], + cwd: workspaceDir, + paths: true, + json: true + }); + + expect(output).toBe('["packages/a","packages/b"]'); + }); + + it('prints human-readable module listings', async () => { + const workspaceDir = createWorkspace([ + { name: 'b', version: '2.0.0' }, + { name: 'a', version: '1.0.0' } + ]); + + const output = await runLs({ + _: ['ls'], + cwd: workspaceDir + }); + + expect(output).toContain('a'); + expect(output).toContain('b'); + expect(output).toContain('1.0.0'); + expect(output).toContain('packages/a'); + }); + + it('prints an empty JSON array for an empty workspace', async () => { + const workspaceDir = createWorkspace(); + + const output = await runLs({ + _: ['ls'], + cwd: workspaceDir, + names: true, + json: true + }); + + expect(output).toBe('[]'); + }); + + it('fails outside a workspace', async () => { + const exit = jest.spyOn(process, 'exit').mockImplementation((code?: string | number) => { + throw new Error(`process.exit(${code})`); + }); + const error = jest.spyOn(console, 'error').mockImplementation(() => undefined); + + await expect(fixture.runCmd({ + _: ['ls'], + cwd: fixture.tempDir, + json: true + })).rejects.toThrow('process.exit(1)'); + + expect(error).toHaveBeenCalledWith( + expect.stringContaining('Not inside a pgpm workspace') + ); + + error.mockRestore(); + exit.mockRestore(); + }); +}); diff --git a/pgpm/cli/src/commands.ts b/pgpm/cli/src/commands.ts index 44bc9a571..f1efc7e69 100644 --- a/pgpm/cli/src/commands.ts +++ b/pgpm/cli/src/commands.ts @@ -19,6 +19,7 @@ import _import from './commands/import'; import init from './commands/init'; import install from './commands/install'; import kill from './commands/kill'; +import ls from './commands/ls'; import materialize from './commands/materialize'; import migrate from './commands/migrate'; import _package from './commands/package'; @@ -62,6 +63,8 @@ const ENGINE_EXEMPT_COMMANDS = new Set([ 'import', 'init', 'install', + 'ls', + 'list', 'package', 'materialize', 'plan', @@ -111,6 +114,8 @@ export const createPgpmCommandMap = (skipPgTeardown: boolean = false): Record Working directory (default: current directory) + +Examples: + pgpm ls + pgpm ls --names + pgpm ls --paths --json +`; + +interface ModuleListing { + name: string; + version: string; + path: string; + requires: string[]; +} + +export default async ( + argv: Partial, + _prompter: Inquirerer, + _options: CLIOptions +) => { + if (argv.help || argv.h) { + console.log(lsUsageText); + process.exit(0); + } + + const names = Boolean(argv.names); + const paths = Boolean(argv.paths); + const json = Boolean(argv.json); + + if (names && paths) { + await cliExitWithError('--names and --paths cannot be used together.'); + } + + const cwd = path.resolve((argv.cwd as string) || process.cwd()); + const workspace = new PgpmPackage(cwd); + const workspacePath = workspace.getWorkspacePath(); + if (!workspacePath) { + await cliExitWithError( + `Not inside a pgpm workspace: ${cwd}. Pass --cwd .` + ); + } + + const entries: ModuleListing[] = Object.entries(workspace.getModuleMap()) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, module]) => ({ + name, + version: module.version || 'unknown', + path: module.path, + requires: [...module.requires] + })); + + if (json) { + if (names) { + process.stdout.write(JSON.stringify(entries.map(entry => entry.name))); + return; + } + if (paths) { + process.stdout.write(JSON.stringify(entries.map(entry => entry.path))); + return; + } + process.stdout.write(`${JSON.stringify(entries, null, 2)}\n`); + return; + } + + if (names) { + process.stdout.write(entries.map(entry => entry.name).join('\n')); + if (entries.length > 0) process.stdout.write('\n'); + return; + } + + if (paths) { + process.stdout.write(entries.map(entry => entry.path).join('\n')); + if (entries.length > 0) process.stdout.write('\n'); + return; + } + + if (entries.length === 0) { + process.stdout.write('No modules found.\n'); + return; + } + + const nameWidth = Math.max(...entries.map(entry => entry.name.length)); + const versionWidth = Math.max(...entries.map(entry => entry.version.length)); + process.stdout.write(entries + .map(entry => + `${entry.name.padEnd(nameWidth)} ${entry.version.padEnd(versionWidth)} ${entry.path}` + ) + .join('\n') + '\n'); +}; diff --git a/pgpm/cli/src/index.ts b/pgpm/cli/src/index.ts index 3d9b58cd6..c19d9ec3d 100644 --- a/pgpm/cli/src/index.ts +++ b/pgpm/cli/src/index.ts @@ -19,6 +19,7 @@ export { default as _export } from './commands/export'; export { default as extension } from './commands/extension'; export { default as install } from './commands/install'; export { default as kill } from './commands/kill'; +export { default as ls } from './commands/ls'; export { default as migrate } from './commands/migrate'; export { default as _package } from './commands/package'; export { default as plan } from './commands/plan';