@positronic/cli 0.0.2

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 (193) hide show
  1. package/dist/src/cli.js +739 -0
  2. package/dist/src/commands/backend.js +199 -0
  3. package/dist/src/commands/brain.js +446 -0
  4. package/dist/src/commands/brain.test.js +2936 -0
  5. package/dist/src/commands/helpers.js +1315 -0
  6. package/dist/src/commands/helpers.test.js +832 -0
  7. package/dist/src/commands/project-config-manager.js +197 -0
  8. package/dist/src/commands/project.js +130 -0
  9. package/dist/src/commands/project.test.js +1201 -0
  10. package/dist/src/commands/resources.js +272 -0
  11. package/dist/src/commands/resources.test.js +2511 -0
  12. package/dist/src/commands/schedule.js +73 -0
  13. package/dist/src/commands/schedule.test.js +1235 -0
  14. package/dist/src/commands/secret.js +87 -0
  15. package/dist/src/commands/secret.test.d.js +1 -0
  16. package/dist/src/commands/secret.test.js +761 -0
  17. package/dist/src/commands/server.js +816 -0
  18. package/dist/src/commands/server.test.js +1237 -0
  19. package/dist/src/commands/test-utils.js +737 -0
  20. package/dist/src/components/brain-history.js +169 -0
  21. package/dist/src/components/brain-list.js +108 -0
  22. package/dist/src/components/brain-rerun.js +313 -0
  23. package/dist/src/components/brain-show.js +65 -0
  24. package/dist/src/components/error.js +19 -0
  25. package/dist/src/components/project-add.js +95 -0
  26. package/dist/src/components/project-create.js +276 -0
  27. package/dist/src/components/project-list.js +88 -0
  28. package/dist/src/components/project-remove.js +91 -0
  29. package/dist/src/components/project-select.js +224 -0
  30. package/dist/src/components/project-show.js +41 -0
  31. package/dist/src/components/resource-clear.js +152 -0
  32. package/dist/src/components/resource-delete.js +189 -0
  33. package/dist/src/components/resource-list.js +174 -0
  34. package/dist/src/components/resource-sync.js +386 -0
  35. package/dist/src/components/resource-types.js +243 -0
  36. package/dist/src/components/resource-upload.js +366 -0
  37. package/dist/src/components/schedule-create.js +259 -0
  38. package/dist/src/components/schedule-delete.js +161 -0
  39. package/dist/src/components/schedule-list.js +176 -0
  40. package/dist/src/components/schedule-runs.js +103 -0
  41. package/dist/src/components/secret-bulk.js +262 -0
  42. package/dist/src/components/secret-create.js +199 -0
  43. package/dist/src/components/secret-delete.js +190 -0
  44. package/dist/src/components/secret-list.js +190 -0
  45. package/dist/src/components/secret-sync.js +303 -0
  46. package/dist/src/components/watch.js +184 -0
  47. package/dist/src/hooks/useApi.js +512 -0
  48. package/dist/src/positronic.js +33 -0
  49. package/dist/src/test/mock-api-client.js +371 -0
  50. package/dist/src/test/test-dev-server.js +1376 -0
  51. package/dist/types/cli.d.ts +9 -0
  52. package/dist/types/cli.d.ts.map +1 -0
  53. package/dist/types/commands/backend.d.ts +6 -0
  54. package/dist/types/commands/backend.d.ts.map +1 -0
  55. package/dist/types/commands/brain.d.ts +35 -0
  56. package/dist/types/commands/brain.d.ts.map +1 -0
  57. package/dist/types/commands/helpers.d.ts +55 -0
  58. package/dist/types/commands/helpers.d.ts.map +1 -0
  59. package/dist/types/commands/project-config-manager.d.ts +37 -0
  60. package/dist/types/commands/project-config-manager.d.ts.map +1 -0
  61. package/dist/types/commands/project.d.ts +55 -0
  62. package/dist/types/commands/project.d.ts.map +1 -0
  63. package/dist/types/commands/resources.d.ts +13 -0
  64. package/dist/types/commands/resources.d.ts.map +1 -0
  65. package/dist/types/commands/schedule.d.ts +27 -0
  66. package/dist/types/commands/schedule.d.ts.map +1 -0
  67. package/dist/types/commands/secret.d.ts +23 -0
  68. package/dist/types/commands/secret.d.ts.map +1 -0
  69. package/dist/types/commands/server.d.ts +12 -0
  70. package/dist/types/commands/server.d.ts.map +1 -0
  71. package/dist/types/commands/test-utils.d.ts +45 -0
  72. package/dist/types/commands/test-utils.d.ts.map +1 -0
  73. package/dist/types/components/brain-history.d.ts +7 -0
  74. package/dist/types/components/brain-history.d.ts.map +1 -0
  75. package/dist/types/components/brain-list.d.ts +2 -0
  76. package/dist/types/components/brain-list.d.ts.map +1 -0
  77. package/dist/types/components/brain-rerun.d.ts +9 -0
  78. package/dist/types/components/brain-rerun.d.ts.map +1 -0
  79. package/dist/types/components/brain-show.d.ts +6 -0
  80. package/dist/types/components/brain-show.d.ts.map +1 -0
  81. package/dist/types/components/error.d.ts +10 -0
  82. package/dist/types/components/error.d.ts.map +1 -0
  83. package/dist/types/components/project-add.d.ts +9 -0
  84. package/dist/types/components/project-add.d.ts.map +1 -0
  85. package/dist/types/components/project-create.d.ts +6 -0
  86. package/dist/types/components/project-create.d.ts.map +1 -0
  87. package/dist/types/components/project-list.d.ts +7 -0
  88. package/dist/types/components/project-list.d.ts.map +1 -0
  89. package/dist/types/components/project-remove.d.ts +8 -0
  90. package/dist/types/components/project-remove.d.ts.map +1 -0
  91. package/dist/types/components/project-select.d.ts +8 -0
  92. package/dist/types/components/project-select.d.ts.map +1 -0
  93. package/dist/types/components/project-show.d.ts +7 -0
  94. package/dist/types/components/project-show.d.ts.map +1 -0
  95. package/dist/types/components/resource-clear.d.ts +2 -0
  96. package/dist/types/components/resource-clear.d.ts.map +1 -0
  97. package/dist/types/components/resource-delete.d.ts +9 -0
  98. package/dist/types/components/resource-delete.d.ts.map +1 -0
  99. package/dist/types/components/resource-list.d.ts +2 -0
  100. package/dist/types/components/resource-list.d.ts.map +1 -0
  101. package/dist/types/components/resource-sync.d.ts +8 -0
  102. package/dist/types/components/resource-sync.d.ts.map +1 -0
  103. package/dist/types/components/resource-types.d.ts +7 -0
  104. package/dist/types/components/resource-types.d.ts.map +1 -0
  105. package/dist/types/components/resource-upload.d.ts +8 -0
  106. package/dist/types/components/resource-upload.d.ts.map +1 -0
  107. package/dist/types/components/schedule-create.d.ts +7 -0
  108. package/dist/types/components/schedule-create.d.ts.map +1 -0
  109. package/dist/types/components/schedule-delete.d.ts +7 -0
  110. package/dist/types/components/schedule-delete.d.ts.map +1 -0
  111. package/dist/types/components/schedule-list.d.ts +6 -0
  112. package/dist/types/components/schedule-list.d.ts.map +1 -0
  113. package/dist/types/components/schedule-runs.d.ts +8 -0
  114. package/dist/types/components/schedule-runs.d.ts.map +1 -0
  115. package/dist/types/components/secret-bulk.d.ts +8 -0
  116. package/dist/types/components/secret-bulk.d.ts.map +1 -0
  117. package/dist/types/components/secret-create.d.ts +9 -0
  118. package/dist/types/components/secret-create.d.ts.map +1 -0
  119. package/dist/types/components/secret-delete.d.ts +8 -0
  120. package/dist/types/components/secret-delete.d.ts.map +1 -0
  121. package/dist/types/components/secret-list.d.ts +7 -0
  122. package/dist/types/components/secret-list.d.ts.map +1 -0
  123. package/dist/types/components/secret-sync.d.ts +9 -0
  124. package/dist/types/components/secret-sync.d.ts.map +1 -0
  125. package/dist/types/components/watch.d.ts +7 -0
  126. package/dist/types/components/watch.d.ts.map +1 -0
  127. package/dist/types/hooks/useApi.d.ts +29 -0
  128. package/dist/types/hooks/useApi.d.ts.map +1 -0
  129. package/dist/types/positronic.d.ts +3 -0
  130. package/dist/types/positronic.d.ts.map +1 -0
  131. package/dist/types/test/mock-api-client.d.ts +25 -0
  132. package/dist/types/test/mock-api-client.d.ts.map +1 -0
  133. package/dist/types/test/test-dev-server.d.ts +129 -0
  134. package/dist/types/test/test-dev-server.d.ts.map +1 -0
  135. package/package.json +37 -0
  136. package/src/cli.ts +981 -0
  137. package/src/commands/backend.ts +63 -0
  138. package/src/commands/brain.test.ts +1004 -0
  139. package/src/commands/brain.ts +215 -0
  140. package/src/commands/helpers.test.ts +487 -0
  141. package/src/commands/helpers.ts +870 -0
  142. package/src/commands/project-config-manager.ts +152 -0
  143. package/src/commands/project.test.ts +502 -0
  144. package/src/commands/project.ts +109 -0
  145. package/src/commands/resources.test.ts +1052 -0
  146. package/src/commands/resources.ts +97 -0
  147. package/src/commands/schedule.test.ts +481 -0
  148. package/src/commands/schedule.ts +65 -0
  149. package/src/commands/secret.test.ts +210 -0
  150. package/src/commands/secret.ts +50 -0
  151. package/src/commands/server.test.ts +493 -0
  152. package/src/commands/server.ts +353 -0
  153. package/src/commands/test-utils.ts +324 -0
  154. package/src/components/brain-history.tsx +198 -0
  155. package/src/components/brain-list.tsx +105 -0
  156. package/src/components/brain-rerun.tsx +111 -0
  157. package/src/components/brain-show.tsx +92 -0
  158. package/src/components/error.tsx +24 -0
  159. package/src/components/project-add.tsx +59 -0
  160. package/src/components/project-create.tsx +83 -0
  161. package/src/components/project-list.tsx +83 -0
  162. package/src/components/project-remove.tsx +55 -0
  163. package/src/components/project-select.tsx +200 -0
  164. package/src/components/project-show.tsx +58 -0
  165. package/src/components/resource-clear.tsx +127 -0
  166. package/src/components/resource-delete.tsx +160 -0
  167. package/src/components/resource-list.tsx +177 -0
  168. package/src/components/resource-sync.tsx +170 -0
  169. package/src/components/resource-types.tsx +55 -0
  170. package/src/components/resource-upload.tsx +182 -0
  171. package/src/components/schedule-create.tsx +90 -0
  172. package/src/components/schedule-delete.tsx +116 -0
  173. package/src/components/schedule-list.tsx +186 -0
  174. package/src/components/schedule-runs.tsx +151 -0
  175. package/src/components/secret-bulk.tsx +79 -0
  176. package/src/components/secret-create.tsx +49 -0
  177. package/src/components/secret-delete.tsx +41 -0
  178. package/src/components/secret-list.tsx +41 -0
  179. package/src/components/watch.tsx +155 -0
  180. package/src/hooks/useApi.ts +183 -0
  181. package/src/positronic.ts +40 -0
  182. package/src/test/data/resources/config.json +1 -0
  183. package/src/test/data/resources/data/config.json +1 -0
  184. package/src/test/data/resources/data/logo.png +2 -0
  185. package/src/test/data/resources/docs/api.md +3 -0
  186. package/src/test/data/resources/docs/readme.md +3 -0
  187. package/src/test/data/resources/example.md +3 -0
  188. package/src/test/data/resources/file with spaces.txt +1 -0
  189. package/src/test/data/resources/readme.md +3 -0
  190. package/src/test/data/resources/test.txt +1 -0
  191. package/src/test/mock-api-client.ts +145 -0
  192. package/src/test/test-dev-server.ts +1003 -0
  193. package/tsconfig.json +11 -0
@@ -0,0 +1,353 @@
1
+ import * as path from 'path';
2
+ import * as fs from 'fs';
3
+ import { spawn } from 'child_process';
4
+ import chokidar, { type FSWatcher } from 'chokidar';
5
+ import type { ArgumentsCamelCase } from 'yargs';
6
+ import { syncResources, generateTypes } from './helpers.js';
7
+ import type { PositronicDevServer, ServerHandle } from '@positronic/spec';
8
+
9
+ export class ServerCommand {
10
+ constructor(private server: PositronicDevServer) {}
11
+
12
+ async handle(argv: ArgumentsCamelCase<any>) {
13
+ // Handle kill option
14
+ if (argv.k) {
15
+ return this.handleKill(argv);
16
+ }
17
+
18
+ // Validate arguments
19
+ if (argv.port && argv.d && !argv.logFile) {
20
+ throw new Error(
21
+ 'When using --port with -d, you must also specify --log-file'
22
+ );
23
+ }
24
+
25
+ // Check for existing PID file (skip if we're a detached child process)
26
+ const pidFile = this.getPidFilePath(argv.port);
27
+ if (!process.env.POSITRONIC_DETACHED_CHILD && fs.existsSync(pidFile)) {
28
+ const existingPid = parseInt(fs.readFileSync(pidFile, 'utf8').trim());
29
+ if (this.isProcessRunning(existingPid)) {
30
+ throw new Error(
31
+ `Server already running (PID: ${existingPid}). Stop it with: px server -k`
32
+ );
33
+ } else {
34
+ console.log('WARNING: Removing stale PID file');
35
+ fs.unlinkSync(pidFile);
36
+ }
37
+ }
38
+
39
+ // If -d flag is set, spawn a detached process
40
+ if (argv.d) {
41
+ return this.handleDetached(argv);
42
+ }
43
+
44
+ // Write PID file for foreground process too
45
+ fs.writeFileSync(pidFile, String(process.pid));
46
+
47
+ const brainsDir = path.join(this.server.projectRootDir, 'brains');
48
+ const resourcesDir = path.join(this.server.projectRootDir, 'resources');
49
+
50
+ let serverHandle: ServerHandle | null = null;
51
+ let watcher: FSWatcher | null = null;
52
+ let logStream: fs.WriteStream | null = null;
53
+
54
+ // Always create a log file (use default if not specified)
55
+ const logFilePath = argv.logFile
56
+ ? path.resolve(argv.logFile)
57
+ : path.join(this.server.projectRootDir, '.positronic-server.log');
58
+
59
+ // Ensure directory exists
60
+ const logDir = path.dirname(logFilePath);
61
+ if (!fs.existsSync(logDir)) {
62
+ fs.mkdirSync(logDir, { recursive: true });
63
+ }
64
+
65
+ // Create log stream (append mode)
66
+ logStream = fs.createWriteStream(logFilePath, { flags: 'a' });
67
+
68
+ // Helper function to log to both console and file
69
+ const logBoth = (level: string, message: string) => {
70
+ const timestamp = new Date().toISOString();
71
+ const logLine = `[${timestamp}] [${level}] ${message}\n`;
72
+ if (logStream && !logStream.destroyed) {
73
+ logStream.write(logLine);
74
+ }
75
+
76
+ // Console output is handled by the dev server's direct forwarding
77
+ // to avoid duplication
78
+ };
79
+
80
+ // Always register log callbacks to capture server logs
81
+ // The server is responsible for generating logs through these callbacks
82
+ this.server.onLog((message) => logBoth('INFO', message));
83
+ this.server.onError((message) => logBoth('ERROR', message));
84
+ this.server.onWarning((message) => logBoth('WARN', message));
85
+
86
+ const cleanup = async () => {
87
+ if (watcher) {
88
+ await watcher.close();
89
+ watcher = null;
90
+ }
91
+ if (serverHandle && !serverHandle.killed) {
92
+ serverHandle.kill();
93
+ serverHandle = null;
94
+ }
95
+
96
+ // Close log stream
97
+ if (logStream) {
98
+ logStream.end();
99
+ logStream = null;
100
+ }
101
+
102
+ // Remove PID file
103
+ if (fs.existsSync(pidFile)) {
104
+ fs.unlinkSync(pidFile);
105
+ }
106
+
107
+ process.exit(0);
108
+ };
109
+
110
+ process.on('SIGINT', cleanup); // Catches Ctrl+C
111
+ process.on('SIGTERM', cleanup); // Catches kill commands
112
+
113
+ try {
114
+ // Use the dev server's setup method
115
+ await this.server.setup(argv.force);
116
+
117
+ // Use the dev server's start method
118
+ serverHandle = await this.server.start(argv.port);
119
+
120
+ serverHandle.onClose((code?: number | null) => {
121
+ if (watcher) {
122
+ watcher.close();
123
+ watcher = null;
124
+ }
125
+ process.exit(code ?? 1); // Exit with server's code or 1 if null
126
+ });
127
+
128
+ serverHandle.onError((err: Error) => {
129
+ console.error('Failed to start dev server:', err);
130
+ if (watcher) {
131
+ watcher.close();
132
+ watcher = null;
133
+ }
134
+ process.exit(1);
135
+ });
136
+ // Wait for the server to be ready before syncing resources
137
+ const isReady = await serverHandle.waitUntilReady(15000);
138
+
139
+ if (!isReady) {
140
+ console.error(
141
+ '⚠️ Server startup timeout: The server is taking longer than expected to initialize.'
142
+ );
143
+
144
+ // Clean up and exit
145
+ if (serverHandle && !serverHandle.killed) {
146
+ serverHandle.kill();
147
+ }
148
+ process.exit(1);
149
+ }
150
+
151
+ // Initial resource sync and type generation
152
+ try {
153
+ const syncResult = await syncResources(this.server.projectRootDir);
154
+ if (syncResult.errorCount > 0) {
155
+ console.log(
156
+ `⚠️ Resource sync completed with ${syncResult.errorCount} errors:`
157
+ );
158
+ syncResult.errors.forEach((error) => {
159
+ console.log(` • ${error.file}: ${error.message}`);
160
+ });
161
+ } else {
162
+ console.log(
163
+ `✅ Synced ${syncResult.uploadCount} resources (${syncResult.skipCount} up to date, ${syncResult.deleteCount} deleted)`
164
+ );
165
+ }
166
+ await generateTypes(this.server.projectRootDir);
167
+ } catch (error) {
168
+ console.error(
169
+ '❌ Error during resource synchronization:',
170
+ error instanceof Error ? error.message : String(error)
171
+ );
172
+ console.error(
173
+ '\nThe server is running, but resources may not be available to your brains.'
174
+ );
175
+ console.error(
176
+ '\nYou can manually sync resources by running: px resources sync'
177
+ );
178
+ // Don't exit here - let the server continue running
179
+ }
180
+
181
+ // Watcher setup - target the user's brains and resources directories
182
+ const watchPaths = [
183
+ path.join(brainsDir, '*.ts'),
184
+ path.join(resourcesDir, '**/*'),
185
+ ];
186
+
187
+ watcher = chokidar.watch(watchPaths, {
188
+ ignored: [/(^|[\/\\])\../, '**/node_modules/**'],
189
+ persistent: true,
190
+ ignoreInitial: true,
191
+ awaitWriteFinish: {
192
+ stabilityThreshold: 200,
193
+ pollInterval: 100,
194
+ },
195
+ });
196
+
197
+ const handleResourceChange = async () => {
198
+ await syncResources(this.server.projectRootDir);
199
+ await generateTypes(this.server.projectRootDir);
200
+ };
201
+
202
+ watcher
203
+ .on('add', async (filePath) => {
204
+ if (filePath.startsWith(resourcesDir)) {
205
+ await handleResourceChange();
206
+ } else if (filePath.startsWith(brainsDir)) {
207
+ // Call the dev server's watch method if it exists
208
+ if (this.server.watch) {
209
+ await this.server.watch(filePath, 'add');
210
+ }
211
+ }
212
+ })
213
+ .on('change', async (filePath) => {
214
+ if (filePath.startsWith(resourcesDir)) {
215
+ await handleResourceChange();
216
+ } else if (filePath.startsWith(brainsDir)) {
217
+ // Call the dev server's watch method if it exists
218
+ if (this.server.watch) {
219
+ await this.server.watch(filePath, 'change');
220
+ }
221
+ }
222
+ })
223
+ .on('unlink', async (filePath) => {
224
+ if (filePath.startsWith(resourcesDir)) {
225
+ await handleResourceChange();
226
+ } else if (filePath.startsWith(brainsDir)) {
227
+ // Call the dev server's watch method if it exists
228
+ if (this.server.watch) {
229
+ await this.server.watch(filePath, 'unlink');
230
+ }
231
+ }
232
+ })
233
+ .on('error', (error) => console.error(`Watcher error: ${error}`));
234
+ } catch (error) {
235
+ console.error('An error occurred during server startup:', error);
236
+ await cleanup(); // Attempt cleanup on error
237
+ }
238
+ }
239
+
240
+ private async handleDetached(argv: ArgumentsCamelCase<any>) {
241
+ // Get the path to the current CLI executable
242
+ const cliPath = process.argv[1];
243
+
244
+ // Build the command arguments
245
+ const args = ['server'];
246
+
247
+ // Add optional arguments if they were provided
248
+ if (argv.force) args.push('--force');
249
+ if (argv.port) args.push('--port', String(argv.port));
250
+ if (argv.logFile) args.push('--log-file', argv.logFile);
251
+
252
+ // Determine output file for logs
253
+ const logFile =
254
+ argv.logFile ||
255
+ path.join(this.server.projectRootDir, '.positronic-server.log');
256
+
257
+ // Open log file in append mode
258
+ const out = fs.openSync(logFile, 'a');
259
+ const err = fs.openSync(logFile, 'a');
260
+
261
+ // Spawn the detached process with a special environment variable to skip PID check
262
+ const child = spawn(process.execPath, [cliPath, ...args], {
263
+ detached: true,
264
+ stdio: ['ignore', out, err],
265
+ cwd: this.server.projectRootDir,
266
+ env: { ...process.env, POSITRONIC_DETACHED_CHILD: 'true' },
267
+ });
268
+
269
+ // Write the PID to a file for later reference
270
+ const pidFile = this.getPidFilePath(argv.port);
271
+ fs.writeFileSync(pidFile, String(child.pid));
272
+
273
+ // Detach from the child process
274
+ child.unref();
275
+
276
+ console.log(`✅ Server started in background (PID: ${child.pid})`);
277
+ console.log(` Logs: ${logFile}`);
278
+ console.log(` To stop: px server -k`);
279
+
280
+ // Exit the parent process
281
+ process.exit(0);
282
+ }
283
+
284
+ private getPidFilePath(port?: number): string {
285
+ if (port) {
286
+ return path.join(
287
+ this.server.projectRootDir,
288
+ `.positronic-server-${port}.pid`
289
+ );
290
+ }
291
+ return path.join(this.server.projectRootDir, '.positronic-server.pid');
292
+ }
293
+
294
+ private isProcessRunning(pid: number): boolean {
295
+ try {
296
+ // This sends signal 0 which doesn't kill the process, just checks if it exists
297
+ process.kill(pid, 0);
298
+ return true;
299
+ } catch (e) {
300
+ return false;
301
+ }
302
+ }
303
+
304
+ private async handleKill(argv: ArgumentsCamelCase<any>) {
305
+ const pidFile = path.join(this.server.projectRootDir, '.positronic-server.pid');
306
+
307
+ if (!fs.existsSync(pidFile)) {
308
+ console.error(`❌ No default server is running`);
309
+ console.error(` PID file not found: ${pidFile}`);
310
+ process.exit(1);
311
+ }
312
+
313
+ try {
314
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim());
315
+
316
+ if (!this.isProcessRunning(pid)) {
317
+ console.log('⚠️ Server process not found, removing stale PID file');
318
+ fs.unlinkSync(pidFile);
319
+ process.exit(0);
320
+ }
321
+
322
+ // Kill the process
323
+ process.kill(pid, 'SIGTERM');
324
+
325
+ // Wait a moment to see if the process stops
326
+ let killed = false;
327
+ for (let i = 0; i < 10; i++) {
328
+ await new Promise(resolve => setTimeout(resolve, 100));
329
+ if (!this.isProcessRunning(pid)) {
330
+ killed = true;
331
+ break;
332
+ }
333
+ }
334
+
335
+ if (!killed) {
336
+ // Force kill if SIGTERM didn't work
337
+ console.log('⚠️ Server did not stop gracefully, forcing shutdown');
338
+ process.kill(pid, 'SIGKILL');
339
+ }
340
+
341
+ // Clean up PID file
342
+ if (fs.existsSync(pidFile)) {
343
+ fs.unlinkSync(pidFile);
344
+ }
345
+
346
+ console.log(`✅ Server stopped (PID: ${pid})`);
347
+ process.exit(0);
348
+ } catch (error) {
349
+ console.error('❌ Failed to kill server:', error instanceof Error ? error.message : String(error));
350
+ process.exit(1);
351
+ }
352
+ }
353
+ }
@@ -0,0 +1,324 @@
1
+ import React from 'react';
2
+ import { render } from 'ink-testing-library';
3
+ import * as fs from 'fs';
4
+ import * as path from 'path';
5
+ import * as os from 'os';
6
+ import { fileURLToPath } from 'url';
7
+ import process from 'process';
8
+ import type { TestServerHandle } from '../test/test-dev-server.js';
9
+ import { TestDevServer } from '../test/test-dev-server.js';
10
+ import { buildCli } from '../cli.js';
11
+ import type { PositronicDevServer } from '@positronic/spec';
12
+ import caz from 'caz';
13
+
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = path.dirname(__filename);
16
+
17
+ // Singleton cache for the template to avoid repeated npm installs
18
+ // This is shared across all tests in a test run and should NOT be deleted
19
+ // by individual test cleanup. It's cleaned up automatically when the process exits.
20
+ let cachedTemplatePath: string | null = null;
21
+
22
+ async function getCachedTemplate(): Promise<string> {
23
+ // Check if cached template exists and is valid
24
+ if (cachedTemplatePath && fs.existsSync(cachedTemplatePath)) {
25
+ return cachedTemplatePath;
26
+ }
27
+
28
+ // Create cache only once per test run
29
+ const devPath = path.resolve(__dirname, '../../../');
30
+ const originalTemplate = path.resolve(devPath, 'template-new-project');
31
+
32
+ // First, copy template to temp location so caz can mess with that copy
33
+ const tempCopyDir = fs.mkdtempSync(
34
+ path.join(os.tmpdir(), 'positronic-template-copy-')
35
+ );
36
+ fs.cpSync(originalTemplate, tempCopyDir, { recursive: true });
37
+
38
+ // Now generate the actual cached template in another temp directory
39
+ cachedTemplatePath = fs.mkdtempSync(
40
+ path.join(os.tmpdir(), 'positronic-cached-template-')
41
+ );
42
+
43
+ // Run caz once to generate a clean template
44
+ const cazOptions = {
45
+ name: 'test-project',
46
+ backend: 'none',
47
+ install: false,
48
+ claudemd: false, // Add the new claudemd option
49
+ force: true,
50
+ };
51
+
52
+ // Temporarily hijack all output streams to suppress caz output
53
+ const originalStdoutWrite = process.stdout.write;
54
+ const originalStderrWrite = process.stderr.write;
55
+ const originalConsoleLog = console.log;
56
+ const originalConsoleError = console.error;
57
+
58
+ process.stdout.write = () => true;
59
+ process.stderr.write = () => true;
60
+ console.log = () => {};
61
+ console.error = () => {};
62
+
63
+ try {
64
+ await caz.default(tempCopyDir, cachedTemplatePath, cazOptions);
65
+ } finally {
66
+ // Restore original output streams
67
+ process.stdout.write = originalStdoutWrite;
68
+ process.stderr.write = originalStderrWrite;
69
+ console.log = originalConsoleLog;
70
+ console.error = originalConsoleError;
71
+ }
72
+
73
+ // Clean up the temp copy directory
74
+ fs.rmSync(tempCopyDir, { recursive: true, force: true });
75
+
76
+ return cachedTemplatePath;
77
+ }
78
+
79
+ // Helper function to copy test resources from test data directory
80
+ function copyTestResources(targetDir: string) {
81
+ const testDataPath = path.join(__dirname, '../test/data/resources');
82
+ const targetResourcesPath = path.join(targetDir, 'resources');
83
+
84
+ // Remove existing resources directory if it exists
85
+ if (fs.existsSync(targetResourcesPath)) {
86
+ fs.rmSync(targetResourcesPath, { recursive: true, force: true });
87
+ }
88
+
89
+ // Copy the test data resources
90
+ fs.cpSync(testDataPath, targetResourcesPath, { recursive: true });
91
+ }
92
+
93
+ // Helper function to create a minimal Positronic project structure
94
+ export async function createMinimalProject(dir: string) {
95
+ // Get or create the cached template
96
+ const cachedTemplate = await getCachedTemplate();
97
+
98
+ // Copy the cached template to the target directory
99
+ fs.cpSync(cachedTemplate, dir, { recursive: true });
100
+ copyTestResources(dir);
101
+ // Update positronic.config.json with the correct project name if it exists
102
+ const configPath = path.join(dir, 'positronic.config.json');
103
+ if (fs.existsSync(configPath)) {
104
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
105
+ config.name = 'test-project';
106
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
107
+ }
108
+ }
109
+
110
+ export class TestEnv {
111
+ private serverHandle: TestServerHandle | null = null;
112
+ constructor(public server: TestDevServer) {}
113
+ get projectRootDir() {
114
+ return this.server.projectRootDir;
115
+ }
116
+
117
+ setup(setup: (tempDir: string) => void | Promise<void>) {
118
+ setup(this.projectRootDir);
119
+ return this;
120
+ }
121
+
122
+ async start() {
123
+ if (this.serverHandle) {
124
+ throw new Error('Server already started');
125
+ }
126
+ this.serverHandle = await this.server.start();
127
+ return async (argv: string[]) => {
128
+ if (!this.serverHandle) {
129
+ throw new Error('Server not started');
130
+ }
131
+ return px(argv, {
132
+ server: this.server,
133
+ projectRootDir: this.projectRootDir,
134
+ });
135
+ };
136
+ }
137
+
138
+ cleanup() {
139
+ fs.rmSync(this.projectRootDir, { recursive: true, force: true });
140
+ }
141
+
142
+ async stop() {
143
+ if (!this.serverHandle) {
144
+ throw new Error('Server not started');
145
+ }
146
+ this.serverHandle.kill();
147
+ this.serverHandle = null;
148
+ await this.server.stop();
149
+ }
150
+
151
+ async stopAndCleanup() {
152
+ await this.stop();
153
+ this.cleanup();
154
+ }
155
+ }
156
+
157
+ export async function createTestEnv(): Promise<TestEnv> {
158
+ const tempDir = fs.mkdtempSync(
159
+ path.join(os.tmpdir(), 'positronic-server-test-')
160
+ );
161
+ await createMinimalProject(tempDir);
162
+
163
+ // Create test dev server instance
164
+ const devServer = new TestDevServer(tempDir);
165
+
166
+ return new TestEnv(devServer);
167
+ }
168
+
169
+ // Helper function to wait for types file to contain specific content
170
+ export async function waitForTypesFile(
171
+ typesPath: string,
172
+ expectedContent: string | string[],
173
+ maxWaitMs = 5000
174
+ ): Promise<string> {
175
+ const startTime = Date.now();
176
+ const contentToCheck = Array.isArray(expectedContent)
177
+ ? expectedContent
178
+ : [expectedContent];
179
+
180
+ while (Date.now() - startTime < maxWaitMs) {
181
+ if (fs.existsSync(typesPath)) {
182
+ const content = fs.readFileSync(typesPath, 'utf-8');
183
+ // Check if all expected content is present
184
+ if (contentToCheck.every((expected) => content.includes(expected))) {
185
+ return content;
186
+ }
187
+ }
188
+ await new Promise((resolve) => setTimeout(resolve, 100));
189
+ }
190
+
191
+ return '';
192
+ }
193
+
194
+ interface PxResult {
195
+ waitForOutput: (regex?: RegExp, maxTries?: number) => Promise<boolean>;
196
+ waitForTypesFile: (types: string | string[]) => Promise<string>;
197
+ instance: {
198
+ lastFrame: () => string | undefined;
199
+ rerender: (element: React.ReactElement) => void;
200
+ unmount: () => void;
201
+ frames: string[];
202
+ stdin: {
203
+ write: (data: string) => void;
204
+ };
205
+ stdout: {
206
+ lastFrame: () => string | undefined;
207
+ frames: string[];
208
+ };
209
+ stderr: {
210
+ lastFrame: () => string | undefined;
211
+ frames: string[];
212
+ };
213
+ };
214
+ }
215
+
216
+ export async function px(
217
+ argv: string[],
218
+ options: {
219
+ server?: PositronicDevServer;
220
+ projectRootDir?: string;
221
+ configDir?: string;
222
+ } = {}
223
+ ): Promise<PxResult> {
224
+ const { server, projectRootDir, configDir } = options;
225
+ let instance: ReturnType<typeof render> | null = null;
226
+ instance = await runCli(argv, {
227
+ server,
228
+ configDir,
229
+ });
230
+
231
+ // const { lastFrame, rerender, unmount, frames, stdin, stdout, stderr } = instance!;
232
+
233
+ return {
234
+ waitForOutput: async (regex?: RegExp, maxTries = 10) => {
235
+ if (!instance && !regex) {
236
+ return true;
237
+ }
238
+ if (!instance && regex) {
239
+ console.error('waitForOutput failed, instance is null');
240
+ return false;
241
+ }
242
+
243
+ let tries = 0;
244
+ while (tries < maxTries) {
245
+ const lastFrame = instance!.lastFrame() ?? '';
246
+ if (regex!.test(lastFrame)) {
247
+ return true;
248
+ }
249
+ await new Promise((resolve) => setTimeout(resolve, 10));
250
+ tries++;
251
+ }
252
+ console.error('waitForOutput failed, last frame:', instance!.lastFrame());
253
+ return false;
254
+ },
255
+ waitForTypesFile: async (types: string | string[]) => {
256
+ if (projectRootDir) {
257
+ const typesPath = path.join(projectRootDir, 'resources.d.ts');
258
+ return waitForTypesFile(typesPath, types, 1000);
259
+ }
260
+ console.warn(
261
+ "waitForTypesFile didn't wait for anything, projectRootDir is not set"
262
+ );
263
+ return '';
264
+ },
265
+ instance: instance
266
+ ? {
267
+ lastFrame: instance.lastFrame,
268
+ rerender: instance.rerender,
269
+ unmount: instance.unmount,
270
+ frames: instance.frames,
271
+ stdin: instance.stdin,
272
+ stdout: instance.stdout,
273
+ stderr: instance.stderr,
274
+ }
275
+ : {
276
+ lastFrame: () => undefined,
277
+ rerender: () => {},
278
+ unmount: () => {},
279
+ frames: [],
280
+ stdin: { write: () => {} },
281
+ stdout: { lastFrame: () => undefined, frames: [] },
282
+ stderr: { lastFrame: () => undefined, frames: [] },
283
+ },
284
+ };
285
+ }
286
+
287
+ // Helper function to test CLI commands with ink-testing-library
288
+ async function runCli(
289
+ argv: string[],
290
+ options: {
291
+ server?: PositronicDevServer;
292
+ configDir?: string;
293
+ } = {}
294
+ ): Promise<ReturnType<typeof render> | null> {
295
+ let capturedElement: ReturnType<typeof render> | null = null;
296
+ const { configDir, server } = options;
297
+ const mockRenderFn = (element: React.ReactElement) => {
298
+ capturedElement = render(element);
299
+ return capturedElement;
300
+ };
301
+
302
+ // Setup project-specific environment if configDir is provided
303
+ if (configDir) {
304
+ process.env.POSITRONIC_CONFIG_DIR = configDir;
305
+ }
306
+
307
+ try {
308
+ const testCli = buildCli({
309
+ argv,
310
+ server,
311
+ exitProcess: false,
312
+ render: mockRenderFn,
313
+ });
314
+
315
+ await testCli.parse();
316
+
317
+ return capturedElement;
318
+ } finally {
319
+ // Cleanup project-specific environment if configDir was provided
320
+ if (configDir) {
321
+ delete process.env.POSITRONIC_CONFIG_DIR;
322
+ }
323
+ }
324
+ }