nterminal 1.2.89 → 1.2.91
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/assets/brand/claude.webp +0 -0
- package/assets/brand/codex.webp +0 -0
- package/dist/client/assets/MarkdownPreview-DeuMEC4W.js +1 -0
- package/dist/client/assets/{TranscriptMarkdownBody-Du5Pkx1T.js → TranscriptMarkdownBody-Ba-PCW9y.js} +1 -1
- package/dist/client/assets/{index-Cyt748r3.js → index-AlID8r7Q.js} +1 -1
- package/dist/client/assets/index-B8YltmYF.js +57 -0
- package/dist/client/assets/index-BZjaqOfH.css +1 -0
- package/dist/client/brand/claude.webp +0 -0
- package/dist/client/brand/codex.webp +0 -0
- package/dist/client/index.html +2 -2
- package/dist/server/accounts/accountService.d.ts +70 -0
- package/dist/server/accounts/accountService.js +765 -0
- package/dist/server/accounts/accountService.js.map +1 -0
- package/dist/server/agent/agentRoutes.d.ts +2 -0
- package/dist/server/agent/agentRoutes.js +61 -0
- package/dist/server/agent/agentRoutes.js.map +1 -1
- package/dist/server/http.d.ts +2 -1
- package/dist/server/http.js +6 -2
- package/dist/server/http.js.map +1 -1
- package/dist/server/index.js +5 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/accountRoutes.d.ts +9 -0
- package/dist/server/routes/accountRoutes.js +84 -0
- package/dist/server/routes/accountRoutes.js.map +1 -0
- package/dist/server/routes/agentManagementRoutes.js +35 -0
- package/dist/server/routes/agentManagementRoutes.js.map +1 -1
- package/dist/shared/protocol.d.ts +30 -0
- package/dist/shared/protocol.js.map +1 -1
- package/package.json +1 -1
- package/public/brand/claude.webp +0 -0
- package/public/brand/codex.webp +0 -0
- package/dist/client/assets/MarkdownPreview-Bpy611pE.js +0 -1
- package/dist/client/assets/index-BcPAKIRq.css +0 -1
- package/dist/client/assets/index-Bm5iXlSq.js +0 -57
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmod, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
const VAULT_DIRECTORY = 'credential-vault';
|
|
8
|
+
const VAULT_INDEX = 'index.json';
|
|
9
|
+
const VAULT_KEY = 'key';
|
|
10
|
+
const REFRESH_WINDOW_MS = 15 * 60_000;
|
|
11
|
+
const REFRESH_INTERVAL_MS = 60_000;
|
|
12
|
+
const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token';
|
|
13
|
+
const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hr';
|
|
14
|
+
const CLAUDE_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
|
|
15
|
+
const CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
16
|
+
export class AccountServiceError extends Error {
|
|
17
|
+
code;
|
|
18
|
+
constructor(code, message = code) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.name = 'AccountServiceError';
|
|
22
|
+
}
|
|
23
|
+
get statusCode() {
|
|
24
|
+
if (this.code === 'preview_mode')
|
|
25
|
+
return 403;
|
|
26
|
+
if (['provider_panes_running', 'account_operation_busy', 'account_credential_changed', 'account_login_already_pending'].includes(this.code))
|
|
27
|
+
return 409;
|
|
28
|
+
return 400;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export class AccountService {
|
|
32
|
+
config;
|
|
33
|
+
terminalManager;
|
|
34
|
+
vaultPath;
|
|
35
|
+
homeDir;
|
|
36
|
+
now;
|
|
37
|
+
refreshTimer = null;
|
|
38
|
+
refreshInFlight = false;
|
|
39
|
+
operationInFlight = false;
|
|
40
|
+
disposed = false;
|
|
41
|
+
retryAfter = new Map();
|
|
42
|
+
processProbe;
|
|
43
|
+
constructor(config, terminalManager, options = {}) {
|
|
44
|
+
this.config = config;
|
|
45
|
+
this.terminalManager = terminalManager;
|
|
46
|
+
this.homeDir = options.homeDir ?? os.homedir();
|
|
47
|
+
// Keep the vault with NTerminal runtime state. Production defaults to
|
|
48
|
+
// ~/.nterminal; preview and explicitly relocated runtimes get an isolated
|
|
49
|
+
// vault instead of touching production account metadata.
|
|
50
|
+
this.vaultPath = path.join(path.dirname(config.statePath), VAULT_DIRECTORY);
|
|
51
|
+
this.now = options.now ?? (() => new Date());
|
|
52
|
+
this.processProbe = options.processProbe ?? providerProcessRunning;
|
|
53
|
+
}
|
|
54
|
+
// Reject overlapping mutations instead of accumulating requests behind OAuth I/O.
|
|
55
|
+
async mutate(operation) {
|
|
56
|
+
if (this.config.previewMode)
|
|
57
|
+
throw new AccountServiceError('preview_mode');
|
|
58
|
+
if (this.disposed || this.operationInFlight)
|
|
59
|
+
throw new AccountServiceError('account_operation_busy');
|
|
60
|
+
this.operationInFlight = true;
|
|
61
|
+
try {
|
|
62
|
+
return await operation();
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
this.operationInFlight = false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async synchronize() {
|
|
69
|
+
return this.mutate(async () => {
|
|
70
|
+
const index = await this.readIndex();
|
|
71
|
+
await this.captureActiveCredentials(index);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
select(provider, profileId) {
|
|
75
|
+
return this.mutate(() => this.selectUnlocked(provider, profileId));
|
|
76
|
+
}
|
|
77
|
+
remove(provider, profileId) {
|
|
78
|
+
return this.mutate(() => this.removeUnlocked(provider, profileId));
|
|
79
|
+
}
|
|
80
|
+
beginLogin(provider) {
|
|
81
|
+
return this.mutate(() => this.beginLoginUnlocked(provider));
|
|
82
|
+
}
|
|
83
|
+
completeLogin(provider, label) {
|
|
84
|
+
return this.mutate(() => this.completeLoginUnlocked(provider, label));
|
|
85
|
+
}
|
|
86
|
+
cancelLogin(provider) {
|
|
87
|
+
return this.mutate(() => this.cancelLoginUnlocked(provider));
|
|
88
|
+
}
|
|
89
|
+
refresh(provider, profileId) {
|
|
90
|
+
return this.mutate(() => this.refreshUnlocked(provider, profileId));
|
|
91
|
+
}
|
|
92
|
+
async status() {
|
|
93
|
+
const refreshed = await this.readIndex();
|
|
94
|
+
const running = this.runningPanes();
|
|
95
|
+
const pendingReady = new Map();
|
|
96
|
+
await Promise.all(['codex', 'claude'].map(async (provider) => {
|
|
97
|
+
pendingReady.set(provider, Boolean(refreshed.pending[provider] && await this.readCredentialFile(provider)));
|
|
98
|
+
}));
|
|
99
|
+
return {
|
|
100
|
+
providers: ['codex', 'claude'].map((provider) => this.providerStatus(refreshed, provider, running, pendingReady.get(provider) === true))
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
async selectUnlocked(provider, profileId) {
|
|
104
|
+
const index = await this.readIndex();
|
|
105
|
+
await this.assertNoRunningPanes(provider);
|
|
106
|
+
if (index.pending[provider])
|
|
107
|
+
throw new AccountServiceError('account_login_already_pending');
|
|
108
|
+
await this.captureActiveCredentials(index);
|
|
109
|
+
const profile = index.profiles.find((candidate) => candidate.id === profileId && candidate.provider === provider);
|
|
110
|
+
if (!profile)
|
|
111
|
+
throw new AccountServiceError('account_not_found');
|
|
112
|
+
if (index.active[provider] === profile.id)
|
|
113
|
+
return this.status();
|
|
114
|
+
const credentials = await this.readVaultCredentials(profile.id);
|
|
115
|
+
const current = await this.archiveActiveCredential(index, provider);
|
|
116
|
+
const previousId = index.active[provider];
|
|
117
|
+
try {
|
|
118
|
+
await this.writeCredentialFile(provider, credentials);
|
|
119
|
+
index.active[provider] = profile.id;
|
|
120
|
+
await this.writeIndex(index);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (current)
|
|
124
|
+
await this.writeCredentialFile(provider, current);
|
|
125
|
+
else
|
|
126
|
+
await this.removeCredentialFile(provider);
|
|
127
|
+
if (previousId)
|
|
128
|
+
await rm(this.profilePath(previousId), { force: true });
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
await rm(this.profilePath(profile.id), { force: true });
|
|
132
|
+
return this.status();
|
|
133
|
+
}
|
|
134
|
+
async removeUnlocked(provider, profileId) {
|
|
135
|
+
const index = await this.readIndex();
|
|
136
|
+
if (index.pending[provider])
|
|
137
|
+
throw new AccountServiceError('account_login_already_pending');
|
|
138
|
+
await this.captureActiveCredentials(index);
|
|
139
|
+
const profile = index.profiles.find((candidate) => candidate.id === profileId && candidate.provider === provider);
|
|
140
|
+
if (!profile)
|
|
141
|
+
throw new AccountServiceError('account_not_found');
|
|
142
|
+
if (index.active[provider] === profile.id) {
|
|
143
|
+
throw new AccountServiceError('cannot_remove_active_account');
|
|
144
|
+
}
|
|
145
|
+
index.profiles = index.profiles.filter((candidate) => candidate.id !== profile.id);
|
|
146
|
+
await this.writeIndex(index);
|
|
147
|
+
await rm(this.profilePath(profile.id), { force: true });
|
|
148
|
+
this.retryAfter.delete(profile.id);
|
|
149
|
+
return this.status();
|
|
150
|
+
}
|
|
151
|
+
async beginLoginUnlocked(provider) {
|
|
152
|
+
const index = await this.readIndex();
|
|
153
|
+
await this.assertNoRunningPanes(provider);
|
|
154
|
+
if (index.pending[provider])
|
|
155
|
+
throw new AccountServiceError('account_login_already_pending');
|
|
156
|
+
await this.captureActiveCredentials(index);
|
|
157
|
+
const current = await this.archiveActiveCredential(index, provider);
|
|
158
|
+
index.pending[provider] = {
|
|
159
|
+
provider,
|
|
160
|
+
previousProfileId: index.active[provider] ?? null,
|
|
161
|
+
startedAt: this.now().toISOString()
|
|
162
|
+
};
|
|
163
|
+
delete index.active[provider];
|
|
164
|
+
await this.writeIndex(index);
|
|
165
|
+
let terminal;
|
|
166
|
+
try {
|
|
167
|
+
if (current)
|
|
168
|
+
await this.removeCredentialFile(provider);
|
|
169
|
+
terminal = await this.terminalManager.createTerminal({ cwd: this.config.workspaceRoot, cols: 100, rows: 30 });
|
|
170
|
+
const command = provider === 'codex' ? 'codex login' : 'claude auth login';
|
|
171
|
+
const wrote = await this.terminalManager.writeToTerminal(terminal.id, `${command}\r`);
|
|
172
|
+
if (!wrote)
|
|
173
|
+
throw new AccountServiceError('account_login_terminal_unavailable');
|
|
174
|
+
return { terminal, command };
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (terminal)
|
|
178
|
+
await this.terminalManager.closeTerminal(terminal.id);
|
|
179
|
+
if (current)
|
|
180
|
+
await this.writeCredentialFile(provider, current);
|
|
181
|
+
if (index.active[provider] === undefined && index.pending[provider]?.previousProfileId) {
|
|
182
|
+
index.active[provider] = index.pending[provider].previousProfileId;
|
|
183
|
+
}
|
|
184
|
+
delete index.pending[provider];
|
|
185
|
+
await this.writeIndex(index);
|
|
186
|
+
const restoredId = index.active[provider];
|
|
187
|
+
if (restoredId)
|
|
188
|
+
await rm(this.profilePath(restoredId), { force: true });
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async completeLoginUnlocked(provider, label) {
|
|
193
|
+
const index = await this.readIndex();
|
|
194
|
+
const pending = index.pending[provider];
|
|
195
|
+
if (!pending)
|
|
196
|
+
throw new AccountServiceError('account_login_not_pending');
|
|
197
|
+
await this.assertNoRunningPanes(provider);
|
|
198
|
+
const credentials = await this.readCredentialFile(provider);
|
|
199
|
+
if (!credentials)
|
|
200
|
+
throw new AccountServiceError('account_login_not_complete');
|
|
201
|
+
const profile = await this.storeProfile(index, provider, credentials, label || defaultLabel(provider, index.profiles));
|
|
202
|
+
index.active[provider] = profile.id;
|
|
203
|
+
delete index.pending[provider];
|
|
204
|
+
await this.writeIndex(index);
|
|
205
|
+
await rm(this.profilePath(profile.id), { force: true });
|
|
206
|
+
return this.status();
|
|
207
|
+
}
|
|
208
|
+
async cancelLoginUnlocked(provider) {
|
|
209
|
+
const index = await this.readIndex();
|
|
210
|
+
const pending = index.pending[provider];
|
|
211
|
+
if (!pending)
|
|
212
|
+
return this.status();
|
|
213
|
+
await this.assertNoRunningPanes(provider);
|
|
214
|
+
if (pending.previousProfileId) {
|
|
215
|
+
const credentials = await this.readVaultCredentials(pending.previousProfileId);
|
|
216
|
+
await this.writeCredentialFile(provider, credentials);
|
|
217
|
+
index.active[provider] = pending.previousProfileId;
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
await this.removeCredentialFile(provider);
|
|
221
|
+
}
|
|
222
|
+
delete index.pending[provider];
|
|
223
|
+
await this.writeIndex(index);
|
|
224
|
+
const restoredId = index.active[provider];
|
|
225
|
+
if (restoredId)
|
|
226
|
+
await rm(this.profilePath(restoredId), { force: true });
|
|
227
|
+
return this.status();
|
|
228
|
+
}
|
|
229
|
+
async refreshUnlocked(provider, profileId) {
|
|
230
|
+
const index = await this.readIndex();
|
|
231
|
+
if (index.pending[provider])
|
|
232
|
+
throw new AccountServiceError('account_login_already_pending');
|
|
233
|
+
await this.captureActiveCredentials(index);
|
|
234
|
+
const targetId = profileId ?? index.active[provider];
|
|
235
|
+
const profile = targetId ? index.profiles.find((candidate) => candidate.id === targetId && candidate.provider === provider) : undefined;
|
|
236
|
+
if (!profile)
|
|
237
|
+
throw new AccountServiceError('account_not_found');
|
|
238
|
+
await this.refreshProfile(index, profile);
|
|
239
|
+
await this.writeIndex(index);
|
|
240
|
+
return this.status();
|
|
241
|
+
}
|
|
242
|
+
start() {
|
|
243
|
+
if (this.refreshTimer || this.config.previewMode || this.disposed)
|
|
244
|
+
return;
|
|
245
|
+
this.refreshTimer = setInterval(() => void this.refreshExpiringProfiles(), REFRESH_INTERVAL_MS);
|
|
246
|
+
this.refreshTimer.unref();
|
|
247
|
+
void this.refreshExpiringProfiles();
|
|
248
|
+
}
|
|
249
|
+
dispose() {
|
|
250
|
+
this.disposed = true;
|
|
251
|
+
if (this.refreshTimer)
|
|
252
|
+
clearInterval(this.refreshTimer);
|
|
253
|
+
this.refreshTimer = null;
|
|
254
|
+
}
|
|
255
|
+
async refreshExpiringProfiles() {
|
|
256
|
+
if (this.refreshInFlight || this.disposed || this.config.previewMode || this.operationInFlight)
|
|
257
|
+
return;
|
|
258
|
+
this.refreshInFlight = true;
|
|
259
|
+
try {
|
|
260
|
+
await this.synchronize();
|
|
261
|
+
const candidates = (await this.readIndex()).profiles;
|
|
262
|
+
for (const candidate of candidates) {
|
|
263
|
+
if (this.disposed)
|
|
264
|
+
break;
|
|
265
|
+
if (candidate.expiresAt === null || (this.retryAfter.get(candidate.id) ?? 0) > this.now().getTime())
|
|
266
|
+
continue;
|
|
267
|
+
const profile = candidate;
|
|
268
|
+
const expiresAt = Date.parse(candidate.expiresAt);
|
|
269
|
+
if (!Number.isFinite(expiresAt) || expiresAt - this.now().getTime() > REFRESH_WINDOW_MS)
|
|
270
|
+
continue;
|
|
271
|
+
try {
|
|
272
|
+
await this.mutate(async () => {
|
|
273
|
+
const index = await this.readIndex();
|
|
274
|
+
if (index.pending[profile.provider])
|
|
275
|
+
return;
|
|
276
|
+
await this.captureActiveCredentials(index);
|
|
277
|
+
const current = index.profiles.find((entry) => entry.id === profile.id);
|
|
278
|
+
if (!current || current.expiresAt === null || Date.parse(current.expiresAt) - this.now().getTime() > REFRESH_WINDOW_MS)
|
|
279
|
+
return;
|
|
280
|
+
if (current.refreshTokenExpiresAt && Date.parse(current.refreshTokenExpiresAt) <= this.now().getTime())
|
|
281
|
+
return;
|
|
282
|
+
await this.refreshProfile(index, current);
|
|
283
|
+
await this.writeIndex(index);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
if (!(error instanceof AccountServiceError) || !['provider_panes_running', 'account_operation_busy'].includes(error.code)) {
|
|
288
|
+
this.retryAfter.set(profile.id, this.now().getTime() + 15 * 60_000);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
// Background filesystem failures must not become unhandled rejections.
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
this.refreshInFlight = false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
providerStatus(index, provider, running, pendingReady) {
|
|
301
|
+
const activeId = index.active[provider] ?? null;
|
|
302
|
+
const profile = activeId ? index.profiles.find((candidate) => candidate.id === activeId) ?? null : null;
|
|
303
|
+
const pending = index.pending[provider];
|
|
304
|
+
return {
|
|
305
|
+
provider,
|
|
306
|
+
activeProfileId: activeId,
|
|
307
|
+
profiles: index.profiles.filter((candidate) => candidate.provider === provider).map(toPublicProfile),
|
|
308
|
+
runningPanes: running.filter((pane) => pane.provider === provider),
|
|
309
|
+
pendingLogin: pending ? { startedAt: pending.startedAt, ready: pendingReady } : null,
|
|
310
|
+
credentialPresent: Boolean(profile || pending || false)
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
runningPanes() {
|
|
314
|
+
return this.terminalManager.listTerminals().flatMap((terminal) => {
|
|
315
|
+
if (terminal.status !== 'running')
|
|
316
|
+
return [];
|
|
317
|
+
const provider = providerForTerminal(terminal.currentCommand, terminal.agentStatus?.provider);
|
|
318
|
+
return provider ? [{ terminalId: terminal.id, provider, title: terminal.title, cwd: terminal.cwd }] : [];
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
async assertNoRunningPanes(provider) {
|
|
322
|
+
if (this.runningPanes().some((pane) => pane.provider === provider) || await this.processProbe(provider)) {
|
|
323
|
+
throw new AccountServiceError('provider_panes_running');
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
async captureActiveCredentials(index) {
|
|
327
|
+
const before = JSON.stringify(index);
|
|
328
|
+
for (const provider of ['codex', 'claude']) {
|
|
329
|
+
if (!index.pending[provider])
|
|
330
|
+
await this.captureActiveCredential(index, provider);
|
|
331
|
+
}
|
|
332
|
+
if (JSON.stringify(index) !== before)
|
|
333
|
+
await this.writeIndex(index);
|
|
334
|
+
// Only metadata is retained for active accounts. A duplicate can exist
|
|
335
|
+
// briefly during a switch, until the source file and index are committed.
|
|
336
|
+
for (const id of Object.values(index.active)) {
|
|
337
|
+
if (id)
|
|
338
|
+
await rm(this.profilePath(id), { force: true });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
async captureActiveCredential(index, provider) {
|
|
342
|
+
const credentials = await this.readCredentialFile(provider);
|
|
343
|
+
const previousId = index.active[provider];
|
|
344
|
+
if (!credentials) {
|
|
345
|
+
delete index.active[provider];
|
|
346
|
+
await this.removeUnavailableProfile(index, previousId);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const fingerprint = fingerprintFor(provider, credentials);
|
|
350
|
+
const credentialHash = hashCredential(credentials);
|
|
351
|
+
const existing = index.profiles.find((profile) => profile.provider === provider && (profile.fingerprint === fingerprint || profile.credentialHash === credentialHash));
|
|
352
|
+
if (existing) {
|
|
353
|
+
if (existing.credentialHash !== credentialHash)
|
|
354
|
+
await this.updateProfile(existing, provider, credentials);
|
|
355
|
+
index.active[provider] = existing.id;
|
|
356
|
+
if (previousId !== existing.id)
|
|
357
|
+
await this.removeUnavailableProfile(index, previousId);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const profile = await this.storeProfile(index, provider, credentials, defaultLabel(provider, index.profiles));
|
|
361
|
+
index.active[provider] = profile.id;
|
|
362
|
+
if (previousId !== profile.id)
|
|
363
|
+
await this.removeUnavailableProfile(index, previousId);
|
|
364
|
+
}
|
|
365
|
+
async removeUnavailableProfile(index, id) {
|
|
366
|
+
if (!id)
|
|
367
|
+
return;
|
|
368
|
+
try {
|
|
369
|
+
await stat(this.profilePath(id));
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
if (!isNotFound(error))
|
|
373
|
+
throw error;
|
|
374
|
+
index.profiles = index.profiles.filter((profile) => profile.id !== id);
|
|
375
|
+
this.retryAfter.delete(id);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
async archiveActiveCredential(index, provider) {
|
|
379
|
+
const credentials = await this.readCredentialFile(provider);
|
|
380
|
+
const profile = index.profiles.find((entry) => entry.id === index.active[provider]);
|
|
381
|
+
if (!credentials)
|
|
382
|
+
return null;
|
|
383
|
+
if (!profile || profile.credentialHash !== hashCredential(credentials))
|
|
384
|
+
throw new AccountServiceError('account_credential_changed');
|
|
385
|
+
await this.writeVaultCredentials(profile.id, credentials);
|
|
386
|
+
return credentials;
|
|
387
|
+
}
|
|
388
|
+
async storeProfile(index, provider, credentials, label) {
|
|
389
|
+
const fingerprint = fingerprintFor(provider, credentials);
|
|
390
|
+
const existing = index.profiles.find((profile) => profile.provider === provider && profile.fingerprint === fingerprint);
|
|
391
|
+
const now = this.now().toISOString();
|
|
392
|
+
const timing = credentialTiming(provider, credentials);
|
|
393
|
+
const profile = existing ?? {
|
|
394
|
+
id: randomUUID(), provider, label: sanitizeLabel(label), createdAt: now, updatedAt: now,
|
|
395
|
+
expiresAt: timing.expiresAt, refreshTokenExpiresAt: timing.refreshTokenExpiresAt, fingerprint
|
|
396
|
+
};
|
|
397
|
+
profile.label = sanitizeLabel(label || profile.label);
|
|
398
|
+
profile.updatedAt = now;
|
|
399
|
+
profile.expiresAt = timing.expiresAt;
|
|
400
|
+
profile.refreshTokenExpiresAt = timing.refreshTokenExpiresAt;
|
|
401
|
+
profile.fingerprint = fingerprint;
|
|
402
|
+
profile.credentialHash = hashCredential(credentials);
|
|
403
|
+
if (!existing)
|
|
404
|
+
index.profiles.push(profile);
|
|
405
|
+
return profile;
|
|
406
|
+
}
|
|
407
|
+
async updateProfile(profile, provider, credentials) {
|
|
408
|
+
const timing = credentialTiming(provider, credentials);
|
|
409
|
+
profile.updatedAt = this.now().toISOString();
|
|
410
|
+
profile.expiresAt = timing.expiresAt;
|
|
411
|
+
profile.refreshTokenExpiresAt = timing.refreshTokenExpiresAt;
|
|
412
|
+
profile.credentialHash = hashCredential(credentials);
|
|
413
|
+
return profile;
|
|
414
|
+
}
|
|
415
|
+
async refreshProfile(index, profile) {
|
|
416
|
+
const active = index.active[profile.provider] === profile.id;
|
|
417
|
+
if (active)
|
|
418
|
+
await this.assertNoRunningPanes(profile.provider);
|
|
419
|
+
const credentials = active
|
|
420
|
+
? await this.readCredentialFile(profile.provider)
|
|
421
|
+
: await this.readVaultCredentials(profile.id);
|
|
422
|
+
if (!credentials || (active && profile.credentialHash !== hashCredential(credentials))) {
|
|
423
|
+
throw new AccountServiceError('account_credential_changed');
|
|
424
|
+
}
|
|
425
|
+
const refreshToken = refreshTokenFor(profile.provider, credentials);
|
|
426
|
+
if (!refreshToken)
|
|
427
|
+
throw new AccountServiceError('refresh_token_unavailable');
|
|
428
|
+
if (profile.refreshTokenExpiresAt && Date.parse(profile.refreshTokenExpiresAt) <= this.now().getTime()) {
|
|
429
|
+
throw new AccountServiceError('account_refresh_failed');
|
|
430
|
+
}
|
|
431
|
+
const body = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: profile.provider === 'codex' ? CODEX_CLIENT_ID : CLAUDE_CLIENT_ID });
|
|
432
|
+
let response;
|
|
433
|
+
try {
|
|
434
|
+
response = await fetch(profile.provider === 'codex' ? CODEX_TOKEN_URL : CLAUDE_TOKEN_URL, {
|
|
435
|
+
method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body, signal: AbortSignal.timeout(15_000)
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
throw new AccountServiceError('account_refresh_unavailable');
|
|
440
|
+
}
|
|
441
|
+
if (!response.ok)
|
|
442
|
+
throw new AccountServiceError('account_refresh_failed');
|
|
443
|
+
let refreshed;
|
|
444
|
+
try {
|
|
445
|
+
refreshed = await response.json();
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
throw new AccountServiceError('account_refresh_failed');
|
|
449
|
+
}
|
|
450
|
+
if (!refreshed || typeof refreshed !== 'object')
|
|
451
|
+
throw new AccountServiceError('account_refresh_failed');
|
|
452
|
+
const next = applyRefresh(profile.provider, credentials, refreshed, this.now());
|
|
453
|
+
if (active) {
|
|
454
|
+
const live = await this.readCredentialFile(profile.provider);
|
|
455
|
+
if (!live || hashCredential(live) !== hashCredential(credentials)) {
|
|
456
|
+
// An external login can replace the active account during the request.
|
|
457
|
+
// Its new file wins; retain the refreshed old account only if identifiable
|
|
458
|
+
// as a different account, which is now inactive.
|
|
459
|
+
if (live && stableIdentity(profile.provider, credentials) && stableIdentity(profile.provider, live)
|
|
460
|
+
&& stableIdentity(profile.provider, credentials) !== stableIdentity(profile.provider, live)) {
|
|
461
|
+
await this.writeVaultCredentials(profile.id, next);
|
|
462
|
+
await this.updateProfile(profile, profile.provider, next);
|
|
463
|
+
await this.captureActiveCredentials(index);
|
|
464
|
+
}
|
|
465
|
+
throw new AccountServiceError('account_credential_changed');
|
|
466
|
+
}
|
|
467
|
+
// Refresh was started while idle. Once it succeeds, commit the rotated
|
|
468
|
+
// token to its only source even if a CLI has since started reading it.
|
|
469
|
+
await this.writeCredentialFile(profile.provider, next);
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
await this.writeVaultCredentials(profile.id, next);
|
|
473
|
+
}
|
|
474
|
+
await this.updateProfile(profile, profile.provider, next);
|
|
475
|
+
}
|
|
476
|
+
async readIndex() {
|
|
477
|
+
try {
|
|
478
|
+
const text = await readFile(path.join(this.vaultPath, VAULT_INDEX), 'utf8');
|
|
479
|
+
const parsed = JSON.parse(text);
|
|
480
|
+
return {
|
|
481
|
+
version: 1,
|
|
482
|
+
profiles: Array.isArray(parsed.profiles) ? parsed.profiles.filter(isVaultProfile) : [],
|
|
483
|
+
active: isRecord(parsed.active) ? parsed.active : {},
|
|
484
|
+
pending: isRecord(parsed.pending) ? parsed.pending : {}
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
catch (error) {
|
|
488
|
+
if (isNotFound(error))
|
|
489
|
+
return { version: 1, profiles: [], active: {}, pending: {} };
|
|
490
|
+
throw new AccountServiceError('account_vault_unavailable');
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
async writeIndex(index) {
|
|
494
|
+
await this.ensureVault();
|
|
495
|
+
await atomicWrite(path.join(this.vaultPath, VAULT_INDEX), JSON.stringify(index), 0o600);
|
|
496
|
+
}
|
|
497
|
+
profilePath(id) { return path.join(this.vaultPath, `${id}.enc`); }
|
|
498
|
+
async vaultKey(create = false) {
|
|
499
|
+
const keyPath = path.join(this.vaultPath, VAULT_KEY);
|
|
500
|
+
try {
|
|
501
|
+
const key = await readFile(keyPath);
|
|
502
|
+
if (key.byteLength === 32)
|
|
503
|
+
return key;
|
|
504
|
+
throw new AccountServiceError('account_vault_unavailable');
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
if (!isNotFound(error))
|
|
508
|
+
throw error;
|
|
509
|
+
}
|
|
510
|
+
if (!create)
|
|
511
|
+
throw new AccountServiceError('account_vault_unavailable');
|
|
512
|
+
const key = randomBytes(32);
|
|
513
|
+
await this.ensureVault();
|
|
514
|
+
if ((await readdir(this.vaultPath)).some((name) => name.endsWith('.enc'))) {
|
|
515
|
+
throw new AccountServiceError('account_vault_unavailable');
|
|
516
|
+
}
|
|
517
|
+
await writeFile(keyPath, key, { mode: 0o600, flag: 'wx' });
|
|
518
|
+
return key;
|
|
519
|
+
}
|
|
520
|
+
async ensureVault() {
|
|
521
|
+
await mkdir(this.vaultPath, { recursive: true, mode: 0o700 });
|
|
522
|
+
await chmod(this.vaultPath, 0o700);
|
|
523
|
+
}
|
|
524
|
+
async writeVaultCredentials(profileId, credentials) {
|
|
525
|
+
const key = await this.vaultKey(true);
|
|
526
|
+
const iv = randomBytes(12);
|
|
527
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
528
|
+
const plaintext = Buffer.from(JSON.stringify(credentials));
|
|
529
|
+
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
530
|
+
const payload = Buffer.concat([iv, cipher.getAuthTag(), encrypted]);
|
|
531
|
+
await atomicWrite(this.profilePath(profileId), payload.toString('base64'), 0o600);
|
|
532
|
+
}
|
|
533
|
+
async readVaultCredentials(profileId) {
|
|
534
|
+
const key = await this.vaultKey();
|
|
535
|
+
let payload;
|
|
536
|
+
try {
|
|
537
|
+
payload = Buffer.from(await readFile(this.profilePath(profileId), 'utf8'), 'base64');
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
throw new AccountServiceError('account_vault_unavailable');
|
|
541
|
+
}
|
|
542
|
+
if (payload.byteLength < 29)
|
|
543
|
+
throw new AccountServiceError('account_vault_unavailable');
|
|
544
|
+
try {
|
|
545
|
+
const decipher = createDecipheriv('aes-256-gcm', key, payload.subarray(0, 12));
|
|
546
|
+
decipher.setAuthTag(payload.subarray(12, 28));
|
|
547
|
+
const decoded = JSON.parse(Buffer.concat([decipher.update(payload.subarray(28)), decipher.final()]).toString('utf8'));
|
|
548
|
+
if (!isRecord(decoded))
|
|
549
|
+
throw new Error('invalid');
|
|
550
|
+
return decoded;
|
|
551
|
+
}
|
|
552
|
+
catch {
|
|
553
|
+
throw new AccountServiceError('account_vault_unavailable');
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
credentialPath(provider) {
|
|
557
|
+
return provider === 'codex'
|
|
558
|
+
? path.join(this.homeDir, '.codex', 'auth.json')
|
|
559
|
+
: path.join(this.homeDir, '.claude', '.credentials.json');
|
|
560
|
+
}
|
|
561
|
+
async readCredentialFile(provider) {
|
|
562
|
+
try {
|
|
563
|
+
const document = JSON.parse(await readFile(this.credentialPath(provider), 'utf8'));
|
|
564
|
+
if (!isRecord(document))
|
|
565
|
+
return null;
|
|
566
|
+
if (provider === 'claude') {
|
|
567
|
+
const settings = await this.readClaudeSettings();
|
|
568
|
+
// This envelope field stays in the encrypted vault, never in the CLI token file.
|
|
569
|
+
document.nterminalAccountMetadata = isRecord(settings.oauthAccount) ? settings.oauthAccount : null;
|
|
570
|
+
}
|
|
571
|
+
return document;
|
|
572
|
+
}
|
|
573
|
+
catch (error) {
|
|
574
|
+
if (isNotFound(error))
|
|
575
|
+
return null;
|
|
576
|
+
throw new AccountServiceError('credential_file_unavailable');
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
async writeCredentialFile(provider, credentials) {
|
|
580
|
+
const target = this.credentialPath(provider);
|
|
581
|
+
await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
582
|
+
const { nterminalAccountMetadata, ...document } = credentials;
|
|
583
|
+
if (provider === 'claude') {
|
|
584
|
+
const previousSettings = await this.readClaudeSettings();
|
|
585
|
+
const settings = { ...previousSettings };
|
|
586
|
+
if (isRecord(nterminalAccountMetadata))
|
|
587
|
+
settings.oauthAccount = nterminalAccountMetadata;
|
|
588
|
+
else
|
|
589
|
+
delete settings.oauthAccount;
|
|
590
|
+
const settingsPath = path.join(this.homeDir, '.claude.json');
|
|
591
|
+
const changed = JSON.stringify(settings) !== JSON.stringify(previousSettings);
|
|
592
|
+
if (changed)
|
|
593
|
+
await atomicWrite(settingsPath, JSON.stringify(settings, null, 2), 0o600);
|
|
594
|
+
try {
|
|
595
|
+
await atomicWrite(target, JSON.stringify(document, null, 2), 0o600);
|
|
596
|
+
}
|
|
597
|
+
catch (error) {
|
|
598
|
+
if (changed)
|
|
599
|
+
await atomicWrite(settingsPath, JSON.stringify(previousSettings, null, 2), 0o600);
|
|
600
|
+
throw error;
|
|
601
|
+
}
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
await atomicWrite(target, JSON.stringify(document, null, 2), 0o600);
|
|
605
|
+
}
|
|
606
|
+
async readClaudeSettings() {
|
|
607
|
+
try {
|
|
608
|
+
const document = JSON.parse(await readFile(path.join(this.homeDir, '.claude.json'), 'utf8'));
|
|
609
|
+
if (!isRecord(document))
|
|
610
|
+
throw new AccountServiceError('credential_file_unavailable');
|
|
611
|
+
return document;
|
|
612
|
+
}
|
|
613
|
+
catch (error) {
|
|
614
|
+
if (isNotFound(error))
|
|
615
|
+
return {};
|
|
616
|
+
throw new AccountServiceError('credential_file_unavailable');
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
async removeCredentialFile(provider) {
|
|
620
|
+
await rm(this.credentialPath(provider), { force: true });
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
function providerForTerminal(command, transcriptProvider) {
|
|
624
|
+
if (transcriptProvider)
|
|
625
|
+
return transcriptProvider;
|
|
626
|
+
const normalized = command?.toLowerCase() ?? '';
|
|
627
|
+
if (/(^|[\s/])codex(?:[-\s]|$)/.test(normalized))
|
|
628
|
+
return 'codex';
|
|
629
|
+
if (/(^|[\s/])claude(?:[-\s]|$)/.test(normalized))
|
|
630
|
+
return 'claude';
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
const execFileAsync = promisify(execFile);
|
|
634
|
+
async function providerProcessRunning(provider) {
|
|
635
|
+
try {
|
|
636
|
+
if (!process.getuid)
|
|
637
|
+
throw new Error('unsupported');
|
|
638
|
+
const { stdout } = await execFileAsync('ps', ['-u', String(process.getuid()), '-o', 'comm=,args='], {
|
|
639
|
+
timeout: 3_000, maxBuffer: 4 * 1024 * 1024, encoding: 'utf8'
|
|
640
|
+
});
|
|
641
|
+
return stdout.split('\n').some((line) => {
|
|
642
|
+
const [command, executable, ...args] = line.trim().split(/\s+/);
|
|
643
|
+
if (providerForTerminal(command, undefined) === provider || providerForTerminal(executable, undefined) === provider)
|
|
644
|
+
return true;
|
|
645
|
+
// Inspect the script operand, not prompt text passed later in argv.
|
|
646
|
+
if (command === 'node' || command === 'nodejs') {
|
|
647
|
+
const script = args.find((arg) => !arg.startsWith('-'));
|
|
648
|
+
return Boolean(script && (script.includes(`/${provider}/`) || script.includes(`/${provider}-code/`) || providerForTerminal(script, undefined) === provider));
|
|
649
|
+
}
|
|
650
|
+
return false;
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
throw new AccountServiceError('account_process_check_unavailable');
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
function toPublicProfile(profile) {
|
|
658
|
+
return { id: profile.id, provider: profile.provider, label: profile.label, createdAt: profile.createdAt, updatedAt: profile.updatedAt, expiresAt: profile.expiresAt, refreshTokenExpiresAt: profile.refreshTokenExpiresAt };
|
|
659
|
+
}
|
|
660
|
+
function credentialTiming(provider, document) {
|
|
661
|
+
if (provider === 'claude') {
|
|
662
|
+
const oauth = isRecord(document.claudeAiOauth) ? document.claudeAiOauth : {};
|
|
663
|
+
return { expiresAt: isoTime(oauth.expiresAt), refreshTokenExpiresAt: isoTime(oauth.refreshTokenExpiresAt) };
|
|
664
|
+
}
|
|
665
|
+
const tokens = isRecord(document.tokens) ? document.tokens : {};
|
|
666
|
+
return { expiresAt: jwtExpiry(tokens.access_token), refreshTokenExpiresAt: null };
|
|
667
|
+
}
|
|
668
|
+
function refreshTokenFor(provider, document) {
|
|
669
|
+
const source = provider === 'claude' ? (isRecord(document.claudeAiOauth) ? document.claudeAiOauth : {}) : (isRecord(document.tokens) ? document.tokens : {});
|
|
670
|
+
const token = source.refreshToken ?? source.refresh_token;
|
|
671
|
+
return typeof token === 'string' && token ? token : null;
|
|
672
|
+
}
|
|
673
|
+
function applyRefresh(provider, document, response, now) {
|
|
674
|
+
const accessToken = stringValue(response.access_token);
|
|
675
|
+
const refreshToken = stringValue(response.refresh_token);
|
|
676
|
+
if (!accessToken)
|
|
677
|
+
throw new AccountServiceError('account_refresh_failed');
|
|
678
|
+
if (provider === 'claude') {
|
|
679
|
+
const oauth = isRecord(document.claudeAiOauth) ? { ...document.claudeAiOauth } : {};
|
|
680
|
+
oauth.accessToken = accessToken;
|
|
681
|
+
if (refreshToken)
|
|
682
|
+
oauth.refreshToken = refreshToken;
|
|
683
|
+
const expiresIn = numberValue(response.expires_in);
|
|
684
|
+
if (expiresIn)
|
|
685
|
+
oauth.expiresAt = now.getTime() + expiresIn * 1000;
|
|
686
|
+
return { ...document, claudeAiOauth: oauth };
|
|
687
|
+
}
|
|
688
|
+
const tokens = isRecord(document.tokens) ? { ...document.tokens } : {};
|
|
689
|
+
tokens.access_token = accessToken;
|
|
690
|
+
if (refreshToken)
|
|
691
|
+
tokens.refresh_token = refreshToken;
|
|
692
|
+
const idToken = stringValue(response.id_token);
|
|
693
|
+
if (idToken)
|
|
694
|
+
tokens.id_token = idToken;
|
|
695
|
+
return { ...document, tokens, last_refresh: now.toISOString() };
|
|
696
|
+
}
|
|
697
|
+
function defaultLabel(provider, profiles) {
|
|
698
|
+
return `${provider === 'codex' ? 'Codex' : 'Claude'} account ${profiles.filter((profile) => profile.provider === provider).length + 1}`;
|
|
699
|
+
}
|
|
700
|
+
function sanitizeLabel(value) { return value.trim().replace(/\s+/g, ' ').slice(0, 80) || 'Account'; }
|
|
701
|
+
function fingerprintFor(provider, value) {
|
|
702
|
+
const identity = stableIdentity(provider, value);
|
|
703
|
+
return createHash('sha256').update(identity ?? JSON.stringify(value)).digest('hex');
|
|
704
|
+
}
|
|
705
|
+
function hashCredential(value) {
|
|
706
|
+
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
707
|
+
}
|
|
708
|
+
function stableIdentity(provider, value) {
|
|
709
|
+
if (provider === 'codex') {
|
|
710
|
+
const tokens = isRecord(value.tokens) ? value.tokens : {};
|
|
711
|
+
const accountId = stringValue(tokens.account_id);
|
|
712
|
+
if (accountId)
|
|
713
|
+
return `codex:${accountId}`;
|
|
714
|
+
return jwtSubject('codex', tokens.id_token) ?? jwtSubject('codex', tokens.access_token);
|
|
715
|
+
}
|
|
716
|
+
const oauth = isRecord(value.claudeAiOauth) ? value.claudeAiOauth : {};
|
|
717
|
+
const metadata = isRecord(value.nterminalAccountMetadata) ? value.nterminalAccountMetadata : {};
|
|
718
|
+
const accountId = stringValue(metadata.accountUuid);
|
|
719
|
+
if (accountId)
|
|
720
|
+
return `claude:${accountId}`;
|
|
721
|
+
return jwtSubject('claude', oauth.accessToken);
|
|
722
|
+
}
|
|
723
|
+
function jwtSubject(provider, value) {
|
|
724
|
+
if (typeof value !== 'string')
|
|
725
|
+
return null;
|
|
726
|
+
const part = value.split('.')[1];
|
|
727
|
+
if (!part)
|
|
728
|
+
return null;
|
|
729
|
+
try {
|
|
730
|
+
const decoded = JSON.parse(Buffer.from(part.replace(/-/g, '+').replace(/_/g, '/'), 'base64url').toString('utf8'));
|
|
731
|
+
const subject = stringValue(decoded.sub) ?? stringValue(decoded.email) ?? stringValue(decoded.account_id);
|
|
732
|
+
return subject ? `${provider}:${subject}` : null;
|
|
733
|
+
}
|
|
734
|
+
catch {
|
|
735
|
+
return null;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
function isRecord(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); }
|
|
739
|
+
function isVaultProfile(value) { return isRecord(value) && typeof value.id === 'string' && (value.provider === 'codex' || value.provider === 'claude') && typeof value.label === 'string' && typeof value.createdAt === 'string' && typeof value.updatedAt === 'string' && (value.expiresAt === null || typeof value.expiresAt === 'string') && (value.refreshTokenExpiresAt === null || typeof value.refreshTokenExpiresAt === 'string') && typeof value.fingerprint === 'string'; }
|
|
740
|
+
function stringValue(value) { return typeof value === 'string' && value ? value : null; }
|
|
741
|
+
function numberValue(value) { return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null; }
|
|
742
|
+
function isoTime(value) { if (typeof value === 'string' && Number.isFinite(Date.parse(value)))
|
|
743
|
+
return new Date(value).toISOString(); if (typeof value === 'number' && Number.isFinite(value))
|
|
744
|
+
return new Date(value).toISOString(); return null; }
|
|
745
|
+
function jwtExpiry(value) { if (typeof value !== 'string')
|
|
746
|
+
return null; const part = value.split('.')[1]; if (!part)
|
|
747
|
+
return null; try {
|
|
748
|
+
const parsed = JSON.parse(Buffer.from(part.replace(/-/g, '+').replace(/_/g, '/'), 'base64url').toString('utf8'));
|
|
749
|
+
return typeof parsed.exp === 'number' ? new Date(parsed.exp * 1000).toISOString() : null;
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
return null;
|
|
753
|
+
} }
|
|
754
|
+
function isNotFound(error) { return Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'); }
|
|
755
|
+
async function atomicWrite(target, value, mode) {
|
|
756
|
+
const temp = `${target}.${randomUUID()}.tmp`;
|
|
757
|
+
try {
|
|
758
|
+
await writeFile(temp, value, { mode, flag: 'wx' });
|
|
759
|
+
await rename(temp, target);
|
|
760
|
+
}
|
|
761
|
+
finally {
|
|
762
|
+
await rm(temp, { force: true }).catch(() => undefined);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
//# sourceMappingURL=accountService.js.map
|