@atomservice/functions-cli 0.1.6 → 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.
@@ -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
  })
@@ -1,32 +1,80 @@
1
- import { rm } from "node:fs/promises"
1
+ import { existsSync, readdirSync } from "node:fs"
2
+ import type { Dirent } from "node:fs"
3
+ import { rm, writeFile } from "node:fs/promises"
4
+ import { connect } from "node:net"
2
5
  import path from "node:path"
3
- import { intro, log } from "@clack/prompts"
6
+ import { intro, log, outro, select, text } from "@clack/prompts"
4
7
  import { defineCommand } from "citty"
5
8
  import pc from "picocolors"
6
9
  import { resolveFunctions, writeEntry } from "../build.ts"
7
- import { BUNDLE_FILE, DEFAULT_DEV_PORT } from "../consts.ts"
10
+ import { BUNDLES_DIR, BUNDLE_FILE, DEFAULT_DEV_PORT, DEV_PID_FILE } from "../consts.ts"
8
11
  import { loadContext } from "../context.ts"
9
12
  import type { BundleConfig } from "../types.ts"
10
- import { readJson } from "../utils.ts"
13
+ import { isValidSlug, readJson, unwrap, writeJson } from "../utils.ts"
11
14
 
12
- export const devCommand = defineCommand({
13
- meta: { name: "dev", description: "本地热重载运行函数包" },
15
+ function pidFile(bundleDir: string): string {
16
+ return path.join(bundleDir, DEV_PID_FILE)
17
+ }
18
+
19
+ function isPortInUse(port: number): Promise<boolean> {
20
+ return new Promise((resolve) => {
21
+ const sock = connect({ port, host: "127.0.0.1" }, () => {
22
+ sock.destroy()
23
+ resolve(true)
24
+ })
25
+ sock.on("error", () => resolve(false))
26
+ })
27
+ }
28
+
29
+ async function listBundles(root: string): Promise<string[]> {
30
+ const dir = path.join(root, BUNDLES_DIR)
31
+ let entries: Dirent[]
32
+ try {
33
+ entries = readdirSync(dir, { withFileTypes: true }) as unknown as Dirent[]
34
+ } catch {
35
+ return []
36
+ }
37
+ return entries
38
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
39
+ .filter((e) => existsSync(path.join(dir, e.name, BUNDLE_FILE)))
40
+ .map((e) => e.name)
41
+ .sort()
42
+ }
43
+
44
+ const startCommand = defineCommand({
45
+ meta: { name: "start", description: "启动本地开发服务器" },
14
46
  args: {
15
- bundle: { type: "positional", description: "bundle 名", required: true },
16
- port: { type: "string", description: `本地端口(默认 ${DEFAULT_DEV_PORT})` },
47
+ bundle: { type: "positional", required: false, description: "bundle 名" },
48
+ port: { type: "string", description: `本地端口(默认从 atombundle.json 读取,否则 ${DEFAULT_DEV_PORT})` },
17
49
  },
18
50
  async run({ args }) {
19
- intro(pc.cyan(`本地运行 ${args.bundle}`))
20
51
  const ctx = await loadContext()
21
52
  if (!ctx) {
22
53
  log.error("未找到 atomfunctions.json")
23
54
  process.exit(1)
24
55
  }
25
- const bundleDir = path.join(ctx.root, args.bundle)
26
56
 
27
- const config = await readJson<BundleConfig>(path.join(bundleDir, BUNDLE_FILE))
57
+ let bundle: string
58
+ if (typeof args.bundle === "string") {
59
+ if (!isValidSlug(args.bundle)) {
60
+ log.error("bundle 名需以小写字母开头,仅含小写字母、数字、连字符")
61
+ process.exit(1)
62
+ }
63
+ bundle = args.bundle
64
+ } else {
65
+ const bundles = await listBundles(ctx.root)
66
+ if (bundles.length === 0) {
67
+ log.error("尚未创建任何 bundle,请先运行 atomfunctions bundle create")
68
+ process.exit(1)
69
+ }
70
+ bundle = unwrap(await select({ message: "选择函数包", options: bundles.map((b) => ({ value: b, label: b })) })) as string
71
+ }
72
+
73
+ const bundleDir = path.join(ctx.root, BUNDLES_DIR, bundle)
74
+ const configPath = path.join(bundleDir, BUNDLE_FILE)
75
+ const config = await readJson<BundleConfig>(configPath)
28
76
  if (!config) {
29
- log.error(`未找到 ${args.bundle}/${BUNDLE_FILE}`)
77
+ log.error(`未找到 ${BUNDLES_DIR}/${bundle}/${BUNDLE_FILE}`)
30
78
  process.exit(1)
31
79
  }
32
80
  if (config.language !== "bun") {
@@ -34,16 +82,50 @@ export const devCommand = defineCommand({
34
82
  process.exit(1)
35
83
  }
36
84
 
85
+ const defaultPort = args.port ? Number(args.port) : (config.dev?.port ?? DEFAULT_DEV_PORT)
86
+ if (Number.isNaN(defaultPort) || defaultPort < 1 || defaultPort > 65535) {
87
+ log.error("端口号不合法,范围 1-65535")
88
+ process.exit(1)
89
+ }
90
+
91
+ const portStr = unwrap(
92
+ await text({
93
+ message: "监听端口",
94
+ initialValue: String(defaultPort),
95
+ validate: (v) => {
96
+ const n = Number(v)
97
+ if (Number.isNaN(n) || n < 1 || n > 65535) return "端口号不合法,范围 1-65535"
98
+ },
99
+ }),
100
+ ).trim()
101
+ const port = Number(portStr)
102
+
103
+ const pf = pidFile(bundleDir)
104
+ if (existsSync(pf)) {
105
+ const oldPid = Number(await Bun.file(pf).text().catch(() => ""))
106
+ if (oldPid && isProcessAlive(oldPid)) {
107
+ log.error(`bundle ${bundle} 已在运行中(pid ${oldPid})`)
108
+ process.exit(1)
109
+ }
110
+ await rm(pf, { force: true })
111
+ }
112
+
113
+ if (await isPortInUse(port)) {
114
+ log.error(`端口 ${port} 已被占用,请更换`)
115
+ process.exit(1)
116
+ }
117
+
37
118
  const functions = await resolveFunctions(bundleDir, config.defaults)
38
119
  if (functions.length === 0) {
39
120
  log.error("该 bundle 内没有函数")
40
121
  process.exit(1)
41
122
  }
42
123
 
43
- const port = Number(args.port ?? DEFAULT_DEV_PORT)
124
+ intro(pc.cyan(`本地开发 ${bundle}`))
125
+
44
126
  const entryPath = await writeEntry(bundleDir, functions)
45
127
  log.info(`函数:${functions.map((f) => f.name).join("、")}`)
46
- log.info(pc.dim(`http://localhost:${port}/__invoke/<function>`))
128
+ log.info(pc.dim(`http://localhost:${port}/invoke/<function>`))
47
129
 
48
130
  const proc = Bun.spawn(["bun", "--watch", entryPath], {
49
131
  cwd: bundleDir,
@@ -52,20 +134,80 @@ export const devCommand = defineCommand({
52
134
  ...process.env,
53
135
  ...config.env,
54
136
  ATOMFN_PROJECT: ctx.project,
55
- ATOMFN_BUNDLE: args.bundle,
137
+ ATOMFN_BUNDLE: bundle,
56
138
  ATOMFN_PORT: String(port),
57
139
  },
58
140
  })
59
141
 
60
- const cleanup = async () => {
142
+ await writeFile(pf, String(proc.pid))
143
+
144
+ // write back port if changed
145
+ const prevPort = config.dev?.port
146
+ if (port !== prevPort) {
147
+ config.dev = { ...config.dev, port }
148
+ await writeJson(configPath, config)
149
+ }
150
+
151
+ const onExit = async () => {
61
152
  proc.kill()
62
153
  await rm(entryPath, { force: true })
154
+ await rm(pf, { force: true })
63
155
  process.exit(0)
64
156
  }
65
- process.on("SIGINT", cleanup)
66
- process.on("SIGTERM", cleanup)
157
+ process.on("SIGINT", onExit)
158
+ process.on("SIGTERM", onExit)
67
159
 
68
160
  await proc.exited
69
161
  await rm(entryPath, { force: true })
162
+ await rm(pf, { force: true })
163
+ },
164
+ })
165
+
166
+ const stopCommand = defineCommand({
167
+ meta: { name: "stop", description: "停止本地开发服务器" },
168
+ async run() {
169
+ const ctx = await loadContext()
170
+ if (!ctx) {
171
+ log.error("未找到 atomfunctions.json")
172
+ process.exit(1)
173
+ }
174
+
175
+ const bundles = await listBundles(ctx.root)
176
+ let stopped = 0
177
+ for (const b of bundles) {
178
+ const pf = pidFile(path.join(ctx.root, BUNDLES_DIR, b))
179
+ if (!existsSync(pf)) continue
180
+ const pid = Number(await Bun.file(pf).text().catch(() => ""))
181
+ if (!pid) continue
182
+ if (isProcessAlive(pid)) {
183
+ try {
184
+ process.kill(pid, "SIGTERM")
185
+ stopped++
186
+ } catch {
187
+ // already gone
188
+ }
189
+ }
190
+ await rm(pf, { force: true })
191
+ }
192
+
193
+ if (stopped === 0) {
194
+ outro("没有正在运行的 dev 服务")
195
+ } else {
196
+ outro(pc.green(`已停止 ${stopped} 个 dev 服务`))
197
+ }
70
198
  },
71
199
  })
200
+
201
+ function isProcessAlive(pid: number): boolean {
202
+ try {
203
+ process.kill(pid, 0)
204
+ return true
205
+ } catch {
206
+ return false
207
+ }
208
+ }
209
+
210
+ export const devCommand = defineCommand({
211
+ meta: { name: "dev", description: "本地开发服务器" },
212
+ subCommands: { start: startCommand, stop: stopCommand },
213
+ })
@@ -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 } } : {}),