opencode-jobs 1.0.0 → 1.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 +7 -2
- package/dist/cli.js +263 -13
- package/dist/index.js +141 -95
- package/dist/management.d.ts +9 -0
- package/dist/systemd.d.ts +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -79,11 +79,16 @@ opencode-jobs uninstall --purge
|
|
|
79
79
|
```text
|
|
80
80
|
opencode-jobs install [projectDir]
|
|
81
81
|
opencode-jobs uninstall [projectDir] [--purge]
|
|
82
|
+
opencode-jobs list [projectDir]
|
|
83
|
+
opencode-jobs enable [projectDir]
|
|
84
|
+
opencode-jobs disable [projectDir]
|
|
85
|
+
opencode-jobs run <slug> [projectDir]
|
|
82
86
|
```
|
|
83
87
|
|
|
84
88
|
The CLI is installed as the `opencode-jobs` npm executable. `projectDir`
|
|
85
|
-
defaults to the current directory.
|
|
86
|
-
|
|
89
|
+
defaults to the current directory. The management commands mirror the plugin's
|
|
90
|
+
list, project enable/disable, and immediate-run operations. All commands print
|
|
91
|
+
JSON results so they can also be used from setup scripts and CI.
|
|
87
92
|
|
|
88
93
|
## Tools
|
|
89
94
|
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import
|
|
4
|
+
import path9 from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
|
|
7
7
|
// src/install.ts
|
|
@@ -39,6 +39,9 @@ function scopeDirectory(scopeId) {
|
|
|
39
39
|
function runsDirectory(scopeId) {
|
|
40
40
|
return path.join(jobsStateDirectory(), "runs", scopeId);
|
|
41
41
|
}
|
|
42
|
+
function runsFile(scopeId, slug) {
|
|
43
|
+
return path.join(runsDirectory(scopeId), `${slug}.jsonl`);
|
|
44
|
+
}
|
|
42
45
|
function sessionStateDirectory(scopeId) {
|
|
43
46
|
return path.join(jobsStateDirectory(), "sessions", scopeId);
|
|
44
47
|
}
|
|
@@ -843,6 +846,16 @@ Hint: no systemd user session is reachable. Over SSH try enabling lingering: log
|
|
|
843
846
|
}
|
|
844
847
|
return "";
|
|
845
848
|
}
|
|
849
|
+
function isTimerLoaded(base) {
|
|
850
|
+
const result = systemctl([
|
|
851
|
+
"show",
|
|
852
|
+
timerUnit(base),
|
|
853
|
+
"-p",
|
|
854
|
+
"LoadState",
|
|
855
|
+
"--value"
|
|
856
|
+
]);
|
|
857
|
+
return result.ok && result.stdout !== "not-found";
|
|
858
|
+
}
|
|
846
859
|
function timerStatus(base) {
|
|
847
860
|
const result = systemctl([
|
|
848
861
|
"show",
|
|
@@ -880,17 +893,25 @@ function writeJobUnits(job, workdir, scopeId, opencodeBin, pathEnvironment) {
|
|
|
880
893
|
}
|
|
881
894
|
function removeJobUnits(scopeId, slug) {
|
|
882
895
|
const base = unitBase(scopeId, slug);
|
|
883
|
-
|
|
884
|
-
for (const file of [
|
|
896
|
+
const files = [
|
|
885
897
|
path4.join(systemdUserDirectory(), timerUnit(base)),
|
|
886
898
|
path4.join(systemdUserDirectory(), serviceUnit(base))
|
|
887
|
-
]
|
|
899
|
+
];
|
|
900
|
+
const script = runScriptPath(scopeId, slug);
|
|
901
|
+
if ([...files, script].every((file) => !existsSync2(file)) && !isTimerLoaded(base)) {
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
const disable = systemctl(["disable", "--now", timerUnit(base)]);
|
|
905
|
+
if (!disable.ok) {
|
|
906
|
+
return `${timerUnit(base)}: ${disable.stderr}${systemdHint(disable.stderr)}`;
|
|
907
|
+
}
|
|
908
|
+
for (const file of files) {
|
|
888
909
|
if (existsSync2(file))
|
|
889
910
|
rmSync(file);
|
|
890
911
|
}
|
|
891
|
-
const script = runScriptPath(scopeId, slug);
|
|
892
912
|
if (existsSync2(script))
|
|
893
913
|
rmSync(script);
|
|
914
|
+
return;
|
|
894
915
|
}
|
|
895
916
|
function removeStaleUnits(scopeId, expectedSlugs) {
|
|
896
917
|
const prefix = `opencode-sched-${scopeId}-`;
|
|
@@ -966,7 +987,7 @@ ${errors.join(`
|
|
|
966
987
|
lines.push(`Removed stale units for deleted jobs: ${removed.join(", ")}`);
|
|
967
988
|
lines.push(...describeJobSchedules(jobs, scopeId));
|
|
968
989
|
if (failures.length > 0)
|
|
969
|
-
|
|
990
|
+
throw new Error(`Timer activation failures:
|
|
970
991
|
${failures.join(`
|
|
971
992
|
`)}`);
|
|
972
993
|
return lines.join(`
|
|
@@ -987,9 +1008,19 @@ function disableProject(workdir) {
|
|
|
987
1008
|
const entry = registryEntry(abs);
|
|
988
1009
|
if (entry === undefined)
|
|
989
1010
|
return `Project is not enabled: ${abs}`;
|
|
990
|
-
|
|
991
|
-
removeJobUnits(entry.scopeId, slug);
|
|
992
|
-
|
|
1011
|
+
const failures = entry.jobs.flatMap((slug) => {
|
|
1012
|
+
const failure = removeJobUnits(entry.scopeId, slug);
|
|
1013
|
+
return failure === undefined ? [] : [failure];
|
|
1014
|
+
});
|
|
1015
|
+
if (failures.length > 0) {
|
|
1016
|
+
throw new Error(`Timer removal failures:
|
|
1017
|
+
${failures.join(`
|
|
1018
|
+
`)}`);
|
|
1019
|
+
}
|
|
1020
|
+
const reload = systemctl(["daemon-reload"]);
|
|
1021
|
+
if (!reload.ok) {
|
|
1022
|
+
throw new Error(`systemctl --user daemon-reload failed: ${reload.stderr}${systemdHint(reload.stderr)}`);
|
|
1023
|
+
}
|
|
993
1024
|
const registry = loadRegistry();
|
|
994
1025
|
const { [abs]: _omitted, ...remainingProjects } = registry.projects;
|
|
995
1026
|
registry.projects = remainingProjects;
|
|
@@ -1252,6 +1283,159 @@ function uninstallProject(projectDirectory, packageDirectory, shouldPurge) {
|
|
|
1252
1283
|
return uninstall;
|
|
1253
1284
|
}
|
|
1254
1285
|
|
|
1286
|
+
// src/management.ts
|
|
1287
|
+
import { spawn } from "node:child_process";
|
|
1288
|
+
import { closeSync, existsSync as existsSync6, mkdirSync as mkdirSync4, openSync } from "node:fs";
|
|
1289
|
+
import path8 from "node:path";
|
|
1290
|
+
|
|
1291
|
+
// src/runs.ts
|
|
1292
|
+
import { existsSync as existsSync5, readFileSync as readFileSync4 } from "node:fs";
|
|
1293
|
+
import { z as z4 } from "zod";
|
|
1294
|
+
var optionalString = z4.string().optional().catch(undefined);
|
|
1295
|
+
var optionalNumber = z4.number().optional().catch(undefined);
|
|
1296
|
+
var runRecordSchema = z4.object({
|
|
1297
|
+
runId: optionalString,
|
|
1298
|
+
slug: optionalString,
|
|
1299
|
+
scopeId: optionalString,
|
|
1300
|
+
startedAt: optionalNumber,
|
|
1301
|
+
finishedAt: optionalNumber,
|
|
1302
|
+
durationMs: optionalNumber,
|
|
1303
|
+
status: optionalString,
|
|
1304
|
+
exitCode: optionalNumber,
|
|
1305
|
+
sessionId: optionalString,
|
|
1306
|
+
startedBy: optionalString,
|
|
1307
|
+
worktreeBranch: optionalString,
|
|
1308
|
+
worktreeCommit: optionalString
|
|
1309
|
+
});
|
|
1310
|
+
function readRunRecords(scopeId, slug, limit) {
|
|
1311
|
+
const file = runsFile(scopeId, slug);
|
|
1312
|
+
if (!existsSync5(file))
|
|
1313
|
+
return [];
|
|
1314
|
+
const records = [];
|
|
1315
|
+
for (const line of readFileSync4(file, "utf8").split(`
|
|
1316
|
+
`)) {
|
|
1317
|
+
if (line.trim().length === 0)
|
|
1318
|
+
continue;
|
|
1319
|
+
try {
|
|
1320
|
+
const value = JSON.parse(line);
|
|
1321
|
+
const result = runRecordSchema.safeParse(value);
|
|
1322
|
+
if (result.success)
|
|
1323
|
+
records.push(result.data);
|
|
1324
|
+
} catch {}
|
|
1325
|
+
}
|
|
1326
|
+
return records.slice(-limit);
|
|
1327
|
+
}
|
|
1328
|
+
function lastFinishedRun(records) {
|
|
1329
|
+
for (const record of records.toReversed()) {
|
|
1330
|
+
if (record.status !== undefined && record.status !== "running")
|
|
1331
|
+
return record;
|
|
1332
|
+
}
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
function timestampOf(record) {
|
|
1336
|
+
if (record.finishedAt !== undefined)
|
|
1337
|
+
return new Date(record.finishedAt * 1000).toISOString();
|
|
1338
|
+
if (record.startedAt !== undefined)
|
|
1339
|
+
return new Date(record.startedAt * 1000).toISOString();
|
|
1340
|
+
return "?";
|
|
1341
|
+
}
|
|
1342
|
+
function formatRunLine(record) {
|
|
1343
|
+
const duration = record.durationMs === undefined ? "" : ` (${String(Math.round(record.durationMs / 1000))}s)`;
|
|
1344
|
+
const code = record.exitCode === undefined ? "" : ` exit ${String(record.exitCode)}`;
|
|
1345
|
+
const session = record.sessionId === undefined || record.sessionId.length === 0 ? "" : ` session ${record.sessionId}`;
|
|
1346
|
+
const worktree = record.worktreeBranch === undefined || record.worktreeBranch.length === 0 ? "" : ` worktree ${record.worktreeBranch}` + (record.worktreeCommit === undefined || record.worktreeCommit.length === 0 ? "" : `@${record.worktreeCommit.slice(0, 7)}`);
|
|
1347
|
+
return `${timestampOf(record)} ${record.status ?? "?"}${code}${duration}${session}${worktree} via ${record.startedBy ?? "?"}`;
|
|
1348
|
+
}
|
|
1349
|
+
function tailFile(file, lines, maxChars) {
|
|
1350
|
+
if (!existsSync5(file))
|
|
1351
|
+
return;
|
|
1352
|
+
const content = readFileSync4(file, "utf8").trimEnd();
|
|
1353
|
+
if (content.length === 0)
|
|
1354
|
+
return "";
|
|
1355
|
+
const tail = content.split(`
|
|
1356
|
+
`).slice(-lines).join(`
|
|
1357
|
+
`);
|
|
1358
|
+
return tail.length > maxChars ? `...${tail.slice(-maxChars)}` : tail;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// src/management.ts
|
|
1362
|
+
function tryParseCron(schedule) {
|
|
1363
|
+
try {
|
|
1364
|
+
return parseCron(schedule);
|
|
1365
|
+
} catch {
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
function listJobs(directory) {
|
|
1370
|
+
const { jobs, errors } = loadJobs(directory);
|
|
1371
|
+
const entry = registryEntry(directory);
|
|
1372
|
+
const header = entry ? `Project enabled (scope ${entry.scopeId}). Job definitions: ${jobsDirectory(directory)}` : `Project not enabled. Job definitions: ${jobsDirectory(directory)}`;
|
|
1373
|
+
const lines = [header];
|
|
1374
|
+
if (jobs.length === 0)
|
|
1375
|
+
lines.push("No job definitions. Create one with schedule_job.");
|
|
1376
|
+
for (const job of jobs) {
|
|
1377
|
+
const scopeId = entry?.scopeId ?? deriveScopeId(directory);
|
|
1378
|
+
const sets = tryParseCron(job.schedule);
|
|
1379
|
+
if (sets === undefined) {
|
|
1380
|
+
lines.push(`- ${job.slug}: INVALID schedule "${job.schedule}"`);
|
|
1381
|
+
continue;
|
|
1382
|
+
}
|
|
1383
|
+
const records = readRunRecords(scopeId, job.slug, 20);
|
|
1384
|
+
const last = lastFinishedRun(records);
|
|
1385
|
+
const lastDesc = last === undefined ? ", last: never" : `, last: ${last.status ?? "?"} ${formatRunLine(last)}`;
|
|
1386
|
+
const next = entry === undefined ? undefined : timerStatus(unitBase(scopeId, job.slug)).next;
|
|
1387
|
+
const nextDesc = next === undefined ? "" : `, next: ${next}`;
|
|
1388
|
+
lines.push(`- ${job.slug}: ${job.schedule} (${describeCron(sets)})${nextDesc}${lastDesc}`);
|
|
1389
|
+
}
|
|
1390
|
+
lines.push(...errors.map((error) => `! ${error}`));
|
|
1391
|
+
return { ok: true, output: lines.join(`
|
|
1392
|
+
`) };
|
|
1393
|
+
}
|
|
1394
|
+
function runJobNow(slugInput, directory) {
|
|
1395
|
+
const slug = slugify(slugInput);
|
|
1396
|
+
const file = path8.join(jobsDirectory(directory), `${slug}.json`);
|
|
1397
|
+
if (!existsSync6(file)) {
|
|
1398
|
+
return {
|
|
1399
|
+
ok: false,
|
|
1400
|
+
output: `No job "${slug}" in ${jobsDirectory(directory)}`
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
const entry = registryEntry(directory);
|
|
1404
|
+
if (entry === undefined) {
|
|
1405
|
+
return {
|
|
1406
|
+
ok: false,
|
|
1407
|
+
output: `Project is not enabled, so no run script exists for "${slug}". Run enable_project first.`
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
const script = runScriptPath(entry.scopeId, slug);
|
|
1411
|
+
if (!existsSync6(script)) {
|
|
1412
|
+
return {
|
|
1413
|
+
ok: false,
|
|
1414
|
+
output: `Run script missing for "${slug}". Run enable_project to (re)install units.`
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
const log = logFile(entry.scopeId, slug);
|
|
1418
|
+
mkdirSync4(logDirectory(entry.scopeId), { recursive: true });
|
|
1419
|
+
const fd = openSync(log, "a");
|
|
1420
|
+
const child = spawn("/bin/sh", [script], {
|
|
1421
|
+
cwd: path8.resolve(directory),
|
|
1422
|
+
env: { ...process.env, OPENCODE_JOBS_STARTED_BY: "manual" },
|
|
1423
|
+
stdio: ["ignore", fd, fd]
|
|
1424
|
+
});
|
|
1425
|
+
child.unref();
|
|
1426
|
+
closeSync(fd);
|
|
1427
|
+
const tail = tailFile(log, 5, 2000);
|
|
1428
|
+
const parts = [
|
|
1429
|
+
`Started "${slug}" manually (pid ${String(child.pid)})`,
|
|
1430
|
+
`Log: ${log}`
|
|
1431
|
+
];
|
|
1432
|
+
if (tail?.length)
|
|
1433
|
+
parts.push(`Log tail:
|
|
1434
|
+
${tail}`);
|
|
1435
|
+
return { ok: true, output: parts.join(`
|
|
1436
|
+
`) };
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1255
1439
|
// src/cli.ts
|
|
1256
1440
|
var USAGE = `Usage: opencode-jobs <command> [projectDir]
|
|
1257
1441
|
|
|
@@ -1259,16 +1443,45 @@ Commands:
|
|
|
1259
1443
|
install [projectDir] Add the plugin and bundled skill to a project (default: current directory)
|
|
1260
1444
|
uninstall [projectDir] [--purge] Remove the plugin entry, skill, and systemd units from a project;
|
|
1261
1445
|
--purge also deletes job definitions and job data
|
|
1446
|
+
list [projectDir] List jobs and their enabled, next-run, and last-run state
|
|
1447
|
+
enable [projectDir] Enable or re-sync all jobs in a project
|
|
1448
|
+
disable [projectDir] Disable all jobs in a project while keeping definitions and history
|
|
1449
|
+
run <slug> [projectDir] Run one enabled job immediately
|
|
1262
1450
|
help Show this help`;
|
|
1263
1451
|
function packageDirectory() {
|
|
1264
|
-
return
|
|
1452
|
+
return path9.resolve(path9.dirname(fileURLToPath(import.meta.url)), "..");
|
|
1265
1453
|
}
|
|
1266
1454
|
function printError(message) {
|
|
1267
|
-
console.
|
|
1268
|
-
|
|
1269
|
-
${USAGE}`);
|
|
1455
|
+
console.log(JSON.stringify({ ok: false, output: `Error: ${message}` }, undefined, 2));
|
|
1456
|
+
console.error(USAGE);
|
|
1270
1457
|
process.exitCode = 1;
|
|
1271
1458
|
}
|
|
1459
|
+
function projectArgument(command, arguments_) {
|
|
1460
|
+
if (arguments_.some((argument) => argument.startsWith("--"))) {
|
|
1461
|
+
throw new Error(`${command} does not accept options`);
|
|
1462
|
+
}
|
|
1463
|
+
if (arguments_.length > 1) {
|
|
1464
|
+
throw new Error(`${command} accepts at most one project directory`);
|
|
1465
|
+
}
|
|
1466
|
+
return arguments_[0] ?? process.cwd();
|
|
1467
|
+
}
|
|
1468
|
+
function runArguments(arguments_) {
|
|
1469
|
+
if (arguments_.some((argument) => argument.startsWith("--"))) {
|
|
1470
|
+
throw new Error("run does not accept options");
|
|
1471
|
+
}
|
|
1472
|
+
if (arguments_.length === 0 || arguments_.length > 2) {
|
|
1473
|
+
throw new Error("run requires a job slug and accepts one project directory");
|
|
1474
|
+
}
|
|
1475
|
+
return {
|
|
1476
|
+
slug: arguments_[0] ?? "",
|
|
1477
|
+
project: arguments_[1] ?? process.cwd()
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
function printManagementResult(result) {
|
|
1481
|
+
console.log(JSON.stringify(result, undefined, 2));
|
|
1482
|
+
if (!result.ok)
|
|
1483
|
+
process.exitCode = 1;
|
|
1484
|
+
}
|
|
1272
1485
|
function parseUninstallArguments(arguments_) {
|
|
1273
1486
|
let project;
|
|
1274
1487
|
let shouldPurge = false;
|
|
@@ -1318,6 +1531,43 @@ if ([undefined, "help", "--help"].includes(command)) {
|
|
|
1318
1531
|
} catch (error) {
|
|
1319
1532
|
printError(errorMessage(error));
|
|
1320
1533
|
}
|
|
1534
|
+
} else if (command !== undefined && ["list", "enable", "disable", "run"].includes(command)) {
|
|
1535
|
+
try {
|
|
1536
|
+
let slug;
|
|
1537
|
+
let project;
|
|
1538
|
+
if (command === "run") {
|
|
1539
|
+
({ slug, project } = runArguments(rest));
|
|
1540
|
+
} else {
|
|
1541
|
+
project = projectArgument(command, rest);
|
|
1542
|
+
}
|
|
1543
|
+
const migration = migrateStorage(project, true);
|
|
1544
|
+
for (const warning of migration.warnings) {
|
|
1545
|
+
console.error(`storage migration warning: ${warning}`);
|
|
1546
|
+
}
|
|
1547
|
+
switch (command) {
|
|
1548
|
+
case "list": {
|
|
1549
|
+
printManagementResult(listJobs(project));
|
|
1550
|
+
break;
|
|
1551
|
+
}
|
|
1552
|
+
case "enable": {
|
|
1553
|
+
printManagementResult({ ok: true, output: enableProject(project) });
|
|
1554
|
+
break;
|
|
1555
|
+
}
|
|
1556
|
+
case "disable": {
|
|
1557
|
+
printManagementResult({ ok: true, output: disableProject(project) });
|
|
1558
|
+
break;
|
|
1559
|
+
}
|
|
1560
|
+
case "run": {
|
|
1561
|
+
printManagementResult(runJobNow(slug ?? "", project));
|
|
1562
|
+
break;
|
|
1563
|
+
}
|
|
1564
|
+
default: {
|
|
1565
|
+
throw new Error(`unknown management command: ${command}`);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
} catch (error) {
|
|
1569
|
+
printError(errorMessage(error));
|
|
1570
|
+
}
|
|
1321
1571
|
} else {
|
|
1322
1572
|
printError(`unknown command: ${command ?? "(missing)"}`);
|
|
1323
1573
|
}
|
package/dist/index.js
CHANGED
|
@@ -3,16 +3,8 @@
|
|
|
3
3
|
import {
|
|
4
4
|
tool
|
|
5
5
|
} from "@opencode-ai/plugin";
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
existsSync as existsSync4,
|
|
9
|
-
mkdirSync as mkdirSync3,
|
|
10
|
-
openSync,
|
|
11
|
-
readFileSync as readFileSync4,
|
|
12
|
-
rmSync as rmSync2
|
|
13
|
-
} from "fs";
|
|
14
|
-
import { spawn } from "child_process";
|
|
15
|
-
import path6 from "path";
|
|
6
|
+
import { existsSync as existsSync5, readFileSync as readFileSync4, rmSync as rmSync2 } from "fs";
|
|
7
|
+
import path7 from "path";
|
|
16
8
|
|
|
17
9
|
// src/cron.ts
|
|
18
10
|
var DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
@@ -951,6 +943,16 @@ Hint: no systemd user session is reachable. Over SSH try enabling lingering: log
|
|
|
951
943
|
}
|
|
952
944
|
return "";
|
|
953
945
|
}
|
|
946
|
+
function isTimerLoaded(base) {
|
|
947
|
+
const result = systemctl([
|
|
948
|
+
"show",
|
|
949
|
+
timerUnit(base),
|
|
950
|
+
"-p",
|
|
951
|
+
"LoadState",
|
|
952
|
+
"--value"
|
|
953
|
+
]);
|
|
954
|
+
return result.ok && result.stdout !== "not-found";
|
|
955
|
+
}
|
|
954
956
|
function timerStatus(base) {
|
|
955
957
|
const result = systemctl([
|
|
956
958
|
"show",
|
|
@@ -988,17 +990,25 @@ function writeJobUnits(job, workdir, scopeId, opencodeBin, pathEnvironment) {
|
|
|
988
990
|
}
|
|
989
991
|
function removeJobUnits(scopeId, slug) {
|
|
990
992
|
const base = unitBase(scopeId, slug);
|
|
991
|
-
|
|
992
|
-
for (const file of [
|
|
993
|
+
const files = [
|
|
993
994
|
path4.join(systemdUserDirectory(), timerUnit(base)),
|
|
994
995
|
path4.join(systemdUserDirectory(), serviceUnit(base))
|
|
995
|
-
]
|
|
996
|
+
];
|
|
997
|
+
const script = runScriptPath(scopeId, slug);
|
|
998
|
+
if ([...files, script].every((file) => !existsSync3(file)) && !isTimerLoaded(base)) {
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
const disable = systemctl(["disable", "--now", timerUnit(base)]);
|
|
1002
|
+
if (!disable.ok) {
|
|
1003
|
+
return `${timerUnit(base)}: ${disable.stderr}${systemdHint(disable.stderr)}`;
|
|
1004
|
+
}
|
|
1005
|
+
for (const file of files) {
|
|
996
1006
|
if (existsSync3(file))
|
|
997
1007
|
rmSync(file);
|
|
998
1008
|
}
|
|
999
|
-
const script = runScriptPath(scopeId, slug);
|
|
1000
1009
|
if (existsSync3(script))
|
|
1001
1010
|
rmSync(script);
|
|
1011
|
+
return;
|
|
1002
1012
|
}
|
|
1003
1013
|
function removeStaleUnits(scopeId, expectedSlugs) {
|
|
1004
1014
|
const prefix = `opencode-sched-${scopeId}-`;
|
|
@@ -1075,7 +1085,7 @@ ${errors.join(`
|
|
|
1075
1085
|
lines.push(`Removed stale units for deleted jobs: ${removed.join(", ")}`);
|
|
1076
1086
|
lines.push(...describeJobSchedules(jobs, scopeId));
|
|
1077
1087
|
if (failures.length > 0)
|
|
1078
|
-
|
|
1088
|
+
throw new Error(`Timer activation failures:
|
|
1079
1089
|
${failures.join(`
|
|
1080
1090
|
`)}`);
|
|
1081
1091
|
return lines.join(`
|
|
@@ -1096,9 +1106,19 @@ function disableProject(workdir) {
|
|
|
1096
1106
|
const entry = registryEntry(abs);
|
|
1097
1107
|
if (entry === undefined)
|
|
1098
1108
|
return `Project is not enabled: ${abs}`;
|
|
1099
|
-
|
|
1100
|
-
removeJobUnits(entry.scopeId, slug);
|
|
1101
|
-
|
|
1109
|
+
const failures = entry.jobs.flatMap((slug) => {
|
|
1110
|
+
const failure = removeJobUnits(entry.scopeId, slug);
|
|
1111
|
+
return failure === undefined ? [] : [failure];
|
|
1112
|
+
});
|
|
1113
|
+
if (failures.length > 0) {
|
|
1114
|
+
throw new Error(`Timer removal failures:
|
|
1115
|
+
${failures.join(`
|
|
1116
|
+
`)}`);
|
|
1117
|
+
}
|
|
1118
|
+
const reload = systemctl(["daemon-reload"]);
|
|
1119
|
+
if (!reload.ok) {
|
|
1120
|
+
throw new Error(`systemctl --user daemon-reload failed: ${reload.stderr}${systemdHint(reload.stderr)}`);
|
|
1121
|
+
}
|
|
1102
1122
|
const registry = loadRegistry();
|
|
1103
1123
|
const { [abs]: _omitted, ...remainingProjects } = registry.projects;
|
|
1104
1124
|
registry.projects = remainingProjects;
|
|
@@ -1111,13 +1131,10 @@ function disableProject(workdir) {
|
|
|
1111
1131
|
`);
|
|
1112
1132
|
}
|
|
1113
1133
|
|
|
1114
|
-
// src/
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
function fail(message) {
|
|
1119
|
-
return { output: `Error: ${message}`, metadata: { error: true } };
|
|
1120
|
-
}
|
|
1134
|
+
// src/management.ts
|
|
1135
|
+
import { spawn } from "child_process";
|
|
1136
|
+
import { closeSync, existsSync as existsSync4, mkdirSync as mkdirSync3, openSync } from "fs";
|
|
1137
|
+
import path6 from "path";
|
|
1121
1138
|
function tryParseCron(schedule) {
|
|
1122
1139
|
try {
|
|
1123
1140
|
return parseCron(schedule);
|
|
@@ -1125,7 +1142,7 @@ function tryParseCron(schedule) {
|
|
|
1125
1142
|
return;
|
|
1126
1143
|
}
|
|
1127
1144
|
}
|
|
1128
|
-
function
|
|
1145
|
+
function listJobs(directory) {
|
|
1129
1146
|
const { jobs, errors } = loadJobs(directory);
|
|
1130
1147
|
const entry = registryEntry(directory);
|
|
1131
1148
|
const header = entry ? `Project enabled (scope ${entry.scopeId}). Job definitions: ${jobsDirectory(directory)}` : `Project not enabled. Job definitions: ${jobsDirectory(directory)}`;
|
|
@@ -1147,8 +1164,63 @@ function listJobsOutput(directory) {
|
|
|
1147
1164
|
lines.push(`- ${job.slug}: ${job.schedule} (${describeCron(sets)})${nextDesc}${lastDesc}`);
|
|
1148
1165
|
}
|
|
1149
1166
|
lines.push(...errors.map((error) => `! ${error}`));
|
|
1150
|
-
return ok
|
|
1151
|
-
`)
|
|
1167
|
+
return { ok: true, output: lines.join(`
|
|
1168
|
+
`) };
|
|
1169
|
+
}
|
|
1170
|
+
function runJobNow(slugInput, directory) {
|
|
1171
|
+
const slug = slugify(slugInput);
|
|
1172
|
+
const file = path6.join(jobsDirectory(directory), `${slug}.json`);
|
|
1173
|
+
if (!existsSync4(file)) {
|
|
1174
|
+
return {
|
|
1175
|
+
ok: false,
|
|
1176
|
+
output: `No job "${slug}" in ${jobsDirectory(directory)}`
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
const entry = registryEntry(directory);
|
|
1180
|
+
if (entry === undefined) {
|
|
1181
|
+
return {
|
|
1182
|
+
ok: false,
|
|
1183
|
+
output: `Project is not enabled, so no run script exists for "${slug}". Run enable_project first.`
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
const script = runScriptPath(entry.scopeId, slug);
|
|
1187
|
+
if (!existsSync4(script)) {
|
|
1188
|
+
return {
|
|
1189
|
+
ok: false,
|
|
1190
|
+
output: `Run script missing for "${slug}". Run enable_project to (re)install units.`
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
const log = logFile(entry.scopeId, slug);
|
|
1194
|
+
mkdirSync3(logDirectory(entry.scopeId), { recursive: true });
|
|
1195
|
+
const fd = openSync(log, "a");
|
|
1196
|
+
const child = spawn("/bin/sh", [script], {
|
|
1197
|
+
cwd: path6.resolve(directory),
|
|
1198
|
+
env: { ...process.env, OPENCODE_JOBS_STARTED_BY: "manual" },
|
|
1199
|
+
stdio: ["ignore", fd, fd]
|
|
1200
|
+
});
|
|
1201
|
+
child.unref();
|
|
1202
|
+
closeSync(fd);
|
|
1203
|
+
const tail = tailFile(log, 5, 2000);
|
|
1204
|
+
const parts = [
|
|
1205
|
+
`Started "${slug}" manually (pid ${String(child.pid)})`,
|
|
1206
|
+
`Log: ${log}`
|
|
1207
|
+
];
|
|
1208
|
+
if (tail?.length)
|
|
1209
|
+
parts.push(`Log tail:
|
|
1210
|
+
${tail}`);
|
|
1211
|
+
return { ok: true, output: parts.join(`
|
|
1212
|
+
`) };
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// src/tools.ts
|
|
1216
|
+
function ok(output) {
|
|
1217
|
+
return { output };
|
|
1218
|
+
}
|
|
1219
|
+
function fail(message) {
|
|
1220
|
+
return { output: `Error: ${message}`, metadata: { error: true } };
|
|
1221
|
+
}
|
|
1222
|
+
function managementToolResult(result) {
|
|
1223
|
+
return result.ok ? ok(result.output) : fail(result.output);
|
|
1152
1224
|
}
|
|
1153
1225
|
function scheduleJobOutput(input, directory) {
|
|
1154
1226
|
const slug = slugify(input.slug ?? input.name);
|
|
@@ -1192,7 +1264,7 @@ function scheduleJobOutput(input, directory) {
|
|
|
1192
1264
|
} catch (error) {
|
|
1193
1265
|
return fail(errorMessage(error));
|
|
1194
1266
|
}
|
|
1195
|
-
const existing = loadJobFile(
|
|
1267
|
+
const existing = loadJobFile(path7.join(jobsDirectory(directory), `${slug}.json`), slug);
|
|
1196
1268
|
const job = {
|
|
1197
1269
|
slug,
|
|
1198
1270
|
name: input.name,
|
|
@@ -1221,7 +1293,7 @@ function scheduleJobOutput(input, directory) {
|
|
|
1221
1293
|
return ok(lines.join(`
|
|
1222
1294
|
`));
|
|
1223
1295
|
}
|
|
1224
|
-
const abs =
|
|
1296
|
+
const abs = path7.resolve(directory);
|
|
1225
1297
|
const opencodeBin = findOpencode();
|
|
1226
1298
|
const pathEnvironment = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
|
|
1227
1299
|
const base = writeJobUnits(job, abs, entry.scopeId, opencodeBin, pathEnvironment);
|
|
@@ -1247,8 +1319,8 @@ function scheduleJobOutput(input, directory) {
|
|
|
1247
1319
|
`));
|
|
1248
1320
|
}
|
|
1249
1321
|
function showJobOutput(slugInput, directory) {
|
|
1250
|
-
const file =
|
|
1251
|
-
if (!
|
|
1322
|
+
const file = path7.join(jobsDirectory(directory), `${slugify(slugInput)}.json`);
|
|
1323
|
+
if (!existsSync5(file)) {
|
|
1252
1324
|
return fail(`No job "${slugInput}" in ${jobsDirectory(directory)}. Use list_jobs to see definitions.`);
|
|
1253
1325
|
}
|
|
1254
1326
|
const result = loadJobFile(file);
|
|
@@ -1273,7 +1345,7 @@ function showJobOutput(slugInput, directory) {
|
|
|
1273
1345
|
}
|
|
1274
1346
|
if (job.session !== undefined) {
|
|
1275
1347
|
const state = sessionStateFile(scopeId, job.slug);
|
|
1276
|
-
const sessionId =
|
|
1348
|
+
const sessionId = existsSync5(state) ? readFileSync4(state, "utf8").trim() : "";
|
|
1277
1349
|
lines.push(`Session: ${job.session}${sessionId.length > 0 ? ` \u2014 current ${sessionId}` : " \u2014 no session yet"}`);
|
|
1278
1350
|
}
|
|
1279
1351
|
if (job.run.agent !== undefined)
|
|
@@ -1308,22 +1380,30 @@ function showJobOutput(slugInput, directory) {
|
|
|
1308
1380
|
}
|
|
1309
1381
|
function removeJobDefinitionOutput(slugInput, directory) {
|
|
1310
1382
|
const slug = slugify(slugInput);
|
|
1311
|
-
const file =
|
|
1312
|
-
if (!
|
|
1383
|
+
const file = path7.join(jobsDirectory(directory), `${slug}.json`);
|
|
1384
|
+
if (!existsSync5(file))
|
|
1313
1385
|
return fail(`No job "${slug}" in ${jobsDirectory(directory)}`);
|
|
1386
|
+
const abs = path7.resolve(directory);
|
|
1387
|
+
const entry = registryEntry(directory);
|
|
1388
|
+
if (entry?.jobs.includes(slug)) {
|
|
1389
|
+
const removalFailure = removeJobUnits(entry.scopeId, slug);
|
|
1390
|
+
if (removalFailure !== undefined) {
|
|
1391
|
+
return fail(`Failed to remove systemd units: ${removalFailure}`);
|
|
1392
|
+
}
|
|
1393
|
+
const reload = systemctl(["daemon-reload"]);
|
|
1394
|
+
if (!reload.ok) {
|
|
1395
|
+
return fail(`Removed the units but systemd reload failed: ${reload.stderr}${systemdHint(reload.stderr)}`);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1314
1398
|
rmSync2(file);
|
|
1315
1399
|
const lines = [`Deleted job definition .opencode/jobs/${slug}.json`];
|
|
1316
|
-
const scopeId =
|
|
1400
|
+
const scopeId = entry?.scopeId ?? deriveScopeId(directory);
|
|
1317
1401
|
const state = sessionStateFile(scopeId, slug);
|
|
1318
|
-
if (
|
|
1402
|
+
if (existsSync5(state)) {
|
|
1319
1403
|
rmSync2(state);
|
|
1320
1404
|
lines.push(`Removed session state ${state}`);
|
|
1321
1405
|
}
|
|
1322
|
-
const abs = path6.resolve(directory);
|
|
1323
|
-
const entry = registryEntry(directory);
|
|
1324
1406
|
if (entry?.jobs.includes(slug)) {
|
|
1325
|
-
removeJobUnits(entry.scopeId, slug);
|
|
1326
|
-
systemctl(["daemon-reload"]);
|
|
1327
1407
|
const registry = loadRegistry();
|
|
1328
1408
|
const current = registry.projects[abs];
|
|
1329
1409
|
if (current !== undefined) {
|
|
@@ -1342,40 +1422,6 @@ function omitProject(registry, workdir) {
|
|
|
1342
1422
|
const { [workdir]: _omitted, ...remaining } = registry.projects;
|
|
1343
1423
|
registry.projects = remaining;
|
|
1344
1424
|
}
|
|
1345
|
-
function runJobNowOutput(slugInput, directory) {
|
|
1346
|
-
const slug = slugify(slugInput);
|
|
1347
|
-
const file = path6.join(jobsDirectory(directory), `${slug}.json`);
|
|
1348
|
-
if (!existsSync4(file))
|
|
1349
|
-
return fail(`No job "${slug}" in ${jobsDirectory(directory)}`);
|
|
1350
|
-
const entry = registryEntry(directory);
|
|
1351
|
-
if (entry === undefined) {
|
|
1352
|
-
return fail(`Project is not enabled, so no run script exists for "${slug}". Run enable_project first.`);
|
|
1353
|
-
}
|
|
1354
|
-
const script = runScriptPath(entry.scopeId, slug);
|
|
1355
|
-
if (!existsSync4(script)) {
|
|
1356
|
-
return fail(`Run script missing for "${slug}". Run enable_project to (re)install units.`);
|
|
1357
|
-
}
|
|
1358
|
-
const log = logFile(entry.scopeId, slug);
|
|
1359
|
-
mkdirSync3(logDirectory(entry.scopeId), { recursive: true });
|
|
1360
|
-
const fd = openSync(log, "a");
|
|
1361
|
-
const child = spawn("/bin/sh", [script], {
|
|
1362
|
-
cwd: path6.resolve(directory),
|
|
1363
|
-
env: { ...process.env, OPENCODE_JOBS_STARTED_BY: "manual" },
|
|
1364
|
-
stdio: ["ignore", fd, fd]
|
|
1365
|
-
});
|
|
1366
|
-
child.unref();
|
|
1367
|
-
closeSync(fd);
|
|
1368
|
-
const tail = tailFile(log, 5, 2000);
|
|
1369
|
-
const parts = [
|
|
1370
|
-
`Started "${slug}" manually (pid ${String(child.pid)})`,
|
|
1371
|
-
`Log: ${log}`
|
|
1372
|
-
];
|
|
1373
|
-
if (tail?.length)
|
|
1374
|
-
parts.push(`Log tail:
|
|
1375
|
-
${tail}`);
|
|
1376
|
-
return ok(parts.join(`
|
|
1377
|
-
`));
|
|
1378
|
-
}
|
|
1379
1425
|
function jobLogsOutput(slugInput, lineCountInput, directory) {
|
|
1380
1426
|
const entry = registryEntry(directory);
|
|
1381
1427
|
const scopeId = entry?.scopeId ?? deriveScopeId(directory);
|
|
@@ -1396,7 +1442,7 @@ function listProjectsOutput() {
|
|
|
1396
1442
|
return ok("No projects with scheduled jobs are registered.");
|
|
1397
1443
|
const lines = ["Registry: ~/.config/opencode/jobs/registry.json"];
|
|
1398
1444
|
for (const entry of entries) {
|
|
1399
|
-
const missing =
|
|
1445
|
+
const missing = existsSync5(entry.workdir) ? "" : " [WORKDIR MISSING]";
|
|
1400
1446
|
lines.push(`- ${entry.workdir}${missing}`, ` scope ${entry.scopeId}, ${String(entry.jobs.length)} job(s): ${entry.jobs.join(", ")}`);
|
|
1401
1447
|
}
|
|
1402
1448
|
return ok(lines.join(`
|
|
@@ -1405,7 +1451,7 @@ function listProjectsOutput() {
|
|
|
1405
1451
|
var listJobsTool = tool({
|
|
1406
1452
|
description: "List scheduled job definitions for the current project (from .opencode/jobs/), including enabled state, next run, and last run status.",
|
|
1407
1453
|
args: {},
|
|
1408
|
-
execute: (_input, context) => Promise.resolve(
|
|
1454
|
+
execute: (_input, context) => Promise.resolve(managementToolResult(listJobs(context.directory)))
|
|
1409
1455
|
});
|
|
1410
1456
|
var scheduleJobTool = tool({
|
|
1411
1457
|
description: "Create or update a scheduled job definition in the current project (.opencode/jobs/<slug>.json, git-committable). Schedule is a 5-field cron expression. Set either prompt (natural language) or command (custom command name). If the project is enabled, systemd units are re-synced automatically.",
|
|
@@ -1447,7 +1493,7 @@ var runJobTool = tool({
|
|
|
1447
1493
|
args: {
|
|
1448
1494
|
slug: tool.schema.string().describe("Job slug to run now")
|
|
1449
1495
|
},
|
|
1450
|
-
execute: (input, context) => Promise.resolve(
|
|
1496
|
+
execute: (input, context) => Promise.resolve(managementToolResult(runJobNow(input.slug, context.directory)))
|
|
1451
1497
|
});
|
|
1452
1498
|
var jobLogsTool = tool({
|
|
1453
1499
|
description: "Show the tail of a scheduled job's log file (scheduled and manual runs both append to it).",
|
|
@@ -1499,16 +1545,16 @@ var jobsTools = {
|
|
|
1499
1545
|
};
|
|
1500
1546
|
|
|
1501
1547
|
// src/migration.ts
|
|
1502
|
-
import { existsSync as
|
|
1503
|
-
import
|
|
1548
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync4, renameSync as renameSync2, rmdirSync } from "fs";
|
|
1549
|
+
import path8 from "path";
|
|
1504
1550
|
function legacyJobsStateDirectory() {
|
|
1505
|
-
return
|
|
1551
|
+
return path8.join(configRoot(), "opencode", "scheduler");
|
|
1506
1552
|
}
|
|
1507
1553
|
function legacyRegistryPath() {
|
|
1508
|
-
return
|
|
1554
|
+
return path8.join(legacyJobsStateDirectory(), "registry.json");
|
|
1509
1555
|
}
|
|
1510
1556
|
function legacyDefinitionsDirectory(workdir) {
|
|
1511
|
-
return
|
|
1557
|
+
return path8.join(workdir, ".opencode", "scheduler", "jobs");
|
|
1512
1558
|
}
|
|
1513
1559
|
function migrationMoves(projects) {
|
|
1514
1560
|
return [
|
|
@@ -1517,12 +1563,12 @@ function migrationMoves(projects) {
|
|
|
1517
1563
|
to: jobsStateDirectory()
|
|
1518
1564
|
},
|
|
1519
1565
|
{
|
|
1520
|
-
from:
|
|
1521
|
-
to:
|
|
1566
|
+
from: path8.join(configRoot(), "opencode", "logs", "scheduler"),
|
|
1567
|
+
to: path8.join(configRoot(), "opencode", "logs", "jobs")
|
|
1522
1568
|
},
|
|
1523
1569
|
{
|
|
1524
|
-
from:
|
|
1525
|
-
to:
|
|
1570
|
+
from: path8.join(stateRoot(), "opencode", "scheduler", "worktrees"),
|
|
1571
|
+
to: path8.join(stateRoot(), "opencode", "jobs", "worktrees")
|
|
1526
1572
|
},
|
|
1527
1573
|
...[...projects].map((workdir) => ({
|
|
1528
1574
|
from: legacyDefinitionsDirectory(workdir),
|
|
@@ -1532,25 +1578,25 @@ function migrationMoves(projects) {
|
|
|
1532
1578
|
}
|
|
1533
1579
|
function removeLegacyProjectDirectory(workdir) {
|
|
1534
1580
|
try {
|
|
1535
|
-
rmdirSync(
|
|
1581
|
+
rmdirSync(path8.join(workdir, ".opencode", "scheduler"));
|
|
1536
1582
|
} catch {}
|
|
1537
1583
|
}
|
|
1538
1584
|
function migrateStorage(projectDirectory, shouldResync) {
|
|
1539
|
-
const project =
|
|
1585
|
+
const project = path8.resolve(projectDirectory);
|
|
1540
1586
|
const legacyRegistry = legacyRegistryPath();
|
|
1541
|
-
const canonicalRegistry =
|
|
1542
|
-
const registryFile =
|
|
1543
|
-
const registry =
|
|
1587
|
+
const canonicalRegistry = path8.join(jobsStateDirectory(), "registry.json");
|
|
1588
|
+
const registryFile = existsSync6(legacyRegistry) ? legacyRegistry : canonicalRegistry;
|
|
1589
|
+
const registry = existsSync6(registryFile) ? readRegistryFile(registryFile) : { version: 1, projects: {} };
|
|
1544
1590
|
const registeredProjects = new Set(Object.keys(registry.projects));
|
|
1545
1591
|
const projects = new Set([project, ...registeredProjects]);
|
|
1546
|
-
const moves = migrationMoves(projects).filter(({ from }) =>
|
|
1592
|
+
const moves = migrationMoves(projects).filter(({ from }) => existsSync6(from));
|
|
1547
1593
|
for (const { from, to } of moves) {
|
|
1548
|
-
if (
|
|
1594
|
+
if (existsSync6(to)) {
|
|
1549
1595
|
throw new Error(`Cannot migrate legacy job storage because both paths exist: ${from} and ${to}. Reconcile or back up one path, then retry; neither path was changed.`);
|
|
1550
1596
|
}
|
|
1551
1597
|
}
|
|
1552
1598
|
for (const { from, to } of moves) {
|
|
1553
|
-
mkdirSync4(
|
|
1599
|
+
mkdirSync4(path8.dirname(to), { recursive: true });
|
|
1554
1600
|
renameSync2(from, to);
|
|
1555
1601
|
}
|
|
1556
1602
|
for (const workdir of projects)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type ManagementResult = {
|
|
2
|
+
ok: true;
|
|
3
|
+
output: string;
|
|
4
|
+
} | {
|
|
5
|
+
ok: false;
|
|
6
|
+
output: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function listJobs(directory: string): ManagementResult;
|
|
9
|
+
export declare function runJobNow(slugInput: string, directory: string): ManagementResult;
|
package/dist/systemd.d.ts
CHANGED
|
@@ -22,5 +22,5 @@ export interface TimerStatus {
|
|
|
22
22
|
}
|
|
23
23
|
export declare function timerStatus(base: string): TimerStatus;
|
|
24
24
|
export declare function writeJobUnits(job: Job, workdir: string, scopeId: string, opencodeBin: string, pathEnvironment: string): string;
|
|
25
|
-
export declare function removeJobUnits(scopeId: string, slug: string):
|
|
25
|
+
export declare function removeJobUnits(scopeId: string, slug: string): string | undefined;
|
|
26
26
|
export declare function removeStaleUnits(scopeId: string, expectedSlugs: Set<string>): string[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-jobs",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "opencode plugin that schedules recurring agent jobs as systemd user timers, with git-committable job definitions, run history, and session continuity",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|