@astrale-os/cli 0.4.0-alpha.13 → 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 (121) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +2 -2
  3. package/THIRD-PARTY-NOTICES.md +27 -0
  4. package/dist/astrale.js +24396 -9632
  5. package/package.json +24 -22
  6. package/src/command.ts +2 -0
  7. package/src/commands/__tests__/admin-instance.test.ts +3 -2
  8. package/src/commands/__tests__/domain-list.test.ts +6 -2
  9. package/src/commands/__tests__/help-contract.test.ts +27 -14
  10. package/src/commands/__tests__/install-identity-override.test.ts +2 -2
  11. package/src/commands/__tests__/ls.test.ts +1 -1
  12. package/src/commands/__tests__/read-commands.test.ts +201 -0
  13. package/src/commands/__tests__/view.test.ts +100 -0
  14. package/src/commands/call.ts +27 -44
  15. package/src/commands/describe.ts +57 -58
  16. package/src/commands/domain/install.ts +9 -9
  17. package/src/commands/domain/list.ts +2 -2
  18. package/src/commands/domain/publish.ts +3 -3
  19. package/src/commands/get.ts +48 -23
  20. package/src/commands/identity/register.ts +27 -33
  21. package/src/commands/instance/active.ts +2 -2
  22. package/src/commands/instance/create.ts +1 -1
  23. package/src/commands/instance/delete.ts +3 -3
  24. package/src/commands/instance/list.ts +8 -3
  25. package/src/commands/instance/status.ts +4 -3
  26. package/src/commands/instance/use.ts +2 -2
  27. package/src/commands/logs.ts +8 -8
  28. package/src/commands/ls.ts +77 -55
  29. package/src/commands/mutate.ts +191 -0
  30. package/src/commands/query.ts +307 -20
  31. package/src/commands/session/analyze.ts +50 -0
  32. package/src/commands/session/list.ts +36 -0
  33. package/src/commands/token.ts +4 -7
  34. package/src/commands/view-serve.ts +26 -0
  35. package/src/commands/view.ts +614 -0
  36. package/src/connect-core.test.ts +42 -0
  37. package/src/connect-core.ts +53 -0
  38. package/src/kernel/__tests__/auth.test.ts +1 -0
  39. package/src/kernel/__tests__/expand.test.ts +123 -0
  40. package/src/kernel/client.ts +33 -37
  41. package/src/kernel/expand.ts +55 -61
  42. package/src/kernel/graph.ts +96 -0
  43. package/src/kernel/index.ts +18 -3
  44. package/src/kernel/options.ts +1 -1
  45. package/src/kernel/run.ts +0 -13
  46. package/src/lib/__tests__/instance-target.test.ts +34 -0
  47. package/src/lib/__tests__/table.test.ts +1 -1
  48. package/src/lib/__tests__/view-open-intent.test.ts +308 -0
  49. package/src/lib/__tests__/view-snapshot.test.ts +77 -0
  50. package/src/lib/admin-domain.ts +8 -4
  51. package/src/lib/admin-instance.ts +5 -1
  52. package/src/lib/config.ts +1 -0
  53. package/src/lib/domain-identity.ts +1 -1
  54. package/src/lib/instance-target.ts +5 -0
  55. package/src/lib/instance.ts +11 -0
  56. package/src/lib/log.ts +12 -2
  57. package/src/lib/login-flow.ts +41 -4
  58. package/src/lib/provision-instance.ts +2 -2
  59. package/src/lib/self.ts +1 -3
  60. package/src/lib/view/open-intent.ts +97 -0
  61. package/src/lib/view/resolve.ts +104 -0
  62. package/src/lib/view/server.ts +294 -0
  63. package/src/lib/view/session.ts +123 -0
  64. package/src/lib/view/snapshot.ts +101 -0
  65. package/src/program.ts +16 -3
  66. package/src/registry.ts +1 -1
  67. package/src/setup/render.ts +1 -3
  68. package/src/setup/steps/instance.ts +2 -2
  69. package/src/telemetry/__tests__/gate.test.ts +63 -0
  70. package/src/telemetry/__tests__/recorder.test.ts +110 -0
  71. package/src/telemetry/__tests__/redact.test.ts +79 -0
  72. package/src/telemetry/__tests__/session.test.ts +166 -0
  73. package/src/telemetry/__tests__/trigger.test.ts +49 -0
  74. package/src/telemetry/adapters/__tests__/claude-code.test.ts +89 -0
  75. package/src/telemetry/adapters/__tests__/codex.test.ts +138 -0
  76. package/src/telemetry/adapters/__tests__/index.test.ts +88 -0
  77. package/src/telemetry/adapters/claude-code.ts +90 -0
  78. package/src/telemetry/adapters/codex.ts +160 -0
  79. package/src/telemetry/adapters/index.ts +45 -0
  80. package/src/telemetry/adapters/types.ts +21 -0
  81. package/src/telemetry/analyze.ts +227 -0
  82. package/src/telemetry/gate.ts +79 -0
  83. package/src/telemetry/recorder.ts +57 -0
  84. package/src/telemetry/redact.ts +53 -0
  85. package/src/telemetry/session.ts +88 -0
  86. package/src/telemetry/settings.ts +26 -0
  87. package/src/telemetry/store.ts +91 -0
  88. package/src/telemetry/trigger.ts +119 -0
  89. package/src/telemetry/types.ts +64 -0
  90. package/studio/client/dist/assets/index-CyN5G8IA.js +109 -0
  91. package/studio/client/dist/assets/index-DKKMHBBC.css +1 -0
  92. package/studio/client/dist/index.html +2 -2
  93. package/studio/server/agent/ask.ts +7 -2
  94. package/studio/server/agent/claude.ts +15 -3
  95. package/studio/server/agent/runner.ts +4 -1
  96. package/studio/server/agent/session-id.ts +13 -0
  97. package/studio/server/api.ts +50 -32
  98. package/studio/server/cache.ts +17 -6
  99. package/studio/server/client-package.test.ts +147 -0
  100. package/studio/server/client-package.ts +242 -0
  101. package/studio/server/index.ts +13 -0
  102. package/studio/server/introspect/anatomy-extras.test.ts +91 -0
  103. package/studio/server/introspect/anatomy-extras.ts +48 -49
  104. package/studio/server/introspect/anatomy.ts +7 -7
  105. package/studio/server/introspect/overlay-tsmorph.test.ts +104 -0
  106. package/studio/server/introspect/overlay-tsmorph.ts +230 -72
  107. package/studio/server/state/harness-gateway.ts +12 -3
  108. package/studio/server/state/harness-token.ts +0 -0
  109. package/studio/server/state/views.test.ts +90 -0
  110. package/studio/server/state/views.ts +396 -99
  111. package/studio/server/state/visibility.ts +10 -6
  112. package/studio/server/view-dev-server.test.ts +111 -0
  113. package/studio/server/view-dev-server.ts +372 -0
  114. package/studio/shared/types.ts +57 -10
  115. package/viewer/dist/index.html +93 -0
  116. package/viewer/dist/main.js +71 -0
  117. package/src/kernel/__tests__/remote-routing.test.ts +0 -70
  118. package/src/kernel/remote-routing.ts +0 -88
  119. package/studio/client/dist/assets/index-DOwzZAEK.css +0 -1
  120. package/studio/client/dist/assets/index-wtU0Zxhy.js +0 -183
  121. package/studio/tsconfig.json +0 -23
@@ -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
@@ -82,12 +82,15 @@ export async function buildProgram(): Promise<Command> {
82
82
  registerCommand(program, withKernelOptions((await import('./commands/call')).default))
83
83
  registerCommand(program, withKernelOptions((await import('./commands/token')).default))
84
84
  registerCommand(program, withKernelOptions((await import('./commands/get')).default))
85
+ registerCommand(program, withKernelOptions((await import('./commands/mutate')).default))
85
86
  registerCommand(program, withKernelOptions((await import('./commands/ls')).default))
86
87
  registerCommand(program, withKernelOptions((await import('./commands/describe')).default))
87
88
  registerCommand(program, withKernelOptions((await import('./commands/query')).default))
88
89
  registerCommand(program, withKernelOptions((await import('./commands/logs')).default))
90
+ registerCommand(program, withKernelOptions((await import('./commands/view')).default))
89
91
  registerCommand(program, (await import('./commands/status')).default)
90
92
  registerCommand(program, (await import('./commands/browser')).default)
93
+ registerCommand(program, (await import('./commands/view-serve')).default)
91
94
  registerCommand(program, (await import('./commands/studio')).default)
92
95
 
93
96
  registerGroup(program, {
@@ -152,6 +155,15 @@ export async function buildProgram(): Promise<Command> {
152
155
  ],
153
156
  })
154
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
+
155
167
  registerGroup(program, {
156
168
  name: 'idp',
157
169
  description: 'Manage OpenID Connect identity providers',
@@ -169,7 +181,7 @@ export async function buildProgram(): Promise<Command> {
169
181
  `
170
182
  Command groups:
171
183
  Getting started setup (sign in, pick an instance, equip your workspace)
172
- Kernel ls, get, call, query, describe, token
184
+ Kernel ls, get, mutate, call, query, describe, token
173
185
  Management admin, instance, domain, identity, auth, idp, update
174
186
  Agent browser (drive the GUI via agent-browser)
175
187
  Studio studio (launch the local Domain Studio GUI for a workspace)
@@ -191,8 +203,9 @@ Examples:
191
203
  $ astrale instance bookmark staging --url https://kernel.example.com
192
204
  $ astrale instance create my-app
193
205
  $ astrale instance status staging
194
- $ astrale token --audience dist.astrale.ai --ttl 3600
195
- $ astrale query 'MATCH (n) RETURN n LIMIT 5'
206
+ $ astrale token --audience shell.astrale.ai --ttl 3600
207
+ $ astrale query / --depth 1
208
+ $ astrale query --cypher 'MATCH (n) RETURN n LIMIT 5'
196
209
  `,
197
210
  )
198
211
 
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
 
@@ -101,9 +101,7 @@ export async function renderFinale(): Promise<void> {
101
101
  }
102
102
  console.log('')
103
103
  console.log(chalk.bold('Next'))
104
- console.log(
105
- ` ${chalk.cyan('astrale call /:dist.astrale.ai:class.Echo:echo message=hello')} ${chalk.dim('— smoke test')}`,
106
- )
104
+ console.log(` ${chalk.cyan('astrale get @self --json')} ${chalk.dim('— smoke test')}`)
107
105
  console.log(` ${chalk.cyan('astrale browser')} ${chalk.dim('— drive the GUI as your agent')}`)
108
106
  console.log(` ${chalk.cyan('astrale --help')} ${chalk.dim('— everything else')}`)
109
107
  }
@@ -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
+ })
@@ -0,0 +1,166 @@
1
+ import { afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ utimesSync,
9
+ writeFileSync,
10
+ } from 'node:fs'
11
+ import { tmpdir } from 'node:os'
12
+ import { join } from 'node:path'
13
+
14
+ import type { ResolvedSession } from '../session'
15
+ import type { SessionMeta } from '../types'
16
+
17
+ /** Seed an ambient session on disk with a chosen id + last-activity age. */
18
+ function seedAmbient(id: string, root: string, ageMs: number): void {
19
+ mkdirSync(sessionDir(id), { recursive: true })
20
+ const meta: SessionMeta = { id, root, explicit: false, createdAt: new Date().toISOString() }
21
+ writeFileSync(metaPath(id), JSON.stringify(meta))
22
+ writeFileSync(eventsPath(id), '{}\n')
23
+ const when = new Date(Date.now() - ageMs)
24
+ utimesSync(eventsPath(id), when, when)
25
+ }
26
+
27
+ // Set ASTRALE_HOME before the store/paths singleton is first evaluated (below,
28
+ // via dynamic import). All on-disk access goes through the store helpers so it
29
+ // stays consistent whichever home the singleton captured.
30
+ process.env.ASTRALE_HOME = mkdtempSync(join(tmpdir(), 'astrale-tele-session-'))
31
+
32
+ let resolveSession: (cwd: string) => ResolvedSession
33
+ let ensureSession: (cwd: string) => ResolvedSession
34
+ let eventsPath: (id: string) => string
35
+ let metaPath: (id: string) => string
36
+ let sessionDir: (id: string) => string
37
+ let sessionsRoot: () => string
38
+ let IDLE_WINDOW_MS: number
39
+
40
+ let work: string
41
+
42
+ beforeAll(async () => {
43
+ const session = await import('../session')
44
+ const store = await import('../store')
45
+ resolveSession = session.resolveSession
46
+ ensureSession = session.ensureSession
47
+ eventsPath = store.eventsPath
48
+ metaPath = store.metaPath
49
+ sessionDir = store.sessionDir
50
+ sessionsRoot = store.sessionsRoot
51
+ IDLE_WINDOW_MS = store.IDLE_WINDOW_MS
52
+ })
53
+
54
+ beforeEach(() => {
55
+ if (!sessionsRoot().startsWith(tmpdir())) throw new Error('refusing to clean a non-tmp home')
56
+ rmSync(sessionsRoot(), { recursive: true, force: true })
57
+ delete process.env.ASTRALE_SESSION
58
+ work = mkdtempSync(join(tmpdir(), 'astrale-tele-work-'))
59
+ })
60
+
61
+ afterEach(() => {
62
+ rmSync(work, { recursive: true, force: true })
63
+ })
64
+
65
+ describe('resolveSession — ambient', () => {
66
+ test('reuses an open ambient session bucketed to the same root', () => {
67
+ // An old-stamp id proves reuse: a fresh mint would carry the current minute.
68
+ const existing = 'amb-deadbeef-200001010000'
69
+ seedAmbient(existing, work, 5 * 60_000)
70
+
71
+ const r = resolveSession(work)
72
+
73
+ expect(r).toEqual({ id: existing, root: work, explicit: false })
74
+ })
75
+
76
+ test('mints a fresh session once the previous one is idle', () => {
77
+ const existing = 'amb-deadbeef-200001010000'
78
+ seedAmbient(existing, work, IDLE_WINDOW_MS + 60_000)
79
+
80
+ const r = resolveSession(work)
81
+
82
+ expect(r.id).not.toBe(existing)
83
+ expect(r.explicit).toBe(false)
84
+ expect(r.id).toMatch(/^amb-[0-9a-f]{8}-\d{12}$/)
85
+ })
86
+
87
+ test('does not reuse a session bucketed to a different root', () => {
88
+ const existing = 'amb-deadbeef-200001010000'
89
+ seedAmbient(existing, work, 5 * 60_000)
90
+
91
+ const other = mkdtempSync(join(tmpdir(), 'astrale-tele-other-'))
92
+ try {
93
+ expect(resolveSession(other).id).not.toBe(existing)
94
+ } finally {
95
+ rmSync(other, { recursive: true, force: true })
96
+ }
97
+ })
98
+
99
+ test('ensureSession creates then reuses one session across calls', () => {
100
+ const first = ensureSession(work)
101
+ const second = ensureSession(work)
102
+ expect(second.id).toBe(first.id)
103
+ expect(first.id).toMatch(/^amb-[0-9a-f]{8}-\d{12}$/)
104
+ })
105
+ })
106
+
107
+ describe('resolveSession — root bucketing', () => {
108
+ test('buckets by the nearest .git ancestor, not cwd', () => {
109
+ mkdirSync(join(work, '.git'), { recursive: true })
110
+ const deep = join(work, 'sub', 'deep')
111
+ mkdirSync(deep, { recursive: true })
112
+
113
+ expect(resolveSession(deep).root).toBe(work)
114
+ })
115
+
116
+ test('falls back to cwd when there is no git root', () => {
117
+ expect(resolveSession(work).root).toBe(work)
118
+ })
119
+ })
120
+
121
+ describe('resolveSession — explicit ASTRALE_SESSION', () => {
122
+ test('honors an explicit id, sanitizing unsafe chars and capping at 64', () => {
123
+ process.env.ASTRALE_SESSION = 'my/weird:sess!!'
124
+ const r = resolveSession(work)
125
+ expect(r).toEqual({ id: 'myweirdsess', root: work, explicit: true })
126
+
127
+ process.env.ASTRALE_SESSION = 'a'.repeat(80)
128
+ expect(resolveSession(work).id).toBe('a'.repeat(64))
129
+ })
130
+
131
+ test('falls through to ambient when the id is empty after sanitizing', () => {
132
+ process.env.ASTRALE_SESSION = '///'
133
+ const r = resolveSession(work)
134
+ expect(r.explicit).toBe(false)
135
+ expect(r.id).toMatch(/^amb-/)
136
+ })
137
+ })
138
+
139
+ describe('ensureSession', () => {
140
+ test('creates the session dir and a readable meta.json once', () => {
141
+ const r = ensureSession(work)
142
+
143
+ expect(existsSync(metaPath(r.id))).toBe(true)
144
+ const meta = JSON.parse(readFileSync(metaPath(r.id), 'utf-8')) as SessionMeta
145
+ expect(meta.id).toBe(r.id)
146
+ expect(meta.root).toBe(work)
147
+ expect(meta.explicit).toBe(false)
148
+ expect(typeof meta.createdAt).toBe('string')
149
+ })
150
+ })
151
+
152
+ describe('analyzed sessions are never reused', () => {
153
+ test('an open ambient session with an .analyzed marker gets a fresh mint', async () => {
154
+ const { markerPath } = await import('../store')
155
+ const root = join(work, 'analyzed-root')
156
+ mkdirSync(root, { recursive: true })
157
+ seedAmbient('amb-analyzed-open', root, 0) // fresh mtime → open
158
+ writeFileSync(
159
+ markerPath('amb-analyzed-open'),
160
+ JSON.stringify({ analyzedAt: new Date().toISOString(), outcome: 'reported' }),
161
+ )
162
+ const resolved = resolveSession(root)
163
+ expect(resolved.id).not.toBe('amb-analyzed-open')
164
+ expect(resolved.id.startsWith('amb-')).toBe(true)
165
+ })
166
+ })
@@ -0,0 +1,49 @@
1
+ import { beforeAll, describe, expect, test } from 'bun:test'
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ // Set the home before the paths singleton is captured (dynamic imports below).
7
+ process.env.ASTRALE_HOME = mkdtempSync(join(tmpdir(), 'astrale-tele-trigger-'))
8
+
9
+ let maybeTriggerAnalysis: (argv: string[]) => void
10
+ let sessionDir: (id: string) => string
11
+
12
+ beforeAll(async () => {
13
+ ;({ maybeTriggerAnalysis } = await import('../trigger'))
14
+ ;({ sessionDir } = await import('../store'))
15
+ // Sibling test files mutate shared state (env vars, and recorder.test.ts
16
+ // leaves a telemetry-disabled config.json in the shared home) — own it here.
17
+ delete process.env.ASTRALE_TELEMETRY
18
+ delete process.env.ASTRALE_TELEMETRY_NO_TRIGGER
19
+ const { CONFIG_PATH } = await import('../../lib/paths')
20
+ rmSync(CONFIG_PATH, { force: true })
21
+ })
22
+
23
+ function seed(id: string, ageMs: number, analyzed: boolean): void {
24
+ const dir = sessionDir(id)
25
+ mkdirSync(dir, { recursive: true })
26
+ writeFileSync(join(dir, 'meta.json'), JSON.stringify({ id, root: '/w', explicit: false }))
27
+ writeFileSync(join(dir, 'events.jsonl'), '{}\n')
28
+ if (analyzed) {
29
+ writeFileSync(
30
+ join(dir, '.analyzed'),
31
+ JSON.stringify({ analyzedAt: new Date().toISOString(), outcome: 'reported' }),
32
+ )
33
+ }
34
+ const when = new Date(Date.now() - ageMs)
35
+ utimesSync(join(dir, 'events.jsonl'), when, when)
36
+ }
37
+
38
+ describe('opportunistic GC', () => {
39
+ test('removes analyzed sessions past retention, keeps recent and unanalyzed ones', () => {
40
+ const DAY = 24 * 60 * 60 * 1000
41
+ seed('gc-old-analyzed', 40 * DAY, true)
42
+ seed('gc-recent-analyzed', 2 * DAY, true)
43
+ // NOTE: no closed-unanalyzed sessions seeded — the trigger must find no
44
+ // analysis target, so this test never spawns a child process.
45
+ maybeTriggerAnalysis(['bun', 'astrale', 'status'])
46
+ expect(existsSync(sessionDir('gc-old-analyzed'))).toBe(false)
47
+ expect(existsSync(sessionDir('gc-recent-analyzed'))).toBe(true)
48
+ })
49
+ })