Skip to content
6 changes: 6 additions & 0 deletions .changeset/lazy-trees-judge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@powersync/cli-core': minor
'powersync': minor
---

Resolve organization ID and project ID from instance ID automatically.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ playground/

# PNPM
.pnpm-store/
.pnpmfile.cjs

# MacOS
.DS_Store
Expand Down
153 changes: 66 additions & 87 deletions cli/README.md

Large diffs are not rendered by default.

61 changes: 28 additions & 33 deletions cli/src/api/cloud/validate-cloud-link-config.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<PowerSyncManagementClient['getInstanceConfig']>>;

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<ValidateCloudLinkConfigResult> {
const { cloudClient, input, validateInstance = false } = options;
const { instanceId, orgId, projectId } = input;
export async function validateCloudProject(options: ValidateCloudProjectOptions): Promise<void> {
const { orgId, projectId } = options;

ensureObjectId(orgId, '--org-id');
ensureObjectId(projectId, '--project-id');
Expand All @@ -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<FetchCloudInstanceConfigResult> {
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 };
}
5 changes: 1 addition & 4 deletions cli/src/commands/deploy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<id> --project-id=<id>'
];
static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --instance-id=<id>'];
static flags = {
...GENERAL_VALIDATION_FLAG_HELPERS.flags
};
Expand Down
5 changes: 1 addition & 4 deletions cli/src/commands/deploy/service-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<id> --project-id=<id>'
];
static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --instance-id=<id>'];
static flags = {
...SERVICE_CONFIG_VALIDATION_FLAGS.flags
};
Expand Down
5 changes: 1 addition & 4 deletions cli/src/commands/deploy/sync-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<id> --project-id=<id>'
];
static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --instance-id=<id>'];
static flags = {
...SYNC_CONFIG_VALIDATION_FLAGS.flags
};
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/fetch/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<id> --project-id=<id>'
'<%= config.bin %> <%= command.id %> --instance-id=<id>'
];
static flags = {
output: Flags.string({
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/generate/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<id> --project-id=<id>'
'<%= config.bin %> <%= command.id %> --output=dart --output-path=lib/schema.dart --instance-id=<id>'
];
static flags = {
output: Flags.string({
Expand Down
5 changes: 1 addition & 4 deletions cli/src/commands/init/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,7 @@ export default class InitCloud extends InstanceCommand {
'Create a new instance with ',
ux.colorize('blue', '\tpowersync link cloud --create --org-id=<org-id> --project-id=<project-id>'),
'or pull an existing instance with ',
ux.colorize(
'blue',
'\tpowersync pull instance --org-id=<org-id> --project-id=<project-id> --instance-id=<instance-id>'
),
ux.colorize('blue', '\tpowersync pull instance --instance-id=<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'),
Expand Down
61 changes: 38 additions & 23 deletions cli/src/commands/link/cloud.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { ResolvedCloudCLIConfig } from '@powersync/cli-schemas';

import { Flags, ux } from '@oclif/core';
import {
CLI_FILENAME,
Expand All @@ -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=<project-id>',
'<%= config.bin %> <%= command.id %> --instance-id=<id>',
'<%= config.bin %> <%= command.id %> --create --project-id=<project-id>',
'<%= config.bin %> <%= command.id %> --instance-id=<id> --project-id=<project-id> --org-id=<org-id>'
'<%= config.bin %> <%= command.id %> --create --project-id=<project-id> --org-id=<org-id>'
];
static flags = {
create: Flags.boolean({
Expand All @@ -36,13 +38,13 @@ export default class LinkCloud extends CloudInstanceCommand {
'org-id': Flags.string({
default: env.ORG_ID,
description:
'Organization ID. Optional when the token has a single org; required when the token has multiple orgs. Resolved: flag → ORG_ID → cli.yaml.',
'Organization ID. Required with --create when the token has multiple orgs; optional when linking an existing instance.',
required: false
}),
'project-id': Flags.string({
default: env.PROJECT_ID,
description: 'Project ID. Resolved: flag → PROJECT_ID → cli.yaml.',
required: true
description: 'Project ID. Required with --create; optional assertion when linking an existing instance.',
required: false
})
};
static summary = '[Cloud only] Link to a PowerSync Cloud instance (or create one with --create).';
Expand All @@ -51,10 +53,6 @@ export default class LinkCloud extends CloudInstanceCommand {
const { flags } = await this.parse(LinkCloud);
let { create, directory, 'instance-id': instanceId, 'org-id': orgId, 'project-id': projectId } = flags;

if (!orgId) {
orgId = await getDefaultOrgId();
}

const projectDirectory = this.resolveProjectDir(flags);
if (create) {
if (instanceId) {
Expand All @@ -63,12 +61,18 @@ export default class LinkCloud extends CloudInstanceCommand {
});
}

try {
await validateCloudLinkConfig({
cloudClient: this.client,
input: { orgId, projectId },
validateInstance: false
if (!projectId) {
this.styledError({
message: 'Creating a Cloud instance requires --project-id.'
});
}

if (!orgId) {
orgId = await getDefaultOrgId();
}

try {
await validateCloudProject({ cloudClient: this.client, orgId: orgId!, projectId: projectId! });
} catch (error) {
this.styledError({ message: error instanceof Error ? error.message : String(error) });
}
Expand All @@ -80,8 +84,8 @@ export default class LinkCloud extends CloudInstanceCommand {
try {
const result = await createCloudInstance(client, {
name: config.name,
orgId,
projectId,
orgId: orgId!,
projectId: projectId!,
region: config.region
});
newInstanceId = result.instanceId;
Expand All @@ -96,7 +100,7 @@ export default class LinkCloud extends CloudInstanceCommand {
expectedType: ServiceType.CLOUD,
projectDir: projectDirectory
});
writeCloudLink(projectDirectory, { instanceId: newInstanceId, orgId, projectId });
writeCloudLink(projectDirectory, { instanceId: newInstanceId, orgId: orgId!, projectId: projectId! });
this.log(
ux.colorize('green', `Created Cloud instance ${newInstanceId} and updated ${directory}/${CLI_FILENAME}.`)
);
Expand All @@ -110,17 +114,28 @@ export default class LinkCloud extends CloudInstanceCommand {
});
}

let linked: ResolvedCloudCLIConfig | undefined;
try {
await validateCloudLinkConfig({
const validationResult = await fetchCloudInstanceConfig({
cloudClient: this.client,
input: { instanceId, orgId, projectId },
validateInstance: true
instanceId,
orgId,
projectId
});
linked = validationResult.linked;
} catch (error) {
this.styledError({ message: error instanceof Error ? error.message : String(error) });
}

writeCloudLink(projectDirectory, { instanceId, orgId, projectId });
if (!linked) {
this.styledError({ message: `Failed to resolve Cloud instance ${instanceId}.` });
}

writeCloudLink(projectDirectory, {
instanceId: linked.instance_id,
orgId: linked.org_id,
projectId: linked.project_id
});
ensureServiceTypeMatches({
command: this,
configRequired: false,
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/pull/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.';
Expand Down
Loading
Loading