From e81b84140adc63c7ebdfdb5cfa5e90ff88a03475 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Fri, 9 Oct 2026 12:24:34 +0200 Subject: [PATCH 1/4] feat(installer): build unsigned double-click installer downloads for macOS, Windows and Linux --- odd/tasks/double-click-installers.md | 27 ++++++ scripts/build-installer-bundles.mjs | 121 +++++++++++++++++++++++++++ tests/installer-bundles.test.ts | 106 +++++++++++++++++++++++ 3 files changed, 254 insertions(+) create mode 100644 odd/tasks/double-click-installers.md create mode 100644 scripts/build-installer-bundles.mjs create mode 100644 tests/installer-bundles.test.ts diff --git a/odd/tasks/double-click-installers.md b/odd/tasks/double-click-installers.md new file mode 100644 index 000000000..906be4dbd --- /dev/null +++ b/odd/tasks/double-click-installers.md @@ -0,0 +1,27 @@ +# Double-click installers (unsigned) + +Objective: a person who does not use a terminal downloads one file, double-clicks it, and the installation wizard opens and runs. +Branch: `feat/double-click-installers` (from `main` be2869b7b). Delivery: single PR. Test runner: `node --experimental-strip-types --test `; suite `pnpm test`. + +## Specs + +- S1. "como un ejecutor clásico de computadora para una persona que no sabe. Bien, entonces vos entrás, le das un doble clic, se abre el instalador y hace los pasos": one downloadable file per system whose double-click starts the existing bootstrap and wizard, with no git, clone or typed command — macOS (`.command`), Windows (`.cmd`), Linux (`.sh`). +- S2. "sin firma": the downloads are unsigned; no code-signing or notarization. +- S3. "indicando esto que me dices en el readme": the README explains the download and the operating-system warnings an unsigned file triggers and how to continue past them (macOS Gatekeeper "Open Anyway", Windows SmartScreen "More info → Run anyway", extracting the zip first). +- S4 (assumption, needed by S1): every release gets the installers attached with stable names, so a "latest" download link always points at the newest release, plus SHA-256 checksums. + +## Tasks + +| ID | Specs | Route | Status | Evidence | +|----|-------|-------|--------|----------| +| T1 | S1, S2, S4 (bundle builder: wizard files + launchers per OS, archives, checksums) | inline | pending | | +| T2 | S4 (publish.yml: attach the installers to the release after the verified publication) | inline | pending | | +| T3 | S3 (README download section with unsigned warnings; install-wizard doc) | inline | pending | | + +Route evidence: no Writer trigger (sequential, shared bundle layout). Risk: item 5 (release workflow) → independent verifier unless the native assessment says otherwise. + +## Log + +- L1 (2026-10-09, user): "Cuéntame ahora cómo aparece en el readme todo el tema de la instalación fácil con el web. Bien, porque de vuelta, esto necesito que sea como un ejecutor clásico de computadora para una persona que no sabe. Bien, entonces vos entrás, le das un doble clic, se abre el instalador y hace los pasos. Esa es la idea." +- L2 (2026-10-09, user decision): "sin firma, indicando esto que me dices en el readme" +- L3 (evidence): README Path C requires `git clone` and a terminal command. `publish.yml` only publishes npm; the GitHub release is created before it runs (gentle-pi-release skill). The wizard imports only `node:` modules, so a bundle needs no dependencies. diff --git a/scripts/build-installer-bundles.mjs b/scripts/build-installer-bundles.mjs new file mode 100644 index 000000000..52b747568 --- /dev/null +++ b/scripts/build-installer-bundles.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +// Double-click installers: one unsigned download per system that opens the +// browser installation wizard with no git, clone or typed command. +// +// Each archive holds a launcher next to an `installer/` folder with the +// wizard's own files (they import only `node:` modules, so no dependencies). +// The launcher runs the bundled bootstrap, which fetches Node.js and pnpm when +// they are missing and opens the wizard in the browser. Real files only: the +// bootstrap refuses symlinked bundle files. +// +// node scripts/build-installer-bundles.mjs --out +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, posix, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { installerPaths } from "./verify-package-files.mjs"; + +/** Stable names, so `releases/latest/download/` always points at the newest release. */ +export const INSTALLER_ASSETS = Object.freeze([ + "gentle-shell-installer-macos.zip", + "gentle-shell-installer-windows.zip", + "gentle-shell-installer-linux.tar.gz", +]); +export const INSTALLER_CHECKSUMS = "gentle-shell-installers-SHA256SUMS.txt"; + +const RELATIVE_IMPORT = /(?:from\s+|import\s*\(\s*)["'](\.{1,2}\/[^"']+)["']/g; + +/** Wizard files, package.json and everything their modules import, sorted. */ +export function bundleFiles(root) { + const files = new Set(["package.json", ...installerPaths]); + const pending = [...files].filter((path) => path.endsWith(".mjs")); + while (pending.length > 0) { + const file = pending.pop(); + for (const [, specifier] of readFileSync(join(root, file), "utf8").matchAll(RELATIVE_IMPORT)) { + const target = posix.normalize(posix.join(posix.dirname(file), specifier)); + if (files.has(target)) continue; + if (!existsSync(join(root, target))) throw new Error(`${file} imports ${specifier}, which does not exist`); + files.add(target); + if (target.endsWith(".mjs")) pending.push(target); + } + } + return [...files].sort(); +} + +const unixLauncher = (title) => [ + "#!/bin/sh", + `# ${title}: double-click to install or update Gentle Shell.`, + "# It runs the bundled bootstrap, which opens the installation wizard in your browser.", + 'cd "$(dirname "$0")" || exit 1', + 'sh "./installer/scripts/bootstrap.sh"', + "status=$?", + 'printf "\\n%s\\n" "You can close this window."', + 'exit "$status"', + "", +].join("\n"); + +const windowsLauncher = [ + "@echo off", + "rem Double-click to install or update Gentle Shell.", + "rem It runs the bundled bootstrap, which opens the installation wizard in your browser.", + 'call "%~dp0installer\\scripts\\bootstrap.cmd"', + "echo.", + "echo You can close this window.", + "pause", + "", +].join("\r\n"); + +function run(command, args, cwd) { + const result = spawnSync(command, args, { cwd, encoding: "utf8" }); + if (result.error || result.status !== 0) throw new Error(`${command} ${args.join(" ")} failed: ${result.error?.message ?? result.stderr}`); +} + +function stage(root, folder, files, launcher) { + mkdirSync(folder, { recursive: true }); + for (const file of files) { + const target = join(folder, "installer", file); + mkdirSync(dirname(target), { recursive: true }); + copyFileSync(join(root, file), target); + if (file.endsWith(".sh")) chmodSync(target, 0o755); + } + const path = join(folder, launcher.name); + writeFileSync(path, launcher.content); + chmodSync(path, 0o755); +} + +/** Builds the three downloads and their checksums into `outDir`; returns their paths. */ +export function buildInstallerBundles({ root, outDir }) { + const files = bundleFiles(root); + const work = mkdtempSync(join(tmpdir(), "gentle-shell-installers-")); + try { + mkdirSync(outDir, { recursive: true }); + const zipped = (asset, folder, launcher) => { + stage(root, join(work, asset, folder), files, launcher); + rmSync(join(outDir, asset), { force: true }); + run("zip", ["-q", "-r", join(outDir, asset), folder], join(work, asset)); + }; + zipped(INSTALLER_ASSETS[0], "Gentle Shell Installer", { name: "Install Gentle Shell.command", content: unixLauncher("macOS") }); + zipped(INSTALLER_ASSETS[1], "Gentle Shell Installer", { name: "Install Gentle Shell.cmd", content: windowsLauncher }); + const linux = join(work, INSTALLER_ASSETS[2]); + stage(root, join(linux, "gentle-shell-installer"), files, { name: "install-gentle-shell.sh", content: unixLauncher("Linux") }); + run("tar", ["-czf", join(outDir, INSTALLER_ASSETS[2]), "gentle-shell-installer"], linux); + const sums = INSTALLER_ASSETS.map((asset) => `${createHash("sha256").update(readFileSync(join(outDir, asset))).digest("hex")} ${asset}\n`); + writeFileSync(join(outDir, INSTALLER_CHECKSUMS), sums.join("")); + return [...INSTALLER_ASSETS, INSTALLER_CHECKSUMS].map((asset) => join(outDir, asset)); + } finally { + rmSync(work, { recursive: true, force: true }); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const at = process.argv.indexOf("--out"); + const out = at === -1 ? undefined : process.argv[at + 1]; + if (!out || process.argv.length !== 4) { + console.error("usage: node scripts/build-installer-bundles.mjs --out "); + process.exit(2); + } + const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + for (const path of buildInstallerBundles({ root, outDir: resolve(out) })) console.log(path); +} diff --git a/tests/installer-bundles.test.ts b/tests/installer-bundles.test.ts new file mode 100644 index 000000000..87bc2d99e --- /dev/null +++ b/tests/installer-bundles.test.ts @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { INSTALLER_ASSETS, buildInstallerBundles, bundleFiles } from "../scripts/build-installer-bundles.mjs"; +import { installerPaths } from "../scripts/verify-package-files.mjs"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const posixOnly = { skip: process.platform === "win32" }; +const relativeImports = (text: string) => [...text.matchAll(/(?:from\s+|import\s*\(\s*)["'](\.{1,2}\/[^"']+)["']/g)].map((match) => match[1]); + +test("the bundle holds every wizard file, package.json and everything they import, and nothing from tests", () => { + const files = bundleFiles(root); + for (const path of [...installerPaths, "package.json"]) assert.ok(files.includes(path), path); + for (const file of files.filter((path) => path.endsWith(".mjs"))) { + for (const specifier of relativeImports(readFileSync(join(root, file), "utf8"))) { + const target = join(dirname(file), specifier).replaceAll("\\", "/"); + assert.ok(files.includes(target), `${file} imports ${specifier}, missing from the bundle`); + } + } + assert.equal(files.some((path) => path.startsWith("tests/") || path.startsWith("node_modules/")), false); + assert.deepEqual([...files].sort(), files, "sorted, so the archives are reproducible in content"); +}); + +function build() { + const out = realpathSync(mkdtempSync(join(tmpdir(), "installer-bundles-"))); + const artifacts = buildInstallerBundles({ root, outDir: out }); + return { out, artifacts, cleanup: () => rmSync(out, { recursive: true, force: true }) }; +} +const listing = (command: string, args: string[]) => spawnSync(command, args, { encoding: "utf8" }).stdout; + +test("one download per system, with stable names and SHA-256 checksums", posixOnly, () => { + const b = build(); + try { + assert.deepEqual(INSTALLER_ASSETS, ["gentle-shell-installer-macos.zip", "gentle-shell-installer-windows.zip", "gentle-shell-installer-linux.tar.gz"]); + assert.deepEqual(b.artifacts.map((path: string) => path.slice(b.out.length + 1)), [...INSTALLER_ASSETS, "gentle-shell-installers-SHA256SUMS.txt"]); + const sums = readFileSync(join(b.out, "gentle-shell-installers-SHA256SUMS.txt"), "utf8"); + for (const name of INSTALLER_ASSETS) { + const digest = createHash("sha256").update(readFileSync(join(b.out, name))).digest("hex"); + assert.ok(sums.includes(`${digest} ${name}\n`), name); + } + } finally { b.cleanup(); } +}); + +test("macOS and Linux double-click launchers are executable and start the bundled bootstrap", posixOnly, () => { + const b = build(); + try { + const mac = listing("zipinfo", [join(b.out, "gentle-shell-installer-macos.zip")]); + assert.match(mac, /^-rwxr-xr-x .* Gentle Shell Installer\/Install Gentle Shell\.command$/m); + assert.match(mac, / Gentle Shell Installer\/installer\/bin\/gentle-shell-install\.mjs$/m); + const linux = listing("tar", ["-tvzf", join(b.out, "gentle-shell-installer-linux.tar.gz")]); + assert.match(linux, /^-rwxr-xr-x .* gentle-shell-installer\/install-gentle-shell\.sh$/m); + const launcher = listing("unzip", ["-p", join(b.out, "gentle-shell-installer-macos.zip"), "Gentle Shell Installer/Install Gentle Shell.command"]); + assert.match(launcher, /^#!\/bin\/sh\n/); + assert.ok(launcher.includes('sh "./installer/scripts/bootstrap.sh"')); + } finally { b.cleanup(); } +}); + +test("the Windows launcher is a CRLF batch file that calls the bundled bootstrap and keeps its window open", posixOnly, () => { + const b = build(); + try { + const zip = join(b.out, "gentle-shell-installer-windows.zip"); + const launcher = listing("unzip", ["-p", zip, "Gentle Shell Installer/Install Gentle Shell.cmd"]); + assert.ok(launcher.split("\n").slice(0, -1).every((line: string) => line.endsWith("\r")), "CRLF line endings"); + assert.ok(launcher.includes('call "%~dp0installer\\scripts\\bootstrap.cmd"')); + assert.ok(launcher.includes("pause")); + assert.match(listing("unzip", ["-p", zip, "Gentle Shell Installer/installer/scripts/bootstrap.cmd"]), /\r\n/); + } finally { b.cleanup(); } +}); + +test("an extracted bundle runs on its own: real files only, and every wizard module loads", posixOnly, () => { + const b = build(); + const target = realpathSync(mkdtempSync(join(tmpdir(), "installer-extract-"))); + try { + assert.equal(spawnSync("tar", ["-xzf", join(b.out, "gentle-shell-installer-linux.tar.gz"), "-C", target]).status, 0); + const bundle = join(target, "gentle-shell-installer", "installer"); + for (const file of bundleFiles(root)) { + assert.ok(existsSync(join(bundle, file)), file); + assert.equal(lstatSync(join(bundle, file)).isSymbolicLink(), false, file); + } + // Paths go through the environment: as argv[1] the wizard entry would think it was run and start. + const load = spawnSync(process.execPath, ["--input-type=module", "-e", + "for (const m of process.env.BUNDLE_MODULES.split('\\n')) await import(m); console.log('loaded')"], + { cwd: tmpdir(), encoding: "utf8", timeout: 30_000, + env: { ...process.env, BUNDLE_MODULES: [join(bundle, "bin/gentle-shell-install.mjs"), join(bundle, "scripts/installer-downloads.mjs")].join("\n") } }); + assert.equal(load.status, 0, load.stderr); + assert.equal(load.stdout.trim(), "loaded"); + } finally { + b.cleanup(); + rmSync(target, { recursive: true, force: true }); + } +}); + +test("the builder command writes the downloads to the requested folder", posixOnly, () => { + const out = realpathSync(mkdtempSync(join(tmpdir(), "installer-cli-"))); + try { + mkdirSync(join(out, "dist")); + const result = spawnSync(process.execPath, [join(root, "scripts/build-installer-bundles.mjs"), "--out", join(out, "dist")], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + for (const name of INSTALLER_ASSETS) assert.ok(existsSync(join(out, "dist", name)), name); + } finally { rmSync(out, { recursive: true, force: true }); } +}); From 3e04bcb8aa4bf720f740a235581ab513258a1817 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Fri, 9 Oct 2026 12:25:56 +0200 Subject: [PATCH 2/4] ci(release): attach the double-click installers to every release --- .github/workflows/publish.yml | 30 ++++++++++++++++++++++++++++++ tests/package-manifest.test.ts | 17 +++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 876b48f2e..89c68292a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -232,3 +232,33 @@ jobs: fi npm publish --provenance --access public --tag "${DIST_TAG}" + + installers: + # Unsigned double-click installers (macOS .command, Windows .cmd, Linux .sh), + # built from the same verified release commit once npm publication succeeded, + # with stable names so releases/latest/download/ links keep working. + needs: publish + if: github.repository == 'Gentleman-Programming/gentle-shell' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + ref: ${{ github.sha }} + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "24" + + - name: Build double-click installers + run: node scripts/build-installer-bundles.mjs --out "${RUNNER_TEMP}/installers" + + - name: Attach installers to the release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + run: gh release upload "${RELEASE_TAG}" "${RUNNER_TEMP}"/installers/* --repo "${GITHUB_REPOSITORY}" --clobber diff --git a/tests/package-manifest.test.ts b/tests/package-manifest.test.ts index 4bce8f668..03305167c 100644 --- a/tests/package-manifest.test.ts +++ b/tests/package-manifest.test.ts @@ -254,6 +254,23 @@ test("package verification names the native review runtime boundary and packaged ); }); +test("double-click installers are attached to the release only after the verified publication", () => { + const workflow = readFileSync(join(PACKAGE_ROOT, ".github", "workflows", "publish.yml"), "utf8"); + const job = workflow.match(/^ {2}installers:\n([\s\S]*?)(?=^ {2}[A-Za-z0-9_-]+:\n|(?![\s\S]))/m)?.[1]; + assert.ok(job, "publish.yml has an installers job"); + assert.match(job, /^ {4}needs: publish$/m, "installers wait for the verified npm publication"); + assert.match(job, /^ {4}if: github\.repository == 'Gentleman-Programming\/gentle-shell'$/m); + assert.match(job, /^ {6}contents: write$/m, "only this job may write the release"); + assert.match(job, /ref: \$\{\{ github\.sha \}\}/, "the installers come from the verified release commit"); + assert.match(job, /persist-credentials: false/); + assert.match(job, /node scripts\/build-installer-bundles\.mjs --out "\$\{RUNNER_TEMP\}\/installers"/); + assert.match(job, /RELEASE_TAG: \$\{\{ inputs\.tag \}\}/); + assert.match(job, /gh release upload "\$\{RELEASE_TAG\}" "\$\{RUNNER_TEMP\}"\/installers\/\* --repo "\$\{GITHUB_REPOSITORY\}" --clobber/); + const publish = workflow.match(/^ {2}publish:\n([\s\S]*?)(?=^ {2}installers:\n)/m)?.[1]; + assert.ok(publish); + assert.doesNotMatch(publish, /contents: write/, "the npm publication job keeps read-only contents"); +}); + test("npm publication is bound to the exact package tag and triggering commit", () => { const workflow = readFileSync(join(PACKAGE_ROOT, ".github", "workflows", "publish.yml"), "utf8"); const packageJson = readPackageJson(); From 98cb92c69eb5a3b3e0432ae6f326fd93c55128b7 Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Fri, 9 Oct 2026 12:26:49 +0200 Subject: [PATCH 3/4] docs(readme): lead with the double-click installer and explain unsigned warnings --- README.md | 28 +++++++++++++++++++++++++--- docs/install-wizard.md | 33 ++++++++++++++++++++++++++++----- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 30bf1a16c..fa9e45944 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,28 @@ The [v3.5.1 release](https://github.com/Gentleman-Programming/gentle-shell/relea > **Naming transition:** The product is called `gentle-shell`; the current npm package and repository remain `gentle-pi` until migration. +### Easiest: download and double-click (no terminal needed) + +Download the installer for your computer, double-click it, and follow the steps in your browser. It installs everything Gentle Shell needs (Node.js, pnpm, Pi and Gentle Shell), or updates what you already have, and changes nothing until you confirm. + +| Your computer | Download | Then | +| --- | --- | --- | +| **macOS** | [gentle-shell-installer-macos.zip](https://github.com/Gentleman-Programming/gentle-shell/releases/latest/download/gentle-shell-installer-macos.zip) | Open the zip, then double-click **Install Gentle Shell.command** inside the *Gentle Shell Installer* folder. | +| **Windows** | [gentle-shell-installer-windows.zip](https://github.com/Gentleman-Programming/gentle-shell/releases/latest/download/gentle-shell-installer-windows.zip) | Right-click the zip → **Extract All**, open the extracted folder, then double-click **Install Gentle Shell.cmd**. It does not run from inside the zip. | +| **Linux** | [gentle-shell-installer-linux.tar.gz](https://github.com/Gentleman-Programming/gentle-shell/releases/latest/download/gentle-shell-installer-linux.tar.gz) | Extract it, then double-click **install-gentle-shell.sh**. If your file manager does not run scripts, run `sh install-gentle-shell.sh` in that folder. | + +A small window opens, then the installer appears in your browser. Use the tab it opens, and keep the small window open until the installer says it is done. + +> **Your computer will warn you the first time.** These installers are not signed with an Apple or Microsoft developer certificate yet, so the system cannot verify who made them. That warning is expected; here is how to continue: +> +> - **macOS:** you see *"Install Gentle Shell.command" Not Opened* (or *cannot be opened because Apple cannot check it*). Click **Done**, open **System Settings → Privacy & Security**, scroll down to the message about *Install Gentle Shell.command*, click **Open Anyway**, and confirm with your password. On older macOS versions you can instead right-click the file, choose **Open**, then **Open** again. You only do this once. +> - **Windows:** you see *Windows protected your PC*: click **More info**, then **Run anyway**. If you see *Open File – Security Warning* instead, click **Run**. +> - **Linux:** if double-click opens the file in an editor, run `sh install-gentle-shell.sh` in that folder instead. +> +> Only download from this repository's [Releases](https://github.com/Gentleman-Programming/gentle-shell/releases) page. To check a download, compare its SHA-256 with [gentle-shell-installers-SHA256SUMS.txt](https://github.com/Gentleman-Programming/gentle-shell/releases/latest/download/gentle-shell-installers-SHA256SUMS.txt). + +The installer is a preview: it is tested on Linux and in CI, while clean-machine runs on macOS and Windows are still being verified. How it works and what it changes: **[installation wizard](docs/install-wizard.md)**. + ### Path A: standalone `gentle-shell` (recommended, no pi changes) `gentle-shell` opens Pi with the Gentle Shell package loaded, without installing it into your pi agent or editing its `settings.json`. @@ -289,9 +311,9 @@ See the [v3.5.1 release notes](https://github.com/Gentleman-Programming/gentle-s **Builtin codemode warning.** gentle-pi replaces Pi's builtin `codemode` with its compact renderer, so Pi warns at startup that the builtin was not loaded. In your own Pi home (`pi` with this package, or `gentle-shell --link`), gentle-pi asks once in the interactive TUI whether to add `"-builtin:codemode"` to `extensions` in the agent `settings.json` (usually `~/.pi/agent/settings.json`); it writes only if you accept, and the warning disappears from the next launch. A declined prompt is not repeated. To silence it by hand, add the entry yourself, for example `"extensions": ["-builtin:codemode"]`. Isolated `gentle-shell` homes already carry it. -### Path C: browser installer (preview) +### Path C: browser installer from a checkout -The browser installer sets up Gentle Shell on a new computer or one that already has some of it: it installs what is missing (Node.js, pnpm, Pi, Gentle Shell) and updates an existing Gentle Shell. It checks the machine, shows exactly what it will change, and changes nothing until you confirm. +The same installer as the [download above](#easiest-download-and-double-click-no-terminal-needed), started from a clone instead of a download: ```bash git clone https://github.com/Gentleman-Programming/gentle-shell.git @@ -304,7 +326,7 @@ sh scripts/bootstrap.sh scripts\bootstrap.cmd ``` -The bootstrap gets Node.js and pnpm into a temporary folder if they are missing, then opens the wizard in your browser at a private `127.0.0.1` address. Use the tab it opens: the link works once and expires after 2 minutes. On the review screen, choose what to install: +It installs what is missing (Node.js, pnpm, Pi, Gentle Shell) and updates an existing Gentle Shell; nothing changes until you confirm. The bootstrap gets Node.js and pnpm into a temporary folder if they are missing, then opens the wizard in your browser at a private `127.0.0.1` address. Use the tab it opens: the link works once and expires after 2 minutes. On the review screen, choose what to install: - **Latest release** (recommended): the published Gentle Shell with its pinned Gentle AI binary. - **Latest main**: development builds of Gentle Shell and Gentle AI from the latest commit of `main`, built on your computer. Needs Go. diff --git a/docs/install-wizard.md b/docs/install-wizard.md index bf88eb118..aca183b6c 100644 --- a/docs/install-wizard.md +++ b/docs/install-wizard.md @@ -5,11 +5,34 @@ foundation, the standard installation runner module (including runtime persistence), the secure local wizard host with its packaged entry and the interactive browser wizard UI are implemented. Windows native validation remains unavailable locally. -**Preview:** run it from a checkout with `sh scripts/bootstrap.sh` (macOS, -Linux) or `scripts\bootstrap.cmd` (Windows), as the [README](../README.md#path-c-browser-installer-preview) -shows. There are no published bootstrap artifacts yet, and native macOS/Windows -clean-machine runs are still unverified (T7); one Linux clean-container run -passed (see [Clean-machine acceptance](#clean-machine-acceptance-t7)). +**Preview:** download the unsigned double-click installer for your system +from a release, or run it from a checkout with `sh scripts/bootstrap.sh` +(macOS, Linux) or `scripts\bootstrap.cmd` (Windows), as the +[README](../README.md#easiest-download-and-double-click-no-terminal-needed) +shows. Native macOS/Windows clean-machine runs are still unverified (T7); one +Linux clean-container run passed (see +[Clean-machine acceptance](#clean-machine-acceptance-t7)). + +### Double-click downloads + +[`scripts/build-installer-bundles.mjs`](../scripts/build-installer-bundles.mjs) +builds three archives with stable names, attached to every release by the +`installers` job of `publish.yml` after the verified npm publication (same +commit, `contents: write` only in that job), with +`gentle-shell-installers-SHA256SUMS.txt`: + +| Asset | Launcher | +| --- | --- | +| `gentle-shell-installer-macos.zip` | `Gentle Shell Installer/Install Gentle Shell.command` | +| `gentle-shell-installer-windows.zip` | `Gentle Shell Installer/Install Gentle Shell.cmd` (CRLF, ends with `pause`) | +| `gentle-shell-installer-linux.tar.gz` | `gentle-shell-installer/install-gentle-shell.sh` | + +Each launcher runs `installer/scripts/bootstrap.sh` (or `bootstrap.cmd`) next to +it; `installer/` holds `package.json`, every `installerPaths` file and every +module they import (only `node:` built-ins otherwise), as real files because the +bootstrap refuses symlinked bundle files. The downloads are **unsigned**: +macOS Gatekeeper and Windows SmartScreen warn on first run, and the README +explains how to continue. Signing and notarization are not done yet. ## What is available From ac91c0b240a943ac8c667bdfd37574e7b142619c Mon Sep 17 00:00:00 2001 From: Alan Buscaglia Date: Fri, 9 Oct 2026 12:35:22 +0200 Subject: [PATCH 4/4] fix(installer): report failed installs, follow file URLs and upload with the verified tag --- .github/workflows/publish.yml | 6 +++- README.md | 6 ++-- docs/install-wizard.md | 7 +++++ odd/tasks/double-click-installers.md | 9 ++++-- scripts/build-installer-bundles.mjs | 41 +++++++++++++++++++--------- tests/installer-bundles.test.ts | 20 ++++++++++++-- tests/package-manifest.test.ts | 4 ++- 7 files changed, 70 insertions(+), 23 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 89c68292a..2720da1d7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,6 +19,8 @@ jobs: permissions: contents: read id-token: write + outputs: + tag: ${{ steps.release.outputs.tag }} steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -253,6 +255,7 @@ jobs: uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: "24" + package-manager-cache: false - name: Build double-click installers run: node scripts/build-installer-bundles.mjs --out "${RUNNER_TEMP}/installers" @@ -260,5 +263,6 @@ jobs: - name: Attach installers to the release env: GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ inputs.tag }} + # The tag the publish job verified against protected main, not the raw input. + RELEASE_TAG: ${{ needs.publish.outputs.tag }} run: gh release upload "${RELEASE_TAG}" "${RUNNER_TEMP}"/installers/* --repo "${GITHUB_REPOSITORY}" --clobber diff --git a/README.md b/README.md index fa9e45944..29f03661f 100644 --- a/README.md +++ b/README.md @@ -242,15 +242,15 @@ The [v3.5.1 release](https://github.com/Gentleman-Programming/gentle-shell/relea ### Easiest: download and double-click (no terminal needed) -Download the installer for your computer, double-click it, and follow the steps in your browser. It installs everything Gentle Shell needs (Node.js, pnpm, Pi and Gentle Shell), or updates what you already have, and changes nothing until you confirm. +Download the installer for your computer, double-click it, and follow the steps in your browser. It installs everything Gentle Shell needs (Node.js, pnpm, Pi and Gentle Shell), or updates what you already have, and installs nothing until you confirm the plan it shows you. (To show that plan it may first download Node.js and pnpm into a temporary folder, which it removes afterwards.) | Your computer | Download | Then | | --- | --- | --- | | **macOS** | [gentle-shell-installer-macos.zip](https://github.com/Gentleman-Programming/gentle-shell/releases/latest/download/gentle-shell-installer-macos.zip) | Open the zip, then double-click **Install Gentle Shell.command** inside the *Gentle Shell Installer* folder. | -| **Windows** | [gentle-shell-installer-windows.zip](https://github.com/Gentleman-Programming/gentle-shell/releases/latest/download/gentle-shell-installer-windows.zip) | Right-click the zip → **Extract All**, open the extracted folder, then double-click **Install Gentle Shell.cmd**. It does not run from inside the zip. | +| **Windows** | [gentle-shell-installer-windows.zip](https://github.com/Gentleman-Programming/gentle-shell/releases/latest/download/gentle-shell-installer-windows.zip) | Right-click the zip → **Extract All**. Open the extracted folder, then the *Gentle Shell Installer* folder inside it, and double-click **Install Gentle Shell.cmd**. It does not run from inside the zip. | | **Linux** | [gentle-shell-installer-linux.tar.gz](https://github.com/Gentleman-Programming/gentle-shell/releases/latest/download/gentle-shell-installer-linux.tar.gz) | Extract it, then double-click **install-gentle-shell.sh**. If your file manager does not run scripts, run `sh install-gentle-shell.sh` in that folder. | -A small window opens, then the installer appears in your browser. Use the tab it opens, and keep the small window open until the installer says it is done. +A small window opens, then the installer appears in your browser. Use the tab it opens, and keep the small window open until the installer says it is done. If a download link does not open, the newest release does not include the installers yet: use [Path C](#path-c-browser-installer-from-a-checkout) meanwhile. > **Your computer will warn you the first time.** These installers are not signed with an Apple or Microsoft developer certificate yet, so the system cannot verify who made them. That warning is expected; here is how to continue: > diff --git a/docs/install-wizard.md b/docs/install-wizard.md index aca183b6c..cad1e6136 100644 --- a/docs/install-wizard.md +++ b/docs/install-wizard.md @@ -34,6 +34,13 @@ bootstrap refuses symlinked bundle files. The downloads are **unsigned**: macOS Gatekeeper and Windows SmartScreen warn on first run, and the README explains how to continue. Signing and notarization are not done yet. +The `installers` job uploads to the release with the tag the `publish` job +verified (`needs.publish.outputs.tag`). It runs after npm publication, so its +failure never affects npm, but it marks the run failed: recover with +`gh run rerun --failed`, not a new `publish.yml` dispatch (that version +is already on npm). Each launcher reports whether the bootstrap succeeded before +it lets the window close. + ## What is available `scripts/installer-preflight.mjs` exports four small integration surfaces: diff --git a/odd/tasks/double-click-installers.md b/odd/tasks/double-click-installers.md index 906be4dbd..3dc91c21e 100644 --- a/odd/tasks/double-click-installers.md +++ b/odd/tasks/double-click-installers.md @@ -14,9 +14,9 @@ Branch: `feat/double-click-installers` (from `main` be2869b7b). Delivery: single | ID | Specs | Route | Status | Evidence | |----|-------|-------|--------|----------| -| T1 | S1, S2, S4 (bundle builder: wizard files + launchers per OS, archives, checksums) | inline | pending | | -| T2 | S4 (publish.yml: attach the installers to the release after the verified publication) | inline | pending | | -| T3 | S3 (README download section with unsigned warnings; install-wizard doc) | inline | pending | | +| T1 | S1, S2, S4 (bundle builder: wizard files + launchers per OS, archives, checksums) | inline | done e81b84140 | RED (module missing) → GREEN installer-bundles 6/0; downloads ~100 KB; ditto keeps the .command executable | +| T2 | S4 (publish.yml: attach the installers to the release after the verified publication) | inline | done 3e04bcb8a | RED→GREEN package-manifest workflow test; actionlint unavailable | +| T3 | S3 (README download section with unsigned warnings; install-wizard doc) | inline | done 98cb92c69 | structural readback; anchors checked by the verifier | Route evidence: no Writer trigger (sequential, shared bundle layout). Risk: item 5 (release workflow) → independent verifier unless the native assessment says otherwise. @@ -25,3 +25,6 @@ Route evidence: no Writer trigger (sequential, shared bundle layout). Risk: item - L1 (2026-10-09, user): "Cuéntame ahora cómo aparece en el readme todo el tema de la instalación fácil con el web. Bien, porque de vuelta, esto necesito que sea como un ejecutor clásico de computadora para una persona que no sabe. Bien, entonces vos entrás, le das un doble clic, se abre el instalador y hace los pasos. Esa es la idea." - L2 (2026-10-09, user decision): "sin firma, indicando esto que me dices en el readme" - L3 (evidence): README Path C requires `git clone` and a terminal command. `publish.yml` only publishes npm; the GitHub release is created before it runs (gentle-pi-release skill). The wizard imports only `node:` modules, so a bundle needs no dependencies. +- L4 (verification): native assess be2869b7b..98cb92c69 = high (shell_source in publish.yml). Independent verifier: PASS S1–S4, no blockers. Native review lineage review-6d42a119a798ceaa approved by risk, readability and reliability lenses; authority burned. Full `pnpm test`: only the three failures already on main. +- L5 (follow-up from verifier and review advisories): launchers report a failed bootstrap; the scanner also follows `new URL("./file", import.meta.url)`; the installers job uploads with the publish job's verified tag and `package-manager-cache: false`; named assets; README Windows folders, temporary-tools wording and a fallback when links are not live yet; recovery note in docs. Not verified: real double-click on clean macOS/Windows/Linux, Gatekeeper/SmartScreen screens, the job on a real runner. + diff --git a/scripts/build-installer-bundles.mjs b/scripts/build-installer-bundles.mjs index 52b747568..1d1a250e1 100644 --- a/scripts/build-installer-bundles.mjs +++ b/scripts/build-installer-bundles.mjs @@ -18,14 +18,21 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { installerPaths } from "./verify-package-files.mjs"; /** Stable names, so `releases/latest/download/` always points at the newest release. */ -export const INSTALLER_ASSETS = Object.freeze([ - "gentle-shell-installer-macos.zip", - "gentle-shell-installer-windows.zip", - "gentle-shell-installer-linux.tar.gz", -]); +const MACOS_ASSET = "gentle-shell-installer-macos.zip"; +const WINDOWS_ASSET = "gentle-shell-installer-windows.zip"; +const LINUX_ASSET = "gentle-shell-installer-linux.tar.gz"; +export const INSTALLER_ASSETS = Object.freeze([MACOS_ASSET, WINDOWS_ASSET, LINUX_ASSET]); export const INSTALLER_CHECKSUMS = "gentle-shell-installers-SHA256SUMS.txt"; -const RELATIVE_IMPORT = /(?:from\s+|import\s*\(\s*)["'](\.{1,2}\/[^"']+)["']/g; +// Static and dynamic imports, and files a module reads next to itself through +// `new URL("./file.ext", import.meta.url)`. Folder URLs (a trailing slash) are not +// followed: their files are listed in installerPaths. +const RELATIVE_REFERENCE = /(?:from\s+|import\s*\(\s*|new URL\(\s*)["'](\.{1,2}\/[^"']*[^/"'])["']/g; + +/** Relative module and file references in a module's source. */ +export function relativeReferences(text) { + return [...text.matchAll(RELATIVE_REFERENCE)].map((match) => match[1]); +} /** Wizard files, package.json and everything their modules import, sorted. */ export function bundleFiles(root) { @@ -33,7 +40,7 @@ export function bundleFiles(root) { const pending = [...files].filter((path) => path.endsWith(".mjs")); while (pending.length > 0) { const file = pending.pop(); - for (const [, specifier] of readFileSync(join(root, file), "utf8").matchAll(RELATIVE_IMPORT)) { + for (const specifier of relativeReferences(readFileSync(join(root, file), "utf8"))) { const target = posix.normalize(posix.join(posix.dirname(file), specifier)); if (files.has(target)) continue; if (!existsSync(join(root, target))) throw new Error(`${file} imports ${specifier}, which does not exist`); @@ -51,7 +58,11 @@ const unixLauncher = (title) => [ 'cd "$(dirname "$0")" || exit 1', 'sh "./installer/scripts/bootstrap.sh"', "status=$?", - 'printf "\\n%s\\n" "You can close this window."', + 'if [ "$status" -eq 0 ]; then', + ' printf "\\n%s\\n" "You can close this window."', + "else", + ' printf "\\n%s\\n" "The installer stopped. Read the messages above, then close this window."', + "fi", 'exit "$status"', "", ].join("\n"); @@ -62,7 +73,11 @@ const windowsLauncher = [ "rem It runs the bundled bootstrap, which opens the installation wizard in your browser.", 'call "%~dp0installer\\scripts\\bootstrap.cmd"', "echo.", - "echo You can close this window.", + "if errorlevel 1 (", + " echo The installer stopped. Read the messages above, then close this window.", + ") else (", + " echo You can close this window.", + ")", "pause", "", ].join("\r\n"); @@ -96,11 +111,11 @@ export function buildInstallerBundles({ root, outDir }) { rmSync(join(outDir, asset), { force: true }); run("zip", ["-q", "-r", join(outDir, asset), folder], join(work, asset)); }; - zipped(INSTALLER_ASSETS[0], "Gentle Shell Installer", { name: "Install Gentle Shell.command", content: unixLauncher("macOS") }); - zipped(INSTALLER_ASSETS[1], "Gentle Shell Installer", { name: "Install Gentle Shell.cmd", content: windowsLauncher }); - const linux = join(work, INSTALLER_ASSETS[2]); + zipped(MACOS_ASSET, "Gentle Shell Installer", { name: "Install Gentle Shell.command", content: unixLauncher("macOS") }); + zipped(WINDOWS_ASSET, "Gentle Shell Installer", { name: "Install Gentle Shell.cmd", content: windowsLauncher }); + const linux = join(work, LINUX_ASSET); stage(root, join(linux, "gentle-shell-installer"), files, { name: "install-gentle-shell.sh", content: unixLauncher("Linux") }); - run("tar", ["-czf", join(outDir, INSTALLER_ASSETS[2]), "gentle-shell-installer"], linux); + run("tar", ["-czf", join(outDir, LINUX_ASSET), "gentle-shell-installer"], linux); const sums = INSTALLER_ASSETS.map((asset) => `${createHash("sha256").update(readFileSync(join(outDir, asset))).digest("hex")} ${asset}\n`); writeFileSync(join(outDir, INSTALLER_CHECKSUMS), sums.join("")); return [...INSTALLER_ASSETS, INSTALLER_CHECKSUMS].map((asset) => join(outDir, asset)); diff --git a/tests/installer-bundles.test.ts b/tests/installer-bundles.test.ts index 87bc2d99e..9a6b17ec4 100644 --- a/tests/installer-bundles.test.ts +++ b/tests/installer-bundles.test.ts @@ -6,12 +6,24 @@ import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; -import { INSTALLER_ASSETS, buildInstallerBundles, bundleFiles } from "../scripts/build-installer-bundles.mjs"; +import { INSTALLER_ASSETS, buildInstallerBundles, bundleFiles, relativeReferences } from "../scripts/build-installer-bundles.mjs"; import { installerPaths } from "../scripts/verify-package-files.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const posixOnly = { skip: process.platform === "win32" }; -const relativeImports = (text: string) => [...text.matchAll(/(?:from\s+|import\s*\(\s*)["'](\.{1,2}\/[^"']+)["']/g)].map((match) => match[1]); +// Independent of the builder's scanner on purpose: imports and file URLs next to the module. +const relativeImports = (text: string) => [...text.matchAll(/(?:from\s+|import\s*\(\s*|new URL\(\s*)["'](\.{1,2}\/[^"']+\.[a-z]+)["']/g)].map((match) => match[1]); + +test("the scanner follows imports and files a module reads next to itself, but not folders", () => { + const text = [ + 'import { a } from "./a.mjs";', + 'const b = await import("../b.mjs");', + 'const data = readFileSync(new URL("./data.json", import.meta.url));', + 'const assets = new URL("../assets/wizard/", import.meta.url);', + 'import x from "node:fs";', + ].join("\n"); + assert.deepEqual(relativeReferences(text), ["./a.mjs", "../b.mjs", "./data.json"]); +}); test("the bundle holds every wizard file, package.json and everything they import, and nothing from tests", () => { const files = bundleFiles(root); @@ -57,6 +69,8 @@ test("macOS and Linux double-click launchers are executable and start the bundle const launcher = listing("unzip", ["-p", join(b.out, "gentle-shell-installer-macos.zip"), "Gentle Shell Installer/Install Gentle Shell.command"]); assert.match(launcher, /^#!\/bin\/sh\n/); assert.ok(launcher.includes('sh "./installer/scripts/bootstrap.sh"')); + assert.match(launcher, /if \[ "\$status" -eq 0 \]; then/, "only a successful run says the window can be closed"); + assert.ok(launcher.includes("The installer stopped. Read the messages above")); } finally { b.cleanup(); } }); @@ -68,6 +82,8 @@ test("the Windows launcher is a CRLF batch file that calls the bundled bootstrap assert.ok(launcher.split("\n").slice(0, -1).every((line: string) => line.endsWith("\r")), "CRLF line endings"); assert.ok(launcher.includes('call "%~dp0installer\\scripts\\bootstrap.cmd"')); assert.ok(launcher.includes("pause")); + assert.ok(launcher.includes("if errorlevel 1 ("), "a failed bootstrap is reported"); + assert.ok(launcher.includes("The installer stopped. Read the messages above")); assert.match(listing("unzip", ["-p", zip, "Gentle Shell Installer/installer/scripts/bootstrap.cmd"]), /\r\n/); } finally { b.cleanup(); } }); diff --git a/tests/package-manifest.test.ts b/tests/package-manifest.test.ts index 03305167c..626a417ee 100644 --- a/tests/package-manifest.test.ts +++ b/tests/package-manifest.test.ts @@ -264,7 +264,9 @@ test("double-click installers are attached to the release only after the verifie assert.match(job, /ref: \$\{\{ github\.sha \}\}/, "the installers come from the verified release commit"); assert.match(job, /persist-credentials: false/); assert.match(job, /node scripts\/build-installer-bundles\.mjs --out "\$\{RUNNER_TEMP\}\/installers"/); - assert.match(job, /RELEASE_TAG: \$\{\{ inputs\.tag \}\}/); + assert.match(job, /RELEASE_TAG: \$\{\{ needs\.publish\.outputs\.tag \}\}/, "the tag the publish job verified, not the raw input"); + assert.match(job, /package-manager-cache: false/); + assert.match(workflow, /^ {4}outputs:\n {6}tag: \$\{\{ steps\.release\.outputs\.tag \}\}$/m, "publish exports its verified tag"); assert.match(job, /gh release upload "\$\{RELEASE_TAG\}" "\$\{RUNNER_TEMP\}"\/installers\/\* --repo "\$\{GITHUB_REPOSITORY\}" --clobber/); const publish = workflow.match(/^ {2}publish:\n([\s\S]*?)(?=^ {2}installers:\n)/m)?.[1]; assert.ok(publish);