@indigoai-us/hq-cloud 5.4.4 → 5.4.6
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/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +32 -8
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +110 -7
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +36 -14
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +45 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +3 -0
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +8 -0
- package/dist/ignore.test.js.map +1 -1
- package/dist/journal.d.ts +7 -1
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +16 -2
- package/dist/journal.js.map +1 -1
- package/dist/s3.d.ts +3 -1
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +2 -1
- package/dist/s3.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/watcher.js +2 -2
- package/dist/watcher.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/share.test.ts +132 -7
- package/src/cli/share.ts +36 -8
- package/src/cli/sync.test.ts +54 -0
- package/src/cli/sync.ts +39 -14
- package/src/ignore.test.ts +9 -0
- package/src/ignore.ts +3 -0
- package/src/journal.ts +16 -1
- package/src/s3.ts +4 -2
- package/src/types.ts +8 -0
- package/src/watcher.ts +2 -2
package/src/cli/sync.ts
CHANGED
|
@@ -10,7 +10,7 @@ import * as path from "path";
|
|
|
10
10
|
import type { VaultServiceConfig } from "../types.js";
|
|
11
11
|
import { resolveEntityContext, isExpiringSoon, refreshEntityContext } from "../context.js";
|
|
12
12
|
import { downloadFile, listRemoteFiles } from "../s3.js";
|
|
13
|
-
import { readJournal, writeJournal, hashFile, updateEntry, getEntry } from "../journal.js";
|
|
13
|
+
import { readJournal, writeJournal, hashFile, updateEntry, getEntry, normalizeEtag } from "../journal.js";
|
|
14
14
|
import { createIgnoreFilter } from "../ignore.js";
|
|
15
15
|
import { resolveConflict } from "./conflict.js";
|
|
16
16
|
import type { ConflictStrategy, ConflictResolution } from "./conflict.js";
|
|
@@ -147,9 +147,14 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
147
147
|
|
|
148
148
|
if (fs.existsSync(localPath)) {
|
|
149
149
|
const localHash = hashFile(localPath);
|
|
150
|
+
const localChanged = !!journalEntry && journalEntry.hash !== localHash;
|
|
151
|
+
const remoteChanged = !!journalEntry && hasRemoteChanged(remoteFile, journalEntry);
|
|
150
152
|
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
+
// A real conflict requires BOTH sides to have moved since the last
|
|
154
|
+
// sync. If only local changed, push will handle it; pulling here would
|
|
155
|
+
// clobber the local edit. If only remote changed, fall through to
|
|
156
|
+
// download. If neither moved, skip.
|
|
157
|
+
if (localChanged && remoteChanged) {
|
|
153
158
|
conflicts++;
|
|
154
159
|
conflictPaths.push(remoteFile.key);
|
|
155
160
|
|
|
@@ -187,17 +192,18 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
187
192
|
continue;
|
|
188
193
|
}
|
|
189
194
|
// "overwrite" falls through to download
|
|
190
|
-
} else if (journalEntry &&
|
|
191
|
-
// Local
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
195
|
+
} else if (journalEntry && localChanged && !remoteChanged) {
|
|
196
|
+
// Local-only edit: leave it for the push phase to upload. Pulling
|
|
197
|
+
// would silently overwrite the user's work.
|
|
198
|
+
filesSkipped++;
|
|
199
|
+
continue;
|
|
200
|
+
} else if (journalEntry && !localChanged && !remoteChanged) {
|
|
201
|
+
// Neither side moved — nothing to do.
|
|
202
|
+
filesSkipped++;
|
|
203
|
+
continue;
|
|
200
204
|
}
|
|
205
|
+
// Otherwise (no journal entry, or remote-only changed) fall through
|
|
206
|
+
// to download.
|
|
201
207
|
}
|
|
202
208
|
|
|
203
209
|
// Download
|
|
@@ -206,7 +212,9 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
206
212
|
|
|
207
213
|
const hash = hashFile(localPath);
|
|
208
214
|
const stat = fs.statSync(localPath);
|
|
209
|
-
|
|
215
|
+
// Capture the listing's ETag so subsequent syncs can detect remote
|
|
216
|
+
// drift independently of mtime drift.
|
|
217
|
+
updateEntry(journal, remoteFile.key, hash, stat.size, "down", remoteFile.etag);
|
|
210
218
|
|
|
211
219
|
// Attach message from journal entry if present
|
|
212
220
|
const remoteJournalMessage = (journalEntry as { message?: string } | undefined)?.message;
|
|
@@ -262,6 +270,23 @@ function resolveActiveCompany(hqRoot: string): string | undefined {
|
|
|
262
270
|
return undefined;
|
|
263
271
|
}
|
|
264
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Returns true when the remote object appears to have moved since the
|
|
275
|
+
* journal entry's last-recorded sync. Prefers ETag equality; falls back to
|
|
276
|
+
* `lastModified > syncedAt` for legacy entries written before remoteEtag
|
|
277
|
+
* was tracked. Conservative on tie (`<=` skews "remote unchanged").
|
|
278
|
+
*/
|
|
279
|
+
function hasRemoteChanged(
|
|
280
|
+
remote: { lastModified: Date; etag: string },
|
|
281
|
+
entry: { syncedAt: string; remoteEtag?: string },
|
|
282
|
+
): boolean {
|
|
283
|
+
if (entry.remoteEtag) {
|
|
284
|
+
return normalizeEtag(remote.etag) !== entry.remoteEtag;
|
|
285
|
+
}
|
|
286
|
+
const syncedAt = new Date(entry.syncedAt).getTime();
|
|
287
|
+
return remote.lastModified.getTime() > syncedAt;
|
|
288
|
+
}
|
|
289
|
+
|
|
265
290
|
/**
|
|
266
291
|
* Check if an error is an S3 access denied (expected for filtered guests).
|
|
267
292
|
*/
|
package/src/ignore.test.ts
CHANGED
|
@@ -61,6 +61,15 @@ describe("createIgnoreFilter", () => {
|
|
|
61
61
|
expect(shouldSync(path.join(hqRoot, "company.yaml"))).toBe(false);
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
it("permissive mode: INDEX.md and policies/_digest.md are ignored", () => {
|
|
65
|
+
// Both are auto-generated locally — INDEX.md by the tools indexer and
|
|
66
|
+
// policies/_digest.md by the policies digest builder.
|
|
67
|
+
const shouldSync = createIgnoreFilter(hqRoot);
|
|
68
|
+
expect(shouldSync(path.join(hqRoot, "INDEX.md"))).toBe(false);
|
|
69
|
+
expect(shouldSync(path.join(hqRoot, "companies/ghq/tools/INDEX.md"))).toBe(false);
|
|
70
|
+
expect(shouldSync(path.join(hqRoot, "policies/_digest.md"))).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
64
73
|
it("permissive mode: .hq-* internal state is ignored, .hqignore family + .hq/ still sync", () => {
|
|
65
74
|
const shouldSync = createIgnoreFilter(hqRoot);
|
|
66
75
|
// Internal state files that must never round-trip through the bucket.
|
package/src/ignore.ts
CHANGED
|
@@ -81,6 +81,9 @@ export const DEFAULT_IGNORES = [
|
|
|
81
81
|
"modules/modules.yaml",
|
|
82
82
|
// per-company identity file — written locally on first sync, never round-tripped.
|
|
83
83
|
"company.yaml",
|
|
84
|
+
// auto-generated tool index and policy digest — regenerated locally per-machine.
|
|
85
|
+
"INDEX.md",
|
|
86
|
+
"policies/_digest.md",
|
|
84
87
|
|
|
85
88
|
// HQ repos directory (managed separately, not synced)
|
|
86
89
|
"repos/",
|
package/src/journal.ts
CHANGED
|
@@ -80,16 +80,31 @@ export function updateEntry(
|
|
|
80
80
|
hash: string,
|
|
81
81
|
size: number,
|
|
82
82
|
direction: "up" | "down",
|
|
83
|
+
remoteEtag?: string,
|
|
83
84
|
): void {
|
|
84
|
-
|
|
85
|
+
const entry: JournalEntry = {
|
|
85
86
|
hash,
|
|
86
87
|
size,
|
|
87
88
|
syncedAt: new Date().toISOString(),
|
|
88
89
|
direction,
|
|
89
90
|
};
|
|
91
|
+
if (remoteEtag !== undefined && remoteEtag !== "") {
|
|
92
|
+
entry.remoteEtag = normalizeEtag(remoteEtag);
|
|
93
|
+
}
|
|
94
|
+
journal.files[relativePath] = entry;
|
|
90
95
|
journal.lastSync = new Date().toISOString();
|
|
91
96
|
}
|
|
92
97
|
|
|
98
|
+
/**
|
|
99
|
+
* S3 returns ETags wrapped in literal double-quotes (e.g. `"d41d8cd9..."`).
|
|
100
|
+
* Strip them so equality comparisons across HEAD / GET / PUT responses are
|
|
101
|
+
* stable regardless of which AWS SDK call surfaced the value.
|
|
102
|
+
*/
|
|
103
|
+
export function normalizeEtag(etag: string): string {
|
|
104
|
+
if (!etag) return "";
|
|
105
|
+
return etag.replace(/^"|"$/g, "");
|
|
106
|
+
}
|
|
107
|
+
|
|
93
108
|
export function getEntry(
|
|
94
109
|
journal: SyncJournal,
|
|
95
110
|
relativePath: string,
|
package/src/s3.ts
CHANGED
|
@@ -38,11 +38,11 @@ export async function uploadFile(
|
|
|
38
38
|
ctx: EntityContext,
|
|
39
39
|
localPath: string,
|
|
40
40
|
key: string,
|
|
41
|
-
): Promise<
|
|
41
|
+
): Promise<{ etag: string }> {
|
|
42
42
|
const client = buildClient(ctx);
|
|
43
43
|
const body = fs.readFileSync(localPath);
|
|
44
44
|
|
|
45
|
-
await client.send(
|
|
45
|
+
const response = await client.send(
|
|
46
46
|
new PutObjectCommand({
|
|
47
47
|
Bucket: ctx.bucketName,
|
|
48
48
|
Key: key,
|
|
@@ -50,6 +50,8 @@ export async function uploadFile(
|
|
|
50
50
|
ContentType: getMimeType(key),
|
|
51
51
|
}),
|
|
52
52
|
);
|
|
53
|
+
|
|
54
|
+
return { etag: response.ETag || "" };
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
export async function downloadFile(
|
package/src/types.ts
CHANGED
|
@@ -26,6 +26,14 @@ export interface JournalEntry {
|
|
|
26
26
|
size: number;
|
|
27
27
|
syncedAt: string;
|
|
28
28
|
direction: "up" | "down";
|
|
29
|
+
/**
|
|
30
|
+
* S3 ETag of the remote object as of last successful sync, normalized (no
|
|
31
|
+
* surrounding quotes). Optional for backwards compatibility: entries
|
|
32
|
+
* written before this field existed won't have it, in which case
|
|
33
|
+
* conflict detection falls back to comparing remote `lastModified`
|
|
34
|
+
* against `syncedAt`.
|
|
35
|
+
*/
|
|
36
|
+
remoteEtag?: string;
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
export interface SyncJournal {
|
package/src/watcher.ts
CHANGED
|
@@ -113,8 +113,8 @@ export class SyncWatcher {
|
|
|
113
113
|
const existing = journal.files[relativePath];
|
|
114
114
|
if (existing && existing.hash === hash) continue;
|
|
115
115
|
|
|
116
|
-
await uploadFile(this.ctx, change.absolutePath, relativePath);
|
|
117
|
-
updateEntry(journal, relativePath, hash, stat.size, "up");
|
|
116
|
+
const { etag } = await uploadFile(this.ctx, change.absolutePath, relativePath);
|
|
117
|
+
updateEntry(journal, relativePath, hash, stat.size, "up", etag);
|
|
118
118
|
}
|
|
119
119
|
} catch (err) {
|
|
120
120
|
console.error(
|