@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,74 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import type { DomainInfo } from '../../lib/admin-domain'
4
+
5
+ import { byDefaultThenName, domainProjection, type DomainRow } from '../domain/list'
6
+
7
+ const strip = (s: string): string => s.replace(/\[[0-9;]*m/g, '')
8
+
9
+ function entry(over: Partial<DomainInfo>): DomainInfo {
10
+ return {
11
+ id: over.origin ?? 'id',
12
+ origin: 'x.astrale.ai',
13
+ name: 'x',
14
+ createdAt: '',
15
+ updatedAt: '',
16
+ ...over,
17
+ }
18
+ }
19
+
20
+ describe('domain list — ordering', () => {
21
+ test('install-by-default sorts first, then alphabetical by origin', () => {
22
+ const rows = [
23
+ entry({ origin: 'zeta.astrale.ai' }),
24
+ entry({ origin: 'alpha.astrale.ai' }),
25
+ entry({ origin: 'mid.astrale.ai', installByDefault: true }),
26
+ entry({ origin: 'beta.astrale.ai', installByDefault: true }),
27
+ ]
28
+ rows.sort(byDefaultThenName)
29
+ expect(rows.map((r) => r.origin)).toEqual([
30
+ 'beta.astrale.ai', // default group, alpha order
31
+ 'mid.astrale.ai',
32
+ 'alpha.astrale.ai', // non-default group, alpha order
33
+ 'zeta.astrale.ai',
34
+ ])
35
+ })
36
+ })
37
+
38
+ describe('domain list — projection', () => {
39
+ test('row carries name/origin/url and a default marker; -q paths are install urls', () => {
40
+ const proj = domainProjection([
41
+ entry({
42
+ origin: 'crm.acme.dev',
43
+ name: 'crm',
44
+ url: 'https://crm.acme.dev',
45
+ installByDefault: true,
46
+ }),
47
+ ])
48
+ const row = proj.rows[0]
49
+ expect(strip(row.name)).toBe('crm')
50
+ expect(strip(row.origin)).toBe('crm.acme.dev')
51
+ expect(strip(row.url)).toBe('https://crm.acme.dev')
52
+ expect(strip(row.default)).toBe('default')
53
+ // The quiet/pipeable token is the install URL, not the origin.
54
+ expect(proj.paths).toEqual(['https://crm.acme.dev'])
55
+ })
56
+
57
+ test('an unpublished entry shows a placeholder url and falls back to origin for -q', () => {
58
+ const proj = domainProjection([entry({ origin: 'pending.dev', name: 'pending' })])
59
+ expect(strip(proj.rows[0].url)).toBe('(unpublished)')
60
+ expect(strip(proj.rows[0].default)).toBe('')
61
+ expect(proj.paths).toEqual(['pending.dev'])
62
+ })
63
+
64
+ test('STATUS cell is empty without --check, live/down with it', () => {
65
+ const base = entry({ origin: 'a.dev', url: 'https://a.dev' })
66
+ expect(strip(domainProjection([base]).rows[0].status)).toBe('')
67
+
68
+ const live: DomainRow = { ...base, reachable: true, checkError: null }
69
+ expect(strip(domainProjection([live]).rows[0].status)).toBe('● live')
70
+
71
+ const down: DomainRow = { ...base, reachable: false, checkError: 'meta HTTP 502' }
72
+ expect(strip(domainProjection([down]).rows[0].status)).toBe('○ meta HTTP 502')
73
+ })
74
+ })
@@ -0,0 +1,136 @@
1
+ import type { Command } from 'commander'
2
+
3
+ import { describe, expect, test } from 'bun:test'
4
+ import { existsSync, readFileSync } from 'node:fs'
5
+ import { join } from 'node:path'
6
+
7
+ import { buildProgram } from '../../program'
8
+
9
+ // The `astrale-cli` skill claims `astrale --help` is the source of truth and
10
+ // "never drifts". These tests hold that claim to account: the version is
11
+ // single-sourced from package.json, no internal `§` spec anchors leak into
12
+ // rendered help, and the workspace skill mirror stays byte-identical.
13
+
14
+ const cliRoot = join(import.meta.dir, '../../..')
15
+
16
+ /** Every command in the tree (root + groups + nested subgroups), depth-first. */
17
+ function allCommands(cmd: Command): Command[] {
18
+ return [cmd, ...cmd.commands.flatMap(allCommands)]
19
+ }
20
+
21
+ describe('help contract — version is single-sourced', () => {
22
+ test('program version === package.json === release-please manifest', async () => {
23
+ const program = await buildProgram()
24
+ const pkg = JSON.parse(readFileSync(join(cliRoot, 'package.json'), 'utf8')) as {
25
+ version: string
26
+ }
27
+ const manifest = JSON.parse(
28
+ readFileSync(join(cliRoot, '.release-please-manifest.json'), 'utf8'),
29
+ ) as Record<string, string>
30
+
31
+ // `bin/astrale.ts` -> buildProgram -> .version(pkg.version): asserting the
32
+ // rendered version equals BOTH JSON sources catches a re-hardcoded literal
33
+ // and a manifest/package.json divergence (release-please bumps both).
34
+ expect(program.version()).toBe(pkg.version)
35
+ expect(manifest['.']).toBe(pkg.version)
36
+ })
37
+ })
38
+
39
+ describe('help contract — no internal SPEC anchors leak to users', () => {
40
+ test('no rendered --help text contains a § section anchor', async () => {
41
+ const program = await buildProgram()
42
+ const offenders = allCommands(program)
43
+ .filter((c) => c.helpInformation().includes('§'))
44
+ .map((c) => c.name() || '<root>')
45
+
46
+ expect(offenders).toEqual([])
47
+ })
48
+ })
49
+
50
+ describe('help contract — IdP/auth surface is registered', () => {
51
+ test('idp group and auth commands are visible in --help tree', async () => {
52
+ const program = await buildProgram()
53
+ const names = allCommands(program).map((command) => command.name())
54
+
55
+ expect(names).toContain('idp')
56
+ expect(names).toContain('add')
57
+ expect(names).toContain('login')
58
+ expect(names).toContain('token')
59
+ expect(names).toContain('update')
60
+ expect(program.helpInformation()).toContain('idp')
61
+ expect(program.helpInformation()).toContain('update')
62
+ })
63
+ })
64
+
65
+ describe('help contract — admin target surface is registered', () => {
66
+ test('admin group and admin-target flags are visible', async () => {
67
+ const program = await buildProgram()
68
+ const names = allCommands(program).map((command) => command.name())
69
+ const instanceCreate = allCommands(program).find((command) => command.name() === 'create')
70
+
71
+ expect(names).toContain('admin')
72
+ expect(names).toContain('status')
73
+ expect(names).toContain('use')
74
+ expect(program.helpInformation()).toContain('admin')
75
+ expect(instanceCreate?.helpInformation()).toContain('--admin <name>')
76
+ expect(instanceCreate?.helpInformation()).toContain('--admin-url <url>')
77
+ })
78
+
79
+ test('instance create is the alphaCreate flow, not legacy Instance.init DX', async () => {
80
+ const program = await buildProgram()
81
+ const instanceCreate = allCommands(program).find((command) => command.name() === 'create')
82
+ const help = instanceCreate?.helpInformation() ?? ''
83
+
84
+ expect(help).toContain('Instance.alphaCreate')
85
+ expect(help).not.toContain('--no-use')
86
+ expect(help).not.toContain('Instance.init requires --host-id')
87
+ })
88
+ })
89
+
90
+ describe('help contract — connect-only command surface', () => {
91
+ test('runtime management commands are not registered', async () => {
92
+ const program = await buildProgram()
93
+ const names = allCommands(program).map((command) => command.name())
94
+
95
+ // 'logs' and 'domain' are NOT in this list anymore: each was once LOCAL
96
+ // runtime management (the historical `astrale logs`, removed with
97
+ // start/stop; the historical `astrale domain`, local domain wiring) and each
98
+ // has since been RECLAIMED for a connect-side meaning through the admin
99
+ // control plane — `astrale logs <service>` tails a MANAGED service's buffer;
100
+ // `astrale domain publish` registers an installable domain in the admin's
101
+ // catalog and `astrale domain install <url>` mounts one on an instance.
102
+ for (const removed of [
103
+ 'init',
104
+ 'start',
105
+ 'stop',
106
+ 'restart',
107
+ 'reset',
108
+ 'bootstrap',
109
+ 'tunnel',
110
+ 'graph',
111
+ 'server',
112
+ 'env',
113
+ ]) {
114
+ expect(names).not.toContain(removed)
115
+ }
116
+ })
117
+ })
118
+
119
+ describe('help contract — skill is single-source, not duplicated', () => {
120
+ const canonical = join(cliRoot, 'skills/astrale-cli/SKILL.md')
121
+ // Workspace mirror lives in the superrepo, outside this submodule. Absent
122
+ // when the CLI repo is tested standalone — only assert parity when present.
123
+ const mirror = join(cliRoot, '../.agents/skills/astrale-cli/SKILL.md')
124
+
125
+ test('canonical skill file exists and is non-empty', () => {
126
+ expect(existsSync(canonical)).toBe(true)
127
+ expect(readFileSync(canonical, 'utf8').length).toBeGreaterThan(0)
128
+ })
129
+
130
+ test.skipIf(!existsSync(mirror))(
131
+ 'workspace mirror is byte-identical to the canonical skill',
132
+ () => {
133
+ expect(readFileSync(mirror, 'utf8')).toBe(readFileSync(canonical, 'utf8'))
134
+ },
135
+ )
136
+ })
@@ -0,0 +1,65 @@
1
+ import { afterAll, describe, expect, test } from 'bun:test'
2
+
3
+ import { domainRefFromTarget, isIdentityOverride, probeDeclaredOrigin } from '../domain/install'
4
+
5
+ describe('admin-path target classification', () => {
6
+ test('an http(s) url installs by url', () => {
7
+ expect(domainRefFromTarget('https://crm.acme.dev')).toEqual({ url: 'https://crm.acme.dev' })
8
+ expect(domainRefFromTarget('http://localhost:8787')).toEqual({ url: 'http://localhost:8787' })
9
+ })
10
+
11
+ test('a bare origin installs by catalog origin (the unique registry key)', () => {
12
+ expect(domainRefFromTarget('crm.acme.dev')).toEqual({ origin: 'crm.acme.dev' })
13
+ // A host:port that is not a url is still an origin, not a url.
14
+ expect(domainRefFromTarget('dist.astrale.ai')).toEqual({ origin: 'dist.astrale.ai' })
15
+ })
16
+ })
17
+
18
+ describe('identity-override detection', () => {
19
+ test('origin matching the serving host is not an override', () => {
20
+ expect(isIdentityOverride('crm.acme.dev', 'crm.acme.dev')).toBe(false)
21
+ expect(isIdentityOverride('CRM.Acme.Dev', 'crm.acme.dev')).toBe(false)
22
+ })
23
+
24
+ test('origin differing from the serving host is an override', () => {
25
+ // The spec §5 attack shape: a fork on workers.dev claiming a well-known origin.
26
+ expect(isIdentityOverride('distribution.astrale.ai', 'crm.workers.dev')).toBe(true)
27
+ // The scaffold default also aliases until the placeholder origin is edited.
28
+ expect(isIdentityOverride('hldom.example.dev', 'hldom-example-dev.acme.workers.dev')).toBe(true)
29
+ })
30
+ })
31
+
32
+ describe('declared-origin probe (/meta)', () => {
33
+ const servers: { stop(): void }[] = []
34
+ afterAll(() => {
35
+ for (const s of servers) s.stop()
36
+ })
37
+
38
+ function serveMeta(handler: (req: Request) => Response): string {
39
+ const server = Bun.serve({ port: 0, fetch: handler })
40
+ servers.push(server)
41
+ return `http://localhost:${server.port}`
42
+ }
43
+
44
+ test('reads domainName from a well-formed /meta', async () => {
45
+ const url = serveMeta((req) =>
46
+ new URL(req.url).pathname === '/meta'
47
+ ? Response.json({ iss: 'https://x', domainName: 'crm.acme.dev' })
48
+ : new Response('nope', { status: 404 }),
49
+ )
50
+ expect(await probeDeclaredOrigin(url)).toBe('crm.acme.dev')
51
+ })
52
+
53
+ test('degrades to undefined on missing domainName, non-200, bad JSON, or dead host', async () => {
54
+ const noName = serveMeta(() => Response.json({ iss: 'https://x' }))
55
+ expect(await probeDeclaredOrigin(noName)).toBeUndefined()
56
+
57
+ const error = serveMeta(() => new Response('boom', { status: 500 }))
58
+ expect(await probeDeclaredOrigin(error)).toBeUndefined()
59
+
60
+ const badJson = serveMeta(() => new Response('<html>', { status: 200 }))
61
+ expect(await probeDeclaredOrigin(badJson)).toBeUndefined()
62
+
63
+ expect(await probeDeclaredOrigin('http://127.0.0.1:1')).toBeUndefined()
64
+ })
65
+ })
@@ -0,0 +1,101 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ const cliRoot = join(import.meta.dir, '../../..')
7
+
8
+ let tmp: string
9
+
10
+ beforeEach(async () => {
11
+ tmp = await mkdtemp(join(tmpdir(), 'astrale-instance-bookmark-'))
12
+ })
13
+
14
+ afterEach(async () => {
15
+ await rm(tmp, { recursive: true, force: true })
16
+ })
17
+
18
+ describe('instance bookmark command', () => {
19
+ test('normalizes managed instance public roots to /api when creating a bookmark', async () => {
20
+ const result = await runBookmark(
21
+ 'testmarc',
22
+ '--url',
23
+ 'https://testmarc.eu.astrale.ai',
24
+ '--skip-probe',
25
+ )
26
+
27
+ expect(result.exitCode).toBe(0)
28
+ expect(result.stdout).toContain('Bookmarked "testmarc"')
29
+
30
+ const store = await readInstances()
31
+ expect(store.instances.testmarc.url).toBe('https://testmarc.eu.astrale.ai/api')
32
+ })
33
+
34
+ test('repairs an existing managed-root bookmark without clearing optional fields', async () => {
35
+ await mkdir(tmp, { recursive: true })
36
+ await writeFile(
37
+ join(tmp, 'instances.json'),
38
+ JSON.stringify(
39
+ {
40
+ active: 'testmarc',
41
+ instances: {
42
+ testmarc: {
43
+ url: 'https://testmarc.eu.astrale.ai',
44
+ issuer: 'https://issuer.example.com',
45
+ defaultIdentity: 'marc',
46
+ caFile: '/tmp/ca.pem',
47
+ createdAt: '2026-06-10T00:00:00.000Z',
48
+ kind: 'bookmark',
49
+ mode: 'remote',
50
+ },
51
+ },
52
+ },
53
+ null,
54
+ 2,
55
+ ) + '\n',
56
+ )
57
+
58
+ const result = await runBookmark(
59
+ 'testmarc',
60
+ '--url',
61
+ 'https://testmarc.eu.astrale.ai',
62
+ '--skip-probe',
63
+ )
64
+
65
+ expect(result.exitCode).toBe(0)
66
+ expect(result.stdout).toContain('Updated bookmark "testmarc"')
67
+
68
+ const store = await readInstances()
69
+ expect(store.instances.testmarc.url).toBe('https://testmarc.eu.astrale.ai/api')
70
+ expect(store.instances.testmarc.issuer).toBe('https://issuer.example.com')
71
+ expect(store.instances.testmarc.defaultIdentity).toBe('marc')
72
+ expect(store.instances.testmarc.caFile).toBe('/tmp/ca.pem')
73
+ expect(store.instances.testmarc.createdAt).toBe('2026-06-10T00:00:00.000Z')
74
+ })
75
+ })
76
+
77
+ async function readInstances(): Promise<{
78
+ active: string
79
+ instances: Record<string, Record<string, unknown>>
80
+ }> {
81
+ return JSON.parse(await readFile(join(tmp, 'instances.json'), 'utf-8'))
82
+ }
83
+
84
+ async function runBookmark(...args: string[]): Promise<{
85
+ exitCode: number
86
+ stdout: string
87
+ stderr: string
88
+ }> {
89
+ const proc = Bun.spawn({
90
+ cmd: ['bun', join(cliRoot, 'bin/astrale.ts'), 'instance', 'bookmark', ...args],
91
+ env: { ...process.env, ASTRALE_HOME: tmp },
92
+ stdout: 'pipe',
93
+ stderr: 'pipe',
94
+ })
95
+ const [stdout, stderr, exitCode] = await Promise.all([
96
+ new Response(proc.stdout).text(),
97
+ new Response(proc.stderr).text(),
98
+ proc.exited,
99
+ ])
100
+ return { exitCode, stdout, stderr }
101
+ }
@@ -0,0 +1,29 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { parseEligibleHostIds } from '../instance/create'
4
+
5
+ // `instance create` recovers from alphaCreate's MULTI-host ambiguity by popping
6
+ // a picker; the recovery hinges on pulling the eligible ids out of that error.
7
+ // These pin the parse to the server's wording (Option B — no admin change).
8
+ describe('parseEligibleHostIds', () => {
9
+ test('extracts the ids the server listed (the recoverable, >1 case)', () => {
10
+ const e = new Error(
11
+ 'alphaCreate could not choose a host: 2 ready hosts are assigned (host-1, host-paris-02). ' +
12
+ 'Specify host_id once multi-host placement is enabled.',
13
+ )
14
+ expect(parseEligibleHostIds(e)).toEqual(['host-1', 'host-paris-02'])
15
+ })
16
+
17
+ test('returns null for the no-host error (nothing to pick — falls through to fatal)', () => {
18
+ const e = new Error(
19
+ 'alphaCreate could not choose a host: no ready host is assigned to this user. ' +
20
+ 'Ask an admin to assign (grant USE) a host.',
21
+ )
22
+ expect(parseEligibleHostIds(e)).toBeNull()
23
+ })
24
+
25
+ test('returns null for unrelated errors and non-Error values', () => {
26
+ expect(parseEligibleHostIds(new Error('Permission denied'))).toBeNull()
27
+ expect(parseEligibleHostIds('something odd')).toBeNull()
28
+ })
29
+ })
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import type { InstanceInfo } from '../../lib/admin-instance'
4
+
5
+ import { buildInstanceRows, type Bookmark } from '../instance/list'
6
+
7
+ const managed: InstanceInfo[] = [
8
+ { id: 'demo', slug: 'demo', url: 'https://demo.eu.astrale.ai', region: 'eu' },
9
+ ]
10
+
11
+ function bookmark(overrides: Partial<Bookmark> = {}): Bookmark {
12
+ return {
13
+ name: 'demo',
14
+ url: 'https://demo.eu.astrale.ai/api',
15
+ issuer: null,
16
+ active: false,
17
+ defaultIdentity: null,
18
+ createdAt: null,
19
+ ...overrides,
20
+ }
21
+ }
22
+
23
+ describe('buildInstanceRows', () => {
24
+ test('a bookmark of a managed instance does not produce a second row', () => {
25
+ const rows = buildInstanceRows(managed, [bookmark()], { managed: true, bookmarks: true })
26
+ expect(rows).toHaveLength(1)
27
+ expect(rows[0]).toMatchObject({ kind: 'managed' })
28
+ })
29
+
30
+ test('the active marker moves onto the merged managed row', () => {
31
+ const rows = buildInstanceRows(managed, [bookmark({ active: true })], {
32
+ managed: true,
33
+ bookmarks: true,
34
+ })
35
+ expect(rows).toHaveLength(1)
36
+ expect(rows[0]?.name).toContain('*')
37
+ })
38
+
39
+ test('a same-name bookmark with a different URL stays a separate row', () => {
40
+ const rows = buildInstanceRows(managed, [bookmark({ url: 'https://elsewhere.example.com' })], {
41
+ managed: true,
42
+ bookmarks: true,
43
+ })
44
+ expect(rows).toHaveLength(2)
45
+ expect(rows.map((row) => row.kind)).toEqual(['managed', 'bookmark'])
46
+ })
47
+
48
+ test('unrelated bookmarks are listed as before', () => {
49
+ const rows = buildInstanceRows(
50
+ managed,
51
+ [bookmark(), bookmark({ name: 'local', url: 'http://localhost:3001/api' })],
52
+ { managed: true, bookmarks: true },
53
+ )
54
+ expect(rows).toHaveLength(2)
55
+ expect(rows.map((row) => row.name)).toEqual(['demo', 'local'])
56
+ })
57
+
58
+ test('--bookmarked never merges (managed hidden)', () => {
59
+ const rows = buildInstanceRows(managed, [bookmark()], { managed: false, bookmarks: true })
60
+ expect(rows).toHaveLength(1)
61
+ expect(rows[0]).toMatchObject({ kind: 'bookmark' })
62
+ })
63
+ })
@@ -0,0 +1,117 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { buildEventsParams, normalizePage, parseServiceName, parseTimeFlag } from '../logs'
4
+
5
+ describe('parseTimeFlag', () => {
6
+ test('passes through epoch-ms', () => {
7
+ expect(parseTimeFlag('--since', '1718841600000')).toBe(1718841600000)
8
+ expect(parseTimeFlag('--since', '0')).toBe(0)
9
+ expect(parseTimeFlag('--since', '-5')).toBe(-5)
10
+ })
11
+
12
+ test('parses ISO-8601 to epoch ms', () => {
13
+ expect(parseTimeFlag('--since', '2026-06-20T00:00:00Z')).toBe(
14
+ Date.parse('2026-06-20T00:00:00Z'),
15
+ )
16
+ })
17
+
18
+ test('throws on garbage', () => {
19
+ expect(() => parseTimeFlag('--since', 'not-a-time')).toThrow('--since')
20
+ })
21
+ })
22
+
23
+ describe('buildEventsParams', () => {
24
+ test('defaults limit when unset, no other fields', () => {
25
+ expect(buildEventsParams({})).toEqual({ limit: 200 })
26
+ })
27
+
28
+ test('maps typed flags into a JournalFilter', () => {
29
+ expect(
30
+ buildEventsParams({
31
+ topic: 'op:*:failed',
32
+ principal: 'id_abc',
33
+ since: '1000',
34
+ until: '2000',
35
+ limit: '50',
36
+ cursor: '7',
37
+ }),
38
+ ).toEqual({
39
+ topic: 'op:*:failed',
40
+ principal: 'id_abc' as never,
41
+ since: 1000,
42
+ until: 2000,
43
+ limit: 50,
44
+ cursor: 7,
45
+ })
46
+ })
47
+
48
+ test('rejects a non-positive limit', () => {
49
+ expect(() => buildEventsParams({ limit: '0' })).toThrow('--limit')
50
+ expect(() => buildEventsParams({ limit: '-3' })).toThrow('--limit')
51
+ expect(() => buildEventsParams({ limit: 'x' })).toThrow('--limit')
52
+ })
53
+
54
+ test('rejects a non-positive cursor', () => {
55
+ expect(() => buildEventsParams({ cursor: '0' })).toThrow('--cursor')
56
+ })
57
+
58
+ test('omits empty topic/principal', () => {
59
+ expect(buildEventsParams({ topic: '', principal: '' })).toEqual({ limit: 200 })
60
+ })
61
+ })
62
+
63
+ describe('normalizePage', () => {
64
+ const entry = (seq: number) => ({
65
+ seq,
66
+ event: {
67
+ id: `e${seq}`,
68
+ topic: 'op:x',
69
+ payload: {},
70
+ metadata: { traceId: 't', timestamp: seq, principal: 'p', root: 'r' },
71
+ },
72
+ })
73
+
74
+ test('wraps a bare entry array and derives nextCursor from max seq', () => {
75
+ const page = normalizePage([entry(3), entry(7), entry(5)])
76
+ expect(page.entries).toHaveLength(3)
77
+ expect(page.nextCursor).toBe(7)
78
+ })
79
+
80
+ test('empty array → empty entries, null cursor', () => {
81
+ expect(normalizePage([])).toEqual({ entries: [], nextCursor: null })
82
+ })
83
+
84
+ test('accepts a paged { entries, nextCursor } shape', () => {
85
+ const page = normalizePage({ entries: [entry(1)], nextCursor: 42 })
86
+ expect(page.entries).toHaveLength(1)
87
+ expect(page.nextCursor).toBe(42)
88
+ })
89
+
90
+ test('paged shape without nextCursor falls back to max seq', () => {
91
+ expect(normalizePage({ entries: [entry(9)] }).nextCursor).toBe(9)
92
+ })
93
+
94
+ test('non-array, non-page input → empty', () => {
95
+ expect(normalizePage(null)).toEqual({ entries: [], nextCursor: null })
96
+ expect(normalizePage(undefined)).toEqual({ entries: [], nextCursor: null })
97
+ expect(normalizePage('nope')).toEqual({ entries: [], nextCursor: null })
98
+ })
99
+ })
100
+
101
+ describe('parseServiceName', () => {
102
+ test('bare name is returned as-is', () => {
103
+ expect(parseServiceName('my-notes')).toBe('my-notes')
104
+ })
105
+
106
+ test('host → first label', () => {
107
+ expect(parseServiceName('my-notes.svc.eu.astrale.ai')).toBe('my-notes')
108
+ })
109
+
110
+ test('full URL → hostname first label', () => {
111
+ expect(parseServiceName('https://my-notes.example.dev/path')).toBe('my-notes')
112
+ })
113
+
114
+ test('trims whitespace', () => {
115
+ expect(parseServiceName(' my-notes ')).toBe('my-notes')
116
+ })
117
+ })
@@ -0,0 +1,25 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { basename, classNameOf } from '../ls'
4
+
5
+ // Regression: the formatter used to read `item.slug` (never returned by the
6
+ // kernel), blanking the name column and breaking `-q`/`-R`. The real fields are
7
+ // `path` (absolute) and `class` (serialized ClassPath).
8
+ describe('ls — display projection (slug-bug regression)', () => {
9
+ test('basename derives the display name from the absolute path', () => {
10
+ expect(basename('/dist.astrale.ai')).toBe('dist.astrale.ai')
11
+ expect(basename('/kernel.astrale.ai')).toBe('kernel.astrale.ai')
12
+ expect(basename('/')).toBe('/')
13
+ expect(basename(undefined)).toBe('')
14
+ })
15
+
16
+ test('classNameOf parses the kind from the serialized class path', () => {
17
+ expect(classNameOf({ class: '/:kernel.astrale.ai:class.Domain' })).toBe('Domain')
18
+ expect(classNameOf({ class: '/:kernel.astrale.ai:class.Folder' })).toBe('Folder')
19
+ })
20
+
21
+ test('classNameOf falls back to the most specific label, then ?', () => {
22
+ expect(classNameOf({ __labels: ['Node', 'Domain', 'Container'] })).toBe('Container')
23
+ expect(classNameOf({})).toBe('?')
24
+ })
25
+ })
@@ -0,0 +1,61 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { mkdtemp, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ const cliRoot = join(import.meta.dir, '../../..')
7
+
8
+ // `astrale setup --plan --json` is the agent-facing contract: a read-only gap
9
+ // report where every unsatisfied step carries the command to fix it. These pin
10
+ // that shape on a FRESH home (nothing configured) so it stays machine-parseable.
11
+
12
+ let tmp: string
13
+
14
+ beforeEach(async () => {
15
+ tmp = await mkdtemp(join(tmpdir(), 'astrale-setup-plan-'))
16
+ })
17
+
18
+ afterEach(async () => {
19
+ await rm(tmp, { recursive: true, force: true })
20
+ })
21
+
22
+ type PlanStep = { id: string; group: string; state: string; summary: string; fix?: string }
23
+ type Plan = { connected: boolean; steps: PlanStep[] }
24
+
25
+ async function runSetupPlan(...args: string[]): Promise<{ exitCode: number; plan: Plan }> {
26
+ const proc = Bun.spawn({
27
+ cmd: ['bun', join(cliRoot, 'bin/astrale.ts'), 'setup', '--plan', '--json', ...args],
28
+ env: { ...process.env, ASTRALE_HOME: tmp },
29
+ stdout: 'pipe',
30
+ stderr: 'pipe',
31
+ })
32
+ const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
33
+ return { exitCode, plan: JSON.parse(stdout) as Plan }
34
+ }
35
+
36
+ describe('astrale setup --plan', () => {
37
+ test('reports a fresh home as not connected, with a fix per connect gap', async () => {
38
+ const { exitCode, plan } = await runSetupPlan()
39
+ expect(exitCode).toBe(0)
40
+ expect(plan.connected).toBe(false)
41
+
42
+ const byId = Object.fromEntries(plan.steps.map((s) => [s.id, s]))
43
+
44
+ // Not signed in, with the granular command an agent would run.
45
+ expect(byId.auth.state).toBe('gap')
46
+ expect(byId.auth.fix).toBe('astrale auth login')
47
+
48
+ // The admin control plane always has a baked default → satisfied.
49
+ expect(byId.admin.state).toBe('satisfied')
50
+
51
+ // No active instance yet.
52
+ expect(byId.instance.state).toBe('gap')
53
+ expect(byId.instance.fix).toContain('astrale instance create')
54
+ })
55
+
56
+ test('threads the positional slug into the instance fix hint', async () => {
57
+ const { plan } = await runSetupPlan('my-app')
58
+ const instance = plan.steps.find((s) => s.id === 'instance')
59
+ expect(instance?.fix).toBe('astrale instance create my-app')
60
+ })
61
+ })