Skip to content

Commit c8ae99b

Browse files
authored
Merge pull request #1841 from constructive-io/feat/pgpm-ls-command
feat(pgpm): add `pgpm ls` for listing workspace modules
2 parents 635a587 + 5bed508 commit c8ae99b

5 files changed

Lines changed: 312 additions & 0 deletions

File tree

‎.agents/skills/pgpm/references/cli.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,30 @@ Non-interactive init requires every question to be answered by flags; see
175175
`--name --fullName --email --username --repoName --license`, plus module
176176
`--moduleName --packageIdentifier --moduleDesc --access`.
177177
178+
### Workspace Inspection
179+
180+
**pgpm ls** — List the pgpm modules in the current workspace
181+
182+
```bash
183+
# Human-readable listing
184+
pgpm ls
185+
186+
# Names or workspace-relative paths, one per line
187+
pgpm ls --names
188+
pgpm ls --paths
189+
190+
# JSON output for scripts and CI
191+
pgpm ls --json
192+
pgpm ls --paths --json
193+
```
194+
195+
For CI package matrices, use the workspace-relative paths directly:
196+
197+
```yaml
198+
- id: list
199+
run: echo "packages=$(pnpm exec pgpm ls --paths --json)" >> "$GITHUB_OUTPUT"
200+
```
201+
178202
### Change Management
179203
180204
**pgpm add** — Add a new database change

‎pgpm/cli/__tests__/ls.test.ts‎

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
4+
import { TestFixture } from '../test-utils';
5+
6+
describe('pgpm ls', () => {
7+
let fixture: TestFixture;
8+
9+
beforeEach(() => {
10+
fixture = new TestFixture();
11+
});
12+
13+
afterEach(() => {
14+
fixture.cleanup();
15+
});
16+
17+
const createWorkspace = (modules: Array<{
18+
name: string;
19+
version: string;
20+
requires?: string[];
21+
}> = []) => {
22+
const workspaceDir = path.join(fixture.tempDir, 'workspace');
23+
fs.mkdirSync(path.join(workspaceDir, 'packages'), { recursive: true });
24+
fs.writeFileSync(
25+
path.join(workspaceDir, 'pgpm.json'),
26+
JSON.stringify({ packages: ['packages/*'] }, null, 2)
27+
);
28+
29+
for (const module of modules) {
30+
const moduleDir = path.join(workspaceDir, 'packages', module.name);
31+
fs.mkdirSync(moduleDir, { recursive: true });
32+
fs.writeFileSync(
33+
path.join(moduleDir, 'pgpm.plan'),
34+
'%syntax-version=1.0.0\n'
35+
);
36+
fs.writeFileSync(
37+
path.join(moduleDir, `${module.name}.control`),
38+
[
39+
`# ${module.name} extension`,
40+
`default_version = '${module.version}'`,
41+
module.requires && module.requires.length > 0
42+
? `requires = '${module.requires.join(',')}'`
43+
: ''
44+
].filter(Boolean).join('\n') + '\n'
45+
);
46+
}
47+
48+
return workspaceDir;
49+
};
50+
51+
const runLs = async (argv: Record<string, unknown>) => {
52+
const output: string[] = [];
53+
const write = jest.spyOn(process.stdout, 'write').mockImplementation((chunk: any) => {
54+
output.push(String(chunk));
55+
return true;
56+
});
57+
try {
58+
await fixture.runCmd(argv);
59+
} finally {
60+
write.mockRestore();
61+
}
62+
return output.join('');
63+
};
64+
65+
it('prints module objects as JSON sorted by name', async () => {
66+
const workspaceDir = createWorkspace([
67+
{ name: 'b', version: '2.0.0', requires: ['plpgsql'] },
68+
{ name: 'a', version: '1.0.0' }
69+
]);
70+
71+
const output = await runLs({
72+
_: ['ls'],
73+
cwd: workspaceDir,
74+
json: true
75+
});
76+
77+
expect(output).toBe(`[
78+
{
79+
"name": "a",
80+
"version": "1.0.0",
81+
"path": "packages/a",
82+
"requires": []
83+
},
84+
{
85+
"name": "b",
86+
"version": "2.0.0",
87+
"path": "packages/b",
88+
"requires": [
89+
"plpgsql"
90+
]
91+
}
92+
]
93+
`);
94+
});
95+
96+
it('prints sorted names as a single-line JSON array', async () => {
97+
const workspaceDir = createWorkspace([
98+
{ name: 'b', version: '2.0.0' },
99+
{ name: 'a', version: '1.0.0' }
100+
]);
101+
102+
const output = await runLs({
103+
_: ['ls'],
104+
cwd: workspaceDir,
105+
names: true,
106+
json: true
107+
});
108+
109+
expect(output).toBe('["a","b"]');
110+
});
111+
112+
it('prints workspace-relative paths as a single-line JSON array', async () => {
113+
const workspaceDir = createWorkspace([
114+
{ name: 'b', version: '2.0.0' },
115+
{ name: 'a', version: '1.0.0' }
116+
]);
117+
118+
const output = await runLs({
119+
_: ['ls'],
120+
cwd: workspaceDir,
121+
paths: true,
122+
json: true
123+
});
124+
125+
expect(output).toBe('["packages/a","packages/b"]');
126+
});
127+
128+
it('prints human-readable module listings', async () => {
129+
const workspaceDir = createWorkspace([
130+
{ name: 'b', version: '2.0.0' },
131+
{ name: 'a', version: '1.0.0' }
132+
]);
133+
134+
const output = await runLs({
135+
_: ['ls'],
136+
cwd: workspaceDir
137+
});
138+
139+
expect(output).toContain('a');
140+
expect(output).toContain('b');
141+
expect(output).toContain('1.0.0');
142+
expect(output).toContain('packages/a');
143+
});
144+
145+
it('prints an empty JSON array for an empty workspace', async () => {
146+
const workspaceDir = createWorkspace();
147+
148+
const output = await runLs({
149+
_: ['ls'],
150+
cwd: workspaceDir,
151+
names: true,
152+
json: true
153+
});
154+
155+
expect(output).toBe('[]');
156+
});
157+
158+
it('fails outside a workspace', async () => {
159+
const exit = jest.spyOn(process, 'exit').mockImplementation((code?: string | number) => {
160+
throw new Error(`process.exit(${code})`);
161+
});
162+
const error = jest.spyOn(console, 'error').mockImplementation(() => undefined);
163+
164+
await expect(fixture.runCmd({
165+
_: ['ls'],
166+
cwd: fixture.tempDir,
167+
json: true
168+
})).rejects.toThrow('process.exit(1)');
169+
170+
expect(error).toHaveBeenCalledWith(
171+
expect.stringContaining('Not inside a pgpm workspace')
172+
);
173+
174+
error.mockRestore();
175+
exit.mockRestore();
176+
});
177+
});

‎pgpm/cli/src/commands.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import _import from './commands/import';
1919
import init from './commands/init';
2020
import install from './commands/install';
2121
import kill from './commands/kill';
22+
import ls from './commands/ls';
2223
import materialize from './commands/materialize';
2324
import migrate from './commands/migrate';
2425
import _package from './commands/package';
@@ -62,6 +63,8 @@ const ENGINE_EXEMPT_COMMANDS = new Set([
6263
'import',
6364
'init',
6465
'install',
66+
'ls',
67+
'list',
6568
'package',
6669
'materialize',
6770
'plan',
@@ -111,6 +114,8 @@ export const createPgpmCommandMap = (skipPgTeardown: boolean = false): Record<st
111114
tag: pgt(tag),
112115
kill: pgt(kill),
113116
install: pgt(install),
117+
ls,
118+
list: ls,
114119
migrate: pgt(migrate),
115120
materialize,
116121
analyze: pgt(analyze),

‎pgpm/cli/src/commands/ls.ts‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { PgpmPackage } from '@pgpmjs/core';
2+
import { cliExitWithError, CLIOptions, Inquirerer, ParsedArgs } from 'inquirerer';
3+
import path from 'path';
4+
5+
const lsUsageText = `
6+
List Command:
7+
8+
pgpm ls [OPTIONS]
9+
10+
List the pgpm modules in the current workspace.
11+
12+
Options:
13+
--help, -h Show this help message
14+
--json Print JSON output
15+
--names Print module names only
16+
--paths Print workspace-relative module paths only
17+
--cwd <dir> Working directory (default: current directory)
18+
19+
Examples:
20+
pgpm ls
21+
pgpm ls --names
22+
pgpm ls --paths --json
23+
`;
24+
25+
interface ModuleListing {
26+
name: string;
27+
version: string;
28+
path: string;
29+
requires: string[];
30+
}
31+
32+
export default async (
33+
argv: Partial<ParsedArgs>,
34+
_prompter: Inquirerer,
35+
_options: CLIOptions
36+
) => {
37+
if (argv.help || argv.h) {
38+
console.log(lsUsageText);
39+
process.exit(0);
40+
}
41+
42+
const names = Boolean(argv.names);
43+
const paths = Boolean(argv.paths);
44+
const json = Boolean(argv.json);
45+
46+
if (names && paths) {
47+
await cliExitWithError('--names and --paths cannot be used together.');
48+
}
49+
50+
const cwd = path.resolve((argv.cwd as string) || process.cwd());
51+
const workspace = new PgpmPackage(cwd);
52+
const workspacePath = workspace.getWorkspacePath();
53+
if (!workspacePath) {
54+
await cliExitWithError(
55+
`Not inside a pgpm workspace: ${cwd}. Pass --cwd <workspace-directory>.`
56+
);
57+
}
58+
59+
const entries: ModuleListing[] = Object.entries(workspace.getModuleMap())
60+
.sort(([left], [right]) => left.localeCompare(right))
61+
.map(([name, module]) => ({
62+
name,
63+
version: module.version || 'unknown',
64+
path: module.path,
65+
requires: [...module.requires]
66+
}));
67+
68+
if (json) {
69+
if (names) {
70+
process.stdout.write(JSON.stringify(entries.map(entry => entry.name)));
71+
return;
72+
}
73+
if (paths) {
74+
process.stdout.write(JSON.stringify(entries.map(entry => entry.path)));
75+
return;
76+
}
77+
process.stdout.write(`${JSON.stringify(entries, null, 2)}\n`);
78+
return;
79+
}
80+
81+
if (names) {
82+
process.stdout.write(entries.map(entry => entry.name).join('\n'));
83+
if (entries.length > 0) process.stdout.write('\n');
84+
return;
85+
}
86+
87+
if (paths) {
88+
process.stdout.write(entries.map(entry => entry.path).join('\n'));
89+
if (entries.length > 0) process.stdout.write('\n');
90+
return;
91+
}
92+
93+
if (entries.length === 0) {
94+
process.stdout.write('No modules found.\n');
95+
return;
96+
}
97+
98+
const nameWidth = Math.max(...entries.map(entry => entry.name.length));
99+
const versionWidth = Math.max(...entries.map(entry => entry.version.length));
100+
process.stdout.write(entries
101+
.map(entry =>
102+
`${entry.name.padEnd(nameWidth)} ${entry.version.padEnd(versionWidth)} ${entry.path}`
103+
)
104+
.join('\n') + '\n');
105+
};

‎pgpm/cli/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export { default as _export } from './commands/export';
1919
export { default as extension } from './commands/extension';
2020
export { default as install } from './commands/install';
2121
export { default as kill } from './commands/kill';
22+
export { default as ls } from './commands/ls';
2223
export { default as migrate } from './commands/migrate';
2324
export { default as _package } from './commands/package';
2425
export { default as plan } from './commands/plan';

0 commit comments

Comments
 (0)