@skitterbyte/skitterspec 2.0.1 → 3.0.0
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.
- package/LICENSE +21 -0
- package/README.md +31 -4
- package/assets/claude-md-section.md +18 -14
- package/assets/core/env.config.json.example +5 -0
- package/assets/core/env.config.md +29 -2
- package/assets/rules/spec-planning.md +22 -17
- package/assets/skills/spec/SKILL.md +13 -10
- package/assets/skills/spec-cancel/SKILL.md +11 -4
- package/assets/skills/spec-complete/SKILL.md +16 -8
- package/assets/skills/spec-connect/SKILL.md +53 -0
- package/assets/skills/spec-go/SKILL.md +28 -10
- package/assets/skills/spec-review/SKILL.md +2 -2
- package/package.json +4 -6
- package/src/cli.js +177 -3
- package/src/env/config.js +44 -0
- package/src/env/dev.js +63 -0
- package/src/env/proxy.js +159 -0
- package/src/env/supervise.js +150 -0
- package/src/init.js +2 -2
- package/assets/skills/spec-env/SKILL.md +0 -63
- package/assets/skills/spec-env-down/SKILL.md +0 -64
- package/assets/skills/spec-ready/SKILL.md +0 -50
package/src/cli.js
CHANGED
|
@@ -22,6 +22,9 @@ const { ensureWorktreeDirTrusted } = require('./env/trust.js')
|
|
|
22
22
|
const { planUp } = require('./env/provision.js')
|
|
23
23
|
const { planDown } = require('./env/teardown.js')
|
|
24
24
|
const { planIntegrate } = require('./env/integrate.js')
|
|
25
|
+
const { planDev } = require('./env/dev.js')
|
|
26
|
+
const { startProcess, stopProcess, waitHealthy } = require('./env/supervise.js')
|
|
27
|
+
const { renderRoutes, portsInUse, waitListening } = require('./env/proxy.js')
|
|
25
28
|
|
|
26
29
|
const pkg = require('../package.json')
|
|
27
30
|
|
|
@@ -36,6 +39,9 @@ Usage:
|
|
|
36
39
|
specs/.core/env.config.json). Subcommands:
|
|
37
40
|
up <spec> plan a worktree + Docker stack + opener
|
|
38
41
|
down <spec> tear down (guards; --keep-volumes, --force)
|
|
42
|
+
dev up <spec> start host dev servers on the spec's ports
|
|
43
|
+
dev down <spec> stop the spec's host dev servers
|
|
44
|
+
connect <spec> expose a spec on the canonical ports (main = off)
|
|
39
45
|
integrate <spec> plan rebase + fast-forward onto the base branch
|
|
40
46
|
status list provisioned specs + port blocks
|
|
41
47
|
resolve <spec> print resolved slug/type/branch/paths
|
|
@@ -401,9 +407,171 @@ function specEnvResolve(dir, config, specArg) {
|
|
|
401
407
|
)
|
|
402
408
|
}
|
|
403
409
|
|
|
410
|
+
// Start/stop a spec's host dev servers on its reserved port block. Host dev
|
|
411
|
+
// servers (e.g. `pnpm dev`) need a block even on a worktree-only spec, so `up`
|
|
412
|
+
// allocates a slot if the spec has none (idempotent). The planner is pure
|
|
413
|
+
// (dev.js); the spawning/killing lives in supervise.js.
|
|
414
|
+
async function specEnvDev(dir, config, positional) {
|
|
415
|
+
const action = positional[0]
|
|
416
|
+
const specArg = positional[1]
|
|
417
|
+
if ((action !== 'up' && action !== 'down') || !specArg) {
|
|
418
|
+
process.stdout.write('Usage: skitterspec spec-env dev <up|down> <spec>\n')
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
const spec = resolveSpec(specArg, dir, config)
|
|
422
|
+
if (!config.dev.length) {
|
|
423
|
+
process.stdout.write(
|
|
424
|
+
'spec-env dev: no dev processes configured — set "dev": [...] in env.config.json.\n',
|
|
425
|
+
)
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const registry = readRegistry(dir, config)
|
|
430
|
+
let slot
|
|
431
|
+
if (action === 'up') {
|
|
432
|
+
// Ensure a slot (idempotent) so the port block is reserved even worktree-only.
|
|
433
|
+
const alloc = allocateSlot(registry, spec.folder)
|
|
434
|
+
slot = alloc.slot
|
|
435
|
+
writeRegistry(dir, config, alloc.registry)
|
|
436
|
+
} else {
|
|
437
|
+
// Teardown only needs the pid-file paths (keyed by folder, not slot), so the
|
|
438
|
+
// slot value is immaterial — use the existing one, or 0 as a placeholder.
|
|
439
|
+
slot = Object.prototype.hasOwnProperty.call(registry.slots, spec.folder)
|
|
440
|
+
? registry.slots[spec.folder]
|
|
441
|
+
: 0
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const plan = planDev(spec, slot, config)
|
|
445
|
+
|
|
446
|
+
if (action === 'up') {
|
|
447
|
+
const out = [`spec-env dev up: ${spec.folder} slot ${slot} (ports from ${plan.portOffset})`]
|
|
448
|
+
for (const proc of plan.procs) {
|
|
449
|
+
const res = startProcess(proc, { cwd: spec.worktreePath, rootDir: dir })
|
|
450
|
+
let health = ''
|
|
451
|
+
if (proc.health) {
|
|
452
|
+
health = (await waitHealthy(proc.health)) ? ' health: ok' : ' health: TIMEOUT'
|
|
453
|
+
}
|
|
454
|
+
out.push(
|
|
455
|
+
` ${proc.name}: port ${proc.port} pid ${res.pid} ` +
|
|
456
|
+
`${res.started ? 'started' : 'already running'}${health}`,
|
|
457
|
+
)
|
|
458
|
+
}
|
|
459
|
+
out.push('')
|
|
460
|
+
out.push(` logs: ${stateDirLabel(config)}/logs/`)
|
|
461
|
+
process.stdout.write(out.join('\n') + '\n')
|
|
462
|
+
} else {
|
|
463
|
+
const out = [`spec-env dev down: ${spec.folder}`]
|
|
464
|
+
for (const proc of plan.procs) {
|
|
465
|
+
const res = await stopProcess(proc, { rootDir: dir })
|
|
466
|
+
out.push(` ${proc.name}: ${res.stopped ? `stopped (pid ${res.pid})` : 'not running'}`)
|
|
467
|
+
}
|
|
468
|
+
process.stdout.write(out.join('\n') + '\n')
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// The `.spec-env`-style state dir label for user-facing messages.
|
|
473
|
+
function stateDirLabel(config) {
|
|
474
|
+
return path.posix.dirname(config.registry) || '.spec-env'
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// The supervised proxy process descriptor (paths relative to the checkout root).
|
|
478
|
+
function proxyProcFor(config, routesFileAbs) {
|
|
479
|
+
const sdir = stateDirLabel(config)
|
|
480
|
+
return {
|
|
481
|
+
name: 'proxy',
|
|
482
|
+
command: `node ${path.join(__dirname, 'env', 'proxy.js')} ${routesFileAbs}`,
|
|
483
|
+
env: {},
|
|
484
|
+
logFile: `${sdir}/logs/proxy.log`,
|
|
485
|
+
pidFile: `${sdir}/pids/proxy.pid`,
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Connect the canonical origin to ONE spec (exclusive model): (re)start the
|
|
490
|
+
// bundled proxy pointing at that spec's warm dev servers. `connect main` stops
|
|
491
|
+
// the proxy so the primary checkout owns the canonical ports again.
|
|
492
|
+
async function specEnvConnect(dir, config, specArg) {
|
|
493
|
+
const sdir = stateDirLabel(config)
|
|
494
|
+
const abs = (rel) => path.resolve(dir, rel)
|
|
495
|
+
const routesFile = `${sdir}/proxy.json`
|
|
496
|
+
const connectedFile = `${sdir}/connected`
|
|
497
|
+
const proxyProc = proxyProcFor(config, abs(routesFile))
|
|
498
|
+
const target = specArg || 'main'
|
|
499
|
+
|
|
500
|
+
if (target === 'main') {
|
|
501
|
+
const res = await stopProcess(proxyProc, { rootDir: dir })
|
|
502
|
+
for (const f of [connectedFile, routesFile]) {
|
|
503
|
+
try {
|
|
504
|
+
fs.unlinkSync(abs(f))
|
|
505
|
+
} catch {
|
|
506
|
+
/* not connected */
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
process.stdout.write(
|
|
510
|
+
res.stopped
|
|
511
|
+
? 'spec-connect: disconnected — the primary checkout owns the canonical ports again.\n'
|
|
512
|
+
: 'spec-connect: nothing was connected — the primary checkout already owns the ports.\n',
|
|
513
|
+
)
|
|
514
|
+
return
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const spec = resolveSpec(target, dir, config)
|
|
518
|
+
const registry = readRegistry(dir, config)
|
|
519
|
+
if (!Object.prototype.hasOwnProperty.call(registry.slots, spec.folder)) {
|
|
520
|
+
process.stdout.write(
|
|
521
|
+
`spec-connect: ${spec.folder} has no reserved ports yet — ` +
|
|
522
|
+
`run \`skitterspec spec-env dev up ${spec.folder}\` first.\n`,
|
|
523
|
+
)
|
|
524
|
+
return
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const plan = planDev(spec, registry.slots[spec.folder], config)
|
|
528
|
+
const routes = renderRoutes(plan.procs)
|
|
529
|
+
if (!routes.length) {
|
|
530
|
+
process.stdout.write(
|
|
531
|
+
'spec-connect: no dev process declares a frontPort — nothing to expose.\n',
|
|
532
|
+
)
|
|
533
|
+
return
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Stop any proxy we already run (a previous connect), freeing the canonical
|
|
537
|
+
// ports, then refuse if the primary checkout still holds one of them.
|
|
538
|
+
await stopProcess(proxyProc, { rootDir: dir })
|
|
539
|
+
const busy = await portsInUse(routes.map((r) => r.frontPort), config.proxy.host)
|
|
540
|
+
if (busy.length) {
|
|
541
|
+
process.stdout.write(
|
|
542
|
+
`spec-connect: canonical port(s) ${busy.join(', ')} are in use (your main dev server?).\n` +
|
|
543
|
+
'Stop main on those ports, then re-run spec-connect.\n',
|
|
544
|
+
)
|
|
545
|
+
return
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
fs.mkdirSync(abs(sdir), { recursive: true })
|
|
549
|
+
fs.writeFileSync(abs(routesFile), JSON.stringify(routes, null, 2) + '\n')
|
|
550
|
+
const res = startProcess(proxyProc, { cwd: dir, rootDir: dir })
|
|
551
|
+
fs.writeFileSync(abs(connectedFile), spec.folder + '\n')
|
|
552
|
+
|
|
553
|
+
const ready = await waitListening(
|
|
554
|
+
routes.map((r) => r.frontPort),
|
|
555
|
+
{ host: config.proxy.host },
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
const out = [
|
|
559
|
+
`spec-connect: ${spec.folder} → canonical ports (proxy pid ${res.pid})` +
|
|
560
|
+
(ready ? '' : ' [WARNING: proxy did not come up — see .spec-env/logs/proxy.log]'),
|
|
561
|
+
]
|
|
562
|
+
for (const r of routes) {
|
|
563
|
+
out.push(
|
|
564
|
+
` http://${config.proxy.host}:${r.frontPort} → ${r.name} (127.0.0.1:${r.targetPort})`,
|
|
565
|
+
)
|
|
566
|
+
}
|
|
567
|
+
out.push('')
|
|
568
|
+
out.push(' Disconnect with: skitterspec spec-env connect main')
|
|
569
|
+
process.stdout.write(out.join('\n') + '\n')
|
|
570
|
+
}
|
|
571
|
+
|
|
404
572
|
// Dispatch `skitterspec spec-env <sub> [args] [--dir path]`. No-ops with a clear
|
|
405
573
|
// message when the feature isn't enabled (no specs/.core/env.config.json).
|
|
406
|
-
function specEnv(rest) {
|
|
574
|
+
async function specEnv(rest) {
|
|
407
575
|
const [sub, ...args] = rest
|
|
408
576
|
let dir = process.cwd()
|
|
409
577
|
const positional = []
|
|
@@ -432,6 +600,12 @@ function specEnv(rest) {
|
|
|
432
600
|
case 'down':
|
|
433
601
|
specEnvDown(dir, config, positional[0], flags)
|
|
434
602
|
break
|
|
603
|
+
case 'dev':
|
|
604
|
+
await specEnvDev(dir, config, positional)
|
|
605
|
+
break
|
|
606
|
+
case 'connect':
|
|
607
|
+
await specEnvConnect(dir, config, positional[0])
|
|
608
|
+
break
|
|
435
609
|
case 'integrate':
|
|
436
610
|
specEnvIntegrate(dir, config, positional[0])
|
|
437
611
|
break
|
|
@@ -443,7 +617,7 @@ function specEnv(rest) {
|
|
|
443
617
|
break
|
|
444
618
|
default:
|
|
445
619
|
process.stdout.write(
|
|
446
|
-
'Usage: skitterspec spec-env <up|down|integrate|status|resolve> [spec] [--keep-volumes] [--force]\n',
|
|
620
|
+
'Usage: skitterspec spec-env <up|down|dev|connect|integrate|status|resolve> [spec] [--keep-volumes] [--force]\n',
|
|
447
621
|
)
|
|
448
622
|
}
|
|
449
623
|
}
|
|
@@ -461,7 +635,7 @@ async function run(argv) {
|
|
|
461
635
|
const [cmd, ...rest] = argv
|
|
462
636
|
|
|
463
637
|
if (cmd === 'spec-env') {
|
|
464
|
-
specEnv(rest)
|
|
638
|
+
await specEnv(rest)
|
|
465
639
|
return
|
|
466
640
|
}
|
|
467
641
|
|
package/src/env/config.js
CHANGED
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
* worktree: { root, folderPattern },
|
|
17
17
|
* docker: { enabled, composeFile, projectNamePattern, portBase,
|
|
18
18
|
* portsPerSpec, envFile, backupCommand },
|
|
19
|
+
* dev: [ { name, command, portVar, health?, frontPort? } ], // host dev
|
|
20
|
+
* // servers started on the spec's port block (empty = none)
|
|
21
|
+
* proxy: { enabled, host }, // bundled front-door proxy (spec-env connect)
|
|
19
22
|
* open: { command }, // optional, editor/terminal-agnostic opener
|
|
20
23
|
* registry: ".spec-env/registry.json",
|
|
21
24
|
* branch: { pattern, identifierField }, // git branch naming (provider-neutral)
|
|
@@ -40,6 +43,12 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
40
43
|
envFile: '.env',
|
|
41
44
|
backupCommand: '',
|
|
42
45
|
}),
|
|
46
|
+
// Host dev servers started on the spec's port block by `spec-env dev up`.
|
|
47
|
+
// Each: { name, command, portVar, health?, frontPort? }. Default: none.
|
|
48
|
+
dev: Object.freeze([]),
|
|
49
|
+
// Front-door proxy (`spec-env connect`): a bundled Node reverse proxy that
|
|
50
|
+
// exposes one connected spec's frontPort processes on the canonical ports.
|
|
51
|
+
proxy: Object.freeze({ enabled: true, host: '127.0.0.1' }),
|
|
43
52
|
open: Object.freeze({ command: '' }),
|
|
44
53
|
registry: '.spec-env/registry.json',
|
|
45
54
|
// Git branch naming, provider-neutral. `pattern` expands {type}/{slug} and,
|
|
@@ -61,6 +70,8 @@ function defaults() {
|
|
|
61
70
|
return {
|
|
62
71
|
worktree: { ...DEFAULT_CONFIG.worktree },
|
|
63
72
|
docker: { ...DEFAULT_CONFIG.docker },
|
|
73
|
+
dev: [],
|
|
74
|
+
proxy: { ...DEFAULT_CONFIG.proxy },
|
|
64
75
|
open: { ...DEFAULT_CONFIG.open },
|
|
65
76
|
registry: DEFAULT_CONFIG.registry,
|
|
66
77
|
branch: { ...DEFAULT_CONFIG.branch },
|
|
@@ -85,6 +96,30 @@ function assign(base, parsed, key, type) {
|
|
|
85
96
|
}
|
|
86
97
|
}
|
|
87
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Normalise a parsed `dev` array into well-formed process entries. Each entry
|
|
101
|
+
* needs non-empty string `name`, `command`, and `portVar`; `health` (string) and
|
|
102
|
+
* `frontPort` (finite number) are optional. Malformed entries are dropped
|
|
103
|
+
* (lenient, like the rest of the loader) so a stray entry can't crash provisioning.
|
|
104
|
+
*/
|
|
105
|
+
function normalizeDev(parsed) {
|
|
106
|
+
const out = []
|
|
107
|
+
for (const raw of parsed) {
|
|
108
|
+
if (!isObject(raw)) continue
|
|
109
|
+
const name = typeof raw.name === 'string' ? raw.name.trim() : ''
|
|
110
|
+
const command = typeof raw.command === 'string' ? raw.command.trim() : ''
|
|
111
|
+
const portVar = typeof raw.portVar === 'string' ? raw.portVar.trim() : ''
|
|
112
|
+
if (!name || !command || !portVar) continue
|
|
113
|
+
const entry = { name, command, portVar }
|
|
114
|
+
if (typeof raw.health === 'string' && raw.health.trim()) entry.health = raw.health.trim()
|
|
115
|
+
if (typeof raw.frontPort === 'number' && Number.isFinite(raw.frontPort)) {
|
|
116
|
+
entry.frontPort = raw.frontPort
|
|
117
|
+
}
|
|
118
|
+
out.push(entry)
|
|
119
|
+
}
|
|
120
|
+
return out
|
|
121
|
+
}
|
|
122
|
+
|
|
88
123
|
/**
|
|
89
124
|
* Merge a parsed config over the defaults. Only known keys are copied (unknown
|
|
90
125
|
* keys ignored for forward-compat). Nested objects are merged field-by-field.
|
|
@@ -107,6 +142,15 @@ function mergeConfig(base, parsed) {
|
|
|
107
142
|
assign(base.docker, parsed.docker, 'backupCommand', 'string?')
|
|
108
143
|
}
|
|
109
144
|
|
|
145
|
+
if (Array.isArray(parsed.dev)) {
|
|
146
|
+
base.dev = normalizeDev(parsed.dev)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (isObject(parsed.proxy)) {
|
|
150
|
+
assign(base.proxy, parsed.proxy, 'enabled', 'boolean')
|
|
151
|
+
assign(base.proxy, parsed.proxy, 'host', 'string')
|
|
152
|
+
}
|
|
153
|
+
|
|
110
154
|
if (isObject(parsed.open)) {
|
|
111
155
|
// command may be intentionally empty (no auto-open)
|
|
112
156
|
assign(base.open, parsed.open, 'command', 'string?')
|
package/src/env/dev.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure planner for host dev-process supervision (`spec-env dev up|down`).
|
|
5
|
+
*
|
|
6
|
+
* Given a resolved spec and its allocated slot, `planDev` computes — for each
|
|
7
|
+
* configured `dev` process — its resolved port, the env var to inject, the log
|
|
8
|
+
* and pid file paths, the expanded health URL, and the canonical `frontPort` a
|
|
9
|
+
* later proxy (Phase 2) routes to it. No side effects: the CLI's `supervise.js`
|
|
10
|
+
* does the spawning/killing; this stays unit-testable with no processes.
|
|
11
|
+
*
|
|
12
|
+
* Port math: process `i` in the slot's block gets `portBase + slot*portsPerSpec
|
|
13
|
+
* + i` (reusing the same block the Docker stack draws from — see registry.js).
|
|
14
|
+
* So host dev servers get a reserved block even on a worktree-only spec.
|
|
15
|
+
*
|
|
16
|
+
* Log/pid files live beside the registry (default `.spec-env/`) and are keyed by
|
|
17
|
+
* the spec **folder** (not the bare slug) so a `feat-foo`/`bug-foo` pair can't
|
|
18
|
+
* collide — matching how the registry keys slots.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const path = require('node:path')
|
|
22
|
+
const { portOffset } = require('./registry.js')
|
|
23
|
+
const { expandTokens } = require('./resolve.js')
|
|
24
|
+
|
|
25
|
+
// The state dir (logs + pids) sits beside the registry file, e.g. `.spec-env`.
|
|
26
|
+
function stateDir(config) {
|
|
27
|
+
return path.posix.dirname(config.registry) || '.spec-env'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Plan the host dev processes for `spec` at `slot`.
|
|
32
|
+
*
|
|
33
|
+
* @returns {object} { slot, portOffset, procs: [{ name, command, port, portVar,
|
|
34
|
+
* env, frontPort, logFile, pidFile, health }] } — logFile/pidFile are paths
|
|
35
|
+
* relative to the primary checkout root (the CLI resolves them).
|
|
36
|
+
*/
|
|
37
|
+
function planDev(spec, slot, config) {
|
|
38
|
+
const base = portOffset(slot, config)
|
|
39
|
+
const dir = stateDir(config)
|
|
40
|
+
const procs = (config.dev || []).map((entry, i) => {
|
|
41
|
+
const port = base + i
|
|
42
|
+
const tokens = {
|
|
43
|
+
[entry.portVar]: String(port),
|
|
44
|
+
port: String(port),
|
|
45
|
+
slug: spec.slug,
|
|
46
|
+
name: entry.name,
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
name: entry.name,
|
|
50
|
+
command: expandTokens(entry.command, tokens),
|
|
51
|
+
port,
|
|
52
|
+
portVar: entry.portVar,
|
|
53
|
+
env: { [entry.portVar]: String(port) },
|
|
54
|
+
frontPort: typeof entry.frontPort === 'number' ? entry.frontPort : null,
|
|
55
|
+
logFile: `${dir}/logs/${spec.folder}-${entry.name}.log`,
|
|
56
|
+
pidFile: `${dir}/pids/${spec.folder}-${entry.name}.pid`,
|
|
57
|
+
health: entry.health ? expandTokens(entry.health, tokens) : null,
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
return { slot, portOffset: base, procs }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { planDev, stateDir }
|
package/src/env/proxy.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Front-door reverse proxy for `spec-env connect` — a small, dependency-free
|
|
5
|
+
* Node proxy that exposes the ONE connected spec's warm dev servers on the
|
|
6
|
+
* canonical ports (exclusive model: no cookie, one target at a time).
|
|
7
|
+
*
|
|
8
|
+
* `renderRoutes` is pure (procs → routes). `startProxy` builds one `http` server
|
|
9
|
+
* per route, forwarding HTTP and `upgrade` (WebSocket/HMR) to the spec's port.
|
|
10
|
+
* Run directly (`node proxy.js <routesFile>`) it becomes the long-lived,
|
|
11
|
+
* detached proxy process the CLI supervises with the Phase 1 seam.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const http = require('node:http')
|
|
15
|
+
const net = require('node:net')
|
|
16
|
+
const fs = require('node:fs')
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The connected spec's `dev` processes that declare a `frontPort` → proxy routes.
|
|
20
|
+
* @param {Array} procs from `planDev(...).procs`
|
|
21
|
+
* @returns {Array<{name, frontPort, targetPort}>}
|
|
22
|
+
*/
|
|
23
|
+
function renderRoutes(procs) {
|
|
24
|
+
return (procs || [])
|
|
25
|
+
.filter((p) => typeof p.frontPort === 'number')
|
|
26
|
+
.map((p) => ({ name: p.name, frontPort: p.frontPort, targetPort: p.port }))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Build (but don't listen on) one proxy server: frontPort → targetPort. Forwards
|
|
30
|
+
// regular requests and transparently pipes `upgrade` (WebSocket) connections.
|
|
31
|
+
function createRouteServer(route, host = '127.0.0.1') {
|
|
32
|
+
const { targetPort } = route
|
|
33
|
+
|
|
34
|
+
const server = http.createServer((req, res) => {
|
|
35
|
+
const upstream = http.request(
|
|
36
|
+
{ host, port: targetPort, method: req.method, path: req.url, headers: req.headers },
|
|
37
|
+
(ur) => {
|
|
38
|
+
res.writeHead(ur.statusCode || 502, ur.headers)
|
|
39
|
+
ur.pipe(res)
|
|
40
|
+
},
|
|
41
|
+
)
|
|
42
|
+
upstream.on('error', () => {
|
|
43
|
+
if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain' })
|
|
44
|
+
res.end(`spec-connect: upstream 127.0.0.1:${targetPort} not reachable\n`)
|
|
45
|
+
})
|
|
46
|
+
req.pipe(upstream)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// WebSocket / HTTP upgrade passthrough — reconstruct the request line + headers
|
|
50
|
+
// onto a raw TCP connection to the upstream, then pipe both ways.
|
|
51
|
+
server.on('upgrade', (req, socket, head) => {
|
|
52
|
+
const upstream = net.connect(targetPort, host, () => {
|
|
53
|
+
let raw = `${req.method} ${req.url} HTTP/1.1\r\n`
|
|
54
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
|
55
|
+
raw += `${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`
|
|
56
|
+
}
|
|
57
|
+
raw += '\r\n'
|
|
58
|
+
upstream.write(raw)
|
|
59
|
+
if (head && head.length) upstream.write(head)
|
|
60
|
+
socket.pipe(upstream)
|
|
61
|
+
upstream.pipe(socket)
|
|
62
|
+
})
|
|
63
|
+
upstream.on('error', () => socket.destroy())
|
|
64
|
+
socket.on('error', () => upstream.destroy())
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
return server
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Start every route's server listening. Returns `{ servers, close() }` where
|
|
72
|
+
* `close()` resolves once all servers are shut. Rejects (via the returned
|
|
73
|
+
* promise on the server 'error') if a port can't be bound.
|
|
74
|
+
*/
|
|
75
|
+
function startProxy(routes, { host = '127.0.0.1' } = {}) {
|
|
76
|
+
const servers = routes.map((route) => {
|
|
77
|
+
const server = createRouteServer(route, host)
|
|
78
|
+
server.listen(route.frontPort, host)
|
|
79
|
+
return server
|
|
80
|
+
})
|
|
81
|
+
return {
|
|
82
|
+
servers,
|
|
83
|
+
close() {
|
|
84
|
+
return Promise.all(
|
|
85
|
+
servers.map((s) => new Promise((resolve) => s.close(() => resolve()))),
|
|
86
|
+
)
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Which of `ports` are already bound (so `connect` can tell the operator to stop
|
|
93
|
+
* main first). Resolves to the list of busy ports. `host` scopes the check.
|
|
94
|
+
*/
|
|
95
|
+
function portsInUse(ports, host = '127.0.0.1') {
|
|
96
|
+
return Promise.all(
|
|
97
|
+
ports.map(
|
|
98
|
+
(port) =>
|
|
99
|
+
new Promise((resolve) => {
|
|
100
|
+
const tester = net
|
|
101
|
+
.createServer()
|
|
102
|
+
.once('error', () => resolve(port)) // EADDRINUSE → busy
|
|
103
|
+
.once('listening', () => tester.close(() => resolve(null)))
|
|
104
|
+
.listen(port, host)
|
|
105
|
+
}),
|
|
106
|
+
),
|
|
107
|
+
).then((results) => results.filter((p) => p !== null))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Does `port` accept a TCP connection right now?
|
|
111
|
+
function checkListening(port, host) {
|
|
112
|
+
return new Promise((resolve) => {
|
|
113
|
+
const sock = net.connect(port, host)
|
|
114
|
+
sock.once('connect', () => {
|
|
115
|
+
sock.destroy()
|
|
116
|
+
resolve(true)
|
|
117
|
+
})
|
|
118
|
+
sock.once('error', () => {
|
|
119
|
+
sock.destroy()
|
|
120
|
+
resolve(false)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Poll until every port in `ports` accepts a connection (the detached proxy has
|
|
127
|
+
* finished binding), or `timeoutMs` elapses. Resolves true when all are up.
|
|
128
|
+
* `now`/`wait` are injectable for tests.
|
|
129
|
+
*/
|
|
130
|
+
async function waitListening(
|
|
131
|
+
ports,
|
|
132
|
+
{ host = '127.0.0.1', timeoutMs = 3000, intervalMs = 50, now = () => Date.now(), wait } = {},
|
|
133
|
+
) {
|
|
134
|
+
const sleep = wait || ((ms) => new Promise((r) => setTimeout(r, ms)))
|
|
135
|
+
const deadline = now() + timeoutMs
|
|
136
|
+
while (now() < deadline) {
|
|
137
|
+
const results = await Promise.all(ports.map((p) => checkListening(p, host)))
|
|
138
|
+
if (results.every(Boolean)) return true
|
|
139
|
+
await sleep(intervalMs)
|
|
140
|
+
}
|
|
141
|
+
return false
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { renderRoutes, createRouteServer, startProxy, portsInUse, waitListening }
|
|
145
|
+
|
|
146
|
+
// Entry point: run as a detached process by the CLI. Reads its routes from a
|
|
147
|
+
// file so a re-`connect` just rewrites the file and restarts this (tiny) process.
|
|
148
|
+
if (require.main === module) {
|
|
149
|
+
const routesFile = process.argv[2]
|
|
150
|
+
if (!routesFile) {
|
|
151
|
+
process.stderr.write('proxy: usage: node proxy.js <routesFile>\n')
|
|
152
|
+
process.exit(1)
|
|
153
|
+
}
|
|
154
|
+
const routes = JSON.parse(fs.readFileSync(routesFile, 'utf-8'))
|
|
155
|
+
const { close } = startProxy(routes)
|
|
156
|
+
const shutdown = () => close().then(() => process.exit(0))
|
|
157
|
+
process.on('SIGTERM', shutdown)
|
|
158
|
+
process.on('SIGINT', shutdown)
|
|
159
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Process-supervision IO for host dev servers — the side-effecting seam the CLI
|
|
5
|
+
* drives (the planning lives in the pure `dev.js`). Keeps `cli.js` thin and lets
|
|
6
|
+
* start/stop/health be exercised against a fixture server in tests.
|
|
7
|
+
*
|
|
8
|
+
* A "proc" here is one entry from `planDev(...).procs` (it carries `command`,
|
|
9
|
+
* `env`, `logFile`, `pidFile`, `health`). Log/pid paths are relative to the
|
|
10
|
+
* primary checkout root, resolved against `rootDir`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('node:fs')
|
|
14
|
+
const path = require('node:path')
|
|
15
|
+
const { spawn } = require('node:child_process')
|
|
16
|
+
|
|
17
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
18
|
+
|
|
19
|
+
function resolveState(rootDir, rel) {
|
|
20
|
+
return path.resolve(rootDir, rel)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Is `pid` a live process? `kill(pid, 0)` probes without signalling.
|
|
24
|
+
function isAlive(pid) {
|
|
25
|
+
try {
|
|
26
|
+
process.kill(pid, 0)
|
|
27
|
+
return true
|
|
28
|
+
} catch (err) {
|
|
29
|
+
// EPERM → exists but not ours; still "alive" for our purposes.
|
|
30
|
+
return err.code === 'EPERM'
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Signal a detached process's whole group (its leader pid is `pid`, so the group
|
|
35
|
+
// id is `-pid`) — reaches children like vite/tsc that a bare `pnpm dev` spawns.
|
|
36
|
+
// Falls back to signalling just the leader if the group send fails.
|
|
37
|
+
function signalGroup(pid, sig) {
|
|
38
|
+
try {
|
|
39
|
+
process.kill(-pid, sig)
|
|
40
|
+
} catch {
|
|
41
|
+
try {
|
|
42
|
+
process.kill(pid, sig)
|
|
43
|
+
} catch {
|
|
44
|
+
/* already gone */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Read a pid from its file (absolute path). null when missing/malformed.
|
|
50
|
+
function readPid(pidFileAbs) {
|
|
51
|
+
try {
|
|
52
|
+
const n = Number(fs.readFileSync(pidFileAbs, 'utf-8').trim())
|
|
53
|
+
return Number.isInteger(n) && n > 0 ? n : null
|
|
54
|
+
} catch {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Start one planned process detached, appending stdout+stderr to its log and
|
|
61
|
+
* writing its pid file. Idempotent: if the pid file already names a live
|
|
62
|
+
* process, nothing is spawned — returns `{ started: false, pid }`.
|
|
63
|
+
*
|
|
64
|
+
* `spawnImpl` is injectable for tests; defaults to child_process.spawn.
|
|
65
|
+
*/
|
|
66
|
+
function startProcess(proc, { cwd, rootDir, spawnImpl = spawn }) {
|
|
67
|
+
const logAbs = resolveState(rootDir, proc.logFile)
|
|
68
|
+
const pidAbs = resolveState(rootDir, proc.pidFile)
|
|
69
|
+
|
|
70
|
+
const existing = readPid(pidAbs)
|
|
71
|
+
if (existing && isAlive(existing)) {
|
|
72
|
+
return { started: false, pid: existing, logFile: logAbs, pidFile: pidAbs }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
fs.mkdirSync(path.dirname(logAbs), { recursive: true })
|
|
76
|
+
fs.mkdirSync(path.dirname(pidAbs), { recursive: true })
|
|
77
|
+
|
|
78
|
+
const out = fs.openSync(logAbs, 'a')
|
|
79
|
+
try {
|
|
80
|
+
const child = spawnImpl('sh', ['-c', proc.command], {
|
|
81
|
+
cwd,
|
|
82
|
+
env: { ...process.env, ...proc.env },
|
|
83
|
+
detached: true,
|
|
84
|
+
stdio: ['ignore', out, out],
|
|
85
|
+
})
|
|
86
|
+
child.unref()
|
|
87
|
+
fs.writeFileSync(pidAbs, `${child.pid}\n`)
|
|
88
|
+
return { started: true, pid: child.pid, logFile: logAbs, pidFile: pidAbs }
|
|
89
|
+
} finally {
|
|
90
|
+
fs.closeSync(out)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Stop one planned process: SIGTERM its pid, wait up to `graceMs` for it to
|
|
96
|
+
* exit, then SIGKILL if still alive. Removes the pid file either way. Idempotent
|
|
97
|
+
* — a missing/dead pid is a clean no-op. Returns `{ stopped, pid }`.
|
|
98
|
+
*/
|
|
99
|
+
async function stopProcess(proc, { rootDir, graceMs = 3000, now = () => Date.now(), wait = sleep } = {}) {
|
|
100
|
+
const pidAbs = resolveState(rootDir, proc.pidFile)
|
|
101
|
+
const pid = readPid(pidAbs)
|
|
102
|
+
let stopped = false
|
|
103
|
+
|
|
104
|
+
if (pid && isAlive(pid)) {
|
|
105
|
+
signalGroup(pid, 'SIGTERM')
|
|
106
|
+
const deadline = now() + graceMs
|
|
107
|
+
while (now() < deadline && isAlive(pid)) await wait(100)
|
|
108
|
+
if (isAlive(pid)) signalGroup(pid, 'SIGKILL')
|
|
109
|
+
stopped = true
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
fs.unlinkSync(pidAbs)
|
|
114
|
+
} catch {
|
|
115
|
+
/* no pid file → nothing to clean */
|
|
116
|
+
}
|
|
117
|
+
return { stopped, pid }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Poll `url` until it answers (any HTTP status counts as "up") or `timeoutMs`
|
|
122
|
+
* elapses. Returns true when reachable, false on timeout. A null/empty url means
|
|
123
|
+
* "no health gate" → true immediately. `fetchImpl`/`now`/`wait` are injectable
|
|
124
|
+
* for deterministic tests.
|
|
125
|
+
*/
|
|
126
|
+
async function waitHealthy(
|
|
127
|
+
url,
|
|
128
|
+
{ timeoutMs = 30000, intervalMs = 500, fetchImpl = fetch, now = () => Date.now(), wait = sleep } = {},
|
|
129
|
+
) {
|
|
130
|
+
if (!url) return true
|
|
131
|
+
const deadline = now() + timeoutMs
|
|
132
|
+
while (now() < deadline) {
|
|
133
|
+
try {
|
|
134
|
+
const ctrl = new AbortController()
|
|
135
|
+
const t = setTimeout(() => ctrl.abort(), intervalMs)
|
|
136
|
+
try {
|
|
137
|
+
await fetchImpl(url, { signal: ctrl.signal })
|
|
138
|
+
return true
|
|
139
|
+
} finally {
|
|
140
|
+
clearTimeout(t)
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
/* not up yet */
|
|
144
|
+
}
|
|
145
|
+
await wait(intervalMs)
|
|
146
|
+
}
|
|
147
|
+
return false
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = { startProcess, stopProcess, waitHealthy, isAlive, readPid }
|
package/src/init.js
CHANGED
|
@@ -275,8 +275,8 @@ function printReport(dir, mode) {
|
|
|
275
275
|
: 'Per-spec isolation is opt-in: re-run with --isolation (or copy' +
|
|
276
276
|
' specs/.core/env.config.json.example → env.config.json) to enable it.\n'
|
|
277
277
|
process.stdout.write(
|
|
278
|
-
'\nDone. Skills resolve as /spec, /spec-
|
|
279
|
-
' /spec-
|
|
278
|
+
'\nDone. Skills resolve as /spec, /spec-go, /spec-complete, /spec-cancel,' +
|
|
279
|
+
' /spec-bug, /spec-review, /spec-init, /spec-connect.\n' +
|
|
280
280
|
'Next: tailor .claude/rules/spec-planning.md + the CLAUDE.md section to this' +
|
|
281
281
|
" project's stack, then run /spec.\n" +
|
|
282
282
|
isolationNote,
|