Skip to content
10 changes: 4 additions & 6 deletions apps/cli/commands/site/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,12 +112,10 @@ export async function runCommand(

try {
if ( isOnlineStatus ) {
logger.reportStart(
LoggerAction.CHECKING_DEPENDENCY_UPDATES,
__( 'Checking for dependency updates…' )
);

await updateServerFiles();
const updated = await updateServerFiles();
if ( updated ) {
logger.reportSuccess( __( 'Dependencies updated' ) );
}
}
} catch ( error ) {
// Errors here aren't critical and likely relate to things outside the user's control,
Expand Down
18 changes: 17 additions & 1 deletion apps/cli/commands/site/tests/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ describe( 'CLI: studio site create', () => {
vi.mocked( keepSqliteIntegrationUpdated ).mockResolvedValue( true );
vi.mocked( connectToDaemon ).mockResolvedValue( undefined );
vi.mocked( disconnectFromDaemon ).mockResolvedValue( undefined );
vi.mocked( updateServerFiles ).mockResolvedValue( undefined );
vi.mocked( updateServerFiles ).mockResolvedValue( true );
vi.mocked( setupCustomDomain ).mockResolvedValue( undefined );
vi.mocked( startWordPressServer ).mockResolvedValue( mockProcessDescription );
vi.mocked( runBlueprint ).mockResolvedValue( undefined );
Expand Down Expand Up @@ -1033,4 +1033,20 @@ $table_prefix = 'wp_';
);
} );
} );

describe( 'Dependency updates', () => {
it( 'calls updateServerFiles when online', async () => {
await runCommand( mockSitePath, { ...defaultTestOptions } );

expect( updateServerFiles ).toHaveBeenCalledTimes( 1 );
} );

it( 'skips updateServerFiles when offline', async () => {
vi.mocked( isOnline ).mockResolvedValue( false );

await runCommand( mockSitePath, { ...defaultTestOptions } );

expect( updateServerFiles ).not.toHaveBeenCalled();
} );
} );
} );
1 change: 1 addition & 0 deletions apps/cli/lib/cli-config/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const cliConfigSchema = z.object( {
lastBumpStats: z
.record( z.string(), z.partialRecord( z.enum( StatsMetric ), z.number() ) )
.optional(),
lastDependencyCheckTime: z.number().optional(),
updateCheck: updateCheckSchema.optional(),
} );

Expand Down
41 changes: 40 additions & 1 deletion apps/cli/lib/dependency-management/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path';
import { recursiveCopyDirectory } from '@studio/common/lib/fs-utils';
import semver from 'semver';
import { readCliConfig, updateCliConfigWithPartial } from 'cli/lib/cli-config/core';
import { getSqliteVersionFromInstallation } from 'cli/lib/sqlite-integration';
import {
getAiInstructionsPath,
Expand Down Expand Up @@ -261,10 +262,48 @@ export async function setupServerFiles() {
}
}

export async function updateServerFiles() {
export const DEPENDENCY_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;

async function shouldCheckDependencyUpdates(): Promise< boolean > {
try {
const { lastDependencyCheckTime } = await readCliConfig();
if ( typeof lastDependencyCheckTime !== 'number' ) {
return true;
}
const now = Date.now();
// Treat future timestamps (clock skew) as stale.
if ( lastDependencyCheckTime > now ) {
return true;
}
return now - lastDependencyCheckTime >= DEPENDENCY_CHECK_INTERVAL_MS;
} catch {
return true;
}
}

async function markDependencyCheckTime(): Promise< void > {
try {
await updateCliConfigWithPartial( { lastDependencyCheckTime: Date.now() } );
} catch ( error ) {
console.error( 'Failed to persist dependency check timestamp:', error );
}
}

/**
* Checks for and applies dependency updates (e.g. WordPress versions), throttled
* to at most once per 24 hours. Returns true if the check ran, false if skipped.
*/
export async function updateServerFiles(): Promise< boolean > {
if ( ! ( await shouldCheckDependencyUpdates() ) ) {
return false;
}

try {
await updateLatestWordPressVersion();
} catch ( error ) {
console.error( 'Failed to update dependency WordPress version:', error );
}

await markDependencyCheckTime();
return true;
}
139 changes: 139 additions & 0 deletions apps/cli/lib/dependency-management/tests/setup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { readCliConfig, updateCliConfigWithPartial } from 'cli/lib/cli-config/core';
import { DEPENDENCY_CHECK_INTERVAL_MS, updateServerFiles } from '../setup';
import { updateLatestWordPressVersion } from '../wordpress';

vi.mock( 'cli/lib/cli-config/core', () => ( {
readCliConfig: vi.fn(),
updateCliConfigWithPartial: vi.fn(),
} ) );

vi.mock( '../wordpress' );

describe( 'updateServerFiles', () => {
const NOW = 1_700_000_000_000;

beforeEach( () => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime( NOW );
vi.spyOn( console, 'error' ).mockImplementation( () => {} );
vi.mocked( updateLatestWordPressVersion ).mockResolvedValue( undefined );
vi.mocked( updateCliConfigWithPartial ).mockResolvedValue( undefined );
} );

afterEach( () => {
vi.useRealTimers();
vi.restoreAllMocks();
} );

describe( 'throttling', () => {
it( 'runs the update when no timestamp has been recorded', async () => {
vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } );

const result = await updateServerFiles();

expect( result ).toBe( true );
expect( updateLatestWordPressVersion ).toHaveBeenCalledTimes( 1 );
} );

it( 'runs the update when the last check is older than the interval', async () => {
vi.mocked( readCliConfig ).mockResolvedValue( {
version: 1,
sites: [],
snapshots: [],
lastDependencyCheckTime: NOW - DEPENDENCY_CHECK_INTERVAL_MS - 1,
} );

const result = await updateServerFiles();

expect( result ).toBe( true );
expect( updateLatestWordPressVersion ).toHaveBeenCalledTimes( 1 );
} );

it( 'skips the update when the last check is within the interval', async () => {
vi.mocked( readCliConfig ).mockResolvedValue( {
version: 1,
sites: [],
snapshots: [],
lastDependencyCheckTime: NOW - 60 * 1000,
} );

const result = await updateServerFiles();

expect( result ).toBe( false );
expect( updateLatestWordPressVersion ).not.toHaveBeenCalled();
} );

it( 'runs the update when the timestamp is in the future (clock skew)', async () => {
vi.mocked( readCliConfig ).mockResolvedValue( {
version: 1,
sites: [],
snapshots: [],
lastDependencyCheckTime: NOW + 60 * 1000,
} );

const result = await updateServerFiles();

expect( result ).toBe( true );
expect( updateLatestWordPressVersion ).toHaveBeenCalledTimes( 1 );
} );

it( 'runs the update when reading the config throws', async () => {
vi.mocked( readCliConfig ).mockRejectedValue( new Error( 'boom' ) );

const result = await updateServerFiles();

expect( result ).toBe( true );
expect( updateLatestWordPressVersion ).toHaveBeenCalledTimes( 1 );
} );
} );

describe( 'timestamp persistence', () => {
it( 'persists the current time after a successful update', async () => {
vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } );

await updateServerFiles();

expect( updateCliConfigWithPartial ).toHaveBeenCalledWith( {
lastDependencyCheckTime: NOW,
} );
} );

it( 'persists the timestamp even when the update throws', async () => {
vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } );
vi.mocked( updateLatestWordPressVersion ).mockRejectedValue( new Error( 'network' ) );

await updateServerFiles();

expect( updateCliConfigWithPartial ).toHaveBeenCalledWith( {
lastDependencyCheckTime: NOW,
} );
} );

it( 'does not persist the timestamp when the check is skipped', async () => {
vi.mocked( readCliConfig ).mockResolvedValue( {
version: 1,
sites: [],
snapshots: [],
lastDependencyCheckTime: NOW - 60 * 1000,
} );

await updateServerFiles();

expect( updateCliConfigWithPartial ).not.toHaveBeenCalled();
} );

it( 'swallows errors from the timestamp write and logs them', async () => {
vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } );
const error = new Error( 'write failed' );
vi.mocked( updateCliConfigWithPartial ).mockRejectedValue( error );

await expect( updateServerFiles() ).resolves.toBe( true );
expect( console.error ).toHaveBeenCalledWith(
'Failed to persist dependency check timestamp:',
error
);
} );
} );
} );
Loading