Skip to content

Commit 48141cb

Browse files
authored
Merge branch 'main' into ci/security-audit-timeout
2 parents d5fe4d7 + 3b6d4d0 commit 48141cb

10 files changed

Lines changed: 1608 additions & 102 deletions

File tree

.changeset/reenable-self-update.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Restore `pythinker update` and `pythinker upgrade`: version checks read code.pythinker.com again and native installs download the release archive from GitHub.

apps/pythinker-code/src/cli/update/cdn.ts

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import { valid } from 'semver';
22
import { z } from 'zod';
33

4+
import { PYTHINKER_CODE_CDN_LATEST_JSON_URL, PYTHINKER_CODE_CDN_LATEST_URL } from '#/constant/app';
5+
46
import type { UpdateManifest } from './types';
57

8+
const CDN_FETCH_TIMEOUT_MS = 3_000;
9+
610
const RolloutBatchSchema = z.object({
711
percent: z.number().int().min(0).max(100),
812
delaySeconds: z.number().int().min(0),
@@ -29,17 +33,65 @@ export interface FetchLatestResult {
2933
readonly manifest: UpdateManifest | null;
3034
}
3135

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

48+
/**
49+
* Fetch the latest published Pythinker Code version from the CDN.
50+
*
51+
* **Throws** on any failure (network error, non-2xx, empty body, non-semver
52+
* text). Callers must catch — `refreshUpdateCache` deliberately lets the
53+
* error propagate so the existing cache stays intact instead of being
54+
* overwritten with a null `latest` on a transient blip.
55+
*
56+
* `fetchImpl` is injectable for tests; defaults to the global `fetch`.
57+
*/
3558
export async function fetchLatestVersionFromCdn(
36-
_fetchImpl: typeof fetch = fetch,
59+
fetchImpl: typeof fetch = fetch,
3760
): Promise<string> {
38-
throw new Error(UPDATE_DISABLED_MESSAGE);
61+
const response = await fetchWithTimeout(fetchImpl, PYTHINKER_CODE_CDN_LATEST_URL);
62+
if (!response.ok) {
63+
throw new Error(`CDN /latest returned HTTP ${response.status}`);
64+
}
65+
const raw = (await response.text()).trim();
66+
if (valid(raw) === null) {
67+
throw new Error(`CDN /latest returned invalid semver: ${JSON.stringify(raw)}`);
68+
}
69+
return raw;
3970
}
4071

72+
async function fetchUpdateManifestFromCdn(fetchImpl: typeof fetch): Promise<UpdateManifest> {
73+
const response = await fetchWithTimeout(fetchImpl, PYTHINKER_CODE_CDN_LATEST_JSON_URL);
74+
if (!response.ok) {
75+
throw new Error(`CDN /latest.json returned HTTP ${response.status}`);
76+
}
77+
return UpdateManifestSchema.parse(JSON.parse(await response.text()));
78+
}
79+
80+
/**
81+
* Fetch the rollout manifest, falling back to the plain-text `/latest` when
82+
* `latest.json` is unavailable or malformed. The fallback removes any
83+
* deployment-order coupling between client releases and the CDN file, and a
84+
* null manifest means "fully rolled out" — exactly the pre-rollout behavior.
85+
*
86+
* **Throws** only when both sources fail; callers must catch (see above).
87+
*/
4188
export async function fetchLatestFromCdn(
42-
_fetchImpl: typeof fetch = fetch,
89+
fetchImpl: typeof fetch = fetch,
4390
): Promise<FetchLatestResult> {
44-
throw new Error(UPDATE_DISABLED_MESSAGE);
91+
const manifest = await fetchUpdateManifestFromCdn(fetchImpl).catch(() => null);
92+
if (manifest !== null) {
93+
return { latest: manifest.version, manifest };
94+
}
95+
const latest = await fetchLatestVersionFromCdn(fetchImpl);
96+
return { latest, manifest: null };
4597
}

apps/pythinker-code/src/cli/update/native-manifest.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
/**
2-
* Per-release native artifact manifest (`/binaries/<version>/manifest.json`).
2+
* Per-release native artifact manifest (`manifest.json` on the GitHub
3+
* release of that version).
34
*
45
* Published alongside the release and consumed by the install scripts; the
56
* staged updater reuses the same file so checksums and file names have a
6-
* single source of truth. Entries point at the bare platform binary
7-
* (`pythinker-code-<target>[.exe]`), not an archive.
7+
* single source of truth. Entries point at the per-platform zip archive
8+
* (`pythinker-code-<target>.zip`) holding the single platform binary; the
9+
* checksum is the archive's sha256.
810
*/
911

1012
import { valid } from 'semver';
1113
import { z } from 'zod';
1214

13-
import { UPDATE_DISABLED_MESSAGE } from './cdn';
15+
import { pythinkerCodeReleaseAssetUrl } from '#/constant/app';
1416

1517
const MANIFEST_FETCH_TIMEOUT_MS = 10_000;
1618

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

35-
export function nativeManifestUrl(_version: string): string {
36-
throw new Error(UPDATE_DISABLED_MESSAGE);
37+
export function nativeManifestUrl(version: string): string {
38+
return pythinkerCodeReleaseAssetUrl(version, 'manifest.json');
3739
}
3840

39-
export function nativeBinaryUrl(_version: string, _filename: string): string {
40-
throw new Error(UPDATE_DISABLED_MESSAGE);
41+
export function nativeBinaryUrl(version: string, filename: string): string {
42+
return pythinkerCodeReleaseAssetUrl(version, filename);
4143
}
4244

4345
/**

apps/pythinker-code/src/cli/update/native-stage.ts

Lines changed: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
* without touching the running executable. The actual swap happens on the
44
* next startup (see `native-swap.ts`).
55
*
6-
* The CDN serves the bare platform binary (e.g. `pythinker-code-win32-x64.exe`),
7-
* whose sha256 comes from the per-release manifest over HTTPS — a staged
8-
* binary is byte-exact what the release pipeline produced.
6+
* The GitHub release serves a per-platform zip archive holding the single
7+
* platform binary; the archive's sha256 comes from the per-release manifest
8+
* over HTTPS. The archive is verified before it is opened and the binary is
9+
* extracted next to it, so a staged binary is byte-exact what the release
10+
* pipeline produced.
911
*/
1012

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

23-
import { UPDATE_DISABLED_MESSAGE } from './cdn';
2425
import {
2526
fetchNativeReleaseManifest,
2627
nativeBinaryUrl,
2728
selectPlatformEntry,
2829
} from './native-manifest';
30+
import { extractZipEntry, readSingleZipEntry } from './zip-archive';
2931

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

187189
/**
188190
* Whether a `.staging/` entry is an updater-owned artifact: a staged
189-
* executable (`pythinker-<version>[.<pid>.<epoch-ms>.<n>][.exe]`) or a download
190-
* intermediate (the same plus `.part`). Ownership derives from the
191+
* executable (`pythinker-<version>[.<pid>.<epoch-ms>.<n>][.exe]`), an
192+
* extraction intermediate (the same plus `.part`), or a download
193+
* intermediate (the same plus `.zip.part`). Ownership derives from the
191194
* semver/file-name contract (prerelease and build metadata included), so
192195
* foreign files in the directory are never matched.
193196
*/
194197
function isUpdaterOwnedStagingFile(entry: string): boolean {
195198
if (!entry.startsWith('pythinker-')) return false;
196199
let name = entry.slice('pythinker-'.length);
197200
if (name.endsWith('.part')) name = name.slice(0, -'.part'.length);
201+
if (name.endsWith('.zip')) name = name.slice(0, -'.zip'.length);
198202
if (name.endsWith('.exe')) name = name.slice(0, -'.exe'.length);
199203
// Published artifacts may carry a unique per-worker infix after the
200204
// version (.<pid>.<epoch-ms>.<n>, or the older .<pid>.<n>) — try with and
@@ -377,10 +381,6 @@ async function downloadAndHash(
377381
export async function stageNativeUpdate(
378382
options: StageNativeUpdateOptions,
379383
): Promise<StageNativeUpdateResult> {
380-
if (UPDATE_DISABLED_MESSAGE.length > 0) {
381-
throw new Error(UPDATE_DISABLED_MESSAGE);
382-
}
383-
384384
const platform = options.platform ?? process.platform;
385385
const arch = options.arch ?? process.arch;
386386
// Validate BEFORE anything derives a filesystem path from the version: the
@@ -444,31 +444,42 @@ export async function stageNativeUpdate(
444444
manual: options.manual === true ? true : undefined,
445445
};
446446

447-
// The .part intermediate is just the publish name plus the suffix — the
448-
// name already carries this worker's unique infix, so concurrent workers
449-
// never interleave writes into a shared path.
447+
// The intermediates are just the publish name plus a suffix — the name
448+
// already carries this worker's unique infix, so concurrent workers never
449+
// interleave writes into a shared path. The archive lands in `.zip.part`,
450+
// the extracted binary in `.part`.
451+
const archivePath = join(stagingDir, `${exeFileName}.zip.part`);
450452
const partPath = join(stagingDir, `${exeFileName}.part`);
451453
try {
452454
const manifest = await fetchNativeReleaseManifest(options.version, fetchImpl);
453455
const entry = selectPlatformEntry(manifest, platform, arch);
454-
const size = await downloadAndHash(
456+
await downloadAndHash(
455457
nativeBinaryUrl(options.version, entry.filename),
456-
partPath,
458+
archivePath,
457459
entry.checksum,
458460
fetchImpl,
459461
options.onProgress,
460462
options.idleTimeoutMs,
461463
);
462-
// sha256 matched the manifest. Make the private .part file executable
463-
// BEFORE publishing it: a concurrent swap may move the staged exe into
464-
// the install path the instant it appears at its published name, so a
465-
// post-publish chmod could land on a path that is already gone — leaving
466-
// a non-executable installation behind.
464+
// The archive's sha256 matched the manifest, so its single entry is the
465+
// binary the release pipeline packaged. The extracted bytes get their
466+
// own digest: that is what the startup swap re-verifies on disk.
467+
const extracted = await extractZipEntry(
468+
archivePath,
469+
await readSingleZipEntry(archivePath),
470+
partPath,
471+
);
472+
await rm(archivePath, { force: true });
473+
// Make the private .part file executable BEFORE publishing it: a
474+
// concurrent swap may move the staged exe into the install path the
475+
// instant it appears at its published name, so a post-publish chmod
476+
// could land on a path that is already gone — leaving a non-executable
477+
// installation behind.
467478
await chmod(partPath, 0o755);
468479
await rename(partPath, stagedExePath(options.exePath, staged));
469480

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

0 commit comments

Comments
 (0)