@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,181 @@
1
+ /**
2
+ * harness-gateway.ts — point the agent harness at a custom Anthropic-compatible
3
+ * model gateway (e.g. an Astrale `ai-gateway` model node) instead of Claude
4
+ * Code's built-in auth. Two scopes, layered:
5
+ * - per-domain : `<domain>/.domain-studio/harness-gateway.json` — AUTHORITATIVE
6
+ * when present (its presence is the override, even when disabled).
7
+ * - global : `~/.domain-studio/harness-gateway.json` — the studio-wide
8
+ * default applied to every domain that has no local override.
9
+ *
10
+ * The config never escapes the studio's spawned child: `harnessGatewayEnv` turns
11
+ * it into ANTHROPIC_* env that runner / ask / loadout merge into the `claude`
12
+ * subprocess env ONLY — never the studio's own process env, the user's shell, or
13
+ * a `claude` they run themselves outside the studio.
14
+ */
15
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
16
+ import { homedir } from 'node:os'
17
+ import { dirname, join } from 'node:path'
18
+
19
+ import type {
20
+ HarnessGatewayAuth,
21
+ HarnessGatewayConfig,
22
+ HarnessGatewayState,
23
+ } from '../../shared/types'
24
+
25
+ import { acquireGatewayToken } from './harness-token'
26
+ import { readJson, removeState, writeJson } from './store'
27
+
28
+ /** Per-domain file (lives under the domain's `.domain-studio/`, gitignored). */
29
+ const LOCAL_FILE = 'harness-gateway.json'
30
+ /** Studio-wide default — a sibling-named hidden dir in the user's home. */
31
+ const GLOBAL_FILE = join(homedir(), '.domain-studio', 'harness-gateway.json')
32
+
33
+ /** Coerce a wire/disk auth block into a well-formed discriminated union. Default
34
+ * is `mint` (no secret on disk). A legacy `{ apiKey }` shape maps to `token` mode. */
35
+ function normalizeAuth(input: any): HarnessGatewayAuth {
36
+ if (input?.mode === 'token' || (input?.token != null && input?.mode == null))
37
+ return { mode: 'token', token: typeof input.token === 'string' ? input.token.trim() : '' }
38
+ if (input?.mode === 'host') return { mode: 'host' }
39
+ const instance = typeof input?.instance === 'string' ? input.instance.trim() : ''
40
+ return { mode: 'mint', ...(instance ? { instance } : {}) }
41
+ }
42
+
43
+ /** Coerce a wire/disk value into a well-formed config (trim, drop blanks). */
44
+ function normalize(input: any): HarnessGatewayConfig {
45
+ const model = typeof input?.model === 'string' ? input.model.trim() : ''
46
+ // back-compat: a pre-union config stored only `apiKey` → treat as a static token
47
+ const authInput =
48
+ input?.auth ?? (typeof input?.apiKey === 'string' ? { mode: 'token', token: input.apiKey } : undefined)
49
+ return {
50
+ enabled: input?.enabled === true,
51
+ baseUrl: typeof input?.baseUrl === 'string' ? input.baseUrl.trim() : '',
52
+ ...(model ? { model } : {}),
53
+ auth: normalizeAuth(authInput),
54
+ }
55
+ }
56
+
57
+ function readLocal(root: string): HarnessGatewayConfig | null {
58
+ const raw = readJson<HarnessGatewayConfig | null>(root, LOCAL_FILE, null)
59
+ return raw ? normalize(raw) : null
60
+ }
61
+
62
+ function readGlobal(): HarnessGatewayConfig | null {
63
+ if (!existsSync(GLOBAL_FILE)) return null
64
+ try {
65
+ return normalize(JSON.parse(readFileSync(GLOBAL_FILE, 'utf8')))
66
+ } catch {
67
+ return null
68
+ }
69
+ }
70
+
71
+ /** Write (or, on null, delete) the global default. Not under any domain root, so
72
+ * it bypasses the store's domain-scoped write-allowlist by design. */
73
+ function writeGlobal(cfg: HarnessGatewayConfig | null): void {
74
+ if (cfg === null) {
75
+ if (existsSync(GLOBAL_FILE)) rmSync(GLOBAL_FILE, { force: true })
76
+ return
77
+ }
78
+ mkdirSync(dirname(GLOBAL_FILE), { recursive: true })
79
+ writeFileSync(GLOBAL_FILE, JSON.stringify(cfg, null, 2))
80
+ }
81
+
82
+ /** Resolve the layered state. A PRESENT local override wins outright — even when
83
+ * disabled, which is exactly how you turn the gateway off for one domain despite
84
+ * a global default. With no local override, the global default applies. */
85
+ export function getHarnessGatewayState(root: string): HarnessGatewayState {
86
+ const local = readLocal(root)
87
+ const global = readGlobal()
88
+ if (local) {
89
+ return { local, global, effective: local.enabled ? local : null, source: local.enabled ? 'domain' : 'none' }
90
+ }
91
+ return {
92
+ local: null,
93
+ global,
94
+ effective: global?.enabled ? global : null,
95
+ source: global?.enabled ? 'global' : 'none',
96
+ }
97
+ }
98
+
99
+ export interface SetHarnessGatewayInput {
100
+ scope: 'domain' | 'global'
101
+ config: Partial<HarnessGatewayConfig>
102
+ }
103
+
104
+ /** Persist the config to the chosen scope. Writing GLOBAL also clears any local
105
+ * override so this domain (and every un-overridden one) inherits it — that is the
106
+ * "apply to all domains" intent. */
107
+ export function setHarnessGateway(root: string, input: SetHarnessGatewayInput): HarnessGatewayState {
108
+ const cfg = normalize(input.config)
109
+ if (input.scope === 'global') {
110
+ writeGlobal(cfg)
111
+ removeState(root, LOCAL_FILE)
112
+ } else {
113
+ writeJson(root, LOCAL_FILE, cfg)
114
+ }
115
+ return getHarnessGatewayState(root)
116
+ }
117
+
118
+ /** Drop the override at a scope (revert to the layer below / default harness auth). */
119
+ export function clearHarnessGateway(root: string, scope: 'domain' | 'global'): HarnessGatewayState {
120
+ if (scope === 'global') writeGlobal(null)
121
+ else removeState(root, LOCAL_FILE)
122
+ return getHarnessGatewayState(root)
123
+ }
124
+
125
+ /** The config that takes effect for a domain (or undefined ⇒ default harness auth). */
126
+ export function resolveHarnessGateway(root: string): HarnessGatewayConfig | undefined {
127
+ return getHarnessGatewayState(root).effective ?? undefined
128
+ }
129
+
130
+ /** The gateway audience (origin) for a domain's effective config — the token
131
+ * audience to mint/relay for. Null when no gateway is configured / URL invalid. */
132
+ export function gatewayAudience(root: string): string | null {
133
+ const cfg = resolveHarnessGateway(root)
134
+ if (!cfg?.enabled || !cfg.baseUrl) return null
135
+ try {
136
+ return new URL(cfg.baseUrl).origin
137
+ } catch {
138
+ return null
139
+ }
140
+ }
141
+
142
+ /** Either the ANTHROPIC_* env to inject (empty ⇒ no gateway configured), or a
143
+ * human error when a gateway IS configured but its token can't be obtained — so
144
+ * callers fail loudly instead of silently falling back to the default Claude auth. */
145
+ export type HarnessEnvResult =
146
+ | { ok: true; env: Record<string, string> }
147
+ | { ok: false; error: string }
148
+
149
+ /**
150
+ * Resolve the ANTHROPIC_* env for a domain's harness child. Derives the gateway
151
+ * audience from the URL, acquires the bearer token per auth mode (mint / static /
152
+ * host-supplied), and sets `ANTHROPIC_AUTH_TOKEN` (Authorization: Bearer — the
153
+ * custom-gateway path, which sidesteps the x-api-key approval prompt) plus the
154
+ * model labels. The Astrale gateway pins the real model by URL, so `model` is
155
+ * only for display.
156
+ */
157
+ export async function resolveHarnessEnv(root: string): Promise<HarnessEnvResult> {
158
+ const cfg = resolveHarnessGateway(root)
159
+ if (!cfg || !cfg.enabled || !cfg.baseUrl) return { ok: true, env: {} }
160
+ let audience: string
161
+ try {
162
+ audience = new URL(cfg.baseUrl).origin
163
+ } catch {
164
+ return { ok: false, error: `invalid gateway base URL: ${cfg.baseUrl}` }
165
+ }
166
+ let token: string
167
+ try {
168
+ token = await acquireGatewayToken(cfg, audience)
169
+ } catch (e) {
170
+ return { ok: false, error: (e as Error)?.message ?? String(e) }
171
+ }
172
+ const env: Record<string, string> = {
173
+ ANTHROPIC_BASE_URL: cfg.baseUrl,
174
+ ANTHROPIC_AUTH_TOKEN: token,
175
+ }
176
+ if (cfg.model) {
177
+ env.ANTHROPIC_MODEL = cfg.model
178
+ env.ANTHROPIC_SMALL_FAST_MODEL = cfg.model
179
+ }
180
+ return { ok: true, env }
181
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * state/instance.ts — the deploy/install bridge to Astrale.
3
+ *
4
+ * The ACTIVE instance is GLOBAL (not per-domain) and the `astrale` CLI owns it —
5
+ * we never keep our own copy: `listInstances` reads `astrale instance list`,
6
+ * `setActiveInstance` runs `astrale instance use`. Install + drift are GROUND
7
+ * TRUTH, queried from the target instance (`astrale get /<origin>`) — NOT a local
8
+ * record — so a deploy done outside the studio (CLI/terminal) is still seen.
9
+ *
10
+ * Deploy (`pnpm prod`) = the managed astrale adapter: deploy + auto-install on
11
+ * the configured instance; we capture the printed service URL.
12
+ */
13
+ import { readFileSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+
16
+ import type {
17
+ DeployRecord,
18
+ DeployResult,
19
+ InstanceInfo,
20
+ InstanceStatus,
21
+ InstancesState,
22
+ } from '../../shared/types'
23
+ import type { DomainHandle } from '../domain'
24
+
25
+ import { schemaHashOf } from '../introspect/hash'
26
+ import { readJson, writeJson } from './store'
27
+
28
+ const DEPLOY_REC = 'deploy.json'
29
+
30
+ function hasProdScript(root: string): boolean {
31
+ try {
32
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
33
+ return typeof pkg?.scripts?.prod === 'string'
34
+ } catch {
35
+ return false
36
+ }
37
+ }
38
+
39
+ async function astraleJson(args: string[]): Promise<any | null> {
40
+ try {
41
+ const proc = Bun.spawn(['astrale', ...args], { stdout: 'pipe', stderr: 'ignore' })
42
+ const out = await new Response(proc.stdout).text()
43
+ await proc.exited
44
+ return JSON.parse(out)
45
+ } catch {
46
+ return null
47
+ }
48
+ }
49
+
50
+ // ── global: the CLI's instances (source of truth = `astrale instance ...`) ──
51
+
52
+ /** The active instance name — from `astrale instance active` (local, reliable). */
53
+ async function activeName(): Promise<string | null> {
54
+ const a = await astraleJson(['instance', 'active', '--json'])
55
+ return a?.name ?? null
56
+ }
57
+
58
+ /** Public wrapper — the create-domain endpoint stamps this as `--instance` on the scaffold. */
59
+ export const activeInstanceName = activeName
60
+
61
+ export async function listInstances(): Promise<InstancesState> {
62
+ // The plain `instance list` fetches signing keys for EVERY bookmark, so one
63
+ // unreachable bookmark (e.g. a stopped localhost kernel) errors out the whole
64
+ // command. `--bookmarked` is local-only (reliable); `--admin-only` adds the
65
+ // managed instances best-effort (skipped if the admin call is unavailable).
66
+ const local = await astraleJson(['instance', 'list', '--bookmarked', '--json'])
67
+ const active: string | null = local?.active ?? (await activeName())
68
+ const instances: InstanceInfo[] = []
69
+ for (const b of local?.bookmarks ?? []) {
70
+ instances.push({
71
+ name: b.name,
72
+ url: b.url ?? '',
73
+ active: !!b.active || b.name === active,
74
+ kind: 'bookmark',
75
+ })
76
+ }
77
+ const managed = await astraleJson(['instance', 'list', '--admin-only', '--json'])
78
+ for (const m of managed?.instances ?? []) {
79
+ if (!m.slug || instances.some((i) => i.name === m.slug)) continue
80
+ instances.push({ name: m.slug, url: m.url ?? '', active: m.slug === active, kind: 'managed' })
81
+ }
82
+ if (active && !instances.some((i) => i.name === active)) {
83
+ instances.unshift({ name: active, url: '', active: true, kind: 'bookmark' })
84
+ }
85
+ return { active, instances }
86
+ }
87
+
88
+ export async function setActiveInstance(
89
+ name: string,
90
+ ): Promise<{ ok: boolean; active: string | null; output: string }> {
91
+ try {
92
+ // --adopt-default: never block on an interactive identity prompt;
93
+ // --skip-jwks-check: don't do a network /meta↔JWKS check (which fails for an
94
+ // unreachable bookmark like a stopped localhost kernel). Switching is then a
95
+ // deterministic local write to instances.json.
96
+ const proc = Bun.spawn(
97
+ ['astrale', 'instance', 'use', name, '--adopt-default', '--skip-jwks-check'],
98
+ { stdout: 'pipe', stderr: 'pipe' },
99
+ )
100
+ const [out, err] = await Promise.all([
101
+ new Response(proc.stdout).text(),
102
+ new Response(proc.stderr).text(),
103
+ ])
104
+ const code = await proc.exited
105
+ const combined = `${out}\n${err}`.trim()
106
+ if (code !== 0) return { ok: false, active: await activeName(), output: combined.slice(-1000) }
107
+ return { ok: true, active: await activeName(), output: combined.slice(-1000) }
108
+ } catch (e: any) {
109
+ return { ok: false, active: null, output: String(e?.message ?? e) }
110
+ }
111
+ }
112
+
113
+ // ── per-domain: deploy target + GROUND-TRUTH install/drift (queried from the instance) ──
114
+
115
+ export function lastDeploy(root: string): DeployRecord | null {
116
+ return readJson<DeployRecord | null>(root, DEPLOY_REC, null)
117
+ }
118
+
119
+ /**
120
+ * Ask the target instance for the domain node (`astrale get /<origin> -i <instance>`).
121
+ * Installed ⇒ the kernel returns the Domain node (with the installed schema in
122
+ * `props.schema`); `NOT_FOUND` ⇒ not installed; anything else (offline / not
123
+ * authed / unknown instance) ⇒ unknown (never falsely "not installed").
124
+ */
125
+ async function getInstalledDomain(
126
+ origin: string,
127
+ instance: string,
128
+ timeoutMs = 8000,
129
+ ): Promise<{ state: 'installed' | 'not-installed' | 'unknown'; schema: unknown | null }> {
130
+ try {
131
+ const proc = Bun.spawn(['astrale', 'get', `/${origin}`, '-i', instance, '--json'], {
132
+ stdout: 'pipe',
133
+ stderr: 'pipe',
134
+ })
135
+ const timer = setTimeout(() => {
136
+ try {
137
+ proc.kill()
138
+ } catch {
139
+ /* already exited */
140
+ }
141
+ }, timeoutMs)
142
+ try {
143
+ const [out, err] = await Promise.all([
144
+ new Response(proc.stdout).text(),
145
+ new Response(proc.stderr).text(),
146
+ ])
147
+ await proc.exited
148
+ let parsed: any = null
149
+ try {
150
+ parsed = JSON.parse(out)
151
+ } catch {
152
+ /* non-JSON */
153
+ }
154
+ if (parsed?.error === 'NOT_FOUND' || /\bNOT_FOUND\b/.test(`${out}\n${err}`))
155
+ return { state: 'not-installed', schema: null }
156
+ if (parsed?.path && parsed?.props) {
157
+ let schema: unknown = null
158
+ try {
159
+ schema = parsed.props.schema ? JSON.parse(parsed.props.schema) : null
160
+ } catch {
161
+ /* schema absent/unparseable — still installed */
162
+ }
163
+ return { state: 'installed', schema }
164
+ }
165
+ return { state: 'unknown', schema: null }
166
+ } finally {
167
+ clearTimeout(timer)
168
+ }
169
+ } catch {
170
+ return { state: 'unknown', schema: null }
171
+ }
172
+ }
173
+
174
+ export async function instanceStatus(
175
+ handle: DomainHandle,
176
+ deployTarget: string | null,
177
+ origin: string | null,
178
+ localHash: string | null,
179
+ ): Promise<InstanceStatus> {
180
+ const deployable = hasProdScript(handle.root)
181
+ const last = lastDeploy(handle.root)
182
+ let install: InstanceStatus['install'] = 'unknown'
183
+ let installedHash: string | null = null
184
+ let drift: InstanceStatus['drift'] = 'unknown'
185
+
186
+ if (origin && deployTarget) {
187
+ const probe = await getInstalledDomain(origin, deployTarget)
188
+ if (probe.state === 'installed') {
189
+ install = 'installed'
190
+ installedHash = probe.schema ? schemaHashOf(probe.schema) : null
191
+ drift =
192
+ installedHash && localHash
193
+ ? installedHash === localHash
194
+ ? 'in-sync'
195
+ : 'drifted'
196
+ : 'unknown'
197
+ } else if (probe.state === 'not-installed') {
198
+ install = 'not-installed'
199
+ }
200
+ }
201
+
202
+ return { deployTarget, deployable, install, drift, localHash, installedHash, lastDeploy: last }
203
+ }
204
+
205
+ const SVC_URL = /https:\/\/[\w-]+\.svc\.[\w.-]+\.astrale\.ai\b/
206
+
207
+ /** Run `pnpm prod` (deploy + managed auto-install). Outward-facing — only call on an explicit request. */
208
+ export async function runDeploy(
209
+ handle: DomainHandle,
210
+ localHash: string | null,
211
+ ): Promise<DeployResult> {
212
+ if (!hasProdScript(handle.root)) {
213
+ return {
214
+ ok: false,
215
+ url: null,
216
+ output:
217
+ 'This domain has no "prod" script in package.json — it is not deployable via `pnpm prod`.',
218
+ }
219
+ }
220
+ let out = ''
221
+ let err = ''
222
+ let code = 1
223
+ try {
224
+ const proc = Bun.spawn(['pnpm', 'prod'], { cwd: handle.root, stdout: 'pipe', stderr: 'pipe' })
225
+ ;[out, err] = await Promise.all([
226
+ new Response(proc.stdout).text(),
227
+ new Response(proc.stderr).text(),
228
+ ])
229
+ code = await proc.exited
230
+ } catch (e: any) {
231
+ return { ok: false, url: null, output: `failed to start pnpm: ${e?.message ?? e}` }
232
+ }
233
+ const combined = `${out}\n${err}`.trim()
234
+ const url = combined.match(SVC_URL)?.[0] ?? null
235
+ const ok = code === 0
236
+ if (ok)
237
+ writeJson(handle.root, DEPLOY_REC, {
238
+ at: new Date().toISOString(),
239
+ schemaHash: localHash ?? '',
240
+ ok: true,
241
+ url: url ?? undefined,
242
+ } satisfies DeployRecord)
243
+ return { ok, url, output: combined.slice(-6000) }
244
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * integrations.ts — persists user-declared integrations at integrations.json
3
+ * ({ integrations: Integration[] }). `detectedSubfolders` is a caller-supplied
4
+ * hint (a shallow readdir of integrations/) merged in on read, never persisted.
5
+ * ALL writes go through store.ts.
6
+ */
7
+ import type { Integration, IntegrationsState } from '../../shared/types'
8
+
9
+ import { readJson, writeJson } from './store'
10
+
11
+ const PATH = 'integrations.json'
12
+
13
+ interface IntegrationsFile {
14
+ integrations: Integration[]
15
+ }
16
+
17
+ function read(root: string): Integration[] {
18
+ return readJson<IntegrationsFile>(root, PATH, { integrations: [] }).integrations
19
+ }
20
+
21
+ function write(root: string, integrations: Integration[]): void {
22
+ writeJson(root, PATH, { integrations })
23
+ }
24
+
25
+ export function readIntegrations(root: string, detectedSubfolders: string[]): IntegrationsState {
26
+ return { integrations: read(root), detectedSubfolders }
27
+ }
28
+
29
+ export function upsertIntegration(
30
+ root: string,
31
+ input: { id?: string; name: string; kind: string; status: string; notes?: string },
32
+ ): Integration {
33
+ const integrations = read(root)
34
+ const id = input.id ?? crypto.randomUUID()
35
+ const next: Integration = {
36
+ id,
37
+ name: input.name,
38
+ kind: input.kind,
39
+ status: input.status,
40
+ notes: input.notes,
41
+ }
42
+ const idx = integrations.findIndex((it) => it.id === id)
43
+ if (idx === -1) integrations.push(next)
44
+ else integrations[idx] = next
45
+ write(root, integrations)
46
+ return next
47
+ }
48
+
49
+ export function deleteIntegration(root: string, id: string): boolean {
50
+ const integrations = read(root)
51
+ const kept = integrations.filter((it) => it.id !== id)
52
+ if (kept.length === integrations.length) return false
53
+ write(root, kept)
54
+ return true
55
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * layout.ts — persisted manual graph layout per domain. Positions are keyed by
3
+ * node id (e.g. `class.Monitor`), so they survive schema changes: removed nodes'
4
+ * positions are harmless, new nodes fall back to auto-layout. Stamped with the
5
+ * schemaHash for reference. Persisted via the allow-listed store (layout.json).
6
+ */
7
+ import { readJson, removeState, writeJson } from './store'
8
+
9
+ export interface NodePosition {
10
+ x: number
11
+ y: number
12
+ /** persisted size — only expanded module containers carry one. */
13
+ w?: number
14
+ h?: number
15
+ }
16
+
17
+ export interface LayoutState {
18
+ schemaHash?: string
19
+ /** node id → manual position (only nodes the user has moved) */
20
+ positions: Record<string, NodePosition>
21
+ }
22
+
23
+ const FILE = 'layout.json'
24
+
25
+ export function readLayout(root: string): LayoutState {
26
+ return readJson<LayoutState>(root, FILE, { positions: {} })
27
+ }
28
+
29
+ export function saveLayout(
30
+ root: string,
31
+ positions: Record<string, NodePosition>,
32
+ schemaHash?: string,
33
+ ): LayoutState {
34
+ const next: LayoutState = { schemaHash, positions }
35
+ writeJson(root, FILE, next)
36
+ return next
37
+ }
38
+
39
+ export function setNodePositions(
40
+ root: string,
41
+ updates: Record<string, NodePosition>,
42
+ schemaHash?: string,
43
+ ): LayoutState {
44
+ const cur = readLayout(root)
45
+ const next: LayoutState = {
46
+ schemaHash: schemaHash ?? cur.schemaHash,
47
+ positions: { ...cur.positions, ...updates },
48
+ }
49
+ writeJson(root, FILE, next)
50
+ return next
51
+ }
52
+
53
+ export function resetLayout(root: string): void {
54
+ removeState(root, FILE)
55
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * settings.ts — per-domain power-user overrides for values the studio otherwise
3
+ * hard-codes (first: the integrations/ folder name). Stored at
4
+ * `.domain-studio/settings.json`; missing keys fall back to DEFAULT_SETTINGS.
5
+ * Surfaced subtly in the UI (command palette + a faint gear) for power users.
6
+ */
7
+ import { AGENT_EFFORT_LEVELS, type StudioSettings } from '../../shared/types'
8
+ import { readJson, writeJson } from './store'
9
+
10
+ const PATH = 'settings.json'
11
+
12
+ export const DEFAULT_SETTINGS: StudioSettings = {
13
+ agentEffort: 'high',
14
+ integrationsDir: 'integrations',
15
+ introspectTimeoutMs: 20000,
16
+ instancePollMs: 30000,
17
+ updatesPollMs: 600000,
18
+ viewProbeTimeoutMs: 8000,
19
+ }
20
+
21
+ function normalizeSettings(input: Partial<StudioSettings>): Partial<StudioSettings> {
22
+ const out = { ...input }
23
+ if (out.agentEffort !== undefined && !AGENT_EFFORT_LEVELS.includes(out.agentEffort as any))
24
+ delete out.agentEffort
25
+ return out
26
+ }
27
+
28
+ export function readSettings(root: string): StudioSettings {
29
+ return {
30
+ ...DEFAULT_SETTINGS,
31
+ ...normalizeSettings(readJson<Partial<StudioSettings>>(root, PATH, {})),
32
+ }
33
+ }
34
+
35
+ export function updateSettings(root: string, patch: Partial<StudioSettings>): StudioSettings {
36
+ const next: StudioSettings = { ...readSettings(root), ...normalizeSettings(patch) }
37
+ writeJson(root, PATH, next)
38
+ return next
39
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * store.ts — the ONLY filesystem-write gateway in the studio. It REJECTS any
3
+ * write whose resolved path is not under `<domain>/.domain-studio/`. This makes
4
+ * the read-only-domain rule a code-enforced invariant (§17 of the spec): the
5
+ * studio can never write domain source. Reads of domain source are allowed and
6
+ * done elsewhere; this module never writes outside the dotted folder.
7
+ */
8
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
9
+ import { dirname, join, resolve, sep } from 'node:path'
10
+
11
+ export const DOT = '.domain-studio'
12
+
13
+ export function dotDir(domainRoot: string): string {
14
+ return join(domainRoot, DOT)
15
+ }
16
+
17
+ function assertInsideDot(domainRoot: string, target: string): string {
18
+ const abs = resolve(target)
19
+ const root = resolve(dotDir(domainRoot))
20
+ if (abs !== root && !abs.startsWith(root + sep)) {
21
+ throw new Error(`write-allowlist violation: ${abs} is outside ${root}`)
22
+ }
23
+ return abs
24
+ }
25
+
26
+ export function ensureDir(domainRoot: string, subpath = ''): string {
27
+ const target = subpath ? join(dotDir(domainRoot), subpath) : dotDir(domainRoot)
28
+ assertInsideDot(domainRoot, target)
29
+ mkdirSync(target, { recursive: true })
30
+ return target
31
+ }
32
+
33
+ export function writeState(domainRoot: string, subpath: string, contents: string): void {
34
+ const target = join(dotDir(domainRoot), subpath)
35
+ const abs = assertInsideDot(domainRoot, target)
36
+ mkdirSync(dirname(abs), { recursive: true })
37
+ writeFileSync(abs, contents)
38
+ }
39
+
40
+ export function writeJson(domainRoot: string, subpath: string, value: unknown): void {
41
+ writeState(domainRoot, subpath, JSON.stringify(value, null, 2))
42
+ }
43
+
44
+ /** Binary write (e.g. dropped documents), allow-listed to the dotted folder. */
45
+ export function writeStateBuffer(domainRoot: string, subpath: string, data: Uint8Array): void {
46
+ const target = join(dotDir(domainRoot), subpath)
47
+ const abs = assertInsideDot(domainRoot, target)
48
+ mkdirSync(dirname(abs), { recursive: true })
49
+ writeFileSync(abs, data)
50
+ }
51
+
52
+ /** Absolute path of a state file (allow-listed) — for reading/serving. */
53
+ export function statePath(domainRoot: string, subpath: string): string {
54
+ return assertInsideDot(domainRoot, join(dotDir(domainRoot), subpath))
55
+ }
56
+
57
+ export function readState(domainRoot: string, subpath: string): string | null {
58
+ const target = join(dotDir(domainRoot), subpath)
59
+ if (!existsSync(target)) return null
60
+ return readFileSync(target, 'utf8')
61
+ }
62
+
63
+ export function readJson<T>(domainRoot: string, subpath: string, fallback: T): T {
64
+ const raw = readState(domainRoot, subpath)
65
+ if (raw == null) return fallback
66
+ try {
67
+ return JSON.parse(raw) as T
68
+ } catch {
69
+ return fallback
70
+ }
71
+ }
72
+
73
+ export function listState(domainRoot: string, subpath: string): string[] {
74
+ const target = join(dotDir(domainRoot), subpath)
75
+ if (!existsSync(target)) return []
76
+ return readdirSync(target)
77
+ }
78
+
79
+ export function removeState(domainRoot: string, subpath: string): void {
80
+ const target = join(dotDir(domainRoot), subpath)
81
+ const abs = assertInsideDot(domainRoot, target)
82
+ if (existsSync(abs)) rmSync(abs, { recursive: true, force: true })
83
+ }
84
+
85
+ export function stateExists(domainRoot: string, subpath: string): boolean {
86
+ return existsSync(join(dotDir(domainRoot), subpath))
87
+ }
88
+
89
+ /** Initialise the dotted folder skeleton + a .cache/.gitignore (never touches the user's root .gitignore). */
90
+ export function initDotDir(domainRoot: string): void {
91
+ ensureDir(domainRoot)
92
+ ensureDir(domainRoot, 'context/user')
93
+ ensureDir(domainRoot, 'context/auto')
94
+ ensureDir(domainRoot, '.cache')
95
+ if (!stateExists(domainRoot, '.cache/.gitignore'))
96
+ writeState(domainRoot, '.cache/.gitignore', '*\n')
97
+ }