Ship plugin framework shore-up: frontend scaffold, sister-site adoption kit
All checks were successful
CI / backend (push) Successful in 24s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

- flask plugin new now scaffolds the frontend too: List/Detail/Form
  views on the global styles, a gated route module (ADR-009), and an
  api-client snippet emitted into the plugin dir. Views are written
  before the route file so a partially generated plugin cannot 500 the
  dev server.
- docs/PLUGIN-EXTERNAL-REPO.md + scripts/test-external-plugin.sh: how a
  sister site develops a plugin in its own repo and runs the framework
  contract tests in CI against a pinned framework ref (script verified
  to fail on a broken core_version pin).
- docs/CONTRACT-STABILITY.md: settled vs churning contract surface and
  the provisional 1.0 criteria.
- CLAUDE.md active-state refresh (contract 0.6.0, 11 plugins, 340
  tests, measuringtools done).

Known limitation documented: Path.rglob does not descend symlinks, so
the import-surface contract test skips symlinked external plugins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-11 10:30:03 -04:00
parent 94f852a1c8
commit 529b9f2fed
13 changed files with 1547 additions and 14 deletions

View File

@@ -57,12 +57,104 @@ def pascal_case(name: str) -> str:
return name[:1].upper() + name[1:]
def _render_template(
template_path: Path,
out_path: Path,
substitutions: dict,
overwrite: bool,
) -> bool:
"""Render one template to out_path.
Skips silently when out_path already exists and overwrite is False, so a
scaffold never clobbers a file the author has already edited. Returns True
when the file was written, False when it was skipped.
"""
if out_path.exists() and not overwrite:
return False
out_path.parent.mkdir(parents=True, exist_ok=True)
body = template_path.read_text()
out_path.write_text(Template(body).safe_substitute(substitutions))
return True
def _scaffold_frontend(
name: str,
substitutions: dict,
plugin_target: Path,
frontend_dir: Path,
template_root: Path,
overwrite: bool,
) -> None:
"""Render the frontend starting points for a scaffolded plugin.
Writes the paste-in api-client snippet into the plugin directory, then the
Vue views and the router route file into the real frontend tree. The
snippet is emitted regardless of whether the frontend tree exists, because
it is a plugin-directory artifact useful even for external-repo plugins.
Views and the route file are skipped when frontend_dir is missing, which is
the normal case for a plugin developed in its own repository.
Ordering matters: every view is written before the route file. A route file
that lazy-imports a view that is not on disk crashes the Vite dev server,
so the views must land first.
"""
fe_templates = template_root / 'frontend'
if not fe_templates.exists():
return
plugin_name = substitutions['Name']
# snippet lands in the plugin dir; author pastes it into api/index.js
snippet_template = fe_templates / 'frontend-api-snippet.js.tmpl'
if snippet_template.exists():
_render_template(
snippet_template,
plugin_target / 'frontend-api-snippet.js',
substitutions,
overwrite,
)
# views and route need the real frontend tree; external repos skip these
if not frontend_dir.exists():
return
views_dir = frontend_dir / 'views' / name
# views first: route file lazy-imports them, missing views 500 vite
view_templates = {
'List.vue.tmpl': f'{plugin_name}List.vue',
'Detail.vue.tmpl': f'{plugin_name}Detail.vue',
'Form.vue.tmpl': f'{plugin_name}Form.vue',
}
for template_name, out_name in view_templates.items():
template_path = fe_templates / 'views' / template_name
if template_path.exists():
_render_template(
template_path,
views_dir / out_name,
substitutions,
overwrite,
)
# route file last: all referenced views now exist on disk
route_template = fe_templates / 'routes.js.tmpl'
if route_template.exists():
_render_template(
route_template,
frontend_dir / 'router' / 'routes' / f'{name}.js',
substitutions,
overwrite,
)
def scaffold_plugin(
name: str,
description: str,
plugins_dir: Path,
template_root: Optional[Path] = None,
overwrite: bool = False,
frontend: bool = True,
frontend_dir: Optional[Path] = None,
) -> Path:
"""Generate a new plugin from templates.
@@ -71,7 +163,14 @@ def scaffold_plugin(
description: One-sentence description for manifest.json + README
plugins_dir: Target plugins directory (e.g., <repo>/plugins)
template_root: Override template source dir (default: bundled templates)
overwrite: If True, overwrite an existing plugin directory
overwrite: If True, overwrite an existing plugin directory and any
existing generated frontend files
frontend: If True, also render the Vue frontend starting points (list,
detail, form views, a route file, and a paste-in api-client snippet)
frontend_dir: Frontend src directory to render views/routes into
(default: <plugins_dir>/../frontend/src). Views and the route file
are skipped when this directory does not exist, which is the normal
case for a plugin developed in its own repository.
Returns:
Path to the generated plugin directory.
@@ -106,6 +205,10 @@ def scaffold_plugin(
for template_path in template_root.rglob('*.tmpl'):
rel = template_path.relative_to(template_root)
# frontend templates render into the frontend tree, not the plugin dir
if rel.parts and rel.parts[0] == 'frontend':
continue
out_rel_str = str(rel.with_suffix(''))
if 'model.py' in out_rel_str:
out_rel_str = out_rel_str.replace('model.py', f'{name}.py')
@@ -118,4 +221,16 @@ def scaffold_plugin(
out_path.write_text(rendered)
if frontend:
if frontend_dir is None:
frontend_dir = plugins_dir.parent / 'frontend' / 'src'
_scaffold_frontend(
name=name,
substitutions=substitutions,
plugin_target=target,
frontend_dir=Path(frontend_dir),
template_root=template_root,
overwrite=overwrite,
)
return target

View File

@@ -0,0 +1,35 @@
/*
* ${Name} API client snippet.
*
* Paste this ${name}Api block into frontend/src/api/index.js (next to the
* other per-resource blocks). Then, in the generated ${Name}List, ${Name}Detail,
* and ${Name}Form views, delete the local ${name}Api const and import the shared
* one instead:
*
* import { ${name}Api } from '../../api'
*
* The scaffolded views ship with an identical inline client so they build and
* run before you touch the shared api module. This file is NOT auto-merged into
* api/index.js on purpose; that module is hand-maintained and shared.
*
* The create/update/delete calls assume matching POST/PUT/DELETE routes exist
* on the backend. The scaffolded api/routes.py only ships list and get; add the
* write endpoints when you wire up the form.
*/
export const ${name}Api = {
list(params = {}) {
return api.get('/${name}', { params })
},
get(itemId) {
return api.get(`/${name}/${itemId}`)
},
create(data) {
return api.post('/${name}', data)
},
update(itemId, data) {
return api.put(`/${name}/${itemId}`, data)
},
remove(itemId) {
return api.delete(`/${name}/${itemId}`)
}
}

View File

@@ -0,0 +1,35 @@
/**
* ${Name} plugin routes.
*
* Auto-discovered by the router via import.meta.glob, so no registration
* edit is needed. Every route carries meta.plugin '${name}' so the ADR-009
* guard redirects to the dashboard when the ${name} backend plugin is
* disabled. Form routes add requiresAuth so anonymous users cannot reach
* create or edit.
*/
export default [
{
path: '${name}',
name: '${name}',
component: () => import('../../views/${name}/${Name}List.vue'),
meta: { plugin: '${name}' }
},
{
path: '${name}/new',
name: '${name}-new',
component: () => import('../../views/${name}/${Name}Form.vue'),
meta: { requiresAuth: true, plugin: '${name}' }
},
{
path: '${name}/:id',
name: '${name}-detail',
component: () => import('../../views/${name}/${Name}Detail.vue'),
meta: { plugin: '${name}' }
},
{
path: '${name}/:id/edit',
name: '${name}-edit',
component: () => import('../../views/${name}/${Name}Form.vue'),
meta: { requiresAuth: true, plugin: '${name}' }
}
]

View File

@@ -0,0 +1,149 @@
<template>
<div class="detail-page" v-if="item">
<div class="hero-card">
<div class="hero-content">
<div class="hero-title-row">
<h1 class="hero-title">{{ item.name || item.assetnumber || '${Name}' }}</h1>
<router-link
v-if="authStore.isAuthenticated"
:to="`/${name}/${itemId}/edit`"
class="btn btn-secondary"
>
Edit
</router-link>
</div>
<div class="hero-details">
<div class="detail-item" v-if="item.assetnumber">
<span class="label">Asset #</span>
<span class="value">{{ item.assetnumber }}</span>
</div>
<div class="detail-item" v-if="item.serialnumber">
<span class="label">Serial</span>
<span class="value mono">{{ item.serialnumber }}</span>
</div>
</div>
</div>
</div>
<div class="content-grid">
<div class="content-column">
<div class="section-card">
<h3 class="section-title">${Name} Information</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Example Field</span>
<span class="info-value">{{ item.examplefield || '-' }}</span>
</div>
</div>
</div>
</div>
<div class="content-column">
<div class="section-card">
<h3 class="section-title">Asset Information</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Asset Number</span>
<span class="info-value">{{ item.assetnumber || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Name</span>
<span class="info-value">{{ item.name || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Serial Number</span>
<span class="info-value mono">{{ item.serialnumber || '-' }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="action-bar" v-if="authStore.isAuthenticated">
<router-link :to="`/${name}/${itemId}/edit`" class="btn btn-primary">Edit</router-link>
<button @click="confirmDelete" class="btn btn-danger">Delete</button>
</div>
</div>
<div v-else-if="loading" class="loading-container">
<div class="loading">Loading...</div>
</div>
<div v-else class="error-container">
<p>Record not found</p>
<router-link to="/${name}" class="btn btn-secondary">Back to ${Name}</router-link>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '../../api'
import { useAuthStore } from '../../stores/auth'
// local api client. move into src/api/index.js (see
// plugins/${name}/frontend-api-snippet.js) then swap for:
// import { ${name}Api } from '../../api'
const ${name}Api = {
list(params = {}) { return api.get('/${name}', { params }) },
get(itemId) { return api.get(`/${name}/${itemId}`) },
create(data) { return api.post('/${name}', data) },
update(itemId, data) { return api.put(`/${name}/${itemId}`, data) },
remove(itemId) { return api.delete(`/${name}/${itemId}`) }
}
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const itemId = route.params.id
const item = ref(null)
const loading = ref(true)
onMounted(loadItem)
async function loadItem() {
loading.value = true
try {
const response = await ${name}Api.get(itemId)
item.value = response.data.data
} catch (error) {
console.error('Error loading ${name}:', error)
item.value = null
} finally {
loading.value = false
}
}
async function confirmDelete() {
if (confirm('Delete this record?')) {
try {
await ${name}Api.remove(itemId)
router.push('/${name}')
} catch (error) {
console.error('Error deleting ${name}:', error)
}
}
}
</script>
<style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.action-bar {
display: flex;
gap: 1rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.loading-container,
.error-container {
text-align: center;
padding: 3rem;
color: var(--text-light);
}
</style>

View File

@@ -0,0 +1,228 @@
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit ${Name}' : 'Add ${Name}' }}</h2>
</div>
<div class="card form-card">
<form @submit.prevent="submitForm">
<fieldset>
<legend>Asset Information</legend>
<div class="form-row">
<div class="form-group">
<label for="assetnumber">Asset Number *</label>
<input
id="assetnumber"
v-model="form.assetnumber"
type="text"
class="form-control"
required
:disabled="isEdit"
/>
</div>
<div class="form-group">
<label for="name">Name</label>
<input
id="name"
v-model="form.name"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="serialnumber">Serial Number</label>
<input
id="serialnumber"
v-model="form.serialnumber"
type="text"
class="form-control"
/>
</div>
</div>
</fieldset>
<fieldset>
<legend>${Name} Details</legend>
<div class="form-row">
<div class="form-group">
<label for="examplefield">Example Field</label>
<input
id="examplefield"
v-model="form.examplefield"
type="text"
class="form-control"
/>
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="cancel">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : (isEdit ? 'Save Changes' : 'Create') }}
</button>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '../../api'
// local api client. move into src/api/index.js (see
// plugins/${name}/frontend-api-snippet.js) then swap for:
// import { ${name}Api } from '../../api'
const ${name}Api = {
list(params = {}) { return api.get('/${name}', { params }) },
get(itemId) { return api.get(`/${name}/${itemId}`) },
create(data) { return api.post('/${name}', data) },
update(itemId, data) { return api.put(`/${name}/${itemId}`, data) },
remove(itemId) { return api.delete(`/${name}/${itemId}`) }
}
const route = useRoute()
const router = useRouter()
const itemId = route.params.id
const isEdit = computed(() => !!itemId)
const form = ref({
assetnumber: '',
name: '',
serialnumber: '',
examplefield: ''
})
const saving = ref(false)
const error = ref('')
onMounted(async () => {
if (isEdit.value) {
await loadItem()
}
})
async function loadItem() {
try {
const response = await ${name}Api.get(itemId)
const data = response.data.data
form.value.assetnumber = data.assetnumber || ''
form.value.name = data.name || ''
form.value.serialnumber = data.serialnumber || ''
form.value.examplefield = data.examplefield || ''
} catch (loadError) {
console.error('Error loading ${name}:', loadError)
error.value = 'Failed to load record'
}
}
async function submitForm() {
saving.value = true
error.value = ''
try {
const payload = {
assetnumber: form.value.assetnumber,
name: form.value.name || null,
serialnumber: form.value.serialnumber || null,
examplefield: form.value.examplefield || null
}
let redirectId = itemId
if (isEdit.value) {
await ${name}Api.update(itemId, payload)
} else {
const response = await ${name}Api.create(payload)
redirectId = response.data.data?.assetid
}
router.push(redirectId ? `/${name}/${redirectId}` : '/${name}')
} catch (submitError) {
console.error('Error saving ${name}:', submitError)
error.value = 'Failed to save record'
} finally {
saving.value = false
}
}
function cancel() {
if (isEdit.value) {
router.push(`/${name}/${itemId}`)
} else {
router.push('/${name}')
}
}
</script>
<style scoped>
.form-card {
max-width: 800px;
}
fieldset {
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
legend {
font-weight: 600;
padding: 0 0.5rem;
color: var(--text);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.form-row:last-child {
margin-bottom: 0;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
margin-bottom: 0.375rem;
font-weight: 500;
color: var(--text);
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.error-message {
margin-top: 1rem;
padding: 0.75rem 1rem;
background: var(--danger);
color: white;
border-radius: 6px;
}
@media (max-width: 600px) {
.form-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,142 @@
<template>
<div>
<div class="page-header">
<h2>${Name}</h2>
<router-link to="/${name}/new" class="btn btn-primary">Add ${Name}</router-link>
</div>
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search..."
@input="debouncedSearch"
/>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Asset Tag</th>
<th>Name</th>
<th>Example Field</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.assetid">
<td>{{ item.assetnumber || '-' }}</td>
<td>{{ item.name || '-' }}</td>
<td>{{ item.examplefield || '-' }}</td>
<td class="actions">
<router-link
:to="`/${name}/${item.assetid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No ${name} records found
</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
// local api client. move into src/api/index.js (see
// plugins/${name}/frontend-api-snippet.js) then swap for:
// import { ${name}Api } from '../../api'
const ${name}Api = {
list(params = {}) { return api.get('/${name}', { params }) },
get(itemId) { return api.get(`/${name}/${itemId}`) },
create(data) { return api.post('/${name}', data) },
update(itemId, data) { return api.put(`/${name}/${itemId}`, data) },
remove(itemId) { return api.delete(`/${name}/${itemId}`) }
}
const items = ref([])
const loading = ref(true)
const search = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(25)
let searchTimeout = null
onMounted(loadItems)
async function loadItems() {
loading.value = true
try {
const params = { page: page.value, perpage: perPage.value }
if (search.value) params.search = search.value
const response = await ${name}Api.list(params)
items.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || 1
} catch (error) {
console.error('Error loading ${name}:', error)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadItems()
}, 300)
}
function goToPage(target) {
if (target >= 1 && target <= totalPages.value) {
page.value = target
loadItems()
}
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadItems()
}
</script>
<style scoped>
.filters {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters .form-control {
flex: 1;
min-width: 150px;
}
</style>