@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/src/kernel/client.ts
CHANGED
|
@@ -5,7 +5,12 @@ import type { AdminTargetCommandOpts } from '../lib/admin-target'
|
|
|
5
5
|
import type { KernelCommandOpts } from './types'
|
|
6
6
|
|
|
7
7
|
import { AstraleError } from '../errors'
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
callOwnedInstances,
|
|
10
|
+
findOwnedInstance,
|
|
11
|
+
type InstanceInfo,
|
|
12
|
+
type OwnedInstanceInfo,
|
|
13
|
+
} from '../lib/admin-instance'
|
|
9
14
|
import { readConfig } from '../lib/config'
|
|
10
15
|
import { resolveInstanceTarget, type ResolvedInstanceTarget } from '../lib/instance-target'
|
|
11
16
|
import { resolveCredential } from './auth'
|
|
@@ -37,18 +42,15 @@ export async function resolveKernelTarget(
|
|
|
37
42
|
// Ad-hoc `--url` — unknown kernel. Stamp the URL itself as audience,
|
|
38
43
|
// no slug for per-instance signing.
|
|
39
44
|
if (opts.url && !opts.instance) {
|
|
40
|
-
const resolved = await resolveInstanceTarget(
|
|
41
|
-
{ source: 'url', url: opts.url },
|
|
42
|
-
{ config, admin: adminLookupOpts(opts) },
|
|
43
|
-
)
|
|
45
|
+
const resolved = await resolveInstanceTarget({ source: 'url', url: opts.url }, { config })
|
|
44
46
|
return resolvedToKernelTarget(resolved)
|
|
45
47
|
}
|
|
46
48
|
const resolved = await resolveInstanceTarget(
|
|
47
49
|
opts.instance ? { source: 'name', name: opts.instance } : { source: 'active' },
|
|
48
50
|
{
|
|
49
51
|
config,
|
|
50
|
-
admin:
|
|
51
|
-
managed: (slug) =>
|
|
52
|
+
admin: {},
|
|
53
|
+
managed: (slug) => lookupImplicitOwnedInstance(slug, opts),
|
|
52
54
|
},
|
|
53
55
|
)
|
|
54
56
|
return resolvedToKernelTarget(resolved, opts.url)
|
|
@@ -68,21 +70,46 @@ export async function withKernelClient<T>(
|
|
|
68
70
|
return withResolvedKernelClient(opts, config, target, fn)
|
|
69
71
|
}
|
|
70
72
|
|
|
71
|
-
async function
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
(await ctx.client.call(adminInstanceMethod('info'), { id: slug })) as InstanceInfo,
|
|
76
|
-
)
|
|
73
|
+
export async function listOwnedInstances(
|
|
74
|
+
opts: KernelCommandOpts & AdminTargetCommandOpts,
|
|
75
|
+
): Promise<OwnedInstanceInfo[]> {
|
|
76
|
+
return await withAdminKernelClient(opts, async (ctx) => callOwnedInstances(ctx.client))
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
function
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
79
|
+
export async function lookupOwnedInstance(
|
|
80
|
+
slug: string,
|
|
81
|
+
opts: KernelCommandOpts & AdminTargetCommandOpts,
|
|
82
|
+
): Promise<OwnedInstanceInfo> {
|
|
83
|
+
const instance = findOwnedInstance(await listOwnedInstances(opts), slug)
|
|
84
|
+
if (instance) return instance
|
|
85
|
+
throw new AstraleError('INSTANCE_NOT_FOUND', `No owned instance matches "${slug}".`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type ImplicitOwnedInstanceLookupDependencies = {
|
|
89
|
+
lookupOwned: (
|
|
90
|
+
slug: string,
|
|
91
|
+
opts: KernelCommandOpts & AdminTargetCommandOpts,
|
|
92
|
+
) => Promise<OwnedInstanceInfo>
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve an implicit managed target through the caller's owner inventory.
|
|
97
|
+
*
|
|
98
|
+
* Target `--as` / `--creds` belong to the eventual child-kernel call. They
|
|
99
|
+
* must not authenticate the admin discovery request: leaving them out lets
|
|
100
|
+
* `withAdminKernelClient` use the admin bookmark's default WorkOS identity.
|
|
101
|
+
*/
|
|
102
|
+
export async function lookupImplicitOwnedInstance(
|
|
103
|
+
slug: string,
|
|
104
|
+
targetOpts: KernelCommandOpts,
|
|
105
|
+
deps: ImplicitOwnedInstanceLookupDependencies = {
|
|
106
|
+
lookupOwned: lookupOwnedInstance,
|
|
107
|
+
},
|
|
108
|
+
): Promise<InstanceInfo> {
|
|
109
|
+
return await deps.lookupOwned(slug, {
|
|
110
|
+
timeout: targetOpts.timeout,
|
|
111
|
+
debug: targetOpts.debug,
|
|
112
|
+
})
|
|
86
113
|
}
|
|
87
114
|
|
|
88
115
|
function resolvedToKernelTarget(
|
package/src/kernel/expand.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import type { InstanceInfo } from '../lib/admin-instance'
|
|
2
1
|
import type { KernelCommandOpts } from './types'
|
|
3
2
|
|
|
4
|
-
import { adminInstanceMethod } from '../lib/admin-instance'
|
|
5
3
|
/** CLI bridge for @self resolution and stale-registration error hints. */
|
|
6
4
|
import { readConfig } from '../lib/config'
|
|
7
5
|
import { getDefault, getIdentity, setRegistration } from '../lib/identity'
|
|
@@ -17,7 +15,7 @@ import {
|
|
|
17
15
|
type SelfResolverContext,
|
|
18
16
|
type SelfResolution,
|
|
19
17
|
} from '../lib/self'
|
|
20
|
-
import {
|
|
18
|
+
import { lookupImplicitOwnedInstance, withKernelClient } from './client'
|
|
21
19
|
|
|
22
20
|
/** Metadata attached to errors so the NotFoundError path can hint at stale `@self` expansions. */
|
|
23
21
|
export type SelfExpansionMeta = {
|
|
@@ -41,8 +39,8 @@ export async function buildSelfContext(opts: KernelCommandOpts): Promise<SelfRes
|
|
|
41
39
|
opts.instance ? { source: 'name', name: opts.instance } : { source: 'active' },
|
|
42
40
|
{
|
|
43
41
|
config,
|
|
44
|
-
admin:
|
|
45
|
-
managed: (instanceSlug) =>
|
|
42
|
+
admin: {},
|
|
43
|
+
managed: (instanceSlug) => lookupImplicitOwnedInstance(instanceSlug, opts),
|
|
46
44
|
},
|
|
47
45
|
)
|
|
48
46
|
slug = resolved.name
|
|
@@ -83,23 +81,6 @@ export async function buildSelfContext(opts: KernelCommandOpts): Promise<SelfRes
|
|
|
83
81
|
return { identity, instanceSlug: slug, credsJwt: opts.creds, instanceSigned, idpSubject }
|
|
84
82
|
}
|
|
85
83
|
|
|
86
|
-
function adminLookupOpts(opts: KernelCommandOpts): KernelCommandOpts {
|
|
87
|
-
return {
|
|
88
|
-
as: opts.as,
|
|
89
|
-
creds: opts.creds,
|
|
90
|
-
timeout: opts.timeout,
|
|
91
|
-
debug: opts.debug,
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async function lookupManagedInstance(slug: string, opts: KernelCommandOpts): Promise<InstanceInfo> {
|
|
96
|
-
return await withAdminKernelClient(
|
|
97
|
-
adminLookupOpts(opts),
|
|
98
|
-
async (ctx) =>
|
|
99
|
-
(await ctx.client.call(adminInstanceMethod('info'), { id: slug })) as InstanceInfo,
|
|
100
|
-
)
|
|
101
|
-
}
|
|
102
|
-
|
|
103
84
|
/**
|
|
104
85
|
* Run `fn`; if it throws a `NotFoundError`, stamp expansion metadata onto the
|
|
105
86
|
* error so `formatKernelError` can append the stale-registration hint. The
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type { OwnedInstanceInfo } from '../admin-instance'
|
|
4
4
|
import type { InstanceStore } from '../instance'
|
|
5
5
|
|
|
6
6
|
import { collectInstanceCandidates, describeInstanceCandidate } from '../instance-candidates'
|
|
@@ -13,48 +13,116 @@ const store: InstanceStore = {
|
|
|
13
13
|
},
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
const managedDemo:
|
|
16
|
+
const managedDemo: OwnedInstanceInfo = {
|
|
17
17
|
id: 'demo',
|
|
18
18
|
slug: 'demo',
|
|
19
19
|
url: 'https://demo.eu.astrale.ai',
|
|
20
|
+
state: 'ready',
|
|
20
21
|
region: 'eu',
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
describe('collectInstanceCandidates', () => {
|
|
24
25
|
test('bookmark and managed at the same URL collapse into one candidate', () => {
|
|
25
26
|
const candidates = collectInstanceCandidates('demo', store, [managedDemo])
|
|
26
|
-
expect(candidates).
|
|
27
|
-
|
|
27
|
+
expect(candidates).toEqual([
|
|
28
|
+
{
|
|
29
|
+
source: 'bookmark',
|
|
30
|
+
key: 'demo',
|
|
31
|
+
url: 'https://demo.eu.astrale.ai/api',
|
|
32
|
+
entry: store.instances.demo,
|
|
33
|
+
},
|
|
34
|
+
])
|
|
28
35
|
})
|
|
29
36
|
|
|
30
37
|
test('bookmark and managed at different URLs are both candidates', () => {
|
|
31
|
-
const conflicting:
|
|
38
|
+
const conflicting: OwnedInstanceInfo = {
|
|
39
|
+
...managedDemo,
|
|
40
|
+
url: 'https://demo.us.astrale.ai',
|
|
41
|
+
}
|
|
32
42
|
const candidates = collectInstanceCandidates('demo', store, [conflicting])
|
|
33
|
-
expect(
|
|
34
|
-
|
|
43
|
+
expect(
|
|
44
|
+
candidates.map((candidate) => ({
|
|
45
|
+
source: candidate.source,
|
|
46
|
+
key: candidate.key,
|
|
47
|
+
url: candidate.url,
|
|
48
|
+
id: candidate.source === 'managed' ? candidate.info.id : undefined,
|
|
49
|
+
})),
|
|
50
|
+
).toEqual([
|
|
51
|
+
{
|
|
52
|
+
source: 'bookmark',
|
|
53
|
+
key: 'demo',
|
|
54
|
+
url: 'https://demo.eu.astrale.ai/api',
|
|
55
|
+
id: undefined,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
source: 'managed',
|
|
59
|
+
key: 'demo',
|
|
60
|
+
url: 'https://demo.us.astrale.ai/api',
|
|
61
|
+
id: 'demo',
|
|
62
|
+
},
|
|
63
|
+
])
|
|
35
64
|
})
|
|
36
65
|
|
|
37
66
|
test('multiple managed instances with the same slug are all candidates', () => {
|
|
38
|
-
const a:
|
|
39
|
-
|
|
67
|
+
const a: OwnedInstanceInfo = {
|
|
68
|
+
id: 'x1',
|
|
69
|
+
slug: 'twin',
|
|
70
|
+
url: 'https://twin.eu.astrale.ai',
|
|
71
|
+
state: 'ready',
|
|
72
|
+
}
|
|
73
|
+
const b: OwnedInstanceInfo = {
|
|
74
|
+
id: 'x2',
|
|
75
|
+
slug: 'twin',
|
|
76
|
+
url: 'https://twin.us.astrale.ai',
|
|
77
|
+
state: 'ready',
|
|
78
|
+
}
|
|
40
79
|
const candidates = collectInstanceCandidates('twin', store, [a, b])
|
|
41
|
-
expect(
|
|
42
|
-
|
|
80
|
+
expect(
|
|
81
|
+
candidates.map((candidate) => ({
|
|
82
|
+
source: candidate.source,
|
|
83
|
+
key: candidate.key,
|
|
84
|
+
id: candidate.source === 'managed' ? candidate.info.id : undefined,
|
|
85
|
+
})),
|
|
86
|
+
).toEqual([
|
|
87
|
+
{ source: 'managed', key: 'twin', id: 'x1' },
|
|
88
|
+
{ source: 'managed', key: 'twin', id: 'x2' },
|
|
89
|
+
])
|
|
43
90
|
})
|
|
44
91
|
|
|
45
92
|
test('managed instances with a different slug are ignored', () => {
|
|
46
|
-
const unrelated:
|
|
47
|
-
|
|
93
|
+
const unrelated: OwnedInstanceInfo = {
|
|
94
|
+
id: 'z',
|
|
95
|
+
slug: 'zeta',
|
|
96
|
+
url: 'https://zeta.eu.astrale.ai',
|
|
97
|
+
state: 'ready',
|
|
98
|
+
}
|
|
99
|
+
expect(
|
|
100
|
+
collectInstanceCandidates('demo', store, [unrelated]).map((candidate) => ({
|
|
101
|
+
source: candidate.source,
|
|
102
|
+
key: candidate.key,
|
|
103
|
+
})),
|
|
104
|
+
).toEqual([{ source: 'bookmark', key: 'demo' }])
|
|
48
105
|
})
|
|
49
106
|
|
|
50
107
|
test('bookmark resolution honours slug aliases', () => {
|
|
51
108
|
const candidates = collectInstanceCandidates('oth', store, [])
|
|
52
|
-
expect(
|
|
53
|
-
|
|
109
|
+
expect(
|
|
110
|
+
candidates.map((candidate) => ({
|
|
111
|
+
source: candidate.source,
|
|
112
|
+
key: candidate.key,
|
|
113
|
+
url: candidate.url,
|
|
114
|
+
})),
|
|
115
|
+
).toEqual([
|
|
116
|
+
{
|
|
117
|
+
source: 'bookmark',
|
|
118
|
+
key: 'other',
|
|
119
|
+
url: 'https://other.example.com/api',
|
|
120
|
+
},
|
|
121
|
+
])
|
|
54
122
|
})
|
|
55
123
|
|
|
56
124
|
test('unknown name yields no candidates', () => {
|
|
57
|
-
expect(collectInstanceCandidates('missing', store, [])).
|
|
125
|
+
expect(collectInstanceCandidates('missing', store, [])).toEqual([])
|
|
58
126
|
})
|
|
59
127
|
})
|
|
60
128
|
|
|
@@ -65,9 +133,16 @@ describe('describeInstanceCandidate', () => {
|
|
|
65
133
|
expect(describeInstanceCandidate(bookmark!)).toContain('https://demo.eu.astrale.ai/api')
|
|
66
134
|
|
|
67
135
|
const [managed] = collectInstanceCandidates('twin', store, [
|
|
68
|
-
{
|
|
136
|
+
{
|
|
137
|
+
id: 'x',
|
|
138
|
+
slug: 'twin',
|
|
139
|
+
url: 'https://twin.eu.astrale.ai',
|
|
140
|
+
state: 'ready',
|
|
141
|
+
region: 'eu',
|
|
142
|
+
},
|
|
69
143
|
])
|
|
70
|
-
expect(describeInstanceCandidate(managed!)).
|
|
71
|
-
|
|
144
|
+
expect(describeInstanceCandidate(managed!)).toBe(
|
|
145
|
+
'twin (managed) https://twin.eu.astrale.ai/api — eu',
|
|
146
|
+
)
|
|
72
147
|
})
|
|
73
148
|
})
|
|
@@ -98,7 +98,7 @@ describe('resolveInstanceTarget', () => {
|
|
|
98
98
|
})
|
|
99
99
|
})
|
|
100
100
|
|
|
101
|
-
test('managed slug resolves through injected
|
|
101
|
+
test('managed slug resolves through the injected managed lookup', async () => {
|
|
102
102
|
await expect(
|
|
103
103
|
resolveInstanceTarget(
|
|
104
104
|
{ source: 'name', name: 'bryan' },
|
|
@@ -181,6 +181,14 @@ describe('instance target helpers', () => {
|
|
|
181
181
|
|
|
182
182
|
expect(isManagedInstanceNotFound(auth)).toBe(false)
|
|
183
183
|
})
|
|
184
|
+
|
|
185
|
+
test('recognizes an owner-scoped lookup miss', () => {
|
|
186
|
+
expect(
|
|
187
|
+
isManagedInstanceNotFound(
|
|
188
|
+
new AstraleError('INSTANCE_NOT_FOUND', 'No owned instance matches "missing".'),
|
|
189
|
+
),
|
|
190
|
+
).toBe(true)
|
|
191
|
+
})
|
|
184
192
|
})
|
|
185
193
|
|
|
186
194
|
describe('isManagedInstanceNotFound: kernel InternalKernelError wrap', () => {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
3
|
+
import net from 'node:net'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
import { findFreePort } from '../port'
|
|
8
|
+
import { withViewPortAllocationLock } from '../view/port-allocation'
|
|
9
|
+
|
|
10
|
+
let tmp: string
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
tmp = await mkdtemp(join(tmpdir(), 'astrale-view-port-'))
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await rm(tmp, { recursive: true, force: true })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
function listen(port: number): Promise<net.Server> {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const server = net.createServer()
|
|
23
|
+
server.once('error', reject)
|
|
24
|
+
server.listen({ port, host: '127.0.0.1', exclusive: true }, () => resolve(server))
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function close(server: net.Server): Promise<void> {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
server.close((error) => (error ? reject(error) : resolve()))
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('view port allocation', () => {
|
|
35
|
+
test('never overlaps concurrent allocation critical sections', async () => {
|
|
36
|
+
const lockPath = join(tmp, 'ports.lock')
|
|
37
|
+
let active = 0
|
|
38
|
+
let maxActive = 0
|
|
39
|
+
|
|
40
|
+
const contender = () =>
|
|
41
|
+
withViewPortAllocationLock(async () => {
|
|
42
|
+
active++
|
|
43
|
+
maxActive = Math.max(maxActive, active)
|
|
44
|
+
await Bun.sleep(40)
|
|
45
|
+
active--
|
|
46
|
+
}, lockPath)
|
|
47
|
+
|
|
48
|
+
await Promise.all([contender(), contender()])
|
|
49
|
+
|
|
50
|
+
// Without the view-specific lock both contenders enter before either
|
|
51
|
+
// delay completes, making this 2 and recreating the probe/bind window.
|
|
52
|
+
expect(maxActive).toBe(1)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('serializes concurrent probe-then-listen allocations', async () => {
|
|
56
|
+
const base = await findFreePort(48000, 200)
|
|
57
|
+
expect(base).not.toBeNull()
|
|
58
|
+
if (base === null) throw new Error('test port window exhausted')
|
|
59
|
+
const lockPath = join(tmp, 'ports.lock')
|
|
60
|
+
|
|
61
|
+
const allocate = () =>
|
|
62
|
+
withViewPortAllocationLock(async () => {
|
|
63
|
+
const port = await findFreePort(base, 4)
|
|
64
|
+
if (port === null) throw new Error('test port window exhausted')
|
|
65
|
+
|
|
66
|
+
// Widen the exact pre-fix race: both callers would finish the advisory
|
|
67
|
+
// probe before either one binds, then one would fail with EADDRINUSE.
|
|
68
|
+
await Bun.sleep(40)
|
|
69
|
+
const server = await listen(port)
|
|
70
|
+
return { port, server }
|
|
71
|
+
}, lockPath)
|
|
72
|
+
|
|
73
|
+
const allocations = await Promise.all([allocate(), allocate()])
|
|
74
|
+
try {
|
|
75
|
+
const ports = allocations.map(({ port }) => port).sort((a, b) => a - b)
|
|
76
|
+
expect(ports[0]).toBe(base)
|
|
77
|
+
expect(ports[1]).toBeGreaterThan(base)
|
|
78
|
+
} finally {
|
|
79
|
+
await Promise.all(allocations.map(({ server }) => close(server)))
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
})
|
|
@@ -5,10 +5,14 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export const ADMIN_INSTANCE = '/:admin.astrale.ai:class.Instance'
|
|
7
7
|
|
|
8
|
-
export function adminInstanceMethod(
|
|
8
|
+
export function adminInstanceMethod(
|
|
9
|
+
method: 'alphaCreate' | 'delete' | 'info' | 'list' | 'listMine',
|
|
10
|
+
): string {
|
|
9
11
|
return `${ADMIN_INSTANCE}:${method}`
|
|
10
12
|
}
|
|
11
13
|
|
|
14
|
+
export type InstanceState = 'provisioning' | 'ready' | 'failed'
|
|
15
|
+
|
|
12
16
|
/** Read shape returned by `Instance.list` / `info` / `delete` (domain `InstanceInfoSchema`). */
|
|
13
17
|
export type InstanceInfo = {
|
|
14
18
|
id: string
|
|
@@ -16,10 +20,31 @@ export type InstanceInfo = {
|
|
|
16
20
|
url: string
|
|
17
21
|
hostId?: string
|
|
18
22
|
region?: string
|
|
19
|
-
state?:
|
|
23
|
+
state?: InstanceState
|
|
20
24
|
phase?: string
|
|
21
25
|
error?: string | null
|
|
22
26
|
createdAt?: string
|
|
27
|
+
organizationId?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Owner-scoped read shape returned by `Instance.listMine`. */
|
|
31
|
+
export type OwnedInstanceInfo = InstanceInfo & {
|
|
32
|
+
state: InstanceState
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Call the owner-scoped list contract through any admin client-shaped object. */
|
|
36
|
+
export async function callOwnedInstances(client: {
|
|
37
|
+
call: (path: string, params: Record<string, never>) => Promise<unknown>
|
|
38
|
+
}): Promise<OwnedInstanceInfo[]> {
|
|
39
|
+
return (await client.call(adminInstanceMethod('listMine'), {})) as OwnedInstanceInfo[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Match an owner-scoped instance by its stable node id or human slug. */
|
|
43
|
+
export function findOwnedInstance(
|
|
44
|
+
instances: readonly OwnedInstanceInfo[],
|
|
45
|
+
identifier: string,
|
|
46
|
+
): OwnedInstanceInfo | undefined {
|
|
47
|
+
return instances.find((instance) => instance.id === identifier || instance.slug === identifier)
|
|
23
48
|
}
|
|
24
49
|
|
|
25
50
|
/** Human-readable location of a managed instance ("region · hostId"). */
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { InstanceEntry, InstanceStore } from './instance'
|
|
2
2
|
|
|
3
|
-
import { formatInstanceLocation, type
|
|
3
|
+
import { formatInstanceLocation, type OwnedInstanceInfo } from './admin-instance'
|
|
4
4
|
import { normalizeInstanceKernelUrl, resolveInstanceKey } from './instance'
|
|
5
5
|
|
|
6
6
|
export type InstanceCandidate =
|
|
7
7
|
| { source: 'bookmark'; key: string; url: string; entry: InstanceEntry }
|
|
8
|
-
| { source: 'managed'; key: string; url: string; info:
|
|
8
|
+
| { source: 'managed'; key: string; url: string; info: OwnedInstanceInfo }
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Gather every instance a bare name can refer to — the local bookmark
|
|
@@ -16,7 +16,7 @@ export type InstanceCandidate =
|
|
|
16
16
|
export function collectInstanceCandidates(
|
|
17
17
|
name: string,
|
|
18
18
|
store: InstanceStore,
|
|
19
|
-
managed:
|
|
19
|
+
managed: OwnedInstanceInfo[],
|
|
20
20
|
): InstanceCandidate[] {
|
|
21
21
|
const out: InstanceCandidate[] = []
|
|
22
22
|
|
|
@@ -170,6 +170,7 @@ export function adminTargetToInstance(target: ResolvedAdminTarget): ResolvedInst
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
export function isManagedInstanceNotFound(error: unknown): boolean {
|
|
173
|
+
if (error instanceof AstraleError && error.code === 'INSTANCE_NOT_FOUND') return true
|
|
173
174
|
if (!(error instanceof Error)) return false
|
|
174
175
|
if (error.name === 'NotFoundError') return true
|
|
175
176
|
// The admin kernel reports a missing instance node as InternalKernelError
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { withFileLock } from '../fs-atomic'
|
|
4
|
+
import { VIEW_DIR } from './session'
|
|
5
|
+
|
|
6
|
+
const VIEW_PORT_LOCK = join(VIEW_DIR, 'ports.lock')
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Keep the free-port probe and detached server startup in one cross-process
|
|
10
|
+
* critical section. A probe alone is only advisory: without this lock, two
|
|
11
|
+
* concurrent `astrale view` processes can both observe 4419 as free before
|
|
12
|
+
* either child binds it.
|
|
13
|
+
*/
|
|
14
|
+
export function withViewPortAllocationLock<T>(
|
|
15
|
+
fn: () => Promise<T>,
|
|
16
|
+
lockPath = VIEW_PORT_LOCK,
|
|
17
|
+
): Promise<T> {
|
|
18
|
+
return withFileLock(lockPath, fn)
|
|
19
|
+
}
|