@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/src/consts.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import os from "node:os"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
|
|
4
|
+
export const MANIFEST_FILE = "atomfunctions.json"
|
|
5
|
+
export const MANIFEST_SCHEMA_FILE = "atomfunctions.schema.json"
|
|
6
|
+
export const BUNDLE_FILE = "atombundle.json"
|
|
7
|
+
export const BUNDLES_DIR = "bundles"
|
|
8
|
+
export const SCHEMAS_DIR = "schemas"
|
|
9
|
+
export const FUNCTION_FILE = "function.ts"
|
|
10
|
+
export const FUNCTION_CONFIG_FILE = "function.json"
|
|
11
|
+
export const ENTRY_FILE = ".atomfn-entry.ts"
|
|
12
|
+
|
|
13
|
+
export const CREDS_DIR = path.join(os.homedir(), ".atomfunctions")
|
|
14
|
+
export const CREDS_FILE = path.join(CREDS_DIR, "credentials.json")
|
|
15
|
+
export const KEYCHAIN_SERVICE = "com.atomservice.functions"
|
|
16
|
+
export const KEYCHAIN_SERVICE_PASSWORD = "com.atomservice.functions.password"
|
|
17
|
+
|
|
18
|
+
export const ADMIN_PREFIX = "/v1"
|
|
19
|
+
export const SLUG_RE = /^[a-z][a-z0-9-]*$/
|
|
20
|
+
export const VIEWS_DIR = "views"
|
|
21
|
+
export const LOCAL_DIR = "local"
|
|
22
|
+
export const REMOTE_DIR = "remote"
|
|
23
|
+
|
|
24
|
+
export const GITIGNORE_BODY = `${LOCAL_DIR}/\n${REMOTE_DIR}/\n${VIEWS_DIR}/\n`
|
|
25
|
+
|
|
26
|
+
export const SDK_PACKAGE = "@atomservice/functions-sdk"
|
|
27
|
+
export const LINUX_TARGET = "bun-linux-x64"
|
|
28
|
+
export const DEFAULT_DEV_PORT = 8080
|
|
29
|
+
|
|
30
|
+
import { ManifestSchema } from "./types.ts"
|
|
31
|
+
|
|
32
|
+
export const MANIFEST_SCHEMA = JSON.stringify(
|
|
33
|
+
{ $schema: "http://json-schema.org/draft-07/schema", ...ManifestSchema },
|
|
34
|
+
null,
|
|
35
|
+
2,
|
|
36
|
+
)
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import { log } from "@clack/prompts"
|
|
3
|
+
import { AdminClient } from "./client.ts"
|
|
4
|
+
import { MANIFEST_FILE, VIEWS_DIR } from "./consts.ts"
|
|
5
|
+
import { getCredential } from "./creds.ts"
|
|
6
|
+
import type { CliContext, Manifest, ServerTarget } from "./types.ts"
|
|
7
|
+
import { hostOf, readJson } from "./utils.ts"
|
|
8
|
+
|
|
9
|
+
async function findRoot(start: string): Promise<string | null> {
|
|
10
|
+
let dir = start
|
|
11
|
+
while (true) {
|
|
12
|
+
if (await Bun.file(path.join(dir, MANIFEST_FILE)).exists()) return dir
|
|
13
|
+
const parent = path.dirname(dir)
|
|
14
|
+
if (parent === dir) return null
|
|
15
|
+
dir = parent
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveServers(manifest: Manifest): string[] {
|
|
20
|
+
const list = [...(manifest.servers ?? []), ...(manifest.server ? [manifest.server] : [])]
|
|
21
|
+
return [...new Set(list)]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function pickTarget(ctx: CliContext, server?: string): ServerTarget {
|
|
25
|
+
if (!server) return ctx.targets[0] as ServerTarget
|
|
26
|
+
const match = ctx.targets.find((t) => t.host === server || t.server === server)
|
|
27
|
+
if (!match) {
|
|
28
|
+
log.error(`未找到机器 ${server},已登录:${ctx.targets.map((t) => t.host).join("、")}`)
|
|
29
|
+
process.exit(1)
|
|
30
|
+
}
|
|
31
|
+
return match
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function loadContext(): Promise<CliContext | null> {
|
|
35
|
+
const root = await findRoot(process.cwd())
|
|
36
|
+
if (!root) return null
|
|
37
|
+
const manifest = await readJson<Manifest>(path.join(root, MANIFEST_FILE))
|
|
38
|
+
if (!manifest) return null
|
|
39
|
+
return {
|
|
40
|
+
root,
|
|
41
|
+
manifest,
|
|
42
|
+
project: manifest.project?.slug ?? "",
|
|
43
|
+
viewsDir: path.join(root, VIEWS_DIR),
|
|
44
|
+
targets: [],
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function requireServerContext(): Promise<CliContext> {
|
|
49
|
+
const ctx = await loadContext()
|
|
50
|
+
if (!ctx) {
|
|
51
|
+
log.error(`未找到 ${MANIFEST_FILE},请先运行 atomfunctions init`)
|
|
52
|
+
process.exit(1)
|
|
53
|
+
}
|
|
54
|
+
if (!ctx.manifest.server) {
|
|
55
|
+
log.error(`${MANIFEST_FILE} 缺少 server 字段`)
|
|
56
|
+
process.exit(1)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const servers = resolveServers(ctx.manifest)
|
|
60
|
+
const targets: ServerTarget[] = []
|
|
61
|
+
for (const server of servers) {
|
|
62
|
+
const host = hostOf(server)
|
|
63
|
+
const cred = await getCredential(host)
|
|
64
|
+
if (cred) targets.push({ server, host, token: cred.token })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { ...ctx, targets }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function requireProjectContext(): Promise<CliContext> {
|
|
71
|
+
const ctx = await requireServerContext()
|
|
72
|
+
if (!ctx.manifest.project?.slug) {
|
|
73
|
+
log.error(`${MANIFEST_FILE} 中尚未设置项目,请运行 atomfunctions project link <slug>`)
|
|
74
|
+
process.exit(1)
|
|
75
|
+
}
|
|
76
|
+
if (ctx.targets.length === 0) {
|
|
77
|
+
log.error("未找到可用的控制面服务器登录凭证,请先运行 atomfunctions login")
|
|
78
|
+
process.exit(1)
|
|
79
|
+
}
|
|
80
|
+
return ctx
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function authedClient(ctx: CliContext): Promise<AdminClient> {
|
|
84
|
+
const target = ctx.targets[0]
|
|
85
|
+
if (!target) {
|
|
86
|
+
log.error("未找到可用的控制面服务器")
|
|
87
|
+
process.exit(1)
|
|
88
|
+
}
|
|
89
|
+
return new AdminClient(target.server, target.token)
|
|
90
|
+
}
|
package/src/creds.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { chmodSync } from "node:fs"
|
|
2
|
+
import { CREDS_DIR, CREDS_FILE, KEYCHAIN_SERVICE, KEYCHAIN_SERVICE_PASSWORD } from "./consts.ts"
|
|
3
|
+
import type { CredentialEntry } from "./types.ts"
|
|
4
|
+
import { ensureDir, readJson, writeJson } from "./utils.ts"
|
|
5
|
+
|
|
6
|
+
type CredentialStore = Record<string, CredentialEntry>
|
|
7
|
+
|
|
8
|
+
function useKeychain(): boolean {
|
|
9
|
+
return process.platform === "darwin"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function keychainGet(host: string): Promise<CredentialEntry | null> {
|
|
13
|
+
const raw = await Bun.secrets.get({ service: KEYCHAIN_SERVICE, name: host })
|
|
14
|
+
if (!raw) return null
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(raw) as CredentialEntry
|
|
17
|
+
} catch {
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function keychainSet(host: string, entry: CredentialEntry): Promise<void> {
|
|
23
|
+
await Bun.secrets.set({ service: KEYCHAIN_SERVICE, name: host, value: JSON.stringify(entry) })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function keychainClear(host: string): Promise<boolean> {
|
|
27
|
+
return Bun.secrets.delete({ service: KEYCHAIN_SERVICE, name: host })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function getSavedPassword(host: string): Promise<string | null> {
|
|
31
|
+
if (!useKeychain()) return null
|
|
32
|
+
return Bun.secrets.get({ service: KEYCHAIN_SERVICE_PASSWORD, name: host })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function savePassword(host: string, password: string): Promise<void> {
|
|
36
|
+
if (!useKeychain()) return
|
|
37
|
+
await Bun.secrets.set({ service: KEYCHAIN_SERVICE_PASSWORD, name: host, value: password })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function load(): Promise<CredentialStore> {
|
|
41
|
+
return (await readJson<CredentialStore>(CREDS_FILE)) ?? {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function persist(store: CredentialStore): Promise<void> {
|
|
45
|
+
ensureDir(CREDS_DIR)
|
|
46
|
+
chmodSync(CREDS_DIR, 0o700)
|
|
47
|
+
await writeJson(CREDS_FILE, store)
|
|
48
|
+
chmodSync(CREDS_FILE, 0o600)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function getCredential(host: string): Promise<CredentialEntry | null> {
|
|
52
|
+
if (useKeychain()) return keychainGet(host)
|
|
53
|
+
const store = await load()
|
|
54
|
+
return store[host] ?? null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function setCredential(host: string, entry: CredentialEntry): Promise<void> {
|
|
58
|
+
if (useKeychain()) return keychainSet(host, entry)
|
|
59
|
+
const store = await load()
|
|
60
|
+
store[host] = entry
|
|
61
|
+
await persist(store)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function clearCredential(host: string): Promise<boolean> {
|
|
65
|
+
if (useKeychain()) return keychainClear(host)
|
|
66
|
+
const store = await load()
|
|
67
|
+
if (!store[host]) return false
|
|
68
|
+
delete store[host]
|
|
69
|
+
await persist(store)
|
|
70
|
+
return true
|
|
71
|
+
}
|
package/src/editor.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function openInEditor(file: string): void {
|
|
2
|
+
if (process.platform !== "darwin") return
|
|
3
|
+
if (process.env.ATOMFN_NO_EDITOR === "1") return
|
|
4
|
+
const code = Bun.which("code")
|
|
5
|
+
if (!code) return
|
|
6
|
+
try {
|
|
7
|
+
Bun.spawnSync([code, file])
|
|
8
|
+
} catch {}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function openBrowser(file: string): void {
|
|
12
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"
|
|
13
|
+
try {
|
|
14
|
+
Bun.spawnSync([cmd, file])
|
|
15
|
+
} catch {}
|
|
16
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { defineCommand } from "citty"
|
|
2
|
+
import { bundleCommand } from "./commands/bundle.ts"
|
|
3
|
+
import { deployCommand } from "./commands/deploy.ts"
|
|
4
|
+
import { devCommand } from "./commands/dev.ts"
|
|
5
|
+
import { functionCommand } from "./commands/function.ts"
|
|
6
|
+
import { initCommand } from "./commands/init.ts"
|
|
7
|
+
import { invokeCommand } from "./commands/invoke.ts"
|
|
8
|
+
import { listCommand } from "./commands/list.ts"
|
|
9
|
+
import { loginCommand } from "./commands/login.ts"
|
|
10
|
+
import { logoutCommand } from "./commands/logout.ts"
|
|
11
|
+
import { logsCommand } from "./commands/logs.ts"
|
|
12
|
+
import { memberCommand } from "./commands/member.ts"
|
|
13
|
+
import { newCommand } from "./commands/new.ts"
|
|
14
|
+
import { projectCommand } from "./commands/project.ts"
|
|
15
|
+
import { pullCommand } from "./commands/pull.ts"
|
|
16
|
+
import { pushCommand } from "./commands/push.ts"
|
|
17
|
+
import { rollbackCommand } from "./commands/rollback.ts"
|
|
18
|
+
import { tokenCommand } from "./commands/token.ts"
|
|
19
|
+
import { userCommand } from "./commands/user.ts"
|
|
20
|
+
import { whoamiCommand } from "./commands/whoami.ts"
|
|
21
|
+
|
|
22
|
+
export const mainCommand = defineCommand({
|
|
23
|
+
meta: { name: "atomfunctions", description: "原子函数服务 CLI" },
|
|
24
|
+
subCommands: {
|
|
25
|
+
init: initCommand,
|
|
26
|
+
login: loginCommand,
|
|
27
|
+
logout: logoutCommand,
|
|
28
|
+
whoami: whoamiCommand,
|
|
29
|
+
user: userCommand,
|
|
30
|
+
project: projectCommand,
|
|
31
|
+
member: memberCommand,
|
|
32
|
+
token: tokenCommand,
|
|
33
|
+
bundle: bundleCommand,
|
|
34
|
+
function: functionCommand,
|
|
35
|
+
push: pushCommand,
|
|
36
|
+
pull: pullCommand,
|
|
37
|
+
new: newCommand,
|
|
38
|
+
dev: devCommand,
|
|
39
|
+
deploy: deployCommand,
|
|
40
|
+
list: listCommand,
|
|
41
|
+
logs: logsCommand,
|
|
42
|
+
invoke: invokeCommand,
|
|
43
|
+
rollback: rollbackCommand,
|
|
44
|
+
},
|
|
45
|
+
})
|
package/src/scaffold.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import { BUNDLE_FILE, FUNCTION_CONFIG_FILE, FUNCTION_FILE, SDK_PACKAGE } from "./consts.ts"
|
|
3
|
+
import type { BundleConfig, FunctionKind, Language } from "./types.ts"
|
|
4
|
+
import { ensureDir, writeJson } from "./utils.ts"
|
|
5
|
+
|
|
6
|
+
function packageJson(bundle: string): string {
|
|
7
|
+
return `${JSON.stringify(
|
|
8
|
+
{
|
|
9
|
+
name: bundle,
|
|
10
|
+
private: true,
|
|
11
|
+
type: "module",
|
|
12
|
+
dependencies: { [SDK_PACKAGE]: "latest", typebox: "latest" },
|
|
13
|
+
devDependencies: { "@types/bun": "latest", typescript: "latest" },
|
|
14
|
+
},
|
|
15
|
+
null,
|
|
16
|
+
2,
|
|
17
|
+
)}\n`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function tsconfigJson(): string {
|
|
21
|
+
return `${JSON.stringify(
|
|
22
|
+
{
|
|
23
|
+
compilerOptions: {
|
|
24
|
+
lib: ["ESNext"],
|
|
25
|
+
target: "ESNext",
|
|
26
|
+
module: "Preserve",
|
|
27
|
+
moduleResolution: "bundler",
|
|
28
|
+
allowImportingTsExtensions: true,
|
|
29
|
+
verbatimModuleSyntax: true,
|
|
30
|
+
noEmit: true,
|
|
31
|
+
strict: true,
|
|
32
|
+
skipLibCheck: true,
|
|
33
|
+
types: ["bun"],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
null,
|
|
37
|
+
2,
|
|
38
|
+
)}\n`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function functionSource(kind: FunctionKind): string {
|
|
42
|
+
const defName = {
|
|
43
|
+
function: "defineFunction",
|
|
44
|
+
streamFunction: "defineStreamFunction",
|
|
45
|
+
asyncFunction: "defineAsyncFunction",
|
|
46
|
+
asyncStreamFunction: "defineAsyncStreamFunction",
|
|
47
|
+
}[kind]
|
|
48
|
+
const isStream = kind === "streamFunction" || kind === "asyncStreamFunction"
|
|
49
|
+
const handlerBody = isStream
|
|
50
|
+
? ` async *handler (event, ctx) {
|
|
51
|
+
ctx.log.info("greeting", { name: event.name })
|
|
52
|
+
yield { greeting: \`hello\` }
|
|
53
|
+
yield { greeting: \`, \${event.name}\` }
|
|
54
|
+
},`
|
|
55
|
+
: ` async handler (event, ctx) {
|
|
56
|
+
ctx.log.info("greeting", { name: event.name })
|
|
57
|
+
return { greeting: \`hello, \${event.name}\` }
|
|
58
|
+
},`
|
|
59
|
+
return `import { ${defName} } from "${SDK_PACKAGE}"
|
|
60
|
+
import { Type } from "typebox"
|
|
61
|
+
|
|
62
|
+
export default ${defName}({
|
|
63
|
+
input: Type.Object({ name: Type.String() }),
|
|
64
|
+
output: Type.Object({ greeting: Type.String() }),
|
|
65
|
+
errors: {},
|
|
66
|
+
${handlerBody}
|
|
67
|
+
})
|
|
68
|
+
`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function scaffoldBundle(bundleDir: string, config: BundleConfig): Promise<void> {
|
|
72
|
+
ensureDir(bundleDir)
|
|
73
|
+
if (config.language === "rust") {
|
|
74
|
+
await scaffoldRustBundle(bundleDir, config)
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
await writeJson(path.join(bundleDir, BUNDLE_FILE), config)
|
|
78
|
+
await Bun.write(path.join(bundleDir, "package.json"), packageJson(path.basename(bundleDir)))
|
|
79
|
+
await Bun.write(path.join(bundleDir, "tsconfig.json"), tsconfigJson())
|
|
80
|
+
await Bun.write(path.join(bundleDir, ".gitignore"), "node_modules/\n.atomfn-*\nbundle\n")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function scaffoldFunction(
|
|
84
|
+
bundleDir: string,
|
|
85
|
+
fn: string,
|
|
86
|
+
language: Language = "bun",
|
|
87
|
+
kind: FunctionKind = "function",
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
if (language === "rust") {
|
|
90
|
+
const fnPath = path.join(bundleDir, "src", "functions", `${fn}.rs`)
|
|
91
|
+
ensureDir(path.dirname(fnPath))
|
|
92
|
+
await Bun.write(fnPath, rustFunctionSource(fn))
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
const fnDir = path.join(bundleDir, fn)
|
|
96
|
+
ensureDir(fnDir)
|
|
97
|
+
await Bun.write(path.join(fnDir, FUNCTION_FILE), functionSource(kind))
|
|
98
|
+
await writeJson(path.join(fnDir, FUNCTION_CONFIG_FILE), {})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function rustCargoToml(bundle: string): string {
|
|
102
|
+
return `[package]
|
|
103
|
+
name = ${JSON.stringify(bundle)}
|
|
104
|
+
version = "0.1.0"
|
|
105
|
+
edition = "2021"
|
|
106
|
+
|
|
107
|
+
[[bin]]
|
|
108
|
+
name = ${JSON.stringify(bundle)}
|
|
109
|
+
path = "src/main.rs"
|
|
110
|
+
|
|
111
|
+
[dependencies]
|
|
112
|
+
atomfunctions = "0.0.1"
|
|
113
|
+
serde = { version = "1", features = ["derive"] }
|
|
114
|
+
serde_json = "1"
|
|
115
|
+
`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function rustMainSource(): string {
|
|
119
|
+
return `use atomfunctions::{serve, Ctx, FunctionError, Registry};
|
|
120
|
+
use serde::{Deserialize, Serialize};
|
|
121
|
+
|
|
122
|
+
#[derive(Deserialize)]
|
|
123
|
+
struct GreetInput {
|
|
124
|
+
name: String,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
#[derive(Serialize)]
|
|
128
|
+
struct GreetOutput {
|
|
129
|
+
greeting: String,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
fn greet(input: GreetInput, ctx: &Ctx) -> Result<GreetOutput, FunctionError> {
|
|
133
|
+
ctx.info("greeting");
|
|
134
|
+
Ok(GreetOutput { greeting: format!("hello, {}", input.name) })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fn main() {
|
|
138
|
+
let mut registry = Registry::new();
|
|
139
|
+
registry.function("greet", greet);
|
|
140
|
+
serve(registry);
|
|
141
|
+
}
|
|
142
|
+
`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function rustFunctionSource(fn: string): string {
|
|
146
|
+
return `// 函数 ${fn}:在 src/main.rs 中用 registry.function("${fn}", ${fn}) 注册,
|
|
147
|
+
// 并在 ${BUNDLE_FILE} 的 functions 数组中加入 "${fn}"。
|
|
148
|
+
use atomfunctions::{Ctx, FunctionError};
|
|
149
|
+
use serde::{Deserialize, Serialize};
|
|
150
|
+
|
|
151
|
+
#[derive(Deserialize)]
|
|
152
|
+
pub struct Input {
|
|
153
|
+
pub name: String,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#[derive(Serialize)]
|
|
157
|
+
pub struct Output {
|
|
158
|
+
pub greeting: String,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
pub fn ${fn}(input: Input, _ctx: &Ctx) -> Result<Output, FunctionError> {
|
|
162
|
+
Ok(Output { greeting: format!("hello, {}", input.name) })
|
|
163
|
+
}
|
|
164
|
+
`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function rustContainerfile(bundle: string): string {
|
|
168
|
+
return `FROM rust:1-slim AS builder
|
|
169
|
+
WORKDIR /build
|
|
170
|
+
COPY . .
|
|
171
|
+
RUN cargo build --release
|
|
172
|
+
|
|
173
|
+
FROM debian:bookworm-slim
|
|
174
|
+
RUN apt-get update \\
|
|
175
|
+
&& apt-get install -y --no-install-recommends ca-certificates \\
|
|
176
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
177
|
+
COPY --from=builder /build/target/release/${bundle} /app/bundle
|
|
178
|
+
ENTRYPOINT ["/app/bundle"]
|
|
179
|
+
`
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function scaffoldRustBundle(bundleDir: string, config: BundleConfig): Promise<void> {
|
|
183
|
+
const bundle = path.basename(bundleDir)
|
|
184
|
+
await writeJson(path.join(bundleDir, BUNDLE_FILE), { ...config, functions: config.functions ?? ["greet"] })
|
|
185
|
+
await Bun.write(path.join(bundleDir, "Cargo.toml"), rustCargoToml(bundle))
|
|
186
|
+
await Bun.write(path.join(bundleDir, "Containerfile"), rustContainerfile(bundle))
|
|
187
|
+
await Bun.write(path.join(bundleDir, "src", "main.rs"), rustMainSource())
|
|
188
|
+
await Bun.write(path.join(bundleDir, ".gitignore"), "target/\n.atomfn-*\n")
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function containerfileSource(): string {
|
|
192
|
+
return `# 逃生舱:声明系统依赖(如 ffmpeg、libreoffice)后由平台本地构建整镜像。
|
|
193
|
+
# 约定:平台会把编译好的二进制放在构建上下文的 ./bundle,需 COPY 到 /app/bundle 并作为 ENTRYPOINT。
|
|
194
|
+
FROM debian:bookworm-slim
|
|
195
|
+
|
|
196
|
+
RUN apt-get update \\
|
|
197
|
+
&& apt-get install -y --no-install-recommends ca-certificates \\
|
|
198
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
199
|
+
|
|
200
|
+
WORKDIR /app
|
|
201
|
+
COPY bundle /app/bundle
|
|
202
|
+
RUN chmod +x /app/bundle
|
|
203
|
+
|
|
204
|
+
ENTRYPOINT ["/app/bundle"]
|
|
205
|
+
`
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function scaffoldContainerfile(bundleDir: string): Promise<void> {
|
|
209
|
+
const target = path.join(bundleDir, "Containerfile")
|
|
210
|
+
if (await Bun.file(target).exists()) return
|
|
211
|
+
await Bun.write(target, containerfileSource())
|
|
212
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type Static, Type } from "typebox"
|
|
2
|
+
|
|
3
|
+
export type Language = "bun" | "rust"
|
|
4
|
+
|
|
5
|
+
export type FunctionKind = "function" | "streamFunction" | "asyncFunction" | "asyncStreamFunction"
|
|
6
|
+
|
|
7
|
+
export type ExecutionMode = "inline" | "pooled" | "isolated"
|
|
8
|
+
|
|
9
|
+
export interface FunctionFileConfig {
|
|
10
|
+
execution?: ExecutionMode
|
|
11
|
+
timeout?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const ManifestSchema = Type.Object(
|
|
15
|
+
{
|
|
16
|
+
$schema: Type.Optional(Type.String({ description: "JSON Schema 引用,由工具自动生成,勿手动修改" })),
|
|
17
|
+
server: Type.Optional(Type.String({ description: "控制面服务器地址,如 http://localhost:4922" })),
|
|
18
|
+
servers: Type.Optional(Type.Array(Type.String(), { description: "已登录的全部控制面机器地址,deploy 时扇出" })),
|
|
19
|
+
project: Type.Optional(
|
|
20
|
+
Type.Object(
|
|
21
|
+
{ slug: Type.String({ pattern: "^[a-z][a-z0-9-]*$", description: "项目标识" }) },
|
|
22
|
+
{ additionalProperties: false, description: "项目信息(可选,后续可通过 project link 绑定)" },
|
|
23
|
+
),
|
|
24
|
+
),
|
|
25
|
+
},
|
|
26
|
+
{ additionalProperties: false },
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
export type Manifest = Static<typeof ManifestSchema>
|
|
30
|
+
|
|
31
|
+
export interface CredentialEntry {
|
|
32
|
+
token: string
|
|
33
|
+
account: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ServerTarget {
|
|
37
|
+
server: string
|
|
38
|
+
host: string
|
|
39
|
+
token: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface BundleConfig {
|
|
43
|
+
language: Language
|
|
44
|
+
memory?: string
|
|
45
|
+
cpus?: number
|
|
46
|
+
maxConcurrency?: number
|
|
47
|
+
defaults?: FunctionFileConfig
|
|
48
|
+
functions?: string[]
|
|
49
|
+
env?: Record<string, string>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface DeployManifest {
|
|
53
|
+
project: string
|
|
54
|
+
bundle: string
|
|
55
|
+
version: string
|
|
56
|
+
artifact: "binary" | "image"
|
|
57
|
+
functions: string[]
|
|
58
|
+
asyncFunctions?: string[]
|
|
59
|
+
image?: string
|
|
60
|
+
memory?: string
|
|
61
|
+
cpus?: number
|
|
62
|
+
maxConcurrency?: number
|
|
63
|
+
env?: Record<string, string>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface BundleRecord {
|
|
67
|
+
project: string
|
|
68
|
+
bundle: string
|
|
69
|
+
containerName: string
|
|
70
|
+
host: string
|
|
71
|
+
port: number
|
|
72
|
+
functions: string[]
|
|
73
|
+
version: string
|
|
74
|
+
artifact: "binary" | "image"
|
|
75
|
+
image: string
|
|
76
|
+
memory?: string
|
|
77
|
+
maxConcurrency?: number
|
|
78
|
+
updatedAt: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface CliContext {
|
|
82
|
+
root: string
|
|
83
|
+
manifest: Manifest
|
|
84
|
+
project: string
|
|
85
|
+
viewsDir: string
|
|
86
|
+
targets: ServerTarget[]
|
|
87
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { cancel, isCancel } from "@clack/prompts"
|
|
4
|
+
import { SLUG_RE } from "./consts.ts"
|
|
5
|
+
|
|
6
|
+
export function ensureDir(dir: string): void {
|
|
7
|
+
mkdirSync(dir, { recursive: true })
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function readJson<T>(file: string): Promise<T | null> {
|
|
11
|
+
const handle = Bun.file(file)
|
|
12
|
+
if (!(await handle.exists())) return null
|
|
13
|
+
return (await handle.json()) as T
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function writeJson(file: string, data: unknown): Promise<void> {
|
|
17
|
+
ensureDir(path.dirname(file))
|
|
18
|
+
await Bun.write(file, `${JSON.stringify(data, null, 2)}\n`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function hostOf(server: string): string {
|
|
22
|
+
return new URL(server).host
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isValidSlug(slug: string): boolean {
|
|
26
|
+
return SLUG_RE.test(slug)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function unwrap<T>(value: T | symbol): T {
|
|
30
|
+
if (isCancel(value)) {
|
|
31
|
+
cancel("已取消")
|
|
32
|
+
process.exit(0)
|
|
33
|
+
}
|
|
34
|
+
return value as T
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseRef(ref: string): { bundle: string; fn?: string } {
|
|
38
|
+
const parts = ref.split("/").filter(Boolean)
|
|
39
|
+
if (parts.length === 1) return { bundle: parts[0] as string }
|
|
40
|
+
if (parts.length === 2) return { bundle: parts[0] as string, fn: parts[1] as string }
|
|
41
|
+
throw new Error("引用格式应为 <bundle> 或 <bundle>/<function>")
|
|
42
|
+
}
|
package/src/views.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import { openInEditor } from "./editor.ts"
|
|
3
|
+
import type { CliContext } from "./types.ts"
|
|
4
|
+
import { writeJson } from "./utils.ts"
|
|
5
|
+
|
|
6
|
+
const TIME_FIELDS = new Set(["createdAt", "updatedAt", "joinedAt", "lastUsedAt", "expiresAt"])
|
|
7
|
+
|
|
8
|
+
function formatTimestamps(val: unknown): unknown {
|
|
9
|
+
if (Array.isArray(val)) return val.map(formatTimestamps)
|
|
10
|
+
if (val !== null && typeof val === "object") {
|
|
11
|
+
const out: Record<string, unknown> = {}
|
|
12
|
+
for (const [k, v] of Object.entries(val as Record<string, unknown>)) {
|
|
13
|
+
if (TIME_FIELDS.has(k) && typeof v === "number") {
|
|
14
|
+
out[k] = new Date(v * 1000).toLocaleString("zh-CN", { hour12: false })
|
|
15
|
+
} else if (TIME_FIELDS.has(k) && typeof v === "string") {
|
|
16
|
+
out[k] = v
|
|
17
|
+
} else {
|
|
18
|
+
out[k] = formatTimestamps(v)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return out
|
|
22
|
+
}
|
|
23
|
+
return val
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function writeView(ctx: CliContext, relPath: string, data: unknown): Promise<string> {
|
|
27
|
+
const file = path.join(ctx.viewsDir, relPath)
|
|
28
|
+
await writeJson(file, formatTimestamps(data))
|
|
29
|
+
openInEditor(file)
|
|
30
|
+
return file
|
|
31
|
+
}
|