@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.
- package/README.md +94 -20
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/install.d.ts +89 -0
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +219 -18
- package/dist/install.js.map +1 -1
- package/dist/validate/index.d.ts +21 -0
- package/dist/validate/index.d.ts.map +1 -1
- package/dist/validate/index.js +77 -0
- package/dist/validate/index.js.map +1 -1
- package/extensions/cmux/.agents/plugin.json +37 -0
- package/extensions/cmux/skills/cmux/SKILL.md +82 -0
- package/extensions/cmux/skills/cmux/agents/openai.yaml +4 -0
- package/extensions/cmux/skills/cmux/references/handles-and-identify.md +35 -0
- package/extensions/cmux/skills/cmux/references/panes-surfaces.md +37 -0
- package/extensions/cmux/skills/cmux/references/trigger-flash-and-health.md +23 -0
- package/extensions/cmux/skills/cmux/references/windows-workspaces.md +31 -0
- package/extensions/cmux/skills/cmux-vm-monitor/SKILL.md +122 -0
- package/extensions/cmux/skills/cmux-vm-monitor/agents/openai.yaml +4 -0
- package/extensions/cmux/skills/cmux-vm-monitor/references/cmux-commands.md +66 -0
- package/extensions/cmux/skills/cmux-vm-monitor/scripts/codex_vm_monitor.sh +45 -0
- package/extensions/cmux/skills/cmux-workspace/SKILL.md +93 -0
- package/extensions/dev-state/.agents/plugin.json +35 -0
- package/extensions/dev-state/skills/dev-state-plan-graduation/SKILL.md +194 -0
- package/extensions/dev-state/skills/dev-state-plan-graduation/agents/openai.yaml +4 -0
- package/extensions/dev-state/skills/dev-state-plan-graduation/references/reference.md +130 -0
- package/extensions/devops/.agents/plugin.json +36 -0
- package/extensions/devops/skills/azure-devops-cli/SKILL.md +431 -0
- package/extensions/devops/skills/azure-devops-cli/agents/openai.yaml +4 -0
- package/extensions/devops/skills/ci-pipeline-strategy/SKILL.md +217 -0
- package/extensions/devops/skills/ci-pipeline-strategy/agents/openai.yaml +4 -0
- package/{plugins/net-revenue-management/.codex-plugin → extensions/net-revenue-management/.agents}/plugin.json +10 -6
- package/extensions/plugin-dev/.agents/plugin.json +42 -0
- package/extensions/plugin-dev/.mcp.json +3 -0
- package/extensions/plugin-dev/agents/agent-creator.md +199 -0
- package/extensions/plugin-dev/agents/plugin-validator.md +91 -0
- package/extensions/plugin-dev/agents/skill-reviewer.md +212 -0
- package/extensions/plugin-dev/commands/_archive/create-marketplace.md +427 -0
- package/extensions/plugin-dev/commands/_archive/plugin-dev-guide.md +12 -0
- package/extensions/plugin-dev/commands/create-plugin.md +498 -0
- package/extensions/plugin-dev/commands/start.md +81 -0
- package/extensions/plugin-dev/hooks/hooks.json +3 -0
- package/extensions/plugin-dev/skills/agent-development/SKILL.md +641 -0
- package/extensions/plugin-dev/skills/agent-development/examples/agent-creation-prompt.md +250 -0
- package/extensions/plugin-dev/skills/agent-development/examples/complete-agent-examples.md +461 -0
- package/extensions/plugin-dev/skills/agent-development/references/advanced-agent-fields.md +246 -0
- package/extensions/plugin-dev/skills/agent-development/references/agent-creation-system-prompt.md +216 -0
- package/extensions/plugin-dev/skills/agent-development/references/permission-modes-rules.md +226 -0
- package/extensions/plugin-dev/skills/agent-development/references/system-prompt-design.md +464 -0
- package/extensions/plugin-dev/skills/agent-development/references/triggering-examples.md +474 -0
- package/extensions/plugin-dev/skills/agent-development/scripts/create-agent-skeleton.sh +176 -0
- package/extensions/plugin-dev/skills/agent-development/scripts/test-agent-trigger.sh +227 -0
- package/extensions/plugin-dev/skills/agent-development/scripts/validate-agent.sh +227 -0
- package/extensions/plugin-dev/skills/command-development/SKILL.md +763 -0
- package/extensions/plugin-dev/skills/command-development/examples/plugin-commands.md +612 -0
- package/extensions/plugin-dev/skills/command-development/examples/simple-commands.md +527 -0
- package/extensions/plugin-dev/skills/command-development/references/advanced-workflows.md +762 -0
- package/extensions/plugin-dev/skills/command-development/references/documentation-patterns.md +769 -0
- package/extensions/plugin-dev/skills/command-development/references/frontmatter-reference.md +508 -0
- package/extensions/plugin-dev/skills/command-development/references/interactive-commands.md +966 -0
- package/extensions/plugin-dev/skills/command-development/references/marketplace-considerations.md +943 -0
- package/extensions/plugin-dev/skills/command-development/references/plugin-features-reference.md +637 -0
- package/extensions/plugin-dev/skills/command-development/references/plugin-integration.md +191 -0
- package/extensions/plugin-dev/skills/command-development/references/skill-tool.md +447 -0
- package/extensions/plugin-dev/skills/command-development/references/testing-strategies.md +723 -0
- package/extensions/plugin-dev/skills/command-development/scripts/check-frontmatter.sh +234 -0
- package/extensions/plugin-dev/skills/command-development/scripts/validate-command.sh +160 -0
- package/extensions/plugin-dev/skills/hook-development/SKILL.md +861 -0
- package/extensions/plugin-dev/skills/hook-development/examples/load-context.sh +55 -0
- package/extensions/plugin-dev/skills/hook-development/examples/validate-bash.sh +57 -0
- package/extensions/plugin-dev/skills/hook-development/examples/validate-write.sh +48 -0
- package/extensions/plugin-dev/skills/hook-development/references/advanced.md +871 -0
- package/extensions/plugin-dev/skills/hook-development/references/hook-input-schemas.md +145 -0
- package/extensions/plugin-dev/skills/hook-development/references/migration.md +392 -0
- package/extensions/plugin-dev/skills/hook-development/references/patterns.md +430 -0
- package/extensions/plugin-dev/skills/hook-development/scripts/README.md +181 -0
- package/extensions/plugin-dev/skills/hook-development/scripts/hook-linter.sh +153 -0
- package/extensions/plugin-dev/skills/hook-development/scripts/test-hook.sh +276 -0
- package/extensions/plugin-dev/skills/hook-development/scripts/validate-hook-schema.sh +159 -0
- package/extensions/plugin-dev/skills/mcp-integration/SKILL.md +775 -0
- package/extensions/plugin-dev/skills/mcp-integration/examples/http-server.json +20 -0
- package/extensions/plugin-dev/skills/mcp-integration/examples/sse-server.json +19 -0
- package/extensions/plugin-dev/skills/mcp-integration/examples/stdio-server.json +38 -0
- package/extensions/plugin-dev/skills/mcp-integration/examples/ws-server.json +26 -0
- package/extensions/plugin-dev/skills/mcp-integration/references/authentication.md +601 -0
- package/extensions/plugin-dev/skills/mcp-integration/references/server-discovery.md +190 -0
- package/extensions/plugin-dev/skills/mcp-integration/references/server-types.md +572 -0
- package/extensions/plugin-dev/skills/mcp-integration/references/tool-usage.md +623 -0
- package/extensions/plugin-dev/skills/plugin-dev-guide/SKILL.md +222 -0
- package/extensions/plugin-dev/skills/plugin-structure/SKILL.md +705 -0
- package/extensions/plugin-dev/skills/plugin-structure/examples/advanced-plugin.md +774 -0
- package/extensions/plugin-dev/skills/plugin-structure/examples/minimal-plugin.md +83 -0
- package/extensions/plugin-dev/skills/plugin-structure/examples/standard-plugin.md +611 -0
- package/extensions/plugin-dev/skills/plugin-structure/references/advanced-topics.md +289 -0
- package/extensions/plugin-dev/skills/plugin-structure/references/component-patterns.md +592 -0
- package/extensions/plugin-dev/skills/plugin-structure/references/github-actions.md +233 -0
- package/extensions/plugin-dev/skills/plugin-structure/references/headless-ci-mode.md +193 -0
- package/extensions/plugin-dev/skills/plugin-structure/references/manifest-reference.md +625 -0
- package/extensions/plugin-dev/skills/plugin-structure/references/output-styles.md +116 -0
- package/extensions/plugin-dev/skills/skill-development/SKILL.md +564 -0
- package/extensions/plugin-dev/skills/skill-development/examples/complete-skill.md +465 -0
- package/extensions/plugin-dev/skills/skill-development/examples/frontmatter-templates.md +167 -0
- package/extensions/plugin-dev/skills/skill-development/examples/minimal-skill.md +111 -0
- package/extensions/plugin-dev/skills/skill-development/references/advanced-frontmatter.md +225 -0
- package/extensions/plugin-dev/skills/skill-development/references/commands-vs-skills.md +39 -0
- package/extensions/plugin-dev/skills/skill-development/references/skill-creation-workflow.md +379 -0
- package/extensions/plugin-dev/skills/skill-development/references/skill-creator-original.md +210 -0
- package/package.json +8 -11
- package/scripts/enact-extensions.mjs +751 -16
- package/scripts/hooks/session-start-drift-check.mjs +58 -0
- package/scripts/lib/build-index.mjs +50 -0
- package/scripts/lib/bundle-hash.mjs +137 -0
- package/scripts/lib/hooks.mjs +389 -0
- package/scripts/lib/ledger.mjs +162 -0
- package/scripts/lib/list-bundles.mjs +70 -0
- package/scripts/lib/outdated.mjs +144 -0
- package/scripts/lib/provision-mcp.mjs +369 -0
- package/scripts/lib/resolve-bundle.mjs +121 -0
- package/scripts/lib/run-install.mjs +321 -39
- package/scripts/lib/run-uninstall.mjs +220 -0
- package/scripts/lib/run-update.mjs +152 -0
- package/scripts/lib/run-validate.mjs +12 -18
- package/scripts/lib/serve.mjs +454 -0
- package/scripts/postinstall.mjs +63 -0
- package/scripts/setup-enact-context.sh +2 -2
- package/spec/index.json +59 -0
- package/web/assets/README.md +111 -0
- package/web/assets/logo-full.png +0 -0
- package/web/assets/logo-slim.png +0 -0
- package/web/assets/tokens/base.css +45 -0
- package/web/assets/tokens/colors.css +248 -0
- package/web/assets/tokens/effects.css +24 -0
- package/web/assets/tokens/fonts.css +8 -0
- package/web/assets/tokens/index.css +18 -0
- package/web/assets/tokens/spacing.css +50 -0
- package/web/index.html +1188 -0
- package/.agents/plugins/marketplace.json +0 -20
- package/catalog/enact-context.json +0 -9
- package/catalog/enact-factory.json +0 -7
- package/catalog/enact-operator.json +0 -7
- package/catalog/enact-wiki.json +0 -7
- package/catalog/net-revenue-management.json +0 -8
- package/scripts/rename-supervisor-to-operator.pl +0 -66
- package/scripts/sync-manifests.mjs +0 -23
- package/scripts/validate-catalog.mjs +0 -37
- package/scripts/validate-plugin.mjs +0 -10
- /package/{plugins → extensions}/net-revenue-management/.mcp.json +0 -0
- /package/{plugins → extensions}/net-revenue-management/skills/net-revenue-risks/SKILL.md +0 -0
- /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
|
-
|
|
3
|
-
|
|
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 =
|
|
10
|
-
|
|
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 &&
|
|
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
|
+
}
|