agentsw 0.7.1 → 0.7.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/README.md +30 -11
- package/README.zh-CN.md +26 -11
- package/dist/apps.js +48 -44
- package/dist/commands.js +221 -74
- package/dist/config-transaction.js +169 -84
- package/dist/discover.js +55 -17
- package/dist/envfile.js +101 -20
- package/dist/fsutil.js +109 -14
- package/dist/i18n.js +2 -1
- package/dist/import.js +2 -2
- package/dist/index.js +16 -1
- package/dist/jsonc.js +4 -10
- package/dist/modelsdev.js +7 -3
- package/dist/provider-identity.js +25 -0
- package/dist/remove.js +84 -38
- package/dist/rename.js +91 -58
- package/dist/store.js +38 -7
- package/dist/target-transaction.js +25 -0
- package/dist/targets/claudecode.js +12 -3
- package/dist/targets/codex.js +34 -3
- package/dist/targets/dsh.js +117 -65
- package/dist/targets/hermes.js +98 -49
- package/dist/targets/omp.js +11 -47
- package/dist/targets/opencode.js +27 -6
- package/dist/targets/pistyle.js +12 -8
- package/dist/targets/wire.js +15 -9
- package/dist/targets/workbuddy.js +151 -76
- package/dist/yaml.js +39 -0
- package/package.json +4 -2
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
-
import { backupsDir } from "./fsutil.js";
|
|
5
|
-
function
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
catch (error) {
|
|
13
|
-
if (error.code === "ENOENT")
|
|
14
|
-
return { text: undefined };
|
|
15
|
-
throw new Error(`${file}: cannot read a regular configuration file`);
|
|
4
|
+
import { appDataDir, backupsDir, isDryRun, readFileSnapshot } from "./fsutil.js";
|
|
5
|
+
function sameSnapshot(actual, expected) {
|
|
6
|
+
return actual.text === expected.text && actual.mode === expected.mode && actual.identity === expected.identity;
|
|
7
|
+
}
|
|
8
|
+
function assertSnapshot(file, expected) {
|
|
9
|
+
if (!sameSnapshot(readFileSnapshot(file), expected)) {
|
|
10
|
+
throw new Error(`${file}: configuration changed since it was read`);
|
|
16
11
|
}
|
|
17
12
|
}
|
|
18
13
|
function atomicWrite(file, text, mode) {
|
|
@@ -20,16 +15,94 @@ function atomicWrite(file, text, mode) {
|
|
|
20
15
|
try {
|
|
21
16
|
fs.writeFileSync(temp, text, { flag: "wx", mode });
|
|
22
17
|
fs.chmodSync(temp, mode);
|
|
18
|
+
const stat = fs.lstatSync(temp, { bigint: true });
|
|
23
19
|
fs.renameSync(temp, file);
|
|
20
|
+
return { text, mode: Number(stat.mode & 511n), identity: `${stat.dev}:${stat.ino}` };
|
|
24
21
|
}
|
|
25
22
|
finally {
|
|
26
23
|
fs.rmSync(temp, { force: true });
|
|
27
24
|
}
|
|
28
25
|
}
|
|
26
|
+
// Store saves and all adapter/management commits share this cross-process lock.
|
|
27
|
+
// It covers synchronous validation/commit only, never discovery or network waits.
|
|
28
|
+
const lockFile = path.join(appDataDir("agentsw"), ".write.lock");
|
|
29
|
+
function writeLockBusyError() {
|
|
30
|
+
let owner;
|
|
31
|
+
try {
|
|
32
|
+
const stat = fs.lstatSync(lockFile);
|
|
33
|
+
if (stat.isFile() && stat.size <= 4096) {
|
|
34
|
+
const value = JSON.parse(fs.readFileSync(lockFile, "utf8"));
|
|
35
|
+
if (value && typeof value === "object" && "pid" in value && "createdAt" in value &&
|
|
36
|
+
typeof value.pid === "number" && Number.isInteger(value.pid) && value.pid > 0 && value.pid <= 0x7fffffff &&
|
|
37
|
+
typeof value.createdAt === "string" && Number.isFinite(Date.parse(value.createdAt))) {
|
|
38
|
+
owner = { pid: value.pid, createdAt: new Date(value.createdAt).toISOString() };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch { /* An empty, unreadable, or initializing lock is not evidence that its owner exited. */ }
|
|
43
|
+
let status = "owner metadata is missing or unreadable; the lock may still be initializing";
|
|
44
|
+
if (owner) {
|
|
45
|
+
status = `owner PID ${owner.pid}, created ${owner.createdAt}; process status could not be determined`;
|
|
46
|
+
try {
|
|
47
|
+
process.kill(owner.pid, 0);
|
|
48
|
+
return new Error(`${lockFile}: configuration write busy; owner PID ${owner.pid} is running on this host (created ${owner.createdAt}); wait for it to finish; do not remove an active lock`);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error.code === "ESRCH") {
|
|
52
|
+
status = `owner PID ${owner.pid} is no longer running on this host (created ${owner.createdAt}); a previous writer may have crashed`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// No timeout-based or automatic unlink: PID reuse, shared homes, and initializing writers
|
|
57
|
+
// make even an apparently abandoned lock unsafe to steal while other writers can start.
|
|
58
|
+
return new Error(`${lockFile}: configuration write busy; ${status}; recovery: stop all agentsw writers, confirm this lock is abandoned, then manually remove ${lockFile} and retry; never remove a lock held by an active writer`);
|
|
59
|
+
}
|
|
60
|
+
function withWriteLock(operation) {
|
|
61
|
+
fs.mkdirSync(path.dirname(lockFile), { recursive: true, mode: 0o700 });
|
|
62
|
+
let fd;
|
|
63
|
+
try {
|
|
64
|
+
fd = fs.openSync(lockFile, "wx", 0o600);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error.code === "EEXIST") {
|
|
68
|
+
throw writeLockBusyError();
|
|
69
|
+
}
|
|
70
|
+
throw new Error(`${lockFile}: cannot acquire configuration write lock`);
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const owned = fs.fstatSync(fd, { bigint: true });
|
|
74
|
+
try {
|
|
75
|
+
try {
|
|
76
|
+
fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }) + "\n");
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw new Error(`${lockFile}: cannot record configuration write lock owner`);
|
|
80
|
+
}
|
|
81
|
+
return operation();
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
// Keep the descriptor open while checking ownership, preventing inode reuse.
|
|
85
|
+
// A replaced lock belongs to somebody else; never remove it on their behalf.
|
|
86
|
+
try {
|
|
87
|
+
const current = fs.lstatSync(lockFile, { bigint: true });
|
|
88
|
+
if (current.dev === owned.dev && current.ino === owned.ino)
|
|
89
|
+
fs.unlinkSync(lockFile);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (error.code !== "ENOENT")
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
fs.closeSync(fd);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
29
101
|
/** Validate the entire plan and save every original before mutating any configuration. */
|
|
30
102
|
export function commitFileChanges(changes, opts = {}) {
|
|
31
103
|
const paths = new Set();
|
|
32
104
|
const identities = new Set();
|
|
105
|
+
const reads = new Map(opts.reads);
|
|
33
106
|
const plan = changes.map((change) => {
|
|
34
107
|
const file = path.resolve(change.file);
|
|
35
108
|
// Resolve the parent too, so directory symlinks cannot disguise duplicate plans.
|
|
@@ -45,94 +118,106 @@ export function commitFileChanges(changes, opts = {}) {
|
|
|
45
118
|
if (paths.has(canonical))
|
|
46
119
|
throw new Error(`${file}: duplicate file plan`);
|
|
47
120
|
paths.add(canonical);
|
|
48
|
-
const current =
|
|
121
|
+
const current = readFileSnapshot(file);
|
|
49
122
|
if (current.identity && identities.has(current.identity))
|
|
50
123
|
throw new Error(`${file}: duplicate file plan`);
|
|
51
124
|
if (current.identity)
|
|
52
125
|
identities.add(current.identity);
|
|
53
|
-
if (current.text !== change.before)
|
|
126
|
+
if (current.text !== change.before || (change.expected && !sameSnapshot(current, change.expected))) {
|
|
54
127
|
throw new Error(`${file}: configuration changed since it was read`);
|
|
128
|
+
}
|
|
55
129
|
if (change.mode !== undefined && (!Number.isInteger(change.mode) || change.mode < 0 || change.mode > 0o777)) {
|
|
56
130
|
throw new Error(`${file}: invalid file permissions`);
|
|
57
131
|
}
|
|
58
|
-
|
|
59
|
-
|
|
132
|
+
if (!reads.has(file))
|
|
133
|
+
reads.set(file, change.expected ?? current);
|
|
134
|
+
return { ...change, file, original: current };
|
|
135
|
+
}).filter((change) => change.before !== change.after || (change.original.text !== undefined && change.mode !== undefined && change.mode !== change.original.mode));
|
|
136
|
+
const validateReads = () => {
|
|
137
|
+
for (const [file, expected] of reads)
|
|
138
|
+
assertSnapshot(file, expected);
|
|
139
|
+
};
|
|
140
|
+
validateReads();
|
|
60
141
|
const files = plan.map((change) => change.file);
|
|
61
|
-
if (!plan.length || opts.dryRun)
|
|
142
|
+
if (!plan.length || (opts.dryRun ?? isDryRun()))
|
|
62
143
|
return { files };
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
144
|
+
return withWriteLock(() => {
|
|
145
|
+
// Another writer may have committed between planning and acquiring the lock.
|
|
146
|
+
validateReads();
|
|
147
|
+
let backupDir;
|
|
148
|
+
let currentFile = backupsDir;
|
|
149
|
+
try {
|
|
150
|
+
fs.mkdirSync(backupsDir, { recursive: true, mode: 0o700 });
|
|
151
|
+
backupDir = fs.mkdtempSync(path.join(backupsDir, "transaction-"));
|
|
152
|
+
fs.chmodSync(backupDir, 0o700);
|
|
153
|
+
for (const [index, change] of plan.entries()) {
|
|
154
|
+
currentFile = change.file;
|
|
155
|
+
if (change.before === undefined)
|
|
156
|
+
continue;
|
|
157
|
+
const backup = path.join(backupDir, `${index}-${path.basename(change.file)}`);
|
|
158
|
+
fs.writeFileSync(backup, change.before, { flag: "wx", mode: 0o600 });
|
|
159
|
+
fs.chmodSync(backup, 0o600);
|
|
160
|
+
}
|
|
161
|
+
fs.writeFileSync(path.join(backupDir, "manifest.json"), JSON.stringify(plan.map((change, index) => ({
|
|
162
|
+
file: change.file,
|
|
163
|
+
backup: change.before === undefined ? null : `${index}-${path.basename(change.file)}`,
|
|
164
|
+
mode: change.original.mode,
|
|
165
|
+
})), null, 2) + "\n", { flag: "wx", mode: 0o600 });
|
|
166
|
+
fs.chmodSync(path.join(backupDir, "manifest.json"), 0o600);
|
|
167
|
+
validateReads();
|
|
76
168
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
backup: change.before === undefined ? null : `${index}-${path.basename(change.file)}`,
|
|
80
|
-
mode: change.originalMode,
|
|
81
|
-
})), null, 2) + "\n", { flag: "wx", mode: 0o600 });
|
|
82
|
-
for (const change of plan) {
|
|
83
|
-
currentFile = change.file;
|
|
84
|
-
if (snapshot(change.file).text !== change.before)
|
|
85
|
-
throw new Error("stale snapshot");
|
|
169
|
+
catch {
|
|
170
|
+
throw new Error(`${currentFile}: backup or pre-write validation failed; configuration files were not changed`);
|
|
86
171
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
172
|
+
const applied = [];
|
|
173
|
+
const createdDirectories = [];
|
|
174
|
+
try {
|
|
175
|
+
for (const change of plan) {
|
|
176
|
+
currentFile = change.file;
|
|
177
|
+
assertSnapshot(change.file, change.original);
|
|
178
|
+
let written;
|
|
179
|
+
if (change.after === undefined) {
|
|
180
|
+
fs.unlinkSync(change.file);
|
|
181
|
+
written = { text: undefined };
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
const missing = [];
|
|
185
|
+
for (let dir = path.dirname(change.file); !fs.existsSync(dir); dir = path.dirname(dir))
|
|
186
|
+
missing.push(dir);
|
|
187
|
+
for (const dir of missing.reverse()) {
|
|
188
|
+
fs.mkdirSync(dir, { mode: 0o700 });
|
|
189
|
+
createdDirectories.push(dir);
|
|
190
|
+
}
|
|
191
|
+
written = atomicWrite(change.file, change.after, change.mode ?? change.original.mode ?? 0o600);
|
|
107
192
|
}
|
|
108
|
-
|
|
193
|
+
applied.push({ change, written });
|
|
194
|
+
reads.set(change.file, written);
|
|
109
195
|
}
|
|
110
|
-
|
|
196
|
+
validateReads();
|
|
111
197
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
failed.push(change.file);
|
|
198
|
+
catch {
|
|
199
|
+
const failed = [];
|
|
200
|
+
for (const { change, written } of applied.reverse()) {
|
|
201
|
+
try {
|
|
202
|
+
// Do not overwrite a third party's intervening edit while rolling back.
|
|
203
|
+
assertSnapshot(change.file, written);
|
|
204
|
+
if (change.before === undefined)
|
|
205
|
+
fs.unlinkSync(change.file);
|
|
206
|
+
else
|
|
207
|
+
atomicWrite(change.file, change.before, change.original.mode ?? 0o600);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
failed.push(change.file);
|
|
211
|
+
}
|
|
127
212
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
213
|
+
for (const dir of createdDirectories.reverse()) {
|
|
214
|
+
try {
|
|
215
|
+
fs.rmdirSync(dir);
|
|
216
|
+
}
|
|
217
|
+
catch { /* Only remove empty directories created by this transaction. */ }
|
|
132
218
|
}
|
|
133
|
-
|
|
219
|
+
throw new Error(`${currentFile}: configuration transaction failed; ${failed.length ? `rollback incomplete for ${failed.join(", ")}` : "previous writes rolled back"}; backups: ${backupDir}`);
|
|
134
220
|
}
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
return { files, backupDir };
|
|
221
|
+
return { files, backupDir };
|
|
222
|
+
});
|
|
138
223
|
}
|
package/dist/discover.js
CHANGED
|
@@ -1,23 +1,64 @@
|
|
|
1
|
+
/** Append operations to the path, never to a query string or fragment. */
|
|
2
|
+
function modelListUrl(baseUrl) {
|
|
3
|
+
const url = new URL(baseUrl);
|
|
4
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
5
|
+
throw new Error("provider URL must use HTTP or HTTPS");
|
|
6
|
+
const pathname = url.pathname.replace(/\/+$/, "");
|
|
7
|
+
url.pathname = /\/v\d+(?:beta\d*)?$/.test(pathname) ? `${pathname}/models` : `${pathname}/v1/models`;
|
|
8
|
+
url.hash = "";
|
|
9
|
+
return url;
|
|
10
|
+
}
|
|
11
|
+
function parseModelPage(body) {
|
|
12
|
+
if (!body || typeof body !== "object")
|
|
13
|
+
throw new Error("invalid model-list response");
|
|
14
|
+
const page = body;
|
|
15
|
+
const rows = page.data ?? page.models;
|
|
16
|
+
if (!Array.isArray(rows))
|
|
17
|
+
throw new Error("model-list response has no model array");
|
|
18
|
+
if (page.has_more !== undefined && typeof page.has_more !== "boolean")
|
|
19
|
+
throw new Error("invalid model-list pagination flag");
|
|
20
|
+
const ids = rows.flatMap((row) => {
|
|
21
|
+
if (!row || typeof row !== "object")
|
|
22
|
+
return [];
|
|
23
|
+
const id = row.id;
|
|
24
|
+
return typeof id === "string" && id.length > 0 ? [id] : [];
|
|
25
|
+
});
|
|
26
|
+
return { ids, hasMore: page.has_more === true, lastId: typeof page.last_id === "string" ? page.last_id : undefined };
|
|
27
|
+
}
|
|
1
28
|
/**
|
|
2
29
|
* List model ids from the provider's own /v1/models endpoint.
|
|
3
30
|
* Resellers expose the id list here but no metadata (context, thinking, modalities) —
|
|
4
31
|
* metadata is filled from models.dev afterwards (enrichModels).
|
|
5
32
|
*/
|
|
6
33
|
export async function discoverProviderModels(opts) {
|
|
7
|
-
const
|
|
8
|
-
const url = /\/v\d+$/.test(base) ? `${base}/models` : `${base}/v1/models`;
|
|
34
|
+
const url = modelListUrl(opts.baseUrl);
|
|
9
35
|
const headers = opts.protocol === "anthropic"
|
|
10
36
|
? { "x-api-key": opts.apiKey, "anthropic-version": "2023-06-01" }
|
|
11
37
|
: { authorization: `Bearer ${opts.apiKey}` };
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
38
|
+
const ids = new Set();
|
|
39
|
+
const cursors = new Set();
|
|
40
|
+
const initialCursor = url.searchParams.get("after_id");
|
|
41
|
+
if (initialCursor)
|
|
42
|
+
cursors.add(initialCursor);
|
|
43
|
+
while (true) {
|
|
44
|
+
const res = await fetch(url.toString(), { headers, signal: AbortSignal.timeout(20000) });
|
|
45
|
+
if (!res.ok)
|
|
46
|
+
throw new Error(`model discovery -> HTTP ${res.status}`);
|
|
47
|
+
const page = parseModelPage(await res.json());
|
|
48
|
+
for (const id of page.ids)
|
|
49
|
+
ids.add(id);
|
|
50
|
+
if (!page.hasMore)
|
|
51
|
+
break;
|
|
52
|
+
if (!page.lastId || !page.lastId.trim())
|
|
53
|
+
throw new Error("model-list pagination is missing last_id");
|
|
54
|
+
if (cursors.has(page.lastId))
|
|
55
|
+
throw new Error("model-list pagination repeated a cursor");
|
|
56
|
+
cursors.add(page.lastId);
|
|
57
|
+
url.searchParams.set("after_id", page.lastId);
|
|
58
|
+
}
|
|
59
|
+
if (ids.size === 0)
|
|
60
|
+
throw new Error("provider returned no models");
|
|
61
|
+
return [...ids].sort();
|
|
21
62
|
}
|
|
22
63
|
/**
|
|
23
64
|
* Probe which wire protocols an endpoint supports by hitting /v1/models
|
|
@@ -25,8 +66,7 @@ export async function discoverProviderModels(opts) {
|
|
|
25
66
|
* with a valid model list (at least one model id).
|
|
26
67
|
*/
|
|
27
68
|
export async function probeProtocols(opts) {
|
|
28
|
-
const
|
|
29
|
-
const url = /\/v\d+$/.test(base) ? `${base}/models` : `${base}/v1/models`;
|
|
69
|
+
const url = modelListUrl(opts.baseUrl).toString();
|
|
30
70
|
const attempts = [
|
|
31
71
|
{ protocol: "openai", headers: { authorization: `Bearer ${opts.apiKey}` } },
|
|
32
72
|
{ protocol: "anthropic", headers: { "x-api-key": opts.apiKey, "anthropic-version": "2023-06-01" } },
|
|
@@ -37,10 +77,8 @@ export async function probeProtocols(opts) {
|
|
|
37
77
|
const res = await fetch(url, { headers: a.headers, signal: AbortSignal.timeout(15000) });
|
|
38
78
|
if (!res.ok)
|
|
39
79
|
continue;
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
const ids = rows.map((r) => r.id).filter((id) => typeof id === "string" && id.length > 0);
|
|
43
|
-
if (ids.length > 0)
|
|
80
|
+
const page = parseModelPage(await res.json());
|
|
81
|
+
if (page.ids.length > 0)
|
|
44
82
|
supported.push(a.protocol);
|
|
45
83
|
}
|
|
46
84
|
catch {
|
package/dist/envfile.js
CHANGED
|
@@ -1,27 +1,108 @@
|
|
|
1
1
|
/** Tokenize complete dotenv assignments, including quoted multiline values. */
|
|
2
2
|
export function envAssignments(file, text) {
|
|
3
3
|
const result = [];
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
if (
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const quote = value[0];
|
|
15
|
-
value = value.slice(1, -1);
|
|
16
|
-
if (quote === '"')
|
|
17
|
-
value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r");
|
|
4
|
+
let position = 0;
|
|
5
|
+
const invalid = () => { throw new Error(`${file}: invalid environment configuration`); };
|
|
6
|
+
const skipSpace = () => { while (text[position] === " " || text[position] === "\t")
|
|
7
|
+
position++; };
|
|
8
|
+
while (position < text.length) {
|
|
9
|
+
const start = position;
|
|
10
|
+
skipSpace();
|
|
11
|
+
if (text[position] === "#") {
|
|
12
|
+
while (position < text.length && text[position] !== "\n" && text[position] !== "\r")
|
|
13
|
+
position++;
|
|
18
14
|
}
|
|
19
|
-
else
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
15
|
+
else if (position < text.length && text[position] !== "\r" && text[position] !== "\n") {
|
|
16
|
+
if (text.startsWith("export", position) && /[\t ]/.test(text[position + 6] ?? "")) {
|
|
17
|
+
position += 6;
|
|
18
|
+
skipSpace();
|
|
19
|
+
}
|
|
20
|
+
const offset = position;
|
|
21
|
+
const name = /^[A-Za-z_][A-Za-z0-9_]*/.exec(text.slice(position))?.[0];
|
|
22
|
+
if (!name)
|
|
23
|
+
return invalid();
|
|
24
|
+
position += name.length;
|
|
25
|
+
skipSpace();
|
|
26
|
+
if (text[position++] !== "=")
|
|
27
|
+
invalid();
|
|
28
|
+
skipSpace();
|
|
29
|
+
const valueStart = position;
|
|
30
|
+
let value;
|
|
31
|
+
const quote = text[position];
|
|
32
|
+
if (quote === '"' || quote === "'") {
|
|
33
|
+
position++;
|
|
34
|
+
const contentStart = position;
|
|
35
|
+
while (position < text.length && text[position] !== quote) {
|
|
36
|
+
if (quote === '"' && text[position] === "\\")
|
|
37
|
+
position++;
|
|
38
|
+
position++;
|
|
39
|
+
}
|
|
40
|
+
if (text[position] !== quote)
|
|
41
|
+
invalid();
|
|
42
|
+
value = text.slice(contentStart, position++);
|
|
43
|
+
if (quote === '"')
|
|
44
|
+
value = value.replace(/\\(\\|"|n|r)/g, (_escape, character) => character === "n" ? "\n" : character === "r" ? "\r" : character);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
while (position < text.length && !"\r\n#".includes(text[position]))
|
|
48
|
+
position++;
|
|
49
|
+
value = text.slice(valueStart, position).trimEnd();
|
|
50
|
+
}
|
|
51
|
+
const valueLength = quote === '"' || quote === "'" ? position - valueStart : value.length;
|
|
52
|
+
skipSpace();
|
|
53
|
+
if (text[position] === "#") {
|
|
54
|
+
while (position < text.length && text[position] !== "\n" && text[position] !== "\r")
|
|
55
|
+
position++;
|
|
56
|
+
}
|
|
57
|
+
if (position < text.length && text[position] !== "\r" && text[position] !== "\n")
|
|
58
|
+
invalid();
|
|
59
|
+
if (text[position] === "\r") {
|
|
60
|
+
if (text[position + 1] !== "\n")
|
|
61
|
+
invalid();
|
|
62
|
+
position++;
|
|
63
|
+
}
|
|
64
|
+
if (text[position] === "\n")
|
|
65
|
+
position++;
|
|
66
|
+
result.push({ name, offset, value, start, length: position - start, valueStart, valueLength });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (text[position] === "\r") {
|
|
70
|
+
if (text[position + 1] !== "\n")
|
|
71
|
+
invalid();
|
|
72
|
+
position++;
|
|
73
|
+
}
|
|
74
|
+
if (text[position] === "\n")
|
|
75
|
+
position++;
|
|
23
76
|
}
|
|
24
|
-
if (!trivia(text.slice(end)))
|
|
25
|
-
throw new Error(`${file}: invalid environment configuration`);
|
|
26
77
|
return result;
|
|
27
78
|
}
|
|
79
|
+
/** Quote only when necessary, with no dotenv escape round-trip ambiguity. */
|
|
80
|
+
function encodeEnvValue(value) {
|
|
81
|
+
if (/^[^\s#'"`]+$/.test(value) && !value.includes("\\"))
|
|
82
|
+
return value;
|
|
83
|
+
if (!value.includes("'"))
|
|
84
|
+
return `'${value}'`;
|
|
85
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r")}"`;
|
|
86
|
+
}
|
|
87
|
+
/** Update complete parsed values, retaining exports, comments and unrelated bytes. */
|
|
88
|
+
export function upsertEnvAssignment(file, text, name, value) {
|
|
89
|
+
const assignments = envAssignments(file, text).filter((assignment) => assignment.name === name);
|
|
90
|
+
const encoded = encodeEnvValue(value);
|
|
91
|
+
if (!assignments.length) {
|
|
92
|
+
const newline = text.includes("\r\n") ? "\r\n" : "\n";
|
|
93
|
+
return text + (text === "" || text.endsWith("\n") ? "" : newline) + `${name}=${encoded}${newline}`;
|
|
94
|
+
}
|
|
95
|
+
for (const assignment of assignments.reverse()) {
|
|
96
|
+
text = text.slice(0, assignment.valueStart) + encoded + text.slice(assignment.valueStart + assignment.valueLength);
|
|
97
|
+
}
|
|
98
|
+
return text;
|
|
99
|
+
}
|
|
100
|
+
/** Delete complete assignments, never lines embedded in another quoted value. */
|
|
101
|
+
export function removeEnvAssignments(file, text, names) {
|
|
102
|
+
const assignments = envAssignments(file, text);
|
|
103
|
+
for (const assignment of assignments.reverse()) {
|
|
104
|
+
if (names.has(assignment.name))
|
|
105
|
+
text = text.slice(0, assignment.start) + text.slice(assignment.start + assignment.length);
|
|
106
|
+
}
|
|
107
|
+
return text;
|
|
108
|
+
}
|