Add free-text api_instructions field to Sites

For custom sites we'll post to via their own API — there's no wired-in
publisher for these, so this is where the site owner documents how to
actually post there (auth, endpoints, payload shape). Shown in the UI
for non-WordPress sites, exposed via the JSON API, and pointed to from
AI_API.md's "any other network/CMS" fallback section so an agent checks
it before improvising.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
m105
2026-07-22 14:03:43 +03:00
co-authored by Claude Sonnet 5
parent 4b3af4cc89
commit b3b250aaca
9 changed files with 110 additions and 5 deletions
+15 -5
View File
@@ -82,12 +82,21 @@ Response shape:
"cms_type": "wordpress", "cms_type": "wordpress",
"wp_username": "admin", "wp_username": "admin",
"wp_app_password": "abcd 1234 efgh 5678", "wp_app_password": "abcd 1234 efgh 5678",
"api_instructions": null,
"created_at": "2026-07-19T12:00:00.000000Z", "created_at": "2026-07-19T12:00:00.000000Z",
"updated_at": "2026-07-19T12:00:00.000000Z" "updated_at": "2026-07-19T12:00:00.000000Z"
} }
} }
``` ```
`api_instructions` is a free-text field (nullable) for `cms_type=custom`/`other`
sites — there's no built-in publisher for these yet, so this is where the
user documents how to actually post there: auth method, endpoints, payload
shape, whatever's needed. **Read it before improvising** when you're about
to publish to a site with no wired-in publisher (see "Any other
network/CMS" below) — it's the site owner telling you directly how their
API works, rather than you having to guess or ask.
`cms_type` is one of `wordpress`, `custom`, `other`. `wp_username`/ `cms_type` is one of `wordpress`, `custom`, `other`. `wp_username`/
`wp_app_password` are only meaningful (non-null) when `cms_type` is `wp_app_password` are only meaningful (non-null) when `cms_type` is
`wordpress` — they're a WordPress **Application Password** pair (see `wordpress` — they're a WordPress **Application Password** pair (see
@@ -269,11 +278,12 @@ integration yet)
There is no built-in publishing path. Do it however is appropriate for There is no built-in publishing path. Do it however is appropriate for
that platform (its own API, its own automation), using the credentials that platform (its own API, its own automation), using the credentials
from `GET /api/sites` / `GET /api/accounts`, then report back via from `GET /api/sites` / `GET /api/accounts` — check the site's
`PATCH /api/posts/{id}` exactly as described above. This is the general `api_instructions` first, that's exactly what it's there for — then report
fallback contract: **whatever publishes a post, always ends with either back via `PATCH /api/posts/{id}` exactly as described above. This is the
the `posts:publish` command's automatic bookkeeping, or a manual `PATCH` general fallback contract: **whatever publishes a post, always ends with
setting `status` and, if applicable, `published_url`.** either the `posts:publish` command's automatic bookkeeping, or a manual
`PATCH` setting `status` and, if applicable, `published_url`.**
## Quick reference ## Quick reference
+1
View File
@@ -17,6 +17,7 @@ class SiteResource extends JsonResource
'cms_type' => $this->cms_type, 'cms_type' => $this->cms_type,
'wp_username' => $this->wp_username, 'wp_username' => $this->wp_username,
'wp_app_password' => $this->wp_app_password, 'wp_app_password' => $this->wp_app_password,
'api_instructions' => $this->api_instructions,
'created_at' => $this->created_at, 'created_at' => $this->created_at,
'updated_at' => $this->updated_at, 'updated_at' => $this->updated_at,
]; ];
+6
View File
@@ -27,6 +27,8 @@ class Index extends Component
public string $wpAppPassword = ''; public string $wpAppPassword = '';
public ?string $apiInstructions = null;
public function render() public function render()
{ {
return view('livewire.sites.index', [ return view('livewire.sites.index', [
@@ -51,6 +53,7 @@ class Index extends Component
$this->cmsType = $site->cms_type; $this->cmsType = $site->cms_type;
$this->wpUsername = $site->wp_username ?? ''; $this->wpUsername = $site->wp_username ?? '';
$this->wpAppPassword = $site->wp_app_password ?? ''; $this->wpAppPassword = $site->wp_app_password ?? '';
$this->apiInstructions = $site->api_instructions;
$this->showForm = true; $this->showForm = true;
} }
@@ -63,6 +66,7 @@ class Index extends Component
'cmsType' => ['required', 'string', 'in:' . implode(',', Site::CMS_TYPES)], 'cmsType' => ['required', 'string', 'in:' . implode(',', Site::CMS_TYPES)],
'wpUsername' => ['required_if:cmsType,wordpress', 'nullable', 'string', 'max:255'], 'wpUsername' => ['required_if:cmsType,wordpress', 'nullable', 'string', 'max:255'],
'wpAppPassword' => ['required_if:cmsType,wordpress', 'nullable', 'string', 'max:255'], 'wpAppPassword' => ['required_if:cmsType,wordpress', 'nullable', 'string', 'max:255'],
'apiInstructions' => ['nullable', 'string'],
]); ]);
Site::updateOrCreate(['id' => $this->editingId], [ Site::updateOrCreate(['id' => $this->editingId], [
@@ -72,6 +76,7 @@ class Index extends Component
'cms_type' => $validated['cmsType'], 'cms_type' => $validated['cmsType'],
'wp_username' => $validated['cmsType'] === 'wordpress' ? $validated['wpUsername'] : null, 'wp_username' => $validated['cmsType'] === 'wordpress' ? $validated['wpUsername'] : null,
'wp_app_password' => $validated['cmsType'] === 'wordpress' ? $validated['wpAppPassword'] : null, 'wp_app_password' => $validated['cmsType'] === 'wordpress' ? $validated['wpAppPassword'] : null,
'api_instructions' => $validated['apiInstructions'],
]); ]);
$this->resetForm(); $this->resetForm();
@@ -97,6 +102,7 @@ class Index extends Component
$this->cmsType = 'custom'; $this->cmsType = 'custom';
$this->wpUsername = ''; $this->wpUsername = '';
$this->wpAppPassword = ''; $this->wpAppPassword = '';
$this->apiInstructions = null;
$this->resetValidation(); $this->resetValidation();
} }
} }
+1
View File
@@ -18,5 +18,6 @@ class Site extends Model
'cms_type', 'cms_type',
'wp_username', 'wp_username',
'wp_app_password', 'wp_app_password',
'api_instructions',
]; ];
} }
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('sites', function (Blueprint $table) {
$table->text('api_instructions')->nullable()->after('wp_app_password');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('sites', function (Blueprint $table) {
$table->dropColumn('api_instructions');
});
}
};
@@ -34,6 +34,8 @@
{{ $revealedId === $site->id ? 'Hide' : 'Show' }} {{ $revealedId === $site->id ? 'Hide' : 'Show' }}
</button> </button>
</div> </div>
@elseif ($site->api_instructions)
<div class="text-xs text-gray-500" title="{{ $site->api_instructions }}">Has API instructions</div>
@endif @endif
</td> </td>
<td class="p-3 text-right space-x-2"> <td class="p-3 text-right space-x-2">
@@ -89,6 +91,13 @@
<input type="text" wire:model="wpAppPassword" class="w-full border rounded p-2"> <input type="text" wire:model="wpAppPassword" class="w-full border rounded p-2">
@error('wpAppPassword') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror @error('wpAppPassword') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div> </div>
@else
<div>
<label class="block text-sm font-medium">API Instructions (optional)</label>
<p class="text-xs text-gray-500 mb-1">Notes for posting here via this site's own API — auth method, endpoints, payload shape, etc. Read by whoever (agent or you) ends up publishing to it.</p>
<textarea wire:model="apiInstructions" rows="4" class="w-full border rounded p-2"></textarea>
@error('apiInstructions') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
</div>
@endif @endif
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
+12
View File
@@ -46,3 +46,15 @@ test('it includes WordPress fields in the site response', function () {
->assertJsonPath('data.wp_username', 'admin') ->assertJsonPath('data.wp_username', 'admin')
->assertJsonPath('data.wp_app_password', 'abcd 1234 efgh 5678'); ->assertJsonPath('data.wp_app_password', 'abcd 1234 efgh 5678');
}); });
test('it includes api_instructions in the site response', function () {
$site = Site::factory()->create([
'cms_type' => 'custom',
'api_instructions' => 'POST to /api/publish with a bearer token from .env',
]);
$response = $this->getJson("/api/sites/{$site->id}");
$response->assertOk()
->assertJsonPath('data.api_instructions', 'POST to /api/publish with a bearer token from .env');
});
+21
View File
@@ -121,3 +121,24 @@ test('switching a WordPress site to a non-WordPress cms_type clears its credenti
->wp_username->toBeNull() ->wp_username->toBeNull()
->wp_app_password->toBeNull(); ->wp_app_password->toBeNull();
}); });
test('a custom site can be created with api_instructions', function () {
Livewire::test(Index::class)
->set('name', 'Custom Site')
->set('url', 'https://custom.example.com')
->set('cmsType', 'custom')
->set('apiInstructions', 'POST to /api/publish with a bearer token from .env')
->call('save');
$site = Site::where('name', 'Custom Site')->first();
expect($site->api_instructions)->toBe('POST to /api/publish with a bearer token from .env');
});
test('editing a site loads its api_instructions', function () {
$site = Site::factory()->create(['cms_type' => 'custom', 'api_instructions' => 'Use the /publish endpoint']);
Livewire::test(Index::class)
->call('edit', $site->id)
->assertSet('apiInstructions', 'Use the /publish endpoint');
});
+17
View File
@@ -50,3 +50,20 @@ test('a site can be created as WordPress with credentials', function () {
->wp_username->toBe('admin') ->wp_username->toBe('admin')
->wp_app_password->toBe('abcd 1234 efgh 5678'); ->wp_app_password->toBe('abcd 1234 efgh 5678');
}); });
test('a custom site can store free-text api_instructions', function () {
$site = Site::create([
'name' => 'Custom Site',
'url' => 'https://custom.example.com',
'cms_type' => 'custom',
'api_instructions' => 'POST to /api/publish with a bearer token from .env',
]);
expect($site->fresh()->api_instructions)->toBe('POST to /api/publish with a bearer token from .env');
});
test('api_instructions defaults to null', function () {
$site = Site::create(['name' => 'My Blog', 'url' => 'https://example.com']);
expect($site->fresh()->api_instructions)->toBeNull();
});