@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,300 @@
1
+ /**
2
+ * hq sync commands — cloud sync management via API proxy
3
+ *
4
+ * All sync operations go through the hq-cloud API (authenticated with Clerk
5
+ * tokens from US-002). No AWS credentials or direct S3 access needed.
6
+ *
7
+ * Commands:
8
+ * hq sync push — Upload changed local files to cloud
9
+ * hq sync pull — Download changed cloud files to local
10
+ * hq sync start — Begin background auto-sync watcher
11
+ * hq sync stop — Halt the background watcher
12
+ * hq sync status — Show sync state, last sync time, file counts
13
+ */
14
+
15
+ import { Command } from 'commander';
16
+ import { fork } from 'child_process';
17
+ import * as path from 'path';
18
+ import { fileURLToPath } from 'url';
19
+ import chalk from 'chalk';
20
+ import { findHqRoot } from '../utils/manifest.js';
21
+ import { readCredentials, isExpired } from '../utils/credentials.js';
22
+ import {
23
+ pushChanges,
24
+ pullChanges,
25
+ fullSync,
26
+ computeLocalManifest,
27
+ readSyncState,
28
+ writeSyncState,
29
+ getQuota,
30
+ type CloudSyncState,
31
+ } from '../utils/sync.js';
32
+
33
+ /**
34
+ * Verify that the user is authenticated before running sync commands.
35
+ * Throws if not logged in or token is expired.
36
+ */
37
+ function requireAuth(): void {
38
+ const creds = readCredentials();
39
+ if (!creds) {
40
+ throw new Error('Not logged in. Run "hq auth login" first.');
41
+ }
42
+ if (isExpired(creds)) {
43
+ throw new Error('Session expired. Run "hq auth login" to re-authenticate.');
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Check if a background sync process is actually running (not just recorded).
49
+ */
50
+ function isProcessRunning(pid: number): boolean {
51
+ try {
52
+ // Sending signal 0 checks if process exists without killing it
53
+ process.kill(pid, 0);
54
+ return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ export function registerCloudCommands(program: Command): void {
61
+ // ── hq sync push ────────────────────────────────────────────────────────
62
+
63
+ program
64
+ .command('push')
65
+ .description('Upload changed local files to cloud via API proxy')
66
+ .action(async () => {
67
+ try {
68
+ requireAuth();
69
+ const hqRoot = findHqRoot();
70
+
71
+ console.log(chalk.blue('Computing local manifest...'));
72
+ const manifest = computeLocalManifest(hqRoot);
73
+ console.log(chalk.dim(` ${manifest.length} local files scanned`));
74
+
75
+ console.log(chalk.blue('Checking for changes...'));
76
+ const result = await pushChanges(hqRoot);
77
+
78
+ if (result.uploaded === 0 && result.errors.length === 0) {
79
+ console.log(chalk.green('Already up to date. No files to push.'));
80
+ } else {
81
+ console.log(chalk.green(`Pushed ${result.uploaded} file${result.uploaded !== 1 ? 's' : ''} to cloud.`));
82
+ }
83
+
84
+ if (result.errors.length > 0) {
85
+ console.log(chalk.yellow(` ${result.errors.length} error${result.errors.length !== 1 ? 's' : ''}:`));
86
+ for (const err of result.errors.slice(0, 5)) {
87
+ console.log(chalk.red(` - ${err}`));
88
+ }
89
+ if (result.errors.length > 5) {
90
+ console.log(chalk.dim(` ... and ${result.errors.length - 5} more`));
91
+ }
92
+ }
93
+
94
+ // Update sync state
95
+ const state = readSyncState(hqRoot);
96
+ state.lastSync = new Date().toISOString();
97
+ state.fileCount = manifest.length;
98
+ state.errors = result.errors;
99
+ writeSyncState(hqRoot, state);
100
+ } catch (error) {
101
+ console.error(chalk.red('Push failed:'), error instanceof Error ? error.message : error);
102
+ process.exit(1);
103
+ }
104
+ });
105
+
106
+ // ── hq sync pull ────────────────────────────────────────────────────────
107
+
108
+ program
109
+ .command('pull')
110
+ .description('Download changed cloud files to local via API proxy')
111
+ .action(async () => {
112
+ try {
113
+ requireAuth();
114
+ const hqRoot = findHqRoot();
115
+
116
+ console.log(chalk.blue('Computing local manifest...'));
117
+ const manifest = computeLocalManifest(hqRoot);
118
+ console.log(chalk.dim(` ${manifest.length} local files scanned`));
119
+
120
+ console.log(chalk.blue('Checking for changes...'));
121
+ const result = await pullChanges(hqRoot);
122
+
123
+ if (result.downloaded === 0 && result.errors.length === 0) {
124
+ console.log(chalk.green('Already up to date. No files to pull.'));
125
+ } else {
126
+ console.log(chalk.green(`Pulled ${result.downloaded} file${result.downloaded !== 1 ? 's' : ''} from cloud.`));
127
+ }
128
+
129
+ if (result.errors.length > 0) {
130
+ console.log(chalk.yellow(` ${result.errors.length} error${result.errors.length !== 1 ? 's' : ''}:`));
131
+ for (const err of result.errors.slice(0, 5)) {
132
+ console.log(chalk.red(` - ${err}`));
133
+ }
134
+ if (result.errors.length > 5) {
135
+ console.log(chalk.dim(` ... and ${result.errors.length - 5} more`));
136
+ }
137
+ }
138
+
139
+ // Update sync state
140
+ const state = readSyncState(hqRoot);
141
+ state.lastSync = new Date().toISOString();
142
+ state.fileCount = manifest.length;
143
+ state.errors = result.errors;
144
+ writeSyncState(hqRoot, state);
145
+ } catch (error) {
146
+ console.error(chalk.red('Pull failed:'), error instanceof Error ? error.message : error);
147
+ process.exit(1);
148
+ }
149
+ });
150
+
151
+ // ── hq sync start ──────────────────────────────────────────────────────
152
+
153
+ program
154
+ .command('start')
155
+ .description('Start background auto-sync watcher (polls every 30s)')
156
+ .option('-i, --interval <seconds>', 'Polling interval in seconds', '30')
157
+ .action(async (opts: { interval: string }) => {
158
+ try {
159
+ requireAuth();
160
+ const hqRoot = findHqRoot();
161
+ const intervalSec = parseInt(opts.interval, 10);
162
+
163
+ if (isNaN(intervalSec) || intervalSec < 5) {
164
+ throw new Error('Interval must be at least 5 seconds.');
165
+ }
166
+
167
+ // Check if already running
168
+ const existingState = readSyncState(hqRoot);
169
+ if (existingState.running && existingState.pid && isProcessRunning(existingState.pid)) {
170
+ console.log(chalk.yellow(`Sync watcher already running (PID ${existingState.pid}).`));
171
+ console.log('Use "hq sync stop" to stop it first.');
172
+ return;
173
+ }
174
+
175
+ // Fork a background worker process
176
+ // The worker script path is relative to the compiled output
177
+ const thisFile = fileURLToPath(import.meta.url);
178
+ const workerScript = path.join(path.dirname(thisFile), '..', 'sync-worker.js');
179
+
180
+ const child = fork(workerScript, [hqRoot, String(intervalSec * 1000)], {
181
+ detached: true,
182
+ stdio: 'ignore',
183
+ });
184
+
185
+ if (!child.pid) {
186
+ throw new Error('Failed to start background sync process.');
187
+ }
188
+
189
+ // Allow the parent to exit without waiting for the child
190
+ child.unref();
191
+
192
+ // Record state
193
+ const state: CloudSyncState = {
194
+ running: true,
195
+ pid: child.pid,
196
+ lastSync: existingState.lastSync,
197
+ fileCount: existingState.fileCount,
198
+ errors: [],
199
+ };
200
+ writeSyncState(hqRoot, state);
201
+
202
+ console.log(chalk.green(`Sync watcher started (PID ${child.pid}, interval: ${intervalSec}s).`));
203
+ console.log('Use "hq sync status" to check, "hq sync stop" to halt.');
204
+ } catch (error) {
205
+ console.error(chalk.red('Start failed:'), error instanceof Error ? error.message : error);
206
+ process.exit(1);
207
+ }
208
+ });
209
+
210
+ // ── hq sync stop ───────────────────────────────────────────────────────
211
+
212
+ program
213
+ .command('stop')
214
+ .description('Stop the background sync watcher')
215
+ .action(async () => {
216
+ try {
217
+ const hqRoot = findHqRoot();
218
+ const state = readSyncState(hqRoot);
219
+
220
+ if (!state.running || !state.pid) {
221
+ console.log(chalk.yellow('No sync watcher is running.'));
222
+ return;
223
+ }
224
+
225
+ if (isProcessRunning(state.pid)) {
226
+ try {
227
+ process.kill(state.pid, 'SIGTERM');
228
+ console.log(chalk.green(`Sync watcher stopped (PID ${state.pid}).`));
229
+ } catch {
230
+ console.log(chalk.yellow(`Could not stop process ${state.pid} — it may have already exited.`));
231
+ }
232
+ } else {
233
+ console.log(chalk.dim(`Sync watcher process ${state.pid} is no longer running. Cleaning up state.`));
234
+ }
235
+
236
+ // Update state
237
+ state.running = false;
238
+ state.pid = undefined;
239
+ writeSyncState(hqRoot, state);
240
+ } catch (error) {
241
+ console.error(chalk.red('Stop failed:'), error instanceof Error ? error.message : error);
242
+ process.exit(1);
243
+ }
244
+ });
245
+
246
+ // ── hq sync status ─────────────────────────────────────────────────────
247
+
248
+ program
249
+ .command('status')
250
+ .description('Show sync state, last sync time, and file counts')
251
+ .action(async () => {
252
+ try {
253
+ const hqRoot = findHqRoot();
254
+ const state = readSyncState(hqRoot);
255
+
256
+ // Check if the recorded PID is actually alive
257
+ const actuallyRunning = state.running && state.pid
258
+ ? isProcessRunning(state.pid)
259
+ : false;
260
+
261
+ if (state.running && !actuallyRunning) {
262
+ // Stale state — clean it up
263
+ state.running = false;
264
+ state.pid = undefined;
265
+ writeSyncState(hqRoot, state);
266
+ }
267
+
268
+ console.log(chalk.bold('HQ Cloud Sync Status'));
269
+ console.log();
270
+ console.log(` Watcher: ${actuallyRunning ? chalk.green('running') + ` (PID ${state.pid})` : chalk.dim('stopped')}`);
271
+ console.log(` Last sync: ${state.lastSync ? state.lastSync : chalk.dim('never')}`);
272
+ console.log(` Files: ${state.fileCount != null ? `${state.fileCount} tracked` : chalk.dim('unknown')}`);
273
+ console.log(` HQ root: ${hqRoot}`);
274
+
275
+ if (state.errors.length > 0) {
276
+ console.log(` Errors: ${chalk.yellow(String(state.errors.length))}`);
277
+ for (const err of state.errors.slice(0, 5)) {
278
+ console.log(chalk.red(` - ${err}`));
279
+ }
280
+ }
281
+
282
+ // Try to fetch quota info (non-fatal if it fails)
283
+ try {
284
+ requireAuth();
285
+ const quota = await getQuota();
286
+ console.log();
287
+ console.log(chalk.bold(' Storage Quota'));
288
+ const usedMB = (quota.used / (1024 * 1024)).toFixed(1);
289
+ const limitMB = (quota.limit / (1024 * 1024)).toFixed(1);
290
+ const pctColor = quota.percentage > 90 ? chalk.red : quota.percentage > 70 ? chalk.yellow : chalk.green;
291
+ console.log(` Used: ${usedMB} MB / ${limitMB} MB (${pctColor(quota.percentage + '%')})`);
292
+ } catch {
293
+ // Quota info is optional — skip silently
294
+ }
295
+ } catch (error) {
296
+ console.error(chalk.red('Status check failed:'), error instanceof Error ? error.message : error);
297
+ process.exit(1);
298
+ }
299
+ });
300
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Initial HQ file upload for first-time cloud setup.
3
+ *
4
+ * After Clerk auth and Claude token setup, this command uploads the user's
5
+ * local HQ files to the cloud so the first session has access to the workspace.
6
+ *
7
+ * Respects the same ignore rules as sync (shouldIgnore from utils/sync.ts):
8
+ * - .git/, node_modules/, .claude/, dist/, cdk.out/, etc.
9
+ * - .log files, .env files, .DS_Store, Thumbs.db
10
+ *
11
+ * Exports `runInitialUpload` so it can be called programmatically from the
12
+ * create-hq installer (US-004) or other commands.
13
+ */
14
+
15
+ import * as readline from 'readline';
16
+ import chalk from 'chalk';
17
+ import { apiRequest } from '../utils/api-client.js';
18
+ import {
19
+ walkDir,
20
+ uploadFile,
21
+ computeLocalManifest,
22
+ readSyncState,
23
+ writeSyncState,
24
+ } from '../utils/sync.js';
25
+
26
+ /** Response shape from GET /api/files/list */
27
+ export interface RemoteFileList {
28
+ files: string[];
29
+ }
30
+
31
+ /** Result returned by runInitialUpload */
32
+ export interface InitialUploadResult {
33
+ /** Total local files discovered (after ignore filtering) */
34
+ totalFiles: number;
35
+ /** Number of files successfully uploaded */
36
+ uploaded: number;
37
+ /** Number of files that failed to upload */
38
+ failed: number;
39
+ /** Error messages for failed uploads */
40
+ errors: string[];
41
+ /** Whether the user chose to skip (remote had files and user declined) */
42
+ skipped: boolean;
43
+ }
44
+
45
+ /**
46
+ * Prompt the user with a yes/no question on stdin.
47
+ * Returns true for 'y'/'yes', false for 'n'/'no'.
48
+ * Defaults to defaultAnswer if user just presses Enter.
49
+ */
50
+ export function promptYesNo(
51
+ question: string,
52
+ defaultAnswer: boolean = true,
53
+ ): Promise<boolean> {
54
+ const hint = defaultAnswer ? '[Y/n]' : '[y/N]';
55
+ const rl = readline.createInterface({
56
+ input: process.stdin,
57
+ output: process.stdout,
58
+ });
59
+
60
+ return new Promise((resolve) => {
61
+ rl.question(`${question} ${hint} `, (answer) => {
62
+ rl.close();
63
+ const trimmed = answer.trim().toLowerCase();
64
+ if (trimmed === '') {
65
+ resolve(defaultAnswer);
66
+ } else {
67
+ resolve(trimmed === 'y' || trimmed === 'yes');
68
+ }
69
+ });
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Prompt the user to choose between merge and replace.
75
+ * Returns 'merge' or 'replace'.
76
+ */
77
+ export function promptMergeOrReplace(): Promise<'merge' | 'replace'> {
78
+ const rl = readline.createInterface({
79
+ input: process.stdin,
80
+ output: process.stdout,
81
+ });
82
+
83
+ return new Promise((resolve) => {
84
+ rl.question('Choose [m]erge or [r]eplace: ', (answer) => {
85
+ rl.close();
86
+ const trimmed = answer.trim().toLowerCase();
87
+ if (trimmed === 'r' || trimmed === 'replace') {
88
+ resolve('replace');
89
+ } else {
90
+ resolve('merge');
91
+ }
92
+ });
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Delete all remote files (used when user chooses 'replace').
98
+ */
99
+ async function deleteRemoteFiles(): Promise<void> {
100
+ const resp = await apiRequest('DELETE', '/api/files/all');
101
+ if (!resp.ok) {
102
+ throw new Error(`Failed to clear remote files: ${resp.error ?? `HTTP ${resp.status}`}`);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Write a progress line that overwrites the previous line.
108
+ * Falls back to newline output if stdout is not a TTY (e.g., in tests or pipes).
109
+ */
110
+ export function writeProgress(current: number, total: number): void {
111
+ const pct = total > 0 ? Math.round((current / total) * 100) : 0;
112
+ const line = `Uploading: ${current}/${total} files (${pct}%)`;
113
+
114
+ if (process.stdout.isTTY) {
115
+ process.stdout.write(`\r${line}`);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Run the initial HQ file upload.
121
+ *
122
+ * This is the core function that:
123
+ * 1. Lists remote files via GET /api/files/list
124
+ * 2. If remote has files, asks user to merge or replace
125
+ * 3. Walks local files (respecting ignore rules)
126
+ * 4. Uploads all files with progress indicator
127
+ * 5. Updates sync state
128
+ *
129
+ * @param hqRoot - Absolute path to the HQ root directory
130
+ * @param options - Optional overrides (for testing / programmatic use)
131
+ * @returns Upload result with counts and any errors
132
+ */
133
+ export async function runInitialUpload(
134
+ hqRoot: string,
135
+ options?: {
136
+ /** Override the merge/replace prompt (for non-interactive use) */
137
+ onConflict?: 'merge' | 'replace' | 'skip';
138
+ /** Suppress console output */
139
+ quiet?: boolean;
140
+ },
141
+ ): Promise<InitialUploadResult> {
142
+ const quiet = options?.quiet ?? false;
143
+
144
+ const log = (msg: string) => {
145
+ if (!quiet) console.log(msg);
146
+ };
147
+
148
+ // 1. Check remote state
149
+ log(chalk.blue('Checking cloud storage...'));
150
+
151
+ let remoteFiles: string[] = [];
152
+ try {
153
+ const resp = await apiRequest<RemoteFileList>('GET', '/api/files/list');
154
+ if (resp.ok && resp.data) {
155
+ remoteFiles = resp.data.files ?? [];
156
+ }
157
+ } catch {
158
+ // If the endpoint doesn't exist yet or fails, treat as empty
159
+ }
160
+
161
+ // 2. Handle existing remote files
162
+ if (remoteFiles.length > 0) {
163
+ log('');
164
+ log(chalk.yellow(`Cloud storage already has ${remoteFiles.length} file${remoteFiles.length !== 1 ? 's' : ''}.`));
165
+
166
+ let action: 'merge' | 'replace' | 'skip';
167
+
168
+ if (options?.onConflict) {
169
+ action = options.onConflict;
170
+ } else {
171
+ log(' merge — Upload local files, keeping existing remote files');
172
+ log(' replace — Delete all remote files first, then upload');
173
+ log('');
174
+ action = await promptMergeOrReplace();
175
+ }
176
+
177
+ if (action === 'skip') {
178
+ log(chalk.dim('Skipping upload.'));
179
+ return { totalFiles: 0, uploaded: 0, failed: 0, errors: [], skipped: true };
180
+ }
181
+
182
+ if (action === 'replace') {
183
+ log(chalk.dim('Clearing remote files...'));
184
+ await deleteRemoteFiles();
185
+ log(chalk.dim('Remote files cleared.'));
186
+ } else {
187
+ log(chalk.dim('Merging: existing remote files will be preserved.'));
188
+ }
189
+ }
190
+
191
+ // 3. Walk local files
192
+ log(chalk.blue('Scanning local HQ files...'));
193
+ const localFiles = walkDir(hqRoot);
194
+
195
+ if (localFiles.length === 0) {
196
+ log(chalk.yellow('No files found to upload.'));
197
+ return { totalFiles: 0, uploaded: 0, failed: 0, errors: [], skipped: false };
198
+ }
199
+
200
+ log(chalk.dim(` Found ${localFiles.length} file${localFiles.length !== 1 ? 's' : ''} to upload`));
201
+ log('');
202
+
203
+ // 4. Upload with progress
204
+ const errors: string[] = [];
205
+ let uploaded = 0;
206
+
207
+ for (let i = 0; i < localFiles.length; i++) {
208
+ const filePath = localFiles[i];
209
+
210
+ if (!quiet) {
211
+ writeProgress(i + 1, localFiles.length);
212
+ }
213
+
214
+ try {
215
+ await uploadFile(filePath, hqRoot);
216
+ uploaded++;
217
+ } catch (err) {
218
+ errors.push(
219
+ `${filePath}: ${err instanceof Error ? err.message : String(err)}`
220
+ );
221
+ }
222
+ }
223
+
224
+ // Clear the progress line
225
+ if (!quiet && process.stdout.isTTY) {
226
+ process.stdout.write('\r' + ' '.repeat(60) + '\r');
227
+ }
228
+
229
+ // 5. Report results
230
+ if (errors.length === 0) {
231
+ log(chalk.green(`Uploaded ${uploaded}/${localFiles.length} files successfully.`));
232
+ } else {
233
+ log(chalk.green(`Uploaded ${uploaded}/${localFiles.length} files.`));
234
+ log(chalk.yellow(` ${errors.length} error${errors.length !== 1 ? 's' : ''}:`));
235
+ for (const err of errors.slice(0, 5)) {
236
+ log(chalk.red(` - ${err}`));
237
+ }
238
+ if (errors.length > 5) {
239
+ log(chalk.dim(` ... and ${errors.length - 5} more`));
240
+ }
241
+ }
242
+
243
+ // 6. Update sync state
244
+ const manifest = computeLocalManifest(hqRoot);
245
+ const state = readSyncState(hqRoot);
246
+ state.lastSync = new Date().toISOString();
247
+ state.fileCount = manifest.length;
248
+ state.errors = errors;
249
+ writeSyncState(hqRoot, state);
250
+
251
+ if (errors.length === 0) {
252
+ log('');
253
+ log(chalk.green('Sync status: in sync'));
254
+ }
255
+
256
+ return {
257
+ totalFiles: localFiles.length,
258
+ uploaded,
259
+ failed: errors.length,
260
+ errors,
261
+ skipped: false,
262
+ };
263
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * hq modules list command (US-005)
3
+ */
4
+
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ import { Command } from 'commander';
8
+ import { findHqRoot, readManifest, readLock, getModulesDir } from '../utils/manifest.js';
9
+ import { isRepo, getCurrentCommit, isBehindRemote } from '../utils/git.js';
10
+
11
+ export function registerListCommand(program: Command): void {
12
+ program
13
+ .command('list')
14
+ .alias('ls')
15
+ .description('List all modules and their status')
16
+ .action(async () => {
17
+ try {
18
+ const hqRoot = findHqRoot();
19
+ const manifest = readManifest(hqRoot);
20
+
21
+ if (!manifest || manifest.modules.length === 0) {
22
+ console.log('No modules in manifest. Use "hq modules add" to add modules.');
23
+ return;
24
+ }
25
+
26
+ const modulesDir = getModulesDir(hqRoot);
27
+ const lock = readLock(hqRoot);
28
+
29
+ console.log('Modules:\n');
30
+
31
+ for (const module of manifest.modules) {
32
+ const moduleDir = path.join(modulesDir, module.name);
33
+ const installed = await isRepo(moduleDir);
34
+
35
+ console.log(` ${module.name}`);
36
+ console.log(` Repo: ${module.repo}`);
37
+ console.log(` Branch: ${module.branch || 'main'}`);
38
+ console.log(` Strategy: ${module.strategy}`);
39
+ console.log(` Paths: ${module.paths.map(p => `${p.src} -> ${p.dest}`).join(', ')}`);
40
+
41
+ if (installed) {
42
+ const commit = await getCurrentCommit(moduleDir);
43
+ const shortCommit = commit.slice(0, 7);
44
+ const lockedCommit = lock?.locked[module.name];
45
+ const isLocked = lockedCommit === commit;
46
+
47
+ console.log(` Status: ✓ installed @ ${shortCommit}${isLocked ? ' (locked)' : ''}`);
48
+
49
+ // Check if behind upstream
50
+ const { behind, commits } = await isBehindRemote(moduleDir);
51
+ if (behind) {
52
+ console.log(` Updates: ${commits} commit(s) behind remote`);
53
+ }
54
+ } else {
55
+ console.log(` Status: ✗ not installed`);
56
+ }
57
+
58
+ console.log();
59
+ }
60
+
61
+ } catch (error) {
62
+ console.error('Error:', error instanceof Error ? error.message : error);
63
+ process.exit(1);
64
+ }
65
+ });
66
+ }