@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@take-out/scripts",
3
- "version": "0.1.18",
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
- "@take-out/helpers": "0.1.16",
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
+ }
@@ -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(` ssh attempt ${attempt}/${maxRetries} failed, retrying in ${retryDelay / 1000}s...`)
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(`cannot connect to ${host} after ${maxRetries} attempts - check ssh key: ${sshKey}`)
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
- `ssh-keyscan -H ${host} >> ${join(sshDir, 'known_hosts')}`,
152
- { silent: true, timeout: time.ms.seconds(10) }
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
  }
@@ -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
+ })