@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.
- package/dist/astrale.js +272 -198
- package/package.json +1 -1
- 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/call.ts +1 -1
- 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/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-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/view/port-allocation.ts +19 -0
- package/src/setup/__tests__/instance-step.test.ts +239 -0
- package/src/setup/steps/instance.ts +141 -61
- package/studio/client/dist/assets/index-huaFafBC.css +1 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/client/dist/assets/index-DKKMHBBC.css +0 -1
- /package/studio/client/dist/assets/{index-CyN5G8IA.js → index-DAC1a9vW.js} +0 -0
|
@@ -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 {
|
|
6
|
-
import {
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
*
|
|
52
|
-
*
|
|
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
|
-
|
|
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
|
}
|