From 4d98590c085c55ed97d7ecb112d20e23cf3052c6 Mon Sep 17 00:00:00 2001 From: Jason Heddings Date: Wed, 6 May 2026 14:12:34 -0600 Subject: [PATCH 1/2] feat(cli): add unpublish command and align publish with new API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `everywhere unpublish` command — DELETE /api/v1/app/:name - Update publish endpoint to POST /api/v1/apps/publish - Switch publish body from multipart form to raw application/zip - Update publish response shape to {tenant, name, title, bundleUrl} The npm package name from package.json is the canonical plugin identifier across publish, unpublish, and bundle URLs. Co-Authored-By: Claude Sonnet 4.6 --- cli/oclif.manifest.json | 74 +++---- cli/src/commands/everywhere/publish.ts | 13 +- cli/src/commands/everywhere/unpublish.ts | 54 +++++ cli/src/registry/registry.ts | 64 ++++-- cli/tests/commands/everywhere/publish.test.ts | 26 +-- .../commands/everywhere/unpublish.test.ts | 195 ++++++++++++++++++ cli/tests/registry/registry-delete.test.ts | 78 +++++++ cli/tests/registry/registry.test.ts | 57 +++-- 8 files changed, 443 insertions(+), 118 deletions(-) create mode 100644 cli/src/commands/everywhere/unpublish.ts create mode 100644 cli/tests/commands/everywhere/unpublish.test.ts create mode 100644 cli/tests/registry/registry-delete.test.ts diff --git a/cli/oclif.manifest.json b/cli/oclif.manifest.json index 13de4e9..a655fa5 100644 --- a/cli/oclif.manifest.json +++ b/cli/oclif.manifest.json @@ -1,42 +1,5 @@ { "commands": { - "everywhere:base": { - "aliases": [], - "args": {}, - "flags": { - "plugin-dir": { - "char": "D", - "description": "Plugin directory (defaults to current working directory).", - "name": "plugin-dir", - "hasDynamicHelp": false, - "multiple": false, - "type": "option" - }, - "verbose": { - "char": "v", - "description": "Show detailed output.", - "name": "verbose", - "allowNo": false, - "type": "boolean" - } - }, - "hasDynamicHelp": false, - "hidden": true, - "hiddenAliases": [], - "id": "everywhere:base", - "pluginAlias": "@workday/everywhere", - "pluginName": "@workday/everywhere", - "pluginType": "core", - "strict": true, - "enableJsonFlag": false, - "isESM": true, - "relativePath": [ - "dist", - "commands", - "everywhere", - "base.js" - ] - }, "everywhere:bind": { "aliases": [], "args": { @@ -287,6 +250,43 @@ "publish.js" ] }, + "everywhere:unpublish": { + "aliases": [], + "args": {}, + "description": "Unpublishes your plugin from the Workday plugin registry.", + "flags": { + "plugin-dir": { + "char": "D", + "description": "Plugin directory (defaults to current working directory).", + "name": "plugin-dir", + "hasDynamicHelp": false, + "multiple": false, + "type": "option" + }, + "verbose": { + "char": "v", + "description": "Show detailed output.", + "name": "verbose", + "allowNo": false, + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [], + "id": "everywhere:unpublish", + "pluginAlias": "@workday/everywhere", + "pluginName": "@workday/everywhere", + "pluginType": "core", + "strict": true, + "enableJsonFlag": false, + "isESM": true, + "relativePath": [ + "dist", + "commands", + "everywhere", + "unpublish.js" + ] + }, "everywhere:view": { "aliases": [], "args": {}, diff --git a/cli/src/commands/everywhere/publish.ts b/cli/src/commands/everywhere/publish.ts index 8d9fc66..8e01b7a 100644 --- a/cli/src/commands/everywhere/publish.ts +++ b/cli/src/commands/everywhere/publish.ts @@ -31,7 +31,7 @@ export default class PublishCommand extends EverywhereBaseCommand { const manifest = this.loadManifest(pluginDir); this.log('Bundling plugin...'); - const { archive, slug } = await this.buildPluginArchive(manifest, pluginDir); + const { archive } = await this.buildPluginArchive(manifest, pluginDir); this.log('Publishing plugin...'); const result = await this.publishPlugin({ @@ -39,7 +39,6 @@ export default class PublishCommand extends EverywhereBaseCommand { httpsEnabled: config.auth?.https ?? true, token, archivePath: archive.filePath, - appRefId: slug, }); this.log(this.formatSuccessMessage(result, config)); @@ -86,14 +85,12 @@ export default class PublishCommand extends EverywhereBaseCommand { private formatSuccessMessage(result: RegistryUploadResult, config: AppConfig): string { const scheme = (config.auth?.https ?? true) ? 'https' : 'http'; return [ - `Successfully published your plugin: ${result.referenceId}`, + `Successfully published your plugin: ${result.name}`, '', - `Log into ${scheme}://${config.auth?.gateway}/builder/preview to view and deploy your app`, + `Bundle: ${scheme}://${config.auth?.gateway}${result.bundleUrl}`, '', - `ID: ${result.id}`, - `Status: ${result.status}`, - `App type: ${result.appType}`, - `Created by: ${result.creator}`, + `Tenant: ${result.tenant}`, + `Title: ${result.title}`, ].join('\n'); } } diff --git a/cli/src/commands/everywhere/unpublish.ts b/cli/src/commands/everywhere/unpublish.ts new file mode 100644 index 0000000..801875a --- /dev/null +++ b/cli/src/commands/everywhere/unpublish.ts @@ -0,0 +1,54 @@ +import { type AppConfig, appConfig } from '../../config.js'; +import { readPluginManifest } from '../../manifest/manifest.js'; +import { deleteFromRegistry } from '../../registry/registry.js'; +import EverywhereBaseCommand from '../../lib/command.js'; + +export default class UnpublishCommand extends EverywhereBaseCommand { + static override description = 'Unpublishes your plugin from the Workday plugin registry.'; + + static override flags = { + ...EverywhereBaseCommand.baseFlags, + }; + + async run(): Promise { + const pluginDir = await this.parsePluginDir(); + const config = appConfig().read(); + + const { gateway, token } = this.requireAuth(config); + const name = this.loadPluginName(pluginDir); + + await this.unpublishPlugin({ + gateway, + httpsEnabled: config.auth?.https ?? true, + token, + appId: name, + }); + + this.log(`Successfully unpublished plugin: ${name}`); + } + + private requireAuth(config: AppConfig): { gateway: string; token: string } { + const gateway = config.auth?.gateway; + const token = config.auth?.token; + if (!gateway || !token) { + this.error('You must be logged in to unpublish your plugin'); + } + return { gateway, token }; + } + + private loadPluginName(pluginDir: string): string { + try { + return readPluginManifest(pluginDir).name; + } catch (err) { + this.error(err instanceof Error ? err.message : 'Failed to read package.json'); + } + } + + private async unpublishPlugin(options: Parameters[0]): Promise { + try { + await deleteFromRegistry(options); + } catch (err) { + this.error(err instanceof Error ? err.message : 'Failed to unpublish plugin'); + } + } +} diff --git a/cli/src/registry/registry.ts b/cli/src/registry/registry.ts index 8082db5..246617c 100644 --- a/cli/src/registry/registry.ts +++ b/cli/src/registry/registry.ts @@ -1,28 +1,24 @@ import * as fs from 'node:fs'; -import * as path from 'node:path'; export interface RegistryUploadOptions { gateway: string; httpsEnabled: boolean; token: string; archivePath: string; - appRefId: string; } export interface RegistryUploadResult { - id: string; - referenceId: string; - status: string; - appType: string; - creator: string; + tenant: string; + name: string; + title: string; + bundleUrl: string; } const REGISTRY_UPLOAD_RESULT_KEYS: (keyof RegistryUploadResult)[] = [ - 'id', - 'referenceId', - 'status', - 'appType', - 'creator', + 'tenant', + 'name', + 'title', + 'bundleUrl', ]; function parseRegistryUploadResult(json: unknown): RegistryUploadResult { @@ -49,26 +45,54 @@ function parseRegistryUploadResult(json: unknown): RegistryUploadResult { return result; } +export interface RegistryDeleteOptions { + gateway: string; + httpsEnabled: boolean; + token: string; + appId: string; +} + +export async function deleteFromRegistry(options: RegistryDeleteOptions): Promise { + const { gateway, httpsEnabled, token, appId } = options; + + const scheme = httpsEnabled ? 'https' : 'http'; + const url = new URL(`${scheme}://${gateway}/api/v1/app/${appId}`); + + let response: Response; + try { + response = await fetch(url, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error occurred'; + throw new Error(`Failed to unpublish plugin: ${message}`, { cause: error }); + } + + if (!response.ok) { + throw new Error('There was an error unpublishing your plugin from the registry'); + } +} + export async function uploadToRegistry( options: RegistryUploadOptions ): Promise { - const { gateway, httpsEnabled, token, archivePath, appRefId } = options; + const { gateway, httpsEnabled, token, archivePath } = options; const scheme = httpsEnabled ? 'https' : 'http'; - const url = new URL(`${scheme}://${gateway}/builder/v1/apps/source/archive`); + const url = new URL(`${scheme}://${gateway}/api/v1/apps/publish`); const blob = await fs.openAsBlob(archivePath, { type: 'application/zip' }); - const filename = path.basename(archivePath); - const form = new FormData(); - form.set('payload', new File([blob], filename, { type: 'application/zip' })); - form.set('appRefId', appRefId); let response: Response; try { response = await fetch(url, { method: 'POST', - headers: { Authorization: `Bearer ${token}` }, - body: form, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/zip', + }, + body: blob, }); } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error occurred'; diff --git a/cli/tests/commands/everywhere/publish.test.ts b/cli/tests/commands/everywhere/publish.test.ts index d3bbc13..5fade3c 100644 --- a/cli/tests/commands/everywhere/publish.test.ts +++ b/cli/tests/commands/everywhere/publish.test.ts @@ -192,11 +192,10 @@ describe('everywhere publish', () => { describe('when the plugin is published successfully', () => { const registryUploadSuccessResponse = { - id: 'abc123', - referenceId: 'ref-456', - status: 'published', - appType: 'plugin', - creator: 'user@example.com', + tenant: 'acme', + name: 'my-test-plugin', + title: 'My Test Plugin', + bundleUrl: '/api/v1/app/my-test-plugin/bundle.js', }; beforeEach(() => { @@ -253,27 +252,16 @@ describe('everywhere publish', () => { await cmd.run(); expect(fetch).toHaveBeenCalledWith( - new URL('https://registry.example.com/builder/v1/apps/source/archive'), + new URL('https://registry.example.com/api/v1/apps/publish'), expect.objectContaining({ method: 'POST' }) ); }); - it('includes the app reference ID in the upload form', async () => { - await cmd.run(); - - const calls = (fetch as ReturnType).mock.calls; - const lastCall = calls[calls.length - 1]; - if (!lastCall) throw new Error('No fetch calls made'); - const [, options] = lastCall; - const body = options.body as FormData; - expect(body.get('appRefId')).toBe('my-test-plugin'); - }); - it('logs a success message with the registry response details', async () => { const logSpy = vi.spyOn(cmd, 'log'); await cmd.run(); - expect(logSpy).toHaveBeenLastCalledWith(expect.stringContaining('abc123')); + expect(logSpy).toHaveBeenLastCalledWith(expect.stringContaining('my-test-plugin')); }); describe('when auth.https is false', () => { @@ -289,7 +277,7 @@ describe('everywhere publish', () => { await cmd.run(); expect(fetch).toHaveBeenCalledWith( - new URL('http://registry.example.com/builder/v1/apps/source/archive'), + new URL('http://registry.example.com/api/v1/apps/publish'), expect.objectContaining({ method: 'POST' }) ); }); diff --git a/cli/tests/commands/everywhere/unpublish.test.ts b/cli/tests/commands/everywhere/unpublish.test.ts new file mode 100644 index 0000000..4a9810d --- /dev/null +++ b/cli/tests/commands/everywhere/unpublish.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from '@oclif/core/config'; +import type { AppConfig, ConfigProvider } from '../../../src/config.js'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import UnpublishCommand from '../../../src/commands/everywhere/unpublish.js'; +import EverywhereBaseCommand from '../../../src/lib/command.js'; + +vi.mock('../../../src/config.js', () => ({ + appConfig: vi.fn(), + setPluginDir: vi.fn(), +})); + +vi.mock('../../../src/registry/registry.js', () => ({ + deleteFromRegistry: vi.fn(), +})); + +import { appConfig } from '../../../src/config.js'; +import { deleteFromRegistry } from '../../../src/registry/registry.js'; + +describe('everywhere unpublish', () => { + describe('when accessing the description', () => { + it('should include information about unpublishing a plugin from the registry', () => { + expect(UnpublishCommand.description).toBe( + 'Unpublishes your plugin from the Workday plugin registry.' + ); + }); + }); + + describe('flags', () => { + it('inherits the plugin-dir flag from the base command', () => { + expect(UnpublishCommand.flags).toMatchObject(EverywhereBaseCommand.baseFlags); + }); + }); + + describe('run', () => { + let cmd: UnpublishCommand; + let pluginDir: string; + + const loggedInConfig = { + auth: { gateway: 'registry.example.com', token: 'test-auth-token' }, + }; + + const makeConfigProvider = (data: object) => ({ + read: () => data, + write: vi.fn(), + path: '', + }); + + beforeEach(() => { + pluginDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-unpublish-plugin-')); + cmd = new UnpublishCommand([], {} as Config); + vi.spyOn( + cmd as unknown as { parsePluginDir: () => Promise }, + 'parsePluginDir' + ).mockResolvedValue(pluginDir); + }); + + afterEach(() => { + fs.rmSync(pluginDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + describe('when the user is not logged in', () => { + describe('when there is no auth config', () => { + it('errors with a login message', async () => { + vi.mocked(appConfig).mockReturnValue(makeConfigProvider({}) as ConfigProvider); + + await expect(cmd.run()).rejects.toThrow('You must be logged in to unpublish your plugin'); + }); + }); + + describe('when the gateway is not configured', () => { + it('errors with a login message', async () => { + vi.mocked(appConfig).mockReturnValue( + makeConfigProvider({ auth: { token: 'test-auth-token' } }) as ConfigProvider + ); + + await expect(cmd.run()).rejects.toThrow('You must be logged in to unpublish your plugin'); + }); + }); + + describe('when the token is not configured', () => { + it('errors with a login message', async () => { + vi.mocked(appConfig).mockReturnValue( + makeConfigProvider({ + auth: { gateway: 'registry.example.com' }, + }) as ConfigProvider + ); + + await expect(cmd.run()).rejects.toThrow('You must be logged in to unpublish your plugin'); + }); + }); + }); + + describe('when the package manifest is invalid', () => { + beforeEach(() => { + vi.mocked(appConfig).mockReturnValue( + makeConfigProvider(loggedInConfig) as ConfigProvider + ); + }); + + describe('when package.json is missing', () => { + it('errors with a missing manifest message', async () => { + await expect(cmd.run()).rejects.toThrow('No package.json found in the plugin directory.'); + }); + }); + + describe('when the name field is missing', () => { + it('errors about the missing name field', async () => { + fs.writeFileSync( + path.join(pluginDir, 'package.json'), + JSON.stringify({ version: '1.0.0' }), + 'utf-8' + ); + + await expect(cmd.run()).rejects.toThrow('package.json is missing required field: name'); + }); + }); + }); + + describe('when the plugin is unpublished successfully', () => { + beforeEach(() => { + fs.writeFileSync( + path.join(pluginDir, 'package.json'), + JSON.stringify({ name: 'my-test-plugin', version: '1.0.0' }), + 'utf-8' + ); + + vi.mocked(appConfig).mockReturnValue( + makeConfigProvider(loggedInConfig) as ConfigProvider + ); + vi.mocked(deleteFromRegistry).mockResolvedValue(undefined); + }); + + it('calls deleteFromRegistry with the plugin name and auth details', async () => { + await cmd.run(); + + expect(deleteFromRegistry).toHaveBeenCalledWith({ + gateway: 'registry.example.com', + httpsEnabled: true, + token: 'test-auth-token', + appId: 'my-test-plugin', + }); + }); + + it('logs a success message', async () => { + const logSpy = vi.spyOn(cmd, 'log'); + await cmd.run(); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('my-test-plugin')); + }); + + describe('when auth.https is false', () => { + beforeEach(() => { + vi.mocked(appConfig).mockReturnValue( + makeConfigProvider({ + auth: { ...loggedInConfig.auth, https: false }, + }) as ConfigProvider + ); + }); + + it('calls deleteFromRegistry with httpsEnabled false', async () => { + await cmd.run(); + + expect(deleteFromRegistry).toHaveBeenCalledWith( + expect.objectContaining({ httpsEnabled: false }) + ); + }); + }); + }); + + describe('when the delete fails', () => { + it('errors with the failure message', async () => { + fs.writeFileSync( + path.join(pluginDir, 'package.json'), + JSON.stringify({ name: 'my-test-plugin', version: '1.0.0' }), + 'utf-8' + ); + vi.mocked(appConfig).mockReturnValue( + makeConfigProvider(loggedInConfig) as ConfigProvider + ); + vi.mocked(deleteFromRegistry).mockRejectedValue( + new Error('There was an error unpublishing your plugin from the registry') + ); + + await expect(cmd.run()).rejects.toThrow( + 'There was an error unpublishing your plugin from the registry' + ); + }); + }); + }); +}); diff --git a/cli/tests/registry/registry-delete.test.ts b/cli/tests/registry/registry-delete.test.ts new file mode 100644 index 0000000..11cfa9b --- /dev/null +++ b/cli/tests/registry/registry-delete.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { deleteFromRegistry } from '../../src/registry/registry.js'; + +describe('deleteFromRegistry', () => { + const baseOptions = { + gateway: 'registry.example.com', + httpsEnabled: false, + token: 'test-token', + appId: 'abc123', + }; + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + describe('when the delete succeeds', () => { + beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + }) + ); + }); + + it('sends a DELETE request to the http app endpoint when httpsEnabled is false', async () => { + await deleteFromRegistry(baseOptions); + expect(fetch).toHaveBeenCalledWith( + new URL('http://registry.example.com/api/v1/app/abc123'), + expect.objectContaining({ method: 'DELETE' }) + ); + }); + + it('sends a DELETE request to the https app endpoint when httpsEnabled is true', async () => { + await deleteFromRegistry({ ...baseOptions, httpsEnabled: true }); + expect(fetch).toHaveBeenCalledWith( + new URL('https://registry.example.com/api/v1/app/abc123'), + expect.objectContaining({ method: 'DELETE' }) + ); + }); + + it('includes the authorization bearer token in the request header', async () => { + await deleteFromRegistry(baseOptions); + expect(fetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + headers: { Authorization: 'Bearer test-token' }, + }) + ); + }); + }); + + describe('when the server returns a non-OK response', () => { + it('throws with a delete error message', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + }) + ); + + await expect(deleteFromRegistry(baseOptions)).rejects.toThrow( + 'There was an error unpublishing your plugin from the registry' + ); + }); + }); + + describe('when the network request fails', () => { + it('throws with a failure message that includes the cause', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('connection refused'))); + + await expect(deleteFromRegistry(baseOptions)).rejects.toThrow( + 'Failed to unpublish plugin: connection refused' + ); + }); + }); +}); diff --git a/cli/tests/registry/registry.test.ts b/cli/tests/registry/registry.test.ts index 909d7a0..b5539f3 100644 --- a/cli/tests/registry/registry.test.ts +++ b/cli/tests/registry/registry.test.ts @@ -13,7 +13,6 @@ describe('uploadToRegistry', () => { httpsEnabled: false, token: 'test-token', archivePath: '/tmp/test-plugin.zip', - appRefId: 'my-test-plugin', }; beforeEach(() => { @@ -29,11 +28,10 @@ describe('uploadToRegistry', () => { describe('when the upload succeeds', () => { const successResponse = { - id: 'abc123', - referenceId: 'ref-456', - status: 'published', - appType: 'plugin', - creator: 'user@example.com', + tenant: 'acme', + name: 'my-test-plugin', + title: 'My Test Plugin', + bundleUrl: '/api/v1/app/my-test-plugin/bundle.js', }; beforeEach(() => { @@ -54,7 +52,7 @@ describe('uploadToRegistry', () => { it('posts to the http registry URL when httpsEnabled is false', async () => { await uploadToRegistry({ ...baseOptions, httpsEnabled: false }); expect(fetch).toHaveBeenCalledWith( - new URL('http://registry.example.com/builder/v1/apps/source/archive'), + new URL('http://registry.example.com/api/v1/apps/publish'), expect.anything() ); }); @@ -62,7 +60,7 @@ describe('uploadToRegistry', () => { it('posts to the https registry URL when httpsEnabled is true', async () => { await uploadToRegistry({ ...baseOptions, httpsEnabled: true }); expect(fetch).toHaveBeenCalledWith( - new URL('https://registry.example.com/builder/v1/apps/source/archive'), + new URL('https://registry.example.com/api/v1/apps/publish'), expect.anything() ); }); @@ -72,37 +70,28 @@ describe('uploadToRegistry', () => { expect(fetch).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ - headers: { Authorization: 'Bearer test-token' }, + headers: expect.objectContaining({ Authorization: 'Bearer test-token' }), }) ); }); - it('includes the appRefId in the upload form', async () => { + it('sets the Content-Type header to application/zip', async () => { await uploadToRegistry(baseOptions); - const calls = (fetch as ReturnType).mock.calls; - const lastCall = calls[calls.length - 1]; - if (!lastCall) throw new Error('No fetch calls made'); - const [, options] = lastCall; - expect((options.body as FormData).get('appRefId')).toBe('my-test-plugin'); - }); - - it('includes the archive as a File in the upload form payload field', async () => { - await uploadToRegistry(baseOptions); - const calls = (fetch as ReturnType).mock.calls; - const lastCall = calls[calls.length - 1]; - if (!lastCall) throw new Error('No fetch calls made'); - const [, options] = lastCall; - expect((options.body as FormData).get('payload')).toBeInstanceOf(File); + expect(fetch).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + headers: expect.objectContaining({ 'Content-Type': 'application/zip' }), + }) + ); }); - it('uses the archive filename for the uploaded file', async () => { + it('sends the archive contents as the raw request body', async () => { await uploadToRegistry(baseOptions); const calls = (fetch as ReturnType).mock.calls; const lastCall = calls[calls.length - 1]; if (!lastCall) throw new Error('No fetch calls made'); const [, options] = lastCall; - const payload = (options.body as FormData).get('payload') as File; - expect(payload.name).toBe('test-plugin.zip'); + expect(options.body).toBeInstanceOf(Blob); }); }); @@ -138,10 +127,10 @@ describe('uploadToRegistry', () => { ok: true, json: () => Promise.resolve({ + tenant: 'acme', id: 'abc123', - referenceId: 'ref-456', - status: 'published', - appType: 'plugin', + name: 'my-test-plugin', + title: 'My Test Plugin', }), }) ); @@ -156,11 +145,11 @@ describe('uploadToRegistry', () => { ok: true, json: () => Promise.resolve({ + tenant: 'acme', id: 'abc123', - referenceId: 'ref-456', - status: 'published', - appType: 'plugin', - creator: 123, + name: 'my-test-plugin', + title: 'My Test Plugin', + bundleUrl: 123, }), }) ); From 966a9102d0816e0eb1ea4e774c941734c00d6caf Mon Sep 17 00:00:00 2001 From: Jason Heddings Date: Thu, 7 May 2026 14:42:50 -0600 Subject: [PATCH 2/2] fix: migrate remaining endpoints to /api/v1/ prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth token: GET /auth/token → GET /api/v1/auth/token - GraphQL data: /api/data/graphql → /api/v1/data/graphql (default in GraphQLResolver, dev-server proxy in view, vite plugin middleware) Co-Authored-By: Claude Sonnet 4.6 --- cli/src/commands/everywhere/auth/token.ts | 9 ++- cli/src/commands/everywhere/view.ts | 2 +- cli/src/data/vite-data-plugin.ts | 2 +- .../commands/everywhere/auth/token.test.ts | 70 ++++++++++++++++++- src/data/GraphQLResolver.ts | 2 +- tests/data/GraphQLResolver.test.ts | 2 +- 6 files changed, 80 insertions(+), 7 deletions(-) diff --git a/cli/src/commands/everywhere/auth/token.ts b/cli/src/commands/everywhere/auth/token.ts index 8cdec38..e341e5f 100644 --- a/cli/src/commands/everywhere/auth/token.ts +++ b/cli/src/commands/everywhere/auth/token.ts @@ -15,7 +15,7 @@ export default class AuthTokenCommand extends EverywhereBaseCommand { }; async run(): Promise { - const { flags } = await this.parse(AuthTokenCommand); + const { flags } = await this.parseFlags(); const config = appConfig(); const saved = config.read(); const token = saved.auth?.token; @@ -26,7 +26,7 @@ export default class AuthTokenCommand extends EverywhereBaseCommand { const gateway = saved.auth?.gateway ?? DEFAULT_GATEWAY; const scheme = (saved.auth?.https ?? DEFAULT_HTTPS) ? 'https' : 'http'; - const url = `${scheme}://${gateway}/auth/token`; + const url = `${scheme}://${gateway}/api/v1/auth/token`; let response: Response; try { @@ -66,4 +66,9 @@ export default class AuthTokenCommand extends EverywhereBaseCommand { } this.log((parsed as { token: string }).token); } + + protected async parseFlags(): Promise<{ flags: { json: boolean } }> { + const { flags } = await this.parse(AuthTokenCommand); + return { flags: { json: flags.json } }; + } } diff --git a/cli/src/commands/everywhere/view.ts b/cli/src/commands/everywhere/view.ts index 81a5858..c43e248 100644 --- a/cli/src/commands/everywhere/view.ts +++ b/cli/src/commands/everywhere/view.ts @@ -61,7 +61,7 @@ export default class ViewCommand extends EverywhereBaseCommand { ? {} : { proxy: { - '/api/data/graphql': { + '/api/v1/data/graphql': { target: apiServer, changeOrigin: true, configure: (proxy, _options) => { diff --git a/cli/src/data/vite-data-plugin.ts b/cli/src/data/vite-data-plugin.ts index a0f0105..4dd1f2e 100644 --- a/cli/src/data/vite-data-plugin.ts +++ b/cli/src/data/vite-data-plugin.ts @@ -13,7 +13,7 @@ export function dataServicePlugin(pluginDir: string): VitePlugin { // eslint-disable-next-line @typescript-eslint/no-explicit-any configureServer(server: { middlewares: { use: (...args: any[]) => void } }) { server.middlewares.use( - '/api/data/graphql', + '/api/v1/data/graphql', async (req: IncomingMessage, res: ServerResponse) => { if (req.method !== 'POST') { res.writeHead(405, { 'Content-Type': 'application/json' }); diff --git a/cli/tests/commands/everywhere/auth/token.test.ts b/cli/tests/commands/everywhere/auth/token.test.ts index 7e460b8..98ed479 100644 --- a/cli/tests/commands/everywhere/auth/token.test.ts +++ b/cli/tests/commands/everywhere/auth/token.test.ts @@ -1,7 +1,16 @@ -import { describe, it, expect } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from '@oclif/core/config'; +import type { AppConfig, ConfigProvider } from '../../../../src/config.js'; import AuthTokenCommand from '../../../../src/commands/everywhere/auth/token.js'; import EverywhereBaseCommand from '../../../../src/lib/command.js'; +vi.mock('../../../../src/config.js', () => ({ + appConfig: vi.fn(), + setPluginDir: vi.fn(), +})); + +import { appConfig } from '../../../../src/config.js'; + describe('everywhere auth token', () => { it('exists as a command class', () => { expect(AuthTokenCommand).toBeDefined(); @@ -26,4 +35,63 @@ describe('everywhere auth token', () => { expect(AuthTokenCommand.flags['json']).toBeDefined(); }); }); + + describe('run', () => { + let cmd: AuthTokenCommand; + + const loggedInConfig: AppConfig = { + auth: { gateway: 'gateway.example.com', https: false, token: 'test-token' }, + }; + + const makeConfigProvider = (data: object) => + ({ + read: () => data, + write: vi.fn(), + path: '', + }) as ConfigProvider; + + beforeEach(() => { + cmd = new AuthTokenCommand([], {} as Config); + vi.spyOn( + cmd as unknown as { parseFlags: () => Promise<{ flags: { json: boolean } }> }, + 'parseFlags' + ).mockResolvedValue({ flags: { json: false } }); + vi.mocked(appConfig).mockReturnValue(makeConfigProvider(loggedInConfig)); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: () => Promise.resolve('{"token":"new-token"}'), + }) + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('fetches the token from the /api/v1/auth/token endpoint', async () => { + await cmd.run(); + + expect(fetch).toHaveBeenCalledWith( + 'http://gateway.example.com/api/v1/auth/token', + expect.anything() + ); + }); + + it('uses https when auth.https is true', async () => { + vi.mocked(appConfig).mockReturnValue( + makeConfigProvider({ auth: { ...loggedInConfig.auth, https: true } }) + ); + + await cmd.run(); + + expect(fetch).toHaveBeenCalledWith( + 'https://gateway.example.com/api/v1/auth/token', + expect.anything() + ); + }); + }); }); diff --git a/src/data/GraphQLResolver.ts b/src/data/GraphQLResolver.ts index 8bfc51a..fe632a1 100644 --- a/src/data/GraphQLResolver.ts +++ b/src/data/GraphQLResolver.ts @@ -37,7 +37,7 @@ export class GraphQLResolver implements DataResolver { private readonly schemaMap: Map; constructor(referenceId: string, schemas: Record, endpoint?: string) { - this.endpoint = endpoint ?? `${globalThis.window?.location.origin ?? ''}/api/data/graphql`; + this.endpoint = endpoint ?? `${globalThis.window?.location.origin ?? ''}/api/v1/data/graphql`; this.referenceId = referenceId; this.graphPrefix = referenceIdToGraphPrefix(referenceId); this.schemaMap = new Map(Object.entries(schemas)); diff --git a/tests/data/GraphQLResolver.test.ts b/tests/data/GraphQLResolver.test.ts index 79ae18f..31c9eca 100644 --- a/tests/data/GraphQLResolver.test.ts +++ b/tests/data/GraphQLResolver.test.ts @@ -3,7 +3,7 @@ import { GraphQLResolver } from '../../src/data/GraphQLResolver.js'; import type { ModelSchema } from '../../src/data/types.js'; const SCHEMA: ModelSchema = { fields: [], collection: 'things', securityDomains: [] }; -const ENDPOINT = 'https://tenant.workday.com/api/data/graphql'; +const ENDPOINT = 'https://tenant.workday.com/api/v1/data/graphql'; function mockFetch(data: unknown[] = []) { return vi.fn().mockResolvedValue({