diff --git a/.changeset/show-target-instance.md b/.changeset/show-target-instance.md new file mode 100644 index 00000000..49811a09 --- /dev/null +++ b/.changeset/show-target-instance.md @@ -0,0 +1,8 @@ +--- +'@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. `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 aaa85adc..17de9234 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, the validation results and what would change, 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, the + validation results and what would change, 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, the validation results and what would + change, without deploying. EXAMPLES $ powersync deploy sync-config + $ powersync deploy sync-config --dry-run + $ powersync deploy sync-config --instance-id= ``` @@ -842,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 @@ -1509,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/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 a3b6c021..e35379f0 100644 --- a/cli/src/api/BaseDeployCommand.ts +++ b/cli/src/api/BaseDeployCommand.ts @@ -9,6 +9,7 @@ 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'; export default abstract class BaseDeployCommand extends CloudInstanceCommand { @@ -26,6 +27,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 +81,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 +114,45 @@ 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. + * Set provisionFirst when the instance is deprovisioned, since a real run would provision it before deploying. + */ + protected logDryRun(params: { + cloudConfigState: routes.InstanceConfigResponse; + provisionFirst?: boolean; + /** Whether the command sends service.yaml. */ + serviceConfig: boolean; + /** 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( + `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.'}` + ); + + 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}`); + } + } + } + override parseLocalConfig(projectDirectory: string, useRawConfig?: boolean): ServiceCloudConfigDecoded { const config = parseLocalCloudServiceConfig(projectDirectory, useRawConfig ?? false); if (!config) { @@ -272,6 +333,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/api/dry-run.ts b/cli/src/api/dry-run.ts new file mode 100644 index 00000000..c3ab23ac --- /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); + 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 2540d9bb..3f5a479c 100644 --- a/cli/src/commands/compact.ts +++ b/cli/src/commands/compact.ts @@ -25,12 +25,13 @@ export default class Compact extends CloudInstanceCommand { async run(): Promise { const { flags } = await this.parse(Compact); const { linked } = await this.loadProject(flags); + const instanceLabel = await this.logTargetInstance(); 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..d7d4bd54 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, the validation results and what would change, 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,16 +35,19 @@ 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); 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...'); @@ -60,6 +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: true }; let didReprovision = false; @@ -90,6 +99,11 @@ export default class DeployAll extends WithSyncConfigFilePath(BaseDeployCommand) }); } + if (dryRun) { + this.logDryRun({ ...dryRunSummary, provisionFirst: true }); + 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. @@ -139,6 +153,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 e095206a..e44d114f 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, the validation results and what would change, 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 }; @@ -35,6 +40,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({ @@ -55,6 +62,11 @@ export default class DeployServiceConfig extends BaseDeployCommand { }); } + if (flags['dry-run']) { + this.logDryRun({ cloudConfigState, serviceConfig: true, syncConfig: false }); + 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 06078d9d..4e2b0527 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, the validation results and what would change, 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({ @@ -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({ @@ -119,7 +120,14 @@ export default class DeploySyncConfig extends WithSyncConfigFilePath(BaseDeployC }); }); + const dryRunSummary = { cloudConfigState, serviceConfig: false, syncConfig: true }; + if (!instanceStatus.provisioned) { + if (dryRun) { + this.logDryRun({ ...dryRunSummary, provisionFirst: true }); + return; + } + this.log( `\nThe instance is not currently provisioned. Triggering a deploy in order to reprovision. This may take a few minutes.\n` ); @@ -148,6 +156,11 @@ export default class DeploySyncConfig extends WithSyncConfigFilePath(BaseDeployC }); } + if (dryRun) { + this.logDryRun(dryRunSummary); + return; + } + await this.deploySyncConfig({ cloudConfigState, timeout: deployTimeoutMs }); } } diff --git a/cli/src/commands/destroy.ts b/cli/src/commands/destroy.ts index f7759ca0..29510c66 100644 --- a/cli/src/commands/destroy.ts +++ b/cli/src/commands/destroy.ts @@ -23,11 +23,12 @@ export default class Destroy extends CloudInstanceCommand { } const { linked } = await this.loadProject(flags); + const instanceLabel = await this.logTargetInstance(); 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/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/src/commands/stop.ts b/cli/src/commands/stop.ts index 3e2644c3..b87a1e6a 100644 --- a/cli/src/commands/stop.ts +++ b/cli/src/commands/stop.ts @@ -24,12 +24,13 @@ export default class Stop extends CloudInstanceCommand { } const { linked } = await this.loadProject(flags); + const instanceLabel = await this.logTargetInstance(); 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/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/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..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: '' @@ -144,6 +147,83 @@ 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 (› 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"'); + }); + + 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/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/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/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..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(), @@ -57,6 +58,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/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/packages/cli-core/src/command-types/CloudInstanceCommand.ts b/packages/cli-core/src/command-types/CloudInstanceCommand.ts index 734ae71b..8d187864 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'; @@ -209,6 +210,19 @@ export abstract class CloudInstanceCommand extends InstanceCommand { return this._project; } + /** + * Prints which Cloud instance the command is about to act on. Call this after loadProject(). + * See {@link logTargetInstance} for details and the returned label. + */ + async logTargetInstance(options: { instanceName?: string } = {}): Promise { + return logTargetInstance({ + client: this.client, + command: this, + instanceName: options.instanceName, + project: this.project + }); + } + parseLocalConfig(projectDirectory: string): ServiceCloudConfigDecoded { const servicePath = join(projectDirectory, SERVICE_FILENAME); const doc = parseYamlFile(servicePath); diff --git a/packages/cli-core/src/command-types/SharedInstanceCommand.ts b/packages/cli-core/src/command-types/SharedInstanceCommand.ts index bb384a9f..afc01229 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, command: this, 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..053a5928 --- /dev/null +++ b/packages/cli-core/src/utils/log-target-instance.ts @@ -0,0 +1,51 @@ +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'; + +export type LogTargetInstanceParams = { + client: PowerSyncManagementClient; + command: Command; + /** Skips the lookup when the Cloud instance name is already known, for example from getInstanceConfig. */ + instanceName?: string; + 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 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, command, project } = params; + const { linked } = project; + + if (linked.type === 'self-hosted') { + command.log(`Target instance: ${ux.colorize('blue', linked.api_url)} ${ux.colorize('gray', '(self-hosted)')}`); + return linked.api_url; + } + + 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); + 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 == null ? linked.instance_id : `${instanceName} (${linked.instance_id})`; +} 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