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
package/src/api/index.js
ADDED
|
@@ -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
|
+
}
|