@agentbean/daemon 0.1.0
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 +158 -0
- package/dist/adapters/adapter.js +9 -0
- package/dist/adapters/claude-code.js +79 -0
- package/dist/adapters/codex.js +114 -0
- package/dist/adapters/hermes.js +80 -0
- package/dist/adapters/openclaw.js +70 -0
- package/dist/agent-instance.js +84 -0
- package/dist/auth-store.js +24 -0
- package/dist/bin.js +6 -0
- package/dist/config.js +138 -0
- package/dist/connection.js +113 -0
- package/dist/device-daemon.js +212 -0
- package/dist/index.js +302 -0
- package/dist/log.js +7 -0
- package/dist/post-process.js +71 -0
- package/dist/sandbox.js +40 -0
- package/dist/scanner.js +296 -0
- package/dist/uploader.js +46 -0
- package/package.json +36 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { loadConfig, loadDeviceConfig } from './config.js';
|
|
3
|
+
import { createConnection } from './connection.js';
|
|
4
|
+
import { createDeviceDaemon } from './device-daemon.js';
|
|
5
|
+
import { AgentInstance } from './agent-instance.js';
|
|
6
|
+
import { CodexAdapter } from './adapters/codex.js';
|
|
7
|
+
import { ClaudeCodeAdapter } from './adapters/claude-code.js';
|
|
8
|
+
import { OpenClawAdapter } from './adapters/openclaw.js';
|
|
9
|
+
import { HermesAdapter } from './adapters/hermes.js';
|
|
10
|
+
import { logger } from './log.js';
|
|
11
|
+
import { scanRuntimes, scanAgentOSAgents, scanLocalAgents, getDeviceId } from './scanner.js';
|
|
12
|
+
import { loadAuth, saveAuth } from './auth-store.js';
|
|
13
|
+
function pickAdapter(cfg) {
|
|
14
|
+
switch (cfg.kind) {
|
|
15
|
+
case 'codex':
|
|
16
|
+
return new CodexAdapter({
|
|
17
|
+
command: cfg.command,
|
|
18
|
+
args: cfg.args,
|
|
19
|
+
cwd: cfg.cwd,
|
|
20
|
+
systemPrompt: cfg.systemPrompt,
|
|
21
|
+
});
|
|
22
|
+
case 'claude-code':
|
|
23
|
+
return new ClaudeCodeAdapter({
|
|
24
|
+
command: cfg.command,
|
|
25
|
+
args: cfg.args,
|
|
26
|
+
cwd: cfg.cwd,
|
|
27
|
+
systemPrompt: cfg.systemPrompt,
|
|
28
|
+
});
|
|
29
|
+
case 'openclaw':
|
|
30
|
+
return new OpenClawAdapter({
|
|
31
|
+
command: cfg.command,
|
|
32
|
+
args: cfg.args,
|
|
33
|
+
cwd: cfg.cwd,
|
|
34
|
+
systemPrompt: cfg.systemPrompt,
|
|
35
|
+
});
|
|
36
|
+
case 'hermes':
|
|
37
|
+
return new HermesAdapter({
|
|
38
|
+
command: cfg.command,
|
|
39
|
+
args: cfg.args,
|
|
40
|
+
cwd: cfg.cwd,
|
|
41
|
+
systemPrompt: cfg.systemPrompt,
|
|
42
|
+
});
|
|
43
|
+
default:
|
|
44
|
+
throw new Error(`adapter '${cfg.kind}' not yet implemented`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async function discoverAgents() {
|
|
48
|
+
const [_runtimes, agentos, local] = await Promise.all([
|
|
49
|
+
scanRuntimes(),
|
|
50
|
+
scanAgentOSAgents(),
|
|
51
|
+
scanLocalAgents(),
|
|
52
|
+
]);
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
const results = [];
|
|
55
|
+
for (const s of [...agentos, ...local]) {
|
|
56
|
+
if (seen.has(s.command))
|
|
57
|
+
continue;
|
|
58
|
+
seen.add(s.command);
|
|
59
|
+
const id = s.name.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
|
60
|
+
results.push({
|
|
61
|
+
id,
|
|
62
|
+
name: s.name,
|
|
63
|
+
role: s.category === 'executor-hosted' ? 'executor-agent' : s.category === 'agentos-hosted' ? 'gateway-agent' : 'standalone-agent',
|
|
64
|
+
category: s.category,
|
|
65
|
+
adapter: {
|
|
66
|
+
kind: s.adapterKind,
|
|
67
|
+
command: s.command,
|
|
68
|
+
args: s.args,
|
|
69
|
+
},
|
|
70
|
+
visibility: 'public',
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
logger.info({ discovered: results.map((r) => r.name) }, 'agents discovered via scanning');
|
|
74
|
+
return results;
|
|
75
|
+
}
|
|
76
|
+
async function startDeviceDaemon(cfg) {
|
|
77
|
+
const agents = new Map();
|
|
78
|
+
for (const entry of cfg.agents) {
|
|
79
|
+
const adapter = pickAdapter(entry.adapter);
|
|
80
|
+
const instance = new AgentInstance(entry, adapter);
|
|
81
|
+
agents.set(entry.id, instance);
|
|
82
|
+
logger.info({ id: entry.id, kind: entry.adapter.kind, visibility: entry.visibility }, 'agent instance created');
|
|
83
|
+
}
|
|
84
|
+
logger.info({ deviceId: cfg.deviceId, agentCount: agents.size }, 'device daemon starting');
|
|
85
|
+
const daemon = createDeviceDaemon(cfg, agents);
|
|
86
|
+
await daemon.start();
|
|
87
|
+
const shutdown = async (signal) => {
|
|
88
|
+
logger.info({ signal }, 'shutting down device daemon');
|
|
89
|
+
await daemon.stop();
|
|
90
|
+
process.exit(0);
|
|
91
|
+
};
|
|
92
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
93
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
94
|
+
}
|
|
95
|
+
async function runDeviceMode(cfgPath) {
|
|
96
|
+
let cfg;
|
|
97
|
+
let scannedEntries;
|
|
98
|
+
try {
|
|
99
|
+
cfg = loadDeviceConfig(cfgPath);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
const shouldScan = err.message?.includes('agents array is required');
|
|
103
|
+
if (!shouldScan)
|
|
104
|
+
throw err;
|
|
105
|
+
scannedEntries = await discoverAgents();
|
|
106
|
+
if (scannedEntries.length === 0) {
|
|
107
|
+
throw new Error('device config missing and no agents discovered via scanning');
|
|
108
|
+
}
|
|
109
|
+
let fileSettings = {};
|
|
110
|
+
try {
|
|
111
|
+
const { readFileSync } = await import('node:fs');
|
|
112
|
+
const { load: parseYaml } = await import('js-yaml');
|
|
113
|
+
const raw = parseYaml(readFileSync(cfgPath, 'utf8'));
|
|
114
|
+
fileSettings = {
|
|
115
|
+
deviceId: raw.deviceId,
|
|
116
|
+
networkId: raw.networkId,
|
|
117
|
+
server: raw.server,
|
|
118
|
+
heartbeatIntervalMs: raw.heartbeatIntervalMs,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
catch { /* ignore */ }
|
|
122
|
+
cfg = {
|
|
123
|
+
deviceId: fileSettings.deviceId ?? process.env.DEVICE_ID ?? await getDeviceId(),
|
|
124
|
+
networkId: fileSettings.networkId ?? process.env.NETWORK_ID ?? 'default',
|
|
125
|
+
server: fileSettings.server ?? {
|
|
126
|
+
url: process.env.SERVER_URL ?? 'http://localhost:3000/agent',
|
|
127
|
+
token: process.env.SERVER_TOKEN ?? '',
|
|
128
|
+
},
|
|
129
|
+
heartbeatIntervalMs: fileSettings.heartbeatIntervalMs ?? 10_000,
|
|
130
|
+
agents: scannedEntries,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (cfg.scan === true) {
|
|
134
|
+
scannedEntries = await discoverAgents();
|
|
135
|
+
if (scannedEntries.length > 0) {
|
|
136
|
+
cfg = { ...cfg, agents: scannedEntries };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
await startDeviceDaemon(cfg);
|
|
140
|
+
}
|
|
141
|
+
async function runSingleAgentMode(cfgPath) {
|
|
142
|
+
const cfg = loadConfig(cfgPath);
|
|
143
|
+
const adapter = pickAdapter(cfg.adapter);
|
|
144
|
+
logger.info({ id: cfg.id, kind: cfg.adapter.kind }, 'agent daemon starting (single-agent mode)');
|
|
145
|
+
const conn = createConnection(cfg, adapter);
|
|
146
|
+
await conn.start();
|
|
147
|
+
const shutdown = async (signal) => {
|
|
148
|
+
logger.info({ signal }, 'shutting down');
|
|
149
|
+
await conn.stop();
|
|
150
|
+
process.exit(0);
|
|
151
|
+
};
|
|
152
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
153
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
154
|
+
}
|
|
155
|
+
async function runCliMode() {
|
|
156
|
+
const { values } = parseArgs({
|
|
157
|
+
options: {
|
|
158
|
+
'server-url': { type: 'string' },
|
|
159
|
+
'token': { type: 'string' },
|
|
160
|
+
'invite': { type: 'string' },
|
|
161
|
+
'device-id': { type: 'string' },
|
|
162
|
+
'network-id': { type: 'string' },
|
|
163
|
+
'help': { type: 'boolean' },
|
|
164
|
+
},
|
|
165
|
+
strict: true,
|
|
166
|
+
});
|
|
167
|
+
if (values.help) {
|
|
168
|
+
console.log(`Usage: agentbean-daemon --server-url <url> --token <token> [--device-id <id>] [--network-id <id>]
|
|
169
|
+
|
|
170
|
+
Options:
|
|
171
|
+
--server-url AgentBean Server URL (required)
|
|
172
|
+
--token Authentication token (required)
|
|
173
|
+
--device-id Device ID (default: auto-detected from hardware)
|
|
174
|
+
--network-id Network ID (default: default)
|
|
175
|
+
`);
|
|
176
|
+
process.exit(0);
|
|
177
|
+
}
|
|
178
|
+
let serverUrl = values['server-url'] ?? process.env.AGENT_BEAN_SERVER_URL;
|
|
179
|
+
let token = values['token'] ?? process.env.AGENT_BEAN_AGENT_TOKEN;
|
|
180
|
+
let networkId = values['network-id'] ?? 'default';
|
|
181
|
+
if (values.invite) {
|
|
182
|
+
if (!serverUrl) {
|
|
183
|
+
console.error('Error: --server-url is required with --invite.');
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
const auth = await runInviteMode(serverUrl, values.invite);
|
|
187
|
+
serverUrl = auth.serverUrl;
|
|
188
|
+
token = auth.token;
|
|
189
|
+
networkId = auth.networkId ?? networkId;
|
|
190
|
+
}
|
|
191
|
+
else if (!token) {
|
|
192
|
+
const saved = loadAuth();
|
|
193
|
+
if (saved) {
|
|
194
|
+
serverUrl = serverUrl ?? saved.serverUrl;
|
|
195
|
+
token = saved.token;
|
|
196
|
+
networkId = saved.networkId ?? networkId;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (!serverUrl || !token) {
|
|
200
|
+
console.error('Error: --server-url and --token are required.');
|
|
201
|
+
console.error('Usage: agentbean-daemon --server-url <url> --token <token>');
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
const deviceId = values['device-id'] ?? await getDeviceId();
|
|
205
|
+
logger.info({ serverUrl, deviceId, networkId }, 'CLI mode: auto-discovering agents');
|
|
206
|
+
const agents = await discoverAgents();
|
|
207
|
+
if (agents.length === 0) {
|
|
208
|
+
logger.warn('no agents discovered on this machine. Daemon will start with no agents.');
|
|
209
|
+
}
|
|
210
|
+
const cfg = {
|
|
211
|
+
deviceId,
|
|
212
|
+
networkId,
|
|
213
|
+
server: { url: serverUrl, token },
|
|
214
|
+
heartbeatIntervalMs: 10_000,
|
|
215
|
+
agents,
|
|
216
|
+
};
|
|
217
|
+
await startDeviceDaemon(cfg);
|
|
218
|
+
}
|
|
219
|
+
function normalizeBaseUrl(serverUrl) {
|
|
220
|
+
return serverUrl.replace(/\/agent\/?$/, '').replace(/\/web\/?$/, '');
|
|
221
|
+
}
|
|
222
|
+
function normalizeAgentUrl(serverUrl) {
|
|
223
|
+
const base = normalizeBaseUrl(serverUrl);
|
|
224
|
+
return `${base}/agent`;
|
|
225
|
+
}
|
|
226
|
+
async function runInviteMode(serverUrl, inviteCode) {
|
|
227
|
+
const { io } = await import('socket.io-client');
|
|
228
|
+
const { execFile } = await import('node:child_process');
|
|
229
|
+
const baseUrl = normalizeBaseUrl(serverUrl);
|
|
230
|
+
const webSocketUrl = `${baseUrl}/web`;
|
|
231
|
+
logger.info({ serverUrl: baseUrl, inviteCode }, 'invite mode: connecting to server');
|
|
232
|
+
const socket = io(webSocketUrl, {
|
|
233
|
+
auth: { invite: true },
|
|
234
|
+
transports: ['websocket'],
|
|
235
|
+
reconnection: false,
|
|
236
|
+
});
|
|
237
|
+
return new Promise((resolve, reject) => {
|
|
238
|
+
const fail = (err) => {
|
|
239
|
+
socket.disconnect();
|
|
240
|
+
reject(err);
|
|
241
|
+
};
|
|
242
|
+
socket.on('connect_error', (err) => {
|
|
243
|
+
logger.error({ err: err.message }, 'invite mode: connection failed');
|
|
244
|
+
fail(new Error(`connection failed: ${err.message}`));
|
|
245
|
+
});
|
|
246
|
+
socket.on('connect', () => {
|
|
247
|
+
logger.info('invite mode: connected, validating invite code');
|
|
248
|
+
socket.emit('auth:invite:validate', { code: inviteCode }, (res) => {
|
|
249
|
+
if (!res?.ok) {
|
|
250
|
+
fail(new Error(res?.error ?? 'invalid invite code'));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const registerUrl = res.registerUrl;
|
|
254
|
+
logger.info({ registerUrl }, 'invite mode: opening browser');
|
|
255
|
+
console.log(`\nOpen this URL to finish joining AgentBean:\n${registerUrl}\n`);
|
|
256
|
+
execFile('open', [registerUrl], (err) => {
|
|
257
|
+
if (err) {
|
|
258
|
+
logger.info({ registerUrl }, 'invite mode: could not open browser automatically');
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
console.log('Waiting for registration to complete...');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
socket.on('auth:token:deliver', (payload) => {
|
|
265
|
+
if (!payload?.token)
|
|
266
|
+
return;
|
|
267
|
+
const auth = {
|
|
268
|
+
token: payload.token,
|
|
269
|
+
serverUrl: normalizeAgentUrl(serverUrl),
|
|
270
|
+
userId: payload.userId,
|
|
271
|
+
networkId: payload.networkId,
|
|
272
|
+
};
|
|
273
|
+
saveAuth(auth);
|
|
274
|
+
logger.info({ networkId: auth.networkId }, 'invite mode: token received and saved');
|
|
275
|
+
console.log('Registration complete! Starting daemon...');
|
|
276
|
+
socket.disconnect();
|
|
277
|
+
resolve(auth);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
export async function main() {
|
|
282
|
+
// Check for CLI flags first (npx mode)
|
|
283
|
+
const hasCliFlags = process.argv.some((a) => a === '--server-url' || a === '--token' || a === '--invite' || a === '--help');
|
|
284
|
+
if (hasCliFlags) {
|
|
285
|
+
await runCliMode();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
// Fallback: YAML config mode
|
|
289
|
+
const cfgPath = process.env.DEVICE_CONFIG ?? process.env.AGENT_CONFIG ?? './agent.config.yaml';
|
|
290
|
+
try {
|
|
291
|
+
await runDeviceMode(cfgPath);
|
|
292
|
+
}
|
|
293
|
+
catch (deviceErr) {
|
|
294
|
+
if (deviceErr.message?.includes('deviceId') || deviceErr.message?.includes('agents')) {
|
|
295
|
+
logger.info({ reason: deviceErr.message }, 'not a device config, falling back to single-agent mode');
|
|
296
|
+
await runSingleAgentMode(cfgPath);
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
throw deviceErr;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
package/dist/log.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readdirSync, statSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { logger } from './log.js';
|
|
5
|
+
const CODE_BLOCK_RE = /```python\n([\s\S]*?)```/g;
|
|
6
|
+
const CODEX_IMG_DIR = join(homedir(), '.codex', 'generated_images');
|
|
7
|
+
export function listAllFiles(dir, maxDepth = 10, depth = 0) {
|
|
8
|
+
if (!existsSync(dir) || depth > maxDepth)
|
|
9
|
+
return [];
|
|
10
|
+
const results = [];
|
|
11
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
12
|
+
const full = join(dir, entry.name);
|
|
13
|
+
if (entry.isSymbolicLink())
|
|
14
|
+
continue;
|
|
15
|
+
if (entry.isDirectory())
|
|
16
|
+
results.push(...listAllFiles(full, maxDepth, depth + 1));
|
|
17
|
+
else
|
|
18
|
+
results.push(full);
|
|
19
|
+
}
|
|
20
|
+
return results;
|
|
21
|
+
}
|
|
22
|
+
export async function postProcess(reply, workspace, kind, dispatchStart) {
|
|
23
|
+
const outputFiles = [];
|
|
24
|
+
// Codex native image detection
|
|
25
|
+
if (kind === 'codex') {
|
|
26
|
+
const allCodexFiles = listAllFiles(CODEX_IMG_DIR);
|
|
27
|
+
for (const f of allCodexFiles) {
|
|
28
|
+
try {
|
|
29
|
+
const st = statSync(f);
|
|
30
|
+
if (st.mtimeMs > dispatchStart) {
|
|
31
|
+
outputFiles.push(f);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch { }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// Detect files created during this dispatch
|
|
38
|
+
if (workspace) {
|
|
39
|
+
for (const entry of readdirSync(workspace, { withFileTypes: true })) {
|
|
40
|
+
if (entry.isDirectory())
|
|
41
|
+
continue;
|
|
42
|
+
const f = join(workspace, entry.name);
|
|
43
|
+
if (entry.name.startsWith('.agentbean-exec-'))
|
|
44
|
+
continue;
|
|
45
|
+
try {
|
|
46
|
+
const st = statSync(f);
|
|
47
|
+
if (st.mtimeMs > dispatchStart) {
|
|
48
|
+
outputFiles.push(f);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch { }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Extract code blocks for logging but do NOT auto-execute (security)
|
|
55
|
+
if (workspace) {
|
|
56
|
+
const codeBlocks = [];
|
|
57
|
+
let m;
|
|
58
|
+
const re = new RegExp(CODE_BLOCK_RE.source, 'g');
|
|
59
|
+
while ((m = re.exec(reply)) !== null) {
|
|
60
|
+
codeBlocks.push(m[1]);
|
|
61
|
+
}
|
|
62
|
+
if (codeBlocks.length > 0) {
|
|
63
|
+
logger.info({ count: codeBlocks.length }, 'code blocks detected but not executed (auto-exec disabled)');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
let replyText = reply;
|
|
67
|
+
if (outputFiles.length > 0) {
|
|
68
|
+
replyText += '\n\n已生成文件:\n' + outputFiles.map((f) => `- ${f}`).join('\n');
|
|
69
|
+
}
|
|
70
|
+
return { replyText, outputFiles };
|
|
71
|
+
}
|
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
function escapeSchemeString(value) {
|
|
5
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
6
|
+
}
|
|
7
|
+
export function getWorkspaceDir(agentId) {
|
|
8
|
+
const dir = join(homedir(), '.agentbean', 'workspaces', agentId);
|
|
9
|
+
if (!existsSync(dir))
|
|
10
|
+
mkdirSync(dir, { recursive: true });
|
|
11
|
+
return dir;
|
|
12
|
+
}
|
|
13
|
+
export function isSandboxAvailable() {
|
|
14
|
+
return process.platform === 'darwin';
|
|
15
|
+
}
|
|
16
|
+
export function generateSandboxProfile(agentId, runtimePath) {
|
|
17
|
+
const workspaceDir = getWorkspaceDir(agentId);
|
|
18
|
+
const runtimeDir = runtimePath.includes('/') ? dirname(runtimePath) : '/usr/bin';
|
|
19
|
+
const profilePath = `/tmp/agentbean-sandbox-${agentId}.sb`;
|
|
20
|
+
const profile = `(version 1)
|
|
21
|
+
(allow file-read* file-write*
|
|
22
|
+
(subpath "${escapeSchemeString(workspaceDir)}"))
|
|
23
|
+
(allow file-read* file-write*
|
|
24
|
+
(subpath "/tmp"))
|
|
25
|
+
(allow file-read*
|
|
26
|
+
(subpath "${escapeSchemeString(runtimeDir)}"))
|
|
27
|
+
(allow file-read*
|
|
28
|
+
(subpath "/bin")
|
|
29
|
+
(subpath "/usr/bin")
|
|
30
|
+
(subpath "/usr/local/bin")
|
|
31
|
+
(subpath "/opt/homebrew/bin"))
|
|
32
|
+
(allow network-outbound
|
|
33
|
+
(remote tcp "api.anthropic.com" 443))
|
|
34
|
+
(allow network-outbound
|
|
35
|
+
(remote tcp "api.openai.com" 443))
|
|
36
|
+
(deny default)
|
|
37
|
+
`;
|
|
38
|
+
writeFileSync(profilePath, profile);
|
|
39
|
+
return profilePath;
|
|
40
|
+
}
|