From 68e87951c50463872714efac1249103e02db61bb Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Tue, 14 Apr 2026 19:11:29 -0700 Subject: [PATCH 1/3] feat: add --token flag and fetch projects command for agent UX Enables AI coding agents to use the PowerSync CLI end-to-end without visiting the dashboard: - Add reusable --token flag on all Cloud commands (inherited via CloudInstanceCommand.baseFlags) plus fetch instances. Precedence: --token > PS_ADMIN_TOKEN env > keychain/stored token. Implemented via a process-wide override store mirroring cli-client-headers. - Add fetch projects command to list Cloud projects the token can access (id, name, org, instance count). Supports --output=json for piping into jq. Removes the "extract project-id from dashboard URL" step. - Improve not-authenticated error: lists all three auth methods (--token flag, PS_ADMIN_TOKEN env, powersync login) and links to the PAT creation page. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/package.json | 2 +- cli/src/commands/fetch/index.ts | 6 +- cli/src/commands/fetch/instances.ts | 7 +- cli/src/commands/fetch/projects.ts | 156 ++++++++++++++++++ cli/test/clients/cli-token-override.test.ts | 88 ++++++++++ cli/test/commands/fetch/projects.test.ts | 122 ++++++++++++++ .../src/clients/AccountsHubClientSDKClient.ts | 9 +- .../cli-core/src/clients/auth-token-flag.ts | 20 +++ .../src/clients/cli-token-override.ts | 41 +++++ .../src/clients/create-cloud-client.ts | 9 +- .../src/command-types/CloudInstanceCommand.ts | 5 + packages/cli-core/src/index.ts | 2 + 12 files changed, 458 insertions(+), 9 deletions(-) create mode 100644 cli/src/commands/fetch/projects.ts create mode 100644 cli/test/clients/cli-token-override.test.ts create mode 100644 cli/test/commands/fetch/projects.test.ts create mode 100644 packages/cli-core/src/clients/auth-token-flag.ts create mode 100644 packages/cli-core/src/clients/cli-token-override.ts diff --git a/cli/package.json b/cli/package.json index 1ca2423..3dd7c2b 100644 --- a/cli/package.json +++ b/cli/package.json @@ -91,7 +91,7 @@ "description": "[Cloud only] Deploy local config changes to the Cloud instance. Run \"powersync deploy --help\" to list subcommands." }, "fetch": { - "description": "Inspect Cloud instances and configuration (instances, config, status). Run \"powersync fetch --help\" to list subcommands." + "description": "Inspect Cloud projects, instances, and configuration (projects, instances, config, status). Run \"powersync fetch --help\" to list subcommands." }, "generate": { "description": "Generate client artifacts from instance/config data (schema, token). Run \"powersync generate --help\" to list subcommands." diff --git a/cli/src/commands/fetch/index.ts b/cli/src/commands/fetch/index.ts index 2949ea4..69eb27d 100644 --- a/cli/src/commands/fetch/index.ts +++ b/cli/src/commands/fetch/index.ts @@ -8,13 +8,13 @@ import { Command } from '@oclif/core'; export default class Fetch extends Command { static description = - 'Subcommands: list Cloud instances in org/project (fetch instances), print instance config as YAML/JSON (fetch config), or show instance diagnostics (fetch status).'; + 'Subcommands: list Cloud projects visible to the token (fetch projects), list Cloud instances in org/project (fetch instances), print instance config as YAML/JSON (fetch config), or show instance diagnostics (fetch status).'; static examples = ['<%= config.bin %> <%= command.id %>']; static hidden = true; - static summary = 'List instances, fetch config, or fetch instance diagnostics.'; + static summary = 'List projects, list instances, fetch config, or fetch instance diagnostics.'; async run(): Promise { await this.parse(Fetch); - this.log('Use a subcommand: fetch instances | fetch config | fetch status'); + this.log('Use a subcommand: fetch projects | fetch instances | fetch config | fetch status'); } } diff --git a/cli/src/commands/fetch/instances.ts b/cli/src/commands/fetch/instances.ts index a4c329b..8531c62 100644 --- a/cli/src/commands/fetch/instances.ts +++ b/cli/src/commands/fetch/instances.ts @@ -1,10 +1,12 @@ import { Command, Flags, ux } from '@oclif/core'; import { + authTokenFlag, CLI_FILENAME, CommandHelpGroup, createAccountsHubClient, createCloudClient, - parseYamlFile + parseYamlFile, + setCliTokenOverride } from '@powersync/cli-core'; import { CLIConfig } from '@powersync/cli-schemas'; import sortBy from 'lodash/sortBy.js'; @@ -48,6 +50,7 @@ export default class FetchInstances extends Command { '<%= config.bin %> <%= command.id %> --project-id= --output=json' ]; static flags = { + ...authTokenFlag, 'org-id': Flags.string({ description: 'Optional Organization ID. Defaults to all organizations.', required: false @@ -175,6 +178,8 @@ export default class FetchInstances extends Command { async run(): Promise { const { flags } = await this.parse(FetchInstances); + setCliTokenOverride(flags.token); + this.log(''); // Add spacing const cloudInstanceMap = await this.fetchCloudInstances({ diff --git a/cli/src/commands/fetch/projects.ts b/cli/src/commands/fetch/projects.ts new file mode 100644 index 0000000..edd2019 --- /dev/null +++ b/cli/src/commands/fetch/projects.ts @@ -0,0 +1,156 @@ +import { Command, Flags, ux } from '@oclif/core'; +import { + authTokenFlag, + CommandHelpGroup, + createAccountsHubClient, + createCloudClient, + setCliTokenOverride +} from '@powersync/cli-core'; +import sortBy from 'lodash/sortBy.js'; +import fs from 'node:fs/promises'; +import ora from 'ora'; + +type ProjectRow = { + id: string; + instance_count: number; + name: string; + org_id: string; + org_name: string; +}; + +export default class FetchProjects extends Command { + static commandHelpGroup = CommandHelpGroup.CLOUD; + static description = + 'List PowerSync Cloud projects the authenticated token has access to, grouped by organization. Use this to discover the project-id needed for `link cloud`, `fetch instances`, and other Cloud commands without opening the dashboard.'; + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --output=json', + '<%= config.bin %> <%= command.id %> --org-id= --output=json', + '<%= config.bin %> <%= command.id %> --token=jpt_xxx --output=json' + ]; + static flags = { + ...authTokenFlag, + 'include-instance-count': Flags.boolean({ + allowNo: true, + default: true, + description: + 'Include the number of instances for each project (one extra API call per project). Use --no-include-instance-count to skip.' + }), + 'org-id': Flags.string({ + description: 'Optional Organization ID. Defaults to all organizations the token can access.', + required: false + }), + output: Flags.string({ + default: 'human', + description: 'Output format: human or json.', + options: ['human', 'json'] + }), + 'output-file': Flags.string({ + description: 'Optionally write project information to a file.', + required: false + }) + }; + static summary = 'List Cloud projects (id, name, org, instance count).'; + + async run(): Promise { + const { flags } = await this.parse(FetchProjects); + + setCliTokenOverride(flags.token); + + const accountsClient = createAccountsHubClient(); + const managementClient = createCloudClient(); + + const rows: ProjectRow[] = []; + // In JSON mode, progress must not pollute stdout; route spinner to stderr and skip when not TTY. + const jsonMode = flags.output === 'json'; + const spinner = ora({ + discardStdin: false, + isEnabled: !jsonMode && process.stderr.isTTY, + stream: process.stderr, + text: 'Fetching projects...' + }); + + let spinnerStarted = false; + try { + for await (const orgPage of accountsClient.listOrganizations.paginate({ id: flags['org-id'] })) { + const { objects: organizations, total: totalOrgs } = orgPage; + if (!spinnerStarted && totalOrgs > 0) { + spinner.start(); + spinnerStarted = true; + } + + for (const organization of organizations) { + spinner.text = `Fetching projects in ${organization.label}...`; + for await (const projectPage of accountsClient.listProjects.paginate({ + org_id: organization.id + })) { + for (const project of projectPage.objects) { + let instance_count = 0; + if (flags['include-instance-count']) { + const instances = await managementClient.listInstances({ + app_id: project.id, + org_id: organization.id + }); + instance_count = instances.instances.length; + } + + rows.push({ + id: project.id, + instance_count, + name: project.name, + org_id: organization.id, + org_name: organization.label + }); + } + } + } + } + } finally { + if (spinnerStarted) { + spinner.stop(); + } + } + + const sorted = sortBy(rows, ['org_name', 'name']); + + if (flags.output === 'human') { + this.log(''); + if (sorted.length === 0) { + this.log('No projects found for the authenticated token.'); + } else { + let currentOrgId = ''; + for (const row of sorted) { + if (row.org_id !== currentOrgId) { + this.log( + `${ux.colorize('blue', 'Organization: ')} ${row.org_name} ${ux.colorize('gray', `id: ${row.org_id}`)}` + ); + currentOrgId = row.org_id; + } + + const instanceLabel = flags['include-instance-count'] + ? ` ${ux.colorize('gray', `instances: ${row.instance_count}`)}` + : ''; + this.log( + `\t${ux.colorize('blue', 'Project: ')} ${row.name} ${ux.colorize('gray', `id: ${row.id}`)}${instanceLabel}` + ); + } + } + + this.log(''); + } + + const outputObject = { projects: sorted }; + + if (flags.output === 'json' || flags['output-file']) { + // Plain JSON (no ANSI colors) so it can be piped into jq, file, etc. without post-processing. + const content = JSON.stringify(outputObject, null, 2); + if (flags.output === 'json') { + this.log(content); + } + + if (flags['output-file']) { + await fs.writeFile(flags['output-file'], content); + } + } + } +} diff --git a/cli/test/clients/cli-token-override.test.ts b/cli/test/clients/cli-token-override.test.ts new file mode 100644 index 0000000..e07d487 --- /dev/null +++ b/cli/test/clients/cli-token-override.test.ts @@ -0,0 +1,88 @@ +import { createAccountsHubClient, env, Services, setCliTokenOverride } from '@powersync/cli-core'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +describe('cli token override', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + vi.resetModules(); + + fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => + new Response(JSON.stringify({ id: 'org', label: 'test' }), { + headers: { 'content-type': 'application/json' }, + status: 200 + }) + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + env.PS_ADMIN_TOKEN = undefined; + setCliTokenOverride(null); + }); + + test('override takes precedence over PS_ADMIN_TOKEN and stored token', async () => { + env.PS_ADMIN_TOKEN = 'env-token'; + vi.spyOn(Services.authentication, 'getToken').mockResolvedValue('stored-token'); + setCliTokenOverride('override-token'); + + const accounts = createAccountsHubClient(); + await accounts.getOrganization({ id: 'org' }); + + const headers = new Headers( + (fetchSpy.mock.calls[0] as [unknown, { headers?: Record }])[1]?.headers + ); + expect(headers.get('authorization')).toEqual('Bearer override-token'); + }); + + test('falls back to PS_ADMIN_TOKEN when override is null', async () => { + env.PS_ADMIN_TOKEN = 'env-token'; + vi.spyOn(Services.authentication, 'getToken').mockResolvedValue('stored-token'); + setCliTokenOverride(null); + + const accounts = createAccountsHubClient(); + await accounts.getOrganization({ id: 'org' }); + + const headers = new Headers( + (fetchSpy.mock.calls[0] as [unknown, { headers?: Record }])[1]?.headers + ); + expect(headers.get('authorization')).toEqual('Bearer env-token'); + }); + + test('falls back to stored token when override and env are absent', async () => { + env.PS_ADMIN_TOKEN = undefined; + vi.spyOn(Services.authentication, 'getToken').mockResolvedValue('stored-token'); + setCliTokenOverride(null); + + const accounts = createAccountsHubClient(); + await accounts.getOrganization({ id: 'org' }); + + const headers = new Headers( + (fetchSpy.mock.calls[0] as [unknown, { headers?: Record }])[1]?.headers + ); + expect(headers.get('authorization')).toEqual('Bearer stored-token'); + }); + + test('empty/whitespace override is treated as absent', async () => { + env.PS_ADMIN_TOKEN = 'env-token'; + vi.spyOn(Services.authentication, 'getToken').mockResolvedValue('stored-token'); + setCliTokenOverride(' '); + + const accounts = createAccountsHubClient(); + await accounts.getOrganization({ id: 'org' }); + + const headers = new Headers( + (fetchSpy.mock.calls[0] as [unknown, { headers?: Record }])[1]?.headers + ); + expect(headers.get('authorization')).toEqual('Bearer env-token'); + }); + + test('throws with PAT creation URL when no token is available', async () => { + env.PS_ADMIN_TOKEN = undefined; + vi.spyOn(Services.authentication, 'getToken').mockResolvedValue(null); + setCliTokenOverride(null); + + const accounts = createAccountsHubClient(); + await expect(accounts.getOrganization({ id: 'org' })).rejects.toThrow(/dashboard\.powersync\.com/); + }); +}); diff --git a/cli/test/commands/fetch/projects.test.ts b/cli/test/commands/fetch/projects.test.ts new file mode 100644 index 0000000..f4713ac --- /dev/null +++ b/cli/test/commands/fetch/projects.test.ts @@ -0,0 +1,122 @@ +import { Config } from '@oclif/core'; +import { captureOutput } from '@oclif/test'; +import { Services } from '@powersync/cli-core'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import FetchProjectsCommand from '../../../src/commands/fetch/projects.js'; +import { root } from '../../helpers/root.js'; +import { managementClientMock, MOCK_CLOUD_IDS } from '../../setup.js'; + +describe('fetch projects', () => { + let fetchSpy: ReturnType; + let oclifConfig: Config; + + beforeAll(async () => { + oclifConfig = await Config.load({ root }); + }); + + beforeEach(() => { + vi.spyOn(Services.authentication, 'getToken').mockResolvedValue('stored-token'); + + // Mock the accounts API responses (organizations and projects lists). + fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = typeof input === 'string' ? input : input.toString(); + + if (url.includes('/api/accounts/v5/organizations/list')) { + return new Response( + JSON.stringify({ + data: { + count: 1, + more: false, + objects: [{ id: MOCK_CLOUD_IDS.orgId, label: 'test-org' }], + total: 1 + } + }), + { headers: { 'content-type': 'application/json' }, status: 200 } + ); + } + + if (url.includes('/api/accounts/v5/apps/list')) { + return new Response( + JSON.stringify({ + data: { + count: 2, + more: false, + objects: [ + { id: MOCK_CLOUD_IDS.projectId, name: 'alpha-project' }, + { id: '699ef9c371c56d0007320544', name: 'beta-project' } + ], + total: 2 + } + }), + { headers: { 'content-type': 'application/json' }, status: 200 } + ); + } + + return new Response(JSON.stringify({ data: {} }), { + headers: { 'content-type': 'application/json' }, + status: 200 + }); + }); + + managementClientMock.listInstances = vi.fn().mockResolvedValue({ instances: [{ id: 'a' }, { id: 'b' }] }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + delete managementClientMock.listInstances; + }); + + function runDirect(args: string[]) { + const cmd = new FetchProjectsCommand(args, oclifConfig); + return captureOutput(() => cmd.run()); + } + + it('lists projects in human format by default', async () => { + const result = await runDirect(['--no-include-instance-count']); + expect(result.error).toBeUndefined(); + expect(result.stdout).toContain('Organization:'); + expect(result.stdout).toContain('test-org'); + expect(result.stdout).toContain('alpha-project'); + expect(result.stdout).toContain('beta-project'); + expect(result.stdout).toContain(MOCK_CLOUD_IDS.projectId); + }); + + it('returns JSON when --output=json', async () => { + const result = await runDirect(['--output', 'json', '--no-include-instance-count']); + expect(result.error).toBeUndefined(); + + const parsed = JSON.parse(result.stdout) as { + projects: Array<{ id: string; instance_count: number; name: string; org_id: string; org_name: string }>; + }; + expect(parsed.projects).toHaveLength(2); + expect(parsed.projects[0]).toMatchObject({ + id: MOCK_CLOUD_IDS.projectId, + instance_count: 0, + name: 'alpha-project', + org_id: MOCK_CLOUD_IDS.orgId, + org_name: 'test-org' + }); + }); + + it('passes --token to the API via authorization header', async () => { + const result = await runDirect([ + '--token', + 'override-token', + '--output', + 'json', + '--no-include-instance-count' + ]); + expect(result.error).toBeUndefined(); + + const accountsCall = (fetchSpy.mock.calls as Array<[unknown, { headers?: Record }?]>).find( + ([input]) => { + const url = typeof input === 'string' ? input : String(input); + return url.includes('/api/accounts/'); + } + ); + expect(accountsCall).toBeDefined(); + const headers = new Headers(accountsCall?.[1]?.headers); + expect(headers.get('authorization')).toEqual('Bearer override-token'); + }); +}); diff --git a/packages/cli-core/src/clients/AccountsHubClientSDKClient.ts b/packages/cli-core/src/clients/AccountsHubClientSDKClient.ts index b599fc2..5be3187 100644 --- a/packages/cli-core/src/clients/AccountsHubClientSDKClient.ts +++ b/packages/cli-core/src/clients/AccountsHubClientSDKClient.ts @@ -13,6 +13,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'; +import { getCliTokenOverride } from './cli-token-override.js'; /** * Client for interacting with the AccountsHub API service. @@ -65,10 +66,14 @@ export function createAccountsHubClient(): AccountsHubClientSDKClient { client: sdk.createWebNetworkClient({ async headers() { const { authentication } = Services; - const token = env.PS_ADMIN_TOKEN || (await authentication.getToken()); + const token = getCliTokenOverride() || 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.` + `Not authenticated. Provide a PowerSync Personal Access Token via any of:\n` + + ` - ${ux.colorize('blue', '--token=')} flag on the command\n` + + ` - ${ux.colorize('blue', 'PS_ADMIN_TOKEN')} environment variable\n` + + ` - ${ux.colorize('blue', 'powersync login')} (stores token in secure storage)\n` + + `Create a token at ${ux.colorize('blue', 'https://dashboard.powersync.com/dashboard/administration/personal-access-tokens')}.` ); } diff --git a/packages/cli-core/src/clients/auth-token-flag.ts b/packages/cli-core/src/clients/auth-token-flag.ts new file mode 100644 index 0000000..eedb1bc --- /dev/null +++ b/packages/cli-core/src/clients/auth-token-flag.ts @@ -0,0 +1,20 @@ +import { Flags } from '@oclif/core'; + +/** + * Reusable `--token` flag that accepts a PowerSync Personal Access Token inline. + * + * Precedence when resolving auth: + * 1. `--token` flag (this flag, via `setCliTokenOverride(flags.token)`) + * 2. `PS_ADMIN_TOKEN` env var + * 3. Token stored via `powersync login` + * + * Commands that touch the Cloud API should spread this into their `flags` and pass the + * value to `setCliTokenOverride` before making any authenticated calls. + */ +export const authTokenFlag = { + token: Flags.string({ + description: + 'PowerSync Personal Access Token (PAT). Overrides PS_ADMIN_TOKEN env var and the token stored via `powersync login`. Create one at https://dashboard.powersync.com/dashboard/administration/personal-access-tokens.', + required: false + }) +}; diff --git a/packages/cli-core/src/clients/cli-token-override.ts b/packages/cli-core/src/clients/cli-token-override.ts new file mode 100644 index 0000000..6856d8b --- /dev/null +++ b/packages/cli-core/src/clients/cli-token-override.ts @@ -0,0 +1,41 @@ +/** + * Process-wide store for an explicit auth token passed via `--token` flag. + * + * Resolution precedence for auth: + * 1. Token override set here via `setCliTokenOverride` (from `--token` flag) + * 2. `PS_ADMIN_TOKEN` environment variable + * 3. Stored token (keychain or insecure config) via `Services.authentication` + * + * Uses the same `globalThis` + `Symbol.for(...)` pattern as `cli-client-headers` so + * that all copies of `@powersync/cli-core` in the process see the same override. + */ +const CLI_TOKEN_OVERRIDE_STORE_KEY = Symbol.for('powersync.cli-core.cliTokenOverride'); + +type CliTokenOverrideStore = { + token: null | string; +}; + +function getStore(): CliTokenOverrideStore { + const globalScope = globalThis as typeof globalThis & { + [CLI_TOKEN_OVERRIDE_STORE_KEY]?: CliTokenOverrideStore; + }; + + if (!globalScope[CLI_TOKEN_OVERRIDE_STORE_KEY]) { + globalScope[CLI_TOKEN_OVERRIDE_STORE_KEY] = { token: null }; + } + + return globalScope[CLI_TOKEN_OVERRIDE_STORE_KEY]; +} + +/** + * Sets an explicit auth token that takes precedence over the env var and stored token. + * Pass null/undefined/empty string to clear the override. + */ +export function setCliTokenOverride(token: null | string | undefined): void { + const trimmed = typeof token === 'string' ? token.trim() : ''; + getStore().token = trimmed.length > 0 ? trimmed : null; +} + +export function getCliTokenOverride(): null | string { + return getStore().token; +} diff --git a/packages/cli-core/src/clients/create-cloud-client.ts b/packages/cli-core/src/clients/create-cloud-client.ts index e4b2217..8b9f16a 100644 --- a/packages/cli-core/src/clients/create-cloud-client.ts +++ b/packages/cli-core/src/clients/create-cloud-client.ts @@ -5,6 +5,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'; +import { getCliTokenOverride } from './cli-token-override.js'; /** * Creates a PowerSync Management Client for the Cloud. @@ -23,10 +24,14 @@ export function createCloudClient(): PowerSyncManagementClient { */ client: sdk.createWebNetworkClient({ async headers() { - const token = env.PS_ADMIN_TOKEN || (await Services.authentication.getToken()); + const token = getCliTokenOverride() || env.PS_ADMIN_TOKEN || (await Services.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.` + `Not authenticated. Provide a PowerSync Personal Access Token via any of:\n` + + ` - ${ux.colorize('blue', '--token=')} flag on the command\n` + + ` - ${ux.colorize('blue', 'PS_ADMIN_TOKEN')} environment variable\n` + + ` - ${ux.colorize('blue', 'powersync login')} (stores token in secure storage)\n` + + `Create a token at ${ux.colorize('blue', 'https://dashboard.powersync.com/dashboard/administration/personal-access-tokens')}.` ); } diff --git a/packages/cli-core/src/command-types/CloudInstanceCommand.ts b/packages/cli-core/src/command-types/CloudInstanceCommand.ts index ca728dd..ad287ca 100644 --- a/packages/cli-core/src/command-types/CloudInstanceCommand.ts +++ b/packages/cli-core/src/command-types/CloudInstanceCommand.ts @@ -10,6 +10,8 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { getDefaultOrgId } from '../clients/AccountsHubClientSDKClient.js'; +import { authTokenFlag } from '../clients/auth-token-flag.js'; +import { setCliTokenOverride } from '../clients/cli-token-override.js'; import { createCloudClient } from '../clients/create-cloud-client.js'; import { ensureServiceTypeMatches, ServiceType } from '../utils/ensure-service-type.js'; import { env } from '../utils/env.js'; @@ -57,6 +59,7 @@ export abstract class CloudInstanceCommand extends InstanceCommand { * Instance ID, org ID, and project ID are resolved in order: flags → cli.yaml → env (INSTANCE_ID, ORG_ID, PROJECT_ID). */ ...InstanceCommand.baseFlags, + ...authTokenFlag, 'instance-id': Flags.string({ dependsOn: ['project-id'], description: 'PowerSync Cloud instance ID. Manually passed if the current context has not been linked.', @@ -122,6 +125,8 @@ export abstract class CloudInstanceCommand extends InstanceCommand { flags: CloudInstanceCommandFlags, options: EnsureConfigOptions = DEFAULT_ENSURE_CONFIG_OPTIONS ): Promise { + // Apply --token override before any authenticated API call (getDefaultOrgId below, plus subclass calls). + setCliTokenOverride(flags.token); const resolvedOptions = { ...DEFAULT_ENSURE_CONFIG_OPTIONS, // Keep this order so call-site options override defaults. diff --git a/packages/cli-core/src/index.ts b/packages/cli-core/src/index.ts index e2ef8c1..fb29baa 100644 --- a/packages/cli-core/src/index.ts +++ b/packages/cli-core/src/index.ts @@ -4,7 +4,9 @@ */ export * from './api/validate-sync-config.js'; export * from './clients/AccountsHubClientSDKClient.js'; +export * from './clients/auth-token-flag.js'; export * from './clients/cli-client-headers.js'; +export * from './clients/cli-token-override.js'; export * from './clients/create-cloud-client.js'; export * from './clients/create-self-hosted-client.js'; export * from './command-types/CloudInstanceCommand.js'; From 777d946b209618135a7efb8dc7514e427dc01732 Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Tue, 14 Apr 2026 19:51:17 -0700 Subject: [PATCH 2/3] fix(fetch projects): suppress spinner entirely in json mode for clean stderr --- cli/src/commands/fetch/projects.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/cli/src/commands/fetch/projects.ts b/cli/src/commands/fetch/projects.ts index edd2019..148390e 100644 --- a/cli/src/commands/fetch/projects.ts +++ b/cli/src/commands/fetch/projects.ts @@ -61,26 +61,30 @@ export default class FetchProjects extends Command { const managementClient = createCloudClient(); const rows: ProjectRow[] = []; - // In JSON mode, progress must not pollute stdout; route spinner to stderr and skip when not TTY. + // In JSON mode, skip the spinner entirely so stderr stays clean for strict machine-readable output. const jsonMode = flags.output === 'json'; - const spinner = ora({ - discardStdin: false, - isEnabled: !jsonMode && process.stderr.isTTY, - stream: process.stderr, - text: 'Fetching projects...' - }); + const spinner = + !jsonMode && process.stderr.isTTY + ? ora({ + discardStdin: false, + stream: process.stderr, + text: 'Fetching projects...' + }) + : undefined; let spinnerStarted = false; try { for await (const orgPage of accountsClient.listOrganizations.paginate({ id: flags['org-id'] })) { const { objects: organizations, total: totalOrgs } = orgPage; - if (!spinnerStarted && totalOrgs > 0) { + if (spinner && !spinnerStarted && totalOrgs > 0) { spinner.start(); spinnerStarted = true; } for (const organization of organizations) { - spinner.text = `Fetching projects in ${organization.label}...`; + if (spinner) { + spinner.text = `Fetching projects in ${organization.label}...`; + } for await (const projectPage of accountsClient.listProjects.paginate({ org_id: organization.id })) { @@ -106,7 +110,7 @@ export default class FetchProjects extends Command { } } } finally { - if (spinnerStarted) { + if (spinner && spinnerStarted) { spinner.stop(); } } From 458bac0ba18f0f03a9b88a528ba481c66457a22b Mon Sep 17 00:00:00 2001 From: Aleksey Bykhun Date: Tue, 14 Apr 2026 20:07:32 -0700 Subject: [PATCH 3/3] feat(login): add --token flag for non-interactive login Enables AI agents and CI scripts to persist a PAT without going through the interactive browser/password flow. When --token is passed, all prompts are skipped and the token is stored via the same AuthenticationService path as interactive login. Safety: on platforms without secure storage, --token alone errors out pointing to PS_ADMIN_TOKEN or --force-insecure. This prevents silently writing plaintext tokens to disk. Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/src/commands/login.ts | 44 ++++++++++++++++++++++-- cli/test/commands/login.test.ts | 61 +++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/cli/src/commands/login.ts b/cli/src/commands/login.ts index abceb1a..d2287c7 100644 --- a/cli/src/commands/login.ts +++ b/cli/src/commands/login.ts @@ -1,5 +1,5 @@ import { confirm, password } from '@inquirer/prompts'; -import { ux } from '@oclif/core'; +import { Flags, ux } from '@oclif/core'; import { CommandHelpGroup, createAccountsHubClient, PowerSyncCommand, Services } from '@powersync/cli-core'; import { startPATLoginServer } from '../api/login-server.js'; @@ -8,13 +8,51 @@ export default class Login extends PowerSyncCommand { static commandHelpGroup = CommandHelpGroup.AUTHENTICATION; static description = 'Store a PowerSync auth token (PAT) in secure storage so later Cloud commands run without passing a token. If secure storage is unavailable, login can optionally store it in a local config file. Use PS_ADMIN_TOKEN env var for CI or scripts instead.'; - static examples = ['<%= config.bin %> <%= command.id %>']; + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --token=jpt_...' + ]; + static flags = { + 'force-insecure': Flags.boolean({ + default: false, + description: + 'When combined with --token on a platform without secure storage, persist the token in plaintext at the local config path instead of erroring.' + }), + token: Flags.string({ + description: + 'Store this token non-interactively, skipping all prompts (browser flow, overwrite confirmation, password input). Intended for CI and AI agents.', + required: false + }) + }; static summary = 'Store auth token for Cloud commands.'; async run(): Promise { - await this.parse(Login); + const { flags } = await this.parse(Login); const { authentication, storage } = Services; + + if (flags.token !== undefined) { + const token = flags.token.trim(); + if (!token) { + this.styledError({ message: 'Token is required.' }); + } + + if (!storage.capabilities.supportsSecureStorage && !flags['force-insecure']) { + this.styledError({ + message: `Secure storage is unavailable on this platform. Re-run with --force-insecure to persist the token in plaintext at ${storage.insecureStoragePath}, or set the ${ux.colorize('blue', 'PS_ADMIN_TOKEN')} environment variable to authenticate without persisting.` + }); + } + + const existing = await authentication.getToken(); + if (existing) { + await authentication.deleteToken(); + } + + await authentication.setToken(token); + this.log('Token stored.'); + return; + } + const shouldUseInsecureStorage = !storage.capabilities.supportsSecureStorage && (await confirm({ diff --git a/cli/test/commands/login.test.ts b/cli/test/commands/login.test.ts index c056713..afb0f88 100644 --- a/cli/test/commands/login.test.ts +++ b/cli/test/commands/login.test.ts @@ -76,6 +76,67 @@ describe('login', () => { return captureOutput(() => cmd.run()); } + function runLoginWithArgs(argv: string[]) { + const cmd = new LoginCommand(argv, oclifConfig); + return captureOutput(() => cmd.run()); + } + + it('stores token non-interactively when --token is provided', async () => { + const result = await runLoginWithArgs(['--token', ' jpt_abc123 ']); + + expect(result.error).toBeUndefined(); + expect(authentication.setToken).toHaveBeenCalledWith('jpt_abc123'); + expect(mockedConfirm).not.toHaveBeenCalled(); + expect(mockedPassword).not.toHaveBeenCalled(); + expect(mockedStartPATLoginServer).not.toHaveBeenCalled(); + expect(result.stdout).toContain('Token stored.'); + }); + + it('overwrites existing token silently when --token is provided', async () => { + authentication.getToken.mockResolvedValueOnce('existing-token'); + const result = await runLoginWithArgs(['--token', 'new-token']); + + expect(result.error).toBeUndefined(); + expect(authentication.deleteToken).toHaveBeenCalledTimes(1); + expect(authentication.setToken).toHaveBeenCalledWith('new-token'); + expect(mockedConfirm).not.toHaveBeenCalled(); + }); + + it('errors when --token is provided but secure storage is unavailable and --force-insecure is not set', async () => { + Services.storage = { + capabilities: { supportsSecureStorage: false }, + insecureStoragePath: '/tmp/powersync-config.json' + } as unknown as StorageImpl; + + const result = await runLoginWithArgs(['--token', 'abc']); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toContain('Secure storage is unavailable'); + expect(result.error?.message).toContain('PS_ADMIN_TOKEN'); + expect(authentication.setToken).not.toHaveBeenCalled(); + }); + + it('stores token in insecure storage when --token and --force-insecure are provided', async () => { + Services.storage = { + capabilities: { supportsSecureStorage: false }, + insecureStoragePath: '/tmp/powersync-config.json' + } as unknown as StorageImpl; + + const result = await runLoginWithArgs(['--token', 'abc', '--force-insecure']); + + expect(result.error).toBeUndefined(); + expect(authentication.setToken).toHaveBeenCalledWith('abc'); + expect(result.stdout).toContain('Token stored.'); + }); + + it('errors when --token is empty/whitespace', async () => { + const result = await runLoginWithArgs(['--token', ' ']); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toContain('Token is required.'); + expect(authentication.setToken).not.toHaveBeenCalled(); + }); + it('stores a valid token from prompt when browser flow is declined', async () => { mockedConfirm.mockResolvedValueOnce(true); // openBrowser mockedPassword.mockImplementationOnce(() => Object.assign(Promise.resolve('test-token'), { cancel: vi.fn() }));