Skip to content

Commit b517131

Browse files
authored
Merge pull request #93 from hyldmo/hyldmo/optional-process-config
2 parents 4758f60 + ba8b0fc commit b517131

File tree

7 files changed

+101
-0
lines changed

7 files changed

+101
-0
lines changed

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ src/
4343
- Non-interactive panes hide the terminal cursor
4444
- Set `errorMatcher: true` to detect ANSI red output, or a regex string to match custom patterns — shows a red indicator on the tab while the process keeps running
4545
- `readyPattern` accepts `string` (simple match) or `RegExp` (match + capture groups). RegExp captures are expanded into dependent `command` and `env` values via `$dep.group` syntax (e.g. `$odoo.url`)
46+
- `optional: true` makes a process visible as a tab but not auto-started (starts in `stopped` state). Alt+S starts it manually. Unlike `condition`, optional does not cascade to dependents
4647
- `platform` restricts a process to specific OS(es) (e.g. `'darwin'`, `'linux'`). Non-matching processes are removed; their dependents still start with the dependency stripped (unlike `condition` which cascades)
4748

4849
## Commits

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ Each process accepts:
256256
| `readyTimeout` | `number` || Milliseconds to wait for `readyPattern` before failing |
257257
| `maxRestarts` | `number` | `0` | Max auto-restart attempts on non-zero exit (0 = no restarts) |
258258
| `delay` | `number` || Milliseconds to wait before starting the process |
259+
| `optional` | `boolean` | `false` | Process is visible as a tab but not started automatically. Use Alt+S to start manually |
259260
| `condition` | `string` || Env var name; process skipped if falsy. Prefix with `!` to negate |
260261
| `platform` | `string \| string[]` || OS(es) this process runs on (e.g. `'darwin'`, `'linux'`). Non-matching processes are removed; dependents still start |
261262
| `stopSignal` | `string` | `SIGTERM` | Signal for graceful stop (`SIGTERM`, `SIGINT`, or `SIGHUP`) |
@@ -360,6 +361,24 @@ export default defineConfig({
360361

361362
Falsy values: unset, empty string, `"0"`, `"false"`, `"no"`, `"off"` (case-insensitive). If a conditional process is skipped, its dependents are also skipped.
362363

364+
### Optional processes
365+
366+
Use `optional` for tools you want visible in tabs but not auto-started (e.g. Prisma Studio, debug servers):
367+
368+
```ts
369+
export default defineConfig({
370+
processes: {
371+
app: { command: 'bun run dev' },
372+
studio: {
373+
command: 'bunx prisma studio',
374+
optional: true, // shows as stopped tab, start with Alt+S
375+
},
376+
},
377+
})
378+
```
379+
380+
Unlike `condition`, optional processes don't cascade — their dependents still start normally.
381+
363382
### Dependency orchestration
364383

365384
Each process starts as soon as its declared `dependsOn` dependencies are ready — it does not wait for unrelated processes. If a process fails, its dependents are skipped.

src/config/validator.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,24 @@ describe('validateConfig', () => {
4949
expect(() => validateConfig({ processes: {} })).toThrow('at least one process')
5050
})
5151

52+
test('passes through optional: true', () => {
53+
const config = validateConfig({
54+
processes: {
55+
studio: { command: 'prisma studio', optional: true }
56+
}
57+
})
58+
expect(config.processes.studio.optional).toBe(true)
59+
})
60+
61+
test('ignores non-boolean optional values', () => {
62+
const config = validateConfig({
63+
processes: {
64+
studio: { command: 'prisma studio', optional: 'yes' }
65+
}
66+
})
67+
expect(config.processes.studio.optional).toBeUndefined()
68+
})
69+
5270
test('throws on missing command', () => {
5371
expect(() => validateConfig({ processes: { web: {} } })).toThrow('non-empty "command" string')
5472
})

src/config/validator.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ export function validateConfig(raw: unknown, _warnings?: ValidationWarning[]): R
149149

150150
validated[name] = {
151151
command: p.command,
152+
...(p.optional === true ? { optional: true } : {}),
152153
cwd: processCwd ?? globalCwd,
153154
env: globalEnv || processEnv ? { ...globalEnv, ...processEnv } : undefined,
154155
envFile: processEnvFile ?? globalEnvFile,

src/process/manager.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,58 @@ describe('ProcessManager — delay', () => {
741741
}, 5000)
742742
})
743743

744+
describe('ProcessManager — optional', () => {
745+
test('optional process starts as stopped', async () => {
746+
const config: ResolvedNumuxConfig = {
747+
processes: {
748+
studio: { command: 'sleep 60', optional: true }
749+
}
750+
}
751+
const mgr = new ProcessManager(config)
752+
await mgr.startAll(80, 24)
753+
754+
expect(mgr.getState('studio')?.status).toBe('stopped')
755+
await mgr.stopAll()
756+
}, 5000)
757+
758+
test('optional process can be manually started', async () => {
759+
const config: ResolvedNumuxConfig = {
760+
processes: {
761+
studio: { command: 'true', optional: true }
762+
}
763+
}
764+
const mgr = new ProcessManager(config)
765+
await mgr.startAll(80, 24)
766+
767+
expect(mgr.getState('studio')?.status).toBe('stopped')
768+
769+
mgr.start('studio', 80, 24)
770+
await new Promise(r => setTimeout(r, 500))
771+
772+
const status = mgr.getState('studio')?.status
773+
expect(status === 'finished' || status === 'ready').toBe(true)
774+
await mgr.stopAll()
775+
}, 5000)
776+
777+
test('optional process does not block dependents', async () => {
778+
const config: ResolvedNumuxConfig = {
779+
processes: {
780+
studio: { command: 'sleep 60', optional: true },
781+
child: { command: 'true', dependsOn: ['studio'] }
782+
}
783+
}
784+
const mgr = new ProcessManager(config)
785+
await mgr.startAll(80, 24)
786+
787+
expect(mgr.getState('studio')?.status).toBe('stopped')
788+
// child should have started and finished — not blocked by optional dep
789+
const childStatus = mgr.getState('child')?.status
790+
expect(childStatus).not.toBe('pending')
791+
expect(childStatus).not.toBe('skipped')
792+
await mgr.stopAll()
793+
}, 5000)
794+
})
795+
744796
describe('ProcessManager — condition', () => {
745797
test('skips process when condition env var is unset', async () => {
746798
delete process.env.NUMUX_TEST_CONDITION

src/process/manager.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,14 @@ export class ProcessManager {
9393
const proc = this.config.processes[name]
9494
const resolve = readyResolvers.get(name)!
9595

96+
// Optional processes start as stopped — resolve immediately so dependents aren't blocked
97+
if (proc.optional) {
98+
this.updateStatus(name, 'stopped')
99+
this.createRunner(name)
100+
resolve()
101+
return
102+
}
103+
96104
// Wait for declared dependencies only
97105
const deps = proc.dependsOn ?? []
98106
if (deps.length > 0) {

src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export interface NumuxProcessConfig<K extends string = string> {
4444
* @default false
4545
*/
4646
interactive?: boolean
47+
/** Process is visible but not started automatically. Use Alt+S to start manually */
48+
optional?: boolean
4749
/** `true` = detect ANSI red output, string = regex pattern */
4850
errorMatcher?: boolean | string
4951
/**

0 commit comments

Comments
 (0)