diff --git a/README.md b/README.md index 86d88cd..c874eb0 100644 --- a/README.md +++ b/README.md @@ -437,6 +437,24 @@ submitting another replacement. Reading requires `sessions:read`; registration requires `sessions:manage`, an eligible Official API Number, and Graph access. These commands do not enable dynamic Flow publishing. +### Submit a project template + +```shell +polymorfa template submit TEMPLATE_ID --project-slug support --session number_123 --yes +``` + +This command uses a server API key or project token with `templates:manage`. +The saved project template and Number must belong to the same project. The API +checks the definition before submitting it to Meta. A successful response +records the submission, not approval. Read the Number's template with +`polymorfa cloud template get NAME --session number_123 --language en_US` to +check its status. If the request times out or its response is lost, read the +template before choosing whether to submit again. The command makes one +network attempt and requires `--yes` because a second submission can create a +duplicate provider effect. +`--project-slug` selects the target project even when the current directory is +linked to another project; the API checks that the credential can access it. + ### Official API observations and templates The `cloud` commands require an organization API key or a project token with the diff --git a/docs/internal/2026-09-26-project-template-submit.md b/docs/internal/2026-09-26-project-template-submit.md new file mode 100644 index 0000000..901ad5c --- /dev/null +++ b/docs/internal/2026-09-26-project-template-submit.md @@ -0,0 +1,18 @@ +# Project-template submission command gate + +`template submit` consumes `MessagingClient.templates.submit` from SDK PR #308 +plus its one-attempt follow-up. The API contract is +`POST /messaging/projects/{projectSlug}/templates/{id}/submit` at +`polymorfa/polymorfa` PR #377, commit +`0f559ed7f89227fa220d6a0275fcc40b8e319a55`. That route returns local +validation feedback before dispatching an invalid draft. The CLI makes one +attempt even with its older SDK pin, and requires `--yes` for the provider +effect. A lost response is not a rejection; inspect the Number's Meta catalog +before another attempt. + +This command is implementation-only until the upstream API branches merge, +the SDK follow-up merges and publishes under an exact registry version, and +the CLI pins that version with a regenerated lockfile. No live Meta submission, +approval webhook, deployed path, or customer availability was established by +this change. Cloud onboarding beta authority and release controls remain +unchanged. diff --git a/package.json b/package.json index bb66f97..af0b4ee 100644 --- a/package.json +++ b/package.json @@ -300,6 +300,9 @@ "cloud:template": { "description": "Manage Meta templates, separate from Polymorfa project drafts." }, + "template": { + "description": "Submit saved project templates for approval." + }, "cloud:credentials": { "description": "Read redacted credential health and create guided reauthorization links." }, diff --git a/src/commands/template/submit.ts b/src/commands/template/submit.ts new file mode 100644 index 0000000..1fed974 --- /dev/null +++ b/src/commands/template/submit.ts @@ -0,0 +1,53 @@ +import {Args, Flags} from '@oclif/core' +import {PolymorfaConfigurationError} from '@polymorfa/sdk' +import {BaseCommand} from '../../base-command.js' +import {cloudResult} from '../../services/cloud.js' + +export default class TemplateSubmit extends BaseCommand { + static override description = + 'Submit a saved project template for Meta approval (beta). Requires templates:manage. Makes one attempt; an accepted submission is not approval.' + + static override args = { + template: Args.string({ + description: 'Saved project template ID.', + required: true, + }), + } + + static override flags = { + 'project-slug': Flags.string({ + description: 'Project slug that owns the template.', + required: true, + }), + session: Flags.string({ + description: 'Official API Number ID in the same project.', + required: true, + }), + yes: Flags.boolean({ + description: 'Confirm one submission attempt to Meta.', + required: true, + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(TemplateSubmit) + if (!flags.yes) + throw new PolymorfaConfigurationError( + 'Pass --yes to submit this project template for Meta approval.', + 'yes', + ) + + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Project template submission', + ignoreProjectLink: true, + }) + const response = await client.templates.submit( + flags['project-slug'], + args.template, + {session: flags.session}, + {maxNetworkRetries: 0}, + ) + const result = cloudResult(response) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/test/commands/cloud.test.ts b/test/commands/cloud.test.ts index 8381edb..882a930 100644 --- a/test/commands/cloud.test.ts +++ b/test/commands/cloud.test.ts @@ -1,8 +1,9 @@ -import {mkdtemp, rm, writeFile} from 'node:fs/promises' +import {mkdir, mkdtemp, rm, writeFile} from 'node:fs/promises' +import {AsyncEntry} from '@napi-rs/keyring' import {homedir} from 'node:os' import {join} from 'node:path' import {runCommand} from '@oclif/test' -import {afterEach, describe, expect, test} from 'vitest' +import {afterEach, describe, expect, test, vi} from 'vitest' import { startHttpServer, withEnvironment, @@ -84,6 +85,72 @@ describe('Official API commands', () => { expect(server.requests[0]!.headers['idempotency-key']).toBe('tpl-1') }) + test('uses the explicit template project when a different directory is linked', async () => { + directory = await mkdtemp( + join(homedir(), '.polymorfa-agent-work', 'cloud-cli-test-'), + ) + await writeFile( + join(directory, 'polymorfa.json'), + JSON.stringify({ + project: '11111111-1111-4111-8111-111111111111', + version: 1, + }), + ) + const configDirectory = join(directory, 'config') + await mkdir(configDirectory) + await writeFile( + join(configDirectory, 'config.json'), + JSON.stringify({ + version: 2, + activeProfile: 'template', + profiles: { + template: { + credential: { + kind: 'projectToken', + account: 'profile:template:projectToken', + }, + owner: 'project', + project: '22222222-2222-4222-8222-222222222222', + }, + }, + }), + ) + const keyring = vi + .spyOn(AsyncEntry.prototype, 'getPassword') + .mockResolvedValue(`pmfa_pt_${'A'.repeat(94)}`) + server = await startHttpServer({ + status: 202, + body: {success: true, data: {accepted: true}}, + }) + const previousDirectory = process.cwd() + try { + process.chdir(directory) + const result = await withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_PROJECT_TOKEN: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CONFIG_DIR: configDirectory, + }, + () => + runCommand( + 'template submit template_123 --project-slug support --session number_123 --yes --json', + ), + ) + expect(result.error, result.stderr).toBeUndefined() + expect(keyring).toHaveBeenCalledOnce() + expect(server.requests).toHaveLength(1) + expect(server.requests[0]).toMatchObject({ + method: 'POST', + url: '/messaging/projects/support/templates/template_123/submit', + }) + } finally { + process.chdir(previousDirectory) + keyring.mockRestore() + } + }) + test('edits a selected template language and reports acceptance', async () => { directory = await mkdtemp( join(homedir(), '.polymorfa-agent-work', 'cloud-cli-test-'),