@indigoai-us/hq-cli 5.70.0 → 5.72.0
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 +20 -0
- package/dist/bin/hq-auth-refresh.js +1 -4
- package/dist/cli-version.js +1 -4
- package/dist/commands/__fixtures__/make-tar.js +1 -4
- package/dist/commands/_patterns.js +1 -4
- package/dist/commands/add.js +1 -4
- package/dist/commands/agents.js +1 -4
- package/dist/commands/api-keys.js +1 -4
- package/dist/commands/auth.js +1 -4
- package/dist/commands/billing.js +1 -4
- package/dist/commands/channels.js +1 -4
- package/dist/commands/cloud-demote.js +1 -4
- package/dist/commands/cloud-provision.js +1 -4
- package/dist/commands/cloud.js +1 -4
- package/dist/commands/company.js +1 -4
- package/dist/commands/creators.js +1 -4
- package/dist/commands/crm.js +1 -4
- package/dist/commands/db-migrate.js +1 -4
- package/dist/commands/db-provision.js +1 -4
- package/dist/commands/db-sql.js +1 -4
- package/dist/commands/db-status.js +1 -4
- package/dist/commands/db.js +1 -4
- package/dist/commands/dm.js +1 -4
- package/dist/commands/feedback.js +1 -4
- package/dist/commands/files-browse.js +1 -4
- package/dist/commands/files.js +1 -4
- package/dist/commands/group-grants.js +1 -4
- package/dist/commands/groups.js +1 -4
- package/dist/commands/integrations.js +1 -4
- package/dist/commands/list.js +1 -4
- package/dist/commands/login.js +1 -4
- package/dist/commands/logout.js +1 -4
- package/dist/commands/mcp-registration.js +1 -4
- package/dist/commands/mcp-status.js +1 -4
- package/dist/commands/meetings.js +1 -4
- package/dist/commands/members.js +1 -4
- package/dist/commands/onboard-identity-guard.js +1 -4
- package/dist/commands/onboard-join.js +1 -4
- package/dist/commands/onboard-warning.js +1 -4
- package/dist/commands/onboard.js +1 -4
- package/dist/commands/outposts.d.ts +59 -1
- package/dist/commands/outposts.js +319 -10
- package/dist/commands/pack-install.js +1 -4
- package/dist/commands/packs.js +1 -4
- package/dist/commands/people.js +1 -4
- package/dist/commands/pkg-install.js +1 -4
- package/dist/commands/pkg-list.js +1 -4
- package/dist/commands/pkg-remove.js +1 -4
- package/dist/commands/pkg-update.js +1 -4
- package/dist/commands/publish.js +1 -4
- package/dist/commands/reindex.js +1 -4
- package/dist/commands/rescue.js +1 -4
- package/dist/commands/run.js +1 -4
- package/dist/commands/safe-extract.js +1 -4
- package/dist/commands/secrets-scope.js +1 -4
- package/dist/commands/secrets.js +1 -4
- package/dist/commands/signals.js +1 -4
- package/dist/commands/skill.js +1 -4
- package/dist/commands/sources.js +1 -4
- package/dist/commands/sync-mode.js +1 -4
- package/dist/commands/sync-narrow.js +1 -4
- package/dist/commands/sync.js +1 -4
- package/dist/commands/team-sync.js +1 -4
- package/dist/commands/update.js +1 -4
- package/dist/commands/whoami.js +1 -4
- package/dist/commands/workers.js +1 -4
- package/dist/index.js +1 -4
- package/dist/lib/db/control-plane.js +1 -4
- package/dist/lib/db/local.js +1 -4
- package/dist/lib/db/migrate.js +1 -4
- package/dist/lib/db/paths.js +1 -4
- package/dist/lib/db/remote-engine.js +1 -4
- package/dist/lib/db/remote-sql.js +1 -4
- package/dist/lib/db/sql.js +1 -4
- package/dist/lib/local-tree-diff.js +1 -4
- package/dist/lib/narrow-hint-banner.js +1 -4
- package/dist/main.js +1 -4
- package/dist/node-preflight.js +1 -4
- package/dist/run/discover-schemas.js +1 -4
- package/dist/run/hq-plugin.js +1 -4
- package/dist/sentry-before-send.js +1 -4
- package/dist/sentry-dsn.generated.js +1 -4
- package/dist/sentry.js +1 -4
- package/dist/strategies/link.js +1 -4
- package/dist/strategies/merge.js +1 -4
- package/dist/types.js +1 -4
- package/dist/utils/billing-gate.js +1 -4
- package/dist/utils/breadcrumb-buffer.js +1 -4
- package/dist/utils/cli-telemetry.js +1 -4
- package/dist/utils/cognito-session.js +1 -4
- package/dist/utils/contribution-table.js +1 -4
- package/dist/utils/environmental-error.js +1 -4
- package/dist/utils/epipe.js +1 -4
- package/dist/utils/feedback-diagnostics.js +1 -4
- package/dist/utils/feedback-screenshots.js +1 -4
- package/dist/utils/feedback-versions.js +1 -4
- package/dist/utils/git.js +1 -4
- package/dist/utils/integrity.js +1 -4
- package/dist/utils/intercepted-process-exit.js +1 -4
- package/dist/utils/login-provider.js +1 -4
- package/dist/utils/manifest.js +1 -4
- package/dist/utils/pack-contributions.js +1 -4
- package/dist/utils/people.js +1 -4
- package/dist/utils/registry-client.js +1 -4
- package/dist/utils/registry.js +1 -4
- package/dist/utils/sandbox-runner-client.js +1 -4
- package/dist/utils/secrets-cache.js +1 -4
- package/dist/utils/sentry-identity.js +1 -4
- package/dist/utils/vault-api.js +1 -4
- package/dist/utils/version-check.js +1 -4
- package/dist/utils/version-gate.js +1 -4
- package/package.json +3 -2
- package/scripts/chmod-bins.mjs +49 -0
- package/src/commands/outposts-replica-sync.test.ts +272 -0
- package/src/commands/outposts.test.ts +252 -4
- package/src/commands/outposts.ts +469 -7
- package/src/packaging.test.ts +55 -0
package/src/commands/outposts.ts
CHANGED
|
@@ -29,6 +29,7 @@ import * as os from "node:os";
|
|
|
29
29
|
import * as path from "node:path";
|
|
30
30
|
import * as readline from "node:readline";
|
|
31
31
|
import { randomBytes } from "node:crypto";
|
|
32
|
+
import * as yaml from "js-yaml";
|
|
32
33
|
import { loadCachedTokens } from "@indigoai-us/hq-cloud";
|
|
33
34
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
34
35
|
import { vaultApiFetch } from "../utils/vault-api.js";
|
|
@@ -228,6 +229,116 @@ export async function execOutpost(
|
|
|
228
229
|
});
|
|
229
230
|
}
|
|
230
231
|
|
|
232
|
+
/** Presigned input-upload details from `mode: "stage"`. */
|
|
233
|
+
export interface OutpostExecStage {
|
|
234
|
+
ok: true;
|
|
235
|
+
userId: string;
|
|
236
|
+
outpostId: string;
|
|
237
|
+
key: string;
|
|
238
|
+
putUrl: string;
|
|
239
|
+
getUrl: string;
|
|
240
|
+
expiresInSeconds: number;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Asynchronous SSM command details from `mode: "submit"`. */
|
|
244
|
+
export interface OutpostExecSubmission {
|
|
245
|
+
ok: true;
|
|
246
|
+
userId: string;
|
|
247
|
+
outpostId: string;
|
|
248
|
+
instanceId: string;
|
|
249
|
+
commandId: string;
|
|
250
|
+
outputPrefix: string;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Poll response from `mode: "result"`; streams arrive only when terminal. */
|
|
254
|
+
export interface OutpostExecAsyncResult {
|
|
255
|
+
ok: true;
|
|
256
|
+
userId: string;
|
|
257
|
+
outpostId: string;
|
|
258
|
+
status: string;
|
|
259
|
+
done: boolean;
|
|
260
|
+
exitCode?: number | null;
|
|
261
|
+
stdout?: string;
|
|
262
|
+
stderr?: string;
|
|
263
|
+
truncated?: boolean;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function stageExecInput(
|
|
267
|
+
token: string,
|
|
268
|
+
outpostId?: string,
|
|
269
|
+
): Promise<OutpostExecStage> {
|
|
270
|
+
return outpostRequest({
|
|
271
|
+
token,
|
|
272
|
+
path: "/outpost/exec",
|
|
273
|
+
method: "POST",
|
|
274
|
+
body: { mode: "stage" },
|
|
275
|
+
query: outpostId ? { outpostId } : undefined,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function submitExec(
|
|
280
|
+
token: string,
|
|
281
|
+
command: string,
|
|
282
|
+
outpostId?: string,
|
|
283
|
+
): Promise<OutpostExecSubmission> {
|
|
284
|
+
return outpostRequest({
|
|
285
|
+
token,
|
|
286
|
+
path: "/outpost/exec",
|
|
287
|
+
method: "POST",
|
|
288
|
+
body: { mode: "submit", command },
|
|
289
|
+
query: outpostId ? { outpostId } : undefined,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export async function fetchExecResult(
|
|
294
|
+
token: string,
|
|
295
|
+
commandId: string,
|
|
296
|
+
outpostId?: string,
|
|
297
|
+
): Promise<OutpostExecAsyncResult> {
|
|
298
|
+
return outpostRequest({
|
|
299
|
+
token,
|
|
300
|
+
path: "/outpost/exec",
|
|
301
|
+
method: "POST",
|
|
302
|
+
body: { mode: "result", commandId },
|
|
303
|
+
query: outpostId ? { outpostId } : undefined,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function shellQuote(part: string): string {
|
|
308
|
+
return `'${part.replace(/'/g, `'\\''`)}'`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Preserve a single command string; safely join argv when Commander split it. */
|
|
312
|
+
export function joinCommandParts(commandParts: string[]): string {
|
|
313
|
+
if (commandParts.length === 1) return commandParts[0];
|
|
314
|
+
return commandParts.map(shellQuote).join(" ");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const EXEC_RESULT_INITIAL_POLL_MS = 500;
|
|
318
|
+
const EXEC_RESULT_MAX_POLL_MS = 5_000;
|
|
319
|
+
|
|
320
|
+
function sleep(ms: number): Promise<void> {
|
|
321
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function waitForExecResult(
|
|
325
|
+
token: string,
|
|
326
|
+
commandId: string,
|
|
327
|
+
outpostId?: string,
|
|
328
|
+
): Promise<OutpostExecAsyncResult> {
|
|
329
|
+
let delayMs = EXEC_RESULT_INITIAL_POLL_MS;
|
|
330
|
+
while (true) {
|
|
331
|
+
try {
|
|
332
|
+
const result = await fetchExecResult(token, commandId, outpostId);
|
|
333
|
+
if (result.done) return result;
|
|
334
|
+
} catch (err) {
|
|
335
|
+
if (!(err instanceof OutpostHttpError) || err.status !== 429) throw err;
|
|
336
|
+
}
|
|
337
|
+
await sleep(delayMs);
|
|
338
|
+
delayMs = Math.min(delayMs * 2, EXEC_RESULT_MAX_POLL_MS);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
231
342
|
/**
|
|
232
343
|
* Prefix that best-effort `cd`s into the box's HQ checkout before running the
|
|
233
344
|
* caller's command. `exec` runs over two transports with two different default
|
|
@@ -685,12 +796,246 @@ async function selfDeployOutpost(
|
|
|
685
796
|
console.log(chalk.dim("It is unregistered and unmanaged by hq-pro (no console entry or remote management)."));
|
|
686
797
|
}
|
|
687
798
|
|
|
799
|
+
// ---------------------------------------------------------------------------
|
|
800
|
+
// Hidden replica-sync command (box-side full-replica timer target)
|
|
801
|
+
// ---------------------------------------------------------------------------
|
|
802
|
+
//
|
|
803
|
+
// A MANAGED outpost (provisioned via `hq outposts provision`) runs this on a
|
|
804
|
+
// systemd timer to make the box a full replica of the operator's HQ:
|
|
805
|
+
// 1. hq auth refresh — freshen the seeded Cognito session
|
|
806
|
+
// 2. hq sync pull --personal — the personal vault (repos.yaml, policies, …)
|
|
807
|
+
// 3. hq rescue — re-lay the core kernel (needs companies/)
|
|
808
|
+
// 4. clone repos.yaml — the operator's OWN repos, gh-authed via vault
|
|
809
|
+
//
|
|
810
|
+
// Unlike `self-deploy` (which is a manual, unmanaged, all-company watcher on a
|
|
811
|
+
// box you own), replica-sync is GENERIC: it works for every HQ user off their
|
|
812
|
+
// own personal vault — no per-operator setup script. It is deliberately
|
|
813
|
+
// best-effort end to end: a repeating timer must never leave the systemd unit
|
|
814
|
+
// in `failed` just because one transient step (a mirror blip, a not-yet-signed
|
|
815
|
+
// box) didn't complete this cycle.
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* The extra local surface `replica-sync` needs on top of the self-deploy set:
|
|
819
|
+
* two filesystem probes for the repos clone. Kept as an extension so the
|
|
820
|
+
* `self-deploy` dependency surface stays minimal.
|
|
821
|
+
*/
|
|
822
|
+
export interface ReplicaSyncDependencies extends SelfDeployDependencies {
|
|
823
|
+
pathExists: (p: string) => boolean;
|
|
824
|
+
mkdirp: (p: string) => void;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const defaultReplicaSyncDependencies: ReplicaSyncDependencies = {
|
|
828
|
+
...defaultSelfDeployDependencies,
|
|
829
|
+
pathExists: (p) => fs.existsSync(p),
|
|
830
|
+
mkdirp: (p) => {
|
|
831
|
+
fs.mkdirSync(p, { recursive: true });
|
|
832
|
+
},
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
/** One repo entry resolved from `personal/data/repos.yaml`. */
|
|
836
|
+
export interface ReplicaRepo {
|
|
837
|
+
url: string;
|
|
838
|
+
visibility: "public" | "private";
|
|
839
|
+
name: string;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function repoNameFromUrl(url: string): string {
|
|
843
|
+
const tail = url.split("/").pop() ?? "";
|
|
844
|
+
return tail.replace(/\.git$/, "").trim();
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* A repo `name` becomes a path segment under repos/<visibility>/. A synced
|
|
849
|
+
* repos.yaml is operator data, but a malformed or hostile name must never
|
|
850
|
+
* escape that directory, so constrain it to a single, non-traversal component.
|
|
851
|
+
*/
|
|
852
|
+
function isSafePathComponent(value: string): boolean {
|
|
853
|
+
return (
|
|
854
|
+
value.length > 0 &&
|
|
855
|
+
!value.includes("/") &&
|
|
856
|
+
!value.includes("\\") &&
|
|
857
|
+
value !== "." &&
|
|
858
|
+
value !== ".."
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Parse `personal/data/repos.yaml` into a clone list. Pure + exported for
|
|
864
|
+
* tests. Mirrors setup.sh's `clone_repos`: `visibility` defaults to public,
|
|
865
|
+
* `name` defaults to the URL basename. Malformed entries are skipped, never
|
|
866
|
+
* thrown.
|
|
867
|
+
*/
|
|
868
|
+
export function parseReposManifest(yamlText: string): ReplicaRepo[] {
|
|
869
|
+
let doc: unknown;
|
|
870
|
+
try {
|
|
871
|
+
doc = yaml.load(yamlText);
|
|
872
|
+
} catch {
|
|
873
|
+
return [];
|
|
874
|
+
}
|
|
875
|
+
const rawRepos = (doc as { repos?: unknown } | null)?.repos;
|
|
876
|
+
if (!Array.isArray(rawRepos)) return [];
|
|
877
|
+
|
|
878
|
+
const out: ReplicaRepo[] = [];
|
|
879
|
+
for (const entry of rawRepos) {
|
|
880
|
+
if (!entry || typeof entry !== "object") continue;
|
|
881
|
+
const e = entry as Record<string, unknown>;
|
|
882
|
+
const url = typeof e.url === "string" ? e.url.trim() : "";
|
|
883
|
+
if (!url) continue;
|
|
884
|
+
const visibility = e.visibility === "private" ? "private" : "public";
|
|
885
|
+
const name =
|
|
886
|
+
typeof e.name === "string" && e.name.trim()
|
|
887
|
+
? e.name.trim()
|
|
888
|
+
: repoNameFromUrl(url);
|
|
889
|
+
if (!isSafePathComponent(name)) continue;
|
|
890
|
+
out.push({ url, visibility, name });
|
|
891
|
+
}
|
|
892
|
+
return out;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Best-effort spawn: log a one-line outcome and carry on. NEVER throws and
|
|
897
|
+
* NEVER echoes child output — the same secret-safety stance as `runChecked`,
|
|
898
|
+
* since these children (auth refresh, secrets exec) touch credentials.
|
|
899
|
+
*/
|
|
900
|
+
function runBestEffort(
|
|
901
|
+
deps: ReplicaSyncDependencies,
|
|
902
|
+
command: string,
|
|
903
|
+
args: string[],
|
|
904
|
+
description: string,
|
|
905
|
+
options?: Parameters<typeof spawnSync>[2],
|
|
906
|
+
): boolean {
|
|
907
|
+
let result: ReturnType<typeof spawnSync>;
|
|
908
|
+
try {
|
|
909
|
+
result = deps.spawnSync(command, args, options ?? { stdio: "inherit" });
|
|
910
|
+
} catch {
|
|
911
|
+
console.warn(`replica-sync: ${description} could not run — continuing.`);
|
|
912
|
+
return false;
|
|
913
|
+
}
|
|
914
|
+
if (!result || result.error || result.status !== 0) {
|
|
915
|
+
console.warn(`replica-sync: ${description} did not complete cleanly — continuing.`);
|
|
916
|
+
return false;
|
|
917
|
+
}
|
|
918
|
+
return true;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* Authenticate `gh` for private clones by streaming GITHUB_TOKEN through the
|
|
923
|
+
* vault into the child's ENV ONLY, unsetting it in-shell, and piping it to
|
|
924
|
+
* `gh auth login --with-token` over stdin. The token never touches argv or
|
|
925
|
+
* this process's logs. Mirrors personal/scripts/setup.sh's `configure_gh`.
|
|
926
|
+
*/
|
|
927
|
+
function authGitHubViaVault(deps: ReplicaSyncDependencies): void {
|
|
928
|
+
const ghLogin = [
|
|
929
|
+
'TOKEN="$GITHUB_TOKEN"',
|
|
930
|
+
"unset GITHUB_TOKEN",
|
|
931
|
+
'printf "%s" "$TOKEN" | gh auth login --with-token',
|
|
932
|
+
].join("\n");
|
|
933
|
+
const authed = runBestEffort(
|
|
934
|
+
deps,
|
|
935
|
+
"hq",
|
|
936
|
+
["secrets", "--personal", "exec", "--only", "GITHUB_TOKEN", "--", "bash", "-c", ghLogin],
|
|
937
|
+
"GitHub auth via vault",
|
|
938
|
+
);
|
|
939
|
+
if (authed) {
|
|
940
|
+
// Let plain `git clone https://github.com/...` reuse gh's token so private
|
|
941
|
+
// repos don't prompt for a username/password.
|
|
942
|
+
runBestEffort(deps, "gh", ["auth", "setup-git"], "gh auth setup-git");
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/** Clone any repos from `personal/data/repos.yaml` that aren't on disk yet. */
|
|
947
|
+
function replicateRepos(deps: ReplicaSyncDependencies, hqRoot: string): void {
|
|
948
|
+
const manifestPath = path.join(hqRoot, "personal", "data", "repos.yaml");
|
|
949
|
+
let manifestText: string;
|
|
950
|
+
try {
|
|
951
|
+
manifestText = deps.readTextFile(manifestPath);
|
|
952
|
+
} catch {
|
|
953
|
+
console.log("replica-sync: no personal/data/repos.yaml — skipping repo clone.");
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const repos = parseReposManifest(manifestText);
|
|
958
|
+
if (repos.length === 0) {
|
|
959
|
+
console.log("replica-sync: repos.yaml lists no repos — skipping.");
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const missing = repos.filter(
|
|
964
|
+
(repo) =>
|
|
965
|
+
!deps.pathExists(path.join(hqRoot, "repos", repo.visibility, repo.name, ".git")),
|
|
966
|
+
);
|
|
967
|
+
if (missing.length === 0) {
|
|
968
|
+
console.log("replica-sync: all repos already present.");
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Private repos need gh auth first; public-only manifests skip the vault call.
|
|
973
|
+
if (missing.some((repo) => repo.visibility === "private")) {
|
|
974
|
+
authGitHubViaVault(deps);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
for (const repo of missing) {
|
|
978
|
+
deps.mkdirp(path.join(hqRoot, "repos", repo.visibility));
|
|
979
|
+
const dest = path.join(hqRoot, "repos", repo.visibility, repo.name);
|
|
980
|
+
console.log(`replica-sync: cloning ${repo.visibility}/${repo.name}…`);
|
|
981
|
+
runBestEffort(
|
|
982
|
+
deps,
|
|
983
|
+
"git",
|
|
984
|
+
["clone", "--recurse-submodules", repo.url, dest],
|
|
985
|
+
`clone ${repo.visibility}/${repo.name}`,
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
async function replicaSyncOutpost(
|
|
991
|
+
opts: { hqRoot?: string },
|
|
992
|
+
deps: ReplicaSyncDependencies,
|
|
993
|
+
): Promise<void> {
|
|
994
|
+
const hqRoot = opts.hqRoot ?? deps.defaultHqRoot();
|
|
995
|
+
|
|
996
|
+
// Gate: only replicate on a box that has an HQ session. A box that was never
|
|
997
|
+
// signed in has nothing to pull — exit cleanly so the timer stays green.
|
|
998
|
+
const session = deps.loadCachedTokens();
|
|
999
|
+
if (!session?.refreshToken) {
|
|
1000
|
+
console.log("replica-sync: no HQ session on this box yet — nothing to replicate.");
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Auth refresh gates the run: if the seeded refresh token is dead, every
|
|
1005
|
+
// authenticated step below (pull, secrets exec) would just cascade 401s.
|
|
1006
|
+
// Stop the cycle cleanly instead — the timer retries next tick.
|
|
1007
|
+
if (!runBestEffort(deps, "hq", ["auth", "refresh"], "HQ auth refresh")) {
|
|
1008
|
+
console.warn(
|
|
1009
|
+
"replica-sync: auth refresh failed — skipping this cycle (token likely expired).",
|
|
1010
|
+
);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
runBestEffort(
|
|
1014
|
+
deps,
|
|
1015
|
+
"hq",
|
|
1016
|
+
["sync", "pull", "--personal", "--hq-root", hqRoot, "--on-conflict", "keep"],
|
|
1017
|
+
"personal vault pull",
|
|
1018
|
+
);
|
|
1019
|
+
|
|
1020
|
+
// rescue lays down the core kernel and expects companies/ to exist.
|
|
1021
|
+
deps.mkdirp(path.join(hqRoot, "companies"));
|
|
1022
|
+
runBestEffort(
|
|
1023
|
+
deps,
|
|
1024
|
+
"hq",
|
|
1025
|
+
["rescue", "--hq-root", hqRoot, "--yes"],
|
|
1026
|
+
"HQ kernel rescue",
|
|
1027
|
+
);
|
|
1028
|
+
|
|
1029
|
+
replicateRepos(deps, hqRoot);
|
|
1030
|
+
console.log("replica-sync: complete.");
|
|
1031
|
+
}
|
|
1032
|
+
|
|
688
1033
|
export function registerOutpostsCommand(
|
|
689
1034
|
program: Command,
|
|
690
|
-
selfDeployOverrides: Partial<
|
|
1035
|
+
selfDeployOverrides: Partial<ReplicaSyncDependencies> = {},
|
|
691
1036
|
): void {
|
|
692
|
-
const
|
|
693
|
-
...
|
|
1037
|
+
const deps: ReplicaSyncDependencies = {
|
|
1038
|
+
...defaultReplicaSyncDependencies,
|
|
694
1039
|
...selfDeployOverrides,
|
|
695
1040
|
};
|
|
696
1041
|
const outposts = program
|
|
@@ -701,15 +1046,33 @@ export function registerOutpostsCommand(
|
|
|
701
1046
|
.command("self-deploy", { hidden: true })
|
|
702
1047
|
.description("Configure this EC2 host as a locally self-hosted HQ outpost")
|
|
703
1048
|
.option("--yes", "Skip the self-hosting confirmation")
|
|
704
|
-
.option("--hq-root <path>", "HQ root to sync",
|
|
1049
|
+
.option("--hq-root <path>", "HQ root to sync", deps.defaultHqRoot())
|
|
705
1050
|
.action(async (opts: { yes?: boolean; hqRoot?: string }) => {
|
|
706
1051
|
try {
|
|
707
|
-
await selfDeployOutpost(opts,
|
|
1052
|
+
await selfDeployOutpost(opts, deps);
|
|
708
1053
|
} catch (err) {
|
|
709
1054
|
fail(err);
|
|
710
1055
|
}
|
|
711
1056
|
});
|
|
712
1057
|
|
|
1058
|
+
outposts
|
|
1059
|
+
.command("replica-sync", { hidden: true })
|
|
1060
|
+
.description(
|
|
1061
|
+
"Replicate your full HQ onto this box (personal pull + core rescue + repos). Runs on a timer.",
|
|
1062
|
+
)
|
|
1063
|
+
.option("--hq-root <path>", "HQ root to replicate into", deps.defaultHqRoot())
|
|
1064
|
+
.action(async (opts: { hqRoot?: string }) => {
|
|
1065
|
+
// Best-effort background job: never hard-fail the systemd unit on a
|
|
1066
|
+
// transient step. Swallow, log, and exit 0.
|
|
1067
|
+
try {
|
|
1068
|
+
await replicaSyncOutpost(opts, deps);
|
|
1069
|
+
} catch (err) {
|
|
1070
|
+
console.warn(
|
|
1071
|
+
`replica-sync: unexpected error — ${err instanceof Error ? err.message : String(err)}`,
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
});
|
|
1075
|
+
|
|
713
1076
|
outposts
|
|
714
1077
|
.command("provision")
|
|
715
1078
|
.alias("create")
|
|
@@ -876,8 +1239,8 @@ export function registerOutpostsCommand(
|
|
|
876
1239
|
opts: { id?: string; json?: boolean },
|
|
877
1240
|
) {
|
|
878
1241
|
try {
|
|
879
|
-
const command = commandParts
|
|
880
|
-
if (!command) {
|
|
1242
|
+
const command = joinCommandParts(commandParts);
|
|
1243
|
+
if (!command.trim()) {
|
|
881
1244
|
console.error(chalk.red("No command given. Usage: hq outposts exec -- <command>"));
|
|
882
1245
|
process.exit(1);
|
|
883
1246
|
}
|
|
@@ -948,6 +1311,105 @@ export function registerOutpostsCommand(
|
|
|
948
1311
|
}
|
|
949
1312
|
});
|
|
950
1313
|
|
|
1314
|
+
outposts
|
|
1315
|
+
.command("exec-stage")
|
|
1316
|
+
.description("Stage a file for an asynchronous Outpost command")
|
|
1317
|
+
.requiredOption("--file <path>", "File to upload")
|
|
1318
|
+
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
1319
|
+
.option("--json", "Emit raw JSON")
|
|
1320
|
+
.action(async function (
|
|
1321
|
+
this: Command,
|
|
1322
|
+
opts: { file: string; id?: string; json?: boolean },
|
|
1323
|
+
) {
|
|
1324
|
+
try {
|
|
1325
|
+
const bytes = fs.readFileSync(opts.file);
|
|
1326
|
+
const token = await ensureCognitoToken();
|
|
1327
|
+
const staged = await stageExecInput(token, opts.id);
|
|
1328
|
+
const upload = await fetch(staged.putUrl, {
|
|
1329
|
+
method: "PUT",
|
|
1330
|
+
headers: { "Content-Length": String(bytes.byteLength) },
|
|
1331
|
+
body: bytes,
|
|
1332
|
+
});
|
|
1333
|
+
if (!upload.ok) {
|
|
1334
|
+
throw new Error(
|
|
1335
|
+
`Could not upload exec input: HTTP ${upload.status} ${upload.statusText}`,
|
|
1336
|
+
);
|
|
1337
|
+
}
|
|
1338
|
+
const output = { key: staged.key, getUrl: staged.getUrl };
|
|
1339
|
+
if (opts.json) {
|
|
1340
|
+
process.stdout.write(JSON.stringify(output) + "\n");
|
|
1341
|
+
} else {
|
|
1342
|
+
printKeyValues(output);
|
|
1343
|
+
}
|
|
1344
|
+
} catch (err) {
|
|
1345
|
+
fail(err);
|
|
1346
|
+
}
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
outposts
|
|
1350
|
+
.command("exec-submit <command...>")
|
|
1351
|
+
.description("Submit an asynchronous shell command to an Outpost")
|
|
1352
|
+
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
1353
|
+
.option("--json", "Emit raw JSON")
|
|
1354
|
+
.action(async function (
|
|
1355
|
+
this: Command,
|
|
1356
|
+
commandParts: string[],
|
|
1357
|
+
opts: { id?: string; json?: boolean },
|
|
1358
|
+
) {
|
|
1359
|
+
try {
|
|
1360
|
+
const command = joinCommandParts(commandParts);
|
|
1361
|
+
if (!command.trim()) {
|
|
1362
|
+
console.error(
|
|
1363
|
+
chalk.red("No command given. Usage: hq outposts exec-submit -- <command>"),
|
|
1364
|
+
);
|
|
1365
|
+
process.exit(1);
|
|
1366
|
+
}
|
|
1367
|
+
const token = await ensureCognitoToken();
|
|
1368
|
+
const submitted = await submitExec(token, command, opts.id);
|
|
1369
|
+
const output = { commandId: submitted.commandId };
|
|
1370
|
+
if (opts.json) {
|
|
1371
|
+
process.stdout.write(JSON.stringify(output) + "\n");
|
|
1372
|
+
} else {
|
|
1373
|
+
printKeyValues(output);
|
|
1374
|
+
}
|
|
1375
|
+
} catch (err) {
|
|
1376
|
+
fail(err);
|
|
1377
|
+
}
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
outposts
|
|
1381
|
+
.command("exec-result")
|
|
1382
|
+
.description("Fetch the result of an asynchronous Outpost command")
|
|
1383
|
+
.requiredOption("--command-id <commandId>", "Command id returned by exec-submit")
|
|
1384
|
+
.option("--id <outpostId>", "Outpost id (defaults to your primary box)")
|
|
1385
|
+
.option("--wait", "Poll until the command reaches a terminal state")
|
|
1386
|
+
.option("--json", "Emit raw JSON")
|
|
1387
|
+
.action(async function (
|
|
1388
|
+
this: Command,
|
|
1389
|
+
opts: { commandId: string; id?: string; wait?: boolean; json?: boolean },
|
|
1390
|
+
) {
|
|
1391
|
+
try {
|
|
1392
|
+
const token = await ensureCognitoToken();
|
|
1393
|
+
const result = opts.wait
|
|
1394
|
+
? await waitForExecResult(token, opts.commandId, opts.id)
|
|
1395
|
+
: await fetchExecResult(token, opts.commandId, opts.id);
|
|
1396
|
+
const output = {
|
|
1397
|
+
done: result.done,
|
|
1398
|
+
status: result.status,
|
|
1399
|
+
exitCode: result.exitCode ?? null,
|
|
1400
|
+
stdout: result.stdout ?? "",
|
|
1401
|
+
stderr: result.stderr ?? "",
|
|
1402
|
+
};
|
|
1403
|
+
if (opts.json) {
|
|
1404
|
+
process.stdout.write(JSON.stringify(output) + "\n");
|
|
1405
|
+
} else {
|
|
1406
|
+
printKeyValues(output);
|
|
1407
|
+
}
|
|
1408
|
+
} catch (err) {
|
|
1409
|
+
fail(err);
|
|
1410
|
+
}
|
|
1411
|
+
});
|
|
1412
|
+
|
|
951
1413
|
outposts
|
|
952
1414
|
.command("codex-enable")
|
|
953
1415
|
.description("Enable (or retry) Codex on an Outpost")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Packaging guard: every declared `bin` must ship EXECUTABLE.
|
|
3
|
+
*
|
|
4
|
+
* Regression pin for the "outpost is not CLI-ready" class of failure: `tsc`
|
|
5
|
+
* emits dist bins as 0644, and a root global install on a fresh outpost has
|
|
6
|
+
* been observed to leave the linked target non-executable, so `/usr/bin/hq`
|
|
7
|
+
* dies with "Permission denied" and the box never becomes usable. The build
|
|
8
|
+
* runs `scripts/chmod-bins.mjs` to 0755 the bins; this test locks that wiring
|
|
9
|
+
* in and, when a build has already produced `dist/`, asserts the bits directly.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
12
|
+
import { dirname, resolve } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { describe, expect, it } from "vitest";
|
|
15
|
+
|
|
16
|
+
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
+
const pkg = JSON.parse(
|
|
18
|
+
readFileSync(resolve(repoRoot, "package.json"), "utf8"),
|
|
19
|
+
) as {
|
|
20
|
+
bin?: Record<string, string> | string;
|
|
21
|
+
scripts?: Record<string, string>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const binMap =
|
|
25
|
+
typeof pkg.bin === "string" ? { hq: pkg.bin } : (pkg.bin ?? {});
|
|
26
|
+
|
|
27
|
+
describe("packaging: bin executable bit", () => {
|
|
28
|
+
it("wires the chmod-bins step into build and prepublishOnly", () => {
|
|
29
|
+
expect(pkg.scripts?.build ?? "").toContain("chmod-bins");
|
|
30
|
+
// prepublishOnly must rebuild so a publish always re-chmods, regardless of
|
|
31
|
+
// the publisher's local dist state.
|
|
32
|
+
expect(pkg.scripts?.prepublishOnly ?? "").toContain("build");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("declares at least one bin, all pointing at built .js files", () => {
|
|
36
|
+
const entries = Object.entries(binMap);
|
|
37
|
+
expect(entries.length).toBeGreaterThan(0);
|
|
38
|
+
for (const [, relPath] of entries) {
|
|
39
|
+
expect(relPath.endsWith(".js")).toBe(true);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("ships every bin executable once dist is built", () => {
|
|
44
|
+
for (const [name, relPath] of Object.entries(binMap)) {
|
|
45
|
+
const target = resolve(repoRoot, relPath);
|
|
46
|
+
// Pre-build (e.g. a bare `vitest` before `tsc`) → nothing to assert yet.
|
|
47
|
+
if (!existsSync(target)) continue;
|
|
48
|
+
const mode = statSync(target).mode;
|
|
49
|
+
// Owner-executable bit is the one that matters for `npm` bin linking.
|
|
50
|
+
expect(mode & 0o100, `${name} (${relPath}) must be executable`).toBe(
|
|
51
|
+
0o100,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|