diff --git a/.changeset/lazy-trees-judge.md b/.changeset/lazy-trees-judge.md new file mode 100644 index 0000000..7018de4 --- /dev/null +++ b/.changeset/lazy-trees-judge.md @@ -0,0 +1,6 @@ +--- +'@powersync/cli-core': minor +'powersync': minor +--- + +Resolve organization ID and project ID from instance ID automatically. diff --git a/.gitignore b/.gitignore index 14a46e8..502a4c8 100644 --- a/.gitignore +++ b/.gitignore @@ -143,6 +143,7 @@ playground/ # PNPM .pnpm-store/ +.pnpmfile.cjs # MacOS .DS_Store diff --git a/cli/README.md b/cli/README.md index 25889ed..1954e65 100644 --- a/cli/README.md +++ b/cli/README.md @@ -129,11 +129,11 @@ This avoids re-supplying the raw password in subsequent deploys while reusing th ## Using an existing instance (pull) -Run **`powersync pull instance`** with the instance identifiers (from the PowerSync Dashboard URL or **`powersync fetch instances`**). This creates the config directory, writes **`cli.yaml`**, and downloads **`service.yaml`** and **`sync-config.yaml`**. Edit as needed, then run **`powersync deploy`**. +Run **`powersync pull instance`** with the instance ID (from the PowerSync Dashboard URL or **`powersync fetch instances`**). This creates the config directory, writes **`cli.yaml`**, and downloads **`service.yaml`** and **`sync-config.yaml`**. Edit as needed, then run **`powersync deploy`**. ```sh powersync login -powersync pull instance --project-id= --instance-id= # add --org-id if multiple orgs +powersync pull instance --instance-id= # then edit powersync/service.yaml and sync-config.yaml as needed powersync deploy ``` @@ -144,18 +144,17 @@ To refresh local config after external edits from the cloud when already linked, You can run CLI commands (e.g. **`powersync generate schema`**, **`powersync generate token`**, **`powersync status`**) against a Cloud instance whose configuration is managed elsewhere—for example in the PowerSync Dashboard. No local config directory or link file is required. -Specify the instance using **environment variables** or **CLI flags** (flags take precedence): `--instance-id` and `--project-id` (or `INSTANCE_ID`, `PROJECT_ID`). **`--org-id` is optional**: when omitted, the CLI uses the token’s single organization if the token has access to exactly one; if the token has multiple orgs, you must pass **`--org-id`** (or set `ORG_ID`). +Specify the instance using **`--instance-id`** (or the `INSTANCE_ID` environment variable). Org and project are resolved automatically from the instance. ```sh powersync login -powersync generate schema --instance-id= --project-id= --output-path=schema.ts --output=ts # add --org-id if multiple orgs - # or with env vars (set ORG_ID only if your token has multiple orgs): -export PROJECT_ID= +powersync generate schema --instance-id= --output-path=schema.ts --output=ts + # or with an env var: export INSTANCE_ID= powersync generate schema --output-path=schema.ts --output=ts ``` -**Tip:** To avoid passing instance params on every command, run **`powersync link cloud`** (e.g. `powersync link cloud --instance-id= --project-id=`) once. The CLI writes `cli.yaml` in the current directory, and subsequent commands use that instance without flags or env vars. +**Tip:** To avoid passing `--instance-id` on every command, run **`powersync link cloud --instance-id=`** once. The CLI writes `cli.yaml` in the current directory, and subsequent commands use that instance without flags or env vars. # Self-hosted @@ -253,15 +252,13 @@ USAGE You can supply instance and auth context via environment variables (useful for CI or scripts): - **`PS_ADMIN_TOKEN`** — PowerSync personal access token for Cloud commands. [Learn more](https://docs.powersync.com/usage/tools/cli#personal-access-token). -- **`ORG_ID`** — Organization ID (optional for Cloud). Omit when your token has a single organization; required when it has multiple. -- **`PROJECT_ID`** — Project ID (Cloud). - **`INSTANCE_ID`** — Instance ID (Cloud). Get IDs from the [PowerSync Dashboard](https://dashboard.powersync.com) or **`powersync fetch instances`**. - **`API_URL`** — Self-hosted PowerSync API base URL (e.g. `https://powersync.example.com`). Example (Cloud): ```sh -PS_ADMIN_TOKEN=your-token PROJECT_ID=456 INSTANCE_ID=789 powersync status +PS_ADMIN_TOKEN=your-token INSTANCE_ID=123 powersync status ``` See [docs/usage.md](../docs/usage.md) for full usage and resolution order (flags, env, cli.yaml). @@ -377,7 +374,7 @@ _See code: [@oclif/plugin-commands](https://github.com/oclif/plugin-commands/blo ``` USAGE - $ powersync compact [--directory ] [--instance-id --project-id ] [--org-id ] + $ powersync compact [--directory ] [--instance-id ] [--org-id ] [--project-id ] [--timeout ] FLAGS @@ -391,9 +388,8 @@ PROJECT FLAGS CLOUD_PROJECT FLAGS --instance-id= PowerSync Cloud instance ID. Manually passed if the current context has not been linked. - --org-id= Organization ID (optional). Defaults to the token’s single org when only one is available; pass - explicitly if the token has multiple orgs. - --project-id= Project ID. Manually passed if the current context has not been linked. + --org-id= [Deprecated] Organization ID. Automatically resolved from --instance-id. + --project-id= [Deprecated] Project ID. Automatically resolved from --instance-id. DESCRIPTION [Cloud only] Compact the linked Cloud instance. @@ -436,8 +432,9 @@ _See code: [src/commands/configure/ide.ts](https://github.com/powersync-ja/power ``` USAGE - $ powersync deploy [--deploy-timeout ] [--directory ] [--instance-id --project-id - ] [--org-id ] [--sync-config-file-path ] [--skip-validations | --validate-only ] + $ powersync deploy [--deploy-timeout ] [--directory ] [--instance-id ] [--org-id + ] [--project-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 @@ -456,9 +453,8 @@ PROJECT FLAGS CLOUD_PROJECT FLAGS --instance-id= PowerSync Cloud instance ID. Manually passed if the current context has not been linked. - --org-id= Organization ID (optional). Defaults to the token’s single org when only one is available; pass - explicitly if the token has multiple orgs. - --project-id= Project ID. Manually passed if the current context has not been linked. + --org-id= [Deprecated] Organization ID. Automatically resolved from --instance-id. + --project-id= [Deprecated] Project ID. Automatically resolved from --instance-id. DESCRIPTION [Cloud only] Deploy local config to the linked Cloud instance (connections + auth + sync config). @@ -471,7 +467,7 @@ DESCRIPTION EXAMPLES $ powersync deploy - $ powersync deploy --instance-id= --project-id= + $ powersync deploy --instance-id= ``` _See code: [src/commands/deploy/index.ts](https://github.com/powersync-ja/powersync-cli/blob/v0.9.6/src/commands/deploy/index.ts)_ @@ -482,8 +478,8 @@ _See code: [src/commands/deploy/index.ts](https://github.com/powersync-ja/powers ``` USAGE - $ powersync deploy service-config [--deploy-timeout ] [--directory ] [--instance-id --project-id - ] [--org-id ] [--skip-validations | --validate-only ] + $ powersync deploy service-config [--deploy-timeout ] [--directory ] [--instance-id ] [--org-id + ] [--project-id ] [--skip-validations | --validate-only ] FLAGS --deploy-timeout= [default: 300] Seconds to wait after scheduling a deploy before timing out while polling @@ -500,9 +496,8 @@ PROJECT FLAGS CLOUD_PROJECT FLAGS --instance-id= PowerSync Cloud instance ID. Manually passed if the current context has not been linked. - --org-id= Organization ID (optional). Defaults to the token’s single org when only one is available; pass - explicitly if the token has multiple orgs. - --project-id= Project ID. Manually passed if the current context has not been linked. + --org-id= [Deprecated] Organization ID. Automatically resolved from --instance-id. + --project-id= [Deprecated] Project ID. Automatically resolved from --instance-id. DESCRIPTION [Cloud only] Deploy only local service config to the linked Cloud instance. @@ -512,7 +507,7 @@ DESCRIPTION EXAMPLES $ powersync deploy service-config - $ powersync deploy service-config --instance-id= --project-id= + $ powersync deploy service-config --instance-id= ``` _See code: [src/commands/deploy/service-config.ts](https://github.com/powersync-ja/powersync-cli/blob/v0.9.6/src/commands/deploy/service-config.ts)_ @@ -523,8 +518,8 @@ _See code: [src/commands/deploy/service-config.ts](https://github.com/powersync- ``` USAGE - $ powersync deploy sync-config [--deploy-timeout ] [--directory ] [--instance-id --project-id - ] [--org-id ] [--sync-config-file-path ] [--skip-validations | ] + $ powersync deploy sync-config [--deploy-timeout ] [--directory ] [--instance-id ] [--org-id + ] [--project-id ] [--sync-config-file-path ] [--skip-validations | ] FLAGS --deploy-timeout= [default: 300] Seconds to wait after scheduling a deploy before timing out while polling @@ -541,9 +536,8 @@ PROJECT FLAGS CLOUD_PROJECT FLAGS --instance-id= PowerSync Cloud instance ID. Manually passed if the current context has not been linked. - --org-id= Organization ID (optional). Defaults to the token’s single org when only one is available; pass - explicitly if the token has multiple orgs. - --project-id= Project ID. Manually passed if the current context has not been linked. + --org-id= [Deprecated] Organization ID. Automatically resolved from --instance-id. + --project-id= [Deprecated] Project ID. Automatically resolved from --instance-id. DESCRIPTION [Cloud only] Deploy only local sync config to the linked Cloud instance. @@ -553,7 +547,7 @@ DESCRIPTION EXAMPLES $ powersync deploy sync-config - $ powersync deploy sync-config --instance-id= --project-id= + $ powersync deploy sync-config --instance-id= ``` _See code: [src/commands/deploy/sync-config.ts](https://github.com/powersync-ja/powersync-cli/blob/v0.9.6/src/commands/deploy/sync-config.ts)_ @@ -564,7 +558,7 @@ _See code: [src/commands/deploy/sync-config.ts](https://github.com/powersync-ja/ ``` USAGE - $ powersync destroy [--directory ] [--instance-id --project-id ] [--org-id ] + $ powersync destroy [--directory ] [--instance-id ] [--org-id ] [--project-id ] [--confirm yes] FLAGS @@ -578,9 +572,8 @@ PROJECT FLAGS CLOUD_PROJECT FLAGS --instance-id= PowerSync Cloud instance ID. Manually passed if the current context has not been linked. - --org-id= Organization ID (optional). Defaults to the token’s single org when only one is available; pass - explicitly if the token has multiple orgs. - --project-id= Project ID. Manually passed if the current context has not been linked. + --org-id= [Deprecated] Organization ID. Automatically resolved from --instance-id. + --project-id= [Deprecated] Project ID. Automatically resolved from --instance-id. DESCRIPTION [Cloud only] Permanently destroy the linked Cloud instance. @@ -757,9 +750,8 @@ PROJECT FLAGS CLOUD_PROJECT FLAGS --instance-id= [Cloud] PowerSync Cloud instance ID (BSON ObjectID). When set, context is treated as cloud (exclusive with --api-url). Resolved: flag → cli.yaml → INSTANCE_ID. - --org-id= [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. - --project-id= [Cloud] Project ID. Resolved: flag → cli.yaml → PROJECT_ID. + --org-id= [Cloud] [Deprecated] Organization ID. Automatically resolved from --instance-id. + --project-id= [Cloud] [Deprecated] Project ID. Automatically resolved from --instance-id. DESCRIPTION Open the PowerSync configuration editor (Nitro server). @@ -780,7 +772,7 @@ _See code: [@powersync/cli-plugin-config-edit](https://github.com/powersync-ja/p ``` USAGE - $ powersync fetch config [--directory ] [--instance-id --project-id ] [--org-id ] + $ powersync fetch config [--directory ] [--instance-id ] [--org-id ] [--project-id ] [--output json|yaml] FLAGS @@ -794,9 +786,8 @@ PROJECT FLAGS CLOUD_PROJECT FLAGS --instance-id= PowerSync Cloud instance ID. Manually passed if the current context has not been linked. - --org-id= Organization ID (optional). Defaults to the token’s single org when only one is available; pass - explicitly if the token has multiple orgs. - --project-id= Project ID. Manually passed if the current context has not been linked. + --org-id= [Deprecated] Organization ID. Automatically resolved from --instance-id. + --project-id= [Deprecated] Project ID. Automatically resolved from --instance-id. DESCRIPTION [Cloud only] Print linked Cloud instance config (YAML or JSON). @@ -864,9 +855,8 @@ PROJECT FLAGS CLOUD_PROJECT FLAGS --instance-id= [Cloud] PowerSync Cloud instance ID (BSON ObjectID). When set, context is treated as cloud (exclusive with --api-url). Resolved: flag → cli.yaml → INSTANCE_ID. - --org-id= [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. - --project-id= [Cloud] Project ID. Resolved: flag → cli.yaml → PROJECT_ID. + --org-id= [Cloud] [Deprecated] Organization ID. Automatically resolved from --instance-id. + --project-id= [Cloud] [Deprecated] Project ID. Automatically resolved from --instance-id. DESCRIPTION Show instance diagnostics (connections, sync config, replication). @@ -879,7 +869,7 @@ EXAMPLES $ powersync fetch status --output=json - $ powersync fetch status --instance-id= --project-id= + $ powersync fetch status --instance-id= ``` _See code: [src/commands/fetch/status.ts](https://github.com/powersync-ja/powersync-cli/blob/v0.9.6/src/commands/fetch/status.ts)_ @@ -914,9 +904,8 @@ PROJECT FLAGS CLOUD_PROJECT FLAGS --instance-id= [Cloud] PowerSync Cloud instance ID (BSON ObjectID). When set, context is treated as cloud (exclusive with --api-url). Resolved: flag → cli.yaml → INSTANCE_ID. - --org-id= [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. - --project-id= [Cloud] Project ID. Resolved: flag → cli.yaml → PROJECT_ID. + --org-id= [Cloud] [Deprecated] Organization ID. Automatically resolved from --instance-id. + --project-id= [Cloud] [Deprecated] Project ID. Automatically resolved from --instance-id. DESCRIPTION Generate client schema file from instance schema and sync config. @@ -927,7 +916,7 @@ DESCRIPTION EXAMPLES $ powersync generate schema --output=ts --output-path=schema.ts - $ powersync generate schema --output=dart --output-path=lib/schema.dart --instance-id= --project-id= + $ powersync generate schema --output=dart --output-path=lib/schema.dart --instance-id= ``` _See code: [src/commands/generate/schema.ts](https://github.com/powersync-ja/powersync-cli/blob/v0.9.6/src/commands/generate/schema.ts)_ @@ -959,9 +948,8 @@ PROJECT FLAGS CLOUD_PROJECT FLAGS --instance-id= [Cloud] PowerSync Cloud instance ID (BSON ObjectID). When set, context is treated as cloud (exclusive with --api-url). Resolved: flag → cli.yaml → INSTANCE_ID. - --org-id= [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. - --project-id= [Cloud] Project ID. Resolved: flag → cli.yaml → PROJECT_ID. + --org-id= [Cloud] [Deprecated] Organization ID. Automatically resolved from --instance-id. + --project-id= [Cloud] [Deprecated] Project ID. Automatically resolved from --instance-id. DESCRIPTION Generate a development JWT for client connections. @@ -1057,16 +1045,16 @@ _See code: [src/commands/init/self-hosted.ts](https://github.com/powersync-ja/po ``` USAGE - $ powersync link cloud --project-id [--directory ] [--instance-id ] [--org-id ] + $ powersync link cloud [--directory ] [--instance-id ] [--org-id ] [--project-id ] [--create] FLAGS --create Create a new Cloud instance in the given org and project, then link. Do not supply --instance-id when using --create. --instance-id= PowerSync Cloud instance ID. Omit when using --create. Resolved: flag → INSTANCE_ID → cli.yaml. - --org-id= Organization ID. Optional when the token has a single org; required when the token has multiple - orgs. Resolved: flag → ORG_ID → cli.yaml. - --project-id= (required) Project ID. Resolved: flag → PROJECT_ID → cli.yaml. + --org-id= Organization ID. Required with --create when the token has multiple orgs; optional when linking + an existing instance. + --project-id= Project ID. Required with --create; optional assertion when linking an existing instance. PROJECT FLAGS --directory= [default: powersync] Directory containing PowerSync config. Defaults to "powersync". This is @@ -1076,16 +1064,15 @@ PROJECT FLAGS DESCRIPTION [Cloud only] Link to a PowerSync Cloud instance (or create one with --create). - 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. EXAMPLES - $ powersync link cloud --project-id= + $ powersync link cloud --instance-id= $ powersync link cloud --create --project-id= - $ powersync link cloud --instance-id= --project-id= --org-id= + $ powersync link cloud --create --project-id= --org-id= ``` _See code: [src/commands/link/cloud.ts](https://github.com/powersync-ja/powersync-cli/blob/v0.9.6/src/commands/link/cloud.ts)_ @@ -1184,9 +1171,8 @@ PROJECT FLAGS CLOUD_PROJECT FLAGS --instance-id= [Cloud] PowerSync Cloud instance ID (BSON ObjectID). When set, context is treated as cloud (exclusive with --api-url). Resolved: flag → cli.yaml → INSTANCE_ID. - --org-id= [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. - --project-id= [Cloud] Project ID. Resolved: flag → cli.yaml → PROJECT_ID. + --org-id= [Cloud] [Deprecated] Organization ID. Automatically resolved from --instance-id. + --project-id= [Cloud] [Deprecated] Project ID. Automatically resolved from --instance-id. DESCRIPTION Migrates Sync Rules to Sync Streams @@ -1492,7 +1478,7 @@ _See code: [@oclif/plugin-plugins](https://github.com/oclif/plugin-plugins/blob/ ``` USAGE - $ powersync pull instance [--directory ] [--instance-id --project-id ] [--org-id ] + $ powersync pull instance [--directory ] [--instance-id ] [--org-id ] [--project-id ] [--overwrite] FLAGS @@ -1507,23 +1493,19 @@ PROJECT FLAGS CLOUD_PROJECT FLAGS --instance-id= PowerSync Cloud instance ID. Manually passed if the current context has not been linked. - --org-id= Organization ID (optional). Defaults to the token’s single org when only one is available; pass - explicitly if the token has multiple orgs. - --project-id= Project ID. Manually passed if the current context has not been linked. + --org-id= [Deprecated] Organization ID. Automatically resolved from --instance-id. + --project-id= [Deprecated] Project ID. Automatically resolved from --instance-id. DESCRIPTION [Cloud only] Pull an existing Cloud instance: link and download config into local service.yaml and sync-config.yaml. 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. + service.yaml and sync-config.yaml. Cloud only. EXAMPLES $ powersync pull instance - $ powersync pull instance --instance-id= --project-id= - - $ powersync pull instance --instance-id= --project-id= --org-id= + $ powersync pull instance --instance-id= ``` _See code: [src/commands/pull/instance.ts](https://github.com/powersync-ja/powersync-cli/blob/v0.9.6/src/commands/pull/instance.ts)_ @@ -1553,9 +1535,8 @@ PROJECT FLAGS CLOUD_PROJECT FLAGS --instance-id= [Cloud] PowerSync Cloud instance ID (BSON ObjectID). When set, context is treated as cloud (exclusive with --api-url). Resolved: flag → cli.yaml → INSTANCE_ID. - --org-id= [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. - --project-id= [Cloud] Project ID. Resolved: flag → cli.yaml → PROJECT_ID. + --org-id= [Cloud] [Deprecated] Organization ID. Automatically resolved from --instance-id. + --project-id= [Cloud] [Deprecated] Project ID. Automatically resolved from --instance-id. DESCRIPTION Show instance diagnostics (connections, sync config, replication). @@ -1568,7 +1549,7 @@ EXAMPLES $ powersync status --output=json - $ powersync status --instance-id= --project-id= + $ powersync status --instance-id= ``` _See code: [src/commands/status.ts](https://github.com/powersync-ja/powersync-cli/blob/v0.9.6/src/commands/status.ts)_ @@ -1579,7 +1560,7 @@ _See code: [src/commands/status.ts](https://github.com/powersync-ja/powersync-cl ``` USAGE - $ powersync stop [--directory ] [--instance-id --project-id ] [--org-id ] + $ powersync stop [--directory ] [--instance-id ] [--org-id ] [--project-id ] [--confirm yes] FLAGS @@ -1593,9 +1574,8 @@ PROJECT FLAGS CLOUD_PROJECT FLAGS --instance-id= PowerSync Cloud instance ID. Manually passed if the current context has not been linked. - --org-id= Organization ID (optional). Defaults to the token’s single org when only one is available; pass - explicitly if the token has multiple orgs. - --project-id= Project ID. Manually passed if the current context has not been linked. + --org-id= [Deprecated] Organization ID. Automatically resolved from --instance-id. + --project-id= [Deprecated] Project ID. Automatically resolved from --instance-id. DESCRIPTION [Cloud only] Stop the linked Cloud instance (restart with deploy). @@ -1642,9 +1622,8 @@ PROJECT FLAGS CLOUD_PROJECT FLAGS --instance-id= [Cloud] PowerSync Cloud instance ID (BSON ObjectID). When set, context is treated as cloud (exclusive with --api-url). Resolved: flag → cli.yaml → INSTANCE_ID. - --org-id= [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. - --project-id= [Cloud] Project ID. Resolved: flag → cli.yaml → PROJECT_ID. + --org-id= [Cloud] [Deprecated] Organization ID. Automatically resolved from --instance-id. + --project-id= [Cloud] [Deprecated] Project ID. Automatically resolved from --instance-id. DESCRIPTION Validate config schema, connections, and sync config before deploy. diff --git a/cli/src/api/cloud/validate-cloud-link-config.ts b/cli/src/api/cloud/validate-cloud-link-config.ts index 365ad1f..2e14e4b 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 {}; - } +export async function fetchCloudInstanceConfig( + options: FetchCloudInstanceConfigOptions +): Promise { + const { cloudClient, instanceId, orgId, projectId } = options; - if (!instanceId) { - throw new Error('Instance validation requested but no instance ID was provided.'); - } - - 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..19b43b1 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,17 +12,17 @@ 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=' + '<%= config.bin %> <%= command.id %> --create --project-id= --org-id=' ]; static flags = { create: Flags.boolean({ @@ -35,14 +37,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.', + description: 'Organization ID. Required with --create when the token has multiple orgs.', 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.', + required: false }) }; static summary = '[Cloud only] Link to a PowerSync Cloud instance (or create one with --create).'; @@ -51,10 +52,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 +60,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 +83,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 +99,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 +113,23 @@ 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}.` }); + } + ensureServiceTypeMatches({ command: this, configRequired: false, @@ -128,6 +137,12 @@ export default class LinkCloud extends CloudInstanceCommand { expectedType: ServiceType.CLOUD, projectDir: projectDirectory }); + + writeCloudLink(projectDirectory, { + instanceId: linked.instance_id, + orgId: linked.org_id, + projectId: linked.project_id + }); this.log(ux.colorize('green', `Updated ${directory}/${CLI_FILENAME} with Cloud instance link.`)); } } 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..9041ee6 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,12 +32,8 @@ 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.'; - static examples = [ - '<%= config.bin %> <%= command.id %>', - '<%= config.bin %> <%= command.id %> --instance-id= --project-id=', - '<%= config.bin %> <%= command.id %> --instance-id= --project-id= --org-id=' - ]; + '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=']; static flags = { overwrite: Flags.boolean({ description: @@ -47,21 +45,31 @@ 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 { directory, 'instance-id': instanceId } = flags; + const inputInstanceId = instanceId ?? env.INSTANCE_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 + }); + 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 +82,53 @@ 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 + }); + 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 +177,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/command-types/resolution-order.test.ts b/cli/test/command-types/resolution-order.test.ts index ba7bd6d..50dea8e 100644 --- a/cli/test/command-types/resolution-order.test.ts +++ b/cli/test/command-types/resolution-order.test.ts @@ -9,12 +9,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import DestroyCommand from '../../src/commands/destroy.js'; import FetchStatusCommand from '../../src/commands/fetch/status.js'; import { root } from '../helpers/root.js'; +import { managementClientMock, MOCK_CLOUD_IDS } from '../setup.js'; type EnvSnapshot = { API_URL: string | undefined; INSTANCE_ID: string | undefined; - ORG_ID: string | undefined; - PROJECT_ID: string | undefined; PS_ADMIN_TOKEN: string | undefined; }; @@ -60,8 +59,6 @@ describe('instance resolution order', () => { envSnapshot = { 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 }; }); @@ -70,14 +67,17 @@ describe('instance resolution order', () => { process.chdir(origCwd); env.API_URL = envSnapshot.API_URL; env.INSTANCE_ID = envSnapshot.INSTANCE_ID; - env.ORG_ID = envSnapshot.ORG_ID; env.PS_ADMIN_TOKEN = envSnapshot.PS_ADMIN_TOKEN; - env.PROJECT_ID = envSnapshot.PROJECT_ID; vi.restoreAllMocks(); rmSync(tmpRoot, { force: true, recursive: true }); }); - it('CloudInstanceCommand resolves cloud fields as flag → cli.yaml → env', async () => { + it('CloudInstanceCommand resolves instance_id as flag → cli.yaml → env; org/project from cli.yaml or API', async () => { + // getInstance echoes the requested id so we can verify which instance was resolved + managementClientMock.getInstance.mockImplementation(({ id }: { id: string }) => + Promise.resolve({ app_id: MOCK_CLOUD_IDS.projectId, id, org_id: MOCK_CLOUD_IDS.orgId }) + ); + const projectDir = join(tmpRoot, 'powersync'); const cliPath = join(projectDir, 'cli.yaml'); mkdirSync(projectDir, { recursive: true }); @@ -95,23 +95,18 @@ describe('instance resolution order', () => { ); env.INSTANCE_ID = IDS.env.instance; - env.ORG_ID = IDS.env.org; - env.PROJECT_ID = IDS.env.project; const loadProjectSpy = vi.spyOn(CloudInstanceCommand.prototype, 'loadProject'); - await runDestroyDirect([ - '--confirm=yes', - `--instance-id=${IDS.flag.instance}`, - `--org-id=${IDS.flag.org}`, - `--project-id=${IDS.flag.project}` - ]); + // Flag takes precedence for instance_id; org/project come from cli.yaml (API skipped when both present) + await runDestroyDirect(['--confirm=yes', `--instance-id=${IDS.flag.instance}`]); expect(loadProjectSpy).toHaveBeenCalledTimes(1); const fromFlag = await loadProjectSpy.mock.results[0]!.value; expect(fromFlag.linked.instance_id).toBe(IDS.flag.instance); - expect(fromFlag.linked.org_id).toBe(IDS.flag.org); - expect(fromFlag.linked.project_id).toBe(IDS.flag.project); + expect(fromFlag.linked.org_id).toBe(IDS.cli.org); + expect(fromFlag.linked.project_id).toBe(IDS.cli.project); + // cli.yaml is the source for all three fields when no flag is passed await runDestroyDirect(['--confirm=yes']); expect(loadProjectSpy).toHaveBeenCalledTimes(2); const fromCli = await loadProjectSpy.mock.results[1]!.value; @@ -119,13 +114,14 @@ describe('instance resolution order', () => { expect(fromCli.linked.org_id).toBe(IDS.cli.org); expect(fromCli.linked.project_id).toBe(IDS.cli.project); + // With no cli.yaml, instance_id comes from env and org/project are resolved via getInstance rmSync(cliPath, { force: true }); await runDestroyDirect(['--confirm=yes']); expect(loadProjectSpy).toHaveBeenCalledTimes(3); const fromEnv = await loadProjectSpy.mock.results[2]!.value; expect(fromEnv.linked.instance_id).toBe(IDS.env.instance); - expect(fromEnv.linked.org_id).toBe(IDS.env.org); - expect(fromEnv.linked.project_id).toBe(IDS.env.project); + expect(fromEnv.linked.org_id).toBe(MOCK_CLOUD_IDS.orgId); + expect(fromEnv.linked.project_id).toBe(MOCK_CLOUD_IDS.projectId); }); it('CloudInstanceCommand rejects invalid cloud BSON ObjectID values', async () => { @@ -185,7 +181,12 @@ describe('instance resolution order', () => { expect(fromEnv.linked.api_url).toBe('https://env.example.com'); }); - it('SharedInstanceCommand resolves cloud context and fields as flag → cli.yaml → env', async () => { + it('SharedInstanceCommand resolves cloud instance_id as flag → cli.yaml → env; org/project from cli.yaml or API', async () => { + // getInstance echoes the requested id so we can verify which instance was resolved + managementClientMock.getInstance.mockImplementation(({ id }: { id: string }) => + Promise.resolve({ app_id: MOCK_CLOUD_IDS.projectId, id, org_id: MOCK_CLOUD_IDS.orgId }) + ); + const projectDir = join(tmpRoot, 'powersync'); const cliPath = join(projectDir, 'cli.yaml'); mkdirSync(projectDir, { recursive: true }); @@ -204,25 +205,20 @@ describe('instance resolution order', () => { env.API_URL = 'https://env-self-hosted.example.com'; env.INSTANCE_ID = IDS.env.instance; - env.ORG_ID = IDS.env.org; - env.PROJECT_ID = IDS.env.project; const loadProjectSpy = vi.spyOn(SharedInstanceCommand.prototype, 'loadProject'); vi.spyOn(FetchStatusCommand.prototype, 'getCloudStatus').mockRejectedValue(new Error('expected-test-failure')); - await runFetchStatusDirect([ - '--output=json', - `--instance-id=${IDS.flag.instance}`, - `--org-id=${IDS.flag.org}`, - `--project-id=${IDS.flag.project}` - ]); + // Flag takes precedence for instance_id; org/project come from cli.yaml (API skipped when both present) + await runFetchStatusDirect(['--output=json', `--instance-id=${IDS.flag.instance}`]); expect(loadProjectSpy).toHaveBeenCalledTimes(1); - const fromCli = await loadProjectSpy.mock.results[0]!.value; - expect(fromCli.linked.type).toBe('cloud'); - expect(fromCli.linked.instance_id).toBe(IDS.flag.instance); - expect(fromCli.linked.org_id).toBe(IDS.flag.org); - expect(fromCli.linked.project_id).toBe(IDS.flag.project); + const fromFlag = await loadProjectSpy.mock.results[0]!.value; + expect(fromFlag.linked.type).toBe('cloud'); + expect(fromFlag.linked.instance_id).toBe(IDS.flag.instance); + expect(fromFlag.linked.org_id).toBe(IDS.cli.org); + expect(fromFlag.linked.project_id).toBe(IDS.cli.project); + // cli.yaml is the source for all three fields when no flag is passed await runFetchStatusDirect(['--output=json']); expect(loadProjectSpy).toHaveBeenCalledTimes(2); const fromLink = await loadProjectSpy.mock.results[1]!.value; @@ -231,6 +227,7 @@ describe('instance resolution order', () => { expect(fromLink.linked.org_id).toBe(IDS.cli.org); expect(fromLink.linked.project_id).toBe(IDS.cli.project); + // With no cli.yaml, instance_id comes from env and org/project are resolved via getInstance rmSync(cliPath, { force: true }); env.API_URL = undefined; await runFetchStatusDirect(['--output=json']); @@ -238,7 +235,7 @@ describe('instance resolution order', () => { const fromEnv = await loadProjectSpy.mock.results[2]!.value; expect(fromEnv.linked.type).toBe('cloud'); expect(fromEnv.linked.instance_id).toBe(IDS.env.instance); - expect(fromEnv.linked.org_id).toBe(IDS.env.org); - expect(fromEnv.linked.project_id).toBe(IDS.env.project); + expect(fromEnv.linked.org_id).toBe(MOCK_CLOUD_IDS.orgId); + expect(fromEnv.linked.project_id).toBe(MOCK_CLOUD_IDS.projectId); }); }); diff --git a/cli/test/commands/deploy/service-config.test.ts b/cli/test/commands/deploy/service-config.test.ts index 1508e49..aaa0baf 100644 --- a/cli/test/commands/deploy/service-config.test.ts +++ b/cli/test/commands/deploy/service-config.test.ts @@ -64,7 +64,7 @@ function writeLinkYaml(projectDir: string): void { describe('deploy:service-config', () => { let tmpDir: string; let origCwd: string; - let origEnv: { INSTANCE_ID?: string; ORG_ID?: string; PROJECT_ID?: string; PS_ADMIN_TOKEN?: string }; + let origEnv: { INSTANCE_ID?: string; PS_ADMIN_TOKEN?: string }; beforeEach(() => { resetManagementClientMocks(); @@ -72,8 +72,6 @@ describe('deploy:service-config', () => { origCwd = process.cwd(); origEnv = { INSTANCE_ID: env.INSTANCE_ID, - ORG_ID: env.ORG_ID, - PROJECT_ID: env.PROJECT_ID, PS_ADMIN_TOKEN: env.PS_ADMIN_TOKEN }; @@ -81,8 +79,6 @@ describe('deploy:service-config', () => { process.chdir(tmpDir); env.PS_ADMIN_TOKEN = 'test-token'; env.INSTANCE_ID = undefined; - env.ORG_ID = undefined; - env.PROJECT_ID = undefined; managementClientMock.getInstanceConfig.mockResolvedValue(MOCK_CLOUD_CONFIG); managementClientMock.getInstanceStatus.mockResolvedValue({ operations: [], provisioned: true }); @@ -100,8 +96,6 @@ describe('deploy:service-config', () => { env.PS_ADMIN_TOKEN = origEnv.PS_ADMIN_TOKEN; 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 }); }); @@ -119,31 +113,22 @@ describe('deploy:service-config', () => { expect(result.error?.message).toMatch(/mock deploy failure/); }); - it('works with --instance-id / --project-id / --org-id flags (no sync-config.yaml)', async () => { + it('works with --instance-id flag (no sync-config.yaml)', async () => { const projectDir = makeProjectDir(tmpDir); writeServiceYaml(projectDir); // No cli.yaml, no sync-config.yaml - const result = await runServiceConfigDirect([ - '--instance-id', - INSTANCE_ID, - '--project-id', - PROJECT_ID, - '--org-id', - ORG_ID - ]); + const result = await runServiceConfigDirect(['--instance-id', INSTANCE_ID]); expect(result.error?.message).toMatch(/mock deploy failure/); }); - it('works with INSTANCE_ID / ORG_ID / PROJECT_ID env vars (no sync-config.yaml)', async () => { + it('works with INSTANCE_ID env var (no sync-config.yaml)', async () => { const projectDir = makeProjectDir(tmpDir); writeServiceYaml(projectDir); // No cli.yaml, no sync-config.yaml env.INSTANCE_ID = INSTANCE_ID; - env.ORG_ID = ORG_ID; - env.PROJECT_ID = PROJECT_ID; const result = await runServiceConfigDirect(); diff --git a/cli/test/commands/deploy/sync-config.test.ts b/cli/test/commands/deploy/sync-config.test.ts index 2ddad81..63a8521 100644 --- a/cli/test/commands/deploy/sync-config.test.ts +++ b/cli/test/commands/deploy/sync-config.test.ts @@ -50,7 +50,7 @@ function writeLinkYaml(projectDir: string): void { describe('deploy:sync-config', () => { let tmpDir: string; let origCwd: string; - let origEnv: { INSTANCE_ID?: string; ORG_ID?: string; PROJECT_ID?: string; PS_ADMIN_TOKEN?: string }; + let origEnv: { INSTANCE_ID?: string; PS_ADMIN_TOKEN?: string }; beforeEach(() => { resetManagementClientMocks(); @@ -58,8 +58,6 @@ describe('deploy:sync-config', () => { origCwd = process.cwd(); origEnv = { INSTANCE_ID: env.INSTANCE_ID, - ORG_ID: env.ORG_ID, - PROJECT_ID: env.PROJECT_ID, PS_ADMIN_TOKEN: env.PS_ADMIN_TOKEN }; @@ -67,8 +65,6 @@ describe('deploy:sync-config', () => { process.chdir(tmpDir); env.PS_ADMIN_TOKEN = 'test-token'; env.INSTANCE_ID = undefined; - env.ORG_ID = undefined; - env.PROJECT_ID = undefined; managementClientMock.getInstanceConfig.mockResolvedValue(MOCK_CLOUD_CONFIG); managementClientMock.getInstanceStatus.mockResolvedValue({ operations: [], provisioned: true }); @@ -82,8 +78,6 @@ describe('deploy:sync-config', () => { env.PS_ADMIN_TOKEN = origEnv.PS_ADMIN_TOKEN; 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 }); }); @@ -101,31 +95,22 @@ describe('deploy:sync-config', () => { expect(result.error?.message).toMatch(/mock deploy failure/); }); - it('works with --instance-id / --project-id / --org-id flags (no service.yaml, no cli.yaml)', async () => { + it('works with --instance-id flag (no service.yaml, no cli.yaml)', async () => { const projectDir = makeProjectDir(tmpDir); // No service.yaml, no cli.yaml writeFileSync(join(projectDir, SYNC_FILENAME), SYNC_CONFIG_CONTENT, 'utf8'); - const result = await runSyncConfigDirect([ - '--instance-id', - INSTANCE_ID, - '--project-id', - PROJECT_ID, - '--org-id', - ORG_ID - ]); + const result = await runSyncConfigDirect(['--instance-id', INSTANCE_ID]); expect(result.error?.message).toMatch(/mock deploy failure/); }); - it('works with INSTANCE_ID / ORG_ID / PROJECT_ID env vars (no service.yaml, no cli.yaml)', async () => { + it('works with INSTANCE_ID env var (no service.yaml, no cli.yaml)', async () => { const projectDir = makeProjectDir(tmpDir); // No service.yaml, no cli.yaml writeFileSync(join(projectDir, SYNC_FILENAME), SYNC_CONFIG_CONTENT, 'utf8'); env.INSTANCE_ID = INSTANCE_ID; - env.ORG_ID = ORG_ID; - env.PROJECT_ID = PROJECT_ID; const result = await runSyncConfigDirect(); expect(result.error?.message).toMatch(/mock deploy failure/); diff --git a/cli/test/commands/link.test.ts b/cli/test/commands/link.test.ts index 4429c05..16b452d 100644 --- a/cli/test/commands/link.test.ts +++ b/cli/test/commands/link.test.ts @@ -224,6 +224,7 @@ type: cloud it('errors when project does not exist in the organization', async () => { accountsClientMock.listProjects.mockResolvedValueOnce({ objects: [], total: 0 }); + managementClientMock.getInstanceConfig.mockRejectedValueOnce(new Error('not found')); const { error } = await runLinkCloudDirect([ `--instance-id=${INSTANCE_ID}`, @@ -231,8 +232,9 @@ type: cloud `--project-id=${PROJECT_ID}` ]); - expect(error?.message).toContain(`Project ${PROJECT_ID} was not found in organization ${ORG_ID}`); - expect(error?.message).not.toContain(', ::'); + expect(error?.message).toContain( + `Instance ${INSTANCE_ID} was not found in project ${PROJECT_ID} in organization ${ORG_ID}` + ); }); it('errors when instance does not exist and --create is not used', async () => { diff --git a/cli/test/commands/pull/instance.test.ts b/cli/test/commands/pull/instance.test.ts index 26d8629..afed8b4 100644 --- a/cli/test/commands/pull/instance.test.ts +++ b/cli/test/commands/pull/instance.test.ts @@ -34,6 +34,7 @@ const MOCK_CONFIG_WITH_EMPTY_JWKS_KEYS = { const mockCloudClient = { deployInstance: vi.fn(), + getInstance: vi.fn(), getInstanceConfig: vi.fn() }; @@ -79,6 +80,8 @@ describe('pull instance', () => { origCwd = process.cwd(); tmpDir = mkdtempSync(join(tmpdir(), 'pull-instance-test-')); process.chdir(tmpDir); + mockCloudClient.getInstance.mockReset(); + mockCloudClient.getInstance.mockResolvedValue({ app_id: PROJECT_ID, id: INSTANCE_ID, org_id: ORG_ID }); mockCloudClient.getInstanceConfig.mockReset(); mockCloudClient.getInstanceConfig.mockRejectedValue(new Error('network error')); accountsClientMock.getOrganization.mockResolvedValue({ id: ORG_ID, label: 'Test Org' }); @@ -200,13 +203,13 @@ describe('pull instance', () => { it('errors when organization does not exist', async () => { accountsClientMock.getOrganization.mockRejectedValueOnce(new Error('not found')); const result = await runPullInstanceDirect(); - expect(result.error?.message).toContain(`Organization ${ORG_ID} was not found or is not accessible`); + expect(result.error?.message).toMatch(/Instance .* was not found in project .* in organization .*/); }); it('errors when project does not exist in the organization', async () => { accountsClientMock.listProjects.mockResolvedValueOnce({ objects: [], total: 0 }); const result = await runPullInstanceDirect(); - expect(result.error?.message).toContain(`Project ${PROJECT_ID} was not found in organization ${ORG_ID}`); + expect(result.error?.message).toMatch(/Instance .* was not found in project .* in organization .*/); }); }); }); diff --git a/cli/test/commands/validate.test.ts b/cli/test/commands/validate.test.ts index 220e158..123be8e 100644 --- a/cli/test/commands/validate.test.ts +++ b/cli/test/commands/validate.test.ts @@ -20,8 +20,6 @@ const emptySyncValidation = { type EnvSnapshot = { API_URL: string | undefined; INSTANCE_ID: string | undefined; - ORG_ID: string | undefined; - PROJECT_ID: string | undefined; PS_ADMIN_TOKEN: string | undefined; }; @@ -35,8 +33,6 @@ describe('validate', () => { 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 }; tmpRoot = mkdtempSync(join(tmpdir(), 'validate-cmd-test-')); @@ -47,8 +43,6 @@ describe('validate', () => { process.chdir(origCwd); env.API_URL = origEnv.API_URL; env.INSTANCE_ID = origEnv.INSTANCE_ID; - env.ORG_ID = origEnv.ORG_ID; - env.PROJECT_ID = origEnv.PROJECT_ID; env.PS_ADMIN_TOKEN = origEnv.PS_ADMIN_TOKEN; vi.restoreAllMocks(); if (tmpRoot && existsSync(tmpRoot)) { @@ -123,8 +117,6 @@ describe('validate', () => { ); env.PS_ADMIN_TOKEN = 'token'; env.INSTANCE_ID = undefined; - env.ORG_ID = undefined; - env.PROJECT_ID = undefined; managementClientMock.getInstanceConfig.mockResolvedValue({ config: { diff --git a/cli/test/setup.ts b/cli/test/setup.ts index 695d419..ef5e301 100644 --- a/cli/test/setup.ts +++ b/cli/test/setup.ts @@ -39,6 +39,7 @@ export const managementClientMock = { deactivateInstance: vi.fn(), deployInstance: vi.fn(), destroyInstance: vi.fn(), + getInstance: vi.fn(), getInstanceConfig: vi.fn(), getInstanceStatus: vi.fn(), listRegions: vi.fn(), @@ -53,6 +54,11 @@ export function resetManagementClientMocks(): void { managementClientMock.compact.mockRejectedValue(new Error('mock compact failure')); managementClientMock.createInstance.mockResolvedValue({ id: MOCK_CLOUD_IDS.instanceId }); + managementClientMock.getInstance.mockResolvedValue({ + app_id: MOCK_CLOUD_IDS.projectId, + id: MOCK_CLOUD_IDS.instanceId, + org_id: MOCK_CLOUD_IDS.orgId + }); managementClientMock.destroyInstance.mockRejectedValue(new Error('mock destroy failure')); managementClientMock.deactivateInstance.mockRejectedValue(new Error('mock deactivate failure')); managementClientMock.deployInstance.mockRejectedValue(new Error('mock deploy failure')); diff --git a/cli/test/utils/resolve-cloud-instance-link.test.ts b/cli/test/utils/resolve-cloud-instance-link.test.ts new file mode 100644 index 0000000..fc4702a --- /dev/null +++ b/cli/test/utils/resolve-cloud-instance-link.test.ts @@ -0,0 +1,121 @@ +import type { PowerSyncManagementClient } from '@powersync/management-client'; + +import { resolveCloudInstanceLink } from '@powersync/cli-core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { managementClientMock, MOCK_CLOUD_IDS, resetManagementClientMocks } from '../setup.js'; + +const { instanceId: INSTANCE_ID, orgId: ORG_ID, projectId: PROJECT_ID } = MOCK_CLOUD_IDS; +const OTHER_ORG_ID = '4ffabc821ea10f9b2a000002'; +const OTHER_PROJECT_ID = '699ef9c371c56d0007320544'; + +const mockClient = managementClientMock as unknown as PowerSyncManagementClient; + +describe('resolveCloudInstanceLink', () => { + beforeEach(() => { + resetManagementClientMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('throws when instanceId has an invalid format', async () => { + await expect(resolveCloudInstanceLink({ client: mockClient, instanceId: 'not-a-valid-id' })).rejects.toThrow( + 'Invalid --instance-id' + ); + expect(managementClientMock.getInstance).not.toHaveBeenCalled(); + }); + + describe('when both orgId and projectId are provided', () => { + it('returns the resolved link without an API call', async () => { + const result = await resolveCloudInstanceLink({ + client: mockClient, + instanceId: INSTANCE_ID, + orgId: ORG_ID, + projectId: PROJECT_ID + }); + expect(result).toEqual({ instance_id: INSTANCE_ID, org_id: ORG_ID, project_id: PROJECT_ID, type: 'cloud' }); + expect(managementClientMock.getInstance).not.toHaveBeenCalled(); + }); + + it('throws when orgId has an invalid format', async () => { + await expect( + resolveCloudInstanceLink({ + client: mockClient, + instanceId: INSTANCE_ID, + orgId: 'bad-org', + projectId: PROJECT_ID + }) + ).rejects.toThrow('Invalid --org-id'); + expect(managementClientMock.getInstance).not.toHaveBeenCalled(); + }); + + it('throws when projectId has an invalid format', async () => { + await expect( + resolveCloudInstanceLink({ + client: mockClient, + instanceId: INSTANCE_ID, + orgId: ORG_ID, + projectId: 'bad-project' + }) + ).rejects.toThrow('Invalid --project-id'); + expect(managementClientMock.getInstance).not.toHaveBeenCalled(); + }); + }); + + describe('when org or project IDs are missing (API lookup path)', () => { + it('throws when orgId has an invalid format before calling the API', async () => { + await expect( + resolveCloudInstanceLink({ client: mockClient, instanceId: INSTANCE_ID, orgId: 'bad-org' }) + ).rejects.toThrow('Invalid --org-id'); + expect(managementClientMock.getInstance).not.toHaveBeenCalled(); + }); + + it('throws when projectId has an invalid format before calling the API', async () => { + await expect( + resolveCloudInstanceLink({ client: mockClient, instanceId: INSTANCE_ID, projectId: 'bad-project' }) + ).rejects.toThrow('Invalid --project-id'); + expect(managementClientMock.getInstance).not.toHaveBeenCalled(); + }); + + it('throws when the instance is not found', async () => { + managementClientMock.getInstance.mockRejectedValueOnce(new Error('not found')); + await expect(resolveCloudInstanceLink({ client: mockClient, instanceId: INSTANCE_ID })).rejects.toThrow( + `Instance ${INSTANCE_ID} was not found or is not accessible with the current token.` + ); + }); + + it('resolves all fields from the instance when only instanceId is provided', async () => { + const result = await resolveCloudInstanceLink({ client: mockClient, instanceId: INSTANCE_ID }); + expect(result).toEqual({ instance_id: INSTANCE_ID, org_id: ORG_ID, project_id: PROJECT_ID, type: 'cloud' }); + expect(managementClientMock.getInstance).toHaveBeenCalledWith({ id: INSTANCE_ID }); + }); + + it('throws when the provided orgId does not match the instance', async () => { + await expect( + resolveCloudInstanceLink({ client: mockClient, instanceId: INSTANCE_ID, orgId: OTHER_ORG_ID }) + ).rejects.toThrow(`Instance ${INSTANCE_ID} belongs to organization ${ORG_ID}, not ${OTHER_ORG_ID}.`); + }); + + it('throws when the provided projectId does not match the instance', async () => { + await expect( + resolveCloudInstanceLink({ client: mockClient, instanceId: INSTANCE_ID, projectId: OTHER_PROJECT_ID }) + ).rejects.toThrow(`Instance ${INSTANCE_ID} belongs to project ${PROJECT_ID}, not ${OTHER_PROJECT_ID}.`); + }); + + it('resolves correctly when instanceId and a matching orgId are provided', async () => { + const result = await resolveCloudInstanceLink({ client: mockClient, instanceId: INSTANCE_ID, orgId: ORG_ID }); + expect(result).toEqual({ instance_id: INSTANCE_ID, org_id: ORG_ID, project_id: PROJECT_ID, type: 'cloud' }); + }); + + it('resolves correctly when instanceId and a matching projectId are provided', async () => { + const result = await resolveCloudInstanceLink({ + client: mockClient, + instanceId: INSTANCE_ID, + projectId: PROJECT_ID + }); + expect(result).toEqual({ instance_id: INSTANCE_ID, org_id: ORG_ID, project_id: PROJECT_ID, type: 'cloud' }); + }); + }); +}); diff --git a/docs/cli-documentation-conventions.md b/docs/cli-documentation-conventions.md index a050ce3..5550fa1 100644 --- a/docs/cli-documentation-conventions.md +++ b/docs/cli-documentation-conventions.md @@ -8,7 +8,7 @@ This document describes how we document commands and help text in the PowerSync - **`static summary`** — Short one-liner for the command list (e.g. in `powersync --help`). Prefer a terse summary; use `[Cloud only]` or `[Self-hosted only]` when applicable. - **`static examples`** — Array of example invocations. Always include at least: - `'<%= config.bin %> <%= command.id %>'` (base form). - - Additional entries for common flag combinations (e.g. `--confirm=yes`, `--output=json`, `--instance-id= --project-id=`). + - Additional entries for common flag combinations (e.g. `--confirm=yes`, `--output=json`, `--instance-id=`). Use the oclif template so the bin name stays correct when the CLI is installed under a different name. ## Flag descriptions @@ -27,7 +27,7 @@ For grouped commands (e.g. `fetch`, `generate`, `init`, `link`, `pull`, `migrate ## README - The CLI README (`cli/README.md`) uses oclif markers: ``, ``, ``. Content between these is replaced by `oclif readme` (run on `prepack` and `version`). Do not hand-edit the generated command blocks. -- An **Environment variables** section (after the usage block) documents `PS_ADMIN_TOKEN`, `ORG_ID`, `PROJECT_ID`, `INSTANCE_ID`, and `API_URL` for script/CI use, with a short example. +- An **Environment variables** section (after the usage block) documents `PS_ADMIN_TOKEN`, `INSTANCE_ID`, and `API_URL` for script/CI use, with a short example. ## Regenerating command docs diff --git a/docs/usage.md b/docs/usage.md index f41fe58..e4ac51f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -15,10 +15,10 @@ You can **explicitly link** your local config to a cloud or self-hosted project. For commands that don’t require locally stored config (or when you don’t want to use it), you can supply **instance information** in either of these ways: - **Inline as flags** - - **Cloud:** `--instance-id`, `--project-id`, and optionally `--org-id`. If `--org-id` (and `ORG_ID`) are omitted, the CLI uses the token’s single organization when the token has access to exactly one; if the token has multiple orgs, you must pass `--org-id` (or set `ORG_ID`). + - **Cloud:** `--instance-id`. Org and project are resolved automatically from the instance. - **Self-hosted:** `--api-url` (API key is not accepted via flags; use link command or `PS_ADMIN_TOKEN` env var) - **Environment variables** - - **Cloud:** `INSTANCE_ID`, `PROJECT_ID`, and optionally `ORG_ID` (same default behaviour as above when omitted) + - **Cloud:** `INSTANCE_ID` - **Self-hosted:** `API_URL`, `PS_ADMIN_TOKEN` (token used as API key) That lets you run one-off or scripted operations (e.g. generating a development token, generating client side schemas) without creating or using a `powersync/` folder or a link file. @@ -56,8 +56,6 @@ Example in **cli.yaml** (cloud — instance resolved from env): ```yaml type: cloud instance_id: !env MY_INSTANCE_ID_VAR -org_id: !env MY_ORG_ID_VAR -project_id: !env MY_PROJECT_ID_VAR ``` Or for self-hosted: @@ -119,9 +117,8 @@ For an instance that already exists (e.g. created in the Dashboard), there is no ```bash powersync login -# IDs from the PowerSync Dashboard URL or `powersync fetch instances`. Creates powersync/, cli.yaml, and downloads config. -powersync pull instance --project-id= --instance-id= -# If your token has multiple orgs: add --org-id= +# Instance ID from the PowerSync Dashboard URL or `powersync fetch instances`. Creates powersync/, cli.yaml, and downloads config. +powersync pull instance --instance-id= # Edit the YAML files in powersync/ as needed powersync validate @@ -145,8 +142,7 @@ You can run commands against an instance whose configuration is managed elsewher ```bash powersync login powersync fetch instances # to see available instances and their IDs -powersync link cloud --instance-id= --project-id= -# If your token has multiple orgs: add --org-id= +powersync link cloud --instance-id= ``` Then run commands without passing IDs again, for example: @@ -156,7 +152,7 @@ powersync generate schema powersync generate token ``` -You can also supply `--instance-id` and `--project-id` (and `--org-id` only when your token has multiple orgs) or the corresponding environment variables on individual commands if you don’t want to link. +You can also supply `--instance-id` (or the `INSTANCE_ID` environment variable) on individual commands if you don’t want to link. --- @@ -226,16 +222,14 @@ If you decline this prompt, login exits without storing a token. Use `PS_ADMIN_T # Supplying Linking Information for Cloud and Self-Hosted Commands -Cloud and self-hosted commands need instance (and for Cloud, org and project) identifiers. **Cloud only:** `powersync deploy`, `powersync deploy service-config`, `powersync deploy sync-config`, `powersync destroy`, `powersync stop`, `powersync fetch config`, `powersync pull instance`. **Both:** `powersync status`, `powersync generate schema`, `powersync generate token`, `powersync validate`. The same three methods apply: the CLI uses the first that is available for each field (flags override environment variables, environment variables override link file). For Cloud, **org_id is optional**: when not set via flags, env, or link file, the CLI fetches the token’s organizations and uses the single org if there is exactly one; if the token has multiple orgs, the command errors and you must pass `--org-id` (or set `ORG_ID`). +Cloud and self-hosted commands need an instance identifier. **Cloud only:** `powersync deploy`, `powersync deploy service-config`, `powersync deploy sync-config`, `powersync destroy`, `powersync stop`, `powersync fetch config`, `powersync pull instance`. **Both:** `powersync status`, `powersync generate schema`, `powersync generate token`, `powersync validate`. The same three methods apply: the CLI uses the first that is available (flags override environment variables, environment variables override link file). For Cloud commands, the org and project are resolved automatically from the instance. 1. **Flags** - - **Cloud:** `--instance-id`, `--project-id` (required when using instance-id), `--org-id` (optional; defaults to token’s single org) + - **Cloud:** `--instance-id` - **Self-hosted:** `--api-url` only (API key from env or link file only) 2. **Environment variables** - - **Cloud:** `INSTANCE_ID`, `PROJECT_ID`, and optionally `ORG_ID` (same default as above) - -- **Self-hosted:** `API_URL`, `PS_ADMIN_TOKEN` (API key) - + - **Cloud:** `INSTANCE_ID` + - **Self-hosted:** `API_URL`, `PS_ADMIN_TOKEN` (API key) 3. **cli.yaml** — a `powersync/cli.yaml` file in the project (written by `powersync link cloud` or `powersync link self-hosted`) --- @@ -250,10 +244,7 @@ Pass the identifiers on each command. Useful for one-off runs or to override the powersync login # Stop a specific instance without linking the directory (overrides cli.yaml if present) -powersync stop --confirm=yes \ - --instance-id=688736sdfcfb46688f509bd0 \ - --project-id=6703fd8a3cfe3000hrydg463 -# If your token has multiple orgs: add --org-id= +powersync stop --confirm=yes --instance-id=688736sdfcfb46688f509bd0 ``` **Self-hosted:** Set `PS_ADMIN_TOKEN` (or use a linked project with API key in cli.yaml), then: @@ -265,8 +256,8 @@ powersync status --api-url=https://powersync.example.com You can use a different project directory with `--directory`: ```bash -# Cloud (add --org-id=... only if your token has multiple orgs) -powersync stop --confirm=yes --directory=my-powersync --instance-id=... --project-id=... +# Cloud +powersync stop --confirm=yes --directory=my-powersync --instance-id=... # Self-hosted (API key from PS_ADMIN_TOKEN or cli.yaml) powersync status --directory=my-powersync --api-url=https://... @@ -282,10 +273,7 @@ Link the project once; later commands use the stored IDs. Best for day-to-day wo powersync login # Link this project to a Cloud instance (writes powersync/cli.yaml) -powersync link cloud \ - --instance-id=688736sdfcfb46688f509bd0 \ - --project-id=6703fd8a3cfe3000hrydg463 -# If your token has multiple orgs: add --org-id=5cc84a3ccudjfhgytw0c08b +powersync link cloud --instance-id=688736sdfcfb46688f509bd0 # No IDs needed on later commands powersync stop --confirm=yes @@ -295,7 +283,7 @@ powersync status If the project lives in a non-default directory: ```bash -powersync link cloud --directory=my-powersync --instance-id=... --project-id=... +powersync link cloud --directory=my-powersync --instance-id=... powersync stop --confirm=yes --directory=my-powersync ``` @@ -305,12 +293,10 @@ powersync stop --confirm=yes --directory=my-powersync Set identifiers in the environment when you don’t want to link the directory or pass flags every time (e.g. CI or scripts). -**Cloud:** (Most tokens have a single org; omit `ORG_ID`. Set it only if your token has multiple orgs.) +**Cloud:** ```bash export INSTANCE_ID=688736sdfcfb46688f509bd0 -export PROJECT_ID=6703fd8a3cfe3000hrydg463 -# export ORG_ID=... # only if your token has multiple orgs powersync stop --confirm=yes powersync fetch config --output=json @@ -328,8 +314,8 @@ powersync status --output=json Inline for a single command: ```bash -# Cloud (add ORG_ID=... only if your token has multiple orgs) -INSTANCE_ID=... PROJECT_ID=... powersync stop --confirm=yes +# Cloud +INSTANCE_ID=... powersync stop --confirm=yes # Self-hosted API_URL=https://... PS_ADMIN_TOKEN=... powersync status --output=json diff --git a/examples/cloud/basic-cloud-pull/README.md b/examples/cloud/basic-cloud-pull/README.md index 304a48d..53db681 100644 --- a/examples/cloud/basic-cloud-pull/README.md +++ b/examples/cloud/basic-cloud-pull/README.md @@ -1,13 +1,12 @@ # Basic Cloud Pull Example -This example was created by pulling an existing PowerSync Cloud instance with **`powersync pull instance`**. You do not need to run **`powersync init`** first: **`pull instance`** with your instance IDs creates the config directory, writes `cli.yaml`, and downloads `service.yaml` and `sync-config.yaml`. +This example was created by pulling an existing PowerSync Cloud instance with **`powersync pull instance`**. You do not need to run **`powersync init`** first: **`pull instance`** with your instance ID creates the config directory, writes `cli.yaml`, and downloads `service.yaml` and `sync-config.yaml`. Log in (`powersync login`) or set the `PS_ADMIN_TOKEN` environment variable, then run: ```bash # Creates powersync/, writes cli.yaml, and downloads config for the given instance -powersync pull instance --project-id=abc --instance-id=def -# If your token has multiple orgs: add --org-id= +powersync pull instance --instance-id=abc ``` The configuration file in `./powersync/service.yaml` can now be edited. diff --git a/packages/cli-core/src/command-types/CloudInstanceCommand.ts b/packages/cli-core/src/command-types/CloudInstanceCommand.ts index ca728dd..734ae71 100644 --- a/packages/cli-core/src/command-types/CloudInstanceCommand.ts +++ b/packages/cli-core/src/command-types/CloudInstanceCommand.ts @@ -9,12 +9,13 @@ 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 { LINK_MISSING_ERROR_MESSAGE } from '../utils/errors.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,37 +42,44 @@ 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.', + deprecated: { + message: '--org-id is a no-op. Organization ID is resolved automatically.' + }, + description: '[Deprecated] Organization ID. Automatically resolved from --instance-id.', helpGroup: HelpGroup.CLOUD_PROJECT, + hidden: true, required: false }), 'project-id': Flags.string({ - description: 'Project ID. Manually passed if the current context has not been linked.', + deprecated: { + message: '--project-id is a no-op. Project ID is resolved automatically.' + }, + description: '[Deprecated] Project ID. Automatically resolved from --instance-id.', helpGroup: HelpGroup.CLOUD_PROJECT, + hidden: true, required: false }) }; @@ -155,40 +163,31 @@ export abstract class CloudInstanceCommand extends InstanceCommand { } } + // Only instance_id is accepted as a CLI flag - project_id and org_id overrides must come from cli.yaml 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 project_id = rawLink?.project_id as string | undefined; + const org_id = rawLink?.org_id as string | undefined; if (instance_id != null || project_id != null || org_id != null) { this.ensureObjectIdIfPresent(instance_id, '--instance-id'); this.ensureObjectIdIfPresent(org_id, '--org-id'); this.ensureObjectIdIfPresent(project_id, '--project-id'); + if (!instance_id) { + this.styledError({ message: LINK_MISSING_ERROR_MESSAGE }); + } + 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.' - }); + this.styledError({ error, message: LINK_MISSING_ERROR_MESSAGE }); } } diff --git a/packages/cli-core/src/command-types/SharedInstanceCommand.ts b/packages/cli-core/src/command-types/SharedInstanceCommand.ts index 68b8e88..bb384a9 100644 --- a/packages/cli-core/src/command-types/SharedInstanceCommand.ts +++ b/packages/cli-core/src/command-types/SharedInstanceCommand.ts @@ -16,11 +16,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 { LINK_MISSING_ERROR_MESSAGE } from '../utils/errors.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 +51,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,21 +68,27 @@ 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.', + deprecated: { + message: '--org-id is a no-op. Organization ID is resolved automatically.' + }, + description: '[Cloud] [Deprecated] Organization ID. Automatically resolved from --instance-id.', helpGroup: HelpGroup.CLOUD_PROJECT, + hidden: true, required: false }), 'project-id': Flags.string({ - description: '[Cloud] Project ID. Resolved: flag → cli.yaml → PROJECT_ID.', + deprecated: { + message: '--project-id is a no-op. Project ID is resolved automatically.' + }, + description: '[Cloud] [Deprecated] Project ID. Automatically resolved from --instance-id.', helpGroup: HelpGroup.CLOUD_PROJECT, + hidden: true, required: false }), ...InstanceCommand.baseFlags @@ -127,7 +134,7 @@ export abstract class SharedInstanceCommand extends InstanceCommand { const linkPath = join(projectDir, CLI_FILENAME); // 1) Context type: flags first, then link file, then env (see class JSDoc for resolution order). - const hasCloudFlagInputs = flags['instance-id'] || flags['org-id'] || flags['project-id']; + const hasCloudFlagInputs = flags['instance-id']; const hasSelfHostedFlagInputs = flags['api-url']; if (hasCloudFlagInputs && hasSelfHostedFlagInputs) { @@ -154,7 +161,7 @@ export abstract class SharedInstanceCommand extends InstanceCommand { // If type still not set, use env inputs. if (!projectType) { - const hasCloudEnvInputs = env.INSTANCE_ID || env.ORG_ID || env.PROJECT_ID; + const hasCloudEnvInputs = env.INSTANCE_ID; const hasSelfHostedEnvInputs = env.API_URL; if (hasCloudEnvInputs && hasSelfHostedEnvInputs) { @@ -164,14 +171,9 @@ export abstract class SharedInstanceCommand extends InstanceCommand { projectType = hasSelfHostedEnvInputs ? ServiceType.SELF_HOSTED : hasCloudEnvInputs ? ServiceType.CLOUD : null; } - 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.' - ].join('\n'); - // If we don't have a project type by now, we need to error if (!projectType) { - this.styledError({ message: linkMissingErrorMessage }); + this.styledError({ message: LINK_MISSING_ERROR_MESSAGE }); } // 2) Per-field: flags → link file → env (see class JSDoc). @@ -185,29 +187,32 @@ export abstract class SharedInstanceCommand extends InstanceCommand { api_url: flags['api-url'] ?? _rawSelfHostedCLIConfig.api_url! ?? env.API_URL }); } catch (error) { - this.styledError({ error, message: linkMissingErrorMessage }); + this.styledError({ error, message: LINK_MISSING_ERROR_MESSAGE }); } } 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(); - } + const instanceId = flags['instance-id'] ?? _rawCloudCLIConfig.instance_id ?? env.INSTANCE_ID; + if (!instanceId) { + this.styledError({ message: LINK_MISSING_ERROR_MESSAGE }); + } - 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 - }); + try { + cliConfig = ResolvedCloudCLIConfig.decode( + await resolveCloudInstanceLink({ + client: this.cloudClient, + instanceId, + // orgId and projectId can either be set via cli.yaml or resolved via instanceId + orgId: _rawCloudCLIConfig.org_id, + projectId: _rawCloudCLIConfig.project_id + }) + ); } catch (error) { - this.styledError({ error, message: linkMissingErrorMessage }); + this.styledError({ error, message: LINK_MISSING_ERROR_MESSAGE }); } } if (!cliConfig) { - this.styledError({ message: linkMissingErrorMessage }); + this.styledError({ message: LINK_MISSING_ERROR_MESSAGE }); } // ensure the link config is valid 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/ensure-service-type.ts b/packages/cli-core/src/utils/ensure-service-type.ts index 0ae86bd..55a0c21 100644 --- a/packages/cli-core/src/utils/ensure-service-type.ts +++ b/packages/cli-core/src/utils/ensure-service-type.ts @@ -39,7 +39,7 @@ export function ensureServiceTypeMatches(options: EnsureServiceTypeMatchesOption const service = parseYamlFile(servicePath); const serviceJson = service.contents?.toJSON(); - if (serviceJson?._type === undefined || serviceJson?._type === null) { + if (serviceJson?._type == null) { command.styledError({ message: `${SERVICE_FILENAME} in "./${directoryLabel}/" is missing \`_type\`. Add \`_type: ${expectedType}\` for this command.` }); diff --git a/packages/cli-core/src/utils/errors.ts b/packages/cli-core/src/utils/errors.ts new file mode 100644 index 0000000..7738fe9 --- /dev/null +++ b/packages/cli-core/src/utils/errors.ts @@ -0,0 +1,7 @@ +/** + * Shown when a command needs an instance link but none of the inputs (flags, cli.yaml, env) resolved one. + */ +export const LINK_MISSING_ERROR_MESSAGE = [ + 'Linking is required before using this command.', + 'Provide --api-url (self-hosted) or --instance-id (cloud), or link the project first.' +].join('\n'); 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..9bf94c5 --- /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. + * + * Note that this function does NOT check if the org/project IDs reference valid destinations if provided manually. + */ +export async function resolveCloudInstanceLink(input: ResolveCloudInstanceLinkInput): Promise { + const { client, instanceId, orgId, projectId } = input; + + ensureObjectId(instanceId, '--instance-id'); + if (orgId) ensureObjectId(orgId, '--org-id'); + if (projectId) ensureObjectId(projectId, '--project-id'); + + // Skip the API request when org and project IDs are **both** provided; otherwise ignore + // the provided values and re-fetch them via getInstance. + // + // If both values are provided (i.e. if the values are present in cli.yaml), we trust that the + // org and project ID are both correct for the given instance. This lets us save an API call, and + // if we are incorrect, then we will receive a server-side validation error later in the chain. + // + // In most cases the ID fields in the cli.yaml file are generated by the CLI and will therefore contain the correct values. + if (orgId && projectId) { + 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 && orgId !== instance.org_id) { + throw new Error(`Instance ${instanceId} belongs to organization ${instance.org_id}, not ${orgId}.`); + } + + if (projectId && 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' + }; +}