@atomservice/functions-cli 0.1.6 → 0.1.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atomservice/functions-cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "函数服务命令行工具(atomfunctions):项目脚手架、本地构建、部署、运维",
5
5
  "type": "module",
6
6
  "author": "openorson",
@@ -1,10 +1,11 @@
1
1
  import path from "node:path"
2
+ import { $ } from "bun"
2
3
  import { confirm, intro, isCancel, log, outro, select, text } from "@clack/prompts"
3
4
  import { defineCommand } from "citty"
4
5
  import pc from "picocolors"
5
6
  import { BUNDLE_FILE, BUNDLES_DIR } from "../consts.ts"
6
7
  import { loadContext } from "../context.ts"
7
- import { scaffoldBundle } from "../scaffold.ts"
8
+ import { scaffoldBundle, scaffoldContainerfile } from "../scaffold.ts"
8
9
  import type { BundleConfig, Language } from "../types.ts"
9
10
  import { isValidSlug, readJson, unwrap } from "../utils.ts"
10
11
 
@@ -13,6 +14,7 @@ const createCommand = defineCommand({
13
14
  args: {
14
15
  slug: { type: "positional", required: false, description: "bundle 名" },
15
16
  lang: { type: "string", description: "语言:bun(默认)" },
17
+ "with-containerfile": { type: "boolean", description: "生成逃生舱 Containerfile 模板" },
16
18
  },
17
19
  async run({ args }) {
18
20
  intro(pc.cyan("创建函数包"))
@@ -66,6 +68,28 @@ const createCommand = defineCommand({
66
68
 
67
69
  await scaffoldBundle(bundleDir, { language })
68
70
  log.success(`已创建 bundle ${slug}`)
71
+
72
+ const withContainerfile =
73
+ args["with-containerfile"] === true ||
74
+ (await confirm({ message: "是否生成 Containerfile 模板?", initialValue: false }))
75
+ if (!isCancel(withContainerfile) && withContainerfile) {
76
+ await scaffoldContainerfile(bundleDir)
77
+ log.success("已生成 Containerfile")
78
+ }
79
+
80
+ if (language === "bun") {
81
+ const install = await confirm({ message: "是否安装依赖包?", initialValue: true })
82
+ if (!isCancel(install) && install) {
83
+ log.info("正在安装依赖...")
84
+ const result = await $`bun install`.cwd(bundleDir).quiet().nothrow()
85
+ if (result.exitCode === 0) {
86
+ log.success("依赖安装完成")
87
+ } else {
88
+ log.warn("依赖安装失败,请稍后手动执行 bun install")
89
+ }
90
+ }
91
+ }
92
+
69
93
  outro(pc.green(`完成,路径 ${path.relative(ctx.root, bundleDir)}`))
70
94
  },
71
95
  })
@@ -1,32 +1,80 @@
1
- import { rm } from "node:fs/promises"
1
+ import { existsSync, readdirSync } from "node:fs"
2
+ import type { Dirent } from "node:fs"
3
+ import { rm, writeFile } from "node:fs/promises"
4
+ import { connect } from "node:net"
2
5
  import path from "node:path"
3
- import { intro, log } from "@clack/prompts"
6
+ import { intro, log, outro, select, text } from "@clack/prompts"
4
7
  import { defineCommand } from "citty"
5
8
  import pc from "picocolors"
6
9
  import { resolveFunctions, writeEntry } from "../build.ts"
7
- import { BUNDLE_FILE, DEFAULT_DEV_PORT } from "../consts.ts"
10
+ import { BUNDLES_DIR, BUNDLE_FILE, DEFAULT_DEV_PORT, DEV_PID_FILE } from "../consts.ts"
8
11
  import { loadContext } from "../context.ts"
9
12
  import type { BundleConfig } from "../types.ts"
10
- import { readJson } from "../utils.ts"
13
+ import { isValidSlug, readJson, unwrap, writeJson } from "../utils.ts"
11
14
 
12
- export const devCommand = defineCommand({
13
- meta: { name: "dev", description: "本地热重载运行函数包" },
15
+ function pidFile(bundleDir: string): string {
16
+ return path.join(bundleDir, DEV_PID_FILE)
17
+ }
18
+
19
+ function isPortInUse(port: number): Promise<boolean> {
20
+ return new Promise((resolve) => {
21
+ const sock = connect({ port, host: "127.0.0.1" }, () => {
22
+ sock.destroy()
23
+ resolve(true)
24
+ })
25
+ sock.on("error", () => resolve(false))
26
+ })
27
+ }
28
+
29
+ async function listBundles(root: string): Promise<string[]> {
30
+ const dir = path.join(root, BUNDLES_DIR)
31
+ let entries: Dirent[]
32
+ try {
33
+ entries = readdirSync(dir, { withFileTypes: true }) as unknown as Dirent[]
34
+ } catch {
35
+ return []
36
+ }
37
+ return entries
38
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
39
+ .filter((e) => existsSync(path.join(dir, e.name, BUNDLE_FILE)))
40
+ .map((e) => e.name)
41
+ .sort()
42
+ }
43
+
44
+ const startCommand = defineCommand({
45
+ meta: { name: "start", description: "启动本地开发服务器" },
14
46
  args: {
15
- bundle: { type: "positional", description: "bundle 名", required: true },
16
- port: { type: "string", description: `本地端口(默认 ${DEFAULT_DEV_PORT})` },
47
+ bundle: { type: "positional", required: false, description: "bundle 名" },
48
+ port: { type: "string", description: `本地端口(默认从 atombundle.json 读取,否则 ${DEFAULT_DEV_PORT})` },
17
49
  },
18
50
  async run({ args }) {
19
- intro(pc.cyan(`本地运行 ${args.bundle}`))
20
51
  const ctx = await loadContext()
21
52
  if (!ctx) {
22
53
  log.error("未找到 atomfunctions.json")
23
54
  process.exit(1)
24
55
  }
25
- const bundleDir = path.join(ctx.root, args.bundle)
26
56
 
27
- const config = await readJson<BundleConfig>(path.join(bundleDir, BUNDLE_FILE))
57
+ let bundle: string
58
+ if (typeof args.bundle === "string") {
59
+ if (!isValidSlug(args.bundle)) {
60
+ log.error("bundle 名需以小写字母开头,仅含小写字母、数字、连字符")
61
+ process.exit(1)
62
+ }
63
+ bundle = args.bundle
64
+ } else {
65
+ const bundles = await listBundles(ctx.root)
66
+ if (bundles.length === 0) {
67
+ log.error("尚未创建任何 bundle,请先运行 atomfn bundle create")
68
+ process.exit(1)
69
+ }
70
+ bundle = unwrap(await select({ message: "选择函数包", options: bundles.map((b) => ({ value: b, label: b })) })) as string
71
+ }
72
+
73
+ const bundleDir = path.join(ctx.root, BUNDLES_DIR, bundle)
74
+ const configPath = path.join(bundleDir, BUNDLE_FILE)
75
+ const config = await readJson<BundleConfig>(configPath)
28
76
  if (!config) {
29
- log.error(`未找到 ${args.bundle}/${BUNDLE_FILE}`)
77
+ log.error(`未找到 ${BUNDLES_DIR}/${bundle}/${BUNDLE_FILE}`)
30
78
  process.exit(1)
31
79
  }
32
80
  if (config.language !== "bun") {
@@ -34,16 +82,50 @@ export const devCommand = defineCommand({
34
82
  process.exit(1)
35
83
  }
36
84
 
85
+ const defaultPort = args.port ? Number(args.port) : (config.dev?.port ?? DEFAULT_DEV_PORT)
86
+ if (Number.isNaN(defaultPort) || defaultPort < 1 || defaultPort > 65535) {
87
+ log.error("端口号不合法,范围 1-65535")
88
+ process.exit(1)
89
+ }
90
+
91
+ const portStr = unwrap(
92
+ await text({
93
+ message: "监听端口",
94
+ initialValue: String(defaultPort),
95
+ validate: (v) => {
96
+ const n = Number(v)
97
+ if (Number.isNaN(n) || n < 1 || n > 65535) return "端口号不合法,范围 1-65535"
98
+ },
99
+ }),
100
+ ).trim()
101
+ const port = Number(portStr)
102
+
103
+ const pf = pidFile(bundleDir)
104
+ if (existsSync(pf)) {
105
+ const oldPid = Number(await Bun.file(pf).text().catch(() => ""))
106
+ if (oldPid && isProcessAlive(oldPid)) {
107
+ log.error(`bundle ${bundle} 已在运行中(pid ${oldPid})`)
108
+ process.exit(1)
109
+ }
110
+ await rm(pf, { force: true })
111
+ }
112
+
113
+ if (await isPortInUse(port)) {
114
+ log.error(`端口 ${port} 已被占用,请更换`)
115
+ process.exit(1)
116
+ }
117
+
37
118
  const functions = await resolveFunctions(bundleDir, config.defaults)
38
119
  if (functions.length === 0) {
39
120
  log.error("该 bundle 内没有函数")
40
121
  process.exit(1)
41
122
  }
42
123
 
43
- const port = Number(args.port ?? DEFAULT_DEV_PORT)
124
+ intro(pc.cyan(`本地开发 ${bundle}`))
125
+
44
126
  const entryPath = await writeEntry(bundleDir, functions)
45
127
  log.info(`函数:${functions.map((f) => f.name).join("、")}`)
46
- log.info(pc.dim(`http://localhost:${port}/__invoke/<function>`))
128
+ log.info(pc.dim(`http://localhost:${port}/invoke/<function>`))
47
129
 
48
130
  const proc = Bun.spawn(["bun", "--watch", entryPath], {
49
131
  cwd: bundleDir,
@@ -52,20 +134,80 @@ export const devCommand = defineCommand({
52
134
  ...process.env,
53
135
  ...config.env,
54
136
  ATOMFN_PROJECT: ctx.project,
55
- ATOMFN_BUNDLE: args.bundle,
137
+ ATOMFN_BUNDLE: bundle,
56
138
  ATOMFN_PORT: String(port),
57
139
  },
58
140
  })
59
141
 
60
- const cleanup = async () => {
142
+ await writeFile(pf, String(proc.pid))
143
+
144
+ // write back port if changed
145
+ const prevPort = config.dev?.port
146
+ if (port !== prevPort) {
147
+ config.dev = { ...config.dev, port }
148
+ await writeJson(configPath, config)
149
+ }
150
+
151
+ const onExit = async () => {
61
152
  proc.kill()
62
153
  await rm(entryPath, { force: true })
154
+ await rm(pf, { force: true })
63
155
  process.exit(0)
64
156
  }
65
- process.on("SIGINT", cleanup)
66
- process.on("SIGTERM", cleanup)
157
+ process.on("SIGINT", onExit)
158
+ process.on("SIGTERM", onExit)
67
159
 
68
160
  await proc.exited
69
161
  await rm(entryPath, { force: true })
162
+ await rm(pf, { force: true })
163
+ },
164
+ })
165
+
166
+ const stopCommand = defineCommand({
167
+ meta: { name: "stop", description: "停止本地开发服务器" },
168
+ async run() {
169
+ const ctx = await loadContext()
170
+ if (!ctx) {
171
+ log.error("未找到 atomfunctions.json")
172
+ process.exit(1)
173
+ }
174
+
175
+ const bundles = await listBundles(ctx.root)
176
+ let stopped = 0
177
+ for (const b of bundles) {
178
+ const pf = pidFile(path.join(ctx.root, BUNDLES_DIR, b))
179
+ if (!existsSync(pf)) continue
180
+ const pid = Number(await Bun.file(pf).text().catch(() => ""))
181
+ if (!pid) continue
182
+ if (isProcessAlive(pid)) {
183
+ try {
184
+ process.kill(pid, "SIGTERM")
185
+ stopped++
186
+ } catch {
187
+ // already gone
188
+ }
189
+ }
190
+ await rm(pf, { force: true })
191
+ }
192
+
193
+ if (stopped === 0) {
194
+ outro("没有正在运行的 dev 服务")
195
+ } else {
196
+ outro(pc.green(`已停止 ${stopped} 个 dev 服务`))
197
+ }
70
198
  },
71
199
  })
200
+
201
+ function isProcessAlive(pid: number): boolean {
202
+ try {
203
+ process.kill(pid, 0)
204
+ return true
205
+ } catch {
206
+ return false
207
+ }
208
+ }
209
+
210
+ export const devCommand = defineCommand({
211
+ meta: { name: "dev", description: "本地开发服务器" },
212
+ subCommands: { start: startCommand, stop: stopCommand },
213
+ })
package/src/consts.ts CHANGED
@@ -5,6 +5,7 @@ export const MANIFEST_FILE = "atomfunctions.json"
5
5
  export const MANIFEST_SCHEMA_FILE = "atomfunctions.schema.json"
6
6
  export const BUNDLE_FILE = "atombundle.json"
7
7
  export const BUNDLES_DIR = "bundles"
8
+ export const DEV_PID_FILE = ".atomfn-dev.pid"
8
9
  export const SCHEMAS_DIR = "schemas"
9
10
  export const FUNCTION_FILE = "function.ts"
10
11
  export const FUNCTION_CONFIG_FILE = "function.json"
package/src/index.ts CHANGED
@@ -10,7 +10,6 @@ import { loginCommand } from "./commands/login.ts"
10
10
  import { logoutCommand } from "./commands/logout.ts"
11
11
  import { logsCommand } from "./commands/logs.ts"
12
12
  import { memberCommand } from "./commands/member.ts"
13
- import { newCommand } from "./commands/new.ts"
14
13
  import { projectCommand } from "./commands/project.ts"
15
14
  import { pullCommand } from "./commands/pull.ts"
16
15
  import { pushCommand } from "./commands/push.ts"
@@ -34,7 +33,6 @@ export const mainCommand = defineCommand({
34
33
  function: functionCommand,
35
34
  push: pushCommand,
36
35
  pull: pullCommand,
37
- new: newCommand,
38
36
  dev: devCommand,
39
37
  deploy: deployCommand,
40
38
  list: listCommand,
package/src/types.ts CHANGED
@@ -47,6 +47,7 @@ export interface BundleConfig {
47
47
  defaults?: FunctionFileConfig
48
48
  functions?: string[]
49
49
  env?: Record<string, string>
50
+ dev?: { port?: number }
50
51
  }
51
52
 
52
53
  export interface DeployManifest {
@@ -1,68 +0,0 @@
1
- import path from "node:path"
2
- import { intro, log, outro, select } from "@clack/prompts"
3
- import { defineCommand } from "citty"
4
- import pc from "picocolors"
5
- import { BUNDLE_FILE } from "../consts.ts"
6
- import { loadContext } from "../context.ts"
7
- import { scaffoldBundle, scaffoldContainerfile, scaffoldFunction } from "../scaffold.ts"
8
- import type { BundleConfig, Language } from "../types.ts"
9
- import { isValidSlug, parseRef, readJson, unwrap } from "../utils.ts"
10
-
11
- export const newCommand = defineCommand({
12
- meta: { name: "new", description: "创建函数包(bundle)或函数(function)" },
13
- args: {
14
- ref: { type: "positional", description: "<bundle> 或 <bundle>/<function>", required: true },
15
- lang: { type: "string", description: "语言:bun(默认)" },
16
- "with-containerfile": { type: "boolean", description: "生成逃生舱 Containerfile 模板" },
17
- },
18
- async run({ args }) {
19
- intro(pc.cyan("创建脚手架"))
20
- const ctx = await loadContext()
21
- if (!ctx) {
22
- log.error("未找到 atomfunctions.json")
23
- process.exit(1)
24
- }
25
- const { bundle, fn } = parseRef(args.ref)
26
-
27
- if (!isValidSlug(bundle)) {
28
- log.error("bundle 名需以小写字母开头,仅含小写字母、数字、连字符")
29
- process.exit(1)
30
- }
31
-
32
- const bundleDir = path.join(ctx.root, bundle)
33
- const existingConfig = await readJson<BundleConfig>(path.join(bundleDir, BUNDLE_FILE))
34
-
35
- let language: Language = existingConfig?.language ?? "bun"
36
- if (!existingConfig) {
37
- language = (args.lang ??
38
- unwrap(
39
- await select({
40
- message: "选择语言",
41
- options: [
42
- { value: "bun", label: "Bun (TypeScript)" },
43
- { value: "rust", label: "Rust" },
44
- ],
45
- initialValue: "bun",
46
- }),
47
- )) as Language
48
- await scaffoldBundle(bundleDir, { language })
49
- log.success(`已创建 bundle ${bundle}`)
50
- }
51
-
52
- if (args["with-containerfile"]) {
53
- await scaffoldContainerfile(bundleDir)
54
- log.success("已生成逃生舱 Containerfile")
55
- }
56
-
57
- if (fn) {
58
- if (!isValidSlug(fn)) {
59
- log.error("function 名需以小写字母开头,仅含小写字母、数字、连字符")
60
- process.exit(1)
61
- }
62
- await scaffoldFunction(bundleDir, fn, language)
63
- log.success(`已创建函数 ${bundle}/${fn}`)
64
- }
65
-
66
- outro(pc.green("完成,运行 atomfunctions deploy 进行部署"))
67
- },
68
- })