Skip to content

Commit 23ad67e

Browse files
Various command improvements. cloud Token and schema generation commands.
1 parent 0def649 commit 23ad67e

12 files changed

Lines changed: 583 additions & 66 deletions

File tree

docs/usage.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,36 @@ powersync pull config --org-id=5cc84a3ccudjfhgytw0c08b --project-id=6703fd8a3cfe
1212
powersync deploy
1313
```
1414

15+
# Authentication (Tokens)
16+
17+
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:
18+
19+
1. **Environment variable**`PS_TOKEN`
20+
2. **Stored via login** — token saved by `powersync login` (secure storage, e.g. macOS Keychain)
21+
22+
**Environment variable** — useful for CI, scripts, or one-off runs:
23+
24+
```bash
25+
export PS_TOKEN=your-token-here
26+
powersync stop --confirm=yes
27+
```
28+
29+
Inline:
30+
31+
```bash
32+
PS_TOKEN=your-token-here powersync fetch config --output=json
33+
```
34+
35+
**Stored via login** — convenient for local use; token is stored securely and reused:
36+
37+
```bash
38+
powersync login --token=your-token-here
39+
# Later commands use the stored token
40+
powersync fetch config
41+
```
42+
43+
Login is supported on macOS (other platforms coming soon). If you use another platform or prefer not to store the token, set `PS_TOKEN` in the environment instead.
44+
1545
# Supplying Linking Information for Cloud Commands
1646

1747
Cloud commands (`deploy`, `destroy`, `stop`, `fetch config`, `pull config`) need instance, org, and project IDs. You can supply them in three ways; the CLI uses the first that is available:

packages/cli/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@
1616
"@powersync/management-client": "0.0.1",
1717
"@powersync/management-types": "0.0.1",
1818
"@powersync/service-client": "0.0.1",
19+
"@powersync/service-sync-rules": "^0.30.0",
1920
"keychain": "^1.5.0",
21+
"ora": "^9.0.0",
2022
"ts-codec": "^1.3.0",
2123
"yaml": "^2"
2224
},
2325
"devDependencies": {
24-
"tsx": "^4",
2526
"@eslint/compat": "^1",
2627
"@oclif/prettier-config": "^0.2.1",
2728
"@oclif/test": "^4",
@@ -33,6 +34,7 @@
3334
"oclif": "^4",
3435
"shx": "^0.3.3",
3536
"ts-node": "^10",
37+
"tsx": "^4",
3638
"typescript": "^5",
3739
"vitest": "^4.0.0"
3840
},

packages/cli/src/command-types/CloudInstanceCommand.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,10 @@ export abstract class CloudInstanceCommand extends InstanceCommand {
122122

123123
if (!linked && options?.linkingIsRequired) {
124124
this.error(
125-
`Linking is required before using this command. No linking information was found in the current context.`,
125+
[
126+
'Linking is required before using this command.',
127+
'No linking information was found in the current context.'
128+
].join('\n'),
126129
{ exit: 1 }
127130
);
128131
}

packages/cli/src/commands/deploy.ts

Lines changed: 84 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,36 @@
1+
import type { RequiredCloudLinkConfig } from '@powersync/cli-schemas';
2+
import { PowerSyncManagementClient } from '@powersync/management-client';
13
import { routes } from '@powersync/management-types';
4+
import ora from 'ora';
25
import { CloudInstanceCommand } from '../command-types/CloudInstanceCommand.js';
36

7+
const STATUS_POLL_INTERVAL_MS = 5000;
8+
type DeployStatus = 'pending' | 'running' | 'failed' | 'completed';
9+
10+
async function waitForStatusChange(
11+
client: PowerSyncManagementClient,
12+
linked: RequiredCloudLinkConfig,
13+
instanceId: string
14+
): Promise<DeployStatus> {
15+
for (;;) {
16+
const result = await client.getInstanceStatus({
17+
org_id: linked.org_id,
18+
app_id: linked.project_id,
19+
id: instanceId
20+
});
21+
const operation = result.operations?.[0];
22+
const status = operation?.status as DeployStatus | undefined;
23+
if (status === 'failed' || status === 'completed') return status;
24+
if (status === undefined) {
25+
// No operation or unknown status; trpeat as failed to avoid infinite loop
26+
return 'failed';
27+
}
28+
await new Promise((resolve) => setTimeout(resolve, STATUS_POLL_INTERVAL_MS));
29+
}
30+
}
31+
432
/** Pretty-print test connection response for error output. Uses types from @powersync/management-types (BaseTestConnectionResponse). */
5-
function formatTestConnectionFailure(
6-
response: {
7-
success?: boolean;
8-
connection?: { success?: boolean; reachable?: boolean };
9-
configuration?: { success?: boolean };
10-
error?: string;
11-
},
12-
connectionName: string
13-
): string {
33+
function formatTestConnectionFailure(response: routes.TestConnectionResponse, connectionName: string): string {
1434
const lines: string[] = [
1535
`Failed to test connection for connection "${connectionName}":`,
1636
'',
@@ -53,23 +73,36 @@ export default class Deploy extends CloudInstanceCommand {
5373
})
5474
.catch((error) => {
5575
this.error(
56-
`
57-
Failed to get existing config for instance ${linked.instance_id} in project ${linked.project_id} in org ${linked.org_id}: ${error}
58-
Ensure the instance has been created before deploying.
59-
`.trim(),
76+
[
77+
`Failed to get existing config for instance ${linked.instance_id} in project ${linked.project_id} in org ${linked.org_id}: ${error}`,
78+
'Ensure the instance has been created before deploying.'
79+
].join('\n'),
6080
{ exit: 1 }
6181
);
6282
});
6383

6484
const existingRegion = existingConfig.config?.region;
6585
if (existingRegion && existingRegion !== config.region) {
6686
this.error(
67-
`
68-
The existing config for instance ${linked.instance_id} in project ${linked.project_id} in org ${linked.org_id} has a different region than the config being deployed.
69-
The region cannot be changed after the initial deployment.
70-
Existing region: ${existingRegion}
71-
New region: ${config.region}
72-
`.trim(),
87+
[
88+
`The existing config for instance ${linked.instance_id} in project ${linked.project_id} in org ${linked.org_id} has a different region than the config being deployed.`,
89+
'The region cannot be changed after the initial deployment.',
90+
`Existing region: ${existingRegion}`,
91+
`New region: ${config.region}`
92+
].join('\n'),
93+
{ exit: 1 }
94+
);
95+
}
96+
97+
// Validate region against list of regions obtained from client.listRegions()
98+
const regions = await client.listRegions({}).catch((error) => {
99+
this.error(`Could not validate region against list of regions: Failed to list regions: ${error}`, { exit: 1 });
100+
});
101+
102+
const foundRegion = regions.regions.find((region) => region.name === config.region);
103+
if (!foundRegion) {
104+
this.error(
105+
`The region ${config.region} is not supported. Please choose a region from the list of supported regions: ${regions.regions.map((region) => region.name).join(', ')}`,
73106
{ exit: 1 }
74107
);
75108
}
@@ -83,13 +116,15 @@ New region: ${config.region}
83116
}
84117
for (const connection of config.replication?.connections ?? []) {
85118
const response = await client
86-
.testConnection({
87-
// The instance ID allows secret_refs to be used
88-
id: linked.instance_id,
89-
org_id: linked.org_id,
90-
app_id: linked.project_id,
91-
connection
92-
})
119+
.testConnection(
120+
routes.TestConnectionRequest.encode({
121+
// The instance ID allows secret_refs to be used
122+
id: linked.instance_id,
123+
org_id: linked.org_id,
124+
app_id: linked.project_id,
125+
connection
126+
})
127+
)
93128
.catch((error) => {
94129
this.error(`Failed to test connection for connection ${connection.name}: ${error}`, { exit: 1 });
95130
});
@@ -99,12 +134,16 @@ New region: ${config.region}
99134
}
100135
this.log('Connection test successful.');
101136

102-
this.log(
103-
`Deploying changes to instance ${linked.instance_id} in project ${linked.project_id} in org ${linked.org_id}`
104-
);
137+
const spinner = ora({
138+
prefixText: 'Deploying instance.\n',
139+
spinner: 'moon',
140+
suffixText: '\nThis may take a few minutes.\n'
141+
});
142+
spinner.start();
105143

144+
let deployResult: { id: string; operation_id?: string };
106145
try {
107-
await client.deployInstance(
146+
deployResult = await client.deployInstance(
108147
routes.DeployInstanceRequest.encode({
109148
// Spread the existing config like name, and program version contraints.
110149
// Should we allow specifying these in the config file?
@@ -115,10 +154,25 @@ New region: ${config.region}
115154
})
116155
);
117156
} catch (error) {
157+
spinner.stop();
118158
this.error(
119159
`Failed to deploy changes to instance ${linked.instance_id} in project ${linked.project_id} in org ${linked.org_id}: ${error}`,
120160
{ exit: 1 }
121161
);
122162
}
163+
164+
const status = await waitForStatusChange(client, linked, deployResult.id);
165+
spinner.stop();
166+
167+
if (status === 'failed') {
168+
this.error(
169+
[
170+
`Deploy failed for instance ${linked.instance_id}.`,
171+
'Check instance diagnostics for details, for example:',
172+
' powersync fetch status'
173+
].join('\n'),
174+
{ exit: 1 }
175+
);
176+
}
123177
}
124178
}
Lines changed: 151 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,157 @@
1-
import {Command} from '@oclif/core'
1+
import { Flags } from '@oclif/core';
2+
import { routes } from '@powersync/management-types';
3+
import { Document } from 'yaml';
24

3-
export default class FetchStatus extends Command {
5+
import { CloudInstanceCommand } from '../../command-types/CloudInstanceCommand.js';
6+
7+
type DiagnosticsResponse = routes.InstanceDiagnosticsResponse;
8+
type SyncRulesSection = NonNullable<DiagnosticsResponse['active_sync_rules']>;
9+
10+
const INDENT = ' ';
11+
const BULLET = '•';
12+
13+
function pad(level: number): string {
14+
return INDENT.repeat(level);
15+
}
16+
17+
/** Format a date value as "ISO (local)" for human output. Returns raw value if not parseable. */
18+
function formatDate(value: string | number | undefined | null): string {
19+
if (value === undefined || value === null) return '—';
20+
const date = new Date(value);
21+
if (Number.isNaN(date.getTime())) return String(value);
22+
return `${date.toISOString()} (${date.toLocaleString()})`;
23+
}
24+
25+
function formatErrors(errors: Array<{ level: string; message: string; ts?: string }>, indentLevel: number): string {
26+
if (!errors?.length) return '';
27+
const p = pad(indentLevel);
28+
return errors.map((e) => `${p}${BULLET} [${e.level}] ${e.message}${e.ts ? ` (${e.ts})` : ''}`).join('\n');
29+
}
30+
31+
function formatConnectionsSection(connections: DiagnosticsResponse['connections'], indentLevel: number): string {
32+
if (!connections?.length) return `${pad(indentLevel)}(no connections)\n`;
33+
const p = pad(indentLevel);
34+
const lines: string[] = [];
35+
for (const conn of connections) {
36+
const status = conn.connected ? 'connected' : 'disconnected';
37+
lines.push(`${p}${BULLET} ${conn.id}`);
38+
lines.push(`${p} Postgres URI: ${conn.postgres_uri ?? '—'}`);
39+
lines.push(`${p} Status: ${status}`);
40+
if (conn.errors?.length) {
41+
lines.push(`${p} Errors:`);
42+
lines.push(formatErrors(conn.errors, indentLevel + 2));
43+
}
44+
lines.push('');
45+
}
46+
return lines.join('\n').trimEnd() + '\n';
47+
}
48+
49+
function formatSyncRulesSection(section: SyncRulesSection, indentLevel: number): string {
50+
const p = pad(indentLevel);
51+
const lines: string[] = [];
52+
53+
if (section.errors?.length) {
54+
lines.push(`${p}Errors:`);
55+
lines.push(formatErrors(section.errors, indentLevel + 1));
56+
lines.push('');
57+
}
58+
59+
if (section.connections?.length) {
60+
lines.push(`${p}Connections:`);
61+
for (const conn of section.connections) {
62+
lines.push(`${p} ${BULLET} ${conn.tag ?? conn.id} (slot: ${conn.slot_name ?? '—'})`);
63+
lines.push(`${p} Initial replication done: ${conn.initial_replication_done}`);
64+
if (conn.last_lsn != null) lines.push(`${p} Last LSN: ${conn.last_lsn}`);
65+
if (conn.last_keepalive_ts != null) lines.push(`${p} Last keepalive: ${formatDate(conn.last_keepalive_ts)}`);
66+
if (conn.last_checkpoint_ts != null)
67+
lines.push(`${p} Last checkpoint: ${formatDate(conn.last_checkpoint_ts)}`);
68+
if (conn.replication_lag_bytes != null)
69+
lines.push(`${p} Replication lag: ${conn.replication_lag_bytes} bytes`);
70+
if (conn.tables?.length) {
71+
lines.push(`${p} Tables:`);
72+
for (const table of conn.tables) {
73+
const name = `${table.schema}.${table.name}`;
74+
const repl = table.replication_id?.length ? table.replication_id.join(', ') : '—';
75+
lines.push(`${p} - ${name} (replication_id: ${repl})`);
76+
if (table.data_queries != null) lines.push(`${p} data_queries: ${table.data_queries}`);
77+
if (table.parameter_queries != null) lines.push(`${p} parameter_queries: ${table.parameter_queries}`);
78+
if (table.errors?.length) lines.push(formatErrors(table.errors, indentLevel + 3));
79+
}
80+
}
81+
}
82+
lines.push('');
83+
}
84+
85+
if (section.content != null && section.content !== '') {
86+
lines.push(`${p}Content:`);
87+
lines.push(`${p} ${section.content.split('\n').join(`\n${p} `)}`);
88+
}
89+
90+
return lines.join('\n').trimEnd() || `${p}(no data)\n`;
91+
}
92+
93+
function formatDiagnosticsHuman(diagnostics: DiagnosticsResponse): string {
94+
const sections: string[] = [];
95+
96+
sections.push('═══ Connections ═══');
97+
sections.push(formatConnectionsSection(diagnostics.connections ?? [], 0));
98+
99+
if (diagnostics.active_sync_rules != null) {
100+
sections.push('═══ Active Sync Rules ═══');
101+
sections.push(formatSyncRulesSection(diagnostics.active_sync_rules, 0));
102+
}
103+
104+
if (diagnostics.deploying_sync_rules != null) {
105+
sections.push('═══ Deploying Sync Rules ═══');
106+
sections.push(formatSyncRulesSection(diagnostics.deploying_sync_rules, 0));
107+
}
108+
109+
return sections.join('\n').trimEnd();
110+
}
111+
112+
// TODO self hosted support
113+
export default class FetchStatus extends CloudInstanceCommand {
4114
static description =
5-
'Fetches diagnostics (connections, sync rules state, etc.). Routes to Management service (Cloud) or linked instance (self-hosted).'
6-
static summary = 'Fetch diagnostics status for an instance.'
115+
'Fetches diagnostics (connections, sync rules state, etc.). Routes to Management service (Cloud) or linked instance (self-hosted).';
116+
static summary = 'Fetch diagnostics status for an instance.';
117+
118+
static flags = {
119+
...CloudInstanceCommand.flags,
120+
output: Flags.string({
121+
default: 'human',
122+
description: 'Output format: human-readable, json, or yaml.',
123+
options: ['human', 'json', 'yaml']
124+
})
125+
};
7126

8127
async run(): Promise<void> {
9-
this.log('fetch status: not yet implemented')
128+
const { flags } = await this.parse(FetchStatus);
129+
130+
const { linked } = this.loadProject(flags, {
131+
configFileRequired: false,
132+
linkingIsRequired: true
133+
});
134+
135+
const client = await this.getClient();
136+
137+
const diagnostics = await client
138+
.getInstanceDiagnostics({
139+
app_id: linked.project_id,
140+
org_id: linked.org_id,
141+
id: linked.instance_id
142+
})
143+
.catch((error) => {
144+
this.error(`Failed to fetch diagnostics for instance ${linked.instance_id}: ${error}`, { exit: 1 });
145+
});
146+
147+
if (flags.output === 'json') {
148+
this.log(JSON.stringify(diagnostics, null, 2));
149+
return;
150+
} else if (flags.output === 'yaml') {
151+
this.log(new Document(diagnostics).toString());
152+
return;
153+
} else {
154+
this.log(formatDiagnosticsHuman(diagnostics));
155+
}
10156
}
11157
}

0 commit comments

Comments
 (0)