@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,870 @@
1
+ import process from 'process';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+ import caz from 'caz';
6
+ import { type ResourceEntry } from '@positronic/core';
7
+ import { isText } from 'istextorbinary';
8
+ import * as http from 'http';
9
+ import * as https from 'https';
10
+ import { URL } from 'url';
11
+ import { createMinimalProject } from './test-utils.js';
12
+
13
+ // Progress callback types
14
+ export interface ProgressInfo {
15
+ loaded: number;
16
+ total: number;
17
+ percentage: number;
18
+ }
19
+
20
+ export type ProgressCallback = (progress: ProgressInfo) => void;
21
+
22
+ // Type for the API client
23
+ export type ApiClient = typeof apiClient;
24
+
25
+ // Singleton API client instance
26
+ export const apiClient = {
27
+ fetch: async (apiPath: string, options?: RequestInit): Promise<Response> => {
28
+ const port = process.env.POSITRONIC_PORT || '8787';
29
+ const baseUrl = `http://localhost:${port}`;
30
+ const fullUrl = `${baseUrl}${
31
+ apiPath.startsWith('/') ? apiPath : '/' + apiPath
32
+ }`;
33
+
34
+ return fetch(fullUrl, options);
35
+ },
36
+ };
37
+
38
+ export async function generateProject(projectName: string, projectDir: string) {
39
+ if (process.env.NODE_ENV === 'test') {
40
+ await createMinimalProject(projectDir);
41
+ return;
42
+ }
43
+
44
+ const devPath = process.env.POSITRONIC_LOCAL_PATH;
45
+ let newProjectTemplatePath = '@positronic/template-new-project';
46
+ let cazOptions: {
47
+ name: string;
48
+ backend?: string;
49
+ install?: boolean;
50
+ pm?: string;
51
+ } = { name: projectName };
52
+
53
+ try {
54
+ if (devPath) {
55
+ // Copying templates, why you ask?
56
+ // Well because when caz runs if you pass it a path to the template module
57
+ // (e.g. for development environment setting POSITRONIC_LOCAL_PATH)
58
+ // it runs npm install --production in the template directory. This is a problem
59
+ // in our monorepo because this messes up the node_modules at the root of the
60
+ // monorepo which then causes the tests to fail. Also ny time I was generating a new
61
+ // project it was a pain to have to run npm install over and over again just
62
+ // to get back to a good state.
63
+ const originalNewProjectPkg = path.resolve(
64
+ devPath,
65
+ 'packages',
66
+ 'template-new-project'
67
+ );
68
+ const copiedNewProjectPkg = fs.mkdtempSync(
69
+ path.join(os.tmpdir(), 'positronic-newproj-')
70
+ );
71
+ fs.cpSync(originalNewProjectPkg, copiedNewProjectPkg, {
72
+ recursive: true,
73
+ });
74
+ newProjectTemplatePath = copiedNewProjectPkg;
75
+ cazOptions = {
76
+ name: projectName,
77
+ install: true,
78
+ pm: 'npm',
79
+ };
80
+ }
81
+
82
+ // In test or CI environments, skip interactive prompts and dependency installation
83
+ if (process.env.NODE_ENV === 'test') {
84
+ cazOptions = {
85
+ ...cazOptions,
86
+ backend: 'none',
87
+ install: false,
88
+ pm: 'npm',
89
+ };
90
+ }
91
+
92
+ await caz.default(newProjectTemplatePath, projectDir, {
93
+ ...cazOptions,
94
+ force: false,
95
+ });
96
+ } finally {
97
+ // Clean up the temporary copied new project package
98
+ if (devPath) {
99
+ fs.rmSync(newProjectTemplatePath, {
100
+ recursive: true,
101
+ force: true,
102
+ maxRetries: 3,
103
+ });
104
+ }
105
+ }
106
+ }
107
+
108
+ export function scanLocalResources(resourcesDir: string): ResourceEntry[] {
109
+ const localResources: ResourceEntry[] = [];
110
+
111
+ const scanDirectory = (dir: string, baseDir: string) => {
112
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
113
+
114
+ for (const entry of entries) {
115
+ const fullPath = path.join(dir, entry.name);
116
+
117
+ if (entry.isDirectory()) {
118
+ // Recursively scan subdirectories
119
+ scanDirectory(fullPath, baseDir);
120
+ } else if (entry.isFile()) {
121
+ // Calculate relative path from resources directory
122
+ const relativePath = path.relative(baseDir, fullPath);
123
+ // Use forward slashes for consistency across platforms
124
+ const key = relativePath.replace(/\\/g, '/');
125
+
126
+ // Determine file type using istextorbinary
127
+ // It checks both filename and content (first few bytes)
128
+ const type: ResourceEntry['type'] = isText(
129
+ entry.name,
130
+ fs.readFileSync(fullPath)
131
+ )
132
+ ? 'text'
133
+ : 'binary';
134
+
135
+ localResources.push({
136
+ key,
137
+ path: fullPath,
138
+ type,
139
+ });
140
+ }
141
+ }
142
+ };
143
+
144
+ scanDirectory(resourcesDir, resourcesDir);
145
+ return localResources;
146
+ }
147
+
148
+ // Extended ResourceEntry to add fields returned by the API
149
+ interface ApiResourceEntry extends ResourceEntry {
150
+ size: number;
151
+ lastModified: string;
152
+ local: boolean;
153
+ }
154
+
155
+ interface ResourcesListResponse {
156
+ resources: ApiResourceEntry[];
157
+ truncated: boolean;
158
+ count: number;
159
+ }
160
+
161
+ interface SyncResult {
162
+ uploadCount: number;
163
+ skipCount: number;
164
+ errorCount: number;
165
+ totalCount: number;
166
+ deleteCount: number;
167
+ errors: Array<{ file: string; message: string }>;
168
+ }
169
+
170
+ export type SyncProgressCallback = (progress: {
171
+ currentFile: string;
172
+ action: 'checking' | 'uploading' | 'deleting';
173
+ stats: Partial<SyncResult>;
174
+ }) => void;
175
+
176
+ /**
177
+ * Core resource sync logic without UI dependencies
178
+ */
179
+ export async function syncResources(
180
+ projectRootPath: string,
181
+ client: ApiClient = apiClient,
182
+ onProgress?: SyncProgressCallback
183
+ ): Promise<SyncResult> {
184
+ const resourcesDir = path.join(projectRootPath, 'resources');
185
+ // Ensure resources directory exists
186
+ if (!fs.existsSync(resourcesDir)) {
187
+ fs.mkdirSync(resourcesDir, { recursive: true });
188
+ }
189
+
190
+ const localResources = scanLocalResources(resourcesDir);
191
+ // Fetch server resources
192
+ const response = await client.fetch('/resources');
193
+ if (!response.ok) {
194
+ const errorText = await response.text();
195
+ throw new Error(
196
+ `Failed to fetch resources: ${response.status} ${errorText}`
197
+ );
198
+ }
199
+ const data = (await response.json()) as ResourcesListResponse;
200
+ const serverResourceMap = new Map(data.resources.map((r) => [r.key, r]));
201
+
202
+ // Create a set of local resource keys for easy lookup
203
+ const localResourceKeys = new Set(localResources.map((r) => r.key));
204
+
205
+ let uploadCount = 0;
206
+ let skipCount = 0;
207
+ let errorCount = 0;
208
+ let deleteCount = 0;
209
+ const errors: Array<{ file: string; message: string }> = [];
210
+ // First, handle uploads and updates
211
+ for (const resource of localResources) {
212
+ // Report progress for checking
213
+ if (onProgress) {
214
+ onProgress({
215
+ currentFile: resource.key,
216
+ action: 'checking',
217
+ stats: {
218
+ uploadCount,
219
+ skipCount,
220
+ errorCount,
221
+ totalCount: localResources.length,
222
+ deleteCount,
223
+ errors,
224
+ },
225
+ });
226
+ }
227
+
228
+ const fileStats = fs.statSync(resource.path);
229
+ const serverResource = serverResourceMap.get(resource.key);
230
+
231
+ // Check if we need to upload (new or modified)
232
+ let shouldUpload = !serverResource;
233
+
234
+ if (serverResource && serverResource.size !== fileStats.size) {
235
+ // Size mismatch indicates file has changed
236
+ shouldUpload = true;
237
+ } else if (serverResource) {
238
+ // For same-size files, check modification time if available
239
+ const localModTime = fileStats.mtime.toISOString();
240
+ if (localModTime > serverResource.lastModified) {
241
+ shouldUpload = true;
242
+ }
243
+ }
244
+
245
+ if (shouldUpload) {
246
+ try {
247
+ // Report progress for uploading
248
+ if (onProgress) {
249
+ onProgress({
250
+ currentFile: resource.key,
251
+ action: 'uploading',
252
+ stats: {
253
+ uploadCount,
254
+ skipCount,
255
+ errorCount,
256
+ totalCount: localResources.length,
257
+ deleteCount,
258
+ errors,
259
+ },
260
+ });
261
+ }
262
+
263
+ const fileContent = fs.readFileSync(resource.path);
264
+ const formData = new FormData();
265
+
266
+ formData.append(
267
+ 'file',
268
+ new Blob([fileContent]),
269
+ path.basename(resource.path)
270
+ );
271
+ formData.append('type', resource.type);
272
+ formData.append('path', resource.key);
273
+ formData.append('key', resource.key);
274
+ formData.append('local', 'true');
275
+
276
+ const uploadResponse = await client.fetch('/resources', {
277
+ method: 'POST',
278
+ body: formData,
279
+ });
280
+
281
+ if (!uploadResponse.ok) {
282
+ const errorText = await uploadResponse.text();
283
+ throw new Error(
284
+ `Upload failed: ${uploadResponse.status} ${errorText}`
285
+ );
286
+ }
287
+
288
+ uploadCount++;
289
+ } catch (error: any) {
290
+ errorCount++;
291
+ errors.push({
292
+ file: resource.key,
293
+ message: error.message || 'Unknown error',
294
+ });
295
+ }
296
+ } else {
297
+ skipCount++;
298
+ }
299
+ }
300
+
301
+ // Now handle deletions - only delete resources that were synced (local === true)
302
+ // and are no longer present in the local filesystem
303
+ for (const [key, serverResource] of serverResourceMap) {
304
+ // Only consider deleting if:
305
+ // 1. The resource was synced from local (local === true)
306
+ // 2. The resource no longer exists locally
307
+ if (serverResource.local && !localResourceKeys.has(key)) {
308
+ try {
309
+ // Report progress for deleting
310
+ if (onProgress) {
311
+ onProgress({
312
+ currentFile: key,
313
+ action: 'deleting',
314
+ stats: {
315
+ uploadCount,
316
+ skipCount,
317
+ errorCount,
318
+ totalCount: localResources.length,
319
+ deleteCount,
320
+ errors,
321
+ },
322
+ });
323
+ }
324
+
325
+ const deleteResponse = await client.fetch(
326
+ `/resources/${encodeURIComponent(key)}`,
327
+ {
328
+ method: 'DELETE',
329
+ }
330
+ );
331
+
332
+ if (!deleteResponse.ok && deleteResponse.status !== 404) {
333
+ const errorText = await deleteResponse.text();
334
+ throw new Error(
335
+ `Delete failed: ${deleteResponse.status} ${errorText}`
336
+ );
337
+ }
338
+
339
+ deleteCount++;
340
+ } catch (error: any) {
341
+ errorCount++;
342
+ errors.push({
343
+ file: key,
344
+ message: `Failed to delete: ${error.message || 'Unknown error'}`,
345
+ });
346
+ }
347
+ }
348
+ }
349
+
350
+ return {
351
+ uploadCount,
352
+ skipCount,
353
+ errorCount,
354
+ totalCount: localResources.length,
355
+ deleteCount,
356
+ errors,
357
+ };
358
+ }
359
+
360
+ /**
361
+ * Check if a string is a valid JavaScript identifier
362
+ */
363
+ function isValidJSIdentifier(name: string): boolean {
364
+ // Must start with letter, underscore, or dollar sign
365
+ // Can contain letters, digits, underscores, dollar signs
366
+ return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name);
367
+ }
368
+
369
+ // TypeScript AST-like structures for cleaner generation
370
+ interface TypeProperty {
371
+ name: string;
372
+ type: string | TypeObject;
373
+ }
374
+
375
+ interface TypeObject {
376
+ properties: TypeProperty[];
377
+ }
378
+
379
+ // Internal structure for building resource tree
380
+ interface ResourceNode {
381
+ type?: 'text' | 'binary';
382
+ fullName?: string; // Store the full filename for resources
383
+ children?: Record<string, ResourceNode>;
384
+ }
385
+
386
+ /**
387
+ * Build TypeScript structure from resource tree
388
+ */
389
+ function buildTypeStructure(node: ResourceNode): TypeProperty[] {
390
+ if (!node.children) return [];
391
+
392
+ const properties: TypeProperty[] = [];
393
+ const processedNames = new Set<string>();
394
+
395
+ for (const [name, child] of Object.entries(node.children)) {
396
+ if (processedNames.has(name)) continue;
397
+
398
+ if (child.type) {
399
+ // File resource
400
+ const resourceType =
401
+ child.type === 'text' ? 'TextResource' : 'BinaryResource';
402
+ properties.push({ name, type: resourceType });
403
+ processedNames.add(name);
404
+
405
+ if (child.fullName) {
406
+ processedNames.add(child.fullName);
407
+ }
408
+ } else if (child.children) {
409
+ // Directory with nested resources
410
+ const nestedProps = buildTypeStructure(child);
411
+ if (nestedProps.length > 0) {
412
+ properties.push({
413
+ name,
414
+ type: { properties: nestedProps },
415
+ });
416
+ processedNames.add(name);
417
+ }
418
+ }
419
+ }
420
+
421
+ return properties;
422
+ }
423
+
424
+ /**
425
+ * Render TypeScript from structure
426
+ */
427
+ function renderTypeScript(
428
+ properties: TypeProperty[],
429
+ indent: string = ' '
430
+ ): string {
431
+ return properties
432
+ .map((prop) => {
433
+ if (typeof prop.type === 'string') {
434
+ return `${indent}${prop.name}: ${prop.type};`;
435
+ } else {
436
+ const nestedContent = renderTypeScript(
437
+ prop.type.properties,
438
+ indent + ' '
439
+ );
440
+ return `${indent}${prop.name}: {\n${nestedContent}\n${indent}};`;
441
+ }
442
+ })
443
+ .join('\n');
444
+ }
445
+
446
+ /**
447
+ * Generate TypeScript declarations for resources
448
+ */
449
+ function generateResourceTypes(resources: ApiResourceEntry[]): string {
450
+ const root: ResourceNode = { children: {} };
451
+
452
+ // Build the tree structure
453
+ for (const resource of resources) {
454
+ const parts = resource.key.split('/');
455
+ let current = root;
456
+
457
+ for (let i = 0; i < parts.length; i++) {
458
+ const part = parts[i];
459
+ const isLeaf = i === parts.length - 1;
460
+
461
+ if (!current.children) {
462
+ current.children = {};
463
+ }
464
+
465
+ if (isLeaf) {
466
+ const resourceNode: ResourceNode = {
467
+ type: resource.type,
468
+ fullName: part,
469
+ };
470
+
471
+ if (isValidJSIdentifier(part)) {
472
+ current.children[part] = resourceNode;
473
+ }
474
+
475
+ const withoutExt = part.replace(/\.[^/.]+$/, '');
476
+ if (withoutExt !== part && isValidJSIdentifier(withoutExt)) {
477
+ if (!current.children[withoutExt]) {
478
+ current.children[withoutExt] = resourceNode;
479
+ }
480
+ }
481
+ } else {
482
+ if (isValidJSIdentifier(part)) {
483
+ if (!current.children[part]) {
484
+ current.children[part] = { children: {} };
485
+ }
486
+ current = current.children[part];
487
+ } else {
488
+ break;
489
+ }
490
+ }
491
+ }
492
+ }
493
+
494
+ const typeStructure = buildTypeStructure(root);
495
+ const interfaceContent = renderTypeScript(typeStructure);
496
+
497
+ return `// Generated by Positronic CLI
498
+ // This file provides TypeScript types for your resources
499
+
500
+ declare module '@positronic/core' {
501
+ interface TextResource {
502
+ load(): Promise<string>;
503
+ }
504
+
505
+ interface BinaryResource {
506
+ load(): Promise<Buffer>;
507
+ }
508
+
509
+ interface Resources {
510
+ // Method signatures for loading resources by path
511
+ loadText(path: string): Promise<string>;
512
+ loadBinary(path: string): Promise<Buffer>;
513
+
514
+ // Resource properties accessible via dot notation
515
+ ${interfaceContent}
516
+ }
517
+ }
518
+
519
+ export {}; // Make this a module
520
+ `;
521
+ }
522
+
523
+ /**
524
+ * Core type generation logic without UI dependencies
525
+ */
526
+ export async function generateTypes(
527
+ projectRootPath: string,
528
+ client: ApiClient = apiClient
529
+ ) {
530
+ const typesFilePath = path.join(projectRootPath, 'resources.d.ts');
531
+
532
+ // Fetch resources from the API
533
+ const response = await client.fetch('/resources');
534
+
535
+ if (!response.ok) {
536
+ const errorText = await response.text();
537
+ throw new Error(
538
+ `Failed to fetch resources: ${response.status} ${errorText}`
539
+ );
540
+ }
541
+
542
+ const data = (await response.json()) as ResourcesListResponse;
543
+ const typeDefinitions = generateResourceTypes(data.resources);
544
+
545
+ fs.writeFileSync(typesFilePath, typeDefinitions, 'utf-8');
546
+
547
+ return path.relative(process.cwd(), typesFilePath);
548
+ }
549
+
550
+ /**
551
+ * Wait for resources to stabilize (indicating initial sync is complete)
552
+ */
553
+ async function waitForResources(
554
+ maxWaitMs: number,
555
+ startTime: number
556
+ ): Promise<boolean> {
557
+ let lastCount = -1;
558
+ let stableCount = 0;
559
+
560
+ while (Date.now() - startTime < maxWaitMs) {
561
+ try {
562
+ const response = await apiClient.fetch(`/resources`);
563
+ if (response.ok) {
564
+ const data = (await response.json()) as { count: number };
565
+
566
+ // If the count hasn't changed for 3 checks, we consider it stable
567
+ if (data.count === lastCount) {
568
+ stableCount++;
569
+ if (stableCount >= 3) {
570
+ return true; // Resources are stable
571
+ }
572
+ } else {
573
+ lastCount = data.count;
574
+ stableCount = 0;
575
+ }
576
+ }
577
+ } catch (e) {
578
+ // Server might still be initializing
579
+ }
580
+ await new Promise((resolve) => setTimeout(resolve, 100));
581
+ }
582
+
583
+ return false;
584
+ }
585
+
586
+ /**
587
+ * Helper function to wait for server to be ready
588
+ */
589
+ export async function waitUntilReady(
590
+ port?: number,
591
+ maxWaitMs = 5000
592
+ ): Promise<boolean> {
593
+ const serverPort = port || Number(process.env.POSITRONIC_PORT) || 8787;
594
+ const startTime = Date.now();
595
+
596
+ // First wait for basic HTTP readiness
597
+ while (Date.now() - startTime < maxWaitMs) {
598
+ try {
599
+ const response = await apiClient.fetch(`/status`);
600
+ if (response.ok) {
601
+ const status = (await response.json()) as { ready: boolean };
602
+ if (status.ready) {
603
+ break; // HTTP server is ready
604
+ }
605
+ }
606
+ } catch (e: any) {
607
+ // Server not ready yet - connection refused is expected
608
+ }
609
+ await new Promise((resolve) => setTimeout(resolve, 100));
610
+ }
611
+
612
+ // Now wait for resources to stabilize (indicating initial sync is done)
613
+ return waitForResources(maxWaitMs, startTime);
614
+ }
615
+
616
+ /**
617
+ * Upload a file using presigned URL for large files
618
+ * This function is used by the `px resources upload` command to upload files
619
+ * directly to R2 storage, bypassing the 100MB Worker limit.
620
+ *
621
+ * @param filePath - Path to the file to upload
622
+ * @param customKey - The key (path) where the file will be stored in R2
623
+ * @param client - Optional API client (defaults to singleton)
624
+ * @param onProgress - Optional progress callback
625
+ * @param signal - Optional AbortSignal for cancellation
626
+ */
627
+ export async function uploadFileWithPresignedUrl(
628
+ filePath: string,
629
+ customKey: string,
630
+ client: ApiClient = apiClient,
631
+ onProgress?: ProgressCallback,
632
+ signal?: AbortSignal
633
+ ): Promise<void> {
634
+ // Get file info
635
+ const stats = fs.statSync(filePath);
636
+ const fileName = path.basename(filePath);
637
+
638
+ // For small files, read to determine type
639
+ // For large files, use extension-based detection to avoid reading entire file
640
+ let fileType: 'text' | 'binary';
641
+ if (stats.size < 1024 * 1024) {
642
+ // 1MB
643
+ const fileContent = fs.readFileSync(filePath);
644
+ fileType = isText(fileName, fileContent) ? 'text' : 'binary';
645
+ } else {
646
+ // For large files, use extension-based detection
647
+ fileType = isText(fileName) ? 'text' : 'binary';
648
+ }
649
+
650
+ // Request presigned URL
651
+ const presignedResponse = await client.fetch('/resources/presigned-link', {
652
+ method: 'POST',
653
+ headers: {
654
+ 'Content-Type': 'application/json',
655
+ },
656
+ body: JSON.stringify({
657
+ key: customKey,
658
+ type: fileType,
659
+ size: stats.size,
660
+ }),
661
+ });
662
+
663
+ if (!presignedResponse.ok) {
664
+ const errorText = await presignedResponse.text();
665
+ let errorMessage = `Failed to get presigned URL: ${presignedResponse.status}`;
666
+
667
+ // Parse error response if it's JSON
668
+ try {
669
+ const errorData = JSON.parse(errorText);
670
+ if (errorData.error) {
671
+ errorMessage = errorData.error;
672
+ }
673
+ } catch {
674
+ errorMessage += ` ${errorText}`;
675
+ }
676
+
677
+ throw new Error(errorMessage);
678
+ }
679
+
680
+ const { url, method } = (await presignedResponse.json()) as {
681
+ url: string;
682
+ method: string;
683
+ expiresIn: number;
684
+ };
685
+
686
+ // Headers that must be included in the upload to match the presigned URL signature
687
+ const requiredHeaders = {
688
+ 'x-amz-meta-type': fileType,
689
+ 'x-amz-meta-local': 'false',
690
+ };
691
+
692
+ // For large files, use streaming with progress
693
+ if (stats.size > 10 * 1024 * 1024) {
694
+ // 10MB
695
+ await uploadLargeFileWithProgress(
696
+ filePath,
697
+ url,
698
+ method,
699
+ stats.size,
700
+ requiredHeaders,
701
+ onProgress,
702
+ signal
703
+ );
704
+ } else {
705
+ // For small files, use simple upload
706
+ const fileContent = fs.readFileSync(filePath);
707
+
708
+ // Use native http/https for the upload
709
+ const parsedUrl = new URL(url);
710
+ const httpModule = parsedUrl.protocol === 'https:' ? https : http;
711
+
712
+ const uploadResponse = await new Promise<Response>((resolve, reject) => {
713
+ const requestOptions: http.RequestOptions = {
714
+ hostname: parsedUrl.hostname,
715
+ port: parseInt(
716
+ parsedUrl.port || (parsedUrl.protocol === 'https:' ? '443' : '80'),
717
+ 10
718
+ ),
719
+ path: parsedUrl.pathname + parsedUrl.search,
720
+ method: method,
721
+ headers: {
722
+ ...requiredHeaders,
723
+ 'Content-Length': fileContent.length.toString(),
724
+ },
725
+ };
726
+
727
+ const req = httpModule.request(requestOptions, (res) => {
728
+ const chunks: Buffer[] = [];
729
+
730
+ res.on('data', (chunk: Buffer) => {
731
+ chunks.push(chunk);
732
+ });
733
+
734
+ res.on('end', () => {
735
+ const responseBody = Buffer.concat(chunks);
736
+ resolve(
737
+ new Response(responseBody, {
738
+ status: res.statusCode,
739
+ statusText: res.statusMessage,
740
+ headers: Object.fromEntries(
741
+ Object.entries(res.headers).map(([key, value]) => [
742
+ key,
743
+ Array.isArray(value) ? value.join(', ') : value || '',
744
+ ])
745
+ ),
746
+ })
747
+ );
748
+ });
749
+
750
+ res.on('error', reject);
751
+ });
752
+
753
+ req.on('error', reject);
754
+
755
+ if (signal) {
756
+ signal.addEventListener('abort', () => {
757
+ req.destroy();
758
+ reject(new Error('AbortError'));
759
+ });
760
+ }
761
+
762
+ req.write(fileContent);
763
+ req.end();
764
+ });
765
+
766
+ if (!uploadResponse.ok) {
767
+ throw new Error(
768
+ `Failed to upload file: ${uploadResponse.status} ${uploadResponse.statusText}`
769
+ );
770
+ }
771
+ }
772
+ }
773
+
774
+ /**
775
+ * Stream upload large file with progress tracking
776
+ * Uses Node.js https module for true streaming without loading entire file into memory
777
+ */
778
+ async function uploadLargeFileWithProgress(
779
+ filePath: string,
780
+ url: string,
781
+ method: string,
782
+ totalSize: number,
783
+ requiredHeaders: Record<string, string>,
784
+ onProgress?: ProgressCallback,
785
+ signal?: AbortSignal
786
+ ): Promise<void> {
787
+ const parsedUrl = new URL(url);
788
+ const httpModule = parsedUrl.protocol === 'https:' ? https : http;
789
+
790
+ return new Promise<void>((resolve, reject) => {
791
+ // Abort handler
792
+ const onAbort = () => {
793
+ req.destroy();
794
+ reject(new Error('AbortError'));
795
+ };
796
+
797
+ if (signal) {
798
+ signal.addEventListener('abort', onAbort);
799
+ }
800
+
801
+ const options: https.RequestOptions = {
802
+ hostname: parsedUrl.hostname,
803
+ port: parsedUrl.port ? parseInt(parsedUrl.port, 10) : undefined,
804
+ path: parsedUrl.pathname + parsedUrl.search,
805
+ method: method,
806
+ headers: {
807
+ ...requiredHeaders,
808
+ 'Content-Length': totalSize.toString(), // R2 requires Content-Length
809
+ },
810
+ };
811
+
812
+ const req = httpModule.request(options, (res) => {
813
+ let responseBody = '';
814
+
815
+ res.on('data', (chunk) => {
816
+ responseBody += chunk.toString();
817
+ });
818
+
819
+ res.on('end', () => {
820
+ if (signal) {
821
+ signal.removeEventListener('abort', onAbort);
822
+ }
823
+
824
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
825
+ resolve();
826
+ } else {
827
+ reject(
828
+ new Error(
829
+ `Failed to upload file: ${res.statusCode} ${res.statusMessage}. Response: ${responseBody}`
830
+ )
831
+ );
832
+ }
833
+ });
834
+ });
835
+
836
+ req.on('error', (err: any) => {
837
+ if (signal) {
838
+ signal.removeEventListener('abort', onAbort);
839
+ }
840
+ reject(err);
841
+ });
842
+
843
+ // Stream the file directly to the request
844
+ const fileStream = fs.createReadStream(filePath);
845
+ let uploadedBytes = 0;
846
+
847
+ fileStream.on('data', (chunk) => {
848
+ const chunkLength = Buffer.isBuffer(chunk)
849
+ ? chunk.length
850
+ : Buffer.byteLength(chunk);
851
+ uploadedBytes += chunkLength;
852
+
853
+ if (onProgress) {
854
+ onProgress({
855
+ loaded: uploadedBytes,
856
+ total: totalSize,
857
+ percentage: Math.round((uploadedBytes / totalSize) * 100),
858
+ });
859
+ }
860
+ });
861
+
862
+ fileStream.on('error', (err) => {
863
+ req.destroy();
864
+ reject(err);
865
+ });
866
+
867
+ // Pipe the file stream to the request
868
+ fileStream.pipe(req);
869
+ });
870
+ }