diff --git a/cli/bin/dev.js b/cli/bin/dev.js index 3fa79e7..46a4b38 100755 --- a/cli/bin/dev.js +++ b/cli/bin/dev.js @@ -1,9 +1,16 @@ #!/usr/bin/env -S node --import tsx import { execute } from '@oclif/core'; +import { setCliClientHeaders } from '@powersync/cli-core'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import packageJSON from '../package.json' with { type: 'json' }; + +setCliClientHeaders({ + 'user-agent': `POWERSYNC_CLI/${packageJSON.version}` +}); + const __dirname = path.dirname(fileURLToPath(import.meta.url)); await execute({ diff --git a/cli/bin/run.js b/cli/bin/run.js index 176d2af..d953d0b 100755 --- a/cli/bin/run.js +++ b/cli/bin/run.js @@ -1,5 +1,12 @@ #!/usr/bin/env node import { execute } from '@oclif/core'; +import { setCliClientHeaders } from '@powersync/cli-core'; + +import packageJSON from '../package.json' with { type: 'json' }; + +setCliClientHeaders({ + 'user-agent': `POWERSYNC_CLI/${packageJSON.version}` +}); await execute({ dir: import.meta.url }); diff --git a/cli/src/api/cloud/validate-cloud-link-config.ts b/cli/src/api/cloud/validate-cloud-link-config.ts index c30b6bd..365ad1f 100644 --- a/cli/src/api/cloud/validate-cloud-link-config.ts +++ b/cli/src/api/cloud/validate-cloud-link-config.ts @@ -34,7 +34,7 @@ export async function validateCloudLinkConfig( ensureObjectId(orgId, '--org-id'); ensureObjectId(projectId, '--project-id'); - const accountsClient = await createAccountsHubClient(); + const accountsClient = createAccountsHubClient(); try { await accountsClient.getOrganization({ id: orgId }); diff --git a/cli/src/commands/fetch/instances.ts b/cli/src/commands/fetch/instances.ts index 3e17853..f0e2967 100644 --- a/cli/src/commands/fetch/instances.ts +++ b/cli/src/commands/fetch/instances.ts @@ -59,8 +59,8 @@ export default class FetchInstances extends Command { static summary = '[Cloud only] List Cloud instances in the current org/project.'; async run(): Promise { - const accountsClient = await createAccountsHubClient(); - const managementClient = await createCloudClient(); + const accountsClient = createAccountsHubClient(); + const managementClient = createCloudClient(); const { flags } = await this.parse(FetchInstances); const { org_id, project_id } = flags; diff --git a/cli/src/commands/generate/token.ts b/cli/src/commands/generate/token.ts index 26be364..39bcb9b 100644 --- a/cli/src/commands/generate/token.ts +++ b/cli/src/commands/generate/token.ts @@ -44,7 +44,7 @@ export default class GenerateToken extends SharedInstanceCommand { protected async generateCloudToken(project: CloudProject, config: TokenConfig): Promise { const { linked } = project; - const client = await createCloudClient(); + const client = createCloudClient(); // Get the config in order to check if development tokens are enabled. const cloudInstanceConfig = await client diff --git a/cli/src/commands/login.ts b/cli/src/commands/login.ts index 1fa542c..632e0df 100644 --- a/cli/src/commands/login.ts +++ b/cli/src/commands/login.ts @@ -29,7 +29,7 @@ export default class Login extends PowerSyncCommand { } const listOrgs = async (): Promise => { - const accountsHubClient = await createAccountsHubClient(); + const accountsHubClient = createAccountsHubClient(); const orgs = await accountsHubClient.listOrganizations({}); const objects = orgs?.objects ?? []; return objects.map((org) => `\t - ${org.label} - ${org.id}`).join('\n'); diff --git a/cli/test/clients/cli-client-headers.test.ts b/cli/test/clients/cli-client-headers.test.ts new file mode 100644 index 0000000..51238cd --- /dev/null +++ b/cli/test/clients/cli-client-headers.test.ts @@ -0,0 +1,70 @@ +import { + createAccountsHubClient, + createSelfHostedClient, + env, + Services, + setCliClientHeaders +} from '@powersync/cli-core'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +describe('cli client headers', () => { + let fetchSpy: ReturnType; + + beforeEach(async () => { + vi.resetModules(); + + // Ensure the accounts client has a token available. + env.PS_ADMIN_TOKEN = 'test-token'; + vi.spyOn(Services.authentication, 'getToken').mockResolvedValue('test-token'); + + // Spy on fetch with endpoint-specific responses so SDK calls settle. + fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = typeof input === 'string' ? input : input.toString(); + + if (url.includes('/api/admin/v1/diagnostics')) { + return new Response(JSON.stringify({ data: { connections: [] } }), { + headers: { 'content-type': 'application/json' }, + status: 200 + }); + } + + if (url.includes('/api/accounts/v5/organizations/get')) { + return new Response(JSON.stringify({ id: 'org', label: 'test' }), { + headers: { 'content-type': 'application/json' }, + status: 200 + }); + } + + return new Response('{}', { + headers: { 'content-type': 'application/json' }, + status: 200 + }); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + env.PS_ADMIN_TOKEN = undefined; + }); + + test('applies CLI headers across self-hosted, accounts, and cloud clients', async () => { + setCliClientHeaders({ 'user-agent': 'POWERSYNC_CLI/test', 'x-custom': 'value' }); + + // We don't use a cloud client directly, since that is mocked other tests' convinience. + const selfHosted = createSelfHostedClient({ apiKey: 'key', apiUrl: 'test-url' }); + const accounts = createAccountsHubClient(); + + await Promise.all([selfHosted.diagnostics({}), accounts.getOrganization({ id: 'org' })]); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + + const headerSets: Headers[] = fetchSpy.mock.calls.map( + (args: [unknown, { headers?: Record }?]) => new Headers(args[1]?.headers) + ); + + for (const headers of headerSets) { + expect(headers.get('user-agent')).toEqual('POWERSYNC_CLI/test'); + expect(headers.get('x-custom')).toEqual('value'); + } + }); +}); diff --git a/cli/test/commands/link.test.ts b/cli/test/commands/link.test.ts index e134804..4429c05 100644 --- a/cli/test/commands/link.test.ts +++ b/cli/test/commands/link.test.ts @@ -29,8 +29,8 @@ const accountsClientMock = { listProjects: vi.fn() }; -vi.spyOn(cliCore, 'createAccountsHubClient').mockResolvedValue( - accountsClientMock as unknown as Awaited> +vi.spyOn(cliCore, 'createAccountsHubClient').mockImplementation( + () => accountsClientMock as unknown as ReturnType ); function writeServiceYaml(projectDir: string, type: 'cloud' | 'self-hosted') { diff --git a/cli/test/commands/login.test.ts b/cli/test/commands/login.test.ts index 054a0f0..c056713 100644 --- a/cli/test/commands/login.test.ts +++ b/cli/test/commands/login.test.ts @@ -60,7 +60,7 @@ describe('login', () => { address: 'http://127.0.0.1:54321', tokenPromise: Promise.resolve('server-token') }); - mockedCreateAccountsHubClient.mockResolvedValue({ + mockedCreateAccountsHubClient.mockReturnValue({ listOrganizations: vi.fn().mockResolvedValue({ objects: [{ id: 'org-1', label: 'Org One' }] }) diff --git a/cli/test/commands/pull/instance.test.ts b/cli/test/commands/pull/instance.test.ts index e2112ec..9198868 100644 --- a/cli/test/commands/pull/instance.test.ts +++ b/cli/test/commands/pull/instance.test.ts @@ -31,7 +31,7 @@ const accountsClientMock = { }; vi.spyOn(cliCore, 'createAccountsHubClient').mockImplementation( - async () => accountsClientMock as unknown as Awaited> + () => accountsClientMock as unknown as ReturnType ); function writeServiceYaml(projectDir: string, type: 'cloud' | 'self-hosted') { diff --git a/packages/cli-core/src/clients/AccountsHubClientSDKClient.ts b/packages/cli-core/src/clients/AccountsHubClientSDKClient.ts index ac05e7a..b599fc2 100644 --- a/packages/cli-core/src/clients/AccountsHubClientSDKClient.ts +++ b/packages/cli-core/src/clients/AccountsHubClientSDKClient.ts @@ -12,6 +12,7 @@ import { ux } from '@oclif/core'; import { Services } from '../services/Services.js'; import { env } from '../utils/env.js'; +import { getCliClientHeadersStore } from './cli-client-headers.js'; /** * Client for interacting with the AccountsHub API service. @@ -59,20 +60,23 @@ export class AccountsHubClientSDKClient { - const { authentication } = Services; - const token = env.PS_ADMIN_TOKEN || (await authentication.getToken()); - if (!token) { - throw new Error( - `Not logged in. Run ${ux.colorize('blue', 'powersync login')} to authenticate (you will be prompted for your token), or provide the ${ux.colorize('blue', 'PS_ADMIN_TOKEN')} environment variable.` - ); - } - +export function createAccountsHubClient(): AccountsHubClientSDKClient { return new AccountsHubClientSDKClient({ client: sdk.createWebNetworkClient({ - headers: () => ({ - Authorization: `Bearer ${token}` - }) + async headers() { + const { authentication } = Services; + const token = env.PS_ADMIN_TOKEN || (await authentication.getToken()); + if (!token) { + throw new Error( + `Not logged in. Run ${ux.colorize('blue', 'powersync login')} to authenticate (you will be prompted for your token), or provide the ${ux.colorize('blue', 'PS_ADMIN_TOKEN')} environment variable.` + ); + } + + return { + ...getCliClientHeadersStore().headers, + Authorization: `Bearer ${token}` + }; + } }), endpoint: env._PS_ACCOUNTS_HUB_SERVICE_URL }); @@ -86,7 +90,7 @@ export async function createAccountsHubClient(): Promise { - const client = await createAccountsHubClient(); + const client = createAccountsHubClient(); const { objects: organizations, total } = await client.listOrganizations({}); if (total === 0) { throw new Error( diff --git a/packages/cli-core/src/clients/cli-client-headers.ts b/packages/cli-core/src/clients/cli-client-headers.ts new file mode 100644 index 0000000..1e442f7 --- /dev/null +++ b/packages/cli-core/src/clients/cli-client-headers.ts @@ -0,0 +1,38 @@ +/** + * Process-wide store key for CLI client headers. + * + * Why global: + * - CLI startup code lives in the `cli` package, while client creation lives in `cli-core`. + * - In some environments, duplicate copies of `@powersync/cli-core` could be loaded (in the future perhaps). + * - Module-level state would then be duplicated, causing header injection (for example User-Agent) + * to only affect one copy. + * + * Using `globalThis` + `Symbol.for(...)` gives us one shared store for this process, + * regardless of how many module instances are loaded. + */ +const CLI_CLIENT_HEADERS_STORE_KEY = Symbol.for('powersync.cli-core.cliClientHeaders'); + +type CliClientHeadersStore = { + headers: Record; +}; + +export function getCliClientHeadersStore(): CliClientHeadersStore { + // Read/write the shared process-wide store so all cli-core instances observe the same headers. + const globalScope = globalThis as typeof globalThis & { + [CLI_CLIENT_HEADERS_STORE_KEY]?: CliClientHeadersStore; + }; + + if (!globalScope[CLI_CLIENT_HEADERS_STORE_KEY]) { + globalScope[CLI_CLIENT_HEADERS_STORE_KEY] = { headers: {} }; + } + + return globalScope[CLI_CLIENT_HEADERS_STORE_KEY]; +} + +/** + * Sets headers that are applied to all outbound CLI clients (cloud and self-hosted). + * Existing clients also pick up updates because headers are resolved per request. + */ +export function setCliClientHeaders(headers: Record): void { + Object.assign(getCliClientHeadersStore().headers, headers); +} diff --git a/packages/cli-core/src/clients/create-cloud-client.ts b/packages/cli-core/src/clients/create-cloud-client.ts index d13cde0..e4b2217 100644 --- a/packages/cli-core/src/clients/create-cloud-client.ts +++ b/packages/cli-core/src/clients/create-cloud-client.ts @@ -4,6 +4,7 @@ import { PowerSyncManagementClient } from '@powersync/management-client'; import { Services } from '../services/Services.js'; import { env } from '../utils/env.js'; +import { getCliClientHeadersStore } from './cli-client-headers.js'; /** * Creates a PowerSync Management Client for the Cloud. @@ -30,6 +31,7 @@ export function createCloudClient(): PowerSyncManagementClient { } return { + ...getCliClientHeadersStore().headers, Authorization: `Bearer ${token}` }; } diff --git a/packages/cli-core/src/clients/create-self-hosted-client.ts b/packages/cli-core/src/clients/create-self-hosted-client.ts index 6db25cb..d7f5762 100644 --- a/packages/cli-core/src/clients/create-self-hosted-client.ts +++ b/packages/cli-core/src/clients/create-self-hosted-client.ts @@ -1,6 +1,8 @@ import * as sdk from '@journeyapps-labs/common-sdk'; import { InstanceClient } from '@powersync/service-client'; +import { getCliClientHeadersStore } from './cli-client-headers.js'; + export type SelfHostedClientConfig = { apiKey: string; apiUrl: string; @@ -11,8 +13,13 @@ export type SelfHostedClientConfig = { */ export function createSelfHostedClient(config: SelfHostedClientConfig) { return new InstanceClient({ - client: sdk.createNodeNetworkClient({ + /** + * Use the web (fetch-based) network client to mirror the cloud client behavior and + * allow fetch to be spied on in tests. Node exposes fetch globally so we can rely on it. + */ + client: sdk.createWebNetworkClient({ headers: () => ({ + ...getCliClientHeadersStore().headers, Authorization: `Bearer ${config.apiKey}` }) }), diff --git a/packages/cli-core/src/index.ts b/packages/cli-core/src/index.ts index 98b4396..22e51a1 100644 --- a/packages/cli-core/src/index.ts +++ b/packages/cli-core/src/index.ts @@ -3,6 +3,7 @@ * Plugins (e.g. plugin-docker) import from @powersync/cli-core. */ export * from './clients/AccountsHubClientSDKClient.js'; +export * from './clients/cli-client-headers.js'; export * from './clients/create-cloud-client.js'; export * from './clients/create-self-hosted-client.js'; export * from './command-types/CloudInstanceCommand.js';