@indigoai-us/hq-cloud 5.1.8 → 5.1.10
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.js +2 -2
- package/dist/auth.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +26 -3
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +77 -2
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +165 -9
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/accept.js +2 -2
- package/dist/cli/accept.js.map +1 -1
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +23 -7
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +51 -13
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +6 -1
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +31 -12
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/cognito-auth.d.ts +13 -2
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +18 -9
- package/dist/cognito-auth.js.map +1 -1
- package/dist/cognito-auth.test.d.ts +3 -3
- package/dist/cognito-auth.test.js +21 -10
- package/dist/cognito-auth.test.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/vault-client.d.ts +43 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +53 -0
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +135 -0
- package/dist/vault-client.test.js.map +1 -1
- package/package.json +1 -1
- package/src/auth.ts +2 -2
- package/src/bin/sync-runner.test.ts +200 -13
- package/src/bin/sync-runner.ts +114 -5
- package/src/cli/accept.ts +2 -2
- package/src/cli/share.test.ts +59 -13
- package/src/cli/share.ts +25 -6
- package/src/cli/sync.test.ts +33 -12
- package/src/cli/sync.ts +6 -1
- package/src/cognito-auth.test.ts +22 -14
- package/src/cognito-auth.ts +31 -11
- package/src/index.ts +1 -0
- package/src/vault-client.test.ts +173 -0
- package/src/vault-client.ts +78 -0
- package/test/invite-flow.integration.test.ts +1 -1
|
@@ -15,7 +15,11 @@ import type {
|
|
|
15
15
|
VaultClientSurface,
|
|
16
16
|
} from "./sync-runner.js";
|
|
17
17
|
import type { SyncResult, SyncOptions } from "../cli/sync.js";
|
|
18
|
-
import type {
|
|
18
|
+
import type {
|
|
19
|
+
Membership,
|
|
20
|
+
EntityInfo,
|
|
21
|
+
PendingInviteByEmail,
|
|
22
|
+
} from "../vault-client.js";
|
|
19
23
|
import { VaultAuthError } from "../vault-client.js";
|
|
20
24
|
|
|
21
25
|
// ---------------------------------------------------------------------------
|
|
@@ -70,11 +74,31 @@ function makeVaultStub(
|
|
|
70
74
|
opts: {
|
|
71
75
|
memberships?: Array<Pick<Membership, "companyUid">>;
|
|
72
76
|
entityGet?: (uid: string) => Promise<EntityInfo>;
|
|
77
|
+
pendingInvites?: Array<Record<string, unknown>>;
|
|
78
|
+
ensurePerson?: (hints: {
|
|
79
|
+
ownerSub: string;
|
|
80
|
+
displayName: string;
|
|
81
|
+
}) => Promise<EntityInfo>;
|
|
82
|
+
claim?: (personUid: string) => Promise<void>;
|
|
73
83
|
} = {},
|
|
74
84
|
): VaultClientSurface {
|
|
75
85
|
const memberships = opts.memberships ?? [];
|
|
86
|
+
const pending = opts.pendingInvites ?? [];
|
|
76
87
|
return {
|
|
77
88
|
listMyMemberships: () => Promise.resolve(memberships as Membership[]),
|
|
89
|
+
listMyPendingInvitesByEmail: () =>
|
|
90
|
+
Promise.resolve(pending as unknown as PendingInviteByEmail[]),
|
|
91
|
+
claimPendingInvitesByEmail:
|
|
92
|
+
opts.claim ?? (() => Promise.resolve(undefined)),
|
|
93
|
+
ensureMyPersonEntity:
|
|
94
|
+
opts.ensurePerson ??
|
|
95
|
+
(() =>
|
|
96
|
+
Promise.resolve({
|
|
97
|
+
uid: "ent_person_default",
|
|
98
|
+
type: "person",
|
|
99
|
+
slug: "default-person",
|
|
100
|
+
status: "active",
|
|
101
|
+
} as unknown as EntityInfo)),
|
|
78
102
|
entity: {
|
|
79
103
|
get:
|
|
80
104
|
opts.entityGet ??
|
|
@@ -181,12 +205,9 @@ describe("auth", () => {
|
|
|
181
205
|
it("emits auth-error when VaultAuthError thrown during discovery", async () => {
|
|
182
206
|
const deps = makeDeps({
|
|
183
207
|
createVaultClient: () => ({
|
|
208
|
+
...makeVaultStub(),
|
|
184
209
|
listMyMemberships: () =>
|
|
185
210
|
Promise.reject(new VaultAuthError("token expired")),
|
|
186
|
-
entity: {
|
|
187
|
-
get: (uid: string) =>
|
|
188
|
-
Promise.resolve({ uid, slug: uid } as unknown as EntityInfo),
|
|
189
|
-
},
|
|
190
211
|
}),
|
|
191
212
|
});
|
|
192
213
|
const code = await runRunner(["--companies"], deps);
|
|
@@ -199,11 +220,8 @@ describe("auth", () => {
|
|
|
199
220
|
it("emits error event and returns 1 on non-auth discovery failure", async () => {
|
|
200
221
|
const deps = makeDeps({
|
|
201
222
|
createVaultClient: () => ({
|
|
223
|
+
...makeVaultStub(),
|
|
202
224
|
listMyMemberships: () => Promise.reject(new Error("network down")),
|
|
203
|
-
entity: {
|
|
204
|
-
get: (uid: string) =>
|
|
205
|
-
Promise.resolve({ uid, slug: uid } as unknown as EntityInfo),
|
|
206
|
-
},
|
|
207
225
|
}),
|
|
208
226
|
});
|
|
209
227
|
const code = await runRunner(["--companies"], deps);
|
|
@@ -218,6 +236,150 @@ describe("auth", () => {
|
|
|
218
236
|
});
|
|
219
237
|
});
|
|
220
238
|
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// claim-dance (first sign-in)
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
describe("claim-dance", () => {
|
|
244
|
+
const claims = {
|
|
245
|
+
sub: "sub-abc",
|
|
246
|
+
email: "stefan@getindigo.ai",
|
|
247
|
+
name: "Stefan Johnson",
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
it("claims pending invites + ensures person before listing memberships", async () => {
|
|
251
|
+
const ensureSpy = vi.fn().mockResolvedValue({
|
|
252
|
+
uid: "ent_person_stefan",
|
|
253
|
+
type: "person",
|
|
254
|
+
slug: "stefan-johnson",
|
|
255
|
+
status: "active",
|
|
256
|
+
});
|
|
257
|
+
const claimSpy = vi.fn().mockResolvedValue(undefined);
|
|
258
|
+
// First listMyMemberships returns the just-claimed row.
|
|
259
|
+
let listCalls = 0;
|
|
260
|
+
const stub = makeVaultStub({
|
|
261
|
+
pendingInvites: [
|
|
262
|
+
{
|
|
263
|
+
membershipKey: "inv_1",
|
|
264
|
+
companyUid: "cmp_indigo",
|
|
265
|
+
role: "owner",
|
|
266
|
+
invitedBy: "sub-admin",
|
|
267
|
+
invitedAt: "2026-04-20T00:00:00Z",
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
ensurePerson: ensureSpy as unknown as VaultClientSurface["ensureMyPersonEntity"],
|
|
271
|
+
claim: claimSpy as unknown as VaultClientSurface["claimPendingInvitesByEmail"],
|
|
272
|
+
});
|
|
273
|
+
stub.listMyMemberships = () => {
|
|
274
|
+
listCalls++;
|
|
275
|
+
return Promise.resolve([{ companyUid: "cmp_indigo" }] as Membership[]);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const deps = makeDeps({
|
|
279
|
+
createVaultClient: () => stub,
|
|
280
|
+
getIdTokenClaims: () => claims,
|
|
281
|
+
});
|
|
282
|
+
const code = await runRunner(["--companies"], deps);
|
|
283
|
+
expect(code).toBe(0);
|
|
284
|
+
expect(ensureSpy).toHaveBeenCalledWith({
|
|
285
|
+
ownerSub: "sub-abc",
|
|
286
|
+
displayName: "Stefan Johnson",
|
|
287
|
+
});
|
|
288
|
+
expect(claimSpy).toHaveBeenCalledWith("ent_person_stefan");
|
|
289
|
+
expect(listCalls).toBe(1);
|
|
290
|
+
// setup-needed must NOT fire — the user has memberships after the claim.
|
|
291
|
+
expect(deps.stdout.events().some((e) => e.type === "setup-needed")).toBe(
|
|
292
|
+
false,
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("skips ensurePerson + claim when no pending invites exist", async () => {
|
|
297
|
+
const ensureSpy = vi.fn();
|
|
298
|
+
const claimSpy = vi.fn();
|
|
299
|
+
const deps = makeDeps({
|
|
300
|
+
createVaultClient: () =>
|
|
301
|
+
makeVaultStub({
|
|
302
|
+
pendingInvites: [],
|
|
303
|
+
ensurePerson:
|
|
304
|
+
ensureSpy as unknown as VaultClientSurface["ensureMyPersonEntity"],
|
|
305
|
+
claim: claimSpy as unknown as VaultClientSurface["claimPendingInvitesByEmail"],
|
|
306
|
+
}),
|
|
307
|
+
getIdTokenClaims: () => claims,
|
|
308
|
+
});
|
|
309
|
+
const code = await runRunner(["--companies"], deps);
|
|
310
|
+
expect(code).toBe(0);
|
|
311
|
+
expect(ensureSpy).not.toHaveBeenCalled();
|
|
312
|
+
expect(claimSpy).not.toHaveBeenCalled();
|
|
313
|
+
// No memberships, no invites — truly empty → setup-needed is correct here.
|
|
314
|
+
expect(deps.stdout.events()).toEqual([{ type: "setup-needed" }]);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("skips claim-dance entirely when no idToken claims are available", async () => {
|
|
318
|
+
const pendingSpy = vi.fn().mockResolvedValue([]);
|
|
319
|
+
const stub = makeVaultStub();
|
|
320
|
+
stub.listMyPendingInvitesByEmail =
|
|
321
|
+
pendingSpy as unknown as VaultClientSurface["listMyPendingInvitesByEmail"];
|
|
322
|
+
const deps = makeDeps({
|
|
323
|
+
createVaultClient: () => stub,
|
|
324
|
+
getIdTokenClaims: () => null,
|
|
325
|
+
});
|
|
326
|
+
await runRunner(["--companies"], deps);
|
|
327
|
+
expect(pendingSpy).not.toHaveBeenCalled();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("does not fail the run when claim-dance throws (best-effort)", async () => {
|
|
331
|
+
const stub = makeVaultStub({
|
|
332
|
+
memberships: [{ companyUid: "cmp_a" }],
|
|
333
|
+
});
|
|
334
|
+
stub.listMyPendingInvitesByEmail = () =>
|
|
335
|
+
Promise.reject(new Error("vault 500"));
|
|
336
|
+
const deps = makeDeps({
|
|
337
|
+
createVaultClient: () => stub,
|
|
338
|
+
getIdTokenClaims: () => claims,
|
|
339
|
+
});
|
|
340
|
+
const code = await runRunner(["--companies"], deps);
|
|
341
|
+
expect(code).toBe(0);
|
|
342
|
+
// Sync proceeds as usual for the existing membership.
|
|
343
|
+
expect(deps.sync).toHaveBeenCalledTimes(1);
|
|
344
|
+
expect(deps.stderr.raw()).toContain("claim-dance skipped");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("falls back to given_name + family_name when name claim is absent", async () => {
|
|
348
|
+
const ensureSpy = vi.fn().mockResolvedValue({
|
|
349
|
+
uid: "ent_person_x",
|
|
350
|
+
type: "person",
|
|
351
|
+
slug: "x",
|
|
352
|
+
status: "active",
|
|
353
|
+
});
|
|
354
|
+
const deps = makeDeps({
|
|
355
|
+
createVaultClient: () =>
|
|
356
|
+
makeVaultStub({
|
|
357
|
+
pendingInvites: [
|
|
358
|
+
{
|
|
359
|
+
membershipKey: "inv_1",
|
|
360
|
+
companyUid: "cmp_x",
|
|
361
|
+
role: "owner",
|
|
362
|
+
invitedBy: "sub-admin",
|
|
363
|
+
invitedAt: "2026-04-20T00:00:00Z",
|
|
364
|
+
},
|
|
365
|
+
],
|
|
366
|
+
ensurePerson:
|
|
367
|
+
ensureSpy as unknown as VaultClientSurface["ensureMyPersonEntity"],
|
|
368
|
+
}),
|
|
369
|
+
getIdTokenClaims: () => ({
|
|
370
|
+
sub: "sub-xyz",
|
|
371
|
+
given_name: "Ada",
|
|
372
|
+
family_name: "Lovelace",
|
|
373
|
+
}),
|
|
374
|
+
});
|
|
375
|
+
await runRunner(["--companies"], deps);
|
|
376
|
+
expect(ensureSpy).toHaveBeenCalledWith({
|
|
377
|
+
ownerSub: "sub-xyz",
|
|
378
|
+
displayName: "Ada Lovelace",
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
221
383
|
// ---------------------------------------------------------------------------
|
|
222
384
|
// target resolution
|
|
223
385
|
// ---------------------------------------------------------------------------
|
|
@@ -236,11 +398,11 @@ describe("target resolution", () => {
|
|
|
236
398
|
const listSpy = vi.fn();
|
|
237
399
|
const deps = makeDeps({
|
|
238
400
|
createVaultClient: () => ({
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
get: (uid: string) =>
|
|
401
|
+
...makeVaultStub({
|
|
402
|
+
entityGet: (uid: string) =>
|
|
242
403
|
Promise.resolve({ uid, slug: "acme" } as unknown as EntityInfo),
|
|
243
|
-
},
|
|
404
|
+
}),
|
|
405
|
+
listMyMemberships: listSpy as unknown as () => Promise<Membership[]>,
|
|
244
406
|
}),
|
|
245
407
|
});
|
|
246
408
|
const code = await runRunner(["--company", "cmp_abc"], deps);
|
|
@@ -303,6 +465,31 @@ describe("fanout-plan", () => {
|
|
|
303
465
|
expect(plan.companies).toEqual([{ uid: "cmp_ghost", slug: "cmp_ghost" }]);
|
|
304
466
|
});
|
|
305
467
|
|
|
468
|
+
it("includes entity.name on plan entries when available", async () => {
|
|
469
|
+
const deps = makeDeps({
|
|
470
|
+
createVaultClient: () =>
|
|
471
|
+
makeVaultStub({
|
|
472
|
+
memberships: [{ companyUid: "cmp_a" }, { companyUid: "cmp_b" }],
|
|
473
|
+
entityGet: (uid: string) =>
|
|
474
|
+
Promise.resolve({
|
|
475
|
+
uid,
|
|
476
|
+
slug: uid === "cmp_a" ? "acme" : "beta",
|
|
477
|
+
name: uid === "cmp_a" ? "Acme Corp" : undefined,
|
|
478
|
+
} as unknown as EntityInfo),
|
|
479
|
+
}),
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const code = await runRunner(["--companies"], deps);
|
|
483
|
+
expect(code).toBe(0);
|
|
484
|
+
const plan = deps.stdout
|
|
485
|
+
.events()
|
|
486
|
+
.find((e) => e.type === "fanout-plan") as Extract<RunnerEvent, { type: "fanout-plan" }>;
|
|
487
|
+
expect(plan.companies).toEqual([
|
|
488
|
+
{ uid: "cmp_a", slug: "acme", name: "Acme Corp" },
|
|
489
|
+
{ uid: "cmp_b", slug: "beta" },
|
|
490
|
+
]);
|
|
491
|
+
});
|
|
492
|
+
|
|
306
493
|
it("degrades to UID when entity.get returns falsy slug", async () => {
|
|
307
494
|
const deps = makeDeps({
|
|
308
495
|
createVaultClient: () =>
|
package/src/bin/sync-runner.ts
CHANGED
|
@@ -38,12 +38,15 @@ import * as fs from "fs";
|
|
|
38
38
|
import { fileURLToPath } from "url";
|
|
39
39
|
import {
|
|
40
40
|
getValidAccessToken,
|
|
41
|
+
loadCachedTokens,
|
|
41
42
|
VaultClient,
|
|
42
43
|
VaultAuthError,
|
|
43
44
|
type CognitoAuthConfig,
|
|
45
|
+
type CognitoTokens,
|
|
44
46
|
type VaultServiceConfig,
|
|
45
47
|
type Membership,
|
|
46
48
|
type EntityInfo,
|
|
49
|
+
type PendingInviteByEmail,
|
|
47
50
|
} from "../index.js";
|
|
48
51
|
import { sync as defaultSync } from "../cli/sync.js";
|
|
49
52
|
import type {
|
|
@@ -90,7 +93,7 @@ export type RunnerEvent =
|
|
|
90
93
|
| { type: "auth-error"; message: string }
|
|
91
94
|
| {
|
|
92
95
|
type: "fanout-plan";
|
|
93
|
-
companies: Array<{ uid: string; slug: string }>;
|
|
96
|
+
companies: Array<{ uid: string; slug: string; name?: string }>;
|
|
94
97
|
}
|
|
95
98
|
| ({ type: "progress"; company: string } & Omit<Extract<SyncProgressEvent, { type: "progress" }>, "type">)
|
|
96
99
|
| ({ type: "error"; company?: string } & Omit<Extract<SyncProgressEvent, { type: "error" }>, "type">)
|
|
@@ -113,11 +116,26 @@ export type RunnerEvent =
|
|
|
113
116
|
*/
|
|
114
117
|
export interface VaultClientSurface {
|
|
115
118
|
listMyMemberships: () => Promise<Membership[]>;
|
|
119
|
+
listMyPendingInvitesByEmail: () => Promise<PendingInviteByEmail[]>;
|
|
120
|
+
claimPendingInvitesByEmail: (personUid: string) => Promise<void>;
|
|
121
|
+
ensureMyPersonEntity: (hints: {
|
|
122
|
+
ownerSub: string;
|
|
123
|
+
displayName: string;
|
|
124
|
+
}) => Promise<EntityInfo>;
|
|
116
125
|
entity: {
|
|
117
126
|
get: (uid: string) => Promise<EntityInfo>;
|
|
118
127
|
};
|
|
119
128
|
}
|
|
120
129
|
|
|
130
|
+
/** Minimal shape of the claims we read off the Cognito idToken. */
|
|
131
|
+
interface IdTokenClaims {
|
|
132
|
+
sub?: string;
|
|
133
|
+
email?: string;
|
|
134
|
+
name?: string;
|
|
135
|
+
given_name?: string;
|
|
136
|
+
family_name?: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
121
139
|
export interface RunnerDeps {
|
|
122
140
|
/** Where to write ndjson events. Defaults to `process.stdout`. */
|
|
123
141
|
stdout?: { write: (chunk: string) => boolean | void };
|
|
@@ -125,16 +143,93 @@ export interface RunnerDeps {
|
|
|
125
143
|
stderr?: { write: (chunk: string) => boolean | void };
|
|
126
144
|
/** Resolve a valid access token. Defaults to `getValidAccessToken` non-interactive. */
|
|
127
145
|
getAccessToken?: () => Promise<string>;
|
|
146
|
+
/**
|
|
147
|
+
* Read the caller's identity claims (sub/email/name) off the cached Cognito
|
|
148
|
+
* idToken. Defaults to decoding `loadCachedTokens().idToken`. Returns `null`
|
|
149
|
+
* when no cached tokens exist — the runner will then skip the claim-dance
|
|
150
|
+
* and fall through to the usual listMyMemberships path.
|
|
151
|
+
*/
|
|
152
|
+
getIdTokenClaims?: () => IdTokenClaims | null;
|
|
128
153
|
/**
|
|
129
154
|
* Produce a VaultClient-like object. Defaults to `new VaultClient(config)`.
|
|
130
|
-
* Tests inject a stub here — only
|
|
131
|
-
*
|
|
155
|
+
* Tests inject a stub here — the runner only calls the methods listed in
|
|
156
|
+
* `VaultClientSurface`.
|
|
132
157
|
*/
|
|
133
158
|
createVaultClient?: (config: VaultServiceConfig) => VaultClientSurface;
|
|
134
159
|
/** Sync function. Defaults to `cli/sync.sync`. */
|
|
135
160
|
sync?: (options: SyncOptions) => Promise<SyncResult>;
|
|
136
161
|
}
|
|
137
162
|
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// JWT claim decoder — inlined to avoid pulling a dep just to read an idToken.
|
|
165
|
+
// We do NOT verify the signature here — Cognito already did that when it
|
|
166
|
+
// issued the token, and we only read the public claims (sub/email/name) to
|
|
167
|
+
// drive the claim-dance + create the person entity. If the token is tampered
|
|
168
|
+
// with, the downstream vault-service call will reject it (signature-verified
|
|
169
|
+
// there) long before any claimed value causes harm.
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
function decodeJwtClaims(jwt: string): IdTokenClaims | null {
|
|
173
|
+
const parts = jwt.split(".");
|
|
174
|
+
if (parts.length !== 3) return null;
|
|
175
|
+
try {
|
|
176
|
+
const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
177
|
+
const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4);
|
|
178
|
+
const json = Buffer.from(padded, "base64").toString("utf-8");
|
|
179
|
+
return JSON.parse(json) as IdTokenClaims;
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function defaultGetIdTokenClaims(): IdTokenClaims | null {
|
|
186
|
+
const tokens: CognitoTokens | null = loadCachedTokens();
|
|
187
|
+
if (!tokens?.idToken) return null;
|
|
188
|
+
return decodeJwtClaims(tokens.idToken);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Best-effort: claim any email-keyed pending invites that were sent before
|
|
193
|
+
* this user had a person entity. Mirrors the installer's vault-handoff flow.
|
|
194
|
+
*
|
|
195
|
+
* Silent on the happy path — only logs to stderr on soft failures (so a
|
|
196
|
+
* transient network blip doesn't block the sync). Never throws: a caller who
|
|
197
|
+
* can't list memberships despite an unclaimed invite is no worse off than the
|
|
198
|
+
* pre-claim-dance behavior (which was to emit setup-needed).
|
|
199
|
+
*/
|
|
200
|
+
async function runClaimDance(
|
|
201
|
+
client: VaultClientSurface,
|
|
202
|
+
claims: IdTokenClaims,
|
|
203
|
+
stderr: { write: (chunk: string) => boolean | void },
|
|
204
|
+
): Promise<void> {
|
|
205
|
+
try {
|
|
206
|
+
const pending = await client.listMyPendingInvitesByEmail();
|
|
207
|
+
if (pending.length === 0) return;
|
|
208
|
+
|
|
209
|
+
const displayName =
|
|
210
|
+
claims.name ??
|
|
211
|
+
[claims.given_name, claims.family_name].filter(Boolean).join(" ") ??
|
|
212
|
+
claims.email ??
|
|
213
|
+
"";
|
|
214
|
+
const ownerSub = claims.sub ?? "";
|
|
215
|
+
if (!ownerSub || !displayName) {
|
|
216
|
+
stderr.write(
|
|
217
|
+
"hq-sync-runner: skipping claim-dance — idToken missing sub/name\n",
|
|
218
|
+
);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const person = await client.ensureMyPersonEntity({
|
|
223
|
+
ownerSub,
|
|
224
|
+
displayName,
|
|
225
|
+
});
|
|
226
|
+
await client.claimPendingInvitesByEmail(person.uid);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
229
|
+
stderr.write(`hq-sync-runner: claim-dance skipped — ${msg}\n`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
138
233
|
// ---------------------------------------------------------------------------
|
|
139
234
|
// argv parser — intentionally minimal (no commander/yargs dep)
|
|
140
235
|
// ---------------------------------------------------------------------------
|
|
@@ -244,8 +339,20 @@ export async function runRunner(
|
|
|
244
339
|
let memberships: Pick<Membership, "companyUid">[];
|
|
245
340
|
try {
|
|
246
341
|
if (parsed.companies) {
|
|
342
|
+
// Before giving up on memberships, run the claim-dance: new users signed
|
|
343
|
+
// in via the tray may have email-keyed invites waiting for them. Without
|
|
344
|
+
// this, an invited user would see "setup-needed" on every tray click.
|
|
345
|
+
const getClaims = deps.getIdTokenClaims ?? defaultGetIdTokenClaims;
|
|
346
|
+
const claims = getClaims();
|
|
347
|
+
if (claims) {
|
|
348
|
+
await runClaimDance(client, claims, stderr);
|
|
349
|
+
}
|
|
350
|
+
|
|
247
351
|
memberships = await client.listMyMemberships();
|
|
248
352
|
if (memberships.length === 0) {
|
|
353
|
+
// Truly empty — still a valid state (no memberships = nothing to
|
|
354
|
+
// sync). The tray will show a friendly "create your first company"
|
|
355
|
+
// CTA rather than an alarm banner.
|
|
249
356
|
emit({ type: "setup-needed" });
|
|
250
357
|
return 0;
|
|
251
358
|
}
|
|
@@ -278,16 +385,18 @@ export async function runRunner(
|
|
|
278
385
|
// The menubar wants "Syncing indigo" in its UI, not the raw cmp_* ULID.
|
|
279
386
|
// If the entity fetch fails for some row (entity deleted, scoping issue),
|
|
280
387
|
// degrade to using the UID as the slug rather than aborting the run.
|
|
281
|
-
const plan: Array<{ uid: string; slug: string }> = [];
|
|
388
|
+
const plan: Array<{ uid: string; slug: string; name?: string }> = [];
|
|
282
389
|
for (const m of memberships) {
|
|
283
390
|
let slug = m.companyUid;
|
|
391
|
+
let name: string | undefined;
|
|
284
392
|
try {
|
|
285
393
|
const info = await client.entity.get(m.companyUid);
|
|
286
394
|
slug = info.slug || m.companyUid;
|
|
395
|
+
name = info.name;
|
|
287
396
|
} catch {
|
|
288
397
|
// Best-effort — keep UID as the display identifier.
|
|
289
398
|
}
|
|
290
|
-
plan.push({ uid: m.companyUid, slug });
|
|
399
|
+
plan.push({ uid: m.companyUid, slug, ...(name ? { name } : {}) });
|
|
291
400
|
}
|
|
292
401
|
emit({ type: "fanout-plan", companies: plan });
|
|
293
402
|
|
package/src/cli/accept.ts
CHANGED
|
@@ -40,8 +40,8 @@ export function parseToken(tokenOrLink: string): string {
|
|
|
40
40
|
return trimmed.slice("hq://accept/".length);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
// https://
|
|
44
|
-
const httpsPrefix = "https://
|
|
43
|
+
// https://example.com/accept/<token> (future web route)
|
|
44
|
+
const httpsPrefix = "https://example.com/accept/";
|
|
45
45
|
if (trimmed.startsWith(httpsPrefix)) {
|
|
46
46
|
return trimmed.slice(httpsPrefix.length);
|
|
47
47
|
}
|
package/src/cli/share.test.ts
CHANGED
|
@@ -19,7 +19,7 @@ vi.mock("../s3.js", () => ({
|
|
|
19
19
|
}));
|
|
20
20
|
|
|
21
21
|
import { share } from "./share.js";
|
|
22
|
-
import { headRemoteFile } from "../s3.js";
|
|
22
|
+
import { headRemoteFile, uploadFile } from "../s3.js";
|
|
23
23
|
|
|
24
24
|
const mockConfig: VaultServiceConfig = {
|
|
25
25
|
apiUrl: "https://vault-api.test",
|
|
@@ -82,8 +82,10 @@ describe("share", () => {
|
|
|
82
82
|
delete process.env.HQ_STATE_DIR;
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
-
it("shares a single file", async () => {
|
|
86
|
-
const
|
|
85
|
+
it("shares a single file keyed relative to the company root", async () => {
|
|
86
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
87
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
88
|
+
const testFile = path.join(companyRoot, "test.md");
|
|
87
89
|
fs.writeFileSync(testFile, "# Hello World");
|
|
88
90
|
|
|
89
91
|
const result = await share({
|
|
@@ -95,15 +97,18 @@ describe("share", () => {
|
|
|
95
97
|
|
|
96
98
|
expect(result.filesUploaded).toBe(1);
|
|
97
99
|
expect(result.aborted).toBe(false);
|
|
100
|
+
// Remote key must be company-relative, not hqRoot-relative
|
|
101
|
+
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), testFile, "test.md");
|
|
98
102
|
});
|
|
99
103
|
|
|
100
104
|
it("respects ignore rules", async () => {
|
|
101
|
-
|
|
102
|
-
fs.
|
|
103
|
-
fs.writeFileSync(path.join(
|
|
105
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
106
|
+
fs.mkdirSync(path.join(companyRoot, ".git"), { recursive: true });
|
|
107
|
+
fs.writeFileSync(path.join(companyRoot, ".git", "config"), "git config");
|
|
108
|
+
fs.writeFileSync(path.join(companyRoot, "readme.md"), "readme");
|
|
104
109
|
|
|
105
110
|
const result = await share({
|
|
106
|
-
paths: [
|
|
111
|
+
paths: [companyRoot],
|
|
107
112
|
company: "acme",
|
|
108
113
|
vaultConfig: mockConfig,
|
|
109
114
|
hqRoot: tmpDir,
|
|
@@ -113,12 +118,13 @@ describe("share", () => {
|
|
|
113
118
|
});
|
|
114
119
|
|
|
115
120
|
it("shares a directory of files", async () => {
|
|
116
|
-
|
|
117
|
-
fs.
|
|
118
|
-
fs.writeFileSync(path.join(
|
|
121
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
122
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
123
|
+
fs.writeFileSync(path.join(companyRoot, "docs", "a.md"), "doc a");
|
|
124
|
+
fs.writeFileSync(path.join(companyRoot, "docs", "b.md"), "doc b");
|
|
119
125
|
|
|
120
126
|
const result = await share({
|
|
121
|
-
paths: [path.join(
|
|
127
|
+
paths: [path.join(companyRoot, "docs")],
|
|
122
128
|
company: "acme",
|
|
123
129
|
vaultConfig: mockConfig,
|
|
124
130
|
hqRoot: tmpDir,
|
|
@@ -127,6 +133,44 @@ describe("share", () => {
|
|
|
127
133
|
expect(result.filesUploaded).toBe(2);
|
|
128
134
|
});
|
|
129
135
|
|
|
136
|
+
it("keys nested paths relative to the company root, not hqRoot", async () => {
|
|
137
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
138
|
+
fs.mkdirSync(path.join(companyRoot, "knowledge"), { recursive: true });
|
|
139
|
+
const nested = path.join(companyRoot, "knowledge", "crawl.json");
|
|
140
|
+
fs.writeFileSync(nested, "{}");
|
|
141
|
+
|
|
142
|
+
await share({
|
|
143
|
+
paths: [nested],
|
|
144
|
+
company: "acme",
|
|
145
|
+
vaultConfig: mockConfig,
|
|
146
|
+
hqRoot: tmpDir,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Key is "knowledge/crawl.json", not "companies/acme/knowledge/crawl.json"
|
|
150
|
+
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), nested, "knowledge/crawl.json");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("skips files outside the company folder with a warning", async () => {
|
|
154
|
+
const warnSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
155
|
+
// File at hqRoot, outside companies/acme/
|
|
156
|
+
const outsideFile = path.join(tmpDir, "stray.md");
|
|
157
|
+
fs.writeFileSync(outsideFile, "stray");
|
|
158
|
+
|
|
159
|
+
const result = await share({
|
|
160
|
+
paths: [outsideFile],
|
|
161
|
+
company: "acme",
|
|
162
|
+
vaultConfig: mockConfig,
|
|
163
|
+
hqRoot: tmpDir,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(result.filesUploaded).toBe(0);
|
|
167
|
+
expect(uploadFile).not.toHaveBeenCalled();
|
|
168
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
169
|
+
expect.stringMatching(/outside company folder/i),
|
|
170
|
+
);
|
|
171
|
+
warnSpy.mockRestore();
|
|
172
|
+
});
|
|
173
|
+
|
|
130
174
|
it("throws when no company specified and no active company", async () => {
|
|
131
175
|
fs.writeFileSync(path.join(tmpDir, "test.md"), "test");
|
|
132
176
|
|
|
@@ -140,12 +184,14 @@ describe("share", () => {
|
|
|
140
184
|
});
|
|
141
185
|
|
|
142
186
|
it("resolves active company from .hq/config.json", async () => {
|
|
187
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
188
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
143
189
|
fs.mkdirSync(path.join(tmpDir, ".hq"), { recursive: true });
|
|
144
190
|
fs.writeFileSync(path.join(tmpDir, ".hq", "config.json"), JSON.stringify({ activeCompany: "acme" }));
|
|
145
|
-
fs.writeFileSync(path.join(
|
|
191
|
+
fs.writeFileSync(path.join(companyRoot, "test.md"), "test");
|
|
146
192
|
|
|
147
193
|
const result = await share({
|
|
148
|
-
paths: [path.join(
|
|
194
|
+
paths: [path.join(companyRoot, "test.md")],
|
|
149
195
|
vaultConfig: mockConfig,
|
|
150
196
|
hqRoot: tmpDir,
|
|
151
197
|
});
|
package/src/cli/share.ts
CHANGED
|
@@ -54,6 +54,10 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
54
54
|
|
|
55
55
|
// Resolve entity context (handles STS vending + caching)
|
|
56
56
|
let ctx = await resolveEntityContext(companyRef, vaultConfig);
|
|
57
|
+
// Remote keys are company-relative; the on-disk scoping prefix is
|
|
58
|
+
// companies/{slug}/. Anything outside this folder gets skipped to avoid
|
|
59
|
+
// leaking cross-company state into the vault.
|
|
60
|
+
const syncRoot = path.join(hqRoot, "companies", ctx.slug);
|
|
57
61
|
const shouldSync = createIgnoreFilter(hqRoot);
|
|
58
62
|
const journal = readJournal(ctx.slug);
|
|
59
63
|
|
|
@@ -62,7 +66,7 @@ export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
|
62
66
|
let filesSkipped = 0;
|
|
63
67
|
|
|
64
68
|
// Collect all files to share
|
|
65
|
-
const filesToShare = collectFiles(paths, hqRoot, shouldSync);
|
|
69
|
+
const filesToShare = collectFiles(paths, hqRoot, syncRoot, shouldSync);
|
|
66
70
|
|
|
67
71
|
for (const { absolutePath, relativePath } of filesToShare) {
|
|
68
72
|
if (!isWithinSizeLimit(absolutePath)) {
|
|
@@ -155,10 +159,15 @@ function resolveActiveCompany(hqRoot: string): string | undefined {
|
|
|
155
159
|
|
|
156
160
|
/**
|
|
157
161
|
* Collect files from paths (expanding directories recursively).
|
|
162
|
+
*
|
|
163
|
+
* Remote S3 keys are computed relative to `syncRoot` (companies/{slug}/), not
|
|
164
|
+
* `hqRoot`. Files outside `syncRoot` are skipped with a warning — sharing
|
|
165
|
+
* anything outside a company's folder would leak state into the wrong vault.
|
|
158
166
|
*/
|
|
159
167
|
function collectFiles(
|
|
160
168
|
paths: string[],
|
|
161
169
|
hqRoot: string,
|
|
170
|
+
syncRoot: string,
|
|
162
171
|
filter: (p: string) => boolean,
|
|
163
172
|
): { absolutePath: string; relativePath: string }[] {
|
|
164
173
|
const results: { absolutePath: string; relativePath: string }[] = [];
|
|
@@ -171,11 +180,16 @@ function collectFiles(
|
|
|
171
180
|
continue;
|
|
172
181
|
}
|
|
173
182
|
|
|
183
|
+
if (!isWithin(syncRoot, absolutePath)) {
|
|
184
|
+
console.error(` Warning: ${p} is outside company folder, skipping.`);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
174
188
|
const stat = fs.statSync(absolutePath);
|
|
175
189
|
if (stat.isDirectory()) {
|
|
176
|
-
results.push(...walkDir(absolutePath,
|
|
190
|
+
results.push(...walkDir(absolutePath, syncRoot, filter));
|
|
177
191
|
} else if (stat.isFile()) {
|
|
178
|
-
const relativePath = path.relative(
|
|
192
|
+
const relativePath = path.relative(syncRoot, absolutePath);
|
|
179
193
|
if (filter(absolutePath)) {
|
|
180
194
|
results.push({ absolutePath, relativePath });
|
|
181
195
|
}
|
|
@@ -187,7 +201,7 @@ function collectFiles(
|
|
|
187
201
|
|
|
188
202
|
function walkDir(
|
|
189
203
|
dir: string,
|
|
190
|
-
|
|
204
|
+
syncRoot: string,
|
|
191
205
|
filter: (p: string) => boolean,
|
|
192
206
|
): { absolutePath: string; relativePath: string }[] {
|
|
193
207
|
const results: { absolutePath: string; relativePath: string }[] = [];
|
|
@@ -199,14 +213,19 @@ function walkDir(
|
|
|
199
213
|
if (!filter(absolutePath)) continue;
|
|
200
214
|
|
|
201
215
|
if (entry.isDirectory()) {
|
|
202
|
-
results.push(...walkDir(absolutePath,
|
|
216
|
+
results.push(...walkDir(absolutePath, syncRoot, filter));
|
|
203
217
|
} else if (entry.isFile()) {
|
|
204
218
|
results.push({
|
|
205
219
|
absolutePath,
|
|
206
|
-
relativePath: path.relative(
|
|
220
|
+
relativePath: path.relative(syncRoot, absolutePath),
|
|
207
221
|
});
|
|
208
222
|
}
|
|
209
223
|
}
|
|
210
224
|
|
|
211
225
|
return results;
|
|
212
226
|
}
|
|
227
|
+
|
|
228
|
+
function isWithin(parent: string, child: string): boolean {
|
|
229
|
+
const rel = path.relative(parent, child);
|
|
230
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
231
|
+
}
|