@aku11i/phantom 0.1.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.
@@ -0,0 +1,256 @@
1
+ import { strictEqual } from "node:assert";
2
+ import { before, describe, it, mock } from "node:test";
3
+
4
+ describe("execInGarden", () => {
5
+ let spawnMock: ReturnType<typeof mock.fn>;
6
+ let whereGardenMock: ReturnType<typeof mock.fn>;
7
+ let execInGarden: typeof import("./exec.ts").execInGarden;
8
+
9
+ before(async () => {
10
+ spawnMock = mock.fn();
11
+ whereGardenMock = mock.fn();
12
+
13
+ mock.module("node:child_process", {
14
+ namedExports: {
15
+ spawn: spawnMock,
16
+ },
17
+ });
18
+
19
+ mock.module("../gardens/commands/where.ts", {
20
+ namedExports: {
21
+ whereGarden: whereGardenMock,
22
+ },
23
+ });
24
+
25
+ ({ execInGarden } = await import("./exec.ts"));
26
+ });
27
+
28
+ it("should return error when garden name is not provided", async () => {
29
+ const result = await execInGarden("", ["echo", "test"]);
30
+ strictEqual(result.success, false);
31
+ strictEqual(result.message, "Error: garden name required");
32
+ });
33
+
34
+ it("should return error when command is not provided", async () => {
35
+ const result = await execInGarden("test-garden", []);
36
+ strictEqual(result.success, false);
37
+ strictEqual(result.message, "Error: command required");
38
+ });
39
+
40
+ it("should return error when garden does not exist", async () => {
41
+ whereGardenMock.mock.resetCalls();
42
+ spawnMock.mock.resetCalls();
43
+
44
+ whereGardenMock.mock.mockImplementation(() =>
45
+ Promise.resolve({
46
+ success: false,
47
+ message: "Error: Garden 'nonexistent' does not exist",
48
+ }),
49
+ );
50
+
51
+ const result = await execInGarden("nonexistent", ["echo", "test"]);
52
+
53
+ strictEqual(result.success, false);
54
+ strictEqual(result.message, "Error: Garden 'nonexistent' does not exist");
55
+ });
56
+
57
+ it("should execute command successfully with exit code 0", async () => {
58
+ whereGardenMock.mock.resetCalls();
59
+ spawnMock.mock.resetCalls();
60
+
61
+ // Mock successful garden location
62
+ whereGardenMock.mock.mockImplementation(() =>
63
+ Promise.resolve({
64
+ success: true,
65
+ path: "/test/repo/.git/phantom/gardens/test-garden",
66
+ }),
67
+ );
68
+
69
+ // Mock successful command execution
70
+ const mockChildProcess = {
71
+ on: mock.fn(
72
+ (
73
+ event: string,
74
+ callback: (code: number | null, signal: string | null) => void,
75
+ ) => {
76
+ if (event === "exit") {
77
+ // Simulate successful command (exit code 0)
78
+ setTimeout(() => callback(0, null), 0);
79
+ }
80
+ },
81
+ ),
82
+ };
83
+
84
+ spawnMock.mock.mockImplementation(() => mockChildProcess);
85
+
86
+ const result = await execInGarden("test-garden", ["echo", "hello"]);
87
+
88
+ strictEqual(result.success, true);
89
+ strictEqual(result.exitCode, 0);
90
+
91
+ // Verify spawn was called with correct arguments
92
+ strictEqual(spawnMock.mock.calls.length, 1);
93
+ const [cmd, args, options] = spawnMock.mock.calls[0].arguments as [
94
+ string,
95
+ string[],
96
+ { cwd: string; stdio: string },
97
+ ];
98
+ strictEqual(cmd, "echo");
99
+ strictEqual(args[0], "hello");
100
+ strictEqual(options.cwd, "/test/repo/.git/phantom/gardens/test-garden");
101
+ strictEqual(options.stdio, "inherit");
102
+ });
103
+
104
+ it("should handle command execution failure with non-zero exit code", async () => {
105
+ whereGardenMock.mock.resetCalls();
106
+ spawnMock.mock.resetCalls();
107
+
108
+ // Mock successful garden location
109
+ whereGardenMock.mock.mockImplementation(() =>
110
+ Promise.resolve({
111
+ success: true,
112
+ path: "/test/repo/.git/phantom/gardens/test-garden",
113
+ }),
114
+ );
115
+
116
+ // Mock failed command execution
117
+ const mockChildProcess = {
118
+ on: mock.fn(
119
+ (
120
+ event: string,
121
+ callback: (code: number | null, signal: string | null) => void,
122
+ ) => {
123
+ if (event === "exit") {
124
+ // Simulate failed command (exit code 1)
125
+ setTimeout(() => callback(1, null), 0);
126
+ }
127
+ },
128
+ ),
129
+ };
130
+
131
+ spawnMock.mock.mockImplementation(() => mockChildProcess);
132
+
133
+ const result = await execInGarden("test-garden", ["false"]);
134
+
135
+ strictEqual(result.success, false);
136
+ strictEqual(result.exitCode, 1);
137
+ });
138
+
139
+ it("should handle command execution error", async () => {
140
+ whereGardenMock.mock.resetCalls();
141
+ spawnMock.mock.resetCalls();
142
+
143
+ // Mock successful garden location
144
+ whereGardenMock.mock.mockImplementation(() =>
145
+ Promise.resolve({
146
+ success: true,
147
+ path: "/test/repo/.git/phantom/gardens/test-garden",
148
+ }),
149
+ );
150
+
151
+ // Mock command execution error
152
+ const mockChildProcess = {
153
+ on: mock.fn((event: string, callback: (error: Error) => void) => {
154
+ if (event === "error") {
155
+ setTimeout(() => callback(new Error("Command not found")), 0);
156
+ }
157
+ }),
158
+ };
159
+
160
+ spawnMock.mock.mockImplementation(() => mockChildProcess);
161
+
162
+ const result = await execInGarden("test-garden", ["nonexistent-command"]);
163
+
164
+ strictEqual(result.success, false);
165
+ strictEqual(result.message, "Error executing command: Command not found");
166
+ });
167
+
168
+ it("should handle signal termination", async () => {
169
+ whereGardenMock.mock.resetCalls();
170
+ spawnMock.mock.resetCalls();
171
+
172
+ // Mock successful garden location
173
+ whereGardenMock.mock.mockImplementation(() =>
174
+ Promise.resolve({
175
+ success: true,
176
+ path: "/test/repo/.git/phantom/gardens/test-garden",
177
+ }),
178
+ );
179
+
180
+ // Mock signal termination
181
+ const mockChildProcess = {
182
+ on: mock.fn(
183
+ (
184
+ event: string,
185
+ callback: (code: number | null, signal: string | null) => void,
186
+ ) => {
187
+ if (event === "exit") {
188
+ // Simulate signal termination
189
+ setTimeout(() => callback(null, "SIGTERM"), 0);
190
+ }
191
+ },
192
+ ),
193
+ };
194
+
195
+ spawnMock.mock.mockImplementation(() => mockChildProcess);
196
+
197
+ const result = await execInGarden("test-garden", ["long-running-command"]);
198
+
199
+ strictEqual(result.success, false);
200
+ strictEqual(result.message, "Command terminated by signal: SIGTERM");
201
+ strictEqual(result.exitCode, 143); // 128 + 15 (SIGTERM)
202
+ });
203
+
204
+ it("should parse complex commands with multiple arguments", async () => {
205
+ whereGardenMock.mock.resetCalls();
206
+ spawnMock.mock.resetCalls();
207
+
208
+ // Mock successful garden location
209
+ whereGardenMock.mock.mockImplementation(() =>
210
+ Promise.resolve({
211
+ success: true,
212
+ path: "/test/repo/.git/phantom/gardens/test-garden",
213
+ }),
214
+ );
215
+
216
+ // Mock successful command execution
217
+ const mockChildProcess = {
218
+ on: mock.fn(
219
+ (
220
+ event: string,
221
+ callback: (code: number | null, signal: string | null) => void,
222
+ ) => {
223
+ if (event === "exit") {
224
+ setTimeout(() => callback(0, null), 0);
225
+ }
226
+ },
227
+ ),
228
+ };
229
+
230
+ spawnMock.mock.mockImplementation(() => mockChildProcess);
231
+
232
+ const result = await execInGarden("test-garden", [
233
+ "npm",
234
+ "run",
235
+ "test",
236
+ "--",
237
+ "--verbose",
238
+ ]);
239
+
240
+ strictEqual(result.success, true);
241
+ strictEqual(result.exitCode, 0);
242
+
243
+ // Verify spawn was called with correct arguments
244
+ const [cmd, args] = spawnMock.mock.calls[0].arguments as [
245
+ string,
246
+ string[],
247
+ object,
248
+ ];
249
+ strictEqual(cmd, "npm");
250
+ strictEqual(args.length, 4);
251
+ strictEqual(args[0], "run");
252
+ strictEqual(args[1], "test");
253
+ strictEqual(args[2], "--");
254
+ strictEqual(args[3], "--verbose");
255
+ });
256
+ });
@@ -0,0 +1,81 @@
1
+ import { spawn } from "node:child_process";
2
+ import { exit } from "node:process";
3
+ import { whereGarden } from "../gardens/commands/where.ts";
4
+
5
+ export async function execInGarden(
6
+ gardenName: string,
7
+ command: string[],
8
+ ): Promise<{
9
+ success: boolean;
10
+ message?: string;
11
+ exitCode?: number;
12
+ }> {
13
+ if (!gardenName) {
14
+ return { success: false, message: "Error: garden name required" };
15
+ }
16
+
17
+ if (!command || command.length === 0) {
18
+ return { success: false, message: "Error: command required" };
19
+ }
20
+
21
+ // Validate garden exists and get its path
22
+ const gardenResult = await whereGarden(gardenName);
23
+ if (!gardenResult.success) {
24
+ return { success: false, message: gardenResult.message };
25
+ }
26
+
27
+ const gardenPath = gardenResult.path as string;
28
+ const [cmd, ...args] = command;
29
+
30
+ return new Promise((resolve) => {
31
+ const childProcess = spawn(cmd, args, {
32
+ cwd: gardenPath,
33
+ stdio: "inherit",
34
+ });
35
+
36
+ childProcess.on("error", (error) => {
37
+ resolve({
38
+ success: false,
39
+ message: `Error executing command: ${error.message}`,
40
+ });
41
+ });
42
+
43
+ childProcess.on("exit", (code, signal) => {
44
+ if (signal) {
45
+ resolve({
46
+ success: false,
47
+ message: `Command terminated by signal: ${signal}`,
48
+ exitCode: 128 + (signal === "SIGTERM" ? 15 : 1),
49
+ });
50
+ } else {
51
+ const exitCode = code ?? 0;
52
+ resolve({
53
+ success: exitCode === 0,
54
+ exitCode,
55
+ });
56
+ }
57
+ });
58
+ });
59
+ }
60
+
61
+ export async function execHandler(args: string[]): Promise<void> {
62
+ if (args.length < 2) {
63
+ console.error("Usage: phantom exec <garden-name> <command> [args...]");
64
+ exit(1);
65
+ }
66
+
67
+ const gardenName = args[0];
68
+ const command = args.slice(1);
69
+
70
+ const result = await execInGarden(gardenName, command);
71
+
72
+ if (!result.success) {
73
+ if (result.message) {
74
+ console.error(result.message);
75
+ }
76
+ exit(result.exitCode ?? 1);
77
+ }
78
+
79
+ // For successful commands, exit with the same code as the child process
80
+ exit(result.exitCode ?? 0);
81
+ }
@@ -0,0 +1,248 @@
1
+ import { strictEqual } from "node:assert";
2
+ import { before, describe, it, mock } from "node:test";
3
+
4
+ describe("shellInGarden", () => {
5
+ let spawnMock: ReturnType<typeof mock.fn>;
6
+ let whereGardenMock: ReturnType<typeof mock.fn>;
7
+ let shellInGarden: typeof import("./shell.ts").shellInGarden;
8
+ const originalEnv = process.env;
9
+
10
+ before(async () => {
11
+ spawnMock = mock.fn();
12
+ whereGardenMock = mock.fn();
13
+
14
+ mock.module("node:child_process", {
15
+ namedExports: {
16
+ spawn: spawnMock,
17
+ },
18
+ });
19
+
20
+ mock.module("../gardens/commands/where.ts", {
21
+ namedExports: {
22
+ whereGarden: whereGardenMock,
23
+ },
24
+ });
25
+
26
+ ({ shellInGarden } = await import("./shell.ts"));
27
+ });
28
+
29
+ it("should return error when garden name is not provided", async () => {
30
+ const result = await shellInGarden("");
31
+ strictEqual(result.success, false);
32
+ strictEqual(result.message, "Error: garden name required");
33
+ });
34
+
35
+ it("should return error when garden does not exist", async () => {
36
+ whereGardenMock.mock.resetCalls();
37
+ spawnMock.mock.resetCalls();
38
+
39
+ whereGardenMock.mock.mockImplementation(() =>
40
+ Promise.resolve({
41
+ success: false,
42
+ message: "Error: Garden 'nonexistent' does not exist",
43
+ }),
44
+ );
45
+
46
+ const result = await shellInGarden("nonexistent");
47
+
48
+ strictEqual(result.success, false);
49
+ strictEqual(result.message, "Error: Garden 'nonexistent' does not exist");
50
+ });
51
+
52
+ it("should start shell successfully with exit code 0", async () => {
53
+ whereGardenMock.mock.resetCalls();
54
+ spawnMock.mock.resetCalls();
55
+
56
+ // Mock successful garden location
57
+ whereGardenMock.mock.mockImplementation(() =>
58
+ Promise.resolve({
59
+ success: true,
60
+ path: "/test/repo/.git/phantom/gardens/test-garden",
61
+ }),
62
+ );
63
+
64
+ // Mock successful shell session
65
+ const mockChildProcess = {
66
+ on: mock.fn(
67
+ (
68
+ event: string,
69
+ callback: (code: number | null, signal: string | null) => void,
70
+ ) => {
71
+ if (event === "exit") {
72
+ // Simulate successful shell exit
73
+ setTimeout(() => callback(0, null), 0);
74
+ }
75
+ },
76
+ ),
77
+ };
78
+
79
+ spawnMock.mock.mockImplementation(() => mockChildProcess);
80
+
81
+ const result = await shellInGarden("test-garden");
82
+
83
+ strictEqual(result.success, true);
84
+ strictEqual(result.exitCode, 0);
85
+
86
+ // Verify spawn was called with correct arguments
87
+ strictEqual(spawnMock.mock.calls.length, 1);
88
+ const [shell, args, options] = spawnMock.mock.calls[0].arguments as [
89
+ string,
90
+ string[],
91
+ { cwd: string; stdio: string; env: NodeJS.ProcessEnv },
92
+ ];
93
+ strictEqual(shell, process.env.SHELL || "/bin/sh");
94
+ strictEqual(args.length, 0);
95
+ strictEqual(options.cwd, "/test/repo/.git/phantom/gardens/test-garden");
96
+ strictEqual(options.stdio, "inherit");
97
+ strictEqual(options.env.PHANTOM_GARDEN, "test-garden");
98
+ strictEqual(
99
+ options.env.PHANTOM_GARDEN_PATH,
100
+ "/test/repo/.git/phantom/gardens/test-garden",
101
+ );
102
+ });
103
+
104
+ it("should use /bin/sh when SHELL is not set", async () => {
105
+ whereGardenMock.mock.resetCalls();
106
+ spawnMock.mock.resetCalls();
107
+
108
+ // Temporarily remove SHELL env var
109
+ const originalShell = process.env.SHELL;
110
+ // biome-ignore lint/performance/noDelete: Need to actually delete for test
111
+ delete process.env.SHELL;
112
+
113
+ // Mock successful garden location
114
+ whereGardenMock.mock.mockImplementation(() =>
115
+ Promise.resolve({
116
+ success: true,
117
+ path: "/test/repo/.git/phantom/gardens/test-garden",
118
+ }),
119
+ );
120
+
121
+ // Mock successful shell session
122
+ const mockChildProcess = {
123
+ on: mock.fn(
124
+ (
125
+ event: string,
126
+ callback: (code: number | null, signal: string | null) => void,
127
+ ) => {
128
+ if (event === "exit") {
129
+ setTimeout(() => callback(0, null), 0);
130
+ }
131
+ },
132
+ ),
133
+ };
134
+
135
+ spawnMock.mock.mockImplementation(() => mockChildProcess);
136
+
137
+ await shellInGarden("test-garden");
138
+
139
+ // Verify /bin/sh was used
140
+ const [shell] = spawnMock.mock.calls[0].arguments as [string, unknown];
141
+ strictEqual(shell, "/bin/sh");
142
+
143
+ // Restore SHELL env var
144
+ if (originalShell !== undefined) {
145
+ process.env.SHELL = originalShell;
146
+ }
147
+ });
148
+
149
+ it("should handle shell execution failure with non-zero exit code", async () => {
150
+ whereGardenMock.mock.resetCalls();
151
+ spawnMock.mock.resetCalls();
152
+
153
+ // Mock successful garden location
154
+ whereGardenMock.mock.mockImplementation(() =>
155
+ Promise.resolve({
156
+ success: true,
157
+ path: "/test/repo/.git/phantom/gardens/test-garden",
158
+ }),
159
+ );
160
+
161
+ // Mock failed shell session
162
+ const mockChildProcess = {
163
+ on: mock.fn(
164
+ (
165
+ event: string,
166
+ callback: (code: number | null, signal: string | null) => void,
167
+ ) => {
168
+ if (event === "exit") {
169
+ // Simulate failed shell exit
170
+ setTimeout(() => callback(1, null), 0);
171
+ }
172
+ },
173
+ ),
174
+ };
175
+
176
+ spawnMock.mock.mockImplementation(() => mockChildProcess);
177
+
178
+ const result = await shellInGarden("test-garden");
179
+
180
+ strictEqual(result.success, false);
181
+ strictEqual(result.exitCode, 1);
182
+ });
183
+
184
+ it("should handle shell startup error", async () => {
185
+ whereGardenMock.mock.resetCalls();
186
+ spawnMock.mock.resetCalls();
187
+
188
+ // Mock successful garden location
189
+ whereGardenMock.mock.mockImplementation(() =>
190
+ Promise.resolve({
191
+ success: true,
192
+ path: "/test/repo/.git/phantom/gardens/test-garden",
193
+ }),
194
+ );
195
+
196
+ // Mock shell startup error
197
+ const mockChildProcess = {
198
+ on: mock.fn((event: string, callback: (error: Error) => void) => {
199
+ if (event === "error") {
200
+ setTimeout(() => callback(new Error("Shell not found")), 0);
201
+ }
202
+ }),
203
+ };
204
+
205
+ spawnMock.mock.mockImplementation(() => mockChildProcess);
206
+
207
+ const result = await shellInGarden("test-garden");
208
+
209
+ strictEqual(result.success, false);
210
+ strictEqual(result.message, "Error starting shell: Shell not found");
211
+ });
212
+
213
+ it("should handle signal termination", async () => {
214
+ whereGardenMock.mock.resetCalls();
215
+ spawnMock.mock.resetCalls();
216
+
217
+ // Mock successful garden location
218
+ whereGardenMock.mock.mockImplementation(() =>
219
+ Promise.resolve({
220
+ success: true,
221
+ path: "/test/repo/.git/phantom/gardens/test-garden",
222
+ }),
223
+ );
224
+
225
+ // Mock signal termination
226
+ const mockChildProcess = {
227
+ on: mock.fn(
228
+ (
229
+ event: string,
230
+ callback: (code: number | null, signal: string | null) => void,
231
+ ) => {
232
+ if (event === "exit") {
233
+ // Simulate signal termination
234
+ setTimeout(() => callback(null, "SIGTERM"), 0);
235
+ }
236
+ },
237
+ ),
238
+ };
239
+
240
+ spawnMock.mock.mockImplementation(() => mockChildProcess);
241
+
242
+ const result = await shellInGarden("test-garden");
243
+
244
+ strictEqual(result.success, false);
245
+ strictEqual(result.message, "Shell terminated by signal: SIGTERM");
246
+ strictEqual(result.exitCode, 143); // 128 + 15 (SIGTERM)
247
+ });
248
+ });
@@ -0,0 +1,91 @@
1
+ import { spawn } from "node:child_process";
2
+ import { exit } from "node:process";
3
+ import { whereGarden } from "../gardens/commands/where.ts";
4
+
5
+ export async function shellInGarden(gardenName: string): Promise<{
6
+ success: boolean;
7
+ message?: string;
8
+ exitCode?: number;
9
+ }> {
10
+ if (!gardenName) {
11
+ return { success: false, message: "Error: garden name required" };
12
+ }
13
+
14
+ // Validate garden exists and get its path
15
+ const gardenResult = await whereGarden(gardenName);
16
+ if (!gardenResult.success) {
17
+ return { success: false, message: gardenResult.message };
18
+ }
19
+
20
+ const gardenPath = gardenResult.path as string;
21
+ // Use user's preferred shell or fallback to /bin/sh
22
+ const shell = process.env.SHELL || "/bin/sh";
23
+
24
+ return new Promise((resolve) => {
25
+ const childProcess = spawn(shell, [], {
26
+ cwd: gardenPath,
27
+ stdio: "inherit",
28
+ env: {
29
+ ...process.env,
30
+ // Add environment variable to indicate we're in a phantom garden
31
+ PHANTOM_GARDEN: gardenName,
32
+ PHANTOM_GARDEN_PATH: gardenPath,
33
+ },
34
+ });
35
+
36
+ childProcess.on("error", (error) => {
37
+ resolve({
38
+ success: false,
39
+ message: `Error starting shell: ${error.message}`,
40
+ });
41
+ });
42
+
43
+ childProcess.on("exit", (code, signal) => {
44
+ if (signal) {
45
+ resolve({
46
+ success: false,
47
+ message: `Shell terminated by signal: ${signal}`,
48
+ exitCode: 128 + (signal === "SIGTERM" ? 15 : 1),
49
+ });
50
+ } else {
51
+ const exitCode = code ?? 0;
52
+ resolve({
53
+ success: exitCode === 0,
54
+ exitCode,
55
+ });
56
+ }
57
+ });
58
+ });
59
+ }
60
+
61
+ export async function shellHandler(args: string[]): Promise<void> {
62
+ if (args.length < 1) {
63
+ console.error("Usage: phantom shell <garden-name>");
64
+ exit(1);
65
+ }
66
+
67
+ const gardenName = args[0];
68
+
69
+ // Get garden path for display
70
+ const gardenResult = await whereGarden(gardenName);
71
+ if (!gardenResult.success) {
72
+ console.error(gardenResult.message);
73
+ exit(1);
74
+ }
75
+
76
+ // Display entering message
77
+ console.log(`Entering garden '${gardenName}' at ${gardenResult.path}`);
78
+ console.log("Type 'exit' to return to your original directory\n");
79
+
80
+ const result = await shellInGarden(gardenName);
81
+
82
+ if (!result.success) {
83
+ if (result.message) {
84
+ console.error(result.message);
85
+ }
86
+ exit(result.exitCode ?? 1);
87
+ }
88
+
89
+ // Exit with the same code as the shell
90
+ exit(result.exitCode ?? 0);
91
+ }