@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
|
@@ -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, "&")
|
|
371
|
+
.replace(/</g, "<")
|
|
372
|
+
.replace(/>/g, ">")
|
|
373
|
+
.replace(/"/g, """)
|
|
374
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|