@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,156 @@
1
+ /**
2
+ * state/create.ts — scaffold a brand-new domain into the workspace and bring it
3
+ * online. This is the studio's ONE write that ADDS a domain (every other state
4
+ * module operates on an existing one).
5
+ *
6
+ * Flow: validate the slug → `create-astrale-domain <slug> --yes` in the
7
+ * workspace root (the managed `astrale` adapter is its default; we stamp the
8
+ * active instance so prod targets it) → `pnpm install` in the new dir so the
9
+ * domain is fully introspectable + deployable → register + (re)boot it (the live
10
+ * watcher may have already booted a deps-less static fallback while we were
11
+ * installing; we stop that and boot fresh) → warm its bundle. The caller
12
+ * broadcasts the `workspace` event so every client refetches the domain list.
13
+ */
14
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+
17
+ import { getBundle } from '../cache'
18
+ import { registerDomain } from '../domain'
19
+ import { bootDomain } from '../lifecycle'
20
+ import { stoppers, workspaceRoot, workspaceSchemaDirName } from '../workspace-state'
21
+
22
+ /** Mirrors create-astrale-domain's `isValidSlug`: lowercase letters, digits, dots, dashes;
23
+ * must start/end alphanumeric. Also guards the filesystem target (no `/`, no `..`, no leading dot). */
24
+ const SLUG = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/
25
+
26
+ export interface CreateDomainResult {
27
+ ok: boolean
28
+ id?: string
29
+ origin?: string
30
+ error?: string
31
+ output: string
32
+ }
33
+
34
+ async function run(
35
+ cmd: string,
36
+ args: string[],
37
+ cwd: string,
38
+ ): Promise<{ code: number; output: string }> {
39
+ try {
40
+ const proc = Bun.spawn([cmd, ...args], {
41
+ cwd,
42
+ stdout: 'pipe',
43
+ stderr: 'pipe',
44
+ env: process.env,
45
+ })
46
+ const [out, err] = await Promise.all([
47
+ new Response(proc.stdout).text(),
48
+ new Response(proc.stderr).text(),
49
+ ])
50
+ const code = await proc.exited
51
+ return { code, output: `${out}\n${err}`.trim() }
52
+ } catch (e) {
53
+ return { code: 1, output: `failed to spawn ${cmd}: ${(e as Error)?.message ?? e}` }
54
+ }
55
+ }
56
+
57
+ export async function createDomain(
58
+ rawName: string,
59
+ instance: string | null,
60
+ ): Promise<CreateDomainResult> {
61
+ const name = rawName.trim().toLowerCase()
62
+ if (!name || name.length > 64 || !SLUG.test(name)) {
63
+ return {
64
+ ok: false,
65
+ error: 'Use lowercase letters, digits, dots and dashes (e.g. “crm” or “crm.acme.dev”).',
66
+ output: '',
67
+ }
68
+ }
69
+ const root = workspaceRoot()
70
+ if (!root) return { ok: false, error: 'No workspace root is configured.', output: '' }
71
+ const dir = join(root, name)
72
+ if (existsSync(dir)) {
73
+ return {
74
+ ok: false,
75
+ error: `A folder named “${name}” already exists in the workspace.`,
76
+ output: '',
77
+ }
78
+ }
79
+
80
+ // 1. Scaffold (non-interactive). `--yes` accepts defaults (astrale adapter, template);
81
+ // `--instance` stamps the active instance into the managed prod target.
82
+ const scaffoldArgs = [
83
+ '--yes',
84
+ 'create-astrale-domain@latest',
85
+ name,
86
+ '--yes',
87
+ ...(instance ? ['--instance', instance] : []),
88
+ ]
89
+ const scaffold = await run('npx', scaffoldArgs, root)
90
+ if (!existsSync(join(dir, 'domain.ts')) || !existsSync(join(dir, 'astrale.config.ts'))) {
91
+ return {
92
+ ok: false,
93
+ error: 'Scaffolding did not produce a domain. See the log.',
94
+ output: scaffold.output.slice(-6000),
95
+ }
96
+ }
97
+
98
+ // 1b. Flag the placeholder origin for the agent (we ask for the name only, so the
99
+ // origin is `<name>.example.dev` until someone — or the agent — sets the real one).
100
+ annotateOrigin(dir)
101
+
102
+ // 2. Install deps so the domain is fully introspectable + deployable. Best-effort:
103
+ // a scaffolded-but-uninstalled domain still loads (static fallback), so a failed
104
+ // install is a soft warning, not a hard failure.
105
+ const install = await run('pnpm', ['install'], dir)
106
+
107
+ // 3. Register + boot with deps present. The live watcher may have already booted a
108
+ // deps-less fallback for this dir mid-install — stop it and boot fresh.
109
+ const handle = registerDomain(dir, workspaceSchemaDirName())
110
+ if (!handle) {
111
+ return {
112
+ ok: false,
113
+ error: 'Scaffold incomplete — the domain triple is missing.',
114
+ output: combine(scaffold, install),
115
+ }
116
+ }
117
+ stoppers.get(handle.id)?.()
118
+ const { origin } = await bootDomain(handle).then((b) => {
119
+ stoppers.set(handle.id, b.stop)
120
+ return b
121
+ })
122
+ await getBundle(handle.id, true) // refresh the cache now that deps are installed
123
+
124
+ return { ok: true, id: handle.id, origin, output: combine(scaffold, install) }
125
+ }
126
+
127
+ function combine(a: { output: string }, b: { output: string }): string {
128
+ return `$ create-astrale-domain\n${a.output}\n\n$ pnpm install\n${b.output}`.trim().slice(-6000)
129
+ }
130
+
131
+ /**
132
+ * Drop an agent-actionable marker on the origin line of the scaffolded
133
+ * schema/index.ts. We only ask for the NAME, so the origin starts as the
134
+ * `<name>.example.dev` placeholder — this comment tells a human (or the studio's
135
+ * agent) it's a placeholder to change, and that the studio re-parses the literal
136
+ * as the source of truth so the rename takes effect on save. Best-effort: the
137
+ * template already explains the origin, so a parse miss is harmless.
138
+ */
139
+ function annotateOrigin(dir: string): void {
140
+ const file = join(dir, workspaceSchemaDirName(), 'index.ts')
141
+ try {
142
+ const src = readFileSync(file, 'utf8')
143
+ if (src.includes('ORIGIN —')) return // already annotated (idempotent)
144
+ const m = src.match(/^([ \t]*)export const schema = defineSchema\(/m)
145
+ if (!m) return
146
+ const pad = m[1] ?? ''
147
+ const note =
148
+ `${pad}// ORIGIN — the domain's permanent identity in the graph. It was set from the name as a\n` +
149
+ `${pad}// PLACEHOLDER below; change it to your real domain (e.g. "crm.acme.dev"), ideally BEFORE\n` +
150
+ `${pad}// the first deploy (the origin is hard to change once installed). The studio parses this\n` +
151
+ `${pad}// literal as the source of truth, so the rename refreshes on save — no other file to touch.\n`
152
+ writeFileSync(file, src.replace(m[0], note + m[0]))
153
+ } catch {
154
+ /* best-effort */
155
+ }
156
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * documents.ts — context DOCUMENTS the user drops in for the AI agent. Files are
3
+ * stored under `.domain-studio/context/documents/` (allow-listed) and tracked in
4
+ * an index. They travel with the domain and are part of the agent handoff context.
5
+ */
6
+ import { randomUUID } from 'node:crypto'
7
+ import { existsSync } from 'node:fs'
8
+ import { extname } from 'node:path'
9
+
10
+ import type { DocMeta } from '../../shared/types'
11
+
12
+ import { readJson, removeState, statePath, writeJson, writeStateBuffer } from './store'
13
+
14
+ const INDEX = 'context/documents/index.json'
15
+ const storedPath = (id: string, name: string) => `context/documents/${id}${extname(name)}`
16
+
17
+ export function listDocuments(root: string): DocMeta[] {
18
+ return readJson<DocMeta[]>(root, INDEX, [])
19
+ }
20
+
21
+ export function addDocument(root: string, name: string, type: string, data: Uint8Array): DocMeta {
22
+ const id = randomUUID()
23
+ const stored = storedPath(id, name)
24
+ writeStateBuffer(root, stored, data)
25
+ const meta: DocMeta = {
26
+ id,
27
+ name: name || 'untitled',
28
+ type: type || 'application/octet-stream',
29
+ size: data.byteLength,
30
+ addedAt: new Date().toISOString(),
31
+ stored,
32
+ }
33
+ const docs = listDocuments(root)
34
+ docs.unshift(meta)
35
+ writeJson(root, INDEX, docs)
36
+ return meta
37
+ }
38
+
39
+ /** Overwrite an existing document's content in place (keeps id/name). */
40
+ export function updateDocument(root: string, id: string, data: Uint8Array): DocMeta | null {
41
+ const docs = listDocuments(root)
42
+ const doc = docs.find((d) => d.id === id)
43
+ if (!doc) return null
44
+ writeStateBuffer(root, doc.stored, data)
45
+ doc.size = data.byteLength
46
+ doc.updatedAt = new Date().toISOString()
47
+ writeJson(root, INDEX, docs)
48
+ return doc
49
+ }
50
+
51
+ export function deleteDocument(root: string, id: string): boolean {
52
+ const docs = listDocuments(root)
53
+ const doc = docs.find((d) => d.id === id)
54
+ if (!doc) return false
55
+ removeState(root, doc.stored)
56
+ writeJson(
57
+ root,
58
+ INDEX,
59
+ docs.filter((d) => d.id !== id),
60
+ )
61
+ return true
62
+ }
63
+
64
+ export function readDocument(root: string, id: string): { meta: DocMeta; abs: string } | null {
65
+ const doc = listDocuments(root).find((d) => d.id === id)
66
+ if (!doc) return null
67
+ const abs = statePath(root, doc.stored)
68
+ if (!existsSync(abs)) return null
69
+ return { meta: doc, abs }
70
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * env.ts — the env-vars editor backend. Reconciles a domain's `.env.<env>` file
3
+ * (the actual secrets/vars, dotenv format) against the `Env` interface in
4
+ * `env.ts` (the typed contract, parsed by buildEnvFields), and is the ONE place
5
+ * the otherwise read-only studio writes a domain file — the explicit exception
6
+ * the user sanctioned for env editing.
7
+ *
8
+ * Env injection is adapter-specific (cloudflare dev: merged into wrangler vars;
9
+ * cloudflare/astrale prod: pushed to the worker/platform secret store), but the
10
+ * secrets FILE is universal (`@astrale-os/sdk` loads `secrets: '.env.<env>'`
11
+ * wholesale via its dotenv parser). We edit that file; we never deploy.
12
+ */
13
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
14
+ import { join, resolve } from 'node:path'
15
+
16
+ import type { EnvFileModel, EnvName, EnvVarRow } from '../../shared/types'
17
+
18
+ import { buildEnvFields } from '../introspect/anatomy-extras'
19
+
20
+ const ENV_NAMES: EnvName[] = ['dev', 'prod']
21
+ export const isEnvName = (v: unknown): v is EnvName =>
22
+ typeof v === 'string' && (ENV_NAMES as string[]).includes(v)
23
+
24
+ /** The conventional secrets file for an env — what create-astrale-domain scaffolds
25
+ * and every domain uses. The filename is fixed, so no path-traversal is possible. */
26
+ const envFileName = (env: EnvName) => `.env.${env}`
27
+
28
+ /** Mirror of the SDK's minimal dotenv parser (`@astrale-os/sdk` cli/dotenv) so the
29
+ * studio reads values exactly as the deploy path will: `#` comments, `export`,
30
+ * quotes (single = literal), `${VAR}` interpolation against earlier keys. */
31
+ function parseDotenv(contents: string): Record<string, string> {
32
+ const out: Record<string, string> = {}
33
+ for (const raw of contents.split('\n')) {
34
+ const line = raw.trim()
35
+ if (!line || line.startsWith('#')) continue
36
+ const m = /^(?:export\s+)?([A-Za-z_]\w*)\s*=\s*(.*)$/.exec(line)
37
+ if (!m) continue
38
+ let value = m[2].trim()
39
+ const single = value.length >= 2 && value.startsWith("'") && value.endsWith("'")
40
+ if (single || (value.length >= 2 && value.startsWith('"') && value.endsWith('"')))
41
+ value = value.slice(1, -1)
42
+ out[m[1]] = single ? value : value.replace(/\$\{(\w+)\}/g, (_, n: string) => out[n] ?? '')
43
+ }
44
+ return out
45
+ }
46
+
47
+ /** astrale.config.ts with comments stripped — so a COMMENTED-OUT adapter block
48
+ * (e.g. the scaffold's "swap to cloudflare" example) never reads as active config.
49
+ * The `[^:]` guard keeps `https://` in URLs from being mistaken for a line comment. */
50
+ function configText(root: string): string {
51
+ let s: string
52
+ try {
53
+ s = readFileSync(join(root, 'astrale.config.ts'), 'utf8')
54
+ } catch {
55
+ return ''
56
+ }
57
+ return s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
58
+ }
59
+
60
+ /** Is this `.env.<env>` wired into astrale.config's ACTIVE `secrets:`? (vs convention only) */
61
+ function isConfigured(config: string, env: EnvName): boolean {
62
+ return new RegExp(`secrets\\s*:\\s*['"]\\.env\\.${env}['"]`).test(config)
63
+ }
64
+
65
+ function adapterOf(config: string): EnvFileModel['adapter'] {
66
+ if (/\bastrale\s*\(/.test(config)) return 'astrale'
67
+ if (/\bcloudflare\s*\(/.test(config)) return 'cloudflare'
68
+ return 'unknown'
69
+ }
70
+
71
+ /** Build the merged model for one env: env.ts fillable fields ⨯ the `.env.<env>` values. */
72
+ export function readEnvModel(root: string, env: EnvName): EnvFileModel {
73
+ const file = envFileName(env)
74
+ const abs = join(root, file)
75
+ const exists = existsSync(abs)
76
+ const values = exists ? parseDotenv(readFileSync(abs, 'utf8')) : {}
77
+
78
+ // env.ts contract — only fillable (non-binding) fields; bindings are adapter-injected.
79
+ const declared = buildEnvFields(root).filter((f) => f.secret)
80
+ const declaredNames = new Set(declared.map((f) => f.name))
81
+
82
+ const rows: EnvVarRow[] = declared.map((f) => ({
83
+ name: f.name,
84
+ value: values[f.name] ?? '',
85
+ declared: true,
86
+ optional: f.optional,
87
+ ...(f.doc ? { doc: f.doc } : {}),
88
+ }))
89
+ // orphans — present in the file but not declared in env.ts (kept, flagged, removable)
90
+ for (const [name, value] of Object.entries(values)) {
91
+ if (!declaredNames.has(name)) rows.push({ name, value, declared: false, optional: true })
92
+ }
93
+
94
+ const requiredMissing = rows.filter((r) => r.declared && !r.optional && r.value === '').length
95
+ const config = configText(root)
96
+ return {
97
+ env,
98
+ file,
99
+ configured: isConfigured(config, env),
100
+ exists,
101
+ adapter: adapterOf(config),
102
+ rows,
103
+ requiredMissing,
104
+ }
105
+ }
106
+
107
+ /** Quote a value for the dotenv file: bare when safe, single-quoted (literal) for
108
+ * most specials, double-quoted+escaped only when it contains a single quote. */
109
+ function formatValue(v: string): string {
110
+ if (v === '') return ''
111
+ if (/^[A-Za-z0-9_./:@+-]+$/.test(v)) return v
112
+ if (!v.includes("'")) return `'${v}'`
113
+ return `"${v.replace(/(["\\$`])/g, '\\$1')}"`
114
+ }
115
+
116
+ /** Apply key→value updates to dotenv text IN PLACE — comments, blank lines and key
117
+ * order are preserved; `null` deletes a key; unknown keys are appended. */
118
+ function applyUpdates(contents: string, updates: Record<string, string | null>): string {
119
+ const lines = contents.split('\n')
120
+ const seen = new Set<string>()
121
+ const out: string[] = []
122
+ for (const line of lines) {
123
+ const m = /^(\s*(?:export\s+)?)([A-Za-z_]\w*)\s*=.*$/.exec(line)
124
+ if (m && !line.trim().startsWith('#') && m[2] in updates) {
125
+ const key = m[2]
126
+ seen.add(key)
127
+ const val = updates[key]
128
+ if (val === null) continue // drop the line
129
+ out.push(`${m[1]}${key}=${formatValue(val)}`)
130
+ continue
131
+ }
132
+ out.push(line)
133
+ }
134
+ // preserve a single trailing newline while appending new keys after content
135
+ const trailing = out.length > 0 && out[out.length - 1] === ''
136
+ if (trailing) out.pop()
137
+ for (const [key, val] of Object.entries(updates)) {
138
+ if (val === null || seen.has(key) || !/^[A-Za-z_]\w*$/.test(key)) continue
139
+ out.push(`${key}=${formatValue(val)}`)
140
+ }
141
+ out.push('') // final newline
142
+ return out.join('\n')
143
+ }
144
+
145
+ const SCAFFOLD_HEADER = (env: EnvName) =>
146
+ `# ${env === 'dev' ? 'Dev' : 'Prod'} secrets — the ENTIRE file is treated as secrets by the adapter.\n# Gitignored. Edited via the Domain Studio settings.\n`
147
+
148
+ /** Write key→value updates to `.env.<env>`, creating it if absent. Returns the
149
+ * fresh model. Confined to `<root>/.env.<env>` (fixed name, validated inside root). */
150
+ export function writeEnvUpdates(
151
+ root: string,
152
+ env: EnvName,
153
+ updates: Record<string, string | null>,
154
+ ): EnvFileModel {
155
+ const abs = join(root, envFileName(env))
156
+ if (!resolve(abs).startsWith(resolve(root)))
157
+ throw new Error('refused: path escapes the domain root')
158
+ const prior = existsSync(abs) ? readFileSync(abs, 'utf8') : SCAFFOLD_HEADER(env)
159
+ writeFileSync(abs, applyUpdates(prior, updates), 'utf8')
160
+ return readEnvModel(root, env)
161
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * git.ts — best-effort git enrichment for change tracking. The fixtures are not
3
+ * git repos, so EVERY function here degrades gracefully and NEVER throws. The
4
+ * baseline (baseline.ts) is the primary tracker; git only enriches when present.
5
+ */
6
+ import { execFileSync } from 'node:child_process'
7
+ import { existsSync } from 'node:fs'
8
+ import { dirname, relative, resolve } from 'node:path'
9
+
10
+ import type { FileChange } from '../../shared/types'
11
+
12
+ /** Walk parent dirs looking for a `.git` entry (dir or file, to support worktrees/submodules). */
13
+ export function detectGit(root: string): { hasGit: boolean; gitRoot?: string } {
14
+ let dir = resolve(root)
15
+ while (true) {
16
+ if (existsSync(resolve(dir, '.git'))) return { hasGit: true, gitRoot: dir }
17
+ const parent = dirname(dir)
18
+ if (parent === dir) return { hasGit: false }
19
+ dir = parent
20
+ }
21
+ }
22
+
23
+ function git(gitRoot: string, args: string[]): string | null {
24
+ try {
25
+ return execFileSync('git', ['-C', gitRoot, ...args], {
26
+ encoding: 'utf8',
27
+ stdio: ['ignore', 'pipe', 'ignore'],
28
+ maxBuffer: 64 * 1024 * 1024,
29
+ })
30
+ } catch {
31
+ return null
32
+ }
33
+ }
34
+
35
+ /** `git diff` scoped to the schema dir. Returns stdout (possibly '') or null when there is no git. */
36
+ export function gitDiff(root: string, schemaDirName: string): string | null {
37
+ const { hasGit, gitRoot } = detectGit(root)
38
+ if (!hasGit || !gitRoot) return null
39
+ // Path to the schema dir relative to the git root (forward-slashed for git).
40
+ const rel = relative(gitRoot, resolve(root, schemaDirName)).split('\\').join('/')
41
+ const out = git(gitRoot, ['diff', '--', rel || '.'])
42
+ return out
43
+ }
44
+
45
+ /** XY porcelain code → our FileChange status. Returns null for codes we don't surface. */
46
+ function mapStatus(xy: string): FileChange['status'] | null {
47
+ const x = xy[0] ?? ' '
48
+ const y = xy[1] ?? ' '
49
+ if (x === '?' || y === '?') return 'added' // untracked
50
+ if (x === 'A' || y === 'A') return 'added'
51
+ if (x === 'D' || y === 'D') return 'removed'
52
+ if (x === 'R' || y === 'R') return 'modified' // rename → surface destination as modified
53
+ if (x === 'M' || y === 'M' || x === 'C' || y === 'C' || x === 'U' || y === 'U') return 'modified'
54
+ return null
55
+ }
56
+
57
+ /** Parse `git status --porcelain` into FileChange[]. Empty array when there is no git. */
58
+ export function gitStatus(root: string): FileChange[] {
59
+ const { hasGit, gitRoot } = detectGit(root)
60
+ if (!hasGit || !gitRoot) return []
61
+ const out = git(gitRoot, ['status', '--porcelain'])
62
+ if (out == null) return []
63
+ const changes: FileChange[] = []
64
+ for (const line of out.split('\n')) {
65
+ if (!line.trim()) continue
66
+ const xy = line.slice(0, 2)
67
+ let path = line.slice(3)
68
+ // Renames/copies render as "old -> new"; keep the destination path.
69
+ const arrow = path.indexOf(' -> ')
70
+ if (arrow !== -1) path = path.slice(arrow + 4)
71
+ const status = mapStatus(xy)
72
+ if (status) changes.push({ file: path, status })
73
+ }
74
+ return changes
75
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * handoff.ts — shared helpers that turn live domain state into the "what changed
3
+ * + what to read" material both the Copy payload (api.ts) and the live agent
4
+ * runner depend on. Extracted so there is ONE definition of the change text and
5
+ * the auto-context refresh.
6
+ */
7
+ import type { ChangeSet } from '../../shared/types'
8
+ import type { DomainHandle } from '../domain'
9
+
10
+ import { getBundle } from '../cache'
11
+ import { computeChanges, hashAnatomyFiles } from './baseline'
12
+ import { readComments } from './comments'
13
+ import { materializeAuto } from './context'
14
+
15
+ /** Current changes vs the review baseline (schema IR diff + anatomy file diff). */
16
+ export async function changeSet(handle: DomainHandle): Promise<ChangeSet> {
17
+ const bundle = await getBundle(handle.id)
18
+ const files = hashAnatomyFiles(handle.root, handle.schemaDirName)
19
+ return computeChanges(handle.root, bundle?.ir ?? null, files, {
20
+ schemaDirName: handle.schemaDirName,
21
+ })
22
+ }
23
+
24
+ /** A human-readable "current changes" blob for the handoff payload. */
25
+ export function changeText(cs: ChangeSet): string {
26
+ if (cs.schemaDiffText && cs.schemaDiffText.trim()) return cs.schemaDiffText.trim()
27
+ const lines: string[] = []
28
+ for (const c of cs.schemaChanges)
29
+ lines.push(
30
+ `${c.breaking ? '! ' : '+ '}${c.kind} ${c.target}${c.detail ? ` (${c.detail})` : ''}`,
31
+ )
32
+ for (const f of cs.fileChanges) lines.push(`~ ${f.status} ${f.file}`)
33
+ return lines.join('\n')
34
+ }
35
+
36
+ /** Refresh the on-disk context/auto digests so an external agent can read them. */
37
+ export async function refreshAuto(handle: DomainHandle): Promise<void> {
38
+ const cs = await changeSet(handle)
39
+ const store = readComments(handle.root)
40
+ const open = store.comments.filter((c) => c.status === 'open')
41
+ const commentsDigest = open
42
+ .map(
43
+ (c, i) =>
44
+ `${i + 1}. [${c.kind} ${c.id}] ${c.anchorRefs[0]?.ref ?? ''} — ${c.thread.at(-1)?.text ?? ''}`,
45
+ )
46
+ .join('\n')
47
+ const schemaSummary = cs.schemaChanges
48
+ .map((c) => `${c.breaking ? 'BREAKING ' : ''}${c.kind} ${c.target}`)
49
+ .join('\n')
50
+ materializeAuto(handle.root, {
51
+ changes: changeText(cs) || 'no tracked changes',
52
+ schemaChange: schemaSummary || 'no schema changes since baseline',
53
+ comments: commentsDigest || 'no open comments',
54
+ })
55
+ }