bod-cli 0.3.1 → 0.5.1

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": "bod-cli",
3
- "version": "0.3.1",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "bod": "./dist/cli.js"
@@ -13,6 +13,7 @@
13
13
  "chalk": "^5.3.0",
14
14
  "citty": "^0.1.6",
15
15
  "cli-table3": "^0.6.5",
16
+ "yaml": "^2.8.3",
16
17
  "zod": "^3.23.0"
17
18
  },
18
19
  "devDependencies": {
package/src/cli.ts CHANGED
@@ -14,10 +14,13 @@ import removeCmd from './commands/remove'
14
14
  import openCmd from './commands/open'
15
15
  import serveCmd from './commands/serve'
16
16
  import sshCmd from './commands/ssh'
17
+ import publishCmd from './commands/publish'
17
18
 
18
- // Parse --instance early so it's set before citty dispatches subcommands
19
+ // Parse --instance / --local / -l early so it's set before citty dispatches subcommands
19
20
  const instanceFlag = process.argv.find(a => a.startsWith('--instance='))?.split('=').slice(1).join('=')
21
+ const isLocal = process.argv.includes('--local') || process.argv.includes('-l')
20
22
  if (instanceFlag) setInstanceOverride(instanceFlag)
23
+ else if (isLocal) setInstanceOverride('local')
21
24
 
22
25
  const subCommands = {
23
26
  login: loginCmd,
@@ -32,6 +35,7 @@ const subCommands = {
32
35
  open: openCmd,
33
36
  serve: serveCmd,
34
37
  ssh: sshCmd,
38
+ publish: publishCmd,
35
39
  }
36
40
 
37
41
  const main = defineCommand({
@@ -72,6 +76,7 @@ const main = defineCommand({
72
76
  { value: 'init', name: 'init — Initialize a project' },
73
77
  { value: 'add', name: 'add — Add a package' },
74
78
  { value: 'remove', name: 'remove — Remove a package' },
79
+ { value: 'publish', name: 'publish — Publish package to registry' },
75
80
  { value: 'login', name: 'login — Add/switch instance' },
76
81
  { value: 'exit', name: 'exit — Exit' },
77
82
  ]
@@ -79,7 +84,7 @@ const main = defineCommand({
79
84
  while (true) {
80
85
  let command: string
81
86
  try {
82
- command = await select({ message: 'What would you like to do?', choices })
87
+ command = await select({ message: 'What would you like to do?', choices, theme: { prefix: { idle: '>', done: '>' }, icon: { cursor: '>' }, style: { help: () => '', keysHelpTip: () => '' } } })
83
88
  } catch (e) {
84
89
  if ((e as Error).name === 'ExitPromptError') return
85
90
  throw e
@@ -135,6 +140,7 @@ async function getInteractiveArgs(command: string): Promise<string[] | null> {
135
140
  if (!pkg) return null
136
141
  return [pkg]
137
142
  }
143
+ case 'publish': return []
138
144
  default: return []
139
145
  }
140
146
  } catch (e) {
@@ -2,7 +2,7 @@ import { defineCommand } from 'citty'
2
2
  import chalk from 'chalk'
3
3
  import { loadConfig, getResolvedInstance } from '../config'
4
4
  import { BodClient } from '../client'
5
- import { resolveAppId, resolveAppName } from '../utils/resolve'
5
+ import { resolveAppId, resolveAppName, readInstanceFromYaml, readDeployModeFromYaml, readExcludesFromYaml, detectSiblingDeps } from '../utils/resolve'
6
6
 
7
7
  async function detectBranch(explicit?: string): Promise<string> {
8
8
  if (explicit) return explicit
@@ -11,36 +11,65 @@ async function detectBranch(explicit?: string): Promise<string> {
11
11
  return branch || 'main'
12
12
  }
13
13
 
14
+ function formatSize(bytes: number): string {
15
+ if (bytes < 1024) return `${bytes} B`
16
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
17
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
18
+ }
19
+
14
20
  async function uploadDeploy(client: BodClient, appId: string, branch: string) {
15
- console.log(chalk.dim('Packing source...'))
16
- const tar = Bun.spawn(
17
- ['tar', 'cz', '--exclude=node_modules', '--exclude=.git', '--exclude=dist', '.'],
18
- { stdout: 'pipe', stderr: 'pipe' },
19
- )
21
+ const defaultExcludes = ['node_modules', '.git', 'dist']
22
+ const userExcludes = readExcludesFromYaml()
23
+ const allExcludes = [...new Set([...defaultExcludes, ...userExcludes])]
24
+ const excludeFlags = allExcludes.map(e => `--exclude=${e}`)
25
+
26
+ // Auto-detect file:.. dependencies and include them in the tarball
27
+ const siblingPaths = detectSiblingDeps()
28
+ if (siblingPaths.length) console.log(chalk.dim(`Including siblings: ${siblingPaths.join(', ')}`))
29
+
30
+ console.log(chalk.dim(`Packing source (excluding: ${allExcludes.join(', ')})...`))
31
+ const tmpFile = `${Bun.env.TMPDIR || '/tmp'}/bod-deploy-${Date.now()}.tar.gz`
32
+ const tar = Bun.spawnSync(['tar', 'cz', ...excludeFlags, '-f', tmpFile, '.', ...siblingPaths], { stderr: 'pipe' })
33
+ if (tar.exitCode !== 0) {
34
+ const errText = tar.stderr.toString()
35
+ throw new Error(`tar failed: ${errText}`)
36
+ }
37
+
38
+ const file = Bun.file(tmpFile)
39
+ const size = file.size
40
+ console.log(chalk.dim(`Uploading ${formatSize(size)}...`))
41
+
42
+ const start = Date.now()
20
43
  await client.upload<any>(
21
44
  `/apps/${appId}/deploy/upload`,
22
- tar.stdout as ReadableStream,
45
+ file.stream(),
23
46
  { 'x-branch': branch },
24
47
  )
25
- const exitCode = await tar.exited
26
- if (exitCode !== 0) {
27
- const errText = await new Response(tar.stderr).text().catch(() => '')
28
- throw new Error(`tar failed: ${errText}`)
29
- }
48
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1)
49
+ console.log(chalk.dim(`Uploaded ${formatSize(size)} in ${elapsed}s`))
50
+
51
+ // Cleanup temp file
52
+ try { (await import('fs')).unlinkSync(tmpFile) } catch {}
30
53
  }
31
54
 
32
55
  async function gitDeploy(client: BodClient, appId: string, branch: string) {
33
56
  await client.post<any>(`/apps/${appId}/deploy`, { branch })
34
57
  }
35
58
 
36
- async function pollDeploy(client: BodClient, appId: string, since: number) {
59
+ function branchToSubdomain(branch: string, domain: string, productionBranch: string): string {
60
+ if (branch === productionBranch) return domain
61
+ const sanitized = branch.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
62
+ return `${sanitized}--${domain}`
63
+ }
64
+
65
+ async function pollDeploy(client: BodClient, appId: string, since: number, instanceCaps?: { baseDomain?: string | null }, localConfig?: { localDomain?: string; caddyPort?: number } | null) {
37
66
  console.log(chalk.dim('Waiting for deployment...'))
38
67
  let lastStatus = ''
68
+ let lastLogTs = since
39
69
  for (let i = 0; i < 120; i++) {
40
70
  await Bun.sleep(2000)
41
71
  const detail = await client.get<any>(`/apps/${appId}`)
42
72
  const deps = detail.deployments ?? []
43
- // Find the deployment created after we triggered (don't fall back to older ones)
44
73
  const latest = deps.find((d: any) => (d.createdAt ?? 0) >= since)
45
74
  if (!latest) continue
46
75
  const status = latest.status
@@ -48,13 +77,49 @@ async function pollDeploy(client: BodClient, appId: string, since: number) {
48
77
  console.log(chalk.dim(` Status: ${status}`))
49
78
  lastStatus = status
50
79
  }
80
+
81
+ // Tail logs during build/deploy
82
+ try {
83
+ const signals = await client.get<any[]>(`/apps/${appId}/signals?limit=50&from=${lastLogTs + 1}`)
84
+ if (Array.isArray(signals) && signals.length) {
85
+ // Signals come desc — reverse for chronological display
86
+ for (const s of [...signals].reverse()) {
87
+ const time = new Date(s.ts).toISOString().slice(11, 23)
88
+ const msg = s.message ?? ''
89
+ if (s.level === 'error' || s.source === 'stderr') {
90
+ console.log(chalk.dim(time) + ' ' + chalk.red(msg))
91
+ } else {
92
+ console.log(chalk.dim(time) + ' ' + chalk.dim(msg))
93
+ }
94
+ }
95
+ lastLogTs = Math.max(...signals.map((s: any) => s.ts))
96
+ }
97
+ } catch { /* signal fetch is best-effort */ }
98
+
51
99
  if (status === 'live') {
52
100
  console.log(chalk.green(`✓ Deployment live!`))
53
- if (detail.domain) console.log(chalk.dim(` → https://${detail.domain}`))
101
+ if (detail.domain && latest.branch) {
102
+ const host = branchToSubdomain(latest.branch, detail.domain, detail.productionBranch ?? 'main')
103
+ const isPreview = host !== detail.domain
104
+ // For local instances, rewrite domain to localDomain variant
105
+ const baseDomain = instanceCaps?.baseDomain
106
+ const ld = localConfig?.localDomain
107
+ const port = localConfig?.caddyPort
108
+ let displayUrl: string
109
+ if (ld && baseDomain && host.endsWith(`.${baseDomain}`)) {
110
+ const localHost = host.slice(0, -(baseDomain.length + 1)) + `.${ld}`
111
+ displayUrl = `http://${localHost}${port ? `:${port}` : ''}`
112
+ } else {
113
+ displayUrl = `https://${host}`
114
+ }
115
+ console.log(` → ${displayUrl}`)
116
+ if (isPreview) console.log(chalk.yellow(` ⚠ Preview deployment (branch: ${latest.branch})`))
117
+ }
54
118
  return
55
119
  }
56
120
  if (status === 'failed') {
57
121
  console.error(chalk.red(`✗ Deployment failed`))
122
+ if (latest.error) console.error(chalk.red(` ${latest.error}`))
58
123
  process.exit(1)
59
124
  }
60
125
  }
@@ -66,10 +131,18 @@ export default defineCommand({
66
131
  args: {
67
132
  app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
68
133
  branch: { type: 'string', description: 'Branch to deploy (default: current git branch)' },
134
+ upload: { type: 'boolean', description: 'Upload local source instead of git clone', default: false },
69
135
  },
70
136
  async run({ args }) {
71
- const { url, apiKey } = getResolvedInstance(loadConfig())
137
+ const { url, apiKey, name: instanceName, instance } = getResolvedInstance(loadConfig(), readInstanceFromYaml())
138
+ console.log(chalk.dim(`Using instance "${instanceName}"`))
72
139
  const client = new BodClient(url, apiKey)
140
+ // Fetch local config for URL generation
141
+ const isLocal = url.startsWith('http://localhost')
142
+ let localConfig: { localDomain?: string; caddyPort?: number } | null = null
143
+ if (isLocal) {
144
+ try { localConfig = await client.get<any>('/config/public') } catch {}
145
+ }
73
146
 
74
147
  const appName = resolveAppName(args.app)
75
148
 
@@ -77,8 +150,10 @@ export default defineCommand({
77
150
  const branch = await detectBranch(args.branch)
78
151
  const detail = await client.get<any>(`/apps/${appId}`)
79
152
 
153
+ const forceUpload = args.upload || readDeployModeFromYaml() === 'upload'
154
+
80
155
  let hasRepo = !!detail.repo
81
- if (!hasRepo) {
156
+ if (!hasRepo && !forceUpload) {
82
157
  const proc = Bun.spawnSync(['git', 'remote', 'get-url', 'origin'])
83
158
  const repo = proc.exitCode === 0 ? proc.stdout.toString().trim() : ''
84
159
  if (repo) {
@@ -88,16 +163,17 @@ export default defineCommand({
88
163
  }
89
164
  }
90
165
 
91
- console.log(chalk.dim(`Deploying ${appName} (branch: ${branch})${hasRepo ? '' : ' via upload'}...`))
166
+ const useUpload = forceUpload || !hasRepo
167
+ console.log(chalk.dim(`Deploying ${appName} (branch: ${branch})${useUpload ? ' via upload' : ''}...`))
92
168
 
93
- const since = Date.now()
94
- if (hasRepo) {
95
- await gitDeploy(client, appId, branch)
96
- } else {
169
+ const since = Date.now() - 5000 // buffer for clock skew
170
+ if (useUpload) {
97
171
  await uploadDeploy(client, appId, branch)
172
+ } else {
173
+ await gitDeploy(client, appId, branch)
98
174
  }
99
175
 
100
176
  console.log(chalk.green(`✓ Deployment queued`))
101
- await pollDeploy(client, appId, since)
177
+ await pollDeploy(client, appId, since, instance.capabilities, localConfig)
102
178
  },
103
179
  })
@@ -32,8 +32,13 @@ function parseDotEnv(content: string): Record<string, string> {
32
32
  if (eqIdx === -1) continue
33
33
  const key = trimmed.slice(0, eqIdx).trim()
34
34
  let value = trimmed.slice(eqIdx + 1).trim()
35
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
35
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
36
36
  value = value.slice(1, -1)
37
+ } else {
38
+ // Strip inline comments (unquoted values only)
39
+ const commentIdx = value.indexOf(' #')
40
+ if (commentIdx !== -1) value = value.slice(0, commentIdx).trimEnd()
41
+ }
37
42
  env[key] = value
38
43
  }
39
44
  return env
@@ -0,0 +1,87 @@
1
+ import { defineCommand } from 'citty'
2
+ import chalk from 'chalk'
3
+ import { readFileSync } from 'fs'
4
+ import { loadConfig, getResolvedInstance } from '../config'
5
+ import { BodClient } from '../client'
6
+
7
+ export default defineCommand({
8
+ meta: { name: 'publish', description: 'Publish a package to Bodify registry' },
9
+ args: {
10
+ tag: { type: 'string', description: 'Dist-tag (default: latest)', default: 'latest' },
11
+ },
12
+ async run({ args }) {
13
+ // Read package.json
14
+ let pkg: { name: string; version: string; description?: string }
15
+ try {
16
+ pkg = JSON.parse(readFileSync('package.json', 'utf-8'))
17
+ } catch {
18
+ console.error(chalk.red('No package.json found in current directory'))
19
+ process.exit(1)
20
+ }
21
+ if (!pkg.name || !pkg.version) {
22
+ console.error(chalk.red('package.json must have name and version'))
23
+ process.exit(1)
24
+ }
25
+
26
+ const { url, apiKey, name: instanceName, instance } = getResolvedInstance(loadConfig())
27
+ if (!instance.capabilities.registryEnabled) {
28
+ console.error(chalk.red(`Registry not enabled on instance "${instanceName}"`))
29
+ process.exit(1)
30
+ }
31
+
32
+ console.log(chalk.dim(`Publishing ${pkg.name}@${pkg.version} to ${instanceName}...`))
33
+
34
+ // Pack tarball (npm pack works with any package manager)
35
+ const pack = Bun.spawnSync(['npm', 'pack', '--pack-destination', '.'], { stderr: 'pipe', stdout: 'pipe' })
36
+ if (pack.exitCode !== 0) {
37
+ console.error(chalk.red(`npm pack failed: ${pack.stderr.toString()}`))
38
+ process.exit(1)
39
+ }
40
+ const tarballPath = pack.stdout.toString().trim().split('\n').pop()!
41
+ const tarballData = readFileSync(tarballPath)
42
+ const tarballBase64 = tarballData.toString('base64')
43
+
44
+ // Build npm-compatible publish body
45
+ const distTag = args.tag || 'latest'
46
+ const safeName = pkg.name.replace('/', '%2f')
47
+ const tarballFilename = `${pkg.name.replace('@', '').replace('/', '-')}-${pkg.version}.tgz`
48
+
49
+ const body = {
50
+ name: pkg.name,
51
+ 'dist-tags': { [distTag]: pkg.version },
52
+ versions: {
53
+ [pkg.version]: {
54
+ name: pkg.name,
55
+ version: pkg.version,
56
+ description: pkg.description,
57
+ dist: {
58
+ tarball: `${url}/api/registry/${safeName}/-/${tarballFilename}`,
59
+ },
60
+ },
61
+ },
62
+ _attachments: {
63
+ [tarballFilename]: {
64
+ content_type: 'application/octet-stream',
65
+ data: tarballBase64,
66
+ length: tarballData.length,
67
+ },
68
+ },
69
+ }
70
+
71
+ const client = new BodClient(url, apiKey)
72
+ try {
73
+ await client.put(`/registry/${encodeURIComponent(pkg.name)}`, body)
74
+ console.log(chalk.green(`✓ Published ${pkg.name}@${pkg.version}`))
75
+ } catch (e: any) {
76
+ if (e.message?.includes('409')) {
77
+ console.error(chalk.red(`${pkg.name}@${pkg.version} already exists`))
78
+ } else {
79
+ console.error(chalk.red(`Publish failed: ${e.message}`))
80
+ }
81
+ process.exit(1)
82
+ }
83
+
84
+ // Cleanup tarball
85
+ try { (await import('fs')).unlinkSync(tarballPath) } catch {}
86
+ },
87
+ })
@@ -2,7 +2,7 @@ import { defineCommand } from 'citty'
2
2
  import chalk from 'chalk'
3
3
  import { loadConfig, getResolvedInstance } from '../config'
4
4
  import { BodClient } from '../client'
5
- import { resolveAppId, resolveAppName } from '../utils/resolve'
5
+ import { resolveAppId, resolveAppName, readInstanceFromYaml } from '../utils/resolve'
6
6
 
7
7
  export default defineCommand({
8
8
  meta: { name: 'rollback', description: 'Rollback to previous deployment' },
@@ -11,7 +11,7 @@ export default defineCommand({
11
11
  branch: { type: 'string', description: 'Branch to rollback (default: production branch)' },
12
12
  },
13
13
  async run({ args }) {
14
- const { url, apiKey } = getResolvedInstance(loadConfig())
14
+ const { url, apiKey } = getResolvedInstance(loadConfig(), readInstanceFromYaml())
15
15
  const client = new BodClient(url, apiKey)
16
16
 
17
17
  const appName = resolveAppName(args.app)
package/src/config.ts CHANGED
@@ -63,15 +63,14 @@ export function getDefaultInstance(config: Config): { name: string; instance: In
63
63
  return { name, instance: config.instances[name] }
64
64
  }
65
65
 
66
- /** Override instance from --instance flag or BOD_INSTANCE env var */
67
- let instanceOverride: string | undefined
68
-
66
+ /** Override instance from --instance flag or BOD_INSTANCE env var.
67
+ * Uses process.env to avoid bundler module-duplication issues. */
69
68
  export function setInstanceOverride(name: string | undefined) {
70
- instanceOverride = name
69
+ if (name) process.env._BOD_INSTANCE_OVERRIDE = name
71
70
  }
72
71
 
73
- export function getResolvedInstance(config: Config): { name: string; url: string; apiKey: string; instance: Instance } {
74
- const override = instanceOverride ?? process.env.BOD_INSTANCE
72
+ export function getResolvedInstance(config: Config, _yamlInstance?: string): { name: string; url: string; apiKey: string; instance: Instance } {
73
+ const override = process.env._BOD_INSTANCE_OVERRIDE ?? process.env.BOD_INSTANCE
75
74
  if (override) {
76
75
  const inst = config.instances[override]
77
76
  if (!inst) throw new Error(`Instance "${override}" not found in config. Available: ${Object.keys(config.instances).join(', ')}`)
@@ -7,6 +7,7 @@ export function printTable(rows: Record<string, unknown>[], columns?: string[])
7
7
  const table = new Table({
8
8
  head: keys.map(k => chalk.bold(k)),
9
9
  style: { head: [], border: [] },
10
+ chars: { top: '-', 'top-mid': '+', 'top-left': '+', 'top-right': '+', bottom: '-', 'bottom-mid': '+', 'bottom-left': '+', 'bottom-right': '+', left: '|', 'left-mid': '+', mid: '-', 'mid-mid': '+', right: '|', 'right-mid': '+', middle: '|' },
10
11
  })
11
12
  for (const row of rows) {
12
13
  table.push(keys.map(k => String(row[k] ?? '')))
@@ -1,7 +1,28 @@
1
1
  import chalk from 'chalk'
2
2
  import { readFileSync } from 'fs'
3
+ import { parse } from 'yaml'
3
4
  import { BodClient } from '../client'
4
5
 
6
+ interface BodifyYaml {
7
+ name?: string
8
+ instance?: string
9
+ deploy?: 'upload' | 'git'
10
+ exclude?: string[]
11
+ [key: string]: unknown
12
+ }
13
+
14
+ let _parsedYaml: BodifyYaml | null | undefined
15
+
16
+ function readYaml(): BodifyYaml | null {
17
+ if (_parsedYaml !== undefined) return _parsedYaml
18
+ try {
19
+ _parsedYaml = parse(readFileSync('bodify.yaml', 'utf-8')) as BodifyYaml
20
+ } catch {
21
+ _parsedYaml = null
22
+ }
23
+ return _parsedYaml
24
+ }
25
+
5
26
  export async function resolveAppId(client: BodClient, nameOrId: string): Promise<string> {
6
27
  const apps = await client.get<any[]>('/apps')
7
28
  const match = apps.find(a => a.name === nameOrId || a.id === nameOrId)
@@ -13,10 +34,66 @@ export async function resolveAppId(client: BodClient, nameOrId: string): Promise
13
34
  }
14
35
 
15
36
  export function readAppNameFromYaml(): string | undefined {
37
+ return readYaml()?.name
38
+ }
39
+
40
+ export function readInstanceFromYaml(): string | undefined {
41
+ return readYaml()?.instance
42
+ }
43
+
44
+ export function readDeployModeFromYaml(): 'upload' | 'git' | undefined {
45
+ const mode = readYaml()?.deploy
46
+ return mode === 'upload' ? 'upload' : mode === 'git' ? 'git' : undefined
47
+ }
48
+
49
+ export function readExcludesFromYaml(): string[] {
50
+ return readYaml()?.exclude ?? []
51
+ }
52
+
53
+ /** Scan all package.json files for file:.. dependencies and return unique relative paths */
54
+ export function detectSiblingDeps(): string[] {
55
+ const { existsSync, readFileSync, readdirSync } = require('fs')
56
+ const paths = new Set<string>()
57
+
58
+ const scan = (pkgPath: string) => {
59
+ if (!existsSync(pkgPath)) return
60
+ try {
61
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
62
+ for (const field of ['dependencies', 'devDependencies']) {
63
+ if (!pkg[field]) continue
64
+ for (const ver of Object.values(pkg[field]) as string[]) {
65
+ if (ver.startsWith('file:') && ver.includes('..')) {
66
+ // Normalize: resolve relative to project root
67
+ const rel = ver.slice(5) // strip 'file:'
68
+ // From package.json dir, resolve the relative path, then make it relative to cwd
69
+ const { resolve, relative } = require('path')
70
+ const { dirname } = require('path')
71
+ const abs = resolve(dirname(pkgPath), rel)
72
+ const fromCwd = relative(process.cwd(), abs)
73
+ if (fromCwd.startsWith('..') && existsSync(abs)) paths.add(fromCwd)
74
+ }
75
+ }
76
+ }
77
+ } catch {}
78
+ }
79
+
80
+ // Root package.json
81
+ scan('package.json')
82
+
83
+ // Workspace packages
16
84
  try {
17
- const yaml = readFileSync('bodify.yaml', 'utf-8')
18
- return yaml.match(/name:\s*(.+)/)?.[1]?.trim()
19
- } catch { return undefined }
85
+ const root = JSON.parse(readFileSync('package.json', 'utf8'))
86
+ for (const pattern of root.workspaces ?? []) {
87
+ const base = pattern.replace(/\/?\*$/, '')
88
+ if (existsSync(base)) {
89
+ for (const dir of readdirSync(base)) {
90
+ scan(`${base}/${dir}/package.json`)
91
+ }
92
+ }
93
+ }
94
+ } catch {}
95
+
96
+ return [...paths]
20
97
  }
21
98
 
22
99
  /** Resolve app name from explicit arg or bodify.yaml fallback. Logs clearly when falling back. */