tokenmix 1.5.1 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents/claude.js +25 -21
- package/dist/agents/opencode.js +13 -6
- package/dist/api/client.js +41 -7
- package/dist/config/store.js +16 -9
- package/dist/i18n/messages.js +6 -0
- package/dist/utils/browser.js +14 -5
- package/dist/utils/fs.js +31 -0
- package/package.json +1 -1
package/dist/agents/claude.js
CHANGED
|
@@ -2,6 +2,7 @@ import os from 'os';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
4
|
import { run } from '../utils/exec.js';
|
|
5
|
+
import { writeFileAtomic } from '../utils/fs.js';
|
|
5
6
|
import { npmInstallCheck, npmInstallGlobal } from './helpers.js';
|
|
6
7
|
import { t } from '../i18n/index.js';
|
|
7
8
|
const CLAUDE_BIN = 'claude';
|
|
@@ -16,11 +17,16 @@ async function configure(apiKey, baseUrl, _defaultModel) {
|
|
|
16
17
|
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
|
17
18
|
let existing = {};
|
|
18
19
|
try {
|
|
19
|
-
const
|
|
20
|
-
|
|
20
|
+
const parsed = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
|
|
21
|
+
// Valid JSON that isn't a plain object (null, array, string) must be treated as
|
|
22
|
+
// "start fresh" — otherwise `existing.env` below throws (JSON.parse("null") →
|
|
23
|
+
// null → null.env → TypeError, which would block `tokenmix claude` entirely).
|
|
24
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
25
|
+
existing = parsed;
|
|
26
|
+
}
|
|
21
27
|
}
|
|
22
28
|
catch {
|
|
23
|
-
// first run
|
|
29
|
+
// first run, or unreadable/corrupt — start fresh
|
|
24
30
|
}
|
|
25
31
|
const existingEnv = existing.env || {};
|
|
26
32
|
// Detect that we're about to overwrite a user's OWN (non-tokenmix) Anthropic
|
|
@@ -42,11 +48,17 @@ async function configure(apiKey, baseUrl, _defaultModel) {
|
|
|
42
48
|
// Stash the user's original Anthropic env creds on the FIRST overwrite, so
|
|
43
49
|
// cleanup() can put them back instead of leaving Claude Code broken. Once the
|
|
44
50
|
// stored key is ours, replacingForeign is false — so we never clobber the backup.
|
|
45
|
-
|
|
46
|
-
|
|
51
|
+
// Guard: `existing.tokenmix` could be any JSON (a user or another tool might set
|
|
52
|
+
// it to a string/array). Only spread it when it's a real object — otherwise a
|
|
53
|
+
// string would explode into numeric-index keys and pollute settings.json.
|
|
54
|
+
const prevTmRaw = existing.tokenmix;
|
|
55
|
+
const prevTm = prevTmRaw && typeof prevTmRaw === 'object' && !Array.isArray(prevTmRaw)
|
|
56
|
+
? prevTmRaw
|
|
57
|
+
: {};
|
|
58
|
+
const alreadyBackedUp = 'claudeEnvBackup' in prevTm;
|
|
47
59
|
if (replacingForeign && !alreadyBackedUp) {
|
|
48
60
|
next.tokenmix = {
|
|
49
|
-
...
|
|
61
|
+
...prevTm,
|
|
50
62
|
claudeEnvBackup: {
|
|
51
63
|
ANTHROPIC_API_KEY: prevKey ?? null,
|
|
52
64
|
ANTHROPIC_BASE_URL: prevBase ?? null,
|
|
@@ -54,13 +66,7 @@ async function configure(apiKey, baseUrl, _defaultModel) {
|
|
|
54
66
|
};
|
|
55
67
|
}
|
|
56
68
|
await fs.ensureDir(path.dirname(settingsPath));
|
|
57
|
-
await
|
|
58
|
-
try {
|
|
59
|
-
await fs.chmod(settingsPath, 0o600);
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
// ignore
|
|
63
|
-
}
|
|
69
|
+
await writeFileAtomic(settingsPath, JSON.stringify(next, null, 2), 0o600);
|
|
64
70
|
// Claude Pro/Max users sign in via OAuth (creds live in ~/.claude/.credentials.json
|
|
65
71
|
// or the OS keychain — NOT in settings.json env). Claude Code prefers
|
|
66
72
|
// ANTHROPIC_API_KEY over the OAuth subscription, so injecting our key silently
|
|
@@ -101,7 +107,11 @@ async function cleanup() {
|
|
|
101
107
|
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
|
102
108
|
let existing;
|
|
103
109
|
try {
|
|
104
|
-
|
|
110
|
+
const parsed = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
|
|
111
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
112
|
+
return { reverted: false, configPath: settingsPath };
|
|
113
|
+
}
|
|
114
|
+
existing = parsed;
|
|
105
115
|
}
|
|
106
116
|
catch {
|
|
107
117
|
return { reverted: false };
|
|
@@ -148,13 +158,7 @@ async function cleanup() {
|
|
|
148
158
|
else
|
|
149
159
|
existing.tokenmix = tm;
|
|
150
160
|
}
|
|
151
|
-
await
|
|
152
|
-
try {
|
|
153
|
-
await fs.chmod(settingsPath, 0o600);
|
|
154
|
-
}
|
|
155
|
-
catch {
|
|
156
|
-
// ignore
|
|
157
|
-
}
|
|
161
|
+
await writeFileAtomic(settingsPath, JSON.stringify(existing, null, 2), 0o600);
|
|
158
162
|
return {
|
|
159
163
|
reverted: true,
|
|
160
164
|
configPath: settingsPath,
|
package/dist/agents/opencode.js
CHANGED
|
@@ -4,6 +4,7 @@ import fs from 'fs-extra';
|
|
|
4
4
|
import { run } from '../utils/exec.js';
|
|
5
5
|
import { npmInstallCheck, npmInstallGlobal } from './helpers.js';
|
|
6
6
|
import { v1Url } from '../config/store.js';
|
|
7
|
+
import { writeFileAtomic } from '../utils/fs.js';
|
|
7
8
|
import { t } from '../i18n/index.js';
|
|
8
9
|
const OPENCODE_BIN = 'opencode';
|
|
9
10
|
const OPENCODE_NPM_PACKAGE = 'opencode-ai';
|
|
@@ -22,11 +23,13 @@ async function configure(apiKey, baseUrl, defaultModel) {
|
|
|
22
23
|
const filePath = configPath();
|
|
23
24
|
let existing = {};
|
|
24
25
|
try {
|
|
25
|
-
const
|
|
26
|
-
|
|
26
|
+
const parsed = JSON.parse(await fs.readFile(filePath, 'utf-8'));
|
|
27
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
28
|
+
existing = parsed;
|
|
29
|
+
}
|
|
27
30
|
}
|
|
28
31
|
catch {
|
|
29
|
-
// not present yet
|
|
32
|
+
// not present yet, or corrupt — start fresh
|
|
30
33
|
}
|
|
31
34
|
// Register tokenmix as an OpenAI-compatible provider.
|
|
32
35
|
// OpenCode uses @ai-sdk/openai-compatible under the hood for custom providers.
|
|
@@ -52,7 +55,7 @@ async function configure(apiKey, baseUrl, defaultModel) {
|
|
|
52
55
|
},
|
|
53
56
|
};
|
|
54
57
|
await fs.ensureDir(path.dirname(filePath));
|
|
55
|
-
await
|
|
58
|
+
await writeFileAtomic(filePath, JSON.stringify(next, null, 2));
|
|
56
59
|
return {
|
|
57
60
|
configPath: filePath,
|
|
58
61
|
notes: [t('opencode.noteModel', { model: defaultModel }), t('opencode.noteSwitch')],
|
|
@@ -67,7 +70,11 @@ async function cleanup() {
|
|
|
67
70
|
const filePath = configPath();
|
|
68
71
|
let existing;
|
|
69
72
|
try {
|
|
70
|
-
|
|
73
|
+
const parsed = JSON.parse(await fs.readFile(filePath, 'utf-8'));
|
|
74
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
75
|
+
return { reverted: false, configPath: filePath };
|
|
76
|
+
}
|
|
77
|
+
existing = parsed;
|
|
71
78
|
}
|
|
72
79
|
catch {
|
|
73
80
|
return { reverted: false };
|
|
@@ -86,7 +93,7 @@ async function cleanup() {
|
|
|
86
93
|
}
|
|
87
94
|
if (!changed)
|
|
88
95
|
return { reverted: false, configPath: filePath };
|
|
89
|
-
await
|
|
96
|
+
await writeFileAtomic(filePath, JSON.stringify(existing, null, 2));
|
|
90
97
|
return { reverted: true, configPath: filePath };
|
|
91
98
|
}
|
|
92
99
|
export const OpenCodeAgent = {
|
package/dist/api/client.js
CHANGED
|
@@ -9,7 +9,10 @@ export class ApiError extends Error {
|
|
|
9
9
|
}
|
|
10
10
|
export function unwrap(resp) {
|
|
11
11
|
const body = resp.data;
|
|
12
|
-
|
|
12
|
+
// Normalize `code` with Number() — a backend/proxy could serialize it as a string
|
|
13
|
+
// ("1"), which a strict `typeof === 'number'` check would miss, silently swallowing
|
|
14
|
+
// a real error and passing the error body downstream as if it were data.
|
|
15
|
+
if (body && body.code != null && Number(body.code) !== 0) {
|
|
13
16
|
throw new ApiError(0, body.message || 'API error');
|
|
14
17
|
}
|
|
15
18
|
// Standard success envelope → return the inner `data`. A body with no envelope
|
|
@@ -31,7 +34,8 @@ function handleAxios(err) {
|
|
|
31
34
|
// timeout (override with TOKENMIX_TIMEOUT_MS) and automatic retry of transient
|
|
32
35
|
// TRANSPORT failures (no HTTP response) with exponential backoff. HTTP errors
|
|
33
36
|
// (4xx/5xx) are real answers and are NEVER retried.
|
|
34
|
-
|
|
37
|
+
const envTimeout = Number(process.env.TOKENMIX_TIMEOUT_MS);
|
|
38
|
+
export const REQUEST_TIMEOUT_MS = Number.isFinite(envTimeout) && envTimeout > 0 ? envTimeout : 20000;
|
|
35
39
|
const MAX_RETRIES = 2;
|
|
36
40
|
export async function withRetry(fn) {
|
|
37
41
|
let lastErr;
|
|
@@ -78,9 +82,19 @@ export async function verifyApiKey(apiKey, baseUrl) {
|
|
|
78
82
|
timeout: REQUEST_TIMEOUT_MS,
|
|
79
83
|
validateStatus: () => true,
|
|
80
84
|
}));
|
|
81
|
-
|
|
85
|
+
if (r.status === 200)
|
|
86
|
+
return true;
|
|
87
|
+
// 401/403 = a genuinely invalid/expired key → false. But 5xx/429 are server-side
|
|
88
|
+
// problems, NOT a bad key — throw so callers report "API unavailable" instead of
|
|
89
|
+
// falsely telling the user to re-create a perfectly good key.
|
|
90
|
+
if (r.status >= 500 || r.status === 429) {
|
|
91
|
+
throw new ApiError(r.status, `TokenMix API is temporarily unavailable (HTTP ${r.status}). Please try again in a moment.`);
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
82
94
|
}
|
|
83
95
|
catch (err) {
|
|
96
|
+
if (err instanceof ApiError)
|
|
97
|
+
throw err;
|
|
84
98
|
handleAxios(err);
|
|
85
99
|
}
|
|
86
100
|
}
|
|
@@ -121,18 +135,38 @@ const DEFAULT_EXPIRES_IN_S = 900;
|
|
|
121
135
|
// Throws DeviceFlowError with code ∈ {expired_token, access_denied, api_key_limit_reached, timeout} on terminal failures.
|
|
122
136
|
// onTick is called once per polling iteration with the seconds remaining (for progress display).
|
|
123
137
|
export async function pollDeviceToken(baseUrl, auth, onTick) {
|
|
124
|
-
|
|
125
|
-
|
|
138
|
+
// Clamp the server-provided interval/deadline so a malicious or buggy server can't
|
|
139
|
+
// drive a ~1ms busy-loop — Infinity/NaN/huge values slip past a bare Math.max.
|
|
140
|
+
// Interval ∈ [1s, 60s]; the overall deadline is capped at 1h.
|
|
141
|
+
const clampIntervalMs = (s) => {
|
|
142
|
+
const ms = (Number.isFinite(s) && s > 0 ? s : DEFAULT_POLL_INTERVAL_S) * 1000;
|
|
143
|
+
return Math.min(60_000, Math.max(1000, ms));
|
|
144
|
+
};
|
|
145
|
+
let intervalMs = clampIntervalMs(Number(auth.interval));
|
|
146
|
+
const expiresInRaw = Number(auth.expires_in);
|
|
147
|
+
const expiresIn = Number.isFinite(expiresInRaw) && expiresInRaw > 0
|
|
148
|
+
? Math.min(expiresInRaw, 3600)
|
|
149
|
+
: DEFAULT_EXPIRES_IN_S;
|
|
126
150
|
const deadline = Date.now() + expiresIn * 1000;
|
|
127
151
|
while (Date.now() < deadline) {
|
|
128
152
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
129
153
|
if (onTick) {
|
|
130
|
-
|
|
154
|
+
try {
|
|
155
|
+
onTick(Math.max(0, Math.round((deadline - Date.now()) / 1000)));
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// a progress callback must never be able to abort the login flow
|
|
159
|
+
}
|
|
131
160
|
}
|
|
132
161
|
try {
|
|
133
162
|
const r = await axios.post(`${baseUrl}/api/auth/device/token`, { device_code: auth.device_code }, { timeout: REQUEST_TIMEOUT_MS, validateStatus: () => true });
|
|
134
163
|
if (r.status === 200 && r.data?.code === 0) {
|
|
135
164
|
const body = r.data.data;
|
|
165
|
+
// A 200/code:0 with no usable token would otherwise be written to config as
|
|
166
|
+
// `apiKey: undefined` and look like a successful login. Treat it as an error.
|
|
167
|
+
if (!body || typeof body.access_token !== 'string') {
|
|
168
|
+
throw new DeviceFlowError('unknown', 'Server returned a success response without a token.');
|
|
169
|
+
}
|
|
136
170
|
return {
|
|
137
171
|
apiKey: body.access_token,
|
|
138
172
|
apiKeyId: body.api_key_id,
|
|
@@ -147,7 +181,7 @@ export async function pollDeviceToken(baseUrl, auth, onTick) {
|
|
|
147
181
|
case 'slow_down': {
|
|
148
182
|
const ra = parseInt(String(r.headers['retry-after'] ?? '5'), 10);
|
|
149
183
|
if (Number.isFinite(ra) && ra > 0)
|
|
150
|
-
intervalMs = ra
|
|
184
|
+
intervalMs = clampIntervalMs(ra);
|
|
151
185
|
continue;
|
|
152
186
|
}
|
|
153
187
|
case 'expired_token':
|
package/dist/config/store.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import { configDir, configFile } from './paths.js';
|
|
3
|
+
import { writeFileAtomic } from '../utils/fs.js';
|
|
4
|
+
import { logger } from '../utils/logger.js';
|
|
5
|
+
import { t } from '../i18n/index.js';
|
|
3
6
|
export const DEFAULT_API_BASE = 'https://api.tokenmix.ai';
|
|
4
7
|
// The model agents default to when the user hasn't chosen one (overridable via
|
|
5
8
|
// the TOKENMIX_DEFAULT_MODEL env var or stored config). Single source of truth.
|
|
@@ -10,24 +13,28 @@ export function v1Url(baseUrl) {
|
|
|
10
13
|
return `${baseUrl.replace(/\/+$/, '')}/v1`;
|
|
11
14
|
}
|
|
12
15
|
export async function readConfig() {
|
|
16
|
+
let raw;
|
|
17
|
+
try {
|
|
18
|
+
raw = await fs.readFile(configFile(), 'utf-8');
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return {}; // not logged in yet — the config file simply doesn't exist
|
|
22
|
+
}
|
|
13
23
|
try {
|
|
14
|
-
const raw = await fs.readFile(configFile(), 'utf-8');
|
|
15
24
|
return JSON.parse(raw);
|
|
16
25
|
}
|
|
17
26
|
catch {
|
|
27
|
+
// The file exists but is corrupt (e.g. a crash truncated it mid-write). Don't
|
|
28
|
+
// silently treat it as "logged out" — warn so the user knows to re-login.
|
|
29
|
+
logger.warn(t('config.corrupt'));
|
|
18
30
|
return {};
|
|
19
31
|
}
|
|
20
32
|
}
|
|
21
33
|
export async function writeConfig(cfg) {
|
|
22
34
|
await fs.ensureDir(configDir());
|
|
23
|
-
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
await fs.chmod(configFile(), 0o600);
|
|
27
|
-
}
|
|
28
|
-
catch {
|
|
29
|
-
// ignore on filesystems that don't support chmod
|
|
30
|
-
}
|
|
35
|
+
// Atomic write so a crash or a concurrent writer can't truncate the config to
|
|
36
|
+
// 0 bytes and silently lose the apiKey. 0600 = owner read/write only.
|
|
37
|
+
await writeFileAtomic(configFile(), JSON.stringify(cfg, null, 2), 0o600);
|
|
31
38
|
}
|
|
32
39
|
export async function updateConfig(patch) {
|
|
33
40
|
const current = await readConfig();
|
package/dist/i18n/messages.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// `{name}`-style placeholders are filled by t() at call time.
|
|
7
7
|
export const en = {
|
|
8
8
|
'common.notLoggedIn': 'Not logged in. Run `tokenmix login` first.',
|
|
9
|
+
'config.corrupt': 'Your TokenMix config file was corrupt and has been ignored — run `tokenmix login` to re-create it.',
|
|
9
10
|
// welcome screen (bare `tokenmix`, no args — onboarding for first-time users)
|
|
10
11
|
'welcome.tagline': 'one account, 160+ models, every open-source coding agent',
|
|
11
12
|
'welcome.why': 'The model you pick is the model you get — no silent swaps, billed at real usage.',
|
|
@@ -184,6 +185,7 @@ export const en = {
|
|
|
184
185
|
};
|
|
185
186
|
export const zh = {
|
|
186
187
|
'common.notLoggedIn': '未登录,请先运行 `tokenmix login`。',
|
|
188
|
+
'config.corrupt': 'TokenMix 配置文件已损坏,已忽略 —— 请运行 `tokenmix login` 重新创建。',
|
|
187
189
|
'welcome.tagline': '一个账户、160+ 模型、对接所有开源编程 agent',
|
|
188
190
|
'welcome.why': '你选的模型就是你用的模型 —— 不偷换、不降级,按真实用量计费。',
|
|
189
191
|
'welcome.start': '快速开始:',
|
|
@@ -354,6 +356,7 @@ export const zh = {
|
|
|
354
356
|
};
|
|
355
357
|
export const ja = {
|
|
356
358
|
'common.notLoggedIn': 'ログインしていません。まず `tokenmix login` を実行してください。',
|
|
359
|
+
'config.corrupt': 'TokenMix の設定ファイルが破損していたため無視しました —— `tokenmix login` を実行して再作成してください。',
|
|
357
360
|
'welcome.tagline': '1 つのアカウントで 160+ モデル、あらゆるオープンソース・コーディング agent に対応',
|
|
358
361
|
'welcome.why': '選んだモデルがそのまま使われます —— すり替えなし、実使用量で課金。',
|
|
359
362
|
'welcome.start': 'はじめに:',
|
|
@@ -509,6 +512,7 @@ export const ja = {
|
|
|
509
512
|
};
|
|
510
513
|
export const ko = {
|
|
511
514
|
'common.notLoggedIn': '로그인되어 있지 않습니다. 먼저 `tokenmix login`을 실행하세요.',
|
|
515
|
+
'config.corrupt': 'TokenMix 설정 파일이 손상되어 무시했습니다 —— `tokenmix login`을 실행해 다시 만드세요.',
|
|
512
516
|
'welcome.tagline': '하나의 계정으로 160+ 모델, 모든 오픈소스 코딩 agent 지원',
|
|
513
517
|
'welcome.why': '선택한 모델 그대로 실행됩니다 —— 몰래 바꾸지 않고 실사용량으로 과금합니다.',
|
|
514
518
|
'welcome.start': '시작하기:',
|
|
@@ -664,6 +668,7 @@ export const ko = {
|
|
|
664
668
|
};
|
|
665
669
|
export const es = {
|
|
666
670
|
'common.notLoggedIn': 'No has iniciado sesión. Ejecuta `tokenmix login` primero.',
|
|
671
|
+
'config.corrupt': 'Tu archivo de configuración de TokenMix estaba dañado y se ha ignorado: ejecuta `tokenmix login` para volver a crearlo.',
|
|
667
672
|
'welcome.tagline': 'una cuenta, 160+ modelos, todos los agentes de programación de código abierto',
|
|
668
673
|
'welcome.why': 'El modelo que eliges es el que usas: sin cambios ocultos, facturado por uso real.',
|
|
669
674
|
'welcome.start': 'Empezar:',
|
|
@@ -819,6 +824,7 @@ export const es = {
|
|
|
819
824
|
};
|
|
820
825
|
export const fr = {
|
|
821
826
|
'common.notLoggedIn': 'Non connecté. Exécutez d’abord `tokenmix login`.',
|
|
827
|
+
'config.corrupt': 'Votre fichier de configuration TokenMix était corrompu et a été ignoré — exécutez `tokenmix login` pour le recréer.',
|
|
822
828
|
'welcome.tagline': 'un seul compte, 160+ modèles, tous les agents de codage open source',
|
|
823
829
|
'welcome.why': 'Le modèle que vous choisissez est celui que vous utilisez — sans substitution, facturé à l’usage réel.',
|
|
824
830
|
'welcome.start': 'Démarrer :',
|
package/dist/utils/browser.js
CHANGED
|
@@ -2,15 +2,24 @@ import open from 'open';
|
|
|
2
2
|
import { logger } from './logger.js';
|
|
3
3
|
import { t } from '../i18n/index.js';
|
|
4
4
|
export async function openInBrowser(url) {
|
|
5
|
-
await open(url);
|
|
5
|
+
const child = await open(url);
|
|
6
|
+
// open() spawns a detached, unref'd child; if the launcher binary is missing its
|
|
7
|
+
// async 'error' event would otherwise be uncaught (→ process crash). Swallow it —
|
|
8
|
+
// callers print the URL and treat a browser failure as non-fatal.
|
|
9
|
+
child.on('error', () => { });
|
|
6
10
|
}
|
|
7
|
-
//
|
|
8
|
-
//
|
|
11
|
+
// Print the URL, then best-effort open the browser. We print UNCONDITIONALLY and
|
|
12
|
+
// first: without `{ wait: true }`, open() resolves immediately even when no browser
|
|
13
|
+
// actually launches (headless / SSH / container / no desktop), so a catch-based
|
|
14
|
+
// fallback would never fire and the user would be left staring at nothing. Printing
|
|
15
|
+
// first guarantees they can always copy the link.
|
|
9
16
|
export async function openOrHint(url) {
|
|
17
|
+
logger.info(t('browser.manual', { url }));
|
|
10
18
|
try {
|
|
11
|
-
await open(url);
|
|
19
|
+
const child = await open(url);
|
|
20
|
+
child.on('error', () => { }); // swallow async spawn errors; the URL is already shown
|
|
12
21
|
}
|
|
13
22
|
catch {
|
|
14
|
-
|
|
23
|
+
// best-effort — the URL is already shown above
|
|
15
24
|
}
|
|
16
25
|
}
|
package/dist/utils/fs.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
// Atomically write a file: write to a temp file alongside the target, then rename
|
|
3
|
+
// it over the target. Rename is atomic on the same filesystem, so a crash or a
|
|
4
|
+
// concurrent writer can never observe a half-written / 0-byte target — which is how
|
|
5
|
+
// a plain `fs.writeFile` (open with O_TRUNC) would otherwise corrupt user config
|
|
6
|
+
// (e.g. truncating ~/.claude/settings.json or our own config.json to 0 bytes).
|
|
7
|
+
export async function writeFileAtomic(filePath, data, mode) {
|
|
8
|
+
const tmp = `${filePath}.${process.pid}.tmp`;
|
|
9
|
+
try {
|
|
10
|
+
await fs.writeFile(tmp, data);
|
|
11
|
+
if (mode !== undefined) {
|
|
12
|
+
try {
|
|
13
|
+
await fs.chmod(tmp, mode);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// chmod unsupported (e.g. Windows) — non-fatal, keep going.
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
await fs.rename(tmp, filePath);
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
// Best-effort cleanup of the temp file if the rename never happened.
|
|
23
|
+
try {
|
|
24
|
+
await fs.remove(tmp);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// ignore
|
|
28
|
+
}
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
}
|