@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
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import { intro, log, outro, select, text } from "@clack/prompts"
|
|
3
|
+
import { defineCommand } from "citty"
|
|
4
|
+
import pc from "picocolors"
|
|
5
|
+
import { ClientError } from "../client.ts"
|
|
6
|
+
import { LOCAL_DIR, MANIFEST_FILE } from "../consts.ts"
|
|
7
|
+
import { authedClient, requireServerContext } from "../context.ts"
|
|
8
|
+
import { openInEditor } from "../editor.ts"
|
|
9
|
+
import type { Manifest } from "../types.ts"
|
|
10
|
+
import { isValidSlug, readJson, unwrap, writeJson } from "../utils.ts"
|
|
11
|
+
import { writeView } from "../views.ts"
|
|
12
|
+
|
|
13
|
+
function intentFile(ctx: { root: string }, slug: string, action: string): string {
|
|
14
|
+
return path.join(ctx.root, LOCAL_DIR, "project", `${slug}.${action}.json`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function askSlug(provided: string | undefined): Promise<string> {
|
|
18
|
+
if (provided) {
|
|
19
|
+
if (!isValidSlug(provided)) throw new Error("项目标识需以小写字母开头,仅含小写字母、数字、连字符")
|
|
20
|
+
return provided
|
|
21
|
+
}
|
|
22
|
+
return unwrap(
|
|
23
|
+
await text({
|
|
24
|
+
message: "项目标识",
|
|
25
|
+
validate: (v) =>
|
|
26
|
+
v?.trim() && isValidSlug(v.trim()) ? undefined : "需以小写字母开头,仅含小写字母、数字、连字符",
|
|
27
|
+
}),
|
|
28
|
+
).trim()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const listCommand = defineCommand({
|
|
32
|
+
meta: { name: "list", description: "查看所有项目" },
|
|
33
|
+
async run() {
|
|
34
|
+
intro(pc.cyan("项目列表"))
|
|
35
|
+
const ctx = await requireServerContext()
|
|
36
|
+
const client = await authedClient(ctx)
|
|
37
|
+
try {
|
|
38
|
+
const data = await client.get<{ projects: unknown[] }>("/projects")
|
|
39
|
+
const file = await writeView(ctx, "projects.list.json", data)
|
|
40
|
+
outro(pc.green(`已生成 ${file}`))
|
|
41
|
+
} catch (err) {
|
|
42
|
+
if (err instanceof ClientError) {
|
|
43
|
+
outro(pc.red(err.message))
|
|
44
|
+
process.exit(1)
|
|
45
|
+
}
|
|
46
|
+
throw err
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const createCommand = defineCommand({
|
|
52
|
+
meta: { name: "create", description: "新建项目意图(仅超管,push 生效)" },
|
|
53
|
+
args: {
|
|
54
|
+
slug: { type: "positional", required: false, description: "项目标识" },
|
|
55
|
+
name: { type: "string", description: "项目名称" },
|
|
56
|
+
description: { type: "string", description: "项目描述" },
|
|
57
|
+
},
|
|
58
|
+
async run({ args }) {
|
|
59
|
+
intro(pc.cyan("新建项目"))
|
|
60
|
+
const ctx = await requireServerContext()
|
|
61
|
+
const slug = await askSlug(args.slug)
|
|
62
|
+
const name =
|
|
63
|
+
args.name ??
|
|
64
|
+
unwrap(
|
|
65
|
+
await text({
|
|
66
|
+
message: "项目名称",
|
|
67
|
+
initialValue: slug,
|
|
68
|
+
validate: (v) => (v?.trim() ? undefined : "请输入名称"),
|
|
69
|
+
}),
|
|
70
|
+
).trim()
|
|
71
|
+
const payload: Record<string, unknown> = { slug, name }
|
|
72
|
+
if (args.description) payload.description = args.description
|
|
73
|
+
|
|
74
|
+
const file = intentFile(ctx, slug, "create")
|
|
75
|
+
await writeJson(file, payload)
|
|
76
|
+
openInEditor(file)
|
|
77
|
+
outro(pc.green(`已生成意图 ${path.relative(ctx.root, file)},编辑后执行 atomfunctions push`))
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const linkCommand = defineCommand({
|
|
82
|
+
meta: { name: "link", description: "绑定当前目录到指定项目" },
|
|
83
|
+
args: { slug: { type: "positional", required: false, description: "项目标识" } },
|
|
84
|
+
async run({ args }) {
|
|
85
|
+
intro(pc.cyan("绑定项目"))
|
|
86
|
+
const ctx = await requireServerContext()
|
|
87
|
+
const client = await authedClient(ctx)
|
|
88
|
+
|
|
89
|
+
let slug: string
|
|
90
|
+
if (args.slug) {
|
|
91
|
+
if (!isValidSlug(args.slug)) {
|
|
92
|
+
log.error("项目标识需以小写字母开头,仅含小写字母、数字、连字符")
|
|
93
|
+
process.exit(1)
|
|
94
|
+
}
|
|
95
|
+
slug = args.slug
|
|
96
|
+
} else {
|
|
97
|
+
let projects: { slug: string; name: string }[] = []
|
|
98
|
+
try {
|
|
99
|
+
const data = await client.get<{ projects: { slug: string; name: string }[] }>("/projects")
|
|
100
|
+
projects = data.projects
|
|
101
|
+
} catch {}
|
|
102
|
+
if (projects.length > 0) {
|
|
103
|
+
slug = unwrap(
|
|
104
|
+
await select({
|
|
105
|
+
message: "选择项目",
|
|
106
|
+
options: projects.map((p) => ({ value: p.slug, label: p.slug, hint: p.name })),
|
|
107
|
+
}),
|
|
108
|
+
) as string
|
|
109
|
+
} else {
|
|
110
|
+
slug = unwrap(
|
|
111
|
+
await text({
|
|
112
|
+
message: "项目标识",
|
|
113
|
+
validate: (v) =>
|
|
114
|
+
v?.trim() && isValidSlug(v.trim()) ? undefined : "需以小写字母开头,仅含小写字母、数字、连字符",
|
|
115
|
+
}),
|
|
116
|
+
).trim()
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
await client.get(`/projects/${slug}`)
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (err instanceof ClientError && err.status === 404) {
|
|
124
|
+
log.error(`项目 "${slug}" 在服务器上不存在`)
|
|
125
|
+
process.exit(1)
|
|
126
|
+
}
|
|
127
|
+
throw err
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const manifestPath = path.join(ctx.root, MANIFEST_FILE)
|
|
131
|
+
const manifest = await readJson<Manifest>(manifestPath)
|
|
132
|
+
if (!manifest) {
|
|
133
|
+
log.error("读取 atomfunctions.json 失败")
|
|
134
|
+
process.exit(1)
|
|
135
|
+
}
|
|
136
|
+
manifest.project = { slug }
|
|
137
|
+
await writeJson(manifestPath, manifest)
|
|
138
|
+
outro(pc.green(`已绑定项目 ${slug}`))
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
export const projectCommand = defineCommand({
|
|
143
|
+
meta: { name: "project", description: "管理项目" },
|
|
144
|
+
subCommands: { list: listCommand, create: createCommand, link: linkCommand },
|
|
145
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { rmSync } from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { intro, outro, spinner } from "@clack/prompts"
|
|
4
|
+
import { defineCommand } from "citty"
|
|
5
|
+
import pc from "picocolors"
|
|
6
|
+
import type { AdminClient } from "../client.ts"
|
|
7
|
+
import { ClientError } from "../client.ts"
|
|
8
|
+
import { REMOTE_DIR } from "../consts.ts"
|
|
9
|
+
import { authedClient, requireProjectContext } from "../context.ts"
|
|
10
|
+
import type { CliContext } from "../types.ts"
|
|
11
|
+
import { ensureDir, writeJson } from "../utils.ts"
|
|
12
|
+
|
|
13
|
+
interface ProjectDto {
|
|
14
|
+
id: string
|
|
15
|
+
slug: string
|
|
16
|
+
name: string
|
|
17
|
+
description: string | null
|
|
18
|
+
createdAt: number
|
|
19
|
+
updatedAt: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface BundleRecord {
|
|
23
|
+
project: string
|
|
24
|
+
bundle: string
|
|
25
|
+
version: string
|
|
26
|
+
functions: string[]
|
|
27
|
+
updatedAt: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function runPull(
|
|
31
|
+
ctx: CliContext & { manifest: { project: { slug: string } } },
|
|
32
|
+
client: AdminClient,
|
|
33
|
+
): Promise<{ bundles: number }> {
|
|
34
|
+
const project = ctx.manifest.project.slug
|
|
35
|
+
const remoteDir = path.join(ctx.root, REMOTE_DIR)
|
|
36
|
+
|
|
37
|
+
rmSync(path.join(remoteDir, "bundles"), { recursive: true, force: true })
|
|
38
|
+
ensureDir(path.join(remoteDir, "bundles"))
|
|
39
|
+
|
|
40
|
+
const { project: projectDto } = await client.get<{ project: ProjectDto }>(`/projects/${project}`)
|
|
41
|
+
await writeJson(path.join(remoteDir, "project.json"), projectDto)
|
|
42
|
+
|
|
43
|
+
const { bundles } = await client.get<{ bundles: BundleRecord[] }>(`/projects/${project}/bundles`)
|
|
44
|
+
for (const bundle of bundles) {
|
|
45
|
+
await writeJson(path.join(remoteDir, "bundles", `${bundle.bundle}.json`), bundle)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { bundles: bundles.length }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const pullCommand = defineCommand({
|
|
52
|
+
meta: { name: "pull", description: "拉取服务器最新状态到 remote/" },
|
|
53
|
+
async run() {
|
|
54
|
+
intro(pc.cyan("拉取最新状态"))
|
|
55
|
+
const ctx = await requireProjectContext()
|
|
56
|
+
const client = await authedClient(ctx)
|
|
57
|
+
const spin = spinner()
|
|
58
|
+
spin.start("同步中")
|
|
59
|
+
try {
|
|
60
|
+
const stats = await runPull(ctx as Parameters<typeof runPull>[0], client)
|
|
61
|
+
spin.stop(pc.green(`已同步:${stats.bundles} 函数包`))
|
|
62
|
+
outro("remote/ 已刷新")
|
|
63
|
+
} catch (err) {
|
|
64
|
+
spin.stop(pc.red("拉取失败"))
|
|
65
|
+
if (err instanceof ClientError) {
|
|
66
|
+
outro(pc.red(err.message))
|
|
67
|
+
} else {
|
|
68
|
+
outro(pc.red(String(err)))
|
|
69
|
+
}
|
|
70
|
+
process.exit(1)
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { readdirSync } from "node:fs"
|
|
2
|
+
import { rm } from "node:fs/promises"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import { confirm, intro, isCancel, log, outro, spinner } from "@clack/prompts"
|
|
5
|
+
import { defineCommand } from "citty"
|
|
6
|
+
import pc from "picocolors"
|
|
7
|
+
import { ClientError } from "../client.ts"
|
|
8
|
+
import { LOCAL_DIR } from "../consts.ts"
|
|
9
|
+
import { authedClient, requireServerContext } from "../context.ts"
|
|
10
|
+
import { readJson } from "../utils.ts"
|
|
11
|
+
|
|
12
|
+
type IntentAction = "create" | "update" | "delete"
|
|
13
|
+
type IntentKind = "project"
|
|
14
|
+
|
|
15
|
+
interface Intent {
|
|
16
|
+
kind: IntentKind
|
|
17
|
+
action: IntentAction
|
|
18
|
+
slug: string
|
|
19
|
+
file: string
|
|
20
|
+
payload: Record<string, unknown>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseName(file: string): { action: IntentAction } | null {
|
|
24
|
+
const match = file.match(/^.+\.(create|update|delete)\.json$/)
|
|
25
|
+
if (!match?.[1]) return null
|
|
26
|
+
return { action: match[1] as IntentAction }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function scanIntents(root: string): Promise<Intent[]> {
|
|
30
|
+
const intents: Intent[] = []
|
|
31
|
+
const dir = path.join(root, LOCAL_DIR, "project")
|
|
32
|
+
let entries: string[]
|
|
33
|
+
try {
|
|
34
|
+
entries = readdirSync(dir)
|
|
35
|
+
} catch {
|
|
36
|
+
return []
|
|
37
|
+
}
|
|
38
|
+
for (const file of entries) {
|
|
39
|
+
const parsed = parseName(file)
|
|
40
|
+
if (!parsed) continue
|
|
41
|
+
const filePath = path.join(dir, file)
|
|
42
|
+
const payload = (await readJson<Record<string, unknown>>(filePath)) ?? {}
|
|
43
|
+
const slug = typeof payload.slug === "string" ? payload.slug : null
|
|
44
|
+
if (!slug) throw new Error(`意图文件 ${file} 缺少 slug 字段`)
|
|
45
|
+
intents.push({ kind: "project", action: parsed.action, slug, file: filePath, payload })
|
|
46
|
+
}
|
|
47
|
+
// create → update → delete
|
|
48
|
+
const rank = (i: Intent) => ["create", "update", "delete"].indexOf(i.action)
|
|
49
|
+
return intents.sort((a, b) => rank(a) - rank(b))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function cleanPayload(payload: Record<string, unknown>): Record<string, unknown> {
|
|
53
|
+
const out: Record<string, unknown> = {}
|
|
54
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
55
|
+
if (key === "$schema" || key === "slug") continue
|
|
56
|
+
out[key] = value
|
|
57
|
+
}
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const actionLabel: Record<IntentAction, string> = {
|
|
62
|
+
create: pc.green("新增"),
|
|
63
|
+
update: pc.blue("更新"),
|
|
64
|
+
delete: pc.red("删除"),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const kindLabel: Record<IntentKind, string> = { project: "项目" }
|
|
68
|
+
|
|
69
|
+
export const pushCommand = defineCommand({
|
|
70
|
+
meta: { name: "push", description: "将本地意图应用到服务器" },
|
|
71
|
+
async run() {
|
|
72
|
+
intro(pc.cyan("推送变更"))
|
|
73
|
+
const ctx = await requireServerContext()
|
|
74
|
+
|
|
75
|
+
let intents: Intent[]
|
|
76
|
+
try {
|
|
77
|
+
intents = await scanIntents(ctx.root)
|
|
78
|
+
} catch (err) {
|
|
79
|
+
outro(pc.red(String(err)))
|
|
80
|
+
process.exit(1)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (intents.length === 0) {
|
|
84
|
+
outro("没有待推送的意图")
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
console.log("")
|
|
89
|
+
console.log(pc.bold("待推送变更:"))
|
|
90
|
+
for (const intent of intents) {
|
|
91
|
+
console.log(` ${actionLabel[intent.action]} ${kindLabel[intent.kind]} ${pc.bold(intent.slug)}`)
|
|
92
|
+
}
|
|
93
|
+
console.log("")
|
|
94
|
+
|
|
95
|
+
const confirmed = await confirm({ message: `确认推送以上 ${intents.length} 项变更?` })
|
|
96
|
+
if (isCancel(confirmed) || !confirmed) {
|
|
97
|
+
outro(pc.yellow("已取消"))
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const client = await authedClient(ctx)
|
|
102
|
+
let applied = 0
|
|
103
|
+
let failed = 0
|
|
104
|
+
|
|
105
|
+
for (const intent of intents) {
|
|
106
|
+
const spin = spinner()
|
|
107
|
+
spin.start(`${intent.action} ${intent.kind} ${intent.slug}`)
|
|
108
|
+
try {
|
|
109
|
+
const body = cleanPayload(intent.payload)
|
|
110
|
+
if (intent.kind === "project") {
|
|
111
|
+
if (intent.action === "create") {
|
|
112
|
+
await client.post("/projects", { slug: intent.slug, ...body })
|
|
113
|
+
} else if (intent.action === "update") {
|
|
114
|
+
await client.post(`/projects/${intent.slug}`, body)
|
|
115
|
+
} else if (intent.action === "delete") {
|
|
116
|
+
await client.del(`/projects/${intent.slug}`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
await rm(intent.file, { force: true })
|
|
120
|
+
spin.stop(pc.green(`✓ ${actionLabel[intent.action]} ${kindLabel[intent.kind]} ${intent.slug}`))
|
|
121
|
+
applied += 1
|
|
122
|
+
} catch (err) {
|
|
123
|
+
failed += 1
|
|
124
|
+
if (err instanceof ClientError) {
|
|
125
|
+
spin.stop(pc.red(`✗ ${intent.kind} ${intent.slug}:${err.message}`))
|
|
126
|
+
} else {
|
|
127
|
+
spin.stop(pc.red(`✗ ${intent.kind} ${intent.slug}`))
|
|
128
|
+
log.error(String(err))
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
outro(failed === 0 ? pc.green(`完成:应用 ${applied} 项`) : pc.yellow(`应用 ${applied} 项,失败 ${failed} 项`))
|
|
134
|
+
if (failed > 0) process.exit(1)
|
|
135
|
+
},
|
|
136
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { intro, log, outro, select } from "@clack/prompts"
|
|
2
|
+
import { defineCommand } from "citty"
|
|
3
|
+
import pc from "picocolors"
|
|
4
|
+
import { AdminClient } from "../client.ts"
|
|
5
|
+
import { pickTarget, requireProjectContext } from "../context.ts"
|
|
6
|
+
import { unwrap } from "../utils.ts"
|
|
7
|
+
|
|
8
|
+
export const rollbackCommand = defineCommand({
|
|
9
|
+
meta: { name: "rollback", description: "回滚函数包到历史版本(扇出到所有机器)" },
|
|
10
|
+
args: {
|
|
11
|
+
bundle: { type: "positional", description: "bundle 名", required: true },
|
|
12
|
+
version: { type: "string", description: "目标版本(不填则从历史中选择)" },
|
|
13
|
+
},
|
|
14
|
+
async run({ args }) {
|
|
15
|
+
intro(pc.cyan(`回滚 ${args.bundle}`))
|
|
16
|
+
const ctx = await requireProjectContext()
|
|
17
|
+
|
|
18
|
+
const primary = pickTarget(ctx)
|
|
19
|
+
const history = await new AdminClient(primary.server, primary.token).versions(ctx.project, args.bundle)
|
|
20
|
+
|
|
21
|
+
let version = args.version
|
|
22
|
+
if (!version) {
|
|
23
|
+
version = unwrap(
|
|
24
|
+
await select({
|
|
25
|
+
message: "选择回滚到的版本",
|
|
26
|
+
options: history.map((record, index) => ({
|
|
27
|
+
value: record.version,
|
|
28
|
+
label: `${record.version}${index === 0 ? pc.dim(" (当前)") : ""}`,
|
|
29
|
+
hint: record.updatedAt,
|
|
30
|
+
})),
|
|
31
|
+
}),
|
|
32
|
+
) as string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const results = await Promise.all(
|
|
36
|
+
ctx.targets.map(async (t) => {
|
|
37
|
+
try {
|
|
38
|
+
await new AdminClient(t.server, t.token).rollback(ctx.project, args.bundle, version as string)
|
|
39
|
+
return { host: t.host, ok: true }
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return { host: t.host, ok: false, message: error instanceof Error ? error.message : String(error) }
|
|
42
|
+
}
|
|
43
|
+
}),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const failed = results.filter((r) => !r.ok)
|
|
47
|
+
if (failed.length === 0) {
|
|
48
|
+
log.success(`已在 ${ctx.targets.length} 台机器回滚到 ${version}`)
|
|
49
|
+
outro(pc.green("完成"))
|
|
50
|
+
} else {
|
|
51
|
+
for (const f of failed) log.error(` ${f.host}: ${f.message}`)
|
|
52
|
+
outro(pc.red("部分机器回滚失败"))
|
|
53
|
+
process.exitCode = 1
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { intro, log, outro, select, text } from "@clack/prompts"
|
|
2
|
+
import { defineCommand } from "citty"
|
|
3
|
+
import pc from "picocolors"
|
|
4
|
+
import { ClientError } from "../client.ts"
|
|
5
|
+
import { authedClient, requireProjectContext } from "../context.ts"
|
|
6
|
+
import { unwrap } from "../utils.ts"
|
|
7
|
+
import { writeView } from "../views.ts"
|
|
8
|
+
|
|
9
|
+
interface TokenInfo {
|
|
10
|
+
id: string
|
|
11
|
+
name: string
|
|
12
|
+
project: string
|
|
13
|
+
tokenPrefix: string
|
|
14
|
+
revoked: boolean
|
|
15
|
+
createdAt: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function fmtDate(ts: number): string {
|
|
19
|
+
const d = new Date(ts * 1000)
|
|
20
|
+
return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, "0")}/${String(d.getDate()).padStart(2, "0")}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const listCommand = defineCommand({
|
|
24
|
+
meta: { name: "list", description: "查看当前项目的访问 token" },
|
|
25
|
+
async run() {
|
|
26
|
+
intro(pc.cyan("token 列表"))
|
|
27
|
+
const ctx = await requireProjectContext()
|
|
28
|
+
const client = await authedClient(ctx)
|
|
29
|
+
try {
|
|
30
|
+
const data = await client.get<{ tokens: unknown[] }>(`/projects/${ctx.project}/tokens`)
|
|
31
|
+
const file = await writeView(ctx, "tokens.list.json", data)
|
|
32
|
+
outro(pc.green(`已生成 ${file}`))
|
|
33
|
+
} catch (err) {
|
|
34
|
+
if (err instanceof ClientError) {
|
|
35
|
+
outro(pc.red(err.message))
|
|
36
|
+
process.exit(1)
|
|
37
|
+
}
|
|
38
|
+
throw err
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const createCommand = defineCommand({
|
|
44
|
+
meta: { name: "create", description: "创建当前项目的访问 token(派发给客户端调用函数)" },
|
|
45
|
+
args: {
|
|
46
|
+
name: { type: "string", description: "令牌备注" },
|
|
47
|
+
},
|
|
48
|
+
async run({ args }) {
|
|
49
|
+
intro(pc.cyan("创建访问 token"))
|
|
50
|
+
const ctx = await requireProjectContext()
|
|
51
|
+
const client = await authedClient(ctx)
|
|
52
|
+
const name = args.name ?? unwrap(await text({ message: "令牌备注(可选)", placeholder: "ci" })).trim()
|
|
53
|
+
try {
|
|
54
|
+
const res = await client.post<{ token: string }>(`/projects/${ctx.project}/tokens`, { name: name || undefined })
|
|
55
|
+
log.warn("以下令牌仅显示一次,请妥善保存:")
|
|
56
|
+
log.message(pc.bold(res.token))
|
|
57
|
+
outro(pc.green("已创建访问 token"))
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err instanceof ClientError) {
|
|
60
|
+
outro(pc.red(err.message))
|
|
61
|
+
process.exit(1)
|
|
62
|
+
}
|
|
63
|
+
throw err
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const revokeCommand = defineCommand({
|
|
69
|
+
meta: { name: "revoke", description: "吊销当前项目的访问 token" },
|
|
70
|
+
args: {
|
|
71
|
+
id: { type: "positional", required: false, description: "token id" },
|
|
72
|
+
},
|
|
73
|
+
async run({ args }) {
|
|
74
|
+
intro(pc.cyan("吊销 token"))
|
|
75
|
+
const ctx = await requireProjectContext()
|
|
76
|
+
const client = await authedClient(ctx)
|
|
77
|
+
|
|
78
|
+
let id: string
|
|
79
|
+
if (args.id) {
|
|
80
|
+
id = args.id
|
|
81
|
+
} else {
|
|
82
|
+
let tokens: TokenInfo[] = []
|
|
83
|
+
try {
|
|
84
|
+
const data = await client.get<{ tokens: TokenInfo[] }>(`/projects/${ctx.project}/tokens`)
|
|
85
|
+
tokens = data.tokens
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if (err instanceof ClientError) {
|
|
88
|
+
outro(pc.red(err.message))
|
|
89
|
+
process.exit(1)
|
|
90
|
+
}
|
|
91
|
+
throw err
|
|
92
|
+
}
|
|
93
|
+
if (tokens.length === 0) {
|
|
94
|
+
outro("当前项目暂无 token")
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
const active = tokens.filter((t) => !t.revoked)
|
|
98
|
+
if (active.length === 0) {
|
|
99
|
+
outro("当前项目没有有效的 token")
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
id = unwrap(
|
|
103
|
+
await select({
|
|
104
|
+
message: "选择要吊销的 token",
|
|
105
|
+
options: active.map((t) => ({
|
|
106
|
+
value: t.id,
|
|
107
|
+
label: `${t.name || "—"} · ${t.tokenPrefix}`,
|
|
108
|
+
hint: fmtDate(t.createdAt),
|
|
109
|
+
})),
|
|
110
|
+
}),
|
|
111
|
+
) as string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
await client.del(`/projects/${ctx.project}/tokens/${id}`)
|
|
116
|
+
outro(pc.green("已吊销"))
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (err instanceof ClientError) {
|
|
119
|
+
outro(pc.red(err.message))
|
|
120
|
+
process.exit(1)
|
|
121
|
+
}
|
|
122
|
+
throw err
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
export const tokenCommand = defineCommand({
|
|
128
|
+
meta: { name: "token", description: "管理客户端访问 token" },
|
|
129
|
+
subCommands: { list: listCommand, create: createCommand, revoke: revokeCommand },
|
|
130
|
+
})
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { intro, outro, password as passwordPrompt, text } from "@clack/prompts"
|
|
2
|
+
import { defineCommand } from "citty"
|
|
3
|
+
import pc from "picocolors"
|
|
4
|
+
import { ClientError } from "../client.ts"
|
|
5
|
+
import { authedClient, requireServerContext } from "../context.ts"
|
|
6
|
+
import { unwrap } from "../utils.ts"
|
|
7
|
+
import { writeView } from "../views.ts"
|
|
8
|
+
|
|
9
|
+
const listCommand = defineCommand({
|
|
10
|
+
meta: { name: "list", description: "查看所有用户" },
|
|
11
|
+
async run() {
|
|
12
|
+
intro(pc.cyan("用户列表"))
|
|
13
|
+
const ctx = await requireServerContext()
|
|
14
|
+
const client = await authedClient(ctx)
|
|
15
|
+
try {
|
|
16
|
+
const data = await client.get<{ users: unknown[] }>("/users")
|
|
17
|
+
const file = await writeView(ctx, "users.list.json", data)
|
|
18
|
+
outro(pc.green(`已生成 ${file}`))
|
|
19
|
+
} catch (err) {
|
|
20
|
+
if (err instanceof ClientError) {
|
|
21
|
+
outro(pc.red(err.message))
|
|
22
|
+
process.exit(1)
|
|
23
|
+
}
|
|
24
|
+
throw err
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const createCommand = defineCommand({
|
|
30
|
+
meta: { name: "create", description: "创建用户(仅超管)" },
|
|
31
|
+
args: {
|
|
32
|
+
email: { type: "string" },
|
|
33
|
+
name: { type: "string" },
|
|
34
|
+
password: { type: "string" },
|
|
35
|
+
super: { type: "boolean", description: "授予超级管理员", default: false },
|
|
36
|
+
},
|
|
37
|
+
async run({ args }) {
|
|
38
|
+
intro(pc.cyan("创建用户"))
|
|
39
|
+
const ctx = await requireServerContext()
|
|
40
|
+
const client = await authedClient(ctx)
|
|
41
|
+
const email =
|
|
42
|
+
args.email ??
|
|
43
|
+
unwrap(await text({ message: "邮箱", validate: (v) => (v?.trim() ? undefined : "请输入邮箱") })).trim()
|
|
44
|
+
const name =
|
|
45
|
+
args.name ??
|
|
46
|
+
unwrap(await text({ message: "用户名", validate: (v) => (v?.trim() ? undefined : "请输入用户名") })).trim()
|
|
47
|
+
const secret =
|
|
48
|
+
args.password ??
|
|
49
|
+
unwrap(
|
|
50
|
+
await passwordPrompt({
|
|
51
|
+
message: "初始密码(至少 8 位)",
|
|
52
|
+
validate: (v) => (v && v.length >= 8 ? undefined : "至少 8 位"),
|
|
53
|
+
}),
|
|
54
|
+
)
|
|
55
|
+
const isSuperAdmin = args.super
|
|
56
|
+
try {
|
|
57
|
+
const res = await client.post<{ user: { name: string } }>("/users", {
|
|
58
|
+
email,
|
|
59
|
+
name,
|
|
60
|
+
password: secret,
|
|
61
|
+
isSuperAdmin,
|
|
62
|
+
})
|
|
63
|
+
outro(pc.green(`已创建用户 ${res.user.name}`))
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err instanceof ClientError) {
|
|
66
|
+
outro(pc.red(err.message))
|
|
67
|
+
process.exit(1)
|
|
68
|
+
}
|
|
69
|
+
throw err
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
export const userCommand = defineCommand({
|
|
75
|
+
meta: { name: "user", description: "管理用户" },
|
|
76
|
+
subCommands: { list: listCommand, create: createCommand },
|
|
77
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { intro, log, outro } from "@clack/prompts"
|
|
2
|
+
import { defineCommand } from "citty"
|
|
3
|
+
import pc from "picocolors"
|
|
4
|
+
import { AdminClient } from "../client.ts"
|
|
5
|
+
import { loadContext } from "../context.ts"
|
|
6
|
+
import { getCredential } from "../creds.ts"
|
|
7
|
+
import { hostOf } from "../utils.ts"
|
|
8
|
+
|
|
9
|
+
interface WhoamiUser {
|
|
10
|
+
id: string
|
|
11
|
+
email: string
|
|
12
|
+
name: string
|
|
13
|
+
isSuperAdmin: boolean
|
|
14
|
+
createdAt: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const whoamiCommand = defineCommand({
|
|
18
|
+
meta: { name: "whoami", description: "查看当前登录账号信息" },
|
|
19
|
+
args: {
|
|
20
|
+
server: { type: "string", description: "服务器地址(默认读取 atomfunctions.json)" },
|
|
21
|
+
},
|
|
22
|
+
async run({ args }) {
|
|
23
|
+
intro(pc.cyan("atomfunctions whoami"))
|
|
24
|
+
const ctx = await loadContext()
|
|
25
|
+
if (!ctx) process.exit(1)
|
|
26
|
+
const server = args.server ?? ctx.manifest.server
|
|
27
|
+
if (!server) {
|
|
28
|
+
log.error("无法确定服务器地址")
|
|
29
|
+
process.exit(1)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const host = hostOf(server)
|
|
33
|
+
const cred = await getCredential(host)
|
|
34
|
+
if (!cred) {
|
|
35
|
+
log.error(`尚未登录 ${host}`)
|
|
36
|
+
process.exit(1)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const client = new AdminClient(server, cred.token)
|
|
40
|
+
const res = await client.get<{ user: WhoamiUser }>("/auth/whoami")
|
|
41
|
+
const user = res.user
|
|
42
|
+
|
|
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
|
+
|
|
49
|
+
outro(pc.green("已登录"))
|
|
50
|
+
},
|
|
51
|
+
})
|