diff --git a/.changeset/nice-pears-find.md b/.changeset/nice-pears-find.md new file mode 100644 index 0000000..73cf27b --- /dev/null +++ b/.changeset/nice-pears-find.md @@ -0,0 +1,5 @@ +--- +'powersync': patch +--- + +Added `--sync-config-file-path` so local sync YAML can be read from a path other than `sync-config.yaml` for **`powersync deploy`**, **`powersync deploy sync-config`**, **`powersync validate`**, and **`powersync generate schema`**. diff --git a/cli/src/api/BaseDeployCommand.ts b/cli/src/api/BaseDeployCommand.ts index cc522f4..a3b6c02 100644 --- a/cli/src/api/BaseDeployCommand.ts +++ b/cli/src/api/BaseDeployCommand.ts @@ -12,7 +12,7 @@ import { DEFAULT_DEPLOY_TIMEOUT_MS, waitForOperationStatusChange } from './cloud import { parseLocalCloudServiceConfig } from './parse-local-cloud-service-config.js'; export default abstract class BaseDeployCommand extends CloudInstanceCommand { - static flags = { + static baseFlags = { 'deploy-timeout': Flags.integer({ default: DEFAULT_DEPLOY_TIMEOUT_MS / 1000, description: @@ -26,7 +26,7 @@ export default abstract class BaseDeployCommand extends CloudInstanceCommand { return value; } }), - ...CloudInstanceCommand.flags + ...CloudInstanceCommand.baseFlags }; protected async deployAll(params: { diff --git a/cli/src/api/cloud/fetch-cloud-sync-rules-content.ts b/cli/src/api/cloud/fetch-cloud-sync-rules-content.ts index 76382f7..e574a0c 100644 --- a/cli/src/api/cloud/fetch-cloud-sync-rules-content.ts +++ b/cli/src/api/cloud/fetch-cloud-sync-rules-content.ts @@ -1,6 +1,4 @@ -import { CloudProject, createCloudClient, SYNC_FILENAME } from '@powersync/cli-core'; -import { existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { CloudProject, createCloudClient } from '@powersync/cli-core'; /** * Fetches the sync config content for a cloud project. @@ -8,14 +6,14 @@ import { join } from 'node:path'; * @returns The sync config content. */ export async function fetchCloudSyncRulesContent(project: CloudProject): Promise { - const { linked } = project; - const client = await createCloudClient(); - // First try and use the local file - if (existsSync(join(project.projectDirectory, SYNC_FILENAME))) { - return readFileSync(join(project.projectDirectory, SYNC_FILENAME), 'utf8'); + if (project.syncRulesContent) { + return project.syncRulesContent; } + const { linked } = project; + const client = createCloudClient(); + // Try and fetch from the cloud config const instanceConfig = await client.getInstanceConfig({ app_id: linked.project_id, diff --git a/cli/src/api/self-hosted/fetch-self-hosted-sync-rules-content.ts b/cli/src/api/self-hosted/fetch-self-hosted-sync-rules-content.ts index 39be1a6..c4b364b 100644 --- a/cli/src/api/self-hosted/fetch-self-hosted-sync-rules-content.ts +++ b/cli/src/api/self-hosted/fetch-self-hosted-sync-rules-content.ts @@ -1,6 +1,4 @@ -import { createSelfHostedClient, SelfHostedProject, SYNC_FILENAME } from '@powersync/cli-core'; -import { existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { createSelfHostedClient, SelfHostedProject } from '@powersync/cli-core'; /** * Fetches the sync config content for a self-hosted project. @@ -8,18 +6,18 @@ import { join } from 'node:path'; * @returns The sync config content. */ export async function fetchSelfHostedSyncRulesContent(project: SelfHostedProject): Promise { + // First try and use the local file + if (project.syncRulesContent) { + return project.syncRulesContent; + } + const { linked } = project; const client = createSelfHostedClient({ apiKey: linked.api_key, apiUrl: linked.api_url }); - // First try and use the local file - if (existsSync(join(project.projectDirectory, SYNC_FILENAME))) { - return readFileSync(join(project.projectDirectory, SYNC_FILENAME), 'utf8'); - } - - // Try and fetch from the cloud config + // Try and fetch from the self-hosted config const instanceConfig = await client.diagnostics({}); const content = instanceConfig.active_sync_rules?.content; diff --git a/cli/src/api/validations/validations.ts b/cli/src/api/validations/validations.ts index c3c64d0..f30f0ee 100644 --- a/cli/src/api/validations/validations.ts +++ b/cli/src/api/validations/validations.ts @@ -97,16 +97,14 @@ export async function runSyncConfigTestCloud(project: CloudProject): Promise { - const syncRulesPath = join(project.projectDirectory, SYNC_FILENAME); - const syncRulesContent = existsSync(syncRulesPath) ? readFileSync(syncRulesPath, 'utf8') : undefined; - const syncText = syncRulesContent ?? ''; + const syncRulesContent = project.syncRulesContent ?? ''; try { return wrapsSyncValidation({ result: await validateSelfHostedSyncRules({ linked: project.linked, - syncRulesContent: syncText + syncRulesContent }), - syncText + syncText: syncRulesContent }); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/cli/src/commands/deploy/index.ts b/cli/src/commands/deploy/index.ts index c94fdaa..e53899e 100644 --- a/cli/src/commands/deploy/index.ts +++ b/cli/src/commands/deploy/index.ts @@ -1,4 +1,5 @@ import { ux } from '@oclif/core'; +import { WithSyncConfigFilePath } from '@powersync/cli-core'; import BaseDeployCommand from '../../api/BaseDeployCommand.js'; import { DEFAULT_DEPLOY_TIMEOUT_MS } from '../../api/cloud/wait-for-operation.js'; @@ -8,7 +9,7 @@ import { formatValidationHuman } from '../../api/validations/validation-utils.js import { ValidationsRunner } from '../../api/validations/ValidationsRunner.js'; import { ValidationTest } from '../../api/validations/ValidationTestDefinition.js'; -export default class DeployAll extends BaseDeployCommand { +export default class DeployAll extends WithSyncConfigFilePath(BaseDeployCommand) { static description = [ 'Deploy local config (service.yaml, sync config) to the linked PowerSync Cloud instance.', 'Validates connections and sync config before deploying.', @@ -20,7 +21,6 @@ export default class DeployAll extends BaseDeployCommand { '<%= config.bin %> <%= command.id %> --instance-id= --project-id=' ]; static flags = { - ...BaseDeployCommand.flags, ...GENERAL_VALIDATION_FLAG_HELPERS.flags }; static summary = '[Cloud only] Deploy local config to the linked Cloud instance (connections + auth + sync config).'; @@ -29,7 +29,6 @@ export default class DeployAll extends BaseDeployCommand { const { flags } = await this.parse(DeployAll); const project = await this.loadProject(flags, { - // local config state is required when deploying all configFileRequired: true }); @@ -37,7 +36,6 @@ export default class DeployAll extends BaseDeployCommand { const validationTestsFilter = GENERAL_VALIDATION_FLAG_HELPERS.parseValidationTestFlags(flags); - // The existing config is required to deploy changes. The instance should have been created already. const cloudConfigState = await this.loadCloudConfigState(); // Parse and store for later diff --git a/cli/src/commands/deploy/service-config.ts b/cli/src/commands/deploy/service-config.ts index 36ddde8..c9c7122 100644 --- a/cli/src/commands/deploy/service-config.ts +++ b/cli/src/commands/deploy/service-config.ts @@ -17,8 +17,7 @@ export default class DeployServiceConfig extends BaseDeployCommand { '<%= config.bin %> <%= command.id %> --instance-id= --project-id=' ]; static flags = { - ...SERVICE_CONFIG_VALIDATION_FLAGS.flags, - ...BaseDeployCommand.flags + ...SERVICE_CONFIG_VALIDATION_FLAGS.flags }; static summary = '[Cloud only] Deploy only local service config to the linked Cloud instance.'; diff --git a/cli/src/commands/deploy/sync-config.ts b/cli/src/commands/deploy/sync-config.ts index 820ed38..74c2319 100644 --- a/cli/src/commands/deploy/sync-config.ts +++ b/cli/src/commands/deploy/sync-config.ts @@ -1,8 +1,7 @@ -import { Flags } from '@oclif/core'; import { ux } from '@oclif/core/ux'; +import { WithSyncConfigFilePath } from '@powersync/cli-core'; import { routes } from '@powersync/management-types'; import { ObjectId } from 'bson'; -import { readFileSync } from 'node:fs'; import BaseDeployCommand from '../../api/BaseDeployCommand.js'; import { DEFAULT_DEPLOY_TIMEOUT_MS } from '../../api/cloud/wait-for-operation.js'; @@ -16,20 +15,14 @@ const SYNC_CONFIG_VALIDATION_FLAGS = generateValidationTestFlags({ limitOptions: [ValidationTest['SYNC-CONFIG']] }); -export default class DeploySyncConfig extends BaseDeployCommand { +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= --project-id=' ]; static flags = { - ...BaseDeployCommand.flags, - ...SYNC_CONFIG_VALIDATION_FLAGS.flags, - 'sync-config-file-path': Flags.file({ - description: - 'Path to a sync config file. If provided, this file will be validated and deployed instead of the default sync-config.yaml.', - exists: true - }) + ...SYNC_CONFIG_VALIDATION_FLAGS.flags }; static summary = '[Cloud only] Deploy only local sync config to the linked Cloud instance.'; @@ -84,9 +77,10 @@ export default class DeploySyncConfig extends BaseDeployCommand { const { linked } = project; const deployTimeoutMs = (flags['deploy-timeout'] ?? DEFAULT_DEPLOY_TIMEOUT_MS / 1000) * 1000; - const syncConfigFilePath = flags['sync-config-file-path']; - if (syncConfigFilePath) { - project.syncRulesContent = readFileSync(syncConfigFilePath, 'utf8'); + if (!project.syncRulesContent) { + this.styledError({ + message: `Sync config content not loaded. Ensure sync config is present and valid.` + }); } if (!project.syncRulesContent) { diff --git a/cli/src/commands/destroy.ts b/cli/src/commands/destroy.ts index 007a344..f7759ca 100644 --- a/cli/src/commands/destroy.ts +++ b/cli/src/commands/destroy.ts @@ -11,8 +11,7 @@ export default class Destroy extends CloudInstanceCommand { confirm: Flags.string({ description: 'Set to "yes" to confirm destruction of the instance.', options: ['yes'] - }), - ...CloudInstanceCommand.flags + }) }; static summary = '[Cloud only] Permanently destroy the linked Cloud instance.'; diff --git a/cli/src/commands/fetch/config.ts b/cli/src/commands/fetch/config.ts index 7f0d1ca..b645f33 100644 --- a/cli/src/commands/fetch/config.ts +++ b/cli/src/commands/fetch/config.ts @@ -12,8 +12,7 @@ export default class FetchConfig extends CloudInstanceCommand { default: 'yaml', description: 'Output format: yaml or json.', options: ['json', 'yaml'] - }), - ...CloudInstanceCommand.flags + }) }; static summary = '[Cloud only] Print linked Cloud instance config (YAML or JSON).'; diff --git a/cli/src/commands/fetch/status.ts b/cli/src/commands/fetch/status.ts index 8147ff9..02b6596 100644 --- a/cli/src/commands/fetch/status.ts +++ b/cli/src/commands/fetch/status.ts @@ -17,8 +17,7 @@ export default class FetchStatus extends SharedInstanceCommand { default: 'human', description: 'Output format: human-readable, json, or yaml.', options: ['human', 'json', 'yaml'] - }), - ...SharedInstanceCommand.flags + }) }; static summary = 'Show instance diagnostics (connections, sync config, replication).'; diff --git a/cli/src/commands/generate/schema.ts b/cli/src/commands/generate/schema.ts index cd04f5b..e101b3e 100644 --- a/cli/src/commands/generate/schema.ts +++ b/cli/src/commands/generate/schema.ts @@ -4,7 +4,8 @@ import { createCloudClient, createSelfHostedClient, SelfHostedProject, - SharedInstanceCommand + SharedInstanceCommand, + WithSyncConfigFilePath } from '@powersync/cli-core'; import { routes } from '@powersync/management-types'; import { schemaGenerators, SqlSyncRules, StaticSchema } from '@powersync/service-sync-rules'; @@ -13,7 +14,7 @@ import { writeFileSync } from 'node:fs'; import { fetchCloudSyncRulesContent } from '../../api/cloud/fetch-cloud-sync-rules-content.js'; import { fetchSelfHostedSyncRulesContent } from '../../api/self-hosted/fetch-self-hosted-sync-rules-content.js'; -export default class GenerateSchema extends SharedInstanceCommand { +export default class GenerateSchema extends WithSyncConfigFilePath(SharedInstanceCommand) { static description = 'Generate a client-side schema file from the instance database schema and sync config. Supports multiple output types (e.g. type, dart). Requires a linked instance. Cloud and self-hosted.'; static examples = [ @@ -30,14 +31,13 @@ export default class GenerateSchema extends SharedInstanceCommand { 'output-path': Flags.string({ description: 'Path to output the schema file.', required: true - }), - ...SharedInstanceCommand.flags + }) }; static summary = 'Generate client schema file from instance schema and sync config.'; async getCloudSchema(project: CloudProject): Promise { const { linked } = project; - const client = await createCloudClient(); + const client = createCloudClient(); return client.getInstanceSchema({ app_id: linked.project_id, id: linked.instance_id, diff --git a/cli/src/commands/generate/token.ts b/cli/src/commands/generate/token.ts index 39bcb9b..6520b21 100644 --- a/cli/src/commands/generate/token.ts +++ b/cli/src/commands/generate/token.ts @@ -37,8 +37,7 @@ export default class GenerateToken extends SharedInstanceCommand { subject: Flags.string({ description: 'Subject of the token.', required: true - }), - ...SharedInstanceCommand.flags + }) }; static summary = 'Generate a development JWT for client connections.'; diff --git a/cli/src/commands/init/cloud.ts b/cli/src/commands/init/cloud.ts index 4ead5a7..2f1d408 100644 --- a/cli/src/commands/init/cloud.ts +++ b/cli/src/commands/init/cloud.ts @@ -27,9 +27,6 @@ export default class InitCloud extends InstanceCommand { '<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --directory=powersync' ]; - static flags = { - ...InstanceCommand.flags - }; static summary = 'Scaffold a PowerSync Cloud config directory from a template.'; async run(): Promise { diff --git a/cli/src/commands/init/self-hosted.ts b/cli/src/commands/init/self-hosted.ts index 5a33f50..3e498d5 100644 --- a/cli/src/commands/init/self-hosted.ts +++ b/cli/src/commands/init/self-hosted.ts @@ -27,9 +27,6 @@ export default class InitSelfHosted extends InstanceCommand { '<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --directory=powersync' ]; - static flags = { - ...InstanceCommand.flags - }; static summary = 'Scaffold a PowerSync self-hosted config directory from a template.'; async run(): Promise { diff --git a/cli/src/commands/link/cloud.ts b/cli/src/commands/link/cloud.ts index c9cefee..212c200 100644 --- a/cli/src/commands/link/cloud.ts +++ b/cli/src/commands/link/cloud.ts @@ -6,7 +6,6 @@ import { ensureServiceTypeMatches, env, getDefaultOrgId, - InstanceCommand, ServiceType } from '@powersync/cli-core'; @@ -44,8 +43,7 @@ export default class LinkCloud extends CloudInstanceCommand { default: env.PROJECT_ID, description: 'Project ID. Resolved: flag → PROJECT_ID → cli.yaml.', required: true - }), - ...InstanceCommand.flags + }) }; static summary = '[Cloud only] Link to a PowerSync Cloud instance (or create one with --create).'; diff --git a/cli/src/commands/link/self-hosted.ts b/cli/src/commands/link/self-hosted.ts index f8bc517..559bc9e 100644 --- a/cli/src/commands/link/self-hosted.ts +++ b/cli/src/commands/link/self-hosted.ts @@ -5,7 +5,6 @@ import { CommandHelpGroup, ensureServiceTypeMatches, env, - InstanceCommand, parseYamlFile, SelfHostedInstanceCommand, ServiceType @@ -25,8 +24,7 @@ export default class LinkSelfHosted extends SelfHostedInstanceCommand { 'api-url': Flags.string({ description: 'Self-hosted PowerSync API base URL (e.g. https://powersync.example.com).', required: true - }), - ...InstanceCommand.flags + }) }; static summary = 'Link to a self-hosted PowerSync instance by API URL.'; diff --git a/cli/src/commands/migrate/sync-rules.ts b/cli/src/commands/migrate/sync-rules.ts index eb12bf7..6cd7049 100644 --- a/cli/src/commands/migrate/sync-rules.ts +++ b/cli/src/commands/migrate/sync-rules.ts @@ -15,8 +15,7 @@ export default class MigrateSyncRules extends SharedInstanceCommand { 'output-file': Flags.string({ description: 'Path to the output sync streams file. Defaults to overwrite the input file.', required: false - }), - ...SharedInstanceCommand.flags + }) }; static summary = 'Migrates Sync Rules to Sync Streams'; diff --git a/cli/src/commands/pull/instance.ts b/cli/src/commands/pull/instance.ts index 0798fab..db7d5f4 100644 --- a/cli/src/commands/pull/instance.ts +++ b/cli/src/commands/pull/instance.ts @@ -36,7 +36,6 @@ export default class PullInstance extends CloudInstanceCommand { '<%= config.bin %> <%= command.id %> --instance-id= --project-id= --org-id=' ]; static flags = { - ...CloudInstanceCommand.flags, overwrite: Flags.boolean({ description: 'Overwrite existing service.yaml and sync-config.yaml if they exist. By default, if these files already exist, the fetched configs will be written to service-fetched.yaml and sync-fetched.yaml to avoid overwriting local changes.' diff --git a/cli/src/commands/stop.ts b/cli/src/commands/stop.ts index ddfeac8..3e2644c 100644 --- a/cli/src/commands/stop.ts +++ b/cli/src/commands/stop.ts @@ -12,8 +12,7 @@ export default class Stop extends CloudInstanceCommand { confirm: Flags.string({ description: 'Set to "yes" to confirm stopping the instance.', options: ['yes'] - }), - ...CloudInstanceCommand.flags + }) }; static summary = '[Cloud only] Stop the linked Cloud instance (restart with deploy).'; diff --git a/cli/src/commands/validate.ts b/cli/src/commands/validate.ts index 7400d1e..03fbd75 100644 --- a/cli/src/commands/validate.ts +++ b/cli/src/commands/validate.ts @@ -1,5 +1,11 @@ import { Flags } from '@oclif/core'; -import { CloudProject, SelfHostedProject, SharedInstanceCommand, ValidationResult } from '@powersync/cli-core'; +import { + CloudProject, + SelfHostedProject, + SharedInstanceCommand, + ValidationResult, + WithSyncConfigFilePath +} from '@powersync/cli-core'; import { parseLocalCloudServiceConfig } from '../api/parse-local-cloud-service-config.js'; import { getCloudValidations } from '../api/validations/cloud-validations.js'; @@ -9,7 +15,7 @@ import { formatValidationJson, formatValidationYaml } from '../api/validations/v import { ValidationsRunner } from '../api/validations/ValidationsRunner.js'; import { ValidationTest } from '../api/validations/ValidationTestDefinition.js'; -export default class Validate extends SharedInstanceCommand { +export default class Validate extends WithSyncConfigFilePath(SharedInstanceCommand) { static description = 'Run validation checks on local config: config schema, database connections, and sync config. Requires a linked instance. Works with Cloud and self-hosted.'; static examples = [ @@ -23,8 +29,7 @@ export default class Validate extends SharedInstanceCommand { description: 'Output format: human-readable, json, or yaml.', options: ['human', 'json', 'yaml'] }), - ...GENERAL_VALIDATION_FLAG_HELPERS.flags, - ...SharedInstanceCommand.flags + ...GENERAL_VALIDATION_FLAG_HELPERS.flags }; static summary = 'Validate config schema, connections, and sync config before deploy.'; diff --git a/cli/test/commands/deploy.test.ts b/cli/test/commands/deploy.test.ts index a767fc3..a03c6a6 100644 --- a/cli/test/commands/deploy.test.ts +++ b/cli/test/commands/deploy.test.ts @@ -269,5 +269,34 @@ describe('deploy', () => { new RegExp(`Failed to .* instance ${INSTANCE_ID} in project ${PROJECT_ID} in org ${ORG_ID}`) ); }); + + it('uses sync rules from --sync-config-file-path instead of sync-config.yaml', async () => { + const projectDir = join(tmpDir, PROJECT_DIR); + const fromDefaultFile = 'bucket_definitions:\n only_in_sync_config_yaml: true\n'; + const fromCustomPath = 'bucket_definitions:\n only_in_custom_path: true\n'; + writeFileSync(join(projectDir, SYNC_FILENAME), fromDefaultFile, 'utf8'); + const customPath = join(tmpDir, 'other-sync.yaml'); + writeFileSync(customPath, fromCustomPath, 'utf8'); + + managementClientMock.validateSyncRules.mockImplementation(({ sync_rules }) => { + expect(sync_rules).toBe(fromCustomPath); + expect(sync_rules).not.toContain('only_in_sync_config_yaml'); + return Promise.resolve({ errors: [] }); + }); + + const result = await runDeployDirect({ + args: ['--sync-config-file-path', customPath] + }); + + expect(managementClientMock.validateSyncRules).toHaveBeenCalled(); + expect(managementClientMock.deployInstance).toHaveBeenCalledWith( + expect.objectContaining({ + sync_rules: fromCustomPath + }) + ); + expect(result.error?.message).toMatch( + new RegExp(`Failed to .* instance ${INSTANCE_ID} in project ${PROJECT_ID} in org ${ORG_ID}`) + ); + }); }); }); diff --git a/cli/test/commands/deploy/sync-config.test.ts b/cli/test/commands/deploy/sync-config.test.ts index ffb3ab1..2ddad81 100644 --- a/cli/test/commands/deploy/sync-config.test.ts +++ b/cli/test/commands/deploy/sync-config.test.ts @@ -198,6 +198,26 @@ describe('deploy:sync-config', () => { expect(managementClientMock.validateSyncRules).toHaveBeenCalled(); }); + it('deploy sync-config sends custom file YAML to deployInstance, not default sync-config.yaml', async () => { + const projectDir = makeProjectDir(tmpDir); + writeLinkYaml(projectDir); + writeFileSync(join(projectDir, SYNC_FILENAME), 'bucket_definitions:\n only_default:\n data: []\n', 'utf8'); + const customPath = join(tmpDir, 'custom-sync.yaml'); + const customYaml = 'bucket_definitions:\n only_custom_flag:\n data:\n - SELECT 1 FROM custom_path\n'; + writeFileSync(customPath, customYaml, 'utf8'); + + managementClientMock.deployInstance.mockImplementation((req: unknown) => { + const s = JSON.stringify(req); + expect(s).toContain('SELECT 1 FROM custom_path'); + expect(s).not.toContain('only_default'); + return Promise.reject(new Error('mock deploy failure')); + }); + + const result = await runSyncConfigDirect(['--sync-config-file-path', customPath]); + expect(result.error?.message).toMatch(/mock deploy failure/); + expect(managementClientMock.deployInstance).toHaveBeenCalled(); + }); + it('errors when --sync-config-file-path points to a non-existent file', async () => { const projectDir = makeProjectDir(tmpDir); writeLinkYaml(projectDir); diff --git a/cli/test/commands/validate.test.ts b/cli/test/commands/validate.test.ts index 110e70c..220e158 100644 --- a/cli/test/commands/validate.test.ts +++ b/cli/test/commands/validate.test.ts @@ -1,139 +1,181 @@ import { Config } from '@oclif/core'; import { captureOutput } from '@oclif/test'; +import * as cliCore from '@powersync/cli-core'; import { CLI_FILENAME, env, SERVICE_FILENAME, SYNC_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 } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import Validate from '../../src/commands/validate.js'; import { root } from '../helpers/root.js'; -import { managementClientMock, MOCK_CLOUD_CONFIG, MOCK_CLOUD_IDS, resetManagementClientMocks } from '../setup.js'; - -const { instanceId: INSTANCE_ID, orgId: ORG_ID, projectId: PROJECT_ID } = MOCK_CLOUD_IDS; - -const SERVICE_YAML_CONTENT = /* yaml */ ` -_type: cloud -name: test-instance -region: us -replication: - connections: - - name: default - type: postgresql - uri: postgres://user:pass@host/db -`; - -const SYNC_CONFIG_CONTENT = /* yaml */ ` -bucket_definitions: - global: - data: - - SELECT * FROM todos -`; - -async function runValidateDirect(args: string[] = []) { - const config = await Config.load({ root }); - const cmd = new Validate(args, config); - return captureOutput(() => cmd.run()); -} +import { managementClientMock, MOCK_CLOUD_IDS, resetManagementClientMocks } from '../setup.js'; + +const emptySyncValidation = { + connections: [] as { name?: string }[], + diagnostics: [] as cliCore.SyncDiagnostic[], + errors: [] as { level: string; message: string }[] +}; + +type EnvSnapshot = { + API_URL: string | undefined; + INSTANCE_ID: string | undefined; + ORG_ID: string | undefined; + PROJECT_ID: string | undefined; + PS_ADMIN_TOKEN: string | undefined; +}; describe('validate', () => { - let tmpDir: string; + let tmpRoot: string; let origCwd: string; - let origEnv: { INSTANCE_ID?: string; ORG_ID?: string; PROJECT_ID?: string; PS_ADMIN_TOKEN?: string }; + let origEnv: EnvSnapshot; beforeEach(() => { - resetManagementClientMocks(); - origCwd = process.cwd(); origEnv = { + API_URL: env.API_URL, INSTANCE_ID: env.INSTANCE_ID, ORG_ID: env.ORG_ID, PROJECT_ID: env.PROJECT_ID, PS_ADMIN_TOKEN: env.PS_ADMIN_TOKEN }; - - tmpDir = mkdtempSync(join(tmpdir(), 'validate-test-')); - process.chdir(tmpDir); - env.PS_ADMIN_TOKEN = 'test-token'; - env.INSTANCE_ID = undefined; - env.ORG_ID = undefined; - env.PROJECT_ID = undefined; - - const projectDir = join(tmpDir, 'powersync'); - mkdirSync(projectDir, { recursive: true }); - writeFileSync(join(projectDir, SERVICE_FILENAME), SERVICE_YAML_CONTENT, 'utf8'); - writeFileSync(join(projectDir, SYNC_FILENAME), SYNC_CONFIG_CONTENT, 'utf8'); - writeFileSync( - join(projectDir, CLI_FILENAME), - `type: cloud\ninstance_id: ${INSTANCE_ID}\norg_id: ${ORG_ID}\nproject_id: ${PROJECT_ID}\n`, - 'utf8' - ); - - // All validations succeed by default - managementClientMock.getInstanceConfig.mockResolvedValue(MOCK_CLOUD_CONFIG); - managementClientMock.getInstanceStatus.mockResolvedValue({ operations: [], provisioned: true }); - managementClientMock.validateSyncRules.mockResolvedValue({ errors: [] }); - managementClientMock.testConnection.mockResolvedValue({ - configuration: { success: true }, - connection: { reachable: true, success: true }, - success: true - }); + tmpRoot = mkdtempSync(join(tmpdir(), 'validate-cmd-test-')); + process.chdir(tmpRoot); }); afterEach(() => { process.chdir(origCwd); - - env.PS_ADMIN_TOKEN = origEnv.PS_ADMIN_TOKEN; + env.API_URL = origEnv.API_URL; env.INSTANCE_ID = origEnv.INSTANCE_ID; env.ORG_ID = origEnv.ORG_ID; env.PROJECT_ID = origEnv.PROJECT_ID; - - if (tmpDir && existsSync(tmpDir)) rmSync(tmpDir, { recursive: true }); + env.PS_ADMIN_TOKEN = origEnv.PS_ADMIN_TOKEN; + vi.restoreAllMocks(); + if (tmpRoot && existsSync(tmpRoot)) { + rmSync(tmpRoot, { recursive: true }); + } }); - describe('--skip-validations', () => { - it('calls testConnection and validateSyncRules when no flags are passed', async () => { - await runValidateDirect(); - expect(managementClientMock.testConnection).toHaveBeenCalled(); - expect(managementClientMock.validateSyncRules).toHaveBeenCalled(); - }); - - it('does not call testConnection when --skip-validations=connections', async () => { - await runValidateDirect(['--skip-validations=connections']); - expect(managementClientMock.testConnection).not.toHaveBeenCalled(); - expect(managementClientMock.validateSyncRules).toHaveBeenCalled(); - }); - - it('does not call validateSyncRules when --skip-validations=sync-config', async () => { - await runValidateDirect(['--skip-validations=sync-config']); - expect(managementClientMock.testConnection).toHaveBeenCalled(); - expect(managementClientMock.validateSyncRules).not.toHaveBeenCalled(); - }); - - it('skips both connections and sync-config when passed as a comma-separated list', async () => { - await runValidateDirect(['--skip-validations=connections,sync-config']); - expect(managementClientMock.testConnection).not.toHaveBeenCalled(); - expect(managementClientMock.validateSyncRules).not.toHaveBeenCalled(); + describe('self-hosted', () => { + it('validates sync config from --sync-config-file-path, not default sync-config.yaml', async () => { + const spy = vi.spyOn(cliCore, 'validateSelfHostedSyncRules').mockResolvedValue(emptySyncValidation as never); + + const projectDir = join(tmpRoot, 'powersync'); + mkdirSync(projectDir, { recursive: true }); + + writeFileSync( + join(projectDir, CLI_FILENAME), + 'type: self-hosted\napi_url: https://self-hosted.validate.test\napi_key: test-key\n', + 'utf8' + ); + env.PS_ADMIN_TOKEN = 'test-key'; + + writeFileSync( + join(projectDir, SYNC_FILENAME), + 'bucket_definitions:\n only_in_default_sync_config_yaml:\n data: []\n', + 'utf8' + ); + const customPath = join(tmpRoot, 'override-sync.yaml'); + writeFileSync( + customPath, + 'bucket_definitions:\n only_from_flag_path:\n data:\n - SELECT 1 FROM validate_custom_path_marker\n', + 'utf8' + ); + + const config = await Config.load({ root }); + const cmd = new Validate( + [ + '--directory', + 'powersync', + '--validate-only', + 'sync-config', + '--sync-config-file-path', + customPath, + '--output', + 'json' + ], + config + ); + + const result = await captureOutput(() => cmd.run()); + expect(result.error).toBeUndefined(); + + expect(spy).toHaveBeenCalledTimes(1); + const call = spy.mock.calls[0]![0]; + expect(call.syncRulesContent).toContain('SELECT 1 FROM validate_custom_path_marker'); + expect(call.syncRulesContent).not.toContain('only_in_default_sync_config_yaml'); }); }); - describe('--validate-only', () => { - it('calls only validateSyncRules when --validate-only=sync-config', async () => { - await runValidateDirect(['--validate-only=sync-config']); - expect(managementClientMock.testConnection).not.toHaveBeenCalled(); - expect(managementClientMock.validateSyncRules).toHaveBeenCalled(); - }); - - it('calls only testConnection when --validate-only=connections', async () => { - await runValidateDirect(['--validate-only=connections']); - expect(managementClientMock.testConnection).toHaveBeenCalled(); - expect(managementClientMock.validateSyncRules).not.toHaveBeenCalled(); - }); - - it('calls neither testConnection nor validateSyncRules when --validate-only=configuration', async () => { - await runValidateDirect(['--validate-only=configuration']); - expect(managementClientMock.testConnection).not.toHaveBeenCalled(); - expect(managementClientMock.validateSyncRules).not.toHaveBeenCalled(); + describe('cloud', () => { + it('validates sync config from --sync-config-file-path when linked as cloud', async () => { + resetManagementClientMocks(); + const spy = vi.spyOn(cliCore, 'validateCloudSyncRules').mockResolvedValue(emptySyncValidation as never); + + const { instanceId, orgId, projectId } = MOCK_CLOUD_IDS; + const projectDir = join(tmpRoot, 'powersync'); + mkdirSync(projectDir, { recursive: true }); + + writeFileSync( + join(projectDir, CLI_FILENAME), + `type: cloud\ninstance_id: ${instanceId}\norg_id: ${orgId}\nproject_id: ${projectId}\n`, + 'utf8' + ); + env.PS_ADMIN_TOKEN = 'token'; + env.INSTANCE_ID = undefined; + env.ORG_ID = undefined; + env.PROJECT_ID = undefined; + + managementClientMock.getInstanceConfig.mockResolvedValue({ + config: { + region: 'us', + replication: { connections: [{ name: 'default', type: 'postgresql', uri: 'postgres://x' }] } + }, + id: instanceId, + name: 'test-instance', + sync_rules: '' + }); + managementClientMock.getInstanceStatus.mockResolvedValue({ operations: [], provisioned: true }); + + writeFileSync( + join(projectDir, SERVICE_FILENAME), + '_type: cloud\nname: test-instance\nregion: us\nreplication:\n connections:\n - name: default\n type: postgresql\n uri: postgres://x\n', + 'utf8' + ); + writeFileSync( + join(projectDir, SYNC_FILENAME), + 'bucket_definitions:\n cloud_default_file_only:\n data: []\n', + 'utf8' + ); + const customPath = join(tmpRoot, 'cloud-custom-sync.yaml'); + writeFileSync( + customPath, + 'bucket_definitions:\n cloud_flag_path:\n data:\n - SELECT 1 FROM cloud_validate_override\n', + 'utf8' + ); + + const config = await Config.load({ root }); + const cmd = new Validate( + [ + '--directory', + 'powersync', + '--validate-only', + 'sync-config', + '--sync-config-file-path', + customPath, + '--output', + 'json' + ], + config + ); + + const result = await captureOutput(() => cmd.run()); + expect(result.error).toBeUndefined(); + + expect(spy).toHaveBeenCalledTimes(1); + const call = spy.mock.calls[0]![0]; + expect(call.syncRulesContent).toContain('SELECT 1 FROM cloud_validate_override'); + expect(call.syncRulesContent).not.toContain('cloud_default_file_only'); }); }); }); diff --git a/docs/usage.md b/docs/usage.md index f153d25..f41fe58 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -43,6 +43,10 @@ powersync deploy service-config --directory=powersync # service.yaml only (kee powersync deploy sync-config --directory=powersync # sync-config.yaml only ``` +**Alternate sync config file** + +These commands accept **`--sync-config-file-path=/path/to/sync.yaml`** instead of **`sync-config.yaml`** in the project directory: **`powersync deploy`**, **`powersync deploy sync-config`**, **`powersync validate`**, **`powersync generate schema`**. Other commands (e.g. **`deploy service-config`**, **`generate token`**, **`destroy`**, **`status`**) do not expose this flag. + **Single directory and link file, with `!env` substitution** Use a single `powersync/` folder and a single `cli.yaml`, and use the **`!env`** custom tag in your YAML to substitute values from the environment. That way you can keep one set of config files and one link file, while varying things like instance IDs, API URLs, or database URLs per environment (e.g. production database URL from an env var). Both the link file and the main config (e.g. `service.yaml`) can use `!env` so that the same repo works for dev, staging, and prod by changing only environment variables. diff --git a/eslint.config.mjs b/eslint.config.mjs index f6155e3..c199c68 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -18,6 +18,8 @@ export default [ prettier, { rules: { + // Allow PascalCase factory/mixin calls (e.g. WithSyncConfigFilePath(Base)) without `new`. + 'new-cap': ['error', { capIsNew: false, newIsCap: true }], camelcase: 'off', eqeqeq: ['error', 'always', { null: 'ignore' }], 'n/no-unsupported-features/node-builtins': 'off', diff --git a/packages/cli-core/src/command-types/CloudInstanceCommand.ts b/packages/cli-core/src/command-types/CloudInstanceCommand.ts index 48d992c..ca728dd 100644 --- a/packages/cli-core/src/command-types/CloudInstanceCommand.ts +++ b/packages/cli-core/src/command-types/CloudInstanceCommand.ts @@ -6,7 +6,7 @@ import { validateCloudConfig } from '@powersync/cli-schemas'; import { PowerSyncManagementClient } from '@powersync/management-client'; -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { getDefaultOrgId } from '../clients/AccountsHubClientSDKClient.js'; @@ -14,7 +14,8 @@ import { createCloudClient } from '../clients/create-cloud-client.js'; import { ensureServiceTypeMatches, ServiceType } from '../utils/ensure-service-type.js'; import { env } from '../utils/env.js'; import { OBJECT_ID_REGEX } from '../utils/object-id.js'; -import { CLI_FILENAME, SERVICE_FILENAME, SYNC_FILENAME } from '../utils/project-config.js'; +import { CLI_FILENAME, SERVICE_FILENAME } from '../utils/project-config.js'; +import { resolveSyncRulesContent } from '../utils/resolve-sync-rules-content.js'; import { parseYamlFile } from '../utils/yaml.js'; import { CommandHelpGroup, HelpGroup } from './HelpGroup.js'; import { DEFAULT_ENSURE_CONFIG_OPTIONS, EnsureConfigOptions, InstanceCommand } from './InstanceCommand.js'; @@ -51,12 +52,11 @@ export type CloudInstanceCommandFlags = Interfaces.InferredFlags< * pnpm exec powersync some-cloud-cmd --instance-id=... --org-id=... --project-id=... */ export abstract class CloudInstanceCommand extends InstanceCommand { - static commandHelpGroup = CommandHelpGroup.CLOUD; - static flags = { + static baseFlags = { /** * Instance ID, org ID, and project ID are resolved in order: flags → cli.yaml → env (INSTANCE_ID, ORG_ID, PROJECT_ID). */ - ...InstanceCommand.flags, + ...InstanceCommand.baseFlags, 'instance-id': Flags.string({ dependsOn: ['project-id'], description: 'PowerSync Cloud instance ID. Manually passed if the current context has not been linked.', @@ -75,6 +75,7 @@ export abstract class CloudInstanceCommand extends InstanceCommand { required: false }) }; + static commandHelpGroup = CommandHelpGroup.CLOUD; protected _project: CloudProject | null = null; /** * Used to interface with the PowerSync Management API for Cloud instances. Automatically created with the token from login (or PS_ADMIN_TOKEN env variable). @@ -96,6 +97,10 @@ export abstract class CloudInstanceCommand extends InstanceCommand { return this._project; } + async _loadProjectHook(flags: CloudInstanceCommandFlags, project: CloudProject): Promise { + return project; + } + /** * Some commands require contacting a provisioned PowerSync instance. * This verifies that the linked instance is provisioned, and shows an error with next steps if it's not. @@ -194,17 +199,13 @@ export abstract class CloudInstanceCommand extends InstanceCommand { }); } - const syncRulesPath = join(projectDir, SYNC_FILENAME); - let syncRulesContent: string | undefined; - if (existsSync(syncRulesPath)) { - syncRulesContent = readFileSync(syncRulesPath, 'utf8'); - } + const syncRulesContent = resolveSyncRulesContent({ projectDirectory: projectDir }); - this._project = { + this._project = await this._loadProjectHook(flags, { linked, projectDirectory: projectDir, syncRulesContent - }; + }); return this._project; } diff --git a/packages/cli-core/src/command-types/InstanceCommand.ts b/packages/cli-core/src/command-types/InstanceCommand.ts index 8113352..07ebe41 100644 --- a/packages/cli-core/src/command-types/InstanceCommand.ts +++ b/packages/cli-core/src/command-types/InstanceCommand.ts @@ -20,8 +20,8 @@ export const DEFAULT_INSTANCE_DIRECTORY = 'powersync'; /** Base command for operations that target a PowerSync project directory (e.g. link, init). */ export abstract class InstanceCommand extends PowerSyncCommand { - static flags = { - ...PowerSyncCommand.flags, + static baseFlags = { + ...PowerSyncCommand.baseFlags, directory: Flags.string({ async default() { // Before we default, we need to ensure only 1 linked project is present. diff --git a/packages/cli-core/src/command-types/SelfHostedInstanceCommand.ts b/packages/cli-core/src/command-types/SelfHostedInstanceCommand.ts index 47247a1..9efc51c 100644 --- a/packages/cli-core/src/command-types/SelfHostedInstanceCommand.ts +++ b/packages/cli-core/src/command-types/SelfHostedInstanceCommand.ts @@ -18,6 +18,8 @@ import { DEFAULT_ENSURE_CONFIG_OPTIONS, EnsureConfigOptions, InstanceCommand } f export type SelfHostedProject = { linked: ResolvedSelfHostedCLIConfig; projectDirectory: string; + /** Present when using SharedInstanceCommand with a local sync config file (default or --sync-config-file-path). */ + syncRulesContent?: string; }; export type SelfHostedInstanceCommandFlags = Interfaces.InferredFlags< @@ -29,8 +31,8 @@ export type SelfHostedInstanceCommandFlags = Interfaces.InferredFlags< * Import from @powersync/cli-core when building plugins. */ export abstract class SelfHostedInstanceCommand extends InstanceCommand { - static flags = { - ...InstanceCommand.flags, + static baseFlags = { + ...InstanceCommand.baseFlags, 'api-url': Flags.string({ description: 'PowerSync API URL. Resolved: flag → cli.yaml → API_URL environment variable.', helpGroup: HelpGroup.SELF_HOSTED_PROJECT, @@ -50,10 +52,17 @@ export abstract class SelfHostedInstanceCommand extends InstanceCommand { return this._project; } - loadProject( + async _loadProjectHook( + flags: SelfHostedInstanceCommandFlags, + project: SelfHostedProject + ): Promise { + return project; + } + + async loadProject( flags: SelfHostedInstanceCommandFlags, options: EnsureConfigOptions = DEFAULT_ENSURE_CONFIG_OPTIONS - ): SelfHostedProject { + ): Promise { const resolvedOptions = { ...DEFAULT_ENSURE_CONFIG_OPTIONS, // Keep this order so call-site options override defaults. @@ -106,10 +115,10 @@ export abstract class SelfHostedInstanceCommand extends InstanceCommand { }); } - this._project = { + this._project = await this._loadProjectHook(flags, { linked, projectDirectory: projectDir - }; + }); return this._project; } diff --git a/packages/cli-core/src/command-types/SharedInstanceCommand.ts b/packages/cli-core/src/command-types/SharedInstanceCommand.ts index 6614571..68b8e88 100644 --- a/packages/cli-core/src/command-types/SharedInstanceCommand.ts +++ b/packages/cli-core/src/command-types/SharedInstanceCommand.ts @@ -13,14 +13,15 @@ import { validateSelfHostedConfig } from '@powersync/cli-schemas'; import { PowerSyncManagementClient } from '@powersync/management-client'; -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { getDefaultOrgId } from '../clients/AccountsHubClientSDKClient.js'; import { createCloudClient } from '../clients/create-cloud-client.js'; import { ensureServiceTypeMatches, ServiceType } from '../utils/ensure-service-type.js'; import { env } from '../utils/env.js'; -import { CLI_FILENAME, SERVICE_FILENAME, SYNC_FILENAME } from '../utils/project-config.js'; +import { CLI_FILENAME, SERVICE_FILENAME } from '../utils/project-config.js'; +import { resolveSyncRulesContent } from '../utils/resolve-sync-rules-content.js'; import { parseYamlFile } from '../utils/yaml.js'; import { CloudProject } from './CloudInstanceCommand.js'; import { CommandHelpGroup, HelpGroup } from './HelpGroup.js'; @@ -56,8 +57,7 @@ export type SharedInstanceCommandFlags = Interfaces.InferredFlags< * pnpm exec powersync some-shared-cmd --instance-id=... --org-id=... --project-id=... */ export abstract class SharedInstanceCommand extends InstanceCommand { - static commandHelpGroup = CommandHelpGroup.INSTANCE; - static flags = { + static baseFlags = { 'api-url': Flags.string({ description: '[Self-hosted] PowerSync API URL. When set, context is treated as self-hosted (exclusive with --instance-id). Resolved: flag → cli.yaml → API_URL.', @@ -84,10 +84,18 @@ export abstract class SharedInstanceCommand extends InstanceCommand { helpGroup: HelpGroup.CLOUD_PROJECT, required: false }), - ...InstanceCommand.flags + ...InstanceCommand.baseFlags }; + static commandHelpGroup = CommandHelpGroup.INSTANCE; cloudClient: PowerSyncManagementClient = createCloudClient(); + async _loadProjectHook( + flags: SharedInstanceCommandFlags, + project: CloudProject | SelfHostedProject + ): Promise { + return project; + } + /** * Some commands require contacting a provisioned PowerSync instance. * This verifies that the linked instance is provisioned, and shows an error with next steps if it's not. @@ -211,11 +219,7 @@ export abstract class SharedInstanceCommand extends InstanceCommand { projectDir }); - const syncRulesPath = join(projectDir, SYNC_FILENAME); - let syncRulesContent: string | undefined; - if (existsSync(syncRulesPath)) { - syncRulesContent = readFileSync(syncRulesPath, 'utf8'); - } + const syncRulesContent = resolveSyncRulesContent({ projectDirectory: projectDir }); if (!existsSync(join(projectDir, SERVICE_FILENAME)) && resolvedOptions.configFileRequired) { this.styledError({ @@ -224,18 +228,18 @@ export abstract class SharedInstanceCommand extends InstanceCommand { } if (projectType === ServiceType.CLOUD) { - return { + return this._loadProjectHook(flags, { linked: cliConfig as ResolvedCloudCLIConfig, projectDirectory: projectDir, syncRulesContent - }; + }); } - return { + return this._loadProjectHook(flags, { linked: cliConfig as ResolvedSelfHostedCLIConfig, projectDirectory: projectDir, syncRulesContent - }; + }); } parseCloudConfig(projectDirectory: string): ServiceCloudConfigDecoded { diff --git a/packages/cli-core/src/command-types/WithSyncConfigFilePath.ts b/packages/cli-core/src/command-types/WithSyncConfigFilePath.ts new file mode 100644 index 0000000..a29668d --- /dev/null +++ b/packages/cli-core/src/command-types/WithSyncConfigFilePath.ts @@ -0,0 +1,54 @@ +import { Command } from '@oclif/core'; +import { readFileSync } from 'node:fs'; + +import type { CloudProject } from './CloudInstanceCommand.js'; +import type { SelfHostedProject } from './SelfHostedInstanceCommand.js'; +import type { SharedInstanceCommandFlags } from './SharedInstanceCommand.js'; + +import { syncConfigFilePathFlags } from '../utils/sync-config-file-path-flags.js'; +import { SharedInstanceCommand } from './SharedInstanceCommand.js'; + +type ProjectLoadableCommand = Command & { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _loadProjectHook(flags: any, project: CloudProject | SelfHostedProject): Promise; +}; + +/** Flags added by this mixin; use with base flags for loadProject. */ +type SyncConfigFilePathFlags = { 'sync-config-file-path'?: string }; + +type ProjectLoadableCommandCtor = OclifCommandCtorWithFlags; + +/** + * Oclif command constructor that exposes static `flags` (same shape as {@link Command.flags}). + * Use this when a mixin spreads `Base.flags` so the base is known to declare flags. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type OclifCommandCtorWithFlags = (abstract new (...args: any[]) => TInstance) & + Pick; + +/** + * Adds `--sync-config-file-path` for shared (cloud/self-hosted) commands that need local sync YAML (e.g. validate). + * Commands that only need instance linking (e.g. status, token) should extend {@link SharedInstanceCommand} without this mixin. + */ +export function WithSyncConfigFilePath(Base: T): T { + abstract class CommandWithSyncConfigFilePath extends Base { + static baseFlags = { + ...Base.baseFlags, + ...syncConfigFilePathFlags + }; + + override async _loadProjectHook( + flags: SharedInstanceCommandFlags & SyncConfigFilePathFlags, + project: CloudProject | SelfHostedProject + ): Promise { + const customPath = flags['sync-config-file-path']; + if (customPath) { + project.syncRulesContent = readFileSync(customPath, 'utf8'); + } + + return project; + } + } + + return CommandWithSyncConfigFilePath; +} diff --git a/packages/cli-core/src/index.ts b/packages/cli-core/src/index.ts index ad8e588..e2ef8c1 100644 --- a/packages/cli-core/src/index.ts +++ b/packages/cli-core/src/index.ts @@ -13,6 +13,7 @@ export * from './command-types/InstanceCommand.js'; export * from './command-types/PowerSyncCommand.js'; export * from './command-types/SelfHostedInstanceCommand.js'; export * from './command-types/SharedInstanceCommand.js'; +export * from './command-types/WithSyncConfigFilePath.js'; export * from './services/authentication/AuthenticationService.js'; export * from './services/authentication/AuthenticationServiceImpl.js'; export * from './services/Services.js'; @@ -22,4 +23,6 @@ export * from './utils/ensure-service-type.js'; export * from './utils/env.js'; export * from './utils/object-id.js'; export * from './utils/project-config.js'; +export * from './utils/resolve-sync-rules-content.js'; +export * from './utils/sync-config-file-path-flags.js'; export * from './utils/yaml.js'; diff --git a/packages/cli-core/src/utils/resolve-sync-rules-content.ts b/packages/cli-core/src/utils/resolve-sync-rules-content.ts new file mode 100644 index 0000000..2cd6a31 --- /dev/null +++ b/packages/cli-core/src/utils/resolve-sync-rules-content.ts @@ -0,0 +1,27 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { SYNC_FILENAME } from './project-config.js'; + +export type ResolveSyncRulesContentOptions = { + projectDirectory: string; + /** When set, read sync rules from this file instead of `{projectDirectory}/sync-config.yaml`. */ + syncConfigFilePath?: string; +}; + +/** + * Loads local sync rules YAML: optional explicit path, otherwise default file in the project directory. + */ +export function resolveSyncRulesContent(options: ResolveSyncRulesContentOptions): string | undefined { + const { projectDirectory, syncConfigFilePath } = options; + if (syncConfigFilePath) { + return readFileSync(syncConfigFilePath, 'utf8'); + } + + const defaultPath = join(projectDirectory, SYNC_FILENAME); + if (existsSync(defaultPath)) { + return readFileSync(defaultPath, 'utf8'); + } + + return undefined; +} diff --git a/packages/cli-core/src/utils/sync-config-file-path-flags.ts b/packages/cli-core/src/utils/sync-config-file-path-flags.ts new file mode 100644 index 0000000..d3a4f5a --- /dev/null +++ b/packages/cli-core/src/utils/sync-config-file-path-flags.ts @@ -0,0 +1,15 @@ +import { Flags } from '@oclif/core'; + +import { HelpGroup } from '../command-types/HelpGroup.js'; + +/** + * Shared CLI flags for overriding the sync config file path (used by CloudInstanceCommand and SharedInstanceCommand). + */ +export const syncConfigFilePathFlags = { + 'sync-config-file-path': Flags.file({ + description: + '[Optional] Override the path to a sync config file. When set, this file is used instead of sync-config.yaml in the project directory.', + exists: true, + helpGroup: HelpGroup.PROJECT + }) +}; diff --git a/plugins/config-edit/src/commands/edit/config.ts b/plugins/config-edit/src/commands/edit/config.ts index b15bd3a..d0b230e 100644 --- a/plugins/config-edit/src/commands/edit/config.ts +++ b/plugins/config-edit/src/commands/edit/config.ts @@ -12,7 +12,6 @@ export default class EditConfig extends SharedInstanceCommand { static description = 'Loads the linked project context and runs the editor Nitro server to edit config files.'; static examples = ['<%= config.bin %> edit config', '<%= config.bin %> edit config --directory ./powersync']; static flags = { - ...SharedInstanceCommand.flags, host: Flags.string({ default: '127.0.0.1', description: 'Host to bind the editor preview server. Pass 0.0.0.0 to expose on all interfaces.', diff --git a/plugins/docker/src/commands/docker/configure.ts b/plugins/docker/src/commands/docker/configure.ts index 22e0700..57c613e 100644 --- a/plugins/docker/src/commands/docker/configure.ts +++ b/plugins/docker/src/commands/docker/configure.ts @@ -47,7 +47,6 @@ export default class DockerConfigure extends DockerCommand { '<%= config.bin %> <%= command.id %> --database=postgres --storage=postgres' ]; static flags = { - ...DockerCommand.flags, database: Flags.string({ description: 'Database module for replication source. Omit to be prompted.', options: [...TEMPLATES[DockerModuleType.SOURCE_DATABASE].map((t) => t.name), NONE_OPTION], diff --git a/plugins/docker/src/commands/docker/index.ts b/plugins/docker/src/commands/docker/index.ts index 4fa80cf..601ad1d 100644 --- a/plugins/docker/src/commands/docker/index.ts +++ b/plugins/docker/src/commands/docker/index.ts @@ -4,9 +4,6 @@ export default class Docker extends DockerCommand { static description = 'Scaffold and run a self-hosted PowerSync stack via Docker. Use `docker configure` to create powersync/docker/, then `docker reset` (stop+remove then start) or `docker start` / `docker stop`.'; static examples = ['<%= config.bin %> <%= command.id %>']; - static flags = { - ...DockerCommand.flags - }; static hidden = true; static summary = '[Self-hosted only] Manage self-hosted PowerSync with Docker Compose (configure, reset, start, stop).'; diff --git a/plugins/docker/src/commands/docker/reset.ts b/plugins/docker/src/commands/docker/reset.ts index 1b607a3..26cd59a 100644 --- a/plugins/docker/src/commands/docker/reset.ts +++ b/plugins/docker/src/commands/docker/reset.ts @@ -13,14 +13,11 @@ export default class DockerReset extends DockerCommand { static description = 'Run `docker compose down` then `docker compose up -d --wait`: stops and removes containers, then starts the stack and waits for services (including PowerSync) to be healthy. Use when you want a clean bring-up (e.g. after config changes). Use `powersync status` to debug running instances.'; static examples = ['<%= config.bin %> <%= command.id %>']; - static flags = { - ...DockerCommand.flags - }; static summary = 'Reset the self-hosted PowerSync stack (stop and remove, then start).'; async run(): Promise { const { flags } = await this.parse(DockerReset); - const { projectDirectory } = this.loadProject(flags as SelfHostedInstanceCommandFlags, { + const { projectDirectory } = await this.loadProject(flags as SelfHostedInstanceCommandFlags, { configFileRequired: true }); diff --git a/plugins/docker/src/commands/docker/start.ts b/plugins/docker/src/commands/docker/start.ts index 72c81a9..9342c6e 100644 --- a/plugins/docker/src/commands/docker/start.ts +++ b/plugins/docker/src/commands/docker/start.ts @@ -7,14 +7,11 @@ export default class DockerStart extends DockerCommand { static description = 'Runs `docker compose up -d --wait` for the project docker/ compose file; waits for services (including PowerSync) to be healthy. Use `powersync status` to debug running instances.'; static examples = ['<%= config.bin %> <%= command.id %>']; - static flags = { - ...DockerCommand.flags - }; static summary = 'Start the self-hosted PowerSync stack via Docker Compose.'; async run(): Promise { const { flags } = await this.parse(DockerStart); - const { projectDirectory } = this.loadProject(flags, { + const { projectDirectory } = await this.loadProject(flags, { configFileRequired: true }); diff --git a/plugins/docker/src/commands/docker/stop.ts b/plugins/docker/src/commands/docker/stop.ts index 556f9fa..15d40c1 100644 --- a/plugins/docker/src/commands/docker/stop.ts +++ b/plugins/docker/src/commands/docker/stop.ts @@ -12,7 +12,6 @@ export default class DockerStop extends DockerCommand { '<%= config.bin %> <%= command.id %> --project-name=powersync_myapp --remove' ]; static flags = { - ...DockerCommand.flags, 'project-name': Flags.string({ description: 'Docker Compose project name to stop (e.g. powersync_myapp). If omitted and run from a project directory, uses plugins.docker.project_name from cli.yaml. Pass this to stop from any directory without loading the project.' @@ -34,7 +33,7 @@ export default class DockerStop extends DockerCommand { let projectName = flags['project-name']; if (projectName == null || projectName === '') { - const { projectDirectory } = this.loadProject(flags as SelfHostedInstanceCommandFlags, { + const { projectDirectory } = await this.loadProject(flags as SelfHostedInstanceCommandFlags, { configFileRequired: true }); projectName = getDockerProjectName(projectDirectory) ?? undefined;