@astrale-os/cli 0.6.2-alpha.0 → 0.7.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.
@@ -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
+ })
@@ -1,30 +1,35 @@
1
1
  import chalk from 'chalk'
2
2
 
3
+ import type { OwnedInstanceInfo } from '../../lib/admin-instance'
3
4
  import type { SetupContext, SetupStep } from '../types'
4
5
 
5
- import { withAdminKernelClient } from '../../kernel/client'
6
- import { adminInstanceMethod, type InstanceInfo } from '../../lib/admin-instance'
6
+ import { AstraleError } from '../../errors'
7
+ import { listOwnedInstances } from '../../kernel/client'
7
8
  import { normalizeInstanceKernelUrl, setActive, upsertManagedBookmark } from '../../lib/instance'
8
9
  import { readLocalStatus } from '../../lib/local-status'
9
10
  import { log, withSpinner } from '../../lib/log'
10
11
  import { renderInstanceHero } from '../../lib/panel'
11
12
  import { confirmDefaultYes, promptText, selectFrom } from '../../lib/prompt'
12
- import { provisionInstance } from '../../lib/provision-instance'
13
+ import { provisionInstance, type ProvisionResult } from '../../lib/provision-instance'
13
14
  import { guiOrigin, slugError } from '../util'
14
15
 
15
- /** All admin-managed instances; degrades to [] when the admin kernel is unreachable. */
16
- async function fetchManaged(ctx: SetupContext): Promise<InstanceInfo[]> {
17
- try {
18
- return await withSpinner('Checking for existing instances', !ctx.machine, () =>
19
- withAdminKernelClient(
20
- ctx.opts,
21
- async (client) =>
22
- (await client.client.call(adminInstanceMethod('list'), {})) as InstanceInfo[],
23
- ),
24
- )
25
- } catch {
26
- return []
27
- }
16
+ export type InstanceSetupDependencies = {
17
+ fetchOwned: (ctx: SetupContext) => Promise<OwnedInstanceInfo[]>
18
+ adopt: (info: OwnedInstanceInfo) => Promise<void>
19
+ selectReady: (instances: OwnedInstanceInfo[]) => Promise<OwnedInstanceInfo | null>
20
+ confirmCreate: () => Promise<boolean>
21
+ promptSlug: () => Promise<string | undefined>
22
+ provision: (slug: string) => Promise<ProvisionResult>
23
+ }
24
+
25
+ export type OwnedInstanceAdoptionDependencies = {
26
+ upsert: (
27
+ key: string,
28
+ slug: string,
29
+ url: string,
30
+ organizationId?: string,
31
+ ) => Promise<{ repointedFrom?: string }>
32
+ activate: (slug: string) => Promise<unknown>
28
33
  }
29
34
 
30
35
  /** Print the click-inviting hero for a freshly-active instance. */
@@ -34,9 +39,15 @@ function hero(slug: string, kernelUrl: string): void {
34
39
  console.log('')
35
40
  }
36
41
 
37
- async function adopt(info: InstanceInfo): Promise<void> {
38
- const { repointedFrom } = await upsertManagedBookmark(info.slug, info.slug, info.url)
39
- await setActive(info.slug)
42
+ export async function adoptOwnedInstance(
43
+ info: OwnedInstanceInfo,
44
+ deps: OwnedInstanceAdoptionDependencies = {
45
+ upsert: upsertManagedBookmark,
46
+ activate: setActive,
47
+ },
48
+ ): Promise<void> {
49
+ const { repointedFrom } = await deps.upsert(info.slug, info.slug, info.url, info.organizationId)
50
+ await deps.activate(info.slug)
40
51
  if (repointedFrom) {
41
52
  log.warn(
42
53
  `Bookmark "${info.slug}" repointed: ${repointedFrom} → ${normalizeInstanceKernelUrl(info.url)}`,
@@ -46,10 +57,118 @@ async function adopt(info: InstanceInfo): Promise<void> {
46
57
  hero(info.slug, info.url)
47
58
  }
48
59
 
60
+ function defaultDependencies(ctx: SetupContext): InstanceSetupDependencies {
61
+ return {
62
+ fetchOwned: (setupCtx) =>
63
+ withSpinner('Checking for existing instances', !setupCtx.machine, () =>
64
+ listOwnedInstances(setupCtx.opts),
65
+ ),
66
+ adopt: adoptOwnedInstance,
67
+ selectReady: (instances) =>
68
+ selectFrom(
69
+ 'No active instance. Pick one:',
70
+ instances.map((info) => ({
71
+ label: `${info.slug} ${chalk.dim(guiOrigin(info.url))}`,
72
+ value: info,
73
+ })),
74
+ ),
75
+ confirmCreate: () => confirmDefaultYes('No instances yet. Provision your first one now?'),
76
+ promptSlug: () =>
77
+ ctx.slug
78
+ ? Promise.resolve(ctx.slug)
79
+ : promptText('Pick a slug for your instance', { validate: slugError }),
80
+ provision: (slug) => provisionInstance(slug, ctx.opts),
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Reconcile owner-scoped admin instances when no local active bookmark exists.
86
+ * A failed ownership lookup is never treated as an empty account: setup stops
87
+ * before creation so a transient auth/network failure cannot create a duplicate.
88
+ */
89
+ export async function ensureOwnedInstance(
90
+ ctx: SetupContext,
91
+ deps: InstanceSetupDependencies = defaultDependencies(ctx),
92
+ ): Promise<'fixed' | 'skipped'> {
93
+ let owned: OwnedInstanceInfo[]
94
+ try {
95
+ owned = await deps.fetchOwned(ctx)
96
+ } catch (cause) {
97
+ const detail = cause instanceof Error ? cause.message : String(cause)
98
+ throw new AstraleError(
99
+ 'INSTANCE_DISCOVERY_FAILED',
100
+ `Could not check your Astrale instances: ${detail}`,
101
+ 'No instance was created. Check `astrale admin status`, then rerun `astrale setup`.',
102
+ )
103
+ }
104
+
105
+ const ready = owned.filter((info) => info.state === 'ready')
106
+ if (ready.length === 1) {
107
+ await deps.adopt(ready[0]!)
108
+ return 'fixed'
109
+ }
110
+
111
+ if (ready.length > 1) {
112
+ const choice = await deps.selectReady(ready)
113
+ if (choice === null) {
114
+ log.dim(' Skipped — no active instance set.')
115
+ return 'skipped'
116
+ }
117
+ await deps.adopt(choice)
118
+ return 'fixed'
119
+ }
120
+
121
+ if (owned.length > 0) {
122
+ reportNotReady(owned)
123
+ return 'skipped'
124
+ }
125
+
126
+ if (!(await deps.confirmCreate())) {
127
+ log.dim(' Skipped — create one later: astrale instance create <slug>')
128
+ return 'skipped'
129
+ }
130
+
131
+ const slug = await deps.promptSlug()
132
+ if (!slug) {
133
+ log.dim(' Skipped — no slug given.')
134
+ return 'skipped'
135
+ }
136
+
137
+ const { created, selectionError } = await deps.provision(slug)
138
+ if (selectionError) {
139
+ const detail = selectionError instanceof Error ? selectionError.message : String(selectionError)
140
+ throw new AstraleError(
141
+ 'INSTANCE_SELECTION_FAILED',
142
+ `Instance "${slug}" was provisioned, but the CLI could not select it: ${detail}`,
143
+ `Fix local CLI storage, then run \`astrale instance use ${slug}\`.`,
144
+ )
145
+ }
146
+ hero(slug, created.url)
147
+ return 'fixed'
148
+ }
149
+
150
+ function reportNotReady(instances: OwnedInstanceInfo[]): void {
151
+ log.warn(
152
+ `${instances.length === 1 ? 'Your instance is' : 'Your instances are'} not ready; setup will not create another.`,
153
+ )
154
+ for (const info of instances) {
155
+ const phase = info.phase && info.phase !== info.state ? ` (${info.phase})` : ''
156
+ log.dim(` ${info.slug}: ${info.state}${phase} · astrale instance status ${info.slug}`)
157
+ if (info.error) log.dim(` ${info.error}`)
158
+ }
159
+ if (instances.some((info) => info.state === 'failed')) {
160
+ log.dim(
161
+ ' Inspect the failure, then deliberately delete/recreate it with `astrale instance` commands.',
162
+ )
163
+ } else {
164
+ log.dim(' Wait for provisioning to finish, then rerun `astrale setup`.')
165
+ }
166
+ }
167
+
49
168
  /**
50
169
  * Step 3 — an active instance, the heart of the flow. If the user already has
51
- * managed instances, offer to adopt one or create another; otherwise offer to
52
- * provision their first. Either way ends on the instance hero.
170
+ * owned instances, adopt the sole ready one or ask among several; only a
171
+ * confirmed empty owner list may fall through to first-instance provisioning.
53
172
  */
54
173
  export const instanceStep: SetupStep = {
55
174
  id: 'instance',
@@ -75,45 +194,6 @@ export const instanceStep: SetupStep = {
75
194
  return 'unchanged'
76
195
  }
77
196
 
78
- const managed = await fetchManaged(ctx)
79
-
80
- // Pick an existing instance or fall through to creating a new one.
81
- if (managed.length > 0) {
82
- const choice = await selectFrom<InstanceInfo | 'create'>(
83
- 'No active instance. Pick one or create a new instance:',
84
- [
85
- ...managed.map((info) => ({
86
- label: `${info.slug} ${chalk.dim(guiOrigin(info.url))}`,
87
- value: info as InstanceInfo | 'create',
88
- })),
89
- { label: chalk.cyan('➕ Create a new instance'), value: 'create' as const },
90
- ],
91
- )
92
- if (choice === null) {
93
- log.dim(' Skipped — no active instance set.')
94
- return 'skipped'
95
- }
96
- if (choice !== 'create') {
97
- await adopt(choice)
98
- return 'fixed'
99
- }
100
- } else {
101
- const yes = await confirmDefaultYes('No instances yet. Provision your first one now?')
102
- if (!yes) {
103
- log.dim(' Skipped — create one later: astrale instance create <slug>')
104
- return 'skipped'
105
- }
106
- }
107
-
108
- const slug =
109
- ctx.slug ?? (await promptText('Pick a slug for your instance', { validate: slugError }))
110
- if (!slug) {
111
- log.dim(' Skipped — no slug given.')
112
- return 'skipped'
113
- }
114
-
115
- const { created } = await provisionInstance(slug, ctx.opts)
116
- hero(slug, created.url)
117
- return 'fixed'
197
+ return ensureOwnedInstance(ctx)
118
198
  },
119
199
  }