@nemus-cli/nemus 0.2.1
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 +86 -0
- package/LICENSE +21 -0
- package/README.md +275 -0
- package/assets/README.md +53 -0
- package/assets/banner.png +0 -0
- package/assets/banner.svg +66 -0
- package/assets/favicon-32.png +0 -0
- package/assets/favicon-64.png +0 -0
- package/assets/icon-256.png +0 -0
- package/assets/icon.svg +23 -0
- package/assets/logo-256.png +0 -0
- package/assets/logo-512.png +0 -0
- package/assets/logo-wordmark.svg +35 -0
- package/assets/logo.svg +32 -0
- package/bin/workspace.js +130 -0
- package/dist/cli/ai-prompt.js +421 -0
- package/dist/cli/dashboard/AgentList.js +61 -0
- package/dist/cli/dashboard/DashboardSidebar.js +280 -0
- package/dist/cli/dashboard/HelpFooter.js +73 -0
- package/dist/cli/dashboard/StatusBadge.js +50 -0
- package/dist/cli/dashboard/index.js +11 -0
- package/dist/cli/dashboard/types.js +2 -0
- package/dist/cli/tui.js +48 -0
- package/dist/commands/ai-prompt.js +22 -0
- package/dist/commands/analyze-deps.js +138 -0
- package/dist/commands/archive.js +113 -0
- package/dist/commands/branch/create.js +119 -0
- package/dist/commands/branch/index.js +104 -0
- package/dist/commands/branch/merge.js +90 -0
- package/dist/commands/branch/rebase.js +84 -0
- package/dist/commands/branch/switch.js +165 -0
- package/dist/commands/cache/index.js +80 -0
- package/dist/commands/cache/manager.js +198 -0
- package/dist/commands/cleanup.js +153 -0
- package/dist/commands/configure-claude.js +55 -0
- package/dist/commands/configure.js +231 -0
- package/dist/commands/create.js +204 -0
- package/dist/commands/dashboard/hook-handler.js +162 -0
- package/dist/commands/dashboard/index.js +258 -0
- package/dist/commands/dashboard/launcher.js +40 -0
- package/dist/commands/dashboard/session-picker.js +106 -0
- package/dist/commands/dashboard/sidebar.js +39 -0
- package/dist/commands/dashboard/workspace-picker.js +95 -0
- package/dist/commands/delete.js +194 -0
- package/dist/commands/deprecated-aliases.js +44 -0
- package/dist/commands/diff.js +129 -0
- package/dist/commands/doctor.js +141 -0
- package/dist/commands/generate-docs.js +77 -0
- package/dist/commands/ghq-status.js +72 -0
- package/dist/commands/go.js +154 -0
- package/dist/commands/history.js +124 -0
- package/dist/commands/list.js +191 -0
- package/dist/commands/mcp/index.js +69 -0
- package/dist/commands/migrate.js +296 -0
- package/dist/commands/remove-repo.js +217 -0
- package/dist/commands/report-bug.js +112 -0
- package/dist/commands/run.js +147 -0
- package/dist/commands/save-context.js +171 -0
- package/dist/commands/sessions.js +129 -0
- package/dist/commands/status.js +138 -0
- package/dist/commands/suite/create.js +212 -0
- package/dist/commands/suite/delete.js +64 -0
- package/dist/commands/suite/export.js +126 -0
- package/dist/commands/suite/import.js +128 -0
- package/dist/commands/suite/index.js +94 -0
- package/dist/commands/suite/list.js +49 -0
- package/dist/commands/suite/use.js +227 -0
- package/dist/commands/sync.js +117 -0
- package/dist/commands/update.js +203 -0
- package/dist/mcp/install.js +550 -0
- package/dist/mcp/server.js +398 -0
- package/dist/mcp/tools.js +922 -0
- package/dist/pi-extensions/permission-sync.ts.template +41 -0
- package/dist/pi-extensions/permissions-gate.ts.template +69 -0
- package/dist/pi-extensions/pr-fix-trigger.ts.template +124 -0
- package/dist/pi-extensions/review-reminder.ts.template +58 -0
- package/dist/pi-extensions/workspace-input-status.ts.template +919 -0
- package/dist/program.js +136 -0
- package/dist/scripts/workspace-table.py +394 -0
- package/dist/types/dashboard.js +11 -0
- package/dist/types/index.js +2 -0
- package/dist/utils/agent-config.js +263 -0
- package/dist/utils/agent-state.js +178 -0
- package/dist/utils/banner.js +24 -0
- package/dist/utils/branch-operations.js +232 -0
- package/dist/utils/bug-report.js +355 -0
- package/dist/utils/cache.js +168 -0
- package/dist/utils/claude-integration.js +617 -0
- package/dist/utils/claude-sessions.js +260 -0
- package/dist/utils/cleanup-operations.js +120 -0
- package/dist/utils/colors.js +26 -0
- package/dist/utils/command-helpers.js +44 -0
- package/dist/utils/config.js +150 -0
- package/dist/utils/context-file.js +33 -0
- package/dist/utils/dashboard-hooks.js +172 -0
- package/dist/utils/dependency-analyzer.js +234 -0
- package/dist/utils/diff-operations.js +80 -0
- package/dist/utils/doc-generator.js +184 -0
- package/dist/utils/ghq-integration.js +332 -0
- package/dist/utils/git-operations.js +237 -0
- package/dist/utils/git-status.js +187 -0
- package/dist/utils/github.js +68 -0
- package/dist/utils/health-checks.js +345 -0
- package/dist/utils/history.js +150 -0
- package/dist/utils/hooks.js +100 -0
- package/dist/utils/logger.js +40 -0
- package/dist/utils/permission-sync.js +681 -0
- package/dist/utils/pi-extensions.js +224 -0
- package/dist/utils/progress.js +92 -0
- package/dist/utils/prompts.js +279 -0
- package/dist/utils/repo-resolver.js +260 -0
- package/dist/utils/retry.js +65 -0
- package/dist/utils/run-operations.js +30 -0
- package/dist/utils/suite.js +159 -0
- package/dist/utils/sync-operations.js +107 -0
- package/dist/utils/tmux-dashboard.js +184 -0
- package/dist/utils/validation.js +153 -0
- package/dist/utils/version-check.js +121 -0
- package/dist/utils/workspace-meta.js +185 -0
- package/install-shell-integration.sh +532 -0
- package/install.sh +401 -0
- package/package.json +90 -0
- package/remove-shell-block.awk +44 -0
- package/scripts/postinstall.js +85 -0
- package/skills/analyze-deps.md +23 -0
- package/skills/archive-workspace.md +26 -0
- package/skills/branch-create.md +18 -0
- package/skills/create-workspace.md +39 -0
- package/skills/delete-workspace.md +37 -0
- package/skills/list-org-repos.md +26 -0
- package/skills/list-suites.md +24 -0
- package/skills/list-workspaces.md +27 -0
- package/skills/refresh-workspace-docs.md +89 -0
- package/skills/remove-repo.md +25 -0
- package/skills/run-command.md +23 -0
- package/skills/save-context.md +68 -0
- package/skills/search-repos.md +31 -0
- package/skills/snapshot-list.md +18 -0
- package/skills/snapshot-save.md +12 -0
- package/skills/switch-branch.md +18 -0
- package/skills/update-cache.md +21 -0
- package/skills/update-workspace.md +66 -0
- package/skills/workspace-cleanup.md +18 -0
- package/skills/workspace-diff.md +23 -0
- package/skills/workspace-doctor.md +23 -0
- package/skills/workspace-info.md +45 -0
- package/skills/workspace-manager/SKILL.md +201 -0
- package/skills/workspace-manager/references/ai-prompt.md +44 -0
- package/skills/workspace-manager/references/analyze-deps.md +45 -0
- package/skills/workspace-manager/references/archive-workspace.md +38 -0
- package/skills/workspace-manager/references/branch-create.md +31 -0
- package/skills/workspace-manager/references/branch-merge.md +22 -0
- package/skills/workspace-manager/references/branch-rebase.md +22 -0
- package/skills/workspace-manager/references/cache-clear.md +18 -0
- package/skills/workspace-manager/references/cache-info.md +22 -0
- package/skills/workspace-manager/references/configure-claude.md +31 -0
- package/skills/workspace-manager/references/configure.md +35 -0
- package/skills/workspace-manager/references/create-workspace.md +45 -0
- package/skills/workspace-manager/references/dashboard.md +61 -0
- package/skills/workspace-manager/references/delete-workspace.md +33 -0
- package/skills/workspace-manager/references/generate-docs.md +36 -0
- package/skills/workspace-manager/references/ghq-status.md +28 -0
- package/skills/workspace-manager/references/go.md +33 -0
- package/skills/workspace-manager/references/history.md +41 -0
- package/skills/workspace-manager/references/list-org-repos.md +26 -0
- package/skills/workspace-manager/references/list-suites.md +21 -0
- package/skills/workspace-manager/references/list-workspaces.md +27 -0
- package/skills/workspace-manager/references/mcp.md +49 -0
- package/skills/workspace-manager/references/remove-repo.md +26 -0
- package/skills/workspace-manager/references/run-command.md +44 -0
- package/skills/workspace-manager/references/search-repos.md +24 -0
- package/skills/workspace-manager/references/sessions.md +27 -0
- package/skills/workspace-manager/references/suite-create.md +22 -0
- package/skills/workspace-manager/references/suite-delete.md +21 -0
- package/skills/workspace-manager/references/suite-export.md +19 -0
- package/skills/workspace-manager/references/suite-import.md +19 -0
- package/skills/workspace-manager/references/suite-use.md +27 -0
- package/skills/workspace-manager/references/switch-branch.md +25 -0
- package/skills/workspace-manager/references/update-cache.md +22 -0
- package/skills/workspace-manager/references/update-workspace.md +50 -0
- package/skills/workspace-manager/references/workspace-cleanup.md +33 -0
- package/skills/workspace-manager/references/workspace-diff.md +27 -0
- package/skills/workspace-manager/references/workspace-doctor.md +27 -0
- package/skills/workspace-manager/references/workspace-info.md +33 -0
- package/skills/workspace-manager/references/workspace-status.md +43 -0
- package/skills/workspace-manager/references/workspace-sync.md +26 -0
- package/skills/workspace-status.md +26 -0
- package/skills/workspace-sync.md +23 -0
- package/src/cli/ai-prompt.test.ts +667 -0
- package/src/cli/ai-prompt.ts +421 -0
- package/src/cli/dashboard/AgentList.tsx +44 -0
- package/src/cli/dashboard/DashboardSidebar.tsx +291 -0
- package/src/cli/dashboard/HelpFooter.tsx +22 -0
- package/src/cli/dashboard/StatusBadge.tsx +23 -0
- package/src/cli/dashboard/index.ts +5 -0
- package/src/cli/dashboard/types.ts +16 -0
- package/src/cli/tui.tsx +51 -0
- package/src/cli/workspace-cli.test.ts +30 -0
- package/src/commands/ai-prompt.ts +21 -0
- package/src/commands/analyze-deps.ts +119 -0
- package/src/commands/archive.ts +114 -0
- package/src/commands/branch/create.ts +94 -0
- package/src/commands/branch/index.ts +74 -0
- package/src/commands/branch/merge.ts +61 -0
- package/src/commands/branch/rebase.ts +54 -0
- package/src/commands/branch/switch.ts +150 -0
- package/src/commands/cache/index.ts +51 -0
- package/src/commands/cache/manager.ts +168 -0
- package/src/commands/cleanup.ts +132 -0
- package/src/commands/configure-claude.ts +55 -0
- package/src/commands/configure.ts +204 -0
- package/src/commands/create.ts +196 -0
- package/src/commands/dashboard/hook-handler.ts +138 -0
- package/src/commands/dashboard/index.ts +248 -0
- package/src/commands/dashboard/launcher.ts +42 -0
- package/src/commands/dashboard/session-picker.ts +73 -0
- package/src/commands/dashboard/sidebar.js +39 -0
- package/src/commands/dashboard/workspace-picker.ts +62 -0
- package/src/commands/delete.test.ts +250 -0
- package/src/commands/delete.ts +171 -0
- package/src/commands/deprecated-aliases.ts +55 -0
- package/src/commands/diff.ts +106 -0
- package/src/commands/doctor.ts +110 -0
- package/src/commands/generate-docs.ts +45 -0
- package/src/commands/ghq-status.ts +70 -0
- package/src/commands/go.ts +125 -0
- package/src/commands/history.ts +136 -0
- package/src/commands/list.test.ts +219 -0
- package/src/commands/list.ts +176 -0
- package/src/commands/mcp/index.ts +39 -0
- package/src/commands/migrate.ts +277 -0
- package/src/commands/remove-repo.ts +194 -0
- package/src/commands/report-bug.ts +88 -0
- package/src/commands/run.ts +125 -0
- package/src/commands/save-context.test.ts +113 -0
- package/src/commands/save-context.ts +141 -0
- package/src/commands/sessions.ts +99 -0
- package/src/commands/status.ts +111 -0
- package/src/commands/suite/create.ts +240 -0
- package/src/commands/suite/delete.ts +65 -0
- package/src/commands/suite/export.ts +97 -0
- package/src/commands/suite/import.ts +100 -0
- package/src/commands/suite/index.ts +66 -0
- package/src/commands/suite/list.ts +54 -0
- package/src/commands/suite/use.ts +232 -0
- package/src/commands/sync.ts +97 -0
- package/src/commands/update.ts +197 -0
- package/src/mcp/install.ts +529 -0
- package/src/mcp/server.ts +579 -0
- package/src/mcp/tools.test.ts +545 -0
- package/src/mcp/tools.ts +1019 -0
- package/src/mcp/update-workspace.test.ts +220 -0
- package/src/pi-extensions/permission-sync.ts.template +41 -0
- package/src/pi-extensions/permissions-gate.ts.template +69 -0
- package/src/pi-extensions/pr-fix-trigger.ts.template +124 -0
- package/src/pi-extensions/review-reminder.ts.template +58 -0
- package/src/pi-extensions/workspace-input-status.ts.template +919 -0
- package/src/program.ts +112 -0
- package/src/scripts/workspace-table.py +394 -0
- package/src/types/dashboard.ts +29 -0
- package/src/types/index.ts +109 -0
- package/src/types/ink.d.ts +171 -0
- package/src/utils/agent-config.test.ts +303 -0
- package/src/utils/agent-config.ts +264 -0
- package/src/utils/agent-state.test.ts +166 -0
- package/src/utils/agent-state.ts +135 -0
- package/src/utils/banner.ts +24 -0
- package/src/utils/branch-operations.ts +237 -0
- package/src/utils/bug-report.test.ts +174 -0
- package/src/utils/bug-report.ts +355 -0
- package/src/utils/cache.ts +150 -0
- package/src/utils/claude-integration.test.ts +381 -0
- package/src/utils/claude-integration.ts +649 -0
- package/src/utils/claude-sessions.test.ts +262 -0
- package/src/utils/claude-sessions.ts +251 -0
- package/src/utils/cleanup-operations.ts +94 -0
- package/src/utils/colors.ts +25 -0
- package/src/utils/command-helpers.ts +46 -0
- package/src/utils/config.test.ts +203 -0
- package/src/utils/config.ts +133 -0
- package/src/utils/context-file.ts +36 -0
- package/src/utils/dashboard-hooks.test.ts +168 -0
- package/src/utils/dashboard-hooks.ts +163 -0
- package/src/utils/dependency-analyzer.ts +243 -0
- package/src/utils/diff-operations.ts +59 -0
- package/src/utils/doc-generator.ts +187 -0
- package/src/utils/ghq-integration.test.ts +195 -0
- package/src/utils/ghq-integration.ts +317 -0
- package/src/utils/git-operations.test.ts +265 -0
- package/src/utils/git-operations.ts +233 -0
- package/src/utils/git-status.ts +171 -0
- package/src/utils/github.ts +74 -0
- package/src/utils/health-checks.ts +359 -0
- package/src/utils/history.ts +140 -0
- package/src/utils/hooks.ts +92 -0
- package/src/utils/logger.ts +37 -0
- package/src/utils/permission-sync.test.ts +593 -0
- package/src/utils/permission-sync.ts +720 -0
- package/src/utils/pi-extensions.test.ts +182 -0
- package/src/utils/pi-extensions.ts +226 -0
- package/src/utils/progress.ts +73 -0
- package/src/utils/prompts.test.ts +169 -0
- package/src/utils/prompts.ts +296 -0
- package/src/utils/repo-resolver.test.ts +313 -0
- package/src/utils/repo-resolver.ts +294 -0
- package/src/utils/retry.test.ts +124 -0
- package/src/utils/retry.ts +85 -0
- package/src/utils/run-operations.ts +38 -0
- package/src/utils/suite.test.ts +256 -0
- package/src/utils/suite.ts +144 -0
- package/src/utils/sync-operations.ts +87 -0
- package/src/utils/tmux-dashboard.ts +193 -0
- package/src/utils/validation.test.ts +139 -0
- package/src/utils/validation.ts +128 -0
- package/src/utils/version-check.test.ts +108 -0
- package/src/utils/version-check.ts +96 -0
- package/src/utils/workspace-meta.ts +173 -0
- package/sync-permissions.sh +137 -0
- package/tsconfig.json +20 -0
- package/uninstall.sh +162 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import { installPiExtensions, uninstallPiExtensions, getPiExtensionStatus } from './pi-extensions';
|
|
6
|
+
|
|
7
|
+
describe('Pi extensions', () => {
|
|
8
|
+
let tmpDir: string;
|
|
9
|
+
let extensionsDir: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-ext-test-'));
|
|
13
|
+
extensionsDir = path.join(tmpDir, '.pi', 'agent', 'extensions');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('installPiExtensions', () => {
|
|
21
|
+
it('creates the extensions directory if it does not exist', () => {
|
|
22
|
+
expect(fs.existsSync(extensionsDir)).toBe(false);
|
|
23
|
+
installPiExtensions(extensionsDir);
|
|
24
|
+
expect(fs.existsSync(extensionsDir)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('installs all managed extensions', () => {
|
|
28
|
+
installPiExtensions(extensionsDir);
|
|
29
|
+
|
|
30
|
+
const files = fs.readdirSync(extensionsDir);
|
|
31
|
+
expect(files).toContain('ws-permission-sync.ts');
|
|
32
|
+
expect(files).toContain('ws-review-reminder.ts');
|
|
33
|
+
expect(files).toContain('ws-permissions-gate.ts');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('installed extensions contain the managed marker', () => {
|
|
37
|
+
installPiExtensions(extensionsDir);
|
|
38
|
+
|
|
39
|
+
for (const file of fs.readdirSync(extensionsDir)) {
|
|
40
|
+
const content = fs.readFileSync(path.join(extensionsDir, file), 'utf-8');
|
|
41
|
+
expect(content).toContain('auto-generated by workspace-manager');
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('permission-sync extension has the script path replaced', () => {
|
|
46
|
+
installPiExtensions(extensionsDir);
|
|
47
|
+
|
|
48
|
+
const content = fs.readFileSync(
|
|
49
|
+
path.join(extensionsDir, 'ws-permission-sync.ts'),
|
|
50
|
+
'utf-8'
|
|
51
|
+
);
|
|
52
|
+
// Should not contain the placeholder
|
|
53
|
+
expect(content).not.toContain('__SYNC_PERMISSIONS_SCRIPT__');
|
|
54
|
+
// Should contain an actual path
|
|
55
|
+
expect(content).toContain('sync-permissions.sh');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('review-reminder extension references cache directory', () => {
|
|
59
|
+
installPiExtensions(extensionsDir);
|
|
60
|
+
|
|
61
|
+
const content = fs.readFileSync(
|
|
62
|
+
path.join(extensionsDir, 'ws-review-reminder.ts'),
|
|
63
|
+
'utf-8'
|
|
64
|
+
);
|
|
65
|
+
expect(content).toContain('.workspace-manager-cache');
|
|
66
|
+
expect(content).toContain('last-permission-review');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('permissions-gate extension has dangerous patterns', () => {
|
|
70
|
+
installPiExtensions(extensionsDir);
|
|
71
|
+
|
|
72
|
+
const content = fs.readFileSync(
|
|
73
|
+
path.join(extensionsDir, 'ws-permissions-gate.ts'),
|
|
74
|
+
'utf-8'
|
|
75
|
+
);
|
|
76
|
+
expect(content).toContain('rm\\s+-rf');
|
|
77
|
+
expect(content).toContain('isToolCallEventType');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('is idempotent — installing twice overwrites cleanly', () => {
|
|
81
|
+
installPiExtensions(extensionsDir);
|
|
82
|
+
const firstFiles = fs.readdirSync(extensionsDir).sort();
|
|
83
|
+
|
|
84
|
+
installPiExtensions(extensionsDir);
|
|
85
|
+
const secondFiles = fs.readdirSync(extensionsDir).sort();
|
|
86
|
+
|
|
87
|
+
expect(firstFiles).toEqual(secondFiles);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('does not touch non-managed files', () => {
|
|
91
|
+
fs.mkdirSync(extensionsDir, { recursive: true });
|
|
92
|
+
fs.writeFileSync(
|
|
93
|
+
path.join(extensionsDir, 'my-custom-extension.ts'),
|
|
94
|
+
'// custom'
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
installPiExtensions(extensionsDir);
|
|
98
|
+
|
|
99
|
+
expect(
|
|
100
|
+
fs.readFileSync(path.join(extensionsDir, 'my-custom-extension.ts'), 'utf-8')
|
|
101
|
+
).toBe('// custom');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('uninstallPiExtensions', () => {
|
|
106
|
+
it('removes all managed extensions', () => {
|
|
107
|
+
installPiExtensions(extensionsDir);
|
|
108
|
+
expect(fs.readdirSync(extensionsDir).length).toBeGreaterThan(0);
|
|
109
|
+
|
|
110
|
+
uninstallPiExtensions(extensionsDir);
|
|
111
|
+
|
|
112
|
+
expect(fs.readdirSync(extensionsDir).length).toBe(0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('does not remove non-managed extensions', () => {
|
|
116
|
+
installPiExtensions(extensionsDir);
|
|
117
|
+
fs.writeFileSync(
|
|
118
|
+
path.join(extensionsDir, 'my-custom-extension.ts'),
|
|
119
|
+
'// custom extension'
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
uninstallPiExtensions(extensionsDir);
|
|
123
|
+
|
|
124
|
+
const remaining = fs.readdirSync(extensionsDir);
|
|
125
|
+
expect(remaining).toEqual(['my-custom-extension.ts']);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('removes files with managed marker even if renamed', () => {
|
|
129
|
+
installPiExtensions(extensionsDir);
|
|
130
|
+
|
|
131
|
+
// Rename a managed extension
|
|
132
|
+
fs.renameSync(
|
|
133
|
+
path.join(extensionsDir, 'ws-permission-sync.ts'),
|
|
134
|
+
path.join(extensionsDir, 'renamed-sync.ts')
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
uninstallPiExtensions(extensionsDir);
|
|
138
|
+
|
|
139
|
+
// renamed file should also be removed (has marker)
|
|
140
|
+
expect(fs.readdirSync(extensionsDir).length).toBe(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('handles non-existent directory gracefully', () => {
|
|
144
|
+
expect(() => uninstallPiExtensions('/nonexistent/path')).not.toThrow();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('getPiExtensionStatus', () => {
|
|
149
|
+
it('reports all missing when nothing is installed', () => {
|
|
150
|
+
fs.mkdirSync(extensionsDir, { recursive: true });
|
|
151
|
+
|
|
152
|
+
const status = getPiExtensionStatus(extensionsDir);
|
|
153
|
+
expect(status.installed).toHaveLength(0);
|
|
154
|
+
expect(status.missing.length).toBeGreaterThan(0);
|
|
155
|
+
expect(status.missing).toContain('ws-permission-sync.ts');
|
|
156
|
+
expect(status.missing).toContain('ws-review-reminder.ts');
|
|
157
|
+
expect(status.missing).toContain('ws-permissions-gate.ts');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('reports all installed after installation', () => {
|
|
161
|
+
installPiExtensions(extensionsDir);
|
|
162
|
+
|
|
163
|
+
const status = getPiExtensionStatus(extensionsDir);
|
|
164
|
+
expect(status.missing).toHaveLength(0);
|
|
165
|
+
expect(status.installed).toContain('ws-permission-sync.ts');
|
|
166
|
+
expect(status.installed).toContain('ws-review-reminder.ts');
|
|
167
|
+
expect(status.installed).toContain('ws-permissions-gate.ts');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('reports partial when some are missing', () => {
|
|
171
|
+
installPiExtensions(extensionsDir);
|
|
172
|
+
|
|
173
|
+
// Delete one
|
|
174
|
+
fs.unlinkSync(path.join(extensionsDir, 'ws-review-reminder.ts'));
|
|
175
|
+
|
|
176
|
+
const status = getPiExtensionStatus(extensionsDir);
|
|
177
|
+
expect(status.installed).not.toContain('ws-review-reminder.ts');
|
|
178
|
+
expect(status.missing).toContain('ws-review-reminder.ts');
|
|
179
|
+
expect(status.installed.length + status.missing.length).toBe(5);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi extension management — install/uninstall Pi extensions that replicate
|
|
3
|
+
* Claude Code hooks and MCP functionality.
|
|
4
|
+
*
|
|
5
|
+
* Pi uses TypeScript extensions instead of:
|
|
6
|
+
* - settings.json hooks (Stop, SessionStart, PostToolUse)
|
|
7
|
+
* - .mcp.json / `claude mcp add` (MCP servers)
|
|
8
|
+
*
|
|
9
|
+
* This module generates and installs extensions from bundled templates.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as path from 'path';
|
|
14
|
+
import * as os from 'os';
|
|
15
|
+
import { logSuccess, logInfo, logWarning } from './logger';
|
|
16
|
+
|
|
17
|
+
const PI_EXTENSIONS_DIR = path.join(os.homedir(), '.pi', 'agent', 'extensions');
|
|
18
|
+
|
|
19
|
+
/** Marker comment that identifies workspace-manager managed extensions */
|
|
20
|
+
const MANAGED_MARKER = '(auto-generated by workspace-manager)';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Extension template definitions.
|
|
24
|
+
* Each maps a template file to the installed extension filename,
|
|
25
|
+
* with optional placeholder replacements.
|
|
26
|
+
*/
|
|
27
|
+
interface ExtensionTemplate {
|
|
28
|
+
/** Template filename (relative to pi-extensions/ dir) */
|
|
29
|
+
templateFile: string;
|
|
30
|
+
/** Installed filename under ~/.pi/agent/extensions/ */
|
|
31
|
+
installedName: string;
|
|
32
|
+
/** Placeholder replacements: key => value */
|
|
33
|
+
replacements?: Record<string, string>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getTemplatesDir(): string {
|
|
37
|
+
// When compiled: dist/utils/ -> dist/pi-extensions/
|
|
38
|
+
const distPath = path.resolve(__dirname, '..', 'pi-extensions');
|
|
39
|
+
// When running from source: src/utils/ -> src/pi-extensions/
|
|
40
|
+
const srcPath = path.resolve(__dirname, '..', '..', 'src', 'pi-extensions');
|
|
41
|
+
|
|
42
|
+
if (fs.existsSync(distPath)) return distPath;
|
|
43
|
+
if (fs.existsSync(srcPath)) return srcPath;
|
|
44
|
+
|
|
45
|
+
throw new Error('Could not find pi-extensions/ directory.');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getSyncPermissionsScript(): string {
|
|
49
|
+
const scriptPath = path.resolve(__dirname, '..', '..', 'sync-permissions.sh');
|
|
50
|
+
if (fs.existsSync(scriptPath)) return scriptPath;
|
|
51
|
+
throw new Error('Could not find sync-permissions.sh');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getExtensionTemplates(options: { workspaceInputStatus?: boolean } = {}): ExtensionTemplate[] {
|
|
55
|
+
let syncScript: string;
|
|
56
|
+
try {
|
|
57
|
+
syncScript = getSyncPermissionsScript();
|
|
58
|
+
} catch {
|
|
59
|
+
logWarning('Could not locate sync-permissions.sh — permission-sync extension will need manual path configuration');
|
|
60
|
+
syncScript = ''; // Empty placeholder — user must configure manually
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const templates: ExtensionTemplate[] = [
|
|
64
|
+
{
|
|
65
|
+
templateFile: 'permission-sync.ts.template',
|
|
66
|
+
installedName: 'ws-permission-sync.ts',
|
|
67
|
+
replacements: syncScript ? { '__SYNC_PERMISSIONS_SCRIPT__': syncScript } : {},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
templateFile: 'review-reminder.ts.template',
|
|
71
|
+
installedName: 'ws-review-reminder.ts',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
templateFile: 'permissions-gate.ts.template',
|
|
75
|
+
installedName: 'ws-permissions-gate.ts',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
templateFile: 'pr-fix-trigger.ts.template',
|
|
79
|
+
installedName: 'ws-pr-fix-trigger.ts',
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
// Workspace input-status widget — enabled by default, opt-out via config
|
|
84
|
+
if (options.workspaceInputStatus !== false) {
|
|
85
|
+
templates.push({
|
|
86
|
+
templateFile: 'workspace-input-status.ts.template',
|
|
87
|
+
installedName: 'ws-workspace-input-status.ts',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return templates;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Install all workspace-manager Pi extensions.
|
|
96
|
+
* Idempotent — overwrites existing managed extensions with latest templates.
|
|
97
|
+
*
|
|
98
|
+
* @param extensionsDir Override the extensions directory (for testing).
|
|
99
|
+
*/
|
|
100
|
+
export function installPiExtensions(extensionsDir?: string, options: { workspaceInputStatus?: boolean } = {}): void {
|
|
101
|
+
const targetDir = extensionsDir ?? PI_EXTENSIONS_DIR;
|
|
102
|
+
|
|
103
|
+
let templatesDir: string;
|
|
104
|
+
try {
|
|
105
|
+
templatesDir = getTemplatesDir();
|
|
106
|
+
} catch {
|
|
107
|
+
logWarning('Could not find Pi extension templates — skipping');
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!fs.existsSync(targetDir)) {
|
|
112
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const templates = getExtensionTemplates(options);
|
|
116
|
+
let installed = 0;
|
|
117
|
+
|
|
118
|
+
for (const tpl of templates) {
|
|
119
|
+
const templatePath = path.join(templatesDir, tpl.templateFile);
|
|
120
|
+
if (!fs.existsSync(templatePath)) {
|
|
121
|
+
logWarning(`Template not found: ${tpl.templateFile} — skipping`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let content = fs.readFileSync(templatePath, 'utf-8');
|
|
126
|
+
|
|
127
|
+
// Apply replacements
|
|
128
|
+
if (tpl.replacements) {
|
|
129
|
+
for (const [placeholder, value] of Object.entries(tpl.replacements)) {
|
|
130
|
+
content = content.replace(new RegExp(escapeRegex(placeholder), 'g'), value);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const targetPath = path.join(targetDir, tpl.installedName);
|
|
135
|
+
fs.writeFileSync(targetPath, content, 'utf-8');
|
|
136
|
+
installed++;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (installed > 0) {
|
|
140
|
+
logSuccess(`Installed ${installed} Pi extension(s) in ${targetDir}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Uninstall workspace-manager managed Pi extensions.
|
|
146
|
+
* Only removes extensions that contain the managed marker or match
|
|
147
|
+
* known installed names.
|
|
148
|
+
*
|
|
149
|
+
* @param extensionsDir Override the extensions directory (for testing).
|
|
150
|
+
*/
|
|
151
|
+
/**
|
|
152
|
+
* Uninstall workspace-manager managed Pi extensions.
|
|
153
|
+
* Pass `onlyNames` to remove only specific extension files (for opt-out).
|
|
154
|
+
*
|
|
155
|
+
* @param extensionsDir Override the extensions directory (for testing).
|
|
156
|
+
* @param options.onlyNames If set, only remove files in this list.
|
|
157
|
+
*/
|
|
158
|
+
export function uninstallPiExtensions(
|
|
159
|
+
extensionsDir?: string,
|
|
160
|
+
options?: { onlyNames?: string[] },
|
|
161
|
+
): void {
|
|
162
|
+
const targetDir = extensionsDir ?? PI_EXTENSIONS_DIR;
|
|
163
|
+
|
|
164
|
+
if (!fs.existsSync(targetDir)) return;
|
|
165
|
+
|
|
166
|
+
const templates = getExtensionTemplates({ workspaceInputStatus: true });
|
|
167
|
+
const knownNames = new Set(templates.map(t => t.installedName));
|
|
168
|
+
const onlyNames = options?.onlyNames ? new Set(options.onlyNames) : null;
|
|
169
|
+
let removed = 0;
|
|
170
|
+
|
|
171
|
+
for (const file of fs.readdirSync(targetDir)) {
|
|
172
|
+
if (!file.endsWith('.ts')) continue;
|
|
173
|
+
|
|
174
|
+
// When onlyNames is set, skip files not in that list
|
|
175
|
+
if (onlyNames && !onlyNames.has(file)) continue;
|
|
176
|
+
|
|
177
|
+
const filePath = path.join(targetDir, file);
|
|
178
|
+
|
|
179
|
+
// Remove if it matches a known managed name
|
|
180
|
+
if (knownNames.has(file)) {
|
|
181
|
+
fs.unlinkSync(filePath);
|
|
182
|
+
removed++;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Also remove if it contains the managed marker (for renamed files)
|
|
187
|
+
if (!onlyNames) {
|
|
188
|
+
try {
|
|
189
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
190
|
+
if (content.includes(MANAGED_MARKER)) {
|
|
191
|
+
fs.unlinkSync(filePath);
|
|
192
|
+
removed++;
|
|
193
|
+
}
|
|
194
|
+
} catch { /* ignore read errors */ }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (removed > 0) {
|
|
199
|
+
logSuccess(`Removed ${removed} Pi extension(s) from ${targetDir}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Check which managed Pi extensions are installed.
|
|
205
|
+
*/
|
|
206
|
+
export function getPiExtensionStatus(extensionsDir?: string): { installed: string[]; missing: string[] } {
|
|
207
|
+
const targetDir = extensionsDir ?? PI_EXTENSIONS_DIR;
|
|
208
|
+
const templates = getExtensionTemplates({ workspaceInputStatus: true });
|
|
209
|
+
const installed: string[] = [];
|
|
210
|
+
const missing: string[] = [];
|
|
211
|
+
|
|
212
|
+
for (const tpl of templates) {
|
|
213
|
+
const targetPath = path.join(targetDir, tpl.installedName);
|
|
214
|
+
if (fs.existsSync(targetPath)) {
|
|
215
|
+
installed.push(tpl.installedName);
|
|
216
|
+
} else {
|
|
217
|
+
missing.push(tpl.installedName);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { installed, missing };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function escapeRegex(str: string): string {
|
|
225
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
226
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import * as cliProgress from 'cli-progress';
|
|
2
|
+
|
|
3
|
+
export class ProgressTracker {
|
|
4
|
+
private multibar: cliProgress.MultiBar;
|
|
5
|
+
private bars: Map<string, cliProgress.SingleBar>;
|
|
6
|
+
private startTimes: Map<string, number>;
|
|
7
|
+
|
|
8
|
+
constructor() {
|
|
9
|
+
this.multibar = new cliProgress.MultiBar(
|
|
10
|
+
{
|
|
11
|
+
clearOnComplete: false,
|
|
12
|
+
hideCursor: true,
|
|
13
|
+
format: ' {bar} | {percentage}% | {value}/{total} | {task} | {duration}',
|
|
14
|
+
},
|
|
15
|
+
cliProgress.Presets.shades_classic
|
|
16
|
+
);
|
|
17
|
+
this.bars = new Map();
|
|
18
|
+
this.startTimes = new Map();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
addTask(taskId: string, total: number, taskName: string): void {
|
|
22
|
+
const bar = this.multibar.create(total, 0, { task: taskName, duration: '0s' });
|
|
23
|
+
this.bars.set(taskId, bar);
|
|
24
|
+
this.startTimes.set(taskId, Date.now());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
updateTask(taskId: string, current: number, taskName?: string): void {
|
|
28
|
+
const bar = this.bars.get(taskId);
|
|
29
|
+
const startTime = this.startTimes.get(taskId);
|
|
30
|
+
|
|
31
|
+
if (bar && startTime) {
|
|
32
|
+
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
|
33
|
+
const duration = this.formatDuration(elapsed);
|
|
34
|
+
|
|
35
|
+
bar.update(current, {
|
|
36
|
+
task: taskName || 'Processing',
|
|
37
|
+
duration,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
completeTask(taskId: string): void {
|
|
43
|
+
const bar = this.bars.get(taskId);
|
|
44
|
+
if (bar) {
|
|
45
|
+
bar.stop();
|
|
46
|
+
this.bars.delete(taskId);
|
|
47
|
+
this.startTimes.delete(taskId);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
stop(): void {
|
|
52
|
+
this.multibar.stop();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private formatDuration(seconds: number): string {
|
|
56
|
+
if (seconds < 60) {
|
|
57
|
+
return `${seconds}s`;
|
|
58
|
+
}
|
|
59
|
+
const minutes = Math.floor(seconds / 60);
|
|
60
|
+
const remainingSeconds = seconds % 60;
|
|
61
|
+
return `${minutes}m ${remainingSeconds}s`;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const createSimpleProgressBar = (total: number, taskName: string): cliProgress.SingleBar => {
|
|
66
|
+
return new cliProgress.SingleBar(
|
|
67
|
+
{
|
|
68
|
+
format: ` ${taskName} | {bar} | {percentage}% | {value}/{total} | {duration}`,
|
|
69
|
+
hideCursor: true,
|
|
70
|
+
},
|
|
71
|
+
cliProgress.Presets.shades_classic
|
|
72
|
+
);
|
|
73
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { mockPrompt } = vi.hoisted(() => ({
|
|
4
|
+
mockPrompt: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
// Mock inquirer before imports
|
|
8
|
+
vi.mock('inquirer', () => ({
|
|
9
|
+
default: {
|
|
10
|
+
prompt: mockPrompt,
|
|
11
|
+
registerPrompt: vi.fn(),
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('inquirer-autocomplete-prompt', () => ({
|
|
16
|
+
default: {},
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock('fuzzy', () => ({
|
|
20
|
+
filter: (input: string, list: string[]) => {
|
|
21
|
+
const lower = input.toLowerCase();
|
|
22
|
+
return list
|
|
23
|
+
.filter(item => item.toLowerCase().includes(lower))
|
|
24
|
+
.map(item => ({ original: item, string: item }));
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock('./colors', () => ({
|
|
29
|
+
colorize: (text: string) => text,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
vi.mock('./validation', () => ({
|
|
33
|
+
validateWorkspaceName: vi.fn().mockReturnValue(true),
|
|
34
|
+
checkWorkspaceExists: vi.fn().mockResolvedValue(false),
|
|
35
|
+
sanitizeWorkspaceName: vi.fn((input: string) => input),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
import { promptMultiWorkspaceSelection } from './prompts';
|
|
39
|
+
|
|
40
|
+
const makeWorkspaces = (...names: string[]) =>
|
|
41
|
+
names.map(name => ({
|
|
42
|
+
name,
|
|
43
|
+
path: `/workspaces/${name}`,
|
|
44
|
+
metadata: { repositories: [], createdAt: new Date().toISOString() },
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
describe('promptMultiWorkspaceSelection', () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
vi.clearAllMocks();
|
|
50
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('returns selected workspace names', async () => {
|
|
54
|
+
mockPrompt
|
|
55
|
+
.mockResolvedValueOnce({ workspaceName: 'ws-alpha' })
|
|
56
|
+
.mockResolvedValueOnce({ workspaceName: 'ws-beta' })
|
|
57
|
+
.mockResolvedValueOnce({ workspaceName: 'done' });
|
|
58
|
+
|
|
59
|
+
const result = await promptMultiWorkspaceSelection(
|
|
60
|
+
makeWorkspaces('ws-alpha', 'ws-beta', 'ws-gamma')
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
expect(result).toEqual(['ws-alpha', 'ws-beta']);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('requires at least one selection before accepting done', async () => {
|
|
67
|
+
// First "done" should be rejected, then select one, then done
|
|
68
|
+
mockPrompt
|
|
69
|
+
.mockResolvedValueOnce({ workspaceName: 'done' })
|
|
70
|
+
.mockResolvedValueOnce({ workspaceName: 'ws-alpha' })
|
|
71
|
+
.mockResolvedValueOnce({ workspaceName: 'done' });
|
|
72
|
+
|
|
73
|
+
const result = await promptMultiWorkspaceSelection(
|
|
74
|
+
makeWorkspaces('ws-alpha', 'ws-beta')
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect(result).toEqual(['ws-alpha']);
|
|
78
|
+
// Should have prompted 3 times (done rejected, select, done accepted)
|
|
79
|
+
expect(mockPrompt).toHaveBeenCalledTimes(3);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('auto-completes when all workspaces are selected', async () => {
|
|
83
|
+
mockPrompt
|
|
84
|
+
.mockResolvedValueOnce({ workspaceName: 'ws-alpha' })
|
|
85
|
+
.mockResolvedValueOnce({ workspaceName: 'ws-beta' });
|
|
86
|
+
|
|
87
|
+
const result = await promptMultiWorkspaceSelection(
|
|
88
|
+
makeWorkspaces('ws-alpha', 'ws-beta')
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(result).toEqual(['ws-alpha', 'ws-beta']);
|
|
92
|
+
// Only 2 prompts needed - loop breaks when no available names left
|
|
93
|
+
expect(mockPrompt).toHaveBeenCalledTimes(2);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('returns single workspace selection', async () => {
|
|
97
|
+
mockPrompt
|
|
98
|
+
.mockResolvedValueOnce({ workspaceName: 'ws-only' })
|
|
99
|
+
.mockResolvedValueOnce({ workspaceName: 'done' });
|
|
100
|
+
|
|
101
|
+
const result = await promptMultiWorkspaceSelection(
|
|
102
|
+
makeWorkspaces('ws-only', 'ws-other')
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
expect(result).toEqual(['ws-only']);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('exits process on Ctrl+C', async () => {
|
|
109
|
+
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
|
110
|
+
throw new Error('process.exit');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
mockPrompt.mockRejectedValueOnce(new Error('User force closed'));
|
|
114
|
+
|
|
115
|
+
await expect(
|
|
116
|
+
promptMultiWorkspaceSelection(makeWorkspaces('ws-alpha'))
|
|
117
|
+
).rejects.toThrow('process.exit');
|
|
118
|
+
|
|
119
|
+
expect(mockExit).toHaveBeenCalledWith(0);
|
|
120
|
+
mockExit.mockRestore();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('prints summary with correct singular form', async () => {
|
|
124
|
+
const logSpy = vi.spyOn(console, 'log');
|
|
125
|
+
mockPrompt
|
|
126
|
+
.mockResolvedValueOnce({ workspaceName: 'ws-alpha' })
|
|
127
|
+
.mockResolvedValueOnce({ workspaceName: 'done' });
|
|
128
|
+
|
|
129
|
+
await promptMultiWorkspaceSelection(makeWorkspaces('ws-alpha', 'ws-beta'));
|
|
130
|
+
|
|
131
|
+
const summaryCalls = logSpy.mock.calls.map(c => c[0]);
|
|
132
|
+
expect(summaryCalls).toContainEqual(
|
|
133
|
+
expect.stringContaining('Selected 1 workspace:')
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('prints summary with correct plural form', async () => {
|
|
138
|
+
const logSpy = vi.spyOn(console, 'log');
|
|
139
|
+
mockPrompt
|
|
140
|
+
.mockResolvedValueOnce({ workspaceName: 'ws-alpha' })
|
|
141
|
+
.mockResolvedValueOnce({ workspaceName: 'ws-beta' })
|
|
142
|
+
.mockResolvedValueOnce({ workspaceName: 'done' });
|
|
143
|
+
|
|
144
|
+
await promptMultiWorkspaceSelection(
|
|
145
|
+
makeWorkspaces('ws-alpha', 'ws-beta', 'ws-gamma')
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const summaryCalls = logSpy.mock.calls.map(c => c[0]);
|
|
149
|
+
expect(summaryCalls).toContainEqual(
|
|
150
|
+
expect.stringContaining('Selected 2 workspaces:')
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('prints green confirmation after each selection', async () => {
|
|
155
|
+
const logSpy = vi.spyOn(console, 'log');
|
|
156
|
+
mockPrompt
|
|
157
|
+
.mockResolvedValueOnce({ workspaceName: 'ws-alpha' })
|
|
158
|
+
.mockResolvedValueOnce({ workspaceName: 'ws-beta' })
|
|
159
|
+
.mockResolvedValueOnce({ workspaceName: 'done' });
|
|
160
|
+
|
|
161
|
+
await promptMultiWorkspaceSelection(
|
|
162
|
+
makeWorkspaces('ws-alpha', 'ws-beta', 'ws-gamma')
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const loggedMessages = logSpy.mock.calls.map(c => c[0]);
|
|
166
|
+
expect(loggedMessages).toContainEqual(expect.stringContaining('Added: ws-alpha'));
|
|
167
|
+
expect(loggedMessages).toContainEqual(expect.stringContaining('Added: ws-beta'));
|
|
168
|
+
});
|
|
169
|
+
});
|