@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.
- package/.check-workspace.cjs +40 -0
- package/README.md +151 -0
- package/dist/astrale.js +59749 -0
- package/package.json +90 -0
- package/src/command.ts +40 -0
- package/src/commands/__tests__/admin-instance.test.ts +73 -0
- package/src/commands/__tests__/auth-login.test.ts +178 -0
- package/src/commands/__tests__/auth-token.test.ts +223 -0
- package/src/commands/__tests__/call.test.ts +72 -0
- package/src/commands/__tests__/domain-list.test.ts +74 -0
- package/src/commands/__tests__/help-contract.test.ts +136 -0
- package/src/commands/__tests__/install-identity-override.test.ts +65 -0
- package/src/commands/__tests__/instance-bookmark.test.ts +101 -0
- package/src/commands/__tests__/instance-create-hosts.test.ts +29 -0
- package/src/commands/__tests__/instance-list-rows.test.ts +63 -0
- package/src/commands/__tests__/logs.test.ts +117 -0
- package/src/commands/__tests__/ls.test.ts +25 -0
- package/src/commands/__tests__/setup-plan.test.ts +61 -0
- package/src/commands/admin/status.ts +61 -0
- package/src/commands/admin/use.ts +77 -0
- package/src/commands/auth/login.ts +82 -0
- package/src/commands/auth/logout.ts +50 -0
- package/src/commands/auth/status.ts +86 -0
- package/src/commands/auth/token.ts +162 -0
- package/src/commands/browser.ts +207 -0
- package/src/commands/call.ts +300 -0
- package/src/commands/describe.ts +182 -0
- package/src/commands/domain/install.ts +420 -0
- package/src/commands/domain/list.ts +154 -0
- package/src/commands/domain/publish.ts +155 -0
- package/src/commands/get.ts +60 -0
- package/src/commands/identity/create.ts +26 -0
- package/src/commands/identity/delete.ts +18 -0
- package/src/commands/identity/export.ts +66 -0
- package/src/commands/identity/import.ts +101 -0
- package/src/commands/identity/list.ts +56 -0
- package/src/commands/identity/register.ts +170 -0
- package/src/commands/identity/sync.ts +32 -0
- package/src/commands/identity/unsync.ts +24 -0
- package/src/commands/identity/use.ts +18 -0
- package/src/commands/identity/whoami.ts +34 -0
- package/src/commands/idp/add.ts +150 -0
- package/src/commands/idp/list.ts +57 -0
- package/src/commands/idp/refresh.ts +38 -0
- package/src/commands/idp/remove.ts +36 -0
- package/src/commands/idp/show.ts +29 -0
- package/src/commands/instance/active.ts +64 -0
- package/src/commands/instance/bookmark.ts +72 -0
- package/src/commands/instance/create.ts +69 -0
- package/src/commands/instance/delete.ts +72 -0
- package/src/commands/instance/forget.ts +26 -0
- package/src/commands/instance/list.ts +149 -0
- package/src/commands/instance/status.ts +42 -0
- package/src/commands/instance/use.ts +210 -0
- package/src/commands/logs.ts +347 -0
- package/src/commands/ls.ts +229 -0
- package/src/commands/query.ts +32 -0
- package/src/commands/setup.ts +54 -0
- package/src/commands/status.ts +60 -0
- package/src/commands/studio.ts +401 -0
- package/src/commands/token.ts +77 -0
- package/src/commands/update.ts +267 -0
- package/src/commands/use.ts +87 -0
- package/src/errors.ts +65 -0
- package/src/kernel/__tests__/auth.test.ts +77 -0
- package/src/kernel/__tests__/errors.test.ts +43 -0
- package/src/kernel/__tests__/remote-routing.test.ts +70 -0
- package/src/kernel/auth.ts +234 -0
- package/src/kernel/ca-fetch.ts +119 -0
- package/src/kernel/client.ts +191 -0
- package/src/kernel/errors.ts +280 -0
- package/src/kernel/expand.ts +217 -0
- package/src/kernel/index.ts +14 -0
- package/src/kernel/options.ts +22 -0
- package/src/kernel/remote-routing.ts +88 -0
- package/src/kernel/run.ts +63 -0
- package/src/kernel/types.ts +14 -0
- package/src/lib/__tests__/admin-target.test.ts +112 -0
- package/src/lib/__tests__/binary.test.ts +56 -0
- package/src/lib/__tests__/command-dx.test.ts +58 -0
- package/src/lib/__tests__/concurrency.test.ts +62 -0
- package/src/lib/__tests__/config.test.ts +53 -0
- package/src/lib/__tests__/design.test.ts +99 -0
- package/src/lib/__tests__/domain-identity.test.ts +60 -0
- package/src/lib/__tests__/format.test.ts +22 -0
- package/src/lib/__tests__/fs-atomic.test.ts +104 -0
- package/src/lib/__tests__/identity.test.ts +79 -0
- package/src/lib/__tests__/idp-session.driver.ts +53 -0
- package/src/lib/__tests__/idp-session.test.ts +357 -0
- package/src/lib/__tests__/idp.test.ts +385 -0
- package/src/lib/__tests__/instance-candidates.test.ts +73 -0
- package/src/lib/__tests__/instance-target.test.ts +183 -0
- package/src/lib/__tests__/instance.test.ts +136 -0
- package/src/lib/__tests__/keys.test.ts +129 -0
- package/src/lib/__tests__/local-status.test.ts +202 -0
- package/src/lib/__tests__/output.test.ts +150 -0
- package/src/lib/__tests__/panel.test.ts +40 -0
- package/src/lib/__tests__/port.test.ts +44 -0
- package/src/lib/__tests__/prompt.test.ts +25 -0
- package/src/lib/__tests__/sdk-deps.test.ts +68 -0
- package/src/lib/__tests__/self.test.ts +272 -0
- package/src/lib/__tests__/studio-server-deps.test.ts +74 -0
- package/src/lib/__tests__/table.test.ts +53 -0
- package/src/lib/__tests__/update.test.ts +246 -0
- package/src/lib/__tests__/use-target.test.ts +56 -0
- package/src/lib/__tests__/validation.test.ts +34 -0
- package/src/lib/admin-domain.ts +25 -0
- package/src/lib/admin-instance.ts +26 -0
- package/src/lib/admin-target.ts +217 -0
- package/src/lib/binary.ts +131 -0
- package/src/lib/browser.ts +150 -0
- package/src/lib/command-dx.ts +161 -0
- package/src/lib/concurrency.ts +31 -0
- package/src/lib/config.ts +45 -0
- package/src/lib/domain-identity.ts +49 -0
- package/src/lib/env.ts +49 -0
- package/src/lib/format.ts +4 -0
- package/src/lib/fs-atomic.ts +126 -0
- package/src/lib/identity.ts +256 -0
- package/src/lib/idp-session.ts +134 -0
- package/src/lib/idp.ts +876 -0
- package/src/lib/instance-candidates.ts +49 -0
- package/src/lib/instance-target.ts +182 -0
- package/src/lib/instance.ts +395 -0
- package/src/lib/keys.ts +294 -0
- package/src/lib/local-status.ts +152 -0
- package/src/lib/log.ts +116 -0
- package/src/lib/login-flow.ts +164 -0
- package/src/lib/meta.ts +86 -0
- package/src/lib/output.ts +222 -0
- package/src/lib/panel.ts +61 -0
- package/src/lib/paths.ts +11 -0
- package/src/lib/port.ts +41 -0
- package/src/lib/proc.ts +82 -0
- package/src/lib/prompt.ts +136 -0
- package/src/lib/provision-instance.ts +170 -0
- package/src/lib/sdk-deps.ts +104 -0
- package/src/lib/self.ts +166 -0
- package/src/lib/skills.ts +171 -0
- package/src/lib/table.ts +62 -0
- package/src/lib/update.ts +315 -0
- package/src/lib/use-target.ts +24 -0
- package/src/lib/validation.ts +59 -0
- package/src/program.ts +200 -0
- package/src/registry.ts +59 -0
- package/src/setup/__tests__/util.test.ts +29 -0
- package/src/setup/engine.ts +83 -0
- package/src/setup/render.ts +109 -0
- package/src/setup/steps/admin.ts +78 -0
- package/src/setup/steps/agent-browser.ts +81 -0
- package/src/setup/steps/auth.ts +54 -0
- package/src/setup/steps/domain.ts +68 -0
- package/src/setup/steps/index.ts +18 -0
- package/src/setup/steps/instance.ts +119 -0
- package/src/setup/steps/skills-bridge.ts +70 -0
- package/src/setup/steps/skills.ts +59 -0
- package/src/setup/types.ts +61 -0
- package/src/setup/util.ts +34 -0
- package/src/test-utils.ts +18 -0
- package/studio/client/dist/assets/index-DOwzZAEK.css +1 -0
- package/studio/client/dist/assets/index-wtU0Zxhy.js +183 -0
- package/studio/client/dist/index.html +13 -0
- package/studio/package.json +62 -0
- package/studio/server/agent/ask.ts +68 -0
- package/studio/server/agent/bridge-mcp.ts +182 -0
- package/studio/server/agent/bridge.ts +188 -0
- package/studio/server/agent/claude.ts +666 -0
- package/studio/server/agent/mock.ts +186 -0
- package/studio/server/agent/prompt.ts +202 -0
- package/studio/server/agent/registry.ts +29 -0
- package/studio/server/agent/runner.ts +484 -0
- package/studio/server/agent/schema-map.ts +112 -0
- package/studio/server/agent/types.ts +120 -0
- package/studio/server/api.ts +574 -0
- package/studio/server/cache.ts +138 -0
- package/studio/server/detect.ts +81 -0
- package/studio/server/domain.ts +70 -0
- package/studio/server/index.ts +136 -0
- package/studio/server/introspect/anatomy-extras.ts +398 -0
- package/studio/server/introspect/anatomy.ts +108 -0
- package/studio/server/introspect/bundle.ts +57 -0
- package/studio/server/introspect/core-extractor.ts +119 -0
- package/studio/server/introspect/core.ts +44 -0
- package/studio/server/introspect/diff.ts +133 -0
- package/studio/server/introspect/extractor.ts +102 -0
- package/studio/server/introspect/hash.ts +21 -0
- package/studio/server/introspect/overlay-tsmorph.ts +874 -0
- package/studio/server/introspect/overlay.ts +57 -0
- package/studio/server/introspect/runtime.ts +99 -0
- package/studio/server/introspect/schema-refs.ts +46 -0
- package/studio/server/lifecycle.ts +38 -0
- package/studio/server/sse.ts +57 -0
- package/studio/server/state/baseline.ts +211 -0
- package/studio/server/state/catalog.ts +117 -0
- package/studio/server/state/comments.ts +321 -0
- package/studio/server/state/context.ts +167 -0
- package/studio/server/state/copy.ts +156 -0
- package/studio/server/state/create.ts +156 -0
- package/studio/server/state/documents.ts +70 -0
- package/studio/server/state/env.ts +161 -0
- package/studio/server/state/git.ts +75 -0
- package/studio/server/state/handoff.ts +55 -0
- package/studio/server/state/harness-gateway.ts +181 -0
- package/studio/server/state/harness-token.ts +0 -0
- package/studio/server/state/instance.ts +244 -0
- package/studio/server/state/integrations.ts +55 -0
- package/studio/server/state/layout.ts +55 -0
- package/studio/server/state/settings.ts +39 -0
- package/studio/server/state/store.ts +97 -0
- package/studio/server/state/updates.ts +63 -0
- package/studio/server/state/usage.ts +37 -0
- package/studio/server/state/views.ts +138 -0
- package/studio/server/state/visibility.ts +33 -0
- package/studio/server/watch.ts +81 -0
- package/studio/server/workspace-state.ts +26 -0
- package/studio/server/workspace-watch.ts +101 -0
- package/studio/shared/types.ts +873 -0
- package/studio/tsconfig.json +23 -0
- 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
|
+
}
|
package/src/lib/table.ts
ADDED
|
@@ -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>
|