Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
6 changes: 3 additions & 3 deletions cli/src/commands/fetch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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');
}
}
7 changes: 6 additions & 1 deletion cli/src/commands/fetch/instances.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -48,6 +50,7 @@ export default class FetchInstances extends Command {
'<%= config.bin %> <%= command.id %> --project-id=<id> --output=json'
];
static flags = {
...authTokenFlag,
'org-id': Flags.string({
description: 'Optional Organization ID. Defaults to all organizations.',
required: false
Expand Down Expand Up @@ -175,6 +178,8 @@ export default class FetchInstances extends Command {
async run(): Promise<void> {
const { flags } = await this.parse(FetchInstances);

setCliTokenOverride(flags.token);

this.log(''); // Add spacing

const cloudInstanceMap = await this.fetchCloudInstances({
Expand Down
160 changes: 160 additions & 0 deletions cli/src/commands/fetch/projects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
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=<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<void> {
const { flags } = await this.parse(FetchProjects);

setCliTokenOverride(flags.token);

const accountsClient = createAccountsHubClient();
const managementClient = createCloudClient();

const rows: ProjectRow[] = [];
// In JSON mode, skip the spinner entirely so stderr stays clean for strict machine-readable output.
const jsonMode = flags.output === 'json';
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 (spinner && !spinnerStarted && totalOrgs > 0) {
spinner.start();
spinnerStarted = true;
}

for (const organization of organizations) {
if (spinner) {
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 (spinner && 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);
}
}
}
}
44 changes: 41 additions & 3 deletions cli/src/commands/login.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<void> {
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({
Expand Down
88 changes: 88 additions & 0 deletions cli/test/clients/cli-token-override.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.spyOn>;

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<string, string> }])[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<string, string> }])[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<string, string> }])[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<string, string> }])[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/);
});
});
Loading