@nemus-cli/nemus 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +29 -5
- package/bin/workspace.js +12 -0
- package/dist/commands/analyze-deps.js +5 -12
- package/dist/commands/archive.js +13 -18
- package/dist/commands/branch/create.js +5 -12
- package/dist/commands/branch/switch.js +14 -25
- package/dist/commands/cache/manager.js +13 -24
- package/dist/commands/cleanup.js +14 -26
- package/dist/commands/config.js +82 -0
- package/dist/commands/configure-claude.js +4 -8
- package/dist/commands/configure.js +38 -32
- package/dist/commands/dashboard/session-picker.js +19 -26
- package/dist/commands/dashboard/workspace-picker.js +19 -26
- package/dist/commands/delete.js +15 -30
- package/dist/commands/ghq-status.js +7 -12
- package/dist/commands/go.js +18 -27
- package/dist/commands/history.js +6 -13
- package/dist/commands/list.js +20 -29
- package/dist/commands/remove-repo.js +21 -29
- package/dist/commands/save-context.js +5 -10
- package/dist/commands/sessions.js +19 -25
- package/dist/commands/suite/create.js +27 -53
- package/dist/commands/suite/delete.js +13 -25
- package/dist/commands/suite/export.js +20 -36
- package/dist/commands/suite/import.js +9 -20
- package/dist/commands/suite/use.js +9 -17
- package/dist/utils/config-schema.js +57 -0
- package/dist/utils/editor.js +35 -0
- package/dist/utils/prompt.js +26 -0
- package/dist/utils/prompts.js +86 -118
- package/package.json +3 -6
- package/src/commands/analyze-deps.ts +5 -9
- package/src/commands/archive.ts +5 -7
- package/src/commands/branch/create.ts +5 -9
- package/src/commands/branch/switch.ts +14 -22
- package/src/commands/cache/manager.ts +13 -21
- package/src/commands/cleanup.ts +14 -23
- package/src/commands/config.ts +52 -1
- package/src/commands/configure-claude.ts +4 -5
- package/src/commands/configure.ts +35 -29
- package/src/commands/dashboard/session-picker.ts +6 -11
- package/src/commands/dashboard/workspace-picker.ts +6 -11
- package/src/commands/delete.test.ts +36 -42
- package/src/commands/delete.ts +15 -27
- package/src/commands/ghq-status.ts +5 -7
- package/src/commands/go.ts +18 -25
- package/src/commands/history.ts +6 -10
- package/src/commands/list.test.ts +19 -26
- package/src/commands/list.ts +24 -31
- package/src/commands/remove-repo.ts +11 -17
- package/src/commands/save-context.ts +3 -5
- package/src/commands/sessions.ts +6 -10
- package/src/commands/suite/create.ts +28 -50
- package/src/commands/suite/delete.ts +14 -23
- package/src/commands/suite/export.ts +20 -33
- package/src/commands/suite/import.ts +9 -17
- package/src/commands/suite/use.ts +9 -14
- package/src/utils/config-schema.test.ts +49 -0
- package/src/utils/config-schema.ts +65 -0
- package/src/utils/editor.test.ts +37 -0
- package/src/utils/editor.ts +48 -0
- package/src/utils/prompt.ts +16 -0
- package/src/utils/prompts.test.ts +70 -41
- package/src/utils/prompts.ts +98 -128
|
@@ -13,7 +13,7 @@ import { colorize } from '../../utils/colors';
|
|
|
13
13
|
import { listSuites } from '../../utils/suite';
|
|
14
14
|
import { runPostCloneHooks } from '../../utils/hooks';
|
|
15
15
|
import { GitHubRepo } from '../../types';
|
|
16
|
-
import
|
|
16
|
+
import { select } from '../../utils/prompt';
|
|
17
17
|
import { validateWorkspaceName, checkWorkspaceExists, sanitizeWorkspaceName, resolveWorkspaceNameConflict } from '../../utils/validation';
|
|
18
18
|
|
|
19
19
|
export interface SuiteUseOpts {
|
|
@@ -64,19 +64,14 @@ export async function main(opts?: SuiteUseOpts) {
|
|
|
64
64
|
}
|
|
65
65
|
selectedSuite = found;
|
|
66
66
|
} else {
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
name: '
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
short: s.name,
|
|
76
|
-
})),
|
|
77
|
-
pageSize: 15,
|
|
78
|
-
},
|
|
79
|
-
]);
|
|
67
|
+
const suite = await select({
|
|
68
|
+
message: 'Select a suite:',
|
|
69
|
+
choices: suites.map(s => ({
|
|
70
|
+
name: `${s.name} (${s.entries.length} repos)${s.description ? ` - ${s.description}` : ''}`,
|
|
71
|
+
value: s,
|
|
72
|
+
})),
|
|
73
|
+
pageSize: 15,
|
|
74
|
+
});
|
|
80
75
|
selectedSuite = suite;
|
|
81
76
|
}
|
|
82
77
|
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
parseConfigValue,
|
|
8
8
|
applyConfigSet,
|
|
9
9
|
applyConfigUnset,
|
|
10
|
+
validateTypedValue,
|
|
11
|
+
reviewConfigFileText,
|
|
10
12
|
formatConfigValue,
|
|
11
13
|
} from './config-schema';
|
|
12
14
|
|
|
@@ -95,6 +97,53 @@ describe('applyConfigSet / applyConfigUnset (pure, immutable)', () => {
|
|
|
95
97
|
});
|
|
96
98
|
});
|
|
97
99
|
|
|
100
|
+
describe('validateTypedValue (already-typed, as from the JSON file)', () => {
|
|
101
|
+
it('accepts correctly-typed values', () => {
|
|
102
|
+
expect(validateTypedValue('autoReportBugs', true)).toEqual({ ok: true });
|
|
103
|
+
expect(validateTypedValue('cloneProtocol', 'https')).toEqual({ ok: true });
|
|
104
|
+
expect(validateTypedValue('githubOrg', '')).toEqual({ ok: true }); // allowEmpty
|
|
105
|
+
expect(validateTypedValue('workspacesDir', '/x')).toEqual({ ok: true });
|
|
106
|
+
});
|
|
107
|
+
it('rejects wrong types and bad enum/empty values', () => {
|
|
108
|
+
expect(validateTypedValue('autoReportBugs', 'yes').ok).toBe(false); // string, not boolean
|
|
109
|
+
expect(validateTypedValue('cloneProtocol', 'ftp').ok).toBe(false);
|
|
110
|
+
expect(validateTypedValue('cloneProtocol', 42 as unknown).ok).toBe(false);
|
|
111
|
+
expect(validateTypedValue('workspacesDir', ' ').ok).toBe(false); // required, blank
|
|
112
|
+
expect(validateTypedValue('installMcp', 1 as unknown).ok).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('reviewConfigFileText (for `config edit`)', () => {
|
|
117
|
+
it('flags unparseable JSON', () => {
|
|
118
|
+
const r = reviewConfigFileText('{ not json');
|
|
119
|
+
expect(r.parseError).toBe(true);
|
|
120
|
+
expect(r.ok).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
it('flags non-object JSON (null / array / scalar) without throwing', () => {
|
|
123
|
+
for (const t of ['null', '42', '"str"', '[1,2]']) {
|
|
124
|
+
const r = reviewConfigFileText(t);
|
|
125
|
+
expect(r.notObject).toBe(true);
|
|
126
|
+
expect(r.ok).toBe(false);
|
|
127
|
+
expect(r.unknownKeys).toEqual([]); // no numeric-index "keys" from an array
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
it('reports unknown keys but stays ok if known values are valid', () => {
|
|
131
|
+
const r = reviewConfigFileText(JSON.stringify({ githubOrg: 'acme', bogus: 1, nope: true }));
|
|
132
|
+
expect(r.unknownKeys.sort()).toEqual(['bogus', 'nope']);
|
|
133
|
+
expect(r.ok).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
it('catches invalid VALUES the same way config set would', () => {
|
|
136
|
+
const r = reviewConfigFileText(JSON.stringify({ cloneProtocol: 'ftp', autoReportBugs: 'yes' }));
|
|
137
|
+
expect(r.ok).toBe(false);
|
|
138
|
+
expect(r.invalid.join('\n')).toMatch(/cloneProtocol must be one of/);
|
|
139
|
+
expect(r.invalid.join('\n')).toMatch(/autoReportBugs must be a boolean/);
|
|
140
|
+
});
|
|
141
|
+
it('accepts a clean object', () => {
|
|
142
|
+
const r = reviewConfigFileText(JSON.stringify({ cloneProtocol: 'https', installMcp: true }));
|
|
143
|
+
expect(r).toMatchObject({ parseError: false, notObject: false, unknownKeys: [], invalid: [], ok: true });
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
98
147
|
describe('formatConfigValue', () => {
|
|
99
148
|
it('renders booleans and empty strings predictably', () => {
|
|
100
149
|
expect(formatConfigValue(true)).toBe('true');
|
|
@@ -80,6 +80,29 @@ export function parseConfigValue(key: ConfigKey, raw: string): ParseResult {
|
|
|
80
80
|
return { ok: true, value: trimmed };
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Validate an already-typed value (as it appears in the JSON config file) for
|
|
85
|
+
* `key` against its field spec. This is the parse-free sibling of
|
|
86
|
+
* parseConfigValue (which coerces a CLI string): it checks that a boolean field
|
|
87
|
+
* holds a boolean, an enum holds an allowed string, and a string field holds a
|
|
88
|
+
* (non-empty, unless allowEmpty) string. Used by `config edit` so a hand-edit
|
|
89
|
+
* is validated the same way `config set` validates. Pure + unit-tested.
|
|
90
|
+
*/
|
|
91
|
+
export function validateTypedValue(key: ConfigKey, value: unknown): { ok: true } | { ok: false; error: string } {
|
|
92
|
+
const spec: FieldSpec = CONFIG_SCHEMA[key];
|
|
93
|
+
if (spec.type === 'boolean') {
|
|
94
|
+
return typeof value === 'boolean' ? { ok: true } : { ok: false, error: `${key} must be a boolean` };
|
|
95
|
+
}
|
|
96
|
+
if (spec.type === 'enum') {
|
|
97
|
+
return typeof value === 'string' && (spec.values as readonly string[]).includes(value)
|
|
98
|
+
? { ok: true }
|
|
99
|
+
: { ok: false, error: `${key} must be one of: ${spec.values.join(', ')}` };
|
|
100
|
+
}
|
|
101
|
+
if (typeof value !== 'string') return { ok: false, error: `${key} must be a string` };
|
|
102
|
+
if (!spec.allowEmpty && value.trim() === '') return { ok: false, error: `${key} cannot be empty` };
|
|
103
|
+
return { ok: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
83
106
|
/** Apply a `set` to a config object, returning a NEW config or an error. Pure. */
|
|
84
107
|
export function applyConfigSet(
|
|
85
108
|
current: UserConfig,
|
|
@@ -110,3 +133,45 @@ export function applyConfigUnset(
|
|
|
110
133
|
export function formatConfigValue(value: unknown): string {
|
|
111
134
|
return typeof value === 'boolean' ? String(value) : String(value ?? '');
|
|
112
135
|
}
|
|
136
|
+
|
|
137
|
+
export interface ConfigFileReview {
|
|
138
|
+
/** JSON.parse failed. */
|
|
139
|
+
parseError: boolean;
|
|
140
|
+
/** Parsed, but not a plain object (null / array / scalar). */
|
|
141
|
+
notObject: boolean;
|
|
142
|
+
/** Keys present in the file that aren't recognized config keys. */
|
|
143
|
+
unknownKeys: string[];
|
|
144
|
+
/** Validation error messages for known keys holding invalid values. */
|
|
145
|
+
invalid: string[];
|
|
146
|
+
/** True when the file is a usable config object (may still have warnings). */
|
|
147
|
+
ok: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Review the raw text of a hand-edited config file the same way `config set`
|
|
152
|
+
* validates: it must parse, be a plain object, only use known keys, and every
|
|
153
|
+
* known key present must hold a schema-valid value. Pure so `config edit` can be
|
|
154
|
+
* fully unit-tested without spawning an editor.
|
|
155
|
+
*/
|
|
156
|
+
export function reviewConfigFileText(text: string): ConfigFileReview {
|
|
157
|
+
const base = { parseError: false, notObject: false, unknownKeys: [] as string[], invalid: [] as string[] };
|
|
158
|
+
let raw: unknown;
|
|
159
|
+
try {
|
|
160
|
+
raw = JSON.parse(text);
|
|
161
|
+
} catch {
|
|
162
|
+
return { ...base, parseError: true, ok: false };
|
|
163
|
+
}
|
|
164
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
165
|
+
return { ...base, notObject: true, ok: false };
|
|
166
|
+
}
|
|
167
|
+
const obj = raw as Record<string, unknown>;
|
|
168
|
+
const known = new Set<string>(CONFIG_KEYS);
|
|
169
|
+
const unknownKeys = Object.keys(obj).filter((k) => !known.has(k));
|
|
170
|
+
const invalid: string[] = [];
|
|
171
|
+
for (const key of CONFIG_KEYS) {
|
|
172
|
+
if (!(key in obj)) continue;
|
|
173
|
+
const res = validateTypedValue(key, obj[key]);
|
|
174
|
+
if (!res.ok) invalid.push(res.error);
|
|
175
|
+
}
|
|
176
|
+
return { parseError: false, notObject: false, unknownKeys, invalid, ok: invalid.length === 0 };
|
|
177
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { resolveEditor, openInEditor } from './editor';
|
|
3
|
+
|
|
4
|
+
describe('resolveEditor', () => {
|
|
5
|
+
it('prefers $VISUAL over $EDITOR', () => {
|
|
6
|
+
expect(resolveEditor({ VISUAL: 'code --wait', EDITOR: 'vim' }, 'darwin')).toEqual(['code', '--wait']);
|
|
7
|
+
});
|
|
8
|
+
it('falls back to $EDITOR, splitting flags', () => {
|
|
9
|
+
expect(resolveEditor({ EDITOR: 'emacs -nw' }, 'linux')).toEqual(['emacs', '-nw']);
|
|
10
|
+
});
|
|
11
|
+
it('platform default when neither is set', () => {
|
|
12
|
+
expect(resolveEditor({}, 'win32')).toEqual(['notepad']);
|
|
13
|
+
expect(resolveEditor({}, 'linux')).toEqual(['vi']);
|
|
14
|
+
expect(resolveEditor({ EDITOR: ' ' }, 'darwin')).toEqual(['vi']); // blank ignored
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('openInEditor', () => {
|
|
19
|
+
it('launches editor argv[0] + flags + file, inheriting stdio', () => {
|
|
20
|
+
const spawn = vi.fn().mockReturnValue({ status: 0 }) as any;
|
|
21
|
+
const res = openInEditor('/tmp/config.json', { spawn, env: { EDITOR: 'code --wait' }, platform: 'darwin' });
|
|
22
|
+
expect(spawn).toHaveBeenCalledWith('code', ['--wait', '/tmp/config.json'], { stdio: 'inherit' });
|
|
23
|
+
expect(res).toEqual({ ok: true, editor: 'code', code: 0 });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('reports a non-zero editor exit as not-ok', () => {
|
|
27
|
+
const spawn = vi.fn().mockReturnValue({ status: 1 }) as any;
|
|
28
|
+
expect(openInEditor('/f', { spawn, env: { EDITOR: 'vi' } }).ok).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('reports a missing editor (ENOENT) with a clear message', () => {
|
|
32
|
+
const spawn = vi.fn().mockReturnValue({ error: Object.assign(new Error('x'), { code: 'ENOENT' }) }) as any;
|
|
33
|
+
const res = openInEditor('/f', { spawn, env: { EDITOR: 'nope' } });
|
|
34
|
+
expect(res.ok).toBe(false);
|
|
35
|
+
expect(res.error).toMatch(/not found/);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the user's preferred editor as an argv array. Honors `$VISUAL` then
|
|
5
|
+
* `$EDITOR` (the long-standing Unix convention — `VISUAL` wins for full-screen
|
|
6
|
+
* editors), falling back to `notepad` on Windows and `vi` elsewhere. The env
|
|
7
|
+
* value may include flags (e.g. `code --wait`, `emacs -nw`), so it's split on
|
|
8
|
+
* whitespace into a command + args. Pure + unit-tested.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveEditor(
|
|
11
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
12
|
+
platform: NodeJS.Platform = process.platform,
|
|
13
|
+
): string[] {
|
|
14
|
+
const raw = (env.VISUAL || env.EDITOR || '').trim();
|
|
15
|
+
if (raw) return raw.split(/\s+/);
|
|
16
|
+
return platform === 'win32' ? ['notepad'] : ['vi'];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface EditorResult {
|
|
20
|
+
ok: boolean;
|
|
21
|
+
/** Editor argv[0] that was launched. */
|
|
22
|
+
editor: string;
|
|
23
|
+
/** Process exit code, when the editor ran and exited normally. */
|
|
24
|
+
code?: number;
|
|
25
|
+
/** Populated when the editor couldn't be launched at all. */
|
|
26
|
+
error?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Open `file` in the resolved editor, inheriting the terminal so the editor is
|
|
31
|
+
* interactive. Returns a structured result rather than throwing so the caller
|
|
32
|
+
* controls messaging/exit. `spawn` is injected for tests.
|
|
33
|
+
*/
|
|
34
|
+
export function openInEditor(
|
|
35
|
+
file: string,
|
|
36
|
+
deps: { spawn?: typeof spawnSync; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {},
|
|
37
|
+
): EditorResult {
|
|
38
|
+
const spawn = deps.spawn ?? spawnSync;
|
|
39
|
+
const [cmd, ...args] = resolveEditor(deps.env, deps.platform);
|
|
40
|
+
const res = spawn(cmd, [...args, file], { stdio: 'inherit' });
|
|
41
|
+
if (res.error) {
|
|
42
|
+
const err = res.error as NodeJS.ErrnoException;
|
|
43
|
+
const reason = err.code === 'ENOENT' ? `editor "${cmd}" not found` : err.message;
|
|
44
|
+
return { ok: false, editor: cmd, error: reason };
|
|
45
|
+
}
|
|
46
|
+
const code = typeof res.status === 'number' ? res.status : 1;
|
|
47
|
+
return { ok: code === 0, editor: cmd, code };
|
|
48
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive prompt primitives.
|
|
3
|
+
*
|
|
4
|
+
* We depend on the modular `@inquirer/prompts` (the maintained successor to the
|
|
5
|
+
* classic `inquirer` package and its `inquirer-autocomplete-prompt` plugin).
|
|
6
|
+
* All prompt call sites import from HERE, not from `@inquirer/prompts` directly,
|
|
7
|
+
* so there is a single place to:
|
|
8
|
+
* - centralize the ESM-only package (loaded from our CommonJS build via
|
|
9
|
+
* Node 22's stable `require(esm)` — the CLI requires Node >= 22), and
|
|
10
|
+
* - stub prompts in tests (mock '../utils/prompt').
|
|
11
|
+
*
|
|
12
|
+
* The classic array API (`inquirer.prompt([{ type, name, message }])` returning
|
|
13
|
+
* `{ name: value }`) is replaced by these functions, which take an options
|
|
14
|
+
* object and return the answer VALUE directly.
|
|
15
|
+
*/
|
|
16
|
+
export { confirm, input, select, checkbox, password, search, Separator } from '@inquirer/prompts';
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
|
|
3
|
-
const {
|
|
4
|
-
|
|
3
|
+
const { mockSearch, mockInput } = vi.hoisted(() => ({
|
|
4
|
+
mockSearch: vi.fn(),
|
|
5
|
+
mockInput: vi.fn(),
|
|
5
6
|
}));
|
|
6
7
|
|
|
7
|
-
// Mock
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
vi.
|
|
16
|
-
|
|
8
|
+
// Mock the modular prompt module. promptMultiWorkspaceSelection uses search();
|
|
9
|
+
// the modular API returns the selected VALUE directly (not { name: value }).
|
|
10
|
+
vi.mock('./prompt', () => ({
|
|
11
|
+
search: mockSearch,
|
|
12
|
+
input: mockInput,
|
|
13
|
+
confirm: vi.fn(),
|
|
14
|
+
select: vi.fn(),
|
|
15
|
+
checkbox: vi.fn(),
|
|
16
|
+
password: vi.fn(),
|
|
17
|
+
Separator: class {},
|
|
17
18
|
}));
|
|
18
19
|
|
|
19
20
|
vi.mock('fuzzy', () => ({
|
|
@@ -35,7 +36,8 @@ vi.mock('./validation', () => ({
|
|
|
35
36
|
sanitizeWorkspaceName: vi.fn((input: string) => input),
|
|
36
37
|
}));
|
|
37
38
|
|
|
38
|
-
import { promptMultiWorkspaceSelection } from './prompts';
|
|
39
|
+
import { promptMultiWorkspaceSelection, promptWorkspaceName } from './prompts';
|
|
40
|
+
import { validateWorkspaceName, sanitizeWorkspaceName } from './validation';
|
|
39
41
|
|
|
40
42
|
const makeWorkspaces = (...names: string[]) =>
|
|
41
43
|
names.map(name => ({
|
|
@@ -44,6 +46,33 @@ const makeWorkspaces = (...names: string[]) =>
|
|
|
44
46
|
metadata: { repositories: [], createdAt: new Date().toISOString() },
|
|
45
47
|
}));
|
|
46
48
|
|
|
49
|
+
describe('promptWorkspaceName', () => {
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
vi.clearAllMocks();
|
|
52
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('validates the SANITIZED name, not the raw input (classic filter-before-validate parity)', async () => {
|
|
56
|
+
// Classic inquirer ran filter (sanitize) BEFORE validate, so a name like
|
|
57
|
+
// "My Workspace" was validated as "my-workspace". Regression guard.
|
|
58
|
+
(sanitizeWorkspaceName as any).mockImplementation((s: string) =>
|
|
59
|
+
s.trim().toLowerCase().replace(/ +/g, '-'));
|
|
60
|
+
(validateWorkspaceName as any).mockReturnValue(true);
|
|
61
|
+
|
|
62
|
+
mockInput.mockImplementation(async (opts: any) => {
|
|
63
|
+
// Simulate the user submitting a raw value containing a space.
|
|
64
|
+
opts.validate('My Workspace');
|
|
65
|
+
return 'My Workspace';
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const result = await promptWorkspaceName();
|
|
69
|
+
|
|
70
|
+
// validate must have seen the sanitized value, and the return is sanitized.
|
|
71
|
+
expect(validateWorkspaceName).toHaveBeenCalledWith('my-workspace');
|
|
72
|
+
expect(result).toBe('my-workspace');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
47
76
|
describe('promptMultiWorkspaceSelection', () => {
|
|
48
77
|
beforeEach(() => {
|
|
49
78
|
vi.clearAllMocks();
|
|
@@ -51,10 +80,10 @@ describe('promptMultiWorkspaceSelection', () => {
|
|
|
51
80
|
});
|
|
52
81
|
|
|
53
82
|
it('returns selected workspace names', async () => {
|
|
54
|
-
|
|
55
|
-
.mockResolvedValueOnce(
|
|
56
|
-
.mockResolvedValueOnce(
|
|
57
|
-
.mockResolvedValueOnce(
|
|
83
|
+
mockSearch
|
|
84
|
+
.mockResolvedValueOnce('ws-alpha')
|
|
85
|
+
.mockResolvedValueOnce('ws-beta')
|
|
86
|
+
.mockResolvedValueOnce('done');
|
|
58
87
|
|
|
59
88
|
const result = await promptMultiWorkspaceSelection(
|
|
60
89
|
makeWorkspaces('ws-alpha', 'ws-beta', 'ws-gamma')
|
|
@@ -65,10 +94,10 @@ describe('promptMultiWorkspaceSelection', () => {
|
|
|
65
94
|
|
|
66
95
|
it('requires at least one selection before accepting done', async () => {
|
|
67
96
|
// First "done" should be rejected, then select one, then done
|
|
68
|
-
|
|
69
|
-
.mockResolvedValueOnce(
|
|
70
|
-
.mockResolvedValueOnce(
|
|
71
|
-
.mockResolvedValueOnce(
|
|
97
|
+
mockSearch
|
|
98
|
+
.mockResolvedValueOnce('done')
|
|
99
|
+
.mockResolvedValueOnce('ws-alpha')
|
|
100
|
+
.mockResolvedValueOnce('done');
|
|
72
101
|
|
|
73
102
|
const result = await promptMultiWorkspaceSelection(
|
|
74
103
|
makeWorkspaces('ws-alpha', 'ws-beta')
|
|
@@ -76,13 +105,13 @@ describe('promptMultiWorkspaceSelection', () => {
|
|
|
76
105
|
|
|
77
106
|
expect(result).toEqual(['ws-alpha']);
|
|
78
107
|
// Should have prompted 3 times (done rejected, select, done accepted)
|
|
79
|
-
expect(
|
|
108
|
+
expect(mockSearch).toHaveBeenCalledTimes(3);
|
|
80
109
|
});
|
|
81
110
|
|
|
82
111
|
it('auto-completes when all workspaces are selected', async () => {
|
|
83
|
-
|
|
84
|
-
.mockResolvedValueOnce(
|
|
85
|
-
.mockResolvedValueOnce(
|
|
112
|
+
mockSearch
|
|
113
|
+
.mockResolvedValueOnce('ws-alpha')
|
|
114
|
+
.mockResolvedValueOnce('ws-beta');
|
|
86
115
|
|
|
87
116
|
const result = await promptMultiWorkspaceSelection(
|
|
88
117
|
makeWorkspaces('ws-alpha', 'ws-beta')
|
|
@@ -90,13 +119,13 @@ describe('promptMultiWorkspaceSelection', () => {
|
|
|
90
119
|
|
|
91
120
|
expect(result).toEqual(['ws-alpha', 'ws-beta']);
|
|
92
121
|
// Only 2 prompts needed - loop breaks when no available names left
|
|
93
|
-
expect(
|
|
122
|
+
expect(mockSearch).toHaveBeenCalledTimes(2);
|
|
94
123
|
});
|
|
95
124
|
|
|
96
125
|
it('returns single workspace selection', async () => {
|
|
97
|
-
|
|
98
|
-
.mockResolvedValueOnce(
|
|
99
|
-
.mockResolvedValueOnce(
|
|
126
|
+
mockSearch
|
|
127
|
+
.mockResolvedValueOnce('ws-only')
|
|
128
|
+
.mockResolvedValueOnce('done');
|
|
100
129
|
|
|
101
130
|
const result = await promptMultiWorkspaceSelection(
|
|
102
131
|
makeWorkspaces('ws-only', 'ws-other')
|
|
@@ -110,7 +139,7 @@ describe('promptMultiWorkspaceSelection', () => {
|
|
|
110
139
|
throw new Error('process.exit');
|
|
111
140
|
});
|
|
112
141
|
|
|
113
|
-
|
|
142
|
+
mockSearch.mockRejectedValueOnce(new Error('User force closed'));
|
|
114
143
|
|
|
115
144
|
await expect(
|
|
116
145
|
promptMultiWorkspaceSelection(makeWorkspaces('ws-alpha'))
|
|
@@ -122,9 +151,9 @@ describe('promptMultiWorkspaceSelection', () => {
|
|
|
122
151
|
|
|
123
152
|
it('prints summary with correct singular form', async () => {
|
|
124
153
|
const logSpy = vi.spyOn(console, 'log');
|
|
125
|
-
|
|
126
|
-
.mockResolvedValueOnce(
|
|
127
|
-
.mockResolvedValueOnce(
|
|
154
|
+
mockSearch
|
|
155
|
+
.mockResolvedValueOnce('ws-alpha')
|
|
156
|
+
.mockResolvedValueOnce('done');
|
|
128
157
|
|
|
129
158
|
await promptMultiWorkspaceSelection(makeWorkspaces('ws-alpha', 'ws-beta'));
|
|
130
159
|
|
|
@@ -136,10 +165,10 @@ describe('promptMultiWorkspaceSelection', () => {
|
|
|
136
165
|
|
|
137
166
|
it('prints summary with correct plural form', async () => {
|
|
138
167
|
const logSpy = vi.spyOn(console, 'log');
|
|
139
|
-
|
|
140
|
-
.mockResolvedValueOnce(
|
|
141
|
-
.mockResolvedValueOnce(
|
|
142
|
-
.mockResolvedValueOnce(
|
|
168
|
+
mockSearch
|
|
169
|
+
.mockResolvedValueOnce('ws-alpha')
|
|
170
|
+
.mockResolvedValueOnce('ws-beta')
|
|
171
|
+
.mockResolvedValueOnce('done');
|
|
143
172
|
|
|
144
173
|
await promptMultiWorkspaceSelection(
|
|
145
174
|
makeWorkspaces('ws-alpha', 'ws-beta', 'ws-gamma')
|
|
@@ -153,10 +182,10 @@ describe('promptMultiWorkspaceSelection', () => {
|
|
|
153
182
|
|
|
154
183
|
it('prints green confirmation after each selection', async () => {
|
|
155
184
|
const logSpy = vi.spyOn(console, 'log');
|
|
156
|
-
|
|
157
|
-
.mockResolvedValueOnce(
|
|
158
|
-
.mockResolvedValueOnce(
|
|
159
|
-
.mockResolvedValueOnce(
|
|
185
|
+
mockSearch
|
|
186
|
+
.mockResolvedValueOnce('ws-alpha')
|
|
187
|
+
.mockResolvedValueOnce('ws-beta')
|
|
188
|
+
.mockResolvedValueOnce('done');
|
|
160
189
|
|
|
161
190
|
await promptMultiWorkspaceSelection(
|
|
162
191
|
makeWorkspaces('ws-alpha', 'ws-beta', 'ws-gamma')
|