metergraph-cli 0.0.0-stage → 0.2.0-preview.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/LICENSE +201 -0
- package/README.md +876 -2
- package/assets/skill/SKILL.md +73 -0
- package/assets/skill/manifest.json +9 -0
- package/bin/metergraph.js +11 -0
- package/package.json +44 -4
- package/src/args.js +486 -0
- package/src/auth-binding.js +202 -0
- package/src/auth-browser.js +74 -0
- package/src/auth-callback.js +177 -0
- package/src/auth-login.js +410 -0
- package/src/auth-oauth.js +462 -0
- package/src/auth-session.js +174 -0
- package/src/auth-store.js +397 -0
- package/src/cli.js +166 -0
- package/src/constants.js +176 -0
- package/src/deployment-credential.js +207 -0
- package/src/deployment-route.js +449 -0
- package/src/doctor.js +208 -0
- package/src/http.js +129 -0
- package/src/origin.js +37 -0
- package/src/output.js +726 -0
- package/src/read-contract.js +601 -0
- package/src/read-output.js +216 -0
- package/src/read.js +325 -0
- package/src/setup-deployment.js +178 -0
- package/src/setup-env-acl.js +99 -0
- package/src/setup-env-git.js +103 -0
- package/src/setup-env-parse.js +169 -0
- package/src/setup-env.js +685 -0
- package/src/setup-state.js +141 -0
- package/src/setup.js +317 -0
- package/src/skill-bundle.js +53 -0
- package/src/skill.js +449 -0
- package/src/trace-contract.js +129 -0
- package/src/trace-open.js +36 -0
- package/src/transport.js +171 -0
- package/src/verify-output.js +36 -0
- package/src/verify.js +120 -0
package/src/setup-env.js
ADDED
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { resolveProject } from "./auth-binding.js";
|
|
6
|
+
import { Stop } from "./auth-store.js";
|
|
7
|
+
import { SKILL_CLIENTS } from "./constants.js";
|
|
8
|
+
import { aclStatus, protectFile, restoreAcl, saveAcl } from "./setup-env-acl.js";
|
|
9
|
+
import { appendRule, hasRule, ignoreRule, inspectGit, pathState } from "./setup-env-git.js";
|
|
10
|
+
import { ENV_NAMES, checkSerialized, isAppToken, parseEnv, parseIngestUrl, serializeEnv } from "./setup-env-parse.js";
|
|
11
|
+
|
|
12
|
+
export { ENV_NAMES, INGEST_PATHS, isAppToken, parseIngestUrl } from "./setup-env-parse.js";
|
|
13
|
+
|
|
14
|
+
// Private writer for the two Metergraph variables in a project env file:
|
|
15
|
+
// METERGRAPH_APP_TOKEN and METERGRAPH_INGEST_URL. It is an internal module
|
|
16
|
+
// for setup. Values come only from the caller (an authorized server
|
|
17
|
+
// response), never from arguments, the environment or stdin.
|
|
18
|
+
//
|
|
19
|
+
// preflightEnv resolves and reads the env file and checks Git and file
|
|
20
|
+
// protection, without writing anything
|
|
21
|
+
// commitEnv makes the file ignored and private, then upserts the
|
|
22
|
+
// values, under a project lock, or rolls everything back
|
|
23
|
+
// rollbackEnv undoes a commit only where the files are still exactly
|
|
24
|
+
// what the commit left
|
|
25
|
+
//
|
|
26
|
+
// The plan, receipt and rollback handle hold only public fields: a
|
|
27
|
+
// project-relative path, fixed status words and the two known variable
|
|
28
|
+
// names. Token values, file content, other variable names, absolute paths and
|
|
29
|
+
// raw errors stay inside this module, and every failure is a Stop with a
|
|
30
|
+
// fixed outcome and reason. currentEnvValues is the only way to read the
|
|
31
|
+
// existing values, for the caller to validate privately.
|
|
32
|
+
|
|
33
|
+
const IS_WINDOWS = process.platform === "win32";
|
|
34
|
+
const NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
35
|
+
const EMPTY = Buffer.alloc(0);
|
|
36
|
+
export const MAX_ENV_BYTES = 256 * 1024;
|
|
37
|
+
export const PROTECTION_KIND = IS_WINDOWS ? "windows_acl" : "posix_mode";
|
|
38
|
+
const LOCK = ".metergraph-env.lock";
|
|
39
|
+
const DEFAULT_WAIT_MS = 2000;
|
|
40
|
+
const MAX_WAIT_MS = 30000;
|
|
41
|
+
const DENIED = new Set(["EACCES", "EPERM", "EROFS"]);
|
|
42
|
+
|
|
43
|
+
const ENV_REASONS = { unsafe: "env_path_unsafe", notOwned: "env_not_owned", tooLarge: "env_too_large" };
|
|
44
|
+
const IGNORE_REASONS = { unsafe: "ignore_path_unsafe", notOwned: "ignore_not_owned", tooLarge: "ignore_too_large" };
|
|
45
|
+
|
|
46
|
+
// Path rules for a project-relative env file. Forward slashes only. Any
|
|
47
|
+
// character that is unsafe in a Windows file name, a Git pathspec or an
|
|
48
|
+
// ignore pattern is refused rather than escaped, except [ ] ! # which the
|
|
49
|
+
// ignore rule escapes.
|
|
50
|
+
const BAD_CHARACTER = /[\x00-\x1f\x7f<>:"|?*\\]/;
|
|
51
|
+
const DEVICE_NAME = /^(?:con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\..*)?$/i;
|
|
52
|
+
const ENV_BASENAME = /^\.env(?:\..+)?$|^.+\.env$/i;
|
|
53
|
+
const TEMPLATE_BASENAME = /\.(?:example|sample|template|dist|defaults?)$/i;
|
|
54
|
+
const PROTECTED_DIRS = new Set([
|
|
55
|
+
".git",
|
|
56
|
+
".github",
|
|
57
|
+
".metergraph",
|
|
58
|
+
...Object.values(SKILL_CLIENTS).map((client) => client.dir),
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const PLANS = new WeakMap();
|
|
62
|
+
const HANDLES = new WeakMap();
|
|
63
|
+
|
|
64
|
+
export const ENV_MESSAGES = Object.freeze({
|
|
65
|
+
invalid_project: "--project must name an existing directory.",
|
|
66
|
+
env_path_invalid:
|
|
67
|
+
"The env file path is not allowed. Use a project-relative path with forward slashes to a file named .env, .env.<name> or <name>.env.",
|
|
68
|
+
env_path_outside_project: "The env file path is outside the project. Nothing was changed.",
|
|
69
|
+
env_dir_missing: "The directory for the env file does not exist. Nothing was changed.",
|
|
70
|
+
env_dir_unsafe: "The directory for the env file can be changed by other users. Nothing was changed.",
|
|
71
|
+
env_path_unsafe:
|
|
72
|
+
"The env file path is a symbolic link, a hard link or not a regular file. Nothing was changed.",
|
|
73
|
+
env_not_owned: "The env file is owned by another user. Nothing was changed.",
|
|
74
|
+
env_too_large: "The env file is larger than this CLI reads. Nothing was changed.",
|
|
75
|
+
env_file_invalid: "The env file is not UTF-8 text. Nothing was changed.",
|
|
76
|
+
env_syntax_ambiguous:
|
|
77
|
+
"The env file has syntax this CLI cannot read without guessing. Edit it by hand. Nothing was changed.",
|
|
78
|
+
env_duplicate_assignment:
|
|
79
|
+
"The env file sets a Metergraph variable more than once. Keep one assignment and retry. Nothing was changed.",
|
|
80
|
+
env_tracked:
|
|
81
|
+
"The env file is tracked by Git. Remove it from the index yourself if that is intended, then retry. Nothing was changed.",
|
|
82
|
+
ignore_path_unsafe: "The project .gitignore is a symbolic link or not a regular file. Nothing was changed.",
|
|
83
|
+
ignore_not_owned: "The project .gitignore is owned by another user. Nothing was changed.",
|
|
84
|
+
ignore_too_large: "The project .gitignore is larger than this CLI reads. Nothing was changed.",
|
|
85
|
+
git_unavailable: "The project is in a Git repository but git could not be run. Nothing was changed.",
|
|
86
|
+
git_check_failed: "Git could not confirm how the env file is ignored or tracked. Nothing was changed.",
|
|
87
|
+
git_ignore_ineffective:
|
|
88
|
+
"Another ignore rule keeps the env file from being ignored by Git. Changes were rolled back.",
|
|
89
|
+
token_invalid: "The application token has an unexpected format. Nothing was written.",
|
|
90
|
+
ingest_url_invalid: "The ingest URL is not a supported Metergraph ingest URL. Nothing was written.",
|
|
91
|
+
env_changed: "The env file changed while setup was running. Changes were rolled back. Retry.",
|
|
92
|
+
ignore_changed: "The project .gitignore changed while setup was running. Changes were rolled back. Retry.",
|
|
93
|
+
env_locked: `Another setup may be running in this project. If none is, delete ${LOCK} and retry.`,
|
|
94
|
+
cancelled: "Setup was cancelled before the env file was changed.",
|
|
95
|
+
protection_failed:
|
|
96
|
+
"The env file could not be made readable only by you. Changes were rolled back.",
|
|
97
|
+
env_read_denied: "The env file or its directory could not be read. Nothing was changed.",
|
|
98
|
+
env_read_failed: "The env file could not be read. Nothing was changed.",
|
|
99
|
+
env_write_denied:
|
|
100
|
+
"The env file could not be written: permission denied or a read-only file system. Changes were rolled back.",
|
|
101
|
+
env_write_failed: "The env file could not be written. Changes were rolled back.",
|
|
102
|
+
rollback_incomplete:
|
|
103
|
+
"The env file could not be written and the rollback did not finish. Check the env file and .gitignore.",
|
|
104
|
+
rollback_conflict:
|
|
105
|
+
"A file changed after setup wrote it, so it was not rolled back. Your edit was kept.",
|
|
106
|
+
invalid_plan: "Internal error: the env plan is not valid or was already used.",
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Returns a frozen plan with public fields only. Nothing is written.
|
|
110
|
+
export function preflightEnv({ project, envFile = ".env", allowOutsideProject = false, signal = null } = {}) {
|
|
111
|
+
checkCancel(signal);
|
|
112
|
+
let state;
|
|
113
|
+
try {
|
|
114
|
+
const root = resolveProject(project);
|
|
115
|
+
const target = resolveTarget(root, envFile, allowOutsideProject);
|
|
116
|
+
checkDirSafe(target.dir);
|
|
117
|
+
const env = snapshot(target.file, ENV_REASONS);
|
|
118
|
+
const parsed = parseEnv(env?.content ?? EMPTY);
|
|
119
|
+
const protection = protectionState(target.file, env);
|
|
120
|
+
let ignore = null;
|
|
121
|
+
let git = null;
|
|
122
|
+
if (target.location === "project") {
|
|
123
|
+
checkDirSafe(root);
|
|
124
|
+
const file = path.join(root, ".gitignore");
|
|
125
|
+
ignore = { file, before: snapshot(file, IGNORE_REASONS), rule: ignoreRule(target.rel) };
|
|
126
|
+
checkCancel(signal);
|
|
127
|
+
git = inspectGit(root, target.rel);
|
|
128
|
+
}
|
|
129
|
+
state = { root, target, env, parsed, protection, ignore, git, used: false };
|
|
130
|
+
} catch (error) {
|
|
131
|
+
throw fixed(error, "env_read_failed");
|
|
132
|
+
}
|
|
133
|
+
const plan = freeze({
|
|
134
|
+
path: state.target.rel,
|
|
135
|
+
location: state.target.location,
|
|
136
|
+
exists: state.env !== null,
|
|
137
|
+
fields: currentFields(state.parsed),
|
|
138
|
+
protection: { kind: PROTECTION_KIND, status: state.protection },
|
|
139
|
+
git: plannedGit(state),
|
|
140
|
+
});
|
|
141
|
+
PLANS.set(plan, state);
|
|
142
|
+
return plan;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The existing raw values, or null when unset. For private validation by
|
|
146
|
+
// the caller only; never print, log or return them.
|
|
147
|
+
export function currentEnvValues(plan) {
|
|
148
|
+
const { entries } = stateOf(plan).parsed;
|
|
149
|
+
return {
|
|
150
|
+
token: entries[ENV_NAMES.token]?.value ?? null,
|
|
151
|
+
ingestUrl: entries[ENV_NAMES.ingestUrl]?.value ?? null,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Upserts the values given (either may be omitted) and returns
|
|
156
|
+
// { receipt, rollback }. A value equal to the current one leaves its line
|
|
157
|
+
// byte for byte. The file is made ignored and private even when no value
|
|
158
|
+
// changes. A plan can be committed once.
|
|
159
|
+
export async function commitEnv(plan, { token, ingestUrl, signal = null, waitMs = DEFAULT_WAIT_MS } = {}) {
|
|
160
|
+
const state = stateOf(plan);
|
|
161
|
+
const updates = {};
|
|
162
|
+
if (token !== undefined) {
|
|
163
|
+
if (!isAppToken(token)) throw new Stop("invalid_input", "token_invalid");
|
|
164
|
+
updates[ENV_NAMES.token] = token;
|
|
165
|
+
}
|
|
166
|
+
if (ingestUrl !== undefined) {
|
|
167
|
+
const url = parseIngestUrl(ingestUrl);
|
|
168
|
+
if (url === null) throw new Stop("invalid_input", "ingest_url_invalid");
|
|
169
|
+
updates[ENV_NAMES.ingestUrl] = url;
|
|
170
|
+
}
|
|
171
|
+
checkCancel(signal);
|
|
172
|
+
state.used = true;
|
|
173
|
+
PLANS.delete(plan);
|
|
174
|
+
const lock = await takeLock(state.target.lockDir, waitMs, signal);
|
|
175
|
+
try {
|
|
176
|
+
checkCancel(signal);
|
|
177
|
+
const { receipt, journal } = deferSignals(() => apply(state, updates));
|
|
178
|
+
const rollback = Object.freeze({});
|
|
179
|
+
HANDLES.set(rollback, { journal, lockDir: state.target.lockDir });
|
|
180
|
+
return { receipt, rollback };
|
|
181
|
+
} finally {
|
|
182
|
+
dropLock(lock);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Undoes a commit. Each file is restored only while it is still exactly what
|
|
187
|
+
// the commit left; otherwise the run stops with rollback_conflict and keeps
|
|
188
|
+
// that file and every earlier change, including the ignore rule, so a
|
|
189
|
+
// changed env file never loses its protection. A stopped rollback can be
|
|
190
|
+
// retried once the conflict is resolved.
|
|
191
|
+
export async function rollbackEnv(handle, { signal = null, waitMs = DEFAULT_WAIT_MS } = {}) {
|
|
192
|
+
const state = HANDLES.get(handle);
|
|
193
|
+
if (state === undefined) throw new Stop("internal_error", "invalid_plan");
|
|
194
|
+
checkCancel(signal);
|
|
195
|
+
if (state.journal.length > 0) {
|
|
196
|
+
const lock = await takeLock(state.lockDir, waitMs, signal);
|
|
197
|
+
try {
|
|
198
|
+
checkCancel(signal);
|
|
199
|
+
deferSignals(() => {
|
|
200
|
+
try {
|
|
201
|
+
undo(state.journal);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
if (error instanceof Stop && error.outcome === "conflict") throw new Stop("conflict", "rollback_conflict");
|
|
204
|
+
throw new Stop("filesystem_error", "rollback_incomplete");
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
} finally {
|
|
208
|
+
dropLock(lock);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
HANDLES.delete(handle);
|
|
212
|
+
return freeze({ status: "restored" });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function stateOf(plan) {
|
|
216
|
+
const state = PLANS.get(plan);
|
|
217
|
+
if (state === undefined || state.used) throw new Stop("internal_error", "invalid_plan");
|
|
218
|
+
return state;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function resolveTarget(root, envFile, allowOutside) {
|
|
222
|
+
if (typeof envFile !== "string" || envFile.length === 0 || envFile.length > 4096 || /[\x00-\x1f\x7f]/.test(envFile)) {
|
|
223
|
+
throw new Stop("invalid_input", "env_path_invalid");
|
|
224
|
+
}
|
|
225
|
+
if (path.isAbsolute(envFile)) return resolveOutside(root, envFile, allowOutside);
|
|
226
|
+
const segments = envFile.split("/");
|
|
227
|
+
if (envFile.length > 1024 || PROTECTED_DIRS.has(segments[0].toLowerCase())) {
|
|
228
|
+
throw new Stop("invalid_input", "env_path_invalid");
|
|
229
|
+
}
|
|
230
|
+
segments.forEach(checkSegment);
|
|
231
|
+
const base = segments.at(-1);
|
|
232
|
+
checkBasename(base);
|
|
233
|
+
let dir = root;
|
|
234
|
+
for (const segment of segments.slice(0, -1)) {
|
|
235
|
+
dir = path.join(dir, segment);
|
|
236
|
+
const stat = lstatOrNull(dir);
|
|
237
|
+
if (stat === null) throw new Stop("invalid_input", "env_dir_missing");
|
|
238
|
+
if (!stat.isDirectory()) throw new Stop("conflict", "env_path_unsafe");
|
|
239
|
+
}
|
|
240
|
+
return { location: "project", rel: envFile, dir, file: path.join(dir, base), lockDir: root };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// A private path outside the project, for example in the user's config
|
|
244
|
+
// directory, only when the caller allows it. Git checks do not apply there
|
|
245
|
+
// and are never claimed. An absolute path inside the project is refused so
|
|
246
|
+
// project files always go through the project-relative checks.
|
|
247
|
+
function resolveOutside(root, envFile, allowOutside) {
|
|
248
|
+
if (!allowOutside) throw new Stop("invalid_input", "env_path_outside_project");
|
|
249
|
+
const base = path.basename(envFile);
|
|
250
|
+
checkSegment(base);
|
|
251
|
+
checkBasename(base);
|
|
252
|
+
let dir;
|
|
253
|
+
try {
|
|
254
|
+
dir = fs.realpathSync(path.dirname(envFile));
|
|
255
|
+
if (!fs.statSync(dir).isDirectory()) throw new Error("not a directory");
|
|
256
|
+
} catch {
|
|
257
|
+
throw new Stop("invalid_input", "env_dir_missing");
|
|
258
|
+
}
|
|
259
|
+
const relative = path.relative(root, dir);
|
|
260
|
+
const outside = relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
|
|
261
|
+
if (!outside) throw new Stop("invalid_input", "env_path_invalid");
|
|
262
|
+
return { location: "outside_project", rel: null, dir, file: path.join(dir, base), lockDir: dir };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function checkSegment(segment) {
|
|
266
|
+
if (
|
|
267
|
+
segment === "" ||
|
|
268
|
+
segment === "." ||
|
|
269
|
+
segment === ".." ||
|
|
270
|
+
segment.toLowerCase() === ".git" ||
|
|
271
|
+
BAD_CHARACTER.test(segment) ||
|
|
272
|
+
/[ .]$/.test(segment) ||
|
|
273
|
+
/~[0-9]/.test(segment) ||
|
|
274
|
+
DEVICE_NAME.test(segment) ||
|
|
275
|
+
Buffer.byteLength(segment, "utf8") > 255
|
|
276
|
+
) {
|
|
277
|
+
throw new Stop("invalid_input", "env_path_invalid");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function checkBasename(base) {
|
|
282
|
+
if (!ENV_BASENAME.test(base) || TEMPLATE_BASENAME.test(base)) throw new Stop("invalid_input", "env_path_invalid");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// A directory any user can write without the sticky bit would let another
|
|
286
|
+
// user swap the env file between the checks and the write.
|
|
287
|
+
function checkDirSafe(dir) {
|
|
288
|
+
if (IS_WINDOWS) return;
|
|
289
|
+
const { mode } = fs.statSync(dir);
|
|
290
|
+
if ((mode & 0o002) !== 0 && (mode & 0o1000) === 0) throw new Stop("conflict", "env_dir_unsafe");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function protectionState(file, env) {
|
|
294
|
+
if (env === null) return "will_create_private";
|
|
295
|
+
if (IS_WINDOWS) return aclStatus(file) === "private" ? "private" : "needs_protection";
|
|
296
|
+
return (env.mode & 0o077) === 0 ? "private" : "needs_tightening";
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function currentFields(parsed) {
|
|
300
|
+
const token = parsed.entries[ENV_NAMES.token];
|
|
301
|
+
const url = parsed.entries[ENV_NAMES.ingestUrl];
|
|
302
|
+
return {
|
|
303
|
+
[ENV_NAMES.token]: token === undefined ? "absent" : isAppToken(token.value) ? "present" : "invalid",
|
|
304
|
+
[ENV_NAMES.ingestUrl]: url === undefined ? "absent" : parseIngestUrl(url.value) === url.value ? "present" : "invalid",
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function plannedGit({ target, git, ignore }) {
|
|
309
|
+
if (target.location !== "project") return { status: "outside_project", ignore_rule: null, verified: false };
|
|
310
|
+
if (!git.repository) {
|
|
311
|
+
const present = hasRule(ignore.before?.content ?? EMPTY, ignore.rule);
|
|
312
|
+
return { status: "not_applicable", ignore_rule: present ? "present" : "needed", verified: false };
|
|
313
|
+
}
|
|
314
|
+
return git.ignored
|
|
315
|
+
? { status: "ignored", ignore_rule: "not_needed", verified: true }
|
|
316
|
+
: { status: "needs_ignore_rule", ignore_rule: "needed", verified: false };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Runs under the lock with signals deferred. Order: recheck both files,
|
|
320
|
+
// make the path ignored, write the env file privately, verify everything
|
|
321
|
+
// again. Any failure undoes the journal in reverse.
|
|
322
|
+
function apply(state, updates) {
|
|
323
|
+
const { target, ignore } = state;
|
|
324
|
+
const journal = [];
|
|
325
|
+
try {
|
|
326
|
+
expectUnchanged(target.file, state.env, ENV_REASONS, "env_changed");
|
|
327
|
+
if (ignore !== null) expectUnchanged(ignore.file, ignore.before, IGNORE_REASONS, "ignore_changed");
|
|
328
|
+
const content = serializeEnv(state.parsed, updates);
|
|
329
|
+
checkSerialized(content, updates, state.parsed);
|
|
330
|
+
const write = state.env === null ? Object.keys(updates).length > 0 : !content.equals(state.env.content);
|
|
331
|
+
const present = state.env !== null || write;
|
|
332
|
+
|
|
333
|
+
let git = plannedGit(state);
|
|
334
|
+
if (ignore !== null && present) git = ensureIgnored(state, journal);
|
|
335
|
+
|
|
336
|
+
let protection = "not_created";
|
|
337
|
+
if (write) protection = writeEnv(state, content, journal);
|
|
338
|
+
else if (present) protection = ensurePrivate(state, journal);
|
|
339
|
+
|
|
340
|
+
// Everything again, with all changes in place.
|
|
341
|
+
if (present) {
|
|
342
|
+
const now = snapshot(target.file, ENV_REASONS);
|
|
343
|
+
const expected = journal.findLast((entry) => entry.env)?.after ?? state.env;
|
|
344
|
+
if (!same(expected, now)) throw new Stop("conflict", "env_changed");
|
|
345
|
+
checkPrivate(target.file, now);
|
|
346
|
+
}
|
|
347
|
+
if (ignore !== null && present) {
|
|
348
|
+
const expected = journal.find((entry) => !entry.env)?.after ?? ignore.before;
|
|
349
|
+
expectUnchanged(ignore.file, expected, IGNORE_REASONS, "ignore_changed");
|
|
350
|
+
if (git.verified && !pathState(state.root, target.rel).ignored) {
|
|
351
|
+
throw new Stop("conflict", "git_ignore_ineffective");
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const receipt = freeze({
|
|
356
|
+
path: target.rel,
|
|
357
|
+
location: target.location,
|
|
358
|
+
file: write ? (state.env === null ? "created" : "updated") : state.env === null ? "absent" : "unchanged",
|
|
359
|
+
fields: fieldResults(state.parsed, updates),
|
|
360
|
+
protection: { kind: PROTECTION_KIND, status: protection },
|
|
361
|
+
git,
|
|
362
|
+
});
|
|
363
|
+
return { receipt, journal };
|
|
364
|
+
} catch (error) {
|
|
365
|
+
try {
|
|
366
|
+
undo(journal);
|
|
367
|
+
} catch {
|
|
368
|
+
throw new Stop("filesystem_error", "rollback_incomplete");
|
|
369
|
+
}
|
|
370
|
+
throw fixed(error, "env_write_failed");
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Inside a repository: refuse a tracked path, add the anchored rule only when
|
|
375
|
+
// Git does not already ignore the path, then require Git to confirm it.
|
|
376
|
+
// Without a repository: make sure the project .gitignore holds the rule, and
|
|
377
|
+
// report that Git verification does not apply.
|
|
378
|
+
function ensureIgnored(state, journal) {
|
|
379
|
+
const { root, target, ignore } = state;
|
|
380
|
+
const git = inspectGit(root, target.rel);
|
|
381
|
+
const before = ignore.before?.content ?? EMPTY;
|
|
382
|
+
if (git.repository && git.ignored) return { status: "ignored", ignore_rule: "not_needed", verified: true };
|
|
383
|
+
if (!git.repository && hasRule(before, ignore.rule)) {
|
|
384
|
+
return { status: "not_applicable", ignore_rule: "present", verified: false };
|
|
385
|
+
}
|
|
386
|
+
const content = appendRule(before, ignore.rule);
|
|
387
|
+
replaceFile(ignore.file, content, {
|
|
388
|
+
before: ignore.before,
|
|
389
|
+
mode: ignore.before?.mode ?? null,
|
|
390
|
+
protect: false,
|
|
391
|
+
reasons: IGNORE_REASONS,
|
|
392
|
+
changed: "ignore_changed",
|
|
393
|
+
});
|
|
394
|
+
const after = snapshot(ignore.file, IGNORE_REASONS);
|
|
395
|
+
journal.push({ env: false, kind: "replace", file: ignore.file, reasons: IGNORE_REASONS, before: ignore.before, after, acl: null });
|
|
396
|
+
if (after === null || !after.content.equals(content)) throw new Stop("conflict", "ignore_changed");
|
|
397
|
+
if (!git.repository) return { status: "not_applicable", ignore_rule: "added", verified: false };
|
|
398
|
+
if (!pathState(root, target.rel).ignored) throw new Stop("conflict", "git_ignore_ineffective");
|
|
399
|
+
return { status: "ignored", ignore_rule: "added", verified: true };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function writeEnv(state, content, journal) {
|
|
403
|
+
const { target, env } = state;
|
|
404
|
+
const keep = env !== null && state.protection === "private";
|
|
405
|
+
const acl = IS_WINDOWS && env !== null ? saveAcl(target.file) : null;
|
|
406
|
+
replaceFile(target.file, content, {
|
|
407
|
+
before: env,
|
|
408
|
+
mode: IS_WINDOWS ? null : keep ? env.mode : 0o600,
|
|
409
|
+
protect: IS_WINDOWS,
|
|
410
|
+
reasons: ENV_REASONS,
|
|
411
|
+
changed: "env_changed",
|
|
412
|
+
});
|
|
413
|
+
const after = snapshot(target.file, ENV_REASONS);
|
|
414
|
+
journal.push({ env: true, kind: "replace", file: target.file, reasons: ENV_REASONS, before: env, after, acl });
|
|
415
|
+
if (after === null || !after.content.equals(content)) throw new Stop("conflict", "env_changed");
|
|
416
|
+
checkPrivate(target.file, after);
|
|
417
|
+
if (env === null) return "created_private";
|
|
418
|
+
if (keep) return "already_private";
|
|
419
|
+
return IS_WINDOWS ? "protected" : "tightened";
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Content is unchanged: only make an existing file private if it is not.
|
|
423
|
+
function ensurePrivate(state, journal) {
|
|
424
|
+
const { target, env } = state;
|
|
425
|
+
if (state.protection === "private") return "already_private";
|
|
426
|
+
const entry = { env: true, kind: "mode", file: target.file, reasons: ENV_REASONS, before: env, acl: null };
|
|
427
|
+
if (IS_WINDOWS) {
|
|
428
|
+
// Windows snapshots compare content only, so the entry is valid as soon
|
|
429
|
+
// as the old ACL is saved, even if protecting the file then fails.
|
|
430
|
+
entry.acl = saveAcl(target.file);
|
|
431
|
+
journal.push({ ...entry, after: env });
|
|
432
|
+
protectFile(target.file);
|
|
433
|
+
return "protected";
|
|
434
|
+
}
|
|
435
|
+
setMode(target.file, 0o600);
|
|
436
|
+
journal.push({ ...entry, after: { content: env.content, mode: 0o600 } });
|
|
437
|
+
return "tightened";
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function checkPrivate(file, stat) {
|
|
441
|
+
const ok = IS_WINDOWS ? aclStatus(file) === "private" : stat !== null && (stat.mode & 0o077) === 0;
|
|
442
|
+
if (!ok) throw new Stop("filesystem_error", "protection_failed");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Reverts entries newest first, removing each one once it is reverted, so a
|
|
446
|
+
// stopped rollback can tell exactly what is left.
|
|
447
|
+
function undo(journal) {
|
|
448
|
+
while (journal.length > 0) {
|
|
449
|
+
const entry = journal.at(-1);
|
|
450
|
+
expectUnchanged(entry.file, entry.after, entry.reasons, "rollback_conflict");
|
|
451
|
+
if (entry.kind === "mode") {
|
|
452
|
+
if (IS_WINDOWS) restoreAcl(entry.file, entry.acl);
|
|
453
|
+
else setMode(entry.file, entry.before.mode);
|
|
454
|
+
} else if (entry.before === null) {
|
|
455
|
+
fs.unlinkSync(entry.file);
|
|
456
|
+
} else {
|
|
457
|
+
replaceFile(entry.file, entry.before.content, {
|
|
458
|
+
before: entry.after,
|
|
459
|
+
mode: IS_WINDOWS ? null : entry.before.mode,
|
|
460
|
+
protect: IS_WINDOWS && entry.env,
|
|
461
|
+
reasons: entry.reasons,
|
|
462
|
+
changed: "rollback_conflict",
|
|
463
|
+
});
|
|
464
|
+
if (entry.acl !== null) restoreAcl(entry.file, entry.acl);
|
|
465
|
+
}
|
|
466
|
+
journal.pop();
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function fieldResults(parsed, updates) {
|
|
471
|
+
const result = {};
|
|
472
|
+
for (const name of Object.values(ENV_NAMES)) {
|
|
473
|
+
const entry = parsed.entries[name];
|
|
474
|
+
if (!Object.hasOwn(updates, name)) result[name] = entry === undefined ? "absent" : "unchanged";
|
|
475
|
+
else if (entry === undefined) result[name] = "added";
|
|
476
|
+
else result[name] = entry.value === updates[name] ? "unchanged" : "updated";
|
|
477
|
+
}
|
|
478
|
+
return result;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function lstatOrNull(file) {
|
|
482
|
+
try {
|
|
483
|
+
return fs.lstatSync(file);
|
|
484
|
+
} catch (error) {
|
|
485
|
+
if (error.code === "ENOENT") return null;
|
|
486
|
+
throw error;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Returns { content, mode } for an owned regular file, or null when there is
|
|
491
|
+
// none. Symbolic links, hard links and other entries are refused.
|
|
492
|
+
function snapshot(file, reasons) {
|
|
493
|
+
const stat = lstatOrNull(file);
|
|
494
|
+
if (stat === null) return null;
|
|
495
|
+
if (!stat.isFile() || stat.nlink > 1) throw new Stop("conflict", reasons.unsafe);
|
|
496
|
+
let fd;
|
|
497
|
+
try {
|
|
498
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | NOFOLLOW);
|
|
499
|
+
} catch (error) {
|
|
500
|
+
if (error.code === "ELOOP") throw new Stop("conflict", reasons.unsafe);
|
|
501
|
+
if (error.code === "ENOENT") return null;
|
|
502
|
+
throw error;
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
505
|
+
const opened = fs.fstatSync(fd);
|
|
506
|
+
const moved = !IS_WINDOWS && (opened.ino !== stat.ino || opened.dev !== stat.dev);
|
|
507
|
+
if (!opened.isFile() || opened.nlink > 1 || moved) throw new Stop("conflict", reasons.unsafe);
|
|
508
|
+
if (!IS_WINDOWS && typeof process.getuid === "function" && opened.uid !== process.getuid()) {
|
|
509
|
+
throw new Stop("conflict", reasons.notOwned);
|
|
510
|
+
}
|
|
511
|
+
if (opened.size > MAX_ENV_BYTES) throw new Stop("conflict", reasons.tooLarge);
|
|
512
|
+
const content = fs.readFileSync(fd);
|
|
513
|
+
if (content.length > MAX_ENV_BYTES) throw new Stop("conflict", reasons.tooLarge);
|
|
514
|
+
return { content, mode: opened.mode & 0o777 };
|
|
515
|
+
} finally {
|
|
516
|
+
fs.closeSync(fd);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function same(expected, now) {
|
|
521
|
+
if (expected === null || now === null) return expected === now;
|
|
522
|
+
return now.content.equals(expected.content) && (IS_WINDOWS || now.mode === expected.mode);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function expectUnchanged(file, expected, reasons, reason) {
|
|
526
|
+
if (!same(expected, snapshot(file, reasons))) throw new Stop("conflict", reason);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function setMode(file, mode) {
|
|
530
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | NOFOLLOW);
|
|
531
|
+
try {
|
|
532
|
+
if (!fs.fstatSync(fd).isFile()) throw new Stop("conflict", "env_path_unsafe");
|
|
533
|
+
fs.fchmodSync(fd, mode);
|
|
534
|
+
} finally {
|
|
535
|
+
fs.closeSync(fd);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Writes an exclusive temporary file next to file and renames it into
|
|
540
|
+
// place. With protect, the temporary file gets the private Windows ACL
|
|
541
|
+
// before any content is written. On POSIX it is created with mode (or the
|
|
542
|
+
// umask default when mode is null). Right before the rename, file must still
|
|
543
|
+
// match before, so a concurrent edit is never replaced. The temporary file
|
|
544
|
+
// is removed on any failure.
|
|
545
|
+
function replaceFile(file, content, { before, mode, protect, reasons, changed }) {
|
|
546
|
+
const dir = path.dirname(file);
|
|
547
|
+
const temp = path.join(dir, `.metergraph-${randomBytes(6).toString("hex")}.tmp`);
|
|
548
|
+
let created = false;
|
|
549
|
+
try {
|
|
550
|
+
if (protect) {
|
|
551
|
+
fs.closeSync(fs.openSync(temp, "wx"));
|
|
552
|
+
created = true;
|
|
553
|
+
protectFile(temp);
|
|
554
|
+
const fd = fs.openSync(temp, "r+");
|
|
555
|
+
try {
|
|
556
|
+
fs.writeFileSync(fd, content);
|
|
557
|
+
fs.fsyncSync(fd);
|
|
558
|
+
} finally {
|
|
559
|
+
fs.closeSync(fd);
|
|
560
|
+
}
|
|
561
|
+
} else {
|
|
562
|
+
const fd = fs.openSync(temp, "wx", mode ?? 0o666);
|
|
563
|
+
created = true;
|
|
564
|
+
try {
|
|
565
|
+
if (mode !== null && !IS_WINDOWS) fs.fchmodSync(fd, mode);
|
|
566
|
+
fs.writeFileSync(fd, content);
|
|
567
|
+
fs.fsyncSync(fd);
|
|
568
|
+
} finally {
|
|
569
|
+
fs.closeSync(fd);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
expectUnchanged(file, before, reasons, changed);
|
|
573
|
+
fs.renameSync(temp, file);
|
|
574
|
+
created = false;
|
|
575
|
+
} catch (error) {
|
|
576
|
+
if (created) {
|
|
577
|
+
try {
|
|
578
|
+
fs.unlinkSync(temp);
|
|
579
|
+
} catch {
|
|
580
|
+
// Reported by the caller's failure; nothing else to do.
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
throw error;
|
|
584
|
+
}
|
|
585
|
+
syncDir(dir);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function syncDir(dir) {
|
|
589
|
+
if (IS_WINDOWS) return;
|
|
590
|
+
try {
|
|
591
|
+
const fd = fs.openSync(dir, "r");
|
|
592
|
+
try {
|
|
593
|
+
fs.fsyncSync(fd);
|
|
594
|
+
} finally {
|
|
595
|
+
fs.closeSync(fd);
|
|
596
|
+
}
|
|
597
|
+
} catch {
|
|
598
|
+
// Not supported everywhere. The rename itself is still atomic.
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// An exclusive lock file in the project (or, outside a project, in the env
|
|
603
|
+
// file's directory). A held lock is waited on for at most waitMs, then
|
|
604
|
+
// reported; it is never taken over or removed, even if it looks stale.
|
|
605
|
+
async function takeLock(dir, waitMs, signal) {
|
|
606
|
+
const file = path.join(dir, LOCK);
|
|
607
|
+
const wait = Number.isFinite(waitMs) ? Math.min(Math.max(waitMs, 0), MAX_WAIT_MS) : DEFAULT_WAIT_MS;
|
|
608
|
+
const until = Date.now() + wait;
|
|
609
|
+
for (;;) {
|
|
610
|
+
checkCancel(signal);
|
|
611
|
+
let fd;
|
|
612
|
+
try {
|
|
613
|
+
fd = fs.openSync(file, "wx", 0o600);
|
|
614
|
+
} catch (error) {
|
|
615
|
+
if (error.code !== "EEXIST") throw fixed(error, "env_write_failed");
|
|
616
|
+
if (Date.now() >= until) throw new Stop("conflict", "env_locked");
|
|
617
|
+
await pause(50, signal);
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
const stat = fs.fstatSync(fd);
|
|
622
|
+
return { file, dev: stat.dev, ino: stat.ino };
|
|
623
|
+
} finally {
|
|
624
|
+
fs.closeSync(fd);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Removes the lock only if it is still the file this process created.
|
|
630
|
+
function dropLock(lock) {
|
|
631
|
+
try {
|
|
632
|
+
const stat = fs.lstatSync(lock.file);
|
|
633
|
+
if (IS_WINDOWS || (stat.dev === lock.dev && stat.ino === lock.ino)) fs.unlinkSync(lock.file);
|
|
634
|
+
} catch {
|
|
635
|
+
// Reported by the next run as a held lock.
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function pause(ms, signal) {
|
|
640
|
+
return new Promise((resolve) => {
|
|
641
|
+
const done = () => {
|
|
642
|
+
clearTimeout(timer);
|
|
643
|
+
signal?.removeEventListener("abort", done);
|
|
644
|
+
resolve();
|
|
645
|
+
};
|
|
646
|
+
const timer = setTimeout(done, ms);
|
|
647
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function checkCancel(signal) {
|
|
652
|
+
if (signal?.aborted) throw new Stop("cancelled", "cancelled");
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Keeps SIGINT, SIGTERM and SIGHUP from killing the process while files are
|
|
656
|
+
// being replaced. The work inside is synchronous, so it finishes or rolls
|
|
657
|
+
// back before a signal could be handled. A hard kill cannot be deferred.
|
|
658
|
+
function deferSignals(work) {
|
|
659
|
+
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
660
|
+
const ignore = () => {};
|
|
661
|
+
for (const signal of signals) process.on(signal, ignore);
|
|
662
|
+
try {
|
|
663
|
+
return work();
|
|
664
|
+
} finally {
|
|
665
|
+
for (const signal of signals) process.removeListener(signal, ignore);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Every failure leaves this module as a Stop with a fixed reason. A raw
|
|
670
|
+
// error, which can hold a path, is never passed on.
|
|
671
|
+
function fixed(error, reason) {
|
|
672
|
+
if (error instanceof Stop) return error;
|
|
673
|
+
if (DENIED.has(error?.code)) {
|
|
674
|
+
return new Stop("filesystem_error", reason === "env_read_failed" ? "env_read_denied" : "env_write_denied");
|
|
675
|
+
}
|
|
676
|
+
return new Stop("filesystem_error", reason);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function freeze(value) {
|
|
680
|
+
if (value !== null && typeof value === "object") {
|
|
681
|
+
for (const item of Object.values(value)) freeze(item);
|
|
682
|
+
Object.freeze(value);
|
|
683
|
+
}
|
|
684
|
+
return value;
|
|
685
|
+
}
|