Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/reenable-self-update.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Restore `pythinker update` and `pythinker upgrade`: version checks read code.pythinker.com again and native installs download the release archive from GitHub.
64 changes: 58 additions & 6 deletions apps/pythinker-code/src/cli/update/cdn.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { valid } from 'semver';
import { z } from 'zod';

import { PYTHINKER_CODE_CDN_LATEST_JSON_URL, PYTHINKER_CODE_CDN_LATEST_URL } from '#/constant/app';

import type { UpdateManifest } from './types';

const CDN_FETCH_TIMEOUT_MS = 3_000;

const RolloutBatchSchema = z.object({
percent: z.number().int().min(0).max(100),
delaySeconds: z.number().int().min(0),
Expand All @@ -29,17 +33,65 @@ export interface FetchLatestResult {
readonly manifest: UpdateManifest | null;
}

export const UPDATE_DISABLED_MESSAGE =
'Self-update is disabled in this build. Install updates from https://github.com/PyModel/pythinker-code/releases or via npm.';
async function fetchWithTimeout(fetchImpl: typeof fetch, input: string): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, CDN_FETCH_TIMEOUT_MS);
try {
return await fetchImpl(input, { signal: controller.signal });
} finally {
clearTimeout(timeout);
}
}

/**
* Fetch the latest published Pythinker Code version from the CDN.
*
* **Throws** on any failure (network error, non-2xx, empty body, non-semver
* text). Callers must catch — `refreshUpdateCache` deliberately lets the
* error propagate so the existing cache stays intact instead of being
* overwritten with a null `latest` on a transient blip.
*
* `fetchImpl` is injectable for tests; defaults to the global `fetch`.
*/
export async function fetchLatestVersionFromCdn(
_fetchImpl: typeof fetch = fetch,
fetchImpl: typeof fetch = fetch,
): Promise<string> {
throw new Error(UPDATE_DISABLED_MESSAGE);
const response = await fetchWithTimeout(fetchImpl, PYTHINKER_CODE_CDN_LATEST_URL);
if (!response.ok) {
throw new Error(`CDN /latest returned HTTP ${response.status}`);
}
const raw = (await response.text()).trim();
if (valid(raw) === null) {
throw new Error(`CDN /latest returned invalid semver: ${JSON.stringify(raw)}`);
}
return raw;
}

async function fetchUpdateManifestFromCdn(fetchImpl: typeof fetch): Promise<UpdateManifest> {
const response = await fetchWithTimeout(fetchImpl, PYTHINKER_CODE_CDN_LATEST_JSON_URL);
if (!response.ok) {
throw new Error(`CDN /latest.json returned HTTP ${response.status}`);
}
return UpdateManifestSchema.parse(JSON.parse(await response.text()));
}

/**
* Fetch the rollout manifest, falling back to the plain-text `/latest` when
* `latest.json` is unavailable or malformed. The fallback removes any
* deployment-order coupling between client releases and the CDN file, and a
* null manifest means "fully rolled out" — exactly the pre-rollout behavior.
*
* **Throws** only when both sources fail; callers must catch (see above).
*/
export async function fetchLatestFromCdn(
_fetchImpl: typeof fetch = fetch,
fetchImpl: typeof fetch = fetch,
): Promise<FetchLatestResult> {
throw new Error(UPDATE_DISABLED_MESSAGE);
const manifest = await fetchUpdateManifestFromCdn(fetchImpl).catch(() => null);
if (manifest !== null) {
return { latest: manifest.version, manifest };
}
const latest = await fetchLatestVersionFromCdn(fetchImpl);
return { latest, manifest: null };
}
18 changes: 10 additions & 8 deletions apps/pythinker-code/src/cli/update/native-manifest.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
/**
* Per-release native artifact manifest (`/binaries/<version>/manifest.json`).
* Per-release native artifact manifest (`manifest.json` on the GitHub
* release of that version).
*
* Published alongside the release and consumed by the install scripts; the
* staged updater reuses the same file so checksums and file names have a
* single source of truth. Entries point at the bare platform binary
* (`pythinker-code-<target>[.exe]`), not an archive.
* single source of truth. Entries point at the per-platform zip archive
* (`pythinker-code-<target>.zip`) holding the single platform binary; the
* checksum is the archive's sha256.
*/

import { valid } from 'semver';
import { z } from 'zod';

import { UPDATE_DISABLED_MESSAGE } from './cdn';
import { pythinkerCodeReleaseAssetUrl } from '#/constant/app';

const MANIFEST_FETCH_TIMEOUT_MS = 10_000;

Expand All @@ -32,12 +34,12 @@ export const NativeReleaseManifestSchema = z.object({
export type NativeReleaseManifest = z.infer<typeof NativeReleaseManifestSchema>;
export type NativePlatformEntry = z.infer<typeof PlatformEntrySchema>;

export function nativeManifestUrl(_version: string): string {
throw new Error(UPDATE_DISABLED_MESSAGE);
export function nativeManifestUrl(version: string): string {
return pythinkerCodeReleaseAssetUrl(version, 'manifest.json');
}

export function nativeBinaryUrl(_version: string, _filename: string): string {
throw new Error(UPDATE_DISABLED_MESSAGE);
export function nativeBinaryUrl(version: string, filename: string): string {
return pythinkerCodeReleaseAssetUrl(version, filename);
}

/**
Expand Down
66 changes: 39 additions & 27 deletions apps/pythinker-code/src/cli/update/native-stage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
* without touching the running executable. The actual swap happens on the
* next startup (see `native-swap.ts`).
*
* The CDN serves the bare platform binary (e.g. `pythinker-code-win32-x64.exe`),
* whose sha256 comes from the per-release manifest over HTTPS — a staged
* binary is byte-exact what the release pipeline produced.
* The GitHub release serves a per-platform zip archive holding the single
* platform binary; the archive's sha256 comes from the per-release manifest
* over HTTPS. The archive is verified before it is opened and the binary is
* extracted next to it, so a staged binary is byte-exact what the release
* pipeline produced.
*/

import { createHash } from 'node:crypto';
Expand All @@ -20,12 +22,12 @@ import { PYTHINKER_CODE_NATIVE_STAGED_STATE_FILE_NAME } from '#/constant/app';
import { getNativeStagedStateFile, getNativeStagingDir } from '#/utils/paths';
import { writeJsonFile } from '#/utils/persistence';

import { UPDATE_DISABLED_MESSAGE } from './cdn';
import {
fetchNativeReleaseManifest,
nativeBinaryUrl,
selectPlatformEntry,
} from './native-manifest';
import { extractZipEntry, readSingleZipEntry } from './zip-archive';

const StagedNativeUpdateSchema = z
.object({
Expand Down Expand Up @@ -186,15 +188,17 @@ export async function hashFileSha256(filePath: string): Promise<string | null> {

/**
* Whether a `.staging/` entry is an updater-owned artifact: a staged
* executable (`pythinker-<version>[.<pid>.<epoch-ms>.<n>][.exe]`) or a download
* intermediate (the same plus `.part`). Ownership derives from the
* executable (`pythinker-<version>[.<pid>.<epoch-ms>.<n>][.exe]`), an
* extraction intermediate (the same plus `.part`), or a download
* intermediate (the same plus `.zip.part`). Ownership derives from the
* semver/file-name contract (prerelease and build metadata included), so
* foreign files in the directory are never matched.
*/
function isUpdaterOwnedStagingFile(entry: string): boolean {
if (!entry.startsWith('pythinker-')) return false;
let name = entry.slice('pythinker-'.length);
if (name.endsWith('.part')) name = name.slice(0, -'.part'.length);
if (name.endsWith('.zip')) name = name.slice(0, -'.zip'.length);
if (name.endsWith('.exe')) name = name.slice(0, -'.exe'.length);
// Published artifacts may carry a unique per-worker infix after the
// version (.<pid>.<epoch-ms>.<n>, or the older .<pid>.<n>) — try with and
Expand Down Expand Up @@ -377,10 +381,6 @@ async function downloadAndHash(
export async function stageNativeUpdate(
options: StageNativeUpdateOptions,
): Promise<StageNativeUpdateResult> {
if (UPDATE_DISABLED_MESSAGE.length > 0) {
throw new Error(UPDATE_DISABLED_MESSAGE);
}

const platform = options.platform ?? process.platform;
const arch = options.arch ?? process.arch;
// Validate BEFORE anything derives a filesystem path from the version: the
Expand Down Expand Up @@ -444,31 +444,42 @@ export async function stageNativeUpdate(
manual: options.manual === true ? true : undefined,
};

// The .part intermediate is just the publish name plus the suffix — the
// name already carries this worker's unique infix, so concurrent workers
// never interleave writes into a shared path.
// The intermediates are just the publish name plus a suffix — the name
// already carries this worker's unique infix, so concurrent workers never
// interleave writes into a shared path. The archive lands in `.zip.part`,
// the extracted binary in `.part`.
const archivePath = join(stagingDir, `${exeFileName}.zip.part`);
const partPath = join(stagingDir, `${exeFileName}.part`);
try {
const manifest = await fetchNativeReleaseManifest(options.version, fetchImpl);
const entry = selectPlatformEntry(manifest, platform, arch);
const size = await downloadAndHash(
await downloadAndHash(
nativeBinaryUrl(options.version, entry.filename),
partPath,
archivePath,
entry.checksum,
fetchImpl,
options.onProgress,
options.idleTimeoutMs,
);
// sha256 matched the manifest. Make the private .part file executable
// BEFORE publishing it: a concurrent swap may move the staged exe into
// the install path the instant it appears at its published name, so a
// post-publish chmod could land on a path that is already gone — leaving
// a non-executable installation behind.
// The archive's sha256 matched the manifest, so its single entry is the
// binary the release pipeline packaged. The extracted bytes get their
// own digest: that is what the startup swap re-verifies on disk.
const extracted = await extractZipEntry(
archivePath,
await readSingleZipEntry(archivePath),
partPath,
);
await rm(archivePath, { force: true });
// Make the private .part file executable BEFORE publishing it: a
// concurrent swap may move the staged exe into the install path the
// instant it appears at its published name, so a post-publish chmod
// could land on a path that is already gone — leaving a non-executable
// installation behind.
await chmod(partPath, 0o755);
await rename(partPath, stagedExePath(options.exePath, staged));

staged.sha256 = entry.checksum;
staged.exeSize = size;
staged.sha256 = extracted.sha256;
staged.exeSize = extracted.size;
// Atomic write: staged.json only ever appears complete and consistent.
await writeJsonFile(
getNativeStagedStateFile(options.exePath),
Expand All @@ -477,11 +488,12 @@ export async function stageNativeUpdate(
);
return { status: 'staged', staged };
} catch (error) {
// Remove only what THIS attempt privately owns: its unique .part file.
// If the failure landed after the publishing rename, this attempt's exe
// is already at its unique name with no metadata pointing at it — left
// in place (a just-published exe may belong to a concurrent metadata
// write) and reaped by the age-gated orphan cleanup.
// Remove only what THIS attempt privately owns: its unique intermediate
// files. If the failure landed after the publishing rename, this
// attempt's exe is already at its unique name with no metadata pointing
// at it — left in place (a just-published exe may belong to a concurrent
// metadata write) and reaped by the age-gated orphan cleanup.
await rm(archivePath, { force: true }).catch(() => {});
await rm(partPath, { force: true }).catch(() => {});
// Best effort: drop the staging dir itself when empty (a concurrent
// worker's files keep it around — rmdir only removes empty dirs).
Expand Down
Loading
Loading