@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,14 @@
|
|
|
1
|
+
import type { OutputOpts } from '../lib/output'
|
|
2
|
+
|
|
3
|
+
export type KernelCommandOpts = OutputOpts & {
|
|
4
|
+
url?: string
|
|
5
|
+
instance?: string
|
|
6
|
+
timeout?: string
|
|
7
|
+
as?: string
|
|
8
|
+
creds?: string
|
|
9
|
+
debug?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type CallCommandOpts = KernelCommandOpts & {
|
|
13
|
+
data?: string
|
|
14
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import type { InstanceStore } from '../instance'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_ADMIN_TARGET_NAME,
|
|
7
|
+
DEFAULT_ADMIN_TARGET_URL,
|
|
8
|
+
resolveAdminTargetFromStore,
|
|
9
|
+
} from '../admin-target'
|
|
10
|
+
import { DEFAULT_CONFIG, type AstraleConfig } from '../config'
|
|
11
|
+
|
|
12
|
+
const instances: InstanceStore = {
|
|
13
|
+
active: 'primary',
|
|
14
|
+
instances: {
|
|
15
|
+
primary: {
|
|
16
|
+
url: 'https://primary.example.com',
|
|
17
|
+
issuer: 'https://primary-issuer.example.com',
|
|
18
|
+
},
|
|
19
|
+
admin: {
|
|
20
|
+
url: 'https://bookmarked-admin.example.com',
|
|
21
|
+
issuer: 'https://bookmarked-issuer.example.com',
|
|
22
|
+
defaultIdentity: 'admin-workos',
|
|
23
|
+
},
|
|
24
|
+
alias: { url: 'https://alias-admin.example.com', name: 'named-admin' },
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('resolveAdminTargetFromStore', () => {
|
|
29
|
+
test('uses hosted admin default without active instance coupling', () => {
|
|
30
|
+
expect(resolveAdminTargetFromStore({}, DEFAULT_CONFIG, instances)).toEqual({
|
|
31
|
+
name: DEFAULT_ADMIN_TARGET_NAME,
|
|
32
|
+
registrationSlug: DEFAULT_ADMIN_TARGET_NAME,
|
|
33
|
+
url: DEFAULT_ADMIN_TARGET_URL,
|
|
34
|
+
issuer: DEFAULT_ADMIN_TARGET_URL,
|
|
35
|
+
source: 'default',
|
|
36
|
+
configured: false,
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('uses configured admin bookmark', () => {
|
|
41
|
+
const config: AstraleConfig = {
|
|
42
|
+
...DEFAULT_CONFIG,
|
|
43
|
+
admin: { instance: 'admin' },
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
expect(resolveAdminTargetFromStore({}, config, instances)).toEqual({
|
|
47
|
+
name: 'admin',
|
|
48
|
+
registrationSlug: 'admin',
|
|
49
|
+
url: 'https://bookmarked-admin.example.com',
|
|
50
|
+
issuer: 'https://bookmarked-issuer.example.com',
|
|
51
|
+
defaultIdentity: 'admin-workos',
|
|
52
|
+
source: 'config-instance',
|
|
53
|
+
configured: true,
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('allows explicit admin bookmark and admin url overrides', () => {
|
|
58
|
+
expect(
|
|
59
|
+
resolveAdminTargetFromStore({ admin: 'alias' }, DEFAULT_CONFIG, instances),
|
|
60
|
+
).toMatchObject({
|
|
61
|
+
name: 'alias',
|
|
62
|
+
url: 'https://alias-admin.example.com',
|
|
63
|
+
issuer: 'https://alias-admin.example.com',
|
|
64
|
+
source: 'admin',
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
expect(
|
|
68
|
+
resolveAdminTargetFromStore(
|
|
69
|
+
{ adminUrl: 'https://override-admin.example.com' },
|
|
70
|
+
DEFAULT_CONFIG,
|
|
71
|
+
instances,
|
|
72
|
+
),
|
|
73
|
+
).toMatchObject({
|
|
74
|
+
name: 'admin',
|
|
75
|
+
url: 'https://override-admin.example.com',
|
|
76
|
+
issuer: 'https://override-admin.example.com',
|
|
77
|
+
source: 'admin-url',
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('accepts -i and --url as admin target overrides', () => {
|
|
82
|
+
expect(
|
|
83
|
+
resolveAdminTargetFromStore({ instance: 'admin' }, DEFAULT_CONFIG, instances),
|
|
84
|
+
).toMatchObject({
|
|
85
|
+
name: 'admin',
|
|
86
|
+
url: 'https://bookmarked-admin.example.com',
|
|
87
|
+
source: 'admin',
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
expect(
|
|
91
|
+
resolveAdminTargetFromStore(
|
|
92
|
+
{ url: 'https://override.example.com' },
|
|
93
|
+
DEFAULT_CONFIG,
|
|
94
|
+
instances,
|
|
95
|
+
),
|
|
96
|
+
).toMatchObject({
|
|
97
|
+
name: 'admin',
|
|
98
|
+
url: 'https://override.example.com',
|
|
99
|
+
source: 'admin-url',
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('rejects conflicting admin target overrides', () => {
|
|
104
|
+
expect(() =>
|
|
105
|
+
resolveAdminTargetFromStore(
|
|
106
|
+
{ admin: 'admin', adminUrl: 'https://override-admin.example.com' },
|
|
107
|
+
DEFAULT_CONFIG,
|
|
108
|
+
instances,
|
|
109
|
+
),
|
|
110
|
+
).toThrow('Choose one admin target override')
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
|
2
|
+
import { readFile, unlink } from 'node:fs/promises'
|
|
3
|
+
|
|
4
|
+
import { presentBinary, type BinaryLike } from '../binary'
|
|
5
|
+
|
|
6
|
+
const png = (): BinaryLike => ({
|
|
7
|
+
status: 200,
|
|
8
|
+
contentType: 'image/png',
|
|
9
|
+
body: new Uint8Array([1, 2, 3, 4]),
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
describe('presentBinary', () => {
|
|
13
|
+
let chunks: Buffer[] = []
|
|
14
|
+
let originalWrite: typeof process.stdout.write
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
chunks = []
|
|
18
|
+
originalWrite = process.stdout.write.bind(process.stdout)
|
|
19
|
+
process.stdout.write = mock((chunk: string | Uint8Array) => {
|
|
20
|
+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk))
|
|
21
|
+
return true
|
|
22
|
+
}) as typeof process.stdout.write
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
process.stdout.write = originalWrite
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('--raw writes the raw bytes to stdout', async () => {
|
|
30
|
+
await presentBinary(png(), { raw: true })
|
|
31
|
+
expect(Array.from(Buffer.concat(chunks))).toEqual([1, 2, 3, 4])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('--json writes a base64-wrapped JSON envelope', async () => {
|
|
35
|
+
await presentBinary(png(), { json: true })
|
|
36
|
+
const obj = JSON.parse(Buffer.concat(chunks).toString())
|
|
37
|
+
expect(obj.contentType).toBe('image/png')
|
|
38
|
+
expect([...Buffer.from(obj.bodyBase64, 'base64')]).toEqual([1, 2, 3, 4])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('--json inlines text for text-like content types', async () => {
|
|
42
|
+
await presentBinary(
|
|
43
|
+
{ status: 200, contentType: 'text/plain', body: new TextEncoder().encode('hi') },
|
|
44
|
+
{ json: true },
|
|
45
|
+
)
|
|
46
|
+
const obj = JSON.parse(Buffer.concat(chunks).toString())
|
|
47
|
+
expect(obj.body).toBe('hi')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('-o writes raw bytes to a file', async () => {
|
|
51
|
+
const tmp = `${process.env.TMPDIR ?? '/tmp'}/astrale-bin-test-${process.pid}.bin`
|
|
52
|
+
await presentBinary(png(), {}, { outFile: tmp })
|
|
53
|
+
expect([...(await readFile(tmp))]).toEqual([1, 2, 3, 4])
|
|
54
|
+
await unlink(tmp)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { CommanderError } from 'commander'
|
|
3
|
+
|
|
4
|
+
import { buildProgram } from '../../program'
|
|
5
|
+
import { collectCommandCatalog, renderCommanderError } from '../command-dx'
|
|
6
|
+
|
|
7
|
+
// `renderCommanderError` styles commands with `chalk.bold`, which emits ANSI
|
|
8
|
+
// codes only when chalk detects a color-capable stdout (TTY). Strip them so the
|
|
9
|
+
// assertions are deterministic whether the suite runs under a TTY or in CI.
|
|
10
|
+
// (Built from a char code so the regex source carries no literal control char.)
|
|
11
|
+
const ANSI_RE = new RegExp(String.fromCharCode(27) + '\\[[0-9;]*m', 'g')
|
|
12
|
+
const stripAnsi = (s: string): string => s.replace(ANSI_RE, '')
|
|
13
|
+
|
|
14
|
+
describe('command DX suggestions', () => {
|
|
15
|
+
test('collects command paths from the registered program tree', async () => {
|
|
16
|
+
const program = await buildProgram()
|
|
17
|
+
const usages = collectCommandCatalog(program).map((entry) => entry.usage)
|
|
18
|
+
|
|
19
|
+
expect(usages).toContain('identity use <name>')
|
|
20
|
+
expect(usages).toContain('instance use [name]')
|
|
21
|
+
expect(usages).toContain('admin status')
|
|
22
|
+
expect(usages).toContain('admin use [bookmark]')
|
|
23
|
+
expect(usages).toContain('use <name>')
|
|
24
|
+
expect(usages).toContain('update')
|
|
25
|
+
expect(usages).toContain('ls [path]')
|
|
26
|
+
expect(usages).toContain('status')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('explains shared verbs as namespaced commands, not arity errors', async () => {
|
|
30
|
+
const program = await buildProgram()
|
|
31
|
+
const error = new CommanderError(
|
|
32
|
+
1,
|
|
33
|
+
'commander.excessArguments',
|
|
34
|
+
'error: too many arguments. Expected 0 arguments but got 2.',
|
|
35
|
+
)
|
|
36
|
+
const rendered = stripAnsi(renderCommanderError(program, error, ['delete', 'demo-system']))
|
|
37
|
+
|
|
38
|
+
expect(rendered).toContain('Unknown command: astrale delete demo-system')
|
|
39
|
+
expect(rendered).toContain('"delete" is available under:')
|
|
40
|
+
expect(rendered).toContain('astrale identity delete <name>')
|
|
41
|
+
expect(rendered).toContain('astrale instance delete <id>')
|
|
42
|
+
expect(rendered).not.toContain('too many arguments')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('suggests nearest command for typo paths', async () => {
|
|
46
|
+
const program = await buildProgram()
|
|
47
|
+
const error = new CommanderError(
|
|
48
|
+
1,
|
|
49
|
+
'commander.excessArguments',
|
|
50
|
+
'error: too many arguments. Expected 0 arguments but got 2.',
|
|
51
|
+
)
|
|
52
|
+
const rendered = stripAnsi(renderCommanderError(program, error, ['indetityl', 'ist']))
|
|
53
|
+
|
|
54
|
+
expect(rendered).toContain('Unknown command: astrale indetityl ist')
|
|
55
|
+
expect(rendered).toContain('Did you mean:')
|
|
56
|
+
expect(rendered).toContain('astrale identity list')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { mapBounded } from '../concurrency'
|
|
4
|
+
|
|
5
|
+
const tick = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
6
|
+
|
|
7
|
+
describe('mapBounded', () => {
|
|
8
|
+
test('preserves input order regardless of completion order', async () => {
|
|
9
|
+
const out = await mapBounded([30, 10, 20, 0], 4, async (ms, i) => {
|
|
10
|
+
await tick(ms)
|
|
11
|
+
return i
|
|
12
|
+
})
|
|
13
|
+
expect(out).toEqual([0, 1, 2, 3])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('never exceeds the concurrency bound', async () => {
|
|
17
|
+
let inFlight = 0
|
|
18
|
+
let peak = 0
|
|
19
|
+
await mapBounded(
|
|
20
|
+
Array.from({ length: 12 }, (_, i) => i),
|
|
21
|
+
3,
|
|
22
|
+
async () => {
|
|
23
|
+
inFlight++
|
|
24
|
+
peak = Math.max(peak, inFlight)
|
|
25
|
+
await tick(5)
|
|
26
|
+
inFlight--
|
|
27
|
+
},
|
|
28
|
+
)
|
|
29
|
+
expect(peak).toBeLessThanOrEqual(3)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('processes every item exactly once', async () => {
|
|
33
|
+
const seen: number[] = []
|
|
34
|
+
await mapBounded([1, 2, 3, 4, 5], 2, async (n) => {
|
|
35
|
+
seen.push(n)
|
|
36
|
+
})
|
|
37
|
+
expect(seen.sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('empty input → empty output, fn never called', async () => {
|
|
41
|
+
let called = false
|
|
42
|
+
const out = await mapBounded([], 4, async () => {
|
|
43
|
+
called = true
|
|
44
|
+
})
|
|
45
|
+
expect(out).toEqual([])
|
|
46
|
+
expect(called).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('limit below 1 is clamped (still makes progress)', async () => {
|
|
50
|
+
const out = await mapBounded([1, 2, 3], 0, async (n) => n * 2)
|
|
51
|
+
expect(out).toEqual([2, 4, 6])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('a rejection propagates', async () => {
|
|
55
|
+
await expect(
|
|
56
|
+
mapBounded([1, 2, 3], 2, async (n) => {
|
|
57
|
+
if (n === 2) throw new Error('boom')
|
|
58
|
+
return n
|
|
59
|
+
}),
|
|
60
|
+
).rejects.toThrow('boom')
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_ADMIN_TARGET_URL } from '../admin-target'
|
|
4
|
+
import { AstraleConfigSchema } from '../config'
|
|
5
|
+
|
|
6
|
+
describe('AstraleConfigSchema', () => {
|
|
7
|
+
test('parses valid config', () => {
|
|
8
|
+
const result = AstraleConfigSchema.parse({
|
|
9
|
+
issuer: 'https://test.astrale.ai',
|
|
10
|
+
})
|
|
11
|
+
expect(result.issuer).toBe('https://test.astrale.ai')
|
|
12
|
+
expect(result.admin.url).toBe(DEFAULT_ADMIN_TARGET_URL)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('applies defaults for empty object', () => {
|
|
16
|
+
const result = AstraleConfigSchema.parse({})
|
|
17
|
+
expect(result.issuer).toBe('https://unregistered.invalid')
|
|
18
|
+
expect(result.admin).toEqual({
|
|
19
|
+
name: 'admin',
|
|
20
|
+
url: DEFAULT_ADMIN_TARGET_URL,
|
|
21
|
+
issuer: DEFAULT_ADMIN_TARGET_URL,
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('rejects non-url issuer', () => {
|
|
26
|
+
expect(() => AstraleConfigSchema.parse({ issuer: 'not-a-url' })).toThrow()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('strips removed local-runtime config keys', () => {
|
|
30
|
+
const result = AstraleConfigSchema.parse({
|
|
31
|
+
issuer: 'https://test.astrale.ai',
|
|
32
|
+
managerPort: 9000,
|
|
33
|
+
removedRuntimeKey: true,
|
|
34
|
+
})
|
|
35
|
+
expect('managerPort' in result).toBe(false)
|
|
36
|
+
expect('removedRuntimeKey' in result).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('accepts configured admin bookmark', () => {
|
|
40
|
+
const result = AstraleConfigSchema.parse({
|
|
41
|
+
admin: { instance: 'staging-admin' },
|
|
42
|
+
})
|
|
43
|
+
expect(result.admin).toEqual({ instance: 'staging-admin' })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('rejects admin config with url and instance together', () => {
|
|
47
|
+
expect(() =>
|
|
48
|
+
AstraleConfigSchema.parse({
|
|
49
|
+
admin: { url: DEFAULT_ADMIN_TARGET_URL, instance: 'admin' },
|
|
50
|
+
}),
|
|
51
|
+
).toThrow()
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { IdentifierCollisionError, ReservedSlugError } from '../../errors'
|
|
4
|
+
import { InstanceStoreSchema, assertNoCollision, resolveInstanceKey } from '../instance'
|
|
5
|
+
import { validateSlug } from '../validation'
|
|
6
|
+
|
|
7
|
+
const baseStore = InstanceStoreSchema.parse({
|
|
8
|
+
active: 'staging',
|
|
9
|
+
instances: {
|
|
10
|
+
staging: {
|
|
11
|
+
url: 'https://staging.example.com',
|
|
12
|
+
createdAt: '2024-02-01T00:00:00Z',
|
|
13
|
+
slug: 'staging',
|
|
14
|
+
name: 'Staging Cluster',
|
|
15
|
+
kind: 'bookmark',
|
|
16
|
+
mode: 'remote',
|
|
17
|
+
},
|
|
18
|
+
localdev: {
|
|
19
|
+
url: 'https://localdev.example.com',
|
|
20
|
+
createdAt: '2024-02-02T00:00:00Z',
|
|
21
|
+
slug: 'localdev',
|
|
22
|
+
kind: 'bookmark',
|
|
23
|
+
mode: 'remote',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('DESIGN — §4.7 slug + namespace', () => {
|
|
29
|
+
test('validateSlug accepts URL-safe slugs', () => {
|
|
30
|
+
expect(() => validateSlug('foo')).not.toThrow()
|
|
31
|
+
expect(() => validateSlug('foo-bar')).not.toThrow()
|
|
32
|
+
expect(() => validateSlug('f00-bar-1')).not.toThrow()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('validateSlug rejects invalid slugs', () => {
|
|
36
|
+
expect(() => validateSlug('Foo')).toThrow(/Invalid slug/)
|
|
37
|
+
expect(() => validateSlug('-foo')).toThrow(/Invalid slug/)
|
|
38
|
+
expect(() => validateSlug('foo_bar')).toThrow(/Invalid slug/)
|
|
39
|
+
expect(() => validateSlug('foo.bar')).toThrow(/Invalid slug/)
|
|
40
|
+
expect(() => validateSlug('')).toThrow(/Invalid slug/)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('validateSlug rejects reserved "manager"', () => {
|
|
44
|
+
expect(() => validateSlug('manager')).toThrow(ReservedSlugError)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('assertNoCollision rejects existing key', () => {
|
|
48
|
+
expect(() => assertNoCollision(baseStore, ['staging'])).toThrow(IdentifierCollisionError)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('assertNoCollision rejects existing slug', () => {
|
|
52
|
+
expect(() => assertNoCollision(baseStore, ['localdev'])).toThrow(IdentifierCollisionError)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('assertNoCollision rejects existing name', () => {
|
|
56
|
+
expect(() => assertNoCollision(baseStore, ['Staging Cluster'])).toThrow(
|
|
57
|
+
IdentifierCollisionError,
|
|
58
|
+
)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('assertNoCollision accepts new identifier', () => {
|
|
62
|
+
expect(() => assertNoCollision(baseStore, ['newname'])).not.toThrow()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('assertNoCollision ignoreKey skips that entry', () => {
|
|
66
|
+
expect(() =>
|
|
67
|
+
assertNoCollision(baseStore, ['staging', 'Staging Cluster'], 'staging'),
|
|
68
|
+
).not.toThrow()
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('DESIGN — §7 resolver', () => {
|
|
73
|
+
test('resolveInstanceKey matches by key', () => {
|
|
74
|
+
expect(resolveInstanceKey(baseStore, 'staging')).toBe('staging')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('resolveInstanceKey matches by slug', () => {
|
|
78
|
+
expect(resolveInstanceKey(baseStore, 'localdev')).toBe('localdev')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('resolveInstanceKey matches by name', () => {
|
|
82
|
+
expect(resolveInstanceKey(baseStore, 'Staging Cluster')).toBe('staging')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('resolveInstanceKey returns null on miss', () => {
|
|
86
|
+
expect(resolveInstanceKey(baseStore, 'nope')).toBeNull()
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('DESIGN — backward compat registry', () => {
|
|
91
|
+
test('legacy store without kind/slug/mode parses', () => {
|
|
92
|
+
const legacy = InstanceStoreSchema.parse({
|
|
93
|
+
active: 'old',
|
|
94
|
+
instances: { old: { url: 'http://x', createdAt: '2023-01-01' } },
|
|
95
|
+
})
|
|
96
|
+
expect(legacy.instances.old.kind).toBeUndefined()
|
|
97
|
+
expect(legacy.instances.old.mode).toBeUndefined()
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `inferAlg` regression tests.
|
|
3
|
+
*
|
|
4
|
+
* (The historical `collectFunctionSubs` adversarial suite — 15 cases that
|
|
5
|
+
* pinned the raw-spec edge-walker against both wire forms / both member
|
|
6
|
+
* namespaces — was removed when the CLI converged onto the kernel's
|
|
7
|
+
* canonical resolver. Subs derivation now runs the IDENTICAL
|
|
8
|
+
* `Graph.fromWire` → `deserializeDomainFromGraph` → `collectFunctionSubs`
|
|
9
|
+
* pipeline the kernel uses at install validation, so drift is impossible
|
|
10
|
+
* by construction. The contract is pinned by
|
|
11
|
+
* `kernel/core/__tests__/domain/resolve-callables-agreement.test.ts`.)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, expect, test } from 'bun:test'
|
|
15
|
+
|
|
16
|
+
import { AstraleError } from '../../errors'
|
|
17
|
+
import { inferAlg } from '../domain-identity'
|
|
18
|
+
|
|
19
|
+
describe('inferAlg', () => {
|
|
20
|
+
// Adversarial: older CLI releases generated ES256 keys without
|
|
21
|
+
// stamping `alg`. Every fresh-machine `astrale domain install` then
|
|
22
|
+
// crashed with "alg is required when jwk.alg is not present" because the
|
|
23
|
+
// CLI read `privateJwk.alg as string` directly and handed `undefined` to
|
|
24
|
+
// jose's importJWK. The fix sets `alg` at keygen time AND falls back to
|
|
25
|
+
// inferring from `crv`/`kty` for already-issued keys (META_TRACE #34).
|
|
26
|
+
|
|
27
|
+
test('returns explicit alg when present', () => {
|
|
28
|
+
expect(inferAlg({ alg: 'ES256' })).toBe('ES256')
|
|
29
|
+
expect(inferAlg({ alg: 'EdDSA' })).toBe('EdDSA')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('infers ES256 from P-256 EC keys (crv + kty) when alg is missing', () => {
|
|
33
|
+
expect(inferAlg({ kty: 'EC', crv: 'P-256' })).toBe('ES256')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('infers EdDSA from Ed25519 OKP keys (crv + kty) when alg is missing', () => {
|
|
37
|
+
expect(inferAlg({ kty: 'OKP', crv: 'Ed25519' })).toBe('EdDSA')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('explicit alg wins over inferable shape (no second-guessing)', () => {
|
|
41
|
+
// If a file says ES256 but has Ed25519 components, that's a broken
|
|
42
|
+
// file — don't silently override. assertKeyPairConsistent will catch
|
|
43
|
+
// the mismatch downstream.
|
|
44
|
+
expect(inferAlg({ alg: 'ES256', kty: 'OKP', crv: 'Ed25519' })).toBe('ES256')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('throws AstraleError with hint when no alg/crv/kty resolves', () => {
|
|
48
|
+
expect(() => inferAlg({})).toThrow(AstraleError)
|
|
49
|
+
expect(() => inferAlg({ kty: 'EC' })).toThrow(AstraleError)
|
|
50
|
+
expect(() => inferAlg({ kty: 'EC', crv: 'P-384' })).toThrow(AstraleError)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('error message includes keyPath when provided', () => {
|
|
54
|
+
expect(() => inferAlg({}, '/path/to/broken.jwk')).toThrow(/\/path\/to\/broken\.jwk/)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('treats empty-string alg as missing', () => {
|
|
58
|
+
expect(inferAlg({ alg: '', kty: 'EC', crv: 'P-256' })).toBe('ES256')
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { formatElapsed } from '../format'
|
|
4
|
+
|
|
5
|
+
describe('formatElapsed', () => {
|
|
6
|
+
test('formats milliseconds under 1s', () => {
|
|
7
|
+
expect(formatElapsed(50)).toBe('50ms')
|
|
8
|
+
expect(formatElapsed(999)).toBe('999ms')
|
|
9
|
+
expect(formatElapsed(0)).toBe('0ms')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('rounds fractional ms', () => {
|
|
13
|
+
expect(formatElapsed(50.7)).toBe('51ms')
|
|
14
|
+
expect(formatElapsed(0.4)).toBe('0ms')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('formats seconds at 1000ms+', () => {
|
|
18
|
+
expect(formatElapsed(1000)).toBe('1.00s')
|
|
19
|
+
expect(formatElapsed(1500)).toBe('1.50s')
|
|
20
|
+
expect(formatElapsed(12345)).toBe('12.35s')
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtemp, readdir, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { atomicWrite, withFileLock } from '../fs-atomic'
|
|
7
|
+
|
|
8
|
+
let tmp: string
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
tmp = await mkdtemp(join(tmpdir(), 'astrale-fs-atomic-'))
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await rm(tmp, { recursive: true, force: true })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('atomicWrite', () => {
|
|
19
|
+
test('writes the content with mode 0600 and leaves no tmp files behind', async () => {
|
|
20
|
+
const path = join(tmp, 'out.json')
|
|
21
|
+
|
|
22
|
+
await atomicWrite(path, '{"a":1}')
|
|
23
|
+
|
|
24
|
+
expect(await readFile(path, 'utf-8')).toBe('{"a":1}')
|
|
25
|
+
expect(((await stat(path)).mode & 0o777).toString(8)).toBe('600')
|
|
26
|
+
expect(await readdir(tmp)).toEqual(['out.json'])
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('withFileLock', () => {
|
|
31
|
+
test('serializes concurrent read-modify-write cycles', async () => {
|
|
32
|
+
const lockPath = join(tmp, 'counter.lock')
|
|
33
|
+
const counterPath = join(tmp, 'counter')
|
|
34
|
+
await writeFile(counterPath, '0')
|
|
35
|
+
|
|
36
|
+
const bump = () =>
|
|
37
|
+
withFileLock(
|
|
38
|
+
lockPath,
|
|
39
|
+
async () => {
|
|
40
|
+
const value = Number(await readFile(counterPath, 'utf-8'))
|
|
41
|
+
await new Promise((r) => setTimeout(r, 2))
|
|
42
|
+
await writeFile(counterPath, String(value + 1))
|
|
43
|
+
},
|
|
44
|
+
{ pollIntervalMs: 2 },
|
|
45
|
+
)
|
|
46
|
+
await Promise.all(Array.from({ length: 20 }, bump))
|
|
47
|
+
|
|
48
|
+
expect(await readFile(counterPath, 'utf-8')).toBe('20')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('takes over a lock whose mtime is stale', async () => {
|
|
52
|
+
const lockPath = join(tmp, 'stale.lock')
|
|
53
|
+
await writeFile(lockPath, JSON.stringify({ pid: process.pid }))
|
|
54
|
+
const past = new Date(Date.now() - 60_000)
|
|
55
|
+
await utimes(lockPath, past, past)
|
|
56
|
+
|
|
57
|
+
const ran = await withFileLock(lockPath, async () => 'ran', { pollIntervalMs: 5 })
|
|
58
|
+
|
|
59
|
+
expect(ran).toBe('ran')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('takes over a fresh lock held by a dead pid', async () => {
|
|
63
|
+
const lockPath = join(tmp, 'dead.lock')
|
|
64
|
+
await writeFile(lockPath, JSON.stringify({ pid: 99999999 }))
|
|
65
|
+
|
|
66
|
+
const ran = await withFileLock(lockPath, async () => 'ran', { pollIntervalMs: 5 })
|
|
67
|
+
|
|
68
|
+
expect(ran).toBe('ran')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('releases the lock when fn throws', async () => {
|
|
72
|
+
const lockPath = join(tmp, 'throw.lock')
|
|
73
|
+
|
|
74
|
+
await expect(
|
|
75
|
+
withFileLock(lockPath, async () => {
|
|
76
|
+
throw new Error('boom')
|
|
77
|
+
}),
|
|
78
|
+
).rejects.toThrow('boom')
|
|
79
|
+
|
|
80
|
+
const ran = await withFileLock(lockPath, async () => 'ran', { timeoutMs: 200 })
|
|
81
|
+
expect(ran).toBe('ran')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('creates a missing parent directory', async () => {
|
|
85
|
+
const lockPath = join(tmp, 'nested', 'deep', 'a.lock')
|
|
86
|
+
|
|
87
|
+
const ran = await withFileLock(lockPath, async () => 'ran')
|
|
88
|
+
|
|
89
|
+
expect(ran).toBe('ran')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('times out while a live holder keeps the lock', async () => {
|
|
93
|
+
const lockPath = join(tmp, 'held.lock')
|
|
94
|
+
await writeFile(lockPath, JSON.stringify({ pid: process.pid }))
|
|
95
|
+
|
|
96
|
+
await expect(
|
|
97
|
+
withFileLock(lockPath, async () => 'ran', {
|
|
98
|
+
pollIntervalMs: 20,
|
|
99
|
+
staleAfterMs: 60_000,
|
|
100
|
+
timeoutMs: 150,
|
|
101
|
+
}),
|
|
102
|
+
).rejects.toThrow('Timed out')
|
|
103
|
+
})
|
|
104
|
+
})
|