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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +380 -0
  3. package/addons/ravensight_driver/driver.gd +836 -0
  4. package/addons/ravensight_driver/export_plugin.gd +51 -0
  5. package/addons/ravensight_driver/plugin.cfg +7 -0
  6. package/addons/ravensight_driver/plugin.gd +36 -0
  7. package/bin/ravensight-playtest.js +31 -0
  8. package/package.json +45 -0
  9. package/src/api/README.md +500 -0
  10. package/src/api/client.js +340 -0
  11. package/src/api/errors.js +115 -0
  12. package/src/api/http.js +194 -0
  13. package/src/api/index.js +107 -0
  14. package/src/auth/deviceCode.js +79 -0
  15. package/src/auth/keychain.js +159 -0
  16. package/src/auth/session.js +128 -0
  17. package/src/cli.js +335 -0
  18. package/src/commands/brief.js +303 -0
  19. package/src/commands/check.js +318 -0
  20. package/src/commands/fakeCore.js +379 -0
  21. package/src/commands/init.js +120 -0
  22. package/src/commands/login.js +90 -0
  23. package/src/commands/logout.js +70 -0
  24. package/src/commands/open.js +125 -0
  25. package/src/commands/profile.js +262 -0
  26. package/src/commands/resume.js +156 -0
  27. package/src/commands/run.js +1015 -0
  28. package/src/commands/upload.js +137 -0
  29. package/src/config.js +100 -0
  30. package/src/dashboard.js +97 -0
  31. package/src/detect.js +77 -0
  32. package/src/errors.js +44 -0
  33. package/src/fsutil.js +77 -0
  34. package/src/godot.js +85 -0
  35. package/src/packs/index.js +191 -0
  36. package/src/paths.js +129 -0
  37. package/src/run/aggregate.js +658 -0
  38. package/src/run/args.js +111 -0
  39. package/src/run/context.js +181 -0
  40. package/src/run/deps.js +184 -0
  41. package/src/run/drivers/driver.js +183 -0
  42. package/src/run/drivers/godot-observation.js +138 -0
  43. package/src/run/drivers/godot-project.js +475 -0
  44. package/src/run/drivers/godot-rpc.js +225 -0
  45. package/src/run/drivers/godot.js +587 -0
  46. package/src/run/drivers/index.js +52 -0
  47. package/src/run/drivers/web.js +385 -0
  48. package/src/run/exit.js +21 -0
  49. package/src/run/heartbeat.js +131 -0
  50. package/src/run/index.js +31 -0
  51. package/src/run/json.js +56 -0
  52. package/src/run/model.js +384 -0
  53. package/src/run/paths.js +88 -0
  54. package/src/run/personaLoop.js +871 -0
  55. package/src/run/profile.js +214 -0
  56. package/src/run/regenerate.js +149 -0
  57. package/src/run/repoTools.js +286 -0
  58. package/src/run/report.js +222 -0
  59. package/src/run/resume.js +272 -0
  60. package/src/run/secretScan.js +171 -0
  61. package/src/run/state.js +198 -0
  62. package/src/run/synthetic.js +206 -0
  63. package/src/run/tools.js +344 -0
  64. package/src/run/transcript.js +93 -0
  65. package/src/run/usage.js +115 -0
  66. package/src/state/index.js +105 -0
  67. package/src/states.js +104 -0
  68. package/src/ui/index.js +195 -0
  69. package/src/upload/allowlist.js +116 -0
  70. package/src/upload/index.js +467 -0
  71. package/src/upload/queue.js +114 -0
  72. package/src/version.js +63 -0
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The runner facing interface. See ./README.md for the contract.
3
+ *
4
+ * This module is the package's public entry point and the only thing
5
+ * `src/run/*` and `src/run/drivers/*` may import from outside their own
6
+ * directory. Everything else under `src/` is implementation detail. Adding an
7
+ * export here is a deliberate act; reaching past it is not.
8
+ */
9
+
10
+ export { createClient } from './client.js';
11
+ export { ApiError, isRetryable, codeRetryLimit, parseRetryAfter } from './errors.js';
12
+ export { CliError, ExitCode, authError, environmentError } from '../errors.js';
13
+ export { resolveApiUrl, apiHost, paths, RUN_FILES, JOB_FILES, DEFAULT_API_URL } from '../paths.js';
14
+ export {
15
+ resolveToken,
16
+ looksLikeCliToken,
17
+ looksLikeIngestKey,
18
+ handleUnauthorized,
19
+ withAuthHandling,
20
+ TOKEN_PREFIX
21
+ } from '../auth/session.js';
22
+ export { loadConfig, requireConfig, saveConfig, defaultConfig, resolveGameId } from '../config.js';
23
+ export { packs } from '../packs/index.js';
24
+ export {
25
+ uploadRunDir,
26
+ finalizeRun,
27
+ planRunDir,
28
+ walkRunDir,
29
+ putFile,
30
+ taggingHeader,
31
+ unverifiedPaths
32
+ } from '../upload/index.js';
33
+ export {
34
+ contentTypeFor,
35
+ isAllowedPath,
36
+ JOB_UPLOAD_PATHS,
37
+ RETENTION_TAGGED_PATHS,
38
+ VIDEO_PATHS,
39
+ TRANSCRIPT_PATH,
40
+ MAX_FILES_PER_RUN,
41
+ MAX_RUN_BYTES,
42
+ MAX_VIDEO_BYTES
43
+ } from '../upload/allowlist.js';
44
+ export { readQueue, pending as pendingUploads, STATUS as UPLOAD_STATUS } from '../upload/queue.js';
45
+ export {
46
+ readState,
47
+ writeState,
48
+ updateState,
49
+ listStates,
50
+ newState,
51
+ stateFileFor,
52
+ STATE_VERSION
53
+ } from '../state/index.js';
54
+ export { ui } from '../ui/index.js';
55
+ export {
56
+ MODULES,
57
+ DRIVERS,
58
+ JOB_STATES,
59
+ JOB_TERMINAL_STATES,
60
+ RUN_STATES,
61
+ RUN_TERMINAL_STATES,
62
+ RUN_TRANSITIONS,
63
+ isJobTerminal,
64
+ isRunTerminal,
65
+ canTransitionRun
66
+ } from '../states.js';
67
+ export { dashboardUrl, gameLink, jobLink, reviewLink, briefLink, trustLink, openInBrowser } from '../dashboard.js';
68
+ export { inspectGodot } from '../godot.js';
69
+ export { recommendConcurrency, freeBytes, probe } from '../detect.js';
70
+ export { CLI_VERSION, CLIENT_NAME, compareVersions } from '../version.js';
71
+ export { sha256, sha256File, readJson, writeJson, writeFileAtomic, stableStringify } from '../fsutil.js';
72
+
73
+ import { createClient } from './client.js';
74
+ import { resolveApiUrl } from '../paths.js';
75
+ import { resolveToken } from '../auth/session.js';
76
+ import { loadConfig } from '../config.js';
77
+
78
+ /**
79
+ * Resolve everything a command needs in one call: the API URL, the credential,
80
+ * the repo's config, and a client built from them.
81
+ *
82
+ * Every command starts this way, so the order of precedence (flag, then env,
83
+ * then config, then default) exists once rather than nine times.
84
+ *
85
+ * @param {Object} [options]
86
+ * @param {string} [options.apiUrl]
87
+ * @param {string} [options.repoRoot]
88
+ * @param {boolean} [options.requireAuth] - throw when there is no credential
89
+ * @param {Object} [options.clientOptions] - passed through to createClient
90
+ * @returns {Promise<{api: Object, apiUrl: string, token: string|null,
91
+ * tokenSource: string|null, host: string, config: Object|null, repoRoot: string}>}
92
+ */
93
+ export async function createContext(options = {}) {
94
+ const apiUrl = options.apiUrl || resolveApiUrl();
95
+ const { token, source, host } = await resolveToken({ apiUrl, required: options.requireAuth });
96
+ const config = await loadConfig({ repoRoot: options.repoRoot });
97
+ const api = createClient({ apiUrl, token, ...(options.clientOptions || {}) });
98
+ return {
99
+ api,
100
+ apiUrl,
101
+ token,
102
+ tokenSource: source,
103
+ host,
104
+ config,
105
+ repoRoot: options.repoRoot || process.cwd()
106
+ };
107
+ }
@@ -0,0 +1,79 @@
1
+ import { ApiError } from '../api/errors.js';
2
+ import { authError } from '../errors.js';
3
+
4
+ const MIN_INTERVAL_SECONDS = 1;
5
+ const MAX_INTERVAL_SECONDS = 60;
6
+ /** How much the interval grows when the server says slow_down. */
7
+ const SLOW_DOWN_BUMP_SECONDS = 5;
8
+
9
+ /**
10
+ * Poll `POST /playtest/cli/token` until the person approves, denies, or the
11
+ * device code expires.
12
+ *
13
+ * The four non-200 answers mean four different things and the loop treats them
14
+ * differently, which is the whole reason the server bothers to distinguish
15
+ * them:
16
+ *
17
+ * - 428 `authorization_pending`: nobody has acted yet. Wait and ask again.
18
+ * - 429 `slow_down`: we polled sooner than `interval`. Wait longer, and grow
19
+ * the interval so it does not happen again. This is NOT a generic rate
20
+ * limit retry, so it is handled here rather than inside the HTTP layer.
21
+ * - 400 `expired_token`: the code is gone, for any reason. Stop.
22
+ * - 403 `access_denied`: a person said no. Stop, and say so differently.
23
+ *
24
+ * @param {Object} options
25
+ * @param {import('../api/client.js').createClient} options.api
26
+ * @param {string} options.deviceCode
27
+ * @param {number} [options.intervalSeconds] - the server's suggested interval
28
+ * @param {number} [options.expiresInSeconds]
29
+ * @param {(state: {waited: number}) => void} [options.onPoll]
30
+ * @param {(ms: number) => Promise<void>} [options.sleep]
31
+ * @param {() => number} [options.now]
32
+ * @returns {Promise<{token: string, games: Array, scopes: Array, expires_at: string}>}
33
+ */
34
+ export async function pollForToken(options) {
35
+ const {
36
+ api,
37
+ deviceCode,
38
+ intervalSeconds = 5,
39
+ expiresInSeconds = 600,
40
+ onPoll,
41
+ sleep = ms => new Promise(resolve => setTimeout(resolve, ms)),
42
+ now = () => Date.now()
43
+ } = options;
44
+
45
+ let interval = Math.min(Math.max(intervalSeconds, MIN_INTERVAL_SECONDS), MAX_INTERVAL_SECONDS);
46
+ const startedAt = now();
47
+ const deadline = startedAt + expiresInSeconds * 1000;
48
+
49
+ for (;;) {
50
+ await sleep(interval * 1000);
51
+ if (onPoll) onPoll({ waited: Math.round((now() - startedAt) / 1000) });
52
+
53
+ try {
54
+ return await api.cli.pollToken(deviceCode);
55
+ } catch (error) {
56
+ if (!(error instanceof ApiError)) throw error;
57
+
58
+ if (error.code === 'authorization_pending') {
59
+ // Keep waiting. The deadline below is what ends the loop.
60
+ } else if (error.code === 'slow_down') {
61
+ interval = Math.min(interval + SLOW_DOWN_BUMP_SECONDS, MAX_INTERVAL_SECONDS);
62
+ } else if (error.code === 'access_denied') {
63
+ throw authError('That login was denied on the dashboard.');
64
+ } else if (error.code === 'expired_token') {
65
+ throw authError('That login expired before it was approved.', 'Run login again.');
66
+ } else if (error.isNetwork) {
67
+ // A dropped connection mid login is not a failed login. Back off once
68
+ // and keep polling until the code itself expires.
69
+ interval = Math.min(interval + SLOW_DOWN_BUMP_SECONDS, MAX_INTERVAL_SECONDS);
70
+ } else {
71
+ throw error;
72
+ }
73
+ }
74
+
75
+ if (now() >= deadline) {
76
+ throw authError('That login expired before it was approved.', 'Run login again.');
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,159 @@
1
+ import { mkdir } from 'node:fs/promises';
2
+ import { unlink } from 'node:fs/promises';
3
+ import { paths } from '../paths.js';
4
+ import { readJson, writeJson } from '../fsutil.js';
5
+
6
+ export const KEYCHAIN_SERVICE = 'ravensight-playtest';
7
+ const FILE_MODE = 0o600;
8
+
9
+ let keytarModule;
10
+ let keytarTried = false;
11
+
12
+ /**
13
+ * `keytar` is a native module, and every realistic reason it is missing is a
14
+ * reason the CLI still has to work: it is an optional dependency whose install
15
+ * script may not have run, a headless Linux box with no libsecret, or a CI
16
+ * container. So it is required lazily, once, inside a try, and a failure means
17
+ * "use the file backend" rather than "crash".
18
+ *
19
+ * @returns {Promise<object|null>}
20
+ */
21
+ async function keytar() {
22
+ if (keytarTried) return keytarModule || null;
23
+ keytarTried = true;
24
+ try {
25
+ const mod = await import('keytar');
26
+ const candidate = mod.default || mod;
27
+ // An import that resolves but has no getPassword is a broken build, not a
28
+ // working backend: treat it exactly like an absent module.
29
+ keytarModule = typeof candidate.getPassword === 'function' ? candidate : null;
30
+ } catch {
31
+ keytarModule = null;
32
+ }
33
+ return keytarModule;
34
+ }
35
+
36
+ /** Test seam: forget the cached probe so a stub can be installed. */
37
+ export function resetKeychainProbe(stub) {
38
+ keytarTried = stub !== undefined;
39
+ keytarModule = stub === undefined ? undefined : stub;
40
+ }
41
+
42
+ /**
43
+ * Which backend is in use, for `check` to report. The OS keychain when keytar
44
+ * loaded, otherwise the 0600 file, and `env` is not a backend at all: it is a
45
+ * credential this process was handed and will not store.
46
+ * @returns {Promise<'keychain'|'file'>}
47
+ */
48
+ export async function backendName() {
49
+ return (await keytar()) ? 'keychain' : 'file';
50
+ }
51
+
52
+ async function fileStore() {
53
+ const value = await readJson(paths.credentialsFile(), {});
54
+ return value && typeof value === 'object' ? value : {};
55
+ }
56
+
57
+ async function writeFileStore(store) {
58
+ await mkdir(paths.userDir(), { recursive: true, mode: 0o700 });
59
+ await writeJson(paths.credentialsFile(), store, { mode: FILE_MODE });
60
+ }
61
+
62
+ /**
63
+ * The stored token for one API host, and which backend answered.
64
+ *
65
+ * The backend is reported rather than inferred, because the two are genuinely
66
+ * independent: `backendName()` says which store this machine WOULD use, and
67
+ * this says which one the token actually came out of. A token written to the
68
+ * file when the keychain was locked, and read back later when it is not, comes
69
+ * from the file while the backend is the keychain, and `check` should say the
70
+ * true thing.
71
+ *
72
+ * Hosts are separate accounts: a token minted against a staging server must
73
+ * never be sent to production.
74
+ *
75
+ * @param {string} host
76
+ * @returns {Promise<{token: string|null, backend: 'keychain'|'file'|null}>}
77
+ */
78
+ export async function readStoredToken(host) {
79
+ const kt = await keytar();
80
+ if (kt) {
81
+ try {
82
+ const found = await kt.getPassword(KEYCHAIN_SERVICE, host);
83
+ if (found) return { token: found, backend: 'keychain' };
84
+ } catch {
85
+ // A locked or unavailable keychain falls through to the file, rather than
86
+ // failing a command that could have worked.
87
+ }
88
+ }
89
+ const store = await fileStore();
90
+ const entry = store[host];
91
+ return typeof entry === 'string'
92
+ ? { token: entry, backend: 'file' }
93
+ : { token: null, backend: null };
94
+ }
95
+
96
+ /**
97
+ * The stored token for one API host, or null.
98
+ * @param {string} host
99
+ * @returns {Promise<string|null>}
100
+ */
101
+ export async function getStoredToken(host) {
102
+ return (await readStoredToken(host)).token;
103
+ }
104
+
105
+ /**
106
+ * Store a token for one host.
107
+ * @param {string} host
108
+ * @param {string} token
109
+ * @returns {Promise<{backend: 'keychain'|'file'}>}
110
+ */
111
+ export async function setStoredToken(host, token) {
112
+ const kt = await keytar();
113
+ if (kt) {
114
+ try {
115
+ await kt.setPassword(KEYCHAIN_SERVICE, host, token);
116
+ return { backend: 'keychain' };
117
+ } catch {
118
+ // Fall through: a token we cannot store is a login the developer has to
119
+ // repeat, which is worse than a file with 0600 on it.
120
+ }
121
+ }
122
+ const store = await fileStore();
123
+ store[host] = token;
124
+ await writeFileStore(store);
125
+ return { backend: 'file' };
126
+ }
127
+
128
+ /**
129
+ * Forget the token for one host. Answers whether anything was there, so
130
+ * `logout` can tell "revoked and cleared" from "nothing to clear".
131
+ * @param {string} host
132
+ * @returns {Promise<boolean>}
133
+ */
134
+ export async function deleteStoredToken(host) {
135
+ let removed = false;
136
+ const kt = await keytar();
137
+ if (kt) {
138
+ try {
139
+ removed = Boolean(await kt.deletePassword(KEYCHAIN_SERVICE, host));
140
+ } catch {
141
+ removed = false;
142
+ }
143
+ }
144
+ const store = await fileStore();
145
+ if (Object.prototype.hasOwnProperty.call(store, host)) {
146
+ delete store[host];
147
+ if (Object.keys(store).length === 0) {
148
+ try {
149
+ await unlink(paths.credentialsFile());
150
+ } catch (error) {
151
+ if (error.code !== 'ENOENT') throw error;
152
+ }
153
+ } else {
154
+ await writeFileStore(store);
155
+ }
156
+ removed = true;
157
+ }
158
+ return removed;
159
+ }
@@ -0,0 +1,128 @@
1
+ import { apiHost, resolveApiUrl } from '../paths.js';
2
+ import { authError } from '../errors.js';
3
+ import { ApiError } from '../api/errors.js';
4
+ import { deleteStoredToken, readStoredToken } from './keychain.js';
5
+ import { ui } from '../ui/index.js';
6
+
7
+ export const TOKEN_PREFIX = 'gt_cli_';
8
+
9
+ /**
10
+ * Find the credential to use, and say where it came from.
11
+ *
12
+ * `RAVENSIGHT_TOKEN` wins over the keychain, which is what makes CI work: a
13
+ * runner in a container has no keychain, and an explicit env var must not be
14
+ * quietly overridden by whatever happens to be stored on a developer machine
15
+ * that shares a home directory with it.
16
+ *
17
+ * `source` is the real origin, not a label: `'env'`, or `'keychain'` or
18
+ * `'file'` depending on which store actually answered. Calling a token from the
19
+ * fallback file a keychain token would make `check`'s own report wrong, which is
20
+ * the one place a developer looks to find out why a credential is not being
21
+ * picked up.
22
+ *
23
+ * @param {{apiUrl?: string, required?: boolean}} [options]
24
+ * @returns {Promise<{token: string|null, source: 'env'|'keychain'|'file'|null, host: string}>}
25
+ */
26
+ export async function resolveToken(options = {}) {
27
+ const apiUrl = options.apiUrl || resolveApiUrl();
28
+ const host = apiHost(apiUrl);
29
+
30
+ const fromEnv = process.env.RAVENSIGHT_TOKEN;
31
+ if (fromEnv && fromEnv.trim()) {
32
+ return { token: fromEnv.trim(), source: 'env', host };
33
+ }
34
+
35
+ const stored = await readStoredToken(host);
36
+ if (stored.token) return { token: stored.token, source: stored.backend, host };
37
+
38
+ if (options.required) {
39
+ throw authError(
40
+ `Not logged in to ${host}.`,
41
+ 'Run ravensight-playtest login, or set RAVENSIGHT_TOKEN for CI.'
42
+ );
43
+ }
44
+ return { token: null, source: null, host };
45
+ }
46
+
47
+ /**
48
+ * Whether a string looks like a CLI token. Only ever used to warn: the server
49
+ * decides what a valid credential is, and a client side shape check that
50
+ * rejected a token the server would have accepted would be a bug that no
51
+ * amount of logging in could fix.
52
+ * @param {string|null|undefined} token
53
+ * @returns {boolean}
54
+ */
55
+ export function looksLikeCliToken(token) {
56
+ return typeof token === 'string' && token.startsWith(TOKEN_PREFIX);
57
+ }
58
+
59
+ /**
60
+ * Whether a string looks like an ingest key. Worth its own check because
61
+ * pasting one in is a mistake people actually make, and the server answers 403
62
+ * `ingest_key_not_allowed` rather than 401, which reads like a permissions
63
+ * problem instead of "wrong kind of key".
64
+ * @param {string|null|undefined} token
65
+ * @returns {boolean}
66
+ */
67
+ export function looksLikeIngestKey(token) {
68
+ return typeof token === 'string' && token.startsWith('gt_live_');
69
+ }
70
+
71
+ /**
72
+ * What to do when the server refuses the credential.
73
+ *
74
+ * A 401 means the token this machine holds is no longer a credential: revoked
75
+ * from the dashboard, expired past its sliding TTL, or minted against another
76
+ * deployment. Keeping it means every later command fails the same way, and the
77
+ * developer has to work out that `logout` is the fix for a problem that never
78
+ * said "log out". So a stored token is deleted here, and the message says it
79
+ * was, which turns an unexplained 401 into one obvious next step.
80
+ *
81
+ * A token from `RAVENSIGHT_TOKEN` is NEVER deleted, for the same reason `logout`
82
+ * leaves it alone: it is the caller's, it is probably a CI secret shared with
83
+ * other jobs, and this process has no business deciding it is finished with. It
84
+ * is only reported.
85
+ *
86
+ * Every command funnels its API work through this, so the clearing happens in
87
+ * one place rather than in nine catch blocks that could each forget.
88
+ *
89
+ * @param {{tokenSource?: string, host?: string, apiUrl?: string}} context
90
+ * @param {unknown} error
91
+ * @returns {Promise<Error>} the error to throw, unchanged when it was not a 401
92
+ */
93
+ export async function handleUnauthorized(context, error) {
94
+ if (!(error instanceof ApiError) || error.status !== 401) return error;
95
+
96
+ const source = context && context.tokenSource;
97
+ const host = (context && context.host) || apiHost(context && context.apiUrl);
98
+
99
+ if (source === 'env') {
100
+ return authError(
101
+ `${host} refused the token in RAVENSIGHT_TOKEN.`,
102
+ 'Mint a new CLI token and update that secret. The variable was left alone.'
103
+ );
104
+ }
105
+
106
+ const cleared = await deleteStoredToken(host);
107
+ if (cleared) ui.warn(`${host} refused this machine's token, so it has been cleared.`);
108
+ return authError(
109
+ cleared ? `${host} no longer accepts the stored token.` : `${host} refused the credential.`,
110
+ 'Run ravensight-playtest login.'
111
+ );
112
+ }
113
+
114
+ /**
115
+ * Run API work with the 401 handling above applied. The shape every command
116
+ * uses, so no command has to remember the catch.
117
+ *
118
+ * @param {Object} context
119
+ * @param {Function} fn
120
+ * @returns {Promise<unknown>}
121
+ */
122
+ export async function withAuthHandling(context, fn) {
123
+ try {
124
+ return await fn();
125
+ } catch (error) {
126
+ throw await handleUnauthorized(context, error);
127
+ }
128
+ }