@atomservice/functions-cli 0.1.7 → 0.1.8

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": "@atomservice/functions-cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "函数服务命令行工具(atomfunctions):项目脚手架、本地构建、部署、运维",
5
5
  "type": "module",
6
6
  "author": "openorson",
@@ -30,9 +30,11 @@
30
30
  "atomfn": "./bin/functions.ts"
31
31
  },
32
32
  "dependencies": {
33
- "@atomservice/functions-sdk": "0.1.6",
33
+ "@atomservice/functions-sdk": "0.1.8",
34
34
  "@clack/prompts": "1.5.0",
35
+ "change-case": "5.4.4",
35
36
  "citty": "0.2.2",
37
+ "listr2": "10.2.1",
36
38
  "picocolors": "1.1.1",
37
39
  "typebox": "1.2.2"
38
40
  },
package/src/build.ts CHANGED
@@ -1,10 +1,24 @@
1
- import { readdir, rm } from "node:fs/promises"
1
+ import { chmod, readdir, rm } from "node:fs/promises"
2
2
  import path from "node:path"
3
3
  import { $ } from "bun"
4
- import { BUNDLE_FILE, ENTRY_FILE, FUNCTION_CONFIG_FILE, FUNCTION_FILE, LINUX_TARGET, SDK_PACKAGE } from "./consts.ts"
4
+ import { BUNDLE_FILE, ENTRY_FILE, FUNCTION_CONFIG_FILE, FUNCTION_FILE, SDK_PACKAGE } from "./consts.ts"
5
5
  import type { BundleConfig, DeployManifest, FunctionFileConfig } from "./types.ts"
6
6
  import { readJson } from "./utils.ts"
7
7
 
8
+ async function spawnQuiet(cmd: string[], timeoutMs: number): Promise<{ exitCode: number; stderr: string }> {
9
+ const proc = Bun.spawn(cmd, { stdout: "ignore", stderr: "pipe" })
10
+ const timer = setTimeout(() => proc.kill(), timeoutMs)
11
+ try {
12
+ const exitCode = await proc.exited
13
+ clearTimeout(timer)
14
+ const stderr = await new Response(proc.stderr).text()
15
+ return { exitCode, stderr }
16
+ } catch (err) {
17
+ clearTimeout(timer)
18
+ throw err
19
+ }
20
+ }
21
+
8
22
  export interface BuildResult {
9
23
  manifest: DeployManifest
10
24
  artifactPath: string
@@ -81,11 +95,11 @@ export async function writeEntry(bundleDir: string, functions: ResolvedFunction[
81
95
  return entryPath
82
96
  }
83
97
 
84
- async function compileBinary(bundleDir: string, functions: ResolvedFunction[]): Promise<string> {
98
+ async function compileBinary(bundleDir: string, functions: ResolvedFunction[], target: string): Promise<string> {
85
99
  const entryPath = await writeEntry(bundleDir, functions)
86
100
  const outPath = path.join(bundleDir, ".atomfn-bundle")
87
101
  try {
88
- const result = await $`bun build --compile --minify --target=${LINUX_TARGET} ${entryPath} --outfile ${outPath}`
102
+ const result = await $`bun build --compile --minify --target=${target} ${entryPath} --outfile ${outPath}`
89
103
  .cwd(bundleDir)
90
104
  .quiet()
91
105
  .nothrow()
@@ -98,7 +112,7 @@ async function compileBinary(bundleDir: string, functions: ResolvedFunction[]):
98
112
 
99
113
  async function podmanBuildAndSave(bundleDir: string, image: string): Promise<string> {
100
114
  const containerfile = path.join(bundleDir, "Containerfile")
101
- const build = await $`podman build -t ${image} -f ${containerfile} ${bundleDir}`.quiet().nothrow()
115
+ const build = await $`podman build --pull=missing -t ${image} -f ${containerfile} ${bundleDir}`.quiet().nothrow()
102
116
  if (build.exitCode !== 0) throw new Error(`镜像构建失败:${build.stderr.toString().slice(-500)}`)
103
117
 
104
118
  const tarPath = path.join(bundleDir, ".atomfn-image.tar")
@@ -112,9 +126,10 @@ async function buildImage(
112
126
  project: string,
113
127
  bundle: string,
114
128
  version: string,
129
+ arch: string,
115
130
  binaryPath: string,
116
131
  ): Promise<string> {
117
- const image = `localhost/atomfn/${project}-${bundle}:${version}`
132
+ const image = `localhost/atomfn/${project}-${bundle}:${version}-${arch}`
118
133
  const stagedBinary = path.join(bundleDir, "bundle")
119
134
  await Bun.write(stagedBinary, Bun.file(binaryPath))
120
135
  try {
@@ -145,45 +160,192 @@ async function hashTree(dir: string): Promise<string> {
145
160
  return hasher.digest("hex").slice(0, 16)
146
161
  }
147
162
 
163
+ async function parseRustFunctions(bundleDir: string): Promise<string[]> {
164
+ const mainPath = path.join(bundleDir, "src", "main.rs")
165
+ if (!(await Bun.file(mainPath).exists())) return []
166
+
167
+ const source = await Bun.file(mainPath).text()
168
+ const macroMatch = source.match(/serve!\s*\{([^}]*)\}/)
169
+ if (!macroMatch) return []
170
+
171
+ const body = macroMatch[1] ?? ""
172
+ const functions: string[] = []
173
+ const entryRegex = /(stream\s+)?([a-z][a-z0-9_]*)/g
174
+ for (let match = entryRegex.exec(body); match !== null; match = entryRegex.exec(body)) {
175
+ const name = match[2]
176
+ if (name) functions.push(name)
177
+ }
178
+ return functions.sort()
179
+ }
180
+
181
+ async function generateRustConfig(
182
+ bundleDir: string,
183
+ functions: string[],
184
+ defaults: FunctionFileConfig = {},
185
+ ): Promise<void> {
186
+ const config: Record<string, FunctionFileConfig> = {}
187
+ for (const fn of functions) {
188
+ const fnConfigPath = path.join(bundleDir, "src", "functions", fn, FUNCTION_CONFIG_FILE)
189
+ const fnConfig = await readJson<FunctionFileConfig>(fnConfigPath)
190
+ config[fn] = { ...defaults, ...(fnConfig ?? {}) }
191
+ }
192
+ const configPath = path.join(bundleDir, "functions.config.json")
193
+ await Bun.write(configPath, JSON.stringify({ functions: config }, null, 2))
194
+ }
195
+
196
+ async function podmanMachineArch(): Promise<string> {
197
+ try {
198
+ const proc = Bun.spawn(["podman", "info", "--format", "{{.Host.Arch}}"], { stdout: "pipe", stderr: "ignore" })
199
+ await proc.exited
200
+ const out = (await new Response(proc.stdout).text()).trim()
201
+ return out === "amd64" ? "x64" : out // normalize amd64→x64
202
+ } catch {
203
+ return "x64"
204
+ }
205
+ }
206
+
207
+ async function buildRustBinary(bundleDir: string, bundle: string, arch: string): Promise<string> {
208
+ const outPath = path.join(bundleDir, ".atomfn-bundle")
209
+ const machineArch = await podmanMachineArch()
210
+
211
+ let cmd: string[]
212
+ let binaryRelPath: string
213
+
214
+ if (machineArch === arch) {
215
+ // podman machine 与目标架构相同,原生编译
216
+ cmd = [
217
+ "podman",
218
+ "run",
219
+ "--rm",
220
+ "--pull=missing",
221
+ "-v",
222
+ `${bundleDir}:/build:Z`,
223
+ "-e",
224
+ "CARGO_TARGET_DIR=/build/target",
225
+ "-w",
226
+ "/build",
227
+ "rust:1-slim-bookworm",
228
+ "cargo",
229
+ "build",
230
+ "--release",
231
+ ]
232
+ binaryRelPath = path.join("target", "release", bundle)
233
+ } else {
234
+ // 跨架构编译,使用交叉编译工具链(避免 QEMU)
235
+ const rustTarget = arch === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"
236
+ const aptPackage = arch === "arm64" ? "gcc-aarch64-linux-gnu" : "gcc-x86-64-linux-gnu"
237
+ const linker = arch === "arm64" ? "aarch64-linux-gnu-gcc" : "x86_64-linux-gnu-gcc"
238
+ const linkerEnvKey =
239
+ arch === "arm64"
240
+ ? "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER"
241
+ : "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER"
242
+ cmd = [
243
+ "podman",
244
+ "run",
245
+ "--rm",
246
+ "--pull=missing",
247
+ "-v",
248
+ `${bundleDir}:/build:Z`,
249
+ "-e",
250
+ "CARGO_TARGET_DIR=/build/target",
251
+ "-e",
252
+ `${linkerEnvKey}=${linker}`,
253
+ "-w",
254
+ "/build",
255
+ "rust:1-slim-bookworm",
256
+ "sh",
257
+ "-c",
258
+ `apt-get update -qq && apt-get install -y -q ${aptPackage} && cargo build --target ${rustTarget} --release`,
259
+ ]
260
+ binaryRelPath = path.join("target", rustTarget, "release", bundle)
261
+ }
262
+
263
+ const result = await spawnQuiet(cmd, 600_000)
264
+
265
+ if (result.exitCode !== 0) {
266
+ throw new Error(`Rust 编译失败(${arch}):${result.stderr.slice(-500)}`)
267
+ }
268
+
269
+ const binaryPath = path.join(bundleDir, binaryRelPath)
270
+ await Bun.write(outPath, Bun.file(binaryPath))
271
+ await chmod(outPath, 0o755)
272
+ return outPath
273
+ }
274
+
148
275
  async function buildRustBundle(
149
276
  root: string,
150
277
  project: string,
151
278
  bundle: string,
152
279
  config: BundleConfig,
280
+ arch: string,
153
281
  ): Promise<BuildResult> {
154
- const bundleDir = path.join(root, bundle)
155
- const functions = config.functions ?? []
282
+ const bundleDir = path.join(root, "bundles", bundle)
283
+ const functions = await parseRustFunctions(bundleDir)
156
284
  if (functions.length === 0) {
157
- throw new Error(`rust bundle ${bundle} 需在 ${BUNDLE_FILE} functions 中声明函数名`)
158
- }
159
- if (!(await Bun.file(path.join(bundleDir, "Containerfile")).exists())) {
160
- throw new Error(`rust bundle ${bundle} 缺少 Containerfile`)
285
+ throw new Error(`rust bundle ${bundle} src/main.rs 中没有找到 serve! 宏声明`)
161
286
  }
287
+
288
+ await generateRustConfig(bundleDir, functions, config.defaults)
162
289
  const version = await hashTree(bundleDir)
163
- const image = `localhost/atomfn/${project}-${bundle}:${version}`
164
- const tarPath = await podmanBuildAndSave(bundleDir, image)
165
- return {
166
- manifest: {
167
- project,
168
- bundle,
169
- version,
170
- artifact: "image",
171
- functions: [...functions].sort(),
172
- image,
173
- memory: config.memory,
174
- cpus: config.cpus,
175
- maxConcurrency: config.maxConcurrency,
176
- env: config.env,
177
- },
178
- artifactPath: tarPath,
290
+
291
+ const hasContainerfile = await Bun.file(path.join(bundleDir, "Containerfile")).exists()
292
+
293
+ if (!hasContainerfile) {
294
+ const binaryPath = await buildRustBinary(bundleDir, bundle, arch)
295
+ return {
296
+ manifest: {
297
+ project,
298
+ bundle,
299
+ arch,
300
+ version,
301
+ artifact: "binary",
302
+ functions,
303
+ memory: config.memory,
304
+ cpus: config.cpus,
305
+ maxConcurrency: config.maxConcurrency,
306
+ env: config.env,
307
+ },
308
+ artifactPath: binaryPath,
309
+ }
310
+ }
311
+
312
+ // 有 Containerfile:与 bun 流程对齐——先交叉编译 binary,再用 Containerfile 打包运行时镜像
313
+ const binaryPath = await buildRustBinary(bundleDir, bundle, arch)
314
+ try {
315
+ const image = `localhost/atomfn/${project}-${bundle}:${version}-${arch}`
316
+ const tarPath = await buildImage(bundleDir, project, bundle, version, arch, binaryPath)
317
+ return {
318
+ manifest: {
319
+ project,
320
+ bundle,
321
+ arch,
322
+ version,
323
+ artifact: "image",
324
+ functions,
325
+ image,
326
+ memory: config.memory,
327
+ cpus: config.cpus,
328
+ maxConcurrency: config.maxConcurrency,
329
+ env: config.env,
330
+ },
331
+ artifactPath: tarPath,
332
+ }
333
+ } finally {
334
+ await rm(binaryPath, { force: true })
179
335
  }
180
336
  }
181
337
 
182
- export async function buildBundle(root: string, project: string, bundle: string): Promise<BuildResult> {
183
- const bundleDir = path.join(root, bundle)
338
+ export async function buildBundle(
339
+ root: string,
340
+ project: string,
341
+ bundle: string,
342
+ target: string,
343
+ arch: string,
344
+ ): Promise<BuildResult> {
345
+ const bundleDir = path.join(root, "bundles", bundle)
184
346
  const config = await readJson<BundleConfig>(path.join(bundleDir, BUNDLE_FILE))
185
347
  if (!config) throw new Error(`未找到 ${bundle}/${BUNDLE_FILE}`)
186
- if (config.language === "rust") return buildRustBundle(root, project, bundle, config)
348
+ if (config.language === "rust") return buildRustBundle(root, project, bundle, config, arch)
187
349
  if (config.language !== "bun") throw new Error(`暂不支持的语言:${config.language}`)
188
350
 
189
351
  const functions = await resolveFunctions(bundleDir, config.defaults)
@@ -195,18 +357,20 @@ export async function buildBundle(root: string, project: string, bundle: string)
195
357
  const hasContainerfile = await Bun.file(path.join(bundleDir, "Containerfile")).exists()
196
358
 
197
359
  if (hasContainerfile) {
198
- const binaryPath = await compileBinary(bundleDir, functions)
360
+ const binaryPath = await compileBinary(bundleDir, functions, target)
199
361
  try {
200
- const tarPath = await buildImage(bundleDir, project, bundle, version, binaryPath)
362
+ const image = `localhost/atomfn/${project}-${bundle}:${version}-${arch}`
363
+ const tarPath = await buildImage(bundleDir, project, bundle, version, arch, binaryPath)
201
364
  return {
202
365
  manifest: {
203
366
  project,
204
367
  bundle,
368
+ arch,
205
369
  version,
206
370
  artifact: "image",
207
371
  functions: names,
208
372
  asyncFunctions: asyncFunctions.length ? asyncFunctions : undefined,
209
- image: `localhost/atomfn/${project}-${bundle}:${version}`,
373
+ image,
210
374
  memory: config.memory,
211
375
  cpus: config.cpus,
212
376
  maxConcurrency: config.maxConcurrency,
@@ -219,11 +383,12 @@ export async function buildBundle(root: string, project: string, bundle: string)
219
383
  }
220
384
  }
221
385
 
222
- const binaryPath = await compileBinary(bundleDir, functions)
386
+ const binaryPath = await compileBinary(bundleDir, functions, target)
223
387
  return {
224
388
  manifest: {
225
389
  project,
226
390
  bundle,
391
+ arch,
227
392
  version,
228
393
  artifact: "binary",
229
394
  functions: names,
package/src/client.ts CHANGED
@@ -55,10 +55,10 @@ export class AdminClient {
55
55
  }
56
56
 
57
57
  async deploy(project: string, manifest: DeployManifest, artifact: Blob): Promise<BundleRecord> {
58
- const form = new FormData()
59
- form.set("manifest", JSON.stringify(manifest))
60
- form.set("artifact", artifact, `${manifest.bundle}-${manifest.version}`)
61
- const data = await this.send<{ bundle: BundleRecord }>("POST", `/projects/${project}/bundles`, { body: form })
58
+ const data = await this.send<{ bundle: BundleRecord }>("POST", `/projects/${project}/bundles`, {
59
+ headers: { "x-atomfn-manifest": JSON.stringify(manifest), "content-type": "application/octet-stream" },
60
+ body: artifact,
61
+ })
62
62
  return data.bundle
63
63
  }
64
64