@astrale-os/cli 1.0.0-beta.2 → 1.0.0-beta.20
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 +16 -1
- package/dist/astrale.js +51105 -58057
- package/dist/public/connect-core.js +2085 -30702
- package/dist/public/keys/index.js +884 -29476
- package/dist/public/paths/index.js +13101 -41452
- package/dist/types/admin/contract.d.ts +26 -0
- package/dist/types/admin/instance/client.d.ts +20 -0
- package/dist/types/admin/instance/index.d.ts +2 -0
- package/dist/types/admin/instance/model.d.ts +6 -10
- 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/errors.d.ts +1 -1
- package/dist/types/lib/admin-instance.d.ts +17 -0
- package/dist/types/lib/admin-target.d.ts +11 -36
- package/dist/types/lib/config.d.ts +7 -59
- package/dist/types/lib/failure-debug.d.ts +1 -0
- package/dist/types/lib/idp.d.ts +25 -173
- package/dist/types/lib/instance-target.d.ts +1 -1
- package/dist/types/lib/instance.d.ts +30 -88
- package/dist/types/lib/log.d.ts +5 -3
- package/dist/types/lib/output.d.ts +1 -1
- package/dist/types/lib/proc.d.ts +43 -0
- package/dist/types/lib/self.d.ts +9 -0
- package/dist/types/lib/update.d.ts +109 -0
- package/dist/types/lib/validation.d.ts +4 -1
- package/package.json +15 -20
- package/src/__tests__/fixtures/publication.ts +20 -0
- package/src/admin/.spec/architecture.md +12 -5
- package/src/admin/__tests__/fixture.ts +25 -0
- package/src/admin/catalog/.spec/api.d.ts +1 -3
- package/src/admin/catalog/.spec/architecture.md +5 -4
- package/src/admin/catalog/__tests__/client.test.ts +143 -69
- package/src/admin/catalog/client.ts +40 -54
- package/src/admin/catalog/model.ts +3 -4
- package/src/admin/contract.ts +46 -0
- package/src/admin/graph/.spec/api.d.ts +4 -10
- package/src/admin/graph/nodes.ts +1 -1
- package/src/admin/instance/.spec/api.d.ts +5 -9
- package/src/admin/instance/.spec/architecture.md +7 -7
- package/src/admin/instance/__tests__/client.test.ts +157 -175
- package/src/admin/instance/client.ts +39 -128
- package/src/admin/instance/index.ts +1 -2
- package/src/admin/instance/model.ts +12 -18
- package/src/commands/__tests__/admin-instance.test.ts +11 -8
- package/src/commands/__tests__/call-describe.test.ts +46 -33
- package/src/commands/__tests__/call.test.ts +34 -0
- package/src/commands/__tests__/domain-install-operation.test.ts +7 -22
- package/src/commands/__tests__/domain-install-owned.test.ts +1 -1
- package/src/commands/__tests__/domain-list.test.ts +5 -25
- package/src/commands/__tests__/install-direct.test.ts +115 -8
- package/src/commands/__tests__/install-identity-override.test.ts +3 -14
- package/src/commands/__tests__/instance-list-rows.test.ts +1 -1
- package/src/commands/__tests__/instance-status.test.ts +139 -0
- package/src/commands/__tests__/introspect-parse.test.ts +80 -1
- package/src/commands/__tests__/logs.test.ts +250 -2
- package/src/commands/__tests__/read-commands.test.ts +62 -10
- package/src/commands/__tests__/token-ttl.test.ts +49 -4
- package/src/commands/__tests__/update.test.ts +25 -3
- package/src/commands/__tests__/view.test.ts +49 -0
- package/src/commands/call-describe.ts +60 -68
- package/src/commands/call.ts +71 -59
- package/src/commands/domain/install.ts +54 -35
- package/src/commands/domain/list.ts +1 -1
- package/src/commands/domain/publish.ts +14 -11
- package/src/commands/domain/uninstall.ts +2 -2
- package/src/commands/get.ts +14 -15
- package/src/commands/identity/__tests__/register.test.ts +3 -3
- package/src/commands/identity/register.ts +5 -4
- package/src/commands/instance/create.ts +8 -16
- package/src/commands/instance/delete.ts +12 -9
- package/src/commands/instance/list.ts +2 -2
- package/src/commands/instance/status.ts +66 -20
- package/src/commands/instance/use.ts +16 -12
- package/src/commands/introspect.ts +48 -35
- package/src/commands/logs.ts +123 -33
- package/src/commands/mutate.ts +6 -5
- package/src/commands/query.ts +35 -32
- package/src/commands/token.ts +42 -27
- package/src/commands/ui/__tests__/commands.test.ts +152 -0
- package/src/commands/ui/add.ts +51 -0
- package/src/commands/ui/doctor.ts +13 -0
- package/src/commands/ui/init.ts +38 -0
- package/src/commands/ui/list.ts +26 -0
- package/src/commands/ui/preset-apply.ts +19 -0
- package/src/commands/ui/preset-list.ts +12 -0
- package/src/commands/ui/shared.ts +25 -0
- package/src/commands/update.ts +31 -11
- package/src/commands/view.ts +82 -37
- package/src/connection/.spec/api.d.ts +7 -8
- package/src/connection/.spec/architecture.md +5 -4
- package/src/connection/.spec/flows/session.ts +2 -2
- package/src/connection/.spec/laws/connection.ts +6 -2
- package/src/connection/.spec/layout.ts +1 -0
- package/src/connection/__tests__/credential.test.ts +20 -1
- package/src/connection/__tests__/errors.test.ts +27 -20
- package/src/connection/__tests__/exchange.test.ts +30 -1
- package/src/connection/__tests__/failure-fixtures.ts +39 -0
- package/src/connection/__tests__/failure-safety.test.ts +75 -0
- package/src/connection/__tests__/self.test.ts +31 -11
- package/src/connection/__tests__/session.test.ts +3 -3
- package/src/connection/__tests__/target.test.ts +1 -0
- package/src/connection/auth.ts +13 -0
- package/src/connection/call.ts +1 -1
- package/src/connection/credential.ts +2 -2
- package/src/connection/errors.ts +1 -355
- package/src/connection/exchange.ts +47 -23
- package/src/connection/failure/classify.ts +110 -0
- package/src/connection/failure/index.ts +18 -0
- package/src/connection/failure/model.ts +28 -0
- package/src/connection/failure/render.ts +116 -0
- package/src/connection/reasons.ts +16 -0
- package/src/connection/self.ts +39 -21
- package/src/connection/session.ts +19 -8
- package/src/errors.ts +3 -2
- package/src/graph/.spec/api.d.ts +12 -5
- package/src/graph/.spec/layout.ts +9 -1
- package/src/graph/__tests__/mutation.test.ts +2 -2
- package/src/graph/__tests__/query.test.ts +25 -6
- package/src/graph/class.ts +30 -0
- package/src/graph/index.ts +1 -0
- package/src/graph/query.ts +21 -32
- package/src/identity/.spec/api.d.ts +2 -2
- package/src/identity/__tests__/registration.test.ts +1 -1
- package/src/identity/registration.ts +3 -3
- package/src/keys/__tests__/keys.test.ts +4 -0
- package/src/keys/credential.ts +1 -0
- package/src/lib/__tests__/admin-target.test.ts +12 -0
- package/src/lib/__tests__/binary.test.ts +16 -1
- package/src/lib/__tests__/command-dx.test.ts +1 -1
- package/src/lib/__tests__/domain-publication.test.ts +10 -16
- package/src/lib/__tests__/idp.test.ts +34 -4
- package/src/lib/__tests__/instance-candidates.test.ts +1 -3
- package/src/lib/__tests__/instance-target.test.ts +7 -39
- package/src/lib/__tests__/log-errors.test.ts +64 -0
- package/src/lib/__tests__/studio-server-deps.test.ts +1 -1
- package/src/lib/__tests__/update.test.ts +264 -11
- package/src/lib/__tests__/view-assets.test.ts +14 -0
- package/src/lib/__tests__/view-external-open-intent.test.ts +150 -0
- package/src/lib/__tests__/view-external-open-origins.test.ts +27 -0
- package/src/lib/__tests__/view-open-intent.test.ts +0 -2
- package/src/lib/__tests__/view-server.test.ts +26 -3
- package/src/lib/__tests__/view-session.test.ts +2 -1
- package/src/lib/admin-instance.ts +14 -8
- package/src/lib/admin-target.ts +21 -2
- package/src/lib/binary.ts +22 -5
- package/src/lib/command-dx.ts +9 -9
- package/src/lib/domain-publication.ts +9 -7
- package/src/lib/failure-debug.ts +26 -0
- package/src/lib/idp.ts +34 -3
- package/src/lib/instance-candidates.ts +2 -2
- package/src/lib/instance-target.ts +3 -14
- package/src/lib/instance.ts +16 -11
- package/src/lib/log.ts +16 -9
- package/src/lib/output.ts +1 -1
- package/src/lib/proc.ts +7 -2
- package/src/lib/provision-instance.ts +34 -57
- package/src/lib/update.ts +298 -45
- package/src/lib/validation.ts +2 -2
- package/src/lib/view/assets.ts +11 -2
- package/src/lib/view/external-open-intent.ts +41 -0
- package/src/lib/view/external-open-origins.ts +37 -0
- package/src/lib/view/host-capabilities.ts +17 -0
- package/src/lib/view/resolve.ts +1 -1
- package/src/lib/view/server.ts +18 -3
- package/src/lib/view/session.ts +2 -0
- package/src/program/.spec/api.d.ts +1 -0
- package/src/program/__tests__/program.test.ts +163 -6
- package/src/program/argv.ts +14 -0
- package/src/program/build.ts +26 -4
- package/src/program/command.ts +1 -0
- package/src/program/index.ts +1 -0
- package/src/program/registry.ts +2 -1
- package/src/setup/__tests__/instance-step.test.ts +27 -10
- package/src/setup/steps/instance.ts +22 -15
- package/src/telemetry/__tests__/recorder.test.ts +1 -0
- package/src/telemetry/recorder.ts +9 -1
- package/src/ui/.spec/api.d.ts +14 -0
- package/src/ui/.spec/architecture.md +10 -0
- package/src/ui/.spec/laws.ts +36 -0
- package/src/ui/.spec/layout.ts +16 -0
- package/src/ui/__tests__/ui.test.ts +981 -0
- package/src/ui/index.ts +13 -0
- package/src/ui/lock.ts +87 -0
- package/src/ui/model.ts +83 -0
- package/src/ui/operations.ts +710 -0
- package/src/ui/project.ts +219 -0
- package/src/ui/release.ts +321 -0
- package/src/ui/runner.ts +18 -0
- package/studio/client/dist/assets/elk-api-DjmKsHXc.js +1 -0
- package/studio/client/dist/assets/index-7YHdeCxp.js +81 -0
- package/studio/client/dist/assets/index-C0FAnwNw.css +2 -0
- package/studio/client/dist/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
- package/studio/client/dist/assets/schema-studio-BilUNb6Z.css +1 -0
- package/studio/client/dist/assets/schema-studio-CABpTfvH.js +8 -0
- package/studio/client/dist/index.html +3 -2
- package/studio/package.json +7 -13
- package/studio/server/agent/ask.test.ts +1 -1
- package/studio/server/agent/bridge/grant.test.ts +1 -1
- package/studio/server/agent/bridge/routes.test.ts +1 -1
- package/studio/server/agent/harness/claude/adapter.test.ts +15 -5
- package/studio/server/agent/harness/gateway/token.test.ts +1 -1
- package/studio/server/agent/harness/gateway/token.ts +3 -3
- package/studio/server/agent/prompts/anchors.test.ts +32 -36
- package/studio/server/agent/prompts/anchors.ts +20 -49
- package/studio/server/agent/prompts/system.test.ts +3 -2
- package/studio/server/agent/prompts/system.ts +3 -3
- package/studio/server/agent/routes.test.ts +1 -1
- package/studio/server/agent/run/coordinator.test.ts +1 -1
- package/studio/server/api/canvas.ts +0 -1
- package/studio/server/api-agent-loadout.test.ts +1 -1
- package/studio/server/api.test.ts +1 -1
- package/studio/server/cache.test.ts +23 -12
- package/studio/server/cache.ts +12 -15
- package/studio/server/domain.test.ts +50 -68
- package/studio/server/domain.ts +82 -63
- package/studio/server/handoff/copy.ts +1 -1
- package/studio/server/index.ts +1 -1
- package/studio/server/instances/probe.test.ts +1 -1
- package/studio/server/introspect/anatomy/views/routes.ts +46 -37
- package/studio/server/introspect/anatomy/views.ts +9 -14
- package/studio/server/introspect/anatomy-extras.test.ts +26 -42
- package/studio/server/introspect/anatomy.test.ts +22 -14
- package/studio/server/introspect/anatomy.ts +6 -9
- package/studio/server/introspect/bundle.ts +0 -3
- package/studio/server/introspect/canonical-schema.test.ts +94 -389
- package/studio/server/introspect/canonical-schema.ts +227 -457
- package/studio/server/introspect/core-extractor.ts +18 -127
- package/studio/server/introspect/core.ts +1 -2
- package/studio/server/introspect/diff.test.ts +11 -7
- package/studio/server/introspect/diff.ts +18 -55
- package/studio/server/introspect/extractor.ts +22 -117
- package/studio/server/introspect/overlay-tsmorph.test.ts +119 -240
- package/studio/server/introspect/overlay.test.ts +26 -54
- package/studio/server/introspect/overlay.ts +10 -37
- package/studio/server/introspect/revision.test.ts +1 -1
- package/studio/server/introspect/revision.ts +1 -1
- package/studio/server/introspect/runtime.test.ts +65 -183
- package/studio/server/introspect/runtime.ts +8 -25
- package/studio/server/introspect/schema-ir-json.test.ts +70 -0
- package/studio/server/introspect/schema-ir-json.ts +38 -68
- package/studio/server/introspect/schema-refs.test.ts +43 -88
- package/studio/server/introspect/schema-refs.ts +14 -49
- package/studio/server/introspect/source-overlay/handlers.ts +116 -636
- package/studio/server/introspect/source-overlay/kernel-calls.ts +0 -3
- package/studio/server/introspect/source-overlay/spans.ts +15 -45
- package/studio/server/state/baseline.test.ts +14 -9
- package/studio/server/state/baseline.ts +3 -5
- package/studio/server/state/visibility.ts +1 -5
- package/studio/server/views/model.ts +3 -3
- package/studio/server/views/target.test.ts +17 -74
- package/studio/server/views/target.ts +24 -50
- package/studio/server/watch.test.ts +11 -11
- package/studio/server/watch.ts +8 -4
- package/studio/server/workspace-watch.ts +2 -2
- package/studio/shared/contracts/schema.ts +38 -107
- package/studio/shared/contracts/surface.test.ts +13 -15
- package/studio/shared/contracts/workspace.ts +2 -11
- package/studio/shared/schema/identity.test.ts +20 -44
- package/studio/shared/schema/identity.ts +9 -23
- package/viewer/dist/main.js +30 -51
- package/.check-workspace.cjs +0 -40
- package/src/admin/binding.ts +0 -168
- package/src/commands/__tests__/instance-create-hosts.test.ts +0 -29
- package/studio/client/dist/assets/elk-api-lwhFo7vB.js +0 -1
- package/studio/client/dist/assets/index-B-c-smo9.js +0 -8
- package/studio/client/dist/assets/index-BckHuAWk.js +0 -81
- package/studio/client/dist/assets/index-C4aNQ6dz.css +0 -1
- package/studio/client/dist/assets/index-ChOr3yP0.css +0 -1
- package/studio/server/introspect/anatomy/views/legacy.ts +0 -232
|
@@ -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 {
|
package/src/lib/update.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
chmod,
|
|
4
|
+
copyFile,
|
|
5
|
+
mkdir,
|
|
6
|
+
mkdtemp,
|
|
7
|
+
readFile,
|
|
8
|
+
realpath,
|
|
9
|
+
rename,
|
|
10
|
+
rm,
|
|
11
|
+
writeFile,
|
|
12
|
+
} from 'node:fs/promises'
|
|
3
13
|
import { tmpdir } from 'node:os'
|
|
4
14
|
import { dirname, join } from 'node:path'
|
|
5
15
|
import { z } from 'zod'
|
|
@@ -61,9 +71,26 @@ export type UpdateRequest = {
|
|
|
61
71
|
currentVersion: string
|
|
62
72
|
platform?: Platform
|
|
63
73
|
installPath?: string
|
|
74
|
+
execution?: UpdateExecution
|
|
64
75
|
}
|
|
65
76
|
|
|
77
|
+
export type UpdateExecution =
|
|
78
|
+
| { kind: 'standalone'; executable: string }
|
|
79
|
+
| { kind: 'package-managed'; executable: string }
|
|
80
|
+
|
|
81
|
+
const admittedScriptInstall = Symbol('admittedScriptInstall')
|
|
82
|
+
export type AdmittedScriptInstall = Readonly<{
|
|
83
|
+
metadata: InstallMetadata
|
|
84
|
+
executable: string
|
|
85
|
+
[admittedScriptInstall]: true
|
|
86
|
+
}>
|
|
87
|
+
|
|
66
88
|
export type UpdateResult =
|
|
89
|
+
| {
|
|
90
|
+
status: 'managed'
|
|
91
|
+
currentVersion: string
|
|
92
|
+
executable: string
|
|
93
|
+
}
|
|
67
94
|
| {
|
|
68
95
|
status: 'up-to-date'
|
|
69
96
|
currentVersion: string
|
|
@@ -106,50 +133,138 @@ export function platformKey(platform: Platform): string {
|
|
|
106
133
|
return `${platform.os}-${platform.arch}`
|
|
107
134
|
}
|
|
108
135
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
136
|
+
export function classifyUpdateExecution(input: {
|
|
137
|
+
bunVersion?: string
|
|
138
|
+
executable: string
|
|
139
|
+
entry?: string
|
|
140
|
+
}): UpdateExecution {
|
|
141
|
+
return input.bunVersion && input.entry?.startsWith('/$bunfs/')
|
|
142
|
+
? { kind: 'standalone', executable: input.executable }
|
|
143
|
+
: { kind: 'package-managed', executable: input.executable }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function detectUpdateExecution(): UpdateExecution {
|
|
147
|
+
return classifyUpdateExecution({
|
|
148
|
+
bunVersion: (process.versions as { bun?: string }).bun,
|
|
149
|
+
executable: process.execPath,
|
|
150
|
+
entry: process.argv[1],
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function packageManagedUpdateError(executable: string): AstraleError {
|
|
155
|
+
return new AstraleError(
|
|
156
|
+
'UPDATE_PACKAGE_MANAGED',
|
|
157
|
+
'This Astrale build is managed by your package manager.',
|
|
158
|
+
`Active binary: ${executable}. Update it with npm, pnpm, or bun; or put the official script installation first on PATH.`,
|
|
159
|
+
)
|
|
116
160
|
}
|
|
117
161
|
|
|
118
162
|
export async function readInstallMetadata(path = INSTALL_PATH): Promise<InstallMetadata> {
|
|
119
163
|
let raw: string
|
|
120
164
|
try {
|
|
121
165
|
raw = await readFile(path, 'utf8')
|
|
122
|
-
} catch {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
: new AstraleError(
|
|
130
|
-
'UPDATE_PACKAGE_MANAGED',
|
|
131
|
-
'This Astrale build is managed by your package manager.',
|
|
132
|
-
'Update with: npm install -g @astrale-os/cli@latest (or pnpm/bun)',
|
|
133
|
-
)
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (!isMissingFile(error)) throw error
|
|
168
|
+
throw new AstraleError(
|
|
169
|
+
'UPDATE_NOT_SCRIPT_INSTALLED',
|
|
170
|
+
'Astrale was not installed by the official install script.',
|
|
171
|
+
'Reinstall with: curl -fsSL https://raw.githubusercontent.com/astrale-os/cli/main/install.sh | sh',
|
|
172
|
+
)
|
|
134
173
|
}
|
|
135
174
|
|
|
136
|
-
|
|
175
|
+
let decoded: unknown
|
|
176
|
+
try {
|
|
177
|
+
decoded = JSON.parse(raw)
|
|
178
|
+
} catch {
|
|
179
|
+
throw badInstallMetadata(path)
|
|
180
|
+
}
|
|
181
|
+
const parsed = InstallMetadataSchema.safeParse(decoded)
|
|
137
182
|
if (!parsed.success) {
|
|
183
|
+
throw badInstallMetadata(path)
|
|
184
|
+
}
|
|
185
|
+
return parsed.data
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isMissingFile(error: unknown): error is NodeJS.ErrnoException {
|
|
189
|
+
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function badInstallMetadata(path: string): AstraleError {
|
|
193
|
+
return new AstraleError(
|
|
194
|
+
'UPDATE_BAD_INSTALL_METADATA',
|
|
195
|
+
`Invalid install metadata at ${path}.`,
|
|
196
|
+
'Reinstall with: curl -fsSL https://raw.githubusercontent.com/astrale-os/cli/main/install.sh | sh',
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function admitScriptInstall(
|
|
201
|
+
meta: InstallMetadata,
|
|
202
|
+
execution: Extract<UpdateExecution, { kind: 'standalone' }>,
|
|
203
|
+
): Promise<AdmittedScriptInstall> {
|
|
204
|
+
const [running, recorded] = await Promise.all([
|
|
205
|
+
realpathIfExists(execution.executable),
|
|
206
|
+
realpathIfExists(meta.bin),
|
|
207
|
+
])
|
|
208
|
+
if (running === undefined || recorded === undefined || running !== recorded) {
|
|
138
209
|
throw new AstraleError(
|
|
139
|
-
'
|
|
140
|
-
|
|
141
|
-
|
|
210
|
+
'UPDATE_INSTALL_MISMATCH',
|
|
211
|
+
'The running Astrale binary does not own the recorded script installation.',
|
|
212
|
+
`Running binary: ${execution.executable}; recorded binary: ${meta.bin}. Refusing to replace a different installation.`,
|
|
142
213
|
)
|
|
143
214
|
}
|
|
144
|
-
return
|
|
215
|
+
return Object.freeze({
|
|
216
|
+
metadata: meta,
|
|
217
|
+
executable: running,
|
|
218
|
+
[admittedScriptInstall]: true as const,
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function realpathIfExists(path: string): Promise<string | undefined> {
|
|
223
|
+
try {
|
|
224
|
+
return await realpath(path)
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (isMissingFile(error)) return undefined
|
|
227
|
+
throw error
|
|
228
|
+
}
|
|
145
229
|
}
|
|
146
230
|
|
|
147
231
|
export async function writeInstallMetadata(
|
|
148
232
|
meta: InstallMetadata,
|
|
149
233
|
path = INSTALL_PATH,
|
|
234
|
+
filesystem: Pick<CohortFilesystem, 'mkdir' | 'rename' | 'rm'> &
|
|
235
|
+
Pick<typeof import('node:fs/promises'), 'writeFile'> = { mkdir, rename, rm, writeFile },
|
|
150
236
|
): Promise<void> {
|
|
151
|
-
|
|
152
|
-
|
|
237
|
+
const staged = `${path}.next`
|
|
238
|
+
const previous = `${path}.previous`
|
|
239
|
+
await filesystem.mkdir(dirname(path), { recursive: true })
|
|
240
|
+
await filesystem.rm(staged, { force: true })
|
|
241
|
+
await filesystem.rm(previous, { force: true })
|
|
242
|
+
await filesystem.writeFile(staged, JSON.stringify(meta, null, 2) + '\n')
|
|
243
|
+
|
|
244
|
+
let backedUp = false
|
|
245
|
+
try {
|
|
246
|
+
try {
|
|
247
|
+
await filesystem.rename(path, previous)
|
|
248
|
+
backedUp = true
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (!isMissingFile(error)) throw error
|
|
251
|
+
}
|
|
252
|
+
await filesystem.rename(staged, path)
|
|
253
|
+
await filesystem.rm(previous, { force: true })
|
|
254
|
+
} catch (error) {
|
|
255
|
+
const rollback: unknown[] = []
|
|
256
|
+
if (backedUp) {
|
|
257
|
+
await filesystem.rename(previous, path).catch((failure) => rollback.push(failure))
|
|
258
|
+
}
|
|
259
|
+
await filesystem.rm(staged, { force: true }).catch((failure) => rollback.push(failure))
|
|
260
|
+
if (rollback.length > 0) {
|
|
261
|
+
throw new AggregateError(
|
|
262
|
+
[error, ...rollback],
|
|
263
|
+
'Install metadata update and rollback both failed.',
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
throw error
|
|
267
|
+
}
|
|
153
268
|
}
|
|
154
269
|
|
|
155
270
|
export function releaseBase(
|
|
@@ -174,8 +289,138 @@ export function shouldUpdate(currentVersion: string, manifestVersion: string): b
|
|
|
174
289
|
return currentVersion !== manifestVersion
|
|
175
290
|
}
|
|
176
291
|
|
|
177
|
-
|
|
178
|
-
|
|
292
|
+
interface CohortFilesystem {
|
|
293
|
+
readonly chmod: typeof chmod
|
|
294
|
+
readonly copyFile: typeof copyFile
|
|
295
|
+
readonly mkdir: typeof mkdir
|
|
296
|
+
readonly rename: typeof rename
|
|
297
|
+
readonly rm: typeof rm
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const defaultCohortFilesystem: CohortFilesystem = { chmod, copyFile, mkdir, rename, rm }
|
|
301
|
+
|
|
302
|
+
export interface StandaloneCohortReplacement {
|
|
303
|
+
readonly finalize: () => Promise<void>
|
|
304
|
+
readonly rollback: () => Promise<void>
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Replace the standalone executable and its Viewer as one rollback-safe cohort. */
|
|
308
|
+
export async function replaceStandaloneCohort(
|
|
309
|
+
installedBinary: string,
|
|
310
|
+
nextBinary: string,
|
|
311
|
+
nextViewerDist: string,
|
|
312
|
+
filesystem: Partial<CohortFilesystem> = {},
|
|
313
|
+
): Promise<StandaloneCohortReplacement> {
|
|
314
|
+
const fs = { ...defaultCohortFilesystem, ...filesystem }
|
|
315
|
+
const binDirectory = dirname(installedBinary)
|
|
316
|
+
const previousBinary = `${installedBinary}.previous`
|
|
317
|
+
const stagedBinary = `${installedBinary}.next`
|
|
318
|
+
const viewer = join(binDirectory, 'viewer')
|
|
319
|
+
const previousViewer = join(binDirectory, 'viewer.previous')
|
|
320
|
+
const stagedViewer = join(binDirectory, 'viewer.next')
|
|
321
|
+
const stagedViewerDist = join(stagedViewer, 'dist')
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
await fs.rm(stagedViewer, { recursive: true, force: true })
|
|
325
|
+
await fs.mkdir(stagedViewerDist, { recursive: true })
|
|
326
|
+
await fs.copyFile(join(nextViewerDist, 'main.js'), join(stagedViewerDist, 'main.js'))
|
|
327
|
+
await fs.copyFile(join(nextViewerDist, 'index.html'), join(stagedViewerDist, 'index.html'))
|
|
328
|
+
await fs.copyFile(installedBinary, previousBinary)
|
|
329
|
+
await fs.copyFile(nextBinary, stagedBinary)
|
|
330
|
+
await fs.chmod(stagedBinary, 0o755)
|
|
331
|
+
} catch (error) {
|
|
332
|
+
await fs.rm(stagedViewer, { recursive: true, force: true }).catch(() => undefined)
|
|
333
|
+
await fs.rm(stagedBinary, { force: true }).catch(() => undefined)
|
|
334
|
+
throw error
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
await fs.rm(previousViewer, { recursive: true, force: true })
|
|
338
|
+
let viewerBackedUp = false
|
|
339
|
+
let viewerCommitted = false
|
|
340
|
+
let binaryCommitted = false
|
|
341
|
+
try {
|
|
342
|
+
try {
|
|
343
|
+
await fs.rename(viewer, previousViewer)
|
|
344
|
+
viewerBackedUp = true
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
347
|
+
}
|
|
348
|
+
await fs.rename(stagedViewer, viewer)
|
|
349
|
+
viewerCommitted = true
|
|
350
|
+
await fs.rename(stagedBinary, installedBinary)
|
|
351
|
+
binaryCommitted = true
|
|
352
|
+
} catch (error) {
|
|
353
|
+
const rollback: unknown[] = []
|
|
354
|
+
if (binaryCommitted) {
|
|
355
|
+
await fs.copyFile(previousBinary, installedBinary).catch((failure) => rollback.push(failure))
|
|
356
|
+
await fs.chmod(installedBinary, 0o755).catch((failure) => rollback.push(failure))
|
|
357
|
+
}
|
|
358
|
+
if (viewerCommitted) {
|
|
359
|
+
await fs
|
|
360
|
+
.rm(viewer, { recursive: true, force: true })
|
|
361
|
+
.catch((failure) => rollback.push(failure))
|
|
362
|
+
}
|
|
363
|
+
if (viewerBackedUp) {
|
|
364
|
+
await fs.rename(previousViewer, viewer).catch((failure) => rollback.push(failure))
|
|
365
|
+
}
|
|
366
|
+
if (rollback.length > 0) {
|
|
367
|
+
throw new AggregateError([error, ...rollback], 'Standalone update and rollback both failed.')
|
|
368
|
+
}
|
|
369
|
+
throw error
|
|
370
|
+
} finally {
|
|
371
|
+
await fs.rm(stagedViewer, { recursive: true, force: true }).catch(() => undefined)
|
|
372
|
+
await fs.rm(stagedBinary, { force: true }).catch(() => undefined)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
let settled = false
|
|
376
|
+
return Object.freeze({
|
|
377
|
+
finalize: async () => {
|
|
378
|
+
if (settled) return
|
|
379
|
+
settled = true
|
|
380
|
+
await fs.rm(previousViewer, { recursive: true, force: true }).catch(() => undefined)
|
|
381
|
+
},
|
|
382
|
+
rollback: async () => {
|
|
383
|
+
if (settled) return
|
|
384
|
+
settled = true
|
|
385
|
+
const rollback: unknown[] = []
|
|
386
|
+
await fs.copyFile(previousBinary, installedBinary).catch((failure) => rollback.push(failure))
|
|
387
|
+
await fs.chmod(installedBinary, 0o755).catch((failure) => rollback.push(failure))
|
|
388
|
+
await fs
|
|
389
|
+
.rm(viewer, { recursive: true, force: true })
|
|
390
|
+
.catch((failure) => rollback.push(failure))
|
|
391
|
+
if (viewerBackedUp) {
|
|
392
|
+
await fs.rename(previousViewer, viewer).catch((failure) => rollback.push(failure))
|
|
393
|
+
}
|
|
394
|
+
if (rollback.length > 0) {
|
|
395
|
+
throw new AggregateError(rollback, 'Standalone update rollback failed.')
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
interface UpdateDependencies {
|
|
402
|
+
readonly replaceStandaloneCohort: typeof replaceStandaloneCohort
|
|
403
|
+
readonly writeInstallMetadata: typeof writeInstallMetadata
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const defaultUpdateDependencies = Object.freeze({ replaceStandaloneCohort, writeInstallMetadata })
|
|
407
|
+
|
|
408
|
+
export async function updateAstrale(
|
|
409
|
+
req: UpdateRequest,
|
|
410
|
+
dependencies: Partial<UpdateDependencies> = {},
|
|
411
|
+
): Promise<UpdateResult> {
|
|
412
|
+
const update = { ...defaultUpdateDependencies, ...dependencies }
|
|
413
|
+
const execution = req.execution ?? detectUpdateExecution()
|
|
414
|
+
if (execution.kind === 'package-managed') {
|
|
415
|
+
return {
|
|
416
|
+
status: 'managed',
|
|
417
|
+
currentVersion: req.currentVersion,
|
|
418
|
+
executable: execution.executable,
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const install = await admitScriptInstall(await readInstallMetadata(req.installPath), execution)
|
|
423
|
+
const meta = install.metadata
|
|
179
424
|
const currentVersion = meta.version ?? req.currentVersion
|
|
180
425
|
const channel = req.channel ?? meta.channel ?? DEFAULT_UPDATE_CHANNEL
|
|
181
426
|
const platform = req.platform ?? detectPlatform()
|
|
@@ -226,25 +471,33 @@ export async function updateAstrale(req: UpdateRequest): Promise<UpdateResult> {
|
|
|
226
471
|
|
|
227
472
|
await extractTarGz(archive, tmp)
|
|
228
473
|
const nextBin = join(tmp, 'astrale')
|
|
474
|
+
const nextViewer = join(tmp, 'viewer', 'dist')
|
|
229
475
|
await chmod(nextBin, 0o755)
|
|
230
476
|
await smokeVersion(nextBin, manifest.binaryVersion ?? manifest.version)
|
|
231
477
|
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
478
|
+
const replacement = await update.replaceStandaloneCohort(meta.bin, nextBin, nextViewer)
|
|
479
|
+
try {
|
|
480
|
+
await update.writeInstallMetadata(
|
|
481
|
+
{
|
|
482
|
+
...meta,
|
|
483
|
+
channel: manifest.channel,
|
|
484
|
+
version: manifest.version,
|
|
485
|
+
installedAt: new Date().toISOString(),
|
|
486
|
+
},
|
|
487
|
+
req.installPath,
|
|
488
|
+
)
|
|
489
|
+
} catch (error) {
|
|
490
|
+
try {
|
|
491
|
+
await replacement.rollback()
|
|
492
|
+
} catch (rollbackError) {
|
|
493
|
+
throw new AggregateError(
|
|
494
|
+
[error, rollbackError],
|
|
495
|
+
'Standalone update metadata commit and cohort rollback both failed.',
|
|
496
|
+
)
|
|
497
|
+
}
|
|
498
|
+
throw error
|
|
499
|
+
}
|
|
500
|
+
await replacement.finalize()
|
|
248
501
|
|
|
249
502
|
return {
|
|
250
503
|
status: 'updated',
|
package/src/lib/validation.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isDnsLabel } from '@astrale-os/sdk/
|
|
1
|
+
import { isDnsLabel } from '@astrale-os/sdk/value'
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
|
|
4
4
|
import { ReservedSlugError } from '../errors'
|
|
@@ -21,7 +21,7 @@ export function validateName(name: string, entity: string): void {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export function validateSlug(slug: string): void {
|
|
24
|
-
// Canonical DNS-label rule
|
|
24
|
+
// Canonical Core DNS-label rule: a slug becomes a
|
|
25
25
|
// hostname label, so reject anything not DNS-safe (§4.7).
|
|
26
26
|
if (!slug || !isDnsLabel(slug)) {
|
|
27
27
|
throw new Error(
|
package/src/lib/view/assets.ts
CHANGED
|
@@ -8,7 +8,11 @@ import { fileURLToPath } from 'node:url'
|
|
|
8
8
|
* (`<pkg>/viewer/dist`). The module URL is authoritative because npm global
|
|
9
9
|
* installs expose the CLI through a bin symlink outside the package root.
|
|
10
10
|
*/
|
|
11
|
-
export function viewerDistDir(
|
|
11
|
+
export function viewerDistDir(
|
|
12
|
+
moduleUrl = import.meta.url,
|
|
13
|
+
entry = process.argv[1] ?? '.',
|
|
14
|
+
executable = process.execPath,
|
|
15
|
+
): string {
|
|
12
16
|
const override = process.env.ASTRALE_VIEWER_DIR
|
|
13
17
|
if (override) return override
|
|
14
18
|
|
|
@@ -16,7 +20,12 @@ export function viewerDistDir(moduleUrl = import.meta.url, entry = process.argv[
|
|
|
16
20
|
const published = join(moduleDirectory, '..', 'viewer', 'dist')
|
|
17
21
|
const source = join(moduleDirectory, '..', '..', '..', 'viewer', 'dist')
|
|
18
22
|
const legacy = join(dirname(entry), '..', 'viewer', 'dist')
|
|
19
|
-
const
|
|
23
|
+
const standalone = entry.startsWith('/$bunfs/')
|
|
24
|
+
? join(dirname(executable), 'viewer', 'dist')
|
|
25
|
+
: undefined
|
|
26
|
+
const complete = [standalone, published, source, legacy].find(
|
|
27
|
+
(candidate): candidate is string => candidate !== undefined && hasViewerBundle(candidate),
|
|
28
|
+
)
|
|
20
29
|
if (complete) return complete
|
|
21
30
|
|
|
22
31
|
// A source checkout may intentionally omit generated dist assets. Keep the
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ExternalOpenRequest, IntentMessage, Shell } from '@astrale-os/shell'
|
|
2
|
+
|
|
3
|
+
import { replyToIntent } from '@astrale-os/shell'
|
|
4
|
+
|
|
5
|
+
export interface ExternalOpenIntentHost {
|
|
6
|
+
open(request: ExternalOpenRequest): boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Register the root host's browser-owned external navigation effect. */
|
|
10
|
+
export function installExternalOpenIntentHandler(
|
|
11
|
+
shell: Shell,
|
|
12
|
+
host: ExternalOpenIntentHost,
|
|
13
|
+
): () => void {
|
|
14
|
+
return shell.onIntent('browser.openExternal', (message) => {
|
|
15
|
+
const opened = host.open(message.envelope.payload)
|
|
16
|
+
replyToIntent(shell.children, message.envelope.sender.windowId, message, {
|
|
17
|
+
outcome: opened ? 'opened' : 'blocked',
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Open an external document without retaining a cross-origin opener capability. */
|
|
23
|
+
export function openExternalBrowserWindow(
|
|
24
|
+
browser: Pick<Window, 'open'>,
|
|
25
|
+
request: ExternalOpenRequest,
|
|
26
|
+
): boolean {
|
|
27
|
+
const popup = request.mode === 'popup'
|
|
28
|
+
const opened = browser.open('', '_blank', popup ? 'popup,width=720,height=760' : undefined)
|
|
29
|
+
if (opened === null) return false
|
|
30
|
+
try {
|
|
31
|
+
opened.opener = null
|
|
32
|
+
if (opened.opener !== null) throw new Error('Browser retained the opener capability.')
|
|
33
|
+
opened.location.replace(request.url)
|
|
34
|
+
} catch {
|
|
35
|
+
opened.close()
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type ExternalOpenIntentMessage = IntentMessage<'browser.openExternal'>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { AstraleError } from '../../errors.js'
|
|
2
|
+
|
|
3
|
+
/** Admit exact HTTPS origins explicitly granted by the CLI operator. */
|
|
4
|
+
export function admitExternalOpenOrigins(input: readonly string[] | undefined): readonly string[] {
|
|
5
|
+
if (input === undefined) return Object.freeze([])
|
|
6
|
+
const admitted = new Set<string>()
|
|
7
|
+
for (const candidate of input) {
|
|
8
|
+
let url: URL
|
|
9
|
+
try {
|
|
10
|
+
url = new URL(candidate)
|
|
11
|
+
} catch (cause) {
|
|
12
|
+
throw invalidExternalOrigin(candidate, cause)
|
|
13
|
+
}
|
|
14
|
+
if (
|
|
15
|
+
url.protocol !== 'https:' ||
|
|
16
|
+
url.hostname.includes('*') ||
|
|
17
|
+
url.username !== '' ||
|
|
18
|
+
url.password !== '' ||
|
|
19
|
+
url.pathname !== '/' ||
|
|
20
|
+
url.search !== '' ||
|
|
21
|
+
url.hash !== ''
|
|
22
|
+
) {
|
|
23
|
+
throw invalidExternalOrigin(candidate)
|
|
24
|
+
}
|
|
25
|
+
admitted.add(url.origin)
|
|
26
|
+
}
|
|
27
|
+
return Object.freeze([...admitted])
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function invalidExternalOrigin(candidate: string, cause?: unknown): AstraleError {
|
|
31
|
+
return new AstraleError(
|
|
32
|
+
'INVALID_EXTERNAL_ORIGIN',
|
|
33
|
+
`External navigation grant "${candidate}" is not an exact HTTPS origin.`,
|
|
34
|
+
'Pass an origin such as https://connect.example.com with no credentials, path, query, or fragment.',
|
|
35
|
+
cause === undefined ? undefined : { cause },
|
|
36
|
+
)
|
|
37
|
+
}
|