@atomservice/core 0.1.0

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 ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@atomservice/core",
3
+ "version": "0.1.0",
4
+ "description": "atomservice 编排核心:defineService、依赖拓扑、生命周期与 CLI 运行时",
5
+ "type": "module",
6
+ "author": "openorson",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/openorson/atomservice.git",
10
+ "directory": "packages/core"
11
+ },
12
+ "bugs": "https://github.com/openorson/atomservice/issues",
13
+ "homepage": "https://github.com/openorson/atomservice/tree/main/packages/core#readme",
14
+ "keywords": [
15
+ "atomservice",
16
+ "self-hosted",
17
+ "podman",
18
+ "orchestration",
19
+ "bun"
20
+ ],
21
+ "engines": {
22
+ "bun": ">=1.0.0"
23
+ },
24
+ "files": [
25
+ "src"
26
+ ],
27
+ "exports": {
28
+ ".": "./src/index.ts"
29
+ },
30
+ "dependencies": {
31
+ "@clack/prompts": "^1.4.0",
32
+ "consola": "^3.4.2",
33
+ "defu": "^6.1.7",
34
+ "pathe": "^2.0.3",
35
+ "picocolors": "^1.1.1",
36
+ "zod": "^4.4.3"
37
+ }
38
+ }
@@ -0,0 +1,26 @@
1
+ import fs from "node:fs"
2
+ import { z } from "zod"
3
+ import { ATOMSERVICE_DIR, CONFIG_PATH } from "./core.consts.ts"
4
+ import { UserError } from "./core.errors.ts"
5
+ import type { ResolvedAtomserviceConfig } from "./core.types.ts"
6
+
7
+ const UserConfigSchema = z.object({
8
+ root: z.string().optional(),
9
+ services: z.array(z.any()),
10
+ })
11
+
12
+ export async function loadConfig(): Promise<ResolvedAtomserviceConfig> {
13
+ if (!fs.existsSync(CONFIG_PATH)) {
14
+ throw new UserError(`找不到配置文件 ${CONFIG_PATH}`, "请先执行 atom init 创建配置文件")
15
+ }
16
+ const configPath = CONFIG_PATH
17
+ const mod = await import(configPath)
18
+ const raw = mod.default ?? mod
19
+
20
+ const parsed = UserConfigSchema.parse(raw)
21
+
22
+ return {
23
+ root: parsed.root ?? ATOMSERVICE_DIR,
24
+ services: parsed.services,
25
+ }
26
+ }
@@ -0,0 +1,10 @@
1
+ import os from "node:os"
2
+ import path from "node:path"
3
+
4
+ export const ATOMSERVICE_DIR = path.join(os.homedir(), ".atomservice")
5
+ export const CONFIG_PATH = path.join(ATOMSERVICE_DIR, "atom.config.ts")
6
+
7
+ export const YELLOW = Bun.color("#facc15", "ansi") ?? ""
8
+ export const RESET = "\x1b[0m"
9
+ export const BOLD = "\x1b[1m"
10
+ export const DIM = "\x1b[2m"
@@ -0,0 +1,27 @@
1
+ import { useService } from "./core.hooks.ts"
2
+ import type { AtomserviceConfig, CallableService, ServiceDefinition } from "./core.types.ts"
3
+
4
+ function makeCallable<ServiceInstance>(def: ServiceDefinition<ServiceInstance>): CallableService<ServiceInstance> {
5
+ const callable = (): ServiceInstance => useService(def as ServiceDefinition<ServiceInstance>)
6
+ const { name, ...rest } = def
7
+ Object.assign(callable, rest)
8
+ Object.defineProperty(callable, "name", { value: name, configurable: true })
9
+ return callable as CallableService<ServiceInstance>
10
+ }
11
+
12
+ export function defineService<ServiceInstance = void>({
13
+ name,
14
+ id = "default",
15
+ setup,
16
+ }: {
17
+ name: string
18
+ id?: string
19
+ setup: () => Bun.MaybePromise<ServiceInstance>
20
+ }): CallableService<ServiceInstance> {
21
+ const def: ServiceDefinition<ServiceInstance> = { name, id, _setup: setup }
22
+ return makeCallable(def)
23
+ }
24
+
25
+ export function defineConfig(config: AtomserviceConfig): AtomserviceConfig {
26
+ return config
27
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * 用户可见错误
3
+ *
4
+ * - 用于表示「可预期、由用户配置或环境导致」的错误
5
+ * - CLI 捕获后只打印简洁的中文提示,不输出堆栈
6
+ */
7
+ export class UserError extends Error {
8
+ readonly hint?: string
9
+
10
+ constructor(message: string, hint?: string) {
11
+ super(message)
12
+ this.name = "UserError"
13
+ this.hint = hint
14
+ }
15
+ }
@@ -0,0 +1,267 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks"
2
+ import { readFileSync } from "node:fs"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+ import * as clack from "@clack/prompts"
6
+ import { BOLD, DIM, RESET, YELLOW } from "./core.consts.ts"
7
+ import { UserError } from "./core.errors.ts"
8
+ import type {
9
+ AnyService,
10
+ HealthStatus,
11
+ Logger,
12
+ ResolvedAtomserviceConfig,
13
+ ServiceCommand,
14
+ ServiceDefinition,
15
+ ServiceLifecycle,
16
+ ServiceStateHandle,
17
+ ServiceStateWatchRule,
18
+ SetupStore,
19
+ } from "./core.types.ts"
20
+
21
+ const setupStore = new AsyncLocalStorage<SetupStore>()
22
+ const apiRegistry = new Map<string, unknown>()
23
+ function getStore(hookName: string): SetupStore {
24
+ const store = setupStore.getStore()
25
+ if (!store) throw new Error(`${hookName}() 必须在 defineService 的 setup 内调用`)
26
+ return store
27
+ }
28
+
29
+ export function onUp(fn: () => Bun.MaybePromise<void>): void {
30
+ getStore("onUp").lifecycle.upHandlers.push(fn)
31
+ }
32
+
33
+ export function onDown(fn: () => Bun.MaybePromise<void>): void {
34
+ getStore("onDown").lifecycle.downHandlers.push(fn)
35
+ }
36
+
37
+ export function onHealth(fn: () => Bun.MaybePromise<HealthStatus>): void {
38
+ getStore("onHealth").lifecycle.healthHandler = fn
39
+ }
40
+
41
+ export function addCommand(cmd: ServiceCommand): void {
42
+ getStore("addCommand").lifecycle.commands.push(cmd)
43
+ }
44
+
45
+ export function useLogger(): Logger {
46
+ return getStore("useLogger").logger
47
+ }
48
+
49
+ export function useShell(): typeof Bun.$ {
50
+ const shell = getStore("useShell").$
51
+ const RESET = "\x1b[0m"
52
+ const BAR = Bun.color("#52525b", "ansi") ?? ""
53
+ const CMD = Bun.color("#a1a1aa", "ansi") ?? ""
54
+ const OUT = Bun.color("#71717a", "ansi") ?? ""
55
+ const home = os.homedir()
56
+ const short = (s: string) => s.replaceAll(home, "~")
57
+
58
+ function run(...args: Parameters<typeof Bun.$>) {
59
+ // 默认抛错(沿用 Bun.$ 行为),服务通过 .nothrow() 显式忽略;.quiet() 抑制实时输出
60
+ if (!process.env.ATOMSERVICE_VERBOSE) return Reflect.apply(shell, undefined, args).quiet()
61
+
62
+ // verbose 模式:内部以 nothrow 捕获结果用于打印,再按需重新抛错
63
+ const cmd = Reflect.apply(shell, undefined, args).quiet().nothrow()
64
+ const strings = args[0] as TemplateStringsArray
65
+ const values = args.slice(1)
66
+ const cmdStr = strings.reduce(
67
+ (acc, str, i) =>
68
+ acc +
69
+ str +
70
+ (i < values.length ? (Array.isArray(values[i]) ? (values[i] as unknown[]).join(" ") : String(values[i])) : ""),
71
+ "",
72
+ )
73
+
74
+ // 惰性执行:仅在首次 await 时运行一次,throwOnError 由是否调用过 .nothrow() 决定
75
+ let throwOnError = true
76
+ let started: Promise<Bun.ShellOutput> | undefined
77
+ const exec = () => {
78
+ if (started) return started
79
+ started = (async () => {
80
+ process.stdout.write(`${BAR}│${RESET} ${CMD}$ ${short(cmdStr)}${RESET}\n`)
81
+ const result = await cmd
82
+ const printLines = (buf: Buffer | undefined) => {
83
+ for (const line of (buf?.toString() ?? "").split("\n")) {
84
+ const trimmed = line.trim()
85
+ if (!trimmed) continue
86
+ // 过滤 podman-compose 透传的 provider 提示噪音
87
+ if (trimmed.includes("Executing external compose provider")) continue
88
+ process.stdout.write(`${BAR}│${RESET} ${OUT}${short(line.trimEnd())}${RESET}\n`)
89
+ }
90
+ }
91
+ printLines(result.stdout)
92
+ printLines(result.stderr)
93
+ if (throwOnError && result.exitCode !== 0) {
94
+ throw new Error(result.stderr?.toString().trim() || `Exit code ${result.exitCode}`)
95
+ }
96
+ return result
97
+ })()
98
+ return started
99
+ }
100
+
101
+ const handle = {
102
+ quiet: () => handle,
103
+ nothrow: () => {
104
+ throwOnError = false
105
+ return handle
106
+ },
107
+ // biome-ignore lint/suspicious/noThenProperty: 实现 thenable 以在 verbose 模式拦截并打印 shell 输出
108
+ then: (onFulfilled?: (r: Bun.ShellOutput) => unknown, onRejected?: (e: unknown) => unknown) =>
109
+ exec().then(onFulfilled, onRejected),
110
+ catch: (onRejected?: (e: unknown) => unknown) => exec().catch(onRejected),
111
+ finally: (onFinally?: () => void) => exec().finally(onFinally),
112
+ text: () => exec().then((r) => r.text()),
113
+ blob: () => exec().then((r) => r.blob()),
114
+ json: () => exec().then((r) => r.json()),
115
+ arrayBuffer: () => exec().then((r) => r.arrayBuffer()),
116
+ }
117
+ return handle
118
+ }
119
+
120
+ return new Proxy(shell, {
121
+ apply(_target, _thisArg, args) {
122
+ return run(...(args as Parameters<typeof Bun.$>))
123
+ },
124
+ })
125
+ }
126
+
127
+ export function useConfig(): ResolvedAtomserviceConfig {
128
+ return getStore("useConfig").config
129
+ }
130
+
131
+ export function useService<ServiceInstance>(service: ServiceDefinition<ServiceInstance>): ServiceInstance {
132
+ const store = getStore("useService")
133
+ const id = resolveServiceId(service)
134
+ store.lifecycle.deps.add(id)
135
+ return new Proxy({} as Record<string, unknown>, {
136
+ get(_, prop: string) {
137
+ const api = apiRegistry.get(id) as Record<string, unknown> | undefined
138
+ if (!api) throw new Error(`服务 "${id}" 未暴露 API,或尚未完成初始化`)
139
+ const val = api[prop]
140
+ if (typeof val === "function") {
141
+ return (...args: unknown[]) => (val as (...a: unknown[]) => unknown)(...args)
142
+ }
143
+ return val
144
+ },
145
+ }) as unknown as ServiceInstance
146
+ }
147
+
148
+ export function resolveServiceId(service: Pick<AnyService, "name" | "id">): string {
149
+ return service.id !== "default" && service.id ? `${service.name}:${service.id}` : service.name
150
+ }
151
+
152
+ export async function runSetup(
153
+ service: AnyService,
154
+ config: ResolvedAtomserviceConfig,
155
+ logger: Logger,
156
+ ): Promise<ServiceLifecycle> {
157
+ const lifecycle: ServiceLifecycle = {
158
+ upHandlers: [],
159
+ downHandlers: [],
160
+ commands: [],
161
+ deps: new Set(),
162
+ }
163
+ const store: SetupStore = { lifecycle, config, logger, $: Bun.$, name: service.name, id: service.id }
164
+ const api = await setupStore.run(store, () => service._setup())
165
+ if (api != null) {
166
+ apiRegistry.set(resolveServiceId(service), api)
167
+ }
168
+ return lifecycle
169
+ }
170
+
171
+ export function _clearApiRegistry(): void {
172
+ apiRegistry.clear()
173
+ }
174
+
175
+ function printStateChangeWarning(
176
+ key: string,
177
+ risk: "low" | "high",
178
+ previousValue: unknown,
179
+ currentValue: unknown,
180
+ message: string,
181
+ ): void {
182
+ const riskLabel = risk === "high" ? ` ${BOLD}[高风险]${RESET}${YELLOW}` : ""
183
+ process.stdout.write(
184
+ `${YELLOW}${BOLD}⚠ 配置变更检测 [${key}]${riskLabel}${RESET}\n` +
185
+ `${DIM} 上次值 ${RESET}${String(previousValue ?? "(未设置)")}\n` +
186
+ `${DIM} 当前值 ${RESET}${String(currentValue ?? "(未设置)")}\n` +
187
+ `${DIM} 说明 ${RESET}${message}\n`,
188
+ )
189
+ }
190
+
191
+ async function askConfirmation(key: string): Promise<boolean> {
192
+ if (process.argv.includes("--yes")) return true
193
+ if (!process.stdout.isTTY) {
194
+ process.stdout.write(
195
+ `${YELLOW} [${key}] 非交互环境,高风险变更已自动中止。如需强制继续,添加 --yes 参数。${RESET}\n`,
196
+ )
197
+ return false
198
+ }
199
+ const result = await clack.confirm({ message: `确认继续启动?` })
200
+ if (clack.isCancel(result)) {
201
+ clack.cancel("已取消")
202
+ return false
203
+ }
204
+ return result
205
+ }
206
+
207
+ export function useState<TState extends Record<string, unknown>>(): ServiceStateHandle<TState> {
208
+ const store = getStore("useState")
209
+ const { config } = store
210
+
211
+ const dirName = store.id !== "default" && store.id ? `${store.name}-${store.id}` : store.name
212
+ const stateFilePath = path.join(config.root, dirName, "atomservice.state.json")
213
+
214
+ let prev: TState | null = null
215
+ try {
216
+ const content = readFileSync(stateFilePath, "utf-8")
217
+ prev = JSON.parse(content) as TState
218
+ } catch {
219
+ prev = null
220
+ }
221
+
222
+ const watchRules: Array<{ key: string; rule: ServiceStateWatchRule<TState, unknown> }> = []
223
+
224
+ return {
225
+ prev,
226
+
227
+ watch(key: string, rule: ServiceStateWatchRule<TState, unknown>) {
228
+ watchRules.push({ key, rule })
229
+ },
230
+
231
+ async checkChanges(currentState: TState) {
232
+ if (!prev) return
233
+
234
+ for (const { key, rule } of watchRules) {
235
+ const select = rule.select ?? ((s: TState) => s[key as keyof TState] as unknown)
236
+ const previousValue = select(prev)
237
+ const currentValue = select(currentState)
238
+
239
+ let message: string | undefined
240
+
241
+ if (previousValue == null && currentValue != null && rule.added) {
242
+ message = rule.added(currentValue)
243
+ } else if (previousValue != null && currentValue == null && rule.removed) {
244
+ message = rule.removed(previousValue)
245
+ } else if (previousValue !== currentValue && previousValue != null && currentValue != null && rule.changed) {
246
+ message = rule.changed(previousValue, currentValue)
247
+ }
248
+
249
+ if (!message) continue
250
+
251
+ const risk = rule.risk ?? "low"
252
+ printStateChangeWarning(key, risk, previousValue, currentValue, message)
253
+
254
+ if (risk === "high") {
255
+ const confirmed = await askConfirmation(key)
256
+ if (!confirmed) {
257
+ throw new UserError(`[${key}] 高风险变更未确认,启动已中止`)
258
+ }
259
+ }
260
+ }
261
+ },
262
+
263
+ async save(currentState: TState) {
264
+ await Bun.write(stateFilePath, JSON.stringify(currentState, null, 2))
265
+ },
266
+ } as ServiceStateHandle<TState>
267
+ }
@@ -0,0 +1,12 @@
1
+ import type { Logger } from "./core.types.ts"
2
+
3
+ export function createLogger(tag?: string): Logger {
4
+ const prefix = tag ? `[${tag}]` : ""
5
+ return {
6
+ info: (msg) => console.log(`${prefix} ${msg}`),
7
+ warn: (msg) => console.warn(`${prefix} ${msg}`),
8
+ error: (msg) => console.error(`${prefix} ${msg}`),
9
+ debug: (msg) => console.debug(`${prefix} ${msg}`),
10
+ success: (msg) => console.log(`${prefix} ${msg}`),
11
+ }
12
+ }
@@ -0,0 +1,79 @@
1
+ import { resolveServiceId, runSetup } from "./core.hooks.ts"
2
+ import { createLogger } from "./core.logger.ts"
3
+ import { topoSort } from "./core.topo.ts"
4
+ import type { HealthStatus, InitResult, Logger, ResolvedAtomserviceConfig, ServiceLifecycle } from "./core.types.ts"
5
+
6
+ async function initServices(config: ResolvedAtomserviceConfig): Promise<{ sorted: InitResult[] }> {
7
+ const results: InitResult[] = []
8
+ const lifecycleMap = new Map<string, ServiceLifecycle>()
9
+
10
+ for (const service of config.services) {
11
+ const id = resolveServiceId(service)
12
+ const logger = createLogger(id)
13
+ const lifecycle = await runSetup(service, config, logger)
14
+ lifecycleMap.set(id, lifecycle)
15
+ results.push({ serviceId: id, lifecycle, logger })
16
+ }
17
+
18
+ const sortedServices = topoSort(config.services, lifecycleMap)
19
+ const idToResult = new Map<string, InitResult>()
20
+ for (const r of results) {
21
+ idToResult.set(r.serviceId, r)
22
+ }
23
+
24
+ const sorted = sortedServices.map((s) => {
25
+ const id = resolveServiceId(s)
26
+ const r = idToResult.get(id)
27
+ if (!r) throw new Error(`内部错误:找不到服务 "${id}" 的初始化结果`)
28
+ return r
29
+ })
30
+
31
+ return { sorted }
32
+ }
33
+
34
+ export async function runUp(config: ResolvedAtomserviceConfig): Promise<void> {
35
+ const { sorted } = await initServices(config)
36
+ for (const { lifecycle } of sorted) {
37
+ for (const fn of lifecycle.upHandlers) {
38
+ await fn()
39
+ }
40
+ }
41
+ }
42
+
43
+ export async function runDown(config: ResolvedAtomserviceConfig): Promise<void> {
44
+ const { sorted } = await initServices(config)
45
+ for (const { lifecycle } of [...sorted].reverse()) {
46
+ for (const fn of lifecycle.downHandlers) {
47
+ await fn()
48
+ }
49
+ }
50
+ }
51
+
52
+ export async function runStatus(config: ResolvedAtomserviceConfig): Promise<Map<string, HealthStatus>> {
53
+ const { sorted } = await initServices(config)
54
+ const results = new Map<string, HealthStatus>()
55
+
56
+ await Promise.all(
57
+ sorted.map(async ({ serviceId, lifecycle }) => {
58
+ const status = lifecycle.healthHandler ? await lifecycle.healthHandler() : ({ status: "unknown" } as const)
59
+ results.set(serviceId, status)
60
+ }),
61
+ )
62
+
63
+ return results
64
+ }
65
+
66
+ export async function initForCli(config: ResolvedAtomserviceConfig): Promise<
67
+ Array<{
68
+ serviceId: string
69
+ state: ServiceLifecycle
70
+ logger: Logger
71
+ }>
72
+ > {
73
+ const { sorted } = await initServices(config)
74
+ return sorted.map(({ serviceId, lifecycle, logger }) => ({
75
+ serviceId,
76
+ state: lifecycle,
77
+ logger,
78
+ }))
79
+ }
@@ -0,0 +1,6 @@
1
+ import type { Primitive } from "./core.types.ts"
2
+
3
+ export function tmpl(strings: TemplateStringsArray, ...values: Primitive[]): string {
4
+ const result = strings.raw.reduce((acc, str, i) => `${acc}${str}${values[i] ?? ""}`, "")
5
+ return result.replace(/^\\\n/, "")
6
+ }
@@ -0,0 +1,141 @@
1
+ import { UserError } from "./core.errors.ts"
2
+ import { resolveServiceId } from "./core.hooks.ts"
3
+ import type { AnyService, ServiceLifecycle } from "./core.types.ts"
4
+
5
+ export function topoSort(services: AnyService[], stateMap: Map<string, ServiceLifecycle>): AnyService[] {
6
+ const idToService = new Map<string, AnyService>()
7
+ for (const a of services) {
8
+ idToService.set(resolveServiceId(a), a)
9
+ }
10
+
11
+ const byName = new Map<string, string[]>()
12
+ for (const id of idToService.keys()) {
13
+ const name = id.split(":")[0] ?? id
14
+ const list = byName.get(name) ?? []
15
+ list.push(id)
16
+ byName.set(name, list)
17
+ }
18
+
19
+ function resolveDepIds(dep: string): string[] {
20
+ if (idToService.has(dep)) return [dep]
21
+ return byName.get(dep) ?? []
22
+ }
23
+
24
+ const inDegree = new Map<string, number>()
25
+ const edges = new Map<string, string[]>() // from → dependants
26
+
27
+ for (const id of idToService.keys()) {
28
+ inDegree.set(id, 0)
29
+ edges.set(id, [])
30
+ }
31
+
32
+ for (const [id, state] of stateMap) {
33
+ for (const dep of state.deps) {
34
+ const depIds = resolveDepIds(dep)
35
+ if (depIds.length === 0) {
36
+ throw new UserError(`服务 "${id}" 声明了不存在的依赖 "${dep}"`)
37
+ }
38
+ for (const depId of depIds) {
39
+ edges.get(depId)?.push(id)
40
+ inDegree.set(id, (inDegree.get(id) ?? 0) + 1)
41
+ }
42
+ }
43
+ }
44
+
45
+ const queue: string[] = []
46
+ for (const [id, deg] of inDegree) {
47
+ if (deg === 0) queue.push(id)
48
+ }
49
+
50
+ const sorted: AnyService[] = []
51
+ while (queue.length > 0) {
52
+ const id = queue.shift()
53
+ if (id === undefined) break
54
+ const service = idToService.get(id)
55
+ if (service) sorted.push(service)
56
+ for (const dependant of edges.get(id) ?? []) {
57
+ const newDeg = (inDegree.get(dependant) ?? 0) - 1
58
+ inDegree.set(dependant, newDeg)
59
+ if (newDeg === 0) queue.push(dependant)
60
+ }
61
+ }
62
+
63
+ if (sorted.length !== services.length) {
64
+ throw new UserError("服务依赖存在循环,无法完成拓扑排序")
65
+ }
66
+
67
+ return sorted
68
+ }
69
+
70
+ /**
71
+ * 根据用户指定的服务名筛选要操作的服务子集
72
+ *
73
+ * - `direction: "deps"` — 含目标服务及其全部传递依赖(用于 up)
74
+ * - `direction: "dependents"` — 含目标服务及其全部传递下游(用于 down)
75
+ * - `only: true` — 仅目标服务本身,不做依赖联动
76
+ *
77
+ * 返回结果保持传入 `results` 的相对顺序
78
+ */
79
+ export function selectServices<Result extends { serviceId: string; state: ServiceLifecycle }>(
80
+ results: Result[],
81
+ names: string[],
82
+ opts: { direction: "deps" | "dependents"; only: boolean },
83
+ ): Result[] {
84
+ const ids = results.map((r) => r.serviceId)
85
+ const idSet = new Set(ids)
86
+
87
+ const byName = new Map<string, string[]>()
88
+ for (const id of ids) {
89
+ const name = id.split(":")[0] ?? id
90
+ const list = byName.get(name) ?? []
91
+ list.push(id)
92
+ byName.set(name, list)
93
+ }
94
+
95
+ const targets = new Set<string>()
96
+ for (const raw of names) {
97
+ if (idSet.has(raw)) {
98
+ targets.add(raw)
99
+ continue
100
+ }
101
+ const matched = byName.get(raw)
102
+ if (!matched || matched.length === 0) {
103
+ throw new UserError(`找不到服务 "${raw}"`, `已配置的服务:${ids.join("、") || "(无)"}`)
104
+ }
105
+ for (const id of matched) targets.add(id)
106
+ }
107
+
108
+ if (opts.only) {
109
+ return results.filter((r) => targets.has(r.serviceId))
110
+ }
111
+
112
+ const depsMap = new Map<string, Set<string>>()
113
+ const dependentsMap = new Map<string, Set<string>>()
114
+ for (const id of ids) {
115
+ depsMap.set(id, new Set())
116
+ dependentsMap.set(id, new Set())
117
+ }
118
+ for (const r of results) {
119
+ for (const dep of r.state.deps) {
120
+ const depIds = idSet.has(dep) ? [dep] : (byName.get(dep) ?? [])
121
+ for (const depId of depIds) {
122
+ depsMap.get(r.serviceId)?.add(depId)
123
+ dependentsMap.get(depId)?.add(r.serviceId)
124
+ }
125
+ }
126
+ }
127
+
128
+ const graph = opts.direction === "deps" ? depsMap : dependentsMap
129
+ const selected = new Set<string>()
130
+ const stack = [...targets]
131
+ while (stack.length > 0) {
132
+ const id = stack.pop()
133
+ if (id === undefined || selected.has(id)) continue
134
+ selected.add(id)
135
+ for (const next of graph.get(id) ?? []) {
136
+ if (!selected.has(next)) stack.push(next)
137
+ }
138
+ }
139
+
140
+ return results.filter((r) => selected.has(r.serviceId))
141
+ }
@@ -0,0 +1,81 @@
1
+ export interface Logger {
2
+ info(msg: string): void
3
+ warn(msg: string): void
4
+ error(msg: string): void
5
+ debug(msg: string): void
6
+ success(msg: string): void
7
+ }
8
+
9
+ export type HealthStatus =
10
+ | { status: "healthy"; message?: string }
11
+ | { status: "unhealthy"; message: string }
12
+ | { status: "unknown"; message?: string }
13
+
14
+ export interface ResolvedAtomserviceConfig {
15
+ root: string
16
+ services: AnyService[]
17
+ }
18
+
19
+ export interface AtomserviceConfig {
20
+ root?: string
21
+ services: AnyService[]
22
+ }
23
+
24
+ export interface ServiceDefinition<ServiceInstance = void> {
25
+ readonly name: string
26
+ readonly id: string
27
+ readonly _setup: () => Bun.MaybePromise<ServiceInstance>
28
+ }
29
+
30
+ export type AnyService = ServiceDefinition<unknown>
31
+
32
+ export interface ServiceCommand {
33
+ name: string
34
+ description: string
35
+ handler(args: Record<string, string>): Bun.MaybePromise<void>
36
+ }
37
+
38
+ export interface ServiceLifecycle {
39
+ upHandlers: Array<() => Bun.MaybePromise<void>>
40
+ downHandlers: Array<() => Bun.MaybePromise<void>>
41
+ healthHandler?: () => Bun.MaybePromise<HealthStatus>
42
+ commands: ServiceCommand[]
43
+ deps: Set<string>
44
+ }
45
+
46
+ export type ServiceStateWatchRisk = "low" | "high"
47
+
48
+ export interface ServiceStateWatchRule<TState, TValue> {
49
+ select?: (state: TState) => TValue
50
+ risk?: ServiceStateWatchRisk
51
+ added?: (currentValue: TValue) => string
52
+ removed?: (previousValue: TValue) => string
53
+ changed?: (previousValue: TValue, currentValue: TValue) => string
54
+ }
55
+
56
+ export interface ServiceStateHandle<TState extends Record<string, unknown>> {
57
+ readonly prev: TState | null
58
+ watch<TKey extends keyof TState & string>(key: TKey, rule: ServiceStateWatchRule<TState, TState[TKey]>): void
59
+ watch<TValue>(key: string, rule: ServiceStateWatchRule<TState, TValue> & { select: (state: TState) => TValue }): void
60
+ checkChanges(currentState: TState): Promise<void>
61
+ save(currentState: TState): Promise<void>
62
+ }
63
+
64
+ export type Primitive = string | number | boolean
65
+
66
+ export interface InitResult {
67
+ serviceId: string
68
+ lifecycle: ServiceLifecycle
69
+ logger: Logger
70
+ }
71
+
72
+ export interface SetupStore {
73
+ lifecycle: ServiceLifecycle
74
+ config: ResolvedAtomserviceConfig
75
+ logger: Logger
76
+ $: typeof Bun.$
77
+ name: string
78
+ id: string
79
+ }
80
+
81
+ export type CallableService<ServiceInstance = void> = ServiceDefinition<ServiceInstance> & (() => ServiceInstance)
package/src/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ export { loadConfig } from "./core.config.ts"
2
+ export { ATOMSERVICE_DIR } from "./core.consts.ts"
3
+ export { defineConfig, defineService } from "./core.define.ts"
4
+ export { UserError } from "./core.errors.ts"
5
+ export {
6
+ addCommand,
7
+ onDown,
8
+ onHealth,
9
+ onUp,
10
+ resolveServiceId,
11
+ useConfig,
12
+ useLogger,
13
+ useService,
14
+ useShell,
15
+ useState,
16
+ } from "./core.hooks.ts"
17
+ export { createLogger } from "./core.logger.ts"
18
+ export { initForCli, runDown, runStatus, runUp } from "./core.runner.ts"
19
+ export { tmpl } from "./core.tmpl.ts"
20
+ export { selectServices, topoSort } from "./core.topo.ts"
21
+ export type {
22
+ AnyService,
23
+ AtomserviceConfig,
24
+ CallableService,
25
+ HealthStatus,
26
+ InitResult,
27
+ Logger,
28
+ ResolvedAtomserviceConfig,
29
+ ServiceCommand,
30
+ ServiceDefinition,
31
+ ServiceLifecycle,
32
+ ServiceStateHandle,
33
+ ServiceStateWatchRisk,
34
+ ServiceStateWatchRule,
35
+ } from "./core.types.ts"