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