diff --git a/cli/README.md b/cli/README.md index 59d3281..2a5c0f7 100644 --- a/cli/README.md +++ b/cli/README.md @@ -971,7 +971,7 @@ _See code: [src/commands/init/self-hosted.ts](https://github.com/powersync-ja/po ## `powersync link cloud` -Link to a PowerSync Cloud instance (or create one with --create). +[Cloud only] Link to a PowerSync Cloud instance (or create one with --create). ``` USAGE @@ -992,7 +992,7 @@ PROJECT FLAGS directory. DESCRIPTION - Link to a PowerSync Cloud instance (or create one with --create). + [Cloud only] Link to a PowerSync Cloud instance (or create one with --create). Write or update cli.yaml with a Cloud instance (instance-id, org-id, project-id). Use --create to create a new instance from service.yaml name/region and link it; omit --instance-id when using --create. Org ID is optional when @@ -1083,17 +1083,29 @@ Migrates Sync Rules to Sync Streams ``` USAGE - $ powersync migrate sync-rules [--input-file ] [--output-file ] [--directory ] + $ powersync migrate sync-rules [--input-file ] [--output-file ] [--api-url | --instance-id + | --org-id | --project-id ] [--directory ] FLAGS --input-file= Path to the input sync rules file. Defaults to the project sync-config.yaml file. --output-file= Path to the output sync streams file. Defaults to overwrite the input file. +SELF_HOSTED_PROJECT FLAGS + --api-url= [Self-hosted] PowerSync API URL. When set, context is treated as self-hosted (exclusive with + --instance-id). Resolved: flag → cli.yaml → API_URL. + PROJECT FLAGS --directory= [default: powersync] Directory containing PowerSync config. Defaults to "powersync". This is required if multiple powersync config files are present in subdirectories of the current working directory. +CLOUD_PROJECT FLAGS + --instance-id= [Cloud] PowerSync Cloud instance ID (BSON ObjectID). When set, context is treated as cloud + (exclusive with --api-url). Resolved: flag → cli.yaml → INSTANCE_ID. + --org-id= [Cloud] Organization ID (optional). Defaults to the token’s single org when only one is + available; pass explicitly if the token has multiple orgs. Resolved: flag → cli.yaml → ORG_ID. + --project-id= [Cloud] Project ID. Resolved: flag → cli.yaml → PROJECT_ID. + DESCRIPTION Migrates Sync Rules to Sync Streams @@ -1394,7 +1406,7 @@ _See code: [@oclif/plugin-plugins](https://github.com/oclif/plugin-plugins/blob/ ## `powersync pull instance` -Pull an existing Cloud instance: link and download config into local service.yaml and sync-config.yaml. +[Cloud only] Pull an existing Cloud instance: link and download config into local service.yaml and sync-config.yaml. ``` USAGE @@ -1412,7 +1424,7 @@ CLOUD_PROJECT FLAGS --project-id= Project ID. Manually passed if the current context has not been linked. DESCRIPTION - Pull an existing Cloud instance: link and download config into local service.yaml and sync-config.yaml. + [Cloud only] Pull an existing Cloud instance: link and download config into local service.yaml and sync-config.yaml. Fetch an existing Cloud instance by ID: create the config directory if needed, write cli.yaml, and download service.yaml and sync-config.yaml. Pass --instance-id and --project-id when the directory is not yet linked; --org-id diff --git a/cli/package.json b/cli/package.json index 4381cd8..5a874ab 100644 --- a/cli/package.json +++ b/cli/package.json @@ -72,6 +72,7 @@ "bin": "powersync", "dirname": "powersync", "commands": "./dist/commands", + "helpClass": "./dist/help.js", "plugins": [ "@oclif/plugin-help", "@oclif/plugin-plugins", diff --git a/cli/src/commands/configure/ide.ts b/cli/src/commands/configure/ide.ts index f89b4bb..ae0e205 100644 --- a/cli/src/commands/configure/ide.ts +++ b/cli/src/commands/configure/ide.ts @@ -1,6 +1,6 @@ import { select } from '@inquirer/prompts'; import { ux } from '@oclif/core'; -import { CLI_FILENAME, parseYamlFile, PowerSyncCommand } from '@powersync/cli-core'; +import { CLI_FILENAME, CommandHelpGroup, parseYamlFile, PowerSyncCommand } from '@powersync/cli-core'; import { CLIConfig } from '@powersync/cli-schemas'; import { readdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; @@ -39,6 +39,7 @@ function findLinkedProjectDirs(cwd: string): string[] { } export default class ConfigureIde extends PowerSyncCommand { + static commandHelpGroup = CommandHelpGroup.PROJECT_SETUP; static description = 'Configure or guide your IDE setup for the best PowerSync CLI developer experience. Enables YAML schema validation and autocompletion, sets up !env custom tag support, and patches existing config files with language server directives.'; static examples = ['<%= config.bin %> <%= command.id %>']; diff --git a/cli/src/commands/fetch/instances.ts b/cli/src/commands/fetch/instances.ts index 8bcc930..a4c329b 100644 --- a/cli/src/commands/fetch/instances.ts +++ b/cli/src/commands/fetch/instances.ts @@ -1,5 +1,11 @@ import { Command, Flags, ux } from '@oclif/core'; -import { CLI_FILENAME, createAccountsHubClient, createCloudClient, parseYamlFile } from '@powersync/cli-core'; +import { + CLI_FILENAME, + CommandHelpGroup, + createAccountsHubClient, + createCloudClient, + parseYamlFile +} from '@powersync/cli-core'; import { CLIConfig } from '@powersync/cli-schemas'; import sortBy from 'lodash/sortBy.js'; import fs, { readdir } from 'node:fs/promises'; @@ -34,6 +40,7 @@ type OrganizationMap = { }; export default class FetchInstances extends Command { + static commandHelpGroup = CommandHelpGroup.CLOUD; static description = 'List PowerSync Cloud and linked instances, Cloud instances are grouped by organization and project.'; static examples = [ diff --git a/cli/src/commands/init/cloud.ts b/cli/src/commands/init/cloud.ts index 1da6b97..b7e5268 100644 --- a/cli/src/commands/init/cloud.ts +++ b/cli/src/commands/init/cloud.ts @@ -1,6 +1,7 @@ import { ux } from '@oclif/core'; import { CLI_FILENAME, + CommandHelpGroup, InstanceCommand, SERVICE_FILENAME, SYNC_FILENAME, @@ -16,6 +17,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const TEMPLATES_DIR = join(__dirname, '..', '..', '..', 'templates'); export default class InitCloud extends InstanceCommand { + static commandHelpGroup = CommandHelpGroup.PROJECT_SETUP; static description = 'Copy a Cloud template into a config directory (default powersync/). Edit service.yaml then run link cloud and deploy.'; static examples = [ diff --git a/cli/src/commands/init/self-hosted.ts b/cli/src/commands/init/self-hosted.ts index 8d88e5d..f3a2845 100644 --- a/cli/src/commands/init/self-hosted.ts +++ b/cli/src/commands/init/self-hosted.ts @@ -1,6 +1,7 @@ import { ux } from '@oclif/core'; import { CLI_FILENAME, + CommandHelpGroup, InstanceCommand, SERVICE_FILENAME, SYNC_FILENAME, @@ -16,6 +17,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const TEMPLATES_DIR = join(__dirname, '..', '..', '..', 'templates'); export default class InitSelfHosted extends InstanceCommand { + static commandHelpGroup = CommandHelpGroup.PROJECT_SETUP; static description = 'Copy a self-hosted template into a config directory (default powersync/). Configure service.yaml with your self-hosted instance details.'; static examples = [ diff --git a/cli/src/commands/link/cloud.ts b/cli/src/commands/link/cloud.ts index a17064f..c9cefee 100644 --- a/cli/src/commands/link/cloud.ts +++ b/cli/src/commands/link/cloud.ts @@ -2,6 +2,7 @@ import { Flags, ux } from '@oclif/core'; import { CLI_FILENAME, CloudInstanceCommand, + CommandHelpGroup, ensureServiceTypeMatches, env, getDefaultOrgId, @@ -14,6 +15,7 @@ import { validateCloudLinkConfig } from '../../api/cloud/validate-cloud-link-con 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.'; static examples = [ @@ -45,7 +47,7 @@ export default class LinkCloud extends CloudInstanceCommand { }), ...InstanceCommand.flags }; - static summary = 'Link to a PowerSync Cloud instance (or create one with --create).'; + static summary = '[Cloud only] Link to a PowerSync Cloud instance (or create one with --create).'; async run(): Promise { const { flags } = await this.parse(LinkCloud); diff --git a/cli/src/commands/link/self-hosted.ts b/cli/src/commands/link/self-hosted.ts index 933ca3a..f8bc517 100644 --- a/cli/src/commands/link/self-hosted.ts +++ b/cli/src/commands/link/self-hosted.ts @@ -2,6 +2,7 @@ import { input } from '@inquirer/prompts'; import { Flags, ux } from '@oclif/core'; import { CLI_FILENAME, + CommandHelpGroup, ensureServiceTypeMatches, env, InstanceCommand, @@ -14,6 +15,7 @@ import { join } from 'node:path'; import { Document } from 'yaml'; export default class LinkSelfHosted extends SelfHostedInstanceCommand { + static commandHelpGroup = CommandHelpGroup.PROJECT_SETUP; static description = [ `Links a self hosted PowerSync instance by API URL.`, `API Keys can be specified via input or specified in the PS_ADMIN_TOKEN environment variable.` diff --git a/cli/src/commands/login.ts b/cli/src/commands/login.ts index 632e0df..abceb1a 100644 --- a/cli/src/commands/login.ts +++ b/cli/src/commands/login.ts @@ -1,10 +1,11 @@ import { confirm, password } from '@inquirer/prompts'; import { ux } from '@oclif/core'; -import { createAccountsHubClient, PowerSyncCommand, Services } from '@powersync/cli-core'; +import { CommandHelpGroup, createAccountsHubClient, PowerSyncCommand, Services } from '@powersync/cli-core'; import { startPATLoginServer } from '../api/login-server.js'; export default class Login extends PowerSyncCommand { + static commandHelpGroup = CommandHelpGroup.AUTHENTICATION; static description = 'Store a PowerSync auth token (PAT) in secure storage so later Cloud commands run without passing a token. If secure storage is unavailable, login can optionally store it in a local config file. Use PS_ADMIN_TOKEN env var for CI or scripts instead.'; static examples = ['<%= config.bin %> <%= command.id %>']; diff --git a/cli/src/commands/logout.ts b/cli/src/commands/logout.ts index a85d9f5..20977b3 100644 --- a/cli/src/commands/logout.ts +++ b/cli/src/commands/logout.ts @@ -1,7 +1,8 @@ import { ux } from '@oclif/core'; -import { PowerSyncCommand, Services } from '@powersync/cli-core'; +import { CommandHelpGroup, PowerSyncCommand, Services } from '@powersync/cli-core'; export default class Logout extends PowerSyncCommand { + static commandHelpGroup = CommandHelpGroup.AUTHENTICATION; static description = 'Remove the stored PowerSync auth token from secure storage or local fallback config storage. Cloud commands will no longer use stored credentials until you run login again.'; static examples = ['<%= config.bin %> <%= command.id %>']; diff --git a/cli/src/commands/migrate/sync-rules.ts b/cli/src/commands/migrate/sync-rules.ts index dac15a2..eb12bf7 100644 --- a/cli/src/commands/migrate/sync-rules.ts +++ b/cli/src/commands/migrate/sync-rules.ts @@ -1,11 +1,11 @@ import { Flags, ux } from '@oclif/core'; import { instantiate } from '@powersync-community/sync-config-rewriter'; -import { InstanceCommand, SYNC_FILENAME, YAML_SYNC_RULES_SCHEMA } from '@powersync/cli-core'; +import { SharedInstanceCommand, SYNC_FILENAME, YAML_SYNC_RULES_SCHEMA } from '@powersync/cli-core'; import { access, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -export default class MigrateSyncRules extends InstanceCommand { +export default class MigrateSyncRules extends SharedInstanceCommand { static description = 'Migrates Sync Rules to Sync Streams'; static flags = { 'input-file': Flags.string({ @@ -16,7 +16,7 @@ export default class MigrateSyncRules extends InstanceCommand { description: 'Path to the output sync streams file. Defaults to overwrite the input file.', required: false }), - ...InstanceCommand.flags + ...SharedInstanceCommand.flags }; static summary = 'Migrates Sync Rules to Sync Streams'; diff --git a/cli/src/commands/pull/instance.ts b/cli/src/commands/pull/instance.ts index eeb5ae8..c9c3505 100644 --- a/cli/src/commands/pull/instance.ts +++ b/cli/src/commands/pull/instance.ts @@ -2,6 +2,7 @@ import { ux } from '@oclif/core'; import { CLI_FILENAME, CloudInstanceCommand, + CommandHelpGroup, ensureServiceTypeMatches, getDefaultOrgId, SERVICE_FILENAME, @@ -26,6 +27,7 @@ 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 = [ @@ -37,7 +39,7 @@ export default class PullInstance extends CloudInstanceCommand { ...CloudInstanceCommand.flags }; static summary = - 'Pull an existing Cloud instance: link and download config into local service.yaml and sync-config.yaml.'; + '[Cloud only] Pull an existing Cloud instance: link and download config into local service.yaml and sync-config.yaml.'; async run(): Promise { const { flags } = await this.parse(PullInstance); diff --git a/cli/src/help.ts b/cli/src/help.ts new file mode 100644 index 0000000..e3d189a --- /dev/null +++ b/cli/src/help.ts @@ -0,0 +1,146 @@ +import { Help } from '@oclif/core'; +import { CommandHelpGroup } from '@powersync/cli-core'; + +/** Order in which sections appear in the root help output. */ +const SECTION_ORDER: CommandHelpGroup[] = [ + CommandHelpGroup.AUTHENTICATION, + CommandHelpGroup.PROJECT_SETUP, + CommandHelpGroup.CLOUD, + CommandHelpGroup.INSTANCE, + CommandHelpGroup.LOCAL_DEVELOPMENT, + CommandHelpGroup.ADDITIONAL +]; + +/** + * Custom help implementation for the `powersync help` and `powersync --help` commands. + * + * This override: + * - Displays a flat list of commands at the root level instead of nested topic trees. + * - Formats command IDs using the configured `topicSeparator` (for example, "deploy:instance" → "deploy instance" + * when the separator is a space). + * - Groups commands into sections defined by the `commandHelpGroup` static property on each command class. + * Commands without a `commandHelpGroup` are shown under ADDITIONAL COMMANDS. + */ +export default class PowerSyncHelp extends Help { + protected commandRows(commands: typeof this.sortedCommands, idPadding: number): [string, string | undefined][] { + return commands + .filter((command) => (this.opts.hideAliasesFromRoot ? !command.aliases?.includes(command.id) : true)) + .map((command) => { + const id = this.displayId(command.id); + // Strip type prefixes (e.g. "[Cloud only]", "[Self-hosted only]") since the section grouping already conveys this. + const summary = this.summary(command)?.replace(/^\[(Cloud|Self-hosted) only\]\s*/i, ''); + // We add some padding to align the command name to the description + return [id.padEnd(idPadding), summary]; + }); + } + + /** + * Commands IDs use a `:` separator, but we allow ` ` spaces. + * This method converts command IDs to the display format based on the config topicSeparator (e.g. "deploy:instance" → "deploy instance" if topicSeparator is " "). + */ + protected displayId(commandId: string): string { + if (this.config.topicSeparator === ':') { + return commandId; + } + + return commandId.replaceAll(':', this.config.topicSeparator); + } + + /** + * Formats a section of commands for display in the help output. + * Groups commands by their top-level key and renders them with proper indentation and spacing. + */ + protected formatCommandsSection(title: string, commands: typeof this.sortedCommands): string { + const groupedCommands: Array = []; + let currentGroup: typeof this.sortedCommands = []; + let currentKey: string | undefined; + + for (const command of commands) { + const key = this.groupKeyForCommand(command.id); + if (currentKey === undefined || key === currentKey) { + currentGroup.push(command); + currentKey = key; + continue; + } + + groupedCommands.push(currentGroup); + currentGroup = [command]; + currentKey = key; + } + + if (currentGroup.length > 0) { + groupedCommands.push(currentGroup); + } + + let idPadding = 0; + for (const command of commands) { + idPadding = Math.max(idPadding, this.displayId(command.id).length); + } + + const body = groupedCommands.map((group) => this.renderCommandsBody(group, idPadding)).join('\n\n'); + + return this.section(title, body); + } + + /** + * Commands are grouped by the first segment of their ID (e.g. "deploy" for "deploy:instance", "deploy:service-config", etc.) + * to create a flat command list at the root level, while still allowing for nested commands. + */ + protected groupKeyForCommand(commandId: string): string { + const separator = this.config.topicSeparator ?? ':'; + // Prefer the configured topic separator when deriving the top-level key + if (separator && commandId.includes(separator)) { + const topLevel = commandId.split(separator)[0]; + return topLevel || commandId; + } + + // If the configured separator is not ":" but the ID still uses ":", fall back to ":"-based grouping + if (separator !== ':' && commandId.includes(':')) { + const topLevel = commandId.split(':')[0]; + return topLevel || commandId; + } + + // No known separator found; treat the whole ID as the group key + return commandId; + } + + protected renderCommandsBody(commands: typeof this.sortedCommands, idPadding: number): string { + return this.renderList(this.commandRows(commands, idPadding), { + indentation: 2, + spacer: '\n', + stripAnsi: this.opts.stripAnsi + }); + } + + protected async showRootHelp(): Promise { + const state = this.config.pjson?.oclif?.state; + if (state) { + this.log(state === 'deprecated' ? `${this.config.bin} is deprecated` : `${this.config.bin} is in ${state}.\n`); + } + + this.log(this.formatRoot()); + this.log(''); + + const commandsByGroup = new Map(); + + for (const command of this.sortedCommands.filter((c) => c.id)) { + const CommandClass = await command.load(); + const group = + (CommandClass as { commandHelpGroup?: CommandHelpGroup }).commandHelpGroup ?? CommandHelpGroup.ADDITIONAL; + + if (!commandsByGroup.has(group)) { + commandsByGroup.set(group, []); + } + + commandsByGroup.get(group)!.push(command); + } + + for (const group of SECTION_ORDER) { + const groupCommands = commandsByGroup.get(group); + if (groupCommands && groupCommands.length > 0) { + this.log(this.formatCommandsSection(`${group} COMMANDS`, groupCommands)); + this.log(''); + } + } + } +} diff --git a/cli/test/commands/help.test.ts b/cli/test/commands/help.test.ts new file mode 100644 index 0000000..88359ee --- /dev/null +++ b/cli/test/commands/help.test.ts @@ -0,0 +1,41 @@ +import { runCommand } from '@oclif/test'; +import { describe, expect, it } from 'vitest'; + +import { root } from '../helpers/root.js'; + +describe('help', () => { + it('shows a grouped command list at root help', async () => { + const result = await runCommand('--help', { root }); + + expect(result.error).toBeUndefined(); + expect(result.stdout).toContain('AUTHENTICATION COMMANDS'); + expect(result.stdout).toContain('PROJECT SETUP COMMANDS'); + expect(result.stdout).toContain('CLOUD COMMANDS'); + expect(result.stdout).toContain('INSTANCE COMMANDS'); + expect(result.stdout).toContain('LOCAL DEVELOPMENT COMMANDS'); + expect(result.stdout).toContain('ADDITIONAL COMMANDS'); + expect(result.stdout).toContain('deploy service-config'); + expect(result.stdout).toContain('fetch status'); + expect(result.stdout).toContain('docker start'); + expect(result.stdout).toContain('plugins add'); + const deployServiceIdx = result.stdout.indexOf('deploy service-config'); + const deploySyncIdx = result.stdout.indexOf('deploy sync-config'); + const destroyIdx = result.stdout.indexOf('destroy'); + expect(deployServiceIdx).toBeLessThan(deploySyncIdx); + expect(deploySyncIdx).toBeLessThan(destroyIdx); + expect(result.stdout).not.toContain('TOPICS'); + + const cloudSectionStart = result.stdout.indexOf('CLOUD COMMANDS'); + const instanceSectionStart = result.stdout.indexOf('INSTANCE COMMANDS'); + const localDevSectionStart = result.stdout.indexOf('LOCAL DEVELOPMENT COMMANDS'); + const additionalSectionStart = result.stdout.indexOf('ADDITIONAL COMMANDS'); + + const cloudSection = result.stdout.slice(cloudSectionStart, instanceSectionStart); + const localDevSection = result.stdout.slice(localDevSectionStart, additionalSectionStart); + const additionalSection = result.stdout.slice(additionalSectionStart); + + expect(cloudSection).toContain('deploy service-config'); + expect(localDevSection).toContain('docker start'); + expect(additionalSection).not.toContain('docker start'); + }); +}); diff --git a/packages/cli-core/src/command-types/CloudInstanceCommand.ts b/packages/cli-core/src/command-types/CloudInstanceCommand.ts index b15e3e9..48d992c 100644 --- a/packages/cli-core/src/command-types/CloudInstanceCommand.ts +++ b/packages/cli-core/src/command-types/CloudInstanceCommand.ts @@ -16,7 +16,7 @@ import { env } from '../utils/env.js'; import { OBJECT_ID_REGEX } from '../utils/object-id.js'; import { CLI_FILENAME, SERVICE_FILENAME, SYNC_FILENAME } from '../utils/project-config.js'; import { parseYamlFile } from '../utils/yaml.js'; -import { HelpGroup } from './HelpGroup.js'; +import { CommandHelpGroup, HelpGroup } from './HelpGroup.js'; import { DEFAULT_ENSURE_CONFIG_OPTIONS, EnsureConfigOptions, InstanceCommand } from './InstanceCommand.js'; export type CloudProject = { @@ -51,6 +51,7 @@ export type CloudInstanceCommandFlags = Interfaces.InferredFlags< * pnpm exec powersync some-cloud-cmd --instance-id=... --org-id=... --project-id=... */ export abstract class CloudInstanceCommand extends InstanceCommand { + static commandHelpGroup = CommandHelpGroup.CLOUD; static flags = { /** * Instance ID, org ID, and project ID are resolved in order: flags → cli.yaml → env (INSTANCE_ID, ORG_ID, PROJECT_ID). diff --git a/packages/cli-core/src/command-types/HelpGroup.ts b/packages/cli-core/src/command-types/HelpGroup.ts index d2d3f3f..118050a 100644 --- a/packages/cli-core/src/command-types/HelpGroup.ts +++ b/packages/cli-core/src/command-types/HelpGroup.ts @@ -7,3 +7,18 @@ export enum HelpGroup { PROJECT = 'PROJECT', SELF_HOSTED_PROJECT = 'SELF_HOSTED_PROJECT' } + +/** + * Section headings used to group commands in the root help output. + * Set `static commandHelpGroup = CommandHelpGroup.` on a command class (or its base) to control which section it appears in. + * The string value is the category name; " COMMANDS" is appended automatically when rendering. + * Commands without a `commandHelpGroup` are shown under ADDITIONAL COMMANDS. + */ +export enum CommandHelpGroup { + ADDITIONAL = 'ADDITIONAL', + AUTHENTICATION = 'AUTHENTICATION', + CLOUD = 'CLOUD', + INSTANCE = 'INSTANCE', + LOCAL_DEVELOPMENT = 'LOCAL DEVELOPMENT', + PROJECT_SETUP = 'PROJECT SETUP' +} diff --git a/packages/cli-core/src/command-types/PowerSyncCommand.ts b/packages/cli-core/src/command-types/PowerSyncCommand.ts index 670c274..7945ecf 100644 --- a/packages/cli-core/src/command-types/PowerSyncCommand.ts +++ b/packages/cli-core/src/command-types/PowerSyncCommand.ts @@ -2,6 +2,8 @@ import { JourneyError } from '@journeyapps-labs/micro-errors'; import { Command, ux } from '@oclif/core'; import { join } from 'node:path'; +import { CommandHelpGroup } from './HelpGroup.js'; + export type StyledErrorParams = { error?: Error | unknown; exitCode?: number; @@ -11,6 +13,13 @@ export type StyledErrorParams = { /** Base command for operations that target a PowerSync project directory (e.g. link, init). */ export abstract class PowerSyncCommand extends Command { + /** + * Controls which section this command appears under in the root help output. + * Override in subclasses or individual commands to place them in the correct section. + * Defaults to undefined, which maps to ADDITIONAL_COMMANDS in the help renderer. + */ + static commandHelpGroup?: CommandHelpGroup; + /** Resolves the project directory path from the --directory flag (relative to cwd). */ resolveProjectDir(flags: { directory: string }): string { return join(process.cwd(), flags.directory); diff --git a/packages/cli-core/src/command-types/SharedInstanceCommand.ts b/packages/cli-core/src/command-types/SharedInstanceCommand.ts index 18beb6b..6614571 100644 --- a/packages/cli-core/src/command-types/SharedInstanceCommand.ts +++ b/packages/cli-core/src/command-types/SharedInstanceCommand.ts @@ -23,7 +23,7 @@ import { env } from '../utils/env.js'; import { CLI_FILENAME, SERVICE_FILENAME, SYNC_FILENAME } from '../utils/project-config.js'; import { parseYamlFile } from '../utils/yaml.js'; import { CloudProject } from './CloudInstanceCommand.js'; -import { HelpGroup } from './HelpGroup.js'; +import { CommandHelpGroup, HelpGroup } from './HelpGroup.js'; import { DEFAULT_ENSURE_CONFIG_OPTIONS, EnsureConfigOptions, InstanceCommand } from './InstanceCommand.js'; import { SelfHostedProject } from './SelfHostedInstanceCommand.js'; @@ -56,6 +56,7 @@ export type SharedInstanceCommandFlags = Interfaces.InferredFlags< * pnpm exec powersync some-shared-cmd --instance-id=... --org-id=... --project-id=... */ export abstract class SharedInstanceCommand extends InstanceCommand { + static commandHelpGroup = CommandHelpGroup.INSTANCE; static flags = { 'api-url': Flags.string({ description: diff --git a/plugins/docker/src/DockerCommand.ts b/plugins/docker/src/DockerCommand.ts new file mode 100644 index 0000000..0144933 --- /dev/null +++ b/plugins/docker/src/DockerCommand.ts @@ -0,0 +1,8 @@ +import { CommandHelpGroup, SelfHostedInstanceCommand } from '@powersync/cli-core'; + +/** + * Base command for Docker-related operations. Groups all docker subcommands under LOCAL DEVELOPMENT in the root help output. + */ +export abstract class DockerCommand extends SelfHostedInstanceCommand { + static commandHelpGroup = CommandHelpGroup.LOCAL_DEVELOPMENT; +} diff --git a/plugins/docker/src/commands/docker/configure.ts b/plugins/docker/src/commands/docker/configure.ts index 299200b..22e0700 100644 --- a/plugins/docker/src/commands/docker/configure.ts +++ b/plugins/docker/src/commands/docker/configure.ts @@ -3,7 +3,6 @@ import { Flags, ux } from '@oclif/core'; import { CLI_FILENAME, parseYamlDocumentPreserveTags, - SelfHostedInstanceCommand, type SelfHostedInstanceCommandFlags, SERVICE_FILENAME } from '@powersync/cli-core'; @@ -13,6 +12,7 @@ import { fileURLToPath } from 'node:url'; import { Document, isMap, isSeq, stringify } from 'yaml'; import { DEV_TOKEN } from '../../constants.js'; +import { DockerCommand } from '../../DockerCommand.js'; import { TEMPLATES } from '../../templates/templates-index.js'; import { DockerModuleContext, DockerModuleType } from '../../types.js'; @@ -36,7 +36,7 @@ function composeProjectName(projectDirectory: string): string { return `powersync_${sanitized}`; } -export default class DockerConfigure extends SelfHostedInstanceCommand { +export default class DockerConfigure extends DockerCommand { static description = [ 'Configures a self hosted project with Docker Compose services.', 'Docker configuration is located in ./powersync/docker/.', @@ -47,7 +47,7 @@ export default class DockerConfigure extends SelfHostedInstanceCommand { '<%= config.bin %> <%= command.id %> --database=postgres --storage=postgres' ]; static flags = { - ...SelfHostedInstanceCommand.flags, + ...DockerCommand.flags, database: Flags.string({ description: 'Database module for replication source. Omit to be prompted.', options: [...TEMPLATES[DockerModuleType.SOURCE_DATABASE].map((t) => t.name), NONE_OPTION], diff --git a/plugins/docker/src/commands/docker/index.ts b/plugins/docker/src/commands/docker/index.ts index b9540c1..4fa80cf 100644 --- a/plugins/docker/src/commands/docker/index.ts +++ b/plugins/docker/src/commands/docker/index.ts @@ -1,11 +1,11 @@ -import { SelfHostedInstanceCommand } from '@powersync/cli-core'; +import { DockerCommand } from '../../DockerCommand.js'; -export default class Docker extends SelfHostedInstanceCommand { +export default class Docker extends DockerCommand { static description = 'Scaffold and run a self-hosted PowerSync stack via Docker. Use `docker configure` to create powersync/docker/, then `docker reset` (stop+remove then start) or `docker start` / `docker stop`.'; static examples = ['<%= config.bin %> <%= command.id %>']; static flags = { - ...SelfHostedInstanceCommand.flags + ...DockerCommand.flags }; static hidden = true; static summary = diff --git a/plugins/docker/src/commands/docker/reset.ts b/plugins/docker/src/commands/docker/reset.ts index 1d31a89..1b607a3 100644 --- a/plugins/docker/src/commands/docker/reset.ts +++ b/plugins/docker/src/commands/docker/reset.ts @@ -1,5 +1,5 @@ import { ux } from '@oclif/core'; -import { SelfHostedInstanceCommand, type SelfHostedInstanceCommandFlags } from '@powersync/cli-core'; +import { type SelfHostedInstanceCommandFlags } from '@powersync/cli-core'; import { getDockerProjectName, @@ -7,13 +7,14 @@ import { runDockerCompose, runDockerComposeDown } from '../../docker.js'; +import { DockerCommand } from '../../DockerCommand.js'; -export default class DockerReset extends SelfHostedInstanceCommand { +export default class DockerReset extends DockerCommand { static description = 'Run `docker compose down` then `docker compose up -d --wait`: stops and removes containers, then starts the stack and waits for services (including PowerSync) to be healthy. Use when you want a clean bring-up (e.g. after config changes). Use `powersync status` to debug running instances.'; static examples = ['<%= config.bin %> <%= command.id %>']; static flags = { - ...SelfHostedInstanceCommand.flags + ...DockerCommand.flags }; static summary = 'Reset the self-hosted PowerSync stack (stop and remove, then start).'; diff --git a/plugins/docker/src/commands/docker/start.ts b/plugins/docker/src/commands/docker/start.ts index 3e00371..72c81a9 100644 --- a/plugins/docker/src/commands/docker/start.ts +++ b/plugins/docker/src/commands/docker/start.ts @@ -1,14 +1,14 @@ import { ux } from '@oclif/core'; -import { SelfHostedInstanceCommand } from '@powersync/cli-core'; import { getDockerProjectName, logPowersyncProjectsStopHelp, runDockerCompose } from '../../docker.js'; +import { DockerCommand } from '../../DockerCommand.js'; -export default class DockerStart extends SelfHostedInstanceCommand { +export default class DockerStart extends DockerCommand { static description = 'Runs `docker compose up -d --wait` for the project docker/ compose file; waits for services (including PowerSync) to be healthy. Use `powersync status` to debug running instances.'; static examples = ['<%= config.bin %> <%= command.id %>']; static flags = { - ...SelfHostedInstanceCommand.flags + ...DockerCommand.flags }; static summary = 'Start the self-hosted PowerSync stack via Docker Compose.'; diff --git a/plugins/docker/src/commands/docker/stop.ts b/plugins/docker/src/commands/docker/stop.ts index b248f36..556f9fa 100644 --- a/plugins/docker/src/commands/docker/stop.ts +++ b/plugins/docker/src/commands/docker/stop.ts @@ -1,9 +1,10 @@ import { Flags, ux } from '@oclif/core'; -import { SelfHostedInstanceCommand, type SelfHostedInstanceCommandFlags } from '@powersync/cli-core'; +import { type SelfHostedInstanceCommandFlags } from '@powersync/cli-core'; import { getDockerProjectName, runDockerComposeDown, runDockerComposeStop } from '../../docker.js'; +import { DockerCommand } from '../../DockerCommand.js'; -export default class DockerStop extends SelfHostedInstanceCommand { +export default class DockerStop extends DockerCommand { static description = 'Run `docker compose -p stop` (containers are not removed by default). Does not require the project directory or a compose file, so you can run it from anywhere (e.g. after a reset conflict). Use --project-name or run from a project with cli.yaml to choose which project to stop. Use --remove to also remove the containers. Use --remove-volumes to also remove volumes (e.g. to re-run DB init scripts on next reset).'; static examples = [ @@ -11,7 +12,7 @@ export default class DockerStop extends SelfHostedInstanceCommand { '<%= config.bin %> <%= command.id %> --project-name=powersync_myapp --remove' ]; static flags = { - ...SelfHostedInstanceCommand.flags, + ...DockerCommand.flags, 'project-name': Flags.string({ description: 'Docker Compose project name to stop (e.g. powersync_myapp). If omitted and run from a project directory, uses plugins.docker.project_name from cli.yaml. Pass this to stop from any directory without loading the project.'