@wasm-oj/cli 0.2.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/LICENSE +21 -0
- package/README.md +16 -0
- package/THIRD_PARTY_NOTICES.md +302 -0
- package/bin/woj.js +5 -0
- package/dist/index.d.ts +286 -0
- package/dist/index.js +3685 -0
- package/licenses/napi-rs-keyring-MIT.txt +21 -0
- package/package.json +58 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3685 @@
|
|
|
1
|
+
import { runCollectionCli } from "@wasm-oj/organizer";
|
|
2
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { watch } from "node:fs";
|
|
5
|
+
import { lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
|
+
import { PROJECT_SOURCE_LIMITS, WASM_OJ_JUDGE_PACKAGE_MAX_BYTES, decodeJudgePackageForExecution, parseStandaloneProblemBundle, trustedJudgeSpec, validateJudgePackage } from "@wasm-oj/core";
|
|
10
|
+
import { createServerEngine } from "@wasm-oj/server";
|
|
11
|
+
//#region src/cli/contracts.ts
|
|
12
|
+
var WOJ_CLI_VERSION = "0.2.0";
|
|
13
|
+
/** Stable process exit codes. They are part of the public CLI contract. */
|
|
14
|
+
var WOJ_EXIT = Object.freeze({
|
|
15
|
+
success: 0,
|
|
16
|
+
unsuccessful: 1,
|
|
17
|
+
usage: 2,
|
|
18
|
+
authentication: 3,
|
|
19
|
+
integrity: 4,
|
|
20
|
+
conflict: 5,
|
|
21
|
+
infrastructure: 6,
|
|
22
|
+
localIntegrity: 7
|
|
23
|
+
});
|
|
24
|
+
var local = (path, summary, usage, options) => ({
|
|
25
|
+
path: path.split(" "),
|
|
26
|
+
summary,
|
|
27
|
+
boundary: "local",
|
|
28
|
+
usage,
|
|
29
|
+
options
|
|
30
|
+
});
|
|
31
|
+
var remote = (path, summary, usage, options) => ({
|
|
32
|
+
path: path.split(" "),
|
|
33
|
+
summary,
|
|
34
|
+
boundary: "remote",
|
|
35
|
+
usage,
|
|
36
|
+
options
|
|
37
|
+
});
|
|
38
|
+
var network = (path, summary, usage, options) => ({
|
|
39
|
+
path: path.split(" "),
|
|
40
|
+
summary,
|
|
41
|
+
boundary: "network",
|
|
42
|
+
usage,
|
|
43
|
+
options
|
|
44
|
+
});
|
|
45
|
+
var paging = {
|
|
46
|
+
limit: "string",
|
|
47
|
+
cursor: "string"
|
|
48
|
+
};
|
|
49
|
+
var wait = { wait: "boolean" };
|
|
50
|
+
var language = {
|
|
51
|
+
language: "string",
|
|
52
|
+
target: "string",
|
|
53
|
+
optimization: "string",
|
|
54
|
+
entry: "string"
|
|
55
|
+
};
|
|
56
|
+
/** The complete Issue #42 command tree. Every leaf is dispatched explicitly. */
|
|
57
|
+
var WOJ_COMMANDS = Object.freeze([
|
|
58
|
+
remote("auth login", "Sign in through the browser device flow.", "", { "device-name": "string" }),
|
|
59
|
+
remote("auth logout", "Revoke the current CLI session."),
|
|
60
|
+
remote("auth status", "Show the signed-in account and roles."),
|
|
61
|
+
local("init", "Create a local woj workspace.", "[directory]", {
|
|
62
|
+
...language,
|
|
63
|
+
name: "string",
|
|
64
|
+
force: "boolean"
|
|
65
|
+
}),
|
|
66
|
+
local("build", "Compile the current workspace without network access."),
|
|
67
|
+
local("run", "Compile and run locally; makes no correctness claim.", "", {
|
|
68
|
+
input: "string",
|
|
69
|
+
text: "string",
|
|
70
|
+
arg: "repeatable"
|
|
71
|
+
}),
|
|
72
|
+
local("test", "Run only locally available public/sample cases.", "", { case: "repeatable" }),
|
|
73
|
+
local("bench", "Benchmark the local program.", "", {
|
|
74
|
+
iterations: "string",
|
|
75
|
+
stdin: "string"
|
|
76
|
+
}),
|
|
77
|
+
local("watch", "Re-run a local action after workspace files change.", "", { command: "string" }),
|
|
78
|
+
remote("problem list", "List public problem versions.", "", { locale: "string" }),
|
|
79
|
+
remote("problem show", "Show one public problem version.", "<problem-version-id>", {
|
|
80
|
+
locale: "string",
|
|
81
|
+
contest: "string"
|
|
82
|
+
}),
|
|
83
|
+
remote("problem pull", "Pull and pin one exact public problem version.", "<problem-version-id> [directory]", {
|
|
84
|
+
locale: "string",
|
|
85
|
+
language: "string",
|
|
86
|
+
contest: "string",
|
|
87
|
+
force: "boolean"
|
|
88
|
+
}),
|
|
89
|
+
remote("submit", "Create an Official Submit from the pinned workspace.", "", {
|
|
90
|
+
...language,
|
|
91
|
+
contest: "string",
|
|
92
|
+
wait: "boolean"
|
|
93
|
+
}),
|
|
94
|
+
remote("submission list", "List your Official Submits.", "", paging),
|
|
95
|
+
remote("submission show", "Show an Official Submit.", "<submission-id>"),
|
|
96
|
+
remote("submission watch", "Wait for an Official Submit to settle.", "<submission-id>", { interval: "string" }),
|
|
97
|
+
remote("submission cancel", "Cancel an unsettled Official Submit.", "<submission-id>"),
|
|
98
|
+
remote("submission source", "Download visible source for an Official Submit.", "<submission-id>"),
|
|
99
|
+
remote("submission policy", "Show the submission policy summary.", "<submission-id>"),
|
|
100
|
+
remote("contest list", "List visible contests."),
|
|
101
|
+
remote("contest show", "Show one contest.", "<contest-id>"),
|
|
102
|
+
remote("contest join", "Join an invite contest.", "<contest-id>", { "code-file": "string" }),
|
|
103
|
+
remote("contest problems", "List the exact problem versions in a contest.", "<contest-id>"),
|
|
104
|
+
remote("contest standings", "Show contest standings.", "<contest-id>", { limit: "string" }),
|
|
105
|
+
remote("performance frontier", "Show the verified performance frontier.", "<problem-version-id>", {
|
|
106
|
+
language: "string",
|
|
107
|
+
contest: "string"
|
|
108
|
+
}),
|
|
109
|
+
remote("performance evolution", "Show your verified performance evolution.", "<problem-version-id>", {
|
|
110
|
+
language: "string",
|
|
111
|
+
contest: "string"
|
|
112
|
+
}),
|
|
113
|
+
local("judge inspect", "Inspect a complete local judge package.", "<judge-package>"),
|
|
114
|
+
local("judge verify", "Verify a complete local judge package deterministically.", "<judge-package>", {
|
|
115
|
+
sha256: "string",
|
|
116
|
+
bytes: "string"
|
|
117
|
+
}),
|
|
118
|
+
local("judge execute", "Execute a source workspace against a complete local judge package.", "<judge-package>", {
|
|
119
|
+
source: "string",
|
|
120
|
+
all: "boolean"
|
|
121
|
+
}),
|
|
122
|
+
local("toolchain list", "List explicitly installed toolchains."),
|
|
123
|
+
local("toolchain info", "Show one installed toolchain.", "<toolchain-id>"),
|
|
124
|
+
network("toolchain fetch", "Fetch one pinned toolchain explicitly.", "<toolchain-id>"),
|
|
125
|
+
local("toolchain verify", "Verify installed toolchain bytes and digests.", "[toolchain-id]"),
|
|
126
|
+
local("toolchain prune", "Remove unreferenced toolchain assets.", "", { yes: "boolean" }),
|
|
127
|
+
remote("organizer repo list", "List authorized Organizer repositories."),
|
|
128
|
+
remote("organizer repo show", "Show one authorized Organizer repository.", "<repository-id>"),
|
|
129
|
+
local("organizer collection init", "Create a collection authoring skeleton.", "[directory]", { force: "boolean" }),
|
|
130
|
+
local("organizer collection build", "Build deterministic collection artifacts locally.", "[directory]", {
|
|
131
|
+
index: "string",
|
|
132
|
+
source: "string",
|
|
133
|
+
managed: "string",
|
|
134
|
+
"managed-source": "string"
|
|
135
|
+
}),
|
|
136
|
+
local("organizer collection verify", "Verify collection bytes locally without executing judge code.", "[directory]", {
|
|
137
|
+
index: "string",
|
|
138
|
+
source: "string",
|
|
139
|
+
managed: "string"
|
|
140
|
+
}),
|
|
141
|
+
remote("organizer collection list", "List Organizer collections."),
|
|
142
|
+
remote("organizer collection show", "Show one Organizer collection.", "<collection-id>"),
|
|
143
|
+
remote("organizer collection create", "Register one repository collection.", "", {
|
|
144
|
+
repo: "string",
|
|
145
|
+
index: "string"
|
|
146
|
+
}),
|
|
147
|
+
remote("organizer collection validate", "Resolve a ref once and statically validate the exact commit.", "<collection-id>", {
|
|
148
|
+
ref: "string",
|
|
149
|
+
...wait
|
|
150
|
+
}),
|
|
151
|
+
remote("organizer collection validation", "Show or watch a static validation.", "<validation-id>", {
|
|
152
|
+
watch: "boolean",
|
|
153
|
+
interval: "string"
|
|
154
|
+
}),
|
|
155
|
+
remote("organizer collection publish", "Publish one validated immutable revision.", "<revision-id>", {
|
|
156
|
+
mode: "string",
|
|
157
|
+
...wait
|
|
158
|
+
}),
|
|
159
|
+
remote("organizer collection publication", "Show or watch a publication job.", "<publication-job-id>", {
|
|
160
|
+
watch: "boolean",
|
|
161
|
+
interval: "string"
|
|
162
|
+
}),
|
|
163
|
+
remote("organizer collection activate", "Explicitly activate a published official-practice revision.", "<publication-id>"),
|
|
164
|
+
remote("organizer contest list", "List contests you organize."),
|
|
165
|
+
remote("organizer contest show", "Show one Organizer contest.", "<contest-id>"),
|
|
166
|
+
remote("organizer contest create", "Create a draft contest.", "", {
|
|
167
|
+
title: "string",
|
|
168
|
+
description: "string",
|
|
169
|
+
starts: "string",
|
|
170
|
+
ends: "string",
|
|
171
|
+
freeze: "string",
|
|
172
|
+
access: "string",
|
|
173
|
+
"invite-code-file": "string",
|
|
174
|
+
problem: "repeatable"
|
|
175
|
+
}),
|
|
176
|
+
remote("organizer contest update", "Update draft contest settings.", "<contest-id>", {
|
|
177
|
+
title: "string",
|
|
178
|
+
description: "string",
|
|
179
|
+
starts: "string",
|
|
180
|
+
ends: "string",
|
|
181
|
+
freeze: "string",
|
|
182
|
+
access: "string",
|
|
183
|
+
"invite-code-file": "string"
|
|
184
|
+
}),
|
|
185
|
+
remote("organizer contest add-problem", "Add an exact published problem version.", "<contest-id> <problem-version-id>"),
|
|
186
|
+
remote("organizer contest remove-problem", "Remove a problem from a draft contest.", "<contest-id> <problem-version-id>"),
|
|
187
|
+
remote("organizer contest publish", "Publish a draft contest.", "<contest-id>"),
|
|
188
|
+
remote("organizer contest archive", "Archive a contest.", "<contest-id>"),
|
|
189
|
+
remote("organizer contest participants", "List contest participants.", "<contest-id>", paging),
|
|
190
|
+
remote("organizer contest standings", "Show organizer-visible standings.", "<contest-id>", { limit: "string" }),
|
|
191
|
+
remote("organizer rejudge options", "List valid immutable rejudge endpoints.", "<problem-version-id>"),
|
|
192
|
+
remote("organizer rejudge start", "Start a rejudge batch.", "", {
|
|
193
|
+
from: "string",
|
|
194
|
+
to: "string",
|
|
195
|
+
...wait
|
|
196
|
+
}),
|
|
197
|
+
remote("organizer rejudge list", "List rejudge batches.", "", { limit: "string" }),
|
|
198
|
+
remote("organizer rejudge show", "Show one rejudge batch.", "<batch-id>"),
|
|
199
|
+
remote("organizer rejudge watch", "Wait for a rejudge batch to settle.", "<batch-id>", { interval: "string" }),
|
|
200
|
+
remote("organizer rejudge cancel", "Cancel an unsettled rejudge batch.", "<batch-id>"),
|
|
201
|
+
local("config list", "List non-secret CLI configuration."),
|
|
202
|
+
local("config get", "Read one configuration value.", "<key>"),
|
|
203
|
+
local("config set", "Set one configuration value.", "<key> <value>"),
|
|
204
|
+
local("config unset", "Remove one configuration value.", "<key>"),
|
|
205
|
+
local("cache status", "Show local cache usage."),
|
|
206
|
+
local("cache prune", "Prune unreferenced local cache entries.", "", { yes: "boolean" }),
|
|
207
|
+
local("cache clear", "Clear the local cache.", "", { yes: "boolean" }),
|
|
208
|
+
local("doctor", "Check explicit runtime, toolchain, workspace, auth, and server configuration."),
|
|
209
|
+
local("completion", "Print a shell completion script.", "<bash|zsh|fish>"),
|
|
210
|
+
local("version", "Print the woj CLI version.")
|
|
211
|
+
]);
|
|
212
|
+
function commandKey(path) {
|
|
213
|
+
return path.join(" ");
|
|
214
|
+
}
|
|
215
|
+
var COMMAND_BY_KEY = new Map(WOJ_COMMANDS.map((command) => [commandKey(command.path), command]));
|
|
216
|
+
//#endregion
|
|
217
|
+
//#region src/cli/errors.ts
|
|
218
|
+
var CliError = class extends Error {
|
|
219
|
+
exitCode;
|
|
220
|
+
code;
|
|
221
|
+
constructor(message, options = {}) {
|
|
222
|
+
super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
|
|
223
|
+
this.name = "CliError";
|
|
224
|
+
this.exitCode = options.exitCode ?? WOJ_EXIT.infrastructure;
|
|
225
|
+
this.code = options.code ?? "cli-error";
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
function usageError(message) {
|
|
229
|
+
return new CliError(message, {
|
|
230
|
+
exitCode: WOJ_EXIT.usage,
|
|
231
|
+
code: "usage"
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
function unavailableError(message, cause) {
|
|
235
|
+
return new CliError(message, {
|
|
236
|
+
exitCode: WOJ_EXIT.infrastructure,
|
|
237
|
+
code: "unavailable",
|
|
238
|
+
cause
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
function asCliError(error) {
|
|
242
|
+
if (error instanceof CliError) return error;
|
|
243
|
+
if (error instanceof Error) return new CliError(error.message, { cause: error });
|
|
244
|
+
return new CliError("The CLI failed with a non-Error value.");
|
|
245
|
+
}
|
|
246
|
+
//#endregion
|
|
247
|
+
//#region src/cli/keychain.ts
|
|
248
|
+
var SERVICE = "wasm-oj";
|
|
249
|
+
var WOJ_ACCESS_TOKEN = /^[A-Za-z0-9_-]{43}$/;
|
|
250
|
+
function isWojAccessToken(value) {
|
|
251
|
+
return typeof value === "string" && WOJ_ACCESS_TOKEN.test(value);
|
|
252
|
+
}
|
|
253
|
+
var OsKeychainTokenStore = class {
|
|
254
|
+
constructorPromise;
|
|
255
|
+
constructor_() {
|
|
256
|
+
this.constructorPromise ??= import("@napi-rs/keyring").then((module) => module.Entry).catch((error) => {
|
|
257
|
+
throw new CliError("The OS keychain adapter could not be loaded; woj will not store credentials in a file.", {
|
|
258
|
+
code: "keychain-unavailable",
|
|
259
|
+
exitCode: 7,
|
|
260
|
+
cause: error
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
return this.constructorPromise;
|
|
264
|
+
}
|
|
265
|
+
async entry(serverOrigin) {
|
|
266
|
+
return new (await (this.constructor_()))(SERVICE, new URL(serverOrigin).origin);
|
|
267
|
+
}
|
|
268
|
+
async get(serverOrigin) {
|
|
269
|
+
try {
|
|
270
|
+
return (await this.entry(serverOrigin)).getPassword() ?? void 0;
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (error instanceof CliError) throw error;
|
|
273
|
+
throw new CliError("The OS keychain could not read the woj credential.", {
|
|
274
|
+
code: "keychain-read-failed",
|
|
275
|
+
exitCode: 7,
|
|
276
|
+
cause: error
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
async set(serverOrigin, token) {
|
|
281
|
+
if (!isWojAccessToken(token)) throw new CliError("Refusing to store a malformed woj access token.", {
|
|
282
|
+
code: "access-token-invalid",
|
|
283
|
+
exitCode: 7
|
|
284
|
+
});
|
|
285
|
+
try {
|
|
286
|
+
(await this.entry(serverOrigin)).setPassword(token);
|
|
287
|
+
} catch (error) {
|
|
288
|
+
if (error instanceof CliError) throw error;
|
|
289
|
+
throw new CliError("The OS keychain could not store the woj credential.", {
|
|
290
|
+
code: "keychain-write-failed",
|
|
291
|
+
exitCode: 7,
|
|
292
|
+
cause: error
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
async delete(serverOrigin) {
|
|
297
|
+
try {
|
|
298
|
+
(await this.entry(serverOrigin)).deletePassword();
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (error instanceof CliError) throw error;
|
|
301
|
+
throw new CliError("The OS keychain could not delete the woj credential.", {
|
|
302
|
+
code: "keychain-delete-failed",
|
|
303
|
+
exitCode: 7,
|
|
304
|
+
cause: error
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
var MemoryTokenStore = class {
|
|
310
|
+
tokens = /* @__PURE__ */ new Map();
|
|
311
|
+
get(serverOrigin) {
|
|
312
|
+
return Promise.resolve(this.tokens.get(new URL(serverOrigin).origin));
|
|
313
|
+
}
|
|
314
|
+
set(serverOrigin, token) {
|
|
315
|
+
if (!isWojAccessToken(token)) throw new CliError("Refusing to store a malformed woj access token.", {
|
|
316
|
+
code: "access-token-invalid",
|
|
317
|
+
exitCode: 7
|
|
318
|
+
});
|
|
319
|
+
this.tokens.set(new URL(serverOrigin).origin, token);
|
|
320
|
+
return Promise.resolve();
|
|
321
|
+
}
|
|
322
|
+
delete(serverOrigin) {
|
|
323
|
+
this.tokens.delete(new URL(serverOrigin).origin);
|
|
324
|
+
return Promise.resolve();
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
//#endregion
|
|
328
|
+
//#region src/cli/http.ts
|
|
329
|
+
var MAX_JSON_BYTES = 8388608;
|
|
330
|
+
var ApiError = class extends CliError {
|
|
331
|
+
status;
|
|
332
|
+
details;
|
|
333
|
+
verificationUrl;
|
|
334
|
+
constructor(status, code, message, details) {
|
|
335
|
+
super(message, {
|
|
336
|
+
exitCode: exitForApiError(status, code),
|
|
337
|
+
code
|
|
338
|
+
});
|
|
339
|
+
this.name = "ApiError";
|
|
340
|
+
this.status = status;
|
|
341
|
+
this.details = details;
|
|
342
|
+
this.verificationUrl = verificationUrlFrom(details);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
function exitForApiError(status, code) {
|
|
346
|
+
if (status === 401 || status === 403 || code.includes("auth") || code.includes("role")) return 3;
|
|
347
|
+
if (status === 400 || code.includes("schema") || code.includes("digest") || code.includes("validation")) return 4;
|
|
348
|
+
if (status === 404 || status === 409 || status === 410 || status === 422) return 5;
|
|
349
|
+
return 6;
|
|
350
|
+
}
|
|
351
|
+
function verificationUrlFrom(details) {
|
|
352
|
+
if (!details || typeof details !== "object" || Array.isArray(details)) return void 0;
|
|
353
|
+
const candidate = details.verificationUrl;
|
|
354
|
+
return typeof candidate === "string" ? candidate : void 0;
|
|
355
|
+
}
|
|
356
|
+
function record$1(value) {
|
|
357
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
358
|
+
}
|
|
359
|
+
async function boundedBytes(response, maximum, label) {
|
|
360
|
+
if (!response.body) throw unavailableError(`Server ${label} response has no body.`);
|
|
361
|
+
const reader = response.body.getReader();
|
|
362
|
+
const chunks = [];
|
|
363
|
+
let total = 0;
|
|
364
|
+
try {
|
|
365
|
+
for (;;) {
|
|
366
|
+
const { done, value } = await reader.read();
|
|
367
|
+
if (done) break;
|
|
368
|
+
total += value.byteLength;
|
|
369
|
+
if (total > maximum) {
|
|
370
|
+
await reader.cancel(`${label} response exceeds CLI limit`).catch(() => void 0);
|
|
371
|
+
throw new CliError(`Server ${label} response exceeds the CLI limit.`, {
|
|
372
|
+
exitCode: label === "JSON" ? 6 : 4,
|
|
373
|
+
code: "response-too-large"
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
chunks.push(value);
|
|
377
|
+
}
|
|
378
|
+
} finally {
|
|
379
|
+
reader.releaseLock();
|
|
380
|
+
}
|
|
381
|
+
const output = new Uint8Array(total);
|
|
382
|
+
let offset = 0;
|
|
383
|
+
for (const chunk of chunks) {
|
|
384
|
+
output.set(chunk, offset);
|
|
385
|
+
offset += chunk.byteLength;
|
|
386
|
+
}
|
|
387
|
+
return output;
|
|
388
|
+
}
|
|
389
|
+
async function boundedJson(response) {
|
|
390
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
391
|
+
if (!/^application\/json(?:\s*;|$)/iu.test(contentType)) throw unavailableError(`Server returned non-JSON content (HTTP ${response.status}).`);
|
|
392
|
+
const declared = response.headers.get("content-length");
|
|
393
|
+
if (declared !== null && (!/^(?:0|[1-9][0-9]*)$/.test(declared) || Number(declared) > MAX_JSON_BYTES)) throw unavailableError("Server JSON response exceeds the CLI limit.");
|
|
394
|
+
const bytes = await boundedBytes(response, MAX_JSON_BYTES, "JSON");
|
|
395
|
+
let text;
|
|
396
|
+
try {
|
|
397
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
398
|
+
} catch (error) {
|
|
399
|
+
throw unavailableError("Server JSON response is not valid UTF-8.", error);
|
|
400
|
+
}
|
|
401
|
+
try {
|
|
402
|
+
return text ? JSON.parse(text) : null;
|
|
403
|
+
} catch (error) {
|
|
404
|
+
throw unavailableError("Server response is not valid JSON.", error);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
var HttpRemoteClient = class {
|
|
408
|
+
tokenStore;
|
|
409
|
+
fetchImplementation;
|
|
410
|
+
origin;
|
|
411
|
+
constructor(origin, tokenStore, fetchImplementation = globalThis.fetch) {
|
|
412
|
+
this.tokenStore = tokenStore;
|
|
413
|
+
this.fetchImplementation = fetchImplementation;
|
|
414
|
+
this.origin = new URL(origin).origin;
|
|
415
|
+
}
|
|
416
|
+
async request(apiPath, options = {}) {
|
|
417
|
+
if (!apiPath.startsWith("/api/") || apiPath.includes("\\") || apiPath.includes("\0")) throw new CliError("Remote API path is invalid.", {
|
|
418
|
+
exitCode: 4,
|
|
419
|
+
code: "api-path-invalid"
|
|
420
|
+
});
|
|
421
|
+
const headers = new Headers({
|
|
422
|
+
accept: "application/json",
|
|
423
|
+
...options.headers
|
|
424
|
+
});
|
|
425
|
+
if (options.body !== void 0) headers.set("content-type", "application/json");
|
|
426
|
+
if (options.authenticated !== false) {
|
|
427
|
+
const token = await this.tokenStore.get(this.origin);
|
|
428
|
+
if (!token && options.authenticated !== "optional") throw new CliError(`Not signed in to ${this.origin}. Run 'woj auth login'.`, {
|
|
429
|
+
exitCode: 3,
|
|
430
|
+
code: "authentication-required"
|
|
431
|
+
});
|
|
432
|
+
if (token && !isWojAccessToken(token)) throw new CliError("The OS keychain contains a malformed woj credential.", {
|
|
433
|
+
exitCode: 7,
|
|
434
|
+
code: "access-token-invalid"
|
|
435
|
+
});
|
|
436
|
+
if (token) headers.set("authorization", `Bearer ${token}`);
|
|
437
|
+
}
|
|
438
|
+
let response;
|
|
439
|
+
try {
|
|
440
|
+
response = await this.fetchImplementation(new URL(apiPath, this.origin), {
|
|
441
|
+
method: options.method ?? (options.body === void 0 ? "GET" : "POST"),
|
|
442
|
+
headers,
|
|
443
|
+
redirect: "error",
|
|
444
|
+
...options.body === void 0 ? {} : { body: JSON.stringify(options.body) }
|
|
445
|
+
});
|
|
446
|
+
} catch (error) {
|
|
447
|
+
throw unavailableError(`Could not reach ${this.origin}.`, error);
|
|
448
|
+
}
|
|
449
|
+
const value = await boundedJson(response);
|
|
450
|
+
if (!response.ok) {
|
|
451
|
+
const envelope = record$1(value);
|
|
452
|
+
const error = record$1(envelope?.error);
|
|
453
|
+
const code = typeof error?.code === "string" ? error.code : `http-${response.status}`;
|
|
454
|
+
const message = typeof error?.message === "string" ? error.message : `Server request failed with HTTP ${response.status}.`;
|
|
455
|
+
throw new ApiError(response.status, code, message, error?.details ?? envelope?.details);
|
|
456
|
+
}
|
|
457
|
+
return value;
|
|
458
|
+
}
|
|
459
|
+
async requestBytes(apiPath, options = {}) {
|
|
460
|
+
if (!apiPath.startsWith("/api/") || apiPath.includes("\\") || apiPath.includes("\0")) throw new CliError("Remote API path is invalid.", {
|
|
461
|
+
exitCode: 4,
|
|
462
|
+
code: "api-path-invalid"
|
|
463
|
+
});
|
|
464
|
+
const headers = new Headers({
|
|
465
|
+
accept: "application/json",
|
|
466
|
+
...options.headers
|
|
467
|
+
});
|
|
468
|
+
if (options.authenticated !== false) {
|
|
469
|
+
const token = await this.tokenStore.get(this.origin);
|
|
470
|
+
if (!token && options.authenticated !== "optional") throw new CliError(`Not signed in to ${this.origin}. Run 'woj auth login'.`, {
|
|
471
|
+
exitCode: 3,
|
|
472
|
+
code: "authentication-required"
|
|
473
|
+
});
|
|
474
|
+
if (token && !isWojAccessToken(token)) throw new CliError("The OS keychain contains a malformed woj credential.", {
|
|
475
|
+
exitCode: 7,
|
|
476
|
+
code: "access-token-invalid"
|
|
477
|
+
});
|
|
478
|
+
if (token) headers.set("authorization", `Bearer ${token}`);
|
|
479
|
+
}
|
|
480
|
+
let response;
|
|
481
|
+
try {
|
|
482
|
+
response = await this.fetchImplementation(new URL(apiPath, this.origin), {
|
|
483
|
+
method: options.method ?? "GET",
|
|
484
|
+
headers,
|
|
485
|
+
redirect: "error"
|
|
486
|
+
});
|
|
487
|
+
} catch (error) {
|
|
488
|
+
throw unavailableError(`Could not reach ${this.origin}.`, error);
|
|
489
|
+
}
|
|
490
|
+
if (!response.ok) {
|
|
491
|
+
const envelope = record$1(await boundedJson(response));
|
|
492
|
+
const error = record$1(envelope?.error);
|
|
493
|
+
throw new ApiError(response.status, typeof error?.code === "string" ? error.code : `http-${response.status}`, typeof error?.message === "string" ? error.message : `Server request failed with HTTP ${response.status}.`, error?.details ?? envelope?.details);
|
|
494
|
+
}
|
|
495
|
+
const declared = response.headers.get("content-length");
|
|
496
|
+
if (declared !== null && (!/^(?:0|[1-9][0-9]*)$/.test(declared) || Number(declared) > MAX_JSON_BYTES)) throw new CliError("Problem content exceeds the CLI limit.", { exitCode: 4 });
|
|
497
|
+
const bytes = await boundedBytes(response, MAX_JSON_BYTES, "problem content");
|
|
498
|
+
if (bytes.byteLength < 1 || bytes.byteLength > MAX_JSON_BYTES) throw new CliError("Problem content is outside the CLI limit.", { exitCode: 4 });
|
|
499
|
+
return bytes;
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
//#endregion
|
|
503
|
+
//#region src/cli/auth.ts
|
|
504
|
+
var SystemBrowserOpener = class {
|
|
505
|
+
open(url) {
|
|
506
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32" : "xdg-open";
|
|
507
|
+
const arguments_ = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
|
|
508
|
+
return new Promise((resolve, reject) => {
|
|
509
|
+
const child = spawn(command, arguments_, {
|
|
510
|
+
detached: true,
|
|
511
|
+
stdio: "ignore"
|
|
512
|
+
});
|
|
513
|
+
child.once("error", reject);
|
|
514
|
+
child.once("spawn", () => {
|
|
515
|
+
child.unref();
|
|
516
|
+
resolve();
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
function base64Url(bytes) {
|
|
522
|
+
return Buffer.from(bytes).toString("base64url");
|
|
523
|
+
}
|
|
524
|
+
function object$1(value, label) {
|
|
525
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new CliError(`${label} response has an invalid shape.`, { exitCode: 6 });
|
|
526
|
+
return value;
|
|
527
|
+
}
|
|
528
|
+
function exactObject(value, keys, label) {
|
|
529
|
+
const result = object$1(value, label);
|
|
530
|
+
if (JSON.stringify(Object.keys(result).sort()) !== JSON.stringify([...keys].sort())) throw new CliError(`${label} response has an invalid shape.`, {
|
|
531
|
+
exitCode: 6,
|
|
532
|
+
code: "server-response-invalid"
|
|
533
|
+
});
|
|
534
|
+
return result;
|
|
535
|
+
}
|
|
536
|
+
function requiredString(value, label) {
|
|
537
|
+
if (typeof value !== "string" || !value) throw new CliError(`${label} response field is invalid.`, { exitCode: 6 });
|
|
538
|
+
return value;
|
|
539
|
+
}
|
|
540
|
+
function exactBrowserUrl(origin, candidate, pathname, parameter, expected) {
|
|
541
|
+
let url;
|
|
542
|
+
try {
|
|
543
|
+
url = new URL(candidate);
|
|
544
|
+
} catch (error) {
|
|
545
|
+
throw new CliError("Server browser verification URL is invalid.", {
|
|
546
|
+
exitCode: 6,
|
|
547
|
+
code: "verification-url-invalid",
|
|
548
|
+
cause: error
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
const entries = [...url.searchParams.entries()];
|
|
552
|
+
if (url.origin !== new URL(origin).origin || url.username || url.password || url.hash || url.pathname !== pathname || entries.length !== 1 || entries[0]?.[0] !== parameter || entries[0]?.[1] !== expected) throw new CliError("Server browser verification URL failed origin or request binding.", {
|
|
553
|
+
exitCode: 6,
|
|
554
|
+
code: "verification-url-invalid"
|
|
555
|
+
});
|
|
556
|
+
return url.toString();
|
|
557
|
+
}
|
|
558
|
+
function turnstileVerificationUrl(clientOrigin, details) {
|
|
559
|
+
const values = exactObject(details, ["requestKey", "verificationUrl"], "Turnstile verification");
|
|
560
|
+
const requestKey = requiredString(values.requestKey, "requestKey");
|
|
561
|
+
if (!/^[0-9a-f]{64}$/.test(requestKey)) throw new CliError("Turnstile request key is invalid.", {
|
|
562
|
+
exitCode: 6,
|
|
563
|
+
code: "verification-url-invalid"
|
|
564
|
+
});
|
|
565
|
+
return exactBrowserUrl(clientOrigin, requiredString(values.verificationUrl, "verificationUrl"), "/auth/cli/turnstile", "requestKey", requestKey);
|
|
566
|
+
}
|
|
567
|
+
async function deviceLogin(client, tokenStore, opener, options) {
|
|
568
|
+
const codeVerifier = base64Url(randomBytes(32));
|
|
569
|
+
const codeChallenge = base64Url(createHash("sha256").update(codeVerifier).digest());
|
|
570
|
+
const started = exactObject(await client.request("/api/auth/cli/start", {
|
|
571
|
+
method: "POST",
|
|
572
|
+
authenticated: false,
|
|
573
|
+
body: {
|
|
574
|
+
codeChallenge,
|
|
575
|
+
deviceName: options.deviceName
|
|
576
|
+
}
|
|
577
|
+
}), [
|
|
578
|
+
"flowId",
|
|
579
|
+
"verificationUrl",
|
|
580
|
+
"expiresAt",
|
|
581
|
+
"pollIntervalSeconds"
|
|
582
|
+
], "CLI login start");
|
|
583
|
+
const flowId = requiredString(started.flowId, "flowId");
|
|
584
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(flowId)) throw new CliError("CLI login flow ID is invalid.", {
|
|
585
|
+
exitCode: 6,
|
|
586
|
+
code: "server-response-invalid"
|
|
587
|
+
});
|
|
588
|
+
const verificationUrl = exactBrowserUrl(client.origin, requiredString(started.verificationUrl, "verificationUrl"), "/auth/cli", "flow", flowId);
|
|
589
|
+
const expiresAt = Date.parse(requiredString(started.expiresAt, "expiresAt"));
|
|
590
|
+
const interval = Number(started.pollIntervalSeconds);
|
|
591
|
+
if (!Number.isFinite(expiresAt) || !Number.isInteger(interval) || interval < 1 || interval > 30) throw new CliError("CLI login timing response is invalid.", { exitCode: 6 });
|
|
592
|
+
options.onVerification(verificationUrl);
|
|
593
|
+
try {
|
|
594
|
+
await opener.open(verificationUrl);
|
|
595
|
+
} catch (error) {
|
|
596
|
+
throw new CliError(`Could not open the browser. Visit ${verificationUrl}`, {
|
|
597
|
+
exitCode: 6,
|
|
598
|
+
cause: error
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
602
|
+
while (Date.now() < expiresAt) {
|
|
603
|
+
await sleep(interval * 1e3);
|
|
604
|
+
try {
|
|
605
|
+
const exchanged = exactObject(await client.request("/api/auth/cli/token", {
|
|
606
|
+
method: "POST",
|
|
607
|
+
authenticated: false,
|
|
608
|
+
body: {
|
|
609
|
+
flowId,
|
|
610
|
+
codeVerifier
|
|
611
|
+
}
|
|
612
|
+
}), [
|
|
613
|
+
"accessToken",
|
|
614
|
+
"tokenType",
|
|
615
|
+
"expiresAt"
|
|
616
|
+
], "CLI login token");
|
|
617
|
+
const token = requiredString(exchanged.accessToken, "accessToken");
|
|
618
|
+
if (!isWojAccessToken(token)) throw new CliError("CLI login returned a malformed access token.", {
|
|
619
|
+
exitCode: 6,
|
|
620
|
+
code: "server-response-invalid"
|
|
621
|
+
});
|
|
622
|
+
if (exchanged.tokenType !== "Bearer") throw new CliError("CLI login token type is unsupported.", { exitCode: 6 });
|
|
623
|
+
const tokenExpiresAt = requiredString(exchanged.expiresAt, "expiresAt");
|
|
624
|
+
if (Number.isNaN(Date.parse(tokenExpiresAt)) || new Date(tokenExpiresAt).toISOString() !== tokenExpiresAt) throw new CliError("CLI login token expiry is invalid.", { exitCode: 6 });
|
|
625
|
+
await tokenStore.set(client.origin, token);
|
|
626
|
+
return {
|
|
627
|
+
authenticated: true,
|
|
628
|
+
server: client.origin,
|
|
629
|
+
expiresAt: tokenExpiresAt
|
|
630
|
+
};
|
|
631
|
+
} catch (error) {
|
|
632
|
+
if (error instanceof ApiError && error.status === 428 && error.code === "cli-login-pending") {
|
|
633
|
+
if (object$1(error.details, "CLI login pending").retryAfterSeconds !== interval) throw new CliError("CLI login retry interval changed unexpectedly.", {
|
|
634
|
+
exitCode: 6,
|
|
635
|
+
code: "server-response-invalid"
|
|
636
|
+
});
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
throw error;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
throw new CliError("CLI login expired before browser approval.", {
|
|
643
|
+
exitCode: 3,
|
|
644
|
+
code: "login-expired"
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
//#endregion
|
|
648
|
+
//#region src/cli/config.ts
|
|
649
|
+
var CONFIG_KEYS = [
|
|
650
|
+
"server",
|
|
651
|
+
"runtime-directory",
|
|
652
|
+
"cache-directory"
|
|
653
|
+
];
|
|
654
|
+
function defaultConfigDirectory(environment = process.env, platform = process.platform) {
|
|
655
|
+
if (platform === "darwin") return path.join(os.homedir(), "Library", "Application Support", "woj");
|
|
656
|
+
if (platform === "win32") {
|
|
657
|
+
const appData = environment.APPDATA;
|
|
658
|
+
if (!appData) throw new CliError("APPDATA is required to locate woj configuration on Windows.");
|
|
659
|
+
return path.join(appData, "woj");
|
|
660
|
+
}
|
|
661
|
+
return path.join(environment.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "woj");
|
|
662
|
+
}
|
|
663
|
+
function defaultConfigPath() {
|
|
664
|
+
return path.join(defaultConfigDirectory(), "config.json");
|
|
665
|
+
}
|
|
666
|
+
function isRecord(value) {
|
|
667
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
668
|
+
}
|
|
669
|
+
function canonicalServer(value) {
|
|
670
|
+
let url;
|
|
671
|
+
try {
|
|
672
|
+
url = new URL(value);
|
|
673
|
+
} catch {
|
|
674
|
+
throw usageError("server must be an absolute HTTPS origin.");
|
|
675
|
+
}
|
|
676
|
+
const loopback = url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "localhost";
|
|
677
|
+
if (url.protocol !== "https:" && !(loopback && url.protocol === "http:") || url.username || url.password || url.pathname !== "/" || url.search || url.hash) throw usageError("server must be an HTTPS origin (HTTP is accepted only for loopback development).");
|
|
678
|
+
return url.origin;
|
|
679
|
+
}
|
|
680
|
+
function validateConfigValue(key, value) {
|
|
681
|
+
if (!value || value.includes("\0")) throw usageError(`${key} cannot be empty.`);
|
|
682
|
+
if (key === "server") return canonicalServer(value);
|
|
683
|
+
return path.resolve(value);
|
|
684
|
+
}
|
|
685
|
+
function parseConfig(value) {
|
|
686
|
+
if (!isRecord(value)) throw new CliError("CLI configuration must be a JSON object.", {
|
|
687
|
+
exitCode: 4,
|
|
688
|
+
code: "config-invalid"
|
|
689
|
+
});
|
|
690
|
+
if (Object.keys(value).some((key) => !CONFIG_KEYS.includes(key))) throw new CliError("CLI configuration contains an unknown key.", {
|
|
691
|
+
exitCode: 4,
|
|
692
|
+
code: "config-invalid"
|
|
693
|
+
});
|
|
694
|
+
const output = {};
|
|
695
|
+
for (const key of CONFIG_KEYS) {
|
|
696
|
+
const candidate = value[key];
|
|
697
|
+
if (candidate !== void 0) {
|
|
698
|
+
if (typeof candidate !== "string") throw new CliError(`CLI configuration '${key}' must be a string.`, {
|
|
699
|
+
exitCode: 4,
|
|
700
|
+
code: "config-invalid"
|
|
701
|
+
});
|
|
702
|
+
try {
|
|
703
|
+
output[key] = validateConfigValue(key, candidate);
|
|
704
|
+
} catch (error) {
|
|
705
|
+
throw new CliError(`CLI configuration '${key}' is invalid.`, {
|
|
706
|
+
exitCode: 4,
|
|
707
|
+
code: "config-invalid",
|
|
708
|
+
cause: error
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return output;
|
|
714
|
+
}
|
|
715
|
+
var JsonConfigStore = class {
|
|
716
|
+
file;
|
|
717
|
+
constructor(file = defaultConfigPath()) {
|
|
718
|
+
this.file = file;
|
|
719
|
+
}
|
|
720
|
+
async read() {
|
|
721
|
+
let source;
|
|
722
|
+
try {
|
|
723
|
+
source = await readFile(this.file, "utf8");
|
|
724
|
+
} catch (error) {
|
|
725
|
+
if (error.code === "ENOENT") return {};
|
|
726
|
+
throw error;
|
|
727
|
+
}
|
|
728
|
+
try {
|
|
729
|
+
return parseConfig(JSON.parse(source));
|
|
730
|
+
} catch (error) {
|
|
731
|
+
if (error instanceof SyntaxError) throw new CliError(`CLI configuration is not valid JSON: '${this.file}'.`, { cause: error });
|
|
732
|
+
throw error;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
async write(config) {
|
|
736
|
+
const validated = parseConfig(config);
|
|
737
|
+
await mkdir(path.dirname(this.file), {
|
|
738
|
+
recursive: true,
|
|
739
|
+
mode: 448
|
|
740
|
+
});
|
|
741
|
+
const temporary = `${this.file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
742
|
+
await writeFile(temporary, `${JSON.stringify(validated, null, 2)}\n`, {
|
|
743
|
+
encoding: "utf8",
|
|
744
|
+
mode: 384,
|
|
745
|
+
flag: "wx"
|
|
746
|
+
});
|
|
747
|
+
await rename(temporary, this.file);
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
var MemoryConfigStore = class {
|
|
751
|
+
value;
|
|
752
|
+
constructor(initial = {}) {
|
|
753
|
+
this.value = parseConfig(initial);
|
|
754
|
+
}
|
|
755
|
+
read() {
|
|
756
|
+
return Promise.resolve({ ...this.value });
|
|
757
|
+
}
|
|
758
|
+
write(config) {
|
|
759
|
+
this.value = parseConfig(config);
|
|
760
|
+
return Promise.resolve();
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
function isConfigKey(value) {
|
|
764
|
+
return CONFIG_KEYS.includes(value);
|
|
765
|
+
}
|
|
766
|
+
//#endregion
|
|
767
|
+
//#region src/path-safety.ts
|
|
768
|
+
/** Resolve only the operating system's own temporary-directory alias. */
|
|
769
|
+
async function canonicalizeSystemTemporaryPrefix(value) {
|
|
770
|
+
const absolute = path.resolve(value);
|
|
771
|
+
const temporary = path.resolve(os.tmpdir());
|
|
772
|
+
if (absolute !== temporary && !absolute.startsWith(`${temporary}${path.sep}`)) return absolute;
|
|
773
|
+
const canonicalTemporary = await realpath(temporary);
|
|
774
|
+
return path.join(canonicalTemporary, path.relative(temporary, absolute));
|
|
775
|
+
}
|
|
776
|
+
/** True only when the absolute path's existing components do not traverse links. */
|
|
777
|
+
async function anchoredPathHasNoSymlink(value) {
|
|
778
|
+
const anchored = await canonicalizeSystemTemporaryPrefix(path.resolve(value));
|
|
779
|
+
const root = path.parse(anchored).root;
|
|
780
|
+
let current = root;
|
|
781
|
+
for (const segment of path.relative(root, anchored).split(path.sep).filter(Boolean)) {
|
|
782
|
+
current = path.join(current, segment);
|
|
783
|
+
try {
|
|
784
|
+
if ((await lstat(current)).isSymbolicLink()) return false;
|
|
785
|
+
} catch (error) {
|
|
786
|
+
if (error.code === "ENOENT") return true;
|
|
787
|
+
throw error;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return true;
|
|
791
|
+
}
|
|
792
|
+
//#endregion
|
|
793
|
+
//#region src/cli/destinations.ts
|
|
794
|
+
/**
|
|
795
|
+
* Checks every declared write target before the first write. Force may replace
|
|
796
|
+
* regular files, but never follows symlinks or writes through non-directories.
|
|
797
|
+
*/
|
|
798
|
+
async function assertSafeFileDestinations(root, relativeFiles, force) {
|
|
799
|
+
const absoluteRoot = path.resolve(root);
|
|
800
|
+
const anchoredRoot = await canonicalizeSystemTemporaryPrefix(absoluteRoot);
|
|
801
|
+
const filesystemRoot = path.parse(anchoredRoot).root;
|
|
802
|
+
const targets = relativeFiles.map((relative) => path.join(absoluteRoot, ...relative.split("/")));
|
|
803
|
+
const existing = [];
|
|
804
|
+
let anchored = filesystemRoot;
|
|
805
|
+
for (const segment of path.relative(filesystemRoot, anchoredRoot).split(path.sep).filter(Boolean)) {
|
|
806
|
+
anchored = path.join(anchored, segment);
|
|
807
|
+
try {
|
|
808
|
+
const metadata = await lstat(anchored);
|
|
809
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory()) throw new CliError(`Destination root component '${anchored}' must be a real directory.`, {
|
|
810
|
+
exitCode: 5,
|
|
811
|
+
code: "destination-symlink"
|
|
812
|
+
});
|
|
813
|
+
} catch (error) {
|
|
814
|
+
if (error.code === "ENOENT") break;
|
|
815
|
+
throw error;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
for (const target of targets) {
|
|
819
|
+
const segments = path.relative(absoluteRoot, target).split(path.sep).filter(Boolean);
|
|
820
|
+
let current = absoluteRoot;
|
|
821
|
+
for (let index = -1; index < segments.length; index += 1) {
|
|
822
|
+
if (index >= 0) current = path.join(current, segments[index]);
|
|
823
|
+
try {
|
|
824
|
+
const metadata = await lstat(current);
|
|
825
|
+
if (metadata.isSymbolicLink()) throw new CliError(`Refusing symlink destination '${current}'.`, {
|
|
826
|
+
exitCode: 5,
|
|
827
|
+
code: "destination-symlink"
|
|
828
|
+
});
|
|
829
|
+
if (index === segments.length - 1) {
|
|
830
|
+
if (!metadata.isFile()) throw new CliError(`Destination '${current}' is not a regular file.`, {
|
|
831
|
+
exitCode: 5,
|
|
832
|
+
code: "destination-invalid"
|
|
833
|
+
});
|
|
834
|
+
if (!force) existing.push(path.relative(absoluteRoot, current));
|
|
835
|
+
} else if (!metadata.isDirectory()) throw new CliError(`Destination ancestor '${current}' is not a directory.`, {
|
|
836
|
+
exitCode: 5,
|
|
837
|
+
code: "destination-invalid"
|
|
838
|
+
});
|
|
839
|
+
} catch (error) {
|
|
840
|
+
if (error.code === "ENOENT") break;
|
|
841
|
+
throw error;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
if (existing.length > 0) throw new CliError(`Refusing to overwrite existing files: ${[...new Set(existing)].sort().join(", ")}. Use --force to replace them.`, {
|
|
846
|
+
exitCode: 5,
|
|
847
|
+
code: "destination-exists"
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
//#endregion
|
|
851
|
+
//#region src/cli/files.ts
|
|
852
|
+
async function readProtectedTextFile(cwd, value, option, maximumBytes = 1024) {
|
|
853
|
+
if (value === void 0) return void 0;
|
|
854
|
+
const file = path.resolve(cwd, value);
|
|
855
|
+
if (!await anchoredPathHasNoSymlink(file)) throw usageError(`${option} must name a real, non-symlink file.`);
|
|
856
|
+
let metadata;
|
|
857
|
+
try {
|
|
858
|
+
metadata = await lstat(file);
|
|
859
|
+
} catch (error) {
|
|
860
|
+
throw new CliError(`${option} could not be read.`, {
|
|
861
|
+
exitCode: 7,
|
|
862
|
+
code: "protected-input-invalid",
|
|
863
|
+
cause: error
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size < 1 || metadata.size > maximumBytes) throw usageError(`${option} must name a real file containing at most ${maximumBytes} bytes.`);
|
|
867
|
+
const bytes = new Uint8Array(await readFile(file));
|
|
868
|
+
let text;
|
|
869
|
+
try {
|
|
870
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
871
|
+
} catch (error) {
|
|
872
|
+
throw new CliError(`${option} is not valid UTF-8.`, {
|
|
873
|
+
exitCode: 7,
|
|
874
|
+
code: "protected-input-invalid",
|
|
875
|
+
cause: error
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
if (text.endsWith("\n")) text = text.slice(0, -1);
|
|
879
|
+
if (text.endsWith("\r")) text = text.slice(0, -1);
|
|
880
|
+
if (!text || /[\r\n\0]/u.test(text)) throw usageError(`${option} must contain exactly one non-empty line.`);
|
|
881
|
+
return text;
|
|
882
|
+
}
|
|
883
|
+
async function atomicWriteFile(file, contents) {
|
|
884
|
+
const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
885
|
+
try {
|
|
886
|
+
await writeFile(temporary, contents, {
|
|
887
|
+
flag: "wx",
|
|
888
|
+
mode: 384
|
|
889
|
+
});
|
|
890
|
+
await rename(temporary, file);
|
|
891
|
+
} finally {
|
|
892
|
+
await rm(temporary, { force: true });
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
//#endregion
|
|
896
|
+
//#region src/cli/toolchains.ts
|
|
897
|
+
function profiles(languages, targets) {
|
|
898
|
+
return languages.flatMap((language) => targets.flatMap((target) => ["debug", "release"].map((optimization) => ({
|
|
899
|
+
language,
|
|
900
|
+
target,
|
|
901
|
+
optimization
|
|
902
|
+
}))));
|
|
903
|
+
}
|
|
904
|
+
function descriptor(input) {
|
|
905
|
+
return Object.freeze({
|
|
906
|
+
schema: "wasm-oj-v2/toolchain-package",
|
|
907
|
+
id: input.id,
|
|
908
|
+
version: input.version,
|
|
909
|
+
wasmOjContract: 2,
|
|
910
|
+
languages: Object.freeze([...input.languages]),
|
|
911
|
+
profiles: Object.freeze(profiles(input.languages, input.targets).map((profile) => Object.freeze(profile))),
|
|
912
|
+
assets: Object.freeze(input.assets.map((asset) => Object.freeze(asset)))
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
var CLI_TOOLCHAIN_DESCRIPTORS = Object.freeze([
|
|
916
|
+
descriptor({
|
|
917
|
+
id: "clang",
|
|
918
|
+
version: "22.0.0-git20542-10",
|
|
919
|
+
languages: ["c", "cpp"],
|
|
920
|
+
targets: ["wasip1", "wasix"],
|
|
921
|
+
assets: [
|
|
922
|
+
{
|
|
923
|
+
path: "/toolchains/clang-22.0.0-git20542-10.cc1-pins.json",
|
|
924
|
+
bytes: 7457,
|
|
925
|
+
sha256: "66c4604dccd3f89d8e1472bf4432367d7396cce4a01279b1a1db445f229dba72",
|
|
926
|
+
exportPath: "./assets/clang-22.0.0-git20542-10.cc1-pins.json"
|
|
927
|
+
},
|
|
928
|
+
{
|
|
929
|
+
path: "/toolchains/clang-22.0.0-git20542-10.cpp-debug.pch.gz.bin",
|
|
930
|
+
bytes: 13871086,
|
|
931
|
+
sha256: "a4152027d248412eca8aec3e7e23f6f7c81f95170cae9fd385bcf02e57e91fc9",
|
|
932
|
+
exportPath: "./assets/clang-22.0.0-git20542-10.cpp-debug.pch.gz.bin"
|
|
933
|
+
},
|
|
934
|
+
{
|
|
935
|
+
path: "/toolchains/clang-22.0.0-git20542-10.cpp-release.pch.gz.bin",
|
|
936
|
+
bytes: 13870913,
|
|
937
|
+
sha256: "18f4ca8ab8ca7888db572ba34146fc1acb213a7e7305000ea6285188f52f99f4",
|
|
938
|
+
exportPath: "./assets/clang-22.0.0-git20542-10.cpp-release.pch.gz.bin"
|
|
939
|
+
},
|
|
940
|
+
{
|
|
941
|
+
path: "/toolchains/clang-22.0.0-git20542-10.libcxx-pch.json",
|
|
942
|
+
bytes: 1987,
|
|
943
|
+
sha256: "d126c99e951a7302d4ea2b66da4ed64d3d74e9d319d562518867c8d8c97a06b8",
|
|
944
|
+
exportPath: "./assets/clang-22.0.0-git20542-10.libcxx-pch.json"
|
|
945
|
+
},
|
|
946
|
+
{
|
|
947
|
+
path: "/toolchains/clang-22.0.0-git20542-10.manifest.json",
|
|
948
|
+
bytes: 744,
|
|
949
|
+
sha256: "6382dcdfb6a2da49032a0e08da3b1fb490eb24432be85c3c12e3e871a5065273",
|
|
950
|
+
exportPath: "./assets/clang-22.0.0-git20542-10.manifest.json"
|
|
951
|
+
},
|
|
952
|
+
{
|
|
953
|
+
path: "/toolchains/clang-22.0.0-git20542-10.webc.gz.bin",
|
|
954
|
+
bytes: 27000264,
|
|
955
|
+
sha256: "7f10d90b8e52b270f04874641a1d0bf9e94e85b4f6c7573a774cebbc6d32552a",
|
|
956
|
+
exportPath: "./assets/clang-22.0.0-git20542-10.webc.gz.bin"
|
|
957
|
+
}
|
|
958
|
+
]
|
|
959
|
+
}),
|
|
960
|
+
descriptor({
|
|
961
|
+
id: "go",
|
|
962
|
+
version: "1.26.5",
|
|
963
|
+
languages: ["go"],
|
|
964
|
+
targets: ["wasip1"],
|
|
965
|
+
assets: [
|
|
966
|
+
{
|
|
967
|
+
path: "/toolchains/go-1.26.5-wasip1.manifest.json",
|
|
968
|
+
bytes: 67888,
|
|
969
|
+
sha256: "5d784e9ca640b9525e84b598c0beb97ca110ae908568ca45f6441a441d99a262",
|
|
970
|
+
exportPath: "./assets/go-1.26.5-wasip1.manifest.json"
|
|
971
|
+
},
|
|
972
|
+
{
|
|
973
|
+
path: "/toolchains/go-1.26.5-wasip1.stdlib.gz.bin",
|
|
974
|
+
bytes: 29300578,
|
|
975
|
+
sha256: "aeffc384fdc624544f174ba5fc3c22395717fdbc3c4387d677d20855b6be80d8",
|
|
976
|
+
exportPath: "./assets/go-1.26.5-wasip1.stdlib.gz.bin"
|
|
977
|
+
},
|
|
978
|
+
{
|
|
979
|
+
path: "/toolchains/go-1.26.5-wasip1.webc.gz.bin",
|
|
980
|
+
bytes: 12412445,
|
|
981
|
+
sha256: "70a7e359884b09b2e1a622d6ac5cd6e31c334aab519e6dd80dff5e040a9e09e4",
|
|
982
|
+
exportPath: "./assets/go-1.26.5-wasip1.webc.gz.bin"
|
|
983
|
+
}
|
|
984
|
+
]
|
|
985
|
+
}),
|
|
986
|
+
descriptor({
|
|
987
|
+
id: "java-teavm",
|
|
988
|
+
version: "teavm-0.13.1-wasi",
|
|
989
|
+
languages: ["java"],
|
|
990
|
+
targets: ["wasip1"],
|
|
991
|
+
assets: [
|
|
992
|
+
{
|
|
993
|
+
path: "/toolchains/java-teavm-0.13.1.compile-classlib.bin",
|
|
994
|
+
bytes: 1198350,
|
|
995
|
+
sha256: "acfe3fb09e5f2c0c7c8dc2339c66fcdadc1f8e1bf1c74be446926175ef770868",
|
|
996
|
+
exportPath: "./assets/java-teavm-0.13.1.compile-classlib.bin"
|
|
997
|
+
},
|
|
998
|
+
{
|
|
999
|
+
path: "/toolchains/java-teavm-0.13.1.runtime-classlib.bin",
|
|
1000
|
+
bytes: 8798302,
|
|
1001
|
+
sha256: "21a9394586e416af2fca4eb0ed08521cbc8924e1d1afaa07863a59a3cfae54ab",
|
|
1002
|
+
exportPath: "./assets/java-teavm-0.13.1.runtime-classlib.bin"
|
|
1003
|
+
},
|
|
1004
|
+
{
|
|
1005
|
+
path: "/toolchains/java-teavm-0.13.1.wasi.compiler.webc.gz.bin",
|
|
1006
|
+
bytes: 6059335,
|
|
1007
|
+
sha256: "129f1f51d591e58954f88787d36396b856a9a68ba3ae9c9d14f20bd67c2c7722",
|
|
1008
|
+
exportPath: "./assets/java-teavm-0.13.1.wasi.compiler.webc.gz.bin"
|
|
1009
|
+
}
|
|
1010
|
+
]
|
|
1011
|
+
}),
|
|
1012
|
+
descriptor({
|
|
1013
|
+
id: "javascript",
|
|
1014
|
+
version: "typescript-7.0.2+quickjs-0.15.1",
|
|
1015
|
+
languages: ["javascript", "typescript"],
|
|
1016
|
+
targets: ["wasip1"],
|
|
1017
|
+
assets: [{
|
|
1018
|
+
path: "/toolchains/quickjs-0.15.1.wasm.gz.bin",
|
|
1019
|
+
bytes: 384057,
|
|
1020
|
+
sha256: "8c7f0588210490e7d77f198fc91f72c1b94787ab4c359c4786ca59a363c4f5e8",
|
|
1021
|
+
exportPath: "./assets/quickjs-0.15.1.wasm.gz.bin"
|
|
1022
|
+
}, {
|
|
1023
|
+
path: "/toolchains/typescript-7.0.2.wasm.gz.bin",
|
|
1024
|
+
bytes: 7113466,
|
|
1025
|
+
sha256: "06e58ce887d95d1895055699b8dc96a1cde7d1f2baa48de40f9b790e3271dc16",
|
|
1026
|
+
exportPath: "./assets/typescript-7.0.2.wasm.gz.bin"
|
|
1027
|
+
}]
|
|
1028
|
+
}),
|
|
1029
|
+
descriptor({
|
|
1030
|
+
id: "python",
|
|
1031
|
+
version: "3.14.6",
|
|
1032
|
+
languages: ["python"],
|
|
1033
|
+
targets: ["wasip1"],
|
|
1034
|
+
assets: [{
|
|
1035
|
+
path: "/toolchains/python-3.14.6-wasip1.manifest.json",
|
|
1036
|
+
bytes: 8257,
|
|
1037
|
+
sha256: "054eccad04a7cee7ba1661062142ef0d639976850981eab8fc785f48eb26129e",
|
|
1038
|
+
exportPath: "./assets/python-3.14.6-wasip1.manifest.json"
|
|
1039
|
+
}, {
|
|
1040
|
+
path: "/toolchains/python-3.14.6-wasip1.webc.gz.bin",
|
|
1041
|
+
bytes: 5188678,
|
|
1042
|
+
sha256: "218cd20ac4abb443e0700816010a615a345a43eae623a0232da2227135a6c7a6",
|
|
1043
|
+
exportPath: "./assets/python-3.14.6-wasip1.webc.gz.bin"
|
|
1044
|
+
}]
|
|
1045
|
+
}),
|
|
1046
|
+
descriptor({
|
|
1047
|
+
id: "rust",
|
|
1048
|
+
version: "1.91.1-dev",
|
|
1049
|
+
languages: ["rust"],
|
|
1050
|
+
targets: ["wasip1"],
|
|
1051
|
+
assets: [{
|
|
1052
|
+
path: "/toolchains/rust-1.91.1-dev.manifest.json",
|
|
1053
|
+
bytes: 5974,
|
|
1054
|
+
sha256: "d5bbdca994e61888679c5738cb9420649c0854ed0eb5d65468bc67d5d550bce1",
|
|
1055
|
+
exportPath: "./assets/rust-1.91.1-dev.manifest.json"
|
|
1056
|
+
}, {
|
|
1057
|
+
path: "/toolchains/rust-1.91.1-dev.webc.gz.bin",
|
|
1058
|
+
bytes: 74138827,
|
|
1059
|
+
sha256: "cfbdadc67be1315e735aa55bdf8a5a0d00171982a023fefcf7ba586127753887",
|
|
1060
|
+
exportPath: "./assets/rust-1.91.1-dev.webc.gz.bin"
|
|
1061
|
+
}]
|
|
1062
|
+
})
|
|
1063
|
+
]);
|
|
1064
|
+
//#endregion
|
|
1065
|
+
//#region src/cli/workspace.ts
|
|
1066
|
+
var WOJ_WORKSPACE_SCHEMA = "wasm-oj-cli-workspace-v1";
|
|
1067
|
+
var WORKSPACE_FILE = "woj.json";
|
|
1068
|
+
var LANGUAGES = [
|
|
1069
|
+
"c",
|
|
1070
|
+
"cpp",
|
|
1071
|
+
"rust",
|
|
1072
|
+
"go",
|
|
1073
|
+
"python",
|
|
1074
|
+
"javascript",
|
|
1075
|
+
"typescript"
|
|
1076
|
+
];
|
|
1077
|
+
var SOURCE_BY_LANGUAGE = {
|
|
1078
|
+
c: {
|
|
1079
|
+
entry: "main.c",
|
|
1080
|
+
source: "#include <stdio.h>\nint main(void) { return 0; }\n"
|
|
1081
|
+
},
|
|
1082
|
+
cpp: {
|
|
1083
|
+
entry: "main.cpp",
|
|
1084
|
+
source: "#include <iostream>\nint main() { return 0; }\n"
|
|
1085
|
+
},
|
|
1086
|
+
rust: {
|
|
1087
|
+
entry: "main.rs",
|
|
1088
|
+
source: "fn main() {}\n"
|
|
1089
|
+
},
|
|
1090
|
+
go: {
|
|
1091
|
+
entry: "main.go",
|
|
1092
|
+
source: "package main\nfunc main() {}\n"
|
|
1093
|
+
},
|
|
1094
|
+
python: {
|
|
1095
|
+
entry: "main.py",
|
|
1096
|
+
source: "def main():\n pass\n\nif __name__ == \"__main__\":\n main()\n"
|
|
1097
|
+
},
|
|
1098
|
+
javascript: {
|
|
1099
|
+
entry: "main.js",
|
|
1100
|
+
source: "function main() {}\nmain();\n"
|
|
1101
|
+
},
|
|
1102
|
+
typescript: {
|
|
1103
|
+
entry: "main.ts",
|
|
1104
|
+
source: "function main(): void {}\nmain();\n"
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
function record(value, label) {
|
|
1108
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new CliError(`${label} must be an object.`);
|
|
1109
|
+
return value;
|
|
1110
|
+
}
|
|
1111
|
+
function exactKeys(value, keys, label) {
|
|
1112
|
+
const actual = Object.keys(value).sort();
|
|
1113
|
+
const expected = [...keys].sort();
|
|
1114
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new CliError(`${label} has an invalid shape.`);
|
|
1115
|
+
}
|
|
1116
|
+
function relativeFile(value, label) {
|
|
1117
|
+
if (typeof value !== "string" || !value || value.startsWith("/") || value.includes("\\") || value.includes("\0") || value.split("/").some((part) => !part || part === "." || part === "..")) throw new CliError(`${label} must be a normalized relative POSIX file path.`);
|
|
1118
|
+
return value;
|
|
1119
|
+
}
|
|
1120
|
+
function parseWorkspaceInternal(value) {
|
|
1121
|
+
const workspace = record(value, "woj workspace");
|
|
1122
|
+
exactKeys(workspace, [
|
|
1123
|
+
"schema",
|
|
1124
|
+
"name",
|
|
1125
|
+
"language",
|
|
1126
|
+
"target",
|
|
1127
|
+
"optimization",
|
|
1128
|
+
"entry",
|
|
1129
|
+
"sources",
|
|
1130
|
+
...workspace.problem === void 0 ? [] : ["problem"]
|
|
1131
|
+
], "woj workspace");
|
|
1132
|
+
if (workspace.schema !== "wasm-oj-cli-workspace-v1") throw new CliError(`Unsupported workspace schema '${String(workspace.schema)}'.`);
|
|
1133
|
+
if (typeof workspace.name !== "string" || !workspace.name.trim() || workspace.name.length > 128) throw new CliError("Workspace name is invalid.");
|
|
1134
|
+
if (!LANGUAGES.includes(workspace.language)) throw new CliError("Workspace language is unsupported.");
|
|
1135
|
+
if (workspace.target !== "wasip1" && workspace.target !== "wasix") throw new CliError("Workspace target must be 'wasip1' or 'wasix'.");
|
|
1136
|
+
if (workspace.optimization !== "debug" && workspace.optimization !== "release") throw new CliError("Workspace optimization is invalid.");
|
|
1137
|
+
const entry = relativeFile(workspace.entry, "Workspace entry");
|
|
1138
|
+
if (!Array.isArray(workspace.sources) || workspace.sources.length < 1 || workspace.sources.length > 256) throw new CliError("Workspace sources are invalid.");
|
|
1139
|
+
const sources = workspace.sources.map((source) => relativeFile(source, "Workspace source"));
|
|
1140
|
+
if (new Set(sources).size !== sources.length || !sources.includes(entry)) throw new CliError("Workspace sources must be unique and include the entry.");
|
|
1141
|
+
let problem;
|
|
1142
|
+
if (workspace.problem !== void 0) {
|
|
1143
|
+
const pinned = record(workspace.problem, "Pinned problem");
|
|
1144
|
+
exactKeys(pinned, [
|
|
1145
|
+
"problemVersionId",
|
|
1146
|
+
"catalogPublicationId",
|
|
1147
|
+
"serverOrigin",
|
|
1148
|
+
"contentUrl",
|
|
1149
|
+
"contentSha256",
|
|
1150
|
+
"contentFile",
|
|
1151
|
+
"locale",
|
|
1152
|
+
...pinned.contestId === void 0 ? [] : ["contestId"]
|
|
1153
|
+
], "Pinned problem");
|
|
1154
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
1155
|
+
if (typeof pinned.problemVersionId !== "string" || !uuid.test(pinned.problemVersionId)) throw new CliError("Pinned problem version ID is invalid.");
|
|
1156
|
+
if (typeof pinned.catalogPublicationId !== "string" || !uuid.test(pinned.catalogPublicationId)) throw new CliError("Pinned catalog publication ID is invalid.");
|
|
1157
|
+
let serverOrigin;
|
|
1158
|
+
try {
|
|
1159
|
+
serverOrigin = new URL(String(pinned.serverOrigin)).origin;
|
|
1160
|
+
} catch {
|
|
1161
|
+
throw new CliError("Pinned server origin is invalid.");
|
|
1162
|
+
}
|
|
1163
|
+
if (serverOrigin !== pinned.serverOrigin) throw new CliError("Pinned server origin must be canonical.");
|
|
1164
|
+
if (typeof pinned.contentUrl !== "string" || !pinned.contentUrl.startsWith("/api/problems/")) throw new CliError("Pinned content URL is invalid.");
|
|
1165
|
+
if (typeof pinned.contentSha256 !== "string" || !/^[0-9a-f]{64}$/.test(pinned.contentSha256)) throw new CliError("Pinned problem digest is invalid.");
|
|
1166
|
+
if (pinned.contentFile !== "problem.json") throw new CliError("Pinned problem content file is invalid.");
|
|
1167
|
+
if (pinned.locale !== "zh-TW" && pinned.locale !== "en") throw new CliError("Pinned problem locale is invalid.");
|
|
1168
|
+
if (pinned.contestId !== void 0 && (typeof pinned.contestId !== "string" || !uuid.test(pinned.contestId))) throw new CliError("Pinned contest ID is invalid.");
|
|
1169
|
+
problem = {
|
|
1170
|
+
problemVersionId: pinned.problemVersionId,
|
|
1171
|
+
catalogPublicationId: pinned.catalogPublicationId,
|
|
1172
|
+
serverOrigin,
|
|
1173
|
+
contentUrl: pinned.contentUrl,
|
|
1174
|
+
contentSha256: pinned.contentSha256,
|
|
1175
|
+
contentFile: "problem.json",
|
|
1176
|
+
locale: pinned.locale,
|
|
1177
|
+
...pinned.contestId ? { contestId: pinned.contestId } : {}
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
return {
|
|
1181
|
+
schema: WOJ_WORKSPACE_SCHEMA,
|
|
1182
|
+
name: workspace.name.trim(),
|
|
1183
|
+
language: workspace.language,
|
|
1184
|
+
target: workspace.target,
|
|
1185
|
+
optimization: workspace.optimization,
|
|
1186
|
+
entry,
|
|
1187
|
+
sources,
|
|
1188
|
+
...problem ? { problem } : {}
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
function parseWorkspace(value) {
|
|
1192
|
+
try {
|
|
1193
|
+
return parseWorkspaceInternal(value);
|
|
1194
|
+
} catch (error) {
|
|
1195
|
+
if (error instanceof CliError) throw new CliError(error.message, {
|
|
1196
|
+
exitCode: 4,
|
|
1197
|
+
code: "workspace-invalid",
|
|
1198
|
+
cause: error
|
|
1199
|
+
});
|
|
1200
|
+
throw error;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
async function safeWorkspaceFile(root, relative, maximumBytes) {
|
|
1204
|
+
const anchoredRoot = await canonicalizeSystemTemporaryPrefix(path.resolve(root));
|
|
1205
|
+
const filesystemRoot = path.parse(anchoredRoot).root;
|
|
1206
|
+
let current = filesystemRoot;
|
|
1207
|
+
for (const segment of path.relative(filesystemRoot, anchoredRoot).split(path.sep).filter(Boolean)) {
|
|
1208
|
+
current = path.join(current, segment);
|
|
1209
|
+
const metadata = await lstat(current);
|
|
1210
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory()) throw new CliError(`Workspace component '${current}' must be a real directory.`, {
|
|
1211
|
+
exitCode: 7,
|
|
1212
|
+
code: "workspace-source-integrity"
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
const segments = relative.split("/");
|
|
1216
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
1217
|
+
current = path.join(current, segments[index]);
|
|
1218
|
+
const metadata = await lstat(current);
|
|
1219
|
+
const final = index === segments.length - 1;
|
|
1220
|
+
if (metadata.isSymbolicLink() || (final ? !metadata.isFile() : !metadata.isDirectory())) throw new CliError(`Workspace path '${relative}' has a symlink or invalid ancestor.`, {
|
|
1221
|
+
exitCode: 7,
|
|
1222
|
+
code: "workspace-source-integrity"
|
|
1223
|
+
});
|
|
1224
|
+
if (final && metadata.size > maximumBytes) throw new CliError(`Workspace file '${relative}' exceeds its byte limit.`, {
|
|
1225
|
+
exitCode: 7,
|
|
1226
|
+
code: "workspace-source-integrity"
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
return current;
|
|
1230
|
+
}
|
|
1231
|
+
async function readWorkspaceFileBytes(root, relative, maximumBytes) {
|
|
1232
|
+
return new Uint8Array(await readFile(await safeWorkspaceFile(root, relative, maximumBytes)));
|
|
1233
|
+
}
|
|
1234
|
+
async function readWorkspace(root = process.cwd()) {
|
|
1235
|
+
let source;
|
|
1236
|
+
try {
|
|
1237
|
+
const bytes = await readWorkspaceFileBytes(root, WORKSPACE_FILE, 1048576);
|
|
1238
|
+
source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
1239
|
+
} catch (error) {
|
|
1240
|
+
if (error.code === "ENOENT") throw usageError(`No ${WORKSPACE_FILE} exists in '${root}'. Run 'woj init'.`);
|
|
1241
|
+
if (error instanceof TypeError) throw new CliError(`${WORKSPACE_FILE} is not valid UTF-8.`, {
|
|
1242
|
+
exitCode: 4,
|
|
1243
|
+
code: "workspace-invalid",
|
|
1244
|
+
cause: error
|
|
1245
|
+
});
|
|
1246
|
+
throw error;
|
|
1247
|
+
}
|
|
1248
|
+
try {
|
|
1249
|
+
return parseWorkspace(JSON.parse(source));
|
|
1250
|
+
} catch (error) {
|
|
1251
|
+
if (error instanceof SyntaxError) throw new CliError(`${WORKSPACE_FILE} is not valid JSON.`, {
|
|
1252
|
+
exitCode: 4,
|
|
1253
|
+
code: "workspace-invalid",
|
|
1254
|
+
cause: error
|
|
1255
|
+
});
|
|
1256
|
+
throw error;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
async function writeWorkspace(root, workspace) {
|
|
1260
|
+
const validated = parseWorkspace(workspace);
|
|
1261
|
+
await mkdir(root, { recursive: true });
|
|
1262
|
+
const file = path.join(root, WORKSPACE_FILE);
|
|
1263
|
+
const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
1264
|
+
await writeFile(temporary, `${JSON.stringify(validated, null, 2)}\n`, {
|
|
1265
|
+
encoding: "utf8",
|
|
1266
|
+
flag: "wx"
|
|
1267
|
+
});
|
|
1268
|
+
await rename(temporary, file);
|
|
1269
|
+
}
|
|
1270
|
+
async function createWorkspace(root, options) {
|
|
1271
|
+
const language = options.language ?? "cpp";
|
|
1272
|
+
if (!LANGUAGES.includes(language)) throw usageError(`Unsupported language '${language}'.`);
|
|
1273
|
+
if (options.target !== void 0 && options.target !== "wasip1" && options.target !== "wasix") throw usageError("--target must be 'wasip1' or 'wasix'.");
|
|
1274
|
+
if (options.optimization !== void 0 && options.optimization !== "debug" && options.optimization !== "release") throw usageError("--optimization must be debug or release.");
|
|
1275
|
+
const starter = SOURCE_BY_LANGUAGE[language];
|
|
1276
|
+
const entry = options.entry ?? starter.entry;
|
|
1277
|
+
const workspace = parseWorkspace({
|
|
1278
|
+
schema: WOJ_WORKSPACE_SCHEMA,
|
|
1279
|
+
name: options.name ?? path.basename(path.resolve(root)),
|
|
1280
|
+
language,
|
|
1281
|
+
target: options.target ?? "wasip1",
|
|
1282
|
+
optimization: options.optimization ?? "debug",
|
|
1283
|
+
entry,
|
|
1284
|
+
sources: [entry]
|
|
1285
|
+
});
|
|
1286
|
+
await assertSafeFileDestinations(root, [WORKSPACE_FILE, entry], Boolean(options.force));
|
|
1287
|
+
await mkdir(root, { recursive: true });
|
|
1288
|
+
await mkdir(path.dirname(path.join(root, ...entry.split("/"))), { recursive: true });
|
|
1289
|
+
await writeWorkspace(root, workspace);
|
|
1290
|
+
await atomicWriteFile(path.join(root, entry), starter.source);
|
|
1291
|
+
return workspace;
|
|
1292
|
+
}
|
|
1293
|
+
async function readWorkspaceSources(root, workspace) {
|
|
1294
|
+
let totalBytes = 0;
|
|
1295
|
+
const verified = [];
|
|
1296
|
+
for (const relative of workspace.sources) {
|
|
1297
|
+
const file = await safeWorkspaceFile(root, relative, PROJECT_SOURCE_LIMITS.bytesPerFile);
|
|
1298
|
+
const metadata = await lstat(file);
|
|
1299
|
+
totalBytes += metadata.size;
|
|
1300
|
+
if (totalBytes > PROJECT_SOURCE_LIMITS.totalBytes) throw new CliError("Workspace sources exceed the aggregate limit.", {
|
|
1301
|
+
exitCode: 7,
|
|
1302
|
+
code: "workspace-source-integrity"
|
|
1303
|
+
});
|
|
1304
|
+
verified.push([relative, file]);
|
|
1305
|
+
}
|
|
1306
|
+
const entries = [];
|
|
1307
|
+
for (const [relative, file] of verified) {
|
|
1308
|
+
const bytes = new Uint8Array(await readFile(file));
|
|
1309
|
+
let source;
|
|
1310
|
+
try {
|
|
1311
|
+
source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
1312
|
+
} catch (error) {
|
|
1313
|
+
throw new CliError(`Workspace source '${relative}' is not valid UTF-8.`, {
|
|
1314
|
+
exitCode: 7,
|
|
1315
|
+
code: "workspace-source-integrity",
|
|
1316
|
+
cause: error
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
entries.push([relative, source]);
|
|
1320
|
+
}
|
|
1321
|
+
return Object.fromEntries(entries);
|
|
1322
|
+
}
|
|
1323
|
+
//#endregion
|
|
1324
|
+
//#region src/cli/local.ts
|
|
1325
|
+
var DESCRIPTORS = CLI_TOOLCHAIN_DESCRIPTORS;
|
|
1326
|
+
var CACHE_MARKER = ".woj-cache";
|
|
1327
|
+
var CACHE_MARKER_CONTENTS = "wasm-oj-cli-cache-v1\n";
|
|
1328
|
+
function cacheRoot(config) {
|
|
1329
|
+
if (config["cache-directory"]) return config["cache-directory"];
|
|
1330
|
+
if (process.platform === "darwin") return path.join(os.homedir(), "Library", "Caches", "woj");
|
|
1331
|
+
if (process.platform === "win32") {
|
|
1332
|
+
const root = process.env.LOCALAPPDATA;
|
|
1333
|
+
if (!root) throw new CliError("LOCALAPPDATA is required to locate the woj cache on Windows.", { exitCode: 7 });
|
|
1334
|
+
return path.join(root, "woj");
|
|
1335
|
+
}
|
|
1336
|
+
return path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"), "woj");
|
|
1337
|
+
}
|
|
1338
|
+
async function ownedCacheRoot(config, create) {
|
|
1339
|
+
const root = path.resolve(cacheRoot(config));
|
|
1340
|
+
const anchoredRoot = await canonicalizeSystemTemporaryPrefix(root);
|
|
1341
|
+
const parsed = path.parse(anchoredRoot);
|
|
1342
|
+
const home = path.resolve(os.homedir());
|
|
1343
|
+
const configDirectory = path.resolve(defaultConfigDirectory());
|
|
1344
|
+
if (parsed.root === root || root === home || configDirectory === root || configDirectory.startsWith(`${root}${path.sep}`)) throw new CliError("The woj cache directory cannot be a filesystem, home, or CLI configuration root (or its ancestor).", {
|
|
1345
|
+
exitCode: 7,
|
|
1346
|
+
code: "cache-root-invalid"
|
|
1347
|
+
});
|
|
1348
|
+
let existingParent = parsed.root;
|
|
1349
|
+
const components = path.relative(parsed.root, anchoredRoot).split(path.sep).filter(Boolean);
|
|
1350
|
+
for (let index = 0; index < components.length; index += 1) {
|
|
1351
|
+
const candidate = path.join(existingParent, components[index]);
|
|
1352
|
+
try {
|
|
1353
|
+
const componentMetadata = await lstat(candidate);
|
|
1354
|
+
if (componentMetadata.isSymbolicLink() || !componentMetadata.isDirectory() && index < components.length - 1) throw new CliError(`Cache path component '${candidate}' must be a real directory.`, {
|
|
1355
|
+
exitCode: 7,
|
|
1356
|
+
code: "cache-root-invalid"
|
|
1357
|
+
});
|
|
1358
|
+
existingParent = candidate;
|
|
1359
|
+
} catch (error) {
|
|
1360
|
+
if (error.code === "ENOENT") break;
|
|
1361
|
+
throw error;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
let metadata;
|
|
1365
|
+
try {
|
|
1366
|
+
metadata = await lstat(root);
|
|
1367
|
+
} catch (error) {
|
|
1368
|
+
if (error.code !== "ENOENT") throw error;
|
|
1369
|
+
if (!create) return void 0;
|
|
1370
|
+
await mkdir(root, {
|
|
1371
|
+
recursive: true,
|
|
1372
|
+
mode: 448
|
|
1373
|
+
});
|
|
1374
|
+
const createdMetadata = await lstat(root);
|
|
1375
|
+
const entries = await readdir(root);
|
|
1376
|
+
if (!createdMetadata.isDirectory() || createdMetadata.isSymbolicLink() || entries.length > 0) throw new CliError(`Refusing to adopt cache directory '${root}'.`, {
|
|
1377
|
+
exitCode: 7,
|
|
1378
|
+
code: "cache-root-invalid"
|
|
1379
|
+
});
|
|
1380
|
+
await writeFile(path.join(root, CACHE_MARKER), CACHE_MARKER_CONTENTS, {
|
|
1381
|
+
encoding: "utf8",
|
|
1382
|
+
flag: "wx",
|
|
1383
|
+
mode: 384
|
|
1384
|
+
});
|
|
1385
|
+
return root;
|
|
1386
|
+
}
|
|
1387
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new CliError("The woj cache path must be a real directory.", {
|
|
1388
|
+
exitCode: 7,
|
|
1389
|
+
code: "cache-root-invalid"
|
|
1390
|
+
});
|
|
1391
|
+
const marker = path.join(root, CACHE_MARKER);
|
|
1392
|
+
let markerMetadata;
|
|
1393
|
+
try {
|
|
1394
|
+
markerMetadata = await lstat(marker);
|
|
1395
|
+
} catch (error) {
|
|
1396
|
+
if (error.code !== "ENOENT") throw error;
|
|
1397
|
+
const entries = await readdir(root);
|
|
1398
|
+
if (!create || entries.length > 0) throw new CliError(`Refusing to use unowned cache directory '${root}'.`, {
|
|
1399
|
+
exitCode: 7,
|
|
1400
|
+
code: "cache-marker-missing"
|
|
1401
|
+
});
|
|
1402
|
+
await writeFile(marker, CACHE_MARKER_CONTENTS, {
|
|
1403
|
+
encoding: "utf8",
|
|
1404
|
+
flag: "wx",
|
|
1405
|
+
mode: 384
|
|
1406
|
+
});
|
|
1407
|
+
return root;
|
|
1408
|
+
}
|
|
1409
|
+
if (!markerMetadata.isFile() || markerMetadata.isSymbolicLink()) throw new CliError(`Cache marker in '${root}' failed integrity verification.`, {
|
|
1410
|
+
exitCode: 7,
|
|
1411
|
+
code: "cache-marker-invalid"
|
|
1412
|
+
});
|
|
1413
|
+
let markerContents;
|
|
1414
|
+
try {
|
|
1415
|
+
markerContents = await readFile(marker, "utf8");
|
|
1416
|
+
} catch (error) {
|
|
1417
|
+
throw new CliError(`Cache marker in '${root}' could not be read.`, {
|
|
1418
|
+
exitCode: 7,
|
|
1419
|
+
code: "cache-marker-invalid",
|
|
1420
|
+
cause: error
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
if (markerContents !== CACHE_MARKER_CONTENTS) throw new CliError(`Cache marker in '${root}' failed integrity verification.`, {
|
|
1424
|
+
exitCode: 7,
|
|
1425
|
+
code: "cache-marker-invalid"
|
|
1426
|
+
});
|
|
1427
|
+
return root;
|
|
1428
|
+
}
|
|
1429
|
+
function toolchainDirectory(config, descriptor) {
|
|
1430
|
+
return path.join(cacheRoot(config), "toolchains", descriptor.id, descriptor.version);
|
|
1431
|
+
}
|
|
1432
|
+
async function safeCacheDirectory(root, segments, create) {
|
|
1433
|
+
let current = root;
|
|
1434
|
+
for (const segment of segments) {
|
|
1435
|
+
current = path.join(current, segment);
|
|
1436
|
+
let metadata;
|
|
1437
|
+
try {
|
|
1438
|
+
metadata = await lstat(current);
|
|
1439
|
+
} catch (error) {
|
|
1440
|
+
if (error.code !== "ENOENT") throw error;
|
|
1441
|
+
if (!create) return void 0;
|
|
1442
|
+
try {
|
|
1443
|
+
await mkdir(current, { mode: 448 });
|
|
1444
|
+
} catch (mkdirError) {
|
|
1445
|
+
if (mkdirError.code !== "EEXIST") throw mkdirError;
|
|
1446
|
+
}
|
|
1447
|
+
metadata = await lstat(current);
|
|
1448
|
+
}
|
|
1449
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new CliError(`Cache component '${current}' must be a real directory.`, {
|
|
1450
|
+
exitCode: 7,
|
|
1451
|
+
code: "cache-path-integrity"
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
return current;
|
|
1455
|
+
}
|
|
1456
|
+
function descriptorById(id) {
|
|
1457
|
+
const descriptor = DESCRIPTORS.find((candidate) => candidate.id === id);
|
|
1458
|
+
if (!descriptor) throw usageError(`Unknown toolchain '${id}'.`);
|
|
1459
|
+
return descriptor;
|
|
1460
|
+
}
|
|
1461
|
+
function sourceFor(config, descriptor) {
|
|
1462
|
+
return {
|
|
1463
|
+
kind: "server",
|
|
1464
|
+
descriptor,
|
|
1465
|
+
directory: pathToFileURL(`${toolchainDirectory(config, descriptor)}${path.sep}`)
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
async function sha256(file) {
|
|
1469
|
+
const digest = createHash("sha256");
|
|
1470
|
+
const handle = await open(file, "r");
|
|
1471
|
+
try {
|
|
1472
|
+
for await (const chunk of handle.createReadStream({ autoClose: false })) digest.update(chunk);
|
|
1473
|
+
} finally {
|
|
1474
|
+
await handle.close();
|
|
1475
|
+
}
|
|
1476
|
+
return digest.digest("hex");
|
|
1477
|
+
}
|
|
1478
|
+
async function verifyDescriptor(config, descriptor) {
|
|
1479
|
+
const owned = await ownedCacheRoot(config, false);
|
|
1480
|
+
if (!owned) throw new CliError(`Toolchain '${descriptor.id}' is not fetched. Run 'woj toolchain fetch ${descriptor.id}'.`, {
|
|
1481
|
+
exitCode: 7,
|
|
1482
|
+
code: "toolchain-missing"
|
|
1483
|
+
});
|
|
1484
|
+
const directory = await safeCacheDirectory(owned, [
|
|
1485
|
+
"toolchains",
|
|
1486
|
+
descriptor.id,
|
|
1487
|
+
descriptor.version
|
|
1488
|
+
], false);
|
|
1489
|
+
if (!directory) throw new CliError(`Toolchain '${descriptor.id}' is not fetched. Run 'woj toolchain fetch ${descriptor.id}'.`, {
|
|
1490
|
+
exitCode: 7,
|
|
1491
|
+
code: "toolchain-missing"
|
|
1492
|
+
});
|
|
1493
|
+
for (const asset of descriptor.assets) {
|
|
1494
|
+
const file = path.join(directory, path.basename(asset.path));
|
|
1495
|
+
let metadata;
|
|
1496
|
+
try {
|
|
1497
|
+
metadata = await lstat(file);
|
|
1498
|
+
} catch (error) {
|
|
1499
|
+
if (error.code === "ENOENT") throw new CliError(`Toolchain '${descriptor.id}' is not fetched. Run 'woj toolchain fetch ${descriptor.id}'.`, {
|
|
1500
|
+
exitCode: 7,
|
|
1501
|
+
code: "toolchain-missing"
|
|
1502
|
+
});
|
|
1503
|
+
throw error;
|
|
1504
|
+
}
|
|
1505
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size !== asset.bytes || await sha256(file) !== asset.sha256) throw new CliError(`Toolchain asset '${asset.path}' failed size or digest verification.`, {
|
|
1506
|
+
exitCode: 7,
|
|
1507
|
+
code: "toolchain-integrity"
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
return {
|
|
1511
|
+
descriptor,
|
|
1512
|
+
directory
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
function descriptorForLanguage(language) {
|
|
1516
|
+
const matches = DESCRIPTORS.filter((descriptor) => descriptor.languages.includes(language));
|
|
1517
|
+
if (matches.length !== 1) throw new CliError(`No unique toolchain provides language '${language}'.`, {
|
|
1518
|
+
exitCode: 7,
|
|
1519
|
+
code: "toolchain-unavailable"
|
|
1520
|
+
});
|
|
1521
|
+
return matches[0];
|
|
1522
|
+
}
|
|
1523
|
+
async function engineFor(root, config, workspace) {
|
|
1524
|
+
if (!config["runtime-directory"]) throw new CliError("runtime-directory is not configured. Run 'woj config set runtime-directory <path>'.", {
|
|
1525
|
+
exitCode: 7,
|
|
1526
|
+
code: "runtime-missing"
|
|
1527
|
+
});
|
|
1528
|
+
const descriptor = descriptorForLanguage(workspace.language);
|
|
1529
|
+
await verifyDescriptor(config, descriptor);
|
|
1530
|
+
const owned = await ownedCacheRoot(config, true);
|
|
1531
|
+
if (!owned) throw new CliError("The woj cache directory could not be created.", {
|
|
1532
|
+
exitCode: 7,
|
|
1533
|
+
code: "cache-root-invalid"
|
|
1534
|
+
});
|
|
1535
|
+
const engineCache = await safeCacheDirectory(owned, ["engine"], true);
|
|
1536
|
+
if (!engineCache) throw new CliError("The engine cache directory could not be created.", {
|
|
1537
|
+
exitCode: 7,
|
|
1538
|
+
code: "cache-path-integrity"
|
|
1539
|
+
});
|
|
1540
|
+
for (const component of [
|
|
1541
|
+
"runtime",
|
|
1542
|
+
"artifacts",
|
|
1543
|
+
"dependencies"
|
|
1544
|
+
]) if (!await safeCacheDirectory(engineCache, [component], true)) throw new CliError(`The engine ${component} cache directory could not be created.`, {
|
|
1545
|
+
exitCode: 7,
|
|
1546
|
+
code: "cache-path-integrity"
|
|
1547
|
+
});
|
|
1548
|
+
try {
|
|
1549
|
+
return await createServerEngine({
|
|
1550
|
+
runtimeDirectory: config["runtime-directory"],
|
|
1551
|
+
cacheDirectory: engineCache,
|
|
1552
|
+
toolchains: [sourceFor(config, descriptor)]
|
|
1553
|
+
});
|
|
1554
|
+
} catch (error) {
|
|
1555
|
+
throw new CliError("The configured local runtime distribution failed verification.", {
|
|
1556
|
+
exitCode: 7,
|
|
1557
|
+
code: "runtime-integrity",
|
|
1558
|
+
cause: error
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
async function project(root) {
|
|
1563
|
+
const workspace = await readWorkspace(root);
|
|
1564
|
+
return {
|
|
1565
|
+
workspace,
|
|
1566
|
+
input: {
|
|
1567
|
+
language: workspace.language,
|
|
1568
|
+
entry: workspace.entry,
|
|
1569
|
+
files: await readWorkspaceSources(root, workspace),
|
|
1570
|
+
target: workspace.target,
|
|
1571
|
+
optimization: workspace.optimization,
|
|
1572
|
+
name: workspace.name
|
|
1573
|
+
}
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
async function withEngine(root, config, workspace, action) {
|
|
1577
|
+
const engine = await engineFor(root, config, workspace);
|
|
1578
|
+
try {
|
|
1579
|
+
return await action(engine);
|
|
1580
|
+
} finally {
|
|
1581
|
+
engine.dispose();
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
function buildProjection(build) {
|
|
1585
|
+
return {
|
|
1586
|
+
success: build.success,
|
|
1587
|
+
diagnostics: build.diagnostics,
|
|
1588
|
+
stdout: build.stdout,
|
|
1589
|
+
stderr: build.stderr,
|
|
1590
|
+
cacheHit: build.cacheHit,
|
|
1591
|
+
artifact: build.artifact ? {
|
|
1592
|
+
kind: build.artifact.kind,
|
|
1593
|
+
size: build.artifact.size,
|
|
1594
|
+
metadata: {
|
|
1595
|
+
id: build.artifact.id,
|
|
1596
|
+
language: build.artifact.language,
|
|
1597
|
+
target: build.artifact.target,
|
|
1598
|
+
optimization: build.artifact.optimization,
|
|
1599
|
+
durationMs: build.artifact.durationMs
|
|
1600
|
+
}
|
|
1601
|
+
} : null
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1604
|
+
function parsePublicProblem(bytes) {
|
|
1605
|
+
let value;
|
|
1606
|
+
try {
|
|
1607
|
+
value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
1608
|
+
} catch (error) {
|
|
1609
|
+
throw new CliError("Pinned problem content is invalid UTF-8 JSON.", {
|
|
1610
|
+
exitCode: 4,
|
|
1611
|
+
code: "problem-content-invalid",
|
|
1612
|
+
cause: error
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
1615
|
+
const record = value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
|
|
1616
|
+
try {
|
|
1617
|
+
if (record?.schema === "wasm-oj-platform/practice-problem-projection/v1") return parseStandaloneProblemBundle({
|
|
1618
|
+
schema: "wasm-oj-browser-problem-v1",
|
|
1619
|
+
problem: record.problem
|
|
1620
|
+
});
|
|
1621
|
+
if (record?.schema === "wasm-oj-platform/contest-public-problem-projection/v1") {
|
|
1622
|
+
const problem = record.problem;
|
|
1623
|
+
return {
|
|
1624
|
+
...parseStandaloneProblemBundle({
|
|
1625
|
+
schema: "wasm-oj-browser-problem-v1",
|
|
1626
|
+
problem: {
|
|
1627
|
+
...problem,
|
|
1628
|
+
editorial: {
|
|
1629
|
+
"zh-TW": "redacted",
|
|
1630
|
+
en: "redacted"
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
}),
|
|
1634
|
+
editorial: {
|
|
1635
|
+
"zh-TW": "",
|
|
1636
|
+
en: ""
|
|
1637
|
+
}
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
return parseStandaloneProblemBundle(value);
|
|
1641
|
+
} catch (error) {
|
|
1642
|
+
throw new CliError(error instanceof Error ? error.message : "Pinned problem content has an invalid schema.", {
|
|
1643
|
+
exitCode: 4,
|
|
1644
|
+
code: "problem-content-invalid",
|
|
1645
|
+
cause: error
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
async function pinnedProblem(root, workspace) {
|
|
1650
|
+
if (!workspace.problem) throw usageError("This workspace has no pinned problem. Run 'woj problem pull'.");
|
|
1651
|
+
let bytes;
|
|
1652
|
+
try {
|
|
1653
|
+
bytes = await readWorkspaceFileBytes(root, workspace.problem.contentFile, 8388608);
|
|
1654
|
+
} catch (error) {
|
|
1655
|
+
throw new CliError("Pinned problem content is missing or unsafe.", {
|
|
1656
|
+
exitCode: 4,
|
|
1657
|
+
code: "problem-content-integrity",
|
|
1658
|
+
cause: error
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
if (createHash("sha256").update(bytes).digest("hex") !== workspace.problem.contentSha256) throw new CliError("Pinned problem bytes no longer match woj.json.", {
|
|
1662
|
+
exitCode: 4,
|
|
1663
|
+
code: "problem-content-integrity"
|
|
1664
|
+
});
|
|
1665
|
+
return parsePublicProblem(bytes);
|
|
1666
|
+
}
|
|
1667
|
+
async function directoryBytes(root) {
|
|
1668
|
+
let total = 0;
|
|
1669
|
+
const walk = async (directory) => {
|
|
1670
|
+
let entries;
|
|
1671
|
+
try {
|
|
1672
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
1673
|
+
} catch (error) {
|
|
1674
|
+
if (error.code === "ENOENT") return;
|
|
1675
|
+
throw error;
|
|
1676
|
+
}
|
|
1677
|
+
for (const entry of entries) {
|
|
1678
|
+
const file = path.join(directory, entry.name);
|
|
1679
|
+
if (entry.isDirectory()) await walk(file);
|
|
1680
|
+
else if (entry.isFile()) total += (await stat(file)).size;
|
|
1681
|
+
}
|
|
1682
|
+
};
|
|
1683
|
+
await walk(root);
|
|
1684
|
+
return total;
|
|
1685
|
+
}
|
|
1686
|
+
async function exactResponseBytes(response, expected, label) {
|
|
1687
|
+
const declared = response.headers.get("content-length");
|
|
1688
|
+
if (declared !== null && (!/^(?:0|[1-9][0-9]*)$/.test(declared) || Number(declared) !== expected)) throw new CliError(`Fetched toolchain asset '${label}' has the wrong Content-Length.`, {
|
|
1689
|
+
exitCode: 7,
|
|
1690
|
+
code: "toolchain-integrity"
|
|
1691
|
+
});
|
|
1692
|
+
if (!response.body) throw new CliError(`Fetched toolchain asset '${label}' has no body.`, { exitCode: 6 });
|
|
1693
|
+
const reader = response.body.getReader();
|
|
1694
|
+
const chunks = [];
|
|
1695
|
+
let total = 0;
|
|
1696
|
+
try {
|
|
1697
|
+
for (;;) {
|
|
1698
|
+
const { done, value } = await reader.read();
|
|
1699
|
+
if (done) break;
|
|
1700
|
+
total += value.byteLength;
|
|
1701
|
+
if (total > expected) {
|
|
1702
|
+
await reader.cancel("toolchain asset exceeds its pinned size");
|
|
1703
|
+
throw new CliError(`Fetched toolchain asset '${label}' exceeds its pinned size.`, {
|
|
1704
|
+
exitCode: 7,
|
|
1705
|
+
code: "toolchain-integrity"
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
chunks.push(value);
|
|
1709
|
+
}
|
|
1710
|
+
} finally {
|
|
1711
|
+
reader.releaseLock();
|
|
1712
|
+
}
|
|
1713
|
+
if (total !== expected) throw new CliError(`Fetched toolchain asset '${label}' has the wrong size.`, {
|
|
1714
|
+
exitCode: 7,
|
|
1715
|
+
code: "toolchain-integrity"
|
|
1716
|
+
});
|
|
1717
|
+
const output = new Uint8Array(total);
|
|
1718
|
+
let offset = 0;
|
|
1719
|
+
for (const chunk of chunks) {
|
|
1720
|
+
output.set(chunk, offset);
|
|
1721
|
+
offset += chunk.byteLength;
|
|
1722
|
+
}
|
|
1723
|
+
return output;
|
|
1724
|
+
}
|
|
1725
|
+
var NodeLocalRuntime = class {
|
|
1726
|
+
async build(root, config) {
|
|
1727
|
+
const { workspace, input } = await project(root);
|
|
1728
|
+
const build = await withEngine(root, config, workspace, (engine) => engine.compile(input));
|
|
1729
|
+
return {
|
|
1730
|
+
value: buildProjection(build),
|
|
1731
|
+
successful: build.success
|
|
1732
|
+
};
|
|
1733
|
+
}
|
|
1734
|
+
async run(root, config, options) {
|
|
1735
|
+
const { workspace, input } = await project(root);
|
|
1736
|
+
const result = await withEngine(root, config, workspace, (engine) => engine.execute(input, {
|
|
1737
|
+
stdin: options.stdin ?? "",
|
|
1738
|
+
args: options.args
|
|
1739
|
+
}));
|
|
1740
|
+
return {
|
|
1741
|
+
value: {
|
|
1742
|
+
build: buildProjection(result.build),
|
|
1743
|
+
run: result.run ?? null
|
|
1744
|
+
},
|
|
1745
|
+
successful: Boolean(result.build.success && result.run?.termination === "exited" && result.run.code === 0)
|
|
1746
|
+
};
|
|
1747
|
+
}
|
|
1748
|
+
async test(root, config, options) {
|
|
1749
|
+
const { workspace, input } = await project(root);
|
|
1750
|
+
const publicSamples = (await pinnedProblem(root, workspace)).judgeCases.filter((testCase) => testCase.kind === "sample");
|
|
1751
|
+
if (new Set(options.cases).size !== options.cases.length) throw usageError("--case values must be unique.");
|
|
1752
|
+
const samples = options.cases.length === 0 ? publicSamples : options.cases.map((id) => {
|
|
1753
|
+
const sample = publicSamples.find((candidate) => candidate.id === id);
|
|
1754
|
+
if (!sample) throw usageError(`Public sample case '${id}' does not exist in the pinned problem.`);
|
|
1755
|
+
return sample;
|
|
1756
|
+
});
|
|
1757
|
+
const results = [];
|
|
1758
|
+
let successful = true;
|
|
1759
|
+
await withEngine(root, config, workspace, async (engine) => {
|
|
1760
|
+
const build = await engine.compile(input);
|
|
1761
|
+
if (!build.success || !build.artifact) {
|
|
1762
|
+
results.push({ build: buildProjection(build) });
|
|
1763
|
+
successful = false;
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
for (const sample of samples) {
|
|
1767
|
+
const run = await engine.run(build.artifact, { stdin: sample.input });
|
|
1768
|
+
const accepted = run.termination === "exited" && run.code === 0 && run.stdout.replace(/[ \t]+$/gm, "").trimEnd() === sample.output.replace(/[ \t]+$/gm, "").trimEnd();
|
|
1769
|
+
results.push({
|
|
1770
|
+
id: sample.id,
|
|
1771
|
+
accepted,
|
|
1772
|
+
run
|
|
1773
|
+
});
|
|
1774
|
+
if (!accepted) successful = false;
|
|
1775
|
+
}
|
|
1776
|
+
});
|
|
1777
|
+
return {
|
|
1778
|
+
value: {
|
|
1779
|
+
samples: results,
|
|
1780
|
+
passed: successful
|
|
1781
|
+
},
|
|
1782
|
+
successful
|
|
1783
|
+
};
|
|
1784
|
+
}
|
|
1785
|
+
async bench(root, config, options) {
|
|
1786
|
+
const { workspace, input } = await project(root);
|
|
1787
|
+
const durations = [];
|
|
1788
|
+
let buildValue;
|
|
1789
|
+
let successful = true;
|
|
1790
|
+
await withEngine(root, config, workspace, async (engine) => {
|
|
1791
|
+
const build = await engine.compile(input);
|
|
1792
|
+
buildValue = buildProjection(build);
|
|
1793
|
+
if (!build.success || !build.artifact) {
|
|
1794
|
+
successful = false;
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
for (let index = 0; index < options.iterations; index += 1) {
|
|
1798
|
+
const run = await engine.run(build.artifact, { stdin: options.stdin ?? "" });
|
|
1799
|
+
durations.push(run.durationMs);
|
|
1800
|
+
if (run.termination !== "exited" || run.code !== 0) successful = false;
|
|
1801
|
+
}
|
|
1802
|
+
});
|
|
1803
|
+
const sorted = [...durations].sort((left, right) => left - right);
|
|
1804
|
+
return {
|
|
1805
|
+
value: {
|
|
1806
|
+
build: buildValue,
|
|
1807
|
+
iterations: durations.length,
|
|
1808
|
+
durationsMs: durations,
|
|
1809
|
+
medianMs: sorted[Math.floor(sorted.length / 2)] ?? null
|
|
1810
|
+
},
|
|
1811
|
+
successful
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
1814
|
+
async inspectJudge(file) {
|
|
1815
|
+
let validated;
|
|
1816
|
+
try {
|
|
1817
|
+
const target = path.resolve(file);
|
|
1818
|
+
const metadata = await lstat(target);
|
|
1819
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > WASM_OJ_JUDGE_PACKAGE_MAX_BYTES) throw new Error("Judge package must be a bounded regular file.");
|
|
1820
|
+
validated = await validateJudgePackage(new Uint8Array(await readFile(target)));
|
|
1821
|
+
} catch (error) {
|
|
1822
|
+
throw new CliError(error instanceof Error ? error.message : "Judge package is invalid.", {
|
|
1823
|
+
exitCode: 4,
|
|
1824
|
+
code: "judge-package-invalid",
|
|
1825
|
+
cause: error
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
return {
|
|
1829
|
+
value: validated,
|
|
1830
|
+
successful: true
|
|
1831
|
+
};
|
|
1832
|
+
}
|
|
1833
|
+
async verifyJudge(file, options) {
|
|
1834
|
+
let validated;
|
|
1835
|
+
try {
|
|
1836
|
+
const target = path.resolve(file);
|
|
1837
|
+
const metadata = await lstat(target);
|
|
1838
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > WASM_OJ_JUDGE_PACKAGE_MAX_BYTES) throw new Error("Judge package must be a bounded regular file.");
|
|
1839
|
+
validated = await validateJudgePackage(new Uint8Array(await readFile(target)), {
|
|
1840
|
+
expectedBytes: options.bytes,
|
|
1841
|
+
expectedSha256: options.sha256
|
|
1842
|
+
});
|
|
1843
|
+
} catch (error) {
|
|
1844
|
+
throw new CliError(error instanceof Error ? error.message : "Judge package is invalid.", {
|
|
1845
|
+
exitCode: 4,
|
|
1846
|
+
code: "judge-package-invalid",
|
|
1847
|
+
cause: error
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
return {
|
|
1851
|
+
value: validated,
|
|
1852
|
+
successful: true
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
async executeJudge(root, config, file) {
|
|
1856
|
+
const { workspace, input } = await project(root);
|
|
1857
|
+
let package_;
|
|
1858
|
+
try {
|
|
1859
|
+
const target = path.resolve(file);
|
|
1860
|
+
const metadata = await lstat(target);
|
|
1861
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > WASM_OJ_JUDGE_PACKAGE_MAX_BYTES) throw new Error("Judge package must be a bounded regular file.");
|
|
1862
|
+
package_ = await decodeJudgePackageForExecution(new Uint8Array(await readFile(target)));
|
|
1863
|
+
} catch (error) {
|
|
1864
|
+
throw new CliError(error instanceof Error ? error.message : "Judge package is invalid.", {
|
|
1865
|
+
exitCode: 4,
|
|
1866
|
+
code: "judge-package-invalid",
|
|
1867
|
+
cause: error
|
|
1868
|
+
});
|
|
1869
|
+
}
|
|
1870
|
+
const allowed = package_.allowedProfiles[workspace.language];
|
|
1871
|
+
if (!allowed || allowed.target !== workspace.target || allowed.optimization !== workspace.optimization) throw new CliError("Workspace compile profile is not allowed by this judge package.", {
|
|
1872
|
+
exitCode: 4,
|
|
1873
|
+
code: "judge-profile-mismatch"
|
|
1874
|
+
});
|
|
1875
|
+
const result = await withEngine(root, config, workspace, (engine) => engine.judgeProject(input, trustedJudgeSpec(package_.judgeData, package_.judge)));
|
|
1876
|
+
const accepted = Boolean(result.build.success && result.judge?.verdict === "accepted");
|
|
1877
|
+
return {
|
|
1878
|
+
value: {
|
|
1879
|
+
build: buildProjection(result.build),
|
|
1880
|
+
judge: result.judge ?? null
|
|
1881
|
+
},
|
|
1882
|
+
successful: accepted
|
|
1883
|
+
};
|
|
1884
|
+
}
|
|
1885
|
+
async toolchainList(config) {
|
|
1886
|
+
return {
|
|
1887
|
+
value: { toolchains: await Promise.all(DESCRIPTORS.map(async (descriptor) => {
|
|
1888
|
+
try {
|
|
1889
|
+
await verifyDescriptor(config, descriptor);
|
|
1890
|
+
return {
|
|
1891
|
+
...descriptor,
|
|
1892
|
+
fetched: true
|
|
1893
|
+
};
|
|
1894
|
+
} catch (error) {
|
|
1895
|
+
if (error instanceof CliError && error.code === "toolchain-missing") return {
|
|
1896
|
+
...descriptor,
|
|
1897
|
+
fetched: false
|
|
1898
|
+
};
|
|
1899
|
+
throw error;
|
|
1900
|
+
}
|
|
1901
|
+
})) },
|
|
1902
|
+
successful: true
|
|
1903
|
+
};
|
|
1904
|
+
}
|
|
1905
|
+
async toolchainInfo(config, id) {
|
|
1906
|
+
const descriptor = descriptorById(id);
|
|
1907
|
+
let fetched = false;
|
|
1908
|
+
try {
|
|
1909
|
+
await verifyDescriptor(config, descriptor);
|
|
1910
|
+
fetched = true;
|
|
1911
|
+
} catch (error) {
|
|
1912
|
+
if (!(error instanceof CliError) || error.code !== "toolchain-missing") throw error;
|
|
1913
|
+
}
|
|
1914
|
+
return {
|
|
1915
|
+
value: {
|
|
1916
|
+
...descriptor,
|
|
1917
|
+
fetched,
|
|
1918
|
+
directory: toolchainDirectory(config, descriptor)
|
|
1919
|
+
},
|
|
1920
|
+
successful: true
|
|
1921
|
+
};
|
|
1922
|
+
}
|
|
1923
|
+
async toolchainFetch(config, server, id, fetchImplementation = globalThis.fetch) {
|
|
1924
|
+
const descriptor = descriptorById(id);
|
|
1925
|
+
const owned = await ownedCacheRoot(config, true);
|
|
1926
|
+
if (!owned) throw new CliError("The woj cache directory could not be created.", {
|
|
1927
|
+
exitCode: 7,
|
|
1928
|
+
code: "cache-root-invalid"
|
|
1929
|
+
});
|
|
1930
|
+
const directory = await safeCacheDirectory(owned, [
|
|
1931
|
+
"toolchains",
|
|
1932
|
+
descriptor.id,
|
|
1933
|
+
descriptor.version
|
|
1934
|
+
], true);
|
|
1935
|
+
if (!directory) throw new CliError("The toolchain cache directory could not be created.", {
|
|
1936
|
+
exitCode: 7,
|
|
1937
|
+
code: "cache-path-integrity"
|
|
1938
|
+
});
|
|
1939
|
+
for (const asset of descriptor.assets) {
|
|
1940
|
+
let response;
|
|
1941
|
+
try {
|
|
1942
|
+
response = await fetchImplementation(new URL(asset.path, server), { redirect: "error" });
|
|
1943
|
+
} catch (error) {
|
|
1944
|
+
throw new CliError(`Could not fetch '${asset.path}'.`, {
|
|
1945
|
+
exitCode: 6,
|
|
1946
|
+
cause: error
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
if (!response.ok) throw new CliError(`Could not fetch '${asset.path}' (HTTP ${response.status}).`, { exitCode: 6 });
|
|
1950
|
+
const bytes = await exactResponseBytes(response, asset.bytes, asset.path);
|
|
1951
|
+
if (bytes.byteLength !== asset.bytes || createHash("sha256").update(bytes).digest("hex") !== asset.sha256) throw new CliError(`Fetched toolchain asset '${asset.path}' failed size or digest verification.`, {
|
|
1952
|
+
exitCode: 7,
|
|
1953
|
+
code: "toolchain-integrity"
|
|
1954
|
+
});
|
|
1955
|
+
const output = path.join(directory, path.basename(asset.path));
|
|
1956
|
+
const temporary = `${output}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
1957
|
+
await writeFile(temporary, bytes, {
|
|
1958
|
+
flag: "wx",
|
|
1959
|
+
mode: 384
|
|
1960
|
+
});
|
|
1961
|
+
await rename(temporary, output);
|
|
1962
|
+
}
|
|
1963
|
+
await verifyDescriptor(config, descriptor);
|
|
1964
|
+
return {
|
|
1965
|
+
value: {
|
|
1966
|
+
id: descriptor.id,
|
|
1967
|
+
version: descriptor.version,
|
|
1968
|
+
directory
|
|
1969
|
+
},
|
|
1970
|
+
successful: true
|
|
1971
|
+
};
|
|
1972
|
+
}
|
|
1973
|
+
async toolchainVerify(config, id) {
|
|
1974
|
+
const descriptors = id ? [descriptorById(id)] : DESCRIPTORS;
|
|
1975
|
+
const verified = [];
|
|
1976
|
+
for (const descriptor of descriptors) {
|
|
1977
|
+
await verifyDescriptor(config, descriptor);
|
|
1978
|
+
verified.push({
|
|
1979
|
+
id: descriptor.id,
|
|
1980
|
+
version: descriptor.version
|
|
1981
|
+
});
|
|
1982
|
+
}
|
|
1983
|
+
return {
|
|
1984
|
+
value: { verified },
|
|
1985
|
+
successful: true
|
|
1986
|
+
};
|
|
1987
|
+
}
|
|
1988
|
+
async toolchainPrune(config) {
|
|
1989
|
+
const owned = await ownedCacheRoot(config, true);
|
|
1990
|
+
if (!owned) throw new CliError("The woj cache directory could not be created.", {
|
|
1991
|
+
exitCode: 7,
|
|
1992
|
+
code: "cache-root-invalid"
|
|
1993
|
+
});
|
|
1994
|
+
const root = await safeCacheDirectory(owned, ["toolchains"], true);
|
|
1995
|
+
if (!root) throw new CliError("The toolchain cache directory could not be created.", {
|
|
1996
|
+
exitCode: 7,
|
|
1997
|
+
code: "cache-path-integrity"
|
|
1998
|
+
});
|
|
1999
|
+
const keep = new Set(DESCRIPTORS.map((descriptor) => path.resolve(toolchainDirectory(config, descriptor))));
|
|
2000
|
+
const removed = [];
|
|
2001
|
+
let ids;
|
|
2002
|
+
try {
|
|
2003
|
+
ids = await readdir(root, { withFileTypes: true });
|
|
2004
|
+
} catch (error) {
|
|
2005
|
+
if (error.code === "ENOENT") return {
|
|
2006
|
+
value: { removed },
|
|
2007
|
+
successful: true
|
|
2008
|
+
};
|
|
2009
|
+
throw error;
|
|
2010
|
+
}
|
|
2011
|
+
for (const id of ids) {
|
|
2012
|
+
if (id.isSymbolicLink() || !id.isDirectory()) throw new CliError(`Cache component '${path.join(root, id.name)}' must be a real directory.`, {
|
|
2013
|
+
exitCode: 7,
|
|
2014
|
+
code: "cache-path-integrity"
|
|
2015
|
+
});
|
|
2016
|
+
const idRoot = await safeCacheDirectory(root, [id.name], false);
|
|
2017
|
+
if (!idRoot) continue;
|
|
2018
|
+
const versions = await readdir(idRoot, { withFileTypes: true });
|
|
2019
|
+
for (const version of versions) {
|
|
2020
|
+
const candidate = path.resolve(idRoot, version.name);
|
|
2021
|
+
if (version.isSymbolicLink() || !version.isDirectory()) throw new CliError(`Cache component '${candidate}' must be a real directory.`, {
|
|
2022
|
+
exitCode: 7,
|
|
2023
|
+
code: "cache-path-integrity"
|
|
2024
|
+
});
|
|
2025
|
+
if (await safeCacheDirectory(idRoot, [version.name], false) && !keep.has(candidate)) {
|
|
2026
|
+
await rm(candidate, {
|
|
2027
|
+
recursive: true,
|
|
2028
|
+
force: false
|
|
2029
|
+
});
|
|
2030
|
+
removed.push(candidate);
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
return {
|
|
2035
|
+
value: { removed },
|
|
2036
|
+
successful: true
|
|
2037
|
+
};
|
|
2038
|
+
}
|
|
2039
|
+
async cacheStatus(config) {
|
|
2040
|
+
const root = await ownedCacheRoot(config, false);
|
|
2041
|
+
return {
|
|
2042
|
+
value: {
|
|
2043
|
+
directory: cacheRoot(config),
|
|
2044
|
+
bytes: root ? await directoryBytes(root) : 0
|
|
2045
|
+
},
|
|
2046
|
+
successful: true
|
|
2047
|
+
};
|
|
2048
|
+
}
|
|
2049
|
+
async cachePrune(config) {
|
|
2050
|
+
const root = await ownedCacheRoot(config, true);
|
|
2051
|
+
if (!root) throw new CliError("The woj cache directory could not be created.", {
|
|
2052
|
+
exitCode: 7,
|
|
2053
|
+
code: "cache-root-invalid"
|
|
2054
|
+
});
|
|
2055
|
+
const engine = await safeCacheDirectory(root, ["engine"], false);
|
|
2056
|
+
if (!engine) return {
|
|
2057
|
+
value: { removed: [] },
|
|
2058
|
+
successful: true
|
|
2059
|
+
};
|
|
2060
|
+
await rm(engine, {
|
|
2061
|
+
recursive: true,
|
|
2062
|
+
force: false
|
|
2063
|
+
});
|
|
2064
|
+
return {
|
|
2065
|
+
value: { removed: engine },
|
|
2066
|
+
successful: true
|
|
2067
|
+
};
|
|
2068
|
+
}
|
|
2069
|
+
async cacheClear(config) {
|
|
2070
|
+
const root = await ownedCacheRoot(config, true);
|
|
2071
|
+
if (!root) throw new CliError("The woj cache directory could not be created.", {
|
|
2072
|
+
exitCode: 7,
|
|
2073
|
+
code: "cache-root-invalid"
|
|
2074
|
+
});
|
|
2075
|
+
await rm(root, {
|
|
2076
|
+
recursive: true,
|
|
2077
|
+
force: true
|
|
2078
|
+
});
|
|
2079
|
+
return {
|
|
2080
|
+
value: { removed: root },
|
|
2081
|
+
successful: true
|
|
2082
|
+
};
|
|
2083
|
+
}
|
|
2084
|
+
async doctor(root, config) {
|
|
2085
|
+
const checks = [];
|
|
2086
|
+
checks.push({
|
|
2087
|
+
name: "server",
|
|
2088
|
+
ok: Boolean(config.server),
|
|
2089
|
+
detail: config.server ?? "not configured"
|
|
2090
|
+
});
|
|
2091
|
+
checks.push({
|
|
2092
|
+
name: "runtime",
|
|
2093
|
+
ok: Boolean(config["runtime-directory"]),
|
|
2094
|
+
detail: config["runtime-directory"] ?? "not configured"
|
|
2095
|
+
});
|
|
2096
|
+
try {
|
|
2097
|
+
const workspace = await readWorkspace(root);
|
|
2098
|
+
checks.push({
|
|
2099
|
+
name: "workspace",
|
|
2100
|
+
ok: true,
|
|
2101
|
+
detail: `${workspace.language}/${workspace.target}/${workspace.optimization}`
|
|
2102
|
+
});
|
|
2103
|
+
} catch (error) {
|
|
2104
|
+
checks.push({
|
|
2105
|
+
name: "workspace",
|
|
2106
|
+
ok: false,
|
|
2107
|
+
detail: error instanceof Error ? error.message : "invalid"
|
|
2108
|
+
});
|
|
2109
|
+
}
|
|
2110
|
+
for (const descriptor of DESCRIPTORS) try {
|
|
2111
|
+
await verifyDescriptor(config, descriptor);
|
|
2112
|
+
checks.push({
|
|
2113
|
+
name: `toolchain:${descriptor.id}`,
|
|
2114
|
+
ok: true,
|
|
2115
|
+
detail: descriptor.version
|
|
2116
|
+
});
|
|
2117
|
+
} catch (error) {
|
|
2118
|
+
checks.push({
|
|
2119
|
+
name: `toolchain:${descriptor.id}`,
|
|
2120
|
+
ok: false,
|
|
2121
|
+
detail: error instanceof Error ? error.message : "invalid"
|
|
2122
|
+
});
|
|
2123
|
+
}
|
|
2124
|
+
return {
|
|
2125
|
+
value: { checks },
|
|
2126
|
+
successful: checks.every((check) => check.ok)
|
|
2127
|
+
};
|
|
2128
|
+
}
|
|
2129
|
+
};
|
|
2130
|
+
function judgeSucceeded(result) {
|
|
2131
|
+
return result?.verdict === "accepted";
|
|
2132
|
+
}
|
|
2133
|
+
//#endregion
|
|
2134
|
+
//#region src/cli/commands.ts
|
|
2135
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
2136
|
+
var TERMINAL_SUBMISSION_STATES = /* @__PURE__ */ new Set([
|
|
2137
|
+
"completed",
|
|
2138
|
+
"compile-error",
|
|
2139
|
+
"judge-error",
|
|
2140
|
+
"infrastructure-error",
|
|
2141
|
+
"cancelled"
|
|
2142
|
+
]);
|
|
2143
|
+
var TERMINAL_VALIDATION_STATES = /* @__PURE__ */ new Set([
|
|
2144
|
+
"valid",
|
|
2145
|
+
"invalid",
|
|
2146
|
+
"infrastructure-error"
|
|
2147
|
+
]);
|
|
2148
|
+
var TERMINAL_PUBLICATION_STATES = /* @__PURE__ */ new Set(["published", "failed"]);
|
|
2149
|
+
var TERMINAL_REJUDGE_STATES = /* @__PURE__ */ new Set([
|
|
2150
|
+
"effective",
|
|
2151
|
+
"failed",
|
|
2152
|
+
"cancelled"
|
|
2153
|
+
]);
|
|
2154
|
+
function stringOption(command, name) {
|
|
2155
|
+
const value = command.options[name];
|
|
2156
|
+
if (value === void 0) return void 0;
|
|
2157
|
+
if (typeof value !== "string") throw usageError(`--${name} requires one value.`);
|
|
2158
|
+
return value;
|
|
2159
|
+
}
|
|
2160
|
+
function booleanOption(command, name) {
|
|
2161
|
+
return command.options[name] === true;
|
|
2162
|
+
}
|
|
2163
|
+
function repeatableOption(command, name) {
|
|
2164
|
+
const value = command.options[name];
|
|
2165
|
+
if (value === void 0) return [];
|
|
2166
|
+
if (!Array.isArray(value)) throw usageError(`--${name} is repeatable.`);
|
|
2167
|
+
return value;
|
|
2168
|
+
}
|
|
2169
|
+
function localeOption(command) {
|
|
2170
|
+
const locale = stringOption(command, "locale") ?? "zh-TW";
|
|
2171
|
+
if (locale !== "zh-TW" && locale !== "en") throw usageError("--locale must be zh-TW or en.");
|
|
2172
|
+
return locale;
|
|
2173
|
+
}
|
|
2174
|
+
async function runInput(command, cwd) {
|
|
2175
|
+
const input = stringOption(command, "input");
|
|
2176
|
+
const text = stringOption(command, "text");
|
|
2177
|
+
if (input !== void 0 && text !== void 0) throw usageError("Use either --input or --text, not both.");
|
|
2178
|
+
if (input === void 0) return text;
|
|
2179
|
+
const file = path.resolve(cwd, input);
|
|
2180
|
+
let metadata;
|
|
2181
|
+
try {
|
|
2182
|
+
metadata = await lstat(file);
|
|
2183
|
+
} catch (error) {
|
|
2184
|
+
throw new CliError("--input must name a readable UTF-8 file no larger than 8 MiB.", {
|
|
2185
|
+
exitCode: 7,
|
|
2186
|
+
code: "input-file-invalid",
|
|
2187
|
+
cause: error
|
|
2188
|
+
});
|
|
2189
|
+
}
|
|
2190
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > 8388608) throw new CliError("--input must name a real UTF-8 file no larger than 8 MiB.", {
|
|
2191
|
+
exitCode: 7,
|
|
2192
|
+
code: "input-file-invalid"
|
|
2193
|
+
});
|
|
2194
|
+
const bytes = new Uint8Array(await readFile(file));
|
|
2195
|
+
try {
|
|
2196
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
2197
|
+
} catch (error) {
|
|
2198
|
+
throw new CliError("--input is not valid UTF-8.", {
|
|
2199
|
+
exitCode: 7,
|
|
2200
|
+
code: "input-file-invalid",
|
|
2201
|
+
cause: error
|
|
2202
|
+
});
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
function canonicalTimestamp(value, label) {
|
|
2206
|
+
if (value === void 0) return void 0;
|
|
2207
|
+
if (Number.isNaN(Date.parse(value)) || new Date(value).toISOString() !== value) throw usageError(`--${label} must be a canonical ISO timestamp.`);
|
|
2208
|
+
return value;
|
|
2209
|
+
}
|
|
2210
|
+
function canonicalCursorTimestamp(value, label) {
|
|
2211
|
+
if (Number.isNaN(Date.parse(value)) || new Date(value).toISOString() !== value) throw usageError(`${label} timestamp is invalid.`);
|
|
2212
|
+
return value;
|
|
2213
|
+
}
|
|
2214
|
+
function normalizedRelativePath(value, label) {
|
|
2215
|
+
if (!value || value.length > 512 || value.startsWith("/") || value.includes("\\") || value.includes("\0") || value.split("/").some((part) => !part || part === "." || part === "..")) throw usageError(`${label} must be a normalized relative POSIX path.`);
|
|
2216
|
+
return value;
|
|
2217
|
+
}
|
|
2218
|
+
function exactPositionals(command, minimum, maximum = minimum) {
|
|
2219
|
+
if (command.positionals.length < minimum || command.positionals.length > maximum) throw usageError(`Usage: woj ${command.spec.path.join(" ")}${command.spec.usage ? ` ${command.spec.usage}` : ""}`);
|
|
2220
|
+
return command.positionals;
|
|
2221
|
+
}
|
|
2222
|
+
function uuid(value, label) {
|
|
2223
|
+
if (!UUID.test(value)) throw usageError(`${label} must be a UUID.`);
|
|
2224
|
+
return value;
|
|
2225
|
+
}
|
|
2226
|
+
function serverUuid(record, name, label = name) {
|
|
2227
|
+
const value = field(record, name, label);
|
|
2228
|
+
if (!UUID.test(value)) throw new CliError(`Server ${label} is not a UUID.`, {
|
|
2229
|
+
exitCode: 6,
|
|
2230
|
+
code: "server-response-invalid"
|
|
2231
|
+
});
|
|
2232
|
+
return value;
|
|
2233
|
+
}
|
|
2234
|
+
function positiveInteger(value, fallback, maximum, label) {
|
|
2235
|
+
const parsed = Number(value ?? String(fallback));
|
|
2236
|
+
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > maximum) throw usageError(`${label} must be an integer from 1 to ${maximum}.`);
|
|
2237
|
+
return parsed;
|
|
2238
|
+
}
|
|
2239
|
+
function boundedIntegerOption(command, name, maximum) {
|
|
2240
|
+
const value = stringOption(command, name);
|
|
2241
|
+
return value === void 0 ? void 0 : String(positiveInteger(value, 1, maximum, `--${name}`));
|
|
2242
|
+
}
|
|
2243
|
+
function query(parameters) {
|
|
2244
|
+
const search = new URLSearchParams();
|
|
2245
|
+
for (const [name, value] of Object.entries(parameters)) if (value !== void 0) search.set(name, value);
|
|
2246
|
+
const suffix = search.toString();
|
|
2247
|
+
return suffix ? `?${suffix}` : "";
|
|
2248
|
+
}
|
|
2249
|
+
function cursor(value, fields, label) {
|
|
2250
|
+
if (value === void 0) return {};
|
|
2251
|
+
let parsed;
|
|
2252
|
+
try {
|
|
2253
|
+
parsed = JSON.parse(value);
|
|
2254
|
+
} catch (error) {
|
|
2255
|
+
throw new CliError(`${label} must be the exact JSON nextCursor returned by the server.`, {
|
|
2256
|
+
exitCode: 2,
|
|
2257
|
+
code: "usage",
|
|
2258
|
+
cause: error
|
|
2259
|
+
});
|
|
2260
|
+
}
|
|
2261
|
+
const record = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
|
|
2262
|
+
if (!record || JSON.stringify(Object.keys(record).sort()) !== JSON.stringify([...fields].sort()) || fields.some((fieldName) => typeof record[fieldName] !== "string" || !record[fieldName])) throw usageError(`${label} must be the exact JSON nextCursor returned by the server.`);
|
|
2263
|
+
return Object.fromEntries(fields.map((fieldName) => [fieldName, record[fieldName]]));
|
|
2264
|
+
}
|
|
2265
|
+
function object(value, label) {
|
|
2266
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new CliError(`${label} has an invalid shape.`, {
|
|
2267
|
+
exitCode: 6,
|
|
2268
|
+
code: "server-response-invalid"
|
|
2269
|
+
});
|
|
2270
|
+
return value;
|
|
2271
|
+
}
|
|
2272
|
+
function array(value, label) {
|
|
2273
|
+
if (!Array.isArray(value)) throw new CliError(`${label} has an invalid shape.`, {
|
|
2274
|
+
exitCode: 6,
|
|
2275
|
+
code: "server-response-invalid"
|
|
2276
|
+
});
|
|
2277
|
+
return value;
|
|
2278
|
+
}
|
|
2279
|
+
function localOutcome(result) {
|
|
2280
|
+
return {
|
|
2281
|
+
value: result.value,
|
|
2282
|
+
exitCode: result.successful ? WOJ_EXIT.success : WOJ_EXIT.unsuccessful
|
|
2283
|
+
};
|
|
2284
|
+
}
|
|
2285
|
+
async function configured(command, dependencies) {
|
|
2286
|
+
const config = await dependencies.configStore.read();
|
|
2287
|
+
const origin = command.global.server ?? config.server;
|
|
2288
|
+
if (!origin) throw usageError("No server is configured. Pass --server or run 'woj config set server <origin>'.");
|
|
2289
|
+
const canonical = new URL(validateConfigValue("server", origin)).origin;
|
|
2290
|
+
return {
|
|
2291
|
+
config,
|
|
2292
|
+
origin: canonical,
|
|
2293
|
+
client: dependencies.remote(canonical)
|
|
2294
|
+
};
|
|
2295
|
+
}
|
|
2296
|
+
async function logout(origin, client, tokenStore) {
|
|
2297
|
+
const token = await tokenStore.get(origin);
|
|
2298
|
+
if (token === void 0) return {
|
|
2299
|
+
value: {
|
|
2300
|
+
authenticated: false,
|
|
2301
|
+
server: origin
|
|
2302
|
+
},
|
|
2303
|
+
exitCode: 0
|
|
2304
|
+
};
|
|
2305
|
+
if (!isWojAccessToken(token)) {
|
|
2306
|
+
await tokenStore.delete(origin);
|
|
2307
|
+
return {
|
|
2308
|
+
value: {
|
|
2309
|
+
authenticated: false,
|
|
2310
|
+
server: origin
|
|
2311
|
+
},
|
|
2312
|
+
exitCode: 0
|
|
2313
|
+
};
|
|
2314
|
+
}
|
|
2315
|
+
let value;
|
|
2316
|
+
try {
|
|
2317
|
+
value = await client.request("/api/auth/logout", {
|
|
2318
|
+
method: "POST",
|
|
2319
|
+
body: {}
|
|
2320
|
+
});
|
|
2321
|
+
} catch (error) {
|
|
2322
|
+
if (!(error instanceof ApiError && error.status === 401 && error.code === "authentication-required")) throw error;
|
|
2323
|
+
await tokenStore.delete(origin);
|
|
2324
|
+
return {
|
|
2325
|
+
value: {
|
|
2326
|
+
authenticated: false,
|
|
2327
|
+
server: origin
|
|
2328
|
+
},
|
|
2329
|
+
exitCode: 0
|
|
2330
|
+
};
|
|
2331
|
+
}
|
|
2332
|
+
const response = object(value, "logout response");
|
|
2333
|
+
if (JSON.stringify(Object.keys(response).sort()) !== JSON.stringify(["ok"]) || response.ok !== true) throw new CliError("Server logout response has an invalid shape.", {
|
|
2334
|
+
exitCode: 6,
|
|
2335
|
+
code: "server-response-invalid"
|
|
2336
|
+
});
|
|
2337
|
+
await tokenStore.delete(origin);
|
|
2338
|
+
return {
|
|
2339
|
+
value: {
|
|
2340
|
+
authenticated: false,
|
|
2341
|
+
server: origin
|
|
2342
|
+
},
|
|
2343
|
+
exitCode: 0
|
|
2344
|
+
};
|
|
2345
|
+
}
|
|
2346
|
+
function field(record, name, label = name) {
|
|
2347
|
+
const value = record[name];
|
|
2348
|
+
if (typeof value !== "string" || !value) throw new CliError(`Server ${label} is invalid.`, {
|
|
2349
|
+
exitCode: 6,
|
|
2350
|
+
code: "server-response-invalid"
|
|
2351
|
+
});
|
|
2352
|
+
return value;
|
|
2353
|
+
}
|
|
2354
|
+
function terminalSummary(value, envelope) {
|
|
2355
|
+
return object(object(value, `${envelope} response`)[envelope], envelope);
|
|
2356
|
+
}
|
|
2357
|
+
async function watchResource(options) {
|
|
2358
|
+
for (;;) {
|
|
2359
|
+
const value = await options.client.request(options.path);
|
|
2360
|
+
const resource = terminalSummary(value, options.envelope);
|
|
2361
|
+
const state = typeof resource.state === "string" ? resource.state : typeof resource.status === "string" ? resource.status : void 0;
|
|
2362
|
+
if (!state) throw new CliError(`${options.envelope} state is missing.`, { exitCode: 6 });
|
|
2363
|
+
if (options.terminal.has(state)) return {
|
|
2364
|
+
value,
|
|
2365
|
+
exitCode: options.success.has(state) ? 0 : 1
|
|
2366
|
+
};
|
|
2367
|
+
await options.sleep(options.intervalMs);
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
async function submissionWatch(client, id, intervalMs, sleep) {
|
|
2371
|
+
let cursor = 0;
|
|
2372
|
+
for (;;) {
|
|
2373
|
+
const value = object(await client.request(`/api/submissions/${id}/events?after=${cursor}`), "submission events");
|
|
2374
|
+
if (Number.isSafeInteger(value.nextCursor) && value.nextCursor >= cursor) cursor = value.nextCursor;
|
|
2375
|
+
const summary = object(value.summary, "submission summary");
|
|
2376
|
+
const state = field(summary, "state", "submission state");
|
|
2377
|
+
if (TERMINAL_SUBMISSION_STATES.has(state)) return {
|
|
2378
|
+
value,
|
|
2379
|
+
exitCode: state === "completed" && summary.verdict === "accepted" ? 0 : 1
|
|
2380
|
+
};
|
|
2381
|
+
await sleep(intervalMs);
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
async function retryTurnstile(action, dependencies, origin) {
|
|
2385
|
+
let openedUrl;
|
|
2386
|
+
for (let attempt = 0; attempt < 150; attempt += 1) try {
|
|
2387
|
+
return await action();
|
|
2388
|
+
} catch (error) {
|
|
2389
|
+
if (!(error instanceof ApiError) || error.code !== "turnstile-required") throw error;
|
|
2390
|
+
const verificationUrl = turnstileVerificationUrl(origin, error.details);
|
|
2391
|
+
if (openedUrl !== void 0 && openedUrl !== verificationUrl) throw new CliError("Browser verification binding changed while retrying the same submission.", {
|
|
2392
|
+
exitCode: 6,
|
|
2393
|
+
code: "verification-url-invalid"
|
|
2394
|
+
});
|
|
2395
|
+
if (openedUrl === void 0) {
|
|
2396
|
+
openedUrl = verificationUrl;
|
|
2397
|
+
dependencies.onNotice(`Complete the browser verification: ${verificationUrl}`);
|
|
2398
|
+
await dependencies.opener.open(verificationUrl);
|
|
2399
|
+
}
|
|
2400
|
+
await dependencies.sleep(2e3);
|
|
2401
|
+
}
|
|
2402
|
+
throw new CliError("Browser verification did not complete before the CLI deadline.", {
|
|
2403
|
+
exitCode: 3,
|
|
2404
|
+
code: "turnstile-expired"
|
|
2405
|
+
});
|
|
2406
|
+
}
|
|
2407
|
+
async function exactPublicProblem(client, problemVersionId, contestId) {
|
|
2408
|
+
const metadataPath = `/api/problems/${problemVersionId}${query({ contestId })}`;
|
|
2409
|
+
const metadata = object(await client.request(metadataPath, { authenticated: contestId ? true : "optional" }), "problem metadata");
|
|
2410
|
+
if (metadata.problemVersionId !== problemVersionId || metadata.schema !== "wasm-oj-platform/problem-content-pointer/v2") throw new CliError("Problem metadata identity is invalid.", { exitCode: 4 });
|
|
2411
|
+
const content = object(metadata.content, "problem content pointer");
|
|
2412
|
+
const contentUrl = field(content, "url", "problem content URL");
|
|
2413
|
+
const contentSha256 = field(content, "sha256", "problem content digest");
|
|
2414
|
+
if (!/^[0-9a-f]{64}$/.test(contentSha256) || !Number.isSafeInteger(content.bytes) || content.bytes < 1) throw new CliError("Problem content pointer is invalid.", { exitCode: 4 });
|
|
2415
|
+
const bytes = await client.requestBytes(contentUrl, { authenticated: contestId ? true : "optional" });
|
|
2416
|
+
if (bytes.byteLength !== content.bytes || createHash("sha256").update(bytes).digest("hex") !== contentSha256) throw new CliError("Downloaded problem bytes disagree with their immutable pointer.", {
|
|
2417
|
+
exitCode: 4,
|
|
2418
|
+
code: "problem-content-integrity"
|
|
2419
|
+
});
|
|
2420
|
+
return {
|
|
2421
|
+
metadata,
|
|
2422
|
+
content,
|
|
2423
|
+
bytes,
|
|
2424
|
+
problem: parsePublicProblem(bytes)
|
|
2425
|
+
};
|
|
2426
|
+
}
|
|
2427
|
+
function localizedList(value, locale) {
|
|
2428
|
+
const response = object(value, "problem list");
|
|
2429
|
+
const collections = array(response.collections, "problem collections").map((collectionValue) => {
|
|
2430
|
+
const collection = object(collectionValue, "problem collection");
|
|
2431
|
+
const problems = array(collection.problems, "collection problems").map((problemValue) => {
|
|
2432
|
+
const problem = object(problemValue, "problem summary");
|
|
2433
|
+
const title = object(problem.title, "problem title")[locale];
|
|
2434
|
+
const track = problem.track === null ? null : object(problem.track, "problem track")[locale];
|
|
2435
|
+
if (typeof title !== "string" || track !== null && typeof track !== "string") throw new CliError("Problem localization is invalid.", { exitCode: 6 });
|
|
2436
|
+
return {
|
|
2437
|
+
...problem,
|
|
2438
|
+
title,
|
|
2439
|
+
track
|
|
2440
|
+
};
|
|
2441
|
+
});
|
|
2442
|
+
return {
|
|
2443
|
+
...collection,
|
|
2444
|
+
problems
|
|
2445
|
+
};
|
|
2446
|
+
});
|
|
2447
|
+
return {
|
|
2448
|
+
...response,
|
|
2449
|
+
locale,
|
|
2450
|
+
collections
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2453
|
+
async function problemPull(command, dependencies, client) {
|
|
2454
|
+
const [problemVersionId, directory = "."] = exactPositionals(command, 1, 2);
|
|
2455
|
+
uuid(problemVersionId, "problem-version-id");
|
|
2456
|
+
const contestId = stringOption(command, "contest");
|
|
2457
|
+
if (contestId) uuid(contestId, "contest");
|
|
2458
|
+
const locale = localeOption(command);
|
|
2459
|
+
const language = stringOption(command, "language");
|
|
2460
|
+
if (!language || !LANGUAGES.includes(language)) throw usageError("problem pull requires a supported --language.");
|
|
2461
|
+
const { metadata, content, bytes, problem } = await exactPublicProblem(client, problemVersionId, contestId);
|
|
2462
|
+
const catalogPublicationId = serverUuid(metadata, "catalogPublicationId", "catalogPublicationId");
|
|
2463
|
+
const contentUrl = field(content, "url", "problem content URL");
|
|
2464
|
+
const contentSha256 = field(content, "sha256", "problem content digest");
|
|
2465
|
+
const profile = object(object(metadata.allowedProfiles, "allowedProfiles")[language], `allowedProfiles.${language}`);
|
|
2466
|
+
if (profile.target !== "wasip1" && profile.target !== "wasix" || profile.optimization !== "debug" && profile.optimization !== "release") throw usageError(`Language '${language}' is not available for this problem version.`);
|
|
2467
|
+
const template = problem.starterTemplates[language];
|
|
2468
|
+
if (!template) throw usageError(`Problem has no starter template for '${language}'.`);
|
|
2469
|
+
const root = path.resolve(dependencies.cwd, directory);
|
|
2470
|
+
await assertSafeFileDestinations(root, [
|
|
2471
|
+
"woj.json",
|
|
2472
|
+
"problem.json",
|
|
2473
|
+
...Object.keys(template.files)
|
|
2474
|
+
], booleanOption(command, "force"));
|
|
2475
|
+
await mkdir(root, { recursive: true });
|
|
2476
|
+
for (const [relative, source] of Object.entries(template.files)) {
|
|
2477
|
+
const file = path.join(root, ...relative.split("/"));
|
|
2478
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
2479
|
+
await atomicWriteFile(file, source);
|
|
2480
|
+
}
|
|
2481
|
+
await atomicWriteFile(path.join(root, "problem.json"), bytes);
|
|
2482
|
+
await writeWorkspace(root, {
|
|
2483
|
+
schema: WOJ_WORKSPACE_SCHEMA,
|
|
2484
|
+
name: problem.id,
|
|
2485
|
+
language,
|
|
2486
|
+
target: profile.target,
|
|
2487
|
+
optimization: profile.optimization,
|
|
2488
|
+
entry: template.entry,
|
|
2489
|
+
sources: Object.keys(template.files).sort(),
|
|
2490
|
+
problem: {
|
|
2491
|
+
problemVersionId,
|
|
2492
|
+
catalogPublicationId,
|
|
2493
|
+
serverOrigin: client.origin,
|
|
2494
|
+
contentUrl,
|
|
2495
|
+
contentSha256,
|
|
2496
|
+
contentFile: "problem.json",
|
|
2497
|
+
locale,
|
|
2498
|
+
...contestId ? { contestId } : {}
|
|
2499
|
+
}
|
|
2500
|
+
});
|
|
2501
|
+
return {
|
|
2502
|
+
value: {
|
|
2503
|
+
directory: root,
|
|
2504
|
+
problemVersionId,
|
|
2505
|
+
catalogPublicationId,
|
|
2506
|
+
contentSha256,
|
|
2507
|
+
language,
|
|
2508
|
+
target: profile.target,
|
|
2509
|
+
optimization: profile.optimization
|
|
2510
|
+
},
|
|
2511
|
+
exitCode: 0
|
|
2512
|
+
};
|
|
2513
|
+
}
|
|
2514
|
+
async function submit(command, dependencies, client) {
|
|
2515
|
+
exactPositionals(command, 0);
|
|
2516
|
+
const workspace = await readWorkspace(dependencies.cwd);
|
|
2517
|
+
if (!workspace.problem) throw usageError("Official Submit requires a workspace created by 'woj problem pull'.");
|
|
2518
|
+
if (workspace.problem.serverOrigin !== client.origin) throw new CliError(`Pinned problem belongs to ${workspace.problem.serverOrigin}; refusing to submit it to ${client.origin}.`, {
|
|
2519
|
+
exitCode: 5,
|
|
2520
|
+
code: "server-origin-mismatch"
|
|
2521
|
+
});
|
|
2522
|
+
const language = stringOption(command, "language") ?? workspace.language;
|
|
2523
|
+
const target = stringOption(command, "target") ?? workspace.target;
|
|
2524
|
+
const optimization = stringOption(command, "optimization") ?? workspace.optimization;
|
|
2525
|
+
const entry = stringOption(command, "entry") ?? workspace.entry;
|
|
2526
|
+
if (!LANGUAGES.includes(language)) throw usageError("--language must name a supported language.");
|
|
2527
|
+
if (target !== "wasip1" && target !== "wasix") throw usageError("--target must be wasip1 or wasix.");
|
|
2528
|
+
if (optimization !== "debug" && optimization !== "release") throw usageError("--optimization must be debug or release.");
|
|
2529
|
+
normalizedRelativePath(entry, "--entry");
|
|
2530
|
+
if (!workspace.sources.includes(entry)) throw usageError("--entry must identify a source pinned in woj.json.");
|
|
2531
|
+
if (language !== workspace.language || target !== workspace.target || optimization !== workspace.optimization) throw new CliError("Official Submit must use the exact compile profile pinned by problem pull.", {
|
|
2532
|
+
exitCode: 5,
|
|
2533
|
+
code: "profile-pin-mismatch"
|
|
2534
|
+
});
|
|
2535
|
+
const contestId = stringOption(command, "contest") ?? workspace.problem.contestId;
|
|
2536
|
+
if (contestId !== void 0) uuid(contestId, "contest");
|
|
2537
|
+
if (contestId !== workspace.problem.contestId) throw new CliError("Official Submit contest context must match the pinned workspace.", { exitCode: 5 });
|
|
2538
|
+
const sources = await readWorkspaceSources(dependencies.cwd, workspace);
|
|
2539
|
+
const body = {
|
|
2540
|
+
problemVersionId: workspace.problem.problemVersionId,
|
|
2541
|
+
...contestId ? { contestId } : {},
|
|
2542
|
+
language,
|
|
2543
|
+
target,
|
|
2544
|
+
optimization,
|
|
2545
|
+
entry,
|
|
2546
|
+
sourceFiles: Object.entries(sources).sort(([left], [right]) => left.localeCompare(right)).map(([filePath, content]) => ({
|
|
2547
|
+
path: filePath,
|
|
2548
|
+
encoding: "utf8",
|
|
2549
|
+
content
|
|
2550
|
+
})),
|
|
2551
|
+
idempotencyKey: `woj-submit-${randomUUID()}`
|
|
2552
|
+
};
|
|
2553
|
+
const created = await retryTurnstile(() => client.request("/api/submissions", {
|
|
2554
|
+
method: "POST",
|
|
2555
|
+
body
|
|
2556
|
+
}), dependencies, client.origin);
|
|
2557
|
+
if (!booleanOption(command, "wait")) return {
|
|
2558
|
+
value: created,
|
|
2559
|
+
exitCode: 0
|
|
2560
|
+
};
|
|
2561
|
+
return submissionWatch(client, serverUuid(object(created, "submission creation"), "submissionId"), 2e3, dependencies.sleep);
|
|
2562
|
+
}
|
|
2563
|
+
async function currentContestDraft(client, id) {
|
|
2564
|
+
const value = object(await client.request(`/api/organizer/contests/${id}`), "Organizer contest");
|
|
2565
|
+
const contest = object(value.contest, "contest");
|
|
2566
|
+
const problems = array(value.problems, "contest problems");
|
|
2567
|
+
const freezeAt = contest.freezeAt;
|
|
2568
|
+
if (freezeAt !== null && freezeAt !== void 0 && typeof freezeAt !== "string") throw new CliError("Organizer contest freezeAt has an invalid shape.", {
|
|
2569
|
+
exitCode: 6,
|
|
2570
|
+
code: "server-response-invalid"
|
|
2571
|
+
});
|
|
2572
|
+
return {
|
|
2573
|
+
current: value,
|
|
2574
|
+
body: {
|
|
2575
|
+
title: contest.title,
|
|
2576
|
+
description: contest.description,
|
|
2577
|
+
accessMode: contest.accessMode,
|
|
2578
|
+
startsAt: contest.startsAt,
|
|
2579
|
+
endsAt: contest.endsAt,
|
|
2580
|
+
...typeof freezeAt === "string" ? { freezeAt } : {},
|
|
2581
|
+
problemVersionIds: problems.map((problem) => field(object(problem, "contest problem"), "problemVersionId"))
|
|
2582
|
+
}
|
|
2583
|
+
};
|
|
2584
|
+
}
|
|
2585
|
+
async function contestBody(command, cwd, base = {}) {
|
|
2586
|
+
const body = { ...base };
|
|
2587
|
+
for (const [option, key] of Object.entries({
|
|
2588
|
+
title: "title",
|
|
2589
|
+
description: "description",
|
|
2590
|
+
access: "accessMode",
|
|
2591
|
+
starts: "startsAt",
|
|
2592
|
+
ends: "endsAt",
|
|
2593
|
+
freeze: "freezeAt"
|
|
2594
|
+
})) {
|
|
2595
|
+
const value = stringOption(command, option);
|
|
2596
|
+
if (value !== void 0) body[key] = value;
|
|
2597
|
+
}
|
|
2598
|
+
const inviteCode = await readProtectedTextFile(cwd, stringOption(command, "invite-code-file"), "--invite-code-file");
|
|
2599
|
+
if (inviteCode !== void 0) body.inviteCode = inviteCode;
|
|
2600
|
+
const problems = repeatableOption(command, "problem");
|
|
2601
|
+
if (problems.length) body.problemVersionIds = problems.map((id) => uuid(id, "problem"));
|
|
2602
|
+
if (typeof body.title === "string" && (!body.title.trim() || body.title.length > 120)) throw usageError("--title must contain 1–120 characters.");
|
|
2603
|
+
if (typeof body.description === "string" && body.description.length > 1e4) throw usageError("--description must contain at most 10,000 characters.");
|
|
2604
|
+
if (Array.isArray(body.problemVersionIds) && (body.problemVersionIds.length < 1 || body.problemVersionIds.length > 100 || new Set(body.problemVersionIds).size !== body.problemVersionIds.length)) throw usageError("Contest problem IDs must be unique and contain 1–100 values.");
|
|
2605
|
+
if (body.accessMode !== "public" && body.accessMode !== "invite") throw usageError("--access must be public or invite.");
|
|
2606
|
+
const invite = body.inviteCode;
|
|
2607
|
+
if (invite !== void 0 && (typeof invite !== "string" || invite.length < 16 || invite.length > 128)) throw usageError("--invite-code-file must contain 16–128 characters.");
|
|
2608
|
+
if (body.accessMode === "public" && invite !== void 0) throw usageError("--invite-code-file cannot be used with --access public.");
|
|
2609
|
+
const startsAt = canonicalTimestamp(typeof body.startsAt === "string" ? body.startsAt : void 0, "starts");
|
|
2610
|
+
const endsAt = canonicalTimestamp(typeof body.endsAt === "string" ? body.endsAt : void 0, "ends");
|
|
2611
|
+
const freezeAt = canonicalTimestamp(typeof body.freezeAt === "string" ? body.freezeAt : void 0, "freeze");
|
|
2612
|
+
if (startsAt !== void 0 && endsAt !== void 0 && (endsAt <= startsAt || freezeAt !== void 0 && (freezeAt <= startsAt || freezeAt >= endsAt))) throw usageError("Contest timestamps must satisfy starts < freeze < ends (freeze is optional).");
|
|
2613
|
+
return body;
|
|
2614
|
+
}
|
|
2615
|
+
async function createCollectionSkeleton(root, force) {
|
|
2616
|
+
const file = path.join(root, "collection", "source.json");
|
|
2617
|
+
await assertSafeFileDestinations(root, ["collection/source.json"], force);
|
|
2618
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
2619
|
+
await atomicWriteFile(file, `${JSON.stringify({
|
|
2620
|
+
schema: "wasm-oj-browser-collection-source-v1",
|
|
2621
|
+
localization: {
|
|
2622
|
+
defaultLocale: "zh-TW",
|
|
2623
|
+
supportedLocales: ["zh-TW", "en"]
|
|
2624
|
+
},
|
|
2625
|
+
problems: []
|
|
2626
|
+
}, null, 2)}\n`);
|
|
2627
|
+
return {
|
|
2628
|
+
value: {
|
|
2629
|
+
directory: root,
|
|
2630
|
+
source: file
|
|
2631
|
+
},
|
|
2632
|
+
exitCode: 0
|
|
2633
|
+
};
|
|
2634
|
+
}
|
|
2635
|
+
async function watchLocalWorkspace(root, execute, onNotice) {
|
|
2636
|
+
const watched = ["woj.json", ...(await readWorkspace(root)).sources].map((relative) => path.join(root, ...relative.split("/")));
|
|
2637
|
+
let latest = await execute();
|
|
2638
|
+
onNotice(JSON.stringify(latest.value));
|
|
2639
|
+
let running = false;
|
|
2640
|
+
let queued = false;
|
|
2641
|
+
const rerun = async () => {
|
|
2642
|
+
if (running) {
|
|
2643
|
+
queued = true;
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2646
|
+
running = true;
|
|
2647
|
+
try {
|
|
2648
|
+
do {
|
|
2649
|
+
queued = false;
|
|
2650
|
+
try {
|
|
2651
|
+
latest = await execute();
|
|
2652
|
+
onNotice(JSON.stringify(latest.value));
|
|
2653
|
+
} catch (error) {
|
|
2654
|
+
onNotice(`watch error: ${error instanceof Error ? error.message : String(error)}`);
|
|
2655
|
+
}
|
|
2656
|
+
} while (queued);
|
|
2657
|
+
} finally {
|
|
2658
|
+
running = false;
|
|
2659
|
+
}
|
|
2660
|
+
};
|
|
2661
|
+
const watchers = watched.map((file) => watch(file, { persistent: true }, () => {
|
|
2662
|
+
rerun();
|
|
2663
|
+
}));
|
|
2664
|
+
await new Promise((resolve) => {
|
|
2665
|
+
const stop = () => {
|
|
2666
|
+
for (const watcher of watchers) watcher.close();
|
|
2667
|
+
process.off("SIGINT", stop);
|
|
2668
|
+
process.off("SIGTERM", stop);
|
|
2669
|
+
resolve();
|
|
2670
|
+
};
|
|
2671
|
+
process.once("SIGINT", stop);
|
|
2672
|
+
process.once("SIGTERM", stop);
|
|
2673
|
+
});
|
|
2674
|
+
return latest;
|
|
2675
|
+
}
|
|
2676
|
+
function completion(shell) {
|
|
2677
|
+
const commands = [
|
|
2678
|
+
"auth",
|
|
2679
|
+
"init",
|
|
2680
|
+
"build",
|
|
2681
|
+
"run",
|
|
2682
|
+
"test",
|
|
2683
|
+
"bench",
|
|
2684
|
+
"watch",
|
|
2685
|
+
"problem",
|
|
2686
|
+
"submit",
|
|
2687
|
+
"submission",
|
|
2688
|
+
"contest",
|
|
2689
|
+
"performance",
|
|
2690
|
+
"judge",
|
|
2691
|
+
"toolchain",
|
|
2692
|
+
"organizer",
|
|
2693
|
+
"config",
|
|
2694
|
+
"cache",
|
|
2695
|
+
"doctor",
|
|
2696
|
+
"completion",
|
|
2697
|
+
"version"
|
|
2698
|
+
].join(" ");
|
|
2699
|
+
if (shell === "bash") return `complete -W '${commands}' woj`;
|
|
2700
|
+
if (shell === "zsh") return `#compdef woj\n_arguments '1:command:(${commands})'`;
|
|
2701
|
+
if (shell === "fish") return commands.split(" ").map((command) => `complete -c woj -n '__fish_use_subcommand' -a ${command}`).join("\n");
|
|
2702
|
+
throw usageError("completion shell must be bash, zsh, or fish.");
|
|
2703
|
+
}
|
|
2704
|
+
async function dispatchCommand(command, dependencies) {
|
|
2705
|
+
const key = commandKey(command.spec.path);
|
|
2706
|
+
const config = await dependencies.configStore.read();
|
|
2707
|
+
if (key === "version") {
|
|
2708
|
+
exactPositionals(command, 0);
|
|
2709
|
+
return {
|
|
2710
|
+
value: { version: WOJ_CLI_VERSION },
|
|
2711
|
+
exitCode: 0
|
|
2712
|
+
};
|
|
2713
|
+
}
|
|
2714
|
+
if (key === "config list") {
|
|
2715
|
+
exactPositionals(command, 0);
|
|
2716
|
+
return {
|
|
2717
|
+
value: config,
|
|
2718
|
+
exitCode: 0
|
|
2719
|
+
};
|
|
2720
|
+
}
|
|
2721
|
+
if (key === "config get") {
|
|
2722
|
+
const [name] = exactPositionals(command, 1);
|
|
2723
|
+
if (!isConfigKey(name)) throw usageError(`Unknown config key '${name}'. Valid keys: ${CONFIG_KEYS.join(", ")}.`);
|
|
2724
|
+
return {
|
|
2725
|
+
value: {
|
|
2726
|
+
key: name,
|
|
2727
|
+
value: config[name] ?? null
|
|
2728
|
+
},
|
|
2729
|
+
exitCode: 0
|
|
2730
|
+
};
|
|
2731
|
+
}
|
|
2732
|
+
if (key === "config set") {
|
|
2733
|
+
const [name, value] = exactPositionals(command, 2);
|
|
2734
|
+
if (!isConfigKey(name)) throw usageError(`Unknown config key '${name}'. Valid keys: ${CONFIG_KEYS.join(", ")}.`);
|
|
2735
|
+
const next = {
|
|
2736
|
+
...config,
|
|
2737
|
+
[name]: validateConfigValue(name, value)
|
|
2738
|
+
};
|
|
2739
|
+
await dependencies.configStore.write(next);
|
|
2740
|
+
return {
|
|
2741
|
+
value: {
|
|
2742
|
+
key: name,
|
|
2743
|
+
value: next[name]
|
|
2744
|
+
},
|
|
2745
|
+
exitCode: 0
|
|
2746
|
+
};
|
|
2747
|
+
}
|
|
2748
|
+
if (key === "config unset") {
|
|
2749
|
+
const [name] = exactPositionals(command, 1);
|
|
2750
|
+
if (!isConfigKey(name)) throw usageError(`Unknown config key '${name}'. Valid keys: ${CONFIG_KEYS.join(", ")}.`);
|
|
2751
|
+
const next = { ...config };
|
|
2752
|
+
delete next[name];
|
|
2753
|
+
await dependencies.configStore.write(next);
|
|
2754
|
+
return {
|
|
2755
|
+
value: {
|
|
2756
|
+
key: name,
|
|
2757
|
+
removed: true
|
|
2758
|
+
},
|
|
2759
|
+
exitCode: 0
|
|
2760
|
+
};
|
|
2761
|
+
}
|
|
2762
|
+
if (key === "init") {
|
|
2763
|
+
const [directory = "."] = exactPositionals(command, 0, 1);
|
|
2764
|
+
return {
|
|
2765
|
+
value: await createWorkspace(path.resolve(dependencies.cwd, directory), {
|
|
2766
|
+
name: stringOption(command, "name"),
|
|
2767
|
+
language: stringOption(command, "language"),
|
|
2768
|
+
target: stringOption(command, "target"),
|
|
2769
|
+
optimization: stringOption(command, "optimization"),
|
|
2770
|
+
entry: stringOption(command, "entry"),
|
|
2771
|
+
force: booleanOption(command, "force")
|
|
2772
|
+
}),
|
|
2773
|
+
exitCode: 0
|
|
2774
|
+
};
|
|
2775
|
+
}
|
|
2776
|
+
if (key === "build") {
|
|
2777
|
+
exactPositionals(command, 0);
|
|
2778
|
+
return localOutcome(await dependencies.local.build(dependencies.cwd, config));
|
|
2779
|
+
}
|
|
2780
|
+
if (key === "run") {
|
|
2781
|
+
exactPositionals(command, 0);
|
|
2782
|
+
return localOutcome(await dependencies.local.run(dependencies.cwd, config, {
|
|
2783
|
+
stdin: await runInput(command, dependencies.cwd),
|
|
2784
|
+
args: repeatableOption(command, "arg")
|
|
2785
|
+
}));
|
|
2786
|
+
}
|
|
2787
|
+
if (key === "test") {
|
|
2788
|
+
exactPositionals(command, 0);
|
|
2789
|
+
return localOutcome(await dependencies.local.test(dependencies.cwd, config, { cases: repeatableOption(command, "case") }));
|
|
2790
|
+
}
|
|
2791
|
+
if (key === "bench") {
|
|
2792
|
+
exactPositionals(command, 0);
|
|
2793
|
+
return localOutcome(await dependencies.local.bench(dependencies.cwd, config, {
|
|
2794
|
+
stdin: stringOption(command, "stdin"),
|
|
2795
|
+
iterations: positiveInteger(stringOption(command, "iterations"), 10, 1e4, "iterations")
|
|
2796
|
+
}));
|
|
2797
|
+
}
|
|
2798
|
+
if (key === "watch") {
|
|
2799
|
+
exactPositionals(command, 0);
|
|
2800
|
+
const action = stringOption(command, "command") ?? "test";
|
|
2801
|
+
if (!(/* @__PURE__ */ new Set([
|
|
2802
|
+
"build",
|
|
2803
|
+
"run",
|
|
2804
|
+
"test"
|
|
2805
|
+
])).has(action)) throw usageError("--command must be build, run, or test.");
|
|
2806
|
+
const execute = action === "build" ? () => dependencies.local.build(dependencies.cwd, config) : action === "run" ? () => dependencies.local.run(dependencies.cwd, config, { args: [] }) : () => dependencies.local.test(dependencies.cwd, config, { cases: [] });
|
|
2807
|
+
return localOutcome(await watchLocalWorkspace(dependencies.cwd, execute, dependencies.onNotice));
|
|
2808
|
+
}
|
|
2809
|
+
if (key === "judge inspect") {
|
|
2810
|
+
const [file] = exactPositionals(command, 1);
|
|
2811
|
+
return localOutcome(await dependencies.local.inspectJudge(path.resolve(dependencies.cwd, file)));
|
|
2812
|
+
}
|
|
2813
|
+
if (key === "judge verify") {
|
|
2814
|
+
const [file] = exactPositionals(command, 1);
|
|
2815
|
+
return localOutcome(await dependencies.local.verifyJudge(path.resolve(dependencies.cwd, file), {
|
|
2816
|
+
sha256: stringOption(command, "sha256"),
|
|
2817
|
+
bytes: stringOption(command, "bytes") === void 0 ? void 0 : positiveInteger(stringOption(command, "bytes"), 1, 33554432, "bytes")
|
|
2818
|
+
}));
|
|
2819
|
+
}
|
|
2820
|
+
if (key === "judge execute") {
|
|
2821
|
+
const [file] = exactPositionals(command, 1);
|
|
2822
|
+
const source = stringOption(command, "source");
|
|
2823
|
+
if (!source) throw usageError("judge execute requires --source <woj-workspace-directory>.");
|
|
2824
|
+
if (!booleanOption(command, "all")) throw usageError("judge execute requires --all to acknowledge that every packaged case may be exposed.");
|
|
2825
|
+
return localOutcome(await dependencies.local.executeJudge(path.resolve(dependencies.cwd, source), config, path.resolve(dependencies.cwd, file)));
|
|
2826
|
+
}
|
|
2827
|
+
if (key === "toolchain list") {
|
|
2828
|
+
exactPositionals(command, 0);
|
|
2829
|
+
return localOutcome(await dependencies.local.toolchainList(config));
|
|
2830
|
+
}
|
|
2831
|
+
if (key === "toolchain info") {
|
|
2832
|
+
const [id] = exactPositionals(command, 1);
|
|
2833
|
+
return localOutcome(await dependencies.local.toolchainInfo(config, id));
|
|
2834
|
+
}
|
|
2835
|
+
if (key === "toolchain verify") {
|
|
2836
|
+
const [id] = exactPositionals(command, 0, 1);
|
|
2837
|
+
return localOutcome(await dependencies.local.toolchainVerify(config, id));
|
|
2838
|
+
}
|
|
2839
|
+
if (key === "toolchain prune") {
|
|
2840
|
+
exactPositionals(command, 0);
|
|
2841
|
+
if (!booleanOption(command, "yes")) throw usageError("toolchain prune requires --yes.");
|
|
2842
|
+
return localOutcome(await dependencies.local.toolchainPrune(config));
|
|
2843
|
+
}
|
|
2844
|
+
if (key === "cache status") {
|
|
2845
|
+
exactPositionals(command, 0);
|
|
2846
|
+
return localOutcome(await dependencies.local.cacheStatus(config));
|
|
2847
|
+
}
|
|
2848
|
+
if (key === "cache prune") {
|
|
2849
|
+
exactPositionals(command, 0);
|
|
2850
|
+
if (!booleanOption(command, "yes")) throw usageError("cache prune requires --yes.");
|
|
2851
|
+
return localOutcome(await dependencies.local.cachePrune(config));
|
|
2852
|
+
}
|
|
2853
|
+
if (key === "cache clear") {
|
|
2854
|
+
exactPositionals(command, 0);
|
|
2855
|
+
if (!booleanOption(command, "yes")) throw usageError("cache clear requires --yes.");
|
|
2856
|
+
return localOutcome(await dependencies.local.cacheClear(config));
|
|
2857
|
+
}
|
|
2858
|
+
if (key === "doctor") {
|
|
2859
|
+
exactPositionals(command, 0);
|
|
2860
|
+
const result = await dependencies.local.doctor(dependencies.cwd, config);
|
|
2861
|
+
return {
|
|
2862
|
+
value: result.value,
|
|
2863
|
+
exitCode: result.successful ? 0 : 7
|
|
2864
|
+
};
|
|
2865
|
+
}
|
|
2866
|
+
if (key === "completion") {
|
|
2867
|
+
const [shell] = exactPositionals(command, 1);
|
|
2868
|
+
return {
|
|
2869
|
+
value: completion(shell),
|
|
2870
|
+
exitCode: 0
|
|
2871
|
+
};
|
|
2872
|
+
}
|
|
2873
|
+
if (key === "organizer collection init") {
|
|
2874
|
+
const [directory = "."] = exactPositionals(command, 0, 1);
|
|
2875
|
+
return createCollectionSkeleton(path.resolve(dependencies.cwd, directory), booleanOption(command, "force"));
|
|
2876
|
+
}
|
|
2877
|
+
if (key === "organizer collection build" || key === "organizer collection verify") {
|
|
2878
|
+
const [directory = "."] = exactPositionals(command, 0, 1);
|
|
2879
|
+
const arguments_ = [key.endsWith("build") ? "build" : "verify", path.resolve(dependencies.cwd, directory)];
|
|
2880
|
+
for (const name of [
|
|
2881
|
+
"index",
|
|
2882
|
+
"source",
|
|
2883
|
+
"managed",
|
|
2884
|
+
"managed-source"
|
|
2885
|
+
]) {
|
|
2886
|
+
const value = stringOption(command, name);
|
|
2887
|
+
if (value !== void 0) arguments_.push(`--${name}`, normalizedRelativePath(value, `--${name}`));
|
|
2888
|
+
}
|
|
2889
|
+
if (key.endsWith("build") && stringOption(command, "managed") !== void 0 && stringOption(command, "managed-source") === void 0) throw usageError("--managed requires --managed-source when building.");
|
|
2890
|
+
try {
|
|
2891
|
+
await dependencies.collectionCli(arguments_);
|
|
2892
|
+
} catch (error) {
|
|
2893
|
+
throw new CliError(error instanceof Error ? error.message : "Collection operation failed.", {
|
|
2894
|
+
exitCode: 4,
|
|
2895
|
+
code: "collection-invalid",
|
|
2896
|
+
cause: error
|
|
2897
|
+
});
|
|
2898
|
+
}
|
|
2899
|
+
return {
|
|
2900
|
+
value: {
|
|
2901
|
+
command: arguments_[0],
|
|
2902
|
+
directory: arguments_[1]
|
|
2903
|
+
},
|
|
2904
|
+
exitCode: 0
|
|
2905
|
+
};
|
|
2906
|
+
}
|
|
2907
|
+
const { origin, client } = await configured(command, dependencies);
|
|
2908
|
+
if (key === "toolchain fetch") {
|
|
2909
|
+
const [id] = exactPositionals(command, 1);
|
|
2910
|
+
return localOutcome(await dependencies.local.toolchainFetch(config, origin, id));
|
|
2911
|
+
}
|
|
2912
|
+
if (key === "auth login") {
|
|
2913
|
+
exactPositionals(command, 0);
|
|
2914
|
+
const deviceName = stringOption(command, "device-name") ?? `${process.platform} woj`;
|
|
2915
|
+
if (!deviceName || deviceName !== deviceName.normalize("NFC") || deviceName !== deviceName.trim() || new TextEncoder().encode(deviceName).byteLength > 80 || /[\p{Cc}\p{Cf}\p{Cs}]/u.test(deviceName)) throw usageError("--device-name must be trimmed NFC text containing 1–80 visible UTF-8 bytes.");
|
|
2916
|
+
return {
|
|
2917
|
+
value: await deviceLogin(client, dependencies.tokenStore, dependencies.opener, {
|
|
2918
|
+
deviceName,
|
|
2919
|
+
onVerification: dependencies.onNotice,
|
|
2920
|
+
sleep: dependencies.sleep
|
|
2921
|
+
}),
|
|
2922
|
+
exitCode: 0
|
|
2923
|
+
};
|
|
2924
|
+
}
|
|
2925
|
+
if (key === "auth logout") {
|
|
2926
|
+
exactPositionals(command, 0);
|
|
2927
|
+
return logout(origin, client, dependencies.tokenStore);
|
|
2928
|
+
}
|
|
2929
|
+
if (key === "auth status") {
|
|
2930
|
+
exactPositionals(command, 0);
|
|
2931
|
+
return {
|
|
2932
|
+
value: {
|
|
2933
|
+
server: origin,
|
|
2934
|
+
session: await client.request("/api/auth/session", { authenticated: "optional" })
|
|
2935
|
+
},
|
|
2936
|
+
exitCode: 0
|
|
2937
|
+
};
|
|
2938
|
+
}
|
|
2939
|
+
if (key === "problem list") {
|
|
2940
|
+
exactPositionals(command, 0);
|
|
2941
|
+
const locale = localeOption(command);
|
|
2942
|
+
return {
|
|
2943
|
+
value: localizedList(await client.request("/api/problems", { authenticated: "optional" }), locale),
|
|
2944
|
+
exitCode: 0
|
|
2945
|
+
};
|
|
2946
|
+
}
|
|
2947
|
+
if (key === "problem show") {
|
|
2948
|
+
const [id] = exactPositionals(command, 1);
|
|
2949
|
+
uuid(id, "problem-version-id");
|
|
2950
|
+
const contestId = stringOption(command, "contest");
|
|
2951
|
+
if (contestId) uuid(contestId, "contest");
|
|
2952
|
+
const locale = localeOption(command);
|
|
2953
|
+
const downloaded = await exactPublicProblem(client, id, contestId);
|
|
2954
|
+
const problem = downloaded.problem;
|
|
2955
|
+
return {
|
|
2956
|
+
value: {
|
|
2957
|
+
problemVersionId: id,
|
|
2958
|
+
catalogPublicationId: downloaded.metadata.catalogPublicationId,
|
|
2959
|
+
content: downloaded.metadata.content,
|
|
2960
|
+
locale,
|
|
2961
|
+
title: problem.title[locale],
|
|
2962
|
+
track: problem.track[locale],
|
|
2963
|
+
difficulty: problem.difficulty,
|
|
2964
|
+
tags: problem.tags,
|
|
2965
|
+
statement: problem.statement[locale],
|
|
2966
|
+
editorial: problem.editorial[locale],
|
|
2967
|
+
samples: problem.judgeCases.filter((testCase) => testCase.kind === "sample"),
|
|
2968
|
+
availableLanguages: Object.keys(problem.starterTemplates).sort()
|
|
2969
|
+
},
|
|
2970
|
+
exitCode: 0
|
|
2971
|
+
};
|
|
2972
|
+
}
|
|
2973
|
+
if (key === "problem pull") return problemPull(command, dependencies, client);
|
|
2974
|
+
if (key === "submit") return submit(command, dependencies, client);
|
|
2975
|
+
if (key === "submission list") {
|
|
2976
|
+
exactPositionals(command, 0);
|
|
2977
|
+
const next = cursor(stringOption(command, "cursor"), ["before", "beforeId"], "submission cursor");
|
|
2978
|
+
if (next.before) {
|
|
2979
|
+
canonicalCursorTimestamp(next.before, "submission cursor");
|
|
2980
|
+
uuid(next.beforeId, "submission cursor beforeId");
|
|
2981
|
+
}
|
|
2982
|
+
return {
|
|
2983
|
+
value: await client.request(`/api/submissions${query({
|
|
2984
|
+
limit: boundedIntegerOption(command, "limit", 100),
|
|
2985
|
+
before: next.before,
|
|
2986
|
+
beforeId: next.beforeId
|
|
2987
|
+
})}`),
|
|
2988
|
+
exitCode: 0
|
|
2989
|
+
};
|
|
2990
|
+
}
|
|
2991
|
+
if (key === "submission show") {
|
|
2992
|
+
const [id] = exactPositionals(command, 1);
|
|
2993
|
+
uuid(id, "submission-id");
|
|
2994
|
+
return {
|
|
2995
|
+
value: await client.request(`/api/submissions/${id}`),
|
|
2996
|
+
exitCode: 0
|
|
2997
|
+
};
|
|
2998
|
+
}
|
|
2999
|
+
if (key === "submission watch") {
|
|
3000
|
+
const [id] = exactPositionals(command, 1);
|
|
3001
|
+
uuid(id, "submission-id");
|
|
3002
|
+
return submissionWatch(client, id, positiveInteger(stringOption(command, "interval"), 2, 30, "interval") * 1e3, dependencies.sleep);
|
|
3003
|
+
}
|
|
3004
|
+
if (key === "submission cancel") {
|
|
3005
|
+
const [id] = exactPositionals(command, 1);
|
|
3006
|
+
uuid(id, "submission-id");
|
|
3007
|
+
return {
|
|
3008
|
+
value: await client.request(`/api/submissions/${id}/cancel`, {
|
|
3009
|
+
method: "POST",
|
|
3010
|
+
body: {}
|
|
3011
|
+
}),
|
|
3012
|
+
exitCode: 0
|
|
3013
|
+
};
|
|
3014
|
+
}
|
|
3015
|
+
if (key === "submission source") {
|
|
3016
|
+
const [id] = exactPositionals(command, 1);
|
|
3017
|
+
uuid(id, "submission-id");
|
|
3018
|
+
return {
|
|
3019
|
+
value: await client.request(`/api/submissions/${id}/source`),
|
|
3020
|
+
exitCode: 0
|
|
3021
|
+
};
|
|
3022
|
+
}
|
|
3023
|
+
if (key === "submission policy") {
|
|
3024
|
+
const [id] = exactPositionals(command, 1);
|
|
3025
|
+
uuid(id, "submission-id");
|
|
3026
|
+
return {
|
|
3027
|
+
value: await client.request(`/api/submissions/${id}/policy-summary`),
|
|
3028
|
+
exitCode: 0
|
|
3029
|
+
};
|
|
3030
|
+
}
|
|
3031
|
+
if (key === "contest list") {
|
|
3032
|
+
exactPositionals(command, 0);
|
|
3033
|
+
return {
|
|
3034
|
+
value: await client.request("/api/contests", { authenticated: "optional" }),
|
|
3035
|
+
exitCode: 0
|
|
3036
|
+
};
|
|
3037
|
+
}
|
|
3038
|
+
if (key === "contest show" || key === "contest problems") {
|
|
3039
|
+
const [id] = exactPositionals(command, 1);
|
|
3040
|
+
uuid(id, "contest-id");
|
|
3041
|
+
const value = object(await client.request(`/api/contests/${id}`, { authenticated: "optional" }), "contest");
|
|
3042
|
+
return {
|
|
3043
|
+
value: key.endsWith("problems") ? { problems: array(value.problems, "contest problems") } : value,
|
|
3044
|
+
exitCode: 0
|
|
3045
|
+
};
|
|
3046
|
+
}
|
|
3047
|
+
if (key === "contest join") {
|
|
3048
|
+
const [id] = exactPositionals(command, 1);
|
|
3049
|
+
uuid(id, "contest-id");
|
|
3050
|
+
const code = await readProtectedTextFile(dependencies.cwd, stringOption(command, "code-file"), "--code-file");
|
|
3051
|
+
if (code !== void 0 && (code.length < 16 || code.length > 128)) throw usageError("--code-file must contain 16–128 characters.");
|
|
3052
|
+
return {
|
|
3053
|
+
value: await client.request(`/api/contests/${id}/join`, {
|
|
3054
|
+
method: "POST",
|
|
3055
|
+
body: { ...code ? { inviteCode: code } : {} }
|
|
3056
|
+
}),
|
|
3057
|
+
exitCode: 0
|
|
3058
|
+
};
|
|
3059
|
+
}
|
|
3060
|
+
if (key === "contest standings") {
|
|
3061
|
+
const [id] = exactPositionals(command, 1);
|
|
3062
|
+
uuid(id, "contest-id");
|
|
3063
|
+
return {
|
|
3064
|
+
value: await client.request(`/api/contests/${id}/leaderboard${query({ limit: boundedIntegerOption(command, "limit", 100) })}`, { authenticated: "optional" }),
|
|
3065
|
+
exitCode: 0
|
|
3066
|
+
};
|
|
3067
|
+
}
|
|
3068
|
+
if (key === "performance frontier" || key === "performance evolution") {
|
|
3069
|
+
const [id] = exactPositionals(command, 1);
|
|
3070
|
+
uuid(id, "problem-version-id");
|
|
3071
|
+
const language = stringOption(command, "language");
|
|
3072
|
+
if (language !== void 0 && !LANGUAGES.includes(language)) throw usageError("--language must name a supported language.");
|
|
3073
|
+
const contestId = stringOption(command, "contest");
|
|
3074
|
+
if (contestId !== void 0) uuid(contestId, "contest");
|
|
3075
|
+
const value = object(await client.request(`/api/problems/${id}/performance${query({
|
|
3076
|
+
language,
|
|
3077
|
+
contestId
|
|
3078
|
+
})}`, { authenticated: key.endsWith("frontier") ? "optional" : true }), "performance");
|
|
3079
|
+
return {
|
|
3080
|
+
value: key.endsWith("frontier") ? {
|
|
3081
|
+
context: value.context,
|
|
3082
|
+
frontier: value.frontier
|
|
3083
|
+
} : {
|
|
3084
|
+
context: value.context,
|
|
3085
|
+
myEvolution: value.myEvolution
|
|
3086
|
+
},
|
|
3087
|
+
exitCode: 0
|
|
3088
|
+
};
|
|
3089
|
+
}
|
|
3090
|
+
if (key === "organizer repo list" || key === "organizer repo show") {
|
|
3091
|
+
if (key.endsWith("list")) exactPositionals(command, 0);
|
|
3092
|
+
const id = key.endsWith("show") ? exactPositionals(command, 1)[0] : void 0;
|
|
3093
|
+
if (id !== void 0) positiveInteger(id, 1, Number.MAX_SAFE_INTEGER, "repository-id");
|
|
3094
|
+
const values = object(await client.request("/api/organizer/repositories"), "repositories");
|
|
3095
|
+
if (key.endsWith("list")) return {
|
|
3096
|
+
value: values,
|
|
3097
|
+
exitCode: 0
|
|
3098
|
+
};
|
|
3099
|
+
const repository = array(values.repositories, "repositories").find((item) => String(object(item, "repository").id ?? object(item, "repository").github_repository_id) === id);
|
|
3100
|
+
if (!repository) throw new CliError(`Repository '${id}' is not in your Organizer scope.`, { exitCode: 5 });
|
|
3101
|
+
return {
|
|
3102
|
+
value: { repository },
|
|
3103
|
+
exitCode: 0
|
|
3104
|
+
};
|
|
3105
|
+
}
|
|
3106
|
+
if (key === "organizer collection list") {
|
|
3107
|
+
exactPositionals(command, 0);
|
|
3108
|
+
return {
|
|
3109
|
+
value: await client.request("/api/organizer/collections"),
|
|
3110
|
+
exitCode: 0
|
|
3111
|
+
};
|
|
3112
|
+
}
|
|
3113
|
+
if (key === "organizer collection show") {
|
|
3114
|
+
const [id] = exactPositionals(command, 1);
|
|
3115
|
+
uuid(id, "collection-id");
|
|
3116
|
+
return {
|
|
3117
|
+
value: await client.request(`/api/organizer/collections/${id}`),
|
|
3118
|
+
exitCode: 0
|
|
3119
|
+
};
|
|
3120
|
+
}
|
|
3121
|
+
if (key === "organizer collection create") {
|
|
3122
|
+
exactPositionals(command, 0);
|
|
3123
|
+
const repository = stringOption(command, "repo");
|
|
3124
|
+
const index = stringOption(command, "index") ?? "collection/index.json";
|
|
3125
|
+
if (!repository) throw usageError("--repo must be a GitHub numeric repository ID.");
|
|
3126
|
+
const repositoryId = positiveInteger(repository, 1, Number.MAX_SAFE_INTEGER, "--repo");
|
|
3127
|
+
return {
|
|
3128
|
+
value: await client.request("/api/organizer/collections", {
|
|
3129
|
+
method: "POST",
|
|
3130
|
+
body: {
|
|
3131
|
+
githubRepositoryId: repositoryId,
|
|
3132
|
+
indexPath: normalizedRelativePath(index, "--index")
|
|
3133
|
+
}
|
|
3134
|
+
}),
|
|
3135
|
+
exitCode: 0
|
|
3136
|
+
};
|
|
3137
|
+
}
|
|
3138
|
+
if (key === "organizer collection validate") {
|
|
3139
|
+
const [id] = exactPositionals(command, 1);
|
|
3140
|
+
uuid(id, "collection-id");
|
|
3141
|
+
const ref = stringOption(command, "ref");
|
|
3142
|
+
if (!ref || ref.length > 256 || /[\u0000-\u001f\u007f]/u.test(ref)) throw usageError("--ref must be a 1–256 character printable Git ref.");
|
|
3143
|
+
const created = await client.request(`/api/organizer/collections/${id}/validations`, {
|
|
3144
|
+
method: "POST",
|
|
3145
|
+
body: { ref }
|
|
3146
|
+
});
|
|
3147
|
+
if (!booleanOption(command, "wait")) return {
|
|
3148
|
+
value: created,
|
|
3149
|
+
exitCode: 0
|
|
3150
|
+
};
|
|
3151
|
+
const validation = object(object(created, "validation creation").validation, "validation");
|
|
3152
|
+
const initialState = field(validation, "state", "validation state");
|
|
3153
|
+
if (TERMINAL_VALIDATION_STATES.has(initialState)) return {
|
|
3154
|
+
value: created,
|
|
3155
|
+
exitCode: initialState === "valid" ? 0 : 1
|
|
3156
|
+
};
|
|
3157
|
+
return watchResource({
|
|
3158
|
+
client,
|
|
3159
|
+
path: `/api/organizer/validations/${serverUuid(validation, "id", "validation ID")}`,
|
|
3160
|
+
envelope: "validation",
|
|
3161
|
+
terminal: TERMINAL_VALIDATION_STATES,
|
|
3162
|
+
success: /* @__PURE__ */ new Set(["valid"]),
|
|
3163
|
+
intervalMs: 2e3,
|
|
3164
|
+
sleep: dependencies.sleep
|
|
3165
|
+
});
|
|
3166
|
+
}
|
|
3167
|
+
if (key === "organizer collection validation") {
|
|
3168
|
+
const [id] = exactPositionals(command, 1);
|
|
3169
|
+
uuid(id, "validation-id");
|
|
3170
|
+
if (!booleanOption(command, "watch")) return {
|
|
3171
|
+
value: await client.request(`/api/organizer/validations/${id}`),
|
|
3172
|
+
exitCode: 0
|
|
3173
|
+
};
|
|
3174
|
+
return watchResource({
|
|
3175
|
+
client,
|
|
3176
|
+
path: `/api/organizer/validations/${id}`,
|
|
3177
|
+
envelope: "validation",
|
|
3178
|
+
terminal: TERMINAL_VALIDATION_STATES,
|
|
3179
|
+
success: /* @__PURE__ */ new Set(["valid"]),
|
|
3180
|
+
intervalMs: positiveInteger(stringOption(command, "interval"), 2, 30, "interval") * 1e3,
|
|
3181
|
+
sleep: dependencies.sleep
|
|
3182
|
+
});
|
|
3183
|
+
}
|
|
3184
|
+
if (key === "organizer collection publish") {
|
|
3185
|
+
const [id] = exactPositionals(command, 1);
|
|
3186
|
+
uuid(id, "revision-id");
|
|
3187
|
+
const mode = stringOption(command, "mode") ?? "official-practice";
|
|
3188
|
+
if (mode !== "official-practice" && mode !== "contest") throw usageError("--mode must be official-practice or contest.");
|
|
3189
|
+
const created = await client.request(`/api/organizer/revisions/${id}/publications`, {
|
|
3190
|
+
method: "POST",
|
|
3191
|
+
body: {
|
|
3192
|
+
mode,
|
|
3193
|
+
idempotencyKey: `woj-publish-${randomUUID()}`
|
|
3194
|
+
}
|
|
3195
|
+
});
|
|
3196
|
+
if (!booleanOption(command, "wait")) return {
|
|
3197
|
+
value: created,
|
|
3198
|
+
exitCode: 0
|
|
3199
|
+
};
|
|
3200
|
+
return watchResource({
|
|
3201
|
+
client,
|
|
3202
|
+
path: `/api/organizer/publications/${serverUuid(object(object(created, "publication creation").publicationJob, "publication job"), "id", "publication job ID")}`,
|
|
3203
|
+
envelope: "publication",
|
|
3204
|
+
terminal: TERMINAL_PUBLICATION_STATES,
|
|
3205
|
+
success: /* @__PURE__ */ new Set(["published"]),
|
|
3206
|
+
intervalMs: 2e3,
|
|
3207
|
+
sleep: dependencies.sleep
|
|
3208
|
+
});
|
|
3209
|
+
}
|
|
3210
|
+
if (key === "organizer collection publication") {
|
|
3211
|
+
const [id] = exactPositionals(command, 1);
|
|
3212
|
+
uuid(id, "publication-job-id");
|
|
3213
|
+
if (!booleanOption(command, "watch")) return {
|
|
3214
|
+
value: await client.request(`/api/organizer/publications/${id}`),
|
|
3215
|
+
exitCode: 0
|
|
3216
|
+
};
|
|
3217
|
+
return watchResource({
|
|
3218
|
+
client,
|
|
3219
|
+
path: `/api/organizer/publications/${id}`,
|
|
3220
|
+
envelope: "publication",
|
|
3221
|
+
terminal: TERMINAL_PUBLICATION_STATES,
|
|
3222
|
+
success: /* @__PURE__ */ new Set(["published"]),
|
|
3223
|
+
intervalMs: positiveInteger(stringOption(command, "interval"), 2, 30, "interval") * 1e3,
|
|
3224
|
+
sleep: dependencies.sleep
|
|
3225
|
+
});
|
|
3226
|
+
}
|
|
3227
|
+
if (key === "organizer collection activate") {
|
|
3228
|
+
const [id] = exactPositionals(command, 1);
|
|
3229
|
+
uuid(id, "publication-id");
|
|
3230
|
+
return {
|
|
3231
|
+
value: await client.request(`/api/organizer/publications/${id}/activate`, {
|
|
3232
|
+
method: "POST",
|
|
3233
|
+
body: {}
|
|
3234
|
+
}),
|
|
3235
|
+
exitCode: 0
|
|
3236
|
+
};
|
|
3237
|
+
}
|
|
3238
|
+
if (key === "organizer contest list") {
|
|
3239
|
+
exactPositionals(command, 0);
|
|
3240
|
+
return {
|
|
3241
|
+
value: await client.request("/api/organizer/contests"),
|
|
3242
|
+
exitCode: 0
|
|
3243
|
+
};
|
|
3244
|
+
}
|
|
3245
|
+
if (key === "organizer contest show") {
|
|
3246
|
+
const [id] = exactPositionals(command, 1);
|
|
3247
|
+
uuid(id, "contest-id");
|
|
3248
|
+
return {
|
|
3249
|
+
value: await client.request(`/api/organizer/contests/${id}`),
|
|
3250
|
+
exitCode: 0
|
|
3251
|
+
};
|
|
3252
|
+
}
|
|
3253
|
+
if (key === "organizer contest create") {
|
|
3254
|
+
exactPositionals(command, 0);
|
|
3255
|
+
const body = await contestBody(command, dependencies.cwd);
|
|
3256
|
+
for (const name of [
|
|
3257
|
+
"title",
|
|
3258
|
+
"startsAt",
|
|
3259
|
+
"endsAt",
|
|
3260
|
+
"accessMode",
|
|
3261
|
+
"problemVersionIds"
|
|
3262
|
+
]) if (body[name] === void 0) throw usageError(`contest create requires ${name}.`);
|
|
3263
|
+
if (body.description === void 0) body.description = "";
|
|
3264
|
+
if (body.accessMode === "invite" && body.inviteCode === void 0) throw usageError("Invite contests require --invite-code-file.");
|
|
3265
|
+
return {
|
|
3266
|
+
value: await client.request("/api/contests", {
|
|
3267
|
+
method: "POST",
|
|
3268
|
+
body
|
|
3269
|
+
}),
|
|
3270
|
+
exitCode: 0
|
|
3271
|
+
};
|
|
3272
|
+
}
|
|
3273
|
+
if (key === "organizer contest update") {
|
|
3274
|
+
const [contestId] = exactPositionals(command, 1);
|
|
3275
|
+
const id = uuid(contestId, "contest-id");
|
|
3276
|
+
const draft = await currentContestDraft(client, id);
|
|
3277
|
+
return {
|
|
3278
|
+
value: await client.request(`/api/organizer/contests/${id}`, {
|
|
3279
|
+
method: "PUT",
|
|
3280
|
+
body: await contestBody(command, dependencies.cwd, draft.body)
|
|
3281
|
+
}),
|
|
3282
|
+
exitCode: 0
|
|
3283
|
+
};
|
|
3284
|
+
}
|
|
3285
|
+
if (key === "organizer contest add-problem" || key === "organizer contest remove-problem") {
|
|
3286
|
+
const [contestId, problemVersionId] = exactPositionals(command, 2);
|
|
3287
|
+
const id = uuid(contestId, "contest-id");
|
|
3288
|
+
const problem = uuid(problemVersionId, "problem-version-id");
|
|
3289
|
+
return {
|
|
3290
|
+
value: await client.request(`/api/organizer/contests/${id}/problems/${problem}`, {
|
|
3291
|
+
method: key.endsWith("add-problem") ? "POST" : "DELETE",
|
|
3292
|
+
body: {}
|
|
3293
|
+
}),
|
|
3294
|
+
exitCode: 0
|
|
3295
|
+
};
|
|
3296
|
+
}
|
|
3297
|
+
if (key === "organizer contest publish") {
|
|
3298
|
+
const [id] = exactPositionals(command, 1);
|
|
3299
|
+
uuid(id, "contest-id");
|
|
3300
|
+
return {
|
|
3301
|
+
value: await client.request(`/api/contests/${id}/publish`, {
|
|
3302
|
+
method: "POST",
|
|
3303
|
+
body: {}
|
|
3304
|
+
}),
|
|
3305
|
+
exitCode: 0
|
|
3306
|
+
};
|
|
3307
|
+
}
|
|
3308
|
+
if (key === "organizer contest archive") {
|
|
3309
|
+
const [id] = exactPositionals(command, 1);
|
|
3310
|
+
uuid(id, "contest-id");
|
|
3311
|
+
return {
|
|
3312
|
+
value: await client.request(`/api/organizer/contests/${id}/archive`, {
|
|
3313
|
+
method: "POST",
|
|
3314
|
+
body: {}
|
|
3315
|
+
}),
|
|
3316
|
+
exitCode: 0
|
|
3317
|
+
};
|
|
3318
|
+
}
|
|
3319
|
+
if (key === "organizer contest participants") {
|
|
3320
|
+
const [id] = exactPositionals(command, 1);
|
|
3321
|
+
uuid(id, "contest-id");
|
|
3322
|
+
const next = cursor(stringOption(command, "cursor"), ["afterJoinedAt", "afterUserId"], "participant cursor");
|
|
3323
|
+
if (next.afterJoinedAt) {
|
|
3324
|
+
canonicalCursorTimestamp(next.afterJoinedAt, "participant cursor");
|
|
3325
|
+
uuid(next.afterUserId, "participant cursor afterUserId");
|
|
3326
|
+
}
|
|
3327
|
+
return {
|
|
3328
|
+
value: await client.request(`/api/organizer/contests/${id}/participants${query({
|
|
3329
|
+
limit: boundedIntegerOption(command, "limit", 100),
|
|
3330
|
+
afterJoinedAt: next.afterJoinedAt,
|
|
3331
|
+
afterUserId: next.afterUserId
|
|
3332
|
+
})}`),
|
|
3333
|
+
exitCode: 0
|
|
3334
|
+
};
|
|
3335
|
+
}
|
|
3336
|
+
if (key === "organizer contest standings") {
|
|
3337
|
+
const [id] = exactPositionals(command, 1);
|
|
3338
|
+
uuid(id, "contest-id");
|
|
3339
|
+
return {
|
|
3340
|
+
value: await client.request(`/api/contests/${id}/leaderboard${query({ limit: boundedIntegerOption(command, "limit", 100) })}`),
|
|
3341
|
+
exitCode: 0
|
|
3342
|
+
};
|
|
3343
|
+
}
|
|
3344
|
+
if (key === "organizer rejudge options") {
|
|
3345
|
+
const [source] = exactPositionals(command, 1);
|
|
3346
|
+
uuid(source, "problem-version-id");
|
|
3347
|
+
return {
|
|
3348
|
+
value: await client.request(`/api/organizer/rejudges/options${query({ source })}`),
|
|
3349
|
+
exitCode: 0
|
|
3350
|
+
};
|
|
3351
|
+
}
|
|
3352
|
+
if (key === "organizer rejudge start") {
|
|
3353
|
+
exactPositionals(command, 0);
|
|
3354
|
+
const from = stringOption(command, "from");
|
|
3355
|
+
const to = stringOption(command, "to");
|
|
3356
|
+
if (!from || !to) throw usageError("rejudge start requires --from and --to.");
|
|
3357
|
+
uuid(from, "from");
|
|
3358
|
+
uuid(to, "to");
|
|
3359
|
+
const created = await client.request("/api/organizer/rejudges", {
|
|
3360
|
+
method: "POST",
|
|
3361
|
+
body: {
|
|
3362
|
+
oldProblemVersionId: from,
|
|
3363
|
+
newProblemVersionId: to,
|
|
3364
|
+
idempotencyKey: `woj-rejudge-${randomUUID()}`
|
|
3365
|
+
}
|
|
3366
|
+
});
|
|
3367
|
+
if (!booleanOption(command, "wait")) return {
|
|
3368
|
+
value: created,
|
|
3369
|
+
exitCode: 0
|
|
3370
|
+
};
|
|
3371
|
+
return watchResource({
|
|
3372
|
+
client,
|
|
3373
|
+
path: `/api/organizer/rejudges/${serverUuid(object(created, "rejudge creation"), "rejudgeBatchId")}`,
|
|
3374
|
+
envelope: "rejudgeBatch",
|
|
3375
|
+
terminal: TERMINAL_REJUDGE_STATES,
|
|
3376
|
+
success: /* @__PURE__ */ new Set(["effective"]),
|
|
3377
|
+
intervalMs: 2e3,
|
|
3378
|
+
sleep: dependencies.sleep
|
|
3379
|
+
});
|
|
3380
|
+
}
|
|
3381
|
+
if (key === "organizer rejudge list") {
|
|
3382
|
+
exactPositionals(command, 0);
|
|
3383
|
+
return {
|
|
3384
|
+
value: await client.request(`/api/organizer/rejudges${query({ limit: boundedIntegerOption(command, "limit", 100) })}`),
|
|
3385
|
+
exitCode: 0
|
|
3386
|
+
};
|
|
3387
|
+
}
|
|
3388
|
+
if (key === "organizer rejudge show") {
|
|
3389
|
+
const [id] = exactPositionals(command, 1);
|
|
3390
|
+
uuid(id, "batch-id");
|
|
3391
|
+
return {
|
|
3392
|
+
value: await client.request(`/api/organizer/rejudges/${id}`),
|
|
3393
|
+
exitCode: 0
|
|
3394
|
+
};
|
|
3395
|
+
}
|
|
3396
|
+
if (key === "organizer rejudge watch") {
|
|
3397
|
+
const [id] = exactPositionals(command, 1);
|
|
3398
|
+
uuid(id, "batch-id");
|
|
3399
|
+
return watchResource({
|
|
3400
|
+
client,
|
|
3401
|
+
path: `/api/organizer/rejudges/${id}`,
|
|
3402
|
+
envelope: "rejudgeBatch",
|
|
3403
|
+
terminal: TERMINAL_REJUDGE_STATES,
|
|
3404
|
+
success: /* @__PURE__ */ new Set(["effective"]),
|
|
3405
|
+
intervalMs: positiveInteger(stringOption(command, "interval"), 2, 30, "interval") * 1e3,
|
|
3406
|
+
sleep: dependencies.sleep
|
|
3407
|
+
});
|
|
3408
|
+
}
|
|
3409
|
+
if (key === "organizer rejudge cancel") {
|
|
3410
|
+
const [id] = exactPositionals(command, 1);
|
|
3411
|
+
uuid(id, "batch-id");
|
|
3412
|
+
return {
|
|
3413
|
+
value: await client.request(`/api/organizer/rejudges/${id}/cancel`, {
|
|
3414
|
+
method: "POST",
|
|
3415
|
+
body: {}
|
|
3416
|
+
}),
|
|
3417
|
+
exitCode: 0
|
|
3418
|
+
};
|
|
3419
|
+
}
|
|
3420
|
+
throw new CliError(`Command '${key}' has no handler.`, {
|
|
3421
|
+
exitCode: 6,
|
|
3422
|
+
code: "handler-missing"
|
|
3423
|
+
});
|
|
3424
|
+
}
|
|
3425
|
+
//#endregion
|
|
3426
|
+
//#region src/cli/help.ts
|
|
3427
|
+
var ROOT_HELP = `woj ${WOJ_CLI_VERSION} — local-first WASM-OJ
|
|
3428
|
+
|
|
3429
|
+
Usage: woj [--offline] [--json] [--server origin] <command> [options]
|
|
3430
|
+
|
|
3431
|
+
Local engine: init, build, run, test, bench, watch, judge, toolchain
|
|
3432
|
+
Student: auth, problem, submit, submission, contest, performance
|
|
3433
|
+
Organizer: organizer repo, organizer collection, organizer contest, organizer rejudge
|
|
3434
|
+
Operations: config, cache, doctor, completion, version
|
|
3435
|
+
|
|
3436
|
+
Boundaries:
|
|
3437
|
+
Local commands use only bytes already on this machine.
|
|
3438
|
+
Remote commands address immutable server resources and require authentication where noted.
|
|
3439
|
+
--offline rejects every command that could access the network before dispatch.
|
|
3440
|
+
|
|
3441
|
+
Run 'woj <command> --help' for command details.`;
|
|
3442
|
+
function helpText(prefix) {
|
|
3443
|
+
if (prefix.length === 0) return ROOT_HELP;
|
|
3444
|
+
const exact = WOJ_COMMANDS.find((command) => command.path.length === prefix.length && prefix.every((part, index) => command.path[index] === part));
|
|
3445
|
+
const children = WOJ_COMMANDS.filter((command) => command.path.length > prefix.length && prefix.every((part, index) => command.path[index] === part)).map((command) => ({
|
|
3446
|
+
name: command.path[prefix.length],
|
|
3447
|
+
command
|
|
3448
|
+
}));
|
|
3449
|
+
if (exact && children.length === 0) {
|
|
3450
|
+
const optionLines = Object.entries(exact.options ?? {}).map(([name, kind]) => ` --${name}${kind === "boolean" ? "" : " <value>"}`);
|
|
3451
|
+
return [
|
|
3452
|
+
exact.summary,
|
|
3453
|
+
"",
|
|
3454
|
+
`Usage: woj ${exact.path.join(" ")}${exact.usage ? ` ${exact.usage}` : ""}${optionLines.length ? " [options]" : ""}`,
|
|
3455
|
+
`Boundary: ${exact.boundary}`,
|
|
3456
|
+
...optionLines.length ? [
|
|
3457
|
+
"",
|
|
3458
|
+
"Options:",
|
|
3459
|
+
...optionLines
|
|
3460
|
+
] : []
|
|
3461
|
+
].join("\n");
|
|
3462
|
+
}
|
|
3463
|
+
const unique = /* @__PURE__ */ new Map();
|
|
3464
|
+
for (const child of children) {
|
|
3465
|
+
const direct = child.command.path.length === prefix.length + 1;
|
|
3466
|
+
unique.set(child.name, direct ? child.command.summary : `${child.name} commands`);
|
|
3467
|
+
}
|
|
3468
|
+
return [
|
|
3469
|
+
`Usage: woj ${prefix.join(" ")} <command>`,
|
|
3470
|
+
"",
|
|
3471
|
+
"Commands:",
|
|
3472
|
+
...[...unique].sort(([left], [right]) => left.localeCompare(right)).map(([name, summary]) => ` ${name.padEnd(16)} ${summary}`)
|
|
3473
|
+
].join("\n");
|
|
3474
|
+
}
|
|
3475
|
+
//#endregion
|
|
3476
|
+
//#region src/cli/parser.ts
|
|
3477
|
+
var GLOBAL_OPTIONS = /* @__PURE__ */ new Set([
|
|
3478
|
+
"offline",
|
|
3479
|
+
"json",
|
|
3480
|
+
"server"
|
|
3481
|
+
]);
|
|
3482
|
+
function takeGlobal(arguments_) {
|
|
3483
|
+
const rest = [];
|
|
3484
|
+
let offline = false;
|
|
3485
|
+
let json = false;
|
|
3486
|
+
let server;
|
|
3487
|
+
let help = false;
|
|
3488
|
+
let version = false;
|
|
3489
|
+
for (let index = 0; index < arguments_.length; index += 1) {
|
|
3490
|
+
const argument = arguments_[index];
|
|
3491
|
+
if (argument === "--") {
|
|
3492
|
+
rest.push(...arguments_.slice(index + 1));
|
|
3493
|
+
break;
|
|
3494
|
+
}
|
|
3495
|
+
if (argument === "--offline") offline = true;
|
|
3496
|
+
else if (argument === "--json") json = true;
|
|
3497
|
+
else if (argument === "--help" || argument === "-h") help = true;
|
|
3498
|
+
else if (argument === "--version" || argument === "-V") version = true;
|
|
3499
|
+
else if (argument === "--server") {
|
|
3500
|
+
const value = arguments_[index + 1];
|
|
3501
|
+
if (!value || value.startsWith("-")) throw usageError("--server requires an origin.");
|
|
3502
|
+
server = value;
|
|
3503
|
+
index += 1;
|
|
3504
|
+
} else rest.push(argument);
|
|
3505
|
+
}
|
|
3506
|
+
return {
|
|
3507
|
+
rest,
|
|
3508
|
+
offline,
|
|
3509
|
+
json,
|
|
3510
|
+
...server ? { server } : {},
|
|
3511
|
+
help,
|
|
3512
|
+
version
|
|
3513
|
+
};
|
|
3514
|
+
}
|
|
3515
|
+
function matchingCommand(words) {
|
|
3516
|
+
let match;
|
|
3517
|
+
for (let count = 1; count <= words.length; count += 1) {
|
|
3518
|
+
const spec = COMMAND_BY_KEY.get(words.slice(0, count).join(" "));
|
|
3519
|
+
if (spec) match = {
|
|
3520
|
+
spec,
|
|
3521
|
+
consumed: count
|
|
3522
|
+
};
|
|
3523
|
+
}
|
|
3524
|
+
return match;
|
|
3525
|
+
}
|
|
3526
|
+
function knownPrefix(words) {
|
|
3527
|
+
return WOJ_COMMANDS.some((command) => words.length <= command.path.length && words.every((word, index) => command.path[index] === word));
|
|
3528
|
+
}
|
|
3529
|
+
function parseLeafArguments(spec, arguments_) {
|
|
3530
|
+
const positionals = [];
|
|
3531
|
+
const options = {};
|
|
3532
|
+
for (let index = 0; index < arguments_.length; index += 1) {
|
|
3533
|
+
const argument = arguments_[index];
|
|
3534
|
+
if (argument === "--") {
|
|
3535
|
+
positionals.push(...arguments_.slice(index + 1));
|
|
3536
|
+
break;
|
|
3537
|
+
}
|
|
3538
|
+
if (!argument.startsWith("--")) {
|
|
3539
|
+
if (argument.startsWith("-") && argument !== "-") throw usageError(`Unknown option '${argument}'.`);
|
|
3540
|
+
positionals.push(argument);
|
|
3541
|
+
continue;
|
|
3542
|
+
}
|
|
3543
|
+
const equals = argument.indexOf("=");
|
|
3544
|
+
const name = argument.slice(2, equals < 0 ? void 0 : equals);
|
|
3545
|
+
if (!name || GLOBAL_OPTIONS.has(name)) throw usageError(`Global option '--${name}' must appear before the command.`);
|
|
3546
|
+
const kind = spec.options?.[name];
|
|
3547
|
+
if (!kind) throw usageError(`Unknown option '--${name}' for '${spec.path.join(" ")}'.`);
|
|
3548
|
+
if (kind === "boolean") {
|
|
3549
|
+
if (equals >= 0) throw usageError(`--${name} does not accept a value.`);
|
|
3550
|
+
options[name] = true;
|
|
3551
|
+
continue;
|
|
3552
|
+
}
|
|
3553
|
+
const value = equals >= 0 ? argument.slice(equals + 1) : arguments_[index + 1];
|
|
3554
|
+
if (!value || equals < 0 && value.startsWith("--")) throw usageError(`--${name} requires a value.`);
|
|
3555
|
+
if (equals < 0) index += 1;
|
|
3556
|
+
if (kind === "repeatable") {
|
|
3557
|
+
const current = options[name];
|
|
3558
|
+
options[name] = [...Array.isArray(current) ? current : [], value];
|
|
3559
|
+
} else {
|
|
3560
|
+
if (name in options) throw usageError(`--${name} may be provided only once.`);
|
|
3561
|
+
options[name] = value;
|
|
3562
|
+
}
|
|
3563
|
+
}
|
|
3564
|
+
return {
|
|
3565
|
+
positionals,
|
|
3566
|
+
options
|
|
3567
|
+
};
|
|
3568
|
+
}
|
|
3569
|
+
function parseCli(arguments_) {
|
|
3570
|
+
const global = takeGlobal(arguments_);
|
|
3571
|
+
if (global.version) return { kind: "version" };
|
|
3572
|
+
if (global.rest.length === 0) return {
|
|
3573
|
+
kind: "help",
|
|
3574
|
+
prefix: []
|
|
3575
|
+
};
|
|
3576
|
+
const match = matchingCommand(global.rest);
|
|
3577
|
+
if (global.help) {
|
|
3578
|
+
const prefix = match?.spec.path ?? global.rest.filter((part) => !part.startsWith("-"));
|
|
3579
|
+
if (!knownPrefix(prefix)) throw usageError(`Unknown command '${prefix.join(" ")}'.`);
|
|
3580
|
+
return {
|
|
3581
|
+
kind: "help",
|
|
3582
|
+
prefix
|
|
3583
|
+
};
|
|
3584
|
+
}
|
|
3585
|
+
if (!match) {
|
|
3586
|
+
const prefix = global.rest.filter((part) => !part.startsWith("-"));
|
|
3587
|
+
if (knownPrefix(prefix)) return {
|
|
3588
|
+
kind: "help",
|
|
3589
|
+
prefix
|
|
3590
|
+
};
|
|
3591
|
+
throw usageError(`Unknown command '${prefix.join(" ")}'. Run 'woj --help'.`);
|
|
3592
|
+
}
|
|
3593
|
+
const leaf = parseLeafArguments(match.spec, global.rest.slice(match.consumed));
|
|
3594
|
+
return {
|
|
3595
|
+
kind: "command",
|
|
3596
|
+
command: {
|
|
3597
|
+
spec: match.spec,
|
|
3598
|
+
...leaf,
|
|
3599
|
+
global: {
|
|
3600
|
+
offline: global.offline,
|
|
3601
|
+
json: global.json,
|
|
3602
|
+
...global.server ? { server: global.server } : {}
|
|
3603
|
+
}
|
|
3604
|
+
}
|
|
3605
|
+
};
|
|
3606
|
+
}
|
|
3607
|
+
//#endregion
|
|
3608
|
+
//#region src/cli/index.ts
|
|
3609
|
+
function defaultIo() {
|
|
3610
|
+
return {
|
|
3611
|
+
stdout: (text) => process.stdout.write(text),
|
|
3612
|
+
stderr: (text) => process.stderr.write(text)
|
|
3613
|
+
};
|
|
3614
|
+
}
|
|
3615
|
+
var TERMINAL_CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu;
|
|
3616
|
+
function escapedCodePoint(value) {
|
|
3617
|
+
return [...value].map((character) => `\\u{${character.codePointAt(0).toString(16).padStart(4, "0")}}`).join("");
|
|
3618
|
+
}
|
|
3619
|
+
function terminalText(value) {
|
|
3620
|
+
return value.replace(TERMINAL_CONTROL, escapedCodePoint).replaceAll("\r", "\\u{000d}");
|
|
3621
|
+
}
|
|
3622
|
+
function terminalLine(value, maximumCodePoints) {
|
|
3623
|
+
const sanitized = terminalText(value).replace(/[\n\t\u2028\u2029]+/gu, " ").trim();
|
|
3624
|
+
const codePoints = [...sanitized];
|
|
3625
|
+
return codePoints.length <= maximumCodePoints ? sanitized : `${codePoints.slice(0, maximumCodePoints).join("")}…`;
|
|
3626
|
+
}
|
|
3627
|
+
function terminalJson(value) {
|
|
3628
|
+
return (JSON.stringify(value, null, 2) ?? "null").replace(/[\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu, (character) => `\\u${character.codePointAt(0).toString(16).padStart(4, "0")}`);
|
|
3629
|
+
}
|
|
3630
|
+
function printValue(io, value, json) {
|
|
3631
|
+
if (typeof value === "string" && !json) {
|
|
3632
|
+
io.stdout(`${terminalText(value)}\n`);
|
|
3633
|
+
return;
|
|
3634
|
+
}
|
|
3635
|
+
io.stdout(`${terminalJson(value)}\n`);
|
|
3636
|
+
}
|
|
3637
|
+
function printError(io, error, json) {
|
|
3638
|
+
if (json) io.stderr(`${terminalJson({ error: {
|
|
3639
|
+
code: error.code,
|
|
3640
|
+
message: error.message,
|
|
3641
|
+
exitCode: error.exitCode
|
|
3642
|
+
} })}\n`);
|
|
3643
|
+
else io.stderr(`error[${terminalLine(error.code, 80) || "cli-error"}]: ${terminalLine(error.message, 2e3) || "The CLI failed."}\n`);
|
|
3644
|
+
}
|
|
3645
|
+
async function runWojCli(arguments_, provided = {}) {
|
|
3646
|
+
const io = provided.io ?? defaultIo();
|
|
3647
|
+
let json = arguments_.includes("--json");
|
|
3648
|
+
try {
|
|
3649
|
+
const parsed = parseCli(arguments_);
|
|
3650
|
+
if (parsed.kind === "help") {
|
|
3651
|
+
io.stdout(`${helpText(parsed.prefix)}\n`);
|
|
3652
|
+
return WOJ_EXIT.success;
|
|
3653
|
+
}
|
|
3654
|
+
if (parsed.kind === "version") {
|
|
3655
|
+
io.stdout(`woj ${WOJ_CLI_VERSION}\n`);
|
|
3656
|
+
return WOJ_EXIT.success;
|
|
3657
|
+
}
|
|
3658
|
+
json = parsed.command.global.json;
|
|
3659
|
+
if (parsed.command.global.offline && parsed.command.spec.boundary !== "local") throw usageError(`'woj ${parsed.command.spec.path.join(" ")}' can access the network and is disabled by --offline.`);
|
|
3660
|
+
const tokenStore = provided.tokenStore ?? new OsKeychainTokenStore();
|
|
3661
|
+
const dependencies = {
|
|
3662
|
+
cwd: provided.cwd ?? process.cwd(),
|
|
3663
|
+
configStore: provided.configStore ?? new JsonConfigStore(),
|
|
3664
|
+
tokenStore,
|
|
3665
|
+
local: provided.local ?? new NodeLocalRuntime(),
|
|
3666
|
+
collectionCli: provided.collectionCli ?? runCollectionCli,
|
|
3667
|
+
remote: provided.remote ?? ((origin) => new HttpRemoteClient(origin, tokenStore)),
|
|
3668
|
+
opener: provided.opener ?? new SystemBrowserOpener(),
|
|
3669
|
+
sleep: provided.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))),
|
|
3670
|
+
onNotice: (message) => io.stderr(`${terminalLine(message, 4e3)}\n`)
|
|
3671
|
+
};
|
|
3672
|
+
const outcome = await dispatchCommand(parsed.command, dependencies);
|
|
3673
|
+
printValue(io, outcome.value, json);
|
|
3674
|
+
return outcome.exitCode;
|
|
3675
|
+
} catch (error) {
|
|
3676
|
+
const cliError = asCliError(error);
|
|
3677
|
+
printError(io, cliError, json);
|
|
3678
|
+
return cliError.exitCode;
|
|
3679
|
+
}
|
|
3680
|
+
}
|
|
3681
|
+
async function main(arguments_) {
|
|
3682
|
+
return runWojCli(arguments_);
|
|
3683
|
+
}
|
|
3684
|
+
//#endregion
|
|
3685
|
+
export { ApiError, CLI_TOOLCHAIN_DESCRIPTORS, COMMAND_BY_KEY, CONFIG_KEYS, CliError, HttpRemoteClient, JsonConfigStore, LANGUAGES, MemoryConfigStore, MemoryTokenStore, NodeLocalRuntime, OsKeychainTokenStore, WOJ_ACCESS_TOKEN, WOJ_CLI_VERSION, WOJ_COMMANDS, WOJ_EXIT, WOJ_WORKSPACE_SCHEMA, WORKSPACE_FILE, asCliError, commandKey, createWorkspace, defaultConfigDirectory, defaultConfigPath, deviceLogin, isConfigKey, isWojAccessToken, judgeSucceeded, main, parseCli, parseConfig, parsePublicProblem, parseWorkspace, readWorkspace, readWorkspaceFileBytes, readWorkspaceSources, runWojCli, unavailableError, usageError, validateConfigValue, writeWorkspace };
|