@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.
- package/LICENSE +202 -0
- package/README.md +2 -2
- package/THIRD-PARTY-NOTICES.md +27 -0
- package/dist/astrale.js +24396 -9632
- package/package.json +24 -22
- package/src/command.ts +2 -0
- package/src/commands/__tests__/admin-instance.test.ts +3 -2
- package/src/commands/__tests__/domain-list.test.ts +6 -2
- package/src/commands/__tests__/help-contract.test.ts +27 -14
- package/src/commands/__tests__/install-identity-override.test.ts +2 -2
- package/src/commands/__tests__/ls.test.ts +1 -1
- package/src/commands/__tests__/read-commands.test.ts +201 -0
- package/src/commands/__tests__/view.test.ts +100 -0
- package/src/commands/call.ts +27 -44
- package/src/commands/describe.ts +57 -58
- package/src/commands/domain/install.ts +9 -9
- package/src/commands/domain/list.ts +2 -2
- package/src/commands/domain/publish.ts +3 -3
- package/src/commands/get.ts +48 -23
- package/src/commands/identity/register.ts +27 -33
- package/src/commands/instance/active.ts +2 -2
- package/src/commands/instance/create.ts +1 -1
- package/src/commands/instance/delete.ts +3 -3
- package/src/commands/instance/list.ts +8 -3
- package/src/commands/instance/status.ts +4 -3
- package/src/commands/instance/use.ts +2 -2
- package/src/commands/logs.ts +8 -8
- package/src/commands/ls.ts +77 -55
- package/src/commands/mutate.ts +191 -0
- package/src/commands/query.ts +307 -20
- package/src/commands/session/analyze.ts +50 -0
- package/src/commands/session/list.ts +36 -0
- package/src/commands/token.ts +4 -7
- package/src/commands/view-serve.ts +26 -0
- package/src/commands/view.ts +614 -0
- package/src/connect-core.test.ts +42 -0
- package/src/connect-core.ts +53 -0
- package/src/kernel/__tests__/auth.test.ts +1 -0
- package/src/kernel/__tests__/expand.test.ts +123 -0
- package/src/kernel/client.ts +33 -37
- package/src/kernel/expand.ts +55 -61
- package/src/kernel/graph.ts +96 -0
- package/src/kernel/index.ts +18 -3
- package/src/kernel/options.ts +1 -1
- package/src/kernel/run.ts +0 -13
- package/src/lib/__tests__/instance-target.test.ts +34 -0
- package/src/lib/__tests__/table.test.ts +1 -1
- package/src/lib/__tests__/view-open-intent.test.ts +308 -0
- package/src/lib/__tests__/view-snapshot.test.ts +77 -0
- package/src/lib/admin-domain.ts +8 -4
- package/src/lib/admin-instance.ts +5 -1
- package/src/lib/config.ts +1 -0
- package/src/lib/domain-identity.ts +1 -1
- package/src/lib/instance-target.ts +5 -0
- package/src/lib/instance.ts +11 -0
- package/src/lib/log.ts +12 -2
- package/src/lib/login-flow.ts +41 -4
- package/src/lib/provision-instance.ts +2 -2
- package/src/lib/self.ts +1 -3
- package/src/lib/view/open-intent.ts +97 -0
- package/src/lib/view/resolve.ts +104 -0
- package/src/lib/view/server.ts +294 -0
- package/src/lib/view/session.ts +123 -0
- package/src/lib/view/snapshot.ts +101 -0
- package/src/program.ts +16 -3
- package/src/registry.ts +1 -1
- package/src/setup/render.ts +1 -3
- package/src/setup/steps/instance.ts +2 -2
- package/src/telemetry/__tests__/gate.test.ts +63 -0
- package/src/telemetry/__tests__/recorder.test.ts +110 -0
- package/src/telemetry/__tests__/redact.test.ts +79 -0
- package/src/telemetry/__tests__/session.test.ts +166 -0
- package/src/telemetry/__tests__/trigger.test.ts +49 -0
- package/src/telemetry/adapters/__tests__/claude-code.test.ts +89 -0
- package/src/telemetry/adapters/__tests__/codex.test.ts +138 -0
- package/src/telemetry/adapters/__tests__/index.test.ts +88 -0
- package/src/telemetry/adapters/claude-code.ts +90 -0
- package/src/telemetry/adapters/codex.ts +160 -0
- package/src/telemetry/adapters/index.ts +45 -0
- package/src/telemetry/adapters/types.ts +21 -0
- package/src/telemetry/analyze.ts +227 -0
- package/src/telemetry/gate.ts +79 -0
- package/src/telemetry/recorder.ts +57 -0
- package/src/telemetry/redact.ts +53 -0
- package/src/telemetry/session.ts +88 -0
- package/src/telemetry/settings.ts +26 -0
- package/src/telemetry/store.ts +91 -0
- package/src/telemetry/trigger.ts +119 -0
- package/src/telemetry/types.ts +64 -0
- package/studio/client/dist/assets/index-CyN5G8IA.js +109 -0
- package/studio/client/dist/assets/index-DKKMHBBC.css +1 -0
- package/studio/client/dist/index.html +2 -2
- package/studio/server/agent/ask.ts +7 -2
- package/studio/server/agent/claude.ts +15 -3
- package/studio/server/agent/runner.ts +4 -1
- package/studio/server/agent/session-id.ts +13 -0
- package/studio/server/api.ts +50 -32
- package/studio/server/cache.ts +17 -6
- package/studio/server/client-package.test.ts +147 -0
- package/studio/server/client-package.ts +242 -0
- package/studio/server/index.ts +13 -0
- package/studio/server/introspect/anatomy-extras.test.ts +91 -0
- package/studio/server/introspect/anatomy-extras.ts +48 -49
- package/studio/server/introspect/anatomy.ts +7 -7
- package/studio/server/introspect/overlay-tsmorph.test.ts +104 -0
- package/studio/server/introspect/overlay-tsmorph.ts +230 -72
- package/studio/server/state/harness-gateway.ts +12 -3
- package/studio/server/state/harness-token.ts +0 -0
- package/studio/server/state/views.test.ts +90 -0
- package/studio/server/state/views.ts +396 -99
- package/studio/server/state/visibility.ts +10 -6
- package/studio/server/view-dev-server.test.ts +111 -0
- package/studio/server/view-dev-server.ts +372 -0
- package/studio/shared/types.ts +57 -10
- package/viewer/dist/index.html +93 -0
- package/viewer/dist/main.js +71 -0
- package/src/kernel/__tests__/remote-routing.test.ts +0 -70
- package/src/kernel/remote-routing.ts +0 -88
- package/studio/client/dist/assets/index-DOwzZAEK.css +0 -1
- package/studio/client/dist/assets/index-wtU0Zxhy.js +0 -183
- 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
|
|
195
|
-
$ astrale query
|
|
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
|
|
package/src/setup/render.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
+
})
|