@elmundi/ship-cli 0.12.2 → 0.14.1
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 +70 -653
- package/bin/shipctl.mjs +3 -1
- package/lib/agents/cursor.mjs +143 -0
- package/lib/agents/index.mjs +51 -0
- package/lib/commands/help.mjs +7 -6
- package/lib/commands/lanes.mjs +74 -35
- package/lib/commands/run.mjs +469 -784
- package/lib/commands/sync.mjs +1 -1
- package/lib/config/schema.mjs +3 -3
- package/lib/vendor/run-agent.workflow.yml +254 -0
- package/package.json +1 -1
package/bin/shipctl.mjs
CHANGED
|
@@ -152,7 +152,9 @@ try {
|
|
|
152
152
|
process.exit(0);
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
if (cmd === "run") {
|
|
155
|
+
if (cmd === "run" || cmd === "agent-run") {
|
|
156
|
+
// ``agent-run`` is a back-compat alias for ``run`` — older trigger
|
|
157
|
+
// workflows still spell it that way until they re-seed.
|
|
156
158
|
const { runCommand } = await import("../lib/commands/run.mjs");
|
|
157
159
|
await runCommand(ctx, rest);
|
|
158
160
|
process.exit(0);
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor Cloud Agent runtime adapter.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the public ``POST https://api.cursor.com/v0/agents`` API the
|
|
5
|
+
* ElMundi sibling repo uses (``tools/linear-agent/scripts/cloud-agent-launch.mjs``).
|
|
6
|
+
* The shape Ship needs:
|
|
7
|
+
*
|
|
8
|
+
* 1. Launch an agent against ``repo`` at ``ref`` with ``prompt``.
|
|
9
|
+
* 2. Poll until the agent's status is one of the terminal values
|
|
10
|
+
* (``FINISHED`` / ``ERRORED`` / ``CANCELLED``).
|
|
11
|
+
* 3. Return the branch name + final status to ``shipctl run``. Side
|
|
12
|
+
* effects (tracker writes / inbox rows) happen via the agent's
|
|
13
|
+
* own ``POST /agent-runs/finish`` call from inside Cursor — the
|
|
14
|
+
* CLI no longer reads a state file off the branch.
|
|
15
|
+
*
|
|
16
|
+
* Auth: ``CURSOR_API_KEY`` env var, sent as Basic ``<key>:`` per Cursor's
|
|
17
|
+
* docs.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { Buffer } from "node:buffer";
|
|
21
|
+
|
|
22
|
+
const BASE = "https://api.cursor.com";
|
|
23
|
+
const TERMINAL_STATUSES = new Set(["FINISHED", "ERRORED", "CANCELLED"]);
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
function authHeader() {
|
|
27
|
+
const key = (process.env.CURSOR_API_KEY || "").trim();
|
|
28
|
+
if (!key) {
|
|
29
|
+
throw new Error("CURSOR_API_KEY is not set");
|
|
30
|
+
}
|
|
31
|
+
return "Basic " + Buffer.from(`${key}:`).toString("base64");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async function postJson(path, body) {
|
|
36
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: {
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
Authorization: authHeader(),
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify(body),
|
|
43
|
+
});
|
|
44
|
+
const text = await res.text();
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
throw new Error(`Cursor API ${path} ${res.status}: ${text.slice(0, 500)}`);
|
|
47
|
+
}
|
|
48
|
+
return text ? JSON.parse(text) : {};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async function getJson(path) {
|
|
53
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
54
|
+
headers: { Authorization: authHeader() },
|
|
55
|
+
});
|
|
56
|
+
const text = await res.text();
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
throw new Error(`Cursor API ${path} ${res.status}: ${text.slice(0, 500)}`);
|
|
59
|
+
}
|
|
60
|
+
return text ? JSON.parse(text) : {};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Launch a single Cursor agent and poll until it terminates.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} opts
|
|
68
|
+
* @param {string} opts.repoUrl e.g. ``https://github.com/owner/repo``
|
|
69
|
+
* @param {string} opts.ref branch the agent checks out (default ``main``)
|
|
70
|
+
* @param {string} opts.branchName branch the agent commits onto
|
|
71
|
+
* @param {string} opts.prompt full prompt body
|
|
72
|
+
* @param {boolean} [opts.autoCreatePr] open a PR when done (default false)
|
|
73
|
+
* @param {number} [opts.pollIntervalMs] poll cadence (default 15s)
|
|
74
|
+
* @param {number} [opts.timeoutMs] total deadline (default 30 min)
|
|
75
|
+
* @param {(line: string) => void} [opts.onLog] streaming log hook
|
|
76
|
+
* @returns {Promise<{ agentId: string, branchName: string, status: string, raw: object }>}
|
|
77
|
+
*/
|
|
78
|
+
export async function runCursorAgent({
|
|
79
|
+
repoUrl,
|
|
80
|
+
ref = "main",
|
|
81
|
+
branchName,
|
|
82
|
+
prompt,
|
|
83
|
+
autoCreatePr = false,
|
|
84
|
+
pollIntervalMs = 15_000,
|
|
85
|
+
timeoutMs = 30 * 60 * 1000,
|
|
86
|
+
onLog = (l) => console.error(`[cursor] ${l}`),
|
|
87
|
+
} = {}) {
|
|
88
|
+
if (!repoUrl) throw new Error("runCursorAgent: repoUrl required");
|
|
89
|
+
if (!branchName) throw new Error("runCursorAgent: branchName required");
|
|
90
|
+
if (!prompt || typeof prompt !== "string") {
|
|
91
|
+
throw new Error("runCursorAgent: prompt required");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const launch = await postJson("/v0/agents", {
|
|
95
|
+
prompt: { text: prompt },
|
|
96
|
+
source: { repository: repoUrl, ref },
|
|
97
|
+
target: {
|
|
98
|
+
branchName,
|
|
99
|
+
autoCreatePr,
|
|
100
|
+
openAsCursorGithubApp: false,
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
const agentId = launch.id || launch.agentId || launch.agent_id;
|
|
104
|
+
if (!agentId) {
|
|
105
|
+
throw new Error(`Cursor launch returned no agent id: ${JSON.stringify(launch).slice(0, 300)}`);
|
|
106
|
+
}
|
|
107
|
+
onLog(`launched agent=${agentId} branch=${branchName} ref=${ref}`);
|
|
108
|
+
|
|
109
|
+
const deadline = Date.now() + timeoutMs;
|
|
110
|
+
let lastStatus = launch.status || "CREATING";
|
|
111
|
+
while (Date.now() < deadline) {
|
|
112
|
+
if (TERMINAL_STATUSES.has(String(lastStatus).toUpperCase())) {
|
|
113
|
+
onLog(`agent terminal: status=${lastStatus}`);
|
|
114
|
+
return { agentId, branchName, status: lastStatus, raw: launch };
|
|
115
|
+
}
|
|
116
|
+
await sleep(pollIntervalMs);
|
|
117
|
+
let snapshot;
|
|
118
|
+
try {
|
|
119
|
+
snapshot = await getJson(`/v0/agents/${encodeURIComponent(agentId)}`);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
onLog(`poll error (continuing): ${err.message}`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const next = (snapshot.status || "").toString();
|
|
125
|
+
if (next && next !== lastStatus) {
|
|
126
|
+
onLog(`status: ${lastStatus} → ${next}`);
|
|
127
|
+
lastStatus = next;
|
|
128
|
+
}
|
|
129
|
+
if (TERMINAL_STATUSES.has(next.toUpperCase())) {
|
|
130
|
+
return { agentId, branchName, status: next, raw: snapshot };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
throw new Error(
|
|
134
|
+
`Cursor agent ${agentId} did not reach a terminal state within ${Math.round(
|
|
135
|
+
timeoutMs / 1000,
|
|
136
|
+
)}s (last status: ${lastStatus})`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
function sleep(ms) {
|
|
142
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
143
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent runtime dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* E14 picks the runtime per ``.ship/config.yml`` ``agent.default.provider``
|
|
5
|
+
* (and per-routine override under ``agent.overrides.<routine>``). Today
|
|
6
|
+
* Cursor Cloud is the only one wired in; ``claude`` / ``codex`` slots
|
|
7
|
+
* exist so adding them later is a single new file plus an entry in
|
|
8
|
+
* ``RUNTIMES`` here.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { runCursorAgent } from "./cursor.mjs";
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
const RUNTIMES = {
|
|
15
|
+
cursor: runCursorAgent,
|
|
16
|
+
// claude: runClaudeCodeAgent, // TODO when we add it
|
|
17
|
+
// codex: runCodexAgent, // TODO when we add it
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const DEFAULT_PROVIDER = "cursor";
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
export function resolveProvider(config, routineId) {
|
|
24
|
+
const override = config?.agent?.overrides?.[routineId]?.provider;
|
|
25
|
+
if (override) return String(override).toLowerCase();
|
|
26
|
+
const def = config?.agent?.default?.provider;
|
|
27
|
+
if (def) return String(def).toLowerCase();
|
|
28
|
+
return DEFAULT_PROVIDER;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Run the configured agent runtime.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} provider — one of ``RUNTIMES``' keys.
|
|
36
|
+
* @param {object} opts — passed straight through to the runtime.
|
|
37
|
+
* @returns {Promise<{ agentId: string, branchName: string, status: string, raw: object }>}
|
|
38
|
+
*/
|
|
39
|
+
export async function runAgent(provider, opts) {
|
|
40
|
+
const fn = RUNTIMES[provider];
|
|
41
|
+
if (!fn) {
|
|
42
|
+
const known = Object.keys(RUNTIMES).join(", ") || "(none)";
|
|
43
|
+
throw new Error(
|
|
44
|
+
`agent runtime '${provider}' is not wired in this build. Known: ${known}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return fn(opts);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
export const SUPPORTED_PROVIDERS = Object.freeze(Object.keys(RUNTIMES));
|
package/lib/commands/help.mjs
CHANGED
|
@@ -72,12 +72,13 @@ COMMANDS
|
|
|
72
72
|
shipctl trigger --event schedule --repo <id|owner/name> [--workspace <id>] [--json]
|
|
73
73
|
— compute due routines locally, then claim
|
|
74
74
|
the schedule window in Ship.
|
|
75
|
-
shipctl run --routine <id> [--
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
75
|
+
shipctl run --routine <id> [--dry-run] [--json] [--cwd <dir>]
|
|
76
|
+
— execute one routine end-to-end:
|
|
77
|
+
resolve pattern, fetch a ticket
|
|
78
|
+
(if FSM-staged), launch the agent
|
|
79
|
+
runtime, exit on terminal status.
|
|
80
|
+
'shipctl agent-run' is a back-compat
|
|
81
|
+
alias.
|
|
81
82
|
shipctl lanes install [--only <csv>] [--ref <git-ref>] [--owner <gh>] [--repo <name>]
|
|
82
83
|
[--shipctl-version <v>] [--dry-run] [--force] [--json] [--cwd <dir>]
|
|
83
84
|
shipctl lanes list [--json] [--cwd <dir>]
|
package/lib/commands/lanes.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `shipctl lanes` — generate and manage the thin GitHub Actions caller
|
|
3
|
-
* workflows that delegate to
|
|
3
|
+
* workflows that delegate to a **vendored** reusable `run-agent.yml` in the
|
|
4
|
+
* same repository (RFC-0007 Phase 3).
|
|
4
5
|
*
|
|
5
6
|
* Each lane in `.ship/config.yml` (v2) gets one file at
|
|
6
7
|
* .github/workflows/ship-<lane_id>.yml
|
|
7
|
-
* whose body is
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* single ref bump.
|
|
8
|
+
* whose body is the `on:` triggers (derived from the lane kind) and
|
|
9
|
+
* `uses: ./.github/workflows/run-agent.yml`
|
|
10
|
+
* `shipctl lanes install` also writes `.github/workflows/run-agent.yml`
|
|
11
|
+
* from the template bundled inside `@elmundi/ship-cli`.
|
|
12
12
|
*
|
|
13
13
|
* Subcommands:
|
|
14
14
|
* install — render wrappers for every declared lane (or just --only X).
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import fs from "node:fs";
|
|
23
23
|
import path from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
24
25
|
import YAML from "yaml";
|
|
25
26
|
|
|
26
27
|
import { findShipRoot, readConfig } from "../config/io.mjs";
|
|
@@ -28,7 +29,6 @@ import {
|
|
|
28
29
|
validateConfig,
|
|
29
30
|
CONFIG_SCHEMA_VERSION,
|
|
30
31
|
lanePatterns,
|
|
31
|
-
lanePrimaryPattern,
|
|
32
32
|
laneFanout,
|
|
33
33
|
} from "../config/schema.mjs";
|
|
34
34
|
|
|
@@ -42,9 +42,16 @@ const BANNER_HEADER = `${BANNER_MARKER}
|
|
|
42
42
|
# Regenerate via: shipctl lanes install
|
|
43
43
|
# Hand edits OUTSIDE the \`ship-cli:\` markers will NOT be overwritten.`;
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
45
|
+
/** Same-repo reusable workflow path (GitHub Actions). */
|
|
46
|
+
const LOCAL_REUSABLE_USES = "./.github/workflows/run-agent.yml";
|
|
47
|
+
|
|
48
|
+
const RUN_AGENT_MARKER = "# ship-cli: run-agent v1";
|
|
49
|
+
|
|
50
|
+
function readRunAgentTemplate() {
|
|
51
|
+
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
52
|
+
const vendor = path.join(dir, "..", "vendor", "run-agent.workflow.yml");
|
|
53
|
+
return fs.readFileSync(vendor, "utf8");
|
|
54
|
+
}
|
|
48
55
|
|
|
49
56
|
function printHelp() {
|
|
50
57
|
console.log(`shipctl lanes — manage GitHub Actions caller workflows for the
|
|
@@ -62,11 +69,8 @@ USAGE
|
|
|
62
69
|
|
|
63
70
|
FLAGS (install)
|
|
64
71
|
--only <ids> Comma-separated lane ids to render (default: all).
|
|
65
|
-
--ref
|
|
66
|
-
|
|
67
|
-
prefixed with 'v' — e.g. v0.12.0).
|
|
68
|
-
--owner <gh-owner> GitHub owner of the ship repo (default: ElMundiUA).
|
|
69
|
-
--repo <name> GitHub repo name (default: ship).
|
|
72
|
+
--ref, --owner, --repo Ignored (legacy); reusable workflow is always
|
|
73
|
+
${LOCAL_REUSABLE_USES} in the caller repo.
|
|
70
74
|
--shipctl-version <v> Pin the @elmundi/ship-cli version the reusable
|
|
71
75
|
workflow installs on the runner (default: latest).
|
|
72
76
|
--force Overwrite wrappers that exist but were not generated
|
|
@@ -97,6 +101,45 @@ export async function lanesCommand(ctx, rest) {
|
|
|
97
101
|
|
|
98
102
|
/* ─────────────────────────── install ─────────────────────────── */
|
|
99
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Copy vendored `run-agent.workflow.yml` into `.github/workflows/run-agent.yml`.
|
|
106
|
+
*/
|
|
107
|
+
function syncRunAgentTemplate({ runAgentFile, runAgentRel, dryRun, force, installed, skipped }) {
|
|
108
|
+
let template;
|
|
109
|
+
try {
|
|
110
|
+
template = readRunAgentTemplate();
|
|
111
|
+
} catch (err) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`cannot read bundled run-agent template: ${err instanceof Error ? err.message : err}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const existed = fs.existsSync(runAgentFile);
|
|
117
|
+
if (existed) {
|
|
118
|
+
const cur = fs.readFileSync(runAgentFile, "utf8");
|
|
119
|
+
if (!cur.includes(RUN_AGENT_MARKER) && !force) {
|
|
120
|
+
skipped.push({ path: runAgentRel, reason: "run-agent-exists-without-banner" });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (cur === template) {
|
|
124
|
+
skipped.push({ path: runAgentRel, reason: "run-agent-up-to-date" });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (dryRun) {
|
|
129
|
+
installed.push({
|
|
130
|
+
path: runAgentRel,
|
|
131
|
+
action: existed ? "would-update-run-agent" : "would-write-run-agent",
|
|
132
|
+
});
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
fs.mkdirSync(path.dirname(runAgentFile), { recursive: true });
|
|
136
|
+
fs.writeFileSync(runAgentFile, template, "utf8");
|
|
137
|
+
installed.push({
|
|
138
|
+
path: runAgentRel,
|
|
139
|
+
action: existed ? "updated-run-agent" : "wrote-run-agent",
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
100
143
|
async function installCmd(ctx, rest) {
|
|
101
144
|
const args = parseInstallArgs(rest);
|
|
102
145
|
args.dryRun = args.dryRun || Boolean(ctx.dryRun);
|
|
@@ -112,20 +155,26 @@ async function installCmd(ctx, rest) {
|
|
|
112
155
|
);
|
|
113
156
|
}
|
|
114
157
|
|
|
115
|
-
const
|
|
116
|
-
const reusable = formatReusableRef({
|
|
117
|
-
owner: args.owner || DEFAULT_REUSABLE_OWNER,
|
|
118
|
-
repo: args.repo || DEFAULT_REUSABLE_REPO,
|
|
119
|
-
ref,
|
|
120
|
-
});
|
|
121
|
-
|
|
158
|
+
const reusable = LOCAL_REUSABLE_USES;
|
|
122
159
|
const targetDir = path.join(shipRoot, WORKFLOW_DIR);
|
|
123
|
-
|
|
160
|
+
const runAgentFile = path.join(targetDir, "run-agent.yml");
|
|
161
|
+
const runAgentRel = path.relative(shipRoot, runAgentFile) || runAgentFile;
|
|
124
162
|
|
|
125
163
|
const installed = [];
|
|
126
164
|
const skipped = [];
|
|
127
165
|
const errors = [];
|
|
128
166
|
|
|
167
|
+
syncRunAgentTemplate({
|
|
168
|
+
runAgentFile,
|
|
169
|
+
runAgentRel,
|
|
170
|
+
dryRun: args.dryRun,
|
|
171
|
+
force: args.force,
|
|
172
|
+
installed,
|
|
173
|
+
skipped,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (!args.dryRun) fs.mkdirSync(targetDir, { recursive: true });
|
|
177
|
+
|
|
129
178
|
for (const [laneId, lane] of wanted) {
|
|
130
179
|
const file = path.join(targetDir, `ship-${laneId}.yml`);
|
|
131
180
|
const rel = path.relative(shipRoot, file) || file;
|
|
@@ -169,7 +218,7 @@ async function installCmd(ctx, rest) {
|
|
|
169
218
|
};
|
|
170
219
|
|
|
171
220
|
emitSummary(ctx, args, payload, () => {
|
|
172
|
-
console.log(`Ship lanes → ${reusable}`);
|
|
221
|
+
console.log(`Ship lanes → reusable ${reusable}`);
|
|
173
222
|
for (const row of installed) console.log(` ${row.action}: ${row.path}`);
|
|
174
223
|
for (const row of skipped) console.log(` skipped (${row.reason}): ${row.path}`);
|
|
175
224
|
for (const row of errors) console.log(` ERROR: ${row.lane}: ${row.error}`);
|
|
@@ -191,7 +240,7 @@ function listCmd(ctx, rest) {
|
|
|
191
240
|
kind: lane.kind,
|
|
192
241
|
// ``pattern`` keeps the single-string shape for humans/scripts
|
|
193
242
|
// that eyeball the first pattern; ``patterns`` always lists all
|
|
194
|
-
// so multi-pattern
|
|
243
|
+
// so multi-pattern routines (RFC-0008 C3.1) surface correctly.
|
|
195
244
|
pattern: pats[0] || null,
|
|
196
245
|
patterns: pats,
|
|
197
246
|
// ``fanout`` resolves to the runtime default (``matrix``) when
|
|
@@ -310,16 +359,6 @@ function selectLanes(config, onlyCsv) {
|
|
|
310
359
|
return all.filter(([id]) => wanted.has(id));
|
|
311
360
|
}
|
|
312
361
|
|
|
313
|
-
function deriveDefaultRef(config) {
|
|
314
|
-
const min = config.shipctl_min;
|
|
315
|
-
if (typeof min === "string" && /^\d+\.\d+\.\d+/.test(min)) return `v${min}`;
|
|
316
|
-
return "main";
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
function formatReusableRef({ owner, repo, ref }) {
|
|
320
|
-
return `${owner}/${repo}/${DEFAULT_REUSABLE_PATH}@${ref}`;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
362
|
/**
|
|
324
363
|
* Render the caller workflow YAML. The body is structured first as a JS
|
|
325
364
|
* object so the output is guaranteed to be syntactically valid; we then
|