@joystack/cli 0.0.0-stage → 0.1.43
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/CHANGELOG.md +738 -0
- package/LICENSE +202 -0
- package/README.md +21 -2
- package/bin/joystack.mjs +300 -0
- package/lib/agent-session.mjs +52 -0
- package/lib/agent-surface.mjs +333 -0
- package/lib/assets-json.mjs +205 -0
- package/lib/assets-page.mjs +184 -0
- package/lib/assets-sheet.mjs +365 -0
- package/lib/build-id.mjs +130 -0
- package/lib/bundle-primitives.mjs +123 -0
- package/lib/changelog.mjs +193 -0
- package/lib/config.mjs +56 -0
- package/lib/create.mjs +241 -0
- package/lib/dev.mjs +435 -0
- package/lib/engine.mjs +198 -0
- package/lib/feedback-sync.mjs +231 -0
- package/lib/feedback.mjs +209 -0
- package/lib/freeze.mjs +108 -0
- package/lib/game-files.mjs +99 -0
- package/lib/history.mjs +612 -0
- package/lib/inbox-cmd.mjs +57 -0
- package/lib/inject.mjs +250 -0
- package/lib/joystack-node-doors.mjs +230 -0
- package/lib/lane.mjs +141 -0
- package/lib/legacy-names.mjs +246 -0
- package/lib/lint.mjs +292 -0
- package/lib/login.mjs +134 -0
- package/lib/model-bake-cli.mjs +17 -0
- package/lib/model-bake.mjs +869 -0
- package/lib/npm.mjs +38 -0
- package/lib/ports.mjs +234 -0
- package/lib/primitives.mjs +393 -0
- package/lib/printable.mjs +12 -0
- package/lib/process-tree.mjs +30 -0
- package/lib/project.mjs +84 -0
- package/lib/publish.mjs +179 -0
- package/lib/replay-cmd.mjs +426 -0
- package/lib/replay-export.mjs +1379 -0
- package/lib/replay-list.mjs +308 -0
- package/lib/replay-profile.mjs +467 -0
- package/lib/replay-store.mjs +719 -0
- package/lib/room-reload.mjs +168 -0
- package/lib/room-server.mjs +539 -0
- package/lib/self-update.mjs +162 -0
- package/lib/shadow-resolve.mjs +85 -0
- package/lib/share.mjs +149 -0
- package/lib/smoke-gpu-preload.mjs +91 -0
- package/lib/source-path.mjs +33 -0
- package/lib/static-server.mjs +1238 -0
- package/lib/test-cmd.mjs +697 -0
- package/lib/test-policy.mjs +157 -0
- package/lib/test-reporter.mjs +177 -0
- package/lib/test-selection-cli.mjs +18 -0
- package/lib/update.mjs +299 -0
- package/package.json +27 -4
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// The names the CLI still accepts from games written for the Looop engine
|
|
2
|
+
// (engine 0.2.35, CLI 0.1.43 and earlier). Joystack is the same engine under a
|
|
3
|
+
// new name; a game written against the old one keeps working unchanged, so
|
|
4
|
+
// every old name the CLI reads is mapped onto its new one here, and nowhere
|
|
5
|
+
// else. New code and new games use only the joystack names.
|
|
6
|
+
//
|
|
7
|
+
// imports `looop`, `looop/<sub>` → `joystack`, `joystack/<sub>`
|
|
8
|
+
// `@looop-games/engine/<path>` → `@joystack/engine/<path>`
|
|
9
|
+
// `@looop-games/cli/<path>` → `@joystack/cli/<path>`
|
|
10
|
+
// environment LOOOP_<NAME> is read when JOYSTACK_<NAME> is unset, and a
|
|
11
|
+
// variable the CLI hands a game's tests is set under both names
|
|
12
|
+
// page globals the dev server sets `LOOOP_IDENTITY` and the other globals
|
|
13
|
+
// the production serve path sets under both names
|
|
14
|
+
// package.json the `"looop": { … }` block, when there is no `"joystack"` block
|
|
15
|
+
// test policy looop.tests.json, when there is no joystack.tests.json
|
|
16
|
+
// state folder a game's `.looop/`, when it has no `.joystack/`
|
|
17
|
+
// dev routes `/__looop/<route>` → `/__joystack/<route>`
|
|
18
|
+
// data formats recordings tagged `looop-…/1` are read as `joystack-…/1`
|
|
19
|
+
// AGENTS.md the `looop:managed` markers are read as `joystack:managed`
|
|
20
|
+
// build ids hashed from the version line the old engine used (BUILD_ID_FORMAT)
|
|
21
|
+
import { existsSync, lstatSync, mkdirSync, readlinkSync, rmSync, symlinkSync } from 'node:fs';
|
|
22
|
+
import { dirname, join, relative } from 'node:path';
|
|
23
|
+
|
|
24
|
+
const OLD = 'looop';
|
|
25
|
+
const NEW = 'joystack';
|
|
26
|
+
|
|
27
|
+
// ── hash inputs ──────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The version line a build id's hash starts from. A hash input keeps its exact
|
|
31
|
+
* bytes: a different line would give every game a new build id, and every
|
|
32
|
+
* recording made before would read as made on another build.
|
|
33
|
+
*/
|
|
34
|
+
export const BUILD_ID_FORMAT = 'looop-build/1\n';
|
|
35
|
+
|
|
36
|
+
// ── imports ──────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
// Engine releases up to 0.2.35 install under this package name. This CLI does
|
|
39
|
+
// not run them; it names the problem instead of failing on a missing folder.
|
|
40
|
+
export const LEGACY_ENGINE_PACKAGE = '@looop-games/engine';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Make `node_modules/@looop-games/engine` a link to the installed engine.
|
|
44
|
+
*
|
|
45
|
+
* A game written for engine 0.2.35 or earlier can name files inside the engine
|
|
46
|
+
* package by path rather than through a door: a relative import such as
|
|
47
|
+
* `../../node_modules/@looop-games/engine/shared/ui/room/entity/frac.js`, its
|
|
48
|
+
* eslint.config.js importing `@looop-games/engine/eslint/index.js`, or a path in
|
|
49
|
+
* its handbook. Every one of those files is still in the engine under the same
|
|
50
|
+
* path, so the old folder name only has to lead to it. Whatever sits at the old
|
|
51
|
+
* path is replaced: it is an engine npm installed without saving, and the game
|
|
52
|
+
* now runs on the one at `engineDir`.
|
|
53
|
+
*
|
|
54
|
+
* @returns {boolean} whether the link is in place afterwards
|
|
55
|
+
*/
|
|
56
|
+
export function leaveLegacyEnginePath(projectDir, engineDir) {
|
|
57
|
+
const at = join(projectDir, 'node_modules', ...LEGACY_ENGINE_PACKAGE.split('/'));
|
|
58
|
+
const target = relative(dirname(at), engineDir);
|
|
59
|
+
try {
|
|
60
|
+
let stat = null;
|
|
61
|
+
try { stat = lstatSync(at); } catch { /* nothing there yet */ }
|
|
62
|
+
if (stat?.isSymbolicLink() && readlinkSync(at) === target) return true;
|
|
63
|
+
if (stat) rmSync(at, { recursive: true, force: true });
|
|
64
|
+
mkdirSync(dirname(at), { recursive: true });
|
|
65
|
+
symlinkSync(target, at, process.platform === 'win32' ? 'junction' : 'dir');
|
|
66
|
+
return true;
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The CLI's package name before the rename. A game that still depends on it is
|
|
73
|
+
// moved onto @joystack/cli by `joystack update` (self-update.mjs).
|
|
74
|
+
export const LEGACY_CLI_PACKAGE = '@looop-games/cli';
|
|
75
|
+
|
|
76
|
+
const PACKAGE_PREFIXES = [
|
|
77
|
+
['@looop-games/engine', '@joystack/engine'],
|
|
78
|
+
['@looop-games/cli', '@joystack/cli'],
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The specifier a game's import means: an old door or package path mapped onto
|
|
83
|
+
* its joystack name by prefix, so a door added later is covered with no edit
|
|
84
|
+
* here. Anything else comes back unchanged.
|
|
85
|
+
*/
|
|
86
|
+
export function canonicalSpecifier(specifier) {
|
|
87
|
+
if (typeof specifier !== 'string') return specifier;
|
|
88
|
+
if (specifier === OLD) return NEW;
|
|
89
|
+
if (specifier.startsWith(`${OLD}/`)) return NEW + specifier.slice(OLD.length);
|
|
90
|
+
for (const [from, to] of PACKAGE_PREFIXES) {
|
|
91
|
+
if (specifier === from || specifier.startsWith(`${from}/`)) return to + specifier.slice(from.length);
|
|
92
|
+
}
|
|
93
|
+
return specifier;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A browser import map extended with the old name of every joystack door it maps. */
|
|
97
|
+
export function withLegacyDoors(imports) {
|
|
98
|
+
const out = { ...imports };
|
|
99
|
+
for (const [key, url] of Object.entries(imports)) {
|
|
100
|
+
if (key === NEW || key.startsWith(`${NEW}/`)) out[OLD + key.slice(NEW.length)] = url;
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const IMPORT_MAP_RE = /(<script type="importmap">)([\s\S]*?)(<\/script>)/;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* An HTML page with its own import map (the Assets page carries one) and the
|
|
109
|
+
* old name of every door that map names added to it, so a game module the page
|
|
110
|
+
* imports resolves an old door too.
|
|
111
|
+
*/
|
|
112
|
+
export function withLegacyImportMap(html) {
|
|
113
|
+
const m = IMPORT_MAP_RE.exec(html);
|
|
114
|
+
if (!m) return html;
|
|
115
|
+
let map;
|
|
116
|
+
try { map = JSON.parse(m[2]); } catch { return html; }
|
|
117
|
+
if (!map || typeof map.imports !== 'object') return html;
|
|
118
|
+
const extended = JSON.stringify({ ...map, imports: withLegacyDoors(map.imports) }, null, 2);
|
|
119
|
+
return html.slice(0, m.index) + m[1] + '\n' + extended + '\n' + m[3] + html.slice(m.index + m[0].length);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The esbuild `onResolve` filter matching a door specifier under either name. */
|
|
123
|
+
export const DOOR_FILTER = new RegExp(`^(?:${NEW}|${OLD})(\\/.*)?$`);
|
|
124
|
+
|
|
125
|
+
/** The esbuild `onResolve` filter matching the engine package under either name. */
|
|
126
|
+
export const ENGINE_PACKAGE_FILTER = /^@(?:joystack|looop-games)\/engine(\/|$)/;
|
|
127
|
+
|
|
128
|
+
// ── environment ──────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
const legacyEnvName = (name) => (name.startsWith(`${NEW.toUpperCase()}_`)
|
|
131
|
+
? `${OLD.toUpperCase()}_${name.slice(NEW.length + 1)}`
|
|
132
|
+
: null);
|
|
133
|
+
|
|
134
|
+
/** `JOYSTACK_<NAME>` from `env`, or `LOOOP_<NAME>` when the joystack one is unset. */
|
|
135
|
+
export function readEnv(name, env = process.env) {
|
|
136
|
+
if (env[name] !== undefined) return env[name];
|
|
137
|
+
const old = legacyEnvName(name);
|
|
138
|
+
return old ? env[old] : undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Variables the CLI hands to a game's own processes (its tests and smokes), set
|
|
143
|
+
* under both names: a test written for the old engine reads `LOOOP_<NAME>`.
|
|
144
|
+
*/
|
|
145
|
+
export function withLegacyEnv(vars) {
|
|
146
|
+
const out = { ...vars };
|
|
147
|
+
for (const [name, value] of Object.entries(vars)) {
|
|
148
|
+
const old = legacyEnvName(name);
|
|
149
|
+
if (old && !(old in vars)) out[old] = value;
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── page globals ─────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The page globals a served game page carries under both names, keyed by the
|
|
158
|
+
* joystack name. The production serve path (backend/api/src/lib/inject.js) sets
|
|
159
|
+
* the same list, so game code reading an old name finds it in `joystack dev`
|
|
160
|
+
* exactly as it does on its published page.
|
|
161
|
+
*/
|
|
162
|
+
export const LEGACY_PAGE_GLOBALS = Object.freeze({
|
|
163
|
+
__JOYSTACK_SKELETON__: '__LOOOP_SKELETON__',
|
|
164
|
+
JOYSTACK_ROOM_PARTY: 'LOOOP_ROOM_PARTY',
|
|
165
|
+
JOYSTACK_IDENTITY: 'LOOOP_IDENTITY',
|
|
166
|
+
JOYSTACK_REVIEW_BUILD: 'LOOOP_REVIEW_BUILD',
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// ── a game's package.json block ──────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
/** Which key of package.json holds the game's settings: `joystack`, or `looop` when only that exists. */
|
|
172
|
+
export function gameConfigKey(pkg) {
|
|
173
|
+
if (pkg && typeof pkg[NEW] === 'object' && pkg[NEW] !== null) return NEW;
|
|
174
|
+
if (pkg && typeof pkg[OLD] === 'object' && pkg[OLD] !== null) return OLD;
|
|
175
|
+
return NEW;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The game's settings block from package.json (`{}` when it has none). */
|
|
179
|
+
export function gameConfig(pkg) {
|
|
180
|
+
return pkg?.[gameConfigKey(pkg)] ?? {};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── test policy file ─────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
export const TEST_POLICY_FILE = `${NEW}.tests.json`;
|
|
186
|
+
const LEGACY_TEST_POLICY_FILE = `${OLD}.tests.json`;
|
|
187
|
+
|
|
188
|
+
/** The test policy file name a game uses: joystack.tests.json, or looop.tests.json when only that exists. */
|
|
189
|
+
export function testPolicyFile(projectDir) {
|
|
190
|
+
if (!existsSync(join(projectDir, TEST_POLICY_FILE)) && existsSync(join(projectDir, LEGACY_TEST_POLICY_FILE))) {
|
|
191
|
+
return LEGACY_TEST_POLICY_FILE;
|
|
192
|
+
}
|
|
193
|
+
return TEST_POLICY_FILE;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── the game's state folder ──────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Every name a game's private state folder can have. Each one is kept out of
|
|
200
|
+
* publishing, serving, linting and test triggers, whichever the game uses.
|
|
201
|
+
*/
|
|
202
|
+
export const STATE_DIR_NAMES = Object.freeze([`.${NEW}`, `.${OLD}`]);
|
|
203
|
+
|
|
204
|
+
/** The state folder's name in this game: `.joystack`, or `.looop` when only that exists. */
|
|
205
|
+
export function stateDirName(projectDir) {
|
|
206
|
+
if (projectDir && !existsSync(join(projectDir, `.${NEW}`)) && existsSync(join(projectDir, `.${OLD}`))) return `.${OLD}`;
|
|
207
|
+
return `.${NEW}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The absolute path of the game's state folder (see stateDirName). */
|
|
211
|
+
export function stateDir(projectDir) {
|
|
212
|
+
return join(projectDir, stateDirName(projectDir));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── dev-server routes ────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
const OLD_ROUTE = `/__${OLD}/`;
|
|
218
|
+
const NEW_ROUTE = `/__${NEW}/`;
|
|
219
|
+
|
|
220
|
+
/** A dev-server request path with an old `/__looop/` route mapped onto `/__joystack/`. */
|
|
221
|
+
export function canonicalDevPath(urlPath) {
|
|
222
|
+
if (typeof urlPath === 'string' && (urlPath.startsWith(OLD_ROUTE) || urlPath === `/__${OLD}`)) {
|
|
223
|
+
return `/__${NEW}` + urlPath.slice(OLD_ROUTE.length - 1);
|
|
224
|
+
}
|
|
225
|
+
return urlPath;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── persisted data formats ───────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
const LEGACY_FORMATS = new Map([
|
|
231
|
+
[`${OLD}-replay/1`, `${NEW}-replay/1`],
|
|
232
|
+
[`${OLD}-replay-seg/1`, `${NEW}-replay-seg/1`],
|
|
233
|
+
[`${OLD}-track-seg/1`, `${NEW}-track-seg/1`],
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
/** A recording's format tag, with an old tag mapped onto the one written today. */
|
|
237
|
+
export function canonicalFormat(tag) {
|
|
238
|
+
return LEGACY_FORMATS.get(tag) ?? tag;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── AGENTS.md managed markers ────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
export const LEGACY_MANAGED_MARKERS = Object.freeze({
|
|
244
|
+
start: `<!-- ${OLD}:managed:start -->`,
|
|
245
|
+
end: `<!-- ${OLD}:managed:end -->`,
|
|
246
|
+
});
|
package/lib/lint.mjs
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// `joystack lint` — the hard rules, made executable.
|
|
2
|
+
//
|
|
3
|
+
// A "Never" that lives only in prose is a suggestion. The rules that have
|
|
4
|
+
// actually shipped broken games are enforced here: in the editor as you type
|
|
5
|
+
// (via the game's eslint.config.js) and in the gate (`joystack test` runs this
|
|
6
|
+
// first).
|
|
7
|
+
//
|
|
8
|
+
// The rules themselves live in the ENGINE (`<engine>/eslint/`), not in this
|
|
9
|
+
// package — they are something the creator receives, so they have one authoring
|
|
10
|
+
// home and one release lane, and `joystack update` brings a game the new ones. This
|
|
11
|
+
// command resolves them out of the game's installed engine, which is the very
|
|
12
|
+
// same file its eslint.config.js imports. One copy: the gate and the editor
|
|
13
|
+
// cannot disagree.
|
|
14
|
+
//
|
|
15
|
+
// Escape hatch: `// eslint-disable-next-line joystack/<rule>` — the one every
|
|
16
|
+
// developer already knows. This is mandatory, not a nicety: creators are not
|
|
17
|
+
// developers, and a false positive must never wall someone out of their own
|
|
18
|
+
// test command with no way through.
|
|
19
|
+
import { existsSync } from 'node:fs';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { join, relative } from 'node:path';
|
|
22
|
+
import { pathToFileURL } from 'node:url';
|
|
23
|
+
import { isFrameworkV2 } from './primitives.mjs';
|
|
24
|
+
import { findProject, resolveEngine } from './project.mjs';
|
|
25
|
+
|
|
26
|
+
const CONFIG_NAMES = ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs'];
|
|
27
|
+
|
|
28
|
+
// Resolve ESLint from the GAME's node_modules — same preflight shape as
|
|
29
|
+
// playwright in test-cmd.mjs. Games scaffolded before lint existed simply don't
|
|
30
|
+
// have it; they skip rather than break.
|
|
31
|
+
async function loadFromProject(projectDir) {
|
|
32
|
+
try {
|
|
33
|
+
const req = createRequire(join(projectDir, 'package.json'));
|
|
34
|
+
const mod = await import(pathToFileURL(req.resolve('eslint')).href);
|
|
35
|
+
return mod.ESLint ?? mod.default?.ESLint ?? null;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The shareable config, out of the installed engine artifact.
|
|
42
|
+
//
|
|
43
|
+
// The two ways this can come up empty are DIFFERENT problems with different
|
|
44
|
+
// fixes, and conflating them sends people in a circle: the first version of this
|
|
45
|
+
// said "engine ships no rules — run joystack update" when the engine was not
|
|
46
|
+
// installed at all, so `joystack update` was the one command that could never help.
|
|
47
|
+
// Say which it is.
|
|
48
|
+
async function loadRulesFromEngine(projectDir) {
|
|
49
|
+
let engineDir;
|
|
50
|
+
try {
|
|
51
|
+
({ dir: engineDir } = resolveEngine(projectDir));
|
|
52
|
+
} catch {
|
|
53
|
+
return { configs: null, why: 'no-engine' };
|
|
54
|
+
}
|
|
55
|
+
const entry = join(engineDir, 'eslint', 'index.js');
|
|
56
|
+
if (!existsSync(entry)) return { configs: null, why: 'engine-too-old' };
|
|
57
|
+
const mod = await import(pathToFileURL(entry).href);
|
|
58
|
+
return { configs: mod.configs ?? mod.default?.configs ?? null, why: 'ok' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The entity graph lint — the SECOND shared step, alongside the eslint rules.
|
|
62
|
+
// Like the rules, the implementation lives in the ENGINE (shared/ui/room/
|
|
63
|
+
// entity/lint-run.mjs), so every gate that checks a game runs the identical
|
|
64
|
+
// pass — there is no second copy to drift. An engine too old to carry it
|
|
65
|
+
// skips — same degradation contract as the rules.
|
|
66
|
+
async function loadEntityLintFromEngine(projectDir) {
|
|
67
|
+
let engineDir;
|
|
68
|
+
try {
|
|
69
|
+
({ dir: engineDir } = resolveEngine(projectDir));
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
const entry = join(engineDir, 'shared', 'ui', 'room', 'entity', 'lint-run.mjs');
|
|
74
|
+
if (!existsSync(entry)) return null;
|
|
75
|
+
const mod = await import(pathToFileURL(entry).href);
|
|
76
|
+
return mod.runEntityLint ?? null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The framework-v2 build gate — the THIRD shared step, and engine-resident for
|
|
80
|
+
// the same reason as the other two: the gate that refuses to build a game and
|
|
81
|
+
// the gate that lints it must be the same code, or lint would bless a game the
|
|
82
|
+
// build then rejects.
|
|
83
|
+
//
|
|
84
|
+
// It runs only for a v2 game (package.json `joystack.framework === 'v2'`); a
|
|
85
|
+
// classic game has no world to expand. An engine too old to carry it skips —
|
|
86
|
+
// same degradation contract as the rules and the entity lint.
|
|
87
|
+
async function loadBuildGateFromEngine(projectDir) {
|
|
88
|
+
let engineDir;
|
|
89
|
+
try {
|
|
90
|
+
({ dir: engineDir } = resolveEngine(projectDir));
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const entry = join(engineDir, 'shared', 'framework', 'build', 'gate.js');
|
|
95
|
+
if (!existsSync(entry)) return null;
|
|
96
|
+
const mod = await import(pathToFileURL(entry).href);
|
|
97
|
+
return mod.runGate ?? null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The handbook path check — the FOURTH shared step. Every file the game's
|
|
101
|
+
// `handbook/` names in backticks must exist. It lives in the engine next to the
|
|
102
|
+
// rules (eslint/handbook-paths.js) because the rules for what a handbook says
|
|
103
|
+
// ship with the engine's `/handbook` skill, and the check and the skill have to
|
|
104
|
+
// move together. An engine too old to carry it skips.
|
|
105
|
+
async function loadHandbookCheckFromEngine(projectDir) {
|
|
106
|
+
let engineDir;
|
|
107
|
+
try {
|
|
108
|
+
({ dir: engineDir } = resolveEngine(projectDir));
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const entry = join(engineDir, 'eslint', 'handbook-paths.js');
|
|
113
|
+
if (!existsSync(entry)) return null;
|
|
114
|
+
const mod = await import(pathToFileURL(entry).href);
|
|
115
|
+
if (!mod.runHandbookPathCheck) return null;
|
|
116
|
+
return (dir, opts) => mod.runHandbookPathCheck(dir, { engineDir, ...opts });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Gate errors, printed the way lint prints everything else: the location first,
|
|
120
|
+
// so an agent or an editor can jump to it, then the rule that was broken, then
|
|
121
|
+
// the gate's own prescriptive text.
|
|
122
|
+
//
|
|
123
|
+
// The heading has a job beyond decoration. These errors are in GAME code, and a
|
|
124
|
+
// creator's agent used to meet them first as a node test that could not open its
|
|
125
|
+
// world — which reads as a broken test and sends the agent to debug the test.
|
|
126
|
+
// Say what they are.
|
|
127
|
+
async function runBuildGateStep(runGate, dir, log) {
|
|
128
|
+
let report;
|
|
129
|
+
try {
|
|
130
|
+
// `write: false` — lint reads, it does not leave `.joystack/diagnostics.json`
|
|
131
|
+
// behind. The build path writes that file when it actually builds.
|
|
132
|
+
({ report } = await runGate(dir, { write: false }));
|
|
133
|
+
} catch (e) {
|
|
134
|
+
// The gate import()s the game, so a module that throws at load arrives
|
|
135
|
+
// here. A diagnosis beats a stack trace out of the engine's insides.
|
|
136
|
+
log('');
|
|
137
|
+
log('❌ joystack lint: the build gate could not read your game.');
|
|
138
|
+
log(` ${e?.message ?? e}`);
|
|
139
|
+
return { ok: false, errors: [] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const errors = report?.errors ?? [];
|
|
143
|
+
if (!errors.length) return { ok: true, errors: [] };
|
|
144
|
+
|
|
145
|
+
log('');
|
|
146
|
+
log(`❌ joystack lint: ${errors.length} build gate error(s) — your game cannot be built, so it cannot run.`);
|
|
147
|
+
log(' These are errors in your game\'s code, not a broken test.');
|
|
148
|
+
log('');
|
|
149
|
+
for (const e of errors) {
|
|
150
|
+
const where = [e.file, e.line, e.col].filter((p) => p != null && p !== '').join(':');
|
|
151
|
+
log(` ${where || '(location unknown)'} ${e.code}`);
|
|
152
|
+
for (const line of String(e.message ?? '').split('\n')) log(` ${line}`);
|
|
153
|
+
}
|
|
154
|
+
return { ok: false, errors };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function lintCmd({
|
|
158
|
+
cwd = process.cwd(),
|
|
159
|
+
fix = false,
|
|
160
|
+
log = console.log,
|
|
161
|
+
loadEslint = loadFromProject,
|
|
162
|
+
loadRules = loadRulesFromEngine,
|
|
163
|
+
loadEntityLint = loadEntityLintFromEngine,
|
|
164
|
+
loadBuildGate = loadBuildGateFromEngine,
|
|
165
|
+
loadHandbookCheck = loadHandbookCheckFromEngine,
|
|
166
|
+
} = {}) {
|
|
167
|
+
const project = findProject(cwd);
|
|
168
|
+
|
|
169
|
+
// The entity graph check runs regardless of the eslint tooling below — a
|
|
170
|
+
// missing dev tool must never switch off a structural guarantee. It skips
|
|
171
|
+
// itself silently for games with no entity folders.
|
|
172
|
+
const runEntity = await loadEntityLint(project.dir);
|
|
173
|
+
const entity = runEntity ? await runEntity(project.dir, { log }) : { ok: true, skipped: true };
|
|
174
|
+
|
|
175
|
+
// Same contract for the build gate, and for the same reason.
|
|
176
|
+
// An engine with no gate file is an older engine and skips. A gate file that
|
|
177
|
+
// is there and cannot be imported is a broken install, and that fails: a
|
|
178
|
+
// skipped gate would pass a game that nothing checked.
|
|
179
|
+
let runGate = null;
|
|
180
|
+
let gate = null;
|
|
181
|
+
if (isFrameworkV2(project.dir)) {
|
|
182
|
+
try {
|
|
183
|
+
runGate = await loadBuildGate(project.dir);
|
|
184
|
+
} catch (e) {
|
|
185
|
+
log('');
|
|
186
|
+
log('❌ joystack lint: the build gate could not be loaded, so your game was not checked.');
|
|
187
|
+
log(` ${e?.message ?? e}`);
|
|
188
|
+
log(' This is the installed engine, not your game: `npx joystack update` reinstalls it.');
|
|
189
|
+
gate = { ok: false };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
gate ??= runGate ? await runBuildGateStep(runGate, project.dir, log) : { ok: true, skipped: true };
|
|
193
|
+
|
|
194
|
+
// Runs whether or not eslint is installed; an engine without the check skips.
|
|
195
|
+
// A check that is present and throws fails the step rather than the command,
|
|
196
|
+
// so the entity and gate results above still reach the caller.
|
|
197
|
+
let handbook;
|
|
198
|
+
try {
|
|
199
|
+
const runHandbook = await loadHandbookCheck(project.dir);
|
|
200
|
+
handbook = runHandbook ? runHandbook(project.dir, { log }) : { ok: true, skipped: true };
|
|
201
|
+
} catch (e) {
|
|
202
|
+
log('');
|
|
203
|
+
log('❌ joystack lint: the handbook path check could not run.');
|
|
204
|
+
log(` ${e?.message ?? e}`);
|
|
205
|
+
handbook = { ok: false, error: String(e?.message ?? e) };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const ESLint = await loadEslint(project.dir);
|
|
209
|
+
if (!ESLint) {
|
|
210
|
+
log('⚠️ joystack lint: eslint is not installed in this game — skipping the rule checks.');
|
|
211
|
+
log(' To turn them on (recommended — they catch bugs that silently pass your tests):');
|
|
212
|
+
log(' npm i -D eslint');
|
|
213
|
+
return { ok: entity.ok && gate.ok && handbook.ok, skipped: true, errorCount: 0, warningCount: 0, entity, gate, handbook };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const { configs, why } = await loadRules(project.dir);
|
|
217
|
+
if (!configs) {
|
|
218
|
+
if (why === 'no-engine') {
|
|
219
|
+
// The engine is not on disk. Usually it was PRUNED: it installs with
|
|
220
|
+
// `npm install --no-save`, so npm has no record of it and any later
|
|
221
|
+
// `npm install` in this game deletes it. It is not lost — it is cached.
|
|
222
|
+
log('⚠️ joystack lint: the Joystack engine is not installed in this game — skipping.');
|
|
223
|
+
log(' (A plain `npm install` removes it; it is cached, so putting it back is instant.)');
|
|
224
|
+
log(' Restore it with: joystack update');
|
|
225
|
+
} else {
|
|
226
|
+
log('⚠️ joystack lint: your engine is older than the Joystack rules — skipping.');
|
|
227
|
+
log(' Get an engine that carries them: joystack update');
|
|
228
|
+
}
|
|
229
|
+
return { ok: entity.ok && gate.ok && handbook.ok, skipped: true, errorCount: 0, warningCount: 0, entity, gate, handbook };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// A game that ships its own eslint.config.js owns its rules — we do not
|
|
233
|
+
// override it (and the scaffold's config already spreads ours in). Otherwise
|
|
234
|
+
// lint with the engine's recommended set, so a repo created before lint
|
|
235
|
+
// existed still gets the checks with zero setup.
|
|
236
|
+
const hasOwnConfig = CONFIG_NAMES.some((n) => existsSync(join(project.dir, n)));
|
|
237
|
+
const eslint = new ESLint({
|
|
238
|
+
cwd: project.dir,
|
|
239
|
+
fix,
|
|
240
|
+
...(hasOwnConfig ? {} : { overrideConfigFile: true, baseConfig: configs.recommended }),
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const results = await eslint.lintFiles(['.']);
|
|
244
|
+
if (fix) await ESLint.outputFixes(results);
|
|
245
|
+
|
|
246
|
+
let errorCount = 0;
|
|
247
|
+
let warningCount = 0;
|
|
248
|
+
for (const r of results) {
|
|
249
|
+
errorCount += r.errorCount;
|
|
250
|
+
warningCount += r.warningCount;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (errorCount || warningCount) {
|
|
254
|
+
// ESLint's own stylish formatter: the creator's agent (and their editor)
|
|
255
|
+
// already know how to read it.
|
|
256
|
+
const formatter = await eslint.loadFormatter('stylish');
|
|
257
|
+
const text = await formatter.format(results);
|
|
258
|
+
if (text.trim()) log(text.trim());
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// The closing line speaks for every step: a green line under a failed
|
|
262
|
+
// build gate would be the last thing read.
|
|
263
|
+
const failedSteps = [
|
|
264
|
+
!entity.ok && 'the entity check',
|
|
265
|
+
!gate.ok && 'the build gate',
|
|
266
|
+
!handbook.ok && 'the handbook path check',
|
|
267
|
+
].filter(Boolean);
|
|
268
|
+
if (errorCount === 0 && failedSteps.length) {
|
|
269
|
+
const named = failedSteps.length > 1 ? `${failedSteps.slice(0, -1).join(', ')} and ${failedSteps.at(-1)}` : failedSteps[0];
|
|
270
|
+
log(`❌ joystack lint: ${named} failed (above); the ${results.length} file(s) have no rule errors`);
|
|
271
|
+
} else if (errorCount === 0) {
|
|
272
|
+
log(`✅ joystack lint: clean (${results.length} file(s))${fix ? ' — fixes applied' : ''}`);
|
|
273
|
+
} else {
|
|
274
|
+
log('');
|
|
275
|
+
log(`❌ joystack lint: ${errorCount} error(s) in ${results.filter((r) => r.errorCount).length} file(s)`);
|
|
276
|
+
if (results.some((r) => r.messages.some((m) => m.fix))) {
|
|
277
|
+
log(' Some of these are auto-fixable: `joystack lint --fix`');
|
|
278
|
+
}
|
|
279
|
+
log(' A rule is wrong about your code? `// eslint-disable-next-line <rule>` above the line.');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
ok: errorCount === 0 && entity.ok && gate.ok && handbook.ok,
|
|
284
|
+
skipped: false,
|
|
285
|
+
errorCount,
|
|
286
|
+
warningCount,
|
|
287
|
+
entity,
|
|
288
|
+
gate,
|
|
289
|
+
handbook,
|
|
290
|
+
files: results.map((r) => relative(project.dir, r.filePath)),
|
|
291
|
+
};
|
|
292
|
+
}
|
package/lib/login.mjs
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// `joystack login` — device-flow authentication (the `gh auth login` shape):
|
|
2
|
+
//
|
|
3
|
+
// 1. ask the platform for a pairing (device_code for us, user_code for the
|
|
4
|
+
// human),
|
|
5
|
+
// 2. show the human the short code and open /activate in their browser,
|
|
6
|
+
// 3. poll until they approve,
|
|
7
|
+
// 4. store the minted creator token in ~/.joystack/config.json.
|
|
8
|
+
//
|
|
9
|
+
// The secret never passes through the human's hands or the chat — non-dev
|
|
10
|
+
// creators can't fumble a 40-char paste (why web-paste was rejected).
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { clearToken, setToken, getToken, getApiBase, DEFAULT_API_BASE } from './config.mjs';
|
|
13
|
+
|
|
14
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
15
|
+
|
|
16
|
+
function defaultOpenBrowser(url) {
|
|
17
|
+
const [cmd, args] =
|
|
18
|
+
process.platform === 'darwin'
|
|
19
|
+
? ['open', [url]]
|
|
20
|
+
: process.platform === 'win32'
|
|
21
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
22
|
+
: ['xdg-open', [url]];
|
|
23
|
+
try {
|
|
24
|
+
spawn(cmd, args, { stdio: 'ignore', detached: true }).unref();
|
|
25
|
+
} catch {
|
|
26
|
+
// No browser available (headless) — the printed URL is the fallback.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function postJson(url, body) {
|
|
31
|
+
const res = await fetch(url, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: { 'Content-Type': 'application/json' },
|
|
34
|
+
body: JSON.stringify(body ?? {}),
|
|
35
|
+
});
|
|
36
|
+
return { status: res.status, body: await res.json().catch(() => ({})) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function login({
|
|
40
|
+
apiBase = getApiBase(DEFAULT_API_BASE),
|
|
41
|
+
log = console.log,
|
|
42
|
+
openBrowser = defaultOpenBrowser,
|
|
43
|
+
pollIntervalMsOverride,
|
|
44
|
+
} = {}) {
|
|
45
|
+
const { status, body: grant } = await postJson(`${apiBase}/api/device/code`);
|
|
46
|
+
if (status !== 200 || !grant.device_code) {
|
|
47
|
+
throw new Error(`could not start login against ${apiBase} (HTTP ${status})`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
log('');
|
|
51
|
+
log(' To connect this computer to your Joystack account:');
|
|
52
|
+
log('');
|
|
53
|
+
log(` 1. Open ${grant.verification_uri}`);
|
|
54
|
+
log(` 2. Enter ${grant.user_code}`);
|
|
55
|
+
log('');
|
|
56
|
+
log(' (opening your browser…)');
|
|
57
|
+
openBrowser(grant.verification_uri_complete ?? grant.verification_uri);
|
|
58
|
+
|
|
59
|
+
const intervalMs = pollIntervalMsOverride ?? (grant.interval ?? 5) * 1000;
|
|
60
|
+
const deadline = Date.now() + (grant.expires_in ?? 900) * 1000;
|
|
61
|
+
while (Date.now() < deadline) {
|
|
62
|
+
await sleep(intervalMs);
|
|
63
|
+
const poll = await postJson(`${apiBase}/api/device/token`, { device_code: grant.device_code });
|
|
64
|
+
if (poll.status === 200 && poll.body.token) {
|
|
65
|
+
setToken(poll.body.token, { apiBase, userId: poll.body.user_id });
|
|
66
|
+
log('');
|
|
67
|
+
log(` ✅ Logged in. This machine now builds and publishes as your account (${poll.body.user_id}).`);
|
|
68
|
+
return { userId: poll.body.user_id };
|
|
69
|
+
}
|
|
70
|
+
if (poll.body.error === 'authorization_pending') continue;
|
|
71
|
+
if (poll.body.error === 'expired_token') {
|
|
72
|
+
throw new Error('that login code expired before it was approved — run `joystack login` again for a fresh one.');
|
|
73
|
+
}
|
|
74
|
+
throw new Error(`login failed: ${poll.body.error ?? `HTTP ${poll.status}`}`);
|
|
75
|
+
}
|
|
76
|
+
throw new Error('login timed out — run `joystack login` again.');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function whoami({ apiBase = getApiBase(DEFAULT_API_BASE), log = console.log } = {}) {
|
|
80
|
+
const token = getToken();
|
|
81
|
+
if (!token) throw new Error('not logged in — run `joystack login` first.');
|
|
82
|
+
const res = await fetch(`${apiBase}/api/me`, { headers: { Authorization: `Bearer ${token}` } });
|
|
83
|
+
if (res.status === 401) throw new Error('stored token was rejected (unauthenticated) — run `joystack login` again.');
|
|
84
|
+
if (!res.ok) throw new Error(`whoami failed: HTTP ${res.status}`);
|
|
85
|
+
const me = await res.json();
|
|
86
|
+
log(`${me.name ?? me.userId} (${me.userId}) via ${apiBase}`);
|
|
87
|
+
return me;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// `joystack logout [--all]`: revokes the stored token on the platform, or with
|
|
91
|
+
// `all` every token of the account (every machine it is signed in on), then
|
|
92
|
+
// forgets it here. The local copy is forgotten even when the platform cannot be
|
|
93
|
+
// reached, and the output says what may still be valid in that case.
|
|
94
|
+
export async function logout({ all = false, apiBase = getApiBase(DEFAULT_API_BASE), log = console.log, timeoutMs = 15_000 } = {}) {
|
|
95
|
+
const token = getToken();
|
|
96
|
+
if (!token) {
|
|
97
|
+
log('Not logged in on this machine.');
|
|
98
|
+
return { revoked: 0 };
|
|
99
|
+
}
|
|
100
|
+
let revoked = null;
|
|
101
|
+
let rejected = false;
|
|
102
|
+
let problem = null;
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetch(`${apiBase}/api/creator/logout`, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
107
|
+
body: JSON.stringify({ all }),
|
|
108
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
109
|
+
});
|
|
110
|
+
if (res.ok) {
|
|
111
|
+
const body = await res.json().catch(() => null);
|
|
112
|
+
revoked = typeof body?.revoked === 'number' ? body.revoked : null;
|
|
113
|
+
} else if (res.status === 401) {
|
|
114
|
+
rejected = true;
|
|
115
|
+
} else {
|
|
116
|
+
problem = `HTTP ${res.status}`;
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
problem = err?.message ?? String(err);
|
|
120
|
+
}
|
|
121
|
+
clearToken();
|
|
122
|
+
if (problem) {
|
|
123
|
+
log(`Logged out on this machine, but ${apiBase} could not revoke the token (${problem}); it stays valid until it expires.`);
|
|
124
|
+
} else if (rejected && all) {
|
|
125
|
+
log('Logged out on this machine. Its token had already expired or been revoked, so other machines were not signed out: run `joystack login`, then `joystack logout --all`.');
|
|
126
|
+
} else if (rejected) {
|
|
127
|
+
log('Logged out: the token had already expired or been revoked, and is removed from this machine.');
|
|
128
|
+
} else if (all) {
|
|
129
|
+
log(revoked === null ? 'Logged out everywhere.' : `Logged out everywhere: ${revoked} token${revoked === 1 ? '' : 's'} revoked.`);
|
|
130
|
+
} else {
|
|
131
|
+
log('Logged out: the token is revoked and removed from this machine.');
|
|
132
|
+
}
|
|
133
|
+
return { revoked, rejected, problem };
|
|
134
|
+
}
|