diff --git a/CHANGELOG.md b/CHANGELOG.md index 3180a8f6..9fad61b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Games go live on Summer's automatic checks, with Summer's review afterwards: `su - A sent-back page's decision comes from the review's `latestReview`; while the platform lacks it, from `/approval` when it answers; else the page still reports `needs_changes` and points at `store.status`. On a platform without `/review`, a first-launch hold reports as Summer's, as before. The waiting answer no longer carries `approvalState`. - **Platforms a hosted release left out: `notIncluded` and `notUsed`.** `summer_go_live` reads `droppedTargets` from the release, answers `notIncluded` (one entry per platform: the reason, `checkErrorCode` and the line to tell the creator, such as "iOS isn't included: this build couldn't be checked for iPhone.") and goes live on the platforms the release kept instead of waiting for the others. `notUsed` names a newer client the release did not use (that platform stays live with its current client). The live answer's `next` names both. - **`playable_release_required` answers `blocked`** with the platform's own message, or "Upload a build that includes your game's apps so they can be checked against the new server." +- **Setup names an older `summer` on PATH.** After `npx -y summer-engine@latest setup`, a stale global `summer` (3.1.0) first on PATH ran on the next `summer login`. Setup now says which one it is and how to update it (`npm install -g summer-engine@latest`, or `brew upgrade summerengine/tap/summer`), and to use `npx -y summer-engine@latest login` until then; `--json` lists it in `warnings`. - **"Summer is still checking this; nothing to do on your side."** When a publish's checks have run over an hour, `summer_go_live`'s waiting answer and `summer_publish_build`'s hold say so, with the time (`stillCheckingSince`). - **`summer_publish_build`'s hold** reads the review too: on hold (with the changes to make), taken down, or Summer's own hold. `approval_denied` now reads "This game was taken down, so it can't take new builds." - **Copy:** the README, `AGENTS.md`, the project `AGENTS.md` block and the `publish-your-game` skill say a game is live once the automatic checks pass, with staff review afterwards, and tell an automatic-check rejection from a staff hold and a take-down. diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 4e8e7d0c..aaa19ecd 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -22,6 +22,7 @@ import { brandLine, c, sym, tildeify } from "../../core/format.js"; import { TOOLKIT_VERSION as cliVersion } from "../../core/version.js"; import { listsSummerEngine, resolveDefaultChannel } from "../../installer/version-check.js"; +import { findShadowingSummer, shadowWarning } from "../../installer/path-shadow.js"; import { agentLabel, agentSpec, cliSetupRefusal, defaultPathContext } from "../../installer/agent-table.js"; import { addAllowRules, allowRulesPath, removeAllowRules, supportsAllowRules, type AllowRulesResult } from "../../installer/cli-permissions.js"; import { PROFILES, parseProfile, type Profile } from "../../core/tool-profiles.js"; @@ -271,6 +272,7 @@ async function setupAgent(agentArg: string | undefined, opts: SetupCommandOption const projectFiles = projectStep.value; const doctor = await runDoctor({ quiet: true }); + const shadow = opts.print ? null : staleSummerOnPath(); if (json) { const steps: SetupStep[] = [ @@ -281,12 +283,12 @@ async function setupAgent(agentArg: string | undefined, opts: SetupCommandOption doctorStep(doctor), ]; const ok = steps.every((step) => step.ok); - console.log(JSON.stringify({ ok, agent, scope, steps, config, games, skills, projectFiles, doctor }, null, 2)); + console.log(JSON.stringify({ ok, agent, scope, steps, config, games, skills, projectFiles, doctor, ...(shadow ? { warnings: [shadow] } : {}) }, null, 2)); if (!ok) process.exit(1); return; } - printSetupResult(config!, games, skills, doctor, projectFiles); + printSetupResult(config!, games, skills, doctor, projectFiles, shadow); if (!doctor.ok || skills.status === "failed") { process.exit(1); } @@ -390,6 +392,8 @@ async function setupCliOnly(agent: SupportedAgent, scope: ConfigScope, opts: Set if (existing) { warnings.push(`${tildeify(existing)} still lists the summer-engine MCP server from an earlier setup. Remove that entry to use the CLI only.`); } + const shadow = staleSummerOnPath(); + if (shadow) warnings.push(shadow); const dryRun = Boolean(opts.dryRun); const skills = setupSkills(agent, { @@ -511,6 +515,16 @@ function printProjectFiles(project: ProjectFiles | null): void { } } +/** An older summer-engine first on PATH would run instead of this one after setup (`summer login`). */ +function staleSummerOnPath(): string | null { + try { + const found = findShadowingSummer(cliVersion); + return found ? shadowWarning(found, cliVersion) : null; + } catch { + return null; + } +} + /** The config file that already lists summer-engine for this agent and scope, or null. */ async function recordedMcpEntry(agent: SupportedAgent, scope: ConfigScope): Promise { try { @@ -540,7 +554,8 @@ function printSetupResult( games: Awaited>, skills: SkillSetupResult, doctor: DoctorResult, - projectFiles: ProjectFiles | null + projectFiles: ProjectFiles | null, + shadow: string | null = null ): void { if (config.print) { console.log(config.snippet.trimEnd()); @@ -593,7 +608,7 @@ function printSetupResult( printSkillsLine(skills, config.dryRun); printProjectFiles(projectFiles); - for (const warning of config.warnings) { + for (const warning of shadow ? [...config.warnings, shadow] : config.warnings) { console.log(` ${sym.warn()} ${c.yellow(warning)}`); } diff --git a/src/installer/path-shadow.test.ts b/src/installer/path-shadow.test.ts new file mode 100644 index 00000000..289d9150 --- /dev/null +++ b/src/installer/path-shadow.test.ts @@ -0,0 +1,60 @@ +import { delimiter, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { findShadowingSummer, shadowWarning, type PathShadowDeps } from "./path-shadow.js"; + +/** A fake disk: files with contents, and links (path -> target). */ +function disk(files: Record, links: Record = {}): PathShadowDeps { + return { + exists: (path) => path in files || path in links, + realpath: (path) => links[path] ?? path, + readFile: (path) => { + if (!(path in files)) throw new Error(`ENOENT ${path}`); + return files[path]; + }, + platform: "darwin", + packageRoot: "/npx/abc/node_modules/summer-engine", + }; +} + +const pkg = (version: string) => JSON.stringify({ name: "summer-engine", version }); +const npx = { + "/npx/abc/node_modules/summer-engine/package.json": pkg("4.8.0"), + "/npx/abc/node_modules/summer-engine/dist/bin/summer.js": "", +}; +const npxLink = { "/npx/abc/node_modules/.bin/summer": "/npx/abc/node_modules/summer-engine/dist/bin/summer.js" }; +const globalOld = { + "/usr/local/lib/node_modules/summer-engine/package.json": pkg("3.1.0"), + "/usr/local/lib/node_modules/summer-engine/dist/bin/summer.js": "", +}; +const globalLink = { "/usr/local/bin/summer": "/usr/local/lib/node_modules/summer-engine/dist/bin/summer.js" }; +const path = (...dirs: string[]) => ({ PATH: dirs.join(delimiter) }); + +describe("findShadowingSummer", () => { + it("names an older global summer behind npx's own copy", () => { + const found = findShadowingSummer("4.8.0", path("/npx/abc/node_modules/.bin", "/usr/local/bin"), disk({ ...npx, ...globalOld }, { ...npxLink, ...globalLink })); + expect(found).toEqual({ path: "/usr/local/bin/summer", version: "3.1.0", homebrew: false }); + expect(shadowWarning(found!, "4.8.0")).toContain("npm install -g summer-engine@latest"); + }); + + it("says nothing when the global summer is as new, or there is none", () => { + const current = { ...globalOld, "/usr/local/lib/node_modules/summer-engine/package.json": pkg("4.8.0") }; + expect(findShadowingSummer("4.8.0", path("/npx/abc/node_modules/.bin", "/usr/local/bin"), disk({ ...npx, ...current }, { ...npxLink, ...globalLink }))).toBeNull(); + expect(findShadowingSummer("4.8.0", path("/npx/abc/node_modules/.bin", "/usr/bin"), disk(npx, npxLink))).toBeNull(); + }); + + it("says nothing when the first summer is another program", () => { + const other = { "/opt/bin/summer": "#!/bin/sh" }; + expect(findShadowingSummer("4.8.0", path("/opt/bin", "/usr/local/bin"), disk({ ...other, ...globalOld }, globalLink))).toBeNull(); + }); + + it("tells a Homebrew install to upgrade with brew", () => { + const cellar = "/opt/homebrew/Cellar/summer/3.1.0/libexec/lib/node_modules/summer-engine"; + const found = findShadowingSummer( + "4.8.0", + path("/opt/homebrew/bin"), + disk({ [join(cellar, "package.json")]: pkg("3.1.0"), [join(cellar, "dist/bin/summer.js")]: "" }, { "/opt/homebrew/bin/summer": join(cellar, "dist/bin/summer.js") }) + ); + expect(found?.homebrew).toBe(true); + expect(shadowWarning(found!, "4.8.0")).toContain("brew upgrade summerengine/tap/summer"); + }); +}); diff --git a/src/installer/path-shadow.ts b/src/installer/path-shadow.ts new file mode 100644 index 00000000..0251d424 --- /dev/null +++ b/src/installer/path-shadow.ts @@ -0,0 +1,106 @@ +/** + * An older `summer` first on PATH. After `npx -y summer-engine@latest setup`, + * the person's next `summer login` runs whatever `summer` their shell finds + * first, which can be a stale global install (3.1.0 while setup ran 4.8.0). + * Setup names it and says how to update it. + */ + +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { delimiter, dirname, join, sep } from "node:path"; +import { PACKAGE_ROOT } from "../core/package-root.js"; +import { compareSemver, parseSemver } from "./version-check.js"; + +export interface ShadowingSummer { + /** The `summer` the shell runs, as found on PATH. */ + path: string; + version: string; + /** Installed by Homebrew (its Cellar), so brew updates it. */ + homebrew: boolean; +} + +export interface PathShadowDeps { + exists?: (path: string) => boolean; + realpath?: (path: string) => string; + readFile?: (path: string) => string; + platform?: NodeJS.Platform; + packageRoot?: string; +} + +/** The summer-engine package a `summer` launcher belongs to: up from where its link points, or the + * npm prefix beside a Windows `summer.cmd`. Null for any other program named summer. */ +function owningPackage(bin: string, deps: Required): { root: string; version: string } | null { + const candidates: string[] = []; + let dir = dirname(deps.realpath(bin)); + for (let i = 0; i < 6; i += 1) { + candidates.push(dir); + const up = dirname(dir); + if (up === dir) break; + dir = up; + } + candidates.push(join(dirname(bin), "node_modules", "summer-engine")); + for (const root of candidates) { + const manifest = join(root, "package.json"); + if (!deps.exists(manifest)) continue; + try { + const pkg = JSON.parse(deps.readFile(manifest)) as { name?: string; version?: string }; + if (pkg.name === "summer-engine" && pkg.version) return { root, version: pkg.version }; + } catch { + // not a package we can read: keep looking + } + } + return null; +} + +/** + * The first `summer` on PATH that is not this package, when it is an older summer-engine. + * npx puts its own copy (this package) first while setup runs; that copy is gone afterwards, + * so the next one is what the person's shell runs. + */ +export function findShadowingSummer( + runningVersion: string, + env: NodeJS.ProcessEnv = process.env, + overrides: PathShadowDeps = {} +): ShadowingSummer | null { + const deps: Required = { + exists: overrides.exists ?? existsSync, + realpath: overrides.realpath ?? ((path) => realpathSync(path)), + readFile: overrides.readFile ?? ((path) => readFileSync(path, "utf-8")), + platform: overrides.platform ?? process.platform, + packageRoot: overrides.packageRoot ?? PACKAGE_ROOT, + }; + const running = parseSemver(runningVersion); + if (!running) return null; + const names = deps.platform === "win32" ? ["summer.cmd", "summer.exe", "summer"] : ["summer"]; + const ours = safeRealpath(deps.packageRoot, deps); + for (const dir of (env.PATH ?? env.Path ?? "").split(delimiter)) { + if (!dir) continue; + const bin = names.map((name) => join(dir, name)).find((path) => deps.exists(path)); + if (!bin) continue; + let owner: { root: string; version: string } | null; + try { + owner = owningPackage(bin, deps); + } catch { + continue; + } + if (owner && safeRealpath(owner.root, deps) === ours) continue; + if (!owner) return null; + const found = parseSemver(owner.version); + if (!found || compareSemver(found, running) >= 0) return null; + return { path: bin, version: owner.version, homebrew: safeRealpath(bin, deps).includes(`${sep}Cellar${sep}`) }; + } + return null; +} + +function safeRealpath(path: string, deps: Required): string { + try { + return deps.realpath(path); + } catch { + return path; + } +} + +/** The one warning line: which `summer` the shell runs, and the command that updates it. */ +export function shadowWarning(found: ShadowingSummer, runningVersion: string): string { + const update = found.homebrew ? "brew upgrade summerengine/tap/summer" : "npm install -g summer-engine@latest"; + return `The summer command in your shell is ${found.version} (${found.path}), older than this setup (${runningVersion}), so summer login would run the old CLI. Update it: ${update}. Until then: npx -y summer-engine@latest login`; +}