Skip to content
Open
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
11 changes: 11 additions & 0 deletions .changeset/cli-targets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@powersync/cli-schemas': minor
'@powersync/cli-core': minor
'powersync': minor
---

Added named targets to `cli.yaml`. Link several Cloud instances from one project directory with `powersync link cloud --target=<name> --instance-id=<id>`, then pick one per command with `--target=<name>` or the `POWERSYNC_TARGET` variable. The top-level `instance_id`, `org_id` and `project_id` fields remain the default target.

- `powersync fetch instances` lists the targets of each linked directory.
- Commands that work with both Cloud and self-hosted instances now let `--instance-id` or `--api-url` decide the context, even when `cli.yaml` is linked to the other type.
- When a linked directory has no `service.yaml`, the error now suggests `powersync pull instance`.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,4 @@ playground/

# IDE
.idea

93 changes: 64 additions & 29 deletions cli/README.md

Large diffs are not rendered by default.

15 changes: 9 additions & 6 deletions cli/src/api/cloud/write-cloud-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,26 @@ export type WriteCloudLinkOptions = {
instanceId: string;
orgId: string;
projectId: string;
/** Store the link under targets.<name> instead of the top-level fields. */
target?: 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 target. Creates a new file if it does not exist.
*/
export function writeCloudLink(projectDir: string, options: WriteCloudLinkOptions): void {
const { instanceId, orgId, projectId } = options;
const { instanceId, orgId, projectId, target } = options;
const linkPath = join(projectDir, CLI_FILENAME);
if (!existsSync(projectDir)) {
mkdirSync(projectDir, { recursive: true });
}

const doc = existsSync(linkPath) ? parseYamlFile(linkPath) : new Document();
const basePath = target ? ['targets', target] : [];
doc.set('type', 'cloud');
doc.set('instance_id', instanceId);
doc.set('org_id', orgId);
doc.set('project_id', projectId);
doc.setIn([...basePath, 'instance_id'], instanceId);
doc.setIn([...basePath, 'org_id'], orgId);
doc.setIn([...basePath, 'project_id'], projectId);
writeFileSync(linkPath, doc.toString(), 'utf8');
}
12 changes: 10 additions & 2 deletions cli/src/commands/fetch/instances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,16 @@ 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, target] of Object.entries(linked.config.targets ?? {})) {
this.log(
`\t${ux.colorize('blue', `Target ${name}: `)} ${ux.colorize('gray', `instance_id: ${target.instance_id}`)}`
);
}
} else if (linked.config.type === 'self-hosted') {
this.log(`\t${ux.colorize('blue', 'API URL: ')} ${linked.config.api_url}`);
}
Expand Down
50 changes: 29 additions & 21 deletions cli/src/commands/link/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 --target to store the link as a named target, selected later with --target or POWERSYNC_TARGET.';
static examples = [
'<%= config.bin %> <%= command.id %> --instance-id=<id>',
'<%= config.bin %> <%= command.id %> --target=staging --instance-id=<id>',
'<%= config.bin %> <%= command.id %> --create --project-id=<project-id>',
'<%= config.bin %> <%= command.id %> --create --project-id=<project-id> --org-id=<org-id>'
];
Expand All @@ -32,7 +33,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.',
Comment thread
bean1352 marked this conversation as resolved.
description: 'PowerSync Cloud instance ID. Omit when using --create. Resolved: flag → INSTANCE_ID.',
required: false
}),
'org-id': Flags.string({
Expand All @@ -44,15 +45,28 @@ export default class LinkCloud extends CloudInstanceCommand {
default: env.PROJECT_ID,
description: 'Project ID. Required with --create.',
required: false
}),
target: Flags.string({
description: `Store the link under targets.<name> in ${CLI_FILENAME} instead of the top-level fields. Select it later with --target or POWERSYNC_TARGET.`,
required: false
})
};
static summary = '[Cloud only] Link to a PowerSync Cloud instance (or create one with --create).';

async run(): Promise<void> {
const { flags } = await this.parse(LinkCloud);
let { create, directory, 'instance-id': instanceId, 'org-id': orgId, 'project-id': projectId } = flags;
let { create, directory, 'instance-id': instanceId, 'org-id': orgId, 'project-id': projectId, target } = flags;
const linkLabel = target ? ` (target "${target}")` : '';

const projectDirectory = this.resolveProjectDir(flags);
ensureServiceTypeMatches({
command: this,
configRequired: create,
directoryLabel: directory,
expectedType: ServiceType.CLOUD,
projectDir: projectDirectory
});

if (create) {
if (instanceId) {
this.styledError({
Expand Down Expand Up @@ -92,16 +106,17 @@ 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!,
target
});
writeCloudLink(projectDirectory, { 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;
}
Expand Down Expand Up @@ -130,19 +145,12 @@ 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,
projectId: linked.project_id
projectId: linked.project_id,
target
});
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}.`));
}
}
196 changes: 195 additions & 1 deletion cli/test/command-types/resolution-order.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { managementClientMock, MOCK_CLOUD_IDS } from '../setup.js';
type EnvSnapshot = {
API_URL: string | undefined;
INSTANCE_ID: string | undefined;
POWERSYNC_TARGET: string | undefined;
PS_ADMIN_TOKEN: string | undefined;
};

Expand Down Expand Up @@ -59,6 +60,7 @@ describe('instance resolution order', () => {
envSnapshot = {
API_URL: env.API_URL,
INSTANCE_ID: env.INSTANCE_ID,
POWERSYNC_TARGET: env.POWERSYNC_TARGET,
PS_ADMIN_TOKEN: env.PS_ADMIN_TOKEN
};
});
Expand All @@ -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_TARGET = envSnapshot.POWERSYNC_TARGET;
env.PS_ADMIN_TOKEN = envSnapshot.PS_ADMIN_TOKEN;
vi.restoreAllMocks();
rmSync(tmpRoot, { force: true, recursive: true });
Expand Down Expand Up @@ -141,7 +144,89 @@ 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 target from --target or POWERSYNC_TARGET', 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}`,
'targets:',
' 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');

// --target picks the named entry, including its org/project
await runDestroyDirect(['--confirm=yes', '--target=staging']);
const fromFlag = await loadProjectSpy.mock.results[0]!.value;
expect(fromFlag.target).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_TARGET selects an entry too; its missing org/project are resolved via getInstance
env.POWERSYNC_TARGET = 'production';
await runDestroyDirect(['--confirm=yes']);
const fromEnv = await loadProjectSpy.mock.results[1]!.value;
expect(fromEnv.target).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 target 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.target).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 target and --target 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', 'targets:', ' staging:', ` instance_id: ${IDS.env.instance}`, ''].join('\n'),
'utf8'
);

const unknown = await runDestroyDirect(['--confirm=yes', '--target=production']);
expect(unknown.error?.message).toContain('Target "production" is not defined in cli.yaml');
expect(unknown.error?.message).toContain('staging');

// No default link and no selection: point at the targets that do exist
const unselected = await runDestroyDirect(['--confirm=yes']);
expect(unselected.error?.message).toContain('Linking is required');
expect(unselected.error?.suggestions?.[0]).toContain('--target or POWERSYNC_TARGET: staging');

const exclusive = await runDestroyDirect([
'--confirm=yes',
'--target=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 () => {
Expand Down Expand Up @@ -181,6 +266,115 @@ describe('instance resolution order', () => {
expect(fromEnv.linked.api_url).toBe('https://env.example.com');
});

it('SharedInstanceCommand selects a cli.yaml target from --target or POWERSYNC_TARGET', 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',
'targets:',
' 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', '--target=staging']);
const fromFlag = await loadProjectSpy.mock.results[0]!.value;
expect(fromFlag.target).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_TARGET = 'staging';
await runFetchStatusDirect(['--output=json']);
const fromEnv = await loadProjectSpy.mock.results[1]!.value;
expect(fromEnv.target).toBe('staging');
expect(fromEnv.linked.instance_id).toBe(IDS.env.instance);

env.POWERSYNC_TARGET = undefined;
const unselected = await runFetchStatusDirect(['--output=json']);
expect(unselected.error?.message).toContain('Linking is required');
expect(unselected.error?.suggestions?.[0]).toContain('--target or POWERSYNC_TARGET: 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 targets 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.target).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.target).toBeUndefined();
expect(sharedProject.linked.instance_id).toBe(IDS.cli.instance);

// Selecting a target on such a file explains how to add one
const { error } = await runDestroyDirect(['--confirm=yes', '--target=staging']);
expect(error?.message).toContain(
'Target "staging" is not defined in cli.yaml. Add it with: powersync link cloud --target=staging'
);
});

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 }) =>
Expand Down
Loading
Loading