@astrale-os/cli 1.0.0-beta.0 → 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 +3769 -3549
- 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 +5 -3
- package/src/admin/instance/client.ts +9 -27
- 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 +73 -0
- 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 +39 -13
- 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
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { afterEach, expect, test } from 'bun:test'
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { asJsonRecord, asString } from '../json'
|
|
7
|
+
import {
|
|
8
|
+
ensureDir,
|
|
9
|
+
listState,
|
|
10
|
+
readJson,
|
|
11
|
+
readState,
|
|
12
|
+
removeState,
|
|
13
|
+
stateExists,
|
|
14
|
+
statePath,
|
|
15
|
+
writeJson,
|
|
16
|
+
writeState,
|
|
17
|
+
writeStateBuffer,
|
|
18
|
+
} from './store'
|
|
19
|
+
|
|
20
|
+
const roots: string[] = []
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('readJson returns only values admitted by its explicit decoder', () => {
|
|
27
|
+
const root = mkdtempSync(join(tmpdir(), 'studio-json-store-'))
|
|
28
|
+
roots.push(root)
|
|
29
|
+
const decodeName = (value: unknown): string | undefined => asString(asJsonRecord(value)?.name)
|
|
30
|
+
|
|
31
|
+
writeState(root, 'value.json', '{not json')
|
|
32
|
+
expect(readJson(root, 'value.json', decodeName, 'fallback')).toBe('fallback')
|
|
33
|
+
|
|
34
|
+
writeJson(root, 'value.json', { name: 42, future: { revision: 2 } })
|
|
35
|
+
expect(readJson(root, 'value.json', decodeName, 'fallback')).toBe('fallback')
|
|
36
|
+
|
|
37
|
+
writeJson(root, 'value.json', { name: 'accepted', future: { revision: 2 } })
|
|
38
|
+
expect(readJson(root, 'value.json', decodeName, 'fallback')).toBe('accepted')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const guardedOperations = [
|
|
42
|
+
{
|
|
43
|
+
name: 'ensureDir',
|
|
44
|
+
run: (root: string, prefix: string) => ensureDir(root, `${prefix}/created-dir`),
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'writeState',
|
|
48
|
+
run: (root: string, prefix: string) => writeState(root, `${prefix}/created.txt`, 'nope'),
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'writeStateBuffer',
|
|
52
|
+
run: (root: string, prefix: string) =>
|
|
53
|
+
writeStateBuffer(root, `${prefix}/created.bin`, new Uint8Array([1, 2, 3])),
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'statePath',
|
|
57
|
+
run: (root: string, prefix: string) => statePath(root, `${prefix}/created.txt`),
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'readState',
|
|
61
|
+
run: (root: string, prefix: string) => readState(root, `${prefix}/marker.txt`),
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'listState',
|
|
65
|
+
run: (root: string, prefix: string) => listState(root, prefix),
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'removeState',
|
|
69
|
+
run: (root: string, prefix: string) => removeState(root, prefix),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: 'stateExists',
|
|
73
|
+
run: (root: string, prefix: string) => stateExists(root, prefix),
|
|
74
|
+
},
|
|
75
|
+
] as const
|
|
76
|
+
|
|
77
|
+
for (const operation of guardedOperations) {
|
|
78
|
+
test(`${operation.name} rejects lexical traversal outside .domain-studio`, () => {
|
|
79
|
+
const root = mkdtempSync(join(tmpdir(), 'studio-store-traversal-'))
|
|
80
|
+
roots.push(root)
|
|
81
|
+
|
|
82
|
+
expect(() => operation.run(root, '../escape')).toThrow('state-allowlist violation')
|
|
83
|
+
expect(existsSync(join(root, 'escape'))).toBe(false)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test(`${operation.name} rejects an existing descendant symlink escape`, () => {
|
|
87
|
+
const container = mkdtempSync(join(tmpdir(), 'studio-store-symlink-'))
|
|
88
|
+
roots.push(container)
|
|
89
|
+
const root = join(container, 'domain')
|
|
90
|
+
const external = join(container, 'external')
|
|
91
|
+
mkdirSync(join(root, '.domain-studio'), { recursive: true })
|
|
92
|
+
mkdirSync(external)
|
|
93
|
+
writeFileSync(join(external, 'marker.txt'), 'keep')
|
|
94
|
+
symlinkSync(external, join(root, '.domain-studio', 'escape'), 'dir')
|
|
95
|
+
|
|
96
|
+
expect(() => operation.run(root, 'escape')).toThrow('state-allowlist violation')
|
|
97
|
+
expect(existsSync(join(external, 'marker.txt'))).toBe(true)
|
|
98
|
+
expect(existsSync(join(external, 'created.txt'))).toBe(false)
|
|
99
|
+
expect(existsSync(join(external, 'created.bin'))).toBe(false)
|
|
100
|
+
expect(existsSync(join(external, 'created-dir'))).toBe(false)
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
test('rejects a .domain-studio symlink but permits the domain root itself to be symlinked', () => {
|
|
105
|
+
const container = mkdtempSync(join(tmpdir(), 'studio-store-root-symlink-'))
|
|
106
|
+
roots.push(container)
|
|
107
|
+
const physicalDomain = join(container, 'physical-domain')
|
|
108
|
+
const linkedDomain = join(container, 'linked-domain')
|
|
109
|
+
const external = join(container, 'external')
|
|
110
|
+
mkdirSync(physicalDomain)
|
|
111
|
+
mkdirSync(external)
|
|
112
|
+
symlinkSync(physicalDomain, linkedDomain, 'dir')
|
|
113
|
+
|
|
114
|
+
writeState(linkedDomain, 'allowed.txt', 'inside')
|
|
115
|
+
expect(readState(linkedDomain, 'allowed.txt')).toBe('inside')
|
|
116
|
+
expect(existsSync(join(physicalDomain, '.domain-studio', 'allowed.txt'))).toBe(true)
|
|
117
|
+
|
|
118
|
+
rmSync(join(physicalDomain, '.domain-studio'), { recursive: true })
|
|
119
|
+
symlinkSync(external, join(physicalDomain, '.domain-studio'), 'dir')
|
|
120
|
+
expect(() => writeState(linkedDomain, 'escaped.txt', 'nope')).toThrow('state-allowlist violation')
|
|
121
|
+
expect(existsSync(join(external, 'escaped.txt'))).toBe(false)
|
|
122
|
+
})
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* store.ts — the
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* done elsewhere; this module never writes outside the dotted folder.
|
|
2
|
+
* store.ts — the gateway for Studio-owned persistence under
|
|
3
|
+
* `<domain>/.domain-studio/`. It rejects every target outside that directory.
|
|
4
|
+
* Explicit user-requested domain edits (for example environment files or
|
|
5
|
+
* workspace creation) belong to their own feature boundary, not this state store.
|
|
7
6
|
*/
|
|
8
|
-
import {
|
|
9
|
-
|
|
7
|
+
import {
|
|
8
|
+
existsSync,
|
|
9
|
+
lstatSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
realpathSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from 'node:fs'
|
|
17
|
+
import { dirname, join, relative, resolve, sep } from 'node:path'
|
|
18
|
+
|
|
19
|
+
import { parseJson, type JsonDecoder } from '../json'
|
|
10
20
|
|
|
11
21
|
export const DOT = '.domain-studio'
|
|
12
22
|
|
|
@@ -18,7 +28,43 @@ function assertInsideDot(domainRoot: string, target: string): string {
|
|
|
18
28
|
const abs = resolve(target)
|
|
19
29
|
const root = resolve(dotDir(domainRoot))
|
|
20
30
|
if (abs !== root && !abs.startsWith(root + sep)) {
|
|
21
|
-
throw new Error(`
|
|
31
|
+
throw new Error(`state-allowlist violation: ${abs} is outside ${root}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Lexical containment alone is insufficient: an existing `.domain-studio`
|
|
35
|
+
// symlink (or any symlink below it) could otherwise redirect an allowed path
|
|
36
|
+
// outside the state directory. Resolve every existing prefix against the
|
|
37
|
+
// physical domain root. Resolving domainRoot first intentionally permits the
|
|
38
|
+
// domain root itself to be a symlink.
|
|
39
|
+
let physicalDomainRoot: string
|
|
40
|
+
try {
|
|
41
|
+
physicalDomainRoot = realpathSync(domainRoot)
|
|
42
|
+
} catch {
|
|
43
|
+
physicalDomainRoot = resolve(domainRoot)
|
|
44
|
+
}
|
|
45
|
+
const physicalRoot = resolve(physicalDomainRoot, DOT)
|
|
46
|
+
const relativeTarget = relative(root, abs)
|
|
47
|
+
const parts = relativeTarget === '' ? [] : relativeTarget.split(sep)
|
|
48
|
+
let prefix = root
|
|
49
|
+
for (const part of ['', ...parts]) {
|
|
50
|
+
if (part) prefix = join(prefix, part)
|
|
51
|
+
try {
|
|
52
|
+
lstatSync(prefix)
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
55
|
+
if (code === 'ENOENT') break
|
|
56
|
+
throw error
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let physicalPrefix: string
|
|
60
|
+
try {
|
|
61
|
+
physicalPrefix = realpathSync(prefix)
|
|
62
|
+
} catch {
|
|
63
|
+
throw new Error(`state-allowlist violation: ${prefix} is an unresolved symlink`)
|
|
64
|
+
}
|
|
65
|
+
if (physicalPrefix !== physicalRoot && !physicalPrefix.startsWith(physicalRoot + sep)) {
|
|
66
|
+
throw new Error(`state-allowlist violation: ${prefix} resolves outside ${physicalRoot}`)
|
|
67
|
+
}
|
|
22
68
|
}
|
|
23
69
|
return abs
|
|
24
70
|
}
|
|
@@ -55,23 +101,26 @@ export function statePath(domainRoot: string, subpath: string): string {
|
|
|
55
101
|
}
|
|
56
102
|
|
|
57
103
|
export function readState(domainRoot: string, subpath: string): string | null {
|
|
58
|
-
const target = join(dotDir(domainRoot), subpath)
|
|
104
|
+
const target = assertInsideDot(domainRoot, join(dotDir(domainRoot), subpath))
|
|
59
105
|
if (!existsSync(target)) return null
|
|
60
106
|
return readFileSync(target, 'utf8')
|
|
61
107
|
}
|
|
62
108
|
|
|
63
|
-
export function readJson<T>(
|
|
109
|
+
export function readJson<T>(
|
|
110
|
+
domainRoot: string,
|
|
111
|
+
subpath: string,
|
|
112
|
+
decode: JsonDecoder<T>,
|
|
113
|
+
fallback: T,
|
|
114
|
+
): T {
|
|
64
115
|
const raw = readState(domainRoot, subpath)
|
|
65
116
|
if (raw == null) return fallback
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return fallback
|
|
70
|
-
}
|
|
117
|
+
const parsed = parseJson(raw)
|
|
118
|
+
if (parsed === undefined) return fallback
|
|
119
|
+
return decode(parsed) ?? fallback
|
|
71
120
|
}
|
|
72
121
|
|
|
73
122
|
export function listState(domainRoot: string, subpath: string): string[] {
|
|
74
|
-
const target = join(dotDir(domainRoot), subpath)
|
|
123
|
+
const target = assertInsideDot(domainRoot, join(dotDir(domainRoot), subpath))
|
|
75
124
|
if (!existsSync(target)) return []
|
|
76
125
|
return readdirSync(target)
|
|
77
126
|
}
|
|
@@ -83,7 +132,7 @@ export function removeState(domainRoot: string, subpath: string): void {
|
|
|
83
132
|
}
|
|
84
133
|
|
|
85
134
|
export function stateExists(domainRoot: string, subpath: string): boolean {
|
|
86
|
-
return existsSync(join(dotDir(domainRoot), subpath))
|
|
135
|
+
return existsSync(assertInsideDot(domainRoot, join(dotDir(domainRoot), subpath)))
|
|
87
136
|
}
|
|
88
137
|
|
|
89
138
|
/** Initialise the dotted folder skeleton + a .cache/.gitignore (never touches the user's root .gitignore). */
|
|
@@ -5,13 +5,10 @@
|
|
|
5
5
|
* stable refs, so they survive schema edits (a removed ref is harmless).
|
|
6
6
|
* Persisted via the allow-listed store (visibility.json).
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import type { VisibilityState } from '../../shared/types'
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
showInheritedEdges: boolean
|
|
13
|
-
materializedInterfaces: Record<string, true>
|
|
14
|
-
}
|
|
10
|
+
import { asBoolean, asJsonRecord } from '../json'
|
|
11
|
+
import { readJson, removeState, writeJson } from './store'
|
|
15
12
|
|
|
16
13
|
const FILE = 'visibility.json'
|
|
17
14
|
const DEFAULT: VisibilityState = {
|
|
@@ -20,10 +17,28 @@ const DEFAULT: VisibilityState = {
|
|
|
20
17
|
materializedInterfaces: {},
|
|
21
18
|
}
|
|
22
19
|
|
|
20
|
+
function trueSet(value: unknown): Record<string, true> {
|
|
21
|
+
return Object.fromEntries(
|
|
22
|
+
Object.entries(asJsonRecord(value) ?? {}).filter(
|
|
23
|
+
(entry): entry is [string, true] => entry[1] === true,
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function decodeVisibility(value: unknown): VisibilityState | undefined {
|
|
29
|
+
const record = asJsonRecord(value)
|
|
30
|
+
if (!record) return undefined
|
|
31
|
+
return {
|
|
32
|
+
hidden: trueSet(record.hidden),
|
|
33
|
+
showInheritedEdges: asBoolean(record.showInheritedEdges) ?? DEFAULT.showInheritedEdges,
|
|
34
|
+
materializedInterfaces: trueSet(record.materializedInterfaces),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
23
38
|
export function readVisibility(root: string): VisibilityState {
|
|
24
39
|
// merge over DEFAULT so a file written before a field existed (e.g. pre-materialize)
|
|
25
40
|
// still returns the complete shape — the client reads every field unconditionally.
|
|
26
|
-
return
|
|
41
|
+
return readJson(root, FILE, decodeVisibility, { ...DEFAULT })
|
|
27
42
|
}
|
|
28
43
|
|
|
29
44
|
export function saveVisibility(root: string, state: VisibilityState): VisibilityState {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import type { RememberedViewTarget, ViewTargetCandidate } from '../../shared/types'
|
|
4
|
+
|
|
5
|
+
import { reconcileRememberedTarget, targetFromRow } from './model'
|
|
6
|
+
|
|
7
|
+
describe('target candidates', () => {
|
|
8
|
+
const remembered: RememberedViewTarget = {
|
|
9
|
+
id: 'gone-id',
|
|
10
|
+
className: 'Issue',
|
|
11
|
+
classOrigin: 'issues.astrale.ai',
|
|
12
|
+
label: 'Deleted issue',
|
|
13
|
+
}
|
|
14
|
+
const candidate: ViewTargetCandidate = {
|
|
15
|
+
id: 'live-id',
|
|
16
|
+
ref: '@live-id',
|
|
17
|
+
className: 'Issue',
|
|
18
|
+
classOrigin: 'issues.astrale.ai',
|
|
19
|
+
label: 'Live issue',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('turns a missing remembered node into an explicit stale selection', () => {
|
|
23
|
+
expect(reconcileRememberedTarget(remembered, [candidate])).toEqual({
|
|
24
|
+
selected: null,
|
|
25
|
+
stale: remembered,
|
|
26
|
+
})
|
|
27
|
+
expect(reconcileRememberedTarget({ ...remembered, id: 'live-id' }, [candidate])).toEqual({
|
|
28
|
+
selected: candidate,
|
|
29
|
+
stale: null,
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('builds friendly candidates from generic qualified node properties', () => {
|
|
34
|
+
expect(
|
|
35
|
+
targetFromRow(
|
|
36
|
+
{
|
|
37
|
+
id: 'iss-1',
|
|
38
|
+
props: {
|
|
39
|
+
'kernel.astrale.ai:interface.Named.property.name': 'Broken authorization badge',
|
|
40
|
+
'kernel.astrale.ai:interface.Descriptable.property.description': 'Studio regression',
|
|
41
|
+
'kernel.astrale.ai:interface.Statused.property.status': 'open',
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
'Issue',
|
|
45
|
+
'issues.astrale.ai',
|
|
46
|
+
),
|
|
47
|
+
).toEqual({
|
|
48
|
+
id: 'iss-1',
|
|
49
|
+
ref: '@iss-1',
|
|
50
|
+
className: 'Issue',
|
|
51
|
+
classOrigin: 'issues.astrale.ai',
|
|
52
|
+
label: 'Broken authorization badge',
|
|
53
|
+
description: 'Studio regression',
|
|
54
|
+
status: 'open',
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RememberedViewTarget,
|
|
3
|
+
ViewTargetCandidate,
|
|
4
|
+
ViewTargetResult,
|
|
5
|
+
} from '../../shared/types'
|
|
6
|
+
|
|
7
|
+
const NAMED_NAME = 'kernel.astrale.ai:interface.Named.property.name'
|
|
8
|
+
const DESCRIPTABLE_DESCRIPTION = 'kernel.astrale.ai:interface.Descriptable.property.description'
|
|
9
|
+
const STATUSED_STATUS = 'kernel.astrale.ai:interface.Statused.property.status'
|
|
10
|
+
|
|
11
|
+
export interface RawTargetRow {
|
|
12
|
+
id?: unknown
|
|
13
|
+
props?: Record<string, unknown>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function reconcileRememberedTarget(
|
|
17
|
+
remembered: RememberedViewTarget | null,
|
|
18
|
+
items: ViewTargetCandidate[],
|
|
19
|
+
): Pick<ViewTargetResult, 'selected' | 'stale'> {
|
|
20
|
+
if (!remembered) return { selected: null, stale: null }
|
|
21
|
+
const selected = items.find((item) => item.id === remembered.id) ?? null
|
|
22
|
+
return { selected, stale: selected ? null : remembered }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function targetFromRow(
|
|
26
|
+
row: RawTargetRow,
|
|
27
|
+
className: string,
|
|
28
|
+
classOrigin: string,
|
|
29
|
+
): ViewTargetCandidate | null {
|
|
30
|
+
const id =
|
|
31
|
+
typeof row.id === 'string' ? row.id : typeof row.props?.id === 'string' ? row.props.id : ''
|
|
32
|
+
if (!id) return null
|
|
33
|
+
const props = row.props ?? {}
|
|
34
|
+
const firstName = stringProp(props, ['.property.firstName'])
|
|
35
|
+
const lastName = stringProp(props, ['.property.lastName'])
|
|
36
|
+
const label =
|
|
37
|
+
asNonEmptyString(props[NAMED_NAME]) ??
|
|
38
|
+
stringProp(props, ['.property.title', '.property.label', '.property.name', '.property.slug']) ??
|
|
39
|
+
([firstName, lastName].filter(Boolean).join(' ') || `${className} · ${id.slice(0, 8)}`)
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
ref: `@${id}`,
|
|
43
|
+
className,
|
|
44
|
+
classOrigin,
|
|
45
|
+
label,
|
|
46
|
+
description:
|
|
47
|
+
asNonEmptyString(props[DESCRIPTABLE_DESCRIPTION]) ??
|
|
48
|
+
stringProp(props, ['.property.description', '.property.email']),
|
|
49
|
+
status: asNonEmptyString(props[STATUSED_STATUS]) ?? stringProp(props, ['.property.status']),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function stringProp(props: Record<string, unknown>, suffixes: string[]): string | undefined {
|
|
54
|
+
for (const suffix of suffixes) {
|
|
55
|
+
for (const [key, value] of Object.entries(props)) {
|
|
56
|
+
if (!key.endsWith(suffix)) continue
|
|
57
|
+
const text = asNonEmptyString(value)
|
|
58
|
+
if (text) return text
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function asNonEmptyString(value: unknown): string | undefined {
|
|
65
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
|
66
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
StudioSchemaBundle,
|
|
3
|
+
ViewInfo,
|
|
4
|
+
ViewRuntime,
|
|
5
|
+
ViewTargetResult,
|
|
6
|
+
} from '../../shared/types'
|
|
7
|
+
|
|
8
|
+
import { activeInstanceName } from '../instances/active'
|
|
9
|
+
import { listViewTargets, viewDefinitionBindings } from './target'
|
|
10
|
+
|
|
11
|
+
export async function getViewRuntime(
|
|
12
|
+
root: string,
|
|
13
|
+
origin: string,
|
|
14
|
+
view: ViewInfo,
|
|
15
|
+
bundle: StudioSchemaBundle | null,
|
|
16
|
+
timeoutMs: number,
|
|
17
|
+
): Promise<ViewRuntime> {
|
|
18
|
+
const instance = await activeInstanceName()
|
|
19
|
+
const targetRequired = viewDefinitionBindings(origin, view, bundle).length > 0
|
|
20
|
+
const targets = targetRequired
|
|
21
|
+
? instance
|
|
22
|
+
? await listViewTargets(root, origin, view, bundle, instance, timeoutMs)
|
|
23
|
+
: ({
|
|
24
|
+
status: 'unavailable',
|
|
25
|
+
items: [],
|
|
26
|
+
selected: null,
|
|
27
|
+
stale: null,
|
|
28
|
+
truncated: false,
|
|
29
|
+
reason: 'No active Astrale instance.',
|
|
30
|
+
} satisfies ViewTargetResult)
|
|
31
|
+
: ({
|
|
32
|
+
status: 'available',
|
|
33
|
+
items: [],
|
|
34
|
+
selected: null,
|
|
35
|
+
stale: null,
|
|
36
|
+
truncated: false,
|
|
37
|
+
} satisfies ViewTargetResult)
|
|
38
|
+
|
|
39
|
+
return { slug: view.slug, instance, targetRequired, targets }
|
|
40
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { afterEach, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { readRememberedTarget, rememberTarget } from './selection-repository'
|
|
7
|
+
|
|
8
|
+
const roots: string[] = []
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('persists the remembered target through the view-owned repository', () => {
|
|
15
|
+
const root = mkdtempSync(join(tmpdir(), 'studio-view-selection-'))
|
|
16
|
+
roots.push(root)
|
|
17
|
+
rememberTarget(root, 'staging', 'issue-detail', {
|
|
18
|
+
id: 'issue-1',
|
|
19
|
+
ref: '@issue-1',
|
|
20
|
+
className: 'Issue',
|
|
21
|
+
classOrigin: 'issues.example.dev',
|
|
22
|
+
label: 'First issue',
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
expect(readRememberedTarget(root, 'staging', 'issue-detail')).toEqual({
|
|
26
|
+
id: 'issue-1',
|
|
27
|
+
className: 'Issue',
|
|
28
|
+
classOrigin: 'issues.example.dev',
|
|
29
|
+
label: 'First issue',
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { RememberedViewTarget, ViewTargetCandidate } from '../../shared/types'
|
|
2
|
+
|
|
3
|
+
import { asJsonRecord, asString } from '../json'
|
|
4
|
+
import { readJson, writeJson } from '../state/store'
|
|
5
|
+
|
|
6
|
+
const STATE_FILE = 'views.json'
|
|
7
|
+
|
|
8
|
+
interface StoredViewState {
|
|
9
|
+
targets?: Record<string, Record<string, RememberedViewTarget>>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function decodeRememberedTarget(value: unknown): RememberedViewTarget | undefined {
|
|
13
|
+
const record = asJsonRecord(value)
|
|
14
|
+
const id = asString(record?.id)
|
|
15
|
+
const className = asString(record?.className)
|
|
16
|
+
const classOrigin = asString(record?.classOrigin)
|
|
17
|
+
const label = asString(record?.label)
|
|
18
|
+
return id && className && classOrigin && label ? { id, className, classOrigin, label } : undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function decodeStoredViewState(value: unknown): StoredViewState | undefined {
|
|
22
|
+
const record = asJsonRecord(value)
|
|
23
|
+
if (!record) return undefined
|
|
24
|
+
const targets: NonNullable<StoredViewState['targets']> = {}
|
|
25
|
+
for (const [instance, bySlugValue] of Object.entries(asJsonRecord(record.targets) ?? {})) {
|
|
26
|
+
const bySlug: Record<string, RememberedViewTarget> = {}
|
|
27
|
+
for (const [slug, targetValue] of Object.entries(asJsonRecord(bySlugValue) ?? {})) {
|
|
28
|
+
const target = decodeRememberedTarget(targetValue)
|
|
29
|
+
if (target) bySlug[slug] = target
|
|
30
|
+
}
|
|
31
|
+
targets[instance] = bySlug
|
|
32
|
+
}
|
|
33
|
+
return { targets }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function rememberTarget(
|
|
37
|
+
root: string,
|
|
38
|
+
instance: string,
|
|
39
|
+
slug: string,
|
|
40
|
+
target: ViewTargetCandidate,
|
|
41
|
+
): void {
|
|
42
|
+
const stored = readJson(root, STATE_FILE, decodeStoredViewState, {})
|
|
43
|
+
stored.targets ??= {}
|
|
44
|
+
stored.targets[instance] ??= {}
|
|
45
|
+
stored.targets[instance][slug] = {
|
|
46
|
+
id: target.id,
|
|
47
|
+
className: target.className,
|
|
48
|
+
classOrigin: target.classOrigin,
|
|
49
|
+
label: target.label,
|
|
50
|
+
}
|
|
51
|
+
writeJson(root, STATE_FILE, stored)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function readRememberedTarget(
|
|
55
|
+
root: string,
|
|
56
|
+
instance: string,
|
|
57
|
+
slug: string,
|
|
58
|
+
): RememberedViewTarget | null {
|
|
59
|
+
return readJson(root, STATE_FILE, decodeStoredViewState, {}).targets?.[instance]?.[slug] ?? null
|
|
60
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { conciseCliFailure, readyViewSession, viewSessionArgs } from './session'
|
|
4
|
+
|
|
5
|
+
describe('view session command', () => {
|
|
6
|
+
test('opens the installed ViewPath without rejected placement overrides', () => {
|
|
7
|
+
const args = viewSessionArgs('issues.astrale.ai', 'issue-detail', 'staging', '@issue-1')
|
|
8
|
+
expect(args).toEqual([
|
|
9
|
+
'view',
|
|
10
|
+
'/:issues.astrale.ai:view.issue-detail',
|
|
11
|
+
'--target',
|
|
12
|
+
'@issue-1',
|
|
13
|
+
'--no-open',
|
|
14
|
+
'--json',
|
|
15
|
+
'-i',
|
|
16
|
+
'staging',
|
|
17
|
+
])
|
|
18
|
+
expect(args).not.toContain('--view-url')
|
|
19
|
+
expect(args).not.toContain('--handshake')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('omits --target for a standalone view', () => {
|
|
23
|
+
expect(viewSessionArgs('issues.astrale.ai', 'dashboard', 'local')).toEqual([
|
|
24
|
+
'view',
|
|
25
|
+
'/:issues.astrale.ai:view.dashboard',
|
|
26
|
+
'--no-open',
|
|
27
|
+
'--json',
|
|
28
|
+
'-i',
|
|
29
|
+
'local',
|
|
30
|
+
])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('reads the verified placement URL from the current CLI session route', () => {
|
|
34
|
+
expect(
|
|
35
|
+
readyViewSession(
|
|
36
|
+
{
|
|
37
|
+
session: {
|
|
38
|
+
id: 'v-a1b2',
|
|
39
|
+
pageUrl: 'http://127.0.0.1:4419/s/nonce/',
|
|
40
|
+
view: { route: { href: 'https://issues.example/ui/issue' } },
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
null,
|
|
44
|
+
),
|
|
45
|
+
).toEqual({
|
|
46
|
+
status: 'ready',
|
|
47
|
+
sessionId: 'v-a1b2',
|
|
48
|
+
pageUrl: 'http://127.0.0.1:4419/s/nonce/',
|
|
49
|
+
viewUrl: 'https://issues.example/ui/issue',
|
|
50
|
+
target: null,
|
|
51
|
+
})
|
|
52
|
+
expect(
|
|
53
|
+
readyViewSession(
|
|
54
|
+
{
|
|
55
|
+
session: {
|
|
56
|
+
id: 'v-old',
|
|
57
|
+
pageUrl: 'http://127.0.0.1:4419/s/old/',
|
|
58
|
+
view: { url: 'https://legacy.invalid/view' },
|
|
59
|
+
},
|
|
60
|
+
} as never,
|
|
61
|
+
null,
|
|
62
|
+
),
|
|
63
|
+
).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('reduces CLI stack output to the actionable kernel failure', () => {
|
|
68
|
+
expect(
|
|
69
|
+
conciseCliFailure(
|
|
70
|
+
`275 | invalid\nPermissionDeniedError: Permission denied: READ on /:issues.astrale.ai:view.issue\n details: {}\n at mapServerError (errors.ts:280:61)`,
|
|
71
|
+
),
|
|
72
|
+
).toBe('PermissionDeniedError: Permission denied: READ on /:issues.astrale.ai:view.issue')
|
|
73
|
+
})
|