@topy-ai/maggie 0.7.36 → 0.7.38

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,137 @@ 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
+ # A deployment config is host-owned. The default Node config is
266
+ # useful for a blank Astro project, but adding a second config to
267
+ # a host that already has astro.config.ts/cloudflare/etc. can make
268
+ # Astro ambiguous or change its deployment target.
269
+ if framework == "astro" and child.name == "astro.config.mjs" and any(root.glob("astro.config.*")):
270
+ continue
271
+ pairs.append((path, root / target_relative / child, source_relative / child))
272
+ return pairs
273
+
274
+
275
+ 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
276
  pairs = manifest_file_pairs(source, manifest, target)
150
277
  root_pairs = manifest_root_file_pairs(source, manifest, target.parents[1])
278
+ workspace_pairs = manifest_workspace_file_pairs(source, manifest, target.parents[1])
279
+ host_pairs = host_pairs or []
151
280
  files: list[dict[str, str]] = []
152
281
  source_relative = {relative for _, _, relative in pairs}
153
282
  for source_file, destination, relative in pairs:
@@ -172,6 +301,24 @@ def command_install_diff(source: Path, manifest: dict[str, object], target: Path
172
301
  else:
173
302
  action = "update" if force else "preserve"
174
303
  files.append({"path": str(relative), "action": action, "comparePath": str(compare_file)})
304
+ for source_file, destination, relative in workspace_pairs:
305
+ compare_file = destination
306
+ if not compare_file.exists():
307
+ action = "add"
308
+ elif compare_file.read_bytes() == source_file.read_bytes():
309
+ action = "unchanged"
310
+ else:
311
+ action = "update" if force else "preserve"
312
+ files.append({"path": str(relative), "action": action, "comparePath": str(compare_file)})
313
+ for source_file, destination, relative in host_pairs:
314
+ compare_file = destination
315
+ if not compare_file.exists():
316
+ action = "add"
317
+ elif compare_file.read_bytes() == source_file.read_bytes():
318
+ action = "unchanged"
319
+ else:
320
+ action = "update" if force else "preserve"
321
+ files.append({"path": f"host/{host_framework}/{relative}", "action": action, "comparePath": str(compare_file)})
175
322
  if compare_dir and compare_dir.exists():
176
323
  for existing in sorted(compare_dir.rglob("*")):
177
324
  if not existing.is_file():
@@ -183,13 +330,14 @@ def command_install_diff(source: Path, manifest: dict[str, object], target: Path
183
330
  summary: dict[str, int] = {}
184
331
  for item in files:
185
332
  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}
333
+ 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
334
 
188
335
 
189
336
  def command_install(args: argparse.Namespace) -> int:
190
337
  if not args.dry_run and not args.diff:
191
338
  require_confirm(args)
192
339
  root = project_root(args)
340
+ host_framework = detect_host_framework(root, args.host)
193
341
  source_value = args.source or os.environ.get("MAGGIE_DASH_SOURCE") or DEFAULT_DASH_SOURCE
194
342
  ref = args.ref
195
343
  target_relative = Path(args.target)
@@ -222,6 +370,7 @@ def command_install(args: argparse.Namespace) -> int:
222
370
  raise RuntimeError("unsupported MaggieDash distribution schema")
223
371
  if manifest.get("target") != "_maggie/admin" and args.target == "_maggie/admin":
224
372
  raise RuntimeError("MaggieDash manifest target is not ./_maggie/admin")
373
+ host_pairs = manifest_host_file_pairs(source, manifest, root, host_framework)
225
374
  compare_dir = None
226
375
  if args.existing_dir:
227
376
  compare_dir = Path(args.existing_dir).expanduser()
@@ -229,7 +378,7 @@ def command_install(args: argparse.Namespace) -> int:
229
378
  compare_dir = root / compare_dir
230
379
  compare_dir = compare_dir.resolve()
231
380
  if args.dry_run or args.diff:
232
- emit(command_install_diff(source, manifest, target, compare_dir, args.force))
381
+ emit(command_install_diff(source, manifest, target, compare_dir, args.force, host_pairs, host_framework))
233
382
  return 0
234
383
  installed: list[str] = []
235
384
  preserved: list[str] = []
@@ -264,6 +413,32 @@ def command_install(args: argparse.Namespace) -> int:
264
413
  destination.parent.mkdir(parents=True, exist_ok=True)
265
414
  shutil.copy2(source_file, destination)
266
415
  installed.append(str(destination))
416
+ for source_file, destination, _ in manifest_workspace_file_pairs(source, manifest, root):
417
+ if destination.exists() and not args.force:
418
+ preserved.append(str(destination))
419
+ else:
420
+ destination.parent.mkdir(parents=True, exist_ok=True)
421
+ shutil.copy2(source_file, destination)
422
+ installed.append(str(destination))
423
+ host_installed: list[str] = []
424
+ host_preserved: list[str] = []
425
+ host_updated: list[str] = []
426
+ host_warnings: list[str] = []
427
+ for source_file, destination, _ in host_pairs:
428
+ if destination.exists() and not args.force:
429
+ host_preserved.append(str(destination))
430
+ else:
431
+ destination.parent.mkdir(parents=True, exist_ok=True)
432
+ shutil.copy2(source_file, destination)
433
+ host_installed.append(str(destination))
434
+ if host_framework == "astro":
435
+ middleware = root / "src" / "middleware.ts"
436
+ if middleware.exists():
437
+ merge_result = merge_astro_middleware(middleware)
438
+ if merge_result.get("status") == "updated":
439
+ host_updated.append(str(middleware))
440
+ elif merge_result.get("status") == "manual":
441
+ host_warnings.append(str(merge_result.get("reason")))
267
442
  state = {
268
443
  "schemaVersion": "maggiedash-install.v1",
269
444
  "status": "installed",
@@ -274,13 +449,19 @@ def command_install(args: argparse.Namespace) -> int:
274
449
  "target": str(target),
275
450
  "installed": sorted(set(installed)),
276
451
  "preserved": sorted(set(preserved)),
452
+ "hostFramework": host_framework,
453
+ "hostBootstrap": "installed" if host_framework else "skipped",
454
+ "hostInstalled": sorted(set(host_installed)),
455
+ "hostPreserved": sorted(set(host_preserved)),
456
+ "hostUpdated": sorted(set(host_updated)),
457
+ "hostWarnings": sorted(set(host_warnings)),
277
458
  "updatedAt": utc_now(),
278
459
  }
279
460
  state_path = root / ".maggie" / "dash-install.json"
280
461
  state_path.parent.mkdir(parents=True, exist_ok=True)
281
462
  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
463
+ 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)})
464
+ return 1 if host_warnings else 0
284
465
  finally:
285
466
  if temporary is not None:
286
467
  temporary.cleanup()
@@ -630,6 +811,7 @@ def parser() -> argparse.ArgumentParser:
630
811
  install.add_argument("--source", help="local checkout or Git URL; defaults to MaggieDash repository")
631
812
  install.add_argument("--ref", default="main", help="Git ref when installing from a remote source")
632
813
  install.add_argument("--target", default="_maggie/admin", help="installation target relative to the project")
814
+ install.add_argument("--host", choices=["auto", "astro", "none"], default="auto", help="install the matching non-destructive host scaffold (default: auto-detect Astro)")
633
815
  install.add_argument("--force", action="store_true", help="replace existing dashboard files")
634
816
  install.add_argument("--dry-run", action="store_true", help="show the install plan without writing files")
635
817
  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.38",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",