bod-cli 0.3.2 → 0.5.2
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/dist/cli.js +7243 -48
- package/package.json +2 -1
- package/src/cli.ts +8 -2
- package/src/commands/deploy.ts +90 -19
- package/src/commands/env.ts +6 -1
- package/src/commands/init/init.ts +27 -7
- package/src/commands/publish.ts +87 -0
- package/src/commands/serve.ts +6 -1
- package/src/config.ts +5 -6
- package/src/utils/output.ts +1 -0
- package/src/utils/resolve.ts +74 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bod-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.2",
|
|
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) {
|
package/src/commands/deploy.ts
CHANGED
|
@@ -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, readInstanceFromYaml, readDeployModeFromYaml } 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
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
45
|
+
file.stream(),
|
|
23
46
|
{ 'x-branch': branch },
|
|
24
47
|
)
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
|
@@ -69,9 +134,15 @@ export default defineCommand({
|
|
|
69
134
|
upload: { type: 'boolean', description: 'Upload local source instead of git clone', default: false },
|
|
70
135
|
},
|
|
71
136
|
async run({ args }) {
|
|
72
|
-
const { url, apiKey, name: instanceName } = getResolvedInstance(loadConfig(), readInstanceFromYaml())
|
|
73
|
-
|
|
137
|
+
const { url, apiKey, name: instanceName, instance } = getResolvedInstance(loadConfig(), readInstanceFromYaml())
|
|
138
|
+
console.log(chalk.dim(`Using instance "${instanceName}"`))
|
|
74
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
|
+
}
|
|
75
146
|
|
|
76
147
|
const appName = resolveAppName(args.app)
|
|
77
148
|
|
|
@@ -95,7 +166,7 @@ export default defineCommand({
|
|
|
95
166
|
const useUpload = forceUpload || !hasRepo
|
|
96
167
|
console.log(chalk.dim(`Deploying ${appName} (branch: ${branch})${useUpload ? ' via upload' : ''}...`))
|
|
97
168
|
|
|
98
|
-
const since = Date.now()
|
|
169
|
+
const since = Date.now() - 5000 // buffer for clock skew
|
|
99
170
|
if (useUpload) {
|
|
100
171
|
await uploadDeploy(client, appId, branch)
|
|
101
172
|
} else {
|
|
@@ -103,6 +174,6 @@ export default defineCommand({
|
|
|
103
174
|
}
|
|
104
175
|
|
|
105
176
|
console.log(chalk.green(`✓ Deployment queued`))
|
|
106
|
-
await pollDeploy(client, appId, since)
|
|
177
|
+
await pollDeploy(client, appId, since, instance.capabilities, localConfig)
|
|
107
178
|
},
|
|
108
179
|
})
|
package/src/commands/env.ts
CHANGED
|
@@ -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
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { defineCommand } from 'citty'
|
|
2
2
|
import { select, input, confirm } from '@inquirer/prompts'
|
|
3
3
|
import chalk from 'chalk'
|
|
4
|
-
import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'fs'
|
|
4
|
+
import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from 'fs'
|
|
5
5
|
import { basename, join } from 'path'
|
|
6
6
|
import { loadConfig, getResolvedInstance, configExists } from '../../config'
|
|
7
7
|
import { BodClient } from '../../client'
|
|
8
8
|
import { TEMPLATES, type TemplateId } from './templates'
|
|
9
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
9
10
|
|
|
10
11
|
type AppType = 'caab' | 'static' | 'server' | 'mixed' | 'unknown'
|
|
11
12
|
|
|
@@ -232,13 +233,27 @@ export default defineCommand({
|
|
|
232
233
|
repo = proc.stdout.toString().trim()
|
|
233
234
|
} catch { /* no git remote */ }
|
|
234
235
|
|
|
235
|
-
// bodify.yaml
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
236
|
+
// bodify.yaml — create or merge missing fields
|
|
237
|
+
const yamlPath = join(cwd, 'bodify.yaml')
|
|
238
|
+
let yamlData: Record<string, any> = {}
|
|
239
|
+
let yamlExisted = false
|
|
240
|
+
if (existsSync(yamlPath)) {
|
|
241
|
+
yamlExisted = true
|
|
242
|
+
yamlData = parseYaml(readFileSync(yamlPath, 'utf8')) ?? {}
|
|
243
|
+
}
|
|
244
|
+
let yamlDirty = false
|
|
245
|
+
if (!yamlData.name) { yamlData.name = appName; yamlDirty = true }
|
|
246
|
+
if (!yamlExisted) {
|
|
239
247
|
const useDb = detected === 'caab' && await confirm({ message: 'Enable database?', default: false })
|
|
240
|
-
|
|
241
|
-
|
|
248
|
+
if (useDb) { yamlData.database = true; yamlDirty = true }
|
|
249
|
+
if (repo) { yamlData.repo = repo; yamlDirty = true }
|
|
250
|
+
yamlDirty = true
|
|
251
|
+
}
|
|
252
|
+
if (yamlDirty) {
|
|
253
|
+
writeFileSync(yamlPath, stringifyYaml(yamlData))
|
|
254
|
+
console.log(chalk.green(yamlExisted ? '✓ Updated bodify.yaml' : '✓ Created bodify.yaml'))
|
|
255
|
+
} else {
|
|
256
|
+
console.log(chalk.dim('bodify.yaml already up to date'))
|
|
242
257
|
}
|
|
243
258
|
|
|
244
259
|
// Register
|
|
@@ -251,6 +266,11 @@ export default defineCommand({
|
|
|
251
266
|
})
|
|
252
267
|
console.log(chalk.green(`✓ App registered (id: ${app.id})`))
|
|
253
268
|
if (app.domain) console.log(chalk.dim(` Domain: ${app.domain}`))
|
|
269
|
+
// Persist appId + domain into bodify.yaml
|
|
270
|
+
let changed = false
|
|
271
|
+
if (app.id && !yamlData.appId) { yamlData.appId = app.id; changed = true }
|
|
272
|
+
if (app.domain && !yamlData.domain) { yamlData.domain = app.domain; changed = true }
|
|
273
|
+
if (changed) writeFileSync(yamlPath, stringifyYaml(yamlData))
|
|
254
274
|
} catch (e) {
|
|
255
275
|
console.warn(chalk.yellow(`Warning: Could not register: ${(e as Error).message}`))
|
|
256
276
|
}
|
|
@@ -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
|
+
})
|
package/src/commands/serve.ts
CHANGED
|
@@ -4,7 +4,12 @@ import { existsSync } from 'fs'
|
|
|
4
4
|
|
|
5
5
|
function resolveBodifyServe(): string {
|
|
6
6
|
// Sibling directory convention: bod-cli and bodify live side by side
|
|
7
|
-
|
|
7
|
+
// Try both source (src/commands/) and dist (dist/) layouts
|
|
8
|
+
const candidates = [
|
|
9
|
+
join(import.meta.dir, '../../../bodify/src/serve.ts'), // from src/commands/
|
|
10
|
+
join(import.meta.dir, '../../bodify/src/serve.ts'), // from dist/
|
|
11
|
+
]
|
|
12
|
+
const sibling = candidates.find(existsSync) ?? candidates[0]
|
|
8
13
|
if (existsSync(sibling)) return sibling
|
|
9
14
|
// Fallback: try global BODIFY_DIR env
|
|
10
15
|
const envDir = process.env.BODIFY_DIR
|
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
|
-
|
|
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
|
-
|
|
69
|
+
if (name) process.env._BOD_INSTANCE_OVERRIDE = name
|
|
71
70
|
}
|
|
72
71
|
|
|
73
|
-
export function getResolvedInstance(config: Config,
|
|
74
|
-
const override =
|
|
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(', ')}`)
|
package/src/utils/output.ts
CHANGED
|
@@ -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] ?? '')))
|
package/src/utils/resolve.ts
CHANGED
|
@@ -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,25 +34,66 @@ export async function resolveAppId(client: BodClient, nameOrId: string): Promise
|
|
|
13
34
|
}
|
|
14
35
|
|
|
15
36
|
export function readAppNameFromYaml(): string | undefined {
|
|
16
|
-
|
|
17
|
-
const yaml = readFileSync('bodify.yaml', 'utf-8')
|
|
18
|
-
return yaml.match(/name:\s*(.+)/)?.[1]?.trim()
|
|
19
|
-
} catch { return undefined }
|
|
37
|
+
return readYaml()?.name
|
|
20
38
|
}
|
|
21
39
|
|
|
22
40
|
export function readInstanceFromYaml(): string | undefined {
|
|
23
|
-
|
|
24
|
-
const yaml = readFileSync('bodify.yaml', 'utf-8')
|
|
25
|
-
return yaml.match(/instance:\s*(.+)/)?.[1]?.trim()
|
|
26
|
-
} catch { return undefined }
|
|
41
|
+
return readYaml()?.instance
|
|
27
42
|
}
|
|
28
43
|
|
|
29
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
|
|
30
84
|
try {
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
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]
|
|
35
97
|
}
|
|
36
98
|
|
|
37
99
|
/** Resolve app name from explicit arg or bodify.yaml fallback. Logs clearly when falling back. */
|