Skip to content

Commit 23aff77

Browse files
More token storage options.
1 parent e082df0 commit 23aff77

9 files changed

Lines changed: 221 additions & 89 deletions

File tree

cli/README.md

Lines changed: 62 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@ CLI for PowerSync
77
[![Downloads/week](https://img.shields.io/npm/dw/@powersync/cli.svg)](https://npmjs.org/package/@powersync/cli)
88

99
<!-- toc -->
10-
* [@powersync/cli](#powersynccli)
11-
* [Overview](#overview)
12-
* [Cloud](#cloud)
13-
* [Self-hosted](#self-hosted)
14-
* [Known Limitations](#known-limitations)
15-
* [Usage](#usage)
16-
* [Commands](#commands)
10+
11+
- [@powersync/cli](#powersynccli)
12+
- [Overview](#overview)
13+
- [Cloud](#cloud)
14+
- [Self-hosted](#self-hosted)
15+
- [Known Limitations](#known-limitations)
16+
- [Usage](#usage)
17+
- [Commands](#commands)
1718
<!-- tocstop -->
1819

1920
# Overview
@@ -34,17 +35,21 @@ The CLI supports **PowerSync Cloud** for creating instances, deploying config, p
3435
Cloud commands require a PowerSync **personal access token (PAT)**. You can authenticate in two ways:
3536

3637
**1. Interactive login (recommended for local use)**
37-
Run **`powersync login`**. You can either open a browser to create a token in the [PowerSync Dashboard](https://dashboard.powersync.com/account/access-tokens/create) or paste an existing token. On **macOS**, the token is stored in Keychain so you don’t need to pass it again. On **Windows and Linux**, secure storage is not yet supported—use the **`TOKEN`** environment variable instead (see below).
38+
Run **`powersync login`**. You can either open a browser to create a token in the [PowerSync Dashboard](https://dashboard.powersync.com/account/access-tokens/create) or paste an existing token.
39+
40+
- If secure storage is available, the token is saved there (for example, macOS Keychain).
41+
- If secure storage is unavailable, the CLI asks for confirmation before storing the token in plaintext at **`$XDG_CONFIG_HOME/powersync/config.yaml`** (or **`~/.config/powersync/config.yaml`** when `XDG_CONFIG_HOME` is unset).
42+
- If you decline, login exits without storing a token.
3843

39-
**2. Environment variable (CI, scripts, or when not using macOS)**
44+
**2. Environment variable (CI, scripts, or non-persistent use)**
4045
Set **`TOKEN`** to your PAT. The CLI uses **`TOKEN`** when set; otherwise it uses the token from **`powersync login`**. Example:
4146

4247
```sh
4348
export TOKEN=your-personal-access-token
4449
powersync fetch instances --project-id=<project-id>
4550
```
4651

47-
To stop using stored credentials, run **`powersync logout`**.
52+
To stop using stored credentials, run **`powersync logout`**. This clears the stored token from the active backend (secure storage or config-file fallback).
4853

4954
## Creating a new instance
5055

@@ -99,7 +104,7 @@ The CLI can run a subset of commands against **self-hosted** PowerSync instances
99104
For any self-hosted instance (local or remote), you must link the running API to the CLI and configure an API key. On the **server** (your PowerSync instance config), define the tokens that are valid in **`service.yaml`**:
100105

101106
```yaml
102-
# powersync/service.yaml (self-hosted instance config)
107+
# powersync/service.yaml (self-hosted instance config)
103108
api:
104109
tokens:
105110
- dev-token-do-not-use-in-production # or use !env MY_API_TOKEN for secrets
@@ -108,7 +113,7 @@ api:
108113
Then tell the CLI which token to use when running commands. Run **`powersync link self-hosted --api-url <url>`** to write **`cli.yaml`** with the API URL, and either set the **`TOKEN`** environment variable or set **`api_key`** in **`cli.yaml`**:
109114

110115
```yaml
111-
# powersync/cli.yaml (self-hosted)
116+
# powersync/cli.yaml (self-hosted)
112117
type: self-hosted
113118
api_url: https://powersync.example.com
114119
api_key: !env TOKEN # or a literal value matching one of the tokens in service.yaml
@@ -139,11 +144,12 @@ Only some CLI commands work with self-hosted instances. Supported commands inclu
139144

140145
# Known Limitations
141146

142-
- **Login secure storage**: Secure storage for auth tokens is only supported on macOS (via Keychain). On Windows and Linux, `powersync login` will not persist credentials; use the `TOKEN` environment variable instead for Cloud commands.
147+
- **Plaintext fallback storage**: When secure storage is unavailable, login can store the token in plaintext config (`$XDG_CONFIG_HOME/powersync/config.yaml` or `~/.config/powersync/config.yaml`) only after explicit confirmation.
143148

144149
# Usage
145150

146151
<!-- usage -->
152+
147153
```sh-session
148154
$ npm install -g @powersync/cli
149155
$ powersync COMMAND
@@ -155,51 +161,53 @@ USAGE
155161
$ powersync COMMAND
156162
...
157163
```
164+
158165
<!-- usagestop -->
159166

160167
# Commands
161168

162169
<!-- commands -->
163-
* [`powersync deploy`](#powersync-deploy)
164-
* [`powersync destroy`](#powersync-destroy)
165-
* [`powersync docker`](#powersync-docker)
166-
* [`powersync docker configure`](#powersync-docker-configure)
167-
* [`powersync docker reset`](#powersync-docker-reset)
168-
* [`powersync docker start`](#powersync-docker-start)
169-
* [`powersync docker stop`](#powersync-docker-stop)
170-
* [`powersync fetch`](#powersync-fetch)
171-
* [`powersync fetch config`](#powersync-fetch-config)
172-
* [`powersync fetch instances`](#powersync-fetch-instances)
173-
* [`powersync fetch status`](#powersync-fetch-status)
174-
* [`powersync generate`](#powersync-generate)
175-
* [`powersync generate schema`](#powersync-generate-schema)
176-
* [`powersync generate token`](#powersync-generate-token)
177-
* [`powersync help [COMMAND]`](#powersync-help-command)
178-
* [`powersync init`](#powersync-init)
179-
* [`powersync init base`](#powersync-init-base)
180-
* [`powersync init cloud`](#powersync-init-cloud)
181-
* [`powersync init self-hosted`](#powersync-init-self-hosted)
182-
* [`powersync link`](#powersync-link)
183-
* [`powersync link cloud`](#powersync-link-cloud)
184-
* [`powersync link self-hosted`](#powersync-link-self-hosted)
185-
* [`powersync login`](#powersync-login)
186-
* [`powersync logout`](#powersync-logout)
187-
* [`powersync migrate`](#powersync-migrate)
188-
* [`powersync plugins`](#powersync-plugins)
189-
* [`powersync plugins add PLUGIN`](#powersync-plugins-add-plugin)
190-
* [`powersync plugins:inspect PLUGIN...`](#powersync-pluginsinspect-plugin)
191-
* [`powersync plugins install PLUGIN`](#powersync-plugins-install-plugin)
192-
* [`powersync plugins link PATH`](#powersync-plugins-link-path)
193-
* [`powersync plugins remove [PLUGIN]`](#powersync-plugins-remove-plugin)
194-
* [`powersync plugins reset`](#powersync-plugins-reset)
195-
* [`powersync plugins uninstall [PLUGIN]`](#powersync-plugins-uninstall-plugin)
196-
* [`powersync plugins unlink [PLUGIN]`](#powersync-plugins-unlink-plugin)
197-
* [`powersync plugins update`](#powersync-plugins-update)
198-
* [`powersync pull`](#powersync-pull)
199-
* [`powersync pull config`](#powersync-pull-config)
200-
* [`powersync pull instance`](#powersync-pull-instance)
201-
* [`powersync stop`](#powersync-stop)
202-
* [`powersync validate`](#powersync-validate)
170+
171+
- [`powersync deploy`](#powersync-deploy)
172+
- [`powersync destroy`](#powersync-destroy)
173+
- [`powersync docker`](#powersync-docker)
174+
- [`powersync docker configure`](#powersync-docker-configure)
175+
- [`powersync docker reset`](#powersync-docker-reset)
176+
- [`powersync docker start`](#powersync-docker-start)
177+
- [`powersync docker stop`](#powersync-docker-stop)
178+
- [`powersync fetch`](#powersync-fetch)
179+
- [`powersync fetch config`](#powersync-fetch-config)
180+
- [`powersync fetch instances`](#powersync-fetch-instances)
181+
- [`powersync fetch status`](#powersync-fetch-status)
182+
- [`powersync generate`](#powersync-generate)
183+
- [`powersync generate schema`](#powersync-generate-schema)
184+
- [`powersync generate token`](#powersync-generate-token)
185+
- [`powersync help [COMMAND]`](#powersync-help-command)
186+
- [`powersync init`](#powersync-init)
187+
- [`powersync init base`](#powersync-init-base)
188+
- [`powersync init cloud`](#powersync-init-cloud)
189+
- [`powersync init self-hosted`](#powersync-init-self-hosted)
190+
- [`powersync link`](#powersync-link)
191+
- [`powersync link cloud`](#powersync-link-cloud)
192+
- [`powersync link self-hosted`](#powersync-link-self-hosted)
193+
- [`powersync login`](#powersync-login)
194+
- [`powersync logout`](#powersync-logout)
195+
- [`powersync migrate`](#powersync-migrate)
196+
- [`powersync plugins`](#powersync-plugins)
197+
- [`powersync plugins add PLUGIN`](#powersync-plugins-add-plugin)
198+
- [`powersync plugins:inspect PLUGIN...`](#powersync-pluginsinspect-plugin)
199+
- [`powersync plugins install PLUGIN`](#powersync-plugins-install-plugin)
200+
- [`powersync plugins link PATH`](#powersync-plugins-link-path)
201+
- [`powersync plugins remove [PLUGIN]`](#powersync-plugins-remove-plugin)
202+
- [`powersync plugins reset`](#powersync-plugins-reset)
203+
- [`powersync plugins uninstall [PLUGIN]`](#powersync-plugins-uninstall-plugin)
204+
- [`powersync plugins unlink [PLUGIN]`](#powersync-plugins-unlink-plugin)
205+
- [`powersync plugins update`](#powersync-plugins-update)
206+
- [`powersync pull`](#powersync-pull)
207+
- [`powersync pull config`](#powersync-pull-config)
208+
- [`powersync pull instance`](#powersync-pull-instance)
209+
- [`powersync stop`](#powersync-stop)
210+
- [`powersync validate`](#powersync-validate)
203211

204212
## `powersync deploy`
205213

@@ -1233,4 +1241,5 @@ DESCRIPTION
12331241
```
12341242
12351243
_See code: [src/commands/validate.ts](https://github.com/powersync-ja/powersync-js/blob/v0.0.0/src/commands/validate.ts)_
1244+
12361245
<!-- commandsstop -->

cli/src/commands/login.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,25 @@ import { startPATLoginServer } from '../api/login-server.js';
55

66
export default class Login extends PowerSyncCommand {
77
static description =
8-
'Store a PowerSync auth token (PAT) in secure storage so later Cloud commands run without passing a token. Use TOKEN env var for CI or scripts instead.';
9-
static summary = 'Store auth token in secure storage for Cloud commands.';
8+
'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 TOKEN env var for CI or scripts instead.';
9+
static summary = 'Store auth token for Cloud commands.';
1010

1111
async run(): Promise<void> {
1212
this.parse(Login);
1313

1414
const { authentication, storage } = Services;
15+
const shouldUseInsecureStorage =
16+
!storage.capabilities.supportsSecureStorage &&
17+
(await confirm({
18+
message: `Keychain storage is unavailable on this platform. Store token in plaintext at ${storage.insecureStoragePath}? Set the ${ux.colorize('blue', 'TOKEN')} environment variable instead to avoid this.`,
19+
default: false
20+
}));
1521

16-
if (!storage.capabilities.supportsSecureStorage) {
17-
this.styledError({
18-
message: 'Secure storage is not yet supported on this platform.',
19-
suggestions: [`Export and use the ${ux.colorize('blue', 'TOKEN')} environment variable for commands.`]
20-
});
22+
if (!storage.capabilities.supportsSecureStorage && !shouldUseInsecureStorage) {
23+
this.log(
24+
`Login cancelled. Use ${ux.colorize('blue', 'TOKEN')} environment variable for commands, or rerun login and allow local fallback storage.`
25+
);
26+
this.exit(0);
2127
}
2228

2329
const listOrgs = async (): Promise<string> => {

cli/src/commands/logout.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { PowerSyncCommand, Services } from '@powersync/cli-core';
44

55
export default class Logout extends PowerSyncCommand {
66
static description =
7-
'Remove the stored PowerSync auth token from secure storage. Cloud commands will no longer use stored credentials until you run login again.';
8-
static summary = 'Remove stored auth token from secure storage.';
7+
'Remove the stored PowerSync auth token from secure storage or local fallback config storage. Cloud commands will no longer use stored credentials until you run login again.';
8+
static summary = 'Remove stored auth token.';
99

1010
async run(): Promise<void> {
1111
const { authentication } = Services;

docs/usage.md

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ The sections below split usage by **Cloud** and **Self-hosted**, then provide re
7676

7777
# Cloud usage
7878

79-
Authentication is usually the first step. Use `powersync login` to store a token in secure storage (e.g. macOS Keychain), or set the `TOKEN` environment variable if you prefer not to persist the token. See [Authentication (Tokens)](#authentication-tokens) for details.
79+
Authentication is usually the first step. Use `powersync login` to store a token, or set the `TOKEN` environment variable. Storage behavior depends on platform capabilities and your login choice; see [Authentication (Tokens)](#authentication-tokens) for details.
8080

8181
## Creating a new Cloud instance
8282

@@ -157,10 +157,13 @@ Use `--api-url` with link file or `API_URL` and `TOKEN` when you prefer not to l
157157

158158
# Authentication (Tokens)
159159

160-
Cloud commands need an auth token (e.g. a PowerSync PAT). You can supply it in two ways; the CLI uses the first that is available:
160+
Cloud commands need an auth token (e.g. a PowerSync PAT). The CLI uses the first available source:
161161

162162
1. **Environment variable**`TOKEN`
163-
2. **Stored via login** — token saved by `powersync login` (secure storage, e.g. macOS Keychain)
163+
2. **Stored via login**
164+
165+
- **Secure storage** when available (for example, macOS Keychain)
166+
- **Config-file fallback** when secure storage is unavailable **and** you explicitly confirm at login
164167

165168
**Environment variable** — useful for CI, scripts, or one-off runs:
166169

@@ -175,7 +178,7 @@ Inline:
175178
TOKEN=your-token-here powersync fetch config --output=json
176179
```
177180

178-
**Stored via login** — convenient for local use; token is stored securely and reused:
181+
**Stored via login** — convenient for local use; token is reused by later commands:
179182

180183
```bash
181184
powersync login
@@ -184,7 +187,17 @@ powersync login
184187
powersync fetch config
185188
```
186189

187-
Login is supported on macOS (other platforms coming soon). If you use another platform or prefer not to store the token, set `TOKEN` in the environment instead.
190+
If secure storage is not available, `powersync login` asks whether to store the token in plaintext at:
191+
192+
```bash
193+
$XDG_CONFIG_HOME/powersync/config.yaml
194+
# or, when XDG_CONFIG_HOME is not set:
195+
~/.config/powersync/config.yaml
196+
```
197+
198+
If you decline this prompt, login exits without storing a token. Use `TOKEN` in that case.
199+
200+
`powersync logout` removes the stored token from whichever backend is active (secure storage or config-file fallback).
188201

189202
# Supplying Linking Information for Cloud and Self-Hosted Commands
190203

packages/cli-core/src/clients/CloudClient.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export function createCloudClient(): PowerSyncManagementClient {
2424
const token = env.TOKEN || (await Services.authentication.getToken());
2525
if (!token) {
2626
throw new Error(
27-
`Not logged in. Run ${ux.colorize('blue', 'powersync login')} to authenticate (you will be prompted for your token). Login is supported on macOS (other platforms coming soon), or provide the ${ux.colorize('blue', 'TOKEN')} environment variable.`
27+
`Not logged in. Run ${ux.colorize('blue', 'powersync login')} to authenticate (you will be prompted for your token), or provide the ${ux.colorize('blue', 'TOKEN')} environment variable.`
2828
);
2929
}
3030
return {

packages/cli-core/src/clients/accounts-client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export async function createAccountsHubClient(): Promise<AccountsHubClientSDKCli
6565
const token = env.TOKEN || (await authentication.getToken());
6666
if (!token) {
6767
throw new Error(
68-
`Not logged in. Run ${ux.colorize('blue', 'powersync login')} to authenticate (you will be prompted for your token). Login is supported on macOS (other platforms coming soon), or provide the ${ux.colorize('blue', 'TOKEN')} environment variable.`
68+
`Not logged in. Run ${ux.colorize('blue', 'powersync login')} to authenticate (you will be prompted for your token), or provide the ${ux.colorize('blue', 'TOKEN')} environment variable.`
6969
);
7070
}
7171
return new AccountsHubClientSDKClient({
Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { env } from '../../utils/env.js';
21
import { StorageService } from '../storage/StorageService.js';
32
import { AuthenticationService } from './AuthenticationService.js';
43

@@ -9,36 +8,50 @@ export type AuthenticationServiceImplOptions = {
98
const TOKEN_KEY = 'auth-token';
109

1110
export class AuthenticationServiceImpl implements AuthenticationService {
12-
protected storage: StorageService | null;
11+
protected storage: StorageService;
1312

1413
constructor(options: AuthenticationServiceImplOptions) {
15-
const storageService = options.storage;
16-
// Fallback to internal storage if secure storage is not supported
17-
if (storageService.capabilities.supportsSecureStorage) {
18-
this.storage = storageService;
19-
} else {
20-
this.storage = null;
21-
}
14+
this.storage = options.storage;
2215
}
2316

2417
async getToken(): Promise<string | null> {
25-
if (!this.storage) {
26-
return env.TOKEN || null;
18+
if (this.storage.capabilities.supportsSecureStorage) {
19+
return this.storage.secureStorage.getItem(TOKEN_KEY);
2720
}
28-
return this.storage.secureStorage.getItem(TOKEN_KEY);
21+
22+
const config = await this.storage.getInsecureConfig();
23+
return config.auth?.token ?? null;
2924
}
3025

3126
async setToken(token: string): Promise<void> {
32-
if (!this.storage) {
33-
throw new Error('Secure storage is not supported on this platform.');
27+
if (this.storage.capabilities.supportsSecureStorage) {
28+
await this.storage.secureStorage.setItem(TOKEN_KEY, token);
29+
return;
3430
}
35-
await this.storage.secureStorage.setItem(TOKEN_KEY, token);
31+
32+
const config = await this.storage.getInsecureConfig();
33+
config.auth = {
34+
...(config.auth ?? {}),
35+
token
36+
};
37+
await this.storage.updateInsecureConfig(config);
3638
}
3739

3840
async deleteToken(): Promise<void> {
39-
if (!this.storage) {
40-
throw new Error('Secure storage is not supported on this platform.');
41+
if (this.storage.capabilities.supportsSecureStorage) {
42+
await this.storage.secureStorage.removeItem(TOKEN_KEY);
43+
return;
44+
}
45+
46+
const config = await this.storage.getInsecureConfig();
47+
if (!config.auth || typeof config.auth.token === 'undefined') {
48+
return;
49+
}
50+
51+
delete config.auth.token;
52+
if (Object.keys(config.auth).length === 0) {
53+
delete config.auth;
4154
}
42-
await this.storage.secureStorage.removeItem(TOKEN_KEY);
55+
await this.storage.updateInsecureConfig(config);
4356
}
4457
}

0 commit comments

Comments
 (0)