From 21d48ca74015d1540fb67c98b2c149ec43dacc31 Mon Sep 17 00:00:00 2001 From: bean1352 Date: Wed, 2 Sep 2026 13:40:07 +0200 Subject: [PATCH 01/12] tidy link cloud and shared instance resolution --- cli/README.md | 2 +- cli/src/commands/deploy/sync-config.ts | 6 ----- cli/src/commands/link/cloud.ts | 25 +++++++------------ docs/usage.md | 6 ++--- .../command-types/SharedInstanceCommand.ts | 10 +++++--- 5 files changed, 20 insertions(+), 29 deletions(-) diff --git a/cli/README.md b/cli/README.md index 17de923..9d6db21 100644 --- a/cli/README.md +++ b/cli/README.md @@ -261,7 +261,7 @@ Example (Cloud): 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). +See [docs/usage.md](../docs/usage.md) for full usage and resolution order (flags, cli.yaml, env). # Commands diff --git a/cli/src/commands/deploy/sync-config.ts b/cli/src/commands/deploy/sync-config.ts index 4e2b052..b84b9c5 100644 --- a/cli/src/commands/deploy/sync-config.ts +++ b/cli/src/commands/deploy/sync-config.ts @@ -81,12 +81,6 @@ export default class DeploySyncConfig extends WithSyncConfigFilePath(BaseDeployC const deployTimeoutMs = (flags['deploy-timeout'] ?? DEFAULT_DEPLOY_TIMEOUT_MS / 1000) * 1000; const dryRun = flags['dry-run']; - if (!project.syncRulesContent) { - this.styledError({ - message: `Sync config content not loaded. Ensure sync config is present and valid.` - }); - } - // The existing config is required to deploy changes. The instance should have been created already. const cloudConfigState = await this.loadCloudConfigState(); await this.logTargetInstance({ instanceName: cloudConfigState.name }); diff --git a/cli/src/commands/link/cloud.ts b/cli/src/commands/link/cloud.ts index 19b43b1..be3acc8 100644 --- a/cli/src/commands/link/cloud.ts +++ b/cli/src/commands/link/cloud.ts @@ -32,7 +32,7 @@ export default class LinkCloud extends CloudInstanceCommand { }), 'instance-id': Flags.string({ default: env.INSTANCE_ID, - description: 'PowerSync Cloud instance ID. Omit when using --create. Resolved: flag → INSTANCE_ID → cli.yaml.', + description: 'PowerSync Cloud instance ID. Omit when using --create. Resolved: flag → INSTANCE_ID.', required: false }), 'org-id': Flags.string({ @@ -53,6 +53,14 @@ export default class LinkCloud extends CloudInstanceCommand { let { create, directory, 'instance-id': instanceId, 'org-id': orgId, 'project-id': projectId } = flags; const projectDirectory = this.resolveProjectDir(flags); + ensureServiceTypeMatches({ + command: this, + configRequired: create, + directoryLabel: directory, + expectedType: ServiceType.CLOUD, + projectDir: projectDirectory + }); + if (create) { if (instanceId) { this.styledError({ @@ -92,13 +100,6 @@ export default class LinkCloud extends CloudInstanceCommand { this.styledError({ error, message: 'Failed to create Cloud instance' }); } - ensureServiceTypeMatches({ - command: this, - configRequired: false, - directoryLabel: directory, - expectedType: ServiceType.CLOUD, - projectDir: projectDirectory - }); writeCloudLink(projectDirectory, { instanceId: newInstanceId, orgId: orgId!, projectId: projectId! }); this.log( ux.colorize('green', `Created Cloud instance ${newInstanceId} and updated ${directory}/${CLI_FILENAME}.`) @@ -130,14 +131,6 @@ export default class LinkCloud extends CloudInstanceCommand { this.styledError({ message: `Failed to resolve Cloud instance ${instanceId}.` }); } - ensureServiceTypeMatches({ - command: this, - configRequired: false, - directoryLabel: directory, - expectedType: ServiceType.CLOUD, - projectDir: projectDirectory - }); - writeCloudLink(projectDirectory, { instanceId: linked.instance_id, orgId: linked.org_id, diff --git a/docs/usage.md b/docs/usage.md index a4e2da3..51cce65 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -223,15 +223,15 @@ 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 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. +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 the link file, and the link file overrides environment variables). For Cloud commands, the org and project are resolved automatically from the instance. 1. **Flags** - **Cloud:** `--instance-id` - **Self-hosted:** `--api-url` only (API key from env or link file only) -2. **Environment variables** +2. **cli.yaml** — a `powersync/cli.yaml` file in the project (written by `powersync link cloud` or `powersync link self-hosted`) +3. **Environment variables** - **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`) --- diff --git a/packages/cli-core/src/command-types/SharedInstanceCommand.ts b/packages/cli-core/src/command-types/SharedInstanceCommand.ts index afc0122..6871faf 100644 --- a/packages/cli-core/src/command-types/SharedInstanceCommand.ts +++ b/packages/cli-core/src/command-types/SharedInstanceCommand.ts @@ -151,8 +151,12 @@ export abstract class SharedInstanceCommand extends InstanceCommand { // If type not set by flags, use link file type (if present). let rawCLIConfig: CLIConfig | null = null; if (existsSync(linkPath)) { - const doc = parseYamlFile(linkPath); - rawCLIConfig = CLIConfig.decode(doc.contents?.toJSON()); + try { + rawCLIConfig = CLIConfig.decode(parseYamlFile(linkPath).contents?.toJSON()); + } catch (error) { + this.styledError({ error, message: `Failed to parse ${CLI_FILENAME}` }); + } + if (rawCLIConfig.type === 'self-hosted') { projectType = ServiceType.SELF_HOSTED; } else if (rawCLIConfig.type === 'cloud') { @@ -220,7 +224,7 @@ export abstract class SharedInstanceCommand extends InstanceCommand { ensureServiceTypeMatches({ command: this, configRequired: false, - directoryLabel: projectDir, + directoryLabel: flags.directory, expectedType: projectType!, projectDir }); From 4227cb23007808c740bd30fe487f414ce5bdedeb Mon Sep 17 00:00:00 2001 From: bean1352 Date: Wed, 2 Sep 2026 13:48:30 +0200 Subject: [PATCH 02/12] add cli.yaml environments to instance resolution --- .../command-types/resolution-order.test.ts | 118 +++++++++++++++++- .../src/command-types/CloudInstanceCommand.ts | 109 ++++++++-------- .../command-types/SharedInstanceCommand.ts | 26 ++-- packages/cli-core/src/index.ts | 2 + .../src/utils/create-environment-flag.ts | 13 ++ packages/cli-core/src/utils/env.ts | 2 + .../cli-core/src/utils/log-target-instance.ts | 4 +- .../src/utils/select-cloud-link-target.ts | 28 +++++ packages/schemas/src/CLIConfig.ts | 10 ++ 9 files changed, 248 insertions(+), 64 deletions(-) create mode 100644 packages/cli-core/src/utils/create-environment-flag.ts create mode 100644 packages/cli-core/src/utils/select-cloud-link-target.ts diff --git a/cli/test/command-types/resolution-order.test.ts b/cli/test/command-types/resolution-order.test.ts index 50dea8e..932d35c 100644 --- a/cli/test/command-types/resolution-order.test.ts +++ b/cli/test/command-types/resolution-order.test.ts @@ -14,6 +14,7 @@ import { managementClientMock, MOCK_CLOUD_IDS } from '../setup.js'; type EnvSnapshot = { API_URL: string | undefined; INSTANCE_ID: string | undefined; + POWERSYNC_ENVIRONMENT: string | undefined; PS_ADMIN_TOKEN: string | undefined; }; @@ -59,6 +60,7 @@ describe('instance resolution order', () => { envSnapshot = { API_URL: env.API_URL, INSTANCE_ID: env.INSTANCE_ID, + POWERSYNC_ENVIRONMENT: env.POWERSYNC_ENVIRONMENT, PS_ADMIN_TOKEN: env.PS_ADMIN_TOKEN }; }); @@ -67,6 +69,7 @@ describe('instance resolution order', () => { process.chdir(origCwd); env.API_URL = envSnapshot.API_URL; env.INSTANCE_ID = envSnapshot.INSTANCE_ID; + env.POWERSYNC_ENVIRONMENT = envSnapshot.POWERSYNC_ENVIRONMENT; env.PS_ADMIN_TOKEN = envSnapshot.PS_ADMIN_TOKEN; vi.restoreAllMocks(); rmSync(tmpRoot, { force: true, recursive: true }); @@ -141,7 +144,84 @@ describe('instance resolution order', () => { ); const { error } = await runDestroyDirect(['--confirm=yes']); - expect(error?.message).toContain('Invalid --instance-id'); + expect(error?.message).toContain('Invalid instance_id in cli.yaml'); + }); + + it('CloudInstanceCommand selects a cli.yaml environment from --environment or POWERSYNC_ENVIRONMENT', async () => { + 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'); + mkdirSync(projectDir, { recursive: true }); + writeFileSync(join(projectDir, 'service.yaml'), '_type: cloud\n', 'utf8'); + writeFileSync( + join(projectDir, 'cli.yaml'), + [ + 'type: cloud', + `instance_id: ${IDS.cli.instance}`, + `org_id: ${IDS.cli.org}`, + `project_id: ${IDS.cli.project}`, + 'environments:', + ' staging:', + ` instance_id: ${IDS.env.instance}`, + ` org_id: ${IDS.env.org}`, + ` project_id: ${IDS.env.project}`, + ' production:', + ` instance_id: ${IDS.flag.instance}`, + '' + ].join('\n'), + 'utf8' + ); + + const loadProjectSpy = vi.spyOn(CloudInstanceCommand.prototype, 'loadProject'); + + // --environment picks the named entry, including its org/project + await runDestroyDirect(['--confirm=yes', '--environment=staging']); + const fromFlag = await loadProjectSpy.mock.results[0]!.value; + expect(fromFlag.environment).toBe('staging'); + expect(fromFlag.linked.instance_id).toBe(IDS.env.instance); + expect(fromFlag.linked.org_id).toBe(IDS.env.org); + expect(fromFlag.linked.project_id).toBe(IDS.env.project); + + // POWERSYNC_ENVIRONMENT selects an entry too; its missing org/project are resolved via getInstance + env.POWERSYNC_ENVIRONMENT = 'production'; + await runDestroyDirect(['--confirm=yes']); + const fromEnv = await loadProjectSpy.mock.results[1]!.value; + expect(fromEnv.environment).toBe('production'); + expect(fromEnv.linked.instance_id).toBe(IDS.flag.instance); + expect(fromEnv.linked.org_id).toBe(MOCK_CLOUD_IDS.orgId); + expect(fromEnv.linked.project_id).toBe(MOCK_CLOUD_IDS.projectId); + + // --instance-id wins over the selected environment and uses the top-level org/project + await runDestroyDirect(['--confirm=yes', `--instance-id=${IDS.env.instance}`]); + const fromInstanceFlag = await loadProjectSpy.mock.results[2]!.value; + expect(fromInstanceFlag.environment).toBeUndefined(); + expect(fromInstanceFlag.linked.instance_id).toBe(IDS.env.instance); + expect(fromInstanceFlag.linked.org_id).toBe(IDS.cli.org); + expect(fromInstanceFlag.linked.project_id).toBe(IDS.cli.project); + }); + + it('CloudInstanceCommand rejects an unknown environment and --environment combined with --instance-id', async () => { + const projectDir = join(tmpRoot, 'powersync'); + mkdirSync(projectDir, { recursive: true }); + writeFileSync(join(projectDir, 'service.yaml'), '_type: cloud\n', 'utf8'); + writeFileSync( + join(projectDir, 'cli.yaml'), + ['type: cloud', 'environments:', ' staging:', ` instance_id: ${IDS.env.instance}`, ''].join('\n'), + 'utf8' + ); + + const unknown = await runDestroyDirect(['--confirm=yes', '--environment=production']); + expect(unknown.error?.message).toContain('Environment "production" is not defined in cli.yaml'); + expect(unknown.error?.message).toContain('staging'); + + const exclusive = await runDestroyDirect([ + '--confirm=yes', + '--environment=staging', + `--instance-id=${IDS.cli.instance}` + ]); + expect(exclusive.error?.message).toContain('cannot also be provided'); }); it('SharedInstanceCommand resolves self-hosted api_url as flag → cli.yaml → env', async () => { @@ -181,6 +261,42 @@ describe('instance resolution order', () => { expect(fromEnv.linked.api_url).toBe('https://env.example.com'); }); + it('SharedInstanceCommand selects a cli.yaml environment from --environment or POWERSYNC_ENVIRONMENT', async () => { + const projectDir = join(tmpRoot, 'powersync'); + mkdirSync(projectDir, { recursive: true }); + writeFileSync(join(projectDir, 'service.yaml'), '_type: cloud\n', 'utf8'); + writeFileSync( + join(projectDir, 'cli.yaml'), + [ + 'type: cloud', + 'environments:', + ' staging:', + ` instance_id: ${IDS.env.instance}`, + ` org_id: ${IDS.env.org}`, + ` project_id: ${IDS.env.project}`, + '' + ].join('\n'), + 'utf8' + ); + + const loadProjectSpy = vi.spyOn(SharedInstanceCommand.prototype, 'loadProject'); + vi.spyOn(FetchStatusCommand.prototype, 'getCloudStatus').mockRejectedValue(new Error('expected-test-failure')); + + await runFetchStatusDirect(['--output=json', '--environment=staging']); + const fromFlag = await loadProjectSpy.mock.results[0]!.value; + expect(fromFlag.environment).toBe('staging'); + expect(fromFlag.linked.type).toBe('cloud'); + expect(fromFlag.linked.instance_id).toBe(IDS.env.instance); + expect(fromFlag.linked.org_id).toBe(IDS.env.org); + expect(fromFlag.linked.project_id).toBe(IDS.env.project); + + env.POWERSYNC_ENVIRONMENT = 'staging'; + await runFetchStatusDirect(['--output=json']); + const fromEnv = await loadProjectSpy.mock.results[1]!.value; + expect(fromEnv.environment).toBe('staging'); + expect(fromEnv.linked.instance_id).toBe(IDS.env.instance); + }); + 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 }) => diff --git a/packages/cli-core/src/command-types/CloudInstanceCommand.ts b/packages/cli-core/src/command-types/CloudInstanceCommand.ts index 8d18786..febf9fc 100644 --- a/packages/cli-core/src/command-types/CloudInstanceCommand.ts +++ b/packages/cli-core/src/command-types/CloudInstanceCommand.ts @@ -1,5 +1,6 @@ import { Flags, Interfaces, ux } from '@oclif/core'; import { + CloudCLIConfig, ResolvedCloudCLIConfig, ServiceCloudConfig, ServiceCloudConfigDecoded, @@ -10,6 +11,7 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { createCloudClient } from '../clients/create-cloud-client.js'; +import { createEnvironmentFlag } from '../utils/create-environment-flag.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'; @@ -18,11 +20,14 @@ 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 { CloudLinkTarget, selectCloudLinkTarget } from '../utils/select-cloud-link-target.js'; import { parseYamlFile } from '../utils/yaml.js'; import { CommandHelpGroup, HelpGroup } from './HelpGroup.js'; import { DEFAULT_ENSURE_CONFIG_OPTIONS, EnsureConfigOptions, InstanceCommand } from './InstanceCommand.js'; export type CloudProject = { + /** Name of the cli.yaml environment the link was selected from, if any. */ + environment?: string; linked: ResolvedCloudCLIConfig; projectDirectory: string; syncRulesContent?: string; @@ -40,14 +45,17 @@ export type CloudInstanceCommandFlags = Interfaces.InferredFlags< * Base command for operations that require a Cloud-type PowerSync project (service.yaml _type: cloud). * * Instance context (instance_id, org_id, project_id) is resolved in this order: - * 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 or project_id is still missing: resolve it from the Cloud instance. + * 1. --instance-id + * 2. The cli.yaml environment selected with --environment or POWERSYNC_ENVIRONMENT + * 3. The top-level fields in cli.yaml + * 4. INSTANCE_ID + * 5. 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 + * # Use a named environment from cli.yaml + * pnpm exec powersync some-cloud-cmd --environment=staging * # Override with env * INSTANCE_ID=... pnpm exec powersync some-cloud-cmd * # Override with flags @@ -56,10 +64,11 @@ export type CloudInstanceCommandFlags = Interfaces.InferredFlags< 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). + * Instance ID, org ID, and project ID are resolved in order: flags → cli.yaml (selected environment, then top-level fields) → INSTANCE_ID. * Missing org/project IDs are resolved from the Cloud instance. */ ...InstanceCommand.baseFlags, + environment: createEnvironmentFlag(['instance-id']), 'instance-id': Flags.string({ description: 'PowerSync Cloud instance ID. Manually passed if the current context has not been linked.', helpGroup: HelpGroup.CLOUD_PROJECT, @@ -149,59 +158,58 @@ export abstract class CloudInstanceCommand extends InstanceCommand { const linkPath = join(projectDir, CLI_FILENAME); - let linked: null | ResolvedCloudCLIConfig = null; - let rawLink: null | Record = null; - + let cliConfig: CloudCLIConfig | null = null; if (existsSync(linkPath)) { try { - const doc = parseYamlFile(linkPath); - rawLink = doc.contents?.toJSON() as Record; + cliConfig = CloudCLIConfig.decode(parseYamlFile(linkPath).contents?.toJSON()); } catch (error) { - this.styledError({ - error, - message: `Failed to parse ${CLI_FILENAME} as CloudCLIConfig` - }); + this.styledError({ error, message: `Failed to parse ${CLI_FILENAME} as CloudCLIConfig` }); } } - // 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 = 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'); + const instanceIdFlag = flags['instance-id']; + // --instance-id targets one instance directly, so a selected environment does not apply. + const environment = instanceIdFlag ? undefined : (flags.environment ?? env.POWERSYNC_ENVIRONMENT); - if (!instance_id) { - this.styledError({ message: LINK_MISSING_ERROR_MESSAGE }); - } + let target: CloudLinkTarget; + try { + target = selectCloudLinkTarget(cliConfig, environment); + } catch (error) { + this.styledError({ message: error instanceof Error ? error.message : String(error) }); + } - try { - linked = ResolvedCloudCLIConfig.decode( - await resolveCloudInstanceLink({ - client: this.client, - instanceId: instance_id, - orgId: org_id, - projectId: project_id - }) - ); - } catch (error) { - this.styledError({ error, message: LINK_MISSING_ERROR_MESSAGE }); - } + const instance_id = instanceIdFlag ?? target.instance_id ?? env.INSTANCE_ID; + if (!instance_id) { + this.styledError({ message: LINK_MISSING_ERROR_MESSAGE }); } - if (!linked) { - this.styledError({ - message: - 'Linking is required before using this command. No linking information was found in the current context.' - }); + const linkField = (field: string) => + `${environment ? `environments.${environment}.${field}` : field} in ${CLI_FILENAME}`; + this.ensureObjectIdIfPresent( + instance_id, + instanceIdFlag ? '--instance-id' : target.instance_id ? linkField('instance_id') : 'INSTANCE_ID' + ); + this.ensureObjectIdIfPresent(target.org_id, linkField('org_id')); + this.ensureObjectIdIfPresent(target.project_id, linkField('project_id')); + + let linked: ResolvedCloudCLIConfig; + try { + linked = ResolvedCloudCLIConfig.decode( + await resolveCloudInstanceLink({ + client: this.client, + instanceId: instance_id, + orgId: target.org_id, + projectId: target.project_id + }) + ); + } catch (error) { + this.styledError({ error, message: LINK_MISSING_ERROR_MESSAGE }); } const syncRulesContent = resolveSyncRulesContent({ projectDirectory: projectDir }); this._project = await this._loadProjectHook(flags, { + environment: target.environment, linked, projectDirectory: projectDir, syncRulesContent @@ -237,18 +245,9 @@ export abstract class CloudInstanceCommand extends InstanceCommand { return this.serviceConfig; } - private ensureObjectIdIfPresent( - value: string | undefined, - flagName: '--instance-id' | '--org-id' | '--project-id' - ): void { - if (value == null) { - return; - } - - if (!OBJECT_ID_REGEX.test(value)) { - this.styledError({ - message: `Invalid ${flagName} "${value}". Expected a BSON ObjectID (24 hex characters).` - }); + private ensureObjectIdIfPresent(value: string | undefined, label: string): void { + if (value != null && !OBJECT_ID_REGEX.test(value)) { + this.styledError({ message: `Invalid ${label} "${value}". Expected a BSON ObjectID (24 hex characters).` }); } } } diff --git a/packages/cli-core/src/command-types/SharedInstanceCommand.ts b/packages/cli-core/src/command-types/SharedInstanceCommand.ts index 6871faf..2fcb4c7 100644 --- a/packages/cli-core/src/command-types/SharedInstanceCommand.ts +++ b/packages/cli-core/src/command-types/SharedInstanceCommand.ts @@ -17,6 +17,7 @@ import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { createCloudClient } from '../clients/create-cloud-client.js'; +import { createEnvironmentFlag } from '../utils/create-environment-flag.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'; @@ -24,6 +25,7 @@ import { logTargetInstance } from '../utils/log-target-instance.js'; import { CLI_FILENAME, SERVICE_FILENAME } from '../utils/project-config.js'; import { resolveCloudInstanceLink } from '../utils/resolve-cloud-instance-link.js'; import { resolveSyncRulesContent } from '../utils/resolve-sync-rules-content.js'; +import { CloudLinkTarget, selectCloudLinkTarget } from '../utils/select-cloud-link-target.js'; import { parseYamlFile } from '../utils/yaml.js'; import { CloudProject } from './CloudInstanceCommand.js'; import { CommandHelpGroup, HelpGroup } from './HelpGroup.js'; @@ -45,7 +47,7 @@ export type SharedInstanceCommandFlags = Interfaces.InferredFlags< * - Then from environment variables. * * 2. **Per-field values** (instance_id, org_id, project_id for cloud; api_url, api_key for self-hosted): - * - Cloud: flags → cli.yaml → env. + * - Cloud: flags → cli.yaml (environment selected with --environment or POWERSYNC_ENVIRONMENT, then top-level fields) → env. * - Self-hosted: api_url from flag → cli.yaml → env; api_key from env → cli.yaml only (no flag). * * @example @@ -68,6 +70,7 @@ export abstract class SharedInstanceCommand extends InstanceCommand { helpGroup: HelpGroup.SELF_HOSTED_PROJECT, required: false }), + environment: createEnvironmentFlag(['api-url', 'instance-id']), 'instance-id': Flags.string({ 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.', @@ -135,7 +138,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']; + const hasCloudFlagInputs = flags['instance-id'] ?? flags.environment; const hasSelfHostedFlagInputs = flags['api-url']; if (hasCloudFlagInputs && hasSelfHostedFlagInputs) { @@ -166,7 +169,7 @@ export abstract class SharedInstanceCommand extends InstanceCommand { // If type still not set, use env inputs. if (!projectType) { - const hasCloudEnvInputs = env.INSTANCE_ID; + const hasCloudEnvInputs = env.INSTANCE_ID ?? env.POWERSYNC_ENVIRONMENT; const hasSelfHostedEnvInputs = env.API_URL; if (hasCloudEnvInputs && hasSelfHostedEnvInputs) { @@ -183,6 +186,7 @@ export abstract class SharedInstanceCommand extends InstanceCommand { // 2) Per-field: flags → link file → env (see class JSDoc). let cliConfig: null | ResolvedCloudCLIConfig | ResolvedSelfHostedCLIConfig = null; + let target: CloudLinkTarget | undefined; if (projectType === 'self-hosted') { const _rawSelfHostedCLIConfig = (rawCLIConfig as SelfHostedCLIConfig) ?? { type: 'self-hosted' }; try { @@ -195,8 +199,15 @@ export abstract class SharedInstanceCommand extends InstanceCommand { this.styledError({ error, message: LINK_MISSING_ERROR_MESSAGE }); } } else { - const _rawCloudCLIConfig = (rawCLIConfig as CloudCLIConfig) ?? { type: 'cloud' }; - const instanceId = flags['instance-id'] ?? _rawCloudCLIConfig.instance_id ?? env.INSTANCE_ID; + // --instance-id targets one instance directly, so a selected environment does not apply. + const environment = flags['instance-id'] ? undefined : (flags.environment ?? env.POWERSYNC_ENVIRONMENT); + try { + target = selectCloudLinkTarget(rawCLIConfig as CloudCLIConfig | null, environment); + } catch (error) { + this.styledError({ message: error instanceof Error ? error.message : String(error) }); + } + + const instanceId = flags['instance-id'] ?? target.instance_id ?? env.INSTANCE_ID; if (!instanceId) { this.styledError({ message: LINK_MISSING_ERROR_MESSAGE }); } @@ -207,8 +218,8 @@ export abstract class SharedInstanceCommand extends InstanceCommand { 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 + orgId: target.org_id, + projectId: target.project_id }) ); } catch (error) { @@ -239,6 +250,7 @@ export abstract class SharedInstanceCommand extends InstanceCommand { if (projectType === ServiceType.CLOUD) { return this._loadProjectHook(flags, { + environment: target?.environment, linked: cliConfig as ResolvedCloudCLIConfig, projectDirectory: projectDir, syncRulesContent diff --git a/packages/cli-core/src/index.ts b/packages/cli-core/src/index.ts index ffd991b..5c44cf1 100644 --- a/packages/cli-core/src/index.ts +++ b/packages/cli-core/src/index.ts @@ -19,6 +19,7 @@ export * from './services/authentication/AuthenticationServiceImpl.js'; export * from './services/Services.js'; export * from './services/storage/StorageImp.js'; export * from './services/storage/StorageService.js'; +export * from './utils/create-environment-flag.js'; export * from './utils/ensure-service-type.js'; export * from './utils/env.js'; export * from './utils/log-target-instance.js'; @@ -26,5 +27,6 @@ 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/select-cloud-link-target.js'; export * from './utils/sync-config-file-path-flags.js'; export * from './utils/yaml.js'; diff --git a/packages/cli-core/src/utils/create-environment-flag.ts b/packages/cli-core/src/utils/create-environment-flag.ts new file mode 100644 index 0000000..e6c10cd --- /dev/null +++ b/packages/cli-core/src/utils/create-environment-flag.ts @@ -0,0 +1,13 @@ +import { Flags } from '@oclif/core'; + +import { HelpGroup } from '../command-types/HelpGroup.js'; +import { CLI_FILENAME } from './project-config.js'; + +export function createEnvironmentFlag(exclusive: string[]) { + return Flags.string({ + description: `[Cloud] Name of an environment defined in ${CLI_FILENAME} to run against. Resolved: flag → POWERSYNC_ENVIRONMENT.`, + exclusive, + helpGroup: HelpGroup.CLOUD_PROJECT, + required: false + }); +} diff --git a/packages/cli-core/src/utils/env.ts b/packages/cli-core/src/utils/env.ts index e49cc3a..1f8346a 100644 --- a/packages/cli-core/src/utils/env.ts +++ b/packages/cli-core/src/utils/env.ts @@ -9,6 +9,7 @@ export type ENV = { API_URL?: string; INSTANCE_ID?: string; ORG_ID?: string; + POWERSYNC_ENVIRONMENT?: string; PROJECT_ID?: string; PS_ADMIN_TOKEN?: string; }; @@ -20,6 +21,7 @@ export const env: ENV = { API_URL: process.env.API_URL, INSTANCE_ID: process.env.INSTANCE_ID, ORG_ID: process.env.ORG_ID, + POWERSYNC_ENVIRONMENT: process.env.POWERSYNC_ENVIRONMENT, PROJECT_ID: process.env.PROJECT_ID, PS_ADMIN_TOKEN: process.env.PS_ADMIN_TOKEN }; diff --git a/packages/cli-core/src/utils/log-target-instance.ts b/packages/cli-core/src/utils/log-target-instance.ts index 053a592..19d4681 100644 --- a/packages/cli-core/src/utils/log-target-instance.ts +++ b/packages/cli-core/src/utils/log-target-instance.ts @@ -42,7 +42,9 @@ export async function logTargetInstance(params: LogTargetInstanceParams): Promis const nameLabel = instanceName == null ? ux.colorize('yellow', '(name unavailable)') : ux.colorize('blue', instanceName); - command.log(`Target instance: ${nameLabel} ${ux.colorize('gray', `id: ${linked.instance_id}`)}`); + const environment = 'environment' in project ? project.environment : undefined; + const environmentLabel = environment ? ` ${ux.colorize('gray', `environment: ${environment}`)}` : ''; + command.log(`Target instance: ${nameLabel} ${ux.colorize('gray', `id: ${linked.instance_id}`)}${environmentLabel}`); command.log( `\t${ux.colorize('gray', `project: ${linked.project_id}`)} ${ux.colorize('gray', `org: ${linked.org_id}`)}` ); diff --git a/packages/cli-core/src/utils/select-cloud-link-target.ts b/packages/cli-core/src/utils/select-cloud-link-target.ts new file mode 100644 index 0000000..ffdbe4c --- /dev/null +++ b/packages/cli-core/src/utils/select-cloud-link-target.ts @@ -0,0 +1,28 @@ +import { CloudCLIConfig, CloudEnvironmentConfig } from '@powersync/cli-schemas'; + +import { CLI_FILENAME } from './project-config.js'; + +export type CloudLinkTarget = Partial & { environment?: string }; + +/** + * Picks the link fields a Cloud command should use: the named environment when one is selected, + * otherwise the top-level fields of cli.yaml. + */ +export function selectCloudLinkTarget(cliConfig: CloudCLIConfig | null, environment?: string): CloudLinkTarget { + if (environment == null) { + return { instance_id: cliConfig?.instance_id, org_id: cliConfig?.org_id, project_id: cliConfig?.project_id }; + } + + const environments = cliConfig?.environments ?? {}; + const target = environments[environment]; + if (!target) { + const available = Object.keys(environments); + throw new Error( + available.length > 0 + ? `Environment "${environment}" is not defined in ${CLI_FILENAME}. Available environments: ${available.join(', ')}.` + : `Environment "${environment}" is not defined in ${CLI_FILENAME}. Add it with: powersync link cloud --environment=${environment} --instance-id=` + ); + } + + return { ...target, environment }; +} diff --git a/packages/schemas/src/CLIConfig.ts b/packages/schemas/src/CLIConfig.ts index d18ea10..87e12c0 100644 --- a/packages/schemas/src/CLIConfig.ts +++ b/packages/schemas/src/CLIConfig.ts @@ -1,6 +1,16 @@ import * as t from 'ts-codec'; +export const CloudEnvironmentConfig = t.object({ + instance_id: t.string, + org_id: t.string.optional(), + project_id: t.string.optional() +}); + +export type CloudEnvironmentConfig = t.Encoded; + export const CloudCLIConfig = t.object({ + /** Named targets selected with --environment or POWERSYNC_ENVIRONMENT. The top-level fields stay the default target. */ + environments: t.record(CloudEnvironmentConfig).optional(), instance_id: t.string.optional(), org_id: t.string.optional(), project_id: t.string.optional(), From fa95b59a3324bce9b2a023e1fec29735872eea0c Mon Sep 17 00:00:00 2001 From: bean1352 Date: Wed, 2 Sep 2026 13:48:51 +0200 Subject: [PATCH 03/12] link cloud --environment, fetch instances output and docs --- .changeset/cli-environments.md | 7 +++++++ cli/README.md | 3 ++- cli/src/api/cloud/write-cloud-link.ts | 15 ++++++++------ cli/src/commands/fetch/instances.ts | 5 +++++ cli/src/commands/link/cloud.ts | 25 ++++++++++++++++++----- cli/test/commands/link.test.ts | 29 +++++++++++++++++++++++++++ docs/usage.md | 28 ++++++++++++++++++++++++-- 7 files changed, 98 insertions(+), 14 deletions(-) create mode 100644 .changeset/cli-environments.md diff --git a/.changeset/cli-environments.md b/.changeset/cli-environments.md new file mode 100644 index 0000000..6a326b5 --- /dev/null +++ b/.changeset/cli-environments.md @@ -0,0 +1,7 @@ +--- +'@powersync/cli-schemas': minor +'@powersync/cli-core': minor +'powersync': minor +--- + +Added named environments to `cli.yaml`. Link several Cloud instances from one project directory with `powersync link cloud --environment= --instance-id=`, then pick one per command with `--environment=` or the `POWERSYNC_ENVIRONMENT` variable. The top-level `instance_id`, `org_id` and `project_id` fields remain the default target. diff --git a/cli/README.md b/cli/README.md index 9d6db21..669d84f 100644 --- a/cli/README.md +++ b/cli/README.md @@ -154,7 +154,7 @@ export INSTANCE_ID= powersync generate schema --output-path=schema.ts --output=ts ``` -**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. +**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. To work with several instances from one directory, link each one as a named environment with **`powersync link cloud --environment= --instance-id=`** and select it with `--environment=` or `POWERSYNC_ENVIRONMENT`. # Self-hosted @@ -253,6 +253,7 @@ You can supply instance and auth context via environment variables (useful for C - **`PS_ADMIN_TOKEN`** — PowerSync personal access token for Cloud commands. [Learn more](https://docs.powersync.com/usage/tools/cli#personal-access-token). - **`INSTANCE_ID`** — Instance ID (Cloud). Get IDs from the [PowerSync Dashboard](https://dashboard.powersync.com) or **`powersync fetch instances`**. +- **`POWERSYNC_ENVIRONMENT`** — Name of an environment defined in `cli.yaml` (Cloud). Same as passing `--environment`. - **`API_URL`** — Self-hosted PowerSync API base URL (e.g. `https://powersync.example.com`). Example (Cloud): diff --git a/cli/src/api/cloud/write-cloud-link.ts b/cli/src/api/cloud/write-cloud-link.ts index 8c6e91f..a1c608e 100644 --- a/cli/src/api/cloud/write-cloud-link.ts +++ b/cli/src/api/cloud/write-cloud-link.ts @@ -4,26 +4,29 @@ import { join } from 'node:path'; import { Document } from 'yaml'; export type WriteCloudLinkOptions = { + /** Store the link under environments. instead of the top-level fields. */ + environment?: string; instanceId: string; orgId: string; projectId: string; }; /** - * Writes or updates cli.yaml with Cloud instance link (type: cloud, instance_id, org_id, project_id). - * Creates a new file if it does not exist. + * Writes or updates cli.yaml with a Cloud instance link (type: cloud, instance_id, org_id, project_id), + * either at the top level or under a named environment. Creates a new file if it does not exist. */ export function writeCloudLink(projectDir: string, options: WriteCloudLinkOptions): void { - const { instanceId, orgId, projectId } = options; + const { environment, instanceId, orgId, projectId } = options; const linkPath = join(projectDir, CLI_FILENAME); if (!existsSync(projectDir)) { mkdirSync(projectDir, { recursive: true }); } const doc = existsSync(linkPath) ? parseYamlFile(linkPath) : new Document(); + const path = environment ? ['environments', environment] : []; doc.set('type', 'cloud'); - doc.set('instance_id', instanceId); - doc.set('org_id', orgId); - doc.set('project_id', projectId); + doc.setIn([...path, 'instance_id'], instanceId); + doc.setIn([...path, 'org_id'], orgId); + doc.setIn([...path, 'project_id'], projectId); writeFileSync(linkPath, doc.toString(), 'utf8'); } diff --git a/cli/src/commands/fetch/instances.ts b/cli/src/commands/fetch/instances.ts index a4c329b..169fa21 100644 --- a/cli/src/commands/fetch/instances.ts +++ b/cli/src/commands/fetch/instances.ts @@ -212,6 +212,11 @@ export default class FetchInstances extends Command { if (linked.config.type === 'cloud') { this.log(`\t${ux.colorize('blue', 'Project ID: ')} ${linked.config.project_id}`); this.log(`\t${ux.colorize('blue', 'Instance ID: ')} ${linked.config.instance_id}`); + for (const [name, environment] of Object.entries(linked.config.environments ?? {})) { + this.log( + `\t${ux.colorize('blue', `Environment ${name}: `)} ${ux.colorize('gray', `instance_id: ${environment.instance_id}`)}` + ); + } } else if (linked.config.type === 'self-hosted') { this.log(`\t${ux.colorize('blue', 'API URL: ')} ${linked.config.api_url}`); } diff --git a/cli/src/commands/link/cloud.ts b/cli/src/commands/link/cloud.ts index be3acc8..d3b092f 100644 --- a/cli/src/commands/link/cloud.ts +++ b/cli/src/commands/link/cloud.ts @@ -18,9 +18,10 @@ 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. Use --create to create a new instance from service.yaml name/region and link it; omit --instance-id when using --create.'; + '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. Use --environment to store the link as a named environment, selected later with --environment or POWERSYNC_ENVIRONMENT.'; static examples = [ '<%= config.bin %> <%= command.id %> --instance-id=', + '<%= config.bin %> <%= command.id %> --environment=staging --instance-id=', '<%= config.bin %> <%= command.id %> --create --project-id=', '<%= config.bin %> <%= command.id %> --create --project-id= --org-id=' ]; @@ -30,6 +31,10 @@ export default class LinkCloud extends CloudInstanceCommand { description: 'Create a new Cloud instance in the given org and project, then link. Do not supply --instance-id when using --create.' }), + environment: Flags.string({ + description: `Store the link under environments. in ${CLI_FILENAME} instead of the top-level fields. Select it later with --environment or POWERSYNC_ENVIRONMENT.`, + required: false + }), 'instance-id': Flags.string({ default: env.INSTANCE_ID, description: 'PowerSync Cloud instance ID. Omit when using --create. Resolved: flag → INSTANCE_ID.', @@ -50,7 +55,8 @@ export default class LinkCloud extends CloudInstanceCommand { async run(): Promise { const { flags } = await this.parse(LinkCloud); - let { create, directory, 'instance-id': instanceId, 'org-id': orgId, 'project-id': projectId } = flags; + let { create, directory, environment, 'instance-id': instanceId, 'org-id': orgId, 'project-id': projectId } = flags; + const linkLabel = environment ? ` (environment "${environment}")` : ''; const projectDirectory = this.resolveProjectDir(flags); ensureServiceTypeMatches({ @@ -100,9 +106,17 @@ export default class LinkCloud extends CloudInstanceCommand { this.styledError({ error, message: 'Failed to create Cloud instance' }); } - writeCloudLink(projectDirectory, { instanceId: newInstanceId, orgId: orgId!, projectId: projectId! }); + writeCloudLink(projectDirectory, { + environment, + instanceId: newInstanceId, + orgId: orgId!, + projectId: projectId! + }); this.log( - ux.colorize('green', `Created Cloud instance ${newInstanceId} and updated ${directory}/${CLI_FILENAME}.`) + ux.colorize( + 'green', + `Created Cloud instance ${newInstanceId} and updated ${directory}/${CLI_FILENAME}${linkLabel}.` + ) ); return; } @@ -132,10 +146,11 @@ export default class LinkCloud extends CloudInstanceCommand { } writeCloudLink(projectDirectory, { + environment, 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.`)); + this.log(ux.colorize('green', `Updated ${directory}/${CLI_FILENAME} with Cloud instance link${linkLabel}.`)); } } diff --git a/cli/test/commands/link.test.ts b/cli/test/commands/link.test.ts index 16b452d..71b5cd8 100644 --- a/cli/test/commands/link.test.ts +++ b/cli/test/commands/link.test.ts @@ -151,6 +151,35 @@ describe('link', () => { expect(linkYaml.project_id).toBe(PROJECT_ID); }); + it('stores the link under a named environment with --environment', async () => { + const projectDir = join(tmpDir, PROJECT_DIR); + mkdirSync(projectDir, { recursive: true }); + writeServiceYaml(projectDir, 'cloud'); + const linkPath = join(projectDir, CLI_FILENAME); + writeFileSync( + linkPath, + `type: cloud\ninstance_id: ${INSTANCE_ID}\norg_id: ${ORG_ID}\nproject_id: ${PROJECT_ID}\n`, + 'utf8' + ); + const stagingInstanceId = '690cf75c96a2ff4fd98b160b'; + + const { error, stdout } = await runLinkCloudDirect([ + '--environment=staging', + `--instance-id=${stagingInstanceId}`, + `--org-id=${ORG_ID}`, + `--project-id=${PROJECT_ID}` + ]); + expect(error).toBeUndefined(); + expect(stdout).toContain( + `Updated ${PROJECT_DIR}/${CLI_FILENAME} with Cloud instance link (environment "staging").` + ); + const linkYaml = parseYaml(readFileSync(linkPath, 'utf8')); + expect(linkYaml.instance_id).toBe(INSTANCE_ID); + expect(linkYaml.environments).toEqual({ + staging: { instance_id: stagingInstanceId, org_id: ORG_ID, project_id: PROJECT_ID } + }); + }); + it('creates and links cloud instance when directory exists and --create is used', async () => { const projectDir = join(tmpDir, PROJECT_DIR); mkdirSync(projectDir, { recursive: true }); diff --git a/docs/usage.md b/docs/usage.md index 51cce65..529c7c3 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -43,6 +43,30 @@ powersync deploy service-config --directory=powersync # service.yaml only (kee powersync deploy sync-config --directory=powersync # sync-config.yaml only ``` +**Named environments in one `cli.yaml`** + +Keep one config directory and list each instance under `environments` in `cli.yaml`. Link them with `powersync link cloud --environment= --instance-id=`, then pick one per command with `--environment` or the `POWERSYNC_ENVIRONMENT` variable. The top-level fields stay the default target when no environment is selected. + +```yaml +type: cloud +instance_id: # default target +org_id: +project_id: +environments: + staging: + instance_id: + org_id: + project_id: +``` + +```bash +powersync link cloud --environment=staging --instance-id= +powersync deploy --environment=staging +POWERSYNC_ENVIRONMENT=staging powersync deploy sync-config # same thing, for CI +``` + +Cloud commands print the target instance name and IDs, plus the selected environment, before making changes. Note that deploy writes the `name` from `service.yaml` to the instance, so instances deployed from one `service.yaml` end up with the same name in the dashboard. + **Alternate sync config file** These commands accept **`--sync-config-file-path=/path/to/sync.yaml`** instead of **`sync-config.yaml`** in the project directory: **`powersync deploy`**, **`powersync deploy sync-config`**, **`powersync validate`**, **`powersync generate schema`**. Other commands (e.g. **`deploy service-config`**, **`generate token`**, **`destroy`**, **`status`**) do not expose this flag. @@ -226,11 +250,11 @@ If you decline this prompt, login exits without storing a token. Use `PS_ADMIN_T 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 the link file, and the link file overrides environment variables). For Cloud commands, the org and project are resolved automatically from the instance. 1. **Flags** - - **Cloud:** `--instance-id` + - **Cloud:** `--instance-id`, or `--environment` to pick a named environment from `cli.yaml` - **Self-hosted:** `--api-url` only (API key from env or link file only) 2. **cli.yaml** — a `powersync/cli.yaml` file in the project (written by `powersync link cloud` or `powersync link self-hosted`) 3. **Environment variables** - - **Cloud:** `INSTANCE_ID` + - **Cloud:** `INSTANCE_ID`, or `POWERSYNC_ENVIRONMENT` to pick a named environment from `cli.yaml` - **Self-hosted:** `API_URL`, `PS_ADMIN_TOKEN` (API key) --- From 7e0ca43a7f9504b473163aeb80a93fee92d2b8e3 Mon Sep 17 00:00:00 2001 From: bean1352 Date: Wed, 2 Sep 2026 13:55:29 +0200 Subject: [PATCH 04/12] suggest environments when no link is selected --- .gitignore | 3 +++ cli/test/command-types/resolution-order.test.ts | 5 +++++ .../cli-core/src/command-types/CloudInstanceCommand.ts | 4 ++-- .../cli-core/src/command-types/SharedInstanceCommand.ts | 7 ++++--- packages/cli-core/src/utils/select-cloud-link-target.ts | 8 ++++++++ 5 files changed, 22 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 502a4c8..80e1b63 100644 --- a/.gitignore +++ b/.gitignore @@ -150,3 +150,6 @@ playground/ # IDE .idea + +# Powersync +powersync/ diff --git a/cli/test/command-types/resolution-order.test.ts b/cli/test/command-types/resolution-order.test.ts index 932d35c..2fd0ada 100644 --- a/cli/test/command-types/resolution-order.test.ts +++ b/cli/test/command-types/resolution-order.test.ts @@ -216,6 +216,11 @@ describe('instance resolution order', () => { expect(unknown.error?.message).toContain('Environment "production" is not defined in cli.yaml'); expect(unknown.error?.message).toContain('staging'); + // No default link and no selection: point at the environments that do exist + const unselected = await runDestroyDirect(['--confirm=yes']); + expect(unselected.error?.message).toContain('Linking is required'); + expect(unselected.error?.suggestions?.[0]).toContain('--environment or POWERSYNC_ENVIRONMENT: staging'); + const exclusive = await runDestroyDirect([ '--confirm=yes', '--environment=staging', diff --git a/packages/cli-core/src/command-types/CloudInstanceCommand.ts b/packages/cli-core/src/command-types/CloudInstanceCommand.ts index febf9fc..d418a13 100644 --- a/packages/cli-core/src/command-types/CloudInstanceCommand.ts +++ b/packages/cli-core/src/command-types/CloudInstanceCommand.ts @@ -20,7 +20,7 @@ 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 { CloudLinkTarget, selectCloudLinkTarget } from '../utils/select-cloud-link-target.js'; +import { CloudLinkTarget, selectCloudLinkTarget, suggestEnvironments } from '../utils/select-cloud-link-target.js'; import { parseYamlFile } from '../utils/yaml.js'; import { CommandHelpGroup, HelpGroup } from './HelpGroup.js'; import { DEFAULT_ENSURE_CONFIG_OPTIONS, EnsureConfigOptions, InstanceCommand } from './InstanceCommand.js'; @@ -180,7 +180,7 @@ export abstract class CloudInstanceCommand extends InstanceCommand { const instance_id = instanceIdFlag ?? target.instance_id ?? env.INSTANCE_ID; if (!instance_id) { - this.styledError({ message: LINK_MISSING_ERROR_MESSAGE }); + this.styledError({ message: LINK_MISSING_ERROR_MESSAGE, suggestions: suggestEnvironments(cliConfig) }); } const linkField = (field: string) => diff --git a/packages/cli-core/src/command-types/SharedInstanceCommand.ts b/packages/cli-core/src/command-types/SharedInstanceCommand.ts index 2fcb4c7..4333057 100644 --- a/packages/cli-core/src/command-types/SharedInstanceCommand.ts +++ b/packages/cli-core/src/command-types/SharedInstanceCommand.ts @@ -25,7 +25,7 @@ import { logTargetInstance } from '../utils/log-target-instance.js'; import { CLI_FILENAME, SERVICE_FILENAME } from '../utils/project-config.js'; import { resolveCloudInstanceLink } from '../utils/resolve-cloud-instance-link.js'; import { resolveSyncRulesContent } from '../utils/resolve-sync-rules-content.js'; -import { CloudLinkTarget, selectCloudLinkTarget } from '../utils/select-cloud-link-target.js'; +import { CloudLinkTarget, selectCloudLinkTarget, suggestEnvironments } from '../utils/select-cloud-link-target.js'; import { parseYamlFile } from '../utils/yaml.js'; import { CloudProject } from './CloudInstanceCommand.js'; import { CommandHelpGroup, HelpGroup } from './HelpGroup.js'; @@ -201,15 +201,16 @@ export abstract class SharedInstanceCommand extends InstanceCommand { } else { // --instance-id targets one instance directly, so a selected environment does not apply. const environment = flags['instance-id'] ? undefined : (flags.environment ?? env.POWERSYNC_ENVIRONMENT); + const rawCloudCLIConfig = rawCLIConfig as CloudCLIConfig | null; try { - target = selectCloudLinkTarget(rawCLIConfig as CloudCLIConfig | null, environment); + target = selectCloudLinkTarget(rawCloudCLIConfig, environment); } catch (error) { this.styledError({ message: error instanceof Error ? error.message : String(error) }); } const instanceId = flags['instance-id'] ?? target.instance_id ?? env.INSTANCE_ID; if (!instanceId) { - this.styledError({ message: LINK_MISSING_ERROR_MESSAGE }); + this.styledError({ message: LINK_MISSING_ERROR_MESSAGE, suggestions: suggestEnvironments(rawCloudCLIConfig) }); } try { diff --git a/packages/cli-core/src/utils/select-cloud-link-target.ts b/packages/cli-core/src/utils/select-cloud-link-target.ts index ffdbe4c..4849ed2 100644 --- a/packages/cli-core/src/utils/select-cloud-link-target.ts +++ b/packages/cli-core/src/utils/select-cloud-link-target.ts @@ -26,3 +26,11 @@ export function selectCloudLinkTarget(cliConfig: CloudCLIConfig | null, environm return { ...target, environment }; } + +/** Suggestion for a missing link when cli.yaml defines environments but none was selected. */ +export function suggestEnvironments(cliConfig: CloudCLIConfig | null): string[] { + const names = Object.keys(cliConfig?.environments ?? {}); + return names.length > 0 + ? [`Select an environment from ${CLI_FILENAME} with --environment or POWERSYNC_ENVIRONMENT: ${names.join(', ')}.`] + : []; +} From ade74751d3adcbaa6d17fbe1700a1e4d9ed5bc9c Mon Sep 17 00:00:00 2001 From: bean1352 Date: Wed, 2 Sep 2026 13:58:25 +0200 Subject: [PATCH 05/12] note older CLI versions ignore environments in docs --- docs/usage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/usage.md b/docs/usage.md index 529c7c3..b55d447 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -65,7 +65,7 @@ powersync deploy --environment=staging POWERSYNC_ENVIRONMENT=staging powersync deploy sync-config # same thing, for CI ``` -Cloud commands print the target instance name and IDs, plus the selected environment, before making changes. Note that deploy writes the `name` from `service.yaml` to the instance, so instances deployed from one `service.yaml` end up with the same name in the dashboard. +Older CLI versions ignore `environments` and `POWERSYNC_ENVIRONMENT` and use the top-level fields, so pin the CLI version in CI jobs that rely on them. Cloud commands print the target instance name and IDs, plus the selected environment, before making changes. Note that deploy writes the `name` from `service.yaml` to the instance, so instances deployed from one `service.yaml` end up with the same name in the dashboard. **Alternate sync config file** From 5d6c0b84efc93da044bbeb3b32db4f1abb03f1c8 Mon Sep 17 00:00:00 2001 From: bean1352 Date: Wed, 2 Sep 2026 14:21:22 +0200 Subject: [PATCH 06/12] harden environment resolution and add coverage --- cli/src/commands/fetch/instances.ts | 7 +- .../command-types/resolution-order.test.ts | 73 +++++++++++++++++++ cli/test/commands/link.test.ts | 15 ++++ .../command-types/SharedInstanceCommand.ts | 6 +- .../src/utils/select-cloud-link-target.ts | 2 +- 5 files changed, 95 insertions(+), 8 deletions(-) diff --git a/cli/src/commands/fetch/instances.ts b/cli/src/commands/fetch/instances.ts index 169fa21..a02acb2 100644 --- a/cli/src/commands/fetch/instances.ts +++ b/cli/src/commands/fetch/instances.ts @@ -210,8 +210,11 @@ export default class FetchInstances extends Command { this.log(`Locally linked in ./${linked.subDirectory}/`); this.log(`\t${ux.colorize('blue', 'Project type: ')} ${linked.config.type}`); if (linked.config.type === 'cloud') { - this.log(`\t${ux.colorize('blue', 'Project ID: ')} ${linked.config.project_id}`); - this.log(`\t${ux.colorize('blue', 'Instance ID: ')} ${linked.config.instance_id}`); + if (linked.config.instance_id) { + this.log(`\t${ux.colorize('blue', 'Project ID: ')} ${linked.config.project_id}`); + this.log(`\t${ux.colorize('blue', 'Instance ID: ')} ${linked.config.instance_id}`); + } + for (const [name, environment] of Object.entries(linked.config.environments ?? {})) { this.log( `\t${ux.colorize('blue', `Environment ${name}: `)} ${ux.colorize('gray', `instance_id: ${environment.instance_id}`)}` diff --git a/cli/test/command-types/resolution-order.test.ts b/cli/test/command-types/resolution-order.test.ts index 2fd0ada..1f7c872 100644 --- a/cli/test/command-types/resolution-order.test.ts +++ b/cli/test/command-types/resolution-order.test.ts @@ -300,6 +300,79 @@ describe('instance resolution order', () => { const fromEnv = await loadProjectSpy.mock.results[1]!.value; expect(fromEnv.environment).toBe('staging'); expect(fromEnv.linked.instance_id).toBe(IDS.env.instance); + + env.POWERSYNC_ENVIRONMENT = undefined; + const unselected = await runFetchStatusDirect(['--output=json']); + expect(unselected.error?.message).toContain('Linking is required'); + expect(unselected.error?.suggestions?.[0]).toContain('--environment or POWERSYNC_ENVIRONMENT: staging'); + }); + + it('SharedInstanceCommand lets --instance-id pick the cloud context over a self-hosted cli.yaml', async () => { + 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'); + mkdirSync(projectDir, { recursive: true }); + writeFileSync( + join(projectDir, 'cli.yaml'), + ['type: self-hosted', 'api_url: https://cli.example.com', 'api_key: cli-key', ''].join('\n'), + 'utf8' + ); + + 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}`]); + const project = await loadProjectSpy.mock.results[0]!.value; + expect(project.linked.type).toBe('cloud'); + expect(project.linked.instance_id).toBe(IDS.flag.instance); + }); + + it('accepts a cli.yaml written by older CLI versions (no environments key)', async () => { + 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'); + mkdirSync(projectDir, { recursive: true }); + writeFileSync(join(projectDir, 'service.yaml'), '_type: cloud\n', 'utf8'); + writeFileSync( + join(projectDir, 'cli.yaml'), + [ + '# yaml-language-server: $schema=https://unpkg.com/@powersync/cli-schemas@latest/json-schema/cli-config.json', + 'type: cloud', + `instance_id: ${IDS.cli.instance}`, + `org_id: ${IDS.cli.org}`, + `project_id: ${IDS.cli.project}`, + '' + ].join('\n'), + 'utf8' + ); + + const cloudSpy = vi.spyOn(CloudInstanceCommand.prototype, 'loadProject'); + await runDestroyDirect(['--confirm=yes']); + const cloudProject = await cloudSpy.mock.results[0]!.value; + expect(cloudProject.environment).toBeUndefined(); + expect(cloudProject.linked).toEqual({ + instance_id: IDS.cli.instance, + org_id: IDS.cli.org, + project_id: IDS.cli.project, + type: 'cloud' + }); + + const sharedSpy = vi.spyOn(SharedInstanceCommand.prototype, 'loadProject'); + vi.spyOn(FetchStatusCommand.prototype, 'getCloudStatus').mockRejectedValue(new Error('expected-test-failure')); + await runFetchStatusDirect(['--output=json']); + const sharedProject = await sharedSpy.mock.results[0]!.value; + expect(sharedProject.environment).toBeUndefined(); + expect(sharedProject.linked.instance_id).toBe(IDS.cli.instance); + + // Selecting an environment on such a file explains how to add one + const { error } = await runDestroyDirect(['--confirm=yes', '--environment=staging']); + expect(error?.message).toContain( + 'Environment "staging" is not defined in cli.yaml. Add it with: powersync link cloud --environment=staging' + ); }); it('SharedInstanceCommand resolves cloud instance_id as flag → cli.yaml → env; org/project from cli.yaml or API', async () => { diff --git a/cli/test/commands/link.test.ts b/cli/test/commands/link.test.ts index 71b5cd8..69fadcc 100644 --- a/cli/test/commands/link.test.ts +++ b/cli/test/commands/link.test.ts @@ -151,6 +151,21 @@ describe('link', () => { expect(linkYaml.project_id).toBe(PROJECT_ID); }); + it('creates cli.yaml with only a named environment when --environment is used in a new directory', async () => { + const { error } = await runLinkCloudDirect([ + '--environment=staging', + `--instance-id=${INSTANCE_ID}`, + `--org-id=${ORG_ID}`, + `--project-id=${PROJECT_ID}` + ]); + expect(error).toBeUndefined(); + const linkYaml = parseYaml(readFileSync(join(tmpDir, PROJECT_DIR, CLI_FILENAME), 'utf8')); + expect(linkYaml).toEqual({ + environments: { staging: { instance_id: INSTANCE_ID, org_id: ORG_ID, project_id: PROJECT_ID } }, + type: 'cloud' + }); + }); + it('stores the link under a named environment with --environment', async () => { const projectDir = join(tmpDir, PROJECT_DIR); mkdirSync(projectDir, { recursive: true }); diff --git a/packages/cli-core/src/command-types/SharedInstanceCommand.ts b/packages/cli-core/src/command-types/SharedInstanceCommand.ts index 4333057..3e84ead 100644 --- a/packages/cli-core/src/command-types/SharedInstanceCommand.ts +++ b/packages/cli-core/src/command-types/SharedInstanceCommand.ts @@ -160,11 +160,7 @@ export abstract class SharedInstanceCommand extends InstanceCommand { this.styledError({ error, message: `Failed to parse ${CLI_FILENAME}` }); } - if (rawCLIConfig.type === 'self-hosted') { - projectType = ServiceType.SELF_HOSTED; - } else if (rawCLIConfig.type === 'cloud') { - projectType = ServiceType.CLOUD; - } + projectType ??= rawCLIConfig.type === 'self-hosted' ? ServiceType.SELF_HOSTED : ServiceType.CLOUD; } // If type still not set, use env inputs. diff --git a/packages/cli-core/src/utils/select-cloud-link-target.ts b/packages/cli-core/src/utils/select-cloud-link-target.ts index 4849ed2..2b05a8e 100644 --- a/packages/cli-core/src/utils/select-cloud-link-target.ts +++ b/packages/cli-core/src/utils/select-cloud-link-target.ts @@ -14,7 +14,7 @@ export function selectCloudLinkTarget(cliConfig: CloudCLIConfig | null, environm } const environments = cliConfig?.environments ?? {}; - const target = environments[environment]; + const target = Object.hasOwn(environments, environment) ? environments[environment] : undefined; if (!target) { const available = Object.keys(environments); throw new Error( From ebfd1a12f2d596aa455bf7b00ff295b042aabe91 Mon Sep 17 00:00:00 2001 From: bean1352 Date: Wed, 2 Sep 2026 14:23:13 +0200 Subject: [PATCH 07/12] update docs and regenerate readme for environments --- cli/README.md | 109 ++++++++++++++++++++++++++++++++++++-------------- docs/usage.md | 37 +++++++++++++++-- 2 files changed, 113 insertions(+), 33 deletions(-) diff --git a/cli/README.md b/cli/README.md index 669d84f..9749fe8 100644 --- a/cli/README.md +++ b/cli/README.md @@ -154,7 +154,19 @@ export INSTANCE_ID= powersync generate schema --output-path=schema.ts --output=ts ``` -**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. To work with several instances from one directory, link each one as a named environment with **`powersync link cloud --environment= --instance-id=`** and select it with `--environment=` or `POWERSYNC_ENVIRONMENT`. +**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. To work with several instances from one directory, see the next section. + +## Several instances from one directory + +Link each instance as a named environment, then pick one per command: + +```sh +powersync link cloud --environment=staging --instance-id= +powersync deploy --environment=staging +POWERSYNC_ENVIRONMENT=staging powersync deploy sync-config # for scripts and CI +``` + +Commands without an environment use the top-level link in `cli.yaml`. See [docs/usage.md](../docs/usage.md#configuring-multiple-instances-eg-dev-staging-production) for the full walkthrough. # Self-hosted @@ -375,7 +387,8 @@ _See code: [@oclif/plugin-commands](https://github.com/oclif/plugin-commands/blo ``` USAGE - $ powersync compact [--directory ] [--instance-id ] [--timeout ] + $ powersync compact [--directory ] [--environment | --instance-id ] [--timeout + ] FLAGS --timeout= [default: 30] Maximum time to wait for compaction to complete, in minutes. Use 0 to wait @@ -387,6 +400,8 @@ PROJECT FLAGS directory. CLOUD_PROJECT FLAGS + --environment= [Cloud] Name of an environment defined in cli.yaml to run against. Resolved: flag → + POWERSYNC_ENVIRONMENT. --instance-id= PowerSync Cloud instance ID. Manually passed if the current context has not been linked. DESCRIPTION @@ -430,8 +445,8 @@ _See code: [src/commands/configure/ide.ts](https://github.com/powersync-ja/power ``` USAGE - $ powersync deploy [--deploy-timeout ] [--dry-run] [--directory ] [--instance-id ] - [--sync-config-file-path ] [--skip-validations | --validate-only ] + $ powersync deploy [--deploy-timeout ] [--dry-run] [--directory ] [--environment | --instance-id + ] [--sync-config-file-path ] [--skip-validations | --validate-only ] FLAGS --deploy-timeout= [default: 300] Seconds to wait after scheduling a deploy before timing out while polling @@ -451,6 +466,8 @@ PROJECT FLAGS instead of sync-config.yaml in the project directory. CLOUD_PROJECT FLAGS + --environment= [Cloud] Name of an environment defined in cli.yaml to run against. Resolved: flag → + POWERSYNC_ENVIRONMENT. --instance-id= PowerSync Cloud instance ID. Manually passed if the current context has not been linked. DESCRIPTION @@ -478,8 +495,8 @@ _See code: [src/commands/deploy/index.ts](https://github.com/powersync-ja/powers ``` USAGE - $ powersync deploy service-config [--deploy-timeout ] [--dry-run] [--directory ] [--instance-id ] - [--skip-validations | --validate-only ] + $ powersync deploy service-config [--deploy-timeout ] [--dry-run] [--directory ] [--environment | --instance-id + ] [--skip-validations | --validate-only ] FLAGS --deploy-timeout= [default: 300] Seconds to wait after scheduling a deploy before timing out while polling @@ -497,6 +514,8 @@ PROJECT FLAGS directory. CLOUD_PROJECT FLAGS + --environment= [Cloud] Name of an environment defined in cli.yaml to run against. Resolved: flag → + POWERSYNC_ENVIRONMENT. --instance-id= PowerSync Cloud instance ID. Manually passed if the current context has not been linked. DESCRIPTION @@ -521,8 +540,8 @@ _See code: [src/commands/deploy/service-config.ts](https://github.com/powersync- ``` USAGE - $ powersync deploy sync-config [--deploy-timeout ] [--dry-run] [--directory ] [--instance-id ] - [--sync-config-file-path ] [--skip-validations | ] + $ powersync deploy sync-config [--deploy-timeout ] [--dry-run] [--directory ] [--environment | --instance-id + ] [--sync-config-file-path ] [--skip-validations | ] FLAGS --deploy-timeout= [default: 300] Seconds to wait after scheduling a deploy before timing out while polling @@ -540,6 +559,8 @@ PROJECT FLAGS instead of sync-config.yaml in the project directory. CLOUD_PROJECT FLAGS + --environment= [Cloud] Name of an environment defined in cli.yaml to run against. Resolved: flag → + POWERSYNC_ENVIRONMENT. --instance-id= PowerSync Cloud instance ID. Manually passed if the current context has not been linked. DESCRIPTION @@ -564,7 +585,7 @@ _See code: [src/commands/deploy/sync-config.ts](https://github.com/powersync-ja/ ``` USAGE - $ powersync destroy [--directory ] [--instance-id ] [--confirm yes] + $ powersync destroy [--directory ] [--environment | --instance-id ] [--confirm yes] FLAGS --confirm=