@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.
@@ -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 { adminInstanceMethod, type InstanceInfo } from '../lib/admin-instance'
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: adminLookupOpts(opts),
51
- managed: (slug) => lookupManagedInstance(slug, opts),
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 lookupManagedInstance(slug: string, opts: KernelCommandOpts): Promise<InstanceInfo> {
72
- return await withAdminKernelClient(
73
- adminLookupOpts(opts),
74
- async (ctx) =>
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 adminLookupOpts(opts: KernelCommandOpts): KernelCommandOpts & AdminTargetCommandOpts {
80
- return {
81
- as: opts.as,
82
- creds: opts.creds,
83
- timeout: opts.timeout,
84
- debug: opts.debug,
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(
@@ -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 { withAdminKernelClient, withKernelClient } from './client'
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: adminLookupOpts(opts),
45
- managed: (instanceSlug) => lookupManagedInstance(instanceSlug, opts),
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 { InstanceInfo } from '../admin-instance'
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: InstanceInfo = {
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).toHaveLength(1)
27
- expect(candidates[0]).toMatchObject({ source: 'bookmark', key: 'demo' })
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: InstanceInfo = { ...managedDemo, url: 'https://demo.us.astrale.ai' }
38
+ const conflicting: OwnedInstanceInfo = {
39
+ ...managedDemo,
40
+ url: 'https://demo.us.astrale.ai',
41
+ }
32
42
  const candidates = collectInstanceCandidates('demo', store, [conflicting])
33
- expect(candidates).toHaveLength(2)
34
- expect(candidates.map((c) => c.source)).toEqual(['bookmark', 'managed'])
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: InstanceInfo = { id: 'x1', slug: 'twin', url: 'https://twin.eu.astrale.ai' }
39
- const b: InstanceInfo = { id: 'x2', slug: 'twin', url: 'https://twin.us.astrale.ai' }
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(candidates).toHaveLength(2)
42
- expect(candidates.every((c) => c.source === 'managed')).toBe(true)
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: InstanceInfo = { id: 'z', slug: 'zeta', url: 'https://zeta.eu.astrale.ai' }
47
- expect(collectInstanceCandidates('demo', store, [unrelated])).toHaveLength(1)
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(candidates).toHaveLength(1)
53
- expect(candidates[0]).toMatchObject({ source: 'bookmark', key: 'other' })
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, [])).toHaveLength(0)
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
- { id: 'x', slug: 'twin', url: 'https://twin.eu.astrale.ai', region: 'eu' },
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!)).toContain('(managed)')
71
- expect(describeInstanceCandidate(managed!)).toContain('eu')
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 Instance.info lookup', async () => {
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(method: 'alphaCreate' | 'delete' | 'info' | 'list'): string {
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?: 'provisioning' | 'ready' | 'failed'
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 InstanceInfo } from './admin-instance'
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: InstanceInfo }
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: InstanceInfo[],
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
+ }