diff --git a/.changeset/update-flow-reliability.md b/.changeset/update-flow-reliability.md new file mode 100644 index 00000000..a9a6f343 --- /dev/null +++ b/.changeset/update-flow-reliability.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +Stop offering an update with no build for the running platform, give every installer network call a timeout, expire a stale install lease instead of blocking updates forever, and say which version is installing and why a failed one stopped retrying. diff --git a/.changeset/update-minimum-supported-version.md b/.changeset/update-minimum-supported-version.md new file mode 100644 index 00000000..45fa57a6 --- /dev/null +++ b/.changeset/update-minimum-supported-version.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": minor +--- + +Let a release declare a minimum supported version, so a client below it is offered the update without waiting for its staged rollout batch. diff --git a/.changeset/update-status-under-the-prompt.md b/.changeset/update-status-under-the-prompt.md new file mode 100644 index 00000000..8d02e747 --- /dev/null +++ b/.changeset/update-status-under-the-prompt.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": minor +--- + +Show update availability and live download progress in the status row under the prompt, replacing the startup banner chip that was computed once and never refreshed. diff --git a/apps/pythinker-code/src/cli/sub/upgrade.ts b/apps/pythinker-code/src/cli/sub/upgrade.ts index 5260714c..e2b545bd 100644 --- a/apps/pythinker-code/src/cli/sub/upgrade.ts +++ b/apps/pythinker-code/src/cli/sub/upgrade.ts @@ -2,7 +2,16 @@ import { log, type Logger } from '@pythoughts/pythinker-code-sdk'; import { track as trackTelemetry, type TelemetryProperties } from '@pythoughts/pythinker-telemetry'; import { refreshUpdateCache } from '#/cli/update/refresh'; -import { selectUpdateTarget } from '#/cli/update/select'; +import { tryAcquireUpdateInstallLock } from '#/cli/update/install-lock'; +import type { UpdateInstallLockHandle, UpdateInstallLockRequest } from '#/cli/update/install-lock'; +import { + emptyUpdateInstallState, + failureAttemptsFor, + hasFreshActiveInstall, + readUpdateInstallState, + writeUpdateInstallState, +} from '#/cli/update/install-state'; +import { isTargetInstallable, selectUpdateTarget } from '#/cli/update/select'; import { detectInstallSource } from '#/cli/update/source'; import { canAutoInstall, @@ -20,6 +29,8 @@ import { NPM_PACKAGE_NAME, type InstallSource, type UpdateCache, + type UpdateInstallState, + type UpdateTarget, } from '#/cli/update/types'; interface WritableLike { @@ -40,6 +51,11 @@ export interface UpgradeDeps { readonly promptForInstallChoice: ( options: InstallPromptOptions, ) => Promise; + readonly readUpdateInstallState: () => Promise; + readonly writeUpdateInstallState: (state: UpdateInstallState) => Promise; + readonly tryAcquireUpdateInstallLock: ( + request: UpdateInstallLockRequest, + ) => Promise; readonly platform: NodeJS.Platform; readonly stdout: WritableLike; readonly stderr: WritableLike; @@ -85,6 +101,20 @@ export async function handleUpgrade( } const source = await deps.detectInstallSource().catch(() => 'unsupported' as const); + // A native install consumes the manifest's platform artifact; without one + // the update cannot succeed, so take the same exit as being up to date. + if (!isTargetInstallable(source, cache.manifest)) { + trackUpgradeEvent(deps.track, 'upgrade_command_no_update', { + current_version: currentVersion, + }); + logUpgradeInfo(deps.logger, 'manual upgrade no update', { + currentVersion, + }); + deps.stdout.write( + `${formatDisplayVersion(target.version)} is published but has no build for this platform yet.\n`, + ); + return 0; + } const installCommand = installCommandFor(source, target.version, deps.platform); if (!canAutoInstall(source, deps.platform) || !deps.isInteractive) { trackUpgradeEvent(deps.track, 'upgrade_command_manual_command', { @@ -131,6 +161,19 @@ export async function handleUpgrade( return 0; } + // The foreground install holds the update-install lock for its whole run: + // another live installer (usually a detached background one) must never be + // raced by this path, which writes the same executable. A fresh active + // record or a held lock means an install is already in flight — refuse. + const installState = await deps.readUpdateInstallState().catch(() => emptyUpdateInstallState()); + if (hasFreshActiveInstall(installState)) { + return refuseForegroundInstall(deps, currentVersion, target, source, installState.active?.version); + } + const lock = await deps.tryAcquireUpdateInstallLock({ version: target.version }); + if (lock === null) { + return refuseForegroundInstall(deps, currentVersion, target, source, undefined); + } + try { trackUpgradeEvent(deps.track, 'upgrade_command_install_selected', { current_version: currentVersion, @@ -138,6 +181,16 @@ export async function handleUpgrade( source, }); await deps.installUpdate(source, target.version, deps.platform); + await deps.writeUpdateInstallState({ + ...installState, + active: null, + lastFailure: null, + lastSuccess: { + version: target.version, + installedAt: nowIso(), + notifiedAt: null, + }, + }).catch(() => {}); trackUpgradeEvent(deps.track, 'upgrade_command_succeeded', { current_version: currentVersion, target_version: target.version, @@ -151,6 +204,18 @@ export async function handleUpgrade( deps.stdout.write(renderInstallSuccessMessage(target)); return 0; } catch (error) { + const attempts = failureAttemptsFor(installState, target, 'install') + 1; + await deps.writeUpdateInstallState({ + ...installState, + active: null, + lastFailure: { + version: target.version, + failedAt: nowIso(), + attempts, + operation: 'install', + message: formatErrorMessage(error), + }, + }).catch(() => {}); trackUpgradeEvent(deps.track, 'upgrade_command_failed', { current_version: currentVersion, target_version: target.version, @@ -169,6 +234,8 @@ export async function handleUpgrade( `${formatErrorMessage(error)}\n`, ); return 1; + } finally { + await lock.release().catch(() => {}); } } @@ -178,6 +245,9 @@ function createDefaultUpgradeDeps(overrides: Partial): UpgradeDeps detectInstallSource: overrides.detectInstallSource ?? (() => detectInstallSource()), installUpdate: overrides.installUpdate ?? installUpdateForeground, promptForInstallChoice: overrides.promptForInstallChoice ?? promptForInstallChoice, + readUpdateInstallState: overrides.readUpdateInstallState ?? (() => readUpdateInstallState()), + writeUpdateInstallState: overrides.writeUpdateInstallState ?? writeUpdateInstallState, + tryAcquireUpdateInstallLock: overrides.tryAcquireUpdateInstallLock ?? tryAcquireUpdateInstallLock, platform: overrides.platform ?? process.platform, stdout: overrides.stdout ?? process.stdout, stderr: overrides.stderr ?? process.stderr, @@ -191,6 +261,39 @@ function formatDisplayVersion(version: string): string { return version.startsWith('v') ? version : `v${version}`; } +function nowIso(): string { + return new Date().toISOString(); +} + +/** + * Refuse the foreground install because another install is already in + * flight. The active-record case names the version being installed; the + * lock-held case cannot know it, so the message stays generic. + */ +function refuseForegroundInstall( + deps: UpgradeDeps, + currentVersion: string, + target: UpdateTarget, + source: InstallSource, + activeVersion: string | undefined, +): number { + trackUpgradeEvent(deps.track, 'upgrade_command_failed', { + current_version: currentVersion, + target_version: target.version, + source, + stage: 'install', + reason: 'another update install is already in progress', + }); + const suffix = activeVersion === undefined + ? '' + : ` (${formatDisplayVersion(activeVersion)})`; + deps.stderr.write( + `error: another update install is already in progress${suffix}; ` + + 'try again once it finishes.\n', + ); + return 1; +} + function formatErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/apps/pythinker-code/src/cli/update/cdn.ts b/apps/pythinker-code/src/cli/update/cdn.ts index eddf84b9..0f59d86e 100644 --- a/apps/pythinker-code/src/cli/update/cdn.ts +++ b/apps/pythinker-code/src/cli/update/cdn.ts @@ -1,7 +1,7 @@ -import { valid } from 'semver'; +import { lt, valid } from 'semver'; import { z } from 'zod'; -import { PYTHINKER_CODE_CDN_LATEST_JSON_URL, PYTHINKER_CODE_CDN_LATEST_URL } from '#/constant/app'; +import { PYTHINKER_CODE_CDN_LATEST_JSON_URL } from '#/constant/app'; import type { UpdateManifest } from './types'; @@ -12,11 +12,28 @@ const RolloutBatchSchema = z.object({ delaySeconds: z.number().int().min(0), }); +const UpdateManifestPlatformSchema = z.object({ + url: z + .string() + .refine( + (value) => { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } + }, + { error: 'invalid url' }, + ), + sha256: z.string().regex(/^[a-f0-9]{64}$/u), +}); + /** * CDN `latest.json` wire format. Deliberately NOT `.strict()` — unknown * fields are ignored so future manifest additions never break shipped - * clients (the plain-text `/latest` taught us that hard-failing on - * unexpected content bricks the update path forever). + * clients. Hard-failing on unexpected content bricks the update path for + * every already-installed client, which is unrecoverable from our side. */ export const UpdateManifestSchema = z.object({ version: z.string().refine((value) => valid(value) !== null, { error: 'invalid semver' }), @@ -24,15 +41,30 @@ export const UpdateManifestSchema = z.object({ .string() .refine((value) => Number.isFinite(Date.parse(value)), { error: 'invalid timestamp' }), rollout: z.array(RolloutBatchSchema).readonly().default([]), + /** + * Resolved per-platform artifacts, keyed `-`. A malformed + * value drops only this field via `.catch(undefined)` so `version` and + * `publishedAt` still parse — failing the whole manifest would cost the + * client its update over one unreadable field. + */ + platforms: z + .record(z.string(), UpdateManifestPlatformSchema) + .readonly() + .optional() + .catch(undefined), + /** + * Lowest version that can still work against the current services. A + * malformed value drops only this field via `.catch(undefined)` so + * `version` and `publishedAt` still parse — a client below the floor must + * not lose its update because the declaration is unreadable. + */ + minRequiredVersion: z + .string() + .refine((value) => valid(value) !== null, { error: 'invalid semver' }) + .optional() + .catch(undefined), }); -export interface FetchLatestResult { - /** Raw newest version — what `pythinker upgrade` installs, never rollout-gated. */ - readonly latest: string; - /** Null when the JSON manifest was unavailable and we fell back to plain text. */ - readonly manifest: UpdateManifest | null; -} - async function fetchWithTimeout(fetchImpl: typeof fetch, input: string): Promise { const controller = new AbortController(); const timeout = setTimeout(() => { @@ -46,30 +78,25 @@ async function fetchWithTimeout(fetchImpl: typeof fetch, input: string): Promise } /** - * Fetch the latest published Pythinker Code version from the CDN. + * Fetch the CDN update manifest — the client's only source of update truth. * - * **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. + * **Throws** on any failure (network error, non-2xx, unparseable body). Callers + * must catch: `refreshUpdateCache` deliberately lets the error propagate so the + * existing cache stays intact instead of being overwritten on a transient blip. + * + * There is deliberately no fallback to the plain-text `/latest` endpoint, which + * still exists for `install.sh`. That endpoint carries no per-platform artifact + * data, so falling back to it turns "cannot verify this platform has a build" + * into "verified" and re-opens the hole `platforms` exists to close. It also + * cannot fail independently: both files come from the same generator in the same + * deploy, and the manifest schema already tolerates unknown fields and a + * malformed `platforms` value without failing the parse. * * `fetchImpl` is injectable for tests; defaults to the global `fetch`. */ -export async function fetchLatestVersionFromCdn( +export async function fetchUpdateManifest( fetchImpl: typeof fetch = fetch, -): Promise { - 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 { +): Promise { const response = await fetchWithTimeout(fetchImpl, PYTHINKER_CODE_CDN_LATEST_JSON_URL); if (!response.ok) { throw new Error(`CDN /latest.json returned HTTP ${response.status}`); @@ -77,21 +104,42 @@ async function fetchUpdateManifestFromCdn(fetchImpl: typeof fetch): Promise { - const manifest = await fetchUpdateManifestFromCdn(fetchImpl).catch(() => null); - if (manifest !== null) { - return { latest: manifest.version, manifest }; +export function manifestArtifactAvailability( + manifest: UpdateManifest | null, + target: string = `${process.platform}-${process.arch}`, +): ArtifactAvailability { + if (manifest === null) { + return 'available'; + } + if (manifest.platforms === undefined) { + return 'available'; } - const latest = await fetchLatestVersionFromCdn(fetchImpl); - return { latest, manifest: null }; + return Object.hasOwn(manifest.platforms, target) ? 'available' : 'unavailable'; +} + +/** + * Whether the running version is below the manifest's declared floor, which + * makes its update mandatory rather than merely available: the staged rollout + * delay exists for ordinary releases, not for one a client cannot skip. + * + * An absent, unreadable or non-semver floor answers false — a declaration we + * cannot understand must not escalate an update on its own. + */ +export function isBelowMinRequiredVersion( + manifest: UpdateManifest | null, + currentVersion: string, +): boolean { + const floor = manifest?.minRequiredVersion; + if (floor === undefined) return false; + if (valid(currentVersion) === null || valid(floor) === null) return false; + return lt(currentVersion, floor); } diff --git a/apps/pythinker-code/src/cli/update/install-lock.ts b/apps/pythinker-code/src/cli/update/install-lock.ts index 3242587d..8f27d4f0 100644 --- a/apps/pythinker-code/src/cli/update/install-lock.ts +++ b/apps/pythinker-code/src/cli/update/install-lock.ts @@ -4,8 +4,13 @@ import { dirname } from 'node:path'; import { getUpdateInstallLockFile } from '#/utils/paths'; -const UPDATE_INSTALL_LOCK_STALE_MS = 30 * 60 * 1000; -const UPDATE_INSTALL_LOCK_CLOCK_SKEW_MS = 5 * 60 * 1000; +import { isLeaseFresh, type LeaseLimits } from './lease'; + +const LOCK_LEASE_LIMITS: LeaseLimits = { + pidCeilingMs: 6 * 60 * 60 * 1000, + pidlessTtlMs: 30 * 60 * 1000, + clockSkewMs: 5 * 60 * 1000, +}; export interface UpdateInstallLockRequest { readonly version: string; @@ -36,23 +41,6 @@ function isAlreadyExists(error: unknown): boolean { ); } -/** - * Liveness probe via `kill(pid, 0)`. EPERM means the process exists but is - * owned by another user, which is still "running" for lock purposes. - */ -function isProcessRunning(pid: number): boolean { - if (!Number.isSafeInteger(pid) || pid <= 0) return false; - try { - process.kill(pid, 0); - return true; - } catch (error) { - return typeof error === 'object' - && error !== null - && 'code' in error - && error.code === 'EPERM'; - } -} - async function readLockSnapshot(filePath: string): Promise { try { const raw = await readFile(filePath, 'utf-8'); @@ -80,18 +68,8 @@ async function readLockSnapshot(filePath: string): Promise } } -/** - * A recorded pid overrides timestamps: a live owner keeps the lock no - * matter how old it is. PID-less (legacy) locks expire by age, with a - * clock-skew tolerance so a small rollback cannot orphan a live install. - */ function isStaleLock(snapshot: LockSnapshot, now: Date): boolean { - if (snapshot.pid !== undefined) return !isProcessRunning(snapshot.pid); - if (snapshot.startedAt === undefined) return true; - const startedAt = Date.parse(snapshot.startedAt); - if (!Number.isFinite(startedAt)) return true; - const age = now.getTime() - startedAt; - return age < -UPDATE_INSTALL_LOCK_CLOCK_SKEW_MS || age > UPDATE_INSTALL_LOCK_STALE_MS; + return !isLeaseFresh(snapshot, LOCK_LEASE_LIMITS, now); } function hasSameOwner(current: LockSnapshot, expected: LockSnapshot): boolean { diff --git a/apps/pythinker-code/src/cli/update/install-state.ts b/apps/pythinker-code/src/cli/update/install-state.ts index c34ce297..c9a7539e 100644 --- a/apps/pythinker-code/src/cli/update/install-state.ts +++ b/apps/pythinker-code/src/cli/update/install-state.ts @@ -3,7 +3,87 @@ import { z } from 'zod'; import { getUpdateInstallStateFile } from '#/utils/paths'; import { readJsonFile, writeJsonFile } from '#/utils/persistence'; -import { emptyUpdateInstallState, type InstallSource, type UpdateInstallState } from './types'; +import { isLeaseFresh, type LeaseLimits } from './lease'; +import { emptyUpdateInstallState, type InstallSource, type UpdateInstallOperation, type UpdateInstallProgress, type UpdateInstallState, type UpdateTarget } from './types'; + +const ACTIVE_LEASE_LIMITS: LeaseLimits = { + pidCeilingMs: 6 * 60 * 60 * 1000, + pidlessTtlMs: 6 * 60 * 60 * 1000, + clockSkewMs: 5 * 60 * 1000, +}; + +/** + * Whether an install is still in flight. It lives here, next to the record it + * reads, because both the preflight and the foreground upgrade command must + * answer it the same way — a second copy of this predicate is how the + * foreground paths came to ignore the lease at all. + */ +export function hasFreshActiveInstall( + state: UpdateInstallState, + now: Date = new Date(), +): boolean { + const active = state.active; + return active !== null && isLeaseFresh(active, ACTIVE_LEASE_LIMITS, now); +} + +/** + * The number of recorded failures for a target. Threshold gates omit + * `operation`: any failure kind at the limit parks the version. Increment + * sites pass their operation so a counter never resumes from another + * operation's attempts. Legacy records without `operation` count toward any + * operation. + */ +export function failureAttemptsFor( + state: UpdateInstallState, + target: UpdateTarget, + operation?: UpdateInstallOperation, +): number { + const failure = state.lastFailure; + if (failure?.version !== target.version) return 0; + if ( + operation !== undefined && + failure.operation !== undefined && + failure.operation !== operation + ) { + return 0; + } + return failure.attempts; +} + +const ABANDONED_INSTALL_MESSAGE = + 'The background install was abandoned: the process that started it exited before recording an outcome.'; + +/** + * One startup reconciliation for an install record whose owner never recorded + * an outcome. The background installer's terminal state write lives in the + * parent process, so when the parent dies the `active` record stays behind and + * no failure is recorded; without this, a version that cannot succeed is + * retried on every launch instead of being parked by the failure counter. + * + * A fresh record is a live lease and is left alone; an abandoned one is + * cleared and recorded as one more failure for its version and operation, so + * the existing threshold logic parks the version after enough of them. + */ +export async function reconcileAbandonedInstall( + state: UpdateInstallState, + now: Date = new Date(), +): Promise { + const active = state.active; + if (active === null || hasFreshActiveInstall(state, now)) return state; + const reconciled: UpdateInstallState = { + ...state, + active: null, + lastFailure: { + version: active.version, + failedAt: now.toISOString(), + attempts: failureAttemptsFor(state, { version: active.version }, active.operation) + 1, + operation: active.operation, + message: ABANDONED_INSTALL_MESSAGE, + }, + }; + await writeUpdateInstallState(reconciled).catch(() => {}); + return reconciled; +} const InstallSourceSchema: z.ZodType = z.enum([ 'npm-global', @@ -18,6 +98,16 @@ const InstallSourceSchema: z.ZodType = z.enum([ const UpdateInstallOperationSchema = z.enum(['install', 'prepare', 'activate']); const Sha256Schema = z.string().regex(/^[a-f0-9]{64}$/u); +const UpdateInstallProgressSchema: z.ZodType = z + .object({ + state: z.enum(['downloading', 'waiting', 'done', 'failed']), + percent: z.number().int().min(0).max(100).optional(), + transferred: z.number().int().nonnegative().optional(), + total: z.number().int().nonnegative().optional(), + updatedAt: z.string().min(1), + }) + .strict(); + const UpdateInstallStateSchema: z.ZodType = z .object({ active: z @@ -28,6 +118,7 @@ const UpdateInstallStateSchema: z.ZodType = z pid: z.number().int().positive().optional(), operation: UpdateInstallOperationSchema.optional(), jobId: z.uuid().optional(), + progress: UpdateInstallProgressSchema.optional(), }) .strict() .nullable(), diff --git a/apps/pythinker-code/src/cli/update/lease.ts b/apps/pythinker-code/src/cli/update/lease.ts new file mode 100644 index 00000000..371a02b4 --- /dev/null +++ b/apps/pythinker-code/src/cli/update/lease.ts @@ -0,0 +1,56 @@ +/** + * One rule for "is this install lease still held", shared by the install lock + * file and the active-install record. + * + * Both used to answer it with their own copy of `isProcessRunning` and their own + * age arithmetic, and they drifted: a live pid used to hold either lease forever, + * with no ceiling, so a recycled pid wedged every update path permanently. + */ + +export interface LeaseRecord { + /** Owner process id; absent in leases written before pids were recorded. */ + readonly pid?: number; + readonly startedAt?: string; +} + +export interface LeaseLimits { + /** Ceiling on a lease whose owner pid is still alive. The OS reuses pids. */ + readonly pidCeilingMs: number; + /** Ceiling on a lease with no recorded pid, where age is the only signal. */ + readonly pidlessTtlMs: number; + /** A clock rollback within this tolerance must not orphan a live install. */ + readonly clockSkewMs: number; +} + +/** + * Liveness probe via `kill(pid, 0)`. EPERM means the process exists but is + * owned by another user, which is still "running" for lease purposes. + */ +export function isProcessRunning(pid: number): boolean { + if (!Number.isSafeInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return typeof error === 'object' + && error !== null + && 'code' in error + && error.code === 'EPERM'; + } +} + +/** + * A lease is held while its owner is alive *and* it is younger than the + * ceiling. A lease with no usable timestamp can never age out, so it is never + * fresh; a far-future timestamp is not fresh either. + */ +export function isLeaseFresh(record: LeaseRecord, limits: LeaseLimits, now: Date): boolean { + const startedAt = record.startedAt === undefined ? Number.NaN : Date.parse(record.startedAt); + if (!Number.isFinite(startedAt)) return false; + const age = now.getTime() - startedAt; + if (age < -limits.clockSkewMs) return false; + if (record.pid !== undefined) { + return isProcessRunning(record.pid) && age < limits.pidCeilingMs; + } + return age < limits.pidlessTtlMs; +} diff --git a/apps/pythinker-code/src/cli/update/preflight.ts b/apps/pythinker-code/src/cli/update/preflight.ts index d321ccaa..c3c68d81 100644 --- a/apps/pythinker-code/src/cli/update/preflight.ts +++ b/apps/pythinker-code/src/cli/update/preflight.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'; import { homedir } from 'node:os'; import type { Readable } from 'node:stream'; -import { gte, valid } from 'semver'; +import { gt, gte, valid } from 'semver'; import { log, type Logger } from '@pythoughts/pythinker-code-sdk'; import type { TelemetryProperties } from '@pythoughts/pythinker-telemetry'; @@ -18,7 +18,14 @@ import { loadTuiConfig } from '#/tui/config'; import { readUpdateCache } from './cache'; import { formatErrorMessage } from './format-error'; import { tryAcquireUpdateInstallLock } from './install-lock'; -import { emptyUpdateInstallState, readUpdateInstallState, writeUpdateInstallState } from './install-state'; +import { + emptyUpdateInstallState, + failureAttemptsFor, + hasFreshActiveInstall, + readUpdateInstallState, + reconcileAbandonedInstall, + writeUpdateInstallState, +} from './install-state'; import { CHANGELOG_URL, promptForInstallChoice, @@ -26,7 +33,7 @@ import { type InstallPromptOptions, } from './prompt'; import { refreshUpdateCache } from './refresh'; -import { selectUpdateTarget } from './select'; +import { isTargetInstallable, selectUpdateTarget } from './select'; import { appendRolloutDecisionLog, decidePassiveUpdateTarget, @@ -41,7 +48,7 @@ import { NPM_PACKAGE_NAME, type InstallSource, type UpdateDecision, - type UpdateInstallOperation, + type UpdateInstallProgress, type UpdateInstallState, type UpdateCache, type UpdateManifest, @@ -61,8 +68,6 @@ export interface RunUpdatePreflightOptions { } const AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD = 2; -const AUTO_INSTALL_ACTIVE_TTL_MS = 6 * 60 * 60 * 1000; -const AUTO_INSTALL_ACTIVE_CLOCK_SKEW_MS = 5 * 60 * 1000; const USER_VISIBLE_UPDATE_REFRESH_TIMEOUT_MS = 1_000; const UPDATE_HELPER_ENV = 'PYTHINKER_CODE_UPDATE_HELPER'; @@ -231,10 +236,6 @@ function renderBackgroundInstallSuccessNotice(version: string): string { return `Pythinker Code updated to ${displayVersion}\nChangelog: ${CHANGELOG_URL}\n`; } -function refreshInBackground(): void { - void refreshUpdateCache().catch(() => {}); -} - /** Telemetry properties describing where this device sits in the rollout. */ interface RolloutTelemetry { readonly rollout_bucket: number; @@ -376,57 +377,6 @@ function nowIso(): string { return new Date().toISOString(); } -function failureAttemptsFor( - state: UpdateInstallState, - target: UpdateTarget, - operation?: UpdateInstallOperation, -): number { - const failure = state.lastFailure; - if (failure?.version !== target.version) return 0; - // Threshold gates omit `operation`: any failure kind at the limit parks the - // version. Increment sites pass their operation so a counter never resumes - // from another operation's attempts. Legacy records without `operation` - // count toward any operation. - if ( - operation !== undefined && - failure.operation !== undefined && - failure.operation !== operation - ) { - return 0; - } - return failure.attempts; -} - -function isProcessRunning(pid: number): boolean { - if (!Number.isSafeInteger(pid) || pid <= 0) return false; - try { - process.kill(pid, 0); - return true; - } catch (error) { - return typeof error === 'object' - && error !== null - && 'code' in error - && error.code === 'EPERM'; - } -} - -/** - * An active record still counts as a lease when the recorded installer - * pid is alive (liveness beats the TTL), or — for legacy pid-less - * records — when its timestamp is within the TTL (with clock-skew - * tolerance). Far-future timestamps never count as fresh. - */ -function hasFreshActiveInstall(state: UpdateInstallState): boolean { - const active = state.active; - if (active === null) return false; - const startedAt = Date.parse(active.startedAt); - if (!Number.isFinite(startedAt)) return false; - const age = Date.now() - startedAt; - if (age < -AUTO_INSTALL_ACTIVE_CLOCK_SKEW_MS) return false; - if (active.pid !== undefined) return isProcessRunning(active.pid); - return age < AUTO_INSTALL_ACTIVE_TTL_MS; -} - async function showPendingBackgroundInstallNotice( state: UpdateInstallState, currentVersion: string, @@ -614,21 +564,79 @@ export async function installUpdate( /** Keep the tail only: installers can be chatty, and the state file is small. */ const INSTALLER_STDERR_TAIL_CHARS = 2000; +/** At most one active-record progress write every 2s; terminal states bypass it. */ +const INSTALLER_PROGRESS_WRITE_INTERVAL_MS = 2_000; +const INSTALLER_PROGRESS_PREFIX = 'progress: '; + +function isInstallerProgressState(value: string): value is UpdateInstallProgress['state'] { + return value === 'downloading' || value === 'waiting' || value === 'done' || value === 'failed'; +} + +/** + * Parse one `progress: key=value key=value` line from the installer's stderr. + * Unknown or malformed keys are skipped — an installer from a different + * release must never crash the parent. Returns null when the line carries no + * usable state. + */ +function parseInstallerProgressLine(line: string): UpdateInstallProgress | null { + let state: UpdateInstallProgress['state'] | undefined; + let percent: number | undefined; + let transferred: number | undefined; + let total: number | undefined; + for (const field of line.slice(INSTALLER_PROGRESS_PREFIX.length).split(/\s+/u)) { + const eq = field.indexOf('='); + if (eq <= 0) continue; + const key = field.slice(0, eq); + const value = field.slice(eq + 1); + switch (key) { + case 'state': + if (isInstallerProgressState(value)) state = value; + break; + case 'percent': + if (/^(?:100|[0-9]{1,2})$/u.test(value)) percent = Number(value); + break; + case 'transferred': + if (/^[0-9]+$/u.test(value)) transferred = Number(value); + break; + case 'total': + if (/^[0-9]+$/u.test(value)) total = Number(value); + break; + default: + break; + } + } + if (state === undefined) return null; + return { state, percent, transferred, total, updatedAt: nowIso() }; +} /** - * Buffer the installer's stderr so a failed background install records why it - * failed. Discarding it (the previous `stdio: 'ignore'`) left `lastFailure` - * with nothing but an exit code, which made a broken installer script - * impossible to diagnose without reproducing the spawn by hand. + * Read the installer's stderr as lines. `progress: …` lines are parsed and + * handed to `onProgress`; they never enter the failure tail, or a long + * download would evict the very error text the tail exists to preserve. All + * other lines are kept in the trailing `INSTALLER_STDERR_TAIL_CHARS` window. + * Returns a getter for that tail. */ -function captureStderrTail(child: ReturnType): () => string | undefined { +function captureStderrTail( + child: ReturnType, + onProgress: (update: UpdateInstallProgress) => void, +): () => string | undefined { // Typed `Readable | null`, but absent entirely when stderr was not piped. const stream: Readable | null | undefined = child.stderr; if (stream === null || stream === undefined) return () => undefined; let tail = ''; + let partial = ''; stream.setEncoding('utf8'); stream.on('data', (chunk: string) => { - tail = (tail + chunk).slice(-INSTALLER_STDERR_TAIL_CHARS); + const lines = (partial + chunk).split('\n'); + partial = lines.pop() ?? ''; + for (const line of lines) { + if (line.startsWith(INSTALLER_PROGRESS_PREFIX)) { + const update = parseInstallerProgressLine(line); + if (update !== null) onProgress(update); + } else { + tail = (tail + line + '\n').slice(-INSTALLER_STDERR_TAIL_CHARS); + } + } }); // A detached installer outliving this process must not crash it, and the // pipe must not hold the event loop open on the way out. `child.stderr` is @@ -637,7 +645,12 @@ function captureStderrTail(child: ReturnType): () => string | unde stream.on('error', () => {}); (stream as Readable & { unref?: () => void }).unref?.(); return () => { - const trimmed = tail.trim(); + // A final partial line without a newline is still installer text; include + // it unless it is a truncated progress line. + const complete = partial.length === 0 || partial.startsWith(INSTALLER_PROGRESS_PREFIX) + ? tail + : tail + partial; + const trimmed = complete.trim(); return trimmed.length === 0 ? undefined : trimmed; }; } @@ -681,6 +694,17 @@ function preparedVersionCoversTarget(preparedVersion: string, targetVersion: str return valid(preparedVersion) !== null && valid(targetVersion) !== null && gte(preparedVersion, targetVersion); } +/** + * Whether the target is strictly newer than the version an active install is + * working on — the newer update can only start after the running one finishes. + */ +function targetSupersedesInstallingVersion( + installingVersion: string, + targetVersion: string, +): boolean { + return valid(installingVersion) !== null && valid(targetVersion) !== null && gt(targetVersion, installingVersion); +} + async function startBackgroundHomebrewPreparation( state: UpdateInstallState, currentVersion: string, @@ -817,6 +841,15 @@ async function startBackgroundInstall( let ready = false; let settled = false; let pendingOutcome: { succeeded: boolean; reason: string } | undefined; + // Progress writes are fire-and-forget, and the state file is written as a + // temp file plus rename — so the last rename wins. The installer's terminal + // `state=done` line bypasses the throttle and writes just as the child + // exits, so without ordering that write can land *after* the outcome write + // and restore `active` while dropping `lastSuccess`. The next launch reads + // that as an abandoned install and records a failure for a version that + // installed cleanly. One chain keeps the writes ordered and gives `finish` + // something to drain. + let progressWrites: Promise = Promise.resolve(); const finish = async (succeeded: boolean, reason: string): Promise => { if (!ready) { @@ -825,6 +858,9 @@ async function startBackgroundInstall( } if (settled) return; settled = true; + // `settled` already stops new progress writes; drain the ones in flight so + // none of them renames over the outcome below. + await progressWrites; const attempts = failureAttemptsFor(startedState, target, 'install') + 1; const stderrTail = readStderrTail(); const message = stderrTail === undefined ? reason : `${reason}: ${stderrTail}`; @@ -886,11 +922,48 @@ async function startBackgroundInstall( // of stdio; stdio: 'ignore' alone does not suppress it. windowsHide: platform === 'win32', // stdout stays discarded (install progress is noise); stderr is piped so - // a failure records the installer's own error text. + // the installer's machine-readable progress lines can be recorded and a + // failure still keeps the installer's own error text. stdio: ['ignore', 'ignore', 'pipe'], env: env === undefined ? undefined : { ...process.env, ...env }, }); - const readStderrTail = captureStderrTail(child); + let lastProgressWriteAt = 0; + const recordInstallerProgress = (update: UpdateInstallProgress): void => { + // Once the outcome is being written, progress is history: writing it would + // undo the terminal record. + if (settled) return; + // Terminal states always persist; intermediate ones at most every 2s. + const terminal = update.state === 'done' || update.state === 'failed'; + if ( + !terminal + && Date.now() - lastProgressWriteAt < INSTALLER_PROGRESS_WRITE_INTERVAL_MS + ) return; + if (startedState.active === null) return; + lastProgressWriteAt = Date.now(); + const nextState: UpdateInstallState = { + ...startedState, + // Carry the pid explicitly: a progress line can arrive while the pid + // write is still in flight, and writing the pre-pid record over it + // would strip the pid this record's liveness check depends on. + active: { + ...startedState.active, + pid: child.pid ?? startedState.active.pid, + progress: update, + }, + }; + progressWrites = progressWrites + .then(() => writeUpdateInstallState(nextState)) + .catch((error) => { + // A progress write is best-effort; it must never reject the spawn path + // and must not break the chain for the writes queued behind it. + logUpdateWarn(logger, 'could not record installer progress', { + targetVersion: target.version, + source, + error: formatErrorMessage(error), + }); + }); + }; + const readStderrTail = captureStderrTail(child, recordInstallerProgress); child.once('error', (error) => { void finish(false, formatErrorMessage(error)); }); child.once('exit', (code, signal) => { void finish(code === 0, describeChildExit(cmd, code, signal)); @@ -999,7 +1072,9 @@ export type ManualUpdateResult = | { readonly status: 'started'; readonly version: string; readonly installOnRestart: boolean } | { readonly status: 'in-progress'; - readonly version: string; + readonly installingVersion: string; + /** Present only when it is newer than the version being installed. */ + readonly targetVersion?: string; readonly installOnRestart: boolean; readonly readyToInstall: boolean; } @@ -1008,6 +1083,14 @@ export type ManualUpdateResult = readonly version: string; readonly command: string; readonly source: InstallSource; + } + | { + readonly status: 'failed'; + readonly version: string; + readonly attempts: number; + readonly failedAt: string; + readonly message?: string; + readonly command: string; }; /** @@ -1032,11 +1115,21 @@ export async function startManualUpdate( const platform = process.platform; const source = await detectInstallSource().catch(() => 'unsupported' as const); - const installState = await readUpdateInstallState().catch(() => emptyUpdateInstallState()); + // A native install consumes the manifest's platform artifact; without one + // the update cannot succeed, so treat it as nothing to update. + if (!isTargetInstallable(source, cache.manifest)) { + return { status: 'up-to-date' }; + } + let installState = await readUpdateInstallState().catch(() => emptyUpdateInstallState()); + installState = await reconcileAbandonedInstall(installState); if (hasFreshActiveInstall(installState)) { + const installingVersion = installState.active?.version ?? target.version; return { status: 'in-progress', - version: installState.active?.version ?? target.version, + installingVersion, + targetVersion: targetSupersedesInstallingVersion(installingVersion, target.version) + ? target.version + : undefined, installOnRestart: installState.active?.source === 'homebrew', readyToInstall: false, }; @@ -1059,19 +1152,28 @@ export async function startManualUpdate( } return { status: 'in-progress', - version: pending.version, + installingVersion: pending.version, installOnRestart: true, readyToInstall: true, }; } - // Repeated background failures fall back to the copyable command instead of - // claiming "started" for work the background lifecycle would refuse. - if (failureAttemptsFor(installState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD) { + // A version parks once the failure counter hits the threshold: the + // background lifecycle refuses to touch it again, so claiming "started" or + // "in-progress" would be a lie and another retry would only burn another + // launch on an install that already failed. Report the recorded failure + // and the copyable command instead. + const failure = installState.lastFailure; + if ( + failure !== null && + failureAttemptsFor(installState, target) >= AUTO_INSTALL_FAILURE_PROMPT_THRESHOLD + ) { return { - status: 'manual', + status: 'failed', version: target.version, + attempts: failure.attempts, + failedAt: failure.failedAt, + message: failure.message, command: installCommandFor(source, target.version, platform), - source, }; } @@ -1097,7 +1199,7 @@ export async function startManualUpdate( if (!started) { return { status: 'in-progress', - version: target.version, + installingVersion: target.version, installOnRestart: true, readyToInstall: false, }; @@ -1125,7 +1227,7 @@ export async function startManualUpdate( if (!started) { return { status: 'in-progress', - version: target.version, + installingVersion: target.version, installOnRestart: false, readyToInstall: false, }; @@ -1165,6 +1267,7 @@ export async function runUpdatePreflight( const deviceId = resolveUpdateDeviceId(); const bypassRollout = isRolloutBypassedByExperimentalEnv(); let installState = await readUpdateInstallState().catch(() => emptyUpdateInstallState()); + installState = await reconcileAbandonedInstall(installState); if (isInteractive) { installState = await showPendingBackgroundInstallNotice( installState, @@ -1206,28 +1309,11 @@ export async function runUpdatePreflight( ? 'unsupported' : await detectInstallSource().catch(() => 'unsupported' as const); - const decision = decideUpdateAction(target, isInteractive, source, platform); - if (decision === 'none') { - refreshInBackground(); - return 'continue'; - } - - if ( - await tryStartAutomaticBackgroundInstall( - installState, - currentVersion, - target, - source, - platform, - options.track, - logger, - rolloutTelemetryFor(deviceId, target.version, cachedManifest, bypassRollout), - ) - ) { - refreshInBackground(); - return 'continue'; - } - + // The cached target above only decides whether anything is worth + // refreshing for; the bounded refresh below is this launch's single + // decision. Everything after it uses the refreshed target and manifest, + // with the cached pair as the fallback when the refresh fails or times + // out. A null refreshed target means the refresh offers nothing. const userVisibleUpdate = await refreshUserVisibleUpdateTarget( currentVersion, deviceId, @@ -1243,6 +1329,19 @@ export async function runUpdatePreflight( userVisibleUpdate.manifest, bypassRollout, ); + + // A native install consumes the manifest's platform artifact; without one + // the update cannot succeed, so do not offer or start it. Non-native + // sources install from the registry/formula and are never gated here. + if (!isTargetInstallable(source, userVisibleUpdate.manifest)) { + return 'continue'; + } + + const decision = decideUpdateAction(userVisibleTarget, isInteractive, source, platform); + if (decision === 'none') { + return 'continue'; + } + if ( await tryStartAutomaticBackgroundInstall( installState, @@ -1271,6 +1370,11 @@ export async function runUpdatePreflight( return 'continue'; } + // An install that is already in flight must not be prompted for a second + // time: the user would get a confusing double message for work that is + // already running elsewhere. + if (hasFreshActiveInstall(installState)) return 'continue'; + const choice = await promptInstall( currentVersion, userVisibleTarget, @@ -1280,16 +1384,47 @@ export async function runUpdatePreflight( ); if (choice === 'skip') return 'continue'; + // Take the lock only after the prompt resolves: holding it across an + // indefinite interactive wait would block the background path as long as + // the prompt sits unanswered. A null handle means another installer is + // already running — do not install on top of it. + const lock = await tryAcquireUpdateInstallLock({ version: userVisibleTarget.version }); + if (lock === null) return 'continue'; + try { await installUpdate(source, userVisibleTarget.version, platform); + await writeUpdateInstallState({ + ...installState, + active: null, + lastFailure: null, + lastSuccess: { + version: userVisibleTarget.version, + installedAt: nowIso(), + notifiedAt: null, + }, + }).catch(() => {}); stdout.write(renderInstallSuccessMessage(userVisibleTarget)); return 'exit'; } catch (error) { + const attempts = failureAttemptsFor(installState, userVisibleTarget, 'install') + 1; + await writeUpdateInstallState({ + ...installState, + active: null, + lastFailure: { + version: userVisibleTarget.version, + failedAt: nowIso(), + attempts, + operation: 'install', + message: formatErrorMessage(error), + }, + }).catch(() => {}); stderr.write( `warning: failed to install ${NPM_PACKAGE_NAME}@${userVisibleTarget.version}: ` + `${formatErrorMessage(error)}\n`, ); return 'continue'; + } finally { + await lock.release().catch(() => {}); } } catch { return 'continue'; diff --git a/apps/pythinker-code/src/cli/update/refresh.ts b/apps/pythinker-code/src/cli/update/refresh.ts index 938a4a0f..dbc842c1 100644 --- a/apps/pythinker-code/src/cli/update/refresh.ts +++ b/apps/pythinker-code/src/cli/update/refresh.ts @@ -1,14 +1,13 @@ import { writeUpdateCache } from './cache'; -import { fetchLatestFromCdn, type FetchLatestResult } from './cdn'; -import { type UpdateCache } from './types'; +import { fetchUpdateManifest } from './cdn'; +import { type UpdateCache, type UpdateManifest } from './types'; export interface RefreshUpdateCacheDeps { - /** Resolves with the latest version + rollout manifest. **Throws** on any - * failure — callers (including the default background invocation in - * preflight) must catch. Errors intentionally skip `writeCache` so a - * transient CDN blip does not overwrite a previously known `latest` with - * `null`. */ - readonly fetchLatest: () => Promise; + /** Resolves with the CDN update manifest. **Throws** on any failure — callers + * (including the default background invocation in preflight) must catch. + * Errors intentionally skip `writeCache` so a transient CDN blip does not + * overwrite a previously known `latest` with `null`. */ + readonly fetchManifest: () => Promise; readonly writeCache: (cache: UpdateCache) => Promise; readonly now: () => Date; } @@ -17,16 +16,16 @@ export async function refreshUpdateCache( overrides: Partial = {}, ): Promise { const resolved: RefreshUpdateCacheDeps = { - fetchLatest: overrides.fetchLatest ?? (() => fetchLatestFromCdn()), + fetchManifest: overrides.fetchManifest ?? (() => fetchUpdateManifest()), writeCache: overrides.writeCache ?? writeUpdateCache, now: overrides.now ?? (() => new Date()), }; - const { latest, manifest } = await resolved.fetchLatest(); + const manifest = await resolved.fetchManifest(); const cache: UpdateCache = { source: 'cdn', checkedAt: resolved.now().toISOString(), - latest, + latest: manifest.version, manifest, }; await resolved.writeCache(cache); diff --git a/apps/pythinker-code/src/cli/update/rollout.ts b/apps/pythinker-code/src/cli/update/rollout.ts index c212a07b..f546724b 100644 --- a/apps/pythinker-code/src/cli/update/rollout.ts +++ b/apps/pythinker-code/src/cli/update/rollout.ts @@ -7,6 +7,7 @@ import { resolvePythinkerHome } from '@pythoughts/pythinker-code-sdk'; import { getUpdateRolloutLogFile } from '#/utils/paths'; +import { isBelowMinRequiredVersion } from './cdn'; import { selectUpdateTarget } from './select'; import type { RolloutBatch, UpdateManifest, UpdateTarget } from './types'; @@ -72,6 +73,8 @@ export type PassiveUpdateReason = | 'held' /** Gated and the batch delay has elapsed: update is visible. */ | 'eligible' + /** Manifest floor: client is below minRequiredVersion, rollout bypassed. */ + | 'required' /** PYTHINKER_CODE_EXPERIMENTAL_FLAG is on: rollout skipped, newest always visible. */ | 'experimental'; @@ -139,6 +142,19 @@ export function decidePassiveUpdateTarget( const eligibleAt = Number.isFinite(publishedAt) ? new Date(publishedAt + delaySeconds * 1000).toISOString() : null; + // A client below the manifest floor must take the update now: the staged + // delay exists for ordinary releases, not for one the client cannot skip. + // Bucket, delay and eligibleAt stay populated so the telemetry and the + // decision log still describe where the device sits in the plan. + if (isBelowMinRequiredVersion(manifest, currentVersion)) { + return { + target, + reason: 'required', + bucket, + delaySeconds, + eligibleAt, + }; + } const eligible = isRolloutEligible(manifest, deviceId, now); return { target: eligible ? target : null, diff --git a/apps/pythinker-code/src/cli/update/select.ts b/apps/pythinker-code/src/cli/update/select.ts index bf241ed8..c5ab033c 100644 --- a/apps/pythinker-code/src/cli/update/select.ts +++ b/apps/pythinker-code/src/cli/update/select.ts @@ -1,6 +1,7 @@ import { gt, valid } from 'semver'; -import { type UpdateTarget } from './types'; +import { manifestArtifactAvailability } from './cdn'; +import { type InstallSource, type UpdateManifest, type UpdateTarget } from './types'; export function selectUpdateTarget( currentVersion: string, @@ -11,3 +12,19 @@ export function selectUpdateTarget( if (!gt(latest, currentVersion)) return null; return { version: latest }; } + +/** + * Whether an update can be installed from this source at all. Only the native + * install path consumes a platform artifact, so only it must be suppressed + * when the manifest does not advertise one: + * - npm-family sources (`npm-global`, `pnpm-global`, `yarn-global`, + * `bun-global`) install from the npm registry, where the published version + * *is* the artifact — a missing native zip says nothing about them, and + * suppressing their update would be a regression. + * - `homebrew` installs through its own formula. + * - `unsupported` never installs anyway. + */ +export function isTargetInstallable(source: InstallSource, manifest: UpdateManifest | null): boolean { + if (source !== 'native') return true; + return manifestArtifactAvailability(manifest) === 'available'; +} diff --git a/apps/pythinker-code/src/cli/update/types.ts b/apps/pythinker-code/src/cli/update/types.ts index 485535ec..caf048c1 100644 --- a/apps/pythinker-code/src/cli/update/types.ts +++ b/apps/pythinker-code/src/cli/update/types.ts @@ -22,6 +22,11 @@ export interface RolloutBatch { readonly delaySeconds: number; } +export interface UpdateManifestPlatform { + readonly url: string; + readonly sha256: string; +} + /** * Parsed CDN `latest.json`. `rollout` batches claim bucket ranges in array * order; an empty array means the release is fully rolled out immediately. @@ -30,6 +35,17 @@ export interface UpdateManifest { readonly version: string; readonly publishedAt: string; readonly rollout: readonly RolloutBatch[]; + /** + * Resolved per-platform artifacts, keyed `-`. Absent on + * manifests published before artifact addressing shipped. + */ + readonly platforms?: Readonly>; + /** + * Lowest version that can still work against the current services. A + * client below it must take the update without waiting for its rollout + * batch. Absent on ordinary releases. + */ + readonly minRequiredVersion?: string; } export interface UpdateCache { @@ -43,6 +59,19 @@ export interface UpdateCache { export type UpdateInstallOperation = 'install' | 'prepare' | 'activate'; export type UpdateRequestOrigin = 'automatic' | 'manual'; +/** + * Latest machine-readable progress line from the background installer's + * stderr, as recorded on the active install record. + */ +export interface UpdateInstallProgress { + readonly state: 'downloading' | 'waiting' | 'done' | 'failed'; + /** Integer 0..100; absent while the download size is unknown. */ + readonly percent?: number; + readonly transferred?: number; + readonly total?: number; + readonly updatedAt: string; +} + export interface UpdateInstallActive { readonly version: string; readonly source: InstallSource; @@ -51,6 +80,11 @@ export interface UpdateInstallActive { readonly pid?: number; readonly operation?: UpdateInstallOperation; readonly jobId?: string; + /** + * Latest progress line from the installer; absent until the installer + * emits one or in records persisted by older versions. + */ + readonly progress?: UpdateInstallProgress; } export interface UpdatePreparedHomebrew { diff --git a/apps/pythinker-code/src/constant/app.ts b/apps/pythinker-code/src/constant/app.ts index f6af29a2..265516c8 100644 --- a/apps/pythinker-code/src/constant/app.ts +++ b/apps/pythinker-code/src/constant/app.ts @@ -51,10 +51,10 @@ export const FEEDBACK_TELEMETRY_EVENT = 'feedback_submitted'; // CDN source of truth: all version checks and native install scripts pull from here. export const PYTHINKER_CODE_CDN_BASE = 'https://code.pythinker.com/pythinker-code'; -export const PYTHINKER_CODE_CDN_LATEST_URL = `${PYTHINKER_CODE_CDN_BASE}/latest`; -// Rollout manifest consumed by update checks; the plain-text `/latest` above -// stays unchanged forever — already-shipped clients hard-fail on non-semver -// bodies, and the CDN install scripts read it for fresh installs. +// The only update source this client reads. The plain-text `/latest` endpoint +// still exists on the CDN for install.sh and for clients shipped before the +// manifest, but it carries no per-platform artifact data, so reading it here +// would report an unverifiable target as verified. export const PYTHINKER_CODE_CDN_LATEST_JSON_URL = `${PYTHINKER_CODE_CDN_BASE}/latest.json`; export const PYTHINKER_CODE_TIPS_BANNER_URL = 'https://cdn.pythinker.com/pythinker-code-tips/tips.json'; export const PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL = `${PYTHINKER_CODE_CDN_BASE}/plugins/marketplace.json`; diff --git a/apps/pythinker-code/src/tui/commands/info.ts b/apps/pythinker-code/src/tui/commands/info.ts index 8db3ffd6..08859eef 100644 --- a/apps/pythinker-code/src/tui/commands/info.ts +++ b/apps/pythinker-code/src/tui/commands/info.ts @@ -237,6 +237,21 @@ export async function handleHooksCommand( ); } +/** The installer's recorded stderr tail can be ~2 KB; show one line of it. */ +const UPDATE_FAILURE_REASON_MAX_CHARS = 160; + +/** + * Collapse the recorded failure reason onto one line; a long tail is cut to + * a single sensible line and marked as truncated instead of flooding the + * transcript with the installer's full stderr. + */ +function renderUpdateFailureReason(message: string): string | undefined { + const singleLine = message.replaceAll(/\s+/gu, ' ').trim(); + if (singleLine.length === 0) return undefined; + if (singleLine.length <= UPDATE_FAILURE_REASON_MAX_CHARS) return singleLine; + return `${singleLine.slice(0, UPDATE_FAILURE_REASON_MAX_CHARS)}… (truncated)`; +} + export async function handleUpdateCommand( host: SlashCommandHost, args: string, @@ -261,8 +276,18 @@ export async function handleUpdateCommand( ); return; case 'in-progress': + // The target is present only when it is newer than the version the + // running install is working on; everything else keeps the old wording. + if (result.targetVersion !== undefined) { + host.showNotice( + `Installing v${result.installingVersion} — v${result.targetVersion} will follow`, + `The running install of v${result.installingVersion} finishes first; ` + + `v${result.targetVersion} installs after the next start.`, + ); + return; + } host.showNotice( - `Update to v${result.version} already in progress`, + `Update to v${result.installingVersion} already in progress`, result.installOnRestart ? result.readyToInstall ? 'Close this terminal and open a new one to install it.' @@ -276,6 +301,16 @@ export async function handleUpdateCommand( `Run: ${result.command}`, ); return; + case 'failed': { + const reason = + result.message === undefined ? undefined : renderUpdateFailureReason(result.message); + host.showError( + `Update to v${result.version} failed after ${result.attempts} attempts.` + + (reason === undefined ? '' : `\nReason: ${reason}`) + + `\nTo update manually, run: ${result.command}`, + ); + return; + } case 'check-failed': host.showError(`Update check failed: ${result.message}`); return; diff --git a/apps/pythinker-code/src/tui/components/chrome/welcome-banner.ts b/apps/pythinker-code/src/tui/components/chrome/welcome-banner.ts index 949ed93a..cdf89a07 100644 --- a/apps/pythinker-code/src/tui/components/chrome/welcome-banner.ts +++ b/apps/pythinker-code/src/tui/components/chrome/welcome-banner.ts @@ -1,15 +1,11 @@ /** * Welcome banner layout — mirrors the Python shell `_print_welcome_info` design: - * panel title + subtitle chip, robot mark, facts grid, and optional tips column. + * panel title, robot mark, facts grid, and optional tips column. */ -import { readFileSync } from 'node:fs'; - import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; -import { gt, valid } from 'semver'; import chalk from 'chalk'; -import { getUpdateStateFile } from '#/utils/paths'; import { createGitStatusCache, type GitStatusCache } from '#/utils/git/git-status'; import { currentTheme } from '#/tui/theme'; import type { AppState } from '#/tui/types'; @@ -68,7 +64,6 @@ export interface RenderWelcomeBannerOptions { readonly version: string; readonly infoItems: readonly WelcomeInfoItem[]; readonly copy: WelcomeBannerCopy; - readonly subtitleChip?: string | null; readonly logoLines?: readonly string[]; readonly asciiMode?: boolean; } @@ -293,26 +288,12 @@ function renderTwoColumns( return rows; } -function renderPanelTopBorder(title: string, subtitle: string | null, width: number): string { +function renderPanelTopBorder(title: string, width: number): string { const inner = Math.max(0, width - 2); const titlePart = ` ${title} `; const titleWidth = visibleWidth(titlePart); - - if (!subtitle) { - const dashCount = Math.max(0, inner - titleWidth); - return borderPaint('╭') + titlePart + borderPaint('─'.repeat(dashCount)) + borderPaint('╮'); - } - - const subtitlePart = ` ${subtitle} `; - const subtitleWidth = visibleWidth(subtitlePart); - const dashCount = Math.max(1, inner - titleWidth - subtitleWidth); - return ( - borderPaint('╭') + - titlePart + - borderPaint('─'.repeat(dashCount)) + - subtitlePart + - borderPaint('╮') - ); + const dashCount = Math.max(0, inner - titleWidth); + return borderPaint('╭') + titlePart + borderPaint('─'.repeat(dashCount)) + borderPaint('╮'); } export function buildWelcomeCopy(isLoggedOut: boolean): WelcomeBannerCopy { @@ -366,38 +347,6 @@ export function buildWelcomeTips(): WelcomeInfoItem[] { return WELCOME_TIPS.map((value) => ({ name: 'Tip', value })); } -export function readWelcomeUpdateTarget(currentVersion: string): string | null { - try { - const raw = readFileSync(getUpdateStateFile(), 'utf-8'); - const parsed = JSON.parse(raw) as { latest?: string | null }; - const latest = parsed.latest; - if ( - typeof latest === 'string' && - valid(latest) && - valid(currentVersion) && - gt(latest, currentVersion) - ) { - return latest; - } - } catch { - // Missing or malformed cache — no chip. - } - return null; -} - -export function buildWelcomeSubtitleChip(version: string): string | null { - const updateTarget = readWelcomeUpdateTarget(version); - const palette = currentTheme.palette; - if (updateTarget) { - return paintWithSlashAccent( - `↑ Update available — v${updateTarget} · /update`, - palette.warning, - PYTHINKER_LOGO_COLORS.accent, - ); - } - return null; -} - export function renderWelcomeBanner(options: RenderWelcomeBannerOptions): string[] { const safeWidth = Math.max(0, options.width); if (safeWidth < 24) { @@ -490,11 +439,10 @@ export function renderWelcomeBanner(options: RenderWelcomeBannerOptions): string const versionTitle = chalk.hex(currentTheme.palette.textMuted)('Pythinker Code') + chalk.hex(currentTheme.palette.textDim)(` v${options.version}`); - const subtitle = options.subtitleChip ?? null; const lines: string[] = [ '', - renderPanelTopBorder(versionTitle, subtitle, panelWidth), + renderPanelTopBorder(versionTitle, panelWidth), borderPaint('│') + ' '.repeat(panelWidth - 2) + borderPaint('│'), ]; diff --git a/apps/pythinker-code/src/tui/components/chrome/welcome.ts b/apps/pythinker-code/src/tui/components/chrome/welcome.ts index 9d145d38..f3a40da9 100644 --- a/apps/pythinker-code/src/tui/components/chrome/welcome.ts +++ b/apps/pythinker-code/src/tui/components/chrome/welcome.ts @@ -27,7 +27,6 @@ import { asciiGlyphsEnabled, buildWelcomeCopy, buildWelcomeInfoItems, - buildWelcomeSubtitleChip, createWelcomeGitCache, renderWelcomeBanner, } from './welcome-banner'; @@ -81,7 +80,6 @@ export class WelcomeComponent implements Component, WelcomeLogoAnimationHost { version: this.state.version, infoItems: buildWelcomeInfoItems(this.state, this.gitCache), copy, - subtitleChip: buildWelcomeSubtitleChip(this.state.version), logoLines, asciiMode: asciiGlyphsEnabled(), }); diff --git a/apps/pythinker-code/src/tui/pythinker-tui.ts b/apps/pythinker-code/src/tui/pythinker-tui.ts index c2c0a5e7..00e4dadc 100644 --- a/apps/pythinker-code/src/tui/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/pythinker-tui.ts @@ -22,6 +22,10 @@ import type { MigrationPlan } from '@pythoughts/migration-legacy'; import { resolve } from 'pathe'; import type { CLIOptions } from '#/cli/options'; +import { readUpdateCache } from '#/cli/update/cache'; +import { readUpdateInstallState } from '#/cli/update/install-state'; +import { detectInstallSource } from '#/cli/update/source'; +import type { InstallSource } from '#/cli/update/types'; import { MigrationScreenComponent, type MigrationScreenResult } from '#/migration/index'; import { effortColorToken } from '#/tui/utils/thinking-levels'; import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; @@ -138,7 +142,9 @@ import { type FooterActivity, type FooterEvent, type FooterGoal, + type FooterUpdate, } from './runtime/footer/footer-model'; +import { footerUpdateFromState } from './runtime/footer/update-status'; import { LegacyPiPresentation } from './runtime/legacy-pi-presentation'; import { currentTheme, getColorPalette, getBuiltInPalette, isBuiltInTheme } from './theme'; import type { ColorToken, ResolvedTheme, ThemeName } from './theme'; @@ -191,6 +197,13 @@ export interface PythinkerTUIStartupInput { type EffectiveActivityPaneMode = ActivityPaneMode | 'idle' | 'session'; +/** Poll cadence for the update cache and install state files. */ +const UPDATE_STATUS_POLL_INTERVAL_MS = 2_000; + +function footerUpdateEquals(a: FooterUpdate, b: FooterUpdate): boolean { + return a.version === b.version && a.state === b.state && a.percent === b.percent; +} + function createInitialAppState(input: PythinkerTUIStartupInput): AppState { const startupPermission: PermissionMode = input.cliOptions.auto ? 'auto' @@ -292,6 +305,13 @@ export class PythinkerTUI { } | undefined; private stopKeybindingsWatcher: (() => void) | undefined; + private updateStatusSource: InstallSource | null = null; + private updateStatusTimer: ReturnType | undefined; + private lastDispatchedUpdate: FooterUpdate = { + version: null, + state: null, + percent: null, + }; public onExit?: (exitCode?: number) => Promise; @@ -601,6 +621,57 @@ export class PythinkerTUI { }); } + // Update availability + install progress poll. The install source is + // resolved once — it cannot change mid-session and detecting it repeatedly + // costs a subprocess. Both state files are read off the render path, and a + // quiet session repaints only when the computed update changes. + private startUpdateStatusPolling(): void { + void (async () => { + let source: InstallSource = 'unsupported'; + try { + source = await detectInstallSource(); + } catch { + // Detection failure means the update flow treats this install as unsupported. + } + this.updateStatusSource = source; + if (this.isShuttingDown) return; + await this.pollUpdateStatus(); + if (this.isShuttingDown) return; + this.updateStatusTimer = setInterval(() => { + void this.pollUpdateStatus(); + }, UPDATE_STATUS_POLL_INTERVAL_MS); + // A cosmetic poll must never be the reason the process refuses to exit. + this.updateStatusTimer.unref(); + })(); + } + + private async pollUpdateStatus(): Promise { + if (this.isShuttingDown || this.updateStatusSource === null) return; + try { + const [cache, installState] = await Promise.all([ + readUpdateCache(), + readUpdateInstallState(), + ]); + const update = footerUpdateFromState( + this.state.appState.version, + this.updateStatusSource, + cache, + installState, + ); + if (footerUpdateEquals(this.lastDispatchedUpdate, update)) return; + this.lastDispatchedUpdate = update; + this.dispatchFooter({ type: 'update.updated', update }); + } catch { + // An unreadable update state file means "no update to show", never a crash. + } + } + + private stopUpdateStatusPolling(): void { + if (this.updateStatusTimer === undefined) return; + clearInterval(this.updateStatusTimer); + this.updateStatusTimer = undefined; + } + private async refreshProviderModelsInBackground(): Promise { try { const result = await this.authFlow.refreshProviderModels(); @@ -617,6 +688,7 @@ export class PythinkerTUI { } private async finishStartup(shouldReplayHistory: boolean): Promise { + this.startUpdateStatusPolling(); if (this.startupNotice !== undefined) { this.showStatus(this.startupNotice); this.startupNotice = undefined; @@ -763,6 +835,7 @@ export class PythinkerTUI { this.editorKeyboard.clearPendingExit(); this.stopKeybindingsWatcher?.(); this.stopKeybindingsWatcher = undefined; + this.stopUpdateStatusPolling(); for (const dispose of this.reverseRpcDisposers) { dispose(); } diff --git a/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts b/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts index 3db8b5e7..802535ee 100644 --- a/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts +++ b/apps/pythinker-code/src/tui/runtime/footer/footer-model.ts @@ -78,10 +78,19 @@ export interface FooterCompaction { readonly label: string | null; } -export interface FooterTerminalProgress { - readonly active: boolean; +export type FooterUpdateState = + | 'available' + | 'required' + | 'downloading' + | 'waiting' + | 'ready' + | 'failed'; + +export interface FooterUpdate { + readonly version: string | null; + readonly state: FooterUpdateState | null; + /** Null means indeterminate — render without a bar rather than inventing a percentage. */ readonly percent: number | null; - readonly label: string | null; } export type FooterBtwPhase = 'closed' | 'running' | 'done' | 'failed'; @@ -146,7 +155,7 @@ export interface FooterState { readonly subagents: FooterSubagentCounts; readonly compaction: FooterCompaction; readonly transientHint: string | null; - readonly terminalProgress: FooterTerminalProgress; + readonly update: FooterUpdate; readonly btw: FooterBtwState; readonly status: FooterStatus; readonly composer: FooterComposerState; @@ -174,10 +183,7 @@ export type FooterEvent = readonly compaction: FooterCompaction; } | { readonly type: 'transient-hint.updated'; readonly hint: string | null } - | { - readonly type: 'terminal-progress.updated'; - readonly progress: FooterTerminalProgress; - } + | { readonly type: 'update.updated'; readonly update: FooterUpdate } | { readonly type: 'btw.updated'; readonly btw: FooterBtwState } | { readonly type: 'status.updated'; readonly changes: Partial } | { @@ -272,7 +278,7 @@ export function createFooterState( subagents: { active: 0, queued: 0, completed: 0, failed: 0 }, compaction: { active: false, label: null }, transientHint: null, - terminalProgress: { active: false, percent: null, label: null }, + update: { version: null, state: null, percent: null }, btw: { phase: 'closed', turnCount: 0 }, status: { ...DEFAULT_STATUS, ...status }, composer: { textLength: 0, placeholder: 'Composer' }, @@ -304,8 +310,8 @@ export function reduceFooterState( return freezeState({ ...state, compaction: event.compaction }); case 'transient-hint.updated': return freezeState({ ...state, transientHint: event.hint }); - case 'terminal-progress.updated': - return freezeState({ ...state, terminalProgress: event.progress }); + case 'update.updated': + return freezeState({ ...state, update: event.update }); case 'btw.updated': return freezeState({ ...state, btw: event.btw }); case 'status.updated': @@ -416,12 +422,6 @@ function selectActivityRow(state: FooterState): FooterActivityRowViewModel { state.activity.label?.trim() || defaultActivityLabel(state.activity.phase); spinnerActive = state.activity.spinnerActive; - } else if ( - state.terminalProgress.active && - state.terminalProgress.label !== null - ) { - primary = state.terminalProgress.label.trim(); - spinnerActive = true; } if (spinnerActive && primary.length > 0) { @@ -430,8 +430,6 @@ function selectActivityRow(state: FooterState): FooterActivityRowViewModel { } const indicators: string[] = []; - const progress = formatTerminalProgress(state.terminalProgress); - if (progress !== null) indicators.push(progress); if (state.queue.count > 0) { indicators.push( `[${String(nonNegativeInteger(state.queue.count))} queued]`, @@ -472,6 +470,8 @@ function selectStatusItems( statusLine: StatusLineConfig, ): string[] { const items: string[] = []; + const update = formatUpdate(state.update); + if (update !== null) items.push(update); const model = normalizeSingleLine(state.status.model); if (statusLine.showModel && model.length > 0) { const effortSuffix = @@ -608,19 +608,37 @@ function formatSessionSpend(spend: number | undefined): string | null { : `$${spend.toFixed(6).replace(/\.?0+$/, '')}`; } -function formatTerminalProgress( - progress: FooterTerminalProgress, -): string | null { - if (!progress.active) return null; - const parts = ['progress']; - if (progress.percent !== null && Number.isFinite(progress.percent)) { - parts.push( - `${String(Math.round(Math.min(100, Math.max(0, progress.percent))))}%`, - ); +/** `↑ v0.11.0` — or `↓` while the download is in flight. */ +function formatUpdate(update: FooterUpdate): string | null { + const version = update.version; + const state = update.state; + if (version === null || state === null) return null; + const base = `${state === 'available' || state === 'required' || state === 'ready' || state === 'failed' ? '↑' : '↓'} v${version}`; + switch (state) { + case 'available': + return base; + case 'required': + return `${base} required`; + case 'downloading': { + const percent = update.percent; + if (percent === null || !Number.isFinite(percent)) return base; + const rounded = Math.round(Math.min(100, Math.max(0, percent))); + const filled = Math.min( + CONTEXT_BAR_CELLS, + Math.round((rounded / 100) * CONTEXT_BAR_CELLS), + ); + const bar = + CONTEXT_BAR_FILLED.repeat(filled) + + CONTEXT_BAR_EMPTY.repeat(CONTEXT_BAR_CELLS - filled); + return `${base} ${bar} ${String(rounded)}%`; + } + case 'waiting': + return `${base} waiting`; + case 'ready': + return `${base} restart to apply`; + case 'failed': + return `${base} failed`; } - const label = normalizeSingleLine(progress.label ?? ''); - if (label.length > 0) parts.push(label); - return `[${parts.join(' ')}]`; } /** @@ -722,7 +740,7 @@ function freezeState(state: FooterState): FooterState { failed: nonNegativeInteger(state.subagents.failed), }), compaction: Object.freeze({ ...state.compaction }), - terminalProgress: Object.freeze({ ...state.terminalProgress }), + update: Object.freeze({ ...state.update }), btw: Object.freeze({ ...state.btw, turnCount: nonNegativeInteger(state.btw.turnCount), diff --git a/apps/pythinker-code/src/tui/runtime/footer/update-status.ts b/apps/pythinker-code/src/tui/runtime/footer/update-status.ts new file mode 100644 index 00000000..6ad8a491 --- /dev/null +++ b/apps/pythinker-code/src/tui/runtime/footer/update-status.ts @@ -0,0 +1,76 @@ +import { gt, valid } from 'semver'; + +import { isBelowMinRequiredVersion } from '#/cli/update/cdn'; +import { isTargetInstallable, selectUpdateTarget } from '#/cli/update/select'; +import type { + InstallSource, + UpdateCache, + UpdateInstallState, +} from '#/cli/update/types'; +import type { FooterUpdate } from './footer-model'; + +/** + * Map the persisted update state onto the footer update slice. + * + * Precedence, highest first: + * 1. an active install for a newer version that is downloading or waiting + * 2. a recorded success for a newer version (needs a restart to apply) + * 3. a recorded failure for a newer version + * 4. an installable target advertised by the update cache + * 5. nothing + * + * Pure: no file reads, no clock, no `process.*` — everything comes in as + * arguments so the poller stays off the render path. + */ +export function footerUpdateFromState( + currentVersion: string, + source: InstallSource, + cache: UpdateCache | null, + installState: UpdateInstallState, +): FooterUpdate { + const active = installState.active; + const progress = active?.progress; + if ( + active !== null && + progress !== undefined && + isNewer(active.version, currentVersion) && + (progress.state === 'downloading' || progress.state === 'waiting') + ) { + return { + version: active.version, + state: progress.state, + percent: progress.percent ?? null, + }; + } + + const success = installState.lastSuccess; + if (success !== null && isNewer(success.version, currentVersion)) { + return { version: success.version, state: 'ready', percent: null }; + } + + const failure = installState.lastFailure; + if (failure !== null && isNewer(failure.version, currentVersion)) { + return { version: failure.version, state: 'failed', percent: null }; + } + + const target = selectUpdateTarget(currentVersion, cache?.latest ?? null); + if (target !== null && isTargetInstallable(source, cache?.manifest ?? null)) { + // A manifest floor above the running version labels the offer as + // required; the precedence order above still lets a download in flight + // outrank it. + return { + version: target.version, + state: isBelowMinRequiredVersion(cache?.manifest ?? null, currentVersion) + ? 'required' + : 'available', + percent: null, + }; + } + + return { version: null, state: null, percent: null }; +} + +function isNewer(version: string, currentVersion: string): boolean { + if (valid(version) === null || valid(currentVersion) === null) return false; + return gt(version, currentVersion); +} diff --git a/apps/pythinker-code/test/cli/update/cdn.test.ts b/apps/pythinker-code/test/cli/update/cdn.test.ts index fe9237d4..5bbaefe4 100644 --- a/apps/pythinker-code/test/cli/update/cdn.test.ts +++ b/apps/pythinker-code/test/cli/update/cdn.test.ts @@ -1,23 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; -import { fetchLatestFromCdn, fetchLatestVersionFromCdn } from '#/cli/update/cdn'; -import { PYTHINKER_CODE_CDN_LATEST_JSON_URL, PYTHINKER_CODE_CDN_LATEST_URL } from '#/constant/app'; - -function mockFetchOk(body: string): typeof fetch { - return vi.fn(async () => ({ - ok: true, - status: 200, - text: async () => body, - })) as unknown as typeof fetch; -} - -function mockFetchStatus(status: number): typeof fetch { - return vi.fn(async () => ({ - ok: status >= 200 && status < 300, - status, - text: async () => '', - })) as unknown as typeof fetch; -} +import { fetchUpdateManifest, manifestArtifactAvailability } from '#/cli/update/cdn'; +import { PYTHINKER_CODE_CDN_LATEST_JSON_URL } from '#/constant/app'; type Route = { readonly status?: number; readonly body?: string } | Error; @@ -49,52 +33,17 @@ const MANIFEST_BODY = JSON.stringify({ ], }); -describe('fetchLatestVersionFromCdn', () => { - it('returns the trimmed semver returned by CDN /latest', async () => { - const f = mockFetchOk(' 0.5.0\n'); - await expect(fetchLatestVersionFromCdn(f)).resolves.toBe('0.5.0'); - expect(f).toHaveBeenCalledWith( - PYTHINKER_CODE_CDN_LATEST_URL, - expect.objectContaining({ signal: expect.any(AbortSignal) }), - ); - }); - - it('throws when response is non-2xx', async () => { - await expect(fetchLatestVersionFromCdn(mockFetchStatus(404))).rejects.toThrow(/HTTP 404/); - }); - - it('throws when body is not valid semver', async () => { - await expect(fetchLatestVersionFromCdn(mockFetchOk('not-a-version'))).rejects.toThrow( - /invalid semver/, - ); - }); - - it('throws when body is empty', async () => { - await expect(fetchLatestVersionFromCdn(mockFetchOk(' '))).rejects.toThrow(/invalid semver/); - }); - - it('propagates the underlying fetch error', async () => { - const f = vi.fn(async () => { - throw new Error('network down'); - }) as unknown as typeof fetch; - await expect(fetchLatestVersionFromCdn(f)).rejects.toThrow(/network down/); - }); -}); - -describe('fetchLatestFromCdn', () => { +describe('fetchUpdateManifest', () => { it('parses latest.json and returns the manifest', async () => { const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body: MANIFEST_BODY } }); - await expect(fetchLatestFromCdn(f)).resolves.toEqual({ - latest: '2.0.0', - manifest: { - version: '2.0.0', - publishedAt: '2026-06-12T00:00:00.000Z', - rollout: [ - { percent: 30, delaySeconds: 0 }, - { percent: 30, delaySeconds: 43_200 }, - { percent: 40, delaySeconds: 86_400 }, - ], - }, + await expect(fetchUpdateManifest(f)).resolves.toEqual({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [ + { percent: 30, delaySeconds: 0 }, + { percent: 30, delaySeconds: 43_200 }, + { percent: 40, delaySeconds: 86_400 }, + ], }); expect(f).toHaveBeenCalledWith( PYTHINKER_CODE_CDN_LATEST_JSON_URL, @@ -112,8 +61,8 @@ describe('fetchLatestFromCdn', () => { futureField: { nested: true }, }); const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body } }); - const result = await fetchLatestFromCdn(f); - expect(result.manifest).toEqual({ + const result = await fetchUpdateManifest(f); + expect(result).toEqual({ version: '2.0.0', publishedAt: '2026-06-12T00:00:00.000Z', rollout: [], @@ -126,91 +75,151 @@ describe('fetchLatestFromCdn', () => { publishedAt: '2026-06-12T00:00:00.000Z', }); const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body } }); - const result = await fetchLatestFromCdn(f); - expect(result.manifest?.rollout).toEqual([]); + const result = await fetchUpdateManifest(f); + expect(result.rollout).toEqual([]); }); - const fallbackCases: ReadonlyArray = [ - ['latest.json is missing (HTTP 404)', { status: 404 }], - ['latest.json fetch throws', new Error('network down')], - ['body is not valid JSON', { body: 'not json {' }], - ['version is not semver', { body: JSON.stringify({ version: 'nope', publishedAt: '2026-06-12T00:00:00.000Z' }) }], - ['publishedAt is unparseable', { body: JSON.stringify({ version: '2.0.0', publishedAt: 'garbage' }) }], - ['a batch percent is out of range', { - body: JSON.stringify({ - version: '2.0.0', - publishedAt: '2026-06-12T00:00:00.000Z', - rollout: [{ percent: 150, delaySeconds: 0 }], - }), - }], - ['a batch delay is negative', { - body: JSON.stringify({ - version: '2.0.0', - publishedAt: '2026-06-12T00:00:00.000Z', - rollout: [{ percent: 100, delaySeconds: -1 }], - }), - }], - ]; + it('drops a platforms entry with an invalid sha256 but keeps the manifest', async () => { + const body = JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + platforms: { + 'darwin-arm64': { + url: 'https://github.com/Pythoughts-labs/pythinker-code/releases/download/%40pythoughts%2Fpythinker-code%400.9.2/pythinker-code-darwin-arm64.zip', + sha256: 'nope', + }, + }, + }); + const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body } }); + const result = await fetchUpdateManifest(f); + expect(result.version).toBe('2.0.0'); + expect(result.platforms).toBeUndefined(); + expect(manifestArtifactAvailability(result, 'darwin-arm64')).toBe('available'); + }); - for (const [name, route] of fallbackCases) { - it(`falls back to plain /latest when ${name}`, async () => { - const f = mockRoutedFetch({ - [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: route, - [PYTHINKER_CODE_CDN_LATEST_URL]: { body: '1.9.0\n' }, - }); - await expect(fetchLatestFromCdn(f)).resolves.toEqual({ - latest: '1.9.0', - manifest: null, - }); + it('drops a platforms entry with a non-URL url but keeps the manifest', async () => { + const body = JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + platforms: { + 'darwin-arm64': { url: 'not-a-url', sha256: 'a'.repeat(64) }, + }, }); - } + const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body } }); + const result = await fetchUpdateManifest(f); + expect(result.version).toBe('2.0.0'); + expect(result.platforms).toBeUndefined(); + expect(manifestArtifactAvailability(result, 'darwin-arm64')).toBe('available'); + }); - it('throws when both latest.json and plain /latest fail', async () => { - const f = mockRoutedFetch({ - [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { status: 500 }, - [PYTHINKER_CODE_CDN_LATEST_URL]: { status: 500 }, + it('carries a well-formed minRequiredVersion onto the parsed manifest', async () => { + const body = JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [], + minRequiredVersion: '1.5.0', }); - await expect(fetchLatestFromCdn(f)).rejects.toThrow(/HTTP 500/); + const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body } }); + const result = await fetchUpdateManifest(f); + expect(result.version).toBe('2.0.0'); + expect(result.minRequiredVersion).toBe('1.5.0'); }); - it('propagates the plain /latest error when the fallback also breaks', async () => { - const f = mockRoutedFetch({ - [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: new Error('json down'), - [PYTHINKER_CODE_CDN_LATEST_URL]: { body: 'not-a-version' }, + it('drops a malformed minRequiredVersion but keeps the manifest', async () => { + const body = JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [], + minRequiredVersion: 'nope', }); - await expect(fetchLatestFromCdn(f)).rejects.toThrow(/invalid semver/); + const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body } }); + const result = await fetchUpdateManifest(f); + expect(result.version).toBe('2.0.0'); + expect(result.minRequiredVersion).toBeUndefined(); }); - it('falls back to plain /latest when latest.json hangs past the request timeout', async () => { - vi.useFakeTimers(); - try { - const f = vi.fn(async (input: string | URL, init?: RequestInit) => { - if (String(input) === PYTHINKER_CODE_CDN_LATEST_JSON_URL) { - return new Promise((_resolve, reject) => { - init?.signal?.addEventListener('abort', () => { - reject(new Error('aborted')); - }, { once: true }); - }); - } - if (String(input) === PYTHINKER_CODE_CDN_LATEST_URL) { - return { ok: true, status: 200, text: async () => '1.9.0\n' }; - } - return { ok: false, status: 404, text: async () => '' }; - }) as unknown as typeof fetch; + it('carries a well-formed platforms record onto the parsed manifest', async () => { + const body = JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [], + platforms: { + 'darwin-arm64': { + url: 'https://github.com/Pythoughts-labs/pythinker-code/releases/download/%40pythoughts%2Fpythinker-code%400.9.2/pythinker-code-darwin-arm64.zip', + sha256: 'a'.repeat(64), + }, + 'linux-x64': { + url: 'https://github.com/Pythoughts-labs/pythinker-code/releases/download/%40pythoughts%2Fpythinker-code%400.9.2/pythinker-code-linux-x64.zip', + sha256: 'b'.repeat(64), + }, + }, + }); + const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: { body } }); + const result = await fetchUpdateManifest(f); + expect(result.version).toBe('2.0.0'); + expect(result.platforms).toEqual({ + 'darwin-arm64': { + url: 'https://github.com/Pythoughts-labs/pythinker-code/releases/download/%40pythoughts%2Fpythinker-code%400.9.2/pythinker-code-darwin-arm64.zip', + sha256: 'a'.repeat(64), + }, + 'linux-x64': { + url: 'https://github.com/Pythoughts-labs/pythinker-code/releases/download/%40pythoughts%2Fpythinker-code%400.9.2/pythinker-code-linux-x64.zip', + sha256: 'b'.repeat(64), + }, + }); + }); - const result = fetchLatestFromCdn(f); - await vi.advanceTimersByTimeAsync(3_000); + // No fallback: the plain-text `/latest` carries no per-platform artifact data, + // so reading it after a bad manifest would report an unverifiable target as + // verified. Every one of these must reject and leave the cached answer alone. + const rejectCases: ReadonlyArray = [ + ['latest.json is missing (HTTP 404)', { status: 404 }, /HTTP 404/u], + ['latest.json fetch throws', new Error('network down'), /network down/u], + ['body is not valid JSON', { body: 'not json {' }, /JSON/iu], + [ + 'version is not semver', + { body: JSON.stringify({ version: 'nope', publishedAt: '2026-06-12T00:00:00.000Z' }) }, + /invalid semver/u, + ], + [ + 'publishedAt is unparseable', + { body: JSON.stringify({ version: '2.0.0', publishedAt: 'garbage' }) }, + /invalid timestamp/u, + ], + [ + 'a batch percent is out of range', + { + body: JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [{ percent: 150, delaySeconds: 0 }], + }), + }, + // Name the field: `/./` matched any non-empty message, so a JSON.parse + // failure would have satisfied it just as well as the schema rejection. + /percent/u, + ], + [ + 'a batch delay is negative', + { + body: JSON.stringify({ + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [{ percent: 100, delaySeconds: -1 }], + }), + }, + /delaySeconds/u, + ], + ]; - await expect(result).resolves.toEqual({ - latest: '1.9.0', - manifest: null, - }); - } finally { - vi.useRealTimers(); - } - }); + for (const [name, route, message] of rejectCases) { + it(`rejects when ${name}`, async () => { + const f = mockRoutedFetch({ [PYTHINKER_CODE_CDN_LATEST_JSON_URL]: route }); + await expect(fetchUpdateManifest(f)).rejects.toThrow(message); + }); + } - it('rejects when plain /latest also hangs past the request timeout', async () => { + it('rejects when latest.json hangs past the request timeout', async () => { vi.useFakeTimers(); try { const f = vi.fn(async (_input: string | URL, init?: RequestInit) => { @@ -221,9 +230,9 @@ describe('fetchLatestFromCdn', () => { }); }) as unknown as typeof fetch; - const result = fetchLatestFromCdn(f); - const expectation = expect(result).rejects.toThrow(/aborted/); - await vi.advanceTimersByTimeAsync(6_000); + const result = fetchUpdateManifest(f); + const expectation = expect(result).rejects.toThrow(/aborted/u); + await vi.advanceTimersByTimeAsync(3_000); await expectation; } finally { @@ -231,3 +240,73 @@ describe('fetchLatestFromCdn', () => { } }); }); + +describe('manifestArtifactAvailability', () => { + it('treats a null manifest as available (unknown is not a denial)', () => { + expect(manifestArtifactAvailability(null)).toBe('available'); + }); + + it('treats a manifest without platforms as available', () => { + const manifest = { + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [], + }; + expect(manifestArtifactAvailability(manifest, 'darwin-arm64')).toBe('available'); + }); + + it('is available when platforms has an own entry for the target', () => { + const manifest = { + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [], + platforms: { + 'darwin-arm64': { + url: 'https://github.com/Pythoughts-labs/pythinker-code/releases/download/%40pythoughts%2Fpythinker-code%400.9.2/pythinker-code-darwin-arm64.zip', + sha256: 'a'.repeat(64), + }, + }, + }; + expect(manifestArtifactAvailability(manifest, 'darwin-arm64')).toBe('available'); + }); + + it('is unavailable when platforms omits the target', () => { + const manifest = { + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [], + platforms: { + 'darwin-arm64': { + url: 'https://github.com/Pythoughts-labs/pythinker-code/releases/download/%40pythoughts%2Fpythinker-code%400.9.2/pythinker-code-darwin-arm64.zip', + sha256: 'a'.repeat(64), + }, + }, + }; + expect(manifestArtifactAvailability(manifest, 'linux-x64')).toBe('unavailable'); + }); + + it('is unavailable for an empty platforms object', () => { + const manifest = { + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [], + platforms: {}, + }; + expect(manifestArtifactAvailability(manifest, 'darwin-arm64')).toBe('unavailable'); + }); + + it('defaults the target to the running platform', () => { + const manifest = { + version: '2.0.0', + publishedAt: '2026-06-12T00:00:00.000Z', + rollout: [], + platforms: { + [`${process.platform}-${process.arch}`]: { + url: 'https://github.com/Pythoughts-labs/pythinker-code/releases/download/%40pythoughts%2Fpythinker-code%400.9.2/pythinker-code-darwin-arm64.zip', + sha256: 'a'.repeat(64), + }, + }, + }; + expect(manifestArtifactAvailability(manifest)).toBe('available'); + }); +}); diff --git a/apps/pythinker-code/test/cli/update/install-lock.test.ts b/apps/pythinker-code/test/cli/update/install-lock.test.ts index b597936d..aef85b1f 100644 --- a/apps/pythinker-code/test/cli/update/install-lock.test.ts +++ b/apps/pythinker-code/test/cli/update/install-lock.test.ts @@ -44,12 +44,91 @@ describe('update install lock', () => { await third?.release(); }); - it('does not reclaim an old lock while its owner process is alive', async () => { + it('does not reclaim a lock with a live pid just under the 6-hour pid ceiling', async () => { writeLock({ version: '0.5.0', ownerId: 'live-owner', pid: process.pid, - startedAt: '2026-01-01T00:00:00.000Z', + startedAt: '2026-08-03T00:00:00.000Z', + }); + + await expect(tryAcquireUpdateInstallLock({ + version: '0.5.0', + now: new Date('2026-08-03T05:59:00.000Z'), + })).resolves.toBeNull(); + }); + + it('reclaims a lock with a live pid just over the 6-hour pid ceiling', async () => { + writeLock({ + version: '0.5.0', + ownerId: 'live-owner', + pid: process.pid, + startedAt: '2026-08-03T00:00:00.000Z', + }); + + const lock = await tryAcquireUpdateInstallLock({ + version: '0.5.0', + now: new Date('2026-08-03T06:01:00.000Z'), + }); + + expect(lock).not.toBeNull(); + await lock?.release(); + }); + + it('reclaims a lock with a live pid and no startedAt', async () => { + writeLock({ + version: '0.5.0', + ownerId: 'untimestamped-owner', + pid: process.pid, + }); + + const lock = await tryAcquireUpdateInstallLock({ version: '0.5.0' }); + + expect(lock).not.toBeNull(); + await lock?.release(); + }); + + it('expires a pid-less lock at 30 minutes, not the 6-hour pid ceiling', async () => { + writeLock({ + version: '0.5.0', + ownerId: 'legacy-owner', + startedAt: '2026-08-03T00:00:00.000Z', + }); + + // 29 minutes: still honoured. + await expect(tryAcquireUpdateInstallLock({ + version: '0.5.0', + now: new Date('2026-08-03T00:29:00.000Z'), + })).resolves.toBeNull(); + + // 31 minutes: stale, well before the pid ceiling. + const lock = await tryAcquireUpdateInstallLock({ + version: '0.5.0', + now: new Date('2026-08-03T00:31:00.000Z'), + }); + expect(lock).not.toBeNull(); + await lock?.release(); + }); + + it('honours a lock 2 minutes in the future while its owner process is alive', async () => { + writeLock({ + version: '0.5.0', + ownerId: 'skewed-owner', + pid: process.pid, + startedAt: '2026-08-03T00:02:00.000Z', + }); + + await expect(tryAcquireUpdateInstallLock({ + version: '0.5.0', + now: new Date('2026-08-03T00:00:00.000Z'), + })).resolves.toBeNull(); + }); + + it('honours a pid-less lock 2 minutes in the future', async () => { + writeLock({ + version: '0.5.0', + ownerId: 'skewed-legacy-owner', + startedAt: '2026-08-03T00:02:00.000Z', }); await expect(tryAcquireUpdateInstallLock({ diff --git a/apps/pythinker-code/test/cli/update/install-state.test.ts b/apps/pythinker-code/test/cli/update/install-state.test.ts new file mode 100644 index 00000000..3a78d2dc --- /dev/null +++ b/apps/pythinker-code/test/cli/update/install-state.test.ts @@ -0,0 +1,260 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + emptyUpdateInstallState, + readUpdateInstallState, + reconcileAbandonedInstall, + writeUpdateInstallState, +} from '#/cli/update/install-state'; +import type { + UpdateInstallState, + UpdateInstallSuccess, + UpdatePreparedHomebrew, +} from '#/cli/update/types'; +import { getUpdateInstallStateFile } from '#/utils/paths'; + +const originalEnv = { ...process.env }; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'pythinker-install-state-')); + process.env['PYTHINKER_CODE_HOME'] = dir; +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + process.env = { ...originalEnv }; +}); + +describe('update install state', () => { + it('round-trips an active record carrying installer progress', async () => { + const state: UpdateInstallState = { + active: { + version: '0.5.0', + source: 'native', + startedAt: '2026-04-23T08:00:00.000Z', + pid: 42_424, + progress: { + state: 'downloading', + percent: 42, + transferred: 5_320_000, + total: 12_600_000, + updatedAt: '2026-04-23T08:01:00.000Z', + }, + }, + pending: null, + lastFailure: null, + lastSuccess: null, + }; + + await writeUpdateInstallState(state); + + await expect(readUpdateInstallState()).resolves.toEqual(state); + }); + + it('round-trips progress without a total (unknown download size)', async () => { + const state: UpdateInstallState = { + active: { + version: '0.5.0', + source: 'native', + startedAt: '2026-04-23T08:00:00.000Z', + progress: { + state: 'downloading', + transferred: 5_320_000, + updatedAt: '2026-04-23T08:01:00.000Z', + }, + }, + pending: null, + lastFailure: null, + lastSuccess: null, + }; + + await writeUpdateInstallState(state); + + await expect(readUpdateInstallState()).resolves.toEqual(state); + }); + + it('falls back to an empty state when the active record has malformed progress', async () => { + mkdirSync(join(dir, 'updates'), { recursive: true }); + writeFileSync( + getUpdateInstallStateFile(), + JSON.stringify({ + active: { + version: '0.5.0', + source: 'native', + startedAt: '2026-04-23T08:00:00.000Z', + progress: { state: 'bogus', updatedAt: '2026-04-23T08:01:00.000Z' }, + }, + pending: null, + lastFailure: null, + lastSuccess: null, + }), + 'utf-8', + ); + + await expect(readUpdateInstallState()).resolves.toEqual(emptyUpdateInstallState()); + }); +}); + +describe('reconcileAbandonedInstall', () => { + const now = new Date('2026-04-23T09:00:00.000Z'); + const fixedNowIso = now.toISOString(); + + function doomedActiveInstall(): UpdateInstallState { + return { + active: { + version: '0.5.0', + source: 'npm-global', + startedAt: '2026-04-23T08:00:00.000Z', + // Outside any plausible pid range: the owner is gone. + pid: 999_999_999, + }, + pending: null, + lastFailure: null, + lastSuccess: null, + }; + } + + it('clears an abandoned active record and records the first failure attempt', async () => { + const reconciled = await reconcileAbandonedInstall(doomedActiveInstall(), now); + + expect(reconciled).toEqual({ + active: null, + pending: null, + lastFailure: { + version: '0.5.0', + failedAt: fixedNowIso, + attempts: 1, + message: expect.any(String), + }, + lastSuccess: null, + }); + // The reconciled state is what the next launch reads. + await expect(readUpdateInstallState()).resolves.toEqual(reconciled); + }); + + it('reaches the parking threshold after two abandoned installs of the same version', async () => { + const first = await reconcileAbandonedInstall(doomedActiveInstall(), now); + expect(first.lastFailure?.attempts).toBe(1); + + // The next launch records a fresh active record on top of the previous + // failure, exactly like the background lifecycle does. + const second = await reconcileAbandonedInstall( + { ...doomedActiveInstall(), lastFailure: first.lastFailure }, + now, + ); + expect(second.lastFailure?.attempts).toBe(2); + }); + + it('leaves an active record with a live installer pid exactly as it is', async () => { + const state: UpdateInstallState = { + active: { + version: '0.5.0', + source: 'npm-global', + startedAt: new Date().toISOString(), + pid: process.pid, + }, + pending: null, + lastFailure: null, + lastSuccess: null, + }; + + const reconciled = await reconcileAbandonedInstall(state); + + expect(reconciled).toBe(state); + expect(existsSync(getUpdateInstallStateFile())).toBe(false); + }); + + it('leaves a state without an active record exactly as it is', async () => { + const state = emptyUpdateInstallState(); + + const reconciled = await reconcileAbandonedInstall(state, now); + + expect(reconciled).toBe(state); + expect(existsSync(getUpdateInstallStateFile())).toBe(false); + }); + + it('starts a fresh failure counter when an abandoned prepare follows install failures', async () => { + const state: UpdateInstallState = { + active: { + version: '0.5.0', + source: 'homebrew', + operation: 'prepare', + startedAt: '2026-04-23T08:00:00.000Z', + pid: 999_999_999, + }, + pending: null, + lastFailure: { + version: '0.5.0', + failedAt: '2026-04-22T08:00:00.000Z', + attempts: 2, + operation: 'install', + message: 'npm exited with code 1', + }, + lastSuccess: null, + }; + + const reconciled = await reconcileAbandonedInstall(state, now); + + expect(reconciled.lastFailure).toEqual({ + version: '0.5.0', + failedAt: fixedNowIso, + attempts: 1, + operation: 'prepare', + message: expect.any(String), + }); + }); + + it('leaves lastSuccess and pending untouched while reconciling', async () => { + const lastSuccess: UpdateInstallSuccess = { + version: '0.4.9', + installedAt: '2026-04-21T08:00:00.000Z', + notifiedAt: null, + }; + const pending: UpdatePreparedHomebrew = { + jobId: '7e717f78-70c6-4f7c-9745-ceb45822d24b', + source: 'homebrew', + version: '0.6.0', + preparedAt: '2026-04-22T08:00:00.000Z', + requestedBy: 'automatic', + formulaUrl: 'https://registry.example.com/pythinker-code-0.6.0.tgz', + artifactKind: 'source', + artifactSha256: 'a'.repeat(64), + formulaFileSha256: 'b'.repeat(64), + artifactPath: '/tmp/cache/pythinker-code-0.6.0.tgz', + }; + const state: UpdateInstallState = { + ...doomedActiveInstall(), + pending, + lastSuccess, + }; + + const reconciled = await reconcileAbandonedInstall(state, now); + + expect(reconciled.lastSuccess).toBe(lastSuccess); + expect(reconciled.pending).toBe(pending); + }); + + it('returns the reconciled state even when persisting it fails', async () => { + // Plant a file where the data directory would be created, so the state + // write fails with ENOTDIR and startup must not break. + const blocked = join(dir, 'blocked'); + writeFileSync(blocked, 'not a directory', 'utf-8'); + process.env['PYTHINKER_CODE_HOME'] = blocked; + + await expect(reconcileAbandonedInstall(doomedActiveInstall(), now)).resolves.toEqual({ + active: null, + pending: null, + lastFailure: expect.objectContaining({ + version: '0.5.0', + attempts: 1, + }), + lastSuccess: null, + }); + }); +}); diff --git a/apps/pythinker-code/test/cli/update/preflight.test.ts b/apps/pythinker-code/test/cli/update/preflight.test.ts index 3773ae75..014fd4fc 100644 --- a/apps/pythinker-code/test/cli/update/preflight.test.ts +++ b/apps/pythinker-code/test/cli/update/preflight.test.ts @@ -28,6 +28,7 @@ import { DEFAULT_STATUS_LINE_CONFIG, type TuiConfig, } from '#/tui/config'; +import { getUpdateInstallStateFile } from '#/utils/paths'; const mocks = vi.hoisted(() => ({ readUpdateCache: vi.fn(), @@ -40,6 +41,8 @@ const mocks = vi.hoisted(() => ({ refreshUpdateCache: vi.fn(), resolveUpdateDeviceId: vi.fn(), appendRolloutDecisionLog: vi.fn(), + readJsonFile: vi.fn(), + writeJsonFile: vi.fn(), spawn: vi.fn(), })); @@ -51,15 +54,26 @@ vi.mock('../../../src/cli/update/install-lock', () => ({ tryAcquireUpdateInstallLock: mocks.tryAcquireUpdateInstallLock, })); -vi.mock('../../../src/cli/update/install-state', () => ({ - emptyUpdateInstallState: () => ({ - active: null, - pending: null, - lastFailure: null, - lastSuccess: null, - }), - readUpdateInstallState: mocks.readUpdateInstallState, - writeUpdateInstallState: mocks.writeUpdateInstallState, +// Only the file IO is faked: `hasFreshActiveInstall` is the lease rule under +// test in several cases below, so it must be the real one. +vi.mock('../../../src/cli/update/install-state', async () => { + const actual = await vi.importActual< + typeof import('../../../src/cli/update/install-state.js') + >('../../../src/cli/update/install-state'); + return { + ...actual, + readUpdateInstallState: mocks.readUpdateInstallState, + writeUpdateInstallState: mocks.writeUpdateInstallState, + }; +}); + +// The reconciliation lives inside install-state.ts and calls its own module's +// writer directly, which the module mock above cannot rewire. Mocking the +// persistence layer catches those writes too — and keeps them off the real +// home directory. +vi.mock('../../../src/utils/persistence', () => ({ + readJsonFile: mocks.readJsonFile, + writeJsonFile: mocks.writeJsonFile, })); vi.mock('../../../src/tui/config', async () => { @@ -160,6 +174,31 @@ function releasedForEveryone(version: string): UpdateManifest { }); } +/** A manifest advertising an artifact for a platform other than the running one. */ +function manifestOmittingRunningTarget(version: string): UpdateManifest { + const otherArch = process.arch === 'arm64' ? 'x64' : 'arm64'; + return manifestFor(version, { + platforms: { + [`${process.platform}-${otherArch}`]: { + url: `https://code.pythinker.com/pythinker-code-${version}.zip`, + sha256: 'a'.repeat(64), + }, + }, + }); +} + +/** A manifest advertising an artifact for the running platform. */ +function manifestForRunningTarget(version: string): UpdateManifest { + return manifestFor(version, { + platforms: { + [`${process.platform}-${process.arch}`]: { + url: `https://code.pythinker.com/pythinker-code-${version}.zip`, + sha256: 'a'.repeat(64), + }, + }, + }); +} + function installState(overrides: Partial = {}): UpdateInstallState { return { active: null, @@ -275,6 +314,88 @@ function mockSpawnExitWithStderr(code: number, stderrText: string): void { }); } +/** + * Like mockSpawnExitWithStderr, but stderr arrives as several separate chunks + * so the line reader has to reassemble a progress line split mid-way across + * 'data' events. + */ +function mockSpawnExitWithChunkedStderr(code: number, chunks: string[]): void { + mocks.spawn.mockImplementation((_cmd: string, _args: string[], options?: { stdio?: unknown }) => { + const stdio = options?.stdio; + const stderrPiped = Array.isArray(stdio) && stdio[2] === 'pipe'; + const stderr = Object.assign(new EventEmitter(), { + setEncoding: vi.fn(), + unref: vi.fn(), + }); + const child = Object.assign(new EventEmitter(), { + pid: 42_424, + unref: vi.fn(), + stderr: stderrPiped ? stderr : null, + }); + queueMicrotask(() => { + if (stderrPiped) { + for (const chunk of chunks) stderr.emit('data', chunk); + } + child.emit('exit', code, null); + }); + return child; + }); +} + +/** + * Like mockSpawnExitWithStderr, but stderr chunks and the exit arrive on real + * timers, so the parent's write throttle sees realistic time deltas. + */ +function mockSpawnExitWithTimedStderr( + code: number, + chunks: Array<{ atMs: number; text: string }>, + exitAtMs: number, +): void { + mocks.spawn.mockImplementation((_cmd: string, _args: string[], options?: { stdio?: unknown }) => { + const stdio = options?.stdio; + const stderrPiped = Array.isArray(stdio) && stdio[2] === 'pipe'; + const stderr = Object.assign(new EventEmitter(), { + setEncoding: vi.fn(), + unref: vi.fn(), + }); + const child = Object.assign(new EventEmitter(), { + pid: 42_424, + unref: vi.fn(), + stderr: stderrPiped ? stderr : null, + }); + for (const chunk of chunks) { + setTimeout(() => { + if (stderrPiped) stderr.emit('data', chunk.text); + }, chunk.atMs); + } + setTimeout(() => { child.emit('exit', code, null); }, exitAtMs); + return child; + }); +} + +/** The failure messages written by the background-install finalizer, in order. */ +function progressFailureMessages(): string[] { + return mocks.writeUpdateInstallState.mock.calls + .map((call) => call[0]) + .filter((state) => state !== undefined && state !== null && state.lastFailure !== undefined && state.lastFailure !== null) + .map((state) => state.lastFailure.message); +} + +/** The states written with an active record carrying progress, in order. */ +function progressActiveStates(): unknown[] { + return mocks.writeUpdateInstallState.mock.calls + .map((call) => call[0]) + .filter((state) => state !== undefined && state !== null && state.active?.progress !== undefined); +} + +/** The terminal success records written by the finalizer, in order. */ +function successOutcomeStates(): unknown[] { + return mocks.writeUpdateInstallState.mock.calls + .map((call) => call[0]) + .filter((state) => state !== undefined && state !== null + && state.active === null && state.lastSuccess !== undefined && state.lastSuccess !== null); +} + async function flushBackgroundInstall(): Promise { await new Promise((resolve) => { setImmediate(resolve); @@ -285,6 +406,8 @@ describe('runUpdatePreflight', () => { beforeEach(() => { mocks.readUpdateInstallState.mockResolvedValue(emptyUpdateInstallState()); mocks.writeUpdateInstallState.mockResolvedValue(undefined); + mocks.readJsonFile.mockResolvedValue(null); + mocks.writeJsonFile.mockResolvedValue(undefined); mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); mocks.resolveUpdateDeviceId.mockReturnValue('test-device'); mocks.appendRolloutDecisionLog.mockResolvedValue(undefined); @@ -460,6 +583,119 @@ describe('runUpdatePreflight', () => { } }); + it('starts the background install for the refreshed version, never the cached one', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.10.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.11.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.9.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(mocks.spawn).toHaveBeenCalledTimes(1); + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/u), + ['install', '-g', '@pythoughts/pythinker-code@0.11.0'], + { detached: true, windowsHide: false, stdio: ['ignore', 'ignore', 'pipe'] }, + ); + expect(mocks.spawn).not.toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/u), + ['install', '-g', '@pythoughts/pythinker-code@0.10.0'], + expect.anything(), + ); + }); + + it('starts nothing when the refresh offers no newer version than the current one', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.10.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.9.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.9.0', options)).resolves.toBe('continue'); + + expect(mocks.spawn).not.toHaveBeenCalled(); + expect(promptForInstallChoice).not.toHaveBeenCalled(); + }); + + it('falls back to the cached target when the refresh rejects', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.10.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockRejectedValue(new Error('offline')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.9.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(mocks.spawn).toHaveBeenCalledTimes(1); + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/u), + ['install', '-g', '@pythoughts/pythinker-code@0.10.0'], + { detached: true, windowsHide: false, stdio: ['ignore', 'ignore', 'pipe'] }, + ); + }); + + it('falls back to the cached target when the refresh hangs past the 1-second budget', async () => { + vi.useFakeTimers(); + try { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.10.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockReturnValue(new Promise(() => {})); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const { options } = captureOutput(); + + const result = runUpdatePreflight('0.9.0', options); + await vi.advanceTimersByTimeAsync(1_000); + + await expect(result).resolves.toBe('continue'); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/u), + ['install', '-g', '@pythoughts/pythinker-code@0.10.0'], + { detached: true, windowsHide: false, stdio: ['ignore', 'ignore', 'pipe'] }, + ); + } finally { + vi.useRealTimers(); + } + }); + + it('native: offers and installs nothing when the refreshed manifest omits the running platform', async () => { + const cached = cacheWithManifest(manifestForRunningTarget('0.10.0')); + const refreshed = cacheWithManifest(manifestOmittingRunningTarget('0.11.0')); + mocks.readUpdateCache.mockResolvedValue(cached); + mocks.refreshUpdateCache.mockResolvedValue(refreshed); + mocks.detectInstallSource.mockResolvedValue('native'); + const { stdout, options } = captureOutput(); + + await expect(runUpdatePreflight('0.9.0', options)).resolves.toBe('continue'); + + expect(stdout.join('')).toBe(''); + expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('decides from the cache and from the refresh exactly once each per launch', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.10.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.11.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.9.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + const phases = mocks.appendRolloutDecisionLog.mock.calls.map((call) => call[0].phase); + expect(phases.filter((phase) => phase === 'startup-cache')).toHaveLength(1); + expect(phases.filter((phase) => phase === 'prompt-refresh')).toHaveLength(1); + expect(phases.filter((phase) => phase === 'background-refresh')).toHaveLength(0); + }); + it('pnpm-global: spawns pnpm add -g', async () => { disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); @@ -616,6 +852,61 @@ describe('runUpdatePreflight', () => { } }); + it('native: offers and installs nothing when the manifest omits the running platform', async () => { + const omitted = cacheWithManifest(manifestOmittingRunningTarget('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(omitted); + mocks.refreshUpdateCache.mockResolvedValue(omitted); + mocks.detectInstallSource.mockResolvedValue('native'); + const { stdout, options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + + expect(stdout.join('')).toBe(''); + expect(promptForInstallChoice).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + expect(detectInstallSource).toHaveBeenCalledTimes(1); + }); + + it('native: prompts and installs when the manifest advertises the running platform', async () => { + disableAutoInstall(); + const advertised = cacheWithManifest(manifestForRunningTarget('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(advertised); + mocks.refreshUpdateCache.mockResolvedValue(advertised); + mocks.detectInstallSource.mockResolvedValue('native'); + mocks.promptForInstallChoice.mockResolvedValue('install'); + mockSpawnExit(0); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('exit'); + + expect(mocks.promptForInstallChoice).toHaveBeenCalledWith( + expect.objectContaining({ installSource: 'native' }), + ); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + }); + + it('npm-global: still prompts and installs when the manifest omits the running platform', async () => { + disableAutoInstall(); + const omitted = cacheWithManifest(manifestOmittingRunningTarget('0.5.0')); + mocks.readUpdateCache.mockResolvedValue(omitted); + mocks.refreshUpdateCache.mockResolvedValue(omitted); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('install'); + mockSpawnExit(0); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('exit'); + + expect(mocks.promptForInstallChoice).toHaveBeenCalledWith( + expect.objectContaining({ installSource: 'npm-global' }), + ); + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/u), + ['install', '-g', '@pythoughts/pythinker-code@0.5.0'], + { stdio: 'inherit' }, + ); + }); + it('unsupported: prints fallback npm command', async () => { mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); @@ -637,6 +928,102 @@ describe('runUpdatePreflight', () => { expect(mocks.spawn).not.toHaveBeenCalled(); }); + it('does not prompt for a foreground install while a fresh active install is running', async () => { + disableAutoInstall(); + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + active: { + version: '0.5.0', + source: 'npm-global', + startedAt: new Date().toISOString(), + }, + })); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('install'); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + + expect(mocks.promptForInstallChoice).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + expect(mocks.tryAcquireUpdateInstallLock).not.toHaveBeenCalled(); + }); + + it('acquires the install lock only after the prompt resolves and releases it afterwards', async () => { + disableAutoInstall(); + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const release = vi.fn().mockResolvedValue(undefined); + mocks.tryAcquireUpdateInstallLock.mockResolvedValue({ + filePath: '/tmp/pythinker-update-install.lock', + release, + }); + let resolvePrompt: ((value: 'install') => void) | undefined; + const prompt = new Promise<'install'>((resolve) => { resolvePrompt = resolve; }); + mocks.promptForInstallChoice.mockReturnValue(prompt); + const { stdout, options } = captureOutput(); + + const running = runUpdatePreflight('0.4.0', options); + + // Let the flow actually reach the prompt first. Asserting straight after the + // call was vacuous: nothing had run past the first await, so "no lock yet" + // held wherever the acquisition sat, and the ordering claim in the test name + // went unchecked. + await vi.waitFor(() => { + expect(mocks.promptForInstallChoice).toHaveBeenCalled(); + }); + expect(mocks.tryAcquireUpdateInstallLock).not.toHaveBeenCalled(); + resolvePrompt?.('install'); + await expect(running).resolves.toBe('exit'); + expect(mocks.tryAcquireUpdateInstallLock).toHaveBeenCalledWith({ version: '0.5.0' }); + expect(release).toHaveBeenCalledOnce(); + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + active: null, + lastFailure: null, + lastSuccess: { + version: '0.5.0', + installedAt: expect.any(String), + notifiedAt: null, + }, + })); + expect(stdout.join('')).toContain('Updated @pythoughts/pythinker-code to 0.5.0'); + }); + + it('releases the lock and records the failure when the foreground install fails', async () => { + disableAutoInstall(); + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('install'); + mockSpawnExit(1); + const release = vi.fn().mockResolvedValue(undefined); + mocks.tryAcquireUpdateInstallLock.mockResolvedValue({ + filePath: '/tmp/pythinker-update-install.lock', + release, + }); + const { stderr, options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + + expect(stderr.join('')).toContain('warning: failed to install'); + expect(release).toHaveBeenCalledOnce(); + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + active: null, + lastFailure: expect.objectContaining({ + version: '0.5.0', + attempts: 1, + operation: 'install', + failedAt: expect.any(String), + }), + lastSuccess: null, + })); + }); + it('warns and continues when spawn exits non-zero, without claiming success', async () => { disableAutoInstall(); mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); @@ -767,13 +1154,13 @@ describe('runUpdatePreflight', () => { expect(promptForInstallChoice).not.toHaveBeenCalled(); }); - it('blocks a changed target while the previous target installer pid remains alive past the PID-less TTL', async () => { + it('blocks a changed target while the previous target installer pid remains alive within the TTL', async () => { mocks.readUpdateCache.mockResolvedValue(cacheWith('0.6.0')); mocks.readUpdateInstallState.mockResolvedValue(installState({ active: { version: '0.5.0', source: 'npm-global', - startedAt: new Date(Date.now() - 7 * 60 * 60 * 1_000).toISOString(), + startedAt: new Date(Date.now() - (6 * 60 * 60 * 1_000 - 60_000)).toISOString(), pid: process.pid, }, })); @@ -788,6 +1175,30 @@ describe('runUpdatePreflight', () => { expect(promptForInstallChoice).not.toHaveBeenCalled(); }); + it('recovers a changed target after the previous installer pid outlives the TTL ceiling', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.6.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + active: { + version: '0.5.0', + source: 'npm-global', + startedAt: new Date(Date.now() - (6 * 60 * 60 * 1_000 + 60_000)).toISOString(), + pid: process.pid, + }, + })); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.6.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.5.0', options)).resolves.toBe('continue'); + + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/u), + ['install', '-g', '@pythoughts/pythinker-code@0.6.0'], + { detached: true, windowsHide: false, stdio: ['ignore', 'ignore', 'pipe'] }, + ); + }); + it('tolerates a small clock rollback while the recorded installer pid is alive', async () => { mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.readUpdateInstallState.mockResolvedValue(installState({ @@ -868,11 +1279,56 @@ describe('runUpdatePreflight', () => { await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); - expect(mocks.spawn).toHaveBeenCalledWith( - expect.stringMatching(/^npm(\.cmd)?$/), - ['install', '-g', '@pythoughts/pythinker-code@0.6.0'], - { detached: true, windowsHide: false, stdio: ['ignore', 'ignore', 'pipe'] }, - ); + expect(mocks.spawn).toHaveBeenCalledWith( + expect.stringMatching(/^npm(\.cmd)?$/), + ['install', '-g', '@pythoughts/pythinker-code@0.6.0'], + { detached: true, windowsHide: false, stdio: ['ignore', 'ignore', 'pipe'] }, + ); + }); + + it('parks a doomed version after two abandoned installs are reconciled', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.promptForInstallChoice.mockResolvedValue('skip'); + let persisted: UpdateInstallState = installState({ + active: { + version: '0.5.0', + source: 'npm-global', + startedAt: new Date().toISOString(), + pid: 999_999_999, + }, + }); + // The reconciliation writes through install-state's own writer, which the + // module mock cannot intercept — capture that path via the persistence + // mock so both write routes feed the same simulated state file. + mocks.writeUpdateInstallState.mockImplementation( + async (state: UpdateInstallState) => { persisted = state; }, + ); + mocks.writeJsonFile.mockImplementation( + async (_filePath: string, _schema: unknown, value: UpdateInstallState) => { persisted = value; }, + ); + mocks.readUpdateInstallState.mockImplementation(async () => persisted); + // Each launch's installer dies without recording an outcome, so the next + // launch finds only an abandoned active record. + mocks.spawn.mockImplementation( + () => Object.assign(new EventEmitter(), { pid: 999_999_999, unref: vi.fn() }), + ); + const { options } = captureOutput(); + + // First launch: the abandoned record is reconciled to one attempt and the + // version is still attempted in the background. + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + expect(persisted.lastFailure?.attempts).toBe(1); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + + // Second launch: the counter reaches the parking threshold and the + // automatic path refuses to start the installer again — assert the + // refusal, not just the counter. + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + expect(persisted.lastFailure?.attempts).toBe(2); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + expect(mocks.tryAcquireUpdateInstallLock).toHaveBeenCalledTimes(1); }); it('recovers a changed target from a far-future active timestamp', async () => { @@ -1316,7 +1772,7 @@ describe('runUpdatePreflight', () => { expect(mocks.tryAcquireUpdateInstallLock).not.toHaveBeenCalled(); }); - it('infers a background update success notice when the active install version is now running', async () => { + it('records an abandoned install as a failure instead of inferring a success notice', async () => { mocks.readUpdateCache.mockResolvedValue(emptyUpdateCache()); mocks.readUpdateInstallState.mockResolvedValue(installState({ active: { @@ -1330,15 +1786,22 @@ describe('runUpdatePreflight', () => { await expect(runUpdatePreflight('0.5.0', options)).resolves.toBe('continue'); - expect(stdout.join('')).toContain('Pythinker Code updated to v0.5.0'); - expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ - active: null, - lastFailure: null, - lastSuccess: expect.objectContaining({ - version: '0.5.0', - notifiedAt: expect.any(String), + // A stale active record is an abandoned install, not an inferred success: + // it is reconciled into a recorded failure and never shows the notice. + expect(stdout.join('')).toBe(''); + expect(mocks.writeJsonFile).toHaveBeenCalledWith( + getUpdateInstallStateFile(), + expect.anything(), + expect.objectContaining({ + active: null, + lastFailure: expect.objectContaining({ + version: '0.5.0', + attempts: 1, + message: expect.stringContaining('abandoned'), + }), }), - })); + expect.objectContaining({ durable: true }), + ); }); it('tracks update_prompted telemetry', async () => { @@ -1581,6 +2044,245 @@ describe('runUpdatePreflight', () => { ); }); }); + + describe('background installer progress lines', () => { + it('reassembles a progress line split across data events into one update', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExitWithChunkedStderr(0, [ + 'progress: state=downloading percent=4', + '2 transferred=5320', + '000 total=12600000\n', + ]); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + active: expect.objectContaining({ + progress: expect.objectContaining({ + state: 'downloading', + percent: 42, + transferred: 5_320_000, + total: 12_600_000, + }), + }), + })); + }); + + /** + * The exact bytes a real `install.sh` run emitted while downloading the + * 0.9.2 release, captured from its stderr. Pinning them here means the + * emitter and this parser cannot drift apart silently. + */ + it('parses the bytes a real installer run actually emitted', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExitWithStderr( + 0, + 'progress: state=downloading percent=0 transferred=0 total=55795679\n' + + 'progress: state=downloading percent=49 transferred=27103232 total=55795679\n' + + 'progress: state=done transferred=55795679\n', + ); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + // All three lines arrive in one chunk, so the 2-second write throttle + // keeps the first downloading update and drops the second; the terminal + // state always bypasses the throttle. + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + active: expect.objectContaining({ + progress: expect.objectContaining({ + state: 'downloading', + percent: 0, + transferred: 0, + total: 55_795_679, + }), + }), + })); + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + active: expect.objectContaining({ + progress: expect.objectContaining({ state: 'done', transferred: 55_795_679 }), + }), + })); + }); + + /** + * The state file is written as a temp file plus rename, so the last rename + * wins. The installer's terminal `state=done` line writes just as the child + * exits, so an unawaited progress write can rename over the outcome — + * restoring `active` and dropping `lastSuccess`. The next launch reads that + * as an abandoned install and records a failure for a version that + * installed cleanly, which at two attempts parks it for good. + */ + it('never lets a slow progress write rename over the install outcome', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + // Hold the progress write open; every other write settles at once. + let releaseProgressWrite: (() => void) | undefined; + mocks.writeUpdateInstallState.mockImplementation( + (state: { active?: { progress?: unknown } | null }) => ( + state.active?.progress === undefined + ? Promise.resolve() + : new Promise((resolve) => { releaseProgressWrite = resolve; }) + ), + ); + mockSpawnExitWithStderr(0, 'progress: state=done transferred=55795679\n'); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + // The progress write is still in flight, so the outcome must not be out yet. + expect(progressActiveStates()).toHaveLength(1); + expect(successOutcomeStates()).toEqual([]); + + releaseProgressWrite?.(); + await flushBackgroundInstall(); + await flushBackgroundInstall(); + + expect(successOutcomeStates()).toHaveLength(1); + // The outcome is the last thing written, so it survives on disk. + expect(mocks.writeUpdateInstallState.mock.calls.at(-1)?.[0]).toMatchObject({ + active: null, + lastSuccess: expect.objectContaining({ version: '0.5.0' }), + }); + }); + + it('keeps progress lines out of the failure tail and ordinary stderr lines in it', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExitWithStderr( + 1, + 'progress: state=downloading percent=42 transferred=5320000 total=12600000\n' + + 'bash: line 900: BASH_SOURCE[0]: unbound variable\n', + ); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + const messages = progressFailureMessages(); + expect(messages).toHaveLength(1); + expect(messages[0]).toContain('BASH_SOURCE[0]: unbound variable'); + expect(messages[0]).not.toContain('progress: state=downloading'); + expect(messages[0]).not.toContain('percent=42'); + }); + + it('leaves the real error in the tail after a hundred progress lines', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + const progressLines = Array.from({ length: 100 }, (_, i) => ( + `progress: state=downloading percent=${i} transferred=${(i + 1) * 1000} total=12600000\n` + )).join(''); + mockSpawnExitWithStderr(1, `${progressLines}npm ERR! real failure\n`); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + const messages = progressFailureMessages(); + expect(messages).toHaveLength(1); + expect(messages[0]).toContain('npm ERR! real failure'); + expect(messages[0]).not.toContain('progress:'); + expect(messages[0]).not.toContain('percent='); + }); + + it('ignores unknown keys and non-numeric percent values without throwing', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExitWithStderr( + 0, + 'progress: state=downloading percent=not-a-number transferred=5320000 total=12600000 mystery=1\n', + ); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + active: expect.objectContaining({ + progress: expect.objectContaining({ + state: 'downloading', + transferred: 5_320_000, + total: 12_600_000, + percent: undefined, + }), + }), + })); + }); + + it('accepts a downloading update without a total and without a percent', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExitWithStderr(0, 'progress: state=downloading transferred=5320000\n'); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await flushBackgroundInstall(); + + expect(writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + active: expect.objectContaining({ + progress: expect.objectContaining({ + state: 'downloading', + transferred: 5_320_000, + percent: undefined, + total: undefined, + }), + }), + })); + }); + + it('throttles progress writes to one per two seconds but never drops the terminal update', async () => { + mocks.readUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.readUpdateInstallState.mockResolvedValue(installState()); + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExitWithTimedStderr( + 0, + [ + { atMs: 0, text: 'progress: state=downloading percent=10 transferred=1000 total=10000\n' }, + { atMs: 100, text: 'progress: state=downloading percent=20 transferred=2000 total=10000\n' }, + { atMs: 250, text: 'progress: state=done transferred=10000\n' }, + ], + 320, + ); + const { options } = captureOutput(); + + await expect(runUpdatePreflight('0.4.0', options)).resolves.toBe('continue'); + await new Promise((resolve) => setTimeout(resolve, 500)); + + const progressStates = progressActiveStates(); + expect(progressStates).toHaveLength(2); + expect(progressStates[0]).toEqual(expect.objectContaining({ + active: expect.objectContaining({ + progress: expect.objectContaining({ state: 'downloading', percent: 10 }), + }), + })); + expect(progressStates[1]).toEqual(expect.objectContaining({ + active: expect.objectContaining({ + progress: expect.objectContaining({ state: 'done', transferred: 10_000 }), + }), + })); + }); + }); }); describe('spawnForSource native', () => { @@ -1645,6 +2347,8 @@ describe('startManualUpdate', () => { beforeEach(() => { mocks.readUpdateInstallState.mockResolvedValue(emptyUpdateInstallState()); mocks.writeUpdateInstallState.mockResolvedValue(undefined); + mocks.readJsonFile.mockResolvedValue(null); + mocks.writeJsonFile.mockResolvedValue(undefined); mocks.loadTuiConfig.mockResolvedValue(tuiConfig()); mocks.resolveUpdateDeviceId.mockReturnValue('test-device'); mocks.appendRolloutDecisionLog.mockResolvedValue(undefined); @@ -1663,6 +2367,40 @@ describe('startManualUpdate', () => { expect(mocks.spawn).not.toHaveBeenCalled(); }); + it('native: reports up-to-date when the manifest omits the running platform', async () => { + mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(manifestOmittingRunningTarget('0.5.0'))); + mocks.detectInstallSource.mockResolvedValue('native'); + + await expect(startManualUpdate('0.4.0')).resolves.toEqual({ status: 'up-to-date' }); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('native: starts a background install when the manifest advertises the running platform', async () => { + mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(manifestForRunningTarget('0.5.0'))); + mocks.detectInstallSource.mockResolvedValue('native'); + mockSpawnExit(0); + + await expect(startManualUpdate('0.4.0')).resolves.toEqual({ + status: 'started', + version: '0.5.0', + installOnRestart: false, + }); + await flushBackgroundInstall(); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + }); + + it('npm-global: still starts the update when the manifest omits the running platform', async () => { + mocks.refreshUpdateCache.mockResolvedValue(cacheWithManifest(manifestOmittingRunningTarget('0.5.0'))); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mockSpawnExit(0); + + await expect(startManualUpdate('0.4.0')).resolves.toEqual({ + status: 'started', + version: '0.5.0', + installOnRestart: false, + }); + }); + it('starts a background install for an auto-installable source', async () => { mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); @@ -1758,7 +2496,7 @@ describe('startManualUpdate', () => { await expect(startManualUpdate('0.4.0')).resolves.toEqual({ status: 'in-progress', - version: '0.5.0', + installingVersion: '0.5.0', installOnRestart: true, readyToInstall: true, }); @@ -1777,22 +2515,172 @@ describe('startManualUpdate', () => { await expect(startManualUpdate('0.4.0')).resolves.toEqual({ status: 'in-progress', + installingVersion: '0.5.0', + installOnRestart: false, + readyToInstall: false, + }); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('reports both the running older install and the newer target it will follow', async () => { + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.11.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + active: { version: '0.10.0', source: 'npm-global', startedAt: new Date().toISOString() }, + })); + + await expect(startManualUpdate('0.9.0')).resolves.toEqual({ + status: 'in-progress', + installingVersion: '0.10.0', + targetVersion: '0.11.0', + installOnRestart: false, + readyToInstall: false, + }); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('does not claim the target supersedes an active install of a newer version', async () => { + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.10.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + active: { version: '0.11.0', source: 'npm-global', startedAt: new Date().toISOString() }, + })); + + await expect(startManualUpdate('0.9.0')).resolves.toEqual({ + status: 'in-progress', + installingVersion: '0.11.0', + installOnRestart: false, + readyToInstall: false, + }); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('keeps installOnRestart for a fresh homebrew active install', async () => { + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('homebrew'); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + active: { version: '0.5.0', source: 'homebrew', startedAt: new Date().toISOString() }, + })); + + await expect(startManualUpdate('0.4.0')).resolves.toEqual({ + status: 'in-progress', + installingVersion: '0.5.0', + installOnRestart: true, + readyToInstall: false, + }); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('reports a parked version as failed with the recorded attempts and reason', async () => { + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + lastFailure: { + version: '0.5.0', + failedAt: '2026-08-05T08:00:00.000Z', + attempts: 2, + operation: 'install', + message: 'npm exited with code 1', + }, + })); + + const result = await startManualUpdate('0.4.0'); + expect(result).toEqual({ + status: 'failed', + version: '0.5.0', + attempts: 2, + failedAt: '2026-08-05T08:00:00.000Z', + message: 'npm exited with code 1', + command: 'npm install -g @pythoughts/pythinker-code@0.5.0', + }); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('still attempts the install one failure below the parked threshold', async () => { + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + lastFailure: { + version: '0.5.0', + failedAt: '2026-08-05T08:00:00.000Z', + attempts: 1, + message: 'npm exited with code 1', + }, + })); + mockSpawnExit(0); + + await expect(startManualUpdate('0.4.0')).resolves.toEqual({ + status: 'started', + version: '0.5.0', + installOnRestart: false, + }); + await flushBackgroundInstall(); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + }); + + it('still attempts the install when the parked failures belong to another version', async () => { + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + lastFailure: { + version: '0.4.1', + failedAt: '2026-08-05T08:00:00.000Z', + attempts: 2, + message: 'npm exited with code 1', + }, + })); + mockSpawnExit(0); + + await expect(startManualUpdate('0.4.0')).resolves.toEqual({ + status: 'started', version: '0.5.0', installOnRestart: false, + }); + await flushBackgroundInstall(); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + }); + + it('reports in-progress when a fresh install runs despite a parked failure', async () => { + mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); + mocks.detectInstallSource.mockResolvedValue('npm-global'); + mocks.readUpdateInstallState.mockResolvedValue(installState({ + active: { version: '0.5.0', source: 'npm-global', startedAt: new Date().toISOString() }, + lastFailure: { + version: '0.5.0', + failedAt: '2026-08-05T08:00:00.000Z', + attempts: 2, + message: 'npm exited with code 1', + }, + })); + + await expect(startManualUpdate('0.4.0')).resolves.toEqual({ + status: 'in-progress', + installingVersion: '0.5.0', + installOnRestart: false, readyToInstall: false, }); expect(mocks.spawn).not.toHaveBeenCalled(); }); - it('falls back to the manual command after repeated background failures', async () => { + it('omits the reason when the recorded failure carries none', async () => { mocks.refreshUpdateCache.mockResolvedValue(cacheWith('0.5.0')); mocks.detectInstallSource.mockResolvedValue('npm-global'); mocks.readUpdateInstallState.mockResolvedValue(installState({ - lastFailure: { version: '0.5.0', failedAt: new Date().toISOString(), attempts: 2 }, + lastFailure: { + version: '0.5.0', + failedAt: '2026-08-05T08:00:00.000Z', + attempts: 2, + }, })); const result = await startManualUpdate('0.4.0'); - expect(result.status).toBe('manual'); + expect(result).toEqual({ + status: 'failed', + version: '0.5.0', + attempts: 2, + failedAt: '2026-08-05T08:00:00.000Z', + command: 'npm install -g @pythoughts/pythinker-code@0.5.0', + }); expect(mocks.spawn).not.toHaveBeenCalled(); }); diff --git a/apps/pythinker-code/test/cli/update/refresh.test.ts b/apps/pythinker-code/test/cli/update/refresh.test.ts index ceb1306f..f0061006 100644 --- a/apps/pythinker-code/test/cli/update/refresh.test.ts +++ b/apps/pythinker-code/test/cli/update/refresh.test.ts @@ -17,7 +17,7 @@ describe('refreshUpdateCache', () => { it('writes a fresh cache carrying the manifest on successful fetch', async () => { const writeCache = vi.fn(async () => {}); const result = await refreshUpdateCache({ - fetchLatest: async () => ({ latest: '0.5.0', manifest: MANIFEST }), + fetchManifest: async () => MANIFEST, writeCache, now: () => new Date('2026-05-20T12:34:56.000Z'), }); @@ -31,28 +31,23 @@ describe('refreshUpdateCache', () => { expect(writeCache).toHaveBeenCalledWith(result); }); - it('writes a null manifest when the fetch fell back to plain text', async () => { + it('takes `latest` from the manifest rather than a separate field', async () => { const writeCache = vi.fn(async () => {}); const result = await refreshUpdateCache({ - fetchLatest: async () => ({ latest: '0.5.0', manifest: null }), + fetchManifest: async () => ({ ...MANIFEST, version: '0.6.0' }), writeCache, now: () => new Date('2026-05-20T12:34:56.000Z'), }); - expect(result).toEqual({ - source: 'cdn', - checkedAt: '2026-05-20T12:34:56.000Z', - latest: '0.5.0', - manifest: null, - }); - expect(writeCache).toHaveBeenCalledWith(result); + expect(result.latest).toBe('0.6.0'); + expect(result.manifest?.version).toBe('0.6.0'); }); it('propagates fetch errors and skips writeCache so the cache is preserved', async () => { const writeCache = vi.fn(async () => {}); await expect( refreshUpdateCache({ - fetchLatest: async () => { + fetchManifest: async () => { throw new Error('network down'); }, writeCache, diff --git a/apps/pythinker-code/test/cli/update/rollout.test.ts b/apps/pythinker-code/test/cli/update/rollout.test.ts index 0664fad1..c88f9e78 100644 --- a/apps/pythinker-code/test/cli/update/rollout.test.ts +++ b/apps/pythinker-code/test/cli/update/rollout.test.ts @@ -246,6 +246,89 @@ describe('decidePassiveUpdateTarget', () => { delaySeconds: 43_200, }); }); + + it('returns the target with reason required while the batch is still held', () => { + const manifest = makeManifest({ + rollout: [{ percent: 100, delaySeconds: 86_400 }], + minRequiredVersion: '1.9.0', + }); + const decision = decidePassiveUpdateTarget( + '1.0.0', + '2.0.0', + manifest, + 'device-a', + secondsAfterPublish(60), + ); + expect(decision).toMatchObject({ + target: { version: '2.0.0' }, + reason: 'required', + bucket: rolloutBucket('device-a', '2.0.0'), + delaySeconds: 86_400, + eligibleAt: new Date(PUBLISHED_AT_MS + 86_400 * 1000).toISOString(), + }); + }); + + it('returns the target with reason required even when already eligible', () => { + const manifest = makeManifest({ + rollout: [{ percent: 100, delaySeconds: 0 }], + minRequiredVersion: '1.9.0', + }); + const decision = decidePassiveUpdateTarget( + '1.0.0', + '2.0.0', + manifest, + 'device-a', + secondsAfterPublish(60), + ); + expect(decision).toMatchObject({ + target: { version: '2.0.0' }, + reason: 'required', + bucket: rolloutBucket('device-a', '2.0.0'), + delaySeconds: 0, + }); + }); + + it('applies ordinary eligibility rules when running at minRequiredVersion', () => { + const held = makeManifest({ + rollout: [{ percent: 100, delaySeconds: 86_400 }], + minRequiredVersion: '1.0.0', + }); + expect( + decidePassiveUpdateTarget('1.0.0', '2.0.0', held, 'device-a', secondsAfterPublish(60)), + ).toMatchObject({ target: null, reason: 'held' }); + const immediate = makeManifest({ + rollout: [{ percent: 100, delaySeconds: 0 }], + minRequiredVersion: '1.0.0', + }); + expect( + decidePassiveUpdateTarget('1.0.0', '2.0.0', immediate, 'device-a', secondsAfterPublish(60)), + ).toMatchObject({ target: { version: '2.0.0' }, reason: 'eligible' }); + }); + + it('applies ordinary eligibility rules when running above minRequiredVersion', () => { + const manifest = makeManifest({ + rollout: [{ percent: 100, delaySeconds: 86_400 }], + minRequiredVersion: '0.9.0', + }); + expect( + decidePassiveUpdateTarget('1.0.0', '2.0.0', manifest, 'device-a', secondsAfterPublish(60)), + ).toMatchObject({ target: null, reason: 'held' }); + }); + + it('behaves exactly as before when the manifest declares no minRequiredVersion', () => { + const held = makeManifest({ rollout: [{ percent: 100, delaySeconds: 86_400 }] }); + expect( + decidePassiveUpdateTarget('1.0.0', '2.0.0', held, 'device-a', secondsAfterPublish(60)), + ).toMatchObject({ + target: null, + reason: 'held', + bucket: rolloutBucket('device-a', '2.0.0'), + }); + const immediate = makeManifest({ rollout: [{ percent: 100, delaySeconds: 0 }] }); + expect( + decidePassiveUpdateTarget('1.0.0', '2.0.0', immediate, 'device-a', secondsAfterPublish(60)), + ).toMatchObject({ target: { version: '2.0.0' }, reason: 'eligible' }); + }); }); describe('appendRolloutDecisionLog', () => { @@ -331,6 +414,21 @@ describe('experimental flag bypass', () => { }); }); + it('still reports experimental under bypass when below minRequiredVersion', () => { + const manifest = makeManifest({ + rollout: [{ percent: 100, delaySeconds: 86_400 }], + minRequiredVersion: '1.9.0', + }); + const decision = decidePassiveUpdateTarget('1.0.0', '2.0.0', manifest, 'device-a', now, true); + expect(decision).toMatchObject({ + target: { version: '2.0.0' }, + reason: 'experimental', + bucket: null, + delaySeconds: null, + eligibleAt: null, + }); + }); + it('still reports not-newer / no-latest under bypass', () => { expect(decidePassiveUpdateTarget('2.0.0', '2.0.0', heldManifest, 'device-a', now, true)).toMatchObject({ target: null, diff --git a/apps/pythinker-code/test/cli/update/select.test.ts b/apps/pythinker-code/test/cli/update/select.test.ts index a616a486..6eb72a71 100644 --- a/apps/pythinker-code/test/cli/update/select.test.ts +++ b/apps/pythinker-code/test/cli/update/select.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { selectUpdateTarget } from '#/cli/update/select'; +import { isTargetInstallable, selectUpdateTarget } from '#/cli/update/select'; +import type { UpdateManifest } from '#/cli/update/types'; describe('selectUpdateTarget', () => { it('returns the latest version when it is newer than current', () => { @@ -32,3 +33,51 @@ describe('selectUpdateTarget', () => { expect(selectUpdateTarget('0.5.0', '0.5.0-rc.1')).toBeNull(); }); }); + +describe('isTargetInstallable', () => { + const artifact = { + url: 'https://code.pythinker.com/pythinker-code-0.5.0.zip', + sha256: 'a'.repeat(64), + }; + + function manifestWithPlatforms(platforms: Record): UpdateManifest { + return { + version: '0.5.0', + publishedAt: '2020-01-01T00:00:00.000Z', + rollout: [], + platforms, + }; + } + + function manifestOmittingRunningTarget(): UpdateManifest { + const otherArch = process.arch === 'arm64' ? 'x64' : 'arm64'; + return manifestWithPlatforms({ [`${process.platform}-${otherArch}`]: artifact }); + } + + it('native: returns false when the manifest omits the running target', () => { + expect(isTargetInstallable('native', manifestOmittingRunningTarget())).toBe(false); + }); + + it('native: returns true when the manifest has an entry for the running target', () => { + expect( + isTargetInstallable('native', manifestWithPlatforms({ [`${process.platform}-${process.arch}`]: artifact })), + ).toBe(true); + }); + + it('native: returns true for a null manifest', () => { + expect(isTargetInstallable('native', null)).toBe(true); + }); + + it('native: returns true for a manifest with no platforms key', () => { + const manifest: UpdateManifest = { + version: '0.5.0', + publishedAt: '2020-01-01T00:00:00.000Z', + rollout: [], + }; + expect(isTargetInstallable('native', manifest)).toBe(true); + }); + + it('npm-global: returns true even when the manifest omits the running target', () => { + expect(isTargetInstallable('npm-global', manifestOmittingRunningTarget())).toBe(true); + }); +}); diff --git a/apps/pythinker-code/test/cli/upgrade.test.ts b/apps/pythinker-code/test/cli/upgrade.test.ts index 455df590..12f9b2c4 100644 --- a/apps/pythinker-code/test/cli/upgrade.test.ts +++ b/apps/pythinker-code/test/cli/upgrade.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; import { handleUpgrade } from '#/cli/sub/upgrade'; +import { emptyUpdateInstallState } from '#/cli/update/install-state'; +import type { UpdateInstallLockHandle } from '#/cli/update/install-lock'; import type { InstallPromptChoiceValue } from '#/cli/update/prompt'; -import type { InstallSource, UpdateCache } from '#/cli/update/types'; +import type { InstallSource, UpdateCache, UpdateInstallState } from '#/cli/update/types'; function cacheWith( version: string | null, @@ -16,6 +18,31 @@ function cacheWith( }; } +function platformManifest(version: string, platform: string): UpdateCache['manifest'] { + return { + version, + publishedAt: '2020-01-01T00:00:00.000Z', + rollout: [], + platforms: { + [platform]: { + url: `https://code.pythinker.com/pythinker-code-${version}.zip`, + sha256: 'a'.repeat(64), + }, + }, + }; +} + +/** A manifest advertising an artifact for a platform other than the running one. */ +function manifestOmittingRunningTarget(version: string): UpdateCache['manifest'] { + const otherArch = process.arch === 'arm64' ? 'x64' : 'arm64'; + return platformManifest(version, `${process.platform}-${otherArch}`); +} + +/** A manifest advertising an artifact for the running platform. */ +function manifestForRunningTarget(version: string): UpdateCache['manifest'] { + return platformManifest(version, `${process.platform}-${process.arch}`); +} + function captureOutput(): { stdout: string[]; stderr: string[]; @@ -43,6 +70,9 @@ function createDeps(overrides: { readonly isInteractive?: boolean; readonly promptForInstallChoice?: () => Promise; readonly installUpdate?: (source: InstallSource, version: string, platform: NodeJS.Platform) => Promise; + readonly readUpdateInstallState?: () => Promise; + readonly writeUpdateInstallState?: (state: UpdateInstallState) => Promise; + readonly tryAcquireUpdateInstallLock?: () => Promise; } = {}) { const installUpdate = overrides.installUpdate ?? @@ -60,6 +90,16 @@ function createDeps(overrides: { promptForInstallChoice: overrides.promptForInstallChoice ?? vi.fn().mockResolvedValue('install'), installUpdate, + readUpdateInstallState: + overrides.readUpdateInstallState ?? vi.fn().mockResolvedValue(emptyUpdateInstallState()), + writeUpdateInstallState: + overrides.writeUpdateInstallState ?? vi.fn().mockResolvedValue(undefined), + tryAcquireUpdateInstallLock: + overrides.tryAcquireUpdateInstallLock ?? + vi.fn().mockResolvedValue({ + filePath: '/tmp/pythinker-update-install.lock', + release: vi.fn().mockResolvedValue(undefined), + }), track: vi.fn(), logger: { info: vi.fn(), @@ -232,4 +272,187 @@ describe('handleUpgrade', () => { expect(deps.installUpdate).toHaveBeenCalledWith('npm-global', '0.5.0', 'darwin'); expect(stdout.join('')).toContain('Updated @pythoughts/pythinker-code to 0.5.0'); }); + + it('native: refuses the update when the manifest omits the running platform', async () => { + const { stdout, writable } = captureOutput(); + const deps = createDeps({ + latest: '0.5.0', + source: 'native', + manifest: manifestOmittingRunningTarget('0.5.0'), + }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(deps.detectInstallSource).toHaveBeenCalledTimes(1); + expect(deps.promptForInstallChoice).not.toHaveBeenCalled(); + expect(deps.installUpdate).not.toHaveBeenCalled(); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_no_update', expect.objectContaining({ + current_version: '0.4.0', + })); + expect(stdout.join('')).toContain('v0.5.0 is published but has no build for this platform yet.'); + }); + + it('native: installs when the manifest advertises the running platform', async () => { + const { stdout, writable } = captureOutput(); + const deps = createDeps({ + latest: '0.5.0', + source: 'native', + manifest: manifestForRunningTarget('0.5.0'), + }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(deps.installUpdate).toHaveBeenCalledWith('native', '0.5.0', 'darwin'); + expect(stdout.join('')).toContain('Updated @pythoughts/pythinker-code to 0.5.0'); + }); + + it('npm-global: still installs when the manifest omits the running platform', async () => { + const { stdout, writable } = captureOutput(); + const deps = createDeps({ + latest: '0.5.0', + source: 'npm-global', + manifest: manifestOmittingRunningTarget('0.5.0'), + }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(deps.installUpdate).toHaveBeenCalledWith('npm-global', '0.5.0', 'darwin'); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_prompted', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + })); + expect(stdout.join('')).toContain('Updated @pythoughts/pythinker-code to 0.5.0'); + }); + + it('refuses the install while a fresh active install for another version is running', async () => { + const { stderr, writable } = captureOutput(); + const deps = createDeps({ + latest: '0.5.0', + source: 'npm-global', + readUpdateInstallState: vi.fn().mockResolvedValue({ + ...emptyUpdateInstallState(), + active: { + version: '0.5.1', + source: 'npm-global', + startedAt: new Date().toISOString(), + }, + }), + }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(1); + + expect(deps.installUpdate).not.toHaveBeenCalled(); + expect(deps.tryAcquireUpdateInstallLock).not.toHaveBeenCalled(); + expect(stderr.join('')).toContain('0.5.1'); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_failed', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + stage: 'install', + })); + }); + + it('refuses the install when another process holds the install lock', async () => { + const { stderr, writable } = captureOutput(); + const deps = createDeps({ + latest: '0.5.0', + source: 'npm-global', + tryAcquireUpdateInstallLock: vi.fn().mockResolvedValue(null), + }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(1); + + expect(deps.installUpdate).not.toHaveBeenCalled(); + expect(stderr.join('')).not.toBe(''); + expect(deps.track).toHaveBeenCalledWith('upgrade_command_failed', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + stage: 'install', + })); + }); + + it('takes the install lock, installs, and records the success', async () => { + const { stdout, writable } = captureOutput(); + const release = vi.fn().mockResolvedValue(undefined); + const deps = createDeps({ + latest: '0.5.0', + source: 'npm-global', + tryAcquireUpdateInstallLock: vi.fn().mockResolvedValue({ + filePath: '/tmp/pythinker-update-install.lock', + release, + }), + }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(deps.tryAcquireUpdateInstallLock).toHaveBeenCalledWith({ version: '0.5.0' }); + expect(deps.installUpdate).toHaveBeenCalledWith('npm-global', '0.5.0', 'darwin'); + expect(release).toHaveBeenCalledOnce(); + expect(deps.writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + active: null, + lastFailure: null, + lastSuccess: { + version: '0.5.0', + installedAt: expect.any(String), + notifiedAt: null, + }, + })); + expect(stdout.join('')).toContain('Updated @pythoughts/pythinker-code to 0.5.0'); + }); + + it('releases the lock and records the failure when the foreground install fails', async () => { + const { stderr, writable } = captureOutput(); + const release = vi.fn().mockResolvedValue(undefined); + const deps = createDeps({ + latest: '0.5.0', + source: 'npm-global', + installUpdate: vi.fn().mockRejectedValue(new Error('npm exited with code 1')), + tryAcquireUpdateInstallLock: vi.fn().mockResolvedValue({ + filePath: '/tmp/pythinker-update-install.lock', + release, + }), + readUpdateInstallState: vi.fn().mockResolvedValue({ + ...emptyUpdateInstallState(), + lastFailure: { + version: '0.5.0', + failedAt: '2026-04-23T08:00:00.000Z', + attempts: 1, + operation: 'install', + }, + }), + }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(1); + + expect(release).toHaveBeenCalledOnce(); + expect(deps.writeUpdateInstallState).toHaveBeenCalledWith(expect.objectContaining({ + active: null, + lastFailure: expect.objectContaining({ + version: '0.5.0', + attempts: 2, + operation: 'install', + failedAt: expect.any(String), + message: 'npm exited with code 1', + }), + })); + expect(stderr.join('')).toContain( + 'warning: failed to install @pythoughts/pythinker-code@0.5.0: npm exited with code 1', + ); + }); + + it('never writes an active record for the foreground install', async () => { + const { writable } = captureOutput(); + const writeUpdateInstallState = vi.fn().mockResolvedValue(undefined); + const deps = createDeps({ + latest: '0.5.0', + source: 'npm-global', + writeUpdateInstallState, + }); + + await expect(handleUpgrade('0.4.0', { ...deps, ...writable })).resolves.toBe(0); + + expect(writeUpdateInstallState).toHaveBeenCalledTimes(1); + expect(writeUpdateInstallState.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ + active: null, + })); + }); }); diff --git a/apps/pythinker-code/test/tui/commands/update-preferences.test.ts b/apps/pythinker-code/test/tui/commands/update-preferences.test.ts index e12012c4..fb688c83 100644 --- a/apps/pythinker-code/test/tui/commands/update-preferences.test.ts +++ b/apps/pythinker-code/test/tui/commands/update-preferences.test.ts @@ -8,12 +8,14 @@ import { handleOutputStyleCommand, handlePermissionsCommand, } from '#/tui/commands/config'; +import { handleUpdateCommand } from '#/tui/commands/info'; import { DEFAULT_STATUS_LINE_CONFIG } from '#/tui/config'; import { darkColors } from '#/tui/theme/colors'; const mocks = vi.hoisted(() => ({ disableTelemetry: vi.fn(), saveTuiConfig: vi.fn(), + startManualUpdate: vi.fn(), })); vi.mock('@pythoughts/pythinker-telemetry', async (importOriginal) => ({ @@ -31,6 +33,16 @@ vi.mock('../../../src/tui/config', async () => { }; }); +vi.mock('../../../src/cli/update/preflight', async (importOriginal) => { + const actual = await vi.importActual( + '../../../src/cli/update/preflight.js', + ); + return { + ...actual, + startManualUpdate: mocks.startManualUpdate, + }; +}); + describe('update preference commands', () => { it('persists telemetry opt-out and stops collection immediately', async () => { const host = { @@ -230,6 +242,133 @@ describe('output style commands', () => { }); }); +describe('update command', () => { + function makeHost() { + const host = { + state: { appState: { version: '0.9.0' } }, + showStatus: vi.fn(), + showNotice: vi.fn(), + showError: vi.fn(), + } as unknown as SlashCommandHost & { + showStatus: ReturnType; + showNotice: ReturnType; + showError: ReturnType; + }; + return host; + } + + it('keeps the existing wording for an in-progress update of the same version', async () => { + const host = makeHost(); + mocks.startManualUpdate.mockResolvedValue({ + status: 'in-progress', + installingVersion: '0.10.0', + installOnRestart: false, + readyToInstall: false, + }); + + await handleUpdateCommand(host, ''); + + expect(host.showNotice).toHaveBeenCalledWith( + 'Update to v0.10.0 already in progress', + 'Close this terminal and open a new one once it completes.', + ); + }); + + it('keeps the homebrew ready-to-install wording for a same-version in-progress update', async () => { + const host = makeHost(); + mocks.startManualUpdate.mockResolvedValue({ + status: 'in-progress', + installingVersion: '0.10.0', + installOnRestart: true, + readyToInstall: true, + }); + + await handleUpdateCommand(host, ''); + + expect(host.showNotice).toHaveBeenCalledWith( + 'Update to v0.10.0 already in progress', + 'Close this terminal and open a new one to install it.', + ); + }); + + it('announces the newer target when the running install is for an older version', async () => { + const host = makeHost(); + mocks.startManualUpdate.mockResolvedValue({ + status: 'in-progress', + installingVersion: '0.10.0', + targetVersion: '0.11.0', + installOnRestart: false, + readyToInstall: false, + }); + + await handleUpdateCommand(host, ''); + + expect(host.showNotice).toHaveBeenCalledWith( + 'Installing v0.10.0 — v0.11.0 will follow', + 'The running install of v0.10.0 finishes first; v0.11.0 installs after the next start.', + ); + }); + + it('reports a parked version as failed with the recorded reason', async () => { + const host = makeHost(); + mocks.startManualUpdate.mockResolvedValue({ + status: 'failed', + version: '0.10.0', + attempts: 2, + failedAt: '2026-08-05T08:00:00.000Z', + message: 'npm exited with code 1', + command: 'npm install -g @pythoughts/pythinker-code@0.10.0', + }); + + await handleUpdateCommand(host, ''); + + expect(host.showError).toHaveBeenCalledWith( + 'Update to v0.10.0 failed after 2 attempts.\n' + + 'Reason: npm exited with code 1\n' + + 'To update manually, run: npm install -g @pythoughts/pythinker-code@0.10.0', + ); + }); + + it('reports a parked version as failed without a reason line', async () => { + const host = makeHost(); + mocks.startManualUpdate.mockResolvedValue({ + status: 'failed', + version: '0.10.0', + attempts: 2, + failedAt: '2026-08-05T08:00:00.000Z', + command: 'npm install -g @pythoughts/pythinker-code@0.10.0', + }); + + await handleUpdateCommand(host, ''); + + expect(host.showError).toHaveBeenCalledWith( + 'Update to v0.10.0 failed after 2 attempts.\n' + + 'To update manually, run: npm install -g @pythoughts/pythinker-code@0.10.0', + ); + }); + + it('truncates a very long recorded reason to one line and marks it truncated', async () => { + const host = makeHost(); + const longReason = `npm failed: ${'x'.repeat(5000)}`; + mocks.startManualUpdate.mockResolvedValue({ + status: 'failed', + version: '0.10.0', + attempts: 3, + failedAt: '2026-08-05T08:00:00.000Z', + message: longReason, + command: 'npm install -g @pythoughts/pythinker-code@0.10.0', + }); + + await handleUpdateCommand(host, ''); + + expect(host.showError).toHaveBeenCalledWith( + 'Update to v0.10.0 failed after 3 attempts.\n' + + `Reason: ${longReason.slice(0, 160)}… (truncated)\n` + + 'To update manually, run: npm install -g @pythoughts/pythinker-code@0.10.0', + ); + }); +}); + describe('permission rule commands', () => { function makeHost() { const session = { diff --git a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts index 721b898c..d73bea0a 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -78,6 +78,11 @@ interface RuntimeStateDriver extends StartupDriver { closeSession(reason: string): Promise; } +interface UpdatePollDriver extends StartupDriver { + startUpdateStatusPolling(): void; + stopUpdateStatusPolling(): void; +} + interface ThemeTrackingDriver extends StartupDriver { refreshTerminalThemeTracking(): void; } @@ -2008,3 +2013,83 @@ describe('startup feature parity baseline', () => { ).toBe(true); }); }); + +describe('footer update status poll', () => { + /** + * The poll is the only thing that puts an update into the footer, and it is + * wired from `finishStartup` — so nothing else in this suite would notice if + * it stopped dispatching. Drive it against real state files. + */ + it('dispatches availability and then live progress into the status row', async () => { + const home = mkdtempSync(join(tmpdir(), 'pk-footer-update-')); + vi.stubEnv('PYTHINKER_CODE_HOME', home); + const updates = join(home, 'updates'); + mkdirSync(updates, { recursive: true }); + const manifest = { + version: '9.9.9', + publishedAt: '2026-08-07T00:00:00.000Z', + rollout: [], + }; + writeFileSync( + join(updates, 'latest.json'), + JSON.stringify({ + source: 'cdn', + checkedAt: '2026-08-07T00:00:00.000Z', + latest: '9.9.9', + manifest, + }), + ); + + const presentation = new RecordingPresentation(); + const driver = new PythinkerTUI( + makeHarness() as never, + makeStartupInput(), + presentation, + ) as unknown as UpdatePollDriver; + + try { + driver.startUpdateStatusPolling(); + await vi.waitFor( + () => { + expect(footerStatusItems(presentation.footerModels.at(-1))).toContain('↑ v9.9.9'); + }, + { timeout: 10_000, interval: 50 }, + ); + + writeFileSync( + join(updates, 'install.json'), + JSON.stringify({ + active: { + version: '9.9.9', + source: 'native', + startedAt: new Date().toISOString(), + pid: process.pid, + progress: { + state: 'downloading', + percent: 42, + transferred: 5_320_000, + total: 12_600_000, + updatedAt: new Date().toISOString(), + }, + }, + pending: null, + lastFailure: null, + lastSuccess: null, + }), + ); + await vi.waitFor( + () => { + expect(footerStatusItems(presentation.footerModels.at(-1))).toContain( + '↓ v9.9.9 ▰▰▰▱▱▱▱▱ 42%', + ); + }, + { timeout: 10_000, interval: 50 }, + ); + } finally { + driver.stopUpdateStatusPolling(); + driver.state.footer.dispose(); + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); + } + }, 30_000); +}); diff --git a/apps/pythinker-code/test/tui/runtime/footer-model.test.ts b/apps/pythinker-code/test/tui/runtime/footer-model.test.ts index 5a9c6163..095f748a 100644 --- a/apps/pythinker-code/test/tui/runtime/footer-model.test.ts +++ b/apps/pythinker-code/test/tui/runtime/footer-model.test.ts @@ -14,6 +14,7 @@ import { type FooterEvent, type FooterStatus, type FooterStatusRowViewModel, + type FooterUpdate, } from '#/tui/runtime/footer/footer-model'; const CLOCK_MS = 90_000; @@ -470,4 +471,125 @@ describe('footer model', () => { /FooterModelCostRates|modelCostRates|formatModelRates|isValidRate|formatRate/, ); }); + + describe('update status row', () => { + function statusRowWithUpdate( + update: FooterUpdate, + statusLine: StatusLineConfig = hideAllStatusItems(), + ): FooterStatusRowViewModel { + const state = foldFooterEvents(createFooterState(), [ + { type: 'update.updated', update }, + ] satisfies readonly FooterEvent[]); + const row = selectFooterViewModel(state, CLOCK_MS, statusLine).rows.find( + (candidate) => candidate.kind === 'status', + ); + if (row?.kind !== 'status') throw new Error('Expected a status row'); + return row; + } + + it.each([ + [ + 'available', + { version: '0.11.0', state: 'available', percent: null }, + '↑ v0.11.0', + ], + [ + 'required', + { version: '0.11.0', state: 'required', percent: null }, + '↑ v0.11.0 required', + ], + [ + 'downloading with percent', + { version: '0.11.0', state: 'downloading', percent: 42 }, + '↓ v0.11.0 ▰▰▰▱▱▱▱▱ 42%', + ], + [ + 'downloading without percent', + { version: '0.11.0', state: 'downloading', percent: null }, + '↓ v0.11.0', + ], + [ + 'waiting', + { version: '0.11.0', state: 'waiting', percent: null }, + '↓ v0.11.0 waiting', + ], + [ + 'ready', + { version: '0.11.0', state: 'ready', percent: null }, + '↑ v0.11.0 restart to apply', + ], + [ + 'failed', + { version: '0.11.0', state: 'failed', percent: null }, + '↑ v0.11.0 failed', + ], + ] as const)('renders %s first in the status row', (_name, update, expected) => { + expect(statusRowWithUpdate(update).items).toEqual([expected]); + }); + + it.each([ + [0, '↓ v0.11.0 ▱▱▱▱▱▱▱▱ 0%'], + [100, '↓ v0.11.0 ▰▰▰▰▰▰▰▰ 100%'], + [-5, '↓ v0.11.0 ▱▱▱▱▱▱▱▱ 0%'], + [150, '↓ v0.11.0 ▰▰▰▰▰▰▰▰ 100%'], + ] as const)('clamps percent %s into the eight-cell bar', (percent, expected) => { + const items = statusRowWithUpdate({ + version: '0.11.0', + state: 'downloading', + percent, + }).items; + + expect(items).toEqual([expected]); + }); + + it('adds no item for an empty update and leaves the status row unchanged', () => { + const state = foldFooterEvents(createFooterState(), [ + { type: 'update.updated', update: { version: null, state: null, percent: null } }, + ] satisfies readonly FooterEvent[]); + const base = selectFooterViewModel(createFooterState(), CLOCK_MS).rows.at(-1); + const updated = selectFooterViewModel(state, CLOCK_MS).rows.at(-1); + + expect(updated).toEqual(base); + }); + + it('adds no item when the version is null', () => { + const row = statusRowWithUpdate({ + version: null, + state: 'available', + percent: null, + }); + + expect(row.items).toEqual([]); + }); + + it('keeps the update under the composer and out of the activity row', () => { + const state = foldFooterEvents(createFooterState(), [ + { + type: 'activity.updated', + activity: { + phase: 'thinking', + label: 'Thinking through the change', + spinnerActive: true, + spinnerFrame: '⠹', + }, + }, + { + type: 'update.updated', + update: { version: '0.11.0', state: 'downloading', percent: 42 }, + }, + ] satisfies readonly FooterEvent[]); + const rows = selectFooterViewModel(state, CLOCK_MS).rows; + + expect(rows.map((row) => row.kind)).toEqual(['activity', 'composer', 'status']); + expect(rows[0]).toMatchObject({ + kind: 'activity', + primary: '⠹ Thinking through the change', + indicators: [], + }); + expect(rows[2]).toMatchObject({ + kind: 'status', + items: ['↓ v0.11.0 ▰▰▰▱▱▱▱▱ 42%', '▱▱▱▱▱▱▱▱ 0%'], + }); + }); + }); }); diff --git a/apps/pythinker-code/test/tui/runtime/footer/update-status.test.ts b/apps/pythinker-code/test/tui/runtime/footer/update-status.test.ts new file mode 100644 index 00000000..bd33f483 --- /dev/null +++ b/apps/pythinker-code/test/tui/runtime/footer/update-status.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, it } from 'vitest'; + +import type { UpdateCache, UpdateInstallState } from '#/cli/update/types'; +import { footerUpdateFromState } from '#/tui/runtime/footer/update-status'; + +const CURRENT = '0.10.0'; +const NEWER = '0.11.0'; + +const EMPTY = { version: null, state: null, percent: null } as const; + +function installState( + overrides: Partial = {}, +): UpdateInstallState { + return { + active: null, + pending: null, + lastFailure: null, + lastSuccess: null, + ...overrides, + }; +} + +function cache(latest: string | null = NEWER): UpdateCache { + return { + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest, + manifest: null, + }; +} + +describe('footerUpdateFromState', () => { + it('shows downloading when an active install is newer and downloading', () => { + const state = installState({ + active: { + version: NEWER, + source: 'native', + startedAt: '2026-04-23T08:00:00.000Z', + progress: { + state: 'downloading', + percent: 42, + transferred: 5_320_000, + total: 12_600_000, + updatedAt: '2026-04-23T08:01:00.000Z', + }, + }, + }); + + expect(footerUpdateFromState(CURRENT, 'native', null, state)).toEqual({ + version: NEWER, + state: 'downloading', + percent: 42, + }); + }); + + it('shows waiting when an active install is newer and waiting', () => { + const state = installState({ + active: { + version: NEWER, + source: 'homebrew', + startedAt: '2026-04-23T08:00:00.000Z', + progress: { + state: 'waiting', + updatedAt: '2026-04-23T08:01:00.000Z', + }, + }, + }); + + expect(footerUpdateFromState(CURRENT, 'homebrew', null, state)).toEqual({ + version: NEWER, + state: 'waiting', + percent: null, + }); + }); + + it('ignores an active install without progress and falls through to nothing', () => { + const state = installState({ + active: { + version: NEWER, + source: 'native', + startedAt: '2026-04-23T08:00:00.000Z', + }, + }); + + expect(footerUpdateFromState(CURRENT, 'native', null, state)).toEqual(EMPTY); + }); + + it('ignores an active install older than the running version', () => { + const state = installState({ + active: { + version: CURRENT, + source: 'native', + startedAt: '2026-04-23T08:00:00.000Z', + progress: { + state: 'downloading', + percent: 42, + updatedAt: '2026-04-23T08:01:00.000Z', + }, + }, + }); + + expect(footerUpdateFromState(CURRENT, 'native', null, state)).toEqual(EMPTY); + }); + + it('shows ready after a newer version was installed', () => { + const state = installState({ + lastSuccess: { + version: NEWER, + installedAt: '2026-04-23T08:02:00.000Z', + notifiedAt: null, + }, + }); + + expect(footerUpdateFromState(CURRENT, 'native', null, state)).toEqual({ + version: NEWER, + state: 'ready', + percent: null, + }); + }); + + it('shows nothing when the last success is the running version', () => { + const state = installState({ + lastSuccess: { + version: CURRENT, + installedAt: '2026-04-23T08:02:00.000Z', + notifiedAt: null, + }, + }); + + expect(footerUpdateFromState(CURRENT, 'native', null, state)).toEqual(EMPTY); + }); + + it('shows failed after a newer install failed', () => { + const state = installState({ + lastFailure: { + version: NEWER, + failedAt: '2026-04-23T08:02:00.000Z', + attempts: 2, + }, + }); + + expect(footerUpdateFromState(CURRENT, 'native', null, state)).toEqual({ + version: NEWER, + state: 'failed', + percent: null, + }); + }); + + it('prefers the active install over a recorded success', () => { + const state = installState({ + active: { + version: NEWER, + source: 'native', + startedAt: '2026-04-23T08:00:00.000Z', + progress: { + state: 'downloading', + percent: 10, + updatedAt: '2026-04-23T08:01:00.000Z', + }, + }, + lastSuccess: { + version: NEWER, + installedAt: '2026-04-23T08:02:00.000Z', + notifiedAt: null, + }, + }); + + expect(footerUpdateFromState(CURRENT, 'native', null, state)).toEqual({ + version: NEWER, + state: 'downloading', + percent: 10, + }); + }); + + it('prefers a recorded success over a recorded failure', () => { + const state = installState({ + lastFailure: { + version: NEWER, + failedAt: '2026-04-23T08:02:00.000Z', + attempts: 1, + }, + lastSuccess: { + version: NEWER, + installedAt: '2026-04-23T08:03:00.000Z', + notifiedAt: null, + }, + }); + + expect(footerUpdateFromState(CURRENT, 'native', null, state)).toEqual({ + version: NEWER, + state: 'ready', + percent: null, + }); + }); + + it('prefers a recorded failure over an available target', () => { + const state = installState({ + lastFailure: { + version: NEWER, + failedAt: '2026-04-23T08:02:00.000Z', + attempts: 1, + }, + }); + + expect(footerUpdateFromState(CURRENT, 'native', cache(), state)).toEqual({ + version: NEWER, + state: 'failed', + percent: null, + }); + }); + + it('shows available when the cache targets a newer installable version', () => { + expect(footerUpdateFromState(CURRENT, 'native', cache(), installState())).toEqual({ + version: NEWER, + state: 'available', + percent: null, + }); + }); + + it('shows required when the cached manifest declares a minRequiredVersion above current', () => { + const requiredCache: UpdateCache = { + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: NEWER, + manifest: { + version: NEWER, + publishedAt: '2026-04-23T08:00:00.000Z', + rollout: [], + minRequiredVersion: '0.10.1', + }, + }; + + expect(footerUpdateFromState(CURRENT, 'native', requiredCache, installState())).toEqual({ + version: NEWER, + state: 'required', + percent: null, + }); + }); + + it('keeps available when the declared minRequiredVersion is at or below current', () => { + const baseManifest = { + version: NEWER, + publishedAt: '2026-04-23T08:00:00.000Z', + rollout: [], + }; + const atCurrent: UpdateCache = { + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: NEWER, + manifest: { ...baseManifest, minRequiredVersion: CURRENT }, + }; + const belowCurrent: UpdateCache = { + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: NEWER, + manifest: { ...baseManifest, minRequiredVersion: '0.9.0' }, + }; + + expect(footerUpdateFromState(CURRENT, 'native', atCurrent, installState())).toEqual({ + version: NEWER, + state: 'available', + percent: null, + }); + expect(footerUpdateFromState(CURRENT, 'native', belowCurrent, installState())).toEqual({ + version: NEWER, + state: 'available', + percent: null, + }); + }); + + it('still shows downloading when a required update is already in flight', () => { + const requiredCache: UpdateCache = { + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: NEWER, + manifest: { + version: NEWER, + publishedAt: '2026-04-23T08:00:00.000Z', + rollout: [], + minRequiredVersion: '0.10.1', + }, + }; + const state = installState({ + active: { + version: NEWER, + source: 'native', + startedAt: '2026-04-23T08:00:00.000Z', + progress: { + state: 'downloading', + percent: 42, + transferred: 5_320_000, + total: 12_600_000, + updatedAt: '2026-04-23T08:01:00.000Z', + }, + }, + }); + + expect(footerUpdateFromState(CURRENT, 'native', requiredCache, state)).toEqual({ + version: NEWER, + state: 'downloading', + percent: 42, + }); + }); + + it('shows nothing when the cache target is not installable from this source', () => { + const unavailableCache: UpdateCache = { + source: 'cdn', + checkedAt: '2026-04-23T08:00:00.000Z', + latest: NEWER, + manifest: { + version: NEWER, + publishedAt: '2026-04-23T08:00:00.000Z', + rollout: [], + platforms: {}, + }, + }; + + expect( + footerUpdateFromState(CURRENT, 'native', unavailableCache, installState()), + ).toEqual(EMPTY); + }); + + it('shows nothing when the cache is null', () => { + expect(footerUpdateFromState(CURRENT, 'native', null, installState())).toEqual(EMPTY); + }); + + it('shows nothing when the cache has no newer latest', () => { + expect(footerUpdateFromState(CURRENT, 'native', cache(CURRENT), installState())).toEqual( + EMPTY, + ); + }); +}); diff --git a/apps/pythinker-web/public/install.ps1 b/apps/pythinker-web/public/install.ps1 index b7aee99a..0c64344f 100644 --- a/apps/pythinker-web/public/install.ps1 +++ b/apps/pythinker-web/public/install.ps1 @@ -41,6 +41,15 @@ param( $InstallShUrl = "https://code.pythinker.com/pythinker-code/install.sh" $InstallPs1Url = "https://code.pythinker.com/pythinker-code/install.ps1" + # Network timeouts, in seconds. The installer owns retry (helpers re-invoke + # up to 3 times with backoff), so no client-side retry is used and each bound + # covers exactly one attempt. + # - Metadata requests (CDN version, GitHub API, checksum): 30s total. + # - Archive download: 600s total. Both share a 10s connect cap. + $ConnectTimeoutSeconds = 10 + $MetadataTimeoutSeconds = 30 + $ArchiveTimeoutSeconds = 600 + $previousOutputEncoding = $null $previousSecurityProtocol = $null $httpClient = $null @@ -302,8 +311,23 @@ Unix / macOS / Linux users: $handler = New-Object System.Net.Http.HttpClientHandler $handler.AllowAutoRedirect = $true + # Connect cap: 10s. HttpClientHandler.ConnectTimeout is available on + # PowerShell 7 (System.Net.Http on .NET Core) and on Windows PowerShell 5.1 + # hosts with .NET Framework 4.7.2+; the guard below skips it on older .NET + # Framework hosts, where the operation timeout still bounds the whole call. + try { + $handler.ConnectTimeout = [TimeSpan]::FromSeconds($ConnectTimeoutSeconds) + } catch { + # Older .NET Framework without ConnectTimeout: nothing to set; the + # operation timeout below still bounds the call. + } $client = New-Object System.Net.Http.HttpClient -ArgumentList $handler - $client.Timeout = [TimeSpan]::FromMinutes(15) + # Operation timeout: 30s, and never reassigned — the setter throws once the + # client has sent its first request. For the metadata calls, which read with + # ResponseContentRead, this covers the whole operation (request, headers and + # body) on both PowerShell 7 and Windows PowerShell 5.1. The archive + # download bounds itself with a cancellation token; see Download-File. + $client.Timeout = [TimeSpan]::FromSeconds($MetadataTimeoutSeconds) [void]$client.DefaultRequestHeaders.UserAgent.ParseAdd('Pythinker-Code-Installer/1.0') [void]$client.DefaultRequestHeaders.Accept.ParseAdd('*/*') return $client @@ -365,6 +389,7 @@ Unix / macOS / Linux users: $inputStream = $null $outputStream = $null $stopwatch = $null + $attemptCts = $null $received = [long]0 $frameIndex = 0 @@ -378,7 +403,22 @@ Unix / macOS / Linux users: try { if ($useAnimation) { Write-Host -NoNewline $HIDE_CURSOR } - $response = $Client.GetAsync($Uri, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() + # Archive download: bounded by a cancellation token, not by + # HttpClient.Timeout. Two reasons the property cannot do this job. + # First, its setter throws InvalidOperationException once the client has + # sent a request, and metadata calls have already run by the time we get + # here. Second, with ResponseHeadersRead it only bounds the wait for the + # headers, never the streaming body — so a connection that accepts and + # then stops would hang the installer forever, with its pid still + # recorded as the active update. + # One token covers the header wait and every read below. + # Total ceiling only, no per-read stall guard: curl's --speed-time + # equivalent needs a token per read. Add it if a 600s trickle ever + # shows up in the wild. + $attemptCts = New-Object System.Threading.CancellationTokenSource + $attemptCts.CancelAfter([TimeSpan]::FromSeconds($ArchiveTimeoutSeconds)) + + $response = $Client.GetAsync($Uri, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead, $attemptCts.Token).GetAwaiter().GetResult() if (-not $response.IsSuccessStatusCode) { $status = [int]$response.StatusCode throw "$Label failed with HTTP $status $($response.ReasonPhrase)" @@ -398,7 +438,7 @@ Unix / macOS / Linux users: $lastRenderMilliseconds = [long]-1000 while ($true) { - $read = $inputStream.Read($buffer, 0, $buffer.Length) + $read = $inputStream.ReadAsync($buffer, 0, $buffer.Length, $attemptCts.Token).GetAwaiter().GetResult() if ($read -le 0) { break } $outputStream.Write($buffer, 0, $read) @@ -434,6 +474,7 @@ Unix / macOS / Linux users: if ($null -ne $outputStream) { $outputStream.Dispose() } if ($null -ne $inputStream) { $inputStream.Dispose() } if ($null -ne $response) { $response.Dispose() } + if ($null -ne $attemptCts) { $attemptCts.Dispose() } if ($null -ne $stopwatch -and $stopwatch.IsRunning) { $stopwatch.Stop() } Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue if ($useAnimation) { diff --git a/apps/pythinker-web/public/install.sh b/apps/pythinker-web/public/install.sh index 9d5998d0..b46ee1b1 100755 --- a/apps/pythinker-web/public/install.sh +++ b/apps/pythinker-web/public/install.sh @@ -28,6 +28,30 @@ NO_COLOR="${NO_COLOR:-}" REPO="Pythoughts-labs/pythinker-code" CDN_LATEST_URL="https://code.pythinker.com/pythinker-code/latest" +# Network timeout policy. The script owns retry — _download_with_progress and +# _download_quiet_with_retry re-invoke the helpers up to 3 times with backoff — +# so curl and wget must not add their own retry: curl's --max-time counter +# resets on every --retry attempt, which would let one logical attempt run far +# beyond its budget. With retry left to the script, --max-time bounds exactly +# one attempt, which is what the retry loops expect. +# +# Metadata requests (_content_length, _fetch): 10s connect, 30s total. +# Archive downloads (_download_quiet, _start_download): 10s connect, 600s total, +# plus a stall guard that aborts when throughput stays under 1024 bytes/s for +# 30s — a connection that is alive but crawling would otherwise burn the whole +# 600s budget. +# wget gets `-T` and nothing else on purpose. GNU's --connect-timeout / +# --read-timeout / --tries do not exist in BusyBox wget, which is the only wget +# on Alpine-class systems, and an unrecognized option there aborts the install +# outright — turning a working fallback into a hard failure. `-T` is understood +# by both: GNU treats it as dns+connect+read at once, BusyBox as the network +# read timeout. It is an inactivity bound, not a total one, so it is sized for +# "this connection is dead", not for the whole transfer. +CURL_META_OPTS=(--connect-timeout 10 --max-time 30) +WGET_META_OPTS=(-T 30) +CURL_ARCHIVE_OPTS=(--connect-timeout 10 --max-time 600 --speed-limit 1024 --speed-time 30) +WGET_ARCHIVE_OPTS=(-T 60) + # Operational globals are populated by main(). Keeping rendering helpers at # file scope makes the installer sourceable for regression tests and tooling. target="" @@ -322,7 +346,7 @@ _current_file_size() { _content_length() { local url="$1" command -v curl >/dev/null 2>&1 || return 1 - curl -fsIL "$url" 2>/dev/null \ + curl -fsIL "${CURL_META_OPTS[@]}" "$url" 2>/dev/null \ | awk 'tolower($1) == "content-length:" { gsub("\r", "", $2) bytes = $2 @@ -534,9 +558,9 @@ print_done() { _fetch() { local url="$1" if command -v curl >/dev/null 2>&1; then - curl -fsSL "$url" + curl -fsSL "${CURL_META_OPTS[@]}" "$url" elif command -v wget >/dev/null 2>&1; then - wget -qO- "$url" + wget -qO- "${WGET_META_OPTS[@]}" "$url" else return 127 fi @@ -545,9 +569,9 @@ _fetch() { _download_quiet() { local url="$1" output="$2" if command -v curl >/dev/null 2>&1; then - curl -fsSL "$url" -o "$output" + curl -fsSL "${CURL_ARCHIVE_OPTS[@]}" "$url" -o "$output" elif command -v wget >/dev/null 2>&1; then - wget -q "$url" -O "$output" + wget -q "${WGET_ARCHIVE_OPTS[@]}" "$url" -O "$output" else return 127 fi @@ -558,9 +582,9 @@ _start_download() { DOWNLOAD_PID="" if command -v curl >/dev/null 2>&1; then - curl -fsSL "$url" -o "$output" & + curl -fsSL "${CURL_ARCHIVE_OPTS[@]}" "$url" -o "$output" & elif command -v wget >/dev/null 2>&1; then - wget -q "$url" -O "$output" & + wget -q "${WGET_ARCHIVE_OPTS[@]}" "$url" -O "$output" & else return 127 fi @@ -568,19 +592,83 @@ _start_download() { DOWNLOAD_PID=$! } +# Machine-readable progress for the parent process. The background installer +# has no TTY, so stdout stays human-only (and is discarded by the spawn) and +# stderr carries the protocol: one newline-terminated line per update. +_emit_download_progress() { + local percent="${1:-}" current="$2" total="$3" + if [[ -n "$percent" ]]; then + printf 'progress: state=downloading percent=%s transferred=%s total=%s\n' \ + "$percent" "$current" "$total" >&2 + else + printf 'progress: state=downloading transferred=%s\n' "$current" >&2 + fi +} + # One download attempt with a live progress display. Returns non-zero on # transport failure, an empty file, or a size short of Content-Length. _download_attempt_with_progress() { local url="$1" output="$2" - local total="" pid="" current=0 percent=0 i=0 frame_index=0 + local total="" pid="" current=0 percent="" last_percent="-1" i=0 last_emit_i=-100 frame_index=0 local -a frames=('◐' '◓' '◑' '◒') rm -f "$output" if [[ -z "$_anim" ]]; then - _download_quiet "$url" "$output" || return 1 - _validate_download "$output" "" || return 1 + # Background install: no TTY, so no ANSI. Poll the same way as the + # animated branch, but report machine-readable lines on stderr instead of + # rendering a bar. One line per second at most, and only when the integer + # percent moved; the parent records these at most every 2s, so the + # protocol stays far below the parent's throttle. + if command -v curl >/dev/null 2>&1; then + total="$(_content_length "$url" || true)" + fi + + # `|| {...}` and not `if ! …`: after `if ! cmd`, `$?` inside the branch is + # the negation's 0, so the real failure code would be reported as success. + _start_download "$url" "$output" || { + local start_rc=$? + printf 'progress: state=failed\n' >&2 + return "$start_rc" + } + pid="$DOWNLOAD_PID" + + while kill -0 "$pid" 2>/dev/null; do + current="$(_current_file_size "$output")" + + if [[ "$total" =~ ^[0-9]+$ ]] && (( total > 0 )); then + percent="$(_download_percent "$output" "$total" || printf '0')" + else + percent="" + fi + + # An unknown size has no percent to change, so it emits on the interval + # alone — otherwise a wget-only host would show one line and then look + # frozen for the whole download. + if (( i - last_emit_i >= 9 )) && [[ -z "$percent" || "$percent" != "$last_percent" ]]; then + _emit_download_progress "$percent" "$current" "$total" + last_percent="$percent" + last_emit_i="$i" + fi + + sleep 0.12 + i=$((i + 1)) + done + + if ! wait "$pid"; then + DOWNLOAD_PID="" + printf 'progress: state=failed\n' >&2 + return 1 + fi + DOWNLOAD_PID="" + + if ! _validate_download "$output" "$total"; then + printf 'progress: state=failed\n' >&2 + return 1 + fi + current="$(_current_file_size "$output")" + printf 'progress: state=done transferred=%s\n' "$current" >&2 status_ok 'Download complete' "$(_format_bytes "$current")" return 0 fi @@ -763,6 +851,7 @@ The release may still be publishing. Try again shortly, or pin a known-good vers _render_waiting "${frames[$((attempt % 4))]}" "$delay" else printf ' Waiting for release assets; retrying in %ss\n' "$delay" + printf 'progress: state=waiting retry_in=%s elapsed=%s\n' "$delay" "$elapsed" >&2 fi sleep "$delay" diff --git a/apps/site/public/install.ps1 b/apps/site/public/install.ps1 deleted file mode 100644 index b7aee99a..00000000 --- a/apps/site/public/install.ps1 +++ /dev/null @@ -1,924 +0,0 @@ -# Pythinker Code — native Windows installer. -# -# Downloads the native single-file binary (pythinker-code-win32-.zip) -# from the GitHub Release matching the CDN's latest version, verifies its -# SHA-256, and installs pythinker.exe to %LOCALAPPDATA%\Programs\Pythinker -# (added to the user PATH). -# -# Usage: -# irm https://code.pythinker.com/pythinker-code/install.ps1 | iex -# -# To pin a version when running the hosted script, set: -# $env:PYTHINKER_VERSION = "0.6.0"; irm https://code.pythinker.com/pythinker-code/install.ps1 | iex -# -# Or run the script directly: -# .\install.ps1 -Version 0.6.0 -# -# Terminal controls: -# $env:PYTHINKER_NO_ANIMATION = "1" # Disable motion, keep concise output. -# $env:NO_COLOR = "1" # Disable ANSI colors. - -[CmdletBinding()] -param( - [string]$Version = $env:PYTHINKER_VERSION, - [switch]$Help -) - -# Invoke the implementation in a child scope. This matters for the hosted -# `irm ... | iex` form: functions, preferences, and temporary variables must -# not leak into the caller's interactive PowerShell session. -& { - param( - [string]$RequestedVersion, - [bool]$ShowHelp - ) - - $ErrorActionPreference = "Stop" - Set-StrictMode -Version 2.0 - - $Repo = "Pythoughts-labs/pythinker-code" - $CdnLatestUrl = "https://code.pythinker.com/pythinker-code/latest" - $InstallShUrl = "https://code.pythinker.com/pythinker-code/install.sh" - $InstallPs1Url = "https://code.pythinker.com/pythinker-code/install.ps1" - - $previousOutputEncoding = $null - $previousSecurityProtocol = $null - $httpClient = $null - $installMutex = $null - $mutexHeld = $false - $tempDir = $null - $stagingBinary = $null - $backupBinary = $null - $targetPath = $null - - try { $previousOutputEncoding = [Console]::OutputEncoding } catch {} - try { $previousSecurityProtocol = [Net.ServicePointManager]::SecurityProtocol } catch {} - - try { - [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) - } catch {} - - # Add TLS 1.2 without discarding newer protocols selected by the host. - try { - $currentProtocols = [Net.ServicePointManager]::SecurityProtocol - $tls12 = [Net.SecurityProtocolType]::Tls12 - if (($currentProtocols -band $tls12) -eq 0) { - [Net.ServicePointManager]::SecurityProtocol = $currentProtocols -bor $tls12 - } - } catch {} - - function Test-EnvironmentVariablePresent([string]$Name) { - return $null -ne [Environment]::GetEnvironmentVariable($Name, 'Process') - } - - function Test-InteractiveTerminal { - try { - if ([Console]::IsOutputRedirected) { return $false } - $null = $Host.UI.RawUI.WindowSize - return $true - } catch { - return $false - } - } - - function Test-AnsiSupport { - if (-not (Test-InteractiveTerminal)) { return $false } - - $term = [Environment]::GetEnvironmentVariable('TERM', 'Process') - if ($term -and $term -ieq 'dumb') { return $false } - - try { - if ([bool]$Host.UI.SupportsVirtualTerminal) { return $true } - } catch {} - - if (Test-EnvironmentVariablePresent 'WT_SESSION') { return $true } - if (Test-EnvironmentVariablePresent 'ANSICON') { return $true } - if ($env:ConEmuANSI -eq 'ON') { return $true } - if ($term -and $term -match '(?i)(xterm|ansi|screen|cygwin|msys|vt100)') { return $true } - - return $false - } - - $interactiveTerminal = Test-InteractiveTerminal - $ansiSupported = Test-AnsiSupport - $useColor = $ansiSupported -and -not (Test-EnvironmentVariablePresent 'NO_COLOR') - $useAnimation = $ansiSupported ` - -and $interactiveTerminal ` - -and -not (Test-EnvironmentVariablePresent 'CI') ` - -and -not (Test-EnvironmentVariablePresent 'PYTHINKER_NO_ANIMATION') - - $ESC = [char]27 - $NAVY = $FACE = $ACCENT = $TIP = $EYE = $BAR = $DIM = $BOLD = $RESET = $SHINE = $SOFT = $ERROR_COLOR = "" - if ($useColor) { - $NAVY = "$ESC[38;5;24m" - $FACE = "$ESC[38;5;255m" - $ACCENT = "$ESC[38;5;147m" - $TIP = "$ESC[38;5;216m" - $EYE = "$ESC[38;5;189m" - $BAR = "$ESC[38;5;250m" - $DIM = "$ESC[2m" - $BOLD = "$ESC[1m" - $RESET = "$ESC[0m" - $SHINE = "$ESC[38;5;231m" - $SOFT = "$ESC[38;5;111m" - $ERROR_COLOR = "$ESC[38;5;203m" - } - - $HIDE_CURSOR = "" - $SHOW_CURSOR = "" - $CLEAR_LINE = "" - if ($useAnimation) { - $HIDE_CURSOR = "$ESC[?25l" - $SHOW_CURSOR = "$ESC[?25h" - $CLEAR_LINE = "$ESC[2K" - } - - function Stop-Installer([string]$Message) { - throw "Pythinker Code install failed: $Message" - } - - function Show-Usage { - @" -Pythinker Code — native Windows installer. - -Downloads the native single-file binary (pythinker-code-win32-.zip) -from the GitHub Release matching the CDN's latest version, verifies its -SHA-256, and installs pythinker.exe to %LOCALAPPDATA%\Programs\Pythinker -(added to the user PATH). - -Usage: - irm $InstallPs1Url | iex - - # Pin a version: - `$env:PYTHINKER_VERSION = "0.6.0"; irm $InstallPs1Url | iex - - # Or run directly: - .\install.ps1 -Version 0.6.0 - -Terminal controls: - `$env:PYTHINKER_NO_ANIMATION = "1" # Disable motion. - `$env:NO_COLOR = "1" # Disable ANSI colors. - -Unix / macOS / Linux users: - curl -fsSL $InstallShUrl | bash -"@ - } - - function Get-TerminalWidth { - $width = 80 - try { $width = [int]$Host.UI.RawUI.WindowSize.Width } catch {} - return [Math]::Max(48, [Math]::Min(120, $width)) - } - - function Get-AnimationDelay([string]$EnvironmentName, [int]$DefaultMilliseconds) { - $raw = [Environment]::GetEnvironmentVariable($EnvironmentName, 'Process') - if (-not $raw) { return $DefaultMilliseconds } - - try { - $milliseconds = [int]([double]$raw * 1000) - return [Math]::Max(0, [Math]::Min(2000, $milliseconds)) - } catch { - return $DefaultMilliseconds - } - } - - function Write-Logo { - $frameDelay = Get-AnimationDelay 'PYTHINKER_LOGO_FRAME_DELAY' 45 - $taglineDelay = Get-AnimationDelay 'PYTHINKER_LOGO_STAGGER_DELAY' 14 - - $logo = @( - " ${TIP}●${RESET}", - " ${NAVY}│${RESET}", - " ${NAVY}▛${RESET}${FACE}▀▀▀▀▀▀▀${RESET}${NAVY}▜${RESET}", - " ${TIP}◖${RESET}${NAVY}█${RESET} ${EYE}◉${RESET} ${EYE}◉${RESET} ${NAVY}█${RESET}${TIP}◗${RESET}", - " ${NAVY}▙▄▄▄${RESET}${FACE}≡${RESET}${NAVY}▄▄▄▟${RESET}" - ) - - Write-Host "" - foreach ($line in $logo) { - Write-Host $line - if ($useAnimation -and $frameDelay -gt 0) { - Start-Sleep -Milliseconds $frameDelay - } - } - - $tagline = "Pythinker Code Think first. Then code." - Write-Host "" - Write-Host -NoNewline " " - if ($useAnimation) { - foreach ($character in $tagline.ToCharArray()) { - Write-Host -NoNewline $character - if ($taglineDelay -gt 0) { Start-Sleep -Milliseconds $taglineDelay } - } - Write-Host "" - } else { - Write-Host $tagline - } - Write-Host "" - } - - function Write-MetadataRow([string]$Label, [string]$Value) { - Write-Host (" {0}{1,-10}{2} {3}" -f $DIM, $Label, $RESET, $Value) - } - - function Write-PhaseOk([string]$Label, [string]$Detail) { - $suffix = if ($Detail) { " ${DIM}$Detail${RESET}" } else { "" } - Write-Host (" ${ACCENT}✓${RESET} {0,-10}{1}" -f $Label, $suffix) - } - - function Write-PhaseInfo([string]$Label, [string]$Detail) { - Write-Host (" ${SOFT}•${RESET} {0,-10} ${DIM}{1}${RESET}" -f $Label, $Detail) - } - - function Write-RetryLine([string]$Label, [int]$Attempt, [int]$DelaySeconds, [string]$Reason) { - if ($useAnimation) { - Write-Host -NoNewline ("`r${CLEAR_LINE}") - } - Write-Host (" ${TIP}↻${RESET} {0,-10} retry {1}/3 in {2}s ${DIM}{3}${RESET}" -f $Label, $Attempt, $DelaySeconds, $Reason) - } - - function Format-ByteSize([long]$Bytes) { - if ($Bytes -lt 1024) { return "$Bytes B" } - if ($Bytes -lt 1MB) { return ("{0:N1} KB" -f ($Bytes / 1KB)) } - if ($Bytes -lt 1GB) { return ("{0:N1} MB" -f ($Bytes / 1MB)) } - return ("{0:N2} GB" -f ($Bytes / 1GB)) - } - - function Write-DownloadStarted([string]$Label) { - Write-Host (" ${SOFT}↓${RESET} {0,-10} ${DIM}starting…${RESET}" -f $Label) - } - - function Write-DownloadProgress( - [long]$ReceivedBytes, - $TotalBytes, - [double]$ElapsedSeconds, - [int]$FrameIndex - ) { - if (-not $useAnimation) { return } - - $spinnerFrames = @('●', '◐', '◓', '◑', '◒') - $spinner = $spinnerFrames[$FrameIndex % $spinnerFrames.Length] - $terminalWidth = Get-TerminalWidth - $barWidth = [Math]::Max(12, [Math]::Min(40, $terminalWidth - 44)) - $rate = if ($ElapsedSeconds -gt 0.05) { [long]($ReceivedBytes / $ElapsedSeconds) } else { 0 } - $rateText = if ($rate -gt 0) { "$(Format-ByteSize $rate)/s" } else { "—/s" } - - if ($null -ne $TotalBytes -and [long]$TotalBytes -gt 0) { - $total = [long]$TotalBytes - $percent = [Math]::Min(100, [Math]::Floor(($ReceivedBytes * 100.0) / $total)) - $filled = [int][Math]::Floor(($percent * $barWidth) / 100) - $empty = $barWidth - $filled - $barText = ("━" * $filled) + ("─" * $empty) - $metrics = "{0,3}% {1}/{2} {3}" -f $percent, (Format-ByteSize $ReceivedBytes), (Format-ByteSize $total), $rateText - $line = " ${ACCENT}${spinner}${RESET} Download ${BAR}${barText}${RESET} $metrics" - } else { - $position = $FrameIndex % $barWidth - $left = "─" * $position - $rightCount = [Math]::Max(0, $barWidth - $position - 1) - $right = "─" * $rightCount - $barText = "${left}${SHINE}◆${RESET}${BAR}${right}" - $line = " ${ACCENT}${spinner}${RESET} Download ${BAR}${barText}${RESET} $(Format-ByteSize $ReceivedBytes) $rateText" - } - - Write-Host -NoNewline ("`r${CLEAR_LINE}${line}") - } - - function Write-DownloadComplete([long]$Bytes, [double]$ElapsedSeconds) { - if ($useAnimation) { - Write-Host -NoNewline ("`r${CLEAR_LINE}") - } - - $duration = [Math]::Max(0.01, $ElapsedSeconds) - $averageRate = [long]($Bytes / $duration) - Write-Host (" ${ACCENT}✓${RESET} {0,-10} {1} ${DIM}in {2:N1}s · {3}/s${RESET}" -f 'Download', (Format-ByteSize $Bytes), $duration, (Format-ByteSize $averageRate)) - } - - function New-InstallerHttpClient { - try { - Add-Type -AssemblyName System.Net.Http -ErrorAction Stop - } catch { - Stop-Installer "System.Net.Http is unavailable: $($_.Exception.Message)" - } - - $handler = New-Object System.Net.Http.HttpClientHandler - $handler.AllowAutoRedirect = $true - $client = New-Object System.Net.Http.HttpClient -ArgumentList $handler - $client.Timeout = [TimeSpan]::FromMinutes(15) - [void]$client.DefaultRequestHeaders.UserAgent.ParseAdd('Pythinker-Code-Installer/1.0') - [void]$client.DefaultRequestHeaders.Accept.ParseAdd('*/*') - return $client - } - - function Get-HttpTextOnce($Client, [string]$Uri, [string]$Description) { - $response = $null - try { - $response = $Client.GetAsync($Uri, [System.Net.Http.HttpCompletionOption]::ResponseContentRead).GetAwaiter().GetResult() - if (-not $response.IsSuccessStatusCode) { - $status = [int]$response.StatusCode - throw "$Description failed with HTTP $status $($response.ReasonPhrase)" - } - return $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() - } finally { - if ($null -ne $response) { $response.Dispose() } - } - } - - function Get-HttpText($Client, [string]$Uri, [string]$Description) { - $lastError = $null - for ($attempt = 1; $attempt -le 3; $attempt++) { - try { - return Get-HttpTextOnce $Client $Uri $Description - } catch { - $lastError = $_.Exception.Message - if ($attempt -lt 3) { - $delay = [Math]::Pow(2, $attempt - 1) - Write-RetryLine $Description ($attempt + 1) ([int]$delay) $lastError - Start-Sleep -Seconds $delay - } - } - } - throw "$Description failed after 3 attempts: $lastError" - } - - function Get-HttpJson($Client, [string]$Uri, [switch]$AllowNotFound) { - $response = $null - try { - $response = $Client.GetAsync($Uri, [System.Net.Http.HttpCompletionOption]::ResponseContentRead).GetAwaiter().GetResult() - $status = [int]$response.StatusCode - if ($AllowNotFound -and $status -eq 404) { return $null } - if (-not $response.IsSuccessStatusCode) { - throw "GitHub API failed with HTTP $status $($response.ReasonPhrase)" - } - $json = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() - return $json | ConvertFrom-Json - } finally { - if ($null -ne $response) { $response.Dispose() } - } - } - - function Download-File($Client, [string]$Uri, [string]$Destination, [string]$Label) { - $lastError = $null - - for ($attempt = 1; $attempt -le 3; $attempt++) { - $partialPath = "$Destination.part" - $response = $null - $inputStream = $null - $outputStream = $null - $stopwatch = $null - $received = [long]0 - $frameIndex = 0 - - Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $Destination -Force -ErrorAction SilentlyContinue - - if (-not $useAnimation) { - Write-DownloadStarted $Label - } - - try { - if ($useAnimation) { Write-Host -NoNewline $HIDE_CURSOR } - - $response = $Client.GetAsync($Uri, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead).GetAwaiter().GetResult() - if (-not $response.IsSuccessStatusCode) { - $status = [int]$response.StatusCode - throw "$Label failed with HTTP $status $($response.ReasonPhrase)" - } - - $totalBytes = $response.Content.Headers.ContentLength - $inputStream = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() - $outputStream = [System.IO.File]::Open( - $partialPath, - [System.IO.FileMode]::Create, - [System.IO.FileAccess]::Write, - [System.IO.FileShare]::None - ) - - $buffer = New-Object byte[] 131072 - $stopwatch = [Diagnostics.Stopwatch]::StartNew() - $lastRenderMilliseconds = [long]-1000 - - while ($true) { - $read = $inputStream.Read($buffer, 0, $buffer.Length) - if ($read -le 0) { break } - - $outputStream.Write($buffer, 0, $read) - $received += $read - - if ($useAnimation -and ($stopwatch.ElapsedMilliseconds - $lastRenderMilliseconds) -ge 80) { - Write-DownloadProgress $received $totalBytes $stopwatch.Elapsed.TotalSeconds $frameIndex - $lastRenderMilliseconds = $stopwatch.ElapsedMilliseconds - $frameIndex++ - } - } - - $outputStream.Flush($true) - $outputStream.Dispose() - $outputStream = $null - $inputStream.Dispose() - $inputStream = $null - $response.Dispose() - $response = $null - $stopwatch.Stop() - - if ($null -ne $totalBytes -and [long]$totalBytes -gt 0 -and $received -ne [long]$totalBytes) { - throw "$Label was truncated: expected $totalBytes bytes, received $received" - } - if ($received -le 0) { throw "$Label returned an empty file" } - - [System.IO.File]::Move($partialPath, $Destination) - Write-DownloadComplete $received $stopwatch.Elapsed.TotalSeconds - return - } catch { - $lastError = $_.Exception.Message - } finally { - if ($null -ne $outputStream) { $outputStream.Dispose() } - if ($null -ne $inputStream) { $inputStream.Dispose() } - if ($null -ne $response) { $response.Dispose() } - if ($null -ne $stopwatch -and $stopwatch.IsRunning) { $stopwatch.Stop() } - Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue - if ($useAnimation) { - Write-Host -NoNewline ("`r${CLEAR_LINE}${SHOW_CURSOR}") - } - } - - if ($attempt -lt 3) { - $delay = [Math]::Pow(2, $attempt - 1) - Write-RetryLine $Label ($attempt + 1) ([int]$delay) $lastError - Start-Sleep -Seconds $delay - } - } - - Stop-Installer "$Label failed after 3 attempts: $lastError" - } - - function Test-Version([string]$Candidate) { - return $Candidate -match '^\d+\.\d+\.\d+$' - } - - function Get-ReleaseTag([string]$ResolvedVersion) { - return "@pythoughts/pythinker-code@$ResolvedVersion" - } - - function Get-EncodedReleaseTag([string]$ResolvedVersion) { - return [uri]::EscapeDataString((Get-ReleaseTag $ResolvedVersion)) - } - - function Test-ReleaseHasAsset($Release, [string]$AssetName) { - if ($null -eq $Release) { return $false } - if ($Release.draft -or $Release.prerelease) { return $false } - $names = @($Release.assets | ForEach-Object { [string]$_.name }) - return (($names -contains $AssetName) -and ($names -contains "$AssetName.sha256")) - } - - function Get-LatestVersion($Client) { - try { - $raw = Get-HttpText $Client $CdnLatestUrl 'CDN latest version' - $candidate = ([string]$raw).Trim().Trim('"') - if (Test-Version $candidate) { return $candidate } - } catch { - Write-PhaseInfo 'Version' 'CDN unavailable; using GitHub release metadata' - } - - $latestApi = "https://api.github.com/repos/$Repo/releases/latest" - try { - $latest = Get-HttpJson $Client $latestApi - $tag = [string]$latest.tag_name - if ($tag -match '^@pythoughts/pythinker-code@(\d+\.\d+\.\d+)$') { - return $Matches[1] - } - Stop-Installer "could not parse latest release tag '$tag' from GitHub" - } catch { - Stop-Installer "could not resolve the latest version: $($_.Exception.Message)" - } - } - - function Wait-ReleaseAssets($Client, [string]$ResolvedVersion, [string]$AssetName) { - $api = "https://api.github.com/repos/$Repo/releases/tags/$(Get-EncodedReleaseTag $ResolvedVersion)" - $delay = 4 - $elapsed = 0 - $maxElapsed = 360 - $frame = 0 - $lastError = $null - - while ($true) { - try { - $release = Get-HttpJson $Client $api -AllowNotFound - if (Test-ReleaseHasAsset $release $AssetName) { - if ($useAnimation) { Write-Host -NoNewline ("`r${CLEAR_LINE}") } - Write-PhaseOk 'Release' 'assets ready' - return - } - } catch { - $lastError = $_.Exception.Message - if ($lastError -match 'HTTP (401|403)') { - Stop-Installer $lastError - } - } - - if ($elapsed -ge $maxElapsed) { - $detail = if ($lastError) { " Last error: $lastError" } else { "" } - Stop-Installer "release assets for $ResolvedVersion were not available after ${maxElapsed}s.$detail" - } - - if ($useAnimation) { - $waitFrames = @('◐', '◓', '◑', '◒') - for ($remaining = $delay; $remaining -gt 0; $remaining--) { - $glyph = $waitFrames[$frame % $waitFrames.Length] - Write-Host -NoNewline ("`r${CLEAR_LINE} ${ACCENT}${glyph}${RESET} Release ${DIM}waiting for assets · retry in ${remaining}s${RESET}") - Start-Sleep -Seconds 1 - $elapsed++ - $frame++ - if ($elapsed -ge $maxElapsed) { break } - } - } else { - Write-Host (" ${SOFT}•${RESET} Release waiting for assets; retrying in ${delay}s") - Start-Sleep -Seconds $delay - $elapsed += $delay - } - - $delay = [Math]::Min($delay * 2, 60) - } - } - - function Read-ExpectedHash([string]$Path, [string]$ExpectedFileName) { - $candidates = @() - - foreach ($line in Get-Content -LiteralPath $Path) { - $trimmed = ([string]$line).Trim() - if (-not $trimmed) { continue } - - if ($trimmed -match '^(?[A-Fa-f0-9]{64})\s+\*?(?.+?)\s*$') { - $candidates += [pscustomobject]@{ - Hash = $Matches.hash.ToLowerInvariant() - Name = $Matches.name.Trim() - } - continue - } - - if ($trimmed -match '^SHA256\s*\((?.+?)\)\s*=\s*(?[A-Fa-f0-9]{64})$') { - $candidates += [pscustomobject]@{ - Hash = $Matches.hash.ToLowerInvariant() - Name = $Matches.name.Trim() - } - continue - } - - if ($trimmed -match '^(?[A-Fa-f0-9]{64})$') { - $candidates += [pscustomobject]@{ - Hash = $Matches.hash.ToLowerInvariant() - Name = $null - } - } - } - - $namedMatches = @($candidates | Where-Object { - $_.Name -and ([System.IO.Path]::GetFileName([string]$_.Name) -ieq $ExpectedFileName) - }) - - if ($namedMatches.Count -eq 1) { return [string]$namedMatches[0].Hash } - - $unnamedMatches = @($candidates | Where-Object { -not $_.Name }) - if ($candidates.Count -eq 1 -and $unnamedMatches.Count -eq 1) { - return [string]$unnamedMatches[0].Hash - } - - Stop-Installer "checksum file did not contain a SHA-256 entry for '$ExpectedFileName'" - } - - function Expand-VerifiedBinary([string]$ArchivePath, [string]$DestinationPath) { - try { - Add-Type -AssemblyName System.IO.Compression -ErrorAction Stop - Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop - } catch { - Stop-Installer "ZIP support is unavailable: $($_.Exception.Message)" - } - - $archive = $null - $entryStream = $null - $destinationStream = $null - - try { - $archive = [System.IO.Compression.ZipFile]::OpenRead($ArchivePath) - $files = @($archive.Entries | Where-Object { -not [string]::IsNullOrEmpty($_.Name) }) - - if ($files.Count -ne 1) { - Stop-Installer "archive must contain exactly one root file named pythinker.exe; found $($files.Count) files" - } - - $entry = $files[0] - $entryPath = ([string]$entry.FullName).Replace('\', '/') - if ($entryPath -cne 'pythinker.exe') { - Stop-Installer "archive must contain exactly one root file named pythinker.exe; found '$entryPath'" - } - if ([long]$entry.Length -le 0) { - Stop-Installer "archive contained an empty pythinker.exe" - } - - $entryStream = $entry.Open() - $destinationStream = [System.IO.File]::Open( - $DestinationPath, - [System.IO.FileMode]::CreateNew, - [System.IO.FileAccess]::Write, - [System.IO.FileShare]::None - ) - $entryStream.CopyTo($destinationStream) - $destinationStream.Flush($true) - } finally { - if ($null -ne $destinationStream) { $destinationStream.Dispose() } - if ($null -ne $entryStream) { $entryStream.Dispose() } - if ($null -ne $archive) { $archive.Dispose() } - } - } - - function Move-FileWithRetry( - [string]$Source, - [string]$Destination, - [string]$Description, - [int]$Attempts = 6 - ) { - $lastError = $null - for ($attempt = 1; $attempt -le $Attempts; $attempt++) { - try { - [System.IO.File]::Move($Source, $Destination) - return - } catch { - $lastError = $_.Exception.Message - if ($attempt -lt $Attempts) { - Start-Sleep -Milliseconds ([Math]::Min(1500, 200 * $attempt)) - } - } - } - throw "$Description failed after $Attempts attempts: $lastError" - } - - function Remove-FileWithRetry([string]$Path, [int]$Attempts = 5) { - for ($attempt = 1; $attempt -le $Attempts; $attempt++) { - if (-not (Test-Path -LiteralPath $Path)) { return $true } - try { - Remove-Item -LiteralPath $Path -Force -ErrorAction Stop - return $true - } catch { - if ($attempt -lt $Attempts) { Start-Sleep -Milliseconds (250 * $attempt) } - } - } - return -not (Test-Path -LiteralPath $Path) - } - - function Repair-InterruptedInstall([string]$BinaryPath) { - $directory = [System.IO.Path]::GetDirectoryName($BinaryPath) - $leaf = [System.IO.Path]::GetFileName($BinaryPath) - $backups = @(Get-ChildItem -LiteralPath $directory -Filter "$leaf.old-*" -File -ErrorAction SilentlyContinue | - Sort-Object LastWriteTimeUtc -Descending) - - if (-not (Test-Path -LiteralPath $BinaryPath) -and $backups.Count -gt 0) { - Move-FileWithRetry $backups[0].FullName $BinaryPath 'recovery of the previous executable' - Write-PhaseOk 'Recovery' 'restored an interrupted prior update' - $backups = @($backups | Select-Object -Skip 1) - } - - if (Test-Path -LiteralPath $BinaryPath) { - foreach ($backup in $backups) { - [void](Remove-FileWithRetry $backup.FullName 2) - } - } - - foreach ($stale in Get-ChildItem -LiteralPath $directory -Filter "$leaf.new-*" -File -ErrorAction SilentlyContinue) { - [void](Remove-FileWithRetry $stale.FullName 2) - } - } - - function Normalize-PathEntry([string]$PathEntry) { - if ([string]::IsNullOrWhiteSpace($PathEntry)) { return "" } - - $clean = $PathEntry.Trim().Trim('"') - $expanded = [Environment]::ExpandEnvironmentVariables($clean) - try { $expanded = [System.IO.Path]::GetFullPath($expanded) } catch {} - return $expanded.TrimEnd([char[]]@('\', '/')) - } - - function Test-PathContains([string]$PathValue, [string]$Entry) { - $normalizedEntry = Normalize-PathEntry $Entry - foreach ($candidate in ($PathValue -split ';')) { - if ((Normalize-PathEntry $candidate) -ieq $normalizedEntry) { return $true } - } - return $false - } - - function Add-InstallDirectoryToPath([string]$InstallDirectory) { - $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') - $added = $false - - if (-not (Test-PathContains $userPath $InstallDirectory)) { - $newPath = if ($userPath) { "$InstallDirectory;$userPath" } else { $InstallDirectory } - [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') - $added = $true - } - - if (-not (Test-PathContains $env:PATH $InstallDirectory)) { - $env:PATH = "$InstallDirectory;$env:PATH" - } - - return $added - } - - function Get-NativeArchitecture { - try { - $registry = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' -ErrorAction Stop - if ($registry.PROCESSOR_ARCHITECTURE) { return [string]$registry.PROCESSOR_ARCHITECTURE } - } catch {} - - if ($env:PROCESSOR_ARCHITEW6432) { return [string]$env:PROCESSOR_ARCHITEW6432 } - return [string]$env:PROCESSOR_ARCHITECTURE - } - - function Print-Intro([string]$ResolvedVersion, [string]$PlatformDisplay, [string]$AssetName, [string]$Action) { - Write-Logo - Write-MetadataRow 'Version' $ResolvedVersion - Write-MetadataRow 'Platform' $PlatformDisplay - Write-MetadataRow 'Package' $AssetName - Write-MetadataRow 'Action' $Action - Write-Host "" - } - - function Print-Done([string]$ResolvedVersion, [string]$BinaryPath, [bool]$PathWasAdded) { - $separatorWidth = [Math]::Max(36, [Math]::Min(58, (Get-TerminalWidth) - 4)) - $separator = "─" * $separatorWidth - - Write-Host "" - Write-Host " ${BAR}${separator}${RESET}" - Write-Host " ${ACCENT}${BOLD}✓ Pythinker Code $ResolvedVersion is ready${RESET}" - Write-Host "" - Write-Host " ${DIM}Run${RESET} ${BOLD}pythinker${RESET}" - Write-Host " ${DIM}Installed${RESET} $BinaryPath" - if ($PathWasAdded) { - Write-Host " ${DIM}PATH${RESET} Added for this user and this session" - } else { - Write-Host " ${DIM}PATH${RESET} Already configured" - } - Write-Host " ${BAR}${separator}${RESET}" - Write-Host "" - } - - try { - if ($ShowHelp) { - Show-Usage - return - } - - if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { - Stop-Installer "this installer is for Windows. Use: curl -fsSL $InstallShUrl | bash" - } - - $httpClient = New-InstallerHttpClient - - $resolvedVersion = ([string]$RequestedVersion).Trim() - if ($resolvedVersion.StartsWith('v', [StringComparison]::OrdinalIgnoreCase)) { - $resolvedVersion = $resolvedVersion.Substring(1) - } - if (-not $resolvedVersion) { - $resolvedVersion = Get-LatestVersion $httpClient - } - if (-not (Test-Version $resolvedVersion)) { - Stop-Installer "invalid version '$resolvedVersion'; expected X.Y.Z" - } - - $nativeArchitecture = (Get-NativeArchitecture).ToUpperInvariant() - switch ($nativeArchitecture) { - 'ARM64' { $archLabel = 'arm64' } - 'AMD64' { $archLabel = 'x64' } - default { Stop-Installer "unsupported Windows architecture '$nativeArchitecture' (need x64 or arm64)" } - } - - $localAppData = [Environment]::GetFolderPath([System.Environment+SpecialFolder]::LocalApplicationData) - if (-not $localAppData) { $localAppData = $env:LOCALAPPDATA } - if (-not $localAppData) { Stop-Installer 'could not resolve LOCALAPPDATA' } - - $installDir = Join-Path $localAppData 'Programs\Pythinker' - New-Item -ItemType Directory -Path $installDir -Force | Out-Null - $targetPath = Join-Path $installDir 'pythinker.exe' - - $mutexUser = ([Environment]::UserName -replace '[^A-Za-z0-9_.-]', '_') - $mutexName = "Local\PythinkerCodeInstaller-$mutexUser" - $installMutex = New-Object System.Threading.Mutex($false, $mutexName) - try { - $mutexHeld = $installMutex.WaitOne(0) - } catch [System.Threading.AbandonedMutexException] { - $mutexHeld = $true - } - if (-not $mutexHeld) { - Stop-Installer 'another Pythinker installer or update is already running' - } - - Repair-InterruptedInstall $targetPath - $action = if (Test-Path -LiteralPath $targetPath) { 'Upgrade' } else { 'Install' } - - $asset = "pythinker-code-win32-$archLabel.zip" - $baseUrl = "https://github.com/$Repo/releases/download/$(Get-EncodedReleaseTag $resolvedVersion)" - $installerUrl = "$baseUrl/$asset" - $shaUrl = "$installerUrl.sha256" - - Print-Intro $resolvedVersion "Windows $archLabel" $asset $action - Wait-ReleaseAssets $httpClient $resolvedVersion $asset - - $tempRoot = [System.IO.Path]::GetTempPath() - $tempDir = Join-Path $tempRoot ("pythinker-install-" + [System.Guid]::NewGuid().ToString('N')) - New-Item -ItemType Directory -Path $tempDir | Out-Null - $installerPath = Join-Path $tempDir $asset - $shaPath = "$installerPath.sha256" - - Download-File $httpClient $installerUrl $installerPath 'Download' - $checksumText = Get-HttpText $httpClient $shaUrl 'Checksum' - [System.IO.File]::WriteAllText($shaPath, $checksumText, [System.Text.Encoding]::ASCII) - - $expectedHash = Read-ExpectedHash $shaPath $asset - $actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $installerPath).Hash.ToLowerInvariant() - if ($expectedHash -ne $actualHash) { - Stop-Installer "SHA-256 mismatch: expected $expectedHash, got $actualHash" - } - Write-PhaseOk 'Verify' ("SHA-256 {0}…" -f $actualHash.Substring(0, 12)) - - $transactionId = [System.Guid]::NewGuid().ToString('N') - $stagingBinary = Join-Path $installDir "pythinker.exe.new-$transactionId" - Expand-VerifiedBinary $installerPath $stagingBinary - - if (Test-Path -LiteralPath $targetPath) { - $backupBinary = Join-Path $installDir "pythinker.exe.old-$transactionId" - try { - Move-FileWithRetry $targetPath $backupBinary 'moving the existing executable aside' - } catch { - Stop-Installer "could not prepare the current installation for update: $($_.Exception.Message)" - } - } - - try { - Move-FileWithRetry $stagingBinary $targetPath 'installing the new executable' - $stagingBinary = $null - } catch { - $installError = $_.Exception.Message - $rollbackError = $null - - # Roll back the previous executable whenever the new same-volume rename - # cannot complete. The user is never intentionally left without a binary. - if ($backupBinary -and (Test-Path -LiteralPath $backupBinary) -and -not (Test-Path -LiteralPath $targetPath)) { - try { - Move-FileWithRetry $backupBinary $targetPath 'rollback of the previous executable' - $backupBinary = $null - } catch { - $rollbackError = $_.Exception.Message - } - } - - if ($rollbackError) { - Stop-Installer "could not install the new executable ($installError); rollback also failed ($rollbackError)" - } - Stop-Installer "could not install the new executable: $installError" - } - - if ($backupBinary -and (Test-Path -LiteralPath $backupBinary)) { - [void](Remove-FileWithRetry $backupBinary 5) - if (-not (Test-Path -LiteralPath $backupBinary)) { $backupBinary = $null } - } - Write-PhaseOk 'Install' $targetPath - - $pathWasAdded = Add-InstallDirectoryToPath $installDir - if ($pathWasAdded) { - Write-PhaseOk 'PATH' 'added for this user' - } else { - Write-PhaseOk 'PATH' 'already configured' - } - - Print-Done $resolvedVersion $targetPath $pathWasAdded - } finally { - if ($useAnimation) { - Write-Host -NoNewline ("`r${CLEAR_LINE}${SHOW_CURSOR}") - } - - if ($null -ne $httpClient) { $httpClient.Dispose() } - - if ($tempDir -and (Test-Path -LiteralPath $tempDir)) { - Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue - } - - if ($stagingBinary -and (Test-Path -LiteralPath $stagingBinary)) { - [void](Remove-FileWithRetry $stagingBinary 2) - } - - # A backup is safe to remove only after the target exists. If rollback did - # not complete, preserve the backup for manual recovery instead of deleting it. - if ($backupBinary -and $targetPath -and (Test-Path -LiteralPath $targetPath) -and (Test-Path -LiteralPath $backupBinary)) { - [void](Remove-FileWithRetry $backupBinary 2) - } - - if ($mutexHeld -and $null -ne $installMutex) { - try { $installMutex.ReleaseMutex() } catch {} - } - if ($null -ne $installMutex) { $installMutex.Dispose() } - - if ($null -ne $previousOutputEncoding) { - try { [Console]::OutputEncoding = $previousOutputEncoding } catch {} - } - if ($null -ne $previousSecurityProtocol) { - try { [Net.ServicePointManager]::SecurityProtocol = $previousSecurityProtocol } catch {} - } - } -} $Version ([bool]$Help) diff --git a/apps/site/public/install.sh b/apps/site/public/install.sh deleted file mode 100755 index 9d5998d0..00000000 --- a/apps/site/public/install.sh +++ /dev/null @@ -1,911 +0,0 @@ -#!/usr/bin/env bash -# Pythinker Code — polished native curl-bash installer. -# -# Downloads the native single-file binary (Node SEA) for the current OS and -# architecture, verifies its SHA-256 checksum, and installs it at: -# ~/.local/bin/pythinker -# -# Usage: -# curl -fsSL https://code.pythinker.com/pythinker-code/install.sh | bash -# -# Pin a version: -# curl -fsSL https://code.pythinker.com/pythinker-code/install.sh | bash -s -- --version 0.6.0 -# -# Choose an install prefix: -# curl -fsSL https://code.pythinker.com/pythinker-code/install.sh | bash -s -- --prefix /opt/pythinker -# -# Supported release targets: -# linux-x64, linux-arm64, darwin-arm64, darwin-x64 -# -# Windows: -# irm https://code.pythinker.com/pythinker-code/install.ps1 | iex -set -euo pipefail - -VERSION="" -INSTALL_PREFIX="${PYTHINKER_INSTALL_PREFIX:-$HOME/.local}" -NO_COLOR="${NO_COLOR:-}" - -REPO="Pythoughts-labs/pythinker-code" -CDN_LATEST_URL="https://code.pythinker.com/pythinker-code/latest" - -# Operational globals are populated by main(). Keeping rendering helpers at -# file scope makes the installer sourceable for regression tests and tooling. -target="" -platform_display="" -tag_encoded="" -archive="" -archive_url="" -sha_url="" -bin_dir="" -install_path="" -TMP_DIR="" -DOWNLOAD_PID="" - -# UI globals are initialized to empty so helper functions are safe before -# _init_ui is called (for example, when the file is sourced by a test). -_anim="" -_cursor_hidden="" -ROBOT="" -FACE="" -ACCENT="" -TIP="" -EYE="" -SUCCESS="" -WARNING="" -ERROR_COLOR="" -MUTED="" -BORDER="" -BOLD="" -DIM="" -RESET="" - -usage() { - cat <<'EOF_USAGE' -Pythinker Code — native curl-bash installer. - -Downloads the native single-file binary for your OS and architecture, -verifies its SHA-256 checksum, and installs it at: - ~/.local/bin/pythinker - -Usage: - curl -fsSL https://code.pythinker.com/pythinker-code/install.sh | bash - -Pin a specific version: - curl -fsSL https://code.pythinker.com/pythinker-code/install.sh | bash -s -- --version 0.6.0 - -Use a custom install prefix (default: $HOME/.local): - curl -fsSL https://code.pythinker.com/pythinker-code/install.sh | bash -s -- --prefix /opt/pythinker - -Supported targets: - linux-x64 Linux x86_64 - linux-arm64 Linux ARM64 - darwin-arm64 macOS Apple Silicon - darwin-x64 macOS Intel - -Environment: - PYTHINKER_INSTALL_PREFIX Default install prefix - PYTHINKER_NO_ANIMATION Disable terminal animation when non-empty - PYTHINKER_TERM_WIDTH Override detected width - NO_COLOR Disable ANSI colors and animation - -Windows: - irm https://code.pythinker.com/pythinker-code/install.ps1 | iex -EOF_USAGE -} - -_parse_args() { - while [[ $# -gt 0 ]]; do - case "$1" in - --version) - [[ -n "${2:-}" ]] || { - printf '%s\n' '--version requires a value' >&2 - return 2 - } - VERSION="$2" - shift 2 - ;; - --prefix) - [[ -n "${2:-}" ]] || { - printf '%s\n' '--prefix requires a value' >&2 - return 2 - } - INSTALL_PREFIX="$2" - shift 2 - ;; - -h|--help) - usage - return 10 - ;; - *) - printf 'unknown argument: %s\n' "$1" >&2 - return 2 - ;; - esac - done -} - -_init_ui() { - # Reset first so repeated calls while sourced are deterministic. - _anim="" - ROBOT=""; FACE=""; ACCENT=""; TIP=""; EYE=""; SUCCESS="" - WARNING=""; ERROR_COLOR=""; MUTED=""; BORDER="" - BOLD=""; DIM=""; RESET="" - - if [[ -t 1 && -z "$NO_COLOR" && "${TERM:-}" != "dumb" ]]; then - # Terminal-default foreground plus restrained neutral/pastel accents. - ROBOT=$'\033[38;5;248m' - FACE=$'\033[39m' - ACCENT=$'\033[38;5;141m' - TIP=$'\033[38;5;173m' - EYE=$'\033[38;5;147m' - SUCCESS=$'\033[38;5;114m' - WARNING=$'\033[38;5;179m' - ERROR_COLOR=$'\033[38;5;203m' - MUTED=$'\033[38;5;245m' - BORDER=$'\033[38;5;245m' - BOLD=$'\033[1m' - DIM=$'\033[2m' - RESET=$'\033[0m' - fi - - if [[ -t 1 \ - && -z "$NO_COLOR" \ - && "${TERM:-}" != "dumb" \ - && -z "${PYTHINKER_NO_ANIMATION:-}" \ - && -z "${CI:-}" ]]; then - _anim=1 - fi -} - -_hide_cursor() { - [[ -n "$_anim" ]] || return 0 - [[ -z "$_cursor_hidden" ]] || return 0 - printf '\033[?25l' - _cursor_hidden=1 -} - -_show_cursor() { - [[ -n "$_cursor_hidden" ]] || return 0 - printf '\033[?25h' - _cursor_hidden="" -} - -_cleanup() { - if [[ -n "$DOWNLOAD_PID" ]] && kill -0 "$DOWNLOAD_PID" 2>/dev/null; then - kill "$DOWNLOAD_PID" 2>/dev/null || true - wait "$DOWNLOAD_PID" 2>/dev/null || true - fi - DOWNLOAD_PID="" - - _show_cursor || true - - if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then - rm -rf "$TMP_DIR" - fi -} - -fail() { - _show_cursor || true - if [[ -n "$_anim" ]]; then - _clear_active_line - fi - printf ' %s✗%s %s\n' "$ERROR_COLOR" "$RESET" "$1" >&2 - exit 1 -} - -# The explicit width argument is optional; callers other than _wrap_text omit it -# and rely on detection. -# shellcheck disable=SC2120 -_terminal_columns() { - local explicit="${1:-}" - local detected="" - - if [[ "$explicit" =~ ^[0-9]+$ ]] && (( explicit > 0 )); then - printf '%s' "$explicit" - return 0 - fi - - if [[ "${PYTHINKER_TERM_WIDTH:-}" =~ ^[0-9]+$ ]] \ - && (( PYTHINKER_TERM_WIDTH > 0 )); then - printf '%s' "$PYTHINKER_TERM_WIDTH" - return 0 - fi - - if [[ "${COLUMNS:-}" =~ ^[0-9]+$ ]] && (( COLUMNS > 0 )); then - printf '%s' "$COLUMNS" - return 0 - fi - - if [[ -t 1 && "${TERM:-}" != "dumb" ]] \ - && command -v tput >/dev/null 2>&1; then - detected="$(tput cols 2>/dev/null || true)" - if [[ "$detected" =~ ^[0-9]+$ ]] && (( detected > 0 )); then - printf '%s' "$detected" - return 0 - fi - fi - - printf '80' -} - -_progress_bar_width() { - local columns="${1:-$(_terminal_columns)}" - local width - - if (( columns >= 80 )); then - width=44 - elif (( columns >= 55 )); then - width=$((columns - 32)) - (( width > 44 )) && width=44 - else - width=0 - fi - - printf '%s' "$width" -} - -_repeat_char() { - local char="$1" count="$2" result="" i - for ((i=0; i 50 )) && width=50 - (( width < 1 )) && width=1 - _repeat_char '─' "$width" -} - -_format_bytes() { - local bytes="${1:-0}" - [[ "$bytes" =~ ^[0-9]+$ ]] || bytes=0 - LC_ALL=C awk -v bytes="$bytes" 'BEGIN { - if (bytes < 1024) { - printf "%d B", bytes - } else if (bytes < 1048576) { - printf "%.1f KB", bytes / 1024 - } else if (bytes < 1073741824) { - printf "%.1f MB", bytes / 1048576 - } else { - printf "%.1f GB", bytes / 1073741824 - } - }' -} - -_format_byte_pair() { - local current="${1:-0}" total="${2:-0}" - [[ "$current" =~ ^[0-9]+$ ]] || current=0 - [[ "$total" =~ ^[0-9]+$ ]] || total=0 - LC_ALL=C awk -v current="$current" -v total="$total" 'BEGIN { - unit = "B"; divisor = 1 - if (total >= 1073741824) { - unit = "GB"; divisor = 1073741824 - } else if (total >= 1048576) { - unit = "MB"; divisor = 1048576 - } else if (total >= 1024) { - unit = "KB"; divisor = 1024 - } - - if (divisor == 1) { - printf "%d/%d %s", current, total, unit - } else { - printf "%.1f/%.1f %s", current / divisor, total / divisor, unit - } - }' -} - -_display_path() { - local path="$1" - if [[ -n "${HOME:-}" && "$path" == "$HOME" ]]; then - printf '~' - elif [[ -n "${HOME:-}" && "$path" == "$HOME/"* ]]; then - printf '~%s' "${path#"$HOME"}" - else - printf '%s' "$path" - fi -} - -_current_file_size() { - local file="$1" - if [[ -f "$file" ]]; then - wc -c < "$file" | tr -d '[:space:]' - else - printf '0' - fi -} - -_content_length() { - local url="$1" - command -v curl >/dev/null 2>&1 || return 1 - curl -fsIL "$url" 2>/dev/null \ - | awk 'tolower($1) == "content-length:" { - gsub("\r", "", $2) - bytes = $2 - } - END { - if (bytes ~ /^[0-9]+$/) print bytes - }' -} - -_download_percent() { - local output="$1" total="$2" size percent - [[ "$total" =~ ^[0-9]+$ ]] && (( total > 0 )) || return 1 - size="$(_current_file_size "$output")" - [[ "$size" =~ ^[0-9]+$ ]] || size=0 - percent=$((size * 100 / total)) - (( percent > 99 )) && percent=99 - (( percent < 0 )) && percent=0 - printf '%s' "$percent" -} - -_clear_active_line() { - [[ -n "$_anim" ]] || return 0 - printf '\r\033[2K' -} - -_render_progress_determinate() { - local percent="$1" current="$2" total="$3" frame="$4" - local columns width filled empty filled_bar empty_bar pair - - [[ "$percent" =~ ^[0-9]+$ ]] || percent=0 - (( percent > 100 )) && percent=100 - (( percent < 0 )) && percent=0 - - columns="$(_terminal_columns)" - width="$(_progress_bar_width "$columns")" - pair="$(_format_byte_pair "$current" "$total")" - - _clear_active_line - - if (( width == 0 )); then - # Below 55 columns, keep the display percentage-only to avoid wrapping. - printf ' %s%s%s Downloading %3d%%' \ - "$ACCENT" "$frame" "$RESET" "$percent" - return 0 - fi - - filled=$((percent * width / 100)) - empty=$((width - filled)) - filled_bar="$(_repeat_char '█' "$filled")" - empty_bar="$(_repeat_char '░' "$empty")" - - printf ' %s%s%s Downloading %s%s%s%s%s%s %3d%%' \ - "$ACCENT" "$frame" "$RESET" \ - "$ACCENT" "$filled_bar" "$RESET" \ - "$BORDER" "$empty_bar" "$RESET" \ - "$percent" - - # At 80 columns the 44-cell bar fits, but byte details can wrap. Add them - # only when there is enough room for the largest common value pair. - if (( columns >= 88 )); then - printf ' %s' "$pair" - fi -} - -_render_progress_indeterminate() { - local frame="$1" current="$2" - local columns received - columns="$(_terminal_columns)" - received="$(_format_bytes "$current")" - _clear_active_line - - if (( columns < 45 )); then - printf ' %s%s%s Downloading %s' \ - "$ACCENT" "$frame" "$RESET" "$received" - else - printf ' %s%s%s Downloading %sReceiving package…%s %s' \ - "$ACCENT" "$frame" "$RESET" "$MUTED" "$RESET" "$received" - fi -} - -_render_waiting() { - local frame="$1" delay="$2" - local columns - columns="$(_terminal_columns)" - _clear_active_line - - if (( columns < 55 )); then - printf ' %s%s%s Waiting; retry in %ss' \ - "$ACCENT" "$frame" "$RESET" "$delay" - else - printf ' %s%s%s Waiting %sRelease assets are publishing; retry in %ss%s' \ - "$ACCENT" "$frame" "$RESET" "$MUTED" "$delay" "$RESET" - fi -} - -status_ok() { - local label="$1" detail="${2:-}" - printf ' %s✓%s %s' "$SUCCESS" "$RESET" "$label" - if [[ -n "$detail" ]]; then - printf ' %s%s%s' "$MUTED" "$detail" "$RESET" - fi - printf '\n' -} - -status_warn() { - local label="$1" detail="${2:-}" - printf ' %s!%s %s' "$WARNING" "$RESET" "$label" - if [[ -n "$detail" ]]; then - printf ' %s%s%s' "$MUTED" "$detail" "$RESET" - fi - printf '\n' -} - -print_logo_art() { - printf ' %s●%s\n' "$TIP" "$RESET" - printf ' %s│%s\n' "$ROBOT" "$RESET" - printf ' %s▛%s%s▀▀▀▀▀▀▀%s%s▜%s\n' \ - "$ROBOT" "$RESET" "$FACE" "$RESET" "$ROBOT" "$RESET" - printf ' %s◖%s%s█%s %s◉%s %s◉%s %s█%s%s◗%s\n' \ - "$TIP" "$RESET" "$ROBOT" "$RESET" \ - "$EYE" "$RESET" "$EYE" "$RESET" \ - "$ROBOT" "$RESET" "$TIP" "$RESET" - printf ' %s▙▄▄▄%s%s≡%s%s▄▄▄▟%s\n' \ - "$ROBOT" "$RESET" "$FACE" "$RESET" "$ROBOT" "$RESET" -} - -_print_brand() { - printf '\n %s%sPYTHINKER CODE%s\n' "$BOLD" "$FACE" "$RESET" - printf ' %sThink first. Then code.%s\n\n' "$MUTED" "$RESET" -} - -print_logo_static() { - printf '\n' - print_logo_art - _print_brand -} - -print_logo_animated() { - local delay="${PYTHINKER_LOGO_FRAME_DELAY:-0.07}" - - printf '\n' - _hide_cursor - - # Each micro-animation rewrites only the line currently being composed. - printf ' %s·%s' "$MUTED" "$RESET" - sleep "$delay" - _clear_active_line - printf ' %s●%s\n' "$TIP" "$RESET" - - printf ' %s│%s\n' "$ROBOT" "$RESET" - sleep "$delay" - printf ' %s▛%s%s▀▀▀▀▀▀▀%s%s▜%s\n' \ - "$ROBOT" "$RESET" "$FACE" "$RESET" "$ROBOT" "$RESET" - sleep "$delay" - - printf ' %s◖%s%s█%s %s·%s %s·%s %s█%s%s◗%s' \ - "$TIP" "$RESET" "$ROBOT" "$RESET" \ - "$MUTED" "$RESET" "$MUTED" "$RESET" \ - "$ROBOT" "$RESET" "$TIP" "$RESET" - sleep "$delay" - _clear_active_line - printf ' %s◖%s%s█%s %s◉%s %s◉%s %s█%s%s◗%s\n' \ - "$TIP" "$RESET" "$ROBOT" "$RESET" \ - "$EYE" "$RESET" "$EYE" "$RESET" \ - "$ROBOT" "$RESET" "$TIP" "$RESET" - - printf ' %s▙▄▄▄%s%s≡%s%s▄▄▄▟%s\n' \ - "$ROBOT" "$RESET" "$FACE" "$RESET" "$ROBOT" "$RESET" - sleep "$delay" - - printf '\n %sPYTHINKER CODE%s' "$DIM" "$RESET" - sleep "$delay" - _clear_active_line - printf ' %s%sPYTHINKER CODE%s\n' "$BOLD" "$FACE" "$RESET" - printf ' %sThink first. Then code.%s\n\n' "$MUTED" "$RESET" - - _show_cursor -} - -print_intro() { - local destination - destination="$(_display_path "$install_path")" - - if [[ -n "$_anim" ]]; then - print_logo_animated - else - print_logo_static - fi - - printf ' %s%-12s%s %s\n' "$MUTED" 'Version' "$RESET" "$VERSION" - printf ' %s%-12s%s %s\n' "$MUTED" 'Platform' "$RESET" "$platform_display" - printf ' %s%-12s%s %s\n' "$MUTED" 'Destination' "$RESET" "$destination" - printf '\n' -} - -print_done() { - local sep destination - sep="$(_separator)" - destination="$(_display_path "$install_path")" - - printf '\n %s%s%s\n\n' "$BORDER" "$sep" "$RESET" - printf ' %s%sReady to think, plan, and build.%s\n\n' \ - "$BOLD" "$FACE" "$RESET" - printf ' %sInstalled at%s %s\n' "$MUTED" "$RESET" "$destination" - printf ' %sStart with%s %s%s$ pythinker%s\n\n' \ - "$MUTED" "$RESET" "$BOLD" "$ACCENT" "$RESET" -} - -_fetch() { - local url="$1" - if command -v curl >/dev/null 2>&1; then - curl -fsSL "$url" - elif command -v wget >/dev/null 2>&1; then - wget -qO- "$url" - else - return 127 - fi -} - -_download_quiet() { - local url="$1" output="$2" - if command -v curl >/dev/null 2>&1; then - curl -fsSL "$url" -o "$output" - elif command -v wget >/dev/null 2>&1; then - wget -q "$url" -O "$output" - else - return 127 - fi -} - -_start_download() { - local url="$1" output="$2" - DOWNLOAD_PID="" - - if command -v curl >/dev/null 2>&1; then - curl -fsSL "$url" -o "$output" & - elif command -v wget >/dev/null 2>&1; then - wget -q "$url" -O "$output" & - else - return 127 - fi - - DOWNLOAD_PID=$! -} - -# One download attempt with a live progress display. Returns non-zero on -# transport failure, an empty file, or a size short of Content-Length. -_download_attempt_with_progress() { - local url="$1" output="$2" - local total="" pid="" current=0 percent=0 i=0 frame_index=0 - local -a frames=('◐' '◓' '◑' '◒') - - rm -f "$output" - - if [[ -z "$_anim" ]]; then - _download_quiet "$url" "$output" || return 1 - _validate_download "$output" "" || return 1 - current="$(_current_file_size "$output")" - status_ok 'Download complete' "$(_format_bytes "$current")" - return 0 - fi - - if command -v curl >/dev/null 2>&1; then - total="$(_content_length "$url" || true)" - fi - - _start_download "$url" "$output" || return $? - pid="$DOWNLOAD_PID" - _hide_cursor - - while kill -0 "$pid" 2>/dev/null; do - frame_index=$((i % 4)) - current="$(_current_file_size "$output")" - - if [[ "$total" =~ ^[0-9]+$ ]] && (( total > 0 )); then - percent="$(_download_percent "$output" "$total" || printf '0')" - _render_progress_determinate \ - "$percent" "$current" "$total" "${frames[$frame_index]}" - else - _render_progress_indeterminate "${frames[$frame_index]}" "$current" - fi - - sleep 0.12 - i=$((i + 1)) - done - - if ! wait "$pid"; then - DOWNLOAD_PID="" - _show_cursor - _clear_active_line - return 1 - fi - DOWNLOAD_PID="" - - if ! _validate_download "$output" "$total"; then - _show_cursor - _clear_active_line - return 1 - fi - - current="$(_current_file_size "$output")" - if [[ "$total" =~ ^[0-9]+$ ]] && (( total > 0 )); then - _render_progress_determinate 100 "$current" "$total" '✓' - printf '\n' - else - _clear_active_line - fi - - _show_cursor - status_ok 'Download complete' "$(_format_bytes "$current")" -} - -# Reject empty downloads, and short downloads when Content-Length is known. -# A truncated archive would fail checksum verification anyway, but catching -# it here lets the retry loop recover instead of aborting the install. -_validate_download() { - local output="$1" total="${2:-}" - local size - size="$(_current_file_size "$output")" - [[ "$size" =~ ^[0-9]+$ ]] && (( size > 0 )) || return 1 - if [[ "$total" =~ ^[0-9]+$ ]] && (( total > 0 )) && (( size != total )); then - return 1 - fi - return 0 -} - -_download_with_progress() { - local url="$1" output="$2" - local attempt delay - - for attempt in 1 2 3; do - if _download_attempt_with_progress "$url" "$output"; then - return 0 - fi - rm -f "$output" - if (( attempt < 3 )); then - delay=$((2 ** (attempt - 1))) - status_warn 'Download failed' "retry $((attempt + 1))/3 in ${delay}s" - sleep "$delay" - fi - done - return 1 -} - -_download_quiet_with_retry() { - local label="$1" url="$2" output="$3" - local attempt delay - - for attempt in 1 2 3; do - if _download_quiet "$url" "$output" && _validate_download "$output" ""; then - return 0 - fi - rm -f "$output" - if (( attempt < 3 )); then - delay=$((2 ** (attempt - 1))) - status_warn "$label failed" "retry $((attempt + 1))/3 in ${delay}s" - sleep "$delay" - fi - done - return 1 -} - -_detect_target() { - local os arch - os="$(uname -s)" - arch="$(uname -m)" - - case "$os/$arch" in - Linux/x86_64|Linux/amd64) - target='linux-x64' - platform_display='Linux · x86_64' - ;; - Linux/aarch64|Linux/arm64) - target='linux-arm64' - platform_display='Linux · ARM64' - ;; - Darwin/arm64) - target='darwin-arm64' - platform_display='macOS · Apple Silicon' - ;; - Darwin/x86_64) - target='darwin-x64' - platform_display='macOS · Intel' - ;; - MINGW*/*|MSYS*/*|CYGWIN*/*) - fail $'On Windows, use the PowerShell installer:\n powershell -c "irm https://code.pythinker.com/pythinker-code/install.ps1 | iex"' - ;; - *) - fail "unsupported target: $os/$arch" - ;; - esac -} - -_resolve_version() { - local api payload - - command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 \ - || fail 'need curl or wget to fetch release metadata' - - if [[ -z "$VERSION" ]]; then - VERSION="$(_fetch "$CDN_LATEST_URL" 2>/dev/null \ - | tr -d '[:space:]' || true)" - - if ! printf '%s' "$VERSION" \ - | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then - api="https://api.github.com/repos/${REPO}/releases/latest" - payload="$(_fetch "$api")" \ - || fail "could not reach $CDN_LATEST_URL or $api" - VERSION="$(printf '%s' "$payload" \ - | sed -nE 's/.*"tag_name": *"@pythoughts\/pythinker-code@([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' \ - | head -n 1)" - fi - fi - - printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ - || fail "invalid version '$VERSION'; expected X.Y.Z" -} - -release_has_assets() { - local api body - api="https://api.github.com/repos/${REPO}/releases/tags/${tag_encoded}" - body="$(_fetch "$api" 2>/dev/null)" || return 1 - printf '%s' "$body" | grep -Fq "\"${archive}\"" \ - && printf '%s' "$body" | grep -Fq "\"${archive}.sha256\"" -} - -_wait_for_release_assets() { - local attempt=0 delay=4 elapsed=0 max_elapsed=360 - local -a frames=('◐' '◓' '◑' '◒') - - until release_has_assets; do - if (( elapsed >= max_elapsed )); then - fail "release assets for ${VERSION} are unavailable after about ${max_elapsed}s: ${archive_url} -The release may still be publishing. Try again shortly, or pin a known-good version with --version X.Y.Z" - fi - - if [[ -n "$_anim" ]]; then - _render_waiting "${frames[$((attempt % 4))]}" "$delay" - else - printf ' Waiting for release assets; retrying in %ss\n' "$delay" - fi - - sleep "$delay" - if [[ -n "$_anim" ]]; then - _clear_active_line - fi - - attempt=$((attempt + 1)) - elapsed=$((elapsed + delay)) - delay=$((delay * 2)) - (( delay > 120 )) && delay=120 - done - - if [[ -n "$_anim" ]] && (( attempt > 0 )); then - _clear_active_line - fi -} - -_verify_checksum() { - local checksum_file="$1" payload_file="$2" - local expected actual - - expected="$(awk 'NR == 1 {print $1}' "$checksum_file" \ - | tr '[:upper:]' '[:lower:]')" - printf '%s' "$expected" | grep -Eq '^[0-9a-f]{64}$' \ - || fail 'the release checksum file is malformed' - - if command -v sha256sum >/dev/null 2>&1; then - actual="$(sha256sum "$payload_file" | awk '{print $1}')" - elif command -v shasum >/dev/null 2>&1; then - actual="$(shasum -a 256 "$payload_file" | awk '{print $1}')" - else - fail 'need sha256sum or shasum to verify the download' - fi - - actual="$(printf '%s' "$actual" | tr '[:upper:]' '[:lower:]')" - [[ "$expected" == "$actual" ]] \ - || fail "SHA-256 mismatch: expected $expected, got $actual" - - status_ok 'Checksum verified' -} - -_extract_and_install() { - local payload="$TMP_DIR/pythinker" - - mkdir -p "$bin_dir" - # Sweep staged leftovers from a previous interrupted run. - rm -f "$install_path".tmp.* 2>/dev/null || true - - if command -v unzip >/dev/null 2>&1; then - unzip -oq "$TMP_DIR/$archive" -d "$TMP_DIR" - elif command -v tar >/dev/null 2>&1 \ - && tar -tf "$TMP_DIR/$archive" >/dev/null 2>&1; then - tar -C "$TMP_DIR" -xf "$TMP_DIR/$archive" - else - fail "need unzip (or bsdtar) to extract $archive" - fi - - [[ -f "$payload" ]] \ - || fail "archive did not contain a regular file named 'pythinker'" - command -v install >/dev/null 2>&1 \ - || fail "need the 'install' command to place the executable" - - # Stage next to the target, then rename into place. `install` alone - # truncate-writes the destination: overwriting a currently running - # `pythinker` fails with ETXTBSY on Linux and can leave a half-written - # binary on any platform. rename() replaces the path atomically and is - # legal even while the old inode is still executing. - local staged="$install_path.tmp.$$" - if ! install -m 0755 "$payload" "$staged"; then - rm -f "$staged" - fail "could not stage the executable in $(_display_path "$bin_dir")" - fi - if ! mv -f "$staged" "$install_path"; then - rm -f "$staged" - fail "could not move the executable into place at $(_display_path "$install_path")" - fi - status_ok 'Installed successfully' "$(_display_path "$install_path")" -} - -_print_path_guidance() { - case ":$PATH:" in - *":$bin_dir:"*) - return 0 - ;; - esac - - printf '\n' - status_warn \ - 'PATH update required' \ - "$(_display_path "$bin_dir") is not currently on PATH" - printf ' %sBash or Zsh%s\n' "$MUTED" "$RESET" - # $PATH stays literal on purpose — this line is shell config for the user to copy. - # shellcheck disable=SC2016 - printf ' export PATH="%s:$PATH"\n' "$bin_dir" - printf ' %sFish%s\n' "$MUTED" "$RESET" - printf ' fish_add_path "%s"\n' "$bin_dir" -} - -main() { - local parse_status=0 - - _parse_args "$@" || parse_status=$? - if (( parse_status == 10 )); then - return 0 - elif (( parse_status != 0 )); then - return "$parse_status" - fi - - _init_ui - trap _cleanup EXIT - trap 'exit 130' INT - trap 'exit 143' TERM - - _detect_target - _resolve_version - - tag_encoded="%40pythoughts%2Fpythinker-code%40${VERSION}" - archive="pythinker-code-${target}.zip" - archive_url="https://github.com/${REPO}/releases/download/${tag_encoded}/${archive}" - sha_url="${archive_url}.sha256" - bin_dir="$INSTALL_PREFIX/bin" - install_path="$bin_dir/pythinker" - - print_intro - _wait_for_release_assets - - TMP_DIR="$(mktemp -d -t pythinker-install.XXXXXX)" - _download_with_progress "$archive_url" "$TMP_DIR/$archive" \ - || fail "download failed after 3 attempts: $archive_url" - _download_quiet_with_retry 'Checksum download' "$sha_url" "$TMP_DIR/$archive.sha256" \ - || fail "checksum download failed after 3 attempts: $sha_url" - - _verify_checksum "$TMP_DIR/$archive.sha256" "$TMP_DIR/$archive" - _extract_and_install - _print_path_guidance - print_done -} - -# `curl … | bash` feeds the script over stdin, where BASH_SOURCE is empty and -# $0 is "bash". Defaulting to $0 keeps the piped install (the documented entry -# point) running main, still runs main when the file is executed directly, and -# still skips it when the script is sourced. -if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then - main "$@" -fi diff --git a/apps/site/scripts/build-cdn.mjs b/apps/site/scripts/build-cdn.mjs index 4e646023..7e3cd9be 100644 --- a/apps/site/scripts/build-cdn.mjs +++ b/apps/site/scripts/build-cdn.mjs @@ -89,6 +89,52 @@ async function resolvePublishedRelease(packageName) { return { version, publishedAt }; } +const RELEASE_DOWNLOAD_BASE = 'https://github.com/Pythoughts-labs/pythinker-code/releases/download'; + +// Set to a version string only when an older client can no longer work against +// the current services; it makes every client below it take the update without +// waiting for its rollout batch. Reset to null once that release is the floor. +const MIN_REQUIRED_VERSION = null; + +/** + * Resolve the per-platform native artifacts for a published version. + * + * The release already carries a `manifest.json` asset written by + * `apps/pythinker-code/scripts/native/produce-manifest.mjs`, so this only turns + * `{ filename, checksum }` into `{ url, sha256 }`. Naming the artifact in + * `latest.json` is what lets a client answer "is there anything to download for + * my platform" from the manifest alone, instead of guessing an asset URL and + * polling GitHub for six minutes when the guess is wrong. + * + * Returns null — never throws — when the release shipped no native manifest. + * An npm-only release is legitimate, and a site build must not fail because of + * it; clients that see no `platforms` key keep their previous behaviour. + */ +async function resolvePlatformArtifacts(version) { + const base = `${RELEASE_DOWNLOAD_BASE}/%40pythoughts%2Fpythinker-code%40${version}`; + let native; + try { + const response = await fetch(`${base}/manifest.json`, { signal: AbortSignal.timeout(30_000) }); + if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`); + native = await response.json(); + } catch (error) { + console.log(`no native manifest for ${version} (${error.message}); omitting platforms`); + return null; + } + if (native?.version !== version) { + console.log(`native manifest reports ${native?.version}, expected ${version}; omitting platforms`); + return null; + } + const platforms = {}; + for (const [target, entry] of Object.entries(native.platforms ?? {})) { + if (typeof entry?.filename !== 'string' || !/^[a-f0-9]{64}$/.test(entry?.checksum ?? '')) { + throw new Error(`Malformed native manifest entry for ${target}`); + } + platforms[target] = { url: `${base}/${entry.filename}`, sha256: entry.checksum }; + } + return Object.keys(platforms).length > 0 ? platforms : null; +} + async function copyPlugins(repoRoot, cdnRoot) { const source = join(repoRoot, 'plugins/cdn'); const destination = join(cdnRoot, 'pythinker-code/plugins'); @@ -184,20 +230,28 @@ await rm(outDir, { recursive: true, force: true }); await cp(siteDist, outDir, { recursive: true }); const channelRoot = join(outDir, 'pythinker-code'); await mkdir(channelRoot, { recursive: true }); +// Plain-text `/latest`: install.sh reads it for a fresh install, and clients +// shipped before latest.json existed still poll it. Current clients read only +// the manifest, so this file must keep being written but must never become the +// place a new field lands. await writeFile(join(channelRoot, 'latest'), `${version}\n`); +const platforms = await resolvePlatformArtifacts(version); await writeFile(join(channelRoot, 'latest.json'), `${JSON.stringify({ version, publishedAt, + minRequiredVersion: MIN_REQUIRED_VERSION ?? undefined, + platforms: platforms ?? undefined, rollout: [], }, null, 2)}\n`); -await cp( - join(repoRoot, 'apps/pythinker-web/public/install.sh'), - join(channelRoot, 'install.sh'), -); -await cp( - join(repoRoot, 'apps/pythinker-web/public/install.ps1'), - join(channelRoot, 'install.ps1'), -); +// One checked-in installer, served at both paths. `apps/site/public/` used to +// hold its own byte-identical copy, which meant a one-sided edit shipped +// silently; the site root keeps working because the file is placed here at +// build time instead of being duplicated in the tree. +for (const name of ['install.sh', 'install.ps1']) { + const source = join(repoRoot, 'apps/pythinker-web/public', name); + await cp(source, join(channelRoot, name)); + await cp(source, join(outDir, name)); +} await copyPlugins(repoRoot, outDir); if (!skipRg) await downloadRipgrep(repoRoot, outDir);