From a593c1b731d12cc9e64511740f510656be3f3ff0 Mon Sep 17 00:00:00 2001 From: bean1352 Date: Wed, 2 Sep 2026 13:39:35 +0200 Subject: [PATCH 1/6] show target instance before deploy, stop, destroy and compact --- .changeset/show-target-instance.md | 6 ++++ cli/src/api/BaseDeployCommand.ts | 15 +++++++++ cli/src/commands/compact.ts | 4 ++- cli/src/commands/deploy/index.ts | 2 ++ cli/src/commands/deploy/service-config.ts | 2 ++ cli/src/commands/deploy/sync-config.ts | 1 + cli/src/commands/destroy.ts | 4 ++- cli/src/commands/stop.ts | 4 ++- cli/test/commands/compact.test.ts | 8 +++++ cli/test/commands/deploy.test.ts | 30 +++++++++++++++++ cli/test/commands/destroy.test.ts | 7 ++++ cli/test/commands/stop.test.ts | 20 ++++++++++++ cli/test/setup.ts | 1 + .../src/command-types/CloudInstanceCommand.ts | 32 +++++++++++++++++++ 14 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 .changeset/show-target-instance.md diff --git a/.changeset/show-target-instance.md b/.changeset/show-target-instance.md new file mode 100644 index 00000000..bc5971e3 --- /dev/null +++ b/.changeset/show-target-instance.md @@ -0,0 +1,6 @@ +--- +'@powersync/cli-core': patch +'powersync': patch +--- + +Show the target instance name and IDs before `deploy`, `deploy sync-config`, `deploy service-config`, `stop`, `destroy` and `compact` do anything, so it is clear which instance is about to be changed. `deploy` and `deploy service-config` now also warn when the local `service.yaml` `name` differs from the instance name, since deploying renames the instance. diff --git a/cli/src/api/BaseDeployCommand.ts b/cli/src/api/BaseDeployCommand.ts index a3b6c021..c525169b 100644 --- a/cli/src/api/BaseDeployCommand.ts +++ b/cli/src/api/BaseDeployCommand.ts @@ -272,6 +272,21 @@ export default abstract class BaseDeployCommand extends CloudInstanceCommand { } } + /** + * Deploying sends the local service.yaml `name` as the instance name, so a deploy renames the + * instance if the two differ. Warn so users targeting several instances from one config notice. + */ + protected warnIfDeployRenamesInstance(cloudConfigState: routes.InstanceConfigResponse): void { + const localName = this.serviceConfig?.name; + if (!localName || localName === cloudConfigState.name) { + return; + } + + this.warn( + `Deploying will rename the instance from "${cloudConfigState.name}" to "${localName}" because ${SERVICE_FILENAME} has name: ${localName}.` + ); + } + protected async withDeploy(timeoutMs: number, fn: () => Promise): Promise { const { project } = this; diff --git a/cli/src/commands/compact.ts b/cli/src/commands/compact.ts index 2540d9bb..c5eb24d7 100644 --- a/cli/src/commands/compact.ts +++ b/cli/src/commands/compact.ts @@ -25,12 +25,14 @@ export default class Compact extends CloudInstanceCommand { async run(): Promise { const { flags } = await this.parse(Compact); const { linked } = await this.loadProject(flags); + const instanceName = await this.logTargetInstance(); + const instanceLabel = instanceName == null ? linked.instance_id : `${instanceName} (${linked.instance_id})`; const { client } = this; const timeoutMs = flags.timeout === 0 ? Number.POSITIVE_INFINITY : flags.timeout * 60 * 1000; const spinner = ora({ discardStdin: false, - prefixText: `\n${ux.colorize('yellow', 'Compacting')} instance ${ux.colorize('blue', linked.instance_id)} in project ${ux.colorize('blue', linked.project_id)} in org ${ux.colorize('blue', linked.org_id)}\n`, + prefixText: `\n${ux.colorize('yellow', 'Compacting')} instance ${ux.colorize('blue', instanceLabel)} in project ${ux.colorize('blue', linked.project_id)} in org ${ux.colorize('blue', linked.org_id)}\n`, spinner: 'moon', suffixText: '\nThis may take a few minutes.\n' }); diff --git a/cli/src/commands/deploy/index.ts b/cli/src/commands/deploy/index.ts index 95feb9e3..85b3eb2b 100644 --- a/cli/src/commands/deploy/index.ts +++ b/cli/src/commands/deploy/index.ts @@ -34,12 +34,14 @@ export default class DeployAll extends WithSyncConfigFilePath(BaseDeployCommand) const validationTestsFilter = GENERAL_VALIDATION_FLAG_HELPERS.parseValidationTestFlags(flags); const cloudConfigState = await this.loadCloudConfigState(); + await this.logTargetInstance({ instanceName: cloudConfigState.name }); // Parse and store for later this.parseLocalConfig( project.projectDirectory, validationTestsFilter.skipped.includes(ValidationTest.CONFIGURATION) ); + this.warnIfDeployRenamesInstance(cloudConfigState); // Start of validations this.log('Performing validations before deploy...'); diff --git a/cli/src/commands/deploy/service-config.ts b/cli/src/commands/deploy/service-config.ts index e095206a..7c77ab46 100644 --- a/cli/src/commands/deploy/service-config.ts +++ b/cli/src/commands/deploy/service-config.ts @@ -35,6 +35,8 @@ export default class DeployServiceConfig extends BaseDeployCommand { // The existing config is required to deploy changes. The instance should have been created already. const cloudConfigState = await this.loadCloudConfigState(); + await this.logTargetInstance({ instanceName: cloudConfigState.name }); + this.warnIfDeployRenamesInstance(cloudConfigState); this.log('Performing validations before deploy...'); const validationRunner = new ValidationsRunner({ diff --git a/cli/src/commands/deploy/sync-config.ts b/cli/src/commands/deploy/sync-config.ts index 06078d9d..ea65324f 100644 --- a/cli/src/commands/deploy/sync-config.ts +++ b/cli/src/commands/deploy/sync-config.ts @@ -89,6 +89,7 @@ export default class DeploySyncConfig extends WithSyncConfigFilePath(BaseDeployC // The existing config is required to deploy changes. The instance should have been created already. const cloudConfigState = await this.loadCloudConfigState(); + await this.logTargetInstance({ instanceName: cloudConfigState.name }); if (!cloudConfigState.config) { this.styledError({ diff --git a/cli/src/commands/destroy.ts b/cli/src/commands/destroy.ts index f7759ca0..b181b141 100644 --- a/cli/src/commands/destroy.ts +++ b/cli/src/commands/destroy.ts @@ -23,11 +23,13 @@ export default class Destroy extends CloudInstanceCommand { } const { linked } = await this.loadProject(flags); + const instanceName = await this.logTargetInstance(); + const instanceLabel = instanceName == null ? linked.instance_id : `${instanceName} (${linked.instance_id})`; const { client } = this; const spinner = ora({ discardStdin: false, - prefixText: `\n${ux.colorize('red', 'Destroying')} instance ${ux.colorize('blue', linked.instance_id)} in project ${ux.colorize('blue', linked.project_id)} in org ${ux.colorize('blue', linked.org_id)}\n`, + prefixText: `\n${ux.colorize('red', 'Destroying')} instance ${ux.colorize('blue', instanceLabel)} in project ${ux.colorize('blue', linked.project_id)} in org ${ux.colorize('blue', linked.org_id)}\n`, spinner: 'moon', suffixText: '\nThis may take a few minutes.\n' }); diff --git a/cli/src/commands/stop.ts b/cli/src/commands/stop.ts index 3e2644c3..85936b85 100644 --- a/cli/src/commands/stop.ts +++ b/cli/src/commands/stop.ts @@ -24,12 +24,14 @@ export default class Stop extends CloudInstanceCommand { } const { linked } = await this.loadProject(flags); + const instanceName = await this.logTargetInstance(); + const instanceLabel = instanceName == null ? linked.instance_id : `${instanceName} (${linked.instance_id})`; const { client } = this; const spinner = ora({ discardStdin: false, - prefixText: `\nStopping instance ${ux.colorize('blue', linked.instance_id)} in project ${ux.colorize('blue', linked.project_id)} in org ${ux.colorize('blue', linked.org_id)}\n`, + prefixText: `\nStopping instance ${ux.colorize('blue', instanceLabel)} in project ${ux.colorize('blue', linked.project_id)} in org ${ux.colorize('blue', linked.org_id)}\n`, spinner: 'moon', suffixText: '\nThis may take a few minutes.\n' }); diff --git a/cli/test/commands/compact.test.ts b/cli/test/commands/compact.test.ts index 845dd369..77201f94 100644 --- a/cli/test/commands/compact.test.ts +++ b/cli/test/commands/compact.test.ts @@ -96,6 +96,14 @@ describe('compact', () => { writeLinkYaml(projectDir, { instance_id: INSTANCE_ID, org_id: ORG_ID, project_id: PROJECT_ID }); }); + it('prints the target instance name and IDs before compacting', async () => { + managementClientMock.compact.mockResolvedValue({ id: INSTANCE_ID }); + const result = await runCompactDirect([]); + + expect(result.stdout).toContain('Target instance: test-instance'); + expect(result.stdout).toContain(`id: ${INSTANCE_ID}`); + }); + it('calls compact on the management client with linked ids', async () => { managementClientMock.compact.mockResolvedValue({ id: INSTANCE_ID }); diff --git a/cli/test/commands/deploy.test.ts b/cli/test/commands/deploy.test.ts index a03c6a6b..56b769c6 100644 --- a/cli/test/commands/deploy.test.ts +++ b/cli/test/commands/deploy.test.ts @@ -144,6 +144,36 @@ describe('deploy', () => { ); }); + it('prints the target instance name and IDs before validating', async () => { + const result = await runDeployDirect(); + + // The name comes from the existing cloud config, no extra getInstance call is needed. + expect(managementClientMock.getInstance).not.toHaveBeenCalled(); + expect(result.stdout).toContain('Target instance: test-instance'); + expect(result.stdout).toContain(`id: ${INSTANCE_ID}`); + expect(result.stdout).toContain(`project: ${PROJECT_ID}`); + expect(result.stdout).toContain(`org: ${ORG_ID}`); + expect(result.stderr).not.toContain('rename'); + }); + + it('warns when deploying would rename the instance', async () => { + const projectDir = join(tmpDir, PROJECT_DIR); + writeCloudServiceYaml(projectDir, { + _type: 'cloud', + name: 'instance-b', + region: 'us', + replication: { + connections: [{ name: 'default', type: 'postgresql', uri: 'postgres://user:pass@host/db' }] + } + }); + + const result = await runDeployDirect(); + + // oclif wraps warnings and prefixes each line with a marker, so normalise whitespace first. + const stderr = result.stderr.replaceAll(/[\s›]+/g, ' '); + expect(stderr).toContain('Deploying will rename the instance from "test-instance" to "instance-b"'); + }); + it('validates sync config before deploying', async () => { const result = await runDeployDirect(); expect(managementClientMock.validateSyncRules).toHaveBeenCalled(); diff --git a/cli/test/commands/destroy.test.ts b/cli/test/commands/destroy.test.ts index fc6d2070..f6b495e5 100644 --- a/cli/test/commands/destroy.test.ts +++ b/cli/test/commands/destroy.test.ts @@ -129,6 +129,13 @@ describe('destroy', () => { writeLinkYaml(projectDir, { instance_id: INSTANCE_ID, org_id: ORG_ID, project_id: PROJECT_ID }); }); + it('prints the target instance name and IDs before destroying', async () => { + const result = await runDestroyDirect(['--confirm=yes']); + + expect(result.stdout).toContain('Target instance: test-instance'); + expect(result.stdout).toContain(`id: ${INSTANCE_ID}`); + }); + it('calls destroyInstance on the management client', async () => { await runDestroyDirect(['--confirm=yes']); diff --git a/cli/test/commands/stop.test.ts b/cli/test/commands/stop.test.ts index 5c801227..5cc83622 100644 --- a/cli/test/commands/stop.test.ts +++ b/cli/test/commands/stop.test.ts @@ -140,6 +140,26 @@ describe('stop', () => { }); }); + it('prints the target instance name and IDs before stopping', async () => { + const result = await runStopDirect(['--confirm=yes']); + + expect(managementClientMock.getInstance).toHaveBeenCalledWith({ id: INSTANCE_ID }); + expect(result.stdout).toContain('Target instance: test-instance'); + expect(result.stdout).toContain(`id: ${INSTANCE_ID}`); + expect(result.stdout).toContain(`project: ${PROJECT_ID}`); + expect(result.stdout).toContain(`org: ${ORG_ID}`); + }); + + it('still stops when the instance name cannot be fetched', async () => { + managementClientMock.getInstance.mockRejectedValue(new Error('network down')); + + const result = await runStopDirect(['--confirm=yes']); + + expect(result.stdout).toContain('Target instance: (name unavailable)'); + expect(result.stdout).toContain(`id: ${INSTANCE_ID}`); + expect(managementClientMock.deactivateInstance).toHaveBeenCalledTimes(1); + }); + it('attempts stop and errors with exit 1 when client fails', async () => { const result = await runStopDirect(['--confirm=yes']); expect(result.error).toBeDefined(); diff --git a/cli/test/setup.ts b/cli/test/setup.ts index ef5e301b..32ec5254 100644 --- a/cli/test/setup.ts +++ b/cli/test/setup.ts @@ -57,6 +57,7 @@ export function resetManagementClientMocks(): void { managementClientMock.getInstance.mockResolvedValue({ app_id: MOCK_CLOUD_IDS.projectId, id: MOCK_CLOUD_IDS.instanceId, + name: 'test-instance', org_id: MOCK_CLOUD_IDS.orgId }); managementClientMock.destroyInstance.mockRejectedValue(new Error('mock destroy failure')); diff --git a/packages/cli-core/src/command-types/CloudInstanceCommand.ts b/packages/cli-core/src/command-types/CloudInstanceCommand.ts index 734ae71b..9e6c8914 100644 --- a/packages/cli-core/src/command-types/CloudInstanceCommand.ts +++ b/packages/cli-core/src/command-types/CloudInstanceCommand.ts @@ -209,6 +209,38 @@ export abstract class CloudInstanceCommand extends InstanceCommand { return this._project; } + /** + * Prints which Cloud instance the command is about to act on, including its name, so users can confirm + * they are targeting the correct instance before anything happens. + * + * Call this after loadProject(). The instance name is fetched from the Management API unless it is + * already known (for example from a previous getInstanceConfig call). If the name cannot be fetched, + * the IDs are still printed and the command continues; a later API call will surface any real error. + * + * @returns The instance name, or undefined if it could not be resolved. + */ + async logTargetInstance(options: { instanceName?: string } = {}): Promise { + const { linked } = this.project; + + let { instanceName } = options; + if (instanceName == null) { + try { + ({ name: instanceName } = await this.client.getInstance({ id: linked.instance_id })); + } catch { + // Fall through, IDs are still printed below. + } + } + + const nameLabel = + instanceName == null ? ux.colorize('yellow', '(name unavailable)') : ux.colorize('blue', instanceName); + this.log(`Target instance: ${nameLabel} ${ux.colorize('gray', `id: ${linked.instance_id}`)}`); + this.log( + `\t${ux.colorize('gray', `project: ${linked.project_id}`)} ${ux.colorize('gray', `org: ${linked.org_id}`)}` + ); + + return instanceName; + } + parseLocalConfig(projectDirectory: string): ServiceCloudConfigDecoded { const servicePath = join(projectDirectory, SERVICE_FILENAME); const doc = parseYamlFile(servicePath); From ebb051d03f14384c84ff133716da70bc20fb7336 Mon Sep 17 00:00:00 2001 From: bean1352 Date: Wed, 2 Sep 2026 14:12:50 +0200 Subject: [PATCH 2/6] handle windows warning marker in deploy test --- cli/test/commands/deploy.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/test/commands/deploy.test.ts b/cli/test/commands/deploy.test.ts index 56b769c6..53ecdf7e 100644 --- a/cli/test/commands/deploy.test.ts +++ b/cli/test/commands/deploy.test.ts @@ -169,8 +169,8 @@ describe('deploy', () => { const result = await runDeployDirect(); - // oclif wraps warnings and prefixes each line with a marker, so normalise whitespace first. - const stderr = result.stderr.replaceAll(/[\s›]+/g, ' '); + // oclif wraps warnings and prefixes each line with a marker (› on macOS/Linux, » on Windows), so normalise first. + const stderr = result.stderr.replaceAll(/[\s›»]+/g, ' '); expect(stderr).toContain('Deploying will rename the instance from "test-instance" to "instance-b"'); }); From 37de2de1d0160bd9c0deafefcbcbc078a2c8575d Mon Sep 17 00:00:00 2001 From: bean1352 Date: Thu, 3 Sep 2026 10:16:12 +0200 Subject: [PATCH 3/6] add --dry-run with config diff to deploy commands --- .changeset/show-target-instance.md | 2 + cli/README.md | 27 +++++++--- cli/package.json | 1 + cli/src/api/BaseDeployCommand.ts | 50 ++++++++++++++++++ cli/src/api/dry-run.ts | 46 ++++++++++++++++ cli/src/commands/deploy/index.ts | 28 +++++++++- cli/src/commands/deploy/service-config.ts | 14 ++++- cli/src/commands/deploy/sync-config.ts | 35 ++++++++++--- cli/test/api/dry-run.test.ts | 43 +++++++++++++++ cli/test/commands/deploy.test.ts | 52 ++++++++++++++++++- .../commands/deploy/service-config.test.ts | 16 ++++++ cli/test/commands/deploy/sync-config.test.ts | 45 ++++++++++++++++ docs/usage.md | 1 + pnpm-lock.yaml | 3 ++ 14 files changed, 344 insertions(+), 19 deletions(-) create mode 100644 cli/src/api/dry-run.ts create mode 100644 cli/test/api/dry-run.test.ts diff --git a/.changeset/show-target-instance.md b/.changeset/show-target-instance.md index bc5971e3..a063cea1 100644 --- a/.changeset/show-target-instance.md +++ b/.changeset/show-target-instance.md @@ -4,3 +4,5 @@ --- Show the target instance name and IDs before `deploy`, `deploy sync-config`, `deploy service-config`, `stop`, `destroy` and `compact` do anything, so it is clear which instance is about to be changed. `deploy` and `deploy service-config` now also warn when the local `service.yaml` `name` differs from the instance name, since deploying renames the instance. + +All deploy commands accept `--dry-run`, which prints the target instance, runs the validations, shows a diff of the sync config and the changed service config sections, and stops without deploying. diff --git a/cli/README.md b/cli/README.md index aaa85adc..81ecde53 100644 --- a/cli/README.md +++ b/cli/README.md @@ -99,7 +99,7 @@ powersync link cloud --create --project-id= # add --org-id if toke powersync deploy ``` -Use `--directory` for a different config folder. The **powersync init cloud** command has a `--vscode` flag to configure your workspace for YAML custom tag support. +Use `--directory` for a different config folder. Add `--dry-run` to a deploy command to print the target instance, run the validations and see what would change, without deploying. The **powersync init cloud** command has a `--vscode` flag to configure your workspace for YAML custom tag support. ## Cloud secrets format (`service.yaml`) @@ -429,12 +429,14 @@ _See code: [src/commands/configure/ide.ts](https://github.com/powersync-ja/power ``` USAGE - $ powersync deploy [--deploy-timeout ] [--directory ] [--instance-id ] + $ powersync deploy [--deploy-timeout ] [--dry-run] [--directory ] [--instance-id ] [--sync-config-file-path ] [--skip-validations | --validate-only ] FLAGS --deploy-timeout= [default: 300] Seconds to wait after scheduling a deploy before timing out while polling status (default 300 seconds). + --dry-run Show the target instance, run the validations and print what would change, then exit + without deploying. --skip-validations= Comma-separated list of validation tests to skip. Options: configuration, connections, sync-config. Example: --skip-validations="configuration" --validate-only= Comma-separated list of validation tests to run, skipping all others. Options: @@ -457,10 +459,13 @@ DESCRIPTION Validates connections and sync config before deploying. See also powersync deploy sync-config to deploy only sync config changes. See also powersync deploy service-config to deploy only service config changes. + Use --dry-run to show the target instance and the validation results without deploying. EXAMPLES $ powersync deploy + $ powersync deploy --dry-run + $ powersync deploy --instance-id= ``` @@ -472,12 +477,14 @@ _See code: [src/commands/deploy/index.ts](https://github.com/powersync-ja/powers ``` USAGE - $ powersync deploy service-config [--deploy-timeout ] [--directory ] [--instance-id ] + $ powersync deploy service-config [--deploy-timeout ] [--dry-run] [--directory ] [--instance-id ] [--skip-validations | --validate-only ] FLAGS --deploy-timeout= [default: 300] Seconds to wait after scheduling a deploy before timing out while polling status (default 300 seconds). + --dry-run Show the target instance, run the validations and print what would change, then exit + without deploying. --skip-validations= Comma-separated list of validation tests to skip. Options: configuration, connections. Example: --skip-validations="configuration" --validate-only= Comma-separated list of validation tests to run, skipping all others. Options: @@ -494,11 +501,14 @@ CLOUD_PROJECT FLAGS DESCRIPTION [Cloud only] Deploy only local service config to the linked Cloud instance. - Deploy only service config changes (without sync config updates). + Deploy only service config changes (without sync config updates). Use --dry-run to show the target instance and the + validation results without deploying. EXAMPLES $ powersync deploy service-config + $ powersync deploy service-config --dry-run + $ powersync deploy service-config --instance-id= ``` @@ -510,12 +520,14 @@ _See code: [src/commands/deploy/service-config.ts](https://github.com/powersync- ``` USAGE - $ powersync deploy sync-config [--deploy-timeout ] [--directory ] [--instance-id ] + $ powersync deploy sync-config [--deploy-timeout ] [--dry-run] [--directory ] [--instance-id ] [--sync-config-file-path ] [--skip-validations | ] FLAGS --deploy-timeout= [default: 300] Seconds to wait after scheduling a deploy before timing out while polling status (default 300 seconds). + --dry-run Show the target instance, run the validations and print what would change, then exit + without deploying. --skip-validations= Comma-separated list of validation tests to skip. Options: sync-config. Example: --skip-validations="sync-config" @@ -532,11 +544,14 @@ CLOUD_PROJECT FLAGS DESCRIPTION [Cloud only] Deploy only local sync config to the linked Cloud instance. - Deploy only sync config changes. + Deploy only sync config changes. Use --dry-run to show the target instance and the validation results without + deploying. EXAMPLES $ powersync deploy sync-config + $ powersync deploy sync-config --dry-run + $ powersync deploy sync-config --instance-id= ``` diff --git a/cli/package.json b/cli/package.json index 5af4cdea..9037cc69 100644 --- a/cli/package.json +++ b/cli/package.json @@ -26,6 +26,7 @@ "@powersync/service-types": "catalog:", "@powersync/sync-config-tools": "^0.1.2", "bson": "^7.2.0", + "diff": "^8.0.4", "fastify": "^5.8.5", "jose": "^6.2.3", "lodash": "^4.18.1", diff --git a/cli/src/api/BaseDeployCommand.ts b/cli/src/api/BaseDeployCommand.ts index c525169b..70c23a70 100644 --- a/cli/src/api/BaseDeployCommand.ts +++ b/cli/src/api/BaseDeployCommand.ts @@ -9,8 +9,17 @@ import { routes } from '@powersync/management-types'; import ora from 'ora'; import { DEFAULT_DEPLOY_TIMEOUT_MS, waitForOperationStatusChange } from './cloud/wait-for-operation.js'; +import { changedServiceConfigSections, formatSyncConfigDiff } from './dry-run.js'; import { parseLocalCloudServiceConfig } from './parse-local-cloud-service-config.js'; +const DRY_RUN_SYNC_CONFIG_LABELS = { + changed: 'would deploy the local sync config. Diff against the deployed sync config:', + skipped: 'not changed by this command.', + unchanged: 'matches the deployed sync config, nothing to update.' +}; + +export type DryRunSyncConfig = keyof typeof DRY_RUN_SYNC_CONFIG_LABELS; + export default abstract class BaseDeployCommand extends CloudInstanceCommand { static baseFlags = { 'deploy-timeout': Flags.integer({ @@ -26,6 +35,11 @@ export default abstract class BaseDeployCommand extends CloudInstanceCommand { return value; } }), + 'dry-run': Flags.boolean({ + default: false, + description: + 'Show the target instance, run the validations and print what would change, then exit without deploying.' + }), ...CloudInstanceCommand.baseFlags }; @@ -75,6 +89,22 @@ export default abstract class BaseDeployCommand extends CloudInstanceCommand { }); } + protected describeServiceConfigChanges(cloudConfigState: routes.InstanceConfigResponse): string { + const summary = `would deploy ${SERVICE_FILENAME}.`; + if (!cloudConfigState.config) { + return `${summary} No config is deployed yet.`; + } + + const sections = changedServiceConfigSections(this.serviceConfig!, cloudConfigState); + if (!sections) { + return `${summary} Could not compare with the deployed config.`; + } + + return sections.length > 0 + ? `${summary} Changes in: ${sections.join(', ')}.` + : `${summary} No changes compared to the deployed config.`; + } + protected async loadCloudConfigState(): Promise { const { client, project } = this; const { linked } = project; @@ -92,6 +122,26 @@ export default abstract class BaseDeployCommand extends CloudInstanceCommand { }); } + /** Ends a --dry-run once the target and validation results are shown: reports what a real run would deploy. */ + protected logDryRun(params: { + cloudConfigState: routes.InstanceConfigResponse; + serviceConfig: boolean; + syncConfig: DryRunSyncConfig; + }): void { + const { cloudConfigState, serviceConfig, syncConfig } = params; + this.log(''); + this.log(ux.colorize('yellow', 'Dry run: nothing was deployed.')); + this.log( + `\tService config: ${serviceConfig ? this.describeServiceConfigChanges(cloudConfigState) : 'not changed by this command.'}` + ); + this.log(`\tSync config: ${DRY_RUN_SYNC_CONFIG_LABELS[syncConfig]}`); + if (syncConfig === 'changed') { + for (const line of formatSyncConfigDiff(cloudConfigState.sync_rules ?? '', this.project.syncRulesContent ?? '')) { + this.log(`\t\t${line}`); + } + } + } + override parseLocalConfig(projectDirectory: string, useRawConfig?: boolean): ServiceCloudConfigDecoded { const config = parseLocalCloudServiceConfig(projectDirectory, useRawConfig ?? false); if (!config) { diff --git a/cli/src/api/dry-run.ts b/cli/src/api/dry-run.ts new file mode 100644 index 00000000..2170affa --- /dev/null +++ b/cli/src/api/dry-run.ts @@ -0,0 +1,46 @@ +import { ux } from '@oclif/core'; +import { AdditionalCloudConfigFields, ServiceCloudConfigDecoded } from '@powersync/cli-schemas'; +import { routes } from '@powersync/management-types'; +import { structuredPatch } from 'diff'; +import isEqual from 'lodash/isEqual.js'; + +import { decodeFetchedCloudConfig } from './cloud/fetch-cloud-config.js'; + +const CLI_ONLY_FIELDS = new Set(Object.keys(AdditionalCloudConfigFields.props.shape)); + +function colorizeDiffLine(line: string): string { + if (line.startsWith('+')) return ux.colorize('green', line); + if (line.startsWith('-')) return ux.colorize('red', line); + return line; +} + +/** + * Names the top-level service config sections whose local value differs from the deployed one. + * Returns undefined when the deployed config cannot be decoded for comparison. + */ +export function changedServiceConfigSections( + localConfig: ServiceCloudConfigDecoded, + cloudConfigState: routes.InstanceConfigResponse +): string[] | undefined { + let deployed: Record; + try { + deployed = decodeFetchedCloudConfig(cloudConfigState).config as Record; + } catch { + return undefined; + } + + const local = localConfig as Record; + const sections = new Set([...Object.keys(deployed), ...Object.keys(local)]); + return [...sections] + .filter((section) => !CLI_ONLY_FIELDS.has(section) && !isEqual(local[section], deployed[section])) + .sort(); +} + +/** Unified diff of the deployed sync config against the local one, one colorized entry per line. Empty when identical. */ +export function formatSyncConfigDiff(deployed: string, local: string): string[] { + const { hunks } = structuredPatch('deployed', 'local', deployed, local, undefined, undefined, { context: 3 }); + return hunks.flatMap((hunk) => [ + ux.colorize('cyan', `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`), + ...hunk.lines.map((line) => colorizeDiffLine(line)) + ]); +} diff --git a/cli/src/commands/deploy/index.ts b/cli/src/commands/deploy/index.ts index 85b3eb2b..c4f3fc42 100644 --- a/cli/src/commands/deploy/index.ts +++ b/cli/src/commands/deploy/index.ts @@ -14,9 +14,14 @@ export default class DeployAll extends WithSyncConfigFilePath(BaseDeployCommand) 'Deploy local config (service.yaml, sync config) to the linked PowerSync Cloud instance.', 'Validates connections and sync config before deploying.', `See also ${ux.colorize('blue', 'powersync deploy sync-config')} to deploy only sync config changes.`, - `See also ${ux.colorize('blue', 'powersync deploy service-config')} to deploy only service config changes.` + `See also ${ux.colorize('blue', 'powersync deploy service-config')} to deploy only service config changes.`, + 'Use --dry-run to show the target instance and the validation results without deploying.' ].join('\n'); - static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --instance-id=']; + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --dry-run', + '<%= config.bin %> <%= command.id %> --instance-id=' + ]; static flags = { ...GENERAL_VALIDATION_FLAG_HELPERS.flags }; @@ -30,6 +35,7 @@ export default class DeployAll extends WithSyncConfigFilePath(BaseDeployCommand) }); const deployTimeoutMs = (flags['deploy-timeout'] ?? DEFAULT_DEPLOY_TIMEOUT_MS / 1000) * 1000; + const dryRun = flags['dry-run']; const validationTestsFilter = GENERAL_VALIDATION_FLAG_HELPERS.parseValidationTestFlags(flags); @@ -62,6 +68,11 @@ export default class DeployAll extends WithSyncConfigFilePath(BaseDeployCommand) const requiresReprovision = instanceStatus.provisioned === false; const syncConfigHasChanges = project.syncRulesContent !== cloudConfigState.sync_rules; + const dryRunSummary = { + cloudConfigState, + serviceConfig: true, + syncConfig: syncConfigHasChanges ? 'changed' : 'unchanged' + } as const; let didReprovision = false; @@ -92,6 +103,14 @@ export default class DeployAll extends WithSyncConfigFilePath(BaseDeployCommand) }); } + if (dryRun) { + this.log( + `The instance is ${ux.colorize('yellow', 'not currently provisioned')}. Deploying would first provision it, then validate and deploy the sync config.` + ); + this.logDryRun(dryRunSummary); + return; + } + /** * The non-sync-config validations passed. Reprovision now so that the instance is active * and we can validate the sync config against it in the second pass below. @@ -141,6 +160,11 @@ export default class DeployAll extends WithSyncConfigFilePath(BaseDeployCommand) }); } + if (dryRun) { + this.logDryRun(dryRunSummary); + return; + } + await this.deployAll({ cloudConfigState, deployTimeoutMs, updateSyncConfig: syncConfigHasChanges }); } } diff --git a/cli/src/commands/deploy/service-config.ts b/cli/src/commands/deploy/service-config.ts index 7c77ab46..3ad6ee23 100644 --- a/cli/src/commands/deploy/service-config.ts +++ b/cli/src/commands/deploy/service-config.ts @@ -11,8 +11,13 @@ const SERVICE_CONFIG_VALIDATION_FLAGS = generateValidationTestFlags({ }); export default class DeployServiceConfig extends BaseDeployCommand { - static description = 'Deploy only service config changes (without sync config updates).'; - static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --instance-id=']; + static description = + 'Deploy only service config changes (without sync config updates). Use --dry-run to show the target instance and the validation results without deploying.'; + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --dry-run', + '<%= config.bin %> <%= command.id %> --instance-id=' + ]; static flags = { ...SERVICE_CONFIG_VALIDATION_FLAGS.flags }; @@ -57,6 +62,11 @@ export default class DeployServiceConfig extends BaseDeployCommand { }); } + if (flags['dry-run']) { + this.logDryRun({ cloudConfigState, serviceConfig: true, syncConfig: 'skipped' }); + return; + } + await this.deployAll({ cloudConfigState, deployTimeoutMs, updateSyncConfig: false }); } } diff --git a/cli/src/commands/deploy/sync-config.ts b/cli/src/commands/deploy/sync-config.ts index ea65324f..652e3898 100644 --- a/cli/src/commands/deploy/sync-config.ts +++ b/cli/src/commands/deploy/sync-config.ts @@ -17,8 +17,13 @@ const SYNC_CONFIG_VALIDATION_FLAGS = generateValidationTestFlags({ }); export default class DeploySyncConfig extends WithSyncConfigFilePath(BaseDeployCommand) { - static description = 'Deploy only sync config changes.'; - static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --instance-id=']; + static description = + 'Deploy only sync config changes. Use --dry-run to show the target instance and the validation results without deploying.'; + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --dry-run', + '<%= config.bin %> <%= command.id %> --instance-id=' + ]; static flags = { ...SYNC_CONFIG_VALIDATION_FLAGS.flags }; @@ -74,12 +79,7 @@ export default class DeploySyncConfig extends WithSyncConfigFilePath(BaseDeployC const { linked } = project; const deployTimeoutMs = (flags['deploy-timeout'] ?? DEFAULT_DEPLOY_TIMEOUT_MS / 1000) * 1000; - - if (!project.syncRulesContent) { - this.styledError({ - message: `Sync config content not loaded. Ensure sync config is present and valid.` - }); - } + const dryRun = flags['dry-run']; if (!project.syncRulesContent) { this.styledError({ @@ -120,7 +120,21 @@ export default class DeploySyncConfig extends WithSyncConfigFilePath(BaseDeployC }); }); + const dryRunSummary = { + cloudConfigState, + serviceConfig: false, + syncConfig: project.syncRulesContent === cloudConfigState.sync_rules ? 'unchanged' : 'changed' + } as const; + if (!instanceStatus.provisioned) { + if (dryRun) { + this.log( + `\nThe instance is ${ux.colorize('yellow', 'not currently provisioned')}. Deploying would first provision it, then validate and deploy the sync config.` + ); + this.logDryRun(dryRunSummary); + return; + } + this.log( `\nThe instance is not currently provisioned. Triggering a deploy in order to reprovision. This may take a few minutes.\n` ); @@ -149,6 +163,11 @@ export default class DeploySyncConfig extends WithSyncConfigFilePath(BaseDeployC }); } + if (dryRun) { + this.logDryRun(dryRunSummary); + return; + } + await this.deploySyncConfig({ cloudConfigState, timeout: deployTimeoutMs }); } } diff --git a/cli/test/api/dry-run.test.ts b/cli/test/api/dry-run.test.ts new file mode 100644 index 00000000..fb2ab56e --- /dev/null +++ b/cli/test/api/dry-run.test.ts @@ -0,0 +1,43 @@ +import { ServiceCloudConfigDecoded } from '@powersync/cli-schemas'; +import { routes } from '@powersync/management-types'; +import { describe, expect, it } from 'vitest'; + +import { changedServiceConfigSections, formatSyncConfigDiff } from '../../src/api/dry-run.js'; + +const ANSI_SEQUENCE = new RegExp(`${String.fromCodePoint(27)}\\[[\\d;]*m`, 'g'); +const stripAnsi = (line: string) => line.replaceAll(ANSI_SEQUENCE, ''); + +const DEPLOYED_CONFIG = { + region: 'us', + replication: { connections: [{ name: 'default', type: 'postgresql', uri: 'postgres://user:pass@host/db' }] } +}; + +const cloudState = (config: unknown) => + ({ config, id: 'instance', name: 'test-instance', sync_rules: '' }) as unknown as routes.InstanceConfigResponse; + +const localConfig = (overrides: Record = {}) => + ({ _type: 'cloud', name: 'test-instance', ...DEPLOYED_CONFIG, ...overrides }) as ServiceCloudConfigDecoded; + +describe('dry run helpers', () => { + it('formatSyncConfigDiff returns nothing for identical sync config', () => { + expect(formatSyncConfigDiff('a: 1\n', 'a: 1\n')).toEqual([]); + }); + + it('formatSyncConfigDiff returns unified diff lines', () => { + const lines = formatSyncConfigDiff('a: 1\nb: 2\n', 'a: 1\nc: 3\n').map((line) => stripAnsi(line)); + expect(lines).toEqual(['@@ -1,2 +1,2 @@', ' a: 1', '-b: 2', '+c: 3']); + }); + + it('changedServiceConfigSections ignores the name and reports differing sections', () => { + expect(changedServiceConfigSections(localConfig({ name: 'renamed' }), cloudState(DEPLOYED_CONFIG))).toEqual([]); + + const changed = localConfig({ + replication: { connections: [{ name: 'default', type: 'postgresql', uri: 'postgres://user:pass@other/db' }] } + }); + expect(changedServiceConfigSections(changed, cloudState(DEPLOYED_CONFIG))).toEqual(['replication']); + }); + + it('changedServiceConfigSections returns undefined when the deployed config cannot be decoded', () => { + expect(changedServiceConfigSections(localConfig(), cloudState({ region: 42 }))).toBeUndefined(); + }); +}); diff --git a/cli/test/commands/deploy.test.ts b/cli/test/commands/deploy.test.ts index 53ecdf7e..53a9c5b8 100644 --- a/cli/test/commands/deploy.test.ts +++ b/cli/test/commands/deploy.test.ts @@ -60,7 +60,10 @@ describe('deploy', () => { process.chdir(tmpDir); process.env.PS_ADMIN_TOKEN = 'test-token'; managementClientMock.getInstanceConfig.mockResolvedValue({ - config: { region: 'us', replication: { connections: [{ name: 'default', type: 'postgresql' }] } }, + config: { + region: 'us', + replication: { connections: [{ name: 'default', type: 'postgresql', uri: 'postgres://user:pass@host/db' }] } + }, id: INSTANCE_ID, name: 'test-instance', sync_rules: '' @@ -174,6 +177,53 @@ describe('deploy', () => { expect(stderr).toContain('Deploying will rename the instance from "test-instance" to "instance-b"'); }); + it('--dry-run prints the target and validation results without deploying', async () => { + const result = await runDeployDirect({ args: ['--dry-run'] }); + + expect(result.error).toBeUndefined(); + expect(managementClientMock.testConnection).toHaveBeenCalled(); + expect(managementClientMock.validateSyncRules).toHaveBeenCalled(); + expect(managementClientMock.deployInstance).not.toHaveBeenCalled(); + expect(result.stdout).toContain('Target instance: test-instance'); + expect(result.stdout).toContain('Dry run: nothing was deployed.'); + expect(result.stdout).toContain( + `Service config: would deploy ${SERVICE_FILENAME}. No changes compared to the deployed config.` + ); + expect(result.stdout).toContain('Sync config: would deploy the local sync config'); + expect(result.stdout).toContain('+bucket_definitions:'); + }); + + it('--dry-run names the service config sections that would change', async () => { + const projectDir = join(tmpDir, PROJECT_DIR); + writeCloudServiceYaml(projectDir, { + _type: 'cloud', + name: 'test-instance', + region: 'us', + replication: { + connections: [{ name: 'default', type: 'postgresql', uri: 'postgres://user:pass@other-host/db' }] + } + }); + + const result = await runDeployDirect({ args: ['--dry-run'] }); + + expect(result.error).toBeUndefined(); + expect(result.stdout).toContain(`Service config: would deploy ${SERVICE_FILENAME}. Changes in: replication.`); + }); + + it('--dry-run does not provision a deprovisioned instance', async () => { + managementClientMock.getInstanceStatus.mockResolvedValue({ operations: [], provisioned: false }); + + const result = await runDeployDirect({ args: ['--dry-run'] }); + + expect(result.error).toBeUndefined(); + expect(managementClientMock.testConnection).toHaveBeenCalled(); + // Sync config validation needs a provisioned instance, and a dry run must not provision one. + expect(managementClientMock.validateSyncRules).not.toHaveBeenCalled(); + expect(managementClientMock.deployInstance).not.toHaveBeenCalled(); + expect(result.stdout).toContain('not currently provisioned'); + expect(result.stdout).toContain('Dry run: nothing was deployed.'); + }); + it('validates sync config before deploying', async () => { const result = await runDeployDirect(); expect(managementClientMock.validateSyncRules).toHaveBeenCalled(); diff --git a/cli/test/commands/deploy/service-config.test.ts b/cli/test/commands/deploy/service-config.test.ts index aaa0bafd..f4a99769 100644 --- a/cli/test/commands/deploy/service-config.test.ts +++ b/cli/test/commands/deploy/service-config.test.ts @@ -188,6 +188,22 @@ describe('deploy:service-config', () => { }); }); + it('--dry-run validates and prints the target without deploying', async () => { + const projectDir = makeProjectDir(tmpDir); + writeServiceYaml(projectDir); + writeLinkYaml(projectDir); + + const result = await runServiceConfigDirect(['--dry-run']); + + expect(result.error).toBeUndefined(); + expect(managementClientMock.testConnection).toHaveBeenCalled(); + expect(managementClientMock.deployInstance).not.toHaveBeenCalled(); + expect(result.stdout).toContain('Target instance: test-instance'); + expect(result.stdout).toContain('Dry run: nothing was deployed.'); + expect(result.stdout).toContain(`Service config: would deploy ${SERVICE_FILENAME}`); + expect(result.stdout).toContain('Sync config: not changed by this command'); + }); + it('errors when service.yaml is missing', async () => { const projectDir = makeProjectDir(tmpDir); writeLinkYaml(projectDir); diff --git a/cli/test/commands/deploy/sync-config.test.ts b/cli/test/commands/deploy/sync-config.test.ts index 63a85219..c550ff88 100644 --- a/cli/test/commands/deploy/sync-config.test.ts +++ b/cli/test/commands/deploy/sync-config.test.ts @@ -212,6 +212,51 @@ describe('deploy:sync-config', () => { expect(result.error?.message).toMatch(/nonexistent/); }); + it('--dry-run validates and reports the sync config change without deploying', async () => { + const projectDir = makeProjectDir(tmpDir); + writeLinkYaml(projectDir); + writeFileSync(join(projectDir, SYNC_FILENAME), SYNC_CONFIG_CONTENT, 'utf8'); + + const result = await runSyncConfigDirect(['--dry-run']); + + expect(result.error).toBeUndefined(); + expect(managementClientMock.validateSyncRules).toHaveBeenCalled(); + expect(managementClientMock.deployInstance).not.toHaveBeenCalled(); + expect(result.stdout).toContain('Target instance: test-instance'); + expect(result.stdout).toContain('Dry run: nothing was deployed.'); + expect(result.stdout).toContain('Service config: not changed by this command'); + expect(result.stdout).toContain('Sync config: would deploy the local sync config'); + expect(result.stdout).toContain('+ - SELECT * FROM todos'); + }); + + it('--dry-run reports when the local sync config matches the deployed one', async () => { + const projectDir = makeProjectDir(tmpDir); + writeLinkYaml(projectDir); + writeFileSync(join(projectDir, SYNC_FILENAME), SYNC_CONFIG_CONTENT, 'utf8'); + managementClientMock.getInstanceConfig.mockResolvedValue({ ...MOCK_CLOUD_CONFIG, sync_rules: SYNC_CONFIG_CONTENT }); + + const result = await runSyncConfigDirect(['--dry-run']); + + expect(result.error).toBeUndefined(); + expect(managementClientMock.deployInstance).not.toHaveBeenCalled(); + expect(result.stdout).toContain('Sync config: matches the deployed sync config, nothing to update'); + }); + + it('--dry-run does not provision a deprovisioned instance', async () => { + const projectDir = makeProjectDir(tmpDir); + writeLinkYaml(projectDir); + writeFileSync(join(projectDir, SYNC_FILENAME), SYNC_CONFIG_CONTENT, 'utf8'); + managementClientMock.getInstanceStatus.mockResolvedValue({ operations: [], provisioned: false }); + + const result = await runSyncConfigDirect(['--dry-run']); + + expect(result.error).toBeUndefined(); + expect(managementClientMock.validateSyncRules).not.toHaveBeenCalled(); + expect(managementClientMock.deployInstance).not.toHaveBeenCalled(); + expect(result.stdout).toContain('not currently provisioned'); + expect(result.stdout).toContain('Dry run: nothing was deployed.'); + }); + it('validates sync config before deploying', async () => { const projectDir = makeProjectDir(tmpDir); writeLinkYaml(projectDir); diff --git a/docs/usage.md b/docs/usage.md index e4ac51fe..a4e2da34 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -107,6 +107,7 @@ Deploy command modes: - `powersync deploy` — deploy both service config and sync config. - `powersync deploy service-config` — deploy only service config changes, without updating sync config. - `powersync deploy sync-config` — deploy only sync config changes. +- `--dry-run` on any deploy command — print the target instance, run the validations, and show what would change (a diff of the sync config and the changed service config sections), then stop without deploying. Use it to check which instance a shared config directory or a CI job points at. Connections that pass a password with `secret` always show `replication` as changed, because the value is sent again. The instance **name** and **region** are taken from your local `service.yaml`; set them before running `powersync link cloud --create` if you want a specific display name and region. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14edd97d..a9e6d60f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: bson: specifier: ^7.2.0 version: 7.2.0 + diff: + specifier: ^8.0.4 + version: 8.0.4 fastify: specifier: ^5.8.5 version: 5.8.5 From 2f2d34e1a70e0df83a403c8a8f4cf5bdf204a839 Mon Sep 17 00:00:00 2001 From: bean1352 Date: Thu, 3 Sep 2026 10:22:44 +0200 Subject: [PATCH 4/6] show target instance in status --- .changeset/show-target-instance.md | 2 +- cli/README.md | 4 +- cli/src/commands/fetch/status.ts | 6 +- cli/test/commands/fetch/status.test.ts | 95 +++++++++++++++++++ cli/test/setup.ts | 1 + .../src/command-types/CloudInstanceCommand.ts | 34 ++----- .../command-types/SharedInstanceCommand.ts | 6 ++ packages/cli-core/src/index.ts | 1 + .../cli-core/src/utils/log-target-instance.ts | 48 ++++++++++ 9 files changed, 168 insertions(+), 29 deletions(-) create mode 100644 cli/test/commands/fetch/status.test.ts create mode 100644 packages/cli-core/src/utils/log-target-instance.ts diff --git a/.changeset/show-target-instance.md b/.changeset/show-target-instance.md index a063cea1..49811a09 100644 --- a/.changeset/show-target-instance.md +++ b/.changeset/show-target-instance.md @@ -3,6 +3,6 @@ 'powersync': patch --- -Show the target instance name and IDs before `deploy`, `deploy sync-config`, `deploy service-config`, `stop`, `destroy` and `compact` do anything, so it is clear which instance is about to be changed. `deploy` and `deploy service-config` now also warn when the local `service.yaml` `name` differs from the instance name, since deploying renames the instance. +Show the target instance name and IDs before `deploy`, `deploy sync-config`, `deploy service-config`, `stop`, `destroy` and `compact` do anything, so it is clear which instance is about to be changed. `status` shows the target first as well, with the API URL for self-hosted instances. `deploy` and `deploy service-config` now also warn when the local `service.yaml` `name` differs from the instance name, since deploying renames the instance. All deploy commands accept `--dry-run`, which prints the target instance, runs the validations, shows a diff of the sync config and the changed service config sections, and stops without deploying. diff --git a/cli/README.md b/cli/README.md index 81ecde53..12da99dc 100644 --- a/cli/README.md +++ b/cli/README.md @@ -857,7 +857,7 @@ DESCRIPTION Show instance diagnostics (connections, sync config, replication). Fetch instance diagnostics: connection status, active and deploying sync config, replication state. Output as - human-readable, JSON, or YAML. Cloud and self-hosted. + human-readable, JSON, or YAML. Human output starts with the target instance. Cloud and self-hosted. EXAMPLES $ powersync fetch status @@ -1524,7 +1524,7 @@ DESCRIPTION Show instance diagnostics (connections, sync config, replication). Fetch instance diagnostics: connection status, active and deploying sync config, replication state. Output as - human-readable, JSON, or YAML. Cloud and self-hosted. + human-readable, JSON, or YAML. Human output starts with the target instance. Cloud and self-hosted. EXAMPLES $ powersync status diff --git a/cli/src/commands/fetch/status.ts b/cli/src/commands/fetch/status.ts index 573702ae..20dcca85 100644 --- a/cli/src/commands/fetch/status.ts +++ b/cli/src/commands/fetch/status.ts @@ -6,7 +6,7 @@ import { DiagnosticsResponse, formatDiagnosticsHuman } from '../../api/display-s export default class FetchStatus extends SharedInstanceCommand { static description = - 'Fetch instance diagnostics: connection status, active and deploying sync config, replication state. Output as human-readable, JSON, or YAML. Cloud and self-hosted.'; + 'Fetch instance diagnostics: connection status, active and deploying sync config, replication state. Output as human-readable, JSON, or YAML. Human output starts with the target instance. Cloud and self-hosted.'; static examples = [ '<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --output=json', @@ -45,6 +45,10 @@ export default class FetchStatus extends SharedInstanceCommand { const { flags } = await this.parse(FetchStatus); const project = await this.loadProject(flags); + if (flags.output === 'human') { + await this.logTargetInstance(project); + this.log(''); + } try { const diagnostics = await (project.linked.type === 'cloud' diff --git a/cli/test/commands/fetch/status.test.ts b/cli/test/commands/fetch/status.test.ts new file mode 100644 index 00000000..7dba0f52 --- /dev/null +++ b/cli/test/commands/fetch/status.test.ts @@ -0,0 +1,95 @@ +import { Config } from '@oclif/core'; +import { captureOutput } from '@oclif/test'; +import { CLI_FILENAME, SERVICE_FILENAME } from '@powersync/cli-core'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import FetchStatus from '../../../src/commands/fetch/status.js'; +import { root } from '../../helpers/root.js'; +import { managementClientMock, MOCK_CLOUD_IDS, resetManagementClientMocks } from '../../setup.js'; + +const { instanceId: INSTANCE_ID, orgId: ORG_ID, projectId: PROJECT_ID } = MOCK_CLOUD_IDS; +const API_URL = 'https://ps.example.com'; + +const DIAGNOSTICS = { + connections: [{ connected: true, errors: [], id: 'default', postgres_uri: 'postgres://host/db' }] +}; + +/** Run status by instantiating the command directly so the managementClientMock applies. */ +async function runStatusDirect(args: string[] = []) { + const config = await Config.load({ root }); + const cmd = new FetchStatus(args, config); + cmd.cloudClient = managementClientMock as unknown as FetchStatus['cloudClient']; + return captureOutput(() => cmd.run()); +} + +describe('fetch status', () => { + let tmpDir: string; + let origCwd: string; + let projectDir: string; + + beforeEach(() => { + resetManagementClientMocks(); + origCwd = process.cwd(); + tmpDir = mkdtempSync(join(tmpdir(), 'status-test-')); + process.chdir(tmpDir); + projectDir = join(tmpDir, 'powersync'); + mkdirSync(projectDir, { recursive: true }); + managementClientMock.getInstanceStatus.mockResolvedValue({ operations: [], provisioned: true }); + managementClientMock.getInstanceDiagnostics.mockResolvedValue(DIAGNOSTICS); + }); + + afterEach(() => { + vi.restoreAllMocks(); + process.chdir(origCwd); + if (tmpDir && existsSync(tmpDir)) rmSync(tmpDir, { recursive: true }); + }); + + function linkCloud() { + writeFileSync(join(projectDir, SERVICE_FILENAME), '_type: cloud\n', 'utf8'); + writeFileSync( + join(projectDir, CLI_FILENAME), + `type: cloud\ninstance_id: ${INSTANCE_ID}\norg_id: ${ORG_ID}\nproject_id: ${PROJECT_ID}\n`, + 'utf8' + ); + } + + it('prints the target Cloud instance before the diagnostics', async () => { + linkCloud(); + + const result = await runStatusDirect(); + + expect(result.error).toBeUndefined(); + expect(managementClientMock.getInstance).toHaveBeenCalledTimes(1); + expect(result.stdout).toContain('Target instance: test-instance'); + expect(result.stdout).toContain(`id: ${INSTANCE_ID}`); + expect(result.stdout).toContain(`project: ${PROJECT_ID}`); + expect(result.stdout).toContain(`org: ${ORG_ID}`); + expect(result.stdout.indexOf('Target instance:')).toBeLessThan(result.stdout.indexOf('Connections')); + }); + + it('keeps json output machine readable', async () => { + linkCloud(); + + const result = await runStatusDirect(['--output=json']); + + expect(result.error).toBeUndefined(); + expect(result.stdout).not.toContain('Target instance'); + expect(JSON.parse(result.stdout)).toEqual(DIAGNOSTICS); + }); + + it('prints the API URL for a self-hosted instance', async () => { + writeFileSync(join(projectDir, SERVICE_FILENAME), '_type: self-hosted\n', 'utf8'); + writeFileSync(join(projectDir, CLI_FILENAME), `type: self-hosted\napi_url: ${API_URL}\napi_key: key\n`, 'utf8'); + vi.spyOn(FetchStatus.prototype, 'getSelfHostedStatus').mockResolvedValue(DIAGNOSTICS); + + const result = await runStatusDirect(); + + expect(result.error).toBeUndefined(); + expect(managementClientMock.getInstance).not.toHaveBeenCalled(); + expect(result.stdout).toContain(`Target instance: ${API_URL} (self-hosted)`); + expect(result.stdout).toContain('Connections'); + }); +}); diff --git a/cli/test/setup.ts b/cli/test/setup.ts index 32ec5254..a8fd6010 100644 --- a/cli/test/setup.ts +++ b/cli/test/setup.ts @@ -41,6 +41,7 @@ export const managementClientMock = { destroyInstance: vi.fn(), getInstance: vi.fn(), getInstanceConfig: vi.fn(), + getInstanceDiagnostics: vi.fn(), getInstanceStatus: vi.fn(), listRegions: vi.fn(), testConnection: vi.fn(), diff --git a/packages/cli-core/src/command-types/CloudInstanceCommand.ts b/packages/cli-core/src/command-types/CloudInstanceCommand.ts index 9e6c8914..207e1bc7 100644 --- a/packages/cli-core/src/command-types/CloudInstanceCommand.ts +++ b/packages/cli-core/src/command-types/CloudInstanceCommand.ts @@ -13,6 +13,7 @@ import { createCloudClient } from '../clients/create-cloud-client.js'; import { ensureServiceTypeMatches, ServiceType } from '../utils/ensure-service-type.js'; import { env } from '../utils/env.js'; import { LINK_MISSING_ERROR_MESSAGE } from '../utils/errors.js'; +import { logTargetInstance } from '../utils/log-target-instance.js'; import { OBJECT_ID_REGEX } from '../utils/object-id.js'; import { CLI_FILENAME, SERVICE_FILENAME } from '../utils/project-config.js'; import { resolveCloudInstanceLink } from '../utils/resolve-cloud-instance-link.js'; @@ -210,35 +211,18 @@ export abstract class CloudInstanceCommand extends InstanceCommand { } /** - * Prints which Cloud instance the command is about to act on, including its name, so users can confirm - * they are targeting the correct instance before anything happens. - * - * Call this after loadProject(). The instance name is fetched from the Management API unless it is - * already known (for example from a previous getInstanceConfig call). If the name cannot be fetched, - * the IDs are still printed and the command continues; a later API call will surface any real error. + * Prints which Cloud instance the command is about to act on. Call this after loadProject(). + * See {@link logTargetInstance} for details. * * @returns The instance name, or undefined if it could not be resolved. */ async logTargetInstance(options: { instanceName?: string } = {}): Promise { - const { linked } = this.project; - - let { instanceName } = options; - if (instanceName == null) { - try { - ({ name: instanceName } = await this.client.getInstance({ id: linked.instance_id })); - } catch { - // Fall through, IDs are still printed below. - } - } - - const nameLabel = - instanceName == null ? ux.colorize('yellow', '(name unavailable)') : ux.colorize('blue', instanceName); - this.log(`Target instance: ${nameLabel} ${ux.colorize('gray', `id: ${linked.instance_id}`)}`); - this.log( - `\t${ux.colorize('gray', `project: ${linked.project_id}`)} ${ux.colorize('gray', `org: ${linked.org_id}`)}` - ); - - return instanceName; + return logTargetInstance({ + client: this.client, + instanceName: options.instanceName, + log: (message) => this.log(message), + project: this.project + }); } parseLocalConfig(projectDirectory: string): ServiceCloudConfigDecoded { diff --git a/packages/cli-core/src/command-types/SharedInstanceCommand.ts b/packages/cli-core/src/command-types/SharedInstanceCommand.ts index bb384a9f..d582fe12 100644 --- a/packages/cli-core/src/command-types/SharedInstanceCommand.ts +++ b/packages/cli-core/src/command-types/SharedInstanceCommand.ts @@ -20,6 +20,7 @@ import { createCloudClient } from '../clients/create-cloud-client.js'; import { ensureServiceTypeMatches, ServiceType } from '../utils/ensure-service-type.js'; import { env } from '../utils/env.js'; import { LINK_MISSING_ERROR_MESSAGE } from '../utils/errors.js'; +import { logTargetInstance } from '../utils/log-target-instance.js'; import { CLI_FILENAME, SERVICE_FILENAME } from '../utils/project-config.js'; import { resolveCloudInstanceLink } from '../utils/resolve-cloud-instance-link.js'; import { resolveSyncRulesContent } from '../utils/resolve-sync-rules-content.js'; @@ -247,6 +248,11 @@ export abstract class SharedInstanceCommand extends InstanceCommand { }); } + /** Prints which instance the command is about to act on. See {@link logTargetInstance}. */ + async logTargetInstance(project: CloudProject | SelfHostedProject): Promise { + return logTargetInstance({ client: this.cloudClient, log: (message) => this.log(message), project }); + } + parseCloudConfig(projectDirectory: string): ServiceCloudConfigDecoded { const servicePath = join(projectDirectory, SERVICE_FILENAME); const doc = parseYamlFile(servicePath); diff --git a/packages/cli-core/src/index.ts b/packages/cli-core/src/index.ts index 0a9034d6..ffd991be 100644 --- a/packages/cli-core/src/index.ts +++ b/packages/cli-core/src/index.ts @@ -21,6 +21,7 @@ export * from './services/storage/StorageImp.js'; export * from './services/storage/StorageService.js'; export * from './utils/ensure-service-type.js'; export * from './utils/env.js'; +export * from './utils/log-target-instance.js'; export * from './utils/object-id.js'; export * from './utils/project-config.js'; export * from './utils/resolve-cloud-instance-link.js'; diff --git a/packages/cli-core/src/utils/log-target-instance.ts b/packages/cli-core/src/utils/log-target-instance.ts new file mode 100644 index 00000000..8c626038 --- /dev/null +++ b/packages/cli-core/src/utils/log-target-instance.ts @@ -0,0 +1,48 @@ +import { ux } from '@oclif/core'; +import { PowerSyncManagementClient } from '@powersync/management-client'; + +import type { CloudProject } from '../command-types/CloudInstanceCommand.js'; +import type { SelfHostedProject } from '../command-types/SelfHostedInstanceCommand.js'; + +export type LogTargetInstanceParams = { + client: PowerSyncManagementClient; + /** Skips the lookup when the Cloud instance name is already known, for example from getInstanceConfig. */ + instanceName?: string; + log: (message: string) => void; + project: CloudProject | SelfHostedProject; +}; + +/** + * Prints which instance a command is about to act on, so users can confirm the target before anything happens. + * + * For Cloud projects the name is fetched from the Management API unless it is given. If that fails, the IDs + * are still printed and the command continues; a later API call surfaces the real error. Self-hosted projects + * have no instance name, so the API URL is printed instead. + * + * @returns The Cloud instance name, or undefined if it is unavailable or the project is self-hosted. + */ +export async function logTargetInstance(params: LogTargetInstanceParams): Promise { + const { client, log, project } = params; + const { linked } = project; + + if (linked.type === 'self-hosted') { + log(`Target instance: ${ux.colorize('blue', linked.api_url)} ${ux.colorize('gray', '(self-hosted)')}`); + return; + } + + let { instanceName } = params; + if (instanceName == null) { + try { + ({ name: instanceName } = await client.getInstance({ id: linked.instance_id })); + } catch { + // Fall through, IDs are still printed below. + } + } + + const nameLabel = + instanceName == null ? ux.colorize('yellow', '(name unavailable)') : ux.colorize('blue', instanceName); + log(`Target instance: ${nameLabel} ${ux.colorize('gray', `id: ${linked.instance_id}`)}`); + log(`\t${ux.colorize('gray', `project: ${linked.project_id}`)} ${ux.colorize('gray', `org: ${linked.org_id}`)}`); + + return instanceName; +} From a3037c86f95ac9f57c1b51200aae0b84a7096522 Mon Sep 17 00:00:00 2001 From: bean1352 Date: Thu, 3 Sep 2026 10:27:58 +0200 Subject: [PATCH 5/6] tidy target labels and dry-run notes --- cli/README.md | 10 +++++----- cli/src/api/BaseDeployCommand.ts | 14 ++++++++++++-- cli/src/api/dry-run.ts | 2 +- cli/src/commands/compact.ts | 4 ++-- cli/src/commands/deploy/index.ts | 7 ++----- cli/src/commands/deploy/service-config.ts | 2 +- cli/src/commands/deploy/sync-config.ts | 7 ++----- cli/src/commands/destroy.ts | 4 ++-- cli/src/commands/stop.ts | 4 ++-- packages/cli-core/src/utils/log-target-instance.ts | 5 +++++ 10 files changed, 34 insertions(+), 25 deletions(-) diff --git a/cli/README.md b/cli/README.md index 12da99dc..17de9234 100644 --- a/cli/README.md +++ b/cli/README.md @@ -459,7 +459,7 @@ DESCRIPTION Validates connections and sync config before deploying. See also powersync deploy sync-config to deploy only sync config changes. See also powersync deploy service-config to deploy only service config changes. - Use --dry-run to show the target instance and the validation results without deploying. + Use --dry-run to show the target instance, the validation results and what would change, without deploying. EXAMPLES $ powersync deploy @@ -501,8 +501,8 @@ CLOUD_PROJECT FLAGS DESCRIPTION [Cloud only] Deploy only local service config to the linked Cloud instance. - Deploy only service config changes (without sync config updates). Use --dry-run to show the target instance and the - validation results without deploying. + Deploy only service config changes (without sync config updates). Use --dry-run to show the target instance, the + validation results and what would change, without deploying. EXAMPLES $ powersync deploy service-config @@ -544,8 +544,8 @@ CLOUD_PROJECT FLAGS DESCRIPTION [Cloud only] Deploy only local sync config to the linked Cloud instance. - Deploy only sync config changes. Use --dry-run to show the target instance and the validation results without - deploying. + Deploy only sync config changes. Use --dry-run to show the target instance, the validation results and what would + change, without deploying. EXAMPLES $ powersync deploy sync-config diff --git a/cli/src/api/BaseDeployCommand.ts b/cli/src/api/BaseDeployCommand.ts index 70c23a70..65a46fa2 100644 --- a/cli/src/api/BaseDeployCommand.ts +++ b/cli/src/api/BaseDeployCommand.ts @@ -122,14 +122,24 @@ export default abstract class BaseDeployCommand extends CloudInstanceCommand { }); } - /** Ends a --dry-run once the target and validation results are shown: reports what a real run would deploy. */ + /** + * Ends a --dry-run once the target and validation results are shown: reports what a real run would deploy. + * Set provisionFirst when the instance is deprovisioned, since a real run would provision it before deploying. + */ protected logDryRun(params: { cloudConfigState: routes.InstanceConfigResponse; + provisionFirst?: boolean; serviceConfig: boolean; syncConfig: DryRunSyncConfig; }): void { - const { cloudConfigState, serviceConfig, syncConfig } = params; + const { cloudConfigState, provisionFirst = false, serviceConfig, syncConfig } = params; this.log(''); + if (provisionFirst) { + this.log( + `The instance is ${ux.colorize('yellow', 'not currently provisioned')}. Deploying would first provision it, then validate and deploy the sync config.` + ); + } + this.log(ux.colorize('yellow', 'Dry run: nothing was deployed.')); this.log( `\tService config: ${serviceConfig ? this.describeServiceConfigChanges(cloudConfigState) : 'not changed by this command.'}` diff --git a/cli/src/api/dry-run.ts b/cli/src/api/dry-run.ts index 2170affa..c3ab23ac 100644 --- a/cli/src/api/dry-run.ts +++ b/cli/src/api/dry-run.ts @@ -38,7 +38,7 @@ export function changedServiceConfigSections( /** Unified diff of the deployed sync config against the local one, one colorized entry per line. Empty when identical. */ export function formatSyncConfigDiff(deployed: string, local: string): string[] { - const { hunks } = structuredPatch('deployed', 'local', deployed, local, undefined, undefined, { context: 3 }); + const { hunks } = structuredPatch('deployed', 'local', deployed, local); return hunks.flatMap((hunk) => [ ux.colorize('cyan', `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`), ...hunk.lines.map((line) => colorizeDiffLine(line)) diff --git a/cli/src/commands/compact.ts b/cli/src/commands/compact.ts index c5eb24d7..9ba1855e 100644 --- a/cli/src/commands/compact.ts +++ b/cli/src/commands/compact.ts @@ -1,5 +1,5 @@ import { Flags, ux } from '@oclif/core'; -import { CloudInstanceCommand } from '@powersync/cli-core'; +import { CloudInstanceCommand, formatInstanceLabel } from '@powersync/cli-core'; import ora from 'ora'; import { waitForOperationStatusChange } from '../api/cloud/wait-for-operation.js'; @@ -26,7 +26,7 @@ export default class Compact extends CloudInstanceCommand { const { flags } = await this.parse(Compact); const { linked } = await this.loadProject(flags); const instanceName = await this.logTargetInstance(); - const instanceLabel = instanceName == null ? linked.instance_id : `${instanceName} (${linked.instance_id})`; + const instanceLabel = formatInstanceLabel(linked.instance_id, instanceName); const { client } = this; const timeoutMs = flags.timeout === 0 ? Number.POSITIVE_INFINITY : flags.timeout * 60 * 1000; diff --git a/cli/src/commands/deploy/index.ts b/cli/src/commands/deploy/index.ts index c4f3fc42..a9f97a72 100644 --- a/cli/src/commands/deploy/index.ts +++ b/cli/src/commands/deploy/index.ts @@ -15,7 +15,7 @@ export default class DeployAll extends WithSyncConfigFilePath(BaseDeployCommand) 'Validates connections and sync config before deploying.', `See also ${ux.colorize('blue', 'powersync deploy sync-config')} to deploy only sync config changes.`, `See also ${ux.colorize('blue', 'powersync deploy service-config')} to deploy only service config changes.`, - 'Use --dry-run to show the target instance and the validation results without deploying.' + 'Use --dry-run to show the target instance, the validation results and what would change, without deploying.' ].join('\n'); static examples = [ '<%= config.bin %> <%= command.id %>', @@ -104,10 +104,7 @@ export default class DeployAll extends WithSyncConfigFilePath(BaseDeployCommand) } if (dryRun) { - this.log( - `The instance is ${ux.colorize('yellow', 'not currently provisioned')}. Deploying would first provision it, then validate and deploy the sync config.` - ); - this.logDryRun(dryRunSummary); + this.logDryRun({ ...dryRunSummary, provisionFirst: true }); return; } diff --git a/cli/src/commands/deploy/service-config.ts b/cli/src/commands/deploy/service-config.ts index 3ad6ee23..08f95cb6 100644 --- a/cli/src/commands/deploy/service-config.ts +++ b/cli/src/commands/deploy/service-config.ts @@ -12,7 +12,7 @@ const SERVICE_CONFIG_VALIDATION_FLAGS = generateValidationTestFlags({ export default class DeployServiceConfig extends BaseDeployCommand { static description = - 'Deploy only service config changes (without sync config updates). Use --dry-run to show the target instance and the validation results without deploying.'; + 'Deploy only service config changes (without sync config updates). Use --dry-run to show the target instance, the validation results and what would change, without deploying.'; static examples = [ '<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --dry-run', diff --git a/cli/src/commands/deploy/sync-config.ts b/cli/src/commands/deploy/sync-config.ts index 652e3898..d215cf69 100644 --- a/cli/src/commands/deploy/sync-config.ts +++ b/cli/src/commands/deploy/sync-config.ts @@ -18,7 +18,7 @@ const SYNC_CONFIG_VALIDATION_FLAGS = generateValidationTestFlags({ export default class DeploySyncConfig extends WithSyncConfigFilePath(BaseDeployCommand) { static description = - 'Deploy only sync config changes. Use --dry-run to show the target instance and the validation results without deploying.'; + 'Deploy only sync config changes. Use --dry-run to show the target instance, the validation results and what would change, without deploying.'; static examples = [ '<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --dry-run', @@ -128,10 +128,7 @@ export default class DeploySyncConfig extends WithSyncConfigFilePath(BaseDeployC if (!instanceStatus.provisioned) { if (dryRun) { - this.log( - `\nThe instance is ${ux.colorize('yellow', 'not currently provisioned')}. Deploying would first provision it, then validate and deploy the sync config.` - ); - this.logDryRun(dryRunSummary); + this.logDryRun({ ...dryRunSummary, provisionFirst: true }); return; } diff --git a/cli/src/commands/destroy.ts b/cli/src/commands/destroy.ts index b181b141..11ff6c5f 100644 --- a/cli/src/commands/destroy.ts +++ b/cli/src/commands/destroy.ts @@ -1,5 +1,5 @@ import { Flags, ux } from '@oclif/core'; -import { CloudInstanceCommand } from '@powersync/cli-core'; +import { CloudInstanceCommand, formatInstanceLabel } from '@powersync/cli-core'; import ora from 'ora'; import { waitForOperationStatusChange } from '../api/cloud/wait-for-operation.js'; @@ -24,7 +24,7 @@ export default class Destroy extends CloudInstanceCommand { const { linked } = await this.loadProject(flags); const instanceName = await this.logTargetInstance(); - const instanceLabel = instanceName == null ? linked.instance_id : `${instanceName} (${linked.instance_id})`; + const instanceLabel = formatInstanceLabel(linked.instance_id, instanceName); const { client } = this; const spinner = ora({ diff --git a/cli/src/commands/stop.ts b/cli/src/commands/stop.ts index 85936b85..b9c0e4b1 100644 --- a/cli/src/commands/stop.ts +++ b/cli/src/commands/stop.ts @@ -1,5 +1,5 @@ import { Flags, ux } from '@oclif/core'; -import { CloudInstanceCommand } from '@powersync/cli-core'; +import { CloudInstanceCommand, formatInstanceLabel } from '@powersync/cli-core'; import ora from 'ora'; import { waitForOperationStatusChange } from '../api/cloud/wait-for-operation.js'; @@ -25,7 +25,7 @@ export default class Stop extends CloudInstanceCommand { const { linked } = await this.loadProject(flags); const instanceName = await this.logTargetInstance(); - const instanceLabel = instanceName == null ? linked.instance_id : `${instanceName} (${linked.instance_id})`; + const instanceLabel = formatInstanceLabel(linked.instance_id, instanceName); const { client } = this; diff --git a/packages/cli-core/src/utils/log-target-instance.ts b/packages/cli-core/src/utils/log-target-instance.ts index 8c626038..d0f2c734 100644 --- a/packages/cli-core/src/utils/log-target-instance.ts +++ b/packages/cli-core/src/utils/log-target-instance.ts @@ -4,6 +4,11 @@ import { PowerSyncManagementClient } from '@powersync/management-client'; import type { CloudProject } from '../command-types/CloudInstanceCommand.js'; import type { SelfHostedProject } from '../command-types/SelfHostedInstanceCommand.js'; +/** Labels an instance as "name (id)" when the name is known, otherwise as the id alone. */ +export function formatInstanceLabel(instanceId: string, instanceName?: string): string { + return instanceName == null ? instanceId : `${instanceName} (${instanceId})`; +} + export type LogTargetInstanceParams = { client: PowerSyncManagementClient; /** Skips the lookup when the Cloud instance name is already known, for example from getInstanceConfig. */ From e54b0cd25297c3f9368e89a3cd30a5de011f9748 Mon Sep 17 00:00:00 2001 From: bean1352 Date: Thu, 3 Sep 2026 10:30:33 +0200 Subject: [PATCH 6/6] simplify target banner and dry-run summary --- cli/src/api/BaseDeployCommand.ts | 25 +++++++++-------- cli/src/commands/compact.ts | 5 ++-- cli/src/commands/deploy/index.ts | 6 +--- cli/src/commands/deploy/service-config.ts | 2 +- cli/src/commands/deploy/sync-config.ts | 6 +--- cli/src/commands/destroy.ts | 5 ++-- cli/src/commands/stop.ts | 5 ++-- .../src/command-types/CloudInstanceCommand.ts | 8 ++---- .../command-types/SharedInstanceCommand.ts | 4 +-- .../cli-core/src/utils/log-target-instance.ts | 28 +++++++++---------- 10 files changed, 40 insertions(+), 54 deletions(-) diff --git a/cli/src/api/BaseDeployCommand.ts b/cli/src/api/BaseDeployCommand.ts index 65a46fa2..e35379f0 100644 --- a/cli/src/api/BaseDeployCommand.ts +++ b/cli/src/api/BaseDeployCommand.ts @@ -12,14 +12,6 @@ import { DEFAULT_DEPLOY_TIMEOUT_MS, waitForOperationStatusChange } from './cloud import { changedServiceConfigSections, formatSyncConfigDiff } from './dry-run.js'; import { parseLocalCloudServiceConfig } from './parse-local-cloud-service-config.js'; -const DRY_RUN_SYNC_CONFIG_LABELS = { - changed: 'would deploy the local sync config. Diff against the deployed sync config:', - skipped: 'not changed by this command.', - unchanged: 'matches the deployed sync config, nothing to update.' -}; - -export type DryRunSyncConfig = keyof typeof DRY_RUN_SYNC_CONFIG_LABELS; - export default abstract class BaseDeployCommand extends CloudInstanceCommand { static baseFlags = { 'deploy-timeout': Flags.integer({ @@ -129,10 +121,14 @@ export default abstract class BaseDeployCommand extends CloudInstanceCommand { protected logDryRun(params: { cloudConfigState: routes.InstanceConfigResponse; provisionFirst?: boolean; + /** Whether the command sends service.yaml. */ serviceConfig: boolean; - syncConfig: DryRunSyncConfig; + /** Whether the command sends the local sync config. */ + syncConfig: boolean; }): void { const { cloudConfigState, provisionFirst = false, serviceConfig, syncConfig } = params; + const { syncRulesContent } = this.project; + this.log(''); if (provisionFirst) { this.log( @@ -144,9 +140,14 @@ export default abstract class BaseDeployCommand extends CloudInstanceCommand { this.log( `\tService config: ${serviceConfig ? this.describeServiceConfigChanges(cloudConfigState) : 'not changed by this command.'}` ); - this.log(`\tSync config: ${DRY_RUN_SYNC_CONFIG_LABELS[syncConfig]}`); - if (syncConfig === 'changed') { - for (const line of formatSyncConfigDiff(cloudConfigState.sync_rules ?? '', this.project.syncRulesContent ?? '')) { + + if (!syncConfig) { + this.log('\tSync config: not changed by this command.'); + } else if (syncRulesContent === cloudConfigState.sync_rules) { + this.log('\tSync config: matches the deployed sync config, nothing to update.'); + } else { + this.log('\tSync config: would deploy the local sync config. Diff against the deployed sync config:'); + for (const line of formatSyncConfigDiff(cloudConfigState.sync_rules ?? '', syncRulesContent ?? '')) { this.log(`\t\t${line}`); } } diff --git a/cli/src/commands/compact.ts b/cli/src/commands/compact.ts index 9ba1855e..3f5a479c 100644 --- a/cli/src/commands/compact.ts +++ b/cli/src/commands/compact.ts @@ -1,5 +1,5 @@ import { Flags, ux } from '@oclif/core'; -import { CloudInstanceCommand, formatInstanceLabel } from '@powersync/cli-core'; +import { CloudInstanceCommand } from '@powersync/cli-core'; import ora from 'ora'; import { waitForOperationStatusChange } from '../api/cloud/wait-for-operation.js'; @@ -25,8 +25,7 @@ export default class Compact extends CloudInstanceCommand { async run(): Promise { const { flags } = await this.parse(Compact); const { linked } = await this.loadProject(flags); - const instanceName = await this.logTargetInstance(); - const instanceLabel = formatInstanceLabel(linked.instance_id, instanceName); + const instanceLabel = await this.logTargetInstance(); const { client } = this; const timeoutMs = flags.timeout === 0 ? Number.POSITIVE_INFINITY : flags.timeout * 60 * 1000; diff --git a/cli/src/commands/deploy/index.ts b/cli/src/commands/deploy/index.ts index a9f97a72..d7d4bd54 100644 --- a/cli/src/commands/deploy/index.ts +++ b/cli/src/commands/deploy/index.ts @@ -68,11 +68,7 @@ export default class DeployAll extends WithSyncConfigFilePath(BaseDeployCommand) const requiresReprovision = instanceStatus.provisioned === false; const syncConfigHasChanges = project.syncRulesContent !== cloudConfigState.sync_rules; - const dryRunSummary = { - cloudConfigState, - serviceConfig: true, - syncConfig: syncConfigHasChanges ? 'changed' : 'unchanged' - } as const; + const dryRunSummary = { cloudConfigState, serviceConfig: true, syncConfig: true }; let didReprovision = false; diff --git a/cli/src/commands/deploy/service-config.ts b/cli/src/commands/deploy/service-config.ts index 08f95cb6..e44d114f 100644 --- a/cli/src/commands/deploy/service-config.ts +++ b/cli/src/commands/deploy/service-config.ts @@ -63,7 +63,7 @@ export default class DeployServiceConfig extends BaseDeployCommand { } if (flags['dry-run']) { - this.logDryRun({ cloudConfigState, serviceConfig: true, syncConfig: 'skipped' }); + this.logDryRun({ cloudConfigState, serviceConfig: true, syncConfig: false }); return; } diff --git a/cli/src/commands/deploy/sync-config.ts b/cli/src/commands/deploy/sync-config.ts index d215cf69..4e2b0527 100644 --- a/cli/src/commands/deploy/sync-config.ts +++ b/cli/src/commands/deploy/sync-config.ts @@ -120,11 +120,7 @@ export default class DeploySyncConfig extends WithSyncConfigFilePath(BaseDeployC }); }); - const dryRunSummary = { - cloudConfigState, - serviceConfig: false, - syncConfig: project.syncRulesContent === cloudConfigState.sync_rules ? 'unchanged' : 'changed' - } as const; + const dryRunSummary = { cloudConfigState, serviceConfig: false, syncConfig: true }; if (!instanceStatus.provisioned) { if (dryRun) { diff --git a/cli/src/commands/destroy.ts b/cli/src/commands/destroy.ts index 11ff6c5f..29510c66 100644 --- a/cli/src/commands/destroy.ts +++ b/cli/src/commands/destroy.ts @@ -1,5 +1,5 @@ import { Flags, ux } from '@oclif/core'; -import { CloudInstanceCommand, formatInstanceLabel } from '@powersync/cli-core'; +import { CloudInstanceCommand } from '@powersync/cli-core'; import ora from 'ora'; import { waitForOperationStatusChange } from '../api/cloud/wait-for-operation.js'; @@ -23,8 +23,7 @@ export default class Destroy extends CloudInstanceCommand { } const { linked } = await this.loadProject(flags); - const instanceName = await this.logTargetInstance(); - const instanceLabel = formatInstanceLabel(linked.instance_id, instanceName); + const instanceLabel = await this.logTargetInstance(); const { client } = this; const spinner = ora({ diff --git a/cli/src/commands/stop.ts b/cli/src/commands/stop.ts index b9c0e4b1..b87a1e6a 100644 --- a/cli/src/commands/stop.ts +++ b/cli/src/commands/stop.ts @@ -1,5 +1,5 @@ import { Flags, ux } from '@oclif/core'; -import { CloudInstanceCommand, formatInstanceLabel } from '@powersync/cli-core'; +import { CloudInstanceCommand } from '@powersync/cli-core'; import ora from 'ora'; import { waitForOperationStatusChange } from '../api/cloud/wait-for-operation.js'; @@ -24,8 +24,7 @@ export default class Stop extends CloudInstanceCommand { } const { linked } = await this.loadProject(flags); - const instanceName = await this.logTargetInstance(); - const instanceLabel = formatInstanceLabel(linked.instance_id, instanceName); + const instanceLabel = await this.logTargetInstance(); const { client } = this; diff --git a/packages/cli-core/src/command-types/CloudInstanceCommand.ts b/packages/cli-core/src/command-types/CloudInstanceCommand.ts index 207e1bc7..8d187864 100644 --- a/packages/cli-core/src/command-types/CloudInstanceCommand.ts +++ b/packages/cli-core/src/command-types/CloudInstanceCommand.ts @@ -212,15 +212,13 @@ export abstract class CloudInstanceCommand extends InstanceCommand { /** * Prints which Cloud instance the command is about to act on. Call this after loadProject(). - * See {@link logTargetInstance} for details. - * - * @returns The instance name, or undefined if it could not be resolved. + * See {@link logTargetInstance} for details and the returned label. */ - async logTargetInstance(options: { instanceName?: string } = {}): Promise { + async logTargetInstance(options: { instanceName?: string } = {}): Promise { return logTargetInstance({ client: this.client, + command: this, instanceName: options.instanceName, - log: (message) => this.log(message), project: this.project }); } diff --git a/packages/cli-core/src/command-types/SharedInstanceCommand.ts b/packages/cli-core/src/command-types/SharedInstanceCommand.ts index d582fe12..afc01229 100644 --- a/packages/cli-core/src/command-types/SharedInstanceCommand.ts +++ b/packages/cli-core/src/command-types/SharedInstanceCommand.ts @@ -249,8 +249,8 @@ export abstract class SharedInstanceCommand extends InstanceCommand { } /** Prints which instance the command is about to act on. See {@link logTargetInstance}. */ - async logTargetInstance(project: CloudProject | SelfHostedProject): Promise { - return logTargetInstance({ client: this.cloudClient, log: (message) => this.log(message), project }); + async logTargetInstance(project: CloudProject | SelfHostedProject): Promise { + return logTargetInstance({ client: this.cloudClient, command: this, project }); } parseCloudConfig(projectDirectory: string): ServiceCloudConfigDecoded { diff --git a/packages/cli-core/src/utils/log-target-instance.ts b/packages/cli-core/src/utils/log-target-instance.ts index d0f2c734..053a5928 100644 --- a/packages/cli-core/src/utils/log-target-instance.ts +++ b/packages/cli-core/src/utils/log-target-instance.ts @@ -1,19 +1,14 @@ -import { ux } from '@oclif/core'; +import { Command, ux } from '@oclif/core'; import { PowerSyncManagementClient } from '@powersync/management-client'; import type { CloudProject } from '../command-types/CloudInstanceCommand.js'; import type { SelfHostedProject } from '../command-types/SelfHostedInstanceCommand.js'; -/** Labels an instance as "name (id)" when the name is known, otherwise as the id alone. */ -export function formatInstanceLabel(instanceId: string, instanceName?: string): string { - return instanceName == null ? instanceId : `${instanceName} (${instanceId})`; -} - export type LogTargetInstanceParams = { client: PowerSyncManagementClient; + command: Command; /** Skips the lookup when the Cloud instance name is already known, for example from getInstanceConfig. */ instanceName?: string; - log: (message: string) => void; project: CloudProject | SelfHostedProject; }; @@ -24,15 +19,16 @@ export type LogTargetInstanceParams = { * are still printed and the command continues; a later API call surfaces the real error. Self-hosted projects * have no instance name, so the API URL is printed instead. * - * @returns The Cloud instance name, or undefined if it is unavailable or the project is self-hosted. + * @returns A short label for later messages: "name (id)", the id alone when the name is unavailable, or the + * API URL for self-hosted projects. */ -export async function logTargetInstance(params: LogTargetInstanceParams): Promise { - const { client, log, project } = params; +export async function logTargetInstance(params: LogTargetInstanceParams): Promise { + const { client, command, project } = params; const { linked } = project; if (linked.type === 'self-hosted') { - log(`Target instance: ${ux.colorize('blue', linked.api_url)} ${ux.colorize('gray', '(self-hosted)')}`); - return; + command.log(`Target instance: ${ux.colorize('blue', linked.api_url)} ${ux.colorize('gray', '(self-hosted)')}`); + return linked.api_url; } let { instanceName } = params; @@ -46,8 +42,10 @@ export async function logTargetInstance(params: LogTargetInstanceParams): Promis const nameLabel = instanceName == null ? ux.colorize('yellow', '(name unavailable)') : ux.colorize('blue', instanceName); - log(`Target instance: ${nameLabel} ${ux.colorize('gray', `id: ${linked.instance_id}`)}`); - log(`\t${ux.colorize('gray', `project: ${linked.project_id}`)} ${ux.colorize('gray', `org: ${linked.org_id}`)}`); + command.log(`Target instance: ${nameLabel} ${ux.colorize('gray', `id: ${linked.instance_id}`)}`); + command.log( + `\t${ux.colorize('gray', `project: ${linked.project_id}`)} ${ux.colorize('gray', `org: ${linked.org_id}`)}` + ); - return instanceName; + return instanceName == null ? linked.instance_id : `${instanceName} (${linked.instance_id})`; }