@atomservice/functions-cli 0.1.7 → 0.1.9

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.
@@ -1,20 +1,78 @@
1
1
  import { readdir, rm } from "node:fs/promises"
2
2
  import path from "node:path"
3
- import { intro, log, outro, spinner } from "@clack/prompts"
3
+ import { confirm, intro, isCancel, log, multiselect, outro } from "@clack/prompts"
4
4
  import { defineCommand } from "citty"
5
+ import { Listr } from "listr2"
5
6
  import pc from "picocolors"
6
7
  import { buildBundle } from "../build.ts"
7
8
  import { AdminClient } from "../client.ts"
8
- import { BUNDLE_FILE } from "../consts.ts"
9
- import { loadContext, pickTarget, requireProjectContext } from "../context.ts"
10
- import type { DeployManifest, ServerTarget } from "../types.ts"
9
+ import { BUNDLE_FILE, BUNDLES_DIR } from "../consts.ts"
10
+ import { requireProjectContext } from "../context.ts"
11
+ import type { BundleConfig, DeployManifest, Manifest, ServerTarget } from "../types.ts"
12
+ import { bunTarget, readJson } from "../utils.ts"
13
+
14
+ class Semaphore {
15
+ private permits: number
16
+ private queue: Array<() => void> = []
17
+ constructor(permits: number) {
18
+ this.permits = permits
19
+ }
20
+ async acquire(): Promise<void> {
21
+ if (this.permits > 0) {
22
+ this.permits--
23
+ return
24
+ }
25
+ await new Promise<void>((resolve) => this.queue.push(resolve))
26
+ }
27
+ release(): void {
28
+ const next = this.queue.shift()
29
+ if (next) {
30
+ next()
31
+ } else {
32
+ this.permits++
33
+ }
34
+ }
35
+ }
36
+
37
+ async function resolveDeployTargets(
38
+ manifest: Manifest,
39
+ allTargets: ServerTarget[],
40
+ arg: string | true | undefined,
41
+ ): Promise<ServerTarget[]> {
42
+ if (arg === undefined || arg === true) return allTargets
43
+ if (arg === "all") return allTargets
44
+
45
+ const urls = [manifest.server, ...(manifest.servers ?? [])].filter(Boolean) as string[]
46
+
47
+ if (/^\d+$/.test(arg)) {
48
+ const idx = Number(arg)
49
+ const url = idx === 0 ? manifest.server : manifest.servers?.[idx - 1]
50
+ if (!url) throw new Error(`索引 ${arg} 超出范围,可用 0-${manifest.servers?.length ?? 0}`)
51
+ const match = allTargets.find((t) => t.server === url)
52
+ if (!match) throw new Error(`服务器 ${url} 未登录,请先执行 atomfunctions login`)
53
+ return [match]
54
+ }
55
+
56
+ if (arg.includes("://") || arg.includes("localhost")) {
57
+ const parts = arg.split(",").map((s) => s.trim())
58
+ const result: ServerTarget[] = []
59
+ for (const part of parts) {
60
+ const match = allTargets.find((t) => t.server === part || t.host === part)
61
+ if (!match) throw new Error(`服务器 ${part} 不在配置中,可用:${urls.join("、")}`)
62
+ result.push(match)
63
+ }
64
+ return result
65
+ }
66
+
67
+ throw new Error(`无法解析 --server 值:${arg}。支持:all、0/1/2(索引)、URL(逗号分隔)`)
68
+ }
11
69
 
12
70
  async function listLocalBundles(root: string): Promise<string[]> {
13
- const entries = await readdir(root, { withFileTypes: true })
71
+ const entries = await readdir(path.join(root, BUNDLES_DIR), { withFileTypes: true })
14
72
  const bundles: string[] = []
15
73
  for (const entry of entries) {
16
74
  if (!entry.isDirectory() || entry.name.startsWith(".")) continue
17
- if (await Bun.file(path.join(root, entry.name, BUNDLE_FILE)).exists()) bundles.push(entry.name)
75
+ if (await Bun.file(path.join(root, BUNDLES_DIR, entry.name, BUNDLE_FILE)).exists()) bundles.push(entry.name)
18
76
  }
19
77
  return bundles.sort()
20
78
  }
@@ -30,7 +88,8 @@ async function deployToTarget(
30
88
  if (!force) {
31
89
  const remote = await client.list(manifest.project)
32
90
  const current = remote.find((r) => r.project === manifest.project && r.bundle === manifest.bundle)
33
- if (current?.version === manifest.version) return { host: target.host, ok: true, skipped: true }
91
+ if (current?.version === manifest.version && current?.arch === manifest.arch)
92
+ return { host: target.host, ok: true, skipped: true }
34
93
  }
35
94
  await client.deploy(manifest.project, manifest, Bun.file(artifactPath))
36
95
  return { host: target.host, ok: true, skipped: false }
@@ -45,15 +104,89 @@ async function deployToTarget(
45
104
  }
46
105
 
47
106
  export const deployCommand = defineCommand({
48
- meta: { name: "deploy", description: "本地构建并部署函数包(扇出到所有机器)" },
107
+ meta: { name: "deploy", description: "本地构建并部署函数包" },
49
108
  args: {
50
109
  bundle: { type: "positional", description: "bundle 名(不填则部署全部变更)", required: false },
51
110
  force: { type: "boolean", description: "忽略版本一致检查,强制部署" },
111
+ target: { type: "string", description: "编译目标架构:x64 | arm64(默认根据服务器自动选择)" },
112
+ server: { type: "string", description: "部署目标:all(全部)、0/1/2(索引)、URL(逗号分隔)" },
52
113
  },
53
114
  async run({ args }) {
54
115
  intro(pc.cyan("部署函数包"))
55
116
  const ctx = await requireProjectContext()
56
117
 
118
+ let deployTargets: ServerTarget[]
119
+ if (typeof args.server === "string") {
120
+ deployTargets = await resolveDeployTargets(ctx.manifest, ctx.targets, args.server)
121
+ } else {
122
+ const options = ctx.targets.map((t, i) => ({ value: String(i), label: t.host, hint: t.server }))
123
+ const selected = await multiselect({
124
+ message: "选择部署目标",
125
+ options,
126
+ required: true,
127
+ initialValues: ctx.targets.map((_, i) => String(i)),
128
+ })
129
+ if (isCancel(selected)) {
130
+ outro(pc.yellow("已取消"))
131
+ return
132
+ }
133
+ deployTargets = (selected as string[]).map((i) => ctx.targets[Number(i)]).filter(Boolean) as ServerTarget[]
134
+ }
135
+
136
+ const force =
137
+ args.force === true ? true : await confirm({ message: "是否忽略版本一致检查,强制部署?", initialValue: false })
138
+ if (isCancel(force)) {
139
+ outro(pc.yellow("已取消"))
140
+ return
141
+ }
142
+
143
+ const targetArches = new Map<string, string | Error>()
144
+ await Promise.all(
145
+ deployTargets.map(async (t) => {
146
+ try {
147
+ const client = new AdminClient(t.server, t.token)
148
+ const health = await client.get<{ arch?: string }>("/health")
149
+ targetArches.set(t.server, health.arch ?? "x64")
150
+ } catch (err) {
151
+ targetArches.set(t.server, err instanceof Error ? err : new Error(String(err)))
152
+ }
153
+ }),
154
+ )
155
+ const bunSemaphore = new Semaphore(10)
156
+ const rustSemaphore = new Semaphore(1)
157
+ const serverSemaphores = new Map<string, Semaphore>()
158
+
159
+ const archErrors = deployTargets.filter((t) => targetArches.get(t.server) instanceof Error)
160
+ if (archErrors.length > 0) {
161
+ for (const t of archErrors) {
162
+ log.error(`${t.host}:无法获取服务器架构 — ${(targetArches.get(t.server) as Error).message}`)
163
+ }
164
+ outro(pc.red("部署中止"))
165
+ process.exitCode = 1
166
+ return
167
+ }
168
+
169
+ if (typeof args.target === "string") {
170
+ for (const key of targetArches.keys()) targetArches.set(key, args.target)
171
+ }
172
+
173
+ const archGroups = new Map<string, ServerTarget[]>()
174
+ for (const t of deployTargets) {
175
+ const arch = targetArches.get(t.server) as string
176
+ if (!archGroups.has(arch)) archGroups.set(arch, [])
177
+ const group = archGroups.get(arch) ?? []
178
+ group.push(t)
179
+ archGroups.set(arch, group)
180
+ }
181
+
182
+ const uniqueArches = [...archGroups.keys()]
183
+ if (uniqueArches.length === 1) {
184
+ log.info(pc.dim(`编译目标:${uniqueArches[0]}${!args.target ? "(服务器自动检测)" : ""}`))
185
+ } else {
186
+ log.info(pc.dim(`多架构部署:${uniqueArches.join("、")}`))
187
+ }
188
+ log.info(pc.dim(`目标机器:${deployTargets.map((t) => t.host).join("、")}`))
189
+
57
190
  const targets = args.bundle ? [args.bundle] : await listLocalBundles(ctx.root)
58
191
  if (targets.length === 0) {
59
192
  log.warn("没有可部署的 bundle")
@@ -61,43 +194,145 @@ export const deployCommand = defineCommand({
61
194
  return
62
195
  }
63
196
 
64
- if (ctx.targets.length > 1) log.info(pc.dim(`目标机器:${ctx.targets.map((t) => t.host).join("、")}`))
65
-
66
- for (const bundle of targets) {
67
- const s = spinner()
68
- s.start(`构建 ${bundle}`)
69
- let artifactPath: string | undefined
70
- try {
71
- const { manifest, artifactPath: built } = await buildBundle(ctx.root, ctx.project, bundle)
72
- artifactPath = built
73
-
74
- s.message(`部署 ${bundle} 到 ${ctx.targets.length} 台机器`)
75
- const results = await Promise.all(
76
- ctx.targets.map((t) => deployToTarget(t, manifest, built, Boolean(args.force))),
77
- )
78
-
79
- const failed = results.filter((r) => !r.ok)
80
- const deployed = results.filter((r) => r.ok && !r.skipped).length
81
- const skipped = results.filter((r) => r.skipped).length
82
-
83
- if (failed.length === 0) {
84
- const detail =
85
- skipped > 0 ? `${deployed} 部署 / ${skipped} 无变更` : `${manifest.artifact}:${manifest.version}`
86
- s.stop(`${bundle} 完成 ${pc.dim(`(${detail})`)}`)
87
- } else {
88
- s.stop(pc.red(`${bundle} 部分失败`))
89
- for (const f of failed) log.error(` ${f.host}: ${f.message}`)
90
- process.exitCode = 1
91
- }
92
- } catch (error) {
93
- s.stop(pc.red(`${bundle} 构建失败`))
94
- log.error(error instanceof Error ? error.message : String(error))
95
- process.exitCode = 1
96
- } finally {
97
- if (artifactPath) await rm(artifactPath, { force: true })
197
+ type DeployTask = { bundle: string; target: ServerTarget; arch: string }
198
+
199
+ const deployTasks: DeployTask[] = targets.flatMap((bundle) =>
200
+ [...archGroups].flatMap(([arch, archTargets]) => archTargets.map((t) => ({ bundle, target: t, arch }))),
201
+ )
202
+
203
+ type DeployLine = {
204
+ bundle: string
205
+ host: string
206
+ ok: boolean
207
+ skipped: boolean
208
+ message?: string
209
+ }
210
+ const deployLines: DeployLine[] = []
211
+ let hasError = false
212
+
213
+ const buildCache = new Map<string, Promise<{ manifest: DeployManifest; artifactPath: string }>>()
214
+ const buildRefs = new Map<string, number>()
215
+ const configCache = new Map<string, BundleConfig>()
216
+
217
+ console.log("")
218
+
219
+ const listr = new Listr(
220
+ deployTasks.map(({ bundle, target, arch }) => ({
221
+ title: bundle,
222
+ task: async (_ctx, task) => {
223
+ const config =
224
+ configCache.get(bundle) ??
225
+ (await readJson<BundleConfig>(path.join(ctx.root, BUNDLES_DIR, bundle, BUNDLE_FILE)))
226
+ if (!config) throw new Error(`${bundle} 缺少 ${BUNDLE_FILE}`)
227
+ configCache.set(bundle, config)
228
+ const isRust = config?.language === "rust"
229
+ const isBun = config?.language === "bun"
230
+ const buildKey = `${bundle}-${arch}`
231
+ let semReleased = false
232
+
233
+ try {
234
+ if (isRust) {
235
+ task.title = ` ${bundle} ${target.host} (${arch}) 等待构建...`
236
+ await rustSemaphore.acquire()
237
+ } else if (isBun) {
238
+ await bunSemaphore.acquire()
239
+ }
240
+
241
+ task.title = ` ${bundle} ${target.host} (${arch}) 构建中...`
242
+
243
+ if (!buildCache.has(buildKey)) {
244
+ buildCache.set(buildKey, buildBundle(ctx.root, ctx.project, bundle, bunTarget(arch), arch))
245
+ }
246
+ buildRefs.set(buildKey, (buildRefs.get(buildKey) ?? 0) + 1)
247
+ const cached = buildCache.get(buildKey)
248
+ if (!cached) throw new Error("构建缓存异常")
249
+ const { manifest, artifactPath } = await cached
250
+ const versionLabel = pc.dim(`${manifest.artifact}:${manifest.version}`)
251
+
252
+ if (isRust) rustSemaphore.release()
253
+ else if (isBun) bunSemaphore.release()
254
+ semReleased = true
255
+
256
+ try {
257
+ const sem = serverSemaphores.get(target.server) ?? new Semaphore(1)
258
+ serverSemaphores.set(target.server, sem)
259
+ task.title = ` ${bundle} ${target.host} (${arch}) 部署中... ${versionLabel}`
260
+ await sem.acquire()
261
+ try {
262
+ const r = await deployToTarget(target, manifest, artifactPath, force)
263
+ if (r.skipped) {
264
+ task.title = ` ${bundle} ${target.host} (${arch}) ${pc.dim("无变更")} ${versionLabel}`
265
+ } else if (r.ok) {
266
+ task.title = ` ${bundle} ${target.host} (${arch}) ${pc.green("已部署")} ${versionLabel}`
267
+ } else {
268
+ task.title = ` ${bundle} ${target.host} (${arch}) ${pc.red(r.message ?? "失败")} ${versionLabel}`
269
+ hasError = true
270
+ }
271
+ deployLines.push({ bundle, host: target.host, ok: r.ok, skipped: r.skipped, message: r.message })
272
+ if (!r.ok && !r.skipped) hasError = true
273
+ } finally {
274
+ sem.release()
275
+ }
276
+ } finally {
277
+ const refs = (buildRefs.get(buildKey) ?? 1) - 1
278
+ if (refs <= 0) {
279
+ buildRefs.delete(buildKey)
280
+ await rm(artifactPath, { force: true })
281
+ } else {
282
+ buildRefs.set(buildKey, refs)
283
+ }
284
+ }
285
+ } catch (error) {
286
+ if (!semReleased) {
287
+ if (isRust) rustSemaphore.release()
288
+ else if (isBun) bunSemaphore.release()
289
+ }
290
+ task.title = ` ${bundle} ${target.host} (${arch}) ${pc.red((error as Error).message.slice(0, 100))}`
291
+ hasError = true
292
+ throw error
293
+ }
294
+ },
295
+ })),
296
+ {
297
+ concurrent: true,
298
+ exitOnError: false,
299
+ rendererOptions: { collapseErrors: false },
300
+ },
301
+ )
302
+
303
+ await listr.run().catch(() => {})
304
+
305
+ let totalOk = 0
306
+ let totalFail = 0
307
+ let totalSkip = 0
308
+ const serverSet = new Set<string>()
309
+
310
+ for (const line of deployLines) {
311
+ serverSet.add(line.host)
312
+ if (line.skipped) {
313
+ totalSkip++
314
+ } else if (line.ok) {
315
+ totalOk++
316
+ } else {
317
+ totalFail++
98
318
  }
99
319
  }
100
320
 
321
+ const total = totalOk + totalFail + totalSkip
322
+ if (total > 0) {
323
+ const parts: string[] = []
324
+ if (totalOk > 0) parts.push(pc.green(`${totalOk} 成功`))
325
+ if (totalFail > 0) parts.push(pc.red(`${totalFail} 失败`))
326
+ if (totalSkip > 0) parts.push(pc.dim(`${totalSkip} 跳过`))
327
+ const bundleCount = new Set(deployLines.map((l) => l.bundle)).size
328
+ const msg = `${pc.bold("部署结果")} ${bundleCount} 个 bundle → ${serverSet.size} 台服务器,${parts.join(",")},共 ${total} 次部署`
329
+ if (totalFail === total) log.error(msg)
330
+ else if (totalFail > 0) log.warn(msg)
331
+ else log.success(msg)
332
+ }
333
+
334
+ if (hasError) process.exitCode = 1
335
+
101
336
  outro(pc.green("部署流程结束"))
102
337
  },
103
338
  })
@@ -64,7 +64,7 @@ const startCommand = defineCommand({
64
64
  } else {
65
65
  const bundles = await listBundles(ctx.root)
66
66
  if (bundles.length === 0) {
67
- log.error("尚未创建任何 bundle,请先运行 atomfn bundle create")
67
+ log.error("尚未创建任何 bundle,请先运行 atomfunctions bundle create")
68
68
  process.exit(1)
69
69
  }
70
70
  bundle = unwrap(await select({ message: "选择函数包", options: bundles.map((b) => ({ value: b, label: b })) })) as string
@@ -8,7 +8,7 @@ import { BUNDLE_FILE, BUNDLES_DIR } from "../consts.ts"
8
8
  import { loadContext } from "../context.ts"
9
9
  import { scaffoldFunction } from "../scaffold.ts"
10
10
  import type { BundleConfig, FunctionKind } from "../types.ts"
11
- import { isValidSlug, readJson, unwrap } from "../utils.ts"
11
+ import { readJson, unwrap } from "../utils.ts"
12
12
 
13
13
  async function listBundles(root: string): Promise<string[]> {
14
14
  const dir = path.join(root, BUNDLES_DIR)
@@ -65,7 +65,7 @@ const createCommand = defineCommand({
65
65
  } else {
66
66
  const bundles = await listBundles(ctx.root)
67
67
  if (bundles.length === 0) {
68
- log.error("尚未创建任何 bundle,请先运行 atomfn bundle create")
68
+ log.error("尚未创建任何 bundle,请先运行 atomfunctions bundle create")
69
69
  process.exit(1)
70
70
  }
71
71
  bundle = unwrap(
@@ -76,6 +76,17 @@ const createCommand = defineCommand({
76
76
  ) as string
77
77
  }
78
78
 
79
+ const bundleDir = path.join(ctx.root, BUNDLES_DIR, bundle)
80
+ const bundleCfg = await readJson<BundleConfig>(path.join(bundleDir, BUNDLE_FILE))
81
+ if (!bundleCfg) {
82
+ log.error(`bundle ${bundle} 不存在或不是有效的函数包`)
83
+ process.exit(1)
84
+ }
85
+ const language = bundleCfg.language
86
+ const isRust = language === "rust"
87
+ const slugRe = /^[a-z][a-z0-9-]*$/
88
+ const slugHint = "需以小写字母开头,仅含小写字母、数字、连字符"
89
+
79
90
  const slug =
80
91
  args.slug ??
81
92
  unwrap(
@@ -84,13 +95,13 @@ const createCommand = defineCommand({
84
95
  validate: (v) => {
85
96
  const s = v?.trim()
86
97
  if (!s) return "请输入函数名"
87
- return isValidSlug(s) ? undefined : "需以小写字母开头,仅含小写字母、数字、连字符"
98
+ return slugRe.test(s) ? undefined : slugHint
88
99
  },
89
100
  }),
90
101
  ).trim()
91
102
 
92
- if (!isValidSlug(slug)) {
93
- log.error("函数名需以小写字母开头,仅含小写字母、数字、连字符")
103
+ if (!slugRe.test(slug)) {
104
+ log.error(slugHint)
94
105
  process.exit(1)
95
106
  }
96
107
 
@@ -105,16 +116,20 @@ const createCommand = defineCommand({
105
116
  }),
106
117
  ) as FunctionKind)
107
118
 
108
- const bundleDir = path.join(ctx.root, BUNDLES_DIR, bundle)
109
- const fnDir = path.join(bundleDir, slug)
110
- if (await Bun.file(path.join(fnDir, "function.ts")).exists()) {
119
+ let exists = false
120
+ if (isRust) {
121
+ exists = await Bun.file(path.join(bundleDir, "src", "functions", slug, "function.rs")).exists()
122
+ } else {
123
+ exists = await Bun.file(path.join(bundleDir, slug, "function.ts")).exists()
124
+ }
125
+ if (exists) {
111
126
  const ok = await confirm({ message: `函数 ${bundle}/${slug} 已存在,是否覆盖?`, initialValue: false })
112
127
  if (isCancel(ok) || !ok) {
113
128
  outro(pc.yellow("已取消"))
114
129
  return
115
130
  }
116
131
  }
117
- await scaffoldFunction(bundleDir, slug, "bun", kind)
132
+ await scaffoldFunction(bundleDir, slug, language, kind)
118
133
  log.success(`已创建${KIND_OPTIONS.find((k) => k.value === kind)?.label}函数 ${bundle}/${slug}`)
119
134
  outro(pc.green("完成,运行 atomfunctions deploy 进行部署"))
120
135
  },
@@ -103,6 +103,7 @@ export const initCommand = defineCommand({
103
103
  }
104
104
 
105
105
  const manifest: Manifest = {
106
+ ...existing,
106
107
  $schema: `./${SCHEMAS_DIR}/${MANIFEST_SCHEMA_FILE}`,
107
108
  server,
108
109
  ...(slug ? { project: { slug } } : {}),