@indigoai-us/hq-cloud 5.1.0 → 5.1.9
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 +134 -0
- package/dist/bin/sync-runner.d.ts.map +1 -0
- package/dist/bin/sync-runner.js +360 -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 +648 -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 +59 -0
- package/dist/vault-client.d.ts.map +1 -1
- package/dist/vault-client.js +72 -0
- package/dist/vault-client.js.map +1 -1
- package/dist/vault-client.test.js +160 -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 +804 -0
- package/src/bin/sync-runner.ts +499 -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 +94 -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 +563 -0
- package/src/vault-client.ts +478 -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,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for hq share command (VLT-5 US-002).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
6
|
+
import * as fs from "fs";
|
|
7
|
+
import * as path from "path";
|
|
8
|
+
import * as os from "os";
|
|
9
|
+
import { clearContextCache } from "../context.js";
|
|
10
|
+
import type { VaultServiceConfig } from "../types.js";
|
|
11
|
+
|
|
12
|
+
// Mock s3 module at the top level
|
|
13
|
+
vi.mock("../s3.js", () => ({
|
|
14
|
+
uploadFile: vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
downloadFile: vi.fn().mockResolvedValue(undefined),
|
|
16
|
+
listRemoteFiles: vi.fn().mockResolvedValue([]),
|
|
17
|
+
deleteRemoteFile: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
headRemoteFile: vi.fn().mockResolvedValue(null),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
import { share } from "./share.js";
|
|
22
|
+
import { headRemoteFile } from "../s3.js";
|
|
23
|
+
|
|
24
|
+
const mockConfig: VaultServiceConfig = {
|
|
25
|
+
apiUrl: "https://vault-api.test",
|
|
26
|
+
authToken: "test-jwt-token",
|
|
27
|
+
region: "us-east-1",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const mockEntity = {
|
|
31
|
+
uid: "cmp_01ABCDEF",
|
|
32
|
+
slug: "acme",
|
|
33
|
+
bucketName: "hq-vault-acme-123",
|
|
34
|
+
status: "active",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const mockVendResponse = {
|
|
38
|
+
credentials: {
|
|
39
|
+
accessKeyId: "ASIA_TEST_KEY",
|
|
40
|
+
secretAccessKey: "test-secret",
|
|
41
|
+
sessionToken: "test-session-token",
|
|
42
|
+
expiration: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
|
43
|
+
},
|
|
44
|
+
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function setupFetchMock() {
|
|
48
|
+
const fetchMock = vi.fn().mockImplementation(async (url: string) => {
|
|
49
|
+
const urlStr = String(url);
|
|
50
|
+
if (urlStr.includes("/entity/by-slug/")) {
|
|
51
|
+
return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
|
|
52
|
+
}
|
|
53
|
+
if (urlStr.includes("/sts/vend")) {
|
|
54
|
+
return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
|
|
55
|
+
}
|
|
56
|
+
return { ok: false, status: 404, text: async () => "Not found" };
|
|
57
|
+
});
|
|
58
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
59
|
+
return fetchMock;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("share", () => {
|
|
63
|
+
let tmpDir: string;
|
|
64
|
+
let stateDir: string;
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
clearContextCache();
|
|
68
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-share-test-"));
|
|
69
|
+
// Redirect per-company journal into tmp so share() doesn't write to the
|
|
70
|
+
// real ~/.hq during tests (ADR-0001 Phase 5).
|
|
71
|
+
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-state-test-"));
|
|
72
|
+
process.env.HQ_STATE_DIR = stateDir;
|
|
73
|
+
setupFetchMock();
|
|
74
|
+
vi.mocked(headRemoteFile).mockResolvedValue(null);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
vi.unstubAllGlobals();
|
|
79
|
+
vi.clearAllMocks();
|
|
80
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
81
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
82
|
+
delete process.env.HQ_STATE_DIR;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("shares a single file", async () => {
|
|
86
|
+
const testFile = path.join(tmpDir, "test.md");
|
|
87
|
+
fs.writeFileSync(testFile, "# Hello World");
|
|
88
|
+
|
|
89
|
+
const result = await share({
|
|
90
|
+
paths: [testFile],
|
|
91
|
+
company: "acme",
|
|
92
|
+
vaultConfig: mockConfig,
|
|
93
|
+
hqRoot: tmpDir,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(result.filesUploaded).toBe(1);
|
|
97
|
+
expect(result.aborted).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("respects ignore rules", async () => {
|
|
101
|
+
fs.mkdirSync(path.join(tmpDir, ".git"));
|
|
102
|
+
fs.writeFileSync(path.join(tmpDir, ".git", "config"), "git config");
|
|
103
|
+
fs.writeFileSync(path.join(tmpDir, "readme.md"), "readme");
|
|
104
|
+
|
|
105
|
+
const result = await share({
|
|
106
|
+
paths: [tmpDir],
|
|
107
|
+
company: "acme",
|
|
108
|
+
vaultConfig: mockConfig,
|
|
109
|
+
hqRoot: tmpDir,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(result.filesUploaded).toBe(1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("shares a directory of files", async () => {
|
|
116
|
+
fs.mkdirSync(path.join(tmpDir, "docs"));
|
|
117
|
+
fs.writeFileSync(path.join(tmpDir, "docs", "a.md"), "doc a");
|
|
118
|
+
fs.writeFileSync(path.join(tmpDir, "docs", "b.md"), "doc b");
|
|
119
|
+
|
|
120
|
+
const result = await share({
|
|
121
|
+
paths: [path.join(tmpDir, "docs")],
|
|
122
|
+
company: "acme",
|
|
123
|
+
vaultConfig: mockConfig,
|
|
124
|
+
hqRoot: tmpDir,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
expect(result.filesUploaded).toBe(2);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("throws when no company specified and no active company", async () => {
|
|
131
|
+
fs.writeFileSync(path.join(tmpDir, "test.md"), "test");
|
|
132
|
+
|
|
133
|
+
await expect(
|
|
134
|
+
share({
|
|
135
|
+
paths: [path.join(tmpDir, "test.md")],
|
|
136
|
+
vaultConfig: mockConfig,
|
|
137
|
+
hqRoot: tmpDir,
|
|
138
|
+
}),
|
|
139
|
+
).rejects.toThrow(/No company specified/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("resolves active company from .hq/config.json", async () => {
|
|
143
|
+
fs.mkdirSync(path.join(tmpDir, ".hq"), { recursive: true });
|
|
144
|
+
fs.writeFileSync(path.join(tmpDir, ".hq", "config.json"), JSON.stringify({ activeCompany: "acme" }));
|
|
145
|
+
fs.writeFileSync(path.join(tmpDir, "test.md"), "test");
|
|
146
|
+
|
|
147
|
+
const result = await share({
|
|
148
|
+
paths: [path.join(tmpDir, "test.md")],
|
|
149
|
+
vaultConfig: mockConfig,
|
|
150
|
+
hqRoot: tmpDir,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(result.filesUploaded).toBe(1);
|
|
154
|
+
});
|
|
155
|
+
});
|
package/src/cli/share.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq share` command — selective push to entity vault (VLT-5 US-002).
|
|
3
|
+
*
|
|
4
|
+
* Broadcasts local file(s) to the company's S3 vault bucket.
|
|
5
|
+
* Refuses to overwrite a newer remote version without prompting.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from "fs";
|
|
9
|
+
import * as path from "path";
|
|
10
|
+
import type { VaultServiceConfig } from "../types.js";
|
|
11
|
+
import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
|
|
12
|
+
import { uploadFile, headRemoteFile } from "../s3.js";
|
|
13
|
+
import { readJournal, writeJournal, hashFile, updateEntry } from "../journal.js";
|
|
14
|
+
import { createIgnoreFilter, isWithinSizeLimit } from "../ignore.js";
|
|
15
|
+
import { resolveConflict } from "./conflict.js";
|
|
16
|
+
import type { ConflictStrategy } from "./conflict.js";
|
|
17
|
+
|
|
18
|
+
export interface ShareOptions {
|
|
19
|
+
/** Path(s) to share (files or directories) */
|
|
20
|
+
paths: string[];
|
|
21
|
+
/** Company slug or UID (defaults to active company from config) */
|
|
22
|
+
company?: string;
|
|
23
|
+
/** Optional message attached to journal entries */
|
|
24
|
+
message?: string;
|
|
25
|
+
/** Non-interactive conflict strategy */
|
|
26
|
+
onConflict?: ConflictStrategy;
|
|
27
|
+
/** Vault service config */
|
|
28
|
+
vaultConfig: VaultServiceConfig;
|
|
29
|
+
/** HQ root directory */
|
|
30
|
+
hqRoot: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ShareResult {
|
|
34
|
+
filesUploaded: number;
|
|
35
|
+
bytesUploaded: number;
|
|
36
|
+
filesSkipped: number;
|
|
37
|
+
aborted: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Share local file(s) to the entity vault.
|
|
42
|
+
*/
|
|
43
|
+
export async function share(options: ShareOptions): Promise<ShareResult> {
|
|
44
|
+
const { paths, company, message, onConflict, vaultConfig, hqRoot } = options;
|
|
45
|
+
|
|
46
|
+
// Resolve company — slug, UID, or from active config
|
|
47
|
+
const companyRef = company ?? resolveActiveCompany(hqRoot);
|
|
48
|
+
if (!companyRef) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
"No company specified and no active company found. " +
|
|
51
|
+
"Use --company <slug> or set up .hq/config.json.",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Resolve entity context (handles STS vending + caching)
|
|
56
|
+
let ctx = await resolveEntityContext(companyRef, vaultConfig);
|
|
57
|
+
const shouldSync = createIgnoreFilter(hqRoot);
|
|
58
|
+
const journal = readJournal(ctx.slug);
|
|
59
|
+
|
|
60
|
+
let filesUploaded = 0;
|
|
61
|
+
let bytesUploaded = 0;
|
|
62
|
+
let filesSkipped = 0;
|
|
63
|
+
|
|
64
|
+
// Collect all files to share
|
|
65
|
+
const filesToShare = collectFiles(paths, hqRoot, shouldSync);
|
|
66
|
+
|
|
67
|
+
for (const { absolutePath, relativePath } of filesToShare) {
|
|
68
|
+
if (!isWithinSizeLimit(absolutePath)) {
|
|
69
|
+
console.error(` Skipped (too large): ${relativePath}`);
|
|
70
|
+
filesSkipped++;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Auto-refresh context if credentials expiring
|
|
75
|
+
if (isExpiringSoon(ctx.expiresAt)) {
|
|
76
|
+
ctx = await refreshEntityContext(companyRef, vaultConfig);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Check for remote conflict — refuse to overwrite newer remote version
|
|
80
|
+
const remoteMeta = await headRemoteFile(ctx, relativePath);
|
|
81
|
+
if (remoteMeta) {
|
|
82
|
+
const journalEntry = journal.files[relativePath];
|
|
83
|
+
const localHash = hashFile(absolutePath);
|
|
84
|
+
|
|
85
|
+
// If remote has changed since our last sync, it's a conflict
|
|
86
|
+
if (journalEntry && journalEntry.hash !== localHash) {
|
|
87
|
+
// Local has changes — check if remote also changed
|
|
88
|
+
const resolution = await resolveConflict(
|
|
89
|
+
{
|
|
90
|
+
path: relativePath,
|
|
91
|
+
localHash,
|
|
92
|
+
remoteModified: remoteMeta.lastModified,
|
|
93
|
+
direction: "push",
|
|
94
|
+
},
|
|
95
|
+
onConflict,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
if (resolution === "abort") {
|
|
99
|
+
return { filesUploaded, bytesUploaded, filesSkipped, aborted: true };
|
|
100
|
+
}
|
|
101
|
+
if (resolution === "keep" || resolution === "skip") {
|
|
102
|
+
filesSkipped++;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// "overwrite" falls through to upload
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Upload
|
|
110
|
+
try {
|
|
111
|
+
const stat = fs.statSync(absolutePath);
|
|
112
|
+
const hash = hashFile(absolutePath);
|
|
113
|
+
|
|
114
|
+
await uploadFile(ctx, absolutePath, relativePath);
|
|
115
|
+
|
|
116
|
+
// Update journal with optional message
|
|
117
|
+
updateEntry(journal, relativePath, hash, stat.size, "up");
|
|
118
|
+
if (message) {
|
|
119
|
+
journal.files[relativePath] = {
|
|
120
|
+
...journal.files[relativePath],
|
|
121
|
+
message,
|
|
122
|
+
} as typeof journal.files[string] & { message: string };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
filesUploaded++;
|
|
126
|
+
bytesUploaded += stat.size;
|
|
127
|
+
console.log(` ✓ ${relativePath}`);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
console.error(
|
|
130
|
+
` ✗ ${relativePath} — ${err instanceof Error ? err.message : err}`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
writeJournal(ctx.slug, journal);
|
|
136
|
+
|
|
137
|
+
return { filesUploaded, bytesUploaded, filesSkipped, aborted: false };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Resolve active company from .hq/config.json or parent directory chain.
|
|
142
|
+
*/
|
|
143
|
+
function resolveActiveCompany(hqRoot: string): string | undefined {
|
|
144
|
+
const configPath = path.join(hqRoot, ".hq", "config.json");
|
|
145
|
+
if (fs.existsSync(configPath)) {
|
|
146
|
+
try {
|
|
147
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
148
|
+
return config.activeCompany ?? config.companySlug;
|
|
149
|
+
} catch {
|
|
150
|
+
// Ignore parse errors
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Collect files from paths (expanding directories recursively).
|
|
158
|
+
*/
|
|
159
|
+
function collectFiles(
|
|
160
|
+
paths: string[],
|
|
161
|
+
hqRoot: string,
|
|
162
|
+
filter: (p: string) => boolean,
|
|
163
|
+
): { absolutePath: string; relativePath: string }[] {
|
|
164
|
+
const results: { absolutePath: string; relativePath: string }[] = [];
|
|
165
|
+
|
|
166
|
+
for (const p of paths) {
|
|
167
|
+
const absolutePath = path.isAbsolute(p) ? p : path.resolve(hqRoot, p);
|
|
168
|
+
|
|
169
|
+
if (!fs.existsSync(absolutePath)) {
|
|
170
|
+
console.error(` Warning: ${p} does not exist, skipping.`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const stat = fs.statSync(absolutePath);
|
|
175
|
+
if (stat.isDirectory()) {
|
|
176
|
+
results.push(...walkDir(absolutePath, hqRoot, filter));
|
|
177
|
+
} else if (stat.isFile()) {
|
|
178
|
+
const relativePath = path.relative(hqRoot, absolutePath);
|
|
179
|
+
if (filter(absolutePath)) {
|
|
180
|
+
results.push({ absolutePath, relativePath });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return results;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function walkDir(
|
|
189
|
+
dir: string,
|
|
190
|
+
root: string,
|
|
191
|
+
filter: (p: string) => boolean,
|
|
192
|
+
): { absolutePath: string; relativePath: string }[] {
|
|
193
|
+
const results: { absolutePath: string; relativePath: string }[] = [];
|
|
194
|
+
if (!fs.existsSync(dir)) return results;
|
|
195
|
+
|
|
196
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
197
|
+
for (const entry of entries) {
|
|
198
|
+
const absolutePath = path.join(dir, entry.name);
|
|
199
|
+
if (!filter(absolutePath)) continue;
|
|
200
|
+
|
|
201
|
+
if (entry.isDirectory()) {
|
|
202
|
+
results.push(...walkDir(absolutePath, root, filter));
|
|
203
|
+
} else if (entry.isFile()) {
|
|
204
|
+
results.push({
|
|
205
|
+
absolutePath,
|
|
206
|
+
relativePath: path.relative(root, absolutePath),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return results;
|
|
212
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for hq sync command (VLT-5 US-002).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
6
|
+
import * as fs from "fs";
|
|
7
|
+
import * as path from "path";
|
|
8
|
+
import * as os from "os";
|
|
9
|
+
import { clearContextCache } from "../context.js";
|
|
10
|
+
import type { VaultServiceConfig } from "../types.js";
|
|
11
|
+
|
|
12
|
+
// Mock s3 module at the top level
|
|
13
|
+
vi.mock("../s3.js", async () => {
|
|
14
|
+
const { vi: innerVi } = await import("vitest");
|
|
15
|
+
const innerFs = await import("fs");
|
|
16
|
+
const innerPath = await import("path");
|
|
17
|
+
|
|
18
|
+
const remoteFiles = [
|
|
19
|
+
{ key: "docs/handoff.md", size: 42, lastModified: new Date(), etag: '"abc123"' },
|
|
20
|
+
{ key: "knowledge/readme.md", size: 100, lastModified: new Date(), etag: '"def456"' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
uploadFile: innerVi.fn().mockResolvedValue(undefined),
|
|
25
|
+
downloadFile: innerVi.fn().mockImplementation(async (_ctx: unknown, _key: string, localPath: string) => {
|
|
26
|
+
const dir = innerPath.dirname(localPath);
|
|
27
|
+
if (!innerFs.existsSync(dir)) innerFs.mkdirSync(dir, { recursive: true });
|
|
28
|
+
innerFs.writeFileSync(localPath, "mock file content");
|
|
29
|
+
}),
|
|
30
|
+
listRemoteFiles: innerVi.fn().mockResolvedValue(remoteFiles),
|
|
31
|
+
deleteRemoteFile: innerVi.fn().mockResolvedValue(undefined),
|
|
32
|
+
headRemoteFile: innerVi.fn().mockResolvedValue(null),
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
import { sync } from "./sync.js";
|
|
37
|
+
|
|
38
|
+
const mockConfig: VaultServiceConfig = {
|
|
39
|
+
apiUrl: "https://vault-api.test",
|
|
40
|
+
authToken: "test-jwt-token",
|
|
41
|
+
region: "us-east-1",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const mockEntity = {
|
|
45
|
+
uid: "cmp_01ABCDEF",
|
|
46
|
+
slug: "acme",
|
|
47
|
+
bucketName: "hq-vault-acme-123",
|
|
48
|
+
status: "active",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const mockVendResponse = {
|
|
52
|
+
credentials: {
|
|
53
|
+
accessKeyId: "ASIA_TEST_KEY",
|
|
54
|
+
secretAccessKey: "test-secret",
|
|
55
|
+
sessionToken: "test-session-token",
|
|
56
|
+
expiration: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
|
57
|
+
},
|
|
58
|
+
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function setupFetchMock() {
|
|
62
|
+
const fetchMock = vi.fn().mockImplementation(async (url: string) => {
|
|
63
|
+
const urlStr = String(url);
|
|
64
|
+
if (urlStr.includes("/entity/by-slug/")) {
|
|
65
|
+
return { ok: true, status: 200, json: async () => ({ entity: mockEntity }), text: async () => "" };
|
|
66
|
+
}
|
|
67
|
+
if (urlStr.includes("/sts/vend")) {
|
|
68
|
+
return { ok: true, status: 200, json: async () => mockVendResponse, text: async () => "" };
|
|
69
|
+
}
|
|
70
|
+
return { ok: false, status: 404, text: async () => "Not found" };
|
|
71
|
+
});
|
|
72
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
73
|
+
return fetchMock;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("sync", () => {
|
|
77
|
+
let tmpDir: string;
|
|
78
|
+
let stateDir: string;
|
|
79
|
+
let journalPath: string;
|
|
80
|
+
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
clearContextCache();
|
|
83
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sync-test-"));
|
|
84
|
+
// Journal moved to ~/.hq/sync-journal.{slug}.json (ADR-0001 Phase 5).
|
|
85
|
+
// Redirect to a tmp dir via HQ_STATE_DIR so the test doesn't pollute the
|
|
86
|
+
// user's real ~/.hq. mockEntity.slug is "acme".
|
|
87
|
+
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-state-test-"));
|
|
88
|
+
process.env.HQ_STATE_DIR = stateDir;
|
|
89
|
+
journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
90
|
+
setupFetchMock();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
vi.unstubAllGlobals();
|
|
95
|
+
vi.clearAllMocks();
|
|
96
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
97
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
98
|
+
delete process.env.HQ_STATE_DIR;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("downloads remote files that don't exist locally", async () => {
|
|
102
|
+
const result = await sync({
|
|
103
|
+
company: "acme",
|
|
104
|
+
vaultConfig: mockConfig,
|
|
105
|
+
hqRoot: tmpDir,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(result.filesDownloaded).toBe(2);
|
|
109
|
+
expect(result.aborted).toBe(false);
|
|
110
|
+
expect(fs.existsSync(path.join(tmpDir, "docs", "handoff.md"))).toBe(true);
|
|
111
|
+
expect(fs.existsSync(path.join(tmpDir, "knowledge", "readme.md"))).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("throws when no company specified and no active company", async () => {
|
|
115
|
+
await expect(
|
|
116
|
+
sync({ vaultConfig: mockConfig, hqRoot: tmpDir }),
|
|
117
|
+
).rejects.toThrow(/No company specified/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("uses active company from .hq/config.json", async () => {
|
|
121
|
+
fs.mkdirSync(path.join(tmpDir, ".hq"), { recursive: true });
|
|
122
|
+
fs.writeFileSync(
|
|
123
|
+
path.join(tmpDir, ".hq", "config.json"),
|
|
124
|
+
JSON.stringify({ activeCompany: "acme" }),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const result = await sync({ vaultConfig: mockConfig, hqRoot: tmpDir });
|
|
128
|
+
expect(result.filesDownloaded).toBe(2);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("detects conflicts with local changes and keeps local on --on-conflict keep", async () => {
|
|
132
|
+
fs.mkdirSync(path.join(tmpDir, "docs"), { recursive: true });
|
|
133
|
+
fs.writeFileSync(path.join(tmpDir, "docs", "handoff.md"), "local version");
|
|
134
|
+
|
|
135
|
+
fs.writeFileSync(
|
|
136
|
+
journalPath,
|
|
137
|
+
JSON.stringify({
|
|
138
|
+
version: "1",
|
|
139
|
+
lastSync: new Date().toISOString(),
|
|
140
|
+
files: {
|
|
141
|
+
"docs/handoff.md": {
|
|
142
|
+
hash: "old-hash-from-last-sync",
|
|
143
|
+
size: 20,
|
|
144
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
145
|
+
direction: "down",
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const result = await sync({
|
|
152
|
+
company: "acme",
|
|
153
|
+
onConflict: "keep",
|
|
154
|
+
vaultConfig: mockConfig,
|
|
155
|
+
hqRoot: tmpDir,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(result.conflicts).toBe(1);
|
|
159
|
+
expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
|
|
160
|
+
expect(fs.readFileSync(path.join(tmpDir, "docs", "handoff.md"), "utf-8")).toBe("local version");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("aborts on --on-conflict abort", async () => {
|
|
164
|
+
fs.mkdirSync(path.join(tmpDir, "docs"), { recursive: true });
|
|
165
|
+
fs.writeFileSync(path.join(tmpDir, "docs", "handoff.md"), "local version");
|
|
166
|
+
|
|
167
|
+
fs.writeFileSync(
|
|
168
|
+
journalPath,
|
|
169
|
+
JSON.stringify({
|
|
170
|
+
version: "1",
|
|
171
|
+
lastSync: new Date().toISOString(),
|
|
172
|
+
files: {
|
|
173
|
+
"docs/handoff.md": {
|
|
174
|
+
hash: "old-hash",
|
|
175
|
+
size: 20,
|
|
176
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
177
|
+
direction: "down",
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const result = await sync({
|
|
184
|
+
company: "acme",
|
|
185
|
+
onConflict: "abort",
|
|
186
|
+
vaultConfig: mockConfig,
|
|
187
|
+
hqRoot: tmpDir,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
expect(result.aborted).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("overwrites local on --on-conflict overwrite", async () => {
|
|
194
|
+
fs.mkdirSync(path.join(tmpDir, "docs"), { recursive: true });
|
|
195
|
+
fs.writeFileSync(path.join(tmpDir, "docs", "handoff.md"), "local version");
|
|
196
|
+
|
|
197
|
+
fs.writeFileSync(
|
|
198
|
+
journalPath,
|
|
199
|
+
JSON.stringify({
|
|
200
|
+
version: "1",
|
|
201
|
+
lastSync: new Date().toISOString(),
|
|
202
|
+
files: {
|
|
203
|
+
"docs/handoff.md": {
|
|
204
|
+
hash: "old-hash",
|
|
205
|
+
size: 20,
|
|
206
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
207
|
+
direction: "down",
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const result = await sync({
|
|
214
|
+
company: "acme",
|
|
215
|
+
onConflict: "overwrite",
|
|
216
|
+
vaultConfig: mockConfig,
|
|
217
|
+
hqRoot: tmpDir,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(result.conflicts).toBe(1);
|
|
221
|
+
expect(result.filesDownloaded).toBeGreaterThanOrEqual(1);
|
|
222
|
+
// File should be overwritten with mock content
|
|
223
|
+
expect(fs.readFileSync(path.join(tmpDir, "docs", "handoff.md"), "utf-8")).toBe("mock file content");
|
|
224
|
+
});
|
|
225
|
+
});
|