dev-cockpit 0.1.0 → 0.2.1

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 (140) hide show
  1. package/README.md +64 -29
  2. package/bin/dev-cockpit.mjs +26 -4
  3. package/dist/actions/builtin.d.ts +25 -0
  4. package/dist/actions/builtin.d.ts.map +1 -0
  5. package/dist/actions/dispatch.d.ts +21 -0
  6. package/dist/actions/dispatch.d.ts.map +1 -0
  7. package/dist/actions/registry.d.ts +11 -0
  8. package/dist/actions/registry.d.ts.map +1 -0
  9. package/dist/actions/types.d.ts +76 -0
  10. package/dist/actions/types.d.ts.map +1 -0
  11. package/dist/buildCli.d.ts.map +1 -1
  12. package/dist/chunk-6XGHLLYT.js +46 -0
  13. package/dist/chunk-6XGHLLYT.js.map +7 -0
  14. package/dist/chunk-Q6677JQF.js +32609 -0
  15. package/dist/chunk-Q6677JQF.js.map +7 -0
  16. package/dist/chunk-VN6UILQW.js +1460 -0
  17. package/dist/chunk-VN6UILQW.js.map +7 -0
  18. package/dist/cockpit/Cockpit.d.ts +6 -0
  19. package/dist/cockpit/Cockpit.d.ts.map +1 -1
  20. package/dist/cockpit/Footer.d.ts +6 -4
  21. package/dist/cockpit/Footer.d.ts.map +1 -1
  22. package/dist/cockpit/TabBar.d.ts.map +1 -1
  23. package/dist/cockpit/hooks/useGlobalKeys.d.ts +15 -15
  24. package/dist/cockpit/hooks/useGlobalKeys.d.ts.map +1 -1
  25. package/dist/cockpit/hooks/useTerminalWidth.d.ts +12 -0
  26. package/dist/cockpit/hooks/useTerminalWidth.d.ts.map +1 -0
  27. package/dist/cockpit/panes/CommandModal.d.ts +18 -0
  28. package/dist/cockpit/panes/CommandModal.d.ts.map +1 -0
  29. package/dist/cockpit/panes/Help.d.ts.map +1 -1
  30. package/dist/cockpit/panes/Output.d.ts +7 -0
  31. package/dist/cockpit/panes/Output.d.ts.map +1 -1
  32. package/dist/cockpit/panes/Repos.d.ts.map +1 -1
  33. package/dist/cockpit/state/store.d.ts +14 -11
  34. package/dist/cockpit/state/store.d.ts.map +1 -1
  35. package/dist/cockpit/tab-state.d.ts +12 -0
  36. package/dist/cockpit/tab-state.d.ts.map +1 -1
  37. package/dist/commands/dev.d.ts.map +1 -1
  38. package/dist/commands/init-config-wizard.d.ts +103 -2
  39. package/dist/commands/init-config-wizard.d.ts.map +1 -1
  40. package/dist/commands/init-config.d.ts.map +1 -1
  41. package/dist/commands/migrate-config.d.ts +18 -0
  42. package/dist/commands/migrate-config.d.ts.map +1 -0
  43. package/dist/commands/mount.d.ts +17 -32
  44. package/dist/commands/mount.d.ts.map +1 -1
  45. package/dist/core/config.d.ts +73 -5
  46. package/dist/core/config.d.ts.map +1 -1
  47. package/dist/core/migrations.d.ts +33 -0
  48. package/dist/core/migrations.d.ts.map +1 -0
  49. package/dist/core/subprocess.d.ts +20 -0
  50. package/dist/core/subprocess.d.ts.map +1 -1
  51. package/dist/core/types.d.ts +36 -12
  52. package/dist/core/types.d.ts.map +1 -1
  53. package/dist/devtools-YXMW6JJ6.js +3720 -0
  54. package/dist/devtools-YXMW6JJ6.js.map +7 -0
  55. package/dist/docker/highlights.d.ts +14 -4
  56. package/dist/docker/highlights.d.ts.map +1 -1
  57. package/dist/docker/logs.d.ts +3 -2
  58. package/dist/docker/logs.d.ts.map +1 -1
  59. package/dist/health/builtin.d.ts.map +1 -1
  60. package/dist/index.d.ts +14 -3
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +92837 -53
  63. package/dist/index.js.map +7 -0
  64. package/dist/ink.js +38 -1
  65. package/dist/ink.js.map +7 -0
  66. package/dist/mount/compose.d.ts +21 -0
  67. package/dist/mount/compose.d.ts.map +1 -0
  68. package/dist/mount/discovery.d.ts +35 -0
  69. package/dist/mount/discovery.d.ts.map +1 -0
  70. package/dist/mount/git-status.d.ts +12 -0
  71. package/dist/mount/git-status.d.ts.map +1 -0
  72. package/dist/mount/manifest.d.ts +16 -0
  73. package/dist/mount/manifest.d.ts.map +1 -0
  74. package/dist/mount/symlinks.d.ts +30 -0
  75. package/dist/mount/symlinks.d.ts.map +1 -0
  76. package/dist/mount/types.d.ts +60 -0
  77. package/dist/mount/types.d.ts.map +1 -0
  78. package/dist/react.js +35 -1
  79. package/dist/react.js.map +7 -0
  80. package/dist/runCockpit.d.ts +3 -0
  81. package/dist/runCockpit.d.ts.map +1 -1
  82. package/docs/commands.md +29 -16
  83. package/docs/config-reference.md +115 -11
  84. package/docs/getting-started.md +9 -6
  85. package/docs/index.md +5 -1
  86. package/docs/init-config.md +34 -8
  87. package/docs/mount.md +198 -25
  88. package/docs/notifications.md +14 -13
  89. package/docs/panes.md +36 -15
  90. package/docs/processes.md +42 -0
  91. package/package.json +93 -90
  92. package/dist/buildCli.js +0 -107
  93. package/dist/cli.js +0 -2
  94. package/dist/cockpit/Cockpit.js +0 -73
  95. package/dist/cockpit/Footer.js +0 -33
  96. package/dist/cockpit/TabBar.js +0 -12
  97. package/dist/cockpit/help/content.js +0 -22
  98. package/dist/cockpit/help/loader.js +0 -118
  99. package/dist/cockpit/help/renderer.js +0 -35
  100. package/dist/cockpit/help/types.js +0 -1
  101. package/dist/cockpit/hooks/useCockpitStore.js +0 -5
  102. package/dist/cockpit/hooks/useGlobalKeys.js +0 -173
  103. package/dist/cockpit/panes/FilterModal.js +0 -22
  104. package/dist/cockpit/panes/Health.js +0 -30
  105. package/dist/cockpit/panes/Help.js +0 -81
  106. package/dist/cockpit/panes/Output.js +0 -108
  107. package/dist/cockpit/panes/Repos.js +0 -48
  108. package/dist/cockpit/panes/SearchModal.js +0 -31
  109. package/dist/cockpit/state/store.js +0 -111
  110. package/dist/cockpit/tab-state.js +0 -7
  111. package/dist/commands/dev.js +0 -158
  112. package/dist/commands/doctor.js +0 -66
  113. package/dist/commands/init-config-wizard.js +0 -818
  114. package/dist/commands/init-config.js +0 -131
  115. package/dist/commands/mount.js +0 -150
  116. package/dist/core/config.js +0 -152
  117. package/dist/core/logger.js +0 -38
  118. package/dist/core/notifier.js +0 -100
  119. package/dist/core/paths.js +0 -18
  120. package/dist/core/subprocess.js +0 -82
  121. package/dist/core/types.js +0 -1
  122. package/dist/docker/highlights.js +0 -79
  123. package/dist/docker/logs.js +0 -172
  124. package/dist/docker/restart.js +0 -45
  125. package/dist/docker/stack-trace.js +0 -44
  126. package/dist/health/builtin.js +0 -144
  127. package/dist/health/context.js +0 -31
  128. package/dist/health/notify-resolver.js +0 -28
  129. package/dist/health/registry.js +0 -64
  130. package/dist/health/remediations.js +0 -41
  131. package/dist/health/runner.js +0 -22
  132. package/dist/health/scheduler.js +0 -107
  133. package/dist/health/types.js +0 -1
  134. package/dist/health/useHealth.js +0 -122
  135. package/dist/lint/reactive.js +0 -131
  136. package/dist/runCockpit.js +0 -75
  137. package/dist/watchers/manager.js +0 -239
  138. package/dist/watchers/path-mapper.js +0 -29
  139. package/dist/watchers/types.js +0 -9
  140. package/docs/watchers.md +0 -27
@@ -1,818 +0,0 @@
1
- /**
2
- * Interactive `cockpit.yaml` wizard for `dev-cockpit init-config --interactive`.
3
- *
4
- * Two pieces:
5
- * 1. {@link runInitWizard} — the prompt loop (lazy-imports @inquirer/prompts
6
- * so non-interactive code paths don't pay the import cost).
7
- * 2. {@link renderWizardYaml} — pure function: WizardResult → yaml string.
8
- * Lives here so tests can exercise it without touching prompts at all.
9
- *
10
- * The wizard intentionally stays narrow: it scaffolds a *usable* starter
11
- * config covering the common knobs (one watcher or two, the five built-in
12
- * health types with their primary args + a remediation, an optional docker
13
- * block, optional highlights, the notifications flag). Anything more
14
- * advanced — custom triggers, expectStatus, exec args — the user edits in
15
- * the generated yaml.
16
- */
17
- // ─── Pure renderer ──────────────────────────────────────────────────────────
18
- function indent(line, level) {
19
- return ' '.repeat(level) + line;
20
- }
21
- function quote(value) {
22
- // Quote when it contains characters yaml cares about; otherwise leave bare.
23
- if (/^[A-Za-z0-9_./-]+$/.test(value))
24
- return value;
25
- return `'${value.replace(/'/g, "''")}'`;
26
- }
27
- function renderHealth(checks) {
28
- if (checks.length === 0)
29
- return [];
30
- const out = ['health:'];
31
- for (const c of checks) {
32
- out.push(indent(`- id: ${quote(c.id)}`, 1));
33
- out.push(indent(` label: ${quote(c.label)}`, 1));
34
- out.push(indent(` type: ${c.type}`, 1));
35
- out.push(indent(` severity: ${c.severity}`, 1));
36
- switch (c.type) {
37
- case 'container-running':
38
- out.push(indent(` container: ${quote(c.container)}`, 1));
39
- break;
40
- case 'port-open':
41
- out.push(indent(` host: ${quote(c.host)}`, 1));
42
- out.push(indent(` port: ${c.port}`, 1));
43
- break;
44
- case 'http-ok':
45
- out.push(indent(` url: ${quote(c.url)}`, 1));
46
- break;
47
- case 'file-exists':
48
- out.push(indent(` path: ${quote(c.path)}`, 1));
49
- break;
50
- case 'exec-zero':
51
- out.push(indent(` command: ${quote(c.command)}`, 1));
52
- break;
53
- }
54
- out.push(indent(` remediation:`, 1));
55
- out.push(indent(` key: ${quote(c.remediation.key)}`, 1));
56
- out.push(indent(` label: ${quote(c.remediation.label)}`, 1));
57
- out.push(indent(` command: ${quote(c.remediation.command)}`, 1));
58
- }
59
- return out;
60
- }
61
- function renderWatchers(watchers) {
62
- if (watchers.length === 0)
63
- return [];
64
- const out = ['watchers:'];
65
- for (const w of watchers) {
66
- out.push(indent(`- id: ${quote(w.id)}`, 1));
67
- out.push(indent(` command: ${quote(w.command)}`, 1));
68
- }
69
- return out;
70
- }
71
- function renderDocker(docker) {
72
- if (!docker)
73
- return [];
74
- const out = ['docker:'];
75
- if (docker.composeFile) {
76
- out.push(indent(`composeFile: ${quote(docker.composeFile)}`, 1));
77
- }
78
- out.push(indent('services:', 1));
79
- for (const s of docker.services) {
80
- out.push(indent(`- name: ${quote(s.name)}`, 2));
81
- }
82
- return out;
83
- }
84
- function renderRepos(repos) {
85
- if (repos.length === 0)
86
- return [];
87
- const out = ['repos:'];
88
- for (const r of repos) {
89
- out.push(indent(`- id: ${quote(r.id)}`, 1));
90
- out.push(indent(` path: ${quote(r.path)}`, 1));
91
- if (r.label && r.label !== r.id) {
92
- out.push(indent(` label: ${quote(r.label)}`, 1));
93
- }
94
- }
95
- return out;
96
- }
97
- function renderHighlights(hs) {
98
- if (hs.length === 0)
99
- return [];
100
- const out = ['highlights:'];
101
- for (const h of hs) {
102
- out.push(indent(`- pattern: ${quote(h.pattern)}`, 1));
103
- out.push(indent(` severity: ${h.severity}`, 1));
104
- }
105
- return out;
106
- }
107
- export function renderWizardYaml(result) {
108
- const blocks = [];
109
- blocks.push([
110
- '# Generated by `dev-cockpit init-config --interactive`. Edit freely.',
111
- '',
112
- 'version: 1',
113
- `appName: ${quote(result.appName)}`,
114
- ]);
115
- const watchers = renderWatchers(result.watchers);
116
- if (watchers.length > 0)
117
- blocks.push(watchers);
118
- const docker = renderDocker(result.docker);
119
- if (docker.length > 0)
120
- blocks.push(docker);
121
- const repos = renderRepos(result.repos);
122
- if (repos.length > 0)
123
- blocks.push(repos);
124
- const highlights = renderHighlights(result.highlights);
125
- if (highlights.length > 0)
126
- blocks.push(highlights);
127
- const health = renderHealth(result.health);
128
- if (health.length > 0)
129
- blocks.push(health);
130
- blocks.push([
131
- 'notifications:',
132
- indent(`enabled: ${result.notifications.enabled}`, 1),
133
- ]);
134
- return blocks.map((b) => b.join('\n')).join('\n\n') + '\n';
135
- }
136
- // ─── Interactive prompt loop ────────────────────────────────────────────────
137
- import fs from 'node:fs';
138
- import path from 'node:path';
139
- import chalk from 'chalk';
140
- async function loadPrompts() {
141
- const m = (await import('@inquirer/prompts'));
142
- return m;
143
- }
144
- // ─── Console helpers (lots of guidance copy in here, kept terminal-friendly) ─
145
- function blank() {
146
- process.stdout.write('\n');
147
- }
148
- function line(s = '') {
149
- process.stdout.write(`${s}\n`);
150
- }
151
- function header(stepIdx, total, title) {
152
- blank();
153
- line(chalk.bold.cyan(`── Step ${stepIdx} of ${total} — ${title} ──`));
154
- }
155
- function explain(s) {
156
- // Wrap soft. Keep a constant left margin.
157
- for (const para of s.split('\n'))
158
- line(chalk.dim(para));
159
- }
160
- function showSummary(result) {
161
- blank();
162
- line(chalk.bold(' cockpit.yaml summary:'));
163
- line(` ${chalk.dim('appName:')} ${result.appName}`);
164
- if (result.watchers.length > 0) {
165
- line(` ${chalk.dim('watchers:')} ${result.watchers.map((w) => w.id).join(', ')}`);
166
- }
167
- if (result.docker) {
168
- line(` ${chalk.dim('docker services:')} ${result.docker.services.map((s) => s.name).join(', ')}`);
169
- }
170
- if (result.repos.length > 0) {
171
- line(` ${chalk.dim('repos:')} ${result.repos.map((r) => r.id).join(', ')}`);
172
- }
173
- if (result.highlights.length > 0) {
174
- line(` ${chalk.dim('highlights:')} ${result.highlights.length} pattern(s)`);
175
- }
176
- if (result.health.length > 0) {
177
- line(` ${chalk.dim('health:')} ${result.health.map((h) => `${h.id} (${h.type})`).join(', ')}`);
178
- }
179
- line(` ${chalk.dim('notifications:')} ${result.notifications.enabled ? 'enabled' : 'disabled'}`);
180
- blank();
181
- }
182
- function safeReadJson(p) {
183
- try {
184
- return JSON.parse(fs.readFileSync(p, 'utf8'));
185
- }
186
- catch {
187
- return null;
188
- }
189
- }
190
- function detectComposeFile(cwd) {
191
- for (const candidate of ['compose.yaml', 'compose.yml', 'docker-compose.yml', 'docker-compose.yaml']) {
192
- if (fs.existsSync(path.join(cwd, candidate)))
193
- return candidate;
194
- }
195
- return undefined;
196
- }
197
- function parseComposeServices(cwd, composeFile) {
198
- // Lightweight scan — doesn't depend on `yaml`. Picks lines like
199
- // ` name:` directly under a top-level `services:` key.
200
- try {
201
- const text = fs.readFileSync(path.join(cwd, composeFile), 'utf8');
202
- const out = [];
203
- let inServices = false;
204
- for (const raw of text.split('\n')) {
205
- // Top-level key (column 0) other than `services:` ends the block.
206
- if (/^[A-Za-z]/.test(raw)) {
207
- inServices = raw.startsWith('services:');
208
- continue;
209
- }
210
- if (!inServices)
211
- continue;
212
- const m = /^ {2,4}([A-Za-z][A-Za-z0-9._-]*):\s*$/.exec(raw);
213
- if (m && m[1])
214
- out.push(m[1]);
215
- }
216
- return out;
217
- }
218
- catch {
219
- return [];
220
- }
221
- }
222
- function detectRepoSuggestions(cwd, pkg) {
223
- const out = [];
224
- // npm / yarn workspaces — package.json `workspaces` is either an array of
225
- // glob patterns OR an object with `packages: [...]`.
226
- const ws = pkg?.['workspaces'];
227
- let workspaceGlobs = [];
228
- if (Array.isArray(ws)) {
229
- workspaceGlobs = ws.filter((g) => typeof g === 'string');
230
- }
231
- else if (ws && typeof ws === 'object') {
232
- const packages = ws.packages;
233
- if (Array.isArray(packages)) {
234
- workspaceGlobs = packages.filter((g) => typeof g === 'string');
235
- }
236
- }
237
- for (const glob of workspaceGlobs) {
238
- for (const entry of expandSimpleGlob(cwd, glob)) {
239
- out.push({ id: path.basename(entry), path: path.relative(cwd, path.join(cwd, entry)) || '.' });
240
- }
241
- }
242
- // pnpm-workspace.yaml — lightweight scan, no yaml parser dep needed.
243
- const pnpmFile = path.join(cwd, 'pnpm-workspace.yaml');
244
- if (fs.existsSync(pnpmFile)) {
245
- try {
246
- const text = fs.readFileSync(pnpmFile, 'utf8');
247
- const inPackages = /packages:\s*([\s\S]*?)(?:\n[a-zA-Z]|$)/.exec(text);
248
- if (inPackages?.[1]) {
249
- for (const line of inPackages[1].split('\n')) {
250
- const m = /^\s*-\s*['"]?([^'"]+)['"]?/.exec(line);
251
- if (m?.[1]) {
252
- for (const entry of expandSimpleGlob(cwd, m[1])) {
253
- if (!out.some((e) => e.path === entry)) {
254
- out.push({ id: path.basename(entry), path: entry });
255
- }
256
- }
257
- }
258
- }
259
- }
260
- }
261
- catch {
262
- // ignore
263
- }
264
- }
265
- // Default fallback: a single entry at the workspace root, named after
266
- // package.json or the directory.
267
- if (out.length === 0) {
268
- const id = pkg?.['name'] ?? path.basename(cwd) ?? 'app';
269
- out.push({ id, path: '.' });
270
- }
271
- return out;
272
- }
273
- /**
274
- * Tiny glob expander good enough for `packages/*` and literal paths.
275
- * Anything more exotic (`**`, brace expansion, negations) just returns the
276
- * literal as-is — the user can hand-edit cockpit.yaml later.
277
- */
278
- function expandSimpleGlob(cwd, glob) {
279
- if (!glob.includes('*')) {
280
- return fs.existsSync(path.join(cwd, glob)) ? [glob] : [];
281
- }
282
- const m = /^([^*]+?)\/?\*$/.exec(glob);
283
- if (!m?.[1])
284
- return [];
285
- const baseRel = m[1];
286
- const baseAbs = path.join(cwd, baseRel);
287
- if (!fs.existsSync(baseAbs))
288
- return [];
289
- try {
290
- return fs
291
- .readdirSync(baseAbs, { withFileTypes: true })
292
- .filter((d) => d.isDirectory() && !d.name.startsWith('.'))
293
- .map((d) => path.posix.join(baseRel, d.name));
294
- }
295
- catch {
296
- return [];
297
- }
298
- }
299
- function gatherHints(cwd) {
300
- const pkg = safeReadJson(path.join(cwd, 'package.json'));
301
- const pkgScripts = pkg?.['scripts'] ?? {};
302
- const composeFile = detectComposeFile(cwd);
303
- const composeServices = composeFile ? parseComposeServices(cwd, composeFile) : [];
304
- return {
305
- cwd,
306
- appNameDefault: pkg?.['name'] ?? path.basename(cwd) ?? 'my-cockpit',
307
- packageJsonScripts: pkgScripts,
308
- composeFile,
309
- composeServices,
310
- hasArtisan: fs.existsSync(path.join(cwd, 'artisan')),
311
- hasComposerJson: fs.existsSync(path.join(cwd, 'composer.json')),
312
- repoSuggestions: detectRepoSuggestions(cwd, pkg),
313
- };
314
- }
315
- // ─── Per-section prompts ────────────────────────────────────────────────────
316
- async function promptAppName(prompts, hints, fallback) {
317
- header(1, 7, 'App name');
318
- explain([
319
- 'Just a label for this cockpit. Shows up in window titles and OS',
320
- 'notification headers. Press enter to accept the default.',
321
- ].join('\n'));
322
- return prompts.input({
323
- message: 'appName',
324
- default: hints.appNameDefault || fallback,
325
- });
326
- }
327
- async function promptWatchers(prompts, hints) {
328
- header(3, 7, 'Watchers — long-running processes');
329
- explain([
330
- 'Watchers are commands the cockpit spawns once on boot and streams into',
331
- 'the Output pane. Use this for anything that runs forever and writes to',
332
- 'stdout — and isn\'t already covered by the docker block above:',
333
- '',
334
- ' npm: `vite`, `tsc --watch`, `jest --watch`, `npm run dev`',
335
- ' php: `php artisan serve`, `php artisan queue:work`',
336
- ' log tails: `tail -f storage/logs/laravel.log`',
337
- '',
338
- 'Each watcher gets a tag (its `id`) so its lines stay grouped in Output.',
339
- ].join('\n'));
340
- const suggestionScripts = ['dev', 'watch', 'start', 'serve'].filter((s) => hints.packageJsonScripts[s]);
341
- const watchers = [];
342
- if (suggestionScripts.length > 0) {
343
- blank();
344
- line(chalk.dim(` Detected package.json scripts: ${suggestionScripts.map((s) => chalk.cyan(s)).join(', ')}`));
345
- const picked = await prompts.checkbox({
346
- message: 'Pick the scripts to register as watchers (space to toggle, enter to confirm)',
347
- choices: [
348
- ...suggestionScripts.map((s) => ({
349
- name: `${s} → npm run ${s} (${hints.packageJsonScripts[s] ?? ''})`.slice(0, 80),
350
- value: s,
351
- checked: s === 'dev' || s === 'watch',
352
- })),
353
- { name: '— none of these, I’ll add custom watchers below', value: '__custom__' },
354
- ],
355
- });
356
- for (const s of picked) {
357
- if (s === '__custom__')
358
- continue;
359
- watchers.push({ id: s, command: `npm run ${s}` });
360
- }
361
- }
362
- let addAnother = await prompts.confirm({
363
- message: watchers.length > 0
364
- ? 'Add another (custom) watcher beyond what was picked?'
365
- : 'Add a custom watcher now?',
366
- default: false,
367
- });
368
- while (addAnother) {
369
- const id = await prompts.input({
370
- message: 'watcher id (short slug, e.g. `tsc`, `vite`)',
371
- validate: (s) => (s.trim().length > 0 ? true : 'cannot be empty'),
372
- });
373
- const command = await prompts.input({
374
- message: `command for ${chalk.cyan(id)} (will be spawned once on cockpit boot)`,
375
- validate: (s) => (s.trim().length > 0 ? true : 'cannot be empty'),
376
- });
377
- watchers.push({ id: id.trim(), command: command.trim() });
378
- addAnother = await prompts.confirm({ message: 'Add another watcher?', default: false });
379
- }
380
- if (watchers.length === 0) {
381
- line(chalk.dim(' (skipped — no watchers configured)'));
382
- }
383
- return watchers;
384
- }
385
- async function promptDocker(prompts, hints) {
386
- header(2, 7, 'Docker — log tailing for compose services');
387
- explain([
388
- 'If you run a docker-compose stack, the cockpit can stream',
389
- '`docker compose logs -f` for selected services into the Output pane.',
390
- '',
391
- 'This section is compose-only. For other long-running processes',
392
- '(`php artisan serve`, `vite`, log tails), the next step covers them as',
393
- 'watchers. Skip this entire section if you don\'t run a compose stack here.',
394
- ].join('\n'));
395
- if (hints.composeFile) {
396
- blank();
397
- line(chalk.dim(` Detected ${chalk.cyan(hints.composeFile)} with services: ${hints.composeServices.length > 0 ? hints.composeServices.map((s) => chalk.cyan(s)).join(', ') : '(none parsed)'}.`));
398
- }
399
- else {
400
- line(chalk.dim(' No compose file detected at the project root.'));
401
- }
402
- const want = await prompts.confirm({
403
- message: 'Include a docker block?',
404
- default: Boolean(hints.composeFile),
405
- });
406
- if (!want) {
407
- line(chalk.dim(' (skipped — docker logs will not be tailed)'));
408
- return undefined;
409
- }
410
- const composeFile = await prompts.input({
411
- message: 'compose file (relative to workspace root)',
412
- default: hints.composeFile ?? 'docker-compose.yml',
413
- });
414
- if (hints.composeServices.length > 0) {
415
- const picked = await prompts.checkbox({
416
- message: 'Which services should the cockpit tail logs from?',
417
- choices: hints.composeServices.map((s) => ({ name: s, value: s, checked: true })),
418
- });
419
- return {
420
- composeFile,
421
- services: picked.map((name) => ({ name })),
422
- };
423
- }
424
- const servicesRaw = await prompts.input({
425
- message: 'services to tail (comma-separated, e.g. `web,db,redis`)',
426
- default: 'web',
427
- validate: (s) => (s.trim().length > 0 ? true : 'enter at least one service'),
428
- });
429
- return {
430
- composeFile,
431
- services: servicesRaw
432
- .split(',')
433
- .map((s) => s.trim())
434
- .filter(Boolean)
435
- .map((name) => ({ name })),
436
- };
437
- }
438
- async function promptRepos(prompts, hints) {
439
- header(4, 7, 'Repos pane entries');
440
- explain([
441
- 'The Repos pane lists things you can act on with single keystrokes',
442
- 'inside the cockpit:',
443
- '',
444
- ' r → rebuild w → toggle watcher l → run lint',
445
- '',
446
- 'Docker services from the docker block already appear here automatically',
447
- '(with r → restart). This step is for code-side entries — typically:',
448
- '',
449
- ' • the project root (single Node/Laravel/Python app)',
450
- ' • each package in a monorepo (npm/yarn/pnpm workspaces)',
451
- ' • each in-house package you develop alongside the app',
452
- '',
453
- 'Skip entirely if you only need docker services in the pane. The r/w/l',
454
- 'actions become live when an active profile bundle wires them; the',
455
- 'generic shell shows the rows but the keys are no-ops without one.',
456
- ].join('\n'));
457
- if (hints.repoSuggestions.length > 0) {
458
- blank();
459
- line(chalk.dim(` Detected ${chalk.cyan(String(hints.repoSuggestions.length))} candidate(s) from this project:`));
460
- }
461
- const picked = await prompts.checkbox({
462
- message: hints.repoSuggestions.length > 0
463
- ? 'Pick which to register as repo entries (space toggles, enter confirms)'
464
- : 'No suggestions detected — press enter to skip, or add custom entries below',
465
- choices: hints.repoSuggestions.map((r) => ({
466
- name: `${r.id} → ${r.path}`,
467
- value: r.id,
468
- checked: hints.repoSuggestions.length === 1, // single-app default: pre-checked
469
- })),
470
- });
471
- const out = hints.repoSuggestions.filter((r) => picked.includes(r.id));
472
- let addAnother = await prompts.confirm({
473
- message: out.length > 0 ? 'Add another (custom) repo entry?' : 'Add a custom repo entry?',
474
- default: false,
475
- });
476
- while (addAnother) {
477
- const id = await prompts.input({
478
- message: 'repo id (slug; e.g. `api`, `web`)',
479
- validate: (s) => (s.trim().length > 0 ? true : 'cannot be empty'),
480
- });
481
- const repoPath = await prompts.input({
482
- message: `path (relative to workspace root) for ${chalk.cyan(id)}`,
483
- default: '.',
484
- validate: (s) => (s.trim().length > 0 ? true : 'cannot be empty'),
485
- });
486
- const label = await prompts.input({
487
- message: `label shown in the pane (defaults to id if blank)`,
488
- default: id,
489
- });
490
- out.push({
491
- id: id.trim(),
492
- path: repoPath.trim(),
493
- label: label.trim() === id.trim() ? undefined : label.trim(),
494
- });
495
- addAnother = await prompts.confirm({ message: 'Add another?', default: false });
496
- }
497
- if (out.length === 0) {
498
- line(chalk.dim(' (skipped — Repos pane will only show docker services, if any)'));
499
- }
500
- return out;
501
- }
502
- async function promptHighlights(prompts) {
503
- header(5, 7, 'Output highlights');
504
- explain([
505
- 'Regex patterns that re-colour matching lines in Output. `error`-severity',
506
- 'matches additionally feed the Recent Errors strip on the Repos pane and',
507
- 'fire OS notifications (per the section below).',
508
- '',
509
- 'Examples — pattern + severity:',
510
- ' Stack trace error (red, also notifies)',
511
- ' \\[ERROR\\] error (Laravel-style log lines)',
512
- ' PHP Fatal error (PHP error surface)',
513
- ' WARN warn (yellow, no notify)',
514
- '',
515
- 'Combine with watchers for max value — e.g. a watcher running',
516
- '`tail -f storage/logs/laravel.log` plus the `\\[ERROR\\]` highlight will',
517
- 'stream Laravel\'s log into Output and paint each error red as it lands.',
518
- ].join('\n'));
519
- const want = await prompts.confirm({ message: 'Add highlight patterns?', default: false });
520
- if (!want) {
521
- line(chalk.dim(' (skipped — Output stays uncoloured)'));
522
- return [];
523
- }
524
- const out = [];
525
- let addAnother = true;
526
- while (addAnother) {
527
- const pattern = await prompts.input({
528
- message: 'pattern (regex; e.g. `ERROR`, `Stack trace`)',
529
- validate: (s) => (s.trim().length > 0 ? true : 'cannot be empty'),
530
- });
531
- const severity = await prompts.select({
532
- message: 'severity for matching lines',
533
- choices: [
534
- { name: 'error — red + feeds notifications', value: 'error' },
535
- { name: 'warn — yellow', value: 'warn' },
536
- { name: 'info — blue (rarely useful, but here for completeness)', value: 'info' },
537
- ],
538
- default: 'warn',
539
- });
540
- out.push({ pattern: pattern.trim(), severity });
541
- addAnother = await prompts.confirm({ message: 'Add another?', default: false });
542
- }
543
- return out;
544
- }
545
- async function promptHealthChecks(prompts, hints, hasDocker) {
546
- header(6, 7, 'Health checks');
547
- explain([
548
- 'Each health check has two parts:',
549
- '',
550
- ` 1. The ${chalk.bold('test')} — the condition the cockpit evaluates`,
551
- ' ("does X pass?"). Runs at startup + on relevant',
552
- ' events (filesystem changes, docker poll, etc.).',
553
- '',
554
- ` 2. The ${chalk.bold('fix')} — a single-keystroke remediation. When the test`,
555
- ' fails, the row goes red and pressing the configured',
556
- ' key runs your fix command.',
557
- '',
558
- 'Five built-in test types:',
559
- '',
560
- ' container-running is the named container in `docker compose ps`?',
561
- ' e.g. "is pgsql up?" → D = docker compose up -d',
562
- '',
563
- ' port-open can we open a TCP socket on host:port?',
564
- ' e.g. "is port 5432 open?" → D = docker compose up -d',
565
- '',
566
- ' http-ok does GET <url> return 2xx?',
567
- ' e.g. "is /health 2xx?" → R = restart the app',
568
- '',
569
- ' file-exists does <path> exist on disk?',
570
- ' e.g. "does vendor/ exist?" → I = composer install',
571
- '',
572
- ' exec-zero does <command> exit with status 0?',
573
- ' e.g. "migrations up-to-date?" → M = run migrations',
574
- '',
575
- 'You can add as many checks as you like — same type can repeat (e.g. one',
576
- 'port-open for 5432, another for 6379).',
577
- ].join('\n'));
578
- const out = [];
579
- let typeChoices = baseHealthTypeChoices(Boolean(hints.composeFile && hints.composeServices.length > 0));
580
- // Hide container-running when there's no docker block — it'd never go green.
581
- if (!hasDocker) {
582
- typeChoices = typeChoices.filter((c) => c.value !== 'container-running');
583
- }
584
- let addAnother = await prompts.confirm({ message: 'Add a health check?', default: true });
585
- while (addAnother) {
586
- const type = await prompts.select({
587
- message: 'check type',
588
- choices: typeChoices,
589
- default: typeChoices[0]?.value,
590
- });
591
- out.push(await promptOneHealthCheck(prompts, type, hints));
592
- addAnother = await prompts.confirm({ message: 'Add another health check?', default: false });
593
- }
594
- if (out.length === 0) {
595
- line(chalk.dim(' (skipped — Health pane will be empty until you add checks by hand)'));
596
- }
597
- return out;
598
- }
599
- function baseHealthTypeChoices(haveDocker) {
600
- return [
601
- {
602
- name: 'container-running — a named docker container is up',
603
- value: 'container-running',
604
- description: haveDocker
605
- ? 'You have a compose stack — use this for "is service X running?".'
606
- : '(no docker block declared — pick this only if your container runs out-of-band)',
607
- },
608
- {
609
- name: 'port-open — a TCP port accepts connections',
610
- value: 'port-open',
611
- description: 'For "is postgres listening on 5432?" / "is the dev server up?".',
612
- },
613
- {
614
- name: 'http-ok — an HTTP endpoint returns 2xx',
615
- value: 'http-ok',
616
- description: 'Stronger than port-open — confirms the app actually responds.',
617
- },
618
- {
619
- name: 'file-exists — a file is present at a path',
620
- value: 'file-exists',
621
- description: 'For vendor/, node_modules/, .env — fast disk-only check.',
622
- },
623
- {
624
- name: 'exec-zero — a command exits 0',
625
- value: 'exec-zero',
626
- description: 'Catch-all: lint clean, typecheck clean, migrations up-to-date.',
627
- },
628
- ];
629
- }
630
- async function promptOneHealthCheck(prompts, type, hints) {
631
- const idDefault = type;
632
- const id = await prompts.input({
633
- message: 'short id (slug; lowercase, e.g. `web-up`)',
634
- default: idDefault,
635
- validate: (s) => (/^[a-z0-9][a-z0-9-]*$/.test(s.trim()) ? true : 'lowercase letters/numbers/dashes only'),
636
- });
637
- const label = await prompts.input({
638
- message: 'human-readable label (shown on the Health row)',
639
- default: id,
640
- });
641
- const severity = await prompts.select({
642
- message: 'how serious is failure?',
643
- choices: [
644
- { name: 'error — red, fires OS notification on failure', value: 'error' },
645
- { name: 'warn — yellow, no notification', value: 'warn' },
646
- { name: 'ok — never goes red even when failing (rare)', value: 'ok' },
647
- ],
648
- default: 'error',
649
- });
650
- const base = {
651
- id: id.trim(),
652
- label,
653
- severity,
654
- remediation: { key: '', label: '', command: '' },
655
- };
656
- let typed;
657
- switch (type) {
658
- case 'container-running': {
659
- explain(' The exact name of the running container (often the compose service name).');
660
- const container = await prompts.input({
661
- message: 'container name',
662
- default: hints.composeServices[0],
663
- validate: (s) => (s.trim().length > 0 ? true : 'cannot be empty'),
664
- });
665
- typed = { ...base, type, container: container.trim() };
666
- break;
667
- }
668
- case 'port-open': {
669
- const host = await prompts.input({
670
- message: 'host (almost always 127.0.0.1)',
671
- default: '127.0.0.1',
672
- });
673
- const portRaw = await prompts.number({ message: 'port (number)', default: 8080 });
674
- typed = { ...base, type, host: host.trim(), port: portRaw ?? 0 };
675
- break;
676
- }
677
- case 'http-ok': {
678
- explain(' Full URL — the check fails if the response is not 2xx.');
679
- const url = await prompts.input({
680
- message: 'url (e.g. http://localhost:3000/health)',
681
- validate: (s) => (s.trim().startsWith('http') ? true : 'must start with http:// or https://'),
682
- });
683
- typed = { ...base, type, url: url.trim() };
684
- break;
685
- }
686
- case 'file-exists': {
687
- const filePath = await prompts.input({
688
- message: 'path relative to workspace root',
689
- default: hints.hasComposerJson ? 'vendor' : 'node_modules',
690
- validate: (s) => (s.trim().length > 0 ? true : 'cannot be empty'),
691
- });
692
- typed = { ...base, type, path: filePath.trim() };
693
- break;
694
- }
695
- case 'exec-zero': {
696
- explain(' The command runs once at startup. Exit code 0 = pass, anything else = fail.');
697
- const command = await prompts.input({
698
- message: 'command',
699
- validate: (s) => (s.trim().length > 0 ? true : 'cannot be empty'),
700
- });
701
- typed = { ...base, type, command: command.trim() };
702
- break;
703
- }
704
- }
705
- blank();
706
- line(chalk.bold(' Remediation — what should pressing a key in the cockpit do when this check fails?'));
707
- explain([
708
- ' Three pieces:',
709
- ' • key: the keystroke to press on the Health pane (single uppercase letter)',
710
- ' • label: what the cockpit shows beside the row when failing',
711
- ' • command: the shell command that runs when the user presses the key',
712
- ].join('\n'));
713
- typed.remediation.key = (await prompts.input({
714
- message: 'key (single uppercase letter, e.g. R)',
715
- default: 'R',
716
- validate: (s) => (/^[A-Z]$/.test(s.trim()) ? true : 'one uppercase letter, e.g. R'),
717
- })).trim();
718
- typed.remediation.label = await prompts.input({
719
- message: 'remediation label',
720
- default: defaultRemediationLabel(type),
721
- });
722
- typed.remediation.command = await prompts.input({
723
- message: 'remediation command',
724
- default: defaultRemediationCommand(type, hints),
725
- validate: (s) => (s.trim().length > 0 ? true : 'cannot be empty'),
726
- });
727
- return typed;
728
- }
729
- function defaultRemediationLabel(type) {
730
- switch (type) {
731
- case 'container-running':
732
- return 'bring up docker stack';
733
- case 'port-open':
734
- return 'restart the listener';
735
- case 'http-ok':
736
- return 'restart the app';
737
- case 'file-exists':
738
- return 'install dependencies';
739
- case 'exec-zero':
740
- return 'run the failing command';
741
- }
742
- }
743
- function defaultRemediationCommand(type, hints) {
744
- switch (type) {
745
- case 'container-running':
746
- return hints.composeFile ? `docker compose -f ${hints.composeFile} up -d` : 'docker compose up -d';
747
- case 'port-open':
748
- return hints.composeFile ? `docker compose -f ${hints.composeFile} up -d` : 'docker compose up -d';
749
- case 'http-ok':
750
- return 'echo "edit cockpit.yaml to set the restart command"';
751
- case 'file-exists':
752
- if (hints.hasComposerJson)
753
- return 'composer install';
754
- if (hints.packageJsonScripts['install'])
755
- return 'npm install';
756
- return 'npm install';
757
- case 'exec-zero':
758
- return 'echo "edit cockpit.yaml to set the fix command"';
759
- }
760
- }
761
- async function promptNotifications(prompts) {
762
- header(7, 7, 'Notifications');
763
- explain([
764
- 'Native OS toasts when a health check or build flips state (ok→error or',
765
- 'error→ok). Only state transitions trigger — no spam, no per-check noise.',
766
- 'You can disable them per-session inside the cockpit by pressing `n`.',
767
- ].join('\n'));
768
- const enabled = await prompts.confirm({
769
- message: 'Enable notifications?',
770
- default: true,
771
- });
772
- return { enabled };
773
- }
774
- // ─── Entry point ────────────────────────────────────────────────────────────
775
- export async function runInitWizard(opts) {
776
- const cwd = opts.cwd ?? process.cwd();
777
- const prompts = await loadPrompts();
778
- const hints = gatherHints(cwd);
779
- blank();
780
- line(chalk.bold.cyan('━━ dev-cockpit init-config wizard ━━'));
781
- blank();
782
- line(' This walks you through a starter cockpit.yaml — the file the cockpit reads on');
783
- line(' every `dev-cockpit dev` run. Seven short steps:');
784
- blank();
785
- line(chalk.dim(' 1. App name'));
786
- line(chalk.dim(' 2. Docker — compose services to tail logs from'));
787
- line(chalk.dim(' 3. Watchers — long-running processes streamed into Output'));
788
- line(chalk.dim(' 4. Repos — entries shown in the Repos pane'));
789
- line(chalk.dim(' 5. Highlights — regex patterns that paint Output lines'));
790
- line(chalk.dim(' 6. Health checks — what should be true; one-keystroke fixes'));
791
- line(chalk.dim(' 7. Notifications — native OS toasts on state changes'));
792
- blank();
793
- line(' Anything you skip you can add later by hand-editing cockpit.yaml.');
794
- line(` Press ${chalk.cyan('Ctrl-C')} to quit; press ${chalk.cyan('enter')} to accept any default.`);
795
- blank();
796
- const proceed = await prompts.confirm({ message: 'Ready?', default: true });
797
- if (!proceed) {
798
- throw new Error('wizard aborted');
799
- }
800
- const appName = await promptAppName(prompts, hints, opts.defaultAppName);
801
- const docker = await promptDocker(prompts, hints);
802
- const watchers = await promptWatchers(prompts, hints);
803
- const repos = await promptRepos(prompts, hints);
804
- const highlights = await promptHighlights(prompts);
805
- const health = await promptHealthChecks(prompts, hints, docker !== undefined);
806
- const notifications = await promptNotifications(prompts);
807
- const result = {
808
- appName,
809
- watchers,
810
- docker,
811
- repos,
812
- highlights,
813
- health,
814
- notifications,
815
- };
816
- showSummary(result);
817
- return result;
818
- }