@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.
- package/dist/__tests__/credentials.test.d.ts +5 -0
- package/dist/__tests__/credentials.test.d.ts.map +1 -0
- package/dist/__tests__/credentials.test.js +169 -0
- package/dist/__tests__/credentials.test.js.map +1 -0
- package/dist/commands/add.d.ts +6 -0
- package/dist/commands/add.d.ts.map +1 -0
- package/dist/commands/add.js +60 -0
- package/dist/commands/add.js.map +1 -0
- package/dist/commands/auth.d.ts +17 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +269 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/cloud-setup.d.ts +19 -0
- package/dist/commands/cloud-setup.d.ts.map +1 -0
- package/dist/commands/cloud-setup.js +206 -0
- package/dist/commands/cloud-setup.js.map +1 -0
- package/dist/commands/cloud.d.ts +16 -0
- package/dist/commands/cloud.d.ts.map +1 -0
- package/dist/commands/cloud.js +263 -0
- package/dist/commands/cloud.js.map +1 -0
- package/dist/commands/initial-upload.d.ts +67 -0
- package/dist/commands/initial-upload.d.ts.map +1 -0
- package/dist/commands/initial-upload.js +205 -0
- package/dist/commands/initial-upload.js.map +1 -0
- package/dist/commands/list.d.ts +6 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +55 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/sync.d.ts +6 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +104 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/update.d.ts +7 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +60 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/strategies/link.d.ts +7 -0
- package/dist/strategies/link.d.ts.map +1 -0
- package/dist/strategies/link.js +51 -0
- package/dist/strategies/link.js.map +1 -0
- package/dist/strategies/merge.d.ts +7 -0
- package/dist/strategies/merge.d.ts.map +1 -0
- package/dist/strategies/merge.js +110 -0
- package/dist/strategies/merge.js.map +1 -0
- package/dist/sync-worker.d.ts +11 -0
- package/dist/sync-worker.d.ts.map +1 -0
- package/dist/sync-worker.js +77 -0
- package/dist/sync-worker.js.map +1 -0
- package/dist/types.d.ts +41 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/api-client.d.ts +26 -0
- package/dist/utils/api-client.d.ts.map +1 -0
- package/dist/utils/api-client.js +87 -0
- package/dist/utils/api-client.js.map +1 -0
- package/dist/utils/credentials.d.ts +44 -0
- package/dist/utils/credentials.d.ts.map +1 -0
- package/dist/utils/credentials.js +101 -0
- package/dist/utils/credentials.js.map +1 -0
- package/dist/utils/git.d.ts +13 -0
- package/dist/utils/git.d.ts.map +1 -0
- package/dist/utils/git.js +70 -0
- package/dist/utils/git.js.map +1 -0
- package/dist/utils/manifest.d.ts +16 -0
- package/dist/utils/manifest.d.ts.map +1 -0
- package/dist/utils/manifest.js +95 -0
- package/dist/utils/manifest.js.map +1 -0
- package/dist/utils/sync.d.ts +125 -0
- package/dist/utils/sync.d.ts.map +1 -0
- package/dist/utils/sync.js +291 -0
- package/dist/utils/sync.js.map +1 -0
- package/package.json +36 -0
- package/src/__tests__/cloud-setup.test.ts +117 -0
- package/src/__tests__/credentials.test.ts +203 -0
- package/src/__tests__/initial-upload.test.ts +414 -0
- package/src/__tests__/sync.test.ts +627 -0
- package/src/commands/add.ts +74 -0
- package/src/commands/auth.ts +303 -0
- package/src/commands/cloud-setup.ts +251 -0
- package/src/commands/cloud.ts +300 -0
- package/src/commands/initial-upload.ts +263 -0
- package/src/commands/list.ts +66 -0
- package/src/commands/sync.ts +149 -0
- package/src/commands/update.ts +71 -0
- package/src/hq-cloud.d.ts +19 -0
- package/src/index.ts +46 -0
- package/src/strategies/link.ts +62 -0
- package/src/strategies/merge.ts +142 -0
- package/src/sync-worker.ts +82 -0
- package/src/types.ts +47 -0
- package/src/utils/api-client.ts +111 -0
- package/src/utils/credentials.ts +124 -0
- package/src/utils/git.ts +74 -0
- package/src/utils/manifest.ts +111 -0
- package/src/utils/sync.ts +381 -0
- package/tsconfig.json +9 -0
- 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
|
+
}
|