@astrale-os/cli 0.5.0-alpha.0 → 0.6.0-alpha.0

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 (96) hide show
  1. package/dist/astrale.js +12962 -487
  2. package/package.json +7 -3
  3. package/src/command.ts +2 -0
  4. package/src/commands/__tests__/admin-instance.test.ts +3 -2
  5. package/src/commands/__tests__/domain-list.test.ts +6 -2
  6. package/src/commands/__tests__/install-identity-override.test.ts +1 -1
  7. package/src/commands/__tests__/view.test.ts +100 -0
  8. package/src/commands/call.ts +1 -1
  9. package/src/commands/domain/install.ts +5 -5
  10. package/src/commands/domain/list.ts +2 -2
  11. package/src/commands/domain/publish.ts +3 -3
  12. package/src/commands/instance/active.ts +2 -2
  13. package/src/commands/instance/create.ts +1 -1
  14. package/src/commands/instance/delete.ts +3 -3
  15. package/src/commands/instance/list.ts +8 -3
  16. package/src/commands/instance/status.ts +4 -3
  17. package/src/commands/instance/use.ts +2 -2
  18. package/src/commands/query.ts +27 -8
  19. package/src/commands/session/analyze.ts +50 -0
  20. package/src/commands/session/list.ts +36 -0
  21. package/src/commands/token.ts +1 -1
  22. package/src/commands/view-serve.ts +26 -0
  23. package/src/commands/view.ts +614 -0
  24. package/src/connect-core.test.ts +42 -0
  25. package/src/connect-core.ts +53 -0
  26. package/src/kernel/__tests__/auth.test.ts +1 -0
  27. package/src/kernel/client.ts +30 -24
  28. package/src/kernel/expand.ts +3 -2
  29. package/src/kernel/index.ts +7 -1
  30. package/src/kernel/options.ts +1 -1
  31. package/src/lib/__tests__/instance-target.test.ts +34 -0
  32. package/src/lib/__tests__/view-open-intent.test.ts +308 -0
  33. package/src/lib/__tests__/view-snapshot.test.ts +77 -0
  34. package/src/lib/admin-domain.ts +8 -4
  35. package/src/lib/admin-instance.ts +5 -1
  36. package/src/lib/config.ts +1 -0
  37. package/src/lib/instance-target.ts +5 -0
  38. package/src/lib/instance.ts +11 -0
  39. package/src/lib/log.ts +12 -2
  40. package/src/lib/login-flow.ts +41 -4
  41. package/src/lib/provision-instance.ts +2 -2
  42. package/src/lib/view/open-intent.ts +97 -0
  43. package/src/lib/view/resolve.ts +104 -0
  44. package/src/lib/view/server.ts +294 -0
  45. package/src/lib/view/session.ts +123 -0
  46. package/src/lib/view/snapshot.ts +101 -0
  47. package/src/program.ts +12 -1
  48. package/src/registry.ts +1 -1
  49. package/src/setup/steps/instance.ts +2 -2
  50. package/src/telemetry/__tests__/gate.test.ts +63 -0
  51. package/src/telemetry/__tests__/recorder.test.ts +110 -0
  52. package/src/telemetry/__tests__/redact.test.ts +79 -0
  53. package/src/telemetry/__tests__/session.test.ts +166 -0
  54. package/src/telemetry/__tests__/trigger.test.ts +49 -0
  55. package/src/telemetry/adapters/__tests__/claude-code.test.ts +89 -0
  56. package/src/telemetry/adapters/__tests__/codex.test.ts +138 -0
  57. package/src/telemetry/adapters/__tests__/index.test.ts +88 -0
  58. package/src/telemetry/adapters/claude-code.ts +90 -0
  59. package/src/telemetry/adapters/codex.ts +160 -0
  60. package/src/telemetry/adapters/index.ts +45 -0
  61. package/src/telemetry/adapters/types.ts +21 -0
  62. package/src/telemetry/analyze.ts +227 -0
  63. package/src/telemetry/gate.ts +79 -0
  64. package/src/telemetry/recorder.ts +57 -0
  65. package/src/telemetry/redact.ts +53 -0
  66. package/src/telemetry/session.ts +88 -0
  67. package/src/telemetry/settings.ts +26 -0
  68. package/src/telemetry/store.ts +91 -0
  69. package/src/telemetry/trigger.ts +119 -0
  70. package/src/telemetry/types.ts +64 -0
  71. package/studio/client/dist/assets/index-CyN5G8IA.js +109 -0
  72. package/studio/client/dist/assets/index-DKKMHBBC.css +1 -0
  73. package/studio/client/dist/index.html +2 -2
  74. package/studio/server/agent/ask.ts +2 -1
  75. package/studio/server/agent/runner.ts +4 -1
  76. package/studio/server/agent/session-id.ts +13 -0
  77. package/studio/server/api.ts +43 -25
  78. package/studio/server/cache.ts +17 -6
  79. package/studio/server/client-package.test.ts +147 -0
  80. package/studio/server/client-package.ts +242 -0
  81. package/studio/server/index.ts +13 -0
  82. package/studio/server/introspect/anatomy-extras.test.ts +91 -0
  83. package/studio/server/introspect/anatomy-extras.ts +48 -49
  84. package/studio/server/introspect/anatomy.ts +7 -7
  85. package/studio/server/introspect/overlay-tsmorph.test.ts +104 -0
  86. package/studio/server/introspect/overlay-tsmorph.ts +122 -22
  87. package/studio/server/state/views.test.ts +90 -0
  88. package/studio/server/state/views.ts +396 -99
  89. package/studio/server/state/visibility.ts +5 -5
  90. package/studio/server/view-dev-server.test.ts +111 -0
  91. package/studio/server/view-dev-server.ts +372 -0
  92. package/studio/shared/types.ts +57 -10
  93. package/viewer/dist/index.html +93 -0
  94. package/viewer/dist/main.js +71 -0
  95. package/studio/client/dist/assets/index-BcejyJpa.css +0 -1
  96. package/studio/client/dist/assets/index-Cqz3Oy_B.js +0 -179
@@ -0,0 +1,123 @@
1
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ import { paths } from '../env'
5
+
6
+ /**
7
+ * View-session records: one detached server process per open view, tracked as
8
+ * `~/.astrale/view/<id>.json` (+ `<id>.log`, `<id>.config.json`). Records are
9
+ * garbage-collected on every `astrale view` invocation by probing the pid.
10
+ */
11
+
12
+ export const VIEW_DIR = join(paths.home, 'view')
13
+
14
+ export type ViewSessionRecord = {
15
+ id: string
16
+ pid: number
17
+ port: number
18
+ nonce: string
19
+ /** Host page URL, nonce-scoped: `http://127.0.0.1:<port>/s/<nonce>/`. */
20
+ pageUrl: string
21
+ view: {
22
+ url: string
23
+ functionId: string
24
+ handshake: 'shell' | 'none'
25
+ /** ViewPath or resolved view node path, when the view came from the graph. */
26
+ path?: string
27
+ name?: string
28
+ }
29
+ target?: { id?: string; path?: string }
30
+ instance?: string
31
+ identity?: string
32
+ createdAt: string
33
+ }
34
+
35
+ /** Everything the detached `view __serve` process needs, written before spawn. */
36
+ export type ViewServeConfig = {
37
+ session: ViewSessionRecord
38
+ /** Kernel passthrough opts, re-resolved server-side for token mints. */
39
+ kernel: { url?: string; instance?: string; as?: string; creds?: string; timeout?: string }
40
+ /**
41
+ * Kernel the view dispatches to. `direct: true` hands the child the kernel
42
+ * URL itself (public https kernels — the router serves CORS, and Chrome's
43
+ * local-network-access rules forbid a public view origin fetching our
44
+ * loopback proxy). Otherwise the child gets the nonce-scoped proxy, which
45
+ * handles CORS and self-signed local CAs.
46
+ */
47
+ proxy: { kernelUrl: string; caFile?: string; direct: boolean }
48
+ idleMs: number
49
+ }
50
+
51
+ export const recordPath = (id: string): string => join(VIEW_DIR, `${id}.json`)
52
+ export const logPath = (id: string): string => join(VIEW_DIR, `${id}.log`)
53
+ export const configPath = (id: string): string => join(VIEW_DIR, `${id}.config.json`)
54
+
55
+ export async function saveRecord(record: ViewSessionRecord): Promise<void> {
56
+ await mkdir(VIEW_DIR, { recursive: true })
57
+ await writeFile(recordPath(record.id), `${JSON.stringify(record, null, 2)}\n`)
58
+ }
59
+
60
+ export async function removeSessionFiles(id: string): Promise<void> {
61
+ await Promise.all([
62
+ rm(recordPath(id), { force: true }),
63
+ rm(configPath(id), { force: true }),
64
+ rm(logPath(id), { force: true }),
65
+ ])
66
+ }
67
+
68
+ export function isAlive(pid: number): boolean {
69
+ try {
70
+ process.kill(pid, 0)
71
+ return true
72
+ } catch {
73
+ return false
74
+ }
75
+ }
76
+
77
+ /** List live sessions, removing records whose server process is gone. */
78
+ export async function listSessions(): Promise<ViewSessionRecord[]> {
79
+ let entries: string[]
80
+ try {
81
+ entries = await readdir(VIEW_DIR)
82
+ } catch {
83
+ return []
84
+ }
85
+ const live: ViewSessionRecord[] = []
86
+ for (const entry of entries) {
87
+ if (!/^v-[0-9a-f]+\.json$/.test(entry)) continue
88
+ let record: ViewSessionRecord
89
+ try {
90
+ record = JSON.parse(await readFile(join(VIEW_DIR, entry), 'utf8')) as ViewSessionRecord
91
+ } catch {
92
+ continue
93
+ }
94
+ if (isAlive(record.pid)) live.push(record)
95
+ else await removeSessionFiles(record.id)
96
+ }
97
+ return live.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
98
+ }
99
+
100
+ const CLOSE_GRACE_MS = 2000
101
+
102
+ /** SIGTERM the session server (SIGKILL after a grace period), drop its files. */
103
+ export async function closeSession(record: ViewSessionRecord): Promise<void> {
104
+ if (isAlive(record.pid)) {
105
+ try {
106
+ process.kill(record.pid, 'SIGTERM')
107
+ } catch {
108
+ // already gone
109
+ }
110
+ const deadline = Date.now() + CLOSE_GRACE_MS
111
+ while (isAlive(record.pid) && Date.now() < deadline) {
112
+ await new Promise((resolve) => setTimeout(resolve, 100))
113
+ }
114
+ if (isAlive(record.pid)) {
115
+ try {
116
+ process.kill(record.pid, 'SIGKILL')
117
+ } catch {
118
+ // already gone
119
+ }
120
+ }
121
+ }
122
+ await removeSessionFiles(record.id)
123
+ }
@@ -0,0 +1,101 @@
1
+ import type { AbResult } from '../browser'
2
+
3
+ const SETTLE_TIMEOUT_MS = 8_000
4
+ const QUIET_WINDOW_MS = 750
5
+ const POLL_MS = 250
6
+
7
+ type SettleClock = {
8
+ now(): number
9
+ sleep(ms: number): Promise<void>
10
+ }
11
+
12
+ type SettlePolicy = {
13
+ timeoutMs: number
14
+ quietWindowMs: number
15
+ pollMs: number
16
+ }
17
+
18
+ const realClock: SettleClock = {
19
+ now: Date.now,
20
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
21
+ }
22
+
23
+ const defaultPolicy: SettlePolicy = {
24
+ timeoutMs: SETTLE_TIMEOUT_MS,
25
+ quietWindowMs: QUIET_WINDOW_MS,
26
+ pollMs: POLL_MS,
27
+ }
28
+
29
+ export function snapshotText(result: AbResult): string | null {
30
+ const data = result.data as { snapshot?: unknown } | string | null
31
+ if (typeof data === 'string') return data
32
+ return typeof data?.snapshot === 'string' ? data.snapshot : null
33
+ }
34
+
35
+ function isMeaningfulSnapshot(text: string): boolean {
36
+ return /"[^"\n]+"/.test(text)
37
+ }
38
+
39
+ function isPendingSnapshot(text: string): boolean {
40
+ if (/\[[^\]]*\bbusy\b[^\]]*\]/i.test(text)) return true
41
+ if (/^\s*-\s*progressbar\b/im.test(text)) return true
42
+
43
+ // Views often render loading text without aria-busy. The timeout still
44
+ // returns that snapshot when a view never leaves its loading state.
45
+ return /"(?:loading|opening|connecting|initializing|preparing|starting|fetching|refreshing|please wait)(?:\b[^"\n]{0,100})?"/i.test(
46
+ text,
47
+ )
48
+ }
49
+
50
+ function fingerprint(text: string): string {
51
+ return text
52
+ .replace(/\bref=e\d+\b/g, 'ref')
53
+ .replace(/\s+/g, ' ')
54
+ .trim()
55
+ }
56
+
57
+ /**
58
+ * Warm the cross-origin accessibility tree, then wait for a meaningful view
59
+ * snapshot to remain unchanged briefly. Loading/busy states never settle
60
+ * early, but the timeout returns their latest snapshot for diagnosis.
61
+ */
62
+ export async function waitForSettledSnapshot(
63
+ takeSnapshot: () => Promise<AbResult>,
64
+ clock: SettleClock = realClock,
65
+ policy: SettlePolicy = defaultPolicy,
66
+ ): Promise<AbResult> {
67
+ let lastAttempt = await takeSnapshot()
68
+ let lastSuccess = lastAttempt.ok ? lastAttempt : null
69
+ let stableFingerprint: string | null = null
70
+ let stableSince = clock.now()
71
+ const deadline = clock.now() + policy.timeoutMs
72
+
73
+ while (true) {
74
+ lastAttempt = await takeSnapshot()
75
+ const sampledAt = clock.now()
76
+
77
+ if (lastAttempt.ok) {
78
+ lastSuccess = lastAttempt
79
+ const text = snapshotText(lastAttempt)
80
+ if (text && isMeaningfulSnapshot(text)) {
81
+ const nextFingerprint = fingerprint(text)
82
+ if (nextFingerprint !== stableFingerprint) {
83
+ stableFingerprint = nextFingerprint
84
+ stableSince = sampledAt
85
+ } else if (!isPendingSnapshot(text) && sampledAt - stableSince >= policy.quietWindowMs) {
86
+ return lastAttempt
87
+ }
88
+ } else {
89
+ stableFingerprint = null
90
+ stableSince = sampledAt
91
+ }
92
+ } else {
93
+ stableFingerprint = null
94
+ stableSince = sampledAt
95
+ }
96
+
97
+ const remaining = deadline - clock.now()
98
+ if (remaining <= 0) return lastSuccess ?? lastAttempt
99
+ await clock.sleep(Math.min(policy.pollMs, remaining))
100
+ }
101
+ }
package/src/program.ts CHANGED
@@ -87,8 +87,10 @@ export async function buildProgram(): Promise<Command> {
87
87
  registerCommand(program, withKernelOptions((await import('./commands/describe')).default))
88
88
  registerCommand(program, withKernelOptions((await import('./commands/query')).default))
89
89
  registerCommand(program, withKernelOptions((await import('./commands/logs')).default))
90
+ registerCommand(program, withKernelOptions((await import('./commands/view')).default))
90
91
  registerCommand(program, (await import('./commands/status')).default)
91
92
  registerCommand(program, (await import('./commands/browser')).default)
93
+ registerCommand(program, (await import('./commands/view-serve')).default)
92
94
  registerCommand(program, (await import('./commands/studio')).default)
93
95
 
94
96
  registerGroup(program, {
@@ -153,6 +155,15 @@ export async function buildProgram(): Promise<Command> {
153
155
  ],
154
156
  })
155
157
 
158
+ registerGroup(program, {
159
+ name: 'session',
160
+ description: 'Locally recorded work sessions: list and DX-analyze them',
161
+ commands: [
162
+ (await import('./commands/session/list')).default,
163
+ (await import('./commands/session/analyze')).default,
164
+ ],
165
+ })
166
+
156
167
  registerGroup(program, {
157
168
  name: 'idp',
158
169
  description: 'Manage OpenID Connect identity providers',
@@ -192,7 +203,7 @@ Examples:
192
203
  $ astrale instance bookmark staging --url https://kernel.example.com
193
204
  $ astrale instance create my-app
194
205
  $ astrale instance status staging
195
- $ astrale token --audience workspace.astrale.ai --ttl 3600
206
+ $ astrale token --audience shell.astrale.ai --ttl 3600
196
207
  $ astrale query / --depth 1
197
208
  $ astrale query --cypher 'MATCH (n) RETURN n LIMIT 5'
198
209
  `,
package/src/registry.ts CHANGED
@@ -6,7 +6,7 @@ import type { CommandDefinition, CommandGroup } from './command'
6
6
  * Register a single command on a Commander program or subcommand.
7
7
  */
8
8
  export function registerCommand(parent: Command, def: CommandDefinition): void {
9
- const cmd = parent.command(def.name).description(def.description)
9
+ const cmd = parent.command(def.name, { hidden: def.hidden ?? false }).description(def.description)
10
10
 
11
11
  if (def.summary) cmd.summary(def.summary)
12
12
 
@@ -3,7 +3,7 @@ import chalk from 'chalk'
3
3
  import type { SetupContext, SetupStep } from '../types'
4
4
 
5
5
  import { withAdminKernelClient } from '../../kernel/client'
6
- import { ADMIN_INSTANCE, type InstanceInfo } from '../../lib/admin-instance'
6
+ import { adminInstanceMethod, type InstanceInfo } from '../../lib/admin-instance'
7
7
  import { normalizeInstanceKernelUrl, setActive, upsertManagedBookmark } from '../../lib/instance'
8
8
  import { readLocalStatus } from '../../lib/local-status'
9
9
  import { log, withSpinner } from '../../lib/log'
@@ -19,7 +19,7 @@ async function fetchManaged(ctx: SetupContext): Promise<InstanceInfo[]> {
19
19
  withAdminKernelClient(
20
20
  ctx.opts,
21
21
  async (client) =>
22
- (await client.client.call(`${ADMIN_INSTANCE}/list`, {})) as InstanceInfo[],
22
+ (await client.client.call(adminInstanceMethod('list'), {})) as InstanceInfo[],
23
23
  ),
24
24
  )
25
25
  } catch {
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import type { TelemetryEvent } from '../types'
4
+
5
+ import { commandHead, extractSignals, hasSignals } from '../gate'
6
+
7
+ function ev(argv: string[], exitCode: number, errorName?: string): TelemetryEvent {
8
+ return {
9
+ v: 1,
10
+ ts: '2026-07-10T10:00:00.000Z',
11
+ argv,
12
+ exitCode,
13
+ durationMs: 10,
14
+ errorName,
15
+ cwd: '/w',
16
+ root: '/w',
17
+ surface: 'cli',
18
+ }
19
+ }
20
+
21
+ describe('commandHead', () => {
22
+ test('keeps up to two leading command tokens, stops at flags/values', () => {
23
+ expect(commandHead(['get', '/path', '-i', 'x'])).toBe('get /path')
24
+ expect(commandHead(['domain', 'install', './foo'])).toBe('domain install')
25
+ expect(commandHead(['status'])).toBe('status')
26
+ expect(commandHead(['--help'])).toBe('(bare)')
27
+ expect(commandHead(['call', 'k=v'])).toBe('call')
28
+ })
29
+ })
30
+
31
+ describe('extractSignals / hasSignals', () => {
32
+ test('failures grouped by command with error names', () => {
33
+ const s = extractSignals([
34
+ ev(['domain', 'install'], 1, 'AUTH_ERROR'),
35
+ ev(['domain', 'install'], 1, 'AUTH_ERROR'),
36
+ ev(['status'], 0),
37
+ ])
38
+ expect(s.failures).toEqual([
39
+ { command: 'domain install', count: 2, errorNames: ['AUTH_ERROR'] },
40
+ ])
41
+ expect(s.retries).toEqual([])
42
+ expect(hasSignals(s)).toBe(true)
43
+ })
44
+
45
+ test('3+ runs of one command is a retry smell even when green', () => {
46
+ const s = extractSignals([ev(['get', '/a'], 0), ev(['get', '/a'], 0), ev(['get', '/a'], 0)])
47
+ expect(s.retries).toEqual([{ command: 'get /a', count: 3 }])
48
+ expect(hasSignals(s)).toBe(true)
49
+ })
50
+
51
+ test('quiet session has no signals until a transcript is attached', () => {
52
+ const s = extractSignals([ev(['status'], 0), ev(['ls', '/'], 0)])
53
+ expect(hasSignals(s)).toBe(false)
54
+ s.harnessSessions = [
55
+ {
56
+ harness: 'claude-code',
57
+ transcriptPath: '/t.jsonl',
58
+ sizeBytes: 1,
59
+ },
60
+ ]
61
+ expect(hasSignals(s)).toBe(true)
62
+ })
63
+ })
@@ -0,0 +1,110 @@
1
+ import { afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import type { Finalizer } from '../recorder'
7
+ import type { SessionInfo } from '../store'
8
+ import type { TelemetryEvent } from '../types'
9
+
10
+ // ASTRALE_HOME must be set before the store/paths singleton is first imported.
11
+ process.env.ASTRALE_HOME = mkdtempSync(join(tmpdir(), 'astrale-tele-recorder-'))
12
+
13
+ let beginInvocation: (argv: string[]) => Finalizer
14
+ let eventsPath: (id: string) => string
15
+ let listSessions: () => SessionInfo[]
16
+ let sessionsRoot: () => string
17
+ let configPath: string
18
+
19
+ const SESSION = 'rec-test'
20
+
21
+ let work: string
22
+ let cwd0: string
23
+
24
+ beforeAll(async () => {
25
+ const recorder = await import('../recorder')
26
+ const store = await import('../store')
27
+ beginInvocation = recorder.beginInvocation
28
+ eventsPath = store.eventsPath
29
+ listSessions = store.listSessions
30
+ sessionsRoot = store.sessionsRoot
31
+ configPath = join(process.env.ASTRALE_HOME!, 'config.json')
32
+ })
33
+
34
+ beforeEach(() => {
35
+ // Destructive cleanup must be provably confined to this file's mkdtemp home.
36
+ if (!sessionsRoot().startsWith(tmpdir())) throw new Error('refusing to clean a non-tmp home')
37
+ rmSync(sessionsRoot(), { recursive: true, force: true })
38
+ rmSync(configPath, { force: true })
39
+ delete process.env.ASTRALE_TELEMETRY
40
+ process.env.ASTRALE_SESSION = SESSION
41
+ work = mkdtempSync(join(tmpdir(), 'astrale-tele-recwork-'))
42
+ cwd0 = process.cwd()
43
+ process.chdir(work)
44
+ })
45
+
46
+ afterEach(() => {
47
+ process.chdir(cwd0)
48
+ rmSync(work, { recursive: true, force: true })
49
+ })
50
+
51
+ function readEvents(id: string): TelemetryEvent[] {
52
+ return readFileSync(eventsPath(id), 'utf-8')
53
+ .split('\n')
54
+ .filter(Boolean)
55
+ .map((l) => JSON.parse(l) as TelemetryEvent)
56
+ }
57
+
58
+ describe('beginInvocation', () => {
59
+ test('appends one valid, parseable event with redacted argv', () => {
60
+ const cwd = process.cwd()
61
+ const finalize = beginInvocation(['node', 'astrale', 'get', '--token', 'abc', 'x'])
62
+ finalize(0)
63
+
64
+ const events = readEvents(SESSION)
65
+ expect(events).toHaveLength(1)
66
+ const [e] = events
67
+ expect(e.v).toBe(1)
68
+ expect(e.surface).toBe('cli')
69
+ expect(e.exitCode).toBe(0)
70
+ expect(e.argv).toEqual(['get', '--token', '<redacted>', 'x'])
71
+ expect(e.cwd).toBe(cwd)
72
+ expect(e.root).toBe(cwd)
73
+ expect(typeof e.ts).toBe('string')
74
+ expect(typeof e.durationMs).toBe('number')
75
+ expect('errorName' in e).toBe(false)
76
+ })
77
+
78
+ test('records exitCode and errorName on failure', () => {
79
+ beginInvocation(['node', 'astrale', 'call', 'foo'])(1, 'AuthError')
80
+ const [e] = readEvents(SESSION)
81
+ expect(e.exitCode).toBe(1)
82
+ expect(e.errorName).toBe('AuthError')
83
+ })
84
+
85
+ test('appends a second line on a second invocation', () => {
86
+ beginInvocation(['node', 'astrale', 'query'])(0)
87
+ beginInvocation(['node', 'astrale', 'whoami'])(0)
88
+ expect(readEvents(SESSION)).toHaveLength(2)
89
+ })
90
+
91
+ test('writes nothing for help/version-only invocations', () => {
92
+ beginInvocation(['node', 'astrale', '--help'])(0)
93
+ beginInvocation(['node', 'astrale', '--version'])(0)
94
+ beginInvocation(['node', 'astrale'])(0)
95
+ expect(listSessions()).toHaveLength(0)
96
+ })
97
+
98
+ test('writes nothing when disabled via ASTRALE_TELEMETRY', () => {
99
+ process.env.ASTRALE_TELEMETRY = '0'
100
+ beginInvocation(['node', 'astrale', 'get', 'x'])(0)
101
+ expect(listSessions()).toHaveLength(0)
102
+ expect(existsSync(eventsPath(SESSION))).toBe(false)
103
+ })
104
+
105
+ test('writes nothing when disabled via config telemetry.enabled=false', () => {
106
+ writeFileSync(configPath, JSON.stringify({ telemetry: { enabled: false } }))
107
+ beginInvocation(['node', 'astrale', 'get', 'x'])(0)
108
+ expect(listSessions()).toHaveLength(0)
109
+ })
110
+ })
@@ -0,0 +1,79 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { redactArgv } from '../redact'
4
+
5
+ describe('redactArgv', () => {
6
+ test('redacts the value following a secret flag', () => {
7
+ expect(redactArgv(['--token', 'abc123', '--json'])).toEqual(['--token', '<redacted>', '--json'])
8
+ })
9
+
10
+ test('matches secret substrings in flag names, case-insensitively', () => {
11
+ expect(redactArgv(['--api-Token', 's', '--Bearer', 'b', '--jwk', 'k'])).toEqual([
12
+ '--api-Token',
13
+ '<redacted>',
14
+ '--Bearer',
15
+ '<redacted>',
16
+ '--jwk',
17
+ '<redacted>',
18
+ ])
19
+ })
20
+
21
+ test('redacts the value part of inline k=v secret args', () => {
22
+ expect(redactArgv(['--secret=hunter2', 'password=p', '--verbose'])).toEqual([
23
+ '--secret=<redacted>',
24
+ 'password=<redacted>',
25
+ '--verbose',
26
+ ])
27
+ })
28
+
29
+ test('leaves non-secret flags and their values intact', () => {
30
+ expect(redactArgv(['get', '--depth', '3', '--format=json'])).toEqual([
31
+ 'get',
32
+ '--depth',
33
+ '3',
34
+ '--format=json',
35
+ ])
36
+ })
37
+
38
+ test('truncates any arg longer than 200 chars to 200 + ellipsis', () => {
39
+ const long = 'x'.repeat(300)
40
+ const [only] = redactArgv([long])
41
+ expect(only).toBe('x'.repeat(200) + '…')
42
+ expect([...only].length).toBe(201)
43
+ })
44
+
45
+ test('a secret value is redacted before length matters', () => {
46
+ expect(redactArgv(['--password', 'y'.repeat(500)])).toEqual(['--password', '<redacted>'])
47
+ })
48
+
49
+ test('caps the array at 40 items with a …+N marker', () => {
50
+ const args = Array.from({ length: 45 }, (_, i) => `a${i}`)
51
+ const out = redactArgv(args)
52
+ expect(out.length).toBe(41)
53
+ expect(out.slice(0, 40)).toEqual(args.slice(0, 40))
54
+ expect(out[40]).toBe('…+5')
55
+ })
56
+
57
+ test('does not cap at exactly 40 items', () => {
58
+ const args = Array.from({ length: 40 }, (_, i) => `a${i}`)
59
+ expect(redactArgv(args)).toEqual(args)
60
+ })
61
+
62
+ test('a trailing secret flag with no value does not throw', () => {
63
+ expect(redactArgv(['call', '--token'])).toEqual(['call', '--token'])
64
+ })
65
+ })
66
+
67
+ describe('secret-shaped values', () => {
68
+ test('positional JWT is redacted wherever it appears', () => {
69
+ const jwt =
70
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U'
71
+ expect(redactArgv(['call', '/x::y', jwt])).toEqual(['call', '/x::y', '<redacted>'])
72
+ expect(redactArgv(['call', `body=Bearer ${jwt}`])[1]).toBe('body=Bearer <redacted>')
73
+ })
74
+ test('sk- style api key is redacted, plain paths are not', () => {
75
+ expect(redactArgv(['x', 'sk-abcdefghijklmnop1234'])).toEqual(['x', '<redacted>'])
76
+ const p = '/Users/someone/Documents/astrale/workspace/cli/src/telemetry/adapters/index.ts'
77
+ expect(redactArgv([p])).toEqual([p])
78
+ })
79
+ })