@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,136 @@
1
+ import { checkbox, confirm as confirmPrompt, input, select } from '@inquirer/prompts'
2
+ import chalk from 'chalk'
3
+ import { createInterface } from 'node:readline/promises'
4
+
5
+ // All interactive prompts go through `@inquirer/prompts` so a single library
6
+ // owns stdin (raw mode, keypress handling, cleanup). Hand-rolling some prompts
7
+ // with `node:readline` alongside inquirer used to fight over the TTY — the
8
+ // raw↔line-mode handoff swallowed the first keystroke, so a step right after an
9
+ // inquirer prompt needed a double Enter. One owner, no handoff, no lost keys.
10
+ //
11
+ // Every helper guards on `process.stdin.isTTY` BEFORE touching inquirer (which
12
+ // requires a TTY): a piped / CI / no-TTY (LLM) run returns the default
13
+ // immediately and renders nothing, so callers never hang on a read.
14
+
15
+ /**
16
+ * Prompt the user for Y/N confirmation (default No). Returns false in non-TTY
17
+ * environments (use --yes / a flag to bypass).
18
+ */
19
+ export async function confirm(message: string): Promise<boolean> {
20
+ if (!process.stdin.isTTY) return false
21
+ return confirmPrompt({ message, default: false })
22
+ }
23
+
24
+ /**
25
+ * Prompt with a Y default — "Y/n" semantics. Returns true unless the user
26
+ * explicitly declines; returns true in non-TTY.
27
+ */
28
+ export async function confirmDefaultYes(message: string): Promise<boolean> {
29
+ if (!process.stdin.isTTY) return true
30
+ return confirmPrompt({ message, default: true })
31
+ }
32
+
33
+ /**
34
+ * Prompt the user to type a specific string to confirm a dangerous action.
35
+ * Returns false in non-TTY environments (use a flag to bypass).
36
+ */
37
+ export async function confirmWithInput(message: string, expected: string): Promise<boolean> {
38
+ if (!process.stdin.isTTY) return false
39
+ process.stdout.write(chalk.yellow(`${message}\n`))
40
+ const answer = await input({ message: `Type "${expected}" to confirm:` })
41
+ return answer.trim() === expected
42
+ }
43
+
44
+ /**
45
+ * Free-text prompt (a styled `@inquirer/prompts` input — shows the default,
46
+ * supports inline `validate` with live re-ask). Returns the typed value, or the
47
+ * default on empty input (`undefined` when none) in a non-TTY run.
48
+ */
49
+ export async function promptText(
50
+ message: string,
51
+ opts: { default?: string; validate?: (value: string) => boolean | string } = {},
52
+ ): Promise<string | undefined> {
53
+ if (!process.stdin.isTTY) return opts.default
54
+ const answer = await input({
55
+ message,
56
+ ...(opts.default !== undefined ? { default: opts.default } : {}),
57
+ ...(opts.validate ? { validate: opts.validate } : {}),
58
+ })
59
+ return answer.trim() || opts.default
60
+ }
61
+
62
+ /**
63
+ * Single-choice selector (arrow-key `@inquirer/prompts` select). Returns the
64
+ * chosen value, or `undefined` in a non-TTY environment.
65
+ */
66
+ export async function promptSelect<T>(
67
+ message: string,
68
+ choices: Array<{ name: string; value: T; description?: string }>,
69
+ ): Promise<T | undefined> {
70
+ if (!process.stdin.isTTY) return undefined
71
+ return select({ message, choices })
72
+ }
73
+
74
+ /**
75
+ * Multi-choice selector (a styled `@inquirer/prompts` checkbox — space toggles,
76
+ * enter confirms). Pre-check options with `checked: true`. Returns the chosen
77
+ * values, or `undefined` in a non-TTY environment.
78
+ */
79
+ export async function promptMultiSelect<T>(
80
+ message: string,
81
+ choices: Array<{ name: string; value: T; checked?: boolean; description?: string }>,
82
+ ): Promise<T[] | undefined> {
83
+ if (!process.stdin.isTTY) return undefined
84
+ return checkbox({ message, choices })
85
+ }
86
+
87
+ /**
88
+ * Single-choice selector over labeled values. Returns the chosen value, or
89
+ * `null` in a non-TTY environment (callers decide how to fail). In a terminal
90
+ * the user always picks one (Enter selects the highlighted option; Ctrl-C
91
+ * aborts the command), so `null` only ever signals "no TTY".
92
+ */
93
+ export async function selectFrom<T>(
94
+ message: string,
95
+ choices: Array<{ label: string; value: T }>,
96
+ ): Promise<T | null> {
97
+ if (!process.stdin.isTTY) return null
98
+ return select({ message, choices: choices.map((c) => ({ name: c.label, value: c.value })) })
99
+ }
100
+
101
+ /** Prompt a passphrase without echoing. Fails in non-TTY unless env override. */
102
+ export async function readPassphrase(
103
+ message: string,
104
+ opts: { minLength?: number } = {},
105
+ ): Promise<string> {
106
+ const env = process.env.ASTRALE_PASSPHRASE
107
+ if (env) return env
108
+ if (!process.stdin.isTTY) {
109
+ throw new Error('Passphrase required but no TTY. Pipe via ASTRALE_PASSPHRASE env var.')
110
+ }
111
+ // v1 note: passphrase echoes on interactive terminals. Pipe
112
+ // ASTRALE_PASSPHRASE=... for scripted flows. Silent stdin with raw
113
+ // mode is roadmap (requires terminal capabilities handling).
114
+ process.stdout.write(chalk.yellow(message))
115
+ const answer = await readLine()
116
+ process.stdout.write('\n')
117
+ if (opts.minLength && answer.length < opts.minLength) {
118
+ throw new Error(`Passphrase too short (min ${opts.minLength} chars)`)
119
+ }
120
+ return answer
121
+ }
122
+
123
+ /**
124
+ * Read one line from stdin via Node's own line reader (stdlib, zero-dep).
125
+ * Used only by `readPassphrase` (a single-shot prompt that intentionally echoes
126
+ * and honors ASTRALE_PASSPHRASE) — every navigational prompt uses inquirer.
127
+ * Only ever reached behind an `isTTY` gate, so it never blocks a piped run.
128
+ */
129
+ async function readLine(): Promise<string> {
130
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
131
+ try {
132
+ return (await rl.question('')).trim()
133
+ } finally {
134
+ rl.close()
135
+ }
136
+ }
@@ -0,0 +1,170 @@
1
+ import chalk from 'chalk'
2
+
3
+ import type { KernelCommandOpts } from '../kernel'
4
+ import type { AdminTargetCommandOpts } from './admin-target'
5
+
6
+ import { AuthError } from '../errors'
7
+ import { withAdminKernelClient } from '../kernel/client'
8
+ import { ADMIN_INSTANCE } from './admin-instance'
9
+ import { readIdentities, type IdentityStore } from './identity'
10
+ import { setActive, upsertManagedBookmark } from './instance'
11
+ import { withSpinner } from './log'
12
+ import { isMachine } from './output'
13
+ import { promptSelect } from './prompt'
14
+ import { validateSlug } from './validation'
15
+
16
+ export type ProvisionOpts = KernelCommandOpts &
17
+ AdminTargetCommandOpts & {
18
+ hostId?: string
19
+ // Global flags (program.ts) that force non-interactive — mirrors `instance use`.
20
+ ci?: boolean
21
+ noPrompt?: boolean
22
+ }
23
+
24
+ /** The created instance plus the local-bookmark side effects of provisioning. */
25
+ export type ProvisionResult = {
26
+ /** The raw admin-kernel response — the stable machine surface for `--json`. */
27
+ created: { url: string; organizationId?: string }
28
+ slug: string
29
+ /** Set when an existing bookmark of the same name was repointed to a new kernel. */
30
+ repointedFrom?: string
31
+ /** Set when bookmarking/activating the new instance failed (non-fatal). */
32
+ selectionError?: unknown
33
+ }
34
+
35
+ /** Provisioning a child instance runs a multi-step saga (1-3 min). */
36
+ const SAGA_TIMEOUT_MS = '240000'
37
+
38
+ /**
39
+ * Provision an alpha instance through the admin kernel, then bookmark it and
40
+ * make it the active target. Extracted from `instance create` so `astrale
41
+ * setup` provisions through the exact same saga (auth assertion → alphaCreate →
42
+ * bookmark → set-active), including the interactive multi-host picker.
43
+ *
44
+ * Presentation is deliberately minimal here (a spinner + a one-line success);
45
+ * the caller renders anything richer — `setup` follows this with a hero panel.
46
+ */
47
+ export async function provisionInstance(
48
+ slug: string,
49
+ opts: ProvisionOpts,
50
+ ): Promise<ProvisionResult> {
51
+ validateSlug(slug)
52
+ await assertAlphaCreateAuth(opts)
53
+ // The created instance must belong to the LOGGED-IN identity — never to an
54
+ // identity pinned on the admin bookmark (that mismatch silently made a fresh
55
+ // user's instance unusable). `--as` still wins.
56
+ if (!opts.as) opts = { ...opts, as: (await readIdentities()).default }
57
+
58
+ const machine = isMachine(opts)
59
+ const interactive = !!process.stdin.isTTY && !(opts.ci || opts.noPrompt || process.env.CI)
60
+
61
+ let repointedFrom: string | undefined
62
+ let selectionError: unknown = null
63
+ // The global 30s default doesn't just fail the CLIENT: the disconnect kills
64
+ // the worker's request mid-saga and leaves TORN state (slug taken, routing
65
+ // live, no instance node — unrecoverable by retry). Default to a saga-sized
66
+ // timeout; an explicit --timeout still wins.
67
+ const createOpts = { ...opts, timeout: opts.timeout ?? SAGA_TIMEOUT_MS }
68
+
69
+ const runProvision = (hostId: string | undefined) =>
70
+ withSpinner(
71
+ `Provisioning instance ${slug}`,
72
+ !machine,
73
+ async () => {
74
+ const created = await withAdminKernelClient(
75
+ createOpts,
76
+ async (ctx) =>
77
+ (await ctx.client.call(`${ADMIN_INSTANCE}/alphaCreate`, {
78
+ slug,
79
+ ...(hostId ? { host_id: hostId } : {}),
80
+ })) as { url: string; organizationId?: string },
81
+ )
82
+ try {
83
+ // Org id from the create response — authoritative for token scoping
84
+ // (the router's /auth/org is eventually consistent).
85
+ const bookmarked = await upsertManagedBookmark(
86
+ slug,
87
+ slug,
88
+ created.url,
89
+ created.organizationId,
90
+ )
91
+ repointedFrom = bookmarked.repointedFrom
92
+ await setActive(slug)
93
+ } catch (e) {
94
+ selectionError = e
95
+ }
96
+ return created
97
+ },
98
+ {
99
+ success: (created) =>
100
+ `Instance provisioned: ${slug} ${chalk.dim(`(${created.url})${selectionError ? '' : ' · active'}`)}`,
101
+ },
102
+ )
103
+
104
+ // Host placement is chosen SERVER-side (the caller's ready + USE-granted
105
+ // hosts). We recover ONLY from its multi-host ambiguity: pop a picker and
106
+ // retry with the chosen host_id — the ambiguity error throws before any
107
+ // provisioning side effect, so the retry is clean. No host / permission /
108
+ // capacity / a non-interactive run all reject through to the caller's `fatal`,
109
+ // surfacing the server's own message plus the listed ids. An explicit
110
+ // `--host-id` skips all of this.
111
+ const created = await runProvision(opts.hostId).catch(async (e: unknown) => {
112
+ const hostIds = !opts.hostId && interactive ? parseEligibleHostIds(e) : null
113
+ if (!hostIds) throw e
114
+ const chosen = await promptSelect(
115
+ `${hostIds.length} hosts available — pick one to provision on`,
116
+ hostIds.map((hid) => ({ name: hid, value: hid })),
117
+ )
118
+ if (!chosen) throw e
119
+ return runProvision(chosen)
120
+ })
121
+
122
+ // Warnings go to stderr so machine-readable stdout stays clean.
123
+ const warn = (msg: string) => console.error(chalk.yellow('⚠'), msg)
124
+ if (selectionError) {
125
+ const message =
126
+ selectionError instanceof Error ? selectionError.message : String(selectionError)
127
+ warn(`Could not select the new instance: ${message}`)
128
+ } else if (repointedFrom) {
129
+ warn(`Bookmark "${slug}" repointed: ${repointedFrom} → ${created.url}`)
130
+ }
131
+
132
+ return { created, slug, repointedFrom, ...(selectionError ? { selectionError } : {}) }
133
+ }
134
+
135
+ /**
136
+ * Recover the eligible host ids from alphaCreate's multi-host error
137
+ * ("N ready hosts are assigned (id1, id2). Specify host_id…") — only when
138
+ * there's a real choice (>1). Returns null for any other error (no host,
139
+ * permission, capacity). Deliberately coupled to that message wording (Option
140
+ * B: no admin-side change); if it ever drifts we simply stop offering the
141
+ * picker and the raw error is shown instead — no worse than before.
142
+ */
143
+ export function parseEligibleHostIds(error: unknown): string[] | null {
144
+ const text = error instanceof Error ? error.message : String(error)
145
+ const match = /ready hosts are assigned \(([^)]+)\)/.exec(text)
146
+ if (!match) return null
147
+ const ids = match[1]!
148
+ .split(',')
149
+ .map((s) => s.trim())
150
+ .filter(Boolean)
151
+ return ids.length > 1 ? ids : null
152
+ }
153
+
154
+ async function assertAlphaCreateAuth(opts: Pick<ProvisionOpts, 'as' | 'creds'>): Promise<void> {
155
+ if (opts.creds) return
156
+ assertAlphaCreateIdentity(await readIdentities(), opts)
157
+ }
158
+
159
+ export function assertAlphaCreateIdentity(
160
+ store: IdentityStore,
161
+ opts: Pick<ProvisionOpts, 'as'> = {},
162
+ ): void {
163
+ const name = opts.as ?? store.default
164
+ const identity = store.identities[name]
165
+ if (identity && identity.source === 'idp') return
166
+ throw new AuthError(
167
+ 'WorkOS login required for `astrale instance create`',
168
+ 'Run: astrale auth login',
169
+ )
170
+ }
@@ -0,0 +1,104 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { run } from './proc'
5
+
6
+ /**
7
+ * `astrale update`, Axis C — keep a domain project's first-party `@astrale-os/*`
8
+ * dependencies current. We DELEGATE to pnpm rather than reimplement a package
9
+ * manager: `pnpm outdated` to detect, `pnpm update --latest` to apply. pnpm owns
10
+ * the hard, easy-to-get-wrong parts — registry/`.npmrc`/auth resolution, the
11
+ * `minimumReleaseAge` supply-chain gate, workspace members, semver, and the
12
+ * lockfile — so this stays a thin, robust shim (mirrors how the CLI delegates
13
+ * skills to `npx skills`). Every call is best-effort: pnpm missing or any error
14
+ * means "nothing to do", never a failed update.
15
+ */
16
+
17
+ /** The dependency pattern pnpm matches both subcommands against. */
18
+ const ASTRALE_DEP_PATTERN = '@astrale-os/*'
19
+
20
+ export type SdkOutdated = { pkg: string; current: string; latest: string }
21
+
22
+ /** Is the CLI running at the root of a scaffolded domain project? */
23
+ export function inDomainProject(cwd: string = process.cwd()): boolean {
24
+ return existsSync(join(cwd, 'astrale.config.ts'))
25
+ }
26
+
27
+ /**
28
+ * A non-pnpm lockfile means another package manager owns this project; we won't
29
+ * run pnpm against it (that would write a stray `pnpm-lock.yaml`). Returns the
30
+ * foreign PM name, or null when it's pnpm / fresh (Astrale projects are pnpm-first).
31
+ */
32
+ export function foreignPackageManager(cwd: string = process.cwd()): 'npm' | 'yarn' | 'bun' | null {
33
+ if (existsSync(join(cwd, 'pnpm-lock.yaml'))) return null
34
+ if (existsSync(join(cwd, 'package-lock.json'))) return 'npm'
35
+ if (existsSync(join(cwd, 'yarn.lock'))) return 'yarn'
36
+ if (existsSync(join(cwd, 'bun.lockb')) || existsSync(join(cwd, 'bun.lock'))) return 'bun'
37
+ return null
38
+ }
39
+
40
+ type PnpmOutdatedEntry = { current?: string; wanted?: string; latest?: string }
41
+
42
+ /**
43
+ * Parse `pnpm outdated --format json` into the deps that actually have a newer
44
+ * release. pnpm only lists outdated packages and never proposes a downgrade, so
45
+ * we just drop entries with no `latest` or where `latest` equals the current
46
+ * version (it lists `wanted` when nothing is installed yet — use that as the
47
+ * "current" we display). Pure (no I/O) so it's unit-testable without spawning.
48
+ */
49
+ export function parseSdkOutdated(stdout: string): SdkOutdated[] {
50
+ let parsed: Record<string, PnpmOutdatedEntry>
51
+ try {
52
+ parsed = JSON.parse(stdout || '{}') as Record<string, PnpmOutdatedEntry>
53
+ } catch {
54
+ return []
55
+ }
56
+ const out: SdkOutdated[] = []
57
+ for (const [pkg, entry] of Object.entries(parsed)) {
58
+ const current = entry.current ?? entry.wanted
59
+ if (entry.latest && current && entry.latest !== current) {
60
+ out.push({ pkg, current, latest: entry.latest })
61
+ }
62
+ }
63
+ return out
64
+ }
65
+
66
+ /**
67
+ * Ask pnpm which `@astrale-os/*` deps have a newer release. `pnpm outdated`
68
+ * exits 1 when outdated deps exist — that's not an error, so we parse stdout
69
+ * regardless of exit code. Returns [] when none, pnpm is absent, or anything fails.
70
+ */
71
+ export async function findSdkOutdated(cwd: string = process.cwd()): Promise<SdkOutdated[]> {
72
+ try {
73
+ const { stdout } = await run('pnpm', ['outdated', '--format', 'json', ASTRALE_DEP_PATTERN], {
74
+ cwd,
75
+ })
76
+ return parseSdkOutdated(stdout)
77
+ } catch {
78
+ return [] // pnpm not on PATH, etc.
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Apply via `pnpm update --latest --lockfile-only` — pnpm rewrites package.json
84
+ * (preserving exact pins, the scaffold style) and the lockfile, honoring the
85
+ * registry + supply-chain age policy. `--lockfile-only` is deliberate: it updates
86
+ * the manifest + lockfile WITHOUT running install/build scripts, so an `astrale
87
+ * update` never executes a dependency's postinstall (pnpm would otherwise exit
88
+ * non-zero on `ERR_PNPM_IGNORED_BUILDS`) or churns node_modules — the user runs
89
+ * `pnpm install` to materialize, honoring their own build approvals. Surfaces
90
+ * pnpm's output only on failure. Returns true on success.
91
+ */
92
+ export async function applySdkUpdate(cwd: string = process.cwd()): Promise<boolean> {
93
+ try {
94
+ const { code, stdout, stderr } = await run(
95
+ 'pnpm',
96
+ ['update', '--latest', '--lockfile-only', ASTRALE_DEP_PATTERN],
97
+ { cwd },
98
+ )
99
+ if (code !== 0) process.stderr.write(stdout + stderr)
100
+ return code === 0
101
+ } catch {
102
+ return false
103
+ }
104
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * `@self` shorthand: expands to the nodeId behind the JWT `sub` claim that
3
+ * the current CLI invocation would ship. Pure helpers — no I/O.
4
+ *
5
+ * Kernel-side, `Identity::registerIdentity` writes `sub = String(self.id)`
6
+ * (kernel/runtime/syscalls/identity/index.ts), so for any properly-registered
7
+ * identity the JWT `sub` IS the calling node's id on the target kernel.
8
+ * Delegation tokens follow the same shape (outer-envelope `sub` = identityId).
9
+ *
10
+ * The expansion is fail-loud: when no `sub` is resolvable, return a typed
11
+ * refusal carrying the exact next-step command. Never silently fall back to
12
+ * a label-ish `identity.subject` like "manager" or "alice".
13
+ */
14
+ import { decodeJwt } from 'jose'
15
+
16
+ import type { Identity } from './identity'
17
+
18
+ /** Inputs to `resolveSelfNodeId`. All local — no kernel round-trip. */
19
+ export type SelfResolverContext = {
20
+ /** Identity that will sign this call (default identity or the one from `--as`). */
21
+ identity?: Identity & { name: string }
22
+ /** Resolved instance slug, if any (absent when `--url` is used without `-i`). */
23
+ instanceSlug?: string
24
+ /** Raw `--creds` JWT, if the user provided one. */
25
+ credsJwt?: string
26
+ /** True when `auth.ts` will sign as the instance itself (per-instance keypair, not user identity). */
27
+ instanceSigned: boolean
28
+ /** `sub` decoded from a cached IdP token for source=idp identities, when available. */
29
+ idpSubject?: string
30
+ }
31
+
32
+ export type SelfRefusal =
33
+ | { reason: 'manager' }
34
+ | { reason: 'no-registration'; identityName: string; instanceSlug: string }
35
+ | { reason: 'instance-signed'; instanceSlug: string }
36
+ | { reason: 'url-no-slug' }
37
+ | { reason: 'creds-no-sub' }
38
+ | { reason: 'idp-no-sub'; identityName: string }
39
+
40
+ export type SelfResolution = { id: string } | SelfRefusal
41
+
42
+ // Anchors:
43
+ // left: start-of-string OR right after `=` (param-value head)
44
+ // right: end-of-string OR `::` (instance method) OR `/` (path navigation)
45
+ // The `/` lookahead is required so `@self/functions` etc. expand — the
46
+ // `astrale ls @self/functions` form is documented in SKILL.md and the
47
+ // sandbox prefix. Without it the regex silently no-ops on those inputs.
48
+ const SELF_RE = /(?:^|(?<=[=]))@self(?=$|::|\/)/g
49
+
50
+ /** Resolve `@self` → `{ id: <nodeId> }`, or a typed refusal. */
51
+ export function resolveSelfNodeId(ctx: SelfResolverContext): SelfResolution {
52
+ // 1. `--creds <jwt>` — bypasses every identity lookup. Read the sub claim.
53
+ if (ctx.credsJwt) {
54
+ try {
55
+ const sub = decodeJwt(ctx.credsJwt).sub
56
+ // `.trim().length` rejects whitespace-only subs (`' '`, `'\t'`).
57
+ // A hand-crafted JWT with `sub: ' '` previously round-tripped into
58
+ // `@ ::method`, producing a malformed kernel call instead of the
59
+ // typed `creds-no-sub` refusal.
60
+ if (typeof sub === 'string' && sub.trim().length > 0) return { id: sub }
61
+ } catch {
62
+ // fall through to creds-no-sub
63
+ }
64
+ return { reason: 'creds-no-sub' }
65
+ }
66
+ // 2. Instance-signed: no user identity context — `@self` is undefined.
67
+ if (ctx.instanceSigned) {
68
+ return { reason: 'instance-signed', instanceSlug: ctx.instanceSlug ?? 'unknown' }
69
+ }
70
+ // 3. IdP-backed identities: the provider token's `sub` is the IdP USER id
71
+ // (e.g. `user_01K…`), NEVER a graph node id — expanding to it always
72
+ // produced NOT_FOUND. Use a cached registration when one exists (a future
73
+ // `whoami`-backed cache can populate it); otherwise refuse with the recipe.
74
+ if ((ctx.identity?.source ?? 'key') === 'idp') {
75
+ const cached = ctx.instanceSlug
76
+ ? ctx.identity?.registrations?.[ctx.instanceSlug]?.sub
77
+ : undefined
78
+ if (cached) return { id: cached }
79
+ return { reason: 'idp-no-sub', identityName: ctx.identity?.name ?? '(unknown)' }
80
+ }
81
+ // 4. `--url` without `-i`: no slug to look up registration against.
82
+ if (!ctx.instanceSlug) return { reason: 'url-no-slug' }
83
+ // 5. Bootstrap `manager` identity has no graph node by construction.
84
+ if (
85
+ ctx.identity?.name === 'manager' &&
86
+ (!ctx.identity.registrations || Object.keys(ctx.identity.registrations).length === 0)
87
+ ) {
88
+ return { reason: 'manager' }
89
+ }
90
+ // 6. Default path: read the registration entry for the resolved slug.
91
+ // `sub` IS the node id by construction for entries written by
92
+ // `registerIdentity` (kernel/runtime/syscalls/identity/index.ts:207).
93
+ const id = ctx.identity?.registrations?.[ctx.instanceSlug]?.sub
94
+ if (!id) {
95
+ return {
96
+ reason: 'no-registration',
97
+ identityName: ctx.identity?.name ?? '(unknown)',
98
+ instanceSlug: ctx.instanceSlug,
99
+ }
100
+ }
101
+ return { id }
102
+ }
103
+
104
+ /** Cheap pre-check before invoking the resolver — skip when no `@self` appears. */
105
+ export function containsSelfRef(input: string): boolean {
106
+ SELF_RE.lastIndex = 0
107
+ return SELF_RE.test(input)
108
+ }
109
+
110
+ /**
111
+ * Replace every `@self` token (path head or `=@self`) with `@<selfId>`.
112
+ *
113
+ * Uses the function-form replacement so a `selfId` containing `$&`, `$$`,
114
+ * `$<n>`, etc. is not interpreted as a `String.replace` substitution
115
+ * pattern. A delegation-token `sub` claim or future opaque kernel id can
116
+ * legitimately carry `$`.
117
+ */
118
+ export function expandSelfReferences(input: string, selfId: string): string {
119
+ const replacement = `@${selfId}`
120
+ return input.replace(SELF_RE, () => replacement)
121
+ }
122
+
123
+ /** Build a human-facing error carrying the typed refusal as metadata. */
124
+ export function selfRefusalError(r: SelfRefusal): Error {
125
+ const e = new Error(refusalMessage(r))
126
+ e.name = 'SelfRefusalError'
127
+ ;(e as Error & { selfRefusal: SelfRefusal }).selfRefusal = r
128
+ return e
129
+ }
130
+
131
+ function refusalMessage(r: SelfRefusal): string {
132
+ switch (r.reason) {
133
+ case 'manager':
134
+ return [
135
+ "`@self` not available: you're signed in as the bootstrap `manager` identity, which has no graph node.",
136
+ 'Run `astrale identity create <name>` then `astrale identity register <name>` to enable `@self`.',
137
+ ].join('\n ')
138
+ case 'no-registration':
139
+ return [
140
+ `\`@self\` not available: identity "${r.identityName}" has no registration on instance "${r.instanceSlug}".`,
141
+ `Run \`astrale identity register ${r.identityName} -i ${r.instanceSlug}\`.`,
142
+ ].join('\n ')
143
+ case 'instance-signed':
144
+ return [
145
+ `\`@self\` not available: this call signs as instance "${r.instanceSlug}" itself, not a user identity.`,
146
+ 'Use `--as <name>` to sign as a registered identity, or pass a literal `@<nodeId>`.',
147
+ ].join('\n ')
148
+ case 'url-no-slug':
149
+ return [
150
+ '`@self` needs an instance slug to look up the registration.',
151
+ 'Add `-i <slug>`, or if this URL maps to a bookmark, use that slug instead of `--url`.',
152
+ ].join('\n ')
153
+ case 'creds-no-sub':
154
+ return [
155
+ '`@self` not available: the `--creds` JWT has no usable `sub` claim.',
156
+ 'Pass a literal `@<nodeId>` instead.',
157
+ ].join('\n ')
158
+ case 'idp-no-sub':
159
+ return [
160
+ `\`@self\` could not be resolved for IdP identity "${r.identityName}": no cached registration, and the automatic whoami lookup did not return a node id (offline, auth failure, or unprovisioned identity).`,
161
+ 'Get your node id manually, then use it directly:',
162
+ ' astrale call "/:kernel.astrale.ai:interface.Identity:whoami" --json # → { id: <nodeId> }',
163
+ 'and address yourself as `@<nodeId>`.',
164
+ ].join('\n ')
165
+ }
166
+ }