diff --git a/.changeset/catalog-provider-api-key-prompt.md b/.changeset/catalog-provider-api-key-prompt.md new file mode 100644 index 00000000..c3a559b7 --- /dev/null +++ b/.changeset/catalog-provider-api-key-prompt.md @@ -0,0 +1,5 @@ +--- +'@pythoughts/pythinker-code': patch +--- + +Prompt for an API key when connecting a catalog provider whose environment variable is not set, instead of failing with "Environment variable is not set or is empty". Applies to `/login`, `/provider`, and `pythinker provider catalog add`, which now also accepts `--api-key `. diff --git a/.changeset/homebrew-update-hint.md b/.changeset/homebrew-update-hint.md new file mode 100644 index 00000000..a491a429 --- /dev/null +++ b/.changeset/homebrew-update-hint.md @@ -0,0 +1,5 @@ +--- +'@pythoughts/pythinker-code': patch +--- + +Explain in `/update` and the startup update notice that Homebrew installs do not auto-update, and point to the native installer for automatic background updates. diff --git a/.changeset/native-install-script-assets.md b/.changeset/native-install-script-assets.md new file mode 100644 index 00000000..0ac6aa11 --- /dev/null +++ b/.changeset/native-install-script-assets.md @@ -0,0 +1,5 @@ +--- +'@pythoughts/pythinker-code': patch +--- + +Point the native install scripts at the published release assets. diff --git a/.changeset/old-node-launch-guard.md b/.changeset/old-node-launch-guard.md new file mode 100644 index 00000000..55cbaea5 --- /dev/null +++ b/.changeset/old-node-launch-guard.md @@ -0,0 +1,5 @@ +--- +'@pythoughts/pythinker-code': patch +--- + +Show a clear requirement message with the native-installer alternative when the CLI is launched on Node.js older than 26.4, instead of failing with a cryptic flag error. diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index 9a233e4f..93a383ee 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -36,4 +36,4 @@ jobs: - name: Check PowerShell syntax run: | - pwsh -NoProfile -Command '[void][System.Management.Automation.Language.Parser]::ParseFile("apps/pythinker-web/public/install.ps1", [ref]$null, [ref]$errs); if ($errs.Count) { $errs; exit 1 }' + pwsh -NoProfile -Command '$errs = $null; [void][System.Management.Automation.Language.Parser]::ParseFile("apps/pythinker-web/public/install.ps1", [ref]$null, [ref]$errs); if ($errs.Count) { $errs; exit 1 }' diff --git a/README.md b/README.md index 887b48f9..14f98796 100644 --- a/README.md +++ b/README.md @@ -227,30 +227,9 @@ See the [configuration docs](https://pythoughts-labs.github.io/pythinker-code/co Pythinker Code is a **pnpm monorepo**. The CLI consumes capabilities through the SDK and never depends directly on internal engine packages. -```mermaid -flowchart LR - subgraph Apps - CLI["pythinker-code
(CLI / TUI)"] - WEB["pythinker-web
(Browser UI)"] - DASH["dashboard
(Session replay)"] - end - - subgraph Packages - SDK["node-sdk"] - CORE["agent-core"] - ANYLLM["Any LLM
(provider abstraction)"] - KAOS["kaos
(Execution env)"] - SERVER["server
(REST + WebSocket)"] - end - - CLI --> SDK - WEB --> SERVER - DASH --> SERVER - SDK --> CORE - SERVER --> CORE - CORE --> ANYLLM - CORE --> KAOS -``` +

+ Pythinker Code architecture +

| Package | Role | |---------|------| diff --git a/_typos.toml b/_typos.toml index 01953df8..1089fc23 100644 --- a/_typos.toml +++ b/_typos.toml @@ -25,3 +25,4 @@ nd = "nd" # ndJsonStream, `Nd` cron interval token dows = "dows" # formatDows — days-of-week (cron) fo = "fo" # `/FO` flag of Windows schtasks pn = "pn" # "PNGs" tokenized as PN by the checker +iterm = "iterm" # iTerm2 terminal app identifier diff --git a/apps/pythinker-code/src/cli/sub/provider.ts b/apps/pythinker-code/src/cli/sub/provider.ts index b9c49145..79899f11 100644 --- a/apps/pythinker-code/src/cli/sub/provider.ts +++ b/apps/pythinker-code/src/cli/sub/provider.ts @@ -65,6 +65,7 @@ interface CatalogListOptions { } interface CatalogAddOptions { + readonly apiKey?: string; readonly apiKeyEnv?: string; readonly defaultModel?: string; readonly url?: string; @@ -331,19 +332,24 @@ export async function handleCatalogAdd( deps.exit(1); } + const literalApiKey = opts.apiKey?.trim(); const apiKeyEnvVar = (opts.apiKeyEnv ?? entry.env?.[0])?.trim(); - if (apiKeyEnvVar === undefined || apiKeyEnvVar.length === 0) { - deps.stderr.write( - `Provider "${providerId}" does not declare an API key environment variable.\n`, - ); - deps.exit(1); - } - const apiKey = deps.env[apiKeyEnvVar]?.trim(); - if (apiKey === undefined || apiKey.length === 0) { - deps.stderr.write( - `Environment variable "${apiKeyEnvVar}" is not set or is empty.\n`, - ); - deps.exit(1); + let useEnvVar = false; + if (literalApiKey === undefined || literalApiKey.length === 0) { + if (apiKeyEnvVar === undefined || apiKeyEnvVar.length === 0) { + deps.stderr.write( + `Provider "${providerId}" does not declare an API key environment variable. Pass --api-key .\n`, + ); + deps.exit(1); + } + const envValue = deps.env[apiKeyEnvVar]?.trim(); + if (envValue === undefined || envValue.length === 0) { + deps.stderr.write( + `Environment variable "${apiKeyEnvVar}" is not set or is empty. Set it or pass --api-key .\n`, + ); + deps.exit(1); + } + useEnvVar = true; } const models = catalogProviderModels(entry); @@ -386,7 +392,8 @@ export async function handleCatalogAdd( catalogUrl: url, wire, baseUrl, - apiKeyEnvVar, + apiKey: useEnvVar ? undefined : literalApiKey, + apiKeyEnvVar: useEnvVar ? apiKeyEnvVar : undefined, models, selectedModelId: opts.defaultModel ?? '', thinking: false, @@ -519,17 +526,19 @@ export function registerProviderCommand(parent: Command, deps?: Partial') .description('Import a known provider from the catalog by id.') + .option('--api-key ', 'Provider API key to store in config.toml (takes precedence over --api-key-env).') .option('--api-key-env ', 'Environment variable containing the provider API key.') .option('--default-model ', 'Mark the imported model as default_model after import.') .option('--url ', `Override catalog URL. Defaults to ${DEFAULT_CATALOG_URL}.`) .action( async ( providerId: string, - options: { apiKeyEnv?: string; defaultModel?: string; url?: string }, + options: { apiKey?: string; apiKeyEnv?: string; defaultModel?: string; url?: string }, ) => { const resolved = resolveDeps(deps); await runAction(resolved, () => handleCatalogAdd(resolved, providerId, { + apiKey: options.apiKey, apiKeyEnv: options.apiKeyEnv, defaultModel: options.defaultModel, url: options.url, diff --git a/apps/pythinker-code/src/cli/update/preflight.ts b/apps/pythinker-code/src/cli/update/preflight.ts index dfdfdf43..e9d467e8 100644 --- a/apps/pythinker-code/src/cli/update/preflight.ts +++ b/apps/pythinker-code/src/cli/update/preflight.ts @@ -168,11 +168,17 @@ export function renderManualUpdateMessage( sourceDesc = 'unsupported package manager or layout.'; break; } + const homebrewHint = + source === 'homebrew' + ? `Homebrew installs do not auto-update. For automatic background updates, ` + + `switch to the native installer: ${NATIVE_INSTALL_COMMAND_UNIX}\n` + : ''; return ( `A newer version of ${NPM_PACKAGE_NAME} is available ` + `(${currentVersion} -> ${target.version}).\n` + `Detected install source: ${sourceDesc}\n` + - `To update manually, run: ${installCommand}\n` + `To update manually, run: ${installCommand}\n` + + homebrewHint ); } @@ -717,7 +723,12 @@ export type ManualUpdateResult = | { readonly status: 'check-failed'; readonly message: string } | { readonly status: 'started'; readonly version: string } | { readonly status: 'in-progress'; readonly version: string } - | { readonly status: 'manual'; readonly version: string; readonly command: string }; + | { + readonly status: 'manual'; + readonly version: string; + readonly command: string; + readonly source: InstallSource; + }; /** * Explicit user-requested update (TUI `/update`). Unlike the passive @@ -746,6 +757,7 @@ export async function startManualUpdate( status: 'manual', version: target.version, command: installCommandFor(source, target.version, platform), + source, }; } @@ -763,6 +775,7 @@ export async function startManualUpdate( status: 'manual', version: target.version, command: installCommandFor(source, target.version, platform), + source, }; } diff --git a/apps/pythinker-code/src/launcher.ts b/apps/pythinker-code/src/launcher.ts index ed99c4a9..3ef188d9 100644 --- a/apps/pythinker-code/src/launcher.ts +++ b/apps/pythinker-code/src/launcher.ts @@ -4,6 +4,24 @@ const FFI_FLAG = '--experimental-ffi'; const FFI_WARNING_FLAG = '--disable-warning=ExperimentalWarning'; const FFI_CHILD_ENV = 'PYTHINKER_CODE_FFI_CHILD'; const REQUIRED_RUNTIME = 'Node.js 26.4.0 or newer with experimental FFI support'; +const MINIMUM_NODE = [26, 4, 0] as const; +const NATIVE_INSTALL_HINT = + 'Alternatively, use the native installer (no Node.js required): https://code.pythinker.com'; + +/** + * Older Node (e.g. 24 LTS) has no `--experimental-ffi`, so the re-exec below + * would die with a cryptic `bad option` error. npm installs the package on any + * Node version (engines is only a warning for consumers), so guard here with + * an actionable message instead. + */ +function isRuntimeTooOld(): boolean { + const parts = process.versions.node.split('.').map(Number); + const [major = 0, minor = 0, patch = 0] = parts; + const [reqMajor, reqMinor, reqPatch] = MINIMUM_NODE; + if (major !== reqMajor) return major < reqMajor; + if (minor !== reqMinor) return minor < reqMinor; + return patch < reqPatch; +} function isFfiProcess(): boolean { // Only execArgv decides: a stale env marker must never bypass the FFI re-exec. @@ -66,6 +84,15 @@ function launchWindowsFallback( } async function launch(): Promise { + if (isRuntimeTooOld()) { + process.stderr.write( + `Pythinker Code requires ${REQUIRED_RUNTIME}; you are running Node.js ${process.versions.node}.\n` + + `${NATIVE_INSTALL_HINT}\n`, + ); + process.exitCode = 1; + return; + } + if (isFfiProcess()) { await import(new URL('./main.mjs', import.meta.url).href); return; diff --git a/apps/pythinker-code/src/tui/commands/auth.ts b/apps/pythinker-code/src/tui/commands/auth.ts index 8c19c999..40ce31f7 100644 --- a/apps/pythinker-code/src/tui/commands/auth.ts +++ b/apps/pythinker-code/src/tui/commands/auth.ts @@ -250,18 +250,24 @@ export async function connectCatalogProvider( return; } + const baseUrl = catalogBaseUrl(catalogEntry, wire); + const platformName = displayName ?? catalogEntry.name ?? providerId; + const apiKeyEnvVar = catalogEntry.env?.[0]?.trim(); - if (apiKeyEnvVar === undefined || apiKeyEnvVar.length === 0) { - host.showError(`Catalog provider "${providerId}" does not declare an API key environment variable.`); - return; - } - if (process.env[apiKeyEnvVar]?.trim().length === 0 || process.env[apiKeyEnvVar] === undefined) { - host.showError(`Environment variable "${apiKeyEnvVar}" is not set or is empty.`); - return; + const envVarHasValue = + apiKeyEnvVar !== undefined && + apiKeyEnvVar.length > 0 && + (process.env[apiKeyEnvVar]?.trim().length ?? 0) > 0; + let apiKey: string | undefined; + if (!envVarHasValue) { + const subtitleLines = [ + ...(baseUrl === undefined ? [] : [`${'base_url'.padEnd(12)}${baseUrl}`]), + `${'saved to'.padEnd(12)}~/.pythinker-code/config.toml`, + ]; + apiKey = await promptApiKey(host, platformName, subtitleLines); + if (apiKey === undefined) return; } - const baseUrl = catalogBaseUrl(catalogEntry, wire); - const platformName = displayName ?? catalogEntry.name ?? providerId; const models = catalogProviderModels(catalogEntry); if (models.length === 0) { host.showError('No models available for this platform.'); @@ -282,7 +288,8 @@ export async function connectCatalogProvider( catalogUrl: DEFAULT_CATALOG_URL, wire, baseUrl, - apiKeyEnvVar, + apiKey, + apiKeyEnvVar: envVarHasValue ? apiKeyEnvVar : undefined, models, selectedModelId: selection.model.id, thinking: selection.effort !== 'off', @@ -296,7 +303,7 @@ export async function connectCatalogProvider( }); await host.authFlow.refreshConfigAfterLogin(); - host.track('login', { provider: providerId, method: 'api_key_env' }); + host.track('login', { provider: providerId, method: envVarHasValue ? 'api_key_env' : 'api_key' }); host.showStatus(`Setup complete: ${platformName} · ${selection.model.id}`); } diff --git a/apps/pythinker-code/src/tui/commands/info.ts b/apps/pythinker-code/src/tui/commands/info.ts index cce9dfb8..ab95d375 100644 --- a/apps/pythinker-code/src/tui/commands/info.ts +++ b/apps/pythinker-code/src/tui/commands/info.ts @@ -10,7 +10,7 @@ import type { import { handleDoctor } from '#/cli/sub/doctor'; import { startManualUpdate } from '#/cli/update/preflight'; -import { PYTHINKER_CODE_CHANGELOG_URL } from '#/constant/app'; +import { NATIVE_INSTALL_COMMAND_UNIX, PYTHINKER_CODE_CHANGELOG_URL } from '#/constant/app'; import { openUrl } from '#/utils/open-url'; import { buildMcpStatusReportLines } from '../components/messages/mcp-status-panel'; import { buildStatusReportLines } from '../components/messages/status-panel'; @@ -323,7 +323,13 @@ export async function handleUpdateCommand( ); return; case 'manual': - host.showNotice(`Update available — v${result.version}`, `Run: ${result.command}`); + host.showNotice( + `Update available — v${result.version}`, + result.source === 'homebrew' + ? `Homebrew installs do not auto-update. Run: ${result.command}\n` + + `For automatic background updates, switch to the native installer: ${NATIVE_INSTALL_COMMAND_UNIX}` + : `Run: ${result.command}`, + ); return; case 'check-failed': host.showError(`Update check failed: ${result.message}`); diff --git a/apps/pythinker-code/test/cli/provider.test.ts b/apps/pythinker-code/test/cli/provider.test.ts index 1c02c477..32df66a5 100644 --- a/apps/pythinker-code/test/cli/provider.test.ts +++ b/apps/pythinker-code/test/cli/provider.test.ts @@ -1009,4 +1009,63 @@ describe('pythinker provider catalog add', () => { 'CUSTOM_ANTHROPIC_API_KEY', ); }); + + it('stores a literal --api-key when the environment variable is unset', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness, current } = makeHarness({ providers: {} } as PythinkerConfig); + const { deps, exitCodes } = makeDeps(harness, { env: {} }); + + await tryRun(() => handleCatalogAdd(deps, 'anthropic', { apiKey: 'sk-literal' })); + + expect(exitCodes).toEqual([]); + expect(current().providers['anthropic']?.apiKey).toBe('sk-literal'); + expect(current().providers['anthropic']?.apiKeyEnvVar).toBeUndefined(); + }); + + it('prefers a literal --api-key over a set environment variable', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness, current } = makeHarness({ providers: {} } as PythinkerConfig); + const { deps, exitCodes } = makeDeps(harness, { + env: { ANTHROPIC_API_KEY: 'from-env' }, + }); + + await tryRun(() => handleCatalogAdd(deps, 'anthropic', { apiKey: 'sk-literal' })); + + expect(exitCodes).toEqual([]); + expect(current().providers['anthropic']?.apiKey).toBe('sk-literal'); + expect(current().providers['anthropic']?.apiKeyEnvVar).toBeUndefined(); + }); + + it('stores a literal --api-key even when the catalog declares no credential name', async () => { + mockRegistryFetch({ + anthropic: { ...CATALOG_BODY.anthropic, env: undefined }, + }); + const { harness, current } = makeHarness({ providers: {} } as PythinkerConfig); + const { deps, exitCodes } = makeDeps(harness, { env: {} }); + + await tryRun(() => handleCatalogAdd(deps, 'anthropic', { apiKey: 'sk-literal' })); + + expect(exitCodes).toEqual([]); + expect(current().providers['anthropic']?.apiKey).toBe('sk-literal'); + expect(current().providers['anthropic']?.apiKeyEnvVar).toBeUndefined(); + }); + + it('routes --api-key through Commander', async () => { + mockRegistryFetch(CATALOG_BODY); + const { harness, current } = makeHarness({ providers: {} } as PythinkerConfig); + const { deps, exitCodes } = makeDeps(harness, { env: {} }); + const program = new Command('pythinker'); + registerProviderCommand(program, deps); + + await tryRun(() => + program.parseAsync( + ['node', 'pythinker', 'provider', 'catalog', 'add', 'anthropic', '--api-key', 'sk-flag'], + { from: 'node' }, + ), + ); + + expect(exitCodes).toEqual([]); + expect(current().providers['anthropic']?.apiKey).toBe('sk-flag'); + expect(current().providers['anthropic']?.apiKeyEnvVar).toBeUndefined(); + }); }); diff --git a/apps/pythinker-code/test/cli/update/preflight.test.ts b/apps/pythinker-code/test/cli/update/preflight.test.ts index 9e99e808..646b12e7 100644 --- a/apps/pythinker-code/test/cli/update/preflight.test.ts +++ b/apps/pythinker-code/test/cli/update/preflight.test.ts @@ -1502,6 +1502,7 @@ describe('startManualUpdate', () => { status: 'manual', version: '0.5.0', command: 'brew upgrade pythinker-code', + source: 'homebrew', }); expect(mocks.spawn).not.toHaveBeenCalled(); }); diff --git a/apps/pythinker-code/test/tui/commands/auth.test.ts b/apps/pythinker-code/test/tui/commands/auth.test.ts new file mode 100644 index 00000000..c1f7587d --- /dev/null +++ b/apps/pythinker-code/test/tui/commands/auth.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CatalogProviderEntry, PythinkerConfig } from '@pythoughts/pythinker-code-sdk'; + +import { connectCatalogProvider } from '#/tui/commands/auth'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; + +vi.mock('#/tui/commands/prompts', () => ({ + promptApiKey: vi.fn(), + promptLogoutProviderSelection: vi.fn(), + promptModelSelectionForCatalog: vi.fn(), + promptModelSelectionForOpenPlatform: vi.fn(), + promptPlatformSelection: vi.fn(), +})); + +const { promptApiKey, promptModelSelectionForCatalog } = await import('#/tui/commands/prompts'); + +const CATALOG_ENTRY: CatalogProviderEntry = { + id: 'anthropic', + name: 'Anthropic', + npm: '@ai-sdk/anthropic', + api: 'https://api.anthropic.com', + env: ['TEST_CATALOG_API_KEY'], + models: { + 'claude-opus-4-7': { + id: 'claude-opus-4-7', + name: 'Claude Opus 4.7', + limit: { context: 200_000, output: 64_000 }, + tool_call: true, + reasoning: true, + modalities: { input: ['text', 'image'], output: ['text'] }, + }, + }, +} as CatalogProviderEntry; + +function makeHost(initial: PythinkerConfig) { + let config = initial; + const errors: string[] = []; + const host = { + harness: { + getConfig: vi.fn(async () => config), + setConfig: vi.fn(async (patch: Partial) => { + config = { ...config, ...patch }; + }), + removeProvider: vi.fn(async (id: string) => { + delete config.providers[id]; + return config; + }), + }, + authFlow: { refreshConfigAfterLogin: vi.fn(async () => undefined) }, + showError: vi.fn((msg: string) => errors.push(msg)), + showStatus: vi.fn(), + track: vi.fn(), + restoreEditor: vi.fn(), + mountEditorReplacement: vi.fn(), + cancelInFlight: undefined, + } as unknown as SlashCommandHost; + return { host, errors, current: () => config }; +} + +describe('connectCatalogProvider credential acquisition', () => { + beforeEach(() => { + vi.mocked(promptModelSelectionForCatalog).mockResolvedValue({ + model: { id: 'claude-opus-4-7' } as never, + effort: 'off', + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.mocked(promptApiKey).mockReset(); + vi.mocked(promptModelSelectionForCatalog).mockReset(); + }); + + it('uses the env var without prompting when it is set', async () => { + vi.stubEnv('TEST_CATALOG_API_KEY', 'from-env'); + const { host, current } = makeHost({ providers: {} } as PythinkerConfig); + + await connectCatalogProvider(host, 'anthropic', CATALOG_ENTRY); + + expect(promptApiKey).not.toHaveBeenCalled(); + expect(current().providers['anthropic']).toMatchObject({ + apiKeyEnvVar: 'TEST_CATALOG_API_KEY', + }); + expect(current().providers['anthropic']?.apiKey).toBeUndefined(); + }); + + it('prompts for a key and stores it literally when the env var is unset', async () => { + vi.stubEnv('TEST_CATALOG_API_KEY', ''); + vi.mocked(promptApiKey).mockResolvedValue('sk-typed-in'); + const { host, errors, current } = makeHost({ providers: {} } as PythinkerConfig); + + await connectCatalogProvider(host, 'anthropic', CATALOG_ENTRY); + + expect(errors).toEqual([]); + expect(promptApiKey).toHaveBeenCalledWith( + host, + 'Anthropic', + expect.arrayContaining([expect.stringContaining('config.toml')]), + ); + expect(current().providers['anthropic']?.apiKey).toBe('sk-typed-in'); + expect(current().providers['anthropic']?.apiKeyEnvVar).toBeUndefined(); + }); + + it('prompts for a key when the catalog entry declares no env var', async () => { + vi.mocked(promptApiKey).mockResolvedValue('sk-typed-in'); + const entry = { ...CATALOG_ENTRY, env: undefined } as CatalogProviderEntry; + const { host, errors, current } = makeHost({ providers: {} } as PythinkerConfig); + + await connectCatalogProvider(host, 'anthropic', entry); + + expect(errors).toEqual([]); + expect(current().providers['anthropic']?.apiKey).toBe('sk-typed-in'); + expect(current().providers['anthropic']?.apiKeyEnvVar).toBeUndefined(); + }); + + it('aborts without writing config when the key prompt is cancelled', async () => { + vi.stubEnv('TEST_CATALOG_API_KEY', ''); + vi.mocked(promptApiKey).mockResolvedValue(undefined); + const { host, current } = makeHost({ providers: {} } as PythinkerConfig); + + await connectCatalogProvider(host, 'anthropic', CATALOG_ENTRY); + + expect(current().providers['anthropic']).toBeUndefined(); + expect(host.harness.setConfig).not.toHaveBeenCalled(); + expect(promptModelSelectionForCatalog).not.toHaveBeenCalled(); + }); +}); 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 5029b17c..dcf60215 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-startup.test.ts @@ -1785,7 +1785,7 @@ describe('PythinkerTUI startup', () => { } }); - it('requires a non-empty environment API key for catalog providers', async () => { + it('prompts for an API key when the catalog provider environment variable is unset', async () => { const setConfig = vi.fn(async (patch: unknown) => patch); const harness = makeHarness(makeSession(), { getConfig: vi.fn(async () => ({ providers: {}, models: {} })), @@ -1793,6 +1793,7 @@ describe('PythinkerTUI startup', () => { setConfig, }); const driver = makeDriver(harness, makeStartupInput()); + vi.spyOn((driver as any).authFlow, 'refreshConfigAfterLogin').mockResolvedValue(undefined); const showError = vi.spyOn(driver as any, 'showError').mockImplementation(() => {}); vi.mocked(promptPlatformSelection).mockResolvedValue({ platformId: `${CATALOG_PLATFORM_VALUE_PREFIX}deepseek`, @@ -1810,20 +1811,63 @@ describe('PythinkerTUI startup', () => { }, }); vi.mocked(promptApiKey).mockClear(); - vi.mocked(promptModelSelectionForCatalog).mockClear(); + vi.mocked(promptApiKey).mockResolvedValue('typed-in-secret'); + vi.mocked(promptModelSelectionForCatalog).mockImplementation( + async (_host, _providerId, models) => ({ model: models[0]!, effort: 'off' }), + ); try { vi.stubEnv('DEEPSEEK_API_KEY', ''); await handleLoginCommand(driver as any); + expect(showError).not.toHaveBeenCalled(); + expect(promptApiKey).toHaveBeenCalledTimes(1); + const configPatch = setConfig.mock.calls[0]?.[0] as { + providers: Record; + }; + expect(configPatch.providers['deepseek']?.apiKey).toBe('typed-in-secret'); + expect(configPatch.providers['deepseek']?.apiKeyEnvVar).toBeUndefined(); + expect(harness.track).toHaveBeenCalledWith('login', { + provider: 'deepseek', + method: 'api_key', + }); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('aborts catalog provider login when the API key prompt is cancelled', async () => { + const setConfig = vi.fn(async (patch: unknown) => patch); + const harness = makeHarness(makeSession(), { + getConfig: vi.fn(async () => ({ providers: {}, models: {} })), + removeProvider: vi.fn(), + setConfig, + }); + const driver = makeDriver(harness, makeStartupInput()); + vi.mocked(promptPlatformSelection).mockResolvedValue({ + platformId: `${CATALOG_PLATFORM_VALUE_PREFIX}deepseek`, + catalog: { + deepseek: { + id: 'deepseek', + name: 'Example provider', + npm: '@ai-sdk/openai-compatible', + api: 'https://api.example.test', + env: ['DEEPSEEK_API_KEY'], + models: { + chat: { id: 'example-chat', limit: { context: 128_000 } }, + }, + }, + }, + }); + vi.mocked(promptApiKey).mockClear(); + vi.mocked(promptApiKey).mockResolvedValue(undefined); + vi.mocked(promptModelSelectionForCatalog).mockClear(); + + try { vi.stubEnv('DEEPSEEK_API_KEY', undefined); await handleLoginCommand(driver as any); - expect(showError).toHaveBeenCalledTimes(2); - expect(showError).toHaveBeenCalledWith( - 'Environment variable "DEEPSEEK_API_KEY" is not set or is empty.', - ); - expect(promptApiKey).not.toHaveBeenCalled(); + expect(promptApiKey).toHaveBeenCalledTimes(1); expect(promptModelSelectionForCatalog).not.toHaveBeenCalled(); expect(setConfig).not.toHaveBeenCalled(); } finally { diff --git a/apps/pythinker-web/public/install.ps1 b/apps/pythinker-web/public/install.ps1 index d47771ec..c595be19 100644 --- a/apps/pythinker-web/public/install.ps1 +++ b/apps/pythinker-web/public/install.ps1 @@ -1,16 +1,18 @@ -# Pythinker Code — native Windows installer bootstrap. +# Pythinker Code — native Windows installer. # -# Downloads the latest PythinkerSetup-x.y.z.exe from GitHub Releases, verifies -# its SHA-256 file, and runs the per-user Inno Setup installer silently. +# 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://pythinker.com/install.ps1 | iex # # To pin a version when running the hosted script, set: -# $env:PYTHINKER_VERSION = "0.27.0"; irm https://pythinker.com/install.ps1 | iex +# $env:PYTHINKER_VERSION = "0.6.0"; irm https://pythinker.com/install.ps1 | iex # # Or run the script directly: -# .\install.ps1 -Version 0.27.0 +# .\install.ps1 -Version 0.6.0 [CmdletBinding()] param( @@ -21,6 +23,9 @@ param( $ErrorActionPreference = "Stop" $Repo = "Pythoughts-labs/pythinker-code" +# CDN source of truth for the latest published version — same endpoint the +# in-app updater reads, so a fresh install and an auto-update always agree. +$CdnLatestUrl = "https://code.pythinker.com/pythinker-code/latest" $InstallShUrl = "https://pythinker.com/install.sh" $InstallPs1Url = "https://pythinker.com/install.ps1" $NoColor = $env:NO_COLOR @@ -60,19 +65,21 @@ $script:AntennaSpinActive = $false function Show-Usage { @" -Pythinker Code — native Windows installer bootstrap. +Pythinker Code — native Windows installer. -Downloads the latest PythinkerSetup-x.y.z.exe from GitHub Releases, verifies -its SHA-256 file, and runs the per-user Inno Setup installer silently. +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.27.0"; irm $InstallPs1Url | iex + `$env:PYTHINKER_VERSION = "0.6.0"; irm $InstallPs1Url | iex # Or run directly: - .\install.ps1 -Version 0.27.0 + .\install.ps1 -Version 0.6.0 Unix / macOS / Linux users: curl -fsSL $InstallShUrl | bash @@ -257,10 +264,10 @@ function Write-Logo { if ($useAnim) { Write-LogoAnimated } else { Write-LogoStatic } } -function Print-Intro($version, $asset) { +function Print-Intro($version, $platformDisplay, $asset) { Write-Logo Write-Host (" {0,-11} {1}" -f "Version", $version) - Write-Host (" {0,-11} {1}" -f "Platform", "Windows x64") + Write-Host (" {0,-11} {1}" -f "Platform", $platformDisplay) Write-Host (" {0,-11} {1}" -f "Package", $asset) Write-Host "" } @@ -367,15 +374,15 @@ function Print-Done { } } -function Test-ReleaseHasInstaller($release) { - if ($release.draft -or $release.prerelease) { return $null } - $tag = [string]$release.tag_name - if (-not $tag) { return $null } - $candidate = $tag.TrimStart('v') - $exe = "PythinkerSetup-$candidate.exe" +# Releases are tagged "@pythoughts/pythinker-code@X.Y.Z"; the "@" and "/" +# must be percent-encoded in download and API URLs. +function Get-ReleaseTag($version) { return "@pythoughts/pythinker-code@$version" } +function Get-EncodedReleaseTag($version) { return [uri]::EscapeDataString((Get-ReleaseTag $version)) } + +function Test-ReleaseHasAsset($release, $asset) { + if ($release.draft -or $release.prerelease) { return $false } $names = @($release.assets | ForEach-Object { [string]$_.name }) - if (($names -contains $exe) -and ($names -contains "$exe.sha256")) { return $candidate } - return $null + return (($names -contains $asset) -and ($names -contains "$asset.sha256")) } function Format-ReleaseApiError($Uri, $ErrorRecord) { @@ -387,57 +394,36 @@ function Format-ReleaseApiError($Uri, $ErrorRecord) { } function Get-LatestVersion { + # The CDN is the source of truth (same endpoint the in-app updater reads). + # Fall back to the GitHub API if the CDN is unreachable. + try { + $raw = (Invoke-RestMethod -UseBasicParsing -Uri $CdnLatestUrl) + $candidate = ([string]$raw).Trim() + if ($candidate -match '^\d+\.\d+\.\d+$') { return $candidate } + } catch {} $latestApi = "https://api.github.com/repos/$Repo/releases/latest" - $listApi = "https://api.github.com/repos/$Repo/releases?per_page=100" - $delay = 4 - $elapsed = 0 - $maxElapsed = 360 - $lastApiError = $null - while ($true) { - try { - $latest = Invoke-RestMethod -UseBasicParsing -Uri $latestApi - $found = Test-ReleaseHasInstaller $latest - if ($found) { return $found } - } catch { - $lastApiError = Format-ReleaseApiError $latestApi $_ - } - try { - $releases = Invoke-RestMethod -UseBasicParsing -Uri $listApi - foreach ($release in @($releases)) { - $found = Test-ReleaseHasInstaller $release - if ($found) { return $found } - } - } catch { - $lastApiError = Format-ReleaseApiError $listApi $_ - } - if ($elapsed -ge $maxElapsed) { - $detail = if ($lastApiError) { " Last API error: $lastApiError" } else { "" } - Fail "no published release has a ready Windows installer asset after ~${maxElapsed}s; try again shortly or pin `$env:PYTHINKER_VERSION.$detail" - } - if ($useAnim) { - Write-Host -NoNewline ("${ESC}[$($script:ProgressRow);1H${ESC}[K ${DIM}Waiting${RESET} release assets, retrying in ${BAR}${delay}s${RESET}") - } else { - Write-Host " Waiting for release assets, retrying in ${delay}s" - } - Start-Sleep -Seconds $delay - $elapsed += $delay - $delay = [Math]::Min($delay * 2, 120) + try { + $latest = Invoke-RestMethod -UseBasicParsing -Uri $latestApi + $tag = [string]$latest.tag_name + if ($tag -match '^@pythoughts/pythinker-code@(\d+\.\d+\.\d+)$') { return $Matches[1] } + Fail "could not parse latest release tag '$tag' from $latestApi" + } catch { + Fail "could not resolve the latest version: $(Format-ReleaseApiError $latestApi $_)" } } function Wait-ReleaseAssets($version, $asset) { - $api = "https://api.github.com/repos/$Repo/releases/tags/v$version" + $api = "https://api.github.com/repos/$Repo/releases/tags/$(Get-EncodedReleaseTag $version)" $delay = 4 $elapsed = 0 $maxElapsed = 360 while ($true) { try { $release = Invoke-RestMethod -UseBasicParsing -Uri $api - $names = @($release.assets | ForEach-Object { [string]$_.name }) - if (($names -contains $asset) -and ($names -contains "$asset.sha256")) { return } + if (Test-ReleaseHasAsset $release $asset) { return } } catch {} if ($elapsed -ge $maxElapsed) { - Fail "release assets for v$version are not available after ~${maxElapsed}s: https://github.com/$Repo/releases/download/v$version/$asset`nThe latest release may still be publishing. Try again shortly, or pin a known-good version with -Version X.Y.Z" + Fail "release assets for $version are not available after ~${maxElapsed}s: https://github.com/$Repo/releases/download/$(Get-EncodedReleaseTag $version)/$asset`nThe latest release may still be publishing. Try again shortly, or pin a known-good version with -Version X.Y.Z" } if ($useAnim) { Write-Host -NoNewline ("${ESC}[$($script:ProgressRow);1H${ESC}[K ${DIM}Waiting${RESET} release assets, retrying in ${BAR}${delay}s${RESET}") @@ -469,12 +455,32 @@ if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) { if (-not $Version) { $Version = Get-LatestVersion } $Version = $Version.TrimStart('v') -$asset = "PythinkerSetup-$Version.exe" -$baseUrl = "https://github.com/$Repo/releases/download/v$Version" +# The machine's native architecture, independent of process emulation: an +# x64-emulated PowerShell on Windows ARM64 reports OSArchitecture=X64, but the +# registry value below always holds the real hardware architecture. +function Get-NativeArchitecture { + try { + $reg = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' -ErrorAction Stop + if ($reg.PROCESSOR_ARCHITECTURE) { return [string]$reg.PROCESSOR_ARCHITECTURE } + } catch {} + if ($env:PROCESSOR_ARCHITEW6432) { return $env:PROCESSOR_ARCHITEW6432 } + return [string]$env:PROCESSOR_ARCHITECTURE +} + +$nativeArchitecture = Get-NativeArchitecture +$archLabel = switch ($nativeArchitecture) { + 'ARM64' { 'arm64' } + 'AMD64' { 'x64' } + default { Fail "unsupported Windows architecture: $nativeArchitecture (need x64 or arm64)" } +} +$target = "win32-$archLabel" + +$asset = "pythinker-code-$target.zip" +$baseUrl = "https://github.com/$Repo/releases/download/$(Get-EncodedReleaseTag $Version)" $installerUrl = "$baseUrl/$asset" $shaUrl = "$installerUrl.sha256" -Print-Intro $Version $asset +Print-Intro $Version "Windows $archLabel" $asset Wait-ReleaseAssets $Version $asset if ($useAnim) { Write-Host -NoNewline ("${ESC}[$($script:ProgressRow);1H${ESC}[K") } @@ -495,24 +501,28 @@ try { } Phase-Ok "Verifying" - $installerArgs = @( - '/SILENT', - '/NORESTART', - '/CURRENTUSER', - '/CLOSEAPPLICATIONS', - '/NORESTARTAPPLICATIONS' - ) - $process = Start-Process -FilePath $installerPath -ArgumentList $installerArgs -Wait -PassThru - if ($process.ExitCode -ne 0) { - Fail "installer exited with code $($process.ExitCode)" + # The release zip contains a single pythinker.exe at its root. + $extractDir = Join-Path $tempDir "extracted" + Expand-Archive -LiteralPath $installerPath -DestinationPath $extractDir -Force + $binary = Join-Path $extractDir "pythinker.exe" + if (-not (Test-Path $binary)) { + Fail "archive did not contain pythinker.exe" } - Phase-Ok "Installing" $installDir = Join-Path $env:LOCALAPPDATA "Programs\Pythinker" - if (Test-Path (Join-Path $installDir "pythinker.exe")) { - if (($env:PATH -split ';') -notcontains $installDir) { - $env:PATH = "$installDir;$env:PATH" - } + New-Item -ItemType Directory -Path $installDir -Force | Out-Null + Copy-Item -LiteralPath $binary -Destination (Join-Path $installDir "pythinker.exe") -Force + Phase-Ok "Installing" + + # Persist the install dir on the user PATH, and make it available in + # this session so `pythinker` works without reopening the shell. + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + if (($userPath -split ';') -notcontains $installDir) { + $newPath = if ($userPath) { "$installDir;$userPath" } else { $installDir } + [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') + } + if (($env:PATH -split ';') -notcontains $installDir) { + $env:PATH = "$installDir;$env:PATH" } Print-Done diff --git a/apps/pythinker-web/public/install.sh b/apps/pythinker-web/public/install.sh index 21f0c70f..0032b487 100755 --- a/apps/pythinker-web/public/install.sh +++ b/apps/pythinker-web/public/install.sh @@ -1,26 +1,27 @@ #!/usr/bin/env bash # Pythinker Code — native curl-bash installer. # -# Downloads the PyInstaller-built single-file binary for your OS + arch from -# the latest GitHub Release, verifies its SHA-256, and installs it at +# Downloads the native single-file binary (Node SEA) for your OS + arch from +# the GitHub Release matching the CDN's latest version, verifies its SHA-256, +# and installs it at # ~/.local/bin/pythinker # # Usage: # curl -fsSL https://pythinker.com/install.sh | bash # # # Pin a specific version: -# curl -fsSL https://pythinker.com/install.sh | bash -s -- --version 0.27.0 +# curl -fsSL https://pythinker.com/install.sh | bash -s -- --version 0.6.0 # # # Custom install prefix (default $HOME/.local): # curl -fsSL https://pythinker.com/install.sh | bash -s -- --prefix /opt/pythinker # -# Supported targets (target triples — matches existing release artifacts): -# x86_64-unknown-linux-gnu (Linux x86_64) -# aarch64-unknown-linux-gnu (Linux ARM64) -# aarch64-apple-darwin (macOS Apple Silicon) -# x86_64-apple-darwin (macOS Intel) +# Supported targets (matches release artifact names pythinker-code-.zip): +# linux-x64 (Linux x86_64) +# linux-arm64 (Linux ARM64) +# darwin-arm64 (macOS Apple Silicon) +# darwin-x64 (macOS Intel) # -# Windows users: download PythinkerSetup-x.y.z.exe from the Releases page. +# Windows users: irm https://pythinker.com/install.ps1 | iex set -euo pipefail VERSION="" @@ -31,26 +32,27 @@ usage() { cat <<'EOF' Pythinker Code — native curl-bash installer. -Downloads the PyInstaller-built single-file binary for your OS + arch from -the latest GitHub Release, verifies its SHA-256, and installs it at +Downloads the native single-file binary for your OS + arch from the GitHub +Release matching the CDN's latest version, verifies its SHA-256, and +installs it at ~/.local/bin/pythinker Usage: curl -fsSL https://pythinker.com/install.sh | bash # Pin a specific version: - curl -fsSL https://pythinker.com/install.sh | bash -s -- --version 0.27.0 + curl -fsSL https://pythinker.com/install.sh | bash -s -- --version 0.6.0 # Custom install prefix (default $HOME/.local): curl -fsSL https://pythinker.com/install.sh | bash -s -- --prefix /opt/pythinker -Supported targets (target triples — matches existing release artifacts): - x86_64-unknown-linux-gnu (Linux x86_64) - aarch64-unknown-linux-gnu (Linux ARM64) - aarch64-apple-darwin (macOS Apple Silicon) - x86_64-apple-darwin (macOS Intel) +Supported targets (matches release artifact names pythinker-code-.zip): + linux-x64 (Linux x86_64) + linux-arm64 (Linux ARM64) + darwin-arm64 (macOS Apple Silicon) + darwin-x64 (macOS Intel) -Windows users: download PythinkerSetup-x.y.z.exe from the Releases page. +Windows users: irm https://pythinker.com/install.ps1 | iex EOF } @@ -70,17 +72,20 @@ while [[ $# -gt 0 ]]; do done REPO="Pythoughts-labs/pythinker-code" +# CDN source of truth for the latest published version — same endpoint the +# in-app updater reads, so a fresh install and an auto-update always agree. +CDN_LATEST_URL="https://code.pythinker.com/pythinker-code/latest" if [ -t 1 ] && [ -z "$NO_COLOR" ] && [ "${TERM:-}" != "dumb" ]; then NAVY=$'\033[38;5;24m'; FACE=$'\033[38;5;255m' ACCENT=$'\033[38;5;147m'; TIP=$'\033[38;5;216m' EYE=$'\033[38;5;189m'; BAR=$'\033[38;5;250m'; DIM=$'\033[2m' BOLD=$'\033[1m'; RESET=$'\033[0m' - # Shimmer / pulse tones for the "piece landed" + "leading edge" beats. - SHINE=$'\033[38;5;231m'; SOFT=$'\033[38;5;111m' + # Shimmer tone for the "piece landed" + "leading edge" beats. + SHINE=$'\033[38;5;231m' else NAVY=""; FACE=""; ACCENT=""; TIP=""; EYE=""; BAR=""; DIM=""; BOLD=""; RESET="" - SHINE=""; SOFT="" + SHINE="" fi _anim="" @@ -269,7 +274,7 @@ print_intro() { fi printf ' %-11s %s\n' "Version" "$VERSION" printf ' %-11s %s\n' "Platform" "$platform_display" - printf ' %-11s %s\n' "Package" "$tarball" + printf ' %-11s %s\n' "Package" "$archive" # Reserve the progress row one line below the metadata. The cursor # is now on that row; save it so the "Waiting" retry and the # download progress bar can absolute-position to it without @@ -432,14 +437,14 @@ print_logo_animated() { _render "$target_r" "$target_c" "0,0,─,$EYE" sleep 0.05 # Commit the closed eye and hold one beat so the blink registers. - _set_cell $target_r $target_c "─" "$EYE" + _set_cell "$target_r" "$target_c" "─" "$EYE" _render "" "" sleep 0.04 # Frame 3: open with a shine flash, then settle to the final color. - _set_cell $target_r $target_c "$eye_ch" "$SHINE" + _set_cell "$target_r" "$target_c" "$eye_ch" "$SHINE" _render "" "" sleep 0.06 - _set_cell $target_r $target_c "$eye_ch" "$EYE" + _set_cell "$target_r" "$target_c" "$eye_ch" "$EYE" _render "" "" } @@ -453,13 +458,13 @@ print_logo_animated() { _render "$r" "$target_c" "${cells[@]}" sleep "$FRAME_DELAY" done - _set_cell $target_r $target_c "●" "$TIP" + _set_cell "$target_r" "$target_c" "●" "$TIP" _render "$target_r" "$target_c" "0,0,●,$SHINE" sleep 0.07 - _set_cell $target_r $target_c "●" "$SHINE" + _set_cell "$target_r" "$target_c" "●" "$SHINE" _render "" "" sleep 0.05 - _set_cell $target_r $target_c "●" "$TIP" + _set_cell "$target_r" "$target_c" "●" "$TIP" _render "" "" } @@ -571,44 +576,55 @@ os="$(uname -s)" arch="$(uname -m)" case "$os/$arch" in Linux/x86_64|Linux/amd64) - target="x86_64-unknown-linux-gnu" + target="linux-x64" platform_display="Linux x64" ;; Linux/aarch64|Linux/arm64) - target="aarch64-unknown-linux-gnu" + target="linux-arm64" platform_display="Linux arm64" ;; Darwin/arm64) - target="aarch64-apple-darwin" + target="darwin-arm64" platform_display="macOS arm64" ;; Darwin/x86_64) - target="x86_64-apple-darwin" + target="darwin-x64" platform_display="macOS x64" ;; MINGW*/*|MSYS*/*|CYGWIN*/*) - fail "On Windows, download PythinkerSetup-x.y.z.exe from: -https://github.com/${REPO}/releases/latest - -PowerShell installer: + fail "On Windows, use the PowerShell installer: powershell -c \"irm https://pythinker.com/install.ps1 | iex\"" ;; *) fail "unsupported target: $os/$arch" ;; esac # --- resolve version ----------------------------------------------------- -if [ -z "$VERSION" ]; then - api="https://api.github.com/repos/${REPO}/releases/latest" +_fetch() { if command -v curl >/dev/null 2>&1; then - payload="$(curl -fsSL "$api")" + curl -fsSL "$1" elif command -v wget >/dev/null 2>&1; then - payload="$(wget -qO- "$api")" + wget -qO- "$1" else - fail "need curl or wget to fetch the release index" + return 127 + fi +} + +if [ -z "$VERSION" ]; then + command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 \ + || fail "need curl or wget to fetch the release index" + # The CDN is the source of truth (same endpoint the in-app updater reads). + # Fall back to the GitHub API if the CDN is unreachable. + 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)" + [ -z "$VERSION" ] && fail "could not parse latest release tag from $api" fi - VERSION="$(printf '%s' "$payload" | sed -nE 's/.*"tag_name": *"v([0-9]+\.[0-9]+\.[0-9]+)".*/\1/p' | head -n 1)" - [ -z "$VERSION" ] && fail "could not parse latest release tag from $api" fi -tarball="pythinker-${VERSION}-${target}.tar.gz" -tarball_url="https://github.com/${REPO}/releases/download/v${VERSION}/${tarball}" -sha_url="${tarball_url}.sha256" +# Releases are tagged "@pythoughts/pythinker-code@X.Y.Z"; the "@" and "/" +# must be percent-encoded in download and API URLs. +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" print_intro @@ -619,14 +635,10 @@ print_intro # checksum are attached (via the GitHub API, like the in-app updater) before # downloading, so a release caught mid-publish does not 404. release_has_assets() { - _api="https://api.github.com/repos/${REPO}/releases/tags/v${VERSION}" - if command -v curl >/dev/null 2>&1; then - _body="$(curl -fsSL "$_api" 2>/dev/null)" || return 1 - else - _body="$(wget -qO- "$_api" 2>/dev/null)" || return 1 - fi - printf '%s' "$_body" | grep -Fq "\"${tarball}\"" \ - && printf '%s' "$_body" | grep -Fq "\"${tarball}.sha256\"" + _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\"" } # Exponential backoff: the GitHub Release can briefly advertise a version # whose assets are still uploading. Wait 4,8,16,...,120s (capped), ~6m total, @@ -638,7 +650,7 @@ max_elapsed=360 until release_has_assets; do attempt=$((attempt + 1)) if [ "$elapsed" -ge "$max_elapsed" ]; then - fail "release assets for v${VERSION} are not available after ~${max_elapsed}s: ${tarball_url} + fail "release assets for ${VERSION} are not available after ~${max_elapsed}s: ${archive_url} The latest release may still be publishing. Try again shortly, or pin a known-good version with --version X.Y.Z" fi if [ -n "$_anim" ]; then @@ -657,14 +669,14 @@ done tmpdir="$(mktemp -d -t pythinker-install.XXXXXX)" # Layer: keep the cursor-show on every exit path, then clean up tmpdir. trap 'printf "\033[?25h" 2>/dev/null || true; rm -rf "$tmpdir"' EXIT -_download_with_progress "$tarball_url" "$tmpdir/$tarball" || fail "download failed: $tarball_url" -_download_quiet "$sha_url" "$tmpdir/$tarball.sha256" || fail "sha256 missing: $sha_url" +_download_with_progress "$archive_url" "$tmpdir/$archive" || fail "download failed: $archive_url" +_download_quiet "$sha_url" "$tmpdir/$archive.sha256" || fail "sha256 missing: $sha_url" -expected="$(awk '{print $1}' "$tmpdir/$tarball.sha256")" +expected="$(awk '{print $1}' "$tmpdir/$archive.sha256")" if command -v sha256sum >/dev/null 2>&1; then - actual="$(sha256sum "$tmpdir/$tarball" | awk '{print $1}')" + actual="$(sha256sum "$tmpdir/$archive" | awk '{print $1}')" elif command -v shasum >/dev/null 2>&1; then - actual="$(shasum -a 256 "$tmpdir/$tarball" | awk '{print $1}')" + actual="$(shasum -a 256 "$tmpdir/$archive" | awk '{print $1}')" else fail "need sha256sum or shasum to verify the download" fi @@ -674,12 +686,17 @@ phase_ok "Verifying" # --- install ----------------------------------------------------------- bin_dir="$INSTALL_PREFIX/bin" mkdir -p "$bin_dir" -# The existing release tarball contains a single `pythinker` file at the -# tarball root (PyInstaller --onefile output). -tar -C "$tmpdir" -xzf "$tmpdir/$tarball" -[ -x "$tmpdir/pythinker" ] || fail "tarball did not contain an executable named 'pythinker'" +# The release zip contains a single `pythinker` executable at its root. +if command -v unzip >/dev/null 2>&1; then + unzip -oq "$tmpdir/$archive" -d "$tmpdir" +elif tar -tf "$tmpdir/$archive" >/dev/null 2>&1; then + # bsdtar (macOS default) reads zip archives; GNU tar does not. + tar -C "$tmpdir" -xf "$tmpdir/$archive" +else + fail "need unzip (or bsdtar) to extract $archive" +fi +[ -e "$tmpdir/pythinker" ] || fail "archive did not contain an executable named 'pythinker'" install -m 0755 "$tmpdir/pythinker" "$bin_dir/pythinker" -printf 'pythinker-native-build\n' > "$bin_dir/.pythinker-native" phase_ok "Installing" # --- PATH guidance -------------------------------------------------------- diff --git a/apps/site/src/App.vue b/apps/site/src/App.vue index 57a3dceb..ec267581 100644 --- a/apps/site/src/App.vue +++ b/apps/site/src/App.vue @@ -2,6 +2,7 @@ import { onMounted, onUnmounted, ref } from 'vue'; import AgentLoop from './components/AgentLoop.vue'; import InstallCommand from './components/InstallCommand.vue'; +import LegacyDownloadsPopup from './components/LegacyDownloadsPopup.vue'; import PythinkerMascot from './components/PythinkerMascot.vue'; const version = __PYTHINKER_VERSION__; @@ -229,6 +230,9 @@ onUnmounted(() => {
+
+ +

Free and open source. MIT licensed. macOS, Linux, and Windows.

@@ -629,6 +633,12 @@ onUnmounted(() => { margin: 36px auto 0; } +.hero-download-milestone { + display: flex; + margin-top: 14px; + justify-content: center; +} + .hero-caption { margin-top: 12px; color: var(--ink-subtle); diff --git a/apps/site/src/components/AgentLoop.vue b/apps/site/src/components/AgentLoop.vue index 0356d1fb..77d8147a 100644 --- a/apps/site/src/components/AgentLoop.vue +++ b/apps/site/src/components/AgentLoop.vue @@ -19,10 +19,12 @@ diff --git a/docs/media/Architecture.webp b/docs/media/Architecture.webp index ee11c0fa..a05f4aab 100644 Binary files a/docs/media/Architecture.webp and b/docs/media/Architecture.webp differ