@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,40 +1,211 @@
1
- import { intro, log, outro } from "@clack/prompts"
1
+ import { intro, isCancel, log, outro, select, spinner, text } from "@clack/prompts"
2
+ import { JSON5 } from "bun"
2
3
  import { defineCommand } from "citty"
3
4
  import pc from "picocolors"
5
+ import { AdminClient } from "../client.ts"
4
6
  import { pickTarget, requireProjectContext } from "../context.ts"
7
+ import type { ServerTarget } from "../types.ts"
5
8
  import { parseRef } from "../utils.ts"
6
9
 
10
+ function normalizeBody(raw: string): string {
11
+ const trimmed = raw.trim()
12
+ if (!trimmed) return ""
13
+ return JSON.stringify(JSON5.parse(trimmed))
14
+ }
15
+
7
16
  export const invokeCommand = defineCommand({
8
17
  meta: { name: "invoke", description: "调用函数(调试用)" },
9
18
  args: {
10
- ref: { type: "positional", description: "<bundle>/<function>", required: true },
11
- server: { type: "string", description: "指定机器(默认首台)" },
12
- data: { type: "string", description: "JSON 请求体(不填则从 stdin 读取)" },
19
+ server: { type: "string", description: "指定机器(不填则交互式选择)" },
20
+ ref: { type: "positional", description: "<bundle>/<function>(不填则交互式选择)", required: false },
21
+ data: { type: "string", description: "JSON 请求体,支持 @file.json(不填则交互式输入)" },
13
22
  },
14
23
  async run({ args }) {
15
- const { bundle, fn } = parseRef(args.ref)
16
- if (!fn) {
17
- log.error("请使用 <bundle>/<function> 格式")
18
- process.exit(1)
24
+ let refBundle: string | undefined
25
+ let refFn: string | undefined
26
+ if (args.ref) {
27
+ try {
28
+ const parsed = parseRef(args.ref)
29
+ refBundle = parsed.bundle
30
+ refFn = parsed.fn
31
+ } catch (err) {
32
+ log.error(err instanceof Error ? err.message : "引用格式错误")
33
+ process.exit(1)
34
+ }
19
35
  }
20
36
 
21
- intro(pc.cyan(`调用 ${bundle}/${fn}`))
37
+ intro(pc.cyan("调用函数"))
22
38
  const ctx = await requireProjectContext()
23
- const target = pickTarget(ctx, args.server)
24
39
 
25
- const body = args.data ?? ((await Bun.stdin.text()) || "{}")
26
- const url = `${target.server.replace(/\/+$/, "")}/${ctx.project}/${bundle}/${fn}`
40
+ let target: ServerTarget
41
+ if (typeof args.server === "string") {
42
+ target = pickTarget(ctx, args.server)
43
+ } else if (ctx.targets.length === 1 && ctx.targets[0]) {
44
+ target = ctx.targets[0]
45
+ } else {
46
+ const options = ctx.targets.map((t, i) => ({ value: String(i), label: t.host, hint: t.server }))
47
+ const selected = await select({
48
+ message: "选择目标机器",
49
+ options,
50
+ initialValue: "0",
51
+ })
52
+ if (isCancel(selected)) {
53
+ outro(pc.yellow("已取消"))
54
+ return
55
+ }
56
+ const idx = Number(selected)
57
+ const t = ctx.targets[idx]
58
+ if (!t) {
59
+ log.error("无效的目标机器")
60
+ process.exit(1)
61
+ }
62
+ target = t
63
+ }
64
+
65
+ const client = new AdminClient(target.server, target.token)
66
+
67
+ const s = spinner()
68
+ s.start("获取函数包列表")
69
+ let serverBundles: Awaited<ReturnType<typeof client.list>>
70
+ try {
71
+ serverBundles = await client.list(ctx.project)
72
+ } catch (err) {
73
+ s.stop(pc.red("获取失败"))
74
+ log.error(err instanceof Error ? err.message : "获取函数包列表失败")
75
+ process.exit(1)
76
+ }
77
+
78
+ let bundle: string
79
+ let bundleRecord: (typeof serverBundles)[number] | undefined
80
+ if (refBundle) {
81
+ bundle = refBundle
82
+ bundleRecord = serverBundles.find((b) => b.bundle === bundle)
83
+ } else {
84
+ s.stop(pc.dim(target.host))
85
+ if (serverBundles.length === 0) {
86
+ log.warn("该机器上没有已部署的函数包")
87
+ return
88
+ }
89
+ const selected = await select({
90
+ message: "选择 bundle",
91
+ options: serverBundles.map((b) => ({ value: b.bundle, label: b.bundle, hint: `${b.artifact}:${b.version}` })),
92
+ })
93
+ if (isCancel(selected)) {
94
+ outro(pc.yellow("已取消"))
95
+ return
96
+ }
97
+ bundle = selected as string
98
+ bundleRecord = serverBundles.find((b) => b.bundle === bundle)
99
+ s.start()
100
+ }
101
+
102
+ if (!bundleRecord || bundleRecord.functions.length === 0) {
103
+ s.stop(pc.red("无函数"))
104
+ log.error(`bundle ${bundle} 没有已部署的函数`)
105
+ process.exit(1)
106
+ }
107
+ const fnList = bundleRecord.functions
108
+ s.stop(pc.dim(`${bundle} ${target.host}`))
27
109
 
110
+ let fn: string
111
+ if (refFn) {
112
+ if (!fnList.includes(refFn)) {
113
+ log.error(`函数 ${refFn} 不在 ${bundle} 中,可用:${fnList.join("、")}`)
114
+ process.exit(1)
115
+ }
116
+ fn = refFn
117
+ } else {
118
+ const selected = await select({
119
+ message: "选择函数",
120
+ options: fnList.map((f) => ({ value: f, label: f })),
121
+ })
122
+ if (isCancel(selected)) {
123
+ outro(pc.yellow("已取消"))
124
+ return
125
+ }
126
+ fn = selected as string
127
+ }
128
+
129
+ let body: string
130
+ if (args.data !== undefined) {
131
+ if (args.data.startsWith("@")) {
132
+ const filePath = args.data.slice(1)
133
+ try {
134
+ body = normalizeBody(await Bun.file(filePath).text())
135
+ } catch {
136
+ log.error(`无法读取文件:${filePath}`)
137
+ process.exit(1)
138
+ }
139
+ } else {
140
+ body = normalizeBody(args.data)
141
+ }
142
+ } else {
143
+ const input = await text({
144
+ message: "请求体(JSON/JSON5)",
145
+ placeholder: "留空表示无请求体",
146
+ validate: (v) => {
147
+ if (!v || v.trim() === "") return undefined
148
+ try {
149
+ JSON5.parse(v.trim())
150
+ return undefined
151
+ } catch {
152
+ return "JSON/JSON5 格式错误"
153
+ }
154
+ },
155
+ })
156
+ if (isCancel(input)) {
157
+ outro(pc.yellow("已取消"))
158
+ return
159
+ }
160
+ body = normalizeBody(input as string)
161
+ }
162
+
163
+ const url = `${target.server.replace(/\/+$/, "")}/${ctx.project}/${bundle}/${fn}`
164
+ const s2 = spinner()
165
+ s2.start(`调用 ${bundle}/${fn}`)
28
166
  let res: Response
29
167
  try {
30
- res = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body })
168
+ const headers: Record<string, string> = {
169
+ authorization: `Bearer ${target.token}`,
170
+ }
171
+ if (body) headers["content-type"] = "application/json"
172
+ res = await fetch(url, { method: "POST", headers, body: body || undefined })
31
173
  } catch {
174
+ s2.stop(pc.red("调用失败"))
32
175
  log.error(`无法连接到 ${url}`)
33
176
  process.exit(1)
34
177
  }
35
178
 
36
- const text = await res.text()
37
- process.stdout.write(`${text}\n`)
38
- outro(res.ok ? pc.green(`HTTP ${res.status}`) : pc.red(`HTTP ${res.status}`))
179
+ const resText = await res.text()
180
+ s2.stop(pc.dim(`${bundle}/${fn} ${target.host}`))
181
+
182
+ const statusColor = res.ok ? pc.green : pc.red
183
+ log.info(statusColor(`${res.status} ${res.statusText}`))
184
+
185
+ let parsed: unknown
186
+ try {
187
+ parsed = resText ? JSON.parse(resText) : undefined
188
+ } catch {
189
+ parsed = undefined
190
+ }
191
+
192
+ if (!res.ok) {
193
+ const errObj =
194
+ parsed && typeof parsed === "object" && "error" in parsed
195
+ ? (parsed as { error?: { code?: string; message?: string } }).error
196
+ : undefined
197
+ if (errObj?.message) {
198
+ log.error(`${errObj.message}${errObj.code ? pc.dim(`(${errObj.code})`) : ""}`)
199
+ } else if (resText.trim()) {
200
+ log.error(resText.trim())
201
+ } else {
202
+ log.error(`请求失败(${res.status}),服务端未返回错误信息`)
203
+ }
204
+ process.exitCode = 1
205
+ return
206
+ }
207
+
208
+ const output = parsed !== undefined ? JSON.stringify(parsed, null, 2) : resText
209
+ if (output) process.stdout.write(`\n${output}\n`)
39
210
  },
40
211
  })
@@ -1,20 +1,50 @@
1
- import { intro, log, outro } from "@clack/prompts"
1
+ import { intro, isCancel, log, outro, select, spinner } from "@clack/prompts"
2
2
  import { defineCommand } from "citty"
3
3
  import pc from "picocolors"
4
4
  import { AdminClient } from "../client.ts"
5
5
  import { pickTarget, requireProjectContext } from "../context.ts"
6
+ import type { ServerTarget } from "../types.ts"
6
7
 
7
8
  export const listCommand = defineCommand({
8
9
  meta: { name: "list", description: "列出已部署的函数包" },
9
10
  args: {
10
- server: { type: "string", description: "指定查询的机器(默认首台)" },
11
+ server: { type: "string", description: "指定机器(不填则交互式选择)" },
11
12
  },
12
13
  async run({ args }) {
13
14
  intro(pc.cyan("已部署函数包"))
14
15
  const ctx = await requireProjectContext()
15
- const target = pickTarget(ctx, args.server)
16
+
17
+ let target: ServerTarget
18
+ if (typeof args.server === "string") {
19
+ target = pickTarget(ctx, args.server)
20
+ } else if (ctx.targets.length === 1) {
21
+ target = ctx.targets[0]!
22
+ } else {
23
+ const options = ctx.targets.map((t, i) => ({ value: String(i), label: t.host, hint: t.server }))
24
+ const selected = await select({
25
+ message: "选择目标机器",
26
+ options,
27
+ initialValue: "0",
28
+ })
29
+ if (isCancel(selected)) {
30
+ outro(pc.yellow("已取消"))
31
+ return
32
+ }
33
+ target = ctx.targets[Number(selected)]!
34
+ }
35
+
16
36
  const client = new AdminClient(target.server, target.token)
17
- const bundles = await client.list(ctx.project)
37
+ const s = spinner()
38
+ s.start("获取函数包列表")
39
+ let bundles: Awaited<ReturnType<typeof client.list>>
40
+ try {
41
+ bundles = await client.list(ctx.project)
42
+ } catch (err) {
43
+ s.stop(pc.red("获取失败"))
44
+ log.error(err instanceof Error ? err.message : "获取函数包列表失败")
45
+ process.exit(1)
46
+ }
47
+ s.stop(pc.dim(target.host))
18
48
 
19
49
  if (bundles.length === 0) {
20
50
  log.warn("当前项目暂无已部署的函数包")
@@ -24,7 +54,7 @@ export const listCommand = defineCommand({
24
54
 
25
55
  for (const b of bundles) {
26
56
  log.info(
27
- `${pc.bold(`${b.project}/${b.bundle}`)} ${pc.dim(`${b.artifact}:${b.version}`)}\n 函数:${b.functions.join("")}`,
57
+ `${pc.bold(b.bundle)} ${pc.dim(`${b.artifact}:${b.version}`)}\n 函数:${b.functions.join(", ")}`,
28
58
  )
29
59
  }
30
60
  outro(pc.green(`共 ${bundles.length} 个函数包`))
@@ -1,29 +1,105 @@
1
- import { intro, log, outro } from "@clack/prompts"
1
+ import { intro, isCancel, log, outro, select, spinner, text } from "@clack/prompts"
2
2
  import { defineCommand } from "citty"
3
3
  import pc from "picocolors"
4
4
  import { AdminClient } from "../client.ts"
5
5
  import { pickTarget, requireProjectContext } from "../context.ts"
6
+ import type { ServerTarget } from "../types.ts"
6
7
 
7
8
  export const logsCommand = defineCommand({
8
- meta: { name: "logs", description: "查看函数包日志(指定机器)" },
9
+ meta: { name: "logs", description: "查看函数包日志" },
9
10
  args: {
10
- bundle: { type: "positional", description: "bundle 名", required: true },
11
- server: { type: "string", description: "指定机器(默认首台,日志按机器区分)" },
11
+ server: { type: "string", description: "指定机器(不填则交互式选择)" },
12
+ bundle: { type: "positional", description: "bundle 名(不填则交互式选择)", required: false },
12
13
  tail: { type: "string", description: "末尾行数(默认 200)" },
13
14
  },
14
15
  async run({ args }) {
15
- intro(pc.cyan(`日志 ${args.bundle}`))
16
+ intro(pc.cyan("查看日志"))
16
17
  const ctx = await requireProjectContext()
17
- const target = pickTarget(ctx, args.server)
18
+
19
+ let target: ServerTarget
20
+ if (typeof args.server === "string") {
21
+ target = pickTarget(ctx, args.server)
22
+ } else if (ctx.targets.length === 1) {
23
+ target = ctx.targets[0]!
24
+ } else {
25
+ const options = ctx.targets.map((t, i) => ({ value: String(i), label: t.host, hint: t.server }))
26
+ const selected = await select({
27
+ message: "选择目标机器",
28
+ options,
29
+ initialValue: "0",
30
+ })
31
+ if (isCancel(selected)) {
32
+ outro(pc.yellow("已取消"))
33
+ return
34
+ }
35
+ target = ctx.targets[Number(selected)]!
36
+ }
37
+
18
38
  const client = new AdminClient(target.server, target.token)
19
- const tail = Number(args.tail ?? "200")
20
- const text = await client.logs(ctx.project, args.bundle, Number.isFinite(tail) ? tail : 200)
21
39
 
22
- if (!text.trim()) {
23
- log.warn("暂无日志")
40
+ let bundle: string
41
+ if (args.bundle) {
42
+ bundle = args.bundle
24
43
  } else {
25
- process.stdout.write(`${text}\n`)
44
+ const s = spinner()
45
+ s.start("获取函数包列表")
46
+ let serverBundles: Awaited<ReturnType<typeof client.list>>
47
+ try {
48
+ serverBundles = await client.list(ctx.project)
49
+ } catch (err) {
50
+ s.stop(pc.red("获取失败"))
51
+ log.error(err instanceof Error ? err.message : "获取函数包列表失败")
52
+ process.exit(1)
53
+ }
54
+ s.stop(pc.dim(target.host))
55
+
56
+ if (serverBundles.length === 0) {
57
+ log.warn("该机器上没有已部署的函数包")
58
+ return
59
+ }
60
+ const selected = await select({
61
+ message: "选择 bundle",
62
+ options: serverBundles.map((b) => ({ value: b.bundle, label: b.bundle, hint: `${b.artifact}:${b.version}` })),
63
+ })
64
+ if (isCancel(selected)) {
65
+ outro(pc.yellow("已取消"))
66
+ return
67
+ }
68
+ bundle = selected as string
69
+ }
70
+
71
+ let tail: number
72
+ if (args.tail !== undefined) {
73
+ tail = Number(args.tail)
74
+ if (!Number.isFinite(tail) || tail <= 0) tail = 200
75
+ } else {
76
+ const input = await text({
77
+ message: "查看末尾行数",
78
+ initialValue: "200",
79
+ validate: (v) => (v && /^\d+$/.test(v) && Number(v) > 0 ? undefined : "请输入正整数"),
80
+ })
81
+ if (isCancel(input)) {
82
+ outro(pc.yellow("已取消"))
83
+ return
84
+ }
85
+ tail = Number(input)
86
+ }
87
+
88
+ const s = spinner()
89
+ s.start(`获取 ${bundle} 日志`)
90
+ try {
91
+ const logText = await client.logs(ctx.project, bundle, tail)
92
+ s.stop(pc.dim(`${bundle} ${target.host}`))
93
+ if (!logText.trim()) {
94
+ log.warn("暂无日志")
95
+ } else {
96
+ process.stdout.write(`${logText}\n`)
97
+ }
98
+ } catch (err) {
99
+ s.stop(pc.red("获取失败"))
100
+ log.error(err instanceof Error ? err.message : "获取日志失败")
101
+ process.exit(1)
26
102
  }
27
- outro(pc.green("已结束"))
28
103
  },
29
104
  })
105
+
@@ -1,56 +1,182 @@
1
- import { intro, log, outro, select } from "@clack/prompts"
1
+ import { intro, isCancel, log, multiselect, outro, select, spinner } from "@clack/prompts"
2
2
  import { defineCommand } from "citty"
3
3
  import pc from "picocolors"
4
4
  import { AdminClient } from "../client.ts"
5
5
  import { pickTarget, requireProjectContext } from "../context.ts"
6
- import { unwrap } from "../utils.ts"
6
+ import type { BundleRecord, ServerTarget } from "../types.ts"
7
+
8
+ type BundlesOnHost = { host: string; server: string; token: string; bundles: BundleRecord[] }
9
+ type VersionsOnHost = { host: string; server: string; token: string; versions: BundleRecord[] }
10
+
11
+ function commonOf<T>(results: T[], pick: (r: T) => string[]): string[] {
12
+ const sets = results.map((r) => new Set(pick(r)))
13
+ const all = [...new Set(results.flatMap((r) => pick(r)))]
14
+ return all.filter((name) => sets.every((s) => s.has(name)))
15
+ }
16
+
17
+ function missingWarnings<T>(results: T[], pick: (r: T) => string[], common: string[], label: string): void {
18
+ const all = [...new Set(results.flatMap((r) => pick(r)))]
19
+ for (const name of all) {
20
+ if (common.includes(name)) continue
21
+ const missingOn = results.filter((r) => !pick(r).includes(name)).map((r) => (r as BundlesOnHost).host)
22
+ log.warn(`${name} 未在 ${missingOn.join("、")} ${label}`)
23
+ }
24
+ }
25
+
26
+ function formatTime(iso: string): string {
27
+ return new Date(iso).toLocaleString("zh-CN", { hour12: false })
28
+ }
7
29
 
8
30
  export const rollbackCommand = defineCommand({
9
- meta: { name: "rollback", description: "回滚函数包到历史版本(扇出到所有机器)" },
31
+ meta: { name: "rollback", description: "回滚函数包到历史版本" },
10
32
  args: {
11
- bundle: { type: "positional", description: "bundle ", required: true },
33
+ bundle: { type: "positional", description: "bundle 名(不填则交互式选择)", required: false },
34
+ server: { type: "string", description: "指定机器(不填则交互式选择)" },
12
35
  version: { type: "string", description: "目标版本(不填则从历史中选择)" },
13
36
  },
14
37
  async run({ args }) {
15
- intro(pc.cyan(`回滚 ${args.bundle}`))
38
+ intro(pc.cyan("回滚函数包"))
16
39
  const ctx = await requireProjectContext()
17
40
 
18
- const primary = pickTarget(ctx)
19
- const history = await new AdminClient(primary.server, primary.token).versions(ctx.project, args.bundle)
20
-
21
- let version = args.version
22
- if (!version) {
23
- version = unwrap(
24
- await select({
25
- message: "选择回滚到的版本",
26
- options: history.map((record, index) => ({
27
- value: record.version,
28
- label: `${record.version}${index === 0 ? pc.dim(" (当前)") : ""}`,
29
- hint: record.updatedAt,
30
- })),
41
+ let targets: ServerTarget[]
42
+ if (typeof args.server === "string") {
43
+ targets = [pickTarget(ctx, args.server)]
44
+ } else if (ctx.targets.length === 1) {
45
+ targets = [ctx.targets[0]!]
46
+ } else {
47
+ const options = ctx.targets.map((t, i) => ({ value: String(i), label: t.host, hint: t.server }))
48
+ const selected = await multiselect({
49
+ message: "选择目标机器",
50
+ options,
51
+ required: true,
52
+ initialValues: ctx.targets.map((_, i) => String(i)),
53
+ })
54
+ if (isCancel(selected)) {
55
+ outro(pc.yellow("已取消"))
56
+ return
57
+ }
58
+ targets = (selected as string[]).map((i) => ctx.targets[Number(i)]!)
59
+ }
60
+
61
+ const bundlesResults: BundlesOnHost[] = []
62
+ for (const t of targets) {
63
+ try {
64
+ const c = new AdminClient(t.server, t.token)
65
+ bundlesResults.push({ host: t.host, server: t.server, token: t.token, bundles: await c.list(ctx.project) })
66
+ } catch (err) {
67
+ log.error(`${t.host}: ${err instanceof Error ? err.message : "获取函数包列表失败"}`)
68
+ process.exit(1)
69
+ }
70
+ }
71
+
72
+ const commonBundles = commonOf(bundlesResults, (r) => r.bundles.map((b) => b.bundle))
73
+ missingWarnings(bundlesResults, (r) => r.bundles.map((b) => b.bundle), commonBundles, "上部署")
74
+
75
+ if (commonBundles.length === 0) {
76
+ log.error("选中机器上没有共同部署的函数包")
77
+ process.exitCode = 1
78
+ return
79
+ }
80
+
81
+ let bundle: string
82
+ if (args.bundle) {
83
+ if (!commonBundles.includes(args.bundle)) {
84
+ const missingOn = bundlesResults.filter((r) => !r.bundles.some((b) => b.bundle === args.bundle)).map((r) => r.host)
85
+ log.error(`bundle ${args.bundle} 未在 ${missingOn.join("、")} 上部署`)
86
+ process.exit(1)
87
+ }
88
+ bundle = args.bundle
89
+ } else {
90
+ const firstBundles = bundlesResults[0]!.bundles
91
+ const selected = await select({
92
+ message: "选择 bundle",
93
+ options: commonBundles.map((name) => {
94
+ const record = firstBundles.find((b) => b.bundle === name)
95
+ return { value: name, label: name, hint: record ? `${record.artifact}:${record.version}` : undefined }
31
96
  }),
32
- ) as string
97
+ })
98
+ if (isCancel(selected)) {
99
+ outro(pc.yellow("已取消"))
100
+ return
101
+ }
102
+ bundle = selected as string
33
103
  }
34
104
 
105
+ const versionResults: VersionsOnHost[] = []
106
+ for (const t of targets) {
107
+ try {
108
+ const c = new AdminClient(t.server, t.token)
109
+ versionResults.push({ host: t.host, server: t.server, token: t.token, versions: await c.versions(ctx.project, bundle) })
110
+ } catch (err) {
111
+ log.error(`${t.host}: ${err instanceof Error ? err.message : "获取版本历史失败"}`)
112
+ process.exit(1)
113
+ }
114
+ }
115
+
116
+ const commonVersions = commonOf(versionResults, (r) => r.versions.map((v) => v.version))
117
+ missingWarnings(versionResults, (r) => r.versions.map((v) => v.version), commonVersions, "上不存在该版本")
118
+
119
+ if (commonVersions.length === 0) {
120
+ log.error(`选中机器上没有 ${bundle} 的共同版本`)
121
+ process.exitCode = 1
122
+ return
123
+ }
124
+
125
+ let version: string
126
+ if (args.version) {
127
+ if (!commonVersions.includes(args.version)) {
128
+ const missingOn = versionResults.filter((r) => !r.versions.some((v) => v.version === args.version)).map((r) => r.host)
129
+ log.error(`版本 ${args.version} 未在 ${missingOn.join("、")} 上存在`)
130
+ process.exit(1)
131
+ }
132
+ version = args.version
133
+ } else {
134
+ const firstVersions = versionResults[0]!.versions
135
+ const options = commonVersions.map((ver) => {
136
+ const record = firstVersions.find((v) => v.version === ver)
137
+ const isLatest = versionResults.every((r) => r.versions[0]?.version === ver)
138
+ return {
139
+ value: ver,
140
+ label: `${ver}${isLatest ? pc.dim(" (当前)") : ""}`,
141
+ hint: record ? formatTime(record.updatedAt) : undefined,
142
+ }
143
+ })
144
+ const selected = await select({
145
+ message: "选择回滚到的版本",
146
+ options,
147
+ })
148
+ if (isCancel(selected)) {
149
+ outro(pc.yellow("已取消"))
150
+ return
151
+ }
152
+ version = selected as string
153
+ }
154
+
155
+ if (targets.length > 1) log.info(pc.dim(`目标机器:${targets.map((t) => t.host).join("、")}`))
156
+
157
+ const s = spinner()
158
+ s.start(`回滚 ${bundle}`)
35
159
  const results = await Promise.all(
36
- ctx.targets.map(async (t) => {
160
+ targets.map(async (t) => {
37
161
  try {
38
- await new AdminClient(t.server, t.token).rollback(ctx.project, args.bundle, version as string)
162
+ await new AdminClient(t.server, t.token).rollback(ctx.project, bundle, version)
39
163
  return { host: t.host, ok: true }
40
164
  } catch (error) {
41
165
  return { host: t.host, ok: false, message: error instanceof Error ? error.message : String(error) }
42
166
  }
43
167
  }),
44
168
  )
169
+ s.stop(pc.dim(`${bundle} ${targets.map((t) => t.host).join("、")}`))
45
170
 
46
- const failed = results.filter((r) => !r.ok)
47
- if (failed.length === 0) {
48
- log.success(`已在 ${ctx.targets.length} 台机器回滚到 ${version}`)
49
- outro(pc.green("完成"))
50
- } else {
51
- for (const f of failed) log.error(` ${f.host}: ${f.message}`)
52
- outro(pc.red("部分机器回滚失败"))
53
- process.exitCode = 1
171
+ let hasError = false
172
+ for (const r of results) {
173
+ if (r.ok) {
174
+ log.success(`${r.host} 已回滚`)
175
+ } else {
176
+ log.error(`${r.host} ${r.message ?? "回滚失败"}`)
177
+ hasError = true
178
+ }
54
179
  }
180
+ if (hasError) process.exitCode = 1
55
181
  },
56
182
  })
@@ -37,13 +37,17 @@ export const whoamiCommand = defineCommand({
37
37
  }
38
38
 
39
39
  const client = new AdminClient(server, cred.token)
40
- const res = await client.get<{ user: WhoamiUser }>("/auth/whoami")
41
- const user = res.user
42
-
43
- log.info(`服务器:${pc.cyan(host)}`)
44
- log.info(`账号: ${pc.bold(user.name)} (${user.email})`)
45
- if (user.isSuperAdmin) {
46
- log.info(`角色: ${pc.yellow("超级管理员")}`)
40
+ try {
41
+ const res = await client.get<{ user: WhoamiUser }>("/auth/whoami")
42
+ const user = res.user
43
+ log.info(`服务器:${pc.cyan(host)}`)
44
+ log.info(`账号: ${pc.bold(user.name)} (${user.email})`)
45
+ if (user.isSuperAdmin) {
46
+ log.info(`角色: ${pc.yellow("超级管理员")}`)
47
+ }
48
+ } catch (err) {
49
+ outro(pc.red(err instanceof Error ? err.message : "获取用户信息失败"))
50
+ process.exit(1)
47
51
  }
48
52
 
49
53
  outro(pc.green("已登录"))
package/src/consts.ts CHANGED
@@ -5,6 +5,7 @@ export const MANIFEST_FILE = "atomfunctions.json"
5
5
  export const MANIFEST_SCHEMA_FILE = "atomfunctions.schema.json"
6
6
  export const BUNDLE_FILE = "atombundle.json"
7
7
  export const BUNDLES_DIR = "bundles"
8
+ export const DEV_PID_FILE = ".atomfn-dev.pid"
8
9
  export const SCHEMAS_DIR = "schemas"
9
10
  export const FUNCTION_FILE = "function.ts"
10
11
  export const FUNCTION_CONFIG_FILE = "function.json"
@@ -17,6 +18,7 @@ export const KEYCHAIN_SERVICE_PASSWORD = "com.atomservice.functions.password"
17
18
 
18
19
  export const ADMIN_PREFIX = "/v1"
19
20
  export const SLUG_RE = /^[a-z][a-z0-9-]*$/
21
+ export const RUST_SLUG_RE = /^[a-z][a-z0-9_]*$/
20
22
  export const VIEWS_DIR = "views"
21
23
  export const LOCAL_DIR = "local"
22
24
  export const REMOTE_DIR = "remote"
@@ -24,6 +26,7 @@ export const REMOTE_DIR = "remote"
24
26
  export const GITIGNORE_BODY = `${LOCAL_DIR}/\n${REMOTE_DIR}/\n${VIEWS_DIR}/\n`
25
27
 
26
28
  export const SDK_PACKAGE = "@atomservice/functions-sdk"
29
+ export const RUST_SDK_VERSION = "0.1.4"
27
30
  export const LINUX_TARGET = "bun-linux-x64"
28
31
  export const DEFAULT_DEV_PORT = 8080
29
32