@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
package/package.json ADDED
@@ -0,0 +1,90 @@
1
+ {
2
+ "name": "@astrale-os/cli",
3
+ "version": "0.4.0-alpha.13",
4
+ "description": "Astrale CLI — connect to existing Astrale kernels",
5
+ "keywords": [
6
+ "astrale",
7
+ "cli"
8
+ ],
9
+ "license": "MIT",
10
+ "author": "Astrale",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/astrale-os/cli.git"
14
+ },
15
+ "bin": {
16
+ "astrale": "./dist/astrale.js"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src",
21
+ "tsconfig.json",
22
+ ".check-workspace.cjs",
23
+ "studio/server",
24
+ "studio/shared",
25
+ "studio/client/dist",
26
+ "studio/package.json"
27
+ ],
28
+ "type": "module",
29
+ "exports": {
30
+ "./keys": "./src/lib/keys.ts",
31
+ "./paths": "./src/lib/paths.ts"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public",
35
+ "registry": "https://registry.npmjs.org"
36
+ },
37
+ "scripts": {
38
+ "preinstall": "node .check-workspace.cjs",
39
+ "prepare": "husky",
40
+ "build": "bun scripts/build.ts",
41
+ "prepack": "bun scripts/build.ts",
42
+ "typecheck": "tsgo --noEmit",
43
+ "lint": "oxlint .",
44
+ "lint:fix": "oxlint --fix .",
45
+ "format": "pnpm exec oxfmt --write .",
46
+ "format:check": "pnpm exec oxfmt --check .",
47
+ "test": "bun test src",
48
+ "test:watch": "bun test --watch src"
49
+ },
50
+ "dependencies": {
51
+ "@astrale-os/kernel-client": ">=0.2.0 <1.0.0",
52
+ "@astrale-os/kernel-core": ">=0.4.1 <1.0.0",
53
+ "@inquirer/prompts": "^8.5.2",
54
+ "chalk": "^5.6.2",
55
+ "chokidar": "^4.0.3",
56
+ "commander": ">=13.0.0 <14.0.0",
57
+ "jose": "^6.1.3",
58
+ "ora": "^9.0.0",
59
+ "ts-morph": "^25.0.1",
60
+ "yaml": "^2.8.2",
61
+ "zod": "^3.25.0"
62
+ },
63
+ "devDependencies": {
64
+ "@astrale-os/ox": ">=0.1.0 <1.0.0",
65
+ "@astrale/commitlint-config": "jsr:~2.0.1",
66
+ "@commitlint/cli": "~20.3.1",
67
+ "@commitlint/config-conventional": "~20.3.1",
68
+ "@types/bun": "^1.1.16",
69
+ "@types/node": "^22.0.0",
70
+ "@typescript/native-preview": "latest",
71
+ "husky": "~9.1.7",
72
+ "lint-staged": "~16.2.7",
73
+ "oxfmt": "0.52.0",
74
+ "oxlint": "latest",
75
+ "typescript": "~6.0.1-rc"
76
+ },
77
+ "lint-staged": {
78
+ "*.{js,cjs,mjs,ts,tsx}": [
79
+ "oxlint --fix --no-error-on-unmatched-pattern",
80
+ "oxfmt --write"
81
+ ],
82
+ "*.{json,yml,yaml}": [
83
+ "oxfmt --write"
84
+ ]
85
+ },
86
+ "engines": {
87
+ "node": ">=20"
88
+ },
89
+ "packageManager": "pnpm@10.28.0"
90
+ }
package/src/command.ts ADDED
@@ -0,0 +1,40 @@
1
+ export type CommandArgument = {
2
+ name: string
3
+ description: string
4
+ required?: boolean
5
+ }
6
+
7
+ export type CommandOption = {
8
+ flags: string
9
+ description: string
10
+ default?: string
11
+ choices?: string[]
12
+ }
13
+
14
+ export type CommandDefinition = {
15
+ name: string
16
+ description: string
17
+ /** One-line summary shown in the parent's command list (Commander `.summary()`). */
18
+ summary?: string
19
+ aliases?: string[]
20
+ arguments?: CommandArgument[]
21
+ options?: CommandOption[]
22
+ /**
23
+ * Free-form prose appended to `<cmd> --help` (Commander `addHelpText('after')`).
24
+ * Use for command-local behavior the flag list cannot express + canonical
25
+ * examples. Pre-wrap lines to ~78 cols — Commander prints it verbatim.
26
+ */
27
+ afterHelpText?: string
28
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Commander passes typed args at runtime
29
+ action: (...args: any[]) => Promise<void>
30
+ }
31
+
32
+ export type CommandGroup = {
33
+ name: string
34
+ description: string
35
+ /** One-line summary shown in the program's command list (Commander `.summary()`). */
36
+ summary?: string
37
+ commands: CommandDefinition[]
38
+ /** Nested subgroups (one extra level of `astrale foo bar baz` nesting). */
39
+ subgroups?: CommandGroup[]
40
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import type { IdentityStore } from '../../lib/identity'
4
+
5
+ import { AuthError } from '../../errors'
6
+ import { ADMIN_INSTANCE } from '../../lib/admin-instance'
7
+ import { assertAlphaCreateIdentity } from '../instance/create'
8
+
9
+ describe('admin-backed instance commands', () => {
10
+ test('target the merged Instance class', () => {
11
+ expect(ADMIN_INSTANCE).toBe('/admin.astrale.ai/class.Instance')
12
+ })
13
+
14
+ test('instance create preflight points fresh installs at WorkOS login', () => {
15
+ const store: IdentityStore = {
16
+ default: 'manager',
17
+ identities: {
18
+ manager: {
19
+ subject: 'manager',
20
+ createdAt: '2026-06-08T00:00:00.000Z',
21
+ source: 'key',
22
+ mode: 'local',
23
+ },
24
+ },
25
+ }
26
+
27
+ expect(() => assertAlphaCreateIdentity(store)).toThrow(AuthError)
28
+ expect(() => assertAlphaCreateIdentity(store)).toThrow('WorkOS login required')
29
+ })
30
+
31
+ test('instance create preflight accepts IdP-backed identities', () => {
32
+ const store: IdentityStore = {
33
+ default: 'bryan',
34
+ identities: {
35
+ bryan: {
36
+ subject: 'user_123',
37
+ createdAt: '2026-06-08T00:00:00.000Z',
38
+ source: 'idp',
39
+ mode: 'remote',
40
+ idp: 'workos',
41
+ issuer: 'https://auth.example.com',
42
+ },
43
+ },
44
+ }
45
+
46
+ expect(() => assertAlphaCreateIdentity(store)).not.toThrow()
47
+ })
48
+
49
+ test('--as selection must also be IdP-backed', () => {
50
+ const store: IdentityStore = {
51
+ default: 'local',
52
+ identities: {
53
+ local: {
54
+ subject: 'local',
55
+ createdAt: '2026-06-08T00:00:00.000Z',
56
+ source: 'key',
57
+ mode: 'local',
58
+ },
59
+ workos: {
60
+ subject: 'user_123',
61
+ createdAt: '2026-06-08T00:00:00.000Z',
62
+ source: 'idp',
63
+ mode: 'remote',
64
+ idp: 'workos',
65
+ issuer: 'https://auth.example.com',
66
+ },
67
+ },
68
+ }
69
+
70
+ expect(() => assertAlphaCreateIdentity(store, { as: 'local' })).toThrow(AuthError)
71
+ expect(() => assertAlphaCreateIdentity(store, { as: 'workos' })).not.toThrow()
72
+ })
73
+ })
@@ -0,0 +1,178 @@
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-auth-login-'))
12
+ })
13
+
14
+ afterEach(async () => {
15
+ await rm(tmp, { recursive: true, force: true })
16
+ })
17
+
18
+ describe('auth login command', () => {
19
+ test('rejects requested audience when provider mints a different access-token aud', async () => {
20
+ const server = oauthServer({
21
+ accessToken: unsignedJwt({
22
+ iss: 'http://127.0.0.1',
23
+ sub: 'user_123',
24
+ aud: 'unknown/api',
25
+ exp: 1893456000,
26
+ }),
27
+ })
28
+ try {
29
+ await writeIdpConfig(server.url)
30
+
31
+ const result = await runAuthLogin(
32
+ '--idp',
33
+ 'test',
34
+ '--name',
35
+ 'alice',
36
+ '--client-credentials',
37
+ '--client-secret-env',
38
+ 'TEST_CLIENT_SECRET',
39
+ '--audience',
40
+ 'https://kernel.example.com',
41
+ '--raw',
42
+ )
43
+
44
+ expect(result.exitCode).toBe(1)
45
+ expect(result.stderr).toContain(
46
+ 'IdP response access_token was not minted for requested audience https://kernel.example.com',
47
+ )
48
+ expect(
49
+ await readFile(join(tmp, 'idp-sessions', 'alice.json'), 'utf-8').catch(() => null),
50
+ ).toBeNull()
51
+ } finally {
52
+ await server.stop()
53
+ }
54
+ })
55
+
56
+ test('stores the session when provider mints the requested access-token aud', async () => {
57
+ const server = oauthServer({
58
+ accessToken: unsignedJwt({
59
+ iss: 'http://127.0.0.1',
60
+ sub: 'user_123',
61
+ aud: 'https://kernel.example.com',
62
+ exp: 1893456000,
63
+ }),
64
+ })
65
+ try {
66
+ await writeIdpConfig(server.url)
67
+
68
+ const result = await runAuthLogin(
69
+ '--idp',
70
+ 'test',
71
+ '--name',
72
+ 'alice',
73
+ '--client-credentials',
74
+ '--client-secret-env',
75
+ 'TEST_CLIENT_SECRET',
76
+ '--audience',
77
+ 'https://kernel.example.com',
78
+ '--raw',
79
+ )
80
+
81
+ expect(result.exitCode).toBe(0)
82
+ const session = JSON.parse(await readFile(join(tmp, 'idp-sessions', 'alice.json'), 'utf-8'))
83
+ expect(session.audience).toBe('https://kernel.example.com')
84
+ expect(session.subject).toBe('user_123')
85
+ } finally {
86
+ await server.stop()
87
+ }
88
+ })
89
+ })
90
+
91
+ async function writeIdpConfig(baseUrl: string): Promise<void> {
92
+ await mkdir(join(tmp, 'idps', 'test'), { recursive: true })
93
+ await writeFile(
94
+ join(tmp, 'idps', 'index.json'),
95
+ JSON.stringify(
96
+ {
97
+ idps: {
98
+ test: {
99
+ issuer: baseUrl,
100
+ createdAt: '2026-01-01T00:00:00.000Z',
101
+ updatedAt: '2026-01-01T00:00:00.000Z',
102
+ },
103
+ },
104
+ },
105
+ null,
106
+ 2,
107
+ ) + '\n',
108
+ )
109
+ await writeFile(
110
+ join(tmp, 'idps', 'test', 'metadata.json'),
111
+ JSON.stringify(
112
+ {
113
+ issuer: baseUrl,
114
+ token_endpoint: `${baseUrl}/token`,
115
+ jwks_uri: `${baseUrl}/jwks`,
116
+ },
117
+ null,
118
+ 2,
119
+ ) + '\n',
120
+ )
121
+ await writeFile(
122
+ join(tmp, 'idps', 'test', 'client.json'),
123
+ JSON.stringify(
124
+ {
125
+ client_id: 'client_123',
126
+ client_secret_env: 'TEST_CLIENT_SECRET',
127
+ },
128
+ null,
129
+ 2,
130
+ ) + '\n',
131
+ )
132
+ }
133
+
134
+ function oauthServer(args: { accessToken: string }): { url: string; stop: () => Promise<void> } {
135
+ const server = Bun.serve({
136
+ port: 0,
137
+ fetch: async (request) => {
138
+ const url = new URL(request.url)
139
+ if (url.pathname !== '/token') return new Response('not found', { status: 404 })
140
+ return Response.json({
141
+ access_token: args.accessToken,
142
+ token_type: 'Bearer',
143
+ expires_in: 3600,
144
+ })
145
+ },
146
+ })
147
+ return {
148
+ url: `http://${server.hostname}:${server.port}`,
149
+ stop: () => server.stop(true),
150
+ }
151
+ }
152
+
153
+ async function runAuthLogin(...args: string[]): Promise<{
154
+ exitCode: number
155
+ stdout: string
156
+ stderr: string
157
+ }> {
158
+ const proc = Bun.spawn({
159
+ cmd: ['bun', join(cliRoot, 'bin/astrale.ts'), 'auth', 'login', ...args],
160
+ env: { ...process.env, ASTRALE_HOME: tmp, TEST_CLIENT_SECRET: 'secret_123' },
161
+ stdout: 'pipe',
162
+ stderr: 'pipe',
163
+ })
164
+ const [stdout, stderr, exitCode] = await Promise.all([
165
+ new Response(proc.stdout).text(),
166
+ new Response(proc.stderr).text(),
167
+ proc.exited,
168
+ ])
169
+ return { exitCode, stdout, stderr }
170
+ }
171
+
172
+ function unsignedJwt(payload: Record<string, unknown>): string {
173
+ return [base64url({ alg: 'none', typ: 'JWT' }), base64url(payload), ''].join('.')
174
+ }
175
+
176
+ function base64url(value: Record<string, unknown>): string {
177
+ return Buffer.from(JSON.stringify(value)).toString('base64url')
178
+ }
@@ -0,0 +1,223 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { mkdir, mkdtemp, 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-auth-token-'))
12
+ await mkdir(join(tmp, 'idp-sessions'), { recursive: true })
13
+ await writeFile(
14
+ join(tmp, 'identities.json'),
15
+ JSON.stringify(
16
+ {
17
+ default: 'alice',
18
+ identities: {
19
+ alice: {
20
+ subject: 'user_123',
21
+ createdAt: '2026-01-01T00:00:00.000Z',
22
+ source: 'idp',
23
+ mode: 'remote',
24
+ idp: 'workos',
25
+ issuer: 'https://api.workos.com',
26
+ },
27
+ },
28
+ },
29
+ null,
30
+ 2,
31
+ ) + '\n',
32
+ )
33
+ await writeFile(
34
+ join(tmp, 'idp-sessions', 'alice.json'),
35
+ JSON.stringify(
36
+ {
37
+ identity: 'alice',
38
+ idp: 'workos',
39
+ issuer: 'https://api.workos.com',
40
+ subject: 'user_123',
41
+ access_token: 'access-token-123',
42
+ id_token: 'id-token-123',
43
+ token_type: 'Bearer',
44
+ scope: 'openid profile email',
45
+ expires_at: '2999-01-01T00:00:00.000Z',
46
+ updatedAt: '2026-01-01T00:00:00.000Z',
47
+ },
48
+ null,
49
+ 2,
50
+ ) + '\n',
51
+ )
52
+ })
53
+
54
+ afterEach(async () => {
55
+ await rm(tmp, { recursive: true, force: true })
56
+ })
57
+
58
+ describe('auth token command', () => {
59
+ test('prints the active IdP access token with --raw', async () => {
60
+ const result = await runAuthToken('--raw')
61
+
62
+ expect(result.exitCode).toBe(0)
63
+ expect(result.stdout).toBe('access-token-123\n')
64
+ expect(result.stderr).toBe('')
65
+ })
66
+
67
+ test('can select by IdP and print id token metadata as JSON', async () => {
68
+ const result = await runAuthToken('--idp', 'workos', '--type', 'id', '--json')
69
+
70
+ expect(result.exitCode).toBe(0)
71
+ expect(result.stderr).toBe('')
72
+
73
+ const body = JSON.parse(result.stdout) as { identity: string; type: string; token: string }
74
+ expect(body.identity).toBe('alice')
75
+ expect(body.type).toBe('id')
76
+ expect(body.token).toBe('id-token-123')
77
+ })
78
+
79
+ test('two parallel CLI processes refresh a single-use token exactly once', async () => {
80
+ const server = rotationServer()
81
+ try {
82
+ await writeExpiredSessionWithIdp(server.url)
83
+
84
+ const [a, b] = await Promise.all([runAuthToken('--raw'), runAuthToken('--raw')])
85
+
86
+ expect(a.exitCode).toBe(0)
87
+ expect(b.exitCode).toBe(0)
88
+ expect(a.stdout).toBe(b.stdout)
89
+ expect(server.refreshCount()).toBe(1)
90
+ } finally {
91
+ await server.stop()
92
+ }
93
+ })
94
+
95
+ test('an IdP outage does not tell the user to re-login', async () => {
96
+ const server = rotationServer({ status: 503 })
97
+ try {
98
+ await writeExpiredSessionWithIdp(server.url)
99
+
100
+ const result = await runAuthToken('--raw')
101
+
102
+ expect(result.exitCode).toBe(1)
103
+ expect(result.stderr).not.toContain('astrale auth login')
104
+ } finally {
105
+ await server.stop()
106
+ }
107
+ })
108
+
109
+ test('--no-refresh prints the cached token without contacting the IdP', async () => {
110
+ const server = rotationServer()
111
+ try {
112
+ await writeExpiredSessionWithIdp(server.url)
113
+
114
+ const result = await runAuthToken('--raw', '--no-refresh')
115
+
116
+ expect(result.exitCode).toBe(0)
117
+ expect(result.stdout).toBe('expired-access-token\n')
118
+ expect(server.refreshCount()).toBe(0)
119
+ } finally {
120
+ await server.stop()
121
+ }
122
+ })
123
+ })
124
+
125
+ async function writeExpiredSessionWithIdp(baseUrl: string): Promise<void> {
126
+ await mkdir(join(tmp, 'idps', 'test'), { recursive: true })
127
+ await writeFile(
128
+ join(tmp, 'idps', 'index.json'),
129
+ JSON.stringify({
130
+ idps: {
131
+ test: {
132
+ issuer: baseUrl,
133
+ createdAt: '2026-01-01T00:00:00.000Z',
134
+ updatedAt: '2026-01-01T00:00:00.000Z',
135
+ },
136
+ },
137
+ }),
138
+ )
139
+ await writeFile(
140
+ join(tmp, 'idps', 'test', 'metadata.json'),
141
+ JSON.stringify({
142
+ issuer: baseUrl,
143
+ token_endpoint: `${baseUrl}/token`,
144
+ jwks_uri: `${baseUrl}/jwks`,
145
+ }),
146
+ )
147
+ await writeFile(join(tmp, 'idps', 'test', 'client.json'), JSON.stringify({ client_id: 'c_1' }))
148
+ await writeFile(
149
+ join(tmp, 'idp-sessions', 'alice.json'),
150
+ JSON.stringify({
151
+ identity: 'alice',
152
+ idp: 'test',
153
+ issuer: baseUrl,
154
+ subject: 'user_123',
155
+ access_token: 'expired-access-token',
156
+ refresh_token: 'rt-0',
157
+ token_type: 'Bearer',
158
+ expires_at: new Date(Date.now() - 3_600_000).toISOString(),
159
+ updatedAt: '2026-01-01T00:00:00.000Z',
160
+ }),
161
+ )
162
+ }
163
+
164
+ function rotationServer(opts?: { status?: number }): {
165
+ url: string
166
+ refreshCount: () => number
167
+ stop: () => Promise<void>
168
+ } {
169
+ // WorkOS-style single-use rotation: presenting anything but the CURRENT
170
+ // refresh token is invalid_grant.
171
+ let current = 'rt-0'
172
+ let count = 0
173
+ const server = Bun.serve({
174
+ port: 0,
175
+ fetch: async (request) => {
176
+ const url = new URL(request.url)
177
+ if (url.pathname !== '/token') return new Response('not found', { status: 404 })
178
+ if (opts?.status) {
179
+ return Response.json({ error_description: 'unavailable' }, { status: opts.status })
180
+ }
181
+ const form = new URLSearchParams(await request.text())
182
+ if (form.get('refresh_token') !== current) {
183
+ return Response.json(
184
+ { error: 'invalid_grant', error_description: 'Refresh token already exchanged.' },
185
+ { status: 400 },
186
+ )
187
+ }
188
+ await new Promise((r) => setTimeout(r, 50))
189
+ count += 1
190
+ current = `rt-${count}`
191
+ return Response.json({
192
+ access_token: `rotated-access-${count}`,
193
+ refresh_token: current,
194
+ token_type: 'Bearer',
195
+ expires_in: 3600,
196
+ })
197
+ },
198
+ })
199
+ return {
200
+ url: `http://${server.hostname}:${server.port}`,
201
+ refreshCount: () => count,
202
+ stop: () => server.stop(true),
203
+ }
204
+ }
205
+
206
+ async function runAuthToken(...args: string[]): Promise<{
207
+ exitCode: number
208
+ stdout: string
209
+ stderr: string
210
+ }> {
211
+ const proc = Bun.spawn({
212
+ cmd: ['bun', join(cliRoot, 'bin/astrale.ts'), 'auth', 'token', ...args],
213
+ env: { ...process.env, ASTRALE_HOME: tmp },
214
+ stdout: 'pipe',
215
+ stderr: 'pipe',
216
+ })
217
+ const [stdout, stderr, exitCode] = await Promise.all([
218
+ new Response(proc.stdout).text(),
219
+ new Response(proc.stderr).text(),
220
+ proc.exited,
221
+ ])
222
+ return { exitCode, stdout, stderr }
223
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { parseKeyValue, coerceValue } from '../call'
4
+
5
+ describe('coerceValue', () => {
6
+ test('parses JSON objects', () => {
7
+ expect(coerceValue('{"a":1}')).toEqual({ a: 1 })
8
+ })
9
+
10
+ test('parses JSON arrays', () => {
11
+ expect(coerceValue('[1,2,3]')).toEqual([1, 2, 3])
12
+ })
13
+
14
+ test('falls through on invalid JSON object', () => {
15
+ expect(coerceValue('{not-json}')).toBe('{not-json}')
16
+ })
17
+
18
+ test('parses booleans', () => {
19
+ expect(coerceValue('true')).toBe(true)
20
+ expect(coerceValue('false')).toBe(false)
21
+ })
22
+
23
+ test('parses null', () => {
24
+ expect(coerceValue('null')).toBe(null)
25
+ })
26
+
27
+ test('parses numbers', () => {
28
+ expect(coerceValue('42')).toBe(42)
29
+ expect(coerceValue('3.14')).toBe(3.14)
30
+ expect(coerceValue('-1')).toBe(-1)
31
+ expect(coerceValue('0')).toBe(0)
32
+ })
33
+
34
+ test('returns strings for non-special values', () => {
35
+ expect(coerceValue('hello')).toBe('hello')
36
+ expect(coerceValue('')).toBe('')
37
+ expect(coerceValue('some text')).toBe('some text')
38
+ })
39
+
40
+ test('preserves hex, octal, and exotic numeric strings as strings', () => {
41
+ expect(coerceValue('0x1f')).toBe('0x1f')
42
+ expect(coerceValue('0o77')).toBe('0o77')
43
+ expect(coerceValue('0b101')).toBe('0b101')
44
+ expect(coerceValue('1e308')).toBe('1e308')
45
+ expect(coerceValue('Infinity')).toBe('Infinity')
46
+ })
47
+ })
48
+
49
+ describe('parseKeyValue', () => {
50
+ test('parses simple key=value pairs', () => {
51
+ expect(parseKeyValue(['name=test', 'count=5'])).toEqual({
52
+ name: 'test',
53
+ count: 5,
54
+ })
55
+ })
56
+
57
+ test('handles values with = in them', () => {
58
+ expect(parseKeyValue(['query=a=b'])).toEqual({ query: 'a=b' })
59
+ })
60
+
61
+ test('throws on missing =', () => {
62
+ expect(() => parseKeyValue(['noequals'])).toThrow('expected key=value')
63
+ })
64
+
65
+ test('parses JSON values', () => {
66
+ expect(parseKeyValue(['data={"x":1}'])).toEqual({ data: { x: 1 } })
67
+ })
68
+
69
+ test('handles empty value', () => {
70
+ expect(parseKeyValue(['key='])).toEqual({ key: '' })
71
+ })
72
+ })