@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,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the sync journal (ADR-0001 Phase 5).
|
|
3
|
+
*
|
|
4
|
+
* Verifies per-company isolation, HQ_STATE_DIR override, and filename
|
|
5
|
+
* sanitization — all behaviors that the pre-Phase-5 monolithic journal
|
|
6
|
+
* didn't need.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
10
|
+
import * as fs from "fs";
|
|
11
|
+
import * as os from "os";
|
|
12
|
+
import * as path from "path";
|
|
13
|
+
import {
|
|
14
|
+
getJournalPath,
|
|
15
|
+
getStateDir,
|
|
16
|
+
readJournal,
|
|
17
|
+
writeJournal,
|
|
18
|
+
updateEntry,
|
|
19
|
+
} from "./journal.js";
|
|
20
|
+
import type { SyncJournal } from "./types.js";
|
|
21
|
+
|
|
22
|
+
describe("journal", () => {
|
|
23
|
+
let stateDir: string;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-journal-test-"));
|
|
27
|
+
process.env.HQ_STATE_DIR = stateDir;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
32
|
+
delete process.env.HQ_STATE_DIR;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("getStateDir", () => {
|
|
36
|
+
it("honors HQ_STATE_DIR env var", () => {
|
|
37
|
+
expect(getStateDir()).toBe(stateDir);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("falls back to ~/.hq when env var unset", () => {
|
|
41
|
+
delete process.env.HQ_STATE_DIR;
|
|
42
|
+
expect(getStateDir()).toBe(path.join(os.homedir(), ".hq"));
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("getJournalPath", () => {
|
|
47
|
+
it("produces a per-slug filename", () => {
|
|
48
|
+
expect(getJournalPath("indigo")).toBe(
|
|
49
|
+
path.join(stateDir, "sync-journal.indigo.json"),
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("isolates different slugs into different files", () => {
|
|
54
|
+
expect(getJournalPath("indigo")).not.toBe(getJournalPath("brandstage"));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("sanitizes path-unsafe characters", () => {
|
|
58
|
+
expect(getJournalPath("foo/bar")).toBe(
|
|
59
|
+
path.join(stateDir, "sync-journal.foo_bar.json"),
|
|
60
|
+
);
|
|
61
|
+
expect(getJournalPath("../escape")).toBe(
|
|
62
|
+
path.join(stateDir, "sync-journal.___escape.json"),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("throws on empty slug", () => {
|
|
67
|
+
expect(() => getJournalPath("")).toThrow(/slug is required/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("throws on slug that sanitizes to empty", () => {
|
|
71
|
+
expect(() => getJournalPath("///")).toThrow(/empty identifier/);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("readJournal", () => {
|
|
76
|
+
it("returns an empty journal when the file doesn't exist", () => {
|
|
77
|
+
const j = readJournal("indigo");
|
|
78
|
+
expect(j.version).toBe("1");
|
|
79
|
+
expect(j.files).toEqual({});
|
|
80
|
+
expect(j.lastSync).toBe("");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("reads a journal written with writeJournal", () => {
|
|
84
|
+
const original: SyncJournal = {
|
|
85
|
+
version: "1",
|
|
86
|
+
lastSync: "2026-04-19T00:00:00.000Z",
|
|
87
|
+
files: {
|
|
88
|
+
"docs/handoff.md": {
|
|
89
|
+
hash: "abc123",
|
|
90
|
+
size: 42,
|
|
91
|
+
syncedAt: "2026-04-19T00:00:00.000Z",
|
|
92
|
+
direction: "down",
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
writeJournal("indigo", original);
|
|
97
|
+
const roundTripped = readJournal("indigo");
|
|
98
|
+
expect(roundTripped).toEqual(original);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("writeJournal", () => {
|
|
103
|
+
it("creates the state directory if it doesn't exist", () => {
|
|
104
|
+
const nestedDir = path.join(stateDir, "nested", "deep");
|
|
105
|
+
process.env.HQ_STATE_DIR = nestedDir;
|
|
106
|
+
expect(fs.existsSync(nestedDir)).toBe(false);
|
|
107
|
+
|
|
108
|
+
writeJournal("indigo", { version: "1", lastSync: "", files: {} });
|
|
109
|
+
expect(fs.existsSync(nestedDir)).toBe(true);
|
|
110
|
+
expect(
|
|
111
|
+
fs.existsSync(path.join(nestedDir, "sync-journal.indigo.json")),
|
|
112
|
+
).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("keeps per-company journals independent", () => {
|
|
116
|
+
writeJournal("indigo", {
|
|
117
|
+
version: "1",
|
|
118
|
+
lastSync: "",
|
|
119
|
+
files: { "a.md": { hash: "1", size: 1, syncedAt: "", direction: "up" } },
|
|
120
|
+
});
|
|
121
|
+
writeJournal("brandstage", {
|
|
122
|
+
version: "1",
|
|
123
|
+
lastSync: "",
|
|
124
|
+
files: { "b.md": { hash: "2", size: 2, syncedAt: "", direction: "up" } },
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const indigo = readJournal("indigo");
|
|
128
|
+
const brandstage = readJournal("brandstage");
|
|
129
|
+
expect(indigo.files).toHaveProperty("a.md");
|
|
130
|
+
expect(indigo.files).not.toHaveProperty("b.md");
|
|
131
|
+
expect(brandstage.files).toHaveProperty("b.md");
|
|
132
|
+
expect(brandstage.files).not.toHaveProperty("a.md");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("updateEntry", () => {
|
|
137
|
+
it("stamps lastSync and the per-file syncedAt", () => {
|
|
138
|
+
const j: SyncJournal = { version: "1", lastSync: "", files: {} };
|
|
139
|
+
updateEntry(j, "foo.md", "hash", 10, "up");
|
|
140
|
+
expect(j.files["foo.md"]?.hash).toBe("hash");
|
|
141
|
+
expect(j.files["foo.md"]?.direction).toBe("up");
|
|
142
|
+
expect(j.lastSync).not.toBe("");
|
|
143
|
+
expect(j.files["foo.md"]?.syncedAt).not.toBe("");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
package/src/journal.ts
CHANGED
|
@@ -1,20 +1,61 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Sync journal — tracks file state
|
|
2
|
+
* Sync journal — tracks per-file state (hash, size, last-synced direction) so
|
|
3
|
+
* sync/share can detect local edits that would be clobbered by a blind pull.
|
|
4
|
+
*
|
|
5
|
+
* ADR-0001 Phase 5: the journal is sharded by company slug and lives in
|
|
6
|
+
* `~/.hq/`, not inside the HQ content root. One monolithic journal per HQ
|
|
7
|
+
* install conflates state across companies and forces every runner to
|
|
8
|
+
* serialize through the same file — splitting it lets `hq-sync-runner
|
|
9
|
+
* --companies` fan out without contention, and a corrupted shard only affects
|
|
10
|
+
* one company.
|
|
11
|
+
*
|
|
12
|
+
* Path: `{stateDir}/sync-journal.{slug}.json`, where `stateDir` resolves to
|
|
13
|
+
* `HQ_STATE_DIR` (if set) or `~/.hq`.
|
|
3
14
|
*/
|
|
4
15
|
|
|
5
16
|
import * as fs from "fs";
|
|
17
|
+
import * as os from "os";
|
|
6
18
|
import * as path from "path";
|
|
7
19
|
import * as crypto from "crypto";
|
|
8
20
|
import type { SyncJournal, JournalEntry } from "./types.js";
|
|
9
21
|
|
|
10
|
-
const
|
|
22
|
+
const JOURNAL_FILE_PREFIX = "sync-journal.";
|
|
23
|
+
const JOURNAL_FILE_SUFFIX = ".json";
|
|
11
24
|
|
|
12
|
-
|
|
13
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Where per-company journals are stored. Honors `HQ_STATE_DIR` for tests and
|
|
27
|
+
* non-standard installs; otherwise falls back to `~/.hq`.
|
|
28
|
+
*/
|
|
29
|
+
export function getStateDir(): string {
|
|
30
|
+
return process.env.HQ_STATE_DIR ?? path.join(os.homedir(), ".hq");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Filename-safe form of a slug. Slugs from vault-service are already
|
|
35
|
+
* URL-safe, but this guards against paths, dots, or anything the filesystem
|
|
36
|
+
* might interpret. Empty-or-invalid slugs throw rather than silently writing
|
|
37
|
+
* to a shared "sync-journal..json" file.
|
|
38
|
+
*/
|
|
39
|
+
function sanitizeSlug(slug: string): string {
|
|
40
|
+
if (!slug) {
|
|
41
|
+
throw new Error("journal: slug is required (empty or undefined)");
|
|
42
|
+
}
|
|
43
|
+
const cleaned = slug.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
44
|
+
if (!cleaned || /^[_-]+$/.test(cleaned)) {
|
|
45
|
+
throw new Error(`journal: slug "${slug}" sanitizes to an empty identifier`);
|
|
46
|
+
}
|
|
47
|
+
return cleaned;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getJournalPath(slug: string): string {
|
|
51
|
+
return path.join(
|
|
52
|
+
getStateDir(),
|
|
53
|
+
`${JOURNAL_FILE_PREFIX}${sanitizeSlug(slug)}${JOURNAL_FILE_SUFFIX}`,
|
|
54
|
+
);
|
|
14
55
|
}
|
|
15
56
|
|
|
16
|
-
export function readJournal(
|
|
17
|
-
const journalPath = getJournalPath(
|
|
57
|
+
export function readJournal(slug: string): SyncJournal {
|
|
58
|
+
const journalPath = getJournalPath(slug);
|
|
18
59
|
if (fs.existsSync(journalPath)) {
|
|
19
60
|
const content = fs.readFileSync(journalPath, "utf-8");
|
|
20
61
|
return JSON.parse(content) as SyncJournal;
|
|
@@ -22,8 +63,9 @@ export function readJournal(hqRoot: string): SyncJournal {
|
|
|
22
63
|
return { version: "1", lastSync: "", files: {} };
|
|
23
64
|
}
|
|
24
65
|
|
|
25
|
-
export function writeJournal(
|
|
26
|
-
const journalPath = getJournalPath(
|
|
66
|
+
export function writeJournal(slug: string, journal: SyncJournal): void {
|
|
67
|
+
const journalPath = getJournalPath(slug);
|
|
68
|
+
fs.mkdirSync(path.dirname(journalPath), { recursive: true });
|
|
27
69
|
fs.writeFileSync(journalPath, JSON.stringify(journal, null, 2));
|
|
28
70
|
}
|
|
29
71
|
|
|
@@ -37,7 +79,7 @@ export function updateEntry(
|
|
|
37
79
|
relativePath: string,
|
|
38
80
|
hash: string,
|
|
39
81
|
size: number,
|
|
40
|
-
direction: "up" | "down"
|
|
82
|
+
direction: "up" | "down",
|
|
41
83
|
): void {
|
|
42
84
|
journal.files[relativePath] = {
|
|
43
85
|
hash,
|
|
@@ -50,14 +92,14 @@ export function updateEntry(
|
|
|
50
92
|
|
|
51
93
|
export function getEntry(
|
|
52
94
|
journal: SyncJournal,
|
|
53
|
-
relativePath: string
|
|
95
|
+
relativePath: string,
|
|
54
96
|
): JournalEntry | undefined {
|
|
55
97
|
return journal.files[relativePath];
|
|
56
98
|
}
|
|
57
99
|
|
|
58
100
|
export function removeEntry(
|
|
59
101
|
journal: SyncJournal,
|
|
60
|
-
relativePath: string
|
|
102
|
+
relativePath: string,
|
|
61
103
|
): void {
|
|
62
104
|
delete journal.files[relativePath];
|
|
63
105
|
}
|
package/src/s3.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* S3 operations — upload, download, list, delete
|
|
2
|
+
* S3 operations — upload, download, list, delete.
|
|
3
|
+
*
|
|
4
|
+
* VLT-5: All operations now accept an EntityContext (entity-aware bucket +
|
|
5
|
+
* STS-scoped credentials) instead of reading static env config. The caller
|
|
6
|
+
* is responsible for resolving the context via resolveEntityContext().
|
|
3
7
|
*/
|
|
4
8
|
|
|
5
9
|
import * as fs from "fs";
|
|
@@ -10,79 +14,56 @@ import {
|
|
|
10
14
|
GetObjectCommand,
|
|
11
15
|
ListObjectsV2Command,
|
|
12
16
|
DeleteObjectCommand,
|
|
17
|
+
HeadObjectCommand,
|
|
13
18
|
} from "@aws-sdk/client-s3";
|
|
14
|
-
import type {
|
|
15
|
-
import { readCredentials, refreshAwsCredentials } from "./auth.js";
|
|
16
|
-
|
|
17
|
-
let s3Client: S3Client | null = null;
|
|
18
|
-
|
|
19
|
-
function getConfig(creds: Credentials): SyncConfig {
|
|
20
|
-
const prefix = creds.teamId
|
|
21
|
-
? `teams/${creds.teamId}/users/${creds.userId}/hq/`
|
|
22
|
-
: `users/${creds.userId}/hq/`;
|
|
23
|
-
return {
|
|
24
|
-
bucket: creds.bucket,
|
|
25
|
-
region: creds.region,
|
|
26
|
-
userId: creds.userId,
|
|
27
|
-
prefix,
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
async function getClient(): Promise<{ client: S3Client; config: SyncConfig }> {
|
|
32
|
-
let creds = readCredentials();
|
|
33
|
-
if (!creds) {
|
|
34
|
-
throw new Error("Not authenticated. Run 'hq sync init' first.");
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Refresh if expired or missing access key
|
|
38
|
-
if (!creds.accessKeyId || (creds.expiration && new Date(creds.expiration) < new Date())) {
|
|
39
|
-
creds = await refreshAwsCredentials(creds);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
if (!s3Client) {
|
|
43
|
-
s3Client = new S3Client({
|
|
44
|
-
region: creds.region,
|
|
45
|
-
credentials: {
|
|
46
|
-
accessKeyId: creds.accessKeyId,
|
|
47
|
-
secretAccessKey: creds.secretAccessKey,
|
|
48
|
-
sessionToken: creds.sessionToken,
|
|
49
|
-
},
|
|
50
|
-
});
|
|
51
|
-
}
|
|
19
|
+
import type { EntityContext } from "./types.js";
|
|
52
20
|
|
|
53
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Build an S3Client from an EntityContext's STS-scoped credentials.
|
|
23
|
+
* A new client is created each time to ensure fresh credentials are used
|
|
24
|
+
* (the caller handles caching/refresh at the EntityContext level).
|
|
25
|
+
*/
|
|
26
|
+
function buildClient(ctx: EntityContext): S3Client {
|
|
27
|
+
return new S3Client({
|
|
28
|
+
region: ctx.region,
|
|
29
|
+
credentials: {
|
|
30
|
+
accessKeyId: ctx.credentials.accessKeyId,
|
|
31
|
+
secretAccessKey: ctx.credentials.secretAccessKey,
|
|
32
|
+
sessionToken: ctx.credentials.sessionToken,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
54
35
|
}
|
|
55
36
|
|
|
56
37
|
export async function uploadFile(
|
|
38
|
+
ctx: EntityContext,
|
|
57
39
|
localPath: string,
|
|
58
|
-
|
|
40
|
+
key: string,
|
|
59
41
|
): Promise<void> {
|
|
60
|
-
const
|
|
61
|
-
const key = `${config.prefix}${relativePath}`;
|
|
42
|
+
const client = buildClient(ctx);
|
|
62
43
|
const body = fs.readFileSync(localPath);
|
|
63
44
|
|
|
64
45
|
await client.send(
|
|
65
46
|
new PutObjectCommand({
|
|
66
|
-
Bucket:
|
|
47
|
+
Bucket: ctx.bucketName,
|
|
67
48
|
Key: key,
|
|
68
49
|
Body: body,
|
|
69
|
-
ContentType: getMimeType(
|
|
70
|
-
})
|
|
50
|
+
ContentType: getMimeType(key),
|
|
51
|
+
}),
|
|
71
52
|
);
|
|
72
53
|
}
|
|
73
54
|
|
|
74
55
|
export async function downloadFile(
|
|
75
|
-
|
|
76
|
-
|
|
56
|
+
ctx: EntityContext,
|
|
57
|
+
key: string,
|
|
58
|
+
localPath: string,
|
|
77
59
|
): Promise<void> {
|
|
78
|
-
const
|
|
79
|
-
const key = `${config.prefix}${relativePath}`;
|
|
60
|
+
const client = buildClient(ctx);
|
|
80
61
|
|
|
81
62
|
const response = await client.send(
|
|
82
63
|
new GetObjectCommand({
|
|
83
|
-
Bucket:
|
|
64
|
+
Bucket: ctx.bucketName,
|
|
84
65
|
Key: key,
|
|
85
|
-
})
|
|
66
|
+
}),
|
|
86
67
|
);
|
|
87
68
|
|
|
88
69
|
if (!response.Body) {
|
|
@@ -104,34 +85,33 @@ export async function downloadFile(
|
|
|
104
85
|
|
|
105
86
|
export interface RemoteFile {
|
|
106
87
|
key: string;
|
|
107
|
-
relativePath: string;
|
|
108
88
|
size: number;
|
|
109
89
|
lastModified: Date;
|
|
110
90
|
etag: string;
|
|
111
91
|
}
|
|
112
92
|
|
|
113
|
-
export async function listRemoteFiles(
|
|
114
|
-
|
|
93
|
+
export async function listRemoteFiles(
|
|
94
|
+
ctx: EntityContext,
|
|
95
|
+
prefix?: string,
|
|
96
|
+
): Promise<RemoteFile[]> {
|
|
97
|
+
const client = buildClient(ctx);
|
|
115
98
|
const files: RemoteFile[] = [];
|
|
116
99
|
let continuationToken: string | undefined;
|
|
117
100
|
|
|
118
101
|
do {
|
|
119
102
|
const response = await client.send(
|
|
120
103
|
new ListObjectsV2Command({
|
|
121
|
-
Bucket:
|
|
122
|
-
Prefix:
|
|
104
|
+
Bucket: ctx.bucketName,
|
|
105
|
+
Prefix: prefix,
|
|
123
106
|
ContinuationToken: continuationToken,
|
|
124
|
-
})
|
|
107
|
+
}),
|
|
125
108
|
);
|
|
126
109
|
|
|
127
110
|
for (const obj of response.Contents || []) {
|
|
128
111
|
if (!obj.Key || !obj.Size) continue;
|
|
129
|
-
const relativePath = obj.Key.replace(config.prefix, "");
|
|
130
|
-
if (!relativePath) continue;
|
|
131
112
|
|
|
132
113
|
files.push({
|
|
133
114
|
key: obj.Key,
|
|
134
|
-
relativePath,
|
|
135
115
|
size: obj.Size,
|
|
136
116
|
lastModified: obj.LastModified || new Date(),
|
|
137
117
|
etag: obj.ETag || "",
|
|
@@ -144,18 +124,48 @@ export async function listRemoteFiles(): Promise<RemoteFile[]> {
|
|
|
144
124
|
return files;
|
|
145
125
|
}
|
|
146
126
|
|
|
147
|
-
export async function deleteRemoteFile(
|
|
148
|
-
|
|
149
|
-
|
|
127
|
+
export async function deleteRemoteFile(
|
|
128
|
+
ctx: EntityContext,
|
|
129
|
+
key: string,
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
const client = buildClient(ctx);
|
|
150
132
|
|
|
151
133
|
await client.send(
|
|
152
134
|
new DeleteObjectCommand({
|
|
153
|
-
Bucket:
|
|
135
|
+
Bucket: ctx.bucketName,
|
|
154
136
|
Key: key,
|
|
155
|
-
})
|
|
137
|
+
}),
|
|
156
138
|
);
|
|
157
139
|
}
|
|
158
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Check if a remote key exists and return its metadata.
|
|
143
|
+
*/
|
|
144
|
+
export async function headRemoteFile(
|
|
145
|
+
ctx: EntityContext,
|
|
146
|
+
key: string,
|
|
147
|
+
): Promise<{ lastModified: Date; etag: string; size: number } | null> {
|
|
148
|
+
const client = buildClient(ctx);
|
|
149
|
+
try {
|
|
150
|
+
const response = await client.send(
|
|
151
|
+
new HeadObjectCommand({
|
|
152
|
+
Bucket: ctx.bucketName,
|
|
153
|
+
Key: key,
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
return {
|
|
157
|
+
lastModified: response.LastModified || new Date(),
|
|
158
|
+
etag: response.ETag || "",
|
|
159
|
+
size: response.ContentLength || 0,
|
|
160
|
+
};
|
|
161
|
+
} catch (err: unknown) {
|
|
162
|
+
if (err && typeof err === "object" && "name" in err && err.name === "NotFound") {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
159
169
|
function getMimeType(filePath: string): string {
|
|
160
170
|
const ext = path.extname(filePath).toLowerCase();
|
|
161
171
|
const mimeTypes: Record<string, string> = {
|
package/src/types.ts
CHANGED
|
@@ -57,3 +57,40 @@ export interface DaemonState {
|
|
|
57
57
|
startedAt: string;
|
|
58
58
|
hqRoot: string;
|
|
59
59
|
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Entity-aware context for vault-backed S3 operations (VLT-5).
|
|
63
|
+
* Resolved from vault-service entity registry + STS vending.
|
|
64
|
+
*/
|
|
65
|
+
export interface EntityContext {
|
|
66
|
+
/** Entity UID (cmp_*) */
|
|
67
|
+
uid: string;
|
|
68
|
+
/** Entity slug (human-readable, stable key for per-company local state). */
|
|
69
|
+
slug: string;
|
|
70
|
+
/** S3 bucket name for this entity */
|
|
71
|
+
bucketName: string;
|
|
72
|
+
/** AWS region */
|
|
73
|
+
region: string;
|
|
74
|
+
/** STS-scoped credentials */
|
|
75
|
+
credentials: VaultCredentials;
|
|
76
|
+
/** When the credentials expire (ISO 8601) */
|
|
77
|
+
expiresAt: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface VaultCredentials {
|
|
81
|
+
accessKeyId: string;
|
|
82
|
+
secretAccessKey: string;
|
|
83
|
+
sessionToken: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Configuration for connecting to the vault-service API.
|
|
88
|
+
*/
|
|
89
|
+
export interface VaultServiceConfig {
|
|
90
|
+
/** Vault API base URL (e.g. https://vault-api.example.com) */
|
|
91
|
+
apiUrl: string;
|
|
92
|
+
/** Cognito JWT token for authentication */
|
|
93
|
+
authToken: string;
|
|
94
|
+
/** AWS region for S3 client (defaults to entity region or us-east-1) */
|
|
95
|
+
region?: string;
|
|
96
|
+
}
|