@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,34 @@
1
+ import { ProkomCommand } from '../config/types.js';
2
+
3
+ export interface EventMap {
4
+ 'task:start': [command: ProkomCommand];
5
+ 'task:complete': [id: string, exitCode: number | null];
6
+ 'task:output': [id: string, text: string];
7
+ 'task:error': [id: string, error: Error];
8
+ [event: string]: any[];
9
+ }
10
+
11
+ type Handler<A extends any[]> = (...args: A) => void;
12
+
13
+ export class EventBus {
14
+ private listeners = new Map<string, Set<(...args: any[]) => void>>();
15
+
16
+ on<E extends keyof EventMap>(event: E, handler: Handler<EventMap[E]>): void {
17
+ if (!this.listeners.has(event as string)) {
18
+ this.listeners.set(event as string, new Set());
19
+ }
20
+ this.listeners.get(event as string)!.add(handler as (...args: any[]) => void);
21
+ }
22
+
23
+ off<E extends keyof EventMap>(event: E, handler: Handler<EventMap[E]>): void {
24
+ this.listeners.get(event as string)?.delete(handler as (...args: any[]) => void);
25
+ }
26
+
27
+ emit<E extends keyof EventMap>(event: E, ...args: EventMap[E]): void {
28
+ this.listeners.get(event as string)?.forEach((h) => h(...args));
29
+ }
30
+
31
+ removeAll(): void {
32
+ this.listeners.clear();
33
+ }
34
+ }
@@ -0,0 +1,8 @@
1
+ export { Runtime } from './runtime.js';
2
+ export { EventBus } from './event-bus.js';
3
+ export { TaskRunner } from './task-runner.js';
4
+ export type { WorkspacePackage } from './workspaces.js';
5
+ export { detectWorkspaces } from './workspaces.js';
6
+ export type { CIInfo } from './ci.js';
7
+ export { detectCI } from './ci.js';
8
+ export { sendNotification } from './notifier.js';
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ vi.mock('child_process', () => {
4
+ const spawn = vi.fn(() => {
5
+ const child = { on: vi.fn(), unref: vi.fn(), stdout: null, stderr: null };
6
+ child.on.mockReturnValue(child);
7
+ return child;
8
+ });
9
+ return { spawn, execSync: vi.fn() };
10
+ });
11
+
12
+ import { sendNotification } from './notifier.js';
13
+
14
+ describe('sendNotification', () => {
15
+ beforeEach(() => {
16
+ vi.clearAllMocks();
17
+ });
18
+
19
+ it('does not throw when called', () => {
20
+ expect(() => sendNotification('Test', 'Hello')).not.toThrow();
21
+ });
22
+
23
+ it('handles special characters in title', () => {
24
+ expect(() => sendNotification('Test "quote"', 'Message')).not.toThrow();
25
+ });
26
+
27
+ it('handles special characters in message', () => {
28
+ expect(() => sendNotification('Test', 'Message with "quotes" and $ymbols')).not.toThrow();
29
+ });
30
+ });
@@ -0,0 +1,34 @@
1
+ import { spawn, ChildProcess } from 'child_process';
2
+
3
+ const children = new Set<ChildProcess>();
4
+
5
+ export function sendNotification(
6
+ title: string,
7
+ message: string,
8
+ ): void {
9
+ let child: ChildProcess | undefined;
10
+
11
+ switch (process.platform) {
12
+ case 'linux':
13
+ child = spawn('notify-send', [title, message], {
14
+ stdio: 'ignore',
15
+ });
16
+ break;
17
+ case 'darwin': {
18
+ const escaped = (s: string) => s.replace(/"/g, '\\"');
19
+ child = spawn('osascript', ['-e',
20
+ `display notification "${escaped(message)}" with title "${escaped(title)}"`,
21
+ ], { stdio: 'ignore' });
22
+ break;
23
+ }
24
+ }
25
+
26
+ if (child) {
27
+ children.add(child);
28
+ child.on('error', () => { /* notification failed, non-critical */ });
29
+ child.on('close', () => {
30
+ children.delete(child!);
31
+ });
32
+ child.unref();
33
+ }
34
+ }
@@ -0,0 +1,99 @@
1
+ import { execSync } from 'child_process';
2
+ import { ProkomConfig } from '../config/index.js';
3
+ import { StatusStore } from '../status/index.js';
4
+ import { PluginManager } from '../plugins/index.js';
5
+ import { EventBus } from './event-bus.js';
6
+ import { TaskRunner } from './task-runner.js';
7
+ import { detectWorkspaces, WorkspacePackage } from './workspaces.js';
8
+ import { sendNotification } from './notifier.js';
9
+ import { detectCI, CIInfo } from './ci.js';
10
+ import { timerPlugin } from './timer-plugin.js';
11
+
12
+ function getGitBranch(): string | undefined {
13
+ try {
14
+ return execSync('git rev-parse --abbrev-ref HEAD', {
15
+ encoding: 'utf-8',
16
+ timeout: 2000,
17
+ stdio: 'pipe',
18
+ }).trim();
19
+ } catch {
20
+ return undefined;
21
+ }
22
+ }
23
+
24
+ export class Runtime {
25
+ readonly eventBus = new EventBus();
26
+ readonly statusStore = new StatusStore();
27
+ readonly taskRunner: TaskRunner;
28
+ readonly pluginManager = new PluginManager();
29
+ readonly gitBranch: string | undefined;
30
+ readonly workspaces: WorkspacePackage[];
31
+ readonly ci: CIInfo;
32
+
33
+ private persistenceDir = '.developer-control-center';
34
+ private unsubPersistence?: () => void;
35
+ private unsubNotifier?: () => void;
36
+
37
+ constructor(readonly config: ProkomConfig) {
38
+ this.taskRunner = new TaskRunner(
39
+ this.statusStore,
40
+ this.eventBus,
41
+ this.pluginManager,
42
+ config.commands,
43
+ );
44
+ this.pluginManager.register(timerPlugin);
45
+ this.gitBranch = getGitBranch();
46
+ this.workspaces = detectWorkspaces(process.cwd());
47
+ this.ci = detectCI();
48
+ }
49
+
50
+ async start(): Promise<void> {
51
+ this.statusStore.loadDir(this.persistenceDir);
52
+ this.unsubPersistence = this.statusStore.subscribe(() => {
53
+ try {
54
+ this.statusStore.saveDir(this.persistenceDir);
55
+ } catch {
56
+ // non-critical
57
+ }
58
+ });
59
+
60
+ await this.pluginManager.loadFromConfig(this.config.plugins);
61
+
62
+ if (this.config.notifications !== false) {
63
+ this.unsubNotifier = this.startNotifier();
64
+ }
65
+ }
66
+
67
+ stop(): void {
68
+ this.taskRunner.abortAll();
69
+ this.eventBus.removeAll();
70
+ this.unsubPersistence?.();
71
+ this.unsubNotifier?.();
72
+ }
73
+
74
+ private startNotifier(): () => void {
75
+ const onComplete = (id: string, exitCode: number | null) => {
76
+ const task = this.statusStore.getTask(id);
77
+ if (!task) return;
78
+ const ok = exitCode === 0;
79
+ sendNotification(
80
+ ok ? 'Task completed' : 'Task failed',
81
+ `${task.label} — ${ok ? 'success' : `exit ${exitCode}`}`,
82
+ );
83
+ };
84
+
85
+ const onError = (id: string) => {
86
+ const task = this.statusStore.getTask(id);
87
+ if (!task) return;
88
+ sendNotification('Task error', `${task.label} — failed with error`);
89
+ };
90
+
91
+ this.eventBus.on('task:complete', onComplete);
92
+ this.eventBus.on('task:error', onError);
93
+
94
+ return () => {
95
+ this.eventBus.off('task:complete', onComplete);
96
+ this.eventBus.off('task:error', onError);
97
+ };
98
+ }
99
+ }
@@ -0,0 +1,408 @@
1
+ import { spawn, ChildProcess } from 'child_process';
2
+ import fs from 'fs';
3
+ import { ProkomCommand } from '../config/types.js';
4
+ import { StatusStore } from '../status/store.js';
5
+ import { PluginManager } from '../plugins/index.js';
6
+ import { EventBus } from './event-bus.js';
7
+
8
+ class FileWatcher {
9
+ private watcher?: fs.FSWatcher;
10
+ private debounceTimer?: ReturnType<typeof setTimeout>;
11
+
12
+ watch(dir: string, onChange: () => void): void {
13
+ this.stop();
14
+ try {
15
+ this.watcher = fs.watch(dir, { recursive: true }, () => {
16
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
17
+ this.debounceTimer = setTimeout(onChange, 500);
18
+ });
19
+ } catch {
20
+ console.warn(`[dcc] watch mode: recursive file watching not supported on ${dir}`);
21
+ }
22
+ }
23
+
24
+ stop(): void {
25
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
26
+ if (this.watcher) {
27
+ this.watcher.close();
28
+ this.watcher = undefined;
29
+ }
30
+ }
31
+ }
32
+
33
+ export class TaskRunner {
34
+ private running = new Map<string, ChildProcess>();
35
+ private watchers = new Map<string, FileWatcher>();
36
+
37
+ constructor(
38
+ private statusStore: StatusStore,
39
+ private eventBus: EventBus,
40
+ private pluginManager?: PluginManager,
41
+ private commands: ProkomCommand[] = [],
42
+ ) {}
43
+
44
+ setCommands(commands: ProkomCommand[]): void {
45
+ this.commands = commands;
46
+ }
47
+
48
+ async run(command: ProkomCommand, skipClear = false): Promise<void> {
49
+ if (command.toggle) {
50
+ command = { ...command, command: command.toggle.start };
51
+ }
52
+ if (command.pipelineSteps) {
53
+ return this.runPipeline(command);
54
+ }
55
+ if (command.parallelSteps) {
56
+ return this.runParallelSteps(command);
57
+ }
58
+
59
+ if (!command.command) return;
60
+
61
+ const existing = this.statusStore.getTask(command.id);
62
+ if (existing?.status === 'running') {
63
+ return;
64
+ }
65
+
66
+ await this.pluginManager?.executeHook('beforeRun', command);
67
+
68
+ this.stopWatcher(command.id);
69
+ this.statusStore.pruneCompleted();
70
+ this.statusStore.updateTask(command.id, {
71
+ id: command.id,
72
+ label: command.label,
73
+ status: 'running',
74
+ output: '',
75
+ startTime: Date.now(),
76
+ endTime: undefined,
77
+ exitCode: undefined,
78
+ watchMode: false,
79
+ });
80
+
81
+ this.eventBus.emit('task:start', command);
82
+
83
+ const child = spawn(command.command, [], {
84
+ shell: true,
85
+ detached: true,
86
+ stdio: ['ignore', 'pipe', 'pipe'],
87
+ cwd: command.cwd,
88
+ });
89
+
90
+ this.running.set(command.id, child);
91
+
92
+ let output = '';
93
+
94
+ child.stdout?.on('data', (data: Buffer) => {
95
+ const text = data.toString();
96
+ output += text;
97
+ this.statusStore.updateTask(command.id, { output });
98
+ this.eventBus.emit('task:output', command.id, text);
99
+ this.pluginManager?.executeHook('onOutput', command.id, text);
100
+ });
101
+
102
+ child.stderr?.on('data', (data: Buffer) => {
103
+ const text = data.toString();
104
+ output += text;
105
+ this.statusStore.updateTask(command.id, { output });
106
+ this.eventBus.emit('task:output', command.id, text);
107
+ this.pluginManager?.executeHook('onOutput', command.id, text);
108
+ });
109
+
110
+ child.on('close', (exitCode) => {
111
+ this.running.delete(command.id);
112
+
113
+ const resultStatus = exitCode === 0 ? 'success' as const : 'failure' as const;
114
+
115
+ const result = {
116
+ exitCode: exitCode ?? undefined,
117
+ status: resultStatus,
118
+ };
119
+
120
+ this.statusStore.updateTask(command.id, {
121
+ status: resultStatus,
122
+ exitCode: result.exitCode,
123
+ endTime: Date.now(),
124
+ });
125
+ this.eventBus.emit('task:complete', command.id, exitCode);
126
+ this.pluginManager?.executeHook('afterRun', command, result);
127
+
128
+ if (command.watch) {
129
+ this.startWatcher(command);
130
+ }
131
+ });
132
+
133
+ child.on('error', (error) => {
134
+ this.running.delete(command.id);
135
+
136
+ const result = {
137
+ exitCode: -1,
138
+ status: 'failure' as const,
139
+ };
140
+
141
+ this.statusStore.updateTask(command.id, {
142
+ status: 'failure',
143
+ output: (output || '') + `\nError: ${error.message}`,
144
+ exitCode: -1,
145
+ endTime: Date.now(),
146
+ });
147
+ this.eventBus.emit('task:error', command.id, error);
148
+ this.pluginManager?.executeHook('onError', command.id, error);
149
+ this.pluginManager?.executeHook('afterRun', command, {
150
+ exitCode: -1,
151
+ status: 'failure',
152
+ });
153
+ });
154
+ }
155
+
156
+ abort(id: string): void {
157
+ const child = this.running.get(id);
158
+ if (child) {
159
+ this.killChild(child);
160
+ this.running.delete(id);
161
+ }
162
+ this.stopWatcher(id);
163
+ }
164
+
165
+ abortAll(): void {
166
+ for (const [id, child] of this.running) {
167
+ this.killChild(child);
168
+ this.running.delete(id);
169
+ }
170
+ for (const [id] of this.watchers) {
171
+ this.stopWatcher(id);
172
+ }
173
+ }
174
+
175
+ stop(command: ProkomCommand): void {
176
+ const child = this.running.get(command.id);
177
+ if (child) {
178
+ child.removeAllListeners('close');
179
+ child.removeAllListeners('error');
180
+ this.killChild(child);
181
+ this.running.delete(command.id);
182
+ }
183
+ this.stopWatcher(command.id);
184
+ this.statusStore.updateTask(command.id, {
185
+ status: 'success',
186
+ endTime: Date.now(),
187
+ });
188
+ this.eventBus.emit('task:complete', command.id, 0);
189
+ if (command.toggle?.stop) {
190
+ spawn(command.toggle.stop, {
191
+ shell: true,
192
+ stdio: 'ignore',
193
+ cwd: command.cwd,
194
+ });
195
+ }
196
+ }
197
+
198
+ private async runPipeline(command: ProkomCommand): Promise<void> {
199
+ const existing = this.statusStore.getTask(command.id);
200
+ if (existing?.status === 'running') return;
201
+
202
+ await this.pluginManager?.executeHook('beforeRun', command);
203
+
204
+ this.statusStore.updateTask(command.id, {
205
+ id: command.id,
206
+ label: command.label,
207
+ status: 'running',
208
+ output: '',
209
+ startTime: Date.now(),
210
+ endTime: undefined,
211
+ exitCode: undefined,
212
+ });
213
+
214
+ this.eventBus.emit('task:start', command);
215
+
216
+ const steps = command.pipelineSteps!;
217
+ let fullOutput = '';
218
+
219
+ for (let i = 0; i < steps.length; i++) {
220
+ const stepId = steps[i];
221
+ const stepCmd = this.commands.find((c) => c.id === stepId);
222
+ if (!stepCmd) {
223
+ fullOutput += `✗ Step "${stepId}" not found\n`;
224
+ this.finishPipeline(command, fullOutput, -1);
225
+ return;
226
+ }
227
+
228
+ fullOutput += `▶ Step ${i + 1}/${steps.length}: ${stepCmd.label}\n`;
229
+ this.statusStore.updateTask(command.id, { output: fullOutput });
230
+
231
+ const result = await this.runAndWait(stepCmd);
232
+
233
+ if (result.exitCode !== 0) {
234
+ fullOutput += `✗ Pipeline failed at step ${i + 1} (${stepCmd.label})\n`;
235
+ this.finishPipeline(command, fullOutput, result.exitCode ?? -1);
236
+ return;
237
+ }
238
+
239
+ fullOutput += `✓ Step ${i + 1} passed\n`;
240
+ this.statusStore.updateTask(command.id, { output: fullOutput });
241
+ }
242
+
243
+ fullOutput += '✓ Pipeline completed successfully\n';
244
+ this.finishPipeline(command, fullOutput, 0);
245
+ }
246
+
247
+ private async runParallelSteps(command: ProkomCommand): Promise<void> {
248
+ const existing = this.statusStore.getTask(command.id);
249
+ if (existing?.status === 'running') return;
250
+
251
+ await this.pluginManager?.executeHook('beforeRun', command);
252
+
253
+ this.statusStore.updateTask(command.id, {
254
+ id: command.id,
255
+ label: command.label,
256
+ status: 'running',
257
+ output: '',
258
+ startTime: Date.now(),
259
+ endTime: undefined,
260
+ exitCode: undefined,
261
+ });
262
+
263
+ this.eventBus.emit('task:start', command);
264
+
265
+ const steps = command.parallelSteps!;
266
+ const stepCommands = steps.map((id) =>
267
+ this.commands.find((c) => c.id === id),
268
+ );
269
+
270
+ const missing = stepCommands.findIndex((c) => !c);
271
+ if (missing >= 0) {
272
+ this.statusStore.updateTask(command.id, {
273
+ status: 'failure',
274
+ output: `✗ Step "${steps[missing]}" not found\n`,
275
+ exitCode: -1,
276
+ endTime: Date.now(),
277
+ });
278
+ this.eventBus.emit('task:complete', command.id, -1);
279
+ return;
280
+ }
281
+
282
+ const validCmds = stepCommands as ProkomCommand[];
283
+ let fullOutput = `▶ Running ${validCmds.length} steps in parallel\n`;
284
+
285
+ for (const cmd of validCmds) {
286
+ fullOutput += ` ▶ ${cmd.label}\n`;
287
+ }
288
+ this.statusStore.updateTask(command.id, { output: fullOutput });
289
+
290
+ const results = await Promise.all(
291
+ validCmds.map((cmd) => this.runAndWait(cmd)),
292
+ );
293
+
294
+ const failed: number[] = [];
295
+ for (let i = 0; i < results.length; i++) {
296
+ const ok = results[i].exitCode === 0;
297
+ fullOutput += `${ok ? '✓' : '✗'} ${validCmds[i].label} (exit ${results[i].exitCode ?? -1})\n`;
298
+ if (!ok) failed.push(i);
299
+ }
300
+
301
+ if (failed.length === 0) {
302
+ fullOutput += '✓ All parallel steps passed\n';
303
+ } else {
304
+ fullOutput += `✗ ${failed.length} step(s) failed\n`;
305
+ }
306
+
307
+ this.statusStore.updateTask(command.id, {
308
+ status: failed.length === 0 ? 'success' : 'failure',
309
+ output: fullOutput,
310
+ exitCode: failed.length === 0 ? 0 : -1,
311
+ endTime: Date.now(),
312
+ });
313
+ this.eventBus.emit('task:complete', command.id, failed.length === 0 ? 0 : -1);
314
+ this.pluginManager?.executeHook('afterRun', command, {
315
+ exitCode: failed.length === 0 ? 0 : -1,
316
+ status: failed.length === 0 ? 'success' : 'failure',
317
+ });
318
+ }
319
+
320
+ private finishPipeline(
321
+ command: ProkomCommand,
322
+ output: string,
323
+ exitCode: number,
324
+ ): void {
325
+ const status = exitCode === 0 ? 'success' : 'failure';
326
+ this.statusStore.updateTask(command.id, {
327
+ status,
328
+ output,
329
+ exitCode,
330
+ endTime: Date.now(),
331
+ });
332
+ this.eventBus.emit('task:complete', command.id, exitCode);
333
+ this.pluginManager?.executeHook('afterRun', command, {
334
+ exitCode,
335
+ status,
336
+ });
337
+ }
338
+
339
+ private runAndWait(
340
+ stepCmd: ProkomCommand,
341
+ ): Promise<{ exitCode: number | undefined }> {
342
+ return new Promise((resolve) => {
343
+ const onComplete = (id: string, exitCode: number | null) => {
344
+ if (id === stepCmd.id) {
345
+ cleanup();
346
+ resolve({ exitCode: exitCode ?? undefined });
347
+ }
348
+ };
349
+ const onError = (id: string) => {
350
+ if (id === stepCmd.id) {
351
+ cleanup();
352
+ resolve({ exitCode: -1 });
353
+ }
354
+ };
355
+ const cleanup = () => {
356
+ if (safetyTimer) clearTimeout(safetyTimer);
357
+ this.eventBus.off('task:complete', onComplete);
358
+ this.eventBus.off('task:error', onError);
359
+ };
360
+ const stepTimeout = stepCmd.timeout ?? 30_000;
361
+ const safetyTimer = stepTimeout > 0
362
+ ? setTimeout(() => {
363
+ cleanup();
364
+ resolve({ exitCode: -1 });
365
+ }, stepTimeout)
366
+ : null;
367
+ this.eventBus.on('task:complete', onComplete);
368
+ this.eventBus.on('task:error', onError);
369
+ this.run(stepCmd, true).catch(() => {
370
+ cleanup();
371
+ resolve({ exitCode: -1 });
372
+ });
373
+ });
374
+ }
375
+
376
+ private startWatcher(command: ProkomCommand): void {
377
+ const watcher = new FileWatcher();
378
+ watcher.watch(process.cwd(), () => {
379
+ this.run(command);
380
+ });
381
+ this.watchers.set(command.id, watcher);
382
+ this.statusStore.updateTask(command.id, { watchMode: true });
383
+ }
384
+
385
+ private killChild(child: ChildProcess): void {
386
+ if (child.pid) {
387
+ try {
388
+ process.kill(-child.pid, 'SIGTERM');
389
+ return;
390
+ } catch {
391
+ // fall through to child.kill()
392
+ }
393
+ }
394
+ try {
395
+ child.kill('SIGTERM');
396
+ } catch {
397
+ // already dead
398
+ }
399
+ }
400
+
401
+ private stopWatcher(id: string): void {
402
+ const watcher = this.watchers.get(id);
403
+ if (watcher) {
404
+ watcher.stop();
405
+ this.watchers.delete(id);
406
+ }
407
+ }
408
+ }
@@ -0,0 +1,34 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import type { Plugin } from '../plugins/types.js';
4
+
5
+ const startTimes = new Map<string, number>();
6
+ const TIMINGS_FILE = path.join(process.cwd(), '.developer-control-center', 'timings.jsonl');
7
+
8
+ export const timerPlugin: Plugin = {
9
+ id: 'timer',
10
+ name: 'Timer',
11
+ hooks: {
12
+ beforeRun: (command) => {
13
+ startTimes.set(command.id, Date.now());
14
+ },
15
+ afterRun: (command, result) => {
16
+ const start = startTimes.get(command.id);
17
+ if (!start) return;
18
+ startTimes.delete(command.id);
19
+ try {
20
+ fs.mkdirSync(path.dirname(TIMINGS_FILE), { recursive: true });
21
+ fs.appendFileSync(TIMINGS_FILE, JSON.stringify({
22
+ id: command.id,
23
+ label: command.label,
24
+ duration: Date.now() - start,
25
+ exitCode: result.exitCode,
26
+ status: result.status,
27
+ timestamp: new Date().toISOString(),
28
+ }) + '\n');
29
+ } catch {
30
+ // non-critical
31
+ }
32
+ },
33
+ },
34
+ };