@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
@@ -29,27 +29,6 @@ import { log } from './log'
29
29
  * behavior; presentation (the device URL is logged here; success lines are the
30
30
  * caller's) does not.
31
31
  */
32
- /**
33
- * Interactive device-flow progress. Emitted once, when the device authorization
34
- * has been requested and the user must approve it in a browser. The caller
35
- * decides how to present it (the CLI logs it; the desktop opens the URL and
36
- * pushes it to the renderer). Structurally identical to connect-host's wire
37
- * `AuthVerificationEvent`, but declared here so the CLI carries no dependency on
38
- * connect-host (which depends on the CLI, not the reverse).
39
- */
40
- export type DeviceVerification = {
41
- /** The base verification URL (fallback to the complete one when absent). */
42
- verificationUri: string
43
- /** Pre-filled verification URL (opens straight to the approval page). */
44
- verificationUriComplete?: string
45
- /** The short user code to confirm — a reassurance fallback when the URL is pre-filled. */
46
- userCode?: string
47
- /** Seconds until the device code expires. */
48
- expiresIn?: number
49
- /** Human-readable instruction from the IdP. */
50
- message?: string
51
- }
52
-
53
32
  export type LoginFlowOpts = {
54
33
  idp?: string
55
34
  name?: string
@@ -63,12 +42,6 @@ export type LoginFlowOpts = {
63
42
  codeVerifier?: string
64
43
  /** Switch the default identity to the one we just logged in (default true). */
65
44
  use?: boolean
66
- /**
67
- * When set, receives the device-flow verification details and REPLACES the
68
- * default `log.info` presentation (a headless/GUI caller drives the UI). The
69
- * `auth login` command passes nothing, so its terminal output is unchanged.
70
- */
71
- onVerification?: (e: DeviceVerification) => void
72
45
  }
73
46
 
74
47
  export type LoginResult = {
@@ -175,20 +148,10 @@ async function obtainToken(
175
148
  scope,
176
149
  audience: opts.audience,
177
150
  })
178
- if (opts.onVerification) {
179
- opts.onVerification({
180
- verificationUri: device.verification_uri ?? device.verification_uri_complete ?? '',
181
- verificationUriComplete: device.verification_uri_complete,
182
- userCode: device.user_code,
183
- expiresIn: device.expires_in,
184
- message: device.message,
185
- })
186
- } else {
187
- if (device.verification_uri_complete) log.info(`Open: ${device.verification_uri_complete}`)
188
- else if (device.verification_uri) log.info(`Open: ${device.verification_uri}`)
189
- if (device.user_code) log.info(`Code: ${device.user_code}`)
190
- if (device.message) log.dim(` ${device.message}`)
191
- }
151
+ if (device.verification_uri_complete) log.info(`Open: ${device.verification_uri_complete}`)
152
+ else if (device.verification_uri) log.info(`Open: ${device.verification_uri}`)
153
+ if (device.user_code) log.info(`Code: ${device.user_code}`)
154
+ if (device.message) log.dim(` ${device.message}`)
192
155
 
193
156
  return pollDeviceToken({
194
157
  idp,
@@ -0,0 +1,64 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { copyFile } from 'node:fs/promises'
3
+ import { dirname, join } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ /**
7
+ * The prebuilt host-page bundle, shipped next to the CLI module
8
+ * (`<pkg>/viewer/dist`). The module URL is authoritative because npm global
9
+ * installs expose the CLI through a bin symlink outside the package root.
10
+ */
11
+ export function viewerDistDir(moduleUrl = import.meta.url, entry = process.argv[1] ?? '.'): string {
12
+ const override = process.env.ASTRALE_VIEWER_DIR
13
+ if (override) return override
14
+
15
+ const moduleDirectory = dirname(fileURLToPath(moduleUrl))
16
+ const published = join(moduleDirectory, '..', 'viewer', 'dist')
17
+ const source = join(moduleDirectory, '..', '..', '..', 'viewer', 'dist')
18
+ const legacy = join(dirname(entry), '..', 'viewer', 'dist')
19
+ const complete = [published, source, legacy].find(hasViewerBundle)
20
+ if (complete) return complete
21
+
22
+ // A source checkout may intentionally omit generated dist assets. Keep the
23
+ // source-derived destination so ensureViewerAssets can build it on demand.
24
+ if (hasViewerSource(join(source, '..'))) return source
25
+
26
+ // Published installs cannot rebuild: preserve the package-relative path in
27
+ // the diagnostic instead of pointing at the npm prefix beside the bin link.
28
+ return published
29
+ }
30
+
31
+ /** Ensure the host bundle exists; on a dev checkout, build it with Bun. */
32
+ export async function ensureViewerAssets(
33
+ moduleUrl = import.meta.url,
34
+ entry = process.argv[1] ?? '.',
35
+ ): Promise<string> {
36
+ const dist = viewerDistDir(moduleUrl, entry)
37
+ if (hasViewerBundle(dist)) return dist
38
+ const srcDir = join(dist, '..')
39
+ const bun = (
40
+ globalThis as { Bun?: { build: (o: object) => Promise<{ success: boolean; logs: unknown[] }> } }
41
+ ).Bun
42
+ if (bun && hasViewerSource(srcDir)) {
43
+ const result = await bun.build({
44
+ entrypoints: [join(srcDir, 'main.ts')],
45
+ outdir: dist,
46
+ target: 'browser',
47
+ minify: false,
48
+ })
49
+ if (!result.success) throw new Error(`viewer build failed: ${result.logs.join('\n')}`)
50
+ await copyFile(join(srcDir, 'index.html'), join(dist, 'index.html'))
51
+ return dist
52
+ }
53
+ throw new Error(
54
+ `viewer bundle missing at ${dist} — reinstall the CLI (or run \`bun scripts/build.ts\` in a dev checkout)`,
55
+ )
56
+ }
57
+
58
+ function hasViewerBundle(directory: string): boolean {
59
+ return existsSync(join(directory, 'main.js')) && existsSync(join(directory, 'index.html'))
60
+ }
61
+
62
+ function hasViewerSource(directory: string): boolean {
63
+ return existsSync(join(directory, 'main.ts')) && existsSync(join(directory, 'index.html'))
64
+ }
@@ -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
+ }
@@ -1,17 +1,19 @@
1
1
  import type { ReadableStream as WebReadableStream } from 'node:stream/web'
2
2
 
3
- import { existsSync } from 'node:fs'
4
3
  import { readFile } from 'node:fs/promises'
5
4
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
6
- import { dirname, join } from 'node:path'
5
+ import { join } from 'node:path'
7
6
  import { Readable } from 'node:stream'
8
7
 
9
8
  import type { ViewServeConfig } from './session'
10
9
 
11
10
  import { withKernelClient } from '../../kernel'
12
11
  import { fetchWithCaFile } from '../../kernel/ca-fetch'
12
+ import { viewerDistDir } from './assets'
13
13
  import { removeSessionFiles } from './session'
14
14
 
15
+ export { ensureViewerAssets, viewerDistDir } from './assets'
16
+
15
17
  /**
16
18
  * The view-session server: serves the host page, hands it config and fresh
17
19
  * credentials, receives its lifecycle reports, and proxies the view's kernel
@@ -197,41 +199,6 @@ export function startViewServer(config: ViewServeConfig): Server {
197
199
  return server
198
200
  }
199
201
 
200
- /**
201
- * The prebuilt host-page bundle, shipped next to the CLI entry
202
- * (`<pkg>/viewer/dist`). Dev runs (bun, no dist) build it on demand.
203
- */
204
- export function viewerDistDir(): string {
205
- const override = process.env.ASTRALE_VIEWER_DIR
206
- if (override) return override
207
- return join(dirname(process.argv[1] ?? '.'), '..', 'viewer', 'dist')
208
- }
209
-
210
- /** Ensure the host bundle exists; on a dev checkout, build it with Bun. */
211
- export async function ensureViewerAssets(): Promise<string> {
212
- const dist = viewerDistDir()
213
- if (existsSync(join(dist, 'main.js')) && existsSync(join(dist, 'index.html'))) return dist
214
- const srcDir = join(dist, '..')
215
- const bun = (
216
- globalThis as { Bun?: { build: (o: object) => Promise<{ success: boolean; logs: unknown[] }> } }
217
- ).Bun
218
- if (bun && existsSync(join(srcDir, 'main.ts'))) {
219
- const result = await bun.build({
220
- entrypoints: [join(srcDir, 'main.ts')],
221
- outdir: dist,
222
- target: 'browser',
223
- minify: false,
224
- })
225
- if (!result.success) throw new Error(`viewer build failed: ${result.logs.join('\n')}`)
226
- const { copyFile } = await import('node:fs/promises')
227
- await copyFile(join(srcDir, 'index.html'), join(dist, 'index.html'))
228
- return dist
229
- }
230
- throw new Error(
231
- `viewer bundle missing at ${dist} — reinstall the CLI (or run \`bun scripts/build.ts\` in a dev checkout)`,
232
- )
233
- }
234
-
235
202
  function jwtExpiry(token: string): number | null {
236
203
  try {
237
204
  const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')) as {
@@ -0,0 +1,239 @@
1
+ import { describe, expect, mock, test } from 'bun:test'
2
+
3
+ import type { OwnedInstanceInfo } from '../../lib/admin-instance'
4
+ import type { SetupContext } from '../types'
5
+
6
+ import { AstraleError } from '../../errors'
7
+ import {
8
+ adoptOwnedInstance,
9
+ ensureOwnedInstance,
10
+ type InstanceSetupDependencies,
11
+ } from '../steps/instance'
12
+
13
+ const ctx: SetupContext = {
14
+ interactive: true,
15
+ machine: true,
16
+ opts: {},
17
+ slug: 'new-instance',
18
+ }
19
+
20
+ function instance(slug: string, state: OwnedInstanceInfo['state'] = 'ready'): OwnedInstanceInfo {
21
+ return {
22
+ id: `${slug}-id`,
23
+ slug,
24
+ url: `https://${slug}.eu.astrale.ai`,
25
+ state,
26
+ organizationId: `org_${slug}`,
27
+ }
28
+ }
29
+
30
+ function dependencies(
31
+ overrides: Partial<InstanceSetupDependencies> = {},
32
+ ): InstanceSetupDependencies {
33
+ return {
34
+ fetchOwned: mock(async () => []),
35
+ adopt: mock(async () => {}),
36
+ selectReady: mock(async (instances) => instances[0] ?? null),
37
+ confirmCreate: mock(async () => true),
38
+ promptSlug: mock(async () => 'new-instance'),
39
+ provision: mock(async (slug) => ({
40
+ created: { url: `https://${slug}.eu.astrale.ai`, organizationId: `org_${slug}` },
41
+ slug,
42
+ })),
43
+ ...overrides,
44
+ }
45
+ }
46
+
47
+ describe('setup owned-instance reconciliation', () => {
48
+ test('silently adopts the sole owned ready instance', async () => {
49
+ const ready = instance('only')
50
+ const failed = instance('old-attempt', 'failed')
51
+ const deps = dependencies({ fetchOwned: mock(async () => [failed, ready]) })
52
+
53
+ await expect(ensureOwnedInstance(ctx, deps)).resolves.toBe('fixed')
54
+
55
+ expect(deps.adopt).toHaveBeenCalledTimes(1)
56
+ expect(deps.adopt).toHaveBeenCalledWith(ready)
57
+ expect(deps.selectReady).not.toHaveBeenCalled()
58
+ expect(deps.confirmCreate).not.toHaveBeenCalled()
59
+ expect(deps.provision).not.toHaveBeenCalled()
60
+ })
61
+
62
+ test('asks the user to pick when several owned instances are ready', async () => {
63
+ const first = instance('first')
64
+ const second = instance('second')
65
+ const pending = instance('pending', 'provisioning')
66
+ const deps = dependencies({
67
+ fetchOwned: mock(async () => [first, pending, second]),
68
+ selectReady: mock(async (instances) => instances[1] ?? null),
69
+ })
70
+
71
+ await expect(ensureOwnedInstance(ctx, deps)).resolves.toBe('fixed')
72
+
73
+ expect(deps.selectReady).toHaveBeenCalledWith([first, second])
74
+ expect(deps.adopt).toHaveBeenCalledWith(second)
75
+ expect(deps.confirmCreate).not.toHaveBeenCalled()
76
+ expect(deps.provision).not.toHaveBeenCalled()
77
+ })
78
+
79
+ test('leaves setup skipped when the ready-instance picker is cancelled', async () => {
80
+ const first = instance('first')
81
+ const second = instance('second')
82
+ const deps = dependencies({
83
+ fetchOwned: mock(async () => [first, second]),
84
+ selectReady: mock(async () => null),
85
+ })
86
+
87
+ await expect(ensureOwnedInstance(ctx, deps)).resolves.toBe('skipped')
88
+
89
+ expect(deps.selectReady).toHaveBeenCalledWith([first, second])
90
+ expect(deps.adopt).not.toHaveBeenCalled()
91
+ expect(deps.confirmCreate).not.toHaveBeenCalled()
92
+ expect(deps.provision).not.toHaveBeenCalled()
93
+ })
94
+
95
+ test('offers first-instance creation only after a confirmed empty owner list', async () => {
96
+ const deps = dependencies()
97
+ const original = console.log
98
+ console.log = mock(() => {})
99
+
100
+ try {
101
+ await expect(ensureOwnedInstance(ctx, deps)).resolves.toBe('fixed')
102
+ } finally {
103
+ console.log = original
104
+ }
105
+
106
+ expect(deps.confirmCreate).toHaveBeenCalledTimes(1)
107
+ expect(deps.promptSlug).toHaveBeenCalledTimes(1)
108
+ expect(deps.provision).toHaveBeenCalledWith('new-instance')
109
+ expect(deps.adopt).not.toHaveBeenCalled()
110
+ })
111
+
112
+ test('does not provision when first-instance creation is declined', async () => {
113
+ const deps = dependencies({ confirmCreate: mock(async () => false) })
114
+
115
+ await expect(ensureOwnedInstance(ctx, deps)).resolves.toBe('skipped')
116
+
117
+ expect(deps.confirmCreate).toHaveBeenCalledTimes(1)
118
+ expect(deps.promptSlug).not.toHaveBeenCalled()
119
+ expect(deps.provision).not.toHaveBeenCalled()
120
+ })
121
+
122
+ test('does not provision when the slug prompt is cancelled', async () => {
123
+ const deps = dependencies({ promptSlug: mock(async () => undefined) })
124
+
125
+ await expect(ensureOwnedInstance(ctx, deps)).resolves.toBe('skipped')
126
+
127
+ expect(deps.confirmCreate).toHaveBeenCalledTimes(1)
128
+ expect(deps.promptSlug).toHaveBeenCalledTimes(1)
129
+ expect(deps.provision).not.toHaveBeenCalled()
130
+ })
131
+
132
+ test('does not report setup fixed when provisioning could not select the instance', async () => {
133
+ const deps = dependencies({
134
+ provision: mock(async (slug) => ({
135
+ created: { url: `https://${slug}.eu.astrale.ai`, organizationId: `org_${slug}` },
136
+ slug,
137
+ selectionError: new Error('instances.json is read-only'),
138
+ })),
139
+ })
140
+ const logged: string[] = []
141
+ const original = console.log
142
+ console.log = mock((...args: unknown[]) => {
143
+ logged.push(args.map(String).join(' '))
144
+ })
145
+
146
+ let caught: unknown
147
+ try {
148
+ await ensureOwnedInstance(ctx, deps)
149
+ } catch (error) {
150
+ caught = error
151
+ } finally {
152
+ console.log = original
153
+ }
154
+
155
+ expect(caught).toBeInstanceOf(AstraleError)
156
+ expect((caught as AstraleError).code).toBe('INSTANCE_SELECTION_FAILED')
157
+ expect((caught as AstraleError).message).toContain('instances.json is read-only')
158
+ expect((caught as AstraleError).hint).toContain('astrale instance use new-instance')
159
+ expect(logged.join('\n')).not.toContain('https://new-instance.eu.astrale.ai')
160
+ })
161
+
162
+ test('does not create a duplicate while an owned instance is provisioning or failed', async () => {
163
+ const provisioning = {
164
+ ...instance('pending', 'provisioning'),
165
+ phase: 'installing:default-domains',
166
+ }
167
+ const failed = { ...instance('broken', 'failed'), error: 'postInstall failed' }
168
+ const deps = dependencies({ fetchOwned: mock(async () => [provisioning, failed]) })
169
+ const logged: string[] = []
170
+ const original = console.log
171
+ console.log = mock((...args: unknown[]) => {
172
+ logged.push(args.map(String).join(' '))
173
+ })
174
+
175
+ try {
176
+ await expect(ensureOwnedInstance(ctx, deps)).resolves.toBe('skipped')
177
+ } finally {
178
+ console.log = original
179
+ }
180
+
181
+ expect(deps.selectReady).not.toHaveBeenCalled()
182
+ expect(deps.confirmCreate).not.toHaveBeenCalled()
183
+ expect(deps.promptSlug).not.toHaveBeenCalled()
184
+ expect(deps.provision).not.toHaveBeenCalled()
185
+ expect(logged.join('\n')).toContain('pending: provisioning (installing:default-domains)')
186
+ expect(logged.join('\n')).toContain('broken: failed')
187
+ expect(logged.join('\n')).toContain('postInstall failed')
188
+ expect(logged.join('\n')).toContain('astrale instance status')
189
+ })
190
+
191
+ test('does not treat owner-discovery failure as an empty account', async () => {
192
+ const deps = dependencies({
193
+ fetchOwned: mock(async () => {
194
+ throw new Error('admin unavailable')
195
+ }),
196
+ })
197
+
198
+ let caught: unknown
199
+ try {
200
+ await ensureOwnedInstance(ctx, deps)
201
+ } catch (error) {
202
+ caught = error
203
+ }
204
+
205
+ expect(caught).toBeInstanceOf(AstraleError)
206
+ expect((caught as AstraleError).code).toBe('INSTANCE_DISCOVERY_FAILED')
207
+ expect((caught as AstraleError).hint).toContain('No instance was created')
208
+ expect(deps.confirmCreate).not.toHaveBeenCalled()
209
+ expect(deps.provision).not.toHaveBeenCalled()
210
+ })
211
+ })
212
+
213
+ describe('owned-instance adoption', () => {
214
+ test('persists the owner organization before activating the bookmark', async () => {
215
+ const owned = instance('existing')
216
+ const calls: unknown[][] = []
217
+ const original = console.log
218
+ console.log = mock(() => {})
219
+
220
+ try {
221
+ await adoptOwnedInstance(owned, {
222
+ upsert: mock(async (...args) => {
223
+ calls.push(['upsert', ...args])
224
+ return {}
225
+ }),
226
+ activate: mock(async (...args) => {
227
+ calls.push(['activate', ...args])
228
+ }),
229
+ })
230
+ } finally {
231
+ console.log = original
232
+ }
233
+
234
+ expect(calls).toEqual([
235
+ ['upsert', 'existing', 'existing', 'https://existing.eu.astrale.ai', 'org_existing'],
236
+ ['activate', 'existing'],
237
+ ])
238
+ })
239
+ })