@aligndottech/cli 0.46.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -8
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +14 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/connect-agents.d.ts.map +1 -1
- package/dist/commands/connect-agents.js +13 -3
- package/dist/commands/connect-agents.js.map +1 -1
- package/dist/commands/default-action.d.ts +5 -5
- package/dist/commands/default-action.d.ts.map +1 -1
- package/dist/commands/default-action.js +40 -13
- package/dist/commands/default-action.js.map +1 -1
- package/dist/commands/mcp.d.ts.map +1 -1
- package/dist/commands/mcp.js +12 -4
- package/dist/commands/mcp.js.map +1 -1
- package/dist/commands/registry.d.ts.map +1 -1
- package/dist/commands/registry.js +2 -0
- package/dist/commands/registry.js.map +1 -1
- package/dist/commands/setup.d.ts +2 -1
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +84 -114
- package/dist/commands/setup.js.map +1 -1
- package/dist/commands/use.d.ts +32 -0
- package/dist/commands/use.d.ts.map +1 -0
- package/dist/commands/use.js +94 -0
- package/dist/commands/use.js.map +1 -0
- package/dist/lib/agent-rules.d.ts +18 -5
- package/dist/lib/agent-rules.d.ts.map +1 -1
- package/dist/lib/agent-rules.js +81 -22
- package/dist/lib/agent-rules.js.map +1 -1
- package/dist/lib/config.d.ts +14 -10
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +64 -17
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/foreign-env.d.ts +23 -0
- package/dist/lib/foreign-env.d.ts.map +1 -0
- package/dist/lib/foreign-env.js +27 -0
- package/dist/lib/foreign-env.js.map +1 -0
- package/dist/lib/gateway-client.d.ts +5 -1
- package/dist/lib/gateway-client.d.ts.map +1 -1
- package/dist/lib/gateway-client.js +10 -2
- package/dist/lib/gateway-client.js.map +1 -1
- package/dist/lib/launch/adapters/args.d.ts +7 -0
- package/dist/lib/launch/adapters/args.d.ts.map +1 -0
- package/dist/lib/launch/adapters/args.js +10 -0
- package/dist/lib/launch/adapters/args.js.map +1 -0
- package/dist/lib/launch/adapters/claude-code.d.ts +39 -0
- package/dist/lib/launch/adapters/claude-code.d.ts.map +1 -0
- package/dist/lib/launch/adapters/claude-code.js +39 -0
- package/dist/lib/launch/adapters/claude-code.js.map +1 -0
- package/dist/lib/launch/adapters/cursor.d.ts +18 -0
- package/dist/lib/launch/adapters/cursor.d.ts.map +1 -0
- package/dist/lib/launch/adapters/cursor.js +27 -0
- package/dist/lib/launch/adapters/cursor.js.map +1 -0
- package/dist/lib/launch/adapters/opencode.d.ts +21 -0
- package/dist/lib/launch/adapters/opencode.d.ts.map +1 -0
- package/dist/lib/launch/adapters/opencode.js +64 -0
- package/dist/lib/launch/adapters/opencode.js.map +1 -0
- package/dist/lib/launch/adapters/pi.d.ts +23 -0
- package/dist/lib/launch/adapters/pi.d.ts.map +1 -0
- package/dist/lib/launch/adapters/pi.js +35 -0
- package/dist/lib/launch/adapters/pi.js.map +1 -0
- package/dist/lib/launch/agents.d.ts +24 -0
- package/dist/lib/launch/agents.d.ts.map +1 -0
- package/dist/lib/launch/agents.js +22 -0
- package/dist/lib/launch/agents.js.map +1 -0
- package/dist/lib/launch/config-writes.d.ts +37 -0
- package/dist/lib/launch/config-writes.d.ts.map +1 -0
- package/dist/lib/launch/config-writes.js +26 -0
- package/dist/lib/launch/config-writes.js.map +1 -0
- package/dist/lib/launch/cursor-state.d.ts +14 -0
- package/dist/lib/launch/cursor-state.d.ts.map +1 -0
- package/dist/lib/launch/cursor-state.js +26 -0
- package/dist/lib/launch/cursor-state.js.map +1 -0
- package/dist/lib/launch/detect.d.ts +7 -0
- package/dist/lib/launch/detect.d.ts.map +1 -0
- package/dist/lib/launch/detect.js +34 -0
- package/dist/lib/launch/detect.js.map +1 -0
- package/dist/lib/launch/launch-files.d.ts +15 -0
- package/dist/lib/launch/launch-files.d.ts.map +1 -0
- package/dist/lib/launch/launch-files.js +56 -0
- package/dist/lib/launch/launch-files.js.map +1 -0
- package/dist/lib/launch/launch.d.ts +59 -0
- package/dist/lib/launch/launch.d.ts.map +1 -0
- package/dist/lib/launch/launch.js +198 -0
- package/dist/lib/launch/launch.js.map +1 -0
- package/dist/lib/launch/opencode-state.d.ts +16 -0
- package/dist/lib/launch/opencode-state.d.ts.map +1 -0
- package/dist/lib/launch/opencode-state.js +103 -0
- package/dist/lib/launch/opencode-state.js.map +1 -0
- package/dist/lib/launch/pi-state.d.ts +23 -0
- package/dist/lib/launch/pi-state.d.ts.map +1 -0
- package/dist/lib/launch/pi-state.js +112 -0
- package/dist/lib/launch/pi-state.js.map +1 -0
- package/dist/lib/launch/pick-agent.d.ts +28 -0
- package/dist/lib/launch/pick-agent.d.ts.map +1 -0
- package/dist/lib/launch/pick-agent.js +63 -0
- package/dist/lib/launch/pick-agent.js.map +1 -0
- package/dist/lib/launch/project-state.d.ts +21 -0
- package/dist/lib/launch/project-state.d.ts.map +1 -0
- package/dist/lib/launch/project-state.js +100 -0
- package/dist/lib/launch/project-state.js.map +1 -0
- package/dist/lib/launch/run-agent.d.ts +21 -0
- package/dist/lib/launch/run-agent.d.ts.map +1 -0
- package/dist/lib/launch/run-agent.js +69 -0
- package/dist/lib/launch/run-agent.js.map +1 -0
- package/dist/lib/local-gateway-client.d.ts +4 -4
- package/dist/lib/local-gateway-client.d.ts.map +1 -1
- package/dist/lib/local-gateway-client.js +3 -1
- package/dist/lib/local-gateway-client.js.map +1 -1
- package/dist/lib/mcp-setup.d.ts +9 -2
- package/dist/lib/mcp-setup.d.ts.map +1 -1
- package/dist/lib/mcp-setup.js +104 -28
- package/dist/lib/mcp-setup.js.map +1 -1
- package/dist/lib/mcp-timeline-tools.d.ts.map +1 -1
- package/dist/lib/mcp-timeline-tools.js +5 -3
- package/dist/lib/mcp-timeline-tools.js.map +1 -1
- package/dist/lib/safe-config-write.d.ts +140 -0
- package/dist/lib/safe-config-write.d.ts.map +1 -0
- package/dist/lib/safe-config-write.js +701 -0
- package/dist/lib/safe-config-write.js.map +1 -0
- package/dist/lib/setup-route.d.ts +26 -0
- package/dist/lib/setup-route.d.ts.map +1 -0
- package/dist/lib/setup-route.js +36 -0
- package/dist/lib/setup-route.js.map +1 -0
- package/dist/lib/usage-telemetry.d.ts +6 -2
- package/dist/lib/usage-telemetry.d.ts.map +1 -1
- package/dist/lib/usage-telemetry.js +6 -2
- package/dist/lib/usage-telemetry.js.map +1 -1
- package/dist/lib/user-hooks.d.ts +2 -1
- package/dist/lib/user-hooks.d.ts.map +1 -1
- package/dist/lib/user-hooks.js +42 -10
- package/dist/lib/user-hooks.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { chmodSync, copyFileSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
/*
|
|
6
|
+
* The one way align writes to a config file that belongs to someone else (an agent's MCP
|
|
7
|
+
* config, its hook file). Promises, each of which a plain writeFileSync breaks:
|
|
8
|
+
* 1. The FIRST content is kept in `<file>.align-backup` and never overwritten.
|
|
9
|
+
* 2. The file is re-read after our new content is staged. If another program changed it in
|
|
10
|
+
* the meantime we recompute once from what is there now, and give up if it moves again.
|
|
11
|
+
* 3. The new content lands by an exclusive temp file + rename, keeping the old file's mode.
|
|
12
|
+
* 4. It never writes through a symlink: not the file, and not a directory between the user's
|
|
13
|
+
* home and the file (a linked agent dir usually points at another product's config).
|
|
14
|
+
* 5. It records WHAT it added (the keys it owns, or the managed block), so `align use --undo`
|
|
15
|
+
* can take out align's own entries and nothing else.
|
|
16
|
+
*/
|
|
17
|
+
export const BACKUP_SUFFIX = '.align-backup';
|
|
18
|
+
// Built on use, not at import: a test that mocks node:fs with a few names must still be able
|
|
19
|
+
// to import anything that reaches this module.
|
|
20
|
+
const realFs = () => ({ readFileSync, writeFileSync, renameSync, copyFileSync, lstatSync, readlinkSync, chmodSync, unlinkSync, mkdirSync, readdirSync });
|
|
21
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
22
|
+
const sha = (data) => createHash('sha256').update(data).digest('hex');
|
|
23
|
+
/** JSON with sorted keys, so a hash does not depend on key order. */
|
|
24
|
+
function canon(v) {
|
|
25
|
+
if (Array.isArray(v))
|
|
26
|
+
return `[${v.map(canon).join(',')}]`;
|
|
27
|
+
if (isObject(v))
|
|
28
|
+
return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canon(v[k])}`).join(',')}}`;
|
|
29
|
+
return JSON.stringify(v) ?? 'null';
|
|
30
|
+
}
|
|
31
|
+
const shaOf = (v) => sha(canon(v));
|
|
32
|
+
/** Fold one write into what the manifest already holds for that file. */
|
|
33
|
+
export function mergeWrittenConfig(prev, next) {
|
|
34
|
+
if (!prev)
|
|
35
|
+
return next;
|
|
36
|
+
const key = (i) => `${i.kind}\u0000${i.path.join('\u0000')}${i.kind === 'array-item' ? `\u0000${i.sha256}` : ''}`;
|
|
37
|
+
const owned = new Map((prev.owned ?? []).map((i) => [key(i), i]));
|
|
38
|
+
for (const i of next.owned ?? []) {
|
|
39
|
+
const old = owned.get(key(i));
|
|
40
|
+
// `replaced` is a fact about the user's value before align: a value align itself rewrote is not that.
|
|
41
|
+
if (!old) {
|
|
42
|
+
owned.set(key(i), i);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
// The first write decides whether this was the user's value; later rewrites only move the hash.
|
|
46
|
+
if (i.replaced) {
|
|
47
|
+
// The user put a value of their own there since align's last write and this write replaced it:
|
|
48
|
+
// that value (in the snapshot taken just now) is the one to give back.
|
|
49
|
+
owned.set(key(i), { ...i, createdDepth: old.createdDepth });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const { replaced: _r, beforeSha256: _b, snapshot: _s, ...rest } = i;
|
|
53
|
+
owned.set(key(i), { ...rest, createdDepth: old.createdDepth, ...(old.replaced ? { replaced: true, beforeSha256: old.beforeSha256, snapshot: old.snapshot } : {}) });
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
created: prev.created,
|
|
57
|
+
sha256: next.sha256,
|
|
58
|
+
firstSha256: prev.firstSha256,
|
|
59
|
+
backup: prev.backup,
|
|
60
|
+
...(prev.backupSha256 ? { backupSha256: prev.backupSha256 } : {}),
|
|
61
|
+
...(owned.size > 0 ? { owned: [...owned.values()] } : {}),
|
|
62
|
+
...((prev.snapshots?.length || next.snapshots?.length) ? { snapshots: [...(prev.snapshots ?? []), ...(next.snapshots ?? [])] } : {}),
|
|
63
|
+
...(next.block
|
|
64
|
+
? { block: { ...next.block, ...(prev.block ? (prev.block.replaced ? { replaced: true } : {}) : next.block.replaced ? { replaced: true } : {}) } }
|
|
65
|
+
: prev.block ? { block: prev.block } : {}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
let recorder;
|
|
69
|
+
let lookup;
|
|
70
|
+
/**
|
|
71
|
+
* Where the writer reports each file it wrote (`record`, handed only what THIS write added;
|
|
72
|
+
* the store merges) and how it reads back what is already recorded (`get`). Installed once by
|
|
73
|
+
* the CLI (cli.ts), never by a test, so a test that writes a config does not touch the real store.
|
|
74
|
+
*/
|
|
75
|
+
export function setWriteRecorder(record, get) {
|
|
76
|
+
recorder = record;
|
|
77
|
+
lookup = get;
|
|
78
|
+
}
|
|
79
|
+
export class SafeWriteConflictError extends Error {
|
|
80
|
+
constructor(file) {
|
|
81
|
+
super(`${file} was changed by another program while align was writing it. Align left it as that program wrote it. Run the command again.`);
|
|
82
|
+
this.name = 'SafeWriteConflictError';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const invalidJson = (file, advice = ', then run align again') => new Error(`${file} contains invalid JSON - fix it manually${advice}`);
|
|
86
|
+
function readCurrent(fs, file) {
|
|
87
|
+
try {
|
|
88
|
+
return fs.readFileSync(file, 'utf8');
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
if (err.code === 'ENOENT')
|
|
92
|
+
return null;
|
|
93
|
+
throw new Error(`${file} could not be read (${err.code ?? 'unknown error'}): ${err.message}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function linkAt(fs, p) {
|
|
97
|
+
try {
|
|
98
|
+
if (!fs.lstatSync(p).isSymbolicLink())
|
|
99
|
+
return null;
|
|
100
|
+
return { path: p, target: fs.readlinkSync(p) };
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
return err.code === 'ENOENT' ? 'missing' : null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Directories at or above the root are the user's own and are not checked. A file under neither
|
|
108
|
+
* the given root nor the home dir is checked from `/`, so a PI_CODING_AGENT_DIR that is itself a
|
|
109
|
+
* link is caught too.
|
|
110
|
+
*/
|
|
111
|
+
function effectiveRoot(file, root) {
|
|
112
|
+
const home = os.homedir();
|
|
113
|
+
for (const r of [root, home])
|
|
114
|
+
if (r && file.startsWith(r + path.sep))
|
|
115
|
+
return r;
|
|
116
|
+
return path.parse(file).root;
|
|
117
|
+
}
|
|
118
|
+
/** The first symlink on the way from the root down to (and including) the file, if any. */
|
|
119
|
+
function symlinkOnPath(fs, file, root) {
|
|
120
|
+
const base = effectiveRoot(file, root);
|
|
121
|
+
let cur = base;
|
|
122
|
+
for (const seg of path.relative(base, path.dirname(file)).split(path.sep).filter(Boolean)) {
|
|
123
|
+
cur = path.join(cur, seg);
|
|
124
|
+
const hit = linkAt(fs, cur);
|
|
125
|
+
if (hit === 'missing')
|
|
126
|
+
break;
|
|
127
|
+
// A top-level link such as /tmp or /var (macOS) is the system's, not a redirected config dir.
|
|
128
|
+
if (hit && path.dirname(cur) !== path.parse(cur).root)
|
|
129
|
+
return hit;
|
|
130
|
+
}
|
|
131
|
+
const hit = linkAt(fs, file);
|
|
132
|
+
return hit && hit !== 'missing' ? hit : null;
|
|
133
|
+
}
|
|
134
|
+
function modeOf(fs, file) {
|
|
135
|
+
try {
|
|
136
|
+
return fs.lstatSync(file).mode & 0o777;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** 'made' only when this call created the backup. Anything already at that path is not ours. */
|
|
143
|
+
function backupOnce(fs, file) {
|
|
144
|
+
const backup = `${file}${BACKUP_SUFFIX}`;
|
|
145
|
+
if (linkAt(fs, backup) !== 'missing')
|
|
146
|
+
return { state: 'foreign' };
|
|
147
|
+
try {
|
|
148
|
+
fs.copyFileSync(file, backup, 1); // COPYFILE_EXCL
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
if (err.code === 'EEXIST')
|
|
152
|
+
return { state: 'foreign' };
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
return { state: 'made', sha256: sha(fs.readFileSync(backup)) };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* A copy of the file as it is right now, for the case where someone changed it after align's
|
|
159
|
+
* last write: the backup from before align's first write cannot hold what they added since.
|
|
160
|
+
* `<file>.align-backup.<n>`, exclusive, 0600 (it may hold secrets), never overwritten.
|
|
161
|
+
*/
|
|
162
|
+
function takeSnapshot(fs, file, content, taken) {
|
|
163
|
+
for (let n = Math.max(0, ...taken) + 1; n <= MAX_SNAPSHOTS; n++) {
|
|
164
|
+
const target = `${file}${BACKUP_SUFFIX}.${n}`;
|
|
165
|
+
if (linkAt(fs, target) !== 'missing')
|
|
166
|
+
continue;
|
|
167
|
+
try {
|
|
168
|
+
fs.writeFileSync(target, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
if (err.code === 'EEXIST')
|
|
172
|
+
continue;
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
return { n, sha256: sha(content) };
|
|
176
|
+
}
|
|
177
|
+
throw new Error(`${file} already has ${taken.length} snapshots from align. Run \`align use --undo\` to clear them, then try again.`);
|
|
178
|
+
}
|
|
179
|
+
/** Delete snapshots THIS call made (verified: a regular file whose hash is the one taken). */
|
|
180
|
+
function dropSnapshots(fs, file, list) {
|
|
181
|
+
for (const { n, sha256: want } of list) {
|
|
182
|
+
const target = `${file}${BACKUP_SUFFIX}.${n}`;
|
|
183
|
+
try {
|
|
184
|
+
if (fs.lstatSync(target).isFile() && sha(fs.readFileSync(target)) === want)
|
|
185
|
+
fs.unlinkSync(target);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// already gone
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const MAX_SNAPSHOTS = 200;
|
|
193
|
+
const STALE_TMP_MS = 60_000;
|
|
194
|
+
/** A crash between staging and rename leaves `.<name>.<hex>.align-tmp`. Old ones are removed. */
|
|
195
|
+
function sweepStaleTemps(fs, file) {
|
|
196
|
+
try {
|
|
197
|
+
const dir = path.dirname(file);
|
|
198
|
+
const re = new RegExp(`^\\.${path.basename(file).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.[0-9a-f]+\\.align-tmp$`);
|
|
199
|
+
for (const name of fs.readdirSync(dir)) {
|
|
200
|
+
if (!re.test(name))
|
|
201
|
+
continue;
|
|
202
|
+
const full = path.join(dir, name);
|
|
203
|
+
const st = fs.lstatSync(full);
|
|
204
|
+
if (st.isFile() && Date.now() - st.mtimeMs > STALE_TMP_MS)
|
|
205
|
+
fs.unlinkSync(full);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// best effort: a directory we cannot list has nothing we can sweep
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/** Stage `text` beside `file` and rename it over. Never leaves the temp file behind on failure. */
|
|
213
|
+
function stageAndRename(fs, file, text, mode, beforeRename) {
|
|
214
|
+
let tmp = '';
|
|
215
|
+
for (let i = 0; i < 5; i++) {
|
|
216
|
+
tmp = path.join(path.dirname(file), `.${path.basename(file)}.${randomBytes(4).toString('hex')}.align-tmp`);
|
|
217
|
+
try {
|
|
218
|
+
fs.writeFileSync(tmp, text, { encoding: 'utf8', mode: mode ?? 0o600, flag: 'wx' });
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
if (err.code === 'EEXIST' && i < 4)
|
|
223
|
+
continue;
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
if (mode !== null)
|
|
229
|
+
fs.chmodSync(tmp, mode);
|
|
230
|
+
if (beforeRename && !beforeRename()) {
|
|
231
|
+
fs.unlinkSync(tmp);
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
fs.renameSync(tmp, file);
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
try {
|
|
239
|
+
fs.unlinkSync(tmp);
|
|
240
|
+
}
|
|
241
|
+
catch { /* already gone */ }
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function regionOf(text, markers) {
|
|
246
|
+
const s = text.indexOf(markers.start);
|
|
247
|
+
const e = text.indexOf(markers.end);
|
|
248
|
+
return s === -1 || e === -1 || e < s ? null : text.slice(s, e + markers.end.length);
|
|
249
|
+
}
|
|
250
|
+
function blockOf(before, next, markers) {
|
|
251
|
+
const region = regionOf(next, markers);
|
|
252
|
+
if (region === null)
|
|
253
|
+
return undefined;
|
|
254
|
+
const old = before === null ? null : regionOf(before, markers);
|
|
255
|
+
return { start: markers.start, end: markers.end, sha256: sha(region), ...(old !== null && old !== region ? { replaced: true } : {}) };
|
|
256
|
+
}
|
|
257
|
+
function safeWrite(file, compute, opts, describe) {
|
|
258
|
+
const fs = opts.fs ?? realFs();
|
|
259
|
+
const note = opts.note ?? ((l) => console.error(l));
|
|
260
|
+
const link = symlinkOnPath(fs, file, opts.root);
|
|
261
|
+
if (link) {
|
|
262
|
+
note(link.path === file
|
|
263
|
+
? `Not writing ${file}: it is a symlink to ${link.target}, which may belong to another program. Left it alone.`
|
|
264
|
+
: `Not writing ${file}: ${link.path} is a symlink to ${link.target}, so the file is really in another program's directory. Left it alone.`);
|
|
265
|
+
return 'symlink';
|
|
266
|
+
}
|
|
267
|
+
let backup = { state: 'none' };
|
|
268
|
+
const snapshots = [];
|
|
269
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
270
|
+
const before = readCurrent(fs, file);
|
|
271
|
+
const next = compute(before);
|
|
272
|
+
if (next === undefined)
|
|
273
|
+
return 'declined';
|
|
274
|
+
if (next === before)
|
|
275
|
+
return 'unchanged';
|
|
276
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
277
|
+
sweepStaleTemps(fs, file);
|
|
278
|
+
const mode = modeOf(fs, file);
|
|
279
|
+
// The first time align writes a file that exists, the original is kept. After that, if the file
|
|
280
|
+
// is no longer what align last wrote (the user edited it, or added entries), a snapshot of it is
|
|
281
|
+
// taken first, so a value this write replaces is never held only by the file.
|
|
282
|
+
const prev = lookup?.(file);
|
|
283
|
+
if (before !== null) {
|
|
284
|
+
if (!prev) {
|
|
285
|
+
if (backup.state === 'none')
|
|
286
|
+
backup = backupOnce(fs, file);
|
|
287
|
+
}
|
|
288
|
+
else if (sha(before) !== prev.sha256 && !snapshots.some((x) => x.sha256 === sha(before))) {
|
|
289
|
+
snapshots.push(takeSnapshot(fs, file, before, [...(prev.snapshots ?? []).map((x) => x.n), ...snapshots.map((x) => x.n)]));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
let staged = false;
|
|
293
|
+
try {
|
|
294
|
+
staged = stageAndRename(fs, file, next, mode, () => readCurrent(fs, file) === before);
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
dropSnapshots(fs, file, snapshots);
|
|
298
|
+
throw err;
|
|
299
|
+
}
|
|
300
|
+
if (!staged)
|
|
301
|
+
continue;
|
|
302
|
+
// A snapshot stays only if a unit this write replaced or removed points at it; otherwise it
|
|
303
|
+
// holds nothing the file or the first backup does not, and would just pile up.
|
|
304
|
+
const lastSnap = snapshots.length > 0 ? snapshots[snapshots.length - 1].n : 0;
|
|
305
|
+
let owned = {};
|
|
306
|
+
let block;
|
|
307
|
+
try {
|
|
308
|
+
owned = describe?.(lastSnap) ?? {};
|
|
309
|
+
const b = opts.markers ? blockOf(before, next, opts.markers) : undefined;
|
|
310
|
+
block = b ? { ...b, ...(b.replaced ? { snapshot: lastSnap } : {}) } : undefined;
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
// Not knowing what we changed costs the undo, never the write.
|
|
314
|
+
}
|
|
315
|
+
const referenced = new Set([
|
|
316
|
+
...(owned.owned ?? []).filter((i) => i.replaced).map((i) => i.snapshot ?? 0),
|
|
317
|
+
...(block?.replaced ? [block.snapshot ?? 0] : []),
|
|
318
|
+
]);
|
|
319
|
+
const kept = snapshots.filter((x) => referenced.has(x.n));
|
|
320
|
+
dropSnapshots(fs, file, snapshots.filter((x) => !referenced.has(x.n)));
|
|
321
|
+
try {
|
|
322
|
+
recorder?.(file, {
|
|
323
|
+
created: before === null,
|
|
324
|
+
sha256: sha(next),
|
|
325
|
+
firstSha256: sha(next),
|
|
326
|
+
backup: backup.state,
|
|
327
|
+
...(backup.sha256 ? { backupSha256: backup.sha256 } : {}),
|
|
328
|
+
...(kept.length > 0 ? { snapshots: kept } : {}),
|
|
329
|
+
...owned,
|
|
330
|
+
...(block ? { block } : {}),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
// The file is written. A manifest that cannot be updated costs the undo, not the write. A
|
|
335
|
+
// snapshot that holds a replaced user value is kept anyway: it may be the only copy.
|
|
336
|
+
}
|
|
337
|
+
return 'written';
|
|
338
|
+
}
|
|
339
|
+
dropSnapshots(fs, file, snapshots);
|
|
340
|
+
throw new SafeWriteConflictError(file);
|
|
341
|
+
}
|
|
342
|
+
/** The text form. `compute` gets the current text (null when missing) and returns the new text, or undefined to leave it. */
|
|
343
|
+
export function safeWriteText(file, compute, opts = {}) {
|
|
344
|
+
return safeWrite(file, compute, opts);
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* What align changed in a JSON config. Containers (the root and its direct children, such as
|
|
348
|
+
* `mcpServers` or `hooks`) are walked; a server entry one level further down is a unit:
|
|
349
|
+
* - new key: one `value` item (or, under a new container, one item per new entry);
|
|
350
|
+
* - existing key whose value changed: ONE `replaced` item holding the hash of the user's value,
|
|
351
|
+
* never a diff into it (a diff into `args` would "undo" half an entry);
|
|
352
|
+
* - existing array: if align only added elements, those elements; if it REMOVED or changed any
|
|
353
|
+
* element it did not put there itself, the whole array is one `replaced` unit;
|
|
354
|
+
* - an existing key align took away: a `removed` unit.
|
|
355
|
+
* `prev` is what an earlier write already recorded, so align's own earlier output is never
|
|
356
|
+
* mistaken for the user's.
|
|
357
|
+
*/
|
|
358
|
+
export function ownedOf(before, after, prev = [], snapshot = 0) {
|
|
359
|
+
const out = [];
|
|
360
|
+
const samePath = (a, b) => a.length === b.length && a.every((x, n) => x === b[n]);
|
|
361
|
+
const ownsValue = (p, h) => prev.some((i) => i.kind === 'value' && samePath(i.path, p) && i.sha256 === h);
|
|
362
|
+
const ownsElement = (p, h) => prev.some((i) => i.kind === 'array-item' && samePath(i.path, p) && i.sha256 === h);
|
|
363
|
+
const addItems = (here, bv, av, createdAt) => {
|
|
364
|
+
const remaining = new Map();
|
|
365
|
+
for (const x of bv)
|
|
366
|
+
remaining.set(shaOf(x), (remaining.get(shaOf(x)) ?? 0) + 1);
|
|
367
|
+
for (const x of av) {
|
|
368
|
+
const h = shaOf(x);
|
|
369
|
+
if ((remaining.get(h) ?? 0) > 0)
|
|
370
|
+
remaining.set(h, remaining.get(h) - 1);
|
|
371
|
+
else
|
|
372
|
+
out.push({ path: here, kind: 'array-item', sha256: h, createdDepth: createdAt });
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
/** Elements of `bv` that `av` no longer has and that align did not put there. */
|
|
376
|
+
const userElementsLost = (here, bv, av) => {
|
|
377
|
+
const remaining = new Map();
|
|
378
|
+
for (const x of av)
|
|
379
|
+
remaining.set(shaOf(x), (remaining.get(shaOf(x)) ?? 0) + 1);
|
|
380
|
+
return bv.some((x) => {
|
|
381
|
+
const h = shaOf(x);
|
|
382
|
+
if ((remaining.get(h) ?? 0) > 0) {
|
|
383
|
+
remaining.set(h, remaining.get(h) - 1);
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
return !ownsElement(here, h);
|
|
387
|
+
});
|
|
388
|
+
};
|
|
389
|
+
const walk = (b, a, p, created) => {
|
|
390
|
+
for (const [k, av] of Object.entries(a)) {
|
|
391
|
+
const here = [...p, k];
|
|
392
|
+
const has = b !== undefined && Object.prototype.hasOwnProperty.call(b, k);
|
|
393
|
+
const bv = has ? b[k] : undefined;
|
|
394
|
+
const createdAt = created ?? (has ? null : here.length);
|
|
395
|
+
if (has && shaOf(av) === shaOf(bv))
|
|
396
|
+
continue;
|
|
397
|
+
if (isObject(av) && Object.keys(av).length > 0 && here.length < 2 && (!has || isObject(bv))) {
|
|
398
|
+
walk(has ? bv : undefined, av, here, createdAt);
|
|
399
|
+
}
|
|
400
|
+
else if (has && ownsValue(here, shaOf(bv))) {
|
|
401
|
+
// exactly what align wrote last time: the hash moves, and nothing here is the user's
|
|
402
|
+
out.push({ path: here, kind: 'value', sha256: shaOf(av), createdDepth: here.length });
|
|
403
|
+
}
|
|
404
|
+
else if (Array.isArray(av) && (!has || Array.isArray(bv)) && !(has && userElementsLost(here, bv, av))) {
|
|
405
|
+
// An array that already existed is not pruned when emptied (it was there before align).
|
|
406
|
+
addItems(here, has ? bv : [], av, createdAt ?? here.length + 1);
|
|
407
|
+
}
|
|
408
|
+
else if (!has) {
|
|
409
|
+
out.push({ path: here, kind: 'value', sha256: shaOf(av), createdDepth: createdAt ?? here.length });
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
// The user's value: without the elements align itself put in an array on an earlier write.
|
|
413
|
+
const was = Array.isArray(bv) ? bv.filter((x) => !ownsElement(here, shaOf(x))) : bv;
|
|
414
|
+
out.push({ path: here, kind: 'value', sha256: shaOf(av), createdDepth: here.length, replaced: true, beforeSha256: shaOf(was), snapshot });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
for (const k of Object.keys(b ?? {})) {
|
|
418
|
+
const here = [...p, k];
|
|
419
|
+
if (Object.prototype.hasOwnProperty.call(a, k) || prev.some((i) => samePath(i.path.slice(0, here.length), here)))
|
|
420
|
+
continue;
|
|
421
|
+
out.push({ path: here, kind: 'value', sha256: '', createdDepth: here.length, replaced: true, removed: true, beforeSha256: shaOf(b[k]), snapshot });
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
walk(before, after, [], null);
|
|
425
|
+
return out;
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* The JSON form. `update` gets the parsed object ({} for a missing or empty file) and returns
|
|
429
|
+
* the object to write, or undefined to leave the file alone. A file that does not parse is an
|
|
430
|
+
* error, never overwritten.
|
|
431
|
+
*/
|
|
432
|
+
export function safeWriteJson(file, update, opts = {}) {
|
|
433
|
+
let beforeObj = {};
|
|
434
|
+
let afterObj = {};
|
|
435
|
+
return safeWrite(file, (raw) => {
|
|
436
|
+
let cur = {};
|
|
437
|
+
if (raw !== null && raw.trim()) {
|
|
438
|
+
try {
|
|
439
|
+
const parsed = JSON.parse(raw);
|
|
440
|
+
if (!isObject(parsed))
|
|
441
|
+
throw new Error('not an object');
|
|
442
|
+
cur = parsed;
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
throw invalidJson(file, opts.invalidJsonAdvice);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
beforeObj = JSON.parse(JSON.stringify(cur));
|
|
449
|
+
const next = update(cur);
|
|
450
|
+
if (next === undefined)
|
|
451
|
+
return undefined;
|
|
452
|
+
afterObj = next;
|
|
453
|
+
return JSON.stringify(next, null, 2) + (opts.trailingNewline ? '\n' : '');
|
|
454
|
+
}, opts, (snapshot) => ({ owned: ownedOf(beforeObj, afterObj, lookup?.(file)?.owned, snapshot) }));
|
|
455
|
+
}
|
|
456
|
+
const pathText = (i) => i.path.join('.');
|
|
457
|
+
/**
|
|
458
|
+
* Remove align's own items from parsed JSON, and put back the user's value for each key align
|
|
459
|
+
* overwrote (`original` is the parsed trusted backup, or null). Works on `cur` in place and
|
|
460
|
+
* returns the lines for items it could not handle; the caller writes nothing if there are any.
|
|
461
|
+
*/
|
|
462
|
+
function removeOwned(cur, owned, originalOf, backupPath) {
|
|
463
|
+
const left = [];
|
|
464
|
+
const walkTo = (root, p) => {
|
|
465
|
+
let node = root;
|
|
466
|
+
for (const seg of p.slice(0, -1)) {
|
|
467
|
+
if (!isObject(node))
|
|
468
|
+
return undefined;
|
|
469
|
+
node = node[seg];
|
|
470
|
+
}
|
|
471
|
+
return isObject(node) ? node : undefined;
|
|
472
|
+
};
|
|
473
|
+
// Units the user's value was taken from go first: their hash check needs the array as align left it.
|
|
474
|
+
for (const i of [...owned].sort((x, y) => Number(!!y.replaced) - Number(!!x.replaced))) {
|
|
475
|
+
const parent = walkTo(cur, i.path);
|
|
476
|
+
const key = i.path[i.path.length - 1];
|
|
477
|
+
if (i.removed) {
|
|
478
|
+
const was = walkTo(originalOf(i.snapshot ?? 0) ?? {}, i.path)?.[key];
|
|
479
|
+
if (parent && key in parent) {
|
|
480
|
+
if (shaOf(parent[key]) !== i.beforeSha256)
|
|
481
|
+
left.push(`${pathText(i)} was yours, align removed it, and it is back with different content; check it by hand`);
|
|
482
|
+
}
|
|
483
|
+
else if (!parent || was === undefined || shaOf(was) !== i.beforeSha256) {
|
|
484
|
+
left.push(`${pathText(i)} was yours and align removed it; align cannot restore it from ${backupPath} (missing or changed). Put the original back by hand`);
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
parent[key] = JSON.parse(JSON.stringify(was));
|
|
488
|
+
}
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (!parent || !(key in parent))
|
|
492
|
+
continue; // already gone
|
|
493
|
+
if (i.kind === 'array-item') {
|
|
494
|
+
const arr = parent[key];
|
|
495
|
+
const at = Array.isArray(arr) ? arr.findIndex((x) => shaOf(x) === i.sha256) : -1;
|
|
496
|
+
if (at >= 0)
|
|
497
|
+
arr.splice(at, 1);
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
if (i.path.length === 1 && i.path[0] === 'version' && !i.replaced && !isObject(parent[key]) && !Array.isArray(parent[key]))
|
|
501
|
+
continue; // structural: decided below
|
|
502
|
+
if (shaOf(parent[key]) !== i.sha256) {
|
|
503
|
+
left.push(`${pathText(i)} was edited since align wrote it; change it by hand if you want it gone`);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
if (!i.replaced) {
|
|
507
|
+
delete parent[key];
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
const was = walkTo(originalOf(i.snapshot ?? 0) ?? {}, i.path)?.[key];
|
|
511
|
+
if (was === undefined || shaOf(was) !== i.beforeSha256) {
|
|
512
|
+
left.push(`${pathText(i)} was your own entry and align replaced it; align cannot restore it from ${backupPath} (missing or changed). Put the original back by hand`);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
parent[key] = JSON.parse(JSON.stringify(was));
|
|
516
|
+
}
|
|
517
|
+
// Parents align created go too, once empty.
|
|
518
|
+
for (const i of owned) {
|
|
519
|
+
if (i.replaced)
|
|
520
|
+
continue;
|
|
521
|
+
// An array-item's own array is a candidate too: it was created with the entry that held it.
|
|
522
|
+
for (let depth = i.path.length - (i.kind === 'array-item' ? 0 : 1); depth >= i.createdDepth; depth--) {
|
|
523
|
+
const nodePath = i.path.slice(0, depth);
|
|
524
|
+
const parent = walkTo(cur, nodePath);
|
|
525
|
+
const node = parent?.[nodePath[depth - 1]];
|
|
526
|
+
const empty = Array.isArray(node) ? node.length === 0 : isObject(node) && Object.keys(node).length === 0;
|
|
527
|
+
if (parent && empty)
|
|
528
|
+
delete parent[nodePath[depth - 1]];
|
|
529
|
+
else
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
// A structural key align added (`version` in a hooks file) goes only when nothing of the user's
|
|
534
|
+
// is left beside it: on its own it is what a hooks reader needs to accept their entries.
|
|
535
|
+
const structural = owned.filter((i) => i.path.length === 1 && i.path[0] === 'version' && i.kind === 'value' && !i.replaced && !i.removed && !isObject(cur[i.path[0]]) && !Array.isArray(cur[i.path[0]]));
|
|
536
|
+
const keys = new Set(structural.map((i) => i.path[0]));
|
|
537
|
+
if (structural.length > 0 && Object.keys(cur).every((k) => keys.has(k))) {
|
|
538
|
+
for (const i of structural) {
|
|
539
|
+
if (shaOf(cur[i.path[0]]) === i.sha256)
|
|
540
|
+
delete cur[i.path[0]];
|
|
541
|
+
else
|
|
542
|
+
left.push(`${pathText(i)} was edited since align wrote it; change it by hand if you want it gone`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return left;
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Take align back out of every recorded file.
|
|
549
|
+
* - If the file still holds exactly what align last wrote, the whole file goes back: a file
|
|
550
|
+
* align created is removed, any other comes back from align's own backup (a regular file
|
|
551
|
+
* align made, whose hash matches; anything else at that path is not trusted).
|
|
552
|
+
* - Otherwise the user has touched it since, and only align's own entries come out. An entry
|
|
553
|
+
* the user edited, or one align overwrote, is left and named.
|
|
554
|
+
* Skipped files keep their manifest entry and their backup.
|
|
555
|
+
*/
|
|
556
|
+
export function undoWrittenConfigs(manifest, fs = realFs()) {
|
|
557
|
+
const report = { restored: [], removed: [], cleaned: [], skipped: [], done: [] };
|
|
558
|
+
for (const [file, entry] of Object.entries(manifest)) {
|
|
559
|
+
const backupPath = `${file}${BACKUP_SUFFIX}`;
|
|
560
|
+
const link = linkAt(fs, file);
|
|
561
|
+
if (link && link !== 'missing') {
|
|
562
|
+
report.skipped.push(`${file}: it is a symlink to ${link.target}, left alone`);
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const cur = readCurrent(fs, file);
|
|
566
|
+
if (cur === null) {
|
|
567
|
+
report.done.push(file); // already gone: nothing of align's is left
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
// The first backup (0) and the snapshots (1..n): each is trusted only when it is a regular file
|
|
571
|
+
// whose hash is the one align recorded when it made it.
|
|
572
|
+
const snapPath = (n) => (n === 0 ? backupPath : `${backupPath}.${n}`);
|
|
573
|
+
const snapText = (n) => {
|
|
574
|
+
const want = n === 0 ? (entry.backup === 'made' ? entry.backupSha256 : undefined) : entry.snapshots?.find((x) => x.n === n)?.sha256;
|
|
575
|
+
if (!want)
|
|
576
|
+
return null;
|
|
577
|
+
try {
|
|
578
|
+
if (!fs.lstatSync(snapPath(n)).isFile())
|
|
579
|
+
return null;
|
|
580
|
+
const bytes = fs.readFileSync(snapPath(n));
|
|
581
|
+
return sha(bytes) === want ? bytes.toString('utf8') : null;
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
const backupTrusted = () => snapText(0) !== null;
|
|
588
|
+
/** Only after a fully clean undo, and only copies that are still align's own. */
|
|
589
|
+
const dropBackup = () => {
|
|
590
|
+
for (const n of [0, ...(entry.snapshots ?? []).map((x) => x.n)]) {
|
|
591
|
+
try {
|
|
592
|
+
if (snapText(n) !== null)
|
|
593
|
+
fs.unlinkSync(snapPath(n));
|
|
594
|
+
}
|
|
595
|
+
catch { /* gone */ }
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
// Whole-file only when align wrote this file exactly once and nobody has touched it since.
|
|
599
|
+
if (sha(cur) === entry.sha256 && entry.firstSha256 === entry.sha256) {
|
|
600
|
+
if (entry.created) {
|
|
601
|
+
fs.unlinkSync(file);
|
|
602
|
+
report.removed.push(file);
|
|
603
|
+
report.done.push(file);
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
if (backupTrusted()) {
|
|
607
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${randomBytes(4).toString('hex')}.align-tmp`);
|
|
608
|
+
fs.copyFileSync(backupPath, tmp, 1);
|
|
609
|
+
fs.renameSync(tmp, file);
|
|
610
|
+
dropBackup();
|
|
611
|
+
report.restored.push(file);
|
|
612
|
+
report.done.push(file);
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
// Surgical: only align's own entries, and the user's own values back where align overwrote them.
|
|
617
|
+
const mention = entry.backup === 'foreign' ? `; ${backupPath} was not made by align, so it was not used` : '';
|
|
618
|
+
let next;
|
|
619
|
+
const left = [];
|
|
620
|
+
if (entry.block) {
|
|
621
|
+
const region = regionOf(cur, entry.block);
|
|
622
|
+
if (region === null)
|
|
623
|
+
next = cur;
|
|
624
|
+
else if (sha(region) !== entry.block.sha256) {
|
|
625
|
+
next = cur;
|
|
626
|
+
left.push('the block align manages was edited since align wrote it; remove it by hand');
|
|
627
|
+
}
|
|
628
|
+
else {
|
|
629
|
+
const s0 = cur.indexOf(entry.block.start);
|
|
630
|
+
let replacement = '';
|
|
631
|
+
if (entry.block.replaced) {
|
|
632
|
+
const was = snapText(entry.block.snapshot ?? 0);
|
|
633
|
+
const old = was === null ? null : regionOf(was, entry.block);
|
|
634
|
+
if (old === null)
|
|
635
|
+
left.push(`the block align replaced was yours and align cannot restore it from ${backupPath} (missing or changed). Put the original back by hand`);
|
|
636
|
+
else
|
|
637
|
+
replacement = old;
|
|
638
|
+
}
|
|
639
|
+
next = replacement
|
|
640
|
+
? `${cur.slice(0, s0)}${replacement}${cur.slice(s0 + region.length)}`
|
|
641
|
+
: `${cur.slice(0, s0)}${cur.slice(s0 + region.length)}`.replace(/\n{3,}/g, '\n\n').replace(/^\s+/, '');
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
else if (entry.owned && entry.owned.length > 0) {
|
|
645
|
+
let parsed;
|
|
646
|
+
try {
|
|
647
|
+
parsed = JSON.parse(cur);
|
|
648
|
+
}
|
|
649
|
+
catch {
|
|
650
|
+
parsed = undefined;
|
|
651
|
+
}
|
|
652
|
+
if (!isObject(parsed)) {
|
|
653
|
+
report.skipped.push(`${file}: it is not valid JSON now, so align cannot edit it. Remove by hand: ${entry.owned.map(pathText).join(', ')}${mention}`);
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
const cache = new Map();
|
|
657
|
+
const originalOf = (n) => {
|
|
658
|
+
if (!cache.has(n)) {
|
|
659
|
+
let parsedCopy = null;
|
|
660
|
+
try {
|
|
661
|
+
const o = JSON.parse(snapText(n) ?? 'null');
|
|
662
|
+
parsedCopy = isObject(o) ? o : null;
|
|
663
|
+
}
|
|
664
|
+
catch {
|
|
665
|
+
parsedCopy = null;
|
|
666
|
+
}
|
|
667
|
+
cache.set(n, parsedCopy);
|
|
668
|
+
}
|
|
669
|
+
return cache.get(n) ?? null;
|
|
670
|
+
};
|
|
671
|
+
left.push(...removeOwned(parsed, entry.owned, originalOf, backupPath));
|
|
672
|
+
next = JSON.stringify(parsed, null, 2) + (cur.endsWith('\n') ? '\n' : '');
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
report.skipped.push(`${file}: align has no record of what it added here. Remove its entries by hand${mention}`);
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
// All or nothing: a file with anything left in it is not touched at all.
|
|
679
|
+
if (left.length > 0) {
|
|
680
|
+
for (const l of left)
|
|
681
|
+
report.skipped.push(`${file}: ${l}${mention}`);
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
// A file align created that holds nothing but align's own content once that is out: remove it, as
|
|
685
|
+
// an untouched one is.
|
|
686
|
+
if (entry.created && entry.owned && !entry.block && next.trim() === '{}') {
|
|
687
|
+
fs.unlinkSync(file);
|
|
688
|
+
dropBackup();
|
|
689
|
+
report.removed.push(file);
|
|
690
|
+
report.done.push(file);
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
if (next !== cur)
|
|
694
|
+
stageAndRename(fs, file, next, modeOf(fs, file));
|
|
695
|
+
dropBackup();
|
|
696
|
+
report.cleaned.push(file);
|
|
697
|
+
report.done.push(file);
|
|
698
|
+
}
|
|
699
|
+
return report;
|
|
700
|
+
}
|
|
701
|
+
//# sourceMappingURL=safe-config-write.js.map
|