Skip to content
Merged
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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions docs/internal/2026-09-26-project-template-submit.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
Expand Down
53 changes: 53 additions & 0 deletions src/commands/template/submit.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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,
})
Comment thread
purpshell marked this conversation as resolved.
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)
}
}
71 changes: 69 additions & 2 deletions test/commands/cloud.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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-'),
Expand Down