create-interview-cockpit 0.17.2 → 0.18.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.
@@ -137,6 +137,86 @@ async function downloadFileAuthed(
137
137
  return Buffer.from(res.data as ArrayBuffer);
138
138
  }
139
139
 
140
+ const WORKSPACE_FILES_FOLDER_NAME = "workspace-files";
141
+ const RESERVED_EXPORT_FOLDER_NAMES = new Set([WORKSPACE_FILES_FOLDER_NAME]);
142
+
143
+ function extensionForFilename(filename: string): string {
144
+ return filename.split(".").pop()?.toLowerCase() ?? "";
145
+ }
146
+
147
+ function mimeForFilename(filename: string): string {
148
+ const ext = extensionForFilename(filename);
149
+ const map: Record<string, string> = {
150
+ pdf: "application/pdf",
151
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
152
+ doc: "application/msword",
153
+ json: "application/json",
154
+ csv: "text/csv",
155
+ html: "text/html; charset=utf-8",
156
+ xml: "application/xml",
157
+ txt: "text/plain; charset=utf-8",
158
+ md: "text/markdown; charset=utf-8",
159
+ png: "image/png",
160
+ jpg: "image/jpeg",
161
+ jpeg: "image/jpeg",
162
+ gif: "image/gif",
163
+ webp: "image/webp",
164
+ svg: "image/svg+xml",
165
+ };
166
+ return map[ext] ?? "application/octet-stream";
167
+ }
168
+
169
+ type SerializedContextFile = storage.ContextFile & {
170
+ content?: string;
171
+ originalBase64?: string;
172
+ originalMimeType?: string;
173
+ };
174
+
175
+ async function readContextFileExportBytes(
176
+ cf: storage.ContextFile,
177
+ workspaceId: string,
178
+ ctxDir: string,
179
+ ): Promise<Buffer> {
180
+ const original = await storage.readOriginalBlob(cf.id, workspaceId);
181
+ return original ?? fs.readFile(path.join(ctxDir, cf.id));
182
+ }
183
+
184
+ async function serializeContextFileForExport(
185
+ cf: storage.ContextFile,
186
+ workspaceId: string,
187
+ ctxDir: string,
188
+ ): Promise<SerializedContextFile> {
189
+ const exported: SerializedContextFile = { ...cf };
190
+ try {
191
+ exported.content = await fs.readFile(path.join(ctxDir, cf.id), "utf-8");
192
+ } catch {
193
+ /* Keep metadata even if the extracted-text blob is missing. */
194
+ }
195
+ const original = await storage.readOriginalBlob(cf.id, workspaceId);
196
+ if (original) {
197
+ exported.originalBase64 = original.toString("base64");
198
+ exported.originalMimeType = mimeForFilename(cf.originalName);
199
+ }
200
+ return exported;
201
+ }
202
+
203
+ function contextFileMetadataForImport(
204
+ cf: SerializedContextFile,
205
+ fallbackName: string,
206
+ ): storage.ContextFile {
207
+ return {
208
+ id: cf.id,
209
+ name: cf.name || fallbackName,
210
+ originalName: cf.originalName || fallbackName,
211
+ ...(cf.driveFileId ? { driveFileId: cf.driveFileId } : {}),
212
+ createdAt: cf.createdAt || new Date().toISOString(),
213
+ ...(cf.origin ? { origin: cf.origin } : {}),
214
+ ...(cf.language ? { language: cf.language } : {}),
215
+ ...(cf.label ? { label: cf.label } : {}),
216
+ ...(typeof cf.inContext === "boolean" ? { inContext: cf.inContext } : {}),
217
+ };
218
+ }
219
+
140
220
  export interface SyncResult {
141
221
  topicsUpserted: number;
142
222
  filesImported: number;
@@ -181,42 +261,51 @@ export async function syncWorkspace(
181
261
  errors: [],
182
262
  };
183
263
 
184
- // ── Phase 0: fast wipe (parallel deletes instead of serial deleteTopic calls) ──
185
- await storage.clearWorkspaceData(workspaceId);
186
-
187
264
  const importedQuestionKeys = new Set<string>();
188
265
 
189
266
  // ── Phase 1: fetch all folder listings in parallel ──────────────────────────
190
267
  const subfolders = await doListFolders(syncRoot);
268
+ const workspaceFilesFolder = subfolders.find(
269
+ (folder) => folder.name === WORKSPACE_FILES_FOLDER_NAME,
270
+ );
271
+ const topicFolders = subfolders.filter(
272
+ (folder) => !RESERVED_EXPORT_FOLDER_NAMES.has(folder.name),
273
+ );
191
274
 
192
- const folderData = await Promise.all(
193
- subfolders.map(async (folder) => {
194
- const innerFolders = await doListFolders(folder.id);
195
- const questionsFolder = innerFolders.find((f) => f.name === "questions");
196
- const ctxFilesFolder = innerFolders.find(
197
- (f) => f.name === "context-files",
198
- );
275
+ const [folderData, workspaceFileItems] = await Promise.all([
276
+ Promise.all(
277
+ topicFolders.map(async (folder) => {
278
+ const innerFolders = await doListFolders(folder.id);
279
+ const questionsFolder = innerFolders.find(
280
+ (f) => f.name === "questions",
281
+ );
282
+ const ctxFilesFolder = innerFolders.find(
283
+ (f) => f.name === "context-files",
284
+ );
199
285
 
200
- const [qFiles, cfFiles] = await Promise.all([
201
- questionsFolder
202
- ? doListFiles(questionsFolder.id)
203
- : doListFiles(folder.id),
204
- ctxFilesFolder ? doListFiles(ctxFilesFolder.id) : Promise.resolve([]),
205
- ]);
286
+ const [qFiles, cfFiles] = await Promise.all([
287
+ questionsFolder
288
+ ? doListFiles(questionsFolder.id)
289
+ : doListFiles(folder.id),
290
+ ctxFilesFolder ? doListFiles(ctxFilesFolder.id) : Promise.resolve([]),
291
+ ]);
206
292
 
207
- return { folder, qFiles, cfFiles };
208
- }),
209
- );
293
+ return { folder, qFiles, cfFiles };
294
+ }),
295
+ ),
296
+ workspaceFilesFolder
297
+ ? doListFiles(workspaceFilesFolder.id)
298
+ : Promise.resolve([]),
299
+ ]);
210
300
 
211
- // ── Phase 2: write all topics in one batch (one topics.json write) ───────────
301
+ // ── Phase 2: build topic records in memory ─────────────────────────────────
212
302
  const topicRecords: storage.Topic[] = folderData.map(({ folder }) => ({
213
303
  id: randomUUID(),
214
304
  name: folder.name,
215
305
  contextFiles: [],
216
306
  createdAt: new Date().toISOString(),
217
307
  }));
218
- await storage.replaceAllTopics(topicRecords);
219
- result.topicsUpserted = topicRecords.length;
308
+ const extraTopicRecords: storage.Topic[] = [];
220
309
 
221
310
  const topicIdByFolderId = new Map(
222
311
  folderData.map(({ folder }, i) => [folder.id, topicRecords[i].id]),
@@ -229,8 +318,13 @@ export async function syncWorkspace(
229
318
  buffer: Buffer;
230
319
  isContextFile: boolean;
231
320
  };
321
+ type PendingWorkspaceFile = {
322
+ filename: string;
323
+ buffer: Buffer;
324
+ };
232
325
 
233
326
  const pending: Pending[] = [];
327
+ const pendingWorkspaceFiles: PendingWorkspaceFile[] = [];
234
328
 
235
329
  await Promise.all(
236
330
  folderData.flatMap(({ folder, qFiles, cfFiles }) => {
@@ -274,6 +368,19 @@ export async function syncWorkspace(
274
368
  }),
275
369
  );
276
370
 
371
+ await Promise.all(
372
+ workspaceFileItems.map(async (file) => {
373
+ try {
374
+ const buffer = await doDownload(file.id, file.mimeType);
375
+ pendingWorkspaceFiles.push({ filename: file.name, buffer });
376
+ } catch (err: any) {
377
+ result.errors.push(
378
+ `Workspace file "${file.name}": ${err?.message ?? "failed"}`,
379
+ );
380
+ }
381
+ }),
382
+ );
383
+
277
384
  // Root-level files in syncRoot → "General" topic
278
385
  const rootFiles = await doListFiles(syncRoot);
279
386
  if (rootFiles.length > 0) {
@@ -283,8 +390,7 @@ export async function syncWorkspace(
283
390
  contextFiles: [],
284
391
  createdAt: new Date().toISOString(),
285
392
  };
286
- await storage.saveTopic(generalTopic);
287
- result.topicsUpserted++;
393
+ extraTopicRecords.push(generalTopic);
288
394
 
289
395
  await Promise.all(
290
396
  rootFiles.map(async (file) => {
@@ -309,6 +415,18 @@ export async function syncWorkspace(
309
415
  );
310
416
  }
311
417
 
418
+ // If downloads hit private Drive files or an old token lacks enough scope,
419
+ // stop before wiping the local workspace. The route will prompt re-auth.
420
+ if (result.errors.some((error) => /\b403\b/.test(error))) {
421
+ return result;
422
+ }
423
+
424
+ // ── Phase 3b: fast wipe + write all topics in one batch ────────────────────
425
+ await storage.clearWorkspaceData(workspaceId);
426
+ const allTopicRecords = [...topicRecords, ...extraTopicRecords];
427
+ await storage.replaceAllTopics(allTopicRecords);
428
+ result.topicsUpserted = allTopicRecords.length;
429
+
312
430
  // ── Phase 4: save questions in parallel (each is an individual file) ─────────
313
431
  const questions = pending.filter((p) => !p.isContextFile);
314
432
  const contextFiles = pending.filter((p) => p.isContextFile);
@@ -345,42 +463,53 @@ export async function syncWorkspace(
345
463
  parentTitle = parsed.parentTitle || null;
346
464
  if (parsed.createdAt) restoredCreatedAt = parsed.createdAt;
347
465
 
348
- // Restore code snippet context files (user/ai origin)
349
- if (
350
- Array.isArray(parsed.codeSnippets) &&
351
- parsed.codeSnippets.length > 0
352
- ) {
466
+ // Restore all question-attached context files. New exports use
467
+ // contextFiles; older exports used codeSnippets for lab/code blobs.
468
+ const parsedContextFiles: SerializedContextFile[] = Array.isArray(
469
+ parsed.contextFiles,
470
+ )
471
+ ? parsed.contextFiles
472
+ : Array.isArray(parsed.codeSnippets)
473
+ ? parsed.codeSnippets
474
+ : [];
475
+ if (parsedContextFiles.length > 0) {
353
476
  const ctxDir =
354
477
  storage.getContextFilesDirForWorkspace(workspaceId);
355
478
  await Promise.all(
356
- parsed.codeSnippets.map(
357
- async (cs: storage.ContextFile & { content?: string }) => {
358
- if (
359
- cs.content &&
360
- (cs.origin === "user" ||
361
- cs.origin === "ai" ||
362
- cs.origin === "sandbox" ||
363
- cs.origin === "browser-security" ||
364
- cs.origin === "react" ||
365
- cs.origin === "nextjs" ||
366
- cs.origin === "module-federation" ||
367
- cs.origin === "infra")
368
- ) {
369
- try {
370
- await fs.mkdir(ctxDir, { recursive: true });
371
- await fs.writeFile(
372
- path.join(ctxDir, cs.id),
373
- Buffer.from(cs.content, "utf-8"),
374
- );
375
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
376
- const { content: _content, ...cfMeta } = cs;
377
- restoredContextFiles.push(cfMeta);
378
- } catch {
379
- /* skip bad blobs */
380
- }
479
+ parsedContextFiles.map(async (cs) => {
480
+ if (!cs.id) return;
481
+ const originalName = cs.originalName || cs.name || cs.id;
482
+ try {
483
+ const originalBuffer = cs.originalBase64
484
+ ? Buffer.from(cs.originalBase64, "base64")
485
+ : null;
486
+ const content =
487
+ typeof cs.content === "string"
488
+ ? cs.content
489
+ : originalBuffer
490
+ ? await extractText(originalBuffer, originalName)
491
+ : null;
492
+
493
+ if (content == null) return;
494
+ await fs.mkdir(ctxDir, { recursive: true });
495
+ await fs.writeFile(
496
+ path.join(ctxDir, cs.id),
497
+ Buffer.from(content, "utf-8"),
498
+ );
499
+ if (originalBuffer) {
500
+ await storage.writeOriginalBlob(
501
+ cs.id,
502
+ originalBuffer,
503
+ workspaceId,
504
+ );
381
505
  }
382
- },
383
- ),
506
+ restoredContextFiles.push(
507
+ contextFileMetadataForImport(cs, originalName),
508
+ );
509
+ } catch {
510
+ /* skip bad blobs */
511
+ }
512
+ }),
384
513
  );
385
514
  }
386
515
  } catch {
@@ -439,13 +568,36 @@ export async function syncWorkspace(
439
568
  }
440
569
  }
441
570
 
442
- // ── Phase 5: save context file blobs in parallel, then update topics.json once ─
571
+ // ── Phase 5: save workspace/topic context file blobs ───────────────────────
572
+ if (pendingWorkspaceFiles.length > 0) {
573
+ // saveWorkspaceContextFile updates workspace-files.json; do this
574
+ // sequentially so concurrent reads/writes do not overwrite metadata.
575
+ for (const { filename, buffer } of pendingWorkspaceFiles) {
576
+ const fileId = randomUUID();
577
+ const text = await extractText(buffer, filename);
578
+ await storage.writeOriginalBlob(fileId, buffer, workspaceId);
579
+ await storage.saveWorkspaceContextFile(
580
+ fileId,
581
+ filename,
582
+ Buffer.from(text, "utf-8"),
583
+ workspaceId,
584
+ );
585
+ }
586
+ result.filesImported += pendingWorkspaceFiles.length;
587
+ }
588
+
589
+ // Save topic context file blobs in parallel, then update topics.json once.
443
590
  if (contextFiles.length > 0) {
444
- // Write binary files in parallel
445
591
  const cfRecords = await Promise.all(
446
592
  contextFiles.map(async ({ topicId, filename, buffer }) => {
447
593
  const fileId = randomUUID();
448
- await storage.writeContextFileBlob(workspaceId, fileId, buffer);
594
+ const text = await extractText(buffer, filename);
595
+ await storage.writeOriginalBlob(fileId, buffer, workspaceId);
596
+ await storage.writeContextFileBlob(
597
+ workspaceId,
598
+ fileId,
599
+ Buffer.from(text, "utf-8"),
600
+ );
449
601
  return { topicId, fileId, filename };
450
602
  }),
451
603
  );
@@ -486,6 +638,10 @@ export async function listDriveSubfolders(
486
638
  if (!ws?.driveConfig?.folderId) {
487
639
  throw new Error("No Drive folder linked to this workspace");
488
640
  }
641
+ if (await isExportAuthed()) {
642
+ const drive = await getExportDriveClient();
643
+ return listFoldersAuthed(drive, ws.driveConfig.folderId);
644
+ }
489
645
  return listFolders(ws.driveConfig.folderId);
490
646
  }
491
647
 
@@ -518,11 +674,11 @@ const EXPORT_TOKENS_FILE = path.resolve(
518
674
  );
519
675
 
520
676
  function createExportOAuthClient() {
677
+ const defaultRedirectUri = `http://localhost:${process.env.GOOGLE_EXPORT_REDIRECT_PORT || "3001"}/api/drive/export-callback`;
521
678
  return new google.auth.OAuth2(
522
679
  process.env.GOOGLE_CLIENT_ID,
523
680
  process.env.GOOGLE_CLIENT_SECRET,
524
- process.env.GOOGLE_EXPORT_REDIRECT_URI ||
525
- "http://localhost:3001/api/drive/export-callback",
681
+ process.env.GOOGLE_EXPORT_REDIRECT_URI || defaultRedirectUri,
526
682
  );
527
683
  }
528
684
 
@@ -530,7 +686,7 @@ export function getExportAuthUrl(): string {
530
686
  const client = createExportOAuthClient();
531
687
  return client.generateAuthUrl({
532
688
  access_type: "offline",
533
- scope: ["https://www.googleapis.com/auth/drive.file"],
689
+ scope: ["https://www.googleapis.com/auth/drive"],
534
690
  prompt: "consent",
535
691
  });
536
692
  }
@@ -697,6 +853,31 @@ async function uploadFileToFolder(
697
853
  const buffer = Buffer.isBuffer(content)
698
854
  ? content
699
855
  : Buffer.from(content, "utf-8");
856
+ const safeName = name.replace(/'/g, "\\'");
857
+ const existing = await drive.files.list({
858
+ q: `'${folderId}' in parents and name = '${safeName}' and trashed = false`,
859
+ fields: "files(id)",
860
+ pageSize: 10,
861
+ });
862
+ const [first, ...duplicates] = existing.data.files || [];
863
+ if (first?.id) {
864
+ await drive.files.update({
865
+ fileId: first.id,
866
+ requestBody: { name },
867
+ media: { mimeType, body: Readable.from(buffer) },
868
+ fields: "id",
869
+ });
870
+ await Promise.all(
871
+ duplicates.map((file) =>
872
+ file.id
873
+ ? drive.files.delete({ fileId: file.id }).catch(() => {})
874
+ : null,
875
+ ),
876
+ );
877
+ await ensurePublicRead(drive, first.id);
878
+ return;
879
+ }
880
+
700
881
  const created = await drive.files.create({
701
882
  requestBody: { name, parents: [folderId] },
702
883
  media: { mimeType, body: Readable.from(buffer) },
@@ -731,6 +912,39 @@ export async function exportWorkspace(
731
912
  const exportFolderId =
732
913
  targetFolderId ?? (await getOrCreateFolder(drive, folderId, "_export"));
733
914
  const topics = await storage.getTopicsForWorkspace(workspaceId);
915
+ const ctxDir = storage.getContextFilesDirForWorkspace(workspaceId);
916
+
917
+ const workspaceFiles = await storage.getWorkspaceContextFiles(workspaceId);
918
+ if (workspaceFiles.length > 0) {
919
+ const workspaceFilesFolderId = await getOrCreateFolder(
920
+ drive,
921
+ exportFolderId,
922
+ WORKSPACE_FILES_FOLDER_NAME,
923
+ );
924
+ await Promise.all(
925
+ workspaceFiles.map(async (cf) => {
926
+ try {
927
+ const buffer = await readContextFileExportBytes(
928
+ cf,
929
+ workspaceId,
930
+ ctxDir,
931
+ );
932
+ await uploadFileToFolder(
933
+ drive,
934
+ workspaceFilesFolderId,
935
+ cf.originalName,
936
+ buffer,
937
+ mimeForFilename(cf.originalName),
938
+ );
939
+ result.filesExported++;
940
+ } catch (err: any) {
941
+ result.errors.push(
942
+ `Workspace file "${cf.originalName}": ${err?.message || "upload failed"}`,
943
+ );
944
+ }
945
+ }),
946
+ );
947
+ }
734
948
 
735
949
  for (const topic of topics) {
736
950
  try {
@@ -761,34 +975,16 @@ export async function exportWorkspace(
761
975
  ? questions.find((p) => p.id === q.parentQuestionId)?.title
762
976
  : undefined;
763
977
 
764
- // Read code snippet blobs so they survive drive sync
765
- const ctxDir =
766
- storage.getContextFilesDirForWorkspace(workspaceId);
767
- const contextFilesWithContent: Array<
768
- storage.ContextFile & { content?: string }
769
- > = [];
770
- for (const cf of q.contextFiles || []) {
771
- if (
772
- cf.origin === "user" ||
773
- cf.origin === "ai" ||
774
- cf.origin === "sandbox" ||
775
- cf.origin === "browser-security" ||
776
- cf.origin === "react" ||
777
- cf.origin === "nextjs" ||
778
- cf.origin === "module-federation" ||
779
- cf.origin === "infra"
780
- ) {
781
- try {
782
- const content = await fs.readFile(
783
- path.join(ctxDir, cf.id),
784
- "utf-8",
785
- );
786
- contextFilesWithContent.push({ ...cf, content });
787
- } catch {
788
- contextFilesWithContent.push(cf);
789
- }
790
- }
791
- }
978
+ // Read every question-attached context file so uploaded docs,
979
+ // images, and generated code snippets survive Drive sync.
980
+ const contextFilesWithContent = await Promise.all(
981
+ (q.contextFiles || []).map((cf) =>
982
+ serializeContextFileForExport(cf, workspaceId, ctxDir),
983
+ ),
984
+ );
985
+ const legacyCodeSnippets = contextFilesWithContent.filter(
986
+ (cf) => cf.origin && cf.origin !== "upload",
987
+ );
792
988
 
793
989
  // Export full question as JSON so nothing is lost on sync-back
794
990
  const payload = JSON.stringify(
@@ -799,7 +995,8 @@ export async function exportWorkspace(
799
995
  messages: q.messages,
800
996
  codeContextFiles: q.codeContextFiles,
801
997
  codeAnnotations: q.codeAnnotations ?? {},
802
- codeSnippets: contextFilesWithContent,
998
+ contextFiles: contextFilesWithContent,
999
+ codeSnippets: legacyCodeSnippets,
803
1000
  createdAt: q.createdAt,
804
1001
  },
805
1002
  null,
@@ -829,17 +1026,20 @@ export async function exportWorkspace(
829
1026
  topicFolderId,
830
1027
  "context-files",
831
1028
  );
832
- const ctxDir = storage.getContextFilesDirForWorkspace(workspaceId);
833
1029
  await Promise.all(
834
1030
  topic.contextFiles.map(async (cf) => {
835
1031
  try {
836
- const buffer = await fs.readFile(path.join(ctxDir, cf.id));
1032
+ const buffer = await readContextFileExportBytes(
1033
+ cf,
1034
+ workspaceId,
1035
+ ctxDir,
1036
+ );
837
1037
  await uploadFileToFolder(
838
1038
  drive,
839
1039
  ctxFolderId,
840
1040
  cf.originalName,
841
1041
  buffer,
842
- "application/octet-stream",
1042
+ mimeForFilename(cf.originalName),
843
1043
  );
844
1044
  result.filesExported++;
845
1045
  } catch (err: any) {