@homie-rocks/studio 0.32.0 → 0.33.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/CHANGELOG.md +85 -2
- package/README.md +34 -9
- package/agents/agents.ts +57 -24
- package/bin/homie-studio.mjs +13 -4
- package/lib/build.mjs +64 -9
- package/lib/cf-usage.mjs +76 -0
- package/lib/cloudflare.mjs +25 -2
- package/lib/dev.mjs +45 -4
- package/lib/lab-check.mjs +3 -2
- package/lib/mcp-tools.mjs +1 -1
- package/lib/mcp.mjs +1 -1
- package/lib/office.mjs +7 -0
- package/lib/perf.mjs +8 -3
- package/lib/player-image.mjs +40 -0
- package/lib/rules-build.mjs +184 -0
- package/lib/rules-guard.mjs +770 -0
- package/lib/scaffold.mjs +43 -16
- package/lib/shop-catalog.mjs +10 -9
- package/lib/shop.mjs +32 -15
- package/lib/standalone-cli.mjs +30 -2
- package/lib/standalone-tools.mjs +20 -2
- package/lib/standalone.mjs +215 -9
- package/lib/studio.mjs +21 -1
- package/lib/template-history.json +90 -0
- package/lib/upgrade.mjs +19 -15
- package/lib/version.mjs +2 -7
- package/netplay/NETPLAY.md +249 -4
- package/netplay/netplay.ts +55 -13
- package/package.json +23 -1
- package/rules/core.ts +1250 -0
- package/rules/guard.ts +419 -0
- package/rules/host.ts +707 -0
- package/rules/math.ts +266 -0
- package/rules/pack.ts +292 -0
- package/rules/rules.ts +544 -0
- package/rules/view.ts +447 -0
- package/shop/SELLING.md +14 -16
- package/shop/SHOP.md +119 -30
- package/shop/shop.ts +6 -8
- package/site/SITE.md +119 -2
- package/standalone/STANDALONE.md +61 -6
- package/starters/coin-dash/game.json +11 -0
- package/starters/coin-dash/index.html +17 -0
- package/starters/coin-dash/map/main.json +16 -0
- package/starters/coin-dash/src/move.ts +27 -0
- package/starters/coin-dash/src/rules.ts +86 -0
- package/starters/coin-dash/src/view.ts +106 -0
- package/starters/coin-dash/tunables.json +5 -0
- package/starters/ember-vale/index.html +11 -0
- package/worker/brain.mjs +8 -7
- package/worker/chat-page.mjs +1 -1
- package/worker/embed.mjs +135 -0
- package/worker/hosted.mjs +56 -0
- package/worker/index.mjs +413 -22
- package/worker/office-page.mjs +1 -0
- package/worker/office.mjs +24 -7
- package/worker/pages.mjs +189 -43
- package/worker/players.mjs +10 -6
- package/worker/referrals.mjs +192 -57
- package/worker/room-store.mjs +54 -0
- package/worker/room.mjs +294 -119
- package/worker/saves-shell.mjs +7 -1
- package/worker/schema.mjs +4 -3
- package/worker/shop-page.mjs +103 -37
- package/worker/shop-rules.mjs +156 -117
- package/worker/shop-store.mjs +70 -9
- package/worker/shop.mjs +275 -143
- package/worker/site.mjs +26 -5
- package/worker/stripe.mjs +9 -6
- package/worker/version.mjs +1 -1
package/lib/standalone.mjs
CHANGED
|
@@ -24,8 +24,9 @@
|
|
|
24
24
|
* "built, not started on this computer". A release that could not be signed is said as UNSIGNED and is not `ok`.
|
|
25
25
|
*/
|
|
26
26
|
import { spawn } from 'node:child_process';
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
27
|
+
import { X509Certificate } from 'node:crypto';
|
|
28
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
29
|
+
import { homedir, tmpdir } from 'node:os';
|
|
29
30
|
import { dirname, join, relative } from 'node:path';
|
|
30
31
|
import { build as buildSite, uiOf } from './build.mjs';
|
|
31
32
|
import { compareVersions } from './changelog.mjs';
|
|
@@ -222,6 +223,20 @@ export async function standaloneRows({ platform = process.platform, exec = runTo
|
|
|
222
223
|
fix: mac && team ? null : { who: 'person', open: 'https://developer.apple.com/account#MembershipDetailsCard', say: `With an Apple Developer membership: sign in to it in Xcode (Settings, Accounts), and put your team id (ten letters and digits, on that page) in HOMIE_APPLE_TEAM. ${ENV_HOW}` },
|
|
223
224
|
});
|
|
224
225
|
}
|
|
226
|
+
// A phone for trying a build on (standalone run --for ios --device): what Xcode's own device list says is
|
|
227
|
+
// connected now, by model, and whether its Developer Mode is on. Nothing is asked of the phone itself.
|
|
228
|
+
if (mac) {
|
|
229
|
+
const found = await phones({ exec, env });
|
|
230
|
+
const here = found.ok ? found.phones.filter((p) => p.here) : [];
|
|
231
|
+
const ready = here.filter((p) => p.trusted && p.developerMode === true);
|
|
232
|
+
const one = (p) => `${p.model}${!p.trusted ? ', which does not trust this Mac yet' : p.developerMode === true ? ', Developer Mode on' : p.developerMode === false ? ', Developer Mode OFF' : ', Developer Mode not known'}`;
|
|
233
|
+
rows.push({
|
|
234
|
+
id: 'standalone-phone', label: 'A phone for trying a build', need: 'optional', state: !found.ok ? 'unknown' : ready.length === 1 && here.length === 1 ? 'ok' : 'optional',
|
|
235
|
+
detail: !found.ok ? `could not check (${found.why})` : here.length ? `connected: ${here.map(one).join('; ')}${here.length > 1 ? ' (a run takes one phone: unplug the others)' : ''}` : 'no iPhone or iPad is connected now',
|
|
236
|
+
unlocks: 'the game on your own iPhone or iPad, over the cable (standalone run <game> --for ios --device): it adds the phone to your Apple team\'s device list',
|
|
237
|
+
fix: !found.ok || (ready.length === 1 && here.length === 1) ? null : !here.length ? { who: 'person', say: 'Plug the phone in with a cable, unlock it, and tap Trust if it asks about this computer.' } : here.some((p) => !p.trusted) ? { who: 'person', say: deviceTrouble('not paired').fix } : here.some((p) => p.developerMode !== true) ? { who: 'person', say: deviceTrouble('Developer Mode disabled').fix } : { who: 'person', say: 'Unplug all but the one phone to try the game on.' },
|
|
238
|
+
});
|
|
239
|
+
}
|
|
225
240
|
// steamcmd: only to upload, which a person does.
|
|
226
241
|
{
|
|
227
242
|
const r = await exec(platform === 'win32' ? 'where' : 'which', ['steamcmd'], { timeout: 5000 });
|
|
@@ -292,7 +307,7 @@ export function toolkitNote(root) {
|
|
|
292
307
|
try { pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); } catch { return null; }
|
|
293
308
|
const pinned = pinnedVersion(pkg?.devDependencies?.['@homie-rocks/studio'] ?? pkg?.dependencies?.['@homie-rocks/studio']);
|
|
294
309
|
if (!pinned || compareVersions(pinned, STANDALONE_SINCE) >= 0) return null;
|
|
295
|
-
return `This studio pins @homie-rocks/studio ${pinned}, from before standalone copies (${STANDALONE_SINCE}): its live site
|
|
310
|
+
return `This studio pins @homie-rocks/studio ${pinned}, from before standalone copies (${STANDALONE_SINCE}): its live site does not let a standalone copy read which room to join, so in a copy Quick play is OFFLINE until the studio is upgraded to ${STANDALONE_SINCE} or later AND deployed again. A room made or joined by its code still connects (seen against 0.31.0, not promised for every older version).`;
|
|
296
311
|
}
|
|
297
312
|
|
|
298
313
|
/** Everything a plan and a build both need to know, read once. */
|
|
@@ -366,7 +381,7 @@ export async function standalonePlan(root, id, opts = {}) {
|
|
|
366
381
|
icon: { from: s.icon.from, note: s.icon.note }, steam: s.meta.steam,
|
|
367
382
|
dir: relative(root, s.dir), targets, rows: s.rows, warnings: s.warnings, missing: MISSING, pins: STANDALONE_PINS,
|
|
368
383
|
...(s.note ? { toolkitNote: s.note } : {}),
|
|
369
|
-
needs: `
|
|
384
|
+
needs: `Quick play finds a room only when the live site runs @homie-rocks/studio ${STANDALONE_SINCE} or later (upgrade the studio, then deploy). A room made or joined by its code works with an older site too (seen against 0.31.0, not promised for every older version). This plan did not ask the site which version it runs.`,
|
|
370
385
|
...(refused ? { why: refused.why, instead: refused.instead, needsField: 'appId' } : {}),
|
|
371
386
|
};
|
|
372
387
|
}
|
|
@@ -560,15 +575,22 @@ async function capProject(s, platform, { say }) {
|
|
|
560
575
|
const patch = (file, fn) => { if (!existsSync(file)) return false; const before = readFileSync(file, 'utf8'); const after = fn(before); if (after !== before) writeFileSync(file, after); return true; };
|
|
561
576
|
const freshOut = (s, target) => { const out = join(s.dir, 'out', target, s.kind); rmSync(out, { recursive: true, force: true }); mkdirSync(out, { recursive: true }); return out; };
|
|
562
577
|
|
|
563
|
-
|
|
578
|
+
/** The iOS project with this build's web folder in it, ready for xcodebuild: { ok, notes, project } or { ok: false, why }. */
|
|
579
|
+
async function iosProject(s, { say }) {
|
|
564
580
|
const p = await capProject(s, 'ios', { say });
|
|
565
|
-
if (!p.ok) return {
|
|
581
|
+
if (!p.ok) return { ok: false, why: p.why };
|
|
566
582
|
const notes = [...p.notes];
|
|
567
583
|
if (!patch(join(s.dir, 'ios', 'App', 'App', 'Info.plist'), (t) => patchInfoPlist(t, s.meta.orientation))) notes.push('Info.plist was not where Capacitor puts it: the way the game is held was not set');
|
|
568
584
|
const sync = await tool(s, 'cap', ['sync', 'ios'], { env: p.env, timeout: 15 * 60_000 });
|
|
569
|
-
if (sync.code !== 0) return {
|
|
585
|
+
if (sync.code !== 0) return { ok: false, why: `cap sync ios failed: ${tail(sync)}` };
|
|
570
586
|
const app = join(s.dir, 'ios', 'App');
|
|
571
|
-
|
|
587
|
+
return { ok: true, notes, project: existsSync(join(app, 'App.xcworkspace')) ? ['-workspace', join(app, 'App.xcworkspace')] : ['-project', join(app, 'App.xcodeproj')] };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function buildIos(s, { say }) {
|
|
591
|
+
const p = await iosProject(s, { say });
|
|
592
|
+
if (!p.ok) return { state: 'failed', why: p.why };
|
|
593
|
+
const { notes, project } = p;
|
|
572
594
|
const derived = join(s.dir, 'ios', 'build');
|
|
573
595
|
const version = [`MARKETING_VERSION=${s.meta.version}`, `CURRENT_PROJECT_VERSION=${s.meta.build}`];
|
|
574
596
|
const env = toolEnv(s.env);
|
|
@@ -753,9 +775,18 @@ export async function standaloneBuild(root, id, opts = {}) {
|
|
|
753
775
|
/* ------------------------------------------------------------------ run, steam, ci, deploy */
|
|
754
776
|
|
|
755
777
|
/** `standalone run <game> [--for mac|windows|linux|ios|android]`: the copy that was built, started here. */
|
|
756
|
-
export async function standaloneRun(root, id,
|
|
778
|
+
export async function standaloneRun(root, id, opts = {}) {
|
|
779
|
+
const { for: said = null, platform = process.platform, env = process.env, exec = runTool, home = homedir(), device = false } = opts;
|
|
757
780
|
const g = gameOf(root, id);
|
|
758
781
|
if (g.why) return { ok: false, command: 'standalone run', why: g.why };
|
|
782
|
+
// --device: onto the one phone that is plugged in (runOnPhone). It is the person's yes to what that changes.
|
|
783
|
+
if (device) {
|
|
784
|
+
const asked = targetsOf(said ?? 'ios');
|
|
785
|
+
if (asked.why || asked.targets.length !== 1) return { ok: false, command: 'standalone run', why: asked.why ?? '--device takes one target: --for ios' };
|
|
786
|
+
if (asked.targets[0] === 'android') return { ok: false, command: 'standalone run', why: '--device is for an iPhone or iPad in this version. For an Android phone, leave --device out: `standalone run <game> --for android` hands over to Capacitor, which lists the phones and emulators it finds over adb and asks which one. That has been run on an emulator, never on a real Android phone.' };
|
|
787
|
+
if (asked.targets[0] !== 'ios') return { ok: false, command: 'standalone run', why: `--device puts the game on a phone: --for ios. A ${asked.targets[0]} build runs on a computer (leave --device out).` };
|
|
788
|
+
return runOnPhone(root, id, opts);
|
|
789
|
+
}
|
|
759
790
|
const dir = standaloneDir(root, g.game.id);
|
|
760
791
|
const host = { darwin: 'mac', win32: 'windows', linux: 'linux' }[platform] ?? 'linux';
|
|
761
792
|
const t = targetsOf(said ?? host);
|
|
@@ -865,4 +896,179 @@ export function standaloneDeployNotes(root) {
|
|
|
865
896
|
return notes;
|
|
866
897
|
}
|
|
867
898
|
|
|
899
|
+
/* ------------------------------------------------------------------ a real iPhone or iPad */
|
|
900
|
+
|
|
901
|
+
/** What `--device` changes outside this computer, said before it happens, in the result, in the tool and in the skill. */
|
|
902
|
+
export const DEVICE_ADDS = 'This adds the phone to your Apple team\'s list of development devices (a team may register a limited number a year, and a device stays on the list until the membership year renews) and lets Xcode make a development profile for this app. Apple signs the app for that phone only: it is a build to try, never one for the store.';
|
|
903
|
+
|
|
904
|
+
/** A devicectl command whose answer is a JSON file: { r, json } (json null when it wrote none). */
|
|
905
|
+
async function jsonOf(s, args, { timeout = 60_000 } = {}) {
|
|
906
|
+
const dir = mkdtempSync(join(tmpdir(), 'standalone-device-'));
|
|
907
|
+
const file = join(dir, 'out.json');
|
|
908
|
+
const r = await s.exec('xcrun', ['devicectl', ...args, '--json-output', file], { env: toolEnv(s.env), timeout });
|
|
909
|
+
let json = null;
|
|
910
|
+
try { json = JSON.parse(readFileSync(file, 'utf8')); } catch { json = null; }
|
|
911
|
+
rmSync(dir, { recursive: true, force: true });
|
|
912
|
+
return { r, json };
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* The iPhones and iPads this Mac knows, as Xcode's own devicectl lists them: { ok, phones: [{ id, udid, model,
|
|
917
|
+
* trusted, here, developerMode }] } or { ok: false, why }. `here`: plugged in or on the network now. An id never
|
|
918
|
+
* leaves this module in a result or a line: a person is told a phone by its model.
|
|
919
|
+
*/
|
|
920
|
+
export async function phones({ exec = runTool, env = process.env } = {}) {
|
|
921
|
+
const { r, json } = await jsonOf({ exec, env }, ['list', 'devices']);
|
|
922
|
+
const list = json?.result?.devices;
|
|
923
|
+
if (r.code !== 0 || !Array.isArray(list)) return { ok: false, why: r.code === 127 ? 'xcrun is not here (Xcode is not installed)' : `Xcode's device list did not answer (${tail(r, 2) || `code ${r.code}`})` };
|
|
924
|
+
return {
|
|
925
|
+
ok: true,
|
|
926
|
+
phones: list.filter((d) => d?.hardwareProperties?.reality === 'physical' && ['iPhone', 'iPad'].includes(d?.hardwareProperties?.deviceType)).map((d) => ({
|
|
927
|
+
id: String(d.identifier ?? ''), udid: String(d.hardwareProperties.udid ?? ''), model: String(d.hardwareProperties.marketingName ?? d.hardwareProperties.deviceType),
|
|
928
|
+
trusted: d.connectionProperties?.pairingState === 'paired', here: ['connected', 'disconnected'].includes(d.connectionProperties?.tunnelState),
|
|
929
|
+
developerMode: d.deviceProperties?.developerModeStatus === 'enabled' ? true : d.deviceProperties?.developerModeStatus === 'disabled' ? false : null,
|
|
930
|
+
})),
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const TEAM_ID = /^[A-Z0-9]{10}$/;
|
|
935
|
+
/**
|
|
936
|
+
* The Apple team a build to try is signed for: HOMIE_APPLE_TEAM, else the ONE team among the keychain's development
|
|
937
|
+
* identities. An identity's name carries a person's id, not the team's: the team is in its certificate (the OU of
|
|
938
|
+
* its subject), which is read here and nothing else of it is kept. { team, from } or { team: null, why, teams }.
|
|
939
|
+
*/
|
|
940
|
+
export async function appleTeam({ env = process.env, exec = runTool } = {}) {
|
|
941
|
+
const said = String(env.HOMIE_APPLE_TEAM ?? '').trim();
|
|
942
|
+
if (said) return TEAM_ID.test(said) ? { team: said, from: 'HOMIE_APPLE_TEAM' } : { team: null, teams: 0, why: 'HOMIE_APPLE_TEAM is set and is not a team id (ten capital letters and digits, on your Apple Developer account\'s Membership page)' };
|
|
943
|
+
const ids = await exec('security', ['find-identity', '-v', '-p', 'codesigning'], { timeout: 10_000 });
|
|
944
|
+
if (ids.code !== 0) return { team: null, teams: 0, why: 'could not check the keychain for a development identity (the security tool did not answer)' };
|
|
945
|
+
const hashes = new Set(ids.stdout.split('\n').filter((l) => /"(Apple Development|iPhone Developer): /.test(l)).map((l) => /\b([0-9A-F]{40})\b/.exec(l)?.[1]).filter(Boolean));
|
|
946
|
+
if (!hashes.size) return { team: null, teams: 0, why: 'the keychain has no Apple Development identity: sign in to your Apple account in Xcode (Settings, Accounts), which makes one' };
|
|
947
|
+
const certs = await exec('security', ['find-certificate', '-a', '-p'], { timeout: 20_000 });
|
|
948
|
+
const teams = new Set();
|
|
949
|
+
for (const pem of certs.stdout.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) ?? []) {
|
|
950
|
+
try {
|
|
951
|
+
const c = new X509Certificate(pem);
|
|
952
|
+
if (!hashes.has(c.fingerprint.replace(/:/g, ''))) continue;
|
|
953
|
+
const ou = /^OU=([A-Z0-9]{10})$/m.exec(c.subject)?.[1];
|
|
954
|
+
if (ou) teams.add(ou);
|
|
955
|
+
} catch { /* not a certificate this reads */ }
|
|
956
|
+
}
|
|
957
|
+
if (teams.size === 1) return { team: [...teams][0], from: 'the one Apple team among the keychain\'s development identities' };
|
|
958
|
+
return { team: null, teams: teams.size, why: teams.size ? `the keychain has development identities of ${teams.size} Apple teams, and which one is yours to say` : 'the team of the keychain\'s development identity could not be read' };
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* What stopped xcodebuild, devicectl or the phone, in the words a person can act on: { stage, why, fix } or null
|
|
963
|
+
* when the text is none of the things met so far (the caller then says the tool's own last lines).
|
|
964
|
+
*/
|
|
965
|
+
export function deviceTrouble(text) {
|
|
966
|
+
const t = String(text ?? '');
|
|
967
|
+
if (/Developer Mode disabled|enable Developer Mode|developer mode is (?:not enabled|disabled)/i.test(t)) return { stage: 'developer-mode', why: 'Developer Mode is off on the phone, so it takes no build to try', fix: 'On the phone: Settings, Privacy & Security, Developer Mode, turn it on. The phone restarts, then asks once more to turn it on: say yes, unlock it, and run this again. (The switch appears only after the phone has been plugged in to a Mac with Xcode.)' };
|
|
968
|
+
if (/No Accounts?:|No Account for Team|Add a new account in Accounts settings|not signed in|requires? (?:a|an) (?:development team|Apple ID)/i.test(t)) return { stage: 'no-account', why: 'Xcode is not signed in to an Apple account of that team, so nothing can sign the app', fix: 'Open Xcode, Settings, Accounts, add your Apple account (a free one can put a game on your own phone for a week; a Developer membership for longer), then run this again.' };
|
|
969
|
+
if (/has no devices from which to generate a provisioning profile|No profiles for '[^']*' were found/i.test(t)) return { stage: 'no-devices', why: 'your Apple team has no registered phone to make a development profile for, and Apple did not register this one', fix: 'Unlock the phone, keep it plugged in and run this again: it registers the phone. If it says the same, open the project once in Xcode (ios/App/App.xcodeproj in the standalone folder), pick the phone and press Run: Xcode registers it and says what stands in the way.' };
|
|
970
|
+
if (/device (?:is|was) (?:passcode )?locked|is locked|Unlock .* to Continue|could not be unlocked|because the device was not, or could not be, unlocked/i.test(t)) return { stage: 'locked', why: 'the phone is locked', fix: 'Unlock the phone, keep it awake and run this again.' };
|
|
971
|
+
if (/not paired|is not trusted|Trust This Computer|pairing/i.test(t)) return { stage: 'not-trusted', why: 'the phone does not trust this Mac yet', fix: 'Unlock the phone, plug it in and tap Trust when it asks about this computer (it asks for the passcode), then run this again.' };
|
|
972
|
+
if (/Timed out waiting for all destinations|Unable to find a (?:destination|device) matching/i.test(t)) return { stage: 'not-ready', why: 'Xcode waited for the phone and it never became ready', fix: 'Unlock the phone, keep it plugged in and awake, and run this again. If Xcode has never seen this phone, open Xcode once with it plugged in (Window, Devices and Simulators) and let it finish preparing the phone.' };
|
|
973
|
+
return null;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* `standalone run <game> --for ios --device`: the game built for the ONE iPhone or iPad that is plugged in, signed
|
|
978
|
+
* for the person's own Apple team, installed over the cable and started, and then LOOKED FOR among the phone's
|
|
979
|
+
* running programs: "started" is said only when it is there. Every way it can stop is an answer with what to do.
|
|
980
|
+
* Nothing of the phone or the team is in the result but the phone's model and where the team's id was found.
|
|
981
|
+
*/
|
|
982
|
+
async function runOnPhone(root, id, opts = {}) {
|
|
983
|
+
const say = opts.log ?? (() => {});
|
|
984
|
+
const command = 'standalone run';
|
|
985
|
+
const stop = (stage, why, extra = {}) => ({ ok: false, command, target: 'ios', device: true, stage, why, ...extra });
|
|
986
|
+
if ((opts.platform ?? process.platform) !== 'darwin') return stop('not-a-mac', 'a game goes onto an iPhone from a Mac with Xcode');
|
|
987
|
+
const s = await survey(root, id, { ...opts, for: 'ios', release: false });
|
|
988
|
+
if (s.why) return stop('game', s.why);
|
|
989
|
+
const g = gate('ios', s);
|
|
990
|
+
if (g) return stop('xcode', g.why, { game: s.game.id, row: g.row?.id ?? null, fix: g.row?.fix?.say ?? null });
|
|
991
|
+
const base = { game: s.game.id, name: s.meta.name, appId: s.meta.appId, appIdFrom: s.meta.appIdFrom, site: s.where.url || null, warnings: s.warnings, adds: DEVICE_ADDS };
|
|
992
|
+
// The phone: exactly one, trusted, here, with Developer Mode on.
|
|
993
|
+
const found = await phones(s);
|
|
994
|
+
if (!found.ok) return stop('could-not-check', `could not look for a phone: ${found.why}`, base);
|
|
995
|
+
const here = found.phones.filter((p) => p.here);
|
|
996
|
+
if (!here.length) {
|
|
997
|
+
const known = found.phones.length;
|
|
998
|
+
return stop('no-phone', known ? `no iPhone or iPad is connected now (this Mac knows ${known}: ${found.phones.map((p) => p.model).join(', ')})` : 'no iPhone or iPad is connected to this Mac', { ...base, fix: 'Plug the phone in with a cable, unlock it, and tap Trust if it asks about this computer. Then run this again.' });
|
|
999
|
+
}
|
|
1000
|
+
if (here.length > 1) return stop('several', `${here.length} phones are connected (${here.map((p) => p.model).join(', ')}), and this puts the game on one`, { ...base, phones: here.map((p) => ({ model: p.model })), fix: 'Unplug all but the one to try the game on (and turn Wi-Fi off on the others, or they stay connected over the network), then run this again.' });
|
|
1001
|
+
const phone = here[0];
|
|
1002
|
+
const device = { model: phone.model };
|
|
1003
|
+
if (!phone.trusted) return stop('not-trusted', `the ${phone.model} does not trust this Mac yet`, { ...base, phone: device, fix: deviceTrouble('not paired').fix });
|
|
1004
|
+
if (phone.developerMode === false) { const t = deviceTrouble('Developer Mode disabled'); return stop(t.stage, `Developer Mode is off on the ${phone.model}, so it takes no build to try`, { ...base, phone: device, fix: t.fix }); }
|
|
1005
|
+
// The team: said, or the keychain's only one. Where it came from is said; the id is not.
|
|
1006
|
+
const signer = await appleTeam(s);
|
|
1007
|
+
if (!signer.team) return stop('no-team', `no Apple team to sign for: ${signer.why}`, { ...base, phone: device, fix: `Put your team id in HOMIE_APPLE_TEAM (ten capital letters and digits, on your Apple Developer account's Membership page). ${ENV_HOW}` });
|
|
1008
|
+
say(`for the ${phone.model}, signed for ${signer.from === 'HOMIE_APPLE_TEAM' ? 'the team HOMIE_APPLE_TEAM names' : signer.from}`);
|
|
1009
|
+
say(DEVICE_ADDS);
|
|
1010
|
+
const on = { ...base, phone: device, team: { from: signer.from } };
|
|
1011
|
+
// The game, built as the site builds it, in the same project a simulator build uses.
|
|
1012
|
+
if (opts.buildSite !== false) {
|
|
1013
|
+
say(`building ${s.game.id} for the web (the same build the site serves)`);
|
|
1014
|
+
try { await (opts.buildSite ?? buildSite)(root, { only: s.game.id, log: say }); } catch (error) { return stop('build-failed', `the game did not build: ${String(error?.message ?? error)}`, on); }
|
|
1015
|
+
}
|
|
1016
|
+
const tools = await installTools(s, { say });
|
|
1017
|
+
if (!tools.ok) return stop('build-failed', tools.why, on);
|
|
1018
|
+
await writeIcons(s.dir, s.icon, { name: s.meta.name, colours: uiOf(s.game) });
|
|
1019
|
+
try { webBundle(root, s.game, s.dir, { meta: s.meta, target: 'ios', site: s.where.url }); } catch (error) { return stop('build-failed', String(error?.message ?? error).split('\n')[0], on); }
|
|
1020
|
+
const p = await iosProject(s, { say });
|
|
1021
|
+
if (!p.ok) return stop('build-failed', p.why, on);
|
|
1022
|
+
const derived = join(s.dir, 'ios', 'build-device');
|
|
1023
|
+
say('xcodebuild: a Debug build for the phone, signed by Xcode for your team (the first one registers the phone and makes its profile)');
|
|
1024
|
+
const b = await s.exec('xcodebuild', [...p.project, '-scheme', 'App', '-configuration', 'Debug', '-destination', `id=${phone.udid}`, '-derivedDataPath', derived, `MARKETING_VERSION=${s.meta.version}`, `CURRENT_PROJECT_VERSION=${s.meta.build}`, `DEVELOPMENT_TEAM=${signer.team}`, 'CODE_SIGN_STYLE=Automatic', '-allowProvisioningUpdates', '-allowProvisioningDeviceRegistration', 'build'], { cwd: s.dir, env: toolEnv(s.env), timeout: 40 * 60_000 });
|
|
1025
|
+
const app = join(derived, 'Build', 'Products', 'Debug-iphoneos', 'App.app');
|
|
1026
|
+
// What a tool printed may name the phone or the team: only its meaning, or its last lines with both taken out, is said.
|
|
1027
|
+
const hush = (text) => String(text).split(phone.udid).join('<the phone>').split(phone.id).join('<the phone>').split(signer.team).join('<your team>');
|
|
1028
|
+
if (b.code !== 0 || !existsSync(app)) {
|
|
1029
|
+
const t = deviceTrouble(`${b.stdout}\n${b.stderr}`);
|
|
1030
|
+
return t ? stop(t.stage, t.why, { ...on, fix: t.fix }) : stop('build-failed', `xcodebuild did not finish: ${hush(tail(b, 12))}`, on);
|
|
1031
|
+
}
|
|
1032
|
+
say(`installing it on the ${phone.model}`);
|
|
1033
|
+
const i = await jsonOf(s, ['device', 'install', 'app', '--device', phone.id, app], { timeout: 10 * 60_000 });
|
|
1034
|
+
if (i.r.code !== 0) {
|
|
1035
|
+
const t = deviceTrouble(`${i.r.stdout}\n${i.r.stderr}`);
|
|
1036
|
+
return t ? stop(t.stage, t.why, { ...on, built: true, fix: t.fix }) : stop('install-failed', `the app was built and the phone did not take it: ${hush(tail(i.r, 6))}`, { ...on, built: true });
|
|
1037
|
+
}
|
|
1038
|
+
// Where the phone put it, from the phone's own list of apps: every Capacitor app's program has the same name, so
|
|
1039
|
+
// a running one is told from another game's by where it is installed.
|
|
1040
|
+
const apps = await jsonOf(s, ['device', 'info', 'apps', '--device', phone.id], { timeout: 60_000 });
|
|
1041
|
+
const listed = (apps.json?.result?.apps ?? []).find((x) => x?.bundleIdentifier === s.meta.appId);
|
|
1042
|
+
if (apps.r.code === 0 && Array.isArray(apps.json?.result?.apps) && !listed) return stop('install-failed', 'the phone said it took the app, and the app is not in the phone\'s list of apps', { ...on, built: true });
|
|
1043
|
+
const where = String(listed?.url ?? i.json?.result?.installedApplications?.[0]?.installationURL ?? '');
|
|
1044
|
+
// A locked phone starts nothing. It is asked for now, and waited for a minute, rather than failing at the last step.
|
|
1045
|
+
const wait = opts.wait ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1046
|
+
const locked = async () => (await jsonOf(s, ['device', 'info', 'lockState', '--device', phone.id], { timeout: 30_000 })).json?.result?.passcodeRequired === true;
|
|
1047
|
+
if (await locked()) {
|
|
1048
|
+
say(`The ${phone.model} is locked: unlock it now (waiting up to a minute).`);
|
|
1049
|
+
for (let t = 0; t < (opts.unlockMs ?? 60_000); t += 3000) { await wait(3000); if (!(await locked())) break; }
|
|
1050
|
+
}
|
|
1051
|
+
say('starting it');
|
|
1052
|
+
const l = await jsonOf(s, ['device', 'process', 'launch', '--device', phone.id, '--terminate-existing', s.meta.appId], { timeout: 2 * 60_000 });
|
|
1053
|
+
if (l.r.code !== 0) {
|
|
1054
|
+
const t = deviceTrouble(`${l.r.stdout}\n${l.r.stderr}`);
|
|
1055
|
+
return stop(t?.stage === 'locked' ? 'locked' : 'not-started', t?.stage === 'locked' ? 'the game is on the phone, and the phone is locked, so it was not started' : `the game is on the phone and did not start: ${hush(tail(l.r, 6))}`, { ...on, built: true, installed: true, fix: `Unlock the phone and tap ${s.meta.name} on its home screen.` });
|
|
1056
|
+
}
|
|
1057
|
+
const pid = Number(l.json?.result?.process?.processIdentifier);
|
|
1058
|
+
// Read back: the game is among the phone's running programs a few seconds later, or it is not said to be running.
|
|
1059
|
+
await wait(4000);
|
|
1060
|
+
const ps = await jsonOf(s, ['device', 'info', 'processes', '--device', phone.id], { timeout: 60_000 });
|
|
1061
|
+
const list = ps.json?.result?.runningProcesses;
|
|
1062
|
+
if (ps.r.code !== 0 || !Array.isArray(list)) return stop('not-confirmed', 'the game was installed and asked to start, and the phone\'s running programs could not be read to see it', { ...on, built: true, installed: true, fix: `Look at the phone: ${s.meta.name} should be on its screen.` });
|
|
1063
|
+
const running = list.some((x) => (Number.isFinite(pid) && Number(x?.processIdentifier) === pid) || (where && String(x?.executable ?? '').startsWith(where)));
|
|
1064
|
+
if (!running) return stop('not-running', 'the game was installed and started, and four seconds later it was not among the phone\'s running programs: it closed, or crashed, as it opened', { ...on, built: true, installed: true, fix: `Tap ${s.meta.name} on the phone; if it closes again, open Xcode, Window, Devices and Simulators, the phone, and read its crash log.` });
|
|
1065
|
+
return {
|
|
1066
|
+
ok: true, command, target: 'ios', device: true, stage: 'started', ...on, built: true, installed: true, running: true,
|
|
1067
|
+
notes: [
|
|
1068
|
+
'a development build signed for this phone only: it stops opening when its profile ends (a week with a free Apple account, a year with a Developer membership), and it is not a build for the store',
|
|
1069
|
+
s.where.url ? `Quick play finds a room only when ${s.where.url} runs @homie-rocks/studio ${STANDALONE_SINCE} or later; a room made or joined by its code works with an older site too` : 'this copy has no address built in: it plays offline with its bots',
|
|
1070
|
+
],
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
|
|
868
1074
|
export { MISSING, TARGETS };
|
package/lib/studio.mjs
CHANGED
|
@@ -94,11 +94,31 @@ export function listGames(root) {
|
|
|
94
94
|
.sort((a, b) => a.id.localeCompare(b.id));
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* RULES ON THE SERVER (NETPLAY.md section 29). A game written as rules plus view has a src/rules.ts; its settings are
|
|
99
|
+
* game.json `"room"` (where its rules run, its tick rate…; rules/rules.ts `roomSettings` checks them at build). Any
|
|
100
|
+
* other game is hosted by a player's browser, as before.
|
|
101
|
+
*/
|
|
102
|
+
export const isRulesGame = (g) => existsSync(join(g.dir, 'src', 'rules.ts'));
|
|
103
|
+
|
|
104
|
+
const RULES_HEADER = '// Written by `homie-studio build` from this studio\'s games. Do not edit: the next build writes it again.\n';
|
|
105
|
+
/**
|
|
106
|
+
* site/src/rules/index.mjs: the table of this studio's server-hosted games as site/src/worker.mjs imports it (an
|
|
107
|
+
* empty one for a studio with none, which is what a new studio starts with). lib/rules-build.mjs writes the rest.
|
|
108
|
+
*/
|
|
109
|
+
export function rulesIndex(ids = []) {
|
|
110
|
+
const lines = [`${RULES_HEADER}// The rules of this studio's server-hosted games, for the Worker (site/src/worker.mjs hands them to hostRules).`];
|
|
111
|
+
ids.forEach((id, i) => lines.push(`import rules${i} from './${id}.mjs';`, `import data${i} from './${id}.data.mjs';`));
|
|
112
|
+
lines.push(`export default {${ids.map((id, i) => `\n ${JSON.stringify(id)}: { rules: rules${i}, ...data${i} },`).join('')}${ids.length ? '\n' : ''}};`);
|
|
113
|
+
return `${lines.join('\n')}\n`;
|
|
114
|
+
}
|
|
115
|
+
|
|
97
116
|
export function starters() {
|
|
98
117
|
const dir = join(PACKAGE_ROOT, 'starters');
|
|
99
118
|
return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => {
|
|
100
119
|
const meta = JSON.parse(readFileSync(join(dir, d.name, 'game.json'), 'utf8'));
|
|
101
|
-
|
|
120
|
+
// `example`: a starter that shows how something is written (coin-dash: rules on the server) and is not yet offered as the start of a new game.
|
|
121
|
+
return { id: d.name, name: meta.name, blurb: meta.blurb, players: meta.players, roundSeconds: meta.roundSeconds, ...(meta.example === true ? { example: true } : {}) };
|
|
102
122
|
});
|
|
103
123
|
}
|
|
104
124
|
|
|
@@ -1692,6 +1692,96 @@
|
|
|
1692
1692
|
}
|
|
1693
1693
|
}
|
|
1694
1694
|
}
|
|
1695
|
+
},
|
|
1696
|
+
"0.32.1": {
|
|
1697
|
+
"files": {
|
|
1698
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
1699
|
+
"README.md": "684b3cca8001673c",
|
|
1700
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
1701
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
1702
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
1703
|
+
"videos/README.md": "16babc3437d5545b",
|
|
1704
|
+
"posts/README.md": "37841aae476bd540",
|
|
1705
|
+
"site/README.md": "732894273a32dc3f",
|
|
1706
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
1707
|
+
},
|
|
1708
|
+
"sections": {
|
|
1709
|
+
"AGENTS.md": {
|
|
1710
|
+
"(intro)": "214aa46eb3a895d5",
|
|
1711
|
+
"## Layout": "8baf49e4291e7e88",
|
|
1712
|
+
"## Commands (all through the pinned CLI in node_modules)": "29a04ed3d956610a",
|
|
1713
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
1714
|
+
"## Making games": "2da7fa74c8884834",
|
|
1715
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
1716
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
1717
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
1718
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
1719
|
+
"## Selling things (the shop)": "b04b0a2b25f524f2",
|
|
1720
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
1721
|
+
"## Rules": "2e6b29afb85dcf7e",
|
|
1722
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
1723
|
+
"## Beta": "9d02638207a26e73"
|
|
1724
|
+
}
|
|
1725
|
+
},
|
|
1726
|
+
"named": {
|
|
1727
|
+
"Homie Arcade": {
|
|
1728
|
+
"sections": {
|
|
1729
|
+
"AGENTS.md": {
|
|
1730
|
+
"## Commands (all through the pinned CLI in node_modules)": "5d1c068adda1116b"
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
},
|
|
1736
|
+
"0.33.0": {
|
|
1737
|
+
"files": {
|
|
1738
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
1739
|
+
"README.md": "684b3cca8001673c",
|
|
1740
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
1741
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
1742
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
1743
|
+
"videos/README.md": "16babc3437d5545b",
|
|
1744
|
+
"posts/README.md": "37841aae476bd540",
|
|
1745
|
+
"site/README.md": "732894273a32dc3f",
|
|
1746
|
+
"site/src/worker.mjs": "fc90f3609cad18ba"
|
|
1747
|
+
},
|
|
1748
|
+
"sections": {
|
|
1749
|
+
"AGENTS.md": {
|
|
1750
|
+
"(intro)": "214aa46eb3a895d5",
|
|
1751
|
+
"## Layout": "8baf49e4291e7e88",
|
|
1752
|
+
"## Commands (all through the pinned CLI in node_modules)": [
|
|
1753
|
+
"29a04ed3d956610a",
|
|
1754
|
+
"a7f290579585368c"
|
|
1755
|
+
],
|
|
1756
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
1757
|
+
"## Making games": "2da7fa74c8884834",
|
|
1758
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
1759
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
1760
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
1761
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
1762
|
+
"## Selling things (the shop)": [
|
|
1763
|
+
"3173053462311959",
|
|
1764
|
+
"56225d9c6292d975",
|
|
1765
|
+
"b04b0a2b25f524f2"
|
|
1766
|
+
],
|
|
1767
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
1768
|
+
"## Rules": "2e6b29afb85dcf7e",
|
|
1769
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
1770
|
+
"## Beta": "9d02638207a26e73"
|
|
1771
|
+
}
|
|
1772
|
+
},
|
|
1773
|
+
"named": {
|
|
1774
|
+
"Homie Arcade": {
|
|
1775
|
+
"sections": {
|
|
1776
|
+
"AGENTS.md": {
|
|
1777
|
+
"## Commands (all through the pinned CLI in node_modules)": [
|
|
1778
|
+
"5d1c068adda1116b",
|
|
1779
|
+
"7cad8fd10f62fef1"
|
|
1780
|
+
]
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1695
1785
|
}
|
|
1696
1786
|
}
|
|
1697
1787
|
}
|
package/lib/upgrade.mjs
CHANGED
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
* `--diff` prints the difference, so the person can take the template's words by hand.
|
|
16
16
|
*
|
|
17
17
|
* Plus the pin: when this CLI is newer than the version package.json pins (run through
|
|
18
|
-
* `npx -y
|
|
19
|
-
* studio.json's `homie.studio` says the version the studio is on.
|
|
18
|
+
* `npx -y @homie-rocks/studio@<the new version> upgrade`), --apply pins this version (then `npm install`), and
|
|
19
|
+
* studio.json's `homie.studio` says the version the studio is on. The pin it writes is always the npm registry's:
|
|
20
|
+
* a studio made before 0.10.0 pinned an address on homie.rocks, and its first upgrade moves it to the registry.
|
|
20
21
|
*
|
|
21
22
|
* Without --apply it changes nothing and says what --apply would do: the person agrees first. It never touches
|
|
22
23
|
* studio.json beyond `homie.studio`, the studio's look (site/theme.json), its Worker config (deploy's), its games,
|
|
@@ -43,7 +44,7 @@ import { STUDIO_VERSION, packageSpec } from './version.mjs';
|
|
|
43
44
|
/** Whole files the template writes and a studio may keep as they are: updated only while the studio never changed them. */
|
|
44
45
|
export const TEMPLATE_FILES = ['AGENTS.md', 'CLAUDE.md', 'README.md', 'HANDOFF.md', 'games/README.md', 'music/README.md', 'videos/README.md', 'posts/README.md', 'site/README.md', 'site/src/worker.mjs'];
|
|
45
46
|
/** Files the template adds when missing and never changes once they exist (a migration is applied; a manifest is the studio's). */
|
|
46
|
-
export const ADD_ONLY = ['music/manifest.json', 'videos/manifest.json', '.claude/skills/.gitkeep'];
|
|
47
|
+
export const ADD_ONLY = ['music/manifest.json', 'videos/manifest.json', '.claude/skills/.gitkeep', 'site/src/rules/index.mjs'];
|
|
47
48
|
const MIGRATIONS = /^site\/migrations\/[^/]+\.sql$/;
|
|
48
49
|
/** The name and slug stand-ins the history is fingerprinted with. */
|
|
49
50
|
export const STAND_IN = { name: '{{studio.name}}', slug: '{{studio.slug}}' };
|
|
@@ -124,19 +125,21 @@ function known(history, who = {}) {
|
|
|
124
125
|
const semver = (v) => (/^(\d+)\.(\d+)\.(\d+)$/.exec(String(v ?? '')) ?? []).slice(1).map(Number);
|
|
125
126
|
const newer = (a, b) => { const x = semver(a); const y = semver(b); for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) > (y[i] ?? 0); return false; };
|
|
126
127
|
|
|
127
|
-
/**
|
|
128
|
+
/**
|
|
129
|
+
* The version in the address a studio made before 0.10.0 pinned (https://<directory>/npm/homie-studio-<version>.tgz),
|
|
130
|
+
* or null. Read for one thing only: so that studio's upgrade knows which version it is leaving. Nothing writes
|
|
131
|
+
* such an address; its upgrade writes the npm registry's exact version.
|
|
132
|
+
*/
|
|
133
|
+
const addressPin = (spec) => /^https?:\/\/.+\/homie-studio-(\d+\.\d+\.\d+)\.tgz(?:[?#].*)?$/.exec(String(spec ?? ''))?.[1] ?? null;
|
|
134
|
+
|
|
135
|
+
/** The @homie-rocks/studio version a package.json spec pins: an exact version, npm:… of one, or an older studio's address. */
|
|
128
136
|
export function pinnedVersion(spec) {
|
|
129
137
|
const s = String(spec ?? '');
|
|
130
|
-
return
|
|
138
|
+
return /^(?:npm:@homie-rocks\/studio@)?=?(\d+\.\d+\.\d+)$/.exec(s)?.[1] ?? addressPin(s);
|
|
131
139
|
}
|
|
132
|
-
/** The
|
|
133
|
-
function specFor(spec
|
|
134
|
-
|
|
135
|
-
const tar = /^(https?:\/\/.+\/)homie-studio-\d+\.\d+\.\d+\.tgz$/.exec(s);
|
|
136
|
-
if (tar) return `${tar[1]}homie-studio-${STUDIO_VERSION}.tgz`;
|
|
137
|
-
if (/^npm:@homie-rocks\/studio@/.test(s)) return `npm:@homie-rocks/studio@${STUDIO_VERSION}`;
|
|
138
|
-
if (/^=?\d+\.\d+\.\d+$/.test(s)) return STUDIO_VERSION;
|
|
139
|
-
return packageSpec(directory);
|
|
140
|
+
/** The pin for this version: the npm registry's exact version (an npm: alias stays an alias). */
|
|
141
|
+
function specFor(spec) {
|
|
142
|
+
return /^npm:@homie-rocks\/studio@/.test(String(spec ?? '')) ? `npm:@homie-rocks/studio@${STUDIO_VERSION}` : packageSpec();
|
|
140
143
|
}
|
|
141
144
|
|
|
142
145
|
/** A line diff (the longest common run kept), for the plan: ` ` same, `- ` the studio's, `+ ` the template's. */
|
|
@@ -183,8 +186,9 @@ export function upgradePlan(root, { history = readHistory() } = {}) {
|
|
|
183
186
|
if (pinned && newer(pinned, STUDIO_VERSION)) {
|
|
184
187
|
return { ok: false, command: 'upgrade', from: pinned, to: STUDIO_VERSION, why: `this studio pins @homie-rocks/studio ${pinned}, newer than this ${STUDIO_VERSION}: run its own copy (npx --no-install homie-studio upgrade)` };
|
|
185
188
|
}
|
|
186
|
-
|
|
187
|
-
|
|
189
|
+
// An older studio's address is moved to the registry even when it names this very version: it is no longer served.
|
|
190
|
+
if (pkg && where && pinned && (newer(STUDIO_VERSION, pinned) || addressPin(spec))) {
|
|
191
|
+
changes.push({ file: 'package.json', kind: 'pin', what: `pin @homie-rocks/studio ${STUDIO_VERSION}${addressPin(spec) ? ' from the npm registry' : ''} (was ${pinned}${addressPin(spec) ? ', at an address that is no longer served' : ''}); then npm install`, from: spec, to: specFor(spec) });
|
|
188
192
|
} else if (pkg && where && !pinned) {
|
|
189
193
|
kept.push({ file: 'package.json', what: `@homie-rocks/studio is "${String(spec).slice(0, 80)}" (not a published version): left as it is` });
|
|
190
194
|
}
|
package/lib/version.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The one place @homie-rocks/studio says which version it is and where that exact
|
|
3
|
-
* version lives. A studio pins this
|
|
3
|
+
* version lives. A studio pins this version (package.json + package-lock
|
|
4
4
|
* integrity), so a published game never changes because Homie changed.
|
|
5
5
|
*/
|
|
6
6
|
import { readFileSync } from 'node:fs';
|
|
@@ -8,14 +8,9 @@ import { readFileSync } from 'node:fs';
|
|
|
8
8
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
9
9
|
export const STUDIO_VERSION = pkg.version;
|
|
10
10
|
|
|
11
|
-
/** The pinned tarball of this version, on a Homie directory (homie.rocks by default). Kept for older studios. */
|
|
12
|
-
export function tarballUrl(base = 'https://homie.rocks', version = STUDIO_VERSION) {
|
|
13
|
-
return `${String(base).replace(/\/+$/, '')}/npm/homie-studio-${version}.tgz`;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
11
|
/**
|
|
17
12
|
* What a studio's package.json names: this exact version, from registry.npmjs.org (package-lock.json keeps its
|
|
18
13
|
* integrity). The public registry is what Cloudflare's Workers Builds and a Claude Code cloud session reach with
|
|
19
|
-
* their default network settings
|
|
14
|
+
* their default network settings.
|
|
20
15
|
*/
|
|
21
16
|
export function packageSpec() { return STUDIO_VERSION; }
|