@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.
- package/README.md +95 -0
- package/SERVICE_README_sdk.md +399 -0
- package/agent-assets/guides/README.md +61 -0
- package/agent-assets/guides/dynamic-site-pattern.md +125 -0
- package/agent-assets/guides/knowledge-only-pattern.md +93 -0
- package/agent-assets/guides/microservice-pattern.md +152 -0
- package/agent-assets/guides/static-site-pattern.md +111 -0
- package/agent-assets/instructions/app-asset-validation.md +477 -0
- package/agent-assets/instructions/onboarding-flow.md +408 -0
- package/agent-assets/instructions/standard-folder-structure.md +125 -0
- package/agent-assets/reference/app-creation.md +381 -0
- package/agent-assets/reference/architecture.md +406 -0
- package/agent-assets/reference/local-dev.md +138 -0
- package/agent-assets/reference/mcp-integration.md +424 -0
- package/agent-assets/reference/workspace-config.md +415 -0
- package/agent-assets/rules/descix_mcp.template.mdc +243 -0
- package/bin/descix.js +3930 -0
- package/bin/mcp-server.js +382 -0
- package/descix-cli-1.0.0.tgz +0 -0
- package/lib/agent-files.js +162 -0
- package/lib/api-client.js +458 -0
- package/lib/auth-guard.js +67 -0
- package/lib/commands/app-wizard.js +627 -0
- package/lib/commands/auth.js +407 -0
- package/lib/commands/buy.js +207 -0
- package/lib/commands/clone.js +91 -0
- package/lib/commands/config.js +155 -0
- package/lib/commands/doctor.js +277 -0
- package/lib/commands/folder.js +291 -0
- package/lib/commands/init.js +257 -0
- package/lib/commands/kb.js +654 -0
- package/lib/commands/mcp.js +273 -0
- package/lib/commands/serve.js +12 -0
- package/lib/commands/status.js +239 -0
- package/lib/commands/update.js +653 -0
- package/lib/core/Chunker.js +610 -0
- package/lib/core/Hydrator.js +801 -0
- package/lib/core/PathContext.js +335 -0
- package/lib/core/Syncer.js +310 -0
- package/lib/core/index.js +32 -0
- package/lib/global-config.js +86 -0
- package/lib/google-storage-adc.js +511 -0
- package/lib/wallet-file.js +232 -0
- package/lib/wizard/setup.js +502 -0
- package/lib/workspace-config.js +712 -0
- package/lib/workspace-utils.js +126 -0
- package/package.json +41 -0
- package/templates/agent-claude.md +226 -0
- package/templates/agent-cline.md +133 -0
- package/templates/agent-copilot.md +133 -0
- package/templates/agent-cursor.md +133 -0
- package/templates/descix_mcp.mdc +122 -0
- package/templates/drive/README.md +42 -0
- package/templates/drive/app/assets/app_description.md +13 -0
- package/templates/drive/app/assets/icon.png +0 -0
- package/templates/drive/app/assets/system_instructions.md +17 -0
- package/templates/drive/app/kb/General/README.md +25 -0
- package/templates/drive/community/Apps/.gitkeep +4 -0
- package/templates/drive/community/community_assets/community_description.md +15 -0
- package/templates/drive/community/community_assets/icon.png +0 -0
- package/templates/drive_templates/README.md +104 -0
- package/templates/drive_templates/agent_app_template/assets/app_description.txt +13 -0
- package/templates/drive_templates/agent_app_template/assets/system_instructions.txt +17 -0
- package/templates/drive_templates/agent_app_template/kb/General/README.md +25 -0
- package/templates/drive_templates/agent_app_template/microservice/.gitkeep +10 -0
- package/templates/drive_templates/agent_app_template/site/.gitkeep +4 -0
- package/templates/drive_templates/community_template/Apps/.gitkeep +4 -0
- package/templates/drive_templates/community_template/community_assets/community_description.txt +15 -0
- package/templates/scaffolds/README.md +62 -0
- package/templates/scaffolds/microservice/Dockerfile +19 -0
- package/templates/scaffolds/microservice/SERVICE_README_sdk.md +399 -0
- package/templates/scaffolds/microservice/app.js +50 -0
- package/templates/scaffolds/microservice/app.yaml +8 -0
- package/templates/scaffolds/microservice/defaults-config.json +5 -0
- package/templates/scaffolds/microservice/dev-overrides.json.example +5 -0
- package/templates/scaffolds/microservice/manifest.json +49 -0
- package/templates/scaffolds/microservice/package-lock.json +2645 -0
- package/templates/scaffolds/microservice/package.json +29 -0
- package/templates/scaffolds/microservice/scripts/register.js +55 -0
- package/templates/scaffolds/microservice/services/apiFront.js +75 -0
- package/templates/scaffolds/microservice/services/commandHandlers/sampleCommands.js +57 -0
- package/templates/scaffolds/microservice/services/mcpClient.js +78 -0
- package/templates/scaffolds/microservice/services/utils.js +247 -0
- package/templates/scaffolds/microservice/setup-schema.json +18 -0
- package/templates/scaffolds/microservice/templates/SERVICE_README_TEMPLATE.md +143 -0
- package/templates/scaffolds/site/DeSciXAppSDK.js +62 -0
- package/templates/scaffolds/site/README.md +51 -0
- package/templates/scaffolds/site/app.js +22 -0
- package/templates/scaffolds/site/index.html +42 -0
- package/templates/scaffolds/site/styles.css +123 -0
- package/test-hydration.js +150 -0
- package/vendor/mcp/transports/http-sse-transport.js +196 -0
- package/vendor/mcp/transports/index.js +15 -0
- 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
|
+
};
|