@topy-ai/maggie 0.2.9 → 0.3.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 +19 -0
- package/bin/maggie.js +10 -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-skills/maggie-auth-reference/SKILL.md +28 -0
- package/bundled-skills/maggie-blog/SKILL.md +34 -0
- package/bundled-skills/maggie-design/SKILL.md +17 -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 +38 -0
- package/bundled-tools/runtime/maggie_blog.py +167 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -264,12 +264,31 @@ 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 |
|
|
267
269
|
|
|
268
270
|
The remaining installable skills are `maggie-blog-bootstrap`, `maggie-dash`,
|
|
269
271
|
`maggie-clone`, `maggie-clone-to-template`, `maggie-marketplace`,
|
|
270
272
|
`maggie-template`, `maggie-design`, `maggie-ops`, `maggie-deployment`,
|
|
271
273
|
`maggie-project-context`, `maggie-social-share`, and `maggie-memory`.
|
|
272
274
|
|
|
275
|
+
The package also includes `maggie-auth-reference` and `maggie-blog`. Use the
|
|
276
|
+
stable commands below after installation:
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
maggie auth reference --project . --confirm
|
|
280
|
+
maggie auth check --project . --production
|
|
281
|
+
maggie design author --project . --route /about --purpose "Explain our approach" --audience "Visitors" --confirm
|
|
282
|
+
maggie blog init --project . --confirm
|
|
283
|
+
maggie blog ingest --project . --source local --input content/posts.json --confirm
|
|
284
|
+
maggie blog validate --project .
|
|
285
|
+
maggie blog sitemap --project .
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Blog imports are idempotent and draft-first. Published slugs remain stable;
|
|
289
|
+
public feeds exclude drafts, and `maggie blog rollback --confirm` restores the
|
|
290
|
+
latest local content backup.
|
|
291
|
+
|
|
273
292
|
List every installed skill and command:
|
|
274
293
|
|
|
275
294
|
```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,10 @@ 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 author --project PATH --route /about --purpose TEXT --audience TEXT --confirm
|
|
89
|
+
maggie auth reference --project PATH --confirm
|
|
90
|
+
maggie auth check --project PATH [--production]
|
|
91
|
+
maggie blog init|inspect|ingest|validate|publish|sitemap|settings|rollback --project PATH
|
|
86
92
|
maggie design status <job-id>
|
|
87
93
|
maggie service import <provider-url> --project PATH
|
|
88
94
|
maggie service sync <provider-url> --project PATH
|
|
@@ -206,7 +212,7 @@ function install(args) {
|
|
|
206
212
|
copyIfMissing(join(DESIGN_ROOT, "SPA-DESIGN.md"), join(root, ".maggie", "design-reference", "SPA-DESIGN.md"));
|
|
207
213
|
}
|
|
208
214
|
if (existsSync(MARKETPLACE_ROOT)) copyIfMissing(MARKETPLACE_ROOT, join(root, "marketplace"));
|
|
209
|
-
if (existsSync(
|
|
215
|
+
if (existsSync(CONTRACTS_ROOT)) copyIfMissing(CONTRACTS_ROOT, join(root, "contracts"));
|
|
210
216
|
if (existsSync(join(TEMPLATES_ROOT, "maggiedash"))) copyIfMissing(join(TEMPLATES_ROOT, "maggiedash"), join(root, "templates", "maggiedash"));
|
|
211
217
|
const stateDir = join(root, ".maggie");
|
|
212
218
|
mkdirSync(stateDir, { recursive: true });
|
|
@@ -240,7 +246,7 @@ function update(args) {
|
|
|
240
246
|
updated += syncTree(DESIGN_ROOT, join(root, ".maggie", "design-reference"), force);
|
|
241
247
|
}
|
|
242
248
|
if (existsSync(MARKETPLACE_ROOT) && existsSync(join(root, "marketplace"))) updated += syncTree(MARKETPLACE_ROOT, join(root, "marketplace"), force);
|
|
243
|
-
if (existsSync(
|
|
249
|
+
if (existsSync(CONTRACTS_ROOT) && existsSync(join(root, "contracts"))) updated += syncTree(CONTRACTS_ROOT, join(root, "contracts"), force);
|
|
244
250
|
if (existsSync(join(TEMPLATES_ROOT, "maggiedash")) && existsSync(join(root, "templates", "maggiedash"))) updated += syncTree(join(TEMPLATES_ROOT, "maggiedash"), join(root, "templates", "maggiedash"), force);
|
|
245
251
|
const stateDir = join(root, STATE_DIR);
|
|
246
252
|
mkdirSync(stateDir, { recursive: true });
|
|
@@ -346,6 +352,8 @@ try {
|
|
|
346
352
|
else if (command === "clone") workflowCli("maggie_clone.py", args);
|
|
347
353
|
else if (command === "clone-to-template") workflowCli("maggie_clone_to_template.py", args);
|
|
348
354
|
else if (command === "design") workflowCli("maggie_design.py", args);
|
|
355
|
+
else if (command === "auth") workflowCli("maggie_auth.py", args);
|
|
356
|
+
else if (command === "blog") workflowCli("maggie_blog.py", args);
|
|
349
357
|
else if (command === "service") service(args);
|
|
350
358
|
else if (command === "ops") workflowCli("maggie_ops.py", args);
|
|
351
359
|
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,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,34 @@
|
|
|
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.
|
|
@@ -73,6 +73,23 @@ 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
|
+
|
|
76
93
|
## Explicit homepage rebrand mode
|
|
77
94
|
|
|
78
95
|
The homepage `review` mode only compares screenshots. It does not rebrand a
|
|
@@ -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())
|
|
@@ -453,6 +453,31 @@ def in_place_job(project: Path, routes: list[str], force: bool = False) -> int:
|
|
|
453
453
|
return 0
|
|
454
454
|
|
|
455
455
|
|
|
456
|
+
def author_job(project: Path, route: str, purpose: str, audience: str, brief_file: Path | None, confirm: bool) -> int:
|
|
457
|
+
"""Create an original page brief without requiring an external source URL."""
|
|
458
|
+
project = project.resolve()
|
|
459
|
+
if not confirm:
|
|
460
|
+
print("CONFIRMATION_REQUIRED: rerun with --confirm", file=sys.stderr)
|
|
461
|
+
return 2
|
|
462
|
+
if not route.startswith("/") or route == "/": raise ValueError("author route must be a non-homepage path starting with /")
|
|
463
|
+
contract = require_design_contract(project)
|
|
464
|
+
relative = route.strip("/")
|
|
465
|
+
candidates = [project / "src" / "pages" / relative, project / "src" / "pages" / f"{relative}.astro", project / "src" / "app" / relative / "page.tsx", project / relative]
|
|
466
|
+
collision = next((path for path in candidates if path.exists()), None)
|
|
467
|
+
if collision: raise ValueError(f"route collision; existing route: {collision}")
|
|
468
|
+
source_brief = {}
|
|
469
|
+
if brief_file:
|
|
470
|
+
source_brief = json.loads(brief_file.resolve().read_text(encoding="utf-8"))
|
|
471
|
+
if not purpose.strip() and not source_brief.get("purpose"): raise ValueError("purpose is required")
|
|
472
|
+
if not audience.strip() and not source_brief.get("audience"): raise ValueError("audience is required")
|
|
473
|
+
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()}
|
|
474
|
+
digest = hashlib.sha256((str(project) + "\n" + route).encode()).hexdigest()[:12]
|
|
475
|
+
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")
|
|
476
|
+
plan_path = project / ".maggie" / "design" / f"author-{digest}.json"
|
|
477
|
+
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")
|
|
478
|
+
print(json.dumps({"brief": str(output), "plan": str(plan_path), "phase": "ready"}, indent=2)); return 0
|
|
479
|
+
|
|
480
|
+
|
|
456
481
|
def main() -> int:
|
|
457
482
|
if len(sys.argv) > 1 and sys.argv[1] == "rebrand":
|
|
458
483
|
rebrand = argparse.ArgumentParser(description="Apply an explicit brand identity to a packaged marketplace template.")
|
|
@@ -486,6 +511,19 @@ def main() -> int:
|
|
|
486
511
|
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
487
512
|
print(f"BLOCKED: maggie-design in-place: {error}", file=sys.stderr)
|
|
488
513
|
return 1
|
|
514
|
+
if len(sys.argv) > 1 and sys.argv[1] == "author":
|
|
515
|
+
author = argparse.ArgumentParser(description="Create an original first-party page brief and route plan.")
|
|
516
|
+
author.add_argument("--project", type=Path, default=Path.cwd())
|
|
517
|
+
author.add_argument("--route", required=True)
|
|
518
|
+
author.add_argument("--purpose", default="")
|
|
519
|
+
author.add_argument("--audience", default="")
|
|
520
|
+
author.add_argument("--brief-file", type=Path)
|
|
521
|
+
author.add_argument("--confirm", action="store_true")
|
|
522
|
+
args = author.parse_args(sys.argv[2:])
|
|
523
|
+
try:
|
|
524
|
+
return author_job(args.project, args.route, args.purpose, args.audience, args.brief_file, args.confirm)
|
|
525
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
526
|
+
print(f"BLOCKED: maggie-design author: {error}", file=sys.stderr); return 1
|
|
489
527
|
if len(sys.argv) > 1 and sys.argv[1] in {"run", "status", "resume"}:
|
|
490
528
|
workflow = argparse.ArgumentParser(description=__doc__)
|
|
491
529
|
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('"', """)
|