agentsw 0.7.0 → 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.
@@ -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 snapshot(file) {
6
- try {
7
- const stat = fs.lstatSync(file);
8
- if (!stat.isFile())
9
- throw new Error("not a regular file");
10
- return { text: fs.readFileSync(file, "utf8"), mode: stat.mode & 0o777, identity: `${stat.dev}:${stat.ino}` };
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 = snapshot(file);
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
- return { ...change, file, originalMode: current.mode };
59
- }).filter((change) => change.before !== change.after);
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
- let backupDir;
64
- let currentFile = backupsDir;
65
- try {
66
- fs.mkdirSync(backupsDir, { recursive: true, mode: 0o700 });
67
- backupDir = fs.mkdtempSync(path.join(backupsDir, "transaction-"));
68
- fs.chmodSync(backupDir, 0o700);
69
- for (const [index, change] of plan.entries()) {
70
- currentFile = change.file;
71
- if (change.before === undefined)
72
- continue;
73
- const backup = path.join(backupDir, `${index}-${path.basename(change.file)}`);
74
- fs.writeFileSync(backup, change.before, { flag: "wx", mode: 0o600 });
75
- fs.chmodSync(backup, 0o600);
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
- fs.writeFileSync(path.join(backupDir, "manifest.json"), JSON.stringify(plan.map((change, index) => ({
78
- file: change.file,
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
- catch {
89
- throw new Error(`${currentFile}: backup or pre-write validation failed; configuration files were not changed`);
90
- }
91
- const applied = [];
92
- const createdDirectories = [];
93
- try {
94
- for (const change of plan) {
95
- currentFile = change.file;
96
- if (snapshot(change.file).text !== change.before)
97
- throw new Error("stale snapshot");
98
- if (change.after === undefined)
99
- fs.unlinkSync(change.file);
100
- else {
101
- const missing = [];
102
- for (let dir = path.dirname(change.file); !fs.existsSync(dir); dir = path.dirname(dir))
103
- missing.push(dir);
104
- for (const dir of missing.reverse()) {
105
- fs.mkdirSync(dir, { mode: 0o700 });
106
- createdDirectories.push(dir);
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
- atomicWrite(change.file, change.after, change.mode ?? change.originalMode ?? 0o600);
193
+ applied.push({ change, written });
194
+ reads.set(change.file, written);
109
195
  }
110
- applied.push(change);
196
+ validateReads();
111
197
  }
112
- }
113
- catch {
114
- const failed = [];
115
- for (const change of applied.reverse()) {
116
- try {
117
- // Do not overwrite a third party's intervening edit while rolling back.
118
- if (snapshot(change.file).text !== change.after)
119
- throw new Error("stale rollback snapshot");
120
- if (change.before === undefined)
121
- fs.unlinkSync(change.file);
122
- else
123
- atomicWrite(change.file, change.before, change.originalMode ?? 0o600);
124
- }
125
- catch {
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
- for (const dir of createdDirectories.reverse()) {
130
- try {
131
- fs.rmdirSync(dir);
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
- catch { /* Only remove empty directories created by this transaction. */ }
219
+ throw new Error(`${currentFile}: configuration transaction failed; ${failed.length ? `rollback incomplete for ${failed.join(", ")}` : "previous writes rolled back"}; backups: ${backupDir}`);
134
220
  }
135
- throw new Error(`${currentFile}: configuration transaction failed; ${failed.length ? `rollback incomplete for ${failed.join(", ")}` : "previous writes rolled back"}; backups: ${backupDir}`);
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 base = opts.baseUrl.replace(/\/+$/, "");
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 res = await fetch(url, { headers, signal: AbortSignal.timeout(20000) });
13
- if (!res.ok)
14
- throw new Error(`${url} -> HTTP ${res.status}`);
15
- const body = (await res.json());
16
- const rows = body.data ?? body.models ?? [];
17
- const ids = rows.map((r) => r.id).filter((id) => typeof id === "string" && id.length > 0);
18
- if (ids.length === 0)
19
- throw new Error(`${url} returned no models`);
20
- return [...new Set(ids)].sort();
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 base = opts.baseUrl.replace(/\/+$/, "");
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 body = (await res.json());
41
- const rows = body.data ?? body.models ?? [];
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
- const pattern = /^([\t ]*(?:export[\t ]+)?)([A-Za-z_][A-Za-z0-9_]*)([\t ]*=[\t ]*)("(?:\\[\s\S]|[^"\\])*"|'[^']*'|[^\r\n]*)(?:[\t ]*#[^\r\n]*)?[\t ]*(?:\r?\n|$)/gm;
5
- let end = 0;
6
- const trivia = (text) => text.split(/\r?\n/).every((line) => !line.trim() || line.trimStart().startsWith("#"));
7
- for (const match of text.matchAll(pattern)) {
8
- if (!trivia(text.slice(end, match.index)))
9
- throw new Error(`${file}: invalid environment configuration`);
10
- let value = match[4];
11
- if (value.startsWith('"') || value.startsWith("'")) {
12
- if (value.at(-1) !== value[0])
13
- throw new Error(`${file}: invalid quoted environment value`);
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
- value = value.split("#", 1)[0].trim();
21
- result.push({ name: match[2], offset: match.index + match[1].length, value, start: match.index, length: match[0].length });
22
- end = match.index + match[0].length;
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
+ }