@take-out/scripts 0.1.18 → 0.1.20
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 +3 -2
- package/src/helpers/deploy-health.ts +227 -0
- package/src/helpers/ssh.ts +6 -2
- package/src/helpers/uncloud-deploy.ts +4 -4
- package/src/run-pty.mjs +348 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@take-out/scripts",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/run.ts",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@clack/prompts": "^0.8.2",
|
|
32
|
-
"@
|
|
32
|
+
"@lydell/node-pty": "^1.2.0-beta.3",
|
|
33
|
+
"@take-out/helpers": "0.1.20",
|
|
33
34
|
"picocolors": "^1.1.1"
|
|
34
35
|
},
|
|
35
36
|
"peerDependencies": {
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deployment health check helpers for uncloud deployments
|
|
3
|
+
* shared across repos via @take-out/scripts/helpers/deploy-health
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface SSHContext {
|
|
7
|
+
sshOpts: string
|
|
8
|
+
deployUser: string
|
|
9
|
+
deployHost: string
|
|
10
|
+
$: (
|
|
11
|
+
strings: TemplateStringsArray,
|
|
12
|
+
...values: any[]
|
|
13
|
+
) => { quiet: () => Promise<{ stdout: Buffer }> }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ContainerInfo {
|
|
17
|
+
id: string
|
|
18
|
+
status: string
|
|
19
|
+
state: string
|
|
20
|
+
names: string
|
|
21
|
+
isRestarting: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* check for containers stuck in restart loop
|
|
26
|
+
* fails fast if any container is crash-looping
|
|
27
|
+
* excludes init containers (minio-init, migrate) that are expected to exit
|
|
28
|
+
*/
|
|
29
|
+
export async function checkRestartLoops(
|
|
30
|
+
ctx: SSHContext,
|
|
31
|
+
options?: { excludePatterns?: string[] }
|
|
32
|
+
): Promise<{
|
|
33
|
+
hasLoop: boolean
|
|
34
|
+
services: string[]
|
|
35
|
+
}> {
|
|
36
|
+
const { sshOpts, deployUser, deployHost, $ } = ctx
|
|
37
|
+
// default exclude patterns for init/oneshot containers
|
|
38
|
+
const excludePatterns = options?.excludePatterns ?? ['init', 'migrate']
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const sshCmd = `ssh ${sshOpts} ${deployUser}@${deployHost}`
|
|
42
|
+
const result =
|
|
43
|
+
await $`${sshCmd.split(' ')} "docker ps -a --format '{{.Names}} {{.Status}}'"`.quiet()
|
|
44
|
+
const lines = result.stdout.toString().trim().split('\n').filter(Boolean)
|
|
45
|
+
|
|
46
|
+
const loopingServices: string[] = []
|
|
47
|
+
for (const line of lines) {
|
|
48
|
+
if (line.includes('Restarting')) {
|
|
49
|
+
const name = line.split(' ')[0] || ''
|
|
50
|
+
// skip init/oneshot containers
|
|
51
|
+
const isExcluded = excludePatterns.some((pattern) => name.includes(pattern))
|
|
52
|
+
if (!isExcluded) {
|
|
53
|
+
loopingServices.push(name)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { hasLoop: loopingServices.length > 0, services: loopingServices }
|
|
59
|
+
} catch {
|
|
60
|
+
return { hasLoop: false, services: [] }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* get container status for all running containers
|
|
66
|
+
* extracts service name from container names (handles various naming patterns)
|
|
67
|
+
*/
|
|
68
|
+
export async function getContainerStatus(
|
|
69
|
+
ctx: SSHContext,
|
|
70
|
+
options?: { verbose?: boolean }
|
|
71
|
+
): Promise<Map<string, ContainerInfo>> {
|
|
72
|
+
const { sshOpts, deployUser, deployHost, $ } = ctx
|
|
73
|
+
try {
|
|
74
|
+
const sshCmd = `ssh ${sshOpts} ${deployUser}@${deployHost}`
|
|
75
|
+
// use -a to see all containers including stopped/restarting
|
|
76
|
+
const result = await $`${sshCmd.split(' ')} "docker ps -a --format json"`.quiet()
|
|
77
|
+
const lines = result.stdout.toString().trim().split('\n').filter(Boolean)
|
|
78
|
+
|
|
79
|
+
const containers = new Map<string, ContainerInfo>()
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
try {
|
|
82
|
+
const container = JSON.parse(line)
|
|
83
|
+
const name = container.Names || ''
|
|
84
|
+
let serviceName: string | null = null
|
|
85
|
+
|
|
86
|
+
// try various naming patterns
|
|
87
|
+
// pattern 1: "projectname-service-1" or "projectname_service_1"
|
|
88
|
+
let match = name.match(/[^_-]+[_-]([^_-]+)[_-]\w+/)
|
|
89
|
+
if (match) {
|
|
90
|
+
serviceName = match[1]
|
|
91
|
+
} else {
|
|
92
|
+
// pattern 2: "service-xxxx" where xxxx is alphanumeric
|
|
93
|
+
match = name.match(/^([^_-]+)[_-]\w+$/)
|
|
94
|
+
if (match) {
|
|
95
|
+
serviceName = match[1]
|
|
96
|
+
} else {
|
|
97
|
+
// pattern 3: just the name before any dash or underscore
|
|
98
|
+
match = name.match(/^([^_-]+)/)
|
|
99
|
+
if (match) {
|
|
100
|
+
serviceName = match[1]
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (serviceName) {
|
|
106
|
+
const status = container.Status || ''
|
|
107
|
+
const state = container.State || ''
|
|
108
|
+
const isRestarting = state === 'restarting' || status.includes('Restarting')
|
|
109
|
+
|
|
110
|
+
containers.set(serviceName, {
|
|
111
|
+
id: container.ID,
|
|
112
|
+
status,
|
|
113
|
+
state,
|
|
114
|
+
names: container.Names,
|
|
115
|
+
isRestarting,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// skip invalid json lines
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return containers
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (options?.verbose) {
|
|
126
|
+
console.error('failed to get container status:', error)
|
|
127
|
+
}
|
|
128
|
+
return new Map()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* check health status of a specific container
|
|
134
|
+
* returns 'healthy', 'checking' (mid-probe), or 'unhealthy'
|
|
135
|
+
*/
|
|
136
|
+
export async function checkContainerHealth(
|
|
137
|
+
ctx: SSHContext,
|
|
138
|
+
containerId: string
|
|
139
|
+
): Promise<'healthy' | 'checking' | 'unhealthy'> {
|
|
140
|
+
const { sshOpts, deployUser, deployHost, $ } = ctx
|
|
141
|
+
try {
|
|
142
|
+
const sshCmd = `ssh ${sshOpts} ${deployUser}@${deployHost}`
|
|
143
|
+
const healthResult =
|
|
144
|
+
await $`${sshCmd.split(' ')} "docker inspect ${containerId} --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}no-healthcheck{{end}}'"`.quiet()
|
|
145
|
+
const healthStatus = healthResult.stdout.toString().trim()
|
|
146
|
+
|
|
147
|
+
if (healthStatus === 'healthy') {
|
|
148
|
+
return 'healthy'
|
|
149
|
+
} else if (healthStatus === 'no-healthcheck') {
|
|
150
|
+
// fall back to checking if running for containers without healthcheck
|
|
151
|
+
const statusResult =
|
|
152
|
+
await $`${sshCmd.split(' ')} "docker inspect ${containerId} --format '{{.State.Status}}'"`.quiet()
|
|
153
|
+
const status = statusResult.stdout.toString().trim()
|
|
154
|
+
return status === 'running' ? 'healthy' : 'unhealthy'
|
|
155
|
+
} else if (healthStatus === 'starting') {
|
|
156
|
+
// mid-healthcheck probe, don't treat as failure yet
|
|
157
|
+
return 'checking'
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return 'unhealthy'
|
|
161
|
+
} catch {
|
|
162
|
+
return 'unhealthy'
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* check http health endpoint from inside web container
|
|
168
|
+
*/
|
|
169
|
+
export async function checkHttpHealth(
|
|
170
|
+
ctx: SSHContext,
|
|
171
|
+
options?: { endpoint?: string; containerFilter?: string }
|
|
172
|
+
): Promise<{
|
|
173
|
+
healthy: boolean
|
|
174
|
+
status?: number
|
|
175
|
+
message?: string
|
|
176
|
+
}> {
|
|
177
|
+
const { sshOpts, deployUser, deployHost, $ } = ctx
|
|
178
|
+
const endpoint = options?.endpoint || 'http://localhost:8081/'
|
|
179
|
+
const containerFilter = options?.containerFilter || 'web'
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const sshCmd = `ssh ${sshOpts} ${deployUser}@${deployHost}`
|
|
183
|
+
const containersResult =
|
|
184
|
+
await $`${sshCmd.split(' ')} "docker ps --filter 'name=${containerFilter}' --format '{{.Names}}'"`.quiet()
|
|
185
|
+
const webContainer = containersResult.stdout.toString().trim()
|
|
186
|
+
|
|
187
|
+
if (!webContainer) {
|
|
188
|
+
return { healthy: false, message: 'web container not found' }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const result =
|
|
192
|
+
await $`${sshCmd.split(' ')} "docker exec ${webContainer} curl -so /dev/null -w '%{http_code}' -m 5 ${endpoint}"`.quiet()
|
|
193
|
+
const output = result.stdout.toString().trim()
|
|
194
|
+
const statusCode = parseInt(output, 10)
|
|
195
|
+
|
|
196
|
+
if (statusCode >= 200 && statusCode < 400) {
|
|
197
|
+
return { healthy: true, status: statusCode }
|
|
198
|
+
} else if (statusCode >= 400 && statusCode < 500) {
|
|
199
|
+
return { healthy: true, status: statusCode, message: 'server running' }
|
|
200
|
+
} else if (statusCode >= 500) {
|
|
201
|
+
return { healthy: false, status: statusCode, message: 'server error' }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { healthy: false, message: 'app starting...' }
|
|
205
|
+
} catch {
|
|
206
|
+
return { healthy: false, message: 'app starting...' }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* get recent logs from a container
|
|
212
|
+
*/
|
|
213
|
+
export async function getContainerLogs(
|
|
214
|
+
ctx: SSHContext,
|
|
215
|
+
containerId: string,
|
|
216
|
+
lines = 20
|
|
217
|
+
): Promise<string[]> {
|
|
218
|
+
const { sshOpts, deployUser, deployHost, $ } = ctx
|
|
219
|
+
try {
|
|
220
|
+
const sshCmd = `ssh ${sshOpts} ${deployUser}@${deployHost}`
|
|
221
|
+
const result =
|
|
222
|
+
await $`${sshCmd.split(' ')} "docker logs ${containerId} --tail ${lines} 2>&1"`.quiet()
|
|
223
|
+
return result.stdout.toString().trim().split('\n')
|
|
224
|
+
} catch {
|
|
225
|
+
return []
|
|
226
|
+
}
|
|
227
|
+
}
|
package/src/helpers/ssh.ts
CHANGED
|
@@ -36,11 +36,15 @@ export async function testSSHConnection(
|
|
|
36
36
|
} catch (err) {
|
|
37
37
|
lastError = err as Error
|
|
38
38
|
if (attempt < maxRetries) {
|
|
39
|
-
console.info(
|
|
39
|
+
console.info(
|
|
40
|
+
` ssh attempt ${attempt}/${maxRetries} failed, retrying in ${retryDelay / 1000}s...`
|
|
41
|
+
)
|
|
40
42
|
await sleep(retryDelay)
|
|
41
43
|
}
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
throw new Error(
|
|
47
|
+
throw new Error(
|
|
48
|
+
`cannot connect to ${host} after ${maxRetries} attempts - check ssh key: ${sshKey}`
|
|
49
|
+
)
|
|
46
50
|
}
|
|
@@ -147,10 +147,10 @@ export async function setupSSHKey(options: SetupSSHKeyOptions = {}) {
|
|
|
147
147
|
// add host to known_hosts
|
|
148
148
|
if (host) {
|
|
149
149
|
try {
|
|
150
|
-
await run(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
)
|
|
150
|
+
await run(`ssh-keyscan -H ${host} >> ${join(sshDir, 'known_hosts')}`, {
|
|
151
|
+
silent: true,
|
|
152
|
+
timeout: time.ms.seconds(10),
|
|
153
|
+
})
|
|
154
154
|
} catch {
|
|
155
155
|
// ignore - ssh will prompt if needed
|
|
156
156
|
}
|
package/src/run-pty.mjs
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import { join, relative, resolve } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import pty from '@lydell/node-pty'
|
|
7
|
+
|
|
8
|
+
const colors = [
|
|
9
|
+
'\x1b[38;5;245m',
|
|
10
|
+
'\x1b[38;5;240m',
|
|
11
|
+
'\x1b[38;5;243m',
|
|
12
|
+
'\x1b[38;5;248m',
|
|
13
|
+
'\x1b[38;5;238m',
|
|
14
|
+
'\x1b[38;5;252m',
|
|
15
|
+
]
|
|
16
|
+
const reset = '\x1b[0m'
|
|
17
|
+
const dim = '\x1b[2m'
|
|
18
|
+
const bold = '\x1b[1m'
|
|
19
|
+
const inverse = '\x1b[7m'
|
|
20
|
+
|
|
21
|
+
const args = process.argv.slice(2)
|
|
22
|
+
const ownFlags = ['--no-root', '--bun', '--watch', '--flags=last']
|
|
23
|
+
const runCommands = []
|
|
24
|
+
const forwardArgs = []
|
|
25
|
+
|
|
26
|
+
for (let i = 0; i < args.length; i++) {
|
|
27
|
+
const arg = args[i]
|
|
28
|
+
if (arg.startsWith('--')) {
|
|
29
|
+
if (ownFlags.includes(arg)) continue
|
|
30
|
+
forwardArgs.push(arg)
|
|
31
|
+
const nextArg = args[i + 1]
|
|
32
|
+
if (nextArg && !nextArg.startsWith('--')) {
|
|
33
|
+
forwardArgs.push(nextArg)
|
|
34
|
+
i++
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
runCommands.push(arg)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const noRoot = args.includes('--no-root')
|
|
42
|
+
const runBun = args.includes('--bun')
|
|
43
|
+
const flagsLast = args.includes('--flags=last')
|
|
44
|
+
|
|
45
|
+
const processes = []
|
|
46
|
+
let dashboardMode = false
|
|
47
|
+
let selectedIndex = 0
|
|
48
|
+
|
|
49
|
+
function getPrefix(index) {
|
|
50
|
+
const p = processes[index]
|
|
51
|
+
if (!p) return ''
|
|
52
|
+
const color = colors[index % colors.length]
|
|
53
|
+
return `${color}${p.shortcut} ${p.label}${reset}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (runCommands.length === 0) {
|
|
57
|
+
console.error('Usage: run-pty <script1> [script2] ...')
|
|
58
|
+
process.exit(1)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readPackageJson(dir) {
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(await fs.promises.readFile(join(dir, 'package.json'), 'utf8'))
|
|
64
|
+
} catch {
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function getWorkspacePatterns() {
|
|
70
|
+
const pkg = await readPackageJson('.')
|
|
71
|
+
if (!pkg?.workspaces) return []
|
|
72
|
+
return Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages || []
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function findPackageJsonDirs(base, depth = 3) {
|
|
76
|
+
if (depth <= 0) return []
|
|
77
|
+
const results = []
|
|
78
|
+
try {
|
|
79
|
+
if (
|
|
80
|
+
await fs.promises
|
|
81
|
+
.access(join(base, 'package.json'))
|
|
82
|
+
.then(() => true)
|
|
83
|
+
.catch(() => false)
|
|
84
|
+
) {
|
|
85
|
+
results.push(base)
|
|
86
|
+
}
|
|
87
|
+
const entries = await fs.promises.readdir(base, { withFileTypes: true })
|
|
88
|
+
for (const e of entries) {
|
|
89
|
+
if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') {
|
|
90
|
+
results.push(...(await findPackageJsonDirs(join(base, e.name), depth - 1)))
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch {}
|
|
94
|
+
return results
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function findWorkspaceScripts(scripts) {
|
|
98
|
+
const patterns = await getWorkspacePatterns()
|
|
99
|
+
if (!patterns.length) return new Map()
|
|
100
|
+
|
|
101
|
+
const dirs = await findPackageJsonDirs('.')
|
|
102
|
+
const result = new Map()
|
|
103
|
+
|
|
104
|
+
for (const dir of dirs) {
|
|
105
|
+
if (dir === '.') continue
|
|
106
|
+
const rel = relative('.', dir).replace(/\\/g, '/')
|
|
107
|
+
const matches = patterns.some((p) => {
|
|
108
|
+
const np = p.replace(/\\/g, '/').replace(/^\.\//, '')
|
|
109
|
+
return np.endsWith('/*')
|
|
110
|
+
? rel.startsWith(np.slice(0, -1))
|
|
111
|
+
: rel === np || rel.startsWith(np + '/')
|
|
112
|
+
})
|
|
113
|
+
if (!matches) continue
|
|
114
|
+
|
|
115
|
+
const pkg = await readPackageJson(dir)
|
|
116
|
+
if (!pkg?.scripts) continue
|
|
117
|
+
const available = scripts.filter((s) => typeof pkg.scripts[s] === 'string')
|
|
118
|
+
if (available.length) result.set(dir, { scripts: available, name: pkg.name || dir })
|
|
119
|
+
}
|
|
120
|
+
return result
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function spawnScript(name, cwd, label, extraArgs, index) {
|
|
124
|
+
const runArgs = ['run', '--silent', runBun ? '--bun' : '', name, ...extraArgs].filter(
|
|
125
|
+
Boolean
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
const terminal = pty.spawn('bun', runArgs, {
|
|
129
|
+
cwd: resolve(cwd),
|
|
130
|
+
cols: process.stdout.columns || 80,
|
|
131
|
+
rows: process.stdout.rows || 24,
|
|
132
|
+
env: { ...process.env, FORCE_COLOR: '3' },
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const idx = index ?? processes.length
|
|
136
|
+
const managed = {
|
|
137
|
+
terminal,
|
|
138
|
+
name,
|
|
139
|
+
cwd,
|
|
140
|
+
label,
|
|
141
|
+
extraArgs,
|
|
142
|
+
index: idx,
|
|
143
|
+
shortcut: '',
|
|
144
|
+
killed: false,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (index !== undefined) {
|
|
148
|
+
processes[index] = managed
|
|
149
|
+
} else {
|
|
150
|
+
processes.push(managed)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
terminal.onData((data) => {
|
|
154
|
+
if (dashboardMode) return
|
|
155
|
+
const lines = data.split('\n')
|
|
156
|
+
for (const line of lines) {
|
|
157
|
+
if (line && line !== '\r') {
|
|
158
|
+
process.stdout.write(`${getPrefix(idx)} ${line}\n`)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
terminal.onExit(({ exitCode }) => {
|
|
164
|
+
if (managed.killed) return
|
|
165
|
+
if (exitCode !== 0) {
|
|
166
|
+
process.stdout.write(`${getPrefix(idx)} exited with code ${exitCode}\n`)
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
return managed
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function computeShortcuts() {
|
|
174
|
+
const initials = processes.map((p) =>
|
|
175
|
+
p.label
|
|
176
|
+
.toLowerCase()
|
|
177
|
+
.split(/[^a-z]+/)
|
|
178
|
+
.filter(Boolean)
|
|
179
|
+
.map((w) => w[0])
|
|
180
|
+
.join('')
|
|
181
|
+
)
|
|
182
|
+
const lengths = new Array(processes.length).fill(1)
|
|
183
|
+
|
|
184
|
+
for (let round = 0; round < 5; round++) {
|
|
185
|
+
const shortcuts = initials.map((init, i) => init.slice(0, lengths[i]) || init)
|
|
186
|
+
const groups = new Map()
|
|
187
|
+
shortcuts.forEach((s, i) => groups.set(s, [...(groups.get(s) || []), i]))
|
|
188
|
+
|
|
189
|
+
let collision = false
|
|
190
|
+
for (const indices of groups.values()) {
|
|
191
|
+
if (indices.length > 1) {
|
|
192
|
+
collision = true
|
|
193
|
+
indices.forEach((i) => lengths[i]++)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (!collision) {
|
|
197
|
+
processes.forEach((p, i) => (p.shortcut = shortcuts[i]))
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
processes.forEach(
|
|
202
|
+
(p, i) => (p.shortcut = initials[i]?.slice(0, lengths[i]) || String(i + 1))
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function renderDashboard() {
|
|
207
|
+
process.stdout.write('\x1b[2J\x1b[H')
|
|
208
|
+
process.stdout.write(
|
|
209
|
+
`${bold}Dashboard${reset} ${dim}(↑↓ navigate, r restart, k kill, esc back)${reset}\n\n`
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
for (let i = 0; i < processes.length; i++) {
|
|
213
|
+
const p = processes[i]
|
|
214
|
+
const prefix = i === selectedIndex ? `${inverse} > ${reset}` : ' '
|
|
215
|
+
const status = p.killed
|
|
216
|
+
? `${dim}(killed)${reset}`
|
|
217
|
+
: p.terminal.pid
|
|
218
|
+
? `${dim}(pid ${p.terminal.pid})${reset}`
|
|
219
|
+
: ''
|
|
220
|
+
process.stdout.write(`${prefix} ${getPrefix(i)} ${status}\n`)
|
|
221
|
+
}
|
|
222
|
+
process.stdout.write(`\n${dim}ctrl+c to exit${reset}\n`)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function exitDashboard() {
|
|
226
|
+
dashboardMode = false
|
|
227
|
+
process.stdout.write('\x1b[2J\x1b[H')
|
|
228
|
+
process.stdout.write(
|
|
229
|
+
`${dim} running ${processes.length} processes (ctrl+z dashboard, ctrl+c exit)${reset}\n\n`
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function handleInput(data) {
|
|
234
|
+
const str = data.toString()
|
|
235
|
+
|
|
236
|
+
if (str === '\x03') {
|
|
237
|
+
cleanup()
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (str === '\x1a') {
|
|
242
|
+
if (dashboardMode) {
|
|
243
|
+
exitDashboard()
|
|
244
|
+
} else {
|
|
245
|
+
dashboardMode = true
|
|
246
|
+
renderDashboard()
|
|
247
|
+
}
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (dashboardMode) {
|
|
252
|
+
if (str === '\x1b[A') {
|
|
253
|
+
selectedIndex = Math.max(0, selectedIndex - 1)
|
|
254
|
+
renderDashboard()
|
|
255
|
+
} else if (str === '\x1b[B') {
|
|
256
|
+
selectedIndex = Math.min(processes.length - 1, selectedIndex + 1)
|
|
257
|
+
renderDashboard()
|
|
258
|
+
} else if (str === 'r' || str === 'R') {
|
|
259
|
+
const p = processes[selectedIndex]
|
|
260
|
+
if (p) {
|
|
261
|
+
p.killed = true
|
|
262
|
+
p.terminal.kill()
|
|
263
|
+
setTimeout(() => {
|
|
264
|
+
spawnScript(p.name, p.cwd, p.label, p.extraArgs, p.index)
|
|
265
|
+
exitDashboard()
|
|
266
|
+
}, 100)
|
|
267
|
+
}
|
|
268
|
+
} else if (str === 'k' || str === 'K') {
|
|
269
|
+
const p = processes[selectedIndex]
|
|
270
|
+
if (p && !p.killed) {
|
|
271
|
+
p.killed = true
|
|
272
|
+
p.terminal.kill()
|
|
273
|
+
renderDashboard()
|
|
274
|
+
}
|
|
275
|
+
} else if (str === '\x1b' || str === '\x1b\x1b') {
|
|
276
|
+
exitDashboard()
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function cleanup() {
|
|
282
|
+
process.stdout.write('\n')
|
|
283
|
+
for (const p of processes) {
|
|
284
|
+
if (!p.killed) {
|
|
285
|
+
p.killed = true
|
|
286
|
+
p.terminal.kill()
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
process.exit(0)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function main() {
|
|
293
|
+
const lastScript = runCommands[runCommands.length - 1]
|
|
294
|
+
|
|
295
|
+
if (!noRoot) {
|
|
296
|
+
const pkg = await readPackageJson('.')
|
|
297
|
+
if (pkg?.scripts) {
|
|
298
|
+
for (const name of runCommands) {
|
|
299
|
+
if (typeof pkg.scripts[name] === 'string') {
|
|
300
|
+
const scriptArgs = !flagsLast || name === lastScript ? forwardArgs : []
|
|
301
|
+
spawnScript(name, '.', name, scriptArgs)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const wsScripts = await findWorkspaceScripts(runCommands)
|
|
308
|
+
for (const [dir, { scripts, name }] of wsScripts) {
|
|
309
|
+
for (const script of scripts) {
|
|
310
|
+
const scriptArgs = !flagsLast || script === lastScript ? forwardArgs : []
|
|
311
|
+
spawnScript(script, dir, `${name} ${script}`, scriptArgs)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (processes.length === 0) {
|
|
316
|
+
console.error('No scripts found')
|
|
317
|
+
process.exit(1)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
computeShortcuts()
|
|
321
|
+
process.stdout.write(
|
|
322
|
+
`${dim} running ${processes.length} processes (ctrl+z dashboard, ctrl+c exit)${reset}\n\n`
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
if (process.stdin.isTTY) {
|
|
326
|
+
process.stdin.setRawMode(true)
|
|
327
|
+
process.stdin.resume()
|
|
328
|
+
process.stdin.on('data', handleInput)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
process.stdout.on('resize', () => {
|
|
332
|
+
const cols = process.stdout.columns || 80
|
|
333
|
+
const rows = process.stdout.rows || 24
|
|
334
|
+
for (const p of processes) {
|
|
335
|
+
if (!p.killed) {
|
|
336
|
+
p.terminal.resize(cols, rows)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
process.on('SIGINT', cleanup)
|
|
342
|
+
process.on('SIGTERM', cleanup)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
main().catch((e) => {
|
|
346
|
+
console.error(e)
|
|
347
|
+
process.exit(1)
|
|
348
|
+
})
|