diff --git a/src/bin.ts b/src/bin.ts index 37e78f3..377531d 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -2011,13 +2011,14 @@ yargs(rawArgs) 'api-key': { type: 'string' as const, describe: 'WorkOS API key' }, file: { type: 'string', describe: 'Path to seed YAML file' }, clean: { type: 'boolean', default: false, describe: 'Tear down seeded resources' }, + init: { type: 'boolean', default: false, describe: 'Create an example workos-seed.yml file' }, }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runSeed } = await import('./commands/seed.js'); await runSeed( - { file: argv.file, clean: argv.clean }, + { file: argv.file, clean: argv.clean, init: argv.init }, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl(), ); diff --git a/src/commands/seed.spec.ts b/src/commands/seed.spec.ts index 221e579..deed10a 100644 --- a/src/commands/seed.spec.ts +++ b/src/commands/seed.spec.ts @@ -32,7 +32,7 @@ vi.mock('../lib/workos-client.js', () => ({ })); const { setOutputMode } = await import('../utils/output.js'); -const { runSeed } = await import('./seed.js'); +const { runSeed, runSeedInit } = await import('./seed.js'); const mockExistsSync = vi.mocked(existsSync); const mockReadFileSync = vi.mocked(readFileSync); @@ -79,6 +79,55 @@ describe('seed command', () => { afterEach(() => vi.restoreAllMocks()); + describe('runSeedInit (--init)', () => { + it('creates workos-seed.yml with example content', () => { + mockExistsSync.mockReturnValue(false); + + runSeedInit(); + + expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + const [filePath, content] = mockWriteFileSync.mock.calls[0]; + expect(filePath).toBe('workos-seed.yml'); + expect(content).toContain('permissions:'); + expect(content).toContain('roles:'); + expect(content).toContain('organizations:'); + expect(content).toContain('config:'); + expect(content).toContain('redirect_uris:'); + expect(consoleOutput.some((l) => l.includes('Created'))).toBe(true); + }); + + it('does not overwrite existing file', () => { + mockExistsSync.mockReturnValue(true); + + runSeedInit(); + + expect(mockWriteFileSync).not.toHaveBeenCalled(); + expect(consoleOutput.some((l) => l.includes('already exists'))).toBe(true); + }); + + it('outputs JSON when in JSON mode', () => { + setOutputMode('json'); + mockExistsSync.mockReturnValue(false); + + runSeedInit(); + + const output = JSON.parse(consoleOutput[0]); + expect(output.status).toBe('ok'); + expect(output.file).toBe('workos-seed.yml'); + setOutputMode('human'); + }); + + it('is reachable via runSeed({ init: true })', async () => { + mockExistsSync.mockReturnValue(false); + + await runSeed({ init: true }, 'sk_test'); + + expect(mockWriteFileSync).toHaveBeenCalledTimes(1); + const [filePath] = mockWriteFileSync.mock.calls[0]; + expect(filePath).toBe('workos-seed.yml'); + }); + }); + describe('runSeed with --file', () => { it('creates resources in dependency order: permissions → roles → orgs → config', async () => { mockExistsSync.mockReturnValue(true); diff --git a/src/commands/seed.ts b/src/commands/seed.ts index bc38742..fc71599 100644 --- a/src/commands/seed.ts +++ b/src/commands/seed.ts @@ -38,11 +38,74 @@ function saveState(state: SeedState): void { writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); } +const DEFAULT_SEED_FILE = 'workos-seed.yml'; + +const SEED_TEMPLATE = `# WorkOS seed file — provision resources with \`workos seed --file=${DEFAULT_SEED_FILE}\` +# Resources are created in dependency order: permissions → roles → organizations → config. +# Existing resources are skipped (idempotent). Run \`workos seed --clean\` to tear down. + +permissions: + - name: Read Posts + slug: posts:read + - name: Write Posts + slug: posts:write + description: Create and edit posts + +roles: + - name: Admin + slug: admin + description: Full access + permissions: + - posts:read + - posts:write + - name: Viewer + slug: viewer + permissions: + - posts:read + +organizations: + - name: Acme Corp + domains: + - acme.com + +config: + redirect_uris: + - http://localhost:3000/auth/callback + cors_origins: + - http://localhost:3000 + homepage_url: http://localhost:3000 +`; + +export function runSeedInit(): void { + if (existsSync(DEFAULT_SEED_FILE)) { + if (isJsonMode()) { + outputJson({ status: 'exists', message: `${DEFAULT_SEED_FILE} already exists`, file: DEFAULT_SEED_FILE }); + } else { + console.log(chalk.yellow(`${DEFAULT_SEED_FILE} already exists — not overwriting.`)); + } + return; + } + + writeFileSync(DEFAULT_SEED_FILE, SEED_TEMPLATE); + + if (isJsonMode()) { + outputJson({ status: 'ok', message: `Created ${DEFAULT_SEED_FILE}`, file: DEFAULT_SEED_FILE }); + } else { + console.log(chalk.green(`Created ${DEFAULT_SEED_FILE}`)); + console.log(chalk.dim('Edit the file, then run: workos seed --file=workos-seed.yml')); + } +} + export async function runSeed( - options: { file?: string; clean?: boolean }, + options: { file?: string; clean?: boolean; init?: boolean }, apiKey: string, baseUrl?: string, ): Promise { + if (options.init) { + runSeedInit(); + return; + } + if (options.clean) { await runSeedClean(apiKey, baseUrl); return; @@ -51,7 +114,8 @@ export async function runSeed( if (!options.file) { return exitWithError({ code: 'missing_args', - message: 'Provide a seed file: workos seed --file=workos-seed.yml', + message: + 'Provide a seed file: workos seed --file=workos-seed.yml\nRun workos seed --init to create an example seed file.', }); } diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 32c2f3a..8da463c 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -1064,6 +1064,14 @@ const commands: CommandSchema[] = [ default: false, hidden: false, }, + { + name: 'init', + type: 'boolean', + description: 'Create an example workos-seed.yml file', + required: false, + default: false, + hidden: false, + }, ], }, {