@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,414 @@
1
+ /**
2
+ * Tests for initial-upload command (commands/initial-upload.ts)
3
+ *
4
+ * Covers:
5
+ * - runInitialUpload core logic with mocked API
6
+ * - Empty HQ directory handling
7
+ * - Progress tracking
8
+ * - Merge vs replace conflict handling
9
+ * - Error collection during upload
10
+ * - Sync state update after upload
11
+ * - Command registration in cloud-setup
12
+ */
13
+
14
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
15
+ import * as fs from 'fs';
16
+ import * as path from 'path';
17
+ import * as os from 'os';
18
+
19
+ // Mock the api-client before importing modules that use it
20
+ vi.mock('../utils/api-client.js', () => ({
21
+ apiRequest: vi.fn(),
22
+ getApiUrl: vi.fn(() => 'https://api.test.local'),
23
+ }));
24
+
25
+ // Mock credentials for cloud-setup registration tests
26
+ vi.mock('../utils/credentials.js', () => ({
27
+ readCredentials: vi.fn(() => ({
28
+ token: 'test-token',
29
+ userId: 'user_test',
30
+ email: 'test@example.com',
31
+ storedAt: new Date().toISOString(),
32
+ })),
33
+ isExpired: vi.fn(() => false),
34
+ }));
35
+
36
+ import { apiRequest } from '../utils/api-client.js';
37
+ import {
38
+ runInitialUpload,
39
+ writeProgress,
40
+ type InitialUploadResult,
41
+ } from '../commands/initial-upload.js';
42
+ import { readSyncState } from '../utils/sync.js';
43
+ import { Command } from 'commander';
44
+ import { registerCloudSetupCommand } from '../commands/cloud-setup.js';
45
+
46
+ const mockApiRequest = vi.mocked(apiRequest);
47
+
48
+ // ── Test helpers ─────────────────────────────────────────────────────────────
49
+
50
+ let tmpDir: string;
51
+
52
+ beforeEach(() => {
53
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-upload-test-'));
54
+ vi.clearAllMocks();
55
+ });
56
+
57
+ afterEach(() => {
58
+ try {
59
+ fs.rmSync(tmpDir, { recursive: true, force: true });
60
+ } catch {
61
+ // Ignore cleanup errors on Windows
62
+ }
63
+ });
64
+
65
+ /** Create a file in tmpDir with given relative path and content. */
66
+ function createFile(relativePath: string, content: string): string {
67
+ const absPath = path.join(tmpDir, relativePath);
68
+ const dir = path.dirname(absPath);
69
+ if (!fs.existsSync(dir)) {
70
+ fs.mkdirSync(dir, { recursive: true });
71
+ }
72
+ fs.writeFileSync(absPath, content);
73
+ return absPath;
74
+ }
75
+
76
+ /**
77
+ * Set up mocks for a typical upload flow:
78
+ * 1. GET /api/files/list -> empty
79
+ * 2. POST /api/files/upload -> success (for each file)
80
+ */
81
+ function mockEmptyRemoteAndSuccessfulUploads(fileCount: number): void {
82
+ // 1. list returns empty
83
+ mockApiRequest.mockResolvedValueOnce({
84
+ ok: true,
85
+ status: 200,
86
+ data: { files: [] },
87
+ });
88
+
89
+ // 2. each upload succeeds
90
+ for (let i = 0; i < fileCount; i++) {
91
+ mockApiRequest.mockResolvedValueOnce({ ok: true, status: 200 });
92
+ }
93
+ }
94
+
95
+ // ── runInitialUpload ─────────────────────────────────────────────────────────
96
+
97
+ describe('runInitialUpload', () => {
98
+ it('uploads all local files when remote is empty', async () => {
99
+ createFile('README.md', '# HQ');
100
+ createFile('workers/dev/worker.yaml', 'name: dev');
101
+ createFile('knowledge/index.md', '# Knowledge');
102
+
103
+ // list returns empty, 3 uploads succeed
104
+ mockEmptyRemoteAndSuccessfulUploads(3);
105
+
106
+ const result = await runInitialUpload(tmpDir, { quiet: true });
107
+
108
+ expect(result.totalFiles).toBe(3);
109
+ expect(result.uploaded).toBe(3);
110
+ expect(result.failed).toBe(0);
111
+ expect(result.errors).toEqual([]);
112
+ expect(result.skipped).toBe(false);
113
+ });
114
+
115
+ it('returns zero counts for empty HQ directory', async () => {
116
+ // list returns empty
117
+ mockApiRequest.mockResolvedValueOnce({
118
+ ok: true,
119
+ status: 200,
120
+ data: { files: [] },
121
+ });
122
+
123
+ const result = await runInitialUpload(tmpDir, { quiet: true });
124
+
125
+ expect(result.totalFiles).toBe(0);
126
+ expect(result.uploaded).toBe(0);
127
+ expect(result.failed).toBe(0);
128
+ expect(result.skipped).toBe(false);
129
+ });
130
+
131
+ it('respects ignore rules — skips .git, node_modules, .claude', async () => {
132
+ createFile('.git/config', 'gitconfig');
133
+ createFile('node_modules/dep/index.js', 'code');
134
+ createFile('.claude/config.json', '{}');
135
+ createFile('src/index.ts', 'code'); // only this should be uploaded
136
+
137
+ // list returns empty, 1 upload for src/index.ts
138
+ mockEmptyRemoteAndSuccessfulUploads(1);
139
+
140
+ const result = await runInitialUpload(tmpDir, { quiet: true });
141
+
142
+ expect(result.totalFiles).toBe(1);
143
+ expect(result.uploaded).toBe(1);
144
+ });
145
+
146
+ it('respects ignore rules — skips .env and .log files', async () => {
147
+ createFile('.env', 'SECRET=abc');
148
+ createFile('.env.local', 'LOCAL=abc');
149
+ createFile('debug.log', 'log data');
150
+ createFile('agents.md', 'agent config'); // only this should be uploaded
151
+
152
+ mockEmptyRemoteAndSuccessfulUploads(1);
153
+
154
+ const result = await runInitialUpload(tmpDir, { quiet: true });
155
+
156
+ expect(result.totalFiles).toBe(1);
157
+ expect(result.uploaded).toBe(1);
158
+ });
159
+
160
+ it('collects upload errors without stopping', async () => {
161
+ createFile('good.txt', 'good');
162
+ createFile('bad.txt', 'bad');
163
+
164
+ // list returns empty
165
+ mockApiRequest.mockResolvedValueOnce({
166
+ ok: true,
167
+ status: 200,
168
+ data: { files: [] },
169
+ });
170
+
171
+ // First upload succeeds
172
+ mockApiRequest.mockResolvedValueOnce({ ok: true, status: 200 });
173
+
174
+ // Second upload fails
175
+ mockApiRequest.mockResolvedValueOnce({
176
+ ok: false,
177
+ status: 413,
178
+ error: 'File too large',
179
+ });
180
+
181
+ const result = await runInitialUpload(tmpDir, { quiet: true });
182
+
183
+ expect(result.totalFiles).toBe(2);
184
+ expect(result.uploaded).toBe(1);
185
+ expect(result.failed).toBe(1);
186
+ expect(result.errors.length).toBe(1);
187
+ expect(result.errors[0]).toContain('Upload failed');
188
+ });
189
+
190
+ it('skips upload when onConflict is "skip" and remote has files', async () => {
191
+ createFile('local.txt', 'local content');
192
+
193
+ // list returns files
194
+ mockApiRequest.mockResolvedValueOnce({
195
+ ok: true,
196
+ status: 200,
197
+ data: { files: ['existing.txt'] },
198
+ });
199
+
200
+ const result = await runInitialUpload(tmpDir, {
201
+ quiet: true,
202
+ onConflict: 'skip',
203
+ });
204
+
205
+ expect(result.skipped).toBe(true);
206
+ expect(result.uploaded).toBe(0);
207
+ // No upload calls should have been made
208
+ expect(mockApiRequest).toHaveBeenCalledTimes(1); // only the list call
209
+ });
210
+
211
+ it('merges when onConflict is "merge" and remote has files', async () => {
212
+ createFile('local.txt', 'local content');
213
+
214
+ // list returns existing files
215
+ mockApiRequest.mockResolvedValueOnce({
216
+ ok: true,
217
+ status: 200,
218
+ data: { files: ['existing.txt'] },
219
+ });
220
+
221
+ // upload succeeds
222
+ mockApiRequest.mockResolvedValueOnce({ ok: true, status: 200 });
223
+
224
+ const result = await runInitialUpload(tmpDir, {
225
+ quiet: true,
226
+ onConflict: 'merge',
227
+ });
228
+
229
+ expect(result.skipped).toBe(false);
230
+ expect(result.uploaded).toBe(1);
231
+ // Should have: 1 list + 1 upload = 2 calls (no delete)
232
+ expect(mockApiRequest).toHaveBeenCalledTimes(2);
233
+ });
234
+
235
+ it('deletes remote files when onConflict is "replace"', async () => {
236
+ createFile('local.txt', 'local content');
237
+
238
+ // list returns existing files
239
+ mockApiRequest.mockResolvedValueOnce({
240
+ ok: true,
241
+ status: 200,
242
+ data: { files: ['existing.txt', 'old.txt'] },
243
+ });
244
+
245
+ // DELETE /api/files/all succeeds
246
+ mockApiRequest.mockResolvedValueOnce({ ok: true, status: 200 });
247
+
248
+ // upload succeeds
249
+ mockApiRequest.mockResolvedValueOnce({ ok: true, status: 200 });
250
+
251
+ const result = await runInitialUpload(tmpDir, {
252
+ quiet: true,
253
+ onConflict: 'replace',
254
+ });
255
+
256
+ expect(result.skipped).toBe(false);
257
+ expect(result.uploaded).toBe(1);
258
+
259
+ // Verify DELETE was called
260
+ expect(mockApiRequest).toHaveBeenCalledWith('DELETE', '/api/files/all');
261
+ });
262
+
263
+ it('proceeds when remote list API fails (treats as empty)', async () => {
264
+ createFile('file.txt', 'content');
265
+
266
+ // list fails (e.g., endpoint not deployed yet)
267
+ mockApiRequest.mockRejectedValueOnce(new Error('Network error'));
268
+
269
+ // upload succeeds
270
+ mockApiRequest.mockResolvedValueOnce({ ok: true, status: 200 });
271
+
272
+ const result = await runInitialUpload(tmpDir, { quiet: true });
273
+
274
+ expect(result.uploaded).toBe(1);
275
+ expect(result.skipped).toBe(false);
276
+ });
277
+
278
+ it('updates sync state after successful upload', async () => {
279
+ createFile('file.txt', 'content');
280
+
281
+ mockEmptyRemoteAndSuccessfulUploads(1);
282
+
283
+ await runInitialUpload(tmpDir, { quiet: true });
284
+
285
+ const state = readSyncState(tmpDir);
286
+ expect(state.lastSync).toBeTruthy();
287
+ expect(state.fileCount).toBe(1);
288
+ expect(state.errors).toEqual([]);
289
+ });
290
+
291
+ it('updates sync state with errors after partial upload', async () => {
292
+ createFile('ok.txt', 'ok');
293
+ createFile('fail.txt', 'fail');
294
+
295
+ // list returns empty
296
+ mockApiRequest.mockResolvedValueOnce({
297
+ ok: true,
298
+ status: 200,
299
+ data: { files: [] },
300
+ });
301
+
302
+ // First upload succeeds
303
+ mockApiRequest.mockResolvedValueOnce({ ok: true, status: 200 });
304
+
305
+ // Second upload fails
306
+ mockApiRequest.mockResolvedValueOnce({
307
+ ok: false,
308
+ status: 500,
309
+ error: 'Server error',
310
+ });
311
+
312
+ const result = await runInitialUpload(tmpDir, { quiet: true });
313
+
314
+ const state = readSyncState(tmpDir);
315
+ expect(state.lastSync).toBeTruthy();
316
+ expect(state.errors.length).toBe(1);
317
+ });
318
+
319
+ it('does not call upload when no local files exist', async () => {
320
+ // list returns empty
321
+ mockApiRequest.mockResolvedValueOnce({
322
+ ok: true,
323
+ status: 200,
324
+ data: { files: [] },
325
+ });
326
+
327
+ const result = await runInitialUpload(tmpDir, { quiet: true });
328
+
329
+ expect(result.totalFiles).toBe(0);
330
+ // Only the list call
331
+ expect(mockApiRequest).toHaveBeenCalledTimes(1);
332
+ });
333
+
334
+ it('does not prompt when onConflict is specified and remote is empty', async () => {
335
+ createFile('file.txt', 'content');
336
+
337
+ mockEmptyRemoteAndSuccessfulUploads(1);
338
+
339
+ // Even with onConflict set, should work fine when remote is empty
340
+ const result = await runInitialUpload(tmpDir, {
341
+ quiet: true,
342
+ onConflict: 'merge',
343
+ });
344
+
345
+ expect(result.uploaded).toBe(1);
346
+ expect(result.skipped).toBe(false);
347
+ });
348
+ });
349
+
350
+ // ── writeProgress ────────────────────────────────────────────────────────────
351
+
352
+ describe('writeProgress', () => {
353
+ it('does not throw for valid inputs', () => {
354
+ // writeProgress writes to stdout; just ensure no errors
355
+ expect(() => writeProgress(0, 100)).not.toThrow();
356
+ expect(() => writeProgress(50, 100)).not.toThrow();
357
+ expect(() => writeProgress(100, 100)).not.toThrow();
358
+ });
359
+
360
+ it('handles zero total without error', () => {
361
+ expect(() => writeProgress(0, 0)).not.toThrow();
362
+ });
363
+ });
364
+
365
+ // ── Command registration ─────────────────────────────────────────────────────
366
+
367
+ describe('cloud upload command registration', () => {
368
+ it('registers "upload" subcommand under "cloud"', () => {
369
+ const program = new Command();
370
+ registerCloudSetupCommand(program);
371
+
372
+ const cloudCmd = program.commands.find((c) => c.name() === 'cloud');
373
+ expect(cloudCmd).toBeDefined();
374
+
375
+ const uploadCmd = cloudCmd!.commands.find((c) => c.name() === 'upload');
376
+ expect(uploadCmd).toBeDefined();
377
+ expect(uploadCmd!.description()).toContain('Upload');
378
+ });
379
+
380
+ it('"upload" subcommand accepts --hq-root option', () => {
381
+ const program = new Command();
382
+ registerCloudSetupCommand(program);
383
+
384
+ const cloudCmd = program.commands.find((c) => c.name() === 'cloud');
385
+ const uploadCmd = cloudCmd!.commands.find((c) => c.name() === 'upload');
386
+ expect(uploadCmd).toBeDefined();
387
+
388
+ // Check that the option is registered
389
+ const options = uploadCmd!.options.map((o) => o.long);
390
+ expect(options).toContain('--hq-root');
391
+ });
392
+
393
+ it('"upload" subcommand accepts --on-conflict option', () => {
394
+ const program = new Command();
395
+ registerCloudSetupCommand(program);
396
+
397
+ const cloudCmd = program.commands.find((c) => c.name() === 'cloud');
398
+ const uploadCmd = cloudCmd!.commands.find((c) => c.name() === 'upload');
399
+
400
+ const options = uploadCmd!.options.map((o) => o.long);
401
+ expect(options).toContain('--on-conflict');
402
+ });
403
+
404
+ it('cloud command group still has setup-token and status alongside upload', () => {
405
+ const program = new Command();
406
+ registerCloudSetupCommand(program);
407
+
408
+ const cloudCmd = program.commands.find((c) => c.name() === 'cloud');
409
+ const subcommandNames = cloudCmd!.commands.map((c) => c.name());
410
+ expect(subcommandNames).toContain('setup-token');
411
+ expect(subcommandNames).toContain('status');
412
+ expect(subcommandNames).toContain('upload');
413
+ });
414
+ });