@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.
- package/.check-workspace.cjs +40 -0
- package/README.md +151 -0
- package/dist/astrale.js +59749 -0
- package/package.json +90 -0
- package/src/command.ts +40 -0
- package/src/commands/__tests__/admin-instance.test.ts +73 -0
- package/src/commands/__tests__/auth-login.test.ts +178 -0
- package/src/commands/__tests__/auth-token.test.ts +223 -0
- package/src/commands/__tests__/call.test.ts +72 -0
- package/src/commands/__tests__/domain-list.test.ts +74 -0
- package/src/commands/__tests__/help-contract.test.ts +136 -0
- package/src/commands/__tests__/install-identity-override.test.ts +65 -0
- package/src/commands/__tests__/instance-bookmark.test.ts +101 -0
- package/src/commands/__tests__/instance-create-hosts.test.ts +29 -0
- package/src/commands/__tests__/instance-list-rows.test.ts +63 -0
- package/src/commands/__tests__/logs.test.ts +117 -0
- package/src/commands/__tests__/ls.test.ts +25 -0
- package/src/commands/__tests__/setup-plan.test.ts +61 -0
- package/src/commands/admin/status.ts +61 -0
- package/src/commands/admin/use.ts +77 -0
- package/src/commands/auth/login.ts +82 -0
- package/src/commands/auth/logout.ts +50 -0
- package/src/commands/auth/status.ts +86 -0
- package/src/commands/auth/token.ts +162 -0
- package/src/commands/browser.ts +207 -0
- package/src/commands/call.ts +300 -0
- package/src/commands/describe.ts +182 -0
- package/src/commands/domain/install.ts +420 -0
- package/src/commands/domain/list.ts +154 -0
- package/src/commands/domain/publish.ts +155 -0
- package/src/commands/get.ts +60 -0
- package/src/commands/identity/create.ts +26 -0
- package/src/commands/identity/delete.ts +18 -0
- package/src/commands/identity/export.ts +66 -0
- package/src/commands/identity/import.ts +101 -0
- package/src/commands/identity/list.ts +56 -0
- package/src/commands/identity/register.ts +170 -0
- package/src/commands/identity/sync.ts +32 -0
- package/src/commands/identity/unsync.ts +24 -0
- package/src/commands/identity/use.ts +18 -0
- package/src/commands/identity/whoami.ts +34 -0
- package/src/commands/idp/add.ts +150 -0
- package/src/commands/idp/list.ts +57 -0
- package/src/commands/idp/refresh.ts +38 -0
- package/src/commands/idp/remove.ts +36 -0
- package/src/commands/idp/show.ts +29 -0
- package/src/commands/instance/active.ts +64 -0
- package/src/commands/instance/bookmark.ts +72 -0
- package/src/commands/instance/create.ts +69 -0
- package/src/commands/instance/delete.ts +72 -0
- package/src/commands/instance/forget.ts +26 -0
- package/src/commands/instance/list.ts +149 -0
- package/src/commands/instance/status.ts +42 -0
- package/src/commands/instance/use.ts +210 -0
- package/src/commands/logs.ts +347 -0
- package/src/commands/ls.ts +229 -0
- package/src/commands/query.ts +32 -0
- package/src/commands/setup.ts +54 -0
- package/src/commands/status.ts +60 -0
- package/src/commands/studio.ts +401 -0
- package/src/commands/token.ts +77 -0
- package/src/commands/update.ts +267 -0
- package/src/commands/use.ts +87 -0
- package/src/errors.ts +65 -0
- package/src/kernel/__tests__/auth.test.ts +77 -0
- package/src/kernel/__tests__/errors.test.ts +43 -0
- package/src/kernel/__tests__/remote-routing.test.ts +70 -0
- package/src/kernel/auth.ts +234 -0
- package/src/kernel/ca-fetch.ts +119 -0
- package/src/kernel/client.ts +191 -0
- package/src/kernel/errors.ts +280 -0
- package/src/kernel/expand.ts +217 -0
- package/src/kernel/index.ts +14 -0
- package/src/kernel/options.ts +22 -0
- package/src/kernel/remote-routing.ts +88 -0
- package/src/kernel/run.ts +63 -0
- package/src/kernel/types.ts +14 -0
- package/src/lib/__tests__/admin-target.test.ts +112 -0
- package/src/lib/__tests__/binary.test.ts +56 -0
- package/src/lib/__tests__/command-dx.test.ts +58 -0
- package/src/lib/__tests__/concurrency.test.ts +62 -0
- package/src/lib/__tests__/config.test.ts +53 -0
- package/src/lib/__tests__/design.test.ts +99 -0
- package/src/lib/__tests__/domain-identity.test.ts +60 -0
- package/src/lib/__tests__/format.test.ts +22 -0
- package/src/lib/__tests__/fs-atomic.test.ts +104 -0
- package/src/lib/__tests__/identity.test.ts +79 -0
- package/src/lib/__tests__/idp-session.driver.ts +53 -0
- package/src/lib/__tests__/idp-session.test.ts +357 -0
- package/src/lib/__tests__/idp.test.ts +385 -0
- package/src/lib/__tests__/instance-candidates.test.ts +73 -0
- package/src/lib/__tests__/instance-target.test.ts +183 -0
- package/src/lib/__tests__/instance.test.ts +136 -0
- package/src/lib/__tests__/keys.test.ts +129 -0
- package/src/lib/__tests__/local-status.test.ts +202 -0
- package/src/lib/__tests__/output.test.ts +150 -0
- package/src/lib/__tests__/panel.test.ts +40 -0
- package/src/lib/__tests__/port.test.ts +44 -0
- package/src/lib/__tests__/prompt.test.ts +25 -0
- package/src/lib/__tests__/sdk-deps.test.ts +68 -0
- package/src/lib/__tests__/self.test.ts +272 -0
- package/src/lib/__tests__/studio-server-deps.test.ts +74 -0
- package/src/lib/__tests__/table.test.ts +53 -0
- package/src/lib/__tests__/update.test.ts +246 -0
- package/src/lib/__tests__/use-target.test.ts +56 -0
- package/src/lib/__tests__/validation.test.ts +34 -0
- package/src/lib/admin-domain.ts +25 -0
- package/src/lib/admin-instance.ts +26 -0
- package/src/lib/admin-target.ts +217 -0
- package/src/lib/binary.ts +131 -0
- package/src/lib/browser.ts +150 -0
- package/src/lib/command-dx.ts +161 -0
- package/src/lib/concurrency.ts +31 -0
- package/src/lib/config.ts +45 -0
- package/src/lib/domain-identity.ts +49 -0
- package/src/lib/env.ts +49 -0
- package/src/lib/format.ts +4 -0
- package/src/lib/fs-atomic.ts +126 -0
- package/src/lib/identity.ts +256 -0
- package/src/lib/idp-session.ts +134 -0
- package/src/lib/idp.ts +876 -0
- package/src/lib/instance-candidates.ts +49 -0
- package/src/lib/instance-target.ts +182 -0
- package/src/lib/instance.ts +395 -0
- package/src/lib/keys.ts +294 -0
- package/src/lib/local-status.ts +152 -0
- package/src/lib/log.ts +116 -0
- package/src/lib/login-flow.ts +164 -0
- package/src/lib/meta.ts +86 -0
- package/src/lib/output.ts +222 -0
- package/src/lib/panel.ts +61 -0
- package/src/lib/paths.ts +11 -0
- package/src/lib/port.ts +41 -0
- package/src/lib/proc.ts +82 -0
- package/src/lib/prompt.ts +136 -0
- package/src/lib/provision-instance.ts +170 -0
- package/src/lib/sdk-deps.ts +104 -0
- package/src/lib/self.ts +166 -0
- package/src/lib/skills.ts +171 -0
- package/src/lib/table.ts +62 -0
- package/src/lib/update.ts +315 -0
- package/src/lib/use-target.ts +24 -0
- package/src/lib/validation.ts +59 -0
- package/src/program.ts +200 -0
- package/src/registry.ts +59 -0
- package/src/setup/__tests__/util.test.ts +29 -0
- package/src/setup/engine.ts +83 -0
- package/src/setup/render.ts +109 -0
- package/src/setup/steps/admin.ts +78 -0
- package/src/setup/steps/agent-browser.ts +81 -0
- package/src/setup/steps/auth.ts +54 -0
- package/src/setup/steps/domain.ts +68 -0
- package/src/setup/steps/index.ts +18 -0
- package/src/setup/steps/instance.ts +119 -0
- package/src/setup/steps/skills-bridge.ts +70 -0
- package/src/setup/steps/skills.ts +59 -0
- package/src/setup/types.ts +61 -0
- package/src/setup/util.ts +34 -0
- package/src/test-utils.ts +18 -0
- package/studio/client/dist/assets/index-DOwzZAEK.css +1 -0
- package/studio/client/dist/assets/index-wtU0Zxhy.js +183 -0
- package/studio/client/dist/index.html +13 -0
- package/studio/package.json +62 -0
- package/studio/server/agent/ask.ts +68 -0
- package/studio/server/agent/bridge-mcp.ts +182 -0
- package/studio/server/agent/bridge.ts +188 -0
- package/studio/server/agent/claude.ts +666 -0
- package/studio/server/agent/mock.ts +186 -0
- package/studio/server/agent/prompt.ts +202 -0
- package/studio/server/agent/registry.ts +29 -0
- package/studio/server/agent/runner.ts +484 -0
- package/studio/server/agent/schema-map.ts +112 -0
- package/studio/server/agent/types.ts +120 -0
- package/studio/server/api.ts +574 -0
- package/studio/server/cache.ts +138 -0
- package/studio/server/detect.ts +81 -0
- package/studio/server/domain.ts +70 -0
- package/studio/server/index.ts +136 -0
- package/studio/server/introspect/anatomy-extras.ts +398 -0
- package/studio/server/introspect/anatomy.ts +108 -0
- package/studio/server/introspect/bundle.ts +57 -0
- package/studio/server/introspect/core-extractor.ts +119 -0
- package/studio/server/introspect/core.ts +44 -0
- package/studio/server/introspect/diff.ts +133 -0
- package/studio/server/introspect/extractor.ts +102 -0
- package/studio/server/introspect/hash.ts +21 -0
- package/studio/server/introspect/overlay-tsmorph.ts +874 -0
- package/studio/server/introspect/overlay.ts +57 -0
- package/studio/server/introspect/runtime.ts +99 -0
- package/studio/server/introspect/schema-refs.ts +46 -0
- package/studio/server/lifecycle.ts +38 -0
- package/studio/server/sse.ts +57 -0
- package/studio/server/state/baseline.ts +211 -0
- package/studio/server/state/catalog.ts +117 -0
- package/studio/server/state/comments.ts +321 -0
- package/studio/server/state/context.ts +167 -0
- package/studio/server/state/copy.ts +156 -0
- package/studio/server/state/create.ts +156 -0
- package/studio/server/state/documents.ts +70 -0
- package/studio/server/state/env.ts +161 -0
- package/studio/server/state/git.ts +75 -0
- package/studio/server/state/handoff.ts +55 -0
- package/studio/server/state/harness-gateway.ts +181 -0
- package/studio/server/state/harness-token.ts +0 -0
- package/studio/server/state/instance.ts +244 -0
- package/studio/server/state/integrations.ts +55 -0
- package/studio/server/state/layout.ts +55 -0
- package/studio/server/state/settings.ts +39 -0
- package/studio/server/state/store.ts +97 -0
- package/studio/server/state/updates.ts +63 -0
- package/studio/server/state/usage.ts +37 -0
- package/studio/server/state/views.ts +138 -0
- package/studio/server/state/visibility.ts +33 -0
- package/studio/server/watch.ts +81 -0
- package/studio/server/workspace-state.ts +26 -0
- package/studio/server/workspace-watch.ts +101 -0
- package/studio/shared/types.ts +873 -0
- package/studio/tsconfig.json +23 -0
- 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
|
+
}
|