@outputai/cli 0.7.1-next.ae5bab4.0 → 0.7.1-next.d67ad85.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/bin/run.js +1 -1
- package/dist/api/generated/api.d.ts +38 -0
- package/dist/assets/docker/docker-compose-dev.yml +1 -1
- package/dist/commands/update.js +1 -1
- package/dist/generated/framework_version.json +1 -1
- package/dist/hooks/init.js +12 -3
- package/dist/hooks/init.spec.js +18 -8
- package/dist/scripts/refresh_version_check.d.ts +1 -0
- package/dist/scripts/refresh_version_check.js +9 -0
- package/dist/services/npm_update_service.js +11 -3
- package/dist/services/npm_update_service.spec.js +20 -7
- package/dist/services/version_check.d.ts +19 -1
- package/dist/services/version_check.js +53 -17
- package/dist/services/version_check.spec.js +88 -58
- package/dist/utils/proxy.d.ts +3 -2
- package/dist/utils/proxy.js +4 -3
- package/dist/utils/proxy.spec.js +4 -4
- package/oclif.manifest.json +1428 -0
- package/package.json +6 -5
package/bin/run.js
CHANGED
|
@@ -7,7 +7,7 @@ import { loadCredentialRefs } from '../dist/utils/credentials_loader.js';
|
|
|
7
7
|
|
|
8
8
|
// Load environment variables from .env files before executing CLI
|
|
9
9
|
loadEnvironment();
|
|
10
|
-
bootstrapProxy();
|
|
10
|
+
await bootstrapProxy();
|
|
11
11
|
loadCredentialRefs();
|
|
12
12
|
|
|
13
13
|
await execute( { dir: import.meta.url } );
|
|
@@ -226,6 +226,39 @@ export declare const WorkflowResultResponseStatus: {
|
|
|
226
226
|
readonly timed_out: "timed_out";
|
|
227
227
|
readonly continued: "continued";
|
|
228
228
|
};
|
|
229
|
+
/**
|
|
230
|
+
* Structured failure details if the workflow failed, null otherwise
|
|
231
|
+
* @nullable
|
|
232
|
+
*/
|
|
233
|
+
export type WorkflowResultResponseErrorDetails = {
|
|
234
|
+
/**
|
|
235
|
+
* Friendly failure message (from the underlying application error)
|
|
236
|
+
* @nullable
|
|
237
|
+
*/
|
|
238
|
+
message?: string | null;
|
|
239
|
+
/**
|
|
240
|
+
* Error name/type (the original error's class)
|
|
241
|
+
* @nullable
|
|
242
|
+
*/
|
|
243
|
+
name?: string | null;
|
|
244
|
+
/**
|
|
245
|
+
* Whether Temporal flagged the failure retryable; null if unknown
|
|
246
|
+
* @nullable
|
|
247
|
+
*/
|
|
248
|
+
retryable?: boolean | null;
|
|
249
|
+
/**
|
|
250
|
+
* Failing activity key ("workflow#step"); null if no activity failed
|
|
251
|
+
* @nullable
|
|
252
|
+
*/
|
|
253
|
+
activityId?: string | null;
|
|
254
|
+
/**
|
|
255
|
+
* Sanitized error cause chain (name/message per level, no stack)
|
|
256
|
+
* @nullable
|
|
257
|
+
*/
|
|
258
|
+
cause?: {
|
|
259
|
+
[key: string]: unknown;
|
|
260
|
+
} | null;
|
|
261
|
+
} | null | null;
|
|
229
262
|
export interface WorkflowResultResponse {
|
|
230
263
|
/** The workflow execution id */
|
|
231
264
|
workflowId?: string;
|
|
@@ -248,6 +281,11 @@ export interface WorkflowResultResponse {
|
|
|
248
281
|
* @nullable
|
|
249
282
|
*/
|
|
250
283
|
error?: string | null;
|
|
284
|
+
/**
|
|
285
|
+
* Structured failure details if the workflow failed, null otherwise
|
|
286
|
+
* @nullable
|
|
287
|
+
*/
|
|
288
|
+
errorDetails?: WorkflowResultResponseErrorDetails;
|
|
251
289
|
}
|
|
252
290
|
export interface StopWorkflowResponse {
|
|
253
291
|
workflowId?: string;
|
package/dist/commands/update.js
CHANGED
|
@@ -31,7 +31,7 @@ export default class Update extends Command {
|
|
|
31
31
|
async updateCli() {
|
|
32
32
|
const latest = await fetchLatestVersion();
|
|
33
33
|
if (!latest) {
|
|
34
|
-
this.error('Could not fetch the latest version from npm.
|
|
34
|
+
this.error('Could not fetch the latest version from the npm registry. Run with DEBUG=output-cli:npm-update for details.');
|
|
35
35
|
}
|
|
36
36
|
this.log(`\nLatest @outputai/cli version: v${latest}\n`);
|
|
37
37
|
await this.handleGlobalUpdate(latest);
|
package/dist/hooks/init.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { ux } from '@oclif/core';
|
|
2
|
-
import
|
|
2
|
+
import debugFactory from 'debug';
|
|
3
|
+
import { readCachedResult, spawnBackgroundRefresh } from '#services/version_check.js';
|
|
3
4
|
import { setNonInteractive } from '#utils/interactive.js';
|
|
5
|
+
const debug = debugFactory('output-cli:init');
|
|
4
6
|
export const INTERACTIVE_FLAGS = ['--yes', '--non-interactive'];
|
|
5
7
|
export const GLOBAL_FLAGS = new Set(INTERACTIVE_FLAGS);
|
|
6
8
|
export const hasInteractiveFlag = (argv) => argv.some(arg => INTERACTIVE_FLAGS.includes(arg));
|
|
@@ -18,7 +20,13 @@ const hook = async function (opts) {
|
|
|
18
20
|
setNonInteractive(true);
|
|
19
21
|
}
|
|
20
22
|
try {
|
|
21
|
-
|
|
23
|
+
// Only the local cache is read here; the registry roundtrip happens in a
|
|
24
|
+
// detached child so it never delays the invoked command.
|
|
25
|
+
const result = await readCachedResult(this.config.version, this.config.cacheDir);
|
|
26
|
+
if (!result) {
|
|
27
|
+
spawnBackgroundRefresh(this.config.version, this.config.cacheDir);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
22
30
|
if (!result.updateAvailable) {
|
|
23
31
|
return;
|
|
24
32
|
}
|
|
@@ -39,8 +47,9 @@ const hook = async function (opts) {
|
|
|
39
47
|
ux.stdout(border);
|
|
40
48
|
ux.stdout('');
|
|
41
49
|
}
|
|
42
|
-
catch {
|
|
50
|
+
catch (error) {
|
|
43
51
|
// Never block CLI execution
|
|
52
|
+
debug('Version banner failed: %O', error);
|
|
44
53
|
}
|
|
45
54
|
};
|
|
46
55
|
export default hook;
|
package/dist/hooks/init.spec.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
2
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
3
|
-
import {
|
|
3
|
+
import { readCachedResult, spawnBackgroundRefresh } from '#services/version_check.js';
|
|
4
4
|
import { setNonInteractive } from '#utils/interactive.js';
|
|
5
5
|
vi.mock('#services/version_check.js', () => ({
|
|
6
|
-
|
|
6
|
+
readCachedResult: vi.fn(),
|
|
7
|
+
spawnBackgroundRefresh: vi.fn()
|
|
7
8
|
}));
|
|
8
9
|
vi.mock('#utils/interactive.js', () => ({
|
|
9
10
|
setNonInteractive: vi.fn()
|
|
@@ -23,15 +24,16 @@ describe('init hook', () => {
|
|
|
23
24
|
const createHookContext = (version = '0.8.4') => ({
|
|
24
25
|
config: { version, cacheDir: '/tmp/test-cache' }
|
|
25
26
|
});
|
|
26
|
-
it('should display warning when update is available', async () => {
|
|
27
|
-
vi.mocked(
|
|
27
|
+
it('should display warning when cached result says an update is available', async () => {
|
|
28
|
+
vi.mocked(readCachedResult).mockResolvedValue({
|
|
28
29
|
updateAvailable: true,
|
|
29
30
|
currentVersion: '0.8.4',
|
|
30
31
|
latestVersion: '1.0.0'
|
|
31
32
|
});
|
|
32
33
|
const ctx = createHookContext();
|
|
33
34
|
await hook.call(ctx, { argv: [], id: undefined });
|
|
34
|
-
expect(
|
|
35
|
+
expect(readCachedResult).toHaveBeenCalledWith('0.8.4', '/tmp/test-cache');
|
|
36
|
+
expect(spawnBackgroundRefresh).not.toHaveBeenCalled();
|
|
35
37
|
expect(ux.stdout).toHaveBeenCalled();
|
|
36
38
|
const output = vi.mocked(ux.stdout).mock.calls.map(c => c[0]).join('\n');
|
|
37
39
|
expect(output).toContain('Uhoh');
|
|
@@ -40,17 +42,25 @@ describe('init hook', () => {
|
|
|
40
42
|
expect(output).toContain('npx output update');
|
|
41
43
|
});
|
|
42
44
|
it('should not display anything when up to date', async () => {
|
|
43
|
-
vi.mocked(
|
|
45
|
+
vi.mocked(readCachedResult).mockResolvedValue({
|
|
44
46
|
updateAvailable: false,
|
|
45
47
|
currentVersion: '0.8.4',
|
|
46
48
|
latestVersion: '0.8.4'
|
|
47
49
|
});
|
|
48
50
|
const ctx = createHookContext();
|
|
49
51
|
await hook.call(ctx, { argv: [], id: undefined });
|
|
52
|
+
expect(spawnBackgroundRefresh).not.toHaveBeenCalled();
|
|
53
|
+
expect(ux.stdout).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
it('should kick off a background refresh and stay silent when the cache is stale', async () => {
|
|
56
|
+
vi.mocked(readCachedResult).mockResolvedValue(null);
|
|
57
|
+
const ctx = createHookContext();
|
|
58
|
+
await hook.call(ctx, { argv: [], id: undefined });
|
|
59
|
+
expect(spawnBackgroundRefresh).toHaveBeenCalledWith('0.8.4', '/tmp/test-cache');
|
|
50
60
|
expect(ux.stdout).not.toHaveBeenCalled();
|
|
51
61
|
});
|
|
52
62
|
it('should silently handle errors', async () => {
|
|
53
|
-
vi.mocked(
|
|
63
|
+
vi.mocked(readCachedResult).mockRejectedValue(new Error('cache failure'));
|
|
54
64
|
const ctx = createHookContext();
|
|
55
65
|
await hook.call(ctx, { argv: [], id: undefined });
|
|
56
66
|
expect(ux.stdout).not.toHaveBeenCalled();
|
|
@@ -58,7 +68,7 @@ describe('init hook', () => {
|
|
|
58
68
|
describe('global interactive flags', () => {
|
|
59
69
|
const originalArgv = process.argv;
|
|
60
70
|
beforeEach(() => {
|
|
61
|
-
vi.mocked(
|
|
71
|
+
vi.mocked(readCachedResult).mockResolvedValue({
|
|
62
72
|
updateAvailable: false,
|
|
63
73
|
currentVersion: '0.8.4',
|
|
64
74
|
latestVersion: '0.8.4'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Detached helper spawned by spawnBackgroundRefresh (version_check.ts):
|
|
2
|
+
// refreshes the version-check cache off the critical path.
|
|
3
|
+
// Args: <currentVersion> <cacheDir>
|
|
4
|
+
import debugFactory from 'debug';
|
|
5
|
+
import { bootstrapProxy } from '#utils/proxy.js';
|
|
6
|
+
import { runRefresh } from '#services/version_check.js';
|
|
7
|
+
const debug = debugFactory('output-cli:version-check');
|
|
8
|
+
await bootstrapProxy().catch(error => debug('Proxy bootstrap failed: %O', error));
|
|
9
|
+
process.exitCode = await runRefresh(process.argv);
|
|
@@ -6,6 +6,8 @@ import packageJson from '../../package.json' with { type: 'json' };
|
|
|
6
6
|
const execFile = promisify(execFileCb);
|
|
7
7
|
const debug = debugFactory('output-cli:npm-update');
|
|
8
8
|
const PACKAGE_NAME = packageJson.name;
|
|
9
|
+
const REGISTRY_URL = 'https://registry.npmjs.org';
|
|
10
|
+
const REGISTRY_TIMEOUT_MS = 5000;
|
|
9
11
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
10
12
|
function findVersionInTree(deps) {
|
|
11
13
|
if (!deps) {
|
|
@@ -33,9 +35,15 @@ function parseNpmLsVersion(output) {
|
|
|
33
35
|
}
|
|
34
36
|
export async function fetchLatestVersion() {
|
|
35
37
|
try {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
const response = await fetch(`${REGISTRY_URL}/${PACKAGE_NAME}/latest`, {
|
|
39
|
+
signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS)
|
|
40
|
+
});
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
debug('Registry responded with status %d', response.status);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const data = await response.json();
|
|
46
|
+
return data.version || null;
|
|
39
47
|
}
|
|
40
48
|
catch (error) {
|
|
41
49
|
debug('Failed to fetch latest version: %O', error);
|
|
@@ -8,24 +8,37 @@ vi.mock('node:child_process', () => ({
|
|
|
8
8
|
vi.mock('node:util', () => ({
|
|
9
9
|
promisify: vi.fn(() => mockExecFile)
|
|
10
10
|
}));
|
|
11
|
+
const mockFetch = vi.fn();
|
|
12
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
11
13
|
describe('npm_update_service', () => {
|
|
12
14
|
beforeEach(() => {
|
|
13
15
|
vi.clearAllMocks();
|
|
14
16
|
});
|
|
15
17
|
describe('fetchLatestVersion', () => {
|
|
16
|
-
it('should return version from
|
|
17
|
-
|
|
18
|
+
it('should return version from the registry response', async () => {
|
|
19
|
+
mockFetch.mockResolvedValue({
|
|
20
|
+
ok: true,
|
|
21
|
+
json: async () => ({ version: '1.2.3' })
|
|
22
|
+
});
|
|
18
23
|
const result = await fetchLatestVersion();
|
|
19
24
|
expect(result).toBe('1.2.3');
|
|
20
|
-
expect(
|
|
25
|
+
expect(mockFetch).toHaveBeenCalledWith('https://registry.npmjs.org/@outputai/cli/latest', { signal: expect.any(AbortSignal) });
|
|
21
26
|
});
|
|
22
|
-
it('should return null on
|
|
23
|
-
|
|
27
|
+
it('should return null on non-ok response', async () => {
|
|
28
|
+
mockFetch.mockResolvedValue({ ok: false, status: 404 });
|
|
29
|
+
const result = await fetchLatestVersion();
|
|
30
|
+
expect(result).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
it('should return null when response has no version', async () => {
|
|
33
|
+
mockFetch.mockResolvedValue({
|
|
34
|
+
ok: true,
|
|
35
|
+
json: async () => ({})
|
|
36
|
+
});
|
|
24
37
|
const result = await fetchLatestVersion();
|
|
25
38
|
expect(result).toBeNull();
|
|
26
39
|
});
|
|
27
|
-
it('should return null on
|
|
28
|
-
|
|
40
|
+
it('should return null on network failure or timeout', async () => {
|
|
41
|
+
mockFetch.mockRejectedValue(new Error('aborted'));
|
|
29
42
|
const result = await fetchLatestVersion();
|
|
30
43
|
expect(result).toBeNull();
|
|
31
44
|
});
|
|
@@ -3,4 +3,22 @@ export interface VersionCheckResult {
|
|
|
3
3
|
currentVersion: string;
|
|
4
4
|
latestVersion: string;
|
|
5
5
|
}
|
|
6
|
-
export declare function
|
|
6
|
+
export declare function readCachedResult(currentVersion: string, cacheDir: string): Promise<VersionCheckResult | null>;
|
|
7
|
+
/**
|
|
8
|
+
* Fetches the latest published version and persists the comparison to the
|
|
9
|
+
* cache file. Skips the write and returns false when the latest version
|
|
10
|
+
* can't be determined so the next invocation retries.
|
|
11
|
+
*/
|
|
12
|
+
export declare function refreshVersionCheck(currentVersion: string, cacheDir: string): Promise<boolean>;
|
|
13
|
+
/**
|
|
14
|
+
* Entry point for the detached refresh helper. Validates the argv contract
|
|
15
|
+
* (`<currentVersion> <cacheDir>`) and returns the process exit code:
|
|
16
|
+
* 0 on success, 1 on bad args, 2 when the latest version couldn't be fetched.
|
|
17
|
+
*/
|
|
18
|
+
export declare function runRefresh(argv: string[]): Promise<number>;
|
|
19
|
+
/**
|
|
20
|
+
* Refreshes the version-check cache in a detached child process so the
|
|
21
|
+
* registry roundtrip never blocks the invoked command. The result is picked
|
|
22
|
+
* up from the cache on the next invocation.
|
|
23
|
+
*/
|
|
24
|
+
export declare function spawnBackgroundRefresh(currentVersion: string, cacheDir: string): void;
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import debugFactory from 'debug';
|
|
3
6
|
import { fetchLatestVersion, isOutdated } from '#services/npm_update_service.js';
|
|
7
|
+
const debug = debugFactory('output-cli:version-check');
|
|
4
8
|
const CACHE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
|
|
5
9
|
const CACHE_FILENAME = 'version_check.json';
|
|
6
|
-
async function
|
|
10
|
+
export async function readCachedResult(currentVersion, cacheDir) {
|
|
7
11
|
try {
|
|
8
12
|
const raw = await readFile(join(cacheDir, CACHE_FILENAME), 'utf-8');
|
|
9
13
|
const cache = JSON.parse(raw);
|
|
@@ -15,7 +19,8 @@ async function readCache(cacheDir, currentVersion) {
|
|
|
15
19
|
}
|
|
16
20
|
return cache.result;
|
|
17
21
|
}
|
|
18
|
-
catch {
|
|
22
|
+
catch (error) {
|
|
23
|
+
debug('Failed to read version cache: %O', error);
|
|
19
24
|
return null;
|
|
20
25
|
}
|
|
21
26
|
}
|
|
@@ -25,28 +30,59 @@ async function writeCache(cacheDir, result) {
|
|
|
25
30
|
const cache = { timestamp: Date.now(), result };
|
|
26
31
|
await writeFile(join(cacheDir, CACHE_FILENAME), JSON.stringify(cache));
|
|
27
32
|
}
|
|
28
|
-
catch {
|
|
29
|
-
|
|
33
|
+
catch (error) {
|
|
34
|
+
debug('Failed to write version cache: %O', error);
|
|
30
35
|
}
|
|
31
36
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
37
|
+
/**
|
|
38
|
+
* Fetches the latest published version and persists the comparison to the
|
|
39
|
+
* cache file. Skips the write and returns false when the latest version
|
|
40
|
+
* can't be determined so the next invocation retries.
|
|
41
|
+
*/
|
|
42
|
+
export async function refreshVersionCheck(currentVersion, cacheDir) {
|
|
39
43
|
const latestVersion = await fetchLatestVersion();
|
|
40
44
|
if (!latestVersion) {
|
|
41
|
-
|
|
45
|
+
debug('Latest version unavailable, skipping cache write');
|
|
46
|
+
return false;
|
|
42
47
|
}
|
|
43
|
-
|
|
48
|
+
await writeCache(cacheDir, {
|
|
44
49
|
updateAvailable: isOutdated(currentVersion, latestVersion),
|
|
45
50
|
currentVersion,
|
|
46
51
|
latestVersion
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
});
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Entry point for the detached refresh helper. Validates the argv contract
|
|
57
|
+
* (`<currentVersion> <cacheDir>`) and returns the process exit code:
|
|
58
|
+
* 0 on success, 1 on bad args, 2 when the latest version couldn't be fetched.
|
|
59
|
+
*/
|
|
60
|
+
export async function runRefresh(argv) {
|
|
61
|
+
const [, , currentVersion, cacheDir] = argv;
|
|
62
|
+
if (!currentVersion || !cacheDir) {
|
|
63
|
+
console.error('Usage: refresh_version_check.js <currentVersion> <cacheDir>');
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
return await refreshVersionCheck(currentVersion, cacheDir) ? 0 : 2;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Refreshes the version-check cache in a detached child process so the
|
|
70
|
+
* registry roundtrip never blocks the invoked command. The result is picked
|
|
71
|
+
* up from the cache on the next invocation.
|
|
72
|
+
*/
|
|
73
|
+
export function spawnBackgroundRefresh(currentVersion, cacheDir) {
|
|
74
|
+
try {
|
|
75
|
+
const scriptPath = fileURLToPath(new URL('../scripts/refresh_version_check.js', import.meta.url));
|
|
76
|
+
// stdio is discarded in normal use; surface the child's output when
|
|
77
|
+
// debugging is on so refresh failures are diagnosable
|
|
78
|
+
const stdio = debugFactory.enabled('output-cli:version-check') ? 'inherit' : 'ignore';
|
|
79
|
+
spawn(process.execPath, [scriptPath, currentVersion, cacheDir], {
|
|
80
|
+
detached: true,
|
|
81
|
+
stdio
|
|
82
|
+
}).unref();
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
// Best-effort: a failed refresh only delays the update banner
|
|
86
|
+
debug('Failed to spawn background version refresh: %O', error);
|
|
50
87
|
}
|
|
51
|
-
return result;
|
|
52
88
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import { readCachedResult, refreshVersionCheck, runRefresh, spawnBackgroundRefresh } from './version_check.js';
|
|
3
3
|
import { fetchLatestVersion, isOutdated } from '#services/npm_update_service.js';
|
|
4
4
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
5
6
|
vi.mock('#services/npm_update_service.js', () => ({
|
|
6
7
|
fetchLatestVersion: vi.fn(),
|
|
7
8
|
isOutdated: vi.fn()
|
|
@@ -11,96 +12,125 @@ vi.mock('node:fs/promises', () => ({
|
|
|
11
12
|
writeFile: vi.fn(),
|
|
12
13
|
mkdir: vi.fn()
|
|
13
14
|
}));
|
|
15
|
+
vi.mock('node:child_process', () => ({
|
|
16
|
+
spawn: vi.fn()
|
|
17
|
+
}));
|
|
18
|
+
const { mockDebugEnabled } = vi.hoisted(() => ({ mockDebugEnabled: vi.fn() }));
|
|
19
|
+
vi.mock('debug', () => ({
|
|
20
|
+
default: Object.assign(vi.fn(() => vi.fn()), { enabled: mockDebugEnabled })
|
|
21
|
+
}));
|
|
14
22
|
describe('version_check', () => {
|
|
23
|
+
const cacheDir = '/tmp/test-cache';
|
|
15
24
|
beforeEach(() => {
|
|
16
25
|
vi.clearAllMocks();
|
|
26
|
+
mockDebugEnabled.mockReturnValue(false);
|
|
17
27
|
});
|
|
18
|
-
describe('
|
|
19
|
-
it('should return updateAvailable true when outdated', async () => {
|
|
20
|
-
vi.mocked(fetchLatestVersion).mockResolvedValue('1.0.0');
|
|
21
|
-
vi.mocked(isOutdated).mockReturnValue(true);
|
|
22
|
-
const result = await checkForUpdate('0.8.4');
|
|
23
|
-
expect(result).toEqual({
|
|
24
|
-
updateAvailable: true,
|
|
25
|
-
currentVersion: '0.8.4',
|
|
26
|
-
latestVersion: '1.0.0'
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
it('should return updateAvailable false when up to date', async () => {
|
|
30
|
-
vi.mocked(fetchLatestVersion).mockResolvedValue('0.8.4');
|
|
31
|
-
vi.mocked(isOutdated).mockReturnValue(false);
|
|
32
|
-
const result = await checkForUpdate('0.8.4');
|
|
33
|
-
expect(result).toEqual({
|
|
34
|
-
updateAvailable: false,
|
|
35
|
-
currentVersion: '0.8.4',
|
|
36
|
-
latestVersion: '0.8.4'
|
|
37
|
-
});
|
|
38
|
-
});
|
|
39
|
-
it('should return updateAvailable false when fetch fails', async () => {
|
|
40
|
-
vi.mocked(fetchLatestVersion).mockResolvedValue(null);
|
|
41
|
-
const result = await checkForUpdate('0.8.4');
|
|
42
|
-
expect(result.updateAvailable).toBe(false);
|
|
43
|
-
expect(isOutdated).not.toHaveBeenCalled();
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
describe('caching', () => {
|
|
47
|
-
const cacheDir = '/tmp/test-cache';
|
|
28
|
+
describe('readCachedResult', () => {
|
|
48
29
|
it('should return cached result when cache is fresh', async () => {
|
|
49
30
|
const cached = {
|
|
50
31
|
timestamp: Date.now() - 1000,
|
|
51
32
|
result: { updateAvailable: true, currentVersion: '0.8.4', latestVersion: '1.0.0' }
|
|
52
33
|
};
|
|
53
34
|
vi.mocked(readFile).mockResolvedValue(JSON.stringify(cached));
|
|
54
|
-
const result = await
|
|
35
|
+
const result = await readCachedResult('0.8.4', cacheDir);
|
|
55
36
|
expect(result).toEqual(cached.result);
|
|
56
|
-
expect(fetchLatestVersion).not.toHaveBeenCalled();
|
|
57
37
|
});
|
|
58
|
-
it('should
|
|
38
|
+
it('should return null when cache is expired', async () => {
|
|
59
39
|
const cached = {
|
|
60
40
|
timestamp: Date.now() - (5 * 60 * 60 * 1000), // 5 hours ago
|
|
61
41
|
result: { updateAvailable: true, currentVersion: '0.8.4', latestVersion: '0.9.0' }
|
|
62
42
|
};
|
|
63
43
|
vi.mocked(readFile).mockResolvedValue(JSON.stringify(cached));
|
|
64
|
-
|
|
65
|
-
vi.mocked(isOutdated).mockReturnValue(true);
|
|
66
|
-
const result = await checkForUpdate('0.8.4', cacheDir);
|
|
67
|
-
expect(fetchLatestVersion).toHaveBeenCalled();
|
|
68
|
-
expect(result.latestVersion).toBe('1.0.0');
|
|
44
|
+
expect(await readCachedResult('0.8.4', cacheDir)).toBeNull();
|
|
69
45
|
});
|
|
70
|
-
it('should
|
|
46
|
+
it('should return null when cached version differs from current', async () => {
|
|
71
47
|
const cached = {
|
|
72
48
|
timestamp: Date.now() - 1000,
|
|
73
49
|
result: { updateAvailable: true, currentVersion: '0.8.4', latestVersion: '1.0.0' }
|
|
74
50
|
};
|
|
75
51
|
vi.mocked(readFile).mockResolvedValue(JSON.stringify(cached));
|
|
76
|
-
|
|
77
|
-
vi.mocked(isOutdated).mockReturnValue(true);
|
|
78
|
-
const result = await checkForUpdate('0.9.0', cacheDir);
|
|
79
|
-
expect(fetchLatestVersion).toHaveBeenCalled();
|
|
80
|
-
expect(result.currentVersion).toBe('0.9.0');
|
|
52
|
+
expect(await readCachedResult('0.9.0', cacheDir)).toBeNull();
|
|
81
53
|
});
|
|
82
|
-
it('should
|
|
54
|
+
it('should return null when cache file is missing', async () => {
|
|
83
55
|
vi.mocked(readFile).mockRejectedValue(new Error('ENOENT'));
|
|
84
|
-
|
|
85
|
-
vi.mocked(isOutdated).mockReturnValue(true);
|
|
86
|
-
const result = await checkForUpdate('0.8.4', cacheDir);
|
|
87
|
-
expect(fetchLatestVersion).toHaveBeenCalled();
|
|
88
|
-
expect(result.latestVersion).toBe('1.0.0');
|
|
56
|
+
expect(await readCachedResult('0.8.4', cacheDir)).toBeNull();
|
|
89
57
|
});
|
|
90
|
-
it('should
|
|
91
|
-
vi.mocked(readFile).
|
|
58
|
+
it('should return null when cache file is corrupt', async () => {
|
|
59
|
+
vi.mocked(readFile).mockResolvedValue('not json');
|
|
60
|
+
expect(await readCachedResult('0.8.4', cacheDir)).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
describe('refreshVersionCheck', () => {
|
|
64
|
+
it('should write comparison result to the cache', async () => {
|
|
92
65
|
vi.mocked(fetchLatestVersion).mockResolvedValue('1.0.0');
|
|
93
66
|
vi.mocked(isOutdated).mockReturnValue(true);
|
|
94
|
-
await
|
|
67
|
+
await expect(refreshVersionCheck('0.8.4', cacheDir)).resolves.toBe(true);
|
|
95
68
|
expect(mkdir).toHaveBeenCalledWith(cacheDir, { recursive: true });
|
|
96
|
-
expect(writeFile).
|
|
69
|
+
expect(writeFile).toHaveBeenCalledTimes(1);
|
|
70
|
+
const written = JSON.parse(vi.mocked(writeFile).mock.calls[0][1]);
|
|
71
|
+
expect(written.result).toEqual({
|
|
72
|
+
updateAvailable: true,
|
|
73
|
+
currentVersion: '0.8.4',
|
|
74
|
+
latestVersion: '1.0.0'
|
|
75
|
+
});
|
|
76
|
+
// A missing/invalid timestamp would make the cache immortal (NaN > TTL is false)
|
|
77
|
+
expect(written.timestamp).toBeCloseTo(Date.now(), -3);
|
|
78
|
+
});
|
|
79
|
+
it('should skip the cache write when fetch fails so the next invocation retries', async () => {
|
|
80
|
+
vi.mocked(fetchLatestVersion).mockResolvedValue(null);
|
|
81
|
+
await expect(refreshVersionCheck('0.8.4', cacheDir)).resolves.toBe(false);
|
|
82
|
+
expect(isOutdated).not.toHaveBeenCalled();
|
|
83
|
+
expect(writeFile).not.toHaveBeenCalled();
|
|
97
84
|
});
|
|
98
|
-
|
|
85
|
+
});
|
|
86
|
+
describe('runRefresh', () => {
|
|
87
|
+
it('should refresh the cache from argv in spawn order (script, currentVersion, cacheDir)', async () => {
|
|
99
88
|
vi.mocked(fetchLatestVersion).mockResolvedValue('1.0.0');
|
|
100
89
|
vi.mocked(isOutdated).mockReturnValue(true);
|
|
101
|
-
await
|
|
102
|
-
expect(
|
|
90
|
+
const exitCode = await runRefresh(['node', 'refresh_version_check.js', '0.8.4', cacheDir]);
|
|
91
|
+
expect(exitCode).toBe(0);
|
|
92
|
+
expect(mkdir).toHaveBeenCalledWith(cacheDir, { recursive: true });
|
|
93
|
+
const written = JSON.parse(vi.mocked(writeFile).mock.calls[0][1]);
|
|
94
|
+
expect(written.result.currentVersion).toBe('0.8.4');
|
|
95
|
+
expect(written.result.latestVersion).toBe('1.0.0');
|
|
96
|
+
});
|
|
97
|
+
it('should exit with a distinct code when the latest version cannot be fetched', async () => {
|
|
98
|
+
vi.mocked(fetchLatestVersion).mockResolvedValue(null);
|
|
99
|
+
const exitCode = await runRefresh(['node', 'refresh_version_check.js', '0.8.4', cacheDir]);
|
|
100
|
+
expect(exitCode).toBe(2);
|
|
101
|
+
expect(writeFile).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
it('should exit non-zero with usage on missing args without fetching', async () => {
|
|
104
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
105
|
+
const exitCode = await runRefresh(['node', 'refresh_version_check.js']);
|
|
106
|
+
expect(exitCode).toBe(1);
|
|
107
|
+
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Usage:'));
|
|
108
|
+
expect(fetchLatestVersion).not.toHaveBeenCalled();
|
|
103
109
|
expect(writeFile).not.toHaveBeenCalled();
|
|
110
|
+
errorSpy.mockRestore();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
describe('spawnBackgroundRefresh', () => {
|
|
114
|
+
it('should spawn a detached unref-ed helper with version and cache dir args', () => {
|
|
115
|
+
const unref = vi.fn();
|
|
116
|
+
vi.mocked(spawn).mockReturnValue({ unref });
|
|
117
|
+
spawnBackgroundRefresh('0.8.4', cacheDir);
|
|
118
|
+
expect(spawn).toHaveBeenCalledWith(process.execPath, [expect.stringContaining('refresh_version_check.js'), '0.8.4', cacheDir], { detached: true, stdio: 'ignore' });
|
|
119
|
+
expect(unref).toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
it('should inherit stdio when the version-check debug namespace is enabled', () => {
|
|
122
|
+
mockDebugEnabled.mockReturnValue(true);
|
|
123
|
+
const unref = vi.fn();
|
|
124
|
+
vi.mocked(spawn).mockReturnValue({ unref });
|
|
125
|
+
spawnBackgroundRefresh('0.8.4', cacheDir);
|
|
126
|
+
expect(mockDebugEnabled).toHaveBeenCalledWith('output-cli:version-check');
|
|
127
|
+
expect(spawn).toHaveBeenCalledWith(process.execPath, expect.any(Array), { detached: true, stdio: 'inherit' });
|
|
128
|
+
});
|
|
129
|
+
it('should swallow spawn failures', () => {
|
|
130
|
+
vi.mocked(spawn).mockImplementation(() => {
|
|
131
|
+
throw new Error('spawn failure');
|
|
132
|
+
});
|
|
133
|
+
expect(() => spawnBackgroundRefresh('0.8.4', cacheDir)).not.toThrow();
|
|
104
134
|
});
|
|
105
135
|
});
|
|
106
136
|
});
|
package/dist/utils/proxy.d.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* `http_proxy`). No-op when none are set. Invalid URLs are logged and
|
|
5
5
|
* skipped so the CLI keeps running.
|
|
6
6
|
*
|
|
7
|
-
* Call once at CLI startup, before any network activity.
|
|
7
|
+
* Call once at CLI startup, before any network activity. `undici` is
|
|
8
|
+
* imported lazily so invocations without a proxy skip loading it.
|
|
8
9
|
*/
|
|
9
|
-
export declare const bootstrapProxy: () => void
|
|
10
|
+
export declare const bootstrapProxy: () => Promise<void>;
|
package/dist/utils/proxy.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
|
|
2
1
|
/**
|
|
3
2
|
* Routes all `fetch()` calls through an HTTP/HTTPS proxy when standard
|
|
4
3
|
* proxy env vars are set (`HTTPS_PROXY`, `https_proxy`, `HTTP_PROXY`,
|
|
5
4
|
* `http_proxy`). No-op when none are set. Invalid URLs are logged and
|
|
6
5
|
* skipped so the CLI keeps running.
|
|
7
6
|
*
|
|
8
|
-
* Call once at CLI startup, before any network activity.
|
|
7
|
+
* Call once at CLI startup, before any network activity. `undici` is
|
|
8
|
+
* imported lazily so invocations without a proxy skip loading it.
|
|
9
9
|
*/
|
|
10
|
-
export const bootstrapProxy = () => {
|
|
10
|
+
export const bootstrapProxy = async () => {
|
|
11
11
|
const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy ||
|
|
12
12
|
process.env.HTTP_PROXY || process.env.http_proxy;
|
|
13
13
|
if (!proxyUrl) {
|
|
@@ -20,5 +20,6 @@ export const bootstrapProxy = () => {
|
|
|
20
20
|
console.warn(`[proxy] Ignoring invalid proxy URL: ${proxyUrl}`);
|
|
21
21
|
return;
|
|
22
22
|
}
|
|
23
|
+
const { EnvHttpProxyAgent, setGlobalDispatcher } = await import('undici');
|
|
23
24
|
setGlobalDispatcher(new EnvHttpProxyAgent());
|
|
24
25
|
};
|