spawnfile 0.1.1 → 0.1.2
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 +79 -396
- package/dist/cli/modelCommands.d.ts +3 -0
- package/dist/cli/modelCommands.js +68 -0
- package/dist/cli/runCli.d.ts +6 -1
- package/dist/cli/runCli.js +12 -67
- package/dist/cli/runtimeCommands.d.ts +3 -0
- package/dist/cli/runtimeCommands.js +20 -0
- package/dist/cli/surfaceCommands.d.ts +3 -0
- package/dist/cli/surfaceCommands.js +98 -0
- package/dist/compiler/agentSurfaces.js +51 -5
- package/dist/compiler/buildCompilePlan.js +36 -40
- package/dist/compiler/buildCompilePlanRuntime.d.ts +14 -0
- package/dist/compiler/buildCompilePlanRuntime.js +39 -0
- package/dist/compiler/buildCompilePlanTeams.d.ts +5 -0
- package/dist/compiler/buildCompilePlanTeams.js +38 -0
- package/dist/compiler/compilePlanHelpers.js +4 -1
- package/dist/compiler/compileProject.js +62 -13
- package/dist/compiler/compileProjectSupport.d.ts +17 -0
- package/dist/compiler/compileProjectSupport.js +136 -0
- package/dist/compiler/containerArtifacts.d.ts +6 -1
- package/dist/compiler/containerArtifacts.js +26 -4
- package/dist/compiler/containerArtifactsPlans.js +16 -1
- package/dist/compiler/containerArtifactsRender.d.ts +4 -2
- package/dist/compiler/containerArtifactsRender.js +21 -126
- package/dist/compiler/containerArtifactsTypes.d.ts +7 -0
- package/dist/compiler/containerEntrypointRender.d.ts +12 -0
- package/dist/compiler/containerEntrypointRender.js +186 -0
- package/dist/compiler/index.d.ts +2 -0
- package/dist/compiler/index.js +2 -0
- package/dist/compiler/interactiveSurfaceScopes.d.ts +2 -0
- package/dist/compiler/interactiveSurfaceScopes.js +21 -0
- package/dist/compiler/moltnetArtifacts.d.ts +27 -0
- package/dist/compiler/moltnetArtifacts.js +204 -0
- package/dist/compiler/moltnetBinaries.d.ts +4 -0
- package/dist/compiler/moltnetBinaries.js +103 -0
- package/dist/compiler/moltnetClientConfig.d.ts +11 -0
- package/dist/compiler/moltnetClientConfig.js +89 -0
- package/dist/compiler/moltnetRepresentativeResolution.d.ts +16 -0
- package/dist/compiler/moltnetRepresentativeResolution.js +86 -0
- package/dist/compiler/moltnetResolution.d.ts +3 -0
- package/dist/compiler/moltnetResolution.js +201 -0
- package/dist/compiler/runProject.js +1 -1
- package/dist/compiler/surfaceDefinitions.d.ts +55 -0
- package/dist/compiler/surfaceDefinitions.js +204 -0
- package/dist/compiler/teamContextHelpers.d.ts +18 -0
- package/dist/compiler/teamContextHelpers.js +112 -0
- package/dist/compiler/teamContextSupport.d.ts +4 -0
- package/dist/compiler/teamContextSupport.js +264 -0
- package/dist/compiler/teamContextSupport.testHelpers.d.ts +16 -0
- package/dist/compiler/teamContextSupport.testHelpers.js +68 -0
- package/dist/compiler/teamContextTypes.d.ts +28 -0
- package/dist/compiler/teamContextTypes.js +1 -0
- package/dist/compiler/teamRoster.d.ts +12 -0
- package/dist/compiler/teamRoster.js +48 -0
- package/dist/compiler/teamRosterEntries.d.ts +13 -0
- package/dist/compiler/teamRosterEntries.js +230 -0
- package/dist/compiler/teamRosterTypes.d.ts +45 -0
- package/dist/compiler/teamRosterTypes.js +1 -0
- package/dist/compiler/types.d.ts +72 -6
- package/dist/compiler/updateProjectRuntime.d.ts +9 -0
- package/dist/compiler/updateProjectRuntime.js +67 -0
- package/dist/compiler/updateProjectSurfaces.d.ts +8 -0
- package/dist/compiler/updateProjectSurfaces.js +106 -0
- package/dist/manifest/loadManifest.js +4 -4
- package/dist/manifest/renderSpawnfile.js +74 -8
- package/dist/manifest/scaffold.js +1 -3
- package/dist/manifest/schemas.d.ts +227 -17
- package/dist/manifest/schemas.js +62 -20
- package/dist/manifest/surfaceSchemas.d.ts +154 -0
- package/dist/manifest/surfaceSchemas.js +77 -5
- package/dist/runtime/common.js +3 -0
- package/dist/runtime/openclaw/adapter.js +38 -5
- package/dist/runtime/openclaw/moltnet.d.ts +12 -0
- package/dist/runtime/openclaw/moltnet.js +124 -0
- package/dist/runtime/openclaw/surfaces.js +3 -0
- package/dist/runtime/picoclaw/adapter.js +27 -8
- package/dist/runtime/picoclaw/pico.d.ts +2 -0
- package/dist/runtime/picoclaw/pico.js +2 -0
- package/dist/runtime/picoclaw/surfaces.js +11 -0
- package/dist/runtime/tinyclaw/adapter.js +22 -8
- package/dist/runtime/tinyclaw/runAuth.js +28 -1
- package/dist/runtime/tinyclaw/surfaces.js +8 -0
- package/dist/runtime/types.d.ts +11 -0
- package/package.json +4 -2
- package/runtimes.yaml +4 -4
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
const MOLTNET_SERVER_DATA_DIRECTORY = "/var/lib/spawnfile/moltnet/servers";
|
|
3
|
+
const shellQuote = (value) => `'${value.replace(/'/g, `'\"'\"'`)}'`;
|
|
4
|
+
const pathSafeSegment = (value) => value.replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "server";
|
|
5
|
+
const createMoltnetServerDataPath = (serverId) => `${MOLTNET_SERVER_DATA_DIRECTORY}/${pathSafeSegment(serverId)}.db`;
|
|
6
|
+
const createEnvironmentAssignments = (plan) => {
|
|
7
|
+
const envAssignments = [];
|
|
8
|
+
if (plan.instancePaths.homePath) {
|
|
9
|
+
envAssignments.push(`HOME=${shellQuote(plan.instancePaths.homePath)}`);
|
|
10
|
+
}
|
|
11
|
+
if (plan.runtimeName === "tinyclaw" &&
|
|
12
|
+
plan.instancePaths.homePath &&
|
|
13
|
+
(plan.modelAuthMethods.openai === "api_key" || plan.modelAuthMethods.openai === "codex")) {
|
|
14
|
+
envAssignments.push(`CODEX_HOME=${shellQuote(path.posix.join(plan.instancePaths.homePath, ".codex"))}`);
|
|
15
|
+
}
|
|
16
|
+
if (plan.meta.homeEnv && plan.instancePaths.homePath) {
|
|
17
|
+
envAssignments.push(`${plan.meta.homeEnv}=${shellQuote(plan.instancePaths.homePath)}`);
|
|
18
|
+
}
|
|
19
|
+
if (plan.meta.configPathEnv) {
|
|
20
|
+
envAssignments.push(`${plan.meta.configPathEnv}=${shellQuote(plan.instancePaths.configPath)}`);
|
|
21
|
+
}
|
|
22
|
+
if (plan.meta.portEnv && plan.port) {
|
|
23
|
+
envAssignments.push(`${plan.meta.portEnv}=${shellQuote(String(plan.port))}`);
|
|
24
|
+
}
|
|
25
|
+
for (const [name, value] of Object.entries(plan.meta.staticEnv ?? {}).sort(([left], [right]) => left.localeCompare(right))) {
|
|
26
|
+
envAssignments.push(`${name}=${shellQuote(value)}`);
|
|
27
|
+
}
|
|
28
|
+
return envAssignments;
|
|
29
|
+
};
|
|
30
|
+
const createEnvFileWrites = (plan) => plan.envFiles.map((binding) => `write_env_file ${shellQuote(binding.envName)} ${shellQuote(binding.filePath)}`);
|
|
31
|
+
const createConfigEnvWrites = (plan) => (plan.configEnvBindings ?? []).map((binding) => `apply_json_env_value ${shellQuote(plan.instancePaths.configPath)} ${shellQuote(binding.envName)} ${shellQuote(binding.jsonPath)}`);
|
|
32
|
+
const createAuthSetupCommands = (plan) => {
|
|
33
|
+
if (plan.runtimeName !== "tinyclaw" ||
|
|
34
|
+
!plan.instancePaths.homePath ||
|
|
35
|
+
plan.modelAuthMethods.openai !== "api_key") {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
return [`configure_codex_api_key_auth ${shellQuote(plan.instancePaths.homePath)}`];
|
|
39
|
+
};
|
|
40
|
+
const resolveStartCommand = (plan) => plan.meta.startCommand
|
|
41
|
+
.map((token) => token
|
|
42
|
+
.replaceAll("<runtime-root>", plan.runtimeRoot)
|
|
43
|
+
.replaceAll("<port>", plan.port ? String(plan.port) : ""))
|
|
44
|
+
.filter((token) => token.length > 0);
|
|
45
|
+
const createRuntimeReadinessWait = (plan) => {
|
|
46
|
+
if (plan.runtimeName !== "openclaw" || !plan.port) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
return [
|
|
50
|
+
"attempts=0",
|
|
51
|
+
`until curl -sf ${shellQuote(`http://127.0.0.1:${plan.port}/healthz`)} >/dev/null; do`,
|
|
52
|
+
" attempts=$((attempts + 1))",
|
|
53
|
+
' if [ "$attempts" -ge 180 ]; then',
|
|
54
|
+
` echo ${shellQuote(`Timed out waiting for ${plan.runtimeName} on port ${plan.port}`)} >&2`,
|
|
55
|
+
" exit 1",
|
|
56
|
+
" fi",
|
|
57
|
+
" sleep 1",
|
|
58
|
+
"done",
|
|
59
|
+
""
|
|
60
|
+
];
|
|
61
|
+
};
|
|
62
|
+
export const renderEntrypoint = (runtimePlans, requiredSecrets, options = {}) => {
|
|
63
|
+
const lines = [
|
|
64
|
+
"#!/usr/bin/env bash",
|
|
65
|
+
"set -euo pipefail",
|
|
66
|
+
"",
|
|
67
|
+
"require_env() {",
|
|
68
|
+
' local name=\"$1\"',
|
|
69
|
+
' if [ -z \"${!name:-}\" ]; then',
|
|
70
|
+
' echo \"Missing required env: $name\" >&2',
|
|
71
|
+
" exit 1",
|
|
72
|
+
" fi",
|
|
73
|
+
"}",
|
|
74
|
+
"",
|
|
75
|
+
"require_file() {",
|
|
76
|
+
' local target=\"$1\"',
|
|
77
|
+
' if [ ! -f \"$target\" ]; then',
|
|
78
|
+
' echo \"Missing required file: $target\" >&2',
|
|
79
|
+
" exit 1",
|
|
80
|
+
" fi",
|
|
81
|
+
"}",
|
|
82
|
+
"",
|
|
83
|
+
"write_env_file() {",
|
|
84
|
+
' local name=\"$1\"',
|
|
85
|
+
' local target=\"$2\"',
|
|
86
|
+
' if [ -z \"${!name:-}\" ]; then',
|
|
87
|
+
" return",
|
|
88
|
+
" fi",
|
|
89
|
+
' mkdir -p \"$(dirname \"$target\")\"',
|
|
90
|
+
' printf %s \"${!name:-}\" > \"$target\"',
|
|
91
|
+
"}",
|
|
92
|
+
"",
|
|
93
|
+
"apply_json_env_value() {",
|
|
94
|
+
' local target=\"$1\"',
|
|
95
|
+
' local name=\"$2\"',
|
|
96
|
+
' local json_path=\"$3\"',
|
|
97
|
+
' if [ -z \"${!name:-}\" ]; then',
|
|
98
|
+
" return",
|
|
99
|
+
" fi",
|
|
100
|
+
" python3 - \"$target\" \"$name\" \"$json_path\" <<'PY'",
|
|
101
|
+
"import json",
|
|
102
|
+
"import os",
|
|
103
|
+
"import sys",
|
|
104
|
+
"",
|
|
105
|
+
"target_path = sys.argv[1]",
|
|
106
|
+
"env_name = sys.argv[2]",
|
|
107
|
+
"json_path = sys.argv[3].split('.')",
|
|
108
|
+
"value = os.environ.get(env_name)",
|
|
109
|
+
"if value is None:",
|
|
110
|
+
" raise SystemExit(0)",
|
|
111
|
+
"",
|
|
112
|
+
"with open(target_path, encoding='utf-8') as handle:",
|
|
113
|
+
" data = json.load(handle)",
|
|
114
|
+
"",
|
|
115
|
+
"cursor = data",
|
|
116
|
+
"for part in json_path[:-1]:",
|
|
117
|
+
" child = cursor.get(part)",
|
|
118
|
+
" if not isinstance(child, dict):",
|
|
119
|
+
" child = {}",
|
|
120
|
+
" cursor[part] = child",
|
|
121
|
+
" cursor = child",
|
|
122
|
+
"",
|
|
123
|
+
"cursor[json_path[-1]] = value",
|
|
124
|
+
"",
|
|
125
|
+
"with open(target_path, 'w', encoding='utf-8') as handle:",
|
|
126
|
+
" json.dump(data, handle, indent=2)",
|
|
127
|
+
" handle.write('\\n')",
|
|
128
|
+
"PY",
|
|
129
|
+
"}",
|
|
130
|
+
"",
|
|
131
|
+
"configure_codex_api_key_auth() {",
|
|
132
|
+
' local home_path="$1"',
|
|
133
|
+
' if [ -z "${OPENAI_API_KEY:-}" ]; then',
|
|
134
|
+
" return",
|
|
135
|
+
" fi",
|
|
136
|
+
' mkdir -p "$home_path/.codex"',
|
|
137
|
+
' printf "%s\\n" "${OPENAI_API_KEY:-}" | HOME="$home_path" CODEX_HOME="$home_path/.codex" codex login --with-api-key >/dev/null',
|
|
138
|
+
"}",
|
|
139
|
+
""
|
|
140
|
+
];
|
|
141
|
+
lines.push('if [ -n "${OPENCLAW_GATEWAY_TOKEN:-}" ] && [ -z "${OPENCLAW_HOOKS_TOKEN:-}" ]; then', ' export OPENCLAW_HOOKS_TOKEN="hooks-${OPENCLAW_GATEWAY_TOKEN}"', "fi", "");
|
|
142
|
+
for (const secretName of requiredSecrets) {
|
|
143
|
+
lines.push(`require_env ${shellQuote(secretName)}`);
|
|
144
|
+
}
|
|
145
|
+
if (requiredSecrets.length > 0) {
|
|
146
|
+
lines.push("");
|
|
147
|
+
}
|
|
148
|
+
const moltnetServerPlans = options.moltnet?.serverPlans ?? [];
|
|
149
|
+
const moltnetBridgePlans = options.moltnet?.bridgePlans ?? [];
|
|
150
|
+
if (runtimePlans.length === 1 &&
|
|
151
|
+
moltnetServerPlans.length === 0 &&
|
|
152
|
+
moltnetBridgePlans.length === 0) {
|
|
153
|
+
const plan = runtimePlans[0];
|
|
154
|
+
const commandTokens = resolveStartCommand(plan);
|
|
155
|
+
const envAssignments = createEnvironmentAssignments(plan);
|
|
156
|
+
lines.push(`mkdir -p ${shellQuote(plan.instancePaths.workspacePath)}`, `require_file ${shellQuote(plan.instancePaths.configPath)}`, ...createEnvFileWrites(plan), ...createConfigEnvWrites(plan), ...createAuthSetupCommands(plan), `${envAssignments.join(" ")} exec ${commandTokens.map(shellQuote).join(" ")}`);
|
|
157
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
158
|
+
}
|
|
159
|
+
lines.push("PIDS=()", "", "terminate_children() {", ' for pid in "${PIDS[@]:-}"; do', ' kill "$pid" 2>/dev/null || true', " done", "}", "", "trap terminate_children INT TERM EXIT", "");
|
|
160
|
+
if (moltnetServerPlans.length > 0) {
|
|
161
|
+
lines.push(`mkdir -p ${shellQuote(MOLTNET_SERVER_DATA_DIRECTORY)}`, "");
|
|
162
|
+
}
|
|
163
|
+
for (const serverPlan of moltnetServerPlans) {
|
|
164
|
+
lines.push(`MOLTNET_DATA_PATH=${shellQuote(createMoltnetServerDataPath(serverPlan.id))} MOLTNET_LISTEN_ADDR=${shellQuote(`:${serverPlan.port}`)} MOLTNET_NETWORK_ID=${shellQuote(serverPlan.networkId)} MOLTNET_NETWORK_NAME=${shellQuote(serverPlan.name)} /usr/local/bin/moltnet &`, 'PIDS+=("$!")', "");
|
|
165
|
+
}
|
|
166
|
+
for (const plan of runtimePlans) {
|
|
167
|
+
const commandTokens = resolveStartCommand(plan);
|
|
168
|
+
const envAssignments = createEnvironmentAssignments(plan);
|
|
169
|
+
lines.push(`mkdir -p ${shellQuote(plan.instancePaths.workspacePath)}`, `require_file ${shellQuote(plan.instancePaths.configPath)}`, ...createEnvFileWrites(plan), ...createConfigEnvWrites(plan), ...createAuthSetupCommands(plan), `${envAssignments.join(" ")} ${commandTokens.map(shellQuote).join(" ")} &`, 'PIDS+=("$!")', "", ...createRuntimeReadinessWait(plan));
|
|
170
|
+
}
|
|
171
|
+
for (const serverPlan of moltnetServerPlans) {
|
|
172
|
+
lines.push(`until curl -sf ${shellQuote(`http://127.0.0.1:${serverPlan.port}/healthz`)} >/dev/null; do sleep 1; done`);
|
|
173
|
+
for (const room of serverPlan.rooms) {
|
|
174
|
+
lines.push(`curl -sf -X POST -H 'Content-Type: application/json' -d ${shellQuote(JSON.stringify({
|
|
175
|
+
id: room.id,
|
|
176
|
+
members: room.members
|
|
177
|
+
}))} ${shellQuote(`http://127.0.0.1:${serverPlan.port}/v1/rooms`)} >/dev/null || true`);
|
|
178
|
+
}
|
|
179
|
+
lines.push("");
|
|
180
|
+
}
|
|
181
|
+
for (const bridgePlan of moltnetBridgePlans) {
|
|
182
|
+
lines.push(`/usr/local/bin/moltnet bridge ${shellQuote(bridgePlan.configPath)} &`, 'PIDS+=("$!")', "");
|
|
183
|
+
}
|
|
184
|
+
lines.push('if [ "${#PIDS[@]}" -eq 0 ]; then', ' echo "No runtime targets were generated for this compile output" >&2', " exit 1", "fi", "", "status=0", 'for pid in "${PIDS[@]}"; do', ' if ! wait "$pid"; then', " status=1", " fi", "done", "", 'exit "$status"');
|
|
185
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
186
|
+
};
|
package/dist/compiler/index.d.ts
CHANGED
package/dist/compiler/index.js
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const listMoltnetAttachmentScopes = (attachment) => {
|
|
2
|
+
const scopes = Object.keys(attachment.rooms ?? {})
|
|
3
|
+
.sort((left, right) => left.localeCompare(right))
|
|
4
|
+
.map((roomId) => `moltnet:${attachment.network}:room:${roomId}`);
|
|
5
|
+
if (attachment.dms?.enabled) {
|
|
6
|
+
scopes.push(`moltnet:${attachment.network}:dms`);
|
|
7
|
+
}
|
|
8
|
+
return scopes;
|
|
9
|
+
};
|
|
10
|
+
export const listInteractiveSurfaceScopes = (surfaces) => {
|
|
11
|
+
if (!surfaces) {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
return [
|
|
15
|
+
...(surfaces.discord ? ["discord"] : []),
|
|
16
|
+
...(surfaces.moltnet?.flatMap(listMoltnetAttachmentScopes) ?? []),
|
|
17
|
+
...(surfaces.slack ? ["slack"] : []),
|
|
18
|
+
...(surfaces.telegram ? ["telegram"] : []),
|
|
19
|
+
...(surfaces.whatsapp ? ["whatsapp"] : [])
|
|
20
|
+
];
|
|
21
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { EmittedFile } from "../runtime/index.js";
|
|
2
|
+
import type { CompilePlan } from "./types.js";
|
|
3
|
+
export interface MoltnetServerPlan {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
networkId: string;
|
|
7
|
+
port: number;
|
|
8
|
+
rooms: Array<{
|
|
9
|
+
id: string;
|
|
10
|
+
members: string[];
|
|
11
|
+
}>;
|
|
12
|
+
teamSource: string;
|
|
13
|
+
}
|
|
14
|
+
export interface MoltnetBridgePlan {
|
|
15
|
+
agentId: string;
|
|
16
|
+
configPath: string;
|
|
17
|
+
networkId: string;
|
|
18
|
+
runtime: string;
|
|
19
|
+
}
|
|
20
|
+
export interface MoltnetArtifacts {
|
|
21
|
+
bridgePlans: MoltnetBridgePlan[];
|
|
22
|
+
files: EmittedFile[];
|
|
23
|
+
ports: number[];
|
|
24
|
+
publishedPorts: number[];
|
|
25
|
+
serverPlans: MoltnetServerPlan[];
|
|
26
|
+
}
|
|
27
|
+
export declare const generateMoltnetArtifacts: (plan: CompilePlan) => Promise<MoltnetArtifacts | null>;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { getRuntimeAdapter } from "../runtime/index.js";
|
|
2
|
+
import { SpawnfileError } from "../shared/index.js";
|
|
3
|
+
const DEFAULT_MOLTNET_PORT = 8787;
|
|
4
|
+
const DEFAULT_TINYCLAW_PORT = 3777;
|
|
5
|
+
const ROOTFS_PREFIX = "container/rootfs";
|
|
6
|
+
const INSTANCE_ROOT_PLACEHOLDER = "<instance-root>";
|
|
7
|
+
const CONFIG_FILE_PLACEHOLDER = "<config-file>";
|
|
8
|
+
const createServerKey = (networkId) => networkId;
|
|
9
|
+
const createBridgeConfigPath = (teamSlug, networkId, agentId) => `${ROOTFS_PREFIX}/var/lib/spawnfile/moltnet/bridges/${teamSlug}-${networkId}-${agentId}.json`;
|
|
10
|
+
const resolveSequentialRuntimePort = (plan, runtimeName, slug) => {
|
|
11
|
+
const adapter = getRuntimeAdapter(runtimeName);
|
|
12
|
+
const basePort = adapter.container.port;
|
|
13
|
+
if (basePort === undefined) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
const runtimeAgents = plan.nodes.filter((node) => node.kind === "agent" && node.runtimeName === runtimeName);
|
|
17
|
+
const index = runtimeAgents.findIndex((node) => node.slug === slug);
|
|
18
|
+
if (index < 0) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
return basePort + (index * (adapter.container.portStride ?? 1));
|
|
22
|
+
};
|
|
23
|
+
const createTinyClawChannel = (networkId, agentId) => `moltnet:${networkId}:${agentId}`;
|
|
24
|
+
const replaceContainerPathTemplate = (template, instanceRoot, configFileName) => template
|
|
25
|
+
.replaceAll(INSTANCE_ROOT_PLACEHOLDER, instanceRoot)
|
|
26
|
+
.replaceAll(CONFIG_FILE_PLACEHOLDER, configFileName);
|
|
27
|
+
const resolveRuntimeInstancePaths = (runtimeName, slug) => {
|
|
28
|
+
const adapter = getRuntimeAdapter(runtimeName);
|
|
29
|
+
const instanceRoot = `/var/lib/spawnfile/instances/${runtimeName}/agent-${slug}`;
|
|
30
|
+
return {
|
|
31
|
+
configPath: replaceContainerPathTemplate(adapter.container.instancePaths.configPathTemplate, instanceRoot, adapter.container.configFileName),
|
|
32
|
+
homePath: adapter.container.instancePaths.homePathTemplate
|
|
33
|
+
? replaceContainerPathTemplate(adapter.container.instancePaths.homePathTemplate, instanceRoot, adapter.container.configFileName)
|
|
34
|
+
: undefined
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
const resolveRuntimeConfig = (plan, agentNode, nodeSlug, networkId, agentId) => {
|
|
38
|
+
switch (agentNode.runtime.name) {
|
|
39
|
+
case "openclaw": {
|
|
40
|
+
const port = resolveSequentialRuntimePort(plan, "openclaw", nodeSlug);
|
|
41
|
+
if (!port) {
|
|
42
|
+
throw new SpawnfileError("compile_error", `Unable to resolve OpenClaw gateway port for Moltnet agent ${agentNode.name}`);
|
|
43
|
+
}
|
|
44
|
+
const instancePaths = resolveRuntimeInstancePaths("openclaw", nodeSlug);
|
|
45
|
+
return {
|
|
46
|
+
gateway_url: `ws://127.0.0.1:${port}`,
|
|
47
|
+
...(instancePaths.homePath ? { home_path: instancePaths.homePath } : {}),
|
|
48
|
+
kind: "openclaw",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
case "picoclaw": {
|
|
52
|
+
const instancePaths = resolveRuntimeInstancePaths("picoclaw", nodeSlug);
|
|
53
|
+
return {
|
|
54
|
+
command: "/usr/local/bin/picoclaw",
|
|
55
|
+
config_path: instancePaths.configPath,
|
|
56
|
+
...(instancePaths.homePath ? { home_path: instancePaths.homePath } : {}),
|
|
57
|
+
kind: "picoclaw",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
case "tinyclaw": {
|
|
61
|
+
const channel = createTinyClawChannel(networkId, agentId);
|
|
62
|
+
return {
|
|
63
|
+
ack_url: `http://127.0.0.1:${DEFAULT_TINYCLAW_PORT}/api/responses`,
|
|
64
|
+
channel,
|
|
65
|
+
inbound_url: `http://127.0.0.1:${DEFAULT_TINYCLAW_PORT}/api/message`,
|
|
66
|
+
kind: "tinyclaw",
|
|
67
|
+
outbound_url: `http://127.0.0.1:${DEFAULT_TINYCLAW_PORT}/api/responses/pending?channel=${encodeURIComponent(channel)}`
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
default:
|
|
71
|
+
throw new SpawnfileError("compile_error", `Moltnet does not know how to attach runtime ${agentNode.runtime.name} directly`);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
export const generateMoltnetArtifacts = async (plan) => {
|
|
75
|
+
const teamNodes = plan.nodes
|
|
76
|
+
.filter((node) => node.kind === "team")
|
|
77
|
+
.filter((node) => (node.value.networks?.length ?? 0) > 0);
|
|
78
|
+
if (teamNodes.length === 0) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const serverPlans = new Map();
|
|
82
|
+
let nextPort = DEFAULT_MOLTNET_PORT;
|
|
83
|
+
for (const teamNode of teamNodes) {
|
|
84
|
+
for (const network of teamNode.value.networks ?? []) {
|
|
85
|
+
const serverKey = createServerKey(network.id);
|
|
86
|
+
const existingPlan = serverPlans.get(serverKey);
|
|
87
|
+
if (existingPlan) {
|
|
88
|
+
for (const room of network.rooms) {
|
|
89
|
+
const existingRoom = existingPlan.rooms.find((entry) => entry.id === room.id);
|
|
90
|
+
if (existingRoom) {
|
|
91
|
+
existingRoom.members = [...new Set([...existingRoom.members, ...room.members])].sort();
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
existingPlan.rooms.push({
|
|
95
|
+
id: room.id,
|
|
96
|
+
members: [...new Set(room.members)].sort()
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
existingPlan.rooms.sort((left, right) => left.id.localeCompare(right.id));
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
serverPlans.set(serverKey, {
|
|
104
|
+
id: `${teamNode.slug}-${network.id}`,
|
|
105
|
+
name: network.name,
|
|
106
|
+
networkId: network.id,
|
|
107
|
+
port: nextPort,
|
|
108
|
+
rooms: network.rooms.map((room) => ({
|
|
109
|
+
id: room.id,
|
|
110
|
+
members: [...new Set(room.members)].sort()
|
|
111
|
+
})),
|
|
112
|
+
teamSource: teamNode.value.source
|
|
113
|
+
});
|
|
114
|
+
nextPort += 1;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const bridgePlans = [];
|
|
119
|
+
const bridgePlanKeys = new Set();
|
|
120
|
+
const configFiles = [];
|
|
121
|
+
for (const node of plan.nodes) {
|
|
122
|
+
if (node.kind !== "agent") {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const agentNode = node.value;
|
|
126
|
+
if (!agentNode.surfaces?.moltnet || agentNode.surfaces.moltnet.length === 0) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
for (const attachment of agentNode.surfaces.moltnet) {
|
|
130
|
+
if (!attachment.teamSource || !attachment.memberId) {
|
|
131
|
+
throw new SpawnfileError("validation_error", `Agent ${agentNode.name} Moltnet attachments require a team-bound network context`);
|
|
132
|
+
}
|
|
133
|
+
const teamNode = teamNodes.find((team) => team.value.source === attachment.teamSource);
|
|
134
|
+
if (!teamNode) {
|
|
135
|
+
throw new SpawnfileError("validation_error", `Unable to find team context for Moltnet attachment ${attachment.network} on ${agentNode.name}`);
|
|
136
|
+
}
|
|
137
|
+
const serverPlan = serverPlans.get(createServerKey(attachment.network));
|
|
138
|
+
if (!serverPlan) {
|
|
139
|
+
throw new SpawnfileError("validation_error", `Unable to find Moltnet network ${attachment.network} for ${agentNode.name}`);
|
|
140
|
+
}
|
|
141
|
+
const configPath = createBridgeConfigPath(teamNode.slug, attachment.network, attachment.memberId);
|
|
142
|
+
const bridgePlanKey = `${attachment.network}::${attachment.memberId}`;
|
|
143
|
+
if (bridgePlanKeys.has(bridgePlanKey)) {
|
|
144
|
+
throw new SpawnfileError("validation_error", `Duplicate Moltnet bridge attachment for ${attachment.network}/${attachment.memberId}`);
|
|
145
|
+
}
|
|
146
|
+
bridgePlanKeys.add(bridgePlanKey);
|
|
147
|
+
configFiles.push({
|
|
148
|
+
content: `${JSON.stringify({
|
|
149
|
+
version: "moltnet.bridge.v1",
|
|
150
|
+
agent: {
|
|
151
|
+
id: attachment.memberId,
|
|
152
|
+
name: agentNode.name
|
|
153
|
+
},
|
|
154
|
+
moltnet: {
|
|
155
|
+
base_url: `http://127.0.0.1:${serverPlan.port}`,
|
|
156
|
+
network_id: attachment.network
|
|
157
|
+
},
|
|
158
|
+
runtime: resolveRuntimeConfig(plan, agentNode, node.slug, attachment.network, attachment.memberId),
|
|
159
|
+
...(attachment.rooms
|
|
160
|
+
? {
|
|
161
|
+
rooms: Object.entries(attachment.rooms)
|
|
162
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
163
|
+
.map(([roomId, policy]) => ({
|
|
164
|
+
id: roomId,
|
|
165
|
+
...(policy.read ? { read: policy.read } : {}),
|
|
166
|
+
...(policy.reply ? { reply: policy.reply } : {})
|
|
167
|
+
}))
|
|
168
|
+
}
|
|
169
|
+
: {}),
|
|
170
|
+
...(attachment.dms
|
|
171
|
+
? {
|
|
172
|
+
dms: {
|
|
173
|
+
enabled: attachment.dms.enabled,
|
|
174
|
+
...(attachment.dms.read ? { read: attachment.dms.read } : {}),
|
|
175
|
+
...(attachment.dms.reply ? { reply: attachment.dms.reply } : {})
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
: {})
|
|
179
|
+
}, null, 2)}\n`,
|
|
180
|
+
mode: 0o600,
|
|
181
|
+
path: configPath
|
|
182
|
+
});
|
|
183
|
+
bridgePlans.push({
|
|
184
|
+
agentId: attachment.memberId,
|
|
185
|
+
configPath: `/${configPath.replace(`${ROOTFS_PREFIX}/`, "")}`,
|
|
186
|
+
networkId: attachment.network,
|
|
187
|
+
runtime: agentNode.runtime.name
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
bridgePlans: bridgePlans.sort((left, right) => left.configPath.localeCompare(right.configPath)),
|
|
193
|
+
files: configFiles,
|
|
194
|
+
ports: [...new Set([...serverPlans.values()].map((plan) => plan.port))].sort((left, right) => left - right),
|
|
195
|
+
publishedPorts: [
|
|
196
|
+
...new Set(teamNodes
|
|
197
|
+
.flatMap((teamNode) => (teamNode.value.networks ?? []).map((network) => network.expose
|
|
198
|
+
? serverPlans.get(createServerKey(network.id))?.port
|
|
199
|
+
: undefined))
|
|
200
|
+
.filter((port) => port !== undefined))
|
|
201
|
+
].sort((left, right) => left - right),
|
|
202
|
+
serverPlans: [...serverPlans.values()].sort((left, right) => left.port - right.port)
|
|
203
|
+
};
|
|
204
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const MOLTNET_BIN_DIRECTORY = "moltnet-bin";
|
|
2
|
+
export declare const MOLTNET_BINARY_NAMES: readonly ["moltnet"];
|
|
3
|
+
export declare const resolveMoltnetCliCommand: () => Promise<string>;
|
|
4
|
+
export declare const stageMoltnetBinaries: (outputDirectory: string) => Promise<boolean>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { chmod } from "node:fs/promises";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { ensureDirectory, fileExists } from "../filesystem/index.js";
|
|
6
|
+
import { SpawnfileError } from "../shared/index.js";
|
|
7
|
+
const execFile = promisify(execFileCallback);
|
|
8
|
+
const MOLTNET_CLI_ENV = "SPAWNFILE_MOLTNET_CLI";
|
|
9
|
+
const MOLTNET_RELEASE_DIR_ENV = "SPAWNFILE_MOLTNET_RELEASE_DIR";
|
|
10
|
+
const LOCAL_MOLTNET_CLI_PATH = path.resolve(process.cwd(), "moltnet", "bin", "moltnet");
|
|
11
|
+
const MOLTNET_TARGET_OS = "linux";
|
|
12
|
+
export const MOLTNET_BIN_DIRECTORY = "moltnet-bin";
|
|
13
|
+
export const MOLTNET_BINARY_NAMES = ["moltnet"];
|
|
14
|
+
const resolveTargetArchitecture = () => {
|
|
15
|
+
switch (process.arch) {
|
|
16
|
+
case "arm64":
|
|
17
|
+
return "arm64";
|
|
18
|
+
case "x64":
|
|
19
|
+
return "amd64";
|
|
20
|
+
default:
|
|
21
|
+
throw new SpawnfileError("compile_error", `Moltnet container installs do not support host architecture ${process.arch}`);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const createReleaseAssetName = (architecture) => `moltnet_${MOLTNET_TARGET_OS}_${architecture}.tar.gz`;
|
|
25
|
+
const isCommandNotFoundError = (error) => typeof error === "object" &&
|
|
26
|
+
error !== null &&
|
|
27
|
+
"code" in error &&
|
|
28
|
+
error.code === "ENOENT";
|
|
29
|
+
const validateMoltnetCli = async (command, sourceLabel) => {
|
|
30
|
+
try {
|
|
31
|
+
await execFile(command, ["version"]);
|
|
32
|
+
return command;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
36
|
+
throw new SpawnfileError("compile_error", `Unable to execute compiled Moltnet CLI from ${sourceLabel}: ${reason}. Install Moltnet with \`curl -fsSL https://moltnet.dev/install.sh | sh\` or set ${MOLTNET_CLI_ENV}.`);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const resolveConfiguredReleaseDirectory = async () => {
|
|
40
|
+
const configuredDirectory = process.env[MOLTNET_RELEASE_DIR_ENV]?.trim();
|
|
41
|
+
if (!configuredDirectory) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (!(await fileExists(configuredDirectory))) {
|
|
45
|
+
throw new SpawnfileError("compile_error", `Moltnet release directory ${configuredDirectory} does not exist`);
|
|
46
|
+
}
|
|
47
|
+
return configuredDirectory;
|
|
48
|
+
};
|
|
49
|
+
const findPathMoltnetCli = async () => {
|
|
50
|
+
try {
|
|
51
|
+
await execFile("moltnet", ["version"]);
|
|
52
|
+
return "moltnet";
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (isCommandNotFoundError(error)) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
59
|
+
throw new SpawnfileError("compile_error", `Unable to execute compiled Moltnet CLI from PATH: ${reason}. Install Moltnet with \`curl -fsSL https://moltnet.dev/install.sh | sh\` or set ${MOLTNET_CLI_ENV}.`);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
export const resolveMoltnetCliCommand = async () => {
|
|
63
|
+
const configuredCli = process.env[MOLTNET_CLI_ENV]?.trim();
|
|
64
|
+
if (configuredCli) {
|
|
65
|
+
return validateMoltnetCli(configuredCli, configuredCli);
|
|
66
|
+
}
|
|
67
|
+
const pathCli = await findPathMoltnetCli();
|
|
68
|
+
if (pathCli) {
|
|
69
|
+
return pathCli;
|
|
70
|
+
}
|
|
71
|
+
if (await fileExists(LOCAL_MOLTNET_CLI_PATH)) {
|
|
72
|
+
return validateMoltnetCli(LOCAL_MOLTNET_CLI_PATH, LOCAL_MOLTNET_CLI_PATH);
|
|
73
|
+
}
|
|
74
|
+
return validateMoltnetCli("moltnet", "PATH");
|
|
75
|
+
};
|
|
76
|
+
export const stageMoltnetBinaries = async (outputDirectory) => {
|
|
77
|
+
const releaseDirectory = await resolveConfiguredReleaseDirectory();
|
|
78
|
+
if (!releaseDirectory) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
const architecture = resolveTargetArchitecture();
|
|
82
|
+
const releaseAssetPath = path.join(releaseDirectory, createReleaseAssetName(architecture));
|
|
83
|
+
if (!(await fileExists(releaseAssetPath))) {
|
|
84
|
+
throw new SpawnfileError("compile_error", `Moltnet release asset ${releaseAssetPath} does not exist. Build it with \`cd moltnet && make release-assets\` or set ${MOLTNET_RELEASE_DIR_ENV}.`);
|
|
85
|
+
}
|
|
86
|
+
const installDirectory = path.join(outputDirectory, MOLTNET_BIN_DIRECTORY);
|
|
87
|
+
await ensureDirectory(installDirectory);
|
|
88
|
+
try {
|
|
89
|
+
await execFile("tar", ["-C", installDirectory, "-xzf", releaseAssetPath]);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
93
|
+
throw new SpawnfileError("compile_error", `Unable to extract Moltnet release asset ${releaseAssetPath}: ${reason}`);
|
|
94
|
+
}
|
|
95
|
+
for (const binaryName of MOLTNET_BINARY_NAMES) {
|
|
96
|
+
const binaryPath = path.join(installDirectory, binaryName);
|
|
97
|
+
if (!(await fileExists(binaryPath))) {
|
|
98
|
+
throw new SpawnfileError("compile_error", `Moltnet release asset ${releaseAssetPath} did not contain ${binaryName}`);
|
|
99
|
+
}
|
|
100
|
+
await chmod(binaryPath, 0o755);
|
|
101
|
+
}
|
|
102
|
+
return true;
|
|
103
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { EmittedFile } from "../runtime/index.js";
|
|
2
|
+
import type { MoltnetArtifacts } from "./moltnetArtifacts.js";
|
|
3
|
+
import type { ResolvedAgentNode } from "./types.js";
|
|
4
|
+
export interface MoltnetWorkspaceLayout {
|
|
5
|
+
clientConfigPath: string;
|
|
6
|
+
cliRuntime: string;
|
|
7
|
+
skillPaths: string[];
|
|
8
|
+
workspaceRootPath: string;
|
|
9
|
+
}
|
|
10
|
+
export declare const resolveMoltnetWorkspaceLayout: (runtimeName: string, agentName: string) => MoltnetWorkspaceLayout;
|
|
11
|
+
export declare const createMoltnetClientConfigFiles: (node: ResolvedAgentNode, artifacts: MoltnetArtifacts) => EmittedFile[];
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { SpawnfileError } from "../shared/index.js";
|
|
2
|
+
const GENERATED_SKILL_NAME = "moltnet";
|
|
3
|
+
const GENERATED_CONFIG_PATH = ".moltnet/config.json";
|
|
4
|
+
const createConfigContent = (node, attachments) => `${JSON.stringify({
|
|
5
|
+
version: "moltnet.client.v1",
|
|
6
|
+
agent: {
|
|
7
|
+
name: node.name,
|
|
8
|
+
runtime: node.runtime.name
|
|
9
|
+
},
|
|
10
|
+
attachments
|
|
11
|
+
}, null, 2)}\n`;
|
|
12
|
+
const findServerPlan = (artifacts, attachment) => {
|
|
13
|
+
const exactPlan = artifacts.serverPlans.find((serverPlan) => serverPlan.networkId === attachment.network &&
|
|
14
|
+
serverPlan.teamSource === attachment.teamSource);
|
|
15
|
+
const plan = exactPlan ?? artifacts.serverPlans.find((serverPlan) => serverPlan.networkId === attachment.network);
|
|
16
|
+
if (!plan) {
|
|
17
|
+
throw new SpawnfileError("compile_error", `Unable to resolve Moltnet server plan for ${attachment.network} on ${attachment.memberId ?? "unknown-agent"}`);
|
|
18
|
+
}
|
|
19
|
+
return plan;
|
|
20
|
+
};
|
|
21
|
+
const createAttachmentConfig = (node, artifacts, attachment) => {
|
|
22
|
+
if (!attachment.memberId) {
|
|
23
|
+
throw new SpawnfileError("compile_error", `Moltnet client config requires a resolved member id for ${node.name}`);
|
|
24
|
+
}
|
|
25
|
+
const serverPlan = findServerPlan(artifacts, attachment);
|
|
26
|
+
return {
|
|
27
|
+
agent_name: node.name,
|
|
28
|
+
auth: { mode: "none" },
|
|
29
|
+
base_url: `http://127.0.0.1:${serverPlan.port}`,
|
|
30
|
+
...(attachment.dms
|
|
31
|
+
? {
|
|
32
|
+
dms: {
|
|
33
|
+
enabled: attachment.dms.enabled,
|
|
34
|
+
...(attachment.dms.read ? { read: attachment.dms.read } : {}),
|
|
35
|
+
...(attachment.dms.reply ? { reply: attachment.dms.reply } : {})
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
: {}),
|
|
39
|
+
member_id: attachment.memberId,
|
|
40
|
+
network_id: attachment.network,
|
|
41
|
+
runtime: node.runtime.name,
|
|
42
|
+
...(attachment.rooms
|
|
43
|
+
? {
|
|
44
|
+
rooms: Object.entries(attachment.rooms)
|
|
45
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
46
|
+
.map(([roomId, policy]) => ({
|
|
47
|
+
id: roomId,
|
|
48
|
+
...(policy.read ? { read: policy.read } : {}),
|
|
49
|
+
...(policy.reply ? { reply: policy.reply } : {})
|
|
50
|
+
}))
|
|
51
|
+
}
|
|
52
|
+
: {})
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
export const resolveMoltnetWorkspaceLayout = (runtimeName, agentName) => {
|
|
56
|
+
if (runtimeName === "openclaw" || runtimeName === "picoclaw") {
|
|
57
|
+
return {
|
|
58
|
+
clientConfigPath: `workspace/${GENERATED_CONFIG_PATH}`,
|
|
59
|
+
cliRuntime: runtimeName,
|
|
60
|
+
skillPaths: [`workspace/skills/${GENERATED_SKILL_NAME}/SKILL.md`],
|
|
61
|
+
workspaceRootPath: "workspace"
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (runtimeName === "tinyclaw") {
|
|
65
|
+
return {
|
|
66
|
+
clientConfigPath: `workspace/${agentName}/${GENERATED_CONFIG_PATH}`,
|
|
67
|
+
cliRuntime: runtimeName,
|
|
68
|
+
skillPaths: [
|
|
69
|
+
`workspace/${agentName}/.agents/skills/${GENERATED_SKILL_NAME}/SKILL.md`,
|
|
70
|
+
`workspace/${agentName}/.claude/skills/${GENERATED_SKILL_NAME}/SKILL.md`
|
|
71
|
+
],
|
|
72
|
+
workspaceRootPath: `workspace/${agentName}`
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
throw new SpawnfileError("compile_error", `Moltnet client config does not know how to emit files for runtime ${runtimeName}`);
|
|
76
|
+
};
|
|
77
|
+
export const createMoltnetClientConfigFiles = (node, artifacts) => {
|
|
78
|
+
const attachments = node.surfaces?.moltnet;
|
|
79
|
+
if (!attachments || attachments.length === 0) {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
const layout = resolveMoltnetWorkspaceLayout(node.runtime.name, node.name);
|
|
83
|
+
return [
|
|
84
|
+
{
|
|
85
|
+
content: createConfigContent(node, attachments.map((attachment) => createAttachmentConfig(node, artifacts, attachment))),
|
|
86
|
+
path: layout.clientConfigPath
|
|
87
|
+
}
|
|
88
|
+
];
|
|
89
|
+
};
|