@aotter/mantle 0.1.4 → 0.1.5-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -16
- package/dist/cli/generate-cloudflare.d.ts +7 -0
- package/dist/cli/generate-cloudflare.d.ts.map +1 -0
- package/dist/cli/generate-cloudflare.js +123 -0
- package/dist/cli/generate-cloudflare.js.map +1 -0
- package/dist/cli/generate-project.d.ts +25 -0
- package/dist/cli/generate-project.d.ts.map +1 -0
- package/dist/cli/generate-project.js +282 -0
- package/dist/cli/generate-project.js.map +1 -0
- package/dist/cli/generate-sites.d.ts +8 -0
- package/dist/cli/generate-sites.d.ts.map +1 -0
- package/dist/cli/generate-sites.js +273 -0
- package/dist/cli/generate-sites.js.map +1 -0
- package/dist/cli/generate.d.ts.map +1 -1
- package/dist/cli/generate.js +121 -10
- package/dist/cli/generate.js.map +1 -1
- package/dist/cli/main.d.ts +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +10 -10
- package/docs/adr/0021-retire-starter-scaffolding.md +1 -1
- package/docs/adr/0026-progressive-generate.md +36 -0
- package/docs/adr/0027-scheduled-procedure-triggers.md +31 -0
- package/docs/adr/0028-schema-ttl-expiry.md +33 -0
- package/docs/adr/README.md +5 -2
- package/docs/agent-prompts.md +40 -78
- package/docs/examples/host-chatgpt-sites/README.md +1 -1
- package/docs/examples/host-chatgpt-sites/drizzle/0004_mantle.sql +10 -0
- package/docs/examples/host-chatgpt-sites/drizzle/0005_mantle.sql +24 -0
- package/docs/examples/host-chatgpt-sites/drizzle/meta/_journal.json +1 -1
- package/docs/examples/host-chatgpt-sites/drizzle/meta/mantle-state.json +60 -0
- package/docs/examples/host-chatgpt-sites/package.json +1 -1
- package/docs/examples/host-chatgpt-sites/scripts/migration.mjs +42 -8
- package/docs/examples/host-chatgpt-sites/src/chatgpt-auth.ts +9 -6
- package/docs/examples/host-local-admin-otp/package.json +2 -2
- package/docs/examples/host-minimal-worker/package.json +2 -2
- package/docs/handbook/chatgpt-sites/generated-app.md +84 -0
- package/docs/handbook/chatgpt-sites/index.md +21 -11
- package/docs/handbook/cloudflare/authentication.md +19 -2
- package/docs/handbook/cloudflare/bindings.md +4 -11
- package/docs/handbook/cloudflare/conventional-worker.md +7 -3
- package/docs/handbook/cloudflare/deploy-and-operate.md +1 -1
- package/docs/handbook/concepts/runtime-and-adapters.md +7 -0
- package/docs/handbook/guides/agent-setup.md +3 -3
- package/docs/handbook/navigation.json +3 -2
- package/docs/handbook/reference/diagnostics.md +5 -0
- package/docs/handbook/reference/procedure.md +52 -1
- package/docs/handbook/reference/schema.md +27 -1
- package/docs/handbook/reference/surface.md +3 -3
- package/docs/handbook/reference/trigger.md +21 -1
- package/docs/handbook/releases/index.md +55 -0
- package/docs/handbook/start/overview.md +5 -2
- package/docs/handbook/start/project-and-cli.md +103 -119
- package/docs/handbook/start/quickstart-admin.md +6 -4
- package/docs/handbook/start/quickstart-worker.md +17 -8
- package/docs/skills/develop/SKILL.md +14 -6
- package/docs/skills/integrate/SKILL.md +60 -0
- package/package.json +18 -17
- package/skills/README.md +3 -1
- package/skills/develop/SKILL.md +14 -6
- package/skills/install/SKILL.md +100 -137
- package/skills/integrate/SKILL.md +60 -0
- package/templates/chatgpt-sites/auth.ts +66 -0
- package/templates/chatgpt-sites/build.mjs +14 -0
- package/templates/chatgpt-sites/env.ts +1 -0
- package/templates/chatgpt-sites/mcp.ts +48 -0
- package/templates/chatgpt-sites/public-mcp.ts +29 -0
- package/templates/chatgpt-sites/smoke-local.mjs +20 -0
package/dist/cli/main.js
CHANGED
|
@@ -25,15 +25,16 @@ async function main() {
|
|
|
25
25
|
return 2;
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
|
-
export const MANTLE_OVERVIEW = `mantle — compile manifests
|
|
28
|
+
export const MANTLE_OVERVIEW = `mantle — compile manifests and assemble selected application features
|
|
29
29
|
|
|
30
30
|
Overview
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
New applications default to Spec, Runtime, API, MCP, Admin, and a blank Web
|
|
32
|
+
home. Pass --host cf or --host chatgpt-sites, or use --features for a smaller
|
|
33
|
+
positive selection. Existing authored applications keep compile-only mode.
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
Host-free — Spec only
|
|
36
|
+
mantle generate --features spec compiles a sealed plan and typed binding
|
|
37
|
+
without Runtime, Admin, visitor UI, or a host.
|
|
37
38
|
|
|
38
39
|
Runtime / adapter
|
|
39
40
|
Bind Runtime through an adapter (Cloudflare Worker, Bun, Vercel, or yours).
|
|
@@ -41,10 +42,9 @@ Overview
|
|
|
41
42
|
See docs/examples/host-minimal-worker or
|
|
42
43
|
node_modules/@aotter/mantle/docs/examples/host-minimal-worker.
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
/admin/sign-in (local email OTP via ConsoleEmailSender).
|
|
45
|
+
Selected Admin / Dev UI
|
|
46
|
+
New full applications include the prebuilt UI. Reduced applications can
|
|
47
|
+
omit it; selected packages must be installed before generation completes.
|
|
48
48
|
See docs/examples/host-local-admin-otp or
|
|
49
49
|
node_modules/@aotter/mantle/docs/examples/host-local-admin-otp.
|
|
50
50
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# ADR-0021: Author applications directly; retire Starter scaffolding
|
|
2
2
|
|
|
3
|
-
Status: Accepted by owner, 2026-09-08 (#786). Supersedes ADR-0018 for the 0.1.2 line.
|
|
3
|
+
Status: Accepted by owner, 2026-09-08 (#786). Supersedes ADR-0018 for the 0.1.2 line. Its no-initialization clause is superseded by [ADR-0026](0026-progressive-generate.md) for 0.1.5.
|
|
4
4
|
|
|
5
5
|
Core is an embeddable manifest engine. Applications own package manifests,
|
|
6
6
|
Worker entries, provider configuration, business manifests and visitor UI.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# ADR-0026: Progressive application generation
|
|
2
|
+
|
|
3
|
+
**Status:** Accepted by owner for the 0.1.5 line (#1081–#1086). Supersedes the no-initialization clause of ADR-0021; direct authorship and optional package boundaries remain.
|
|
4
|
+
|
|
5
|
+
Mantle is progressive. A new application defaults to Spec, Runtime, API, MCP, Admin, and an editable blank Web home so an agent does not forget the optional Admin composition. `--features` is a positive replacement list over `spec,runtime,api,mcp,admin,web`; dependencies are closed automatically. `spec` alone is host-free. The first generated hosts are `cf` and `chatgpt-sites`. Only host-dependent selections require `--host`; a TTY may prompt, while headless use must pass it explicitly.
|
|
6
|
+
|
|
7
|
+
| Feature | Required features | Direct package additions |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| `spec` | — | `@aotter/mantle`, `zod` peer |
|
|
10
|
+
| `runtime` | `spec` | Host adapter; current CF adapter also brings Admin/Auth/Web transitively |
|
|
11
|
+
| `api` | `runtime` | Host route composition and `hono` peer |
|
|
12
|
+
| `mcp` | `runtime` | Host MCP route composition |
|
|
13
|
+
| `admin` | `runtime,api,mcp` | `@aotter/mantle-admin`, `@aotter/mantle-admin-ui`; CF owner auth also uses `@aotter/mantle-auth` |
|
|
14
|
+
| `web` | `runtime` | `@aotter/mantle-web` and a user-owned blank home |
|
|
15
|
+
|
|
16
|
+
The host adapter's declared peers (`better-auth`, `hono`, `zod`, `aws4fetch`) are installed for host-dependent selections until its package dependency graph is split. A reduced selection still omits unselected routes and assets; it does not claim smaller transitive installation today.
|
|
17
|
+
|
|
18
|
+
An application with only Git, installed skills, package metadata, a lockfile, README, and installed dependencies is new. Existing manifests or application code remain in direct-authoring compile mode. A saved `mantle.config.json` decides subsequent runs before that heuristic. V1 refuses host changes and feature-list changes after selection; application owners can make an explicit migration rather than have generation delete routes or data. An explicitly named missing manifest path is an error. A new default manifest directory may be absent or empty, yielding a valid empty plan without a fake business Schema.
|
|
19
|
+
|
|
20
|
+
Generation preflights selection, manifest validity, package/script conflicts, file ownership, and symlink escapes before writing. It saves the selection and adds only absent package declarations and scripts; it does not run a package manager. Selected Mantle packages use the running CLI's exact version. Missing selected packages produce an incomplete, nonzero result and an explicit install-and-rerun instruction. `--check` never writes, installs, prompts, or provisions. Generated code and assets remain CLI-owned; the editable frontend, host config, identity, secrets, handlers, and historical migrations are application-owned. A later SDK upgrade does not overwrite them. Clean builds remove only copied output.
|
|
21
|
+
|
|
22
|
+
Cloudflare currently has a facade with transitive Admin/Auth/Web dependencies and mounts OAuth/MCP unconditionally. Host generation must use a lower-level composition for reduced selections or change that facade; omitting dependencies from a manifest does not prove route omission. Cloudflare D1 remains runtime-managed. ChatGPT Sites uses reviewed, append-only managed migrations with applied-state checks (owned by #1086). Neither host generator provisions resources or deploys.
|
|
23
|
+
|
|
24
|
+
This decision restores a convenient default while keeping the sealed Manifest-to-RuntimePlan pipeline and user-owned application source. It does not reintroduce a remote Starter catalog or a `mantle create` command.
|
|
25
|
+
|
|
26
|
+
## 2026-09-25 amendment — existing application integration
|
|
27
|
+
|
|
28
|
+
The original implementation added `--adopt` to convert an existing authored
|
|
29
|
+
application into the generated-project layout. This amendment removes that
|
|
30
|
+
mode. Applications with their own host shape continue direct authoring;
|
|
31
|
+
integrating Mantle is guided by the version-matched `integrate` consumer skill.
|
|
32
|
+
The agent may embed selected capabilities, replace one part at a time, or
|
|
33
|
+
build a fresh Mantle application and migrate frontend and data. The CLI owns
|
|
34
|
+
only deterministic generation and its declared checks; it does not certify
|
|
35
|
+
arbitrary user-owned host wiring. A saved generated project still reruns from
|
|
36
|
+
its own configuration, with file ownership and migration safeguards intact.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# ADR-0027: Scheduled Procedure Triggers
|
|
2
|
+
|
|
3
|
+
**Status:** Accepted for 0.1.5 (#1087). Revises the closed Trigger-source grammar from ADR-0001 and the host-only cron guidance of #322.
|
|
4
|
+
|
|
5
|
+
**Date:** 2026-09-25
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
The earlier contract kept cron outside the Manifest graph. That forced an application Worker to invoke a Procedure by name with its own caller context, leaving scheduling invisible to validation and the compiled plan. A portable declaration is needed without pretending that every host can register a cron event.
|
|
10
|
+
|
|
11
|
+
## Decision
|
|
12
|
+
|
|
13
|
+
`Trigger.spec.source` gains `kind: schedule`, a five-field Cloudflare UTC `cron` (weekday 1=Sunday), and optional `enabled` (default true). It targets one Procedure whose input accepts `{}` and requires no user or staff authorization. The compiled plan carries Trigger identity, expression, target, enabled state, and the required `cloudflare` host. No HTTP route or user identity is granted.
|
|
14
|
+
|
|
15
|
+
Cloudflare's `scheduled` entry finds enabled plan entries with the exact incoming expression and invokes them in Trigger-name order through the existing Procedure pipeline, continuing after individual failures and reporting them together. The system context has null user and staff, no credential, and `ctx.schedule` with stable `${trigger}:${scheduledTime}` identity. The application registers exact expressions with Wrangler. Cloudflare Cron Triggers do not document automatic retries; handlers still use the identity for possible duplicate/manual replay. Other hosts reject enabled schedules rather than silently omit them.
|
|
16
|
+
|
|
17
|
+
## Consequences
|
|
18
|
+
|
|
19
|
+
The grammar and sealed plan version advance. Applications must rerun `mantle generate` after upgrading. A Procedure needing staff authorization cannot run from a schedule. Cron provisioning, retries, retention and exactly-once execution remain host/operator responsibilities; the runtime does not claim them.
|
|
20
|
+
|
|
21
|
+
## Alternatives
|
|
22
|
+
|
|
23
|
+
Keeping host-only calls would preserve the old grammar but fail static validation and omit the feature from introspection. A general background-job framework would add storage and scheduling responsibilities without a second host implementation.
|
|
24
|
+
|
|
25
|
+
## How to apply
|
|
26
|
+
|
|
27
|
+
Declare a schedule Trigger, export `worker.scheduled`, and register its expression in `wrangler.jsonc`. Use `ctx.schedule.id` to deduplicate writes. See [Trigger reference](../handbook/reference/trigger.md#schedule-source).
|
|
28
|
+
|
|
29
|
+
## Implementation status
|
|
30
|
+
|
|
31
|
+
Implemented in the 0.1.5 scheduled Procedure PR, with parser, linker, compiler, runtime and Cloudflare adapter tests.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# ADR-0028: Logical Schema TTL before explicit physical cleanup
|
|
2
|
+
|
|
3
|
+
**Status:** Accepted for 0.1.5 (#1088).
|
|
4
|
+
|
|
5
|
+
**Date:** 2026-09-25
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
An application needs a portable expiry rule across D1 and Bun SQLite. A cron-only delete task cannot guarantee that readers stop seeing expired data if it runs late or fails. Applying a new policy to a populated table must not silently bulk-delete rows.
|
|
10
|
+
|
|
11
|
+
## Decision
|
|
12
|
+
|
|
13
|
+
`Schema.spec.ttl` names one top-level date-time field and a nonnegative `expireAfterSeconds`. At `timestamp + duration <= now`, semantic reads exclude the row. Missing, null and unparseable legacy dates never expire. Declarative Views include the same predicate. Native SQL Views are rejected while any TTL Schema exists, and shared caching of a TTL View is rejected. The compiled plan and introspection expose the policy. Public Cloudflare pages derived from TTL collections use `no-store` rather than shared caching.
|
|
14
|
+
|
|
15
|
+
SQLite storage implements an optional bounded `ExpirySweeper`. A sweep previews by default; `delete: true` is required for physical removal. It returns counts and a cursor, selects at most 100 expired IDs in stable order, and rechecks expiry in one bounded delete statement. A failed page can be retried with the previous cursor. Ref Procedures can call the semantic capability, including from a scheduled Trigger. No host automatically invokes it.
|
|
16
|
+
|
|
17
|
+
Physical TTL removal does not fire before/after entry lifecycle hooks. Expiry already changed logical visibility; cleanup reclaims storage rather than representing an editorial deletion. Consumers needing business actions at expiry must implement them in an idempotent scheduled Procedure before calling the sweep.
|
|
18
|
+
|
|
19
|
+
## Consequences
|
|
20
|
+
|
|
21
|
+
Adding or shortening TTL changes read visibility immediately but never starts physical deletion. Owners preview counts and choose when to sweep; expired rows can still occupy unique indexes until removal. Other adapters must implement equivalent read filtering and a sweep or fail closed for TTL plans. The runtime does not promise an exact deletion time.
|
|
22
|
+
|
|
23
|
+
## Alternatives
|
|
24
|
+
|
|
25
|
+
Scheduler-driven deletion alone leaves a visibility gap. Implicit migration-time cleanup risks irreversible bulk deletion. Firing ordinary deletion hooks during storage reclamation would cause delayed, retry-sensitive business effects after the row was already logically gone.
|
|
26
|
+
|
|
27
|
+
## How to apply
|
|
28
|
+
|
|
29
|
+
Declare `ttl` on a Schema, use declarative Views, preview with `runtime.sweepExpired`, then explicitly request deletion page by page. See [Schema TTL](../handbook/reference/schema.md#ttl).
|
|
30
|
+
|
|
31
|
+
## Implementation status
|
|
32
|
+
|
|
33
|
+
Implemented in the 0.1.5 TTL PR for the SQLite family, with Bun and local D1 conformance checks.
|
package/docs/adr/README.md
CHANGED
|
@@ -21,10 +21,13 @@ Records of *why* mantle ended up shaped this way. The numbering preserves POC AD
|
|
|
21
21
|
| [0018](0018-core-starters-repository-boundary.md) | Core produces published SDK artifacts; the separate starters repository validates them as an external consumer. Revisit after release-contract simplification. | Superseded by 0021 |
|
|
22
22
|
| [0019](0019-sealed-manifest-runtime-pipeline.md) | One sealed source-to-runtime pipeline, semantic storage seam, and optional Web/Admin/platform dependency direction. | Accepted |
|
|
23
23
|
| [0020](0020-builtin-handler-contracts-and-matched-upsert.md) | Static builtin handler contracts and natural-key matched upsert (`handler.match`). | Accepted + amended by 0022 |
|
|
24
|
-
| [0021](0021-retire-starter-scaffolding.md) | Direct authoring; retire Starter scaffolding. | Accepted;
|
|
24
|
+
| [0021](0021-retire-starter-scaffolding.md) | Direct authoring; retire Starter scaffolding. | Accepted; no-initialization clause superseded by 0026 |
|
|
25
25
|
| [0022](0022-caller-observed-version-occ.md) | Caller-observed `expectedVersion` for Admin bind and builtin upsert OCC. | Accepted |
|
|
26
26
|
| [0024](0024-manifest-native-schema-tables.md) | Materialize each Schema as a native storage table and deploy reviewed migration artifacts. | Accepted; authored-native-name clauses superseded by 0025 |
|
|
27
27
|
| [0025](0025-reserved-native-columns-and-published-only-public-views.md) | Reserve the six native column names, let `indexes` reference them, and compile public publishing Views published-only. | Accepted |
|
|
28
|
+
| [0026](0026-progressive-generate.md) | Progressive `mantle generate` with a full default, positive features, saved host selection, and user-owned source. | Accepted; supersedes part of 0021 |
|
|
29
|
+
| [0027](0027-scheduled-procedure-triggers.md) | Schedule Triggers in the sealed plan with Cloudflare-hosted invocation and system identity. | Accepted for 0.1.5 |
|
|
30
|
+
| [0028](0028-schema-ttl-expiry.md) | Logical TTL on Schema reads with preview-first, explicit bounded cleanup. | Accepted for 0.1.5 |
|
|
28
31
|
|
|
29
32
|
## Reading order
|
|
30
33
|
|
|
@@ -36,7 +39,7 @@ If you're new to the codebase:
|
|
|
36
39
|
4. **0007** — what running the SDK feels like as an AI author (and as the operator agent).
|
|
37
40
|
5. **0011** — the boundary between the runtime and the adapter.
|
|
38
41
|
6. **0010** — how locale flows through the system.
|
|
39
|
-
7. **
|
|
42
|
+
7. **0026** — current progressive generation boundary; **0021** retains the direct-authoring history and **0013** is historical install-session context.
|
|
40
43
|
8. **0002, 0008** — the two ADRs that touch every diagnostic and every binding.
|
|
41
44
|
|
|
42
45
|
## What's NOT here (and why)
|
package/docs/agent-prompts.md
CHANGED
|
@@ -1,102 +1,64 @@
|
|
|
1
1
|
# Task-specific agent prompts
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
First install the small bootstrap skill:
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
npx skills add aotter/mantle
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
authoring docs. A live `/mcp` catalog mirrors the Manifest → RuntimePlan, not
|
|
15
|
-
the CLI. Resolve only missing requirements first; do not assume a host. There is no `mantle create`. Empty `generate` fails until
|
|
16
|
-
manifests exist. Admin is opt-in.
|
|
9
|
+
The installed skill locates the target project, resolves its actual SDK
|
|
10
|
+
version, and sends the agent to that package's CLI and docs. A GitHub skill
|
|
11
|
+
may be newer than npm: published 0.1.4 uses the direct-authoring path;
|
|
12
|
+
0.1.5-alpha.1 adds full generated projects. Never assume a new flag exists without
|
|
13
|
+
checking the installed CLI.
|
|
17
14
|
|
|
18
|
-
|
|
15
|
+
## New full Cloudflare app
|
|
19
16
|
|
|
20
17
|
```text
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
(not cf-primitives-*). Implement locally first. Take only the surfaces we
|
|
30
|
-
chose. For Spec-only use, skip Runtime and code generation. For a Worker
|
|
31
|
-
without Admin, follow examples/host-minimal-worker/. For a Worker with
|
|
32
|
-
Dev UI, follow examples/host-local-admin-otp/README.md. ChatGPT Sites only
|
|
33
|
-
if I name that host. No mantle create. Pin all @aotter/mantle* packages
|
|
34
|
-
to one exact version.
|
|
18
|
+
Build a Mantle app on Cloudflare. Follow the installed mantle skill, pin one
|
|
19
|
+
exact stable SDK version, and use its project-generation path if supported.
|
|
20
|
+
Select the full default Spec, Runtime, API, MCP, Admin and blank home. Install
|
|
21
|
+
the CLI-declared dependencies and rerun generation. Ask for my actual data and
|
|
22
|
+
workflow before adding Manifests. For local Admin, copy .dev.vars.example,
|
|
23
|
+
set the real owner email and a random secret, and verify OTP, Admin assets,
|
|
24
|
+
unauthorized access, public API and MCP. Do not claim production Auth is set
|
|
25
|
+
up from a local OTP test.
|
|
35
26
|
```
|
|
36
27
|
|
|
37
|
-
|
|
28
|
+
## Small API or Spec-only app
|
|
38
29
|
|
|
39
30
|
```text
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
31
|
+
Use the installed Mantle CLI and version-matched docs. For a Cloudflare API,
|
|
32
|
+
select --host cf --features spec,api if that CLI supports project generation.
|
|
33
|
+
For manifest parsing only, use --features spec without a host. Keep unselected
|
|
34
|
+
Admin, Web and MCP packages out. Author the Schema and View I request, then
|
|
35
|
+
run generate, validate, typecheck and a real route probe when a host exists.
|
|
36
|
+
Preserve an existing direct-authored application instead of regenerating it.
|
|
46
37
|
```
|
|
47
38
|
|
|
48
|
-
|
|
39
|
+
## ChatGPT Sites
|
|
49
40
|
|
|
50
41
|
```text
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
42
|
+
Build with ChatGPT Sites and Mantle. Start from the installed SDK's generated
|
|
43
|
+
blank app when supported; otherwise follow that version's Sites reference.
|
|
44
|
+
Pin all selected Mantle packages exactly, install declared dependencies,
|
|
45
|
+
review and apply the initial local D1 migration, then add my Manifests and
|
|
46
|
+
review each appended migration. Configure OWNER_EMAIL and PUBLIC_ORIGIN. The
|
|
47
|
+
local owner smoke simulates Sites identity; deployed ChatGPT sign-in needs a
|
|
48
|
+
separate check. Show how I will maintain content through Admin, browser
|
|
49
|
+
WebMCP, public MCP and Sites-session staff MCP. Add R2 only if my workflow
|
|
50
|
+
requires uploads. Never deploy the Worker directly or claim remote staff OAuth
|
|
51
|
+
MCP from a Sites browser session.
|
|
58
52
|
```
|
|
59
53
|
|
|
60
|
-
|
|
54
|
+
## Existing project with typed queries
|
|
61
55
|
|
|
62
56
|
```text
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
57
|
+
Read this project's package.json, lockfile, installed Mantle package and
|
|
58
|
+
projected develop skill. Keep its host and selected surfaces. Use the installed
|
|
59
|
+
CLI's help and handbook to add the requested Schema/View/Procedure, then run
|
|
60
|
+
its generate/check, validation, TypeScript and host smoke. Do not use another
|
|
61
|
+
checkout's SDK docs or overwrite user-owned files.
|
|
68
62
|
```
|
|
69
63
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
```text
|
|
73
|
-
Do not add Admin unless it is already in this project. Read
|
|
74
|
-
handbook/concepts/mcp-and-agents.md and/or
|
|
75
|
-
handbook/cloudflare/public-web.md. Add only the surface I name: MCP
|
|
76
|
-
Triggers at /mcp or /mcp/staff, or optional @aotter/mantle-web
|
|
77
|
-
composition. Keep Core adapter-neutral. Probe the new route; do not
|
|
78
|
-
claim Auth or Admin works from a public 200.
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
### Mantle on ChatGPT Sites, including media
|
|
82
|
-
|
|
83
|
-
Use only when the user names ChatGPT Sites as the host.
|
|
84
|
-
|
|
85
|
-
```text
|
|
86
|
-
Build with ChatGPT Sites; use Mantle for content management and publishing.
|
|
87
|
-
Read handbook/chatgpt-sites/index.md and examples/host-chatgpt-sites/README.md.
|
|
88
|
-
Use that runnable host as a reference and install its pinned
|
|
89
|
-
dependencies from the registry. Derive Schema, View,
|
|
90
|
-
Procedure and Trigger from my requirements and check the Admin editor/picker and public
|
|
91
|
-
projections against them. Request both Sites D1 and R2 when my workflow
|
|
92
|
-
includes uploads. Bind the R2 media port, declare media.purposes, and keep
|
|
93
|
-
the same-origin PUT and committed-only public GET checks. Pin every
|
|
94
|
-
@aotter/mantle* dependency to one exact version. Run the local smoke,
|
|
95
|
-
show a draft-to-publish walkthrough, then save and deploy through Sites when
|
|
96
|
-
requested. Verify the deployed owner/member role, R2 upload, published page, and anonymous draft 404. Treat public read-only
|
|
97
|
-
MCP, Admin WebMCP, and staff OAuth MCP as separate acceptance gates; never
|
|
98
|
-
claim staff MCP from a working browser session or connector URL alone.
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
[Back to the Core README](../README.md#for-engineers-and-agents).
|
|
64
|
+
[Back to the Core README](../README.md).
|
|
@@ -49,7 +49,7 @@ Keep `.openai/hosting.json`; do not copy an existing Site's `project_id`. To use
|
|
|
49
49
|
|
|
50
50
|
Review the entire [smoke script](./scripts/check.mjs) before adapting it. It covers D1 CRUD/version conflict, owner/member/role revocation, R2 read/write/delete, media create → PUT → commit → public read, both advertised MCP URLs, public MCP `initialize`/`tools/list`/View call, staff MCP authentication/catalog, draft isolation, published article HTML/Markdown/SEO, and negative auth/Origin/size checks. It creates and deletes only its own test records and objects.
|
|
51
51
|
|
|
52
|
-
The checked-in `drizzle/` migrations and `src/storage-fingerprint.json` match the example manifest and runtime. Apply all pending D1 migrations before deploying updated code; managed storage
|
|
52
|
+
The checked-in `drizzle/` migrations and `src/storage-fingerprint.json` match the example manifest and runtime. Apply all pending D1 migrations before deploying updated code; managed storage refuses to boot against an older database. After changing a Schema, first apply the current migrations to local D1, then run `npm run generate` and `npm run migrate:generate`. Review the newly appended SQL and apply it with `wrangler d1 migrations apply mantle-sites-reference-local --local`. Commit the new migration, `drizzle/meta/mantle-state.json`, journal, and fingerprint together. The generator checks the local database fingerprint before appending; it never changes an applied SQL file or applies D1 migrations itself. Deploy the reviewed migration before deploying code that expects its fingerprint.
|
|
53
53
|
|
|
54
54
|
## Publish with Sites
|
|
55
55
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS _mantle_managed_runtime_state (
|
|
2
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
3
|
+
canonical_version TEXT NOT NULL
|
|
4
|
+
);
|
|
5
|
+
--> statement-breakpoint
|
|
6
|
+
INSERT INTO _mantle_schema_tables(name,projection) VALUES ('articles','{"columns":[["body","TEXT","string",false],["coverAssetId","TEXT","string",false],["summary","TEXT","string",false],["title","TEXT","string",false]],"indexes":[]}') ON CONFLICT(name) DO UPDATE SET projection=excluded.projection;
|
|
7
|
+
--> statement-breakpoint
|
|
8
|
+
INSERT INTO _mantle_managed_runtime_state(id,canonical_version) VALUES (1,'0006-managed-runtime-version') ON CONFLICT(id) DO UPDATE SET canonical_version=excluded.canonical_version;
|
|
9
|
+
--> statement-breakpoint
|
|
10
|
+
INSERT INTO _mantle_storage_state(id,fingerprint) VALUES (1,(SELECT CASE WHEN fingerprint='9ef85f9933e22328140e924045820af6be2a6985ed8ecc19b63a4f2a37826618' THEN '9ef85f9933e22328140e924045820af6be2a6985ed8ecc19b63a4f2a37826618' ELSE NULL END FROM _mantle_storage_state WHERE id=1)) ON CONFLICT(id) DO UPDATE SET fingerprint=excluded.fingerprint;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS _mantle_schedule_runs (
|
|
2
|
+
run_id TEXT NOT NULL,
|
|
3
|
+
attempt INTEGER NOT NULL,
|
|
4
|
+
schedule_id TEXT NOT NULL,
|
|
5
|
+
scheduled_at INTEGER NOT NULL,
|
|
6
|
+
started_at INTEGER NOT NULL,
|
|
7
|
+
finished_at INTEGER,
|
|
8
|
+
status TEXT NOT NULL,
|
|
9
|
+
error_summary TEXT,
|
|
10
|
+
counts TEXT,
|
|
11
|
+
PRIMARY KEY (run_id, attempt)
|
|
12
|
+
);
|
|
13
|
+
--> statement-breakpoint
|
|
14
|
+
CREATE INDEX IF NOT EXISTS _mantle_schedule_runs_recent
|
|
15
|
+
ON _mantle_schedule_runs (started_at DESC);
|
|
16
|
+
--> statement-breakpoint
|
|
17
|
+
CREATE INDEX IF NOT EXISTS _mantle_schedule_runs_schedule_recent
|
|
18
|
+
ON _mantle_schedule_runs (schedule_id, started_at DESC);
|
|
19
|
+
--> statement-breakpoint
|
|
20
|
+
INSERT INTO _mantle_schema_tables(name,projection) VALUES ('articles','{"columns":[["body","TEXT","string",false],["coverAssetId","TEXT","string",false],["summary","TEXT","string",false],["title","TEXT","string",false]],"indexes":[]}') ON CONFLICT(name) DO UPDATE SET projection=excluded.projection;
|
|
21
|
+
--> statement-breakpoint
|
|
22
|
+
INSERT INTO _mantle_managed_runtime_state(id,canonical_version) VALUES (1,(SELECT CASE WHEN canonical_version='0006-managed-runtime-version' THEN '0007-schedule-run-observations' ELSE NULL END FROM _mantle_managed_runtime_state WHERE id=1)) ON CONFLICT(id) DO UPDATE SET canonical_version=excluded.canonical_version;
|
|
23
|
+
--> statement-breakpoint
|
|
24
|
+
INSERT INTO _mantle_storage_state(id,fingerprint) VALUES (1,(SELECT CASE WHEN fingerprint='9ef85f9933e22328140e924045820af6be2a6985ed8ecc19b63a4f2a37826618' THEN '9ef85f9933e22328140e924045820af6be2a6985ed8ecc19b63a4f2a37826618' ELSE NULL END FROM _mantle_storage_state WHERE id=1)) ON CONFLICT(id) DO UPDATE SET fingerprint=excluded.fingerprint;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"7","dialect":"sqlite","entries":[{"idx":0,"version":"6","when":1790000000000,"tag":"0000_sites_users","breakpoints":true},{"idx":1,"version":"6","when":1790000000001,"tag":"0001_mantle","breakpoints":true},{"idx":2,"version":"6","when":1790000000002,"tag":"0002_article_cover","breakpoints":true},{"idx":3,"version":"6","when":1790000000003,"tag":"0003_store_instance_id","breakpoints":true}]}
|
|
1
|
+
{"version":"7","dialect":"sqlite","entries":[{"idx":0,"version":"6","when":1790000000000,"tag":"0000_sites_users","breakpoints":true},{"idx":1,"version":"6","when":1790000000001,"tag":"0001_mantle","breakpoints":true},{"idx":2,"version":"6","when":1790000000002,"tag":"0002_article_cover","breakpoints":true},{"idx":3,"version":"6","when":1790000000003,"tag":"0003_store_instance_id","breakpoints":true},{"idx":4,"version":"6","when":1790000000004,"tag":"0004_mantle","breakpoints":true},{"idx":5,"version":"6","when":1790300000000,"tag":"0005_mantle","breakpoints":true}]}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"fingerprint": "9ef85f9933e22328140e924045820af6be2a6985ed8ecc19b63a4f2a37826618",
|
|
3
|
+
"schemas": [
|
|
4
|
+
{
|
|
5
|
+
"apiVersion": "cms.mantle.aotter.net/v1",
|
|
6
|
+
"kind": "Schema",
|
|
7
|
+
"metadata": {
|
|
8
|
+
"name": "articles"
|
|
9
|
+
},
|
|
10
|
+
"spec": {
|
|
11
|
+
"title": "文章",
|
|
12
|
+
"schema": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"required": [
|
|
15
|
+
"title"
|
|
16
|
+
],
|
|
17
|
+
"properties": {
|
|
18
|
+
"title": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"title": "標題",
|
|
21
|
+
"maxLength": 160
|
|
22
|
+
},
|
|
23
|
+
"summary": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"title": "摘要",
|
|
26
|
+
"maxLength": 1000
|
|
27
|
+
},
|
|
28
|
+
"body": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"title": "內容",
|
|
31
|
+
"x-mcp-hint": "markdown"
|
|
32
|
+
},
|
|
33
|
+
"coverAssetId": {
|
|
34
|
+
"type": "string",
|
|
35
|
+
"title": "封面圖片",
|
|
36
|
+
"x-mantle-ref": "media_assets",
|
|
37
|
+
"x-mcp-hint": "media-image"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"lifecycle": "publishing",
|
|
42
|
+
"uniqueIndexes": [],
|
|
43
|
+
"indexes": [],
|
|
44
|
+
"searchableFields": [],
|
|
45
|
+
"localized": false
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
"appliedMigrationIds": [
|
|
50
|
+
"0001-init",
|
|
51
|
+
"0002-media-assets",
|
|
52
|
+
"0003-pending-media-uploads",
|
|
53
|
+
"0004-native-schema-storage",
|
|
54
|
+
"0005-store-instance-id",
|
|
55
|
+
"0006-managed-runtime-version",
|
|
56
|
+
"0007-schedule-run-observations"
|
|
57
|
+
],
|
|
58
|
+
"lastIndex": 5,
|
|
59
|
+
"canonicalVersion": "0007-schedule-run-observations"
|
|
60
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"name":"mantle-sites-reference","version":"0.0.1","private":true,"type":"module","scripts":{"build":"node scripts/build.mjs","dev":"wrangler dev","generate":"mantle generate","check":"tsc --noEmit","test":"node --experimental-strip-types scripts/check.mjs"},"dependencies":{"@aotter/mantle":"latest","@aotter/mantle-admin":"latest","@aotter/mantle-admin-ui":"latest","@aotter/mantle-cloudflare":"latest","@aotter/mantle-web":"latest","hono":"^4.9.0","micromark":"4.0.2","zod":"^4.0.0"},"devDependencies":{"@cloudflare/workers-types":"*","esbuild":"^0.28.0","typescript":"^5.9.0","wrangler":"^4.0.0"}}
|
|
1
|
+
{"name":"mantle-sites-reference","version":"0.0.1","private":true,"type":"module","scripts":{"build":"node scripts/build.mjs","dev":"wrangler dev","generate":"mantle generate","check":"tsc --noEmit","test":"node --experimental-strip-types scripts/check.mjs","migrate:generate":"node scripts/migration.mjs","migrate:check":"node scripts/migration.mjs --check"},"dependencies":{"@aotter/mantle":"latest","@aotter/mantle-admin":"latest","@aotter/mantle-admin-ui":"latest","@aotter/mantle-cloudflare":"latest","@aotter/mantle-web":"latest","hono":"^4.9.0","micromark":"4.0.2","zod":"^4.0.0"},"devDependencies":{"@cloudflare/workers-types":"*","esbuild":"^0.28.0","typescript":"^5.9.0","wrangler":"^4.0.0"}}
|
|
@@ -1,9 +1,43 @@
|
|
|
1
|
-
//
|
|
2
|
-
import { buildSqliteMigrationArtifact,
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
await
|
|
1
|
+
// Append a reviewed D1 migration only after the previous one is applied locally.
|
|
2
|
+
import { buildSqliteMigrationArtifact, renderSqliteManagedMigration } from '@aotter/mantle/runtime';
|
|
3
|
+
import { parseManifestSources, ValidateManifestsUseCase } from '@aotter/mantle/spec';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { readFile, readdir, writeFile } from 'node:fs/promises';
|
|
6
|
+
const statePath='drizzle/meta/mantle-state.json';
|
|
7
|
+
const state=JSON.parse(await readFile(statePath,'utf8'));
|
|
8
|
+
const files=(await readdir('manifests')).filter(name=>/\.ya?ml$/i.test(name)).sort();
|
|
9
|
+
const parsed=parseManifestSources({sources:await Promise.all(files.map(async name=>({sourceId:name,text:await readFile(`manifests/${name}`,'utf8')})))});
|
|
10
|
+
if(!parsed.ok)throw Error(`Invalid manifest: ${parsed.diagnostics.map(d=>d.message).join('; ')}`);
|
|
11
|
+
const validated=ValidateManifestsUseCase.run({parsed:parsed.value});
|
|
12
|
+
if(validated.errorCount)throw Error(`Invalid manifest: ${validated.diagnostics.filter(d=>d.severity==='error').map(d=>d.message).join('; ')}`);
|
|
13
|
+
const target=parsed.value.entries.map(entry=>entry.manifest).filter(manifest=>manifest.kind==='Schema');
|
|
14
|
+
const artifact=await buildSqliteMigrationArtifact(state.schemas,target,{appliedMigrationIds:state.appliedMigrationIds});
|
|
15
|
+
if(artifact.sourceFingerprint!==state.fingerprint)throw Error('The saved migration source fingerprint is invalid. Restore the applied state before generating.');
|
|
16
|
+
if(artifact.destructive)throw Error('Destructive Schema change needs a reviewed migration; no D1 file was written.');
|
|
17
|
+
const unchanged=artifact.sourceFingerprint===artifact.targetFingerprint&&artifact.migrations.length===0;
|
|
18
|
+
const fingerprint=JSON.parse(await readFile('src/storage-fingerprint.json','utf8'));
|
|
19
|
+
if(fingerprint!==state.fingerprint)throw Error('Generated fingerprint and migration source disagree. Restore the reviewed migration state.');
|
|
20
|
+
if(process.argv.includes('--check')){
|
|
21
|
+
if(!unchanged)throw Error('Pending Schema or runtime migration. Run node scripts/migration.mjs and review the new D1 file.');
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
const output=execFileSync('node',['node_modules/wrangler/bin/wrangler.js','d1','execute','mantle-sites-reference-local','--local','--command','SELECT fingerprint FROM _mantle_storage_state WHERE id = 1','--json'],{encoding:'utf8'});
|
|
25
|
+
const active=JSON.parse(output)[0]?.results?.[0]?.fingerprint;
|
|
26
|
+
if(active!==state.fingerprint)throw Error(`Local D1 fingerprint ${active??'(missing)'} does not match the last migration ${state.fingerprint}. Apply pending D1 migrations first.`);
|
|
27
|
+
const versionOutput=execFileSync('node',['node_modules/wrangler/bin/wrangler.js','d1','execute','mantle-sites-reference-local','--local','--command',"SELECT name FROM sqlite_schema WHERE type='table' AND name='_mantle_managed_runtime_state'",'--json'],{encoding:'utf8'});
|
|
28
|
+
const versionTable=JSON.parse(versionOutput)[0]?.results?.[0]?.name;
|
|
29
|
+
const version=versionTable?(JSON.parse(execFileSync('node',['node_modules/wrangler/bin/wrangler.js','d1','execute','mantle-sites-reference-local','--local','--command','SELECT canonical_version FROM _mantle_managed_runtime_state WHERE id = 1','--json'],{encoding:'utf8'}))[0]?.results?.[0]?.canonical_version??null):null;
|
|
30
|
+
if(version!==null&&version!==state.canonicalVersion)throw Error(`Local D1 runtime version ${version} does not match migration source ${state.canonicalVersion}.`);
|
|
31
|
+
if(version===null&&state.canonicalVersion!=='0005-store-instance-id')throw Error('Local D1 is missing the managed runtime version marker. Apply pending migrations first.');
|
|
32
|
+
if(unchanged){console.log('D1 migrations are current.');process.exit(0);}
|
|
33
|
+
const sql=renderSqliteManagedMigration(artifact,{canonicalVersion:state.canonicalVersion});
|
|
34
|
+
const index=state.lastIndex+1;
|
|
35
|
+
const path=`drizzle/${String(index).padStart(4,'0')}_mantle.sql`;
|
|
36
|
+
const journalPath='drizzle/meta/_journal.json';
|
|
37
|
+
const journal=JSON.parse(await readFile(journalPath,'utf8'));
|
|
38
|
+
if(journal.entries.at(-1)?.idx!==state.lastIndex)throw Error('Drizzle journal does not match the saved migration state.');
|
|
39
|
+
await writeFile(path,sql,{flag:'wx'});
|
|
40
|
+
await writeFile(journalPath,JSON.stringify({...journal,entries:[...journal.entries,{idx:index,version:'6',when:Date.now(),tag:`${String(index).padStart(4,'0')}_mantle`,breakpoints:true}]})+'\n');
|
|
41
|
+
await writeFile(statePath,JSON.stringify({fingerprint:artifact.targetFingerprint,canonicalVersion:artifact.targetCanonicalVersion,schemas:target,appliedMigrationIds:[...new Set([...state.appliedMigrationIds,...artifact.migrations.map(m=>m.id)])],lastIndex:index},null,2)+'\n');
|
|
9
42
|
await writeFile('src/storage-fingerprint.json',JSON.stringify(artifact.targetFingerprint)+'\n');
|
|
43
|
+
console.log(`Review ${path}, then apply it with wrangler d1 migrations apply mantle-sites-reference-local --local.`);
|
|
@@ -24,13 +24,16 @@ export function createChatGPTAuth(env: Env): AdminAuth {
|
|
|
24
24
|
basePath:'/api/auth', methods:[],
|
|
25
25
|
async getSession(request) {
|
|
26
26
|
const who=readIdentity(request); if(!who)return null;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
let row=await db.prepare('SELECT * FROM sites_users WHERE id=?').bind(who.id).first<UserRow>();
|
|
28
|
+
if(!row){
|
|
29
|
+
// Claim an invitation before inserting; a stable identity keeps its existing role if its email later changes.
|
|
30
|
+
await db.prepare('UPDATE sites_users SET id=?,name=?,signed_in=1 WHERE email=? AND signed_in=0').bind(who.id,who.name,who.email).run();
|
|
31
|
+
await db.prepare('INSERT OR IGNORE INTO sites_users (id,email,name,role,signed_in) VALUES (?,?,?,?,1)')
|
|
32
|
+
.bind(who.id,who.email,who.name,who.email===env.OWNER_EMAIL?.trim().toLowerCase()?'owner':null).run();
|
|
33
|
+
row=await db.prepare('SELECT * FROM sites_users WHERE id=?').bind(who.id).first<UserRow>();
|
|
34
|
+
}
|
|
32
35
|
if(!row)return null;
|
|
33
|
-
return {session:{id:`sites:${who.id}`},user:{
|
|
36
|
+
return {session:{id:`sites:${who.id}`},user:{id:row.id,email:row.email,name:row.name,role:row.role,roleCurrent:true}};
|
|
34
37
|
},
|
|
35
38
|
getUserRole:roleFor,
|
|
36
39
|
async handler(request) {
|
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
"aws4fetch": "^1.0.20"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@cloudflare/workers-types": "
|
|
24
|
+
"@cloudflare/workers-types": "5.20260904.1",
|
|
25
25
|
"typescript": "^6.0.3",
|
|
26
|
-
"wrangler": "
|
|
26
|
+
"wrangler": "4.129.0"
|
|
27
27
|
},
|
|
28
28
|
"packageManager": "pnpm@9.15.0"
|
|
29
29
|
}
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
"aws4fetch": "^1.0.20"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
|
-
"@cloudflare/workers-types": "
|
|
21
|
+
"@cloudflare/workers-types": "5.20260904.1",
|
|
22
22
|
"typescript": "^6.0.3",
|
|
23
|
-
"wrangler": "
|
|
23
|
+
"wrangler": "4.129.0"
|
|
24
24
|
},
|
|
25
25
|
"packageManager": "pnpm@9.15.0"
|
|
26
26
|
}
|