@astrale-os/cli 0.6.3-alpha.0 → 0.8.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.
Files changed (126) hide show
  1. package/README.md +3 -2
  2. package/dist/astrale.js +339 -242
  3. package/package.json +2 -3
  4. package/src/commands/__tests__/admin-instance.test.ts +49 -2
  5. package/src/commands/__tests__/domain-install-owned.test.ts +122 -0
  6. package/src/commands/__tests__/help-contract.test.ts +15 -0
  7. package/src/commands/domain/install.ts +15 -4
  8. package/src/commands/instance/active.ts +4 -7
  9. package/src/commands/instance/list.ts +4 -8
  10. package/src/commands/instance/status.ts +14 -9
  11. package/src/commands/instance/use.ts +13 -11
  12. package/src/commands/studio.ts +13 -0
  13. package/src/commands/view.ts +40 -13
  14. package/src/kernel/__tests__/client-owned-lookup.test.ts +51 -0
  15. package/src/kernel/client.ts +47 -20
  16. package/src/kernel/expand.ts +3 -22
  17. package/src/lib/__tests__/instance-candidates.test.ts +94 -19
  18. package/src/lib/__tests__/instance-target.test.ts +9 -1
  19. package/src/lib/__tests__/view-assets.test.ts +114 -0
  20. package/src/lib/__tests__/view-port-allocation.test.ts +82 -0
  21. package/src/lib/admin-instance.ts +27 -2
  22. package/src/lib/instance-candidates.ts +3 -3
  23. package/src/lib/instance-target.ts +1 -0
  24. package/src/lib/instance.ts +0 -11
  25. package/src/lib/login-flow.ts +4 -41
  26. package/src/lib/view/assets.ts +64 -0
  27. package/src/lib/view/port-allocation.ts +19 -0
  28. package/src/lib/view/server.ts +4 -37
  29. package/src/setup/__tests__/instance-step.test.ts +239 -0
  30. package/src/setup/steps/instance.ts +141 -61
  31. package/studio/client/dist/assets/index-BaqEuIQJ.js +109 -0
  32. package/studio/client/dist/assets/index-jRdExahh.css +1 -0
  33. package/studio/client/dist/index.html +2 -2
  34. package/studio/server/agent/ask.test.ts +74 -0
  35. package/studio/server/agent/ask.ts +20 -22
  36. package/studio/server/agent/bridge/client.test.ts +49 -0
  37. package/studio/server/agent/bridge/client.ts +37 -0
  38. package/studio/server/agent/bridge/grant.test.ts +62 -0
  39. package/studio/server/agent/bridge/grant.ts +84 -0
  40. package/studio/server/agent/bridge/routes.test.ts +151 -0
  41. package/studio/server/agent/bridge/routes.ts +148 -0
  42. package/studio/server/agent/{bridge-mcp.ts → bridge/stdio.ts} +27 -21
  43. package/studio/server/agent/conversation.test.ts +84 -0
  44. package/studio/server/agent/conversation.ts +110 -0
  45. package/studio/server/agent/{types.ts → harness/adapter.ts} +63 -12
  46. package/studio/server/agent/harness/claude/adapter.test.ts +338 -0
  47. package/studio/server/agent/harness/claude/adapter.ts +71 -0
  48. package/studio/server/agent/harness/claude/ask.ts +114 -0
  49. package/studio/server/agent/harness/claude/capabilities.ts +27 -0
  50. package/studio/server/agent/harness/claude/command.ts +63 -0
  51. package/studio/server/agent/harness/claude/events.ts +215 -0
  52. package/studio/server/agent/harness/claude/loadout.ts +132 -0
  53. package/studio/server/agent/harness/claude/mcp.test.ts +36 -0
  54. package/studio/server/agent/harness/claude/mcp.ts +41 -0
  55. package/studio/server/agent/harness/claude/skills.ts +43 -0
  56. package/studio/server/agent/harness/codex/adapter.test.ts +269 -0
  57. package/studio/server/agent/harness/codex/adapter.ts +116 -0
  58. package/studio/server/agent/harness/codex/ask.test.ts +176 -0
  59. package/studio/server/agent/harness/codex/ask.ts +181 -0
  60. package/studio/server/agent/harness/codex/command.test.ts +69 -0
  61. package/studio/server/agent/harness/codex/command.ts +43 -0
  62. package/studio/server/agent/harness/codex/events.test.ts +94 -0
  63. package/studio/server/agent/harness/codex/events.ts +132 -0
  64. package/studio/server/agent/harness/codex/exec.ts +113 -0
  65. package/studio/server/agent/harness/codex/loadout.ts +63 -0
  66. package/studio/server/agent/harness/codex/mcp.test.ts +32 -0
  67. package/studio/server/agent/harness/codex/mcp.ts +27 -0
  68. package/studio/server/agent/harness/codex/models.test.ts +167 -0
  69. package/studio/server/agent/harness/codex/models.ts +211 -0
  70. package/studio/server/agent/harness/codex/skills.ts +33 -0
  71. package/studio/server/agent/harness/gateway/config.test.ts +98 -0
  72. package/studio/server/{state/harness-gateway.ts → agent/harness/gateway/config.ts} +30 -8
  73. package/studio/server/agent/harness/gateway/token.test.ts +133 -0
  74. package/studio/server/agent/harness/gateway/token.ts +166 -0
  75. package/studio/server/agent/harness/mock/adapter.ts +190 -0
  76. package/studio/server/agent/harness/mock/domain-edit.ts +41 -0
  77. package/studio/server/agent/harness/process.test.ts +46 -0
  78. package/studio/server/agent/harness/process.ts +104 -0
  79. package/studio/server/agent/harness/registry.ts +37 -0
  80. package/studio/server/agent/harness/selection.test.ts +75 -0
  81. package/studio/server/agent/harness/selection.ts +77 -0
  82. package/studio/server/agent/harness/skills.test.ts +95 -0
  83. package/studio/server/agent/harness/skills.ts +100 -0
  84. package/studio/server/agent/layout.test.ts +95 -0
  85. package/studio/server/agent/notify.ts +12 -0
  86. package/studio/server/agent/{schema-map.ts → prompts/anchors.ts} +2 -2
  87. package/studio/server/agent/prompts/ask.ts +34 -0
  88. package/studio/server/agent/{prompt.ts → prompts/system.ts} +3 -107
  89. package/studio/server/agent/prompts/turn.ts +57 -0
  90. package/studio/server/agent/routes.test.ts +176 -0
  91. package/studio/server/agent/routes.ts +214 -0
  92. package/studio/server/agent/run/completion.ts +193 -0
  93. package/studio/server/agent/run/coordinator.test.ts +337 -0
  94. package/studio/server/agent/run/coordinator.ts +104 -0
  95. package/studio/server/agent/run/live-state.ts +73 -0
  96. package/studio/server/agent/run/preparation.ts +162 -0
  97. package/studio/server/agent/run/transcript.test.ts +47 -0
  98. package/studio/server/agent/run/transcript.ts +30 -0
  99. package/studio/server/agent/run/usage.test.ts +46 -0
  100. package/studio/server/{state → agent/run}/usage.ts +4 -4
  101. package/studio/server/agent/stream.ts +37 -0
  102. package/studio/server/agent/{session-id.ts → telemetry.ts} +1 -1
  103. package/studio/server/api-agent-loadout.test.ts +69 -0
  104. package/studio/server/api.ts +3 -155
  105. package/studio/server/index.ts +6 -5
  106. package/studio/server/state/comments.test.ts +29 -0
  107. package/studio/server/state/comments.ts +3 -3
  108. package/studio/server/state/settings.test.ts +78 -0
  109. package/studio/server/state/settings.ts +28 -1
  110. package/studio/shared/agent-effort.test.ts +12 -0
  111. package/studio/shared/agent-effort.ts +15 -0
  112. package/studio/shared/agent-models.test.ts +9 -0
  113. package/studio/shared/agent-models.ts +14 -0
  114. package/studio/shared/settings-values.test.ts +18 -0
  115. package/studio/shared/settings-values.ts +20 -0
  116. package/studio/shared/types.ts +58 -7
  117. package/src/connect-core.test.ts +0 -42
  118. package/src/connect-core.ts +0 -53
  119. package/studio/client/dist/assets/index-CyN5G8IA.js +0 -109
  120. package/studio/client/dist/assets/index-DKKMHBBC.css +0 -1
  121. package/studio/server/agent/bridge.ts +0 -188
  122. package/studio/server/agent/claude.ts +0 -678
  123. package/studio/server/agent/mock.ts +0 -186
  124. package/studio/server/agent/registry.ts +0 -29
  125. package/studio/server/agent/runner.ts +0 -487
  126. package/studio/server/state/harness-token.ts +0 -0
@@ -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,114 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test'
2
+ import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { dirname, join, relative } from 'node:path'
5
+ import { pathToFileURL } from 'node:url'
6
+
7
+ import { ensureViewerAssets, viewerDistDir } from '../view/assets'
8
+
9
+ const temporaryDirectories: string[] = []
10
+
11
+ afterEach(async () => {
12
+ await Promise.all(
13
+ temporaryDirectories
14
+ .splice(0)
15
+ .map((directory) => rm(directory, { recursive: true, force: true })),
16
+ )
17
+ })
18
+
19
+ describe('viewer asset resolution', () => {
20
+ test('skips partial candidates and prefers complete package-owned assets', async () => {
21
+ const root = await mkdtemp(join(tmpdir(), 'astrale-view-candidates-'))
22
+ temporaryDirectories.push(root)
23
+ const prefix = join(root, '.npm-global')
24
+ const packageRoot = join(prefix, 'lib', 'node_modules', '@astrale-os', 'cli')
25
+ const entry = join(prefix, 'bin', 'astrale')
26
+ const bundle = join(packageRoot, 'dist', 'astrale.js')
27
+ const published = join(packageRoot, 'viewer', 'dist')
28
+ const legacy = join(prefix, 'viewer', 'dist')
29
+
30
+ await mkdir(dirname(bundle), { recursive: true })
31
+ await mkdir(published, { recursive: true })
32
+ await mkdir(legacy, { recursive: true })
33
+ await writeFile(bundle, '')
34
+ await writeFile(join(published, 'main.js'), '')
35
+ await writeFile(join(legacy, 'main.js'), '')
36
+ await writeFile(join(legacy, 'index.html'), '')
37
+
38
+ expect(viewerDistDir(pathToFileURL(bundle).href, entry)).toBe(legacy)
39
+
40
+ await writeFile(join(published, 'index.html'), '')
41
+ expect(viewerDistDir(pathToFileURL(bundle).href, entry)).toBe(published)
42
+ })
43
+
44
+ test('builds missing viewer assets in a clean source checkout', async () => {
45
+ const root = await mkdtemp(join(tmpdir(), 'astrale-view-source-'))
46
+ temporaryDirectories.push(root)
47
+ const module = join(root, 'src', 'lib', 'view', 'assets.ts')
48
+ const source = join(root, 'viewer')
49
+ const dist = join(source, 'dist')
50
+
51
+ await mkdir(dirname(module), { recursive: true })
52
+ await mkdir(source, { recursive: true })
53
+ await writeFile(module, '')
54
+ await writeFile(join(source, 'main.ts'), 'document.body.textContent = "viewer ready"\n')
55
+ await writeFile(join(source, 'index.html'), '<!doctype html><body></body>\n')
56
+
57
+ expect(await ensureViewerAssets(pathToFileURL(module).href, join(root, 'bin', 'astrale'))).toBe(
58
+ dist,
59
+ )
60
+ expect(await readFile(join(dist, 'index.html'), 'utf8')).toContain('<body>')
61
+ expect(await readFile(join(dist, 'main.js'), 'utf8')).toContain('viewer ready')
62
+ })
63
+
64
+ test('uses the bundled module location when invoked through a global bin symlink', async () => {
65
+ const root = await mkdtemp(join(tmpdir(), 'astrale-view-bundle-'))
66
+ temporaryDirectories.push(root)
67
+ const prefix = join(root, '.npm-global')
68
+ const packageRoot = join(prefix, 'lib', 'node_modules', '@astrale-os', 'cli')
69
+ const entry = join(prefix, 'bin', 'astrale')
70
+ const sourceEntry = join(root, 'entry.ts')
71
+ const viewer = join(packageRoot, 'viewer', 'dist')
72
+ const assetsPath = join(import.meta.dirname, '..', 'view', 'assets.ts')
73
+ const relativeAssetsPath = relative(
74
+ await realpath(dirname(sourceEntry)),
75
+ await realpath(assetsPath),
76
+ )
77
+ const assetsModule = relativeAssetsPath.startsWith('.')
78
+ ? relativeAssetsPath
79
+ : `./${relativeAssetsPath}`
80
+
81
+ await mkdir(join(prefix, 'bin'), { recursive: true })
82
+ await mkdir(viewer, { recursive: true })
83
+ await writeFile(join(viewer, 'main.js'), '')
84
+ await writeFile(join(viewer, 'index.html'), '')
85
+ await writeFile(
86
+ sourceEntry,
87
+ `import { viewerDistDir } from ${JSON.stringify(assetsModule)}\nconsole.log(viewerDistDir())\n`,
88
+ )
89
+ const build = await Bun.build({
90
+ entrypoints: [sourceEntry],
91
+ outdir: join(packageRoot, 'dist'),
92
+ target: 'node',
93
+ format: 'esm',
94
+ })
95
+ expect(build.success).toBe(true)
96
+ await symlink(
97
+ join('..', 'lib', 'node_modules', '@astrale-os', 'cli', 'dist', 'entry.js'),
98
+ entry,
99
+ )
100
+
101
+ const node = Bun.which('node')
102
+ expect(node).not.toBeNull()
103
+ const process = Bun.spawn([node!, entry], { stdout: 'pipe', stderr: 'pipe' })
104
+ const [stdout, stderr, exitCode] = await Promise.all([
105
+ new Response(process.stdout).text(),
106
+ new Response(process.stderr).text(),
107
+ process.exited,
108
+ ])
109
+
110
+ expect(exitCode).toBe(0)
111
+ expect(stderr).toBe('')
112
+ expect(stdout.trim()).toBe(await realpath(viewer))
113
+ })
114
+ })
@@ -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
@@ -113,17 +113,6 @@ export function sanitizeStore(store: InstanceStore): { store: InstanceStore; cha
113
113
 
114
114
  let instancesMemo: InstanceStore | null = null
115
115
 
116
- /**
117
- * Drop the in-process bookmark cache so the next `readInstances` re-reads disk.
118
- *
119
- * A long-lived host (e.g. `@astrale-os/connect-host`) would otherwise serve a
120
- * stale bookmark list after the user runs `astrale instance bookmark`. The CLI
121
- * itself is one-shot, so this is a no-op for command usage.
122
- */
123
- export function resetInstancesMemo(): void {
124
- instancesMemo = null
125
- }
126
-
127
116
  export async function readInstances(
128
117
  _config?: AstraleConfig,
129
118
  opts: { persist?: boolean } = {},