Skip to content

Commit 8150c83

Browse files
authored
Merge pull request #57 from constructive-io/feat/projects-command-group
feat(cli): group project commands under `projects`, add global `settings`
2 parents 724a2fa + 089cf48 commit 8150c83

8 files changed

Lines changed: 422 additions & 90 deletions

File tree

packages/cli/README.md

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,31 +15,42 @@ same way, so the store and config never diverge.
1515

1616
```bash
1717
npm i -g @wavegrid/cli
18-
wavegrid init # create a project + generate its secrets
19-
wavegrid users add # add a UI login (prompted)
18+
wavegrid projects create # create a project + generate its secrets
19+
wavegrid projects users add # add a UI login (prompted)
2020
wavegrid start # run server + receiver
2121
```
2222

23+
Run any command group bare (`wavegrid`, `wavegrid projects`, `wavegrid settings`,
24+
`wavegrid projects users`, …) and it prompts an interactive menu of what you can
25+
do next; stop short of any required argument and it prompts for that too. With no
26+
TTY it prints usage instead of hanging.
27+
2328
## Commands
2429

30+
Project management and everything that edits a project live under `projects`;
31+
global store setup lives under `settings`; `start` and `doctor` are top-level.
32+
2533
| Command | Purpose |
2634
| --- | --- |
27-
| `wavegrid init [name]` | Create a project in the store; **generates secrets once**; optionally add a first user. |
35+
| `wavegrid projects list` | List projects, marking the active one. |
36+
| `wavegrid projects create [name]` | Create a project in the store; **generates secrets once**; optionally add a first user. |
37+
| `wavegrid projects use <name>` | Set the active project (alias: `set`). |
38+
| `wavegrid projects config` | Print the resolved config + provenance (secret values masked). |
39+
| `wavegrid projects config set <k> <v>` | Update a project config field without re-creating it or editing JSON. Keys: `layout`/`preset` (a built-in preset id), `mode` (`auto`/`simple`/`distributed`), `port`, `host`, `ui-port`. |
40+
| `wavegrid projects secrets list` | List required secrets and whether each is set (never values). |
41+
| `wavegrid projects secrets init` | Generate any missing secrets (`--force` rotates). |
42+
| `wavegrid projects users list` | List UI usernames. |
43+
| `wavegrid projects users add [name]` | Add/replace a UI login user (password hashed). |
44+
| `wavegrid projects users rm <name>` | Remove a UI login user. |
45+
| `wavegrid projects env export` | Write a `.env` for the current project (`--file` to override). |
46+
| `wavegrid settings environment` | Show the store location + environment (paths, active project, base override). |
47+
| `wavegrid settings initialize` | Create/ensure the global store scaffold. |
2848
| `wavegrid start` | Load the active project and run server + receiver in-process. |
29-
| `wavegrid projects` | List projects, marking the active one. |
30-
| `wavegrid use <name>` | Set the active project. |
31-
| `wavegrid config` | Print the resolved config + provenance (secret values masked). |
32-
| `wavegrid secrets list` | List required secrets and whether each is set (never values). |
33-
| `wavegrid secrets init` | Generate any missing secrets (`--force` rotates). |
34-
| `wavegrid users add [name]` | Add/replace a UI login user (password hashed). |
35-
| `wavegrid users rm <name>` | Remove a UI login user. |
36-
| `wavegrid users list` | List UI usernames. |
37-
| `wavegrid config set <k> <v>` | Update a project config field without re-`init` or editing JSON. Keys: `layout`/`preset` (a built-in preset id), `mode` (`auto`/`simple`/`distributed`), `port`, `host`, `ui-port`. |
38-
| `wavegrid env export` | Write a `.env` for the current project (`--file` to override). |
3949
| `wavegrid doctor` | Diagnose this laptop (env hijacks, ports, secrets, users, shard) and — if a server is reachable — the whole installation: connected receivers + shard coverage (gaps/overlaps). `--json` for scripting, `--server ws://host:port` to point at a remote server. |
4050

41-
Every command acts on the active project unless you pass `--project <name>`
42-
(or set `WAVEGRID_PROJECT`).
51+
`init`, `config`, `secrets`, `users`, and `env` remain as top-level shortcut
52+
aliases for the `projects …` forms. Every command acts on the active project
53+
unless you pass `--project <name>` (or set `WAVEGRID_PROJECT`).
4354

4455
### Secrets & setup are explicit and one-time
4556

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { mkdtempSync } from 'fs';
2+
import { tmpdir } from 'os';
3+
import { join } from 'path';
4+
5+
import { run } from '../src/cli';
6+
import { getStore } from '../src/project';
7+
8+
function isolate(): void {
9+
process.env.APPSTASH_BASE_DIR = mkdtempSync(join(tmpdir(), 'wg-tree-'));
10+
}
11+
12+
let logged: string[] = [];
13+
const saved = { ...process.env };
14+
15+
beforeEach(() => {
16+
logged = [];
17+
jest.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
18+
logged.push(args.map(String).join(' '));
19+
});
20+
process.exitCode = 0;
21+
});
22+
afterEach(() => {
23+
process.env = { ...saved };
24+
jest.restoreAllMocks();
25+
process.exitCode = 0;
26+
});
27+
28+
// Strip ANSI so assertions match on plain text.
29+
const out = (): string => logged.join('\n').replace(/\u001b\[[0-9;]*m/g, '');
30+
31+
describe('command hierarchy (no TTY)', () => {
32+
it('bare `projects` prints the project subcommand list', async () => {
33+
isolate();
34+
await run(['projects']);
35+
const text = out();
36+
expect(text).toContain('projects list');
37+
expect(text).toContain('projects create');
38+
expect(text).toContain('projects use');
39+
expect(text).toContain('projects config');
40+
expect(text).toContain('projects secrets');
41+
expect(text).toContain('projects users');
42+
expect(text).toContain('projects env');
43+
});
44+
45+
it('bare `settings` prints the settings subcommand list', async () => {
46+
isolate();
47+
await run(['settings']);
48+
const text = out();
49+
expect(text).toContain('settings environment');
50+
expect(text).toContain('settings initialize');
51+
});
52+
53+
it('`use` is no longer a top-level command', async () => {
54+
isolate();
55+
await run(['use', 'whatever']);
56+
expect(out()).toContain('Unknown command: use');
57+
expect(process.exitCode).toBe(1);
58+
});
59+
60+
it('`projects list` lists projects and marks the active one', async () => {
61+
isolate();
62+
const store = getStore();
63+
store.createProject('alpha', { layout: { preset: 'ring-6' } });
64+
store.createProject('beta', { layout: { preset: 'grid-7x7' } });
65+
store.setActiveProject('beta');
66+
67+
await run(['projects', 'list']);
68+
const text = out();
69+
expect(text).toContain('alpha');
70+
expect(text).toContain('beta');
71+
expect(text).toContain('(active)');
72+
});
73+
74+
it('`projects use` sets the active project (grouped form)', async () => {
75+
isolate();
76+
const store = getStore();
77+
store.createProject('alpha', { layout: { preset: 'ring-6' } });
78+
store.createProject('beta', { layout: { preset: 'grid-7x7' } });
79+
store.setActiveProject('alpha');
80+
81+
await run(['projects', 'use', 'beta']);
82+
expect(getStore().getActiveProject()).toBe('beta');
83+
});
84+
85+
it('`projects set` is an alias for `projects use`', async () => {
86+
isolate();
87+
const store = getStore();
88+
store.createProject('alpha', { layout: { preset: 'ring-6' } });
89+
store.createProject('beta', { layout: { preset: 'grid-7x7' } });
90+
store.setActiveProject('alpha');
91+
92+
await run(['projects', 'set', 'beta']);
93+
expect(getStore().getActiveProject()).toBe('beta');
94+
});
95+
96+
it('`projects config set` updates a project field (nested)', async () => {
97+
isolate();
98+
const store = getStore();
99+
store.createProject('alpha', { layout: { preset: 'ring-6' } });
100+
store.setActiveProject('alpha');
101+
102+
await run(['projects', 'config', 'set', 'port', '4321']);
103+
expect(getStore().getProjectConfig('alpha')?.server?.port).toBe(4321);
104+
});
105+
106+
it('`projects config` (no sub, no TTY) prints the resolved config', async () => {
107+
isolate();
108+
const store = getStore();
109+
store.createProject('alpha', { layout: { preset: 'ring-6' } });
110+
store.setActiveProject('alpha');
111+
112+
await run(['projects', 'config']);
113+
expect(out()).toContain('Resolved configuration');
114+
});
115+
116+
it('`settings environment` shows the store root', async () => {
117+
isolate();
118+
await run(['settings', 'environment']);
119+
const text = out();
120+
expect(text).toContain('settings · environment');
121+
expect(text).toContain(process.env.APPSTASH_BASE_DIR as string);
122+
});
123+
124+
it('`settings initialize` reports the store is ready', async () => {
125+
isolate();
126+
await run(['settings', 'initialize']);
127+
expect(out()).toContain('Store ready');
128+
});
129+
130+
it('unknown project subcommand is graceful (exit 1, no crash)', async () => {
131+
isolate();
132+
await run(['projects', 'bogus']);
133+
expect(out()).toContain('Unknown projects subcommand: bogus');
134+
expect(process.exitCode).toBe(1);
135+
});
136+
137+
it('`init` still works as a top-level shortcut for project creation', async () => {
138+
isolate();
139+
await run(['init', 'gamma', '--preset', 'ring-6', '--mode', 'auto', '--yes']);
140+
expect(getStore().hasProject('gamma')).toBe(true);
141+
});
142+
});

packages/cli/__tests__/users-menu.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ describe('pickSubcommand', () => {
3636
expect(await pickSubcommand(undefined, 'users', USERS_SUBS)).toBeNull();
3737
});
3838

39-
it('prompts an autocomplete menu and returns the chosen subcommand', async () => {
39+
it('prompts a list menu and returns the chosen subcommand', async () => {
4040
const questions: Array<Record<string, unknown>> = [];
4141
const stub = {
4242
prompt: async (_argv: unknown, qs: Array<Record<string, unknown>>) => {
@@ -49,7 +49,7 @@ describe('pickSubcommand', () => {
4949
expect(chosen).toBe('add');
5050

5151
const q = questions[0];
52-
expect(q.type).toBe('autocomplete');
52+
expect(q.type).toBe('list');
5353
expect(q.name).toBe('choice');
5454
expect(String(q.message).toLowerCase()).toContain('what do you want to do?');
5555
expect((q.options as Array<{ value: string }>).map((o) => o.value)).toEqual(['list', 'add', 'rm']);

0 commit comments

Comments
 (0)