Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .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
.DS_Store
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
7 changes: 2 additions & 5 deletions cli/src/commands/init/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,9 @@ export default class InitCloud extends InstanceCommand {

const instructions = [
'Create a new instance with ',
ux.colorize('blue', '\tpowersync link cloud --create --org-id=<org-id> --project-id=<project-id>'),
ux.colorize('blue', '\tpowersync link cloud --create --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
41 changes: 24 additions & 17 deletions cli/src/commands/link/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ 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 link. Pass --instance-id to link an existing instance (org-id and project-id are resolved automatically). Use --create with --project-id to create a new instance from service.yaml and link it.';
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 @@ -30,19 +30,20 @@ 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({
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. Auto-resolved from the instance when linking an existing instance; required (or auto-resolved if single org) when using --create. Resolved: flag → ORG_ID.',
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 when using --create; auto-resolved from the instance otherwise. Resolved: flag → PROJECT_ID.',
required: false
})
};
static summary = '[Cloud only] Link to a PowerSync Cloud instance (or create one with --create).';
Expand All @@ -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) {
Expand All @@ -63,6 +60,16 @@ export default class LinkCloud extends CloudInstanceCommand {
});
}

if (!projectId) {
this.styledError({
message: 'Pass --project-id when using --create.'
});
}

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

try {
await validateCloudLinkConfig({
cloudClient: this.client,
Expand Down Expand Up @@ -111,13 +118,13 @@ export default class LinkCloud extends CloudInstanceCommand {
}

try {
await validateCloudLinkConfig({
cloudClient: this.client,
input: { instanceId, orgId, projectId },
validateInstance: true
const instanceMeta = await this.client.getInstance({ id: instanceId });
orgId = instanceMeta.org_id;
projectId = instanceMeta.app_id;
} catch {
this.styledError({
message: `Instance ${instanceId} was not found, or is not accessible with the current token.`
});
} catch (error) {
this.styledError({ message: error instanceof Error ? error.message : String(error) });
}

writeCloudLink(projectDirectory, { instanceId, orgId, projectId });
Expand Down
35 changes: 21 additions & 14 deletions cli/src/commands/pull/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
CloudInstanceCommand,
CommandHelpGroup,
ensureServiceTypeMatches,
getDefaultOrgId,
SERVICE_FILENAME,
ServiceType,
SYNC_FILENAME,
Expand All @@ -30,12 +29,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=<id> --project-id=<id>',
'<%= config.bin %> <%= command.id %> --instance-id=<id> --project-id=<id> --org-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. Pass --instance-id when the directory is not yet linked. Cloud only.';
static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --instance-id=<id>'];
static flags = {
overwrite: Flags.boolean({
description:
Expand All @@ -47,19 +42,18 @@ export default class PullInstance extends CloudInstanceCommand {

async run(): Promise<void> {
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 resolvedOrgId = _orgId ?? (await getDefaultOrgId().catch(() => null));
/**
* The pull instance command can be used to create a new powersync project directory
*/
const projectDir = this.resolveProjectDir(flags);
if (!existsSync(projectDir)) {
if (instanceId && resolvedOrgId && projectId) {
if (instanceId) {
mkdirSync(projectDir, { recursive: true });
} else {
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.`
});
}
}
Expand All @@ -74,13 +68,26 @@ export default class PullInstance extends CloudInstanceCommand {

const linkPath = join(projectDir, CLI_FILENAME);
if (!existsSync(linkPath)) {
if (!instanceId || !resolvedOrgId || !projectId) {
if (!instanceId) {
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=<id> --org-id=<id> --project-id=<id>')} first.`
message: `Linking is required. Pass --instance-id to this command, or run ${ux.colorize('blue', 'powersync link cloud --instance-id=<id>')} first.`
});
}

writeCloudLink(projectDir, { instanceId, orgId: resolvedOrgId, projectId });
let instanceMeta;
try {
instanceMeta = await this.client.getInstance({ id: instanceId });
} catch {
this.styledError({
message: `Instance ${instanceId} was not found, or is not accessible with the current token.`
});
}

writeCloudLink(projectDir, {
instanceId,
orgId: instanceMeta.org_id,
projectId: instanceMeta.app_id
});
this.log(`Created ${ux.colorize('blue', `${directory}/${CLI_FILENAME}`)} with Cloud instance link.`);
}

Expand Down
16 changes: 16 additions & 0 deletions cli/test/command-types/resolution-order.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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, resetManagementClientMocks } from '../setup.js';

type EnvSnapshot = {
API_URL: string | undefined;
Expand Down Expand Up @@ -57,6 +58,7 @@ describe('instance resolution order', () => {
origCwd = process.cwd();
tmpRoot = mkdtempSync(join(tmpdir(), 'resolution-order-'));
process.chdir(tmpRoot);
resetManagementClientMocks();
envSnapshot = {
API_URL: env.API_URL,
INSTANCE_ID: env.INSTANCE_ID,
Expand Down Expand Up @@ -100,6 +102,13 @@ describe('instance resolution order', () => {

const loadProjectSpy = vi.spyOn(CloudInstanceCommand.prototype, 'loadProject');

// When --instance-id is passed as a flag, --org-id/--project-id are ignored;
// org and project are always resolved from getInstance.
managementClientMock.getInstance.mockResolvedValueOnce({
app_id: IDS.flag.project,
id: IDS.flag.instance,
org_id: IDS.flag.org
});
await runDestroyDirect([
'--confirm=yes',
`--instance-id=${IDS.flag.instance}`,
Expand Down Expand Up @@ -210,6 +219,13 @@ describe('instance resolution order', () => {
const loadProjectSpy = vi.spyOn(SharedInstanceCommand.prototype, 'loadProject');
vi.spyOn(FetchStatusCommand.prototype, 'getCloudStatus').mockRejectedValue(new Error('expected-test-failure'));

// When --instance-id is passed as a flag, --org-id/--project-id are ignored;
// org and project are always resolved from getInstance.
managementClientMock.getInstance.mockResolvedValueOnce({
app_id: IDS.flag.project,
id: IDS.flag.instance,
org_id: IDS.flag.org
});
await runFetchStatusDirect([
'--output=json',
`--instance-id=${IDS.flag.instance}`,
Expand Down
42 changes: 12 additions & 30 deletions cli/test/commands/link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ describe('link', () => {
objects: [{ id: PROJECT_ID, name: 'Test Project' }],
total: 1
});
managementClientMock.getInstanceConfig.mockResolvedValue({});
managementClientMock.getInstance.mockResolvedValue({ app_id: PROJECT_ID, id: INSTANCE_ID, org_id: ORG_ID });
origCwd = process.cwd();
tmpDir = mkdtempSync(join(tmpdir(), 'link-test-'));
process.chdir(tmpDir);
Expand Down Expand Up @@ -213,40 +213,22 @@ type: cloud
expect(linkYaml.instance_id).toBe(INSTANCE_ID);
});

it('errors for invalid ObjectID flag values', async () => {
const { error } = await runLinkCloudDirect([
`--instance-id=${INSTANCE_ID}`,
`--org-id=${ORG_ID}`,
'--project-id=invalid/project-id'
]);
expect(error?.message).toContain('Invalid --project-id');
});

it('errors when project does not exist in the organization', async () => {
accountsClientMock.listProjects.mockResolvedValueOnce({ objects: [], total: 0 });

const { error } = await runLinkCloudDirect([
`--instance-id=${INSTANCE_ID}`,
`--org-id=${ORG_ID}`,
`--project-id=${PROJECT_ID}`
]);

expect(error?.message).toContain(`Project ${PROJECT_ID} was not found in organization ${ORG_ID}`);
expect(error?.message).not.toContain(', ::');
it('links with instance-id only (resolves org and project automatically)', async () => {
const { error, stdout } = await runLinkCloudDirect([`--instance-id=${INSTANCE_ID}`]);
expect(error).toBeUndefined();
expect(stdout).toContain(`Updated ${PROJECT_DIR}/${CLI_FILENAME} with Cloud instance link.`);
const linkYaml = parseYaml(readFileSync(join(tmpDir, PROJECT_DIR, CLI_FILENAME), 'utf8'));
expect(linkYaml.instance_id).toBe(INSTANCE_ID);
expect(linkYaml.org_id).toBe(ORG_ID);
expect(linkYaml.project_id).toBe(PROJECT_ID);
});

it('errors when instance does not exist and --create is not used', async () => {
managementClientMock.getInstanceConfig.mockRejectedValueOnce(new Error('not found'));
managementClientMock.getInstance.mockRejectedValueOnce(new Error('not found'));

const { error } = await runLinkCloudDirect([
`--instance-id=${INSTANCE_ID}`,
`--org-id=${ORG_ID}`,
`--project-id=${PROJECT_ID}`
]);
const { error } = await runLinkCloudDirect([`--instance-id=${INSTANCE_ID}`]);

expect(error?.message).toContain(
`Instance ${INSTANCE_ID} was not found in project ${PROJECT_ID} in organization ${ORG_ID}`
);
expect(error?.message).toContain(`Instance ${INSTANCE_ID} was not found`);
});
});

Expand Down
Loading
Loading