bod-cli 0.10.2 → 0.10.4
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 +1 -1
- package/src/cli.ts +2 -0
- package/src/commands/deploy.ts +10 -9
- package/src/commands/env.ts +93 -24
- package/src/commands/pack.ts +59 -0
- package/src/utils/excludes.ts +94 -0
- package/src/utils/output.ts +7 -0
- package/test/env-subs.test.ts +122 -0
- package/test/excludes.test.ts +128 -0
package/package.json
CHANGED
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,
|
package/src/commands/deploy.ts
CHANGED
|
@@ -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
|
|
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()
|
package/src/commands/env.ts
CHANGED
|
@@ -6,6 +6,21 @@ import { BodClient } from '../client'
|
|
|
6
6
|
import { printTable } from '../utils/output'
|
|
7
7
|
import { resolveAppId, resolveAppName } from '../utils/resolve'
|
|
8
8
|
|
|
9
|
+
// --- Subs PLATFORM PROVIDER scope (mirror bodify subs.secrets.ts) ---
|
|
10
|
+
// Provider credentials (Stripe secret key, Apple shared secret, Play SA) are money
|
|
11
|
+
// credentials. They must NOT land in any app's process env, so they live under a
|
|
12
|
+
// PSEUDO app id that no real app equals and no deploy can resolve. The server's
|
|
13
|
+
// resolveScope passes any `__subs__~`-prefixed app string through VERBATIM (no
|
|
14
|
+
// name→id resolution) — that verbatim pass-through is the whole point, so the CLI
|
|
15
|
+
// must send the literal string as scope.app.
|
|
16
|
+
const SUBS_SCOPE_PREFIX = '__subs__~'
|
|
17
|
+
/** Platform-wide provider scope — one credential shared by every app. Note the DOUBLE ~. */
|
|
18
|
+
const SUBS_GLOBAL_SCOPE_APP = `${SUBS_SCOPE_PREFIX}~global`
|
|
19
|
+
/** Per-app provider override — the suffix MUST be the app UUID, never its name. */
|
|
20
|
+
function subsAppScope(appId: string): string {
|
|
21
|
+
return `${SUBS_SCOPE_PREFIX}${appId}`
|
|
22
|
+
}
|
|
23
|
+
|
|
9
24
|
// --- Bodify global secrets store types (mirror the agent /secrets API contract) ---
|
|
10
25
|
interface Scope { app?: string; env?: string; group?: string }
|
|
11
26
|
interface MaskedEntry { scope: Scope; masked: string; updatedAt: number; updatedBy?: string }
|
|
@@ -43,23 +58,44 @@ function buildScope(app: string | undefined, env: string | undefined, global: bo
|
|
|
43
58
|
return scope
|
|
44
59
|
}
|
|
45
60
|
|
|
46
|
-
/**
|
|
47
|
-
* app — the
|
|
61
|
+
/** Subs flags collapse the app positional the same way --global/--group do (there is
|
|
62
|
+
* no <app> positional — the pseudo scope IS the app axis). */
|
|
63
|
+
interface SubsFlags { subs?: boolean; subsApp?: string }
|
|
64
|
+
function isSubsMode(s: SubsFlags): boolean {
|
|
65
|
+
return !!s.subs || !!s.subsApp
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Citty binds the FIRST positional to `app`, but under --global/--group/--subs there is
|
|
69
|
+
* no app — the leading positional is really the next one (KEY=VALUE / key). Shift it back
|
|
48
70
|
* so `app` is undefined and `rest` holds the real positionals. Returns the genuine app
|
|
49
71
|
* only when no scope flag was passed. */
|
|
50
|
-
function resolvePositionals(app: string | undefined, rest: (string | undefined)[], global: boolean, group?: string) {
|
|
51
|
-
if (global || group) return { app: undefined, rest: [app, ...rest].filter((p): p is string => p !== undefined) }
|
|
72
|
+
function resolvePositionals(app: string | undefined, rest: (string | undefined)[], global: boolean, group?: string, subs?: SubsFlags) {
|
|
73
|
+
if (global || group || (subs && isSubsMode(subs))) return { app: undefined, rest: [app, ...rest].filter((p): p is string => p !== undefined) }
|
|
52
74
|
return { app, rest: rest.filter((p): p is string => p !== undefined) }
|
|
53
75
|
}
|
|
54
76
|
|
|
55
|
-
/** Guard: <app>, --global and --
|
|
56
|
-
* primary axis). --env is orthogonal
|
|
57
|
-
|
|
58
|
-
|
|
77
|
+
/** Guard: <app>, --global, --group, --subs and --subs-app are mutually exclusive (they
|
|
78
|
+
* pick the scope's primary axis). --env is orthogonal to app/group/global — but the subs
|
|
79
|
+
* pseudo scope is app-only (its reader matches scope.app by strict equality and ignores
|
|
80
|
+
* env), so --env with a subs flag would write an entry nothing can ever read: forbid it. */
|
|
81
|
+
function assertScopeFlags(app: string | undefined, global: boolean, group: string | undefined, subs: SubsFlags, env?: string) {
|
|
82
|
+
const picked = [app && '<app>', global && '--global', group && '--group', subs.subs && '--subs', subs.subsApp && '--subs-app'].filter(Boolean)
|
|
59
83
|
if (picked.length > 1) {
|
|
60
84
|
console.error(chalk.red(`Pass only one of ${picked.join(', ')} — they are mutually exclusive.`))
|
|
61
85
|
process.exit(1)
|
|
62
86
|
}
|
|
87
|
+
if (isSubsMode(subs) && env) {
|
|
88
|
+
console.error(chalk.red('--env cannot combine with --subs/--subs-app: the provider scope is app-only and an env-scoped entry would be unreadable.'))
|
|
89
|
+
process.exit(1)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Build the literal `__subs__~…` scope.app for a subs write/read.
|
|
94
|
+
* Global → `__subs__~~global` sent verbatim. Per-app → resolve NAME→id client-side
|
|
95
|
+
* (the server won't resolve a `__subs__~` string), THEN prefix. */
|
|
96
|
+
async function buildSubsScopeApp(client: BodClient, subs: SubsFlags): Promise<string> {
|
|
97
|
+
if (subs.subsApp) return subsAppScope(await resolveAppId(client, subs.subsApp))
|
|
98
|
+
return SUBS_GLOBAL_SCOPE_APP
|
|
63
99
|
}
|
|
64
100
|
|
|
65
101
|
/** Human label for a scope's primary axis (+ optional env). */
|
|
@@ -78,18 +114,39 @@ function quoteDotEnvValue(value: string): string {
|
|
|
78
114
|
}
|
|
79
115
|
|
|
80
116
|
const listCmd = defineCommand({
|
|
81
|
-
meta: { name: 'list', description: 'List env vars. Per-app: resolved KEY=VALUE. --global/--group: variables in that scope (masked).' },
|
|
117
|
+
meta: { name: 'list', description: 'List env vars. Per-app: resolved KEY=VALUE. --global/--group/--subs: variables in that scope (masked).' },
|
|
82
118
|
args: {
|
|
83
119
|
app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
|
|
84
120
|
env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
|
|
85
121
|
global: { type: 'boolean', alias: 'g', description: 'List all variables across scopes (masked)' },
|
|
86
122
|
group: { type: 'string', description: 'List masked entries scoped to a group; excludes <app>/--global' },
|
|
123
|
+
subs: { type: 'boolean', description: 'List masked platform-GLOBAL subs provider secrets (__subs__~~global; money creds kept out of app envs)' },
|
|
124
|
+
'subs-app': { type: 'string', description: 'List masked per-app subs provider secrets for this app name/id (__subs__~<appId>)' },
|
|
87
125
|
},
|
|
88
126
|
async run({ args }) {
|
|
89
127
|
const { url, apiKey } = getResolvedInstance(loadConfig())
|
|
90
128
|
const client = new BodClient(url, apiKey)
|
|
91
129
|
|
|
92
|
-
|
|
130
|
+
const subs: SubsFlags = { subs: !!args.subs, subsApp: args['subs-app'] }
|
|
131
|
+
assertScopeFlags(args.app, !!args.global, args.group, subs, args.env)
|
|
132
|
+
|
|
133
|
+
if (isSubsMode(subs)) {
|
|
134
|
+
const scopeApp = await buildSubsScopeApp(client, subs)
|
|
135
|
+
const vars = await client.get<MaskedVar[]>('/secrets/vars')
|
|
136
|
+
const rows: Record<string, unknown>[] = []
|
|
137
|
+
for (const v of vars) {
|
|
138
|
+
for (const e of v.entries) {
|
|
139
|
+
if (e.scope.app !== scopeApp) continue
|
|
140
|
+
rows.push({ key: v.key, scope: renderScope(e.scope), value: e.masked })
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!rows.length) {
|
|
144
|
+
console.log(chalk.dim(`No subs provider secrets set for scope app:${scopeApp}.`))
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
printTable(rows, ['key', 'scope', 'value'])
|
|
148
|
+
return
|
|
149
|
+
}
|
|
93
150
|
|
|
94
151
|
if (args.global || args.group) {
|
|
95
152
|
const vars = await client.get<MaskedVar[]>('/secrets/vars')
|
|
@@ -122,7 +179,7 @@ const listCmd = defineCommand({
|
|
|
122
179
|
})
|
|
123
180
|
|
|
124
181
|
const setCmd = defineCommand({
|
|
125
|
-
meta: { name: 'set', description: 'Set a secret (KEY=VALUE or -f .env). Scoped to <app>, --global, --group, and/or --env.' },
|
|
182
|
+
meta: { name: 'set', description: 'Set a secret (KEY=VALUE or -f .env). Scoped to <app>, --global, --group, --subs/--subs-app, and/or --env.' },
|
|
126
183
|
args: {
|
|
127
184
|
app: { type: 'positional', description: 'App name (or reads from bodify.yaml; omit with --global)', required: false },
|
|
128
185
|
pair: { type: 'positional', description: 'KEY=VALUE', required: false },
|
|
@@ -130,18 +187,24 @@ const setCmd = defineCommand({
|
|
|
130
187
|
env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
|
|
131
188
|
global: { type: 'boolean', alias: 'g', description: 'Shared var (no app); broadens reach' },
|
|
132
189
|
group: { type: 'string', description: 'Scope to a group (apps that inherit it); excludes <app>/--global' },
|
|
190
|
+
subs: { type: 'boolean', description: 'Platform-GLOBAL subs provider secret (__subs__~~global). For money creds (Stripe/Apple/Play) — kept OUT of every app env; NOT --global.' },
|
|
191
|
+
'subs-app': { type: 'string', description: 'Per-app subs provider override for this app name/id (resolved to __subs__~<appId>)' },
|
|
133
192
|
},
|
|
134
193
|
async run({ args }) {
|
|
135
194
|
const { url, apiKey } = getResolvedInstance(loadConfig())
|
|
136
195
|
const client = new BodClient(url, apiKey)
|
|
137
196
|
|
|
138
|
-
|
|
139
|
-
const
|
|
197
|
+
const subs: SubsFlags = { subs: !!args.subs, subsApp: args['subs-app'] }
|
|
198
|
+
const subsMode = isSubsMode(subs)
|
|
199
|
+
// Under --global/--group/--subs the leading positional is the pair, not an app — shift it.
|
|
200
|
+
const { app, rest } = resolvePositionals(args.app, [args.pair], !!args.global, args.group, subs)
|
|
140
201
|
// Real conflict: a scope flag AND a surplus leading positional (a true <app>) before the pair.
|
|
141
|
-
assertScopeFlags(rest.length > 1 ? rest[0] : app, !!args.global, args.group)
|
|
142
|
-
const pair = (args.global || args.group) ? rest[0] : args.pair
|
|
143
|
-
const appName = args.global || args.group ? undefined : resolveAppName(app)
|
|
144
|
-
const scope =
|
|
202
|
+
assertScopeFlags(rest.length > 1 ? rest[0] : app, !!args.global, args.group, subs, args.env)
|
|
203
|
+
const pair = (args.global || args.group || subsMode) ? rest[0] : args.pair
|
|
204
|
+
const appName = args.global || args.group || subsMode ? undefined : resolveAppName(app)
|
|
205
|
+
const scope = subsMode
|
|
206
|
+
? { app: await buildSubsScopeApp(client, subs) }
|
|
207
|
+
: buildScope(appName, args.env, !!args.global, args.group)
|
|
145
208
|
|
|
146
209
|
if (args.file) {
|
|
147
210
|
const f = Bun.file(args.file)
|
|
@@ -182,22 +245,28 @@ const unsetCmd = defineCommand({
|
|
|
182
245
|
env: { type: 'string', alias: 'e', description: 'Environment (e.g. prod)' },
|
|
183
246
|
global: { type: 'boolean', alias: 'g', description: 'Target the shared (no-app) entry' },
|
|
184
247
|
group: { type: 'string', description: 'Target a group-scoped entry; excludes <app>/--global' },
|
|
248
|
+
subs: { type: 'boolean', description: 'Target the platform-GLOBAL subs provider entry (__subs__~~global)' },
|
|
249
|
+
'subs-app': { type: 'string', description: 'Target the per-app subs provider entry for this app name/id (__subs__~<appId>)' },
|
|
185
250
|
},
|
|
186
251
|
async run({ args }) {
|
|
187
252
|
const { url, apiKey } = getResolvedInstance(loadConfig())
|
|
188
253
|
const client = new BodClient(url, apiKey)
|
|
189
254
|
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
const
|
|
255
|
+
const subs: SubsFlags = { subs: !!args.subs, subsApp: args['subs-app'] }
|
|
256
|
+
const subsMode = isSubsMode(subs)
|
|
257
|
+
// Under --global/--group/--subs the leading positional is the key, not an app — shift it.
|
|
258
|
+
const { app, rest } = resolvePositionals(args.app, [args.key], !!args.global, args.group, subs)
|
|
259
|
+
assertScopeFlags(rest.length > 1 ? rest[0] : app, !!args.global, args.group, subs, args.env)
|
|
260
|
+
const key = (args.global || args.group || subsMode) ? rest[0] : args.key
|
|
194
261
|
if (!key) {
|
|
195
|
-
console.error(chalk.red('Variable name required: bod env unset <app> KEY (use --global/--group for shared entries)'))
|
|
262
|
+
console.error(chalk.red('Variable name required: bod env unset <app> KEY (use --global/--group/--subs for shared entries)'))
|
|
196
263
|
process.exit(1)
|
|
197
264
|
}
|
|
198
|
-
const appName = args.global || args.group ? undefined : resolveAppName(app)
|
|
265
|
+
const appName = args.global || args.group || subsMode ? undefined : resolveAppName(app)
|
|
199
266
|
const qs: string[] = []
|
|
200
|
-
|
|
267
|
+
// Subs sends the literal __subs__~… as the app axis (server passes it through verbatim).
|
|
268
|
+
if (subsMode) qs.push(`app=${encodeURIComponent(await buildSubsScopeApp(client, subs))}`)
|
|
269
|
+
else if (appName) qs.push(`app=${encodeURIComponent(appName)}`)
|
|
201
270
|
if (args.group) qs.push(`group=${encodeURIComponent(args.group)}`)
|
|
202
271
|
if (args.env) qs.push(`env=${encodeURIComponent(args.env)}`)
|
|
203
272
|
const suffix = qs.length ? `?${qs.join('&')}` : ''
|
|
@@ -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,94 @@
|
|
|
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
|
+
{ pattern: '.mcp-android', why: 'local device-automation MCP state' },
|
|
51
|
+
{ pattern: '.mcp-ios', why: 'local device-automation MCP state' },
|
|
52
|
+
{ pattern: '.frame-dev', why: 'frame dev-server cache' },
|
|
53
|
+
{ pattern: '.DS_Store', why: 'macOS Finder metadata' },
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
/** Union the built-in defaults with an app's `exclude:` list, honouring `!x` opt-ins. */
|
|
57
|
+
export function resolveExcludes(userExcludes: string[] = []): string[] {
|
|
58
|
+
const optedIn = new Set(
|
|
59
|
+
userExcludes.filter(e => e.startsWith('!')).map(e => e.slice(1).trim()),
|
|
60
|
+
)
|
|
61
|
+
const additions = userExcludes.filter(e => !e.startsWith('!'))
|
|
62
|
+
const defaults = DEFAULT_EXCLUDES.map(d => d.pattern).filter(p => !optedIn.has(p))
|
|
63
|
+
// Late `!x` also cancels a same-named app entry, so `!dist` means what it reads.
|
|
64
|
+
return [...new Set([...defaults, ...additions])].filter(p => !optedIn.has(p))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface Offender { path: string; bytes: number }
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Top space consumers in an ALREADY-PACKED tarball, so a size failure names the
|
|
71
|
+
* culprit instead of guessing at it. Reads the archive index (uncompressed member
|
|
72
|
+
* sizes) and rolls entries up to their first two path components, which is what
|
|
73
|
+
* makes `.agent/checkpoints.git` legible rather than 4,000 loose object files.
|
|
74
|
+
*/
|
|
75
|
+
export function tarballOffenders(tarPath: string, limit = 8): Offender[] {
|
|
76
|
+
const proc = Bun.spawnSync(['tar', 'tvf', tarPath], { stdout: 'pipe', stderr: 'pipe' })
|
|
77
|
+
if (proc.exitCode !== 0) return []
|
|
78
|
+
const totals = new Map<string, number>()
|
|
79
|
+
for (const line of proc.stdout.toString().split('\n')) {
|
|
80
|
+
// The owner columns differ between bsdtar (`0 elya wheel`) and GNU tar (`elya/wheel`),
|
|
81
|
+
// so anchor on the stable part: <size> <mon> <day> <time-or-year> <path>.
|
|
82
|
+
const m = line.match(/\s(\d+)\s+\w{3}\s+\d+\s+[\d:]+\s+(.+)$/)
|
|
83
|
+
if (!m) continue
|
|
84
|
+
const bytes = Number(m[1])
|
|
85
|
+
const parts = m[2].replace(/^\.\//, '').split('/').filter(Boolean)
|
|
86
|
+
if (!parts.length) continue
|
|
87
|
+
const key = parts.slice(0, 2).join('/')
|
|
88
|
+
totals.set(key, (totals.get(key) ?? 0) + bytes)
|
|
89
|
+
}
|
|
90
|
+
return [...totals]
|
|
91
|
+
.map(([path, bytes]) => ({ path, bytes }))
|
|
92
|
+
.sort((a, b) => b.bytes - a.bytes)
|
|
93
|
+
.slice(0, limit)
|
|
94
|
+
}
|
package/src/utils/output.ts
CHANGED
|
@@ -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,122 @@
|
|
|
1
|
+
// Verifies `bod env set/list/unset` with the subs PLATFORM PROVIDER flags emit the
|
|
2
|
+
// exact `__subs__~…` scope.app the bodify vault requires — at the real CLI surface
|
|
3
|
+
// (full citty parse + positional shift + name→id resolution), against a mock agent.
|
|
4
|
+
import { test, expect, beforeAll, afterAll } from 'bun:test'
|
|
5
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
|
|
6
|
+
import { tmpdir } from 'os'
|
|
7
|
+
import { join } from 'path'
|
|
8
|
+
|
|
9
|
+
const CLI = join(import.meta.dir, '..', 'src', 'cli.ts')
|
|
10
|
+
const APP_ID = 'e1dba964-b944-4dd0-b971-fe72ee493bf6'
|
|
11
|
+
const APP_NAME = 'blank'
|
|
12
|
+
|
|
13
|
+
interface Captured { method: string; path: string; body: any }
|
|
14
|
+
let captured: Captured[] = []
|
|
15
|
+
let server: ReturnType<typeof Bun.serve>
|
|
16
|
+
let home: string
|
|
17
|
+
|
|
18
|
+
beforeAll(() => {
|
|
19
|
+
server = Bun.serve({
|
|
20
|
+
port: 0,
|
|
21
|
+
async fetch(req) {
|
|
22
|
+
const url = new URL(req.url)
|
|
23
|
+
const body = req.method === 'PUT' || req.method === 'POST' ? await req.json().catch(() => null) : null
|
|
24
|
+
captured.push({ method: req.method, path: url.pathname + url.search, body })
|
|
25
|
+
if (url.pathname === '/api/apps') {
|
|
26
|
+
return Response.json([{ id: APP_ID, name: APP_NAME }])
|
|
27
|
+
}
|
|
28
|
+
if (url.pathname === '/api/secrets/vars' && req.method === 'GET') {
|
|
29
|
+
return Response.json([
|
|
30
|
+
{ key: 'STRIPE_SECRET_KEY', createdAt: 0, updatedAt: 0, entries: [
|
|
31
|
+
{ scope: { app: '__subs__~~global' }, masked: 'sk_l***', updatedAt: 0 },
|
|
32
|
+
{ scope: { app: `__subs__~${APP_ID}` }, masked: 'sk_a***', updatedAt: 0 },
|
|
33
|
+
{ scope: {}, masked: 'other***', updatedAt: 0 },
|
|
34
|
+
] },
|
|
35
|
+
])
|
|
36
|
+
}
|
|
37
|
+
return new Response('ok')
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
home = mkdtempSync(join(tmpdir(), 'bod-subs-test-'))
|
|
41
|
+
mkdirSync(join(home, '.bod'), { recursive: true })
|
|
42
|
+
writeFileSync(join(home, '.bod', 'config.json'), JSON.stringify({
|
|
43
|
+
defaultInstance: 'test',
|
|
44
|
+
instances: { test: { url: `http://localhost:${server.port}`, apiKey: 'test-key' } },
|
|
45
|
+
}))
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
afterAll(() => {
|
|
49
|
+
server?.stop(true)
|
|
50
|
+
rmSync(home, { recursive: true, force: true })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
async function runCli(args: string[]) {
|
|
54
|
+
captured = []
|
|
55
|
+
const proc = Bun.spawn(['bun', CLI, ...args], {
|
|
56
|
+
env: { ...process.env, HOME: home, _BOD_INSTANCE_OVERRIDE: '', BOD_INSTANCE: '' },
|
|
57
|
+
stdout: 'pipe', stderr: 'pipe',
|
|
58
|
+
})
|
|
59
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
60
|
+
new Response(proc.stdout).text(),
|
|
61
|
+
new Response(proc.stderr).text(),
|
|
62
|
+
proc.exited,
|
|
63
|
+
])
|
|
64
|
+
return { stdout, stderr, code }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
test('env set --subs → scope.app is the literal __subs__~~global (verbatim, no resolution)', async () => {
|
|
68
|
+
const { code, stderr } = await runCli(['env', 'set', '--subs', 'STRIPE_SECRET_KEY=sk_live_platform'])
|
|
69
|
+
expect(code).toBe(0)
|
|
70
|
+
const put = captured.find(c => c.method === 'PUT' && c.path.startsWith('/api/secrets/vars/STRIPE_SECRET_KEY'))
|
|
71
|
+
expect(put).toBeTruthy()
|
|
72
|
+
expect(put!.body).toEqual({ value: 'sk_live_platform', scope: { app: '__subs__~~global' } })
|
|
73
|
+
// Global form must NOT resolve any app name→id.
|
|
74
|
+
expect(captured.some(c => c.path === '/api/apps')).toBe(false)
|
|
75
|
+
expect(stderr).toBe('')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('env set --subs-app <name> → resolves name→id, then prefixes __subs__~<id>', async () => {
|
|
79
|
+
const { code } = await runCli(['env', 'set', '--subs-app', APP_NAME, 'STRIPE_SECRET_KEY=sk_live_app'])
|
|
80
|
+
expect(code).toBe(0)
|
|
81
|
+
// Name was resolved to id via /api/apps.
|
|
82
|
+
expect(captured.some(c => c.path === '/api/apps')).toBe(true)
|
|
83
|
+
const put = captured.find(c => c.method === 'PUT' && c.path.startsWith('/api/secrets/vars/STRIPE_SECRET_KEY'))
|
|
84
|
+
expect(put!.body).toEqual({ value: 'sk_live_app', scope: { app: `__subs__~${APP_ID}` } })
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('env set --subs-app accepts an app id directly', async () => {
|
|
88
|
+
const { code } = await runCli(['env', 'set', '--subs-app', APP_ID, 'APPLE_SHARED_SECRET=abc'])
|
|
89
|
+
expect(code).toBe(0)
|
|
90
|
+
const put = captured.find(c => c.method === 'PUT' && c.path.startsWith('/api/secrets/vars/APPLE_SHARED_SECRET'))
|
|
91
|
+
expect(put!.body).toEqual({ value: 'abc', scope: { app: `__subs__~${APP_ID}` } })
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('env set --subs --env is rejected (subs scope is app-only)', async () => {
|
|
95
|
+
const { code, stderr } = await runCli(['env', 'set', '--subs', '--env', 'prod', 'K=v'])
|
|
96
|
+
expect(code).toBe(1)
|
|
97
|
+
expect(stderr).toContain('app-only')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('env set --subs --global is rejected (mutually exclusive)', async () => {
|
|
101
|
+
const { code, stderr } = await runCli(['env', 'set', '--subs', '--global', 'K=v'])
|
|
102
|
+
expect(code).toBe(1)
|
|
103
|
+
expect(stderr).toContain('mutually exclusive')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('env list --subs shows only the platform-global provider entry (masked)', async () => {
|
|
107
|
+
const { code, stdout } = await runCli(['env', 'list', '--subs'])
|
|
108
|
+
expect(code).toBe(0)
|
|
109
|
+
expect(stdout).toContain('STRIPE_SECRET_KEY')
|
|
110
|
+
expect(stdout).toContain('sk_l***')
|
|
111
|
+
expect(stdout).not.toContain('sk_a***') // per-app entry excluded
|
|
112
|
+
expect(stdout).not.toContain('other***') // default-scoped entry excluded
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('env unset --subs deletes the __subs__~~global entry via app= query param', async () => {
|
|
116
|
+
const { code } = await runCli(['env', 'unset', '--subs', 'STRIPE_SECRET_KEY'])
|
|
117
|
+
expect(code).toBe(0)
|
|
118
|
+
const del = captured.find(c => c.method === 'DELETE')
|
|
119
|
+
expect(del).toBeTruthy()
|
|
120
|
+
expect(del!.path).toContain('/api/secrets/vars/STRIPE_SECRET_KEY/entry')
|
|
121
|
+
expect(del!.path).toContain(`app=${encodeURIComponent('__subs__~~global')}`)
|
|
122
|
+
})
|
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
f('.frame-dev/cache'); f('.DS_Store'); f('.env.local', 'SECRET=1')
|
|
32
|
+
// per-app exclusion target
|
|
33
|
+
f('native/App.js')
|
|
34
|
+
// MUST SHIP
|
|
35
|
+
f('public/app.js', 'served')
|
|
36
|
+
f('config.generated.json', '{"generated":true}') // untracked-but-needed — why .gitignore is not used
|
|
37
|
+
f('dist/bundle.js', 'built') // re-included via the `!dist` escape hatch
|
|
38
|
+
f('src/store/index.ts') // a real source dir named like a hand-rolled exclude
|
|
39
|
+
f('src/test/unit.ts')
|
|
40
|
+
})
|
|
41
|
+
afterAll(() => {
|
|
42
|
+
rmSync(FIX, { recursive: true, force: true })
|
|
43
|
+
rmSync(TAR, { force: true })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('resolveExcludes', () => {
|
|
47
|
+
test('.agent is a default (the incident that motivated this)', () => {
|
|
48
|
+
expect(DEFAULT_EXCLUDES.map(d => d.pattern)).toContain('.agent')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('every default is a BARE name, never a glob path — `star/x` misses top-level ./x on BSD tar', () => {
|
|
52
|
+
for (const { pattern } of DEFAULT_EXCLUDES) expect(pattern).not.toContain('/')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('per-app excludes COMPOSE with the defaults (union, not replace)', () => {
|
|
56
|
+
const r = resolveExcludes(['native'])
|
|
57
|
+
expect(r).toContain('native')
|
|
58
|
+
expect(r).toContain('.agent')
|
|
59
|
+
expect(r).toContain('node_modules')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('`!x` opts a default back in', () => {
|
|
63
|
+
expect(resolveExcludes([])).toContain('dist')
|
|
64
|
+
expect(resolveExcludes(['!dist'])).not.toContain('dist')
|
|
65
|
+
expect(resolveExcludes(['!dist'])).toContain('.agent') // only the named one is cancelled
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('deliberately NOT defaults — plausible real source dir names', () => {
|
|
69
|
+
const p = DEFAULT_EXCLUDES.map(d => d.pattern)
|
|
70
|
+
for (const risky of ['test', 'tests', 'native', 'store', 'evals']) expect(p).not.toContain(risky)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('tar behaviour on this machine (BSD tar)', () => {
|
|
75
|
+
test('a BARE pattern excludes the dir at TOP LEVEL and at depth', () => {
|
|
76
|
+
const members = pack(['.agent'])
|
|
77
|
+
expect(members).not.toContain('.agent/checkpoints.git/pack.bin')
|
|
78
|
+
expect(members).not.toContain('packages/x/.agent/state.json')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// The regression guard: the glob form silently stops matching top-level dirs.
|
|
82
|
+
// This is the exact bug that shipped native/ (~1.7GB) to prod.
|
|
83
|
+
test('the `star/name` glob form does NOT match a top-level dir — so defaults must stay bare', () => {
|
|
84
|
+
const members = pack(['*/.agent'])
|
|
85
|
+
expect(members).toContain('.agent/checkpoints.git/pack.bin') // top-level SURVIVED
|
|
86
|
+
expect(members).not.toContain('packages/x/.agent/state.json') // nested was excluded
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('the real upload tarball', () => {
|
|
91
|
+
let members: string[]
|
|
92
|
+
beforeAll(() => { members = pack(resolveExcludes(['native', '!dist'])) })
|
|
93
|
+
|
|
94
|
+
test('default-excluded local state is absent, including at top level', () => {
|
|
95
|
+
for (const gone of [
|
|
96
|
+
'.agent/checkpoints.git/pack.bin', 'packages/x/.agent/state.json',
|
|
97
|
+
'.claude/settings.json', '.cursor/rules', '.tmp/junk', 'scratchpad/note',
|
|
98
|
+
'.bodify/dev.db', '.mcp-ios/x', '.mcp-android/x', '.vscode/settings.json',
|
|
99
|
+
'.frame-dev/cache', '.DS_Store', '.env.local',
|
|
100
|
+
]) expect(members).not.toContain(gone)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('a per-app exclude still applies', () => {
|
|
104
|
+
expect(members).not.toContain('native/App.js')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('the `!dist` escape hatch really re-includes a default-excluded path', () => {
|
|
108
|
+
expect(members).toContain('dist/bundle.js')
|
|
109
|
+
expect(pack(resolveExcludes([]), TAR + '.2')).not.toContain('dist/bundle.js')
|
|
110
|
+
rmSync(TAR + '.2', { force: true })
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('paths that must ship are still present', () => {
|
|
114
|
+
expect(members).toContain('public/app.js')
|
|
115
|
+
expect(members).toContain('config.generated.json') // untracked-but-needed
|
|
116
|
+
expect(members).toContain('src/store/index.ts')
|
|
117
|
+
expect(members).toContain('src/test/unit.ts')
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('tarballOffenders', () => {
|
|
122
|
+
test('names the biggest entry rolled up to two path components', () => {
|
|
123
|
+
pack([]) // no excludes → the 3MB .agent blob is in the archive
|
|
124
|
+
const top = tarballOffenders(TAR)
|
|
125
|
+
expect(top[0].path).toBe('.agent/checkpoints.git')
|
|
126
|
+
expect(top[0].bytes).toBeGreaterThan(2_900_000)
|
|
127
|
+
})
|
|
128
|
+
})
|