@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.
- package/dist/auth.d.ts +21 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +116 -0
- package/dist/auth.js.map +1 -0
- package/dist/cli/accept.d.ts +29 -0
- package/dist/cli/accept.d.ts.map +1 -0
- package/dist/cli/accept.js +67 -0
- package/dist/cli/accept.js.map +1 -0
- package/dist/cli/conflict.d.ts +33 -0
- package/dist/cli/conflict.d.ts.map +1 -0
- package/dist/cli/conflict.js +91 -0
- package/dist/cli/conflict.js.map +1 -0
- package/dist/cli/index.d.ts +19 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +14 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/invite.d.ts +51 -0
- package/dist/cli/invite.d.ts.map +1 -0
- package/dist/cli/invite.js +120 -0
- package/dist/cli/invite.js.map +1 -0
- package/dist/cli/invite.test.d.ts +5 -0
- package/dist/cli/invite.test.d.ts.map +1 -0
- package/dist/cli/invite.test.js +175 -0
- package/dist/cli/invite.test.js.map +1 -0
- package/dist/cli/promote.d.ts +30 -0
- package/dist/cli/promote.d.ts.map +1 -0
- package/dist/cli/promote.js +79 -0
- package/dist/cli/promote.js.map +1 -0
- package/dist/cli/share.d.ts +33 -0
- package/dist/cli/share.d.ts.map +1 -0
- package/dist/cli/share.js +153 -0
- package/dist/cli/share.js.map +1 -0
- package/dist/cli/share.test.d.ts +5 -0
- package/dist/cli/share.test.d.ts.map +1 -0
- package/dist/cli/share.test.js +121 -0
- package/dist/cli/share.test.js.map +1 -0
- package/dist/cli/sync.d.ts +30 -0
- package/dist/cli/sync.d.ts.map +1 -0
- package/dist/cli/sync.js +138 -0
- package/dist/cli/sync.js.map +1 -0
- package/dist/cli/sync.test.d.ts +5 -0
- package/dist/cli/sync.test.d.ts.map +1 -0
- package/dist/cli/sync.test.js +172 -0
- package/dist/cli/sync.test.js.map +1 -0
- package/dist/cognito-auth.d.ts +70 -0
- package/dist/cognito-auth.d.ts.map +1 -0
- package/dist/cognito-auth.js +280 -0
- package/dist/cognito-auth.js.map +1 -0
- package/dist/context.d.ts +30 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +117 -0
- package/dist/context.js.map +1 -0
- package/dist/context.test.d.ts +7 -0
- package/dist/context.test.d.ts.map +1 -0
- package/dist/context.test.js +148 -0
- package/dist/context.test.js.map +1 -0
- package/dist/daemon-worker.d.ts +6 -0
- package/dist/daemon-worker.d.ts.map +1 -0
- package/dist/daemon-worker.js +26 -0
- package/dist/daemon-worker.js.map +1 -0
- package/dist/daemon.d.ts +10 -0
- package/dist/daemon.d.ts.map +1 -0
- package/dist/daemon.js +88 -0
- package/dist/daemon.js.map +1 -0
- package/dist/ignore.d.ts +10 -0
- package/dist/ignore.d.ts.map +1 -0
- package/dist/ignore.js +54 -0
- package/dist/ignore.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +138 -0
- package/dist/index.js.map +1 -0
- package/dist/journal.d.ts +12 -0
- package/dist/journal.d.ts.map +1 -0
- package/dist/journal.js +42 -0
- package/dist/journal.js.map +1 -0
- package/dist/s3.d.ts +15 -0
- package/dist/s3.d.ts.map +1 -0
- package/dist/s3.js +129 -0
- package/dist/s3.js.map +1 -0
- package/dist/types.d.ts +52 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/vault-client.d.ts +164 -0
- package/dist/vault-client.d.ts.map +1 -0
- package/dist/vault-client.js +209 -0
- package/dist/vault-client.js.map +1 -0
- package/dist/vault-client.test.d.ts +7 -0
- package/dist/vault-client.test.d.ts.map +1 -0
- package/dist/vault-client.test.js +257 -0
- package/dist/vault-client.test.js.map +1 -0
- package/dist/watcher.d.ts +18 -0
- package/dist/watcher.d.ts.map +1 -0
- package/dist/watcher.js +106 -0
- package/dist/watcher.js.map +1 -0
- package/package.json +32 -0
- package/src/auth.ts +146 -0
- package/src/cognito-auth.ts +375 -0
- package/src/daemon-worker.ts +32 -0
- package/src/daemon.ts +97 -0
- package/src/ignore.ts +61 -0
- package/src/index.ts +182 -0
- package/src/journal.ts +63 -0
- package/src/s3.ts +178 -0
- package/src/types.ts +59 -0
- package/src/watcher.ts +130 -0
- 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
|
+
}
|