@indigoai-us/hq-cli 5.345.171 → 5.345.173
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/CHANGELOG.md +34 -0
- package/dist/command-catalog.generated.d.ts +74 -2
- package/dist/command-catalog.generated.js +88 -2
- package/dist/command-registration-plan.d.ts +7 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/bot.js +46 -8
- package/dist/commands/cloud.js +9 -5
- package/dist/commands/daemon.js +16 -1
- package/dist/commands/files.d.ts +1 -0
- package/dist/commands/files.js +22 -4
- package/dist/commands/sync-core.d.ts +22 -0
- package/dist/commands/sync-core.js +126 -0
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +278 -224
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/bot/fleet-local.d.ts +16 -1
- package/dist/lib/bot/fleet-local.js +99 -48
- package/dist/lib/bot/fleet-python.d.ts +47 -0
- package/dist/lib/bot/fleet-python.js +218 -0
- package/dist/lib/bot/index.d.ts +1 -0
- package/dist/lib/bot/index.js +1 -0
- package/dist/lib/bot/prompt.d.ts +6 -1
- package/dist/lib/bot/prompt.js +8 -3
- package/dist/lib/daemon/catalog.d.ts +12 -4
- package/dist/lib/daemon/catalog.js +70 -27
- package/dist/lib/daemon/config.d.ts +0 -7
- package/dist/lib/daemon/config.js +0 -32
- package/dist/lib/daemon/run.d.ts +5 -1
- package/dist/lib/daemon/run.js +48 -11
- package/dist/lib/daemon/supervisor.js +3 -1
- package/dist/lib/sync-core/engine-home.d.ts +45 -0
- package/dist/lib/sync-core/engine-home.js +194 -0
- package/dist/lib/sync-core/flag.d.ts +17 -0
- package/dist/lib/sync-core/flag.js +47 -0
- package/dist/lib/sync-core/reconcile.d.ts +31 -0
- package/dist/lib/sync-core/reconcile.js +217 -0
- package/dist/lib/sync-core/release.d.ts +32 -0
- package/dist/lib/sync-core/release.js +142 -0
- package/dist/lib/sync-core/rollout-state.d.ts +28 -0
- package/dist/lib/sync-core/rollout-state.js +115 -0
- package/dist/lib/sync-core/skip.d.ts +7 -0
- package/dist/lib/sync-core/skip.js +30 -0
- package/dist/lib/vault-sync-switches.d.ts +5 -0
- package/dist/lib/vault-sync-switches.js +43 -0
- package/dist/utils/remote-list-not-converging.d.ts +21 -0
- package/dist/utils/remote-list-not-converging.js +40 -0
- package/package.json +8 -8
- package/dist/lib/daemon/sync-core-canary.d.ts +0 -12
- package/dist/lib/daemon/sync-core-canary.js +0 -61
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createFlagClient, readLocalOverride } from "@indigoai-us/hq-flags-client";
|
|
2
|
+
import { resolveFlagRegistryEndpoint } from "../flag-registry-endpoint.js";
|
|
3
|
+
export const SYNC_RUST_ENGINE_ROLLOUT_FLAG = "sync.rust-engine-rollout";
|
|
4
|
+
const READ_TIMEOUT_MS = 5_000;
|
|
5
|
+
export class RolloutFlagReadError extends Error {
|
|
6
|
+
}
|
|
7
|
+
export async function readCompanyRolloutFlag(companyUid, token, options = {}) {
|
|
8
|
+
const env = options.env ?? process.env;
|
|
9
|
+
const override = readLocalOverride(SYNC_RUST_ENGINE_ROLLOUT_FLAG, env);
|
|
10
|
+
if (override !== null)
|
|
11
|
+
return override;
|
|
12
|
+
let failure;
|
|
13
|
+
const onError = (error) => {
|
|
14
|
+
failure ??= error;
|
|
15
|
+
};
|
|
16
|
+
const client = (options.createClient ?? createFlagClient)({
|
|
17
|
+
endpoint: resolveFlagRegistryEndpoint(undefined, env),
|
|
18
|
+
getToken: () => token,
|
|
19
|
+
env: {},
|
|
20
|
+
refreshIntervalMs: 0,
|
|
21
|
+
requestTimeoutMs: READ_TIMEOUT_MS,
|
|
22
|
+
companyUid,
|
|
23
|
+
onError,
|
|
24
|
+
});
|
|
25
|
+
try {
|
|
26
|
+
await client.ready();
|
|
27
|
+
const resolution = client.explain(SYNC_RUST_ENGINE_ROLLOUT_FLAG, { fallback: false });
|
|
28
|
+
if (failure !== undefined) {
|
|
29
|
+
throw new RolloutFlagReadError(`could not read ${SYNC_RUST_ENGINE_ROLLOUT_FLAG} for ${companyUid}: ${failure instanceof Error ? failure.message : String(failure)}`);
|
|
30
|
+
}
|
|
31
|
+
return resolution.source === "snapshot" && resolution.value === true;
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error instanceof RolloutFlagReadError)
|
|
35
|
+
throw error;
|
|
36
|
+
throw new RolloutFlagReadError(`could not read ${SYNC_RUST_ENGINE_ROLLOUT_FLAG} for ${companyUid}: ${error instanceof Error ? error.message : String(error)}`);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
try {
|
|
40
|
+
client.close();
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
failure ??= undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=flag.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type SyncCorePlanFigures, type SyncCoreVault } from "./engine-home.js";
|
|
2
|
+
import { type SyncCoreBinaryInstall } from "./release.js";
|
|
3
|
+
import { type SyncCoreRolloutState } from "./rollout-state.js";
|
|
4
|
+
export interface SyncCoreMembership {
|
|
5
|
+
companyUid: string;
|
|
6
|
+
companySlug?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface SyncCoreReconcileDeps {
|
|
9
|
+
home: string;
|
|
10
|
+
hqRoot: string;
|
|
11
|
+
tokenFile: string;
|
|
12
|
+
companiesOnly: boolean;
|
|
13
|
+
listMemberships: () => Promise<SyncCoreMembership[]>;
|
|
14
|
+
resolvePersonUid: () => Promise<string | null>;
|
|
15
|
+
readRolloutFlag: (companyUid: string) => Promise<boolean>;
|
|
16
|
+
ensureBinary: (release: string) => Promise<SyncCoreBinaryInstall>;
|
|
17
|
+
release?: string;
|
|
18
|
+
now?: () => Date;
|
|
19
|
+
log?: (line: string) => void;
|
|
20
|
+
}
|
|
21
|
+
export interface SyncCoreReconcileResult {
|
|
22
|
+
state: SyncCoreRolloutState;
|
|
23
|
+
started: string[];
|
|
24
|
+
promoted: string[];
|
|
25
|
+
stopped: string[];
|
|
26
|
+
kept: string[];
|
|
27
|
+
warnings: string[];
|
|
28
|
+
}
|
|
29
|
+
export declare function promotionHold(vault: SyncCoreVault, plan: SyncCorePlanFigures, release: string): string | undefined;
|
|
30
|
+
export declare function reconcileSyncCore(deps: SyncCoreReconcileDeps): Promise<SyncCoreReconcileResult>;
|
|
31
|
+
//# sourceMappingURL=reconcile.d.ts.map
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { readCompaniesManifestMap } from "../work-context/repo-remote.js";
|
|
4
|
+
import { readVaultSyncSwitches, writeVaultSyncSwitches } from "../vault-sync-switches.js";
|
|
5
|
+
import { clearEngineStatus, engineReadOnlyPlan, removeEngineOwnership, syncCoreEngineHome, writeEngineHome, } from "./engine-home.js";
|
|
6
|
+
import { SYNC_CORE_COMPANY_FULL_RELEASE, SYNC_CORE_PROMOTION_OP_LIMIT, SYNC_CORE_RELEASE, companyFullModeAllowed, installedSyncCoreRelease, syncCoreBinaryPath, } from "./release.js";
|
|
7
|
+
import { readSyncCoreSkips } from "./skip.js";
|
|
8
|
+
import { readSyncCoreRolloutState, writeSyncCoreRolloutState, } from "./rollout-state.js";
|
|
9
|
+
function message(error) {
|
|
10
|
+
return error instanceof Error ? error.message : String(error);
|
|
11
|
+
}
|
|
12
|
+
export function promotionHold(vault, plan, release) {
|
|
13
|
+
if (vault.kind === "company" && !companyFullModeAllowed(release)) {
|
|
14
|
+
const needed = SYNC_CORE_COMPANY_FULL_RELEASE ? `engine release ${SYNC_CORE_COMPANY_FULL_RELEASE}` : "an engine release that";
|
|
15
|
+
return `company vaults stay read-only until ${needed} scopes the local tree to this account's visibility (${release} installed)`;
|
|
16
|
+
}
|
|
17
|
+
if (plan.deletes > SYNC_CORE_PROMOTION_OP_LIMIT) {
|
|
18
|
+
return `the read-only plan would delete ${plan.deletes} paths (limit ${SYNC_CORE_PROMOTION_OP_LIMIT})`;
|
|
19
|
+
}
|
|
20
|
+
if (plan.conflicts > SYNC_CORE_PROMOTION_OP_LIMIT) {
|
|
21
|
+
return `the read-only plan would write ${plan.conflicts} conflict or merge copies (limit ${SYNC_CORE_PROMOTION_OP_LIMIT})`;
|
|
22
|
+
}
|
|
23
|
+
if (plan.uploads > SYNC_CORE_PROMOTION_OP_LIMIT) {
|
|
24
|
+
return `the read-only plan would upload ${plan.uploads} files against ${plan.remoteRows} server rows (limit ${SYNC_CORE_PROMOTION_OP_LIMIT})`;
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
function localCompanies(hqRoot, memberships) {
|
|
29
|
+
const manifest = readCompaniesManifestMap(hqRoot) ?? {};
|
|
30
|
+
const byUid = new Map();
|
|
31
|
+
for (const [slug, entry] of Object.entries(manifest)) {
|
|
32
|
+
const uid = entry?.cloud_uid;
|
|
33
|
+
if (typeof uid === "string" && /^[a-z0-9][a-z0-9_-]*$/.test(slug))
|
|
34
|
+
byUid.set(uid, slug);
|
|
35
|
+
}
|
|
36
|
+
const candidates = [];
|
|
37
|
+
for (const membership of memberships) {
|
|
38
|
+
const slug = byUid.get(membership.companyUid);
|
|
39
|
+
if (!slug)
|
|
40
|
+
continue;
|
|
41
|
+
if (!fs.existsSync(path.join(hqRoot, "companies", slug)))
|
|
42
|
+
continue;
|
|
43
|
+
candidates.push({ uid: membership.companyUid, kind: "company", slug });
|
|
44
|
+
}
|
|
45
|
+
return candidates;
|
|
46
|
+
}
|
|
47
|
+
export async function reconcileSyncCore(deps) {
|
|
48
|
+
const now = deps.now ?? (() => new Date());
|
|
49
|
+
const log = deps.log ?? (() => { });
|
|
50
|
+
const release = deps.release ?? SYNC_CORE_RELEASE;
|
|
51
|
+
const warnings = [];
|
|
52
|
+
const previous = readSyncCoreRolloutState(deps.home);
|
|
53
|
+
const previousByUid = new Map((previous?.vaults ?? []).map((vault) => [vault.uid, vault]));
|
|
54
|
+
const memberships = await deps.listMemberships();
|
|
55
|
+
const desired = [];
|
|
56
|
+
let anyCompanyOn = false;
|
|
57
|
+
for (const company of localCompanies(deps.hqRoot, memberships)) {
|
|
58
|
+
let on;
|
|
59
|
+
try {
|
|
60
|
+
on = await deps.readRolloutFlag(company.uid);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
on = previousByUid.has(company.uid);
|
|
64
|
+
warnings.push(`${message(error)}; keeping ${company.slug} ${on ? "on the new engine" : "on the current sync"}`);
|
|
65
|
+
}
|
|
66
|
+
if (on) {
|
|
67
|
+
anyCompanyOn = true;
|
|
68
|
+
desired.push(company);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!deps.companiesOnly && anyCompanyOn) {
|
|
72
|
+
let personUid = null;
|
|
73
|
+
try {
|
|
74
|
+
personUid = await deps.resolvePersonUid();
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
const kept = [...previousByUid.values()].find((vault) => vault.kind === "personal");
|
|
78
|
+
personUid = kept?.uid ?? null;
|
|
79
|
+
warnings.push(`${message(error)}; ${kept ? "keeping the personal vault on the new engine" : "leaving the personal vault on the current sync"}`);
|
|
80
|
+
}
|
|
81
|
+
if (personUid)
|
|
82
|
+
desired.push({ uid: personUid, kind: "personal" });
|
|
83
|
+
}
|
|
84
|
+
const skipped = [];
|
|
85
|
+
try {
|
|
86
|
+
const skips = readSyncCoreSkips(deps.home);
|
|
87
|
+
for (let i = desired.length - 1; i >= 0; i -= 1) {
|
|
88
|
+
const skip = skips.find((entry) => entry.uid === desired[i]?.uid);
|
|
89
|
+
if (!skip)
|
|
90
|
+
continue;
|
|
91
|
+
skipped.unshift(skip);
|
|
92
|
+
desired.splice(i, 1);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
warnings.push(message(error));
|
|
97
|
+
}
|
|
98
|
+
let binaryPath = syncCoreBinaryPath(deps.home);
|
|
99
|
+
let installedRelease = installedSyncCoreRelease(deps.home);
|
|
100
|
+
if (desired.length > 0) {
|
|
101
|
+
try {
|
|
102
|
+
const install = await deps.ensureBinary(release);
|
|
103
|
+
binaryPath = install.binaryPath;
|
|
104
|
+
installedRelease = install.release;
|
|
105
|
+
if (install.installed)
|
|
106
|
+
log(`installed hq-syncd ${install.release} at ${install.binaryPath}`);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
warnings.push(`${message(error)}; ${installedRelease ? `keeping hq-syncd ${installedRelease}` : "no vault moves until the engine download succeeds"}`);
|
|
110
|
+
if (!installedRelease)
|
|
111
|
+
desired.length = 0;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const at = now().toISOString();
|
|
115
|
+
const switchesBefore = readVaultSyncSwitches(deps.home);
|
|
116
|
+
const vaults = [];
|
|
117
|
+
const started = [];
|
|
118
|
+
const promoted = [];
|
|
119
|
+
const kept = [];
|
|
120
|
+
const companies = {};
|
|
121
|
+
let personalOff = false;
|
|
122
|
+
for (const vault of desired) {
|
|
123
|
+
const before = previousByUid.get(vault.uid);
|
|
124
|
+
const engineHome = syncCoreEngineHome(deps.home, vault.uid);
|
|
125
|
+
if (!before)
|
|
126
|
+
clearEngineStatus(deps.home, vault.uid);
|
|
127
|
+
const plan = engineReadOnlyPlan(engineHome, vault.uid) ?? undefined;
|
|
128
|
+
let phase = before?.phase === "full" ? "full" : "warming";
|
|
129
|
+
let hold;
|
|
130
|
+
if (phase === "warming" && plan) {
|
|
131
|
+
hold = promotionHold(vault, plan, installedRelease ?? release);
|
|
132
|
+
if (!hold)
|
|
133
|
+
phase = "full";
|
|
134
|
+
}
|
|
135
|
+
writeEngineHome({ home: deps.home, hqRoot: deps.hqRoot, vault, phase, tokenFile: deps.tokenFile });
|
|
136
|
+
const alreadyOff = vault.kind === "personal"
|
|
137
|
+
? switchesBefore.personalOff
|
|
138
|
+
: switchesBefore.offCompanies.has(vault.slug ?? "");
|
|
139
|
+
const legacyAlreadyOff = before?.phase === "full" ? before.legacyAlreadyOff === true : phase === "full" && alreadyOff;
|
|
140
|
+
const legacyOffSince = phase === "full" ? before?.legacyOffSince ?? at : undefined;
|
|
141
|
+
vaults.push({
|
|
142
|
+
uid: vault.uid,
|
|
143
|
+
kind: vault.kind,
|
|
144
|
+
...(vault.slug ? { slug: vault.slug } : {}),
|
|
145
|
+
phase,
|
|
146
|
+
engineHome,
|
|
147
|
+
...(legacyOffSince ? { legacyOffSince } : {}),
|
|
148
|
+
...(legacyAlreadyOff ? { legacyAlreadyOff: true } : {}),
|
|
149
|
+
...(plan ? { plan } : {}),
|
|
150
|
+
...(hold ? { hold } : {}),
|
|
151
|
+
});
|
|
152
|
+
if (hold && before?.hold !== hold)
|
|
153
|
+
log(`${vault.uid}: staying read-only: ${hold}`);
|
|
154
|
+
if (phase === "full") {
|
|
155
|
+
if (vault.kind === "company" && vault.slug)
|
|
156
|
+
companies[vault.slug] = false;
|
|
157
|
+
else
|
|
158
|
+
personalOff = true;
|
|
159
|
+
}
|
|
160
|
+
if (!before)
|
|
161
|
+
started.push(vault.uid);
|
|
162
|
+
else if (before.phase !== phase)
|
|
163
|
+
promoted.push(vault.uid);
|
|
164
|
+
else
|
|
165
|
+
kept.push(vault.uid);
|
|
166
|
+
}
|
|
167
|
+
const stopped = [];
|
|
168
|
+
let personalBackOn = false;
|
|
169
|
+
for (const vault of previousByUid.values()) {
|
|
170
|
+
if (desired.some((candidate) => candidate.uid === vault.uid))
|
|
171
|
+
continue;
|
|
172
|
+
stopped.push(vault.uid);
|
|
173
|
+
if (vault.phase !== "full" || vault.legacyAlreadyOff)
|
|
174
|
+
continue;
|
|
175
|
+
if (vault.kind === "company" && vault.slug)
|
|
176
|
+
companies[vault.slug] = true;
|
|
177
|
+
else
|
|
178
|
+
personalBackOn = true;
|
|
179
|
+
}
|
|
180
|
+
const state = {
|
|
181
|
+
version: 1,
|
|
182
|
+
readAt: at,
|
|
183
|
+
release: installedRelease ?? release,
|
|
184
|
+
binaryPath,
|
|
185
|
+
vaults,
|
|
186
|
+
...(skipped.length > 0 ? { skipped } : {}),
|
|
187
|
+
...(warnings.length > 0 ? { error: warnings.join("; ") } : {}),
|
|
188
|
+
};
|
|
189
|
+
writeSyncCoreRolloutState(deps.home, state);
|
|
190
|
+
for (const uid of stopped)
|
|
191
|
+
removeEngineOwnership(deps.home, uid);
|
|
192
|
+
const patch = {
|
|
193
|
+
...(personalOff ? { personal: false } : personalBackOn ? { personal: true } : {}),
|
|
194
|
+
companies,
|
|
195
|
+
};
|
|
196
|
+
try {
|
|
197
|
+
if (writeVaultSyncSwitches(deps.home, patch))
|
|
198
|
+
log("updated the sync switches in menubar.json");
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
warnings.push(`could not update the sync switches: ${message(error)}`);
|
|
202
|
+
state.error = warnings.join("; ");
|
|
203
|
+
writeSyncCoreRolloutState(deps.home, state);
|
|
204
|
+
}
|
|
205
|
+
for (const uid of started)
|
|
206
|
+
log(`${uid}: warming the new sync engine's index (home ${syncCoreEngineHome(deps.home, uid)})`);
|
|
207
|
+
for (const uid of promoted)
|
|
208
|
+
log(`${uid}: index warm; moving to the new sync engine`);
|
|
209
|
+
for (const uid of stopped)
|
|
210
|
+
log(`${uid}: back on the current sync`);
|
|
211
|
+
for (const skip of skipped)
|
|
212
|
+
log(`${skip.uid}: skipped on this machine: ${skip.reason}`);
|
|
213
|
+
for (const warning of warnings)
|
|
214
|
+
log(`warning: ${warning}`);
|
|
215
|
+
return { state, started, promoted, stopped, kept, warnings };
|
|
216
|
+
}
|
|
217
|
+
//# sourceMappingURL=reconcile.js.map
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export declare const SYNC_CORE_RELEASE = "v0.1.13";
|
|
2
|
+
export declare const SYNC_CORE_COMPANY_FULL_RELEASE: string | null;
|
|
3
|
+
export declare const SYNC_CORE_PROMOTION_OP_LIMIT = 100;
|
|
4
|
+
export declare function compareSyncCoreReleases(a: string, b: string): number;
|
|
5
|
+
export declare function companyFullModeAllowed(release: string): boolean;
|
|
6
|
+
export declare const SYNC_CORE_DOWNLOAD_BASE = "https://indigoai-public.s3.amazonaws.com/hq-sync-core";
|
|
7
|
+
export declare const SYNC_CORE_DOWNLOAD_BASE_ENV = "HQ_SYNC_CORE_DOWNLOAD_BASE";
|
|
8
|
+
export declare function syncCoreBinaryPath(home?: string): string;
|
|
9
|
+
export declare function syncCoreReleaseMarkerPath(home?: string): string;
|
|
10
|
+
export declare function syncCoreAssetName(release: string, platform: NodeJS.Platform, arch: string): string | null;
|
|
11
|
+
export declare function syncCoreDownloadBase(env?: NodeJS.ProcessEnv): string;
|
|
12
|
+
export declare function parseSha256Sums(text: string): Map<string, string>;
|
|
13
|
+
export declare function installedSyncCoreRelease(home: string): string | null;
|
|
14
|
+
export interface EnsureSyncCoreBinaryOptions {
|
|
15
|
+
home: string;
|
|
16
|
+
platform: NodeJS.Platform;
|
|
17
|
+
arch: string;
|
|
18
|
+
release?: string;
|
|
19
|
+
baseUrl?: string;
|
|
20
|
+
env?: NodeJS.ProcessEnv;
|
|
21
|
+
fetch?: typeof fetch;
|
|
22
|
+
run?: (file: string, args: string[]) => void;
|
|
23
|
+
}
|
|
24
|
+
export interface SyncCoreBinaryInstall {
|
|
25
|
+
binaryPath: string;
|
|
26
|
+
release: string;
|
|
27
|
+
installed: boolean;
|
|
28
|
+
}
|
|
29
|
+
export declare class SyncCoreReleaseError extends Error {
|
|
30
|
+
}
|
|
31
|
+
export declare function ensureSyncCoreBinary(opts: EnsureSyncCoreBinaryOptions): Promise<SyncCoreBinaryInstall>;
|
|
32
|
+
//# sourceMappingURL=release.d.ts.map
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
export const SYNC_CORE_RELEASE = "v0.1.13";
|
|
7
|
+
export const SYNC_CORE_COMPANY_FULL_RELEASE = null;
|
|
8
|
+
export const SYNC_CORE_PROMOTION_OP_LIMIT = 100;
|
|
9
|
+
export function compareSyncCoreReleases(a, b) {
|
|
10
|
+
const parse = (release) => release.replace(/^v/, "").split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
11
|
+
const [x, y] = [parse(a), parse(b)];
|
|
12
|
+
for (let i = 0; i < Math.max(x.length, y.length); i += 1) {
|
|
13
|
+
const diff = (x[i] ?? 0) - (y[i] ?? 0);
|
|
14
|
+
if (diff !== 0)
|
|
15
|
+
return diff;
|
|
16
|
+
}
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
|
+
export function companyFullModeAllowed(release) {
|
|
20
|
+
return SYNC_CORE_COMPANY_FULL_RELEASE !== null && compareSyncCoreReleases(release, SYNC_CORE_COMPANY_FULL_RELEASE) >= 0;
|
|
21
|
+
}
|
|
22
|
+
export const SYNC_CORE_DOWNLOAD_BASE = "https://indigoai-public.s3.amazonaws.com/hq-sync-core";
|
|
23
|
+
export const SYNC_CORE_DOWNLOAD_BASE_ENV = "HQ_SYNC_CORE_DOWNLOAD_BASE";
|
|
24
|
+
const DOWNLOAD_TIMEOUT_MS = 600_000;
|
|
25
|
+
export function syncCoreBinaryPath(home = os.homedir()) {
|
|
26
|
+
return path.join(home, ".hq", "bin", "hq-syncd");
|
|
27
|
+
}
|
|
28
|
+
export function syncCoreReleaseMarkerPath(home = os.homedir()) {
|
|
29
|
+
return path.join(home, ".hq", "bin", "hq-syncd.release");
|
|
30
|
+
}
|
|
31
|
+
export function syncCoreAssetName(release, platform, arch) {
|
|
32
|
+
if (platform === "darwin")
|
|
33
|
+
return `hq-sync-core-${release}-macos-universal.tar.gz`;
|
|
34
|
+
if (platform === "linux" && arch === "x64")
|
|
35
|
+
return `hq-sync-core-${release}-linux-x64.tar.gz`;
|
|
36
|
+
if (platform === "linux" && arch === "arm64")
|
|
37
|
+
return `hq-sync-core-${release}-linux-arm64.tar.gz`;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
export function syncCoreDownloadBase(env = process.env) {
|
|
41
|
+
const override = env[SYNC_CORE_DOWNLOAD_BASE_ENV]?.trim();
|
|
42
|
+
return (override || SYNC_CORE_DOWNLOAD_BASE).replace(/\/+$/, "");
|
|
43
|
+
}
|
|
44
|
+
export function parseSha256Sums(text) {
|
|
45
|
+
const sums = new Map();
|
|
46
|
+
for (const line of text.split("\n")) {
|
|
47
|
+
const match = /^([0-9a-f]{64})\s+\*?(\S+)\s*$/i.exec(line.trim());
|
|
48
|
+
if (match)
|
|
49
|
+
sums.set(match[2], match[1].toLowerCase());
|
|
50
|
+
}
|
|
51
|
+
return sums;
|
|
52
|
+
}
|
|
53
|
+
export function installedSyncCoreRelease(home) {
|
|
54
|
+
try {
|
|
55
|
+
const marker = fs.readFileSync(syncCoreReleaseMarkerPath(home), "utf8").trim();
|
|
56
|
+
const binary = syncCoreBinaryPath(home);
|
|
57
|
+
if (!marker || !fs.statSync(binary).isFile())
|
|
58
|
+
return null;
|
|
59
|
+
fs.accessSync(binary, fs.constants.X_OK);
|
|
60
|
+
return marker;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export class SyncCoreReleaseError extends Error {
|
|
67
|
+
}
|
|
68
|
+
function findFile(dir, name) {
|
|
69
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
70
|
+
const full = path.join(dir, entry.name);
|
|
71
|
+
if (entry.isDirectory()) {
|
|
72
|
+
const nested = findFile(full, name);
|
|
73
|
+
if (nested)
|
|
74
|
+
return nested;
|
|
75
|
+
}
|
|
76
|
+
else if (entry.name === name) {
|
|
77
|
+
return full;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
async function download(fetcher, url) {
|
|
83
|
+
let response;
|
|
84
|
+
try {
|
|
85
|
+
response = await fetcher(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
throw new SyncCoreReleaseError(`could not download ${url}: ${error instanceof Error ? error.message : String(error)}`);
|
|
89
|
+
}
|
|
90
|
+
if (!response.ok)
|
|
91
|
+
throw new SyncCoreReleaseError(`could not download ${url}: HTTP ${response.status}`);
|
|
92
|
+
return Buffer.from(await response.arrayBuffer());
|
|
93
|
+
}
|
|
94
|
+
export async function ensureSyncCoreBinary(opts) {
|
|
95
|
+
const release = opts.release ?? SYNC_CORE_RELEASE;
|
|
96
|
+
const binaryPath = syncCoreBinaryPath(opts.home);
|
|
97
|
+
const installedRelease = installedSyncCoreRelease(opts.home);
|
|
98
|
+
if (installedRelease && compareSyncCoreReleases(installedRelease, release) >= 0 && fs.existsSync(binaryPath)) {
|
|
99
|
+
return { binaryPath, release: installedRelease, installed: false };
|
|
100
|
+
}
|
|
101
|
+
const asset = syncCoreAssetName(release, opts.platform, opts.arch);
|
|
102
|
+
if (!asset)
|
|
103
|
+
throw new SyncCoreReleaseError(`the sync engine has no ${release} build for ${opts.platform} on ${opts.arch}`);
|
|
104
|
+
const base = `${opts.baseUrl?.replace(/\/+$/, "") ?? syncCoreDownloadBase(opts.env)}/${release}`;
|
|
105
|
+
const fetcher = opts.fetch ?? fetch;
|
|
106
|
+
const run = opts.run ?? ((file, args) => {
|
|
107
|
+
execFileSync(file, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
108
|
+
});
|
|
109
|
+
const sums = parseSha256Sums((await download(fetcher, `${base}/SHA256SUMS`)).toString("utf8"));
|
|
110
|
+
const expected = sums.get(asset);
|
|
111
|
+
if (!expected)
|
|
112
|
+
throw new SyncCoreReleaseError(`SHA256SUMS for ${release} does not list ${asset}`);
|
|
113
|
+
const bytes = await download(fetcher, `${base}/${asset}`);
|
|
114
|
+
const actual = crypto.createHash("sha256").update(bytes).digest("hex");
|
|
115
|
+
if (actual !== expected)
|
|
116
|
+
throw new SyncCoreReleaseError(`${asset} did not match SHA256SUMS (${actual} != ${expected})`);
|
|
117
|
+
const binDir = path.dirname(binaryPath);
|
|
118
|
+
fs.mkdirSync(binDir, { recursive: true, mode: 0o700 });
|
|
119
|
+
const staging = fs.mkdtempSync(path.join(binDir, ".hq-syncd-staging-"));
|
|
120
|
+
try {
|
|
121
|
+
const archive = path.join(staging, asset);
|
|
122
|
+
fs.writeFileSync(archive, bytes, { mode: 0o600 });
|
|
123
|
+
const extracted = path.join(staging, "extracted");
|
|
124
|
+
fs.mkdirSync(extracted, { mode: 0o700 });
|
|
125
|
+
run("tar", ["-xzf", archive, "-C", extracted]);
|
|
126
|
+
const unpacked = findFile(extracted, "hq-syncd");
|
|
127
|
+
if (!unpacked)
|
|
128
|
+
throw new SyncCoreReleaseError(`${asset} does not contain hq-syncd`);
|
|
129
|
+
const prepared = path.join(staging, "hq-syncd");
|
|
130
|
+
fs.copyFileSync(unpacked, prepared);
|
|
131
|
+
fs.chmodSync(prepared, 0o700);
|
|
132
|
+
fs.renameSync(prepared, binaryPath);
|
|
133
|
+
const marker = syncCoreReleaseMarkerPath(opts.home);
|
|
134
|
+
fs.writeFileSync(`${marker}.tmp`, `${release}\n`, { mode: 0o600 });
|
|
135
|
+
fs.renameSync(`${marker}.tmp`, marker);
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
139
|
+
}
|
|
140
|
+
return { binaryPath, release, installed: true };
|
|
141
|
+
}
|
|
142
|
+
//# sourceMappingURL=release.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type SyncCorePlanFigures, type SyncCoreVaultKind, type SyncCoreVaultPhase } from "./engine-home.js";
|
|
2
|
+
export interface RolloutVault {
|
|
3
|
+
uid: string;
|
|
4
|
+
kind: SyncCoreVaultKind;
|
|
5
|
+
slug?: string;
|
|
6
|
+
phase: SyncCoreVaultPhase;
|
|
7
|
+
engineHome: string;
|
|
8
|
+
legacyOffSince?: string;
|
|
9
|
+
legacyAlreadyOff?: true;
|
|
10
|
+
plan?: SyncCorePlanFigures;
|
|
11
|
+
hold?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface SyncCoreRolloutState {
|
|
14
|
+
version: 1;
|
|
15
|
+
readAt: string;
|
|
16
|
+
release: string;
|
|
17
|
+
binaryPath: string;
|
|
18
|
+
vaults: RolloutVault[];
|
|
19
|
+
skipped?: {
|
|
20
|
+
uid: string;
|
|
21
|
+
reason: string;
|
|
22
|
+
}[];
|
|
23
|
+
error?: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function syncCoreRolloutStatePath(home: string): string;
|
|
26
|
+
export declare function readSyncCoreRolloutState(home: string): SyncCoreRolloutState | null;
|
|
27
|
+
export declare function writeSyncCoreRolloutState(home: string, state: SyncCoreRolloutState): void;
|
|
28
|
+
//# sourceMappingURL=rollout-state.d.ts.map
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { isSyncCoreVaultUid } from "./engine-home.js";
|
|
4
|
+
function planFigures(value) {
|
|
5
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
6
|
+
return null;
|
|
7
|
+
const p = value;
|
|
8
|
+
if (typeof p.at !== "string")
|
|
9
|
+
return null;
|
|
10
|
+
const numbers = ["uploads", "downloads", "deletes", "remoteRows", "localFiles"];
|
|
11
|
+
if (numbers.some((key) => typeof p[key] !== "number" || !Number.isFinite(p[key])))
|
|
12
|
+
return null;
|
|
13
|
+
if (p.conflicts !== undefined && (typeof p.conflicts !== "number" || !Number.isFinite(p.conflicts)))
|
|
14
|
+
return null;
|
|
15
|
+
return {
|
|
16
|
+
at: p.at,
|
|
17
|
+
uploads: p.uploads,
|
|
18
|
+
downloads: p.downloads,
|
|
19
|
+
deletes: p.deletes,
|
|
20
|
+
conflicts: typeof p.conflicts === "number" ? p.conflicts : 0,
|
|
21
|
+
remoteRows: p.remoteRows,
|
|
22
|
+
localFiles: p.localFiles,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function skippedEntries(value) {
|
|
26
|
+
if (value === undefined)
|
|
27
|
+
return [];
|
|
28
|
+
if (!Array.isArray(value))
|
|
29
|
+
return null;
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const entry of value) {
|
|
32
|
+
const e = entry && typeof entry === "object" ? entry : undefined;
|
|
33
|
+
if (!e || !isSyncCoreVaultUid(e.uid) || typeof e.reason !== "string")
|
|
34
|
+
return null;
|
|
35
|
+
out.push({ uid: e.uid, reason: e.reason });
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
export function syncCoreRolloutStatePath(home) {
|
|
40
|
+
return path.join(home, ".hq", "sync-core", "rollout.json");
|
|
41
|
+
}
|
|
42
|
+
function vault(value) {
|
|
43
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
44
|
+
return null;
|
|
45
|
+
const v = value;
|
|
46
|
+
if (!isSyncCoreVaultUid(v.uid))
|
|
47
|
+
return null;
|
|
48
|
+
if (v.kind !== "personal" && v.kind !== "company")
|
|
49
|
+
return null;
|
|
50
|
+
if (typeof v.engineHome !== "string" || !path.isAbsolute(v.engineHome))
|
|
51
|
+
return null;
|
|
52
|
+
if (v.legacyOffSince !== undefined && typeof v.legacyOffSince !== "string")
|
|
53
|
+
return null;
|
|
54
|
+
if (v.phase === undefined && typeof v.legacyOffSince === "string")
|
|
55
|
+
v.phase = "full";
|
|
56
|
+
if (v.phase !== "warming" && v.phase !== "full")
|
|
57
|
+
return null;
|
|
58
|
+
if (v.kind === "company" && (typeof v.slug !== "string" || !/^[a-z0-9][a-z0-9_-]*$/.test(v.slug)))
|
|
59
|
+
return null;
|
|
60
|
+
const plan = v.plan === undefined ? undefined : planFigures(v.plan);
|
|
61
|
+
if (plan === null)
|
|
62
|
+
return null;
|
|
63
|
+
if (v.hold !== undefined && typeof v.hold !== "string")
|
|
64
|
+
return null;
|
|
65
|
+
return {
|
|
66
|
+
uid: v.uid,
|
|
67
|
+
kind: v.kind,
|
|
68
|
+
...(typeof v.slug === "string" ? { slug: v.slug } : {}),
|
|
69
|
+
phase: v.phase,
|
|
70
|
+
engineHome: v.engineHome,
|
|
71
|
+
...(typeof v.legacyOffSince === "string" ? { legacyOffSince: v.legacyOffSince } : {}),
|
|
72
|
+
...(v.legacyAlreadyOff === true ? { legacyAlreadyOff: true } : {}),
|
|
73
|
+
...(plan ? { plan } : {}),
|
|
74
|
+
...(typeof v.hold === "string" ? { hold: v.hold } : {}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export function readSyncCoreRolloutState(home) {
|
|
78
|
+
let raw;
|
|
79
|
+
try {
|
|
80
|
+
raw = JSON.parse(fs.readFileSync(syncCoreRolloutStatePath(home), "utf8"));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
86
|
+
return null;
|
|
87
|
+
const obj = raw;
|
|
88
|
+
if (obj.version !== 1 || typeof obj.release !== "string" || typeof obj.binaryPath !== "string")
|
|
89
|
+
return null;
|
|
90
|
+
if (typeof obj.readAt !== "string" || !Array.isArray(obj.vaults))
|
|
91
|
+
return null;
|
|
92
|
+
const vaults = obj.vaults.map(vault);
|
|
93
|
+
if (vaults.some((v) => v === null))
|
|
94
|
+
return null;
|
|
95
|
+
const skipped = skippedEntries(obj.skipped);
|
|
96
|
+
if (skipped === null)
|
|
97
|
+
return null;
|
|
98
|
+
return {
|
|
99
|
+
version: 1,
|
|
100
|
+
readAt: obj.readAt,
|
|
101
|
+
release: obj.release,
|
|
102
|
+
binaryPath: obj.binaryPath,
|
|
103
|
+
vaults: vaults,
|
|
104
|
+
...(skipped.length > 0 ? { skipped } : {}),
|
|
105
|
+
...(typeof obj.error === "string" ? { error: obj.error } : {}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
export function writeSyncCoreRolloutState(home, state) {
|
|
109
|
+
const file = syncCoreRolloutStatePath(home);
|
|
110
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
111
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
112
|
+
fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
113
|
+
fs.renameSync(tmp, file);
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=rollout-state.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { isSyncCoreVaultUid } from "./engine-home.js";
|
|
4
|
+
export function syncCoreSkipPath(home) {
|
|
5
|
+
return path.join(home, ".hq", "sync-core", "skip.json");
|
|
6
|
+
}
|
|
7
|
+
export function readSyncCoreSkips(home) {
|
|
8
|
+
let raw;
|
|
9
|
+
try {
|
|
10
|
+
raw = JSON.parse(fs.readFileSync(syncCoreSkipPath(home), "utf8"));
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
if (error.code === "ENOENT")
|
|
14
|
+
return [];
|
|
15
|
+
throw new Error(`${syncCoreSkipPath(home)} could not be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
16
|
+
}
|
|
17
|
+
const vaults = raw && typeof raw === "object" && !Array.isArray(raw) ? raw.vaults : undefined;
|
|
18
|
+
if (!vaults || typeof vaults !== "object" || Array.isArray(vaults)) {
|
|
19
|
+
throw new Error(`${syncCoreSkipPath(home)} must be {"vaults": {"<vault uid>": "<reason>"}}`);
|
|
20
|
+
}
|
|
21
|
+
const skips = [];
|
|
22
|
+
for (const [uid, reason] of Object.entries(vaults)) {
|
|
23
|
+
if (!isSyncCoreVaultUid(uid) || typeof reason !== "string" || reason.trim() === "") {
|
|
24
|
+
throw new Error(`${syncCoreSkipPath(home)} must map vault uids to a non-empty reason (bad entry ${uid})`);
|
|
25
|
+
}
|
|
26
|
+
skips.push({ uid, reason: reason.trim() });
|
|
27
|
+
}
|
|
28
|
+
return skips;
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=skip.js.map
|
|
@@ -38,4 +38,9 @@ export declare function unreadableSwitchesWarning(reason: string): string;
|
|
|
38
38
|
* (`HQ_SYNC_SKIP_COMPANIES`, `HQ_SYNC_SKIP_PERSONAL`). Empty when all are on.
|
|
39
39
|
*/
|
|
40
40
|
export declare function vaultSkipEnv(switches: VaultSyncSwitches): Record<string, string>;
|
|
41
|
+
export interface VaultSyncSwitchPatch {
|
|
42
|
+
personal?: boolean;
|
|
43
|
+
companies?: Record<string, boolean>;
|
|
44
|
+
}
|
|
45
|
+
export declare function writeVaultSyncSwitches(home: string, patch: VaultSyncSwitchPatch): boolean;
|
|
41
46
|
//# sourceMappingURL=vault-sync-switches.d.ts.map
|