@morlay/dsh-desktop-shell 0.2.4 → 0.2.6

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.
Files changed (51) hide show
  1. package/README.md +4 -4
  2. package/dist/{appconfig-BZ9wPROk.mjs → appconfig-CSZh3JG7.mjs} +2 -2
  3. package/dist/appconfig.d.mts +21 -0
  4. package/dist/appconfig.mjs +1 -2
  5. package/dist/{dshhome-Dp89FOr-.mjs → dshhome-DAJ9dkn2.mjs} +2 -2
  6. package/dist/dshhome.d.mts +6 -0
  7. package/dist/dshhome.mjs +1 -2
  8. package/dist/index.d.mts +6 -0
  9. package/dist/index.mjs +1050 -139
  10. package/dist/inline-packages.d.mts +14 -0
  11. package/dist/inline-packages.mjs +21 -0
  12. package/dist/official-BmgBsdhq.mjs +5 -0
  13. package/dist/official.d.mts +4 -0
  14. package/dist/official.mjs +1 -2
  15. package/dist/preload-app.cjs +5 -1
  16. package/dist/profile-project.d.mts +20 -0
  17. package/dist/profile-project.mjs +1 -2
  18. package/dist/{seed-BF71UAuI.mjs → seed-Bxr0BsCk.mjs} +52 -5
  19. package/dist/seed.d.mts +21 -0
  20. package/dist/seed.mjs +1 -2
  21. package/package.json +42 -21
  22. package/src/__tests__/developer-endpoint.spec.ts +17 -0
  23. package/src/__tests__/document-marks.spec.ts +42 -0
  24. package/src/__tests__/dshhome.spec.ts +108 -0
  25. package/src/__tests__/host-process.spec.ts +205 -0
  26. package/src/__tests__/ipc.spec.ts +39 -0
  27. package/src/__tests__/profile-project.spec.ts +204 -0
  28. package/src/__tests__/seed-profile.spec.ts +189 -0
  29. package/src/__tests__/shell-env.spec.ts +131 -0
  30. package/src/__tests__/shell-import-boundary.spec.ts +69 -0
  31. package/src/__tests__/stream-uplink.spec.ts +49 -0
  32. package/src/appconfig.ts +79 -0
  33. package/src/developer-endpoint.ts +30 -0
  34. package/src/developer-menu.ts +198 -0
  35. package/src/directory-picker.ts +37 -0
  36. package/src/document-marks.ts +58 -0
  37. package/src/dshhome.ts +29 -0
  38. package/src/electron.ts +33 -0
  39. package/src/host-process.ts +533 -0
  40. package/src/index.ts +716 -0
  41. package/src/inline-packages.ts +18 -0
  42. package/src/ipc.ts +58 -0
  43. package/src/keybindings.ts +35 -0
  44. package/src/official.ts +8 -0
  45. package/src/preload-app.ts +147 -0
  46. package/src/profile-project.ts +138 -0
  47. package/src/seed.ts +183 -0
  48. package/src/shell-env.ts +31 -0
  49. package/src/shortcuts.ts +236 -0
  50. package/src/stream-uplink.ts +41 -0
  51. package/dist/official-Cz9Mry1n.mjs +0 -167
@@ -0,0 +1,205 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { join } from "node:path";
3
+ import { PassThrough } from "node:stream";
4
+ import type { ChildProcess } from "node:child_process";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { DESKTOP_HOST_PROTOCOL_VERSION } from "@morlay/dsh-desktop-host/wire";
7
+ import { DesktopHostProcess, type DesktopHostOptions } from "../host-process.ts";
8
+
9
+ interface SpawnCall {
10
+ readonly command: string;
11
+ readonly args: readonly string[];
12
+ readonly cwd: string | undefined;
13
+ readonly stdio: unknown;
14
+ }
15
+
16
+ interface FakeChild extends ChildProcess {
17
+ readonly sent: unknown[];
18
+ readonly requestPipe: PassThrough;
19
+ readonly responsePipe: PassThrough;
20
+ }
21
+
22
+ function fakeChild(): FakeChild {
23
+ const child = new EventEmitter() as FakeChild;
24
+ const requestPipe = new PassThrough();
25
+ const responsePipe = new PassThrough();
26
+ Object.assign(child, {
27
+ sent: [] as unknown[],
28
+ requestPipe,
29
+ responsePipe,
30
+ stderr: new PassThrough(),
31
+ stdout: new PassThrough(),
32
+ connected: true,
33
+ kill: () => true,
34
+ send: (message: unknown, callback?: (error: Error | null) => void) => {
35
+ child.sent.push(message);
36
+ callback?.(null);
37
+ return true;
38
+ },
39
+ });
40
+ Object.assign(child, {
41
+ stdio: [null, null, null, requestPipe, responsePipe, null],
42
+ });
43
+ return child;
44
+ }
45
+
46
+ function harness(options: Partial<DesktopHostOptions> = {}, inspectPort?: number) {
47
+ const calls: SpawnCall[] = [];
48
+ const child = fakeChild();
49
+ const host = new DesktopHostProcess(
50
+ "/runtime/node/node",
51
+ "/app/seed",
52
+ "/home/profiles/desktop",
53
+ inspectPort,
54
+ {
55
+ entry: ENTRY,
56
+ ...options,
57
+ spawn: ((command: string, args: readonly string[], spawnOptions: { cwd?: string }) => {
58
+ calls.push({
59
+ command,
60
+ args,
61
+ cwd: spawnOptions.cwd,
62
+ stdio: (spawnOptions as { stdio?: unknown }).stdio,
63
+ });
64
+ return child;
65
+ }) as never,
66
+ },
67
+ );
68
+ return { calls, child, host };
69
+ }
70
+
71
+ // 宿主入口是**源码**(包的 `exports` 指 `src/index.ts`),由 `--import` 的 TS loader 转译:
72
+ // 两种形态(dev / bundle)同一条路,部署里不再落 dist 载荷。
73
+ const ENTRY = join("/app/seed", "node_modules", "@morlay", "dsh-desktop-host", "src", "index.ts");
74
+
75
+ function ready(child: FakeChild): void {
76
+ child.emit("message", {
77
+ type: "ready",
78
+ protocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
79
+ origin: "http://127.0.0.1:3000",
80
+ });
81
+ }
82
+
83
+ // 从请求管道读到至少 count 条完整帧(帧写入跨 tick)。
84
+ describe("桌面 host 子进程", () => {
85
+ it("启动形态:入口路径、argv 顺序与 stdio", async () => {
86
+ const { calls, child, host } = harness(
87
+ {
88
+ packageManager: { pnpm: "/app/runtime/pnpm/bin/pnpm.mjs", nodeBin: "/app/runtime/bin" },
89
+ nodeArgs: ["--import=/app/runtime/loader/register.mjs"],
90
+ },
91
+ 9230,
92
+ );
93
+ const started = host.start();
94
+ expect(calls).toHaveLength(1);
95
+ const call = calls[0];
96
+ expect(call?.command).toBe("/runtime/node/node");
97
+ expect(call?.stdio).toEqual(["ignore", "pipe", "pipe", "ipc"]);
98
+ expect(call?.cwd).toBe("/home/profiles/desktop");
99
+ expect(call?.args.slice(0, 3)).toEqual([
100
+ "--expose-internals",
101
+ "--inspect=127.0.0.1:9230",
102
+ "--import=/app/runtime/loader/register.mjs",
103
+ ]);
104
+ expect(call?.args.slice(3)).toEqual([
105
+ ENTRY,
106
+ "--profile-dir",
107
+ "/home/profiles/desktop",
108
+ "--runtime-dir",
109
+ "/app/seed",
110
+ "--pnpm",
111
+ "/app/runtime/pnpm/bin/pnpm.mjs",
112
+ "--node-bin",
113
+ "/app/runtime/bin",
114
+ ]);
115
+ ready(child);
116
+ await expect(started).resolves.toEqual({
117
+ protocolVersion: DESKTOP_HOST_PROTOCOL_VERSION,
118
+ origin: "http://127.0.0.1:3000",
119
+ });
120
+ const stopped = host.stop();
121
+ child.emit("close", 0);
122
+ await stopped;
123
+ });
124
+
125
+ it("processTitle 转成 node --title,进程在 ps 里可区分", async () => {
126
+ const { calls, child, host } = harness({ processTitle: "dsh-custom-next-server" });
127
+ const started = host.start();
128
+ expect(calls[0]?.args.slice(0, 2)).toEqual([
129
+ "--expose-internals",
130
+ "--title=dsh-custom-next-server",
131
+ ]);
132
+ ready(child);
133
+ await started;
134
+ const stopped = host.stop();
135
+ child.emit("close", 0);
136
+ await stopped;
137
+ });
138
+
139
+ it("ready 之前报 fatal 时启动失败", async () => {
140
+ const { child, host } = harness();
141
+ const started = host.start();
142
+ child.emit("message", { type: "fatal", message: "composition exploded" });
143
+ await expect(started).rejects.toThrow("composition exploded");
144
+ });
145
+
146
+ // fatal 可另带完整诊断(util.inspect 的错误,含 code/syscall/path/cause)。壳的 dialog 只显示
147
+ // message,完整诊断走 stderr——IPC 与 stderr 谁先到不确定,诊断不能只挂在一边。
148
+ it("fatal 带完整诊断时写进 stderr,失败信息仍是 message", async () => {
149
+ const written = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
150
+ try {
151
+ const { child, host } = harness();
152
+ const started = host.start();
153
+ child.emit("message", {
154
+ type: "fatal",
155
+ message: "composition exploded",
156
+ diagnostic: "Error: composition exploded\n at boot (/app/seed/index.mjs:12:3)",
157
+ });
158
+ await expect(started).rejects.toThrow("composition exploded");
159
+ expect(written.mock.calls.map((call) => String(call[0])).join("")).toContain(
160
+ "at boot (/app/seed/index.mjs:12:3)",
161
+ );
162
+ } finally {
163
+ written.mockRestore();
164
+ }
165
+ });
166
+
167
+ it("调试端点命令走 IPC,端点状态回调给订阅者", async () => {
168
+ const { child, host } = harness();
169
+ const started = host.start();
170
+ ready(child);
171
+ await started;
172
+
173
+ const states: unknown[] = [];
174
+ const release = host.onInspected((state) => states.push(state));
175
+ host.inspect(0);
176
+ host.endInspect();
177
+ expect(child.sent).toEqual([{ type: "inspect", port: 0 }, { type: "inspect-off" }]);
178
+
179
+ child.emit("message", { type: "inspected", url: "ws://127.0.0.1:9229/uuid" });
180
+ child.emit("message", { type: "inspected", url: null, message: "listen failed" });
181
+ expect(states).toEqual([
182
+ { type: "inspected", url: "ws://127.0.0.1:9229/uuid" },
183
+ { type: "inspected", url: null, message: "listen failed" },
184
+ ]);
185
+
186
+ release();
187
+ child.emit("message", { type: "inspected", url: null });
188
+ expect(states).toHaveLength(2);
189
+
190
+ const stopped = host.stop();
191
+ child.emit("close", 0);
192
+ await stopped;
193
+ });
194
+
195
+ it("stop 通过 IPC 请求收尾", async () => {
196
+ const { child, host } = harness();
197
+ const started = host.start();
198
+ ready(child);
199
+ await started;
200
+ const stopped = host.stop();
201
+ child.emit("close", 0);
202
+ await stopped;
203
+ expect(child.sent).toEqual([{ type: "shutdown" }]);
204
+ });
205
+ });
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { IpcMainInvokeEvent } from "electron";
3
+ import { DESKTOP_SCHEME_ARGUMENT, assertDesktopSender, desktopScheme } from "../ipc.ts";
4
+
5
+ function sender(url: string): IpcMainInvokeEvent {
6
+ return { senderFrame: { url } } as unknown as IpcMainInvokeEvent;
7
+ }
8
+
9
+ describe("桌面标识取自 app 名", () => {
10
+ it("直接用包名(与官方桌面的 dsh-app 错开)", () => {
11
+ expect(desktopScheme("dsh-custom-next")).toBe("dsh-custom-next");
12
+ });
13
+
14
+ it("去 scope 前缀、转小写、非法字符换成 '-'", () => {
15
+ expect(desktopScheme("@morlay/dsh_custom")).toBe("dsh-custom");
16
+ expect(desktopScheme("@morlay/Dsh.Custom+next")).toBe("dsh.custom+next");
17
+ });
18
+
19
+ it("派生不出合法 scheme 时报错", () => {
20
+ for (const name of ["", "@morlay/", "1app", "_app"]) {
21
+ expect(() => desktopScheme(name)).toThrow(/does not yield a usable URL scheme/u);
22
+ }
23
+ });
24
+
25
+ it("发送者校验用传入的 scheme,而不是写死的 dsh-app", () => {
26
+ const scheme = desktopScheme("dsh-custom-next");
27
+ expect(() => assertDesktopSender(sender(`${scheme}://app/`), scheme, ["app"])).not.toThrow();
28
+ expect(() => assertDesktopSender(sender("dsh-app://app/"), scheme, ["app"])).toThrow(
29
+ /unowned renderer/u,
30
+ );
31
+ expect(() => assertDesktopSender(sender(`${scheme}://other/`), scheme, ["app"])).toThrow(
32
+ /unowned renderer/u,
33
+ );
34
+ });
35
+
36
+ it("preload 读 scheme 的参数名与主进程注入的一致", () => {
37
+ expect(DESKTOP_SCHEME_ARGUMENT).toBe("--dsh-desktop-scheme");
38
+ });
39
+ });
@@ -0,0 +1,204 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+ import {
6
+ PROFILE_RUNTIME_REPORT_NAME,
7
+ installProfile,
8
+ runtimeOverrides,
9
+ workspaceWithOverrides,
10
+ } from "../profile-project.ts";
11
+
12
+ const roots: string[] = [];
13
+
14
+ async function workDir(): Promise<string> {
15
+ const root = await mkdtemp(join(tmpdir(), "dshdev-profile-"));
16
+ roots.push(root);
17
+ return root;
18
+ }
19
+
20
+ async function write(path: string, content: string): Promise<void> {
21
+ await mkdir(dirname(path), { recursive: true });
22
+ await writeFile(path, content);
23
+ }
24
+
25
+ afterEach(async () => {
26
+ for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true });
27
+ });
28
+
29
+ const SETTINGS = `packages:
30
+ - .
31
+
32
+ nodeLinker: hoisted
33
+ autoInstallPeers: false
34
+ allowBuilds:
35
+ node-pty: true
36
+ `;
37
+
38
+ function report(entries: readonly { name: string; path: string }[]): string {
39
+ return `${JSON.stringify({ schemaVersion: 1, runtimePackages: entries }, undefined, 2)}\n`;
40
+ }
41
+
42
+ describe("profile overrides", () => {
43
+ it("links every reported runtime package to its directory inside the runtime", async () => {
44
+ const profile = await workDir();
45
+ const runtime = await workDir();
46
+ await write(
47
+ join(profile, PROFILE_RUNTIME_REPORT_NAME),
48
+ report([
49
+ { name: "@deepseek-ai/dsh-llm", path: "node_modules/@deepseek-ai/dsh-llm" },
50
+ {
51
+ name: "@morlay/ui-conversation-message-actions",
52
+ path: "node_modules/.pnpm/@morlay+better-session@file+/node_modules/@morlay/ui-conversation-message-actions",
53
+ },
54
+ ]),
55
+ );
56
+
57
+ expect(await runtimeOverrides(profile, runtime)).toEqual([
58
+ { name: "@deepseek-ai/dsh-llm", target: join(runtime, "node_modules/@deepseek-ai/dsh-llm") },
59
+ {
60
+ name: "@morlay/ui-conversation-message-actions",
61
+ target: join(
62
+ runtime,
63
+ "node_modules/.pnpm/@morlay+better-session@file+/node_modules/@morlay/ui-conversation-message-actions",
64
+ ),
65
+ },
66
+ ]);
67
+ });
68
+
69
+ it("fails loud when the seed wrote no runtime package report", async () => {
70
+ const profile = await workDir();
71
+ await expect(runtimeOverrides(profile, "/runtime")).rejects.toThrow(
72
+ new RegExp(PROFILE_RUNTIME_REPORT_NAME, "u"),
73
+ );
74
+ });
75
+
76
+ it("rejects a report whose package list is not an entry array", async () => {
77
+ const profile = await workDir();
78
+ await write(
79
+ join(profile, PROFILE_RUNTIME_REPORT_NAME),
80
+ `${JSON.stringify({ schemaVersion: 1, runtimePackages: [7] })}\n`,
81
+ );
82
+ await expect(runtimeOverrides(profile, "/runtime")).rejects.toThrow(/runtimePackages/u);
83
+ });
84
+
85
+ it("rejects an entry without a runtime path", async () => {
86
+ const profile = await workDir();
87
+ await write(
88
+ join(profile, PROFILE_RUNTIME_REPORT_NAME),
89
+ report([{ name: "@deepseek-ai/dsh-llm", path: "" }]),
90
+ );
91
+ await expect(runtimeOverrides(profile, "/runtime")).rejects.toThrow(/runtimePackages/u);
92
+ });
93
+ });
94
+
95
+ describe("profile workspace settings", () => {
96
+ it("appends the runtime overrides after the profile settings", () => {
97
+ const text = workspaceWithOverrides(SETTINGS, [
98
+ { name: "@deepseek-ai/dsh-llm", target: "/app/runtime/node_modules/@deepseek-ai/dsh-llm" },
99
+ ]);
100
+
101
+ expect(text).toContain("nodeLinker: hoisted");
102
+ expect(text).toContain("allowBuilds:\n node-pty: true\n");
103
+ expect(text).toContain(
104
+ 'overrides:\n "@deepseek-ai/dsh-llm": "link:/app/runtime/node_modules/@deepseek-ai/dsh-llm"\n',
105
+ );
106
+ });
107
+
108
+ it("replaces the overrides it wrote on an earlier launch", () => {
109
+ const first = workspaceWithOverrides(SETTINGS, [{ name: "a", target: "/one/a" }]);
110
+ const second = workspaceWithOverrides(first, [{ name: "b", target: "/two/b" }]);
111
+
112
+ expect(second.match(/overrides:/gu)).toHaveLength(1);
113
+ expect(second).not.toContain("/one/a");
114
+ expect(second).toContain('"b": "link:/two/b"');
115
+ });
116
+
117
+ it("keeps the settings untouched when no runtime package is overridden", () => {
118
+ expect(workspaceWithOverrides(SETTINGS, [])).toBe(SETTINGS);
119
+ });
120
+
121
+ it("keeps a hand-edited overrides block when the runtime list is empty", () => {
122
+ const edited = `${SETTINGS}overrides:\n "left": "1.0.0"\n`;
123
+
124
+ expect(workspaceWithOverrides(edited, [])).toBe(edited);
125
+ });
126
+ });
127
+
128
+ describe("profile installation", () => {
129
+ const spawnStub = (calls: { command: string; args: readonly string[]; cwd: string }[]) =>
130
+ ((command: string, args: readonly string[], options: { cwd?: string }) => {
131
+ calls.push({ command, args, cwd: String(options.cwd) });
132
+ return {
133
+ on: (event: string, listener: (...rest: unknown[]) => void) => {
134
+ if (event === "close")
135
+ setImmediate(() => {
136
+ listener(0, null);
137
+ });
138
+ return undefined;
139
+ },
140
+ once: (event: string, listener: (...rest: unknown[]) => void) => {
141
+ if (event === "close")
142
+ setImmediate(() => {
143
+ listener(0, null);
144
+ });
145
+ return undefined;
146
+ },
147
+ };
148
+ }) as unknown as typeof import("node:child_process").spawn;
149
+
150
+ it("installs the profile offline with the bundled pnpm entry", async () => {
151
+ const profile = await workDir();
152
+ const calls: { command: string; args: readonly string[]; cwd: string }[] = [];
153
+
154
+ await installProfile({
155
+ node: "/app/runtime/node/node",
156
+ pnpmEntry: "/app/runtime/pnpm/bin/pnpm.mjs",
157
+ profileDir: profile,
158
+ spawn: spawnStub(calls),
159
+ });
160
+
161
+ expect(calls).toEqual([
162
+ {
163
+ command: "/app/runtime/node/node",
164
+ args: [
165
+ "/app/runtime/pnpm/bin/pnpm.mjs",
166
+ "install",
167
+ "--prod",
168
+ "--ignore-scripts",
169
+ "--offline",
170
+ ],
171
+ cwd: profile,
172
+ },
173
+ ]);
174
+ });
175
+
176
+ it("reports the pnpm exit status when the installation fails", async () => {
177
+ const profile = await workDir();
178
+ const failing = ((_command: string, _args: readonly string[], _options: unknown) => ({
179
+ once: (event: string, listener: (...rest: unknown[]) => void) => {
180
+ if (event === "close")
181
+ setImmediate(() => {
182
+ listener(1, null);
183
+ });
184
+ return undefined;
185
+ },
186
+ on: (event: string, listener: (...rest: unknown[]) => void) => {
187
+ if (event === "close")
188
+ setImmediate(() => {
189
+ listener(1, null);
190
+ });
191
+ return undefined;
192
+ },
193
+ })) as unknown as typeof import("node:child_process").spawn;
194
+
195
+ await expect(
196
+ installProfile({
197
+ node: "/app/runtime/node/node",
198
+ pnpmEntry: "/app/runtime/pnpm/bin/pnpm.mjs",
199
+ profileDir: profile,
200
+ spawn: failing,
201
+ }),
202
+ ).rejects.toThrow(/profile install/u);
203
+ });
204
+ });
@@ -0,0 +1,189 @@
1
+ import { spawn } from "node:child_process";
2
+ import {
3
+ access,
4
+ lstat,
5
+ mkdir,
6
+ mkdtemp,
7
+ readFile,
8
+ readlink,
9
+ rm,
10
+ symlink,
11
+ writeFile,
12
+ } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { dirname, join, resolve } from "node:path";
15
+ import { afterEach, describe, expect, it } from "vitest";
16
+ import {
17
+ ensureSeedProfile,
18
+ PROFILE_MODULES_ARCHIVE_NAME,
19
+ SEED_DIR_NAME,
20
+ SEED_HASH_NAME,
21
+ } from "../seed.ts";
22
+
23
+ const roots: string[] = [];
24
+
25
+ async function exists(path: string): Promise<boolean> {
26
+ return await access(path).then(
27
+ () => true,
28
+ () => false,
29
+ );
30
+ }
31
+
32
+ async function write(path: string, content = ""): Promise<void> {
33
+ await mkdir(dirname(path), { recursive: true });
34
+ await writeFile(path, content);
35
+ }
36
+
37
+ interface Fixture {
38
+ readonly seed: string;
39
+ readonly home: string;
40
+ readonly seedProfile: string;
41
+ readonly homeProfile: string;
42
+ }
43
+
44
+ async function fixture(): Promise<Fixture> {
45
+ const root = await mkdtemp(resolve(tmpdir(), "dshdev-seed-"));
46
+ roots.push(root);
47
+ const seed = join(root, SEED_DIR_NAME);
48
+ const home = join(root, "home");
49
+ const seedProfile = join(seed, "profiles", "desktop");
50
+ await write(join(seedProfile, SEED_HASH_NAME), "abc123");
51
+ await write(join(seedProfile, "cordis.patch.yml"), "- insert: []\n");
52
+ return { seed, home, seedProfile, homeProfile: join(home, "profiles", "desktop") };
53
+ }
54
+
55
+ // 造一份**真的**随包归档(与打包时同名同布局):`node_modules/<pkg>/…` 打成一个 tar。
56
+ async function makeArchive(seed: string, entries: Readonly<Record<string, string>>): Promise<void> {
57
+ const stage = join(seed, ".stage");
58
+ for (const [relative, content] of Object.entries(entries)) {
59
+ await write(join(stage, ...relative.split("/")), content);
60
+ }
61
+ await new Promise<void>((done, fail) => {
62
+ const child = spawn(
63
+ "tar",
64
+ ["czf", join(seed, PROFILE_MODULES_ARCHIVE_NAME), "-C", stage, "node_modules"],
65
+ { stdio: ["ignore", "ignore", "inherit"] },
66
+ );
67
+ child.on("error", fail);
68
+ child.on("close", (code) =>
69
+ code === 0 ? done() : fail(new Error(`tar exited with ${String(code)}`)),
70
+ );
71
+ });
72
+ }
73
+
74
+ const ARCHIVE_ONE = { "node_modules/fixture-pkg/index.js": "export default 1;\n" } as const;
75
+
76
+ afterEach(async () => {
77
+ for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true });
78
+ });
79
+
80
+ describe("seed profile planting", () => {
81
+ it("plants the profile into an empty app home", async () => {
82
+ const { seed, home, homeProfile } = await fixture();
83
+
84
+ expect(await ensureSeedProfile(seed, home)).toBe("seeded");
85
+ expect(await readFile(join(homeProfile, "cordis.patch.yml"), "utf8")).toBe("- insert: []\n");
86
+ expect(await readFile(join(homeProfile, SEED_HASH_NAME), "utf8")).toBe("abc123");
87
+ });
88
+
89
+ it("leaves a matching profile alone on the next launch", async () => {
90
+ const { seed, home, homeProfile } = await fixture();
91
+ await ensureSeedProfile(seed, home);
92
+ await write(join(homeProfile, "local-state.json"), "{}\n");
93
+
94
+ expect(await ensureSeedProfile(seed, home)).toBe("up-to-date");
95
+ expect(await readFile(join(homeProfile, "local-state.json"), "utf8")).toBe("{}\n");
96
+ });
97
+
98
+ it("replaces the profile when the seed fingerprint changes", async () => {
99
+ const { seed, home, homeProfile, seedProfile } = await fixture();
100
+ await ensureSeedProfile(seed, home);
101
+ await write(join(homeProfile, "stale.json"), "{}\n");
102
+
103
+ await write(join(seedProfile, SEED_HASH_NAME), "def456");
104
+ await write(join(seedProfile, "extra.json"), "{}\n");
105
+
106
+ expect(await ensureSeedProfile(seed, home)).toBe("seeded");
107
+ expect(await exists(join(homeProfile, "stale.json"))).toBe(false);
108
+ expect(await exists(join(homeProfile, "extra.json"))).toBe(true);
109
+ expect(await readFile(join(homeProfile, SEED_HASH_NAME), "utf8")).toBe("def456");
110
+ });
111
+
112
+ it("re-plants everything but the user's own patch document", async () => {
113
+ const { seed, home, homeProfile, seedProfile } = await fixture();
114
+ await ensureSeedProfile(seed, home);
115
+ const settings = "- id: locale\n config:\n preference: zh\n";
116
+ await write(join(homeProfile, "cordis.patch.yml"), settings);
117
+ await write(join(homeProfile, "stale.json"), "{}\n");
118
+
119
+ await write(join(seedProfile, SEED_HASH_NAME), "def456");
120
+ await write(join(seedProfile, "cordis.patch.yml"), "- id: locale\n");
121
+
122
+ expect(await ensureSeedProfile(seed, home)).toBe("seeded");
123
+
124
+ // 替换逻辑照旧(种子的文件覆盖、旧文件清掉),只有这一份豁免:settings 写在这里。
125
+ expect(await readFile(join(homeProfile, "cordis.patch.yml"), "utf8")).toBe(settings);
126
+ expect(await exists(join(homeProfile, "stale.json"))).toBe(false);
127
+ expect(await readFile(join(homeProfile, SEED_HASH_NAME), "utf8")).toBe("def456");
128
+ });
129
+
130
+ it("re-plants when the seed carries no fingerprint", async () => {
131
+ const { seed, home, seedProfile } = await fixture();
132
+ await rm(join(seedProfile, SEED_HASH_NAME));
133
+
134
+ expect(await ensureSeedProfile(seed, home)).toBe("seeded");
135
+ expect(await ensureSeedProfile(seed, home)).toBe("seeded");
136
+ });
137
+
138
+ it("does nothing when the seed has no desktop profile", async () => {
139
+ const { seed, home } = await fixture();
140
+ await rm(join(seed, "profiles"), { recursive: true });
141
+
142
+ expect(await ensureSeedProfile(seed, home)).toBe("no-seed");
143
+ expect(await exists(home)).toBe(false);
144
+ });
145
+
146
+ it("keeps symlinks as links and skips the store directories", async () => {
147
+ const { seed, home, homeProfile, seedProfile } = await fixture();
148
+ await write(join(seed, "node_modules", "pkg", "index.js"), "module.exports = {};\n");
149
+ await symlink(join("..", "..", "node_modules", "pkg"), join(seedProfile, "pkg-link"), "dir");
150
+ await write(join(seedProfile, ".store", "cached.txt"), "cached\n");
151
+
152
+ expect(await ensureSeedProfile(seed, home)).toBe("seeded");
153
+ expect((await lstat(join(homeProfile, "pkg-link"))).isSymbolicLink()).toBe(true);
154
+ expect(await readlink(join(homeProfile, "pkg-link"))).toBe(
155
+ join("..", "..", "node_modules", "pkg"),
156
+ );
157
+ expect(await exists(join(homeProfile, ".store"))).toBe(false);
158
+ });
159
+
160
+ it("plants the dependency closure from the shipped archive", async () => {
161
+ const { seed, home, homeProfile } = await fixture();
162
+ await makeArchive(seed, ARCHIVE_ONE);
163
+
164
+ expect(await ensureSeedProfile(seed, home)).toBe("seeded");
165
+ expect(await exists(join(homeProfile, "node_modules", "fixture-pkg", "index.js"))).toBe(true);
166
+ // 依赖面在了,指纹也一致:下一次不该再种。
167
+ expect(await ensureSeedProfile(seed, home)).toBe("up-to-date");
168
+ });
169
+
170
+ it("re-plants when the fingerprint matches but the closure is gone", async () => {
171
+ const { seed, home, homeProfile } = await fixture();
172
+ await makeArchive(seed, ARCHIVE_ONE);
173
+ await ensureSeedProfile(seed, home);
174
+
175
+ // 上一次解压失败、或用户清过:指纹(种子自己的)仍然一致,但依赖面没了——**必须补**,
176
+ // 否则宿主会带着残缺的 profile 起来(表现是"组件不完整",而且永远等不到它自愈)。
177
+ await rm(join(homeProfile, "node_modules"), { recursive: true, force: true });
178
+
179
+ expect(await ensureSeedProfile(seed, home)).toBe("seeded");
180
+ expect(await exists(join(homeProfile, "node_modules", "fixture-pkg", "index.js"))).toBe(true);
181
+ });
182
+
183
+ it("fails loud when the archive does not yield a closure", async () => {
184
+ const { seed, home } = await fixture();
185
+ await write(join(seed, PROFILE_MODULES_ARCHIVE_NAME), "not a tar at all\n");
186
+
187
+ await expect(ensureSeedProfile(seed, home)).rejects.toThrow(/tar -xzf/u);
188
+ });
189
+ });