@vellumai/assistant 0.11.4-dev.202608201011.f81ab8d → 0.11.4-dev.202608201142.dc27263
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/package.json +1 -1
- package/src/__tests__/plugin-config-data-migration.test.ts +8 -1
- package/src/cli/lib/__tests__/install-from-github.test.ts +38 -0
- package/src/cli/lib/__tests__/merge-plugin-tree.test.ts +34 -0
- package/src/cli/lib/__tests__/upgrade-plugin.test.ts +64 -0
- package/src/cli/lib/install-from-github.ts +35 -24
- package/src/cli/lib/merge-plugin-tree.ts +22 -4
- package/src/plugins/plugin-tree-walk.ts +63 -4
package/package.json
CHANGED
|
@@ -32,7 +32,10 @@ import {
|
|
|
32
32
|
populateCacheAtBoot,
|
|
33
33
|
resetPluginCacheForTests,
|
|
34
34
|
} from "../plugins/mtime-cache.js";
|
|
35
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
PRESERVED_ENTRIES,
|
|
37
|
+
USER_STATE_ENTRIES,
|
|
38
|
+
} from "../plugins/plugin-tree-walk.js";
|
|
36
39
|
|
|
37
40
|
const ROOT = join(
|
|
38
41
|
tmpdir(),
|
|
@@ -87,6 +90,10 @@ describe("PRESERVED_ENTRIES", () => {
|
|
|
87
90
|
expect(PRESERVED_ENTRIES).toContain("data");
|
|
88
91
|
expect(PRESERVED_ENTRIES).toContain(".disabled");
|
|
89
92
|
});
|
|
93
|
+
|
|
94
|
+
test("user-state entries are the preserved set minus the provenance sidecar", () => {
|
|
95
|
+
expect([...USER_STATE_ENTRIES]).toEqual(["config.json", "data", ".disabled"]);
|
|
96
|
+
});
|
|
90
97
|
});
|
|
91
98
|
|
|
92
99
|
describe("fingerprint exclusion of preserved entries", () => {
|
|
@@ -303,6 +303,44 @@ describe("installPlugin — install lifecycle", () => {
|
|
|
303
303
|
expect(existsSync(join(target, "README.md"))).toBe(true);
|
|
304
304
|
});
|
|
305
305
|
|
|
306
|
+
test("--force keeps live config.json instead of a pin-shipped default", async () => {
|
|
307
|
+
// GIVEN an install with user config, and a clone that materializes a default
|
|
308
|
+
const target = join(pluginsDir, "caveman");
|
|
309
|
+
mkdirSync(target, { recursive: true });
|
|
310
|
+
writeFileSync(join(target, "package.json"), '{"name":"caveman"}');
|
|
311
|
+
writeFileSync(
|
|
312
|
+
join(target, "config.json"),
|
|
313
|
+
'{"provider":"photon","ingressMode":"live"}\n',
|
|
314
|
+
);
|
|
315
|
+
mkdirSync(join(target, "data"), { recursive: true });
|
|
316
|
+
writeFileSync(join(target, "data", "cursor.json"), '{"n":1}\n');
|
|
317
|
+
|
|
318
|
+
const fetch = makeContentsFetch({ tree: {}, manifest: CAVEMAN_MANIFEST });
|
|
319
|
+
const runGit = fakeGitRunner({
|
|
320
|
+
tree: {
|
|
321
|
+
"package.json": '{"name":"caveman"}',
|
|
322
|
+
"README.md": "# caveman",
|
|
323
|
+
"config.json": '{"provider":"comms","ingressMode":"webhook"}\n',
|
|
324
|
+
},
|
|
325
|
+
commit: CAVEMAN_SHA,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// WHEN we reinstall with --force
|
|
329
|
+
await installPlugin(
|
|
330
|
+
{ name: "caveman", force: true, ref: "main" },
|
|
331
|
+
{ fetch, runGit, workspacePluginsDir: pluginsDir },
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
// THEN user-owned state survives and the pin's default config does not win
|
|
335
|
+
expect(readFileSync(join(target, "config.json"), "utf-8")).toBe(
|
|
336
|
+
'{"provider":"photon","ingressMode":"live"}\n',
|
|
337
|
+
);
|
|
338
|
+
expect(readFileSync(join(target, "data", "cursor.json"), "utf-8")).toBe(
|
|
339
|
+
'{"n":1}\n',
|
|
340
|
+
);
|
|
341
|
+
expect(existsSync(join(target, "README.md"))).toBe(true);
|
|
342
|
+
});
|
|
343
|
+
|
|
306
344
|
test("commitOverride materializes a specific commit, keeping repo from the manifest", async () => {
|
|
307
345
|
// GIVEN a manifest pinning the current SHA, but an override to an older one
|
|
308
346
|
const OLD_SHA = "1".repeat(40);
|
|
@@ -440,4 +440,38 @@ describe("mergePluginTree", () => {
|
|
|
440
440
|
expect(result.conflicts).toEqual(["f.txt"]);
|
|
441
441
|
expect(result.binaryConflicts).toEqual([]);
|
|
442
442
|
});
|
|
443
|
+
|
|
444
|
+
test("copies live config.json, data/, and .disabled from ours, ignoring the pin", async () => {
|
|
445
|
+
// GIVEN user-owned state on the live install and different defaults at the pin
|
|
446
|
+
writeTree(baseDir, { "package.json": '{"name":"p"}\n' });
|
|
447
|
+
writeTree(oursDir, {
|
|
448
|
+
"package.json": '{"name":"p"}\n',
|
|
449
|
+
"config.json": '{"provider":"photon","ingressMode":"live"}\n',
|
|
450
|
+
"data/cursor.json": '{"n":1}\n',
|
|
451
|
+
".disabled": "",
|
|
452
|
+
});
|
|
453
|
+
writeTree(theirsDir, {
|
|
454
|
+
"package.json": '{"name":"p"}\n',
|
|
455
|
+
"config.json": '{"provider":"comms","ingressMode":"webhook"}\n',
|
|
456
|
+
"data/cursor.json": '{"n":0}\n',
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// WHEN merged with theirs (the strategy that would otherwise take the pin)
|
|
460
|
+
const result = await mergePluginTree({
|
|
461
|
+
baseDir,
|
|
462
|
+
oursDir,
|
|
463
|
+
theirsDir,
|
|
464
|
+
destDir,
|
|
465
|
+
strategy: "theirs",
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// THEN user-owned state is the live copy, not the pin, and is not counted
|
|
469
|
+
// as a merged source file
|
|
470
|
+
expect(read("config.json")).toBe(
|
|
471
|
+
'{"provider":"photon","ingressMode":"live"}\n',
|
|
472
|
+
);
|
|
473
|
+
expect(read("data/cursor.json")).toBe('{"n":1}\n');
|
|
474
|
+
expect(read(".disabled")).toBe("");
|
|
475
|
+
expect(result.fileCount).toBe(1);
|
|
476
|
+
});
|
|
443
477
|
});
|
|
@@ -920,6 +920,70 @@ describe("upgradePlugin --strategy", () => {
|
|
|
920
920
|
expect(installedFile("level-up", "local-only.txt")).toBeNull();
|
|
921
921
|
});
|
|
922
922
|
|
|
923
|
+
test("keeps live config.json, data/, and .disabled under overwrite, theirs, and ours", async () => {
|
|
924
|
+
// GIVEN user-owned state on the live install, and a pin that ships defaults
|
|
925
|
+
const userConfig = '{"provider":"photon","ingressMode":"live"}\n';
|
|
926
|
+
const pinConfig = '{"provider":"comms","ingressMode":"webhook"}\n';
|
|
927
|
+
const liveOurs: Tree = {
|
|
928
|
+
...OURS,
|
|
929
|
+
"config.json": userConfig,
|
|
930
|
+
"data/cursor.json": '{"n":1}\n',
|
|
931
|
+
".disabled": "",
|
|
932
|
+
};
|
|
933
|
+
const pinTheirs: Tree = {
|
|
934
|
+
...THEIRS,
|
|
935
|
+
"config.json": pinConfig,
|
|
936
|
+
"data/cursor.json": '{"n":0}\n',
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
for (const strategy of ["overwrite", "theirs", "ours"] as const) {
|
|
940
|
+
rmSync(join(pluginsDir, "level-up"), { recursive: true, force: true });
|
|
941
|
+
installMergeCopy("level-up", liveOurs, SHA_A, BASE);
|
|
942
|
+
const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_B) });
|
|
943
|
+
const runGit = treeGitRunner({
|
|
944
|
+
[SHA_A]: BASE,
|
|
945
|
+
[SHA_B]: pinTheirs,
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
// WHEN the plugin is upgraded
|
|
949
|
+
const result = await upgradePlugin(
|
|
950
|
+
{ name: "level-up", strategy },
|
|
951
|
+
{ fetch, runGit, workspacePluginsDir: pluginsDir },
|
|
952
|
+
);
|
|
953
|
+
|
|
954
|
+
// THEN user-owned state is unchanged, including under `theirs` and
|
|
955
|
+
// overwrite, which would otherwise take the pin wholesale
|
|
956
|
+
expect(result.outcome).toBe("upgraded");
|
|
957
|
+
expect(installedFile("level-up", "config.json")).toBe(userConfig);
|
|
958
|
+
expect(installedFile("level-up", "data/cursor.json")).toBe('{"n":1}\n');
|
|
959
|
+
expect(existsSync(join(pluginsDir, "level-up", ".disabled"))).toBe(true);
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
test("does not seed config.json from the pin when the live install has none", async () => {
|
|
964
|
+
// GIVEN an install with no config.json, and a pin that ships defaults
|
|
965
|
+
installMergeCopy("level-up", OURS, SHA_A, BASE);
|
|
966
|
+
const fetch = makeFetch({ manifest: manifestWith("level-up", SHA_B) });
|
|
967
|
+
const runGit = treeGitRunner({
|
|
968
|
+
[SHA_A]: BASE,
|
|
969
|
+
[SHA_B]: {
|
|
970
|
+
...THEIRS,
|
|
971
|
+
"config.json": '{"provider":"comms","ingressMode":"webhook"}\n',
|
|
972
|
+
},
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
// WHEN upgraded (overwrite materializes the pin tree directly)
|
|
976
|
+
const result = await upgradePlugin(
|
|
977
|
+
{ name: "level-up", strategy: "overwrite" },
|
|
978
|
+
{ fetch, runGit, workspacePluginsDir: pluginsDir },
|
|
979
|
+
);
|
|
980
|
+
|
|
981
|
+
// THEN the pin's config.json does not land: host-owned config is created
|
|
982
|
+
// by the plugin at runtime, not by the installer
|
|
983
|
+
expect(result.outcome).toBe("upgraded");
|
|
984
|
+
expect(installedFile("level-up", "config.json")).toBeNull();
|
|
985
|
+
});
|
|
986
|
+
|
|
923
987
|
test("defaults to overwrite when no strategy is given", async () => {
|
|
924
988
|
// GIVEN an install with a local-only file and an advanced pin
|
|
925
989
|
installMergeCopy("level-up", OURS, SHA_A, BASE);
|
|
@@ -26,7 +26,6 @@
|
|
|
26
26
|
import { execFile } from "node:child_process";
|
|
27
27
|
import {
|
|
28
28
|
copyFileSync,
|
|
29
|
-
cpSync,
|
|
30
29
|
existsSync,
|
|
31
30
|
mkdirSync,
|
|
32
31
|
readdirSync,
|
|
@@ -40,7 +39,12 @@ import { tmpdir } from "node:os";
|
|
|
40
39
|
import { dirname, join, resolve, sep } from "node:path";
|
|
41
40
|
import { promisify } from "node:util";
|
|
42
41
|
|
|
43
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
copyPreservedUserState,
|
|
44
|
+
isPluginUserStateEntry,
|
|
45
|
+
PRESERVED_ENTRIES,
|
|
46
|
+
stripPreservedUserState,
|
|
47
|
+
} from "../../plugins/plugin-tree-walk.js";
|
|
44
48
|
import { ensureBun } from "../../util/bun-runtime.js";
|
|
45
49
|
import { getWorkspacePluginsDir } from "../../util/platform.js";
|
|
46
50
|
import type { FetchLike } from "./fetch-like.js";
|
|
@@ -687,27 +691,13 @@ export async function finalizeStagedInstall(
|
|
|
687
691
|
}
|
|
688
692
|
}
|
|
689
693
|
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
+
// User-owned state (config.json, data/, .disabled) is not plugin source.
|
|
695
|
+
// Drop any copy the pin materialized into staging, then copy the live
|
|
696
|
+
// install's bytes over so an upgrade cannot reset user config to pin
|
|
697
|
+
// defaults. Without the live copy, the rm+rename below would destroy it.
|
|
698
|
+
stripPreservedUserState(stagingDir);
|
|
694
699
|
if (existsSync(target)) {
|
|
695
|
-
|
|
696
|
-
if (entry === INSTALL_META_FILENAME) {
|
|
697
|
-
continue;
|
|
698
|
-
} // sidecar is rewritten above
|
|
699
|
-
const src = join(target, entry);
|
|
700
|
-
if (!existsSync(src)) {
|
|
701
|
-
continue;
|
|
702
|
-
}
|
|
703
|
-
const dest = join(stagingDir, entry);
|
|
704
|
-
const stat = statSync(src);
|
|
705
|
-
if (stat.isDirectory()) {
|
|
706
|
-
cpSync(src, dest, { recursive: true });
|
|
707
|
-
} else {
|
|
708
|
-
copyFileSync(src, dest);
|
|
709
|
-
}
|
|
710
|
-
}
|
|
700
|
+
copyPreservedUserState(target, stagingDir);
|
|
711
701
|
rmSync(target, { recursive: true, force: true });
|
|
712
702
|
}
|
|
713
703
|
renameSync(stagingDir, target);
|
|
@@ -1287,7 +1277,8 @@ function pluginPostinstallEnv(bun: string): NodeJS.ProcessEnv {
|
|
|
1287
1277
|
|
|
1288
1278
|
/**
|
|
1289
1279
|
* Recursively copy regular files from `srcRoot` into `destDir`, skipping the
|
|
1290
|
-
* top-level `.git` directory, a top-level `bunfig.toml` (see below),
|
|
1280
|
+
* top-level `.git` directory, a top-level `bunfig.toml` (see below), top-level
|
|
1281
|
+
* user-state entries (`config.json`, `data/`, `.disabled`), and any
|
|
1291
1282
|
* symlinks. Returns the file count.
|
|
1292
1283
|
*/
|
|
1293
1284
|
function copyTreeSkippingGit(srcRoot: string, destDir: string): number {
|
|
@@ -1300,6 +1291,12 @@ function copyTreeSkippingGit(srcRoot: string, destDir: string): number {
|
|
|
1300
1291
|
if (relDir === "" && entry.name === ".git") {
|
|
1301
1292
|
continue;
|
|
1302
1293
|
}
|
|
1294
|
+
// Runtime-owned state is not plugin source. A pin that ships
|
|
1295
|
+
// `config.json` or `data/` must not land in the staged tree, or an
|
|
1296
|
+
// upgrade would reset user config to whatever the revision committed.
|
|
1297
|
+
if (relDir === "" && isPluginUserStateEntry(entry.name)) {
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1303
1300
|
// Drop a top-level `bunfig.toml`. The adapter postinstall runs `bun` with
|
|
1304
1301
|
// its cwd at the staged root, and Bun auto-loads `$cwd/bunfig.toml` as
|
|
1305
1302
|
// project config — including a `preload` list it executes before the
|
|
@@ -1712,6 +1709,7 @@ async function copyDir(
|
|
|
1712
1709
|
ref: string,
|
|
1713
1710
|
destDir: string,
|
|
1714
1711
|
fetchFn: FetchLike,
|
|
1712
|
+
atPluginRoot = true,
|
|
1715
1713
|
): Promise<number> {
|
|
1716
1714
|
const entries = await listDir(owner, repo, apiPath, ref, fetchFn);
|
|
1717
1715
|
if (entries === null) {
|
|
@@ -1725,11 +1723,24 @@ async function copyDir(
|
|
|
1725
1723
|
continue;
|
|
1726
1724
|
}
|
|
1727
1725
|
assertSafeFilename("entry name", entry.name);
|
|
1726
|
+
// Adapter stubs overlay the plugin root. User-state names at that root
|
|
1727
|
+
// are host-owned, not adapter input.
|
|
1728
|
+
if (atPluginRoot && isPluginUserStateEntry(entry.name)) {
|
|
1729
|
+
continue;
|
|
1730
|
+
}
|
|
1728
1731
|
|
|
1729
1732
|
if (entry.type === "dir") {
|
|
1730
1733
|
const subDest = join(destDir, entry.name);
|
|
1731
1734
|
mkdirSync(subDest, { recursive: true });
|
|
1732
|
-
count += await copyDir(
|
|
1735
|
+
count += await copyDir(
|
|
1736
|
+
owner,
|
|
1737
|
+
repo,
|
|
1738
|
+
entry.path,
|
|
1739
|
+
ref,
|
|
1740
|
+
subDest,
|
|
1741
|
+
fetchFn,
|
|
1742
|
+
false,
|
|
1743
|
+
);
|
|
1733
1744
|
continue;
|
|
1734
1745
|
}
|
|
1735
1746
|
|
|
@@ -23,6 +23,13 @@
|
|
|
23
23
|
* one side) is resolved here before any line merge, since `git merge-file`
|
|
24
24
|
* operates on three existing blobs.
|
|
25
25
|
*
|
|
26
|
+
* Runtime-owned user state (`config.json`, `data/`, `.disabled`) is never
|
|
27
|
+
* merged. Those paths are excluded from the fingerprints and copied from the
|
|
28
|
+
* live install (`ours`) into the destination after the source merge, so a
|
|
29
|
+
* pin that ships default config cannot reset user settings. The provenance
|
|
30
|
+
* sidecar is excluded on every side and rewritten by the caller after the swap.
|
|
31
|
+
*
|
|
32
|
+
*
|
|
26
33
|
* Binary files cannot be line-merged, so a binary file that diverged on both
|
|
27
34
|
* sides is resolved whole-file by the strategy (`ours`/`theirs`); under
|
|
28
35
|
* `assistant` the local copy is kept and the path is reported as a binary
|
|
@@ -44,7 +51,10 @@ import { tmpdir } from "node:os";
|
|
|
44
51
|
import { dirname, join } from "node:path";
|
|
45
52
|
import { promisify } from "node:util";
|
|
46
53
|
|
|
47
|
-
import {
|
|
54
|
+
import {
|
|
55
|
+
copyPreservedUserState,
|
|
56
|
+
PRESERVED_ENTRIES,
|
|
57
|
+
} from "../../plugins/plugin-tree-walk.js";
|
|
48
58
|
import { computeFingerprint } from "./plugin-fingerprint.js";
|
|
49
59
|
|
|
50
60
|
const execFileAsync = promisify(execFile);
|
|
@@ -230,9 +240,11 @@ async function threeWayMergeFile(
|
|
|
230
240
|
|
|
231
241
|
/**
|
|
232
242
|
* Three-way merge `oursDir`/`theirsDir` against `baseDir` into `destDir` per
|
|
233
|
-
* `strategy`.
|
|
234
|
-
*
|
|
235
|
-
*
|
|
243
|
+
* `strategy`. User-owned state (`config.json`, `data/`, `.disabled`) is
|
|
244
|
+
* excluded from the line merge and copied from `oursDir` afterward, so the
|
|
245
|
+
* pin cannot reset it. The provenance sidecar is excluded on every side — it
|
|
246
|
+
* is rewritten by the caller after the swap and must not be carried through
|
|
247
|
+
* the merge.
|
|
236
248
|
*
|
|
237
249
|
* Returns the file count plus, for the `assistant` strategy, the paths left for
|
|
238
250
|
* the assistant to resolve (text files with conflict markers and modify/delete
|
|
@@ -332,5 +344,11 @@ export async function mergePluginTree({
|
|
|
332
344
|
// Present only in the base: removed on both sides, so it stays removed.
|
|
333
345
|
}
|
|
334
346
|
|
|
347
|
+
// User-owned state is not plugin source, so it is never a merge input.
|
|
348
|
+
// Copy it from the live install so `--strategy theirs` cannot take a
|
|
349
|
+
// pin-shipped `config.json` (or drop an untracked one the fingerprint
|
|
350
|
+
// walk never saw).
|
|
351
|
+
copyPreservedUserState(oursDir, destDir);
|
|
352
|
+
|
|
335
353
|
return { fileCount, conflicts, binaryConflicts };
|
|
336
354
|
}
|
|
@@ -15,9 +15,30 @@
|
|
|
15
15
|
* root (`realpathSync`) before walking.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
copyFileSync,
|
|
20
|
+
cpSync,
|
|
21
|
+
existsSync,
|
|
22
|
+
readdirSync,
|
|
23
|
+
rmSync,
|
|
24
|
+
statSync,
|
|
25
|
+
} from "node:fs";
|
|
19
26
|
import { join } from "node:path";
|
|
20
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Top-level runtime-owned entries that belong to the live install, not the
|
|
30
|
+
* plugin's source tree. An upgrade must never take these from the incoming
|
|
31
|
+
* pin: `config.json` is user-edited, `data/` is whatever the plugin wrote at
|
|
32
|
+
* runtime, and `.disabled` is created by `assistant plugins disable`.
|
|
33
|
+
*
|
|
34
|
+
* The provenance sidecar is not in this list. Install rewrites `install-meta.json`
|
|
35
|
+
* at the swap boundary; carrying the outgoing copy forward would pin the
|
|
36
|
+
* upgrade to the previous commit.
|
|
37
|
+
*/
|
|
38
|
+
export const USER_STATE_ENTRIES = ["config.json", "data", ".disabled"] as const;
|
|
39
|
+
|
|
40
|
+
const USER_STATE_NAME_SET: ReadonlySet<string> = new Set(USER_STATE_ENTRIES);
|
|
41
|
+
|
|
21
42
|
/**
|
|
22
43
|
* Top-level entries that are preserved across upgrades and excluded from
|
|
23
44
|
* fingerprinting / drift detection / content hashing. These are runtime-owned
|
|
@@ -35,11 +56,49 @@ import { join } from "node:path";
|
|
|
35
56
|
*/
|
|
36
57
|
export const PRESERVED_ENTRIES = [
|
|
37
58
|
"install-meta.json",
|
|
38
|
-
|
|
39
|
-
"data",
|
|
40
|
-
".disabled",
|
|
59
|
+
...USER_STATE_ENTRIES,
|
|
41
60
|
] as const;
|
|
42
61
|
|
|
62
|
+
/** True when `name` is a top-level user-state entry (`config.json`, `data`, `.disabled`). */
|
|
63
|
+
export function isPluginUserStateEntry(name: string): boolean {
|
|
64
|
+
return USER_STATE_NAME_SET.has(name);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Remove user-state entries from `dir` so a pin-shipped `config.json` or
|
|
69
|
+
* `data/` cannot ride the swap. No-op when those paths are already absent.
|
|
70
|
+
*/
|
|
71
|
+
export function stripPreservedUserState(dir: string): void {
|
|
72
|
+
for (const entry of USER_STATE_ENTRIES) {
|
|
73
|
+
rmSync(join(dir, entry), { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Copy live user-state (`config.json`, `data/`, `.disabled`) from `fromDir`
|
|
79
|
+
* into `toDir`, replacing any copy already at the destination so the live
|
|
80
|
+
* bytes win whole-file / whole-tree.
|
|
81
|
+
*/
|
|
82
|
+
export function copyPreservedUserState(fromDir: string, toDir: string): void {
|
|
83
|
+
if (!existsSync(fromDir)) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
for (const entry of USER_STATE_ENTRIES) {
|
|
87
|
+
const src = join(fromDir, entry);
|
|
88
|
+
if (!existsSync(src)) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const dest = join(toDir, entry);
|
|
92
|
+
rmSync(dest, { recursive: true, force: true });
|
|
93
|
+
const stat = statSync(src);
|
|
94
|
+
if (stat.isDirectory()) {
|
|
95
|
+
cpSync(src, dest, { recursive: true });
|
|
96
|
+
} else {
|
|
97
|
+
copyFileSync(src, dest);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
43
102
|
/**
|
|
44
103
|
* Directory names {@link walkPluginTree} skips at any depth, unconditionally.
|
|
45
104
|
* `node_modules` holds a plugin's installed dependencies — derived from the
|