diff --git a/.gitignore b/.gitignore index 2504c3d..b6d6e28 100644 --- a/.gitignore +++ b/.gitignore @@ -143,6 +143,7 @@ playground/ # PNPM .pnpm-store/ +.pnpmfile.cjs # MacOS -.DS_Store \ No newline at end of file +.DS_Store diff --git a/cli/src/api/cloud/validate-cloud-link-config.ts b/cli/src/api/cloud/validate-cloud-link-config.ts index 365ad1f..87dc7f9 100644 --- a/cli/src/api/cloud/validate-cloud-link-config.ts +++ b/cli/src/api/cloud/validate-cloud-link-config.ts @@ -1,35 +1,30 @@ -import { createAccountsHubClient, OBJECT_ID_REGEX } from '@powersync/cli-core'; +import type { ResolvedCloudCLIConfig } from '@powersync/cli-schemas'; + +import { createAccountsHubClient, ensureObjectId, resolveCloudInstanceLink } from '@powersync/cli-core'; import { PowerSyncManagementClient } from '@powersync/management-client'; type InstanceConfigResponse = Awaited>; -export type CloudLinkValidationInput = { - instanceId?: string; +export type ValidateCloudProjectOptions = { + cloudClient: PowerSyncManagementClient; orgId: string; projectId: string; }; -export type ValidateCloudLinkConfigOptions = { +export type FetchCloudInstanceConfigOptions = { cloudClient: PowerSyncManagementClient; - input: CloudLinkValidationInput; - validateInstance?: boolean; + instanceId?: string; + orgId?: string; + projectId?: string; }; -export type ValidateCloudLinkConfigResult = { - instanceConfig?: InstanceConfigResponse; +export type FetchCloudInstanceConfigResult = { + instanceConfig: InstanceConfigResponse; + linked: ResolvedCloudCLIConfig; }; -function ensureObjectId(value: string, flagName: '--instance-id' | '--org-id' | '--project-id') { - if (!OBJECT_ID_REGEX.test(value)) { - throw new Error(`Invalid ${flagName} "${value}". Expected a BSON ObjectID (24 hex characters).`); - } -} - -export async function validateCloudLinkConfig( - options: ValidateCloudLinkConfigOptions -): Promise { - const { cloudClient, input, validateInstance = false } = options; - const { instanceId, orgId, projectId } = input; +export async function validateCloudProject(options: ValidateCloudProjectOptions): Promise { + const { orgId, projectId } = options; ensureObjectId(orgId, '--org-id'); ensureObjectId(projectId, '--project-id'); @@ -56,27 +51,27 @@ export async function validateCloudLinkConfig( `Project ${projectId} was not found in organization ${orgId}, or is not accessible with the current token.` ); } +} - if (!validateInstance) { - return {}; - } - - if (!instanceId) { - throw new Error('Instance validation requested but no instance ID was provided.'); - } +export async function fetchCloudInstanceConfig( + options: FetchCloudInstanceConfigOptions +): Promise { + const { cloudClient, instanceId, orgId, projectId } = options; - ensureObjectId(instanceId, '--instance-id'); + const linked = await resolveCloudInstanceLink({ client: cloudClient, instanceId, orgId, projectId }); + let instanceConfig: InstanceConfigResponse; try { - const instanceConfig = await cloudClient.getInstanceConfig({ - app_id: projectId, - id: instanceId, - org_id: orgId + instanceConfig = await cloudClient.getInstanceConfig({ + app_id: linked.project_id, + id: linked.instance_id, + org_id: linked.org_id }); - return { instanceConfig }; } catch { throw new Error( - `Instance ${instanceId} was not found in project ${projectId} in organization ${orgId}, or is not accessible with the current token.` + `Instance ${linked.instance_id} was not found in project ${linked.project_id} in organization ${linked.org_id}, or is not accessible with the current token.` ); } + + return { instanceConfig, linked }; } diff --git a/cli/src/commands/deploy/index.ts b/cli/src/commands/deploy/index.ts index e53899e..95feb9e 100644 --- a/cli/src/commands/deploy/index.ts +++ b/cli/src/commands/deploy/index.ts @@ -16,10 +16,7 @@ export default class DeployAll extends WithSyncConfigFilePath(BaseDeployCommand) `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.` ].join('\n'); - static examples = [ - '<%= config.bin %> <%= command.id %>', - '<%= config.bin %> <%= command.id %> --instance-id= --project-id=' - ]; + static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --instance-id=']; static flags = { ...GENERAL_VALIDATION_FLAG_HELPERS.flags }; diff --git a/cli/src/commands/deploy/service-config.ts b/cli/src/commands/deploy/service-config.ts index c9c7122..e095206 100644 --- a/cli/src/commands/deploy/service-config.ts +++ b/cli/src/commands/deploy/service-config.ts @@ -12,10 +12,7 @@ const SERVICE_CONFIG_VALIDATION_FLAGS = generateValidationTestFlags({ export default class DeployServiceConfig extends BaseDeployCommand { static description = 'Deploy only service config changes (without sync config updates).'; - static examples = [ - '<%= config.bin %> <%= command.id %>', - '<%= config.bin %> <%= command.id %> --instance-id= --project-id=' - ]; + static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --instance-id=']; static flags = { ...SERVICE_CONFIG_VALIDATION_FLAGS.flags }; diff --git a/cli/src/commands/deploy/sync-config.ts b/cli/src/commands/deploy/sync-config.ts index 74c2319..de1cbb1 100644 --- a/cli/src/commands/deploy/sync-config.ts +++ b/cli/src/commands/deploy/sync-config.ts @@ -17,10 +17,7 @@ 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= --project-id=' - ]; + static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --instance-id=']; static flags = { ...SYNC_CONFIG_VALIDATION_FLAGS.flags }; diff --git a/cli/src/commands/fetch/status.ts b/cli/src/commands/fetch/status.ts index 02b6596..573702a 100644 --- a/cli/src/commands/fetch/status.ts +++ b/cli/src/commands/fetch/status.ts @@ -10,7 +10,7 @@ export default class FetchStatus extends SharedInstanceCommand { static examples = [ '<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --output=json', - '<%= config.bin %> <%= command.id %> --instance-id= --project-id=' + '<%= config.bin %> <%= command.id %> --instance-id=' ]; static flags = { output: Flags.string({ diff --git a/cli/src/commands/generate/schema.ts b/cli/src/commands/generate/schema.ts index e101b3e..6b05566 100644 --- a/cli/src/commands/generate/schema.ts +++ b/cli/src/commands/generate/schema.ts @@ -19,7 +19,7 @@ export default class GenerateSchema extends WithSyncConfigFilePath(SharedInstanc '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 = [ '<%= config.bin %> <%= command.id %> --output=ts --output-path=schema.ts', - '<%= config.bin %> <%= command.id %> --output=dart --output-path=lib/schema.dart --instance-id= --project-id=' + '<%= config.bin %> <%= command.id %> --output=dart --output-path=lib/schema.dart --instance-id=' ]; static flags = { output: Flags.string({ diff --git a/cli/src/commands/init/cloud.ts b/cli/src/commands/init/cloud.ts index 2f1d408..ba65f49 100644 --- a/cli/src/commands/init/cloud.ts +++ b/cli/src/commands/init/cloud.ts @@ -70,10 +70,7 @@ export default class InitCloud extends InstanceCommand { 'Create a new instance with ', ux.colorize('blue', '\tpowersync link cloud --create --org-id= --project-id='), 'or pull an existing instance with ', - ux.colorize( - 'blue', - '\tpowersync pull instance --org-id= --project-id= --instance-id=' - ), + ux.colorize('blue', '\tpowersync pull instance --instance-id='), `Tip: use ${ux.colorize('blue', 'powersync fetch instances')} to see available organizations and projects for your token.`, 'Then run', ux.colorize('blue', '\tpowersync deploy'), diff --git a/cli/src/commands/link/cloud.ts b/cli/src/commands/link/cloud.ts index 212c200..ba6103c 100644 --- a/cli/src/commands/link/cloud.ts +++ b/cli/src/commands/link/cloud.ts @@ -1,3 +1,5 @@ +import type { ResolvedCloudCLIConfig } from '@powersync/cli-schemas'; + import { Flags, ux } from '@oclif/core'; import { CLI_FILENAME, @@ -10,15 +12,15 @@ import { } from '@powersync/cli-core'; import { createCloudInstance } from '../../api/cloud/create-cloud-instance.js'; -import { validateCloudLinkConfig } from '../../api/cloud/validate-cloud-link-config.js'; +import { fetchCloudInstanceConfig, validateCloudProject } from '../../api/cloud/validate-cloud-link-config.js'; import { writeCloudLink } from '../../api/cloud/write-cloud-link.js'; export default class LinkCloud extends CloudInstanceCommand { static commandHelpGroup = CommandHelpGroup.PROJECT_SETUP; static description = - 'Write or update cli.yaml with a Cloud instance (instance-id, org-id, project-id). Use --create to create a new instance from service.yaml name/region and link it; omit --instance-id when using --create. Org ID is optional when the token has a single organization.'; + 'Write or update cli.yaml with a Cloud instance. Use --create to create a new instance from service.yaml name/region and link it; omit --instance-id when using --create.'; static examples = [ - '<%= config.bin %> <%= command.id %> --project-id=', + '<%= config.bin %> <%= command.id %> --instance-id=', '<%= config.bin %> <%= command.id %> --create --project-id=', '<%= config.bin %> <%= command.id %> --instance-id= --project-id= --org-id=' ]; @@ -36,13 +38,13 @@ export default class LinkCloud extends CloudInstanceCommand { 'org-id': Flags.string({ default: env.ORG_ID, description: - 'Organization ID. Optional when the token has a single org; required when the token has multiple orgs. Resolved: flag → ORG_ID → cli.yaml.', + 'Organization ID. Required with --create when the token has multiple orgs; optional when linking an existing instance.', required: false }), 'project-id': Flags.string({ default: env.PROJECT_ID, - description: 'Project ID. Resolved: flag → PROJECT_ID → cli.yaml.', - required: true + description: 'Project ID. Required with --create; optional assertion when linking an existing instance.', + required: false }) }; static summary = '[Cloud only] Link to a PowerSync Cloud instance (or create one with --create).'; @@ -51,10 +53,6 @@ export default class LinkCloud extends CloudInstanceCommand { const { flags } = await this.parse(LinkCloud); let { create, directory, 'instance-id': instanceId, 'org-id': orgId, 'project-id': projectId } = flags; - if (!orgId) { - orgId = await getDefaultOrgId(); - } - const projectDirectory = this.resolveProjectDir(flags); if (create) { if (instanceId) { @@ -63,12 +61,18 @@ export default class LinkCloud extends CloudInstanceCommand { }); } - try { - await validateCloudLinkConfig({ - cloudClient: this.client, - input: { orgId, projectId }, - validateInstance: false + if (!projectId) { + this.styledError({ + message: 'Creating a Cloud instance requires --project-id.' }); + } + + if (!orgId) { + orgId = await getDefaultOrgId(); + } + + try { + await validateCloudProject({ cloudClient: this.client, orgId: orgId!, projectId: projectId! }); } catch (error) { this.styledError({ message: error instanceof Error ? error.message : String(error) }); } @@ -80,8 +84,8 @@ export default class LinkCloud extends CloudInstanceCommand { try { const result = await createCloudInstance(client, { name: config.name, - orgId, - projectId, + orgId: orgId!, + projectId: projectId!, region: config.region }); newInstanceId = result.instanceId; @@ -96,7 +100,7 @@ export default class LinkCloud extends CloudInstanceCommand { expectedType: ServiceType.CLOUD, projectDir: projectDirectory }); - writeCloudLink(projectDirectory, { instanceId: newInstanceId, orgId, projectId }); + writeCloudLink(projectDirectory, { instanceId: newInstanceId, orgId: orgId!, projectId: projectId! }); this.log( ux.colorize('green', `Created Cloud instance ${newInstanceId} and updated ${directory}/${CLI_FILENAME}.`) ); @@ -110,17 +114,28 @@ export default class LinkCloud extends CloudInstanceCommand { }); } + let linked: ResolvedCloudCLIConfig | undefined; try { - await validateCloudLinkConfig({ + const validationResult = await fetchCloudInstanceConfig({ cloudClient: this.client, - input: { instanceId, orgId, projectId }, - validateInstance: true + instanceId, + orgId, + projectId }); + linked = validationResult.linked; } catch (error) { this.styledError({ message: error instanceof Error ? error.message : String(error) }); } - writeCloudLink(projectDirectory, { instanceId, orgId, projectId }); + if (!linked) { + this.styledError({ message: `Failed to resolve Cloud instance ${instanceId}.` }); + } + + writeCloudLink(projectDirectory, { + instanceId: linked.instance_id, + orgId: linked.org_id, + projectId: linked.project_id + }); ensureServiceTypeMatches({ command: this, configRequired: false, diff --git a/cli/src/commands/pull/index.ts b/cli/src/commands/pull/index.ts index d2ba6a8..2f57a37 100644 --- a/cli/src/commands/pull/index.ts +++ b/cli/src/commands/pull/index.ts @@ -2,7 +2,7 @@ import { Command } from '@oclif/core'; export default class Pull extends Command { static description = - 'Download current config from PowerSync Cloud into local YAML files. Use pull instance; pass --instance-id and --project-id when the directory is not yet linked (--org-id is optional when the token has a single organization).'; + 'Download current config from PowerSync Cloud into local YAML files. Use pull instance; pass --instance-id when the directory is not yet linked.'; static examples = ['<%= config.bin %> <%= command.id %>']; static hidden = true; static summary = '[Cloud only] Download Cloud config into local service.yaml and sync-config.yaml.'; diff --git a/cli/src/commands/pull/instance.ts b/cli/src/commands/pull/instance.ts index 51b1614..efe3d57 100644 --- a/cli/src/commands/pull/instance.ts +++ b/cli/src/commands/pull/instance.ts @@ -1,10 +1,12 @@ +import type { ResolvedCloudCLIConfig } from '@powersync/cli-schemas'; + import { Flags, ux } from '@oclif/core'; import { CLI_FILENAME, CloudInstanceCommand, CommandHelpGroup, ensureServiceTypeMatches, - getDefaultOrgId, + env, SERVICE_FILENAME, ServiceType, SYNC_FILENAME, @@ -17,7 +19,7 @@ import { join } from 'node:path'; import { buildServiceYaml } from '../../api/build-service-yaml.js'; import { CLOUD_SERVICE_TEMPLATE_PATH, writeCloudSyncConfigFile } from '../../api/cloud/create-cloud-template.js'; import { decodeFetchedCloudConfig } from '../../api/cloud/fetch-cloud-config.js'; -import { validateCloudLinkConfig } from '../../api/cloud/validate-cloud-link-config.js'; +import { fetchCloudInstanceConfig } from '../../api/cloud/validate-cloud-link-config.js'; import { writeCloudLink } from '../../api/cloud/write-cloud-link.js'; const SERVICE_FETCHED_FILENAME = 'service-fetched.yaml'; @@ -30,10 +32,10 @@ const PULL_CONFIG_HEADER = `# PowerSync Cloud config (fetched from cloud) export default class PullInstance extends CloudInstanceCommand { static commandHelpGroup = CommandHelpGroup.PROJECT_SETUP; static description = - 'Fetch an existing Cloud instance by ID: create the config directory if needed, write cli.yaml, and download service.yaml and sync-config.yaml. Pass --instance-id and --project-id when the directory is not yet linked; --org-id is optional when the token has a single organization. Cloud only.'; + 'Fetch an existing Cloud instance by ID: create the config directory if needed, write cli.yaml, and download service.yaml and sync-config.yaml. Cloud only.'; static examples = [ '<%= config.bin %> <%= command.id %>', - '<%= config.bin %> <%= command.id %> --instance-id= --project-id=', + '<%= config.bin %> <%= command.id %> --instance-id=', '<%= config.bin %> <%= command.id %> --instance-id= --project-id= --org-id=' ]; static flags = { @@ -48,20 +50,34 @@ export default class PullInstance extends CloudInstanceCommand { async run(): Promise { const { flags } = await this.parse(PullInstance); const { directory, 'instance-id': instanceId, 'org-id': _orgId, 'project-id': projectId } = flags; + const inputInstanceId = instanceId ?? env.INSTANCE_ID; + const inputOrgId = _orgId ?? env.ORG_ID; + const inputProjectId = projectId ?? env.PROJECT_ID; - const resolvedOrgId = _orgId ?? (await getDefaultOrgId().catch(() => null)); - /** - * The pull instance command can be used to create a new powersync project directory - */ + let resolvedLink: ResolvedCloudCLIConfig | undefined; + let instanceConfig; const projectDir = this.resolveProjectDir(flags); if (!existsSync(projectDir)) { - if (instanceId && resolvedOrgId && projectId) { - mkdirSync(projectDir, { recursive: true }); - } else { + if (!inputInstanceId) { this.styledError({ - message: `Directory "${directory}" not found. Pass --instance-id, and --project-id to create the config directory and link, or run this command from a directory that already contains a linked PowerSync config.` + message: `Directory "${directory}" not found. Pass --instance-id to create the config directory and link, or run this command from a directory that already contains a linked PowerSync config.` }); } + + try { + const validationResult = await fetchCloudInstanceConfig({ + cloudClient: this.client, + instanceId: inputInstanceId, + orgId: inputOrgId, + projectId: inputProjectId + }); + resolvedLink = validationResult.linked; + instanceConfig = validationResult.instanceConfig; + } catch (error) { + this.styledError({ message: error instanceof Error ? error.message : String(error) }); + } + + mkdirSync(projectDir, { recursive: true }); } ensureServiceTypeMatches({ @@ -74,32 +90,55 @@ export default class PullInstance extends CloudInstanceCommand { const linkPath = join(projectDir, CLI_FILENAME); if (!existsSync(linkPath)) { - if (!instanceId || !resolvedOrgId || !projectId) { + if (!resolvedLink) { + if (!inputInstanceId) { + this.styledError({ + message: `Linking is required. Pass --instance-id to this command, or run ${ux.colorize('blue', 'powersync link cloud --instance-id=')} first.` + }); + } + + try { + const validationResult = await fetchCloudInstanceConfig({ + cloudClient: this.client, + instanceId: inputInstanceId, + orgId: inputOrgId, + projectId: inputProjectId + }); + resolvedLink = validationResult.linked; + instanceConfig = validationResult.instanceConfig; + } catch (error) { + this.styledError({ message: error instanceof Error ? error.message : String(error) }); + } + } + + if (!resolvedLink) { this.styledError({ - message: `Linking is required. Pass --instance-id, --org-id, and --project-id to this command, or run ${ux.colorize('blue', 'powersync link cloud --instance-id= --org-id= --project-id=')} first.` + message: `Failed to resolve Cloud instance ${inputInstanceId}.` }); } - writeCloudLink(projectDir, { instanceId, orgId: resolvedOrgId, projectId }); + writeCloudLink(projectDir, { + instanceId: resolvedLink.instance_id, + orgId: resolvedLink.org_id, + projectId: resolvedLink.project_id + }); this.log(`Created ${ux.colorize('blue', `${directory}/${CLI_FILENAME}`)} with Cloud instance link.`); } const { linked } = await this.loadProject(flags); - let instanceConfig; - try { - const validationResult = await validateCloudLinkConfig({ - cloudClient: this.client, - input: { + if (!instanceConfig) { + try { + const validationResult = await fetchCloudInstanceConfig({ + cloudClient: this.client, instanceId: linked.instance_id, orgId: linked.org_id, projectId: linked.project_id - }, - validateInstance: true - }); - instanceConfig = validationResult.instanceConfig; - } catch (error) { - this.styledError({ message: error instanceof Error ? error.message : String(error) }); + }); + instanceConfig = validationResult.instanceConfig; + } catch (error) { + this.styledError({ message: error instanceof Error ? error.message : String(error) }); + } } if (!instanceConfig) { @@ -148,7 +187,6 @@ export default class PullInstance extends CloudInstanceCommand { writeFileSync(syncOutputPath, YAML_SYNC_RULES_SCHEMA + '\n' + fetched.syncRules, 'utf8'); this.log(`Wrote ${ux.colorize('blue', syncOutputName)} with sync config from the cloud.`); } else if (!fetched.syncRules && !syncExists) { - // If there is no sync config in the cloud and no existing sync config locally, we should still create an empty sync-config.yaml with the correct header and schema reference await writeCloudSyncConfigFile({ targetDir: projectDir }); this.log( `Wrote ${ux.colorize('blue', SYNC_FILENAME)} with template sync config (no sync config found in the cloud).` diff --git a/cli/test/commands/migrate.test.ts b/cli/test/commands/migrate.test.ts index d6a34cd..dc22a5a 100644 --- a/cli/test/commands/migrate.test.ts +++ b/cli/test/commands/migrate.test.ts @@ -2,33 +2,36 @@ import { runCommand } from '@oclif/test'; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { expect, onTestFinished, test } from 'vitest'; +import { describe, expect, onTestFinished, test } from 'vitest'; import { root } from '../helpers/root.js'; -test('migrates from sync rules to sync streams', async () => { - const testDirectory = mkdtempSync(join(tmpdir(), 'migrate-test-')); - onTestFinished(() => rmSync(testDirectory, { recursive: true })); +describe('migrate', () => { + test('migrates from sync rules to sync streams', async () => { + const testDirectory = mkdtempSync(join(tmpdir(), 'migrate-test-')); + onTestFinished(() => rmSync(testDirectory, { recursive: true })); - const inputFile = join(testDirectory, 'input.yaml'); - const outputFile = join(testDirectory, 'output.yaml'); - writeFileSync( - inputFile, - ` + const inputFile = join(testDirectory, 'input.yaml'); + const outputFile = join(testDirectory, 'output.yaml'); + writeFileSync( + inputFile, + ` bucket_definitions: user_lists: parameters: SELECT request.user_id() as user_id data: - SELECT * FROM lists WHERE owner_id = bucket.user_id ` - ); + ); - const result = await runCommand(`migrate sync-rules --input-file ${inputFile} --output-file ${outputFile}`, { root }); - expect(result.error).toBeUndefined(); + const result = await runCommand(`migrate sync-rules --input-file ${inputFile} --output-file ${outputFile}`, { + root + }); + expect(result.error).toBeUndefined(); - const transformed = readFileSync(outputFile).toString('utf-8'); - expect(transformed) - .toStrictEqual(`# Adds YAML Schema support for VSCode users with the YAML extension installed. This enables features like validation and autocompletion based on the provided schema. + const transformed = readFileSync(outputFile).toString('utf8'); + expect(transformed) + .toStrictEqual(`# Adds YAML Schema support for VSCode users with the YAML extension installed. This enables features like validation and autocompletion based on the provided schema. # yaml-language-server: $schema=https://unpkg.com/@powersync/service-sync-rules@latest/schema/sync_rules.json config: edition: 3 @@ -41,4 +44,5 @@ streams: queries: - SELECT * FROM lists WHERE owner_id = auth.user_id() `); + }); }); diff --git a/packages/cli-core/src/command-types/CloudInstanceCommand.ts b/packages/cli-core/src/command-types/CloudInstanceCommand.ts index ca728dd..4acf20c 100644 --- a/packages/cli-core/src/command-types/CloudInstanceCommand.ts +++ b/packages/cli-core/src/command-types/CloudInstanceCommand.ts @@ -9,12 +9,12 @@ import { PowerSyncManagementClient } from '@powersync/management-client'; 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 { 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'; import { resolveSyncRulesContent } from '../utils/resolve-sync-rules-content.js'; import { parseYamlFile } from '../utils/yaml.js'; import { CommandHelpGroup, HelpGroup } from './HelpGroup.js'; @@ -41,36 +41,35 @@ export type CloudInstanceCommandFlags = Interfaces.InferredFlags< * 1. Command-line flags (--instance-id, --org-id, --project-id) * 2. Linked config from cli.yaml * 3. Environment variables (INSTANCE_ID, ORG_ID, PROJECT_ID) - * 4. If org_id is still missing: token's single org (via accounts API); error if multiple orgs. + * 4. If org_id or project_id is still missing: resolve it from the Cloud instance. * * @example * # Use linked project (cli.yaml) * pnpm exec powersync some-cloud-cmd * # Override with env - * INSTANCE_ID=... ORG_ID=... PROJECT_ID=... pnpm exec powersync some-cloud-cmd + * INSTANCE_ID=... pnpm exec powersync some-cloud-cmd * # Override with flags - * pnpm exec powersync some-cloud-cmd --instance-id=... --org-id=... --project-id=... + * pnpm exec powersync some-cloud-cmd --instance-id=... */ export abstract class CloudInstanceCommand extends InstanceCommand { static baseFlags = { /** * Instance ID, org ID, and project ID are resolved in order: flags → cli.yaml → env (INSTANCE_ID, ORG_ID, PROJECT_ID). + * Missing org/project IDs are resolved from the Cloud instance. */ ...InstanceCommand.baseFlags, 'instance-id': Flags.string({ - dependsOn: ['project-id'], description: 'PowerSync Cloud instance ID. Manually passed if the current context has not been linked.', helpGroup: HelpGroup.CLOUD_PROJECT, required: false }), 'org-id': Flags.string({ - description: - 'Organization ID (optional). Defaults to the token’s single org when only one is available; pass explicitly if the token has multiple orgs.', + description: 'Organization ID (optional). Resolved from the Cloud instance when omitted.', helpGroup: HelpGroup.CLOUD_PROJECT, required: false }), 'project-id': Flags.string({ - description: 'Project ID. Manually passed if the current context has not been linked.', + description: 'Project ID (optional). Resolved from the Cloud instance when omitted.', helpGroup: HelpGroup.CLOUD_PROJECT, required: false }) @@ -157,19 +156,7 @@ export abstract class CloudInstanceCommand extends InstanceCommand { const instance_id = flags['instance-id'] ?? (rawLink?.instance_id as string | undefined) ?? env.INSTANCE_ID; const project_id = flags['project-id'] ?? (rawLink?.project_id as string | undefined) ?? env.PROJECT_ID; - let org_id = flags['org-id'] ?? (rawLink?.org_id as string | undefined) ?? env.ORG_ID; - - try { - if (org_id == null && instance_id != null) { - org_id = await getDefaultOrgId(); - } - } catch (error) { - this.styledError({ - error, - message: - 'Linking is required before using this command. Provide flags, link the project (cli.yaml), or set environment variables.' - }); - } + const org_id = flags['org-id'] ?? (rawLink?.org_id as string | undefined) ?? env.ORG_ID; if (instance_id != null || project_id != null || org_id != null) { this.ensureObjectIdIfPresent(instance_id, '--instance-id'); @@ -177,17 +164,19 @@ export abstract class CloudInstanceCommand extends InstanceCommand { this.ensureObjectIdIfPresent(project_id, '--project-id'); try { - linked = ResolvedCloudCLIConfig.decode({ - instance_id: instance_id!, - org_id: org_id!, - project_id: project_id!, - type: 'cloud' - }); + linked = ResolvedCloudCLIConfig.decode( + await resolveCloudInstanceLink({ + client: this.client, + instanceId: instance_id, + orgId: org_id, + projectId: project_id + }) + ); } catch (error) { this.styledError({ error, message: - 'Linking is required before using this command. Provide flags, link the project (cli.yaml), or set environment variables.' + 'Linking is required before using this command. Provide --instance-id, link the project (cli.yaml), or set environment variables.' }); } } diff --git a/packages/cli-core/src/command-types/SharedInstanceCommand.ts b/packages/cli-core/src/command-types/SharedInstanceCommand.ts index 68b8e88..1973d6e 100644 --- a/packages/cli-core/src/command-types/SharedInstanceCommand.ts +++ b/packages/cli-core/src/command-types/SharedInstanceCommand.ts @@ -16,11 +16,11 @@ import { PowerSyncManagementClient } from '@powersync/management-client'; 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 } from '../utils/project-config.js'; +import { resolveCloudInstanceLink } from '../utils/resolve-cloud-instance-link.js'; import { resolveSyncRulesContent } from '../utils/resolve-sync-rules-content.js'; import { parseYamlFile } from '../utils/yaml.js'; import { CloudProject } from './CloudInstanceCommand.js'; @@ -50,11 +50,11 @@ export type SharedInstanceCommandFlags = Interfaces.InferredFlags< * # Use linked project (cli.yaml determines cloud vs self-hosted) * pnpm exec powersync some-shared-cmd * # Force cloud with env - * INSTANCE_ID=... ORG_ID=... PROJECT_ID=... pnpm exec powersync some-shared-cmd + * INSTANCE_ID=... pnpm exec powersync some-shared-cmd * # Force self-hosted with flag * pnpm exec powersync some-shared-cmd --api-url=https://... * # Force cloud with flags - * pnpm exec powersync some-shared-cmd --instance-id=... --org-id=... --project-id=... + * pnpm exec powersync some-shared-cmd --instance-id=... */ export abstract class SharedInstanceCommand extends InstanceCommand { static baseFlags = { @@ -67,20 +67,18 @@ export abstract class SharedInstanceCommand extends InstanceCommand { required: false }), 'instance-id': Flags.string({ - dependsOn: ['project-id'], description: '[Cloud] PowerSync Cloud instance ID (BSON ObjectID). When set, context is treated as cloud (exclusive with --api-url). Resolved: flag → cli.yaml → INSTANCE_ID.', helpGroup: HelpGroup.CLOUD_PROJECT, required: false }), 'org-id': Flags.string({ - description: - '[Cloud] Organization ID (optional). Defaults to the token’s single org when only one is available; pass explicitly if the token has multiple orgs. Resolved: flag → cli.yaml → ORG_ID.', + description: '[Cloud] Organization ID (optional). Resolved from the Cloud instance when omitted.', helpGroup: HelpGroup.CLOUD_PROJECT, required: false }), 'project-id': Flags.string({ - description: '[Cloud] Project ID. Resolved: flag → cli.yaml → PROJECT_ID.', + description: '[Cloud] Project ID (optional). Resolved from the Cloud instance when omitted.', helpGroup: HelpGroup.CLOUD_PROJECT, required: false }), @@ -166,7 +164,7 @@ export abstract class SharedInstanceCommand extends InstanceCommand { const linkMissingErrorMessage = [ 'Linking is required before using this command.', - 'Provide --api-url (self-hosted) or --instance-id with --org-id and --project-id (cloud), or link the project first.' + 'Provide --api-url (self-hosted) or --instance-id (cloud), or link the project first.' ].join('\n'); // If we don't have a project type by now, we need to error @@ -190,17 +188,14 @@ export abstract class SharedInstanceCommand extends InstanceCommand { } else { const _rawCloudCLIConfig = (rawCLIConfig as CloudCLIConfig) ?? { type: 'cloud' }; try { - let org_id = flags['org-id'] ?? _rawCloudCLIConfig.org_id ?? env.ORG_ID; - if (org_id == null && (flags['instance-id'] || env.INSTANCE_ID)) { - org_id = await getDefaultOrgId(); - } - - cliConfig = ResolvedCloudCLIConfig.decode({ - ..._rawCloudCLIConfig, - instance_id: flags['instance-id'] ?? _rawCloudCLIConfig.instance_id! ?? env.INSTANCE_ID, - org_id: org_id!, - project_id: flags['project-id'] ?? _rawCloudCLIConfig.project_id! ?? env.PROJECT_ID - }); + cliConfig = ResolvedCloudCLIConfig.decode( + await resolveCloudInstanceLink({ + client: this.cloudClient, + instanceId: flags['instance-id'] ?? _rawCloudCLIConfig.instance_id ?? env.INSTANCE_ID, + orgId: flags['org-id'] ?? _rawCloudCLIConfig.org_id ?? env.ORG_ID, + projectId: flags['project-id'] ?? _rawCloudCLIConfig.project_id ?? env.PROJECT_ID + }) + ); } catch (error) { this.styledError({ error, message: linkMissingErrorMessage }); } diff --git a/packages/cli-core/src/index.ts b/packages/cli-core/src/index.ts index e2ef8c1..0a9034d 100644 --- a/packages/cli-core/src/index.ts +++ b/packages/cli-core/src/index.ts @@ -23,6 +23,7 @@ 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-cloud-instance-link.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/object-id.ts b/packages/cli-core/src/utils/object-id.ts index 9a30632..c38b9cb 100644 --- a/packages/cli-core/src/utils/object-id.ts +++ b/packages/cli-core/src/utils/object-id.ts @@ -1 +1,7 @@ export const OBJECT_ID_REGEX = /^[0-9a-fA-F]{24}$/; + +export function ensureObjectId(value: string, label: string): void { + if (!OBJECT_ID_REGEX.test(value)) { + throw new Error(`Invalid ${label} "${value}". Expected a BSON ObjectID (24 hex characters).`); + } +} diff --git a/packages/cli-core/src/utils/resolve-cloud-instance-link.ts b/packages/cli-core/src/utils/resolve-cloud-instance-link.ts new file mode 100644 index 0000000..f5a9a69 --- /dev/null +++ b/packages/cli-core/src/utils/resolve-cloud-instance-link.ts @@ -0,0 +1,63 @@ +import type { ResolvedCloudCLIConfig } from '@powersync/cli-schemas'; +import type { PowerSyncManagementClient } from '@powersync/management-client'; + +import { ensureObjectId } from './object-id.js'; + +export type ResolveCloudInstanceLinkInput = { + client: PowerSyncManagementClient; + instanceId?: string; + orgId?: string; + projectId?: string; +}; + +/** + * Resolves the full Cloud link from an instance ID. If org/project IDs are missing, fetches them from the instance. + */ +export async function resolveCloudInstanceLink(input: ResolveCloudInstanceLinkInput): Promise { + const { client, instanceId, orgId, projectId } = input; + + if (!instanceId) { + throw new Error('Cloud instance resolution requires an instance ID.'); + } + + ensureObjectId(instanceId, '--instance-id'); + + if (orgId && projectId) { + ensureObjectId(orgId, '--org-id'); + ensureObjectId(projectId, '--project-id'); + return { + instance_id: instanceId, + org_id: orgId, + project_id: projectId, + type: 'cloud' + }; + } + + let instance; + try { + instance = await client.getInstance({ id: instanceId }); + } catch { + throw new Error(`Instance ${instanceId} was not found or is not accessible with the current token.`); + } + + if (orgId) { + ensureObjectId(orgId, '--org-id'); + if (orgId !== instance.org_id) { + throw new Error(`Instance ${instanceId} belongs to organization ${instance.org_id}, not ${orgId}.`); + } + } + + if (projectId) { + ensureObjectId(projectId, '--project-id'); + if (projectId !== instance.app_id) { + throw new Error(`Instance ${instanceId} belongs to project ${instance.app_id}, not ${projectId}.`); + } + } + + return { + instance_id: instance.id, + org_id: instance.org_id, + project_id: instance.app_id, + type: 'cloud' + }; +}