@raidiant/notifai 0.5.0 → 1.0.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/CHANGELOG.md +165 -0
- package/README.md +11 -4
- package/dist/claude-wake.d.ts +101 -0
- package/dist/claude-wake.js +397 -0
- package/dist/claude-wake.js.map +1 -0
- package/dist/codex-wake.d.ts +84 -0
- package/dist/codex-wake.js +238 -0
- package/dist/codex-wake.js.map +1 -0
- package/dist/commands.d.ts +31 -11
- package/dist/commands.js +679 -232
- package/dist/commands.js.map +1 -1
- package/dist/config-schema.d.ts +3 -3
- package/dist/config-schema.js +11 -49
- package/dist/config-schema.js.map +1 -1
- package/dist/config.d.ts +23 -45
- package/dist/config.js +61 -39
- package/dist/config.js.map +1 -1
- package/dist/harnesses.d.ts +4 -13
- package/dist/harnesses.js +12 -17
- package/dist/harnesses.js.map +1 -1
- package/dist/hook-adapter.d.ts +10 -1
- package/dist/hook-adapter.js +79 -10
- package/dist/hook-adapter.js.map +1 -1
- package/dist/hooks.d.ts +119 -65
- package/dist/hooks.js +330 -285
- package/dist/hooks.js.map +1 -1
- package/dist/install-hooks.d.ts +76 -24
- package/dist/install-hooks.js +301 -125
- package/dist/install-hooks.js.map +1 -1
- package/dist/interactive.d.ts +32 -1
- package/dist/interactive.js +165 -51
- package/dist/interactive.js.map +1 -1
- package/dist/main.js +14 -21
- package/dist/main.js.map +1 -1
- package/dist/native-skills.d.ts +1 -1
- package/dist/native-skills.js +23 -58
- package/dist/native-skills.js.map +1 -1
- package/dist/pack-feedback-slice.d.ts +35 -0
- package/dist/pack-feedback-slice.js +126 -0
- package/dist/pack-feedback-slice.js.map +1 -0
- package/dist/readiness.d.ts +20 -0
- package/dist/readiness.js +24 -0
- package/dist/readiness.js.map +1 -1
- package/dist/release.d.ts +31 -0
- package/dist/release.js +52 -0
- package/dist/release.js.map +1 -0
- package/dist/ui/theme.d.ts +1 -1
- package/package.json +11 -10
- package/src/commands.test.ts +386 -36
- package/src/commands.ts +473 -182
- package/src/config-schema.ts +2 -17
- package/src/config.test.ts +29 -4
- package/src/config.ts +67 -11
- package/src/harnesses.ts +7 -0
- package/src/hook-adapter.test.ts +43 -1
- package/src/hook-adapter.ts +83 -11
- package/src/hooks.test.ts +4 -5
- package/src/install-hooks.test.ts +154 -2
- package/src/install-hooks.ts +231 -44
- package/src/interactive.test.ts +338 -0
- package/src/interactive.ts +221 -49
- package/src/main.ts +14 -20
- package/src/native-skills.test.ts +75 -0
- package/src/native-skills.ts +31 -75
- package/src/pack-feedback-slice.test.ts +212 -0
- package/src/pack-feedback-slice.ts +187 -0
- package/src/readiness.test.ts +25 -0
- package/src/readiness.ts +38 -0
- package/src/release.ts +51 -0
- package/src/skill.test.ts +43 -0
- package/src/ui/config-view.test.ts +5 -4
- package/dist/idle.d.ts +0 -16
- package/dist/idle.js +0 -44
- package/dist/idle.js.map +0 -1
package/src/native-skills.ts
CHANGED
|
@@ -33,24 +33,16 @@ export interface SkillsAddOptions {
|
|
|
33
33
|
export interface NativeSkills {
|
|
34
34
|
/** Launch the native interactive `npx skills add` flow. */
|
|
35
35
|
add(options: SkillsAddOptions): Promise<number>
|
|
36
|
-
/**
|
|
36
|
+
/** Read installer-managed inventory from lock files. Does not spawn npx. */
|
|
37
37
|
list(scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv): Promise<SkillsListResult>
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
interface JsonSkill {
|
|
41
|
-
name?: unknown
|
|
42
|
-
path?: unknown
|
|
43
|
-
scope?: unknown
|
|
44
|
-
source?: unknown
|
|
45
|
-
sourceType?: unknown
|
|
46
|
-
sourceUrl?: unknown
|
|
47
|
-
}
|
|
48
|
-
|
|
49
40
|
interface LockEntry {
|
|
50
41
|
source?: unknown
|
|
51
42
|
sourceType?: unknown
|
|
52
43
|
sourceUrl?: unknown
|
|
53
44
|
ref?: unknown
|
|
45
|
+
skillPath?: unknown
|
|
54
46
|
}
|
|
55
47
|
|
|
56
48
|
interface LockFile {
|
|
@@ -77,78 +69,47 @@ function readLock(scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv): LockF
|
|
|
77
69
|
}
|
|
78
70
|
}
|
|
79
71
|
|
|
80
|
-
function
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
function jsonArray(stdout: string): unknown[] | null {
|
|
91
|
-
const start = stdout.indexOf('[')
|
|
92
|
-
const end = stdout.lastIndexOf(']')
|
|
93
|
-
if (start < 0 || end < start) return null
|
|
94
|
-
try {
|
|
95
|
-
const parsed: unknown = JSON.parse(stdout.slice(start, end + 1))
|
|
96
|
-
return Array.isArray(parsed) ? parsed : null
|
|
97
|
-
} catch {
|
|
98
|
-
return null
|
|
99
|
-
}
|
|
72
|
+
function conventionalSkillPath(
|
|
73
|
+
scope: SkillScope,
|
|
74
|
+
name: string,
|
|
75
|
+
cwd: string,
|
|
76
|
+
env: NodeJS.ProcessEnv,
|
|
77
|
+
): string {
|
|
78
|
+
if (scope === 'project') return path.join(cwd, '.agents', 'skills', name)
|
|
79
|
+
const home = env['HOME'] ?? env['USERPROFILE'] ?? os.homedir()
|
|
80
|
+
return path.join(home, '.agents', 'skills', name)
|
|
100
81
|
}
|
|
101
82
|
|
|
102
|
-
function
|
|
103
|
-
const entries = jsonArray(stdout)
|
|
104
|
-
if (entries === null) return null
|
|
83
|
+
function skillsFromLock(scope: SkillScope, cwd: string, env: NodeJS.ProcessEnv): NativeSkill[] {
|
|
105
84
|
const lock = readLock(scope, cwd, env)
|
|
106
|
-
return entries.flatMap((entry): NativeSkill[] => {
|
|
85
|
+
return Object.entries(lock.skills ?? {}).flatMap(([name, entry]): NativeSkill[] => {
|
|
107
86
|
if (entry === null || typeof entry !== 'object') return []
|
|
108
|
-
const value = entry as JsonSkill
|
|
109
|
-
if (
|
|
110
|
-
typeof value.name !== 'string' ||
|
|
111
|
-
typeof value.path !== 'string' ||
|
|
112
|
-
value.scope !== scope
|
|
113
|
-
) {
|
|
114
|
-
return []
|
|
115
|
-
}
|
|
116
|
-
const lockEntry = lockEntryFor(lock, value.name)
|
|
117
87
|
return [
|
|
118
88
|
{
|
|
119
|
-
name
|
|
89
|
+
name,
|
|
120
90
|
scope,
|
|
121
|
-
path:
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
91
|
+
path:
|
|
92
|
+
typeof entry.skillPath === 'string' && entry.skillPath !== ''
|
|
93
|
+
? entry.skillPath
|
|
94
|
+
: conventionalSkillPath(scope, name, cwd, env),
|
|
95
|
+
source: typeof entry.source === 'string' ? entry.source : null,
|
|
96
|
+
sourceType: typeof entry.sourceType === 'string' ? entry.sourceType : null,
|
|
97
|
+
sourceUrl: typeof entry.sourceUrl === 'string' ? entry.sourceUrl : null,
|
|
98
|
+
ref: typeof entry.ref === 'string' ? entry.ref : null,
|
|
126
99
|
},
|
|
127
100
|
]
|
|
128
101
|
})
|
|
129
102
|
}
|
|
130
103
|
|
|
131
|
-
function run(
|
|
132
|
-
args: string[],
|
|
133
|
-
options: { cwd: string; env: NodeJS.ProcessEnv; stdio: 'inherit' | 'capture' },
|
|
134
|
-
): Promise<{ code: number; stdout: string }> {
|
|
104
|
+
function run(args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }): Promise<number> {
|
|
135
105
|
return new Promise((resolve) => {
|
|
136
106
|
const child = spawn('npx', args, {
|
|
137
107
|
cwd: options.cwd,
|
|
138
108
|
env: options.env,
|
|
139
|
-
stdio:
|
|
109
|
+
stdio: 'inherit',
|
|
140
110
|
})
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
child.stdout?.on('data', (chunk: Buffer | string) => {
|
|
144
|
-
stdout += chunk.toString()
|
|
145
|
-
})
|
|
146
|
-
child.on('error', () => resolve({ code: 1, stdout }))
|
|
147
|
-
child.on('exit', (code) => resolve({ code: code ?? 1, stdout }))
|
|
148
|
-
return
|
|
149
|
-
}
|
|
150
|
-
child.on('error', () => resolve({ code: 1, stdout: '' }))
|
|
151
|
-
child.on('exit', (code) => resolve({ code: code ?? 1, stdout: '' }))
|
|
111
|
+
child.on('error', () => resolve(1))
|
|
112
|
+
child.on('exit', (code) => resolve(code ?? 1))
|
|
152
113
|
})
|
|
153
114
|
}
|
|
154
115
|
|
|
@@ -160,18 +121,13 @@ export const nativeSkills: NativeSkills = {
|
|
|
160
121
|
// An explicit scope is the unattended contract. Native `--yes` keeps all
|
|
161
122
|
// remaining installer prompts non-interactive after the scope is chosen.
|
|
162
123
|
if (options.scope !== undefined) args.push('--yes')
|
|
163
|
-
return
|
|
124
|
+
return run(args, { cwd: options.cwd, env: options.env })
|
|
164
125
|
},
|
|
165
126
|
|
|
166
127
|
async list(scope, cwd, env) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
)
|
|
171
|
-
if (result.code !== 0) return { skills: [], error: `npx skills list exited with code ${result.code}` }
|
|
172
|
-
const skills = managedSkills(result.stdout, scope, cwd, env)
|
|
173
|
-
return skills === null
|
|
174
|
-
? { skills: [], error: 'npx skills list returned invalid JSON' }
|
|
175
|
-
: { skills }
|
|
128
|
+
// Presence is already on disk. `npx skills list` takes seconds and cannot
|
|
129
|
+
// change whether the notifai skill is installed — the lock file is what
|
|
130
|
+
// the installer itself consults.
|
|
131
|
+
return { skills: skillsFromLock(scope, cwd, env) }
|
|
176
132
|
},
|
|
177
133
|
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { appendFileSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { gunzipSync } from 'node:zlib'
|
|
6
|
+
import { describe, expect, it } from 'vitest'
|
|
7
|
+
import { LOG_SCHEMA_VERSION, activeLogPath, createLogger, type LogRecord } from './logging.js'
|
|
8
|
+
import { encodeFeedbackSlice, packFeedbackSlice } from './pack-feedback-slice.js'
|
|
9
|
+
|
|
10
|
+
function sandbox(): NodeJS.ProcessEnv {
|
|
11
|
+
return { XDG_STATE_HOME: mkdtempSync(path.join(os.tmpdir(), 'notifai-feedback-')) }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function writeLog(env: NodeJS.ProcessEnv, records: LogRecord[]): void {
|
|
15
|
+
const file = activeLogPath(env)
|
|
16
|
+
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
17
|
+
writeFileSync(file, `${records.map((record) => JSON.stringify(record)).join('\n')}\n`, {
|
|
18
|
+
mode: 0o600,
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function record(overrides: Partial<LogRecord> & Pick<LogRecord, 'ts'>): LogRecord {
|
|
23
|
+
return {
|
|
24
|
+
v: 1,
|
|
25
|
+
level: 'info',
|
|
26
|
+
event: 'cli.end',
|
|
27
|
+
run: 'r_1',
|
|
28
|
+
cmd: 'send',
|
|
29
|
+
pid: 1,
|
|
30
|
+
...overrides,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('packFeedbackSlice', () => {
|
|
35
|
+
it('returns an empty result with no gzip when there is no log', () => {
|
|
36
|
+
const result = packFeedbackSlice(sandbox())
|
|
37
|
+
|
|
38
|
+
expect(result.record_count).toBe(0)
|
|
39
|
+
expect(result.records).toEqual([])
|
|
40
|
+
expect(result.jsonl).toBe('')
|
|
41
|
+
expect(result.gzip).toBeUndefined()
|
|
42
|
+
expect(result.uncompressed_bytes).toBe(0)
|
|
43
|
+
expect(result.compressed_bytes).toBe(0)
|
|
44
|
+
expect(result.truncated).toBe(false)
|
|
45
|
+
expect(result.consentSummary.toLowerCase()).toMatch(/credential/)
|
|
46
|
+
expect(result.consentSummary.toLowerCase()).toMatch(/title/)
|
|
47
|
+
expect(result.consentSummary.toLowerCase()).toMatch(/answer/)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('packs records inside the default 24-hour window and drops older ones', () => {
|
|
51
|
+
const env = sandbox()
|
|
52
|
+
const now = Date.parse('2026-08-13T12:00:00.000Z')
|
|
53
|
+
writeLog(env, [
|
|
54
|
+
record({ ts: '2026-08-12T11:00:00.000Z', data: { which: 'too-old' } }),
|
|
55
|
+
record({ ts: '2026-08-12T13:00:00.000Z', data: { which: 'kept' } }),
|
|
56
|
+
])
|
|
57
|
+
|
|
58
|
+
const result = packFeedbackSlice(env, { now })
|
|
59
|
+
|
|
60
|
+
expect(result.record_count).toBe(1)
|
|
61
|
+
expect(result.records[0]?.data).toMatchObject({ which: 'kept' })
|
|
62
|
+
expect(result.since).toBe(Date.parse('2026-08-12T12:00:00.000Z'))
|
|
63
|
+
expect(result.until).toBe(now)
|
|
64
|
+
expect(result.jsonl).toContain('kept')
|
|
65
|
+
expect(result.jsonl).not.toContain('too-old')
|
|
66
|
+
expect(result.gzip).toBeDefined()
|
|
67
|
+
expect(result.uncompressed_bytes).toBe(Buffer.byteLength(result.jsonl))
|
|
68
|
+
expect(result.compressed_bytes).toBe(result.gzip!.length)
|
|
69
|
+
expect(result.truncated).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('gives consent copy that names redacted credentials, titles, answers, window, and size', () => {
|
|
73
|
+
const env = sandbox()
|
|
74
|
+
const now = Date.parse('2026-08-13T12:00:00.000Z')
|
|
75
|
+
writeLog(env, [record({ ts: '2026-08-13T11:00:00.000Z' })])
|
|
76
|
+
|
|
77
|
+
const result = packFeedbackSlice(env, { now })
|
|
78
|
+
const copy = result.consentSummary.toLowerCase()
|
|
79
|
+
|
|
80
|
+
expect(copy).toMatch(/credential/)
|
|
81
|
+
expect(copy).toMatch(/already removed|already redacted|redacted/)
|
|
82
|
+
expect(copy).toMatch(/title/)
|
|
83
|
+
expect(copy).toMatch(/answer/)
|
|
84
|
+
expect(result.consentSummary).toContain('2026-08-12T12:00:00.000Z')
|
|
85
|
+
expect(result.consentSummary).toContain('2026-08-13T12:00:00.000Z')
|
|
86
|
+
expect(result.consentSummary).toContain(String(result.uncompressed_bytes))
|
|
87
|
+
expect(result.consentSummary).toContain(String(result.compressed_bytes))
|
|
88
|
+
expect(result.consentSummary).toContain(String(result.record_count))
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('applies extra log query filters on top of the window', () => {
|
|
92
|
+
const env = sandbox()
|
|
93
|
+
const now = Date.parse('2026-08-13T12:00:00.000Z')
|
|
94
|
+
writeLog(env, [
|
|
95
|
+
record({ ts: '2026-08-13T11:00:00.000Z', event: 'cli.end', data: { which: 'end' } }),
|
|
96
|
+
record({ ts: '2026-08-13T11:01:00.000Z', event: 'cli.error', data: { which: 'error' } }),
|
|
97
|
+
])
|
|
98
|
+
|
|
99
|
+
const result = packFeedbackSlice(env, { now, event: ['cli.error'] })
|
|
100
|
+
|
|
101
|
+
expect(result.record_count).toBe(1)
|
|
102
|
+
expect(result.records[0]?.event).toBe('cli.error')
|
|
103
|
+
expect(result.records[0]?.data).toMatchObject({ which: 'error' })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('drops oldest records until the uncompressed JSONL cap holds', () => {
|
|
107
|
+
const env = sandbox()
|
|
108
|
+
const now = Date.parse('2026-08-13T12:00:00.000Z')
|
|
109
|
+
const start = Date.parse('2026-08-13T10:00:00.000Z')
|
|
110
|
+
const pad = 'x'.repeat(2000)
|
|
111
|
+
writeLog(
|
|
112
|
+
env,
|
|
113
|
+
Array.from({ length: 200 }, (_, sequence) =>
|
|
114
|
+
record({
|
|
115
|
+
ts: new Date(start + sequence * 1000).toISOString(),
|
|
116
|
+
data: { sequence, pad },
|
|
117
|
+
}),
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
const result = packFeedbackSlice(env, { now })
|
|
122
|
+
const sequences = result.records.map((entry) => entry.data?.['sequence'])
|
|
123
|
+
|
|
124
|
+
expect(result.uncompressed_bytes).toBeLessThanOrEqual(256 * 1024)
|
|
125
|
+
expect(result.compressed_bytes).toBeLessThanOrEqual(128 * 1024)
|
|
126
|
+
expect(result.truncated).toBe(true)
|
|
127
|
+
expect(result.record_count).toBeGreaterThan(0)
|
|
128
|
+
expect(sequences[0]).toBeGreaterThan(0)
|
|
129
|
+
expect(sequences.at(-1)).toBe(199)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('drops oldest records until the gzip cap holds', () => {
|
|
133
|
+
const env = sandbox()
|
|
134
|
+
const now = Date.parse('2026-08-13T12:00:00.000Z')
|
|
135
|
+
const start = Date.parse('2026-08-13T10:00:00.000Z')
|
|
136
|
+
const seeded = Array.from({ length: 80 }, (_, sequence) =>
|
|
137
|
+
record({
|
|
138
|
+
ts: new Date(start + sequence * 1000).toISOString(),
|
|
139
|
+
data: { sequence, pad: randomBytes(1800).toString('base64') },
|
|
140
|
+
}),
|
|
141
|
+
)
|
|
142
|
+
writeLog(env, seeded)
|
|
143
|
+
|
|
144
|
+
const result = packFeedbackSlice(env, { now })
|
|
145
|
+
const sequences = result.records.map((entry) => entry.data?.['sequence'])
|
|
146
|
+
|
|
147
|
+
expect(result.uncompressed_bytes).toBeLessThanOrEqual(256 * 1024)
|
|
148
|
+
expect(result.compressed_bytes).toBeLessThanOrEqual(128 * 1024)
|
|
149
|
+
expect(result.truncated).toBe(true)
|
|
150
|
+
expect(result.record_count).toBeGreaterThan(0)
|
|
151
|
+
expect(result.record_count).toBeLessThan(seeded.length)
|
|
152
|
+
expect(sequences[0]).toBeGreaterThan(0)
|
|
153
|
+
expect(sequences.at(-1)).toBe(79)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('encodes the gzip slice as a gzip+base64 wire payload', () => {
|
|
157
|
+
const env = sandbox()
|
|
158
|
+
const now = Date.parse('2026-08-13T12:00:00.000Z')
|
|
159
|
+
writeLog(env, [record({ ts: '2026-08-13T11:00:00.000Z', data: { which: 'wire' } })])
|
|
160
|
+
|
|
161
|
+
const packed = packFeedbackSlice(env, { now })
|
|
162
|
+
const wire = encodeFeedbackSlice(packed)
|
|
163
|
+
|
|
164
|
+
expect(wire).toEqual({
|
|
165
|
+
encoding: 'gzip+base64',
|
|
166
|
+
bytes: packed.gzip!.toString('base64'),
|
|
167
|
+
uncompressed_bytes: packed.uncompressed_bytes,
|
|
168
|
+
compressed_bytes: packed.compressed_bytes,
|
|
169
|
+
record_count: packed.record_count,
|
|
170
|
+
truncated: false,
|
|
171
|
+
since: '2026-08-12T12:00:00.000Z',
|
|
172
|
+
until: '2026-08-13T12:00:00.000Z',
|
|
173
|
+
schema_version: packed.schema_version,
|
|
174
|
+
})
|
|
175
|
+
expect(gunzipSync(Buffer.from(wire!.bytes, 'base64')).toString('utf8')).toBe(packed.jsonl)
|
|
176
|
+
expect(encodeFeedbackSlice(packFeedbackSlice(sandbox()))).toBeUndefined()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('honours an explicit since instead of the 24-hour default', () => {
|
|
180
|
+
const env = sandbox()
|
|
181
|
+
const now = Date.parse('2026-08-13T12:00:00.000Z')
|
|
182
|
+
writeLog(env, [
|
|
183
|
+
record({ ts: '2026-08-10T12:00:00.000Z', data: { which: 'old' } }),
|
|
184
|
+
record({ ts: '2026-08-13T11:00:00.000Z', data: { which: 'new' } }),
|
|
185
|
+
])
|
|
186
|
+
|
|
187
|
+
const result = packFeedbackSlice(env, {
|
|
188
|
+
now,
|
|
189
|
+
since: Date.parse('2026-08-10T00:00:00.000Z'),
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
expect(result.records.map((entry) => entry.data?.['which'])).toEqual(['old', 'new'])
|
|
193
|
+
expect(result.since).toBe(Date.parse('2026-08-10T00:00:00.000Z'))
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('packs records written by the logger and skips a damaged line', () => {
|
|
197
|
+
const env = sandbox()
|
|
198
|
+
const logger = createLogger({ env, now: () => Date.parse('2026-08-13T11:30:00.000Z') })
|
|
199
|
+
logger.info('cli.end', { which: 'first' })
|
|
200
|
+
appendFileSync(activeLogPath(env), 'not json at all\n')
|
|
201
|
+
createLogger({ env, now: () => Date.parse('2026-08-13T11:31:00.000Z') }).info('cli.end', {
|
|
202
|
+
which: 'second',
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
const result = packFeedbackSlice(env, { now: Date.parse('2026-08-13T12:00:00.000Z') })
|
|
206
|
+
|
|
207
|
+
expect(result.schema_version).toBe(LOG_SCHEMA_VERSION)
|
|
208
|
+
expect(result.record_count).toBe(2)
|
|
209
|
+
expect(result.records.map((entry) => entry.data?.['which'])).toEqual(['first', 'second'])
|
|
210
|
+
expect(result.truncated).toBe(false)
|
|
211
|
+
})
|
|
212
|
+
})
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Consented, bounded slice of the local log for a later feedback send.
|
|
3
|
+
*
|
|
4
|
+
* This module packs. It does not upload. A caller that wants a report on
|
|
5
|
+
* the wire asks here for the bytes and the consent copy, then decides
|
|
6
|
+
* whether to send them.
|
|
7
|
+
*/
|
|
8
|
+
import { gzipSync } from 'node:zlib'
|
|
9
|
+
import {
|
|
10
|
+
LOG_SCHEMA_VERSION,
|
|
11
|
+
readLogRecords,
|
|
12
|
+
type LogQuery,
|
|
13
|
+
type LogRecord,
|
|
14
|
+
} from './logging.js'
|
|
15
|
+
|
|
16
|
+
export const FEEDBACK_SLICE_UNCOMPRESSED_CAP = 256 * 1024
|
|
17
|
+
export const FEEDBACK_SLICE_COMPRESSED_CAP = 128 * 1024
|
|
18
|
+
export const FEEDBACK_SLICE_DEFAULT_WINDOW_MS = 24 * 60 * 60 * 1000
|
|
19
|
+
|
|
20
|
+
/** High enough that on-disk rotation, not this number, bounds a full-window read. */
|
|
21
|
+
const FULL_WINDOW_READ_LIMIT = 1_000_000
|
|
22
|
+
|
|
23
|
+
export interface PackFeedbackSliceOptions extends LogQuery {
|
|
24
|
+
/** Epoch ms. Defaults to now. Tests freeze the window with this. */
|
|
25
|
+
now?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PackedFeedbackSlice {
|
|
29
|
+
records: LogRecord[]
|
|
30
|
+
jsonl: string
|
|
31
|
+
gzip?: Buffer
|
|
32
|
+
uncompressed_bytes: number
|
|
33
|
+
compressed_bytes: number
|
|
34
|
+
record_count: number
|
|
35
|
+
truncated: boolean
|
|
36
|
+
since: number
|
|
37
|
+
until: number
|
|
38
|
+
schema_version: number
|
|
39
|
+
consentSummary: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface FeedbackSliceWire {
|
|
43
|
+
encoding: 'gzip+base64'
|
|
44
|
+
bytes: string
|
|
45
|
+
uncompressed_bytes: number
|
|
46
|
+
compressed_bytes: number
|
|
47
|
+
record_count: number
|
|
48
|
+
truncated: boolean
|
|
49
|
+
since: string
|
|
50
|
+
until: string
|
|
51
|
+
schema_version: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Wire form. Empty slices have nothing to encode. */
|
|
55
|
+
export function encodeFeedbackSlice(slice: PackedFeedbackSlice): FeedbackSliceWire | undefined {
|
|
56
|
+
if (slice.gzip === undefined) return undefined
|
|
57
|
+
return {
|
|
58
|
+
encoding: 'gzip+base64',
|
|
59
|
+
bytes: slice.gzip.toString('base64'),
|
|
60
|
+
uncompressed_bytes: slice.uncompressed_bytes,
|
|
61
|
+
compressed_bytes: slice.compressed_bytes,
|
|
62
|
+
record_count: slice.record_count,
|
|
63
|
+
truncated: slice.truncated,
|
|
64
|
+
since: new Date(slice.since).toISOString(),
|
|
65
|
+
until: new Date(slice.until).toISOString(),
|
|
66
|
+
schema_version: slice.schema_version,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function packFeedbackSlice(
|
|
71
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
72
|
+
options: PackFeedbackSliceOptions = {},
|
|
73
|
+
): PackedFeedbackSlice {
|
|
74
|
+
const { now, since: sinceOption, limit, ...filters } = options
|
|
75
|
+
const until = now ?? Date.now()
|
|
76
|
+
const since = sinceOption ?? until - FEEDBACK_SLICE_DEFAULT_WINDOW_MS
|
|
77
|
+
const { records } = readLogRecords(env, {
|
|
78
|
+
...filters,
|
|
79
|
+
since,
|
|
80
|
+
limit: limit ?? FULL_WINDOW_READ_LIMIT,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
if (records.length === 0) {
|
|
84
|
+
return emptySlice(since, until)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const packed = fitToCaps(records)
|
|
88
|
+
if (packed === null) {
|
|
89
|
+
return emptySlice(since, until)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
records: packed.records,
|
|
94
|
+
jsonl: packed.jsonl,
|
|
95
|
+
gzip: packed.gzip,
|
|
96
|
+
uncompressed_bytes: packed.uncompressedBytes,
|
|
97
|
+
compressed_bytes: packed.gzip.length,
|
|
98
|
+
record_count: packed.records.length,
|
|
99
|
+
truncated: packed.truncated,
|
|
100
|
+
since,
|
|
101
|
+
until,
|
|
102
|
+
schema_version: LOG_SCHEMA_VERSION,
|
|
103
|
+
consentSummary: consentSummary({
|
|
104
|
+
since,
|
|
105
|
+
until,
|
|
106
|
+
recordCount: packed.records.length,
|
|
107
|
+
uncompressedBytes: packed.uncompressedBytes,
|
|
108
|
+
compressedBytes: packed.gzip.length,
|
|
109
|
+
}),
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function fitToCaps(records: LogRecord[]): {
|
|
114
|
+
records: LogRecord[]
|
|
115
|
+
jsonl: string
|
|
116
|
+
gzip: Buffer
|
|
117
|
+
uncompressedBytes: number
|
|
118
|
+
truncated: boolean
|
|
119
|
+
} | null {
|
|
120
|
+
const lines = records.map((record) => JSON.stringify(record))
|
|
121
|
+
let start = 0
|
|
122
|
+
let truncated = false
|
|
123
|
+
while (start < lines.length) {
|
|
124
|
+
const jsonl = toJsonl(lines.slice(start))
|
|
125
|
+
const uncompressedBytes = Buffer.byteLength(jsonl)
|
|
126
|
+
if (uncompressedBytes > FEEDBACK_SLICE_UNCOMPRESSED_CAP) {
|
|
127
|
+
start += 1
|
|
128
|
+
truncated = true
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
const gzip = gzipSync(jsonl)
|
|
132
|
+
if (gzip.length <= FEEDBACK_SLICE_COMPRESSED_CAP) {
|
|
133
|
+
return {
|
|
134
|
+
records: records.slice(start),
|
|
135
|
+
jsonl,
|
|
136
|
+
gzip,
|
|
137
|
+
uncompressedBytes,
|
|
138
|
+
truncated,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
start += 1
|
|
142
|
+
truncated = true
|
|
143
|
+
}
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function emptySlice(since: number, until: number): PackedFeedbackSlice {
|
|
148
|
+
return {
|
|
149
|
+
records: [],
|
|
150
|
+
jsonl: '',
|
|
151
|
+
uncompressed_bytes: 0,
|
|
152
|
+
compressed_bytes: 0,
|
|
153
|
+
record_count: 0,
|
|
154
|
+
truncated: false,
|
|
155
|
+
since,
|
|
156
|
+
until,
|
|
157
|
+
schema_version: LOG_SCHEMA_VERSION,
|
|
158
|
+
consentSummary: consentSummary({
|
|
159
|
+
since,
|
|
160
|
+
until,
|
|
161
|
+
recordCount: 0,
|
|
162
|
+
uncompressedBytes: 0,
|
|
163
|
+
compressedBytes: 0,
|
|
164
|
+
}),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function toJsonl(lines: string[]): string {
|
|
169
|
+
if (lines.length === 0) return ''
|
|
170
|
+
return `${lines.join('\n')}\n`
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function consentSummary(input: {
|
|
174
|
+
since: number
|
|
175
|
+
until: number
|
|
176
|
+
recordCount: number
|
|
177
|
+
uncompressedBytes: number
|
|
178
|
+
compressedBytes: number
|
|
179
|
+
}): string {
|
|
180
|
+
const from = new Date(input.since).toISOString()
|
|
181
|
+
const to = new Date(input.until).toISOString()
|
|
182
|
+
return (
|
|
183
|
+
`About to send ${String(input.recordCount)} log records from ${from} to ${to} ` +
|
|
184
|
+
`(${String(input.uncompressedBytes)} bytes uncompressed, ${String(input.compressedBytes)} bytes compressed). ` +
|
|
185
|
+
'Credentials are already removed. Notification titles and the user\'s answers are in the log.'
|
|
186
|
+
)
|
|
187
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { refreshAfterMenuAction } from './readiness.js'
|
|
3
|
+
|
|
4
|
+
describe('refreshAfterMenuAction', () => {
|
|
5
|
+
it('does not re-assess after doctor, a test send, or a device list', () => {
|
|
6
|
+
expect(refreshAfterMenuAction('doctor', true)).toBeNull()
|
|
7
|
+
expect(refreshAfterMenuAction('test', true)).toBeNull()
|
|
8
|
+
expect(refreshAfterMenuAction('devices', true)).toBeNull()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('does not re-assess when the action changed nothing', () => {
|
|
12
|
+
expect(refreshAfterMenuAction('account', false)).toBeNull()
|
|
13
|
+
expect(refreshAfterMenuAction('settings', false)).toBeNull()
|
|
14
|
+
expect(refreshAfterMenuAction('routing', false)).toBeNull()
|
|
15
|
+
expect(refreshAfterMenuAction('setup', false)).toBeNull()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('re-probes the service only after an action that can change it', () => {
|
|
19
|
+
expect(refreshAfterMenuAction('setup', true)).toEqual(['local', 'remote'])
|
|
20
|
+
expect(refreshAfterMenuAction('account', true)).toEqual(['local', 'remote'])
|
|
21
|
+
expect(refreshAfterMenuAction('settings', true)).toEqual(['local'])
|
|
22
|
+
expect(refreshAfterMenuAction('routing', true)).toEqual(['local'])
|
|
23
|
+
expect(refreshAfterMenuAction('settings', true, { remote: true })).toEqual(['local', 'remote'])
|
|
24
|
+
})
|
|
25
|
+
})
|
package/src/readiness.ts
CHANGED
|
@@ -86,3 +86,41 @@ export function isReady(readiness: Readiness): boolean {
|
|
|
86
86
|
export function openItems(readiness: Readiness): ReadinessState[] {
|
|
87
87
|
return readiness.states.filter((s) => s.status === 'gap' || s.status === 'optional-gap')
|
|
88
88
|
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Work an assessment can do.
|
|
92
|
+
*
|
|
93
|
+
* `local` is this machine's files: project config, hook installs, skill lock
|
|
94
|
+
* files. `remote` is everything that leaves the process — the keychain, the
|
|
95
|
+
* service, the account, devices, and delivery evidence. The split exists so a
|
|
96
|
+
* redraw after a settings change does not pay for a health check.
|
|
97
|
+
*/
|
|
98
|
+
export type ReadinessRefresh = 'local' | 'remote'
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* After a menu action, which readiness work is worth doing.
|
|
102
|
+
*
|
|
103
|
+
* `null` means keep the assessment already in hand. Doctor is the report, not
|
|
104
|
+
* a mutation: the check it just ran is the redraw. A test send and a device
|
|
105
|
+
* list cannot change setup. Settings and hook wiring stay on this machine
|
|
106
|
+
* unless the caller says the service URL itself moved.
|
|
107
|
+
*/
|
|
108
|
+
export function refreshAfterMenuAction(
|
|
109
|
+
action: 'setup' | 'account' | 'test' | 'devices' | 'settings' | 'routing' | 'doctor',
|
|
110
|
+
changed: boolean,
|
|
111
|
+
options: { remote?: boolean } = {},
|
|
112
|
+
): readonly ReadinessRefresh[] | null {
|
|
113
|
+
switch (action) {
|
|
114
|
+
case 'doctor':
|
|
115
|
+
case 'test':
|
|
116
|
+
case 'devices':
|
|
117
|
+
return null
|
|
118
|
+
case 'setup':
|
|
119
|
+
case 'account':
|
|
120
|
+
return changed ? ['local', 'remote'] : null
|
|
121
|
+
case 'settings':
|
|
122
|
+
case 'routing':
|
|
123
|
+
if (!changed) return null
|
|
124
|
+
return options.remote === true ? ['local', 'remote'] : ['local']
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/release.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What this build is, according to the manifest npm actually shipped.
|
|
7
|
+
*
|
|
8
|
+
* Read from disk rather than compiled in, because a constant baked into
|
|
9
|
+
* `dist/` is only ever as fresh as the last build. A stale `dist/` sitting
|
|
10
|
+
* next to a newer `package.json` is a real failure mode — it produces a CLI
|
|
11
|
+
* that reports the new version perfectly while running the old code — and the
|
|
12
|
+
* only defence that survives it is refusing to carry a second copy of the
|
|
13
|
+
* number at all.
|
|
14
|
+
*
|
|
15
|
+
* The manifest sits one level above both `src/` and `dist/`, so a single
|
|
16
|
+
* relative path resolves in a source checkout and in a published install.
|
|
17
|
+
*
|
|
18
|
+
* Returns null rather than guessing: a build that cannot establish its own
|
|
19
|
+
* identity has to say so, so callers can degrade honestly instead of
|
|
20
|
+
* asserting something they do not know.
|
|
21
|
+
*/
|
|
22
|
+
export function packageVersion(): string | null {
|
|
23
|
+
try {
|
|
24
|
+
const manifest = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json')
|
|
25
|
+
const parsed: unknown = JSON.parse(readFileSync(manifest, 'utf8'))
|
|
26
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
27
|
+
const { version } = parsed as { version?: unknown }
|
|
28
|
+
if (typeof version === 'string' && version.length > 0) return version
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// Fall through: an unreadable manifest must not stop the CLI running.
|
|
32
|
+
}
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Where `npx skills add` fetches the agent skill from, as printed to users.
|
|
38
|
+
*
|
|
39
|
+
* Always an immutable tag naming this exact build. The skill and the CLI ship
|
|
40
|
+
* from one repository at one version, and the skill describes how to drive the
|
|
41
|
+
* CLI — so a moving ref would hand someone guidance for a tool they do not
|
|
42
|
+
* have. Deriving it from the version is what keeps the two from disagreeing;
|
|
43
|
+
* it used to be maintained by hand in three places, and the copies drifted.
|
|
44
|
+
*
|
|
45
|
+
* In the skills CLI grammar `owner/repo#ref` selects a Git ref, while
|
|
46
|
+
* `owner/repo@name` selects a skill — so the `#` here is load-bearing.
|
|
47
|
+
*/
|
|
48
|
+
export function skillsSource(): string | null {
|
|
49
|
+
const version = packageVersion()
|
|
50
|
+
return version === null ? null : `Raidiant-io/notifai#v${version}`
|
|
51
|
+
}
|