lazyclaw 6.0.0 → 6.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.ko.md +88 -25
- package/README.md +121 -190
- package/channels/handoff.mjs +18 -5
- package/channels/matrix.mjs +23 -5
- package/channels/slack.mjs +83 -50
- package/channels/slack_env.mjs +45 -0
- package/channels/telegram.mjs +49 -6
- package/channels-discord/index.mjs +76 -0
- package/channels-discord/package.json +14 -0
- package/channels-email/index.mjs +109 -0
- package/channels-email/package.json +14 -0
- package/channels-signal/index.mjs +69 -0
- package/channels-signal/package.json +9 -0
- package/channels-voice/index.mjs +81 -0
- package/channels-voice/package.json +9 -0
- package/channels-whatsapp/index.mjs +70 -0
- package/channels-whatsapp/package.json +13 -0
- package/cli.mjs +10 -21
- package/commands/agents.mjs +669 -0
- package/commands/auth_nodes.mjs +323 -0
- package/commands/automation.mjs +582 -0
- package/commands/channels.mjs +254 -0
- package/commands/chat.mjs +1253 -0
- package/commands/config.mjs +315 -0
- package/commands/daemon.mjs +275 -0
- package/commands/gateway.mjs +194 -0
- package/commands/misc.mjs +128 -0
- package/commands/providers.mjs +490 -0
- package/commands/service.mjs +113 -0
- package/commands/sessions.mjs +343 -0
- package/commands/setup.mjs +742 -0
- package/commands/setup_channels.mjs +207 -0
- package/commands/skills.mjs +218 -0
- package/commands/workflow.mjs +661 -0
- package/config_features.mjs +106 -0
- package/daemon/lib/auth.mjs +58 -0
- package/daemon/lib/cost.mjs +30 -0
- package/daemon/lib/inbound_dedup.mjs +108 -0
- package/daemon/lib/learn_queue.mjs +46 -0
- package/daemon/lib/provider.mjs +69 -0
- package/daemon/lib/respond.mjs +83 -0
- package/daemon/route_table.mjs +96 -0
- package/daemon/routes/_deps.mjs +42 -0
- package/daemon/routes/config.mjs +99 -0
- package/daemon/routes/conversation.mjs +435 -0
- package/daemon/routes/meta.mjs +239 -0
- package/daemon/routes/ops.mjs +165 -0
- package/daemon/routes/providers.mjs +211 -0
- package/daemon/routes/rates.mjs +90 -0
- package/daemon/routes/registry.mjs +223 -0
- package/daemon/routes/sessions.mjs +213 -0
- package/daemon/routes/skills.mjs +260 -0
- package/daemon/routes/workflows.mjs +224 -0
- package/dotenv_min.mjs +51 -0
- package/first_run.mjs +24 -0
- package/goals_cron.mjs +37 -0
- package/lib/args.mjs +216 -0
- package/lib/config.mjs +113 -0
- package/lib/gateway_guard.mjs +100 -0
- package/lib/inbound_client.mjs +108 -0
- package/lib/registry_boot.mjs +55 -0
- package/lib/service_install.mjs +207 -0
- package/package.json +15 -3
- package/providers/probe.mjs +28 -0
- package/secure_write.mjs +46 -0
- package/tui/editor.mjs +18 -2
- package/tui/repl.mjs +25 -4
- package/tui/slash_commands.mjs +4 -0
- package/tui/slash_dispatcher.mjs +118 -0
- package/tui/splash_props.mjs +52 -0
- package/web/dashboard.css +6 -12
- package/web/dashboard.html +2 -34
- package/web/dashboard.js +3 -3
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// commands/gateway.mjs — `lazyclaw gateway`: the single-process always-on
|
|
2
|
+
// agent (Phase 5, "approach B").
|
|
3
|
+
//
|
|
4
|
+
// One process owns everything: it starts the HTTP daemon in-process (the
|
|
5
|
+
// session-bearing core) AND the configured channel transports (Slack Socket
|
|
6
|
+
// Mode / Telegram long-poll / Matrix /sync), feeding every inbound through
|
|
7
|
+
// the same POST /inbound the standalone listeners use — so the bridge
|
|
8
|
+
// contract, pairing gate, dedup, cost cap, and learning hook are identical.
|
|
9
|
+
// Because the channels live in-process, each registers a live sender into
|
|
10
|
+
// ctx.channelSenders, which finally gives POST /handoff a notifier: the
|
|
11
|
+
// target channel receives a resume marker and a failed notify rolls the
|
|
12
|
+
// binding back (channels/handoff.mjs). `slack|telegram|matrix listen` remain
|
|
13
|
+
// supported as standalone single-channel forwarders.
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import { randomBytes } from 'node:crypto';
|
|
17
|
+
import { configPath, readConfig, writeConfig, readVersionFromRepo } from '../lib/config.mjs';
|
|
18
|
+
import { ensureRegistry } from '../lib/registry_boot.mjs';
|
|
19
|
+
import { loadDotenvIfAny } from '../dotenv_min.mjs';
|
|
20
|
+
import { assertUnattendedSafe, installCrashHandlers } from '../lib/gateway_guard.mjs';
|
|
21
|
+
import { makeInboundHandler } from '../lib/inbound_client.mjs';
|
|
22
|
+
|
|
23
|
+
export const GATEWAY_CHANNELS = ['slack', 'telegram', 'matrix'];
|
|
24
|
+
|
|
25
|
+
// Default per-channel constructors. Each receives { handler, logger,
|
|
26
|
+
// allowlist }, must return a started channel exposing send()/stop().
|
|
27
|
+
// Injectable so tests can run the full gateway with stub transports.
|
|
28
|
+
const DEFAULT_FACTORIES = {
|
|
29
|
+
async slack({ handler, logger }) {
|
|
30
|
+
const { SlackChannel } = await import('../channels/slack.mjs');
|
|
31
|
+
const ch = new SlackChannel();
|
|
32
|
+
await ch.start(handler);
|
|
33
|
+
await ch._connectSocketMode({ logger });
|
|
34
|
+
return ch;
|
|
35
|
+
},
|
|
36
|
+
async telegram({ handler, logger, allowlist }) {
|
|
37
|
+
const { TelegramChannel } = await import('../channels/telegram.mjs');
|
|
38
|
+
const ch = new TelegramChannel({ allowlist });
|
|
39
|
+
await ch.start(handler, { poll: true, logger });
|
|
40
|
+
return ch;
|
|
41
|
+
},
|
|
42
|
+
async matrix({ handler, logger, allowlist }) {
|
|
43
|
+
const { MatrixChannel } = await import('../channels/matrix.mjs');
|
|
44
|
+
const ch = new MatrixChannel({ allowlist });
|
|
45
|
+
await ch.start(handler, { poll: true, logger });
|
|
46
|
+
return ch;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Which channels should the gateway run? --channels a,b wins; otherwise the
|
|
51
|
+
// enabled cfg.channels sections that we have a built-in transport for.
|
|
52
|
+
export function _selectChannels(cfg, flags = {}) {
|
|
53
|
+
if (flags.channels) {
|
|
54
|
+
return String(flags.channels).split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)
|
|
55
|
+
.filter((n) => GATEWAY_CHANNELS.includes(n));
|
|
56
|
+
}
|
|
57
|
+
const configured = (cfg.channels && typeof cfg.channels === 'object') ? cfg.channels : {};
|
|
58
|
+
return GATEWAY_CHANNELS.filter((n) => configured[n] && configured[n].enabled !== false);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The whole gateway, with injectable deps so tests can drive it end-to-end
|
|
62
|
+
// (stub transports, ephemeral port) without Slack/Telegram/Matrix creds.
|
|
63
|
+
// Throws GatewayGuardError on an unsafe config. Returns { port, channels,
|
|
64
|
+
// channelSenders, skipped, stop }.
|
|
65
|
+
export async function runGateway(flags = {}, deps = {}) {
|
|
66
|
+
await ensureRegistry();
|
|
67
|
+
const log = deps.log || ((s) => process.stderr.write(s));
|
|
68
|
+
const cfg = deps.readConfig ? deps.readConfig() : readConfig();
|
|
69
|
+
assertUnattendedSafe(cfg, { surface: 'gateway' });
|
|
70
|
+
const cfgDir = path.dirname(configPath());
|
|
71
|
+
loadDotenvIfAny(cfgDir);
|
|
72
|
+
|
|
73
|
+
// The gateway is session-bearing AND sits on a well-known always-on port,
|
|
74
|
+
// so unlike the ad-hoc daemon it does not run unauthenticated by default:
|
|
75
|
+
// resolve --auth-token > env > the persisted gateway.token, else mint one
|
|
76
|
+
// and persist it 0600 (so `service install gateway` restarts keep the same
|
|
77
|
+
// token and the operator can read it from the file — it is never logged).
|
|
78
|
+
// --no-auth opts back into the historical open-loopback posture.
|
|
79
|
+
let authToken = flags['auth-token'] || process.env.LAZYCLAW_AUTH_TOKEN || null;
|
|
80
|
+
const tokenFile = path.join(cfgDir, 'gateway.token');
|
|
81
|
+
if (!authToken && !flags['no-auth']) {
|
|
82
|
+
try { authToken = fs.readFileSync(tokenFile, 'utf8').trim() || null; } catch { /* not minted yet */ }
|
|
83
|
+
if (!authToken) {
|
|
84
|
+
authToken = randomBytes(24).toString('hex');
|
|
85
|
+
fs.writeFileSync(tokenFile, authToken + '\n', { mode: 0o600 });
|
|
86
|
+
}
|
|
87
|
+
log(`[gateway] auth token active (read it from ${tokenFile}; external callers need Authorization: Bearer)\n`);
|
|
88
|
+
}
|
|
89
|
+
if (!authToken) log('[gateway] warning: --no-auth — any local process can drive the agent on this port.\n');
|
|
90
|
+
const allowlistArr = (cfg.pairing || []).map((p) => String(p.id));
|
|
91
|
+
if (allowlistArr.length === 0) {
|
|
92
|
+
log('[gateway] warning: pairing allowlist is empty — the agent will answer ANYONE who can reach a connected channel. Pair senders with `lazyclaw pairing add <id>`.\n');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Live sender map: channels register here as they come up; the daemon's
|
|
96
|
+
// handoff route reads it per-request (live Map, registrations visible).
|
|
97
|
+
const channelSenders = new Map();
|
|
98
|
+
|
|
99
|
+
const sessionsMod = await import('../sessions.mjs');
|
|
100
|
+
const { startDaemon } = await import('../daemon.mjs');
|
|
101
|
+
const startDaemonImpl = deps.startDaemonImpl || startDaemon;
|
|
102
|
+
const d = await startDaemonImpl({
|
|
103
|
+
port: flags.port !== undefined ? parseInt(flags.port, 10) : 19600,
|
|
104
|
+
once: false,
|
|
105
|
+
readConfig,
|
|
106
|
+
writeConfig: authToken ? writeConfig : undefined,
|
|
107
|
+
sessionsDirGetter: () => cfgDir,
|
|
108
|
+
sessionsMod,
|
|
109
|
+
version: () => readVersionFromRepo(),
|
|
110
|
+
authToken: authToken || undefined,
|
|
111
|
+
allowedOrigins: [],
|
|
112
|
+
logger: deps.logger || null,
|
|
113
|
+
channelSenders,
|
|
114
|
+
});
|
|
115
|
+
log(`[gateway] daemon core on http://127.0.0.1:${d.port}\n`);
|
|
116
|
+
|
|
117
|
+
const daemonUrl = `http://127.0.0.1:${d.port}`;
|
|
118
|
+
const factories = { ...DEFAULT_FACTORIES, ...(deps.channelFactories || {}) };
|
|
119
|
+
const wanted = _selectChannels(cfg, flags);
|
|
120
|
+
if (wanted.length === 0) {
|
|
121
|
+
log('[gateway] no channels configured/enabled — running the daemon core only. Add one with `lazyclaw setup` or pass --channels slack,telegram,matrix.\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const channels = [];
|
|
125
|
+
const skipped = [];
|
|
126
|
+
for (const name of wanted) {
|
|
127
|
+
const handler = makeInboundHandler({
|
|
128
|
+
channel: name, daemonUrl, daemonToken: authToken,
|
|
129
|
+
provider: flags.provider, model: flags.model,
|
|
130
|
+
});
|
|
131
|
+
try {
|
|
132
|
+
const ch = await factories[name]({
|
|
133
|
+
handler,
|
|
134
|
+
logger: (line) => log(line),
|
|
135
|
+
allowlist: allowlistArr.length ? allowlistArr : null,
|
|
136
|
+
});
|
|
137
|
+
channels.push({ name, ch });
|
|
138
|
+
channelSenders.set(name, (externalId, text) => ch.send(externalId, text));
|
|
139
|
+
log(`[gateway] ${name}: connected\n`);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
// One channel missing creds must not take down the others.
|
|
142
|
+
skipped.push({ name, error: err?.message || String(err) });
|
|
143
|
+
log(`[gateway] ${name}: skipped (${err?.message || err})\n`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const stop = async () => {
|
|
148
|
+
for (const { name, ch } of channels) {
|
|
149
|
+
try { await ch.stop(); } catch { /* best-effort drain */ }
|
|
150
|
+
channelSenders.delete(name);
|
|
151
|
+
}
|
|
152
|
+
// A wedged in-flight request must not hang shutdown forever — the crash
|
|
153
|
+
// handler awaits stop() before exiting, and a hang there would leave a
|
|
154
|
+
// crashed gateway alive-but-dead with no service-manager restart.
|
|
155
|
+
await Promise.race([
|
|
156
|
+
d.close(),
|
|
157
|
+
new Promise((r) => setTimeout(r, 8_000)),
|
|
158
|
+
]);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return { port: d.port, channels, channelSenders, skipped, stop, authToken };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function cmdGateway(flags = {}) {
|
|
165
|
+
let gw;
|
|
166
|
+
try {
|
|
167
|
+
gw = await runGateway(flags);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
console.error(`gateway: ${err?.message || err}`);
|
|
170
|
+
process.exit(2);
|
|
171
|
+
}
|
|
172
|
+
process.stdout.write(JSON.stringify({
|
|
173
|
+
ok: true,
|
|
174
|
+
url: `http://127.0.0.1:${gw.port}`,
|
|
175
|
+
port: gw.port,
|
|
176
|
+
channels: gw.channels.map((c) => c.name),
|
|
177
|
+
skipped: gw.skipped,
|
|
178
|
+
}) + '\n');
|
|
179
|
+
process.stderr.write('[gateway] running. Ctrl-C to stop.\n');
|
|
180
|
+
|
|
181
|
+
installCrashHandlers({ label: 'gateway', stop: () => gw.stop() });
|
|
182
|
+
await new Promise((resolve) => {
|
|
183
|
+
let shuttingDown = false;
|
|
184
|
+
const onSig = async () => {
|
|
185
|
+
if (shuttingDown) return process.exit(1);
|
|
186
|
+
shuttingDown = true;
|
|
187
|
+
process.stderr.write('\n[gateway] shutting down…\n');
|
|
188
|
+
try { await gw.stop(); } catch { /* best-effort */ }
|
|
189
|
+
resolve();
|
|
190
|
+
};
|
|
191
|
+
process.once('SIGINT', onSig);
|
|
192
|
+
process.once('SIGTERM', onSig);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Misc commands: cmdBrowse (headless URL fetch) and the sandbox backend
|
|
2
|
+
// subcommands (list/test/add/use), extracted from cli.mjs (Phase D3).
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { configPath } from '../lib/config.mjs';
|
|
6
|
+
import { resolveSandbox, listBackends } from '../sandbox/index.mjs';
|
|
7
|
+
|
|
8
|
+
export async function cmdBrowse(url, flags = {}) {
|
|
9
|
+
if (!url) { console.error('Usage: lazyclaw browse <url> [--max-bytes <N>] [--timeout-ms <N>] [--meta]'); process.exit(2); }
|
|
10
|
+
const { browse } = await import('../browse.mjs');
|
|
11
|
+
const opts = {};
|
|
12
|
+
if (flags['max-bytes'] !== undefined) opts.maxBytes = parseInt(flags['max-bytes'], 10);
|
|
13
|
+
if (flags['timeout-ms'] !== undefined) opts.timeoutMs = parseInt(flags['timeout-ms'], 10);
|
|
14
|
+
if (flags['user-agent']) opts.userAgent = flags['user-agent'];
|
|
15
|
+
try {
|
|
16
|
+
const r = await browse(url, opts);
|
|
17
|
+
if (flags.meta) {
|
|
18
|
+
process.stderr.write(JSON.stringify({
|
|
19
|
+
url: r.url, title: r.title, bytes: r.bytes, truncated: r.truncated,
|
|
20
|
+
}) + '\n');
|
|
21
|
+
}
|
|
22
|
+
process.stdout.write(r.markdown);
|
|
23
|
+
} catch (e) {
|
|
24
|
+
console.error(`error: ${e?.message || e}`);
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export async function cmdSandbox(args, flags = {}) {
|
|
29
|
+
const sub = args[0];
|
|
30
|
+
|
|
31
|
+
if (!sub || sub === 'list') {
|
|
32
|
+
for (const kind of listBackends()) process.stdout.write(`${kind}\n`);
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (sub === 'test') {
|
|
37
|
+
const name = args[1];
|
|
38
|
+
if (!name) { process.stderr.write('usage: lazyclaw sandbox test <kind|profile>\n'); return 2; }
|
|
39
|
+
const cfg = _sandboxLoadConfigOrEmpty();
|
|
40
|
+
// If `name` looks like a known kind, route to that kind. If it
|
|
41
|
+
// is not a known kind AND not a profile in cfg, treat as an
|
|
42
|
+
// unknown identifier and report SANDBOX_BAD_KIND.
|
|
43
|
+
const isKind = listBackends().includes(name);
|
|
44
|
+
const profile = cfg.sandbox && cfg.sandbox.profiles && cfg.sandbox.profiles[name];
|
|
45
|
+
if (!isKind && !profile) {
|
|
46
|
+
process.stderr.write(`SANDBOX_BAD_KIND: unknown sandbox kind or profile "${name}"\n`);
|
|
47
|
+
return 1;
|
|
48
|
+
}
|
|
49
|
+
let sb;
|
|
50
|
+
try {
|
|
51
|
+
const synthCfg = isKind
|
|
52
|
+
? { sandbox: { default: name, ...cfg.sandbox } }
|
|
53
|
+
: cfg;
|
|
54
|
+
sb = resolveSandbox(synthCfg);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
process.stderr.write(`${e.code || 'SANDBOX_ERR'}: ${e.message}\n`); return 1;
|
|
57
|
+
}
|
|
58
|
+
if (sb.spec.kind !== 'local' && sb.spec.kind !== 'docker') {
|
|
59
|
+
// Remote/serverless backends just construct argv in unit tests;
|
|
60
|
+
// we report "shape-ok" without actually executing.
|
|
61
|
+
process.stdout.write(`ok ${sb.spec.kind} (argv-shape)\n`);
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
const sess = await sb.open();
|
|
65
|
+
try {
|
|
66
|
+
const r = await sess.exec(['echo', 'lazyclaw-sandbox-test']);
|
|
67
|
+
if (r.code !== 0 || !/lazyclaw-sandbox-test/.test(r.stdout)) {
|
|
68
|
+
process.stderr.write(`fail ${name}: exit=${r.code} stdout=${r.stdout}\n`); return 1;
|
|
69
|
+
}
|
|
70
|
+
process.stdout.write(`ok ${name}\n`);
|
|
71
|
+
return 0;
|
|
72
|
+
} finally { await sess.close(); }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (sub === 'add') {
|
|
76
|
+
const name = args[1];
|
|
77
|
+
if (!name) { process.stderr.write('usage: lazyclaw sandbox add <name> --kind <kind> [...]\n'); return 2; }
|
|
78
|
+
const opts = {};
|
|
79
|
+
if (flags.kind) opts.kind = flags.kind;
|
|
80
|
+
if (flags.image) opts.image = flags.image;
|
|
81
|
+
if (flags.host) opts.host = flags.host;
|
|
82
|
+
if (flags.user) opts.user = flags.user;
|
|
83
|
+
if (flags.workspace) opts.workspace = flags.workspace;
|
|
84
|
+
if (flags.app) opts.app = flags.app;
|
|
85
|
+
if (flags.confiner) opts.confiner = flags.confiner;
|
|
86
|
+
if (!listBackends().includes(opts.kind)) {
|
|
87
|
+
process.stderr.write(`unknown kind "${opts.kind}"\n`); return 1;
|
|
88
|
+
}
|
|
89
|
+
const cfg = _sandboxLoadConfigOrEmpty();
|
|
90
|
+
cfg.sandbox = cfg.sandbox || {};
|
|
91
|
+
cfg.sandbox.profiles = cfg.sandbox.profiles || {};
|
|
92
|
+
cfg.sandbox.profiles[name] = opts;
|
|
93
|
+
_sandboxSaveConfig(cfg);
|
|
94
|
+
process.stdout.write(`added profile ${name} (${opts.kind})\n`);
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (sub === 'use') {
|
|
99
|
+
const name = args[1];
|
|
100
|
+
if (!name) { process.stderr.write('usage: lazyclaw sandbox use <profile>\n'); return 2; }
|
|
101
|
+
const cfg = _sandboxLoadConfigOrEmpty();
|
|
102
|
+
const prof = cfg.sandbox && cfg.sandbox.profiles && cfg.sandbox.profiles[name];
|
|
103
|
+
if (!prof) { process.stderr.write(`no profile "${name}"\n`); return 1; }
|
|
104
|
+
cfg.sandbox = cfg.sandbox || {};
|
|
105
|
+
cfg.sandbox.default = prof.kind;
|
|
106
|
+
cfg.sandbox[prof.kind] = { ...(cfg.sandbox[prof.kind] || {}), ...prof, kind: undefined };
|
|
107
|
+
delete cfg.sandbox[prof.kind].kind;
|
|
108
|
+
_sandboxSaveConfig(cfg);
|
|
109
|
+
process.stdout.write(`using profile ${name} (${prof.kind})\n`);
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
process.stderr.write(`unknown subcommand "${sub}". Try: list | test | add | use\n`);
|
|
114
|
+
return 2;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function _sandboxLoadConfigOrEmpty() {
|
|
118
|
+
const p = process.env.LAZYCLAW_CONFIG || configPath();
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
121
|
+
} catch { return {}; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function _sandboxSaveConfig(cfg) {
|
|
125
|
+
const p = process.env.LAZYCLAW_CONFIG || configPath();
|
|
126
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
127
|
+
fs.writeFileSync(p, JSON.stringify(cfg, null, 2));
|
|
128
|
+
}
|