@indigoai-us/hq-cli 5.60.0 → 5.61.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/dist/commands/pack-install.d.ts +7 -1
- package/dist/commands/pack-install.js +86 -15
- package/dist/commands/packs.d.ts +2 -1
- package/dist/commands/packs.js +13 -8
- package/dist/commands/secrets.d.ts +5 -0
- package/dist/commands/secrets.js +129 -5
- package/dist/index.d.ts +5 -3
- package/dist/index.js +14 -240
- package/dist/main.d.ts +7 -0
- package/dist/main.js +247 -0
- package/dist/utils/sandbox-runner-client.d.ts +13 -0
- package/dist/utils/sandbox-runner-client.js +82 -5
- package/dist/utils/version-check.d.ts +6 -0
- package/dist/utils/version-check.js +78 -2
- package/package.json +1 -1
- package/src/commands/pack-install.ts +115 -18
- package/src/commands/pack-update-cache.test.ts +149 -0
- package/src/commands/packs.ts +28 -7
- package/src/commands/secrets.test.ts +318 -0
- package/src/commands/secrets.ts +198 -4
- package/src/index.test.ts +32 -0
- package/src/index.ts +11 -274
- package/src/main.ts +283 -0
- package/src/utils/sandbox-runner-client.test.ts +128 -0
- package/src/utils/sandbox-runner-client.ts +99 -3
- package/src/utils/version-check.test.ts +30 -0
- package/src/utils/version-check.ts +72 -0
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="e77a0f25-ac1a-574f-a1e4-985c6b3306d7")}catch(e){}}();
|
|
3
3
|
const DEFAULT_SANDBOX_RUNNER_URL = "https://hqapi.getindigo.ai/sandbox";
|
|
4
|
+
const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
5
|
+
const DEFAULT_RETRY_OPTIONS = {
|
|
6
|
+
maxAttempts: 8,
|
|
7
|
+
maxElapsedMs: 90_000,
|
|
8
|
+
baseDelayMs: 500,
|
|
9
|
+
maxDelayMs: 5_000,
|
|
10
|
+
};
|
|
4
11
|
function normalizeBaseUrl(baseUrl) {
|
|
5
12
|
return baseUrl.replace(/\/+$/, "");
|
|
6
13
|
}
|
|
@@ -40,15 +47,85 @@ function delay(ms) {
|
|
|
40
47
|
return Promise.resolve();
|
|
41
48
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
42
49
|
}
|
|
50
|
+
function getErrorMessage(error) {
|
|
51
|
+
if (error instanceof Error) {
|
|
52
|
+
return error.message;
|
|
53
|
+
}
|
|
54
|
+
if (typeof error === "string") {
|
|
55
|
+
return error;
|
|
56
|
+
}
|
|
57
|
+
return String(error);
|
|
58
|
+
}
|
|
59
|
+
function parseRetryAfterMs(value) {
|
|
60
|
+
if (!value)
|
|
61
|
+
return undefined;
|
|
62
|
+
const seconds = Number(value);
|
|
63
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
64
|
+
return seconds * 1000;
|
|
65
|
+
}
|
|
66
|
+
const dateMs = Date.parse(value);
|
|
67
|
+
if (!Number.isNaN(dateMs)) {
|
|
68
|
+
return Math.max(0, dateMs - Date.now());
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
43
72
|
export class SandboxRunnerClient {
|
|
44
73
|
baseUrl;
|
|
45
74
|
fetchImpl;
|
|
75
|
+
retry;
|
|
76
|
+
sleep;
|
|
46
77
|
constructor(options = {}) {
|
|
47
78
|
this.baseUrl = normalizeBaseUrl(options.baseUrl ?? getSandboxRunnerBaseUrl());
|
|
48
79
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
80
|
+
this.retry = { ...DEFAULT_RETRY_OPTIONS, ...options.retry };
|
|
81
|
+
this.sleep = options.sleep ?? delay;
|
|
82
|
+
}
|
|
83
|
+
async fetchWithRetry(url, init) {
|
|
84
|
+
const startedAt = Date.now();
|
|
85
|
+
let lastError = "unknown error";
|
|
86
|
+
let attempts = 0;
|
|
87
|
+
for (let attempt = 1; attempt <= this.retry.maxAttempts; attempt += 1) {
|
|
88
|
+
attempts = attempt;
|
|
89
|
+
try {
|
|
90
|
+
const res = await this.fetchImpl(url, init);
|
|
91
|
+
if (!RETRYABLE_STATUS_CODES.has(res.status)) {
|
|
92
|
+
return res;
|
|
93
|
+
}
|
|
94
|
+
lastError = `HTTP ${res.status} ${res.statusText}`.trim();
|
|
95
|
+
if (!this.shouldRetry(attempt, startedAt)) {
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
await this.sleep(this.nextDelayMs(attempt, res));
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
lastError = getErrorMessage(error);
|
|
102
|
+
if (!this.shouldRetry(attempt, startedAt)) {
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
await this.sleep(this.nextDelayMs(attempt));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
throw new Error(`Sandbox Runner did not respond after ${attempts} attempts ` +
|
|
109
|
+
`(cold start or transient network); last error: ${lastError}`);
|
|
110
|
+
}
|
|
111
|
+
shouldRetry(attempt, startedAt) {
|
|
112
|
+
if (attempt >= this.retry.maxAttempts) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
return Date.now() - startedAt < this.retry.maxElapsedMs;
|
|
116
|
+
}
|
|
117
|
+
nextDelayMs(attempt, res) {
|
|
118
|
+
const retryAfterMs = parseRetryAfterMs(res?.headers.get("Retry-After") ?? null);
|
|
119
|
+
if (retryAfterMs !== undefined) {
|
|
120
|
+
return Math.min(retryAfterMs, this.retry.maxDelayMs);
|
|
121
|
+
}
|
|
122
|
+
const exponential = this.retry.baseDelayMs * 2 ** (attempt - 1);
|
|
123
|
+
const capped = Math.min(exponential, this.retry.maxDelayMs);
|
|
124
|
+
const jitter = Math.floor(Math.random() * Math.max(1, capped * 0.25));
|
|
125
|
+
return Math.min(capped + jitter, this.retry.maxDelayMs);
|
|
49
126
|
}
|
|
50
127
|
async startJob(token, request) {
|
|
51
|
-
const res = await this.
|
|
128
|
+
const res = await this.fetchWithRetry(`${this.baseUrl}/jobs`, {
|
|
52
129
|
method: "POST",
|
|
53
130
|
headers: {
|
|
54
131
|
Authorization: `Bearer ${token}`,
|
|
@@ -75,7 +152,7 @@ export class SandboxRunnerClient {
|
|
|
75
152
|
};
|
|
76
153
|
}
|
|
77
154
|
async getJob(token, jobId) {
|
|
78
|
-
const res = await this.
|
|
155
|
+
const res = await this.fetchWithRetry(`${this.baseUrl}/jobs/${encodeURIComponent(jobId)}`, {
|
|
79
156
|
headers: { Authorization: `Bearer ${token}` },
|
|
80
157
|
});
|
|
81
158
|
const body = await parseJsonResponse(res);
|
|
@@ -97,10 +174,10 @@ export class SandboxRunnerClient {
|
|
|
97
174
|
if (job.status === "succeeded" || job.status === "failed") {
|
|
98
175
|
return job;
|
|
99
176
|
}
|
|
100
|
-
await
|
|
177
|
+
await this.sleep(intervalMs);
|
|
101
178
|
}
|
|
102
179
|
throw new Error(`Sandbox Runner job '${jobId}' did not finish before the poll limit.`);
|
|
103
180
|
}
|
|
104
181
|
}
|
|
105
182
|
//# sourceMappingURL=sandbox-runner-client.js.map
|
|
106
|
-
//# debugId=
|
|
183
|
+
//# debugId=e77a0f25-ac1a-574f-a1e4-985c6b3306d7
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
declare function isKnownNoninteractiveStatusProbe(argv?: readonly string[]): boolean;
|
|
1
2
|
export declare function maybeWarnNewVersion(): void;
|
|
2
3
|
export declare function refreshVersionCache(): Promise<void>;
|
|
4
|
+
export declare const __test__: {
|
|
5
|
+
CACHE_TTL_MS: number;
|
|
6
|
+
isKnownNoninteractiveStatusProbe: typeof isKnownNoninteractiveStatusProbe;
|
|
7
|
+
};
|
|
8
|
+
export {};
|
|
3
9
|
//# sourceMappingURL=version-check.d.ts.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
2
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="f7058fc6-251e-5e11-af6c-de15f6e256f3")}catch(e){}}();
|
|
3
3
|
import * as fs from "fs";
|
|
4
4
|
import * as os from "os";
|
|
5
5
|
import * as path from "path";
|
|
@@ -9,10 +9,15 @@ import { CLI_VERSION } from "../cli-version.js";
|
|
|
9
9
|
const PACKAGE_NAME = "@indigoai-us/hq-cli";
|
|
10
10
|
const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}/latest`;
|
|
11
11
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
const CACHE_TTL_JITTER_MS = 60 * 60 * 1000;
|
|
12
13
|
const FETCH_TIMEOUT_MS = 3_000;
|
|
14
|
+
const REFRESH_LOCK_STALE_MS = 10 * 60 * 1000;
|
|
13
15
|
function cachePath() {
|
|
14
16
|
return path.join(os.homedir(), ".hq", "version-check.json");
|
|
15
17
|
}
|
|
18
|
+
function lockPath() {
|
|
19
|
+
return path.join(os.homedir(), ".hq", "version-check.lock");
|
|
20
|
+
}
|
|
16
21
|
function isOptedOut() {
|
|
17
22
|
return process.env.HQ_NO_UPDATE_CHECK === "1";
|
|
18
23
|
}
|
|
@@ -40,6 +45,59 @@ function writeCache(entry) {
|
|
|
40
45
|
// best-effort; never break the CLI on cache write failure
|
|
41
46
|
}
|
|
42
47
|
}
|
|
48
|
+
function freshEnough(entry, now = Date.now()) {
|
|
49
|
+
const jitter = Math.floor(Math.random() * CACHE_TTL_JITTER_MS);
|
|
50
|
+
return now - entry.fetchedAt <= CACHE_TTL_MS - jitter;
|
|
51
|
+
}
|
|
52
|
+
function isKnownNoninteractiveStatusProbe(argv = process.argv) {
|
|
53
|
+
const args = argv.slice(2);
|
|
54
|
+
const positional = args.filter((arg) => !arg.startsWith("-"));
|
|
55
|
+
const json = args.includes("--json") || !process.stdout.isTTY;
|
|
56
|
+
if (!json)
|
|
57
|
+
return false;
|
|
58
|
+
if (positional[0] === "mcp" && positional[1] === "status")
|
|
59
|
+
return true;
|
|
60
|
+
if (positional[0] === "packs" && (positional[1] === "list" || positional[1] === "ls"))
|
|
61
|
+
return true;
|
|
62
|
+
if (positional[0] === "packages" &&
|
|
63
|
+
positional[1] === "packs" &&
|
|
64
|
+
(positional[2] === "list" || positional[2] === "ls")) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
if ((positional[0] === "sources" || positional[0] === "signals") && positional[1] === "list") {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
function acquireRefreshLock(now = Date.now()) {
|
|
73
|
+
const dir = lockPath();
|
|
74
|
+
try {
|
|
75
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
76
|
+
fs.mkdirSync(dir);
|
|
77
|
+
fs.writeFileSync(path.join(dir, "owner"), `${process.pid}\n${now}\n`);
|
|
78
|
+
return () => {
|
|
79
|
+
try {
|
|
80
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// best-effort lock cleanup
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
try {
|
|
89
|
+
const stat = fs.statSync(dir);
|
|
90
|
+
if (now - stat.mtimeMs > REFRESH_LOCK_STALE_MS) {
|
|
91
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
92
|
+
return acquireRefreshLock(now);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// ignore lock inspection failures
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
43
101
|
export function maybeWarnNewVersion() {
|
|
44
102
|
if (isOptedOut())
|
|
45
103
|
return;
|
|
@@ -60,7 +118,18 @@ export function maybeWarnNewVersion() {
|
|
|
60
118
|
export async function refreshVersionCache() {
|
|
61
119
|
if (isOptedOut())
|
|
62
120
|
return;
|
|
121
|
+
if (isKnownNoninteractiveStatusProbe())
|
|
122
|
+
return;
|
|
123
|
+
const existing = readCache();
|
|
124
|
+
if (existing && freshEnough(existing))
|
|
125
|
+
return;
|
|
126
|
+
const releaseLock = acquireRefreshLock();
|
|
127
|
+
if (!releaseLock)
|
|
128
|
+
return;
|
|
63
129
|
try {
|
|
130
|
+
const lockedExisting = readCache();
|
|
131
|
+
if (lockedExisting && freshEnough(lockedExisting))
|
|
132
|
+
return;
|
|
64
133
|
const res = await fetch(REGISTRY_URL, {
|
|
65
134
|
headers: { Accept: "application/json" },
|
|
66
135
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
@@ -75,6 +144,13 @@ export async function refreshVersionCache() {
|
|
|
75
144
|
catch {
|
|
76
145
|
// best-effort; offline / registry down / timeout — silent
|
|
77
146
|
}
|
|
147
|
+
finally {
|
|
148
|
+
releaseLock();
|
|
149
|
+
}
|
|
78
150
|
}
|
|
151
|
+
export const __test__ = {
|
|
152
|
+
CACHE_TTL_MS,
|
|
153
|
+
isKnownNoninteractiveStatusProbe,
|
|
154
|
+
};
|
|
79
155
|
//# sourceMappingURL=version-check.js.map
|
|
80
|
-
//# debugId=
|
|
156
|
+
//# debugId=f7058fc6-251e-5e11-af6c-de15f6e256f3
|
package/package.json
CHANGED
|
@@ -68,6 +68,80 @@ import {
|
|
|
68
68
|
import { readCache, listSecretCacheScopes } from '../utils/secrets-cache.js';
|
|
69
69
|
import type { PackManifest, PackContributeKey } from '../types.js';
|
|
70
70
|
|
|
71
|
+
const PACK_UPDATE_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
|
|
72
|
+
const PACK_UPDATE_FETCH_TIMEOUT_MS = 3_000;
|
|
73
|
+
|
|
74
|
+
interface PackUpdateCacheEntry {
|
|
75
|
+
latest: string;
|
|
76
|
+
fetchedAt: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface PackUpdateCacheFile {
|
|
80
|
+
entries: Record<string, PackUpdateCacheEntry>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ResolveLatestOptions {
|
|
84
|
+
forceRefresh?: boolean;
|
|
85
|
+
now?: number;
|
|
86
|
+
cacheTtlMs?: number;
|
|
87
|
+
fetchImpl?: typeof fetch;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const gitLsRemoteMemo = new Map<string, string>();
|
|
91
|
+
|
|
92
|
+
function packUpdateCachePath(): string {
|
|
93
|
+
return path.join(os.homedir(), '.hq', 'pack-update-cache.json');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readPackUpdateCache(): PackUpdateCacheFile {
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(fs.readFileSync(packUpdateCachePath(), 'utf-8')) as Partial<PackUpdateCacheFile>;
|
|
99
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.entries || typeof parsed.entries !== 'object') {
|
|
100
|
+
return { entries: {} };
|
|
101
|
+
}
|
|
102
|
+
return { entries: parsed.entries as Record<string, PackUpdateCacheEntry> };
|
|
103
|
+
} catch {
|
|
104
|
+
return { entries: {} };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function writePackUpdateCache(cache: PackUpdateCacheFile): void {
|
|
109
|
+
try {
|
|
110
|
+
const file = packUpdateCachePath();
|
|
111
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
112
|
+
fs.writeFileSync(file, JSON.stringify(cache));
|
|
113
|
+
} catch {
|
|
114
|
+
// best-effort; update checks must never fail because the cache is unwritable
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function cachedLatest(cacheKey: string, opts: ResolveLatestOptions): string | undefined {
|
|
119
|
+
if (opts.forceRefresh) return undefined;
|
|
120
|
+
const entry = readPackUpdateCache().entries[cacheKey];
|
|
121
|
+
if (!entry || typeof entry.latest !== 'string' || typeof entry.fetchedAt !== 'number') return undefined;
|
|
122
|
+
const now = opts.now ?? Date.now();
|
|
123
|
+
const ttl = opts.cacheTtlMs ?? PACK_UPDATE_CACHE_TTL_MS;
|
|
124
|
+
return now - entry.fetchedAt <= ttl ? entry.latest : undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function storeCachedLatest(cacheKey: string, latest: string, opts: ResolveLatestOptions): void {
|
|
128
|
+
const cache = readPackUpdateCache();
|
|
129
|
+
cache.entries[cacheKey] = { latest, fetchedAt: opts.now ?? Date.now() };
|
|
130
|
+
writePackUpdateCache(cache);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function latestWithDiskCache(
|
|
134
|
+
cacheKey: string,
|
|
135
|
+
opts: ResolveLatestOptions,
|
|
136
|
+
refresh: () => Promise<string | undefined> | string | undefined,
|
|
137
|
+
): Promise<string | undefined> {
|
|
138
|
+
const cached = cachedLatest(cacheKey, opts);
|
|
139
|
+
if (cached) return cached;
|
|
140
|
+
const latest = await refresh();
|
|
141
|
+
if (latest) storeCachedLatest(cacheKey, latest, opts);
|
|
142
|
+
return latest;
|
|
143
|
+
}
|
|
144
|
+
|
|
71
145
|
// ---------------------------------------------------------------------------
|
|
72
146
|
// Source classification
|
|
73
147
|
// ---------------------------------------------------------------------------
|
|
@@ -676,17 +750,37 @@ function rsyncDir(src: string, dest: string): void {
|
|
|
676
750
|
*/
|
|
677
751
|
function isNamedRef(url: string, ref: string): boolean {
|
|
678
752
|
try {
|
|
679
|
-
const out =
|
|
680
|
-
'git',
|
|
681
|
-
['ls-remote', '--heads', '--tags', url, ref],
|
|
682
|
-
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
683
|
-
);
|
|
753
|
+
const out = gitLsRemote(['--heads', '--tags', url, ref]);
|
|
684
754
|
return out.trim().length > 0;
|
|
685
755
|
} catch {
|
|
686
756
|
return false;
|
|
687
757
|
}
|
|
688
758
|
}
|
|
689
759
|
|
|
760
|
+
function gitLsRemote(args: string[]): string {
|
|
761
|
+
const key = args.join('\0');
|
|
762
|
+
const cached = gitLsRemoteMemo.get(key);
|
|
763
|
+
if (cached !== undefined) return cached;
|
|
764
|
+
const out = execFileSync(
|
|
765
|
+
'git',
|
|
766
|
+
['ls-remote', ...args],
|
|
767
|
+
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
768
|
+
);
|
|
769
|
+
gitLsRemoteMemo.set(key, out);
|
|
770
|
+
return out;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
async function fetchLatestNpmVersion(pkg: string, opts: ResolveLatestOptions): Promise<string | undefined> {
|
|
774
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
775
|
+
const res = await fetchImpl(`https://registry.npmjs.org/${encodeURIComponent(pkg)}/latest`, {
|
|
776
|
+
headers: { Accept: 'application/json' },
|
|
777
|
+
signal: AbortSignal.timeout(PACK_UPDATE_FETCH_TIMEOUT_MS),
|
|
778
|
+
});
|
|
779
|
+
if (!res.ok) throw new Error(`registry returned ${res.status}`);
|
|
780
|
+
const body = (await res.json()) as { version?: unknown };
|
|
781
|
+
return typeof body.version === 'string' ? body.version : undefined;
|
|
782
|
+
}
|
|
783
|
+
|
|
690
784
|
// ---------------------------------------------------------------------------
|
|
691
785
|
// Update-availability probe (no install) — used by `hq packs update --check-only`
|
|
692
786
|
// ---------------------------------------------------------------------------
|
|
@@ -711,6 +805,10 @@ function gitRefFromSource(source: string): string | undefined {
|
|
|
711
805
|
return ref;
|
|
712
806
|
}
|
|
713
807
|
|
|
808
|
+
function isFullGitSha(ref: string): boolean {
|
|
809
|
+
return /^[0-9a-f]{40}$/i.test(ref);
|
|
810
|
+
}
|
|
811
|
+
|
|
714
812
|
/**
|
|
715
813
|
* Probe whether a newer version of an already-installed pack is available,
|
|
716
814
|
* WITHOUT fetching or installing. Reuses the same git/npm primitives as the
|
|
@@ -720,10 +818,11 @@ function gitRefFromSource(source: string): string | undefined {
|
|
|
720
818
|
* @param source the stamped `source:` from the installed package.yaml
|
|
721
819
|
* @param installedVersion the installed pack's manifest `version` (npm compare)
|
|
722
820
|
*/
|
|
723
|
-
export function resolveLatest(
|
|
821
|
+
export async function resolveLatest(
|
|
724
822
|
source: string,
|
|
725
823
|
installedVersion?: string,
|
|
726
|
-
|
|
824
|
+
opts: ResolveLatestOptions = {},
|
|
825
|
+
): Promise<LatestResult> {
|
|
727
826
|
let transport: Transport;
|
|
728
827
|
try {
|
|
729
828
|
transport = classify(source);
|
|
@@ -755,15 +854,14 @@ export function resolveLatest(
|
|
|
755
854
|
const current =
|
|
756
855
|
installedVersion ?? (source.lastIndexOf('@') > 0 ? source.slice(source.lastIndexOf('@') + 1) : undefined);
|
|
757
856
|
try {
|
|
758
|
-
const latest =
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
}).trim();
|
|
857
|
+
const latest = await latestWithDiskCache(`npm:${pkg}`, opts, () =>
|
|
858
|
+
fetchLatestNpmVersion(pkg, opts),
|
|
859
|
+
);
|
|
762
860
|
const updateAvailable =
|
|
763
861
|
current && latest ? semverGt(latest, current) : null;
|
|
764
862
|
return { transport, current, latest, updateAvailable };
|
|
765
863
|
} catch (e) {
|
|
766
|
-
return { transport, current, updateAvailable: null, error: `npm
|
|
864
|
+
return { transport, current, updateAvailable: null, error: `npm registry check failed: ${(e as Error).message}` };
|
|
767
865
|
}
|
|
768
866
|
}
|
|
769
867
|
|
|
@@ -778,13 +876,12 @@ export function resolveLatest(
|
|
|
778
876
|
const current = gitRefFromSource(source);
|
|
779
877
|
// If install followed a named ref (branch/tag), compare that ref's tip;
|
|
780
878
|
// otherwise (default SHA-pin) compare the default branch HEAD.
|
|
781
|
-
const refArg = current && isNamedRef(url, current) ? current : 'HEAD';
|
|
879
|
+
const refArg = current && !isFullGitSha(current) && isNamedRef(url, current) ? current : 'HEAD';
|
|
782
880
|
try {
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
})
|
|
787
|
-
const latest = out.split(/\s+/)[0] || undefined;
|
|
881
|
+
const latest = await latestWithDiskCache(`git:${url}#${refArg}`, opts, () => {
|
|
882
|
+
const out = gitLsRemote([url, refArg]).trim();
|
|
883
|
+
return out.split(/\s+/)[0] || undefined;
|
|
884
|
+
});
|
|
788
885
|
const updateAvailable =
|
|
789
886
|
current && latest ? !latest.startsWith(current) && !current.startsWith(latest) : null;
|
|
790
887
|
return { transport, current, latest, updateAvailable };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import { execFileSync } from 'child_process';
|
|
6
|
+
|
|
7
|
+
vi.mock('child_process', () => ({
|
|
8
|
+
execFileSync: vi.fn(),
|
|
9
|
+
spawnSync: vi.fn(() => ({ status: 0 })),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock('../utils/vault-api.js', () => ({
|
|
13
|
+
vaultApiFetchPublic: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const tmpHome = path.join(os.tmpdir(), `hq-pack-update-cache-${process.pid}`);
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
20
|
+
fs.mkdirSync(tmpHome, { recursive: true });
|
|
21
|
+
vi.stubEnv('HOME', tmpHome);
|
|
22
|
+
vi.mocked(execFileSync).mockReset();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.unstubAllEnvs();
|
|
27
|
+
vi.restoreAllMocks();
|
|
28
|
+
vi.unstubAllGlobals();
|
|
29
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
async function loadModule() {
|
|
33
|
+
vi.resetModules();
|
|
34
|
+
return await import('./pack-install.js');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('resolveLatest pack update cache', () => {
|
|
38
|
+
it('fetches npm registry metadata over HTTP once, then compares cached latest against the installed version at read time', async () => {
|
|
39
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
40
|
+
ok: true,
|
|
41
|
+
json: async () => ({ version: '2.0.0' }),
|
|
42
|
+
} as unknown as Response);
|
|
43
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
44
|
+
|
|
45
|
+
const { resolveLatest } = await loadModule();
|
|
46
|
+
|
|
47
|
+
const staleInstall = await resolveLatest('@scope/pack@1.0.0', '1.0.0', {
|
|
48
|
+
forceRefresh: true,
|
|
49
|
+
});
|
|
50
|
+
const currentInstall = await resolveLatest('@scope/pack@1.0.0', '2.0.0');
|
|
51
|
+
|
|
52
|
+
expect(staleInstall).toMatchObject({
|
|
53
|
+
transport: 'npm',
|
|
54
|
+
current: '1.0.0',
|
|
55
|
+
latest: '2.0.0',
|
|
56
|
+
updateAvailable: true,
|
|
57
|
+
});
|
|
58
|
+
expect(currentInstall).toMatchObject({
|
|
59
|
+
transport: 'npm',
|
|
60
|
+
current: '2.0.0',
|
|
61
|
+
latest: '2.0.0',
|
|
62
|
+
updateAvailable: false,
|
|
63
|
+
});
|
|
64
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
65
|
+
expect(vi.mocked(execFileSync)).not.toHaveBeenCalledWith(
|
|
66
|
+
'npm',
|
|
67
|
+
['view', '@scope/pack', 'version'],
|
|
68
|
+
expect.anything(),
|
|
69
|
+
);
|
|
70
|
+
expect(fs.existsSync(path.join(tmpHome, '.hq', 'pack-update-cache.json'))).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('bypasses a fresh npm cache entry when forceRefresh is set', async () => {
|
|
74
|
+
const fetchMock = vi
|
|
75
|
+
.fn()
|
|
76
|
+
.mockResolvedValueOnce({
|
|
77
|
+
ok: true,
|
|
78
|
+
json: async () => ({ version: '2.0.0' }),
|
|
79
|
+
} as unknown as Response)
|
|
80
|
+
.mockResolvedValueOnce({
|
|
81
|
+
ok: true,
|
|
82
|
+
json: async () => ({ version: '3.0.0' }),
|
|
83
|
+
} as unknown as Response);
|
|
84
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
85
|
+
|
|
86
|
+
const { resolveLatest } = await loadModule();
|
|
87
|
+
|
|
88
|
+
await resolveLatest('@scope/pack@1.0.0', '1.0.0', { forceRefresh: true });
|
|
89
|
+
const refreshed = await resolveLatest('@scope/pack@1.0.0', '1.0.0', {
|
|
90
|
+
forceRefresh: true,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(refreshed).toMatchObject({
|
|
94
|
+
latest: '3.0.0',
|
|
95
|
+
updateAvailable: true,
|
|
96
|
+
});
|
|
97
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('shares git ls-remote probes by URL and ref within one process', async () => {
|
|
101
|
+
vi.mocked(execFileSync).mockImplementation((_cmd, args) => {
|
|
102
|
+
const argv = args as string[];
|
|
103
|
+
if (argv[1] === '--heads') return '';
|
|
104
|
+
if (argv[0] === 'ls-remote') return 'abcdef1234567890\tHEAD\n';
|
|
105
|
+
throw new Error(`unexpected command: ${argv.join(' ')}`);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const { resolveLatest } = await loadModule();
|
|
109
|
+
|
|
110
|
+
const first = await resolveLatest('https://example.test/repo.git#123456', '1.0.0');
|
|
111
|
+
const second = await resolveLatest('https://example.test/repo.git#123456', '1.0.0');
|
|
112
|
+
|
|
113
|
+
expect(first.latest).toBe('abcdef1234567890');
|
|
114
|
+
expect(second.latest).toBe('abcdef1234567890');
|
|
115
|
+
const lsRemoteCalls = vi
|
|
116
|
+
.mocked(execFileSync)
|
|
117
|
+
.mock.calls.filter(([cmd, args]) => cmd === 'git' && (args as string[])[0] === 'ls-remote');
|
|
118
|
+
expect(lsRemoteCalls).toHaveLength(2);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('uses a fresh git disk cache entry without probing git for SHA-pinned sources', async () => {
|
|
122
|
+
const installedSha = '1111111111111111111111111111111111111111';
|
|
123
|
+
vi.mocked(execFileSync).mockImplementation((_cmd, args) => {
|
|
124
|
+
const argv = args as string[];
|
|
125
|
+
if (argv[0] === 'ls-remote') return 'abcdef1234567890\tHEAD\n';
|
|
126
|
+
throw new Error(`unexpected command: ${argv.join(' ')}`);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const firstModule = await loadModule();
|
|
130
|
+
await firstModule.resolveLatest(`https://example.test/repo.git#${installedSha}`, '1.0.0', {
|
|
131
|
+
forceRefresh: true,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
vi.mocked(execFileSync).mockReset();
|
|
135
|
+
const secondModule = await loadModule();
|
|
136
|
+
const cached = await secondModule.resolveLatest(
|
|
137
|
+
`https://example.test/repo.git#${installedSha}`,
|
|
138
|
+
'1.0.0',
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
expect(cached).toMatchObject({
|
|
142
|
+
transport: 'git',
|
|
143
|
+
current: installedSha,
|
|
144
|
+
latest: 'abcdef1234567890',
|
|
145
|
+
updateAvailable: true,
|
|
146
|
+
});
|
|
147
|
+
expect(vi.mocked(execFileSync)).not.toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
});
|
package/src/commands/packs.ts
CHANGED
|
@@ -144,6 +144,7 @@ export function buildInstalledView(
|
|
|
144
144
|
pack: InstalledPack,
|
|
145
145
|
installedSources: Set<string>,
|
|
146
146
|
checkUpdates: boolean,
|
|
147
|
+
latestProbe?: LatestResult,
|
|
147
148
|
): InstalledPackView {
|
|
148
149
|
if (!pack.manifest) {
|
|
149
150
|
return {
|
|
@@ -178,7 +179,7 @@ export function buildInstalledView(
|
|
|
178
179
|
|
|
179
180
|
let updateAvailable: boolean | null = null;
|
|
180
181
|
if (checkUpdates && m.source) {
|
|
181
|
-
updateAvailable =
|
|
182
|
+
updateAvailable = latestProbe?.updateAvailable ?? null;
|
|
182
183
|
}
|
|
183
184
|
|
|
184
185
|
// US-005 — surface the pack's `initialization` block so the HQ Sync
|
|
@@ -214,15 +215,27 @@ export function buildInstalledView(
|
|
|
214
215
|
};
|
|
215
216
|
}
|
|
216
217
|
|
|
217
|
-
function buildListView(
|
|
218
|
+
async function buildListView(
|
|
219
|
+
hqRoot: string,
|
|
220
|
+
checkUpdates: boolean,
|
|
221
|
+
evalConditionals: boolean,
|
|
222
|
+
refreshUpdates: boolean,
|
|
223
|
+
): Promise<PacksListView> {
|
|
218
224
|
const hqVersion = readHqVersion(hqRoot);
|
|
219
225
|
const packs = listInstalledPacks(hqRoot);
|
|
220
226
|
const catalog = readRecommendedPackages(hqRoot);
|
|
221
227
|
const catalogSources = new Set(catalog.map((c) => c.source));
|
|
222
228
|
const warnings: string[] = [];
|
|
223
229
|
|
|
224
|
-
const
|
|
225
|
-
|
|
230
|
+
const latestProbes = await Promise.all(
|
|
231
|
+
packs.map((p) =>
|
|
232
|
+
checkUpdates && p.manifest?.source
|
|
233
|
+
? resolveLatest(p.manifest.source, p.manifest.version, { forceRefresh: refreshUpdates })
|
|
234
|
+
: Promise.resolve(undefined),
|
|
235
|
+
),
|
|
236
|
+
);
|
|
237
|
+
const installed = packs.map((p, i) =>
|
|
238
|
+
buildInstalledView(hqRoot, hqVersion, p, catalogSources, checkUpdates, latestProbes[i]),
|
|
226
239
|
);
|
|
227
240
|
for (const p of installed) {
|
|
228
241
|
if (p.error) warnings.push(`${p.name}: ${p.error}`);
|
|
@@ -303,6 +316,7 @@ interface UpdateCheck {
|
|
|
303
316
|
|
|
304
317
|
interface UpdateOpts extends CommonOpts {
|
|
305
318
|
checkOnly?: boolean;
|
|
319
|
+
refresh?: boolean;
|
|
306
320
|
yes?: boolean;
|
|
307
321
|
allowHooks?: boolean;
|
|
308
322
|
allowMcp?: boolean;
|
|
@@ -334,7 +348,7 @@ async function runUpdate(name: string | undefined, opts: UpdateOpts): Promise<Up
|
|
|
334
348
|
const probe: LatestResult =
|
|
335
349
|
safeClassify(source) === 'marketplace'
|
|
336
350
|
? await resolveLatestMarketplace(source, m.version)
|
|
337
|
-
: resolveLatest(source, m.version);
|
|
351
|
+
: await resolveLatest(source, m.version, { forceRefresh: true });
|
|
338
352
|
const base: UpdateCheck = {
|
|
339
353
|
name: pname,
|
|
340
354
|
transport: probe.transport,
|
|
@@ -517,11 +531,17 @@ export function registerPacksCommand(parent: Command): void {
|
|
|
517
531
|
.option('--json', 'Machine-readable JSON output')
|
|
518
532
|
.option('--hq-root <path>', 'HQ root (default: auto-detect)')
|
|
519
533
|
.option('--check-updates', 'Probe each pack for available updates (network I/O)')
|
|
534
|
+
.option('--refresh', 'Bypass cached update probes')
|
|
520
535
|
.option('--eval-conditionals', 'Evaluate catalog conditional predicates (runs bash)')
|
|
521
536
|
.action(
|
|
522
|
-
async (opts: CommonOpts & { checkUpdates?: boolean; evalConditionals?: boolean }) => {
|
|
537
|
+
async (opts: CommonOpts & { checkUpdates?: boolean; evalConditionals?: boolean; refresh?: boolean }) => {
|
|
523
538
|
try {
|
|
524
|
-
const view = buildListView(
|
|
539
|
+
const view = await buildListView(
|
|
540
|
+
resolveRoot(opts),
|
|
541
|
+
!!opts.checkUpdates,
|
|
542
|
+
!!opts.evalConditionals,
|
|
543
|
+
!!opts.refresh,
|
|
544
|
+
);
|
|
525
545
|
if (wantsJson(opts)) emitJson(view);
|
|
526
546
|
else printListHuman(view);
|
|
527
547
|
} catch (e) {
|
|
@@ -537,6 +557,7 @@ export function registerPacksCommand(parent: Command): void {
|
|
|
537
557
|
.option('--json', 'Machine-readable JSON output')
|
|
538
558
|
.option('--hq-root <path>', 'HQ root (default: auto-detect)')
|
|
539
559
|
.option('--check-only', 'Report availability without installing')
|
|
560
|
+
.option('--refresh', 'Bypass cached update probes (update refreshes by default)')
|
|
540
561
|
.option('-y, --yes', 'Non-interactive (implies --allow-hooks and --allow-mcp)')
|
|
541
562
|
.option('--allow-hooks', 'Install pack hooks without prompting')
|
|
542
563
|
.option('--allow-mcp', 'Register pack MCP servers without prompting')
|