@indigoai-us/hq-cloud 5.1.0 → 5.1.8
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/bin/sync-runner.d.ts +111 -0
- package/dist/bin/sync-runner.d.ts.map +1 -0
- package/dist/bin/sync-runner.js +285 -0
- package/dist/bin/sync-runner.js.map +1 -0
- package/dist/bin/sync-runner.test.d.ts +10 -0
- package/dist/bin/sync-runner.test.d.ts.map +1 -0
- package/dist/bin/sync-runner.test.js +492 -0
- package/dist/bin/sync-runner.test.js.map +1 -0
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/share.js +2 -2
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +9 -1
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +28 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +33 -10
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +15 -4
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +19 -1
- package/dist/cognito-auth.js.map +1 -1
- package/dist/cognito-auth.test.d.ts +9 -0
- package/dist/cognito-auth.test.d.ts.map +1 -0
- package/dist/cognito-auth.test.js +113 -0
- package/dist/cognito-auth.test.js.map +1 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +1 -0
- package/dist/context.js.map +1 -1
- package/dist/daemon-worker.d.ts +6 -1
- package/dist/daemon-worker.d.ts.map +1 -1
- package/dist/daemon-worker.js +12 -16
- package/dist/daemon-worker.js.map +1 -1
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +2 -0
- package/dist/daemon.js.map +1 -1
- package/dist/ignore.d.ts +13 -2
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +69 -12
- package/dist/ignore.js.map +1 -1
- package/dist/index.d.ts +24 -28
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -134
- package/dist/index.js.map +1 -1
- package/dist/journal.d.ts +20 -4
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +45 -8
- package/dist/journal.js.map +1 -1
- package/dist/journal.test.d.ts +9 -0
- package/dist/journal.test.d.ts.map +1 -0
- package/dist/journal.test.js +114 -0
- package/dist/journal.test.js.map +1 -0
- package/dist/s3.d.ts +18 -6
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +57 -56
- package/dist/s3.js.map +1 -1
- package/dist/types.d.ts +34 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/vault-client.d.ts +16 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +19 -0
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +25 -0
- package/dist/vault-client.test.js.map +1 -1
- package/dist/watcher.d.ts +7 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +11 -5
- package/dist/watcher.js.map +1 -1
- package/package.json +15 -3
- package/src/bin/sync-runner.test.ts +617 -0
- package/src/bin/sync-runner.ts +390 -0
- package/src/cli/accept.ts +97 -0
- package/src/cli/conflict.ts +119 -0
- package/src/cli/index.ts +25 -0
- package/src/cli/invite.test.ts +247 -0
- package/src/cli/invite.ts +180 -0
- package/src/cli/promote.ts +123 -0
- package/src/cli/share.test.ts +155 -0
- package/src/cli/share.ts +212 -0
- package/src/cli/sync.test.ts +225 -0
- package/src/cli/sync.ts +225 -0
- package/src/cognito-auth.test.ts +156 -0
- package/src/cognito-auth.ts +18 -1
- package/src/context.test.ts +202 -0
- package/src/context.ts +178 -0
- package/src/daemon-worker.ts +13 -19
- package/src/daemon.ts +2 -0
- package/src/ignore.ts +76 -12
- package/src/index.ts +93 -165
- package/src/journal.test.ts +146 -0
- package/src/journal.ts +53 -11
- package/src/s3.ts +76 -66
- package/src/types.ts +37 -0
- package/src/vault-client.test.ts +390 -0
- package/src/vault-client.ts +400 -0
- package/src/watcher.ts +12 -5
- package/test/invite-flow.integration.test.ts +244 -0
- package/test/share-sync.integration.test.ts +210 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* hq-sync-runner — machine-targeted entrypoint for `@indigoai-us/hq-cloud`
|
|
4
|
+
* (ADR-0001).
|
|
5
|
+
*
|
|
6
|
+
* The AppBar Sync menubar (Tauri + Rust) spawns this binary as a subprocess
|
|
7
|
+
* and reads ndjson events from stdout. The protocol is intentionally narrow
|
|
8
|
+
* and versioned-by-shape, not by tooling — no chalk, no colors, no human
|
|
9
|
+
* prose. If you want to invoke sync as a human, use `hq sync` in
|
|
10
|
+
* `@indigoai-us/hq-cli`.
|
|
11
|
+
*
|
|
12
|
+
* Flags:
|
|
13
|
+
* --companies Fan out across every membership the caller has
|
|
14
|
+
* --company <slug-or-uid> Sync a single company (alternative to --companies)
|
|
15
|
+
* --on-conflict <strategy> abort | overwrite | keep (default: abort)
|
|
16
|
+
* --hq-root <path> Local HQ directory (default: $HOME/hq)
|
|
17
|
+
* --json Ignored — ndjson on stdout is the default and
|
|
18
|
+
* only output mode. Accepted for symmetry with the
|
|
19
|
+
* AppBar's argv in case someone passes it.
|
|
20
|
+
*
|
|
21
|
+
* Event protocol (one JSON object per line on stdout):
|
|
22
|
+
* setup-needed — caller signed in but has no person entity yet
|
|
23
|
+
* auth-error — no valid token available (interactive login disabled)
|
|
24
|
+
* fanout-plan — list of companies we're about to sync
|
|
25
|
+
* progress — per-file download
|
|
26
|
+
* error — per-file or per-company error
|
|
27
|
+
* complete — per-company summary
|
|
28
|
+
* all-complete — aggregate summary after fanout
|
|
29
|
+
*
|
|
30
|
+
* Exit code:
|
|
31
|
+
* 0 — event stream describes the outcome (including setup-needed)
|
|
32
|
+
* 1 — argv parse error or unrecoverable pre-sync failure
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import * as os from "os";
|
|
36
|
+
import * as path from "path";
|
|
37
|
+
import * as fs from "fs";
|
|
38
|
+
import { fileURLToPath } from "url";
|
|
39
|
+
import {
|
|
40
|
+
getValidAccessToken,
|
|
41
|
+
VaultClient,
|
|
42
|
+
VaultAuthError,
|
|
43
|
+
type CognitoAuthConfig,
|
|
44
|
+
type VaultServiceConfig,
|
|
45
|
+
type Membership,
|
|
46
|
+
type EntityInfo,
|
|
47
|
+
} from "../index.js";
|
|
48
|
+
import { sync as defaultSync } from "../cli/sync.js";
|
|
49
|
+
import type {
|
|
50
|
+
SyncOptions,
|
|
51
|
+
SyncResult,
|
|
52
|
+
SyncProgressEvent,
|
|
53
|
+
} from "../cli/sync.js";
|
|
54
|
+
import type { ConflictStrategy } from "../cli/conflict.js";
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Defaults — mirror `hq-cli/src/utils/cognito-session.ts`. Inlined (not
|
|
58
|
+
// imported) to avoid a circular dep between hq-cli and hq-cloud. If these
|
|
59
|
+
// drift, the symptom is "runner talks to a different stage than hq sync"
|
|
60
|
+
// — keep both files lined up.
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
const DEFAULT_COGNITO: CognitoAuthConfig = {
|
|
64
|
+
region: process.env.AWS_REGION ?? "us-east-1",
|
|
65
|
+
userPoolDomain: process.env.HQ_COGNITO_DOMAIN ?? "hq-vault-dev",
|
|
66
|
+
clientId: process.env.HQ_COGNITO_CLIENT_ID ?? "4mmujmjq3srakdueg656b9m0mp",
|
|
67
|
+
port: process.env.HQ_COGNITO_CALLBACK_PORT
|
|
68
|
+
? Number(process.env.HQ_COGNITO_CALLBACK_PORT)
|
|
69
|
+
: 8765,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const DEFAULT_VAULT_API_URL =
|
|
73
|
+
process.env.HQ_VAULT_API_URL ??
|
|
74
|
+
"https://tqdwdqxv75.execute-api.us-east-1.amazonaws.com";
|
|
75
|
+
|
|
76
|
+
const DEFAULT_HQ_ROOT = path.join(os.homedir(), "hq");
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Event protocol
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Every event emitted on stdout. The `company` field is present on every
|
|
84
|
+
* event except `setup-needed` / `auth-error` / `fanout-plan` / `all-complete`
|
|
85
|
+
* (which describe the whole run) — consumers should treat its absence as
|
|
86
|
+
* "meta-event, not tied to a specific company".
|
|
87
|
+
*/
|
|
88
|
+
export type RunnerEvent =
|
|
89
|
+
| { type: "setup-needed" }
|
|
90
|
+
| { type: "auth-error"; message: string }
|
|
91
|
+
| {
|
|
92
|
+
type: "fanout-plan";
|
|
93
|
+
companies: Array<{ uid: string; slug: string }>;
|
|
94
|
+
}
|
|
95
|
+
| ({ type: "progress"; company: string } & Omit<Extract<SyncProgressEvent, { type: "progress" }>, "type">)
|
|
96
|
+
| ({ type: "error"; company?: string } & Omit<Extract<SyncProgressEvent, { type: "error" }>, "type">)
|
|
97
|
+
| ({ type: "complete"; company: string } & SyncResult)
|
|
98
|
+
| {
|
|
99
|
+
type: "all-complete";
|
|
100
|
+
companiesAttempted: number;
|
|
101
|
+
filesDownloaded: number;
|
|
102
|
+
bytesDownloaded: number;
|
|
103
|
+
errors: Array<{ company: string; message: string }>;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The narrow VaultClient surface the runner actually uses. Declared here (not
|
|
108
|
+
* `Pick<VaultClient, ...>`) because `Pick` preserves the *entire* `entity`
|
|
109
|
+
* accessor object — but the runner only needs `entity.get`, and forcing test
|
|
110
|
+
* stubs to also implement `findBySlug`/`create` would be dishonest about the
|
|
111
|
+
* real dependency. Keep this interface in sync with the real VaultClient
|
|
112
|
+
* method signatures (both return types come straight from the SDK).
|
|
113
|
+
*/
|
|
114
|
+
export interface VaultClientSurface {
|
|
115
|
+
listMyMemberships: () => Promise<Membership[]>;
|
|
116
|
+
entity: {
|
|
117
|
+
get: (uid: string) => Promise<EntityInfo>;
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface RunnerDeps {
|
|
122
|
+
/** Where to write ndjson events. Defaults to `process.stdout`. */
|
|
123
|
+
stdout?: { write: (chunk: string) => boolean | void };
|
|
124
|
+
/** Where to write diagnostics. Defaults to `process.stderr`. */
|
|
125
|
+
stderr?: { write: (chunk: string) => boolean | void };
|
|
126
|
+
/** Resolve a valid access token. Defaults to `getValidAccessToken` non-interactive. */
|
|
127
|
+
getAccessToken?: () => Promise<string>;
|
|
128
|
+
/**
|
|
129
|
+
* Produce a VaultClient-like object. Defaults to `new VaultClient(config)`.
|
|
130
|
+
* Tests inject a stub here — only `listMyMemberships` and `entity.get` are
|
|
131
|
+
* called by the runner, so stubs only need to implement those.
|
|
132
|
+
*/
|
|
133
|
+
createVaultClient?: (config: VaultServiceConfig) => VaultClientSurface;
|
|
134
|
+
/** Sync function. Defaults to `cli/sync.sync`. */
|
|
135
|
+
sync?: (options: SyncOptions) => Promise<SyncResult>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// argv parser — intentionally minimal (no commander/yargs dep)
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
interface ParsedArgs {
|
|
143
|
+
companies: boolean;
|
|
144
|
+
company?: string;
|
|
145
|
+
onConflict: ConflictStrategy;
|
|
146
|
+
hqRoot: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parseArgs(argv: string[]): ParsedArgs | { error: string } {
|
|
150
|
+
let companies = false;
|
|
151
|
+
let company: string | undefined;
|
|
152
|
+
let onConflict: ConflictStrategy = "abort";
|
|
153
|
+
let hqRoot = DEFAULT_HQ_ROOT;
|
|
154
|
+
|
|
155
|
+
for (let i = 0; i < argv.length; i++) {
|
|
156
|
+
const arg = argv[i];
|
|
157
|
+
switch (arg) {
|
|
158
|
+
case "--companies":
|
|
159
|
+
companies = true;
|
|
160
|
+
break;
|
|
161
|
+
case "--company":
|
|
162
|
+
company = argv[++i];
|
|
163
|
+
if (!company) return { error: "--company requires a value" };
|
|
164
|
+
break;
|
|
165
|
+
case "--on-conflict": {
|
|
166
|
+
const val = argv[++i];
|
|
167
|
+
if (val !== "abort" && val !== "overwrite" && val !== "keep") {
|
|
168
|
+
return {
|
|
169
|
+
error: `--on-conflict must be one of abort|overwrite|keep, got: ${val ?? "(missing)"}`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
onConflict = val;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
case "--hq-root":
|
|
176
|
+
hqRoot = argv[++i];
|
|
177
|
+
if (!hqRoot) return { error: "--hq-root requires a value" };
|
|
178
|
+
break;
|
|
179
|
+
case "--json":
|
|
180
|
+
// Accepted but ignored — ndjson is the only output mode.
|
|
181
|
+
break;
|
|
182
|
+
default:
|
|
183
|
+
return { error: `Unknown argument: ${arg}` };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (companies && company) {
|
|
188
|
+
return { error: "Pass --companies OR --company <slug>, not both" };
|
|
189
|
+
}
|
|
190
|
+
if (!companies && !company) {
|
|
191
|
+
return { error: "Pass --companies or --company <slug>" };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { companies, company, onConflict, hqRoot };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// runRunner — testable entrypoint
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
export async function runRunner(
|
|
202
|
+
argv: string[],
|
|
203
|
+
deps: RunnerDeps = {},
|
|
204
|
+
): Promise<number> {
|
|
205
|
+
const stdout = deps.stdout ?? process.stdout;
|
|
206
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
207
|
+
|
|
208
|
+
const emit = (event: RunnerEvent): void => {
|
|
209
|
+
stdout.write(`${JSON.stringify(event)}\n`);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// ---- argv -------------------------------------------------------------
|
|
213
|
+
const parsed = parseArgs(argv);
|
|
214
|
+
if ("error" in parsed) {
|
|
215
|
+
stderr.write(`hq-sync-runner: ${parsed.error}\n`);
|
|
216
|
+
return 1;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---- auth -------------------------------------------------------------
|
|
220
|
+
let accessToken: string;
|
|
221
|
+
try {
|
|
222
|
+
const getAccessToken =
|
|
223
|
+
deps.getAccessToken ??
|
|
224
|
+
(() => getValidAccessToken(DEFAULT_COGNITO, { interactive: false }));
|
|
225
|
+
accessToken = await getAccessToken();
|
|
226
|
+
} catch (err) {
|
|
227
|
+
emit({
|
|
228
|
+
type: "auth-error",
|
|
229
|
+
message: err instanceof Error ? err.message : String(err),
|
|
230
|
+
});
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---- vault client -----------------------------------------------------
|
|
235
|
+
const vaultConfig: VaultServiceConfig = {
|
|
236
|
+
apiUrl: DEFAULT_VAULT_API_URL,
|
|
237
|
+
authToken: accessToken,
|
|
238
|
+
region: DEFAULT_COGNITO.region,
|
|
239
|
+
};
|
|
240
|
+
const client =
|
|
241
|
+
deps.createVaultClient?.(vaultConfig) ?? new VaultClient(vaultConfig);
|
|
242
|
+
|
|
243
|
+
// ---- resolve targets --------------------------------------------------
|
|
244
|
+
let memberships: Pick<Membership, "companyUid">[];
|
|
245
|
+
try {
|
|
246
|
+
if (parsed.companies) {
|
|
247
|
+
memberships = await client.listMyMemberships();
|
|
248
|
+
if (memberships.length === 0) {
|
|
249
|
+
emit({ type: "setup-needed" });
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
} else {
|
|
253
|
+
// Single-company mode: fabricate a minimal membership so the fanout
|
|
254
|
+
// loop below treats it uniformly. We don't need to hit
|
|
255
|
+
// /membership/me — the caller already told us which company.
|
|
256
|
+
memberships = [{ companyUid: parsed.company! }];
|
|
257
|
+
}
|
|
258
|
+
} catch (err) {
|
|
259
|
+
if (err instanceof VaultAuthError) {
|
|
260
|
+
emit({
|
|
261
|
+
type: "auth-error",
|
|
262
|
+
message: err.message,
|
|
263
|
+
});
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
// Any other failure is unrecoverable — surface as an error event and
|
|
267
|
+
// exit non-zero so the spawner knows the runner didn't get far enough
|
|
268
|
+
// to emit a useful protocol stream.
|
|
269
|
+
emit({
|
|
270
|
+
type: "error",
|
|
271
|
+
message: err instanceof Error ? err.message : String(err),
|
|
272
|
+
path: "(discovery)",
|
|
273
|
+
});
|
|
274
|
+
return 1;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---- resolve slugs for the fanout plan --------------------------------
|
|
278
|
+
// The menubar wants "Syncing indigo" in its UI, not the raw cmp_* ULID.
|
|
279
|
+
// If the entity fetch fails for some row (entity deleted, scoping issue),
|
|
280
|
+
// degrade to using the UID as the slug rather than aborting the run.
|
|
281
|
+
const plan: Array<{ uid: string; slug: string }> = [];
|
|
282
|
+
for (const m of memberships) {
|
|
283
|
+
let slug = m.companyUid;
|
|
284
|
+
try {
|
|
285
|
+
const info = await client.entity.get(m.companyUid);
|
|
286
|
+
slug = info.slug || m.companyUid;
|
|
287
|
+
} catch {
|
|
288
|
+
// Best-effort — keep UID as the display identifier.
|
|
289
|
+
}
|
|
290
|
+
plan.push({ uid: m.companyUid, slug });
|
|
291
|
+
}
|
|
292
|
+
emit({ type: "fanout-plan", companies: plan });
|
|
293
|
+
|
|
294
|
+
// ---- fanout -----------------------------------------------------------
|
|
295
|
+
const syncFn = deps.sync ?? defaultSync;
|
|
296
|
+
let totalFiles = 0;
|
|
297
|
+
let totalBytes = 0;
|
|
298
|
+
const errors: Array<{ company: string; message: string }> = [];
|
|
299
|
+
|
|
300
|
+
for (const target of plan) {
|
|
301
|
+
const companyLabel = target.slug;
|
|
302
|
+
try {
|
|
303
|
+
const result = await syncFn({
|
|
304
|
+
company: target.uid,
|
|
305
|
+
vaultConfig,
|
|
306
|
+
hqRoot: parsed.hqRoot,
|
|
307
|
+
onConflict: parsed.onConflict,
|
|
308
|
+
onEvent: (event) => {
|
|
309
|
+
// Tag per-file events with the company they belong to so the
|
|
310
|
+
// menubar can route them to the right company's progress bar.
|
|
311
|
+
if (event.type === "progress") {
|
|
312
|
+
emit({
|
|
313
|
+
type: "progress",
|
|
314
|
+
company: companyLabel,
|
|
315
|
+
path: event.path,
|
|
316
|
+
bytes: event.bytes,
|
|
317
|
+
...(event.message ? { message: event.message } : {}),
|
|
318
|
+
});
|
|
319
|
+
} else {
|
|
320
|
+
emit({
|
|
321
|
+
type: "error",
|
|
322
|
+
company: companyLabel,
|
|
323
|
+
path: event.path,
|
|
324
|
+
message: event.message,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
emit({ type: "complete", company: companyLabel, ...result });
|
|
330
|
+
totalFiles += result.filesDownloaded;
|
|
331
|
+
totalBytes += result.bytesDownloaded;
|
|
332
|
+
} catch (err) {
|
|
333
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
334
|
+
errors.push({ company: companyLabel, message });
|
|
335
|
+
emit({
|
|
336
|
+
type: "error",
|
|
337
|
+
company: companyLabel,
|
|
338
|
+
path: "(company)",
|
|
339
|
+
message,
|
|
340
|
+
});
|
|
341
|
+
// Continue — one company's failure shouldn't abort the whole fanout.
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
emit({
|
|
346
|
+
type: "all-complete",
|
|
347
|
+
companiesAttempted: plan.length,
|
|
348
|
+
filesDownloaded: totalFiles,
|
|
349
|
+
bytesDownloaded: totalBytes,
|
|
350
|
+
errors,
|
|
351
|
+
});
|
|
352
|
+
return 0;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
// Entrypoint — only runs when invoked directly, not when imported for tests
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
// Detect whether this module is the entry point. The obvious check
|
|
360
|
+
// (`import.meta.url === file://${argv[1]}`) breaks for every real-world
|
|
361
|
+
// install shape: npm-link'd binaries, global installs via Homebrew, and
|
|
362
|
+
// pnpm's `node_modules/.bin` shims all leave `process.argv[1]` pointing
|
|
363
|
+
// at a symlink named `hq-sync-runner` (no `.js` suffix) while
|
|
364
|
+
// `import.meta.url` always resolves to the underlying `sync-runner.js`.
|
|
365
|
+
//
|
|
366
|
+
// Resolve both sides through realpath before comparing — that's the only
|
|
367
|
+
// way to handle all symlink layouts without false negatives. If realpath
|
|
368
|
+
// fails (argv[1] gone, permissions), fall through to `false` so we
|
|
369
|
+
// don't run twice when imported as a library.
|
|
370
|
+
const isDirectInvocation = (() => {
|
|
371
|
+
if (!process.argv[1]) return false;
|
|
372
|
+
try {
|
|
373
|
+
const modulePath = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
374
|
+
const argvPath = fs.realpathSync(process.argv[1]);
|
|
375
|
+
return modulePath === argvPath;
|
|
376
|
+
} catch {
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
})();
|
|
380
|
+
|
|
381
|
+
if (isDirectInvocation) {
|
|
382
|
+
runRunner(process.argv.slice(2))
|
|
383
|
+
.then((code) => process.exit(code))
|
|
384
|
+
.catch((err) => {
|
|
385
|
+
process.stderr.write(
|
|
386
|
+
`hq-sync-runner: uncaught error — ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`,
|
|
387
|
+
);
|
|
388
|
+
process.exit(1);
|
|
389
|
+
});
|
|
390
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq accept` command — accept a membership invite (VLT-7 US-003).
|
|
3
|
+
*
|
|
4
|
+
* Parses magic links (hq://accept/<token> or raw tokens), resolves the
|
|
5
|
+
* caller's identity from Cognito, and calls VaultClient.acceptInvite().
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { VaultServiceConfig } from "../types.js";
|
|
9
|
+
import {
|
|
10
|
+
VaultClient,
|
|
11
|
+
VaultAuthError,
|
|
12
|
+
VaultNotFoundError,
|
|
13
|
+
VaultConflictError,
|
|
14
|
+
VaultPermissionDeniedError,
|
|
15
|
+
} from "../vault-client.js";
|
|
16
|
+
import type { Membership } from "../vault-client.js";
|
|
17
|
+
|
|
18
|
+
export interface AcceptOptions {
|
|
19
|
+
/** Raw token or magic link (hq://accept/<token>) */
|
|
20
|
+
tokenOrLink: string;
|
|
21
|
+
/** Caller's person UID (from Cognito) */
|
|
22
|
+
callerUid: string;
|
|
23
|
+
/** Vault service config */
|
|
24
|
+
vaultConfig: VaultServiceConfig;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AcceptResult {
|
|
28
|
+
membership: Membership;
|
|
29
|
+
companySlug?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse a magic link or raw token into the raw invite token.
|
|
34
|
+
*/
|
|
35
|
+
export function parseToken(tokenOrLink: string): string {
|
|
36
|
+
const trimmed = tokenOrLink.trim();
|
|
37
|
+
|
|
38
|
+
// hq://accept/<token>
|
|
39
|
+
if (trimmed.startsWith("hq://accept/")) {
|
|
40
|
+
return trimmed.slice("hq://accept/".length);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// https://hq.indigoai.com/accept/<token> (future web route)
|
|
44
|
+
const httpsPrefix = "https://hq.indigoai.com/accept/";
|
|
45
|
+
if (trimmed.startsWith(httpsPrefix)) {
|
|
46
|
+
return trimmed.slice(httpsPrefix.length);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Raw token
|
|
50
|
+
return trimmed;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Accept a membership invite.
|
|
55
|
+
*/
|
|
56
|
+
export async function accept(options: AcceptOptions): Promise<AcceptResult> {
|
|
57
|
+
const { tokenOrLink, callerUid, vaultConfig } = options;
|
|
58
|
+
const token = parseToken(tokenOrLink);
|
|
59
|
+
|
|
60
|
+
if (!token) {
|
|
61
|
+
throw new Error("No invite token provided. Usage: /accept <token-or-magic-link>");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const client = new VaultClient(vaultConfig);
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const result = await client.acceptInvite(token, callerUid);
|
|
68
|
+
const membership = result.membership;
|
|
69
|
+
|
|
70
|
+
// Try to resolve company slug for display
|
|
71
|
+
let companySlug: string | undefined;
|
|
72
|
+
if (membership.companyUid) {
|
|
73
|
+
try {
|
|
74
|
+
const entity = await client.entity.get(membership.companyUid);
|
|
75
|
+
companySlug = entity.slug;
|
|
76
|
+
} catch {
|
|
77
|
+
// Non-critical — just display UID instead
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { membership, companySlug };
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (err instanceof VaultAuthError) {
|
|
84
|
+
throw new Error("Authentication failed — run `hq auth` to refresh your session");
|
|
85
|
+
}
|
|
86
|
+
if (err instanceof VaultConflictError) {
|
|
87
|
+
throw new Error("This invite was already accepted");
|
|
88
|
+
}
|
|
89
|
+
if (err instanceof VaultNotFoundError) {
|
|
90
|
+
throw new Error("Invite not found or expired");
|
|
91
|
+
}
|
|
92
|
+
if (err instanceof VaultPermissionDeniedError) {
|
|
93
|
+
throw new Error("This invite was for a different person");
|
|
94
|
+
}
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conflict resolution for hq share/sync (VLT-5 US-002).
|
|
3
|
+
*
|
|
4
|
+
* Interactive prompts in terminal mode; deterministic resolution via
|
|
5
|
+
* --on-conflict flag for worker/skill callers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from "fs";
|
|
9
|
+
import * as readline from "readline";
|
|
10
|
+
|
|
11
|
+
export type ConflictStrategy = "overwrite" | "keep" | "abort";
|
|
12
|
+
|
|
13
|
+
export interface ConflictInfo {
|
|
14
|
+
path: string;
|
|
15
|
+
localHash?: string;
|
|
16
|
+
remoteHash?: string;
|
|
17
|
+
localModified?: Date;
|
|
18
|
+
remoteModified?: Date;
|
|
19
|
+
direction: "push" | "pull";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ConflictResolution = "overwrite" | "keep" | "skip" | "diff" | "abort";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a conflict interactively or via strategy flag.
|
|
26
|
+
*
|
|
27
|
+
* In non-interactive mode (strategy provided), returns deterministically:
|
|
28
|
+
* overwrite → "overwrite"
|
|
29
|
+
* keep → "keep"
|
|
30
|
+
* abort → "abort"
|
|
31
|
+
*
|
|
32
|
+
* In interactive mode (strategy undefined), prompts the user.
|
|
33
|
+
*/
|
|
34
|
+
export async function resolveConflict(
|
|
35
|
+
conflict: ConflictInfo,
|
|
36
|
+
strategy?: ConflictStrategy,
|
|
37
|
+
): Promise<ConflictResolution> {
|
|
38
|
+
if (strategy) {
|
|
39
|
+
return strategy === "abort" ? "abort" : strategy;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return promptConflict(conflict);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function promptConflict(conflict: ConflictInfo): Promise<ConflictResolution> {
|
|
46
|
+
const rl = readline.createInterface({
|
|
47
|
+
input: process.stdin,
|
|
48
|
+
output: process.stderr,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const direction = conflict.direction === "push"
|
|
52
|
+
? "Remote has a newer version"
|
|
53
|
+
: "Local file has uncommitted edits";
|
|
54
|
+
|
|
55
|
+
console.error(`\n Conflict: ${conflict.path}`);
|
|
56
|
+
console.error(` ${direction}`);
|
|
57
|
+
if (conflict.localModified) {
|
|
58
|
+
console.error(` Local modified: ${conflict.localModified.toISOString()}`);
|
|
59
|
+
}
|
|
60
|
+
if (conflict.remoteModified) {
|
|
61
|
+
console.error(` Remote modified: ${conflict.remoteModified.toISOString()}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const options = conflict.direction === "push"
|
|
65
|
+
? "[o]verwrite remote / [k]eep remote / [d]iff / [a]bort"
|
|
66
|
+
: "[o]verwrite local / [k]eep local / [d]iff / [s]kip";
|
|
67
|
+
|
|
68
|
+
const answer = await new Promise<string>((resolve) => {
|
|
69
|
+
rl.question(` ${options}: `, (ans) => {
|
|
70
|
+
rl.close();
|
|
71
|
+
resolve(ans.trim().toLowerCase());
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
switch (answer) {
|
|
76
|
+
case "o":
|
|
77
|
+
case "overwrite":
|
|
78
|
+
return "overwrite";
|
|
79
|
+
case "k":
|
|
80
|
+
case "keep":
|
|
81
|
+
return "keep";
|
|
82
|
+
case "d":
|
|
83
|
+
case "diff":
|
|
84
|
+
return "diff";
|
|
85
|
+
case "s":
|
|
86
|
+
case "skip":
|
|
87
|
+
return "skip";
|
|
88
|
+
case "a":
|
|
89
|
+
case "abort":
|
|
90
|
+
return "abort";
|
|
91
|
+
default:
|
|
92
|
+
// Default to keep (safe option)
|
|
93
|
+
console.error(" Unrecognized choice, keeping current version.");
|
|
94
|
+
return "keep";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Show a simple diff between local and remote content.
|
|
100
|
+
* Returns the content strings for display.
|
|
101
|
+
*/
|
|
102
|
+
export function showDiff(
|
|
103
|
+
localPath: string,
|
|
104
|
+
remoteContent: Buffer,
|
|
105
|
+
): void {
|
|
106
|
+
const localContent = fs.existsSync(localPath)
|
|
107
|
+
? fs.readFileSync(localPath, "utf-8")
|
|
108
|
+
: "(file does not exist locally)";
|
|
109
|
+
const remoteStr = remoteContent.toString("utf-8");
|
|
110
|
+
|
|
111
|
+
console.error("\n--- LOCAL ---");
|
|
112
|
+
console.error(localContent.slice(0, 2000));
|
|
113
|
+
if (localContent.length > 2000) console.error("... (truncated)");
|
|
114
|
+
|
|
115
|
+
console.error("\n--- REMOTE ---");
|
|
116
|
+
console.error(remoteStr.slice(0, 2000));
|
|
117
|
+
if (remoteStr.length > 2000) console.error("... (truncated)");
|
|
118
|
+
console.error("");
|
|
119
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hq-cloud CLI entry points.
|
|
3
|
+
*
|
|
4
|
+
* Registers `hq share`, `hq sync`, and membership commands.
|
|
5
|
+
* These are consumed by @indigoai-us/hq-cli or invoked directly.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { share } from "./share.js";
|
|
9
|
+
export type { ShareOptions, ShareResult } from "./share.js";
|
|
10
|
+
|
|
11
|
+
export { sync } from "./sync.js";
|
|
12
|
+
export type { SyncOptions, SyncResult, SyncProgressEvent } from "./sync.js";
|
|
13
|
+
|
|
14
|
+
export { resolveConflict, showDiff } from "./conflict.js";
|
|
15
|
+
export type { ConflictStrategy, ConflictInfo, ConflictResolution } from "./conflict.js";
|
|
16
|
+
|
|
17
|
+
// Membership commands (VLT-7)
|
|
18
|
+
export { invite, listInvites, revokeInvite } from "./invite.js";
|
|
19
|
+
export type { InviteOptions, InviteResult, InviteListOptions, InviteRevokeOptions } from "./invite.js";
|
|
20
|
+
|
|
21
|
+
export { accept, parseToken } from "./accept.js";
|
|
22
|
+
export type { AcceptOptions, AcceptResult } from "./accept.js";
|
|
23
|
+
|
|
24
|
+
export { promote } from "./promote.js";
|
|
25
|
+
export type { PromoteOptions, PromoteResult } from "./promote.js";
|