diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index 3f141c514..cadeb1867 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -38,7 +38,7 @@ pi update npm:@foo/bar # update one package pi update --extension npm:@foo/bar ``` -These commands manage pi packages and `pi update` can update the pi CLI installation. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). +These commands manage pi packages and `pi update` can update the pi CLI installation. For experimental installer-managed installations, `pi update` installs the exact checked version into a staged, lockfile-backed release and activates it only after verification, leaving the current release intact if the update fails. Managed installations do not support `--force`; rerun the installer to repair one. To uninstall pi itself, see [Quickstart](quickstart.md#uninstall). By default, `install` and `remove` write to user settings (`~/.pi/agent/settings.json`). Use `-l` to write to project settings (`.pi/settings.json`) instead. Project settings can be shared with your team, and pi installs any missing packages automatically on startup after the project is trusted. diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index ac8b8cf71..23b4d42e2 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -64,7 +64,7 @@ import { builtInExtensions } from "./extensions/index.ts"; import { runMigrations, showDeprecationWarnings } from "./migrations.ts"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts"; import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts"; -import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts"; +import { cleanupManagedInstall, handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts"; import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts"; import { cleanupWindowsSelfUpdateQuarantine } from "./utils/windows-self-update.ts"; @@ -573,6 +573,7 @@ export async function main(args: string[], options?: MainOptions) { if (process.platform === "win32") { cleanupWindowsSelfUpdateQuarantine(getPackageDir()); } + cleanupManagedInstall(); const cwd = process.cwd(); const agentDir = getAgentDir(); diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 0a05f3159..53239daf8 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -1,6 +1,17 @@ -import { join } from "node:path"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join, resolve } from "node:path"; import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui"; import chalk from "chalk"; +import lockfile from "proper-lockfile"; import { selectConfig } from "./cli/config-selector.ts"; import { createProjectTrustContext } from "./cli/project-trust.ts"; import { @@ -23,7 +34,9 @@ import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts"; import { DefaultResourceLoader } from "./core/resource-loader.ts"; import { SettingsManager } from "./core/settings-manager.ts"; import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts"; -import { spawnProcess } from "./utils/child-process.ts"; +import { spawnProcess, spawnProcessSync, waitForChildProcess } from "./utils/child-process.ts"; +import { canonicalizePath, getCwdRelativePath } from "./utils/paths.ts"; +import { getPiUserAgent } from "./utils/pi-user-agent.ts"; import { formatVersionCheckError, getLatestPiRelease, isNewerPackageVersion } from "./utils/version-check.ts"; import { cleanupWindowsSelfUpdateQuarantine, @@ -34,6 +47,179 @@ export type PackageCommand = "install" | "remove" | "update" | "list"; type UpdateTarget = { type: "all" } | { type: "self" } | { type: "extensions"; source?: string } | { type: "models" }; +const DEFAULT_INSTALLER_API_BASE = "https://pi.dev/api/installer/releases"; +const MANAGED_INSTALL_MARKER = "managed-install.json"; +const MANAGED_RELEASE_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; + +function getActiveManagedInstallRoot(): string | undefined { + const configuredRoot = process.env.PI_MANAGED_INSTALL_ROOT?.trim(); + if (!configuredRoot) return undefined; + + const managedRoot = resolve(configuredRoot); + const releasesDir = canonicalizePath(join(managedRoot, "releases")); + // The launcher environment is inherited by child processes. Do not classify a + // source checkout or another Pi installation launched from managed Pi as managed. + if (getCwdRelativePath(canonicalizePath(getPackageDir()), releasesDir) === undefined) return undefined; + + const markerPath = join(managedRoot, MANAGED_INSTALL_MARKER); + try { + const marker = JSON.parse(readFileSync(markerPath, "utf8")) as { + kind?: unknown; + layout?: unknown; + schemaVersion?: unknown; + }; + if (marker.kind !== "pi-managed-install" || marker.schemaVersion !== 1 || marker.layout !== "releases-v1") { + throw new Error(); + } + } catch { + throw new Error(`Managed install marker is missing or invalid: ${markerPath}`); + } + + return managedRoot; +} + +async function fetchInstallerArtifact(url: string, label: string): Promise { + const response = await fetch(url, { headers: { "User-Agent": getPiUserAgent(VERSION) } }); + if (!response.ok) { + throw new Error(`Could not download managed installer ${label} from ${url}: HTTP ${response.status}`); + } + return await response.text(); +} + +async function runManagedNpmCi(stageDir: string): Promise { + const args = [ + "ci", + "--ignore-scripts", + "--min-release-age=0", + "--omit=dev", + "--include=optional", + "--no-fund", + "--no-audit", + "--loglevel=error", + "--progress=false", + ]; + const code = await waitForChildProcess(spawnProcess("npm", args, { cwd: stageDir, stdio: "inherit" })); + if (code !== 0) throw new Error(`npm ${args.join(" ")} exited with code ${code ?? "unknown"}`); +} + +function verifyManagedRelease(releaseDir: string, expectedVersion: string): void { + const binPath = join( + releaseDir, + "node_modules", + ".bin", + process.platform === "win32" ? `${APP_NAME}.cmd` : APP_NAME, + ); + const result = spawnProcessSync(binPath, ["--version"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.error || result.status !== 0) { + const reason = result.error?.message || result.stderr.trim() || `exit code ${result.status ?? "unknown"}`; + throw new Error(`Could not verify managed Pi ${expectedVersion}: ${reason}`); + } + const installedVersion = result.stdout.trim(); + if (installedVersion !== expectedVersion) { + throw new Error(`Managed Pi smoke test returned version ${installedVersion}; expected ${expectedVersion}.`); + } +} + +function activateManagedRelease(managedRoot: string, version: string): void { + const currentPath = join(managedRoot, "current-version"); + const temporaryPath = join(managedRoot, `current-version.tmp.${process.pid}-${Date.now()}`); + try { + writeFileSync(temporaryPath, `${version}\n`); + renameSync(temporaryPath, currentPath); + } finally { + rmSync(temporaryPath, { force: true }); + } +} + +function cleanupManagedStaging(managedRoot: string): void { + const stagingRoot = join(managedRoot, "staging"); + try { + for (const entry of readdirSync(stagingRoot)) { + if (entry.startsWith("update-")) { + rmSync(join(stagingRoot, entry), { force: true, recursive: true }); + } + } + } catch { + // The staging directory does not exist yet or is not writable. + } +} + +export function cleanupManagedInstall(): void { + let managedRoot: string | undefined; + try { + managedRoot = getActiveManagedInstallRoot(); + } catch { + return; + } + if (!managedRoot) return; + + try { + const releaseLock = lockfile.lockSync(join(managedRoot, "update"), { realpath: false }); + try { + cleanupManagedStaging(managedRoot); + } finally { + releaseLock(); + } + } catch { + // A live update owns the staging directory, or cleanup is unavailable. + } +} + +async function runManagedSelfUpdate(managedRoot: string, version: string): Promise { + if (!MANAGED_RELEASE_VERSION_RE.test(version)) { + throw new Error(`Invalid managed release version: ${version}`); + } + + let releaseLock: () => Promise; + try { + releaseLock = await lockfile.lock(join(managedRoot, "update"), { realpath: false }); + } catch (error: unknown) { + if (error instanceof Error && "code" in error && error.code === "ELOCKED") { + throw new Error("Another managed Pi update is already running."); + } + throw error; + } + + let stageDir: string | undefined; + try { + cleanupManagedStaging(managedRoot); + const installerApiBase = (process.env.PI_INSTALLER_API_BASE?.trim() || DEFAULT_INSTALLER_API_BASE).replace( + /\/+$/, + "", + ); + const releaseUrl = `${installerApiBase}/${encodeURIComponent(version)}`; + const stagingRoot = join(managedRoot, "staging"); + const releasesRoot = join(managedRoot, "releases"); + mkdirSync(releasesRoot, { recursive: true }); + const releaseDir = join(releasesRoot, version); + if (existsSync(releaseDir)) { + verifyManagedRelease(releaseDir, version); + activateManagedRelease(managedRoot, version); + return; + } + + mkdirSync(stagingRoot, { recursive: true }); + stageDir = mkdtempSync(join(stagingRoot, "update-")); + const [packageJsonContent, packageLockContent] = await Promise.all([ + fetchInstallerArtifact(`${releaseUrl}/package.json`, "package.json"), + fetchInstallerArtifact(`${releaseUrl}/package-lock.json`, "package-lock.json"), + ]); + writeFileSync(join(stageDir, "package.json"), packageJsonContent); + writeFileSync(join(stageDir, "package-lock.json"), packageLockContent); + + await runManagedNpmCi(stageDir); + verifyManagedRelease(stageDir, version); + renameSync(stageDir, releaseDir); + activateManagedRelease(managedRoot, version); + } finally { + if (stageDir) rmSync(stageDir, { force: true, recursive: true }); + await releaseLock(); + } +} + const SELF_UPDATE_NOTE_MARKDOWN_THEME: MarkdownTheme = { heading: (text) => chalk.bold(chalk.yellow(text)), link: (text) => chalk.cyan(text), @@ -834,10 +1020,37 @@ export async function handlePackageCommand( } } if (updateTargetIncludesSelf(target)) { + const managedInstallRoot = getActiveManagedInstallRoot(); + if (managedInstallRoot && options.force) { + console.error( + chalk.red( + `Managed ${APP_NAME} installations do not support --force; rerun the installer to repair this installation.`, + ), + ); + process.exitCode = 1; + return true; + } const selfUpdatePlan = await getSelfUpdatePlan(options.force); if (!selfUpdatePlan.shouldRun) { return true; } + if (managedInstallRoot) { + if (selfUpdatePlan.note) { + printSelfUpdateNote(selfUpdatePlan.note); + } + try { + console.log(chalk.dim(`Updating managed ${APP_NAME} installation...`)); + await runManagedSelfUpdate(managedInstallRoot, selfUpdatePlan.version); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown managed update error"; + console.error(chalk.red(`Error: ${message}`)); + process.exitCode = 1; + return true; + } + console.log(chalk.green(`Updated ${APP_NAME} from ${VERSION} to ${selfUpdatePlan.version}`)); + return true; + } + const installMethod = detectInstallMethod(); if (process.platform === "win32" && installMethod !== "npm" && installMethod !== "pnpm") { console.error( diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index a804b40e4..1d5343492 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -1,6 +1,17 @@ -import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; +import lockfile from "proper-lockfile"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.ts"; import { ModelRuntime } from "../src/core/model-runtime.ts"; @@ -29,6 +40,77 @@ describe("package commands", () => { return `${major}.${minor}.${Number.parseInt(patch, 10) + 1}`; } + function prepareManagedInstall( + targetVersion: string, + npmExitCode = 0, + ): { managedRoot: string; npmRecordPath: string } { + const managedRoot = join(agentDir, "install"); + const activeRelease = join(managedRoot, "releases", VERSION); + const selfPackageDir = join(activeRelease, "node_modules", ...PACKAGE_NAME.split("/")); + mkdirSync(selfPackageDir, { recursive: true }); + writeFileSync(join(activeRelease, "active.txt"), "active"); + writeFileSync(join(managedRoot, "current-version"), `${VERSION}\n`); + writeFileSync( + join(managedRoot, "managed-install.json"), + `${JSON.stringify({ kind: "pi-managed-install", schemaVersion: 1, layout: "releases-v1" })}\n`, + ); + + const binDir = join(tempDir, "managed-bin"); + const fakeNpmPath = join(tempDir, "managed-npm.cjs"); + const npmRecordPath = join(tempDir, "managed-npm-record.json"); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + fakeNpmPath, + `const fs = require("node:fs"); +const path = require("node:path"); +const args = process.argv.slice(2); +fs.writeFileSync(${JSON.stringify(npmRecordPath)}, JSON.stringify(args)); +if (${npmExitCode} !== 0) process.exit(${npmExitCode}); +const binDir = path.join(process.cwd(), "node_modules", ".bin"); +fs.mkdirSync(binDir, { recursive: true }); +const piPath = path.join(binDir, process.platform === "win32" ? "pi.cmd" : "pi"); +fs.writeFileSync( + piPath, + process.platform === "win32" + ? "@echo off\\r\\necho ${targetVersion}\\r\\n" + : "#!/bin/sh\\nprintf '%s\\n' ${targetVersion}\\n", +); +if (process.platform !== "win32") fs.chmodSync(piPath, 0o755); +`, + ); + const npmPath = join(binDir, process.platform === "win32" ? "npm.cmd" : "npm"); + writeFileSync( + npmPath, + process.platform === "win32" + ? `@echo off\r\n"${originalExecPath}" "${fakeNpmPath}" %*\r\n` + : `#!/bin/sh\nexec "${originalExecPath}" "${fakeNpmPath}" "$@"\n`, + ); + chmodSync(npmPath, 0o755); + + vi.stubEnv("PI_INSTALLER_API_BASE", "https://example.test/api/installer/releases"); + vi.stubEnv("PI_MANAGED_INSTALL_ROOT", managedRoot); + process.env.PI_PACKAGE_DIR = selfPackageDir; + process.env.PATH = `${binDir}${delimiter}${originalPath ?? ""}`; + return { managedRoot, npmRecordPath }; + } + + function mockManagedUpdate(targetVersion: string): void { + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url === "https://pi.dev/api/latest-version") { + return Response.json({ packageName: PACKAGE_NAME, version: targetVersion }); + } + const releaseUrl = `https://example.test/api/installer/releases/${targetVersion}`; + if (url === `${releaseUrl}/package.json` || url === `${releaseUrl}/package-lock.json`) { + return Response.json({}); + } + throw new Error(`Unexpected fetch: ${url}`); + }), + ); + } + async function runPackageCommandDirectly(args: string[]): Promise { expect(await handlePackageCommand(args)).toBe(true); } @@ -82,6 +164,7 @@ describe("package commands", () => { afterEach(() => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); vi.restoreAllMocks(); process.chdir(originalCwd); process.exitCode = originalExitCode; @@ -523,10 +606,103 @@ describe("package commands", () => { } }); - it("uses the update check version for forced self updates even when current", async () => { + it("updates installer-managed Pi through a staged immutable release", async () => { + const targetVersion = getNewerPatchVersion(); + const { managedRoot, npmRecordPath } = prepareManagedInstall(targetVersion); + const abandonedStage = join(managedRoot, "staging", "update-abandoned"); + mkdirSync(abandonedStage, { recursive: true }); + writeFileSync(join(abandonedStage, "partial"), "partial"); + const abandonedLock = join(managedRoot, "update.lock"); + mkdirSync(abandonedLock); + utimesSync(abandonedLock, new Date(0), new Date(0)); + mockManagedUpdate(targetVersion); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); + + expect(readFileSync(join(managedRoot, "current-version"), "utf8")).toBe(`${targetVersion}\n`); + expect(existsSync(join(managedRoot, "releases", targetVersion))).toBe(true); + expect(existsSync(join(managedRoot, "releases", VERSION, "active.txt"))).toBe(true); + expect(readdirSync(join(managedRoot, "staging"))).toEqual([]); + expect(JSON.parse(readFileSync(npmRecordPath, "utf8")) as string[]).toEqual( + expect.arrayContaining(["ci", "--ignore-scripts"]), + ); + expect(logSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain( + `Updated pi from ${VERSION} to ${targetVersion}`, + ); + expect(errorSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }); + + it("rejects a concurrent managed update", async () => { + const targetVersion = getNewerPatchVersion(); + const { managedRoot, npmRecordPath } = prepareManagedInstall(targetVersion); + const releaseLock = await lockfile.lock(join(managedRoot, "update"), { realpath: false }); + mockManagedUpdate(targetVersion); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); + } finally { + await releaseLock(); + } + + expect(readFileSync(join(managedRoot, "current-version"), "utf8")).toBe(`${VERSION}\n`); + expect(existsSync(npmRecordPath)).toBe(false); + expect(logSpy.mock.calls.map(([message]) => String(message)).join("\n")).not.toContain("Updated pi from"); + expect(errorSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain( + "Another managed Pi update is already running.", + ); + expect(process.exitCode).toBe(1); + }); + + it("rejects forced managed reinstalls", async () => { + const targetVersion = getNewerPatchVersion(); + const { npmRecordPath } = prepareManagedInstall(targetVersion); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(runPackageCommandDirectly(["update", "--self", "--force"])).resolves.toBeUndefined(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(existsSync(npmRecordPath)).toBe(false); + expect(errorSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain( + "Managed pi installations do not support --force", + ); + expect(process.exitCode).toBe(1); + }); + + it("keeps the managed release active when its update fails", async () => { + const targetVersion = getNewerPatchVersion(); + const { managedRoot } = prepareManagedInstall(targetVersion, 23); + mockManagedUpdate(targetVersion); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(runPackageCommandDirectly(["update", "--self"])).resolves.toBeUndefined(); + + expect(readFileSync(join(managedRoot, "current-version"), "utf8")).toBe(`${VERSION}\n`); + expect(existsSync(join(managedRoot, "releases", targetVersion))).toBe(false); + expect(readdirSync(join(managedRoot, "staging"))).toEqual([]); + expect(logSpy.mock.calls.map(([message]) => String(message)).join("\n")).not.toContain("Updated pi from"); + expect(errorSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain("exited with code 23"); + expect(process.exitCode).toBe(1); + }); + + it("keeps npm self-updates non-managed when the managed environment is inherited", async () => { const globalPrefix = join(tempDir, "global-prefix"); const projectPrefix = join(tempDir, "project-prefix"); const selfPackageDir = join(globalPrefix, "lib", "node_modules", "@earendil-works", "pi-coding-agent"); + const inheritedManagedRoot = join(tempDir, "inherited-managed-install"); + mkdirSync(join(inheritedManagedRoot, "releases"), { recursive: true }); + writeFileSync( + join(inheritedManagedRoot, "managed-install.json"), + JSON.stringify({ kind: "pi-managed-install", schemaVersion: 1, layout: "releases-v1" }), + ); + vi.stubEnv("PI_MANAGED_INSTALL_ROOT", inheritedManagedRoot); const fakeNpmPath = join(tempDir, "fake-npm.cjs"); const recordPath = join(tempDir, "self-update.json"); mkdirSync(selfPackageDir, { recursive: true }); diff --git a/scripts/publish-release-announcement.mjs b/scripts/publish-release-announcement.mjs index 79d34e4e9..a0a090f27 100644 --- a/scripts/publish-release-announcement.mjs +++ b/scripts/publish-release-announcement.mjs @@ -57,8 +57,8 @@ function parseArgs(args) { if (!options.version || !STABLE_SEMVER_RE.test(options.version)) { throw new Error("--version must be a stable semver version"); } - if (Boolean(options.installerPackageJson) !== Boolean(options.installerPackageLock)) { - throw new Error("--installer-package-json and --installer-package-lock must be provided together"); + if (!options.installerPackageJson || !options.installerPackageLock) { + throw new Error("--installer-package-json and --installer-package-lock are required"); } return options; } @@ -302,6 +302,57 @@ async function main() { } writeFileSync(latestPath, `${JSON.stringify(release, null, "\t")}\n`); + validateInstallerArtifacts(options.installerPackageJson, options.installerPackageLock, options.version); + const installerReleasePrefix = `${INSTALLER_PREFIX}/releases/${options.version}`; + putObject( + options.bucket, + options.endpoint, + options.installerPackageJson, + `${installerReleasePrefix}/package.json`, + "public, max-age=31536000, immutable", + { missing: true }, + ); + putObject( + options.bucket, + options.endpoint, + options.installerPackageLock, + `${installerReleasePrefix}/package-lock.json`, + "public, max-age=31536000, immutable", + { missing: true }, + ); + putJson( + options.bucket, + options.endpoint, + releasePath, + `${installerReleasePrefix}/metadata.json`, + "public, max-age=31536000, immutable", + { missing: true }, + ); + const installerLatest = await advanceLatestRelease( + options.version, + () => + readLatestRelease( + options.bucket, + options.endpoint, + `${INSTALLER_PREFIX}/latest.json`, + join(temporaryDirectory, "installer-latest-current.json"), + ), + (condition) => + putJson( + options.bucket, + options.endpoint, + latestPath, + `${INSTALLER_PREFIX}/latest.json`, + "no-store", + condition, + ), + ); + console.log( + installerLatest.advanced + ? `Published installer artifacts for Pi ${options.version} through s3://${options.bucket}/${INSTALLER_PREFIX}/latest.json` + : `Pi ${installerLatest.version} is already the latest installer release.`, + ); + const result = await advanceLatestRelease( options.version, () => @@ -326,59 +377,6 @@ async function main() { ? `Announced Pi ${options.version} through s3://${options.bucket}/${RELEASES_PREFIX}/latest.json` : `Pi ${result.version} is already the latest announced release.`, ); - - if (options.installerPackageJson) { - validateInstallerArtifacts(options.installerPackageJson, options.installerPackageLock, options.version); - const installerReleasePrefix = `${INSTALLER_PREFIX}/releases/${options.version}`; - putObject( - options.bucket, - options.endpoint, - options.installerPackageJson, - `${installerReleasePrefix}/package.json`, - "public, max-age=31536000, immutable", - { missing: true }, - ); - putObject( - options.bucket, - options.endpoint, - options.installerPackageLock, - `${installerReleasePrefix}/package-lock.json`, - "public, max-age=31536000, immutable", - { missing: true }, - ); - putJson( - options.bucket, - options.endpoint, - releasePath, - `${installerReleasePrefix}/metadata.json`, - "public, max-age=31536000, immutable", - { missing: true }, - ); - const installerLatest = await advanceLatestRelease( - options.version, - () => - readLatestRelease( - options.bucket, - options.endpoint, - `${INSTALLER_PREFIX}/latest.json`, - join(temporaryDirectory, "installer-latest-current.json"), - ), - (condition) => - putJson( - options.bucket, - options.endpoint, - latestPath, - `${INSTALLER_PREFIX}/latest.json`, - "no-store", - condition, - ), - ); - console.log( - installerLatest.advanced - ? `Published installer artifacts for Pi ${options.version} through s3://${options.bucket}/${INSTALLER_PREFIX}/latest.json` - : `Pi ${installerLatest.version} is already the latest installer release.`, - ); - } } finally { rmSync(temporaryDirectory, { force: true, recursive: true }); }