@girardmedia/bootspring 2.0.21 → 2.0.23

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 (159) hide show
  1. package/bin/bootspring.js +5 -0
  2. package/cli/org.js +474 -0
  3. package/cli/preseed/index.js +16 -0
  4. package/cli/preseed/interactive.js +143 -0
  5. package/cli/preseed/templates.js +227 -0
  6. package/cli/preseed.js +9 -301
  7. package/cli/seed/builders/ai-context-builder.js +85 -0
  8. package/cli/seed/builders/index.js +13 -0
  9. package/cli/seed/builders/seed-builder.js +272 -0
  10. package/cli/seed/extractors/content-extractors.js +383 -0
  11. package/cli/seed/extractors/index.js +47 -0
  12. package/cli/seed/extractors/metadata-extractors.js +167 -0
  13. package/cli/seed/extractors/section-extractor.js +54 -0
  14. package/cli/seed/extractors/stack-extractors.js +228 -0
  15. package/cli/seed/index.js +18 -0
  16. package/cli/seed/utils/folder-structure.js +84 -0
  17. package/cli/seed/utils/index.js +11 -0
  18. package/cli/seed.js +23 -1074
  19. package/core/api-client.js +77 -0
  20. package/core/entitlements.js +36 -0
  21. package/core/organizations.js +223 -0
  22. package/core/policies.js +51 -6
  23. package/core/policy-matrix.js +303 -0
  24. package/core/project-context.js +1 -0
  25. package/dist/cli/index.d.ts +3 -0
  26. package/dist/cli/index.js +3220 -0
  27. package/dist/cli/index.js.map +1 -0
  28. package/dist/context-McpJQa_2.d.ts +5710 -0
  29. package/dist/core/index.d.ts +635 -0
  30. package/dist/core/index.js +2593 -0
  31. package/dist/core/index.js.map +1 -0
  32. package/dist/index-QqbeEiDm.d.ts +857 -0
  33. package/dist/index-UiYCgwiH.d.ts +174 -0
  34. package/dist/index.d.ts +453 -0
  35. package/dist/index.js +44228 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/mcp/index.d.ts +1 -0
  38. package/dist/mcp/index.js +41173 -0
  39. package/dist/mcp/index.js.map +1 -0
  40. package/generators/index.ts +82 -0
  41. package/intelligence/orchestrator/config/failure-signatures.js +48 -0
  42. package/intelligence/orchestrator/config/index.js +23 -0
  43. package/intelligence/orchestrator/config/pack-lifecycle.js +262 -0
  44. package/intelligence/orchestrator/config/phases.js +111 -0
  45. package/intelligence/orchestrator/config/remediation.js +150 -0
  46. package/intelligence/orchestrator/config/workflows.js +168 -0
  47. package/intelligence/orchestrator/core/index.js +16 -0
  48. package/intelligence/orchestrator/core/state-manager.js +88 -0
  49. package/intelligence/orchestrator/core/telemetry.js +24 -0
  50. package/intelligence/orchestrator/index.js +17 -0
  51. package/intelligence/orchestrator.js +17 -512
  52. package/mcp/contracts/mcp-contract.v1.json +1 -1
  53. package/package.json +16 -3
  54. package/src/cli/agent.ts +703 -0
  55. package/src/cli/analyze.ts +640 -0
  56. package/src/cli/audit.ts +707 -0
  57. package/src/cli/auth.ts +930 -0
  58. package/src/cli/billing.ts +364 -0
  59. package/src/cli/build.ts +1089 -0
  60. package/src/cli/business.ts +508 -0
  61. package/src/cli/checkpoint-utils.ts +236 -0
  62. package/src/cli/checkpoint.ts +757 -0
  63. package/src/cli/cloud-sync.ts +534 -0
  64. package/src/cli/content.ts +273 -0
  65. package/src/cli/context.ts +667 -0
  66. package/src/cli/dashboard.ts +133 -0
  67. package/src/cli/deploy.ts +704 -0
  68. package/src/cli/doctor.ts +480 -0
  69. package/src/cli/fundraise.ts +494 -0
  70. package/src/cli/generate.ts +346 -0
  71. package/src/cli/github-cmd.ts +566 -0
  72. package/src/cli/health.ts +599 -0
  73. package/src/cli/index.ts +113 -0
  74. package/src/cli/init.ts +838 -0
  75. package/src/cli/legal.ts +495 -0
  76. package/src/cli/log.ts +316 -0
  77. package/src/cli/loop.ts +1660 -0
  78. package/src/cli/manager.ts +878 -0
  79. package/src/cli/mcp.ts +275 -0
  80. package/src/cli/memory.ts +346 -0
  81. package/src/cli/metrics.ts +590 -0
  82. package/src/cli/monitor.ts +960 -0
  83. package/src/cli/mvp.ts +662 -0
  84. package/src/cli/onboard.ts +663 -0
  85. package/src/cli/orchestrator.ts +622 -0
  86. package/src/cli/plugin.ts +483 -0
  87. package/src/cli/prd.ts +671 -0
  88. package/src/cli/preseed-start.ts +1633 -0
  89. package/src/cli/preseed.ts +2434 -0
  90. package/src/cli/project.ts +526 -0
  91. package/src/cli/quality.ts +885 -0
  92. package/src/cli/security.ts +1079 -0
  93. package/src/cli/seed.ts +1224 -0
  94. package/src/cli/skill.ts +537 -0
  95. package/src/cli/suggest.ts +1225 -0
  96. package/src/cli/switch.ts +518 -0
  97. package/src/cli/task.ts +780 -0
  98. package/src/cli/telemetry.ts +172 -0
  99. package/src/cli/todo.ts +627 -0
  100. package/src/cli/types.ts +15 -0
  101. package/src/cli/update.ts +334 -0
  102. package/src/cli/visualize.ts +609 -0
  103. package/src/cli/watch.ts +895 -0
  104. package/src/cli/workspace.ts +709 -0
  105. package/src/core/action-recorder.ts +673 -0
  106. package/src/core/analyze-workflow.ts +1453 -0
  107. package/src/core/api-client.ts +1120 -0
  108. package/src/core/audit-workflow.ts +1681 -0
  109. package/src/core/auth.ts +471 -0
  110. package/src/core/build-orchestrator.ts +509 -0
  111. package/src/core/build-state.ts +621 -0
  112. package/src/core/checkpoint-engine.ts +482 -0
  113. package/src/core/config.ts +1285 -0
  114. package/src/core/context-loader.ts +694 -0
  115. package/src/core/context.ts +410 -0
  116. package/src/core/deploy-workflow.ts +1085 -0
  117. package/src/core/entitlements.ts +322 -0
  118. package/src/core/github-sync.ts +720 -0
  119. package/src/core/index.ts +981 -0
  120. package/src/core/ingest.ts +1186 -0
  121. package/src/core/metrics-engine.ts +886 -0
  122. package/src/core/mvp.ts +847 -0
  123. package/src/core/onboard-workflow.ts +1293 -0
  124. package/src/core/policies.ts +81 -0
  125. package/src/core/preseed-workflow.ts +1163 -0
  126. package/src/core/preseed.ts +1826 -0
  127. package/src/core/project-context.ts +380 -0
  128. package/src/core/project-state.ts +699 -0
  129. package/src/core/r2-sync.ts +691 -0
  130. package/src/core/scaffold.ts +1715 -0
  131. package/src/core/session.ts +286 -0
  132. package/src/core/task-extractor.ts +799 -0
  133. package/src/core/telemetry.ts +371 -0
  134. package/src/core/tier-enforcement.ts +737 -0
  135. package/src/core/utils.ts +437 -0
  136. package/src/index.ts +29 -0
  137. package/src/intelligence/agent-collab.ts +2376 -0
  138. package/src/intelligence/auto-suggest.ts +713 -0
  139. package/src/intelligence/content-gen.ts +1351 -0
  140. package/src/intelligence/cross-project.ts +1692 -0
  141. package/src/intelligence/git-memory.ts +529 -0
  142. package/src/intelligence/index.ts +318 -0
  143. package/src/intelligence/orchestrator.ts +534 -0
  144. package/src/intelligence/prd.ts +466 -0
  145. package/src/intelligence/recommendations.ts +982 -0
  146. package/src/intelligence/workflow-composer.ts +1472 -0
  147. package/src/mcp/capabilities.ts +233 -0
  148. package/src/mcp/index.ts +37 -0
  149. package/src/mcp/registry.ts +1268 -0
  150. package/src/mcp/response-formatter.ts +797 -0
  151. package/src/mcp/server.ts +240 -0
  152. package/src/types/agent.ts +69 -0
  153. package/src/types/config.ts +86 -0
  154. package/src/types/context.ts +77 -0
  155. package/src/types/index.ts +53 -0
  156. package/src/types/mcp.ts +91 -0
  157. package/src/types/skills.ts +47 -0
  158. package/src/types/workflow.ts +155 -0
  159. package/generators/index.js +0 -18
@@ -0,0 +1,691 @@
1
+ /**
2
+ * Bootspring Cloud Context Sync (Cloudflare R2)
3
+ * Sync project context to cloud storage for backup and cross-device access
4
+ *
5
+ * @package bootspring
6
+ * @module core/r2-sync
7
+ */
8
+
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ import * as crypto from 'crypto';
12
+ import * as zlib from 'zlib';
13
+ import * as utils from './utils';
14
+ import * as projectState from './project-state';
15
+
16
+ // ============================================================================
17
+ // Types
18
+ // ============================================================================
19
+
20
+ export interface R2Config {
21
+ accountId: string;
22
+ accessKeyId: string;
23
+ secretAccessKey: string;
24
+ bucketName: string;
25
+ region: string;
26
+ }
27
+
28
+ export interface FileData {
29
+ content: string;
30
+ size: number;
31
+ mtime: string;
32
+ }
33
+
34
+ export interface PackageMetadata {
35
+ totalFiles: number;
36
+ totalSize: number;
37
+ checksum: string | null;
38
+ }
39
+
40
+ export interface ContextPackage {
41
+ version: string;
42
+ createdAt: string;
43
+ projectRoot: string;
44
+ files: Record<string, FileData>;
45
+ metadata: PackageMetadata;
46
+ }
47
+
48
+ export interface SyncManifest {
49
+ files: string[];
50
+ directories: string[];
51
+ exclude: string[];
52
+ }
53
+
54
+ export interface PushResult {
55
+ success: boolean;
56
+ version?: string | undefined;
57
+ fileCount?: number | undefined;
58
+ totalSize?: number | undefined;
59
+ error?: string | undefined;
60
+ }
61
+
62
+ export interface PullResult {
63
+ success: boolean;
64
+ version?: string | undefined;
65
+ restoredCount?: number | undefined;
66
+ totalSize?: number | undefined;
67
+ message?: string | undefined;
68
+ upToDate?: boolean | undefined;
69
+ error?: string | undefined;
70
+ }
71
+
72
+ export interface SyncStatus {
73
+ configured: boolean;
74
+ local: LocalSyncState;
75
+ remote: RemoteMetadata | null;
76
+ needsSync: boolean;
77
+ error?: string | undefined;
78
+ }
79
+
80
+ export interface LocalSyncState {
81
+ lastPush?: string | undefined;
82
+ lastPull?: string | undefined;
83
+ lastVersion?: string | undefined;
84
+ checksum?: string | undefined;
85
+ currentChecksum?: string | undefined;
86
+ updatedAt?: string | undefined;
87
+ }
88
+
89
+ export interface RemoteMetadata {
90
+ lastPush: string;
91
+ lastVersion: string;
92
+ checksum: string;
93
+ fileCount: number;
94
+ totalSize: number;
95
+ syncVersion: string;
96
+ }
97
+
98
+ export interface CredentialValidationResult {
99
+ valid: boolean;
100
+ error?: string | undefined;
101
+ }
102
+
103
+ export interface PushOptions {
104
+ createVersion?: boolean | undefined;
105
+ }
106
+
107
+ export interface PullOptions {
108
+ version?: string | undefined;
109
+ verifyChecksum?: boolean | undefined;
110
+ }
111
+
112
+ // ============================================================================
113
+ // Constants
114
+ // ============================================================================
115
+
116
+ export const SYNC_VERSION = '1.0.0';
117
+
118
+ // Files and directories to sync
119
+ export const SYNC_MANIFEST: SyncManifest = {
120
+ files: [
121
+ 'CLAUDE.md',
122
+ 'todo.md'
123
+ ],
124
+ directories: [
125
+ '.bootspring',
126
+ 'planning'
127
+ ],
128
+ exclude: [
129
+ 'node_modules',
130
+ '.git',
131
+ '*.log',
132
+ '.bootspring/logs/*',
133
+ '.bootspring/telemetry/*'
134
+ ]
135
+ };
136
+
137
+ // R2 path structure
138
+ const R2_PATHS = {
139
+ latest: (projectId: string) => `projects/${projectId}/latest/`,
140
+ versions: (projectId: string) => `projects/${projectId}/versions/`,
141
+ metadata: (projectId: string) => `projects/${projectId}/metadata.json`
142
+ };
143
+
144
+ // Local sync state file
145
+ const SYNC_STATE_FILE = '.bootspring/cloud-sync.json';
146
+
147
+ // ============================================================================
148
+ // R2 Client Management
149
+ // ============================================================================
150
+
151
+ // Lazy-loaded types
152
+ interface S3Client {
153
+ send: (command: unknown) => Promise<unknown>;
154
+ }
155
+
156
+ let r2Client: S3Client | null = null;
157
+
158
+ /**
159
+ * Get R2 configuration from environment or config
160
+ */
161
+ export function getR2Config(): R2Config | null {
162
+ const accountId = process.env['BOOTSPRING_R2_ACCOUNT_ID'];
163
+ const accessKeyId = process.env['BOOTSPRING_R2_ACCESS_KEY_ID'];
164
+ const secretAccessKey = process.env['BOOTSPRING_R2_SECRET_ACCESS_KEY'];
165
+ const bucketName = process.env['BOOTSPRING_R2_BUCKET_NAME'] || 'bootspring-context';
166
+ const region = process.env['BOOTSPRING_R2_REGION'] || 'auto';
167
+
168
+ // Check if essential credentials are present
169
+ if (!accountId || !accessKeyId || !secretAccessKey) {
170
+ return null;
171
+ }
172
+
173
+ return {
174
+ accountId,
175
+ accessKeyId,
176
+ secretAccessKey,
177
+ bucketName,
178
+ region
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Initialize R2 client (lazy loading)
184
+ */
185
+ function getR2Client(): S3Client | null {
186
+ if (r2Client) return r2Client;
187
+
188
+ const config = getR2Config();
189
+ if (!config) return null;
190
+
191
+ try {
192
+ // Use AWS SDK S3 client (R2 is S3-compatible)
193
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
194
+ const { S3Client } = require('@aws-sdk/client-s3') as {
195
+ S3Client: new (config: unknown) => S3Client;
196
+ };
197
+
198
+ r2Client = new S3Client({
199
+ region: config.region,
200
+ endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
201
+ credentials: {
202
+ accessKeyId: config.accessKeyId,
203
+ secretAccessKey: config.secretAccessKey
204
+ }
205
+ });
206
+
207
+ return r2Client;
208
+ } catch (error) {
209
+ const err = error as Error;
210
+ utils.print.debug(`R2 client initialization failed: ${err.message}`);
211
+ return null;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Check if R2 is configured
217
+ */
218
+ export function isConfigured(): boolean {
219
+ return getR2Config() !== null;
220
+ }
221
+
222
+ /**
223
+ * Validate R2 credentials by testing connection
224
+ */
225
+ export async function validateCredentials(): Promise<CredentialValidationResult> {
226
+ const client = getR2Client();
227
+ if (!client) {
228
+ return { valid: false, error: 'R2 credentials not configured' };
229
+ }
230
+
231
+ try {
232
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
233
+ const { HeadBucketCommand } = require('@aws-sdk/client-s3') as {
234
+ HeadBucketCommand: new (params: { Bucket: string }) => unknown;
235
+ };
236
+ const config = getR2Config();
237
+ if (!config) {
238
+ return { valid: false, error: 'R2 credentials not configured' };
239
+ }
240
+
241
+ await client.send(new HeadBucketCommand({ Bucket: config.bucketName }));
242
+ return { valid: true };
243
+ } catch (error) {
244
+ const err = error as Error & { name?: string };
245
+ if (err.name === 'NotFound') {
246
+ const config = getR2Config();
247
+ return { valid: false, error: `Bucket '${config?.bucketName || 'unknown'}' not found` };
248
+ }
249
+ return { valid: false, error: err.message };
250
+ }
251
+ }
252
+
253
+ // ============================================================================
254
+ // Context Package Management
255
+ // ============================================================================
256
+
257
+ /**
258
+ * Check if a path should be excluded from sync
259
+ */
260
+ function shouldExclude(relativePath: string): boolean {
261
+ for (const pattern of SYNC_MANIFEST.exclude) {
262
+ if (pattern.includes('*')) {
263
+ const regex = new RegExp(pattern.replace(/\*/g, '.*'));
264
+ if (regex.test(relativePath)) return true;
265
+ } else if (relativePath === pattern || relativePath.startsWith(pattern + '/')) {
266
+ return true;
267
+ }
268
+ }
269
+ return false;
270
+ }
271
+
272
+ /**
273
+ * Recursively collect files from a directory
274
+ */
275
+ function collectDirectoryFiles(
276
+ dirPath: string,
277
+ relativePath: string,
278
+ pkg: ContextPackage,
279
+ _projectRoot: string
280
+ ): void {
281
+ try {
282
+ const items = fs.readdirSync(dirPath, { withFileTypes: true });
283
+
284
+ for (const item of items) {
285
+ const fullPath = path.join(dirPath, item.name);
286
+ const itemRelativePath = path.join(relativePath, item.name);
287
+
288
+ // Check exclusions
289
+ if (shouldExclude(itemRelativePath)) continue;
290
+
291
+ if (item.isDirectory()) {
292
+ collectDirectoryFiles(fullPath, itemRelativePath, pkg, _projectRoot);
293
+ } else if (item.isFile()) {
294
+ try {
295
+ const content = fs.readFileSync(fullPath, 'utf-8');
296
+ pkg.files[itemRelativePath] = {
297
+ content,
298
+ size: Buffer.byteLength(content),
299
+ mtime: fs.statSync(fullPath).mtime.toISOString()
300
+ };
301
+ pkg.metadata.totalFiles++;
302
+ pkg.metadata.totalSize += Buffer.byteLength(content);
303
+ } catch {
304
+ // Skip binary or unreadable files
305
+ }
306
+ }
307
+ }
308
+ } catch {
309
+ // Skip inaccessible directories
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Generate a context package from project files
315
+ */
316
+ export function generateContextPackage(projectRoot: string): ContextPackage {
317
+ const pkg: ContextPackage = {
318
+ version: SYNC_VERSION,
319
+ createdAt: new Date().toISOString(),
320
+ projectRoot: projectRoot,
321
+ files: {},
322
+ metadata: {
323
+ totalFiles: 0,
324
+ totalSize: 0,
325
+ checksum: null
326
+ }
327
+ };
328
+
329
+ // Collect files
330
+ for (const file of SYNC_MANIFEST.files) {
331
+ const filePath = path.join(projectRoot, file);
332
+ if (fs.existsSync(filePath)) {
333
+ const content = fs.readFileSync(filePath, 'utf-8');
334
+ pkg.files[file] = {
335
+ content,
336
+ size: Buffer.byteLength(content),
337
+ mtime: fs.statSync(filePath).mtime.toISOString()
338
+ };
339
+ pkg.metadata.totalFiles++;
340
+ pkg.metadata.totalSize += Buffer.byteLength(content);
341
+ }
342
+ }
343
+
344
+ // Collect directories
345
+ for (const dir of SYNC_MANIFEST.directories) {
346
+ const dirPath = path.join(projectRoot, dir);
347
+ if (fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory()) {
348
+ collectDirectoryFiles(dirPath, dir, pkg, projectRoot);
349
+ }
350
+ }
351
+
352
+ // Generate checksum
353
+ const contentHash = crypto.createHash('sha256');
354
+ for (const [filePath, fileData] of Object.entries(pkg.files)) {
355
+ contentHash.update(`${filePath}:${fileData.content}`);
356
+ }
357
+ pkg.metadata.checksum = contentHash.digest('hex');
358
+
359
+ return pkg;
360
+ }
361
+
362
+ /**
363
+ * Compress context package
364
+ */
365
+ export function compressPackage(pkg: ContextPackage): Buffer {
366
+ const json = JSON.stringify(pkg);
367
+ return zlib.gzipSync(json);
368
+ }
369
+
370
+ /**
371
+ * Decompress context package
372
+ */
373
+ export function decompressPackage(data: Buffer): ContextPackage {
374
+ const json = zlib.gunzipSync(data).toString('utf-8');
375
+ return JSON.parse(json) as ContextPackage;
376
+ }
377
+
378
+ // ============================================================================
379
+ // Sync Operations
380
+ // ============================================================================
381
+
382
+ /**
383
+ * Push context to R2
384
+ */
385
+ export async function pushContext(
386
+ projectRoot: string,
387
+ projectId: string,
388
+ options: PushOptions = {}
389
+ ): Promise<PushResult> {
390
+ const client = getR2Client();
391
+ if (!client) {
392
+ return { success: false, error: 'R2 not configured' };
393
+ }
394
+
395
+ try {
396
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
397
+ const { PutObjectCommand } = require('@aws-sdk/client-s3') as {
398
+ PutObjectCommand: new (params: unknown) => unknown;
399
+ };
400
+ const config = getR2Config();
401
+ if (!config) {
402
+ return { success: false, error: 'R2 not configured' };
403
+ }
404
+
405
+ // Generate context package
406
+ const pkg = generateContextPackage(projectRoot);
407
+ const compressed = compressPackage(pkg);
408
+ const version = `v${Date.now()}`;
409
+
410
+ // Upload to latest
411
+ await client.send(new PutObjectCommand({
412
+ Bucket: config.bucketName,
413
+ Key: `${R2_PATHS.latest(projectId)}context.gz`,
414
+ Body: compressed,
415
+ ContentType: 'application/gzip',
416
+ Metadata: {
417
+ version: version,
418
+ checksum: pkg.metadata.checksum || '',
419
+ fileCount: String(pkg.metadata.totalFiles),
420
+ syncVersion: SYNC_VERSION
421
+ }
422
+ }));
423
+
424
+ // Upload version backup if versioning enabled
425
+ if (options.createVersion !== false) {
426
+ await client.send(new PutObjectCommand({
427
+ Bucket: config.bucketName,
428
+ Key: `${R2_PATHS.versions(projectId)}${version}/context.gz`,
429
+ Body: compressed,
430
+ ContentType: 'application/gzip'
431
+ }));
432
+ }
433
+
434
+ // Update metadata
435
+ const metadata: RemoteMetadata = {
436
+ lastPush: new Date().toISOString(),
437
+ lastVersion: version,
438
+ checksum: pkg.metadata.checksum || '',
439
+ fileCount: pkg.metadata.totalFiles,
440
+ totalSize: pkg.metadata.totalSize,
441
+ syncVersion: SYNC_VERSION
442
+ };
443
+
444
+ await client.send(new PutObjectCommand({
445
+ Bucket: config.bucketName,
446
+ Key: R2_PATHS.metadata(projectId),
447
+ Body: JSON.stringify(metadata, null, 2),
448
+ ContentType: 'application/json'
449
+ }));
450
+
451
+ // Update local state
452
+ updateLocalSyncState(projectRoot, {
453
+ lastPush: metadata.lastPush,
454
+ lastVersion: version,
455
+ checksum: pkg.metadata.checksum || undefined
456
+ });
457
+
458
+ return {
459
+ success: true,
460
+ version,
461
+ fileCount: pkg.metadata.totalFiles,
462
+ totalSize: pkg.metadata.totalSize
463
+ };
464
+ } catch (error) {
465
+ const err = error as Error;
466
+ return { success: false, error: err.message };
467
+ }
468
+ }
469
+
470
+ /**
471
+ * Pull context from R2
472
+ */
473
+ export async function pullContext(
474
+ projectRoot: string,
475
+ projectId: string,
476
+ options: PullOptions = {}
477
+ ): Promise<PullResult> {
478
+ const client = getR2Client();
479
+ if (!client) {
480
+ return { success: false, error: 'R2 not configured' };
481
+ }
482
+
483
+ try {
484
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
485
+ const { GetObjectCommand } = require('@aws-sdk/client-s3') as {
486
+ GetObjectCommand: new (params: unknown) => unknown;
487
+ };
488
+ const config = getR2Config();
489
+ if (!config) {
490
+ return { success: false, error: 'R2 not configured' };
491
+ }
492
+
493
+ // Determine which version to pull
494
+ const key = options.version
495
+ ? `${R2_PATHS.versions(projectId)}${options.version}/context.gz`
496
+ : `${R2_PATHS.latest(projectId)}context.gz`;
497
+
498
+ // Download
499
+ interface GetObjectResponse {
500
+ Body: AsyncIterable<Uint8Array>;
501
+ Metadata?: Record<string, string>;
502
+ }
503
+ const response = await client.send(new GetObjectCommand({
504
+ Bucket: config.bucketName,
505
+ Key: key
506
+ })) as GetObjectResponse;
507
+
508
+ // Read stream to buffer
509
+ const chunks: Uint8Array[] = [];
510
+ for await (const chunk of response.Body) {
511
+ chunks.push(chunk);
512
+ }
513
+ const data = Buffer.concat(chunks);
514
+
515
+ // Decompress
516
+ const pkg = decompressPackage(data);
517
+
518
+ // Verify checksum if requested
519
+ if (options.verifyChecksum) {
520
+ const localPkg = generateContextPackage(projectRoot);
521
+ if (localPkg.metadata.checksum === pkg.metadata.checksum) {
522
+ return { success: true, message: 'Already up to date', upToDate: true };
523
+ }
524
+ }
525
+
526
+ // Restore files
527
+ let restoredCount = 0;
528
+ for (const [relativePath, fileData] of Object.entries(pkg.files)) {
529
+ const fullPath = path.join(projectRoot, relativePath);
530
+ const dir = path.dirname(fullPath);
531
+
532
+ // Create directory if needed
533
+ if (!fs.existsSync(dir)) {
534
+ fs.mkdirSync(dir, { recursive: true });
535
+ }
536
+
537
+ // Write file
538
+ fs.writeFileSync(fullPath, fileData.content, 'utf-8');
539
+ restoredCount++;
540
+ }
541
+
542
+ // Update local state
543
+ updateLocalSyncState(projectRoot, {
544
+ lastPull: new Date().toISOString(),
545
+ lastVersion: response.Metadata?.['version'] || 'unknown',
546
+ checksum: pkg.metadata.checksum || undefined
547
+ });
548
+
549
+ return {
550
+ success: true,
551
+ version: response.Metadata?.['version'],
552
+ restoredCount,
553
+ totalSize: pkg.metadata.totalSize
554
+ };
555
+ } catch (error) {
556
+ const err = error as Error & { name?: string };
557
+ if (err.name === 'NoSuchKey') {
558
+ return { success: false, error: 'No remote context found' };
559
+ }
560
+ return { success: false, error: err.message };
561
+ }
562
+ }
563
+
564
+ /**
565
+ * Get sync status
566
+ */
567
+ export async function getStatus(projectRoot: string, projectId: string): Promise<SyncStatus> {
568
+ const localState = getLocalSyncState(projectRoot);
569
+ const status: SyncStatus = {
570
+ configured: isConfigured(),
571
+ local: localState,
572
+ remote: null,
573
+ needsSync: false
574
+ };
575
+
576
+ if (!status.configured) {
577
+ return status;
578
+ }
579
+
580
+ try {
581
+ const client = getR2Client();
582
+ if (!client) {
583
+ return status;
584
+ }
585
+
586
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
587
+ const { GetObjectCommand } = require('@aws-sdk/client-s3') as {
588
+ GetObjectCommand: new (params: unknown) => unknown;
589
+ };
590
+ const config = getR2Config();
591
+ if (!config) {
592
+ return status;
593
+ }
594
+
595
+ interface GetObjectResponse {
596
+ Body: AsyncIterable<Uint8Array>;
597
+ }
598
+ const response = await client.send(new GetObjectCommand({
599
+ Bucket: config.bucketName,
600
+ Key: R2_PATHS.metadata(projectId)
601
+ })) as GetObjectResponse;
602
+
603
+ const chunks: Uint8Array[] = [];
604
+ for await (const chunk of response.Body) {
605
+ chunks.push(chunk);
606
+ }
607
+ status.remote = JSON.parse(Buffer.concat(chunks).toString('utf-8')) as RemoteMetadata;
608
+
609
+ // Check if sync needed
610
+ const localPkg = generateContextPackage(projectRoot);
611
+ status.local.currentChecksum = localPkg.metadata.checksum || undefined;
612
+ status.needsSync = localPkg.metadata.checksum !== status.remote?.checksum;
613
+ } catch (error) {
614
+ const err = error as Error & { name?: string };
615
+ if (err.name !== 'NoSuchKey') {
616
+ status.error = err.message;
617
+ }
618
+ }
619
+
620
+ return status;
621
+ }
622
+
623
+ /**
624
+ * List available versions
625
+ */
626
+ export async function listVersions(projectId: string): Promise<string[]> {
627
+ const client = getR2Client();
628
+ if (!client) return [];
629
+
630
+ try {
631
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
632
+ const { ListObjectsV2Command } = require('@aws-sdk/client-s3') as {
633
+ ListObjectsV2Command: new (params: unknown) => unknown;
634
+ };
635
+ const config = getR2Config();
636
+ if (!config) return [];
637
+
638
+ interface ListResponse {
639
+ CommonPrefixes?: Array<{ Prefix?: string }>;
640
+ }
641
+ const response = await client.send(new ListObjectsV2Command({
642
+ Bucket: config.bucketName,
643
+ Prefix: R2_PATHS.versions(projectId),
644
+ Delimiter: '/'
645
+ })) as ListResponse;
646
+
647
+ return (response.CommonPrefixes || [])
648
+ .map(p => p.Prefix?.split('/').filter(Boolean).pop())
649
+ .filter((v): v is string => v !== undefined)
650
+ .sort()
651
+ .reverse();
652
+ } catch {
653
+ return [];
654
+ }
655
+ }
656
+
657
+ // ============================================================================
658
+ // Local State Management
659
+ // ============================================================================
660
+
661
+ /**
662
+ * Get local sync state
663
+ */
664
+ export function getLocalSyncState(projectRoot: string): LocalSyncState {
665
+ const statePath = path.join(projectRoot, SYNC_STATE_FILE);
666
+ if (fs.existsSync(statePath)) {
667
+ try {
668
+ return JSON.parse(fs.readFileSync(statePath, 'utf-8')) as LocalSyncState;
669
+ } catch {
670
+ return {};
671
+ }
672
+ }
673
+ return {};
674
+ }
675
+
676
+ /**
677
+ * Update local sync state
678
+ */
679
+ export function updateLocalSyncState(projectRoot: string, updates: Partial<LocalSyncState>): void {
680
+ const statePath = path.join(projectRoot, SYNC_STATE_FILE);
681
+ const dir = path.dirname(statePath);
682
+
683
+ if (!fs.existsSync(dir)) {
684
+ fs.mkdirSync(dir, { recursive: true });
685
+ }
686
+
687
+ const current = getLocalSyncState(projectRoot);
688
+ const updated: LocalSyncState = { ...current, ...updates, updatedAt: new Date().toISOString() };
689
+
690
+ fs.writeFileSync(statePath, JSON.stringify(updated, null, 2));
691
+ }