@astrale-os/cli 0.4.0-alpha.13

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 (219) hide show
  1. package/.check-workspace.cjs +40 -0
  2. package/README.md +151 -0
  3. package/dist/astrale.js +59749 -0
  4. package/package.json +90 -0
  5. package/src/command.ts +40 -0
  6. package/src/commands/__tests__/admin-instance.test.ts +73 -0
  7. package/src/commands/__tests__/auth-login.test.ts +178 -0
  8. package/src/commands/__tests__/auth-token.test.ts +223 -0
  9. package/src/commands/__tests__/call.test.ts +72 -0
  10. package/src/commands/__tests__/domain-list.test.ts +74 -0
  11. package/src/commands/__tests__/help-contract.test.ts +136 -0
  12. package/src/commands/__tests__/install-identity-override.test.ts +65 -0
  13. package/src/commands/__tests__/instance-bookmark.test.ts +101 -0
  14. package/src/commands/__tests__/instance-create-hosts.test.ts +29 -0
  15. package/src/commands/__tests__/instance-list-rows.test.ts +63 -0
  16. package/src/commands/__tests__/logs.test.ts +117 -0
  17. package/src/commands/__tests__/ls.test.ts +25 -0
  18. package/src/commands/__tests__/setup-plan.test.ts +61 -0
  19. package/src/commands/admin/status.ts +61 -0
  20. package/src/commands/admin/use.ts +77 -0
  21. package/src/commands/auth/login.ts +82 -0
  22. package/src/commands/auth/logout.ts +50 -0
  23. package/src/commands/auth/status.ts +86 -0
  24. package/src/commands/auth/token.ts +162 -0
  25. package/src/commands/browser.ts +207 -0
  26. package/src/commands/call.ts +300 -0
  27. package/src/commands/describe.ts +182 -0
  28. package/src/commands/domain/install.ts +420 -0
  29. package/src/commands/domain/list.ts +154 -0
  30. package/src/commands/domain/publish.ts +155 -0
  31. package/src/commands/get.ts +60 -0
  32. package/src/commands/identity/create.ts +26 -0
  33. package/src/commands/identity/delete.ts +18 -0
  34. package/src/commands/identity/export.ts +66 -0
  35. package/src/commands/identity/import.ts +101 -0
  36. package/src/commands/identity/list.ts +56 -0
  37. package/src/commands/identity/register.ts +170 -0
  38. package/src/commands/identity/sync.ts +32 -0
  39. package/src/commands/identity/unsync.ts +24 -0
  40. package/src/commands/identity/use.ts +18 -0
  41. package/src/commands/identity/whoami.ts +34 -0
  42. package/src/commands/idp/add.ts +150 -0
  43. package/src/commands/idp/list.ts +57 -0
  44. package/src/commands/idp/refresh.ts +38 -0
  45. package/src/commands/idp/remove.ts +36 -0
  46. package/src/commands/idp/show.ts +29 -0
  47. package/src/commands/instance/active.ts +64 -0
  48. package/src/commands/instance/bookmark.ts +72 -0
  49. package/src/commands/instance/create.ts +69 -0
  50. package/src/commands/instance/delete.ts +72 -0
  51. package/src/commands/instance/forget.ts +26 -0
  52. package/src/commands/instance/list.ts +149 -0
  53. package/src/commands/instance/status.ts +42 -0
  54. package/src/commands/instance/use.ts +210 -0
  55. package/src/commands/logs.ts +347 -0
  56. package/src/commands/ls.ts +229 -0
  57. package/src/commands/query.ts +32 -0
  58. package/src/commands/setup.ts +54 -0
  59. package/src/commands/status.ts +60 -0
  60. package/src/commands/studio.ts +401 -0
  61. package/src/commands/token.ts +77 -0
  62. package/src/commands/update.ts +267 -0
  63. package/src/commands/use.ts +87 -0
  64. package/src/errors.ts +65 -0
  65. package/src/kernel/__tests__/auth.test.ts +77 -0
  66. package/src/kernel/__tests__/errors.test.ts +43 -0
  67. package/src/kernel/__tests__/remote-routing.test.ts +70 -0
  68. package/src/kernel/auth.ts +234 -0
  69. package/src/kernel/ca-fetch.ts +119 -0
  70. package/src/kernel/client.ts +191 -0
  71. package/src/kernel/errors.ts +280 -0
  72. package/src/kernel/expand.ts +217 -0
  73. package/src/kernel/index.ts +14 -0
  74. package/src/kernel/options.ts +22 -0
  75. package/src/kernel/remote-routing.ts +88 -0
  76. package/src/kernel/run.ts +63 -0
  77. package/src/kernel/types.ts +14 -0
  78. package/src/lib/__tests__/admin-target.test.ts +112 -0
  79. package/src/lib/__tests__/binary.test.ts +56 -0
  80. package/src/lib/__tests__/command-dx.test.ts +58 -0
  81. package/src/lib/__tests__/concurrency.test.ts +62 -0
  82. package/src/lib/__tests__/config.test.ts +53 -0
  83. package/src/lib/__tests__/design.test.ts +99 -0
  84. package/src/lib/__tests__/domain-identity.test.ts +60 -0
  85. package/src/lib/__tests__/format.test.ts +22 -0
  86. package/src/lib/__tests__/fs-atomic.test.ts +104 -0
  87. package/src/lib/__tests__/identity.test.ts +79 -0
  88. package/src/lib/__tests__/idp-session.driver.ts +53 -0
  89. package/src/lib/__tests__/idp-session.test.ts +357 -0
  90. package/src/lib/__tests__/idp.test.ts +385 -0
  91. package/src/lib/__tests__/instance-candidates.test.ts +73 -0
  92. package/src/lib/__tests__/instance-target.test.ts +183 -0
  93. package/src/lib/__tests__/instance.test.ts +136 -0
  94. package/src/lib/__tests__/keys.test.ts +129 -0
  95. package/src/lib/__tests__/local-status.test.ts +202 -0
  96. package/src/lib/__tests__/output.test.ts +150 -0
  97. package/src/lib/__tests__/panel.test.ts +40 -0
  98. package/src/lib/__tests__/port.test.ts +44 -0
  99. package/src/lib/__tests__/prompt.test.ts +25 -0
  100. package/src/lib/__tests__/sdk-deps.test.ts +68 -0
  101. package/src/lib/__tests__/self.test.ts +272 -0
  102. package/src/lib/__tests__/studio-server-deps.test.ts +74 -0
  103. package/src/lib/__tests__/table.test.ts +53 -0
  104. package/src/lib/__tests__/update.test.ts +246 -0
  105. package/src/lib/__tests__/use-target.test.ts +56 -0
  106. package/src/lib/__tests__/validation.test.ts +34 -0
  107. package/src/lib/admin-domain.ts +25 -0
  108. package/src/lib/admin-instance.ts +26 -0
  109. package/src/lib/admin-target.ts +217 -0
  110. package/src/lib/binary.ts +131 -0
  111. package/src/lib/browser.ts +150 -0
  112. package/src/lib/command-dx.ts +161 -0
  113. package/src/lib/concurrency.ts +31 -0
  114. package/src/lib/config.ts +45 -0
  115. package/src/lib/domain-identity.ts +49 -0
  116. package/src/lib/env.ts +49 -0
  117. package/src/lib/format.ts +4 -0
  118. package/src/lib/fs-atomic.ts +126 -0
  119. package/src/lib/identity.ts +256 -0
  120. package/src/lib/idp-session.ts +134 -0
  121. package/src/lib/idp.ts +876 -0
  122. package/src/lib/instance-candidates.ts +49 -0
  123. package/src/lib/instance-target.ts +182 -0
  124. package/src/lib/instance.ts +395 -0
  125. package/src/lib/keys.ts +294 -0
  126. package/src/lib/local-status.ts +152 -0
  127. package/src/lib/log.ts +116 -0
  128. package/src/lib/login-flow.ts +164 -0
  129. package/src/lib/meta.ts +86 -0
  130. package/src/lib/output.ts +222 -0
  131. package/src/lib/panel.ts +61 -0
  132. package/src/lib/paths.ts +11 -0
  133. package/src/lib/port.ts +41 -0
  134. package/src/lib/proc.ts +82 -0
  135. package/src/lib/prompt.ts +136 -0
  136. package/src/lib/provision-instance.ts +170 -0
  137. package/src/lib/sdk-deps.ts +104 -0
  138. package/src/lib/self.ts +166 -0
  139. package/src/lib/skills.ts +171 -0
  140. package/src/lib/table.ts +62 -0
  141. package/src/lib/update.ts +315 -0
  142. package/src/lib/use-target.ts +24 -0
  143. package/src/lib/validation.ts +59 -0
  144. package/src/program.ts +200 -0
  145. package/src/registry.ts +59 -0
  146. package/src/setup/__tests__/util.test.ts +29 -0
  147. package/src/setup/engine.ts +83 -0
  148. package/src/setup/render.ts +109 -0
  149. package/src/setup/steps/admin.ts +78 -0
  150. package/src/setup/steps/agent-browser.ts +81 -0
  151. package/src/setup/steps/auth.ts +54 -0
  152. package/src/setup/steps/domain.ts +68 -0
  153. package/src/setup/steps/index.ts +18 -0
  154. package/src/setup/steps/instance.ts +119 -0
  155. package/src/setup/steps/skills-bridge.ts +70 -0
  156. package/src/setup/steps/skills.ts +59 -0
  157. package/src/setup/types.ts +61 -0
  158. package/src/setup/util.ts +34 -0
  159. package/src/test-utils.ts +18 -0
  160. package/studio/client/dist/assets/index-DOwzZAEK.css +1 -0
  161. package/studio/client/dist/assets/index-wtU0Zxhy.js +183 -0
  162. package/studio/client/dist/index.html +13 -0
  163. package/studio/package.json +62 -0
  164. package/studio/server/agent/ask.ts +68 -0
  165. package/studio/server/agent/bridge-mcp.ts +182 -0
  166. package/studio/server/agent/bridge.ts +188 -0
  167. package/studio/server/agent/claude.ts +666 -0
  168. package/studio/server/agent/mock.ts +186 -0
  169. package/studio/server/agent/prompt.ts +202 -0
  170. package/studio/server/agent/registry.ts +29 -0
  171. package/studio/server/agent/runner.ts +484 -0
  172. package/studio/server/agent/schema-map.ts +112 -0
  173. package/studio/server/agent/types.ts +120 -0
  174. package/studio/server/api.ts +574 -0
  175. package/studio/server/cache.ts +138 -0
  176. package/studio/server/detect.ts +81 -0
  177. package/studio/server/domain.ts +70 -0
  178. package/studio/server/index.ts +136 -0
  179. package/studio/server/introspect/anatomy-extras.ts +398 -0
  180. package/studio/server/introspect/anatomy.ts +108 -0
  181. package/studio/server/introspect/bundle.ts +57 -0
  182. package/studio/server/introspect/core-extractor.ts +119 -0
  183. package/studio/server/introspect/core.ts +44 -0
  184. package/studio/server/introspect/diff.ts +133 -0
  185. package/studio/server/introspect/extractor.ts +102 -0
  186. package/studio/server/introspect/hash.ts +21 -0
  187. package/studio/server/introspect/overlay-tsmorph.ts +874 -0
  188. package/studio/server/introspect/overlay.ts +57 -0
  189. package/studio/server/introspect/runtime.ts +99 -0
  190. package/studio/server/introspect/schema-refs.ts +46 -0
  191. package/studio/server/lifecycle.ts +38 -0
  192. package/studio/server/sse.ts +57 -0
  193. package/studio/server/state/baseline.ts +211 -0
  194. package/studio/server/state/catalog.ts +117 -0
  195. package/studio/server/state/comments.ts +321 -0
  196. package/studio/server/state/context.ts +167 -0
  197. package/studio/server/state/copy.ts +156 -0
  198. package/studio/server/state/create.ts +156 -0
  199. package/studio/server/state/documents.ts +70 -0
  200. package/studio/server/state/env.ts +161 -0
  201. package/studio/server/state/git.ts +75 -0
  202. package/studio/server/state/handoff.ts +55 -0
  203. package/studio/server/state/harness-gateway.ts +181 -0
  204. package/studio/server/state/harness-token.ts +0 -0
  205. package/studio/server/state/instance.ts +244 -0
  206. package/studio/server/state/integrations.ts +55 -0
  207. package/studio/server/state/layout.ts +55 -0
  208. package/studio/server/state/settings.ts +39 -0
  209. package/studio/server/state/store.ts +97 -0
  210. package/studio/server/state/updates.ts +63 -0
  211. package/studio/server/state/usage.ts +37 -0
  212. package/studio/server/state/views.ts +138 -0
  213. package/studio/server/state/visibility.ts +33 -0
  214. package/studio/server/watch.ts +81 -0
  215. package/studio/server/workspace-state.ts +26 -0
  216. package/studio/server/workspace-watch.ts +101 -0
  217. package/studio/shared/types.ts +873 -0
  218. package/studio/tsconfig.json +23 -0
  219. package/tsconfig.json +14 -0
@@ -0,0 +1,171 @@
1
+ import { existsSync, lstatSync, mkdirSync, readlinkSync, symlinkSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+
5
+ import { findAgentBrowser } from './browser'
6
+ import { run } from './proc'
7
+
8
+ /**
9
+ * Agent skills and the agent-browser tool are owned by the coding-agent harness
10
+ * (Claude Code et al.) and by npm — not by this CLI. `astrale setup` and
11
+ * `astrale update` only *detect* them and delegate installation to their real
12
+ * installers (`npx skills add`, `npm i -g agent-browser`). This module is that
13
+ * detection + delegation layer.
14
+ */
15
+
16
+ /** The skill name the agent harness looks up under `<dir>/<name>/SKILL.md`. */
17
+ export const ASTRALE_CLI_SKILL = 'astrale-cli'
18
+ export const ASTRALE_DOMAIN_SKILL = 'astrale-domain'
19
+ export const AGENT_BROWSER_SKILL = 'agent-browser'
20
+
21
+ /**
22
+ * Published skill source consumed by `npx skills add`. The public `astrale-os/cli`
23
+ * repo hosts BOTH the astrale-cli and astrale-domain skills (under `skills/`), so
24
+ * one `npx skills add astrale-os/cli` installs both; address one with
25
+ * `astrale-os/cli@astrale-cli` or `astrale-os/cli@astrale-domain`.
26
+ */
27
+ export const ASTRALE_CLI_SKILL_SOURCE = 'astrale-os/cli'
28
+
29
+ /** Human-facing command that installs (or refreshes) both astrale skills, globally. */
30
+ export const SKILL_INSTALL_HINT = `npx skills add ${ASTRALE_CLI_SKILL_SOURCE} -g`
31
+
32
+ /**
33
+ * Install or refresh the astrale agent skills by delegating to the skill package
34
+ * manager (`npx skills add`). We install GLOBALLY (`-g`, user-level): one run
35
+ * equips every project on the machine — the harness resolves the skills from the
36
+ * user Claude dir (see {@link detectSkill}) regardless of cwd, and installs land
37
+ * in `~/.agents/skills` rather than clobbering any project's own `.agents/skills`
38
+ * (notably this repo's symlinked source). We don't reimplement skill installation
39
+ * — re-running the real installer is also how an existing install updates to the
40
+ * latest published SKILL.md, so this doubles as the update path.
41
+ *
42
+ * We CAPTURE the installer's output rather than stream it, and surface it only on
43
+ * failure. A global install reports a benign per-agent note for any agent format
44
+ * that can't go user-level (e.g. PromptScript: "does not support global skill
45
+ * installation") as a "Failed to install N" banner — even though `npx skills`
46
+ * still exits 0 and the skills land for every other agent. Streaming that made a
47
+ * successful refresh look broken; suppressing it on success lets the caller print
48
+ * a clean line. Resolves true on exit 0. Requires Node/`npx` on PATH + network.
49
+ */
50
+ export async function installSkills(): Promise<boolean> {
51
+ const { code, stdout, stderr } = await run('npx', [
52
+ 'skills',
53
+ 'add',
54
+ ASTRALE_CLI_SKILL_SOURCE,
55
+ '-g',
56
+ '-y',
57
+ ])
58
+ if (code !== 0) process.stderr.write(stdout + stderr)
59
+ return code === 0
60
+ }
61
+
62
+ /**
63
+ * The directories the agent harness ACTUALLY loads skills from, in resolution
64
+ * order: a project `.claude/skills` found by walking UP from cwd (the harness
65
+ * walks up to the project root), then the user-global Claude dir.
66
+ *
67
+ * Deliberately NOT `.agents/skills`: the harness never reads it, so a skill
68
+ * present only there is on disk but NOT loaded — the false "installed" this
69
+ * used to report, which let `astrale setup` mark a skill satisfied while the
70
+ * agent couldn't see it. `.agents/skills` is a SOURCE convention; it only loads
71
+ * once bridged into `.claude/skills` (e.g. `.claude/skills` symlinked to
72
+ * `.agents/skills`, as this monorepo wires itself — see {@link ensureSkillsBridge}).
73
+ * We probe for `SKILL.md` (not the directory) so a symlinked skill still resolves.
74
+ */
75
+ function skillSearchDirs(): string[] {
76
+ const dirs: string[] = []
77
+ let cur = process.cwd()
78
+ for (;;) {
79
+ dirs.push(join(cur, '.claude', 'skills'))
80
+ const parent = dirname(cur)
81
+ if (parent === cur) break
82
+ cur = parent
83
+ }
84
+ dirs.push(join(homedir(), '.claude', 'skills'))
85
+ return dirs
86
+ }
87
+
88
+ export type SkillPresence = { installed: boolean; location: string | null }
89
+
90
+ /** Is `<name>/SKILL.md` present in a directory the harness actually loads from? */
91
+ export function detectSkill(name: string): SkillPresence {
92
+ for (const dir of skillSearchDirs()) {
93
+ const file = join(dir, name, 'SKILL.md')
94
+ if (existsSync(file)) return { installed: true, location: file }
95
+ }
96
+ return { installed: false, location: null }
97
+ }
98
+
99
+ /** Is the `agent-browser` binary resolvable on PATH? */
100
+ export async function detectAgentBrowser(): Promise<boolean> {
101
+ return (await findAgentBrowser()) !== null
102
+ }
103
+
104
+ /* ───────────────────────────── skills bridge ─────────────────────────────
105
+ * Agent tooling stages skills under `.agents/skills`, but the harness only loads
106
+ * `.claude/skills` (walking up). A single `.claude/skills -> ../.agents/skills`
107
+ * symlink bridges EVERY staged skill — present and future — for a workspace and
108
+ * every project nested under it. This is exactly how this monorepo wires itself.
109
+ * It complements the global `-g` installs above: those equip machine-wide skills
110
+ * the CLI owns; this makes a workspace's own staged skills loadable. */
111
+
112
+ /** The relative target every bridge points at, from `<root>/.claude/skills`. */
113
+ const BRIDGE_TARGET = join('..', '.agents', 'skills')
114
+
115
+ function isSymlink(p: string): boolean {
116
+ try {
117
+ return lstatSync(p).isSymbolicLink()
118
+ } catch {
119
+ return false
120
+ }
121
+ }
122
+
123
+ /** Nearest ancestor of `fromDir` (inclusive) that stages skills under
124
+ * `.agents/skills` — the "skills workspace root". null if none up the tree. */
125
+ function findAgentsSkillsRoot(fromDir: string): string | null {
126
+ let cur = fromDir
127
+ for (;;) {
128
+ if (existsSync(join(cur, '.agents', 'skills'))) return cur
129
+ const parent = dirname(cur)
130
+ if (parent === cur) return null
131
+ cur = parent
132
+ }
133
+ }
134
+
135
+ export type BridgeStatus =
136
+ /** no `.agents/skills` up the tree → nothing to bridge */
137
+ | { kind: 'none' }
138
+ /** `.claude/skills` already symlinks to `.agents/skills` → harness loads them */
139
+ | { kind: 'bridged'; root: string }
140
+ /** `.claude/skills` exists but isn't our bridge (a real dir / foreign link) → leave it */
141
+ | { kind: 'foreign'; root: string }
142
+ /** `.agents/skills` present, no `.claude/skills` → we can create the bridge */
143
+ | { kind: 'unbridged'; root: string; link: string }
144
+
145
+ /** Read-only: is the workspace's `.agents/skills` visible to the harness? */
146
+ export function skillsBridgeStatus(fromDir: string = process.cwd()): BridgeStatus {
147
+ const root = findAgentsSkillsRoot(fromDir)
148
+ if (!root) return { kind: 'none' }
149
+ const link = join(root, '.claude', 'skills')
150
+ if (isSymlink(link)) {
151
+ try {
152
+ if (readlinkSync(link) === BRIDGE_TARGET) return { kind: 'bridged', root }
153
+ } catch {
154
+ /* unreadable link → treat as foreign, don't touch it */
155
+ }
156
+ return { kind: 'foreign', root }
157
+ }
158
+ if (existsSync(link)) return { kind: 'foreign', root }
159
+ return { kind: 'unbridged', root, link }
160
+ }
161
+
162
+ /** Create the `.claude/skills -> ../.agents/skills` bridge if it's missing.
163
+ * Idempotent and NON-destructive: a correct bridge is left as-is; a pre-existing
164
+ * real `.claude/skills` (or a foreign symlink) is never clobbered. */
165
+ export function ensureSkillsBridge(fromDir: string = process.cwd()): BridgeStatus {
166
+ const status = skillsBridgeStatus(fromDir)
167
+ if (status.kind !== 'unbridged') return status
168
+ mkdirSync(dirname(status.link), { recursive: true })
169
+ symlinkSync(BRIDGE_TARGET, status.link)
170
+ return { kind: 'bridged', root: status.root }
171
+ }
@@ -0,0 +1,62 @@
1
+ import chalk from 'chalk'
2
+
3
+ /** A display column: which row key to read, its header, and an optional cell color. */
4
+ export type Column = { key: string; header: string; color?: (s: string) => string }
5
+
6
+ const GUTTER = ' '
7
+ // ESC[…m — built without a control char in the source so no lint disable is needed.
8
+ const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g')
9
+
10
+ /** Printable width of a string, ignoring ANSI color codes. */
11
+ function visibleWidth(s: string): number {
12
+ return s.replace(ANSI, '').length
13
+ }
14
+
15
+ /** Pad to a target *visible* width (color codes don't count toward length). */
16
+ function pad(s: string, width: number): string {
17
+ const diff = width - visibleWidth(s)
18
+ return diff > 0 ? s + ' '.repeat(diff) : s
19
+ }
20
+
21
+ /**
22
+ * Render an aligned, optionally-colored table.
23
+ *
24
+ * Cells may carry ANSI (e.g. a per-row status color) — widths are computed on
25
+ * visible length so alignment stays correct. `Column.color` colors a whole
26
+ * column of otherwise-plain cells. Columns empty across every row are dropped;
27
+ * the last column is left unpadded so there's no trailing whitespace.
28
+ */
29
+ export function renderTable(
30
+ rows: Array<Record<string, string>>,
31
+ opts: { columns: Column[]; showHeader?: boolean },
32
+ ): string {
33
+ if (rows.length === 0) return chalk.dim(' (empty)')
34
+
35
+ const cols = opts.columns.filter((c) => rows.some((r) => (r[c.key] ?? '') !== ''))
36
+ if (cols.length === 0) return chalk.dim(' (empty)')
37
+
38
+ const widths = cols.map((c) =>
39
+ Math.max(
40
+ opts.showHeader ? c.header.length : 0,
41
+ ...rows.map((r) => visibleWidth(r[c.key] ?? '')),
42
+ ),
43
+ )
44
+
45
+ const line = (cell: (col: Column, i: number) => string): string =>
46
+ ' ' + cols.map(cell).join(GUTTER).replace(/\s+$/, '')
47
+
48
+ const lines: string[] = []
49
+ if (opts.showHeader) {
50
+ lines.push(line((c, i) => chalk.dim(pad(c.header, widths[i]))))
51
+ }
52
+ for (const r of rows) {
53
+ lines.push(
54
+ line((c, i) => {
55
+ const raw = r[c.key] ?? ''
56
+ const text = i === cols.length - 1 ? raw : pad(raw, widths[i])
57
+ return c.color ? c.color(text) : text
58
+ }),
59
+ )
60
+ }
61
+ return lines.join('\n')
62
+ }
@@ -0,0 +1,315 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { chmod, copyFile, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { dirname, join } from 'node:path'
5
+ import { z } from 'zod'
6
+
7
+ import { AstraleError } from '../errors'
8
+ import { INSTALL_PATH } from './paths'
9
+ import { run } from './proc'
10
+
11
+ const DEFAULT_REPO = 'astrale-os/cli'
12
+ const DEFAULT_CHANNEL = 'alpha'
13
+
14
+ export const InstallMetadataSchema = z.object({
15
+ method: z.literal('script'),
16
+ channel: z.string().min(1).default(DEFAULT_CHANNEL),
17
+ version: z.string().min(1).optional(),
18
+ repo: z.string().min(1).default(DEFAULT_REPO),
19
+ bin: z.string().min(1),
20
+ installedAt: z.string().optional(),
21
+ })
22
+
23
+ export type InstallMetadata = z.infer<typeof InstallMetadataSchema>
24
+
25
+ const ManifestAssetSchema = z.object({
26
+ name: z.string().min(1),
27
+ sha256: z
28
+ .string()
29
+ .regex(/^[a-fA-F0-9]{64}$/)
30
+ .optional(),
31
+ })
32
+
33
+ export const UpdateManifestSchema = z.object({
34
+ version: z.string().min(1),
35
+ binaryVersion: z.string().min(1).optional(),
36
+ channel: z.string().min(1),
37
+ repo: z.string().min(1).optional(),
38
+ assets: z.record(
39
+ z.string(),
40
+ z.union([
41
+ ManifestAssetSchema,
42
+ z
43
+ .string()
44
+ .min(1)
45
+ .transform((name) => ({ name })),
46
+ ]),
47
+ ),
48
+ })
49
+
50
+ export type UpdateManifest = z.infer<typeof UpdateManifestSchema>
51
+
52
+ export type Platform = {
53
+ os: 'darwin' | 'linux'
54
+ arch: 'arm64' | 'x64'
55
+ }
56
+
57
+ export type UpdateRequest = {
58
+ check?: boolean
59
+ channel?: string
60
+ version?: string
61
+ currentVersion: string
62
+ platform?: Platform
63
+ installPath?: string
64
+ }
65
+
66
+ export type UpdateResult =
67
+ | {
68
+ status: 'up-to-date'
69
+ currentVersion: string
70
+ latestVersion: string
71
+ channel: string
72
+ }
73
+ | {
74
+ status: 'available'
75
+ currentVersion: string
76
+ latestVersion: string
77
+ channel: string
78
+ }
79
+ | {
80
+ status: 'updated'
81
+ previousVersion: string
82
+ currentVersion: string
83
+ channel: string
84
+ bin: string
85
+ }
86
+
87
+ export function detectPlatform(): Platform {
88
+ const os = process.platform
89
+ const arch = process.arch
90
+ if (os !== 'darwin' && os !== 'linux') {
91
+ throw new AstraleError(
92
+ 'UNSUPPORTED_PLATFORM',
93
+ `Unsupported OS "${os}" — Astrale update supports macOS and Linux.`,
94
+ )
95
+ }
96
+ if (arch !== 'arm64' && arch !== 'x64') {
97
+ throw new AstraleError(
98
+ 'UNSUPPORTED_PLATFORM',
99
+ `Unsupported CPU architecture "${arch}" — Astrale update supports arm64 and x64.`,
100
+ )
101
+ }
102
+ return { os, arch }
103
+ }
104
+
105
+ export function platformKey(platform: Platform): string {
106
+ return `${platform.os}-${platform.arch}`
107
+ }
108
+
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)
116
+ }
117
+
118
+ export async function readInstallMetadata(path = INSTALL_PATH): Promise<InstallMetadata> {
119
+ let raw: string
120
+ try {
121
+ 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
+ )
134
+ }
135
+
136
+ const parsed = InstallMetadataSchema.safeParse(JSON.parse(raw))
137
+ if (!parsed.success) {
138
+ 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',
142
+ )
143
+ }
144
+ return parsed.data
145
+ }
146
+
147
+ export async function writeInstallMetadata(
148
+ meta: InstallMetadata,
149
+ path = INSTALL_PATH,
150
+ ): Promise<void> {
151
+ await mkdir(dirname(path), { recursive: true })
152
+ await writeFile(path, JSON.stringify(meta, null, 2) + '\n')
153
+ }
154
+
155
+ export function releaseBase(
156
+ meta: InstallMetadata,
157
+ req: Pick<UpdateRequest, 'channel' | 'version'>,
158
+ ): string {
159
+ if (process.env.ASTRALE_UPDATE_BASE) return process.env.ASTRALE_UPDATE_BASE.replace(/\/+$/, '')
160
+ const repo = meta.repo || DEFAULT_REPO
161
+ if (req.version) {
162
+ const version = req.version.replace(/^cli\/v/, '').replace(/^v/, '')
163
+ return `https://github.com/${repo}/releases/download/cli/v${version}`
164
+ }
165
+ return `https://github.com/${repo}/releases/download/${req.channel ?? meta.channel ?? DEFAULT_CHANNEL}`
166
+ }
167
+
168
+ export async function fetchManifest(base: string): Promise<UpdateManifest> {
169
+ const raw = await readUrlText(`${base}/manifest.json`)
170
+ return UpdateManifestSchema.parse(JSON.parse(raw))
171
+ }
172
+
173
+ export function shouldUpdate(currentVersion: string, manifestVersion: string): boolean {
174
+ return currentVersion !== manifestVersion
175
+ }
176
+
177
+ export async function updateAstrale(req: UpdateRequest): Promise<UpdateResult> {
178
+ const meta = await readInstallMetadata(req.installPath)
179
+ const currentVersion = meta.version ?? req.currentVersion
180
+ const channel = req.channel ?? meta.channel ?? DEFAULT_CHANNEL
181
+ const platform = req.platform ?? detectPlatform()
182
+ const key = platformKey(platform)
183
+ const base = releaseBase(meta, { channel, version: req.version })
184
+ const manifest = await fetchManifest(base)
185
+ const asset = manifest.assets[key]
186
+ if (!asset) {
187
+ throw new AstraleError(
188
+ 'UPDATE_ASSET_NOT_FOUND',
189
+ `No Astrale CLI release asset for ${key}.`,
190
+ `Available platforms: ${Object.keys(manifest.assets).join(', ')}`,
191
+ )
192
+ }
193
+
194
+ if (!shouldUpdate(currentVersion, manifest.version)) {
195
+ return {
196
+ status: 'up-to-date',
197
+ currentVersion,
198
+ latestVersion: manifest.version,
199
+ channel: manifest.channel,
200
+ }
201
+ }
202
+
203
+ if (req.check) {
204
+ return {
205
+ status: 'available',
206
+ currentVersion,
207
+ latestVersion: manifest.version,
208
+ channel: manifest.channel,
209
+ }
210
+ }
211
+
212
+ const tmp = await mkdtemp(join(tmpdir(), 'astrale-update-'))
213
+ try {
214
+ const archive = join(tmp, asset.name)
215
+ await downloadToFile(`${base}/${asset.name}`, archive)
216
+ const manifestChecksum = 'sha256' in asset ? asset.sha256 : undefined
217
+ const expected = manifestChecksum ?? (await fetchChecksum(base, asset.name))
218
+ const actual = await sha256File(archive)
219
+ if (actual !== expected.toLowerCase()) {
220
+ throw new AstraleError(
221
+ 'UPDATE_CHECKSUM_MISMATCH',
222
+ `Checksum mismatch for ${asset.name}.`,
223
+ `Expected ${expected}; got ${actual}.`,
224
+ )
225
+ }
226
+
227
+ await extractTarGz(archive, tmp)
228
+ const nextBin = join(tmp, 'astrale')
229
+ await chmod(nextBin, 0o755)
230
+ await smokeVersion(nextBin, manifest.binaryVersion ?? manifest.version)
231
+
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
+ )
248
+
249
+ return {
250
+ status: 'updated',
251
+ previousVersion: currentVersion,
252
+ currentVersion: manifest.version,
253
+ channel: manifest.channel,
254
+ bin: meta.bin,
255
+ }
256
+ } finally {
257
+ await rm(tmp, { recursive: true, force: true })
258
+ }
259
+ }
260
+
261
+ async function readUrlText(url: string): Promise<string> {
262
+ if (url.startsWith('file://')) {
263
+ return readFile(new URL(url), 'utf8')
264
+ }
265
+ const res = await fetch(url)
266
+ if (!res.ok) throw new Error(`GET ${url} failed: HTTP ${res.status}`)
267
+ return res.text()
268
+ }
269
+
270
+ async function downloadToFile(url: string, path: string): Promise<void> {
271
+ if (url.startsWith('file://')) {
272
+ await copyFile(new URL(url), path)
273
+ return
274
+ }
275
+ const res = await fetch(url)
276
+ if (!res.ok) throw new Error(`GET ${url} failed: HTTP ${res.status}`)
277
+ const bytes = new Uint8Array(await res.arrayBuffer())
278
+ await writeFile(path, bytes)
279
+ }
280
+
281
+ async function fetchChecksum(base: string, assetName: string): Promise<string> {
282
+ const raw = await readUrlText(`${base}/sha256sums.txt`)
283
+ for (const line of raw.split(/\r?\n/)) {
284
+ const [sha, file] = line.trim().split(/\s+/, 2)
285
+ if (!sha || !file) continue
286
+ if (file.replace(/^\*/, '') === assetName && /^[a-fA-F0-9]{64}$/.test(sha)) return sha
287
+ }
288
+ throw new AstraleError(
289
+ 'UPDATE_CHECKSUM_NOT_FOUND',
290
+ `Checksum entry not found for ${assetName}.`,
291
+ `Release base: ${base}`,
292
+ )
293
+ }
294
+
295
+ async function sha256File(path: string): Promise<string> {
296
+ const hash = createHash('sha256')
297
+ hash.update(await readFile(path))
298
+ return hash.digest('hex')
299
+ }
300
+
301
+ async function extractTarGz(archive: string, cwd: string): Promise<void> {
302
+ const { code, stderr } = await run('tar', ['-xzf', archive, '-C', cwd])
303
+ if (code !== 0) {
304
+ throw new Error(`Could not extract update archive: ${stderr.trim()}`)
305
+ }
306
+ }
307
+
308
+ async function smokeVersion(bin: string, expectedVersion: string): Promise<void> {
309
+ const { code, stdout, stderr } = await run(bin, ['--version'])
310
+ if (code !== 0) throw new Error(`Updated binary failed --version: ${stderr.trim()}`)
311
+ const actual = stdout.trim()
312
+ if (actual !== expectedVersion) {
313
+ throw new Error(`Updated binary reported version ${actual}, expected ${expectedVersion}`)
314
+ }
315
+ }
@@ -0,0 +1,24 @@
1
+ import type { IdentityStore } from './identity'
2
+ import type { InstanceStore } from './instance'
3
+
4
+ import { resolveInstanceKey } from './instance'
5
+
6
+ export type UseTarget =
7
+ | { kind: 'identity'; name: string }
8
+ | { kind: 'instance'; name: string }
9
+ | { kind: 'ambiguous'; name: string }
10
+ | { kind: 'missing'; name: string }
11
+
12
+ export function resolveUseTarget(
13
+ name: string,
14
+ instances: InstanceStore,
15
+ identities: IdentityStore,
16
+ ): UseTarget {
17
+ const identityExists = !!identities.identities[name]
18
+ const instanceKey = resolveInstanceKey(instances, name)
19
+
20
+ if (identityExists && instanceKey) return { kind: 'ambiguous', name }
21
+ if (identityExists) return { kind: 'identity', name }
22
+ if (instanceKey) return { kind: 'instance', name: instanceKey }
23
+ return { kind: 'missing', name }
24
+ }
@@ -0,0 +1,59 @@
1
+ import { isDnsLabel } from '@astrale-os/kernel-core'
2
+ import { z } from 'zod'
3
+
4
+ import { ReservedSlugError } from '../errors'
5
+
6
+ // Shared validators + schema fragments used by both instance and identity
7
+ // registries — kept in a leaf module to avoid circular imports.
8
+
9
+ const NAME_RE = /^[a-zA-Z0-9_.-]+$/
10
+ // `host` is the reserved slug of the host/manager kernel (SPEC §5.2); `manager` is the legacy name.
11
+ const RESERVED_SLUGS = new Set(['manager', 'host'])
12
+
13
+ export { RESERVED_SLUGS }
14
+
15
+ export function validateName(name: string, entity: string): void {
16
+ if (!name || !NAME_RE.test(name)) {
17
+ throw new Error(
18
+ `Invalid ${entity.toLowerCase()} name "${name}" — must be non-empty and contain only letters, digits, hyphens, underscores, and dots`,
19
+ )
20
+ }
21
+ }
22
+
23
+ export function validateSlug(slug: string): void {
24
+ // Canonical DNS-label rule (`@astrale-os/kernel-core`): a slug becomes a
25
+ // hostname label, so reject anything not DNS-safe (§4.7).
26
+ if (!slug || !isDnsLabel(slug)) {
27
+ throw new Error(
28
+ `Invalid slug "${slug}" — must be a lowercase DNS label [a-z0-9-], ≤63 chars, no leading/trailing hyphen (§4.7)`,
29
+ )
30
+ }
31
+ if (RESERVED_SLUGS.has(slug)) {
32
+ throw new ReservedSlugError(slug)
33
+ }
34
+ }
35
+
36
+ export function validateUrl(url: string): void {
37
+ try {
38
+ const parsed = new URL(url)
39
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
40
+ throw new Error('not http(s)')
41
+ }
42
+ } catch {
43
+ throw new Error(`Invalid URL "${url}" — expected a valid http:// or https:// URL`)
44
+ }
45
+ }
46
+
47
+ /** Non-throwing predicate form of `validateUrl`. */
48
+ export function isHttpUrl(url: string): boolean {
49
+ try {
50
+ validateUrl(url)
51
+ return true
52
+ } catch {
53
+ return false
54
+ }
55
+ }
56
+
57
+ /** `local` = only this machine. `remote` = mirrored via astrale cloud (§2.7). */
58
+ export const RegistryModeSchema = z.enum(['local', 'remote'])
59
+ export type RegistryMode = z.infer<typeof RegistryModeSchema>