@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.
- package/dist/astrale.js +12962 -487
- package/package.json +7 -3
- 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__/install-identity-override.test.ts +1 -1
- package/src/commands/__tests__/view.test.ts +100 -0
- package/src/commands/call.ts +1 -1
- package/src/commands/domain/install.ts +5 -5
- package/src/commands/domain/list.ts +2 -2
- package/src/commands/domain/publish.ts +3 -3
- 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/query.ts +27 -8
- package/src/commands/session/analyze.ts +50 -0
- package/src/commands/session/list.ts +36 -0
- package/src/commands/token.ts +1 -1
- 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/client.ts +30 -24
- package/src/kernel/expand.ts +3 -2
- package/src/kernel/index.ts +7 -1
- package/src/kernel/options.ts +1 -1
- package/src/lib/__tests__/instance-target.test.ts +34 -0
- 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/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/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 +12 -1
- package/src/registry.ts +1 -1
- 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 +2 -1
- package/studio/server/agent/runner.ts +4 -1
- package/studio/server/agent/session-id.ts +13 -0
- package/studio/server/api.ts +43 -25
- 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 +122 -22
- package/studio/server/state/views.test.ts +90 -0
- package/studio/server/state/views.ts +396 -99
- package/studio/server/state/visibility.ts +5 -5
- 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/studio/client/dist/assets/index-BcejyJpa.css +0 -1
- 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
|
|
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 {
|
|
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
|
+
})
|