@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,136 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { InstanceStoreSchema, normalizeInstanceKernelUrl, sanitizeStore } from '../instance'
|
|
4
|
+
|
|
5
|
+
describe('InstanceStoreSchema', () => {
|
|
6
|
+
test('parses valid store with url', () => {
|
|
7
|
+
const result = InstanceStoreSchema.parse({
|
|
8
|
+
active: 'prod',
|
|
9
|
+
instances: {
|
|
10
|
+
prod: { url: 'https://prod.example.com', createdAt: '2024-01-01T00:00:00Z' },
|
|
11
|
+
},
|
|
12
|
+
})
|
|
13
|
+
expect(result.active).toBe('prod')
|
|
14
|
+
expect(result.instances.prod.url).toBe('https://prod.example.com')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('parses legacy instance without url for migration compatibility', () => {
|
|
18
|
+
const result = InstanceStoreSchema.parse({
|
|
19
|
+
active: 'dev',
|
|
20
|
+
instances: {
|
|
21
|
+
dev: { createdAt: '2024-01-01T00:00:00Z' },
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
expect(result.instances.dev.url).toBeUndefined()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('parses multiple instances', () => {
|
|
28
|
+
const result = InstanceStoreSchema.parse({
|
|
29
|
+
active: 'prod',
|
|
30
|
+
instances: {
|
|
31
|
+
local: { url: 'https://local.example.com', createdAt: '2024-01-01T00:00:00Z' },
|
|
32
|
+
prod: { url: 'https://prod.example.com', createdAt: '2024-06-01T00:00:00Z' },
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
expect(Object.keys(result.instances)).toHaveLength(2)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('rejects missing active field', () => {
|
|
39
|
+
expect(() =>
|
|
40
|
+
InstanceStoreSchema.parse({
|
|
41
|
+
instances: { m: { createdAt: '2024-01-01' } },
|
|
42
|
+
}),
|
|
43
|
+
).toThrow()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('accepts instance without createdAt (optional at schema level)', () => {
|
|
47
|
+
// `createdAt` is intentionally optional on InstanceEntrySchema: only
|
|
48
|
+
// `createdAt` is metadata; the connection URL is the only required
|
|
49
|
+
// field for a usable bookmark.
|
|
50
|
+
const result = InstanceStoreSchema.parse({
|
|
51
|
+
active: 'm',
|
|
52
|
+
instances: { m: { url: 'http://test' } },
|
|
53
|
+
})
|
|
54
|
+
expect(result.instances.m.createdAt).toBeUndefined()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('accepts empty instances record', () => {
|
|
58
|
+
const result = InstanceStoreSchema.parse({
|
|
59
|
+
active: 'none',
|
|
60
|
+
instances: {},
|
|
61
|
+
})
|
|
62
|
+
expect(Object.keys(result.instances)).toHaveLength(0)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('normalizes region-routed managed instance roots to the kernel api URL', () => {
|
|
66
|
+
expect(normalizeInstanceKernelUrl('https://testmarc.eu.astrale.ai')).toBe(
|
|
67
|
+
'https://testmarc.eu.astrale.ai/api',
|
|
68
|
+
)
|
|
69
|
+
expect(normalizeInstanceKernelUrl('https://testmarc.eu.astrale.ai/')).toBe(
|
|
70
|
+
'https://testmarc.eu.astrale.ai/api',
|
|
71
|
+
)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('does not rewrite explicit kernel or non-managed URLs', () => {
|
|
75
|
+
expect(normalizeInstanceKernelUrl('https://testmarc.eu.astrale.ai/api')).toBe(
|
|
76
|
+
'https://testmarc.eu.astrale.ai/api',
|
|
77
|
+
)
|
|
78
|
+
expect(normalizeInstanceKernelUrl('https://localhost:8443/kernel/host')).toBe(
|
|
79
|
+
'https://localhost:8443/kernel/host',
|
|
80
|
+
)
|
|
81
|
+
expect(normalizeInstanceKernelUrl('https://scw-admin.astrale.ai')).toBe(
|
|
82
|
+
'https://scw-admin.astrale.ai',
|
|
83
|
+
)
|
|
84
|
+
expect(normalizeInstanceKernelUrl('https://testmarc.svc.eu.astrale.ai')).toBe(
|
|
85
|
+
'https://testmarc.svc.eu.astrale.ai',
|
|
86
|
+
)
|
|
87
|
+
expect(normalizeInstanceKernelUrl('https://testmarc.eu.astrale.ai?debug=1')).toBe(
|
|
88
|
+
'https://testmarc.eu.astrale.ai?debug=1',
|
|
89
|
+
)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('sanitizeStore — read must not rewrite', () => {
|
|
94
|
+
test('an already-normalized store reports changed=false', () => {
|
|
95
|
+
// `changed` was computed via OBJECT IDENTITY (always true), so every
|
|
96
|
+
// read rewrote instances.json fire-and-forget — concurrent astrale
|
|
97
|
+
// processes clobbered each other's `active` from stale snapshots and
|
|
98
|
+
// calls silently targeted the wrong instance.
|
|
99
|
+
const store = {
|
|
100
|
+
active: 'a',
|
|
101
|
+
instances: {
|
|
102
|
+
a: { url: 'https://a.example/api', kind: 'bookmark' as const },
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
const { changed } = sanitizeStore(store)
|
|
106
|
+
expect(changed).toBe(false)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('a dangling active pointer is preserved (not silently re-aimed)', () => {
|
|
110
|
+
const store = {
|
|
111
|
+
active: 'ghost',
|
|
112
|
+
instances: { a: { url: 'https://a.example/api', kind: 'bookmark' as const } },
|
|
113
|
+
}
|
|
114
|
+
const { store: out } = sanitizeStore(store)
|
|
115
|
+
expect(out.active).toBe('ghost')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('organizationId survives sanitize without flagging a rewrite', () => {
|
|
119
|
+
// The org id captured at `instance create` is what makes token scoping
|
|
120
|
+
// immune to the router's eventually-consistent /auth/org — losing it on
|
|
121
|
+
// a read (or rewriting the file for it) would re-open the stale-org race.
|
|
122
|
+
const store = {
|
|
123
|
+
active: 'a',
|
|
124
|
+
instances: {
|
|
125
|
+
a: {
|
|
126
|
+
url: 'https://a.example/api',
|
|
127
|
+
kind: 'bookmark' as const,
|
|
128
|
+
organizationId: 'org_123',
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
const { store: out, changed } = sanitizeStore(store)
|
|
133
|
+
expect(out.instances.a.organizationId).toBe('org_123')
|
|
134
|
+
expect(changed).toBe(false)
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { CompactEncrypt, compactDecrypt, decodeProtectedHeader } from 'jose'
|
|
3
|
+
import { access, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
import { IdentityKeyMissingError } from '../../errors'
|
|
8
|
+
import {
|
|
9
|
+
generateEd25519Jwk,
|
|
10
|
+
keypairPaths,
|
|
11
|
+
listIdentityKeys,
|
|
12
|
+
persistKeypair,
|
|
13
|
+
removeKeypair,
|
|
14
|
+
signAs,
|
|
15
|
+
} from '../keys'
|
|
16
|
+
|
|
17
|
+
describe('DESIGN — per-identity keys', () => {
|
|
18
|
+
let tmp = ''
|
|
19
|
+
const savedStrict = process.env.ASTRALE_STRICT_IDENTITIES
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
tmp = await mkdtemp(join(tmpdir(), 'astrale-keys-test-'))
|
|
23
|
+
delete process.env.ASTRALE_STRICT_IDENTITIES
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
if (savedStrict) process.env.ASTRALE_STRICT_IDENTITIES = savedStrict
|
|
28
|
+
else delete process.env.ASTRALE_STRICT_IDENTITIES
|
|
29
|
+
await rm(tmp, { recursive: true, force: true })
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('keypairPaths routes manager to legacy filenames', () => {
|
|
33
|
+
const p = keypairPaths('manager', tmp)
|
|
34
|
+
expect(p.privatePath.endsWith('manager.private.jwk')).toBe(true)
|
|
35
|
+
expect(p.publicPath.endsWith('manager.public.jwk')).toBe(true)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('keypairPaths routes others to <name>.*.jwk', () => {
|
|
39
|
+
const p = keypairPaths('alice', tmp)
|
|
40
|
+
expect(p.privatePath.endsWith('alice.private.jwk')).toBe(true)
|
|
41
|
+
expect(p.publicPath.endsWith('alice.public.jwk')).toBe(true)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('persistKeypair writes both files with kid', async () => {
|
|
45
|
+
const result = await persistKeypair('alice', { keysDir: tmp })
|
|
46
|
+
expect(result.kid).toMatch(/^alice-key-/)
|
|
47
|
+
const { privatePath, publicPath } = keypairPaths('alice', tmp)
|
|
48
|
+
await access(privatePath)
|
|
49
|
+
await access(publicPath)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('signAs(alice) works when alice has her own key', async () => {
|
|
53
|
+
await persistKeypair('alice', { keysDir: tmp })
|
|
54
|
+
const jwt = await signAs('alice', tmp)
|
|
55
|
+
expect(jwt.split('.').length).toBe(3)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('signAs(alice) uses the key file algorithm for EdDSA identities', async () => {
|
|
59
|
+
const { privateJwk, publicJwk } = await generateEd25519Jwk('alice-ed25519')
|
|
60
|
+
const { privatePath, publicPath } = keypairPaths('alice', tmp)
|
|
61
|
+
await writeFile(privatePath, JSON.stringify(privateJwk, null, 2))
|
|
62
|
+
await writeFile(publicPath, JSON.stringify(publicJwk, null, 2))
|
|
63
|
+
|
|
64
|
+
const jwt = await signAs('alice', tmp)
|
|
65
|
+
|
|
66
|
+
expect(decodeProtectedHeader(jwt).alg).toBe('EdDSA')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('signAs(alice) falls back to manager key with warning', async () => {
|
|
70
|
+
await persistKeypair('manager', { keysDir: tmp })
|
|
71
|
+
const jwt = await signAs('alice', tmp)
|
|
72
|
+
expect(jwt.split('.').length).toBe(3)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('signAs(alice) errors when strict mode and no alice key', async () => {
|
|
76
|
+
await persistKeypair('manager', { keysDir: tmp })
|
|
77
|
+
process.env.ASTRALE_STRICT_IDENTITIES = '1'
|
|
78
|
+
await expect(signAs('alice', tmp)).rejects.toThrow(IdentityKeyMissingError)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('signAs errors when no keys at all', async () => {
|
|
82
|
+
await expect(signAs('manager', tmp)).rejects.toThrow(IdentityKeyMissingError)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('removeKeypair is idempotent', async () => {
|
|
86
|
+
await persistKeypair('alice', { keysDir: tmp })
|
|
87
|
+
await removeKeypair('alice', tmp)
|
|
88
|
+
await removeKeypair('alice', tmp)
|
|
89
|
+
const { privatePath } = keypairPaths('alice', tmp)
|
|
90
|
+
await expect(access(privatePath)).rejects.toThrow()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('listIdentityKeys returns names with private keys', async () => {
|
|
94
|
+
await persistKeypair('manager', { keysDir: tmp })
|
|
95
|
+
await persistKeypair('alice', { keysDir: tmp })
|
|
96
|
+
await persistKeypair('bob', { keysDir: tmp })
|
|
97
|
+
const names = await listIdentityKeys(tmp)
|
|
98
|
+
expect(names.sort()).toEqual(['alice', 'bob', 'manager'])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('encrypted export envelope round-trip (JWE PBES2)', async () => {
|
|
102
|
+
await persistKeypair('alice', { keysDir: tmp })
|
|
103
|
+
const plain = JSON.stringify({ subject: 'alice', secret: 'x' })
|
|
104
|
+
const passphrase = 'correct-horse-battery'
|
|
105
|
+
const enc = await new CompactEncrypt(new TextEncoder().encode(plain))
|
|
106
|
+
.setProtectedHeader({ alg: 'PBES2-HS256+A128KW', enc: 'A256GCM' })
|
|
107
|
+
.encrypt(new TextEncoder().encode(passphrase))
|
|
108
|
+
|
|
109
|
+
expect(enc.split('.').length).toBe(5)
|
|
110
|
+
|
|
111
|
+
const { plaintext } = await compactDecrypt(enc, new TextEncoder().encode(passphrase), {
|
|
112
|
+
keyManagementAlgorithms: ['PBES2-HS256+A128KW'],
|
|
113
|
+
})
|
|
114
|
+
expect(new TextDecoder().decode(plaintext)).toBe(plain)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('encrypted export fails with wrong passphrase', async () => {
|
|
118
|
+
const plain = JSON.stringify({ subject: 'alice' })
|
|
119
|
+
const enc = await new CompactEncrypt(new TextEncoder().encode(plain))
|
|
120
|
+
.setProtectedHeader({ alg: 'PBES2-HS256+A128KW', enc: 'A256GCM' })
|
|
121
|
+
.encrypt(new TextEncoder().encode('correct'))
|
|
122
|
+
|
|
123
|
+
await expect(
|
|
124
|
+
compactDecrypt(enc, new TextEncoder().encode('wrong'), {
|
|
125
|
+
keyManagementAlgorithms: ['PBES2-HS256+A128KW'],
|
|
126
|
+
}),
|
|
127
|
+
).rejects.toThrow()
|
|
128
|
+
})
|
|
129
|
+
})
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import type { IdentityStore } from '../identity'
|
|
4
|
+
import type { IdpSession } from '../idp'
|
|
5
|
+
import type { InstanceStore } from '../instance'
|
|
6
|
+
|
|
7
|
+
import { DEFAULT_ADMIN_TARGET_URL } from '../admin-target'
|
|
8
|
+
import { buildLocalStatus, decodeJwtExpiration } from '../local-status'
|
|
9
|
+
|
|
10
|
+
describe('local status snapshot', () => {
|
|
11
|
+
test('summarizes active bookmark and key identity registration', async () => {
|
|
12
|
+
const instances: InstanceStore = {
|
|
13
|
+
active: 'staging',
|
|
14
|
+
instances: {
|
|
15
|
+
staging: { url: 'https://kernel.example.com', issuer: 'https://issuer.example.com' },
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
const identities: IdentityStore = {
|
|
19
|
+
default: 'alice',
|
|
20
|
+
identities: {
|
|
21
|
+
alice: {
|
|
22
|
+
subject: 'alice',
|
|
23
|
+
source: 'key',
|
|
24
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
25
|
+
registrations: {
|
|
26
|
+
staging: {
|
|
27
|
+
iss: 'https://kernel.example.com/iss/thumbprint',
|
|
28
|
+
sub: 'node-1',
|
|
29
|
+
registeredAt: '2026-01-02T00:00:00.000Z',
|
|
30
|
+
},
|
|
31
|
+
admin: {
|
|
32
|
+
iss: 'https://admin.eu.astrale.ai/api/iss/thumbprint',
|
|
33
|
+
sub: 'admin-node-1',
|
|
34
|
+
registeredAt: '2026-01-02T00:00:00.000Z',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const status = await buildLocalStatus(instances, identities, async () => null)
|
|
42
|
+
|
|
43
|
+
expect(status.instance?.active).toBe('staging')
|
|
44
|
+
expect(status.instance?.url).toBe('https://kernel.example.com')
|
|
45
|
+
expect('error' in status.admin).toBe(false)
|
|
46
|
+
if (!('error' in status.admin)) {
|
|
47
|
+
expect(status.admin.url).toBe(DEFAULT_ADMIN_TARGET_URL)
|
|
48
|
+
}
|
|
49
|
+
expect(status.identity?.name).toBe('alice')
|
|
50
|
+
expect(status.identity?.source).toBe('key')
|
|
51
|
+
expect(status.identity?.session).toBeNull()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('keeps a refreshable cached IdP session ready when the access token is stale', async () => {
|
|
55
|
+
const instances: InstanceStore = {
|
|
56
|
+
active: '',
|
|
57
|
+
instances: {},
|
|
58
|
+
}
|
|
59
|
+
const identities: IdentityStore = {
|
|
60
|
+
default: 'workos-user',
|
|
61
|
+
identities: {
|
|
62
|
+
'workos-user': {
|
|
63
|
+
subject: 'user_123',
|
|
64
|
+
source: 'idp',
|
|
65
|
+
mode: 'remote',
|
|
66
|
+
idp: 'workos',
|
|
67
|
+
issuer: 'https://idp.example.com',
|
|
68
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
const session: IdpSession = {
|
|
73
|
+
identity: 'workos-user',
|
|
74
|
+
idp: 'workos',
|
|
75
|
+
issuer: 'https://idp.example.com',
|
|
76
|
+
subject: 'user_123',
|
|
77
|
+
access_token: 'redacted',
|
|
78
|
+
refresh_token: 'redacted-refresh',
|
|
79
|
+
expires_at: '2000-01-01T00:00:00.000Z',
|
|
80
|
+
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const status = await buildLocalStatus(instances, identities, async () => session)
|
|
84
|
+
|
|
85
|
+
expect(status.instance).toBeNull()
|
|
86
|
+
expect(status.identity?.source).toBe('idp')
|
|
87
|
+
expect(status.identity?.session?.cached).toBe(true)
|
|
88
|
+
expect(status.identity?.session?.requiresLogin).toBe(false)
|
|
89
|
+
expect(status.identity?.session?.hasRefreshToken).toBe(true)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('requires login when the cached IdP access token is stale and cannot refresh', async () => {
|
|
93
|
+
const instances: InstanceStore = {
|
|
94
|
+
active: '',
|
|
95
|
+
instances: {},
|
|
96
|
+
}
|
|
97
|
+
const identities: IdentityStore = {
|
|
98
|
+
default: 'workos-user',
|
|
99
|
+
identities: {
|
|
100
|
+
'workos-user': {
|
|
101
|
+
subject: 'user_123',
|
|
102
|
+
source: 'idp',
|
|
103
|
+
mode: 'remote',
|
|
104
|
+
idp: 'workos',
|
|
105
|
+
issuer: 'https://idp.example.com',
|
|
106
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
const session: IdpSession = {
|
|
111
|
+
identity: 'workos-user',
|
|
112
|
+
idp: 'workos',
|
|
113
|
+
issuer: 'https://idp.example.com',
|
|
114
|
+
subject: 'user_123',
|
|
115
|
+
access_token: 'redacted',
|
|
116
|
+
expires_at: '2000-01-01T00:00:00.000Z',
|
|
117
|
+
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const status = await buildLocalStatus(instances, identities, async () => session)
|
|
121
|
+
|
|
122
|
+
expect(status.identity?.session?.cached).toBe(true)
|
|
123
|
+
expect(status.identity?.session?.requiresLogin).toBe(true)
|
|
124
|
+
expect(status.identity?.session?.hasRefreshToken).toBe(false)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('summarizes active managed slug without requiring a local bookmark', async () => {
|
|
128
|
+
const instances: InstanceStore = {
|
|
129
|
+
active: 'testmarc',
|
|
130
|
+
instances: {},
|
|
131
|
+
}
|
|
132
|
+
const identities: IdentityStore = {
|
|
133
|
+
default: 'workos-user',
|
|
134
|
+
identities: {
|
|
135
|
+
'workos-user': {
|
|
136
|
+
subject: 'user_123',
|
|
137
|
+
source: 'idp',
|
|
138
|
+
mode: 'remote',
|
|
139
|
+
idp: 'workos',
|
|
140
|
+
issuer: 'https://idp.example.com',
|
|
141
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const status = await buildLocalStatus(instances, identities, async () => null)
|
|
147
|
+
|
|
148
|
+
expect(status.instance).toEqual({
|
|
149
|
+
active: 'testmarc',
|
|
150
|
+
url: '',
|
|
151
|
+
issuer: null,
|
|
152
|
+
defaultIdentity: null,
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('falls back to identity exp claim when IdP session has no expires_at', async () => {
|
|
157
|
+
const identities: IdentityStore = {
|
|
158
|
+
default: 'workos-user',
|
|
159
|
+
identities: {
|
|
160
|
+
'workos-user': {
|
|
161
|
+
subject: 'user_123',
|
|
162
|
+
source: 'idp',
|
|
163
|
+
mode: 'remote',
|
|
164
|
+
idp: 'workos',
|
|
165
|
+
issuer: 'https://idp.example.com',
|
|
166
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
167
|
+
claims: { exp: 946684800 },
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
const session: IdpSession = {
|
|
172
|
+
identity: 'workos-user',
|
|
173
|
+
idp: 'workos',
|
|
174
|
+
issuer: 'https://idp.example.com',
|
|
175
|
+
subject: 'user_123',
|
|
176
|
+
access_token: 'redacted',
|
|
177
|
+
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const status = await buildLocalStatus(
|
|
181
|
+
{ active: '', instances: {} },
|
|
182
|
+
identities,
|
|
183
|
+
async () => session,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
expect(status.identity?.session?.requiresLogin).toBe(true)
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('decodeJwtExpiration', () => {
|
|
191
|
+
test('decodes exp without exposing token content', async () => {
|
|
192
|
+
const b64u = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url')
|
|
193
|
+
const token = `${b64u({ alg: 'none' })}.${b64u({ exp: 946684800 })}.signature`
|
|
194
|
+
|
|
195
|
+
const expiration = decodeJwtExpiration(token, Date.parse('2026-01-01T00:00:00.000Z'))
|
|
196
|
+
|
|
197
|
+
expect(expiration).toEqual({
|
|
198
|
+
expiresAt: '2000-01-01T00:00:00.000Z',
|
|
199
|
+
expired: true,
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
})
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { denoise, isMachine, output, present, presentList } from '../output'
|
|
4
|
+
|
|
5
|
+
function captureStdout(): { writes: string[]; restore: () => void } {
|
|
6
|
+
const writes: string[] = []
|
|
7
|
+
const original = process.stdout.write.bind(process.stdout)
|
|
8
|
+
process.stdout.write = mock((chunk: string | Uint8Array) => {
|
|
9
|
+
writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
|
|
10
|
+
return true
|
|
11
|
+
}) as typeof process.stdout.write
|
|
12
|
+
return { writes, restore: () => (process.stdout.write = original) }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('output — void/undefined normalization', () => {
|
|
16
|
+
let writes: string[] = []
|
|
17
|
+
let originalWrite: typeof process.stdout.write
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
writes = []
|
|
21
|
+
originalWrite = process.stdout.write.bind(process.stdout)
|
|
22
|
+
process.stdout.write = mock((chunk: string | Uint8Array) => {
|
|
23
|
+
writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
|
|
24
|
+
return true
|
|
25
|
+
}) as typeof process.stdout.write
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
process.stdout.write = originalWrite
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('--raw output for void syscall is "null", not bare "undefined"', () => {
|
|
33
|
+
// Regression: JSON.stringify(undefined) returns undefined (not "undefined"),
|
|
34
|
+
// so concatenating with '\n' coerced to the literal string 'undefined\n'.
|
|
35
|
+
// Any wrapper doing JSON.parse(stdout) would blow up.
|
|
36
|
+
output(undefined, { raw: true })
|
|
37
|
+
expect(writes.join('')).toBe('null\n')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('--json output for void syscall is "null"', () => {
|
|
41
|
+
output(undefined, { json: true })
|
|
42
|
+
expect(writes.join('')).toBe('null\n')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('explicit format=json on non-TTY yields "null" for void', () => {
|
|
46
|
+
output(undefined, { format: 'json' })
|
|
47
|
+
const out = writes.join('')
|
|
48
|
+
// On TTY the value gets ANSI-colored; either way the parseable token is `null`.
|
|
49
|
+
expect(out).toContain('null')
|
|
50
|
+
expect(out).not.toContain('undefined')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('--raw output for actual data is unchanged', () => {
|
|
54
|
+
output({ a: 1 }, { raw: true })
|
|
55
|
+
expect(writes.join('')).toBe('{\n "a": 1\n}\n')
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('isMachine', () => {
|
|
60
|
+
test('returns true for --raw', () => {
|
|
61
|
+
expect(isMachine({ raw: true })).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('returns true for --json', () => {
|
|
65
|
+
expect(isMachine({ json: true })).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('returns true when both are set', () => {
|
|
69
|
+
expect(isMachine({ raw: true, json: true })).toBe(true)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('returns false for empty opts when TTY', () => {
|
|
73
|
+
// This test depends on the test runner's TTY state.
|
|
74
|
+
// In a non-TTY environment (CI), isMachine({}) returns true.
|
|
75
|
+
const result = isMachine({})
|
|
76
|
+
expect(typeof result).toBe('boolean')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('handles undefined opts', () => {
|
|
80
|
+
const result = isMachine()
|
|
81
|
+
expect(typeof result).toBe('boolean')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('present — scalar', () => {
|
|
86
|
+
let cap: ReturnType<typeof captureStdout>
|
|
87
|
+
beforeEach(() => (cap = captureStdout()))
|
|
88
|
+
afterEach(() => cap.restore())
|
|
89
|
+
|
|
90
|
+
test('--raw prints a bare string (no quotes) for shell capture', () => {
|
|
91
|
+
present('alice', { raw: true })
|
|
92
|
+
expect(cap.writes.join('')).toBe('alice\n')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('--json prints a quoted JSON string', () => {
|
|
96
|
+
present('alice', { json: true })
|
|
97
|
+
expect(cap.writes.join('')).toBe('"alice"\n')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('objects fall back to JSON under --raw (nothing rawer to give)', () => {
|
|
101
|
+
present({ a: 1 }, { raw: true })
|
|
102
|
+
expect(cap.writes.join('')).toBe('{\n "a": 1\n}\n')
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('denoise', () => {
|
|
107
|
+
test('strips schema / icon / code at any depth, keeps the rest', () => {
|
|
108
|
+
const out = denoise({
|
|
109
|
+
id: 'x',
|
|
110
|
+
icon: '<svg/>',
|
|
111
|
+
props: { schema: 'huge', name: 'n', code: 'c' },
|
|
112
|
+
})
|
|
113
|
+
expect(out).toEqual({ id: 'x', props: { name: 'n' } })
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('presentList', () => {
|
|
118
|
+
let cap: ReturnType<typeof captureStdout>
|
|
119
|
+
beforeEach(() => (cap = captureStdout()))
|
|
120
|
+
afterEach(() => cap.restore())
|
|
121
|
+
|
|
122
|
+
const project = (items: Array<{ name: string; path?: string; props?: unknown }>) => ({
|
|
123
|
+
columns: [{ key: 'name', header: 'NAME' }],
|
|
124
|
+
rows: items.map((i) => ({ name: i.name })),
|
|
125
|
+
paths: items.map((i) => i.path ?? ''),
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('--count prints only the number', () => {
|
|
129
|
+
presentList([{ name: 'a' }, { name: 'b' }], { count: true }, project)
|
|
130
|
+
expect(cap.writes.join('')).toBe('2\n')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('-q prints one path per line', () => {
|
|
134
|
+
presentList(
|
|
135
|
+
[
|
|
136
|
+
{ name: 'a', path: '/a' },
|
|
137
|
+
{ name: 'b', path: '/b' },
|
|
138
|
+
],
|
|
139
|
+
{ quiet: true },
|
|
140
|
+
project,
|
|
141
|
+
)
|
|
142
|
+
expect(cap.writes.join('')).toBe('/a\n/b\n')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('machine output is the denoised raw items, not the projected rows', () => {
|
|
146
|
+
presentList([{ name: 'a', props: { schema: 'big', keep: 1 } }], { json: true }, project)
|
|
147
|
+
const out = JSON.parse(cap.writes.join(''))
|
|
148
|
+
expect(out).toEqual([{ name: 'a', props: { keep: 1 } }])
|
|
149
|
+
})
|
|
150
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import chalk from 'chalk'
|
|
3
|
+
|
|
4
|
+
import { panel, renderInstanceHero } from '../panel'
|
|
5
|
+
|
|
6
|
+
// Built from a char code so the regex source carries no literal control char.
|
|
7
|
+
const ANSI_RE = new RegExp(String.fromCharCode(27) + '\\[[0-9;]*m', 'g')
|
|
8
|
+
const stripAnsi = (s: string): string => s.replace(ANSI_RE, '')
|
|
9
|
+
|
|
10
|
+
describe('panel — rounded box framing', () => {
|
|
11
|
+
test('every visible line is the same width (ANSI excluded)', () => {
|
|
12
|
+
const lines = stripAnsi(panel(['short', 'a much longer line here'])).split('\n')
|
|
13
|
+
const widths = new Set(lines.map((l) => l.length))
|
|
14
|
+
expect(widths.size).toBe(1)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('draws rounded corners top and bottom', () => {
|
|
18
|
+
const lines = stripAnsi(panel(['x'])).split('\n')
|
|
19
|
+
expect(lines[0]).toContain('╭')
|
|
20
|
+
expect(lines[0]).toContain('╮')
|
|
21
|
+
expect(lines[lines.length - 1]).toContain('╰')
|
|
22
|
+
expect(lines[lines.length - 1]).toContain('╯')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('colored content does not break alignment', () => {
|
|
26
|
+
// A bold/underlined cell carries ANSI; visible width must still align.
|
|
27
|
+
const out = panel(['plain', chalk.bold.cyan.underline('https://my-app.eu.astrale.ai')])
|
|
28
|
+
const lines = stripAnsi(out).split('\n')
|
|
29
|
+
expect(new Set(lines.map((l) => l.length)).size).toBe(1)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('renderInstanceHero', () => {
|
|
34
|
+
test('puts the URL front and center', () => {
|
|
35
|
+
const out = stripAnsi(renderInstanceHero('my-app', 'https://my-app.eu.astrale.ai'))
|
|
36
|
+
expect(out).toContain('Your instance is live')
|
|
37
|
+
expect(out).toContain('https://my-app.eu.astrale.ai')
|
|
38
|
+
expect(out).toContain('my-app')
|
|
39
|
+
})
|
|
40
|
+
})
|