@decohere/devkit 0.0.1-dev.125 → 0.0.1-dev.5

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 (121) hide show
  1. package/package.json +3 -12
  2. package/src/cli/action.ts +77 -0
  3. package/src/cli/command.ts +173 -0
  4. package/src/cli/index.ts +30 -0
  5. package/src/cli/module.type.ts +48 -0
  6. package/src/cli/options.ts +81 -0
  7. package/src/diagnostics/trace.ts +92 -0
  8. package/src/interop/docker/docker.ts +57 -0
  9. package/src/interop/errors/interop.error.ts +1 -0
  10. package/src/interop/git/git.ts +98 -0
  11. package/{types/src/interop/git/git.types.d.ts → src/interop/git/git.types.ts} +9 -6
  12. package/{es6/src/interop/index.js → src/interop/index.ts} +1 -0
  13. package/src/interop/node/node.ts +38 -0
  14. package/src/interop/npm/npm.ts +168 -0
  15. package/src/interop/npm/npm.types.ts +28 -0
  16. package/src/interop/pnpm/pnpm.ts +22 -0
  17. package/{types/src/interop/pnpm/pnpm.types.d.ts → src/interop/pnpm/pnpm.types.ts} +1 -1
  18. package/src/interop/shell/shell.argv.ts +31 -0
  19. package/src/interop/shell/shell.ts +100 -0
  20. package/src/interop/shell/shell.types.ts +9 -0
  21. package/src/interop/types/detect-package-manager.ts +45 -0
  22. package/src/interop/types/package-manager.ts +7 -0
  23. package/src/interop/yarn/yarn.ts +28 -0
  24. package/{types/src/interop/yarn/yarn.types.d.ts → src/interop/yarn/yarn.types.ts} +1 -1
  25. package/src/io/find-directories.ts +13 -0
  26. package/src/io/find-file.ts +37 -0
  27. package/src/io/watch.ts +290 -0
  28. package/tests/cli/command.model.spec.ts +91 -0
  29. package/tests/diagnostics/trace.spec.ts +319 -0
  30. package/tests/interop/detect-package-manager.spec.ts +70 -0
  31. package/tests/interop/npm.spec.ts +129 -0
  32. package/tests/interop/pnpm.spec.ts +30 -0
  33. package/tests/interop/shell.spec.ts +47 -0
  34. package/tests/interop/yarn.spec.ts +18 -0
  35. package/tests/io/find-file.spec.ts +188 -0
  36. package/tests/io/watch.real-fs.spec.ts +76 -0
  37. package/tests/io/watch.spec.ts +291 -0
  38. package/LICENSE +0 -15
  39. package/commonjs/src/cli/action.js +0 -65
  40. package/commonjs/src/cli/command.js +0 -127
  41. package/commonjs/src/cli/index.js +0 -61
  42. package/commonjs/src/cli/module.type.js +0 -18
  43. package/commonjs/src/cli/options.js +0 -115
  44. package/commonjs/src/diagnostics/index.js +0 -22
  45. package/commonjs/src/diagnostics/trace.js +0 -79
  46. package/commonjs/src/interop/docker/docker.js +0 -54
  47. package/commonjs/src/interop/errors/interop.error.js +0 -12
  48. package/commonjs/src/interop/git/git.js +0 -134
  49. package/commonjs/src/interop/git/git.types.js +0 -4
  50. package/commonjs/src/interop/index.js +0 -97
  51. package/commonjs/src/interop/node/index.js +0 -18
  52. package/commonjs/src/interop/node/node.js +0 -45
  53. package/commonjs/src/interop/npm/npm.js +0 -231
  54. package/commonjs/src/interop/npm/npm.types.js +0 -16
  55. package/commonjs/src/interop/pnpm/pnpm.js +0 -34
  56. package/commonjs/src/interop/pnpm/pnpm.types.js +0 -4
  57. package/commonjs/src/interop/shell/shell.argv.js +0 -45
  58. package/commonjs/src/interop/shell/shell.js +0 -105
  59. package/commonjs/src/interop/shell/shell.types.js +0 -4
  60. package/commonjs/src/interop/types/detect-package-manager.js +0 -71
  61. package/commonjs/src/interop/types/package-manager.js +0 -4
  62. package/commonjs/src/interop/yarn/yarn.js +0 -34
  63. package/commonjs/src/interop/yarn/yarn.types.js +0 -4
  64. package/commonjs/src/io/find-directories.js +0 -21
  65. package/commonjs/src/io/find-file.js +0 -45
  66. package/commonjs/src/io/index.js +0 -20
  67. package/commonjs/src/io/watch.js +0 -261
  68. package/es6/src/cli/action.js +0 -47
  69. package/es6/src/cli/command.js +0 -106
  70. package/es6/src/cli/index.js +0 -4
  71. package/es6/src/cli/module.type.js +0 -11
  72. package/es6/src/cli/options.js +0 -76
  73. package/es6/src/diagnostics/trace.js +0 -61
  74. package/es6/src/interop/docker/docker.js +0 -44
  75. package/es6/src/interop/errors/interop.error.js +0 -2
  76. package/es6/src/interop/git/git.js +0 -124
  77. package/es6/src/interop/git/git.types.js +0 -1
  78. package/es6/src/interop/node/node.js +0 -35
  79. package/es6/src/interop/npm/npm.js +0 -180
  80. package/es6/src/interop/npm/npm.types.js +0 -6
  81. package/es6/src/interop/pnpm/pnpm.js +0 -24
  82. package/es6/src/interop/pnpm/pnpm.types.js +0 -1
  83. package/es6/src/interop/shell/shell.argv.js +0 -35
  84. package/es6/src/interop/shell/shell.js +0 -95
  85. package/es6/src/interop/shell/shell.types.js +0 -1
  86. package/es6/src/interop/types/detect-package-manager.js +0 -50
  87. package/es6/src/interop/types/package-manager.js +0 -1
  88. package/es6/src/interop/yarn/yarn.js +0 -24
  89. package/es6/src/interop/yarn/yarn.types.js +0 -1
  90. package/es6/src/io/find-directories.js +0 -11
  91. package/es6/src/io/find-file.js +0 -27
  92. package/es6/src/io/watch.js +0 -238
  93. package/types/src/cli/action.d.ts +0 -36
  94. package/types/src/cli/command.d.ts +0 -34
  95. package/types/src/cli/index.d.ts +0 -7
  96. package/types/src/cli/module.type.d.ts +0 -36
  97. package/types/src/cli/options.d.ts +0 -28
  98. package/types/src/diagnostics/index.d.ts +0 -1
  99. package/types/src/diagnostics/trace.d.ts +0 -10
  100. package/types/src/interop/docker/docker.d.ts +0 -19
  101. package/types/src/interop/errors/interop.error.d.ts +0 -2
  102. package/types/src/interop/git/git.d.ts +0 -16
  103. package/types/src/interop/index.d.ts +0 -14
  104. package/types/src/interop/node/index.d.ts +0 -1
  105. package/types/src/interop/node/node.d.ts +0 -5
  106. package/types/src/interop/npm/npm.d.ts +0 -29
  107. package/types/src/interop/npm/npm.types.d.ts +0 -24
  108. package/types/src/interop/pnpm/pnpm.d.ts +0 -9
  109. package/types/src/interop/shell/shell.argv.d.ts +0 -5
  110. package/types/src/interop/shell/shell.d.ts +0 -6
  111. package/types/src/interop/shell/shell.types.d.ts +0 -9
  112. package/types/src/interop/types/detect-package-manager.d.ts +0 -4
  113. package/types/src/interop/types/package-manager.d.ts +0 -10
  114. package/types/src/interop/yarn/yarn.d.ts +0 -9
  115. package/types/src/io/find-directories.d.ts +0 -2
  116. package/types/src/io/find-file.d.ts +0 -9
  117. package/types/src/io/index.d.ts +0 -3
  118. package/types/src/io/watch.d.ts +0 -16
  119. /package/{es6/src/diagnostics/index.js → src/diagnostics/index.ts} +0 -0
  120. /package/{es6/src/interop/node/index.js → src/interop/node/index.ts} +0 -0
  121. /package/{es6/src/io/index.js → src/io/index.ts} +0 -0
@@ -0,0 +1,70 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { Npm } from "../../src/interop/npm/npm.js";
3
+ import { Pnpm } from "../../src/interop/pnpm/pnpm.js";
4
+ import {
5
+ detectPackageManager,
6
+ packageManagerFromString,
7
+ } from "../../src/interop/types/detect-package-manager.js";
8
+ import { Yarn } from "../../src/interop/yarn/yarn.js";
9
+
10
+ vi.mock("node:fs", () => ({ existsSync: vi.fn().mockReturnValue(false) }));
11
+
12
+ const { existsSync } = await import("node:fs");
13
+
14
+ describe("detectPackageManager", () => {
15
+ beforeEach(() => {
16
+ delete process.env.npm_config_user_agent;
17
+ });
18
+
19
+ afterEach(() => {
20
+ vi.mocked(existsSync).mockReturnValue(false);
21
+ });
22
+
23
+ it("returns Pnpm when pnpm-lock.yaml exists", () => {
24
+ vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith("pnpm-lock.yaml"));
25
+ expect(detectPackageManager("/project")).toBeInstanceOf(Pnpm);
26
+ });
27
+
28
+ it("returns Yarn when yarn.lock exists", () => {
29
+ vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith("yarn.lock"));
30
+ expect(detectPackageManager("/project")).toBeInstanceOf(Yarn);
31
+ });
32
+
33
+ it("returns Npm when package-lock.json exists", () => {
34
+ vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith("package-lock.json"));
35
+ expect(detectPackageManager("/project")).toBeInstanceOf(Npm);
36
+ });
37
+
38
+ it("prefers lockfile over user agent", () => {
39
+ vi.mocked(existsSync).mockImplementation((p) => String(p).endsWith("yarn.lock"));
40
+ process.env.npm_config_user_agent = "pnpm/9.0.0";
41
+ expect(detectPackageManager("/project")).toBeInstanceOf(Yarn);
42
+ });
43
+
44
+ it("falls back to user agent when no lockfile", () => {
45
+ process.env.npm_config_user_agent = "pnpm/9.0.0 node/v22.0.0";
46
+ expect(detectPackageManager("/project")).toBeInstanceOf(Pnpm);
47
+ });
48
+
49
+ it("returns undefined when no lockfile and no user agent", () => {
50
+ expect(detectPackageManager("/project")).toBeUndefined();
51
+ });
52
+ });
53
+
54
+ describe("packageManagerFromString", () => {
55
+ it("returns Yarn for 'yarn'", () => {
56
+ expect(packageManagerFromString("yarn")).toBeInstanceOf(Yarn);
57
+ });
58
+
59
+ it("returns Npm for 'npm'", () => {
60
+ expect(packageManagerFromString("npm")).toBeInstanceOf(Npm);
61
+ });
62
+
63
+ it("returns Pnpm for 'pnpm'", () => {
64
+ expect(packageManagerFromString("pnpm")).toBeInstanceOf(Pnpm);
65
+ });
66
+
67
+ it("defaults to Pnpm for unknown string", () => {
68
+ expect(packageManagerFromString("bun")).toBeInstanceOf(Pnpm);
69
+ });
70
+ });
@@ -0,0 +1,129 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { vol } from "memfs";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { InteropError } from "../../src/interop/errors/interop.error.js";
5
+
6
+ const run = vi.hoisted(() => vi.fn());
7
+
8
+ vi.mock("../../src/interop/shell/shell.js", () => ({
9
+ Shell: class {
10
+ public run = run;
11
+ },
12
+ }));
13
+
14
+ import { Npm } from "../../src/interop/npm/npm.js";
15
+
16
+ function seedPublishedPackage() {
17
+ vol.fromJSON({
18
+ "/pkg/package.json": JSON.stringify({
19
+ name: "@decohere/cli",
20
+ version: "0.0.1-dev.79",
21
+ }),
22
+ });
23
+ mkdirSync("/tmp", { recursive: true });
24
+ }
25
+
26
+ describe("Npm", () => {
27
+ const originalToken = process.env.NODE_AUTH_TOKEN;
28
+
29
+ afterEach(() => {
30
+ run.mockReset();
31
+ vol.reset();
32
+ if (originalToken == null) {
33
+ delete process.env.NODE_AUTH_TOKEN;
34
+ } else {
35
+ process.env.NODE_AUTH_TOKEN = originalToken;
36
+ }
37
+ });
38
+
39
+ it("calls install with no extra flags", async () => {
40
+ await new Npm().install({ cwd: "/workspace" });
41
+ expect(run).toHaveBeenCalledWith({ cwd: "/workspace" }, "install");
42
+ });
43
+
44
+ it("installs production dependencies as real folders, with no bin symlinks", async () => {
45
+ await new Npm().installProduction({ cwd: "/out" });
46
+ expect(run).toHaveBeenCalledWith(
47
+ { cwd: "/out" },
48
+ "install",
49
+ "--omit=dev",
50
+ "--no-bin-links",
51
+ "--no-audit",
52
+ "--no-fund",
53
+ "--loglevel=error",
54
+ );
55
+ });
56
+
57
+ it("publish dry-run still packs via npm publish --dry-run", async () => {
58
+ await new Npm().publish("dist/release/pkg", {
59
+ channel: "dev",
60
+ dryRun: true,
61
+ skipIfPublished: true,
62
+ });
63
+ expect(run).toHaveBeenCalledWith(
64
+ expect.objectContaining({ cwd: undefined, env: expect.any(Object) }),
65
+ "publish",
66
+ "dist/release/pkg",
67
+ "--tag",
68
+ "dev",
69
+ "--dry-run",
70
+ );
71
+ const env = run.mock.calls[0][0].env as NodeJS.ProcessEnv;
72
+ expect(env.NODE_AUTH_TOKEN).toBeUndefined();
73
+ expect(env.NPM_CONFIG_USERCONFIG).toBe("/dev/null");
74
+ });
75
+
76
+ it("skipIfPublished uses ambient npm view (no token on argv)", async () => {
77
+ seedPublishedPackage();
78
+ process.env.NODE_AUTH_TOKEN = "read-token";
79
+ run.mockResolvedValueOnce({ message: "view", data: ["0.0.1-dev.79"] });
80
+ const result = await new Npm().publish("/pkg", { channel: "dev", skipIfPublished: true });
81
+ expect(result.message).toMatch(/Skipping publish/);
82
+ expect(run).toHaveBeenCalledTimes(1);
83
+ expect(run.mock.calls[0][1]).toBe("view");
84
+ expect(run.mock.calls[0][2]).toBe("@decohere/cli@0.0.1-dev.79");
85
+ expect(run.mock.calls[0]).not.toContain("--userconfig");
86
+ });
87
+
88
+ it("treats E403 already-published as skip and does not forward NODE_AUTH_TOKEN to npm publish", async () => {
89
+ seedPublishedPackage();
90
+ process.env.NODE_AUTH_TOKEN = "read-token";
91
+ run.mockRejectedValueOnce(new InteropError("404 Not Found"));
92
+ run.mockRejectedValueOnce(
93
+ new InteropError(
94
+ "npm error 403 Forbidden - You cannot publish over the previously published versions: 0.0.1-dev.79",
95
+ ),
96
+ );
97
+ const result = await new Npm().publish("/pkg", { channel: "dev", skipIfPublished: true });
98
+ expect(result.message).toMatch(/Skipping publish/);
99
+ expect(run).toHaveBeenCalledTimes(2);
100
+ const publishCall = run.mock.calls[1];
101
+ expect(publishCall[1]).toBe("publish");
102
+ expect(publishCall[0].env.NODE_AUTH_TOKEN).toBeUndefined();
103
+ expect(publishCall[0].env.NPM_TOKEN).toBeUndefined();
104
+ expect(publishCall[0].env.NPM_CONFIG_USERCONFIG).toBe("/dev/null");
105
+ });
106
+
107
+ it("publish passes otp via env, not argv", async () => {
108
+ await new Npm().publish("dist/release/pkg", {
109
+ channel: "beta",
110
+ dryRun: true,
111
+ otp: "123456",
112
+ });
113
+ expect(run).toHaveBeenCalledWith(
114
+ expect.objectContaining({ cwd: undefined, env: expect.any(Object) }),
115
+ "publish",
116
+ "dist/release/pkg",
117
+ "--tag",
118
+ "beta",
119
+ "--dry-run",
120
+ );
121
+ const env = run.mock.calls[0][0].env as NodeJS.ProcessEnv;
122
+ expect(env.NPM_CONFIG_OTP).toBe("123456");
123
+ expect(env.NODE_AUTH_TOKEN).toBeUndefined();
124
+ expect(env.NPM_CONFIG_USERCONFIG).toBe("/dev/null");
125
+ const argv = run.mock.calls[0]?.slice(1) ?? [];
126
+ expect(argv).not.toContain("--otp");
127
+ expect(argv).not.toContain("123456");
128
+ });
129
+ });
@@ -0,0 +1,30 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const run = vi.hoisted(() => vi.fn());
4
+
5
+ vi.mock("../../src/interop/shell/shell.js", () => ({
6
+ Shell: class {
7
+ public run = run;
8
+ },
9
+ }));
10
+
11
+ import { Pnpm } from "../../src/interop/pnpm/pnpm.js";
12
+
13
+ describe("Pnpm", () => {
14
+ beforeEach(() => run.mockClear());
15
+
16
+ it("uses lockfile-only install flags when syncing lockfile", async () => {
17
+ await new Pnpm().syncLockFile({ cwd: "/workspace" });
18
+ expect(run).toHaveBeenCalledWith(
19
+ { cwd: "/workspace" },
20
+ "install",
21
+ "--lockfile-only",
22
+ "--ignore-scripts",
23
+ );
24
+ });
25
+
26
+ it("calls install with no extra flags", async () => {
27
+ await new Pnpm().install({ cwd: "/workspace" });
28
+ expect(run).toHaveBeenCalledWith({ cwd: "/workspace" }, "install");
29
+ });
30
+ });
@@ -0,0 +1,47 @@
1
+ import { spawn } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { Shell } from "../../src/interop/shell/shell.js";
5
+
6
+ vi.mock("node:child_process", () => ({
7
+ spawn: vi.fn(),
8
+ }));
9
+
10
+ class TestShell extends Shell {
11
+ protected program = "missing-program";
12
+
13
+ public runCommand() {
14
+ return this.run({}, "arg");
15
+ }
16
+ }
17
+
18
+ describe("Shell", () => {
19
+ it("rejects when spawn emits an error", async () => {
20
+ const childProcess = new EventEmitter() as EventEmitter & {
21
+ stdout: EventEmitter;
22
+ };
23
+ childProcess.stdout = new EventEmitter();
24
+
25
+ vi.mocked(spawn).mockReturnValue(childProcess as never);
26
+
27
+ const shell = new TestShell();
28
+ const runPromise = shell.runCommand();
29
+
30
+ let settled = false;
31
+ let rejected = false;
32
+ runPromise
33
+ .then(() => {
34
+ settled = true;
35
+ })
36
+ .catch(() => {
37
+ settled = true;
38
+ rejected = true;
39
+ });
40
+
41
+ childProcess.emit("error", new Error("spawn ENOENT"));
42
+ await new Promise((resolve) => setTimeout(resolve, 0));
43
+
44
+ expect(settled).toBe(true);
45
+ expect(rejected).toBe(true);
46
+ });
47
+ });
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ const run = vi.hoisted(() => vi.fn());
4
+
5
+ vi.mock("../../src/interop/shell/shell.js", () => ({
6
+ Shell: class {
7
+ public run = run;
8
+ },
9
+ }));
10
+
11
+ import { Yarn } from "../../src/interop/yarn/yarn.js";
12
+
13
+ describe("Yarn", () => {
14
+ it("calls install with no extra flags", async () => {
15
+ await new Yarn().install({ cwd: "/workspace" });
16
+ expect(run).toHaveBeenCalledWith({ cwd: "/workspace" }, "install");
17
+ });
18
+ });
@@ -0,0 +1,188 @@
1
+ import * as glob from "glob";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { findFile, findFiles } from "../../src/io/find-file.js";
4
+
5
+ vi.mock("glob");
6
+
7
+ describe("findFile", () => {
8
+ beforeEach(() => {
9
+ vi.clearAllMocks();
10
+ });
11
+
12
+ it("should fail fast when glob is not explicitly overridden", async () => {
13
+ await expect(findFile("**/*.ts")).rejects.toThrow(
14
+ 'Mocked "glob" was called without an explicit test override.',
15
+ );
16
+ });
17
+
18
+ it("should return file with relativePath when pattern matches", async () => {
19
+ vi.spyOn(glob, "glob").mockResolvedValue(["src/index.ts"]);
20
+
21
+ const result = await findFile("**/*.ts");
22
+
23
+ expect(result).toEqual({
24
+ relativePath: "src/index.ts",
25
+ });
26
+ });
27
+
28
+ it("should return undefined when no files match", async () => {
29
+ vi.spyOn(glob, "glob").mockResolvedValue([]);
30
+
31
+ const result = await findFile("**/*.ts");
32
+
33
+ expect(result).toBeUndefined();
34
+ });
35
+
36
+ it("should return only the first match", async () => {
37
+ vi.spyOn(glob, "glob").mockResolvedValue(["src/index.ts", "src/utils.ts"]);
38
+
39
+ const result = await findFile("**/*.ts");
40
+
41
+ expect(result).toEqual({
42
+ relativePath: "src/index.ts",
43
+ });
44
+ });
45
+ });
46
+
47
+ describe("findFiles", () => {
48
+ beforeEach(() => {
49
+ vi.clearAllMocks();
50
+ });
51
+
52
+ it("should transform glob Path objects to File objects with relativePath and fullPath", async () => {
53
+ // Mock glob Path objects
54
+ const mockGlobPaths = [
55
+ {
56
+ isFile: () => true,
57
+ relative: () => "src/index.ts",
58
+ fullpath: () => "/test/project/src/index.ts",
59
+ },
60
+ {
61
+ isFile: () => true,
62
+ relative: () => "src/utils.ts",
63
+ fullpath: () => "/test/project/src/utils.ts",
64
+ },
65
+ ];
66
+
67
+ vi.spyOn(glob, "glob").mockResolvedValue(mockGlobPaths as unknown as string[]);
68
+
69
+ const result = await findFiles(["**/*.ts"], {
70
+ cwd: "/test/project",
71
+ ignore: [],
72
+ });
73
+
74
+ expect(result).toHaveLength(2);
75
+ expect(result[0]).toEqual({
76
+ relativePath: "src/index.ts",
77
+ fullPath: "/test/project/src/index.ts",
78
+ });
79
+ expect(result[1]).toEqual({
80
+ relativePath: "src/utils.ts",
81
+ fullPath: "/test/project/src/utils.ts",
82
+ });
83
+ });
84
+
85
+ it("should use p.relative() for relativePath, not p.name", async () => {
86
+ // This test verifies the bug fix where p.name was incorrectly used
87
+ const mockGlobPaths = [
88
+ {
89
+ isFile: () => true,
90
+ name: "index.ts", // This would be wrong to use
91
+ relative: () => "src/components/index.ts", // This is correct
92
+ fullpath: () => "/test/project/src/components/index.ts",
93
+ },
94
+ ];
95
+
96
+ vi.spyOn(glob, "glob").mockResolvedValue(mockGlobPaths as unknown as string[]);
97
+
98
+ const result = await findFiles(["**/*.ts"], {
99
+ cwd: "/test/project",
100
+ ignore: [],
101
+ });
102
+
103
+ // Assert that we get the full relative path, not just the filename
104
+ expect(result[0].relativePath).toBe("src/components/index.ts");
105
+ expect(result[0].relativePath).not.toBe("index.ts");
106
+ });
107
+
108
+ it("should filter out directories and only return files", async () => {
109
+ const mockGlobPaths = [
110
+ {
111
+ isFile: () => true,
112
+ relative: () => "src/index.ts",
113
+ fullpath: () => "/test/project/src/index.ts",
114
+ },
115
+ {
116
+ isFile: () => false, // Directory
117
+ relative: () => "src/components",
118
+ fullpath: () => "/test/project/src/components",
119
+ },
120
+ {
121
+ isFile: () => true,
122
+ relative: () => "src/utils.ts",
123
+ fullpath: () => "/test/project/src/utils.ts",
124
+ },
125
+ ];
126
+
127
+ vi.spyOn(glob, "glob").mockResolvedValue(mockGlobPaths as unknown as string[]);
128
+
129
+ const result = await findFiles(["**/*.ts"], {
130
+ cwd: "/test/project",
131
+ ignore: [],
132
+ });
133
+
134
+ expect(result).toHaveLength(2);
135
+ expect(result.map((f) => f.relativePath)).toEqual(["src/index.ts", "src/utils.ts"]);
136
+ });
137
+
138
+ it("should handle empty results", async () => {
139
+ vi.spyOn(glob, "glob").mockResolvedValue([]);
140
+
141
+ const result = await findFiles(["**/*.ts"], {
142
+ cwd: "/test/project",
143
+ ignore: [],
144
+ });
145
+
146
+ expect(result).toEqual([]);
147
+ });
148
+
149
+ it("should pass options to glob correctly", async () => {
150
+ const globSpy = vi.spyOn(glob, "glob").mockResolvedValue([]);
151
+
152
+ await findFiles(["**/*.ts", "**/*.js"], {
153
+ cwd: "/test/project",
154
+ ignore: ["**/*.spec.ts"],
155
+ });
156
+
157
+ expect(globSpy).toHaveBeenCalledWith(
158
+ ["**/*.ts", "**/*.js"],
159
+ expect.objectContaining({
160
+ cwd: "/test/project",
161
+ ignore: ["**/*.spec.ts"],
162
+ withFileTypes: true,
163
+ }),
164
+ );
165
+ });
166
+
167
+ it("should work with nested directory structures", async () => {
168
+ const mockGlobPaths = [
169
+ {
170
+ isFile: () => true,
171
+ relative: () => "deep/nested/path/to/file.ts",
172
+ fullpath: () => "/test/project/deep/nested/path/to/file.ts",
173
+ },
174
+ ];
175
+
176
+ vi.spyOn(glob, "glob").mockResolvedValue(mockGlobPaths as unknown as string[]);
177
+
178
+ const result = await findFiles(["**/*.ts"], {
179
+ cwd: "/test/project",
180
+ ignore: [],
181
+ });
182
+
183
+ expect(result[0]).toEqual({
184
+ relativePath: "deep/nested/path/to/file.ts",
185
+ fullPath: "/test/project/deep/nested/path/to/file.ts",
186
+ });
187
+ });
188
+ });
@@ -0,0 +1,76 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // Atomic saves are a real-filesystem behaviour, so this suite opts out of the memfs mock.
4
+ vi.unmock("fs");
5
+ vi.unmock("node:fs");
6
+ vi.unmock("fs/promises");
7
+ vi.unmock("node:fs/promises");
8
+
9
+ const { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } = await import("node:fs");
10
+ const { tmpdir } = await import("node:os");
11
+ const { join } = await import("node:path");
12
+ const { watch } = await import("../../src/io/watch.js");
13
+
14
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
15
+
16
+ /** Wait until `predicate` holds, polling, or fail after `timeoutMs`. */
17
+ async function eventually(predicate: () => boolean, timeoutMs = 2000) {
18
+ const start = Date.now();
19
+ while (!predicate()) {
20
+ if (Date.now() - start > timeoutMs) throw new Error("timed out waiting for watch events");
21
+ await sleep(20);
22
+ }
23
+ }
24
+
25
+ // #505: many editors (vim, sed -i, "safe write") and AI agents save by writing a temp file
26
+ // and renaming it over the original. With Node's recursive fs.watch on Linux the watcher then
27
+ // went silent for that file, so hot reload stopped reacting to it.
28
+ describe("io.watch on a real filesystem", () => {
29
+ let dir: string | undefined;
30
+ let close: (() => void) | undefined;
31
+
32
+ afterEach(() => {
33
+ close?.();
34
+ if (dir) rmSync(dir, { recursive: true, force: true });
35
+ });
36
+
37
+ it("keeps reporting changes to a file after it is replaced by an atomic save", async () => {
38
+ dir = mkdtempSync(join(tmpdir(), "decohere-watch-"));
39
+ mkdirSync(join(dir, "src"));
40
+ const file = join(dir, "src", "a.ts");
41
+ writeFileSync(file, "v0");
42
+
43
+ const changes: string[] = [];
44
+ const watcher = watch(dir, { ignoreInitial: true });
45
+ close = watcher.close;
46
+ watcher.on("change", (f) => changes.push(f.relativePath));
47
+ await watcher.ready;
48
+
49
+ for (let i = 1; i <= 3; i++) {
50
+ const tmp = join(dir, "src", `.a.ts.tmp${i}`);
51
+ writeFileSync(tmp, `v${i}`);
52
+ renameSync(tmp, file);
53
+ await eventually(() => changes.filter((p) => p === "src/a.ts").length >= i);
54
+ }
55
+
56
+ writeFileSync(file, "a plain save after the atomic ones");
57
+ await eventually(() => changes.filter((p) => p === "src/a.ts").length >= 4);
58
+ expect(changes.filter((p) => p === "src/a.ts").length).toBeGreaterThanOrEqual(4);
59
+ });
60
+
61
+ it("reports files in a directory created after the watch started", async () => {
62
+ dir = mkdtempSync(join(tmpdir(), "decohere-watch-"));
63
+ const added: string[] = [];
64
+ const watcher = watch(dir, { ignoreInitial: true });
65
+ close = watcher.close;
66
+ watcher.on("add", (f) => added.push(f.relativePath));
67
+ await watcher.ready;
68
+
69
+ mkdirSync(join(dir, "feature"));
70
+ await sleep(50);
71
+ writeFileSync(join(dir, "feature", "new.ts"), "x");
72
+
73
+ await eventually(() => added.includes("feature/new.ts"));
74
+ expect(added).toContain("feature/new.ts");
75
+ });
76
+ });