@hartvig/developer-control-center 0.8.6 → 0.8.8

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 (103) hide show
  1. package/.developer-control-center/metrics.json +1 -1
  2. package/.developer-control-center/status.json +1 -1
  3. package/.developer-control-center/timings.jsonl +25 -0
  4. package/.github/workflows/ci.yml +1 -7
  5. package/coverage/Developer Control Center/dcc.config.js.html +628 -0
  6. package/coverage/Developer Control Center/index.html +116 -0
  7. package/coverage/Developer Control Center/src/config/index.html +116 -0
  8. package/coverage/Developer Control Center/src/config/loader.ts.html +454 -0
  9. package/coverage/Developer Control Center/src/core/ci.ts.html +163 -0
  10. package/coverage/Developer Control Center/src/core/event-bus.ts.html +187 -0
  11. package/coverage/Developer Control Center/src/core/index.html +191 -0
  12. package/coverage/Developer Control Center/src/core/notifier.ts.html +187 -0
  13. package/coverage/Developer Control Center/src/core/persistence.ts.html +88 -0
  14. package/coverage/Developer Control Center/src/core/task-runner.ts.html +1498 -0
  15. package/coverage/Developer Control Center/src/core/workspaces.ts.html +304 -0
  16. package/coverage/Developer Control Center/src/plugins/index.html +116 -0
  17. package/coverage/Developer Control Center/src/plugins/manager.ts.html +259 -0
  18. package/coverage/Developer Control Center/src/status/index.html +116 -0
  19. package/coverage/Developer Control Center/src/status/store.ts.html +349 -0
  20. package/coverage/Developer Control Center/src/ui/command-list.tsx.html +574 -0
  21. package/coverage/Developer Control Center/src/ui/index.html +161 -0
  22. package/coverage/Developer Control Center/src/ui/metrics-panel.tsx.html +787 -0
  23. package/coverage/Developer Control Center/src/ui/panel.tsx.html +313 -0
  24. package/coverage/Developer Control Center/src/ui/status-panel.tsx.html +565 -0
  25. package/coverage/base.css +224 -0
  26. package/coverage/block-navigation.js +87 -0
  27. package/coverage/clover.xml +588 -0
  28. package/coverage/coverage-final.json +15 -0
  29. package/coverage/favicon.png +0 -0
  30. package/coverage/index.html +191 -0
  31. package/coverage/prettify.css +1 -0
  32. package/coverage/prettify.js +2 -0
  33. package/coverage/sort-arrow-sprite.png +0 -0
  34. package/coverage/sorter.js +210 -0
  35. package/dcc.config.js +2 -2
  36. package/dist/cli.js +1 -1
  37. package/dist/core/persistence.d.ts +2 -0
  38. package/dist/core/persistence.d.ts.map +1 -0
  39. package/dist/core/persistence.js +2 -0
  40. package/dist/core/persistence.js.map +1 -0
  41. package/dist/core/runtime.d.ts.map +1 -1
  42. package/dist/core/runtime.js +5 -3
  43. package/dist/core/runtime.js.map +1 -1
  44. package/dist/core/task-runner.d.ts +1 -0
  45. package/dist/core/task-runner.d.ts.map +1 -1
  46. package/dist/core/task-runner.js +81 -24
  47. package/dist/core/task-runner.js.map +1 -1
  48. package/dist/core/task-runner.test.d.ts +2 -0
  49. package/dist/core/task-runner.test.d.ts.map +1 -0
  50. package/dist/core/task-runner.test.js +326 -0
  51. package/dist/core/task-runner.test.js.map +1 -0
  52. package/dist/core/timer-plugin.d.ts.map +1 -1
  53. package/dist/core/timer-plugin.js +2 -1
  54. package/dist/core/timer-plugin.js.map +1 -1
  55. package/dist/plugins/manager.d.ts +2 -0
  56. package/dist/plugins/manager.d.ts.map +1 -1
  57. package/dist/plugins/manager.js +6 -2
  58. package/dist/plugins/manager.js.map +1 -1
  59. package/dist/plugins/manager.test.js +5 -2
  60. package/dist/plugins/manager.test.js.map +1 -1
  61. package/dist/ui/app.d.ts.map +1 -1
  62. package/dist/ui/app.js +124 -30
  63. package/dist/ui/app.js.map +1 -1
  64. package/dist/ui/app.test.d.ts +2 -0
  65. package/dist/ui/app.test.d.ts.map +1 -0
  66. package/dist/ui/app.test.js +157 -0
  67. package/dist/ui/app.test.js.map +1 -0
  68. package/dist/ui/command-list.test.d.ts +2 -0
  69. package/dist/ui/command-list.test.d.ts.map +1 -0
  70. package/dist/ui/command-list.test.js +104 -0
  71. package/dist/ui/command-list.test.js.map +1 -0
  72. package/dist/ui/metrics-panel.d.ts.map +1 -1
  73. package/dist/ui/metrics-panel.js +10 -9
  74. package/dist/ui/metrics-panel.js.map +1 -1
  75. package/dist/ui/metrics-panel.test.d.ts +2 -0
  76. package/dist/ui/metrics-panel.test.d.ts.map +1 -0
  77. package/dist/ui/metrics-panel.test.js +111 -0
  78. package/dist/ui/metrics-panel.test.js.map +1 -0
  79. package/dist/ui/panel.test.d.ts +2 -0
  80. package/dist/ui/panel.test.d.ts.map +1 -0
  81. package/dist/ui/panel.test.js +51 -0
  82. package/dist/ui/panel.test.js.map +1 -0
  83. package/dist/ui/status-panel.test.d.ts +2 -0
  84. package/dist/ui/status-panel.test.d.ts.map +1 -0
  85. package/dist/ui/status-panel.test.js +88 -0
  86. package/dist/ui/status-panel.test.js.map +1 -0
  87. package/package.json +4 -2
  88. package/src/cli.ts +1 -1
  89. package/src/core/persistence.ts +1 -0
  90. package/src/core/runtime.ts +7 -3
  91. package/src/core/task-runner.test.ts +395 -0
  92. package/src/core/task-runner.ts +80 -24
  93. package/src/core/timer-plugin.ts +2 -1
  94. package/src/plugins/manager.test.ts +5 -2
  95. package/src/plugins/manager.ts +6 -2
  96. package/src/ui/app.test.tsx +177 -0
  97. package/src/ui/app.tsx +167 -41
  98. package/src/ui/command-list.test.tsx +124 -0
  99. package/src/ui/metrics-panel.test.tsx +128 -0
  100. package/src/ui/metrics-panel.tsx +10 -10
  101. package/src/ui/panel.test.tsx +84 -0
  102. package/src/ui/status-panel.test.tsx +116 -0
  103. package/vitest.config.ts +1 -1
@@ -1,4 +1,4 @@
1
- import { spawn, execSync, ChildProcess } from 'child_process';
1
+ import { spawn, exec, ChildProcess } from 'child_process';
2
2
  import fs from 'fs';
3
3
  import { ProkomCommand } from '../config/types.js';
4
4
  import { StatusStore } from '../status/store.js';
@@ -8,16 +8,19 @@ import { EventBus } from './event-bus.js';
8
8
  class FileWatcher {
9
9
  private watcher?: fs.FSWatcher;
10
10
  private debounceTimer?: ReturnType<typeof setTimeout>;
11
+ private exclude = /[/\\](node_modules|\.git)[/\\]/;
11
12
 
12
- watch(dir: string, onChange: () => void): void {
13
+ watch(dir: string, onChange: () => void): boolean {
13
14
  this.stop();
14
15
  try {
15
- this.watcher = fs.watch(dir, { recursive: true }, () => {
16
+ this.watcher = fs.watch(dir, { recursive: true }, (event, filename) => {
17
+ if (filename && this.exclude.test(filename.toString())) return;
16
18
  if (this.debounceTimer) clearTimeout(this.debounceTimer);
17
19
  this.debounceTimer = setTimeout(onChange, 500);
18
20
  });
21
+ return true;
19
22
  } catch {
20
- console.warn(`[dcc] watch mode: recursive file watching not supported on ${dir}`);
23
+ return false;
21
24
  }
22
25
  }
23
26
 
@@ -62,7 +65,8 @@ export class TaskRunner {
62
65
 
63
66
  const existing = this.statusStore.getTask(command.id);
64
67
  if (existing?.status === 'running') {
65
- return;
68
+ if (!command.parallel) return;
69
+ this.abort(command.id);
66
70
  }
67
71
 
68
72
  await this.pluginManager?.executeHook('beforeRun', command);
@@ -82,12 +86,26 @@ export class TaskRunner {
82
86
 
83
87
  this.eventBus.emit('task:start', command);
84
88
 
85
- const child = spawn(command.command, [], {
86
- shell: true,
87
- detached: true,
88
- stdio: ['ignore', 'pipe', 'pipe'],
89
- cwd: command.cwd,
90
- });
89
+ let child: ChildProcess;
90
+ try {
91
+ child = spawn(command.command, [], {
92
+ shell: true,
93
+ detached: true,
94
+ windowsHide: true,
95
+ stdio: ['ignore', 'pipe', 'pipe'],
96
+ cwd: command.cwd,
97
+ });
98
+ } catch (err) {
99
+ this.statusStore.updateTask(command.id, {
100
+ status: 'failure',
101
+ output: `Failed to spawn: ${err instanceof Error ? err.message : String(err)}\n`,
102
+ exitCode: -1,
103
+ endTime: Date.now(),
104
+ });
105
+ this.eventBus.emit('task:error', command.id, err instanceof Error ? err : new Error(String(err)));
106
+ this.pluginManager?.executeHook('onError', command.id, err instanceof Error ? err : new Error(String(err)));
107
+ return;
108
+ }
91
109
 
92
110
  this.running.set(command.id, child);
93
111
 
@@ -95,6 +113,16 @@ export class TaskRunner {
95
113
  this.startCheck(command);
96
114
  }
97
115
 
116
+ let timedOut = false;
117
+ const timeoutMs = command.timeout ?? 0;
118
+ let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
119
+ if (timeoutMs > 0) {
120
+ timeoutTimer = setTimeout(() => {
121
+ timedOut = true;
122
+ this.abort(command.id);
123
+ }, timeoutMs);
124
+ }
125
+
98
126
  let output = '';
99
127
 
100
128
  child.stdout?.on('data', (data: Buffer) => {
@@ -114,6 +142,7 @@ export class TaskRunner {
114
142
  });
115
143
 
116
144
  child.on('close', (exitCode) => {
145
+ if (timeoutTimer) clearTimeout(timeoutTimer);
117
146
  this.running.delete(command.id);
118
147
 
119
148
  if (command.toggle?.check) return;
@@ -169,6 +198,14 @@ export class TaskRunner {
169
198
  }
170
199
  this.stopWatcher(id);
171
200
  this.stopCheck(id);
201
+ const existing = this.statusStore.getTask(id);
202
+ if (existing?.status === 'running') {
203
+ this.statusStore.updateTask(id, {
204
+ status: 'failure',
205
+ endTime: Date.now(),
206
+ });
207
+ this.eventBus.emit('task:complete', id, -1);
208
+ }
172
209
  }
173
210
 
174
211
  abortAll(): void {
@@ -200,12 +237,27 @@ export class TaskRunner {
200
237
  });
201
238
  this.eventBus.emit('task:complete', command.id, 0);
202
239
  if (command.toggle?.stop) {
203
- spawn(command.toggle.stop, {
240
+ const stopCmd = process.platform === 'win32'
241
+ ? this.windowsStopCommand(command.toggle.stop)
242
+ : command.toggle.stop;
243
+ spawn(stopCmd, {
204
244
  shell: true,
205
245
  stdio: 'ignore',
206
246
  cwd: command.cwd,
207
- });
247
+ }).unref();
248
+ }
249
+ }
250
+
251
+ private windowsStopCommand(cmd: string): string {
252
+ const pkillMatch = cmd.match(/^pkill\s+-f\s+(.+)$/);
253
+ if (pkillMatch) {
254
+ return `taskkill /F /IM ${pkillMatch[1]}* 2>nul`;
208
255
  }
256
+ const killMatch = cmd.match(/^kill\s+-?\d*\s+(\d+)$/);
257
+ if (killMatch) {
258
+ return `taskkill /F /PID ${killMatch[1]} 2>nul`;
259
+ }
260
+ return cmd;
209
261
  }
210
262
 
211
263
  private async runPipeline(command: ProkomCommand): Promise<void> {
@@ -388,9 +440,13 @@ export class TaskRunner {
388
440
 
389
441
  private startWatcher(command: ProkomCommand): void {
390
442
  const watcher = new FileWatcher();
391
- watcher.watch(process.cwd(), () => {
443
+ const ok = watcher.watch(process.cwd(), () => {
392
444
  this.run(command);
393
445
  });
446
+ if (!ok) {
447
+ this.eventBus.emit('task:output', command.id, `watch mode: recursive file watching not supported\n`);
448
+ return;
449
+ }
394
450
  this.watchers.set(command.id, watcher);
395
451
  this.statusStore.updateTask(command.id, { watchMode: true });
396
452
  }
@@ -425,16 +481,16 @@ export class TaskRunner {
425
481
 
426
482
  const checkCmd = command.toggle!.check!;
427
483
  const interval = setInterval(() => {
428
- try {
429
- execSync(checkCmd, { encoding: 'utf-8', timeout: 3000, stdio: 'pipe' });
430
- } catch {
431
- this.statusStore.updateTask(id, {
432
- status: 'failure',
433
- endTime: Date.now(),
434
- });
435
- this.eventBus.emit('task:complete', id, -1);
436
- this.stopCheck(id);
437
- }
484
+ exec(checkCmd, { encoding: 'utf-8', timeout: 3000 }, (err) => {
485
+ if (err) {
486
+ this.statusStore.updateTask(id, {
487
+ status: 'failure',
488
+ endTime: Date.now(),
489
+ });
490
+ this.eventBus.emit('task:complete', id, -1);
491
+ this.stopCheck(id);
492
+ }
493
+ });
438
494
  }, this.CHECK_INTERVAL);
439
495
 
440
496
  this.checkIntervals.set(id, interval);
@@ -1,9 +1,10 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import type { Plugin } from '../plugins/types.js';
4
+ import { PERSISTENCE_DIR } from './persistence.js';
4
5
 
5
6
  const startTimes = new Map<string, number>();
6
- const TIMINGS_FILE = path.join(process.cwd(), '.developer-control-center', 'timings.jsonl');
7
+ const TIMINGS_FILE = path.join(process.cwd(), PERSISTENCE_DIR, 'timings.jsonl');
7
8
 
8
9
  export const timerPlugin: Plugin = {
9
10
  id: 'timer',
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeEach, vi } from 'vitest';
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
2
  import { PluginManager } from './manager.js';
3
3
  import type { Plugin } from './types.js';
4
4
 
@@ -68,7 +68,8 @@ describe('PluginManager', () => {
68
68
  });
69
69
 
70
70
  it('does not throw when a hook fails', async () => {
71
- vi.spyOn(console, 'error').mockImplementation(() => {});
71
+ const errors: string[] = [];
72
+ pm = new PluginManager((msg) => errors.push(msg));
72
73
  pm.register({
73
74
  id: 'bad',
74
75
  name: 'Bad',
@@ -77,6 +78,8 @@ describe('PluginManager', () => {
77
78
  await expect(
78
79
  pm.executeHook('beforeRun', { id: 'x', label: 'X', command: 'x' }),
79
80
  ).resolves.toBeUndefined();
81
+ expect(errors.length).toBe(1);
82
+ expect(errors[0]).toContain('bad');
80
83
  });
81
84
 
82
85
  it('skips plugin with no hooks for the requested hook', async () => {
@@ -5,6 +5,10 @@ type HookName = keyof PluginHooks;
5
5
  export class PluginManager {
6
6
  private plugins = new Map<string, Plugin>();
7
7
 
8
+ constructor(
9
+ private onError?: (msg: string, err: unknown) => void,
10
+ ) {}
11
+
8
12
  register(plugin: Plugin): void {
9
13
  this.plugins.set(plugin.id, plugin);
10
14
  }
@@ -33,7 +37,7 @@ export class PluginManager {
33
37
  try {
34
38
  await Promise.resolve(fn(...args));
35
39
  } catch (e) {
36
- console.error(`Plugin ${plugin.id} ${hook} error:`, e);
40
+ this.onError?.(`Plugin ${plugin.id} ${hook} error`, e);
37
41
  }
38
42
  }
39
43
  }
@@ -47,7 +51,7 @@ export class PluginManager {
47
51
  const p: Plugin = mod.default || mod;
48
52
  this.register(p);
49
53
  } catch (e) {
50
- console.error(`Failed to load plugin "${name}":`, e);
54
+ this.onError?.(`Failed to load plugin "${name}"`, e);
51
55
  }
52
56
  }
53
57
  }
@@ -0,0 +1,177 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import React, { act } from 'react';
3
+ import { render } from 'ink-testing-library';
4
+ import { App } from './app.js';
5
+ import { EventBus } from '../core/event-bus.js';
6
+ import { StatusStore } from '../status/store.js';
7
+
8
+ function createMockRuntime() {
9
+ const eventBus = new EventBus();
10
+ const statusStore = new StatusStore();
11
+ const taskRunner = {
12
+ run: vi.fn().mockResolvedValue(undefined),
13
+ stop: vi.fn(),
14
+ abortAll: vi.fn(),
15
+ setCommands: vi.fn(),
16
+ abort: vi.fn(),
17
+ };
18
+ return {
19
+ eventBus,
20
+ statusStore,
21
+ taskRunner,
22
+ pluginManager: { register: vi.fn(), executeHook: vi.fn(), loadFromConfig: vi.fn() },
23
+ gitBranch: 'main',
24
+ workspaces: [] as { name: string; path: string }[],
25
+ ci: { isCI: false, name: undefined as string | undefined },
26
+ stop: vi.fn(),
27
+ };
28
+ }
29
+
30
+ const baseConfig = {
31
+ name: 'test-project',
32
+ commands: [
33
+ { id: 'build', label: 'Build', command: 'npm run build', description: 'Build the project' },
34
+ { id: 'test', label: 'Test', command: 'npm test' },
35
+ ],
36
+ };
37
+
38
+ describe('App', () => {
39
+ let runtime: ReturnType<typeof createMockRuntime>;
40
+
41
+ beforeEach(() => {
42
+ runtime = createMockRuntime() as any;
43
+ });
44
+
45
+ afterEach(() => {
46
+ vi.restoreAllMocks();
47
+ });
48
+
49
+ it('renders the project name in the header', () => {
50
+ const { lastFrame } = render(<App config={baseConfig} runtime={runtime as any} />);
51
+ expect(lastFrame()).toContain('test-project');
52
+ });
53
+
54
+ it('shows the git branch', () => {
55
+ const { lastFrame } = render(<App config={baseConfig} runtime={runtime as any} />);
56
+ expect(lastFrame()).toContain('⎇ main');
57
+ });
58
+
59
+ it('renders command items', () => {
60
+ const { lastFrame } = render(<App config={baseConfig} runtime={runtime as any} />);
61
+ const frame = lastFrame();
62
+ expect(frame).toContain('Build');
63
+ expect(frame).toContain('Test');
64
+ });
65
+
66
+ it('shows no tasks yet in output panel', () => {
67
+ const { lastFrame } = render(<App config={baseConfig} runtime={runtime as any} />);
68
+ expect(lastFrame()).toContain('No tasks yet');
69
+ });
70
+
71
+ it('shows selected command description in footer', () => {
72
+ const { lastFrame } = render(<App config={baseConfig} runtime={runtime as any} />);
73
+ expect(lastFrame()).toContain('Build the project');
74
+ });
75
+
76
+ it('renders with groups', () => {
77
+ const config = {
78
+ name: 'test',
79
+ commands: [
80
+ { id: 'build', label: 'Build', command: 'npm run build', group: 'Build' },
81
+ { id: 'test', label: 'Test', command: 'npm test', group: 'Test' },
82
+ ],
83
+ };
84
+ const { lastFrame } = render(<App config={config} runtime={runtime as any} />);
85
+ expect(lastFrame()).toContain('▶ Build');
86
+ expect(lastFrame()).toContain('▶ Test');
87
+ });
88
+
89
+ it('shows active profile in header', () => {
90
+ const config = {
91
+ name: 'test',
92
+ commands: [{ id: 'build', label: 'Build', command: 'npm run build' }],
93
+ profiles: { prod: { commands: [] } },
94
+ profile: 'prod',
95
+ };
96
+ const { lastFrame } = render(<App config={config} runtime={runtime as any} />);
97
+ expect(lastFrame()).toContain('⚙ prod');
98
+ });
99
+
100
+ it('shows workspace count when present', () => {
101
+ runtime.workspaces = [{ name: 'pkg-a', path: '/pkg-a' }];
102
+ const { lastFrame } = render(<App config={baseConfig} runtime={runtime as any} />);
103
+ expect(lastFrame()).toContain('⊞ 1');
104
+ });
105
+
106
+ it('handles status store updates', () => {
107
+ const { lastFrame } = render(<App config={baseConfig} runtime={runtime as any} />);
108
+ act(() => {
109
+ runtime.statusStore.updateTask('build', {
110
+ id: 'build',
111
+ label: 'Build',
112
+ status: 'running',
113
+ output: 'compiling...',
114
+ });
115
+ });
116
+ const frame = lastFrame();
117
+ expect(frame).toContain('compiling...');
118
+ });
119
+
120
+ it('shows confirm prompt for confirm commands', () => {
121
+ const config = {
122
+ name: 'test',
123
+ commands: [
124
+ { id: 'deploy', label: 'Deploy', command: 'npm run deploy', confirm: true },
125
+ ],
126
+ };
127
+ const { lastFrame, stdin } = render(<App config={config} runtime={runtime as any} />);
128
+ act(() => { stdin.write('\r'); });
129
+ const frame = lastFrame();
130
+ expect(frame).toContain('⚠');
131
+ });
132
+
133
+ it('shows DCC in header', () => {
134
+ const { lastFrame } = render(<App config={baseConfig} runtime={runtime as any} />);
135
+ expect(lastFrame()).toContain('DCC');
136
+ });
137
+
138
+ it('shows status pane (middle panel)', () => {
139
+ const { lastFrame } = render(<App config={baseConfig} runtime={runtime as any} />);
140
+ expect(lastFrame()).toContain('Status');
141
+ });
142
+
143
+ it('shows output pane (right panel)', () => {
144
+ const { lastFrame } = render(<App config={baseConfig} runtime={runtime as any} />);
145
+ expect(lastFrame()).toContain('Output');
146
+ });
147
+
148
+ it('shows CI badge when in CI environment', () => {
149
+ runtime.ci = { isCI: true, name: 'GitHub Actions' };
150
+ const { lastFrame } = render(<App config={baseConfig} runtime={runtime as any} />);
151
+ expect(lastFrame()).toContain('⊡ GitHub Actions');
152
+ });
153
+
154
+ it('switches pane focus on Tab', () => {
155
+ const { lastFrame, stdin } = render(<App config={baseConfig} runtime={runtime as any} />);
156
+ act(() => { stdin.write('\t'); });
157
+ const frame = lastFrame();
158
+ expect(frame).toContain('Output');
159
+ });
160
+
161
+ it('enters search mode on / key', () => {
162
+ const { lastFrame, stdin } = render(<App config={baseConfig} runtime={runtime as any} />);
163
+ act(() => { stdin.write('/'); });
164
+ expect(lastFrame()).toContain('🔍');
165
+ });
166
+
167
+ it('shows default footer hint for command without description', () => {
168
+ const config = {
169
+ name: 'test',
170
+ commands: [
171
+ { id: 'foo', label: 'Foo', command: 'echo foo' },
172
+ ],
173
+ };
174
+ const { lastFrame } = render(<App config={config} runtime={runtime as any} />);
175
+ expect(lastFrame()).toContain('Enter to run');
176
+ });
177
+ });