From 1187fbc383002bdf98204493c06eb90e918a715b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 22 May 2026 19:53:54 +0000 Subject: [PATCH] feat(sawala,sawala-mcp): kontena write surface (schemas + entries, publish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the create/update/delete + publish workflow on top of the existing read-only kontena CLI and MCP surfaces. Three milestones in one PR: * CLI: `sawala kontena schema {create,update,delete}` and `sawala kontena entry {create,update,delete,publish,unpublish}`. Body is supplied via `--file ` (or `-` for stdin) or inline `--data `. Destructive verbs prompt on a TTY and refuse in non-TTY mode without `--yes`. Both create/update support `--dry-run` for round-trip-free validation. * MCP: eight new tools mirroring the CLI verbs. Delete tools require `confirm: true` in the input to guard against empty-payload accidents and carry `destructiveHint`/`irreversibleHint` for host UIs. Entry CRUD transparently fetches the schema first to route single vs collection — schema type stays an implementation detail of the worker, not the tool surface. * Shared helpers: new `packages/sawala/src/lib/io.ts` with `readJsonInput`, `confirmOrThrow`, and `resolveInputPayload` so the CLI verbs share a single I/O convention. The publish/unpublish verbs target collection schemas; for single-type schemas, callers use `update --publish` with the locale in the patch. --- package-lock.json | 4 +- packages/sawala-mcp/src/tools/index.ts | 16 + .../src/tools/kontena-create-entry.ts | 75 ++++ .../src/tools/kontena-create-schema.ts | 49 +++ .../src/tools/kontena-delete-entry.ts | 82 ++++ .../src/tools/kontena-delete-schema.ts | 61 +++ .../src/tools/kontena-publish-entry.ts | 57 +++ .../src/tools/kontena-unpublish-entry.ts | 56 +++ .../src/tools/kontena-update-entry.ts | 77 ++++ .../src/tools/kontena-update-schema.ts | 54 +++ packages/sawala-mcp/test/server.test.ts | 12 +- .../test/tools/kontena-create-entry.test.ts | 97 +++++ .../test/tools/kontena-create-schema.test.ts | 52 +++ .../test/tools/kontena-delete-entry.test.ts | 75 ++++ .../test/tools/kontena-delete-schema.test.ts | 54 +++ .../test/tools/kontena-publish-entry.test.ts | 62 +++ .../test/tools/kontena-update-entry.test.ts | 69 ++++ .../test/tools/kontena-update-schema.test.ts | 53 +++ packages/sawala/src/commands/kontena.ts | 261 ++++++++++++- packages/sawala/src/lib/io.ts | 80 ++++ packages/sawala/test/kontena.test.ts | 360 ++++++++++++++++++ 21 files changed, 1698 insertions(+), 8 deletions(-) create mode 100644 packages/sawala-mcp/src/tools/kontena-create-entry.ts create mode 100644 packages/sawala-mcp/src/tools/kontena-create-schema.ts create mode 100644 packages/sawala-mcp/src/tools/kontena-delete-entry.ts create mode 100644 packages/sawala-mcp/src/tools/kontena-delete-schema.ts create mode 100644 packages/sawala-mcp/src/tools/kontena-publish-entry.ts create mode 100644 packages/sawala-mcp/src/tools/kontena-unpublish-entry.ts create mode 100644 packages/sawala-mcp/src/tools/kontena-update-entry.ts create mode 100644 packages/sawala-mcp/src/tools/kontena-update-schema.ts create mode 100644 packages/sawala-mcp/test/tools/kontena-create-entry.test.ts create mode 100644 packages/sawala-mcp/test/tools/kontena-create-schema.test.ts create mode 100644 packages/sawala-mcp/test/tools/kontena-delete-entry.test.ts create mode 100644 packages/sawala-mcp/test/tools/kontena-delete-schema.test.ts create mode 100644 packages/sawala-mcp/test/tools/kontena-publish-entry.test.ts create mode 100644 packages/sawala-mcp/test/tools/kontena-update-entry.test.ts create mode 100644 packages/sawala-mcp/test/tools/kontena-update-schema.test.ts create mode 100644 packages/sawala/src/lib/io.ts diff --git a/package-lock.json b/package-lock.json index 1ae0a3c..55e94ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4378,7 +4378,7 @@ }, "packages/sawala": { "name": "@sawala/cli", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "dependencies": { "commander": "^12.0.0", @@ -4417,7 +4417,7 @@ }, "packages/sawala-mcp": { "name": "@sawala/mcp", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", diff --git a/packages/sawala-mcp/src/tools/index.ts b/packages/sawala-mcp/src/tools/index.ts index dd031c9..1ffa908 100644 --- a/packages/sawala-mcp/src/tools/index.ts +++ b/packages/sawala-mcp/src/tools/index.ts @@ -5,10 +5,18 @@ import { formulirGetFormTool } from './formulir-get-form' import { formulirGetSubmissionTool } from './formulir-get-submission' import { formulirListFormsTool } from './formulir-list-forms' import { formulirListSubmissionsTool } from './formulir-list-submissions' +import { kontenaCreateEntryTool } from './kontena-create-entry' +import { kontenaCreateSchemaTool } from './kontena-create-schema' +import { kontenaDeleteEntryTool } from './kontena-delete-entry' +import { kontenaDeleteSchemaTool } from './kontena-delete-schema' import { kontenaGetEntryTool } from './kontena-get-entry' import { kontenaGetSchemaTool } from './kontena-get-schema' import { kontenaListEntriesTool } from './kontena-list-entries' import { kontenaListSchemasTool } from './kontena-list-schemas' +import { kontenaPublishEntryTool } from './kontena-publish-entry' +import { kontenaUnpublishEntryTool } from './kontena-unpublish-entry' +import { kontenaUpdateEntryTool } from './kontena-update-entry' +import { kontenaUpdateSchemaTool } from './kontena-update-schema' import { whoamiTool } from './whoami' /** @@ -19,8 +27,16 @@ export const ALL_TOOLS: ReadonlyArray> = [ whoamiTool, kontenaListSchemasTool, kontenaGetSchemaTool, + kontenaCreateSchemaTool, + kontenaUpdateSchemaTool, + kontenaDeleteSchemaTool, kontenaListEntriesTool, kontenaGetEntryTool, + kontenaCreateEntryTool, + kontenaUpdateEntryTool, + kontenaDeleteEntryTool, + kontenaPublishEntryTool, + kontenaUnpublishEntryTool, formulirListFormsTool, formulirGetFormTool, formulirListSubmissionsTool, diff --git a/packages/sawala-mcp/src/tools/kontena-create-entry.ts b/packages/sawala-mcp/src/tools/kontena-create-entry.ts new file mode 100644 index 0000000..0912abf --- /dev/null +++ b/packages/sawala-mcp/src/tools/kontena-create-entry.ts @@ -0,0 +1,75 @@ +import { z } from 'zod' +import { apiFetch } from '../lib/api-client' +import type { CliContext } from '../lib/auth' +import { zodParser, type ToolDefinition, type ToolInputSchema } from './types' + +interface SchemaTypeResponse { + type: string + [k: string]: unknown +} + +const inputZod = z + .object({ + schemaSlug: z.string().min(1), + entry: z.record(z.string(), z.unknown()), + publish: z.boolean().optional(), + }) + .strict() + +type Input = z.infer + +const inputSchema: ToolInputSchema = { + type: 'object', + properties: { + schemaSlug: { + type: 'string', + description: 'Slug of the schema the entry belongs to.', + }, + entry: { + type: 'object', + description: + 'Entry body. Required: `locale`, `data`. Optional: `slug` (collection types only, auto-derived if omitted), ' + + '`status` (`draft` or `published`; defaults to `draft`), `publishedAt` (ISO 8601). ' + + 'For single-type schemas this is an upsert per locale.', + }, + publish: { + type: 'boolean', + description: + "Convenience flag: when true, sets `status='published'` in the same write " + + '(overrides any `status` in `entry`).', + }, + }, + required: ['schemaSlug', 'entry'], + additionalProperties: false, +} + +export const kontenaCreateEntryTool: ToolDefinition = { + name: 'sawala_kontena_create_entry', + description: + 'Create a Kontena content entry in the active project. Transparently fetches the schema first to route ' + + 'single vs collection — callers think in terms of schemas, not in wire-protocol variants. Single-type ' + + 'schemas upsert per locale; collection schemas enforce `(slug, locale)` uniqueness and 409 on duplicates.', + inputSchema, + annotations: { title: 'Create Kontena entry', readOnlyHint: false }, + parseInput: zodParser(inputZod), + async handle(input: Input, ctx: CliContext) { + if (!ctx.activeProjectId) { + throw new Error( + 'No active project id. Run `sawala project use ` in a terminal to refresh, then retry.', + ) + } + const projectId = ctx.activeProjectId + const payload: Record = { ...input.entry } + if (input.publish) payload.status = 'published' + const schemaInfo = await apiFetch( + ctx, + `/cli/kontena/projects/${encodeURIComponent(projectId)}/schemas/${encodeURIComponent(input.schemaSlug)}`, + ) + const subpath = schemaInfo.type === 'single' ? 'single' : 'collection' + return await apiFetch( + ctx, + `/cli/kontena/projects/${encodeURIComponent(projectId)}/content/${subpath}/${encodeURIComponent(input.schemaSlug)}`, + { method: 'POST', body: payload }, + ) + }, +} diff --git a/packages/sawala-mcp/src/tools/kontena-create-schema.ts b/packages/sawala-mcp/src/tools/kontena-create-schema.ts new file mode 100644 index 0000000..d9cfea5 --- /dev/null +++ b/packages/sawala-mcp/src/tools/kontena-create-schema.ts @@ -0,0 +1,49 @@ +import { z } from 'zod' +import { apiFetch } from '../lib/api-client' +import type { CliContext } from '../lib/auth' +import { zodParser, type ToolDefinition, type ToolInputSchema } from './types' + +const inputZod = z + .object({ + schema: z.record(z.string(), z.unknown()), + }) + .strict() + +type Input = z.infer + +const inputSchema: ToolInputSchema = { + type: 'object', + properties: { + schema: { + type: 'object', + description: + 'Schema body to create. Required fields: `name`, `type` (`single` or `collection`), `fields`. ' + + 'Optional: `slug` (auto-derived from name if omitted), `locales`, `staticExport`, `indexFields`.', + }, + }, + required: ['schema'], + additionalProperties: false, +} + +export const kontenaCreateSchemaTool: ToolDefinition = { + name: 'sawala_kontena_create_schema', + description: + 'Create a new Kontena content schema in the active project. The `schema` input is sent as the POST body; ' + + 'see the Kontena docs for the field-definition shape. Returns the created schema row. The backend rejects ' + + 'duplicate slugs with HTTP 409. Requires admin role on the active org.', + inputSchema, + annotations: { title: 'Create Kontena schema', readOnlyHint: false }, + parseInput: zodParser(inputZod), + async handle(input: Input, ctx: CliContext) { + if (!ctx.activeProjectId) { + throw new Error( + 'No active project id. Run `sawala project use ` in a terminal to refresh, then retry.', + ) + } + return await apiFetch( + ctx, + `/cli/kontena/projects/${encodeURIComponent(ctx.activeProjectId)}/schemas`, + { method: 'POST', body: input.schema }, + ) + }, +} diff --git a/packages/sawala-mcp/src/tools/kontena-delete-entry.ts b/packages/sawala-mcp/src/tools/kontena-delete-entry.ts new file mode 100644 index 0000000..31d4ee2 --- /dev/null +++ b/packages/sawala-mcp/src/tools/kontena-delete-entry.ts @@ -0,0 +1,82 @@ +import { z } from 'zod' +import { apiFetch } from '../lib/api-client' +import type { CliContext } from '../lib/auth' +import { zodParser, type ToolDefinition, type ToolInputSchema } from './types' + +interface SchemaTypeResponse { + type: string + [k: string]: unknown +} + +const inputZod = z + .object({ + schemaSlug: z.string().min(1), + slugOrId: z.string().min(1), + locale: z.string().optional(), + confirm: z.literal(true), + }) + .strict() + +type Input = z.infer + +const inputSchema: ToolInputSchema = { + type: 'object', + properties: { + schemaSlug: { + type: 'string', + description: 'Slug of the schema the entry belongs to.', + }, + slugOrId: { + type: 'string', + description: + 'Entry ULID or slug. Ignored for single-type schemas (they target by locale instead).', + }, + locale: { + type: 'string', + description: 'Locale to target (required for single-type schemas).', + }, + confirm: { + type: 'boolean', + enum: [true], + description: + 'Must be `true` to acknowledge the destructive nature. Guards against accidental empty-input invocations.', + }, + }, + required: ['schemaSlug', 'slugOrId', 'confirm'], + additionalProperties: false, +} + +export const kontenaDeleteEntryTool: ToolDefinition = { + name: 'sawala_kontena_delete_entry', + description: + 'Delete a Kontena content entry. **Destructive** — also fails (HTTP 409) if the entry is currently ' + + 'published; unpublish first or run `sawala_kontena_unpublish_entry` before retrying. Transparently ' + + 'fetches the schema to route single vs collection. MCP hosts SHOULD surface this call for human approval.', + inputSchema, + annotations: { + title: 'Delete Kontena entry', + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + irreversibleHint: true, + }, + parseInput: zodParser(inputZod), + async handle(input: Input, ctx: CliContext) { + if (!ctx.activeProjectId) { + throw new Error( + 'No active project id. Run `sawala project use ` in a terminal to refresh, then retry.', + ) + } + const projectId = ctx.activeProjectId + const schemaInfo = await apiFetch( + ctx, + `/cli/kontena/projects/${encodeURIComponent(projectId)}/schemas/${encodeURIComponent(input.schemaSlug)}`, + ) + const url = + schemaInfo.type === 'single' + ? `/cli/kontena/projects/${encodeURIComponent(projectId)}/content/single/${encodeURIComponent(input.schemaSlug)}` + + (input.locale ? `?locale=${encodeURIComponent(input.locale)}` : '') + : `/cli/kontena/projects/${encodeURIComponent(projectId)}/content/collection/${encodeURIComponent(input.schemaSlug)}/${encodeURIComponent(input.slugOrId)}` + return await apiFetch(ctx, url, { method: 'DELETE' }) + }, +} diff --git a/packages/sawala-mcp/src/tools/kontena-delete-schema.ts b/packages/sawala-mcp/src/tools/kontena-delete-schema.ts new file mode 100644 index 0000000..9d13e26 --- /dev/null +++ b/packages/sawala-mcp/src/tools/kontena-delete-schema.ts @@ -0,0 +1,61 @@ +import { z } from 'zod' +import { apiFetch } from '../lib/api-client' +import type { CliContext } from '../lib/auth' +import { zodParser, type ToolDefinition, type ToolInputSchema } from './types' + +const inputZod = z + .object({ + slugOrId: z.string().min(1), + confirm: z.literal(true), + }) + .strict() + +type Input = z.infer + +const inputSchema: ToolInputSchema = { + type: 'object', + properties: { + slugOrId: { + type: 'string', + description: 'Schema ULID or human-readable slug to delete.', + }, + confirm: { + type: 'boolean', + enum: [true], + description: + 'Must be `true` to acknowledge the destructive nature of this call. ' + + 'Guards against accidental empty-input invocations.', + }, + }, + required: ['slugOrId', 'confirm'], + additionalProperties: false, +} + +export const kontenaDeleteSchemaTool: ToolDefinition = { + name: 'sawala_kontena_delete_schema', + description: + 'Delete a Kontena content schema in the active project. **Destructive** — also fails (HTTP 409) if any ' + + 'entries still reference the schema; delete the entries first. Pass `confirm: true` to acknowledge. ' + + 'MCP hosts SHOULD surface this call for human approval before executing.', + inputSchema, + annotations: { + title: 'Delete Kontena schema', + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + irreversibleHint: true, + }, + parseInput: zodParser(inputZod), + async handle(input: Input, ctx: CliContext) { + if (!ctx.activeProjectId) { + throw new Error( + 'No active project id. Run `sawala project use ` in a terminal to refresh, then retry.', + ) + } + return await apiFetch( + ctx, + `/cli/kontena/projects/${encodeURIComponent(ctx.activeProjectId)}/schemas/${encodeURIComponent(input.slugOrId)}`, + { method: 'DELETE' }, + ) + }, +} diff --git a/packages/sawala-mcp/src/tools/kontena-publish-entry.ts b/packages/sawala-mcp/src/tools/kontena-publish-entry.ts new file mode 100644 index 0000000..a17b5cc --- /dev/null +++ b/packages/sawala-mcp/src/tools/kontena-publish-entry.ts @@ -0,0 +1,57 @@ +import { z } from 'zod' +import { apiFetch } from '../lib/api-client' +import type { CliContext } from '../lib/auth' +import { zodParser, type ToolDefinition, type ToolInputSchema } from './types' + +const inputZod = z + .object({ + schemaSlug: z.string().min(1), + slugOrId: z.string().min(1), + }) + .strict() + +type Input = z.infer + +const inputSchema: ToolInputSchema = { + type: 'object', + properties: { + schemaSlug: { + type: 'string', + description: 'Slug of the collection schema the entry belongs to.', + }, + slugOrId: { + type: 'string', + description: 'Entry ULID or slug to publish.', + }, + }, + required: ['schemaSlug', 'slugOrId'], + additionalProperties: false, +} + +export const kontenaPublishEntryTool: ToolDefinition = { + name: 'sawala_kontena_publish_entry', + description: + "Publish a Kontena collection-entry draft (sets `status='published'`). Idempotent: re-publishing an " + + 'already-published entry is a no-op at the wire level. v1 supports collection schemas only; for ' + + 'single-type schemas use `sawala_kontena_update_entry` with `publish: true` and the locale in `patch`.', + inputSchema, + annotations: { + title: 'Publish Kontena entry', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + parseInput: zodParser(inputZod), + async handle(input: Input, ctx: CliContext) { + if (!ctx.activeProjectId) { + throw new Error( + 'No active project id. Run `sawala project use ` in a terminal to refresh, then retry.', + ) + } + return await apiFetch( + ctx, + `/cli/kontena/projects/${encodeURIComponent(ctx.activeProjectId)}/content/collection/${encodeURIComponent(input.schemaSlug)}/${encodeURIComponent(input.slugOrId)}`, + { method: 'PUT', body: { status: 'published' } }, + ) + }, +} diff --git a/packages/sawala-mcp/src/tools/kontena-unpublish-entry.ts b/packages/sawala-mcp/src/tools/kontena-unpublish-entry.ts new file mode 100644 index 0000000..4bcd27f --- /dev/null +++ b/packages/sawala-mcp/src/tools/kontena-unpublish-entry.ts @@ -0,0 +1,56 @@ +import { z } from 'zod' +import { apiFetch } from '../lib/api-client' +import type { CliContext } from '../lib/auth' +import { zodParser, type ToolDefinition, type ToolInputSchema } from './types' + +const inputZod = z + .object({ + schemaSlug: z.string().min(1), + slugOrId: z.string().min(1), + }) + .strict() + +type Input = z.infer + +const inputSchema: ToolInputSchema = { + type: 'object', + properties: { + schemaSlug: { + type: 'string', + description: 'Slug of the collection schema the entry belongs to.', + }, + slugOrId: { + type: 'string', + description: 'Entry ULID or slug to unpublish.', + }, + }, + required: ['schemaSlug', 'slugOrId'], + additionalProperties: false, +} + +export const kontenaUnpublishEntryTool: ToolDefinition = { + name: 'sawala_kontena_unpublish_entry', + description: + "Unpublish a Kontena collection entry (sets `status='draft'`). Idempotent. v1 supports collection " + + 'schemas only; for single-type schemas use `sawala_kontena_update_entry`.', + inputSchema, + annotations: { + title: 'Unpublish Kontena entry', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + }, + parseInput: zodParser(inputZod), + async handle(input: Input, ctx: CliContext) { + if (!ctx.activeProjectId) { + throw new Error( + 'No active project id. Run `sawala project use ` in a terminal to refresh, then retry.', + ) + } + return await apiFetch( + ctx, + `/cli/kontena/projects/${encodeURIComponent(ctx.activeProjectId)}/content/collection/${encodeURIComponent(input.schemaSlug)}/${encodeURIComponent(input.slugOrId)}`, + { method: 'PUT', body: { status: 'draft' } }, + ) + }, +} diff --git a/packages/sawala-mcp/src/tools/kontena-update-entry.ts b/packages/sawala-mcp/src/tools/kontena-update-entry.ts new file mode 100644 index 0000000..3caefe2 --- /dev/null +++ b/packages/sawala-mcp/src/tools/kontena-update-entry.ts @@ -0,0 +1,77 @@ +import { z } from 'zod' +import { apiFetch } from '../lib/api-client' +import type { CliContext } from '../lib/auth' +import { zodParser, type ToolDefinition, type ToolInputSchema } from './types' + +interface SchemaTypeResponse { + type: string + [k: string]: unknown +} + +const inputZod = z + .object({ + schemaSlug: z.string().min(1), + slugOrId: z.string().min(1), + patch: z.record(z.string(), z.unknown()), + publish: z.boolean().optional(), + }) + .strict() + +type Input = z.infer + +const inputSchema: ToolInputSchema = { + type: 'object', + properties: { + schemaSlug: { + type: 'string', + description: 'Slug of the schema the entry belongs to.', + }, + slugOrId: { + type: 'string', + description: + 'Entry ULID or slug. Ignored for single-type schemas (they have one entry per locale).', + }, + patch: { + type: 'object', + description: + 'Partial-of-create body. Any subset of `slug`, `locale`, `data`, `status`, `publishedAt`. ' + + 'PUT replacement semantics.', + }, + publish: { + type: 'boolean', + description: + "Convenience flag: when true, also sets `status='published'` in the same write.", + }, + }, + required: ['schemaSlug', 'slugOrId', 'patch'], + additionalProperties: false, +} + +export const kontenaUpdateEntryTool: ToolDefinition = { + name: 'sawala_kontena_update_entry', + description: + 'Update a Kontena content entry. Transparently fetches the schema to route single vs collection. ' + + 'PUT replacement semantics; 404 if the entry does not exist; 422 if the patch violates schema constraints.', + inputSchema, + annotations: { title: 'Update Kontena entry', readOnlyHint: false }, + parseInput: zodParser(inputZod), + async handle(input: Input, ctx: CliContext) { + if (!ctx.activeProjectId) { + throw new Error( + 'No active project id. Run `sawala project use ` in a terminal to refresh, then retry.', + ) + } + const projectId = ctx.activeProjectId + const payload: Record = { ...input.patch } + if (input.publish) payload.status = 'published' + const schemaInfo = await apiFetch( + ctx, + `/cli/kontena/projects/${encodeURIComponent(projectId)}/schemas/${encodeURIComponent(input.schemaSlug)}`, + ) + const url = + schemaInfo.type === 'single' + ? `/cli/kontena/projects/${encodeURIComponent(projectId)}/content/single/${encodeURIComponent(input.schemaSlug)}` + : `/cli/kontena/projects/${encodeURIComponent(projectId)}/content/collection/${encodeURIComponent(input.schemaSlug)}/${encodeURIComponent(input.slugOrId)}` + return await apiFetch(ctx, url, { method: 'PUT', body: payload }) + }, +} diff --git a/packages/sawala-mcp/src/tools/kontena-update-schema.ts b/packages/sawala-mcp/src/tools/kontena-update-schema.ts new file mode 100644 index 0000000..267cfac --- /dev/null +++ b/packages/sawala-mcp/src/tools/kontena-update-schema.ts @@ -0,0 +1,54 @@ +import { z } from 'zod' +import { apiFetch } from '../lib/api-client' +import type { CliContext } from '../lib/auth' +import { zodParser, type ToolDefinition, type ToolInputSchema } from './types' + +const inputZod = z + .object({ + slugOrId: z.string().min(1), + patch: z.record(z.string(), z.unknown()), + }) + .strict() + +type Input = z.infer + +const inputSchema: ToolInputSchema = { + type: 'object', + properties: { + slugOrId: { + type: 'string', + description: 'Schema ULID or human-readable slug to update.', + }, + patch: { + type: 'object', + description: + 'Partial-of-create body. Any subset of `name`, `slug`, `type`, `fields`, `locales`, ' + + '`staticExport`, `indexFields`. The backend treats the request as PUT replacement semantics.', + }, + }, + required: ['slugOrId', 'patch'], + additionalProperties: false, +} + +export const kontenaUpdateSchemaTool: ToolDefinition = { + name: 'sawala_kontena_update_schema', + description: + 'Update an existing Kontena content schema. PUT semantics — provide the new shape (subset is OK). ' + + 'Returns the updated row. 404 if the schema does not exist; 409 on slug collisions. ' + + 'Requires admin role on the active org.', + inputSchema, + annotations: { title: 'Update Kontena schema', readOnlyHint: false, idempotentHint: true }, + parseInput: zodParser(inputZod), + async handle(input: Input, ctx: CliContext) { + if (!ctx.activeProjectId) { + throw new Error( + 'No active project id. Run `sawala project use ` in a terminal to refresh, then retry.', + ) + } + return await apiFetch( + ctx, + `/cli/kontena/projects/${encodeURIComponent(ctx.activeProjectId)}/schemas/${encodeURIComponent(input.slugOrId)}`, + { method: 'PUT', body: input.patch }, + ) + }, +} diff --git a/packages/sawala-mcp/test/server.test.ts b/packages/sawala-mcp/test/server.test.ts index 1a12500..3d6d210 100644 --- a/packages/sawala-mcp/test/server.test.ts +++ b/packages/sawala-mcp/test/server.test.ts @@ -57,15 +57,23 @@ describe('listToolsHandler', () => { expect(names).toContain('sawala_whoami') }) - it('exposes all eleven registered tools (whoami + 4 kontena + 4 formulir + 2 berkasna read-only)', async () => { + it('exposes every registered tool in the expected order (whoami + kontena read + kontena write + formulir + berkasna)', async () => { const result = await listToolsHandler() const names = result.tools.map((t) => t.name) expect(names).toEqual([ 'sawala_whoami', 'sawala_kontena_list_schemas', 'sawala_kontena_get_schema', + 'sawala_kontena_create_schema', + 'sawala_kontena_update_schema', + 'sawala_kontena_delete_schema', 'sawala_kontena_list_entries', 'sawala_kontena_get_entry', + 'sawala_kontena_create_entry', + 'sawala_kontena_update_entry', + 'sawala_kontena_delete_entry', + 'sawala_kontena_publish_entry', + 'sawala_kontena_unpublish_entry', 'sawala_formulir_list_forms', 'sawala_formulir_get_form', 'sawala_formulir_list_submissions', @@ -77,7 +85,7 @@ describe('listToolsHandler', () => { it('advertises every registered tool with name/description/inputSchema/annotations', async () => { const result = await listToolsHandler() - expect(result.tools.length).toBe(11) + expect(result.tools.length).toBe(19) for (const tool of result.tools) { expect(tool.name).toMatch(/^sawala_/) expect(typeof tool.description).toBe('string') diff --git a/packages/sawala-mcp/test/tools/kontena-create-entry.test.ts b/packages/sawala-mcp/test/tools/kontena-create-entry.test.ts new file mode 100644 index 0000000..d18fd35 --- /dev/null +++ b/packages/sawala-mcp/test/tools/kontena-create-entry.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { kontenaCreateEntryTool } from '../../src/tools/kontena-create-entry' +import type { CliContext } from '../../src/lib/auth' + +const baseCtx: CliContext = { + token: 'koda_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', + apiBase: 'https://api.sawala.cloud', + activeOrg: 'acme', + activeProject: 'blog', + activeProjectId: 'proj_01abc', + scopeOrgId: null, + scopeOrgSlug: null, + tokenSource: 'file', +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +describe('sawala_kontena_create_entry', () => { + it('first GETs the schema then POSTs to /content/collection/ for collection types', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/schemas/posts')) { + return jsonResponse({ + id: 'sch_1', + slug: 'posts', + name: 'Posts', + type: 'collection', + }) + } + return jsonResponse({ id: 'ent_1' }, 201) + }) + vi.stubGlobal('fetch', fetchMock) + const out = await kontenaCreateEntryTool.handle( + { schemaSlug: 'posts', entry: { slug: 'hello', locale: 'en', data: { x: 1 } } }, + baseCtx, + ) + expect(fetchMock).toHaveBeenCalledTimes(2) + const [url2, init2] = fetchMock.mock.calls[1] as unknown as [string, RequestInit] + expect(url2).toBe( + 'https://api.sawala.cloud/cli/kontena/projects/proj_01abc/content/collection/posts', + ) + expect(init2.method).toBe('POST') + expect(out).toEqual({ id: 'ent_1' }) + }) + + it('routes to /content/single/ when the schema type is single', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/schemas/site-settings')) { + return jsonResponse({ + id: 'sch_2', + slug: 'site-settings', + name: 'Site Settings', + type: 'single', + }) + } + return jsonResponse({ id: 'ent_1' }, 201) + }) + vi.stubGlobal('fetch', fetchMock) + await kontenaCreateEntryTool.handle( + { schemaSlug: 'site-settings', entry: { locale: 'en', data: {} } }, + baseCtx, + ) + const [url2] = fetchMock.mock.calls[1] as unknown as [string, RequestInit] + expect(url2).toBe( + 'https://api.sawala.cloud/cli/kontena/projects/proj_01abc/content/single/site-settings', + ) + }) + + it("publish:true injects status='published' into the POST body", async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/schemas/posts')) { + return jsonResponse({ id: 'sch_1', slug: 'posts', name: 'Posts', type: 'collection' }) + } + return jsonResponse({ id: 'ent_1' }, 201) + }) + vi.stubGlobal('fetch', fetchMock) + await kontenaCreateEntryTool.handle( + { + schemaSlug: 'posts', + entry: { slug: 'hello', locale: 'en', data: {} }, + publish: true, + }, + baseCtx, + ) + const [, init] = fetchMock.mock.calls[1] as unknown as [string, RequestInit] + const sent = JSON.parse(init.body as string) as Record + expect(sent.status).toBe('published') + }) +}) diff --git a/packages/sawala-mcp/test/tools/kontena-create-schema.test.ts b/packages/sawala-mcp/test/tools/kontena-create-schema.test.ts new file mode 100644 index 0000000..c4fa79f --- /dev/null +++ b/packages/sawala-mcp/test/tools/kontena-create-schema.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { kontenaCreateSchemaTool } from '../../src/tools/kontena-create-schema' +import type { CliContext } from '../../src/lib/auth' + +const baseCtx: CliContext = { + token: 'koda_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', + apiBase: 'https://api.sawala.cloud', + activeOrg: 'acme', + activeProject: 'blog', + activeProjectId: 'proj_01abc', + scopeOrgId: null, + scopeOrgSlug: null, + tokenSource: 'file', +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +describe('sawala_kontena_create_schema', () => { + it('POSTs the schema body to /schemas and returns the created row', async () => { + const body = { name: 'Posts', type: 'collection', fields: [] } + const created = { id: 'sch_1', slug: 'posts', ...body } + const fetchMock = vi.fn(async () => jsonResponse(created, 201)) + vi.stubGlobal('fetch', fetchMock) + + const out = await kontenaCreateSchemaTool.handle({ schema: body }, baseCtx) + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe('https://api.sawala.cloud/cli/kontena/projects/proj_01abc/schemas') + expect(init.method).toBe('POST') + expect(JSON.parse(init.body as string)).toEqual(body) + expect(out).toEqual(created) + }) + + it('throws when activeProjectId is null', async () => { + await expect( + kontenaCreateSchemaTool.handle({ schema: {} }, { ...baseCtx, activeProjectId: null }), + ).rejects.toThrow(/No active project id/) + }) + + it('rejects empty input via the zod parser', () => { + expect(() => kontenaCreateSchemaTool.parseInput({})).toThrow(/schema/) + }) +}) diff --git a/packages/sawala-mcp/test/tools/kontena-delete-entry.test.ts b/packages/sawala-mcp/test/tools/kontena-delete-entry.test.ts new file mode 100644 index 0000000..1b54394 --- /dev/null +++ b/packages/sawala-mcp/test/tools/kontena-delete-entry.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { kontenaDeleteEntryTool } from '../../src/tools/kontena-delete-entry' +import type { CliContext } from '../../src/lib/auth' + +const baseCtx: CliContext = { + token: 'koda_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', + apiBase: 'https://api.sawala.cloud', + activeOrg: 'acme', + activeProject: 'blog', + activeProjectId: 'proj_01abc', + scopeOrgId: null, + scopeOrgSlug: null, + tokenSource: 'file', +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +describe('sawala_kontena_delete_entry', () => { + it('DELETEs /content/collection// for collection entries', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/schemas/posts')) { + return jsonResponse({ id: 'sch_1', slug: 'posts', name: 'Posts', type: 'collection' }) + } + return jsonResponse({ deleted: true }) + }) + vi.stubGlobal('fetch', fetchMock) + await kontenaDeleteEntryTool.handle( + { schemaSlug: 'posts', slugOrId: 'hello', confirm: true }, + baseCtx, + ) + const [url2, init2] = fetchMock.mock.calls[1] as unknown as [string, RequestInit] + expect(url2).toBe( + 'https://api.sawala.cloud/cli/kontena/projects/proj_01abc/content/collection/posts/hello', + ) + expect(init2.method).toBe('DELETE') + }) + + it('appends ?locale= for single-type entries when locale is provided', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/schemas/site-settings')) { + return jsonResponse({ + id: 'sch_2', + slug: 'site-settings', + name: 'Site Settings', + type: 'single', + }) + } + return jsonResponse({ deleted: true }) + }) + vi.stubGlobal('fetch', fetchMock) + await kontenaDeleteEntryTool.handle( + { schemaSlug: 'site-settings', slugOrId: 'unused', locale: 'en', confirm: true }, + baseCtx, + ) + const [url2] = fetchMock.mock.calls[1] as unknown as [string, RequestInit] + expect(url2).toBe( + 'https://api.sawala.cloud/cli/kontena/projects/proj_01abc/content/single/site-settings?locale=en', + ) + }) + + it('rejects calls missing confirm:true via the zod parser', () => { + expect(() => + kontenaDeleteEntryTool.parseInput({ schemaSlug: 'posts', slugOrId: 'hello' }), + ).toThrow() + }) +}) diff --git a/packages/sawala-mcp/test/tools/kontena-delete-schema.test.ts b/packages/sawala-mcp/test/tools/kontena-delete-schema.test.ts new file mode 100644 index 0000000..12f22e9 --- /dev/null +++ b/packages/sawala-mcp/test/tools/kontena-delete-schema.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { kontenaDeleteSchemaTool } from '../../src/tools/kontena-delete-schema' +import type { CliContext } from '../../src/lib/auth' + +const baseCtx: CliContext = { + token: 'koda_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', + apiBase: 'https://api.sawala.cloud', + activeOrg: 'acme', + activeProject: 'blog', + activeProjectId: 'proj_01abc', + scopeOrgId: null, + scopeOrgSlug: null, + tokenSource: 'file', +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +describe('sawala_kontena_delete_schema', () => { + it('DELETEs /schemas/ when confirm:true', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ deleted: true })) + vi.stubGlobal('fetch', fetchMock) + const out = await kontenaDeleteSchemaTool.handle( + { slugOrId: 'posts', confirm: true }, + baseCtx, + ) + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe( + 'https://api.sawala.cloud/cli/kontena/projects/proj_01abc/schemas/posts', + ) + expect(init.method).toBe('DELETE') + expect(out).toEqual({ deleted: true }) + }) + + it('rejects payloads missing confirm:true via the zod parser', () => { + expect(() => kontenaDeleteSchemaTool.parseInput({ slugOrId: 'posts' })).toThrow() + expect(() => + kontenaDeleteSchemaTool.parseInput({ slugOrId: 'posts', confirm: false }), + ).toThrow() + }) + + it('advertises destructive + irreversible hints to MCP hosts', () => { + expect(kontenaDeleteSchemaTool.annotations.destructiveHint).toBe(true) + expect(kontenaDeleteSchemaTool.annotations.irreversibleHint).toBe(true) + }) +}) diff --git a/packages/sawala-mcp/test/tools/kontena-publish-entry.test.ts b/packages/sawala-mcp/test/tools/kontena-publish-entry.test.ts new file mode 100644 index 0000000..2ed43fa --- /dev/null +++ b/packages/sawala-mcp/test/tools/kontena-publish-entry.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { kontenaPublishEntryTool } from '../../src/tools/kontena-publish-entry' +import { kontenaUnpublishEntryTool } from '../../src/tools/kontena-unpublish-entry' +import type { CliContext } from '../../src/lib/auth' + +const baseCtx: CliContext = { + token: 'koda_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', + apiBase: 'https://api.sawala.cloud', + activeOrg: 'acme', + activeProject: 'blog', + activeProjectId: 'proj_01abc', + scopeOrgId: null, + scopeOrgSlug: null, + tokenSource: 'file', +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +describe('sawala_kontena_publish_entry', () => { + it('PUTs body={status:"published"} to the collection entry path', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ id: 'ent_1', status: 'published' })) + vi.stubGlobal('fetch', fetchMock) + await kontenaPublishEntryTool.handle( + { schemaSlug: 'posts', slugOrId: 'hello' }, + baseCtx, + ) + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe( + 'https://api.sawala.cloud/cli/kontena/projects/proj_01abc/content/collection/posts/hello', + ) + expect(init.method).toBe('PUT') + expect(JSON.parse(init.body as string)).toEqual({ status: 'published' }) + }) + + it('advertises idempotent + non-destructive hints', () => { + expect(kontenaPublishEntryTool.annotations.idempotentHint).toBe(true) + expect(kontenaPublishEntryTool.annotations.destructiveHint).toBe(false) + }) +}) + +describe('sawala_kontena_unpublish_entry', () => { + it('PUTs body={status:"draft"} to the collection entry path', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ id: 'ent_1', status: 'draft' })) + vi.stubGlobal('fetch', fetchMock) + await kontenaUnpublishEntryTool.handle( + { schemaSlug: 'posts', slugOrId: 'hello' }, + baseCtx, + ) + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(JSON.parse(init.body as string)).toEqual({ status: 'draft' }) + }) +}) diff --git a/packages/sawala-mcp/test/tools/kontena-update-entry.test.ts b/packages/sawala-mcp/test/tools/kontena-update-entry.test.ts new file mode 100644 index 0000000..6397743 --- /dev/null +++ b/packages/sawala-mcp/test/tools/kontena-update-entry.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { kontenaUpdateEntryTool } from '../../src/tools/kontena-update-entry' +import type { CliContext } from '../../src/lib/auth' + +const baseCtx: CliContext = { + token: 'koda_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', + apiBase: 'https://api.sawala.cloud', + activeOrg: 'acme', + activeProject: 'blog', + activeProjectId: 'proj_01abc', + scopeOrgId: null, + scopeOrgSlug: null, + tokenSource: 'file', +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +describe('sawala_kontena_update_entry', () => { + it('PUTs /content/collection// for collection schemas', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/schemas/posts')) { + return jsonResponse({ id: 'sch_1', slug: 'posts', name: 'Posts', type: 'collection' }) + } + return jsonResponse({ id: 'ent_1' }) + }) + vi.stubGlobal('fetch', fetchMock) + await kontenaUpdateEntryTool.handle( + { schemaSlug: 'posts', slugOrId: 'hello', patch: { data: { title: 'x' } } }, + baseCtx, + ) + const [url2, init2] = fetchMock.mock.calls[1] as unknown as [string, RequestInit] + expect(url2).toBe( + 'https://api.sawala.cloud/cli/kontena/projects/proj_01abc/content/collection/posts/hello', + ) + expect(init2.method).toBe('PUT') + }) + + it('PUTs /content/single/ for single-type schemas (slugOrId is ignored at the URL level)', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/schemas/site-settings')) { + return jsonResponse({ + id: 'sch_2', + slug: 'site-settings', + name: 'Site Settings', + type: 'single', + }) + } + return jsonResponse({ id: 'ent_1' }) + }) + vi.stubGlobal('fetch', fetchMock) + await kontenaUpdateEntryTool.handle( + { schemaSlug: 'site-settings', slugOrId: 'unused', patch: { data: {} } }, + baseCtx, + ) + const [url2] = fetchMock.mock.calls[1] as unknown as [string, RequestInit] + expect(url2).toBe( + 'https://api.sawala.cloud/cli/kontena/projects/proj_01abc/content/single/site-settings', + ) + }) +}) diff --git a/packages/sawala-mcp/test/tools/kontena-update-schema.test.ts b/packages/sawala-mcp/test/tools/kontena-update-schema.test.ts new file mode 100644 index 0000000..0a5c36c --- /dev/null +++ b/packages/sawala-mcp/test/tools/kontena-update-schema.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { kontenaUpdateSchemaTool } from '../../src/tools/kontena-update-schema' +import type { CliContext } from '../../src/lib/auth' + +const baseCtx: CliContext = { + token: 'koda_ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', + apiBase: 'https://api.sawala.cloud', + activeOrg: 'acme', + activeProject: 'blog', + activeProjectId: 'proj_01abc', + scopeOrgId: null, + scopeOrgSlug: null, + tokenSource: 'file', +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +describe('sawala_kontena_update_schema', () => { + it('PUTs the patch body to /schemas/', async () => { + const patch = { name: 'Renamed Posts' } + const updated = { id: 'sch_1', slug: 'posts', ...patch } + const fetchMock = vi.fn(async () => jsonResponse(updated)) + vi.stubGlobal('fetch', fetchMock) + + const out = await kontenaUpdateSchemaTool.handle( + { slugOrId: 'posts', patch }, + baseCtx, + ) + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe( + 'https://api.sawala.cloud/cli/kontena/projects/proj_01abc/schemas/posts', + ) + expect(init.method).toBe('PUT') + expect(JSON.parse(init.body as string)).toEqual(patch) + expect(out).toEqual(updated) + }) + + it('rejects missing patch via the zod parser', () => { + expect(() => kontenaUpdateSchemaTool.parseInput({ slugOrId: 'posts' })).toThrow( + /patch/, + ) + }) +}) diff --git a/packages/sawala/src/commands/kontena.ts b/packages/sawala/src/commands/kontena.ts index 8a678eb..ad3c9f9 100644 --- a/packages/sawala/src/commands/kontena.ts +++ b/packages/sawala/src/commands/kontena.ts @@ -8,11 +8,13 @@ import { requireActiveProject, requireActiveProjectId, } from '@sawala/auth' +import { confirmOrThrow, resolveInputPayload } from '../lib/io' /** * Kontena is the lightweight content service in the Sawala suite. It exposes * a strapi-v5 compatible read/write API of "schemas" (content models) and - * "entries" (the items inside a schema). The CLI surface here is read-only. + * "entries" (the items inside a schema). The CLI surface here covers both + * read-only inspection and the create/update/delete + publish workflow. * * Note: the Kontena worker resolves `:projId` in the URL path as the project's * stable ULID — not its slug. The api-gateway canonicalises the @@ -85,6 +87,30 @@ function parseState(raw: string | undefined): PublicationState { return raw } +/** + * Fetch a schema's `type` (single vs collection) so the entry CRUD verbs can + * pick the right `/content/{single,collection}/...` subpath. The schema + * type is an implementation detail of the kontena data model — exposing it + * as a user-facing `--type` flag would leak the wire-protocol asymmetry into + * the CLI surface, so we infer transparently. Costs one extra round-trip per + * mutation; document the LRU-cache escape hatch if profiling later flags it. + */ +async function fetchSchemaType( + ctx: Awaited>, + projectId: string, + schemaSlug: string, +): Promise<'single' | 'collection'> { + const schema = await apiFetch( + ctx, + `/cli/kontena/projects/${encodeURIComponent(projectId)}/schemas/${encodeURIComponent(schemaSlug)}`, + ) + const t = schema.type + if (t !== 'single' && t !== 'collection') { + throw new Error(`Schema '${schemaSlug}' has unexpected type '${t}'.`) + } + return t +} + async function listSchemas(): Promise { const ctx = await loadContext(SAWALA_BRAND) requireActiveOrg(ctx, SAWALA_BRAND) @@ -108,7 +134,7 @@ async function listSchemas(): Promise { export function createKontenaCommand(): Command { const kontena = new Command('kontena').description( - 'Read-only Kontena content commands (schemas + entries).', + 'Kontena content commands (schemas + entries, read + write).', ) // Service-root shortcut: `sawala kontena list` → same as `kontena schema list`. @@ -117,7 +143,9 @@ export function createKontenaCommand(): Command { .description('Shortcut for `sawala kontena schema list`.') .action(listSchemas) - const schema = new Command('schema').description('Inspect Kontena content schemas.') + const schema = new Command('schema').description( + 'Manage Kontena content schemas (list, get, create, update, delete).', + ) schema .command('list') @@ -164,9 +192,95 @@ export function createKontenaCommand(): Command { process.stdout.write(JSON.stringify(result, null, 2) + '\n') }) + schema + .command('create') + .description('Create a new schema. Provide the body via --file or --data.') + .option('-f, --file ', "Read JSON body from path. Use '-' for stdin.") + .option('-d, --data ', 'Inline JSON body.') + .option('--dry-run', 'Validate and print the payload without writing.') + .action( + async (opts: { file?: string; data?: string; dryRun?: boolean }) => { + const ctx = await loadContext(SAWALA_BRAND) + requireActiveOrg(ctx, SAWALA_BRAND) + requireActiveProject(ctx, SAWALA_BRAND) + const projectId = requireActiveProjectId(ctx, SAWALA_BRAND) + + const body = await resolveInputPayload(opts) + if (opts.dryRun) { + process.stdout.write( + JSON.stringify({ wouldSend: { method: 'POST', body } }, null, 2) + '\n', + ) + return + } + const result = await apiFetch( + ctx, + `/cli/kontena/projects/${encodeURIComponent(projectId)}/schemas`, + { method: 'POST', body }, + ) + process.stdout.write(JSON.stringify(result, null, 2) + '\n') + }, + ) + + schema + .command('update ') + .description('Update a schema. Body is treated as a PUT replacement.') + .option('-f, --file ', "Read JSON body from path. Use '-' for stdin.") + .option('-d, --data ', 'Inline JSON body.') + .option('--dry-run', 'Validate and print the payload without writing.') + .action( + async ( + slugOrId: string, + opts: { file?: string; data?: string; dryRun?: boolean }, + ) => { + const ctx = await loadContext(SAWALA_BRAND) + requireActiveOrg(ctx, SAWALA_BRAND) + requireActiveProject(ctx, SAWALA_BRAND) + const projectId = requireActiveProjectId(ctx, SAWALA_BRAND) + + const body = await resolveInputPayload(opts) + if (opts.dryRun) { + process.stdout.write( + JSON.stringify({ wouldSend: { method: 'PUT', body } }, null, 2) + '\n', + ) + return + } + const result = await apiFetch( + ctx, + `/cli/kontena/projects/${encodeURIComponent(projectId)}/schemas/${encodeURIComponent(slugOrId)}`, + { method: 'PUT', body }, + ) + process.stdout.write(JSON.stringify(result, null, 2) + '\n') + }, + ) + + schema + .command('delete ') + .description('Delete a schema. Requires --yes or a TTY for confirmation.') + .option('-y, --yes', 'Skip the confirmation prompt.') + .action(async (slugOrId: string, opts: { yes?: boolean }) => { + const ctx = await loadContext(SAWALA_BRAND) + requireActiveOrg(ctx, SAWALA_BRAND) + const activeProject = requireActiveProject(ctx, SAWALA_BRAND) + const projectId = requireActiveProjectId(ctx, SAWALA_BRAND) + + if (!opts.yes) { + await confirmOrThrow( + `Delete schema '${slugOrId}' in project '${activeProject}'?`, + ) + } + const result = await apiFetch( + ctx, + `/cli/kontena/projects/${encodeURIComponent(projectId)}/schemas/${encodeURIComponent(slugOrId)}`, + { method: 'DELETE' }, + ) + process.stdout.write(JSON.stringify(result, null, 2) + '\n') + }) + kontena.addCommand(schema) - const entry = new Command('entry').description('Inspect Kontena content entries.') + const entry = new Command('entry').description( + 'Manage Kontena content entries (list, get, create, update, delete, publish).', + ) entry .command('list ') @@ -243,6 +357,145 @@ export function createKontenaCommand(): Command { }, ) + entry + .command('create ') + .description( + 'Create a content entry. Single-type schemas upsert per locale; ' + + 'collection schemas enforce slug uniqueness per (schemaSlug, locale).', + ) + .option('-f, --file ', "Read JSON body from path. Use '-' for stdin.") + .option('-d, --data ', 'Inline JSON body.') + .option('--publish', "Set status='published' on create (default is draft).") + .option('--dry-run', 'Validate and print the payload without writing.') + .action( + async ( + schemaSlug: string, + opts: { file?: string; data?: string; publish?: boolean; dryRun?: boolean }, + ) => { + const ctx = await loadContext(SAWALA_BRAND) + requireActiveOrg(ctx, SAWALA_BRAND) + requireActiveProject(ctx, SAWALA_BRAND) + const projectId = requireActiveProjectId(ctx, SAWALA_BRAND) + + const payload = (await resolveInputPayload(opts)) as Record + if (opts.publish) payload.status = 'published' + if (opts.dryRun) { + process.stdout.write( + JSON.stringify({ wouldSend: { method: 'POST', body: payload } }, null, 2) + '\n', + ) + return + } + const schemaType = await fetchSchemaType(ctx, projectId, schemaSlug) + const subpath = schemaType === 'single' ? 'single' : 'collection' + const result = await apiFetch( + ctx, + `/cli/kontena/projects/${encodeURIComponent(projectId)}/content/${subpath}/${encodeURIComponent(schemaSlug)}`, + { method: 'POST', body: payload }, + ) + process.stdout.write(JSON.stringify(result, null, 2) + '\n') + }, + ) + + entry + .command('update ') + .description('Update a content entry. PUT replacement semantics.') + .option('-f, --file ', "Read JSON body from path. Use '-' for stdin.") + .option('-d, --data ', 'Inline JSON body.') + .option('--publish', "Also set status='published' in the same write.") + .option('--dry-run', 'Validate and print the payload without writing.') + .action( + async ( + schemaSlug: string, + slugOrId: string, + opts: { file?: string; data?: string; publish?: boolean; dryRun?: boolean }, + ) => { + const ctx = await loadContext(SAWALA_BRAND) + requireActiveOrg(ctx, SAWALA_BRAND) + requireActiveProject(ctx, SAWALA_BRAND) + const projectId = requireActiveProjectId(ctx, SAWALA_BRAND) + + const payload = (await resolveInputPayload(opts)) as Record + if (opts.publish) payload.status = 'published' + if (opts.dryRun) { + process.stdout.write( + JSON.stringify({ wouldSend: { method: 'PUT', body: payload } }, null, 2) + '\n', + ) + return + } + const schemaType = await fetchSchemaType(ctx, projectId, schemaSlug) + const url = + schemaType === 'single' + ? `/cli/kontena/projects/${encodeURIComponent(projectId)}/content/single/${encodeURIComponent(schemaSlug)}` + : `/cli/kontena/projects/${encodeURIComponent(projectId)}/content/collection/${encodeURIComponent(schemaSlug)}/${encodeURIComponent(slugOrId)}` + const result = await apiFetch(ctx, url, { method: 'PUT', body: payload }) + process.stdout.write(JSON.stringify(result, null, 2) + '\n') + }, + ) + + entry + .command('delete ') + .description('Delete a content entry. Requires --yes or a TTY for confirmation.') + .option('-y, --yes', 'Skip the confirmation prompt.') + .option('--locale ', 'Locale to target (required for single-type schemas).') + .action( + async ( + schemaSlug: string, + slugOrId: string, + opts: { yes?: boolean; locale?: string }, + ) => { + const ctx = await loadContext(SAWALA_BRAND) + requireActiveOrg(ctx, SAWALA_BRAND) + requireActiveProject(ctx, SAWALA_BRAND) + const projectId = requireActiveProjectId(ctx, SAWALA_BRAND) + + if (!opts.yes) { + await confirmOrThrow(`Delete entry '${slugOrId}' from '${schemaSlug}'?`) + } + const schemaType = await fetchSchemaType(ctx, projectId, schemaSlug) + const url = + schemaType === 'single' + ? `/cli/kontena/projects/${encodeURIComponent(projectId)}/content/single/${encodeURIComponent(schemaSlug)}` + + (opts.locale ? `?locale=${encodeURIComponent(opts.locale)}` : '') + : `/cli/kontena/projects/${encodeURIComponent(projectId)}/content/collection/${encodeURIComponent(schemaSlug)}/${encodeURIComponent(slugOrId)}` + const result = await apiFetch(ctx, url, { method: 'DELETE' }) + process.stdout.write(JSON.stringify(result, null, 2) + '\n') + }, + ) + + entry + .command('publish ') + .description("Publish a draft collection entry (sets status='published').") + .action(async (schemaSlug: string, slugOrId: string) => { + const ctx = await loadContext(SAWALA_BRAND) + requireActiveOrg(ctx, SAWALA_BRAND) + requireActiveProject(ctx, SAWALA_BRAND) + const projectId = requireActiveProjectId(ctx, SAWALA_BRAND) + + const result = await apiFetch( + ctx, + `/cli/kontena/projects/${encodeURIComponent(projectId)}/content/collection/${encodeURIComponent(schemaSlug)}/${encodeURIComponent(slugOrId)}`, + { method: 'PUT', body: { status: 'published' } }, + ) + process.stdout.write(JSON.stringify(result, null, 2) + '\n') + }) + + entry + .command('unpublish ') + .description("Unpublish a collection entry (sets status='draft').") + .action(async (schemaSlug: string, slugOrId: string) => { + const ctx = await loadContext(SAWALA_BRAND) + requireActiveOrg(ctx, SAWALA_BRAND) + requireActiveProject(ctx, SAWALA_BRAND) + const projectId = requireActiveProjectId(ctx, SAWALA_BRAND) + + const result = await apiFetch( + ctx, + `/cli/kontena/projects/${encodeURIComponent(projectId)}/content/collection/${encodeURIComponent(schemaSlug)}/${encodeURIComponent(slugOrId)}`, + { method: 'PUT', body: { status: 'draft' } }, + ) + process.stdout.write(JSON.stringify(result, null, 2) + '\n') + }) + kontena.addCommand(entry) return kontena diff --git a/packages/sawala/src/lib/io.ts b/packages/sawala/src/lib/io.ts new file mode 100644 index 0000000..5b9a629 --- /dev/null +++ b/packages/sawala/src/lib/io.ts @@ -0,0 +1,80 @@ +import { readFile } from 'node:fs/promises' +import { createInterface } from 'node:readline/promises' + +/** + * Read a JSON payload from a file path, or from stdin when path === '-'. + * Throws with a clear message on missing files or malformed JSON. + */ +export async function readJsonInput(path: string): Promise { + if (path === '-') { + const chunks: Buffer[] = [] + for await (const chunk of process.stdin) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : (chunk as Buffer)) + } + const raw = Buffer.concat(chunks).toString('utf8') + try { + return JSON.parse(raw) + } catch (e) { + throw new Error(`Invalid JSON on stdin: ${(e as Error).message}`) + } + } + let raw: string + try { + raw = await readFile(path, 'utf8') + } catch (e) { + throw new Error(`Cannot read ${path}: ${(e as Error).message}`) + } + try { + return JSON.parse(raw) + } catch (e) { + throw new Error(`Invalid JSON in ${path}: ${(e as Error).message}`) + } +} + +/** + * Prompt on stdin for a yes/no answer. Resolves on a "y"/"yes" answer + * and throws on anything else. + * + * Refuses to run when stdin is not a TTY — scripted callers must + * short-circuit with --yes before invoking this helper. + */ +export async function confirmOrThrow(question: string): Promise { + if (!process.stdin.isTTY) { + throw new Error( + 'Refusing destructive operation without --yes (no TTY for confirmation prompt).', + ) + } + const rl = createInterface({ input: process.stdin, output: process.stderr }) + try { + const answer = (await rl.question(`${question} [y/N]: `)).trim().toLowerCase() + if (answer !== 'y' && answer !== 'yes') { + throw new Error('Aborted by user.') + } + } finally { + rl.close() + } +} + +/** + * Resolve `--data ` or `--file ` (or `--file -` for stdin) into + * a parsed payload. Exactly one of the two must be provided. + */ +export async function resolveInputPayload(opts: { + data?: string + file?: string +}): Promise { + if (opts.data && opts.file) { + throw new Error('Pass either --data or --file, not both.') + } + if (opts.data) { + try { + return JSON.parse(opts.data) + } catch (e) { + throw new Error(`Invalid JSON in --data: ${(e as Error).message}`) + } + } + if (opts.file) { + return readJsonInput(opts.file) + } + throw new Error('Provide the request body via --file or --data .') +} diff --git a/packages/sawala/test/kontena.test.ts b/packages/sawala/test/kontena.test.ts index fc68003..473bfd5 100644 --- a/packages/sawala/test/kontena.test.ts +++ b/packages/sawala/test/kontena.test.ts @@ -346,3 +346,363 @@ describe('sawala kontena entry get', () => { expect(JSON.parse(cap.lines.join(''))).toEqual(entry) }) }) + +describe('sawala kontena schema create / update / delete', () => { + it('create POSTs the parsed --file body to /schemas', async () => { + const filePath = join(tmpDir, 'schema.json') + const body = { + name: 'Posts', + type: 'collection', + fields: [{ name: 'title', type: 'text', required: true }], + } + await fs.writeFile(filePath, JSON.stringify(body), 'utf8') + const created = { id: 'sch_1', slug: 'posts', ...body } + const fetchMock = vi.fn(async () => jsonResponse(created, 201)) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', + 'sawala', + 'kontena', + 'schema', + 'create', + '--file', + filePath, + ]) + cap.restore() + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${API_BASE}/cli/kontena/projects/${PROJECT_ID}/schemas`) + expect(init.method).toBe('POST') + expect(JSON.parse(init.body as string)).toEqual(body) + expect(JSON.parse(cap.lines.join(''))).toEqual(created) + }) + + it('create with --data parses inline JSON and POSTs without touching the filesystem', async () => { + const body = { name: 'Posts', type: 'collection', fields: [] } + const fetchMock = vi.fn(async () => jsonResponse({ id: 'sch_1', ...body }, 201)) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', + 'sawala', + 'kontena', + 'schema', + 'create', + '--data', + JSON.stringify(body), + ]) + cap.restore() + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(JSON.parse(init.body as string)).toEqual(body) + }) + + it('create --dry-run prints the would-be request without calling fetch', async () => { + const body = { name: 'Posts', type: 'collection', fields: [] } + const fetchMock = vi.fn(async () => jsonResponse({}, 200)) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', + 'sawala', + 'kontena', + 'schema', + 'create', + '--data', + JSON.stringify(body), + '--dry-run', + ]) + cap.restore() + expect(fetchMock).not.toHaveBeenCalled() + const out = JSON.parse(cap.lines.join('')) + expect(out.wouldSend).toEqual({ method: 'POST', body }) + }) + + it('create errors when neither --file nor --data is provided', async () => { + const fetchMock = vi.fn(async () => jsonResponse({}, 200)) + vi.stubGlobal('fetch', fetchMock) + await expect( + createProgram().parseAsync(['node', 'sawala', 'kontena', 'schema', 'create']), + ).rejects.toThrow(/--file or --data /) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('update PUTs to /schemas/ with the parsed body', async () => { + const body = { name: 'Updated Posts', type: 'collection', fields: [] } + const updated = { id: 'sch_1', slug: 'posts', ...body } + const fetchMock = vi.fn(async () => jsonResponse(updated)) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', + 'sawala', + 'kontena', + 'schema', + 'update', + 'posts', + '--data', + JSON.stringify(body), + ]) + cap.restore() + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${API_BASE}/cli/kontena/projects/${PROJECT_ID}/schemas/posts`) + expect(init.method).toBe('PUT') + expect(JSON.parse(init.body as string)).toEqual(body) + }) + + it('delete with --yes skips the prompt and DELETEs', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ deleted: true })) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', + 'sawala', + 'kontena', + 'schema', + 'delete', + 'posts', + '--yes', + ]) + cap.restore() + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe(`${API_BASE}/cli/kontena/projects/${PROJECT_ID}/schemas/posts`) + expect(init.method).toBe('DELETE') + expect(JSON.parse(cap.lines.join(''))).toEqual({ deleted: true }) + }) + + it('delete without --yes in non-TTY refuses to run', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ deleted: true })) + vi.stubGlobal('fetch', fetchMock) + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: false }) + try { + await expect( + createProgram().parseAsync(['node', 'sawala', 'kontena', 'schema', 'delete', 'posts']), + ).rejects.toThrow(/Refusing destructive operation without --yes/) + } finally { + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value: undefined }) + } + expect(fetchMock).not.toHaveBeenCalled() + }) +}) + +describe('sawala kontena entry create / update / delete', () => { + function collectionSchemaResponse(): Response { + return jsonResponse({ + id: 'sch_1', + documentId: 'doc_1', + slug: 'posts', + name: 'Posts', + type: 'collection', + }) + } + + function singleSchemaResponse(): Response { + return jsonResponse({ + id: 'sch_2', + documentId: 'doc_2', + slug: 'site-settings', + name: 'Site Settings', + type: 'single', + }) + } + + it('create against a collection schema POSTs to /content/collection/', async () => { + const entry = { slug: 'hello', locale: 'en', data: { title: 'Hi' } } + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/schemas/posts')) return collectionSchemaResponse() + return jsonResponse({ id: 'ent_1', ...entry }, 201) + }) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', + 'sawala', + 'kontena', + 'entry', + 'create', + 'posts', + '--data', + JSON.stringify(entry), + ]) + cap.restore() + expect(fetchMock).toHaveBeenCalledTimes(2) + const [url2, init2] = fetchMock.mock.calls[1] as unknown as [string, RequestInit] + expect(url2).toBe( + `${API_BASE}/cli/kontena/projects/${PROJECT_ID}/content/collection/posts`, + ) + expect(init2.method).toBe('POST') + expect(JSON.parse(init2.body as string)).toEqual(entry) + }) + + it('create against a single-type schema POSTs to /content/single/', async () => { + const entry = { locale: 'en', data: { siteTitle: 'Sawala' } } + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/schemas/site-settings')) return singleSchemaResponse() + return jsonResponse({ id: 'ent_1', ...entry }, 201) + }) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', + 'sawala', + 'kontena', + 'entry', + 'create', + 'site-settings', + '--data', + JSON.stringify(entry), + ]) + cap.restore() + const [url2] = fetchMock.mock.calls[1] as unknown as [string, RequestInit] + expect(url2).toBe( + `${API_BASE}/cli/kontena/projects/${PROJECT_ID}/content/single/site-settings`, + ) + }) + + it('create --publish injects status=published into the body', async () => { + const entry = { slug: 'hello', locale: 'en', data: { title: 'Hi' } } + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/schemas/posts')) return collectionSchemaResponse() + return jsonResponse({ id: 'ent_1', ...entry, status: 'published' }, 201) + }) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', + 'sawala', + 'kontena', + 'entry', + 'create', + 'posts', + '--data', + JSON.stringify(entry), + '--publish', + ]) + cap.restore() + const [, init] = fetchMock.mock.calls[1] as unknown as [string, RequestInit] + const sent = JSON.parse(init.body as string) as Record + expect(sent.status).toBe('published') + expect(sent.slug).toBe('hello') + }) + + it('update PUTs to /content/collection// for collection schemas', async () => { + const patch = { data: { title: 'Updated' } } + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/schemas/posts')) return collectionSchemaResponse() + return jsonResponse({ id: 'ent_1', ...patch }) + }) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', + 'sawala', + 'kontena', + 'entry', + 'update', + 'posts', + 'hello', + '--data', + JSON.stringify(patch), + ]) + cap.restore() + const [url2, init2] = fetchMock.mock.calls[1] as unknown as [string, RequestInit] + expect(url2).toBe( + `${API_BASE}/cli/kontena/projects/${PROJECT_ID}/content/collection/posts/hello`, + ) + expect(init2.method).toBe('PUT') + }) + + it('delete on collection DELETEs /content/collection//', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/schemas/posts')) return collectionSchemaResponse() + return jsonResponse({ deleted: true }) + }) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', + 'sawala', + 'kontena', + 'entry', + 'delete', + 'posts', + 'hello', + '--yes', + ]) + cap.restore() + const [url2, init2] = fetchMock.mock.calls[1] as unknown as [string, RequestInit] + expect(url2).toBe( + `${API_BASE}/cli/kontena/projects/${PROJECT_ID}/content/collection/posts/hello`, + ) + expect(init2.method).toBe('DELETE') + }) + + it('delete on single-type with --locale appends ?locale=', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('/schemas/site-settings')) return singleSchemaResponse() + return jsonResponse({ deleted: true }) + }) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', + 'sawala', + 'kontena', + 'entry', + 'delete', + 'site-settings', + 'ignored', + '--locale', + 'en', + '--yes', + ]) + cap.restore() + const [url2] = fetchMock.mock.calls[1] as unknown as [string, RequestInit] + expect(url2).toBe( + `${API_BASE}/cli/kontena/projects/${PROJECT_ID}/content/single/site-settings?locale=en`, + ) + }) +}) + +describe('sawala kontena entry publish / unpublish', () => { + it('publish PUTs body={status:"published"} to the collection entry path', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ id: 'ent_1', status: 'published' })) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', + 'sawala', + 'kontena', + 'entry', + 'publish', + 'posts', + 'hello', + ]) + cap.restore() + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe( + `${API_BASE}/cli/kontena/projects/${PROJECT_ID}/content/collection/posts/hello`, + ) + expect(init.method).toBe('PUT') + expect(JSON.parse(init.body as string)).toEqual({ status: 'published' }) + }) + + it('unpublish PUTs body={status:"draft"} to the collection entry path', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ id: 'ent_1', status: 'draft' })) + vi.stubGlobal('fetch', fetchMock) + const cap = captureStdout() + await createProgram().parseAsync([ + 'node', + 'sawala', + 'kontena', + 'entry', + 'unpublish', + 'posts', + 'hello', + ]) + cap.restore() + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect(JSON.parse(init.body as string)).toEqual({ status: 'draft' }) + }) +})