@mh-gg/app-runtime 0.1.1-alpha.20260627T001620833Z

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matterhorn contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # @mh-gg/app-runtime
2
+
3
+ Runtime helpers used by Matterhorn host and relay packages to load app descriptors and run frontend bundle commands.
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@mh-gg/app-runtime",
3
+ "version": "0.1.1-alpha.20260627T001620833Z",
4
+ "description": "Matterhorn app runtime helpers shared by host and relay runtime packages.",
5
+ "type": "commonjs",
6
+ "main": "runtime/matterhornAppLoader.cjs",
7
+ "exports": {
8
+ ".": "./runtime/matterhornAppLoader.cjs",
9
+ "./app-loader": "./runtime/matterhornAppLoader.cjs",
10
+ "./app-ref-policy": "./runtime/appRefPolicy.cjs",
11
+ "./frontend/artifacts": "./runtime/appFrontend/artifacts.cjs",
12
+ "./frontend/command-env": "./runtime/appFrontend/commandEnv.cjs",
13
+ "./frontend/command-policy": "./runtime/appFrontend/commandPolicy.cjs",
14
+ "./frontend/processes": "./runtime/appFrontend/processes.cjs",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "dependencies": {
18
+ "@mh-gg/base": "^0.1.1-alpha.20260627T001620833Z",
19
+ "@mh-gg/base-plugins": "^0.1.1-alpha.20260627T001620833Z",
20
+ "@mh-gg/schema": "^0.1.1-alpha.20260627T001620833Z"
21
+ },
22
+ "engines": {
23
+ "node": ">=22.12"
24
+ },
25
+ "license": "MIT",
26
+ "files": [
27
+ "runtime",
28
+ "scripts",
29
+ "README.md",
30
+ "package.json"
31
+ ],
32
+ "scripts": {
33
+ "test": "node --test test/*.test.cjs",
34
+ "coverage": "node --test --experimental-test-coverage --test-coverage-lines=80 --test-coverage-functions=80 --test-coverage-branches=60 --test-coverage-include=runtime/appFrontend/artifacts.cjs --test-coverage-include=runtime/appFrontend/commandEnv.cjs --test-coverage-include=runtime/appFrontend/commandPolicy.cjs --test-coverage-include=runtime/appFrontend/processes.cjs test/*.test.cjs"
35
+ }
36
+ }
@@ -0,0 +1,101 @@
1
+ const fs = require("node:fs");
2
+ const { createHash } = require("node:crypto");
3
+ const path = require("node:path");
4
+ const { hashCanonical, validateFrontendChunkManifest } = require("@mh-gg/base");
5
+ const { bundleDistDir, bundleEntryFile, bundleEntryName, bundleManifestFile, bundleMountPath } = require("./paths.cjs");
6
+
7
+ const DOCUMENT_ENTRYPOINT = "index.html";
8
+
9
+ function fileIntegrity(filePath) {
10
+ return `sha256-${createHash("sha256").update(fs.readFileSync(filePath)).digest("hex")}`;
11
+ }
12
+
13
+ function manifestFileIdentity(manifest) {
14
+ return {
15
+ kind: manifest.kind,
16
+ version: manifest.version,
17
+ entrypoint: manifest.entrypoint,
18
+ files: [...manifest.files]
19
+ .map((file) => ({
20
+ path: file.path,
21
+ type: file.type || "",
22
+ integrity: file.integrity,
23
+ byteLength: file.byteLength
24
+ }))
25
+ .sort((left, right) => left.path.localeCompare(right.path)),
26
+ ...(manifest.frontendChunks ? { frontendChunks: manifest.frontendChunks } : {})
27
+ };
28
+ }
29
+
30
+ function manifestBundleIdentity(manifest) {
31
+ return {
32
+ integrity: hashCanonical(manifestFileIdentity(manifest)),
33
+ byteLength: manifest.files.reduce((total, file) => total + file.byteLength, 0)
34
+ };
35
+ }
36
+
37
+ function readBundleManifest(frontend) {
38
+ const file = bundleManifestFile(frontend);
39
+ let manifest;
40
+ try {
41
+ manifest = JSON.parse(fs.readFileSync(file, "utf8"));
42
+ } catch (error) {
43
+ if (error && (error.code === "ENOENT" || error.code === "ENOTDIR")) return undefined;
44
+ throw error;
45
+ }
46
+ if (manifest?.kind !== "matterhorn.frontend.manifest" || manifest.version !== 1) {
47
+ throw new Error(`Built app bundle manifest is invalid: ${file}`);
48
+ }
49
+ if (typeof manifest.entrypoint !== "string" || !manifest.entrypoint) {
50
+ throw new Error(`Built app bundle manifest is missing entrypoint: ${file}`);
51
+ }
52
+ if (!Array.isArray(manifest.files) || manifest.files.length === 0) {
53
+ throw new Error(`Built app bundle manifest is missing files: ${file}`);
54
+ }
55
+ for (const item of manifest.files) {
56
+ if (!item || typeof item.path !== "string" || !item.path || path.isAbsolute(item.path) || item.path.includes("..")) {
57
+ throw new Error(`Built app bundle manifest has an unsafe file path: ${file}`);
58
+ }
59
+ if (typeof item.integrity !== "string" || !item.integrity.startsWith("sha256-")) {
60
+ throw new Error(`Built app bundle manifest has an invalid file integrity: ${item.path}`);
61
+ }
62
+ if (!Number.isInteger(item.byteLength) || item.byteLength < 0) {
63
+ throw new Error(`Built app bundle manifest has an invalid file size: ${item.path}`);
64
+ }
65
+ }
66
+ if (!manifest.files.some((item) => item.path === DOCUMENT_ENTRYPOINT)) {
67
+ throw new Error(`Built app bundle manifest is missing required ${DOCUMENT_ENTRYPOINT} document.`);
68
+ }
69
+ if (manifest.frontendChunks !== undefined) {
70
+ manifest.frontendChunks = validateFrontendChunkManifest(manifest.frontendChunks);
71
+ }
72
+ return { file, manifest };
73
+ }
74
+
75
+ function appBundleArtifact(frontend) {
76
+ const file = bundleEntryFile(frontend);
77
+ const stat = fs.statSync(file);
78
+ if (!stat.isFile()) throw new Error(`Built app bundle is missing ${file}`);
79
+ const bundleManifest = readBundleManifest(frontend);
80
+ const identity = bundleManifest ? manifestBundleIdentity(bundleManifest.manifest) : {
81
+ byteLength: stat.size,
82
+ integrity: fileIntegrity(file)
83
+ };
84
+ return {
85
+ file,
86
+ entry: bundleEntryName(frontend),
87
+ distDir: bundleDistDir(frontend),
88
+ mountPath: bundleMountPath(frontend),
89
+ byteLength: identity.byteLength,
90
+ integrity: identity.integrity,
91
+ ...(bundleManifest ? {
92
+ manifestFile: bundleManifest.file,
93
+ manifest: bundleManifest.manifest
94
+ } : {})
95
+ };
96
+ }
97
+
98
+ module.exports = {
99
+ appBundleArtifact,
100
+ manifestBundleIdentity
101
+ };
@@ -0,0 +1,95 @@
1
+ const MATTERHORN_PUBLIC_KEYS = new Set([
2
+ "MATTERHORN_APP_BUNDLE_MOUNT",
3
+ "MATTERHORN_APP_BUNDLE_TARGET",
4
+ "MATTERHORN_BUNDLE_BASE",
5
+ "MATTERHORN_BUNDLE_PORT",
6
+ "MATTERHORN_FRONTEND_PORT"
7
+ ]);
8
+ const PRODUCTION_PROFILE = "production";
9
+
10
+ function baseProcessEnv() {
11
+ const env = {};
12
+ for (const key of ["PATH", "Path", "ComSpec", "SystemRoot", "WINDIR", "TEMP", "TMP", "HOME", "USERPROFILE"]) {
13
+ if (process.env[key] !== undefined) env[key] = process.env[key];
14
+ }
15
+ return env;
16
+ }
17
+
18
+ function sensitiveEnvKey(key) {
19
+ const upper = String(key).toUpperCase();
20
+ if (MATTERHORN_PUBLIC_KEYS.has(upper)) return false;
21
+ if (upper === "NODE_OPTIONS" || upper === "SSH_AUTH_SOCK") return true;
22
+ if (upper.startsWith("AWS_") || upper.startsWith("GOOGLE_") || upper.startsWith("AZURE_")) return true;
23
+ return /(?:^|_)(?:TOKEN|SECRET|PASSWORD|KEY)$/.test(upper);
24
+ }
25
+
26
+ function validateCommandEnv(commandEnv = {}, options = {}) {
27
+ for (const key of Object.keys(commandEnv || {})) {
28
+ if (!sensitiveEnvKey(key)) continue;
29
+ if (options.trust === "trusted-local-dev" && options.allowSensitiveCommandEnv === true) continue;
30
+ throw new Error(`App command env key ${key} is not allowed by Matterhorn command env policy`);
31
+ }
32
+ }
33
+
34
+ function commandPortEnv(input = {}) {
35
+ const env = {};
36
+ if (input.port !== undefined) {
37
+ env.PORT = String(input.port);
38
+ env.MATTERHORN_FRONTEND_PORT = String(input.port);
39
+ }
40
+ if (input.bundlePort !== undefined) env.MATTERHORN_BUNDLE_PORT = String(input.bundlePort);
41
+ if (input.mountPath !== undefined) env.MATTERHORN_BUNDLE_BASE = String(input.mountPath || "/");
42
+ return env;
43
+ }
44
+
45
+ function canUnsafeInherit(input) {
46
+ if (input.trust !== "trusted-local-dev") return false;
47
+ return input.unsafeInheritEnv === true || process.env.MATTERHORN_UNSAFE_APP_FULL_ENV === "1";
48
+ }
49
+
50
+ function matterhornProfile(input = {}) {
51
+ return String(input.matterhornProfile || process.env.MATTERHORN_PROFILE || "").toLowerCase();
52
+ }
53
+
54
+ function unsafeEnvMessage(source) {
55
+ return `MATTERHORN_UNSAFE_APP_FULL_ENV is disabled in production profile. Use explicit app command env allowlist entries instead of inheriting the full parent environment.${source ? ` Source: ${source}.` : ""}`;
56
+ }
57
+
58
+ function assertUnsafeEnvAllowed(input = {}) {
59
+ if (!canUnsafeInherit(input)) return;
60
+ if (matterhornProfile(input) !== PRODUCTION_PROFILE) return;
61
+ const source = input.unsafeInheritEnv === true ? "unsafeInheritEnv" : "MATTERHORN_UNSAFE_APP_FULL_ENV";
62
+ const message = unsafeEnvMessage(source);
63
+ input.logger?.warn?.(redactCommandOutput(message));
64
+ input.io?.warn?.(redactCommandOutput(message));
65
+ throw new Error(message);
66
+ }
67
+
68
+ function buildMatterhornCommandEnv(input = {}) {
69
+ const trust = input.trust || "untrusted-metadata";
70
+ const commandEnv = input.commandEnv || {};
71
+ validateCommandEnv(commandEnv, {
72
+ trust,
73
+ allowSensitiveCommandEnv: input.allowSensitiveCommandEnv
74
+ });
75
+ assertUnsafeEnvAllowed({ ...input, trust });
76
+ return {
77
+ ...(canUnsafeInherit({ ...input, trust }) ? process.env : baseProcessEnv()),
78
+ ...commandPortEnv(input),
79
+ ...commandEnv
80
+ };
81
+ }
82
+
83
+ const SECRET_VALUE_PATTERN = /(?:npm|github|ghp|aws|secret|token|password|key)[A-Za-z0-9_./+=:-]{8,}/gi;
84
+
85
+ function redactCommandOutput(value) {
86
+ return String(value).replace(SECRET_VALUE_PATTERN, "[REDACTED]");
87
+ }
88
+
89
+ module.exports = {
90
+ assertUnsafeEnvAllowed,
91
+ buildMatterhornCommandEnv,
92
+ redactCommandOutput,
93
+ sensitiveEnvKey,
94
+ validateCommandEnv
95
+ };
@@ -0,0 +1,23 @@
1
+ const RUNNABLE_COMMAND_POLICIES = new Set(["allowlisted", "sandboxed", "trusted-local-dev"]);
2
+
3
+ function commandPolicyFromTrust(trust) {
4
+ return trust?.commandExecution || "trusted-local-dev";
5
+ }
6
+
7
+ function commandEnvTrustForPolicy(policy) {
8
+ if (policy === "trusted-local-dev") return "trusted-local-dev";
9
+ if (policy === "allowlisted" || policy === "sandboxed") return "trusted-installed";
10
+ return "untrusted-metadata";
11
+ }
12
+
13
+ function assertAppCommandAllowed(trust, label) {
14
+ const policy = commandPolicyFromTrust(trust);
15
+ if (RUNNABLE_COMMAND_POLICIES.has(policy)) return policy;
16
+ throw new Error(`${label} requires app command execution trust; metadata-only app sources cannot run frontend commands`);
17
+ }
18
+
19
+ module.exports = {
20
+ assertAppCommandAllowed,
21
+ commandEnvTrustForPolicy,
22
+ commandPolicyFromTrust
23
+ };
@@ -0,0 +1,121 @@
1
+ const path = require("node:path");
2
+ const fs = require("node:fs");
3
+
4
+ const DEFAULT_BUNDLE_MANIFEST = "matterhorn-frontend-manifest.json";
5
+
6
+ function parseServeMode(value) {
7
+ if (value === true || value === undefined || value === "") return "dev";
8
+ const mode = String(value).trim().toLowerCase();
9
+ if (mode === "dev" || mode === "development") return "dev";
10
+ if (mode === "built" || mode === "build" || mode === "static" || mode === "dist") return "built";
11
+ throw new Error(`Unsupported frontend serve mode "${value}". Use dev or built.`);
12
+ }
13
+
14
+ function frontendRoot(frontend) {
15
+ if (!frontend?.root) throw new Error("App frontend.root is required");
16
+ return path.resolve(frontend.root);
17
+ }
18
+
19
+ function ensureLeadingSlash(value) {
20
+ if (!value) return "/";
21
+ return value.startsWith("/") ? value : `/${value}`;
22
+ }
23
+
24
+ function ensureTrailingSlash(value) {
25
+ return value.endsWith("/") ? value : `${value}/`;
26
+ }
27
+
28
+ function frontendUrl(port, basePath = "/") {
29
+ return `http://127.0.0.1:${port}${ensureTrailingSlash(ensureLeadingSlash(basePath))}`;
30
+ }
31
+
32
+ function repoRoot() {
33
+ return path.resolve(__dirname, "..", "..");
34
+ }
35
+
36
+ function launcherDevCommand() {
37
+ return {
38
+ command: process.execPath,
39
+ args: [
40
+ path.join(repoRoot(), "node_modules", "vite", "bin", "vite.js"),
41
+ "--config",
42
+ path.join("client", "vite.config.ts"),
43
+ "--host",
44
+ "127.0.0.1",
45
+ "--port",
46
+ "${port}",
47
+ "--strictPort"
48
+ ]
49
+ };
50
+ }
51
+
52
+ function launcherBuildCommand() {
53
+ return {
54
+ command: process.platform === "win32" ? "pnpm.cmd" : "pnpm",
55
+ args: ["run", "build:launcher"]
56
+ };
57
+ }
58
+
59
+ function launcherDist(frontend = {}) {
60
+ return path.resolve(frontend.launcher?.dist || path.join(repoRoot(), "client", "dist"));
61
+ }
62
+
63
+ function bundleConfig(frontend) {
64
+ if (!frontend?.bundle) throw new Error("App frontend.bundle is required");
65
+ return frontend.bundle;
66
+ }
67
+
68
+ function bundleMountPath(frontend) {
69
+ const bundle = bundleConfig(frontend);
70
+ return ensureTrailingSlash(ensureLeadingSlash(bundle.mountPath || `/matterhorn/apps/${frontend.appId || "app"}/`));
71
+ }
72
+
73
+ function bundleUrl(appUrl, mountPath, entry) {
74
+ return new URL(`${mountPath}${entry}`, appUrl).toString();
75
+ }
76
+
77
+ function bundleDistDir(frontend) {
78
+ const bundle = bundleConfig(frontend);
79
+ return path.resolve(frontendRoot(frontend), bundle.dist || "frontend/dist");
80
+ }
81
+
82
+ function bundleEntryName(frontend) {
83
+ return bundleConfig(frontend).builtEntry || "matterhorn-app.js";
84
+ }
85
+
86
+ function bundleEntryFile(frontend) {
87
+ return path.join(bundleDistDir(frontend), bundleEntryName(frontend));
88
+ }
89
+
90
+ function bundleManifestName(frontend) {
91
+ return bundleConfig(frontend).manifest || DEFAULT_BUNDLE_MANIFEST;
92
+ }
93
+
94
+ function bundleManifestFile(frontend) {
95
+ const bundle = bundleConfig(frontend);
96
+ if (bundle.manifest) return path.resolve(frontendRoot(frontend), bundle.manifest);
97
+ const rootManifest = path.join(path.dirname(bundleDistDir(frontend)), DEFAULT_BUNDLE_MANIFEST);
98
+ const legacyDistManifest = path.join(bundleDistDir(frontend), DEFAULT_BUNDLE_MANIFEST);
99
+ if (fs.existsSync(rootManifest) || !fs.existsSync(legacyDistManifest)) return rootManifest;
100
+ return legacyDistManifest;
101
+ }
102
+
103
+ module.exports = {
104
+ bundleConfig,
105
+ bundleDistDir,
106
+ bundleEntryFile,
107
+ bundleEntryName,
108
+ bundleManifestFile,
109
+ bundleManifestName,
110
+ bundleMountPath,
111
+ bundleUrl,
112
+ ensureLeadingSlash,
113
+ ensureTrailingSlash,
114
+ frontendRoot,
115
+ frontendUrl,
116
+ launcherBuildCommand,
117
+ launcherDevCommand,
118
+ launcherDist,
119
+ parseServeMode,
120
+ repoRoot
121
+ };
@@ -0,0 +1,135 @@
1
+ const { spawn } = require("node:child_process");
2
+ const { killChildTree } = require("../../scripts/killChildTree.cjs");
3
+ const { buildMatterhornCommandEnv, redactCommandOutput } = require("./commandEnv.cjs");
4
+
5
+ function quoteCmdArg(value) {
6
+ const text = String(value);
7
+ if (/^[A-Za-z0-9_./:@=${}-]+$/.test(text)) return text;
8
+ return `"${text.replaceAll("\"", "\\\"")}"`;
9
+ }
10
+
11
+ function commandInvocation(command, args) {
12
+ if (process.platform === "win32" && ["npm", "npx", "npm.cmd", "npx.cmd", "pnpm", "pnpx", "pnpm.cmd", "pnpx.cmd"].includes(command)) {
13
+ const base = command.endsWith(".cmd") ? command : `${command}.cmd`;
14
+ return {
15
+ command: process.env.ComSpec || "cmd.exe",
16
+ args: ["/d", "/s", "/c", [base, ...args].map(quoteCmdArg).join(" ")]
17
+ };
18
+ }
19
+ return { command, args };
20
+ }
21
+
22
+ function replaceTokens(value, options) {
23
+ return String(value)
24
+ .replaceAll("${port}", String(options.port))
25
+ .replaceAll("${bundlePort}", String(options.bundlePort ?? options.port))
26
+ .replaceAll("${mountPath}", String(options.mountPath || "/"));
27
+ }
28
+
29
+ function commandSpec(command, name) {
30
+ if (!command || typeof command !== "object") throw new Error(`App frontend.${name} command is required`);
31
+ if (Array.isArray(command)) {
32
+ if (!command[0]) throw new Error(`App frontend.${name} command is empty`);
33
+ return { command: command[0], args: command.slice(1), env: {} };
34
+ }
35
+ for (const key of Object.keys(command)) {
36
+ if (key !== "command" && key !== "args" && key !== "env" && key !== "envVars" && key !== "process.env") {
37
+ throw new Error(`App frontend.${name}.${key} is not a supported command field`);
38
+ }
39
+ }
40
+ if (!command.command) throw new Error(`App frontend.${name}.command is required`);
41
+ if (command.args !== undefined && !Array.isArray(command.args)) throw new Error(`App frontend.${name}.args must be an array`);
42
+ const env = {};
43
+ for (const key of ["env", "envVars", "process.env"]) {
44
+ const value = command[key];
45
+ if (value === undefined) continue;
46
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
47
+ throw new Error(`App frontend.${name}.${key} must be an object`);
48
+ }
49
+ Object.assign(env, value);
50
+ }
51
+ return {
52
+ command: command.command,
53
+ args: (command.args || []).map((arg) => String(arg)),
54
+ env
55
+ };
56
+ }
57
+
58
+ function spawnCommand(spec, options) {
59
+ const rawArgs = spec.args.map((arg) => replaceTokens(arg, options));
60
+ const invocation = commandInvocation(spec.command, rawArgs);
61
+ const commandEnv = {
62
+ ...options.env,
63
+ ...Object.fromEntries(Object.entries(spec.env || {}).map(([key, value]) => [key, replaceTokens(value, options)]))
64
+ };
65
+ const child = spawn(invocation.command, invocation.args, {
66
+ cwd: options.cwd,
67
+ env: buildMatterhornCommandEnv({
68
+ trust: options.trust || "trusted-local-dev",
69
+ commandEnv,
70
+ port: options.port,
71
+ bundlePort: options.bundlePort ?? options.port,
72
+ mountPath: options.mountPath || "/",
73
+ unsafeInheritEnv: options.unsafeInheritEnv,
74
+ allowSensitiveCommandEnv: options.allowSensitiveCommandEnv,
75
+ matterhornProfile: options.matterhornProfile,
76
+ logger: options.logger
77
+ }),
78
+ stdio: ["ignore", "pipe", "pipe"],
79
+ shell: false,
80
+ windowsHide: true
81
+ });
82
+
83
+ const logger = options.logger;
84
+ child.stdout.on("data", (chunk) => logger?.log?.(`${options.label} stdout: ${redactCommandOutput(chunk.toString("utf8").trimEnd())}`));
85
+ child.stderr.on("data", (chunk) => logger?.error?.(`${options.label} stderr: ${redactCommandOutput(chunk.toString("utf8").trimEnd())}`));
86
+ return child;
87
+ }
88
+
89
+ function runCommand(spec, options) {
90
+ const child = spawnCommand(spec, options);
91
+ return new Promise((resolve, reject) => {
92
+ const timer = setTimeout(() => {
93
+ child.kill();
94
+ reject(new Error(`${options.label} timed out after ${options.timeoutMs}ms`));
95
+ }, options.timeoutMs);
96
+ child.on("exit", (code) => {
97
+ clearTimeout(timer);
98
+ if (code === 0) resolve();
99
+ else reject(new Error(`${options.label} exited ${code}`));
100
+ });
101
+ child.on("error", (error) => {
102
+ clearTimeout(timer);
103
+ reject(error);
104
+ });
105
+ });
106
+ }
107
+
108
+ function closeChildProcess(child) {
109
+ if (!child || child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
110
+ return new Promise((resolve) => {
111
+ const timer = setTimeout(() => {
112
+ child.stdout?.destroy?.();
113
+ child.stderr?.destroy?.();
114
+ child.unref?.();
115
+ resolve();
116
+ }, 2000);
117
+ child.once("exit", () => {
118
+ clearTimeout(timer);
119
+ child.stdout?.destroy?.();
120
+ child.stderr?.destroy?.();
121
+ child.unref?.();
122
+ resolve();
123
+ });
124
+ killChildTree(child);
125
+ });
126
+ }
127
+
128
+ module.exports = {
129
+ closeChildProcess,
130
+ commandInvocation,
131
+ commandSpec,
132
+ replaceTokens,
133
+ runCommand,
134
+ spawnCommand
135
+ };
@@ -0,0 +1,29 @@
1
+ const MATTERHORN_APP_REF_RE = /^@gg\.matterhorn\.[a-z0-9][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)*$/;
2
+
3
+ function isMatterhornAliasRef(ref) {
4
+ return typeof ref === "string" && ref.startsWith("@") && ref.includes(".matterhorn.");
5
+ }
6
+
7
+ function isCanonicalMatterhornAppRef(ref) {
8
+ return MATTERHORN_APP_REF_RE.test(String(ref || ""));
9
+ }
10
+
11
+ function assertCanonicalMatterhornAppRef(ref) {
12
+ if (isCanonicalMatterhornAppRef(ref)) return ref;
13
+ throw new Error(`Matterhorn app alias ${ref || "(empty)"} is invalid. Use @gg.matterhorn.<app>, for example @gg.matterhorn.events.`);
14
+ }
15
+
16
+ function rejectInvalidMatterhornAliasRef(ref) {
17
+ if (isMatterhornAliasRef(ref) && !isCanonicalMatterhornAppRef(ref)) {
18
+ assertCanonicalMatterhornAppRef(ref);
19
+ }
20
+ return ref;
21
+ }
22
+
23
+ module.exports = {
24
+ MATTERHORN_APP_REF_RE,
25
+ assertCanonicalMatterhornAppRef,
26
+ isCanonicalMatterhornAppRef,
27
+ isMatterhornAliasRef,
28
+ rejectInvalidMatterhornAliasRef
29
+ };
@@ -0,0 +1,702 @@
1
+ const fs = require("node:fs");
2
+ const os = require("node:os");
3
+ const path = require("node:path");
4
+ const { spawnSync } = require("node:child_process");
5
+ const { createHash } = require("node:crypto");
6
+ const { createRequire } = require("node:module");
7
+ const { fileURLToPath } = require("node:url");
8
+ const { verifySignedManifest } = require("@mh-gg/base");
9
+ const { rejectInvalidMatterhornAliasRef } = require("./appRefPolicy.cjs");
10
+
11
+ const APP_REF_ALIASES = {
12
+ "@gg.matterhorn.events": "@mh-gg/example-events",
13
+ "@gg.matterhorn.kanban": "@mh-gg/example-kanban",
14
+ "@gg.matterhorn.wiki": "@mh-gg/example-wiki",
15
+ "@gg.matterhorn.polls": "@mh-gg/example-polls",
16
+ "@gg.matterhorn.budget": "@mh-gg/example-budget",
17
+ "@gg.matterhorn.crm": "@mh-gg/example-crm",
18
+ "@gg.matterhorn.react": "@mh-gg/example-react",
19
+ "@gg.matterhorn.svelte": "@mh-gg/example-svelte",
20
+ "@gg.matterhorn.vue": "@mh-gg/example-vue",
21
+ "@gg.matterhorn.lit": "@mh-gg/example-lit",
22
+ "@gg.matterhorn.vanilla": "@mh-gg/example-vanilla"
23
+ };
24
+
25
+ function canonicalAppRef(ref) {
26
+ rejectInvalidMatterhornAliasRef(ref);
27
+ return APP_REF_ALIASES[ref] || ref;
28
+ }
29
+
30
+ function builtInExampleAppRef(ref) {
31
+ return Object.values(APP_REF_ALIASES).includes(ref) || String(ref).startsWith("@mh-gg/example-");
32
+ }
33
+
34
+ function matterhornProfile(options = {}) {
35
+ return String(options.matterhornProfile || process.env.MATTERHORN_PROFILE || process.env.NODE_ENV || "development").toLowerCase();
36
+ }
37
+
38
+ function isProductionProfile(options = {}) {
39
+ return matterhornProfile(options) === "production";
40
+ }
41
+
42
+ function isPinnedHashMatch(actualHash, pinnedHash) {
43
+ if (typeof pinnedHash !== "string" || pinnedHash.length === 0) return false;
44
+ return pinnedHash === actualHash || pinnedHash === `sha256-${actualHash}`;
45
+ }
46
+
47
+ function hasProductionSourcePin(app, source, trust, options = {}) {
48
+ if (options.trustedSource === true) return true;
49
+ if (isPinnedHashMatch(trust.contentHash, options.pinnedHash || options.appSourceHash)) return true;
50
+ if (source.pinnedCommit) return true;
51
+ if (trust.packageIntegrity || app.pack?.artifactSha256 || app.pack?.frontendArchiveSha256) return true;
52
+ return false;
53
+ }
54
+
55
+ function productionSourcePolicyMessage(app, trust) {
56
+ return `Production profile refuses unpinned app execution for ${app.id || "unknown app"}. Install from a pinned git commit, a package artifact with integrity, or pass an explicit pinnedHash/trustedSource policy for this app source.`;
57
+ }
58
+
59
+ function appDescriptorSignatureVerified(app) {
60
+ try {
61
+ return verifySignedManifest(app.appPack).ok;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ function appHasCommandDescriptor(app) {
68
+ return Boolean(app?.frontend?.bundle?.build
69
+ || app?.frontend?.bundle?.dev
70
+ || app?.frontend?.dev
71
+ || app?.deployment?.launch?.command
72
+ || app?.launch?.command);
73
+ }
74
+
75
+ function assertProductionAppSourcePolicy(app, source, trust, options = {}) {
76
+ if (!isProductionProfile(options)) return;
77
+ if (builtInExampleAppRef(source.ref)) {
78
+ throw new Error("Built-in Matterhorn example apps are development/test fixtures and cannot run under the production profile. Use a pinned production app source instead.");
79
+ }
80
+ if (trust.codeExecution === "local-dev" || source.unsafeDevMode) {
81
+ throw new Error("Production profile refuses local development app JavaScript descriptors. Use --profile development for local app code, or install from a pinned production app source.");
82
+ }
83
+ if (trust.commandExecution === "trusted-local-dev" && appHasCommandDescriptor(app)) {
84
+ throw new Error("Production profile refuses local development app command descriptors. Use --profile development for local app commands, or install from a pinned production app source.");
85
+ }
86
+ if (trust.metadataMode === "executed-js" && options.trustedSource !== true && !trust.packageIntegrity && !source.pinnedCommit) {
87
+ throw new Error("Production profile refuses remote JavaScript app descriptors without an explicit trustedSource policy, package integrity, or pinned git commit.");
88
+ }
89
+ if (!hasProductionSourcePin(app, source, trust, options)) {
90
+ throw new Error(productionSourcePolicyMessage(app, trust));
91
+ }
92
+ if (!appDescriptorSignatureVerified(app)) {
93
+ throw new Error("Production profile refuses unsigned alpha app descriptors. Sign the app descriptor before production use.");
94
+ }
95
+ }
96
+
97
+ function looksLikeLocalRef(ref) {
98
+ return ref === "."
99
+ || ref === ".."
100
+ || ref.startsWith("./")
101
+ || ref.startsWith("../")
102
+ || ref.startsWith(".\\")
103
+ || ref.startsWith("..\\")
104
+ || ref.startsWith("/")
105
+ || ref.startsWith("\\")
106
+ || /^[A-Za-z]:[\\/]/.test(ref);
107
+ }
108
+
109
+ function fileRefPath(ref) {
110
+ if (ref.startsWith("file:")) return ref.slice("file:".length);
111
+ return ref;
112
+ }
113
+
114
+ function candidateEntryFiles(directory) {
115
+ return [
116
+ path.join(directory, "matterhorn.app.json"),
117
+ path.join(directory, "matterhorn.app.cjs"),
118
+ path.join(directory, "matterhorn.app.js"),
119
+ path.join(directory, "index.cjs"),
120
+ path.join(directory, "index.js")
121
+ ];
122
+ }
123
+
124
+ function readJsonFile(filePath) {
125
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
126
+ }
127
+
128
+ function readJsonFileIfPresent(filePath) {
129
+ try {
130
+ return readJsonFile(filePath);
131
+ } catch (error) {
132
+ if (error && (error.code === "ENOENT" || error.code === "ENOTDIR")) return undefined;
133
+ throw error;
134
+ }
135
+ }
136
+
137
+ function fileExists(filePath) {
138
+ try {
139
+ return fs.statSync(filePath).isFile();
140
+ } catch (error) {
141
+ if (error && (error.code === "ENOENT" || error.code === "ENOTDIR")) return false;
142
+ throw error;
143
+ }
144
+ }
145
+
146
+ function directoryExists(filePath) {
147
+ try {
148
+ return fs.statSync(filePath).isDirectory();
149
+ } catch (error) {
150
+ if (error && (error.code === "ENOENT" || error.code === "ENOTDIR")) return false;
151
+ throw error;
152
+ }
153
+ }
154
+
155
+ function bundleEntryFile(directory) {
156
+ const candidates = fs.readdirSync(directory, { withFileTypes: true })
157
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
158
+ .map((entry) => path.join(directory, entry.name));
159
+ const bundles = [];
160
+ for (const file of candidates) {
161
+ try {
162
+ const value = readJsonFile(file);
163
+ if (value?.kind === "matterhorn.app-bundle" && value.appPack) bundles.push(file);
164
+ } catch (matterhornIgnoredError) {
165
+ globalThis.__matterhornIgnoredError?.(matterhornIgnoredError, "bin/matterhornAppLoader.cjs");
166
+ }
167
+ }
168
+ if (bundles.length > 1) throw new Error(`Local Matterhorn app ${directory} exposes multiple app bundle files: ${bundles.map((file) => path.basename(file)).join(", ")}`);
169
+ return bundles[0];
170
+ }
171
+
172
+ function packageEntryFile(directory) {
173
+ const packageFile = path.join(directory, "package.json");
174
+ const manifest = readJsonFileIfPresent(packageFile);
175
+ if (!manifest) return undefined;
176
+ const matterhornEntry = typeof manifest.matterhorn === "string"
177
+ ? manifest.matterhorn
178
+ : manifest.matterhorn?.app;
179
+ const sdkEntry = typeof manifest["matterhorn-sdk"] === "object" ? manifest["matterhorn-sdk"].app : undefined;
180
+ return path.resolve(directory, matterhornEntry || sdkEntry || manifest.main || "index.cjs");
181
+ }
182
+
183
+ function packagePrefersSourceEntry(directory) {
184
+ const manifest = readJsonFileIfPresent(path.join(directory, "package.json"));
185
+ return typeof manifest?.["matterhorn-sdk"]?.app === "string";
186
+ }
187
+
188
+ function staticPackageEntryFile(directory) {
189
+ const packageFile = path.join(directory, "package.json");
190
+ const manifest = readJsonFileIfPresent(packageFile) || {};
191
+ const matterhornEntry = typeof manifest.matterhorn === "object" && typeof manifest.matterhorn.bundle === "string"
192
+ ? path.resolve(directory, manifest.matterhorn.bundle)
193
+ : undefined;
194
+ if (matterhornEntry && fileExists(matterhornEntry)) return matterhornEntry;
195
+ const direct = path.join(directory, "matterhorn.app.json");
196
+ if (fileExists(direct)) return direct;
197
+ return bundleEntryFile(directory);
198
+ }
199
+
200
+ function localEntryFile(localPath) {
201
+ const resolved = path.resolve(localPath);
202
+ const stat = fs.statSync(resolved);
203
+ if (stat.isFile()) return resolved;
204
+ const packageEntry = packageEntryFile(resolved);
205
+ if (packagePrefersSourceEntry(resolved) && packageEntry && fileExists(packageEntry)) return packageEntry;
206
+ const bundleEntry = bundleEntryFile(resolved);
207
+ if (bundleEntry) return bundleEntry;
208
+ if (packageEntry && fileExists(packageEntry)) return packageEntry;
209
+ const found = candidateEntryFiles(resolved).find((file) => fileExists(file));
210
+ if (found) return found;
211
+ throw new Error(`Local Matterhorn app ${localPath} does not expose matterhorn.app.json, matterhorn.app.cjs, index.cjs, or package.json`);
212
+ }
213
+
214
+ function nearestPackageRoot(filePath) {
215
+ let current = fs.statSync(filePath).isDirectory() ? filePath : path.dirname(filePath);
216
+ while (true) {
217
+ if (fileExists(path.join(current, "package.json"))) return current;
218
+ const parent = path.dirname(current);
219
+ if (parent === current) return path.dirname(filePath);
220
+ current = parent;
221
+ }
222
+ }
223
+
224
+ function gitAppRefsAllowed(options = {}) {
225
+ return options.allowGitAppRefs === true || process.env.MATTERHORN_ALLOW_GIT_APP_REFS === "1";
226
+ }
227
+
228
+ function trustedAppCodeAllowed(options = {}) {
229
+ return options.allowTrustedAppCode === true
230
+ || options.trustedAppCode === true;
231
+ }
232
+
233
+ function localAppCodeTrusted(options = {}) {
234
+ return options.localAppCodeTrusted !== false;
235
+ }
236
+
237
+ function contentHash(filePath) {
238
+ return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
239
+ }
240
+
241
+ function sourceIdentity(source) {
242
+ return `${source.sourceKind}:${source.ref}:${source.sourceRoot}:${source.entryFile}`;
243
+ }
244
+
245
+ function matchingTrustRecord(source, options = {}) {
246
+ const record = options.trustRecord;
247
+ if (!record || typeof record !== "object") return false;
248
+ return record.sourceIdentity === sourceIdentity(source)
249
+ && record.contentHash === contentHash(source.entryFile)
250
+ && record.codeExecution === "trusted-installed";
251
+ }
252
+
253
+ function appTrustForSource(source, options = {}) {
254
+ const metadataMode = source.entryFile.endsWith(".json") ? "static-json" : "executed-js";
255
+ let codeExecution = "none";
256
+ let commandExecution = "none";
257
+ if (metadataMode === "executed-js") {
258
+ if (source.directLocal) {
259
+ codeExecution = "local-dev";
260
+ commandExecution = "trusted-local-dev";
261
+ } else if (matchingTrustRecord(source, options) || trustedAppCodeAllowed(options)) {
262
+ codeExecution = "trusted-installed";
263
+ commandExecution = "allowlisted";
264
+ }
265
+ } else if (source.directLocal) {
266
+ commandExecution = "trusted-local-dev";
267
+ } else if (builtInExampleAppRef(source.ref)) {
268
+ commandExecution = "trusted-local-dev";
269
+ }
270
+ return {
271
+ sourceKind: source.sourceKind,
272
+ metadataMode,
273
+ codeExecution,
274
+ commandExecution,
275
+ sourceIdentity: sourceIdentity(source),
276
+ contentHash: contentHash(source.entryFile),
277
+ ...(source.packageIntegrity ? { packageIntegrity: source.packageIntegrity } : {}),
278
+ ...(source.pinnedCommit ? { pinnedCommit: source.pinnedCommit } : {}),
279
+ ...(source.unsafeDevMode ? { unsafeDevMode: true } : {})
280
+ };
281
+ }
282
+
283
+ function resolveMatterhornAppEntry(ref, options = {}) {
284
+ const cwd = options.cwd || process.cwd();
285
+ const loadRef = canonicalAppRef(ref);
286
+ if (loadRef.startsWith("git+")) {
287
+ if (!gitAppRefsAllowed(options)) {
288
+ throw new Error("Git app refs are disabled by default. Clone the app locally or pass allowGitAppRefs: true for trusted sources.");
289
+ }
290
+ const sourceRoot = gitAppCachePath(loadRef, options);
291
+ const gitMetadata = readGitAppCacheMetadata(sourceRoot) || {};
292
+ const parsedGit = parseGitAppRef(loadRef);
293
+ const gitSource = {
294
+ sourceKind: "git",
295
+ pinnedCommit: gitMetadata.unsafeDevRef ? undefined : gitMetadata.commit,
296
+ unsafeDevMode: gitMetadata.unsafeDevRef === true,
297
+ sourceRef: parsedGit.source
298
+ };
299
+ const staticEntry = staticPackageEntryFile(sourceRoot);
300
+ if (staticEntry) return { ref: loadRef, entryFile: staticEntry, sourceRoot, directLocal: false, ...gitSource };
301
+ const jsSource = { ref: loadRef, entryFile: localEntryFile(sourceRoot), sourceRoot, directLocal: false, ...gitSource };
302
+ if (!trustedAppCodeAllowed(options) && !matchingTrustRecord(jsSource, options)) {
303
+ throw new Error("Git app refs can only load static JSON app bundles by default. Set allowTrustedAppCode: true only for trusted local development.");
304
+ }
305
+ return jsSource;
306
+ }
307
+
308
+ if (loadRef.startsWith("file:") || looksLikeLocalRef(loadRef)) {
309
+ const sourcePath = path.resolve(cwd, fileRefPath(loadRef));
310
+ const entryFile = localEntryFile(sourcePath);
311
+ const sourceRoot = fs.statSync(sourcePath).isDirectory() ? sourcePath : nearestPackageRoot(sourcePath);
312
+ const directLocal = localAppCodeTrusted(options);
313
+ const sourceKind = fs.statSync(sourcePath).isDirectory() ? "local-directory" : "local-file";
314
+ const source = { ref: loadRef, entryFile, sourceRoot, directLocal, sourceKind };
315
+ if (!entryFile.endsWith(".json") && !directLocal && !trustedAppCodeAllowed(options) && !matchingTrustRecord(source, options)) {
316
+ throw new Error("Downloaded app packages can only load static JSON app bundles by default. Set allowTrustedAppCode: true only for trusted package code.");
317
+ }
318
+ return source;
319
+ }
320
+
321
+ const sourceRoot = resolvePackageRoot(loadRef, cwd);
322
+ if (builtInExampleAppRef(loadRef)) {
323
+ const entryFile = packageEntryFile(sourceRoot);
324
+ if (entryFile && fileExists(entryFile)) return { ref: loadRef, entryFile, sourceRoot, directLocal: true, sourceKind: "package" };
325
+ }
326
+ const staticEntry = staticPackageEntryFile(sourceRoot);
327
+ if (staticEntry) return { ref: loadRef, entryFile: staticEntry, sourceRoot, directLocal: false, sourceKind: "package" };
328
+ const entryFile = resolvePackageEntryFile(loadRef, cwd);
329
+ const source = { ref: loadRef, entryFile, sourceRoot: nearestPackageRoot(entryFile), directLocal: false, sourceKind: "package" };
330
+ if (!trustedAppCodeAllowed(options) && !matchingTrustRecord(source, options)) {
331
+ throw new Error(`Package app ref ${loadRef} can only load static JSON app bundles by default. Set allowTrustedAppCode: true only for trusted local development.`);
332
+ }
333
+ return source;
334
+ }
335
+
336
+ function resolvePackageRoot(ref, cwd) {
337
+ const entryFile = resolvePackageEntryFile(ref, cwd);
338
+ return nearestPackageRoot(entryFile);
339
+ }
340
+
341
+ function resolvePackageEntryFile(ref, cwd) {
342
+ const requireFromCwd = createRequire(path.join(cwd, "matterhorn-app-loader.cjs"));
343
+ try {
344
+ return requireFromCwd.resolve(ref);
345
+ } catch (cwdError) {
346
+ try {
347
+ return require.resolve(ref);
348
+ } catch {
349
+ throw cwdError;
350
+ }
351
+ }
352
+ }
353
+
354
+ function moduleExportsForEntry(entryFile) {
355
+ if (entryFile.endsWith(".json")) return readJsonFile(entryFile);
356
+ delete require.cache[require.resolve(entryFile)];
357
+ return require(entryFile);
358
+ }
359
+
360
+ let basePluginRegistry;
361
+
362
+ function schemaApi() {
363
+ return require("@mh-gg/schema");
364
+ }
365
+
366
+ function configuredBasePluginRegistry() {
367
+ if (!basePluginRegistry) {
368
+ const { createMicroPluginRegistry } = schemaApi();
369
+ const { registryRecords } = require("@mh-gg/base-plugins/composer/registry");
370
+ basePluginRegistry = createMicroPluginRegistry(registryRecords());
371
+ }
372
+ return basePluginRegistry;
373
+ }
374
+
375
+ function schemaModelHostPlugin(app, plugin) {
376
+ const { createSchemaDefinedHostPlugin } = schemaApi();
377
+ const { hashCanonical } = require("@mh-gg/base");
378
+ const composition = plugin.composition || {
379
+ kind: "matterhorn.app-composition.schema",
380
+ schemaVersion: 1,
381
+ app: {
382
+ id: app.id,
383
+ version: app.version || app.appPack?.version || "0.1.0",
384
+ name: app.name
385
+ },
386
+ primaryPlugin: {
387
+ id: plugin.id,
388
+ version: plugin.version || app.version || app.appPack?.version || "0.1.0",
389
+ model: plugin.model
390
+ },
391
+ plugins: plugin.plugins || [],
392
+ actions: plugin.actions || [],
393
+ views: plugin.views || [],
394
+ routes: plugin.routes || []
395
+ };
396
+ const hostPlugin = createSchemaDefinedHostPlugin(composition);
397
+ hostPlugin.stateSchemaHash = hostPlugin.stateSchemaHash || hashCanonical(hostPlugin.stateSchemaDescriptor || hostPlugin.schemas?.state?.descriptor || {});
398
+ hostPlugin.operationSchemaHash = hostPlugin.operationSchemaHash || hashCanonical(hostPlugin.operationSchemaDescriptor || {});
399
+ return hostPlugin;
400
+ }
401
+
402
+ function hydrateJsonHostPlugin(plugin, app) {
403
+ if (plugin?.kind === "matterhorn.schema-model-host-plugin") return schemaModelHostPlugin(app, plugin);
404
+ if (plugin?.kind === "matterhorn.registry-host-plugin") return registryHostPlugin(app, plugin);
405
+ throw new Error("JSON Matterhorn app manifests can only declare schema-model-host-plugin or registry-host-plugin entries; app reducer modules are not loadable from schema manifests");
406
+ }
407
+
408
+ function registryHostPlugin(app, plugin) {
409
+ const { resolveCompositionHostPluginEntries } = schemaApi();
410
+ const composition = app.appPack?.composition || app.compositionSchema;
411
+ const entries = resolveCompositionHostPluginEntries(composition, configuredBasePluginRegistry());
412
+ const entry = entries.find((candidate) => candidate.schema.key === plugin.key || candidate.plugin.id === plugin.id);
413
+ if (!entry) throw new Error(`JSON Matterhorn app bundle references unknown registry plugin ${plugin.key || plugin.id}`);
414
+ if (plugin.config === undefined) return entry.plugin;
415
+ return { ...entry.plugin, config: plugin.config };
416
+ }
417
+
418
+ function normalizeJsonAppPaths(app, source) {
419
+ if (!source.entryFile.endsWith(".json") && app?.kind !== "matterhorn.app-bundle") return app;
420
+ const manifestDir = path.dirname(source.entryFile);
421
+ const frontend = app.frontend ? { ...app.frontend } : undefined;
422
+ if (frontend) frontend.root = path.resolve(manifestDir, frontend.root || ".");
423
+ return {
424
+ ...app,
425
+ ...(frontend ? { frontend } : {}),
426
+ hostPlugins: Array.isArray(app.hostPlugins)
427
+ ? app.hostPlugins.map((plugin) => hydrateJsonHostPlugin(plugin, app))
428
+ : app.hostPlugins
429
+ };
430
+ }
431
+
432
+ function moduleExportsForRef(ref, options = {}) {
433
+ const source = resolveMatterhornAppEntry(ref, options);
434
+ if (!source.entryFile.endsWith(".json") && !source.directLocal && !trustedAppCodeAllowed(options) && !matchingTrustRecord(source, options)) {
435
+ throw new Error("Only direct local file app refs may execute app JavaScript by default. Use static JSON app bundles for package/git refs.");
436
+ }
437
+ return {
438
+ exports: moduleExportsForEntry(source.entryFile),
439
+ source
440
+ };
441
+ }
442
+
443
+ function gitCacheRoot(options = {}) {
444
+ return options.cacheDir || process.env.MATTERHORN_APP_CACHE_DIR || path.join(os.tmpdir(), "matterhorn-apps");
445
+ }
446
+
447
+ const GIT_COMMIT_RE = /^[0-9a-f]{40}$/i;
448
+
449
+ function parseGitAppRef(ref) {
450
+ const raw = ref.slice("git+".length);
451
+ const fragmentIndex = raw.lastIndexOf("#");
452
+ if (fragmentIndex === -1) return { source: raw, refish: "" };
453
+ return {
454
+ source: raw.slice(0, fragmentIndex),
455
+ refish: raw.slice(fragmentIndex + 1)
456
+ };
457
+ }
458
+
459
+ function gitCloneSourceArgs(source) {
460
+ if (!source.startsWith("file:")) return [source];
461
+ return ["--local", fileURLToPath(source)];
462
+ }
463
+
464
+ function unsafeGitAppRefsAllowed(options = {}) {
465
+ return options.allowUnsafeGitAppRef === true || process.env.MATTERHORN_UNSAFE_GIT_APP_REF === "1";
466
+ }
467
+
468
+ function gitAppCacheMetadataFile(target) {
469
+ return path.join(target, ".git", "matterhorn-app-cache.json");
470
+ }
471
+
472
+ function readGitAppCacheMetadata(target) {
473
+ return readJsonFileIfPresent(gitAppCacheMetadataFile(target));
474
+ }
475
+
476
+ function writeGitAppCacheMetadata(target, metadata) {
477
+ fs.writeFileSync(gitAppCacheMetadataFile(target), `${JSON.stringify(metadata, null, 2)}\n`);
478
+ }
479
+
480
+ function gitHeadCommit(target) {
481
+ return runGit(["-C", target, "rev-parse", "HEAD"], process.cwd()).toLowerCase();
482
+ }
483
+
484
+ function gitWorkingTreeStatus(target) {
485
+ return runGit(["-C", target, "status", "--porcelain", "--untracked-files=all"], process.cwd());
486
+ }
487
+
488
+ function verifyGitAppCache(target, expectedCommit) {
489
+ const metadata = readGitAppCacheMetadata(target);
490
+ const actualCommit = gitHeadCommit(target);
491
+ const normalizedCommit = expectedCommit.toLowerCase();
492
+ if (metadata?.commit !== normalizedCommit || actualCommit !== normalizedCommit) {
493
+ throw new Error(`Git app cache ${target} is not at the pinned commit ${normalizedCommit}; remove the cache and load again`);
494
+ }
495
+ const status = gitWorkingTreeStatus(target);
496
+ if (status.length > 0) {
497
+ throw new Error(`Git app cache ${target} has local modifications; refusing to execute a tampered checkout`);
498
+ }
499
+ }
500
+
501
+ function warnUnsafeGitAppRef(ref, options = {}) {
502
+ const message = `WARNING: ${ref} is an unsafe dev git app ref. Pin git app refs to a commit SHA for deterministic loading.`;
503
+ if (typeof options.io?.warn === "function") options.io.warn(message);
504
+ else if (typeof options.warn === "function") options.warn(message);
505
+ }
506
+
507
+ function gitAppCachePath(ref, options = {}) {
508
+ const parsed = parseGitAppRef(ref);
509
+ const pinnedCommit = parsed.refish && GIT_COMMIT_RE.test(parsed.refish)
510
+ ? parsed.refish.toLowerCase()
511
+ : "";
512
+ if (!pinnedCommit && !unsafeGitAppRefsAllowed(options)) {
513
+ throw new Error("Git app refs must pin a 40-character commit SHA. Set allowUnsafeGitAppRef: true only for local development branch refs.");
514
+ }
515
+ const cacheRoot = gitCacheRoot(options);
516
+ const cacheIdentity = pinnedCommit ? `${parsed.source}#${pinnedCommit}` : `unsafe:${ref}`;
517
+ const cacheKey = createHash("sha256").update(cacheIdentity).digest("hex").slice(0, 24);
518
+ const target = path.join(cacheRoot, cacheKey);
519
+ if (!directoryExists(target)) {
520
+ fs.mkdirSync(cacheRoot, { recursive: true });
521
+ const cloneSourceArgs = gitCloneSourceArgs(parsed.source);
522
+ if (pinnedCommit) {
523
+ runGit(["clone", "--no-checkout", ...cloneSourceArgs, target], process.cwd());
524
+ runGit(["-C", target, "checkout", "--detach", pinnedCommit], process.cwd());
525
+ } else {
526
+ warnUnsafeGitAppRef(ref, options);
527
+ const args = parsed.refish
528
+ ? ["clone", "--depth", "1", "--branch", parsed.refish, ...cloneSourceArgs, target]
529
+ : ["clone", "--depth", "1", ...cloneSourceArgs, target];
530
+ runGit(args, process.cwd());
531
+ }
532
+ writeGitAppCacheMetadata(target, {
533
+ schemaVersion: 1,
534
+ ref,
535
+ source: parsed.source,
536
+ commit: gitHeadCommit(target),
537
+ unsafeDevRef: pinnedCommit ? false : true
538
+ });
539
+ }
540
+ const metadata = readGitAppCacheMetadata(target);
541
+ const expectedCommit = pinnedCommit || metadata?.commit;
542
+ if (!expectedCommit) {
543
+ throw new Error(`Git app cache ${target} does not record the resolved commit; remove the cache and load again`);
544
+ }
545
+ verifyGitAppCache(target, expectedCommit);
546
+ return target;
547
+ }
548
+
549
+ function runGit(args, cwd) {
550
+ const result = spawnSync("git", args, {
551
+ cwd,
552
+ encoding: "utf8",
553
+ stdio: ["ignore", "pipe", "pipe"],
554
+ windowsHide: true
555
+ });
556
+ if (result.error) throw new Error(`Git app ref load failed: ${result.error.message}`);
557
+ if (result.status === 0) return (result.stdout || "").trim();
558
+ const message = (result.stderr || result.stdout || "").trim();
559
+ throw new Error(`Git app ref load failed: ${message || `git ${args.join(" ")} exited ${result.status}`}`);
560
+ }
561
+
562
+ function defineAppExport(exports) {
563
+ if (exports?.toMatterhornBundle) return exports;
564
+ if (exports?.default?.toMatterhornBundle) return exports.default;
565
+ return undefined;
566
+ }
567
+
568
+ function appDescriptorFromExports(exports) {
569
+ const sdkApp = defineAppExport(exports);
570
+ if (sdkApp) return sdkApp.toMatterhornBundle();
571
+ return exports?.matterhornApp || exports?.default || exports;
572
+ }
573
+
574
+ function normalizeMatterhornApp(ref, exports) {
575
+ const app = appDescriptorFromExports(exports);
576
+ if (!app || typeof app !== "object" || Array.isArray(app)) {
577
+ throw new Error(`Matterhorn app ${ref} did not export an app descriptor`);
578
+ }
579
+ if (!app.id || !app.name) throw new Error(`Matterhorn app ${ref} must export id and name`);
580
+ if (!app.appPack) throw new Error(`Matterhorn app ${ref} must export appPack`);
581
+ return {
582
+ ...app,
583
+ ref
584
+ };
585
+ }
586
+
587
+ function appHostRunner(app) {
588
+ return app.host?.runner || app.deployment?.host?.runner || "matterhorn-example-host";
589
+ }
590
+
591
+ function normalizeLaunch(ref, launch) {
592
+ if (launch === undefined) return undefined;
593
+ if (!launch || typeof launch !== "object" || Array.isArray(launch)) {
594
+ throw new Error(`Matterhorn deployment ${ref} launch must be an object`);
595
+ }
596
+ if (typeof launch.command !== "string" || launch.command.length === 0) {
597
+ throw new Error(`Matterhorn deployment ${ref} launch.command is required`);
598
+ }
599
+ if (launch.args !== undefined && !Array.isArray(launch.args)) {
600
+ throw new Error(`Matterhorn deployment ${ref} launch.args must be an array`);
601
+ }
602
+ return {
603
+ command: launch.command,
604
+ args: (launch.args || []).map((arg) => String(arg))
605
+ };
606
+ }
607
+
608
+ function normalizeMatterhornDeployment(ref, app) {
609
+ if (!app || typeof app !== "object" || Array.isArray(app)) {
610
+ throw new Error(`Matterhorn deployment ${ref} requires an app descriptor`);
611
+ }
612
+ if (!app.id || !app.name) throw new Error(`Matterhorn deployment ${ref} must include id and name`);
613
+ if (!app.appPack) throw new Error(`Matterhorn deployment ${ref} must include appPack`);
614
+ if (!app.hostPack) throw new Error(`Matterhorn deployment ${ref} must include hostPack`);
615
+ if (!Array.isArray(app.hostPlugins) || app.hostPlugins.length === 0) {
616
+ throw new Error(`Matterhorn deployment ${ref} must include at least one host plugin`);
617
+ }
618
+ if (!Array.isArray(app.playerPacks) || app.playerPacks.length === 0) {
619
+ throw new Error(`Matterhorn deployment ${ref} must include at least one player pack`);
620
+ }
621
+
622
+ const declared = app.deployment && typeof app.deployment === "object" && !Array.isArray(app.deployment)
623
+ ? app.deployment
624
+ : {};
625
+ if (declared.kind !== undefined && declared.kind !== "matterhorn.self-contained-app") {
626
+ throw new Error(`Matterhorn deployment ${ref} kind must be matterhorn.self-contained-app`);
627
+ }
628
+ return {
629
+ kind: "matterhorn.self-contained-app",
630
+ id: app.id,
631
+ name: app.name,
632
+ version: app.version || app.appPack.version,
633
+ ref: app.ref || ref,
634
+ appPack: app.appPack,
635
+ hostPack: app.hostPack,
636
+ hostPlugins: app.hostPlugins,
637
+ playerPacks: app.playerPacks,
638
+ frontend: app.frontend,
639
+ host: {
640
+ runner: appHostRunner(app),
641
+ ...(declared.host || {})
642
+ },
643
+ relay: {
644
+ mode: "embedded",
645
+ autoStart: true,
646
+ acceptsPeerRelays: true,
647
+ ...(declared.relay || {})
648
+ },
649
+ frontendDelivery: {
650
+ mode: app.frontend?.bundle ? "relay-chunks" : "external",
651
+ ...(declared.frontendDelivery || declared.frontend || {})
652
+ },
653
+ launch: normalizeLaunch(ref, declared.launch),
654
+ trust: app.trust
655
+ };
656
+ }
657
+
658
+ function loadMatterhornApp(ref, options = {}) {
659
+ if (!ref || typeof ref !== "string") throw new Error("Matterhorn app ref is required");
660
+ const loaded = moduleExportsForRef(ref, options);
661
+ const descriptor = appDescriptorFromExports(loaded.exports);
662
+ const app = normalizeMatterhornApp(ref, normalizeJsonAppPaths(descriptor, loaded.source));
663
+ const trust = appTrustForSource(loaded.source, options);
664
+ assertProductionAppSourcePolicy(app, loaded.source, trust, options);
665
+ return {
666
+ ...app,
667
+ sourceEntry: loaded.source.entryFile,
668
+ sourceRoot: loaded.source.sourceRoot,
669
+ sourceKind: defineAppExport(loaded.exports) ? "defineApp" : (loaded.source.entryFile.endsWith(".json") ? "json" : "module"),
670
+ trust,
671
+ bundleFile: path.join(app.frontend?.root || loaded.source.sourceRoot, `${app.id}.json`)
672
+ };
673
+ }
674
+
675
+ function loadMatterhornDeployment(ref, options = {}) {
676
+ return normalizeMatterhornDeployment(ref, loadMatterhornApp(ref, options));
677
+ }
678
+
679
+ function emitAppForRef(ref, options = {}) {
680
+ if (!ref || typeof ref !== "string") throw new Error("Matterhorn app ref is required");
681
+ const source = resolveMatterhornAppEntry(ref, options);
682
+ const exports = moduleExportsForEntry(source.entryFile);
683
+ const sdkApp = defineAppExport(exports);
684
+ if (!sdkApp?.emit) throw new Error(`Matterhorn app ${ref} does not export a defineApp() result`);
685
+ return sdkApp.emit({
686
+ ...(options.bundleOptions || {}),
687
+ ...(options.outDir === undefined ? {} : { outDir: options.outDir })
688
+ });
689
+ }
690
+
691
+ module.exports = {
692
+ emitAppForRef,
693
+ loadMatterhornApp,
694
+ loadMatterhornDeployment,
695
+ normalizeMatterhornApp,
696
+ normalizeMatterhornDeployment,
697
+ resolveMatterhornAppEntry,
698
+ canonicalAppRef,
699
+ gitAppRefsAllowed,
700
+ appTrustForSource,
701
+ assertProductionAppSourcePolicy
702
+ };
@@ -0,0 +1,18 @@
1
+ const { spawn } = require("node:child_process");
2
+
3
+ function killChildTree(child) {
4
+ if (!child?.pid) return;
5
+ if (process.platform === "win32") {
6
+ child.kill();
7
+ const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
8
+ stdio: "ignore",
9
+ windowsHide: true
10
+ });
11
+ killer.unref();
12
+ return;
13
+ }
14
+ child.kill("SIGTERM");
15
+ setTimeout(() => child.kill("SIGKILL"), 1000).unref();
16
+ }
17
+
18
+ module.exports = { killChildTree };