@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.
Files changed (75) hide show
  1. package/README.md +1 -1
  2. package/dist/astrale.js +1531 -1356
  3. package/dist/public/connect-core.js +4 -4
  4. package/dist/types/admin/binding.d.ts +19 -0
  5. package/dist/types/admin/instance/client.d.ts +22 -0
  6. package/dist/types/admin/instance/index.d.ts +2 -0
  7. package/dist/types/admin/instance/model.d.ts +3 -9
  8. package/dist/types/connection/auth.d.ts +3 -0
  9. package/dist/types/connection/call.d.ts +3 -0
  10. package/dist/types/connection/command.d.ts +27 -0
  11. package/dist/types/connection/credential.d.ts +14 -0
  12. package/dist/types/connection/errors.d.ts +1 -0
  13. package/dist/types/connection/exchange.d.ts +9 -0
  14. package/dist/types/connection/failure/classify.d.ts +2 -0
  15. package/dist/types/connection/failure/index.d.ts +5 -0
  16. package/dist/types/connection/failure/model.d.ts +25 -0
  17. package/dist/types/connection/failure/render.d.ts +3 -0
  18. package/dist/types/connection/index.d.ts +8 -0
  19. package/dist/types/connection/reasons.d.ts +37 -0
  20. package/dist/types/connection/self.d.ts +37 -0
  21. package/dist/types/connection/session.d.ts +31 -0
  22. package/dist/types/connection/target.d.ts +38 -0
  23. package/dist/types/lib/admin-instance.d.ts +17 -0
  24. package/dist/types/lib/failure-debug.d.ts +1 -0
  25. package/dist/types/lib/instance.d.ts +7 -1
  26. package/dist/types/lib/log.d.ts +3 -1
  27. package/dist/types/lib/self.d.ts +9 -0
  28. package/package.json +1 -1
  29. package/src/admin/instance/__tests__/client.test.ts +4 -104
  30. package/src/admin/instance/client.ts +2 -96
  31. package/src/admin/instance/index.ts +1 -2
  32. package/src/admin/instance/model.ts +4 -14
  33. package/src/commands/__tests__/admin-instance.test.ts +11 -8
  34. package/src/commands/__tests__/instance-list-rows.test.ts +1 -1
  35. package/src/commands/call-describe.ts +1 -1
  36. package/src/commands/call.ts +2 -2
  37. package/src/commands/domain/list.ts +1 -1
  38. package/src/commands/domain/publish.ts +14 -11
  39. package/src/commands/get.ts +1 -1
  40. package/src/commands/instance/create.ts +8 -16
  41. package/src/commands/instance/delete.ts +7 -6
  42. package/src/commands/instance/list.ts +2 -2
  43. package/src/commands/instance/status.ts +4 -4
  44. package/src/commands/instance/use.ts +16 -12
  45. package/src/commands/introspect.ts +5 -5
  46. package/src/commands/view.ts +3 -3
  47. package/src/connection/__tests__/credential.test.ts +19 -0
  48. package/src/connection/__tests__/target.test.ts +1 -0
  49. package/src/connection/auth.ts +13 -0
  50. package/src/connection/failure/index.ts +1 -1
  51. package/src/connection/session.ts +12 -1
  52. package/src/lib/__tests__/command-dx.test.ts +1 -1
  53. package/src/lib/__tests__/domain-publication.test.ts +6 -1
  54. package/src/lib/__tests__/instance-candidates.test.ts +1 -3
  55. package/src/lib/__tests__/instance-target.test.ts +1 -0
  56. package/src/lib/__tests__/log-errors.test.ts +11 -0
  57. package/src/lib/admin-instance.ts +14 -8
  58. package/src/lib/command-dx.ts +8 -5
  59. package/src/lib/domain-publication.ts +8 -6
  60. package/src/lib/instance-candidates.ts +2 -2
  61. package/src/lib/instance.ts +14 -10
  62. package/src/lib/log.ts +6 -4
  63. package/src/lib/provision-instance.ts +34 -57
  64. package/src/program/__tests__/program.test.ts +20 -4
  65. package/src/program/build.ts +2 -2
  66. package/src/setup/__tests__/instance-step.test.ts +27 -10
  67. package/src/setup/steps/instance.ts +22 -15
  68. package/studio/client/dist/assets/{elk-api-Di4OXGNH.js → elk-api-ByzoDZWH.js} +1 -1
  69. package/studio/client/dist/assets/{index-DTjvkOUX.js → index-B0RCgUOT.js} +4 -4
  70. package/studio/client/dist/assets/{index-BLm6J11H.js → index-N8J8EKlB.js} +2 -2
  71. package/studio/client/dist/index.html +1 -1
  72. package/studio/server/introspect/runtime.test.ts +2 -2
  73. package/viewer/dist/main.js +28 -28
  74. package/src/commands/__tests__/instance-create-hosts.test.ts +0 -29
  75. /package/src/{connection/failure/debug.ts → lib/failure-debug.ts} +0 -0
@@ -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 an emulated host shell, authenticated as
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 an emulated host shell your agent can drive',
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 emulates the shell host (real
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[] = []
@@ -68,6 +68,7 @@ describe('connection target', () => {
68
68
  id: 'managed-id',
69
69
  slug,
70
70
  url: 'https://managed.example',
71
+ state: 'ready',
71
72
  }),
72
73
  }),
73
74
  ).toEqual({
@@ -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({ session, graph, auth: authApi, target }),
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 — eu',
143
+ 'twin (managed) https://twin.eu.astrale.ai/api',
146
144
  )
147
145
  })
148
146
  })
@@ -113,6 +113,7 @@ describe('resolveInstanceTarget', () => {
113
113
  id: 'node-1',
114
114
  slug,
115
115
  url: `https://${slug}.eu.astrale.ai`,
116
+ state: 'ready',
116
117
  }),
117
118
  },
118
119
  ),
@@ -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
- formatInstanceLocation,
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
- /** Provision through Fleet placement or one explicitly resolved Host receiver. */
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, hostId),
40
+ (await connectAdminInstances(context)).create(slug),
35
41
  )
36
42
  }
37
43
 
@@ -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 (!isMachine()) return text
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 root = deploymentRoot(input)
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
- function deploymentRoot(input: string): URL {
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(`Domain deployment URL must be an HTTP(S) origin: ${input}`)
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 { formatInstanceLocation, type OwnedInstanceInfo } from './admin-instance'
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 = formatInstanceLocation(candidate.info)
47
+ const extra = formatInstanceState(candidate.info)
48
48
  return `${candidate.key} (managed) ${candidate.url}${extra ? ` — ${extra}` : ''}`
49
49
  }
@@ -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
- key: string,
301
- slug: string,
302
- rawUrl: string,
303
- organizationId?: string,
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(rawUrl)
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
- process.exit(1)
33
+ } else {
34
+ log.error(`${code}: ${msg}`)
35
+ if (e instanceof AstraleError && e.hint) log.dim(` hint: ${e.hint}`)
33
36
  }
34
- log.error(`${code}: ${msg}`)
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 alpha instance through the admin kernel, then bookmark it and
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 → alphaCreate
41
- * bookmark → set-active), including the interactive multi-host picker.
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 assertAlphaCreateAuth(opts)
52
- // The created instance must belong to the LOGGED-IN identity — never to an
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 = (hostId: string | undefined) =>
61
+ const runProvision = () =>
69
62
  withSpinner(
70
63
  `Provisioning instance ${slug}`,
71
64
  !machine,
72
65
  async () => {
73
- const created = await createOwnedInstance(createOpts, slug, hostId)
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
- // Host placement is chosen SERVER-side (the caller's ready + USE-granted
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
- * Recover the eligible host ids from alphaCreate's multi-host error
129
- * ("N ready hosts are assigned (id1, id2). Specify host_id…") — only when
130
- * there's a real choice (>1). Returns null for any other error (no host,
131
- * permission, capacity). Deliberately coupled to that message wording (Option
132
- * B: no admin-side change); if it ever drifts we simply stop offering the
133
- * picker and the raw error is shown instead — no worse than before.
134
- */
135
- export function parseEligibleHostIds(error: unknown): string[] | null {
136
- const text = error instanceof Error ? error.message : String(error)
137
- const match = /ready hosts are assigned \(([^)]+)\)/.exec(text)
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
- async function assertAlphaCreateAuth(opts: Pick<ProvisionOpts, 'as' | 'creds'>): Promise<void> {
147
- if (opts.creds) return
148
- assertAlphaCreateIdentity(await readIdentities(), opts)
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 assertAlphaCreateIdentity(
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
- '42b4812abbbca3d54289a16d0b066dfb3b1caaf1df6597a425c675744466a728',
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 is the alphaCreate flow, not legacy Instance.init DX', async () => {
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('Instance.alphaCreate')
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 requires --host-id')
277
+ expect(help).not.toContain('Instance.init')
262
278
  })
263
279
  })
264
280