@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.
Files changed (193) hide show
  1. package/.developer-control-center/metrics.json +1 -0
  2. package/.developer-control-center/status.json +1 -0
  3. package/.developer-control-center/timings.jsonl +3 -0
  4. package/.github/workflows/ci.yml +47 -0
  5. package/AGENTS.md +51 -0
  6. package/PLUGINS.md +145 -0
  7. package/README.md +147 -0
  8. package/developer-control-center.config.example.js +91 -0
  9. package/developer-control-center.config.js +177 -0
  10. package/dist/cli.d.ts +3 -0
  11. package/dist/cli.d.ts.map +1 -0
  12. package/dist/cli.js +223 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/config/index.d.ts +3 -0
  15. package/dist/config/index.d.ts.map +1 -0
  16. package/dist/config/index.js +2 -0
  17. package/dist/config/index.js.map +1 -0
  18. package/dist/config/loader.d.ts +4 -0
  19. package/dist/config/loader.d.ts.map +1 -0
  20. package/dist/config/loader.js +96 -0
  21. package/dist/config/loader.js.map +1 -0
  22. package/dist/config/loader.test.d.ts +2 -0
  23. package/dist/config/loader.test.d.ts.map +1 -0
  24. package/dist/config/loader.test.js +25 -0
  25. package/dist/config/loader.test.js.map +1 -0
  26. package/dist/config/presets/node.d.ts +10 -0
  27. package/dist/config/presets/node.d.ts.map +1 -0
  28. package/dist/config/presets/node.js +31 -0
  29. package/dist/config/presets/node.js.map +1 -0
  30. package/dist/config/presets/react.d.ts +10 -0
  31. package/dist/config/presets/react.d.ts.map +1 -0
  32. package/dist/config/presets/react.js +36 -0
  33. package/dist/config/presets/react.js.map +1 -0
  34. package/dist/config/types.d.ts +55 -0
  35. package/dist/config/types.d.ts.map +1 -0
  36. package/dist/config/types.js +2 -0
  37. package/dist/config/types.js.map +1 -0
  38. package/dist/config/types.test.d.ts +2 -0
  39. package/dist/config/types.test.d.ts.map +1 -0
  40. package/dist/config/types.test.js +23 -0
  41. package/dist/config/types.test.js.map +1 -0
  42. package/dist/core/ci.d.ts +6 -0
  43. package/dist/core/ci.d.ts.map +1 -0
  44. package/dist/core/ci.js +22 -0
  45. package/dist/core/ci.js.map +1 -0
  46. package/dist/core/ci.test.d.ts +2 -0
  47. package/dist/core/ci.test.d.ts.map +1 -0
  48. package/dist/core/ci.test.js +45 -0
  49. package/dist/core/ci.test.js.map +1 -0
  50. package/dist/core/event-bus.d.ts +18 -0
  51. package/dist/core/event-bus.d.ts.map +1 -0
  52. package/dist/core/event-bus.js +19 -0
  53. package/dist/core/event-bus.js.map +1 -0
  54. package/dist/core/event-bus.test.d.ts +2 -0
  55. package/dist/core/event-bus.test.d.ts.map +1 -0
  56. package/dist/core/event-bus.test.js +49 -0
  57. package/dist/core/event-bus.test.js.map +1 -0
  58. package/dist/core/index.d.ts +9 -0
  59. package/dist/core/index.d.ts.map +1 -0
  60. package/dist/core/index.js +7 -0
  61. package/dist/core/index.js.map +1 -0
  62. package/dist/core/notifier.d.ts +2 -0
  63. package/dist/core/notifier.d.ts.map +1 -0
  64. package/dist/core/notifier.js +28 -0
  65. package/dist/core/notifier.js.map +1 -0
  66. package/dist/core/notifier.test.d.ts +2 -0
  67. package/dist/core/notifier.test.d.ts.map +1 -0
  68. package/dist/core/notifier.test.js +25 -0
  69. package/dist/core/notifier.test.js.map +1 -0
  70. package/dist/core/runtime.d.ts +25 -0
  71. package/dist/core/runtime.d.ts.map +1 -0
  72. package/dist/core/runtime.js +85 -0
  73. package/dist/core/runtime.js.map +1 -0
  74. package/dist/core/task-runner.d.ts +26 -0
  75. package/dist/core/task-runner.d.ts.map +1 -0
  76. package/dist/core/task-runner.js +354 -0
  77. package/dist/core/task-runner.js.map +1 -0
  78. package/dist/core/timer-plugin.d.ts +3 -0
  79. package/dist/core/timer-plugin.d.ts.map +1 -0
  80. package/dist/core/timer-plugin.js +34 -0
  81. package/dist/core/timer-plugin.js.map +1 -0
  82. package/dist/core/workspaces.d.ts +6 -0
  83. package/dist/core/workspaces.d.ts.map +1 -0
  84. package/dist/core/workspaces.js +60 -0
  85. package/dist/core/workspaces.js.map +1 -0
  86. package/dist/core/workspaces.test.d.ts +2 -0
  87. package/dist/core/workspaces.test.d.ts.map +1 -0
  88. package/dist/core/workspaces.test.js +62 -0
  89. package/dist/core/workspaces.test.js.map +1 -0
  90. package/dist/index.d.ts +16 -0
  91. package/dist/index.d.ts.map +1 -0
  92. package/dist/index.js +11 -0
  93. package/dist/index.js.map +1 -0
  94. package/dist/plugins/index.d.ts +3 -0
  95. package/dist/plugins/index.d.ts.map +1 -0
  96. package/dist/plugins/index.js +2 -0
  97. package/dist/plugins/index.js.map +1 -0
  98. package/dist/plugins/manager.d.ts +13 -0
  99. package/dist/plugins/manager.d.ts.map +1 -0
  100. package/dist/plugins/manager.js +43 -0
  101. package/dist/plugins/manager.js.map +1 -0
  102. package/dist/plugins/manager.test.d.ts +2 -0
  103. package/dist/plugins/manager.test.d.ts.map +1 -0
  104. package/dist/plugins/manager.test.js +79 -0
  105. package/dist/plugins/manager.test.js.map +1 -0
  106. package/dist/plugins/types.d.ts +17 -0
  107. package/dist/plugins/types.d.ts.map +1 -0
  108. package/dist/plugins/types.js +2 -0
  109. package/dist/plugins/types.js.map +1 -0
  110. package/dist/status/index.d.ts +3 -0
  111. package/dist/status/index.d.ts.map +1 -0
  112. package/dist/status/index.js +2 -0
  113. package/dist/status/index.js.map +1 -0
  114. package/dist/status/store.d.ts +18 -0
  115. package/dist/status/store.d.ts.map +1 -0
  116. package/dist/status/store.js +76 -0
  117. package/dist/status/store.js.map +1 -0
  118. package/dist/status/store.test.d.ts +2 -0
  119. package/dist/status/store.test.d.ts.map +1 -0
  120. package/dist/status/store.test.js +107 -0
  121. package/dist/status/store.test.js.map +1 -0
  122. package/dist/status/types.d.ts +12 -0
  123. package/dist/status/types.d.ts.map +1 -0
  124. package/dist/status/types.js +2 -0
  125. package/dist/status/types.js.map +1 -0
  126. package/dist/ui/app.d.ts +10 -0
  127. package/dist/ui/app.d.ts.map +1 -0
  128. package/dist/ui/app.js +479 -0
  129. package/dist/ui/app.js.map +1 -0
  130. package/dist/ui/command-list.d.ts +30 -0
  131. package/dist/ui/command-list.d.ts.map +1 -0
  132. package/dist/ui/command-list.js +45 -0
  133. package/dist/ui/command-list.js.map +1 -0
  134. package/dist/ui/index.d.ts +4 -0
  135. package/dist/ui/index.d.ts.map +1 -0
  136. package/dist/ui/index.js +8 -0
  137. package/dist/ui/index.js.map +1 -0
  138. package/dist/ui/metrics-panel.d.ts +10 -0
  139. package/dist/ui/metrics-panel.d.ts.map +1 -0
  140. package/dist/ui/metrics-panel.js +139 -0
  141. package/dist/ui/metrics-panel.js.map +1 -0
  142. package/dist/ui/panel.d.ts +16 -0
  143. package/dist/ui/panel.d.ts.map +1 -0
  144. package/dist/ui/panel.js +16 -0
  145. package/dist/ui/panel.js.map +1 -0
  146. package/dist/ui/status-panel.d.ts +16 -0
  147. package/dist/ui/status-panel.d.ts.map +1 -0
  148. package/dist/ui/status-panel.js +52 -0
  149. package/dist/ui/status-panel.js.map +1 -0
  150. package/docs/architecture.md +29 -0
  151. package/docs/config.md +15 -0
  152. package/docs/mvp.md +17 -0
  153. package/docs/phases.md +49 -0
  154. package/docs/technical-decisions.md +19 -0
  155. package/docs/ui.md +14 -0
  156. package/package.json +30 -0
  157. package/src/cli.ts +242 -0
  158. package/src/config/index.ts +2 -0
  159. package/src/config/loader.test.ts +30 -0
  160. package/src/config/loader.ts +123 -0
  161. package/src/config/presets/node.ts +30 -0
  162. package/src/config/presets/react.ts +35 -0
  163. package/src/config/types.test.ts +24 -0
  164. package/src/config/types.ts +52 -0
  165. package/src/core/ci.test.ts +54 -0
  166. package/src/core/ci.ts +26 -0
  167. package/src/core/event-bus.test.ts +56 -0
  168. package/src/core/event-bus.ts +34 -0
  169. package/src/core/index.ts +8 -0
  170. package/src/core/notifier.test.ts +30 -0
  171. package/src/core/notifier.ts +34 -0
  172. package/src/core/runtime.ts +99 -0
  173. package/src/core/task-runner.ts +408 -0
  174. package/src/core/timer-plugin.ts +34 -0
  175. package/src/core/workspaces.test.ts +72 -0
  176. package/src/core/workspaces.ts +73 -0
  177. package/src/index.ts +15 -0
  178. package/src/plugins/index.ts +2 -0
  179. package/src/plugins/manager.test.ts +92 -0
  180. package/src/plugins/manager.ts +54 -0
  181. package/src/plugins/types.ts +18 -0
  182. package/src/status/index.ts +2 -0
  183. package/src/status/store.test.ts +122 -0
  184. package/src/status/store.ts +88 -0
  185. package/src/status/types.ts +12 -0
  186. package/src/ui/app.tsx +606 -0
  187. package/src/ui/command-list.tsx +163 -0
  188. package/src/ui/index.tsx +10 -0
  189. package/src/ui/metrics-panel.tsx +234 -0
  190. package/src/ui/panel.tsx +76 -0
  191. package/src/ui/status-panel.tsx +160 -0
  192. package/tsconfig.json +21 -0
  193. 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,2 @@
1
+ export { PluginManager } from './manager.js';
2
+ export type { Plugin, PluginHooks } from './types.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,2 @@
1
+ export { StatusStore } from './store.js';
2
+ export type { TaskState, TaskStatus } from './types.js';
@@ -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
+ }