@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.
- package/README.md +3 -2
- package/dist/astrale.js +339 -242
- package/package.json +2 -3
- 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/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/studio.ts +13 -0
- 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-assets.test.ts +114 -0
- 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/instance.ts +0 -11
- package/src/lib/login-flow.ts +4 -41
- package/src/lib/view/assets.ts +64 -0
- package/src/lib/view/port-allocation.ts +19 -0
- package/src/lib/view/server.ts +4 -37
- package/src/setup/__tests__/instance-step.test.ts +239 -0
- package/src/setup/steps/instance.ts +141 -61
- package/studio/client/dist/assets/index-BaqEuIQJ.js +109 -0
- package/studio/client/dist/assets/index-jRdExahh.css +1 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/server/agent/ask.test.ts +74 -0
- package/studio/server/agent/ask.ts +20 -22
- package/studio/server/agent/bridge/client.test.ts +49 -0
- package/studio/server/agent/bridge/client.ts +37 -0
- package/studio/server/agent/bridge/grant.test.ts +62 -0
- package/studio/server/agent/bridge/grant.ts +84 -0
- package/studio/server/agent/bridge/routes.test.ts +151 -0
- package/studio/server/agent/bridge/routes.ts +148 -0
- package/studio/server/agent/{bridge-mcp.ts → bridge/stdio.ts} +27 -21
- package/studio/server/agent/conversation.test.ts +84 -0
- package/studio/server/agent/conversation.ts +110 -0
- package/studio/server/agent/{types.ts → harness/adapter.ts} +63 -12
- package/studio/server/agent/harness/claude/adapter.test.ts +338 -0
- package/studio/server/agent/harness/claude/adapter.ts +71 -0
- package/studio/server/agent/harness/claude/ask.ts +114 -0
- package/studio/server/agent/harness/claude/capabilities.ts +27 -0
- package/studio/server/agent/harness/claude/command.ts +63 -0
- package/studio/server/agent/harness/claude/events.ts +215 -0
- package/studio/server/agent/harness/claude/loadout.ts +132 -0
- package/studio/server/agent/harness/claude/mcp.test.ts +36 -0
- package/studio/server/agent/harness/claude/mcp.ts +41 -0
- package/studio/server/agent/harness/claude/skills.ts +43 -0
- package/studio/server/agent/harness/codex/adapter.test.ts +269 -0
- package/studio/server/agent/harness/codex/adapter.ts +116 -0
- package/studio/server/agent/harness/codex/ask.test.ts +176 -0
- package/studio/server/agent/harness/codex/ask.ts +181 -0
- package/studio/server/agent/harness/codex/command.test.ts +69 -0
- package/studio/server/agent/harness/codex/command.ts +43 -0
- package/studio/server/agent/harness/codex/events.test.ts +94 -0
- package/studio/server/agent/harness/codex/events.ts +132 -0
- package/studio/server/agent/harness/codex/exec.ts +113 -0
- package/studio/server/agent/harness/codex/loadout.ts +63 -0
- package/studio/server/agent/harness/codex/mcp.test.ts +32 -0
- package/studio/server/agent/harness/codex/mcp.ts +27 -0
- package/studio/server/agent/harness/codex/models.test.ts +167 -0
- package/studio/server/agent/harness/codex/models.ts +211 -0
- package/studio/server/agent/harness/codex/skills.ts +33 -0
- package/studio/server/agent/harness/gateway/config.test.ts +98 -0
- package/studio/server/{state/harness-gateway.ts → agent/harness/gateway/config.ts} +30 -8
- package/studio/server/agent/harness/gateway/token.test.ts +133 -0
- package/studio/server/agent/harness/gateway/token.ts +166 -0
- package/studio/server/agent/harness/mock/adapter.ts +190 -0
- package/studio/server/agent/harness/mock/domain-edit.ts +41 -0
- package/studio/server/agent/harness/process.test.ts +46 -0
- package/studio/server/agent/harness/process.ts +104 -0
- package/studio/server/agent/harness/registry.ts +37 -0
- package/studio/server/agent/harness/selection.test.ts +75 -0
- package/studio/server/agent/harness/selection.ts +77 -0
- package/studio/server/agent/harness/skills.test.ts +95 -0
- package/studio/server/agent/harness/skills.ts +100 -0
- package/studio/server/agent/layout.test.ts +95 -0
- package/studio/server/agent/notify.ts +12 -0
- package/studio/server/agent/{schema-map.ts → prompts/anchors.ts} +2 -2
- package/studio/server/agent/prompts/ask.ts +34 -0
- package/studio/server/agent/{prompt.ts → prompts/system.ts} +3 -107
- package/studio/server/agent/prompts/turn.ts +57 -0
- package/studio/server/agent/routes.test.ts +176 -0
- package/studio/server/agent/routes.ts +214 -0
- package/studio/server/agent/run/completion.ts +193 -0
- package/studio/server/agent/run/coordinator.test.ts +337 -0
- package/studio/server/agent/run/coordinator.ts +104 -0
- package/studio/server/agent/run/live-state.ts +73 -0
- package/studio/server/agent/run/preparation.ts +162 -0
- package/studio/server/agent/run/transcript.test.ts +47 -0
- package/studio/server/agent/run/transcript.ts +30 -0
- package/studio/server/agent/run/usage.test.ts +46 -0
- package/studio/server/{state → agent/run}/usage.ts +4 -4
- package/studio/server/agent/stream.ts +37 -0
- package/studio/server/agent/{session-id.ts → telemetry.ts} +1 -1
- package/studio/server/api-agent-loadout.test.ts +69 -0
- package/studio/server/api.ts +3 -155
- package/studio/server/index.ts +6 -5
- package/studio/server/state/comments.test.ts +29 -0
- package/studio/server/state/comments.ts +3 -3
- package/studio/server/state/settings.test.ts +78 -0
- package/studio/server/state/settings.ts +28 -1
- package/studio/shared/agent-effort.test.ts +12 -0
- package/studio/shared/agent-effort.ts +15 -0
- package/studio/shared/agent-models.test.ts +9 -0
- package/studio/shared/agent-models.ts +14 -0
- package/studio/shared/settings-values.test.ts +18 -0
- package/studio/shared/settings-values.ts +20 -0
- package/studio/shared/types.ts +58 -7
- package/src/connect-core.test.ts +0 -42
- package/src/connect-core.ts +0 -53
- package/studio/client/dist/assets/index-CyN5G8IA.js +0 -109
- package/studio/client/dist/assets/index-DKKMHBBC.css +0 -1
- package/studio/server/agent/bridge.ts +0 -188
- package/studio/server/agent/claude.ts +0 -678
- package/studio/server/agent/mock.ts +0 -186
- package/studio/server/agent/registry.ts +0 -29
- package/studio/server/agent/runner.ts +0 -487
- package/studio/server/state/harness-token.ts +0 -0
package/src/lib/login-flow.ts
CHANGED
|
@@ -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 (
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
+
}
|
package/src/lib/view/server.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
})
|