@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
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Cognito browser-OAuth helper (VLT-9).
3
+ *
4
+ * Drives the Cognito Hosted UI authorization-code + PKCE flow for the
5
+ * vault-service User Pool. Used by the CLI (`hq login`, `create-hq`) to
6
+ * obtain a JWT that is then passed to the vault-service API as
7
+ * `Authorization: Bearer <accessToken>`.
8
+ *
9
+ * Why PKCE: the CLI is a public client (no secret), so we use PKCE per
10
+ * RFC 7636 to prove that the same process that started the auth request
11
+ * is the one exchanging the code for tokens.
12
+ *
13
+ * Why a localhost callback: Cognito allows `http://localhost:*` as a
14
+ * redirect URI specifically for native/CLI apps (RFC 8252 §7). We spin
15
+ * up a one-shot HTTP server on the chosen port, capture exactly one
16
+ * callback, then close it.
17
+ */
18
+
19
+ import * as crypto from "crypto";
20
+ import * as fs from "fs";
21
+ import * as http from "http";
22
+ import * as path from "path";
23
+ import * as os from "os";
24
+ import open from "open";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Types
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export interface CognitoAuthConfig {
31
+ /** AWS region the User Pool lives in (e.g. "us-east-1"). */
32
+ region: string;
33
+ /** Cognito User Pool Domain prefix (e.g. "vault-indigo-stefanjohnson"). */
34
+ userPoolDomain: string;
35
+ /** App Client ID (e.g. "4mmujmjq3srakdueg656b9m0mp"). */
36
+ clientId: string;
37
+ /** Loopback callback port. Defaults to 3000. */
38
+ port?: number;
39
+ /** OAuth scopes. Defaults to ["openid", "email", "profile"]. */
40
+ scopes?: string[];
41
+ }
42
+
43
+ export interface CognitoTokens {
44
+ accessToken: string;
45
+ idToken: string;
46
+ refreshToken: string;
47
+ /** ISO 8601 timestamp when the access token expires. */
48
+ expiresAt: string;
49
+ tokenType: "Bearer";
50
+ }
51
+
52
+ /** Returned when an interactive login is needed but stdin/browser is unavailable. */
53
+ export class CognitoAuthError extends Error {
54
+ constructor(message: string) {
55
+ super(message);
56
+ this.name = "CognitoAuthError";
57
+ }
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Token cache (~/.hq/cognito-tokens.json)
62
+ // ---------------------------------------------------------------------------
63
+
64
+ const HQ_DIR = path.join(os.homedir(), ".hq");
65
+ const TOKEN_FILE = path.join(HQ_DIR, "cognito-tokens.json");
66
+
67
+ export function loadCachedTokens(): CognitoTokens | null {
68
+ if (!fs.existsSync(TOKEN_FILE)) return null;
69
+ try {
70
+ const raw = fs.readFileSync(TOKEN_FILE, "utf-8");
71
+ return JSON.parse(raw) as CognitoTokens;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ export function saveCachedTokens(tokens: CognitoTokens): void {
78
+ if (!fs.existsSync(HQ_DIR)) {
79
+ fs.mkdirSync(HQ_DIR, { recursive: true, mode: 0o700 });
80
+ }
81
+ fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2), { mode: 0o600 });
82
+ }
83
+
84
+ export function clearCachedTokens(): void {
85
+ if (fs.existsSync(TOKEN_FILE)) fs.unlinkSync(TOKEN_FILE);
86
+ }
87
+
88
+ /** True when the token expires within the given buffer (default 60s). */
89
+ export function isExpiring(tokens: CognitoTokens, bufferSeconds = 60): boolean {
90
+ const expiresAt = new Date(tokens.expiresAt).getTime();
91
+ return expiresAt - Date.now() < bufferSeconds * 1000;
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // PKCE
96
+ // ---------------------------------------------------------------------------
97
+
98
+ function base64UrlEncode(buf: Buffer): string {
99
+ return buf
100
+ .toString("base64")
101
+ .replace(/\+/g, "-")
102
+ .replace(/\//g, "_")
103
+ .replace(/=+$/, "");
104
+ }
105
+
106
+ function generatePkce(): { verifier: string; challenge: string } {
107
+ const verifier = base64UrlEncode(crypto.randomBytes(32));
108
+ const challenge = base64UrlEncode(
109
+ crypto.createHash("sha256").update(verifier).digest(),
110
+ );
111
+ return { verifier, challenge };
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Endpoint helpers
116
+ // ---------------------------------------------------------------------------
117
+
118
+ function authBaseUrl(config: CognitoAuthConfig): string {
119
+ return `https://${config.userPoolDomain}.auth.${config.region}.amazoncognito.com`;
120
+ }
121
+
122
+ function redirectUri(port: number): string {
123
+ return `http://localhost:${port}/callback`;
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Browser login
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /**
131
+ * Open the Cognito Hosted UI in the user's browser, wait for the redirect
132
+ * back to localhost, and exchange the auth code for tokens.
133
+ *
134
+ * Times out after 5 minutes if the user doesn't complete the flow.
135
+ */
136
+ export async function browserLogin(
137
+ config: CognitoAuthConfig,
138
+ ): Promise<CognitoTokens> {
139
+ const port = config.port ?? 3000;
140
+ const scopes = (config.scopes ?? ["openid", "email", "profile"]).join(" ");
141
+ const { verifier, challenge } = generatePkce();
142
+ const state = base64UrlEncode(crypto.randomBytes(16));
143
+
144
+ const authUrl = new URL(`${authBaseUrl(config)}/login`);
145
+ authUrl.searchParams.set("client_id", config.clientId);
146
+ authUrl.searchParams.set("response_type", "code");
147
+ authUrl.searchParams.set("scope", scopes);
148
+ authUrl.searchParams.set("redirect_uri", redirectUri(port));
149
+ authUrl.searchParams.set("code_challenge", challenge);
150
+ authUrl.searchParams.set("code_challenge_method", "S256");
151
+ authUrl.searchParams.set("state", state);
152
+
153
+ const code = await waitForAuthCode(port, state);
154
+ const tokens = await exchangeCodeForTokens(config, code, verifier, port);
155
+ saveCachedTokens(tokens);
156
+ return tokens;
157
+
158
+ // -- inner: spin up loopback server and open browser ---------------------
159
+ function waitForAuthCode(port: number, expectedState: string): Promise<string> {
160
+ return new Promise((resolve, reject) => {
161
+ // cleanup() is a function declaration so it can be referenced from the
162
+ // server callbacks and the timeout closure below before its source
163
+ // position. It clears the 15-min login timer + closes the loopback
164
+ // server — without this both keep Node's event loop alive after the
165
+ // calling script "completes", making it look hung.
166
+ const server = http.createServer((req, res) => {
167
+ const url = new URL(req.url ?? "/", `http://localhost:${port}`);
168
+ if (url.pathname !== "/callback") {
169
+ res.writeHead(404);
170
+ res.end("Not found");
171
+ return;
172
+ }
173
+ const code = url.searchParams.get("code");
174
+ const state = url.searchParams.get("state");
175
+ const error = url.searchParams.get("error");
176
+
177
+ if (error) {
178
+ res.writeHead(400, { "Content-Type": "text/html" });
179
+ res.end(`<h1>Authentication failed</h1><p>${escapeHtml(error)}</p>`);
180
+ cleanup();
181
+ reject(new CognitoAuthError(`Cognito returned error: ${error}`));
182
+ return;
183
+ }
184
+ if (state !== expectedState) {
185
+ res.writeHead(400, { "Content-Type": "text/html" });
186
+ res.end("<h1>State mismatch</h1><p>Possible CSRF — try again.</p>");
187
+ cleanup();
188
+ reject(new CognitoAuthError("Cognito state parameter mismatch"));
189
+ return;
190
+ }
191
+ if (!code) {
192
+ res.writeHead(400, { "Content-Type": "text/html" });
193
+ res.end("<h1>Missing code</h1>");
194
+ cleanup();
195
+ reject(new CognitoAuthError("Cognito callback missing code"));
196
+ return;
197
+ }
198
+
199
+ res.writeHead(200, { "Content-Type": "text/html" });
200
+ res.end(
201
+ `<!doctype html><html><body style="font-family:system-ui;text-align:center;padding:48px;">
202
+ <h1>Signed in to HQ by Indigo</h1>
203
+ <p>You can close this tab and return to your terminal.</p>
204
+ <script>setTimeout(()=>window.close(),1500)</script>
205
+ </body></html>`,
206
+ );
207
+ cleanup();
208
+ resolve(code);
209
+ });
210
+
211
+ server.on("error", (err) => {
212
+ cleanup();
213
+ reject(err);
214
+ });
215
+ server.listen(port, "127.0.0.1", () => {
216
+ console.log(`\n Opening browser for HQ sign-in...`);
217
+ console.log(` If your browser doesn't open, visit:\n ${authUrl.toString()}\n`);
218
+ open(authUrl.toString()).catch(() => {
219
+ /* user can paste the URL manually */
220
+ });
221
+ });
222
+
223
+ const loginTimer = setTimeout(
224
+ () => {
225
+ cleanup();
226
+ reject(new CognitoAuthError("Login timed out after 15 minutes"));
227
+ },
228
+ 15 * 60 * 1000,
229
+ );
230
+
231
+ function cleanup() {
232
+ clearTimeout(loginTimer);
233
+ server.close();
234
+ }
235
+ });
236
+ }
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Token exchange + refresh
241
+ // ---------------------------------------------------------------------------
242
+
243
+ interface CognitoTokenResponse {
244
+ access_token: string;
245
+ id_token: string;
246
+ refresh_token?: string;
247
+ expires_in: number;
248
+ token_type: string;
249
+ }
250
+
251
+ async function exchangeCodeForTokens(
252
+ config: CognitoAuthConfig,
253
+ code: string,
254
+ verifier: string,
255
+ port: number,
256
+ ): Promise<CognitoTokens> {
257
+ const body = new URLSearchParams({
258
+ grant_type: "authorization_code",
259
+ client_id: config.clientId,
260
+ code,
261
+ code_verifier: verifier,
262
+ redirect_uri: redirectUri(port),
263
+ });
264
+
265
+ const res = await fetch(`${authBaseUrl(config)}/oauth2/token`, {
266
+ method: "POST",
267
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
268
+ body: body.toString(),
269
+ });
270
+ if (!res.ok) {
271
+ const text = await res.text();
272
+ throw new CognitoAuthError(
273
+ `Token exchange failed (${res.status}): ${text}`,
274
+ );
275
+ }
276
+ const data = (await res.json()) as CognitoTokenResponse;
277
+ if (!data.refresh_token) {
278
+ throw new CognitoAuthError(
279
+ "Cognito did not return a refresh token — check OAuth scopes include offline_access semantics",
280
+ );
281
+ }
282
+ return {
283
+ accessToken: data.access_token,
284
+ idToken: data.id_token,
285
+ refreshToken: data.refresh_token,
286
+ expiresAt: new Date(Date.now() + data.expires_in * 1000).toISOString(),
287
+ tokenType: "Bearer",
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Use the refresh token to obtain a fresh access token without user interaction.
293
+ * The refresh token itself is NOT rotated by Cognito on the refresh grant, so
294
+ * we preserve the existing one in the result.
295
+ */
296
+ export async function refreshTokens(
297
+ config: CognitoAuthConfig,
298
+ currentRefreshToken: string,
299
+ ): Promise<CognitoTokens> {
300
+ const body = new URLSearchParams({
301
+ grant_type: "refresh_token",
302
+ client_id: config.clientId,
303
+ refresh_token: currentRefreshToken,
304
+ });
305
+
306
+ const res = await fetch(`${authBaseUrl(config)}/oauth2/token`, {
307
+ method: "POST",
308
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
309
+ body: body.toString(),
310
+ });
311
+ if (!res.ok) {
312
+ const text = await res.text();
313
+ throw new CognitoAuthError(
314
+ `Refresh failed (${res.status}): ${text}`,
315
+ );
316
+ }
317
+ const data = (await res.json()) as CognitoTokenResponse;
318
+ const tokens: CognitoTokens = {
319
+ accessToken: data.access_token,
320
+ idToken: data.id_token,
321
+ refreshToken: data.refresh_token ?? currentRefreshToken,
322
+ expiresAt: new Date(Date.now() + data.expires_in * 1000).toISOString(),
323
+ tokenType: "Bearer",
324
+ };
325
+ saveCachedTokens(tokens);
326
+ return tokens;
327
+ }
328
+
329
+ /**
330
+ * High-level helper: return a non-expired access token, refreshing or
331
+ * launching browser login as needed.
332
+ *
333
+ * Pass `interactive: false` from automated contexts where you would rather
334
+ * fail fast than open a browser.
335
+ */
336
+ export async function getValidAccessToken(
337
+ config: CognitoAuthConfig,
338
+ options: { interactive?: boolean } = {},
339
+ ): Promise<string> {
340
+ const interactive = options.interactive ?? true;
341
+ const cached = loadCachedTokens();
342
+
343
+ if (cached && !isExpiring(cached)) return cached.accessToken;
344
+
345
+ if (cached) {
346
+ try {
347
+ const refreshed = await refreshTokens(config, cached.refreshToken);
348
+ return refreshed.accessToken;
349
+ } catch {
350
+ // fall through to interactive login
351
+ }
352
+ }
353
+
354
+ if (!interactive) {
355
+ throw new CognitoAuthError(
356
+ "No valid HQ session and interactive login is disabled. Run `hq login` first.",
357
+ );
358
+ }
359
+
360
+ const fresh = await browserLogin(config);
361
+ return fresh.accessToken;
362
+ }
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // Helpers
366
+ // ---------------------------------------------------------------------------
367
+
368
+ function escapeHtml(s: string): string {
369
+ return s
370
+ .replace(/&/g, "&amp;")
371
+ .replace(/</g, "&lt;")
372
+ .replace(/>/g, "&gt;")
373
+ .replace(/"/g, "&quot;")
374
+ .replace(/'/g, "&#39;");
375
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Daemon worker — runs as a detached child process
3
+ * Watches HQ directory and syncs changes to S3
4
+ */
5
+
6
+ import { SyncWatcher } from "./watcher.js";
7
+
8
+ const hqRoot = process.argv[2];
9
+
10
+ if (!hqRoot) {
11
+ console.error("Usage: daemon-worker <hq-root>");
12
+ process.exit(1);
13
+ }
14
+
15
+ const watcher = new SyncWatcher(hqRoot);
16
+ watcher.start();
17
+
18
+ // Handle graceful shutdown
19
+ process.on("SIGTERM", () => {
20
+ watcher.stop();
21
+ process.exit(0);
22
+ });
23
+
24
+ process.on("SIGINT", () => {
25
+ watcher.stop();
26
+ process.exit(0);
27
+ });
28
+
29
+ // Keep process alive
30
+ setInterval(() => {
31
+ // Heartbeat — could add remote change polling here
32
+ }, 30_000);
package/src/daemon.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Background sync daemon management
3
+ * Manages a child process that runs the file watcher
4
+ */
5
+
6
+ import * as fs from "fs";
7
+ import * as path from "path";
8
+ import { fork } from "child_process";
9
+ import { fileURLToPath } from "url";
10
+ import type { DaemonState } from "./types.js";
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = path.dirname(__filename);
14
+
15
+ function getPidFile(hqRoot: string): string {
16
+ return path.join(hqRoot, ".hq-sync.pid");
17
+ }
18
+
19
+ function getStateFile(hqRoot: string): string {
20
+ return path.join(hqRoot, ".hq-sync-daemon.json");
21
+ }
22
+
23
+ export function isDaemonRunning(hqRoot: string): boolean {
24
+ const pidFile = getPidFile(hqRoot);
25
+ if (!fs.existsSync(pidFile)) return false;
26
+
27
+ const pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10);
28
+ try {
29
+ // signal 0 tests if process exists without killing it
30
+ process.kill(pid, 0);
31
+ return true;
32
+ } catch {
33
+ // Process not running, clean up stale PID file
34
+ fs.unlinkSync(pidFile);
35
+ return false;
36
+ }
37
+ }
38
+
39
+ export function startDaemon(hqRoot: string): void {
40
+ if (isDaemonRunning(hqRoot)) {
41
+ console.log(" Sync daemon is already running.");
42
+ return;
43
+ }
44
+
45
+ const workerScript = path.join(__dirname, "daemon-worker.js");
46
+
47
+ const child = fork(workerScript, [hqRoot], {
48
+ detached: true,
49
+ stdio: "ignore",
50
+ });
51
+
52
+ child.unref();
53
+
54
+ if (child.pid) {
55
+ // Write PID file
56
+ fs.writeFileSync(getPidFile(hqRoot), String(child.pid));
57
+
58
+ // Write state
59
+ const state: DaemonState = {
60
+ pid: child.pid,
61
+ startedAt: new Date().toISOString(),
62
+ hqRoot,
63
+ };
64
+ fs.writeFileSync(getStateFile(hqRoot), JSON.stringify(state, null, 2));
65
+ }
66
+ }
67
+
68
+ export function stopDaemon(hqRoot: string): void {
69
+ const pidFile = getPidFile(hqRoot);
70
+ if (!fs.existsSync(pidFile)) {
71
+ console.log(" No sync daemon running.");
72
+ return;
73
+ }
74
+
75
+ const pid = parseInt(fs.readFileSync(pidFile, "utf-8").trim(), 10);
76
+ try {
77
+ process.kill(pid, "SIGTERM");
78
+ } catch {
79
+ // Already dead
80
+ }
81
+
82
+ // Clean up files
83
+ if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile);
84
+ const stateFile = getStateFile(hqRoot);
85
+ if (fs.existsSync(stateFile)) fs.unlinkSync(stateFile);
86
+ }
87
+
88
+ export function getDaemonState(hqRoot: string): DaemonState | null {
89
+ const stateFile = getStateFile(hqRoot);
90
+ if (!fs.existsSync(stateFile)) return null;
91
+ try {
92
+ const content = fs.readFileSync(stateFile, "utf-8");
93
+ return JSON.parse(content) as DaemonState;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
package/src/ignore.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Ignore file parser for .hqsyncignore
3
+ * Uses gitignore-compatible syntax
4
+ */
5
+
6
+ import * as fs from "fs";
7
+ import * as path from "path";
8
+ import ignore from "ignore";
9
+
10
+ // Default patterns that should never sync
11
+ const DEFAULT_IGNORES = [
12
+ ".git/",
13
+ ".git",
14
+ "node_modules/",
15
+ "dist/",
16
+ ".DS_Store",
17
+ "Thumbs.db",
18
+ "*.pid",
19
+ ".hq-sync.pid",
20
+ ".hq-sync-journal.json",
21
+ ".hq-sync-state.json",
22
+ "modules.lock",
23
+ "repos/",
24
+ ".env",
25
+ ".env.*",
26
+ ];
27
+
28
+ export function createIgnoreFilter(hqRoot: string): (filePath: string) => boolean {
29
+ const ig = ignore();
30
+
31
+ // Add defaults
32
+ ig.add(DEFAULT_IGNORES);
33
+
34
+ // Read .hqsyncignore if it exists
35
+ const ignorePath = path.join(hqRoot, ".hqsyncignore");
36
+ if (fs.existsSync(ignorePath)) {
37
+ const content = fs.readFileSync(ignorePath, "utf-8");
38
+ ig.add(content);
39
+ }
40
+
41
+ return (filePath: string): boolean => {
42
+ const relative = path.relative(hqRoot, filePath);
43
+ if (!relative || relative.startsWith("..")) return true; // outside HQ root
44
+ return !ig.ignores(relative);
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Check if a file exceeds the max sync size (50MB default)
50
+ */
51
+ export function isWithinSizeLimit(
52
+ filePath: string,
53
+ maxBytes = 50 * 1024 * 1024
54
+ ): boolean {
55
+ try {
56
+ const stat = fs.statSync(filePath);
57
+ return stat.size <= maxBytes;
58
+ } catch {
59
+ return false;
60
+ }
61
+ }