@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,149 @@
1
+ /**
2
+ * hq modules sync command (US-004)
3
+ */
4
+
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ import { Command } from 'commander';
8
+ import {
9
+ findHqRoot,
10
+ readManifest,
11
+ readLock,
12
+ writeLock,
13
+ getModulesDir,
14
+ } from '../utils/manifest.js';
15
+ import {
16
+ cloneRepo,
17
+ fetchRepo,
18
+ pullRepo,
19
+ getCurrentCommit,
20
+ checkoutCommit,
21
+ isRepo,
22
+ ensureGitignore,
23
+ } from '../utils/git.js';
24
+ import { linkSync } from '../strategies/link.js';
25
+ import { mergeSync } from '../strategies/merge.js';
26
+ import type { ModuleDefinition, ModuleLock, SyncResult } from '../types.js';
27
+
28
+ async function syncModule(
29
+ module: ModuleDefinition,
30
+ moduleDir: string,
31
+ hqRoot: string,
32
+ locked: boolean,
33
+ lockData: ModuleLock | null
34
+ ): Promise<SyncResult> {
35
+ const repoExists = await isRepo(moduleDir);
36
+
37
+ // Clone or fetch
38
+ if (!repoExists) {
39
+ console.log(` Cloning ${module.name}...`);
40
+ await cloneRepo(module.repo, moduleDir, module.branch);
41
+ } else {
42
+ console.log(` Fetching ${module.name}...`);
43
+ await fetchRepo(moduleDir);
44
+
45
+ // Checkout locked commit if --locked
46
+ if (locked && lockData?.locked[module.name]) {
47
+ await checkoutCommit(moduleDir, lockData.locked[module.name]);
48
+ } else {
49
+ await pullRepo(moduleDir);
50
+ }
51
+ }
52
+
53
+ // Apply sync strategy
54
+ console.log(` Syncing with strategy: ${module.strategy}`);
55
+ let result: SyncResult;
56
+
57
+ switch (module.strategy) {
58
+ case 'link':
59
+ result = await linkSync(module, moduleDir, hqRoot);
60
+ break;
61
+ case 'merge':
62
+ case 'copy':
63
+ result = await mergeSync(module, moduleDir, hqRoot);
64
+ break;
65
+ default:
66
+ result = {
67
+ module: module.name,
68
+ success: false,
69
+ action: 'skipped',
70
+ message: `Unknown strategy: ${module.strategy}`,
71
+ };
72
+ }
73
+
74
+ return result;
75
+ }
76
+
77
+ export function registerSyncCommand(program: Command): void {
78
+ program
79
+ .command('sync')
80
+ .description('Sync all modules from manifest')
81
+ .option('--locked', 'Use locked versions from modules.lock')
82
+ .action(async (options: { locked?: boolean }) => {
83
+ try {
84
+ const hqRoot = findHqRoot();
85
+ const manifest = readManifest(hqRoot);
86
+
87
+ if (!manifest || manifest.modules.length === 0) {
88
+ console.log('No modules in manifest. Use "hq modules add" to add modules.');
89
+ return;
90
+ }
91
+
92
+ const modulesDir = getModulesDir(hqRoot);
93
+ if (!fs.existsSync(modulesDir)) {
94
+ fs.mkdirSync(modulesDir, { recursive: true });
95
+ }
96
+
97
+ // Ensure modules/ is gitignored
98
+ ensureGitignore(hqRoot, 'modules/');
99
+
100
+ const lockData = options.locked ? readLock(hqRoot) : null;
101
+ const results: SyncResult[] = [];
102
+ const newLock: ModuleLock = { version: '1', locked: {} };
103
+
104
+ console.log(`Syncing ${manifest.modules.length} module(s)...\n`);
105
+
106
+ for (const module of manifest.modules) {
107
+ console.log(`[${module.name}]`);
108
+ const moduleDir = path.join(modulesDir, module.name);
109
+
110
+ const result = await syncModule(
111
+ module,
112
+ moduleDir,
113
+ hqRoot,
114
+ options.locked ?? false,
115
+ lockData
116
+ );
117
+ results.push(result);
118
+
119
+ // Record commit for lock file
120
+ if (result.success) {
121
+ const commit = await getCurrentCommit(moduleDir);
122
+ newLock.locked[module.name] = commit;
123
+ }
124
+
125
+ const status = result.success ? '✓' : '✗';
126
+ const msg = result.message || `${result.filesChanged ?? 0} files`;
127
+ console.log(` ${status} ${result.action}: ${msg}\n`);
128
+ }
129
+
130
+ // Write lock file (US-008)
131
+ if (!options.locked) {
132
+ writeLock(hqRoot, newLock);
133
+ }
134
+
135
+ // Summary
136
+ const success = results.filter(r => r.success).length;
137
+ const failed = results.filter(r => !r.success).length;
138
+ console.log(`Done: ${success} succeeded, ${failed} failed`);
139
+
140
+ if (failed > 0) {
141
+ process.exit(1);
142
+ }
143
+
144
+ } catch (error) {
145
+ console.error('Error:', error instanceof Error ? error.message : error);
146
+ process.exit(1);
147
+ }
148
+ });
149
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * hq modules update command (US-008)
3
+ * Updates lock for specific module
4
+ */
5
+
6
+ import * as path from 'path';
7
+ import { Command } from 'commander';
8
+ import { findHqRoot, readManifest, readLock, writeLock, getModulesDir } from '../utils/manifest.js';
9
+ import { fetchRepo, pullRepo, getCurrentCommit, isRepo } from '../utils/git.js';
10
+
11
+ export function registerUpdateCommand(program: Command): void {
12
+ program
13
+ .command('update [module-name]')
14
+ .description('Update lock for a specific module (or all if no name given)')
15
+ .action(async (moduleName?: string) => {
16
+ try {
17
+ const hqRoot = findHqRoot();
18
+ const manifest = readManifest(hqRoot);
19
+
20
+ if (!manifest || manifest.modules.length === 0) {
21
+ console.log('No modules in manifest.');
22
+ return;
23
+ }
24
+
25
+ let lock = readLock(hqRoot);
26
+ if (!lock) {
27
+ lock = { version: '1', locked: {} };
28
+ }
29
+
30
+ const modulesDir = getModulesDir(hqRoot);
31
+ const modulesToUpdate = moduleName
32
+ ? manifest.modules.filter(m => m.name === moduleName)
33
+ : manifest.modules;
34
+
35
+ if (moduleName && modulesToUpdate.length === 0) {
36
+ console.error(`Module "${moduleName}" not found in manifest.`);
37
+ process.exit(1);
38
+ }
39
+
40
+ for (const module of modulesToUpdate) {
41
+ const moduleDir = path.join(modulesDir, module.name);
42
+
43
+ if (!await isRepo(moduleDir)) {
44
+ console.log(` ${module.name}: not installed, skipping`);
45
+ continue;
46
+ }
47
+
48
+ console.log(` ${module.name}: fetching...`);
49
+ await fetchRepo(moduleDir);
50
+ await pullRepo(moduleDir);
51
+
52
+ const commit = await getCurrentCommit(moduleDir);
53
+ const oldCommit = lock.locked[module.name];
54
+ lock.locked[module.name] = commit;
55
+
56
+ if (oldCommit === commit) {
57
+ console.log(` ${module.name}: already up to date @ ${commit.slice(0, 7)}`);
58
+ } else {
59
+ console.log(` ${module.name}: updated ${oldCommit?.slice(0, 7) || 'none'} -> ${commit.slice(0, 7)}`);
60
+ }
61
+ }
62
+
63
+ writeLock(hqRoot, lock);
64
+ console.log('\nLock file updated.');
65
+
66
+ } catch (error) {
67
+ console.error('Error:', error instanceof Error ? error.message : error);
68
+ process.exit(1);
69
+ }
70
+ });
71
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Type declarations for @hq-cloud/file-sync (legacy package reference).
3
+ * cloud.ts still imports from this package via dynamic import().
4
+ * Will be removed when cloud.ts is rewritten for US-005 (API proxy mode).
5
+ */
6
+ declare module '@hq-cloud/file-sync' {
7
+ export function initSync(hqRoot: string): Promise<void>;
8
+ export function startDaemon(hqRoot: string): Promise<void>;
9
+ export function stopDaemon(hqRoot: string): Promise<void>;
10
+ export function getStatus(hqRoot: string): Promise<{
11
+ running: boolean;
12
+ lastSync: string | null;
13
+ fileCount: number;
14
+ bucket: string | null;
15
+ errors: string[];
16
+ }>;
17
+ export function pushAll(hqRoot: string): Promise<{ filesUploaded: number }>;
18
+ export function pullAll(hqRoot: string): Promise<{ filesDownloaded: number }>;
19
+ }
package/src/index.ts ADDED
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * HQ CLI - Module management and cloud sync for HQ
5
+ */
6
+
7
+ import { Command } from "commander";
8
+ import { registerAddCommand } from "./commands/add.js";
9
+ import { registerSyncCommand } from "./commands/sync.js";
10
+ import { registerListCommand } from "./commands/list.js";
11
+ import { registerUpdateCommand } from "./commands/update.js";
12
+ import { registerCloudCommands } from "./commands/cloud.js";
13
+ import { registerAuthCommand } from "./commands/auth.js";
14
+ import { registerCloudSetupCommand } from "./commands/cloud-setup.js";
15
+
16
+ const program = new Command();
17
+
18
+ program
19
+ .name("hq")
20
+ .description("HQ management CLI — modules and cloud sync")
21
+ .version("5.1.0");
22
+
23
+ // Module management subcommand group
24
+ const modulesCmd = program
25
+ .command("modules")
26
+ .description("Module management commands");
27
+
28
+ registerAddCommand(modulesCmd);
29
+ registerSyncCommand(modulesCmd);
30
+ registerListCommand(modulesCmd);
31
+ registerUpdateCommand(modulesCmd);
32
+
33
+ // Cloud sync subcommand group
34
+ const syncCmd = program
35
+ .command("sync")
36
+ .description("Cloud sync commands — sync HQ files via API proxy");
37
+
38
+ registerCloudCommands(syncCmd);
39
+
40
+ // Authentication commands (hq auth login|logout|status)
41
+ registerAuthCommand(program);
42
+
43
+ // Cloud session management (hq cloud setup-token|status)
44
+ registerCloudSetupCommand(program);
45
+
46
+ program.parse();
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Link Sync Strategy (US-006)
3
+ * Symlinks module paths into HQ tree
4
+ */
5
+
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import type { ModuleDefinition, SyncResult } from '../types.js';
9
+
10
+ export async function linkSync(
11
+ module: ModuleDefinition,
12
+ moduleDir: string,
13
+ hqRoot: string
14
+ ): Promise<SyncResult> {
15
+ let filesChanged = 0;
16
+
17
+ for (const mapping of module.paths) {
18
+ const srcPath = path.join(moduleDir, mapping.src);
19
+ const destPath = path.join(hqRoot, mapping.dest);
20
+
21
+ // Validate source exists
22
+ if (!fs.existsSync(srcPath)) {
23
+ return {
24
+ module: module.name,
25
+ success: false,
26
+ action: 'skipped',
27
+ message: `Source path not found: ${mapping.src}`,
28
+ };
29
+ }
30
+
31
+ // Ensure dest parent directory exists
32
+ const destDir = path.dirname(destPath);
33
+ if (!fs.existsSync(destDir)) {
34
+ fs.mkdirSync(destDir, { recursive: true });
35
+ }
36
+
37
+ // Handle existing dest
38
+ if (fs.existsSync(destPath)) {
39
+ const stat = fs.lstatSync(destPath);
40
+ if (stat.isSymbolicLink()) {
41
+ // Remove existing symlink and recreate
42
+ fs.unlinkSync(destPath);
43
+ } else {
44
+ // Real file exists - warn and skip
45
+ console.warn(` Warning: Real file exists at ${mapping.dest}, skipping`);
46
+ continue;
47
+ }
48
+ }
49
+
50
+ // Create relative symlink for portability
51
+ const relativeSrc = path.relative(destDir, srcPath);
52
+ fs.symlinkSync(relativeSrc, destPath);
53
+ filesChanged++;
54
+ }
55
+
56
+ return {
57
+ module: module.name,
58
+ success: true,
59
+ action: 'synced',
60
+ filesChanged,
61
+ };
62
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Merge Sync Strategy (US-007)
3
+ * Copies files from module into HQ, tracks state for conflict detection
4
+ */
5
+
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+ import * as crypto from 'crypto';
9
+ import type { ModuleDefinition, SyncResult, SyncState } from '../types.js';
10
+ import { readState, writeState } from '../utils/manifest.js';
11
+
12
+ function hashFile(filePath: string): string {
13
+ const content = fs.readFileSync(filePath);
14
+ return crypto.createHash('sha256').update(content).digest('hex');
15
+ }
16
+
17
+ function copyRecursive(
18
+ srcDir: string,
19
+ destDir: string,
20
+ state: SyncState,
21
+ moduleName: string,
22
+ hqRoot: string,
23
+ filesChanged: { count: number }
24
+ ): void {
25
+ if (!fs.existsSync(destDir)) {
26
+ fs.mkdirSync(destDir, { recursive: true });
27
+ }
28
+
29
+ const entries = fs.readdirSync(srcDir, { withFileTypes: true });
30
+ for (const entry of entries) {
31
+ const srcPath = path.join(srcDir, entry.name);
32
+ const destPath = path.join(destDir, entry.name);
33
+
34
+ if (entry.isDirectory()) {
35
+ copyRecursive(srcPath, destPath, state, moduleName, hqRoot, filesChanged);
36
+ } else {
37
+ const relativeDest = path.relative(hqRoot, destPath).replace(/\\/g, '/');
38
+ const newHash = hashFile(srcPath);
39
+
40
+ // Check if file exists and has been modified by user
41
+ if (fs.existsSync(destPath)) {
42
+ const existingHash = hashFile(destPath);
43
+ const lastSyncedHash = state.files[relativeDest]?.hash;
44
+
45
+ if (lastSyncedHash && existingHash !== lastSyncedHash && existingHash !== newHash) {
46
+ // User modified the file since last sync - skip (conflict)
47
+ console.warn(` Conflict: ${relativeDest} has local changes, skipping`);
48
+ continue;
49
+ }
50
+
51
+ if (existingHash === newHash) {
52
+ // File unchanged, skip
53
+ continue;
54
+ }
55
+ }
56
+
57
+ // Copy file
58
+ fs.copyFileSync(srcPath, destPath);
59
+ filesChanged.count++;
60
+
61
+ // Track in state
62
+ state.files[relativeDest] = {
63
+ hash: newHash,
64
+ syncedAt: new Date().toISOString(),
65
+ fromModule: moduleName,
66
+ };
67
+ }
68
+ }
69
+ }
70
+
71
+ export async function mergeSync(
72
+ module: ModuleDefinition,
73
+ moduleDir: string,
74
+ hqRoot: string
75
+ ): Promise<SyncResult> {
76
+ let state = readState(hqRoot);
77
+ if (!state) {
78
+ state = { version: '1', files: {} };
79
+ }
80
+
81
+ const filesChanged = { count: 0 };
82
+
83
+ for (const mapping of module.paths) {
84
+ const srcPath = path.join(moduleDir, mapping.src);
85
+ const destPath = path.join(hqRoot, mapping.dest);
86
+
87
+ if (!fs.existsSync(srcPath)) {
88
+ return {
89
+ module: module.name,
90
+ success: false,
91
+ action: 'skipped',
92
+ message: `Source path not found: ${mapping.src}`,
93
+ };
94
+ }
95
+
96
+ const srcStat = fs.statSync(srcPath);
97
+ if (srcStat.isDirectory()) {
98
+ copyRecursive(srcPath, destPath, state, module.name, hqRoot, filesChanged);
99
+ } else {
100
+ // Single file
101
+ const destDir = path.dirname(destPath);
102
+ if (!fs.existsSync(destDir)) {
103
+ fs.mkdirSync(destDir, { recursive: true });
104
+ }
105
+
106
+ const relativeDest = path.relative(hqRoot, destPath).replace(/\\/g, '/');
107
+ const newHash = hashFile(srcPath);
108
+
109
+ if (fs.existsSync(destPath)) {
110
+ const existingHash = hashFile(destPath);
111
+ const lastSyncedHash = state.files[relativeDest]?.hash;
112
+
113
+ if (lastSyncedHash && existingHash !== lastSyncedHash && existingHash !== newHash) {
114
+ console.warn(` Conflict: ${relativeDest} has local changes, skipping`);
115
+ continue;
116
+ }
117
+
118
+ if (existingHash === newHash) {
119
+ continue;
120
+ }
121
+ }
122
+
123
+ fs.copyFileSync(srcPath, destPath);
124
+ filesChanged.count++;
125
+
126
+ state.files[relativeDest] = {
127
+ hash: newHash,
128
+ syncedAt: new Date().toISOString(),
129
+ fromModule: module.name,
130
+ };
131
+ }
132
+ }
133
+
134
+ writeState(hqRoot, state);
135
+
136
+ return {
137
+ module: module.name,
138
+ success: true,
139
+ action: 'synced',
140
+ filesChanged: filesChanged.count,
141
+ };
142
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Background sync worker for "hq sync start".
3
+ *
4
+ * Forked as a detached child process. Polls for changes at a configurable
5
+ * interval and runs a full bidirectional sync each cycle.
6
+ *
7
+ * Usage (internal — called by cloud.ts):
8
+ * node sync-worker.js <hqRoot> <intervalMs>
9
+ */
10
+
11
+ import { fullSync, readSyncState, writeSyncState, computeLocalManifest } from './utils/sync.js';
12
+
13
+ const hqRoot = process.argv[2];
14
+ const intervalMs = parseInt(process.argv[3] ?? '30000', 10);
15
+
16
+ if (!hqRoot) {
17
+ process.exit(1);
18
+ }
19
+
20
+ /** Run one sync cycle. */
21
+ async function syncCycle(): Promise<void> {
22
+ try {
23
+ const result = await fullSync(hqRoot);
24
+
25
+ // Update state
26
+ const manifest = computeLocalManifest(hqRoot);
27
+ const state = readSyncState(hqRoot);
28
+ state.running = true;
29
+ state.pid = process.pid;
30
+ state.lastSync = new Date().toISOString();
31
+ state.fileCount = manifest.length;
32
+ state.errors = result.errors;
33
+ writeSyncState(hqRoot, state);
34
+ } catch {
35
+ // Log errors to state, but keep running
36
+ try {
37
+ const state = readSyncState(hqRoot);
38
+ state.errors = ['Sync cycle failed — will retry next interval'];
39
+ writeSyncState(hqRoot, state);
40
+ } catch {
41
+ // Can't even write state — just continue
42
+ }
43
+ }
44
+ }
45
+
46
+ /** Main loop. */
47
+ async function run(): Promise<void> {
48
+ // Handle graceful shutdown
49
+ process.on('SIGTERM', () => {
50
+ try {
51
+ const state = readSyncState(hqRoot);
52
+ state.running = false;
53
+ state.pid = undefined;
54
+ writeSyncState(hqRoot, state);
55
+ } catch {
56
+ // Best-effort cleanup
57
+ }
58
+ process.exit(0);
59
+ });
60
+
61
+ process.on('SIGINT', () => {
62
+ try {
63
+ const state = readSyncState(hqRoot);
64
+ state.running = false;
65
+ state.pid = undefined;
66
+ writeSyncState(hqRoot, state);
67
+ } catch {
68
+ // Best-effort cleanup
69
+ }
70
+ process.exit(0);
71
+ });
72
+
73
+ // Run first sync immediately
74
+ await syncCycle();
75
+
76
+ // Then poll at the configured interval
77
+ setInterval(() => {
78
+ void syncCycle();
79
+ }, intervalMs);
80
+ }
81
+
82
+ void run();
package/src/types.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * HQ Module Manifest Types (US-001)
3
+ */
4
+
5
+ export type SyncStrategy = 'link' | 'merge' | 'copy';
6
+ export type AccessLevel = 'public' | 'team' | `role:${string}`;
7
+
8
+ export interface PathMapping {
9
+ src: string; // Path within module repo
10
+ dest: string; // Path in HQ tree (relative to HQ root)
11
+ }
12
+
13
+ export interface ModuleDefinition {
14
+ name: string;
15
+ repo: string; // Git URL (https or git@)
16
+ branch?: string; // Default: main
17
+ strategy: SyncStrategy; // link | merge | copy
18
+ paths: PathMapping[]; // What to sync and where
19
+ access?: AccessLevel; // For future RBAC
20
+ }
21
+
22
+ export interface ModulesManifest {
23
+ version: '1';
24
+ modules: ModuleDefinition[];
25
+ }
26
+
27
+ export interface ModuleLock {
28
+ version: '1';
29
+ locked: Record<string, string>; // module name -> commit SHA
30
+ }
31
+
32
+ export interface SyncState {
33
+ version: '1';
34
+ files: Record<string, {
35
+ hash: string; // SHA256 of file content at sync time
36
+ syncedAt: string; // ISO timestamp
37
+ fromModule: string; // Module that provided this file
38
+ }>;
39
+ }
40
+
41
+ export interface SyncResult {
42
+ module: string;
43
+ success: boolean;
44
+ action: 'cloned' | 'fetched' | 'synced' | 'skipped';
45
+ message?: string;
46
+ filesChanged?: number;
47
+ }