@rmrdeveloper/sideroom-pi 8.8.0 → 8.10.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/README.md +78 -14
- package/extensions/explain/guard.ts +22 -16
- package/extensions/explain/model.ts +58 -8
- package/extensions/guidelines/index.ts +12 -0
- package/extensions/guidelines/prompt.ts +1 -2
- package/extensions/guidelines/review.ts +115 -0
- package/extensions/jev/client.ts +137 -0
- package/extensions/jev/guard.ts +320 -0
- package/extensions/jev/index.ts +88 -0
- package/extensions/jev/key.ts +95 -0
- package/extensions/jev/model.ts +266 -0
- package/extensions/jev/ui.ts +282 -0
- package/extensions/persona/catalog.ts +8 -8
- package/extensions/persona/guard.ts +20 -16
- package/extensions/persona/index.ts +11 -9
- package/extensions/persona/model.ts +18 -2
- package/extensions/persona/prompt.ts +9 -0
- package/extensions/rules/guard.ts +1 -16
- package/extensions/shared/file-path.ts +26 -1
- package/extensions/shared/read-file.ts +18 -0
- package/extensions/shared/review-note.ts +14 -0
- package/extensions/shared/settle.ts +50 -0
- package/extensions/todo/guards.ts +2 -0
- package/package.json +3 -3
- package/skills/sideroom-persona/SKILL.md +18 -8
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ExtensionAPI,
|
|
3
|
+
type ExtensionContext,
|
|
4
|
+
isEditToolResult,
|
|
5
|
+
isToolCallEventType,
|
|
6
|
+
isWriteToolResult,
|
|
7
|
+
type ToolCallEvent,
|
|
8
|
+
} from '@earendil-works/pi-coding-agent';
|
|
9
|
+
import {
|
|
10
|
+
projectRelativePath,
|
|
11
|
+
resolveFileToolPath,
|
|
12
|
+
} from '../shared/file-path.ts';
|
|
13
|
+
import { readFileIfExists } from '../shared/read-file.ts';
|
|
14
|
+
import { REVIEW_NOTE_EVENT, type ReviewNote } from '../shared/review-note.ts';
|
|
15
|
+
import { startsUserTurn } from '../shared/settle.ts';
|
|
16
|
+
import {
|
|
17
|
+
askJev,
|
|
18
|
+
JEV_FAILURE,
|
|
19
|
+
type JevFailure,
|
|
20
|
+
type JevTransport,
|
|
21
|
+
} from './client.ts';
|
|
22
|
+
import {
|
|
23
|
+
type AddedLine,
|
|
24
|
+
addedLinesForEdit,
|
|
25
|
+
addedLinesForWrite,
|
|
26
|
+
buildFileState,
|
|
27
|
+
findingKey,
|
|
28
|
+
formatFindings,
|
|
29
|
+
isReviewablePath,
|
|
30
|
+
type JevFinding,
|
|
31
|
+
stateFitsBudget,
|
|
32
|
+
} from './model.ts';
|
|
33
|
+
|
|
34
|
+
export const JEV_TRANSIENT_FAILURE_LIMIT = 3;
|
|
35
|
+
|
|
36
|
+
const MUTATION_KIND = {
|
|
37
|
+
write: 'write',
|
|
38
|
+
edit: 'edit',
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
export const JEV_STATUS = {
|
|
42
|
+
ready: 'ready',
|
|
43
|
+
noKey: 'noKey',
|
|
44
|
+
quota: 'quota',
|
|
45
|
+
auth: 'auth',
|
|
46
|
+
rateLimited: 'rateLimited',
|
|
47
|
+
unavailable: 'unavailable',
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
export type JevStatus = (typeof JEV_STATUS)[keyof typeof JEV_STATUS];
|
|
51
|
+
|
|
52
|
+
interface MutationTarget {
|
|
53
|
+
readonly absolutePath: string;
|
|
54
|
+
readonly relativePath: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface PendingMutation extends MutationTarget {
|
|
58
|
+
readonly kind: string;
|
|
59
|
+
readonly lines: readonly AddedLine[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface JevGuardState {
|
|
63
|
+
halted: JevStatus | undefined;
|
|
64
|
+
transientFailures: number;
|
|
65
|
+
rateLimitedThisTurn: boolean;
|
|
66
|
+
lastModel: string | undefined;
|
|
67
|
+
calls: number;
|
|
68
|
+
readonly pending: Map<string, PendingMutation>;
|
|
69
|
+
readonly notedThisTurn: Set<string>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface JevGuardDependencies {
|
|
73
|
+
readonly apiKey: () => string | undefined;
|
|
74
|
+
readonly transport: JevTransport;
|
|
75
|
+
readonly report: (status: JevStatus, ctx: ExtensionContext) => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createJevGuardState(): JevGuardState {
|
|
79
|
+
return {
|
|
80
|
+
halted: undefined,
|
|
81
|
+
transientFailures: 0,
|
|
82
|
+
rateLimitedThisTurn: false,
|
|
83
|
+
lastModel: undefined,
|
|
84
|
+
calls: 0,
|
|
85
|
+
pending: new Map(),
|
|
86
|
+
notedThisTurn: new Set(),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The F10 screen calls this after the user stores a key or asks to retry.
|
|
91
|
+
export function resetJevGuard(state: JevGuardState): void {
|
|
92
|
+
state.halted = undefined;
|
|
93
|
+
state.transientFailures = 0;
|
|
94
|
+
state.rateLimitedThisTurn = false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Usage and per-turn memory belong to the active session: a new session starts
|
|
98
|
+
// counting from zero. The breaker stays, because it tracks the key.
|
|
99
|
+
export function resetJevSession(state: JevGuardState): void {
|
|
100
|
+
state.calls = 0;
|
|
101
|
+
state.rateLimitedThisTurn = false;
|
|
102
|
+
state.pending.clear();
|
|
103
|
+
state.notedThisTurn.clear();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function guardStatus(state: JevGuardState): JevStatus {
|
|
107
|
+
if (state.halted !== undefined) {
|
|
108
|
+
return state.halted;
|
|
109
|
+
}
|
|
110
|
+
if (state.rateLimitedThisTurn) {
|
|
111
|
+
return JEV_STATUS.rateLimited;
|
|
112
|
+
}
|
|
113
|
+
return state.transientFailures > 0
|
|
114
|
+
? JEV_STATUS.unavailable
|
|
115
|
+
: JEV_STATUS.ready;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function shouldCallJev(state: JevGuardState): boolean {
|
|
119
|
+
return (
|
|
120
|
+
state.halted === undefined &&
|
|
121
|
+
!state.rateLimitedThisTurn &&
|
|
122
|
+
state.transientFailures < JEV_TRANSIENT_FAILURE_LIMIT
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function registerJevGuard(
|
|
127
|
+
pi: ExtensionAPI,
|
|
128
|
+
state: JevGuardState,
|
|
129
|
+
dependencies: JevGuardDependencies,
|
|
130
|
+
): void {
|
|
131
|
+
pi.on('input', (event) => {
|
|
132
|
+
if (!startsUserTurn(event)) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
state.notedThisTurn.clear();
|
|
136
|
+
state.rateLimitedThisTurn = false;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// A call another extension blocks in tool_call never reaches tool_result,
|
|
140
|
+
// and every result of a turn lands before its turn_end.
|
|
141
|
+
pi.on('turn_end', () => {
|
|
142
|
+
state.pending.clear();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// The added lines are read before the mutation lands, because after a write
|
|
146
|
+
// the previous content is gone. The body is read after, from disk.
|
|
147
|
+
pi.on('tool_call', (event, ctx) => {
|
|
148
|
+
const mutation = extractMutation(event, ctx.cwd);
|
|
149
|
+
if (mutation === undefined || !isWorthAsking(mutation)) {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
state.pending.set(event.toolCallId, mutation);
|
|
153
|
+
return undefined;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
pi.on('tool_result', async (event, ctx) => {
|
|
157
|
+
const pending = state.pending.get(event.toolCallId);
|
|
158
|
+
state.pending.delete(event.toolCallId);
|
|
159
|
+
if (pending === undefined || event.isError) {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
if (!isWriteToolResult(event) && !isEditToolResult(event)) {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const apiKey = dependencies.apiKey();
|
|
167
|
+
if (apiKey === undefined) {
|
|
168
|
+
dependencies.report(JEV_STATUS.noKey, ctx);
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
if (!shouldCallJev(state)) {
|
|
172
|
+
dependencies.report(guardStatus(state), ctx);
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const body = readFileIfExists(pending.absolutePath);
|
|
177
|
+
if (body === undefined) {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
const fileState = buildFileState(
|
|
181
|
+
pending.relativePath,
|
|
182
|
+
body,
|
|
183
|
+
pending.kind,
|
|
184
|
+
pending.lines,
|
|
185
|
+
);
|
|
186
|
+
if (!stateFitsBudget(fileState) || ctx.signal?.aborted === true) {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
state.calls += 1;
|
|
191
|
+
const attempt = await askJev(
|
|
192
|
+
dependencies.transport,
|
|
193
|
+
apiKey,
|
|
194
|
+
fileState,
|
|
195
|
+
ctx.signal,
|
|
196
|
+
);
|
|
197
|
+
if (attempt.failure !== undefined) {
|
|
198
|
+
registerFailure(state, attempt.failure);
|
|
199
|
+
dependencies.report(guardStatus(state), ctx);
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
registerSuccess(state);
|
|
204
|
+
if (attempt.model !== undefined) {
|
|
205
|
+
state.lastModel = attempt.model;
|
|
206
|
+
}
|
|
207
|
+
dependencies.report(JEV_STATUS.ready, ctx);
|
|
208
|
+
|
|
209
|
+
const findings = attempt.findings ?? [];
|
|
210
|
+
const fresh = findings.filter((finding) =>
|
|
211
|
+
isUnnoted(state, pending.relativePath, finding),
|
|
212
|
+
);
|
|
213
|
+
if (fresh.length === 0) {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
markNoted(state, pending.relativePath, fresh);
|
|
217
|
+
const note: ReviewNote = {
|
|
218
|
+
text: formatFindings(pending.relativePath, fresh),
|
|
219
|
+
};
|
|
220
|
+
pi.events.emit(REVIEW_NOTE_EVENT, note);
|
|
221
|
+
return undefined;
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function isUnnoted(
|
|
226
|
+
state: JevGuardState,
|
|
227
|
+
path: string,
|
|
228
|
+
finding: JevFinding,
|
|
229
|
+
): boolean {
|
|
230
|
+
return !state.notedThisTurn.has(findingKey(path, finding.ruleId));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function markNoted(
|
|
234
|
+
state: JevGuardState,
|
|
235
|
+
path: string,
|
|
236
|
+
findings: readonly JevFinding[],
|
|
237
|
+
): void {
|
|
238
|
+
for (const finding of findings) {
|
|
239
|
+
state.notedThisTurn.add(findingKey(path, finding.ruleId));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// A user abort says nothing about the service, and a rate limit clears on its
|
|
244
|
+
// own, so neither may count toward switching Jev off.
|
|
245
|
+
function registerFailure(state: JevGuardState, failure: JevFailure): void {
|
|
246
|
+
if (failure === JEV_FAILURE.cancelled) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (failure === JEV_FAILURE.rateLimited) {
|
|
250
|
+
state.rateLimitedThisTurn = true;
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (failure === JEV_FAILURE.quota) {
|
|
254
|
+
state.halted = JEV_STATUS.quota;
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (failure === JEV_FAILURE.auth) {
|
|
258
|
+
state.halted = JEV_STATUS.auth;
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
state.transientFailures += 1;
|
|
262
|
+
if (state.transientFailures >= JEV_TRANSIENT_FAILURE_LIMIT) {
|
|
263
|
+
state.halted = JEV_STATUS.unavailable;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function registerSuccess(state: JevGuardState): void {
|
|
268
|
+
state.halted = undefined;
|
|
269
|
+
state.transientFailures = 0;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// A deletion-only edit leaves nothing for the six questions to read, and only
|
|
273
|
+
// the project-relative path of a supported language may leave the machine.
|
|
274
|
+
function isWorthAsking(mutation: PendingMutation): boolean {
|
|
275
|
+
return mutation.lines.length > 0 && isReviewablePath(mutation.relativePath);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function extractMutation(
|
|
279
|
+
event: ToolCallEvent,
|
|
280
|
+
cwd: string,
|
|
281
|
+
): PendingMutation | undefined {
|
|
282
|
+
if (isToolCallEventType('write', event)) {
|
|
283
|
+
const target = resolveTarget(cwd, event.input.path);
|
|
284
|
+
if (target === undefined) {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
...target,
|
|
289
|
+
kind: MUTATION_KIND.write,
|
|
290
|
+
lines: addedLinesForWrite(
|
|
291
|
+
readFileIfExists(target.absolutePath),
|
|
292
|
+
event.input.content,
|
|
293
|
+
),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
if (isToolCallEventType('edit', event)) {
|
|
297
|
+
const target = resolveTarget(cwd, event.input.path);
|
|
298
|
+
if (target === undefined) {
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
...target,
|
|
303
|
+
kind: MUTATION_KIND.edit,
|
|
304
|
+
lines: addedLinesForEdit(event.input.edits),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function resolveTarget(cwd: string, path: string): MutationTarget | undefined {
|
|
311
|
+
const absolutePath = resolveFileToolPath(cwd, path);
|
|
312
|
+
if (absolutePath === undefined) {
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
const relativePath = projectRelativePath(cwd, absolutePath);
|
|
316
|
+
if (relativePath === undefined) {
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
return { absolutePath, relativePath };
|
|
320
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ExtensionAPI,
|
|
3
|
+
type ExtensionContext,
|
|
4
|
+
getAgentDir,
|
|
5
|
+
} from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import { createFetchTransport } from './client.ts';
|
|
7
|
+
import {
|
|
8
|
+
createJevGuardState,
|
|
9
|
+
guardStatus,
|
|
10
|
+
JEV_STATUS,
|
|
11
|
+
type JevStatus,
|
|
12
|
+
registerJevGuard,
|
|
13
|
+
resetJevGuard,
|
|
14
|
+
resetJevSession,
|
|
15
|
+
} from './guard.ts';
|
|
16
|
+
import {
|
|
17
|
+
clearStoredKey,
|
|
18
|
+
configPathFor,
|
|
19
|
+
resolveApiKey,
|
|
20
|
+
storeApiKey,
|
|
21
|
+
} from './key.ts';
|
|
22
|
+
import {
|
|
23
|
+
createJevKeyController,
|
|
24
|
+
JEV_SHORTCUT,
|
|
25
|
+
JEV_STATUS_KEY,
|
|
26
|
+
statusLabel,
|
|
27
|
+
} from './ui.ts';
|
|
28
|
+
|
|
29
|
+
// Pi loads extensions/*/index.ts through export default.
|
|
30
|
+
export default function registerJev(pi: ExtensionAPI): void {
|
|
31
|
+
const configPath = configPathFor(getAgentDir());
|
|
32
|
+
const guardState = createJevGuardState();
|
|
33
|
+
|
|
34
|
+
// The key is read on each use instead of cached at load time, so a key stored
|
|
35
|
+
// during a running session takes effect without a restart.
|
|
36
|
+
const readKey = () => resolveApiKey(process.env, configPath);
|
|
37
|
+
|
|
38
|
+
const currentStatus = (): JevStatus => {
|
|
39
|
+
if (readKey() === undefined) {
|
|
40
|
+
return JEV_STATUS.noKey;
|
|
41
|
+
}
|
|
42
|
+
return guardStatus(guardState);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const publish = (status: JevStatus, ctx: ExtensionContext): void => {
|
|
46
|
+
ctx.ui.setStatus(
|
|
47
|
+
JEV_STATUS_KEY,
|
|
48
|
+
statusLabel(status, guardState.lastModel, guardState.calls),
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const keyController = createJevKeyController({
|
|
53
|
+
store: (apiKey) => {
|
|
54
|
+
storeApiKey(configPath, apiKey);
|
|
55
|
+
},
|
|
56
|
+
clear: () => {
|
|
57
|
+
clearStoredKey(configPath);
|
|
58
|
+
},
|
|
59
|
+
retry: () => {
|
|
60
|
+
resetJevGuard(guardState);
|
|
61
|
+
},
|
|
62
|
+
origin: () => readKey()?.origin,
|
|
63
|
+
usage: () => guardState.calls,
|
|
64
|
+
refresh: (ctx) => {
|
|
65
|
+
publish(currentStatus(), ctx);
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
registerJevGuard(pi, guardState, {
|
|
70
|
+
apiKey: () => readKey()?.key,
|
|
71
|
+
transport: createFetchTransport(),
|
|
72
|
+
report: (status, ctx) => {
|
|
73
|
+
publish(status, ctx);
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
pi.on('session_start', (_event, ctx) => {
|
|
78
|
+
resetJevSession(guardState);
|
|
79
|
+
publish(currentStatus(), ctx);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
pi.registerShortcut(JEV_SHORTCUT, {
|
|
83
|
+
description: 'Set or clear the Jev API key',
|
|
84
|
+
handler: (ctx) => {
|
|
85
|
+
keyController.open(ctx);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const JEV_KEY_ENV_VAR = 'TYPESAFE_API_KEY';
|
|
5
|
+
export const SIDEROOM_CONFIG_FILE = 'sideroom.json';
|
|
6
|
+
|
|
7
|
+
const JEV_API_KEY_FIELD = 'jevApiKey';
|
|
8
|
+
const OWNER_ONLY_FILE_MODE = 0o600;
|
|
9
|
+
const OWNER_ONLY_DIRECTORY_MODE = 0o700;
|
|
10
|
+
|
|
11
|
+
export interface JevKeySource {
|
|
12
|
+
readonly key: string;
|
|
13
|
+
readonly origin: 'environment' | 'config';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function configPathFor(agentDirectory: string): string {
|
|
17
|
+
return join(agentDirectory, SIDEROOM_CONFIG_FILE);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// The environment wins so containers and CI work with no file at all. A missing
|
|
21
|
+
// or malformed config means "no key"; anything else about the file is a real
|
|
22
|
+
// problem and surfaces instead of hiding.
|
|
23
|
+
export function resolveApiKey(
|
|
24
|
+
environment: Readonly<Record<string, string | undefined>>,
|
|
25
|
+
configPath: string,
|
|
26
|
+
): JevKeySource | undefined {
|
|
27
|
+
const fromEnvironment = environment[JEV_KEY_ENV_VAR]?.trim();
|
|
28
|
+
if (fromEnvironment !== undefined && fromEnvironment.length > 0) {
|
|
29
|
+
return { key: fromEnvironment, origin: 'environment' };
|
|
30
|
+
}
|
|
31
|
+
const stored = readStoredKey(configPath);
|
|
32
|
+
if (stored === undefined) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
return { key: stored, origin: 'config' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function readStoredKey(configPath: string): string | undefined {
|
|
39
|
+
let raw: string;
|
|
40
|
+
try {
|
|
41
|
+
raw = readFileSync(configPath, 'utf8');
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (isMissingFile(error)) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const parsed = parseConfig(raw);
|
|
50
|
+
if (parsed === undefined) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
const stored = parsed[JEV_API_KEY_FIELD];
|
|
54
|
+
if (typeof stored !== 'string') {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
const trimmed = stored.trim();
|
|
58
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function storeApiKey(configPath: string, apiKey: string): void {
|
|
62
|
+
mkdirSync(dirname(configPath), {
|
|
63
|
+
recursive: true,
|
|
64
|
+
mode: OWNER_ONLY_DIRECTORY_MODE,
|
|
65
|
+
});
|
|
66
|
+
const payload = `${JSON.stringify({ [JEV_API_KEY_FIELD]: apiKey }, null, 2)}\n`;
|
|
67
|
+
writeFileSync(configPath, payload, {
|
|
68
|
+
encoding: 'utf8',
|
|
69
|
+
mode: OWNER_ONLY_FILE_MODE,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function clearStoredKey(configPath: string): void {
|
|
74
|
+
rmSync(configPath, { force: true });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseConfig(raw: string): Record<string, unknown> | undefined {
|
|
78
|
+
let parsed: unknown;
|
|
79
|
+
try {
|
|
80
|
+
parsed = JSON.parse(raw);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error instanceof SyntaxError) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
return parsed as Record<string, unknown>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isMissingFile(error: unknown): boolean {
|
|
94
|
+
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
95
|
+
}
|