pennyrouter 0.1.9 → 0.1.11
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/package.json +1 -1
- package/src/cli.js +100 -22
- package/src/harnesses/claude-code.js +62 -3
- package/src/session.js +41 -0
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -3,7 +3,11 @@ import { emitKeypressEvents } from "node:readline";
|
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
4
|
import { stdin as input, stdout as output } from "node:process";
|
|
5
5
|
import { aiderHarness } from "./harnesses/aider.js";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
claudeCodeHarness,
|
|
8
|
+
readClaudeCodeGatewayAuth,
|
|
9
|
+
readClaudeCodePennyRouterKey,
|
|
10
|
+
} from "./harnesses/claude-code.js";
|
|
7
11
|
import { clineHarness } from "./harnesses/cline.js";
|
|
8
12
|
import { codeGptHarness } from "./harnesses/codegpt.js";
|
|
9
13
|
import { codexHarness } from "./harnesses/codex.js";
|
|
@@ -13,9 +17,11 @@ import { zedHarness } from "./harnesses/zed.js";
|
|
|
13
17
|
import {
|
|
14
18
|
APP_URL,
|
|
15
19
|
createCliSession,
|
|
20
|
+
forgetAnthropicToken,
|
|
16
21
|
maskKey,
|
|
17
22
|
pollForKey,
|
|
18
23
|
sleep,
|
|
24
|
+
storeAnthropicToken,
|
|
19
25
|
} from "./session.js";
|
|
20
26
|
import {
|
|
21
27
|
backupFile,
|
|
@@ -114,6 +120,8 @@ function parseArgs(argv) {
|
|
|
114
120
|
// `--local=<url>` overrides the port/host.
|
|
115
121
|
else if (arg === "--local") flags.gatewayBaseUrl = "http://localhost:8400";
|
|
116
122
|
else if (arg.startsWith("--local=")) flags.gatewayBaseUrl = arg.slice("--local=".length);
|
|
123
|
+
else if (arg === "--forget") flags.forget = true;
|
|
124
|
+
else if (arg === "--forget-token") flags.forgetToken = true;
|
|
117
125
|
else if (!arg.startsWith("-")) flags.args.push(arg);
|
|
118
126
|
else throw new Error(`Unknown option "${arg}". Run "pennyrouter help".`);
|
|
119
127
|
}
|
|
@@ -127,6 +135,39 @@ async function authProvider(flags) {
|
|
|
127
135
|
throw new Error(`Unknown auth provider "${provider}". Try "pennyrouter auth anthropic".`);
|
|
128
136
|
}
|
|
129
137
|
|
|
138
|
+
if (flags.forget) {
|
|
139
|
+
const pennyKey = flags.pennyKey || await readClaudeCodePennyRouterKey();
|
|
140
|
+
if (!pennyKey) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
"No PennyRouter key found in Claude Code settings. Pass `--penny-key pr-...`.",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL || "https://api.pennyrouter.com";
|
|
146
|
+
if (flags.dryRun) {
|
|
147
|
+
console.log(`Will delete stored Anthropic token from gateway: ${gatewayBaseUrl}`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const ok = await forgetAnthropicToken({ gatewayBaseUrl, apiKey: pennyKey });
|
|
151
|
+
if (ok) {
|
|
152
|
+
console.log("Successfully forgot stored Anthropic token on server.");
|
|
153
|
+
} else {
|
|
154
|
+
console.log("Failed to forget stored Anthropic token on server.");
|
|
155
|
+
}
|
|
156
|
+
const manifest = await loadManifest();
|
|
157
|
+
let updated = false;
|
|
158
|
+
for (const record of (manifest.installations || [])) {
|
|
159
|
+
if (record.harness === "claude-code" && record.anthropic_token_stored) {
|
|
160
|
+
record.anthropic_token_stored = false;
|
|
161
|
+
record.anthropic_oauth = false;
|
|
162
|
+
updated = true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (updated) {
|
|
166
|
+
await saveManifest(manifest);
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
130
171
|
if (flags.dryRun) {
|
|
131
172
|
console.log("Will configure Claude Code for PennyRouter + Anthropic OAuth dual auth.");
|
|
132
173
|
console.log("Dry run only. No files changed.");
|
|
@@ -149,22 +190,22 @@ async function authProvider(flags) {
|
|
|
149
190
|
}
|
|
150
191
|
|
|
151
192
|
const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL || "https://api.pennyrouter.com";
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
});
|
|
193
|
+
// Store the token encrypted server-side, then write a pr-key-only config.
|
|
194
|
+
await storeAnthropicToken({ gatewayBaseUrl, apiKey: pennyKey, token });
|
|
195
|
+
const plan = await claudeCodeHarness.planInstall();
|
|
196
|
+
const record = await claudeCodeHarness.installStoredToken({ apiKey: pennyKey, gatewayBaseUrl, plan });
|
|
157
197
|
|
|
158
198
|
const manifest = await loadManifest();
|
|
159
199
|
upsertManifestRecord(manifest, {
|
|
160
200
|
...record,
|
|
161
201
|
anthropic_oauth: true,
|
|
202
|
+
anthropic_token_stored: true,
|
|
162
203
|
token_source: tokenResult.source,
|
|
163
204
|
});
|
|
164
205
|
await saveManifest(manifest);
|
|
165
206
|
|
|
166
|
-
console.log("Configured Claude Code for PennyRouter +
|
|
167
|
-
console.log("
|
|
207
|
+
console.log("Configured Claude Code for PennyRouter + your Claude subscription.");
|
|
208
|
+
console.log("Your Claude token is stored encrypted; Claude Code holds only your PennyRouter key.");
|
|
168
209
|
console.log("If Claude auth expires, refresh the token source and run `pennyrouter auth anthropic` again.");
|
|
169
210
|
console.log(`Config: ${record.config_path}`);
|
|
170
211
|
}
|
|
@@ -196,14 +237,13 @@ function readAnthropicOAuthToken(flags = {}) {
|
|
|
196
237
|
function normalizeAnthropicToken(value) {
|
|
197
238
|
let text = String(value || "").trim();
|
|
198
239
|
if (!text) return "";
|
|
199
|
-
|
|
200
|
-
text = line.replace(/^export\s+/, "");
|
|
240
|
+
text = text.replace(/^export\s+/, "");
|
|
201
241
|
const match = text.match(/^ANTHROPIC_AUTH_TOKEN=(.*)$/);
|
|
202
242
|
if (match) text = match[1].trim();
|
|
203
243
|
if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
|
|
204
244
|
text = text.slice(1, -1);
|
|
205
245
|
}
|
|
206
|
-
return text.trim();
|
|
246
|
+
return text.replace(/\s+/g, "").trim();
|
|
207
247
|
}
|
|
208
248
|
|
|
209
249
|
async function install(flags) {
|
|
@@ -257,22 +297,28 @@ async function install(flags) {
|
|
|
257
297
|
await runHarnessJob(failures, harness.id, "install", async () => {
|
|
258
298
|
const plan = await harness.planInstall();
|
|
259
299
|
const gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
300
|
+
let record;
|
|
301
|
+
if (harness.id === "claude-code" && claudeAnthropicAuth) {
|
|
302
|
+
// Store the Claude setup-token encrypted server-side, then write a config
|
|
303
|
+
// that carries ONLY the pr- key. Nothing Anthropic-flavored on disk, and no
|
|
304
|
+
// dual-credential warnings from Claude Code.
|
|
305
|
+
await storeAnthropicToken({
|
|
264
306
|
gatewayBaseUrl,
|
|
265
|
-
|
|
266
|
-
|
|
307
|
+
apiKey: claim.api_key,
|
|
308
|
+
token: claudeAnthropicAuth.token,
|
|
309
|
+
});
|
|
310
|
+
record = await harness.installStoredToken({ apiKey: claim.api_key, gatewayBaseUrl, plan });
|
|
311
|
+
record.anthropic_oauth = true;
|
|
312
|
+
record.anthropic_token_stored = true;
|
|
313
|
+
record.token_source = claudeAnthropicAuth.source;
|
|
314
|
+
} else {
|
|
315
|
+
record = await harness.install({
|
|
267
316
|
apiKey: claim.api_key,
|
|
268
317
|
// An explicit --local / --gateway-base-url overrides the URL the server hands back
|
|
269
318
|
// (which points at prod) — so a --local install serves through the local gateway.
|
|
270
319
|
gatewayBaseUrl,
|
|
271
320
|
plan,
|
|
272
321
|
});
|
|
273
|
-
if (harness.id === "claude-code" && claudeAnthropicAuth) {
|
|
274
|
-
record.anthropic_oauth = true;
|
|
275
|
-
record.token_source = claudeAnthropicAuth.source;
|
|
276
322
|
}
|
|
277
323
|
records.push(record);
|
|
278
324
|
upsertManifestRecord(manifest, record);
|
|
@@ -482,6 +528,7 @@ async function uninstall(flags) {
|
|
|
482
528
|
|
|
483
529
|
const kept = [];
|
|
484
530
|
const failures = [];
|
|
531
|
+
let anthropicTokenNotice = false;
|
|
485
532
|
for (const record of records) {
|
|
486
533
|
if (!selectedIds.includes(record.harness)) {
|
|
487
534
|
kept.push(record);
|
|
@@ -496,6 +543,11 @@ async function uninstall(flags) {
|
|
|
496
543
|
}
|
|
497
544
|
|
|
498
545
|
const ok = await runHarnessJob(failures, record.harness, "uninstall", async () => {
|
|
546
|
+
if (flags.forgetToken) {
|
|
547
|
+
await forgetStoredAnthropicToken(record);
|
|
548
|
+
} else if (record.anthropic_token_stored) {
|
|
549
|
+
anthropicTokenNotice = true;
|
|
550
|
+
}
|
|
499
551
|
await harness.uninstall(record);
|
|
500
552
|
console.log(`Removed ${record.harness}.`);
|
|
501
553
|
});
|
|
@@ -510,11 +562,22 @@ async function uninstall(flags) {
|
|
|
510
562
|
continue;
|
|
511
563
|
}
|
|
512
564
|
await runHarnessJob(failures, record.harness, "uninstall", async () => {
|
|
565
|
+
if (flags.forgetToken) {
|
|
566
|
+
await forgetStoredAnthropicToken(record);
|
|
567
|
+
} else if (record.anthropic_token_stored) {
|
|
568
|
+
anthropicTokenNotice = true;
|
|
569
|
+
}
|
|
513
570
|
await harness.uninstall(record);
|
|
514
571
|
console.log(`Removed ${record.harness}.`);
|
|
515
572
|
});
|
|
516
573
|
}
|
|
517
574
|
|
|
575
|
+
if (anthropicTokenNotice) {
|
|
576
|
+
console.log("");
|
|
577
|
+
console.log("Note: Your stored Anthropic token remains on the server for other machines.");
|
|
578
|
+
console.log("To delete it from the server, run: pennyrouter auth anthropic --forget");
|
|
579
|
+
}
|
|
580
|
+
|
|
518
581
|
manifest.installations = kept;
|
|
519
582
|
await saveManifest(manifest);
|
|
520
583
|
reportFailures(failures);
|
|
@@ -821,6 +884,21 @@ function formatError(error) {
|
|
|
821
884
|
return error?.message || String(error);
|
|
822
885
|
}
|
|
823
886
|
|
|
887
|
+
// Best-effort: tell the gateway to forget a stored Claude token when uninstalling a
|
|
888
|
+
// claude-code integration that stored one. Reads the gateway URL + pr- key from the
|
|
889
|
+
// live config BEFORE harness.uninstall strips them. Never throws — a local uninstall
|
|
890
|
+
// must not be blocked by a network hiccup; the record is going away regardless.
|
|
891
|
+
async function forgetStoredAnthropicToken(record) {
|
|
892
|
+
if (record.harness !== "claude-code" || !record.anthropic_token_stored) return;
|
|
893
|
+
try {
|
|
894
|
+
const { gatewayBaseUrl, apiKey } = await readClaudeCodeGatewayAuth();
|
|
895
|
+
if (!gatewayBaseUrl || !apiKey) return;
|
|
896
|
+
await forgetAnthropicToken({ gatewayBaseUrl, apiKey });
|
|
897
|
+
} catch {
|
|
898
|
+
// swallow — see note above
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
824
902
|
async function confirm(question) {
|
|
825
903
|
const rl = createInterface({ input, output });
|
|
826
904
|
const answer = await rl.question(`${question} [y/N] `);
|
|
@@ -901,10 +979,10 @@ function printHelp() {
|
|
|
901
979
|
|
|
902
980
|
Usage:
|
|
903
981
|
pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--existing-account] [--anthropic-auth|--no-anthropic-auth] [--local[=URL]]
|
|
904
|
-
pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL]
|
|
982
|
+
pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
|
|
905
983
|
pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
906
984
|
pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
907
|
-
pennyrouter uninstall [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
985
|
+
pennyrouter uninstall [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--forget-token]
|
|
908
986
|
pennyrouter status
|
|
909
987
|
|
|
910
988
|
Examples:
|
|
@@ -77,6 +77,45 @@ export const claudeCodeHarness = {
|
|
|
77
77
|
};
|
|
78
78
|
},
|
|
79
79
|
|
|
80
|
+
// Stored-token mode: the Anthropic setup-token was already POSTed to the gateway
|
|
81
|
+
// and encrypted against the account (see cli.js storeAnthropicToken). Claude Code's
|
|
82
|
+
// config therefore carries ONLY the pr- key + base URL — no ANTHROPIC_AUTH_TOKEN.
|
|
83
|
+
// The gateway pulls the stored token per request. This avoids the plaintext token
|
|
84
|
+
// on disk AND the dual-credential (api key + auth token) warnings Claude Code emits.
|
|
85
|
+
async installStoredToken({ apiKey, gatewayBaseUrl, plan }) {
|
|
86
|
+
const backupPath = await backupFile(CONFIG_PATH, "claude-code");
|
|
87
|
+
const current = (await readJsonFile(CONFIG_PATH, null)) || {};
|
|
88
|
+
current.env ||= {};
|
|
89
|
+
current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
|
|
90
|
+
current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
|
91
|
+
// Single credential only: no stale api key, no separate Anthropic bearer.
|
|
92
|
+
delete current.env.ANTHROPIC_API_KEY;
|
|
93
|
+
current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
|
|
94
|
+
current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
|
|
95
|
+
current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
|
|
96
|
+
current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.custom;
|
|
97
|
+
|
|
98
|
+
await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
harness: "claude-code",
|
|
102
|
+
config_path: compactHome(plan.configPath),
|
|
103
|
+
backup_path: backupPath ? compactHome(backupPath) : null,
|
|
104
|
+
installed_at: new Date().toISOString(),
|
|
105
|
+
restart: "restart Claude Code",
|
|
106
|
+
changes: {
|
|
107
|
+
env_keys: [
|
|
108
|
+
"ANTHROPIC_BASE_URL",
|
|
109
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
110
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
111
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
112
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
113
|
+
"ANTHROPIC_DEFAULT_FABLE_MODEL",
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
|
|
80
119
|
async installAnthropicOAuth({ pennyKey, anthropicAuthToken, gatewayBaseUrl }) {
|
|
81
120
|
const backupPath = await backupFile(CONFIG_PATH, "claude-code-anthropic-auth");
|
|
82
121
|
const current = (await readJsonFile(CONFIG_PATH, null)) || {};
|
|
@@ -115,13 +154,22 @@ export const claudeCodeHarness = {
|
|
|
115
154
|
async uninstall(record) {
|
|
116
155
|
const current = (await readJsonFile(record.config_path, null)) || {};
|
|
117
156
|
if (hasPennyRouterEnv(current.env)) {
|
|
157
|
+
// If the base URL points at PennyRouter, every auth value in env was written
|
|
158
|
+
// by us for our gateway (a Penny pr- key and/or an Anthropic OAuth bearer we
|
|
159
|
+
// forward upstream) — clear them all. We can't rely on the token's prefix:
|
|
160
|
+
// in OAuth mode ANTHROPIC_AUTH_TOKEN holds an Anthropic sk-ant-oat... token,
|
|
161
|
+
// not a pr- value, and the manifest's anthropic_oauth flag may be absent on a
|
|
162
|
+
// record synthesized from a detected config. Keying off our base URL is what
|
|
163
|
+
// makes cleanup reliable regardless of which auth mode was installed.
|
|
164
|
+
const ourGateway = isPennyRouterBaseUrl(current.env.ANTHROPIC_BASE_URL);
|
|
118
165
|
delete current.env.ANTHROPIC_BASE_URL;
|
|
119
166
|
const tok = current.env.ANTHROPIC_AUTH_TOKEN;
|
|
120
|
-
if (typeof tok === "string" && (tok.startsWith("pr-") || record.anthropic_oauth)) {
|
|
167
|
+
if (typeof tok === "string" && (ourGateway || tok.startsWith("pr-") || record.anthropic_oauth)) {
|
|
121
168
|
delete current.env.ANTHROPIC_AUTH_TOKEN;
|
|
122
169
|
}
|
|
123
|
-
//
|
|
124
|
-
|
|
170
|
+
// ANTHROPIC_API_KEY carries the Penny key in OAuth dual-auth mode, or a pr-
|
|
171
|
+
// key in legacy installs. Clear it when it's ours by base URL or by prefix.
|
|
172
|
+
if (typeof current.env.ANTHROPIC_API_KEY === "string" && (ourGateway || current.env.ANTHROPIC_API_KEY.startsWith("pr-"))) {
|
|
125
173
|
delete current.env.ANTHROPIC_API_KEY;
|
|
126
174
|
}
|
|
127
175
|
for (const k of [
|
|
@@ -188,3 +236,14 @@ export async function readClaudeCodePennyRouterKey() {
|
|
|
188
236
|
}
|
|
189
237
|
return null;
|
|
190
238
|
}
|
|
239
|
+
|
|
240
|
+
// The PennyRouter gateway URL + pr- key currently in Claude Code's config, used to
|
|
241
|
+
// call the gateway's "forget my Claude token" endpoint on uninstall. Read before
|
|
242
|
+
// uninstall strips these keys. Either field may be null if not present.
|
|
243
|
+
export async function readClaudeCodeGatewayAuth() {
|
|
244
|
+
const current = (await readJsonFile(CONFIG_PATH, null)) || {};
|
|
245
|
+
const env = current.env || {};
|
|
246
|
+
const gatewayBaseUrl = isPennyRouterBaseUrl(env.ANTHROPIC_BASE_URL) ? env.ANTHROPIC_BASE_URL : null;
|
|
247
|
+
const apiKey = await readClaudeCodePennyRouterKey();
|
|
248
|
+
return { gatewayBaseUrl, apiKey };
|
|
249
|
+
}
|
package/src/session.js
CHANGED
|
@@ -46,6 +46,47 @@ export function sleep(ms) {
|
|
|
46
46
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// Encrypt-and-store an Anthropic setup-token against the account server-side, so the
|
|
50
|
+
// pr- key alone unlocks Claude and no token is written to the harness config. The
|
|
51
|
+
// gateway (not the web app) owns this: it holds the pr- key auth and the encryption.
|
|
52
|
+
export async function storeAnthropicToken({ gatewayBaseUrl, apiKey, token }) {
|
|
53
|
+
const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/upstream-credentials`;
|
|
54
|
+
const response = await fetch(url, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: {
|
|
57
|
+
"content-type": "application/json",
|
|
58
|
+
authorization: `Bearer ${apiKey}`,
|
|
59
|
+
},
|
|
60
|
+
body: JSON.stringify({ provider: "anthropic", token }),
|
|
61
|
+
});
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
let detail = "";
|
|
64
|
+
try {
|
|
65
|
+
detail = (await response.json())?.detail || "";
|
|
66
|
+
} catch {
|
|
67
|
+
// non-JSON body; fall through to status-only message
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`Storing Claude token failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}`);
|
|
70
|
+
}
|
|
71
|
+
return response.json();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Best-effort "forget my Claude token" on uninstall. Never throws — a cleanup that
|
|
75
|
+
// can't reach the network shouldn't block removing the local integration.
|
|
76
|
+
export async function forgetAnthropicToken({ gatewayBaseUrl, apiKey }) {
|
|
77
|
+
if (!gatewayBaseUrl || !apiKey) return false;
|
|
78
|
+
try {
|
|
79
|
+
const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/upstream-credentials/anthropic`;
|
|
80
|
+
const response = await fetch(url, {
|
|
81
|
+
method: "DELETE",
|
|
82
|
+
headers: { authorization: `Bearer ${apiKey}` },
|
|
83
|
+
});
|
|
84
|
+
return response.ok;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
49
90
|
function platformName() {
|
|
50
91
|
if (process.platform === "darwin") return "Mac";
|
|
51
92
|
if (process.platform === "win32") return "Windows";
|