mini-coder 0.7.4 → 0.8.1
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/AGENTS.md +114 -0
- package/README.md +53 -66
- package/bin/mini-coder.ts +2 -0
- package/demo.gif +0 -0
- package/package.json +17 -20
- package/src/agent.ts +193 -272
- package/src/auth.ts +84 -0
- package/src/cli.ts +99 -0
- package/src/config.ts +179 -0
- package/src/prompt.ts +54 -207
- package/src/session.ts +124 -69
- package/src/tools/bash.ts +86 -0
- package/src/tools/common.ts +32 -0
- package/src/tools/edit.ts +41 -0
- package/src/tools/index.ts +47 -0
- package/src/tools/read.ts +64 -0
- package/src/tui/commands.ts +199 -0
- package/src/tui/complete.ts +85 -0
- package/src/tui/editor.ts +320 -0
- package/src/tui/highlight.ts +189 -0
- package/src/tui/stream.ts +142 -0
- package/src/tui/styles.ts +20 -0
- package/src/tui/term.ts +436 -0
- package/src/tui/theme.ts +120 -0
- package/src/tui/tui.ts +758 -0
- package/src/tui/usage.ts +67 -0
- package/tsconfig.json +8 -8
- package/bin/mc.ts +0 -11
- package/bun.lock +0 -350
- package/nono-mini-coder.json +0 -42
- package/src/args.ts +0 -252
- package/src/error-handling.test.ts +0 -163
- package/src/git.ts +0 -23
- package/src/headless.ts +0 -66
- package/src/index.ts +0 -43
- package/src/models.ts +0 -191
- package/src/oauth.ts +0 -147
- package/src/shared.ts +0 -119
- package/src/themes.ts +0 -234
- package/src/tool-bash.ts +0 -77
- package/src/tool-edit.ts +0 -121
- package/src/tool-read.ts +0 -100
- package/src/tui-components.ts +0 -127
- package/src/tui-conversation.ts +0 -218
- package/src/tui-editor.ts +0 -29
- package/src/tui-overlay.ts +0 -604
- package/src/tui.ts +0 -314
- package/src/types.ts +0 -194
- package/src/update.ts +0 -171
package/src/agent.ts
CHANGED
|
@@ -1,302 +1,223 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
Message,
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
import {
|
|
2
|
+
type Api,
|
|
3
|
+
type AssistantMessage,
|
|
4
|
+
type JsonObject,
|
|
5
|
+
type Message,
|
|
6
|
+
type Model,
|
|
7
|
+
type ModelThinkingLevel,
|
|
8
|
+
type Models,
|
|
9
|
+
type Tool,
|
|
10
|
+
type ToolCall,
|
|
11
|
+
type ToolResultMessage,
|
|
12
|
+
type UserMessage,
|
|
9
13
|
} from "@earendil-works/pi-ai";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import type
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
14
|
+
import type { Session } from "./session.ts";
|
|
15
|
+
import type { ToolName } from "./config.ts";
|
|
16
|
+
import { acceptsImages, executeTool, type ToolDetails, type ToolResult } from "./tools/index.ts";
|
|
17
|
+
|
|
18
|
+
export type Phase = "preparing" | "waitingModel" | "streaming" | "runningTool" | "pausing" | "idle";
|
|
19
|
+
|
|
20
|
+
export type AgentEvent =
|
|
21
|
+
| { type: "phase"; phase: Phase; detail?: string }
|
|
22
|
+
| { type: "text"; delta: string }
|
|
23
|
+
| { type: "reasoning"; delta: string }
|
|
24
|
+
| { type: "toolCallStart"; name: string }
|
|
25
|
+
| { type: "toolCall"; name: string; arguments: JsonObject }
|
|
26
|
+
| { type: "toolOutput"; chunk: string }
|
|
27
|
+
| { type: "toolResult"; name: string; text: string; isError: boolean }
|
|
28
|
+
| { type: "message"; message: AssistantMessage }
|
|
29
|
+
| { type: "error"; message: string }
|
|
30
|
+
| { type: "cancelled" }
|
|
31
|
+
| { type: "complete" };
|
|
32
|
+
|
|
33
|
+
interface Interaction {
|
|
34
|
+
isPauseRequested(): boolean;
|
|
35
|
+
clearPause(): void;
|
|
36
|
+
requestSteering(): Promise<string>;
|
|
37
|
+
}
|
|
28
38
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
39
|
+
export const NO_INTERACTION: Interaction = {
|
|
40
|
+
isPauseRequested: () => false,
|
|
41
|
+
clearPause: () => {},
|
|
42
|
+
requestSteering: async () => "",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** What one turn needs, shared by both projections: the TUI and `--print`. */
|
|
46
|
+
export interface AgentOptions {
|
|
47
|
+
models: Models;
|
|
48
|
+
model: Model<Api>;
|
|
49
|
+
systemPrompt: string;
|
|
50
|
+
tools: Tool[];
|
|
51
|
+
/** The configured tool names, kept so a model switch can rebuild `tools`. */
|
|
52
|
+
toolNames: ToolName[];
|
|
53
|
+
thinkingEffort: ModelThinkingLevel;
|
|
54
|
+
session: Session;
|
|
55
|
+
}
|
|
32
56
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
57
|
+
interface AgentRun extends AgentOptions {
|
|
58
|
+
messages: Message[];
|
|
59
|
+
signal: AbortSignal;
|
|
60
|
+
interaction: Interaction;
|
|
61
|
+
onEvent: (event: AgentEvent) => void;
|
|
62
|
+
}
|
|
39
63
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
64
|
+
/** The concatenated text blocks of an assistant message, ignoring the rest. */
|
|
65
|
+
export function assistantText(message: AssistantMessage): string {
|
|
66
|
+
return message.content
|
|
67
|
+
.filter((block) => block.type === "text")
|
|
68
|
+
.map((block) => block.text)
|
|
69
|
+
.join("");
|
|
70
|
+
}
|
|
43
71
|
|
|
44
|
-
|
|
45
|
-
|
|
72
|
+
export async function runAgentTurn(run: AgentRun): Promise<void> {
|
|
73
|
+
const { messages, session, signal, interaction, onEvent } = run;
|
|
46
74
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
content.length === 1 &&
|
|
50
|
-
content[0].type === "text" &&
|
|
51
|
-
content[0].text.startsWith("Old environment output:")
|
|
52
|
-
) {
|
|
53
|
-
continue;
|
|
54
|
-
}
|
|
55
|
-
toolResultIndices.push(i);
|
|
56
|
-
}
|
|
75
|
+
const cancelled = (): void => onEvent({ type: "cancelled" });
|
|
76
|
+
const failed = (message: string): void => onEvent({ type: "error", message });
|
|
57
77
|
|
|
58
|
-
|
|
78
|
+
/** Appends a user message the model reads at the next step boundary. */
|
|
79
|
+
const steer = (content: string): void => {
|
|
80
|
+
if (content === "") return;
|
|
81
|
+
const message: UserMessage = { role: "user", content, timestamp: Date.now() };
|
|
82
|
+
messages.push(message);
|
|
83
|
+
session.appendMessage(message);
|
|
84
|
+
};
|
|
59
85
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Returns the steering typed while paused, or ""; the caller decides where
|
|
88
|
+
* it lands, because a user message between an assistant turn and its tool
|
|
89
|
+
* results is rejected by the provider. Null once the turn has been aborted;
|
|
90
|
+
* every step boundary is a cancellation point.
|
|
91
|
+
*/
|
|
92
|
+
const pauseStep = async (): Promise<string | null> => {
|
|
93
|
+
if (signal.aborted) return null;
|
|
94
|
+
if (interaction.isPauseRequested()) {
|
|
95
|
+
interaction.clearPause();
|
|
96
|
+
onEvent({ type: "phase", phase: "pausing" });
|
|
97
|
+
const steering = await interaction.requestSteering();
|
|
98
|
+
onEvent({ type: "phase", phase: "idle" });
|
|
99
|
+
if (signal.aborted) return null;
|
|
100
|
+
return steering;
|
|
75
101
|
}
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export async function* streamAgent(
|
|
81
|
-
agentCtx: AgentContex,
|
|
82
|
-
): AsyncGenerator<AgentEvent> {
|
|
83
|
-
const llmCtx: Context = {
|
|
84
|
-
systemPrompt: agentCtx.systemPrompt,
|
|
85
|
-
tools: agentCtx.tools.map((t) => t.tool),
|
|
86
|
-
messages: agentCtx.messages,
|
|
102
|
+
return "";
|
|
87
103
|
};
|
|
88
104
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
105
|
+
for (;;) {
|
|
106
|
+
const steering = await pauseStep();
|
|
107
|
+
if (steering === null) {
|
|
108
|
+
cancelled();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
steer(steering);
|
|
112
|
+
|
|
113
|
+
onEvent({ type: "phase", phase: "preparing" });
|
|
114
|
+
session.appendRequest({
|
|
115
|
+
provider: run.model.provider,
|
|
116
|
+
model: run.model.id,
|
|
117
|
+
api: run.model.api,
|
|
118
|
+
thinkingEffort: run.thinkingEffort,
|
|
119
|
+
systemPrompt: run.systemPrompt,
|
|
120
|
+
tools: run.tools,
|
|
100
121
|
});
|
|
101
122
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
case "text_delta":
|
|
116
|
-
case "text_end":
|
|
117
|
-
case "thinking_start":
|
|
118
|
-
case "thinking_delta":
|
|
119
|
-
case "thinking_end":
|
|
120
|
-
case "toolcall_start":
|
|
121
|
-
case "toolcall_delta":
|
|
122
|
-
case "toolcall_end": {
|
|
123
|
-
if (partial) {
|
|
124
|
-
partial = e.partial;
|
|
125
|
-
llmCtx.messages[llmCtx.messages.length - 1] = partial;
|
|
126
|
-
yield { type: "message_update", partial };
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
break;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
case "error": {
|
|
133
|
-
const finalMessage = await s.result();
|
|
134
|
-
if (!finalMessage) throw new Error(e.error.errorMessage);
|
|
135
|
-
|
|
136
|
-
if (added) {
|
|
137
|
-
llmCtx.messages[llmCtx.messages.length - 1] = finalMessage;
|
|
138
|
-
} else {
|
|
139
|
-
llmCtx.messages.push(finalMessage);
|
|
140
|
-
yield { type: "message_start", partial: { ...finalMessage } };
|
|
141
|
-
}
|
|
123
|
+
onEvent({ type: "phase", phase: "waitingModel" });
|
|
124
|
+
const context = { systemPrompt: run.systemPrompt, tools: run.tools, messages };
|
|
125
|
+
let stream;
|
|
126
|
+
try {
|
|
127
|
+
stream = run.models.streamSimple(run.model, context, {
|
|
128
|
+
reasoning: run.thinkingEffort === "off" ? undefined : run.thinkingEffort,
|
|
129
|
+
signal,
|
|
130
|
+
sessionId: session.id ?? undefined,
|
|
131
|
+
});
|
|
132
|
+
} catch (error) {
|
|
133
|
+
failed((error as Error).message);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
142
136
|
|
|
143
|
-
|
|
144
|
-
|
|
137
|
+
onEvent({ type: "phase", phase: "streaming" });
|
|
138
|
+
try {
|
|
139
|
+
for await (const event of stream) {
|
|
140
|
+
if (event.type === "text_delta") onEvent({ type: "text", delta: event.delta });
|
|
141
|
+
else if (event.type === "thinking_delta") onEvent({ type: "reasoning", delta: event.delta });
|
|
142
|
+
// The tool's name is known as soon as the call starts being written.
|
|
143
|
+
else if (event.type === "toolcall_start") {
|
|
144
|
+
const block = event.partial.content[event.contentIndex];
|
|
145
|
+
if (block.type === "toolCall") onEvent({ type: "toolCallStart", name: block.name });
|
|
146
|
+
} else if (event.type === "toolcall_end") {
|
|
147
|
+
onEvent({
|
|
148
|
+
type: "toolCall",
|
|
149
|
+
name: event.toolCall.name,
|
|
150
|
+
arguments: event.toolCall.arguments,
|
|
151
|
+
});
|
|
145
152
|
}
|
|
146
153
|
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
failed((error as Error).message);
|
|
156
|
+
return;
|
|
147
157
|
}
|
|
148
158
|
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
llmCtx.messages[llmCtx.messages.length - 1] = message;
|
|
154
|
-
} else {
|
|
155
|
-
llmCtx.messages.push(message);
|
|
156
|
-
yield { type: "message_start", partial: { ...message } };
|
|
157
|
-
}
|
|
158
|
-
yield { type: "message_end", message };
|
|
159
|
-
|
|
160
|
-
const toolCalls = message.content.filter(
|
|
161
|
-
(c): c is ToolCall => c.type === "toolCall",
|
|
162
|
-
);
|
|
159
|
+
const assistant = await stream.result();
|
|
160
|
+
messages.push(assistant);
|
|
161
|
+
session.appendMessage(assistant);
|
|
162
|
+
onEvent({ type: "message", message: assistant });
|
|
163
163
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
164
|
+
if (assistant.stopReason === "aborted") {
|
|
165
|
+
cancelled();
|
|
166
|
+
return;
|
|
167
167
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const ts = toolRunner(toolCalls, agentCtx.tools, agentCtx.signal);
|
|
172
|
-
|
|
173
|
-
for await (const e of ts) {
|
|
174
|
-
if (e.type === "tool_update") {
|
|
175
|
-
// Update contex with update or add new.
|
|
176
|
-
const existing = seenToolIds.get(e.partial.toolCallId);
|
|
177
|
-
if (existing && existing >= 0) {
|
|
178
|
-
const m = llmCtx.messages[existing];
|
|
179
|
-
if (m.role === "toolResult") {
|
|
180
|
-
llmCtx.messages[existing] = {
|
|
181
|
-
...m,
|
|
182
|
-
content: [...m.content, ...e.partial.content],
|
|
183
|
-
isError: e.partial.isError,
|
|
184
|
-
};
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
yield {
|
|
188
|
-
type: "tool_message_update",
|
|
189
|
-
partial: e.partial,
|
|
190
|
-
};
|
|
191
|
-
} else {
|
|
192
|
-
llmCtx.messages.push(e.partial);
|
|
193
|
-
seenToolIds.set(e.partial.toolCallId, llmCtx.messages.length - 1);
|
|
194
|
-
|
|
195
|
-
yield {
|
|
196
|
-
type: "tool_message_start",
|
|
197
|
-
partial: e.partial,
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
} else if (e.type === "tool_result") {
|
|
201
|
-
// Update context with full message and yield
|
|
202
|
-
const idx = llmCtx.messages.findIndex(
|
|
203
|
-
(m) =>
|
|
204
|
-
m.role === "toolResult" && m.toolCallId === e.message.toolCallId,
|
|
205
|
-
);
|
|
206
|
-
if (idx >= 0) {
|
|
207
|
-
llmCtx.messages[idx] = e.message;
|
|
208
|
-
} else {
|
|
209
|
-
llmCtx.messages.push(e.message);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
yield {
|
|
213
|
-
type: "tool_message_end",
|
|
214
|
-
message: e.message,
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
}
|
|
168
|
+
if (assistant.stopReason === "error") {
|
|
169
|
+
failed(assistant.errorMessage ?? "provider error");
|
|
170
|
+
return;
|
|
218
171
|
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// This should stay stateless, don't accumulate anything at this layer, just proxy
|
|
223
|
-
// wrapped runners events.
|
|
224
|
-
async function* toolRunner(
|
|
225
|
-
toolCalls: ToolCall[],
|
|
226
|
-
tools: ToolAndRunner[],
|
|
227
|
-
signal?: AbortSignal,
|
|
228
|
-
): AsyncGenerator<AgentToolEvent> {
|
|
229
|
-
for (const call of toolCalls) {
|
|
230
|
-
const timestamp = Date.now();
|
|
231
|
-
const tool = tools.find((t) => t.tool.name === call.name);
|
|
232
172
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
role: "toolResult",
|
|
238
|
-
toolCallId: call.id,
|
|
239
|
-
toolName: call.name,
|
|
240
|
-
content: [{ type: "text", text: "Error: Tool not found" }],
|
|
241
|
-
isError: true,
|
|
242
|
-
timestamp,
|
|
243
|
-
},
|
|
244
|
-
};
|
|
245
|
-
continue;
|
|
173
|
+
const toolCalls = assistant.content.filter((block): block is ToolCall => block.type === "toolCall");
|
|
174
|
+
if (toolCalls.length === 0) {
|
|
175
|
+
onEvent({ type: "complete" });
|
|
176
|
+
return;
|
|
246
177
|
}
|
|
247
178
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
type: "tool_result",
|
|
275
|
-
message: {
|
|
276
|
-
role: "toolResult",
|
|
277
|
-
toolCallId: call.id,
|
|
278
|
-
toolName: call.name,
|
|
279
|
-
content: img ? [content, img] : [content],
|
|
280
|
-
isError: false,
|
|
281
|
-
timestamp,
|
|
282
|
-
},
|
|
283
|
-
};
|
|
179
|
+
// Steering is held until every result of this assistant turn has landed:
|
|
180
|
+
// the provider rejects a user message between an assistant turn and its
|
|
181
|
+
// tool results.
|
|
182
|
+
const held: string[] = [];
|
|
183
|
+
for (const call of toolCalls) {
|
|
184
|
+
const steering = await pauseStep();
|
|
185
|
+
if (steering === null) {
|
|
186
|
+
cancelled();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (steering !== "") held.push(steering);
|
|
190
|
+
onEvent({ type: "phase", phase: "runningTool", detail: call.name });
|
|
191
|
+
|
|
192
|
+
const tool = run.tools.find((candidate) => candidate.name === call.name);
|
|
193
|
+
let result: ToolResult;
|
|
194
|
+
if (tool === undefined) {
|
|
195
|
+
result = { text: `unknown tool: ${call.name}`, isError: true };
|
|
196
|
+
} else {
|
|
197
|
+
try {
|
|
198
|
+
result = await executeTool(call, {
|
|
199
|
+
signal,
|
|
200
|
+
supportsImages: acceptsImages(run.model),
|
|
201
|
+
onOutput: (chunk) => onEvent({ type: "toolOutput", chunk }),
|
|
202
|
+
});
|
|
203
|
+
} catch (error) {
|
|
204
|
+
result = { text: (error as Error).message, isError: true };
|
|
284
205
|
}
|
|
285
206
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
content: [{ type: "text", text: `Error: ${error}` }],
|
|
296
|
-
isError: true,
|
|
297
|
-
timestamp,
|
|
298
|
-
},
|
|
207
|
+
|
|
208
|
+
const toolMessage: ToolResultMessage<ToolDetails> = {
|
|
209
|
+
role: "toolResult",
|
|
210
|
+
toolCallId: call.id,
|
|
211
|
+
toolName: call.name,
|
|
212
|
+
content: [{ type: "text", text: result.text }, ...(result.images ?? [])],
|
|
213
|
+
details: result.details,
|
|
214
|
+
isError: result.isError,
|
|
215
|
+
timestamp: Date.now(),
|
|
299
216
|
};
|
|
217
|
+
messages.push(toolMessage);
|
|
218
|
+
session.appendMessage(toolMessage);
|
|
219
|
+
onEvent({ type: "toolResult", name: call.name, text: result.text, isError: result.isError });
|
|
300
220
|
}
|
|
221
|
+
steer(held.join("\n"));
|
|
301
222
|
}
|
|
302
223
|
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import type { AuthOperationOptions, Credential, CredentialInfo, CredentialStore } from "@earendil-works/pi-ai";
|
|
4
|
+
|
|
5
|
+
/** The `auth.json` layout: one credential per provider id. */
|
|
6
|
+
type AuthFile = Record<string, Credential>;
|
|
7
|
+
|
|
8
|
+
function readFile(path: string): AuthFile {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(readFileSync(path, "utf8")) as AuthFile;
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Write the whole file atomically: temp file, then rename over the target. */
|
|
18
|
+
function writeFile(path: string, data: AuthFile): void {
|
|
19
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
20
|
+
const temp = join(dirname(path), `.${process.pid}.${Date.now()}.tmp`);
|
|
21
|
+
writeFileSync(temp, `${JSON.stringify(data, null, 2)}\n`);
|
|
22
|
+
renameSync(temp, path);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* File-backed `CredentialStore` over `auth.json`. Writes are serialized per
|
|
27
|
+
* provider through an in-process promise chain, so `modify` and `delete` on
|
|
28
|
+
* the same provider never interleave.
|
|
29
|
+
*/
|
|
30
|
+
export function createCredentialStore(path: string): CredentialStore {
|
|
31
|
+
const chains = new Map<string, Promise<unknown>>();
|
|
32
|
+
|
|
33
|
+
function enqueue<T>(providerId: string, task: () => Promise<T>, options?: AuthOperationOptions): Promise<T> {
|
|
34
|
+
options?.signal?.throwIfAborted();
|
|
35
|
+
const previous = chains.get(providerId) ?? Promise.resolve();
|
|
36
|
+
const queued = (async () => {
|
|
37
|
+
await previous.catch(() => {});
|
|
38
|
+
options?.signal?.throwIfAborted();
|
|
39
|
+
return await task();
|
|
40
|
+
})();
|
|
41
|
+
const tail = queued.catch(() => {});
|
|
42
|
+
chains.set(providerId, tail);
|
|
43
|
+
void tail.then(() => {
|
|
44
|
+
if (chains.get(providerId) === tail) chains.delete(providerId);
|
|
45
|
+
});
|
|
46
|
+
return queued;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
async read(providerId, options): Promise<Credential | undefined> {
|
|
51
|
+
options?.signal?.throwIfAborted();
|
|
52
|
+
return readFile(path)[providerId];
|
|
53
|
+
},
|
|
54
|
+
async list(options): Promise<readonly CredentialInfo[]> {
|
|
55
|
+
options?.signal?.throwIfAborted();
|
|
56
|
+
return Object.entries(readFile(path)).map(([providerId, credential]) => ({ providerId, type: credential.type }));
|
|
57
|
+
},
|
|
58
|
+
modify(providerId, fn, options): Promise<Credential | undefined> {
|
|
59
|
+
return enqueue(
|
|
60
|
+
providerId,
|
|
61
|
+
async () => {
|
|
62
|
+
const data = readFile(path);
|
|
63
|
+
const next = await fn(data[providerId]);
|
|
64
|
+
options?.signal?.throwIfAborted();
|
|
65
|
+
if (next !== undefined) data[providerId] = next;
|
|
66
|
+
writeFile(path, data);
|
|
67
|
+
return next ?? data[providerId];
|
|
68
|
+
},
|
|
69
|
+
options,
|
|
70
|
+
);
|
|
71
|
+
},
|
|
72
|
+
delete(providerId, options): Promise<void> {
|
|
73
|
+
return enqueue(
|
|
74
|
+
providerId,
|
|
75
|
+
async () => {
|
|
76
|
+
const data = readFile(path);
|
|
77
|
+
delete data[providerId];
|
|
78
|
+
writeFile(path, data);
|
|
79
|
+
},
|
|
80
|
+
options,
|
|
81
|
+
);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|