@newbeebox/newbeebox-app-engine-cli 1.7.0 → 1.8.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/README.md CHANGED
@@ -90,6 +90,28 @@ nae create --kind template --app-id myredis \
90
90
  --template redis --storage 2Gi --config '{"password":"..."}'
91
91
  ```
92
92
 
93
+ ### 构建(本机无 Docker)
94
+
95
+ 没装 Docker、或不想本地 `docker build && push`?把带 `Dockerfile` 的项目目录交给平台代构建,
96
+ 构建成功推镜像后照常触发自动部署(与你本地 `push` 等效)。
97
+
98
+ | 命令 | 说明 |
99
+ |------|------|
100
+ | `nae build <appid> [path]` | 打包本地目录上传,由平台代跑 `docker build`/`push`;缺省跟随日志直至结束 |
101
+ | `nae builds <appid>` | 列出该应用最近的构建任务(状态 / 排队位置) |
102
+ | `nae build-logs <jobid> [-f]` | 某构建任务日志(游标续读,`-f` 跟随至结束) |
103
+
104
+ ```bash
105
+ nae build myweb # 打包当前目录,构建并跟随日志
106
+ nae build myweb ./service --tag v2 # 指定上下文目录与镜像 tag
107
+ nae build myweb --dockerfile docker/Dockerfile.prod
108
+ nae build myweb --no-follow # 只入队,稍后 nae build-logs <job> -f 再看
109
+ ```
110
+
111
+ - 打包遵循上下文根的 `.dockerignore`,务必排除 `node_modules/`、构建产物、`.git/`;压缩后上限 **200MB**。
112
+ - 镜像运行时约束同样适用(非 root、端口 ≥ 1024);Dockerfile 由你提供。
113
+ - 网页端「应用详情 → 构建」Tab 也能选项目文件夹上传,效果一致。
114
+
93
115
  ### 生命周期
94
116
 
95
117
  | 命令 | 说明 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newbeebox/newbeebox-app-engine-cli",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
4
4
  "description": "NewBee App Engine 命令行客户端(nae)——参数直达、默认人类可读(加 -o json 出 JSON),便于在终端使用与脚本/管道里解析。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/build.js ADDED
@@ -0,0 +1,160 @@
1
+ // 无 Docker 构建:打包本地目录 → 直传 OSS → 平台入队 → 跟随日志直至终态。
2
+ //
3
+ // 字节全程不经平台 Pod:上传走平台开的预签名 PUT(OSS 公网直传),Runner 拉取走内网预签名 GET。
4
+ // 日志用游标轮询(?after=seq),断线可用 `nae build-logs <job>` 从任意位置续看。
5
+ import { request, NetworkError } from './http.js'
6
+ import { pack } from './tar.js'
7
+ import * as out from './output.js'
8
+
9
+ const CONTEXT_MAX = 200 * 1024 * 1024 // 与平台 enqueue 时的 HEAD 校验一致(200MB)
10
+ const TERMINAL = new Set(['succeeded', 'failed', 'expired', 'canceled'])
11
+
12
+ // runBuild `nae build <appid> [path]` 主流程。
13
+ export async function runBuild(cfg, appid, contextPath, opts) {
14
+ const dockerfile = (opts.dockerfile || 'Dockerfile').trim()
15
+ const context = contextPath || '.'
16
+
17
+ // 预检:应用存在且为普通应用(只有普通应用走镜像推送链路)。
18
+ const app = await request(cfg, 'GET', `/apps/${appid}`)
19
+ if (app && app.Kind && app.Kind !== 'normal') {
20
+ throw new Error(`应用 ${appid} 是模板应用,不支持构建上传(仅普通应用可用)`)
21
+ }
22
+
23
+ // 打包(含 .dockerignore 过滤)。
24
+ out.info(`打包构建上下文:${context}(Dockerfile=${dockerfile})…`)
25
+ const { buffer, fileCount, rawSize, skippedSpecial } = pack(context, dockerfile)
26
+ if (skippedSpecial > 0) out.info(`(跳过 ${skippedSpecial} 个符号链接/特殊文件)`)
27
+ out.info(`打包完成:${fileCount} 文件,原始 ${humanSize(rawSize)} → 压缩 ${humanSize(buffer.length)}`)
28
+ if (buffer.length > CONTEXT_MAX) {
29
+ throw new Error(`压缩后 ${humanSize(buffer.length)} 超过 200MB 上限——请用 .dockerignore 排除 node_modules/构建产物等`)
30
+ }
31
+
32
+ // 建任务 → 拿预签名上传 URL。
33
+ const created = await request(cfg, 'POST', `/apps/${appid}/builds`, {
34
+ body: { tag: opts.tag, dockerfile },
35
+ })
36
+ const job = created.job
37
+ const uploadURL = created.upload_url
38
+ out.info(`已创建构建任务 #${job.ID}(tag=${job.Tag})`)
39
+
40
+ // 直传 OSS(不经平台)。预签名 PUT 签名用空 Content-Type,故不带任何 Content-Type/MD5 头。
41
+ out.info(`上传上下文到对象存储…`)
42
+ await putContext(uploadURL, buffer)
43
+
44
+ // 确认入队(平台 HEAD 验对象大小)。
45
+ const queued = await request(cfg, 'POST', `/builds/${job.ID}/enqueue`)
46
+ const ahead = queued && queued.position ? `,前面还有 ${queued.position} 个任务` : ''
47
+ out.info(`已入队${ahead}。任务 #${job.ID}`)
48
+
49
+ if (opts.follow === false) {
50
+ return { job_id: job.ID, status: queued ? queued.Status : 'queued' }
51
+ }
52
+ out.info(`跟随构建日志(Ctrl-C 停止跟随,构建仍在后台进行)…`)
53
+ return await followBuild(cfg, job.ID)
54
+ }
55
+
56
+ // putContext 用 fetch 直接 PUT 到预签名 URL。body 传 Uint8Array,避免 fetch 自动加 Content-Type
57
+ // (签名以空 Content-Type 计算,加了就 SignatureDoesNotMatch)。
58
+ async function putContext(url, buffer) {
59
+ const body = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)
60
+ let resp
61
+ try {
62
+ resp = await fetch(url, { method: 'PUT', body })
63
+ } catch (e) {
64
+ throw new NetworkError(`上传到对象存储失败(${e.message})`)
65
+ }
66
+ if (!resp.ok) {
67
+ const text = await resp.text().catch(() => '')
68
+ throw new Error(`上传到对象存储失败:HTTP ${resp.status} ${text.slice(0, 300)}`)
69
+ }
70
+ }
71
+
72
+ // followBuild 游标轮询日志 + 状态,直至终态。返回终态摘要;非成功以退出码 1 结束进程。
73
+ async function followBuild(cfg, jobID) {
74
+ let after = 0
75
+ let stopping = false
76
+ const onSigint = () => {
77
+ stopping = true
78
+ out.info(`\n已停止跟随。构建仍在后台进行,可用 nae build-logs ${jobID} -f 续看,或 nae builds 查状态。`)
79
+ process.exit(0)
80
+ }
81
+ process.on('SIGINT', onSigint)
82
+
83
+ try {
84
+ for (;;) {
85
+ after = await drainLogs(cfg, jobID, after)
86
+ const job = await request(cfg, 'GET', `/builds/${jobID}`)
87
+ if (TERMINAL.has(job.Status)) {
88
+ after = await drainLogs(cfg, jobID, after) // 收尾把剩余日志读干净
89
+ out.info(`\n构建结束:${statusLabel(job.Status)}${job.Error ? ` — ${job.Error}` : ''}`)
90
+ process.off('SIGINT', onSigint)
91
+ if (job.Status !== 'succeeded') process.exit(1)
92
+ return { job_id: jobID, status: job.Status }
93
+ }
94
+ await sleep(2000)
95
+ }
96
+ } catch (e) {
97
+ if (stopping) return
98
+ throw e
99
+ }
100
+ }
101
+
102
+ // drainLogs 拉出 seq>after 的所有分片打到 stdout,返回新游标。
103
+ async function drainLogs(cfg, jobID, after) {
104
+ for (;;) {
105
+ const chunks = await request(cfg, 'GET', `/builds/${jobID}/logs`, { query: { after } })
106
+ if (!Array.isArray(chunks) || chunks.length === 0) return after
107
+ for (const ch of chunks) {
108
+ process.stdout.write(ch.Content)
109
+ after = ch.Seq
110
+ }
111
+ if (chunks.length < 1000) return after // 未满页=已读到尾
112
+ }
113
+ }
114
+
115
+ // followLogs `nae build-logs <job> [-f]`:单独跟随某任务日志(断线续看入口)。
116
+ export async function followLogs(cfg, jobID, opts) {
117
+ let after = 0
118
+ after = await drainLogs(cfg, jobID, after)
119
+ if (!opts.follow) {
120
+ const job = await request(cfg, 'GET', `/builds/${jobID}`)
121
+ out.info(`\n状态:${statusLabel(job.Status)}${job.Error ? ` — ${job.Error}` : ''}`)
122
+ return
123
+ }
124
+ process.on('SIGINT', () => process.exit(0))
125
+ for (;;) {
126
+ const job = await request(cfg, 'GET', `/builds/${jobID}`)
127
+ if (TERMINAL.has(job.Status)) {
128
+ after = await drainLogs(cfg, jobID, after)
129
+ out.info(`\n构建结束:${statusLabel(job.Status)}${job.Error ? ` — ${job.Error}` : ''}`)
130
+ if (job.Status !== 'succeeded') process.exit(1)
131
+ return
132
+ }
133
+ after = await drainLogs(cfg, jobID, after)
134
+ await sleep(2000)
135
+ }
136
+ }
137
+
138
+ function statusLabel(s) {
139
+ return (
140
+ {
141
+ pending_upload: '等待上传',
142
+ queued: '排队中',
143
+ building: '构建中',
144
+ succeeded: '成功',
145
+ failed: '失败',
146
+ expired: '已过期',
147
+ canceled: '已取消',
148
+ }[s] || s
149
+ )
150
+ }
151
+
152
+ function humanSize(n) {
153
+ if (n < 1024) return `${n} B`
154
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
155
+ return `${(n / 1024 / 1024).toFixed(1)} MB`
156
+ }
157
+
158
+ function sleep(ms) {
159
+ return new Promise((r) => setTimeout(r, ms))
160
+ }
package/src/forward.js CHANGED
@@ -12,7 +12,7 @@ import { readFileSync, writeFileSync } from 'node:fs'
12
12
  import WebSocket from 'ws'
13
13
  import { apiBase } from './config.js'
14
14
  import { CLI_VERSION } from './version.js'
15
- import { request } from './http.js'
15
+ import { request, ApiError } from './http.js'
16
16
  import * as out from './output.js'
17
17
 
18
18
  const isWin = platform() === 'win32'
@@ -22,11 +22,25 @@ async function ensureLoggedIn(cfg) {
22
22
  await request(cfg, 'GET', '/me')
23
23
  }
24
24
 
25
+ // ensureAppReachable app 转发启动前的一次预检:GET /apps/:id 同时走 AuthRequired(验登录)
26
+ // 与 AppOwnership(验存在+归属)。应用不存在/不归本人 → 抛错,绝不开监听。
27
+ // 一次调用顶「验活 + 验目标」两件事——避免凭空对垃圾 appid 起一个永远连不通的本地监听。
28
+ async function ensureAppReachable(cfg, appid) {
29
+ try {
30
+ await request(cfg, 'GET', `/apps/${encodeURIComponent(appid)}`)
31
+ } catch (e) {
32
+ // 应用不存在/无权访问:给清晰提示,不外泄后端内部错误串。
33
+ // 鉴权(AuthError)/网络(NetworkError)错误原样上抛,交 run() 分类(提示 nae login / 网络)。
34
+ if (e instanceof ApiError) throw new Error(`应用 ${appid} 不存在或无权访问(用 nae apps 查看你的应用)`)
35
+ throw e
36
+ }
37
+ }
38
+
25
39
  // --- app 端口转发 ---
26
40
 
27
41
  // forwardApp 在本地起 TCP server,把每条连接桥到平台隧道。前台常驻,Ctrl-C 退出。
28
42
  export async function forwardApp(cfg, appid, opts) {
29
- await ensureLoggedIn(cfg)
43
+ await ensureAppReachable(cfg, appid)
30
44
  const remotePort = opts.port // undefined → 平台取应用容器端口
31
45
  const path = `/apps/${appid}/forward` + (remotePort ? `?port=${remotePort}` : '')
32
46
  let active = 0
@@ -112,7 +126,11 @@ export async function forwardLLM(cfg, opts) {
112
126
  // bridge 一条本地 TCP 连接 ↔ 一条平台 WS 隧道:本地数据缓冲到隧道就绪后发,回包写回本地。
113
127
  function bridge(cfg, path, sock, cb) {
114
128
  const ws = new WebSocket(wsBase(cfg) + path, {
115
- headers: { Authorization: `Bearer ${cfg.token}`, 'X-NAE-CLI-Version': CLI_VERSION },
129
+ headers: {
130
+ 'User-Agent': `nae-cli/${CLI_VERSION}`,
131
+ Authorization: `Bearer ${cfg.token}`,
132
+ 'X-NAE-CLI-Version': CLI_VERSION,
133
+ },
116
134
  })
117
135
  ws.binaryType = 'nodebuffer'
118
136
 
package/src/http.js CHANGED
@@ -49,7 +49,12 @@ export async function request(cfg, method, path, opts = {}) {
49
49
  }
50
50
  }
51
51
 
52
- const headers = { 'X-NAE-CLI-Version': CLI_VERSION }
52
+ // User-Agent 让审计日志一眼认出是 CLI 及版本(nae-cli/<ver>);X-NAE-CLI-Version 仍留着喂
53
+ // APIKey.LastCLIVersion(CLI 配置页展示),两者各司其职、别删。
54
+ const headers = {
55
+ 'User-Agent': `nae-cli/${CLI_VERSION}`,
56
+ 'X-NAE-CLI-Version': CLI_VERSION,
57
+ }
53
58
  if (token) headers['Authorization'] = `Bearer ${token}`
54
59
  let body
55
60
  if (opts.body !== undefined) {
package/src/index.js CHANGED
@@ -9,6 +9,7 @@ import { CLI_VERSION } from './version.js'
9
9
  import { request, AuthError, NetworkError } from './http.js'
10
10
  import { runLogin } from './login.js'
11
11
  import { streamLogs } from './logs.js'
12
+ import { runBuild, followLogs } from './build.js'
12
13
  import { forwardApp, forwardLLM } from './forward.js'
13
14
  import * as out from './output.js'
14
15
 
@@ -370,6 +371,52 @@ program
370
371
  })
371
372
  )
372
373
 
374
+ // --- 构建(无 Docker:把本地目录交平台代构建镜像)---
375
+
376
+ program
377
+ .command('build <appid> [path]')
378
+ .description(
379
+ '打包本地目录上传,由平台构建 Runner 代跑 docker build/push(适合本机无 Docker)。默认跟随日志直至结束'
380
+ )
381
+ .option('--tag <tag>', '目标镜像 tag(缺省 latest)')
382
+ .option('--dockerfile <path>', '上下文内 Dockerfile 相对路径(缺省 Dockerfile)')
383
+ .option('--no-follow', '入队后立即返回,不跟随日志(用 nae build-logs <job> -f 再看)')
384
+ .addHelpText(
385
+ 'after',
386
+ `
387
+ 说明:
388
+ - 打包遵循上下文根的 .dockerignore(node_modules/构建产物请务必排除);压缩后上限 200MB。
389
+ - 上传直传对象存储、构建在仓库机上进行;构建成功推镜像后平台自动部署(与你本地 push 等效)。
390
+ - 排队:Runner 忙时任务排队等待,日志会显示排队位置。
391
+
392
+ 示例:
393
+ nae build myweb # 打包当前目录,构建并跟随日志
394
+ nae build myweb ./service --tag v2 # 指定上下文目录与 tag
395
+ nae build myweb --dockerfile docker/Dockerfile.prod
396
+ nae build myweb --no-follow # 只入队,稍后 nae build-logs 看`
397
+ )
398
+ .action(
399
+ run(async (appid, pathArg, opts) => {
400
+ const res = await runBuild(cfg(), appid, pathArg, opts)
401
+ if (res) emit(res)
402
+ })
403
+ )
404
+
405
+ program
406
+ .command('builds <appid>')
407
+ .description('列出某应用最近的构建任务(含状态与排队位置)')
408
+ .action(run(async (appid) => emit(await request(cfg(), 'GET', `/apps/${appid}/builds`))))
409
+
410
+ program
411
+ .command('build-logs <jobid>')
412
+ .description('查看某构建任务的日志(游标续读;--follow 跟随至结束)')
413
+ .option('-f, --follow', '持续跟随直到构建结束')
414
+ .action(
415
+ run(async (jobid, opts) => {
416
+ await followLogs(cfg(), jobid, opts)
417
+ })
418
+ )
419
+
373
420
  // --- 运行态:pods / events / metrics / logs ---
374
421
 
375
422
  program
package/src/tar.js ADDED
@@ -0,0 +1,198 @@
1
+ // 零依赖构建上下文打包器:遍历目录 → 应用 .dockerignore → USTAR tar → gzip。
2
+ //
3
+ // 为什么手写而不引 tar 依赖:本 CLI 刻意只带 commander/ws 两个运行依赖。tar 格式很稳,
4
+ // USTAR 头 + gzip 百来行可控,胜过再拉一棵依赖树。docker build - 原生吃 tar.gz 作上下文。
5
+ import { statSync, lstatSync, readdirSync, readFileSync, existsSync } from 'fs'
6
+ import path from 'path'
7
+ import zlib from 'zlib'
8
+
9
+ // loadDockerignore 读取并解析 <dir>/.dockerignore,返回模式数组(保序,后匹配覆盖前者)。
10
+ // 支持常见形态:注释(#)/空行忽略、前导 ! 取反(重新包含)、* 不跨 /、** 跨目录、? 单字符。
11
+ function loadDockerignore(dir) {
12
+ const f = path.join(dir, '.dockerignore')
13
+ if (!existsSync(f)) return []
14
+ const patterns = []
15
+ for (let line of readFileSync(f, 'utf8').split('\n')) {
16
+ line = line.trim()
17
+ if (line === '' || line.startsWith('#')) continue
18
+ let negate = false
19
+ if (line.startsWith('!')) {
20
+ negate = true
21
+ line = line.slice(1).trim()
22
+ }
23
+ line = line.replace(/^\/+/, '').replace(/\/+$/, '') // 去首尾斜杠,规整
24
+ if (line === '') continue
25
+ patterns.push({ re: patternToRegExp(line), negate })
26
+ }
27
+ return patterns
28
+ }
29
+
30
+ // patternToRegExp 把单个 .dockerignore 模式编译为锚定正则。
31
+ // 逐字符转义,仅 * ? ** 特殊:** → 任意(含/),* → 非/任意,? → 单个非/。
32
+ function patternToRegExp(pat) {
33
+ let re = '^'
34
+ for (let i = 0; i < pat.length; i++) {
35
+ const ch = pat[i]
36
+ if (ch === '*') {
37
+ if (pat[i + 1] === '*') {
38
+ re += '.*'
39
+ i++
40
+ } else {
41
+ re += '[^/]*'
42
+ }
43
+ } else if (ch === '?') {
44
+ re += '[^/]'
45
+ } else {
46
+ re += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&')
47
+ }
48
+ }
49
+ return new RegExp(re + '$')
50
+ }
51
+
52
+ // isIgnored 判定 relPath(posix 斜杠)是否被排除。
53
+ // 对每个祖先前缀逐一试匹配,任一命中即按该模式的 negate 定夺;保序、后者覆盖前者
54
+ // (与 docker 语义一致:node_modules 排掉整棵子树,随后 !node_modules/keep 可挖回)。
55
+ function isIgnored(relPath, patterns) {
56
+ if (patterns.length === 0) return false
57
+ const parts = relPath.split('/')
58
+ let ignored = false
59
+ for (const { re, negate } of patterns) {
60
+ let hit = false
61
+ let prefix = ''
62
+ for (let i = 0; i < parts.length; i++) {
63
+ prefix = i === 0 ? parts[0] : prefix + '/' + parts[i]
64
+ if (re.test(prefix)) {
65
+ hit = true
66
+ break
67
+ }
68
+ }
69
+ if (hit) ignored = !negate
70
+ }
71
+ return ignored
72
+ }
73
+
74
+ // walk 递归收集普通文件(相对 root 的 posix 路径 + 绝对路径 + 大小),跳过被忽略项与非常规文件。
75
+ // dockerfileRel 恒被包含(docker 不允许 .dockerignore 排掉 Dockerfile 自身)。
76
+ function walk(root, patterns, dockerfileRel) {
77
+ const files = []
78
+ let skippedSpecial = 0
79
+
80
+ function recur(absDir, relDir) {
81
+ for (const name of readdirSync(absDir)) {
82
+ const abs = path.join(absDir, name)
83
+ const rel = relDir ? relDir + '/' + name : name
84
+
85
+ let st
86
+ try {
87
+ st = lstatSync(abs)
88
+ } catch {
89
+ continue
90
+ }
91
+ const keep = rel === dockerfileRel
92
+ if (st.isSymbolicLink() || (!st.isDirectory() && !st.isFile())) {
93
+ if (!keep) {
94
+ skippedSpecial++
95
+ continue
96
+ }
97
+ }
98
+ if (st.isDirectory()) {
99
+ // 目录被忽略则整棵剪枝(除非里头就是 Dockerfile,罕见,简化为不剪)。
100
+ if (isIgnored(rel, patterns) && !dockerfileRel.startsWith(rel + '/')) continue
101
+ recur(abs, rel)
102
+ continue
103
+ }
104
+ if (!keep && isIgnored(rel, patterns)) continue
105
+ files.push({ abs, rel, size: st.size })
106
+ }
107
+ }
108
+
109
+ recur(root, '')
110
+ return { files, skippedSpecial }
111
+ }
112
+
113
+ // --- USTAR 写入 ---
114
+
115
+ function writeOctal(buf, val, off, len) {
116
+ // (len-1) 位前导零八进制 + NUL,经典 USTAR 数字域。
117
+ const s = val.toString(8).padStart(len - 1, '0') + '\0'
118
+ buf.write(s, off, len, 'ascii')
119
+ }
120
+
121
+ // tarHeader 造一个 512 字节 USTAR 头。长名用 prefix(155)+name(100) 拆分。
122
+ function tarHeader(name, size, mtime) {
123
+ const buf = Buffer.alloc(512)
124
+
125
+ // 名称:≤100 直接放 name;否则在某个 / 处拆成 prefix(≤155)+name(≤100)。
126
+ let prefix = ''
127
+ let fname = name
128
+ if (Buffer.byteLength(name) > 100) {
129
+ const idx = splitName(name)
130
+ if (idx < 0) throw new Error(`路径过长,tar 无法编码(请用 .dockerignore 排除):${name}`)
131
+ prefix = name.slice(0, idx)
132
+ fname = name.slice(idx + 1)
133
+ }
134
+
135
+ buf.write(fname, 0, 100, 'utf8')
136
+ writeOctal(buf, 0o644, 100, 8) // mode
137
+ writeOctal(buf, 0, 108, 8) // uid
138
+ writeOctal(buf, 0, 116, 8) // gid
139
+ writeOctal(buf, size, 124, 12) // size
140
+ writeOctal(buf, mtime, 136, 12) // mtime
141
+ buf.write(' ', 148, 8, 'ascii') // chksum 占位=空格
142
+ buf.write('0', 156, 1, 'ascii') // typeflag 普通文件
143
+ buf.write('ustar\0', 257, 6, 'ascii') // magic
144
+ buf.write('00', 263, 2, 'ascii') // version
145
+ if (prefix) buf.write(prefix, 345, 155, 'utf8')
146
+
147
+ // 校验和:全头字节和(chksum 域按空格计),写 6 位八进制 + NUL + 空格。
148
+ let sum = 0
149
+ for (let i = 0; i < 512; i++) sum += buf[i]
150
+ buf.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 8, 'ascii')
151
+ return buf
152
+ }
153
+
154
+ // splitName 找一个 / 位置把长名拆成 prefix(≤155)/name(≤100)。找不到返回 -1。
155
+ function splitName(name) {
156
+ for (let i = name.length - 101; i >= 0; i--) {
157
+ if (name[i] === '/' && Buffer.byteLength(name.slice(0, i)) <= 155 && Buffer.byteLength(name.slice(i + 1)) <= 100) {
158
+ return i
159
+ }
160
+ }
161
+ return -1
162
+ }
163
+
164
+ const PAD = Buffer.alloc(512)
165
+
166
+ // pack 打包 contextDir 为 tar.gz Buffer。dockerfileRel 是上下文内 Dockerfile 相对路径。
167
+ // 返回 { buffer, fileCount, rawSize, skippedSpecial }。
168
+ export function pack(contextDir, dockerfileRel) {
169
+ const root = path.resolve(contextDir)
170
+ if (!existsSync(root) || !statSync(root).isDirectory()) {
171
+ throw new Error(`构建上下文不是目录:${root}`)
172
+ }
173
+ const dfRel = dockerfileRel.split(path.sep).join('/')
174
+ if (!existsSync(path.join(root, dfRel))) {
175
+ throw new Error(`Dockerfile 不存在:${path.join(root, dfRel)}(用 --dockerfile 指定相对路径)`)
176
+ }
177
+
178
+ const patterns = loadDockerignore(root)
179
+ const { files, skippedSpecial } = walk(root, patterns, dfRel)
180
+ if (files.length === 0) throw new Error('构建上下文为空(是否被 .dockerignore 全部排除了?)')
181
+
182
+ const parts = []
183
+ let rawSize = 0
184
+ const mtime = Math.floor(Date.now() / 1000)
185
+ for (const f of files) {
186
+ const content = readFileSync(f.abs)
187
+ parts.push(tarHeader(f.rel, content.length, mtime))
188
+ parts.push(content)
189
+ const rem = content.length % 512
190
+ if (rem !== 0) parts.push(PAD.subarray(0, 512 - rem))
191
+ rawSize += content.length
192
+ }
193
+ parts.push(PAD, PAD) // 两个空块收尾
194
+
195
+ const tar = Buffer.concat(parts)
196
+ const buffer = zlib.gzipSync(tar, { level: 6 })
197
+ return { buffer, fileCount: files.length, rawSize, skippedSpecial }
198
+ }