@hartvig/developer-control-center 0.8.2
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/.developer-control-center/metrics.json +1 -0
- package/.developer-control-center/status.json +1 -0
- package/.developer-control-center/timings.jsonl +3 -0
- package/.github/workflows/ci.yml +47 -0
- package/AGENTS.md +51 -0
- package/PLUGINS.md +145 -0
- package/README.md +147 -0
- package/developer-control-center.config.example.js +91 -0
- package/developer-control-center.config.js +177 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +223 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/index.d.ts +3 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +2 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/loader.d.ts +4 -0
- package/dist/config/loader.d.ts.map +1 -0
- package/dist/config/loader.js +96 -0
- package/dist/config/loader.js.map +1 -0
- package/dist/config/loader.test.d.ts +2 -0
- package/dist/config/loader.test.d.ts.map +1 -0
- package/dist/config/loader.test.js +25 -0
- package/dist/config/loader.test.js.map +1 -0
- package/dist/config/presets/node.d.ts +10 -0
- package/dist/config/presets/node.d.ts.map +1 -0
- package/dist/config/presets/node.js +31 -0
- package/dist/config/presets/node.js.map +1 -0
- package/dist/config/presets/react.d.ts +10 -0
- package/dist/config/presets/react.d.ts.map +1 -0
- package/dist/config/presets/react.js +36 -0
- package/dist/config/presets/react.js.map +1 -0
- package/dist/config/types.d.ts +55 -0
- package/dist/config/types.d.ts.map +1 -0
- package/dist/config/types.js +2 -0
- package/dist/config/types.js.map +1 -0
- package/dist/config/types.test.d.ts +2 -0
- package/dist/config/types.test.d.ts.map +1 -0
- package/dist/config/types.test.js +23 -0
- package/dist/config/types.test.js.map +1 -0
- package/dist/core/ci.d.ts +6 -0
- package/dist/core/ci.d.ts.map +1 -0
- package/dist/core/ci.js +22 -0
- package/dist/core/ci.js.map +1 -0
- package/dist/core/ci.test.d.ts +2 -0
- package/dist/core/ci.test.d.ts.map +1 -0
- package/dist/core/ci.test.js +45 -0
- package/dist/core/ci.test.js.map +1 -0
- package/dist/core/event-bus.d.ts +18 -0
- package/dist/core/event-bus.d.ts.map +1 -0
- package/dist/core/event-bus.js +19 -0
- package/dist/core/event-bus.js.map +1 -0
- package/dist/core/event-bus.test.d.ts +2 -0
- package/dist/core/event-bus.test.d.ts.map +1 -0
- package/dist/core/event-bus.test.js +49 -0
- package/dist/core/event-bus.test.js.map +1 -0
- package/dist/core/index.d.ts +9 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +7 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/notifier.d.ts +2 -0
- package/dist/core/notifier.d.ts.map +1 -0
- package/dist/core/notifier.js +28 -0
- package/dist/core/notifier.js.map +1 -0
- package/dist/core/notifier.test.d.ts +2 -0
- package/dist/core/notifier.test.d.ts.map +1 -0
- package/dist/core/notifier.test.js +25 -0
- package/dist/core/notifier.test.js.map +1 -0
- package/dist/core/runtime.d.ts +25 -0
- package/dist/core/runtime.d.ts.map +1 -0
- package/dist/core/runtime.js +85 -0
- package/dist/core/runtime.js.map +1 -0
- package/dist/core/task-runner.d.ts +26 -0
- package/dist/core/task-runner.d.ts.map +1 -0
- package/dist/core/task-runner.js +354 -0
- package/dist/core/task-runner.js.map +1 -0
- package/dist/core/timer-plugin.d.ts +3 -0
- package/dist/core/timer-plugin.d.ts.map +1 -0
- package/dist/core/timer-plugin.js +34 -0
- package/dist/core/timer-plugin.js.map +1 -0
- package/dist/core/workspaces.d.ts +6 -0
- package/dist/core/workspaces.d.ts.map +1 -0
- package/dist/core/workspaces.js +60 -0
- package/dist/core/workspaces.js.map +1 -0
- package/dist/core/workspaces.test.d.ts +2 -0
- package/dist/core/workspaces.test.d.ts.map +1 -0
- package/dist/core/workspaces.test.js +62 -0
- package/dist/core/workspaces.test.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/plugins/index.d.ts +3 -0
- package/dist/plugins/index.d.ts.map +1 -0
- package/dist/plugins/index.js +2 -0
- package/dist/plugins/index.js.map +1 -0
- package/dist/plugins/manager.d.ts +13 -0
- package/dist/plugins/manager.d.ts.map +1 -0
- package/dist/plugins/manager.js +43 -0
- package/dist/plugins/manager.js.map +1 -0
- package/dist/plugins/manager.test.d.ts +2 -0
- package/dist/plugins/manager.test.d.ts.map +1 -0
- package/dist/plugins/manager.test.js +79 -0
- package/dist/plugins/manager.test.js.map +1 -0
- package/dist/plugins/types.d.ts +17 -0
- package/dist/plugins/types.d.ts.map +1 -0
- package/dist/plugins/types.js +2 -0
- package/dist/plugins/types.js.map +1 -0
- package/dist/status/index.d.ts +3 -0
- package/dist/status/index.d.ts.map +1 -0
- package/dist/status/index.js +2 -0
- package/dist/status/index.js.map +1 -0
- package/dist/status/store.d.ts +18 -0
- package/dist/status/store.d.ts.map +1 -0
- package/dist/status/store.js +76 -0
- package/dist/status/store.js.map +1 -0
- package/dist/status/store.test.d.ts +2 -0
- package/dist/status/store.test.d.ts.map +1 -0
- package/dist/status/store.test.js +107 -0
- package/dist/status/store.test.js.map +1 -0
- package/dist/status/types.d.ts +12 -0
- package/dist/status/types.d.ts.map +1 -0
- package/dist/status/types.js +2 -0
- package/dist/status/types.js.map +1 -0
- package/dist/ui/app.d.ts +10 -0
- package/dist/ui/app.d.ts.map +1 -0
- package/dist/ui/app.js +479 -0
- package/dist/ui/app.js.map +1 -0
- package/dist/ui/command-list.d.ts +30 -0
- package/dist/ui/command-list.d.ts.map +1 -0
- package/dist/ui/command-list.js +45 -0
- package/dist/ui/command-list.js.map +1 -0
- package/dist/ui/index.d.ts +4 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +8 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/metrics-panel.d.ts +10 -0
- package/dist/ui/metrics-panel.d.ts.map +1 -0
- package/dist/ui/metrics-panel.js +139 -0
- package/dist/ui/metrics-panel.js.map +1 -0
- package/dist/ui/panel.d.ts +16 -0
- package/dist/ui/panel.d.ts.map +1 -0
- package/dist/ui/panel.js +16 -0
- package/dist/ui/panel.js.map +1 -0
- package/dist/ui/status-panel.d.ts +16 -0
- package/dist/ui/status-panel.d.ts.map +1 -0
- package/dist/ui/status-panel.js +52 -0
- package/dist/ui/status-panel.js.map +1 -0
- package/docs/architecture.md +29 -0
- package/docs/config.md +15 -0
- package/docs/mvp.md +17 -0
- package/docs/phases.md +49 -0
- package/docs/technical-decisions.md +19 -0
- package/docs/ui.md +14 -0
- package/package.json +30 -0
- package/src/cli.ts +242 -0
- package/src/config/index.ts +2 -0
- package/src/config/loader.test.ts +30 -0
- package/src/config/loader.ts +123 -0
- package/src/config/presets/node.ts +30 -0
- package/src/config/presets/react.ts +35 -0
- package/src/config/types.test.ts +24 -0
- package/src/config/types.ts +52 -0
- package/src/core/ci.test.ts +54 -0
- package/src/core/ci.ts +26 -0
- package/src/core/event-bus.test.ts +56 -0
- package/src/core/event-bus.ts +34 -0
- package/src/core/index.ts +8 -0
- package/src/core/notifier.test.ts +30 -0
- package/src/core/notifier.ts +34 -0
- package/src/core/runtime.ts +99 -0
- package/src/core/task-runner.ts +408 -0
- package/src/core/timer-plugin.ts +34 -0
- package/src/core/workspaces.test.ts +72 -0
- package/src/core/workspaces.ts +73 -0
- package/src/index.ts +15 -0
- package/src/plugins/index.ts +2 -0
- package/src/plugins/manager.test.ts +92 -0
- package/src/plugins/manager.ts +54 -0
- package/src/plugins/types.ts +18 -0
- package/src/status/index.ts +2 -0
- package/src/status/store.test.ts +122 -0
- package/src/status/store.ts +88 -0
- package/src/status/types.ts +12 -0
- package/src/ui/app.tsx +606 -0
- package/src/ui/command-list.tsx +163 -0
- package/src/ui/index.tsx +10 -0
- package/src/ui/metrics-panel.tsx +234 -0
- package/src/ui/panel.tsx +76 -0
- package/src/ui/status-panel.tsx +160 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import { detectWorkspaces } from './workspaces.js';
|
|
5
|
+
|
|
6
|
+
describe('detectWorkspaces', () => {
|
|
7
|
+
const testDir = '/tmp/dcc-test-workspaces';
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function writePackageJson(dir: string, content: object) {
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(content));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
it('returns empty when no workspaces defined', () => {
|
|
23
|
+
writePackageJson(testDir, { name: 'root' });
|
|
24
|
+
expect(detectWorkspaces(testDir)).toEqual([]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('discovers packages from glob pattern', () => {
|
|
28
|
+
writePackageJson(testDir, { name: 'root', workspaces: ['packages/*'] });
|
|
29
|
+
writePackageJson(path.join(testDir, 'packages/a'), { name: '@scope/a' });
|
|
30
|
+
writePackageJson(path.join(testDir, 'packages/b'), { name: '@scope/b' });
|
|
31
|
+
const ws = detectWorkspaces(testDir);
|
|
32
|
+
expect(ws.length).toBe(2);
|
|
33
|
+
expect(ws.find((w) => w.name === '@scope/a')).toBeTruthy();
|
|
34
|
+
expect(ws.find((w) => w.name === '@scope/b')).toBeTruthy();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('discovers packages from explicit paths', () => {
|
|
38
|
+
writePackageJson(testDir, { name: 'root', workspaces: ['libs/x'] });
|
|
39
|
+
writePackageJson(path.join(testDir, 'libs/x'), { name: 'lib-x' });
|
|
40
|
+
const ws = detectWorkspaces(testDir);
|
|
41
|
+
expect(ws.length).toBe(1);
|
|
42
|
+
expect(ws[0].name).toBe('lib-x');
|
|
43
|
+
expect(ws[0].path).toBe('libs/x');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('uses directory name as fallback when no package name', () => {
|
|
47
|
+
writePackageJson(testDir, { name: 'root', workspaces: ['pkgs/*'] });
|
|
48
|
+
fs.mkdirSync(path.join(testDir, 'pkgs/foo'), { recursive: true });
|
|
49
|
+
fs.writeFileSync(path.join(testDir, 'pkgs/foo/package.json'), '{}');
|
|
50
|
+
const ws = detectWorkspaces(testDir);
|
|
51
|
+
expect(ws.length).toBe(1);
|
|
52
|
+
expect(ws[0].name).toBe('foo');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns empty when workspaces glob matches nothing', () => {
|
|
56
|
+
writePackageJson(testDir, { name: 'root', workspaces: ['nowhere/*'] });
|
|
57
|
+
expect(detectWorkspaces(testDir)).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('handles bad JSON gracefully', () => {
|
|
61
|
+
writePackageJson(testDir, { name: 'root', workspaces: ['packages/*'] });
|
|
62
|
+
fs.mkdirSync(path.join(testDir, 'packages/bad'), { recursive: true });
|
|
63
|
+
fs.writeFileSync(path.join(testDir, 'packages/bad/package.json'), '{invalid');
|
|
64
|
+
const ws = detectWorkspaces(testDir);
|
|
65
|
+
expect(ws.length).toBe(1);
|
|
66
|
+
expect(ws[0].name).toBe('bad');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('handles missing root package.json gracefully', () => {
|
|
70
|
+
expect(detectWorkspaces('/tmp/nonexistent-dir')).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export interface WorkspacePackage {
|
|
5
|
+
name: string;
|
|
6
|
+
path: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function detectWorkspaces(rootDir: string): WorkspacePackage[] {
|
|
10
|
+
try {
|
|
11
|
+
const pkg = JSON.parse(
|
|
12
|
+
fs.readFileSync(path.join(rootDir, 'package.json'), 'utf-8'),
|
|
13
|
+
);
|
|
14
|
+
const workspaces = pkg.workspaces;
|
|
15
|
+
if (!workspaces) return [];
|
|
16
|
+
|
|
17
|
+
const patterns: string[] = Array.isArray(workspaces)
|
|
18
|
+
? workspaces
|
|
19
|
+
: workspaces.packages || [];
|
|
20
|
+
|
|
21
|
+
const found = new Map<string, WorkspacePackage>();
|
|
22
|
+
|
|
23
|
+
for (const pattern of patterns) {
|
|
24
|
+
const starIdx = pattern.indexOf('*');
|
|
25
|
+
if (starIdx !== -1) {
|
|
26
|
+
const prefix = pattern.slice(0, starIdx);
|
|
27
|
+
const fullDir = path.join(rootDir, prefix);
|
|
28
|
+
if (fs.existsSync(fullDir)) {
|
|
29
|
+
for (const entry of fs.readdirSync(fullDir, { withFileTypes: true })) {
|
|
30
|
+
if (entry.isDirectory()) {
|
|
31
|
+
const pkgPath = path.join(rootDir, prefix, entry.name);
|
|
32
|
+
addPackage(found, rootDir, pkgPath, entry.name);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
const pkgPath = path.join(rootDir, pattern);
|
|
38
|
+
if (fs.existsSync(pkgPath)) {
|
|
39
|
+
const name = path.basename(pkgPath);
|
|
40
|
+
addPackage(found, rootDir, pkgPath, name);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return Array.from(found.values());
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function addPackage(
|
|
52
|
+
found: Map<string, WorkspacePackage>,
|
|
53
|
+
rootDir: string,
|
|
54
|
+
pkgPath: string,
|
|
55
|
+
fallbackName: string,
|
|
56
|
+
): void {
|
|
57
|
+
const pkgJsonPath = path.join(pkgPath, 'package.json');
|
|
58
|
+
let name = fallbackName;
|
|
59
|
+
if (fs.existsSync(pkgJsonPath)) {
|
|
60
|
+
try {
|
|
61
|
+
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
|
|
62
|
+
name = pkgJson.name || fallbackName;
|
|
63
|
+
} catch {
|
|
64
|
+
// use fallback
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (!found.has(name)) {
|
|
68
|
+
found.set(name, {
|
|
69
|
+
name,
|
|
70
|
+
path: path.relative(rootDir, pkgPath),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { loadConfig, mergeCommands } from './config/index.js';
|
|
2
|
+
export type { ProkomCommand, ProkomConfig, ProkomPreset, ProkomProfile, ProkomPipeline, ProkomToggle } from './config/types.js';
|
|
3
|
+
export { Runtime } from './core/index.js';
|
|
4
|
+
export { EventBus } from './core/event-bus.js';
|
|
5
|
+
export { TaskRunner } from './core/task-runner.js';
|
|
6
|
+
export type { WorkspacePackage } from './core/workspaces.js';
|
|
7
|
+
export { detectWorkspaces } from './core/workspaces.js';
|
|
8
|
+
export type { CIInfo } from './core/ci.js';
|
|
9
|
+
export { detectCI } from './core/ci.js';
|
|
10
|
+
export { sendNotification } from './core/notifier.js';
|
|
11
|
+
export { StatusStore } from './status/index.js';
|
|
12
|
+
export type { TaskState, TaskStatus } from './status/types.js';
|
|
13
|
+
export { PluginManager } from './plugins/index.js';
|
|
14
|
+
export type { Plugin, PluginHooks } from './plugins/types.js';
|
|
15
|
+
export { startUI } from './ui/index.js';
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { PluginManager } from './manager.js';
|
|
3
|
+
import type { Plugin } from './types.js';
|
|
4
|
+
|
|
5
|
+
describe('PluginManager', () => {
|
|
6
|
+
let pm: PluginManager;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
pm = new PluginManager();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('registers and retrieves a plugin', () => {
|
|
13
|
+
pm.register({ id: 'p1', name: 'Plugin 1' });
|
|
14
|
+
expect(pm.get('p1')?.name).toBe('Plugin 1');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('returns all plugins', () => {
|
|
18
|
+
pm.register({ id: 'a', name: 'A' });
|
|
19
|
+
pm.register({ id: 'b', name: 'B' });
|
|
20
|
+
expect(pm.getAll().length).toBe(2);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('removes a plugin', () => {
|
|
24
|
+
pm.register({ id: 'x', name: 'X' });
|
|
25
|
+
expect(pm.remove('x')).toBe(true);
|
|
26
|
+
expect(pm.get('x')).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns false when removing nonexistent plugin', () => {
|
|
30
|
+
expect(pm.remove('nope')).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('executes a hook on all registered plugins', async () => {
|
|
34
|
+
const calls: string[] = [];
|
|
35
|
+
pm.register({
|
|
36
|
+
id: 'a',
|
|
37
|
+
name: 'A',
|
|
38
|
+
hooks: { beforeRun: () => { calls.push('a-before'); } },
|
|
39
|
+
});
|
|
40
|
+
pm.register({
|
|
41
|
+
id: 'b',
|
|
42
|
+
name: 'B',
|
|
43
|
+
hooks: { beforeRun: () => { calls.push('b-before'); } },
|
|
44
|
+
});
|
|
45
|
+
await pm.executeHook('beforeRun', { id: 'test', label: 'T', command: 't' });
|
|
46
|
+
expect(calls).toEqual(['a-before', 'b-before']);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('handles async hooks', async () => {
|
|
50
|
+
const order: number[] = [];
|
|
51
|
+
pm.register({
|
|
52
|
+
id: 'slow',
|
|
53
|
+
name: 'Slow',
|
|
54
|
+
hooks: {
|
|
55
|
+
beforeRun: async () => {
|
|
56
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
57
|
+
order.push(1);
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
pm.register({
|
|
62
|
+
id: 'fast',
|
|
63
|
+
name: 'Fast',
|
|
64
|
+
hooks: { beforeRun: () => { order.push(2); } },
|
|
65
|
+
});
|
|
66
|
+
await pm.executeHook('beforeRun', { id: 'x', label: 'X', command: 'x' });
|
|
67
|
+
expect(order).toEqual([1, 2]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('does not throw when a hook fails', async () => {
|
|
71
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
72
|
+
pm.register({
|
|
73
|
+
id: 'bad',
|
|
74
|
+
name: 'Bad',
|
|
75
|
+
hooks: { beforeRun: () => { throw new Error('fail'); } },
|
|
76
|
+
});
|
|
77
|
+
await expect(
|
|
78
|
+
pm.executeHook('beforeRun', { id: 'x', label: 'X', command: 'x' }),
|
|
79
|
+
).resolves.toBeUndefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('skips plugin with no hooks for the requested hook', async () => {
|
|
83
|
+
pm.register({ id: 'none', name: 'None' });
|
|
84
|
+
await pm.executeHook('beforeRun', { id: 'x', label: 'X', command: 'x' });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('loads nothing when pluginNames is empty', async () => {
|
|
88
|
+
await pm.loadFromConfig();
|
|
89
|
+
await pm.loadFromConfig([]);
|
|
90
|
+
expect(pm.getAll().length).toBe(0);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Plugin, PluginHooks } from './types.js';
|
|
2
|
+
|
|
3
|
+
type HookName = keyof PluginHooks;
|
|
4
|
+
|
|
5
|
+
export class PluginManager {
|
|
6
|
+
private plugins = new Map<string, Plugin>();
|
|
7
|
+
|
|
8
|
+
register(plugin: Plugin): void {
|
|
9
|
+
this.plugins.set(plugin.id, plugin);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
get(id: string): Plugin | undefined {
|
|
13
|
+
return this.plugins.get(id);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
getAll(): Plugin[] {
|
|
17
|
+
return Array.from(this.plugins.values());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
remove(id: string): boolean {
|
|
21
|
+
return this.plugins.delete(id);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async executeHook<K extends HookName>(
|
|
25
|
+
hook: K,
|
|
26
|
+
...args: Parameters<NonNullable<PluginHooks[K]>>
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
for (const plugin of this.plugins.values()) {
|
|
29
|
+
const fn = plugin.hooks?.[hook] as
|
|
30
|
+
| ((...a: any[]) => void | Promise<void>)
|
|
31
|
+
| undefined;
|
|
32
|
+
if (fn) {
|
|
33
|
+
try {
|
|
34
|
+
await Promise.resolve(fn(...args));
|
|
35
|
+
} catch (e) {
|
|
36
|
+
console.error(`Plugin ${plugin.id} ${hook} error:`, e);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async loadFromConfig(pluginNames?: string[]): Promise<void> {
|
|
43
|
+
if (!pluginNames?.length) return;
|
|
44
|
+
for (const name of pluginNames) {
|
|
45
|
+
try {
|
|
46
|
+
const mod = await import(name);
|
|
47
|
+
const p: Plugin = mod.default || mod;
|
|
48
|
+
this.register(p);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
console.error(`Failed to load plugin "${name}":`, e);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ProkomCommand } from '../config/types.js';
|
|
2
|
+
|
|
3
|
+
export interface PluginHooks {
|
|
4
|
+
beforeRun?: (command: ProkomCommand) => void | Promise<void>;
|
|
5
|
+
afterRun?: (
|
|
6
|
+
command: ProkomCommand,
|
|
7
|
+
result: { exitCode?: number; status: string },
|
|
8
|
+
) => void | Promise<void>;
|
|
9
|
+
onOutput?: (commandId: string, text: string) => void | Promise<void>;
|
|
10
|
+
onError?: (commandId: string, error: Error) => void | Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Plugin {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
version?: string;
|
|
17
|
+
hooks?: PluginHooks;
|
|
18
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { StatusStore } from './store.js';
|
|
4
|
+
|
|
5
|
+
describe('StatusStore', () => {
|
|
6
|
+
let store: StatusStore;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
store = new StatusStore();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('starts empty', () => {
|
|
13
|
+
expect(store.getAllTasks().size).toBe(0);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('stores and retrieves a task', () => {
|
|
17
|
+
store.updateTask('build', {
|
|
18
|
+
id: 'build',
|
|
19
|
+
label: 'Build',
|
|
20
|
+
status: 'running',
|
|
21
|
+
output: '',
|
|
22
|
+
});
|
|
23
|
+
const task = store.getTask('build');
|
|
24
|
+
expect(task?.label).toBe('Build');
|
|
25
|
+
expect(task?.status).toBe('running');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('updates an existing task', () => {
|
|
29
|
+
store.updateTask('build', {
|
|
30
|
+
id: 'build',
|
|
31
|
+
label: 'Build',
|
|
32
|
+
status: 'running',
|
|
33
|
+
output: 'start',
|
|
34
|
+
});
|
|
35
|
+
store.updateTask('build', { output: 'start\nprogress' });
|
|
36
|
+
expect(store.getTask('build')?.output).toBe('start\nprogress');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('clears all tasks', () => {
|
|
40
|
+
store.updateTask('a', { id: 'a', label: 'A', status: 'running' });
|
|
41
|
+
store.updateTask('b', { id: 'b', label: 'B', status: 'success' });
|
|
42
|
+
expect(store.getAllTasks().size).toBe(2);
|
|
43
|
+
store.clear();
|
|
44
|
+
expect(store.getAllTasks().size).toBe(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('prunes completed tasks but keeps running ones', () => {
|
|
48
|
+
store.updateTask('running-task', { id: 'running-task', label: 'Running', status: 'running' });
|
|
49
|
+
store.updateTask('done-task', { id: 'done-task', label: 'Done', status: 'success' });
|
|
50
|
+
store.updateTask('failed-task', { id: 'failed-task', label: 'Failed', status: 'failure' });
|
|
51
|
+
expect(store.getAllTasks().size).toBe(3);
|
|
52
|
+
store.pruneCompleted();
|
|
53
|
+
expect(store.getAllTasks().size).toBe(1);
|
|
54
|
+
expect(store.getTask('running-task')).toBeDefined();
|
|
55
|
+
expect(store.getTask('done-task')).toBeUndefined();
|
|
56
|
+
expect(store.getTask('failed-task')).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('pruneCompleted removes nothing when all tasks are running', () => {
|
|
60
|
+
store.updateTask('a', { id: 'a', label: 'A', status: 'running' });
|
|
61
|
+
store.updateTask('b', { id: 'b', label: 'B', status: 'running' });
|
|
62
|
+
store.pruneCompleted();
|
|
63
|
+
expect(store.getAllTasks().size).toBe(2);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('notifies subscribers on update', () => {
|
|
67
|
+
const updates: number[] = [];
|
|
68
|
+
store.subscribe((tasks) => updates.push(tasks.size));
|
|
69
|
+
store.updateTask('x', { id: 'x', label: 'X', status: 'running' });
|
|
70
|
+
expect(updates).toEqual([1]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('unsubscribes correctly', () => {
|
|
74
|
+
const updates: number[] = [];
|
|
75
|
+
const unsub = store.subscribe((tasks) => updates.push(tasks.size));
|
|
76
|
+
unsub();
|
|
77
|
+
store.updateTask('x', { id: 'x', label: 'X', status: 'running' });
|
|
78
|
+
expect(updates).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('persists and loads from directory', () => {
|
|
82
|
+
const dir = '/tmp/dcc-test-store';
|
|
83
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
84
|
+
store.updateTask('persist', {
|
|
85
|
+
id: 'persist',
|
|
86
|
+
label: 'Persist',
|
|
87
|
+
status: 'success',
|
|
88
|
+
exitCode: 0,
|
|
89
|
+
});
|
|
90
|
+
store.saveDir(dir);
|
|
91
|
+
|
|
92
|
+
const store2 = new StatusStore();
|
|
93
|
+
store2.loadDir(dir);
|
|
94
|
+
const task = store2.getTask('persist');
|
|
95
|
+
expect(task?.label).toBe('Persist');
|
|
96
|
+
expect(task?.status).toBe('success');
|
|
97
|
+
|
|
98
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('resets stale running tasks to failure on load', () => {
|
|
102
|
+
const dir = '/tmp/dcc-test-stale-store';
|
|
103
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
104
|
+
const store = new StatusStore();
|
|
105
|
+
store.updateTask('stale-run', {
|
|
106
|
+
id: 'stale-run',
|
|
107
|
+
label: 'Stale Run',
|
|
108
|
+
status: 'running',
|
|
109
|
+
startTime: Date.now(),
|
|
110
|
+
});
|
|
111
|
+
store.saveDir(dir);
|
|
112
|
+
|
|
113
|
+
const store2 = new StatusStore();
|
|
114
|
+
store2.loadDir(dir);
|
|
115
|
+
const task = store2.getTask('stale-run');
|
|
116
|
+
expect(task?.status).toBe('failure');
|
|
117
|
+
expect(task?.label).toBe('Stale Run');
|
|
118
|
+
expect(typeof task?.endTime).toBe('number');
|
|
119
|
+
|
|
120
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { TaskState } from './types.js';
|
|
4
|
+
|
|
5
|
+
type Listener = (tasks: ReadonlyMap<string, TaskState>) => void;
|
|
6
|
+
|
|
7
|
+
export class StatusStore {
|
|
8
|
+
private tasks = new Map<string, TaskState>();
|
|
9
|
+
private listeners = new Set<Listener>();
|
|
10
|
+
|
|
11
|
+
getTask(id: string): TaskState | undefined {
|
|
12
|
+
return this.tasks.get(id);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
getAllTasks(): ReadonlyMap<string, TaskState> {
|
|
16
|
+
return new Map(this.tasks);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
updateTask(id: string, update: Partial<TaskState>): void {
|
|
20
|
+
const existing = this.tasks.get(id) ?? {
|
|
21
|
+
id,
|
|
22
|
+
label: id,
|
|
23
|
+
status: 'idle' as const,
|
|
24
|
+
};
|
|
25
|
+
this.tasks.set(id, { ...existing, ...update });
|
|
26
|
+
this.notify();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
clear(): void {
|
|
30
|
+
this.tasks.clear();
|
|
31
|
+
this.notify();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
removeTask(id: string): void {
|
|
35
|
+
this.tasks.delete(id);
|
|
36
|
+
this.notify();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
pruneCompleted(): void {
|
|
40
|
+
let changed = false;
|
|
41
|
+
for (const [id, task] of this.tasks) {
|
|
42
|
+
if (task.status !== 'running') {
|
|
43
|
+
this.tasks.delete(id);
|
|
44
|
+
changed = true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (changed) this.notify();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
subscribe(listener: Listener): () => void {
|
|
51
|
+
this.listeners.add(listener);
|
|
52
|
+
return () => this.listeners.delete(listener);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
saveDir(dir: string): void {
|
|
56
|
+
const filePath = path.join(dir, 'status.json');
|
|
57
|
+
const data = JSON.stringify(Array.from(this.tasks.entries()));
|
|
58
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
59
|
+
fs.writeFileSync(filePath, data, 'utf-8');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
loadDir(dir: string): void {
|
|
63
|
+
const filePath = path.join(dir, 'status.json');
|
|
64
|
+
if (fs.existsSync(filePath)) {
|
|
65
|
+
const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as [string, TaskState][];
|
|
66
|
+
const tasks = new Map(raw);
|
|
67
|
+
let changed = false;
|
|
68
|
+
for (const [id] of tasks) {
|
|
69
|
+
const task = tasks.get(id)!;
|
|
70
|
+
if (task.status === 'running') {
|
|
71
|
+
tasks.set(id, { ...task, status: 'failure', endTime: Date.now() });
|
|
72
|
+
changed = true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (changed) {
|
|
76
|
+
fs.writeFileSync(filePath, JSON.stringify(Array.from(tasks.entries())), 'utf-8');
|
|
77
|
+
}
|
|
78
|
+
this.tasks = tasks;
|
|
79
|
+
this.notify();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private notify(): void {
|
|
84
|
+
for (const listener of this.listeners) {
|
|
85
|
+
listener(new Map(this.tasks));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type TaskStatus = 'idle' | 'running' | 'success' | 'failure';
|
|
2
|
+
|
|
3
|
+
export interface TaskState {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
status: TaskStatus;
|
|
7
|
+
output?: string;
|
|
8
|
+
exitCode?: number;
|
|
9
|
+
startTime?: number;
|
|
10
|
+
endTime?: number;
|
|
11
|
+
watchMode?: boolean;
|
|
12
|
+
}
|