@astrale-os/cli 1.0.0-beta.1 → 1.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -11
- package/dist/astrale.js +3737 -3523
- package/dist/public/connect-core.js +396 -392
- package/dist/public/keys/index.js +371 -378
- package/dist/public/paths/index.js +371 -378
- package/dist/types/lib/idp.d.ts +6 -0
- package/dist/types/lib/meta.d.ts +2 -2
- package/package.json +6 -4
- package/src/admin/binding.ts +168 -0
- package/src/admin/catalog/__tests__/client.test.ts +2 -2
- package/src/admin/catalog/client.ts +4 -9
- package/src/admin/instance/__tests__/client.test.ts +2 -2
- package/src/admin/instance/client.ts +6 -11
- package/src/commands/__tests__/auth-login.test.ts +54 -1
- package/src/commands/__tests__/call.test.ts +5 -1
- package/src/commands/__tests__/domain-install-operation.test.ts +23 -7
- package/src/commands/__tests__/domain-install-owned.test.ts +5 -1
- package/src/commands/__tests__/domain-list.test.ts +83 -4
- package/src/commands/__tests__/install-identity-override.test.ts +30 -19
- package/src/commands/__tests__/studio-descriptor.test.ts +22 -0
- package/src/commands/__tests__/update.test.ts +23 -16
- package/src/commands/domain/install.ts +11 -15
- package/src/commands/domain/list.ts +17 -12
- package/src/commands/studio.ts +20 -0
- package/src/commands/update.ts +9 -5
- package/src/connection/__tests__/errors.test.ts +16 -12
- package/src/connection/__tests__/exchange.test.ts +6 -5
- package/src/connection/exchange.ts +5 -4
- package/src/lib/__tests__/domain-publication.test.ts +144 -0
- package/src/lib/__tests__/idp-session.test.ts +22 -0
- package/src/lib/__tests__/idp.test.ts +2 -0
- package/src/lib/__tests__/instance.test.ts +6 -0
- package/src/lib/__tests__/studio-server-deps.test.ts +53 -16
- package/src/lib/__tests__/update.test.ts +12 -0
- package/src/lib/domain-publication.ts +102 -0
- package/src/lib/idp.ts +5 -1
- package/src/lib/instance.ts +6 -1
- package/src/lib/login-flow.ts +13 -3
- package/src/lib/meta.ts +2 -2
- package/src/lib/update.ts +4 -4
- package/src/program/__tests__/program.test.ts +1 -1
- package/studio/client/dist/assets/{elk-api-D2xgMJvi.js → elk-api-lwhFo7vB.js} +1 -1
- package/studio/client/dist/assets/index-B-c-smo9.js +8 -0
- package/studio/client/dist/assets/index-BckHuAWk.js +81 -0
- package/studio/client/dist/assets/index-C4aNQ6dz.css +1 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/package.json +3 -1
- package/studio/server/agent/bridge/stdio.ts +8 -1
- package/studio/server/agent/conversation.test.ts +10 -5
- package/studio/server/agent/conversation.ts +56 -8
- package/studio/server/agent/harness/codex/loadout.ts +18 -21
- package/studio/server/agent/harness/gateway/config.ts +20 -18
- package/studio/server/agent/harness/selection.ts +9 -1
- package/studio/server/agent/prompts/system.ts +3 -2
- package/studio/server/agent/prompts/turn.ts +13 -4
- package/studio/server/agent/run/completion.ts +2 -2
- package/studio/server/agent/run/preparation.ts +6 -5
- package/studio/server/agent/run/transcript.test.ts +35 -1
- package/studio/server/agent/run/transcript.ts +169 -3
- package/studio/server/agent/run/usage.test.ts +24 -0
- package/studio/server/agent/run/usage.ts +26 -1
- package/studio/server/api/canvas.ts +44 -0
- package/studio/server/api/comments.ts +85 -0
- package/studio/server/api/context.ts +61 -0
- package/studio/server/api/deployment.ts +39 -0
- package/studio/server/api/documents.ts +55 -0
- package/studio/server/api/domain.ts +59 -0
- package/studio/server/api/http.ts +45 -0
- package/studio/server/api/project.ts +57 -0
- package/studio/server/api/schema.ts +12 -0
- package/studio/server/api/views.ts +57 -0
- package/studio/server/api/workspace.ts +60 -0
- package/studio/server/api.test.ts +147 -0
- package/studio/server/api.ts +20 -422
- package/studio/server/cache.test.ts +111 -0
- package/studio/server/cache.ts +163 -9
- package/studio/server/cli-consumers.test.ts +494 -0
- package/studio/server/cli.test.ts +88 -0
- package/studio/server/cli.ts +240 -0
- package/studio/server/client-package.ts +25 -1
- package/studio/server/domain.test.ts +30 -2
- package/studio/server/domain.ts +14 -2
- package/studio/server/environment/dotenv-preview.test.ts +39 -0
- package/studio/server/environment/dotenv-preview.ts +27 -0
- package/studio/server/environment/files.test.ts +53 -0
- package/studio/server/{state/env.ts → environment/files.ts} +7 -48
- package/studio/server/{state → handoff}/copy.ts +21 -6
- package/studio/server/handoff/service.test.ts +22 -0
- package/studio/server/{state/handoff.ts → handoff/service.ts} +21 -14
- package/studio/server/instances/active.ts +72 -0
- package/studio/server/instances/deploy-record.test.ts +31 -0
- package/studio/server/instances/deploy-record.ts +37 -0
- package/studio/server/instances/deploy.ts +56 -0
- package/studio/server/{state/instance.test.ts → instances/probe.test.ts} +25 -21
- package/studio/server/instances/probe.ts +67 -0
- package/studio/server/instances/status.test.ts +12 -0
- package/studio/server/instances/status.ts +49 -0
- package/studio/server/introspect/anatomy/client-tree.ts +57 -0
- package/studio/server/introspect/anatomy/env-fields.ts +66 -0
- package/studio/server/introspect/anatomy/schema-definition.ts +53 -0
- package/studio/server/introspect/anatomy/source.ts +181 -0
- package/studio/server/introspect/anatomy/views/legacy.ts +232 -0
- package/studio/server/introspect/anatomy/views/routes.ts +105 -0
- package/studio/server/introspect/anatomy/views.ts +58 -0
- package/studio/server/introspect/anatomy-extras.test.ts +91 -4
- package/studio/server/introspect/anatomy-extras.ts +7 -726
- package/studio/server/introspect/anatomy.ts +46 -19
- package/studio/server/introspect/bundle.ts +15 -6
- package/studio/server/introspect/canonical-schema.test.ts +79 -0
- package/studio/server/introspect/canonical-schema.ts +80 -18
- package/studio/server/introspect/config-preview.test.ts +32 -0
- package/studio/server/introspect/config-preview.ts +119 -0
- package/studio/server/introspect/diff.test.ts +7 -11
- package/studio/server/introspect/diff.ts +28 -37
- package/studio/server/introspect/extractor.ts +8 -6
- package/studio/server/introspect/hash.ts +5 -2
- package/studio/server/introspect/overlay-tsmorph.ts +7 -1305
- package/studio/server/introspect/revision.test.ts +54 -0
- package/studio/server/introspect/revision.ts +49 -0
- package/studio/server/introspect/runtime.test.ts +63 -5
- package/studio/server/introspect/runtime.ts +49 -3
- package/studio/server/introspect/schema-ir-json.ts +181 -0
- package/studio/server/introspect/source-overlay/annotations.ts +14 -0
- package/studio/server/introspect/source-overlay/handlers.ts +681 -0
- package/studio/server/introspect/source-overlay/kernel-calls.ts +49 -0
- package/studio/server/introspect/source-overlay/project.ts +248 -0
- package/studio/server/introspect/source-overlay/spans.ts +360 -0
- package/studio/server/json.ts +46 -0
- package/studio/server/lifecycle.ts +5 -1
- package/studio/server/sse.test.ts +26 -0
- package/studio/server/sse.ts +4 -1
- package/studio/server/state/baseline.test.ts +212 -1
- package/studio/server/state/baseline.ts +145 -29
- package/studio/server/state/comments.test.ts +66 -1
- package/studio/server/state/comments.ts +173 -10
- package/studio/server/state/context.ts +34 -2
- package/studio/server/state/documents.ts +42 -1
- package/studio/server/state/integrations.ts +27 -1
- package/studio/server/state/layout.test.ts +34 -0
- package/studio/server/state/layout.ts +44 -17
- package/studio/server/state/settings.ts +29 -22
- package/studio/server/state/store.test.ts +122 -0
- package/studio/server/state/store.ts +66 -17
- package/studio/server/state/visibility.ts +22 -7
- package/studio/server/views/model.test.ts +57 -0
- package/studio/server/views/model.ts +66 -0
- package/studio/server/views/runtime.ts +40 -0
- package/studio/server/views/selection-repository.test.ts +31 -0
- package/studio/server/views/selection-repository.ts +60 -0
- package/studio/server/views/session.test.ts +73 -0
- package/studio/server/views/session.ts +166 -0
- package/studio/server/{state/views.test.ts → views/target.test.ts} +8 -159
- package/studio/server/views/target.ts +248 -0
- package/studio/server/watch.ts +1 -1
- package/studio/server/workspace/catalog.test.ts +17 -0
- package/studio/server/workspace/catalog.ts +43 -0
- package/studio/server/{state → workspace}/create.test.ts +1 -1
- package/studio/server/{state → workspace}/create.ts +1 -1
- package/studio/server/{state → workspace}/git.ts +1 -1
- package/studio/server/workspace/updates.ts +83 -0
- package/studio/server/workspace-state.ts +1 -1
- package/studio/shared/contracts/README.md +16 -0
- package/studio/shared/contracts/agent.ts +270 -0
- package/studio/shared/contracts/runtime.ts +50 -0
- package/studio/shared/contracts/schema.ts +462 -0
- package/studio/shared/contracts/surface.test.ts +319 -0
- package/studio/shared/contracts/workspace.ts +274 -0
- package/studio/shared/layout.test.ts +116 -0
- package/studio/shared/schema/identity.test.ts +58 -0
- package/studio/shared/schema/identity.ts +82 -0
- package/studio/shared/types.ts +11 -1099
- package/studio/tsconfig.json +1 -1
- package/viewer/dist/main.js +35 -35
- package/studio/client/dist/assets/index-BMdnsIJA.css +0 -1
- package/studio/client/dist/assets/index-D-vRV8w7.js +0 -8
- package/studio/client/dist/assets/index-LGSWRrk8.js +0 -81
- package/studio/server/state/catalog.ts +0 -117
- package/studio/server/state/instance.ts +0 -280
- package/studio/server/state/updates.ts +0 -63
- package/studio/server/state/views.ts +0 -535
package/studio/server/api.ts
CHANGED
|
@@ -1,436 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Studio JSON API composition root. It owns only the public `/api/` boundary,
|
|
3
|
+
* cross-site mutation guard, workspace-vs-domain addressing, and final 404.
|
|
4
|
+
* Capability modules under `api/` own the route implementations.
|
|
4
5
|
*/
|
|
5
|
-
import
|
|
6
|
+
import { handleDomainRoute } from './api/domain'
|
|
7
|
+
import { crossSiteBlocked, json, notFound, type Notify } from './api/http'
|
|
8
|
+
import { handleWorkspaceRoute } from './api/workspace'
|
|
9
|
+
import { getDomain } from './domain'
|
|
6
10
|
|
|
7
|
-
|
|
8
|
-
import { getBundle, getAnatomy, getCore, invalidate } from './cache'
|
|
9
|
-
import { type DomainHandle, allDomains, depsInstalled, getDomain } from './domain'
|
|
10
|
-
import { schemaRefs } from './introspect/schema-refs'
|
|
11
|
-
import { buildCatalog } from './state/catalog'
|
|
12
|
-
import {
|
|
13
|
-
addThreadEntry,
|
|
14
|
-
deleteComment,
|
|
15
|
-
editThreadEntry,
|
|
16
|
-
markOrphans,
|
|
17
|
-
mergeReply,
|
|
18
|
-
readComments,
|
|
19
|
-
setStatus,
|
|
20
|
-
upsertComment,
|
|
21
|
-
} from './state/comments'
|
|
22
|
-
import {
|
|
23
|
-
addUserContext,
|
|
24
|
-
deleteContext,
|
|
25
|
-
readContext,
|
|
26
|
-
setAutoInclude,
|
|
27
|
-
updateContext,
|
|
28
|
-
} from './state/context'
|
|
29
|
-
import { buildCopyMarkdown } from './state/copy'
|
|
30
|
-
import { createDomain } from './state/create'
|
|
31
|
-
import {
|
|
32
|
-
addDocument,
|
|
33
|
-
deleteDocument,
|
|
34
|
-
listDocuments,
|
|
35
|
-
readDocument,
|
|
36
|
-
updateDocument,
|
|
37
|
-
} from './state/documents'
|
|
38
|
-
import { isEnvName, readEnvModel, writeEnvUpdates } from './state/env'
|
|
39
|
-
import { detectGit } from './state/git'
|
|
40
|
-
import { refreshAuto } from './state/handoff'
|
|
41
|
-
import {
|
|
42
|
-
activeInstanceName,
|
|
43
|
-
instanceStatus,
|
|
44
|
-
listInstances,
|
|
45
|
-
runDeploy,
|
|
46
|
-
setActiveInstance,
|
|
47
|
-
} from './state/instance'
|
|
48
|
-
import { deleteIntegration, readIntegrations, upsertIntegration } from './state/integrations'
|
|
49
|
-
import { readLayout, resetLayout, saveLayout, setNodePositions } from './state/layout'
|
|
50
|
-
import { readSettings, updateSettings } from './state/settings'
|
|
51
|
-
import { applyUpdates, getUpdates } from './state/updates'
|
|
52
|
-
import { closeViewSession, getViewRuntime, launchViewSession } from './state/views'
|
|
53
|
-
import { readVisibility, resetVisibility, saveVisibility } from './state/visibility'
|
|
54
|
-
|
|
55
|
-
function json(data: unknown, status = 200): Response {
|
|
56
|
-
return new Response(JSON.stringify(data), {
|
|
57
|
-
status,
|
|
58
|
-
headers: { 'content-type': 'application/json' },
|
|
59
|
-
})
|
|
60
|
-
}
|
|
61
|
-
const notFound = () => json({ error: 'not found' }, 404)
|
|
62
|
-
const badReq = (m: string) => json({ error: m }, 400)
|
|
63
|
-
|
|
64
|
-
/** The deploy-target INSTANCE name, or null for route-based / unset targets (which have no instance to query). */
|
|
65
|
-
function instanceTarget(prodTarget?: string): string | null {
|
|
66
|
-
const m = prodTarget?.match(/^instance:\s*(.+)$/)
|
|
67
|
-
return m ? m[1].trim() || null : null
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Block cross-site state-changing requests (CSRF defense-in-depth on top of the
|
|
72
|
-
* loopback bind). A mutation triggers the local agent (file edits + shell), so a
|
|
73
|
-
* malicious page the user visits must NOT be able to POST here. The studio's own
|
|
74
|
-
* SPA is same-origin; curl/CLI/the MCP bridge send no Origin/Sec-Fetch-Site.
|
|
75
|
-
*/
|
|
76
|
-
function crossSiteBlocked(req: Request, url: URL): boolean {
|
|
77
|
-
if (req.method === 'GET' || req.method === 'HEAD') return false
|
|
78
|
-
const sfs = req.headers.get('sec-fetch-site')
|
|
79
|
-
if (sfs) return !(sfs === 'same-origin' || sfs === 'none')
|
|
80
|
-
const origin = req.headers.get('origin')
|
|
81
|
-
if (origin) {
|
|
82
|
-
try {
|
|
83
|
-
return new URL(origin).host !== url.host
|
|
84
|
-
} catch {
|
|
85
|
-
return true
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
return false // no Origin + no Sec-Fetch-Site → a non-browser client (curl/CLI/MCP)
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export type Notify = (e: StudioEvent) => void
|
|
11
|
+
export type { Notify } from './api/http'
|
|
92
12
|
|
|
93
13
|
export async function handleApi(req: Request, url: URL, notify: Notify): Promise<Response | null> {
|
|
94
14
|
const path = url.pathname
|
|
95
15
|
if (!path.startsWith('/api/')) return null
|
|
96
16
|
if (crossSiteBlocked(req, url)) return json({ error: 'cross-site request blocked' }, 403)
|
|
97
17
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
allDomains().map(async (h) => {
|
|
101
|
-
const bundle = await getBundle(h.id)
|
|
102
|
-
return {
|
|
103
|
-
id: h.id,
|
|
104
|
-
origin: bundle?.overlay.origin || h.origin || h.id,
|
|
105
|
-
path: h.root,
|
|
106
|
-
schemaDir: h.schemaDirName,
|
|
107
|
-
depsInstalled: depsInstalled(h.root),
|
|
108
|
-
hasGit: detectGit(h.root).hasGit,
|
|
109
|
-
configFile: h.configFile,
|
|
110
|
-
}
|
|
111
|
-
}),
|
|
112
|
-
)
|
|
113
|
-
return json(out)
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Scaffold a brand-new domain into the workspace (the one write that ADDS a domain).
|
|
117
|
-
if (path === '/api/workspace/create' && req.method === 'POST') {
|
|
118
|
-
const body = await req.json().catch(() => ({}))
|
|
119
|
-
const name = String(body.name ?? '')
|
|
120
|
-
const result = await createDomain(name, await activeInstanceName())
|
|
121
|
-
if (result.ok) notify({ type: 'workspace', domains: allDomains().map((d) => d.id) })
|
|
122
|
-
return json(result) // 200 with {ok:false,error} on failure — the SPA reads the result shape
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (path === '/api/catalog') {
|
|
126
|
-
return json(buildCatalog(allDomains().map((h) => ({ origin: h.origin ?? h.id, id: h.id }))))
|
|
127
|
-
}
|
|
18
|
+
const workspace = await handleWorkspaceRoute(req, path, notify)
|
|
19
|
+
if (workspace) return workspace
|
|
128
20
|
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
const body = await req.json().catch(() => ({}))
|
|
133
|
-
const name = String(body.name ?? '').trim()
|
|
134
|
-
if (!name) return badReq('name is required')
|
|
135
|
-
return json(await setActiveInstance(name))
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// /api/domain/:id/...
|
|
139
|
-
const m = path.match(/^\/api\/domain\/([^/]+)(\/.*)?$/)
|
|
140
|
-
if (!m) return notFound()
|
|
141
|
-
const id = decodeURIComponent(m[1])
|
|
142
|
-
const rest = m[2] ?? ''
|
|
21
|
+
const match = path.match(/^\/api\/domain\/([^/]+)(\/.*)?$/)
|
|
22
|
+
if (!match) return notFound()
|
|
23
|
+
const id = decodeURIComponent(match[1])
|
|
143
24
|
const handle = getDomain(id)
|
|
144
25
|
if (!handle) return notFound()
|
|
145
|
-
const root = handle.root
|
|
146
|
-
|
|
147
|
-
// ── context documents (handled BEFORE the json body parse — uploads are multipart) ──
|
|
148
|
-
if (rest === '/context/documents' && req.method === 'GET') return json(listDocuments(root))
|
|
149
|
-
if (rest === '/context/documents' && req.method === 'POST') {
|
|
150
|
-
const form = await req.formData()
|
|
151
|
-
const added = []
|
|
152
|
-
for (const value of form.getAll('files')) {
|
|
153
|
-
if (value instanceof File)
|
|
154
|
-
added.push(
|
|
155
|
-
addDocument(root, value.name, value.type, new Uint8Array(await value.arrayBuffer())),
|
|
156
|
-
)
|
|
157
|
-
}
|
|
158
|
-
return json(added)
|
|
159
|
-
}
|
|
160
|
-
const rawDoc = rest.match(/^\/context\/documents\/([^/]+)\/raw$/)
|
|
161
|
-
if (rawDoc && req.method === 'GET') {
|
|
162
|
-
const d = readDocument(root, decodeURIComponent(rawDoc[1]))
|
|
163
|
-
if (!d) return notFound()
|
|
164
|
-
return new Response(Bun.file(d.abs), { headers: { 'content-type': d.meta.type } })
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const body = req.method === 'POST' ? await req.json().catch(() => ({})) : {}
|
|
168
|
-
|
|
169
|
-
if (rest === '/context/documents/delete' && req.method === 'POST')
|
|
170
|
-
return json({ ok: deleteDocument(root, body.id) })
|
|
171
|
-
if (rest === '/context/documents/update' && req.method === 'POST') {
|
|
172
|
-
const meta = updateDocument(root, body.id, new TextEncoder().encode(String(body.content ?? '')))
|
|
173
|
-
return meta ? json(meta) : notFound()
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// ── schema bundle ──
|
|
177
|
-
if (rest === '/bundle') return json(await getBundle(id, url.searchParams.has('fresh')))
|
|
178
|
-
if (rest === '/anatomy') return json(await getAnatomy(id, url.searchParams.has('fresh')))
|
|
179
|
-
|
|
180
|
-
// ── update staleness (CLI + SDK deps, via `astrale update --check --json`) ──
|
|
181
|
-
if (rest === '/updates' && req.method === 'GET') return json(await getUpdates(root))
|
|
182
|
-
// ── apply the update (the "Update now" button) — runs `astrale update --yes` ──
|
|
183
|
-
if (rest === '/updates/apply' && req.method === 'POST') return json(await applyUpdates(root))
|
|
184
26
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const viewRuntimeM = rest.match(/^\/views\/([^/]+)\/runtime$/)
|
|
194
|
-
if (viewRuntimeM && req.method === 'GET') {
|
|
195
|
-
const slug = decodeURIComponent(viewRuntimeM[1])
|
|
196
|
-
if (!/^[a-z][a-z0-9-]*$/.test(slug)) return badReq('invalid view slug')
|
|
197
|
-
const anatomy = await getAnatomy(id)
|
|
198
|
-
const view = anatomy?.views.find((candidate) => candidate.slug === slug)
|
|
199
|
-
if (!view) return notFound()
|
|
200
|
-
const bundle = await getBundle(id)
|
|
201
|
-
const origin = bundle?.overlay.origin || anatomy?.overview.origin
|
|
202
|
-
if (!origin) return badReq('domain origin is unavailable')
|
|
203
|
-
return json(
|
|
204
|
-
await getViewRuntime(root, origin, view, bundle, readSettings(root).viewProbeTimeoutMs),
|
|
205
|
-
)
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
const viewSessionM = rest.match(/^\/views\/([^/]+)\/session$/)
|
|
209
|
-
if (viewSessionM && req.method === 'POST') {
|
|
210
|
-
const slug = decodeURIComponent(viewSessionM[1])
|
|
211
|
-
if (!/^[a-z][a-z0-9-]*$/.test(slug)) return badReq('invalid view slug')
|
|
212
|
-
const anatomy = await getAnatomy(id)
|
|
213
|
-
const view = anatomy?.views.find((candidate) => candidate.slug === slug)
|
|
214
|
-
if (!view) return notFound()
|
|
215
|
-
const bundle = await getBundle(id)
|
|
216
|
-
const origin = bundle?.overlay.origin || anatomy?.overview.origin
|
|
217
|
-
if (!origin) return badReq('domain origin is unavailable')
|
|
218
|
-
return json(
|
|
219
|
-
await launchViewSession(
|
|
220
|
-
root,
|
|
221
|
-
origin,
|
|
222
|
-
view,
|
|
223
|
-
bundle,
|
|
224
|
-
body,
|
|
225
|
-
readSettings(root).viewProbeTimeoutMs,
|
|
226
|
-
),
|
|
227
|
-
)
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// ── instance / deploy ──
|
|
231
|
-
if (rest === '/instance') {
|
|
232
|
-
const target = instanceTarget((await getAnatomy(id))?.overview.prodTarget)
|
|
233
|
-
const bundle = await getBundle(id)
|
|
234
|
-
return json(
|
|
235
|
-
await instanceStatus(
|
|
236
|
-
handle,
|
|
237
|
-
target,
|
|
238
|
-
bundle?.overlay.origin ?? null,
|
|
239
|
-
bundle?.schemaHash ?? null,
|
|
240
|
-
),
|
|
241
|
-
)
|
|
242
|
-
}
|
|
243
|
-
if (rest === '/instance/deploy' && req.method === 'POST') {
|
|
244
|
-
const bundle = await getBundle(id)
|
|
245
|
-
const result = await runDeploy(handle, bundle?.schemaHash ?? null)
|
|
246
|
-
return json(result)
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// ── comments ──
|
|
250
|
-
if (rest === '/comments') {
|
|
251
|
-
if (req.method === 'GET') {
|
|
252
|
-
const bundle = await getBundle(id)
|
|
253
|
-
// Valid schema targets come from the IR (authoritative), not source spans —
|
|
254
|
-
// so inherited / span-less members are never falsely orphaned. Spans are
|
|
255
|
-
// unioned in only as a belt-and-suspenders for refs the IR walk might miss.
|
|
256
|
-
const valid = new Set<string>(bundle ? schemaRefs(bundle) : [])
|
|
257
|
-
for (const k of Object.keys(bundle?.overlay.sourceSpans ?? {})) valid.add(k)
|
|
258
|
-
const store = readComments(root)
|
|
259
|
-
for (const c of store.comments)
|
|
260
|
-
for (const a of c.anchorRefs) if (a.kind !== 'schema') valid.add(a.ref)
|
|
261
|
-
return json(markOrphans(root, [...valid]))
|
|
262
|
-
}
|
|
263
|
-
if (body.action === 'create') {
|
|
264
|
-
const bundle = await getBundle(id)
|
|
265
|
-
const c = upsertComment(root, {
|
|
266
|
-
anchors: body.anchors ?? [],
|
|
267
|
-
anchorRefs: body.anchorRefs ?? [],
|
|
268
|
-
text: body.text,
|
|
269
|
-
firstRole: body.firstRole,
|
|
270
|
-
type: body.type,
|
|
271
|
-
options: body.options,
|
|
272
|
-
schemaVersion: bundle?.schemaHash,
|
|
273
|
-
})
|
|
274
|
-
notify({ type: 'comments', domainId: id })
|
|
275
|
-
return json(c)
|
|
276
|
-
}
|
|
277
|
-
if (body.action === 'reply') {
|
|
278
|
-
const c = addThreadEntry(root, body.id, body.entry)
|
|
279
|
-
notify({ type: 'comments', domainId: id })
|
|
280
|
-
return c ? json(c) : notFound()
|
|
281
|
-
}
|
|
282
|
-
if (body.action === 'edit') {
|
|
283
|
-
const c = editThreadEntry(root, body.id, body.entryId, String(body.text ?? ''))
|
|
284
|
-
notify({ type: 'comments', domainId: id })
|
|
285
|
-
return c ? json(c) : notFound()
|
|
286
|
-
}
|
|
287
|
-
if (body.action === 'status') {
|
|
288
|
-
const c = setStatus(root, body.id, body.status, body.closeNote)
|
|
289
|
-
notify({ type: 'comments', domainId: id })
|
|
290
|
-
return c ? json(c) : notFound()
|
|
291
|
-
}
|
|
292
|
-
if (body.action === 'delete') {
|
|
293
|
-
const ok = deleteComment(root, body.id)
|
|
294
|
-
notify({ type: 'comments', domainId: id })
|
|
295
|
-
return json({ ok })
|
|
296
|
-
}
|
|
297
|
-
return badReq('unknown comments action')
|
|
298
|
-
}
|
|
299
|
-
if (rest === '/comments/merge' && req.method === 'POST') {
|
|
300
|
-
const bundle = await getBundle(id)
|
|
301
|
-
try {
|
|
302
|
-
const result = mergeReply(root, bundle?.schemaHash ?? '', String(body.text ?? ''), {
|
|
303
|
-
dedupeAuthorText: true,
|
|
304
|
-
})
|
|
305
|
-
notify({ type: 'comments', domainId: id })
|
|
306
|
-
return json(result)
|
|
307
|
-
} catch (e: any) {
|
|
308
|
-
return badReq(String(e?.message ?? e))
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
const agentResponse = await handleAgentRoute({ req, url, rest, body, handle, notify })
|
|
313
|
-
if (agentResponse) return agentResponse
|
|
314
|
-
|
|
315
|
-
// ── context ──
|
|
316
|
-
if (rest === '/context') {
|
|
317
|
-
if (req.method === 'GET') return json(readContext(root))
|
|
318
|
-
if (body.action === 'add') return notifyJson(notify, id, 'comments', addUserContext(root, body))
|
|
319
|
-
if (body.action === 'update') {
|
|
320
|
-
const it = updateContext(root, body.id, body)
|
|
321
|
-
return it ? json(it) : notFound()
|
|
322
|
-
}
|
|
323
|
-
if (body.action === 'delete') return json({ ok: deleteContext(root, body.id) })
|
|
324
|
-
if (body.action === 'include') {
|
|
325
|
-
const it = setAutoInclude(root, body.id, body.include)
|
|
326
|
-
return it ? json(it) : notFound()
|
|
327
|
-
}
|
|
328
|
-
return badReq('unknown context action')
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// ── integrations ──
|
|
332
|
-
if (rest === '/integrations') {
|
|
333
|
-
const detected = (await getAnatomy(id))?.detectedIntegrations ?? []
|
|
334
|
-
if (req.method === 'GET') return json(readIntegrations(root, detected))
|
|
335
|
-
if (body.action === 'upsert') return json(upsertIntegration(root, body))
|
|
336
|
-
if (body.action === 'delete') return json({ ok: deleteIntegration(root, body.id) })
|
|
337
|
-
return badReq('unknown integrations action')
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// ── settings (per-domain power-user overrides) ──
|
|
341
|
-
if (rest === '/settings') {
|
|
342
|
-
if (req.method === 'GET') return json(readSettings(root))
|
|
343
|
-
if (body.action === 'update') {
|
|
344
|
-
const next = updateSettings(root, body.settings ?? {})
|
|
345
|
-
invalidate(id, 'anatomy') // integrationsDir changes what detection scans
|
|
346
|
-
notify({ type: 'anatomy-diff', domainId: id })
|
|
347
|
-
return json(next)
|
|
348
|
-
}
|
|
349
|
-
return badReq('unknown settings action')
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// ── env vars editor: read/parse + WRITE `.env.<env>` (the read-only studio's one
|
|
353
|
-
// sanctioned domain-file writer), reconciled against env.ts's Env contract ──
|
|
354
|
-
if (rest === '/env') {
|
|
355
|
-
const envName = req.method === 'GET' ? url.searchParams.get('env') : body.env
|
|
356
|
-
if (!isEnvName(envName)) return badReq('env must be "dev" or "prod"')
|
|
357
|
-
if (req.method === 'GET') return json(readEnvModel(root, envName))
|
|
358
|
-
if (req.method === 'POST') {
|
|
359
|
-
if (!body.updates || typeof body.updates !== 'object')
|
|
360
|
-
return badReq('updates object required')
|
|
361
|
-
const updates: Record<string, string | null> = {}
|
|
362
|
-
for (const [k, v] of Object.entries(body.updates as Record<string, unknown>)) {
|
|
363
|
-
if (!/^[A-Za-z_]\w*$/.test(k)) continue // ignore bad keys
|
|
364
|
-
updates[k] = v === null ? null : String(v)
|
|
365
|
-
}
|
|
366
|
-
try {
|
|
367
|
-
const model = writeEnvUpdates(root, envName, updates)
|
|
368
|
-
notify({ type: 'anatomy-diff', domainId: id }) // env.ts/.env changed — nudge consumers
|
|
369
|
-
return json(model)
|
|
370
|
-
} catch (e: any) {
|
|
371
|
-
return badReq(String(e?.message ?? e))
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
return badReq('GET or POST')
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// ── graph layout (persisted manual positions) ──
|
|
378
|
-
if (rest === '/layout') {
|
|
379
|
-
const bundle = await getBundle(id)
|
|
380
|
-
if (req.method === 'GET') return json(readLayout(root))
|
|
381
|
-
if (body.action === 'set')
|
|
382
|
-
return json(setNodePositions(root, body.positions ?? {}, bundle?.schemaHash))
|
|
383
|
-
if (body.action === 'save')
|
|
384
|
-
return json(saveLayout(root, body.positions ?? {}, bundle?.schemaHash))
|
|
385
|
-
if (body.action === 'reset') {
|
|
386
|
-
resetLayout(root)
|
|
387
|
-
return json({ ok: true })
|
|
388
|
-
}
|
|
389
|
-
return badReq('unknown layout action')
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// ── canvas visibility (persisted hide-set, inherited-edge toggle, interface nodes) ──
|
|
393
|
-
if (rest === '/visibility') {
|
|
394
|
-
if (req.method === 'GET') return json(readVisibility(root))
|
|
395
|
-
if (body.action === 'set')
|
|
396
|
-
return json(
|
|
397
|
-
saveVisibility(root, {
|
|
398
|
-
hidden: body.hidden ?? {},
|
|
399
|
-
showInheritedEdges: body.showInheritedEdges ?? true,
|
|
400
|
-
materializedInterfaces: body.materializedInterfaces ?? {},
|
|
401
|
-
}),
|
|
402
|
-
)
|
|
403
|
-
if (body.action === 'reset') return json(resetVisibility(root))
|
|
404
|
-
return badReq('unknown visibility action')
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// ── copy payload ──
|
|
408
|
-
if (rest === '/copy-payload' && req.method === 'POST') {
|
|
409
|
-
await refreshAuto(handle)
|
|
410
|
-
const bundle = await getBundle(id)
|
|
411
|
-
const ctx = readContext(root)
|
|
412
|
-
const open = readComments(root).comments.filter((c) => c.status === 'open')
|
|
413
|
-
const md = buildCopyMarkdown({
|
|
414
|
-
origin: bundle?.overlay.origin ?? id,
|
|
415
|
-
root,
|
|
416
|
-
schemaHash: bundle?.schemaHash ?? '',
|
|
417
|
-
openComments: open,
|
|
418
|
-
userContext: ctx.user,
|
|
419
|
-
autoContext: body.includeAuto ? ctx.auto.filter((a) => a.includeInHandoff) : [],
|
|
420
|
-
documents: listDocuments(root),
|
|
421
|
-
})
|
|
422
|
-
return json({ markdown: md, openComments: open.length })
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
return notFound()
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
function notifyJson(
|
|
429
|
-
notify: Notify,
|
|
430
|
-
id: string,
|
|
431
|
-
type: StudioEvent['type'],
|
|
432
|
-
data: unknown,
|
|
433
|
-
): Response {
|
|
434
|
-
notify({ type, domainId: id } as StudioEvent)
|
|
435
|
-
return json(data)
|
|
27
|
+
return handleDomainRoute({
|
|
28
|
+
req,
|
|
29
|
+
url,
|
|
30
|
+
rest: match[2] ?? '',
|
|
31
|
+
handle,
|
|
32
|
+
notify,
|
|
33
|
+
})
|
|
436
34
|
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { decodeBundleCacheEntry } from './cache'
|
|
4
|
+
|
|
5
|
+
function entry(): Record<string, unknown> {
|
|
6
|
+
return {
|
|
7
|
+
version: 4,
|
|
8
|
+
key: 'cache-key',
|
|
9
|
+
futureEntryField: { version: 5 },
|
|
10
|
+
bundle: {
|
|
11
|
+
domainId: 'notes',
|
|
12
|
+
renderFingerprint: 'render-hash',
|
|
13
|
+
schemaMode: 'legacy',
|
|
14
|
+
extractedBy: 'runtime-bun',
|
|
15
|
+
depsInstalled: true,
|
|
16
|
+
ir: {
|
|
17
|
+
version: 'legacy',
|
|
18
|
+
domain: 'notes.example.dev',
|
|
19
|
+
types: {},
|
|
20
|
+
interfaces: {},
|
|
21
|
+
classes: {},
|
|
22
|
+
imports: {},
|
|
23
|
+
functions: {},
|
|
24
|
+
},
|
|
25
|
+
overlay: {
|
|
26
|
+
origin: 'notes.example.dev',
|
|
27
|
+
requires: [],
|
|
28
|
+
crossDomainImports: [],
|
|
29
|
+
mixins: [],
|
|
30
|
+
handlerLinks: [],
|
|
31
|
+
sourceSpans: {},
|
|
32
|
+
annotations: [],
|
|
33
|
+
},
|
|
34
|
+
importedInterfaces: {},
|
|
35
|
+
error: null,
|
|
36
|
+
extractedAt: '2026-08-20T00:00:00.000Z',
|
|
37
|
+
futureBundleField: true,
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('bundle cache admits known structure while ignoring future fields', () => {
|
|
43
|
+
const decoded = decodeBundleCacheEntry(entry())
|
|
44
|
+
|
|
45
|
+
expect(decoded).toMatchObject({
|
|
46
|
+
version: 4,
|
|
47
|
+
key: 'cache-key',
|
|
48
|
+
bundle: { domainId: 'notes', schemaMode: 'legacy' },
|
|
49
|
+
})
|
|
50
|
+
expect(decoded && 'futureEntryField' in decoded).toBe(false)
|
|
51
|
+
expect(decoded && 'futureBundleField' in decoded.bundle).toBe(false)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
function canonicalEntry(): Record<string, unknown> {
|
|
55
|
+
const value = entry()
|
|
56
|
+
const bundle = value.bundle as Record<string, unknown>
|
|
57
|
+
bundle.schemaMode = 'canonical-admitted'
|
|
58
|
+
bundle.schemaRevision = `sha256:${'a'.repeat(64)}`
|
|
59
|
+
bundle.ir = {
|
|
60
|
+
format: 'astrale.dsl',
|
|
61
|
+
version: 'v1',
|
|
62
|
+
domain: 'notes.example.dev',
|
|
63
|
+
types: {},
|
|
64
|
+
interfaces: {},
|
|
65
|
+
classes: {},
|
|
66
|
+
imports: {},
|
|
67
|
+
functions: {},
|
|
68
|
+
}
|
|
69
|
+
bundle.schemaRoot = {
|
|
70
|
+
format: 'astrale.dsl',
|
|
71
|
+
version: 'v1',
|
|
72
|
+
origin: 'notes.example.dev',
|
|
73
|
+
}
|
|
74
|
+
return value
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
test('canonical-admitted cache entries require their root and a valid revision', () => {
|
|
78
|
+
expect(decodeBundleCacheEntry(canonicalEntry())).toBeDefined()
|
|
79
|
+
|
|
80
|
+
const missingRoot = canonicalEntry()
|
|
81
|
+
delete (missingRoot.bundle as Record<string, unknown>).schemaRoot
|
|
82
|
+
expect(decodeBundleCacheEntry(missingRoot)).toBeUndefined()
|
|
83
|
+
|
|
84
|
+
const invalidRoot = canonicalEntry()
|
|
85
|
+
const invalidRootBundle = invalidRoot.bundle as Record<string, unknown>
|
|
86
|
+
invalidRootBundle.schemaRoot = { canonical: true }
|
|
87
|
+
expect(decodeBundleCacheEntry(invalidRoot)).toBeUndefined()
|
|
88
|
+
|
|
89
|
+
const missingRevision = canonicalEntry()
|
|
90
|
+
delete (missingRevision.bundle as Record<string, unknown>).schemaRevision
|
|
91
|
+
expect(decodeBundleCacheEntry(missingRevision)).toBeUndefined()
|
|
92
|
+
|
|
93
|
+
const invalidRevision = canonicalEntry()
|
|
94
|
+
const invalidRevisionBundle = invalidRevision.bundle as Record<string, unknown>
|
|
95
|
+
invalidRevisionBundle.schemaRevision = 'sha256:not-a-revision'
|
|
96
|
+
expect(decodeBundleCacheEntry(invalidRevision)).toBeUndefined()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('bundle cache rejects corrupt nested business shapes', () => {
|
|
100
|
+
const corruptIr = entry()
|
|
101
|
+
const bundle = corruptIr.bundle as Record<string, unknown>
|
|
102
|
+
const ir = bundle.ir as Record<string, unknown>
|
|
103
|
+
ir.classes = { Broken: null }
|
|
104
|
+
expect(decodeBundleCacheEntry(corruptIr)).toBeUndefined()
|
|
105
|
+
|
|
106
|
+
const corruptOverlay = entry()
|
|
107
|
+
const secondBundle = corruptOverlay.bundle as Record<string, unknown>
|
|
108
|
+
const overlay = secondBundle.overlay as Record<string, unknown>
|
|
109
|
+
overlay.handlerLinks = [{ owner: 'Thing' }]
|
|
110
|
+
expect(decodeBundleCacheEntry(corruptOverlay)).toBeUndefined()
|
|
111
|
+
})
|