@toist/aja 0.6.1 → 0.8.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/CHANGELOG.md CHANGED
@@ -2,6 +2,62 @@
2
2
 
3
3
  All notable changes to `@toist/aja` are recorded here.
4
4
 
5
+ ## 0.8.0 — 2026-05-08
6
+
7
+ Major refactor: `@toist/aja` is now the SQLite host package, with the
8
+ execution kernel extracted to `@toist/core`.
9
+
10
+ **New:**
11
+
12
+ - `createSqliteRuntime({ rootDir, ... })` — durable runtime constructor.
13
+ Owns lock acquisition, migrations, db handle opening, store wiring,
14
+ registry projection, resource resolution. Returns a `ToistRuntime`
15
+ with `close()`.
16
+ - `mountApi(runtime, options): Hono` factory replaces module-level
17
+ state in `server.ts`. `startRunner` builds a runtime then calls
18
+ `mountApi`.
19
+ - `loadFilesystemResources(rootDir)` — reads `toist.yml`, performs
20
+ `${env:VAR}` and `${env:VAR:-default}` substitution, fails closed
21
+ at config-load time on missing required vars.
22
+ - `kindAllowlist` field in `instance.json` filters the registry
23
+ projection at construction time. Glob support (`agent.*`).
24
+ - `StartRunnerOptions.storage: "sqlite" | "memory"` flag.
25
+ - `StartRunnerOptions.runtime?: ToistRuntime` late-bound injection.
26
+ - `HostedRuntime` interface (extends `ToistRuntime` with optional
27
+ `close`, `adminDb`, `pipelinesDir`).
28
+ - `createFilesystemPipelineStore({ rootDir, registry, watch })` for
29
+ YAML pipeline loading + watching.
30
+ - `POST /api/runs` async ad-hoc spec-run endpoint.
31
+ - `GET /api/runs/:id/events` SSE event stream.
32
+ - `client.pipelines.runSpec(spec, payload)` and `client.runs.events(runId)`.
33
+ - `/manifest` response carries `version` + `schema` URL for stable
34
+ downstream consumption.
35
+
36
+ **Removed:**
37
+
38
+ - `db-handles.ts`, `config.ts` (process-global `setRootDir` etc.),
39
+ `runtime.ts` (`createProcessRuntime`). Replaced by explicit
40
+ `createSqliteRuntime`.
41
+ - Local `pipeline.ts`, `hitl.ts`, `runs.ts`, `kinds/{control,data,db,hitl,
42
+ http,runs,transform,types}.ts` — all moved to `@toist/core`.
43
+ `@toist/aja` re-exports the kernel surface.
44
+ - Filesystem `resources/*.yaml` loader (replaced by `toist.yml`).
45
+
46
+ **Renamed:**
47
+
48
+ - `instance.ts` → `instance-metadata.ts` (frees the name for a possible
49
+ future `@toist/instance` package).
50
+
51
+ **New dependency:** `@toist/core@0.8.0`.
52
+
53
+ ## 0.7.1 — 2026-05-06
54
+
55
+ Fix @toist/aja cross-package dependency versions not being bumped by release script
56
+
57
+ ## 0.7.0 — 2026-05-06
58
+
59
+ Add @toist/aja CLI (bunx @toist/aja --config toist.yml) and @toist/in interactive setup wizard (bunx @toist/in). Establishes aja=runner, in=adoption tool split. No forced directory layout — user chooses paths via wizard, config drives the runner.
60
+
5
61
  ## Unreleased
6
62
 
7
63
  ## 0.6.1 — 2026-05-05
@@ -69,7 +125,7 @@ monorepo per Phase F W2 and shipped to the Gitea npm registry per W3.
69
125
 
70
126
  - Public API: `startRunner(options): Promise<RunnerHandle>`,
71
127
  `register(...kinds)`, `getKind(id)`, `manifest()`. See instance-spec at
72
- https://toist.in for the full contract.
128
+ https://toist.in/docs for the full contract.
73
129
  - HTTP server (Hono on Bun) — mounts API at `/api/*` and serves the
74
130
  bundled UI from `@toist/ui`'s `distDir` at `/`.
75
131
  - Pipeline dispatcher with HITL-aware suspendable executor (per-node
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # `@toist/aja`
2
+
3
+ Durable Toist host surface: SQLite runtime, HTTP runner, UI mount, filesystem helpers, and the typed runner client.
4
+
5
+ ```ts
6
+ import { startRunner } from "@toist/aja"
7
+
8
+ await startRunner({
9
+ rootDir: import.meta.dir,
10
+ port: 3000,
11
+ })
12
+ ```
13
+
14
+ ## Main exports
15
+
16
+ - `startRunner(options)`
17
+ - `createSqliteRuntime(options)`
18
+ - `createFilesystemPipelineStore({ rootDir, watch? })`
19
+ - `loadFilesystemResources(rootDir)`
20
+ - `createRunnerClient({ baseUrl })`
21
+ - convenience re-exports from `@toist/core`
22
+
23
+ ## Client quickstart
24
+
25
+ ```ts
26
+ import { createRunnerClient } from "@toist/aja"
27
+
28
+ const client = createRunnerClient({ baseUrl: "http://localhost:3000" })
29
+ const started = await client.pipelines.runSpec(spec, { name: "world" })
30
+ for await (const event of client.runs.events(started.id)) {
31
+ console.log(event.type)
32
+ }
33
+ ```
34
+
35
+ ## Runtime layout
36
+
37
+ With `startRunner({ rootDir, port })`, the runner:
38
+
39
+ - loads pipelines from `<rootDir>/pipelines`
40
+ - stores runtime state under `<rootDir>/data`
41
+ - reads resources from `<rootDir>/toist.yml`
42
+ - serves UI + API from one Bun process
43
+
44
+ ## `/manifest`
45
+
46
+ `GET /api/manifest` returns stable JSON for tooling:
47
+
48
+ ```json
49
+ {
50
+ "version": 1,
51
+ "schema": "https://toist.in/schemas/manifest-v1.json",
52
+ "kinds": []
53
+ }
54
+ ```
55
+
56
+ ## Resources
57
+
58
+ `loadFilesystemResources(rootDir)` reads `toist.yml` and resolves `${env:VAR}` and `${env:VAR:-fallback}` substitutions.
59
+
60
+ ## When to use `@toist/aja`
61
+
62
+ Use it when you want a durable single-tenant runner with HTTP APIs. For in-process execution without the server, import `@toist/core` directly.
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@toist/aja",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
7
+ "bin": {
8
+ "toist-aja": "./src/cli.ts"
9
+ },
7
10
  "exports": {
8
11
  ".": "./src/index.ts",
9
12
  "./client": "./src/client.ts"
@@ -11,17 +14,20 @@
11
14
  "files": [
12
15
  "src/",
13
16
  "migrations/",
14
- "CHANGELOG.md"
17
+ "CHANGELOG.md",
18
+ "README.md"
15
19
  ],
16
20
  "scripts": {
17
21
  "dev": "bun --watch src/server.ts",
18
22
  "smoke:client": "bun test/client-smoke.ts"
19
23
  },
20
24
  "dependencies": {
21
- "@toist/spec": "0.6.1",
22
- "@toist/ui": "0.6.1",
25
+ "@toist/core": "0.8.0",
26
+ "@toist/spec": "0.8.0",
27
+ "@toist/ui": "0.8.0",
23
28
  "hono": "^4.7.7",
24
- "proper-lockfile": "^4.1.2"
29
+ "proper-lockfile": "^4.1.2",
30
+ "yaml": "^2.8.4"
25
31
  },
26
32
  "devDependencies": {
27
33
  "@types/proper-lockfile": "^4.1.4"
package/src/cache-db.ts CHANGED
@@ -1,17 +1,9 @@
1
1
  // 2121
2
- // Cache database — separate file from runtime.db so its drop-recreate
3
- // semantics (TTL makes content valueless) do not entangle runtime durability.
4
- //
5
- // Schema is owned and bootstrapped by makeCache() in cache.ts. No migrations
6
- // here: cache table can be dropped or recreated freely.
7
-
8
2
  import { Database } from "bun:sqlite"
9
3
  import { mkdirSync } from "node:fs"
10
4
  import { dirname } from "node:path"
11
- import { cacheDbPath } from "./config.ts"
12
5
 
13
- export function openCacheDb(): Database {
14
- const path = cacheDbPath()
6
+ export function openCacheDb(path: string): Database {
15
7
  mkdirSync(dirname(path), { recursive: true })
16
8
  return new Database(path, { create: true })
17
9
  }
package/src/cli.ts ADDED
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env bun
2
+ // 2121 toist
3
+ // @toist/aja CLI — start a Toist runner from a config file.
4
+ //
5
+ // Usage:
6
+ // bunx @toist/aja auto-discover toist.yml in cwd
7
+ // bunx @toist/aja --config <path> explicit config file
8
+
9
+ import { existsSync, readFileSync } from "node:fs"
10
+ import { dirname, isAbsolute, resolve } from "node:path"
11
+ import YAML from "yaml"
12
+ import { createSqliteRuntime, register, startRunner } from "./index.ts"
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Config schema
16
+ // ---------------------------------------------------------------------------
17
+
18
+ interface ToistConfig {
19
+ /** TCP port. Env PORT or TOIST_PORT takes precedence. */
20
+ port?: number
21
+ /** Root directory for default path resolution. Defaults to config file dir. */
22
+ root?: string
23
+ /** Pipeline YAML directory. Default: <root>/pipelines */
24
+ pipelines?: string
25
+ /** Resource path override (legacy). Default: <root>/resources */
26
+ resources?: string | Record<string, Record<string, unknown>>
27
+ /** Data directory (SQLite files). Default: <root>/data */
28
+ data?: string
29
+ /** TypeScript kind files to import and register before starting. */
30
+ kinds?: string[]
31
+ /** Disable the bundled UI. */
32
+ disableUi?: boolean
33
+ /** Disable filesystem watch on pipelines/resources. */
34
+ disableWatch?: boolean
35
+ /** Disable the MCP server. */
36
+ disableMcp?: boolean
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // CLI args
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const args = process.argv.slice(2)
44
+
45
+ if (args.includes("--help") || args.includes("-h")) {
46
+ console.log("Usage:")
47
+ console.log(" bunx @toist/aja auto-discover toist.yml in cwd")
48
+ console.log(" bunx @toist/aja --config <path> explicit config file")
49
+ console.log("")
50
+ console.log("Config file format (YAML):")
51
+ console.log(" port: 3000")
52
+ console.log(" root: . # base for all relative paths")
53
+ console.log(" pipelines: ./pipelines # override pipeline dir")
54
+ console.log(" resources: ./resources # override resource dir")
55
+ console.log(" data: ./data # override data dir")
56
+ console.log(" kinds: # TypeScript kind files to register")
57
+ console.log(" - ./src/kinds/index.ts")
58
+ console.log("")
59
+ console.log("Environment:")
60
+ console.log(" TOIST_PORT / PORT Override port from config")
61
+ console.log("")
62
+ console.log("Full docs: https://toist.in/docs")
63
+ process.exit(0)
64
+ }
65
+
66
+ const configIdx = args.indexOf("--config")
67
+ const configArg = configIdx !== -1 ? args[configIdx + 1] : null
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Config discovery
71
+ // ---------------------------------------------------------------------------
72
+
73
+ const CONFIG_NAMES = ["toist.yml", "toist.yaml"]
74
+
75
+ function findConfig(): string {
76
+ if (configArg) {
77
+ const p = resolve(process.cwd(), configArg)
78
+ if (!existsSync(p)) {
79
+ console.error(`Config file not found: ${p}`)
80
+ process.exit(1)
81
+ }
82
+ return p
83
+ }
84
+ for (const name of CONFIG_NAMES) {
85
+ const p = resolve(process.cwd(), name)
86
+ if (existsSync(p)) return p
87
+ }
88
+ console.error("No toist.yml found in current directory.")
89
+ console.error("")
90
+ console.error("Create one, or use: bunx @toist/aja --config <path>")
91
+ console.error("To scaffold a new Toist setup: bunx @toist/in")
92
+ process.exit(1)
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Path resolution — all paths relative to config file directory
97
+ // ---------------------------------------------------------------------------
98
+
99
+ function resolvePath(configDir: string, root: string, p: string | undefined, defaultSub: string): string {
100
+ if (p) return isAbsolute(p) ? p : resolve(configDir, p)
101
+ return resolve(configDir, root, defaultSub)
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Main
106
+ // ---------------------------------------------------------------------------
107
+
108
+ const configPath = findConfig()
109
+ const configDir = dirname(configPath)
110
+ const raw = readFileSync(configPath, "utf8")
111
+ const cfg = (YAML.parse(raw) ?? {}) as ToistConfig
112
+
113
+ const root = cfg.root ? resolve(configDir, cfg.root) : configDir
114
+
115
+ const pipelineDir = resolvePath(configDir, root, cfg.pipelines, "pipelines")
116
+ const resourceDir = resolvePath(configDir, root, typeof cfg.resources === "string" ? cfg.resources : undefined, "resources")
117
+ const dataDir = resolvePath(configDir, root, cfg.data, "data")
118
+ const port = Number(process.env.TOIST_PORT ?? process.env.PORT ?? cfg.port ?? 3000)
119
+
120
+ // Register kinds from config — dynamic imports resolved relative to config dir.
121
+ if (cfg.kinds && cfg.kinds.length > 0) {
122
+ for (const kindPath of cfg.kinds) {
123
+ const abs = isAbsolute(kindPath) ? kindPath : resolve(configDir, kindPath)
124
+ if (!existsSync(abs)) {
125
+ console.error(`Kind file not found: ${abs}`)
126
+ process.exit(1)
127
+ }
128
+ const mod = await import(abs)
129
+ const kinds = Object.values(mod).filter(Boolean)
130
+ if (kinds.length === 0) {
131
+ console.warn(`[toist] warning: no exports found in kind file ${abs}`)
132
+ } else {
133
+ register(...(kinds as Parameters<typeof register>))
134
+ }
135
+ }
136
+ }
137
+
138
+ void cfg.disableMcp
139
+
140
+ const runtime = await createSqliteRuntime({
141
+ rootDir: root,
142
+ pipelinesDir: pipelineDir,
143
+ resourcesDir: resourceDir,
144
+ dataDir,
145
+ })
146
+
147
+ await startRunner({
148
+ port,
149
+ rootDir: root,
150
+ runtime,
151
+ disableUi: cfg.disableUi ?? false,
152
+ disableWatch: cfg.disableWatch ?? false,
153
+ })
package/src/client.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  // that want to drive the runner over its HTTP API without hard-coding paths.
6
6
 
7
7
  import type { NodeKindManifest, ValidateResult } from "@toist/spec"
8
+ import type { ToistEvent } from "@toist/core"
8
9
 
9
10
  export interface RunnerClientOptions {
10
11
  /** Runner API base URL. Accepts either the API root
@@ -74,6 +75,12 @@ export type PipelineRunResponse =
74
75
  | { id: number; pipeline: string; status: "suspended"; suspendedAt: string; task: PipelineTaskRef; steps: StepResult[] }
75
76
  | { id: number; pipeline: string; status: "error"; error: string }
76
77
 
78
+ export interface AsyncRunStartResponse {
79
+ id: number
80
+ pipeline: string
81
+ status: "running"
82
+ }
83
+
77
84
  export interface RunListItem {
78
85
  id: number
79
86
  pipeline: string
@@ -161,12 +168,14 @@ export interface RunnerClient {
161
168
  create(yaml: string): Promise<PipelineSaveResult>
162
169
  update(id: string, yaml: string): Promise<PipelineSaveResult>
163
170
  run(id: string, payload?: Record<string, unknown>): Promise<PipelineRunResponse>
171
+ runSpec(spec: unknown, payload?: Record<string, unknown>): Promise<AsyncRunStartResponse>
164
172
  }
165
173
  runs: {
166
174
  list(opts?: { pipeline?: string; limit?: number }): Promise<RunListItem[]>
167
175
  get(id: number): Promise<RunListItem>
168
176
  nodes(id: number): Promise<RunNodeOutput[]>
169
177
  logs(id: number): Promise<RunLogLine[]>
178
+ events(id: number): AsyncIterable<ToistEvent>
170
179
  }
171
180
  tasks: {
172
181
  list(opts?: { status?: string; runId?: number; assignee?: string; limit?: number }): Promise<TaskListItem[]>
@@ -234,6 +243,10 @@ export function createRunnerClient(options: RunnerClientOptions): RunnerClient {
234
243
  method: "POST",
235
244
  body: json(payload),
236
245
  }),
246
+ runSpec: (spec, payload = {}) => request<AsyncRunStartResponse>("/runs", {
247
+ method: "POST",
248
+ body: json({ spec, payload }),
249
+ }),
237
250
  },
238
251
 
239
252
  runs: {
@@ -241,6 +254,7 @@ export function createRunnerClient(options: RunnerClientOptions): RunnerClient {
241
254
  get: (id) => request<RunListItem>(`/runs/${id}`),
242
255
  nodes: (id) => request<RunNodeOutput[]>(`/runs/${id}/nodes`),
243
256
  logs: (id) => request<RunLogLine[]>(`/runs/${id}/logs`),
257
+ events: (id) => streamEvents(f, `${base}/runs/${id}/events`, options.headers),
244
258
  },
245
259
 
246
260
  tasks: {
@@ -303,3 +317,65 @@ async function readJsonOrText(response: Response): Promise<unknown> {
303
317
  try { return JSON.parse(text) }
304
318
  catch { return text }
305
319
  }
320
+
321
+ function streamEvents(
322
+ f: typeof fetch,
323
+ url: string,
324
+ extraHeaders?: HeadersInit,
325
+ ): AsyncIterable<ToistEvent> {
326
+ return {
327
+ [Symbol.asyncIterator]() {
328
+ let reader: ReadableStreamDefaultReader<Uint8Array> | null = null
329
+ let buffer = ""
330
+ const decoder = new TextDecoder()
331
+ const queue: ToistEvent[] = []
332
+ let finished = false
333
+
334
+ async function ensureReader() {
335
+ if (reader) return reader
336
+ const headers = new Headers(extraHeaders)
337
+ headers.set("accept", "text/event-stream")
338
+ const response = await f(url, { headers })
339
+ if (!response.ok) {
340
+ const body = await readJsonOrText(response)
341
+ throw new RunnerHttpError(response.status, response.statusText, body)
342
+ }
343
+ if (!response.body) throw new Error("runner returned no SSE body")
344
+ reader = response.body.getReader()
345
+ return reader
346
+ }
347
+
348
+ async function pump(): Promise<void> {
349
+ const r = await ensureReader()
350
+ while (queue.length === 0 && !finished) {
351
+ const { value, done } = await r.read()
352
+ if (done) {
353
+ finished = true
354
+ return
355
+ }
356
+ buffer += decoder.decode(value, { stream: true })
357
+ let boundary = buffer.indexOf("\n\n")
358
+ while (boundary !== -1) {
359
+ const frame = buffer.slice(0, boundary)
360
+ buffer = buffer.slice(boundary + 2)
361
+ const data = frame
362
+ .split("\n")
363
+ .filter((line) => line.startsWith("data:"))
364
+ .map((line) => line.slice(5).trim())
365
+ .join("\n")
366
+ if (data) queue.push(JSON.parse(data) as ToistEvent)
367
+ boundary = buffer.indexOf("\n\n")
368
+ }
369
+ }
370
+ }
371
+
372
+ return {
373
+ async next(): Promise<IteratorResult<ToistEvent>> {
374
+ await pump()
375
+ if (queue.length > 0) return { value: queue.shift()!, done: false }
376
+ return { value: undefined as never, done: true }
377
+ },
378
+ }
379
+ },
380
+ }
381
+ }
package/src/data-db.ts CHANGED
@@ -1,21 +1,9 @@
1
1
  // 2121
2
- // Domain data store. The instance owns the schema entirely — every table here
3
- // is created by a pipeline (via db.insert) or by a domain migration the project
4
- // chooses to add. Platform never writes to this DB directly.
5
- //
6
- // Kinds receive this as `ctx.db`. Pipelines write to it (db.insert), read
7
- // from it (db.query), or any custom domain kind interacts with it as needed.
8
- //
9
- // Location: <dataDir>/data.db (per context/instance-spec.md §3 — the host's
10
- // schema-on-write domain DB lives in data/ alongside runtime.db and cache.db).
11
-
12
2
  import { Database } from "bun:sqlite"
13
3
  import { mkdirSync } from "node:fs"
14
4
  import { dirname } from "node:path"
15
- import { dataDbPath } from "./config.ts"
16
5
 
17
- export function openDataDb(): Database {
18
- const path = dataDbPath()
6
+ export function openDataDb(path: string): Database {
19
7
  mkdirSync(dirname(path), { recursive: true })
20
8
  return new Database(path, { create: true })
21
9
  }
package/src/index.ts CHANGED
@@ -9,6 +9,9 @@
9
9
 
10
10
  // Lifecycle entry — what hosts call to start an instance.
11
11
  export { startRunner, type StartRunnerOptions, type RunnerHandle } from "./startRunner.ts"
12
+ export { createSqliteRuntime, type CreateSqliteRuntimeOptions } from "./sqlite-runtime.ts"
13
+ export { loadFilesystemResources } from "./resources-fs.ts"
14
+ export { createFilesystemPipelineStore, type FilesystemPipelineStore } from "./pipeline-store.ts"
12
15
 
13
16
  // Typed HTTP client — what external hosts/tools use to drive an instance.
14
17
  export {
@@ -22,6 +25,7 @@ export {
22
25
  type StepResult,
23
26
  type PipelineTaskRef,
24
27
  type PipelineRunResponse,
28
+ type AsyncRunStartResponse,
25
29
  type RunListItem,
26
30
  type RunNodeOutput,
27
31
  type RunLogLine,
@@ -31,15 +35,44 @@ export {
31
35
  type ResourceRecord,
32
36
  } from "./client.ts"
33
37
 
34
- // Kind registration — must be called before startRunner.
35
- export { register, getKind, manifest } from "./kinds/index.ts"
38
+ // Kernel convenience re-exports.
39
+ export {
40
+ builtinKinds,
41
+ createRegistry,
42
+ createMemoryRuntime,
43
+ createEventStream,
44
+ runSpec,
45
+ register,
46
+ getKind,
47
+ manifest,
48
+ } from "@toist/core"
36
49
 
37
50
  // Type re-exports from @toist/spec, so a host that imports only
38
51
  // @toist/aja doesn't need a second package on its dependency list when
39
52
  // it only writes kinds (rather than authoring pipeline-format tooling).
53
+ export type {
54
+ ToistRuntime,
55
+ ResumableRuntime,
56
+ RunLedger,
57
+ TaskStore,
58
+ LogStore,
59
+ NodeOutputStore,
60
+ ResourceResolver,
61
+ KindRegistry,
62
+ RunCtx,
63
+ RunOutcome,
64
+ RunOptions,
65
+ ToistEvent,
66
+ EventSink,
67
+ RunSpecOptions,
68
+ RunSpecResult,
69
+ } from "@toist/core"
70
+
40
71
  export type {
41
72
  NodeKind,
42
73
  NodeKindManifest,
74
+ DataHandle,
75
+ DataStatement,
43
76
  ParamDef,
44
77
  PortDef,
45
78
  ExecContext,
@@ -0,0 +1,48 @@
1
+ // 2121
2
+ // Per-instance metadata: what kind of platform-instance this is (which
3
+ // customer, what tier, what teasers to surface), distinct from the runtime
4
+ // state in runtime.db.
5
+
6
+ import { existsSync, readFileSync } from "node:fs"
7
+ import { join } from "node:path"
8
+
9
+ export interface Teaser {
10
+ id: string
11
+ title: string
12
+ description?: string
13
+ category?: string
14
+ }
15
+
16
+ export interface Instance {
17
+ platformVersion?: string
18
+ instanceName?: string
19
+ tier?: string
20
+ kindAllowlist?: string[]
21
+ teasers: Teaser[]
22
+ }
23
+
24
+ const DEFAULTS: Instance = { teasers: [] }
25
+
26
+ // Re-read on every call. instance.json is tiny and changes rarely; the cost
27
+ // is negligible and avoids stale-cache surprises when the file is edited.
28
+ export function loadInstance(rootDir: string): Instance {
29
+ const path = join(rootDir, "instance.json")
30
+ if (!existsSync(path)) return DEFAULTS
31
+
32
+ try {
33
+ const raw = readFileSync(path, "utf8")
34
+ const parsed = JSON.parse(raw) as Partial<Instance>
35
+ return {
36
+ platformVersion: parsed.platformVersion,
37
+ instanceName: parsed.instanceName,
38
+ tier: parsed.tier,
39
+ kindAllowlist: Array.isArray(parsed.kindAllowlist)
40
+ ? parsed.kindAllowlist.filter((value): value is string => typeof value === "string")
41
+ : undefined,
42
+ teasers: Array.isArray(parsed.teasers) ? parsed.teasers : [],
43
+ }
44
+ } catch (err) {
45
+ console.warn(`[instance] failed to read ${path}:`, (err as Error).message)
46
+ return DEFAULTS
47
+ }
48
+ }