@descix/cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +95 -0
  2. package/SERVICE_README_sdk.md +399 -0
  3. package/agent-assets/guides/README.md +61 -0
  4. package/agent-assets/guides/dynamic-site-pattern.md +125 -0
  5. package/agent-assets/guides/knowledge-only-pattern.md +93 -0
  6. package/agent-assets/guides/microservice-pattern.md +152 -0
  7. package/agent-assets/guides/static-site-pattern.md +111 -0
  8. package/agent-assets/instructions/app-asset-validation.md +477 -0
  9. package/agent-assets/instructions/onboarding-flow.md +408 -0
  10. package/agent-assets/instructions/standard-folder-structure.md +125 -0
  11. package/agent-assets/reference/app-creation.md +381 -0
  12. package/agent-assets/reference/architecture.md +406 -0
  13. package/agent-assets/reference/local-dev.md +138 -0
  14. package/agent-assets/reference/mcp-integration.md +424 -0
  15. package/agent-assets/reference/workspace-config.md +415 -0
  16. package/agent-assets/rules/descix_mcp.template.mdc +243 -0
  17. package/bin/descix.js +3930 -0
  18. package/bin/mcp-server.js +382 -0
  19. package/descix-cli-1.0.0.tgz +0 -0
  20. package/lib/agent-files.js +162 -0
  21. package/lib/api-client.js +458 -0
  22. package/lib/auth-guard.js +67 -0
  23. package/lib/commands/app-wizard.js +627 -0
  24. package/lib/commands/auth.js +407 -0
  25. package/lib/commands/buy.js +207 -0
  26. package/lib/commands/clone.js +91 -0
  27. package/lib/commands/config.js +155 -0
  28. package/lib/commands/doctor.js +277 -0
  29. package/lib/commands/folder.js +291 -0
  30. package/lib/commands/init.js +257 -0
  31. package/lib/commands/kb.js +654 -0
  32. package/lib/commands/mcp.js +273 -0
  33. package/lib/commands/serve.js +12 -0
  34. package/lib/commands/status.js +239 -0
  35. package/lib/commands/update.js +653 -0
  36. package/lib/core/Chunker.js +610 -0
  37. package/lib/core/Hydrator.js +801 -0
  38. package/lib/core/PathContext.js +335 -0
  39. package/lib/core/Syncer.js +310 -0
  40. package/lib/core/index.js +32 -0
  41. package/lib/global-config.js +86 -0
  42. package/lib/google-storage-adc.js +511 -0
  43. package/lib/wallet-file.js +232 -0
  44. package/lib/wizard/setup.js +502 -0
  45. package/lib/workspace-config.js +712 -0
  46. package/lib/workspace-utils.js +126 -0
  47. package/package.json +41 -0
  48. package/templates/agent-claude.md +226 -0
  49. package/templates/agent-cline.md +133 -0
  50. package/templates/agent-copilot.md +133 -0
  51. package/templates/agent-cursor.md +133 -0
  52. package/templates/descix_mcp.mdc +122 -0
  53. package/templates/drive/README.md +42 -0
  54. package/templates/drive/app/assets/app_description.md +13 -0
  55. package/templates/drive/app/assets/icon.png +0 -0
  56. package/templates/drive/app/assets/system_instructions.md +17 -0
  57. package/templates/drive/app/kb/General/README.md +25 -0
  58. package/templates/drive/community/Apps/.gitkeep +4 -0
  59. package/templates/drive/community/community_assets/community_description.md +15 -0
  60. package/templates/drive/community/community_assets/icon.png +0 -0
  61. package/templates/drive_templates/README.md +104 -0
  62. package/templates/drive_templates/agent_app_template/assets/app_description.txt +13 -0
  63. package/templates/drive_templates/agent_app_template/assets/system_instructions.txt +17 -0
  64. package/templates/drive_templates/agent_app_template/kb/General/README.md +25 -0
  65. package/templates/drive_templates/agent_app_template/microservice/.gitkeep +10 -0
  66. package/templates/drive_templates/agent_app_template/site/.gitkeep +4 -0
  67. package/templates/drive_templates/community_template/Apps/.gitkeep +4 -0
  68. package/templates/drive_templates/community_template/community_assets/community_description.txt +15 -0
  69. package/templates/scaffolds/README.md +62 -0
  70. package/templates/scaffolds/microservice/Dockerfile +19 -0
  71. package/templates/scaffolds/microservice/SERVICE_README_sdk.md +399 -0
  72. package/templates/scaffolds/microservice/app.js +50 -0
  73. package/templates/scaffolds/microservice/app.yaml +8 -0
  74. package/templates/scaffolds/microservice/defaults-config.json +5 -0
  75. package/templates/scaffolds/microservice/dev-overrides.json.example +5 -0
  76. package/templates/scaffolds/microservice/manifest.json +49 -0
  77. package/templates/scaffolds/microservice/package-lock.json +2645 -0
  78. package/templates/scaffolds/microservice/package.json +29 -0
  79. package/templates/scaffolds/microservice/scripts/register.js +55 -0
  80. package/templates/scaffolds/microservice/services/apiFront.js +75 -0
  81. package/templates/scaffolds/microservice/services/commandHandlers/sampleCommands.js +57 -0
  82. package/templates/scaffolds/microservice/services/mcpClient.js +78 -0
  83. package/templates/scaffolds/microservice/services/utils.js +247 -0
  84. package/templates/scaffolds/microservice/setup-schema.json +18 -0
  85. package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +143 -0
  86. package/templates/scaffolds/site/DeSciXAppSDK.js +62 -0
  87. package/templates/scaffolds/site/README.md +51 -0
  88. package/templates/scaffolds/site/app.js +22 -0
  89. package/templates/scaffolds/site/index.html +42 -0
  90. package/templates/scaffolds/site/styles.css +123 -0
  91. package/test-hydration.js +150 -0
  92. package/vendor/mcp/transports/http-sse-transport.js +196 -0
  93. package/vendor/mcp/transports/index.js +15 -0
  94. package/vendor/mcp/transports/stdio-transport.js +118 -0
@@ -0,0 +1,801 @@
1
+ /**
2
+ * Hydrator - Canonical module for all Drive sync operations
3
+ *
4
+ * Responsibilities:
5
+ * - Pull content from Drive to local filesystem
6
+ * - Push staging files to Drive
7
+ * - Convert files using Drive's native capabilities (no local tools)
8
+ * - Conflict detection and resolution
9
+ *
10
+ * Architecture:
11
+ * - Uses ADC (Application Default Credentials) for direct Drive access
12
+ * - Drive-Native processing: PDF/Image -> Google Doc -> Markdown export
13
+ * - Client-Side Mediation: No backend involvement for file transfers
14
+ */
15
+
16
+ import * as fs from 'fs/promises';
17
+ import * as path from 'path';
18
+ import { fileURLToPath } from 'url';
19
+ import { google } from 'googleapis';
20
+ import * as driveADC from '../google-storage-adc.js';
21
+
22
+ // Google Drive MIME type constants
23
+ const DRIVE_FOLDER_MIME = 'application/vnd.google-apps.folder';
24
+
25
+ // MIME types that can be converted to Google Docs for text extraction
26
+ const CONVERTIBLE_MIME_TYPES = [
27
+ 'application/pdf',
28
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // DOCX
29
+ 'application/msword', // DOC
30
+ 'image/png',
31
+ 'image/jpeg',
32
+ 'image/gif',
33
+ 'image/webp'
34
+ ];
35
+
36
+ // Google Workspace MIME types that export directly
37
+ const GOOGLE_WORKSPACE_EXPORTS = {
38
+ 'application/vnd.google-apps.document': { mimeType: 'text/markdown', extension: '.md' },
39
+ 'application/vnd.google-apps.spreadsheet': { mimeType: 'text/csv', extension: '.csv' },
40
+ 'application/vnd.google-apps.presentation': { mimeType: 'text/plain', extension: '.txt' }
41
+ };
42
+
43
+ // Standard app folders that contain syncable content
44
+ const SYNCABLE_FOLDERS = ['assets', 'kb', 'site', 'microservice'];
45
+
46
+ /**
47
+ * Get ADC-based authentication for Google APIs
48
+ */
49
+ async function getADCAuth() {
50
+ const auth = new google.auth.GoogleAuth({
51
+ scopes: [
52
+ 'https://www.googleapis.com/auth/drive',
53
+ 'https://www.googleapis.com/auth/drive.file'
54
+ ]
55
+ });
56
+ return auth;
57
+ }
58
+
59
+ /**
60
+ * Convert a file to Google Doc and export as markdown
61
+ * This is the "Drive-Native" processing pattern:
62
+ * - PDF/DOCX/Images are copied with convert=true to create a Google Doc
63
+ * - Google performs OCR and text extraction
64
+ * - Export as Markdown for local storage
65
+ *
66
+ * @param {string} fileId - Drive file ID to convert
67
+ * @returns {Promise<string>} Markdown content
68
+ */
69
+ async function convertAndExportAsMarkdown(fileId) {
70
+ const auth = await getADCAuth();
71
+ const drive = google.drive({ version: 'v3', auth });
72
+
73
+ // Copy file with conversion to Google Doc
74
+ const copy = await drive.files.copy({
75
+ fileId,
76
+ requestBody: {
77
+ mimeType: 'application/vnd.google-apps.document',
78
+ name: 'Temp Converted Doc'
79
+ },
80
+ supportsAllDrives: true
81
+ });
82
+
83
+ const convertedDocId = copy.data.id;
84
+
85
+ try {
86
+ // Export as Markdown
87
+ const res = await drive.files.export({
88
+ fileId: convertedDocId,
89
+ mimeType: 'text/markdown'
90
+ }, { responseType: 'text' });
91
+
92
+ return res.data;
93
+ } finally {
94
+ // Clean up temporary document
95
+ try {
96
+ await drive.files.delete({ fileId: convertedDocId, supportsAllDrives: true });
97
+ } catch {
98
+ // Ignore cleanup errors
99
+ }
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Download and convert a single file to text format
105
+ *
106
+ * @param {Object} driveFile - { id, name, mimeType } from Drive
107
+ * @param {string} localDir - Local directory to save to
108
+ * @returns {Promise<{localPath: string, converted: boolean}>}
109
+ */
110
+ async function convertAndSave(driveFile, localDir) {
111
+ const auth = await getADCAuth();
112
+ const drive = google.drive({ version: 'v3', auth });
113
+
114
+ const { id, name, mimeType } = driveFile;
115
+
116
+ // Determine export strategy based on MIME type
117
+ const googleExport = GOOGLE_WORKSPACE_EXPORTS[mimeType];
118
+ const isConvertible = CONVERTIBLE_MIME_TYPES.includes(mimeType);
119
+
120
+ let content;
121
+ let targetName;
122
+ let converted = false;
123
+
124
+ if (googleExport) {
125
+ // Google Workspace file - export directly
126
+ const res = await drive.files.export({
127
+ fileId: id,
128
+ mimeType: googleExport.mimeType
129
+ }, { responseType: 'text' });
130
+
131
+ content = res.data;
132
+ targetName = name.replace(/\.[^.]+$/, '') + googleExport.extension;
133
+ converted = true;
134
+ } else if (isConvertible) {
135
+ // PDF/DOCX/Image - convert via Google Doc
136
+ content = await convertAndExportAsMarkdown(id);
137
+ targetName = name.replace(/\.[^.]+$/, '') + '.md';
138
+ converted = true;
139
+ } else {
140
+ // Regular file - download as-is
141
+ const res = await drive.files.get(
142
+ { fileId: id, alt: 'media' },
143
+ { responseType: 'text' }
144
+ );
145
+ content = res.data;
146
+ targetName = name;
147
+ converted = false;
148
+ }
149
+
150
+ // Write to local filesystem
151
+ const localPath = path.join(localDir, targetName);
152
+ await fs.mkdir(path.dirname(localPath), { recursive: true });
153
+ await fs.writeFile(localPath, content, 'utf-8');
154
+
155
+ return { localPath, converted };
156
+ }
157
+
158
+ /**
159
+ * Check if a directory has any content (files or subdirectories with files)
160
+ * @param {string} dirPath - Directory path to check
161
+ * @returns {Promise<boolean>}
162
+ */
163
+ async function hasContent(dirPath) {
164
+ try {
165
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
166
+
167
+ for (const entry of entries) {
168
+ if (entry.isFile()) {
169
+ return true;
170
+ }
171
+ if (entry.isDirectory()) {
172
+ const hasSubContent = await hasContent(path.join(dirPath, entry.name));
173
+ if (hasSubContent) return true;
174
+ }
175
+ }
176
+ return false;
177
+ } catch {
178
+ return false;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Recursively pull app content from Drive to local filesystem
184
+ * Handles Google Workspace files and convertible files (PDF, DOCX, images)
185
+ *
186
+ * @param {string} localPath - Local directory path
187
+ * @param {string} driveFolderId - Drive folder ID to pull from
188
+ * @param {Object} options - { mergeMode: 'overwrite'|'merge', verbose: boolean }
189
+ * @returns {Promise<{downloaded: number, skipped: number, converted: number}>}
190
+ */
191
+ async function pullFolder(localPath, driveFolderId, options = {}) {
192
+ const { mergeMode = 'overwrite', verbose = false } = options;
193
+ const stats = { downloaded: 0, skipped: 0, converted: 0 };
194
+
195
+ await fs.mkdir(localPath, { recursive: true });
196
+
197
+ const driveFiles = await driveADC.listFolderWithMeta(driveFolderId);
198
+
199
+ for (const file of driveFiles) {
200
+ const targetPath = path.join(localPath, file.name);
201
+
202
+ if (file.mimeType === DRIVE_FOLDER_MIME) {
203
+ // Recurse into subfolders
204
+ const subStats = await pullFolder(targetPath, file.id, options);
205
+ stats.downloaded += subStats.downloaded;
206
+ stats.skipped += subStats.skipped;
207
+ stats.converted += subStats.converted;
208
+ } else {
209
+ // Check if file exists locally
210
+ let localExists = false;
211
+ try {
212
+ await fs.access(targetPath);
213
+ localExists = true;
214
+ } catch {
215
+ localExists = false;
216
+ }
217
+
218
+ if (localExists && mergeMode === 'merge') {
219
+ stats.skipped++;
220
+ if (verbose) console.log(` Skipped: ${file.name} (local exists)`);
221
+ } else {
222
+ // Download/convert the file
223
+ const result = await driveADC.downloadFileFromDrive(file.id, targetPath, file.mimeType);
224
+ stats.downloaded++;
225
+ if (result.exported) stats.converted++;
226
+ if (verbose) console.log(` Downloaded: ${file.name}${result.exported ? ' (converted)' : ''}`);
227
+ }
228
+ }
229
+ }
230
+
231
+ return stats;
232
+ }
233
+
234
+ /**
235
+ * Hydrate a specific Knowledge Base from Drive
236
+ *
237
+ * Folder Structure Created:
238
+ * - kb/staging/ - Local originals to push to Drive
239
+ * - kb/General/ - Text-only mirror of Drive (auto-converted)
240
+ * - kb/chunks/ - Processed JSON chunks
241
+ *
242
+ * Enhanced with:
243
+ * - Merge mode options (merge, overwrite, force-overwrite)
244
+ * - Hash-based incremental sync (skip unchanged files)
245
+ * - Never deletes local files not in Drive
246
+ *
247
+ * @param {Object} config - { workspaceRoot, communityId, appId, kbId, baseFolderId, localPath }
248
+ * @param {Object} options - Hydration options
249
+ * @param {boolean} options.verbose - Enable verbose output
250
+ * @param {string} options.mergeMode - 'merge' | 'overwrite' | 'force-overwrite' (default: 'merge')
251
+ * @param {boolean} options.skipUnchanged - Skip files where hash matches (default: true)
252
+ * @param {boolean} options.dryRun - Show what would happen without making changes
253
+ * @param {function} options.onProgress - Progress callback
254
+ * @returns {Promise<{pulled: number, converted: number, skipped: number, unchanged: number}>}
255
+ */
256
+ export async function hydrateKb(config, options = {}) {
257
+ const { workspaceRoot, communityId, appId, kbId = 'General', baseFolderId, localPath } = config;
258
+ const {
259
+ verbose = false,
260
+ mergeMode = 'merge',
261
+ skipUnchanged = true,
262
+ dryRun = false,
263
+ onProgress = null
264
+ } = options;
265
+
266
+ // Verify ADC auth
267
+ await driveADC.verifyDriveAuth();
268
+
269
+ // Navigate to KB folder using template path
270
+ const kbPath = `${communityId}/${appId}/kb/${kbId}`;
271
+ const kbDriveFolderId = await driveADC.findFolderByPath(baseFolderId, kbPath);
272
+
273
+ if (!kbDriveFolderId) {
274
+ throw new Error(
275
+ `KB folder not found at path: ${kbPath}\n` +
276
+ 'Ensure the app was created from template and the KB folder exists in Drive.'
277
+ );
278
+ }
279
+
280
+ // Prepare local directories with new structure
281
+ const appLocalPath = localPath || `${communityId}/${appId}`;
282
+ const kbBaseDir = path.join(workspaceRoot, appLocalPath, 'kb');
283
+ const kbTargetDir = path.join(kbBaseDir, kbId); // kb/General
284
+ const stagingDir = path.join(kbBaseDir, 'staging');
285
+ const chunksDir = path.join(kbBaseDir, 'chunks');
286
+
287
+ if (!dryRun) {
288
+ // Create all directories
289
+ await fs.mkdir(kbTargetDir, { recursive: true });
290
+ await fs.mkdir(stagingDir, { recursive: true });
291
+ await fs.mkdir(chunksDir, { recursive: true });
292
+ }
293
+
294
+ // Migration: if old kb/src/ exists, rename to kb/staging/
295
+ if (!dryRun) {
296
+ const oldSrcDir = path.join(kbBaseDir, 'src');
297
+ try {
298
+ const oldSrcExists = await fs.stat(oldSrcDir).then(() => true).catch(() => false);
299
+ if (oldSrcExists) {
300
+ const srcContents = await fs.readdir(oldSrcDir);
301
+ for (const item of srcContents) {
302
+ const srcPath = path.join(oldSrcDir, item);
303
+ const destPath = path.join(stagingDir, item);
304
+ await fs.rename(srcPath, destPath);
305
+ }
306
+ await fs.rmdir(oldSrcDir);
307
+ if (verbose) console.log(' Migrated kb/src/ to kb/staging/');
308
+ }
309
+ } catch {
310
+ // Ignore migration errors
311
+ }
312
+ }
313
+
314
+ // Load existing metadata for hash comparison
315
+ const metadataPath = path.join(kbTargetDir, '.descix_metadata.json');
316
+ let existingMetadata = { files: {} };
317
+ try {
318
+ const metaContent = await fs.readFile(metadataPath, 'utf-8');
319
+ existingMetadata = JSON.parse(metaContent);
320
+ } catch {
321
+ // No existing metadata
322
+ }
323
+
324
+ // List files in KB folder
325
+ const driveFiles = await driveADC.listFolderWithMeta(kbDriveFolderId);
326
+
327
+ if (driveFiles.length === 0) {
328
+ return { pulled: 0, converted: 0, skipped: 0, unchanged: 0 };
329
+ }
330
+
331
+ // Download and convert files
332
+ const stats = { pulled: 0, converted: 0, skipped: 0, unchanged: 0 };
333
+ const metadata = {
334
+ version: '2.0',
335
+ last_pull: new Date().toISOString(),
336
+ source_kb: kbId,
337
+ merge_mode: mergeMode,
338
+ files: { ...existingMetadata.files } // Preserve existing local-only file metadata
339
+ };
340
+
341
+ for (const file of driveFiles) {
342
+ if (file.mimeType === DRIVE_FOLDER_MIME) continue;
343
+ if (file.name.startsWith('.')) continue;
344
+
345
+ if (onProgress) onProgress(`Processing ${file.name}...`);
346
+
347
+ try {
348
+ // Determine output filename (converted files get .md extension)
349
+ const outputName = getConvertedFilename(file.name, file.mimeType);
350
+ const localFilePath = path.join(kbTargetDir, outputName);
351
+
352
+ // Check if local file exists
353
+ let localExists = false;
354
+ let localHash = null;
355
+ try {
356
+ await fs.access(localFilePath);
357
+ localExists = true;
358
+ if (skipUnchanged) {
359
+ localHash = await driveADC.computeFileHash(localFilePath);
360
+ }
361
+ } catch {
362
+ // File doesn't exist locally
363
+ }
364
+
365
+ // Determine if we should download based on merge mode
366
+ let shouldDownload = true;
367
+ let reason = '';
368
+
369
+ if (localExists) {
370
+ const existingMeta = existingMetadata.files?.[outputName];
371
+
372
+ if (mergeMode === 'merge') {
373
+ // Merge mode: keep local files, only add new from Drive
374
+ shouldDownload = false;
375
+ reason = 'merge mode - keeping local';
376
+ stats.skipped++;
377
+ } else if (mergeMode === 'overwrite') {
378
+ // Overwrite mode: replace if Drive is newer or hash differs
379
+ if (existingMeta?.drive_id === file.id && existingMeta?.drive_modified === file.modifiedTime) {
380
+ // Same file, check hash
381
+ if (skipUnchanged && localHash && existingMeta?.local_hash === localHash) {
382
+ shouldDownload = false;
383
+ reason = 'unchanged (hash match)';
384
+ stats.unchanged++;
385
+ }
386
+ }
387
+ }
388
+ // force-overwrite mode: always download
389
+ }
390
+
391
+ if (shouldDownload) {
392
+ if (dryRun) {
393
+ if (verbose) {
394
+ const action = localExists ? 'overwrite' : 'download';
395
+ console.log(` [DRY RUN] Would ${action}: ${file.name} → ${outputName}`);
396
+ }
397
+ stats.pulled++;
398
+ } else {
399
+ const result = await convertAndSave(file, kbTargetDir);
400
+
401
+ // Compute hash of saved file for metadata
402
+ const savedHash = await driveADC.computeFileHash(result.localPath);
403
+
404
+ metadata.files[path.basename(result.localPath)] = {
405
+ drive_id: file.id,
406
+ drive_name: file.name,
407
+ drive_mime: file.mimeType,
408
+ drive_modified: file.modifiedTime,
409
+ converted: result.converted,
410
+ converted_at: new Date().toISOString(),
411
+ local_hash: savedHash
412
+ };
413
+
414
+ stats.pulled++;
415
+ if (result.converted) stats.converted++;
416
+
417
+ if (verbose) {
418
+ const action = localExists ? '↻' : '✓';
419
+ console.log(` ${action} ${file.name}${result.converted ? ' (converted)' : ''}`);
420
+ }
421
+ }
422
+ } else {
423
+ if (verbose) {
424
+ console.log(` ⊘ ${file.name} (${reason})`);
425
+ }
426
+
427
+ // Keep existing metadata for skipped files
428
+ if (existingMetadata.files?.[outputName]) {
429
+ metadata.files[outputName] = existingMetadata.files[outputName];
430
+ }
431
+ }
432
+ } catch (error) {
433
+ if (verbose) console.log(` ✗ ${file.name}: ${error.message}`);
434
+ }
435
+ }
436
+
437
+ // Write metadata file (preserves local-only files in metadata)
438
+ if (!dryRun) {
439
+ await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2));
440
+ }
441
+
442
+ return stats;
443
+ }
444
+
445
+ /**
446
+ * Get the converted filename for a Drive file
447
+ * Helper to determine output filename based on MIME type
448
+ */
449
+ function getConvertedFilename(originalName, mimeType) {
450
+ const ext = path.extname(originalName).toLowerCase();
451
+ const baseName = path.basename(originalName, ext);
452
+
453
+ // Google Docs types get converted to .md
454
+ const googleDocTypes = [
455
+ 'application/vnd.google-apps.document',
456
+ 'application/vnd.google-apps.spreadsheet',
457
+ 'application/vnd.google-apps.presentation'
458
+ ];
459
+
460
+ // Binary formats that get converted
461
+ const convertibleTypes = [
462
+ 'application/pdf',
463
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
464
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
465
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
466
+ 'application/msword',
467
+ 'application/vnd.ms-excel',
468
+ 'application/vnd.ms-powerpoint'
469
+ ];
470
+
471
+ if (googleDocTypes.includes(mimeType) || convertibleTypes.includes(mimeType)) {
472
+ return `${baseName}.md`;
473
+ }
474
+
475
+ return originalName;
476
+ }
477
+
478
+ /**
479
+ * Hydrate a specific app from Drive
480
+ *
481
+ * @param {Object} config - { workspaceRoot, communityId, appId, baseFolderId, localPath }
482
+ * @param {Object} options - { mergeMode, verbose }
483
+ * @returns {Promise<{downloaded: number, skipped: number}>}
484
+ */
485
+ export async function hydrateApp(config, options = {}) {
486
+ const { workspaceRoot, communityId, appId, baseFolderId, localPath } = config;
487
+ const { mergeMode = 'overwrite', verbose = false } = options;
488
+
489
+ await driveADC.verifyDriveAuth();
490
+
491
+ const drivePath = `${communityId}/${appId}`;
492
+ const appFolderId = await driveADC.findFolderByPath(baseFolderId, drivePath);
493
+
494
+ if (!appFolderId) {
495
+ throw new Error(`App folder not found in Drive: ${drivePath}`);
496
+ }
497
+
498
+ const appLocalPath = localPath || drivePath;
499
+ const targetPath = path.join(workspaceRoot, appLocalPath);
500
+
501
+ return pullFolder(targetPath, appFolderId, { mergeMode, verbose });
502
+ }
503
+
504
+ /**
505
+ * Push staging files to Drive
506
+ * Files in kb/staging/ are uploaded to the corresponding Drive KB folder
507
+ *
508
+ * Enhanced with:
509
+ * - Conflict detection and resolution (interactive/unattended modes)
510
+ * - Move files to .processed/ after successful upload
511
+ * - Support for dry-run mode
512
+ *
513
+ * @param {Object} config - { workspaceRoot, communityId, appId, kbId, baseFolderId, localPath }
514
+ * @param {Object} options - Push options
515
+ * @param {boolean} options.verbose - Enable verbose output
516
+ * @param {boolean} options.interactive - Enable interactive prompts for conflicts
517
+ * @param {string} options.onConflict - Conflict resolution: 'overwrite' | 'skip' (default: 'overwrite')
518
+ * @param {boolean} options.moveToProcessed - Move files to .processed after upload (default: true)
519
+ * @param {boolean} options.dryRun - Show what would happen without making changes
520
+ * @param {function} options.onConflictPrompt - Callback for interactive conflict resolution
521
+ * @returns {Promise<{uploaded: number, skipped: number, errors: number, processed: string[]}>}
522
+ */
523
+ export async function pushStaging(config, options = {}) {
524
+ const { workspaceRoot, communityId, appId, kbId = 'General', baseFolderId, localPath } = config;
525
+ const {
526
+ verbose = false,
527
+ interactive = false,
528
+ onConflict = 'overwrite',
529
+ moveToProcessed = true,
530
+ dryRun = false,
531
+ onConflictPrompt = null
532
+ } = options;
533
+
534
+ await driveADC.verifyDriveAuth();
535
+
536
+ // Navigate to KB folder
537
+ const kbPath = `${communityId}/${appId}/kb/${kbId}`;
538
+ let kbDriveFolderId = await driveADC.findFolderByPath(baseFolderId, kbPath);
539
+
540
+ // Create KB folder if it doesn't exist
541
+ if (!kbDriveFolderId) {
542
+ if (dryRun) {
543
+ if (verbose) console.log(` [DRY RUN] Would create KB folder: ${kbPath}`);
544
+ } else {
545
+ kbDriveFolderId = await driveADC.ensureFolderPath(baseFolderId, kbPath);
546
+ if (verbose) console.log(` Created KB folder: ${kbPath}`);
547
+ }
548
+ }
549
+
550
+ // Find staging directory
551
+ const appLocalPath = localPath || `${communityId}/${appId}`;
552
+ const stagingDir = path.join(workspaceRoot, appLocalPath, 'kb', 'staging');
553
+ const processedDir = path.join(stagingDir, '.processed');
554
+
555
+ try {
556
+ await fs.access(stagingDir);
557
+ } catch {
558
+ // Staging directory doesn't exist - nothing to push
559
+ return { uploaded: 0, skipped: 0, errors: 0, processed: [] };
560
+ }
561
+
562
+ // List files in staging (skip .processed directory and hidden files)
563
+ const entries = await fs.readdir(stagingDir, { withFileTypes: true });
564
+ const files = entries.filter(e =>
565
+ !e.isDirectory() &&
566
+ !e.name.startsWith('.') &&
567
+ e.name !== '.processed'
568
+ );
569
+
570
+ if (files.length === 0) {
571
+ return { uploaded: 0, skipped: 0, errors: 0, processed: [] };
572
+ }
573
+
574
+ const stats = { uploaded: 0, skipped: 0, errors: 0, processed: [] };
575
+
576
+ // Track overwrite-all/skip-all decisions in interactive mode
577
+ let overwriteAll = false;
578
+ let skipAll = false;
579
+
580
+ for (const entry of files) {
581
+ const filePath = path.join(stagingDir, entry.name);
582
+
583
+ try {
584
+ // Check if file exists in Drive (for conflict detection)
585
+ let fileExists = { exists: false };
586
+ if (kbDriveFolderId) {
587
+ fileExists = await driveADC.checkFileExists(kbDriveFolderId, entry.name);
588
+ }
589
+
590
+ let shouldUpload = true;
591
+
592
+ if (fileExists.exists) {
593
+ // Handle conflict
594
+ if (skipAll) {
595
+ shouldUpload = false;
596
+ stats.skipped++;
597
+ if (verbose) console.log(` ⊘ ${entry.name} (skipped - file exists)`);
598
+ } else if (overwriteAll || onConflict === 'overwrite') {
599
+ shouldUpload = true;
600
+ if (verbose && fileExists.exists) console.log(` ↻ ${entry.name} (overwriting)`);
601
+ } else if (onConflict === 'skip') {
602
+ shouldUpload = false;
603
+ stats.skipped++;
604
+ if (verbose) console.log(` ⊘ ${entry.name} (skipped - file exists)`);
605
+ } else if (interactive && onConflictPrompt) {
606
+ // Interactive mode - prompt for each conflict
607
+ const action = await onConflictPrompt(entry.name, fileExists);
608
+
609
+ if (action === 'overwrite') {
610
+ shouldUpload = true;
611
+ } else if (action === 'overwrite-all') {
612
+ shouldUpload = true;
613
+ overwriteAll = true;
614
+ } else if (action === 'skip') {
615
+ shouldUpload = false;
616
+ stats.skipped++;
617
+ } else if (action === 'skip-all') {
618
+ shouldUpload = false;
619
+ skipAll = true;
620
+ stats.skipped++;
621
+ }
622
+ }
623
+ }
624
+
625
+ if (shouldUpload) {
626
+ if (dryRun) {
627
+ if (verbose) {
628
+ const action = fileExists.exists ? 'overwrite' : 'upload';
629
+ console.log(` [DRY RUN] Would ${action}: ${entry.name}`);
630
+ }
631
+ stats.uploaded++;
632
+ } else {
633
+ await driveADC.uploadFileToDrive(filePath, entry.name, kbDriveFolderId);
634
+ stats.uploaded++;
635
+ stats.processed.push(entry.name);
636
+ if (verbose) console.log(` ✓ ${entry.name}${fileExists.exists ? ' (updated)' : ''}`);
637
+
638
+ // Move to .processed directory
639
+ if (moveToProcessed) {
640
+ await fs.mkdir(processedDir, { recursive: true });
641
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
642
+ const processedName = `${path.basename(entry.name, path.extname(entry.name))}_${timestamp}${path.extname(entry.name)}`;
643
+ const processedPath = path.join(processedDir, processedName);
644
+ await fs.rename(filePath, processedPath);
645
+ if (verbose) console.log(` → moved to .processed/`);
646
+ }
647
+ }
648
+ }
649
+ } catch (error) {
650
+ stats.errors++;
651
+ if (verbose) console.log(` ✗ ${entry.name}: ${error.message}`);
652
+ }
653
+ }
654
+
655
+ return stats;
656
+ }
657
+
658
+ /**
659
+ * Check if staging directory has files to process
660
+ * @param {string} stagingDir - Path to staging directory
661
+ * @returns {Promise<{hasFiles: boolean, count: number, files: string[]}>}
662
+ */
663
+ export async function checkStagingFiles(stagingDir) {
664
+ try {
665
+ await fs.access(stagingDir);
666
+ const entries = await fs.readdir(stagingDir, { withFileTypes: true });
667
+ const files = entries.filter(e =>
668
+ !e.isDirectory() &&
669
+ !e.name.startsWith('.') &&
670
+ e.name !== '.processed'
671
+ ).map(e => e.name);
672
+
673
+ return {
674
+ hasFiles: files.length > 0,
675
+ count: files.length,
676
+ files
677
+ };
678
+ } catch {
679
+ return { hasFiles: false, count: 0, files: [] };
680
+ }
681
+ }
682
+
683
+ /**
684
+ * Copy a scaffold template to the current app
685
+ * Scaffolds are Git-based code templates (site, microservice)
686
+ *
687
+ * @param {string} scaffoldType - 'site' or 'microservice'
688
+ * @param {string} appPath - Path to the app directory
689
+ * @param {Object} options - { verbose, force }
690
+ * @returns {Promise<{copied: number}>}
691
+ */
692
+ export async function copyScaffold(scaffoldType, appPath, options = {}) {
693
+ const { verbose = false, force = false } = options;
694
+
695
+ const validTypes = ['site', 'microservice'];
696
+ if (!validTypes.includes(scaffoldType)) {
697
+ throw new Error(`Invalid scaffold type: ${scaffoldType}. Valid types: ${validTypes.join(', ')}`);
698
+ }
699
+
700
+ // Find scaffold template directory (templates live inside CLI package)
701
+ const cliRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
702
+ const scaffoldDir = path.join(cliRoot, 'templates', 'scaffolds', scaffoldType);
703
+
704
+ // Check if scaffold exists
705
+ try {
706
+ await fs.access(scaffoldDir);
707
+ } catch {
708
+ throw new Error(`Scaffold template not found: ${scaffoldDir}`);
709
+ }
710
+
711
+ // Check target directory
712
+ const targetDir = path.join(appPath, scaffoldType);
713
+
714
+ try {
715
+ const targetExists = await fs.stat(targetDir).then(() => true).catch(() => false);
716
+ if (targetExists) {
717
+ const hasContent = await hasContentInDir(targetDir);
718
+ if (hasContent && !force) {
719
+ throw new Error(
720
+ `Target directory ${scaffoldType}/ already has content. ` +
721
+ `Use --force to overwrite.`
722
+ );
723
+ }
724
+ }
725
+ } catch (err) {
726
+ if (err.message.includes('already has content')) throw err;
727
+ // Directory doesn't exist, that's fine
728
+ }
729
+
730
+ // Copy scaffold recursively
731
+ const stats = { copied: 0 };
732
+ await copyDir(scaffoldDir, targetDir, stats, verbose);
733
+
734
+ return stats;
735
+ }
736
+
737
+ /**
738
+ * Check if directory has any content
739
+ */
740
+ async function hasContentInDir(dirPath) {
741
+ try {
742
+ const entries = await fs.readdir(dirPath);
743
+ return entries.filter(e => !e.startsWith('.')).length > 0;
744
+ } catch {
745
+ return false;
746
+ }
747
+ }
748
+
749
+ /**
750
+ * Recursively copy a directory
751
+ */
752
+ async function copyDir(src, dest, stats, verbose) {
753
+ await fs.mkdir(dest, { recursive: true });
754
+
755
+ const entries = await fs.readdir(src, { withFileTypes: true });
756
+
757
+ for (const entry of entries) {
758
+ const srcPath = path.join(src, entry.name);
759
+ const destPath = path.join(dest, entry.name);
760
+
761
+ if (entry.isDirectory()) {
762
+ await copyDir(srcPath, destPath, stats, verbose);
763
+ } else {
764
+ await fs.copyFile(srcPath, destPath);
765
+ stats.copied++;
766
+ if (verbose) console.log(` Copied: ${entry.name}`);
767
+ }
768
+ }
769
+ }
770
+
771
+ /**
772
+ * Get available scaffold types
773
+ * @returns {Promise<string[]>}
774
+ */
775
+ export async function getAvailableScaffolds() {
776
+ const sdkRoot = path.resolve(path.dirname(import.meta.url.replace('file://', '')), '..', '..', '..', '..');
777
+ const scaffoldsDir = path.join(sdkRoot, 'templates', 'scaffolds');
778
+
779
+ try {
780
+ const entries = await fs.readdir(scaffoldsDir, { withFileTypes: true });
781
+ return entries.filter(e => e.isDirectory()).map(e => e.name);
782
+ } catch {
783
+ return [];
784
+ }
785
+ }
786
+
787
+ // Named exports for functions not already exported inline
788
+ export {
789
+ convertAndSave,
790
+ };
791
+
792
+ // Default export for backward compatibility
793
+ export default {
794
+ hydrateApp,
795
+ hydrateKb,
796
+ pushStaging,
797
+ checkStagingFiles,
798
+ convertAndSave,
799
+ copyScaffold,
800
+ getAvailableScaffolds
801
+ };