@amsterdamdatalabs/enact-extensions 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +94 -20
  2. package/dist/index.d.ts +3 -3
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +2 -2
  5. package/dist/index.js.map +1 -1
  6. package/dist/install.d.ts +89 -0
  7. package/dist/install.d.ts.map +1 -1
  8. package/dist/install.js +219 -18
  9. package/dist/install.js.map +1 -1
  10. package/dist/validate/index.d.ts +21 -0
  11. package/dist/validate/index.d.ts.map +1 -1
  12. package/dist/validate/index.js +77 -0
  13. package/dist/validate/index.js.map +1 -1
  14. package/extensions/cmux/.agents/plugin.json +37 -0
  15. package/extensions/cmux/skills/cmux/SKILL.md +82 -0
  16. package/extensions/cmux/skills/cmux/agents/openai.yaml +4 -0
  17. package/extensions/cmux/skills/cmux/references/handles-and-identify.md +35 -0
  18. package/extensions/cmux/skills/cmux/references/panes-surfaces.md +37 -0
  19. package/extensions/cmux/skills/cmux/references/trigger-flash-and-health.md +23 -0
  20. package/extensions/cmux/skills/cmux/references/windows-workspaces.md +31 -0
  21. package/extensions/cmux/skills/cmux-vm-monitor/SKILL.md +122 -0
  22. package/extensions/cmux/skills/cmux-vm-monitor/agents/openai.yaml +4 -0
  23. package/extensions/cmux/skills/cmux-vm-monitor/references/cmux-commands.md +66 -0
  24. package/extensions/cmux/skills/cmux-vm-monitor/scripts/codex_vm_monitor.sh +45 -0
  25. package/extensions/cmux/skills/cmux-workspace/SKILL.md +93 -0
  26. package/extensions/dev-state/.agents/plugin.json +35 -0
  27. package/extensions/dev-state/skills/dev-state-plan-graduation/SKILL.md +194 -0
  28. package/extensions/dev-state/skills/dev-state-plan-graduation/agents/openai.yaml +4 -0
  29. package/extensions/dev-state/skills/dev-state-plan-graduation/references/reference.md +130 -0
  30. package/extensions/devops/.agents/plugin.json +36 -0
  31. package/extensions/devops/skills/azure-devops-cli/SKILL.md +431 -0
  32. package/extensions/devops/skills/azure-devops-cli/agents/openai.yaml +4 -0
  33. package/extensions/devops/skills/ci-pipeline-strategy/SKILL.md +217 -0
  34. package/extensions/devops/skills/ci-pipeline-strategy/agents/openai.yaml +4 -0
  35. package/{plugins/net-revenue-management/.codex-plugin → extensions/net-revenue-management/.agents}/plugin.json +10 -6
  36. package/extensions/plugin-dev/.agents/plugin.json +42 -0
  37. package/extensions/plugin-dev/.mcp.json +3 -0
  38. package/extensions/plugin-dev/agents/agent-creator.md +199 -0
  39. package/extensions/plugin-dev/agents/plugin-validator.md +91 -0
  40. package/extensions/plugin-dev/agents/skill-reviewer.md +212 -0
  41. package/extensions/plugin-dev/commands/_archive/create-marketplace.md +427 -0
  42. package/extensions/plugin-dev/commands/_archive/plugin-dev-guide.md +12 -0
  43. package/extensions/plugin-dev/commands/create-plugin.md +498 -0
  44. package/extensions/plugin-dev/commands/start.md +81 -0
  45. package/extensions/plugin-dev/hooks/hooks.json +3 -0
  46. package/extensions/plugin-dev/skills/agent-development/SKILL.md +641 -0
  47. package/extensions/plugin-dev/skills/agent-development/examples/agent-creation-prompt.md +250 -0
  48. package/extensions/plugin-dev/skills/agent-development/examples/complete-agent-examples.md +461 -0
  49. package/extensions/plugin-dev/skills/agent-development/references/advanced-agent-fields.md +246 -0
  50. package/extensions/plugin-dev/skills/agent-development/references/agent-creation-system-prompt.md +216 -0
  51. package/extensions/plugin-dev/skills/agent-development/references/permission-modes-rules.md +226 -0
  52. package/extensions/plugin-dev/skills/agent-development/references/system-prompt-design.md +464 -0
  53. package/extensions/plugin-dev/skills/agent-development/references/triggering-examples.md +474 -0
  54. package/extensions/plugin-dev/skills/agent-development/scripts/create-agent-skeleton.sh +176 -0
  55. package/extensions/plugin-dev/skills/agent-development/scripts/test-agent-trigger.sh +227 -0
  56. package/extensions/plugin-dev/skills/agent-development/scripts/validate-agent.sh +227 -0
  57. package/extensions/plugin-dev/skills/command-development/SKILL.md +763 -0
  58. package/extensions/plugin-dev/skills/command-development/examples/plugin-commands.md +612 -0
  59. package/extensions/plugin-dev/skills/command-development/examples/simple-commands.md +527 -0
  60. package/extensions/plugin-dev/skills/command-development/references/advanced-workflows.md +762 -0
  61. package/extensions/plugin-dev/skills/command-development/references/documentation-patterns.md +769 -0
  62. package/extensions/plugin-dev/skills/command-development/references/frontmatter-reference.md +508 -0
  63. package/extensions/plugin-dev/skills/command-development/references/interactive-commands.md +966 -0
  64. package/extensions/plugin-dev/skills/command-development/references/marketplace-considerations.md +943 -0
  65. package/extensions/plugin-dev/skills/command-development/references/plugin-features-reference.md +637 -0
  66. package/extensions/plugin-dev/skills/command-development/references/plugin-integration.md +191 -0
  67. package/extensions/plugin-dev/skills/command-development/references/skill-tool.md +447 -0
  68. package/extensions/plugin-dev/skills/command-development/references/testing-strategies.md +723 -0
  69. package/extensions/plugin-dev/skills/command-development/scripts/check-frontmatter.sh +234 -0
  70. package/extensions/plugin-dev/skills/command-development/scripts/validate-command.sh +160 -0
  71. package/extensions/plugin-dev/skills/hook-development/SKILL.md +861 -0
  72. package/extensions/plugin-dev/skills/hook-development/examples/load-context.sh +55 -0
  73. package/extensions/plugin-dev/skills/hook-development/examples/validate-bash.sh +57 -0
  74. package/extensions/plugin-dev/skills/hook-development/examples/validate-write.sh +48 -0
  75. package/extensions/plugin-dev/skills/hook-development/references/advanced.md +871 -0
  76. package/extensions/plugin-dev/skills/hook-development/references/hook-input-schemas.md +145 -0
  77. package/extensions/plugin-dev/skills/hook-development/references/migration.md +392 -0
  78. package/extensions/plugin-dev/skills/hook-development/references/patterns.md +430 -0
  79. package/extensions/plugin-dev/skills/hook-development/scripts/README.md +181 -0
  80. package/extensions/plugin-dev/skills/hook-development/scripts/hook-linter.sh +153 -0
  81. package/extensions/plugin-dev/skills/hook-development/scripts/test-hook.sh +276 -0
  82. package/extensions/plugin-dev/skills/hook-development/scripts/validate-hook-schema.sh +159 -0
  83. package/extensions/plugin-dev/skills/mcp-integration/SKILL.md +775 -0
  84. package/extensions/plugin-dev/skills/mcp-integration/examples/http-server.json +20 -0
  85. package/extensions/plugin-dev/skills/mcp-integration/examples/sse-server.json +19 -0
  86. package/extensions/plugin-dev/skills/mcp-integration/examples/stdio-server.json +38 -0
  87. package/extensions/plugin-dev/skills/mcp-integration/examples/ws-server.json +26 -0
  88. package/extensions/plugin-dev/skills/mcp-integration/references/authentication.md +601 -0
  89. package/extensions/plugin-dev/skills/mcp-integration/references/server-discovery.md +190 -0
  90. package/extensions/plugin-dev/skills/mcp-integration/references/server-types.md +572 -0
  91. package/extensions/plugin-dev/skills/mcp-integration/references/tool-usage.md +623 -0
  92. package/extensions/plugin-dev/skills/plugin-dev-guide/SKILL.md +222 -0
  93. package/extensions/plugin-dev/skills/plugin-structure/SKILL.md +705 -0
  94. package/extensions/plugin-dev/skills/plugin-structure/examples/advanced-plugin.md +774 -0
  95. package/extensions/plugin-dev/skills/plugin-structure/examples/minimal-plugin.md +83 -0
  96. package/extensions/plugin-dev/skills/plugin-structure/examples/standard-plugin.md +611 -0
  97. package/extensions/plugin-dev/skills/plugin-structure/references/advanced-topics.md +289 -0
  98. package/extensions/plugin-dev/skills/plugin-structure/references/component-patterns.md +592 -0
  99. package/extensions/plugin-dev/skills/plugin-structure/references/github-actions.md +233 -0
  100. package/extensions/plugin-dev/skills/plugin-structure/references/headless-ci-mode.md +193 -0
  101. package/extensions/plugin-dev/skills/plugin-structure/references/manifest-reference.md +625 -0
  102. package/extensions/plugin-dev/skills/plugin-structure/references/output-styles.md +116 -0
  103. package/extensions/plugin-dev/skills/skill-development/SKILL.md +564 -0
  104. package/extensions/plugin-dev/skills/skill-development/examples/complete-skill.md +465 -0
  105. package/extensions/plugin-dev/skills/skill-development/examples/frontmatter-templates.md +167 -0
  106. package/extensions/plugin-dev/skills/skill-development/examples/minimal-skill.md +111 -0
  107. package/extensions/plugin-dev/skills/skill-development/references/advanced-frontmatter.md +225 -0
  108. package/extensions/plugin-dev/skills/skill-development/references/commands-vs-skills.md +39 -0
  109. package/extensions/plugin-dev/skills/skill-development/references/skill-creation-workflow.md +379 -0
  110. package/extensions/plugin-dev/skills/skill-development/references/skill-creator-original.md +210 -0
  111. package/package.json +8 -11
  112. package/scripts/enact-extensions.mjs +751 -16
  113. package/scripts/hooks/session-start-drift-check.mjs +58 -0
  114. package/scripts/lib/build-index.mjs +50 -0
  115. package/scripts/lib/bundle-hash.mjs +137 -0
  116. package/scripts/lib/hooks.mjs +389 -0
  117. package/scripts/lib/ledger.mjs +162 -0
  118. package/scripts/lib/list-bundles.mjs +70 -0
  119. package/scripts/lib/outdated.mjs +144 -0
  120. package/scripts/lib/provision-mcp.mjs +369 -0
  121. package/scripts/lib/resolve-bundle.mjs +121 -0
  122. package/scripts/lib/run-install.mjs +321 -39
  123. package/scripts/lib/run-uninstall.mjs +220 -0
  124. package/scripts/lib/run-update.mjs +152 -0
  125. package/scripts/lib/run-validate.mjs +12 -18
  126. package/scripts/lib/serve.mjs +454 -0
  127. package/scripts/postinstall.mjs +63 -0
  128. package/scripts/setup-enact-context.sh +2 -2
  129. package/spec/index.json +59 -0
  130. package/web/assets/README.md +111 -0
  131. package/web/assets/logo-full.png +0 -0
  132. package/web/assets/logo-slim.png +0 -0
  133. package/web/assets/tokens/base.css +45 -0
  134. package/web/assets/tokens/colors.css +248 -0
  135. package/web/assets/tokens/effects.css +24 -0
  136. package/web/assets/tokens/fonts.css +8 -0
  137. package/web/assets/tokens/index.css +18 -0
  138. package/web/assets/tokens/spacing.css +50 -0
  139. package/web/index.html +1188 -0
  140. package/.agents/plugins/marketplace.json +0 -20
  141. package/catalog/enact-context.json +0 -9
  142. package/catalog/enact-factory.json +0 -7
  143. package/catalog/enact-operator.json +0 -7
  144. package/catalog/enact-wiki.json +0 -7
  145. package/catalog/net-revenue-management.json +0 -8
  146. package/scripts/rename-supervisor-to-operator.pl +0 -66
  147. package/scripts/sync-manifests.mjs +0 -23
  148. package/scripts/validate-catalog.mjs +0 -37
  149. package/scripts/validate-plugin.mjs +0 -10
  150. /package/{plugins → extensions}/net-revenue-management/.mcp.json +0 -0
  151. /package/{plugins → extensions}/net-revenue-management/skills/net-revenue-risks/SKILL.md +0 -0
  152. /package/{plugins → extensions}/net-revenue-management/skills/net-revenue-scenario/SKILL.md +0 -0
@@ -0,0 +1,152 @@
1
+ /**
2
+ * run-update.mjs — refresh installed plugins that have drifted from their
3
+ * canonical source bundle.
4
+ *
5
+ * runUpdate({ name?, all?, dryRun?, home?, cwd? }) → summary
6
+ *
7
+ * The update is driven ENTIRELY by the ledger, per (plugin, surface, home):
8
+ *
9
+ * 1. computeOutdated({ home, cwd }) classifies every installed surface as
10
+ * fresh | outdated | orphaned.
11
+ * 2. We keep only the OUTDATED surfaces (fresh needs nothing; orphaned can't be
12
+ * refreshed because the canonical source is gone).
13
+ * 3. Scope the candidate set:
14
+ * --all → every outdated surface
15
+ * <name> → only outdated surfaces for that plugin
16
+ * (neither) → error (don't silently update everything without --all)
17
+ * 4. For each target outdated surface, re-install THAT plugin to THAT ONE surface
18
+ * at THAT recorded home, by calling runInstall(canonicalPath, {
19
+ * platform: entry.platform, scope: entry.scope, <matching *-home>: entry.home,
20
+ * ledgerHome: home, sync: false }).
21
+ * runInstall appends a fresh ledger entry (with the new hash), so the ledger
22
+ * self-updates and a later computeOutdated reports the surface as fresh.
23
+ *
24
+ * CRITICAL: we ONLY touch surfaces the plugin is actually installed on. A plugin
25
+ * installed only on cursor updates ONLY cursor — it is never installed to a
26
+ * surface it isn't already on.
27
+ *
28
+ * --dry-run: report what WOULD be updated and do NOTHING (no install, no ledger
29
+ * writes).
30
+ *
31
+ * Returns a summary:
32
+ * {
33
+ * planned: Entry[], // outdated surfaces in scope (what was / would be targeted)
34
+ * updated: Entry[], // surfaces actually re-installed (empty on dry-run)
35
+ * skipped: Entry[], // orphaned surfaces in scope (can't refresh)
36
+ * failed: { entry, error }[], // surfaces whose re-install threw
37
+ * dryRun: boolean,
38
+ * }
39
+ * where each Entry is the OutdatedEntry { name, platform, scope, home, status, ... }.
40
+ */
41
+
42
+ import { homedir } from "node:os";
43
+ import { cwd as processCwd } from "node:process";
44
+ import { computeOutdated } from "./outdated.mjs";
45
+ import { resolveBundlePath } from "./resolve-bundle.mjs";
46
+ import { runInstall } from "./run-install.mjs";
47
+
48
+ // Map a ledger platform → the runInstall option key that carries its home.
49
+ // (Mirrors the homeField mapping in run-install.mjs PLATFORM_TARGETS; codex has
50
+ // no dedicated *-home table entry there, so it maps to codexHome explicitly.)
51
+ const PLATFORM_HOME_OPTION = {
52
+ codex: "codexHome",
53
+ claude: "claudeHome",
54
+ cursor: "cursorHome",
55
+ enact: "enactHome",
56
+ shared: "sharedHome",
57
+ };
58
+
59
+ /**
60
+ * Resolve the canonical bundle path for a plugin name, or null when the source
61
+ * cannot be found (so an outdated→install attempt degrades to a skip rather than
62
+ * a crash).
63
+ *
64
+ * @param {string} name
65
+ * @param {string} cwd
66
+ * @returns {string|null}
67
+ */
68
+ function safeResolveBundlePath(name, cwd) {
69
+ try {
70
+ return resolveBundlePath(name, { cwd });
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Refresh outdated installed plugin surfaces, driven by the ledger.
78
+ *
79
+ * @param {object} [opts]
80
+ * @param {string} [opts.name] — restrict to a single plugin name.
81
+ * @param {boolean} [opts.all] — update every outdated surface.
82
+ * @param {boolean} [opts.dryRun]— report only; make no changes.
83
+ * @param {string} [opts.home] — ledger home override (defaults to os.homedir()).
84
+ * @param {string} [opts.cwd] — working dir for canonical bundle resolution.
85
+ * @returns {{ planned: object[], updated: object[], skipped: object[], failed: {entry: object, error: Error}[], dryRun: boolean }}
86
+ */
87
+ export function runUpdate({ name, all, dryRun, home, cwd } = {}) {
88
+ const resolvedHome = home ?? homedir();
89
+ const resolvedCwd = cwd ?? processCwd();
90
+
91
+ // Scope guard: require an explicit target. Never silently update everything.
92
+ if (!all && !name) {
93
+ throw new Error(
94
+ "update requires a plugin name or --all. " +
95
+ 'Use "update <name>" to refresh one plugin, or "update --all" to refresh every outdated plugin.',
96
+ );
97
+ }
98
+
99
+ // 1. Compute freshness across every installed surface.
100
+ const outdatedReport = computeOutdated({ home: resolvedHome, cwd: resolvedCwd });
101
+
102
+ // 2. Restrict to the requested plugin (when a name is given).
103
+ const inScope = name
104
+ ? outdatedReport.filter((e) => e.name === name)
105
+ : outdatedReport;
106
+
107
+ // 3. Split into actionable (outdated) vs orphaned (skip). fresh is ignored.
108
+ /** @type {object[]} */
109
+ const planned = inScope.filter((e) => e.status === "outdated");
110
+ /** @type {object[]} */
111
+ const skipped = inScope.filter((e) => e.status === "orphaned");
112
+
113
+ /** @type {object[]} */
114
+ const updated = [];
115
+ /** @type {{ entry: object, error: Error }[]} */
116
+ const failed = [];
117
+
118
+ // Dry-run: report the plan, change nothing.
119
+ if (dryRun) {
120
+ return { planned, updated, skipped, failed, dryRun: true };
121
+ }
122
+
123
+ // 4. Re-install each outdated surface to its EXACT recorded platform + home.
124
+ for (const entry of planned) {
125
+ const canonicalPath = safeResolveBundlePath(entry.name, resolvedCwd);
126
+ if (!canonicalPath) {
127
+ // Source vanished between the freshness scan and now → treat as orphaned skip.
128
+ skipped.push({ ...entry, status: "orphaned" });
129
+ continue;
130
+ }
131
+
132
+ const homeOption = PLATFORM_HOME_OPTION[entry.platform] ?? "codexHome";
133
+ try {
134
+ runInstall(canonicalPath, {
135
+ platform: entry.platform,
136
+ scope: entry.scope,
137
+ [homeOption]: entry.home,
138
+ ledgerHome: resolvedHome,
139
+ // Refresh the already-installed surface only; do not regenerate source
140
+ // manifests, and skip MCP provisioning (a refresh re-installs identical
141
+ // package deps — provisioning is best-effort and not needed here).
142
+ sync: false,
143
+ noProvision: true,
144
+ });
145
+ updated.push(entry);
146
+ } catch (err) {
147
+ failed.push({ entry, error: err instanceof Error ? err : new Error(String(err)) });
148
+ }
149
+ }
150
+
151
+ return { planned, updated, skipped, failed, dryRun: false };
152
+ }
@@ -1,20 +1,18 @@
1
1
  import {
2
- validatePluginBundle,
3
- checkComponentPaths,
4
- checkPluginBundleComponentPaths,
5
- readManifestFile,
2
+ validatePluginBundleFromCanonical,
3
+ checkPluginBundleComponentPathsFromCanonical,
6
4
  } from "../../dist/index.js";
7
5
 
6
+ /**
7
+ * Validate a plugin bundle using IN-MEMORY manifest derivation from the
8
+ * canonical `.agents/plugin.json`. No source `.<platform>-plugin/` dirs are
9
+ * read or created.
10
+ *
11
+ * For backward-compatibility, `platforms` defaults to all four surfaces.
12
+ */
8
13
  export function runValidate(pluginRoot, platforms) {
9
- const report = validatePluginBundle(pluginRoot, platforms);
10
- let warnings = [];
11
-
12
- try {
13
- const enact = readManifestFile(pluginRoot, "enact");
14
- warnings = checkComponentPaths(pluginRoot, enact);
15
- } catch {
16
- // enact manifest optional for partial bundles
17
- }
14
+ const report = validatePluginBundleFromCanonical(pluginRoot, platforms);
15
+ const warnings = checkPluginBundleComponentPathsFromCanonical(pluginRoot);
18
16
 
19
17
  for (const result of report.results) {
20
18
  const status = result.ok ? "ok" : "FAIL";
@@ -27,10 +25,6 @@ export function runValidate(pluginRoot, platforms) {
27
25
  for (const warn of warnings) {
28
26
  console.log(`[warn] ${warn}`);
29
27
  }
30
- const componentIssues = checkPluginBundleComponentPaths(pluginRoot, platforms);
31
- for (const issue of componentIssues) {
32
- console.log(`[FAIL] ${issue}`);
33
- }
34
28
 
35
- return report.ok && componentIssues.length === 0;
29
+ return report.ok && warnings.length === 0;
36
30
  }
@@ -0,0 +1,454 @@
1
+ /**
2
+ * serve.mjs — dependency-free localhost API + static-file server for enact-extensions.
3
+ *
4
+ * Security model:
5
+ * - Binds to 127.0.0.1 by default (localhost-only). Never 0.0.0.0 by default.
6
+ * An explicit --host flag may override, but the caller is responsible for the
7
+ * risk; the docs (and help text) note that binding to a non-loopback address
8
+ * exposes the API to other machines on the network.
9
+ * - Static files are restricted to web/assets/ via path normalisation. Any
10
+ * request that resolves outside that directory is rejected with 400.
11
+ * - User input (name/platform/scope) is validated against an allowlist and
12
+ * passed to JS library functions directly — never to a shell.
13
+ *
14
+ * Exports:
15
+ * createServer(opts) → http.Server (does NOT auto-listen)
16
+ * startServer(opts) → Promise<{ server, url }> (listens, returns url string)
17
+ *
18
+ * Routes:
19
+ * GET / → web/index.html if present, else placeholder HTML
20
+ * GET /assets/* → web/assets/<path> (path-traversal guarded)
21
+ * GET /api/index → buildIndex() JSON
22
+ * GET /api/installed → { "<name>": [{platform,scope,home,path},...] }
23
+ * POST /api/install → { name, platform, scope } → runInstall
24
+ * POST /api/uninstall → { name, platform, scope } → runUninstall
25
+ * * unknown → 404
26
+ * * wrong method → 405 (for known routes with wrong method)
27
+ */
28
+
29
+ import http from "node:http";
30
+ import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
31
+ import { dirname, join, normalize, resolve } from "node:path";
32
+ import { fileURLToPath } from "node:url";
33
+
34
+ import { buildIndex } from "./build-index.mjs";
35
+ import { findInstalled } from "./ledger.mjs";
36
+ import { listBundles } from "./list-bundles.mjs";
37
+ import { resolveBundlePath } from "./resolve-bundle.mjs";
38
+ import { runInstall } from "./run-install.mjs";
39
+ import { runUninstall } from "./run-uninstall.mjs";
40
+
41
+ const __dirname = dirname(fileURLToPath(import.meta.url));
42
+
43
+ /**
44
+ * Resolve the package root (two levels up from scripts/lib/).
45
+ * Can be overridden via opts.packageRoot for tests.
46
+ */
47
+ const DEFAULT_PACKAGE_ROOT = resolve(__dirname, "..", "..");
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Validation constants
51
+ // ---------------------------------------------------------------------------
52
+
53
+ const VALID_PLATFORMS = new Set(["codex", "claude", "cursor", "enact", "shared", "all"]);
54
+ const VALID_SCOPES = new Set(["global", "local"]);
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Content-type helpers
58
+ // ---------------------------------------------------------------------------
59
+
60
+ const MIME_TYPES = {
61
+ ".html": "text/html; charset=utf-8",
62
+ ".css": "text/css; charset=utf-8",
63
+ ".js": "text/javascript; charset=utf-8",
64
+ ".mjs": "text/javascript; charset=utf-8",
65
+ ".json": "application/json; charset=utf-8",
66
+ ".png": "image/png",
67
+ ".jpg": "image/jpeg",
68
+ ".jpeg": "image/jpeg",
69
+ ".gif": "image/gif",
70
+ ".svg": "image/svg+xml",
71
+ ".ico": "image/x-icon",
72
+ ".woff": "font/woff",
73
+ ".woff2": "font/woff2",
74
+ ".ttf": "font/ttf",
75
+ };
76
+
77
+ function mimeForPath(filePath) {
78
+ const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
79
+ return MIME_TYPES[ext] ?? "application/octet-stream";
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Response helpers
84
+ // ---------------------------------------------------------------------------
85
+
86
+ function sendJson(res, status, body) {
87
+ const json = JSON.stringify(body, null, 2);
88
+ res.writeHead(status, {
89
+ "Content-Type": "application/json; charset=utf-8",
90
+ "Content-Length": Buffer.byteLength(json),
91
+ });
92
+ res.end(json);
93
+ }
94
+
95
+ function sendHtml(res, status, html) {
96
+ res.writeHead(status, {
97
+ "Content-Type": "text/html; charset=utf-8",
98
+ "Content-Length": Buffer.byteLength(html),
99
+ });
100
+ res.end(html);
101
+ }
102
+
103
+ function sendError(res, status, message) {
104
+ sendJson(res, status, { ok: false, error: message });
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Placeholder HTML (served when web/index.html is absent)
109
+ // ---------------------------------------------------------------------------
110
+
111
+ function placeholderHtml() {
112
+ return `<!DOCTYPE html>
113
+ <html lang="en">
114
+ <head>
115
+ <meta charset="UTF-8" />
116
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
117
+ <title>enact-extensions</title>
118
+ <link rel="stylesheet" href="/assets/tokens/colors.css" />
119
+ <link rel="stylesheet" href="/assets/tokens/base.css" />
120
+ <style>
121
+ body { font-family: system-ui, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
122
+ pre { background: #f4f4f4; padding: 1rem; border-radius: 4px; overflow: auto; }
123
+ h1 { color: var(--adl-teal-600, #147f84); }
124
+ </style>
125
+ </head>
126
+ <body>
127
+ <h1>enact-extensions</h1>
128
+ <p>The UI is loading. Discovery index:</p>
129
+ <pre id="index-output">Loading <a href="/api/index">/api/index</a>…</pre>
130
+ <script type="module">
131
+ const res = await fetch('/api/index');
132
+ const data = await res.json();
133
+ document.getElementById('index-output').textContent = JSON.stringify(data, null, 2);
134
+ </script>
135
+ </body>
136
+ </html>`;
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Parse request body (JSON)
141
+ // ---------------------------------------------------------------------------
142
+
143
+ const MAX_BODY_BYTES = 64 * 1024; // 64 KiB — more than enough for name+platform+scope
144
+
145
+ function readJsonBody(req) {
146
+ return new Promise((resolve, reject) => {
147
+ const chunks = [];
148
+ let totalBytes = 0;
149
+ req.on("data", (c) => {
150
+ totalBytes += c.length;
151
+ if (totalBytes > MAX_BODY_BYTES) {
152
+ req.destroy();
153
+ reject(new Error("Request body too large"));
154
+ return;
155
+ }
156
+ chunks.push(c);
157
+ });
158
+ req.on("end", () => {
159
+ const raw = Buffer.concat(chunks).toString("utf8");
160
+ if (!raw.trim()) {
161
+ resolve({});
162
+ return;
163
+ }
164
+ try {
165
+ resolve(JSON.parse(raw));
166
+ } catch {
167
+ reject(new Error("Invalid JSON body"));
168
+ }
169
+ });
170
+ req.on("error", reject);
171
+ });
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Static asset handler — guard against path traversal
176
+ // ---------------------------------------------------------------------------
177
+
178
+ function serveAsset(_req, res, assetsRoot, urlPath) {
179
+ // Decode URI, then normalise to strip ../ sequences
180
+ let relPath;
181
+ try {
182
+ relPath = decodeURIComponent(urlPath.slice("/assets".length));
183
+ } catch {
184
+ return sendError(res, 400, "Malformed URL");
185
+ }
186
+
187
+ // Normalise and ensure it stays within assetsRoot
188
+ const resolved = resolve(assetsRoot, "." + normalize(relPath));
189
+ if (!resolved.startsWith(assetsRoot + "/") && resolved !== assetsRoot) {
190
+ return sendError(res, 400, "Path traversal detected");
191
+ }
192
+
193
+ if (!existsSync(resolved)) {
194
+ return sendError(res, 404, `Asset not found: ${urlPath}`);
195
+ }
196
+
197
+ const mime = mimeForPath(resolved);
198
+ let stat;
199
+ try {
200
+ stat = statSync(resolved);
201
+ } catch {
202
+ return sendError(res, 500, "Failed to stat asset");
203
+ }
204
+
205
+ res.writeHead(200, {
206
+ "Content-Type": mime,
207
+ "Content-Length": stat.size,
208
+ });
209
+
210
+ const stream = createReadStream(resolved);
211
+ stream.pipe(res);
212
+ stream.on("error", () => {
213
+ if (!res.headersSent) {
214
+ sendError(res, 500, "Failed to read asset");
215
+ } else {
216
+ res.destroy();
217
+ }
218
+ });
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // createServer — build the http.Server (does NOT start listening)
223
+ // ---------------------------------------------------------------------------
224
+
225
+ /**
226
+ * @param {object} opts
227
+ * @param {string} [opts.packageRoot] - Package root for locating web/ and extensions/.
228
+ * @param {object} [opts.installDefaults] - Option bag merged into every runInstall/runUninstall call.
229
+ * Use this in tests to redirect ledgerHome, sharedHome, etc. to temp dirs.
230
+ * @returns {http.Server}
231
+ */
232
+ export function createServer(opts = {}) {
233
+ const packageRoot = opts.packageRoot ?? DEFAULT_PACKAGE_ROOT;
234
+ const installDefaults = opts.installDefaults ?? {};
235
+
236
+ const webRoot = join(packageRoot, "web");
237
+ const assetsRoot = join(webRoot, "assets");
238
+ const indexHtmlPath = join(webRoot, "index.html");
239
+ const extensionsRoot = join(packageRoot, "extensions");
240
+
241
+ const server = http.createServer(async (req, res) => {
242
+ const url = req.url ?? "/";
243
+ const method = (req.method ?? "GET").toUpperCase();
244
+
245
+ // ---- GET / ----------------------------------------------------------
246
+ if (url === "/" || url === "") {
247
+ if (method !== "GET" && method !== "HEAD") {
248
+ return sendJson(res, 405, { ok: false, error: "Method not allowed" });
249
+ }
250
+ if (existsSync(indexHtmlPath)) {
251
+ const html = readFileSync(indexHtmlPath, "utf8");
252
+ return sendHtml(res, 200, html);
253
+ }
254
+ return sendHtml(res, 200, placeholderHtml());
255
+ }
256
+
257
+ // ---- GET /assets/* --------------------------------------------------
258
+ if (url.startsWith("/assets/") || url === "/assets") {
259
+ if (method !== "GET" && method !== "HEAD") {
260
+ return sendJson(res, 405, { ok: false, error: "Method not allowed" });
261
+ }
262
+ return serveAsset(req, res, assetsRoot, url);
263
+ }
264
+
265
+ // ---- GET /api/index -------------------------------------------------
266
+ if (url === "/api/index") {
267
+ if (method !== "GET" && method !== "HEAD") {
268
+ return sendJson(res, 405, { ok: false, error: "Method not allowed" });
269
+ }
270
+ try {
271
+ const index = buildIndex([extensionsRoot], {
272
+ now: new Date().toISOString(),
273
+ packageRoot,
274
+ });
275
+ return sendJson(res, 200, index);
276
+ } catch (err) {
277
+ return sendError(res, 500, err instanceof Error ? err.message : String(err));
278
+ }
279
+ }
280
+
281
+ // ---- GET /api/installed ---------------------------------------------
282
+ if (url === "/api/installed") {
283
+ if (method !== "GET" && method !== "HEAD") {
284
+ return sendJson(res, 405, { ok: false, error: "Method not allowed" });
285
+ }
286
+ try {
287
+ const bundles = listBundles([extensionsRoot]);
288
+ const ledgerOpts = installDefaults.ledgerHome
289
+ ? { home: installDefaults.ledgerHome }
290
+ : {};
291
+ const result = {};
292
+ for (const bundle of bundles) {
293
+ const entries = findInstalled(bundle.name, ledgerOpts);
294
+ if (entries.length > 0) {
295
+ result[bundle.name] = entries;
296
+ }
297
+ }
298
+ return sendJson(res, 200, result);
299
+ } catch (err) {
300
+ return sendError(res, 500, err instanceof Error ? err.message : String(err));
301
+ }
302
+ }
303
+
304
+ // ---- POST /api/install ----------------------------------------------
305
+ if (url === "/api/install") {
306
+ if (method !== "POST") {
307
+ return sendJson(res, 405, { ok: false, error: "Method not allowed" });
308
+ }
309
+ let body;
310
+ try {
311
+ body = await readJsonBody(req);
312
+ } catch {
313
+ return sendError(res, 400, "Invalid JSON body");
314
+ }
315
+
316
+ const { name, platform, scope, noProvision } = body;
317
+
318
+ // Validate inputs
319
+ if (!name || typeof name !== "string") {
320
+ return sendError(res, 400, "Missing required field: name");
321
+ }
322
+ if (platform && !VALID_PLATFORMS.has(platform)) {
323
+ return sendError(
324
+ res,
325
+ 400,
326
+ `Unknown platform: "${platform}". Valid: codex, claude, cursor, enact, shared, all`,
327
+ );
328
+ }
329
+ if (scope && !VALID_SCOPES.has(scope)) {
330
+ return sendError(res, 400, `Unknown scope: "${scope}". Valid: global, local`);
331
+ }
332
+
333
+ // Resolve bundle path
334
+ let bundlePath;
335
+ try {
336
+ bundlePath = resolveBundlePath(name, { cwd: packageRoot });
337
+ } catch (err) {
338
+ return sendError(res, 400, err instanceof Error ? err.message : String(err));
339
+ }
340
+
341
+ // Run install — MCP dependency provisioning is ON by default; the client
342
+ // may opt out with { noProvision: true }.
343
+ try {
344
+ const runOpts = {
345
+ platform: platform ?? "codex",
346
+ scope: scope ?? "global",
347
+ sync: false,
348
+ noProvision: noProvision === true,
349
+ ...installDefaults,
350
+ };
351
+ const result = runInstall(bundlePath, runOpts);
352
+ // Surface the provision results so the UI can report them. The result's
353
+ // `provision` array is attached by runInstall (single + multi paths).
354
+ const provision = Array.isArray(result.provision) ? result.provision : [];
355
+ return sendJson(res, 200, { ok: true, result, provision });
356
+ } catch (err) {
357
+ return sendError(res, 500, err instanceof Error ? err.message : String(err));
358
+ }
359
+ }
360
+
361
+ // ---- POST /api/uninstall --------------------------------------------
362
+ if (url === "/api/uninstall") {
363
+ if (method !== "POST") {
364
+ return sendJson(res, 405, { ok: false, error: "Method not allowed" });
365
+ }
366
+ let body;
367
+ try {
368
+ body = await readJsonBody(req);
369
+ } catch {
370
+ return sendError(res, 400, "Invalid JSON body");
371
+ }
372
+
373
+ const { name, platform, scope } = body;
374
+
375
+ // Validate inputs
376
+ if (!name || typeof name !== "string") {
377
+ return sendError(res, 400, "Missing required field: name");
378
+ }
379
+ if (platform && !VALID_PLATFORMS.has(platform)) {
380
+ return sendError(
381
+ res,
382
+ 400,
383
+ `Unknown platform: "${platform}". Valid: codex, claude, cursor, enact, shared, all`,
384
+ );
385
+ }
386
+ if (scope && !VALID_SCOPES.has(scope)) {
387
+ return sendError(res, 400, `Unknown scope: "${scope}". Valid: global, local`);
388
+ }
389
+
390
+ // Resolve bundle path to get the canonical plugin name
391
+ // (We pass the name string directly to runUninstall since it accepts plugin names)
392
+ // First verify the plugin name is known
393
+ try {
394
+ resolveBundlePath(name, { cwd: packageRoot });
395
+ } catch (err) {
396
+ return sendError(res, 400, err instanceof Error ? err.message : String(err));
397
+ }
398
+
399
+ // Run uninstall
400
+ try {
401
+ const runOpts = {
402
+ platform: platform ?? "codex",
403
+ scope: scope ?? "global",
404
+ sync: false,
405
+ ...installDefaults,
406
+ };
407
+ const result = runUninstall(name, runOpts);
408
+ return sendJson(res, 200, { ok: true, result });
409
+ } catch (err) {
410
+ return sendError(res, 500, err instanceof Error ? err.message : String(err));
411
+ }
412
+ }
413
+
414
+ // ---- 404 for everything else ----------------------------------------
415
+ return sendError(res, 404, `Not found: ${url}`);
416
+ });
417
+
418
+ return server;
419
+ }
420
+
421
+ // ---------------------------------------------------------------------------
422
+ // startServer — creates + listens; returns { server, url }
423
+ // ---------------------------------------------------------------------------
424
+
425
+ /**
426
+ * Start the server and return the URL.
427
+ *
428
+ * Security note: host defaults to '127.0.0.1' (loopback only). Passing any
429
+ * other value via --host exposes the API to other machines on the network —
430
+ * document this risk to operators.
431
+ *
432
+ * @param {object} opts
433
+ * @param {number} [opts.port=43217] - Port to listen on. Pass 0 for ephemeral.
434
+ * @param {string} [opts.host='127.0.0.1'] - Bind address. MUST be 127.0.0.1 by default.
435
+ * @param {string} [opts.packageRoot] - Package root override.
436
+ * @param {object} [opts.installDefaults] - Merged into runInstall/runUninstall calls.
437
+ * @returns {Promise<{ server: http.Server, url: string }>}
438
+ */
439
+ export function startServer(opts = {}) {
440
+ const host = opts.host ?? "127.0.0.1";
441
+ const port = opts.port ?? 43217;
442
+
443
+ const server = createServer(opts);
444
+
445
+ return new Promise((resolve, reject) => {
446
+ server.on("error", reject);
447
+ server.listen(port, host, () => {
448
+ const addr = server.address();
449
+ const actualPort = typeof addr === "object" && addr ? addr.port : port;
450
+ const url = `http://${host}:${actualPort}`;
451
+ resolve({ server, url });
452
+ });
453
+ });
454
+ }