@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,155 @@
1
+ import chalk from 'chalk'
2
+
3
+ import type { CommandDefinition } from '../../command'
4
+ import type { KernelCommandOpts } from '../../kernel'
5
+
6
+ import { withAdminKernelClient } from '../../kernel/client'
7
+ import { ADMIN_DOMAIN, type DomainInfo } from '../../lib/admin-domain'
8
+ import { ADMIN_TARGET_OPTIONS, type AdminTargetCommandOpts } from '../../lib/admin-target'
9
+ import { fatal, withSpinner } from '../../lib/log'
10
+ import { isMachine, output } from '../../lib/output'
11
+ import { promptText } from '../../lib/prompt'
12
+ import { isHttpUrl, validateName, validateUrl } from '../../lib/validation'
13
+
14
+ type PublishOpts = KernelCommandOpts &
15
+ AdminTargetCommandOpts & {
16
+ origin?: string
17
+ name?: string
18
+ // `--public-url`, not `--url`: the global `--url` already means "target this
19
+ // kernel" (KERNEL_PASSTHROUGH_OPTIONS). This is the domain's own public
20
+ // address the kernel installs from — named for the role, not the substrate.
21
+ publicUrl?: string
22
+ description?: string
23
+ installByDefault?: boolean
24
+ // Global flags (program.ts) that force non-interactive — mirrors `instance use`.
25
+ ci?: boolean
26
+ noPrompt?: boolean
27
+ }
28
+
29
+ /** Host of a URL (the natural `origin` default), or undefined if unparseable. */
30
+ function hostOf(url?: string): string | undefined {
31
+ if (!url) return undefined
32
+ try {
33
+ return new URL(url).host
34
+ } catch {
35
+ return undefined
36
+ }
37
+ }
38
+
39
+ export default {
40
+ name: 'publish',
41
+ description: 'Register a deployed domain in the admin catalog (DomainEntry.publish)',
42
+ afterHelpText: `
43
+ Behavior:
44
+ Upserts a catalog entry on the configured admin kernel: a domain's addressing
45
+ \`origin\`, registry \`name\`, and published \`url\` (no bytes, no version — the
46
+ author deploys the worker independently; publish just points the registry at
47
+ it). Idempotent: re-publishing the same name updates its url — and a publish
48
+ that would change nothing is reported as "already up to date" (no write).
49
+
50
+ Publishing only makes the domain INSTALLABLE. Mount it on an instance with
51
+ \`astrale domain install <url>\` (or rely on the admin's install-by-default
52
+ policy). This is usually invoked for you by \`astrale-domain publish\` (which
53
+ registers the already-deployed URL — it does NOT deploy) or by
54
+ \`astrale-domain deploy --publish\` (deploy AND register in one step).
55
+
56
+ Run in a terminal with flags omitted and it PROMPTS for origin / name /
57
+ public-url (origin defaults to the URL host, name to the origin's first
58
+ label). With no TTY — or \`--ci\` / \`--no-prompt\` — those three are required
59
+ up front, so piped / CI / agent runs fail fast instead of waiting on input.
60
+
61
+ Examples:
62
+ $ astrale domain publish --origin crm.acme.dev --name crm --public-url https://crm.acme.dev
63
+ `,
64
+ options: [
65
+ ...ADMIN_TARGET_OPTIONS,
66
+ { flags: '--origin <origin>', description: 'Domain addressing origin (e.g. crm.acme.dev)' },
67
+ { flags: '--name <name>', description: 'Registry name / catalog slug (e.g. crm)' },
68
+ { flags: '--public-url <url>', description: 'Public URL the kernel installs the domain from' },
69
+ { flags: '--description <text>', description: 'Optional human description for the catalog' },
70
+ {
71
+ flags: '--install-by-default',
72
+ description: 'Mark the domain for install on every new instance (alphaCreate)',
73
+ },
74
+ ],
75
+ // No positional arguments → Commander passes (opts, command); `opts` is first.
76
+ action: async (opts: PublishOpts) => {
77
+ try {
78
+ // Interactive fill (TTY only): a human running this by hand is prompted for
79
+ // any missing field. The primary caller — `astrale-domain publish` — always
80
+ // passes every flag, so it never prompts. No TTY / --ci / --no-prompt / CI
81
+ // env → no prompt: fall straight through to the required-flag error below,
82
+ // so a piped / agent / LLM run fails fast instead of hanging on a read.
83
+ const interactive = !!process.stdin.isTTY && !(opts.ci || opts.noPrompt || process.env.CI)
84
+ let { origin, name, publicUrl } = opts
85
+ if (interactive) {
86
+ if (!publicUrl)
87
+ publicUrl = await promptText('Public URL (https://…)', {
88
+ validate: (v) => isHttpUrl(v) || 'Enter a valid http(s) URL',
89
+ })
90
+ if (!origin)
91
+ origin = await promptText('Domain origin (e.g. crm.acme.dev)', {
92
+ default: hostOf(publicUrl),
93
+ })
94
+ if (!name) name = await promptText('Registry name', { default: origin?.split('.')[0] })
95
+ }
96
+
97
+ if (!origin || !name || !publicUrl) {
98
+ throw new Error(
99
+ 'domain publish requires --origin, --name and --public-url, e.g.\n' +
100
+ ' astrale domain publish --origin crm.acme.dev --name crm --public-url https://crm.acme.dev',
101
+ )
102
+ }
103
+ validateName(origin, 'origin')
104
+ validateName(name, 'domain')
105
+ validateUrl(publicUrl)
106
+
107
+ const result = await withSpinner(
108
+ `Publishing ${name} → ${publicUrl}`,
109
+ !isMachine(opts),
110
+ () =>
111
+ withAdminKernelClient(opts, async (ctx) => {
112
+ // Read the current catalog entry first: a re-publish that would write
113
+ // the same origin/name/url/description/install flag is a no-op we
114
+ // report as "already up to date" (and skip the write) rather than
115
+ // silently bumping `updatedAt`. `info` throws when absent → treat as
116
+ // a fresh publish.
117
+ const existing = (await ctx.client
118
+ .call(`${ADMIN_DOMAIN}/info`, { origin })
119
+ .catch(() => null)) as DomainInfo | null
120
+ if (
121
+ existing &&
122
+ existing.name === name &&
123
+ existing.url === publicUrl &&
124
+ (opts.description === undefined || existing.description === opts.description) &&
125
+ (opts.installByDefault === undefined ||
126
+ (existing.installByDefault ?? false) === opts.installByDefault)
127
+ ) {
128
+ return { entry: existing, changed: false as const, isNew: false }
129
+ }
130
+ const entry = (await ctx.client.call(`${ADMIN_DOMAIN}/publish`, {
131
+ origin,
132
+ name,
133
+ url: publicUrl,
134
+ ...(opts.description ? { description: opts.description } : {}),
135
+ ...(opts.installByDefault ? { installByDefault: true } : {}),
136
+ })) as DomainInfo
137
+ return { entry, changed: true as const, isNew: !existing }
138
+ }),
139
+ {
140
+ success: ({ entry, changed, isNew }) =>
141
+ changed
142
+ ? `${isNew ? 'Published' : 'Updated'}: ${entry.name} ${chalk.dim(`(${entry.origin} → ${entry.url})`)}`
143
+ : `Already up to date: ${entry.name} ${chalk.dim(`(${entry.origin} → ${entry.url} — no change, already latest)`)}`,
144
+ },
145
+ )
146
+
147
+ if (isMachine(opts)) {
148
+ output({ ...result.entry, changed: result.changed }, opts)
149
+ return
150
+ }
151
+ } catch (e) {
152
+ fatal(e)
153
+ }
154
+ },
155
+ } satisfies CommandDefinition
@@ -0,0 +1,60 @@
1
+ import type { CommandDefinition } from '../command'
2
+ import type { KernelCommandOpts } from '../kernel'
3
+
4
+ import { expandSelfInPath, runKernelCommand, withSelfHint } from '../kernel'
5
+ import { log } from '../lib/log'
6
+ import { output } from '../lib/output'
7
+
8
+ type GetOpts = KernelCommandOpts & { long?: boolean }
9
+
10
+ const INTERNAL_KEYS = new Set(['__labels', 'classId'])
11
+
12
+ export async function getCommand(path: string, opts: GetOpts): Promise<void> {
13
+ let expandedPath: string
14
+ let meta
15
+ try {
16
+ ;({ path: expandedPath, meta } = await expandSelfInPath(path, opts))
17
+ } catch (e) {
18
+ log.error(e instanceof Error ? e.message : 'Invalid @self expansion')
19
+ process.exit(1)
20
+ }
21
+ await runKernelCommand({
22
+ opts,
23
+ label: `Node ${expandedPath}`,
24
+ fn: (ctx) => withSelfHint(() => ctx.client.call(`${expandedPath}::get`, {}), meta),
25
+ format: (result, fmtOpts) => {
26
+ output(opts.long ? result : cleanNode(result), fmtOpts)
27
+ },
28
+ })
29
+ }
30
+
31
+ function cleanNode(data: unknown): unknown {
32
+ if (!data || typeof data !== 'object') return data
33
+ const result: Record<string, unknown> = {}
34
+ for (const [k, v] of Object.entries(data as Record<string, unknown>)) {
35
+ if (INTERNAL_KEYS.has(k)) continue
36
+ result[k] = v
37
+ }
38
+ return result
39
+ }
40
+
41
+ export default {
42
+ name: 'get',
43
+ description: 'Get a node by path or ID',
44
+ afterHelpText: `
45
+ Behavior:
46
+ Accepts a tree path (/domain/class.Name) or an id (@nodeId). -l adds
47
+ the internal fields (__labels, classId) hidden in the summary view.
48
+
49
+ Examples:
50
+ $ astrale get /kernel.astrale.ai/class.Root
51
+ $ astrale get @abc123 -l
52
+ `,
53
+ arguments: [{ name: 'path', description: 'Node path (/domain/Class) or ID (@nodeId)' }],
54
+ options: [
55
+ { flags: '-l, --long', description: 'Include all internal fields (__labels, classId)' },
56
+ ],
57
+ action: async (path, opts) => {
58
+ await getCommand(path as string, opts as Parameters<typeof getCommand>[1])
59
+ },
60
+ } satisfies CommandDefinition
@@ -0,0 +1,26 @@
1
+ import type { CommandDefinition } from '../../command'
2
+
3
+ import { createIdentity } from '../../lib/identity'
4
+ import { fatal, log } from '../../lib/log'
5
+
6
+ export default {
7
+ name: 'create',
8
+ description: 'Create a new identity',
9
+ arguments: [{ name: 'name', description: 'Identity name', required: true }],
10
+ options: [
11
+ { flags: '--subject <sub>', description: 'Custom subject (defaults to name)' },
12
+ { flags: '--local', description: 'Local-only identity (default)' },
13
+ { flags: '--remote', description: 'Remote (cloud-synced) identity — requires cloud login' },
14
+ ],
15
+ action: async (name: string, opts: { subject?: string; local?: boolean; remote?: boolean }) => {
16
+ try {
17
+ const mode = opts.remote ? 'remote' : 'local'
18
+ const identity = await createIdentity(name, { subject: opts.subject, mode })
19
+ log.success(
20
+ `Created identity "${name}" (subject: ${identity.subject}, mode: ${identity.mode})`,
21
+ )
22
+ } catch (e) {
23
+ fatal(e)
24
+ }
25
+ },
26
+ } satisfies CommandDefinition
@@ -0,0 +1,18 @@
1
+ import type { CommandDefinition } from '../../command'
2
+
3
+ import { deleteIdentity } from '../../lib/identity'
4
+ import { fatal, log } from '../../lib/log'
5
+
6
+ export default {
7
+ name: 'delete',
8
+ description: 'Delete an identity',
9
+ arguments: [{ name: 'name', description: 'Identity name', required: true }],
10
+ action: async (name: string) => {
11
+ try {
12
+ await deleteIdentity(name)
13
+ log.success(`Deleted identity "${name}"`)
14
+ } catch (e) {
15
+ fatal(e)
16
+ }
17
+ },
18
+ } satisfies CommandDefinition
@@ -0,0 +1,66 @@
1
+ import { CompactEncrypt } from 'jose'
2
+ import { chmod, readFile, writeFile } from 'node:fs/promises'
3
+
4
+ import type { CommandDefinition } from '../../command'
5
+
6
+ import { getIdentity } from '../../lib/identity'
7
+ import { keypairPaths } from '../../lib/keys'
8
+ import { fatal, log } from '../../lib/log'
9
+ import { readPassphrase } from '../../lib/prompt'
10
+
11
+ /**
12
+ * Export the per-identity keypair as a plaintext JWK envelope by default.
13
+ * With `--encrypt`, wrap it in a JOSE JWE (PBES2-HS256+A128KW / A256GCM)
14
+ * using a passphrase prompted from the TTY.
15
+ */
16
+ export default {
17
+ name: 'export',
18
+ description: 'Export an identity keypair to disk (optional --encrypt)',
19
+ arguments: [
20
+ { name: 'name', description: 'Identity name', required: true },
21
+ { name: 'path', description: 'Output file path', required: true },
22
+ ],
23
+ options: [
24
+ { flags: '--encrypt', description: 'Encrypt the envelope with a passphrase (JOSE JWE)' },
25
+ ],
26
+ action: async (name: string, path: string, opts: { encrypt?: boolean }) => {
27
+ try {
28
+ const identity = await getIdentity(name)
29
+ const { privatePath, publicPath } = keypairPaths(identity.subject)
30
+ const [privateJwk, publicJwk] = await Promise.all([
31
+ readFile(privatePath, 'utf-8').then(JSON.parse),
32
+ readFile(publicPath, 'utf-8').then(JSON.parse),
33
+ ])
34
+ const envelope = {
35
+ version: 1,
36
+ subject: identity.subject,
37
+ mode: identity.mode ?? 'local',
38
+ kid: identity.kid,
39
+ issuer: identity.issuer,
40
+ privateJwk,
41
+ publicJwk,
42
+ }
43
+ const plain = JSON.stringify(envelope, null, 2)
44
+
45
+ if (opts.encrypt) {
46
+ const passphrase = await readPassphrase('Passphrase (min 8 chars): ', { minLength: 8 })
47
+ const enc = await new CompactEncrypt(new TextEncoder().encode(plain))
48
+ .setProtectedHeader({ alg: 'PBES2-HS256+A128KW', enc: 'A256GCM' })
49
+ .encrypt(new TextEncoder().encode(passphrase))
50
+ await writeFile(path, enc)
51
+ } else {
52
+ await writeFile(path, plain)
53
+ }
54
+ await chmod(path, 0o600)
55
+
56
+ log.success(`Exported identity "${name}" → ${path}${opts.encrypt ? ' (encrypted)' : ''}`)
57
+ if (!opts.encrypt) {
58
+ log.warn(
59
+ ' Plaintext private JWK — keep this file secure. Use --encrypt for passphrase wrapping.',
60
+ )
61
+ }
62
+ } catch (e) {
63
+ fatal(e)
64
+ }
65
+ },
66
+ } satisfies CommandDefinition
@@ -0,0 +1,101 @@
1
+ import { compactDecrypt, importJWK } from 'jose'
2
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
3
+ import { dirname } from 'node:path'
4
+
5
+ import type { CommandDefinition } from '../../command'
6
+ import type { RegistryMode } from '../../lib/validation'
7
+
8
+ import { createIdentity, upsertKeyIdentity } from '../../lib/identity'
9
+ import { keypairPaths } from '../../lib/keys'
10
+ import { fatal, log } from '../../lib/log'
11
+ import { readPassphrase } from '../../lib/prompt'
12
+
13
+ type ExportEnvelope = {
14
+ version?: number
15
+ subject: string
16
+ mode?: RegistryMode
17
+ kid?: string
18
+ issuer?: string
19
+ privateJwk: Record<string, unknown>
20
+ publicJwk: Record<string, unknown>
21
+ }
22
+
23
+ function looksEncrypted(raw: string): boolean {
24
+ // JOSE compact JWE is 5 base64 segments separated by `.`.
25
+ return raw.trim().split('.').length === 5 && !raw.trim().startsWith('{')
26
+ }
27
+
28
+ export default {
29
+ name: 'import',
30
+ description: 'Import an identity keypair envelope (auto-detects JWE)',
31
+ arguments: [{ name: 'path', description: 'Envelope file path', required: true }],
32
+ options: [
33
+ {
34
+ flags: '--name <name>',
35
+ description: 'Override identity name (defaults to envelope subject)',
36
+ },
37
+ {
38
+ flags: '--issuer <url>',
39
+ description: 'Default issuer for credentials signed with this imported key',
40
+ },
41
+ {
42
+ flags: '--replace',
43
+ description: 'Replace an existing key-backed identity with the imported keypair',
44
+ },
45
+ ],
46
+ action: async (path: string, opts: { name?: string; issuer?: string; replace?: boolean }) => {
47
+ try {
48
+ const raw = await readFile(path, 'utf-8')
49
+
50
+ let envelopeJson: string
51
+ if (looksEncrypted(raw)) {
52
+ const passphrase = await readPassphrase('Passphrase: ')
53
+ const { plaintext } = await compactDecrypt(
54
+ raw.trim(),
55
+ new TextEncoder().encode(passphrase),
56
+ {
57
+ keyManagementAlgorithms: ['PBES2-HS256+A128KW'],
58
+ },
59
+ )
60
+ envelopeJson = new TextDecoder().decode(plaintext)
61
+ } else {
62
+ envelopeJson = raw
63
+ }
64
+
65
+ const env = JSON.parse(envelopeJson) as ExportEnvelope
66
+ if (!env?.subject || !env?.privateJwk || !env?.publicJwk) {
67
+ fatal(new Error('Invalid envelope: missing subject / privateJwk / publicJwk'))
68
+ }
69
+
70
+ // Validate the keypair parses before touching the registry.
71
+ await importJWK(env.privateJwk as never, 'ES256')
72
+
73
+ const name = opts.name ?? env.subject
74
+ // Create registry entry (without regenerating keys — we'll write the imported ones).
75
+ if (opts.replace) {
76
+ await upsertKeyIdentity(name, {
77
+ subject: env.subject,
78
+ mode: env.mode ?? 'local',
79
+ issuer: opts.issuer ?? env.issuer,
80
+ kid: env.kid,
81
+ })
82
+ } else {
83
+ await createIdentity(name, {
84
+ subject: env.subject,
85
+ mode: env.mode ?? 'local',
86
+ issuer: opts.issuer ?? env.issuer,
87
+ kid: env.kid,
88
+ skipKeygen: true,
89
+ })
90
+ }
91
+ const { privatePath, publicPath } = keypairPaths(env.subject)
92
+ await mkdir(dirname(privatePath), { recursive: true })
93
+ await writeFile(privatePath, JSON.stringify(env.privateJwk, null, 2), { mode: 0o600 })
94
+ await writeFile(publicPath, JSON.stringify(env.publicJwk, null, 2), { mode: 0o600 })
95
+
96
+ log.success(`Imported identity "${name}" (subject=${env.subject}, kid=${env.kid ?? '?'})`)
97
+ } catch (e) {
98
+ fatal(e)
99
+ }
100
+ },
101
+ } satisfies CommandDefinition
@@ -0,0 +1,56 @@
1
+ import chalk from 'chalk'
2
+
3
+ import type { CommandDefinition } from '../../command'
4
+ import type { ListOpts, ListProjection } from '../../lib/output'
5
+
6
+ import { readIdentities } from '../../lib/identity'
7
+ import { log } from '../../lib/log'
8
+ import { isMachine, presentList, RAW_OUTPUT_OPTIONS } from '../../lib/output'
9
+
10
+ type IdentityRow = {
11
+ name: string
12
+ subject: string
13
+ source: string
14
+ idp?: string
15
+ default: boolean
16
+ createdAt?: string
17
+ }
18
+
19
+ function projection(items: IdentityRow[]): ListProjection {
20
+ return {
21
+ columns: [
22
+ { key: 'name', header: 'NAME', color: chalk.bold },
23
+ { key: 'subject', header: 'SUBJECT', color: chalk.dim },
24
+ { key: 'source', header: 'SOURCE', color: chalk.dim },
25
+ ],
26
+ rows: items.map((i) => ({
27
+ name: i.default ? `${i.name} ${chalk.green('*')}` : i.name,
28
+ subject: i.subject !== i.name ? i.subject : '',
29
+ source: i.source === 'idp' ? `idp:${i.idp ?? '?'}` : 'key',
30
+ })),
31
+ paths: items.map((i) => i.name),
32
+ }
33
+ }
34
+
35
+ export default {
36
+ name: 'list',
37
+ description: 'List all identities',
38
+ options: [...RAW_OUTPUT_OPTIONS],
39
+ action: async (opts: ListOpts) => {
40
+ const store = await readIdentities()
41
+ const items: IdentityRow[] = Object.entries(store.identities).map(([name, id]) => ({
42
+ name,
43
+ subject: id.subject,
44
+ source: id.source ?? 'key',
45
+ idp: id.idp,
46
+ default: name === store.default,
47
+ createdAt: id.createdAt,
48
+ }))
49
+
50
+ if (items.length === 0 && !isMachine(opts)) {
51
+ log.dim(' No identities. Run: astrale identity create <name>')
52
+ return
53
+ }
54
+ presentList(items, opts, projection)
55
+ },
56
+ } satisfies CommandDefinition
@@ -0,0 +1,170 @@
1
+ import { K } from '@astrale-os/kernel-core'
2
+ import { importJWK, SignJWT, type JWK } from 'jose'
3
+ import { readFile } from 'node:fs/promises'
4
+
5
+ import type { CommandDefinition } from '../../command'
6
+ import type { KernelCommandOpts } from '../../kernel'
7
+
8
+ import { runKernelCommand } from '../../kernel'
9
+ import { KERNEL_PASSTHROUGH_OPTIONS } from '../../kernel/options'
10
+ import { getIdentity, setRegistration } from '../../lib/identity'
11
+ import { getActive } from '../../lib/instance'
12
+ import { fileExists, keypairPaths } from '../../lib/keys'
13
+ import { fatal, log } from '../../lib/log'
14
+ import { output } from '../../lib/output'
15
+
16
+ type CreateNodeResult = { id: string; path: string }
17
+ type RegisterIdentityResult = { iss: string; sub: string }
18
+
19
+ type RegisterOpts = KernelCommandOpts & {
20
+ class?: string
21
+ path?: string
22
+ props?: string
23
+ }
24
+
25
+ async function readJwk(path: string): Promise<JWK> {
26
+ const raw = await readFile(path, 'utf-8')
27
+ return JSON.parse(raw) as JWK
28
+ }
29
+
30
+ type ChildNode = { class?: string; path?: string }
31
+
32
+ /**
33
+ * Discover the identity-bearer class on the target instance: find the
34
+ * installed distribution domain (whatever its origin — `dist.astrale.ai` in
35
+ * prod, `dist.localhost` locally) and address its `User` class semantically.
36
+ * The bearer class must exist before a non-root identity can be registered;
37
+ * failing here names the real problem instead of surfacing as a confusing
38
+ * permission error on a hardcoded prod origin.
39
+ */
40
+ async function resolveUserClassPath(ctx: {
41
+ client: { call(path: string, params: unknown): Promise<unknown> }
42
+ }): Promise<string> {
43
+ const children = (await ctx.client.call('/::listChildren', {})) as ChildNode[]
44
+ const domains = children
45
+ .filter((c) => typeof c.class === 'string' && c.class.endsWith(':class.Domain'))
46
+ .map((c) => (c.path ?? '').replace(/^\//, ''))
47
+ .filter(Boolean)
48
+ for (const origin of domains) {
49
+ try {
50
+ // The class materializes as a `class.User` Folder under the domain mount;
51
+ // a resolvable read means the domain declares it.
52
+ await ctx.client.call(`/${origin}/class.User::get`, {})
53
+ return `/:${origin}:class.User`
54
+ } catch {
55
+ // This domain has no User class — try the next one.
56
+ }
57
+ }
58
+ throw new Error(
59
+ 'No installed domain declares a `User` class on this instance — registering a ' +
60
+ 'non-root identity needs an identity-bearer class (install the distribution ' +
61
+ 'domain, or pass --class <classPath> explicitly).',
62
+ )
63
+ }
64
+
65
+ async function mintBootstrapJwt(privateJwk: JWK): Promise<string> {
66
+ const kid = (privateJwk.kid as string | undefined) ?? 'bootstrap'
67
+ const key = await importJWK(privateJwk, 'ES256')
68
+ return new SignJWT({})
69
+ .setProtectedHeader({ alg: 'ES256', kid })
70
+ .setIssuer('self')
71
+ .setSubject('bootstrap')
72
+ .setAudience('bootstrap')
73
+ .setIssuedAt()
74
+ .setExpirationTime('5m')
75
+ .sign(key)
76
+ }
77
+
78
+ export default {
79
+ name: 'register',
80
+ description:
81
+ "Register a local identity with the target instance's kernel — publishes the public " +
82
+ 'key under a thumbprint-derived issuer and caches the resolved (iss, sub) for future calls',
83
+ arguments: [{ name: 'name', description: 'Identity name', required: true }],
84
+ options: [
85
+ {
86
+ flags: '--class <classPath>',
87
+ description:
88
+ 'Class path of the identity node to create (default: /:dist.<origin>:class.User when distribution is installed)',
89
+ },
90
+ {
91
+ flags: '--path <nodePath>',
92
+ description: 'Path of the new identity node (default: /workspace/users/<name>)',
93
+ },
94
+ {
95
+ flags: '--props <json>',
96
+ description:
97
+ 'Extra props for the identity node (JSON). A User-class node defaults ' +
98
+ 'firstName/lastName to the identity name when omitted.',
99
+ },
100
+ ...KERNEL_PASSTHROUGH_OPTIONS,
101
+ ],
102
+ action: async (name: string, opts: RegisterOpts) => {
103
+ try {
104
+ const identity = await getIdentity(name)
105
+ const { privatePath, publicPath } = keypairPaths(identity.subject)
106
+ if (!(await fileExists(privatePath)) || !(await fileExists(publicPath))) {
107
+ fatal(
108
+ new Error(
109
+ `No keypair on disk for "${name}" (expected ${privatePath}). Recreate via \`astrale identity create ${name}\`.`,
110
+ ),
111
+ )
112
+ }
113
+ const privateJwk = await readJwk(privatePath)
114
+ const publicJwk = await readJwk(publicPath)
115
+ const nodePath = opts.path ?? `/workspace/users/${name}`
116
+
117
+ await runKernelCommand({
118
+ opts,
119
+ label: `Register "${name}"`,
120
+ fn: async (ctx) => {
121
+ const instanceSlug = opts.instance ?? opts.url ?? (await getActive(ctx.config)).name
122
+ const existing = identity.registrations?.[instanceSlug]
123
+ if (existing) {
124
+ log.warn(`"${name}" already registered on "${instanceSlug}"`)
125
+ return existing
126
+ }
127
+
128
+ const classPath = opts.class ?? (await resolveUserClassPath(ctx))
129
+
130
+ // A bare `astrale identity create <name>` carries no profile, but the
131
+ // bearer class may require one (distribution's User wants
132
+ // firstName/lastName) — default both to the identity name so a dev
133
+ // registration works out of the box; `--props` overrides.
134
+ const extraProps = opts.props ? (JSON.parse(opts.props) as Record<string, unknown>) : {}
135
+ const userDefaults = classPath.endsWith(':class.User')
136
+ ? { firstName: name, lastName: name }
137
+ : {}
138
+
139
+ const node = (await ctx.client.call(K.Node.createNode.path.method.raw, {
140
+ class: classPath,
141
+ path: nodePath,
142
+ props: { 'Statused.status': 'creating', ...userDefaults, ...extraProps },
143
+ })) as CreateNodeResult
144
+
145
+ const bootstrap = await mintBootstrapJwt(privateJwk)
146
+ const result = (await ctx.client.call(`@${node.id}::registerIdentity`, {
147
+ signingKey: {
148
+ publicKey: { jwk: publicJwk },
149
+ credential: bootstrap,
150
+ },
151
+ })) as RegisterIdentityResult
152
+
153
+ await setRegistration(name, instanceSlug, {
154
+ iss: result.iss,
155
+ sub: result.sub,
156
+ registeredAt: new Date().toISOString(),
157
+ })
158
+ return result
159
+ },
160
+ format: (result, fmtOpts) => {
161
+ output(result, fmtOpts)
162
+ log.dim(` iss=${result.iss}`)
163
+ log.dim(` sub=${result.sub}`)
164
+ },
165
+ })
166
+ } catch (e) {
167
+ fatal(e)
168
+ }
169
+ },
170
+ } satisfies CommandDefinition