@astrale-os/sdk 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.
Files changed (103) hide show
  1. package/dist/cli/bin.d.ts +7 -0
  2. package/dist/cli/bin.d.ts.map +1 -0
  3. package/dist/cli/bin.js +15 -0
  4. package/dist/cli/bin.js.map +1 -0
  5. package/dist/cli/dotenv.d.ts +13 -0
  6. package/dist/cli/dotenv.d.ts.map +1 -0
  7. package/dist/cli/dotenv.js +46 -0
  8. package/dist/cli/dotenv.js.map +1 -0
  9. package/dist/cli/index.d.ts +15 -0
  10. package/dist/cli/index.d.ts.map +1 -0
  11. package/dist/cli/index.js +15 -0
  12. package/dist/cli/index.js.map +1 -0
  13. package/dist/cli/run.d.ts +79 -0
  14. package/dist/cli/run.d.ts.map +1 -0
  15. package/dist/cli/run.js +569 -0
  16. package/dist/cli/run.js.map +1 -0
  17. package/dist/cli/spec.d.ts +19 -0
  18. package/dist/cli/spec.d.ts.map +1 -0
  19. package/dist/cli/spec.js +31 -0
  20. package/dist/cli/spec.js.map +1 -0
  21. package/dist/config/adapter.d.ts +140 -0
  22. package/dist/config/adapter.d.ts.map +1 -0
  23. package/dist/config/adapter.js +40 -0
  24. package/dist/config/adapter.js.map +1 -0
  25. package/dist/config/define-domain.d.ts +112 -0
  26. package/dist/config/define-domain.d.ts.map +1 -0
  27. package/dist/config/define-domain.js +98 -0
  28. package/dist/config/define-domain.js.map +1 -0
  29. package/dist/config/deploy.d.ts +28 -0
  30. package/dist/config/deploy.d.ts.map +1 -0
  31. package/dist/config/deploy.js +24 -0
  32. package/dist/config/deploy.js.map +1 -0
  33. package/dist/config/index.d.ts +21 -0
  34. package/dist/config/index.d.ts.map +1 -0
  35. package/dist/config/index.js +18 -0
  36. package/dist/config/index.js.map +1 -0
  37. package/dist/define/remote-function.d.ts +19 -11
  38. package/dist/define/remote-function.d.ts.map +1 -1
  39. package/dist/define/remote-function.js.map +1 -1
  40. package/dist/dispatch/call-remote.d.ts +7 -3
  41. package/dist/dispatch/call-remote.d.ts.map +1 -1
  42. package/dist/dispatch/call-remote.js.map +1 -1
  43. package/dist/dispatch/dispatcher.d.ts.map +1 -1
  44. package/dist/dispatch/dispatcher.js +8 -4
  45. package/dist/dispatch/dispatcher.js.map +1 -1
  46. package/dist/dispatch/index.d.ts +1 -1
  47. package/dist/dispatch/index.d.ts.map +1 -1
  48. package/dist/dispatch/index.js.map +1 -1
  49. package/dist/dispatch/self.d.ts +46 -10
  50. package/dist/dispatch/self.d.ts.map +1 -1
  51. package/dist/dispatch/self.js +65 -8
  52. package/dist/dispatch/self.js.map +1 -1
  53. package/dist/domain/define.d.ts +3 -3
  54. package/dist/domain/define.js +3 -3
  55. package/dist/index.d.ts +5 -4
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +8 -2
  58. package/dist/index.js.map +1 -1
  59. package/dist/method/class.d.ts.map +1 -1
  60. package/dist/method/class.js.map +1 -1
  61. package/dist/method/context.d.ts +32 -7
  62. package/dist/method/context.d.ts.map +1 -1
  63. package/dist/method/index.d.ts +1 -1
  64. package/dist/method/index.d.ts.map +1 -1
  65. package/dist/method/single.d.ts +16 -11
  66. package/dist/method/single.d.ts.map +1 -1
  67. package/dist/method/single.js.map +1 -1
  68. package/dist/server/domain-entry.d.ts +67 -0
  69. package/dist/server/domain-entry.d.ts.map +1 -0
  70. package/dist/server/domain-entry.js +58 -0
  71. package/dist/server/domain-entry.js.map +1 -0
  72. package/dist/server/index.d.ts +3 -1
  73. package/dist/server/index.d.ts.map +1 -1
  74. package/dist/server/index.js +2 -1
  75. package/dist/server/index.js.map +1 -1
  76. package/dist/server/worker-entry.d.ts +40 -5
  77. package/dist/server/worker-entry.d.ts.map +1 -1
  78. package/dist/server/worker-entry.js +68 -19
  79. package/dist/server/worker-entry.js.map +1 -1
  80. package/package.json +12 -3
  81. package/src/cli/bin.ts +15 -0
  82. package/src/cli/dotenv.ts +45 -0
  83. package/src/cli/index.ts +15 -0
  84. package/src/cli/run.ts +675 -0
  85. package/src/cli/spec.ts +42 -0
  86. package/src/config/adapter.ts +172 -0
  87. package/src/config/define-domain.ts +218 -0
  88. package/src/config/deploy.ts +35 -0
  89. package/src/config/index.ts +31 -0
  90. package/src/define/remote-function.ts +42 -13
  91. package/src/dispatch/call-remote.ts +7 -2
  92. package/src/dispatch/dispatcher.ts +8 -4
  93. package/src/dispatch/index.ts +1 -1
  94. package/src/dispatch/self.ts +96 -10
  95. package/src/domain/define.ts +3 -3
  96. package/src/index.ts +25 -4
  97. package/src/method/class.ts +4 -3
  98. package/src/method/context.ts +38 -7
  99. package/src/method/index.ts +1 -1
  100. package/src/method/single.ts +30 -11
  101. package/src/server/domain-entry.ts +113 -0
  102. package/src/server/index.ts +3 -1
  103. package/src/server/worker-entry.ts +80 -20
package/src/cli/run.ts ADDED
@@ -0,0 +1,675 @@
1
+ /**
2
+ * The `astrale-domain` CLI — dev | build | deploy.
3
+ *
4
+ * Thin by design: it reads `astrale.config.ts`, resolves `envs[<env>] → params`
5
+ * via the adapter, loads the env's secrets file, then drives `adapter.watch`
6
+ * (dev) or `adapter.deploy` (build+ship). It prints the resulting URL and the
7
+ * exact `astrale domain install <url> --direct` line. It boots no kernel and knows
8
+ * nothing provider-specific — that all lives in the adapter.
9
+ *
10
+ * The diagnostic spec (`.astrale/spec.json`) is written AFTER watch/deploy
11
+ * returns, at the live URL: the install graph (and therefore `schemaHash`)
12
+ * embeds `binding.remoteUrl`s derived from the serving URL, so a spec built at
13
+ * a guessed URL would carry a hash that never matches the worker's `/meta`.
14
+ * Only `astrale-domain build` (no URL exists yet) uses an explicit placeholder
15
+ * and says so.
16
+ *
17
+ * astrale-domain dev # = deploy dev --watch
18
+ * astrale-domain prod # = deploy prod
19
+ * astrale-domain deploy <env> # any env key
20
+ * astrale-domain build # rebuild spec only (placeholder URL)
21
+ * astrale-domain publish [env] # = deploy [prod] then register the URL
22
+ *
23
+ * `publish` (and the `--publish` tail-flag on deploy) deploys, then registers
24
+ * the resulting URL in the admin catalog by SHELLING OUT to the operator CLI
25
+ * (`astrale domain publish --origin --name --public-url`). Auth lives entirely
26
+ * in that CLI — this build tool never touches credentials; it just hands off the
27
+ * fresh URL it alone knows. Requires `astrale` on PATH (the same CLI the deploy
28
+ * footer already points you at for `domain install`).
29
+ */
30
+
31
+ import { spawn } from 'node:child_process'
32
+ import { existsSync, readFileSync, watch as watchFs } from 'node:fs'
33
+ import { mkdir, writeFile } from 'node:fs/promises'
34
+ import { createRequire } from 'node:module'
35
+ import { basename, dirname, isAbsolute, join } from 'node:path'
36
+ import { pathToFileURL } from 'node:url'
37
+
38
+ import type { DeployResult, DomainAdapter, DomainInfo, WatchHandle } from '../config/adapter'
39
+ import type { DeployConfig } from '../config/deploy'
40
+
41
+ import { loadDotenvFile } from './dotenv'
42
+ import { buildProjectSpec } from './spec'
43
+
44
+ const CONFIG_NAMES = ['astrale.config.ts', 'astrale.config.js', 'astrale.config.mjs']
45
+
46
+ type ParsedArgs = {
47
+ command: 'dev' | 'build' | 'deploy'
48
+ env: string
49
+ watch: boolean
50
+ /** `--port <n>` (dev only) — overrides the env's local dev port. */
51
+ port?: number
52
+ /** `--host <url>` (dev only) — public URL of a tunnel/proxy front: binds 0.0.0.0 + pins WORKER_URL. */
53
+ host?: string
54
+ /** Register the deployed URL in the admin catalog (the `publish` command / `--publish` flag). */
55
+ publish?: boolean
56
+ /** `--name <slug>` — registry name to publish under (default: package.json `name`). */
57
+ name?: string
58
+ /** `--install-by-default` — mark the published domain for install on every new instance. */
59
+ installByDefault?: boolean
60
+ }
61
+
62
+ export async function run(argv: readonly string[]): Promise<number> {
63
+ // `--help` anywhere prints usage — previously `prod --help` IGNORED the
64
+ // flag and started a real deploy (observed in an external test run).
65
+ if (argv.includes('--help') || argv.includes('-h')) {
66
+ printUsage()
67
+ return 0
68
+ }
69
+ let parsed: ParsedArgs
70
+ try {
71
+ parsed = parseArgs(argv)
72
+ } catch (err) {
73
+ printUsage((err as Error).message)
74
+ return 2
75
+ }
76
+
77
+ // The CLI imports the project's TypeScript modules (astrale.config.ts, the ★
78
+ // files) directly — that requires a TS-native runtime. Fail with the remedy
79
+ // instead of a cryptic ERR_UNKNOWN_FILE_EXTENSION deep in `import()`.
80
+ if (!process.versions.bun) {
81
+ error(
82
+ 'astrale-domain requires Bun (it imports your astrale.config.ts directly). ' +
83
+ 'Install it from https://bun.sh, then re-run.',
84
+ )
85
+ return 1
86
+ }
87
+
88
+ const projectDir = process.cwd()
89
+ const configPath = findConfig(projectDir)
90
+ if (!configPath) {
91
+ error(`No astrale.config.ts found in ${projectDir}. Run this from a domain project root.`)
92
+ return 1
93
+ }
94
+
95
+ let def: DeployConfig
96
+ try {
97
+ def = await loadConfig(configPath)
98
+ } catch (err) {
99
+ // Adapter-swap papercut: editing the `adapter:` call without the import
100
+ // (or vice versa) surfaces as a bare ReferenceError. Name the real fix.
101
+ const m = err instanceof Error ? /^(\w+) is not defined/.exec(err.message) : null
102
+ if (m) {
103
+ error(
104
+ `astrale.config.ts references \`${m[1]}\` but never imports it.\n` +
105
+ ` If you swapped adapters (cloudflare ⇄ astrale), update BOTH lines:\n` +
106
+ ` import { ${m[1]} } from '@astrale-os/adapter-cloudflare'\n` +
107
+ ` adapter: ${m[1]}({ ... })`,
108
+ )
109
+ return 1
110
+ }
111
+ throw err
112
+ }
113
+ const specPath = join(projectDir, '.astrale', 'spec.json')
114
+
115
+ if (parsed.command === 'build') {
116
+ // Spec-only: no adapter params, no secrets, no deploy → no real URL.
117
+ // Build at an explicit placeholder and say so: the schemaHash of this
118
+ // spec will NOT match a live worker's /meta (the install graph embeds
119
+ // URL-derived bindings).
120
+ const placeholderUrl = `https://${def.origin}`
121
+ try {
122
+ await writeSpec({ projectDir, specPath, def, url: placeholderUrl })
123
+ } catch (err) {
124
+ error(`Spec build failed: ${(err as Error).message}`)
125
+ return 1
126
+ }
127
+ info(`Built spec → ${rel(projectDir, specPath)} (origin: ${def.origin})`)
128
+ info(
129
+ `note: built at placeholder ${placeholderUrl} — its schemaHash differs from a deployed worker.`,
130
+ )
131
+ return 0
132
+ }
133
+
134
+ const adapter = def.adapter as DomainAdapter<unknown>
135
+ // CLI param overrides — applied over the env's params here AND re-applied by
136
+ // the config hot-regen path, which re-resolves `adapter.params` from the
137
+ // fresh config (without them, the first config edit would silently drop the
138
+ // flags — e.g. lose the `--host`-pinned WORKER_URL and drift the iss).
139
+ const paramOverrides: Record<string, unknown> = {}
140
+ // CLI port override beats the env's configured port (both adapters read
141
+ // `params.port` in watch).
142
+ if (parsed.command === 'dev' && parsed.port !== undefined) paramOverrides.port = parsed.port
143
+ // `--host <public-url>`: off-localhost dev (sandbox preview, tunnel). The
144
+ // adapter binds 0.0.0.0 and pins WORKER_URL so the worker's per-request-Host
145
+ // identity doesn't drift to the proxy's internal hostname.
146
+ if (parsed.command === 'dev' && parsed.host !== undefined) paramOverrides.host = parsed.host
147
+ const params = { ...(adapter.params(parsed.env) as Record<string, unknown>), ...paramOverrides }
148
+
149
+ const domain = toDomainInfo(def)
150
+
151
+ // The client SPA is a DECLARED binding (`defineDomain({ client })`), not a
152
+ // folder we sniff for: its presence + source dir come from the definition.
153
+ const clientDir = def.client ? join(projectDir, def.client.dir) : undefined
154
+ const secrets = loadSecrets(adapter, params, projectDir)
155
+
156
+ if (parsed.command === 'dev' || (parsed.command === 'deploy' && parsed.watch)) {
157
+ return runWatch({
158
+ adapter,
159
+ params,
160
+ paramOverrides,
161
+ projectDir,
162
+ specPath,
163
+ clientDir,
164
+ secrets,
165
+ domain,
166
+ env: parsed.env,
167
+ def,
168
+ configPath,
169
+ })
170
+ }
171
+
172
+ // deploy (no watch)
173
+ const result = await adapter.deploy(params, {
174
+ projectDir,
175
+ specPath,
176
+ secrets,
177
+ ...(clientDir ? { clientDir } : {}),
178
+ domain,
179
+ env: parsed.env,
180
+ })
181
+ await writeSpecBestEffort({ projectDir, specPath, def, url: result.url })
182
+ printDeployed(result, def)
183
+
184
+ if (parsed.publish) {
185
+ const pkg = readPackageMeta(projectDir)
186
+ // The registry `name` is a short catalog slug, distinct from the FQDN-like
187
+ // `origin`. It isn't in the domain definition, so default to the project's
188
+ // package.json `name`, then the origin's first label; `--name` overrides.
189
+ const name = parsed.name ?? pkg.name ?? def.origin.split('.')[0] ?? def.origin
190
+ return await publishToAdmin({
191
+ origin: def.origin,
192
+ name,
193
+ url: result.url,
194
+ ...(pkg.description ? { description: pkg.description } : {}),
195
+ ...(parsed.installByDefault ? { installByDefault: true } : {}),
196
+ })
197
+ }
198
+ return 0
199
+ }
200
+
201
+ /**
202
+ * Register the just-deployed URL in the admin catalog by shelling out to the
203
+ * operator CLI (`astrale domain publish`). Deliberately a subprocess, not a
204
+ * library call: all credential/admin-target resolution lives in `@astrale-os/
205
+ * astrale` and we don't want it (or a kernel client) pulled into this build
206
+ * tool — we only own the fresh URL. A missing `astrale` is reported with the
207
+ * exact line to run by hand, so a deploy that already succeeded isn't a dead end.
208
+ */
209
+ async function publishToAdmin(args: {
210
+ origin: string
211
+ name: string
212
+ url: string
213
+ description?: string
214
+ installByDefault?: boolean
215
+ }): Promise<number> {
216
+ // `--public-url`, not `--url`: the operator CLI reserves `--url` for kernel
217
+ // targeting, so the domain's public address rides its own flag.
218
+ const cliArgs = [
219
+ 'domain',
220
+ 'publish',
221
+ '--origin',
222
+ args.origin,
223
+ '--name',
224
+ args.name,
225
+ '--public-url',
226
+ args.url,
227
+ ]
228
+ if (args.description) cliArgs.push('--description', args.description)
229
+ if (args.installByDefault) cliArgs.push('--install-by-default')
230
+
231
+ info(`publishing to the admin catalog → astrale ${cliArgs.join(' ')}`)
232
+ return await new Promise<number>((resolveCode) => {
233
+ const child = spawn('astrale', cliArgs, { stdio: 'inherit' })
234
+ child.on('error', (err) => {
235
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
236
+ error(
237
+ `\`astrale\` CLI not found on PATH. The deploy succeeded — publish it by hand:\n` +
238
+ ` astrale ${cliArgs.join(' ')}`,
239
+ )
240
+ } else {
241
+ error(`failed to run \`astrale domain publish\`: ${err.message}`)
242
+ }
243
+ resolveCode(1)
244
+ })
245
+ child.on('exit', (code) => resolveCode(code ?? 1))
246
+ })
247
+ }
248
+
249
+ /** Best-effort read of the project's package.json `name`/`description` (publish defaults). */
250
+ function readPackageMeta(projectDir: string): { name?: string; description?: string } {
251
+ try {
252
+ const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8')) as {
253
+ name?: string
254
+ description?: string
255
+ }
256
+ return { name: pkg.name, description: pkg.description }
257
+ } catch {
258
+ return {}
259
+ }
260
+ }
261
+
262
+ async function runWatch(args: {
263
+ adapter: DomainAdapter<unknown>
264
+ params: unknown
265
+ paramOverrides: Record<string, unknown>
266
+ projectDir: string
267
+ specPath: string
268
+ clientDir?: string
269
+ secrets: Record<string, string>
270
+ domain: DomainInfo
271
+ env: string
272
+ def: DeployConfig
273
+ configPath: string
274
+ }): Promise<number> {
275
+ const { adapter, params, projectDir, specPath, clientDir, secrets, domain, env, def } = args
276
+ let reloads = 0
277
+ const onReload = () => {
278
+ reloads += 1
279
+ info(`↻ reloaded (#${reloads}) — same URL, no reinstall needed for handler edits`)
280
+ }
281
+ const handle: WatchHandle = await adapter.watch(params, {
282
+ projectDir,
283
+ specPath,
284
+ ...(clientDir ? { clientDir } : {}),
285
+ secrets,
286
+ domain,
287
+ env,
288
+ onReload,
289
+ })
290
+ await writeSpecBestEffort({ projectDir, specPath, def, url: handle.url })
291
+ printDevReady(handle.url, domain)
292
+
293
+ const stopConfigWatch = watchConfigForRegen({
294
+ configPath: args.configPath,
295
+ initial: def,
296
+ env,
297
+ paramOverrides: args.paramOverrides,
298
+ projectDir,
299
+ specPath,
300
+ ...(clientDir ? { clientDir } : {}),
301
+ url: handle.url,
302
+ onReload,
303
+ })
304
+
305
+ await new Promise<void>((resolveStop) => {
306
+ const stop = () => {
307
+ stopConfigWatch()
308
+ void handle.stop().finally(resolveStop)
309
+ }
310
+ process.on('SIGINT', stop)
311
+ process.on('SIGTERM', stop)
312
+ })
313
+ return 0
314
+ }
315
+
316
+ /**
317
+ * Watch `astrale.config.ts` while `dev` runs: on change, re-import the config
318
+ * (cache-busted), re-resolve env params + secrets, and re-run the adapter's
319
+ * codegen via `adapter.regenerate` — the running dev server (e.g. `wrangler
320
+ * dev`, which watches its generated config + entry) reloads them itself, so a
321
+ * config edit lands without restarting the CLI.
322
+ *
323
+ * Watches the config's DIRECTORY, not the file: editors save via atomic
324
+ * rename, which silently kills an inode-bound watch.
325
+ *
326
+ * Deliberate limits, surfaced to the user instead of half-applied:
327
+ * • origin / adapter changes re-key the worker's identity → restart;
328
+ * • a mid-edit broken config (syntax error, bad env) warns and keeps the
329
+ * previous generation running;
330
+ * • modules the config IMPORTS (e.g. the schema) stay module-cached — only
331
+ * the config file itself is re-evaluated, which covers everything
332
+ * `defineDomain` owns (vars, requires, postInstall, wrangler overlay…).
333
+ */
334
+ export function watchConfigForRegen(args: {
335
+ configPath: string
336
+ initial: DeployConfig
337
+ env: string
338
+ /** CLI flag overrides (dev --port / --host) — re-applied on every regen. */
339
+ paramOverrides: Record<string, unknown>
340
+ projectDir: string
341
+ specPath: string
342
+ clientDir?: string
343
+ url: string
344
+ onReload: () => void
345
+ }): () => void {
346
+ let running = false
347
+ let pending = false
348
+ let stopped = false
349
+ let timer: ReturnType<typeof setTimeout> | undefined
350
+
351
+ async function regen(): Promise<void> {
352
+ if (stopped) return
353
+ if (running) {
354
+ pending = true
355
+ return
356
+ }
357
+ running = true
358
+ try {
359
+ const def = await loadConfig(args.configPath, { fresh: true })
360
+ if (def.origin !== args.initial.origin || def.adapter.name !== args.initial.adapter.name) {
361
+ warn(
362
+ `astrale.config.ts: \`origin\` or adapter changed — that re-keys the worker's ` +
363
+ `identity; restart \`astrale-domain dev\` to apply.`,
364
+ )
365
+ return
366
+ }
367
+ const adapter = def.adapter as DomainAdapter<unknown>
368
+ if (!adapter.regenerate) {
369
+ warn(
370
+ `astrale.config.ts changed, but the "${adapter.name}" adapter has no \`regenerate\` — ` +
371
+ `restart \`astrale-domain dev\` to apply.`,
372
+ )
373
+ return
374
+ }
375
+ const params = {
376
+ ...(adapter.params(args.env) as Record<string, unknown>),
377
+ ...args.paramOverrides,
378
+ }
379
+ const domain = toDomainInfo(def)
380
+ await adapter.regenerate(params, {
381
+ projectDir: args.projectDir,
382
+ specPath: args.specPath,
383
+ ...(args.clientDir ? { clientDir: args.clientDir } : {}),
384
+ secrets: loadSecrets(adapter, params, args.projectDir),
385
+ domain,
386
+ env: args.env,
387
+ onReload: args.onReload,
388
+ })
389
+ await writeSpecBestEffort({
390
+ projectDir: args.projectDir,
391
+ specPath: args.specPath,
392
+ def,
393
+ url: args.url,
394
+ })
395
+ info(
396
+ `↻ astrale.config.ts changed — codegen regenerated, the dev server reloads it ` +
397
+ `(a port / workerName / remote change still needs a restart)`,
398
+ )
399
+ } catch (err) {
400
+ warn(
401
+ `astrale.config.ts changed but reloading it failed (previous config stays live): ` +
402
+ `${(err as Error).message}`,
403
+ )
404
+ } finally {
405
+ running = false
406
+ if (pending && !stopped) {
407
+ pending = false
408
+ void regen()
409
+ }
410
+ }
411
+ }
412
+
413
+ const configFile = basename(args.configPath)
414
+ const watcher = watchFs(dirname(args.configPath), (_event, file) => {
415
+ if (file && file !== configFile) return
416
+ clearTimeout(timer)
417
+ // Debounce: editors fire several fs events per save (write + rename).
418
+ timer = setTimeout(() => void regen(), 200)
419
+ })
420
+
421
+ return () => {
422
+ stopped = true
423
+ clearTimeout(timer)
424
+ watcher.close()
425
+ }
426
+ }
427
+
428
+ // ── spec ─────────────────────────────────────────────────────────────────
429
+
430
+ async function writeSpec(args: {
431
+ projectDir: string
432
+ specPath: string
433
+ def: DeployConfig
434
+ url: string
435
+ }): Promise<void> {
436
+ const { wire, schemaHash } = await buildProjectSpec(args.def, args.url)
437
+ await mkdir(dirname(args.specPath), { recursive: true })
438
+ await writeFile(args.specPath, JSON.stringify({ ...wire, schemaHash }, null, 2))
439
+ }
440
+
441
+ /**
442
+ * Diagnostic-spec write for dev/deploy: best-effort — the worker builds the
443
+ * live install bundle itself, so a malformed project still runs/deploys; we
444
+ * warn and continue.
445
+ */
446
+ async function writeSpecBestEffort(args: {
447
+ projectDir: string
448
+ specPath: string
449
+ def: DeployConfig
450
+ url: string
451
+ }): Promise<void> {
452
+ try {
453
+ await writeSpec(args)
454
+ } catch (err) {
455
+ const msg = (err as Error).message
456
+ warn(`(non-fatal) skipped the local diagnostic spec — the worker builds the real one: ${msg}`)
457
+ if (msg.includes('MISSING_DEF')) {
458
+ warn(
459
+ ` MISSING_DEF here usually means TWO copies of @astrale-os/kernel-core are loaded ` +
460
+ `(linked dev deps). Try \`pnpm dedupe\`; the deploy itself is unaffected.`,
461
+ )
462
+ }
463
+ }
464
+ }
465
+
466
+ // ── helpers ────────────────────────────────────────────────────────────────
467
+
468
+ function loadSecrets(
469
+ adapter: DomainAdapter<unknown>,
470
+ params: unknown,
471
+ projectDir: string,
472
+ ): Record<string, string> {
473
+ const file = adapter.secretsFile?.(params)
474
+ if (!file) return {}
475
+ const abs = isAbsolute(file) ? file : join(projectDir, file)
476
+ return loadDotenvFile(abs)
477
+ }
478
+
479
+ function findConfig(dir: string): string | undefined {
480
+ for (const name of CONFIG_NAMES) {
481
+ const candidate = join(dir, name)
482
+ if (existsSync(candidate)) return candidate
483
+ }
484
+ return undefined
485
+ }
486
+
487
+ function toDomainInfo(def: DeployConfig): DomainInfo {
488
+ return {
489
+ origin: def.origin,
490
+ requires: def.requires,
491
+ ...(def.postInstall ? { postInstall: def.postInstall } : {}),
492
+ // Presence comes from the definition the author wired — never a folder probe.
493
+ hasViews: def.views !== undefined,
494
+ hasFunctions: def.functions !== undefined,
495
+ hasClient: def.client !== undefined,
496
+ hasDeps: def.deps !== undefined,
497
+ }
498
+ }
499
+
500
+ async function loadConfig(path: string, opts?: { fresh: boolean }): Promise<DeployConfig> {
501
+ // Re-importing an EDITED file: Bun's ESM registry is keyed by path and never
502
+ // re-reads the source — a `?reload=N` query re-EVALUATES but serves the
503
+ // CACHED (stale) source. Bun unifies that registry with `require.cache`, so
504
+ // deleting the entry forces a fresh read on the next import (the CLI is
505
+ // Bun-gated, see `run()`). Modules the config itself imports stay cached —
506
+ // the documented hot-regen limit.
507
+ if (opts?.fresh) delete createRequire(import.meta.url).cache[path]
508
+ const mod = (await import(pathToFileURL(path).href)) as { default?: unknown }
509
+ const def = mod.default
510
+ if (!def || typeof def !== 'object' || !('adapter' in def) || !('origin' in def)) {
511
+ throw new Error(
512
+ `${path} must \`export default deploy(domain, adapter)\` from '@astrale-os/sdk' ` +
513
+ `(where \`domain\` is a \`defineDomain({ ... })\` from your domain.ts).`,
514
+ )
515
+ }
516
+ return def as DeployConfig
517
+ }
518
+
519
+ /** Require a plausible public URL for `--host`; default the scheme to https. */
520
+ function normalizeHost(raw: string | undefined): string {
521
+ if (!raw || raw.startsWith('-')) throw new Error(`--host needs a public URL, got "${raw ?? ''}"`)
522
+ const url = /^https?:\/\//.test(raw) ? raw : `https://${raw}`
523
+ try {
524
+ return new URL(url).origin
525
+ } catch {
526
+ throw new Error(`--host needs a valid URL, got "${raw}"`)
527
+ }
528
+ }
529
+
530
+ export function parseArgs(argv: readonly string[]): ParsedArgs {
531
+ const [cmd, ...rest] = argv
532
+ const watch = rest.includes('--watch')
533
+ // Tail-flag (deploy/prod/publish): register the deployed URL in the admin
534
+ // catalog after a successful deploy. The `publish` command implies it.
535
+ const publish = rest.includes('--publish')
536
+ const installByDefault = rest.includes('--install-by-default')
537
+ // `--port <n>` / `--port=<n>`: consume the flag AND its value so neither
538
+ // leaks into the positionals (a bare `8788` would be read as the env name).
539
+ let port: number | undefined
540
+ let host: string | undefined
541
+ let name: string | undefined
542
+ const cleaned: string[] = []
543
+ for (let i = 0; i < rest.length; i++) {
544
+ const a = rest[i]!
545
+ if (a === '--port') {
546
+ const v = rest[++i]
547
+ port = Number(v)
548
+ if (!Number.isInteger(port) || port <= 0)
549
+ throw new Error(`--port needs a port number, got "${v}"`)
550
+ } else if (a.startsWith('--port=')) {
551
+ port = Number(a.slice('--port='.length))
552
+ if (!Number.isInteger(port) || port <= 0)
553
+ throw new Error(`--port needs a port number, got "${a}"`)
554
+ } else if (a === '--host') {
555
+ host = normalizeHost(rest[++i])
556
+ } else if (a.startsWith('--host=')) {
557
+ host = normalizeHost(a.slice('--host='.length))
558
+ } else if (a === '--name') {
559
+ name = rest[++i]
560
+ if (!name) throw new Error('--name needs a value (the registry slug to publish under)')
561
+ } else if (a.startsWith('--name=')) {
562
+ name = a.slice('--name='.length)
563
+ } else {
564
+ cleaned.push(a)
565
+ }
566
+ }
567
+ const positionals = cleaned.filter((a) => !a.startsWith('-'))
568
+
569
+ // Publish-related fields shared by deploy/prod/publish (publish itself is
570
+ // gated per-command: opt-in via `--publish`, always-on for `publish`).
571
+ const pub = {
572
+ ...(name !== undefined ? { name } : {}),
573
+ ...(installByDefault ? { installByDefault } : {}),
574
+ }
575
+
576
+ switch (cmd) {
577
+ case 'dev':
578
+ return {
579
+ command: 'dev',
580
+ env: positionals[0] ?? 'dev',
581
+ watch: true,
582
+ ...(port !== undefined ? { port } : {}),
583
+ ...(host !== undefined ? { host } : {}),
584
+ }
585
+ case 'prod':
586
+ return { command: 'deploy', env: 'prod', watch, ...(publish ? { publish } : {}), ...pub }
587
+ case 'deploy': {
588
+ const env = positionals[0]
589
+ if (!env) throw new Error('`deploy` requires an env key, e.g. `deploy prod`.')
590
+ return { command: 'deploy', env, watch, ...(publish ? { publish } : {}), ...pub }
591
+ }
592
+ case 'publish':
593
+ // Sugar for `deploy [prod] --publish`: deploy, then register the URL.
594
+ return {
595
+ command: 'deploy',
596
+ env: positionals[0] ?? 'prod',
597
+ watch: false,
598
+ publish: true,
599
+ ...pub,
600
+ }
601
+ case 'build':
602
+ // Spec-only — no env resolution happens on this path.
603
+ return { command: 'build', env: 'dev', watch: false }
604
+ default:
605
+ throw new Error(cmd ? `Unknown command "${cmd}".` : 'Missing command.')
606
+ }
607
+ }
608
+
609
+ function rel(from: string, to: string): string {
610
+ return to.startsWith(from) ? `.${to.slice(from.length)}` : to
611
+ }
612
+
613
+ // ── output ───────────────────────────────────────────────────────────────
614
+
615
+ const GREEN = '\x1b[32m'
616
+ const DIM = '\x1b[2m'
617
+ const BOLD = '\x1b[1m'
618
+ const YELLOW = '\x1b[33m'
619
+ const RED = '\x1b[31m'
620
+ const RESET = '\x1b[0m'
621
+
622
+ function info(msg: string): void {
623
+ process.stdout.write(`${DIM}›${RESET} ${msg}\n`)
624
+ }
625
+ function warn(msg: string): void {
626
+ process.stderr.write(`${YELLOW}!${RESET} ${msg}\n`)
627
+ }
628
+ function error(msg: string): void {
629
+ process.stderr.write(`${RED}✗${RESET} ${msg}\n`)
630
+ }
631
+
632
+ function printDevReady(url: string, domain: DomainInfo): void {
633
+ process.stdout.write(
634
+ `\n${GREEN}✓${RESET} ${BOLD}${domain.origin}${RESET} running (dev, hot-reload)\n` +
635
+ ` ${BOLD}URL${RESET} ${GREEN}${url}${RESET}\n` +
636
+ ` ${DIM}install on an instance:${RESET}\n` +
637
+ ` ${BOLD}astrale domain install ${url} --direct${RESET}\n` +
638
+ ` ${DIM}edit a handler → reloads at the same URL. edit the schema → reinstall.${RESET}\n\n`,
639
+ )
640
+ }
641
+
642
+ function printDeployed(result: DeployResult, def: DeployConfig): void {
643
+ // Adapters that already completed the install (managed deploys) supply
644
+ // their own next-steps; the default hint would tell the user to redo it.
645
+ const footer =
646
+ result.nextSteps !== undefined
647
+ ? result.nextSteps === ''
648
+ ? ''
649
+ : `${result.nextSteps}\n`
650
+ : ` ${DIM}install on an instance:${RESET}\n` +
651
+ ` ${BOLD}astrale domain install ${result.url} --direct${RESET}\n`
652
+ process.stdout.write(
653
+ `\n${GREEN}✓${RESET} ${BOLD}${def.origin}${RESET} deployed\n` +
654
+ ` ${BOLD}URL${RESET} ${GREEN}${result.url}${RESET}\n` +
655
+ footer +
656
+ `\n`,
657
+ )
658
+ }
659
+
660
+ function printUsage(msg?: string): void {
661
+ if (msg) error(msg)
662
+ process.stderr.write(
663
+ `\nUsage:\n` +
664
+ ` astrale-domain dev # deploy dev --watch (hot-reload, prints URL)\n` +
665
+ ` astrale-domain prod # deploy prod\n` +
666
+ ` astrale-domain deploy <env> # deploy any env key (--watch optional)\n` +
667
+ ` astrale-domain publish [env] # deploy [prod], then register the URL in the admin catalog\n` +
668
+ ` astrale-domain build # rebuild the diagnostic spec only\n` +
669
+ `\nFlags:\n` +
670
+ ` --publish # on deploy/prod: register the deployed URL (= the publish command)\n` +
671
+ ` --name <slug> # registry name to publish under (default: package.json name)\n` +
672
+ ` --install-by-default # mark the published domain for install on every new instance\n` +
673
+ `\n publish shells out to \`astrale domain publish\` (needs \`astrale\` on PATH).\n\n`,
674
+ )
675
+ }