@atomservice/functions-cli 0.1.6
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/bin/functions.ts +9 -0
- package/package.json +42 -0
- package/src/build.ts +252 -0
- package/src/client.ts +109 -0
- package/src/commands/bundle.ts +76 -0
- package/src/commands/deploy.ts +103 -0
- package/src/commands/dev.ts +71 -0
- package/src/commands/function.ts +126 -0
- package/src/commands/init.ts +122 -0
- package/src/commands/invoke.ts +40 -0
- package/src/commands/list.ts +32 -0
- package/src/commands/login.ts +105 -0
- package/src/commands/logout.ts +26 -0
- package/src/commands/logs.ts +29 -0
- package/src/commands/member.ts +140 -0
- package/src/commands/new.ts +68 -0
- package/src/commands/project.ts +145 -0
- package/src/commands/pull.ts +73 -0
- package/src/commands/push.ts +136 -0
- package/src/commands/rollback.ts +56 -0
- package/src/commands/token.ts +130 -0
- package/src/commands/user.ts +77 -0
- package/src/commands/whoami.ts +51 -0
- package/src/consts.ts +36 -0
- package/src/context.ts +90 -0
- package/src/creds.ts +71 -0
- package/src/editor.ts +16 -0
- package/src/index.ts +45 -0
- package/src/scaffold.ts +212 -0
- package/src/types.ts +87 -0
- package/src/utils.ts +42 -0
- package/src/views.ts +31 -0
package/bin/functions.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { log } from "@clack/prompts"
|
|
3
|
+
import { runMain } from "citty"
|
|
4
|
+
import { mainCommand } from "../src/index.ts"
|
|
5
|
+
|
|
6
|
+
runMain(mainCommand).catch((err) => {
|
|
7
|
+
log.error(err instanceof Error ? err.message : String(err))
|
|
8
|
+
process.exit(1)
|
|
9
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@atomservice/functions-cli",
|
|
3
|
+
"version": "0.1.6",
|
|
4
|
+
"description": "函数服务命令行工具(atomfunctions):项目脚手架、本地构建、部署、运维",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "openorson",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/openorson/atomservice.git",
|
|
10
|
+
"directory": "services/functions/cli"
|
|
11
|
+
},
|
|
12
|
+
"bugs": "https://github.com/openorson/atomservice/issues",
|
|
13
|
+
"homepage": "https://github.com/openorson/atomservice/tree/main/services/functions/cli#readme",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"atomservice",
|
|
16
|
+
"functions",
|
|
17
|
+
"faas",
|
|
18
|
+
"cli",
|
|
19
|
+
"bun"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"bun": ">=1.3.14"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src",
|
|
26
|
+
"bin"
|
|
27
|
+
],
|
|
28
|
+
"bin": {
|
|
29
|
+
"atomfunctions": "./bin/functions.ts",
|
|
30
|
+
"atomfn": "./bin/functions.ts"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@atomservice/functions-sdk": "0.1.6",
|
|
34
|
+
"@clack/prompts": "1.5.0",
|
|
35
|
+
"citty": "0.2.2",
|
|
36
|
+
"picocolors": "1.1.1",
|
|
37
|
+
"typebox": "1.2.2"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"typescript": "^6"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/build.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { readdir, rm } from "node:fs/promises"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { $ } from "bun"
|
|
4
|
+
import { BUNDLE_FILE, ENTRY_FILE, FUNCTION_CONFIG_FILE, FUNCTION_FILE, LINUX_TARGET, SDK_PACKAGE } from "./consts.ts"
|
|
5
|
+
import type { BundleConfig, DeployManifest, FunctionFileConfig } from "./types.ts"
|
|
6
|
+
import { readJson } from "./utils.ts"
|
|
7
|
+
|
|
8
|
+
export interface BuildResult {
|
|
9
|
+
manifest: DeployManifest
|
|
10
|
+
artifactPath: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ResolvedFunction {
|
|
14
|
+
name: string
|
|
15
|
+
config: FunctionFileConfig
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function discoverFunctions(bundleDir: string): Promise<string[]> {
|
|
19
|
+
const entries = await readdir(bundleDir, { withFileTypes: true })
|
|
20
|
+
const names: string[] = []
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
if (!entry.isDirectory() || entry.name.startsWith(".")) continue
|
|
23
|
+
if (await Bun.file(path.join(bundleDir, entry.name, FUNCTION_FILE)).exists()) names.push(entry.name)
|
|
24
|
+
}
|
|
25
|
+
return names.sort()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function resolveFunctions(
|
|
29
|
+
bundleDir: string,
|
|
30
|
+
defaults: FunctionFileConfig = {},
|
|
31
|
+
): Promise<ResolvedFunction[]> {
|
|
32
|
+
const names = await discoverFunctions(bundleDir)
|
|
33
|
+
const resolved: ResolvedFunction[] = []
|
|
34
|
+
for (const name of names) {
|
|
35
|
+
const fileConfig = await readJson<FunctionFileConfig>(path.join(bundleDir, name, FUNCTION_CONFIG_FILE))
|
|
36
|
+
resolved.push({ name, config: { ...defaults, ...(fileConfig ?? {}) } })
|
|
37
|
+
}
|
|
38
|
+
return resolved
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function hashSources(bundleDir: string, functions: string[]): Promise<string> {
|
|
42
|
+
const hasher = new Bun.CryptoHasher("sha256")
|
|
43
|
+
const files = [
|
|
44
|
+
path.join(bundleDir, BUNDLE_FILE),
|
|
45
|
+
path.join(bundleDir, "package.json"),
|
|
46
|
+
path.join(bundleDir, "bun.lock"),
|
|
47
|
+
path.join(bundleDir, "Containerfile"),
|
|
48
|
+
...functions.flatMap((fn) => [
|
|
49
|
+
path.join(bundleDir, fn, FUNCTION_FILE),
|
|
50
|
+
path.join(bundleDir, fn, FUNCTION_CONFIG_FILE),
|
|
51
|
+
]),
|
|
52
|
+
]
|
|
53
|
+
for (const file of files) {
|
|
54
|
+
const handle = Bun.file(file)
|
|
55
|
+
if (await handle.exists()) {
|
|
56
|
+
hasher.update(file)
|
|
57
|
+
hasher.update(await handle.arrayBuffer())
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return hasher.digest("hex").slice(0, 16)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function entrySource(functions: ResolvedFunction[]): string {
|
|
64
|
+
const imports = functions.map((f, i) => `import fn${i} from ${JSON.stringify(`./${f.name}/${FUNCTION_FILE}`)}`)
|
|
65
|
+
const entries = functions.map((f, i) => {
|
|
66
|
+
const override = JSON.stringify(f.config)
|
|
67
|
+
return ` ${JSON.stringify(f.name)}: { ...fn${i}, config: { ...fn${i}.config, ...${override} } },`
|
|
68
|
+
})
|
|
69
|
+
return `import { serve } from ${JSON.stringify(SDK_PACKAGE)}
|
|
70
|
+
${imports.join("\n")}
|
|
71
|
+
|
|
72
|
+
serve({
|
|
73
|
+
${entries.join("\n")}
|
|
74
|
+
})
|
|
75
|
+
`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function writeEntry(bundleDir: string, functions: ResolvedFunction[]): Promise<string> {
|
|
79
|
+
const entryPath = path.join(bundleDir, ENTRY_FILE)
|
|
80
|
+
await Bun.write(entryPath, entrySource(functions))
|
|
81
|
+
return entryPath
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function compileBinary(bundleDir: string, functions: ResolvedFunction[]): Promise<string> {
|
|
85
|
+
const entryPath = await writeEntry(bundleDir, functions)
|
|
86
|
+
const outPath = path.join(bundleDir, ".atomfn-bundle")
|
|
87
|
+
try {
|
|
88
|
+
const result = await $`bun build --compile --minify --target=${LINUX_TARGET} ${entryPath} --outfile ${outPath}`
|
|
89
|
+
.cwd(bundleDir)
|
|
90
|
+
.quiet()
|
|
91
|
+
.nothrow()
|
|
92
|
+
if (result.exitCode !== 0) throw new Error(`编译失败:${result.stderr.toString().slice(-500)}`)
|
|
93
|
+
} finally {
|
|
94
|
+
await rm(entryPath, { force: true })
|
|
95
|
+
}
|
|
96
|
+
return outPath
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function podmanBuildAndSave(bundleDir: string, image: string): Promise<string> {
|
|
100
|
+
const containerfile = path.join(bundleDir, "Containerfile")
|
|
101
|
+
const build = await $`podman build -t ${image} -f ${containerfile} ${bundleDir}`.quiet().nothrow()
|
|
102
|
+
if (build.exitCode !== 0) throw new Error(`镜像构建失败:${build.stderr.toString().slice(-500)}`)
|
|
103
|
+
|
|
104
|
+
const tarPath = path.join(bundleDir, ".atomfn-image.tar")
|
|
105
|
+
const save = await $`podman save -o ${tarPath} ${image}`.quiet().nothrow()
|
|
106
|
+
if (save.exitCode !== 0) throw new Error(`镜像导出失败:${save.stderr.toString().slice(-500)}`)
|
|
107
|
+
return tarPath
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function buildImage(
|
|
111
|
+
bundleDir: string,
|
|
112
|
+
project: string,
|
|
113
|
+
bundle: string,
|
|
114
|
+
version: string,
|
|
115
|
+
binaryPath: string,
|
|
116
|
+
): Promise<string> {
|
|
117
|
+
const image = `localhost/atomfn/${project}-${bundle}:${version}`
|
|
118
|
+
const stagedBinary = path.join(bundleDir, "bundle")
|
|
119
|
+
await Bun.write(stagedBinary, Bun.file(binaryPath))
|
|
120
|
+
try {
|
|
121
|
+
return await podmanBuildAndSave(bundleDir, image)
|
|
122
|
+
} finally {
|
|
123
|
+
await rm(stagedBinary, { force: true })
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const HASH_SKIP = new Set(["target", "node_modules", ".git"])
|
|
128
|
+
|
|
129
|
+
async function hashTree(dir: string): Promise<string> {
|
|
130
|
+
const hasher = new Bun.CryptoHasher("sha256")
|
|
131
|
+
const walk = async (current: string): Promise<void> => {
|
|
132
|
+
const entries = (await readdir(current, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
if (HASH_SKIP.has(entry.name) || entry.name.startsWith(".atomfn")) continue
|
|
135
|
+
const full = path.join(current, entry.name)
|
|
136
|
+
if (entry.isDirectory()) {
|
|
137
|
+
await walk(full)
|
|
138
|
+
} else {
|
|
139
|
+
hasher.update(full)
|
|
140
|
+
hasher.update(await Bun.file(full).arrayBuffer())
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
await walk(dir)
|
|
145
|
+
return hasher.digest("hex").slice(0, 16)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function buildRustBundle(
|
|
149
|
+
root: string,
|
|
150
|
+
project: string,
|
|
151
|
+
bundle: string,
|
|
152
|
+
config: BundleConfig,
|
|
153
|
+
): Promise<BuildResult> {
|
|
154
|
+
const bundleDir = path.join(root, bundle)
|
|
155
|
+
const functions = config.functions ?? []
|
|
156
|
+
if (functions.length === 0) {
|
|
157
|
+
throw new Error(`rust bundle ${bundle} 需在 ${BUNDLE_FILE} 的 functions 中声明函数名`)
|
|
158
|
+
}
|
|
159
|
+
if (!(await Bun.file(path.join(bundleDir, "Containerfile")).exists())) {
|
|
160
|
+
throw new Error(`rust bundle ${bundle} 缺少 Containerfile`)
|
|
161
|
+
}
|
|
162
|
+
const version = await hashTree(bundleDir)
|
|
163
|
+
const image = `localhost/atomfn/${project}-${bundle}:${version}`
|
|
164
|
+
const tarPath = await podmanBuildAndSave(bundleDir, image)
|
|
165
|
+
return {
|
|
166
|
+
manifest: {
|
|
167
|
+
project,
|
|
168
|
+
bundle,
|
|
169
|
+
version,
|
|
170
|
+
artifact: "image",
|
|
171
|
+
functions: [...functions].sort(),
|
|
172
|
+
image,
|
|
173
|
+
memory: config.memory,
|
|
174
|
+
cpus: config.cpus,
|
|
175
|
+
maxConcurrency: config.maxConcurrency,
|
|
176
|
+
env: config.env,
|
|
177
|
+
},
|
|
178
|
+
artifactPath: tarPath,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function buildBundle(root: string, project: string, bundle: string): Promise<BuildResult> {
|
|
183
|
+
const bundleDir = path.join(root, bundle)
|
|
184
|
+
const config = await readJson<BundleConfig>(path.join(bundleDir, BUNDLE_FILE))
|
|
185
|
+
if (!config) throw new Error(`未找到 ${bundle}/${BUNDLE_FILE}`)
|
|
186
|
+
if (config.language === "rust") return buildRustBundle(root, project, bundle, config)
|
|
187
|
+
if (config.language !== "bun") throw new Error(`暂不支持的语言:${config.language}`)
|
|
188
|
+
|
|
189
|
+
const functions = await resolveFunctions(bundleDir, config.defaults)
|
|
190
|
+
if (functions.length === 0) throw new Error(`bundle ${bundle} 内没有函数`)
|
|
191
|
+
|
|
192
|
+
const names = functions.map((f) => f.name)
|
|
193
|
+
const asyncFunctions = await detectAsyncFunctions(bundleDir, names)
|
|
194
|
+
const version = await hashSources(bundleDir, names)
|
|
195
|
+
const hasContainerfile = await Bun.file(path.join(bundleDir, "Containerfile")).exists()
|
|
196
|
+
|
|
197
|
+
if (hasContainerfile) {
|
|
198
|
+
const binaryPath = await compileBinary(bundleDir, functions)
|
|
199
|
+
try {
|
|
200
|
+
const tarPath = await buildImage(bundleDir, project, bundle, version, binaryPath)
|
|
201
|
+
return {
|
|
202
|
+
manifest: {
|
|
203
|
+
project,
|
|
204
|
+
bundle,
|
|
205
|
+
version,
|
|
206
|
+
artifact: "image",
|
|
207
|
+
functions: names,
|
|
208
|
+
asyncFunctions: asyncFunctions.length ? asyncFunctions : undefined,
|
|
209
|
+
image: `localhost/atomfn/${project}-${bundle}:${version}`,
|
|
210
|
+
memory: config.memory,
|
|
211
|
+
cpus: config.cpus,
|
|
212
|
+
maxConcurrency: config.maxConcurrency,
|
|
213
|
+
env: config.env,
|
|
214
|
+
},
|
|
215
|
+
artifactPath: tarPath,
|
|
216
|
+
}
|
|
217
|
+
} finally {
|
|
218
|
+
await rm(binaryPath, { force: true })
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const binaryPath = await compileBinary(bundleDir, functions)
|
|
223
|
+
return {
|
|
224
|
+
manifest: {
|
|
225
|
+
project,
|
|
226
|
+
bundle,
|
|
227
|
+
version,
|
|
228
|
+
artifact: "binary",
|
|
229
|
+
functions: names,
|
|
230
|
+
asyncFunctions: asyncFunctions.length ? asyncFunctions : undefined,
|
|
231
|
+
memory: config.memory,
|
|
232
|
+
cpus: config.cpus,
|
|
233
|
+
maxConcurrency: config.maxConcurrency,
|
|
234
|
+
env: config.env,
|
|
235
|
+
},
|
|
236
|
+
artifactPath: binaryPath,
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function detectAsyncFunctions(bundleDir: string, names: string[]): Promise<string[]> {
|
|
241
|
+
const result: string[] = []
|
|
242
|
+
for (const name of names) {
|
|
243
|
+
try {
|
|
244
|
+
const module = await import(path.join(bundleDir, name, FUNCTION_FILE))
|
|
245
|
+
const kind = (module.default as { kind?: string } | undefined)?.kind
|
|
246
|
+
if (kind === "asyncFunction" || kind === "asyncStreamFunction") result.push(name)
|
|
247
|
+
} catch {
|
|
248
|
+
// 忽略无法导入的函数(缺依赖等),按同步处理
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return result
|
|
252
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { ADMIN_PREFIX } from "./consts.ts"
|
|
2
|
+
import type { BundleRecord, DeployManifest } from "./types.ts"
|
|
3
|
+
|
|
4
|
+
export class ClientError extends Error {
|
|
5
|
+
status: number
|
|
6
|
+
code: string
|
|
7
|
+
|
|
8
|
+
constructor(status: number, code: string, message: string) {
|
|
9
|
+
super(message)
|
|
10
|
+
this.status = status
|
|
11
|
+
this.code = code
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ErrorBody {
|
|
16
|
+
error?: { code: string; message: string }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class AdminClient {
|
|
20
|
+
private base: string
|
|
21
|
+
private token: string
|
|
22
|
+
|
|
23
|
+
constructor(server: string, token: string) {
|
|
24
|
+
this.base = `${server.replace(/\/+$/, "")}${ADMIN_PREFIX}`
|
|
25
|
+
this.token = token
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private async parse<T>(res: Response): Promise<T> {
|
|
29
|
+
const text = await res.text()
|
|
30
|
+
let body: ErrorBody & T
|
|
31
|
+
try {
|
|
32
|
+
body = text ? (JSON.parse(text) as ErrorBody & T) : ({} as ErrorBody & T)
|
|
33
|
+
} catch {
|
|
34
|
+
throw new ClientError(res.status, "invalid_response", `服务端返回非 JSON:${text.slice(0, 200)}`)
|
|
35
|
+
}
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
const err = body.error
|
|
38
|
+
throw new ClientError(res.status, err?.code ?? "error", err?.message ?? `请求失败(${res.status})`)
|
|
39
|
+
}
|
|
40
|
+
return body as T
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private async send<T>(method: string, path: string, init?: RequestInit): Promise<T> {
|
|
44
|
+
let res: Response
|
|
45
|
+
try {
|
|
46
|
+
res = await fetch(`${this.base}${path}`, {
|
|
47
|
+
method,
|
|
48
|
+
headers: { authorization: `Bearer ${this.token}`, ...(init?.headers ?? {}) },
|
|
49
|
+
body: init?.body,
|
|
50
|
+
})
|
|
51
|
+
} catch {
|
|
52
|
+
throw new ClientError(0, "network", `无法连接到 ${this.base},请检查地址与网络`)
|
|
53
|
+
}
|
|
54
|
+
return this.parse<T>(res)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async deploy(project: string, manifest: DeployManifest, artifact: Blob): Promise<BundleRecord> {
|
|
58
|
+
const form = new FormData()
|
|
59
|
+
form.set("manifest", JSON.stringify(manifest))
|
|
60
|
+
form.set("artifact", artifact, `${manifest.bundle}-${manifest.version}`)
|
|
61
|
+
const data = await this.send<{ bundle: BundleRecord }>("POST", `/projects/${project}/bundles`, { body: form })
|
|
62
|
+
return data.bundle
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async list(project: string): Promise<BundleRecord[]> {
|
|
66
|
+
const data = await this.send<{ bundles: BundleRecord[] }>("GET", `/projects/${project}/bundles`)
|
|
67
|
+
return data.bundles
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async remove(project: string, bundle: string): Promise<BundleRecord> {
|
|
71
|
+
const data = await this.send<{ bundle: BundleRecord }>("DELETE", `/projects/${project}/bundles/${bundle}`)
|
|
72
|
+
return data.bundle
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async logs(project: string, bundle: string, tail: number): Promise<string> {
|
|
76
|
+
const data = await this.send<{ logs: string }>("GET", `/projects/${project}/bundles/${bundle}/logs?tail=${tail}`)
|
|
77
|
+
return data.logs
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async versions(project: string, bundle: string): Promise<BundleRecord[]> {
|
|
81
|
+
const data = await this.send<{ versions: BundleRecord[] }>("GET", `/projects/${project}/bundles/${bundle}/versions`)
|
|
82
|
+
return data.versions
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async rollback(project: string, bundle: string, version: string): Promise<BundleRecord> {
|
|
86
|
+
const data = await this.send<{ bundle: BundleRecord }>(
|
|
87
|
+
"POST",
|
|
88
|
+
`/projects/${project}/bundles/${bundle}/rollback?version=${version}`,
|
|
89
|
+
)
|
|
90
|
+
return data.bundle
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async get<T>(p: string): Promise<T> {
|
|
94
|
+
return this.send<T>("GET", p)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async post<T>(p: string, body?: unknown): Promise<T> {
|
|
98
|
+
const init: RequestInit = {}
|
|
99
|
+
if (body !== undefined) {
|
|
100
|
+
init.body = JSON.stringify(body)
|
|
101
|
+
init.headers = { "content-type": "application/json" }
|
|
102
|
+
}
|
|
103
|
+
return this.send<T>("POST", p, init)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async del(p: string): Promise<void> {
|
|
107
|
+
await this.send<unknown>("DELETE", p)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import { confirm, intro, isCancel, log, outro, select, text } from "@clack/prompts"
|
|
3
|
+
import { defineCommand } from "citty"
|
|
4
|
+
import pc from "picocolors"
|
|
5
|
+
import { BUNDLE_FILE, BUNDLES_DIR } from "../consts.ts"
|
|
6
|
+
import { loadContext } from "../context.ts"
|
|
7
|
+
import { scaffoldBundle } from "../scaffold.ts"
|
|
8
|
+
import type { BundleConfig, Language } from "../types.ts"
|
|
9
|
+
import { isValidSlug, readJson, unwrap } from "../utils.ts"
|
|
10
|
+
|
|
11
|
+
const createCommand = defineCommand({
|
|
12
|
+
meta: { name: "create", description: "创建函数包(bundle)" },
|
|
13
|
+
args: {
|
|
14
|
+
slug: { type: "positional", required: false, description: "bundle 名" },
|
|
15
|
+
lang: { type: "string", description: "语言:bun(默认)" },
|
|
16
|
+
},
|
|
17
|
+
async run({ args }) {
|
|
18
|
+
intro(pc.cyan("创建函数包"))
|
|
19
|
+
const ctx = await loadContext()
|
|
20
|
+
if (!ctx) {
|
|
21
|
+
log.error("未找到 atomfunctions.json")
|
|
22
|
+
process.exit(1)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const slug =
|
|
26
|
+
args.slug ??
|
|
27
|
+
unwrap(
|
|
28
|
+
await text({
|
|
29
|
+
message: "bundle 名",
|
|
30
|
+
validate: (v) => {
|
|
31
|
+
const s = v?.trim()
|
|
32
|
+
if (!s) return "请输入 bundle 名"
|
|
33
|
+
return isValidSlug(s) ? undefined : "需以小写字母开头,仅含小写字母、数字、连字符"
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
).trim()
|
|
37
|
+
|
|
38
|
+
if (!isValidSlug(slug)) {
|
|
39
|
+
log.error("bundle 名需以小写字母开头,仅含小写字母、数字、连字符")
|
|
40
|
+
process.exit(1)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const bundleDir = path.join(ctx.root, BUNDLES_DIR, slug)
|
|
44
|
+
const existing = await readJson<BundleConfig>(path.join(bundleDir, BUNDLE_FILE))
|
|
45
|
+
if (existing) {
|
|
46
|
+
const ok = await confirm({ message: `bundle ${slug} 已存在,是否覆盖?`, initialValue: false })
|
|
47
|
+
if (isCancel(ok) || !ok) {
|
|
48
|
+
outro(pc.yellow("已取消"))
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const language: Language =
|
|
54
|
+
typeof args.lang === "string"
|
|
55
|
+
? (args.lang as Language)
|
|
56
|
+
: (unwrap(
|
|
57
|
+
await select({
|
|
58
|
+
message: "选择语言",
|
|
59
|
+
options: [
|
|
60
|
+
{ value: "bun", label: "Bun (TypeScript)" },
|
|
61
|
+
{ value: "rust", label: "Rust" },
|
|
62
|
+
],
|
|
63
|
+
initialValue: "bun",
|
|
64
|
+
}),
|
|
65
|
+
) as Language)
|
|
66
|
+
|
|
67
|
+
await scaffoldBundle(bundleDir, { language })
|
|
68
|
+
log.success(`已创建 bundle ${slug}`)
|
|
69
|
+
outro(pc.green(`完成,路径 ${path.relative(ctx.root, bundleDir)}`))
|
|
70
|
+
},
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
export const bundleCommand = defineCommand({
|
|
74
|
+
meta: { name: "bundle", description: "管理函数包" },
|
|
75
|
+
subCommands: { create: createCommand },
|
|
76
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { readdir, rm } from "node:fs/promises"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { intro, log, outro, spinner } from "@clack/prompts"
|
|
4
|
+
import { defineCommand } from "citty"
|
|
5
|
+
import pc from "picocolors"
|
|
6
|
+
import { buildBundle } from "../build.ts"
|
|
7
|
+
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"
|
|
11
|
+
|
|
12
|
+
async function listLocalBundles(root: string): Promise<string[]> {
|
|
13
|
+
const entries = await readdir(root, { withFileTypes: true })
|
|
14
|
+
const bundles: string[] = []
|
|
15
|
+
for (const entry of entries) {
|
|
16
|
+
if (!entry.isDirectory() || entry.name.startsWith(".")) continue
|
|
17
|
+
if (await Bun.file(path.join(root, entry.name, BUNDLE_FILE)).exists()) bundles.push(entry.name)
|
|
18
|
+
}
|
|
19
|
+
return bundles.sort()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function deployToTarget(
|
|
23
|
+
target: ServerTarget,
|
|
24
|
+
manifest: DeployManifest,
|
|
25
|
+
artifactPath: string,
|
|
26
|
+
force: boolean,
|
|
27
|
+
): Promise<{ host: string; ok: boolean; skipped: boolean; message?: string }> {
|
|
28
|
+
const client = new AdminClient(target.server, target.token)
|
|
29
|
+
try {
|
|
30
|
+
if (!force) {
|
|
31
|
+
const remote = await client.list(manifest.project)
|
|
32
|
+
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 }
|
|
34
|
+
}
|
|
35
|
+
await client.deploy(manifest.project, manifest, Bun.file(artifactPath))
|
|
36
|
+
return { host: target.host, ok: true, skipped: false }
|
|
37
|
+
} catch (error) {
|
|
38
|
+
return {
|
|
39
|
+
host: target.host,
|
|
40
|
+
ok: false,
|
|
41
|
+
skipped: false,
|
|
42
|
+
message: error instanceof Error ? error.message : String(error),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const deployCommand = defineCommand({
|
|
48
|
+
meta: { name: "deploy", description: "本地构建并部署函数包(扇出到所有机器)" },
|
|
49
|
+
args: {
|
|
50
|
+
bundle: { type: "positional", description: "bundle 名(不填则部署全部变更)", required: false },
|
|
51
|
+
force: { type: "boolean", description: "忽略版本一致检查,强制部署" },
|
|
52
|
+
},
|
|
53
|
+
async run({ args }) {
|
|
54
|
+
intro(pc.cyan("部署函数包"))
|
|
55
|
+
const ctx = await requireProjectContext()
|
|
56
|
+
|
|
57
|
+
const targets = args.bundle ? [args.bundle] : await listLocalBundles(ctx.root)
|
|
58
|
+
if (targets.length === 0) {
|
|
59
|
+
log.warn("没有可部署的 bundle")
|
|
60
|
+
outro(pc.yellow("已结束"))
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
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 })
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
outro(pc.green("部署流程结束"))
|
|
102
|
+
},
|
|
103
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { intro, log } from "@clack/prompts"
|
|
4
|
+
import { defineCommand } from "citty"
|
|
5
|
+
import pc from "picocolors"
|
|
6
|
+
import { resolveFunctions, writeEntry } from "../build.ts"
|
|
7
|
+
import { BUNDLE_FILE, DEFAULT_DEV_PORT } from "../consts.ts"
|
|
8
|
+
import { loadContext } from "../context.ts"
|
|
9
|
+
import type { BundleConfig } from "../types.ts"
|
|
10
|
+
import { readJson } from "../utils.ts"
|
|
11
|
+
|
|
12
|
+
export const devCommand = defineCommand({
|
|
13
|
+
meta: { name: "dev", description: "本地热重载运行函数包" },
|
|
14
|
+
args: {
|
|
15
|
+
bundle: { type: "positional", description: "bundle 名", required: true },
|
|
16
|
+
port: { type: "string", description: `本地端口(默认 ${DEFAULT_DEV_PORT})` },
|
|
17
|
+
},
|
|
18
|
+
async run({ args }) {
|
|
19
|
+
intro(pc.cyan(`本地运行 ${args.bundle}`))
|
|
20
|
+
const ctx = await loadContext()
|
|
21
|
+
if (!ctx) {
|
|
22
|
+
log.error("未找到 atomfunctions.json")
|
|
23
|
+
process.exit(1)
|
|
24
|
+
}
|
|
25
|
+
const bundleDir = path.join(ctx.root, args.bundle)
|
|
26
|
+
|
|
27
|
+
const config = await readJson<BundleConfig>(path.join(bundleDir, BUNDLE_FILE))
|
|
28
|
+
if (!config) {
|
|
29
|
+
log.error(`未找到 ${args.bundle}/${BUNDLE_FILE}`)
|
|
30
|
+
process.exit(1)
|
|
31
|
+
}
|
|
32
|
+
if (config.language !== "bun") {
|
|
33
|
+
log.error(`dev 暂只支持 bun,当前为 ${config.language}`)
|
|
34
|
+
process.exit(1)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const functions = await resolveFunctions(bundleDir, config.defaults)
|
|
38
|
+
if (functions.length === 0) {
|
|
39
|
+
log.error("该 bundle 内没有函数")
|
|
40
|
+
process.exit(1)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const port = Number(args.port ?? DEFAULT_DEV_PORT)
|
|
44
|
+
const entryPath = await writeEntry(bundleDir, functions)
|
|
45
|
+
log.info(`函数:${functions.map((f) => f.name).join("、")}`)
|
|
46
|
+
log.info(pc.dim(`http://localhost:${port}/__invoke/<function>`))
|
|
47
|
+
|
|
48
|
+
const proc = Bun.spawn(["bun", "--watch", entryPath], {
|
|
49
|
+
cwd: bundleDir,
|
|
50
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
51
|
+
env: {
|
|
52
|
+
...process.env,
|
|
53
|
+
...config.env,
|
|
54
|
+
ATOMFN_PROJECT: ctx.project,
|
|
55
|
+
ATOMFN_BUNDLE: args.bundle,
|
|
56
|
+
ATOMFN_PORT: String(port),
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const cleanup = async () => {
|
|
61
|
+
proc.kill()
|
|
62
|
+
await rm(entryPath, { force: true })
|
|
63
|
+
process.exit(0)
|
|
64
|
+
}
|
|
65
|
+
process.on("SIGINT", cleanup)
|
|
66
|
+
process.on("SIGTERM", cleanup)
|
|
67
|
+
|
|
68
|
+
await proc.exited
|
|
69
|
+
await rm(entryPath, { force: true })
|
|
70
|
+
},
|
|
71
|
+
})
|