@astrale-os/cli 0.6.2-alpha.0 → 0.7.0-alpha.0
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/dist/astrale.js +272 -198
- package/package.json +1 -1
- package/src/commands/__tests__/admin-instance.test.ts +49 -2
- package/src/commands/__tests__/domain-install-owned.test.ts +122 -0
- package/src/commands/__tests__/help-contract.test.ts +15 -0
- package/src/commands/call.ts +1 -1
- package/src/commands/domain/install.ts +15 -4
- package/src/commands/instance/active.ts +4 -7
- package/src/commands/instance/list.ts +4 -8
- package/src/commands/instance/status.ts +14 -9
- package/src/commands/instance/use.ts +13 -11
- package/src/commands/view.ts +40 -13
- package/src/kernel/__tests__/client-owned-lookup.test.ts +51 -0
- package/src/kernel/client.ts +47 -20
- package/src/kernel/expand.ts +3 -22
- package/src/lib/__tests__/instance-candidates.test.ts +94 -19
- package/src/lib/__tests__/instance-target.test.ts +9 -1
- package/src/lib/__tests__/view-port-allocation.test.ts +82 -0
- package/src/lib/admin-instance.ts +27 -2
- package/src/lib/instance-candidates.ts +3 -3
- package/src/lib/instance-target.ts +1 -0
- package/src/lib/view/port-allocation.ts +19 -0
- package/src/setup/__tests__/instance-step.test.ts +239 -0
- package/src/setup/steps/instance.ts +141 -61
- package/studio/client/dist/assets/index-huaFafBC.css +1 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/client/dist/assets/index-DKKMHBBC.css +0 -1
- /package/studio/client/dist/assets/{index-CyN5G8IA.js → index-DAC1a9vW.js} +0 -0
package/package.json
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test'
|
|
1
|
+
import { describe, expect, mock, test } from 'bun:test'
|
|
2
2
|
|
|
3
3
|
import type { IdentityStore } from '../../lib/identity'
|
|
4
4
|
|
|
5
5
|
import { AuthError } from '../../errors'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
ADMIN_INSTANCE,
|
|
8
|
+
adminInstanceMethod,
|
|
9
|
+
callOwnedInstances,
|
|
10
|
+
findOwnedInstance,
|
|
11
|
+
type OwnedInstanceInfo,
|
|
12
|
+
} from '../../lib/admin-instance'
|
|
7
13
|
import { assertAlphaCreateIdentity } from '../instance/create'
|
|
8
14
|
|
|
9
15
|
describe('admin-backed instance commands', () => {
|
|
@@ -12,6 +18,47 @@ describe('admin-backed instance commands', () => {
|
|
|
12
18
|
expect(adminInstanceMethod('list')).toBe('/:admin.astrale.ai:class.Instance:list')
|
|
13
19
|
})
|
|
14
20
|
|
|
21
|
+
test('findOwnedInstance matches owner inventory by slug or stable node id', () => {
|
|
22
|
+
const owned = {
|
|
23
|
+
id: 'instance-node',
|
|
24
|
+
slug: 'demo',
|
|
25
|
+
url: 'https://demo.eu.astrale.ai',
|
|
26
|
+
state: 'failed',
|
|
27
|
+
organizationId: 'org_123',
|
|
28
|
+
hostId: 'host-1',
|
|
29
|
+
region: 'eu',
|
|
30
|
+
phase: 'installing:default-domains',
|
|
31
|
+
error: 'postInstall failed',
|
|
32
|
+
createdAt: '2026-07-16T00:00:00.000Z',
|
|
33
|
+
} satisfies OwnedInstanceInfo
|
|
34
|
+
|
|
35
|
+
expect(findOwnedInstance([owned], 'demo')).toBe(owned)
|
|
36
|
+
expect(findOwnedInstance([owned], 'instance-node')).toBe(owned)
|
|
37
|
+
expect(findOwnedInstance([owned], 'other')).toBeUndefined()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('owner-scoped discovery calls listMine with an empty input', async () => {
|
|
41
|
+
const owned: OwnedInstanceInfo[] = [
|
|
42
|
+
{
|
|
43
|
+
id: 'instance-node',
|
|
44
|
+
slug: 'demo',
|
|
45
|
+
url: 'https://demo.eu.astrale.ai',
|
|
46
|
+
state: 'ready',
|
|
47
|
+
},
|
|
48
|
+
]
|
|
49
|
+
const call = mock(async () => owned)
|
|
50
|
+
|
|
51
|
+
await expect(callOwnedInstances({ call })).resolves.toEqual([
|
|
52
|
+
{
|
|
53
|
+
id: 'instance-node',
|
|
54
|
+
slug: 'demo',
|
|
55
|
+
url: 'https://demo.eu.astrale.ai',
|
|
56
|
+
state: 'ready',
|
|
57
|
+
},
|
|
58
|
+
])
|
|
59
|
+
expect(call).toHaveBeenCalledWith('/:admin.astrale.ai:class.Instance:listMine', {})
|
|
60
|
+
})
|
|
61
|
+
|
|
15
62
|
test('instance create preflight points fresh installs at WorkOS login', () => {
|
|
16
63
|
const store: IdentityStore = {
|
|
17
64
|
default: 'manager',
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import type { OwnedInstanceInfo } from '../../lib/admin-instance'
|
|
4
|
+
|
|
5
|
+
import { adminDomainMethod } from '../../lib/admin-domain'
|
|
6
|
+
import { adminInstanceMethod } from '../../lib/admin-instance'
|
|
7
|
+
|
|
8
|
+
class ExitError extends Error {
|
|
9
|
+
constructor(readonly code: string | number | null | undefined) {
|
|
10
|
+
super(`process.exit(${String(code)})`)
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const calls: Array<{ path: string; params: unknown }> = []
|
|
15
|
+
let inventory: OwnedInstanceInfo[] = []
|
|
16
|
+
|
|
17
|
+
const clientCall = mock(async (path: string, params: unknown): Promise<unknown> => {
|
|
18
|
+
calls.push({ path, params })
|
|
19
|
+
if (path === adminInstanceMethod('listMine')) return inventory
|
|
20
|
+
throw new Error(`Unexpected admin call: ${path}`)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
mock.module('../../kernel', () => ({
|
|
24
|
+
runKernelCommand: mock(),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
mock.module('../../kernel/client', () => ({
|
|
28
|
+
withAdminKernelClient: async (
|
|
29
|
+
_opts: unknown,
|
|
30
|
+
run: (ctx: { client: { call: typeof clientCall } }) => Promise<unknown>,
|
|
31
|
+
) => run({ client: { call: clientCall } }),
|
|
32
|
+
}))
|
|
33
|
+
|
|
34
|
+
let stderr = ''
|
|
35
|
+
let originalExit: typeof process.exit
|
|
36
|
+
let originalStderrWrite: typeof process.stderr.write
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
calls.length = 0
|
|
40
|
+
inventory = []
|
|
41
|
+
stderr = ''
|
|
42
|
+
clientCall.mockClear()
|
|
43
|
+
originalExit = process.exit
|
|
44
|
+
originalStderrWrite = process.stderr.write.bind(process.stderr)
|
|
45
|
+
process.exit = ((code?: string | number | null) => {
|
|
46
|
+
throw new ExitError(code)
|
|
47
|
+
}) as typeof process.exit
|
|
48
|
+
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
49
|
+
stderr += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
|
|
50
|
+
return true
|
|
51
|
+
}) as typeof process.stderr.write
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
process.exit = originalExit
|
|
56
|
+
process.stderr.write = originalStderrWrite
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
async function runInstall(instance: string): Promise<void> {
|
|
60
|
+
const command = (await import('../domain/install')).default
|
|
61
|
+
const action = command.action as (
|
|
62
|
+
target: string | undefined,
|
|
63
|
+
opts: Record<string, unknown>,
|
|
64
|
+
) => Promise<void>
|
|
65
|
+
await action('crm.acme.dev', {
|
|
66
|
+
instance,
|
|
67
|
+
json: true,
|
|
68
|
+
noPrompt: true,
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('admin domain install owner boundary', () => {
|
|
73
|
+
test('rejects a foreign target after listMine without attempting an install', async () => {
|
|
74
|
+
inventory = [
|
|
75
|
+
{
|
|
76
|
+
id: 'owned-id',
|
|
77
|
+
slug: 'owned',
|
|
78
|
+
url: 'https://owned.eu.astrale.ai',
|
|
79
|
+
state: 'ready',
|
|
80
|
+
},
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
await expect(runInstall('foreign')).rejects.toEqual(new ExitError(1))
|
|
84
|
+
|
|
85
|
+
expect(JSON.parse(stderr)).toMatchObject({
|
|
86
|
+
error: 'INSTANCE_NOT_MANAGED',
|
|
87
|
+
message: 'Instance "foreign" is not admin-managed (managed: owned).',
|
|
88
|
+
})
|
|
89
|
+
expect(calls).toEqual([{ path: adminInstanceMethod('listMine'), params: {} }])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test.each([
|
|
93
|
+
{
|
|
94
|
+
state: 'provisioning' as const,
|
|
95
|
+
phase: 'installing:default-domains',
|
|
96
|
+
error: undefined,
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
state: 'failed' as const,
|
|
100
|
+
phase: undefined,
|
|
101
|
+
error: 'postInstall failed',
|
|
102
|
+
},
|
|
103
|
+
])('rejects an owned $state target before the install RPC', async (status) => {
|
|
104
|
+
inventory = [
|
|
105
|
+
{
|
|
106
|
+
id: `${status.state}-id`,
|
|
107
|
+
slug: 'owned',
|
|
108
|
+
url: 'https://owned.eu.astrale.ai',
|
|
109
|
+
...status,
|
|
110
|
+
},
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
await expect(runInstall('owned')).rejects.toEqual(new ExitError(1))
|
|
114
|
+
|
|
115
|
+
const payload = JSON.parse(stderr) as { error: string; message: string; hint?: string }
|
|
116
|
+
expect(payload.error).toBe('INSTANCE_NOT_READY')
|
|
117
|
+
expect(payload.message).toContain(`Instance "owned" is ${status.state}`)
|
|
118
|
+
expect(payload.hint).toBe(status.error ?? 'Run: astrale instance status owned')
|
|
119
|
+
expect(calls).toEqual([{ path: adminInstanceMethod('listMine'), params: {} }])
|
|
120
|
+
expect(calls.some((call) => call.path === adminDomainMethod('install'))).toBe(false)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -129,6 +129,21 @@ describe('help contract — read command split', () => {
|
|
|
129
129
|
})
|
|
130
130
|
})
|
|
131
131
|
|
|
132
|
+
describe('help contract — payload sources', () => {
|
|
133
|
+
test('call excludes --file while mutate supports it', async () => {
|
|
134
|
+
const program = await buildProgram()
|
|
135
|
+
const call = allCommands(program).find((command) => command.name() === 'call')
|
|
136
|
+
const mutate = allCommands(program).find((command) => command.name() === 'mutate')
|
|
137
|
+
const callHelp = call?.helpInformation() ?? ''
|
|
138
|
+
const mutateHelp = mutate?.helpInformation() ?? ''
|
|
139
|
+
|
|
140
|
+
expect(callHelp).toContain('--data <json>')
|
|
141
|
+
expect(callHelp).not.toContain('--file <path>')
|
|
142
|
+
expect(mutateHelp).toContain('--data <json>')
|
|
143
|
+
expect(mutateHelp).toContain('--file <path>')
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
132
147
|
describe('help contract — skill is single-source, not duplicated', () => {
|
|
133
148
|
const canonical = join(cliRoot, 'skills/astrale-cli/SKILL.md')
|
|
134
149
|
// Workspace mirror lives in the superrepo, outside this submodule. Absent
|
package/src/commands/call.ts
CHANGED
|
@@ -261,7 +261,7 @@ Examples:
|
|
|
261
261
|
$ astrale call /:host.astrale.ai:class.KernelInstance:list
|
|
262
262
|
$ astrale call /:blog.acme.com:class.Author:list limit=10
|
|
263
263
|
$ astrale call '@self::deactivate'
|
|
264
|
-
$ astrale call /:shell.astrale.ai:
|
|
264
|
+
$ astrale call /:shell.astrale.ai:function.search-domains --json
|
|
265
265
|
`,
|
|
266
266
|
arguments: [
|
|
267
267
|
{
|
|
@@ -8,7 +8,7 @@ import { AstraleError } from '../../errors'
|
|
|
8
8
|
import { runKernelCommand } from '../../kernel'
|
|
9
9
|
import { withAdminKernelClient } from '../../kernel/client'
|
|
10
10
|
import { adminDomainMethod, type DomainInfo } from '../../lib/admin-domain'
|
|
11
|
-
import {
|
|
11
|
+
import { callOwnedInstances, type OwnedInstanceInfo } from '../../lib/admin-instance'
|
|
12
12
|
import { ADMIN_TARGET_OPTIONS, type AdminTargetCommandOpts } from '../../lib/admin-target'
|
|
13
13
|
import { getActive } from '../../lib/instance'
|
|
14
14
|
import { fatal, log, withSpinner } from '../../lib/log'
|
|
@@ -125,7 +125,7 @@ async function installViaAdmin(target: string | undefined, opts: InstallOpts): P
|
|
|
125
125
|
|
|
126
126
|
try {
|
|
127
127
|
await withAdminKernelClient(adminOpts, async (ctx) => {
|
|
128
|
-
const instances =
|
|
128
|
+
const instances = await callOwnedInstances(ctx.client)
|
|
129
129
|
|
|
130
130
|
const ref = await resolveDomainRef(ctx, target, interactive)
|
|
131
131
|
const slug = await resolveTargetSlug(opts, target, interactive, instances)
|
|
@@ -139,6 +139,7 @@ async function installViaAdmin(target: string | undefined, opts: InstallOpts): P
|
|
|
139
139
|
`Install the url directly onto it instead: astrale domain install <url> --direct -i ${slug}`,
|
|
140
140
|
)
|
|
141
141
|
}
|
|
142
|
+
assertInstallTargetReady(match)
|
|
142
143
|
|
|
143
144
|
const label = ref.origin ?? ref.url ?? 'domain'
|
|
144
145
|
const result = await withSpinner(
|
|
@@ -168,10 +169,20 @@ async function installViaAdmin(target: string | undefined, opts: InstallOpts): P
|
|
|
168
169
|
log.dim(` url: ${result.url}`)
|
|
169
170
|
})
|
|
170
171
|
} catch (e) {
|
|
171
|
-
fatal(e)
|
|
172
|
+
fatal(e, opts)
|
|
172
173
|
}
|
|
173
174
|
}
|
|
174
175
|
|
|
176
|
+
function assertInstallTargetReady(instance: OwnedInstanceInfo): void {
|
|
177
|
+
if (instance.state === 'ready') return
|
|
178
|
+
const detail = instance.phase && instance.phase !== instance.state ? ` (${instance.phase})` : ''
|
|
179
|
+
throw new AstraleError(
|
|
180
|
+
'INSTANCE_NOT_READY',
|
|
181
|
+
`Instance "${instance.slug}" is ${instance.state}${detail}; domains cannot be installed yet.`,
|
|
182
|
+
instance.error ?? `Run: astrale instance status ${instance.slug}`,
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
175
186
|
/**
|
|
176
187
|
* Classify the positional install target for the admin path: an http(s) URL
|
|
177
188
|
* installs by `url`, anything else is treated as a catalog `origin` (the unique
|
|
@@ -219,7 +230,7 @@ async function resolveTargetSlug(
|
|
|
219
230
|
opts: InstallOpts,
|
|
220
231
|
target: string | undefined,
|
|
221
232
|
interactive: boolean,
|
|
222
|
-
instances:
|
|
233
|
+
instances: OwnedInstanceInfo[],
|
|
223
234
|
): Promise<string> {
|
|
224
235
|
if (opts.instance) return opts.instance
|
|
225
236
|
const active = await activeSlug()
|
|
@@ -2,8 +2,8 @@ import chalk from 'chalk'
|
|
|
2
2
|
|
|
3
3
|
import type { CommandDefinition } from '../../command'
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { listOwnedInstances } from '../../kernel/client'
|
|
6
|
+
import { findOwnedInstance } from '../../lib/admin-instance'
|
|
7
7
|
import { getActive, normalizeInstanceKernelUrl } from '../../lib/instance'
|
|
8
8
|
import { log } from '../../lib/log'
|
|
9
9
|
import { RAW_OUTPUT_OPTIONS, isMachine, output, type RawOutputOpts } from '../../lib/output'
|
|
@@ -48,11 +48,8 @@ async function resolveActiveForDisplay(): Promise<{
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
try {
|
|
51
|
-
const managed = await
|
|
52
|
-
|
|
53
|
-
async (ctx) =>
|
|
54
|
-
(await ctx.client.call(adminInstanceMethod('info'), { id: active.name })) as InstanceInfo,
|
|
55
|
-
)
|
|
51
|
+
const managed = findOwnedInstance(await listOwnedInstances({}), active.name)
|
|
52
|
+
if (!managed) return { name: active.name }
|
|
56
53
|
return {
|
|
57
54
|
name: managed.slug,
|
|
58
55
|
url: normalizeInstanceKernelUrl(managed.url),
|
|
@@ -4,11 +4,11 @@ import type { CommandDefinition } from '../../command'
|
|
|
4
4
|
import type { KernelCommandOpts } from '../../kernel'
|
|
5
5
|
import type { Column } from '../../lib/output'
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { listOwnedInstances } from '../../kernel/client'
|
|
8
8
|
import {
|
|
9
|
-
adminInstanceMethod,
|
|
10
9
|
formatInstanceLocation,
|
|
11
10
|
type InstanceInfo,
|
|
11
|
+
type OwnedInstanceInfo,
|
|
12
12
|
} from '../../lib/admin-instance'
|
|
13
13
|
import { ADMIN_TARGET_OPTIONS, type AdminTargetCommandOpts } from '../../lib/admin-target'
|
|
14
14
|
import { normalizeInstanceKernelUrl, readInstances } from '../../lib/instance'
|
|
@@ -59,14 +59,10 @@ export default {
|
|
|
59
59
|
createdAt: entry.createdAt ?? null,
|
|
60
60
|
}))
|
|
61
61
|
|
|
62
|
-
let managed:
|
|
62
|
+
let managed: OwnedInstanceInfo[] = []
|
|
63
63
|
if (!opts.bookmarked) {
|
|
64
64
|
managed = await withSpinner('Fetching instances', !isMachine(opts), () =>
|
|
65
|
-
|
|
66
|
-
opts,
|
|
67
|
-
async (ctx) =>
|
|
68
|
-
(await ctx.client.call(adminInstanceMethod('list'), {})) as InstanceInfo[],
|
|
69
|
-
),
|
|
65
|
+
listOwnedInstances(opts),
|
|
70
66
|
)
|
|
71
67
|
}
|
|
72
68
|
if (isMachine(opts)) {
|
|
@@ -3,8 +3,9 @@ import chalk from 'chalk'
|
|
|
3
3
|
import type { CommandDefinition } from '../../command'
|
|
4
4
|
import type { KernelCommandOpts } from '../../kernel'
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { AstraleError } from '../../errors'
|
|
7
|
+
import { listOwnedInstances } from '../../kernel/client'
|
|
8
|
+
import { findOwnedInstance } from '../../lib/admin-instance'
|
|
8
9
|
import { ADMIN_TARGET_OPTIONS, type AdminTargetCommandOpts } from '../../lib/admin-target'
|
|
9
10
|
import { fatal, log, withSpinner } from '../../lib/log'
|
|
10
11
|
import { isMachine, output, type RawOutputOpts } from '../../lib/output'
|
|
@@ -19,22 +20,26 @@ export default {
|
|
|
19
20
|
action: async (id: string, opts: StatusOpts) => {
|
|
20
21
|
try {
|
|
21
22
|
const result = await withSpinner(`Fetching instance ${id}`, !isMachine(opts), () =>
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
listOwnedInstances(opts).then((instances) => {
|
|
24
|
+
const match = findOwnedInstance(instances, id)
|
|
25
|
+
if (match) return match
|
|
26
|
+
throw new AstraleError(
|
|
27
|
+
'INSTANCE_NOT_FOUND',
|
|
28
|
+
`No owned instance matches "${id}".`,
|
|
29
|
+
'Run `astrale instance list` to see your instances.',
|
|
30
|
+
)
|
|
31
|
+
}),
|
|
27
32
|
)
|
|
28
33
|
if (isMachine(opts)) {
|
|
29
34
|
output(result, opts)
|
|
30
35
|
return
|
|
31
36
|
}
|
|
32
37
|
console.log(`${chalk.bold(result.slug)} ${chalk.dim(result.url)}`)
|
|
33
|
-
|
|
34
|
-
log.dim(` state: ${result.state}${result.phase ? ` (${result.phase})` : ''}`)
|
|
38
|
+
log.dim(` state: ${result.state}${result.phase ? ` (${result.phase})` : ''}`)
|
|
35
39
|
if (result.error) log.dim(` error: ${result.error}`)
|
|
36
40
|
if (result.region) log.dim(` region: ${result.region}`)
|
|
37
41
|
if (result.hostId) log.dim(` host: ${result.hostId}`)
|
|
42
|
+
if (result.organizationId) log.dim(` organization: ${result.organizationId}`)
|
|
38
43
|
if (result.createdAt) log.dim(` created: ${result.createdAt}`)
|
|
39
44
|
} catch (e) {
|
|
40
45
|
fatal(e, opts)
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { CommandDefinition } from '../../command'
|
|
2
|
+
import type { OwnedInstanceInfo } from '../../lib/admin-instance'
|
|
2
3
|
|
|
3
4
|
import { AstraleError } from '../../errors'
|
|
4
|
-
import {
|
|
5
|
-
import { adminInstanceMethod, type InstanceInfo } from '../../lib/admin-instance'
|
|
5
|
+
import { listOwnedInstances } from '../../kernel/client'
|
|
6
6
|
import { ADMIN_TARGET_OPTIONS } from '../../lib/admin-target'
|
|
7
7
|
import { getDefault, setDefault } from '../../lib/identity'
|
|
8
8
|
import {
|
|
@@ -121,7 +121,12 @@ async function resolveUseTarget(name: string, opts: UseOpts): Promise<ResolvedIn
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
assertManagedReady(chosen.info)
|
|
124
|
-
const { repointedFrom } = await upsertManagedBookmark(
|
|
124
|
+
const { repointedFrom } = await upsertManagedBookmark(
|
|
125
|
+
chosen.key,
|
|
126
|
+
chosen.info.slug,
|
|
127
|
+
chosen.url,
|
|
128
|
+
chosen.info.organizationId,
|
|
129
|
+
)
|
|
125
130
|
if (repointedFrom) {
|
|
126
131
|
log.warn(`Bookmark "${chosen.key}" repointed: ${repointedFrom} → ${chosen.url}`)
|
|
127
132
|
}
|
|
@@ -133,29 +138,26 @@ async function resolveUseTarget(name: string, opts: UseOpts): Promise<ResolvedIn
|
|
|
133
138
|
* Best-effort: an unreachable or unauthenticated admin kernel degrades to
|
|
134
139
|
* bookmark-only resolution.
|
|
135
140
|
*/
|
|
136
|
-
async function fetchManagedInstances(name: string, opts: UseOpts): Promise<
|
|
141
|
+
async function fetchManagedInstances(name: string, opts: UseOpts): Promise<OwnedInstanceInfo[]> {
|
|
137
142
|
try {
|
|
138
143
|
validateSlug(name)
|
|
139
144
|
} catch {
|
|
140
145
|
return []
|
|
141
146
|
}
|
|
142
147
|
try {
|
|
143
|
-
return await
|
|
144
|
-
opts,
|
|
145
|
-
async (ctx) => (await ctx.client.call(adminInstanceMethod('list'), {})) as InstanceInfo[],
|
|
146
|
-
)
|
|
148
|
+
return await listOwnedInstances(opts)
|
|
147
149
|
} catch {
|
|
148
150
|
return []
|
|
149
151
|
}
|
|
150
152
|
}
|
|
151
153
|
|
|
152
|
-
function assertManagedReady(info:
|
|
153
|
-
if (
|
|
154
|
+
function assertManagedReady(info: OwnedInstanceInfo): void {
|
|
155
|
+
if (info.state === 'ready') return
|
|
154
156
|
const detail = info.phase && info.phase !== info.state ? ` (${info.phase})` : ''
|
|
155
157
|
throw new AstraleError(
|
|
156
158
|
'INSTANCE_NOT_READY',
|
|
157
159
|
`Instance "${info.slug}" is ${info.state}${detail}; it cannot be selected yet.`,
|
|
158
|
-
info.error ??
|
|
160
|
+
info.error ?? `Run: astrale instance status ${info.slug}`,
|
|
159
161
|
)
|
|
160
162
|
}
|
|
161
163
|
|
package/src/commands/view.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { fatal, log } from '../lib/log'
|
|
|
18
18
|
import { isMachine, output, type RawOutputOpts } from '../lib/output'
|
|
19
19
|
import { findFreePort } from '../lib/port'
|
|
20
20
|
import { run, spawnHandle } from '../lib/proc'
|
|
21
|
+
import { withViewPortAllocationLock } from '../lib/view/port-allocation'
|
|
21
22
|
import {
|
|
22
23
|
applyViewUrlOverride,
|
|
23
24
|
candidateSlug,
|
|
@@ -232,16 +233,45 @@ async function startSession(
|
|
|
232
233
|
await ensureViewerAssets()
|
|
233
234
|
const config = await readConfig()
|
|
234
235
|
const kernelTarget = await resolveKernelTarget(opts, config)
|
|
236
|
+
const [instances, identities, runtime] = await Promise.all([
|
|
237
|
+
readInstances(),
|
|
238
|
+
readIdentities(),
|
|
239
|
+
resolveServeRuntime(),
|
|
240
|
+
])
|
|
241
|
+
return withViewPortAllocationLock(() =>
|
|
242
|
+
startSessionLocked(
|
|
243
|
+
view,
|
|
244
|
+
target,
|
|
245
|
+
opts,
|
|
246
|
+
kernelTarget,
|
|
247
|
+
instances.active,
|
|
248
|
+
identities.default,
|
|
249
|
+
runtime,
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Called under the cross-process port-allocation lock. Keep the lock until the
|
|
256
|
+
* detached child answers its readiness probe: only then is the selected port
|
|
257
|
+
* durably owned and safe for the next CLI process to scan.
|
|
258
|
+
*/
|
|
259
|
+
async function startSessionLocked(
|
|
260
|
+
view: ResolvedView,
|
|
261
|
+
target: ResolvedTarget | undefined,
|
|
262
|
+
opts: ViewOpts,
|
|
263
|
+
kernelTarget: { url: string; caFile?: string },
|
|
264
|
+
activeInstance: string | undefined,
|
|
265
|
+
defaultIdentity: string | undefined,
|
|
266
|
+
runtime: { file: string; args: string[] },
|
|
267
|
+
): Promise<ViewSessionRecord> {
|
|
235
268
|
const port = await findFreePort(VIEW_PORT_BASE, VIEW_PORT_SPAN)
|
|
236
269
|
if (port === null) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
`No free port in ${VIEW_PORT_BASE}-${VIEW_PORT_BASE + VIEW_PORT_SPAN - 1} — close sessions with \`astrale view --close --all\``,
|
|
240
|
-
),
|
|
270
|
+
throw new Error(
|
|
271
|
+
`No free port in ${VIEW_PORT_BASE}-${VIEW_PORT_BASE + VIEW_PORT_SPAN - 1} — close sessions with \`astrale view --close --all\``,
|
|
241
272
|
)
|
|
242
273
|
}
|
|
243
274
|
|
|
244
|
-
const [instances, identities] = await Promise.all([readInstances(), readIdentities()])
|
|
245
275
|
const id = `v-${randomBytes(3).toString('hex')}`
|
|
246
276
|
const nonce = randomBytes(12).toString('hex')
|
|
247
277
|
const record: ViewSessionRecord = {
|
|
@@ -252,8 +282,8 @@ async function startSession(
|
|
|
252
282
|
pageUrl: `http://127.0.0.1:${port}/s/${nonce}/`,
|
|
253
283
|
view,
|
|
254
284
|
target,
|
|
255
|
-
instance: opts.instance ?? (opts.url ? opts.url :
|
|
256
|
-
identity: opts.creds ? '(pre-signed creds)' : (opts.as ??
|
|
285
|
+
instance: opts.instance ?? (opts.url ? opts.url : activeInstance),
|
|
286
|
+
identity: opts.creds ? '(pre-signed creds)' : (opts.as ?? defaultIdentity),
|
|
257
287
|
createdAt: new Date().toISOString(),
|
|
258
288
|
}
|
|
259
289
|
const serveConfig: ViewServeConfig = {
|
|
@@ -276,7 +306,6 @@ async function startSession(
|
|
|
276
306
|
await mkdir(VIEW_DIR, { recursive: true })
|
|
277
307
|
await writeFile(configPath(id), JSON.stringify(serveConfig, null, 2))
|
|
278
308
|
const logFd = openSync(logPath(id), 'a')
|
|
279
|
-
const runtime = await resolveServeRuntime()
|
|
280
309
|
const child = spawnHandle(
|
|
281
310
|
runtime.file,
|
|
282
311
|
[...runtime.args, '__view-serve', '--config', configPath(id)],
|
|
@@ -287,7 +316,7 @@ async function startSession(
|
|
|
287
316
|
)
|
|
288
317
|
child.unref()
|
|
289
318
|
closeSync(logFd)
|
|
290
|
-
if (!child.pid)
|
|
319
|
+
if (!child.pid) throw new Error('Failed to spawn the view session server')
|
|
291
320
|
const live = { ...record, pid: child.pid }
|
|
292
321
|
await saveRecord(live)
|
|
293
322
|
|
|
@@ -304,10 +333,8 @@ async function startSession(
|
|
|
304
333
|
}
|
|
305
334
|
const tail = await readFile(logPath(id), 'utf8').catch(() => '')
|
|
306
335
|
await closeSession(live)
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
`View session server did not come up.${tail ? `\n--- server log ---\n${tail.slice(-2000)}` : ''}`,
|
|
310
|
-
),
|
|
336
|
+
throw new Error(
|
|
337
|
+
`View session server did not come up.${tail ? `\n--- server log ---\n${tail.slice(-2000)}` : ''}`,
|
|
311
338
|
)
|
|
312
339
|
}
|
|
313
340
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import type { OwnedInstanceInfo } from '../../lib/admin-instance'
|
|
4
|
+
import type { AdminTargetCommandOpts } from '../../lib/admin-target'
|
|
5
|
+
import type { KernelCommandOpts } from '../types'
|
|
6
|
+
|
|
7
|
+
import { lookupImplicitOwnedInstance } from '../client'
|
|
8
|
+
|
|
9
|
+
describe('implicit managed target owner discovery', () => {
|
|
10
|
+
test('keeps target credentials out of the admin inventory lookup', async () => {
|
|
11
|
+
const owned: OwnedInstanceInfo = {
|
|
12
|
+
id: 'owned-id',
|
|
13
|
+
slug: 'owned',
|
|
14
|
+
url: 'https://owned.eu.astrale.ai',
|
|
15
|
+
state: 'ready',
|
|
16
|
+
}
|
|
17
|
+
let captured:
|
|
18
|
+
| {
|
|
19
|
+
slug: string
|
|
20
|
+
opts: KernelCommandOpts & AdminTargetCommandOpts
|
|
21
|
+
}
|
|
22
|
+
| undefined
|
|
23
|
+
const lookupOwned = mock(
|
|
24
|
+
async (slug: string, opts: KernelCommandOpts & AdminTargetCommandOpts) => {
|
|
25
|
+
captured = { slug, opts }
|
|
26
|
+
return owned
|
|
27
|
+
},
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
await lookupImplicitOwnedInstance(
|
|
31
|
+
'owned',
|
|
32
|
+
{
|
|
33
|
+
as: 'target-identity',
|
|
34
|
+
creds: 'target-delegation',
|
|
35
|
+
timeout: '45000',
|
|
36
|
+
debug: true,
|
|
37
|
+
},
|
|
38
|
+
{ lookupOwned },
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
expect(captured).toEqual({
|
|
42
|
+
slug: 'owned',
|
|
43
|
+
opts: {
|
|
44
|
+
timeout: '45000',
|
|
45
|
+
debug: true,
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
expect(captured?.opts).not.toHaveProperty('as')
|
|
49
|
+
expect(captured?.opts).not.toHaveProperty('creds')
|
|
50
|
+
})
|
|
51
|
+
})
|