bod-cli 0.10.3 → 0.10.5

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.10.3",
3
+ "version": "0.10.5",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "bod": "./src/cli.ts"
package/src/cli.ts CHANGED
@@ -5,6 +5,7 @@ import { configExists, setInstanceOverride } from './config'
5
5
  import loginCmd from './commands/login'
6
6
  import appsCmd from './commands/apps'
7
7
  import deployCmd from './commands/deploy'
8
+ import packCmd from './commands/pack'
8
9
  import rollbackCmd from './commands/rollback'
9
10
  import logsCmd from './commands/logs'
10
11
  import envCmd from './commands/env'
@@ -39,6 +40,7 @@ const subCommands = {
39
40
  login: loginCmd,
40
41
  init: initCmd,
41
42
  deploy: deployCmd,
43
+ pack: packCmd,
42
44
  rollback: rollbackCmd,
43
45
  apps: appsCmd,
44
46
  logs: logsCmd,
@@ -2,6 +2,8 @@ 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 { formatSize } from '../utils/output'
6
+ import { resolveExcludes, tarballOffenders } from '../utils/excludes'
5
7
  import { resolveAppId, resolveAppName, readInstanceFromYaml, readDeployModeFromYaml, readExcludesFromYaml, detectSiblingDeps, readYamlConfig, resolveRepoFromYaml } from '../utils/resolve'
6
8
 
7
9
  async function detectBranch(explicit?: string): Promise<string> {
@@ -11,16 +13,8 @@ async function detectBranch(explicit?: string): Promise<string> {
11
13
  return branch || 'main'
12
14
  }
13
15
 
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
-
20
16
  async function uploadDeploy(client: BodClient, appId: string, branch: string) {
21
- const defaultExcludes = ['node_modules', '.git', 'dist', '.env.local', '.env.*.local']
22
- const userExcludes = readExcludesFromYaml()
23
- const allExcludes = [...new Set([...defaultExcludes, ...userExcludes])]
17
+ const allExcludes = resolveExcludes(readExcludesFromYaml())
24
18
  const excludeFlags = allExcludes.map(e => `--exclude=${e}`)
25
19
 
26
20
  // Auto-detect file:.. dependencies and include them in the tarball
@@ -113,6 +107,13 @@ async function uploadDeploy(client: BodClient, appId: string, branch: string) {
113
107
 
114
108
  const file = Bun.file(tmpFile)
115
109
  const size = file.size
110
+ // A source upload is normally ~1MB. Past this, name the culprit instead of leaving
111
+ // the operator to go hunting with `du` (that hunt is why `bod pack` exists).
112
+ if (size > 50 * 1e6) {
113
+ console.log(chalk.yellow(`Warning: upload tarball is ${formatSize(size)}. Largest entries:`))
114
+ for (const o of tarballOffenders(tmpFile)) console.log(chalk.yellow(` ${formatSize(o.bytes).padStart(10)} ${o.path}`))
115
+ console.log(chalk.yellow('Add the offender to `exclude:` in bodify.yaml, or run `bod pack --max-mb 50` in a predeploy guard.'))
116
+ }
116
117
  console.log(chalk.dim(`Uploading ${formatSize(size)}...`))
117
118
 
118
119
  const start = Date.now()
@@ -0,0 +1,59 @@
1
+ import { defineCommand } from 'citty'
2
+ import chalk from 'chalk'
3
+ import { formatSize } from '../utils/output'
4
+ import { DEFAULT_EXCLUDES, resolveExcludes, tarballOffenders } from '../utils/excludes'
5
+ import { readExcludesFromYaml } from '../utils/resolve'
6
+
7
+ /**
8
+ * `bod pack` — pack the exact `bod deploy --upload` tarball WITHOUT uploading, report
9
+ * its size, and (with --max-mb) fail naming the top offenders by size.
10
+ *
11
+ * This is the shared home for the bloat guard apps used to hand-roll (blank's
12
+ * scripts/guard-deploy.ts duplicated bod-cli's default exclude list, so it drifted).
13
+ * An app picks it up by replacing its own tar-and-measure step with:
14
+ * bod pack --max-mb 50 # nonzero exit ⇒ predeploy blocks
15
+ */
16
+ export default defineCommand({
17
+ meta: { name: 'pack', description: 'Pack the upload tarball locally and report its size / top offenders' },
18
+ args: {
19
+ 'max-mb': { type: 'string', description: 'Fail (exit 1) if the tarball exceeds this many MB' },
20
+ top: { type: 'string', description: 'How many offenders to list (default 8)' },
21
+ 'list-excludes': { type: 'boolean', description: 'Print the effective exclude set and exit' },
22
+ },
23
+ async run({ args }) {
24
+ const excludes = resolveExcludes(readExcludesFromYaml())
25
+ if (args['list-excludes']) {
26
+ for (const p of excludes) {
27
+ const why = DEFAULT_EXCLUDES.find(d => d.pattern === p)?.why
28
+ console.log(`${p}${why ? chalk.dim(` — ${why}`) : chalk.dim(' — from bodify.yaml')}`)
29
+ }
30
+ return
31
+ }
32
+
33
+ const tmpFile = `${Bun.env.TMPDIR || '/tmp'}/bod-pack-${Date.now()}.tar.gz`
34
+ const tar = Bun.spawnSync(['tar', 'czh', ...excludes.map(e => `--exclude=${e}`), '-f', tmpFile, '.'], {
35
+ stderr: 'pipe',
36
+ env: { ...process.env, COPYFILE_DISABLE: '1' },
37
+ })
38
+ if (tar.exitCode !== 0) {
39
+ console.error(chalk.red(`tar failed: ${tar.stderr.toString().slice(0, 300)}`))
40
+ process.exit(1)
41
+ }
42
+ const bytes = Bun.file(tmpFile).size
43
+ const limit = args['max-mb'] ? Number(args['max-mb']) : undefined
44
+ const over = limit !== undefined && bytes / 1e6 > limit
45
+
46
+ if (over || Bun.env.BOD_PACK_VERBOSE) {
47
+ const top = tarballOffenders(tmpFile, args.top ? Number(args.top) : 8)
48
+ console.error(chalk.bold('\nTop entries in the tarball (uncompressed):'))
49
+ for (const o of top) console.error(` ${formatSize(o.bytes).padStart(10)} ${o.path}`)
50
+ }
51
+ try { (await import('fs')).unlinkSync(tmpFile) } catch {}
52
+
53
+ if (over) {
54
+ console.error(chalk.red(`\nupload tarball ${(bytes / 1e6).toFixed(1)}MB exceeds ${limit}MB ceiling — exclude the offender above in bodify.yaml.`))
55
+ process.exit(1)
56
+ }
57
+ console.log(chalk.green(`upload tarball ${formatSize(bytes)}${limit ? ` (ceiling ${limit}MB)` : ''} OK`))
58
+ },
59
+ })
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Upload-deploy exclude resolution.
3
+ *
4
+ * `bod deploy --upload` tars the WORKING TREE (deliberately NOT .gitignore-driven —
5
+ * untracked-but-needed files like generated config and built assets must still ship).
6
+ * That left every app hand-maintaining its own `exclude:` list, which drifts from
7
+ * reality until a large local-only dir silently rides the upload and breaks a deploy
8
+ * (2026-08-19: `.agent/checkpoints.git` ~56MB blew the 50MB ceiling on Blank).
9
+ *
10
+ * ── BSD-tar pattern gotcha (hard-won, do not "tidy" these into globs) ─────────────
11
+ * `tar --exclude=NAME` matches NAME as any path COMPONENT at any depth — so a bare
12
+ * `.agent` kills `./.agent` and `./packages/x/.agent` alike. the glob form
13
+ * `star-slash-native` does NOT match the top-level `./native` on BSD tar (macOS):
14
+ * that form once shipped
15
+ * native/ (~1.7GB NativeScript build) to prod. KEEP DEFAULTS BARE. The scaffold
16
+ * template still emits that same glob form for tests/, which is the same latent bug.
17
+ * Covered by test/excludes.test.ts ("bare pattern matches top level").
18
+ *
19
+ * ── Composition ──────────────────────────────────────────────────────────────────
20
+ * Effective set = DEFAULT_EXCLUDES ∪ bodify.yaml `exclude:` (union, never replace).
21
+ * Escape hatch: an entry prefixed with `!` opts a default back IN, for the rare app
22
+ * that genuinely serves one of these paths:
23
+ *
24
+ * exclude:
25
+ * - "native" # app-specific addition
26
+ * - "!dist" # this app ships a prebuilt dist/ — undo the default
27
+ *
28
+ * `!` only cancels defaults; it is not a general negation (tar has no such concept).
29
+ */
30
+
31
+ /** Applied to every `--upload` deploy. Bare names on purpose (see gotcha above).
32
+ * Bar for entry: unambiguously LOCAL state that no app can serve at runtime.
33
+ * Deliberately NOT here: `tests`/`test`, `native`, `store`, `evals` — all plausible
34
+ * real source-directory names; a bare pattern would match them at any depth, and
35
+ * dropping something an app serves is far worse than a few MB of tarball. */
36
+ export const DEFAULT_EXCLUDES: { pattern: string; why: string }[] = [
37
+ { pattern: 'node_modules', why: 'prod re-installs from the lockfile' },
38
+ { pattern: '.git', why: 'repo history; the upload is a source snapshot' },
39
+ { pattern: 'dist', why: 'build output — prod builds from source (pre-existing default)' },
40
+ { pattern: '.env.local', why: 'local-only secrets; prod env comes from the platform' },
41
+ { pattern: '.env.*.local', why: 'local-only per-env secrets' },
42
+ { pattern: '.agent', why: 'agentx runtime state; .agent/checkpoints.git alone hit ~56MB' },
43
+ { pattern: '.claude', why: 'Claude Code local session/settings state' },
44
+ { pattern: '.cursor', why: 'Cursor editor local state' },
45
+ { pattern: '.vscode', why: 'VS Code local workspace state' },
46
+ { pattern: '.idea', why: 'JetBrains local workspace state' },
47
+ { pattern: '.tmp', why: 'repo-wide scratch convention — never runtime input' },
48
+ { pattern: 'scratchpad', why: 'agent scratch dir, same class as .tmp' },
49
+ { pattern: '.bodify', why: 'local `bod serve` state / dev database' },
50
+ // Enumerating these by hand is what failed: `.mcp-android`/`.mcp-ios` were listed
51
+ // while `.mcp-playwright/profile` (41.8MB) and `.mcp-cursor/cache` (7.3MB) sailed
52
+ // through on para-li. The family is the unit, not each member. Still a BARE
53
+ // pattern (no leading `*/`), so it matches at top level too — pinned by test.
54
+ { pattern: '.mcp-*', why: 'local MCP tool state (android/ios/playwright/cursor/…)' },
55
+ { pattern: '.bodify-bak*', why: 'local `bod serve` db backups — same class as .bodify' },
56
+ { pattern: '.frame-dev', why: 'frame dev-server cache' },
57
+ { pattern: '.DS_Store', why: 'macOS Finder metadata' },
58
+ ]
59
+
60
+ /** Union the built-in defaults with an app's `exclude:` list, honouring `!x` opt-ins. */
61
+ export function resolveExcludes(userExcludes: string[] = []): string[] {
62
+ const optedIn = new Set(
63
+ userExcludes.filter(e => e.startsWith('!')).map(e => e.slice(1).trim()),
64
+ )
65
+ const additions = userExcludes.filter(e => !e.startsWith('!'))
66
+ const defaults = DEFAULT_EXCLUDES.map(d => d.pattern).filter(p => !optedIn.has(p))
67
+ // Late `!x` also cancels a same-named app entry, so `!dist` means what it reads.
68
+ return [...new Set([...defaults, ...additions])].filter(p => !optedIn.has(p))
69
+ }
70
+
71
+ export interface Offender { path: string; bytes: number }
72
+
73
+ /**
74
+ * Top space consumers in an ALREADY-PACKED tarball, so a size failure names the
75
+ * culprit instead of guessing at it. Reads the archive index (uncompressed member
76
+ * sizes) and rolls entries up to their first two path components, which is what
77
+ * makes `.agent/checkpoints.git` legible rather than 4,000 loose object files.
78
+ */
79
+ export function tarballOffenders(tarPath: string, limit = 8): Offender[] {
80
+ const proc = Bun.spawnSync(['tar', 'tvf', tarPath], { stdout: 'pipe', stderr: 'pipe' })
81
+ if (proc.exitCode !== 0) return []
82
+ const totals = new Map<string, number>()
83
+ for (const line of proc.stdout.toString().split('\n')) {
84
+ // The owner columns differ between bsdtar (`0 elya wheel`) and GNU tar (`elya/wheel`),
85
+ // so anchor on the stable part: <size> <mon> <day> <time-or-year> <path>.
86
+ const m = line.match(/\s(\d+)\s+\w{3}\s+\d+\s+[\d:]+\s+(.+)$/)
87
+ if (!m) continue
88
+ const bytes = Number(m[1])
89
+ const parts = m[2].replace(/^\.\//, '').split('/').filter(Boolean)
90
+ if (!parts.length) continue
91
+ const key = parts.slice(0, 2).join('/')
92
+ totals.set(key, (totals.get(key) ?? 0) + bytes)
93
+ }
94
+ return [...totals]
95
+ .map(([path, bytes]) => ({ path, bytes }))
96
+ .sort((a, b) => b.bytes - a.bytes)
97
+ .slice(0, limit)
98
+ }
@@ -25,3 +25,10 @@ export function printKv(entries: Record<string, unknown>) {
25
25
  console.log(`${chalk.bold(k.padEnd(maxKey))} ${v}`)
26
26
  }
27
27
  }
28
+
29
+ /** Human-readable byte size. Shared by deploy + pack. */
30
+ export function formatSize(bytes: number): string {
31
+ if (bytes < 1024) return `${bytes} B`
32
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
33
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
34
+ }
@@ -0,0 +1,135 @@
1
+ import { describe, expect, test, beforeAll, afterAll } from 'bun:test'
2
+ import { mkdirSync, writeFileSync, rmSync } from 'fs'
3
+ import { join } from 'path'
4
+ import { tmpdir } from 'os'
5
+ import { DEFAULT_EXCLUDES, resolveExcludes, tarballOffenders } from '../src/utils/excludes'
6
+
7
+ const FIX = join(tmpdir(), `bod-excludes-fixture-${process.pid}`)
8
+ const TAR = join(tmpdir(), `bod-excludes-fixture-${process.pid}.tar.gz`)
9
+
10
+ /** Pack FIX with `excludes` exactly the way deploy.ts does, return the member list. */
11
+ function pack(excludes: string[], out = TAR): string[] {
12
+ const r = Bun.spawnSync(['tar', 'czh', ...excludes.map(e => `--exclude=${e}`), '-f', out, '.'], {
13
+ cwd: FIX, stderr: 'pipe', env: { ...process.env, COPYFILE_DISABLE: '1' },
14
+ })
15
+ if (r.exitCode !== 0) throw new Error(r.stderr.toString())
16
+ const l = Bun.spawnSync(['tar', 'tzf', out], { stdout: 'pipe' })
17
+ return l.stdout.toString().split('\n').map(s => s.replace(/^\.\//, '').replace(/\/$/, '')).filter(Boolean)
18
+ }
19
+
20
+ beforeAll(() => {
21
+ const f = (p: string, body = 'x') => {
22
+ const abs = join(FIX, p)
23
+ mkdirSync(abs.slice(0, abs.lastIndexOf('/')), { recursive: true })
24
+ writeFileSync(abs, body)
25
+ }
26
+ // local state that must NEVER ship — at top level (the BSD-tar failure mode) AND nested
27
+ f('.agent/checkpoints.git/pack.bin', 'A'.repeat(3_000_000)) // the 2026-08-19 incident
28
+ f('packages/x/.agent/state.json')
29
+ f('.claude/settings.json'); f('.cursor/rules'); f('.tmp/junk'); f('scratchpad/note')
30
+ f('.bodify/dev.db'); f('.mcp-ios/x'); f('.mcp-android/x'); f('.vscode/settings.json')
31
+ // the family members hand-enumeration missed on para-li (41.8MB + 7.3MB)
32
+ f('.mcp-playwright/profile/p.bin'); f('.mcp-cursor/cache/c.bin')
33
+ f('packages/x/.mcp-ios/s.json'); f('.bodify-bak-1/data.db')
34
+ f('.frame-dev/cache'); f('.DS_Store'); f('.env.local', 'SECRET=1')
35
+ // per-app exclusion target
36
+ f('native/App.js')
37
+ // MUST SHIP
38
+ f('public/app.js', 'served')
39
+ f('config.generated.json', '{"generated":true}') // untracked-but-needed — why .gitignore is not used
40
+ f('dist/bundle.js', 'built') // re-included via the `!dist` escape hatch
41
+ f('src/store/index.ts') // a real source dir named like a hand-rolled exclude
42
+ f('src/test/unit.ts')
43
+ f('src/mcp-client/keep.ts') // a real source dir the `.mcp-*` family must NOT eat
44
+ })
45
+ afterAll(() => {
46
+ rmSync(FIX, { recursive: true, force: true })
47
+ rmSync(TAR, { force: true })
48
+ })
49
+
50
+ describe('resolveExcludes', () => {
51
+ test('.agent is a default (the incident that motivated this)', () => {
52
+ expect(DEFAULT_EXCLUDES.map(d => d.pattern)).toContain('.agent')
53
+ })
54
+
55
+ test('every default is a BARE name, never a glob path — `star/x` misses top-level ./x on BSD tar', () => {
56
+ for (const { pattern } of DEFAULT_EXCLUDES) expect(pattern).not.toContain('/')
57
+ })
58
+
59
+ test('per-app excludes COMPOSE with the defaults (union, not replace)', () => {
60
+ const r = resolveExcludes(['native'])
61
+ expect(r).toContain('native')
62
+ expect(r).toContain('.agent')
63
+ expect(r).toContain('node_modules')
64
+ })
65
+
66
+ test('`!x` opts a default back in', () => {
67
+ expect(resolveExcludes([])).toContain('dist')
68
+ expect(resolveExcludes(['!dist'])).not.toContain('dist')
69
+ expect(resolveExcludes(['!dist'])).toContain('.agent') // only the named one is cancelled
70
+ })
71
+
72
+ test('deliberately NOT defaults — plausible real source dir names', () => {
73
+ const p = DEFAULT_EXCLUDES.map(d => d.pattern)
74
+ for (const risky of ['test', 'tests', 'native', 'store', 'evals']) expect(p).not.toContain(risky)
75
+ })
76
+ })
77
+
78
+ describe('tar behaviour on this machine (BSD tar)', () => {
79
+ test('a BARE pattern excludes the dir at TOP LEVEL and at depth', () => {
80
+ const members = pack(['.agent'])
81
+ expect(members).not.toContain('.agent/checkpoints.git/pack.bin')
82
+ expect(members).not.toContain('packages/x/.agent/state.json')
83
+ })
84
+
85
+ // The regression guard: the glob form silently stops matching top-level dirs.
86
+ // This is the exact bug that shipped native/ (~1.7GB) to prod.
87
+ test('the `star/name` glob form does NOT match a top-level dir — so defaults must stay bare', () => {
88
+ const members = pack(['*/.agent'])
89
+ expect(members).toContain('.agent/checkpoints.git/pack.bin') // top-level SURVIVED
90
+ expect(members).not.toContain('packages/x/.agent/state.json') // nested was excluded
91
+ })
92
+ })
93
+
94
+ describe('the real upload tarball', () => {
95
+ let members: string[]
96
+ beforeAll(() => { members = pack(resolveExcludes(['native', '!dist'])) })
97
+
98
+ test('default-excluded local state is absent, including at top level', () => {
99
+ for (const gone of [
100
+ '.agent/checkpoints.git/pack.bin', 'packages/x/.agent/state.json',
101
+ '.claude/settings.json', '.cursor/rules', '.tmp/junk', 'scratchpad/note',
102
+ '.bodify/dev.db', '.mcp-ios/x', '.mcp-android/x', '.vscode/settings.json',
103
+ '.frame-dev/cache', '.DS_Store', '.env.local',
104
+ '.mcp-playwright/profile/p.bin', '.mcp-cursor/cache/c.bin',
105
+ 'packages/x/.mcp-ios/s.json', '.bodify-bak-1/data.db',
106
+ ]) expect(members).not.toContain(gone)
107
+ })
108
+
109
+ test('a per-app exclude still applies', () => {
110
+ expect(members).not.toContain('native/App.js')
111
+ })
112
+
113
+ test('the `!dist` escape hatch really re-includes a default-excluded path', () => {
114
+ expect(members).toContain('dist/bundle.js')
115
+ expect(pack(resolveExcludes([]), TAR + '.2')).not.toContain('dist/bundle.js')
116
+ rmSync(TAR + '.2', { force: true })
117
+ })
118
+
119
+ test('paths that must ship are still present', () => {
120
+ expect(members).toContain('public/app.js')
121
+ expect(members).toContain('config.generated.json') // untracked-but-needed
122
+ expect(members).toContain('src/store/index.ts')
123
+ expect(members).toContain('src/test/unit.ts')
124
+ expect(members).toContain('src/mcp-client/keep.ts') // `.mcp-*` is dot-anchored, not a substring
125
+ })
126
+ })
127
+
128
+ describe('tarballOffenders', () => {
129
+ test('names the biggest entry rolled up to two path components', () => {
130
+ pack([]) // no excludes → the 3MB .agent blob is in the archive
131
+ const top = tarballOffenders(TAR)
132
+ expect(top[0].path).toBe('.agent/checkpoints.git')
133
+ expect(top[0].bytes).toBeGreaterThan(2_900_000)
134
+ })
135
+ })