@tiny-fish/cli 0.52.4-next.429 → 0.52.4-next.431
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/lib/dotenv-file.d.ts +6 -0
- package/dist/lib/dotenv-file.js +117 -0
- package/dist/lib/hermes-env.d.ts +0 -1
- package/dist/lib/hermes-env.js +6 -111
- package/package.json +1 -1
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function dotenvMarkerHead(client: string): string;
|
|
2
|
+
export declare function writeDotenvKey(envPath: string, varName: string, key: string, markerHead: string): void;
|
|
3
|
+
export declare function readDotenvKey(envPath: string, varName: string): string | undefined;
|
|
4
|
+
/** Restores the exact line writeDotenvKey replaces, quotes and comment included. */
|
|
5
|
+
export declare function captureDotenvKeyRestore(envPath: string, varName: string, markerHead: string): () => void;
|
|
6
|
+
export declare function removeDotenvKey(envPath: string, varName: string, markerHead: string): boolean;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
const MARKER_TAIL = '# tinyfish-cli: end';
|
|
5
|
+
// Keys are `sk-tinyfish-<alnum>`; refusing anything else beats shipping unreachable escaping.
|
|
6
|
+
const BARE_SAFE = /^[A-Za-z0-9._\-/:+=]+$/;
|
|
7
|
+
export function dotenvMarkerHead(client) {
|
|
8
|
+
return `# tinyfish-cli: TinyFish MCP key, managed by \`tinyfish connect ${client}\``;
|
|
9
|
+
}
|
|
10
|
+
/** Mirrors Hermes' _env_line_defines_key: load_env() parses the export form too. */
|
|
11
|
+
function definesKey(line, varName) {
|
|
12
|
+
const stripped = line.trim().replace(/^export\s+/, '');
|
|
13
|
+
return stripped.startsWith(`${varName}=`);
|
|
14
|
+
}
|
|
15
|
+
// A leading BOM would otherwise ride into the first key's name.
|
|
16
|
+
function readLines(envPath) {
|
|
17
|
+
if (!fs.existsSync(envPath))
|
|
18
|
+
return [];
|
|
19
|
+
const raw = fs.readFileSync(envPath);
|
|
20
|
+
// A valid UTF-8 .env has no NUL; every wide encoding does.
|
|
21
|
+
if (raw.includes(0)) {
|
|
22
|
+
throw new Error(`${envPath} contains NUL bytes, so it is a wide encoding or corrupt; TinyFish can only ` +
|
|
23
|
+
`edit a UTF-8 .env. Re-save it as UTF-8 and retry.`);
|
|
24
|
+
}
|
|
25
|
+
return raw
|
|
26
|
+
.toString('utf8')
|
|
27
|
+
.replace(/^\uFEFF/, '')
|
|
28
|
+
.split(/(?<=\n)/);
|
|
29
|
+
}
|
|
30
|
+
// Hermes preserves the original mode so Docker volume mounts survive (config.py:3921-3929).
|
|
31
|
+
function writeAtomic(envPath, lines) {
|
|
32
|
+
let mode = 0o600;
|
|
33
|
+
try {
|
|
34
|
+
mode = fs.statSync(envPath).mode & 0o777;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// No existing file: 0600 is the right default for credentials.
|
|
38
|
+
}
|
|
39
|
+
const tmp = `${envPath}.${randomUUID()}.tmp`;
|
|
40
|
+
try {
|
|
41
|
+
fs.writeFileSync(tmp, lines.join(''), { encoding: 'utf8', mode });
|
|
42
|
+
// open() applies mode through umask; chmod bypasses it, preserving bits.
|
|
43
|
+
fs.chmodSync(tmp, mode);
|
|
44
|
+
fs.renameSync(tmp, envPath);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
fs.rmSync(tmp, { force: true });
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function writeDotenvKey(envPath, varName, key, markerHead) {
|
|
52
|
+
const clean = key.replace(/[\r\n]/g, '').trim();
|
|
53
|
+
// An empty key reads as "bad characters" without its own branch.
|
|
54
|
+
if (!clean)
|
|
55
|
+
throw new Error(`TinyFish API key is empty; nothing to write to ${envPath}`);
|
|
56
|
+
if (!BARE_SAFE.test(clean)) {
|
|
57
|
+
throw new Error('TinyFish API key contains characters that cannot be written to a .env file');
|
|
58
|
+
}
|
|
59
|
+
fs.mkdirSync(path.dirname(envPath), { recursive: true });
|
|
60
|
+
const lines = readLines(envPath);
|
|
61
|
+
const assignment = `${varName}=${clean}\n`;
|
|
62
|
+
const index = lines.findIndex((line) => definesKey(line, varName));
|
|
63
|
+
if (index < 0) {
|
|
64
|
+
if (lines.length && !lines[lines.length - 1].endsWith('\n'))
|
|
65
|
+
lines[lines.length - 1] += '\n';
|
|
66
|
+
lines.push(`${markerHead}\n`, assignment, `${MARKER_TAIL}\n`);
|
|
67
|
+
writeAtomic(envPath, lines);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
lines[index] = assignment;
|
|
71
|
+
// Dotenv loaders are last-wins; a surviving duplicate would beat this value.
|
|
72
|
+
writeAtomic(envPath, lines.filter((line, i) => i === index || !definesKey(line, varName)));
|
|
73
|
+
}
|
|
74
|
+
export function readDotenvKey(envPath, varName) {
|
|
75
|
+
const line = readLines(envPath).find((l) => definesKey(l, varName));
|
|
76
|
+
if (!line)
|
|
77
|
+
return undefined;
|
|
78
|
+
// We never write quoted values, so a quoted one isn't ours.
|
|
79
|
+
const raw = line
|
|
80
|
+
.trim()
|
|
81
|
+
.replace(/^export\s+/, '')
|
|
82
|
+
.slice(varName.length + 1)
|
|
83
|
+
.trim();
|
|
84
|
+
return BARE_SAFE.test(raw) ? raw : undefined;
|
|
85
|
+
}
|
|
86
|
+
/** Restores the exact line writeDotenvKey replaces, quotes and comment included. */
|
|
87
|
+
export function captureDotenvKeyRestore(envPath, varName, markerHead) {
|
|
88
|
+
// Raw, not readDotenvKey: its parse drops quoted and comment-trailed values.
|
|
89
|
+
const previous = readLines(envPath).find((line) => definesKey(line, varName));
|
|
90
|
+
return () => {
|
|
91
|
+
if (previous === undefined) {
|
|
92
|
+
removeDotenvKey(envPath, varName, markerHead);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
let restored = false;
|
|
96
|
+
const lines = readLines(envPath).flatMap((line) => {
|
|
97
|
+
if (!definesKey(line, varName))
|
|
98
|
+
return [line];
|
|
99
|
+
if (restored)
|
|
100
|
+
return [];
|
|
101
|
+
restored = true;
|
|
102
|
+
return [previous];
|
|
103
|
+
});
|
|
104
|
+
writeAtomic(envPath, restored ? lines : [...lines, previous]);
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export function removeDotenvKey(envPath, varName, markerHead) {
|
|
108
|
+
const lines = readLines(envPath);
|
|
109
|
+
if (!lines.length)
|
|
110
|
+
return false;
|
|
111
|
+
const isMarker = (line) => line.trim() === markerHead || line.trim() === MARKER_TAIL;
|
|
112
|
+
const kept = lines.filter((line) => !definesKey(line, varName) && !isMarker(line));
|
|
113
|
+
if (kept.length === lines.length)
|
|
114
|
+
return false;
|
|
115
|
+
writeAtomic(envPath, kept);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
package/dist/lib/hermes-env.d.ts
CHANGED
|
@@ -14,6 +14,5 @@ export declare function hermesEnvPath(home: string): string;
|
|
|
14
14
|
export declare function writeHermesKey(home: string, key: string): void;
|
|
15
15
|
/** Doctor needs this to tell key auth from OAuth. */
|
|
16
16
|
export declare function readHermesKey(home: string): string | undefined;
|
|
17
|
-
/** Restores the exact line writeHermesKey replaces, quotes and comment included. */
|
|
18
17
|
export declare function captureHermesKeyRestore(home: string): () => void;
|
|
19
18
|
export declare function removeHermesKey(home: string): boolean;
|
package/dist/lib/hermes-env.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import fs from 'node:fs';
|
|
4
|
-
import { randomUUID } from 'node:crypto';
|
|
5
3
|
import spawn from 'cross-spawn';
|
|
6
4
|
import { HERMES_COLD_START_TIMEOUT_MS } from './constants.js';
|
|
7
5
|
import { throwIfInterrupted } from './connect-runtime.js';
|
|
8
6
|
import { noColourEnv, sanitizeLine } from './output.js';
|
|
7
|
+
import { captureDotenvKeyRestore, dotenvMarkerHead, readDotenvKey, removeDotenvKey, writeDotenvKey, } from './dotenv-file.js';
|
|
9
8
|
// display_hermes_home() abbreviates under $HOME, so the value is not always absolute.
|
|
10
9
|
const HERMES_HOME_LINE = /^\s*hermes_home:\s*(\S.*?)\s*$/m;
|
|
11
10
|
// Consistency, not speed: doctor's second probe timing out would downgrade
|
|
@@ -46,125 +45,21 @@ function probeHermesHome() {
|
|
|
46
45
|
return path.isAbsolute(expanded) ? expanded : { reason: 'relative' };
|
|
47
46
|
}
|
|
48
47
|
export const HERMES_KEY_VAR = 'MCP_TINYFISH_API_KEY';
|
|
48
|
+
const MARKER_HEAD = dotenvMarkerHead('hermes');
|
|
49
49
|
/** Named in every message about the seeded key, so no caller rebuilds the path. */
|
|
50
50
|
export function hermesEnvPath(home) {
|
|
51
51
|
return path.join(home, '.env');
|
|
52
52
|
}
|
|
53
|
-
const MARKER_HEAD = `# tinyfish-cli: TinyFish MCP key, managed by \`tinyfish connect hermes\``;
|
|
54
|
-
const MARKER_TAIL = '# tinyfish-cli: end';
|
|
55
|
-
// Keys are `sk-tinyfish-<alnum>`; refusing anything else beats shipping unreachable escaping.
|
|
56
|
-
const BARE_SAFE = /^[A-Za-z0-9._\-/:+=]+$/;
|
|
57
|
-
/** Mirrors Hermes' _env_line_defines_key: load_env() parses the export form too. */
|
|
58
|
-
function definesKey(line) {
|
|
59
|
-
const stripped = line.trim().replace(/^export\s+/, '');
|
|
60
|
-
return stripped.startsWith(`${HERMES_KEY_VAR}=`);
|
|
61
|
-
}
|
|
62
|
-
function isMarker(line) {
|
|
63
|
-
return line.trim() === MARKER_HEAD || line.trim() === MARKER_TAIL;
|
|
64
|
-
}
|
|
65
|
-
// A leading BOM would otherwise ride into the first key's name.
|
|
66
|
-
function readLines(envPath) {
|
|
67
|
-
if (!fs.existsSync(envPath))
|
|
68
|
-
return [];
|
|
69
|
-
const raw = fs.readFileSync(envPath);
|
|
70
|
-
// A valid UTF-8 .env has no NUL; every wide encoding does.
|
|
71
|
-
if (raw.includes(0)) {
|
|
72
|
-
throw new Error(`${envPath} contains NUL bytes, so it is a wide encoding or corrupt; TinyFish can only ` +
|
|
73
|
-
`edit a UTF-8 .env. Re-save it as UTF-8 and retry.`);
|
|
74
|
-
}
|
|
75
|
-
return raw
|
|
76
|
-
.toString('utf8')
|
|
77
|
-
.replace(/^\uFEFF/, '')
|
|
78
|
-
.split(/(?<=\n)/);
|
|
79
|
-
}
|
|
80
|
-
// Hermes preserves the original mode so Docker volume mounts survive (config.py:3921-3929).
|
|
81
|
-
function writeAtomic(envPath, lines) {
|
|
82
|
-
let mode = 0o600;
|
|
83
|
-
try {
|
|
84
|
-
mode = fs.statSync(envPath).mode & 0o777;
|
|
85
|
-
}
|
|
86
|
-
catch {
|
|
87
|
-
// No existing file: 0600 is the right default for credentials.
|
|
88
|
-
}
|
|
89
|
-
const tmp = `${envPath}.${randomUUID()}.tmp`;
|
|
90
|
-
try {
|
|
91
|
-
fs.writeFileSync(tmp, lines.join(''), { encoding: 'utf8', mode });
|
|
92
|
-
// open() applies mode through umask; chmod bypasses it, preserving bits.
|
|
93
|
-
fs.chmodSync(tmp, mode);
|
|
94
|
-
fs.renameSync(tmp, envPath);
|
|
95
|
-
}
|
|
96
|
-
catch (error) {
|
|
97
|
-
fs.rmSync(tmp, { force: true });
|
|
98
|
-
throw error;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
53
|
export function writeHermesKey(home, key) {
|
|
102
|
-
|
|
103
|
-
// An empty key reads as "bad characters" without its own branch.
|
|
104
|
-
if (!clean)
|
|
105
|
-
throw new Error("TinyFish API key is empty; nothing to write to Hermes' .env");
|
|
106
|
-
if (!BARE_SAFE.test(clean)) {
|
|
107
|
-
throw new Error('TinyFish API key contains characters that cannot be written to a .env file');
|
|
108
|
-
}
|
|
109
|
-
const envPath = hermesEnvPath(home);
|
|
110
|
-
fs.mkdirSync(home, { recursive: true });
|
|
111
|
-
const lines = readLines(envPath);
|
|
112
|
-
const assignment = `${HERMES_KEY_VAR}=${clean}\n`;
|
|
113
|
-
const index = lines.findIndex(definesKey);
|
|
114
|
-
if (index < 0) {
|
|
115
|
-
if (lines.length && !lines[lines.length - 1].endsWith('\n'))
|
|
116
|
-
lines[lines.length - 1] += '\n';
|
|
117
|
-
lines.push(`${MARKER_HEAD}\n`, assignment, `${MARKER_TAIL}\n`);
|
|
118
|
-
writeAtomic(envPath, lines);
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
lines[index] = assignment;
|
|
122
|
-
// load_env() is last-wins; a surviving duplicate would beat this value.
|
|
123
|
-
writeAtomic(envPath, lines.filter((line, i) => i === index || !definesKey(line)));
|
|
54
|
+
writeDotenvKey(hermesEnvPath(home), HERMES_KEY_VAR, key, MARKER_HEAD);
|
|
124
55
|
}
|
|
125
56
|
/** Doctor needs this to tell key auth from OAuth. */
|
|
126
57
|
export function readHermesKey(home) {
|
|
127
|
-
|
|
128
|
-
if (!line)
|
|
129
|
-
return undefined;
|
|
130
|
-
// We never write quoted values, so a quoted one isn't ours.
|
|
131
|
-
const raw = line
|
|
132
|
-
.trim()
|
|
133
|
-
.replace(/^export\s+/, '')
|
|
134
|
-
.slice(HERMES_KEY_VAR.length + 1)
|
|
135
|
-
.trim();
|
|
136
|
-
return BARE_SAFE.test(raw) ? raw : undefined;
|
|
58
|
+
return readDotenvKey(hermesEnvPath(home), HERMES_KEY_VAR);
|
|
137
59
|
}
|
|
138
|
-
/** Restores the exact line writeHermesKey replaces, quotes and comment included. */
|
|
139
60
|
export function captureHermesKeyRestore(home) {
|
|
140
|
-
|
|
141
|
-
const previous = readLines(hermesEnvPath(home)).find(definesKey);
|
|
142
|
-
return () => {
|
|
143
|
-
if (previous === undefined) {
|
|
144
|
-
removeHermesKey(home);
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
const envPath = hermesEnvPath(home);
|
|
148
|
-
let restored = false;
|
|
149
|
-
const lines = readLines(envPath).flatMap((line) => {
|
|
150
|
-
if (!definesKey(line))
|
|
151
|
-
return [line];
|
|
152
|
-
if (restored)
|
|
153
|
-
return [];
|
|
154
|
-
restored = true;
|
|
155
|
-
return [previous];
|
|
156
|
-
});
|
|
157
|
-
writeAtomic(envPath, restored ? lines : [...lines, previous]);
|
|
158
|
-
};
|
|
61
|
+
return captureDotenvKeyRestore(hermesEnvPath(home), HERMES_KEY_VAR, MARKER_HEAD);
|
|
159
62
|
}
|
|
160
63
|
export function removeHermesKey(home) {
|
|
161
|
-
|
|
162
|
-
const lines = readLines(envPath);
|
|
163
|
-
if (!lines.length)
|
|
164
|
-
return false;
|
|
165
|
-
const kept = lines.filter((line) => !definesKey(line) && !isMarker(line));
|
|
166
|
-
if (kept.length === lines.length)
|
|
167
|
-
return false;
|
|
168
|
-
writeAtomic(envPath, kept);
|
|
169
|
-
return true;
|
|
64
|
+
return removeDotenvKey(hermesEnvPath(home), HERMES_KEY_VAR, MARKER_HEAD);
|
|
170
65
|
}
|