Skip to content
Merged
Show file tree
Hide file tree
Changes from 43 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
8934b71
feat(rechallenge): add types and error classes
rinatkhaziev Jun 4, 2026
8655df4
feat(rechallenge): add keychain-backed per-scope elevated-token cache
rinatkhaziev Jun 4, 2026
35f9916
feat(rechallenge): add REST client for Parker v2 session endpoints
rinatkhaziev Jun 4, 2026
a6df809
fix(rechallenge): satisfy tsc check-types in test files
rinatkhaziev Jun 4, 2026
b2c46de
feat(rechallenge): orchestrate session create, poll, exchange
rinatkhaziev Jun 4, 2026
3e6cc2f
feat(rechallenge): add Apollo link that intercepts elevated-permissio…
rinatkhaziev Jun 4, 2026
f03c423
feat(api): insert rechallenge link into Apollo chain
rinatkhaziev Jun 4, 2026
a461f52
feat(defensive-mode): add GraphQL helpers for status and config mutat…
rinatkhaziev Jun 4, 2026
b8815af
feat(cli): add vip defensive-mode parent command
rinatkhaziev Jun 4, 2026
20e4e0f
feat(cli): add vip defensive-mode enable subcommand
rinatkhaziev Jun 4, 2026
2dc5804
feat(cli): add vip defensive-mode disable subcommand
rinatkhaziev Jun 4, 2026
1109b2c
feat(cli): add vip defensive-mode configure subcommand
rinatkhaziev Jun 4, 2026
c700b4e
feat(cli): register vip defensive-mode top-level command
rinatkhaziev Jun 4, 2026
80e0d1d
feat(logout): clear elevated-token cache on logout
rinatkhaziev Jun 4, 2026
6763ec4
fix(api): lint cleanup for lazy rechallenge link import
rinatkhaziev Jun 4, 2026
a353924
fix: address final-review findings (diff in configure, non-interactiv…
rinatkhaziev Jun 4, 2026
c9fc8df
fix: address PR review feedback (data guards, version constant, paylo…
rinatkhaziev Jun 4, 2026
5a7ca83
fix: address PR review feedback (output formatting, login hardening, …
rinatkhaziev Jun 10, 2026
8736474
chore(lint): scope no-await-in-loop disable to rechallenge polling loop
rinatkhaziev Jun 10, 2026
3a29646
fix(lint): stop importing Response type from node-fetch in rechalleng…
rinatkhaziev Jun 10, 2026
2750aa4
Merge branch 'trunk' into feat/rechallenge-v2-defensive-mode
rinatkhaziev Jun 10, 2026
758fda5
feat(edge-workers): add edge workers commands
alexcriss Jun 10, 2026
13734c6
Merge branch 'trunk' into add/edge-workers-commands
rinatkhaziev Jul 6, 2026
c59fde2
Bump the SDK version to get the new resp.request functionality
alexcriss Jul 15, 2026
029b16c
Merge branch 'trunk' into add/edge-workers-commands
rinatkhaziev Aug 19, 2026
a3e087c
fix(edge-workers): contain project filesystem paths
rinatkhaziev Aug 19, 2026
2bc1165
fix(edge-workers): refuse destructive project initialization
rinatkhaziev Aug 19, 2026
035a32f
fix(edge-workers): fail closed on API results
rinatkhaziev Aug 19, 2026
4493a8f
feat(edge-workers): preview deployments before apply
rinatkhaziev Aug 19, 2026
edc8022
fix(edge-workers): store empty deployment source
rinatkhaziev Aug 19, 2026
6d19c81
fix(edge-workers): guard production mutations
rinatkhaziev Aug 19, 2026
42c557c
fix(api): minimize edge-worker and debug data
rinatkhaziev Aug 19, 2026
b057b59
docs(edge-workers): ship safe scaffold guidance
rinatkhaziev Aug 19, 2026
305128f
fix(edge-workers): address scaffold review gaps
rinatkhaziev Aug 19, 2026
b118e52
test(edge-workers): complete lifecycle coverage
rinatkhaziev Aug 19, 2026
edaebb7
test(edge-workers): assert single terminal exits
rinatkhaziev Aug 19, 2026
45a25a7
fix(edge-workers): close final hardening gaps
rinatkhaziev Aug 19, 2026
9079c5f
feat(edge-workers): support enable after deploy
rinatkhaziev Aug 19, 2026
1a1c9d2
feat(edge-workers): add explicit deploy enable flow
rinatkhaziev Aug 19, 2026
7ea4cd8
fix(edge-workers): address review feedback
rinatkhaziev Aug 20, 2026
daed446
ci: ensure Docker runs on Windows tests
rinatkhaziev Aug 20, 2026
6d353b3
build(edge-workers): bump SDK to 0.3.2
rinatkhaziev Aug 20, 2026
a0da7b4
fix(edge-workers): address review feedback
rinatkhaziev Aug 21, 2026
dbf056f
refactor(edge-workers): simplify pickEnvWorkers after server-side fil…
rinatkhaziev Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/windows-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,15 @@ jobs:
- name: Unit Tests
run: npm run jest

# Work around intermittent hosted-runner Docker startup failures:
# https://github.com/actions/runner-images/issues/13729
- name: Ensure Docker is running
shell: powershell
run: |
$dockerService = Get-Service -Name "docker"
if ($dockerService.Status -ne "Running") {
Start-Service -Name "docker"
}

- name: Test Command line
run: ./__tests__/e2e_test.bat
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ VIP-CLI is a tool for interacting with and managing your [WordPress VIP applicat
- [CONTRIBUTING.md](https://github.com/Automattic/vip-cli/blob/trunk/docs/CONTRIBUTING.md) for information on how to contribute patches and features, also issue and pull request labels.
- [DEBUGGING.md](https://github.com/Automattic/vip-cli/blob/trunk/docs/DEBUGGING.md) for information on how to debug the software.
- [TESTING.md](https://github.com/Automattic/vip-cli/blob/trunk/docs/TESTING.md) for details on testing the software and individual tasks.
- [EDGE-WORKERS.md](https://github.com/Automattic/vip-cli/blob/trunk/docs/EDGE-WORKERS.md) for the edge-worker scaffold, validation, deployment, and operational safety contract.
- [RELEASING.md](https://github.com/Automattic/vip-cli/blob/trunk/docs/RELEASING.md) for details on deploying a new release.
- [SECURITY.md](https://github.com/Automattic/vip-cli/blob/trunk/docs/SECURITY.md) for information if you **found a security issue**.

Expand Down
143 changes: 143 additions & 0 deletions __tests__/bin/vip-edge-workers-build.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import path from 'node:path';

import { edgeWorkersBuildCommand } from '../../src/bin/vip-edge-workers-build';
import * as exit from '../../src/lib/cli/exit';
import * as lib from '../../src/lib/edge-workers';
import * as project from '../../src/lib/edge-workers/project';
import * as tracker from '../../src/lib/tracker';

jest.spyOn( console, 'log' ).mockImplementation( () => {} );
jest.spyOn( exit, 'withError' ).mockImplementation( () => {
throw 'EXIT_WITH_ERROR';
} );

jest.mock( '../../src/lib/cli/command', () => {
const commandMock = {
argv: () => commandMock,
examples: () => commandMock,
option: () => commandMock,
};
return jest.fn( () => commandMock );
} );

jest.mock( '../../src/lib/edge-workers', () => ( {
buildWorker: jest.fn(),
} ) );

jest.mock( '../../src/lib/edge-workers/project', () => ( {
discoverWorkers: jest.fn(),
findWorker: jest.fn(),
resolveProjectDir: jest.fn(),
} ) );

jest.mock( '../../src/lib/tracker', () => ( {
trackEvent: jest.fn(),
} ) );

const worker = name => ( {
dir: `/project/workers/${ name }`,
manifest: { name, entry: 'assembly/index.ts' },
} );

describe( 'edgeWorkersBuildCommand()', () => {
beforeEach( () => {
jest.clearAllMocks();
project.resolveProjectDir.mockReturnValue( '/project' );
project.findWorker.mockImplementation( ( _projectDir, name ) => worker( name ) );
project.discoverWorkers.mockReturnValue( [ worker( 'alpha' ), worker( 'beta' ) ] );
lib.buildWorker.mockImplementation( ( _projectDir, selectedWorker ) => ( {
wasmPath: `/project/build/${ selectedWorker.manifest.name }.wasm`,
sizeBytes: selectedWorker.manifest.name.length,
} ) );
} );

it( 'builds one named worker', async () => {
await edgeWorkersBuildCommand( [ 'alpha' ] );

expect( project.findWorker ).toHaveBeenCalledWith( '/project', 'alpha' );
expect( project.discoverWorkers ).not.toHaveBeenCalled();
expect( lib.buildWorker ).toHaveBeenCalledWith( '/project', worker( 'alpha' ) );
} );

it( 'builds all discovered workers when no name is supplied', async () => {
await edgeWorkersBuildCommand();

expect( project.discoverWorkers ).toHaveBeenCalledWith( '/project' );
expect( lib.buildWorker ).toHaveBeenCalledTimes( 2 );
} );

it( 'builds all discovered workers with --all', async () => {
await edgeWorkersBuildCommand( [], { all: true } );

expect( project.discoverWorkers ).toHaveBeenCalledWith( '/project' );
expect( lib.buildWorker ).toHaveBeenCalledTimes( 2 );
} );

it( 'rejects a worker name together with --all', async () => {
await expect( edgeWorkersBuildCommand( [ 'alpha' ], { all: true } ) ).rejects.toBe(
'EXIT_WITH_ERROR'
);

expect( exit.withError ).toHaveBeenCalledWith(
'Supply either a worker name or --all, not both.'
);
expect( project.findWorker ).not.toHaveBeenCalled();
expect( project.discoverWorkers ).not.toHaveBeenCalled();
expect( lib.buildWorker ).not.toHaveBeenCalled();
} );

it( 'reports when the project has no workers', async () => {
project.discoverWorkers.mockReturnValue( [] );

await expect( edgeWorkersBuildCommand() ).rejects.toBe( 'EXIT_WITH_ERROR' );

expect( exit.withError ).toHaveBeenCalledWith(
'No workers found in this project. Create one with `vip edge-workers new`.'
);
expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_build_command_error', {
name: undefined,
error: 'build_failed',
} );
expect( lib.buildWorker ).not.toHaveBeenCalled();
} );

it( 'keeps compiler diagnostics local and out of analytics', async () => {
const secret = 'SENTINEL_BUILD_SECRET';
const sourcePath = '/private/customer/project/workers/alpha/assembly/index.ts';
const diagnostic = `Compilation failed at ${ sourcePath }: const token = "${ secret }";\n\u001b[31merror`;
lib.buildWorker.mockImplementation( () => {
throw new Error( diagnostic );
} );

await expect( edgeWorkersBuildCommand( [ 'alpha' ] ) ).rejects.toBe( 'EXIT_WITH_ERROR' );

expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_build_command_error', {
name: 'alpha',
error: 'build_failed',
} );
expect( JSON.stringify( tracker.trackEvent.mock.calls ) ).not.toContain( secret );
expect( JSON.stringify( tracker.trackEvent.mock.calls ) ).not.toContain( sourcePath );
expect( exit.withError ).toHaveBeenCalledWith( expect.stringContaining( secret ) );
expect( exit.withError ).toHaveBeenCalledWith( expect.stringContaining( sourcePath ) );
expect( tracker.trackEvent ).not.toHaveBeenCalledWith(
'edge_workers_build_command_success',
expect.anything()
);
expect( console.log ).not.toHaveBeenCalledWith( expect.stringMatching( /^✓/ ) );
expect( exit.withError ).toHaveBeenCalledTimes( 1 );
} );

it( 'prints the relative artifact path and byte size', async () => {
await edgeWorkersBuildCommand( [ 'alpha' ] );

expect( console.log ).toHaveBeenCalledWith(
`✓ Built "alpha" → ${ path.relative( '/project', '/project/build/alpha.wasm' ) } (5 bytes)`
);
expect( tracker.trackEvent ).toHaveBeenCalledWith( 'edge_workers_build_command_success', {
count: 1,
} );
const buildOrder = lib.buildWorker.mock.invocationCallOrder[ 0 ];
const successOrder = tracker.trackEvent.mock.invocationCallOrder.at( -1 );
expect( buildOrder ).toBeLessThan( successOrder );
} );
} );
165 changes: 165 additions & 0 deletions __tests__/bin/vip-edge-workers-delete.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { edgeWorkersDeleteCommand } from '../../src/bin/vip-edge-workers-delete';
import * as edgeWorkersApi from '../../src/lib/api/edge-workers';
import * as exit from '../../src/lib/cli/exit';
import { confirm } from '../../src/lib/envvar/input';
import * as tracker from '../../src/lib/tracker';

jest.spyOn( console, 'log' ).mockImplementation( () => {} );
jest.spyOn( exit, 'withError' ).mockImplementation( () => {
throw 'EXIT_WITH_ERROR';
} );

jest.mock( '../../src/lib/cli/command', () => {
const commandMock = {
argv: () => commandMock,
examples: () => commandMock,
option: () => commandMock,
};
return jest.fn( () => commandMock );
} );

jest.mock( '../../src/lib/api/edge-workers', () => ( {
appQuery: 'mock-app-query',
deleteEdgeWorker: jest.fn(),
findEdgeWorkerByName: jest.fn(),
} ) );

jest.mock( '../../src/lib/envvar/input', () => ( {
confirm: jest.fn(),
} ) );

jest.mock( '../../src/lib/tracker', () => ( {
trackEventWithEnv: jest.fn(),
} ) );

const opts = {
app: { id: 1, name: 'example-app' },
env: { id: 3, type: 'production' },
force: false,
};

const worker = {
id: 7,
name: 'headers',
location: null,
phases: [ 'client_response' ],
onFailure: 'continue',
active: true,
createdAt: '2026-08-19T00:00:00.000Z',
updatedAt: '2026-08-19T00:00:00.000Z',
};

describe( 'edgeWorkersDeleteCommand()', () => {
beforeEach( () => {
jest.clearAllMocks();
edgeWorkersApi.findEdgeWorkerByName.mockResolvedValue( worker );
edgeWorkersApi.deleteEdgeWorker.mockResolvedValue();
confirm.mockResolvedValue( true );
tracker.trackEventWithEnv.mockResolvedValue();
} );

it( 'resolves the target and does not prompt or delete when it is not found', async () => {
edgeWorkersApi.findEdgeWorkerByName.mockResolvedValue( null );

await expect( edgeWorkersDeleteCommand( [ 'missing' ], opts ) ).rejects.toBe(
'EXIT_WITH_ERROR'
);

expect( edgeWorkersApi.findEdgeWorkerByName ).toHaveBeenCalledWith( 1, 3, 'missing' );
expect( confirm ).not.toHaveBeenCalled();
expect( edgeWorkersApi.deleteEdgeWorker ).not.toHaveBeenCalled();
expect( exit.withError ).toHaveBeenCalledTimes( 1 );
expect( console.log ).not.toHaveBeenCalledWith( expect.stringMatching( /^✓/ ) );
expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith(
1,
3,
'edge_workers_delete_command_success',
expect.anything()
);
} );

it( 'confirms the exact resolved target before deleting', async () => {
const order = [];
edgeWorkersApi.findEdgeWorkerByName.mockImplementation( async () => {
order.push( 'resolve' );
return worker;
} );
confirm.mockImplementation( async message => {
expect( message ).toBe(
'Permanently delete edge worker "headers" from example-app.production?'
);
order.push( 'confirm' );
return true;
} );
edgeWorkersApi.deleteEdgeWorker.mockImplementation( async () => {
order.push( 'delete' );
} );

await edgeWorkersDeleteCommand( [ 'headers' ], opts );

expect( order ).toEqual( [ 'resolve', 'confirm', 'delete' ] );
} );

it( 'does not delete when confirmation is declined', async () => {
confirm.mockResolvedValue( false );

await expect( edgeWorkersDeleteCommand( [ 'headers' ], opts ) ).rejects.toBe(
'EXIT_WITH_ERROR'
);

expect( edgeWorkersApi.deleteEdgeWorker ).not.toHaveBeenCalled();
expect( exit.withError ).toHaveBeenCalledWith(
'Failed to delete edge worker: Command cancelled by user.'
);
} );

it( 'uses force as the explicit prompt bypass', async () => {
await edgeWorkersDeleteCommand( [ 'headers' ], { ...opts, force: true } );

expect( confirm ).not.toHaveBeenCalled();
expect( edgeWorkersApi.deleteEdgeWorker ).toHaveBeenCalledWith( 3, 7 );
} );

it( 'reports API rejection without success output or telemetry', async () => {
edgeWorkersApi.deleteEdgeWorker.mockRejectedValue( new Error( 'API unavailable' ) );

await expect( edgeWorkersDeleteCommand( [ 'headers' ], opts ) ).rejects.toBe(
'EXIT_WITH_ERROR'
);

expect( exit.withError ).toHaveBeenCalledWith(
'Failed to delete edge worker: API unavailable'
);
expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith(
1,
3,
'edge_workers_delete_command_error',
{ name: 'headers', error: 'delete_failed' }
);
expect( console.log ).not.toHaveBeenCalledWith( '✓ Deleted edge worker "headers".' );
expect( tracker.trackEventWithEnv ).not.toHaveBeenCalledWith(
1,
3,
'edge_workers_delete_command_success',
expect.anything()
);
} );

it( 'prints and tracks success only after deletion succeeds', async () => {
await edgeWorkersDeleteCommand( [ 'headers' ], opts );

expect( edgeWorkersApi.deleteEdgeWorker ).toHaveBeenCalledWith( 3, 7 );
expect( console.log ).toHaveBeenCalledWith( '✓ Deleted edge worker "headers".' );
expect( tracker.trackEventWithEnv ).toHaveBeenCalledWith(
1,
3,
'edge_workers_delete_command_success',
{ name: 'headers' }
);
const deletionOrder = edgeWorkersApi.deleteEdgeWorker.mock.invocationCallOrder[ 0 ];
const outputOrder = console.log.mock.invocationCallOrder[ 0 ];
const successOrder = tracker.trackEventWithEnv.mock.invocationCallOrder.at( -1 );
expect( deletionOrder ).toBeLessThan( outputOrder );
expect( deletionOrder ).toBeLessThan( successOrder );
} );
} );
Loading