@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.
- package/package.json +4 -2
- package/src/build.ts +200 -35
- package/src/client.ts +4 -4
- package/src/commands/deploy.ts +277 -42
- package/src/commands/dev.ts +1 -1
- package/src/commands/function.ts +24 -9
- package/src/commands/init.ts +1 -0
- package/src/commands/invoke.ts +187 -16
- package/src/commands/list.ts +35 -5
- package/src/commands/logs.ts +88 -12
- package/src/commands/rollback.ts +155 -29
- package/src/commands/whoami.ts +11 -7
- package/src/consts.ts +2 -0
- package/src/context.ts +11 -6
- package/src/scaffold.ts +58 -42
- package/src/types.ts +2 -1
- package/src/utils.ts +5 -0
package/src/commands/invoke.ts
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
data: { type: "string", description: "JSON
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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(
|
|
37
|
+
intro(pc.cyan("调用函数"))
|
|
22
38
|
const ctx = await requireProjectContext()
|
|
23
|
-
const target = pickTarget(ctx, args.server)
|
|
24
39
|
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
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
|
})
|
package/src/commands/list.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
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} 个函数包`))
|
package/src/commands/logs.ts
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
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(
|
|
16
|
+
intro(pc.cyan("查看日志"))
|
|
16
17
|
const ctx = await requireProjectContext()
|
|
17
|
-
|
|
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
|
-
|
|
23
|
-
|
|
40
|
+
let bundle: string
|
|
41
|
+
if (args.bundle) {
|
|
42
|
+
bundle = args.bundle
|
|
24
43
|
} else {
|
|
25
|
-
|
|
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
|
+
|
package/src/commands/rollback.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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(
|
|
38
|
+
intro(pc.cyan("回滚函数包"))
|
|
16
39
|
const ctx = await requireProjectContext()
|
|
17
40
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
)
|
|
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
|
-
|
|
160
|
+
targets.map(async (t) => {
|
|
37
161
|
try {
|
|
38
|
-
await new AdminClient(t.server, t.token).rollback(ctx.project,
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
})
|
package/src/commands/whoami.ts
CHANGED
|
@@ -37,13 +37,17 @@ export const whoamiCommand = defineCommand({
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
const client = new AdminClient(server, cred.token)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
@@ -18,6 +18,7 @@ export const KEYCHAIN_SERVICE_PASSWORD = "com.atomservice.functions.password"
|
|
|
18
18
|
|
|
19
19
|
export const ADMIN_PREFIX = "/v1"
|
|
20
20
|
export const SLUG_RE = /^[a-z][a-z0-9-]*$/
|
|
21
|
+
export const RUST_SLUG_RE = /^[a-z][a-z0-9_]*$/
|
|
21
22
|
export const VIEWS_DIR = "views"
|
|
22
23
|
export const LOCAL_DIR = "local"
|
|
23
24
|
export const REMOTE_DIR = "remote"
|
|
@@ -25,6 +26,7 @@ export const REMOTE_DIR = "remote"
|
|
|
25
26
|
export const GITIGNORE_BODY = `${LOCAL_DIR}/\n${REMOTE_DIR}/\n${VIEWS_DIR}/\n`
|
|
26
27
|
|
|
27
28
|
export const SDK_PACKAGE = "@atomservice/functions-sdk"
|
|
29
|
+
export const RUST_SDK_VERSION = "0.1.4"
|
|
28
30
|
export const LINUX_TARGET = "bun-linux-x64"
|
|
29
31
|
export const DEFAULT_DEV_PORT = 8080
|
|
30
32
|
|