@take-out/scripts 0.1.19 → 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.19",
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.19",
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": {
@@ -24,12 +24,19 @@ export interface ContainerInfo {
24
24
  /**
25
25
  * check for containers stuck in restart loop
26
26
  * fails fast if any container is crash-looping
27
+ * excludes init containers (minio-init, migrate) that are expected to exit
27
28
  */
28
- export async function checkRestartLoops(ctx: SSHContext): Promise<{
29
+ export async function checkRestartLoops(
30
+ ctx: SSHContext,
31
+ options?: { excludePatterns?: string[] }
32
+ ): Promise<{
29
33
  hasLoop: boolean
30
34
  services: string[]
31
35
  }> {
32
36
  const { sshOpts, deployUser, deployHost, $ } = ctx
37
+ // default exclude patterns for init/oneshot containers
38
+ const excludePatterns = options?.excludePatterns ?? ['init', 'migrate']
39
+
33
40
  try {
34
41
  const sshCmd = `ssh ${sshOpts} ${deployUser}@${deployHost}`
35
42
  const result =
@@ -40,7 +47,11 @@ export async function checkRestartLoops(ctx: SSHContext): Promise<{
40
47
  for (const line of lines) {
41
48
  if (line.includes('Restarting')) {
42
49
  const name = line.split(' ')[0] || ''
43
- loopingServices.push(name)
50
+ // skip init/oneshot containers
51
+ const isExcluded = excludePatterns.some((pattern) => name.includes(pattern))
52
+ if (!isExcluded) {
53
+ loopingServices.push(name)
54
+ }
44
55
  }
45
56
  }
46
57
 
@@ -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
+ })