@astrale-os/cli 1.0.0-beta.7 → 1.0.0-beta.9
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 +1 -1
- package/dist/astrale.js +1531 -1356
- package/dist/public/connect-core.js +4 -4
- package/dist/types/admin/binding.d.ts +19 -0
- package/dist/types/admin/instance/client.d.ts +22 -0
- package/dist/types/admin/instance/index.d.ts +2 -0
- package/dist/types/admin/instance/model.d.ts +3 -9
- package/dist/types/connection/auth.d.ts +3 -0
- package/dist/types/connection/call.d.ts +3 -0
- package/dist/types/connection/command.d.ts +27 -0
- package/dist/types/connection/credential.d.ts +14 -0
- package/dist/types/connection/errors.d.ts +1 -0
- package/dist/types/connection/exchange.d.ts +9 -0
- package/dist/types/connection/failure/classify.d.ts +2 -0
- package/dist/types/connection/failure/index.d.ts +5 -0
- package/dist/types/connection/failure/model.d.ts +25 -0
- package/dist/types/connection/failure/render.d.ts +3 -0
- package/dist/types/connection/index.d.ts +8 -0
- package/dist/types/connection/reasons.d.ts +37 -0
- package/dist/types/connection/self.d.ts +37 -0
- package/dist/types/connection/session.d.ts +31 -0
- package/dist/types/connection/target.d.ts +38 -0
- package/dist/types/lib/admin-instance.d.ts +17 -0
- package/dist/types/lib/failure-debug.d.ts +1 -0
- package/dist/types/lib/instance.d.ts +7 -1
- package/dist/types/lib/log.d.ts +3 -1
- package/dist/types/lib/self.d.ts +9 -0
- package/package.json +1 -1
- package/src/admin/instance/__tests__/client.test.ts +4 -104
- package/src/admin/instance/client.ts +2 -96
- package/src/admin/instance/index.ts +1 -2
- package/src/admin/instance/model.ts +4 -14
- package/src/commands/__tests__/admin-instance.test.ts +11 -8
- package/src/commands/__tests__/instance-list-rows.test.ts +1 -1
- package/src/commands/call-describe.ts +1 -1
- package/src/commands/call.ts +2 -2
- package/src/commands/domain/list.ts +1 -1
- package/src/commands/domain/publish.ts +14 -11
- package/src/commands/get.ts +1 -1
- package/src/commands/instance/create.ts +8 -16
- package/src/commands/instance/delete.ts +7 -6
- package/src/commands/instance/list.ts +2 -2
- package/src/commands/instance/status.ts +4 -4
- package/src/commands/instance/use.ts +16 -12
- package/src/commands/introspect.ts +5 -5
- package/src/commands/view.ts +3 -3
- package/src/connection/__tests__/credential.test.ts +19 -0
- package/src/connection/__tests__/target.test.ts +1 -0
- package/src/connection/auth.ts +13 -0
- package/src/connection/failure/index.ts +1 -1
- package/src/connection/session.ts +12 -1
- package/src/lib/__tests__/command-dx.test.ts +1 -1
- package/src/lib/__tests__/domain-publication.test.ts +6 -1
- package/src/lib/__tests__/instance-candidates.test.ts +1 -3
- package/src/lib/__tests__/instance-target.test.ts +1 -0
- package/src/lib/__tests__/log-errors.test.ts +11 -0
- package/src/lib/admin-instance.ts +14 -8
- package/src/lib/command-dx.ts +8 -5
- package/src/lib/domain-publication.ts +8 -6
- package/src/lib/instance-candidates.ts +2 -2
- package/src/lib/instance.ts +14 -10
- package/src/lib/log.ts +6 -4
- package/src/lib/provision-instance.ts +34 -57
- package/src/program/__tests__/program.test.ts +20 -4
- package/src/program/build.ts +2 -2
- package/src/setup/__tests__/instance-step.test.ts +27 -10
- package/src/setup/steps/instance.ts +22 -15
- package/studio/client/dist/assets/{elk-api-Di4OXGNH.js → elk-api-ByzoDZWH.js} +1 -1
- package/studio/client/dist/assets/{index-DTjvkOUX.js → index-B0RCgUOT.js} +4 -4
- package/studio/client/dist/assets/{index-BLm6J11H.js → index-N8J8EKlB.js} +2 -2
- package/studio/client/dist/index.html +1 -1
- package/studio/server/introspect/runtime.test.ts +2 -2
- package/viewer/dist/main.js +28 -28
- package/src/commands/__tests__/instance-create-hosts.test.ts +0 -29
- /package/src/{connection/failure/debug.ts → lib/failure-debug.ts} +0 -0
package/src/commands/view.ts
CHANGED
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
import { snapshotText, waitForSettledSnapshot } from '../lib/view/snapshot'
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
* `astrale view` — open ONE view in
|
|
45
|
+
* `astrale view` — open ONE view in a local browser shell, authenticated as
|
|
46
46
|
* the CLI identity, driveable by agent-browser (default) or a real browser.
|
|
47
47
|
* Design + protocol details: VIEW_CLI_SPEC.md at the workspace root.
|
|
48
48
|
*/
|
|
@@ -485,7 +485,7 @@ async function sessionsCommand(opts: ViewOpts): Promise<void> {
|
|
|
485
485
|
|
|
486
486
|
export default {
|
|
487
487
|
name: 'view',
|
|
488
|
-
description: 'Open one view in
|
|
488
|
+
description: 'Open one view in a local browser shell your agent can drive',
|
|
489
489
|
arguments: [
|
|
490
490
|
{
|
|
491
491
|
name: 'spec',
|
|
@@ -525,7 +525,7 @@ export default {
|
|
|
525
525
|
afterHelpText: `
|
|
526
526
|
What it does:
|
|
527
527
|
Renders ONE view — no GUI, no cookies, no WorkOS. It resolves the view on the
|
|
528
|
-
kernel, starts a loopback session server that
|
|
528
|
+
kernel, starts a loopback session server that supplies the shell handshake (real
|
|
529
529
|
handshake via @astrale-os/shell, token minted from YOUR CLI identity, kernel
|
|
530
530
|
calls proxied), and opens the page headless in agent-browser. Driving stays
|
|
531
531
|
agent-browser's job; auth follows --as/--creds/-i like any kernel command.
|
|
@@ -6,6 +6,7 @@ import { describe, expect, test } from 'bun:test'
|
|
|
6
6
|
|
|
7
7
|
import type { AstraleConfig } from '../../lib/config'
|
|
8
8
|
|
|
9
|
+
import { bindCredentialIdentity } from '../auth'
|
|
9
10
|
import { createCliCredential, createConnectionCredential } from '../credential'
|
|
10
11
|
|
|
11
12
|
const SOURCE = issuer.accept('https://kernel.example')
|
|
@@ -20,6 +21,24 @@ const config: AstraleConfig = {
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
describe('connection credential', () => {
|
|
24
|
+
test('pins the bookmark identity while leaving explicit credentials unnamed', async () => {
|
|
25
|
+
const target = {
|
|
26
|
+
url: `${SOURCE}/invoke`,
|
|
27
|
+
kernelIssuer: SOURCE,
|
|
28
|
+
defaultIdentity: 'bookmark-owner',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
await expect(bindCredentialIdentity({}, target)).resolves.toMatchObject({
|
|
32
|
+
as: 'bookmark-owner',
|
|
33
|
+
})
|
|
34
|
+
await expect(bindCredentialIdentity({ as: 'explicit-owner' }, target)).resolves.toEqual({
|
|
35
|
+
as: 'explicit-owner',
|
|
36
|
+
})
|
|
37
|
+
await expect(bindCredentialIdentity({ creds: 'opaque' }, target)).resolves.toEqual({
|
|
38
|
+
creds: 'opaque',
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
23
42
|
/** @evidence TEST-CLI-CONNECTION-RESOLVES-SOURCE-AUTH-BEFORE-ROUTING */
|
|
24
43
|
test('resolves source authority without destination knowledge', async () => {
|
|
25
44
|
const audiences: IssuerId[] = []
|
package/src/connection/auth.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AstraleConfig } from '../lib/config'
|
|
2
|
+
import type { ConnectionOptions, ConnectionTarget } from './target'
|
|
2
3
|
|
|
3
4
|
import { AuthError } from '../errors'
|
|
4
5
|
import { getDefault, getIdentity, type Identity } from '../identity/registry'
|
|
@@ -21,6 +22,18 @@ export type KeyIdentityAuthOptions = {
|
|
|
21
22
|
audience: string
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
/** Pin the local identity label before a session performs any authenticated effect. */
|
|
26
|
+
export async function bindCredentialIdentity<Options extends ConnectionOptions>(
|
|
27
|
+
options: Options,
|
|
28
|
+
target: ConnectionTarget,
|
|
29
|
+
): Promise<Options> {
|
|
30
|
+
if (options.anonymous === true || options.creds !== undefined || options.as !== undefined) {
|
|
31
|
+
return options
|
|
32
|
+
}
|
|
33
|
+
const identity = target.defaultIdentity ?? (await getDefault()).name
|
|
34
|
+
return Object.freeze({ ...options, as: identity })
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
/**
|
|
25
38
|
* Resolve a signed JWT credential from CLI options.
|
|
26
39
|
*
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { OperationRecovery } from '../command'
|
|
2
2
|
|
|
3
|
+
import { printFailureDebug } from '../../lib/failure-debug'
|
|
3
4
|
import { classifyFailure } from './classify'
|
|
4
|
-
import { printFailureDebug } from './debug'
|
|
5
5
|
import { renderFailure } from './render'
|
|
6
6
|
|
|
7
7
|
export { functionInputIssues, schemaUpgradeHint } from '../reasons'
|
|
@@ -21,6 +21,7 @@ import { fetchWithCaFile } from '../lib/ca-fetch'
|
|
|
21
21
|
import { readConfig } from '../lib/config'
|
|
22
22
|
import { log } from '../lib/log'
|
|
23
23
|
import { isMachine } from '../lib/output'
|
|
24
|
+
import { bindCredentialIdentity } from './auth'
|
|
24
25
|
import { createCliCredential, validateCredentialSelection } from './credential'
|
|
25
26
|
import { resolveAdminConnectionTarget, resolveConnectionTarget } from './target'
|
|
26
27
|
|
|
@@ -38,6 +39,8 @@ export interface ConnectionContext {
|
|
|
38
39
|
readonly graph: GraphApi
|
|
39
40
|
readonly auth: AuthApi
|
|
40
41
|
readonly target: ConnectionTarget
|
|
42
|
+
/** Local identity label selected for this session; absent for raw credentials or anonymous use. */
|
|
43
|
+
readonly identity?: string
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
interface OwnedConnection {
|
|
@@ -63,6 +66,7 @@ export async function withClientSession<Value>(
|
|
|
63
66
|
const target = await resolveConnectionTarget(options, config, {
|
|
64
67
|
managed: (slug) => lookupManagedInstance(slug, options),
|
|
65
68
|
})
|
|
69
|
+
options = await bindCredentialIdentity(options, target)
|
|
66
70
|
warnMissingExplicitTarget(options, target)
|
|
67
71
|
return runResolvedClientSession(target, timeoutMs, options, config, action, openConnection)
|
|
68
72
|
}
|
|
@@ -76,6 +80,7 @@ export async function withAdminClientSession<Value>(
|
|
|
76
80
|
const timeoutMs = resolveTimeoutMs(options.timeout)
|
|
77
81
|
const config = await readConfig()
|
|
78
82
|
const target = await resolveAdminConnectionTarget(options, config)
|
|
83
|
+
options = await bindCredentialIdentity(options, target)
|
|
79
84
|
return runResolvedClientSession(target, timeoutMs, options, config, action, openConnection)
|
|
80
85
|
}
|
|
81
86
|
|
|
@@ -141,7 +146,13 @@ function openConnection(
|
|
|
141
146
|
const graph = createGraph((call, request) => session.call(call, request))
|
|
142
147
|
const authApi = createAuth((path, input, request) => session.call(call(path, input), request))
|
|
143
148
|
return {
|
|
144
|
-
context: Object.freeze({
|
|
149
|
+
context: Object.freeze({
|
|
150
|
+
session,
|
|
151
|
+
graph,
|
|
152
|
+
auth: authApi,
|
|
153
|
+
target,
|
|
154
|
+
...(options.as === undefined ? {} : { identity: options.as }),
|
|
155
|
+
}),
|
|
145
156
|
close() {
|
|
146
157
|
session.close()
|
|
147
158
|
},
|
|
@@ -16,7 +16,7 @@ function dx(
|
|
|
16
16
|
error: CommanderError,
|
|
17
17
|
argv: string[],
|
|
18
18
|
) {
|
|
19
|
-
const rendered = JSON.parse(stripAnsi(renderCommanderError(program, error, argv))) as {
|
|
19
|
+
const rendered = JSON.parse(stripAnsi(renderCommanderError(program, error, argv, true))) as {
|
|
20
20
|
error: string
|
|
21
21
|
message: string
|
|
22
22
|
detail: string
|
|
@@ -3,7 +3,7 @@ import { defineSchema } from '@astrale-os/sdk/schema'
|
|
|
3
3
|
import { describe, expect, test } from 'bun:test'
|
|
4
4
|
|
|
5
5
|
import { releaseFor } from '../../__tests__/fixtures/publication'
|
|
6
|
-
import { fetchDomainPublication } from '../domain-publication'
|
|
6
|
+
import { domainPublicationUrl, fetchDomainPublication } from '../domain-publication'
|
|
7
7
|
|
|
8
8
|
const ROOT = 'https://publication-reader.example.dev'
|
|
9
9
|
const PUBLICATION_URL = `${ROOT}/.well-known/astrale/domain.json`
|
|
@@ -23,6 +23,11 @@ describe('Domain Publication reader', () => {
|
|
|
23
23
|
expect(requests).toEqual([{ url: PUBLICATION_URL, redirect: 'error' }])
|
|
24
24
|
})
|
|
25
25
|
|
|
26
|
+
test('normalizes an origin and admits the exact canonical Publication URL', () => {
|
|
27
|
+
expect(domainPublicationUrl(ROOT).href).toBe(PUBLICATION_URL)
|
|
28
|
+
expect(domainPublicationUrl(PUBLICATION_URL).href).toBe(PUBLICATION_URL)
|
|
29
|
+
})
|
|
30
|
+
|
|
26
31
|
test('propagates redirect rejection instead of accepting another target', async () => {
|
|
27
32
|
const fetch = async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
28
33
|
expect(init?.redirect).toBe('error')
|
|
@@ -18,7 +18,6 @@ const managedDemo: OwnedInstanceInfo = {
|
|
|
18
18
|
slug: 'demo',
|
|
19
19
|
url: 'https://demo.eu.astrale.ai',
|
|
20
20
|
state: 'ready',
|
|
21
|
-
region: 'eu',
|
|
22
21
|
}
|
|
23
22
|
|
|
24
23
|
describe('collectInstanceCandidates', () => {
|
|
@@ -138,11 +137,10 @@ describe('describeInstanceCandidate', () => {
|
|
|
138
137
|
slug: 'twin',
|
|
139
138
|
url: 'https://twin.eu.astrale.ai',
|
|
140
139
|
state: 'ready',
|
|
141
|
-
region: 'eu',
|
|
142
140
|
},
|
|
143
141
|
])
|
|
144
142
|
expect(describeInstanceCandidate(managed!)).toBe(
|
|
145
|
-
'twin (managed) https://twin.eu.astrale.ai/api
|
|
143
|
+
'twin (managed) https://twin.eu.astrale.ai/api',
|
|
146
144
|
)
|
|
147
145
|
})
|
|
148
146
|
})
|
|
@@ -36,6 +36,17 @@ describe('terminal error safety', () => {
|
|
|
36
36
|
})
|
|
37
37
|
})
|
|
38
38
|
|
|
39
|
+
test('retains unknown failure evidence only under explicit debug output', () => {
|
|
40
|
+
const failure = new AggregateError([new Error('provider rejected organization scope')], '')
|
|
41
|
+
|
|
42
|
+
expect(() => fatal(failure, { json: true, debug: true })).toThrow(ExitError)
|
|
43
|
+
|
|
44
|
+
expect(stderr).toContain(
|
|
45
|
+
'{"error":"UNEXPECTED_ERROR","message":"The CLI encountered an unexpected internal failure."}',
|
|
46
|
+
)
|
|
47
|
+
expect(stderr).toContain('aggregate: Error: provider rejected organization scope')
|
|
48
|
+
})
|
|
49
|
+
|
|
39
50
|
test('rejects blank admitted CLI diagnostics', () => {
|
|
40
51
|
expect(() => new AstraleError('INVALID_INPUT', ' ')).toThrow(TypeError)
|
|
41
52
|
})
|
|
@@ -6,7 +6,7 @@ import { withAdminClientSession } from '../connection'
|
|
|
6
6
|
export {
|
|
7
7
|
AdminInstanceNotFoundError,
|
|
8
8
|
findOwnedInstance,
|
|
9
|
-
|
|
9
|
+
formatInstanceState,
|
|
10
10
|
type InstanceInfo,
|
|
11
11
|
type InstanceState,
|
|
12
12
|
type OwnedInstanceInfo,
|
|
@@ -19,19 +19,25 @@ export function listOwnedInstances(options: AdminConnectionOptions) {
|
|
|
19
19
|
)
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** Read caller-visible inventory together with the exact local identity used for the Admin call. */
|
|
23
|
+
export function listOwnedInstancesWithIdentity(options: AdminConnectionOptions) {
|
|
24
|
+
return withAdminClientSession(options, async (context) =>
|
|
25
|
+
Object.freeze({
|
|
26
|
+
instances: await (await connectAdminInstances(context)).list(),
|
|
27
|
+
...(context.identity === undefined ? {} : { identity: context.identity }),
|
|
28
|
+
}),
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
22
32
|
/** Reuse an already-open Admin session for a caller-visible Instance inventory. */
|
|
23
33
|
export async function listOwnedInstancesInContext(context: ConnectionContext) {
|
|
24
34
|
return (await connectAdminInstances(context)).list()
|
|
25
35
|
}
|
|
26
36
|
|
|
27
|
-
/**
|
|
28
|
-
export function createOwnedInstance(
|
|
29
|
-
options: AdminConnectionOptions,
|
|
30
|
-
slug: string,
|
|
31
|
-
hostId?: string,
|
|
32
|
-
) {
|
|
37
|
+
/** Request one managed Instance; Admin owns infrastructure placement. */
|
|
38
|
+
export function createOwnedInstance(options: AdminConnectionOptions, slug: string) {
|
|
33
39
|
return withAdminClientSession(options, async (context) =>
|
|
34
|
-
(await connectAdminInstances(context)).create(slug
|
|
40
|
+
(await connectAdminInstances(context)).create(slug),
|
|
35
41
|
)
|
|
36
42
|
}
|
|
37
43
|
|
package/src/lib/command-dx.ts
CHANGED
|
@@ -2,8 +2,6 @@ import type { Command, CommanderError } from 'commander'
|
|
|
2
2
|
|
|
3
3
|
import chalk from 'chalk'
|
|
4
4
|
|
|
5
|
-
import { isMachine } from './output'
|
|
6
|
-
|
|
7
5
|
export type CommandCatalogEntry = {
|
|
8
6
|
path: string[]
|
|
9
7
|
usage: string
|
|
@@ -30,13 +28,15 @@ export function renderCommanderError(
|
|
|
30
28
|
program: Command,
|
|
31
29
|
error: CommanderError,
|
|
32
30
|
argv = process.argv.slice(2),
|
|
31
|
+
machine = argv.some((token) => token === '--ci' || token === '--json' || token === '--raw') ||
|
|
32
|
+
!(process.stdout.isTTY ?? false),
|
|
33
33
|
): string {
|
|
34
34
|
const tokens = stripOptions(argv)
|
|
35
35
|
const catalog = collectCommandCatalog(program)
|
|
36
36
|
const matched = matchRegisteredPrefix(program, tokens)
|
|
37
37
|
|
|
38
38
|
if (matched.path.length === 0 && tokens.length > 0) {
|
|
39
|
-
return maybeMachine(renderUnknownCommand(tokens, catalog))
|
|
39
|
+
return maybeMachine(renderUnknownCommand(tokens, catalog), machine)
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
if (error.code === 'commander.missingArgument') {
|
|
@@ -48,6 +48,7 @@ export function renderCommanderError(
|
|
|
48
48
|
'Usage:',
|
|
49
49
|
` astrale ${usage}`,
|
|
50
50
|
].join('\n'),
|
|
51
|
+
machine,
|
|
51
52
|
)
|
|
52
53
|
}
|
|
53
54
|
|
|
@@ -63,6 +64,7 @@ export function renderCommanderError(
|
|
|
63
64
|
'Usage:',
|
|
64
65
|
` astrale ${usage}`,
|
|
65
66
|
].join('\n'),
|
|
67
|
+
machine,
|
|
66
68
|
)
|
|
67
69
|
}
|
|
68
70
|
|
|
@@ -74,6 +76,7 @@ export function renderCommanderError(
|
|
|
74
76
|
? ['', 'Did you mean:', ...suggestions.map((s) => ` astrale ${s}`)]
|
|
75
77
|
: []),
|
|
76
78
|
].join('\n'),
|
|
79
|
+
machine,
|
|
77
80
|
)
|
|
78
81
|
}
|
|
79
82
|
|
|
@@ -140,8 +143,8 @@ function usageFor(path: string[], command: Command): string {
|
|
|
140
143
|
|
|
141
144
|
const ANSI_RE = new RegExp(String.fromCharCode(27) + '\\[[0-9;]*m', 'g')
|
|
142
145
|
|
|
143
|
-
function maybeMachine(text: string): string {
|
|
144
|
-
if (!
|
|
146
|
+
function maybeMachine(text: string, machine: boolean): string {
|
|
147
|
+
if (!machine) return text
|
|
145
148
|
const plain = text.replace(ANSI_RE, '')
|
|
146
149
|
const first = plain.split('\n').find((line) => line.trim().length > 0) ?? plain
|
|
147
150
|
return JSON.stringify({ error: 'USAGE_ERROR', message: first, detail: plain })
|
|
@@ -9,8 +9,7 @@ export async function fetchDomainPublication(
|
|
|
9
9
|
signal?: AbortSignal,
|
|
10
10
|
fetchImpl: FetchLike = globalThis.fetch,
|
|
11
11
|
): Promise<publication.Publication> {
|
|
12
|
-
const
|
|
13
|
-
const url = new URL(publication.PATH, root)
|
|
12
|
+
const url = domainPublicationUrl(input)
|
|
14
13
|
const response = await fetchImpl(url, {
|
|
15
14
|
redirect: 'error',
|
|
16
15
|
...(signal === undefined ? {} : { signal }),
|
|
@@ -81,7 +80,8 @@ async function cancel(body: ReadableStream<Uint8Array> | null): Promise<void> {
|
|
|
81
80
|
if (body !== null) await body.cancel().catch(() => undefined)
|
|
82
81
|
}
|
|
83
82
|
|
|
84
|
-
|
|
83
|
+
/** Canonical discovery document for a Domain origin or exact Publication URL. */
|
|
84
|
+
export function domainPublicationUrl(input: string): URL {
|
|
85
85
|
let url: URL
|
|
86
86
|
try {
|
|
87
87
|
url = new URL(input)
|
|
@@ -94,9 +94,11 @@ function deploymentRoot(input: string): URL {
|
|
|
94
94
|
url.password !== '' ||
|
|
95
95
|
url.search !== '' ||
|
|
96
96
|
url.hash !== '' ||
|
|
97
|
-
!['', '/'].includes(url.pathname)
|
|
97
|
+
!['', '/', publication.PATH].includes(url.pathname)
|
|
98
98
|
) {
|
|
99
|
-
throw new TypeError(
|
|
99
|
+
throw new TypeError(
|
|
100
|
+
`Domain deployment URL must be an HTTP(S) origin or canonical Publication URL: ${input}`,
|
|
101
|
+
)
|
|
100
102
|
}
|
|
101
|
-
return new URL(url.origin)
|
|
103
|
+
return new URL(publication.PATH, url.origin)
|
|
102
104
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { InstanceEntry, InstanceStore } from './instance'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { formatInstanceState, type OwnedInstanceInfo } from './admin-instance'
|
|
4
4
|
import { normalizeInstanceKernelUrl, resolveInstanceKey } from './instance'
|
|
5
5
|
|
|
6
6
|
export type InstanceCandidate =
|
|
@@ -44,6 +44,6 @@ export function describeInstanceCandidate(candidate: InstanceCandidate): string
|
|
|
44
44
|
if (candidate.source === 'bookmark') {
|
|
45
45
|
return `${candidate.key} (bookmark) ${candidate.url}`
|
|
46
46
|
}
|
|
47
|
-
const extra =
|
|
47
|
+
const extra = formatInstanceState(candidate.info)
|
|
48
48
|
return `${candidate.key} (managed) ${candidate.url}${extra ? ` — ${extra}` : ''}`
|
|
49
49
|
}
|
package/src/lib/instance.ts
CHANGED
|
@@ -297,22 +297,26 @@ export async function upsertInstance(
|
|
|
297
297
|
* bookmark was repointed to a different kernel so callers can warn.
|
|
298
298
|
*/
|
|
299
299
|
export async function upsertManagedBookmark(
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
300
|
+
input: Readonly<{
|
|
301
|
+
key: string
|
|
302
|
+
slug: string
|
|
303
|
+
url: string
|
|
304
|
+
organizationId?: string
|
|
305
|
+
defaultIdentity?: string
|
|
306
|
+
}>,
|
|
304
307
|
): Promise<{ entry: InstanceEntry; repointedFrom?: string }> {
|
|
305
308
|
const store = await readInstances()
|
|
306
|
-
const previousUrl = store.instances[key]?.url
|
|
307
|
-
const url = normalizeInstanceKernelUrl(
|
|
308
|
-
const { entry } = await upsertInstance(key, {
|
|
309
|
+
const previousUrl = store.instances[input.key]?.url
|
|
310
|
+
const url = normalizeInstanceKernelUrl(input.url)
|
|
311
|
+
const { entry } = await upsertInstance(input.key, {
|
|
309
312
|
url,
|
|
310
313
|
issuer: url,
|
|
311
|
-
slug,
|
|
312
|
-
name: slug,
|
|
314
|
+
slug: input.slug,
|
|
315
|
+
name: input.slug,
|
|
313
316
|
kind: 'bookmark',
|
|
314
317
|
mode: 'remote',
|
|
315
|
-
...(organizationId ? { organizationId } : {}),
|
|
318
|
+
...(input.organizationId ? { organizationId: input.organizationId } : {}),
|
|
319
|
+
...(input.defaultIdentity ? { defaultIdentity: input.defaultIdentity } : {}),
|
|
316
320
|
})
|
|
317
321
|
return {
|
|
318
322
|
entry,
|
package/src/lib/log.ts
CHANGED
|
@@ -2,6 +2,7 @@ import chalk from 'chalk'
|
|
|
2
2
|
import ora, { type Ora } from 'ora'
|
|
3
3
|
|
|
4
4
|
import { AstraleError, NotImplementedError } from '../errors'
|
|
5
|
+
import { printFailureDebug } from './failure-debug'
|
|
5
6
|
import { formatElapsed } from './format'
|
|
6
7
|
import { isMachine, type MachineOpts } from './output'
|
|
7
8
|
|
|
@@ -18,7 +19,7 @@ export const log = {
|
|
|
18
19
|
|
|
19
20
|
/** Report an error with hint (when present) and exit. `--json` / `--ci` / a
|
|
20
21
|
* non-TTY stdout always get one structured JSON line on stderr. */
|
|
21
|
-
export function fatal(e: unknown, opts?: MachineOpts): never {
|
|
22
|
+
export function fatal(e: unknown, opts?: MachineOpts & { readonly debug?: boolean }): never {
|
|
22
23
|
// Ctrl-C at an interactive (@inquirer/prompts) prompt — exit quietly with the
|
|
23
24
|
// SIGINT convention, not a red error line.
|
|
24
25
|
if (e instanceof Error && e.name === 'ExitPromptError') process.exit(130)
|
|
@@ -29,10 +30,11 @@ export function fatal(e: unknown, opts?: MachineOpts): never {
|
|
|
29
30
|
const payload: Record<string, unknown> = { error: code, message: msg }
|
|
30
31
|
if (e instanceof AstraleError && e.hint) payload.hint = e.hint
|
|
31
32
|
process.stderr.write(JSON.stringify(payload) + '\n')
|
|
32
|
-
|
|
33
|
+
} else {
|
|
34
|
+
log.error(`${code}: ${msg}`)
|
|
35
|
+
if (e instanceof AstraleError && e.hint) log.dim(` hint: ${e.hint}`)
|
|
33
36
|
}
|
|
34
|
-
|
|
35
|
-
if (e instanceof AstraleError && e.hint) log.dim(` hint: ${e.hint}`)
|
|
37
|
+
if (opts?.debug) printFailureDebug(e, '')
|
|
36
38
|
process.exit(1)
|
|
37
39
|
}
|
|
38
40
|
|
|
@@ -9,12 +9,10 @@ import { createOwnedInstance } from './admin-instance'
|
|
|
9
9
|
import { setActive, upsertManagedBookmark } from './instance'
|
|
10
10
|
import { withSpinner } from './log'
|
|
11
11
|
import { isMachine } from './output'
|
|
12
|
-
import { promptSelect } from './prompt'
|
|
13
12
|
import { validateSlug } from './validation'
|
|
14
13
|
|
|
15
14
|
export type ProvisionOpts = KernelCommandOpts &
|
|
16
15
|
AdminTargetCommandOpts & {
|
|
17
|
-
hostId?: string
|
|
18
16
|
// Global flags (program.ts) that force non-interactive — mirrors `instance use`.
|
|
19
17
|
ci?: boolean
|
|
20
18
|
noPrompt?: boolean
|
|
@@ -35,10 +33,10 @@ export type ProvisionResult = {
|
|
|
35
33
|
const SAGA_TIMEOUT_MS = '240000'
|
|
36
34
|
|
|
37
35
|
/**
|
|
38
|
-
* Provision an
|
|
36
|
+
* Provision an instance through the admin kernel, then bookmark it and
|
|
39
37
|
* make it the active target. Extracted from `instance create` so `astrale
|
|
40
|
-
* setup` provisions through the exact same saga (auth assertion →
|
|
41
|
-
* bookmark → set-active)
|
|
38
|
+
* setup` provisions through the exact same saga (auth assertion → create →
|
|
39
|
+
* bookmark → set-active).
|
|
42
40
|
*
|
|
43
41
|
* Presentation is deliberately minimal here (a spinner + a one-line success);
|
|
44
42
|
* the caller renders anything richer — `setup` follows this with a hero panel.
|
|
@@ -48,15 +46,10 @@ export async function provisionInstance(
|
|
|
48
46
|
opts: ProvisionOpts,
|
|
49
47
|
): Promise<ProvisionResult> {
|
|
50
48
|
validateSlug(slug)
|
|
51
|
-
await
|
|
52
|
-
|
|
53
|
-
// identity pinned on the admin bookmark (that mismatch silently made a fresh
|
|
54
|
-
// user's instance unusable). `--as` still wins.
|
|
55
|
-
if (!opts.as) opts = { ...opts, as: (await readIdentities()).default }
|
|
49
|
+
const authentication = await instanceCreateAuthentication(opts)
|
|
50
|
+
opts = authentication.opts
|
|
56
51
|
|
|
57
52
|
const machine = isMachine(opts)
|
|
58
|
-
const interactive = !!process.stdin.isTTY && !(opts.ci || opts.noPrompt || process.env.CI)
|
|
59
|
-
|
|
60
53
|
let repointedFrom: string | undefined
|
|
61
54
|
let selectionError: unknown = null
|
|
62
55
|
// The global 30s default doesn't just fail the CLIENT: the disconnect kills
|
|
@@ -65,21 +58,24 @@ export async function provisionInstance(
|
|
|
65
58
|
// timeout; an explicit --timeout still wins.
|
|
66
59
|
const createOpts = { ...opts, timeout: opts.timeout ?? SAGA_TIMEOUT_MS }
|
|
67
60
|
|
|
68
|
-
const runProvision = (
|
|
61
|
+
const runProvision = () =>
|
|
69
62
|
withSpinner(
|
|
70
63
|
`Provisioning instance ${slug}`,
|
|
71
64
|
!machine,
|
|
72
65
|
async () => {
|
|
73
|
-
const created = await createOwnedInstance(createOpts, slug
|
|
66
|
+
const created = await createOwnedInstance(createOpts, slug)
|
|
74
67
|
try {
|
|
75
68
|
// Org id from the create response — authoritative for token scoping
|
|
76
69
|
// (the router's /auth/org is eventually consistent).
|
|
77
|
-
const bookmarked = await upsertManagedBookmark(
|
|
78
|
-
slug,
|
|
70
|
+
const bookmarked = await upsertManagedBookmark({
|
|
71
|
+
key: slug,
|
|
79
72
|
slug,
|
|
80
|
-
created.url,
|
|
81
|
-
created.organizationId,
|
|
82
|
-
|
|
73
|
+
url: created.url,
|
|
74
|
+
...(created.organizationId ? { organizationId: created.organizationId } : {}),
|
|
75
|
+
...(authentication.defaultIdentity
|
|
76
|
+
? { defaultIdentity: authentication.defaultIdentity }
|
|
77
|
+
: {}),
|
|
78
|
+
})
|
|
83
79
|
repointedFrom = bookmarked.repointedFrom
|
|
84
80
|
await setActive(slug)
|
|
85
81
|
} catch (e) {
|
|
@@ -93,23 +89,7 @@ export async function provisionInstance(
|
|
|
93
89
|
},
|
|
94
90
|
)
|
|
95
91
|
|
|
96
|
-
|
|
97
|
-
// hosts). We recover ONLY from its multi-host ambiguity: pop a picker and
|
|
98
|
-
// retry with the chosen host_id — the ambiguity error throws before any
|
|
99
|
-
// provisioning side effect, so the retry is clean. No host / permission /
|
|
100
|
-
// capacity / a non-interactive run all reject through to the caller's `fatal`,
|
|
101
|
-
// surfacing the server's own message plus the listed ids. An explicit
|
|
102
|
-
// `--host-id` skips all of this.
|
|
103
|
-
const created = await runProvision(opts.hostId).catch(async (e: unknown) => {
|
|
104
|
-
const hostIds = !opts.hostId && interactive ? parseEligibleHostIds(e) : null
|
|
105
|
-
if (!hostIds) throw e
|
|
106
|
-
const chosen = await promptSelect(
|
|
107
|
-
`${hostIds.length} hosts available — pick one to provision on`,
|
|
108
|
-
hostIds.map((hid) => ({ name: hid, value: hid })),
|
|
109
|
-
)
|
|
110
|
-
if (!chosen) throw e
|
|
111
|
-
return runProvision(chosen)
|
|
112
|
-
})
|
|
92
|
+
const created = await runProvision()
|
|
113
93
|
|
|
114
94
|
// Warnings go to stderr so machine-readable stdout stays clean.
|
|
115
95
|
const warn = (msg: string) => console.error(chalk.yellow('⚠'), msg)
|
|
@@ -124,31 +104,28 @@ export async function provisionInstance(
|
|
|
124
104
|
return { created, slug, repointedFrom, ...(selectionError ? { selectionError } : {}) }
|
|
125
105
|
}
|
|
126
106
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (!match) return null
|
|
139
|
-
const ids = match[1]!
|
|
140
|
-
.split(',')
|
|
141
|
-
.map((s) => s.trim())
|
|
142
|
-
.filter(Boolean)
|
|
143
|
-
return ids.length > 1 ? ids : null
|
|
107
|
+
async function instanceCreateAuthentication(opts: ProvisionOpts): Promise<{
|
|
108
|
+
readonly opts: ProvisionOpts
|
|
109
|
+
readonly defaultIdentity?: string
|
|
110
|
+
}> {
|
|
111
|
+
if (opts.creds) return { opts }
|
|
112
|
+
const store = await readIdentities()
|
|
113
|
+
const defaultIdentity = selectInstanceCreateIdentity(store, opts)
|
|
114
|
+
return {
|
|
115
|
+
opts: opts.as ? opts : { ...opts, as: defaultIdentity },
|
|
116
|
+
defaultIdentity,
|
|
117
|
+
}
|
|
144
118
|
}
|
|
145
119
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
120
|
+
export function selectInstanceCreateIdentity(
|
|
121
|
+
store: IdentityStore,
|
|
122
|
+
opts: Pick<ProvisionOpts, 'as'> = {},
|
|
123
|
+
): string {
|
|
124
|
+
assertInstanceCreateIdentity(store, opts)
|
|
125
|
+
return opts.as ?? store.default
|
|
149
126
|
}
|
|
150
127
|
|
|
151
|
-
export function
|
|
128
|
+
export function assertInstanceCreateIdentity(
|
|
152
129
|
store: IdentityStore,
|
|
153
130
|
opts: Pick<ProvisionOpts, 'as'> = {},
|
|
154
131
|
): void {
|
|
@@ -187,7 +187,7 @@ describe('program composition', () => {
|
|
|
187
187
|
'whoami',
|
|
188
188
|
])
|
|
189
189
|
expect(createHash('sha256').update(JSON.stringify(surface)).digest('hex')).toBe(
|
|
190
|
-
'
|
|
190
|
+
'abd4d584cbefa707ecc2c939d990b0bfdff8f4d1b28bce9fe4b206fdd1a3e3e9',
|
|
191
191
|
)
|
|
192
192
|
})
|
|
193
193
|
|
|
@@ -213,6 +213,19 @@ describe('help contract — no internal SPEC anchors leak to users', () => {
|
|
|
213
213
|
|
|
214
214
|
expect(offenders).toEqual([])
|
|
215
215
|
})
|
|
216
|
+
|
|
217
|
+
test('Kernel and Instance vocabulary does not expose retired Host or Interface concepts', async () => {
|
|
218
|
+
const program = await buildProgram()
|
|
219
|
+
const help = allCommands(program)
|
|
220
|
+
.map((command) => command.helpInformation())
|
|
221
|
+
.join('\n')
|
|
222
|
+
|
|
223
|
+
expect(help).not.toContain('host.astrale.ai')
|
|
224
|
+
expect(help).not.toContain(':interface.')
|
|
225
|
+
expect(help).not.toContain('Class or Interface')
|
|
226
|
+
expect(help).not.toContain('emulated host')
|
|
227
|
+
expect(help).not.toContain('Host shell')
|
|
228
|
+
})
|
|
216
229
|
})
|
|
217
230
|
|
|
218
231
|
describe('help contract — IdP/auth surface is registered', () => {
|
|
@@ -251,14 +264,17 @@ describe('help contract — admin target surface is registered', () => {
|
|
|
251
264
|
expect(adminUse?.helpInformation()).not.toContain('--issuer <url>')
|
|
252
265
|
})
|
|
253
266
|
|
|
254
|
-
test('instance create
|
|
267
|
+
test('instance create delegates infrastructure placement to Admin', async () => {
|
|
255
268
|
const program = await buildProgram()
|
|
256
269
|
const instanceCreate = allCommands(program).find((command) => command.name() === 'create')
|
|
257
270
|
const help = instanceCreate?.helpInformation() ?? ''
|
|
258
271
|
|
|
259
|
-
expect(help).toContain('
|
|
272
|
+
expect(help).toContain('Provision an instance through the Admin control plane')
|
|
273
|
+
expect(help).not.toContain('Host')
|
|
274
|
+
expect(help).not.toContain('Fleet')
|
|
275
|
+
expect(help).not.toContain('--host-id')
|
|
260
276
|
expect(help).not.toContain('--no-use')
|
|
261
|
-
expect(help).not.toContain('Instance.init
|
|
277
|
+
expect(help).not.toContain('Instance.init')
|
|
262
278
|
})
|
|
263
279
|
})
|
|
264
280
|
|