@indigoai-us/hq-cloud 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 (108) hide show
  1. package/dist/auth.d.ts +21 -0
  2. package/dist/auth.d.ts.map +1 -0
  3. package/dist/auth.js +116 -0
  4. package/dist/auth.js.map +1 -0
  5. package/dist/cli/accept.d.ts +29 -0
  6. package/dist/cli/accept.d.ts.map +1 -0
  7. package/dist/cli/accept.js +67 -0
  8. package/dist/cli/accept.js.map +1 -0
  9. package/dist/cli/conflict.d.ts +33 -0
  10. package/dist/cli/conflict.d.ts.map +1 -0
  11. package/dist/cli/conflict.js +91 -0
  12. package/dist/cli/conflict.js.map +1 -0
  13. package/dist/cli/index.d.ts +19 -0
  14. package/dist/cli/index.d.ts.map +1 -0
  15. package/dist/cli/index.js +14 -0
  16. package/dist/cli/index.js.map +1 -0
  17. package/dist/cli/invite.d.ts +51 -0
  18. package/dist/cli/invite.d.ts.map +1 -0
  19. package/dist/cli/invite.js +120 -0
  20. package/dist/cli/invite.js.map +1 -0
  21. package/dist/cli/invite.test.d.ts +5 -0
  22. package/dist/cli/invite.test.d.ts.map +1 -0
  23. package/dist/cli/invite.test.js +175 -0
  24. package/dist/cli/invite.test.js.map +1 -0
  25. package/dist/cli/promote.d.ts +30 -0
  26. package/dist/cli/promote.d.ts.map +1 -0
  27. package/dist/cli/promote.js +79 -0
  28. package/dist/cli/promote.js.map +1 -0
  29. package/dist/cli/share.d.ts +33 -0
  30. package/dist/cli/share.d.ts.map +1 -0
  31. package/dist/cli/share.js +153 -0
  32. package/dist/cli/share.js.map +1 -0
  33. package/dist/cli/share.test.d.ts +5 -0
  34. package/dist/cli/share.test.d.ts.map +1 -0
  35. package/dist/cli/share.test.js +121 -0
  36. package/dist/cli/share.test.js.map +1 -0
  37. package/dist/cli/sync.d.ts +30 -0
  38. package/dist/cli/sync.d.ts.map +1 -0
  39. package/dist/cli/sync.js +138 -0
  40. package/dist/cli/sync.js.map +1 -0
  41. package/dist/cli/sync.test.d.ts +5 -0
  42. package/dist/cli/sync.test.d.ts.map +1 -0
  43. package/dist/cli/sync.test.js +172 -0
  44. package/dist/cli/sync.test.js.map +1 -0
  45. package/dist/cognito-auth.d.ts +70 -0
  46. package/dist/cognito-auth.d.ts.map +1 -0
  47. package/dist/cognito-auth.js +280 -0
  48. package/dist/cognito-auth.js.map +1 -0
  49. package/dist/context.d.ts +30 -0
  50. package/dist/context.d.ts.map +1 -0
  51. package/dist/context.js +117 -0
  52. package/dist/context.js.map +1 -0
  53. package/dist/context.test.d.ts +7 -0
  54. package/dist/context.test.d.ts.map +1 -0
  55. package/dist/context.test.js +148 -0
  56. package/dist/context.test.js.map +1 -0
  57. package/dist/daemon-worker.d.ts +6 -0
  58. package/dist/daemon-worker.d.ts.map +1 -0
  59. package/dist/daemon-worker.js +26 -0
  60. package/dist/daemon-worker.js.map +1 -0
  61. package/dist/daemon.d.ts +10 -0
  62. package/dist/daemon.d.ts.map +1 -0
  63. package/dist/daemon.js +88 -0
  64. package/dist/daemon.js.map +1 -0
  65. package/dist/ignore.d.ts +10 -0
  66. package/dist/ignore.d.ts.map +1 -0
  67. package/dist/ignore.js +54 -0
  68. package/dist/ignore.js.map +1 -0
  69. package/dist/index.d.ts +33 -0
  70. package/dist/index.d.ts.map +1 -0
  71. package/dist/index.js +138 -0
  72. package/dist/index.js.map +1 -0
  73. package/dist/journal.d.ts +12 -0
  74. package/dist/journal.d.ts.map +1 -0
  75. package/dist/journal.js +42 -0
  76. package/dist/journal.js.map +1 -0
  77. package/dist/s3.d.ts +15 -0
  78. package/dist/s3.d.ts.map +1 -0
  79. package/dist/s3.js +129 -0
  80. package/dist/s3.js.map +1 -0
  81. package/dist/types.d.ts +52 -0
  82. package/dist/types.d.ts.map +1 -0
  83. package/dist/types.js +5 -0
  84. package/dist/types.js.map +1 -0
  85. package/dist/vault-client.d.ts +164 -0
  86. package/dist/vault-client.d.ts.map +1 -0
  87. package/dist/vault-client.js +209 -0
  88. package/dist/vault-client.js.map +1 -0
  89. package/dist/vault-client.test.d.ts +7 -0
  90. package/dist/vault-client.test.d.ts.map +1 -0
  91. package/dist/vault-client.test.js +257 -0
  92. package/dist/vault-client.test.js.map +1 -0
  93. package/dist/watcher.d.ts +18 -0
  94. package/dist/watcher.d.ts.map +1 -0
  95. package/dist/watcher.js +106 -0
  96. package/dist/watcher.js.map +1 -0
  97. package/package.json +32 -0
  98. package/src/auth.ts +146 -0
  99. package/src/cognito-auth.ts +375 -0
  100. package/src/daemon-worker.ts +32 -0
  101. package/src/daemon.ts +97 -0
  102. package/src/ignore.ts +61 -0
  103. package/src/index.ts +182 -0
  104. package/src/journal.ts +63 -0
  105. package/src/s3.ts +178 -0
  106. package/src/types.ts +59 -0
  107. package/src/watcher.ts +130 -0
  108. package/tsconfig.json +8 -0
package/src/index.ts ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * @indigoai-us/hq-cloud — public API
3
+ * Used by @indigoai-us/hq-cli to manage cloud sync
4
+ */
5
+
6
+ import * as fs from "fs";
7
+ import * as path from "path";
8
+ import { authenticate, hasCredentials, readCredentials } from "./auth.js";
9
+ import {
10
+ startDaemon as _startDaemon,
11
+ stopDaemon as _stopDaemon,
12
+ isDaemonRunning,
13
+ } from "./daemon.js";
14
+ import { readJournal, writeJournal, hashFile, updateEntry } from "./journal.js";
15
+ import { uploadFile, downloadFile, listRemoteFiles } from "./s3.js";
16
+ import { createIgnoreFilter, isWithinSizeLimit } from "./ignore.js";
17
+ import type { SyncStatus, PushResult, PullResult } from "./types.js";
18
+
19
+ export type { SyncStatus, PushResult, PullResult } from "./types.js";
20
+
21
+ // Cognito identity helpers — used by `hq auth refresh` and any consumer
22
+ // that needs a valid HQ access token (deploy skill, onboarding, etc.).
23
+ export {
24
+ browserLogin,
25
+ refreshTokens,
26
+ getValidAccessToken,
27
+ loadCachedTokens,
28
+ saveCachedTokens,
29
+ clearCachedTokens,
30
+ isExpiring,
31
+ CognitoAuthError,
32
+ } from "./cognito-auth.js";
33
+ export type { CognitoAuthConfig, CognitoTokens } from "./cognito-auth.js";
34
+
35
+ /**
36
+ * Initialize cloud sync — authenticate and provision bucket
37
+ */
38
+ export async function initSync(hqRoot: string): Promise<void> {
39
+ if (hasCredentials()) {
40
+ console.log(" Already authenticated. Use 'hq sync start' to begin syncing.");
41
+ return;
42
+ }
43
+
44
+ console.log(" Setting up IndigoAI cloud sync...");
45
+ const creds = await authenticate();
46
+ console.log(` ✓ Authenticated as ${creds.userId}`);
47
+ console.log(` ✓ Bucket: ${creds.bucket}`);
48
+ console.log(` ✓ Region: ${creds.region}`);
49
+ console.log();
50
+ console.log(" Run 'hq sync start' to begin syncing.");
51
+ }
52
+
53
+ /**
54
+ * Start the background sync daemon
55
+ */
56
+ export async function startDaemon(hqRoot: string): Promise<void> {
57
+ if (!hasCredentials()) {
58
+ throw new Error("Not authenticated. Run 'hq sync init' first.");
59
+ }
60
+ _startDaemon(hqRoot);
61
+ }
62
+
63
+ /**
64
+ * Stop the background sync daemon
65
+ */
66
+ export async function stopDaemon(hqRoot: string): Promise<void> {
67
+ _stopDaemon(hqRoot);
68
+ }
69
+
70
+ /**
71
+ * Get current sync status
72
+ */
73
+ export async function getStatus(hqRoot: string): Promise<SyncStatus> {
74
+ const journal = readJournal(hqRoot);
75
+ const creds = readCredentials();
76
+ const running = isDaemonRunning(hqRoot);
77
+ const errors: string[] = [];
78
+
79
+ if (!creds) {
80
+ errors.push("Not authenticated — run 'hq sync init'");
81
+ }
82
+
83
+ return {
84
+ running,
85
+ lastSync: journal.lastSync || null,
86
+ fileCount: Object.keys(journal.files).length,
87
+ bucket: creds?.bucket || null,
88
+ errors,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Force push all local files to S3
94
+ */
95
+ export async function pushAll(hqRoot: string): Promise<PushResult> {
96
+ const shouldSync = createIgnoreFilter(hqRoot);
97
+ const journal = readJournal(hqRoot);
98
+ let filesUploaded = 0;
99
+ let bytesUploaded = 0;
100
+
101
+ const files = walkDir(hqRoot, hqRoot, shouldSync);
102
+
103
+ for (const { absolutePath, relativePath } of files) {
104
+ if (!isWithinSizeLimit(absolutePath)) continue;
105
+
106
+ try {
107
+ const hash = hashFile(absolutePath);
108
+ const stat = fs.statSync(absolutePath);
109
+
110
+ await uploadFile(absolutePath, relativePath);
111
+ updateEntry(journal, relativePath, hash, stat.size, "up");
112
+ filesUploaded++;
113
+ bytesUploaded += stat.size;
114
+ } catch (err) {
115
+ console.error(
116
+ ` Failed: ${relativePath} — ${err instanceof Error ? err.message : err}`
117
+ );
118
+ }
119
+ }
120
+
121
+ writeJournal(hqRoot, journal);
122
+ return { filesUploaded, bytesUploaded };
123
+ }
124
+
125
+ /**
126
+ * Force pull all remote files to local
127
+ */
128
+ export async function pullAll(hqRoot: string): Promise<PullResult> {
129
+ const journal = readJournal(hqRoot);
130
+ let filesDownloaded = 0;
131
+ let bytesDownloaded = 0;
132
+
133
+ const remoteFiles = await listRemoteFiles();
134
+
135
+ for (const file of remoteFiles) {
136
+ try {
137
+ const localPath = path.join(hqRoot, file.relativePath);
138
+ await downloadFile(file.relativePath, localPath);
139
+
140
+ const hash = hashFile(localPath);
141
+ updateEntry(journal, file.relativePath, hash, file.size, "down");
142
+ filesDownloaded++;
143
+ bytesDownloaded += file.size;
144
+ } catch (err) {
145
+ console.error(
146
+ ` Failed: ${file.relativePath} — ${err instanceof Error ? err.message : err}`
147
+ );
148
+ }
149
+ }
150
+
151
+ writeJournal(hqRoot, journal);
152
+ return { filesDownloaded, bytesDownloaded };
153
+ }
154
+
155
+ // Helper: recursively walk a directory
156
+ function walkDir(
157
+ dir: string,
158
+ root: string,
159
+ filter: (p: string) => boolean
160
+ ): { absolutePath: string; relativePath: string }[] {
161
+ const results: { absolutePath: string; relativePath: string }[] = [];
162
+
163
+ if (!fs.existsSync(dir)) return results;
164
+
165
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
166
+ for (const entry of entries) {
167
+ const absolutePath = path.join(dir, entry.name);
168
+
169
+ if (!filter(absolutePath)) continue;
170
+
171
+ if (entry.isDirectory()) {
172
+ results.push(...walkDir(absolutePath, root, filter));
173
+ } else if (entry.isFile()) {
174
+ results.push({
175
+ absolutePath,
176
+ relativePath: path.relative(root, absolutePath),
177
+ });
178
+ }
179
+ }
180
+
181
+ return results;
182
+ }
package/src/journal.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Sync journal — tracks file state for conflict detection
3
+ */
4
+
5
+ import * as fs from "fs";
6
+ import * as path from "path";
7
+ import * as crypto from "crypto";
8
+ import type { SyncJournal, JournalEntry } from "./types.js";
9
+
10
+ const JOURNAL_FILE = ".hq-sync-journal.json";
11
+
12
+ export function getJournalPath(hqRoot: string): string {
13
+ return path.join(hqRoot, JOURNAL_FILE);
14
+ }
15
+
16
+ export function readJournal(hqRoot: string): SyncJournal {
17
+ const journalPath = getJournalPath(hqRoot);
18
+ if (fs.existsSync(journalPath)) {
19
+ const content = fs.readFileSync(journalPath, "utf-8");
20
+ return JSON.parse(content) as SyncJournal;
21
+ }
22
+ return { version: "1", lastSync: "", files: {} };
23
+ }
24
+
25
+ export function writeJournal(hqRoot: string, journal: SyncJournal): void {
26
+ const journalPath = getJournalPath(hqRoot);
27
+ fs.writeFileSync(journalPath, JSON.stringify(journal, null, 2));
28
+ }
29
+
30
+ export function hashFile(filePath: string): string {
31
+ const content = fs.readFileSync(filePath);
32
+ return crypto.createHash("sha256").update(content).digest("hex");
33
+ }
34
+
35
+ export function updateEntry(
36
+ journal: SyncJournal,
37
+ relativePath: string,
38
+ hash: string,
39
+ size: number,
40
+ direction: "up" | "down"
41
+ ): void {
42
+ journal.files[relativePath] = {
43
+ hash,
44
+ size,
45
+ syncedAt: new Date().toISOString(),
46
+ direction,
47
+ };
48
+ journal.lastSync = new Date().toISOString();
49
+ }
50
+
51
+ export function getEntry(
52
+ journal: SyncJournal,
53
+ relativePath: string
54
+ ): JournalEntry | undefined {
55
+ return journal.files[relativePath];
56
+ }
57
+
58
+ export function removeEntry(
59
+ journal: SyncJournal,
60
+ relativePath: string
61
+ ): void {
62
+ delete journal.files[relativePath];
63
+ }
package/src/s3.ts ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * S3 operations — upload, download, list, delete
3
+ */
4
+
5
+ import * as fs from "fs";
6
+ import * as path from "path";
7
+ import {
8
+ S3Client,
9
+ PutObjectCommand,
10
+ GetObjectCommand,
11
+ ListObjectsV2Command,
12
+ DeleteObjectCommand,
13
+ } from "@aws-sdk/client-s3";
14
+ import type { Credentials, SyncConfig } from "./types.js";
15
+ import { readCredentials, refreshAwsCredentials } from "./auth.js";
16
+
17
+ let s3Client: S3Client | null = null;
18
+
19
+ function getConfig(creds: Credentials): SyncConfig {
20
+ const prefix = creds.teamId
21
+ ? `teams/${creds.teamId}/users/${creds.userId}/hq/`
22
+ : `users/${creds.userId}/hq/`;
23
+ return {
24
+ bucket: creds.bucket,
25
+ region: creds.region,
26
+ userId: creds.userId,
27
+ prefix,
28
+ };
29
+ }
30
+
31
+ async function getClient(): Promise<{ client: S3Client; config: SyncConfig }> {
32
+ let creds = readCredentials();
33
+ if (!creds) {
34
+ throw new Error("Not authenticated. Run 'hq sync init' first.");
35
+ }
36
+
37
+ // Refresh if expired or missing access key
38
+ if (!creds.accessKeyId || (creds.expiration && new Date(creds.expiration) < new Date())) {
39
+ creds = await refreshAwsCredentials(creds);
40
+ }
41
+
42
+ if (!s3Client) {
43
+ s3Client = new S3Client({
44
+ region: creds.region,
45
+ credentials: {
46
+ accessKeyId: creds.accessKeyId,
47
+ secretAccessKey: creds.secretAccessKey,
48
+ sessionToken: creds.sessionToken,
49
+ },
50
+ });
51
+ }
52
+
53
+ return { client: s3Client, config: getConfig(creds) };
54
+ }
55
+
56
+ export async function uploadFile(
57
+ localPath: string,
58
+ relativePath: string
59
+ ): Promise<void> {
60
+ const { client, config } = await getClient();
61
+ const key = `${config.prefix}${relativePath}`;
62
+ const body = fs.readFileSync(localPath);
63
+
64
+ await client.send(
65
+ new PutObjectCommand({
66
+ Bucket: config.bucket,
67
+ Key: key,
68
+ Body: body,
69
+ ContentType: getMimeType(relativePath),
70
+ })
71
+ );
72
+ }
73
+
74
+ export async function downloadFile(
75
+ relativePath: string,
76
+ localPath: string
77
+ ): Promise<void> {
78
+ const { client, config } = await getClient();
79
+ const key = `${config.prefix}${relativePath}`;
80
+
81
+ const response = await client.send(
82
+ new GetObjectCommand({
83
+ Bucket: config.bucket,
84
+ Key: key,
85
+ })
86
+ );
87
+
88
+ if (!response.Body) {
89
+ throw new Error(`Empty response for ${key}`);
90
+ }
91
+
92
+ const dir = path.dirname(localPath);
93
+ if (!fs.existsSync(dir)) {
94
+ fs.mkdirSync(dir, { recursive: true });
95
+ }
96
+
97
+ const chunks: Buffer[] = [];
98
+ const stream = response.Body as AsyncIterable<Uint8Array>;
99
+ for await (const chunk of stream) {
100
+ chunks.push(Buffer.from(chunk));
101
+ }
102
+ fs.writeFileSync(localPath, Buffer.concat(chunks));
103
+ }
104
+
105
+ export interface RemoteFile {
106
+ key: string;
107
+ relativePath: string;
108
+ size: number;
109
+ lastModified: Date;
110
+ etag: string;
111
+ }
112
+
113
+ export async function listRemoteFiles(): Promise<RemoteFile[]> {
114
+ const { client, config } = await getClient();
115
+ const files: RemoteFile[] = [];
116
+ let continuationToken: string | undefined;
117
+
118
+ do {
119
+ const response = await client.send(
120
+ new ListObjectsV2Command({
121
+ Bucket: config.bucket,
122
+ Prefix: config.prefix,
123
+ ContinuationToken: continuationToken,
124
+ })
125
+ );
126
+
127
+ for (const obj of response.Contents || []) {
128
+ if (!obj.Key || !obj.Size) continue;
129
+ const relativePath = obj.Key.replace(config.prefix, "");
130
+ if (!relativePath) continue;
131
+
132
+ files.push({
133
+ key: obj.Key,
134
+ relativePath,
135
+ size: obj.Size,
136
+ lastModified: obj.LastModified || new Date(),
137
+ etag: obj.ETag || "",
138
+ });
139
+ }
140
+
141
+ continuationToken = response.NextContinuationToken;
142
+ } while (continuationToken);
143
+
144
+ return files;
145
+ }
146
+
147
+ export async function deleteRemoteFile(relativePath: string): Promise<void> {
148
+ const { client, config } = await getClient();
149
+ const key = `${config.prefix}${relativePath}`;
150
+
151
+ await client.send(
152
+ new DeleteObjectCommand({
153
+ Bucket: config.bucket,
154
+ Key: key,
155
+ })
156
+ );
157
+ }
158
+
159
+ function getMimeType(filePath: string): string {
160
+ const ext = path.extname(filePath).toLowerCase();
161
+ const mimeTypes: Record<string, string> = {
162
+ ".md": "text/markdown",
163
+ ".json": "application/json",
164
+ ".yaml": "text/yaml",
165
+ ".yml": "text/yaml",
166
+ ".ts": "text/typescript",
167
+ ".js": "text/javascript",
168
+ ".txt": "text/plain",
169
+ ".html": "text/html",
170
+ ".css": "text/css",
171
+ ".png": "image/png",
172
+ ".jpg": "image/jpeg",
173
+ ".jpeg": "image/jpeg",
174
+ ".svg": "image/svg+xml",
175
+ ".pdf": "application/pdf",
176
+ };
177
+ return mimeTypes[ext] || "application/octet-stream";
178
+ }
package/src/types.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * HQ Cloud Sync Types
3
+ */
4
+
5
+ export interface SyncConfig {
6
+ bucket: string;
7
+ region: string;
8
+ userId: string;
9
+ prefix: string; // e.g. "hq/"
10
+ }
11
+
12
+ export interface Credentials {
13
+ accessKeyId: string;
14
+ secretAccessKey: string;
15
+ sessionToken?: string;
16
+ expiration?: string;
17
+ refreshToken: string;
18
+ userId: string;
19
+ bucket: string;
20
+ region: string;
21
+ teamId?: string;
22
+ }
23
+
24
+ export interface JournalEntry {
25
+ hash: string;
26
+ size: number;
27
+ syncedAt: string;
28
+ direction: "up" | "down";
29
+ }
30
+
31
+ export interface SyncJournal {
32
+ version: "1";
33
+ lastSync: string;
34
+ files: Record<string, JournalEntry>;
35
+ }
36
+
37
+ export interface SyncStatus {
38
+ running: boolean;
39
+ lastSync: string | null;
40
+ fileCount: number;
41
+ bucket: string | null;
42
+ errors: string[];
43
+ }
44
+
45
+ export interface PushResult {
46
+ filesUploaded: number;
47
+ bytesUploaded: number;
48
+ }
49
+
50
+ export interface PullResult {
51
+ filesDownloaded: number;
52
+ bytesDownloaded: number;
53
+ }
54
+
55
+ export interface DaemonState {
56
+ pid: number;
57
+ startedAt: string;
58
+ hqRoot: string;
59
+ }
package/src/watcher.ts ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * File watcher — monitors HQ directory for changes
3
+ * Uses chokidar with debounced batching
4
+ */
5
+
6
+ import * as fs from "fs";
7
+ import * as path from "path";
8
+ import { watch } from "chokidar";
9
+ import type { FSWatcher } from "chokidar";
10
+ import { createIgnoreFilter, isWithinSizeLimit } from "./ignore.js";
11
+ import { readJournal, writeJournal, hashFile, updateEntry } from "./journal.js";
12
+ import { uploadFile, deleteRemoteFile } from "./s3.js";
13
+
14
+ const DEBOUNCE_MS = 2000;
15
+
16
+ interface PendingChange {
17
+ type: "add" | "change" | "unlink";
18
+ absolutePath: string;
19
+ relativePath: string;
20
+ }
21
+
22
+ export class SyncWatcher {
23
+ private watcher: FSWatcher | null = null;
24
+ private hqRoot: string;
25
+ private shouldSync: (filePath: string) => boolean;
26
+ private pendingChanges = new Map<string, PendingChange>();
27
+ private debounceTimer: ReturnType<typeof setTimeout> | null = null;
28
+ private processing = false;
29
+
30
+ constructor(hqRoot: string) {
31
+ this.hqRoot = hqRoot;
32
+ this.shouldSync = createIgnoreFilter(hqRoot);
33
+ }
34
+
35
+ start(): void {
36
+ if (this.watcher) return;
37
+
38
+ this.watcher = watch(this.hqRoot, {
39
+ ignored: (filePath: string) => !this.shouldSync(filePath),
40
+ persistent: true,
41
+ ignoreInitial: true,
42
+ awaitWriteFinish: {
43
+ stabilityThreshold: 500,
44
+ pollInterval: 100,
45
+ },
46
+ });
47
+
48
+ this.watcher
49
+ .on("add", (p) => this.queueChange("add", p))
50
+ .on("change", (p) => this.queueChange("change", p))
51
+ .on("unlink", (p) => this.queueChange("unlink", p))
52
+ .on("error", (err) => console.error("Watcher error:", err));
53
+ }
54
+
55
+ stop(): void {
56
+ if (this.watcher) {
57
+ this.watcher.close();
58
+ this.watcher = null;
59
+ }
60
+ if (this.debounceTimer) {
61
+ clearTimeout(this.debounceTimer);
62
+ this.debounceTimer = null;
63
+ }
64
+ }
65
+
66
+ private queueChange(type: "add" | "change" | "unlink", absolutePath: string): void {
67
+ const relativePath = path.relative(this.hqRoot, absolutePath);
68
+
69
+ // Skip files that exceed size limit
70
+ if (type !== "unlink" && !isWithinSizeLimit(absolutePath)) {
71
+ return;
72
+ }
73
+
74
+ this.pendingChanges.set(relativePath, {
75
+ type,
76
+ absolutePath,
77
+ relativePath,
78
+ });
79
+
80
+ // Debounce: wait for DEBOUNCE_MS of quiet before processing
81
+ if (this.debounceTimer) {
82
+ clearTimeout(this.debounceTimer);
83
+ }
84
+ this.debounceTimer = setTimeout(() => this.flush(), DEBOUNCE_MS);
85
+ }
86
+
87
+ private async flush(): Promise<void> {
88
+ if (this.processing || this.pendingChanges.size === 0) return;
89
+ this.processing = true;
90
+
91
+ const batch = new Map(this.pendingChanges);
92
+ this.pendingChanges.clear();
93
+
94
+ const journal = readJournal(this.hqRoot);
95
+
96
+ for (const [relativePath, change] of batch) {
97
+ try {
98
+ if (change.type === "unlink") {
99
+ await deleteRemoteFile(relativePath);
100
+ delete journal.files[relativePath];
101
+ } else {
102
+ const hash = hashFile(change.absolutePath);
103
+ const stat = fs.statSync(change.absolutePath);
104
+
105
+ // Skip if unchanged from last sync
106
+ const existing = journal.files[relativePath];
107
+ if (existing && existing.hash === hash) continue;
108
+
109
+ await uploadFile(change.absolutePath, relativePath);
110
+ updateEntry(journal, relativePath, hash, stat.size, "up");
111
+ }
112
+ } catch (err) {
113
+ console.error(
114
+ `Sync error [${relativePath}]:`,
115
+ err instanceof Error ? err.message : err
116
+ );
117
+ // Re-queue failed changes
118
+ this.pendingChanges.set(relativePath, change);
119
+ }
120
+ }
121
+
122
+ writeJournal(this.hqRoot, journal);
123
+ this.processing = false;
124
+
125
+ // Process any changes that came in while we were flushing
126
+ if (this.pendingChanges.size > 0) {
127
+ this.debounceTimer = setTimeout(() => this.flush(), DEBOUNCE_MS);
128
+ }
129
+ }
130
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"]
8
+ }