ravensight-playtest 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +380 -0
- package/addons/ravensight_driver/driver.gd +836 -0
- package/addons/ravensight_driver/export_plugin.gd +51 -0
- package/addons/ravensight_driver/plugin.cfg +7 -0
- package/addons/ravensight_driver/plugin.gd +36 -0
- package/bin/ravensight-playtest.js +31 -0
- package/package.json +45 -0
- package/src/api/README.md +500 -0
- package/src/api/client.js +340 -0
- package/src/api/errors.js +115 -0
- package/src/api/http.js +194 -0
- package/src/api/index.js +107 -0
- package/src/auth/deviceCode.js +79 -0
- package/src/auth/keychain.js +159 -0
- package/src/auth/session.js +128 -0
- package/src/cli.js +335 -0
- package/src/commands/brief.js +303 -0
- package/src/commands/check.js +318 -0
- package/src/commands/fakeCore.js +379 -0
- package/src/commands/init.js +120 -0
- package/src/commands/login.js +90 -0
- package/src/commands/logout.js +70 -0
- package/src/commands/open.js +125 -0
- package/src/commands/profile.js +262 -0
- package/src/commands/resume.js +156 -0
- package/src/commands/run.js +1015 -0
- package/src/commands/upload.js +137 -0
- package/src/config.js +100 -0
- package/src/dashboard.js +97 -0
- package/src/detect.js +77 -0
- package/src/errors.js +44 -0
- package/src/fsutil.js +77 -0
- package/src/godot.js +85 -0
- package/src/packs/index.js +191 -0
- package/src/paths.js +129 -0
- package/src/run/aggregate.js +658 -0
- package/src/run/args.js +111 -0
- package/src/run/context.js +181 -0
- package/src/run/deps.js +184 -0
- package/src/run/drivers/driver.js +183 -0
- package/src/run/drivers/godot-observation.js +138 -0
- package/src/run/drivers/godot-project.js +475 -0
- package/src/run/drivers/godot-rpc.js +225 -0
- package/src/run/drivers/godot.js +587 -0
- package/src/run/drivers/index.js +52 -0
- package/src/run/drivers/web.js +385 -0
- package/src/run/exit.js +21 -0
- package/src/run/heartbeat.js +131 -0
- package/src/run/index.js +31 -0
- package/src/run/json.js +56 -0
- package/src/run/model.js +384 -0
- package/src/run/paths.js +88 -0
- package/src/run/personaLoop.js +871 -0
- package/src/run/profile.js +214 -0
- package/src/run/regenerate.js +149 -0
- package/src/run/repoTools.js +286 -0
- package/src/run/report.js +222 -0
- package/src/run/resume.js +272 -0
- package/src/run/secretScan.js +171 -0
- package/src/run/state.js +198 -0
- package/src/run/synthetic.js +206 -0
- package/src/run/tools.js +344 -0
- package/src/run/transcript.js +93 -0
- package/src/run/usage.js +115 -0
- package/src/state/index.js +105 -0
- package/src/states.js +104 -0
- package/src/ui/index.js +195 -0
- package/src/upload/allowlist.js +116 -0
- package/src/upload/index.js +467 -0
- package/src/upload/queue.js +114 -0
- package/src/version.js +63 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { createContext } from '../api/index.js';
|
|
3
|
+
import { resolveGameId } from '../config.js';
|
|
4
|
+
import { withAuthHandling } from '../auth/session.js';
|
|
5
|
+
import { paths } from '../paths.js';
|
|
6
|
+
import { readState } from '../state/index.js';
|
|
7
|
+
import { pending } from '../upload/queue.js';
|
|
8
|
+
import { uploadRunDir } from '../upload/index.js';
|
|
9
|
+
import { JOB_UPLOAD_PATHS } from '../upload/allowlist.js';
|
|
10
|
+
import { ui } from '../ui/index.js';
|
|
11
|
+
import { CliError, ExitCode } from '../errors.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The run ids a job has directories for on disk. The runner names them after
|
|
15
|
+
* the server's own run ids, so these are what the presign route expects.
|
|
16
|
+
* @param {string} jobId
|
|
17
|
+
* @param {{repoRoot?: string}} options
|
|
18
|
+
* @returns {Promise<string[]>}
|
|
19
|
+
*/
|
|
20
|
+
export async function localRunIds(jobId, options = {}) {
|
|
21
|
+
const dir = `${paths.jobDir(jobId, options)}/runs`;
|
|
22
|
+
try {
|
|
23
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
24
|
+
return entries.filter(entry => entry.isDirectory()).map(entry => entry.name).sort();
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error.code === 'ENOENT') return [];
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `ravensight-playtest upload <jobId>`
|
|
33
|
+
*
|
|
34
|
+
* Drain whatever a crashed or offline run left behind: every run directory of a
|
|
35
|
+
* job, plus the job level artifacts (the capability report and the aggregate).
|
|
36
|
+
*
|
|
37
|
+
* Safe to run repeatedly. `uploadRunDir` skips anything the job's
|
|
38
|
+
* `upload-queue.jsonl` already records as uploaded at the same sha256, so this
|
|
39
|
+
* costs one presign request per directory with nothing new in it, and re-sends
|
|
40
|
+
* only a file that actually changed.
|
|
41
|
+
*
|
|
42
|
+
* This command does not call `complete` or `finish`: those are the runner's,
|
|
43
|
+
* because they carry the report and the terminal state. This is the bytes only.
|
|
44
|
+
*
|
|
45
|
+
* @param {string} jobId
|
|
46
|
+
* @param {Object} [flags]
|
|
47
|
+
* @param {Object} [deps]
|
|
48
|
+
* @returns {Promise<number>}
|
|
49
|
+
*/
|
|
50
|
+
export async function upload(jobId, flags = {}, deps = {}) {
|
|
51
|
+
if (!jobId) throw new CliError('Which job? ravensight-playtest upload <jobId>');
|
|
52
|
+
|
|
53
|
+
const context = deps.context || await createContext({
|
|
54
|
+
apiUrl: flags.apiUrl,
|
|
55
|
+
repoRoot: flags.repoRoot,
|
|
56
|
+
requireAuth: true
|
|
57
|
+
});
|
|
58
|
+
return withAuthHandling(context, () => drainJob(context, jobId, flags));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function drainJob(context, jobId, flags) {
|
|
62
|
+
const repoRoot = context.repoRoot;
|
|
63
|
+
const state = await readState(jobId, { repoRoot });
|
|
64
|
+
const gameId = flags.game || (state && state.game_id) || resolveGameId(flags, context.config);
|
|
65
|
+
|
|
66
|
+
const uploadsConfig = (context.config && context.config.uploads) || {};
|
|
67
|
+
const includeVideo = flags.video === undefined ? Boolean(uploadsConfig.video) : Boolean(flags.video);
|
|
68
|
+
const includeTranscript = flags.transcript === undefined
|
|
69
|
+
? Boolean(uploadsConfig.transcript)
|
|
70
|
+
: Boolean(flags.transcript);
|
|
71
|
+
|
|
72
|
+
const runIds = await localRunIds(jobId, { repoRoot });
|
|
73
|
+
const results = [];
|
|
74
|
+
|
|
75
|
+
for (const runId of runIds) {
|
|
76
|
+
const result = await uploadRunDir(paths.runDir(jobId, runId, { repoRoot }), {
|
|
77
|
+
api: context.api,
|
|
78
|
+
gameId,
|
|
79
|
+
jobId,
|
|
80
|
+
runId,
|
|
81
|
+
includeVideo,
|
|
82
|
+
includeTranscript,
|
|
83
|
+
dryRun: Boolean(flags.dryRun),
|
|
84
|
+
repoRoot,
|
|
85
|
+
onFile: flags.json ? undefined : file => ui.info(` ${file.status} ${runId}/${file.path} (${ui.bytes(file.size)})`)
|
|
86
|
+
});
|
|
87
|
+
results.push({ run_id: runId, ...result });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The job level files sit in the job directory itself, one level above the
|
|
91
|
+
// run directories, and get keys without a `runs/<runId>` segment. Narrowed to
|
|
92
|
+
// the three artifacts that actually belong to a job, because that directory
|
|
93
|
+
// also holds state.json and upload-queue.jsonl and is where a future local
|
|
94
|
+
// file would land.
|
|
95
|
+
const jobLevel = await uploadRunDir(paths.jobDir(jobId, { repoRoot }), {
|
|
96
|
+
api: context.api,
|
|
97
|
+
gameId,
|
|
98
|
+
jobId,
|
|
99
|
+
runId: null,
|
|
100
|
+
includeVideo: false,
|
|
101
|
+
includeTranscript: false,
|
|
102
|
+
allowedPaths: JOB_UPLOAD_PATHS,
|
|
103
|
+
dryRun: Boolean(flags.dryRun),
|
|
104
|
+
repoRoot,
|
|
105
|
+
onFile: flags.json ? undefined : file => ui.info(` ${file.status} ${file.path} (${ui.bytes(file.size)})`)
|
|
106
|
+
});
|
|
107
|
+
results.push({ run_id: null, ...jobLevel });
|
|
108
|
+
|
|
109
|
+
const uploaded = results.reduce((sum, result) => sum + result.uploaded.length, 0);
|
|
110
|
+
const bytes = results.reduce((sum, result) => sum + result.bytes, 0);
|
|
111
|
+
const stillPending = await pending(jobId, { repoRoot });
|
|
112
|
+
|
|
113
|
+
if (flags.json) {
|
|
114
|
+
ui.json({ ok: stillPending.length === 0, job_id: jobId, results, pending: stillPending });
|
|
115
|
+
return ExitCode.OK;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
ui.blank();
|
|
119
|
+
if (flags.dryRun) {
|
|
120
|
+
ui.info(`Would send ${uploaded} files, ${ui.bytes(bytes)}.`);
|
|
121
|
+
} else {
|
|
122
|
+
ui.ok(`Sent ${uploaded} files, ${ui.bytes(bytes)}.`);
|
|
123
|
+
}
|
|
124
|
+
const skipped = results.flatMap(result => result.skipped);
|
|
125
|
+
if (skipped.length > 0) {
|
|
126
|
+
ui.blank();
|
|
127
|
+
ui.info('Not sent:');
|
|
128
|
+
ui.table(skipped, [
|
|
129
|
+
{ key: 'path', label: 'PATH' },
|
|
130
|
+
{ key: 'reason', label: 'REASON' }
|
|
131
|
+
]);
|
|
132
|
+
}
|
|
133
|
+
if (stillPending.length > 0) {
|
|
134
|
+
ui.warn(`${stillPending.length} files are still owed. Run upload again when you are online.`);
|
|
135
|
+
}
|
|
136
|
+
return ExitCode.OK;
|
|
137
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { paths } from './paths.js';
|
|
2
|
+
import { readJson, writeJson } from './fsutil.js';
|
|
3
|
+
import { CliError } from './errors.js';
|
|
4
|
+
|
|
5
|
+
export const CONFIG_VERSION = 1;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `<repo>/.ravensight/config.json`: the repo to game link, and the defaults a
|
|
9
|
+
* developer should not have to retype.
|
|
10
|
+
*
|
|
11
|
+
* JSON rather than the YAML spec 17 sketched, because it needs no dependency
|
|
12
|
+
* and the file is machine written far more often than it is hand edited.
|
|
13
|
+
*
|
|
14
|
+
* @typedef {Object} LocalConfig
|
|
15
|
+
* @property {number} config_version
|
|
16
|
+
* @property {string} game_id
|
|
17
|
+
* @property {string} [api_url] - recorded so a repo linked against one server
|
|
18
|
+
* cannot silently be run against another
|
|
19
|
+
* @property {string} [driver] - playwright_web, godot_driver or cli_stdio
|
|
20
|
+
* @property {Object} [build] - `{ kind, ref }`
|
|
21
|
+
* @property {string[]} [personas]
|
|
22
|
+
* @property {Object} [uploads] - `{ video: boolean, transcript: boolean }`
|
|
23
|
+
* @property {number} [max_actions]
|
|
24
|
+
* @property {number} [concurrency]
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The shape a fresh `init` writes. */
|
|
28
|
+
export function defaultConfig(gameId, extra = {}) {
|
|
29
|
+
return {
|
|
30
|
+
config_version: CONFIG_VERSION,
|
|
31
|
+
game_id: gameId,
|
|
32
|
+
driver: 'playwright_web',
|
|
33
|
+
build: { kind: '', ref: '' },
|
|
34
|
+
personas: [],
|
|
35
|
+
uploads: { video: false, transcript: false },
|
|
36
|
+
concurrency: 2,
|
|
37
|
+
...extra
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Read the repo's config, or null when this repo has never been linked.
|
|
43
|
+
* @param {{repoRoot?: string}} [options]
|
|
44
|
+
* @returns {Promise<LocalConfig|null>}
|
|
45
|
+
*/
|
|
46
|
+
export async function loadConfig(options = {}) {
|
|
47
|
+
const value = await readJson(paths.configFile(options), null);
|
|
48
|
+
if (value === null) return null;
|
|
49
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
50
|
+
throw new CliError(`${paths.configFile(options)} is not a config object.`);
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read the config or refuse, with the message that says what to do about it.
|
|
57
|
+
* Every game scoped command starts here.
|
|
58
|
+
* @param {{repoRoot?: string}} [options]
|
|
59
|
+
* @returns {Promise<LocalConfig>}
|
|
60
|
+
*/
|
|
61
|
+
export async function requireConfig(options = {}) {
|
|
62
|
+
const config = await loadConfig(options);
|
|
63
|
+
if (!config || !config.game_id) {
|
|
64
|
+
throw new CliError(
|
|
65
|
+
'This directory is not linked to a Ravensight game.',
|
|
66
|
+
1,
|
|
67
|
+
{ hint: 'Run ravensight-playtest init --game <gameId>.' }
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return config;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Write the repo's config.
|
|
75
|
+
* @param {LocalConfig} config
|
|
76
|
+
* @param {{repoRoot?: string}} [options]
|
|
77
|
+
*/
|
|
78
|
+
export async function saveConfig(config, options = {}) {
|
|
79
|
+
await writeJson(paths.configFile(options), { config_version: CONFIG_VERSION, ...config });
|
|
80
|
+
return config;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The game id for a command: the `--game` flag when given, otherwise the
|
|
85
|
+
* config's. A flag that contradicts the config is honored, not refused, so a
|
|
86
|
+
* developer can run one job against another game without editing a file, but
|
|
87
|
+
* the config is never rewritten as a side effect of that.
|
|
88
|
+
* @param {{game?: string}} flags
|
|
89
|
+
* @param {LocalConfig|null} config
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
export function resolveGameId(flags = {}, config = null) {
|
|
93
|
+
const fromFlag = flags.game || process.env.RAVENSIGHT_GAME_ID;
|
|
94
|
+
if (fromFlag) return fromFlag;
|
|
95
|
+
if (config && config.game_id) return config.game_id;
|
|
96
|
+
throw new CliError(
|
|
97
|
+
'No game id. Pass --game <gameId>, or run init first.',
|
|
98
|
+
1
|
|
99
|
+
);
|
|
100
|
+
}
|
package/src/dashboard.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { resolveApiUrl } from './paths.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where the dashboard is, given where the API is.
|
|
5
|
+
*
|
|
6
|
+
* There is no endpoint that says, so this is a derivation with an override.
|
|
7
|
+
* `api.ravensight.io` pairs with `app.ravensight.io`, which is what
|
|
8
|
+
* `.env.ravensight.prod.example` records as `DASHBOARD_URL`. A localhost API
|
|
9
|
+
* pairs with the Vite dev server's port. Anything else keeps the same host,
|
|
10
|
+
* which is the least surprising guess for a single host deployment.
|
|
11
|
+
*
|
|
12
|
+
* `RAVENSIGHT_DASHBOARD_URL` overrides all of it, and the device code flow does
|
|
13
|
+
* not rely on any of this: it uses the `verification_uri` the server sends.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} [apiUrl]
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
export function dashboardUrl(apiUrl = resolveApiUrl()) {
|
|
19
|
+
const override = process.env.RAVENSIGHT_DASHBOARD_URL;
|
|
20
|
+
if (override) return override.replace(/\/+$/, '');
|
|
21
|
+
|
|
22
|
+
let url;
|
|
23
|
+
try {
|
|
24
|
+
url = new URL(apiUrl);
|
|
25
|
+
} catch {
|
|
26
|
+
return apiUrl;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') {
|
|
30
|
+
return 'http://localhost:5173';
|
|
31
|
+
}
|
|
32
|
+
if (url.hostname.startsWith('api.')) {
|
|
33
|
+
url.hostname = `app.${url.hostname.slice('api.'.length)}`;
|
|
34
|
+
url.port = '';
|
|
35
|
+
}
|
|
36
|
+
return url.toString().replace(/\/+$/, '');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The dashboard page for one game's playtest surface. */
|
|
40
|
+
export function gameLink(gameId, apiUrl) {
|
|
41
|
+
return `${dashboardUrl(apiUrl)}/games/${encodeURIComponent(gameId)}/playtest`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The dashboard page for one job. Matches `links.dashboard` from POST /jobs. */
|
|
45
|
+
export function jobLink(gameId, jobId, apiUrl) {
|
|
46
|
+
return `${gameLink(gameId, apiUrl)}/jobs/${encodeURIComponent(jobId)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The post job review form for one job. */
|
|
50
|
+
export function reviewLink(gameId, jobId, apiUrl) {
|
|
51
|
+
return `${jobLink(gameId, jobId, apiUrl)}/review`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The brief form. */
|
|
55
|
+
export function briefLink(gameId, apiUrl) {
|
|
56
|
+
return `${gameLink(gameId, apiUrl)}/brief`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* "What leaves your machine": the page that lists, artifact by artifact, what a
|
|
61
|
+
* playtest uploads and what it never does. The CLI points at it rather than
|
|
62
|
+
* restating it, so there is one answer to that question and it is the one the
|
|
63
|
+
* server can keep true.
|
|
64
|
+
*/
|
|
65
|
+
export function trustLink(gameId, apiUrl) {
|
|
66
|
+
return `${gameLink(gameId, apiUrl)}/what-leaves-your-machine`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Open a URL in the platform browser, answering whether the attempt was made.
|
|
71
|
+
*
|
|
72
|
+
* Best effort on purpose: a headless box has nothing to open, and a CLI that
|
|
73
|
+
* failed because `xdg-open` is missing would be failing at the wrong thing. The
|
|
74
|
+
* URL is always printed by the caller, so a false answer costs a copy and paste
|
|
75
|
+
* and nothing else.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} url
|
|
78
|
+
* @param {{spawn?: Function}} [options]
|
|
79
|
+
* @returns {Promise<boolean>}
|
|
80
|
+
*/
|
|
81
|
+
export async function openInBrowser(url, options = {}) {
|
|
82
|
+
if (process.env.RAVENSIGHT_NO_BROWSER === '1') return false;
|
|
83
|
+
const spawn = options.spawn || (await import('node:child_process')).spawn;
|
|
84
|
+
const command = process.platform === 'darwin'
|
|
85
|
+
? ['open', [url]]
|
|
86
|
+
: process.platform === 'win32'
|
|
87
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
88
|
+
: ['xdg-open', [url]];
|
|
89
|
+
try {
|
|
90
|
+
const child = spawn(command[0], command[1], { stdio: 'ignore', detached: true });
|
|
91
|
+
if (child.unref) child.unref();
|
|
92
|
+
child.on('error', () => {});
|
|
93
|
+
return true;
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
package/src/detect.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { statfs } from 'node:fs/promises';
|
|
3
|
+
import { freemem } from 'node:os';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
|
|
6
|
+
const run = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Run a command and answer its first line of output, or null when it is not
|
|
10
|
+
* there or does not work. Never throws: every caller of this file is a
|
|
11
|
+
* diagnostic, and a diagnostic that crashes is worse than one that says "not
|
|
12
|
+
* found".
|
|
13
|
+
* @param {string} command
|
|
14
|
+
* @param {string[]} args
|
|
15
|
+
* @param {{timeout?: number}} [options]
|
|
16
|
+
* @returns {Promise<string|null>}
|
|
17
|
+
*/
|
|
18
|
+
export async function probe(command, args, options = {}) {
|
|
19
|
+
try {
|
|
20
|
+
const { stdout, stderr } = await run(command, args, {
|
|
21
|
+
timeout: options.timeout || 15000,
|
|
22
|
+
windowsHide: true
|
|
23
|
+
});
|
|
24
|
+
const text = (stdout || stderr || '').trim();
|
|
25
|
+
return text.split('\n')[0] || '';
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* ffmpeg, used to re-encode a session recording before it is uploaded. Optional:
|
|
33
|
+
* without it the video is either skipped or uploaded as the driver produced it.
|
|
34
|
+
* @returns {Promise<{path: string|null, version: string|null}>}
|
|
35
|
+
*/
|
|
36
|
+
export async function detectFfmpeg() {
|
|
37
|
+
const explicit = process.env.RAVENSIGHT_FFMPEG;
|
|
38
|
+
if (explicit) {
|
|
39
|
+
const version = await probe(explicit, ['-version']);
|
|
40
|
+
if (version !== null) return { path: explicit, version };
|
|
41
|
+
return { path: null, version: null };
|
|
42
|
+
}
|
|
43
|
+
const version = await probe('ffmpeg', ['-version']);
|
|
44
|
+
return version === null ? { path: null, version: null } : { path: 'ffmpeg', version };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Free bytes on the volume holding a path. `statfs` is not on every platform
|
|
49
|
+
* this CLI claims to support, so a miss is null rather than an exception.
|
|
50
|
+
* @param {string} path
|
|
51
|
+
* @returns {Promise<number|null>}
|
|
52
|
+
*/
|
|
53
|
+
export async function freeBytes(path) {
|
|
54
|
+
try {
|
|
55
|
+
const info = await statfs(path);
|
|
56
|
+
return Number(info.bavail) * Number(info.bsize);
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A recommended persona concurrency for this machine, and the numbers behind it.
|
|
64
|
+
* One Chromium is roughly a gigabyte and a half at peak, so RAM is usually the
|
|
65
|
+
* binding constraint rather than cores. Never above 4: past that the machine is
|
|
66
|
+
* thrashing and the personas start timing out on each other.
|
|
67
|
+
* @returns {{cpus: number, freeMemGb: number, recommended: number}}
|
|
68
|
+
*/
|
|
69
|
+
export function recommendConcurrency(machine = {}) {
|
|
70
|
+
const cpus = machine.cpus || (process.availableParallelism ? process.availableParallelism() : 2);
|
|
71
|
+
const freeMemBytes = machine.freeMemBytes === undefined ? freemem() : machine.freeMemBytes;
|
|
72
|
+
const freeMemGb = freeMemBytes / 1e9;
|
|
73
|
+
const byCpu = Math.max(1, Math.floor(cpus / 2));
|
|
74
|
+
const byMem = Math.floor(freeMemGb / 1.5);
|
|
75
|
+
const recommended = Math.max(1, Math.min(4, byMem > 0 ? Math.min(byCpu, byMem) : 1));
|
|
76
|
+
return { cpus, freeMemGb: Number(freeMemGb.toFixed(1)), recommended };
|
|
77
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process exit codes, from spec 17 section 1.2. CI reads these, so they are a
|
|
3
|
+
* contract: add a code, never renumber one.
|
|
4
|
+
*/
|
|
5
|
+
export const ExitCode = Object.freeze({
|
|
6
|
+
OK: 0,
|
|
7
|
+
FAILURE: 1,
|
|
8
|
+
BUDGET_EXCEEDED: 2,
|
|
9
|
+
ENVIRONMENT: 3,
|
|
10
|
+
AUTH: 4,
|
|
11
|
+
MODEL: 5,
|
|
12
|
+
CANCELED: 10
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* An error whose message is meant for the person running the CLI and whose
|
|
17
|
+
* exit code is meaningful to CI. `bin/ravensight-playtest.js` prints the
|
|
18
|
+
* message and exits with the code, with no stack trace: a stack trace for
|
|
19
|
+
* "you are not logged in" is noise, and the difference between that and a
|
|
20
|
+
* genuine crash is exactly what this class records.
|
|
21
|
+
*/
|
|
22
|
+
export class CliError extends Error {
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} message
|
|
25
|
+
* @param {number} [exitCode]
|
|
26
|
+
* @param {{hint?: string, cause?: unknown}} [options]
|
|
27
|
+
*/
|
|
28
|
+
constructor(message, exitCode = ExitCode.FAILURE, options = {}) {
|
|
29
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
30
|
+
this.name = 'CliError';
|
|
31
|
+
this.exitCode = exitCode;
|
|
32
|
+
this.hint = options.hint || null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Not logged in, or the credential was refused. */
|
|
37
|
+
export function authError(message, hint) {
|
|
38
|
+
return new CliError(message, ExitCode.AUTH, { hint });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The machine or the repo is not set up for what was asked. */
|
|
42
|
+
export function environmentError(message, hint) {
|
|
43
|
+
return new CliError(message, ExitCode.ENVIRONMENT, { hint });
|
|
44
|
+
}
|
package/src/fsutil.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { mkdir, readFile, rename, writeFile, chmod } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Write a file so that a crash cannot leave a half written one behind: write a
|
|
9
|
+
* sibling temp file, fsync it through the stream close, then rename over the
|
|
10
|
+
* target. Rename is atomic within a filesystem, which is what makes the job
|
|
11
|
+
* journal safe to resume from after a kill.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} file
|
|
14
|
+
* @param {string} contents
|
|
15
|
+
* @param {{mode?: number}} [options]
|
|
16
|
+
*/
|
|
17
|
+
export async function writeFileAtomic(file, contents, options = {}) {
|
|
18
|
+
await mkdir(dirname(file), { recursive: true });
|
|
19
|
+
const temp = join(dirname(file), `.${Math.random().toString(36).slice(2)}.tmp`);
|
|
20
|
+
await writeFile(temp, contents, options.mode === undefined ? undefined : { mode: options.mode });
|
|
21
|
+
if (options.mode !== undefined) await chmod(temp, options.mode);
|
|
22
|
+
await rename(temp, file);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Read and parse JSON, answering `fallback` when the file is absent. A file
|
|
27
|
+
* that exists but is not JSON is an error, not a fallback: silently treating a
|
|
28
|
+
* corrupt config as "no config" would throw away a developer's settings.
|
|
29
|
+
* @param {string} file
|
|
30
|
+
* @param {unknown} [fallback]
|
|
31
|
+
* @returns {Promise<unknown>}
|
|
32
|
+
*/
|
|
33
|
+
export async function readJson(file, fallback = null) {
|
|
34
|
+
let text;
|
|
35
|
+
try {
|
|
36
|
+
text = await readFile(file, 'utf8');
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error.code === 'ENOENT') return fallback;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(text);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw new Error(`${file} is not valid JSON: ${error.message}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Write JSON atomically, with a trailing newline so the file is diffable. */
|
|
49
|
+
export async function writeJson(file, value, options = {}) {
|
|
50
|
+
await writeFileAtomic(file, `${JSON.stringify(value, null, 2)}\n`, options);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The sha256 of a file, hex, streamed so a 200 MB video does not go through
|
|
55
|
+
* memory. Hex because that is what the presign route wants; it converts to the
|
|
56
|
+
* base64 S3 needs on its own side.
|
|
57
|
+
* @param {string} file
|
|
58
|
+
* @returns {Promise<string>}
|
|
59
|
+
*/
|
|
60
|
+
export async function sha256File(file) {
|
|
61
|
+
const hash = createHash('sha256');
|
|
62
|
+
await pipeline(createReadStream(file), hash);
|
|
63
|
+
return hash.digest('hex');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The sha256 of a string or buffer, hex. */
|
|
67
|
+
export function sha256(value) {
|
|
68
|
+
return createHash('sha256').update(value).digest('hex');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** JSON.stringify with sorted keys, so two equal objects hash identically. */
|
|
72
|
+
export function stableStringify(value) {
|
|
73
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
|
|
74
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
75
|
+
const keys = Object.keys(value).sort();
|
|
76
|
+
return `{${keys.map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
|
|
77
|
+
}
|
package/src/godot.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { detectGodotBinary, isGodotProject, readProjectEngineVersion } from './run/drivers/godot-project.js';
|
|
2
|
+
import { compareVersions } from './version.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The Godot half of `check`.
|
|
6
|
+
*
|
|
7
|
+
* Godot detection lives in exactly one place, `src/run/drivers/godot-project.js`,
|
|
8
|
+
* which is the module the driver itself uses to find and launch a binary. So the
|
|
9
|
+
* binary `check` blesses is the same one a `godot_driver` run will spawn, which
|
|
10
|
+
* is the only property worth having here. The three helpers are re-exported so
|
|
11
|
+
* nothing outside the runner has to reach into `src/run/**`.
|
|
12
|
+
*
|
|
13
|
+
* All this file adds is the shape `check` prints, and one judgement the detector
|
|
14
|
+
* deliberately does not make: `detectGodotBinary` answers `matchesProject: false`
|
|
15
|
+
* both when the versions disagree and when one of them could not be read, which
|
|
16
|
+
* is right for "should I prefer this binary" and wrong for a doctor. `matches`
|
|
17
|
+
* here is three way, with `unknown` naming the missing side.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export { detectGodotBinary, isGodotProject, readProjectEngineVersion };
|
|
21
|
+
|
|
22
|
+
/** Just the major.minor of a version, which is what a project pins. */
|
|
23
|
+
export function majorMinor(version) {
|
|
24
|
+
if (!version) return null;
|
|
25
|
+
const parts = String(version).split('.');
|
|
26
|
+
return parts.length >= 2 ? `${parts[0]}.${parts[1]}` : parts[0];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Everything `check` needs to say about Godot: whether this repo is a Godot
|
|
31
|
+
* project, what version it asks for, which binary was found, and whether the
|
|
32
|
+
* two agree on major and minor.
|
|
33
|
+
*
|
|
34
|
+
* Nothing here is ever a failure, and nothing here throws. Godot is needed only
|
|
35
|
+
* for a `godot_driver` run, so a miss is a warning even in a Godot repo: a
|
|
36
|
+
* developer may be running a web build today. `detectGodotBinary` throws when it
|
|
37
|
+
* finds nothing usable, because for the driver that is fatal; for `check` it is
|
|
38
|
+
* a warning row, so the throw is caught and its message kept as the reason.
|
|
39
|
+
*
|
|
40
|
+
* `matches` is a three way answer on purpose. `true` and `false` mean the two
|
|
41
|
+
* versions were both read and did or did not agree; `null` means one of them
|
|
42
|
+
* could not be read, which is NOT the same as agreement and must not be
|
|
43
|
+
* reported as if it were. `unknown` says which side is missing, so `check` can
|
|
44
|
+
* print "could not read the engine version" instead of quietly passing.
|
|
45
|
+
*
|
|
46
|
+
* @param {{repoRoot?: string, godotPath?: string}} [options]
|
|
47
|
+
* @param {Object} [deps] test seam: `godotProject` replaces the three helpers
|
|
48
|
+
* @returns {Promise<{isProject: boolean, projectVersion: string|null,
|
|
49
|
+
* binary: {path: string|null, version: string|null, reason: string|null},
|
|
50
|
+
* matches: boolean|null, unknown: 'project'|'binary'|'both'|null}>}
|
|
51
|
+
*/
|
|
52
|
+
export async function inspectGodot(options = {}, deps = {}) {
|
|
53
|
+
const repoRoot = options.repoRoot || process.cwd();
|
|
54
|
+
const mod = deps.godotProject || { detectGodotBinary, isGodotProject, readProjectEngineVersion };
|
|
55
|
+
|
|
56
|
+
const isProject = Boolean(await mod.isGodotProject(repoRoot));
|
|
57
|
+
const projectVersion = isProject ? (await mod.readProjectEngineVersion(repoRoot)) || null : null;
|
|
58
|
+
|
|
59
|
+
let binary = { path: null, version: null, reason: null };
|
|
60
|
+
try {
|
|
61
|
+
// `explicitPath` is the parameter name, not `godotPath`: passing the wrong
|
|
62
|
+
// one silently ignores --godot-path and detects something else.
|
|
63
|
+
const found = await mod.detectGodotBinary({
|
|
64
|
+
explicitPath: options.godotPath,
|
|
65
|
+
projectDir: repoRoot
|
|
66
|
+
});
|
|
67
|
+
if (found) binary = { path: found.path || null, version: found.version || null, reason: null };
|
|
68
|
+
} catch (error) {
|
|
69
|
+
binary = { path: null, version: null, reason: error.message };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let matches = null;
|
|
73
|
+
let unknown = null;
|
|
74
|
+
if (isProject) {
|
|
75
|
+
if (projectVersion && binary.version) {
|
|
76
|
+
matches = compareVersions(majorMinor(projectVersion), majorMinor(binary.version)) === 0;
|
|
77
|
+
} else if (!projectVersion && !binary.version) {
|
|
78
|
+
unknown = 'both';
|
|
79
|
+
} else {
|
|
80
|
+
unknown = projectVersion ? 'binary' : 'project';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { isProject, projectVersion, binary, matches, unknown };
|
|
85
|
+
}
|