@zhin.js/gateway 1.2.0 → 1.4.0
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/README.md +1 -1
- package/lib/config/configuration.d.ts +32 -3
- package/lib/config/configuration.js +120 -25
- package/lib/config/environment-files.d.ts +42 -0
- package/lib/config/environment-files.js +347 -7
- package/lib/config/literal-secrets.d.ts +3 -0
- package/lib/config/literal-secrets.js +4 -1
- package/lib/config/patch-path-messages.d.ts +15 -0
- package/lib/config/patch-path-messages.js +71 -0
- package/lib/config/secret-storage.d.ts +42 -0
- package/lib/config/secret-storage.js +140 -0
- package/lib/config/validation-messages.d.ts +15 -0
- package/lib/config/validation-messages.js +66 -0
- package/lib/config.d.ts +1 -0
- package/lib/config.js +1 -0
- package/lib/database/console-role.js +2 -1
- package/lib/http/console-rpc.d.ts +3 -1
- package/lib/http/console-rpc.js +31 -4
- package/lib/http/gateway-console.d.ts +5 -0
- package/lib/http/gateway-console.js +31 -5
- package/lib/http/gateway-http-server.d.ts +2 -0
- package/lib/http/gateway-http-server.js +42 -1
- package/lib/http/local-principal.d.ts +1 -1
- package/lib/http/local-principal.js +3 -2
- package/lib/http/plugin-switch-rpc.js +7 -6
- package/lib/http/start-gateway.d.ts +1 -1
- package/lib/http/start-gateway.js +2 -0
- package/lib/iam/service.js +12 -0
- package/lib/iam/setup-service.d.ts +3 -1
- package/lib/iam/setup-service.js +2 -1
- package/lib/onboarding/ignored-builds.d.ts +18 -0
- package/lib/onboarding/ignored-builds.js +63 -0
- package/lib/onboarding/index.d.ts +1 -0
- package/lib/onboarding/index.js +1 -0
- package/lib/packages/errors.js +1 -0
- package/lib/packages/rpc.d.ts +6 -1
- package/lib/packages/rpc.js +28 -2
- package/lib/packages/service.d.ts +3 -1
- package/lib/packages/service.js +74 -9
- package/lib/plugin-management/plugin-candidates.d.ts +9 -0
- package/lib/plugin-management/plugin-candidates.js +55 -0
- package/lib/plugin-management/use-cases.js +8 -8
- package/lib/plugin-management/workflow.d.ts +13 -2
- package/lib/plugin-management/workflow.js +46 -13
- package/lib/plugin-management.d.ts +1 -0
- package/lib/plugin-management.js +1 -0
- package/lib/project/identity.d.ts +2 -0
- package/lib/project/identity.js +21 -0
- package/lib/project.d.ts +1 -0
- package/lib/project.js +1 -0
- package/lib/supervisor/instance-supervisor.d.ts +4 -1
- package/lib/supervisor/instance-supervisor.js +18 -1
- package/lib/terminal/modules.d.ts +12 -0
- package/lib/terminal/modules.js +38 -2
- package/lib/usage/aggregate.d.ts +64 -3
- package/lib/usage/aggregate.js +199 -26
- package/lib/usage/prompt-cache.d.ts +49 -0
- package/lib/usage/prompt-cache.js +113 -0
- package/lib/usage.d.ts +1 -0
- package/lib/usage.js +1 -0
- package/package.json +8 -8
|
@@ -1,14 +1,354 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import { parse } from 'dotenv';
|
|
3
|
+
import { ENV_FILE_NAMES, ENV_MASK_CHAR, ENV_MASK_RUN, ENV_KEY_PATTERN, ENV_SAVE_LIMITS, isConsoleEnvMaskPlaceholder, maskConsoleEnvValue, } from '@zhin.js/console-protocol';
|
|
4
|
+
import { readDotenvFile, updateDotenvFile, writeDotenvFile } from '@zhin.js/runtime';
|
|
5
|
+
const ENV_FILES = new Set(ENV_FILE_NAMES);
|
|
6
|
+
function envPath(projectRoot, filename) {
|
|
5
7
|
if (!ENV_FILES.has(filename))
|
|
6
8
|
throw new Error(`Invalid env file: ${filename}`);
|
|
7
|
-
return
|
|
9
|
+
return join(projectRoot, filename);
|
|
10
|
+
}
|
|
11
|
+
/** The file as written. Only for the Host itself (setup, doctor); the Console reads {@link readEnvFileView}. */
|
|
12
|
+
export async function readEnvFile(projectRoot, filename) {
|
|
13
|
+
return readDotenvFile(envPath(projectRoot, filename));
|
|
8
14
|
}
|
|
9
15
|
/** Locked, atomic replacement that keeps the file mode (`0600` when new); ADR 0057 §4. */
|
|
10
16
|
export async function writeEnvFile(projectRoot, filename, content) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
17
|
+
await writeDotenvFile(envPath(projectRoot, filename), content);
|
|
18
|
+
}
|
|
19
|
+
// ---------------------------------------------------------------------------------------------
|
|
20
|
+
// Parsing. The process reads `.env` with dotenv's `parse`, so the Console's view of the file must be
|
|
21
|
+
// exactly dotenv's: the same assignments, with the same values. `DOTENV_LINE` is dotenv's own line
|
|
22
|
+
// pattern (dotenv 17, `lib/main.js` `LINE`) with two extra capture groups (the separator and the
|
|
23
|
+
// value) and match indices; `scanEnv` runs it the way `parse` does (`\r\n` / `\r` read as `\n`) and
|
|
24
|
+
// maps every match back to the bytes of the file. Anything dotenv does not read as an assignment is
|
|
25
|
+
// text (comments, blank and stray lines). The tests compare this scan with `parse` itself.
|
|
26
|
+
// No `u` flag: the pattern is kept exactly as dotenv has it.
|
|
27
|
+
const DOTENV_LINE = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/dgm;
|
|
28
|
+
const REFERENCE = /^\$\{[^}]+\}$/u;
|
|
29
|
+
/** dotenv's value rules, applied to the captured value (see `parse` in dotenv's `lib/main.js`). */
|
|
30
|
+
function dotenvValue(captured) {
|
|
31
|
+
let value = captured.trim();
|
|
32
|
+
const maybeQuote = value[0];
|
|
33
|
+
value = value.replace(/^(['"`])([\s\S]*)\1$/mg, '$2');
|
|
34
|
+
if (maybeQuote === '"')
|
|
35
|
+
value = value.replace(/\\n/gu, '\n').replace(/\\r/gu, '\r');
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
function scanEnv(content) {
|
|
39
|
+
// dotenv reads `\r\n` and `\r` as `\n`; `toOriginal[i]` is the offset in `content` of normalised offset `i`.
|
|
40
|
+
let normalized = '';
|
|
41
|
+
const toOriginal = [];
|
|
42
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
43
|
+
toOriginal.push(index);
|
|
44
|
+
if (content[index] === '\r') {
|
|
45
|
+
normalized += '\n';
|
|
46
|
+
if (content[index + 1] === '\n')
|
|
47
|
+
index += 1;
|
|
48
|
+
}
|
|
49
|
+
else
|
|
50
|
+
normalized += content[index];
|
|
51
|
+
}
|
|
52
|
+
toOriginal.push(content.length);
|
|
53
|
+
const entries = [];
|
|
54
|
+
const texts = [];
|
|
55
|
+
let cursor = 0;
|
|
56
|
+
const pattern = new RegExp(DOTENV_LINE.source, DOTENV_LINE.flags);
|
|
57
|
+
let match;
|
|
58
|
+
while ((match = pattern.exec(normalized)) !== null) {
|
|
59
|
+
const indices = match.indices;
|
|
60
|
+
const first = match.index + match[0].search(/\S/u);
|
|
61
|
+
const lineStart = normalized.lastIndexOf('\n', first - 1) + 1;
|
|
62
|
+
const separatorEnd = indices[2][1];
|
|
63
|
+
let valueStart = separatorEnd;
|
|
64
|
+
let valueEnd = separatorEnd;
|
|
65
|
+
if (match[3] !== undefined) {
|
|
66
|
+
const [groupStart, groupEnd] = indices[3];
|
|
67
|
+
const leading = /^\s*/u.exec(match[3])[0];
|
|
68
|
+
// A value on a line of its own (`KEY=` then `"value"`): replacing it replaces that line break too.
|
|
69
|
+
valueStart = leading.includes('\n') ? groupStart : groupStart + leading.length;
|
|
70
|
+
valueEnd = groupStart + match[3].trimEnd().length;
|
|
71
|
+
if (valueEnd < valueStart)
|
|
72
|
+
valueEnd = valueStart;
|
|
73
|
+
}
|
|
74
|
+
const last = Math.max(valueEnd, separatorEnd) - 1;
|
|
75
|
+
const lineBreak = normalized.indexOf('\n', last);
|
|
76
|
+
const lineEnd = lineBreak < 0 ? normalized.length : lineBreak + 1;
|
|
77
|
+
if (lineStart > cursor)
|
|
78
|
+
texts.push({ start: toOriginal[cursor], end: toOriginal[lineStart] });
|
|
79
|
+
const rawStart = groupValueStart(match[3], indices[3]);
|
|
80
|
+
entries.push(Object.freeze({
|
|
81
|
+
key: match[1],
|
|
82
|
+
exported: /^export\s/u.test(normalized.slice(first, indices[1][0])),
|
|
83
|
+
start: toOriginal[lineStart],
|
|
84
|
+
end: toOriginal[lineEnd],
|
|
85
|
+
valueStart: toOriginal[valueStart],
|
|
86
|
+
valueEnd: toOriginal[valueEnd],
|
|
87
|
+
raw: content.slice(toOriginal[rawStart ?? valueStart], toOriginal[valueEnd]),
|
|
88
|
+
value: dotenvValue(match[3] ?? ''),
|
|
89
|
+
}));
|
|
90
|
+
cursor = lineEnd;
|
|
91
|
+
}
|
|
92
|
+
if (cursor < normalized.length)
|
|
93
|
+
texts.push({ start: toOriginal[cursor], end: content.length });
|
|
94
|
+
return { entries, texts };
|
|
95
|
+
}
|
|
96
|
+
/** Where the captured value starts once its leading whitespace is left out. */
|
|
97
|
+
function groupValueStart(captured, span) {
|
|
98
|
+
if (captured === undefined || !span)
|
|
99
|
+
return undefined;
|
|
100
|
+
return span[0] + /^\s*/u.exec(captured)[0].length;
|
|
101
|
+
}
|
|
102
|
+
/** What the process reads from `content`: dotenv's own `parse` (the last assignment of a key wins). */
|
|
103
|
+
function dotenvValues(content) {
|
|
104
|
+
return new Map(Object.entries(parse(content)));
|
|
105
|
+
}
|
|
106
|
+
/** The value as the process reads it (dotenv's own rules on that one assignment). */
|
|
107
|
+
function effectiveValue(key, rawValue) {
|
|
108
|
+
return parse(`${key}=${rawValue}`)[key] ?? '';
|
|
109
|
+
}
|
|
110
|
+
function maskedVariable(entry) {
|
|
111
|
+
return Object.freeze({
|
|
112
|
+
key: entry.key,
|
|
113
|
+
masked: maskConsoleEnvValue(entry.value),
|
|
114
|
+
hasValue: entry.value.length > 0,
|
|
115
|
+
...(REFERENCE.test(entry.value) ? { isReference: true } : {}),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/** A comment that names a key: `# KEY=value`, `## KEY=value`, `#- KEY: value`, `# export KEY=value`. */
|
|
119
|
+
const COMMENTED_ASSIGNMENT = /^\s*#[^\w\r\n]*?(?:export\s+)?([\w.-]+)\s*[=:](.*\S.*)$/u;
|
|
120
|
+
/** Any other sign of a value in a comment: `=` or `:` followed by text (`# old token is: sk-…`). */
|
|
121
|
+
const COMMENTED_VALUE = /[=:](?=\s*\S)/u;
|
|
122
|
+
const unquote = (text) => text.trim().replace(/^(["'`])([\s\S]*)\1$/u, '$2');
|
|
123
|
+
function textLines(raw) {
|
|
124
|
+
return raw.split(/\r\n|\n|\r/u).slice(0, raw.match(/\r\n|\n|\r/u) ? -1 : undefined).map((line) => {
|
|
125
|
+
if (!line.trim())
|
|
126
|
+
return Object.freeze({ kind: 'blank' });
|
|
127
|
+
if (!/^\s*#/u.test(line))
|
|
128
|
+
return Object.freeze({ kind: 'other' });
|
|
129
|
+
const commented = COMMENTED_ASSIGNMENT.exec(line);
|
|
130
|
+
if (commented)
|
|
131
|
+
return Object.freeze({ kind: 'comment', key: commented[1], masked: maskConsoleEnvValue(unquote(commented[2])) });
|
|
132
|
+
const separator = COMMENTED_VALUE.exec(line);
|
|
133
|
+
if (separator) {
|
|
134
|
+
const head = line.slice(0, separator.index + 1);
|
|
135
|
+
return Object.freeze({ kind: 'comment', text: `${head} ${maskConsoleEnvValue(unquote(line.slice(separator.index + 1))) || ENV_MASK_CHAR.repeat(ENV_MASK_RUN)}` });
|
|
136
|
+
}
|
|
137
|
+
return Object.freeze({ kind: 'comment', text: line });
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Masked view of `content` (`env:get`): exactly the variables dotenv reads, each masked; comments
|
|
142
|
+
* that carry a value are masked too. Never contains a value, its length, or a line's length.
|
|
143
|
+
*/
|
|
144
|
+
export function maskEnvFileContent(file, content) {
|
|
145
|
+
const { entries, texts } = scanEnv(content);
|
|
146
|
+
const lines = [];
|
|
147
|
+
const variables = new Map();
|
|
148
|
+
let text = 0;
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
while (text < texts.length && texts[text].start < entry.start)
|
|
151
|
+
lines.push(...textLines(content.slice(texts[text].start, texts[text++].end)));
|
|
152
|
+
lines.push(Object.freeze({ kind: 'entry', key: entry.key, ...(entry.exported ? { export: true } : {}) }));
|
|
153
|
+
// A key assigned twice: the last assignment is the one the process reads; keep first position.
|
|
154
|
+
variables.set(entry.key, maskedVariable(entry));
|
|
155
|
+
}
|
|
156
|
+
while (text < texts.length)
|
|
157
|
+
lines.push(...textLines(content.slice(texts[text].start, texts[text++].end)));
|
|
158
|
+
const order = [...new Set(entries.map(entry => entry.key))];
|
|
159
|
+
const masked = lines.map((line) => {
|
|
160
|
+
switch (line.kind) {
|
|
161
|
+
case 'blank': return '';
|
|
162
|
+
case 'comment': return 'text' in line ? line.text : `# ${line.key}=${line.masked}`;
|
|
163
|
+
case 'entry': return `${line.export ? 'export ' : ''}${line.key}=${variables.get(line.key).masked}`;
|
|
164
|
+
case 'other': return ENV_MASK_CHAR.repeat(ENV_MASK_RUN);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
return Object.freeze({
|
|
168
|
+
format: 'masked',
|
|
169
|
+
file,
|
|
170
|
+
variables: Object.freeze(order.map(key => variables.get(key))),
|
|
171
|
+
lines: Object.freeze(lines),
|
|
172
|
+
content: masked.length ? `${masked.join('\n')}\n` : '',
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
export async function readEnvFileView(projectRoot, filename) {
|
|
176
|
+
return maskEnvFileContent(filename, await readDotenvFile(envPath(projectRoot, filename)));
|
|
177
|
+
}
|
|
178
|
+
export class EnvVariableNotFoundError extends Error {
|
|
179
|
+
code = 'env_variable_not_found';
|
|
180
|
+
constructor(filename, key) {
|
|
181
|
+
super(`${filename} 中没有变量 ${key}`);
|
|
182
|
+
this.name = 'EnvVariableNotFoundError';
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* One variable (its effective, i.e. last, assignment) as written in the file: exactly the text
|
|
187
|
+
* dotenv reads as that variable's value, so it never carries another line of the file.
|
|
188
|
+
*/
|
|
189
|
+
export async function revealEnvVariable(projectRoot, filename, key) {
|
|
190
|
+
if (typeof key !== 'string' || !ENV_KEY_PATTERN.test(key) || key.length > ENV_SAVE_LIMITS.maxKeyLength) {
|
|
191
|
+
throw new EnvChangeError('变量名无效');
|
|
192
|
+
}
|
|
193
|
+
const content = await readDotenvFile(envPath(projectRoot, filename));
|
|
194
|
+
const entry = scanEnv(content).entries.filter(item => item.key === key).at(-1);
|
|
195
|
+
if (!entry)
|
|
196
|
+
throw new EnvVariableNotFoundError(filename, key);
|
|
197
|
+
return Object.freeze({ file: filename, key, value: entry.raw });
|
|
198
|
+
}
|
|
199
|
+
export class EnvChangeError extends Error {
|
|
200
|
+
code = 'invalid_env_change';
|
|
201
|
+
constructor(message) {
|
|
202
|
+
super(message);
|
|
203
|
+
this.name = 'EnvChangeError';
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Whether a raw value (as written after `KEY=`) is read back by dotenv as one assignment of `KEY`
|
|
208
|
+
* and nothing else: a value spanning lines must be quoted; after a closing quote only spaces or an
|
|
209
|
+
* inline comment (`# …`) may follow, on the same line. dotenv reads `"x"zz` as the unquoted `"x`
|
|
210
|
+
* and the next line as a new assignment, so such a value is refused.
|
|
211
|
+
*/
|
|
212
|
+
function assignmentProblem(value) {
|
|
213
|
+
const written = `K=${value}`;
|
|
214
|
+
const { entries, texts } = scanEnv(written);
|
|
215
|
+
const quoted = /^\s*["'`]/u.test(value);
|
|
216
|
+
if (/[\r\n]/u.test(value) && !/^[ \t]*["'`]/u.test(value))
|
|
217
|
+
return '多行值必须用引号包裹';
|
|
218
|
+
const entry = entries[0];
|
|
219
|
+
if (entries.length !== 1 || texts.length !== 0 || entry.key !== 'K' || entry.end !== written.length) {
|
|
220
|
+
return quoted ? '引号没有闭合,或闭合引号之后还有注释以外的内容' : '值会被读成多行或多个变量';
|
|
221
|
+
}
|
|
222
|
+
const tail = written.slice(entry.valueEnd);
|
|
223
|
+
if (/[\r\n]/u.test(tail))
|
|
224
|
+
return '闭合引号之后还有换行';
|
|
225
|
+
if (quoted) {
|
|
226
|
+
const quote = value.trimStart()[0];
|
|
227
|
+
const raw = entry.raw;
|
|
228
|
+
if (raw.length < 2 || raw[0] !== quote || raw.at(-1) !== quote || !/^\s*(?:#.*)?$/u.test(tail)) {
|
|
229
|
+
return '引号没有闭合,或闭合引号之后还有注释以外的内容';
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
/** Validated `env:save` changes; throws {@link EnvChangeError} (nothing is written). */
|
|
235
|
+
export function validateEnvChanges(changes) {
|
|
236
|
+
if (!Array.isArray(changes) || changes.length === 0)
|
|
237
|
+
throw new EnvChangeError('changes 必须是非空数组');
|
|
238
|
+
if (changes.length > ENV_SAVE_LIMITS.maxChanges)
|
|
239
|
+
throw new EnvChangeError(`一次最多修改 ${ENV_SAVE_LIMITS.maxChanges} 个变量`);
|
|
240
|
+
const seen = new Set();
|
|
241
|
+
return Object.freeze(changes.map((item, index) => {
|
|
242
|
+
if (!item || typeof item !== 'object' || Array.isArray(item))
|
|
243
|
+
throw new EnvChangeError(`changes[${index}] 必须是对象`);
|
|
244
|
+
const { key, value } = item;
|
|
245
|
+
const remove = item.delete === true;
|
|
246
|
+
if (typeof key !== 'string' || !ENV_KEY_PATTERN.test(key) || key.length > ENV_SAVE_LIMITS.maxKeyLength) {
|
|
247
|
+
throw new EnvChangeError(`changes[${index}].key 不是合法变量名`);
|
|
248
|
+
}
|
|
249
|
+
if (seen.has(key))
|
|
250
|
+
throw new EnvChangeError(`变量 ${key} 在 changes 中出现了多次`);
|
|
251
|
+
seen.add(key);
|
|
252
|
+
if (remove) {
|
|
253
|
+
if (value !== undefined)
|
|
254
|
+
throw new EnvChangeError(`changes[${index}] 不能同时删除并设置值`);
|
|
255
|
+
return Object.freeze({ key, delete: true });
|
|
256
|
+
}
|
|
257
|
+
if (typeof value !== 'string')
|
|
258
|
+
throw new EnvChangeError(`changes[${index}].value 必须是字符串`);
|
|
259
|
+
if (value.length > ENV_SAVE_LIMITS.maxValueLength)
|
|
260
|
+
throw new EnvChangeError(`变量 ${key} 的值过长`);
|
|
261
|
+
if (isConsoleEnvMaskPlaceholder(value)) {
|
|
262
|
+
throw new EnvChangeError(`变量 ${key} 的值是打码占位(●),不能保存;请填写真实值,或不修改这个变量`);
|
|
263
|
+
}
|
|
264
|
+
const problem = assignmentProblem(value);
|
|
265
|
+
if (problem)
|
|
266
|
+
throw new EnvChangeError(`变量 ${key}:${problem}`);
|
|
267
|
+
return Object.freeze({ key, value });
|
|
268
|
+
}));
|
|
269
|
+
}
|
|
270
|
+
/** `content` with one change applied; every line not naming `change.key` keeps its bytes. */
|
|
271
|
+
function applyEnvChange(content, change) {
|
|
272
|
+
const entries = scanEnv(content).entries.filter(entry => entry.key === change.key);
|
|
273
|
+
let text = content;
|
|
274
|
+
// From the last assignment backwards, so earlier offsets stay valid.
|
|
275
|
+
for (const entry of [...entries].reverse()) {
|
|
276
|
+
text = 'delete' in change
|
|
277
|
+
? text.slice(0, entry.start) + text.slice(entry.end)
|
|
278
|
+
: text.slice(0, entry.valueStart) + change.value + text.slice(entry.valueEnd);
|
|
279
|
+
}
|
|
280
|
+
if ('delete' in change || entries.length)
|
|
281
|
+
return text;
|
|
282
|
+
const eol = text.match(/\r\n|\n|\r/u)?.[0] ?? '\n';
|
|
283
|
+
const separator = text && !/[\r\n]$/u.test(text) ? eol : '';
|
|
284
|
+
return `${text}${separator}${change.key}=${change.value}${eol}`;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Checks what dotenv reads after the changes: every key not submitted keeps its value (none appears
|
|
288
|
+
* or disappears), every set key reads as its submitted value and every deleted key is gone.
|
|
289
|
+
* Throws {@link EnvChangeError} otherwise (the file is not written).
|
|
290
|
+
*/
|
|
291
|
+
function assertOnlySubmittedKeysChanged(before, after, changes) {
|
|
292
|
+
const old = dotenvValues(before);
|
|
293
|
+
const next = dotenvValues(after);
|
|
294
|
+
const submitted = new Map(changes.map(change => [change.key, change]));
|
|
295
|
+
const problems = [];
|
|
296
|
+
for (const key of new Set([...old.keys(), ...next.keys()])) {
|
|
297
|
+
const change = submitted.get(key);
|
|
298
|
+
if (!change) {
|
|
299
|
+
if (old.get(key) !== next.get(key) || old.has(key) !== next.has(key))
|
|
300
|
+
problems.push(key);
|
|
301
|
+
}
|
|
302
|
+
else if ('delete' in change ? next.has(key) : next.get(key) !== effectiveValue(key, change.value)) {
|
|
303
|
+
problems.push(key);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
for (const change of changes) {
|
|
307
|
+
if (!('delete' in change) && !next.has(change.key))
|
|
308
|
+
problems.push(change.key);
|
|
309
|
+
}
|
|
310
|
+
if (problems.length) {
|
|
311
|
+
throw new EnvChangeError(`保存会改变未提交的变量或读不回提交的值:${[...new Set(problems)].slice(0, 8).join('、')};文件没有修改`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Applies validated changes to `content`; every line not naming a changed key keeps its bytes.
|
|
316
|
+
* Throws {@link EnvChangeError} when dotenv would read anything but the submitted changes.
|
|
317
|
+
*/
|
|
318
|
+
export function applyEnvChanges(content, changes) {
|
|
319
|
+
const next = changes.reduce(applyEnvChange, content);
|
|
320
|
+
assertOnlySubmittedKeysChanged(content, next, changes);
|
|
321
|
+
return next;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* `env:save { changes }`: applies the changes to the file as it is on disk, inside the dotenv
|
|
325
|
+
* lock. Keys not named keep their lines byte for byte (they are never rebuilt from a masked view).
|
|
326
|
+
*/
|
|
327
|
+
export async function updateEnvVariables(projectRoot, filename, changes) {
|
|
328
|
+
const valid = validateEnvChanges(changes);
|
|
329
|
+
await updateDotenvFile(envPath(projectRoot, filename), current => applyEnvChanges(current, valid));
|
|
330
|
+
return Object.freeze({ changed: Object.freeze(valid.map(change => change.key)) });
|
|
331
|
+
}
|
|
332
|
+
/** Keys of `content` whose value (or commented value) is a mask placeholder. */
|
|
333
|
+
export function maskedPlaceholderKeys(content) {
|
|
334
|
+
const keys = new Set();
|
|
335
|
+
const { entries, texts } = scanEnv(content);
|
|
336
|
+
for (const entry of entries)
|
|
337
|
+
if (isConsoleEnvMaskPlaceholder(entry.raw))
|
|
338
|
+
keys.add(entry.key);
|
|
339
|
+
for (const text of texts)
|
|
340
|
+
if (isConsoleEnvMaskPlaceholder(content.slice(text.start, text.end)))
|
|
341
|
+
keys.add('(非变量行)');
|
|
342
|
+
return [...keys];
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* `env:save { content, mode: 'replace' }`: the whole-file form. Refused when any value is a mask
|
|
346
|
+
* placeholder, so a masked view can never be written back over the real values.
|
|
347
|
+
*/
|
|
348
|
+
export async function replaceEnvFileContent(projectRoot, filename, content) {
|
|
349
|
+
const masked = maskedPlaceholderKeys(content);
|
|
350
|
+
if (masked.length) {
|
|
351
|
+
throw new EnvChangeError(`内容里有打码占位(●),不能整文件保存:${masked.slice(0, 8).join('、')}。请改用逐键保存`);
|
|
352
|
+
}
|
|
353
|
+
await writeEnvFile(projectRoot, filename, content);
|
|
14
354
|
}
|
|
@@ -32,3 +32,6 @@ export interface LiteralSecretFixResult {
|
|
|
32
32
|
* variables already written, so the operation is idempotent.
|
|
33
33
|
*/
|
|
34
34
|
export declare function moveLiteralSecretsToEnv(projectRoot: string, configFile: string): Promise<LiteralSecretFixResult>;
|
|
35
|
+
/** A `NAME=value` dotenv line that parses back to exactly `value`, or undefined when none does. */
|
|
36
|
+
export declare function dotenvLine(name: string, value: string): string | undefined;
|
|
37
|
+
export declare function readDocumentPath(document: unknown, path: readonly string[]): unknown;
|
|
@@ -100,7 +100,7 @@ export async function moveLiteralSecretsToEnv(projectRoot, configFile) {
|
|
|
100
100
|
return Object.freeze({ moved: Object.freeze(moved), skipped: Object.freeze(skipped) });
|
|
101
101
|
}
|
|
102
102
|
/** A `NAME=value` dotenv line that parses back to exactly `value`, or undefined when none does. */
|
|
103
|
-
function dotenvLine(name, value) {
|
|
103
|
+
export function dotenvLine(name, value) {
|
|
104
104
|
if (/[\r\n]/u.test(value))
|
|
105
105
|
return undefined;
|
|
106
106
|
for (const quote of ["'", '`', '"']) {
|
|
@@ -112,6 +112,9 @@ function dotenvLine(name, value) {
|
|
|
112
112
|
}
|
|
113
113
|
return undefined;
|
|
114
114
|
}
|
|
115
|
+
export function readDocumentPath(document, path) {
|
|
116
|
+
return readPath(document, path);
|
|
117
|
+
}
|
|
115
118
|
function readPath(document, path) {
|
|
116
119
|
let current = document;
|
|
117
120
|
for (const segment of path) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chinese explanations for project config patches that cannot apply (#1250): unknown top-level
|
|
3
|
+
* keys (with close candidates), declaration keys, and array indexes outside the array. Shown by
|
|
4
|
+
* `zhin config set|unset` and the Console; they never name RPCs or revisions.
|
|
5
|
+
*/
|
|
6
|
+
import type { RuntimeConfigDocument } from '@zhin.js/plugin-runtime';
|
|
7
|
+
/** Up to three known keys close to `key` (edit distance, prefix or substring). */
|
|
8
|
+
export declare function closeConfigKeys(key: string, known: Iterable<string>): string[];
|
|
9
|
+
export declare function unknownConfigKeyMessage(section: string, known: Iterable<string>): string;
|
|
10
|
+
export declare function declarationKeyMessage(section: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* Why `path` cannot be written in `document` because of an array segment, or undefined. `set`
|
|
13
|
+
* cannot append: an index equal to the length is out of range too.
|
|
14
|
+
*/
|
|
15
|
+
export declare function describeArrayPathProblem(document: RuntimeConfigDocument, path: readonly string[], op: 'set' | 'remove'): string | undefined;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
function displayPath(path) {
|
|
2
|
+
return path.join('.');
|
|
3
|
+
}
|
|
4
|
+
/** Damerau-free Levenshtein distance, enough for "did you mean" suggestions. */
|
|
5
|
+
function distance(a, b) {
|
|
6
|
+
const row = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
7
|
+
for (let i = 1; i <= a.length; i++) {
|
|
8
|
+
let previous = row[0];
|
|
9
|
+
row[0] = i;
|
|
10
|
+
for (let j = 1; j <= b.length; j++) {
|
|
11
|
+
const current = row[j];
|
|
12
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
13
|
+
previous = current;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return row[b.length];
|
|
17
|
+
}
|
|
18
|
+
/** Up to three known keys close to `key` (edit distance, prefix or substring). */
|
|
19
|
+
export function closeConfigKeys(key, known) {
|
|
20
|
+
const lower = key.toLowerCase();
|
|
21
|
+
return [...new Set(known)]
|
|
22
|
+
.map(candidate => ({ candidate, score: distance(lower, candidate.toLowerCase()) }))
|
|
23
|
+
.filter(({ candidate, score }) => score <= Math.max(2, Math.floor(key.length / 3))
|
|
24
|
+
|| candidate.toLowerCase().startsWith(lower) || lower.startsWith(candidate.toLowerCase())
|
|
25
|
+
|| (lower.length >= 3 && candidate.toLowerCase().includes(lower)))
|
|
26
|
+
.sort((left, right) => left.score - right.score || left.candidate.localeCompare(right.candidate))
|
|
27
|
+
.slice(0, 3)
|
|
28
|
+
.map(({ candidate }) => candidate);
|
|
29
|
+
}
|
|
30
|
+
export function unknownConfigKeyMessage(section, known) {
|
|
31
|
+
const close = closeConfigKeys(section, known);
|
|
32
|
+
return `没有这个配置项:${section}(不是 Host 配置,也不是已声明的插件实例)`
|
|
33
|
+
+ (close.length > 0 ? `。你是不是想写:${close.join('、')}?` : '。')
|
|
34
|
+
+ '要给新插件写配置,先在 Console「插件」页或用 zhin install <包名> 安装并声明它';
|
|
35
|
+
}
|
|
36
|
+
export function declarationKeyMessage(section) {
|
|
37
|
+
return `${section} 是插件声明,不能用配置补丁修改;请在 Console「插件」页管理插件,或直接编辑配置文件`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Why `path` cannot be written in `document` because of an array segment, or undefined. `set`
|
|
41
|
+
* cannot append: an index equal to the length is out of range too.
|
|
42
|
+
*/
|
|
43
|
+
export function describeArrayPathProblem(document, path, op) {
|
|
44
|
+
let current = document;
|
|
45
|
+
for (let index = 0; index < path.length; index++) {
|
|
46
|
+
const segment = path[index];
|
|
47
|
+
const here = path.slice(0, index + 1);
|
|
48
|
+
if (Array.isArray(current)) {
|
|
49
|
+
const parent = displayPath(path.slice(0, index));
|
|
50
|
+
if (!/^(0|[1-9]\d*)$/u.test(segment)) {
|
|
51
|
+
return `${parent} 是数组,${displayPath(here)} 里的 "${segment}" 应为数组下标(0–${Math.max(current.length - 1, 0)})`;
|
|
52
|
+
}
|
|
53
|
+
const position = Number(segment);
|
|
54
|
+
if (position >= current.length) {
|
|
55
|
+
const range = current.length === 0 ? '当前是空数组' : `当前有 ${current.length} 项,下标 0–${current.length - 1}`;
|
|
56
|
+
const append = op === 'set'
|
|
57
|
+
? `。要追加一项:先用 zhin config get ${parent} 读出整个数组,加上新项后用 zhin config set ${parent} '<JSON 数组>' 整体写回`
|
|
58
|
+
: '';
|
|
59
|
+
return `${displayPath(here)} 超出数组范围(${range})${append}`;
|
|
60
|
+
}
|
|
61
|
+
current = current[position];
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!current || typeof current !== 'object')
|
|
65
|
+
return undefined;
|
|
66
|
+
current = Object.hasOwn(current, segment) ? current[segment] : undefined;
|
|
67
|
+
if (current === undefined)
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ConfigPatch, RuntimeConfigDocument } from '@zhin.js/plugin-runtime';
|
|
2
|
+
/** How a patch stores secret values: `env` (default) or `plain` (explicit opt-out, e.g. `zhin config set --plain`). */
|
|
3
|
+
export type ConfigSecretStorage = 'env' | 'plain';
|
|
4
|
+
/** Result / error code when a write would store secret values in `.env` without `ops.env.write`. */
|
|
5
|
+
export declare const SECRET_ENVIRONMENT_WRITE_REQUIRED = "env-write-required";
|
|
6
|
+
/** Refusal message for {@link SECRET_ENVIRONMENT_WRITE_REQUIRED}; names the fields, never values. */
|
|
7
|
+
export declare function secretEnvironmentWriteRequiredMessage(paths: readonly (readonly string[])[]): string;
|
|
8
|
+
/** `setKey` refusal: the block holds secret literals but the caller may not write `.env`. */
|
|
9
|
+
export declare class SecretEnvironmentWriteRequiredError extends Error {
|
|
10
|
+
readonly paths: readonly (readonly string[])[];
|
|
11
|
+
readonly code = "env-write-required";
|
|
12
|
+
constructor(paths: readonly (readonly string[])[]);
|
|
13
|
+
}
|
|
14
|
+
export interface SecretAssignment {
|
|
15
|
+
readonly path: readonly string[];
|
|
16
|
+
readonly variable: string;
|
|
17
|
+
/** Never serialized into results. */
|
|
18
|
+
readonly value: string;
|
|
19
|
+
}
|
|
20
|
+
export interface SecretExternalizationPlan {
|
|
21
|
+
/** `set path → ${VAR}` patches applied after the caller's patches. */
|
|
22
|
+
readonly patches: readonly ConfigPatch[];
|
|
23
|
+
readonly assignments: readonly SecretAssignment[];
|
|
24
|
+
/** Secret values `.env` cannot hold (line breaks): the write is refused for these paths. */
|
|
25
|
+
readonly errors: readonly {
|
|
26
|
+
readonly path: string;
|
|
27
|
+
readonly message: string;
|
|
28
|
+
}[];
|
|
29
|
+
}
|
|
30
|
+
/** `http.token` → `HTTP_TOKEN` (the `${…}` name the scaffold uses), `qq.endpoints.0.appSecret` → `QQ_ENDPOINTS_0_APP_SECRET`. */
|
|
31
|
+
export declare function canonicalSecretVariable(path: readonly string[]): string;
|
|
32
|
+
/**
|
|
33
|
+
* Plans moving the literal secrets that `candidate` newly holds under `touched` paths (values that
|
|
34
|
+
* differ from `current`) into `.env`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function planSecretExternalization(projectRoot: string, environmentName: string, current: RuntimeConfigDocument, candidate: RuntimeConfigDocument, touched: readonly (readonly string[])[]): Promise<SecretExternalizationPlan>;
|
|
37
|
+
/**
|
|
38
|
+
* Writes the assignments: into `.env.<environment>` when that file already defines the variable
|
|
39
|
+
* (it overrides `.env`), and into `.env` otherwise. Returns a function that restores the previous
|
|
40
|
+
* contents (used when the config write does not happen after all).
|
|
41
|
+
*/
|
|
42
|
+
export declare function writeSecretAssignments(projectRoot: string, environmentName: string, assignments: readonly SecretAssignment[]): Promise<() => Promise<void>>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret values written through a config patch go to `.env`, not into the config file (#1250).
|
|
3
|
+
*
|
|
4
|
+
* A `set` that writes a literal into a schema-marked secret field (`x-zhin-secret`, `writeOnly`,
|
|
5
|
+
* `format: password`, `x-zhin-config.secret`, or the secret field-name fallback) is rewritten:
|
|
6
|
+
* the value is stored as a `.env` variable and the config keeps (or gets) the `${VAR}` reference.
|
|
7
|
+
* The variable is the field's canonical name derived from its path (`http.token` → `HTTP_TOKEN`,
|
|
8
|
+
* with a numeric suffix when another value already holds that name). A `${VAR}` the field merely
|
|
9
|
+
* references is never overwritten unless it is that canonical name: otherwise a config write could
|
|
10
|
+
* replace any `.env` variable (e.g. point `http.token` at `${OPENAI_API_KEY}`, then set a literal).
|
|
11
|
+
* Writing into `.env` at all needs the caller's `ops.env.write` ({@link SecretEnvironmentWriteRequiredError}).
|
|
12
|
+
* Values are never returned or logged.
|
|
13
|
+
*/
|
|
14
|
+
import { readFile, rm } from 'node:fs/promises';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { parse as parseDotenv } from 'dotenv';
|
|
17
|
+
import { updateDotenvFile } from '@zhin.js/runtime';
|
|
18
|
+
import { dotenvLine, findProjectLiteralSecrets, literalSecretEnvironmentName, readDocumentPath as readPath } from './literal-secrets.js';
|
|
19
|
+
const WHOLE_REFERENCE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/u;
|
|
20
|
+
/** Result / error code when a write would store secret values in `.env` without `ops.env.write`. */
|
|
21
|
+
export const SECRET_ENVIRONMENT_WRITE_REQUIRED = 'env-write-required';
|
|
22
|
+
/** Refusal message for {@link SECRET_ENVIRONMENT_WRITE_REQUIRED}; names the fields, never values. */
|
|
23
|
+
export function secretEnvironmentWriteRequiredMessage(paths) {
|
|
24
|
+
return `敏感字段 ${paths.map(path => path.join('.')).join('、')} 的值要写入 .env,需要 ops.env.write 权限;未保存。`
|
|
25
|
+
+ '请让有该权限的管理员设置,或在 .env 中设置变量后写入 ${VAR} 引用';
|
|
26
|
+
}
|
|
27
|
+
/** `setKey` refusal: the block holds secret literals but the caller may not write `.env`. */
|
|
28
|
+
export class SecretEnvironmentWriteRequiredError extends Error {
|
|
29
|
+
paths;
|
|
30
|
+
code = SECRET_ENVIRONMENT_WRITE_REQUIRED;
|
|
31
|
+
constructor(paths) {
|
|
32
|
+
super(secretEnvironmentWriteRequiredMessage(paths));
|
|
33
|
+
this.paths = paths;
|
|
34
|
+
this.name = 'SecretEnvironmentWriteRequiredError';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** `http.token` → `HTTP_TOKEN` (the `${…}` name the scaffold uses), `qq.endpoints.0.appSecret` → `QQ_ENDPOINTS_0_APP_SECRET`. */
|
|
38
|
+
export function canonicalSecretVariable(path) {
|
|
39
|
+
return literalSecretEnvironmentName(path).replace(/^ZHIN_/u, '');
|
|
40
|
+
}
|
|
41
|
+
function startsWith(path, prefix) {
|
|
42
|
+
return prefix.length <= path.length && prefix.every((segment, index) => path[index] === segment);
|
|
43
|
+
}
|
|
44
|
+
async function readOptional(file) {
|
|
45
|
+
try {
|
|
46
|
+
return await readFile(file, 'utf8');
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
|
|
50
|
+
return undefined;
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Plans moving the literal secrets that `candidate` newly holds under `touched` paths (values that
|
|
56
|
+
* differ from `current`) into `.env`.
|
|
57
|
+
*/
|
|
58
|
+
export async function planSecretExternalization(projectRoot, environmentName, current, candidate, touched) {
|
|
59
|
+
const literals = (await findProjectLiteralSecrets(projectRoot, candidate))
|
|
60
|
+
.filter(secret => touched.some(path => startsWith(secret.path, path)));
|
|
61
|
+
if (literals.length === 0)
|
|
62
|
+
return { patches: [], assignments: [], errors: [] };
|
|
63
|
+
const layers = await Promise.all(['.env', `.env.${environmentName}`].map(async (name) => parseDotenv((await readOptional(join(projectRoot, name))) ?? '')));
|
|
64
|
+
const taken = new Map();
|
|
65
|
+
for (const layer of layers)
|
|
66
|
+
for (const [name, value] of Object.entries(layer))
|
|
67
|
+
taken.set(name, value);
|
|
68
|
+
const patches = [];
|
|
69
|
+
const assignments = [];
|
|
70
|
+
const errors = [];
|
|
71
|
+
for (const secret of literals) {
|
|
72
|
+
const value = readPath(candidate, secret.path);
|
|
73
|
+
if (typeof value !== 'string' || value === '' || value === readPath(current, secret.path))
|
|
74
|
+
continue;
|
|
75
|
+
const label = secret.path.join('.');
|
|
76
|
+
const canonical = canonicalSecretVariable(secret.path);
|
|
77
|
+
const reference = WHOLE_REFERENCE.exec(String(readPath(current, secret.path) ?? ''))?.[1];
|
|
78
|
+
// Only the field's own canonical variable is updated in place. Any other `${VAR}` the field
|
|
79
|
+
// references may belong to something else (#1250): a fresh name is written instead.
|
|
80
|
+
let variable = canonical;
|
|
81
|
+
if (reference !== canonical) {
|
|
82
|
+
for (let suffix = 2; (taken.has(variable) && taken.get(variable) !== value) || variable === reference; suffix += 1) {
|
|
83
|
+
variable = `${canonical}_${suffix}`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (!dotenvLine(variable, value)) {
|
|
87
|
+
errors.push({ path: `$.${label}`, message: `${label} 是敏感字段,值含换行等字符,无法写入 .env;请手动在 .env 设置后引用 \${${variable}},或明确用明文保存(zhin config set --plain)` });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
taken.set(variable, value);
|
|
91
|
+
assignments.push({ path: secret.path, variable, value });
|
|
92
|
+
patches.push({ op: 'set', path: secret.path, value: `\${${variable}}` });
|
|
93
|
+
}
|
|
94
|
+
return { patches, assignments, errors };
|
|
95
|
+
}
|
|
96
|
+
function assign(content, variable, line) {
|
|
97
|
+
const pattern = new RegExp(`^[ \\t]*(?:export[ \\t]+)?${variable}[ \\t]*=.*$`, 'mu');
|
|
98
|
+
if (pattern.test(content))
|
|
99
|
+
return content.replace(pattern, line);
|
|
100
|
+
const separator = content === '' || content.endsWith('\n') ? '' : '\n';
|
|
101
|
+
return `${content}${separator}${line}\n`;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Writes the assignments: into `.env.<environment>` when that file already defines the variable
|
|
105
|
+
* (it overrides `.env`), and into `.env` otherwise. Returns a function that restores the previous
|
|
106
|
+
* contents (used when the config write does not happen after all).
|
|
107
|
+
*/
|
|
108
|
+
export async function writeSecretAssignments(projectRoot, environmentName, assignments) {
|
|
109
|
+
if (assignments.length === 0)
|
|
110
|
+
return async () => { };
|
|
111
|
+
const files = ['.env', `.env.${environmentName}`].map(name => join(projectRoot, name));
|
|
112
|
+
const originals = await Promise.all(files.map(readOptional));
|
|
113
|
+
const overlay = parseDotenv(originals[1] ?? '');
|
|
114
|
+
const byFile = new Map();
|
|
115
|
+
for (const item of assignments) {
|
|
116
|
+
const file = Object.hasOwn(overlay, item.variable) ? files[1] : files[0];
|
|
117
|
+
byFile.set(file, [...(byFile.get(file) ?? []), item]);
|
|
118
|
+
}
|
|
119
|
+
const written = [];
|
|
120
|
+
try {
|
|
121
|
+
for (const [file, items] of byFile) {
|
|
122
|
+
await updateDotenvFile(file, content => items.reduce((text, item) => assign(text, item.variable, dotenvLine(item.variable, item.value)), content));
|
|
123
|
+
written.push(file);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
await restore(written);
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
async function restore(targets) {
|
|
131
|
+
for (const file of targets) {
|
|
132
|
+
const original = originals[files.indexOf(file)];
|
|
133
|
+
if (original === undefined)
|
|
134
|
+
await rm(file, { force: true });
|
|
135
|
+
else
|
|
136
|
+
await updateDotenvFile(file, () => original);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return () => restore(written);
|
|
140
|
+
}
|