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,191 @@
1
+ import { join } from 'node:path';
2
+ import { paths } from '../paths.js';
3
+ import { readJson, sha256, stableStringify, writeJson } from '../fsutil.js';
4
+ import { CliError } from '../errors.js';
5
+ import { ApiError } from '../api/errors.js';
6
+
7
+ /**
8
+ * The content pack cache.
9
+ *
10
+ * Packs are data (markdown, JSON), never code, and they are fetched from the
11
+ * server rather than shipped in the npm package so a persona or a schema can
12
+ * be improved without a release. That makes three things necessary:
13
+ *
14
+ * - a conditional GET (`If-None-Match`), so the common case is a 304 and not a
15
+ * megabyte on every run;
16
+ * - a sha256 over the cached body, verified on read, so a truncated write or a
17
+ * half finished download cannot be mistaken for content;
18
+ * - an offline path, because spec 17 requires a run to work after one online
19
+ * fetch, with the answer honestly labelled `stale`.
20
+ *
21
+ * The cache is keyed by API host (two servers can serve different packs under
22
+ * the same version) and by the request that produced it (game id and module
23
+ * list), so a bundle fetched for one game is never served for another.
24
+ */
25
+
26
+ const CACHE_FORMAT = 1;
27
+
28
+ function cacheFile({ apiUrl, gameId, modules }) {
29
+ const key = paths.cacheKey({ gameId: gameId || null, modules: modules || null });
30
+ return join(paths.packCacheDir(apiUrl), `bundle-${key}.json`);
31
+ }
32
+
33
+ /**
34
+ * @typedef {Object} PackResult
35
+ * @property {Object} bundle - the server's bundle body
36
+ * @property {string|null} etag
37
+ * @property {boolean} fromCache
38
+ * @property {string} fetchedAt - ISO, when the bundle was last actually fetched
39
+ * @property {boolean} stale - true when this process did not fetch it
40
+ */
41
+
42
+ function resultFrom(entry, { fromCache, stale }) {
43
+ return {
44
+ bundle: entry.bundle,
45
+ etag: entry.etag || null,
46
+ fromCache,
47
+ fetchedAt: entry.fetched_at,
48
+ stale
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Read the cache without touching the network. Answers null when there is no
54
+ * entry, and throws when there is one whose hash does not match its body: a
55
+ * corrupt cache has to be visible, because silently refetching would hide a
56
+ * disk that is losing writes.
57
+ *
58
+ * @param {{apiUrl: string, gameId?: string, modules?: string[]}} options
59
+ * @returns {Promise<PackResult|null>}
60
+ */
61
+ export async function cached(options) {
62
+ const file = cacheFile(options);
63
+ const entry = await readJson(file, null);
64
+ if (!entry || typeof entry !== 'object' || entry.cache_format !== CACHE_FORMAT) return null;
65
+ const expected = entry.sha256;
66
+ const actual = sha256(stableStringify(entry.bundle));
67
+ if (expected !== actual) {
68
+ throw new CliError(
69
+ `The cached content pack at ${file} does not match its checksum.`,
70
+ 1,
71
+ { hint: 'Delete that file and run the command again to refetch it.' }
72
+ );
73
+ }
74
+ return resultFrom(entry, { fromCache: true, stale: true });
75
+ }
76
+
77
+ async function writeCache(options, { bundle, etag }) {
78
+ const entry = {
79
+ cache_format: CACHE_FORMAT,
80
+ api_url: options.apiUrl,
81
+ game_id: options.gameId || null,
82
+ modules: options.modules || null,
83
+ etag: etag || null,
84
+ fetched_at: new Date().toISOString(),
85
+ sha256: sha256(stableStringify(bundle)),
86
+ bundle
87
+ };
88
+ await writeJson(cacheFile(options), entry);
89
+ return entry;
90
+ }
91
+
92
+ /**
93
+ * Fetch the bundle, ignoring any cached copy's ETag. Used by `check` and by an
94
+ * explicit pack refresh.
95
+ * @param {{api: Object, gameId?: string, modules?: string[]}} options
96
+ * @returns {Promise<PackResult>}
97
+ */
98
+ export async function sync({ api, gameId, modules }) {
99
+ const answer = await api.packs.bundle({ gameId, modules });
100
+ const entry = await writeCache({ apiUrl: api.apiUrl, gameId, modules }, {
101
+ bundle: answer.body,
102
+ etag: answer.etag
103
+ });
104
+ return resultFrom(entry, { fromCache: false, stale: false });
105
+ }
106
+
107
+ /**
108
+ * The pack a run needs.
109
+ *
110
+ * Online with a cache: send `If-None-Match` and reuse the cached body on a 304,
111
+ * which is the normal case and costs one small request.
112
+ * Online without a cache: fetch and write one.
113
+ * Offline, or a fetch that fails for a reason a retry would not fix: fall back
114
+ * to the cache and label it `stale: true`. With no cache at all there is
115
+ * nothing to fall back to, so it throws rather than running with no personas.
116
+ *
117
+ * An authentication or authorization failure is never treated as "offline":
118
+ * falling back to a cache on a 401 would let a revoked token keep working.
119
+ *
120
+ * @param {{api: Object, gameId?: string, modules?: string[], offline?: boolean}} options
121
+ * @returns {Promise<PackResult>}
122
+ */
123
+ export async function get({ api, gameId, modules, offline = false }) {
124
+ const key = { apiUrl: api.apiUrl, gameId, modules };
125
+ const existing = await cached(key);
126
+
127
+ if (offline) {
128
+ if (!existing) {
129
+ throw new CliError(
130
+ 'No cached content pack, and offline was requested.',
131
+ 1,
132
+ { hint: 'Run once while online, or drop the offline flag.' }
133
+ );
134
+ }
135
+ return existing;
136
+ }
137
+
138
+ try {
139
+ const answer = await api.packs.bundle({ gameId, modules, etag: existing ? existing.etag : null });
140
+ if (answer.status === 304 && existing) {
141
+ // Refresh `fetched_at` so "using cached content from N days ago" is
142
+ // about the last time the server confirmed it, not the last time the
143
+ // body changed.
144
+ const entry = await writeCache(key, { bundle: existing.bundle, etag: existing.etag });
145
+ return resultFrom(entry, { fromCache: true, stale: false });
146
+ }
147
+ const entry = await writeCache(key, { bundle: answer.body, etag: answer.etag });
148
+ return resultFrom(entry, { fromCache: false, stale: false });
149
+ } catch (error) {
150
+ // Any 5xx is the server being unavailable, and they are all the same thing
151
+ // from here: a 500 from a crashed handler is no more a reason to refuse to
152
+ // run than the 503 a load balancer sends. A 4xx is not: it is an answer
153
+ // about this request, and falling back to a cache on a 401 would let a
154
+ // revoked token keep working.
155
+ const fatal = error instanceof ApiError && !error.isNetwork && error.status < 500;
156
+ if (fatal || !existing) throw error;
157
+ return existing;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Verify a manifest's component ETags against a bundle we hold, which is what
163
+ * `check`'s "pack cache" row reports. Answers the components whose hash the
164
+ * bundle does not account for.
165
+ *
166
+ * The server's ETags are `sha256-<hex>` over each component, and the bundle
167
+ * carries the components themselves, so this is a real integrity check and not
168
+ * a version string comparison.
169
+ *
170
+ * @param {Object} manifest - the `GET /packs/manifest` body
171
+ * @param {Object} bundle - a bundle body
172
+ * @returns {{ok: boolean, checked: number, mismatched: string[]}}
173
+ */
174
+ export function verifyAgainstManifest(manifest, bundle) {
175
+ const components = Array.isArray(manifest && manifest.components) ? manifest.components : [];
176
+ const have = new Map();
177
+ for (const persona of bundle.personas || []) have.set(`personas/${persona.slug}`, persona.etag);
178
+ for (const skill of bundle.skills || []) have.set(`skills/${skill.name}`, skill.etag);
179
+ for (const schema of bundle.schemas || []) have.set(`schemas/${schema.name}`, schema.etag);
180
+
181
+ const mismatched = [];
182
+ let checked = 0;
183
+ for (const component of components) {
184
+ if (!have.has(component.name)) continue;
185
+ checked += 1;
186
+ if (have.get(component.name) !== component.etag) mismatched.push(component.name);
187
+ }
188
+ return { ok: mismatched.length === 0, checked, mismatched };
189
+ }
190
+
191
+ export const packs = { get, sync, cached, verifyAgainstManifest, cacheFile };
package/src/paths.js ADDED
@@ -0,0 +1,129 @@
1
+ import { homedir, tmpdir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+
5
+ export const DEFAULT_API_URL = 'https://api.ravensight.io';
6
+ const USER_DIR_NAME = 'ravensight-playtest';
7
+
8
+ /**
9
+ * Where the API is. `RAVENSIGHT_API_URL` overrides it, which is how a
10
+ * developer points at a local server and how the test suite points at an
11
+ * in-process one. A trailing slash is stripped so every path join is
12
+ * predictable.
13
+ * @returns {string}
14
+ */
15
+ export function resolveApiUrl() {
16
+ const raw = process.env.RAVENSIGHT_API_URL || DEFAULT_API_URL;
17
+ return raw.replace(/\/+$/, '');
18
+ }
19
+
20
+ /**
21
+ * The host part of an API URL, used as the cache and keychain key. Two
22
+ * deployments must not share a token or a pack cache, and the host is the
23
+ * only part of the URL that identifies one.
24
+ * @param {string} [apiUrl]
25
+ * @returns {string}
26
+ */
27
+ export function apiHost(apiUrl = resolveApiUrl()) {
28
+ try {
29
+ return new URL(apiUrl).host;
30
+ } catch {
31
+ return apiUrl;
32
+ }
33
+ }
34
+
35
+ function repoRoot(options = {}) {
36
+ return options.repoRoot || process.cwd();
37
+ }
38
+
39
+ /** Per user state, outside any repo: the credential fallback file and the caches. */
40
+ function userDir() {
41
+ return process.env.RAVENSIGHT_HOME || join(homedir(), `.${USER_DIR_NAME}`);
42
+ }
43
+
44
+ /**
45
+ * The OS cache directory for this CLI, per API host. Pack content is keyed by
46
+ * host because two servers can ship different packs under the same version.
47
+ * @param {string} [apiUrl]
48
+ * @returns {string}
49
+ */
50
+ function cacheDir(apiUrl = resolveApiUrl()) {
51
+ if (process.env.RAVENSIGHT_CACHE_DIR) {
52
+ return join(process.env.RAVENSIGHT_CACHE_DIR, apiHost(apiUrl));
53
+ }
54
+ const host = apiHost(apiUrl);
55
+ if (process.platform === 'darwin') {
56
+ return join(homedir(), 'Library', 'Caches', USER_DIR_NAME, host);
57
+ }
58
+ if (process.platform === 'win32') {
59
+ const base = process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local');
60
+ return join(base, USER_DIR_NAME, 'Cache', host);
61
+ }
62
+ const base = process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
63
+ return join(base, USER_DIR_NAME, host);
64
+ }
65
+
66
+ /**
67
+ * A filesystem safe fragment for a cache key that may contain anything: the
68
+ * modules list, a game id. Hashed rather than sanitized, so two keys that
69
+ * sanitize to the same name cannot collide.
70
+ * @param {unknown} value
71
+ * @returns {string}
72
+ */
73
+ function cacheKey(value) {
74
+ return createHash('sha256').update(JSON.stringify(value === undefined ? null : value)).digest('hex').slice(0, 16);
75
+ }
76
+
77
+ export const paths = {
78
+ repoRoot,
79
+ userDir,
80
+ cacheDir,
81
+ cacheKey,
82
+ tmpDir: () => tmpdir(),
83
+
84
+ /** `<repo>/.ravensight`, everything this CLI writes into the developer's repo. */
85
+ ravensightDir: (options = {}) => join(repoRoot(options), '.ravensight'),
86
+
87
+ /** The repo to game link, written by `init`. */
88
+ configFile: (options = {}) => join(repoRoot(options), '.ravensight', 'config.json'),
89
+
90
+ jobsDir: (options = {}) => join(repoRoot(options), '.ravensight', 'jobs'),
91
+ jobDir: (jobId, options = {}) => join(repoRoot(options), '.ravensight', 'jobs', jobId),
92
+
93
+ /** The resumable journal for one job. */
94
+ stateFile: (jobId, options = {}) => join(repoRoot(options), '.ravensight', 'jobs', jobId, 'state.json'),
95
+
96
+ /** Pending and finished uploads for one job, append only. */
97
+ queueFile: (jobId, options = {}) => join(repoRoot(options), '.ravensight', 'jobs', jobId, 'upload-queue.jsonl'),
98
+
99
+ /** Where a run's artifacts are written, and what `uploadRunDir` is pointed at. */
100
+ runDir: (jobId, runId, options = {}) =>
101
+ join(repoRoot(options), '.ravensight', 'jobs', jobId, 'runs', runId),
102
+
103
+ /** The 0600 credential file, used only when no OS keychain is available. */
104
+ credentialsFile: () => join(userDir(), 'credentials.json'),
105
+
106
+ /** The pack cache directory for one API host. */
107
+ packCacheDir: (apiUrl) => join(cacheDir(apiUrl), 'packs')
108
+ };
109
+
110
+ /**
111
+ * The run relative names the upload allowlist recognizes. Written here rather
112
+ * than in nine command modules, so a writer and the uploader cannot disagree
113
+ * about where a report goes.
114
+ */
115
+ export const RUN_FILES = Object.freeze({
116
+ reportMd: 'report.md',
117
+ reportJson: 'report.json',
118
+ usageJson: 'usage.json',
119
+ transcript: 'transcript.jsonl',
120
+ video: 'session.webm',
121
+ screenshotsDir: 'screenshots'
122
+ });
123
+
124
+ /** The job relative names, for artifacts that belong to a job and not a run. */
125
+ export const JOB_FILES = Object.freeze({
126
+ capabilityReport: 'capability-report.json',
127
+ aggregateMd: 'aggregate-report.md',
128
+ aggregateJson: 'aggregate-report.json'
129
+ });