@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.
Files changed (282) hide show
  1. package/README.md +16 -1
  2. package/dist/astrale.js +51105 -58057
  3. package/dist/public/connect-core.js +2085 -30702
  4. package/dist/public/keys/index.js +884 -29476
  5. package/dist/public/paths/index.js +13101 -41452
  6. package/dist/types/admin/contract.d.ts +26 -0
  7. package/dist/types/admin/instance/client.d.ts +20 -0
  8. package/dist/types/admin/instance/index.d.ts +2 -0
  9. package/dist/types/admin/instance/model.d.ts +6 -10
  10. package/dist/types/connection/auth.d.ts +3 -0
  11. package/dist/types/connection/call.d.ts +3 -0
  12. package/dist/types/connection/command.d.ts +27 -0
  13. package/dist/types/connection/credential.d.ts +14 -0
  14. package/dist/types/connection/errors.d.ts +1 -0
  15. package/dist/types/connection/exchange.d.ts +9 -0
  16. package/dist/types/connection/failure/classify.d.ts +2 -0
  17. package/dist/types/connection/failure/index.d.ts +5 -0
  18. package/dist/types/connection/failure/model.d.ts +25 -0
  19. package/dist/types/connection/failure/render.d.ts +3 -0
  20. package/dist/types/connection/index.d.ts +8 -0
  21. package/dist/types/connection/reasons.d.ts +37 -0
  22. package/dist/types/connection/self.d.ts +37 -0
  23. package/dist/types/connection/session.d.ts +31 -0
  24. package/dist/types/connection/target.d.ts +38 -0
  25. package/dist/types/errors.d.ts +1 -1
  26. package/dist/types/lib/admin-instance.d.ts +17 -0
  27. package/dist/types/lib/admin-target.d.ts +11 -36
  28. package/dist/types/lib/config.d.ts +7 -59
  29. package/dist/types/lib/failure-debug.d.ts +1 -0
  30. package/dist/types/lib/idp.d.ts +25 -173
  31. package/dist/types/lib/instance-target.d.ts +1 -1
  32. package/dist/types/lib/instance.d.ts +30 -88
  33. package/dist/types/lib/log.d.ts +5 -3
  34. package/dist/types/lib/output.d.ts +1 -1
  35. package/dist/types/lib/proc.d.ts +43 -0
  36. package/dist/types/lib/self.d.ts +9 -0
  37. package/dist/types/lib/update.d.ts +109 -0
  38. package/dist/types/lib/validation.d.ts +4 -1
  39. package/package.json +15 -20
  40. package/src/__tests__/fixtures/publication.ts +20 -0
  41. package/src/admin/.spec/architecture.md +12 -5
  42. package/src/admin/__tests__/fixture.ts +25 -0
  43. package/src/admin/catalog/.spec/api.d.ts +1 -3
  44. package/src/admin/catalog/.spec/architecture.md +5 -4
  45. package/src/admin/catalog/__tests__/client.test.ts +143 -69
  46. package/src/admin/catalog/client.ts +40 -54
  47. package/src/admin/catalog/model.ts +3 -4
  48. package/src/admin/contract.ts +46 -0
  49. package/src/admin/graph/.spec/api.d.ts +4 -10
  50. package/src/admin/graph/nodes.ts +1 -1
  51. package/src/admin/instance/.spec/api.d.ts +5 -9
  52. package/src/admin/instance/.spec/architecture.md +7 -7
  53. package/src/admin/instance/__tests__/client.test.ts +157 -175
  54. package/src/admin/instance/client.ts +39 -128
  55. package/src/admin/instance/index.ts +1 -2
  56. package/src/admin/instance/model.ts +12 -18
  57. package/src/commands/__tests__/admin-instance.test.ts +11 -8
  58. package/src/commands/__tests__/call-describe.test.ts +46 -33
  59. package/src/commands/__tests__/call.test.ts +34 -0
  60. package/src/commands/__tests__/domain-install-operation.test.ts +7 -22
  61. package/src/commands/__tests__/domain-install-owned.test.ts +1 -1
  62. package/src/commands/__tests__/domain-list.test.ts +5 -25
  63. package/src/commands/__tests__/install-direct.test.ts +115 -8
  64. package/src/commands/__tests__/install-identity-override.test.ts +3 -14
  65. package/src/commands/__tests__/instance-list-rows.test.ts +1 -1
  66. package/src/commands/__tests__/instance-status.test.ts +139 -0
  67. package/src/commands/__tests__/introspect-parse.test.ts +80 -1
  68. package/src/commands/__tests__/logs.test.ts +250 -2
  69. package/src/commands/__tests__/read-commands.test.ts +62 -10
  70. package/src/commands/__tests__/token-ttl.test.ts +49 -4
  71. package/src/commands/__tests__/update.test.ts +25 -3
  72. package/src/commands/__tests__/view.test.ts +49 -0
  73. package/src/commands/call-describe.ts +60 -68
  74. package/src/commands/call.ts +71 -59
  75. package/src/commands/domain/install.ts +54 -35
  76. package/src/commands/domain/list.ts +1 -1
  77. package/src/commands/domain/publish.ts +14 -11
  78. package/src/commands/domain/uninstall.ts +2 -2
  79. package/src/commands/get.ts +14 -15
  80. package/src/commands/identity/__tests__/register.test.ts +3 -3
  81. package/src/commands/identity/register.ts +5 -4
  82. package/src/commands/instance/create.ts +8 -16
  83. package/src/commands/instance/delete.ts +12 -9
  84. package/src/commands/instance/list.ts +2 -2
  85. package/src/commands/instance/status.ts +66 -20
  86. package/src/commands/instance/use.ts +16 -12
  87. package/src/commands/introspect.ts +48 -35
  88. package/src/commands/logs.ts +123 -33
  89. package/src/commands/mutate.ts +6 -5
  90. package/src/commands/query.ts +35 -32
  91. package/src/commands/token.ts +42 -27
  92. package/src/commands/ui/__tests__/commands.test.ts +152 -0
  93. package/src/commands/ui/add.ts +51 -0
  94. package/src/commands/ui/doctor.ts +13 -0
  95. package/src/commands/ui/init.ts +38 -0
  96. package/src/commands/ui/list.ts +26 -0
  97. package/src/commands/ui/preset-apply.ts +19 -0
  98. package/src/commands/ui/preset-list.ts +12 -0
  99. package/src/commands/ui/shared.ts +25 -0
  100. package/src/commands/update.ts +31 -11
  101. package/src/commands/view.ts +82 -37
  102. package/src/connection/.spec/api.d.ts +7 -8
  103. package/src/connection/.spec/architecture.md +5 -4
  104. package/src/connection/.spec/flows/session.ts +2 -2
  105. package/src/connection/.spec/laws/connection.ts +6 -2
  106. package/src/connection/.spec/layout.ts +1 -0
  107. package/src/connection/__tests__/credential.test.ts +20 -1
  108. package/src/connection/__tests__/errors.test.ts +27 -20
  109. package/src/connection/__tests__/exchange.test.ts +30 -1
  110. package/src/connection/__tests__/failure-fixtures.ts +39 -0
  111. package/src/connection/__tests__/failure-safety.test.ts +75 -0
  112. package/src/connection/__tests__/self.test.ts +31 -11
  113. package/src/connection/__tests__/session.test.ts +3 -3
  114. package/src/connection/__tests__/target.test.ts +1 -0
  115. package/src/connection/auth.ts +13 -0
  116. package/src/connection/call.ts +1 -1
  117. package/src/connection/credential.ts +2 -2
  118. package/src/connection/errors.ts +1 -355
  119. package/src/connection/exchange.ts +47 -23
  120. package/src/connection/failure/classify.ts +110 -0
  121. package/src/connection/failure/index.ts +18 -0
  122. package/src/connection/failure/model.ts +28 -0
  123. package/src/connection/failure/render.ts +116 -0
  124. package/src/connection/reasons.ts +16 -0
  125. package/src/connection/self.ts +39 -21
  126. package/src/connection/session.ts +19 -8
  127. package/src/errors.ts +3 -2
  128. package/src/graph/.spec/api.d.ts +12 -5
  129. package/src/graph/.spec/layout.ts +9 -1
  130. package/src/graph/__tests__/mutation.test.ts +2 -2
  131. package/src/graph/__tests__/query.test.ts +25 -6
  132. package/src/graph/class.ts +30 -0
  133. package/src/graph/index.ts +1 -0
  134. package/src/graph/query.ts +21 -32
  135. package/src/identity/.spec/api.d.ts +2 -2
  136. package/src/identity/__tests__/registration.test.ts +1 -1
  137. package/src/identity/registration.ts +3 -3
  138. package/src/keys/__tests__/keys.test.ts +4 -0
  139. package/src/keys/credential.ts +1 -0
  140. package/src/lib/__tests__/admin-target.test.ts +12 -0
  141. package/src/lib/__tests__/binary.test.ts +16 -1
  142. package/src/lib/__tests__/command-dx.test.ts +1 -1
  143. package/src/lib/__tests__/domain-publication.test.ts +10 -16
  144. package/src/lib/__tests__/idp.test.ts +34 -4
  145. package/src/lib/__tests__/instance-candidates.test.ts +1 -3
  146. package/src/lib/__tests__/instance-target.test.ts +7 -39
  147. package/src/lib/__tests__/log-errors.test.ts +64 -0
  148. package/src/lib/__tests__/studio-server-deps.test.ts +1 -1
  149. package/src/lib/__tests__/update.test.ts +264 -11
  150. package/src/lib/__tests__/view-assets.test.ts +14 -0
  151. package/src/lib/__tests__/view-external-open-intent.test.ts +150 -0
  152. package/src/lib/__tests__/view-external-open-origins.test.ts +27 -0
  153. package/src/lib/__tests__/view-open-intent.test.ts +0 -2
  154. package/src/lib/__tests__/view-server.test.ts +26 -3
  155. package/src/lib/__tests__/view-session.test.ts +2 -1
  156. package/src/lib/admin-instance.ts +14 -8
  157. package/src/lib/admin-target.ts +21 -2
  158. package/src/lib/binary.ts +22 -5
  159. package/src/lib/command-dx.ts +9 -9
  160. package/src/lib/domain-publication.ts +9 -7
  161. package/src/lib/failure-debug.ts +26 -0
  162. package/src/lib/idp.ts +34 -3
  163. package/src/lib/instance-candidates.ts +2 -2
  164. package/src/lib/instance-target.ts +3 -14
  165. package/src/lib/instance.ts +16 -11
  166. package/src/lib/log.ts +16 -9
  167. package/src/lib/output.ts +1 -1
  168. package/src/lib/proc.ts +7 -2
  169. package/src/lib/provision-instance.ts +34 -57
  170. package/src/lib/update.ts +298 -45
  171. package/src/lib/validation.ts +2 -2
  172. package/src/lib/view/assets.ts +11 -2
  173. package/src/lib/view/external-open-intent.ts +41 -0
  174. package/src/lib/view/external-open-origins.ts +37 -0
  175. package/src/lib/view/host-capabilities.ts +17 -0
  176. package/src/lib/view/resolve.ts +1 -1
  177. package/src/lib/view/server.ts +18 -3
  178. package/src/lib/view/session.ts +2 -0
  179. package/src/program/.spec/api.d.ts +1 -0
  180. package/src/program/__tests__/program.test.ts +163 -6
  181. package/src/program/argv.ts +14 -0
  182. package/src/program/build.ts +26 -4
  183. package/src/program/command.ts +1 -0
  184. package/src/program/index.ts +1 -0
  185. package/src/program/registry.ts +2 -1
  186. package/src/setup/__tests__/instance-step.test.ts +27 -10
  187. package/src/setup/steps/instance.ts +22 -15
  188. package/src/telemetry/__tests__/recorder.test.ts +1 -0
  189. package/src/telemetry/recorder.ts +9 -1
  190. package/src/ui/.spec/api.d.ts +14 -0
  191. package/src/ui/.spec/architecture.md +10 -0
  192. package/src/ui/.spec/laws.ts +36 -0
  193. package/src/ui/.spec/layout.ts +16 -0
  194. package/src/ui/__tests__/ui.test.ts +981 -0
  195. package/src/ui/index.ts +13 -0
  196. package/src/ui/lock.ts +87 -0
  197. package/src/ui/model.ts +83 -0
  198. package/src/ui/operations.ts +710 -0
  199. package/src/ui/project.ts +219 -0
  200. package/src/ui/release.ts +321 -0
  201. package/src/ui/runner.ts +18 -0
  202. package/studio/client/dist/assets/elk-api-DjmKsHXc.js +1 -0
  203. package/studio/client/dist/assets/index-7YHdeCxp.js +81 -0
  204. package/studio/client/dist/assets/index-C0FAnwNw.css +2 -0
  205. package/studio/client/dist/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
  206. package/studio/client/dist/assets/schema-studio-BilUNb6Z.css +1 -0
  207. package/studio/client/dist/assets/schema-studio-CABpTfvH.js +8 -0
  208. package/studio/client/dist/index.html +3 -2
  209. package/studio/package.json +7 -13
  210. package/studio/server/agent/ask.test.ts +1 -1
  211. package/studio/server/agent/bridge/grant.test.ts +1 -1
  212. package/studio/server/agent/bridge/routes.test.ts +1 -1
  213. package/studio/server/agent/harness/claude/adapter.test.ts +15 -5
  214. package/studio/server/agent/harness/gateway/token.test.ts +1 -1
  215. package/studio/server/agent/harness/gateway/token.ts +3 -3
  216. package/studio/server/agent/prompts/anchors.test.ts +32 -36
  217. package/studio/server/agent/prompts/anchors.ts +20 -49
  218. package/studio/server/agent/prompts/system.test.ts +3 -2
  219. package/studio/server/agent/prompts/system.ts +3 -3
  220. package/studio/server/agent/routes.test.ts +1 -1
  221. package/studio/server/agent/run/coordinator.test.ts +1 -1
  222. package/studio/server/api/canvas.ts +0 -1
  223. package/studio/server/api-agent-loadout.test.ts +1 -1
  224. package/studio/server/api.test.ts +1 -1
  225. package/studio/server/cache.test.ts +23 -12
  226. package/studio/server/cache.ts +12 -15
  227. package/studio/server/domain.test.ts +50 -68
  228. package/studio/server/domain.ts +82 -63
  229. package/studio/server/handoff/copy.ts +1 -1
  230. package/studio/server/index.ts +1 -1
  231. package/studio/server/instances/probe.test.ts +1 -1
  232. package/studio/server/introspect/anatomy/views/routes.ts +46 -37
  233. package/studio/server/introspect/anatomy/views.ts +9 -14
  234. package/studio/server/introspect/anatomy-extras.test.ts +26 -42
  235. package/studio/server/introspect/anatomy.test.ts +22 -14
  236. package/studio/server/introspect/anatomy.ts +6 -9
  237. package/studio/server/introspect/bundle.ts +0 -3
  238. package/studio/server/introspect/canonical-schema.test.ts +94 -389
  239. package/studio/server/introspect/canonical-schema.ts +227 -457
  240. package/studio/server/introspect/core-extractor.ts +18 -127
  241. package/studio/server/introspect/core.ts +1 -2
  242. package/studio/server/introspect/diff.test.ts +11 -7
  243. package/studio/server/introspect/diff.ts +18 -55
  244. package/studio/server/introspect/extractor.ts +22 -117
  245. package/studio/server/introspect/overlay-tsmorph.test.ts +119 -240
  246. package/studio/server/introspect/overlay.test.ts +26 -54
  247. package/studio/server/introspect/overlay.ts +10 -37
  248. package/studio/server/introspect/revision.test.ts +1 -1
  249. package/studio/server/introspect/revision.ts +1 -1
  250. package/studio/server/introspect/runtime.test.ts +65 -183
  251. package/studio/server/introspect/runtime.ts +8 -25
  252. package/studio/server/introspect/schema-ir-json.test.ts +70 -0
  253. package/studio/server/introspect/schema-ir-json.ts +38 -68
  254. package/studio/server/introspect/schema-refs.test.ts +43 -88
  255. package/studio/server/introspect/schema-refs.ts +14 -49
  256. package/studio/server/introspect/source-overlay/handlers.ts +116 -636
  257. package/studio/server/introspect/source-overlay/kernel-calls.ts +0 -3
  258. package/studio/server/introspect/source-overlay/spans.ts +15 -45
  259. package/studio/server/state/baseline.test.ts +14 -9
  260. package/studio/server/state/baseline.ts +3 -5
  261. package/studio/server/state/visibility.ts +1 -5
  262. package/studio/server/views/model.ts +3 -3
  263. package/studio/server/views/target.test.ts +17 -74
  264. package/studio/server/views/target.ts +24 -50
  265. package/studio/server/watch.test.ts +11 -11
  266. package/studio/server/watch.ts +8 -4
  267. package/studio/server/workspace-watch.ts +2 -2
  268. package/studio/shared/contracts/schema.ts +38 -107
  269. package/studio/shared/contracts/surface.test.ts +13 -15
  270. package/studio/shared/contracts/workspace.ts +2 -11
  271. package/studio/shared/schema/identity.test.ts +20 -44
  272. package/studio/shared/schema/identity.ts +9 -23
  273. package/viewer/dist/main.js +30 -51
  274. package/.check-workspace.cjs +0 -40
  275. package/src/admin/binding.ts +0 -168
  276. package/src/commands/__tests__/instance-create-hosts.test.ts +0 -29
  277. package/studio/client/dist/assets/elk-api-lwhFo7vB.js +0 -1
  278. package/studio/client/dist/assets/index-B-c-smo9.js +0 -8
  279. package/studio/client/dist/assets/index-BckHuAWk.js +0 -81
  280. package/studio/client/dist/assets/index-C4aNQ6dz.css +0 -1
  281. package/studio/client/dist/assets/index-ChOr3yP0.css +0 -1
  282. 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 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 {
package/src/lib/update.ts CHANGED
@@ -1,5 +1,15 @@
1
1
  import { createHash } from 'node:crypto'
2
- import { chmod, copyFile, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
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
- * True when running as the Bun-compiled standalone binary (Linux/macOS), which
111
- * self-updates by swapping its own file. The Node/npm build does not expose
112
- * `process.versions.bun`; it is managed by the user's package manager instead.
113
- */
114
- function isStandaloneBinary(): boolean {
115
- return Boolean((process.versions as { bun?: string }).bun)
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
- throw isStandaloneBinary()
124
- ? new AstraleError(
125
- 'UPDATE_NOT_SCRIPT_INSTALLED',
126
- 'Astrale was not installed by the official install script.',
127
- 'Reinstall with: curl -fsSL https://raw.githubusercontent.com/astrale-os/cli/main/install.sh | sh',
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
- const parsed = InstallMetadataSchema.safeParse(JSON.parse(raw))
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
- 'UPDATE_BAD_INSTALL_METADATA',
140
- `Invalid install metadata at ${path}.`,
141
- 'Reinstall with: curl -fsSL https://raw.githubusercontent.com/astrale-os/cli/main/install.sh | sh',
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 parsed.data
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
- await mkdir(dirname(path), { recursive: true })
152
- await writeFile(path, JSON.stringify(meta, null, 2) + '\n')
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
- export async function updateAstrale(req: UpdateRequest): Promise<UpdateResult> {
178
- const meta = await readInstallMetadata(req.installPath)
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 previous = `${meta.bin}.previous`
233
- const staged = `${meta.bin}.next`
234
- await copyFile(meta.bin, previous).catch(() => undefined)
235
- await copyFile(nextBin, staged)
236
- await chmod(staged, 0o755)
237
- await rename(staged, meta.bin)
238
-
239
- await writeInstallMetadata(
240
- {
241
- ...meta,
242
- channel: manifest.channel,
243
- version: manifest.version,
244
- installedAt: new Date().toISOString(),
245
- },
246
- req.installPath,
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',
@@ -1,4 +1,4 @@
1
- import { isDnsLabel } from '@astrale-os/sdk/domain'
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 (`@astrale-os/sdk/domain`): a slug becomes a
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(
@@ -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(moduleUrl = import.meta.url, entry = process.argv[1] ?? '.'): string {
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 complete = [published, source, legacy].find(hasViewerBundle)
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
+ }