@edgehero/pi-dispatch 2.1.0 → 3.1.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/.env.example +41 -5
- package/README.md +11 -5
- package/deploy/docker-compose.yml +12 -0
- package/deploy/egress-proxy.conf +28 -3
- package/deploy/pi-dispatch-egress-proxy.container +8 -2
- package/package.json +9 -2
- package/src/allocation.mjs +731 -0
- package/src/backends.mjs +243 -0
- package/src/budget.mjs +40 -4
- package/src/cli.mjs +222 -11
- package/src/config.mjs +126 -5
- package/src/daemon-facts.mjs +3 -0
- package/src/deployment-venue.mjs +1 -0
- package/src/doctor.mjs +2280 -195
- package/src/dollar-budget.mjs +373 -0
- package/src/dollar-fingerprint.mjs +83 -0
- package/src/egress-cli.mjs +316 -0
- package/src/egress-proxy-state.mjs +35 -5
- package/src/egress.mjs +12 -0
- package/src/env-allowlist.mjs +125 -6
- package/src/env-file.mjs +194 -25
- package/src/envelope.mjs +413 -0
- package/src/exit-code.mjs +22 -0
- package/src/fleet-lease.mjs +85 -25
- package/src/get-token.mjs +16 -5
- package/src/git-dirty.mjs +67 -0
- package/src/github-app-setup.mjs +6 -3
- package/src/github-host.mjs +5 -3
- package/src/host-pi.mjs +1 -1
- package/src/identity.mjs +2 -1
- package/src/image-preflight.mjs +98 -24
- package/src/image-ref.mjs +37 -0
- package/src/import-pi.mjs +4 -2
- package/src/index.mjs +407 -62
- package/src/init.mjs +18 -0
- package/src/job-id.mjs +26 -3
- package/src/live-probes.mjs +24 -9
- package/src/model-catalog.mjs +297 -0
- package/src/model-endpoints.mjs +671 -0
- package/src/model-ref.mjs +151 -0
- package/src/models-json.mjs +268 -0
- package/src/money.mjs +144 -0
- package/src/octokit-log.mjs +65 -0
- package/src/outbox-plan.mjs +218 -0
- package/src/outbox.mjs +29 -9
- package/src/output-cap.mjs +157 -0
- package/src/pause-windows.mjs +81 -2
- package/src/pi-model-loader.mjs +77 -0
- package/src/podman-stack.mjs +16 -3
- package/src/portfolio-snapshot.mjs +304 -0
- package/src/prepare-local.mjs +247 -12
- package/src/prepare.mjs +35 -3
- package/src/priorities.mjs +569 -0
- package/src/processor.mjs +599 -170
- package/src/project-id.mjs +17 -0
- package/src/projects.mjs +238 -0
- package/src/provider-steering.mjs +179 -65
- package/src/queue.mjs +111 -6
- package/src/reserved-env.mjs +31 -0
- package/src/run-container.mjs +59 -5
- package/src/run-history.mjs +379 -24
- package/src/run-mirror.mjs +30 -0
- package/src/runtime-settings.mjs +104 -9
- package/src/schedules.mjs +33 -1
- package/src/scoped-limits.mjs +447 -27
- package/src/service.mjs +15 -4
- package/src/session-store.mjs +131 -6
- package/src/start.mjs +528 -40
- package/src/triggers-file.mjs +65 -4
- package/src/triggers.mjs +141 -11
- package/src/up.mjs +308 -34
- package/src/valkey-endpoint.mjs +3 -2
package/src/env-file.mjs
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* Deliberately dependency-free (node:fs only, and only in the thin wrapper): it must stay importable
|
|
18
18
|
* from any future setup command without dragging worker config or queue deps along.
|
|
19
19
|
*/
|
|
20
|
-
import { chmodSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { chmodSync, chownSync, closeSync, constants as fsConstants, fchmodSync, fchownSync, fstatSync, fsyncSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
23
|
* Pure transform over .env TEXT: set `key` to `value` only where nothing is set yet.
|
|
@@ -104,6 +104,15 @@ function replacementLines(key, value, bare, wasComment, opts) {
|
|
|
104
104
|
* character, a `"`, a `%`, a `!`, a `^`, a character outside ASCII, and an `=` at the start of the value. Each
|
|
105
105
|
* row there says whether it is cmd's documented behaviour or a cautious refusal. `'` is ordinary there.
|
|
106
106
|
*/
|
|
107
|
+
/**
|
|
108
|
+
* The `code` on every error `renderEnvValue` throws (issue #522): the VALUE cannot be written, so a caller may add advice
|
|
109
|
+
* about the value (`up` says where its path came from). Every other refusal of the writer is about the FILE and carries
|
|
110
|
+
* its own fix, and `up` once gave those this one's advice, telling a gid mismatch to move the deployment somewhere
|
|
111
|
+
* "without that character in its path".
|
|
112
|
+
*/
|
|
113
|
+
export const ENV_VALUE_UNWRITABLE = "ENV_VALUE_UNWRITABLE";
|
|
114
|
+
const unwritableValue = (message) => Object.assign(new Error(message), { code: ENV_VALUE_UNWRITABLE });
|
|
115
|
+
|
|
107
116
|
export function renderEnvValue(value, { platform = process.platform } = {}) {
|
|
108
117
|
const v = String(value);
|
|
109
118
|
// The Windows loader keeps surrounding quotes as part of the value ("Values MUST be UNQUOTED", its own
|
|
@@ -111,16 +120,16 @@ export function renderEnvValue(value, { platform = process.platform } = {}) {
|
|
|
111
120
|
// refused rather than dressed in quotes that become part of a path.
|
|
112
121
|
if (platform === "win32") {
|
|
113
122
|
const bad = cmdValueRefusal(v);
|
|
114
|
-
if (bad !== null) throw
|
|
123
|
+
if (bad !== null) throw unwritableValue(`cannot write this value into a .env on Windows: it contains ${bad}, and the .cmd wrapper (deploy/worker-env-wrapper.cmd) cannot be shown to read that back as written. Choose a value without it, or give the service this key through its own environment (pi-dispatch service install --env-setup)`);
|
|
115
124
|
return v;
|
|
116
125
|
}
|
|
117
126
|
if (UNQUOTED_PLAIN.test(v)) return v;
|
|
118
|
-
if (v.includes("'") || /[\n\r]/.test(v)) throw
|
|
127
|
+
if (v.includes("'") || /[\n\r]/.test(v)) throw unwritableValue(`cannot write this value into a .env safely: ${v.includes("'") ? "it contains a single quote" : "it contains a newline"}`);
|
|
119
128
|
// Gate round 2 of PR #478: a control or invisible character is read alike by every loader inside single quotes, but
|
|
120
129
|
// the reader never vouches for it (`QUOTED_CONTROL`: printing it rewrites a terminal), so writing it quoted gave doctor
|
|
121
130
|
// a line it calls unread. Refused instead, naming the character, never the value.
|
|
122
131
|
const hidden = invisibleCharacter(v);
|
|
123
|
-
if (hidden !== null) throw
|
|
132
|
+
if (hidden !== null) throw unwritableValue(`cannot write this value into a .env safely: it contains ${hidden}, which doctor would not show back. Remove it`);
|
|
124
133
|
return `'${v}'`;
|
|
125
134
|
}
|
|
126
135
|
|
|
@@ -869,6 +878,104 @@ export function envFileEditCheck(content, path, key, value, opts = {}) {
|
|
|
869
878
|
return planEnvEdit(content, path, key, value, opts).error ?? null;
|
|
870
879
|
}
|
|
871
880
|
|
|
881
|
+
/**
|
|
882
|
+
* The group-mismatch refusal (issue #522), its own sentence with its own fix. The group is named where `/etc/group`
|
|
883
|
+
* names it, because "gid 0" sends an operator to look it up and `wheel` does not. It is reached only where the writer
|
|
884
|
+
* could not keep the group (`updateEnvFile` gives the new file the old one wherever this account is in it), and only
|
|
885
|
+
* for a file this account OWNS (the owner refusal comes first), so "run it as another account" is no fix: any other
|
|
886
|
+
* account, a member of that group or root, gets the owner refusal instead. The two fixes are this account joining that
|
|
887
|
+
* group (a new login picks it up), or a group this account is in: `own`, this process's primary group, which an
|
|
888
|
+
* owner can always give its file, and which every later rewrite then keeps. The second is the operator's decision where
|
|
889
|
+
* a service reads the file through its present group, so the sentence says so rather than issuing the chgrp.
|
|
890
|
+
*/
|
|
891
|
+
export function groupRefusal(target, group, made, own, groupName = groupNameOf) {
|
|
892
|
+
const named = (id) => {
|
|
893
|
+
const name = groupName(id);
|
|
894
|
+
return name ? `${name} (gid ${id})` : `gid ${id}`;
|
|
895
|
+
};
|
|
896
|
+
const fix = own === null ? "" : `; or, if nothing reads this file through its present group, run \`chgrp ${groupName(own) ?? own} ${quotedForShell(target)}\` and run this again`;
|
|
897
|
+
return `refusing to edit ${target}: its group is ${named(group)}, which this account could not give the new file, and a new file written beside it gets ${named(made)}, so rewriting it (a new file renamed over it) would change its group, which is how a .env the service reads through its group stops being readable. To fix it, add this account to ${groupName(group) ?? `gid ${group}`} and log in again${fix}; otherwise edit the file by hand. Nothing was written`;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/** A group's name from `/etc/group`, or `null` (no such line, no such file, or not a POSIX host). */
|
|
901
|
+
export function groupNameOf(id, read = () => readFileSync("/etc/group", "utf8")) {
|
|
902
|
+
try {
|
|
903
|
+
for (const line of String(read()).split("\n")) {
|
|
904
|
+
if (line.startsWith("#")) continue;
|
|
905
|
+
const [name, , gid] = line.split(":");
|
|
906
|
+
// A digits-only field, so an empty one (which `Number` reads as 0) never names gid 0.
|
|
907
|
+
if (name && /^\d+$/.test(gid ?? "") && Number(gid) === id) return name;
|
|
908
|
+
}
|
|
909
|
+
} catch {
|
|
910
|
+
// No group database to read: the number alone is said.
|
|
911
|
+
}
|
|
912
|
+
return null;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** `p` as one shell word: bare when it is plainly safe, single-quoted otherwise. */
|
|
916
|
+
function quotedForShell(p) {
|
|
917
|
+
return /^[A-Za-z0-9_./@%+=:,-]+$/.test(p) ? p : `'${String(p).replace(/'/g, "'\\''")}'`;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Every call `updateEnvFile` makes, for a production caller's fs seam to spread (issue #522's review). The descriptor
|
|
922
|
+
* calls are optional on the seam, because a test fake has none, so a seam that drops one does not fail: the writer
|
|
923
|
+
* quietly goes back to chowning and chmodding the tmp BY PATH, which a swapped tmp redirects. One list, spread by
|
|
924
|
+
* `up`, `service install` and `setup github`, and pinned by a test that reads all four objects.
|
|
925
|
+
*/
|
|
926
|
+
export const ENV_WRITER_FS = Object.freeze({ readFileSync, writeFileSync, renameSync, statSync, chmodSync, chownSync, realpathSync, unlinkSync, openSync, fstatSync, fchownSync, fchmodSync, fsyncSync, closeSync });
|
|
927
|
+
|
|
928
|
+
/** `open(2)` flags for the tmp: created here or refused, never through a link (`O_NOFOLLOW` is POSIX-only; 0 elsewhere). */
|
|
929
|
+
const EXCLUSIVE_CREATE = fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0);
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* The tmp, created exclusively at 0600 with `content`, and the four things the writer does to it. Through a DESCRIPTOR
|
|
933
|
+
* where the seam has one (every production seam), so a path swapped after the create is never what is chowned or
|
|
934
|
+
* chmodded; a seam without `openSync` (a test fake) gets the same calls by path, still with the exclusive create.
|
|
935
|
+
*/
|
|
936
|
+
function createTmp(fs, tmp, content) {
|
|
937
|
+
if (typeof fs.openSync === "function") {
|
|
938
|
+
const fd = fs.openSync(tmp, EXCLUSIVE_CREATE, 0o600);
|
|
939
|
+
try {
|
|
940
|
+
fs.writeFileSync(fd, content);
|
|
941
|
+
} catch (err) {
|
|
942
|
+
fs.closeSync(fd);
|
|
943
|
+
try {
|
|
944
|
+
fs.unlinkSync?.(tmp);
|
|
945
|
+
} catch {
|
|
946
|
+
// The write's error is the one to report.
|
|
947
|
+
}
|
|
948
|
+
throw err;
|
|
949
|
+
}
|
|
950
|
+
let open = true;
|
|
951
|
+
const shut = () => {
|
|
952
|
+
if (open) fs.closeSync(fd);
|
|
953
|
+
open = false;
|
|
954
|
+
};
|
|
955
|
+
return {
|
|
956
|
+
gid: () => fs.fstatSync(fd).gid,
|
|
957
|
+
chgrp: (g) => fs.fchownSync(fd, -1, g),
|
|
958
|
+
chmod: (m) => fs.fchmodSync(fd, m),
|
|
959
|
+
close: () => {
|
|
960
|
+
try {
|
|
961
|
+
fs.fsyncSync?.(fd);
|
|
962
|
+
} finally {
|
|
963
|
+
shut();
|
|
964
|
+
}
|
|
965
|
+
},
|
|
966
|
+
abandon: shut,
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
fs.writeFileSync(tmp, content, { mode: 0o600, flag: "wx" });
|
|
970
|
+
return {
|
|
971
|
+
gid: () => fs.statSync(tmp).gid,
|
|
972
|
+
chgrp: (g) => fs.chownSync(tmp, -1, g),
|
|
973
|
+
chmod: (m) => fs.chmodSync(tmp, m),
|
|
974
|
+
close: () => {},
|
|
975
|
+
abandon: () => {},
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
|
|
872
979
|
/**
|
|
873
980
|
* Read → transform → write back ATOMICALLY (tmp + rename, the same shape as the admin's
|
|
874
981
|
* writeTriggers), so a watcher or a concurrent reader never sees a half-written .env. When the
|
|
@@ -895,7 +1002,7 @@ export function updateEnvFile(path, key, value, deps = {}) {
|
|
|
895
1002
|
// and the cmd wrapper kept the quotes, making the path the worker loads wrong behind a ✓ on the key
|
|
896
1003
|
// that decides forge auth. A rendering rule that every writer of this file must remember is a rule one
|
|
897
1004
|
// of them will forget.
|
|
898
|
-
const { fs =
|
|
1005
|
+
const { fs = ENV_WRITER_FS, overwrite = false, platform = process.platform, narrow = false } = deps;
|
|
899
1006
|
// Every refusal is `planEnvEdit`'s (bytes first, never clobber, read back), made before anything is written.
|
|
900
1007
|
const plan = planEnvEdit(fs.readFileSync(path), path, key, value, { overwrite, platform, verify: deps.verify });
|
|
901
1008
|
if (plan.error) throw new Error(plan.error);
|
|
@@ -911,42 +1018,104 @@ export function updateEnvFile(path, key, value, deps = {}) {
|
|
|
911
1018
|
} catch {
|
|
912
1019
|
// Not resolvable (a dangling link, a fs without the call): edit the path we were given.
|
|
913
1020
|
}
|
|
914
|
-
// OWNERSHIP, before
|
|
1021
|
+
// OWNERSHIP, before the file is replaced. `renameSync` makes a new inode owned by whoever runs this, so
|
|
915
1022
|
// a root- or `pi`-owned `.env` at 0640 that the service reads through its group comes back owned by the
|
|
916
1023
|
// operator: the service account loses read access, and `deploy/worker.service` uses a bare
|
|
917
1024
|
// `EnvironmentFile=` (fatal, not `-`), so the unit stops starting. Widening the mode to compensate
|
|
918
1025
|
// would publish a file holding WEBHOOK_SECRET. Refusing is the only honest third option, and the caller
|
|
919
|
-
// turns it into a line rather than a stack trace.
|
|
1026
|
+
// turns it into a line rather than a stack trace. Each refusal carries its own fix (issue #522).
|
|
920
1027
|
const uid = typeof process.getuid === "function" ? process.getuid() : null;
|
|
921
1028
|
const gid = typeof process.getgid === "function" ? process.getgid() : null;
|
|
1029
|
+
let group = null;
|
|
922
1030
|
if (uid !== null) {
|
|
923
1031
|
try {
|
|
924
|
-
const { uid: owner, gid:
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
// rename still makes a new inode with the writer's primary gid, and the `pi` service loses read
|
|
928
|
-
// access exactly as it would have on a uid mismatch.
|
|
929
|
-
if (typeof owner === "number" && owner !== uid) throw new Error(`refusing to edit ${target}: it is owned by uid ${owner} and this process is uid ${uid}, and rewriting it would hand it to the wrong account`);
|
|
930
|
-
if (typeof group === "number" && gid !== null && group !== gid) throw new Error(`refusing to edit ${target}: its group is gid ${group} and this process is gid ${gid}, and rewriting it would hand it to the wrong group, which is how a 0640 .env stops being readable by the service`);
|
|
1032
|
+
const { uid: owner, gid: had } = fs.statSync(target);
|
|
1033
|
+
if (typeof owner === "number" && owner !== uid) throw new Error(`refusing to edit ${target}: it is owned by uid ${owner} and this process is uid ${uid}, and rewriting it (a new file renamed over it) would give it to this account, which is how a .env the service reads stops being readable. To fix it, run this as the account that owns it, or edit the file by hand. Nothing was written`);
|
|
1034
|
+
if (typeof had === "number") group = had;
|
|
931
1035
|
} catch (err) {
|
|
932
1036
|
if (err instanceof Error && err.message.startsWith("refusing to edit")) throw err;
|
|
933
1037
|
// Cannot stat: fall through to the write, which will fail on its own terms if it must.
|
|
934
1038
|
}
|
|
935
1039
|
}
|
|
936
1040
|
const tmp = `${target}.tmp`;
|
|
937
|
-
|
|
1041
|
+
// CREATED HERE, exclusively, or not at all (issue #522's review). The folder may be one another account can write (a
|
|
1042
|
+
// setgid 2770 folder shared with the service's group), and a `.env.tmp` planted there as a symlink was followed by
|
|
1043
|
+
// the write, by the chown that keeps the group, and by the chmod: content at the link's target, and that file's
|
|
1044
|
+
// group and mode changed. `O_EXCL` refuses any existing path, a symlink included (POSIX: not followed), and
|
|
1045
|
+
// `O_NOFOLLOW` says so twice where the platform has it; everything after the create goes through the descriptor, so
|
|
1046
|
+
// a tmp swapped after the create is not what is chowned or chmodded. An existing tmp is refused and left alone: it is
|
|
1047
|
+
// not this writer's to remove.
|
|
1048
|
+
let handle;
|
|
938
1049
|
try {
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1050
|
+
handle = createTmp(fs, tmp, next);
|
|
1051
|
+
} catch (err) {
|
|
1052
|
+
if (err?.code === "EEXIST" || err?.code === "ELOOP") throw new Error(`refusing to edit ${target}: ${tmp} already exists (left by an edit that was interrupted, or put there by another account that can write this folder), and this writer only writes through a file it has just created. Remove it, then run this again. Nothing was written`);
|
|
1053
|
+
throw err;
|
|
1054
|
+
}
|
|
1055
|
+
// GROUP, as well as owner, because the layout this protects is a `.env` at 0640 read by the service THROUGH ITS GROUP:
|
|
1056
|
+
// with `bob:pi 0640` and the operator in group `pi`, the uid matches and the rename can still change the group.
|
|
1057
|
+
// MEASURED on the tmp, not predicted from this process's gid (issue #522). Which group a new file gets is the
|
|
1058
|
+
// folder's business: on macOS and the BSDs it is always the folder's group, and on Linux it is the folder's group
|
|
1059
|
+
// where the folder is setgid (or mounted `grpid`), else this process's. The prediction refused every edit to a
|
|
1060
|
+
// `.env` that `init` had just made in a folder of group `wheel` (gid 0, as `/private/tmp` is): the file took the
|
|
1061
|
+
// folder's group, this process's was `staff`, and the rewrite would have kept `wheel` all along. The tmp holds the
|
|
1062
|
+
// new content at 0600 and is removed before the refusal, so nothing the operator reads has changed.
|
|
1063
|
+
// EVERYTHING after the create removes the tmp on any failure (issue #522's review, round 2): a flush or a rename that
|
|
1064
|
+
// throws (EPERM from a Windows scanner holding the file, a full disk at fsync) left this writer's OWN tmp behind, and
|
|
1065
|
+
// every later edit then refused it as one that "already exists". Removing it here is safe where removing one found at
|
|
1066
|
+
// the create is not: this one was created by this call, exclusively. The original error is rethrown; where the tmp
|
|
1067
|
+
// cannot be removed, its message says where the new content was left instead of claiming nothing was written.
|
|
1068
|
+
try {
|
|
1069
|
+
if (group !== null) {
|
|
1070
|
+
let made = null;
|
|
1071
|
+
try {
|
|
1072
|
+
made = handle.gid();
|
|
1073
|
+
} catch {
|
|
1074
|
+
// Cannot stat what was just written: predict as a folder without setgid would make it.
|
|
1075
|
+
}
|
|
1076
|
+
if (typeof made !== "number") made = gid;
|
|
1077
|
+
// KEPT where this account may keep it: an owner may give a file any group it is in, so the tmp takes the file's
|
|
1078
|
+
// group and the rewrite changes nothing (`bob:pi` with the operator in `pi`, or a `staff` file in a `wheel`
|
|
1079
|
+
// folder). Refused only where that fails, which is a group this account is not in.
|
|
1080
|
+
if (made !== null && made !== group) {
|
|
1081
|
+
try {
|
|
1082
|
+
handle.chgrp(group);
|
|
1083
|
+
made = handle.gid();
|
|
1084
|
+
} catch {
|
|
1085
|
+
// EPERM, a group this account is not in: the refusal below says so.
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
if (made !== null && made !== group) {
|
|
1089
|
+
throw new Error(groupRefusal(target, group, made, gid, deps.groupName ?? groupNameOf));
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
try {
|
|
1093
|
+
// The operator's mode, whatever it is, not just 0600. `.env` holds WEBHOOK_SECRET and provider
|
|
1094
|
+
// keys, and a rename from a fresh tmp lands at the process umask: 0640 and 0400 both came back
|
|
1095
|
+
// 0644, world-readable, on the one file this project says must never reach a scrollback.
|
|
1096
|
+
// `narrow` keeps only the owner's bits of it.
|
|
1097
|
+
const mode = fs.statSync(target).mode & 0o7777;
|
|
1098
|
+
handle.chmod(narrow ? mode & 0o7700 : mode);
|
|
1099
|
+
} catch {
|
|
1100
|
+
// The file vanished between read and write, or the fs cannot stat: the tmp keeps the 0600 it was
|
|
1101
|
+
// created with rather than failing an edit that is otherwise sound.
|
|
1102
|
+
}
|
|
1103
|
+
handle.close();
|
|
1104
|
+
fs.renameSync(tmp, target);
|
|
1105
|
+
} catch (err) {
|
|
1106
|
+
handle.abandon();
|
|
1107
|
+
let removed = typeof fs.unlinkSync === "function";
|
|
1108
|
+
try {
|
|
1109
|
+
if (removed) fs.unlinkSync(tmp);
|
|
1110
|
+
} catch {
|
|
1111
|
+
removed = false;
|
|
1112
|
+
}
|
|
1113
|
+
if (!removed && err instanceof Error) {
|
|
1114
|
+
const left = `${target} itself was not changed, but the new content was left in ${tmp} (mode 0600), which could not be removed: remove it`;
|
|
1115
|
+
err.message = err.message.endsWith(". Nothing was written") ? `${err.message.slice(0, -"Nothing was written".length)}${left}` : `${err.message}. ${left}`;
|
|
1116
|
+
}
|
|
1117
|
+
throw err;
|
|
948
1118
|
}
|
|
949
|
-
fs.renameSync(tmp, target);
|
|
950
1119
|
return { changed: true, ...(narrow ? { narrowed: true } : {}) };
|
|
951
1120
|
}
|
|
952
1121
|
|