dev-cockpit 0.1.0 → 0.2.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 (153) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +86 -29
  3. package/bin/dev-cockpit.mjs +26 -4
  4. package/dist/actions/builtin.d.ts +25 -0
  5. package/dist/actions/builtin.d.ts.map +1 -0
  6. package/dist/actions/dispatch.d.ts +21 -0
  7. package/dist/actions/dispatch.d.ts.map +1 -0
  8. package/dist/actions/registry.d.ts +11 -0
  9. package/dist/actions/registry.d.ts.map +1 -0
  10. package/dist/actions/types.d.ts +76 -0
  11. package/dist/actions/types.d.ts.map +1 -0
  12. package/dist/buildCli.d.ts.map +1 -1
  13. package/dist/chunk-6XGHLLYT.js +46 -0
  14. package/dist/chunk-6XGHLLYT.js.map +7 -0
  15. package/dist/chunk-C4GFJDMG.js +79 -0
  16. package/dist/chunk-C4GFJDMG.js.map +7 -0
  17. package/dist/chunk-Q6677JQF.js +32609 -0
  18. package/dist/chunk-Q6677JQF.js.map +7 -0
  19. package/dist/chunk-VN6UILQW.js +1460 -0
  20. package/dist/chunk-VN6UILQW.js.map +7 -0
  21. package/dist/cockpit/Cockpit.d.ts +6 -0
  22. package/dist/cockpit/Cockpit.d.ts.map +1 -1
  23. package/dist/cockpit/Footer.d.ts +6 -4
  24. package/dist/cockpit/Footer.d.ts.map +1 -1
  25. package/dist/cockpit/TabBar.d.ts.map +1 -1
  26. package/dist/cockpit/hooks/useGlobalKeys.d.ts +15 -15
  27. package/dist/cockpit/hooks/useGlobalKeys.d.ts.map +1 -1
  28. package/dist/cockpit/hooks/useTerminalWidth.d.ts +12 -0
  29. package/dist/cockpit/hooks/useTerminalWidth.d.ts.map +1 -0
  30. package/dist/cockpit/panes/CommandModal.d.ts +18 -0
  31. package/dist/cockpit/panes/CommandModal.d.ts.map +1 -0
  32. package/dist/cockpit/panes/Help.d.ts.map +1 -1
  33. package/dist/cockpit/panes/Output.d.ts +7 -0
  34. package/dist/cockpit/panes/Output.d.ts.map +1 -1
  35. package/dist/cockpit/panes/Repos.d.ts.map +1 -1
  36. package/dist/cockpit/state/store.d.ts +14 -11
  37. package/dist/cockpit/state/store.d.ts.map +1 -1
  38. package/dist/cockpit/tab-state.d.ts +12 -0
  39. package/dist/cockpit/tab-state.d.ts.map +1 -1
  40. package/dist/commands/dev.d.ts.map +1 -1
  41. package/dist/commands/doctor.d.ts.map +1 -1
  42. package/dist/commands/init-config-wizard.d.ts +103 -2
  43. package/dist/commands/init-config-wizard.d.ts.map +1 -1
  44. package/dist/commands/init-config.d.ts +2 -0
  45. package/dist/commands/init-config.d.ts.map +1 -1
  46. package/dist/commands/link.d.ts +20 -0
  47. package/dist/commands/link.d.ts.map +1 -0
  48. package/dist/commands/migrate-config.d.ts +18 -0
  49. package/dist/commands/migrate-config.d.ts.map +1 -0
  50. package/dist/commands/mount.d.ts +17 -32
  51. package/dist/commands/mount.d.ts.map +1 -1
  52. package/dist/core/config-discovery.d.ts +39 -0
  53. package/dist/core/config-discovery.d.ts.map +1 -0
  54. package/dist/core/config.d.ts +73 -5
  55. package/dist/core/config.d.ts.map +1 -1
  56. package/dist/core/manifest.d.ts +47 -0
  57. package/dist/core/manifest.d.ts.map +1 -0
  58. package/dist/core/migrations.d.ts +33 -0
  59. package/dist/core/migrations.d.ts.map +1 -0
  60. package/dist/core/subprocess.d.ts +20 -0
  61. package/dist/core/subprocess.d.ts.map +1 -1
  62. package/dist/core/types.d.ts +36 -12
  63. package/dist/core/types.d.ts.map +1 -1
  64. package/dist/devtools-YXMW6JJ6.js +3720 -0
  65. package/dist/devtools-YXMW6JJ6.js.map +7 -0
  66. package/dist/docker/highlights.d.ts +14 -4
  67. package/dist/docker/highlights.d.ts.map +1 -1
  68. package/dist/docker/logs.d.ts +3 -2
  69. package/dist/docker/logs.d.ts.map +1 -1
  70. package/dist/health/builtin.d.ts.map +1 -1
  71. package/dist/index.d.ts +14 -3
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +92944 -53
  74. package/dist/index.js.map +7 -0
  75. package/dist/ink.js +38 -1
  76. package/dist/ink.js.map +7 -0
  77. package/dist/link-HXNII7EU.js +65 -0
  78. package/dist/link-HXNII7EU.js.map +7 -0
  79. package/dist/mount/compose.d.ts +21 -0
  80. package/dist/mount/compose.d.ts.map +1 -0
  81. package/dist/mount/discovery.d.ts +35 -0
  82. package/dist/mount/discovery.d.ts.map +1 -0
  83. package/dist/mount/git-status.d.ts +12 -0
  84. package/dist/mount/git-status.d.ts.map +1 -0
  85. package/dist/mount/manifest.d.ts +16 -0
  86. package/dist/mount/manifest.d.ts.map +1 -0
  87. package/dist/mount/symlinks.d.ts +30 -0
  88. package/dist/mount/symlinks.d.ts.map +1 -0
  89. package/dist/mount/types.d.ts +60 -0
  90. package/dist/mount/types.d.ts.map +1 -0
  91. package/dist/react.js +35 -1
  92. package/dist/react.js.map +7 -0
  93. package/dist/runCockpit.d.ts +3 -0
  94. package/dist/runCockpit.d.ts.map +1 -1
  95. package/docs/commands.md +29 -16
  96. package/docs/config-reference.md +115 -11
  97. package/docs/getting-started.md +9 -6
  98. package/docs/index.md +5 -1
  99. package/docs/init-config.md +34 -8
  100. package/docs/mount.md +198 -25
  101. package/docs/notifications.md +14 -13
  102. package/docs/panes.md +36 -15
  103. package/docs/processes.md +42 -0
  104. package/package.json +93 -90
  105. package/dist/buildCli.js +0 -107
  106. package/dist/cli.js +0 -2
  107. package/dist/cockpit/Cockpit.js +0 -73
  108. package/dist/cockpit/Footer.js +0 -33
  109. package/dist/cockpit/TabBar.js +0 -12
  110. package/dist/cockpit/help/content.js +0 -22
  111. package/dist/cockpit/help/loader.js +0 -118
  112. package/dist/cockpit/help/renderer.js +0 -35
  113. package/dist/cockpit/help/types.js +0 -1
  114. package/dist/cockpit/hooks/useCockpitStore.js +0 -5
  115. package/dist/cockpit/hooks/useGlobalKeys.js +0 -173
  116. package/dist/cockpit/panes/FilterModal.js +0 -22
  117. package/dist/cockpit/panes/Health.js +0 -30
  118. package/dist/cockpit/panes/Help.js +0 -81
  119. package/dist/cockpit/panes/Output.js +0 -108
  120. package/dist/cockpit/panes/Repos.js +0 -48
  121. package/dist/cockpit/panes/SearchModal.js +0 -31
  122. package/dist/cockpit/state/store.js +0 -111
  123. package/dist/cockpit/tab-state.js +0 -7
  124. package/dist/commands/dev.js +0 -158
  125. package/dist/commands/doctor.js +0 -66
  126. package/dist/commands/init-config-wizard.js +0 -818
  127. package/dist/commands/init-config.js +0 -131
  128. package/dist/commands/mount.js +0 -150
  129. package/dist/core/config.js +0 -152
  130. package/dist/core/logger.js +0 -38
  131. package/dist/core/notifier.js +0 -100
  132. package/dist/core/paths.js +0 -18
  133. package/dist/core/subprocess.js +0 -82
  134. package/dist/core/types.js +0 -1
  135. package/dist/docker/highlights.js +0 -79
  136. package/dist/docker/logs.js +0 -172
  137. package/dist/docker/restart.js +0 -45
  138. package/dist/docker/stack-trace.js +0 -44
  139. package/dist/health/builtin.js +0 -144
  140. package/dist/health/context.js +0 -31
  141. package/dist/health/notify-resolver.js +0 -28
  142. package/dist/health/registry.js +0 -64
  143. package/dist/health/remediations.js +0 -41
  144. package/dist/health/runner.js +0 -22
  145. package/dist/health/scheduler.js +0 -107
  146. package/dist/health/types.js +0 -1
  147. package/dist/health/useHealth.js +0 -122
  148. package/dist/lint/reactive.js +0 -131
  149. package/dist/runCockpit.js +0 -75
  150. package/dist/watchers/manager.js +0 -239
  151. package/dist/watchers/path-mapper.js +0 -29
  152. package/dist/watchers/types.js +0 -9
  153. package/docs/watchers.md +0 -27
@@ -1,107 +0,0 @@
1
- import path from 'node:path';
2
- import { runChecks } from './runner.js';
3
- const DEFAULT_FSEVENT_DEBOUNCE_MS = 500;
4
- const DEFAULT_LOCKFILE_NAMES = ['composer.lock', 'package-lock.json', 'yarn.lock'];
5
- const DEFAULT_DOCKER_POLL_MS = 5000;
6
- export class HealthScheduler {
7
- deps;
8
- debounceMs;
9
- lockfileNames;
10
- dockerPollMs;
11
- fsDebounceTimer = null;
12
- dockerPollTimer = null;
13
- running = false;
14
- constructor(deps) {
15
- this.deps = deps;
16
- this.debounceMs = deps.fsEventDebounceMs ?? DEFAULT_FSEVENT_DEBOUNCE_MS;
17
- this.lockfileNames = new Set(deps.lockfileNames ?? DEFAULT_LOCKFILE_NAMES);
18
- this.dockerPollMs = deps.dockerPollIntervalMs ?? DEFAULT_DOCKER_POLL_MS;
19
- }
20
- async start() {
21
- if (this.running)
22
- return;
23
- this.running = true;
24
- await this.runTrigger(['startup']);
25
- // Start the docker poll only if some check actually subscribes to it.
26
- // Otherwise we'd waste a setInterval on a no-op. Disable entirely with
27
- // dockerPollIntervalMs: 0.
28
- const hasDockerChecks = this.deps.checks.some((c) => c.triggers.includes('docker'));
29
- if (hasDockerChecks && this.dockerPollMs > 0) {
30
- this.dockerPollTimer = setInterval(() => {
31
- if (!this.running)
32
- return;
33
- void this.runTrigger(['docker']);
34
- }, this.dockerPollMs);
35
- }
36
- }
37
- onFsEvent(filePath) {
38
- if (!this.running)
39
- return;
40
- const fileName = path.basename(filePath);
41
- const isLockfile = this.lockfileNames.has(fileName);
42
- const triggers = ['fsevent'];
43
- if (isLockfile) {
44
- triggers.push('lockfile');
45
- }
46
- this.debounceRun(triggers);
47
- }
48
- onDockerEvent() {
49
- if (!this.running)
50
- return;
51
- this.debounceRun(['docker']);
52
- }
53
- /**
54
- * Force every registered check to re-run, regardless of triggers. Used
55
- * after a remediation completes so the user gets immediate visual
56
- * confirmation without waiting for an external fsevent.
57
- */
58
- async runAll() {
59
- if (!this.running)
60
- return;
61
- const { checks, ctx, onHealthUpdate } = this.deps;
62
- const results = await Promise.all(checks.map(async (check) => {
63
- try {
64
- return await check.predicate(ctx);
65
- }
66
- catch (err) {
67
- return {
68
- id: check.id,
69
- label: check.label,
70
- severity: 'warn',
71
- detail: `check failed with error: ${String(err)}`,
72
- remediationKey: check.remediation.key,
73
- };
74
- }
75
- }));
76
- if (results.length > 0) {
77
- onHealthUpdate(results);
78
- }
79
- }
80
- stop() {
81
- this.running = false;
82
- if (this.fsDebounceTimer) {
83
- clearTimeout(this.fsDebounceTimer);
84
- this.fsDebounceTimer = null;
85
- }
86
- if (this.dockerPollTimer) {
87
- clearInterval(this.dockerPollTimer);
88
- this.dockerPollTimer = null;
89
- }
90
- }
91
- debounceRun(triggers) {
92
- if (this.fsDebounceTimer) {
93
- clearTimeout(this.fsDebounceTimer);
94
- }
95
- this.fsDebounceTimer = setTimeout(() => {
96
- this.fsDebounceTimer = null;
97
- void this.runTrigger(triggers);
98
- }, this.debounceMs);
99
- }
100
- async runTrigger(triggers) {
101
- const { checks, ctx, onHealthUpdate } = this.deps;
102
- const results = await runChecks(checks, triggers, ctx);
103
- if (results.length > 0) {
104
- onHealthUpdate(results);
105
- }
106
- }
107
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,122 +0,0 @@
1
- import { useEffect, useMemo, useRef } from 'react';
2
- import { cockpitStore } from '../cockpit/state/store.js';
3
- import { detectTransitions, notify } from '../core/notifier.js';
4
- import { HealthScheduler } from './scheduler.js';
5
- import { runRemediation as dispatchRemediationKey } from './remediations.js';
6
- import { resolveNotify } from './notify-resolver.js';
7
- import { buildHealthRegistry } from './registry.js';
8
- import { buildHealthContext } from './context.js';
9
- export function useHealth(opts) {
10
- const checks = useMemo(() => {
11
- if (!opts)
12
- return [];
13
- return buildHealthRegistry({
14
- profileChecks: opts.profileChecks,
15
- configEntries: opts.configEntries,
16
- });
17
- }, [opts]);
18
- const checksRef = useRef(checks);
19
- const ctxRef = useRef(opts?.ctx);
20
- const optsRef = useRef(opts);
21
- const schedulerRef = useRef(null);
22
- // Keep refs current so the orchestration loop sees latest options without
23
- // requiring full teardown on prop churn.
24
- useEffect(() => {
25
- checksRef.current = checks;
26
- ctxRef.current = opts?.ctx;
27
- optsRef.current = opts;
28
- }, [checks, opts]);
29
- useEffect(() => {
30
- if (!opts)
31
- return;
32
- if (checks.length === 0)
33
- return;
34
- const scheduler = new HealthScheduler({
35
- checks,
36
- ctx: opts.ctx ?? buildDefaultCtx(opts.workspaceRoot),
37
- onHealthUpdate: (statuses) => {
38
- // Trigger-scoped runs (fsevent, lockfile, docker) produce only the
39
- // statuses for checks subscribed to that trigger. If we replaced the
40
- // full array with the partial set, every other check's last-known
41
- // state would be wiped on each poll. Merge by id, replacing any
42
- // existing entry with the new value while preserving the rest.
43
- const prev = cockpitStore.getState().health;
44
- const merged = mergeHealthStatuses(prev, statuses);
45
- const transitions = detectTransitions(prev, statuses);
46
- const sessionEnabled = cockpitStore.getState().notificationsEnabledSession;
47
- for (const t of transitions) {
48
- const owner = checksRef.current.find((c) => c.id === t.id);
49
- if (!resolveNotify(t, opts.notifications, owner?.notify))
50
- continue;
51
- notify(t, {
52
- config: opts.notifications,
53
- sessionEnabled,
54
- appName: opts.appName ?? 'cockpit',
55
- sender: opts.notificationSender,
56
- });
57
- }
58
- cockpitStore.getState().setHealth(merged);
59
- opts.onResults?.(merged);
60
- },
61
- });
62
- schedulerRef.current = scheduler;
63
- void scheduler.start();
64
- const unsub = opts.subscribeFsEvents?.((filePath) => scheduler.onFsEvent(filePath));
65
- return () => {
66
- unsub?.();
67
- scheduler.stop();
68
- schedulerRef.current = null;
69
- };
70
- }, [checks]);
71
- if (!opts)
72
- return null;
73
- return {
74
- runRemediation: (key) => {
75
- const list = checksRef.current;
76
- const ctx = ctxRef.current ?? buildDefaultCtx(optsRef.current?.workspaceRoot ?? '.');
77
- const check = list.find((c) => c.remediation.key === key);
78
- if (!check)
79
- return null;
80
- const promise = dispatchRemediationKey(key, list, ctx, optsRef.current?.workspaceRoot ?? '.').finally(() => {
81
- // Re-run all checks after the remediation completes so the user sees
82
- // the new state immediately, without waiting for an external fsevent.
83
- void schedulerRef.current?.runAll();
84
- });
85
- return {
86
- label: check.remediation.label,
87
- promise,
88
- healthId: check.id,
89
- healthLabel: check.label,
90
- };
91
- },
92
- };
93
- }
94
- function buildDefaultCtx(workspaceRoot) {
95
- return buildHealthContext(workspaceRoot);
96
- }
97
- /**
98
- * Merge a partial set of fresh statuses into the previous full array,
99
- * preserving original order and updating any entry whose id matches.
100
- * Statuses present in `next` but not in `prev` are appended at the end —
101
- * shouldn't happen during normal operation (the registry is fixed) but
102
- * keeps the function total.
103
- */
104
- function mergeHealthStatuses(prev, next) {
105
- if (next.length === 0)
106
- return prev;
107
- const byId = new Map();
108
- for (const s of next)
109
- byId.set(s.id, s);
110
- const merged = [];
111
- const seen = new Set();
112
- for (const s of prev) {
113
- const updated = byId.get(s.id);
114
- merged.push(updated ?? s);
115
- seen.add(s.id);
116
- }
117
- for (const s of next) {
118
- if (!seen.has(s.id))
119
- merged.push(s);
120
- }
121
- return merged;
122
- }
@@ -1,131 +0,0 @@
1
- /**
2
- * Reactive lint engine.
3
- *
4
- * Pure command-selection function: given a file extension and a repo's lint
5
- * config, returns the lint command (or undefined if none configured).
6
- *
7
- * The debounced runner lives here too, backed by spawnStream. Debounce is
8
- * per-file (keyed by absolute path), 300ms.
9
- *
10
- * Lint result is reported via `onLintResult` — this module never mutates
11
- * watcher status. Watcher lifecycle (idle/running/failing) is exclusively
12
- * owned by WatcherManager.
13
- */
14
- import path from 'node:path';
15
- import { spawnStream } from '../core/subprocess.js';
16
- const EXT_TO_LINT_KEY = {
17
- '.js': 'js',
18
- '.jsx': 'js',
19
- '.ts': 'js',
20
- '.tsx': 'js',
21
- '.mjs': 'js',
22
- '.cjs': 'js',
23
- '.css': 'css',
24
- '.scss': 'css',
25
- '.sass': 'css',
26
- '.less': 'css',
27
- '.php': 'php',
28
- };
29
- /** Returns the lint command for a file, or undefined if none configured. Pure. */
30
- export function selectLintCommand(filePath, lintConfig) {
31
- const ext = path.extname(filePath).toLowerCase();
32
- const key = EXT_TO_LINT_KEY[ext];
33
- if (!key)
34
- return undefined;
35
- return lintConfig[key];
36
- }
37
- const debounceTimers = new Map();
38
- const DEBOUNCE_MS = 300;
39
- /**
40
- * Schedule a lint run for `filePath`, debounced 300ms per file. Files with
41
- * no matching lint command are silently skipped.
42
- */
43
- export function scheduleLint(filePath, opts) {
44
- const cmd = selectLintCommand(filePath, opts.lintConfig);
45
- if (!cmd)
46
- return;
47
- const existing = debounceTimers.get(filePath);
48
- if (existing !== undefined)
49
- clearTimeout(existing);
50
- const timer = setTimeout(() => {
51
- debounceTimers.delete(filePath);
52
- void runLintCmd(filePath, cmd, opts);
53
- }, DEBOUNCE_MS);
54
- debounceTimers.set(filePath, timer);
55
- }
56
- /**
57
- * Run all configured lint commands for a repo (js, css, php) in parallel.
58
- * Aggregates pass/fail — any failing kind → overall 'fail', all pass → 'pass'.
59
- */
60
- export async function runAllLints(opts) {
61
- const { repoName, repoRootDir, lintConfig, appendOutput, onLintResult } = opts;
62
- const spawnFn = opts.spawn ?? spawnStream;
63
- const kinds = ['js', 'css', 'php'];
64
- const toRun = [];
65
- for (const kind of kinds) {
66
- const cmd = lintConfig[kind];
67
- if (cmd)
68
- toRun.push({ kind, cmd });
69
- }
70
- if (toRun.length === 0) {
71
- appendOutput({
72
- ts: Date.now(),
73
- source: repoName,
74
- severity: 'info',
75
- text: 'lint: no linters configured for this repo',
76
- });
77
- return;
78
- }
79
- const results = await Promise.all(toRun.map(async ({ kind, cmd }) => {
80
- const [exe, ...args] = cmd.split(/\s+/);
81
- if (!exe)
82
- return { kind, passed: false };
83
- const handle = spawnFn(exe, args, {
84
- cwd: repoRootDir,
85
- onStdout: (line) => appendOutput({ ts: Date.now(), source: repoName, severity: 'info', text: line }),
86
- onStderr: (line) => appendOutput({ ts: Date.now(), source: repoName, severity: 'warn', text: line }),
87
- });
88
- const code = await handle.exitCode;
89
- const passed = code === 0;
90
- appendOutput({
91
- ts: Date.now(),
92
- source: repoName,
93
- severity: passed ? 'info' : 'error',
94
- text: `lint [${kind}]: ${passed ? 'passed' : `FAILED (exit ${code})`}`,
95
- });
96
- return { kind, passed };
97
- }));
98
- const allPassed = results.every((r) => r.passed);
99
- onLintResult(allPassed ? 'pass' : 'fail');
100
- }
101
- async function runLintCmd(filePath, cmd, opts) {
102
- const { repoName, repoRootDir, appendOutput, onLintResult } = opts;
103
- const spawnFn = opts.spawn ?? spawnStream;
104
- const [exe, ...args] = cmd.split(/\s+/);
105
- if (!exe)
106
- return;
107
- const handle = spawnFn(exe, args, {
108
- cwd: repoRootDir,
109
- onStdout: (line) => appendOutput({ ts: Date.now(), source: repoName, severity: 'info', text: line }),
110
- onStderr: (line) => appendOutput({ ts: Date.now(), source: repoName, severity: 'warn', text: line }),
111
- });
112
- const code = await handle.exitCode;
113
- if (code === 0) {
114
- appendOutput({
115
- ts: Date.now(),
116
- source: repoName,
117
- severity: 'info',
118
- text: `lint passed for ${path.basename(filePath)}`,
119
- });
120
- onLintResult('pass');
121
- }
122
- else {
123
- appendOutput({
124
- ts: Date.now(),
125
- source: repoName,
126
- severity: 'error',
127
- text: `lint FAILED for ${path.basename(filePath)} (exit ${code})`,
128
- });
129
- onLintResult('fail');
130
- }
131
- }
@@ -1,75 +0,0 @@
1
- /**
2
- * runCockpit — boot the cockpit TUI.
3
- *
4
- * Responsibilities (Phase 3, shell-only):
5
- * - Switch the terminal into the alternate-screen buffer (so the cockpit fills
6
- * the window without scrolling shell history out of view; restored on exit
7
- * or uncaughtException).
8
- * - Seed `cockpitStore.helpConfig` from `profile.helpSources` + `profile.defaultHelpPage`.
9
- * - Render `<Cockpit>` with the caller-supplied handlers via `ink.render`.
10
- * - Return `waitUntilExit` and `restore` for the caller to await + clean up.
11
- *
12
- * Domain-specific boot (loading config, building watchers, scheduling health
13
- * checks, docker tailers) lives in the consumer's `dev` command; this entry
14
- * stays narrow and reusable.
15
- */
16
- import React from 'react';
17
- import { render } from 'ink';
18
- import { Cockpit } from './cockpit/Cockpit.js';
19
- import { cockpitStore } from './cockpit/state/store.js';
20
- const ENTER_ALT_SCREEN = '\x1b[?1049h\x1b[H';
21
- const EXIT_ALT_SCREEN = '\x1b[?1049l';
22
- export function runCockpit(opts = {}) {
23
- const { profile, noAltScreen, ...cockpitProps } = opts;
24
- // Seed helpConfig from profile.
25
- if (profile) {
26
- const sources = (profile.helpSources ?? []).map((src) => ({
27
- page: src.path == null && src.content != null
28
- ? {
29
- slug: src.id,
30
- title: src.title ?? src.id,
31
- path: '',
32
- body: src.content,
33
- }
34
- : undefined,
35
- path: src.path,
36
- id: src.id,
37
- omit: src.omit,
38
- }));
39
- cockpitStore.getState().setHelpConfig({
40
- sources,
41
- defaultPage: profile.defaultHelpPage,
42
- });
43
- }
44
- // Alternate-screen buffer.
45
- let restored = false;
46
- const restore = () => {
47
- if (restored)
48
- return;
49
- restored = true;
50
- if (!noAltScreen) {
51
- process.stdout.write(EXIT_ALT_SCREEN);
52
- }
53
- };
54
- if (!noAltScreen) {
55
- process.stdout.write(ENTER_ALT_SCREEN);
56
- process.once('exit', restore);
57
- process.once('uncaughtException', (err) => {
58
- restore();
59
- throw err;
60
- });
61
- }
62
- const ink = render(React.createElement(Cockpit, cockpitProps), { exitOnCtrlC: true });
63
- return {
64
- waitUntilExit: async () => {
65
- try {
66
- await ink.waitUntilExit();
67
- }
68
- finally {
69
- restore();
70
- }
71
- },
72
- restore,
73
- ink,
74
- };
75
- }
@@ -1,239 +0,0 @@
1
- /**
2
- * WatcherManager — orchestrates chokidar filesystem watchers and per-repo
3
- * watcher subprocesses.
4
- *
5
- * Responsibilities:
6
- * - Subscribe to chokidar for the union of all repo discover globs.
7
- * - On file change in an idle repo → spawn the repo's watch command.
8
- * - On watcher exit (code 0) → mark repo idle; non-zero → failing.
9
- * - Trigger reactive lint via scheduleLint on each change.
10
- *
11
- * Public API: start(), stop(), toggle(repoName), rebuild(repoName), lint(repoName).
12
- *
13
- * All subprocess work goes through `core/subprocess.spawnStream` (DRY).
14
- */
15
- import fs from 'node:fs';
16
- import path from 'node:path';
17
- import chokidar from 'chokidar';
18
- import { spawnStream } from '../core/subprocess.js';
19
- import { mapPathToRepo } from './path-mapper.js';
20
- import { scheduleLint, runAllLints } from '../lint/reactive.js';
21
- /**
22
- * Detect WSL2 by reading /proc/version (Linux-only).
23
- * Native Linux + macOS don't need polling. Set DEV_COCKPIT_CHOKIDAR_POLLING=1
24
- * to force polling as an escape hatch.
25
- */
26
- function detectUsePolling() {
27
- if (process.env['DEV_COCKPIT_CHOKIDAR_POLLING'] === '1')
28
- return true;
29
- try {
30
- const version = fs.readFileSync('/proc/version', 'utf8');
31
- return /microsoft|wsl/i.test(version);
32
- }
33
- catch {
34
- return false;
35
- }
36
- }
37
- export class WatcherManager {
38
- repos = new Map();
39
- prefixEntries = [];
40
- handles = new Map();
41
- fsWatcher = null;
42
- deps;
43
- buildOutcomes = new Map();
44
- fsEventListeners = new Set();
45
- constructor(repos, deps) {
46
- this.deps = {
47
- appendOutput: deps.appendOutput,
48
- setRepoStatus: deps.setRepoStatus,
49
- setLintStatus: deps.setLintStatus,
50
- spawn: deps.spawn ?? spawnStream,
51
- onBuildOutcome: deps.onBuildOutcome ?? null,
52
- };
53
- for (const repo of repos) {
54
- this.repos.set(repo.config.name, repo);
55
- this.prefixEntries.push({ name: repo.config.name, rootDir: repo.rootDir });
56
- }
57
- }
58
- start() {
59
- if (this.fsWatcher)
60
- return;
61
- const globs = [];
62
- for (const [, managed] of this.repos) {
63
- for (const pattern of managed.config.discover) {
64
- globs.push(path.join(managed.rootDir, pattern));
65
- }
66
- }
67
- if (globs.length === 0)
68
- return;
69
- const usePolling = detectUsePolling();
70
- this.fsWatcher = chokidar.watch(globs, {
71
- usePolling,
72
- ignoreInitial: true,
73
- ignored: /(^|[/\\])\../,
74
- });
75
- this.fsWatcher.on('change', (filePath) => this.handleChange(filePath));
76
- this.fsWatcher.on('add', (filePath) => this.handleChange(filePath));
77
- this.fsWatcher.on('error', (err) => {
78
- this.deps.appendOutput({
79
- ts: Date.now(),
80
- source: 'watcher',
81
- severity: 'error',
82
- text: `chokidar error: ${String(err)}`,
83
- });
84
- });
85
- }
86
- async stop() {
87
- // Snapshot exitCode promises before killing so we can await reap after
88
- // SIGTERM is delivered. Without awaiting, Node's event loop keeps the
89
- // ChildProcess + stdio socket handles alive, which blocks process exit
90
- // on the consumer side.
91
- const reaps = [];
92
- for (const [name, handle] of this.handles) {
93
- reaps.push(handle.exitCode.catch(() => undefined));
94
- this.killHandle(name);
95
- }
96
- await Promise.all(reaps);
97
- if (this.fsWatcher) {
98
- await this.fsWatcher.close();
99
- this.fsWatcher = null;
100
- }
101
- }
102
- toggle(repoName) {
103
- const handle = this.handles.get(repoName);
104
- if (handle) {
105
- this.killHandle(repoName);
106
- this.deps.setRepoStatus(repoName, 'idle');
107
- }
108
- else {
109
- void this.spawnWatcher(repoName);
110
- }
111
- }
112
- rebuild(repoName) {
113
- this.killHandle(repoName);
114
- void this.spawnWatcher(repoName);
115
- }
116
- lint(repoName) {
117
- const managed = this.repos.get(repoName);
118
- if (!managed)
119
- return;
120
- void runAllLints({
121
- repoName,
122
- repoRootDir: managed.rootDir,
123
- lintConfig: managed.config.lint,
124
- appendOutput: this.deps.appendOutput,
125
- onLintResult: (result) => this.deps.setLintStatus(repoName, result),
126
- spawn: this.deps.spawn,
127
- });
128
- }
129
- /**
130
- * Subscribe to filesystem-event notifications. The listener is called for
131
- * every detected change before the watcher-launch logic. Returns an
132
- * unsubscribe function.
133
- */
134
- subscribeFsEvents(listener) {
135
- this.fsEventListeners.add(listener);
136
- return () => {
137
- this.fsEventListeners.delete(listener);
138
- };
139
- }
140
- // ─── Private ────────────────────────────────────────────────────────────
141
- handleChange(filePath) {
142
- const repoName = mapPathToRepo(filePath, this.prefixEntries);
143
- if (!repoName)
144
- return;
145
- const managed = this.repos.get(repoName);
146
- if (!managed)
147
- return;
148
- for (const listener of this.fsEventListeners) {
149
- listener(filePath);
150
- }
151
- scheduleLint(filePath, {
152
- repoName,
153
- repoRootDir: managed.rootDir,
154
- lintConfig: managed.config.lint,
155
- appendOutput: this.deps.appendOutput,
156
- onLintResult: (result) => this.deps.setLintStatus(repoName, result),
157
- spawn: this.deps.spawn,
158
- });
159
- if (!this.handles.has(repoName)) {
160
- void this.spawnWatcher(repoName);
161
- }
162
- }
163
- async spawnWatcher(repoName) {
164
- const managed = this.repos.get(repoName);
165
- if (!managed || !managed.config.watch) {
166
- return;
167
- }
168
- const cmd = managed.config.watch;
169
- const [exe, ...args] = cmd.split(/\s+/);
170
- if (!exe)
171
- return;
172
- this.deps.setRepoStatus(repoName, 'running');
173
- this.deps.appendOutput({
174
- ts: Date.now(),
175
- source: repoName,
176
- severity: 'info',
177
- text: `starting watcher: ${cmd}`,
178
- });
179
- const handle = this.deps.spawn(exe, args, {
180
- cwd: managed.rootDir,
181
- onStdout: (line) => this.deps.appendOutput({
182
- ts: Date.now(),
183
- source: repoName,
184
- severity: 'info',
185
- text: line,
186
- }),
187
- onStderr: (line) => this.deps.appendOutput({
188
- ts: Date.now(),
189
- source: repoName,
190
- severity: 'warn',
191
- text: line,
192
- }),
193
- });
194
- this.handles.set(repoName, handle);
195
- // Capture this spawn's handle identity. If rebuild() replaces the handle
196
- // before exitCode resolves, the stale exit handler must not clobber the
197
- // new watcher's status.
198
- const thisHandle = handle;
199
- const code = await handle.exitCode;
200
- if (this.handles.get(repoName) !== thisHandle)
201
- return;
202
- this.handles.delete(repoName);
203
- if (code === 0) {
204
- this.deps.setRepoStatus(repoName, 'idle');
205
- this.deps.appendOutput({
206
- ts: Date.now(),
207
- source: repoName,
208
- severity: 'info',
209
- text: `watcher exited cleanly`,
210
- });
211
- // Transition: fail→clean only (not on first-ever clean exit).
212
- if (this.deps.onBuildOutcome && this.buildOutcomes.get(repoName) === 'fail') {
213
- this.deps.onBuildOutcome(repoName, 'build-recovered');
214
- }
215
- this.buildOutcomes.set(repoName, 'success');
216
- }
217
- else {
218
- this.deps.setRepoStatus(repoName, 'failing');
219
- this.deps.appendOutput({
220
- ts: Date.now(),
221
- source: repoName,
222
- severity: 'error',
223
- text: `watcher exited with code ${code}`,
224
- });
225
- // Transition: clean→fail only (not on repeated failures).
226
- if (this.deps.onBuildOutcome && this.buildOutcomes.get(repoName) !== 'fail') {
227
- this.deps.onBuildOutcome(repoName, 'build-failed');
228
- }
229
- this.buildOutcomes.set(repoName, 'fail');
230
- }
231
- }
232
- killHandle(repoName) {
233
- const handle = this.handles.get(repoName);
234
- if (handle) {
235
- handle.kill('SIGTERM');
236
- this.handles.delete(repoName);
237
- }
238
- }
239
- }