@positronic/cli 0.0.3 → 0.0.4
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.
- package/dist/src/commands/helpers.js +11 -25
- package/dist/types/commands/helpers.d.ts.map +1 -1
- package/package.json +5 -1
- package/dist/src/commands/brain.test.js +0 -2936
- package/dist/src/commands/helpers.test.js +0 -832
- package/dist/src/commands/project.test.js +0 -1201
- package/dist/src/commands/resources.test.js +0 -2511
- package/dist/src/commands/schedule.test.js +0 -1235
- package/dist/src/commands/secret.test.d.js +0 -1
- package/dist/src/commands/secret.test.js +0 -761
- package/dist/src/commands/server.test.js +0 -1237
- package/dist/src/commands/test-utils.js +0 -737
- package/dist/src/components/secret-sync.js +0 -303
- package/dist/src/test/mock-api-client.js +0 -371
- package/dist/src/test/test-dev-server.js +0 -1376
- package/dist/types/commands/test-utils.d.ts +0 -45
- package/dist/types/commands/test-utils.d.ts.map +0 -1
- package/dist/types/components/secret-sync.d.ts +0 -9
- package/dist/types/components/secret-sync.d.ts.map +0 -1
- package/dist/types/test/mock-api-client.d.ts +0 -25
- package/dist/types/test/mock-api-client.d.ts.map +0 -1
- package/dist/types/test/test-dev-server.d.ts +0 -129
- package/dist/types/test/test-dev-server.d.ts.map +0 -1
- package/src/cli.ts +0 -997
- package/src/commands/backend.ts +0 -63
- package/src/commands/brain.test.ts +0 -1004
- package/src/commands/brain.ts +0 -215
- package/src/commands/helpers.test.ts +0 -487
- package/src/commands/helpers.ts +0 -870
- package/src/commands/project-config-manager.ts +0 -152
- package/src/commands/project.test.ts +0 -502
- package/src/commands/project.ts +0 -109
- package/src/commands/resources.test.ts +0 -1052
- package/src/commands/resources.ts +0 -97
- package/src/commands/schedule.test.ts +0 -481
- package/src/commands/schedule.ts +0 -65
- package/src/commands/secret.test.ts +0 -210
- package/src/commands/secret.ts +0 -50
- package/src/commands/server.test.ts +0 -493
- package/src/commands/server.ts +0 -353
- package/src/commands/test-utils.ts +0 -324
- package/src/components/brain-history.tsx +0 -198
- package/src/components/brain-list.tsx +0 -105
- package/src/components/brain-rerun.tsx +0 -111
- package/src/components/brain-show.tsx +0 -92
- package/src/components/error.tsx +0 -24
- package/src/components/project-add.tsx +0 -59
- package/src/components/project-create.tsx +0 -83
- package/src/components/project-list.tsx +0 -83
- package/src/components/project-remove.tsx +0 -55
- package/src/components/project-select.tsx +0 -200
- package/src/components/project-show.tsx +0 -58
- package/src/components/resource-clear.tsx +0 -127
- package/src/components/resource-delete.tsx +0 -160
- package/src/components/resource-list.tsx +0 -177
- package/src/components/resource-sync.tsx +0 -170
- package/src/components/resource-types.tsx +0 -55
- package/src/components/resource-upload.tsx +0 -182
- package/src/components/schedule-create.tsx +0 -90
- package/src/components/schedule-delete.tsx +0 -116
- package/src/components/schedule-list.tsx +0 -186
- package/src/components/schedule-runs.tsx +0 -151
- package/src/components/secret-bulk.tsx +0 -79
- package/src/components/secret-create.tsx +0 -49
- package/src/components/secret-delete.tsx +0 -41
- package/src/components/secret-list.tsx +0 -41
- package/src/components/watch.tsx +0 -155
- package/src/hooks/useApi.ts +0 -183
- package/src/positronic.ts +0 -40
- package/src/test/data/resources/config.json +0 -1
- package/src/test/data/resources/data/config.json +0 -1
- package/src/test/data/resources/data/logo.png +0 -2
- package/src/test/data/resources/docs/api.md +0 -3
- package/src/test/data/resources/docs/readme.md +0 -3
- package/src/test/data/resources/example.md +0 -3
- package/src/test/data/resources/file with spaces.txt +0 -1
- package/src/test/data/resources/readme.md +0 -3
- package/src/test/data/resources/test.txt +0 -1
- package/src/test/mock-api-client.ts +0 -145
- package/src/test/test-dev-server.ts +0 -1003
- package/tsconfig.json +0 -11
package/src/commands/helpers.ts
DELETED
|
@@ -1,870 +0,0 @@
|
|
|
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
|
-
}
|