@o/run 0.16.19
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/package.json +49 -0
- package/src/helpers/handleProcessExit.ts +230 -0
- package/src/helpers/process-cleanup.test.ts +150 -0
- package/src/helpers/process-state.ts +26 -0
- package/src/helpers/resolveScript.test.ts +340 -0
- package/src/helpers/resolveScript.ts +225 -0
- package/src/node-version-check.ts +55 -0
- package/src/process-cleanup.test.ts +255 -0
- package/src/run-pty-supervision.test.ts +110 -0
- package/src/run-pty.mjs +455 -0
- package/src/run-supervision.test.ts +94 -0
- package/src/run.ts +546 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { flattenScripts, splitCompound } from './resolveScript'
|
|
4
|
+
|
|
5
|
+
// scripts modeled after the real chat package.json
|
|
6
|
+
const scripts: Record<string, string> = {
|
|
7
|
+
dev: 'bun tko run-all --flags=last backend:delayed agent-gateway frontend',
|
|
8
|
+
'backend:delayed': 'sleep 5 && bun backend',
|
|
9
|
+
backend: 'bun tko run backend:up backend:migrate-then-zero jobs',
|
|
10
|
+
'backend:up': 'bun env:dev docker compose up pgdb minio',
|
|
11
|
+
'backend:migrate-then-zero':
|
|
12
|
+
'bun backend:migrate && bun env:dev docker compose up zero',
|
|
13
|
+
'backend:migrate': 'ALLOW_MISSING_ENV=1 bun run:dev scripts/dev/migrate.ts',
|
|
14
|
+
frontend: 'tko run-all --flags=last dev:kill-orphans watch-lazy dev:tunnel one:dev',
|
|
15
|
+
'dev:kill-orphans': 'tko dev kill-orphans',
|
|
16
|
+
'watch-lazy': 'sleep 8 && bun watch',
|
|
17
|
+
watch: 'tko run types:watch build:watch apps:watch',
|
|
18
|
+
'types:watch': 'bun check types --watch',
|
|
19
|
+
'build:watch': 'tko run --no-root watch',
|
|
20
|
+
'apps:watch': 'tko apps build -- --watch',
|
|
21
|
+
'dev:tunnel': 'tko dev tunnel --setup',
|
|
22
|
+
'one:dev': 'bun clear-ports --only web && bun run:dev one dev --port 8081',
|
|
23
|
+
'agent-gateway': 'tko dev agent-gateway',
|
|
24
|
+
jobs: 'ALLOW_MISSING_ENV=1 bun run:dev scripts/dev/jobs.ts',
|
|
25
|
+
lite: 'bun run:dev tko run-all --pty --flags=last watch-lazy lite:backend one:dev agent-gateway',
|
|
26
|
+
'lite:backend': 'bun run:dev orez --data-dir=.orez',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('flattenScripts', () => {
|
|
30
|
+
it('passes through leaf commands that are not tko run', () => {
|
|
31
|
+
const result = flattenScripts(['jobs'], scripts)
|
|
32
|
+
expect(result).toEqual([
|
|
33
|
+
{
|
|
34
|
+
name: 'jobs',
|
|
35
|
+
command: 'ALLOW_MISSING_ENV=1 bun run:dev scripts/dev/jobs.ts',
|
|
36
|
+
isRaw: false,
|
|
37
|
+
},
|
|
38
|
+
])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('parses compound and inlines bun script references', () => {
|
|
42
|
+
// backend:delayed = "sleep 5 && bun backend"
|
|
43
|
+
// backend = "bun tko run backend:up backend:migrate-then-zero jobs" (tko run)
|
|
44
|
+
// since "bun backend" is last segment and resolves to tko run, it becomes deferredScripts
|
|
45
|
+
const result = flattenScripts(['backend:delayed'], scripts)
|
|
46
|
+
expect(result).toEqual([
|
|
47
|
+
{
|
|
48
|
+
name: 'backend:delayed',
|
|
49
|
+
command: 'sleep 5',
|
|
50
|
+
isRaw: false,
|
|
51
|
+
deferredScripts: [
|
|
52
|
+
{
|
|
53
|
+
name: 'backend:up',
|
|
54
|
+
command: 'bun env:dev docker compose up pgdb minio',
|
|
55
|
+
isRaw: false,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'backend:migrate-then-zero',
|
|
59
|
+
command:
|
|
60
|
+
'ALLOW_MISSING_ENV=1 bun run:dev scripts/dev/migrate.ts && bun env:dev docker compose up zero',
|
|
61
|
+
isRaw: false,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'jobs',
|
|
65
|
+
command: 'ALLOW_MISSING_ENV=1 bun run:dev scripts/dev/jobs.ts',
|
|
66
|
+
isRaw: false,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
])
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('resolves bun tko run into child scripts, inlines compounds', () => {
|
|
74
|
+
const result = flattenScripts(['backend'], scripts)
|
|
75
|
+
expect(result).toEqual([
|
|
76
|
+
{
|
|
77
|
+
name: 'backend:up',
|
|
78
|
+
command: 'bun env:dev docker compose up pgdb minio',
|
|
79
|
+
isRaw: false,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'backend:migrate-then-zero',
|
|
83
|
+
// "bun backend:migrate" inlined to its command, "bun env:dev docker compose up zero" not a known script
|
|
84
|
+
command:
|
|
85
|
+
'ALLOW_MISSING_ENV=1 bun run:dev scripts/dev/migrate.ts && bun env:dev docker compose up zero',
|
|
86
|
+
isRaw: false,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: 'jobs',
|
|
90
|
+
command: 'ALLOW_MISSING_ENV=1 bun run:dev scripts/dev/jobs.ts',
|
|
91
|
+
isRaw: false,
|
|
92
|
+
},
|
|
93
|
+
])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('resolves tko run-all and parses compounds within children', () => {
|
|
97
|
+
const result = flattenScripts(['frontend'], scripts)
|
|
98
|
+
expect(result).toEqual([
|
|
99
|
+
{ name: 'dev:kill-orphans', command: 'tko dev kill-orphans', isRaw: false },
|
|
100
|
+
{
|
|
101
|
+
name: 'watch-lazy',
|
|
102
|
+
command: 'sleep 8',
|
|
103
|
+
isRaw: false,
|
|
104
|
+
deferredScripts: [
|
|
105
|
+
{ name: 'types:watch', command: 'bun check types --watch', isRaw: false },
|
|
106
|
+
// build:watch → tko run --no-root watch → treated as leaf (no root expansion)
|
|
107
|
+
{ name: 'build:watch', command: 'tko run --no-root watch', isRaw: false },
|
|
108
|
+
{ name: 'apps:watch', command: 'tko apps build -- --watch', isRaw: false },
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
{ name: 'dev:tunnel', command: 'tko dev tunnel --setup', isRaw: false },
|
|
112
|
+
{
|
|
113
|
+
name: 'one:dev',
|
|
114
|
+
command: 'bun clear-ports --only web && bun run:dev one dev --port 8081',
|
|
115
|
+
isRaw: false,
|
|
116
|
+
},
|
|
117
|
+
])
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('resolves nested tko run chains', () => {
|
|
121
|
+
// watch -> tko run types:watch build:watch apps:watch
|
|
122
|
+
// build:watch -> tko run --no-root watch -> treated as leaf (no root expansion)
|
|
123
|
+
const result = flattenScripts(['watch'], scripts)
|
|
124
|
+
expect(result).toEqual([
|
|
125
|
+
{ name: 'types:watch', command: 'bun check types --watch', isRaw: false },
|
|
126
|
+
{ name: 'build:watch', command: 'tko run --no-root watch', isRaw: false },
|
|
127
|
+
{ name: 'apps:watch', command: 'tko apps build -- --watch', isRaw: false },
|
|
128
|
+
])
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('handles multiple input commands', () => {
|
|
132
|
+
const result = flattenScripts(['jobs', 'agent-gateway'], scripts)
|
|
133
|
+
expect(result).toEqual([
|
|
134
|
+
{
|
|
135
|
+
name: 'jobs',
|
|
136
|
+
command: 'ALLOW_MISSING_ENV=1 bun run:dev scripts/dev/jobs.ts',
|
|
137
|
+
isRaw: false,
|
|
138
|
+
},
|
|
139
|
+
{ name: 'agent-gateway', command: 'tko dev agent-gateway', isRaw: false },
|
|
140
|
+
])
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('handles scripts not found in package.json as raw commands', () => {
|
|
144
|
+
const result = flattenScripts(['nonexistent'], scripts)
|
|
145
|
+
expect(result).toEqual([{ name: 'nonexistent', command: 'nonexistent', isRaw: true }])
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('detects cycles and stops recursion', () => {
|
|
149
|
+
const cyclicScripts: Record<string, string> = {
|
|
150
|
+
a: 'tko run b',
|
|
151
|
+
b: 'tko run a',
|
|
152
|
+
}
|
|
153
|
+
const result = flattenScripts(['a'], cyclicScripts)
|
|
154
|
+
// a -> b -> a (cycle). when 'a' is encountered again, it's emitted as a leaf
|
|
155
|
+
expect(result).toEqual([{ name: 'a', command: 'tko run b', isRaw: false }])
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('strips --flags=last and --pty from tko commands when extracting children', () => {
|
|
159
|
+
// verify flags like --flags=last don't appear as script names
|
|
160
|
+
const simpleScripts: Record<string, string> = {
|
|
161
|
+
parent: 'bun tko run-all --flags=last --pty child1 child2',
|
|
162
|
+
child1: 'echo one',
|
|
163
|
+
child2: 'echo two',
|
|
164
|
+
}
|
|
165
|
+
const result = flattenScripts(['parent'], simpleScripts)
|
|
166
|
+
expect(result).toEqual([
|
|
167
|
+
{ name: 'child1', command: 'echo one', isRaw: false },
|
|
168
|
+
{ name: 'child2', command: 'echo two', isRaw: false },
|
|
169
|
+
])
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('treats --no-root tko run as a leaf command (no root expansion)', () => {
|
|
173
|
+
const simpleScripts: Record<string, string> = {
|
|
174
|
+
parent: 'bun tko run-all --no-root --flags=last child1 child2',
|
|
175
|
+
child1: 'echo one',
|
|
176
|
+
child2: 'echo two',
|
|
177
|
+
}
|
|
178
|
+
const result = flattenScripts(['parent'], simpleScripts)
|
|
179
|
+
// --no-root means this runs in workspace packages only, don't expand against root
|
|
180
|
+
expect(result).toEqual([
|
|
181
|
+
{
|
|
182
|
+
name: 'parent',
|
|
183
|
+
command: 'bun tko run-all --no-root --flags=last child1 child2',
|
|
184
|
+
isRaw: false,
|
|
185
|
+
},
|
|
186
|
+
])
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('filters out scripts listed in BUN_RUN_SCRIPTS', () => {
|
|
190
|
+
const result = flattenScripts(['backend'], scripts, {
|
|
191
|
+
runningScripts: ['backend:up'],
|
|
192
|
+
})
|
|
193
|
+
expect(result).toEqual([
|
|
194
|
+
{
|
|
195
|
+
name: 'backend:migrate-then-zero',
|
|
196
|
+
command:
|
|
197
|
+
'ALLOW_MISSING_ENV=1 bun run:dev scripts/dev/migrate.ts && bun env:dev docker compose up zero',
|
|
198
|
+
isRaw: false,
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
name: 'jobs',
|
|
202
|
+
command: 'ALLOW_MISSING_ENV=1 bun run:dev scripts/dev/jobs.ts',
|
|
203
|
+
isRaw: false,
|
|
204
|
+
},
|
|
205
|
+
])
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('handles tko run-all with --pty flag (lite script)', () => {
|
|
209
|
+
// lite = bun run:dev tko run-all --pty --flags=last watch-lazy lite:backend one:dev agent-gateway
|
|
210
|
+
// the "bun run:dev" prefix before tko means this is NOT a tko command at the top level
|
|
211
|
+
// it contains && or is a raw command - actually "bun run:dev tko run-all ..." IS a tko pattern
|
|
212
|
+
// but wrapped in "bun run:dev" so it should be treated as a leaf
|
|
213
|
+
const result = flattenScripts(['lite'], scripts)
|
|
214
|
+
expect(result).toEqual([
|
|
215
|
+
{
|
|
216
|
+
name: 'lite',
|
|
217
|
+
command:
|
|
218
|
+
'bun run:dev tko run-all --pty --flags=last watch-lazy lite:backend one:dev agent-gateway',
|
|
219
|
+
isRaw: false,
|
|
220
|
+
},
|
|
221
|
+
])
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('deduplicates scripts that appear multiple times', () => {
|
|
225
|
+
const dupeScripts: Record<string, string> = {
|
|
226
|
+
a: 'tko run shared c',
|
|
227
|
+
b: 'tko run shared d',
|
|
228
|
+
shared: 'echo shared',
|
|
229
|
+
c: 'echo c',
|
|
230
|
+
d: 'echo d',
|
|
231
|
+
}
|
|
232
|
+
const result = flattenScripts(['a', 'b'], dupeScripts)
|
|
233
|
+
const names = result.map((r) => r.name)
|
|
234
|
+
expect(names).toEqual(['shared', 'c', 'shared', 'd'])
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('inlines simple bun script in compound middle segment', () => {
|
|
238
|
+
const s: Record<string, string> = {
|
|
239
|
+
setup: 'bun migrate && bun serve',
|
|
240
|
+
migrate: 'drizzle-kit push',
|
|
241
|
+
serve: 'bun run:dev server.ts',
|
|
242
|
+
}
|
|
243
|
+
const result = flattenScripts(['setup'], s)
|
|
244
|
+
expect(result).toEqual([
|
|
245
|
+
{
|
|
246
|
+
name: 'setup',
|
|
247
|
+
command: 'drizzle-kit push && bun run:dev server.ts',
|
|
248
|
+
isRaw: false,
|
|
249
|
+
},
|
|
250
|
+
])
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('leaves non-bun compound segments untouched', () => {
|
|
254
|
+
const s: Record<string, string> = {
|
|
255
|
+
task: 'sleep 5 && echo done',
|
|
256
|
+
}
|
|
257
|
+
const result = flattenScripts(['task'], s)
|
|
258
|
+
expect(result).toEqual([
|
|
259
|
+
{ name: 'task', command: 'sleep 5 && echo done', isRaw: false },
|
|
260
|
+
])
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('handles semicolons as compound operators', () => {
|
|
264
|
+
const s: Record<string, string> = {
|
|
265
|
+
task: 'echo start; bun build; echo end',
|
|
266
|
+
build: 'tsc --noEmit',
|
|
267
|
+
}
|
|
268
|
+
const result = flattenScripts(['task'], s)
|
|
269
|
+
expect(result).toEqual([
|
|
270
|
+
{ name: 'task', command: 'echo start ; tsc --noEmit ; echo end', isRaw: false },
|
|
271
|
+
])
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('passes extra args through when inlining bun references', () => {
|
|
275
|
+
const s: Record<string, string> = {
|
|
276
|
+
task: 'bun build --watch && echo done',
|
|
277
|
+
build: 'tsc',
|
|
278
|
+
}
|
|
279
|
+
const result = flattenScripts(['task'], s)
|
|
280
|
+
expect(result).toEqual([
|
|
281
|
+
{ name: 'task', command: 'tsc --watch && echo done', isRaw: false },
|
|
282
|
+
])
|
|
283
|
+
})
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
describe('splitCompound', () => {
|
|
287
|
+
it('returns null for simple commands', () => {
|
|
288
|
+
expect(splitCompound('echo hello')).toBeNull()
|
|
289
|
+
expect(splitCompound('bun run dev')).toBeNull()
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('splits on &&', () => {
|
|
293
|
+
expect(splitCompound('sleep 5 && echo done')).toEqual({
|
|
294
|
+
segments: ['sleep 5', 'echo done'],
|
|
295
|
+
operators: ['&&'],
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('splits on ||', () => {
|
|
300
|
+
expect(splitCompound('test -f file || echo missing')).toEqual({
|
|
301
|
+
segments: ['test -f file', 'echo missing'],
|
|
302
|
+
operators: ['||'],
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('splits on ;', () => {
|
|
307
|
+
expect(splitCompound('echo a; echo b')).toEqual({
|
|
308
|
+
segments: ['echo a', 'echo b'],
|
|
309
|
+
operators: [';'],
|
|
310
|
+
})
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
it('handles mixed operators', () => {
|
|
314
|
+
expect(splitCompound('a && b || c; d')).toEqual({
|
|
315
|
+
segments: ['a', 'b', 'c', 'd'],
|
|
316
|
+
operators: ['&&', '||', ';'],
|
|
317
|
+
})
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('respects double quotes', () => {
|
|
321
|
+
expect(splitCompound('echo "a && b" && echo c')).toEqual({
|
|
322
|
+
segments: ['echo "a && b"', 'echo c'],
|
|
323
|
+
operators: ['&&'],
|
|
324
|
+
})
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('respects single quotes', () => {
|
|
328
|
+
expect(splitCompound("echo 'a && b' && echo c")).toEqual({
|
|
329
|
+
segments: ["echo 'a && b'", 'echo c'],
|
|
330
|
+
operators: ['&&'],
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('handles escaped characters', () => {
|
|
335
|
+
expect(splitCompound('echo a\\&\\& && echo b')).toEqual({
|
|
336
|
+
segments: ['echo a\\&\\&', 'echo b'],
|
|
337
|
+
operators: ['&&'],
|
|
338
|
+
})
|
|
339
|
+
})
|
|
340
|
+
})
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
export interface FlattenedScript {
|
|
2
|
+
name: string
|
|
3
|
+
command: string
|
|
4
|
+
/** true when the script name doesn't exist in package.json */
|
|
5
|
+
isRaw: boolean
|
|
6
|
+
/** scripts to spawn in parallel after command completes successfully */
|
|
7
|
+
deferredScripts?: FlattenedScript[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface FlattenOptions {
|
|
11
|
+
/** scripts already running (from BUN_RUN_SCRIPTS), will be filtered out */
|
|
12
|
+
runningScripts?: string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// matches: "bun tko run ...", "bun tko run-all ...", "tko run ...", "tko run-all ..."
|
|
16
|
+
// but NOT "bun run:dev tko run-all ..." (has extra words between bun and tko)
|
|
17
|
+
const TKO_RUN_RE = /^(?:bun\s+)?tko\s+(run(?:-all)?)\s+(.+)$/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* parse a tko run/run-all command string, extracting script names and flags
|
|
21
|
+
* returns null if the command is not a tko run/run-all pattern
|
|
22
|
+
*/
|
|
23
|
+
function parseTkoRun(command: string): { scriptNames: string[]; noRoot: boolean } | null {
|
|
24
|
+
const match = command.match(TKO_RUN_RE)
|
|
25
|
+
if (!match) return null
|
|
26
|
+
|
|
27
|
+
const argsPart = match[2]!
|
|
28
|
+
const args = argsPart.split(/\s+/)
|
|
29
|
+
const scriptNames: string[] = []
|
|
30
|
+
let noRoot = false
|
|
31
|
+
|
|
32
|
+
for (const arg of args) {
|
|
33
|
+
if (arg === '--no-root') {
|
|
34
|
+
noRoot = true
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
if (arg.startsWith('--')) continue
|
|
38
|
+
scriptNames.push(arg)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { scriptNames, noRoot }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* split a shell command on &&, ||, ; while respecting quotes.
|
|
46
|
+
* returns null if no operators found (command is not a compound).
|
|
47
|
+
*/
|
|
48
|
+
export function splitCompound(
|
|
49
|
+
command: string
|
|
50
|
+
): { segments: string[]; operators: string[] } | null {
|
|
51
|
+
const segments: string[] = []
|
|
52
|
+
const operators: string[] = []
|
|
53
|
+
let current = ''
|
|
54
|
+
let inSingle = false
|
|
55
|
+
let inDouble = false
|
|
56
|
+
let i = 0
|
|
57
|
+
|
|
58
|
+
while (i < command.length) {
|
|
59
|
+
const ch = command[i]!
|
|
60
|
+
|
|
61
|
+
if (ch === "'" && !inDouble) {
|
|
62
|
+
inSingle = !inSingle
|
|
63
|
+
current += ch
|
|
64
|
+
i++
|
|
65
|
+
} else if (ch === '"' && !inSingle) {
|
|
66
|
+
inDouble = !inDouble
|
|
67
|
+
current += ch
|
|
68
|
+
i++
|
|
69
|
+
} else if (ch === '\\' && !inSingle) {
|
|
70
|
+
current += ch + (command[i + 1] || '')
|
|
71
|
+
i += 2
|
|
72
|
+
} else if (!inSingle && !inDouble) {
|
|
73
|
+
if (command[i] === '&' && command[i + 1] === '&') {
|
|
74
|
+
segments.push(current.trim())
|
|
75
|
+
operators.push('&&')
|
|
76
|
+
current = ''
|
|
77
|
+
i += 2
|
|
78
|
+
} else if (command[i] === '|' && command[i + 1] === '|') {
|
|
79
|
+
segments.push(current.trim())
|
|
80
|
+
operators.push('||')
|
|
81
|
+
current = ''
|
|
82
|
+
i += 2
|
|
83
|
+
} else if (command[i] === ';') {
|
|
84
|
+
segments.push(current.trim())
|
|
85
|
+
operators.push(';')
|
|
86
|
+
current = ''
|
|
87
|
+
i++
|
|
88
|
+
} else {
|
|
89
|
+
current += ch
|
|
90
|
+
i++
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
current += ch
|
|
94
|
+
i++
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (current.trim()) segments.push(current.trim())
|
|
99
|
+
if (operators.length === 0) return null
|
|
100
|
+
return { segments, operators }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* match "bun <name> [args...]" where <name> is a package.json script.
|
|
105
|
+
* returns the script name and any trailing args, or null if no match.
|
|
106
|
+
*/
|
|
107
|
+
function matchBunRun(
|
|
108
|
+
segment: string,
|
|
109
|
+
scripts: Record<string, string>
|
|
110
|
+
): { name: string; args: string[] } | null {
|
|
111
|
+
const parts = segment.trim().split(/\s+/)
|
|
112
|
+
if (parts[0] !== 'bun' || parts.length < 2) return null
|
|
113
|
+
const name = parts[1]!
|
|
114
|
+
if (name.startsWith('-')) return null
|
|
115
|
+
if (scripts[name] === undefined) return null
|
|
116
|
+
return { name, args: parts.slice(2) }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* recursively flatten script references into leaf commands.
|
|
121
|
+
* resolves tko run/run-all chains AND shell compounds (&&, ;, ||).
|
|
122
|
+
*/
|
|
123
|
+
export function flattenScripts(
|
|
124
|
+
commands: string[],
|
|
125
|
+
packageJsonScripts: Record<string, string>,
|
|
126
|
+
options?: FlattenOptions
|
|
127
|
+
): FlattenedScript[] {
|
|
128
|
+
const runningScripts = new Set(options?.runningScripts ?? [])
|
|
129
|
+
const results: FlattenedScript[] = []
|
|
130
|
+
|
|
131
|
+
function resolve(name: string, visited: Set<string>) {
|
|
132
|
+
if (runningScripts.has(name)) return
|
|
133
|
+
|
|
134
|
+
const scriptCommand = packageJsonScripts[name]
|
|
135
|
+
|
|
136
|
+
// script doesn't exist in package.json - treat as raw command
|
|
137
|
+
if (scriptCommand === undefined) {
|
|
138
|
+
results.push({ name, command: name, isRaw: true })
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// cycle detected - emit as leaf to avoid infinite recursion
|
|
143
|
+
if (visited.has(name)) {
|
|
144
|
+
results.push({ name, command: scriptCommand, isRaw: false })
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// try to parse as tko run/run-all
|
|
149
|
+
const parsed = parseTkoRun(scriptCommand)
|
|
150
|
+
if (parsed) {
|
|
151
|
+
// --no-root means this will be handled by a separate tko process
|
|
152
|
+
// scoped to workspace packages — don't resolve against root scripts
|
|
153
|
+
if (parsed.noRoot) {
|
|
154
|
+
results.push({ name, command: scriptCommand, isRaw: false })
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
visited.add(name)
|
|
158
|
+
for (const childName of parsed.scriptNames) {
|
|
159
|
+
resolve(childName, visited)
|
|
160
|
+
}
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// try to parse as shell compound (&&, ;, ||)
|
|
165
|
+
const compound = splitCompound(scriptCommand)
|
|
166
|
+
if (compound) {
|
|
167
|
+
const resolvedSegments: string[] = []
|
|
168
|
+
let deferredScripts: FlattenedScript[] | undefined
|
|
169
|
+
|
|
170
|
+
for (let idx = 0; idx < compound.segments.length; idx++) {
|
|
171
|
+
const segment = compound.segments[idx]!
|
|
172
|
+
const isLast = idx === compound.segments.length - 1
|
|
173
|
+
const bunMatch = matchBunRun(segment, packageJsonScripts)
|
|
174
|
+
|
|
175
|
+
if (bunMatch) {
|
|
176
|
+
const innerCommand = packageJsonScripts[bunMatch.name]!
|
|
177
|
+
const innerParsed = parseTkoRun(innerCommand)
|
|
178
|
+
|
|
179
|
+
if (innerParsed && innerParsed.noRoot) {
|
|
180
|
+
// --no-root: keep as-is, will be handled by separate tko process
|
|
181
|
+
resolvedSegments.push(segment)
|
|
182
|
+
} else if (innerParsed && isLast) {
|
|
183
|
+
// last segment is a tko run → defer its children for parallel spawn
|
|
184
|
+
deferredScripts = flattenScripts(
|
|
185
|
+
innerParsed.scriptNames,
|
|
186
|
+
packageJsonScripts,
|
|
187
|
+
options
|
|
188
|
+
)
|
|
189
|
+
} else if (innerParsed && !isLast) {
|
|
190
|
+
// non-last segment is a tko run — can't inline parallel, keep as bun run
|
|
191
|
+
resolvedSegments.push(segment)
|
|
192
|
+
} else {
|
|
193
|
+
// simple script → inline its command with any extra args
|
|
194
|
+
const inlined = bunMatch.args.length
|
|
195
|
+
? `${innerCommand} ${bunMatch.args.join(' ')}`
|
|
196
|
+
: innerCommand
|
|
197
|
+
resolvedSegments.push(inlined)
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
resolvedSegments.push(segment)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// reconstruct the compound from resolved segments
|
|
205
|
+
let reconstructed = resolvedSegments[0] || ''
|
|
206
|
+
for (let idx = 0; idx < compound.operators.length; idx++) {
|
|
207
|
+
if (idx < resolvedSegments.length - 1) {
|
|
208
|
+
reconstructed += ` ${compound.operators[idx]} ${resolvedSegments[idx + 1]}`
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
results.push({ name, command: reconstructed, isRaw: false, deferredScripts })
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// leaf command - not a tko run/run-all or compound
|
|
217
|
+
results.push({ name, command: scriptCommand, isRaw: false })
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
for (const cmd of commands) {
|
|
221
|
+
resolve(cmd, new Set())
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return results
|
|
225
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
|
|
4
|
+
import semver from 'semver'
|
|
5
|
+
|
|
6
|
+
function getCurrentNodeVersion() {
|
|
7
|
+
// under bun, process.version is bun's bundled node-COMPAT string (a fixed
|
|
8
|
+
// value like v24.3.0), not the project's actual node toolchain — comparing
|
|
9
|
+
// it against engines.node rejects any pin newer than bun's compat level.
|
|
10
|
+
// the check exists to guard native-addon ABI for the real `node` binary
|
|
11
|
+
// child processes will run, so resolve that binary's version from PATH.
|
|
12
|
+
if (process.versions.bun) {
|
|
13
|
+
try {
|
|
14
|
+
return execFileSync('node', ['--version'], { encoding: 'utf-8' }).trim()
|
|
15
|
+
} catch {
|
|
16
|
+
// no node on PATH — nothing for the version pin to guard
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return process.version
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function getRequiredNodeVersion() {
|
|
24
|
+
const path = await import('node:path')
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const nodeVersionContent = await fs.promises.readFile(
|
|
28
|
+
path.join(process.cwd(), '.node-version'),
|
|
29
|
+
'utf-8'
|
|
30
|
+
)
|
|
31
|
+
return nodeVersionContent.trim()
|
|
32
|
+
} catch {}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const packageJson = JSON.parse(
|
|
36
|
+
await fs.promises.readFile(path.join(process.cwd(), 'package.json'), 'utf-8')
|
|
37
|
+
)
|
|
38
|
+
return packageJson?.engines?.node ?? null
|
|
39
|
+
} catch {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function checkNodeVersion() {
|
|
45
|
+
const current = getCurrentNodeVersion()
|
|
46
|
+
const required = await getRequiredNodeVersion()
|
|
47
|
+
|
|
48
|
+
if (!required || !current) return
|
|
49
|
+
|
|
50
|
+
if (semver.validRange(required) && semver.satisfies(current, required)) return
|
|
51
|
+
|
|
52
|
+
throw new Error(
|
|
53
|
+
`[33mWarning: Incorrect Node.js version. Expected ${required} but got ${current}[0m`
|
|
54
|
+
)
|
|
55
|
+
}
|