@indigoai-us/hq-cli 5.1.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 (102) hide show
  1. package/dist/__tests__/credentials.test.d.ts +5 -0
  2. package/dist/__tests__/credentials.test.d.ts.map +1 -0
  3. package/dist/__tests__/credentials.test.js +169 -0
  4. package/dist/__tests__/credentials.test.js.map +1 -0
  5. package/dist/commands/add.d.ts +6 -0
  6. package/dist/commands/add.d.ts.map +1 -0
  7. package/dist/commands/add.js +60 -0
  8. package/dist/commands/add.js.map +1 -0
  9. package/dist/commands/auth.d.ts +17 -0
  10. package/dist/commands/auth.d.ts.map +1 -0
  11. package/dist/commands/auth.js +269 -0
  12. package/dist/commands/auth.js.map +1 -0
  13. package/dist/commands/cloud-setup.d.ts +19 -0
  14. package/dist/commands/cloud-setup.d.ts.map +1 -0
  15. package/dist/commands/cloud-setup.js +206 -0
  16. package/dist/commands/cloud-setup.js.map +1 -0
  17. package/dist/commands/cloud.d.ts +16 -0
  18. package/dist/commands/cloud.d.ts.map +1 -0
  19. package/dist/commands/cloud.js +263 -0
  20. package/dist/commands/cloud.js.map +1 -0
  21. package/dist/commands/initial-upload.d.ts +67 -0
  22. package/dist/commands/initial-upload.d.ts.map +1 -0
  23. package/dist/commands/initial-upload.js +205 -0
  24. package/dist/commands/initial-upload.js.map +1 -0
  25. package/dist/commands/list.d.ts +6 -0
  26. package/dist/commands/list.d.ts.map +1 -0
  27. package/dist/commands/list.js +55 -0
  28. package/dist/commands/list.js.map +1 -0
  29. package/dist/commands/sync.d.ts +6 -0
  30. package/dist/commands/sync.d.ts.map +1 -0
  31. package/dist/commands/sync.js +104 -0
  32. package/dist/commands/sync.js.map +1 -0
  33. package/dist/commands/update.d.ts +7 -0
  34. package/dist/commands/update.d.ts.map +1 -0
  35. package/dist/commands/update.js +60 -0
  36. package/dist/commands/update.js.map +1 -0
  37. package/dist/index.d.ts +6 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +36 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/strategies/link.d.ts +7 -0
  42. package/dist/strategies/link.d.ts.map +1 -0
  43. package/dist/strategies/link.js +51 -0
  44. package/dist/strategies/link.js.map +1 -0
  45. package/dist/strategies/merge.d.ts +7 -0
  46. package/dist/strategies/merge.d.ts.map +1 -0
  47. package/dist/strategies/merge.js +110 -0
  48. package/dist/strategies/merge.js.map +1 -0
  49. package/dist/sync-worker.d.ts +11 -0
  50. package/dist/sync-worker.d.ts.map +1 -0
  51. package/dist/sync-worker.js +77 -0
  52. package/dist/sync-worker.js.map +1 -0
  53. package/dist/types.d.ts +41 -0
  54. package/dist/types.d.ts.map +1 -0
  55. package/dist/types.js +5 -0
  56. package/dist/types.js.map +1 -0
  57. package/dist/utils/api-client.d.ts +26 -0
  58. package/dist/utils/api-client.d.ts.map +1 -0
  59. package/dist/utils/api-client.js +87 -0
  60. package/dist/utils/api-client.js.map +1 -0
  61. package/dist/utils/credentials.d.ts +44 -0
  62. package/dist/utils/credentials.d.ts.map +1 -0
  63. package/dist/utils/credentials.js +101 -0
  64. package/dist/utils/credentials.js.map +1 -0
  65. package/dist/utils/git.d.ts +13 -0
  66. package/dist/utils/git.d.ts.map +1 -0
  67. package/dist/utils/git.js +70 -0
  68. package/dist/utils/git.js.map +1 -0
  69. package/dist/utils/manifest.d.ts +16 -0
  70. package/dist/utils/manifest.d.ts.map +1 -0
  71. package/dist/utils/manifest.js +95 -0
  72. package/dist/utils/manifest.js.map +1 -0
  73. package/dist/utils/sync.d.ts +125 -0
  74. package/dist/utils/sync.d.ts.map +1 -0
  75. package/dist/utils/sync.js +291 -0
  76. package/dist/utils/sync.js.map +1 -0
  77. package/package.json +36 -0
  78. package/src/__tests__/cloud-setup.test.ts +117 -0
  79. package/src/__tests__/credentials.test.ts +203 -0
  80. package/src/__tests__/initial-upload.test.ts +414 -0
  81. package/src/__tests__/sync.test.ts +627 -0
  82. package/src/commands/add.ts +74 -0
  83. package/src/commands/auth.ts +303 -0
  84. package/src/commands/cloud-setup.ts +251 -0
  85. package/src/commands/cloud.ts +300 -0
  86. package/src/commands/initial-upload.ts +263 -0
  87. package/src/commands/list.ts +66 -0
  88. package/src/commands/sync.ts +149 -0
  89. package/src/commands/update.ts +71 -0
  90. package/src/hq-cloud.d.ts +19 -0
  91. package/src/index.ts +46 -0
  92. package/src/strategies/link.ts +62 -0
  93. package/src/strategies/merge.ts +142 -0
  94. package/src/sync-worker.ts +82 -0
  95. package/src/types.ts +47 -0
  96. package/src/utils/api-client.ts +111 -0
  97. package/src/utils/credentials.ts +124 -0
  98. package/src/utils/git.ts +74 -0
  99. package/src/utils/manifest.ts +111 -0
  100. package/src/utils/sync.ts +381 -0
  101. package/tsconfig.json +9 -0
  102. package/vitest.config.ts +8 -0
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Sync utilities for hq-cloud file synchronization.
3
+ *
4
+ * Provides local manifest computation, file hashing, upload/download helpers,
5
+ * and diff computation via the API proxy. No AWS credentials needed — all
6
+ * operations go through the authenticated hq-cloud API.
7
+ */
8
+
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ import * as crypto from 'crypto';
12
+ import { apiRequest } from './api-client.js';
13
+
14
+ // ── Ignore patterns ──────────────────────────────────────────────────────────
15
+
16
+ /** Directories and patterns to skip during local file scanning. */
17
+ const IGNORE_DIRS = new Set([
18
+ '.git',
19
+ 'node_modules',
20
+ '.claude',
21
+ 'dist',
22
+ 'cdk.out',
23
+ '.next',
24
+ '__pycache__',
25
+ '.turbo',
26
+ ]);
27
+
28
+ /** File extensions to skip. */
29
+ const IGNORE_EXTENSIONS = new Set([
30
+ '.log',
31
+ ]);
32
+
33
+ /** Top-level files to skip. */
34
+ const IGNORE_FILES = new Set([
35
+ '.DS_Store',
36
+ 'Thumbs.db',
37
+ '.env',
38
+ '.env.local',
39
+ ]);
40
+
41
+ /**
42
+ * Check whether a relative path should be ignored during sync.
43
+ */
44
+ export function shouldIgnore(relativePath: string): boolean {
45
+ const parts = relativePath.split(/[/\\]/);
46
+
47
+ // Skip if any path segment matches an ignored directory
48
+ for (const part of parts) {
49
+ if (IGNORE_DIRS.has(part)) {
50
+ return true;
51
+ }
52
+ }
53
+
54
+ // Skip ignored file names
55
+ const fileName = parts[parts.length - 1];
56
+ if (IGNORE_FILES.has(fileName)) {
57
+ return true;
58
+ }
59
+
60
+ // Skip ignored extensions
61
+ const ext = path.extname(fileName).toLowerCase();
62
+ if (IGNORE_EXTENSIONS.has(ext)) {
63
+ return true;
64
+ }
65
+
66
+ return false;
67
+ }
68
+
69
+ // ── File hashing ─────────────────────────────────────────────────────────────
70
+
71
+ /**
72
+ * Compute the SHA-256 hash of a file's contents.
73
+ */
74
+ export function hashFile(filePath: string): string {
75
+ const content = fs.readFileSync(filePath);
76
+ return crypto.createHash('sha256').update(content).digest('hex');
77
+ }
78
+
79
+ /**
80
+ * Compute the SHA-256 hash of a buffer.
81
+ */
82
+ export function hashBuffer(content: Buffer): string {
83
+ return crypto.createHash('sha256').update(content).digest('hex');
84
+ }
85
+
86
+ // ── Manifest types ───────────────────────────────────────────────────────────
87
+
88
+ /** A single entry in the local file manifest. */
89
+ export interface ManifestEntry {
90
+ /** Relative path from HQ root (forward slashes) */
91
+ path: string;
92
+ /** SHA-256 hash of file content */
93
+ hash: string;
94
+ /** File size in bytes */
95
+ size: number;
96
+ /** Last modified time (ISO string) */
97
+ lastModified: string;
98
+ }
99
+
100
+ /** Response from POST /api/files/sync */
101
+ export interface SyncDiffResult {
102
+ /** Relative paths that the client should upload (local is newer) */
103
+ toUpload: string[];
104
+ /** Relative paths that the client should download (remote is newer) */
105
+ toDownload: string[];
106
+ }
107
+
108
+ /** Response from GET /api/files/quota */
109
+ export interface QuotaInfo {
110
+ used: number;
111
+ limit: number;
112
+ percentage: number;
113
+ }
114
+
115
+ /** Sync state persisted to .hq-cloud-sync.json */
116
+ export interface CloudSyncState {
117
+ /** Whether a background sync watcher is running */
118
+ running: boolean;
119
+ /** PID of the background watcher process (if running) */
120
+ pid?: number;
121
+ /** ISO timestamp of last successful sync */
122
+ lastSync?: string;
123
+ /** Number of files tracked at last sync */
124
+ fileCount?: number;
125
+ /** Errors from last sync attempt */
126
+ errors: string[];
127
+ }
128
+
129
+ // ── Local manifest computation ───────────────────────────────────────────────
130
+
131
+ /**
132
+ * Recursively walk a directory and collect all files, respecting ignore rules.
133
+ * Returns paths relative to rootDir, using forward slashes.
134
+ */
135
+ export function walkDir(rootDir: string, subDir: string = ''): string[] {
136
+ const results: string[] = [];
137
+ const absDir = subDir ? path.join(rootDir, subDir) : rootDir;
138
+
139
+ let entries: fs.Dirent[];
140
+ try {
141
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
142
+ } catch {
143
+ return results;
144
+ }
145
+
146
+ for (const entry of entries) {
147
+ const relativePath = subDir
148
+ ? `${subDir}/${entry.name}`
149
+ : entry.name;
150
+
151
+ if (entry.isDirectory()) {
152
+ if (!IGNORE_DIRS.has(entry.name)) {
153
+ results.push(...walkDir(rootDir, relativePath));
154
+ }
155
+ } else if (entry.isFile()) {
156
+ if (!shouldIgnore(relativePath)) {
157
+ results.push(relativePath);
158
+ }
159
+ }
160
+ // Skip symlinks, sockets, etc.
161
+ }
162
+
163
+ return results;
164
+ }
165
+
166
+ /**
167
+ * Compute the local file manifest for an HQ root directory.
168
+ * Walks all non-ignored files, computes hashes, and returns manifest entries.
169
+ */
170
+ export function computeLocalManifest(hqRoot: string): ManifestEntry[] {
171
+ const files = walkDir(hqRoot);
172
+ const manifest: ManifestEntry[] = [];
173
+
174
+ for (const relativePath of files) {
175
+ const absPath = path.join(hqRoot, relativePath);
176
+ try {
177
+ const stat = fs.statSync(absPath);
178
+ const hash = hashFile(absPath);
179
+ manifest.push({
180
+ path: relativePath,
181
+ hash,
182
+ size: stat.size,
183
+ lastModified: stat.mtime.toISOString(),
184
+ });
185
+ } catch {
186
+ // File may have been deleted between walk and stat — skip
187
+ }
188
+ }
189
+
190
+ return manifest;
191
+ }
192
+
193
+ // ── API operations ───────────────────────────────────────────────────────────
194
+
195
+ /**
196
+ * Compute the diff between local and remote state via the API.
197
+ * Sends the local manifest to POST /api/files/sync and receives
198
+ * lists of files to upload and download.
199
+ */
200
+ export async function syncDiff(hqRoot: string): Promise<SyncDiffResult> {
201
+ const manifest = computeLocalManifest(hqRoot);
202
+ const resp = await apiRequest<SyncDiffResult>('POST', '/api/files/sync', { manifest });
203
+
204
+ if (!resp.ok || !resp.data) {
205
+ throw new Error(`Sync diff failed: ${resp.error ?? `HTTP ${resp.status}`}`);
206
+ }
207
+
208
+ return resp.data;
209
+ }
210
+
211
+ /**
212
+ * Upload a single file to the cloud via POST /api/files/upload.
213
+ * File content is base64-encoded in the request body.
214
+ */
215
+ export async function uploadFile(filePath: string, hqRoot: string): Promise<void> {
216
+ const absPath = path.join(hqRoot, filePath);
217
+ const content = fs.readFileSync(absPath);
218
+ const stat = fs.statSync(absPath);
219
+
220
+ const resp = await apiRequest('POST', '/api/files/upload', {
221
+ path: filePath,
222
+ content: content.toString('base64'),
223
+ size: stat.size,
224
+ });
225
+
226
+ if (!resp.ok) {
227
+ throw new Error(`Upload failed for ${filePath}: ${resp.error ?? `HTTP ${resp.status}`}`);
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Download a single file from the cloud via GET /api/files/download.
233
+ * Writes the file to the local HQ root, creating directories as needed.
234
+ */
235
+ export async function downloadFile(remotePath: string, hqRoot: string): Promise<void> {
236
+ const encodedPath = encodeURIComponent(remotePath);
237
+ const resp = await apiRequest<{ content: string; size: number }>(
238
+ 'GET',
239
+ `/api/files/download?path=${encodedPath}`,
240
+ );
241
+
242
+ if (!resp.ok || !resp.data) {
243
+ throw new Error(`Download failed for ${remotePath}: ${resp.error ?? `HTTP ${resp.status}`}`);
244
+ }
245
+
246
+ const absPath = path.join(hqRoot, remotePath);
247
+ const dir = path.dirname(absPath);
248
+
249
+ if (!fs.existsSync(dir)) {
250
+ fs.mkdirSync(dir, { recursive: true });
251
+ }
252
+
253
+ const buffer = Buffer.from(resp.data.content, 'base64');
254
+ fs.writeFileSync(absPath, buffer);
255
+ }
256
+
257
+ /**
258
+ * Get storage quota information from the API.
259
+ */
260
+ export async function getQuota(): Promise<QuotaInfo> {
261
+ const resp = await apiRequest<QuotaInfo>('GET', '/api/files/quota');
262
+
263
+ if (!resp.ok || !resp.data) {
264
+ throw new Error(`Quota check failed: ${resp.error ?? `HTTP ${resp.status}`}`);
265
+ }
266
+
267
+ return resp.data;
268
+ }
269
+
270
+ // ── Sync state management ────────────────────────────────────────────────────
271
+
272
+ const SYNC_STATE_FILE = '.hq-cloud-sync.json';
273
+
274
+ /**
275
+ * Get the path to the sync state file.
276
+ */
277
+ export function getSyncStatePath(hqRoot: string): string {
278
+ return path.join(hqRoot, SYNC_STATE_FILE);
279
+ }
280
+
281
+ /**
282
+ * Read the persisted sync state. Returns a default state if no file exists.
283
+ */
284
+ export function readSyncState(hqRoot: string): CloudSyncState {
285
+ const statePath = getSyncStatePath(hqRoot);
286
+ try {
287
+ if (fs.existsSync(statePath)) {
288
+ const raw = fs.readFileSync(statePath, 'utf-8');
289
+ return JSON.parse(raw) as CloudSyncState;
290
+ }
291
+ } catch {
292
+ // Corrupted state file — return default
293
+ }
294
+ return { running: false, errors: [] };
295
+ }
296
+
297
+ /**
298
+ * Write the sync state to disk.
299
+ */
300
+ export function writeSyncState(hqRoot: string, state: CloudSyncState): void {
301
+ const statePath = getSyncStatePath(hqRoot);
302
+ fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
303
+ }
304
+
305
+ // ── Full push / pull operations ──────────────────────────────────────────────
306
+
307
+ /**
308
+ * Push all changed local files to the cloud.
309
+ * Uses the sync diff endpoint to determine what needs uploading, then uploads each file.
310
+ * Returns the number of files uploaded.
311
+ */
312
+ export async function pushChanges(hqRoot: string): Promise<{ uploaded: number; errors: string[] }> {
313
+ const diff = await syncDiff(hqRoot);
314
+ const errors: string[] = [];
315
+ let uploaded = 0;
316
+
317
+ for (const filePath of diff.toUpload) {
318
+ try {
319
+ await uploadFile(filePath, hqRoot);
320
+ uploaded++;
321
+ } catch (err) {
322
+ errors.push(err instanceof Error ? err.message : String(err));
323
+ }
324
+ }
325
+
326
+ return { uploaded, errors };
327
+ }
328
+
329
+ /**
330
+ * Pull all changed remote files to local.
331
+ * Uses the sync diff endpoint to determine what needs downloading, then downloads each file.
332
+ * Returns the number of files downloaded.
333
+ */
334
+ export async function pullChanges(hqRoot: string): Promise<{ downloaded: number; errors: string[] }> {
335
+ const diff = await syncDiff(hqRoot);
336
+ const errors: string[] = [];
337
+ let downloaded = 0;
338
+
339
+ for (const filePath of diff.toDownload) {
340
+ try {
341
+ await downloadFile(filePath, hqRoot);
342
+ downloaded++;
343
+ } catch (err) {
344
+ errors.push(err instanceof Error ? err.message : String(err));
345
+ }
346
+ }
347
+
348
+ return { downloaded, errors };
349
+ }
350
+
351
+ /**
352
+ * Run a full bidirectional sync: upload local changes, then download remote changes.
353
+ */
354
+ export async function fullSync(hqRoot: string): Promise<{ uploaded: number; downloaded: number; errors: string[] }> {
355
+ const diff = await syncDiff(hqRoot);
356
+ const errors: string[] = [];
357
+ let uploaded = 0;
358
+ let downloaded = 0;
359
+
360
+ // Upload first
361
+ for (const filePath of diff.toUpload) {
362
+ try {
363
+ await uploadFile(filePath, hqRoot);
364
+ uploaded++;
365
+ } catch (err) {
366
+ errors.push(err instanceof Error ? err.message : String(err));
367
+ }
368
+ }
369
+
370
+ // Then download
371
+ for (const filePath of diff.toDownload) {
372
+ try {
373
+ await downloadFile(filePath, hqRoot);
374
+ downloaded++;
375
+ } catch (err) {
376
+ errors.push(err instanceof Error ? err.message : String(err));
377
+ }
378
+ }
379
+
380
+ return { uploaded, downloaded, errors };
381
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"],
8
+ "exclude": ["src/__tests__"]
9
+ }
@@ -0,0 +1,8 @@
1
+ export default {
2
+ test: {
3
+ globals: false,
4
+ environment: 'node',
5
+ include: ['src/**/*.test.ts'],
6
+ testTimeout: 10000,
7
+ },
8
+ };