@astrale-os/cli 0.4.0-alpha.13

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 (219) hide show
  1. package/.check-workspace.cjs +40 -0
  2. package/README.md +151 -0
  3. package/dist/astrale.js +59749 -0
  4. package/package.json +90 -0
  5. package/src/command.ts +40 -0
  6. package/src/commands/__tests__/admin-instance.test.ts +73 -0
  7. package/src/commands/__tests__/auth-login.test.ts +178 -0
  8. package/src/commands/__tests__/auth-token.test.ts +223 -0
  9. package/src/commands/__tests__/call.test.ts +72 -0
  10. package/src/commands/__tests__/domain-list.test.ts +74 -0
  11. package/src/commands/__tests__/help-contract.test.ts +136 -0
  12. package/src/commands/__tests__/install-identity-override.test.ts +65 -0
  13. package/src/commands/__tests__/instance-bookmark.test.ts +101 -0
  14. package/src/commands/__tests__/instance-create-hosts.test.ts +29 -0
  15. package/src/commands/__tests__/instance-list-rows.test.ts +63 -0
  16. package/src/commands/__tests__/logs.test.ts +117 -0
  17. package/src/commands/__tests__/ls.test.ts +25 -0
  18. package/src/commands/__tests__/setup-plan.test.ts +61 -0
  19. package/src/commands/admin/status.ts +61 -0
  20. package/src/commands/admin/use.ts +77 -0
  21. package/src/commands/auth/login.ts +82 -0
  22. package/src/commands/auth/logout.ts +50 -0
  23. package/src/commands/auth/status.ts +86 -0
  24. package/src/commands/auth/token.ts +162 -0
  25. package/src/commands/browser.ts +207 -0
  26. package/src/commands/call.ts +300 -0
  27. package/src/commands/describe.ts +182 -0
  28. package/src/commands/domain/install.ts +420 -0
  29. package/src/commands/domain/list.ts +154 -0
  30. package/src/commands/domain/publish.ts +155 -0
  31. package/src/commands/get.ts +60 -0
  32. package/src/commands/identity/create.ts +26 -0
  33. package/src/commands/identity/delete.ts +18 -0
  34. package/src/commands/identity/export.ts +66 -0
  35. package/src/commands/identity/import.ts +101 -0
  36. package/src/commands/identity/list.ts +56 -0
  37. package/src/commands/identity/register.ts +170 -0
  38. package/src/commands/identity/sync.ts +32 -0
  39. package/src/commands/identity/unsync.ts +24 -0
  40. package/src/commands/identity/use.ts +18 -0
  41. package/src/commands/identity/whoami.ts +34 -0
  42. package/src/commands/idp/add.ts +150 -0
  43. package/src/commands/idp/list.ts +57 -0
  44. package/src/commands/idp/refresh.ts +38 -0
  45. package/src/commands/idp/remove.ts +36 -0
  46. package/src/commands/idp/show.ts +29 -0
  47. package/src/commands/instance/active.ts +64 -0
  48. package/src/commands/instance/bookmark.ts +72 -0
  49. package/src/commands/instance/create.ts +69 -0
  50. package/src/commands/instance/delete.ts +72 -0
  51. package/src/commands/instance/forget.ts +26 -0
  52. package/src/commands/instance/list.ts +149 -0
  53. package/src/commands/instance/status.ts +42 -0
  54. package/src/commands/instance/use.ts +210 -0
  55. package/src/commands/logs.ts +347 -0
  56. package/src/commands/ls.ts +229 -0
  57. package/src/commands/query.ts +32 -0
  58. package/src/commands/setup.ts +54 -0
  59. package/src/commands/status.ts +60 -0
  60. package/src/commands/studio.ts +401 -0
  61. package/src/commands/token.ts +77 -0
  62. package/src/commands/update.ts +267 -0
  63. package/src/commands/use.ts +87 -0
  64. package/src/errors.ts +65 -0
  65. package/src/kernel/__tests__/auth.test.ts +77 -0
  66. package/src/kernel/__tests__/errors.test.ts +43 -0
  67. package/src/kernel/__tests__/remote-routing.test.ts +70 -0
  68. package/src/kernel/auth.ts +234 -0
  69. package/src/kernel/ca-fetch.ts +119 -0
  70. package/src/kernel/client.ts +191 -0
  71. package/src/kernel/errors.ts +280 -0
  72. package/src/kernel/expand.ts +217 -0
  73. package/src/kernel/index.ts +14 -0
  74. package/src/kernel/options.ts +22 -0
  75. package/src/kernel/remote-routing.ts +88 -0
  76. package/src/kernel/run.ts +63 -0
  77. package/src/kernel/types.ts +14 -0
  78. package/src/lib/__tests__/admin-target.test.ts +112 -0
  79. package/src/lib/__tests__/binary.test.ts +56 -0
  80. package/src/lib/__tests__/command-dx.test.ts +58 -0
  81. package/src/lib/__tests__/concurrency.test.ts +62 -0
  82. package/src/lib/__tests__/config.test.ts +53 -0
  83. package/src/lib/__tests__/design.test.ts +99 -0
  84. package/src/lib/__tests__/domain-identity.test.ts +60 -0
  85. package/src/lib/__tests__/format.test.ts +22 -0
  86. package/src/lib/__tests__/fs-atomic.test.ts +104 -0
  87. package/src/lib/__tests__/identity.test.ts +79 -0
  88. package/src/lib/__tests__/idp-session.driver.ts +53 -0
  89. package/src/lib/__tests__/idp-session.test.ts +357 -0
  90. package/src/lib/__tests__/idp.test.ts +385 -0
  91. package/src/lib/__tests__/instance-candidates.test.ts +73 -0
  92. package/src/lib/__tests__/instance-target.test.ts +183 -0
  93. package/src/lib/__tests__/instance.test.ts +136 -0
  94. package/src/lib/__tests__/keys.test.ts +129 -0
  95. package/src/lib/__tests__/local-status.test.ts +202 -0
  96. package/src/lib/__tests__/output.test.ts +150 -0
  97. package/src/lib/__tests__/panel.test.ts +40 -0
  98. package/src/lib/__tests__/port.test.ts +44 -0
  99. package/src/lib/__tests__/prompt.test.ts +25 -0
  100. package/src/lib/__tests__/sdk-deps.test.ts +68 -0
  101. package/src/lib/__tests__/self.test.ts +272 -0
  102. package/src/lib/__tests__/studio-server-deps.test.ts +74 -0
  103. package/src/lib/__tests__/table.test.ts +53 -0
  104. package/src/lib/__tests__/update.test.ts +246 -0
  105. package/src/lib/__tests__/use-target.test.ts +56 -0
  106. package/src/lib/__tests__/validation.test.ts +34 -0
  107. package/src/lib/admin-domain.ts +25 -0
  108. package/src/lib/admin-instance.ts +26 -0
  109. package/src/lib/admin-target.ts +217 -0
  110. package/src/lib/binary.ts +131 -0
  111. package/src/lib/browser.ts +150 -0
  112. package/src/lib/command-dx.ts +161 -0
  113. package/src/lib/concurrency.ts +31 -0
  114. package/src/lib/config.ts +45 -0
  115. package/src/lib/domain-identity.ts +49 -0
  116. package/src/lib/env.ts +49 -0
  117. package/src/lib/format.ts +4 -0
  118. package/src/lib/fs-atomic.ts +126 -0
  119. package/src/lib/identity.ts +256 -0
  120. package/src/lib/idp-session.ts +134 -0
  121. package/src/lib/idp.ts +876 -0
  122. package/src/lib/instance-candidates.ts +49 -0
  123. package/src/lib/instance-target.ts +182 -0
  124. package/src/lib/instance.ts +395 -0
  125. package/src/lib/keys.ts +294 -0
  126. package/src/lib/local-status.ts +152 -0
  127. package/src/lib/log.ts +116 -0
  128. package/src/lib/login-flow.ts +164 -0
  129. package/src/lib/meta.ts +86 -0
  130. package/src/lib/output.ts +222 -0
  131. package/src/lib/panel.ts +61 -0
  132. package/src/lib/paths.ts +11 -0
  133. package/src/lib/port.ts +41 -0
  134. package/src/lib/proc.ts +82 -0
  135. package/src/lib/prompt.ts +136 -0
  136. package/src/lib/provision-instance.ts +170 -0
  137. package/src/lib/sdk-deps.ts +104 -0
  138. package/src/lib/self.ts +166 -0
  139. package/src/lib/skills.ts +171 -0
  140. package/src/lib/table.ts +62 -0
  141. package/src/lib/update.ts +315 -0
  142. package/src/lib/use-target.ts +24 -0
  143. package/src/lib/validation.ts +59 -0
  144. package/src/program.ts +200 -0
  145. package/src/registry.ts +59 -0
  146. package/src/setup/__tests__/util.test.ts +29 -0
  147. package/src/setup/engine.ts +83 -0
  148. package/src/setup/render.ts +109 -0
  149. package/src/setup/steps/admin.ts +78 -0
  150. package/src/setup/steps/agent-browser.ts +81 -0
  151. package/src/setup/steps/auth.ts +54 -0
  152. package/src/setup/steps/domain.ts +68 -0
  153. package/src/setup/steps/index.ts +18 -0
  154. package/src/setup/steps/instance.ts +119 -0
  155. package/src/setup/steps/skills-bridge.ts +70 -0
  156. package/src/setup/steps/skills.ts +59 -0
  157. package/src/setup/types.ts +61 -0
  158. package/src/setup/util.ts +34 -0
  159. package/src/test-utils.ts +18 -0
  160. package/studio/client/dist/assets/index-DOwzZAEK.css +1 -0
  161. package/studio/client/dist/assets/index-wtU0Zxhy.js +183 -0
  162. package/studio/client/dist/index.html +13 -0
  163. package/studio/package.json +62 -0
  164. package/studio/server/agent/ask.ts +68 -0
  165. package/studio/server/agent/bridge-mcp.ts +182 -0
  166. package/studio/server/agent/bridge.ts +188 -0
  167. package/studio/server/agent/claude.ts +666 -0
  168. package/studio/server/agent/mock.ts +186 -0
  169. package/studio/server/agent/prompt.ts +202 -0
  170. package/studio/server/agent/registry.ts +29 -0
  171. package/studio/server/agent/runner.ts +484 -0
  172. package/studio/server/agent/schema-map.ts +112 -0
  173. package/studio/server/agent/types.ts +120 -0
  174. package/studio/server/api.ts +574 -0
  175. package/studio/server/cache.ts +138 -0
  176. package/studio/server/detect.ts +81 -0
  177. package/studio/server/domain.ts +70 -0
  178. package/studio/server/index.ts +136 -0
  179. package/studio/server/introspect/anatomy-extras.ts +398 -0
  180. package/studio/server/introspect/anatomy.ts +108 -0
  181. package/studio/server/introspect/bundle.ts +57 -0
  182. package/studio/server/introspect/core-extractor.ts +119 -0
  183. package/studio/server/introspect/core.ts +44 -0
  184. package/studio/server/introspect/diff.ts +133 -0
  185. package/studio/server/introspect/extractor.ts +102 -0
  186. package/studio/server/introspect/hash.ts +21 -0
  187. package/studio/server/introspect/overlay-tsmorph.ts +874 -0
  188. package/studio/server/introspect/overlay.ts +57 -0
  189. package/studio/server/introspect/runtime.ts +99 -0
  190. package/studio/server/introspect/schema-refs.ts +46 -0
  191. package/studio/server/lifecycle.ts +38 -0
  192. package/studio/server/sse.ts +57 -0
  193. package/studio/server/state/baseline.ts +211 -0
  194. package/studio/server/state/catalog.ts +117 -0
  195. package/studio/server/state/comments.ts +321 -0
  196. package/studio/server/state/context.ts +167 -0
  197. package/studio/server/state/copy.ts +156 -0
  198. package/studio/server/state/create.ts +156 -0
  199. package/studio/server/state/documents.ts +70 -0
  200. package/studio/server/state/env.ts +161 -0
  201. package/studio/server/state/git.ts +75 -0
  202. package/studio/server/state/handoff.ts +55 -0
  203. package/studio/server/state/harness-gateway.ts +181 -0
  204. package/studio/server/state/harness-token.ts +0 -0
  205. package/studio/server/state/instance.ts +244 -0
  206. package/studio/server/state/integrations.ts +55 -0
  207. package/studio/server/state/layout.ts +55 -0
  208. package/studio/server/state/settings.ts +39 -0
  209. package/studio/server/state/store.ts +97 -0
  210. package/studio/server/state/updates.ts +63 -0
  211. package/studio/server/state/usage.ts +37 -0
  212. package/studio/server/state/views.ts +138 -0
  213. package/studio/server/state/visibility.ts +33 -0
  214. package/studio/server/watch.ts +81 -0
  215. package/studio/server/workspace-state.ts +26 -0
  216. package/studio/server/workspace-watch.ts +101 -0
  217. package/studio/shared/types.ts +873 -0
  218. package/studio/tsconfig.json +23 -0
  219. package/tsconfig.json +14 -0
@@ -0,0 +1,63 @@
1
+ /**
2
+ * state/updates.ts — the "is anything stale?" bridge for the header Update badge.
3
+ *
4
+ * We don't reimplement staleness: the `astrale` CLI owns it. `astrale update
5
+ * --check --json`, run in the DOMAIN ROOT (so its @astrale-os/* SDK-dep axis sees
6
+ * THIS project), emits a unified `{ stale, cli, sdk }` report. We run it with
7
+ * stdout piped and read it regardless of exit code — a stale result exits 10 by
8
+ * design, which is not an error here. Best-effort: anything unexpected (no
9
+ * `astrale` on PATH, bad output) collapses to "nothing stale" so the badge stays
10
+ * hidden rather than nagging on a hiccup.
11
+ */
12
+ import type { StaleReport } from '../../shared/types'
13
+
14
+ const NOT_STALE: StaleReport = {
15
+ stale: false,
16
+ cli: { stale: false, managed: true },
17
+ sdk: { stale: false, inProject: false, outdated: [] },
18
+ }
19
+
20
+ export async function getUpdates(root: string): Promise<StaleReport> {
21
+ try {
22
+ const proc = Bun.spawn(['astrale', 'update', '--check', '--json'], {
23
+ cwd: root,
24
+ stdout: 'pipe',
25
+ stderr: 'ignore',
26
+ })
27
+ const out = await new Response(proc.stdout).text()
28
+ await proc.exited // exit 10 = "stale" (expected); we already have stdout
29
+ const parsed = JSON.parse(out) as StaleReport
30
+ return typeof parsed?.stale === 'boolean' ? parsed : NOT_STALE
31
+ } catch {
32
+ return NOT_STALE
33
+ }
34
+ }
35
+
36
+ export interface UpdateApplyResult {
37
+ ok: boolean
38
+ output: string
39
+ }
40
+
41
+ /**
42
+ * Apply the update for the header "Update now" button — `astrale update --yes`,
43
+ * run in the domain root. The CLI does the work non-interactively and resiliently
44
+ * (binary + skills + SDK deps; a binary that can't self-update warns but doesn't
45
+ * block the others), so we just spawn it and capture the combined output to show.
46
+ */
47
+ export async function applyUpdates(root: string): Promise<UpdateApplyResult> {
48
+ try {
49
+ const proc = Bun.spawn(['astrale', 'update', '--yes'], {
50
+ cwd: root,
51
+ stdout: 'pipe',
52
+ stderr: 'pipe',
53
+ })
54
+ const [out, err] = await Promise.all([
55
+ new Response(proc.stdout).text(),
56
+ new Response(proc.stderr).text(),
57
+ ])
58
+ const code = await proc.exited
59
+ return { ok: code === 0, output: (out + err).trim() }
60
+ } catch (e) {
61
+ return { ok: false, output: String(e) }
62
+ }
63
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * usage.ts — domain-attributable agent spend. Accumulates tokens + USD from THIS
3
+ * studio's own agent runs on THIS domain (not machine-wide — that lives in
4
+ * ~/.claude/stats-cache.json and is out of scope here). Stored at
5
+ * `.domain-studio/usage.json`; surfaced read-only in the Settings dialog.
6
+ */
7
+ import type { AgentRun, DomainUsage } from '../../shared/types'
8
+
9
+ import { readJson, writeJson } from './store'
10
+
11
+ const PATH = 'usage.json'
12
+
13
+ const EMPTY: DomainUsage = { runs: 0, tokens: 0, costUsd: 0 }
14
+
15
+ export function readUsage(root: string): DomainUsage {
16
+ return { ...EMPTY, ...readJson<Partial<DomainUsage>>(root, PATH, {}) }
17
+ }
18
+
19
+ /** Fold a finished run into the running total. A run with no reported usage (e.g. it
20
+ * failed to spawn) is ignored so the count stays meaningful. Best-effort — never
21
+ * let bookkeeping break a run. */
22
+ export function recordRun(root: string, run: AgentRun): void {
23
+ if (run.tokens == null && run.costUsd == null) return
24
+ try {
25
+ const prev = readUsage(root)
26
+ writeJson(root, PATH, {
27
+ runs: prev.runs + 1,
28
+ tokens: prev.tokens + (run.tokens ?? 0),
29
+ costUsd: prev.costUsd + (run.costUsd ?? 0),
30
+ lastRunAt: run.finishedAt ?? new Date().toISOString(),
31
+ lastTokens: run.tokens,
32
+ lastCostUsd: run.costUsd,
33
+ } satisfies DomainUsage)
34
+ } catch {
35
+ /* usage is best-effort */
36
+ }
37
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * state/views.ts — resolve a view's LIVE serving URL from the instance it is
3
+ * installed on. The kernel stamps each View node (/<origin>/core/views/<slug>)
4
+ * with a `binding` prop = {"remoteUrl": "<svc-url>/ui/…"}; we read it back via
5
+ * `astrale get` (read-only, no install needed). This is the GROUND TRUTH for
6
+ * "where does this view actually run" — there is no local copy.
7
+ */
8
+ import type { PreviewToken, ViewUrlResult } from '../../shared/types'
9
+
10
+ const BINDING_KEY = 'kernel.astrale.ai:interface.Function.property.binding'
11
+
12
+ /** Run an astrale CLI command, return trimmed stdout (or null on failure), killed past timeout. */
13
+ async function astraleText(args: string[], timeoutMs = 8000): Promise<string | null> {
14
+ try {
15
+ const proc = Bun.spawn(['astrale', ...args], { stdout: 'pipe', stderr: 'pipe' })
16
+ const timer = setTimeout(() => {
17
+ try {
18
+ proc.kill()
19
+ } catch {
20
+ /* already exited */
21
+ }
22
+ }, timeoutMs)
23
+ try {
24
+ const out = await new Response(proc.stdout).text()
25
+ const code = await proc.exited
26
+ return code === 0 ? out.trim() || null : null
27
+ } finally {
28
+ clearTimeout(timer)
29
+ }
30
+ } catch {
31
+ return null
32
+ }
33
+ }
34
+
35
+ /** The `iss` claim of a JWT = the kernel URL the token is for (e.g. https://mcac.eu.astrale.ai/api). */
36
+ function jwtIssuer(jwt: string): string | null {
37
+ try {
38
+ const payload = jwt.split('.')[1]
39
+ const json = Buffer.from(payload, 'base64url').toString('utf8')
40
+ const iss = JSON.parse(json)?.iss
41
+ return typeof iss === 'string' ? iss : null
42
+ } catch {
43
+ return null
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Mint a short-lived delegation token so the studio can act as the AUTHENTICATED shell
49
+ * HOST for a live view preview. The studio's `astrale` CLI session is the source of auth;
50
+ * `astrale token` mints a delegation for the active identity on the target instance. The
51
+ * browser parent never calls the kernel — it just hands this token to the view iframe.
52
+ */
53
+ export async function mintPreviewToken(
54
+ origin: string | null,
55
+ instance: string | null,
56
+ slug: string,
57
+ ttlSeconds = 1800,
58
+ ): Promise<PreviewToken> {
59
+ if (!origin || !instance || !slug)
60
+ return { status: 'unavailable', reason: 'no target instance for this domain' }
61
+ const token = await astraleText(
62
+ ['token', '--ttl', String(ttlSeconds), '--raw', '-i', instance],
63
+ 12000,
64
+ )
65
+ if (!token || token.split('.').length !== 3)
66
+ return {
67
+ status: 'unavailable',
68
+ reason: 'could not mint a token (instance unreachable or not authenticated)',
69
+ }
70
+ const kernelUrl = jwtIssuer(token)
71
+ return {
72
+ status: 'ok',
73
+ token,
74
+ expiresAt: Date.now() + ttlSeconds * 1000,
75
+ kernelUrl: kernelUrl ?? '',
76
+ functionId: `/${origin}/core/views/${slug}`,
77
+ }
78
+ }
79
+
80
+ /**
81
+ * @param origin the domain origin (e.g. mcac.app)
82
+ * @param instance the instance to query (the domain's deploy target)
83
+ * @param slug the view slug (its key in views/index.ts)
84
+ */
85
+ export async function resolveViewUrl(
86
+ origin: string | null,
87
+ instance: string | null,
88
+ slug: string,
89
+ timeoutMs = 8000,
90
+ ): Promise<ViewUrlResult> {
91
+ if (!origin || !instance || !slug) return { status: 'unknown' }
92
+ try {
93
+ const proc = Bun.spawn(
94
+ ['astrale', 'get', `/${origin}/core/views/${slug}`, '-i', instance, '--json'],
95
+ { stdout: 'pipe', stderr: 'pipe' },
96
+ )
97
+ const timer = setTimeout(() => {
98
+ try {
99
+ proc.kill()
100
+ } catch {
101
+ /* already exited */
102
+ }
103
+ }, timeoutMs)
104
+ try {
105
+ const [out, err] = await Promise.all([
106
+ new Response(proc.stdout).text(),
107
+ new Response(proc.stderr).text(),
108
+ ])
109
+ await proc.exited
110
+ let parsed: any = null
111
+ try {
112
+ parsed = JSON.parse(out)
113
+ } catch {
114
+ /* non-JSON */
115
+ }
116
+ if (parsed?.error === 'NOT_FOUND' || /\bNOT_FOUND\b/.test(`${out}\n${err}`))
117
+ return { status: 'not-installed' }
118
+ if (parsed?.path) {
119
+ let url: string | null = null
120
+ const binding = parsed.props?.[BINDING_KEY]
121
+ if (typeof binding === 'string') {
122
+ try {
123
+ const u = JSON.parse(binding)?.remoteUrl
124
+ if (typeof u === 'string' && u) url = u
125
+ } catch {
126
+ /* malformed binding */
127
+ }
128
+ }
129
+ return { status: 'installed', url }
130
+ }
131
+ return { status: 'unknown' }
132
+ } finally {
133
+ clearTimeout(timer)
134
+ }
135
+ } catch {
136
+ return { status: 'unknown' }
137
+ }
138
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * visibility.ts — persisted per-domain canvas visibility (the manual hide-set +
3
+ * the inherited-edge category toggle). Sibling of layout.ts: layout owns node
4
+ * POSITIONS, this owns what's SHOWN. Both keyed only by stable refs, so they
5
+ * survive schema edits (a removed ref's hidden flag is harmless). Persisted via
6
+ * the allow-listed store (visibility.json).
7
+ */
8
+ import { readJson, removeState, writeJson } from './store'
9
+
10
+ export interface VisibilityState {
11
+ hidden: Record<string, true>
12
+ showInheritedEdges: boolean
13
+ materializedInterfaces: Record<string, true>
14
+ }
15
+
16
+ const FILE = 'visibility.json'
17
+ const DEFAULT: VisibilityState = { hidden: {}, showInheritedEdges: true, materializedInterfaces: {} }
18
+
19
+ export function readVisibility(root: string): VisibilityState {
20
+ // merge over DEFAULT so a file written before a field existed (e.g. pre-materialize)
21
+ // still returns the complete shape — the client reads every field unconditionally.
22
+ return { ...DEFAULT, ...readJson<Partial<VisibilityState>>(root, FILE, DEFAULT) }
23
+ }
24
+
25
+ export function saveVisibility(root: string, state: VisibilityState): VisibilityState {
26
+ writeJson(root, FILE, state)
27
+ return state
28
+ }
29
+
30
+ export function resetVisibility(root: string): VisibilityState {
31
+ removeState(root, FILE)
32
+ return DEFAULT
33
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * watch.ts — two debounced watch channels per domain:
3
+ * schema/** → re-introspect → schema-diff (+ compile-error)
4
+ * anatomy fileset → anatomy-diff
5
+ * The studio is read-only, so this only ever pushes; the client refetches.
6
+ */
7
+ import chokidar from 'chokidar'
8
+ import { join, relative } from 'node:path'
9
+
10
+ import type { DomainHandle } from './domain'
11
+
12
+ import { getBundle, invalidate } from './cache'
13
+ import { broadcast } from './sse'
14
+
15
+ const ANATOMY = [
16
+ 'domain.ts',
17
+ 'core.ts',
18
+ 'core',
19
+ 'runtime',
20
+ 'views',
21
+ 'functions',
22
+ 'client/src',
23
+ 'deps.ts',
24
+ 'env.ts',
25
+ 'package.json',
26
+ 'astrale.config.ts',
27
+ ]
28
+
29
+ function ignored(p: string): boolean {
30
+ return (
31
+ p.includes('node_modules') ||
32
+ p.includes('.domain-studio') ||
33
+ p.includes('.astrale') ||
34
+ p.includes('.dist')
35
+ )
36
+ }
37
+
38
+ function affectsBundle(handle: DomainHandle, path: string): boolean {
39
+ const rel = relative(handle.root, path).split('\\').join('/')
40
+ return rel === 'domain.ts' || rel === 'runtime' || rel.startsWith('runtime/')
41
+ }
42
+
43
+ export function watchDomain(handle: DomainHandle): () => void {
44
+ const schemaW = chokidar.watch(handle.schemaDir, { ignoreInitial: true, ignored })
45
+ const anatomyW = chokidar.watch(
46
+ ANATOMY.map((p) => join(handle.root, p)),
47
+ { ignoreInitial: true, ignored },
48
+ )
49
+
50
+ let st: ReturnType<typeof setTimeout> | undefined
51
+ let at: ReturnType<typeof setTimeout> | undefined
52
+
53
+ schemaW.on('all', () => {
54
+ clearTimeout(st)
55
+ st = setTimeout(async () => {
56
+ invalidate(handle.id, 'schema')
57
+ broadcast({ type: 'resolving', domainId: handle.id })
58
+ const b = await getBundle(handle.id, true)
59
+ if (b?.error)
60
+ broadcast({ type: 'compile-error', domainId: handle.id, message: b.error.message })
61
+ broadcast({
62
+ type: 'schema-diff',
63
+ domainId: handle.id,
64
+ schemaHash: b?.schemaHash ?? 'sha-none',
65
+ })
66
+ }, 150)
67
+ })
68
+
69
+ anatomyW.on('all', (_event, path) => {
70
+ clearTimeout(at)
71
+ at = setTimeout(() => {
72
+ invalidate(handle.id, affectsBundle(handle, path) ? 'all' : 'anatomy')
73
+ broadcast({ type: 'anatomy-diff', domainId: handle.id })
74
+ }, 150)
75
+ })
76
+
77
+ return () => {
78
+ schemaW.close()
79
+ anatomyW.close()
80
+ }
81
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * workspace-state.ts — process-global workspace context shared by the entrypoint
3
+ * (index.ts), the live watcher (workspace-watch.ts), and the create-domain
4
+ * endpoint (state/create.ts): the scanned ROOT (where new domains are
5
+ * scaffolded), the schema-dir name, and the `domainId → file-watcher stop` map.
6
+ *
7
+ * It exists so the create endpoint can scaffold into the same root the studio
8
+ * watches — and re-boot the freshly-created domain — without index.ts having to
9
+ * thread those values through the API signature.
10
+ */
11
+
12
+ const state = { root: '', schemaDirName: 'schema' }
13
+
14
+ /** domainId → stop its file watcher. The startup scan + the live watcher + the
15
+ * create endpoint all share this one map so "what domains are live" stays single-sourced. */
16
+ export const stoppers = new Map<string, () => void>()
17
+
18
+ /** Called once at boot with the resolved workspace root + schema-dir name. */
19
+ export function initWorkspaceState(root: string, schemaDirName: string): void {
20
+ state.root = root
21
+ state.schemaDirName = schemaDirName
22
+ }
23
+
24
+ /** The directory the studio scans for domains — where `create new` scaffolds a sibling. */
25
+ export const workspaceRoot = (): string => state.root
26
+ export const workspaceSchemaDirName = (): string => state.schemaDirName
@@ -0,0 +1,101 @@
1
+ /**
2
+ * workspace-watch.ts — keeps the domain registry in sync with the workspace while
3
+ * the studio is running. A domain dropped in (or one whose astrale.config.ts +
4
+ * domain.ts + schema/index.ts triple just completed) is registered, booted, and
5
+ * announced over SSE; one whose triple vanished is unregistered. It reuses
6
+ * `scanWorkspace` (detection) + `bootDomain` (lifecycle), so there is no second
7
+ * source of truth for "what is a domain" or "how a domain comes online".
8
+ */
9
+ import chokidar from 'chokidar'
10
+ import { basename } from 'node:path'
11
+
12
+ import { scanWorkspace } from './detect'
13
+ import { allDomains, isDomainDir, unregisterDomain } from './domain'
14
+ import { bootDomain } from './lifecycle'
15
+ import { broadcast } from './sse'
16
+
17
+ const IGNORED =
18
+ /(^|[/\\])(node_modules|\.git|\.astrale|\.domain-studio|dist|\.dist|\.next|\.cache|\.turbo|\.vercel|coverage)([/\\]|$)/
19
+ // only these file changes can change the domain SET (vs. ordinary in-domain edits)
20
+ const TRIGGERS = new Set(['astrale.config.ts', 'domain.ts', 'index.ts'])
21
+
22
+ /**
23
+ * Watch `root` for domains appearing/disappearing.
24
+ * @param stoppers shared `domainId → file-watcher stop` map. The startup scan seeds
25
+ * it (via bootDomain); this watcher keeps it in sync as domains come and go.
26
+ */
27
+ export function watchWorkspace(
28
+ root: string,
29
+ schemaDirName: string,
30
+ stoppers: Map<string, () => void>,
31
+ ): () => void {
32
+ let timer: ReturnType<typeof setTimeout> | undefined
33
+ let running = false
34
+ let queued = false
35
+
36
+ const reconcile = async () => {
37
+ const before = new Set(allDomains().map((d) => d.id))
38
+
39
+ // 1. drop domains whose triple vanished (dir deleted, or a trigger file removed)
40
+ for (const h of allDomains()) {
41
+ if (isDomainDir(h.root, h.schemaDirName)) continue
42
+ stoppers.get(h.id)?.()
43
+ stoppers.delete(h.id)
44
+ unregisterDomain(h.id)
45
+ console.log(` Domain Studio — domain removed: ${h.id}`)
46
+ }
47
+
48
+ // 2. register + boot any new ones (scanWorkspace registers; bootDomain initializes + watches)
49
+ for (const h of scanWorkspace(root, schemaDirName)) {
50
+ if (stoppers.has(h.id)) continue
51
+ const { origin, stop } = await bootDomain(h)
52
+ stoppers.set(h.id, stop)
53
+ console.log(` Domain Studio — domain added: ${origin} (${h.id})`)
54
+ }
55
+
56
+ // 3. announce if the set changed → clients refetch /api/workspace
57
+ const after = new Set(allDomains().map((d) => d.id))
58
+ if (before.size !== after.size || [...after].some((id) => !before.has(id))) {
59
+ broadcast({ type: 'workspace', domains: [...after] })
60
+ }
61
+ }
62
+
63
+ // serialize reconciles — bootDomain is async; coalesce event bursts
64
+ const run = async () => {
65
+ if (running) {
66
+ queued = true
67
+ return
68
+ }
69
+ running = true
70
+ try {
71
+ await reconcile()
72
+ } catch (e) {
73
+ console.error(' Domain Studio — workspace reconcile failed:', e)
74
+ } finally {
75
+ running = false
76
+ if (queued) {
77
+ queued = false
78
+ void run()
79
+ }
80
+ }
81
+ }
82
+ const schedule = () => {
83
+ clearTimeout(timer)
84
+ timer = setTimeout(() => void run(), 400)
85
+ }
86
+ const onFile = (p: string) => {
87
+ if (TRIGGERS.has(basename(p))) schedule()
88
+ }
89
+
90
+ const watcher = chokidar.watch(root, {
91
+ ignoreInitial: true,
92
+ depth: 4,
93
+ ignored: (p: string) => IGNORED.test(p),
94
+ })
95
+ watcher.on('addDir', schedule).on('unlinkDir', schedule).on('add', onFile).on('unlink', onFile)
96
+
97
+ return () => {
98
+ clearTimeout(timer)
99
+ void watcher.close()
100
+ }
101
+ }