@topy-ai/maggie 0.2.9 → 0.5.0
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 +37 -0
- package/bin/maggie.js +13 -2
- package/bundled-contracts/maggie-auth/README.md +8 -0
- package/bundled-contracts/maggie-auth/auth-reference-v1.json +20 -0
- package/bundled-contracts/maggie-blog/blog-post-v1.schema.json +19 -0
- package/bundled-contracts/maggie-blog/blog-settings-v1.json +16 -0
- package/bundled-contracts/maggie-content-ui/README.md +6 -0
- package/bundled-contracts/maggie-content-ui/plan-v1.schema.json +19 -0
- package/bundled-contracts/maggie-content-ui/reference-manifest-v1.schema.json +15 -0
- package/bundled-skills/maggie-auth-reference/SKILL.md +28 -0
- package/bundled-skills/maggie-blog/SKILL.md +46 -0
- package/bundled-skills/maggie-design/SKILL.md +68 -0
- package/bundled-skills/maggie-service-booking/SKILL.md +13 -0
- package/bundled-tools/clis/maggie_auth.py +33 -0
- package/bundled-tools/clis/maggie_blog.py +49 -0
- package/bundled-tools/clis/maggie_design.py +175 -0
- package/bundled-tools/runtime/maggie_blog.py +167 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -264,12 +264,49 @@ python3 tools/clis/maggie_design.py rebrand \
|
|
|
264
264
|
| `maggie-memory` | Persist confirmed preferences, conventions, lessons, and errors |
|
|
265
265
|
| `maggie-content-localization` | Manage locale-aware translation, review, provenance, stale state, and publication gates |
|
|
266
266
|
| `maggie-feedback` | Collect redacted feedback drafts and explicitly submit them to the NoBlox feedback endpoint |
|
|
267
|
+
| `maggie-auth-reference` | Generate and validate traditional email/password auth with secure server-side sessions |
|
|
268
|
+
| `maggie-blog` | Run a provider-neutral blog lifecycle with stable identity, topics, feeds, settings, and rollback |
|
|
269
|
+
|
|
270
|
+
`maggie-design` can initialize native blog and service UI plans from a local,
|
|
271
|
+
read-only structural reference:
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
maggie design reference-ui --project . --reference /path/to/reference \
|
|
275
|
+
--surface blog,service --confirm
|
|
276
|
+
maggie design init --project . --surface blog \
|
|
277
|
+
--reference-run .maggie/design/content-ui/reference-<id> --confirm
|
|
278
|
+
maggie design init --project . --surface service \
|
|
279
|
+
--reference-run .maggie/design/content-ui/reference-<id> --confirm
|
|
280
|
+
maggie design validate-ui --project . \
|
|
281
|
+
--plan .maggie/design/content-ui/blog/init-<id>/plan.json \
|
|
282
|
+
--rendered-dir .maggie/design/content-ui/blog/init-<id>/screenshots --confirm
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
This reuses the host project's homepage shell and `DESIGN.md`; it does not
|
|
286
|
+
copy reference branding, source code, private data, or provider facts.
|
|
267
287
|
|
|
268
288
|
The remaining installable skills are `maggie-blog-bootstrap`, `maggie-dash`,
|
|
269
289
|
`maggie-clone`, `maggie-clone-to-template`, `maggie-marketplace`,
|
|
270
290
|
`maggie-template`, `maggie-design`, `maggie-ops`, `maggie-deployment`,
|
|
271
291
|
`maggie-project-context`, `maggie-social-share`, and `maggie-memory`.
|
|
272
292
|
|
|
293
|
+
The package also includes `maggie-auth-reference` and `maggie-blog`. Use the
|
|
294
|
+
stable commands below after installation:
|
|
295
|
+
|
|
296
|
+
```bash
|
|
297
|
+
maggie auth reference --project . --confirm
|
|
298
|
+
maggie auth check --project . --production
|
|
299
|
+
maggie design author --project . --route /about --purpose "Explain our approach" --audience "Visitors" --confirm
|
|
300
|
+
maggie blog init --project . --confirm
|
|
301
|
+
maggie blog ingest --project . --source local --input content/posts.json --confirm
|
|
302
|
+
maggie blog validate --project .
|
|
303
|
+
maggie blog sitemap --project .
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Blog imports are idempotent and draft-first. Published slugs remain stable;
|
|
307
|
+
public feeds exclude drafts, and `maggie blog rollback --confirm` restores the
|
|
308
|
+
latest local content backup.
|
|
309
|
+
|
|
273
310
|
List every installed skill and command:
|
|
274
311
|
|
|
275
312
|
```bash
|
package/bin/maggie.js
CHANGED
|
@@ -34,6 +34,8 @@ const SKILL_NAMES = [
|
|
|
34
34
|
"maggie-memory",
|
|
35
35
|
"maggie-content-localization",
|
|
36
36
|
"maggie-feedback",
|
|
37
|
+
"maggie-auth-reference",
|
|
38
|
+
"maggie-blog",
|
|
37
39
|
];
|
|
38
40
|
const RETIRED_PATHS = [
|
|
39
41
|
".agents/skills/maggie-emdash",
|
|
@@ -83,6 +85,13 @@ Usage:
|
|
|
83
85
|
maggie clone run <homepage-url> --run-id <id>
|
|
84
86
|
maggie clone status --run-id <id>
|
|
85
87
|
maggie design run <target-url> --clone-run <id>
|
|
88
|
+
maggie design reference-ui --project PATH --reference PATH --surface blog,service --confirm
|
|
89
|
+
maggie design init --project PATH --surface blog|service --reference-run PATH --confirm
|
|
90
|
+
maggie design validate-ui --project PATH --plan PATH --rendered-dir PATH --confirm
|
|
91
|
+
maggie design author --project PATH --route /about --purpose TEXT --audience TEXT --confirm
|
|
92
|
+
maggie auth reference --project PATH --confirm
|
|
93
|
+
maggie auth check --project PATH [--production]
|
|
94
|
+
maggie blog init|inspect|ingest|validate|publish|sitemap|settings|rollback --project PATH
|
|
86
95
|
maggie design status <job-id>
|
|
87
96
|
maggie service import <provider-url> --project PATH
|
|
88
97
|
maggie service sync <provider-url> --project PATH
|
|
@@ -206,7 +215,7 @@ function install(args) {
|
|
|
206
215
|
copyIfMissing(join(DESIGN_ROOT, "SPA-DESIGN.md"), join(root, ".maggie", "design-reference", "SPA-DESIGN.md"));
|
|
207
216
|
}
|
|
208
217
|
if (existsSync(MARKETPLACE_ROOT)) copyIfMissing(MARKETPLACE_ROOT, join(root, "marketplace"));
|
|
209
|
-
if (existsSync(
|
|
218
|
+
if (existsSync(CONTRACTS_ROOT)) copyIfMissing(CONTRACTS_ROOT, join(root, "contracts"));
|
|
210
219
|
if (existsSync(join(TEMPLATES_ROOT, "maggiedash"))) copyIfMissing(join(TEMPLATES_ROOT, "maggiedash"), join(root, "templates", "maggiedash"));
|
|
211
220
|
const stateDir = join(root, ".maggie");
|
|
212
221
|
mkdirSync(stateDir, { recursive: true });
|
|
@@ -240,7 +249,7 @@ function update(args) {
|
|
|
240
249
|
updated += syncTree(DESIGN_ROOT, join(root, ".maggie", "design-reference"), force);
|
|
241
250
|
}
|
|
242
251
|
if (existsSync(MARKETPLACE_ROOT) && existsSync(join(root, "marketplace"))) updated += syncTree(MARKETPLACE_ROOT, join(root, "marketplace"), force);
|
|
243
|
-
if (existsSync(
|
|
252
|
+
if (existsSync(CONTRACTS_ROOT) && existsSync(join(root, "contracts"))) updated += syncTree(CONTRACTS_ROOT, join(root, "contracts"), force);
|
|
244
253
|
if (existsSync(join(TEMPLATES_ROOT, "maggiedash")) && existsSync(join(root, "templates", "maggiedash"))) updated += syncTree(join(TEMPLATES_ROOT, "maggiedash"), join(root, "templates", "maggiedash"), force);
|
|
245
254
|
const stateDir = join(root, STATE_DIR);
|
|
246
255
|
mkdirSync(stateDir, { recursive: true });
|
|
@@ -346,6 +355,8 @@ try {
|
|
|
346
355
|
else if (command === "clone") workflowCli("maggie_clone.py", args);
|
|
347
356
|
else if (command === "clone-to-template") workflowCli("maggie_clone_to_template.py", args);
|
|
348
357
|
else if (command === "design") workflowCli("maggie_design.py", args);
|
|
358
|
+
else if (command === "auth") workflowCli("maggie_auth.py", args);
|
|
359
|
+
else if (command === "blog") workflowCli("maggie_blog.py", args);
|
|
349
360
|
else if (command === "service") service(args);
|
|
350
361
|
else if (command === "ops") workflowCli("maggie_ops.py", args);
|
|
351
362
|
else if (command === "deployment") workflowCli("maggie_deployment.py", args);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Maggie Auth Reference Contract
|
|
2
|
+
|
|
3
|
+
Provider-neutral traditional email/password authentication reference. This
|
|
4
|
+
contract intentionally excludes passkeys, WebAuthn, passwordless login, and
|
|
5
|
+
vendor-specific browser auth.
|
|
6
|
+
|
|
7
|
+
The host application owns persistence and HTTP handlers. The reference CLI
|
|
8
|
+
only writes a reviewable contract and validates production prerequisites.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "maggie-auth-reference.v1",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"required": ["schemaVersion", "passwordPolicy", "sessionPolicy", "tables"],
|
|
6
|
+
"properties": {
|
|
7
|
+
"schemaVersion": { "const": "maggie-auth-reference.v1" },
|
|
8
|
+
"passwordPolicy": {
|
|
9
|
+
"type": "object",
|
|
10
|
+
"required": ["algorithm", "minimumLength"],
|
|
11
|
+
"properties": { "algorithm": { "const": "argon2id" }, "minimumLength": { "type": "integer", "minimum": 12 } }
|
|
12
|
+
},
|
|
13
|
+
"sessionPolicy": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"required": ["serverSide", "httpOnly", "sameSite"],
|
|
16
|
+
"properties": { "serverSide": { "const": true }, "httpOnly": { "const": true }, "sameSite": { "enum": ["lax", "strict"] } }
|
|
17
|
+
},
|
|
18
|
+
"tables": { "type": "array", "items": { "type": "string" }, "minItems": 3 }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "maggie-blog-post.v1",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"required": ["schemaVersion", "id", "projectId", "contentId", "slug", "title", "body", "status", "source"],
|
|
6
|
+
"properties": {
|
|
7
|
+
"schemaVersion": { "const": "maggie-blog-post.v1" },
|
|
8
|
+
"id": { "type": "string", "minLength": 1 },
|
|
9
|
+
"projectId": { "type": "string", "minLength": 1 },
|
|
10
|
+
"contentId": { "type": "string", "minLength": 1 },
|
|
11
|
+
"slug": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
|
|
12
|
+
"title": { "type": "string", "minLength": 1 },
|
|
13
|
+
"excerpt": { "type": "string" },
|
|
14
|
+
"body": { "type": "object", "required": ["format", "value"], "properties": { "format": { "enum": ["markdown", "html"] }, "value": { "type": "string" } } },
|
|
15
|
+
"topics": { "type": "array", "items": { "type": "object", "required": ["slug", "label"] } },
|
|
16
|
+
"status": { "enum": ["draft", "review", "approved", "published", "archived"] },
|
|
17
|
+
"source": { "type": "object", "required": ["provider", "revision"] }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "maggie-blog-settings.v1",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"required": ["schemaVersion", "basePath", "postsPerPage", "defaultPostStatus", "autoPullEnabled"],
|
|
6
|
+
"properties": {
|
|
7
|
+
"schemaVersion": { "const": "maggie-blog-settings.v1" },
|
|
8
|
+
"basePath": { "type": "string", "pattern": "^/" },
|
|
9
|
+
"postsPerPage": { "type": "integer", "minimum": 1, "maximum": 100 },
|
|
10
|
+
"topicLimit": { "type": "integer", "minimum": 1 },
|
|
11
|
+
"defaultPostStatus": { "enum": ["draft", "review"] },
|
|
12
|
+
"autoPullEnabled": { "type": "boolean" },
|
|
13
|
+
"pullIntervalMinutes": { "type": "integer", "minimum": 5 },
|
|
14
|
+
"maxPerRun": { "type": "integer", "minimum": 1 }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Maggie Content UI Contracts
|
|
2
|
+
|
|
3
|
+
These contracts describe sanitized structural evidence captured from a local
|
|
4
|
+
UI reference and the native implementation plan consumed by `maggie-design`.
|
|
5
|
+
They intentionally exclude source-brand content, private data, credentials,
|
|
6
|
+
raw assets, and copied source code.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "maggie-content-ui-plan.v1",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"required": ["schemaVersion", "id", "workflow", "mode", "surface", "phase", "shell", "components", "routes", "dataAdapter", "publicWrite"],
|
|
6
|
+
"properties": {
|
|
7
|
+
"schemaVersion": { "const": "maggie-content-ui-plan.v1" },
|
|
8
|
+
"id": { "type": "string", "pattern": "^init-[a-f0-9]+$" },
|
|
9
|
+
"workflow": { "const": "maggie-design" },
|
|
10
|
+
"mode": { "const": "content-ui-init" },
|
|
11
|
+
"surface": { "enum": ["blog", "service"] },
|
|
12
|
+
"phase": { "const": "ready" },
|
|
13
|
+
"shell": { "type": "object", "required": ["sourceOfTruth", "reuse", "replaceAllowed"] },
|
|
14
|
+
"components": { "type": "array", "minItems": 1 },
|
|
15
|
+
"routes": { "type": "array", "minItems": 1 },
|
|
16
|
+
"dataAdapter": { "enum": ["maggie-blog", "maggie-service-booking"] },
|
|
17
|
+
"publicWrite": { "const": false }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "maggie-content-ui-reference.v1",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"required": ["schemaVersion", "id", "workflow", "mode", "surfaces", "sourcePolicy", "privateSourceCopied"],
|
|
6
|
+
"properties": {
|
|
7
|
+
"schemaVersion": { "const": "maggie-content-ui-reference.v1" },
|
|
8
|
+
"id": { "type": "string", "pattern": "^reference-[a-f0-9]+$" },
|
|
9
|
+
"workflow": { "const": "maggie-design" },
|
|
10
|
+
"mode": { "const": "reference-ui" },
|
|
11
|
+
"surfaces": { "type": "object", "minProperties": 1 },
|
|
12
|
+
"sourcePolicy": { "const": "read-only-structural-guideline" },
|
|
13
|
+
"privateSourceCopied": { "const": false }
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: maggie-auth-reference
|
|
3
|
+
description: Generate and validate a provider-neutral traditional email/password auth reference with secure server sessions and production security gates.
|
|
4
|
+
metadata:
|
|
5
|
+
version: 1.0.0
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Before and after a meaningful auth-reference run, follow the shared
|
|
9
|
+
[Maggie Memory Hook](../../references/memory-hook.md). Store only confirmed,
|
|
10
|
+
project-scoped lessons; never store passwords, hashes, tokens, or provider
|
|
11
|
+
credentials.
|
|
12
|
+
|
|
13
|
+
# Maggie Auth Reference
|
|
14
|
+
|
|
15
|
+
Use this skill when a project needs a reviewed starting point for
|
|
16
|
+
email/password authentication. It does not replace an existing auth provider
|
|
17
|
+
and it does not support passkeys, WebAuthn, or passwordless login.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
maggie auth reference --project . --confirm
|
|
21
|
+
maggie auth check --project . --production
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The reference uses Argon2id in production, normalized email identity,
|
|
25
|
+
HttpOnly/SameSite server-side sessions, generic failed-login responses,
|
|
26
|
+
throttling, lockout, revocation, and an explicit owner setup gate. Secrets,
|
|
27
|
+
hashes, and session tokens must never be emitted into browser responses,
|
|
28
|
+
logs, or project context.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: maggie-blog
|
|
3
|
+
description: Create and operate a provider-neutral blog with stable post identity, topics, draft approval, archive/post routes, RSS, sitemap, settings, and idempotent local ingest.
|
|
4
|
+
metadata:
|
|
5
|
+
version: 1.0.0
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Before and after a meaningful blog run, follow the shared [Maggie Memory
|
|
9
|
+
Hook](../../references/memory-hook.md). Record only confirmed, reusable
|
|
10
|
+
project lessons, never raw provider credentials or temporary content facts.
|
|
11
|
+
|
|
12
|
+
# Maggie Blog
|
|
13
|
+
|
|
14
|
+
Use the stable CLI to initialize a local blog contract, ingest versioned local
|
|
15
|
+
content, validate lifecycle invariants, publish with an actor and reason, and
|
|
16
|
+
generate route/feed artifacts. Read the host framework and database contract
|
|
17
|
+
before adding public routes. The host project owns rendering, persistence
|
|
18
|
+
credentials, and deployment; this skill owns normalized blog semantics.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
maggie blog init --project . --base-path /our-blogs --confirm
|
|
22
|
+
maggie blog ingest --project . --source local --input content/posts.json --confirm
|
|
23
|
+
maggie blog validate --project .
|
|
24
|
+
maggie blog publish --project . --slug example-post --actor owner --reason "approved" --confirm
|
|
25
|
+
maggie blog sitemap --project .
|
|
26
|
+
maggie blog settings --project .
|
|
27
|
+
maggie blog rollback --project . --confirm
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Posts are draft-first. Stable `contentId` is the ingest identity and a
|
|
31
|
+
published slug must not change during a rewrite. Search/sort views are not
|
|
32
|
+
indexable; drafts never appear in public routes, RSS, or sitemap output.
|
|
33
|
+
Provider keys remain server-side. Public publication and migrations always
|
|
34
|
+
require explicit confirmation.
|
|
35
|
+
|
|
36
|
+
To initialize native front-end pages from the approved local UI guideline,
|
|
37
|
+
run `maggie-design`:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
maggie design init --project . --surface blog \
|
|
41
|
+
--reference-run .maggie/design/content-ui/reference-<id> \
|
|
42
|
+
--base-path /our-blogs --confirm
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The blog skill supplies route/data semantics; `maggie-design` supplies the
|
|
46
|
+
host-native components and responsive visual review.
|
|
@@ -73,6 +73,74 @@ run visual review, and validate the route. It reuses shared tokens and the
|
|
|
73
73
|
approved palette; it does not rewrite `DESIGN.md`, change the colour palette,
|
|
74
74
|
or require a clone run.
|
|
75
75
|
|
|
76
|
+
## First-party author mode
|
|
77
|
+
|
|
78
|
+
Use author mode when the requested page is original and has no external source
|
|
79
|
+
URL. It preserves the homepage shell and current `DESIGN.md` contract, writes
|
|
80
|
+
a local brief and route plan, and stops before implementation approval:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
maggie design author --project . --route /about \
|
|
84
|
+
--purpose "Explain our approach and contact path" \
|
|
85
|
+
--audience "Prospective customers" --confirm
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The command rejects `/`, existing route collisions, missing `DESIGN.md`, and
|
|
89
|
+
unconfirmed writes. Generated copy and media must be marked authored rather
|
|
90
|
+
than source-observed. Implementation must still capture responsive screenshots
|
|
91
|
+
and run accessibility/build validation before publication.
|
|
92
|
+
|
|
93
|
+
## Content UI initialization
|
|
94
|
+
|
|
95
|
+
Use content UI initialization when `maggie-blog` or
|
|
96
|
+
`maggie-service-booking` needs native front-end layouts based on a local UI
|
|
97
|
+
reference. The reference is structural evidence only. It must not replace the
|
|
98
|
+
project's `DESIGN.md`, homepage shell, brand, copy, assets, or provider facts.
|
|
99
|
+
|
|
100
|
+
First record a sanitized, read-only reference manifest:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
maggie design reference-ui \
|
|
104
|
+
--project . \
|
|
105
|
+
--reference /home/balalior/Dev/clients/spachevychase.org \
|
|
106
|
+
--surface blog,service --confirm
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Then initialize one surface at a time from that manifest:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
maggie design init --project . --surface blog \
|
|
113
|
+
--reference-run .maggie/design/content-ui/reference-<id> \
|
|
114
|
+
--base-path /our-blogs --confirm
|
|
115
|
+
|
|
116
|
+
maggie design init --project . --surface service \
|
|
117
|
+
--reference-run .maggie/design/content-ui/reference-<id> \
|
|
118
|
+
--route-pattern '/services/[slug]/' --confirm
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The initializer creates a reviewable component/route/responsive/provenance
|
|
122
|
+
plan under `.maggie/design/content-ui/`. It does not write public components
|
|
123
|
+
or publish routes. The implementation phase must call the matching data skill,
|
|
124
|
+
reuse the homepage shell, capture desktop/tablet/mobile screenshots, and pass
|
|
125
|
+
accessibility, metadata, build, and visual review gates before approval.
|
|
126
|
+
|
|
127
|
+
Validate the rendered plan before approval:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
maggie design validate-ui --project . \
|
|
131
|
+
--plan .maggie/design/content-ui/blog/init-<id>/plan.json \
|
|
132
|
+
--rendered-dir .maggie/design/content-ui/blog/init-<id>/screenshots \
|
|
133
|
+
--confirm
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
This gate requires valid PNG evidence for desktop, tablet, and mobile and keeps
|
|
137
|
+
`publicWrite: false` until a separate approval step.
|
|
138
|
+
|
|
139
|
+
For blog UI, preserve archive, topic, post/slug, pagination, RSS, and sitemap
|
|
140
|
+
semantics from `maggie-blog`. For service UI, preserve provider identity,
|
|
141
|
+
variants, booking URLs, duplicate canonical/noindex decisions, and service
|
|
142
|
+
sitemap semantics from `maggie-service-booking`.
|
|
143
|
+
|
|
76
144
|
## Explicit homepage rebrand mode
|
|
77
145
|
|
|
78
146
|
The homepage `review` mode only compares screenshots. It does not rebrand a
|
|
@@ -207,6 +207,19 @@ and `DESIGN.md`. Use the homepage header, footer, fonts, tokens, navigation,
|
|
|
207
207
|
analytics boundary, accessibility behaviour, and responsive breakpoints.
|
|
208
208
|
Only the service content region varies.
|
|
209
209
|
|
|
210
|
+
When initializing a new service UI from the approved reference guideline, use
|
|
211
|
+
the shared `maggie-design` initializer:
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
maggie design init --project . --surface service \
|
|
215
|
+
--reference-run .maggie/design/content-ui/reference-<id> \
|
|
216
|
+
--route-pattern '/services/[slug]/' --confirm
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
This creates a reviewable native component/route plan. It does not copy the
|
|
220
|
+
reference project's brand, source code, assets, or provider facts, and it does
|
|
221
|
+
not publish service pages without the existing service fact/copy gates.
|
|
222
|
+
|
|
210
223
|
Each service page must expose the service title, category breadcrumbs, factual
|
|
211
224
|
description, all active variants, duration, currency-formatted price, booking
|
|
212
225
|
CTA, and payment CTA only if available. Preserve the provider booking URL as
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Auth reference and production prerequisite checks."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
12
|
+
from maggie_auth import production_hashing_ready # noqa: E402
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main() -> int:
|
|
16
|
+
parser = argparse.ArgumentParser(prog="maggie auth")
|
|
17
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
18
|
+
reference = sub.add_parser("reference"); reference.add_argument("--project", type=Path, default=Path.cwd()); reference.add_argument("--confirm", action="store_true")
|
|
19
|
+
check = sub.add_parser("check"); check.add_argument("--project", type=Path, default=Path.cwd()); check.add_argument("--production", action="store_true")
|
|
20
|
+
args = parser.parse_args(); project = args.project.resolve(); state = project / ".maggie" / "auth" / "reference.json"
|
|
21
|
+
if args.command == "reference":
|
|
22
|
+
if not args.confirm: print("CONFIRMATION_REQUIRED: rerun with --confirm", file=sys.stderr); return 2
|
|
23
|
+
state.parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
value = {"schemaVersion": "maggie-auth-reference.v1", "mode": "traditional-email-password", "passwordAlgorithm": "argon2id", "session": {"serverSide": True, "httpOnly": True, "sameSite": "lax"}, "tables": ["accounts", "sessions", "auth_events"], "status": "review", "project": str(project)}
|
|
25
|
+
state.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8"); print(json.dumps(value, indent=2)); return 0
|
|
26
|
+
result = {"reference": state.exists(), "argon2id": production_hashing_ready(), "production": args.production}
|
|
27
|
+
if args.production: result["passed"] = result["reference"] and result["argon2id"]
|
|
28
|
+
else: result["passed"] = result["reference"]
|
|
29
|
+
print(json.dumps(result, indent=2)); return 0 if result["passed"] else 1
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stable Maggie Blog CLI."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
12
|
+
from maggie_blog import BlogStore # noqa: E402
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main() -> int:
|
|
16
|
+
parser = argparse.ArgumentParser(prog="maggie blog")
|
|
17
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
18
|
+
init = sub.add_parser("init"); init.add_argument("--project", type=Path, default=Path.cwd()); init.add_argument("--base-path", default="/our-blogs"); init.add_argument("--posts-per-page", type=int, default=9); init.add_argument("--confirm", action="store_true")
|
|
19
|
+
inspect = sub.add_parser("inspect"); inspect.add_argument("--project", type=Path, default=Path.cwd())
|
|
20
|
+
ingest = sub.add_parser("ingest"); ingest.add_argument("--project", type=Path, default=Path.cwd()); ingest.add_argument("--input", type=Path, required=True); ingest.add_argument("--source", default="local"); ingest.add_argument("--confirm", action="store_true")
|
|
21
|
+
validate = sub.add_parser("validate"); validate.add_argument("--project", type=Path, default=Path.cwd())
|
|
22
|
+
publish = sub.add_parser("publish"); publish.add_argument("--project", type=Path, default=Path.cwd()); publish.add_argument("--slug", required=True); publish.add_argument("--actor", required=True); publish.add_argument("--reason", required=True); publish.add_argument("--confirm", action="store_true")
|
|
23
|
+
sitemap = sub.add_parser("sitemap"); sitemap.add_argument("--project", type=Path, default=Path.cwd())
|
|
24
|
+
settings = sub.add_parser("settings"); settings.add_argument("--project", type=Path, default=Path.cwd())
|
|
25
|
+
rollback = sub.add_parser("rollback"); rollback.add_argument("--project", type=Path, default=Path.cwd()); rollback.add_argument("--backup"); rollback.add_argument("--confirm", action="store_true")
|
|
26
|
+
args = parser.parse_args()
|
|
27
|
+
store = BlogStore(args.project.resolve())
|
|
28
|
+
if args.command in {"init", "ingest", "publish", "rollback"} and not args.confirm:
|
|
29
|
+
print("CONFIRMATION_REQUIRED: rerun with --confirm", file=sys.stderr); return 2
|
|
30
|
+
try:
|
|
31
|
+
if args.command == "init": result = store.init(args.base_path, args.posts_per_page)
|
|
32
|
+
elif args.command == "inspect": result = {"settings": store.settings(), "posts": store.posts(), "runs": json.loads(store.runs_path.read_text(encoding="utf-8")) if store.runs_path.exists() else []}
|
|
33
|
+
elif args.command == "ingest":
|
|
34
|
+
payload = json.loads(args.input.read_text(encoding="utf-8"));
|
|
35
|
+
if not isinstance(payload, list): raise ValueError("local input must be a JSON array")
|
|
36
|
+
result = store.ingest(payload, args.source)
|
|
37
|
+
elif args.command == "validate": result = store.validate()
|
|
38
|
+
elif args.command == "publish": result = store.publish(args.slug, args.actor, args.reason)
|
|
39
|
+
elif args.command == "settings": result = store.settings()
|
|
40
|
+
elif args.command == "rollback": result = store.rollback(args.backup)
|
|
41
|
+
else: result = store.generate_feeds()
|
|
42
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
43
|
+
print(f"maggie-blog: {error}", file=sys.stderr); return 1
|
|
44
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
45
|
+
return 0 if args.command != "validate" or result.get("valid") else 1
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
raise SystemExit(main())
|
|
@@ -34,6 +34,32 @@ DESIGN_HEADINGS = (
|
|
|
34
34
|
|
|
35
35
|
REVIEW_VIEWPORTS = {"desktop": (1440, 900), "tablet": (768, 900), "mobile": (390, 844)}
|
|
36
36
|
|
|
37
|
+
REFERENCE_SURFACES = {
|
|
38
|
+
"blog": {
|
|
39
|
+
"files": [
|
|
40
|
+
"src/layouts/BlogLayout.astro", "src/components/blog/BlogArchive.astro",
|
|
41
|
+
"src/components/blog/PostCard.astro", "src/components/blog/Pagination.astro",
|
|
42
|
+
"src/components/blog/PostSidebar.astro", "src/pages/our-blogs/index.astro",
|
|
43
|
+
"src/pages/our-blogs/[slug].astro", "src/pages/our-blogs/topic/[slug].astro",
|
|
44
|
+
"src/pages/rss.xml.ts", "src/pages/sitemap.xml.ts",
|
|
45
|
+
],
|
|
46
|
+
"components": ["BlogLayout", "BlogArchive", "PostCard", "Pagination", "PostSidebar", "ShareMenu"],
|
|
47
|
+
"routes": ["{base}/", "{base}/page/{n}/", "{base}/{slug}/", "{base}/topic/{slug}/", "{base}/topic/{slug}/page/{n}/", "/rss.xml", "/sitemap.xml"],
|
|
48
|
+
"patterns": ["compact image-backed archive hero", "URL-backed search/topic/sort controls", "responsive post grid", "sticky article sidebar", "related articles", "crawlable topic links"],
|
|
49
|
+
},
|
|
50
|
+
"service": {
|
|
51
|
+
"files": [
|
|
52
|
+
"src/pages/services/index.astro", "src/pages/services/[slug].astro",
|
|
53
|
+
"src/components/ServicePage.astro", "src/components/CategoryServiceHub.astro",
|
|
54
|
+
"src/components/SupportingServicePage.astro", "src/components/ServiceContextNarrative.astro",
|
|
55
|
+
"src/pages/sitemap-services.xml.ts", "src/pages/sitemap-service-categories.xml.ts",
|
|
56
|
+
],
|
|
57
|
+
"components": ["ServiceIndex", "CategoryServiceHub", "ServicePage", "ServiceFacts", "RelatedServices", "ServiceFaq"],
|
|
58
|
+
"routes": ["/services/", "/services/{slug}/", "/service-categories/{slug}/", "/sitemap-services.xml", "/sitemap-service-categories.xml"],
|
|
59
|
+
"patterns": ["category breadcrumb hero", "variant facts and booking card", "service narrative with image", "related treatment grid", "FAQ accordion", "category navigation"],
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
37
63
|
|
|
38
64
|
def rendered_asset_preflight(template: Path) -> list[str]:
|
|
39
65
|
"""Catch package CSS failures before visual comparison is trusted."""
|
|
@@ -56,6 +82,81 @@ def rendered_asset_preflight(template: Path) -> list[str]:
|
|
|
56
82
|
return errors
|
|
57
83
|
|
|
58
84
|
|
|
85
|
+
def _surface_list(value: str) -> list[str]:
|
|
86
|
+
surfaces = [item.strip().lower() for item in value.split(",") if item.strip()]
|
|
87
|
+
invalid = sorted(set(surfaces) - set(REFERENCE_SURFACES))
|
|
88
|
+
if invalid or not surfaces:
|
|
89
|
+
raise ValueError("surface must contain blog and/or service")
|
|
90
|
+
return list(dict.fromkeys(surfaces))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def reference_ui(project: Path, reference: Path, surfaces: list[str], screenshots_dir: Path | None, confirm: bool) -> int:
|
|
94
|
+
"""Record sanitized structural evidence from a read-only reference project."""
|
|
95
|
+
project = project.resolve(); reference = reference.resolve()
|
|
96
|
+
if not reference.is_dir(): raise ValueError(f"reference project not found: {reference}")
|
|
97
|
+
if not confirm: print("CONFIRMATION_REQUIRED: rerun with --confirm", file=sys.stderr); return 2
|
|
98
|
+
missing = {surface: [item for item in REFERENCE_SURFACES[surface]["files"] if not (reference / item).exists()] for surface in surfaces}
|
|
99
|
+
missing = {key: value for key, value in missing.items() if value}
|
|
100
|
+
if missing: raise ValueError("reference files missing: " + json.dumps(missing, ensure_ascii=False))
|
|
101
|
+
digest = hashlib.sha256((str(reference) + "\n" + ",".join(surfaces)).encode()).hexdigest()[:12]
|
|
102
|
+
output_dir = project / ".maggie" / "design" / "content-ui" / f"reference-{digest}"; output_dir.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
evidence = {}
|
|
104
|
+
for surface in surfaces:
|
|
105
|
+
config = REFERENCE_SURFACES[surface]
|
|
106
|
+
evidence[surface] = {"sourceFiles": config["files"], "components": config["components"], "routes": config["routes"], "patterns": config["patterns"], "observations": {pattern: True for pattern in config["patterns"]}}
|
|
107
|
+
screenshots = {name: "pending" for name in REVIEW_VIEWPORTS}
|
|
108
|
+
if screenshots_dir:
|
|
109
|
+
screenshots = {name: str((screenshots_dir / f"{name}.png").resolve()) if (screenshots_dir / f"{name}.png").is_file() else "missing" for name in REVIEW_VIEWPORTS}
|
|
110
|
+
manifest = {"schemaVersion": "maggie-content-ui-reference.v1", "id": f"reference-{digest}", "workflow": "maggie-design", "mode": "reference-ui", "referenceProject": reference.name, "surfaces": evidence, "screenshots": screenshots, "sourcePolicy": "read-only-structural-guideline", "privateSourceCopied": False, "createdAt": datetime.now(timezone.utc).isoformat()}
|
|
111
|
+
path = output_dir / "reference-manifest.json"; path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
112
|
+
print(json.dumps({"manifest": str(path), "id": manifest["id"], "surfaces": surfaces, "screenshotStatus": screenshots}, indent=2, ensure_ascii=False)); return 0
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def init_content_ui(project: Path, surface: str, reference_run: Path, base_path: str, route_pattern: str, confirm: bool) -> int:
|
|
116
|
+
"""Create the reviewable plan that Maggie Design will implement natively."""
|
|
117
|
+
project = project.resolve(); surface = surface.strip().lower()
|
|
118
|
+
if surface not in REFERENCE_SURFACES: raise ValueError("surface must be blog or service")
|
|
119
|
+
if not confirm: print("CONFIRMATION_REQUIRED: rerun with --confirm", file=sys.stderr); return 2
|
|
120
|
+
contract = require_design_contract(project)
|
|
121
|
+
manifest_path = reference_run.resolve()
|
|
122
|
+
if manifest_path.is_dir(): manifest_path /= "reference-manifest.json"
|
|
123
|
+
if not manifest_path.exists(): raise ValueError(f"reference manifest required: {manifest_path}")
|
|
124
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
125
|
+
if surface not in manifest.get("surfaces", {}): raise ValueError(f"reference manifest does not contain surface: {surface}")
|
|
126
|
+
digest = hashlib.sha256((str(project) + "\n" + surface + "\n" + str(manifest_path)).encode()).hexdigest()[:12]
|
|
127
|
+
output_dir = project / ".maggie" / "design" / "content-ui" / surface / f"init-{digest}"; output_dir.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
config = REFERENCE_SURFACES[surface]
|
|
129
|
+
routes = config["routes"]
|
|
130
|
+
if surface == "blog": routes = [route.replace("{base}", base_path.rstrip("/") or "/our-blogs") for route in routes]
|
|
131
|
+
elif route_pattern: routes = [route_pattern, "/sitemap-services.xml", "/sitemap-service-categories.xml"]
|
|
132
|
+
plan = {"schemaVersion": "maggie-content-ui-plan.v1", "id": f"init-{digest}", "workflow": "maggie-design", "mode": "content-ui-init", "surface": surface, "phase": "ready", "referenceManifest": str(manifest_path), "designContract": contract, "shell": {"sourceOfTruth": "existing homepage shell", "reuse": ["header", "footer", "tokens", "typography", "icons", "breakpoints", "accessibility", "analytics"], "replaceAllowed": False}, "components": config["components"], "routes": routes, "dataAdapter": "maggie-blog" if surface == "blog" else "maggie-service-booking", "provenance": {"referenceObserved": True, "projectPreserved": True, "agentAuthored": True, "providerFact": surface == "service"}, "requiredSteps": ["inspect-host-shell", "create-native-components", "bind-data-adapter", "capture-desktop-tablet-mobile", "accessibility-check", "metadata-and-route-check", "build-check", "explicit-approval"], "publicWrite": False, "createdAt": datetime.now(timezone.utc).isoformat()}
|
|
133
|
+
files = {"reference-manifest.json": manifest, "shell-reuse.json": plan["shell"], "component-plan.json": {"components": config["components"], "patterns": config["patterns"]}, "route-plan.json": {"routes": routes, "dataAdapter": plan["dataAdapter"]}, "responsive-plan.json": {"viewports": REVIEW_VIEWPORTS, "mobileStickySidebar": False if surface == "blog" else None}, "provenance.json": plan["provenance"], "validation.json": {"status": "pending", "required": plan["requiredSteps"]}}
|
|
134
|
+
for filename, value in files.items(): (output_dir / filename).write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
135
|
+
(output_dir / "screenshots").mkdir(exist_ok=True)
|
|
136
|
+
(output_dir / "plan.json").write_text(json.dumps(plan, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
137
|
+
print(json.dumps({"plan": str(output_dir / "plan.json"), "surface": surface, "phase": "ready", "publicWrite": False}, indent=2)); return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def validate_content_ui(project: Path, plan_path: Path, rendered_dir: Path | None, confirm: bool) -> int:
|
|
141
|
+
"""Validate a generated content UI plan before host publication."""
|
|
142
|
+
project = project.resolve(); plan_path = plan_path.resolve()
|
|
143
|
+
if not plan_path.exists(): raise ValueError(f"content UI plan not found: {plan_path}")
|
|
144
|
+
plan = json.loads(plan_path.read_text(encoding="utf-8")); errors = []
|
|
145
|
+
if plan.get("workflow") != "maggie-design" or plan.get("mode") != "content-ui-init": errors.append("invalid design workflow plan")
|
|
146
|
+
if plan.get("publicWrite") is not False: errors.append("publicWrite must remain false before approval")
|
|
147
|
+
try: require_design_contract(project)
|
|
148
|
+
except ValueError as error: errors.append(str(error))
|
|
149
|
+
screenshot_status = {}
|
|
150
|
+
for name in REVIEW_VIEWPORTS:
|
|
151
|
+
path = (rendered_dir / f"{name}.png") if rendered_dir else plan_path.parent / "screenshots" / f"{name}.png"
|
|
152
|
+
valid = path.is_file() and path.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n"
|
|
153
|
+
screenshot_status[name] = {"path": str(path), "validPng": valid}
|
|
154
|
+
if not valid: errors.append(f"missing valid {name} screenshot")
|
|
155
|
+
result = {"schemaVersion": "maggie-content-ui-validation.v1", "plan": str(plan_path), "surface": plan.get("surface"), "screenshots": screenshot_status, "checks": {"shellReuse": plan.get("shell", {}).get("replaceAllowed") is False, "publicWriteBlocked": plan.get("publicWrite") is False, "designContract": not any("DESIGN.md" in error for error in errors)}, "status": "passed" if not errors else "failed", "errors": errors, "validatedAt": datetime.now(timezone.utc).isoformat()}
|
|
156
|
+
if not confirm: print(json.dumps(result, indent=2, ensure_ascii=False)); return 0 if not errors else 1
|
|
157
|
+
output = plan_path.parent / "validation.json"; output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"); print(json.dumps(result, indent=2, ensure_ascii=False)); return 0 if not errors else 1
|
|
158
|
+
|
|
159
|
+
|
|
59
160
|
def rebrand_template(args: argparse.Namespace) -> int:
|
|
60
161
|
"""Apply an explicit brand identity to a packaged homepage template."""
|
|
61
162
|
template = args.template.resolve()
|
|
@@ -453,7 +554,68 @@ def in_place_job(project: Path, routes: list[str], force: bool = False) -> int:
|
|
|
453
554
|
return 0
|
|
454
555
|
|
|
455
556
|
|
|
557
|
+
def author_job(project: Path, route: str, purpose: str, audience: str, brief_file: Path | None, confirm: bool) -> int:
|
|
558
|
+
"""Create an original page brief without requiring an external source URL."""
|
|
559
|
+
project = project.resolve()
|
|
560
|
+
if not confirm:
|
|
561
|
+
print("CONFIRMATION_REQUIRED: rerun with --confirm", file=sys.stderr)
|
|
562
|
+
return 2
|
|
563
|
+
if not route.startswith("/") or route == "/": raise ValueError("author route must be a non-homepage path starting with /")
|
|
564
|
+
contract = require_design_contract(project)
|
|
565
|
+
relative = route.strip("/")
|
|
566
|
+
candidates = [project / "src" / "pages" / relative, project / "src" / "pages" / f"{relative}.astro", project / "src" / "app" / relative / "page.tsx", project / relative]
|
|
567
|
+
collision = next((path for path in candidates if path.exists()), None)
|
|
568
|
+
if collision: raise ValueError(f"route collision; existing route: {collision}")
|
|
569
|
+
source_brief = {}
|
|
570
|
+
if brief_file:
|
|
571
|
+
source_brief = json.loads(brief_file.resolve().read_text(encoding="utf-8"))
|
|
572
|
+
if not purpose.strip() and not source_brief.get("purpose"): raise ValueError("purpose is required")
|
|
573
|
+
if not audience.strip() and not source_brief.get("audience"): raise ValueError("audience is required")
|
|
574
|
+
brief = {"schemaVersion": "maggie-page-brief.v1", "route": route, "purpose": purpose.strip() or source_brief["purpose"], "audience": audience.strip() or source_brief["audience"], "contentBrief": source_brief.get("contentBrief", ""), "shell": "homepage-canonical", "status": "draft", "sourceEvidence": None, "approval": {"status": "pending", "actor": None}, "createdAt": datetime.now(timezone.utc).isoformat()}
|
|
575
|
+
digest = hashlib.sha256((str(project) + "\n" + route).encode()).hexdigest()[:12]
|
|
576
|
+
output = project / ".maggie" / "design" / "briefs" / f"{relative.replace('/', '-')}.json"; output.parent.mkdir(parents=True, exist_ok=True); output.write_text(json.dumps(brief, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
577
|
+
plan_path = project / ".maggie" / "design" / f"author-{digest}.json"
|
|
578
|
+
plan_path.write_text(json.dumps({"id": f"author-{digest}", "workflow": "maggie-design", "mode": "author", "phase": "ready", "brief": str(output), "designContract": contract, "sourceUrlRequired": False, "shellSourceOfTruth": "homepage-canonical", "approvalRequired": True, "requiredSteps": ["inspect-shell", "implement-page", "capture-responsive-screenshots", "accessibility-check", "build-check", "approval"]}, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
579
|
+
print(json.dumps({"brief": str(output), "plan": str(plan_path), "phase": "ready"}, indent=2)); return 0
|
|
580
|
+
|
|
581
|
+
|
|
456
582
|
def main() -> int:
|
|
583
|
+
if len(sys.argv) > 1 and sys.argv[1] == "reference-ui":
|
|
584
|
+
command = argparse.ArgumentParser(description="Record sanitized blog/service UI evidence from a read-only reference project.")
|
|
585
|
+
command.add_argument("--project", type=Path, default=Path.cwd())
|
|
586
|
+
command.add_argument("--reference", type=Path, required=True)
|
|
587
|
+
command.add_argument("--surface", default="blog,service")
|
|
588
|
+
command.add_argument("--screenshots-dir", type=Path)
|
|
589
|
+
command.add_argument("--confirm", action="store_true")
|
|
590
|
+
args = command.parse_args(sys.argv[2:])
|
|
591
|
+
try:
|
|
592
|
+
return reference_ui(args.project, args.reference, _surface_list(args.surface), args.screenshots_dir, args.confirm)
|
|
593
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
594
|
+
print(f"BLOCKED: maggie-design reference-ui: {error}", file=sys.stderr); return 1
|
|
595
|
+
if len(sys.argv) > 1 and sys.argv[1] == "init":
|
|
596
|
+
command = argparse.ArgumentParser(description="Initialize a native blog or service UI plan from a sanitized reference manifest.")
|
|
597
|
+
command.add_argument("--project", type=Path, default=Path.cwd())
|
|
598
|
+
command.add_argument("--surface", required=True, choices=["blog", "service"])
|
|
599
|
+
command.add_argument("--reference-run", type=Path, required=True)
|
|
600
|
+
command.add_argument("--base-path", default="/our-blogs")
|
|
601
|
+
command.add_argument("--route-pattern", default="/services/[slug]/")
|
|
602
|
+
command.add_argument("--confirm", action="store_true")
|
|
603
|
+
args = command.parse_args(sys.argv[2:])
|
|
604
|
+
try:
|
|
605
|
+
return init_content_ui(args.project, args.surface, args.reference_run, args.base_path, args.route_pattern, args.confirm)
|
|
606
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
607
|
+
print(f"BLOCKED: maggie-design init: {error}", file=sys.stderr); return 1
|
|
608
|
+
if len(sys.argv) > 1 and sys.argv[1] == "validate-ui":
|
|
609
|
+
command = argparse.ArgumentParser(description="Validate content UI screenshots and design/public-write gates.")
|
|
610
|
+
command.add_argument("--project", type=Path, default=Path.cwd())
|
|
611
|
+
command.add_argument("--plan", type=Path, required=True)
|
|
612
|
+
command.add_argument("--rendered-dir", type=Path)
|
|
613
|
+
command.add_argument("--confirm", action="store_true")
|
|
614
|
+
args = command.parse_args(sys.argv[2:])
|
|
615
|
+
try:
|
|
616
|
+
return validate_content_ui(args.project, args.plan, args.rendered_dir, args.confirm)
|
|
617
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
618
|
+
print(f"BLOCKED: maggie-design validate-ui: {error}", file=sys.stderr); return 1
|
|
457
619
|
if len(sys.argv) > 1 and sys.argv[1] == "rebrand":
|
|
458
620
|
rebrand = argparse.ArgumentParser(description="Apply an explicit brand identity to a packaged marketplace template.")
|
|
459
621
|
rebrand.add_argument("--template", type=Path, required=True)
|
|
@@ -486,6 +648,19 @@ def main() -> int:
|
|
|
486
648
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
487
649
|
print(f"BLOCKED: maggie-design in-place: {error}", file=sys.stderr)
|
|
488
650
|
return 1
|
|
651
|
+
if len(sys.argv) > 1 and sys.argv[1] == "author":
|
|
652
|
+
author = argparse.ArgumentParser(description="Create an original first-party page brief and route plan.")
|
|
653
|
+
author.add_argument("--project", type=Path, default=Path.cwd())
|
|
654
|
+
author.add_argument("--route", required=True)
|
|
655
|
+
author.add_argument("--purpose", default="")
|
|
656
|
+
author.add_argument("--audience", default="")
|
|
657
|
+
author.add_argument("--brief-file", type=Path)
|
|
658
|
+
author.add_argument("--confirm", action="store_true")
|
|
659
|
+
args = author.parse_args(sys.argv[2:])
|
|
660
|
+
try:
|
|
661
|
+
return author_job(args.project, args.route, args.purpose, args.audience, args.brief_file, args.confirm)
|
|
662
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
663
|
+
print(f"BLOCKED: maggie-design author: {error}", file=sys.stderr); return 1
|
|
489
664
|
if len(sys.argv) > 1 and sys.argv[1] in {"run", "status", "resume"}:
|
|
490
665
|
workflow = argparse.ArgumentParser(description=__doc__)
|
|
491
666
|
sub = workflow.add_subparsers(dest="command", required=True)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Local, provider-neutral Maggie Blog content lifecycle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
STATUSES = ("draft", "review", "approved", "published", "archived")
|
|
13
|
+
DEFAULTS = {
|
|
14
|
+
"schemaVersion": "maggie-blog-settings.v1",
|
|
15
|
+
"basePath": "/our-blogs",
|
|
16
|
+
"postsPerPage": 9,
|
|
17
|
+
"topicLimit": 12,
|
|
18
|
+
"defaultPostStatus": "draft",
|
|
19
|
+
"autoPullEnabled": False,
|
|
20
|
+
"pullIntervalMinutes": 120,
|
|
21
|
+
"maxPerRun": 1,
|
|
22
|
+
"fallbackImageMode": "gradient",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def now() -> str:
|
|
27
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def slugify(value: str) -> str:
|
|
31
|
+
value = re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-")
|
|
32
|
+
if not value:
|
|
33
|
+
raise ValueError("slug cannot be empty")
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def checksum(value: object) -> str:
|
|
38
|
+
return "sha256:" + hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BlogStore:
|
|
42
|
+
def __init__(self, project: Path) -> None:
|
|
43
|
+
self.root = project / ".maggie" / "blog"
|
|
44
|
+
self.posts_path = self.root / "posts.json"
|
|
45
|
+
self.settings_path = self.root / "settings.json"
|
|
46
|
+
self.runs_path = self.root / "pull-runs.json"
|
|
47
|
+
|
|
48
|
+
def init(self, base_path: str = "/our-blogs", posts_per_page: int = 9) -> dict:
|
|
49
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
settings = {**DEFAULTS, "basePath": "/" + base_path.strip("/"), "postsPerPage": posts_per_page, "updatedAt": now()}
|
|
51
|
+
if self.settings_path.exists():
|
|
52
|
+
existing = json.loads(self.settings_path.read_text(encoding="utf-8"))
|
|
53
|
+
settings = {**settings, **existing}
|
|
54
|
+
self._write(self.settings_path, settings)
|
|
55
|
+
if not self.posts_path.exists(): self._write(self.posts_path, [])
|
|
56
|
+
if not self.runs_path.exists(): self._write(self.runs_path, [])
|
|
57
|
+
return settings
|
|
58
|
+
|
|
59
|
+
def settings(self) -> dict:
|
|
60
|
+
if not self.settings_path.exists(): raise ValueError("blog is not initialized; run `maggie blog init` first")
|
|
61
|
+
return json.loads(self.settings_path.read_text(encoding="utf-8"))
|
|
62
|
+
|
|
63
|
+
def posts(self) -> list[dict]:
|
|
64
|
+
if not self.posts_path.exists(): return []
|
|
65
|
+
return json.loads(self.posts_path.read_text(encoding="utf-8"))
|
|
66
|
+
|
|
67
|
+
def _write(self, path: Path, value: object) -> None:
|
|
68
|
+
path.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
69
|
+
|
|
70
|
+
def _backup(self) -> None:
|
|
71
|
+
if self.posts_path.exists():
|
|
72
|
+
backup_dir = self.root / "backups"; backup_dir.mkdir(exist_ok=True)
|
|
73
|
+
shutil.copy2(self.posts_path, backup_dir / f"posts-{now().replace(':', '').replace('.', '')}.json")
|
|
74
|
+
|
|
75
|
+
def ingest(self, payload: list[dict], provider: str = "local") -> dict:
|
|
76
|
+
settings = self.settings()
|
|
77
|
+
posts = self.posts()
|
|
78
|
+
by_content = {post["contentId"]: post for post in posts}
|
|
79
|
+
changed = 0
|
|
80
|
+
for item in payload:
|
|
81
|
+
content_id = str(item.get("contentId") or item.get("id") or "").strip()
|
|
82
|
+
title = str(item.get("title") or "").strip()
|
|
83
|
+
if not content_id or not title: raise ValueError("each post requires contentId/id and title")
|
|
84
|
+
old = by_content.get(content_id)
|
|
85
|
+
raw_slug = str(item.get("slug") or title)
|
|
86
|
+
post = {
|
|
87
|
+
"schemaVersion": "maggie-blog-post.v1",
|
|
88
|
+
"id": old["id"] if old else f"post:{content_id}",
|
|
89
|
+
"projectId": str(item.get("projectId") or "local-project"),
|
|
90
|
+
"contentId": content_id,
|
|
91
|
+
"slug": old["slug"] if old else slugify(raw_slug),
|
|
92
|
+
"title": title,
|
|
93
|
+
"excerpt": str(item.get("excerpt") or ""),
|
|
94
|
+
"body": item.get("body") if isinstance(item.get("body"), dict) else {"format": str(item.get("format") or "markdown"), "value": str(item.get("body") or item.get("content") or "")},
|
|
95
|
+
"topics": [{"slug": slugify(str(topic)), "label": str(topic)} for topic in item.get("topics", item.get("keywords", []))],
|
|
96
|
+
# Provider input can suggest a status, but cannot bypass the
|
|
97
|
+
# local approval gate. Existing published state is preserved.
|
|
98
|
+
"status": old.get("status", settings["defaultPostStatus"]) if old else settings["defaultPostStatus"],
|
|
99
|
+
"canonicalUrl": item.get("canonicalUrl"),
|
|
100
|
+
"source": {"provider": provider, "revision": str(item.get("revision") or checksum(item))},
|
|
101
|
+
"publishedAt": old.get("publishedAt") if old else item.get("publishedAt"),
|
|
102
|
+
"updatedAt": now(),
|
|
103
|
+
}
|
|
104
|
+
if post["status"] not in STATUSES: raise ValueError(f"invalid post status: {post['status']}")
|
|
105
|
+
post["canonicalUrl"] = post["canonicalUrl"] or settings["basePath"].rstrip("/") + "/" + post["slug"] + "/"
|
|
106
|
+
comparable_old = {k: v for k, v in (old or {}).items() if k != "updatedAt"}
|
|
107
|
+
comparable_new = {k: v for k, v in post.items() if k != "updatedAt"}
|
|
108
|
+
if comparable_old != comparable_new: changed += 1
|
|
109
|
+
by_content[content_id] = post
|
|
110
|
+
result = list(by_content.values())
|
|
111
|
+
slugs = [post["slug"] for post in result]
|
|
112
|
+
if len(slugs) != len(set(slugs)): raise ValueError("slug collision detected; provide unique slugs")
|
|
113
|
+
self._backup(); self._write(self.posts_path, result)
|
|
114
|
+
runs = json.loads(self.runs_path.read_text(encoding="utf-8")) if self.runs_path.exists() else []
|
|
115
|
+
run = {"id": "pull:" + hashlib.sha256((now() + provider).encode()).hexdigest()[:12], "provider": provider, "status": "completed", "delivered": len(payload), "changed": changed, "finishedAt": now()}
|
|
116
|
+
runs.append(run); self._write(self.runs_path, runs)
|
|
117
|
+
return run
|
|
118
|
+
|
|
119
|
+
def validate(self) -> dict:
|
|
120
|
+
settings = self.settings(); posts = self.posts(); errors = []
|
|
121
|
+
ids = [p.get("contentId") for p in posts]; slugs = [p.get("slug") for p in posts]
|
|
122
|
+
if len(ids) != len(set(ids)): errors.append("duplicate contentId")
|
|
123
|
+
if len(slugs) != len(set(slugs)): errors.append("duplicate slug")
|
|
124
|
+
for post in posts:
|
|
125
|
+
if post.get("schemaVersion") != "maggie-blog-post.v1": errors.append(f"{post.get('id')}: schemaVersion")
|
|
126
|
+
if post.get("status") not in STATUSES: errors.append(f"{post.get('id')}: status")
|
|
127
|
+
if post.get("status") == "published" and not post.get("publishedAt"): errors.append(f"{post.get('id')}: publishedAt required")
|
|
128
|
+
return {"valid": not errors, "posts": len(posts), "published": sum(p.get("status") == "published" for p in posts), "settings": settings, "errors": errors}
|
|
129
|
+
|
|
130
|
+
def publish(self, slug: str, actor: str, reason: str) -> dict:
|
|
131
|
+
if not actor or not reason: raise ValueError("actor and reason are required")
|
|
132
|
+
posts = self.posts(); found = next((p for p in posts if p.get("slug") == slug), None)
|
|
133
|
+
if not found: raise ValueError(f"post not found: {slug}")
|
|
134
|
+
if found["status"] not in {"draft", "review", "approved"}: raise ValueError(f"post cannot publish from {found['status']}")
|
|
135
|
+
found["status"] = "published"; found["publishedAt"] = found.get("publishedAt") or now(); found["updatedAt"] = now(); found["lastTransition"] = {"actor": actor, "reason": reason, "at": now()}
|
|
136
|
+
self._backup(); self._write(self.posts_path, posts); return found
|
|
137
|
+
|
|
138
|
+
def rollback(self, backup: str | None = None) -> dict:
|
|
139
|
+
backups = sorted((self.root / "backups").glob("posts-*.json")) if (self.root / "backups").exists() else []
|
|
140
|
+
source = Path(backup) if backup else (backups[-1] if backups else None)
|
|
141
|
+
if source is None or not source.exists(): raise ValueError("no blog backup available")
|
|
142
|
+
current = self.posts_path.read_text(encoding="utf-8") if self.posts_path.exists() else "[]"
|
|
143
|
+
self._backup(); self.posts_path.write_text(source.read_text(encoding="utf-8"), encoding="utf-8")
|
|
144
|
+
return {"restored": str(source), "previousPostCount": len(json.loads(current)), "postCount": len(self.posts())}
|
|
145
|
+
|
|
146
|
+
def generate_feeds(self) -> dict:
|
|
147
|
+
settings = self.settings(); posts = [p for p in self.posts() if p.get("status") == "published"]
|
|
148
|
+
base = settings["basePath"].rstrip("/"); out = self.root / "generated"; out.mkdir(exist_ok=True)
|
|
149
|
+
topics = sorted({topic["slug"] for post in posts for topic in post.get("topics", [])})
|
|
150
|
+
page_count = max(1, (len(posts) + settings["postsPerPage"] - 1) // settings["postsPerPage"])
|
|
151
|
+
routes = {
|
|
152
|
+
"archive": base + "/", "archivePages": [base + "/page/" + str(n) + "/" for n in range(2, page_count + 1)],
|
|
153
|
+
"post": [base + "/" + p["slug"] + "/" for p in posts],
|
|
154
|
+
"topics": [base + "/topic/" + t + "/" for t in topics], "rss": "/rss.xml", "sitemap": "/sitemap.xml",
|
|
155
|
+
"searchPolicy": {"robots": "noindex,follow", "canonical": "archive"},
|
|
156
|
+
"sortPolicy": {"robots": "noindex,follow", "canonical": "archive"},
|
|
157
|
+
"postMetadata": [{"url": base + "/" + p["slug"] + "/", "canonical": p["canonicalUrl"], "type": "BlogPosting", "headline": p["title"], "datePublished": p.get("publishedAt"), "dateModified": p.get("updatedAt")} for p in posts],
|
|
158
|
+
}
|
|
159
|
+
rss = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><rss version=\"2.0\"><channel>" + "".join(f"<item><title>{_xml(p['title'])}</title><link>{_xml(p['canonicalUrl'])}</link></item>" for p in posts) + "</channel></rss>"
|
|
160
|
+
sitemap_urls = [base + "/"] + routes["archivePages"] + routes["post"] + routes["topics"]
|
|
161
|
+
sitemap = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">" + "".join(f"<url><loc>{_xml(url)}</loc></url>" for url in sitemap_urls) + "</urlset>"
|
|
162
|
+
self._write(out / "routes.json", routes); (out / "rss.xml").write_text(rss, encoding="utf-8"); (out / "sitemap.xml").write_text(sitemap, encoding="utf-8")
|
|
163
|
+
return {"routes": routes, "rss": str(out / "rss.xml"), "sitemap": str(out / "sitemap.xml")}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _xml(value: object) -> str:
|
|
167
|
+
return str(value).replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|