@topy-ai/maggie 0.7.36 → 0.7.37

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.
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
  import argparse
13
13
  import json
14
14
  import os
15
+ import re
15
16
  import shutil
16
17
  import subprocess
17
18
  import sys
@@ -145,9 +146,131 @@ def manifest_root_file_pairs(source: Path, manifest: dict[str, object], root: Pa
145
146
  return pairs
146
147
 
147
148
 
148
- def command_install_diff(source: Path, manifest: dict[str, object], target: Path, compare_dir: Path | None, force: bool) -> dict[str, object]:
149
+ def manifest_workspace_file_pairs(source: Path, manifest: dict[str, object], root: Path) -> list[tuple[Path, Path, Path]]:
150
+ """Return additional installable workspaces such as ./_maggie/booking."""
151
+ pairs: list[tuple[Path, Path, Path]] = []
152
+ for item in manifest.get("workspaces", []):
153
+ if not isinstance(item, dict):
154
+ raise RuntimeError("MaggieDash workspaces entries must be objects")
155
+ source_relative = Path(str(item.get("source", "")))
156
+ target_relative = Path(str(item.get("target", "")))
157
+ if not source_relative.parts or not target_relative.parts:
158
+ raise RuntimeError("MaggieDash workspaces entries require source and target")
159
+ if source_relative.is_absolute() or target_relative.is_absolute() or ".." in source_relative.parts or ".." in target_relative.parts:
160
+ raise RuntimeError("unsafe MaggieDash workspace path")
161
+ source_item = source / source_relative
162
+ if not source_item.is_dir():
163
+ raise RuntimeError(f"MaggieDash workspace source is missing: {source_relative}")
164
+ for path in sorted(source_item.rglob("*")):
165
+ if path.is_file():
166
+ child = path.relative_to(source_item)
167
+ pairs.append((path, root / target_relative / child, source_relative / child))
168
+ return pairs
169
+
170
+
171
+ def detect_host_framework(root: Path, requested: str) -> str | None:
172
+ """Resolve the optional host scaffold without guessing unsupported apps."""
173
+ if requested == "none":
174
+ return None
175
+ if requested != "auto":
176
+ return requested
177
+ package = root / "package.json"
178
+ try:
179
+ value = json.loads(package.read_text(encoding="utf-8"))
180
+ except (OSError, json.JSONDecodeError):
181
+ value = {}
182
+ dependencies = set((value.get("dependencies") or {})) | set((value.get("devDependencies") or {}))
183
+ if "astro" in dependencies or any(root.glob("src/**/*.astro")):
184
+ return "astro"
185
+ return None
186
+
187
+
188
+ ASTRO_MIDDLEWARE_MARKER = "maggie-auto-rewrite-v1"
189
+
190
+
191
+ def merge_astro_middleware(path: Path) -> dict[str, object]:
192
+ """Add the stable Maggie paths to an existing conventional Astro middleware.
193
+
194
+ The installer must preserve host-owned auth/redirect logic. This narrow
195
+ merge only handles the common `onRequest = defineMiddleware((context,
196
+ next) => { ... })` shape and is idempotent. Unknown middleware shapes are
197
+ reported to the caller instead of being rewritten heuristically.
198
+ """
199
+ try:
200
+ content = path.read_text(encoding="utf-8")
201
+ except OSError as error:
202
+ return {"status": "manual", "reason": f"could not read {path}: {error}"}
203
+ if ASTRO_MIDDLEWARE_MARKER in content:
204
+ return {"status": "unchanged", "reason": "Maggie rewrite block already present"}
205
+ required_patterns = ("/_maggie/booking", "/_maggie/login", "/_maggie/register", "/_maggie/reset-password")
206
+ if all(pattern in content for pattern in required_patterns) and "context.rewrite" in content:
207
+ return {"status": "unchanged", "reason": "existing middleware already exposes Maggie paths"}
208
+ match = re.search(
209
+ r"export\s+const\s+onRequest\s*=\s*defineMiddleware\(\s*\(\s*context\s*,\s*next\s*\)\s*=>\s*\{",
210
+ content,
211
+ )
212
+ if not match:
213
+ return {"status": "manual", "reason": "middleware is not the supported defineMiddleware((context, next) => {}) shape"}
214
+ block = r'''
215
+ // maggie-auto-rewrite-v1: generated by `maggie booking install`; keep host auth below.
216
+ const maggieRewrites: Array<[RegExp, (path: string) => string]> = [
217
+ [/^\/_maggie\/login\/?$/, () => "/maggie/login"],
218
+ [/^\/_maggie\/register\/?$/, () => "/maggie/register"],
219
+ [/^\/_maggie\/reset-password\/?$/, () => "/maggie/reset-password"],
220
+ [/^\/_maggie\/booking\/book\/?$/, () => "/maggie/booking/book"],
221
+ [/^\/_maggie\/booking(\/.*)?$/, (path) => "/maggie/booking" + path.replace(/^\/_maggie\/booking/, "")],
222
+ ];
223
+ for (const [pattern, target] of maggieRewrites) {
224
+ if (pattern.test(context.url.pathname)) {
225
+ const destination = new URL(target(context.url.pathname), context.url);
226
+ destination.search = context.url.search;
227
+ return context.rewrite(destination.pathname + destination.search);
228
+ }
229
+ }
230
+ '''
231
+ merged = content[:match.end()] + block + content[match.end():]
232
+ try:
233
+ path.write_text(merged, encoding="utf-8")
234
+ except OSError as error:
235
+ return {"status": "manual", "reason": f"could not update {path}: {error}"}
236
+ return {"status": "updated", "reason": "inserted idempotent Maggie rewrite block"}
237
+
238
+
239
+ def manifest_host_file_pairs(source: Path, manifest: dict[str, object], root: Path, framework: str | None) -> list[tuple[Path, Path, Path]]:
240
+ """Return non-destructive host files for a declared framework adapter."""
241
+ if not framework:
242
+ return []
243
+ bootstrap = manifest.get("hostBootstrap", {})
244
+ if not isinstance(bootstrap, dict):
245
+ return []
246
+ spec = bootstrap.get(framework)
247
+ if not isinstance(spec, dict):
248
+ raise RuntimeError(
249
+ f"MaggieDash distribution at {source} does not provide a {framework} host bootstrap; "
250
+ f"release a source with manifest.hostBootstrap.{framework} or pass --source to a released checkout"
251
+ )
252
+ source_relative = Path(str(spec.get("source", "")))
253
+ target_relative = Path(str(spec.get("target", ".")))
254
+ if not source_relative.parts or source_relative.is_absolute() or ".." in source_relative.parts:
255
+ raise RuntimeError("unsafe MaggieDash host bootstrap source")
256
+ if target_relative.as_posix() not in {".", ""} and (target_relative.is_absolute() or ".." in target_relative.parts):
257
+ raise RuntimeError("unsafe MaggieDash host bootstrap target")
258
+ source_item = source / source_relative
259
+ if not source_item.is_dir():
260
+ raise RuntimeError(f"MaggieDash host bootstrap source is missing: {source_relative}")
261
+ pairs: list[tuple[Path, Path, Path]] = []
262
+ for path in sorted(source_item.rglob("*")):
263
+ if path.is_file():
264
+ child = path.relative_to(source_item)
265
+ pairs.append((path, root / target_relative / child, source_relative / child))
266
+ return pairs
267
+
268
+
269
+ def command_install_diff(source: Path, manifest: dict[str, object], target: Path, compare_dir: Path | None, force: bool, host_pairs: list[tuple[Path, Path, Path]] | None = None, host_framework: str | None = None) -> dict[str, object]:
149
270
  pairs = manifest_file_pairs(source, manifest, target)
150
271
  root_pairs = manifest_root_file_pairs(source, manifest, target.parents[1])
272
+ workspace_pairs = manifest_workspace_file_pairs(source, manifest, target.parents[1])
273
+ host_pairs = host_pairs or []
151
274
  files: list[dict[str, str]] = []
152
275
  source_relative = {relative for _, _, relative in pairs}
153
276
  for source_file, destination, relative in pairs:
@@ -172,6 +295,24 @@ def command_install_diff(source: Path, manifest: dict[str, object], target: Path
172
295
  else:
173
296
  action = "update" if force else "preserve"
174
297
  files.append({"path": str(relative), "action": action, "comparePath": str(compare_file)})
298
+ for source_file, destination, relative in workspace_pairs:
299
+ compare_file = destination
300
+ if not compare_file.exists():
301
+ action = "add"
302
+ elif compare_file.read_bytes() == source_file.read_bytes():
303
+ action = "unchanged"
304
+ else:
305
+ action = "update" if force else "preserve"
306
+ files.append({"path": str(relative), "action": action, "comparePath": str(compare_file)})
307
+ for source_file, destination, relative in host_pairs:
308
+ compare_file = destination
309
+ if not compare_file.exists():
310
+ action = "add"
311
+ elif compare_file.read_bytes() == source_file.read_bytes():
312
+ action = "unchanged"
313
+ else:
314
+ action = "update" if force else "preserve"
315
+ files.append({"path": f"host/{host_framework}/{relative}", "action": action, "comparePath": str(compare_file)})
175
316
  if compare_dir and compare_dir.exists():
176
317
  for existing in sorted(compare_dir.rglob("*")):
177
318
  if not existing.is_file():
@@ -183,13 +324,14 @@ def command_install_diff(source: Path, manifest: dict[str, object], target: Path
183
324
  summary: dict[str, int] = {}
184
325
  for item in files:
185
326
  summary[item["action"]] = summary.get(item["action"], 0) + 1
186
- return {"status": "dry-run", "version": manifest.get("version"), "target": str(target), "compareDir": str(compare_dir) if compare_dir else None, "summary": summary, "files": files}
327
+ return {"status": "dry-run", "version": manifest.get("version"), "target": str(target), "hostFramework": host_framework, "compareDir": str(compare_dir) if compare_dir else None, "summary": summary, "files": files}
187
328
 
188
329
 
189
330
  def command_install(args: argparse.Namespace) -> int:
190
331
  if not args.dry_run and not args.diff:
191
332
  require_confirm(args)
192
333
  root = project_root(args)
334
+ host_framework = detect_host_framework(root, args.host)
193
335
  source_value = args.source or os.environ.get("MAGGIE_DASH_SOURCE") or DEFAULT_DASH_SOURCE
194
336
  ref = args.ref
195
337
  target_relative = Path(args.target)
@@ -222,6 +364,7 @@ def command_install(args: argparse.Namespace) -> int:
222
364
  raise RuntimeError("unsupported MaggieDash distribution schema")
223
365
  if manifest.get("target") != "_maggie/admin" and args.target == "_maggie/admin":
224
366
  raise RuntimeError("MaggieDash manifest target is not ./_maggie/admin")
367
+ host_pairs = manifest_host_file_pairs(source, manifest, root, host_framework)
225
368
  compare_dir = None
226
369
  if args.existing_dir:
227
370
  compare_dir = Path(args.existing_dir).expanduser()
@@ -229,7 +372,7 @@ def command_install(args: argparse.Namespace) -> int:
229
372
  compare_dir = root / compare_dir
230
373
  compare_dir = compare_dir.resolve()
231
374
  if args.dry_run or args.diff:
232
- emit(command_install_diff(source, manifest, target, compare_dir, args.force))
375
+ emit(command_install_diff(source, manifest, target, compare_dir, args.force, host_pairs, host_framework))
233
376
  return 0
234
377
  installed: list[str] = []
235
378
  preserved: list[str] = []
@@ -264,6 +407,32 @@ def command_install(args: argparse.Namespace) -> int:
264
407
  destination.parent.mkdir(parents=True, exist_ok=True)
265
408
  shutil.copy2(source_file, destination)
266
409
  installed.append(str(destination))
410
+ for source_file, destination, _ in manifest_workspace_file_pairs(source, manifest, root):
411
+ if destination.exists() and not args.force:
412
+ preserved.append(str(destination))
413
+ else:
414
+ destination.parent.mkdir(parents=True, exist_ok=True)
415
+ shutil.copy2(source_file, destination)
416
+ installed.append(str(destination))
417
+ host_installed: list[str] = []
418
+ host_preserved: list[str] = []
419
+ host_updated: list[str] = []
420
+ host_warnings: list[str] = []
421
+ for source_file, destination, _ in host_pairs:
422
+ if destination.exists() and not args.force:
423
+ host_preserved.append(str(destination))
424
+ else:
425
+ destination.parent.mkdir(parents=True, exist_ok=True)
426
+ shutil.copy2(source_file, destination)
427
+ host_installed.append(str(destination))
428
+ if host_framework == "astro":
429
+ middleware = root / "src" / "middleware.ts"
430
+ if middleware.exists():
431
+ merge_result = merge_astro_middleware(middleware)
432
+ if merge_result.get("status") == "updated":
433
+ host_updated.append(str(middleware))
434
+ elif merge_result.get("status") == "manual":
435
+ host_warnings.append(str(merge_result.get("reason")))
267
436
  state = {
268
437
  "schemaVersion": "maggiedash-install.v1",
269
438
  "status": "installed",
@@ -274,13 +443,19 @@ def command_install(args: argparse.Namespace) -> int:
274
443
  "target": str(target),
275
444
  "installed": sorted(set(installed)),
276
445
  "preserved": sorted(set(preserved)),
446
+ "hostFramework": host_framework,
447
+ "hostBootstrap": "installed" if host_framework else "skipped",
448
+ "hostInstalled": sorted(set(host_installed)),
449
+ "hostPreserved": sorted(set(host_preserved)),
450
+ "hostUpdated": sorted(set(host_updated)),
451
+ "hostWarnings": sorted(set(host_warnings)),
277
452
  "updatedAt": utc_now(),
278
453
  }
279
454
  state_path = root / ".maggie" / "dash-install.json"
280
455
  state_path.parent.mkdir(parents=True, exist_ok=True)
281
456
  state_path.write_text(json.dumps(state, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
282
- emit({"status": "installed", "version": manifest.get("version"), "target": str(target), "installed": len(set(installed)), "preserved": len(set(preserved)), "state": str(state_path)})
283
- return 0
457
+ emit({"status": "needs-attention" if host_warnings else "installed", "version": manifest.get("version"), "target": str(target), "hostFramework": host_framework, "hostInstalled": len(set(host_installed)), "hostPreserved": len(set(host_preserved)), "hostUpdated": len(set(host_updated)), "hostWarnings": host_warnings, "installed": len(set(installed)), "preserved": len(set(preserved)), "state": str(state_path)})
458
+ return 1 if host_warnings else 0
284
459
  finally:
285
460
  if temporary is not None:
286
461
  temporary.cleanup()
@@ -630,6 +805,7 @@ def parser() -> argparse.ArgumentParser:
630
805
  install.add_argument("--source", help="local checkout or Git URL; defaults to MaggieDash repository")
631
806
  install.add_argument("--ref", default="main", help="Git ref when installing from a remote source")
632
807
  install.add_argument("--target", default="_maggie/admin", help="installation target relative to the project")
808
+ install.add_argument("--host", choices=["auto", "astro", "none"], default="auto", help="install the matching non-destructive host scaffold (default: auto-detect Astro)")
633
809
  install.add_argument("--force", action="store_true", help="replace existing dashboard files")
634
810
  install.add_argument("--dry-run", action="store_true", help="show the install plan without writing files")
635
811
  install.add_argument("--diff", action="store_true", help="show the install plan and compare with an existing workspace")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.7.36",
3
+ "version": "0.7.37",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",