diff --git a/.changeset/show-target-instance.md b/.changeset/show-target-instance.md new file mode 100644 index 0000000..49811a0 --- /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 aaa85ad..17de923 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 5af4cde..9037cc6 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 a3b6c02..e35379f 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 0000000..c3ab23a --- /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 2540d9b..3f5a479 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 95feb9e..d7d4bd5 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 e095206..e44d114 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 06078d9..4e2b052 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 f7759ca..29510c6 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 573702a..20dcca8 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 3e2644c..b87a1e6 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 0000000..fb2ab56 --- /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 845dd36..77201f9 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 a03c6a6..53a9c5b 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 aaa0baf..f4a9976 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 63a8521..c550ff8 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 fc6d207..f6b495e 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 0000000..7dba0f5 --- /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 5c80122..5cc8362 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 ef5e301..a8fd601 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 e4ac51f..a4e2da3 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 734ae71..8d18786 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 bb384a9..afc0122 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 0a9034d..ffd991b 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 0000000..053a592 --- /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 14edd97..a9e6d60 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