@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/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
+ })
@@ -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
+ }