osdy-pi 1.11.1 → 1.11.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -8
- package/extensions/osdy-pi/account-profiles.test.ts +17 -0
- package/extensions/osdy-pi/account-profiles.ts +82 -22
- package/extensions/osdy-pi/control-center-account.test.ts +241 -0
- package/extensions/osdy-pi/control-center-account.ts +141 -0
- package/extensions/osdy-pi/control-center-agents.test.ts +107 -0
- package/extensions/osdy-pi/control-center-agents.ts +60 -0
- package/extensions/osdy-pi/control-center-git.test.ts +48 -0
- package/extensions/osdy-pi/control-center-git.ts +40 -0
- package/extensions/osdy-pi/control-center-preferences.test.ts +48 -0
- package/extensions/osdy-pi/control-center-preferences.ts +57 -0
- package/extensions/osdy-pi/control-center-sounds.test.ts +120 -0
- package/extensions/osdy-pi/control-center-sounds.ts +87 -0
- package/extensions/osdy-pi/control-center-todo.test.ts +68 -0
- package/extensions/osdy-pi/control-center-todo.ts +48 -0
- package/extensions/osdy-pi/control-center-usage.test.ts +90 -0
- package/extensions/osdy-pi/control-center-usage.ts +69 -0
- package/extensions/osdy-pi/control-center.test.ts +625 -0
- package/extensions/osdy-pi/control-center.ts +501 -0
- package/extensions/osdy-pi/profile-codex-usage.test.ts +218 -0
- package/extensions/osdy-pi/profile-codex-usage.ts +69 -0
- package/extensions/osdy-pi/profile-label.ts +40 -0
- package/extensions/osdy-pi/runtime-helpers.ts +40 -13
- package/extensions/osdy-pi/runtime.test.ts +990 -3
- package/extensions/osdy-pi/runtime.ts +179 -27
- package/extensions/osdy-pi/types.ts +6 -0
- package/extensions/osdy-pi/ui.test.ts +182 -2
- package/extensions/osdy-pi/ui.ts +55 -51
- package/extensions/osdy-pi/usage-analytics.test.ts +12 -2
- package/extensions/osdy-pi/usage-analytics.ts +2 -1
- package/package.json +2 -2
- package/scripts/osdy-pi-account-profiles.mjs +40 -0
package/README.md
CHANGED
|
@@ -6,16 +6,19 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
|
|
|
6
6
|
|
|
7
7
|
## Release highlights
|
|
8
8
|
|
|
9
|
+
**The Control Center consolidates Osdy controls in `/osdyConfig`**: ten categories cover appearance, Git, sounds, accounts, usage, and TODO/Agents provider settings. Account quota previews query a selected stored Codex profile without switching it. The header shows the installed Osdy package version when it fits; constrained layouts show only the centered mascot. See [Control Center in Pi](#control-center-in-pi) for confirmation and persistence boundaries.
|
|
10
|
+
|
|
9
11
|
**1.11.0 adds three Spider-Man-inspired dark palettes**, bringing the bundle to **23 themes**. It retains the four-page local usage analytics introduced in 1.10.0, with shared period and profile/provider/model filters, keyboard navigation, and metadata-only history. These version introductions are historical, not a fixed latest-version claim. After npm Trusted Publishing is configured, pushes to main (including direct pushes) run checks and attempt to publish an automatically allocated patch to npm. Failed checks publish nothing; authentication or registry failures can also stop publication. Verify the resolved npm version before installing. Maintainers: [automatic npm publishing setup](https://github.com/OsdyOrtiz/Osdy-Pi/blob/main/docs/npm-publishing.md).
|
|
10
12
|
|
|
11
13
|
| Area | What ships |
|
|
12
14
|
| --- | --- |
|
|
15
|
+
| Control Center | `/osdyConfig` opens ten categories, including confirmed TODO/Agents provider selection and explicit per-profile Codex quota previews. |
|
|
13
16
|
| Local analytics (1.10.0) | `/osdy-usage` opens Summary, Profiles, Models, and History pages for recorded tokens and estimated cost—not billing or subscription quota. |
|
|
14
17
|
| Themes introduced in 1.11.0 | Spider-Man Classic, Miles Morales, and Spider-Verse add three distinct dark palettes. |
|
|
15
18
|
| Themes introduced in 1.9.0 | Halloween and Halloween Killer add two dark palettes. |
|
|
16
19
|
| Themes introduced in 1.8.0 | Gruvbox Dark, Nord, Rosé Pine base dark, and Daniela Cute. |
|
|
17
20
|
| Message cards | Pi-native colored user cards without emojis and plain assistant Markdown preceded by a separate `🦝` transcript row. |
|
|
18
|
-
| Header and mascot |
|
|
21
|
+
| Header and mascot | Independent header/mascot choices, an installed-package version label, and mascot-only fallback when full artwork cannot fit. |
|
|
19
22
|
| Accounts | `/osdy-account` switches Codex profiles in place with atomic activation, rollback, bounded auth files, and process-safe locks. |
|
|
20
23
|
| Quota | `/usage` and compact bars emphasize remaining Codex quota at warning (40% or less) and error (15% or less) thresholds. |
|
|
21
24
|
| TODO (starting in 1.5.0, opt-in) | Default-off first-party `todo` snapshots track the Pi session branch; `/todos` reads them without editing. ODD Markdown remains separate. |
|
|
@@ -52,6 +55,26 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
|
|
|
52
55
|
|
|
53
56
|
Osdy Pi's own extension and themes are bundled—do not install them separately. Starting with **1.11.0**, the package bundles **23 themes**, including Spider-Man Classic, Miles Morales, and Spider-Verse: Gruvbox Dark, Nord, Rosé Pine base dark, and Daniela Cute were introduced in 1.8.0; Halloween and Halloween Killer were added in 1.9.0. Check your installed npm version to see which themes are available. Launch through `osdy-pi` (or `npm run pi:dev`) to activate a valid Osdy default account into Pi's shared agent directory before Pi starts. With no default, Pi remains unmanaged; run `/osdy-account` to create a profile and establish a default. On session start, Osdy Pi restores its persisted enabled state when a UI is available and preserves your selected Pi theme. When disabled, it leaves Gentle Shell (or Pi's native UI) untouched.
|
|
54
57
|
|
|
58
|
+
### Control Center in Pi
|
|
59
|
+
|
|
60
|
+
Run **`/osdyConfig`** in interactive Pi to open the Osdy Control Center. The ten categories—**Theme, Header, Mascot, Editor, Git, Sounds, Account, Usage, TODO, and Agents**—provide inline controls or data in the same modal. Existing commands remain available.
|
|
61
|
+
|
|
62
|
+
- Use **↑/↓**, **Home/End**, or **Page Up/Down** to navigate; **Tab** or **←/→** switches between categories and details. **Enter** selects.
|
|
63
|
+
- TODO shows configured selection, current eligibility, and actually loaded SDK registration separately. Opt-in/out requires an idle session and a Cancel-first inline confirmation naming the target, owned Gentle filter effects, and reload. Successful selection closes/disposes the panel and releases its editor hold before reloading once; restart Pi if reload fails. Rejections stay in the panel. No installs or task-history changes occur; legacy `/osdy-pi todo on|off|status` remains available.
|
|
64
|
+
- Agents inspects Joker/Gentle configuration without activating anything. Selection is idle-only and Cancel-first confirmed, naming the real target, possible Joker installation, owned agent filters, and preserved unrelated resources. Normal personal Pi only: `pi:dev` and isolated/override sessions are blocked, never redirected to personal settings. Success closes/disposes the panel and releases its editor hold before reloading once; restart Pi if reload fails. Legacy `/osdy-pi agents setup|on|off|status` is unchanged.
|
|
65
|
+
- Theme lists Pi's available themes and marks the current theme. A successful selection applies live and saves globally in the active Pi agent directory; project settings can override it at startup. Failures appear in the modal.
|
|
66
|
+
- Header offers **osdy-theme / neon**; Mascot offers **current / Bts**; Editor offers **auto / extended / simple**, with the current preference marked. Editor also shows the effective mode: small terminals stay simple/native even with extended selected.
|
|
67
|
+
- Preferences save immediately through the same runtime and global Osdy settings store as `/osdy-pi` commands (`<agent-dir>/extensions/osdy-pi/settings.json`). Header/Mascot apply live without closing the panel. Editor replacement waits until all overlays close; if another overlay remains, the next existing responsive watcher tick applies the latest choice. The effective mode reports the actually mounted editor while queued. Opening a category only reads values; while Osdy is disabled, choices save for later without enabling it.
|
|
68
|
+
- A selection shows **Saving** until persistence finishes and **Saved globally** only on success. Failed saves retain the live preference but report **could not be saved**; select again to retry. Application errors report **Not saved**, without claiming the runtime change succeeded. Duplicate selections are ignored while saving.
|
|
69
|
+
- **Git** shows the current branch (or detached HEAD), changed-entry count/clean status, and the current widget position as a read-only session detail. Working-tree enablement applies and saves through the same owner as `/osdy-pi working-tree`; Git inspection never writes Git. `/osdy-pi diff` and position commands are unchanged.
|
|
70
|
+
- **Sounds** shows each event's saved and effective path/source. Configure opens a single-line path editor **inside this overlay**: enter a readable `.mp3`/`.wav` path, then **Enter** to validate and save immediately, or **Esc** to cancel editing. Relative paths resolve against the project directory and save as absolute paths. Clear removes only that event's saved path from the existing v1 audio store (`<agent-dir>/extensions/osdy-pi/audio-notifications.json`). Notifications read this store afresh; refresh failures are reported separately from save failures.
|
|
71
|
+
- Sounds' master enablement is **unavailable**: the existing schema has paths, not an enable switch or presets. Startup `--osdy-pi-sound-<event>` flags override saved paths, so **clearing a saved path does not disable flag-configured playback**. Only **Test effective** explicitly plays a sound; opening categories and configuring paths never play. Tests report unavailable files/platforms or playback failures; playback remains macOS/Windows only. The legacy `/osdy-pi sound setup` wizard is preserved.
|
|
72
|
+
- **Account** lists validated profiles and current/default metadata. **View usage: profile** explicitly queries that profile's stored Codex credentials without activating it; opening, navigating or highlighting never queries. The preview shows remaining quota, next resets and checked time (local time); absent data is unknown, not zero. Switch, set default, and clear default require an **in-screen confirmation** with Cancel selected initially; **Esc** cancels confirmation without closing the overlay. Resize if the confirmation cannot fit. Switching waits for Pi to become idle, activates through the existing backend, and refreshes subscription usage. Login remains Pi-owned (`/login`); create/rename/remove remain available in `/osdy-account`. Category navigation never changes profiles or credentials.
|
|
73
|
+
- **Usage** shows the runtime's cached subscription quota (used percentage, window, reset/fetch times) and the existing local history owner's recorded token/cache totals, cost gaps, and model detail. Select **Range: day/week/month** for the current local calendar period; toggle **Accounts: all/current** for local analytics (unmanaged when no valid current profile exists). Quota always belongs to the active account, not the analytics filter. Select **Refresh quota and local history** explicitly; rendering and filters never fetch. Empty, loading, unavailable, and limited/incomplete coverage are reported honestly. Estimates are not billing or subscription quota. `/usage` and `/osdy-usage` remain unchanged.
|
|
74
|
+
- **Esc** closes without undoing live or saved changes. Account read-only previews are cancelled on category exit or close; late results are ignored even if cancellation is not honored. Other in-flight operations still finish and are not claimed cancelled. Late results cannot replace a closed overlay or another category. On narrow terminals, categories and details share the viewport; use Tab to switch.
|
|
75
|
+
|
|
76
|
+
This modal requires Pi's terminal UI, not RPC, JSON, or print mode. `/osdy-pi` remains the legacy extension command; the external shell command `osdy` (`bin/osdy.mjs`) remains the isolated launcher described below.
|
|
77
|
+
|
|
55
78
|
### Optional: isolated `osdy` command
|
|
56
79
|
|
|
57
80
|
Use this path when you want **installed, normal Pi** with Osdy-specific settings and sessions, without changing the official Pi profile. `pi install` loads package resources in Pi; it does not guarantee that package executables are available on your shell's `PATH`. To use the packaged commands, install the CLI separately (or run the scripts directly from a checkout):
|
|
@@ -193,6 +216,10 @@ For a project-local installation, run `pi remove -l npm:@juicesharp/rpiv-todo` i
|
|
|
193
216
|
|
|
194
217
|
## OpenAI account profiles
|
|
195
218
|
|
|
219
|
+
**Quick path:** `/osdyConfig` → **Account** → **View usage: profile** → optionally **Switch to profile** (Activate, Cancel-first confirmation). Each profile has its own explicit preview before its separate switch operation; the active profile also has a preview.
|
|
220
|
+
|
|
221
|
+
Preview is **Codex only**, a live query using the selected profile's **stored credentials snapshot**, with no automatic refresh. It never activates, borrows active credentials, refreshes tokens, writes auth, changes models/session state or overwrites active quota. Missing, malformed, expired or remotely rejected snapshots are unavailable—not zero quota. Even the current profile's stored snapshot may be stale: log in and save through the normal account workflow outside preview. Profile labels and decoded JWT claims do not verify human identity or server validity. Local history remains token/cost estimates, not subscription quota. Legacy `/osdy-account`, `/usage` and `/osdy-usage` remain unchanged; Agents configuration remains normal personal Pi only.
|
|
222
|
+
|
|
196
223
|
Osdy Pi can keep multiple ChatGPT Plus/Pro accounts authenticated and let you choose which one starts Pi. `personal` and `work` are only examples—you can create as many named profiles as you need.
|
|
197
224
|
|
|
198
225
|
### Create and use profiles
|
|
@@ -240,12 +267,14 @@ Profile names accept ASCII letters, numbers, and hyphens, up to 63 characters, p
|
|
|
240
267
|
|
|
241
268
|
### See and switch the active account
|
|
242
269
|
|
|
243
|
-
|
|
270
|
+
Direct `pi` startup restores the last activated profile label from valid active-account metadata. A valid explicit launcher profile (`OSDY_PI_PROFILE_NAME`) takes precedence. Osdy Pi shows the profile name in the editor:
|
|
244
271
|
|
|
245
272
|
- **Simple/native editor:** beside the model, for example `gpt-5.6-sol · personal · think high`.
|
|
246
273
|
- **Extended/framed editor:** `personal` replaces the `Osdy-Pi` title.
|
|
247
274
|
- **No managed profile:** the existing model line and `Osdy-Pi` title remain unchanged.
|
|
248
275
|
|
|
276
|
+
Label recovery is visual only: it does not switch credentials or change the configured default. Missing, invalid, unreadable, or stale active-account metadata keeps the existing fallback.
|
|
277
|
+
|
|
249
278
|
`account use <name>` activates the profile in Pi's shared agent directory, saves it as active/default, then starts Pi. Inside Pi, `/osdy-account` waits for active work to finish and swaps the canonical `auth.json` in place; the next request resolves the new account without a spawn, shutdown, session handoff, or restart.
|
|
250
279
|
|
|
251
280
|
To resume a specific session directly from the terminal:
|
|
@@ -254,7 +283,7 @@ To resume a specific session directly from the terminal:
|
|
|
254
283
|
osdy-pi account use work -- --session /absolute/path/to/session.jsonl
|
|
255
284
|
```
|
|
256
285
|
|
|
257
|
-
> **Privacy:** each profile keeps a private `auth.json`; Pi runs against its canonical shared `auth.json`.
|
|
286
|
+
> **Privacy:** each profile keeps a private `auth.json`; Pi runs against its canonical shared `auth.json`. Account activation/preservation atomically copies opaque auth files. The separate explicit **View usage: profile** preview reads a bounded stored auth snapshot, parses its Codex OAuth fields and unverified JWT claims, and sends the access token/account ID only to the fixed HTTPS Codex usage endpoint. It never refreshes tokens, logs in, switches accounts, writes auth, or queries automatically; it does not print or log credentials. Session history, settings, installed packages, and extension resources are shared, so every profile can access that local state. It keeps Pi's canonical `openai-codex` provider and delegates authentication to Pi's built-in `/login` flow.
|
|
258
287
|
|
|
259
288
|
Account switching is serialized in-process and protected by a private cross-process lock. Osdy Pi accepts only bounded regular auth files, refuses symlink-based auth sources, writes replacements with private permissions, and restores the previous canonical auth and account metadata when activation fails. A separate namespace lock protects concurrent profile creation, rename, and removal. Launchers activate the selected auth before starting Pi while continuing to use the shared agent directory; existing managed-directory links from earlier profile layouts are resolved for compatibility.
|
|
260
289
|
|
|
@@ -341,7 +370,9 @@ Header and mascot choices are independent:
|
|
|
341
370
|
|
|
342
371
|
Both commands update the UI immediately. Add `status` instead of a choice to inspect the current selection. Choices persist in the global Osdy Pi settings across reloads and sessions. The old top-level style commands and `classic` header aliases are no longer registered.
|
|
343
372
|
|
|
344
|
-
|
|
373
|
+
The header also shows **`osdy-pi v<version>`** at the top right in the theme's accent color, read from the installed Osdy package—not Pi's host version or the workspace manifest. It uses an empty first row or adds a row without overwriting artwork; invalid/unreadable versions or labels wider than the terminal are omitted.
|
|
374
|
+
|
|
375
|
+
In normal mode, the selected header and mascot render side by side when both fit. The Neon header derives its highlights from the active theme rather than using one fixed palette. Each mascot keeps its own tone map while its animated edge glow can resolve through theme colors. Header and mascot scaling remain independent, so any combination follows the same responsive layout rules.
|
|
345
376
|
|
|
346
377
|
Animation is enabled by default with an intro animation. Configure it through `OSDY_PI_ANIMATION`:
|
|
347
378
|
|
|
@@ -356,12 +387,12 @@ Animation is enabled by default with an intro animation. Configure it through `O
|
|
|
356
387
|
| Terminal mode | Header and mascot | Editor and Git | Footer |
|
|
357
388
|
| --- | --- | --- | --- |
|
|
358
389
|
| Normal | Full selected header and mascot side by side | Auto mode shows the framed editor by default; simple selects Pi's native editor and extended selects the framed editor; Git summary when enabled | Native editor: model/thinking, usage, path/branch, then statuses; framed editor: path/branch then statuses |
|
|
359
|
-
| Compact (72+ columns) |
|
|
390
|
+
| Compact (72+ columns, full artwork does not fit) | Centered mascot only; art and tone map scale proportionally | Selected editor mode and Git behavior | Same editor-aware footer behavior as normal/small modes |
|
|
360
391
|
| Small (<72 columns) | Mascot only; art and tone map scale proportionally | Pi native editor for every editor mode; Git summary hidden | Model + styled thinking level, usage, path/branch, then dynamic extension statuses (except Pi Lens) |
|
|
361
392
|
|
|
362
393
|
In the extended framed editor, the active thinking level is rendered in bold in the top-right model metadata so it remains easy to scan.
|
|
363
394
|
|
|
364
|
-
Small and compact modes trim only fully empty mascot-art and tone-map margins before applying one proportional width-and-height scale; mascot width starts near four-fifths of the available width. The
|
|
395
|
+
The full header and mascot render side by side only when the available width and height permit it without an additional width-limited mascot reduction. Otherwise, only the centered mascot renders—there is no scaled or stacked logo. Small and compact modes trim only fully empty mascot-art and tone-map margins before applying one proportional width-and-height scale; mascot width starts near four-fifths of the available width. The fallback mascot uses the available terminal rows, capped at 32 content rows, without reserving rows for a logo. The small-mode footer places the model and styled bare thinking level above usage, path/branch, and dynamic extension statuses. Pi Lens's footer status is hidden in small mode, but Pi Lens continues running. Usage includes input/output/cache-read/cache-write tokens, cost, and context. Extension statuses are supplied dynamically by Pi/extensions and may include Osdy Pi, MCP, or LSP; they are not hardcoded.
|
|
365
396
|
|
|
366
397
|
The enabled state, editor mode, working-tree visibility preference, header, and mascot persist globally across Pi reloads and sessions, shared by all projects. They are saved in `$PI_CODING_AGENT_DIR/extensions/osdy-pi/settings.json`, or `~/.pi/agent/extensions/osdy-pi/settings.json` when `PI_CODING_AGENT_DIR` is unset. Existing settings without the new visual fields safely default to `osdy-theme` and `current`; the former `raccoon` mascot value migrates to `bts`. The selected editor mode and working-tree placement are restored when the terminal moves normal → small → normal.
|
|
367
398
|
|
|
@@ -369,6 +400,7 @@ The enabled state, editor mode, working-tree visibility preference, header, and
|
|
|
369
400
|
|
|
370
401
|
| Group | Command |
|
|
371
402
|
| --- | --- |
|
|
403
|
+
| Control Center | `/osdyConfig` (interactive terminal UI) |
|
|
372
404
|
| Main | `/osdy-pi` |
|
|
373
405
|
| Main | `/osdy-pi enable\|disable\|on\|off\|status` |
|
|
374
406
|
| Accounts | `/osdy-account` |
|
|
@@ -399,7 +431,7 @@ At wide widths, the modal pairs the Session and Weekly detail cards. Below 72 co
|
|
|
399
431
|
|
|
400
432
|
Usage loads once when the session starts and refreshes when the modal opens or `r` is pressed; it does not poll. A refresh clears the previous snapshot before authentication resolves, and shutdown clears state, so quota data cannot cross account or profile boundaries.
|
|
401
433
|
|
|
402
|
-
> **Privacy:**
|
|
434
|
+
> **Privacy:** this active-account `/usage` path resolves OAuth through Pi's `modelRegistry`, rather than directly reading auth files. The separate Control Center **View usage: profile** path reads the selected profile's bounded stored credentials snapshot; it never borrows active credentials or refreshes tokens, logs in, switches accounts, writes auth, or queries automatically. Both paths transmit the access token/account ID only to the fixed HTTPS Codex usage endpoint, reject redirects, and bound timeout and response size. Quota previews do not persist or log credentials or display account IDs, tokens, raw response bodies, or endpoint internals.
|
|
403
435
|
|
|
404
436
|
### Local usage analytics (starting in 1.10.0)
|
|
405
437
|
|
|
@@ -530,6 +562,26 @@ npm run pi:dev -- account remove private --confirm private
|
|
|
530
562
|
PI_CODING_AGENT_DIR="$PWD/.pi-dev" OSDY_PI_DEV_EXTENSION_ROOT="$PWD" pi -e "$PWD"
|
|
531
563
|
```
|
|
532
564
|
|
|
565
|
+
### Control Center manual smoke
|
|
566
|
+
|
|
567
|
+
From this checkout with dependencies and Pi already available:
|
|
568
|
+
|
|
569
|
+
```bash
|
|
570
|
+
npm run pi:dev -- --no-session
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
This launches `pi` from `PATH`, loads this checkout with `-e`, and uses `.pi-dev` as the Pi agent directory.
|
|
574
|
+
The explicit argument bypasses the launcher's default-account activation; Pi's `--no-session` disables session persistence, **not settings writes or provider access**.
|
|
575
|
+
An isolated agent directory does **not** make custom audio paths or real provider accounts harmless. Use intentional disposable test profiles/files for profile writes, switches, default confirmations, and audio tests; do not assume an isolated account store makes real credentials safe.
|
|
576
|
+
|
|
577
|
+
- Open `/osdyConfig` in regular and fullscreen Pi. Check arrows/Home/End/Page Up/Down, Tab or left/right, Enter, and Esc; shrink and restore the terminal, checking both panes remain reachable.
|
|
578
|
+
- In disposable settings, change a visual preference, wait for **Saved globally**, close and reopen, then reload/restart to check persistence. Esc does not undo changes; distinguish save/application errors from success.
|
|
579
|
+
- Account (manual): with disposable profiles only, check Cancel-first switch/set-default/clear-default confirmations, Esc cancellation, idle switching, and current/default labels. Check explicit **View usage: profile** before optional switching, no query on navigation, and cancellation on exit. Create/rename/remove stay in `/osdy-account`, login in `/login`.
|
|
580
|
+
- Usage (manual): check day/week/month and all/current filters, empty/unavailable/limited states, and explicit refresh. Authenticated quota and post-switch account correctness need an intentionally authorized live provider check; local estimates are not billing.
|
|
581
|
+
- Sounds (manual): use disposable readable `.mp3`/`.wav` files to check configure, cancel, clear, and startup-flag precedence. Only choose **Test effective** when playback is intended; actual macOS/Windows playback still needs a live check.
|
|
582
|
+
|
|
583
|
+
Live TUI appearance, real account switching/authenticated quota, and actual audio playback remain unverified; this checklist is not a recorded pass.
|
|
584
|
+
|
|
533
585
|
Install a local checkout into Pi with:
|
|
534
586
|
|
|
535
587
|
```bash
|
|
@@ -551,7 +603,8 @@ npm run pi:dev
|
|
|
551
603
|
## Limits and troubleshooting
|
|
552
604
|
|
|
553
605
|
- The custom UI requires a Pi session with a UI; otherwise it does not mount.
|
|
554
|
-
-
|
|
606
|
+
- When the full side-by-side header and mascot cannot fit, only the centered, responsively scaled mascot renders; below 72 columns, Pi also uses the native editor and hides the Git summary until space returns.
|
|
607
|
+
- Fullscreen mascot scrolling remains under investigation; the responsive header changes do not establish that this issue is fixed.
|
|
555
608
|
- The Git summary reports unavailable when Git commands cannot read a working tree.
|
|
556
609
|
- Diff patches depend on readable repository files; a file whose patch cannot load shows the reported error in the panel.
|
|
557
610
|
- Audio playback is limited to macOS and Windows and to readable `.mp3`/`.wav` files.
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
availableProfiles,
|
|
8
8
|
manageAccountProfile,
|
|
9
9
|
manageAccountProfiles,
|
|
10
|
+
narrowStoredProfileCodexCredential,
|
|
11
|
+
readStoredProfileCodexCredential,
|
|
10
12
|
parseDefaultAccountResult,
|
|
11
13
|
sharedAgentDir,
|
|
12
14
|
switchAccountInPlace,
|
|
@@ -14,6 +16,21 @@ import {
|
|
|
14
16
|
// @ts-ignore Node's native TypeScript runner resolves test-only TypeScript source imports.
|
|
15
17
|
} from "./account-profiles.ts";
|
|
16
18
|
|
|
19
|
+
void test("stored credential adapter narrows unknown data and conceals reader paths", async () => {
|
|
20
|
+
const credential = { profile: "Work", access: "synthetic", expires: 2_000_000_000_000 };
|
|
21
|
+
assert.deepEqual(narrowStoredProfileCodexCredential({ ...credential, refresh: "SECRET", raw: "SECRET" }), credential);
|
|
22
|
+
for (const value of [null, [], {}, { ...credential, profile: "../Work" }, { ...credential, expires: NaN }, { ...credential, accountId: null }]) {
|
|
23
|
+
assert.equal(narrowStoredProfileCodexCredential(value), undefined);
|
|
24
|
+
}
|
|
25
|
+
const root = await mkdtemp(join(tmpdir(), "osdy-quota-adapter-SECRET-"));
|
|
26
|
+
await assert.rejects(readStoredProfileCodexCredential(root, "Work"), (error: unknown) => {
|
|
27
|
+
assert(error instanceof Error);
|
|
28
|
+
assert.equal(error.message, "Stored profile credentials are unavailable.");
|
|
29
|
+
assert.doesNotMatch(JSON.stringify(error), /SECRET|auth.json/);
|
|
30
|
+
return true;
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
17
34
|
void test("uses the account resolver for split-agent roots and explicit overrides", async () => {
|
|
18
35
|
const candidate = await mkdtemp(join(tmpdir(), "osdy-pi-extension-agent-test-"));
|
|
19
36
|
const managedParent = await mkdtemp(join(tmpdir(), "osdy-pi-extension-managed-test-"));
|
|
@@ -38,8 +38,14 @@ export interface AccountProfilesCommandDependencies {
|
|
|
38
38
|
requestRender?: (() => void) | undefined;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
function isProfileName(value:
|
|
42
|
-
return PROFILE_NAME.test(value) && !RESERVED_PROFILE_NAMES.has(value.toLowerCase());
|
|
41
|
+
export function isProfileName(value: unknown): boolean {
|
|
42
|
+
return typeof value === "string" && PROFILE_NAME.test(value) && !RESERVED_PROFILE_NAMES.has(value.toLowerCase());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Validate external active metadata before exposing it to typed consumers. */
|
|
46
|
+
export function readActiveProfileName(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
47
|
+
const profile = env.OSDY_PI_PROFILE_NAME;
|
|
48
|
+
return typeof profile === "string" && isProfileName(profile) ? profile : undefined;
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
export async function sharedAgentDir(env: NodeJS.ProcessEnv): Promise<string> {
|
|
@@ -64,19 +70,63 @@ export async function availableProfiles(baseDir: string): Promise<string[]> {
|
|
|
64
70
|
return profiles.listProfiles(baseDir);
|
|
65
71
|
}
|
|
66
72
|
|
|
73
|
+
export type StoredProfileCodexCredential = {
|
|
74
|
+
profile: string;
|
|
75
|
+
access: string;
|
|
76
|
+
expires: number;
|
|
77
|
+
accountId?: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Narrow the local owner module's unknown result without exposing raw auth. */
|
|
81
|
+
export function narrowStoredProfileCodexCredential(value: unknown): StoredProfileCodexCredential | undefined {
|
|
82
|
+
if (!value || typeof value !== "object" || Array.isArray(value) ||
|
|
83
|
+
!("profile" in value) || typeof value.profile !== "string" || !isProfileName(value.profile) ||
|
|
84
|
+
!("access" in value) || typeof value.access !== "string" || !value.access ||
|
|
85
|
+
!("expires" in value) || typeof value.expires !== "number" || !Number.isFinite(value.expires) ||
|
|
86
|
+
("accountId" in value && (typeof value.accountId !== "string" || !value.accountId))) return undefined;
|
|
87
|
+
return {
|
|
88
|
+
profile: value.profile,
|
|
89
|
+
access: value.access,
|
|
90
|
+
expires: value.expires,
|
|
91
|
+
...("accountId" in value && typeof value.accountId === "string" ? { accountId: value.accountId } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The JavaScript owner accepts root/name strings; its return remains unknown until narrowed.
|
|
96
|
+
function isStoredCredentialOwner(value: unknown): value is {
|
|
97
|
+
readStoredProfileCodexCredential(baseDir: string, profile: string): Promise<unknown>;
|
|
98
|
+
} {
|
|
99
|
+
return !!value && typeof value === "object" && "readStoredProfileCodexCredential" in value &&
|
|
100
|
+
typeof value.readStoredProfileCodexCredential === "function";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function readStoredProfileCodexCredential(baseDir: string, profile: string): Promise<StoredProfileCodexCredential> {
|
|
104
|
+
try {
|
|
105
|
+
const moduleUrl = new URL("../../scripts/osdy-pi-account-profiles.mjs", import.meta.url).href;
|
|
106
|
+
const owner: unknown = await import(moduleUrl);
|
|
107
|
+
if (!isStoredCredentialOwner(owner)) throw new Error("Unavailable owner.");
|
|
108
|
+
const raw: unknown = await owner.readStoredProfileCodexCredential(baseDir, profile);
|
|
109
|
+
const credential = narrowStoredProfileCodexCredential(raw);
|
|
110
|
+
if (!credential || !sameProfile(credential.profile, profile)) throw new Error("Invalid snapshot.");
|
|
111
|
+
return credential;
|
|
112
|
+
} catch {
|
|
113
|
+
throw new Error("Stored profile credentials are unavailable.");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
67
117
|
export async function switchAccountInPlace(
|
|
68
118
|
ctx: AccountContext,
|
|
69
119
|
profile: string,
|
|
70
120
|
activate: (profile: string) => Promise<void>,
|
|
71
121
|
refreshUsage?: () => Promise<void>,
|
|
72
122
|
requestRender?: () => void,
|
|
73
|
-
): Promise<
|
|
123
|
+
): Promise<boolean> {
|
|
74
124
|
if (!isProfileName(profile)) {
|
|
75
125
|
ctx.ui.notify(
|
|
76
126
|
"Cannot switch accounts: the selected profile name is invalid.",
|
|
77
127
|
"warning",
|
|
78
128
|
);
|
|
79
|
-
return;
|
|
129
|
+
return false;
|
|
80
130
|
}
|
|
81
131
|
if (!ctx.isIdle()) await ctx.waitForIdle();
|
|
82
132
|
try {
|
|
@@ -86,7 +136,7 @@ export async function switchAccountInPlace(
|
|
|
86
136
|
"Cannot switch accounts: the account files could not be safely activated.",
|
|
87
137
|
"warning",
|
|
88
138
|
);
|
|
89
|
-
return;
|
|
139
|
+
return false;
|
|
90
140
|
}
|
|
91
141
|
process.env.OSDY_PI_PROFILE_NAME = profile;
|
|
92
142
|
requestRender?.();
|
|
@@ -99,6 +149,7 @@ export async function switchAccountInPlace(
|
|
|
99
149
|
`Switched to ${profile}. Your next request uses this account.`,
|
|
100
150
|
"info",
|
|
101
151
|
);
|
|
152
|
+
return true;
|
|
102
153
|
}
|
|
103
154
|
|
|
104
155
|
function bundledLauncherPath(): string {
|
|
@@ -459,6 +510,31 @@ function runBundledCommand(
|
|
|
459
510
|
});
|
|
460
511
|
}
|
|
461
512
|
|
|
513
|
+
function activationFunction(value: unknown): value is (baseDir: string, profile: string) => Promise<unknown> {
|
|
514
|
+
return typeof value === "function";
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Shared backend only: authentication files and launcher output never enter a view. */
|
|
518
|
+
export function createAccountManagementDependencies(
|
|
519
|
+
options: AccountProfilesCommandDependencies = {},
|
|
520
|
+
): AccountManagementDependencies {
|
|
521
|
+
return {
|
|
522
|
+
profiles: async () => availableProfiles(await sharedAgentDir(process.env)),
|
|
523
|
+
run: runBundledCommand,
|
|
524
|
+
activeProfile: process.env.OSDY_PI_PROFILE_NAME,
|
|
525
|
+
refreshUsage: options.refreshUsage,
|
|
526
|
+
requestRender: options.requestRender,
|
|
527
|
+
activate: async (profile) => {
|
|
528
|
+
const moduleUrl = new URL("../../scripts/osdy-pi-account-profiles.mjs", import.meta.url).href;
|
|
529
|
+
const loaded: unknown = await import(moduleUrl);
|
|
530
|
+
if (typeof loaded !== "object" || loaded === null || !("switchAccountAuth" in loaded) || !activationFunction(loaded.switchAccountAuth)) {
|
|
531
|
+
throw new Error("Account activation unavailable");
|
|
532
|
+
}
|
|
533
|
+
await loaded.switchAccountAuth(await sharedAgentDir(process.env), profile);
|
|
534
|
+
},
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
462
538
|
export function registerAccountProfilesCommand(
|
|
463
539
|
pi: {
|
|
464
540
|
registerCommand(
|
|
@@ -479,23 +555,7 @@ export function registerAccountProfilesCommand(
|
|
|
479
555
|
ctx.ui.notify("Account manager requires an interactive Pi UI.", "warning");
|
|
480
556
|
return;
|
|
481
557
|
}
|
|
482
|
-
const dependencies
|
|
483
|
-
profiles: async () => availableProfiles(await sharedAgentDir(process.env)),
|
|
484
|
-
run: runBundledCommand,
|
|
485
|
-
activeProfile: process.env.OSDY_PI_PROFILE_NAME,
|
|
486
|
-
refreshUsage: commandDependencies.refreshUsage,
|
|
487
|
-
requestRender: commandDependencies.requestRender,
|
|
488
|
-
activate: async (profile) => {
|
|
489
|
-
const moduleUrl = new URL(
|
|
490
|
-
"../../scripts/osdy-pi-account-profiles.mjs",
|
|
491
|
-
import.meta.url,
|
|
492
|
-
).href;
|
|
493
|
-
const profiles = (await import(moduleUrl)) as {
|
|
494
|
-
switchAccountAuth(baseDir: string, name: string): Promise<void>;
|
|
495
|
-
};
|
|
496
|
-
await profiles.switchAccountAuth(await sharedAgentDir(process.env), profile);
|
|
497
|
-
},
|
|
498
|
-
};
|
|
558
|
+
const dependencies = createAccountManagementDependencies(commandDependencies);
|
|
499
559
|
if (mode === "rename" || mode === "remove") {
|
|
500
560
|
await manageAccountProfile(ctx, mode, dependencies);
|
|
501
561
|
return;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { registerHooks } from "node:module";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import type { ProfileCodexUsageResult } from "./profile-codex-usage.js";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
registerHooks({ resolve(specifier, context, next) {
|
|
8
|
+
if (specifier.startsWith(".") && specifier.endsWith(".js") && context.parentURL?.endsWith(".ts")) {
|
|
9
|
+
const url = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL);
|
|
10
|
+
if (existsSync(fileURLToPath(url))) return { shortCircuit: true, url: url.href };
|
|
11
|
+
}
|
|
12
|
+
return next(specifier, context);
|
|
13
|
+
} });
|
|
14
|
+
const { createControlCenterAccount, bindControlCenterAccount } = await import("./control-center-account.js");
|
|
15
|
+
|
|
16
|
+
void test("Profile preview is explicit, independently selected and never activates", async () => {
|
|
17
|
+
const requests: string[] = [];
|
|
18
|
+
const service = createControlCenterAccount({
|
|
19
|
+
profiles: () => Promise.resolve(["work", "personal"]), active: () => "work",
|
|
20
|
+
defaultProfile: () => Promise.resolve(undefined),
|
|
21
|
+
switch: () => { throw new Error("activation forbidden"); }, setDefault: () => { throw new Error("write forbidden"); },
|
|
22
|
+
usage: profile => {
|
|
23
|
+
requests.push(profile);
|
|
24
|
+
return Promise.resolve({ status: "ready", profile, checkedAt: 1000, quotaSnapshot: { fetchedAt: 1000,
|
|
25
|
+
planType: undefined, ordinaryUsageAllowed: undefined, credits: undefined,
|
|
26
|
+
buckets: [
|
|
27
|
+
{ id: "codex", label: undefined, primary: { usedPercent: 25, windowMinutes: 300, resetsAt: 2000 }, secondary: { usedPercent: 80, windowMinutes: 10080, resetsAt: undefined } },
|
|
28
|
+
{ id: "extra", label: "Extra bucket", primary: { usedPercent: 10, windowMinutes: undefined, resetsAt: undefined }, secondary: undefined },
|
|
29
|
+
] } });
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
const initial = await service.read();
|
|
33
|
+
assert.deepEqual(requests, []);
|
|
34
|
+
assert.equal(initial.rows[0]?.label, "View usage: work");
|
|
35
|
+
assert.ok(initial.rows.findIndex(row => row.label === "View usage: personal") < initial.rows.findIndex(row => row.label === "Switch to personal"));
|
|
36
|
+
for (const profile of ["work", "personal"]) {
|
|
37
|
+
assert.equal((await service.apply({ kind: "account-usage", profile })).failed, false);
|
|
38
|
+
const row = (await service.read()).rows.find(row => row.label === `View usage: ${profile}`);
|
|
39
|
+
assert.match(row?.details?.join(" ") ?? "", /75% remaining/);
|
|
40
|
+
assert.match(row?.details?.join(" ") ?? "", /Weekly: 20% remaining.*Next reset: unknown/);
|
|
41
|
+
assert.match(row?.details?.join(" ") ?? "", /Extra bucket.*90% remaining.*Next reset: unknown/);
|
|
42
|
+
assert.ok(row?.details?.includes(`Profile: ${profile}`));
|
|
43
|
+
assert.ok(row?.details?.includes(`Checked: ${new Date(1000).toLocaleString()} (local time)`));
|
|
44
|
+
assert.ok(row?.details?.some(line => line.endsWith(`Next reset: ${new Date(2000 * 1000).toLocaleString()}`)));
|
|
45
|
+
}
|
|
46
|
+
assert.deepEqual(requests, ["work", "personal"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
void test("Profile preview preserves timestamp units and marks missing or invalid resets unknown", async () => {
|
|
50
|
+
const checkedAt = 1_780_000_000_123;
|
|
51
|
+
for (const resetsAt of [1_780_000_000, 0, undefined, NaN, Infinity, -Infinity, Number.MAX_VALUE]) {
|
|
52
|
+
const service = createControlCenterAccount({
|
|
53
|
+
profiles: () => Promise.resolve(["work"]), active: () => "work",
|
|
54
|
+
defaultProfile: () => Promise.resolve(undefined),
|
|
55
|
+
switch: () => { throw new Error("activation forbidden"); },
|
|
56
|
+
setDefault: () => { throw new Error("write forbidden"); },
|
|
57
|
+
usage: profile => Promise.resolve({ status: "ready", profile, checkedAt,
|
|
58
|
+
quotaSnapshot: { fetchedAt: checkedAt, planType: undefined, ordinaryUsageAllowed: undefined, credits: undefined,
|
|
59
|
+
buckets: [{ id: "codex", label: undefined,
|
|
60
|
+
primary: { usedPercent: 25, windowMinutes: 300, resetsAt },
|
|
61
|
+
secondary: { usedPercent: 80, windowMinutes: 10080, resetsAt } }] } }),
|
|
62
|
+
});
|
|
63
|
+
await service.apply({ kind: "account-usage", profile: "work" });
|
|
64
|
+
const details = (await service.read()).rows[0]?.details ?? [];
|
|
65
|
+
assert.ok(details.includes(`Checked: ${new Date(checkedAt).toLocaleString()} (local time)`));
|
|
66
|
+
const expectedReset = resetsAt === 1_780_000_000 || resetsAt === 0
|
|
67
|
+
? new Date(resetsAt * 1000).toLocaleString() : "unknown";
|
|
68
|
+
for (const label of ["Session: 75", "Weekly: 20"]) {
|
|
69
|
+
assert.ok(details.includes(`codex / ${label}% remaining · Next reset: ${expectedReset}`));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
void test("Account projects validated metadata, never output or credential fields", async () => {
|
|
75
|
+
const service = createControlCenterAccount({
|
|
76
|
+
profiles: () => Promise.resolve(["work", "personal", "../auth.json", { token: "secret" }]),
|
|
77
|
+
active: () => "work", defaultProfile: () => Promise.resolve({ code: 0, stdout: "personal", stderr: "secret" }),
|
|
78
|
+
switch: () => Promise.resolve(true), setDefault: () => Promise.resolve(true),
|
|
79
|
+
});
|
|
80
|
+
const view = await service.read();
|
|
81
|
+
assert.match(view.summary, /Current: work.*Default: personal/);
|
|
82
|
+
assert.equal(JSON.stringify(view).includes("secret"), false);
|
|
83
|
+
assert.equal(JSON.stringify(view).includes("auth.json"), false);
|
|
84
|
+
assert.equal(view.rows.some(row => row.label === "Switch to personal"), true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
void test("Account revalidates membership and uses real switch/default adapters", async () => {
|
|
88
|
+
const calls: string[] = [];
|
|
89
|
+
const service = createControlCenterAccount({ profiles: () => Promise.resolve(["work"]), active: () => undefined,
|
|
90
|
+
defaultProfile: () => Promise.resolve({ code: 1, stdout: "secret", stderr: "secret" }),
|
|
91
|
+
switch: name => { calls.push(`switch:${name}`); return Promise.resolve(true); },
|
|
92
|
+
setDefault: name => { calls.push(`default:${name ?? "clear"}`); return Promise.resolve(true); },
|
|
93
|
+
});
|
|
94
|
+
assert.equal((await service.read()).summary.includes("secret"), false);
|
|
95
|
+
assert.equal((await service.apply({ kind: "account-switch", profile: "missing" })).failed, true);
|
|
96
|
+
assert.equal((await service.apply({ kind: "account-switch", profile: "work" })).failed, false);
|
|
97
|
+
assert.equal((await service.apply({ kind: "account-default", profile: "work" })).failed, false);
|
|
98
|
+
assert.deepEqual(calls, ["switch:work", "default:work"]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
void test("Unavailable default metadata cannot mark a profile named unavailable as default", async () => {
|
|
102
|
+
const service = createControlCenterAccount({ profiles: () => Promise.resolve(["unavailable"]), active: () => undefined,
|
|
103
|
+
defaultProfile: () => Promise.resolve({ code: 1, stdout: "", stderr: "" }),
|
|
104
|
+
switch: () => Promise.resolve(true), setDefault: () => Promise.resolve(true) });
|
|
105
|
+
assert.equal((await service.read()).rows.some(row => row.current), false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
void test("Bound Account reuses idle switching, quota refresh, and authoritative default commands", async () => {
|
|
109
|
+
const previous = process.env.OSDY_PI_PROFILE_NAME;
|
|
110
|
+
process.env.OSDY_PI_PROFILE_NAME = "personal";
|
|
111
|
+
const events: string[] = [];
|
|
112
|
+
const commands: string[][] = [];
|
|
113
|
+
let idle = false;
|
|
114
|
+
const service = bindControlCenterAccount({ isIdle: () => idle,
|
|
115
|
+
waitForIdle: () => { events.push("idle"); idle = true; return Promise.resolve(); },
|
|
116
|
+
ui: { notify: text => events.push(text), select: () => Promise.reject(new Error("old dialog forbidden")), input: () => Promise.reject(new Error("old dialog forbidden")) } },
|
|
117
|
+
() => { events.push("refresh"); return Promise.resolve(); }, () => { events.push("render"); },
|
|
118
|
+
{ profiles: () => Promise.resolve(["work", "personal"]), activate: name => { events.push(`activate:${name}`); return Promise.resolve(); },
|
|
119
|
+
run: args => { commands.push(args); return Promise.resolve({ code: 0, stdout: "work", stderr: "secret" }); } });
|
|
120
|
+
try {
|
|
121
|
+
await service.read();
|
|
122
|
+
assert.equal((await service.apply({ kind: "account-switch", profile: "work" })).failed, false);
|
|
123
|
+
assert.deepEqual(events.slice(0, 4), ["idle", "activate:work", "render", "refresh"]);
|
|
124
|
+
assert.equal(process.env.OSDY_PI_PROFILE_NAME, "work");
|
|
125
|
+
await service.apply({ kind: "account-default", profile: "work" });
|
|
126
|
+
await service.apply({ kind: "account-default", profile: undefined });
|
|
127
|
+
assert.deepEqual(commands, [["account", "default"], ["account", "default", "work"], ["account", "default", "--clear"]]);
|
|
128
|
+
assert.equal(events.join(" ").includes("secret"), false);
|
|
129
|
+
} finally {
|
|
130
|
+
if (previous === undefined) delete process.env.OSDY_PI_PROFILE_NAME;
|
|
131
|
+
else process.env.OSDY_PI_PROFILE_NAME = previous;
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
void test("Bound Account validates live external active names without rendering unsafe metadata", async () => {
|
|
136
|
+
const previous = process.env.OSDY_PI_PROFILE_NAME;
|
|
137
|
+
const service = bindControlCenterAccount({ isIdle: () => true, waitForIdle: () => Promise.resolve(),
|
|
138
|
+
ui: { notify: () => {}, select: () => Promise.resolve(undefined), input: () => Promise.resolve(undefined) } },
|
|
139
|
+
() => Promise.resolve(), () => {},
|
|
140
|
+
{ profiles: () => Promise.resolve(["work"]), run: () => Promise.resolve({ code: 0, stdout: "No default account.", stderr: "" }) });
|
|
141
|
+
try {
|
|
142
|
+
for (const value of [undefined, "", "../auth.json", "auth.json", "DEFAULT", "token-secret\n", "x".repeat(64)]) {
|
|
143
|
+
if (value === undefined) delete process.env.OSDY_PI_PROFILE_NAME;
|
|
144
|
+
else process.env.OSDY_PI_PROFILE_NAME = value;
|
|
145
|
+
const view = await service.read();
|
|
146
|
+
assert.match(view.summary, /Current: unmanaged/);
|
|
147
|
+
assert.equal(view.rows.some(row => row.label === "Switch to work"), true);
|
|
148
|
+
assert.equal(JSON.stringify(view).includes("token-secret"), false);
|
|
149
|
+
assert.equal(JSON.stringify(view).includes("auth.json"), false);
|
|
150
|
+
}
|
|
151
|
+
process.env.OSDY_PI_PROFILE_NAME = "Work";
|
|
152
|
+
assert.match((await service.read()).summary, /Current: Work/);
|
|
153
|
+
assert.equal((await service.read()).rows.some(row => row.label === "Switch to work"), false);
|
|
154
|
+
} finally {
|
|
155
|
+
if (previous === undefined) delete process.env.OSDY_PI_PROFILE_NAME;
|
|
156
|
+
else process.env.OSDY_PI_PROFILE_NAME = previous;
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
void test("Account busy guarding rejects overlap without cancelling the first operation", async () => {
|
|
161
|
+
let finish: (value: boolean) => void = () => {};
|
|
162
|
+
let switches = 0;
|
|
163
|
+
const service = createControlCenterAccount({ profiles: () => Promise.resolve(["work"]), active: () => undefined,
|
|
164
|
+
defaultProfile: () => Promise.resolve(undefined), setDefault: () => Promise.resolve(true),
|
|
165
|
+
switch: () => { switches++; return new Promise(resolve => { finish = resolve; }); } });
|
|
166
|
+
const first = service.apply({ kind: "account-switch", profile: "work" });
|
|
167
|
+
await Promise.resolve(); await Promise.resolve();
|
|
168
|
+
assert.equal((await service.apply({ kind: "account-switch", profile: "work" })).failed, true);
|
|
169
|
+
assert.equal(switches, 1); finish(true); assert.equal((await first).failed, false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
void test("Account errors are fixed messages and never leak backend errors", async () => {
|
|
173
|
+
const service = createControlCenterAccount({ profiles: () => Promise.resolve(["work"]), active: () => "work",
|
|
174
|
+
defaultProfile: () => Promise.reject(new Error("token-secret")),
|
|
175
|
+
switch: () => Promise.reject(new Error("token-secret")), setDefault: () => Promise.resolve(false),
|
|
176
|
+
});
|
|
177
|
+
assert.equal(JSON.stringify(await service.read()).includes("token-secret"), false);
|
|
178
|
+
const result = await service.apply({ kind: "account-default", profile: "work" });
|
|
179
|
+
assert.equal(result.failed, true);
|
|
180
|
+
assert.equal(result.message.includes("token-secret"), false);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
void test("Profile preview cancellation, supersession and stale runtime discard ignored-abort results", async () => {
|
|
184
|
+
const requests: { signal: AbortSignal; finish: (result: ProfileCodexUsageResult) => void }[] = [];
|
|
185
|
+
let current = true;
|
|
186
|
+
const service = createControlCenterAccount({ profiles: () => Promise.resolve(["work", "personal"]), active: () => "work",
|
|
187
|
+
defaultProfile: () => Promise.resolve(undefined), switch: () => { throw new Error("forbidden"); }, setDefault: () => { throw new Error("forbidden"); },
|
|
188
|
+
isCurrent: () => current, usage: (_profile, signal) => new Promise(finish => requests.push({ signal, finish })),
|
|
189
|
+
});
|
|
190
|
+
const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); };
|
|
191
|
+
const first = service.apply({ kind: "account-usage", profile: "work" }); await flush();
|
|
192
|
+
assert.equal((await service.apply({ kind: "account-default", profile: undefined })).failed, true);
|
|
193
|
+
const second = service.apply({ kind: "account-usage", profile: "personal" }); await flush();
|
|
194
|
+
assert.equal(requests[0]?.signal.aborted, true);
|
|
195
|
+
requests[1]?.finish({ status: "unavailable", profile: "personal", checkedAt: 1000, reason: "remote-usage-unavailable" });
|
|
196
|
+
await second;
|
|
197
|
+
requests[0]?.finish({ status: "unavailable", profile: "work", checkedAt: 1000, reason: "stored-credentials-unavailable" }); await first;
|
|
198
|
+
assert.match(JSON.stringify(await service.read()), /Remote Codex usage unavailable/);
|
|
199
|
+
assert.doesNotMatch(JSON.stringify(await service.read()), /Stored credentials unavailable \(missing/);
|
|
200
|
+
const third = service.apply({ kind: "account-usage", profile: "work" }); await flush();
|
|
201
|
+
service.cancelUsage?.(); assert.equal(requests[2]?.signal.aborted, true);
|
|
202
|
+
requests[2]?.finish({ status: "unavailable", profile: "work", checkedAt: 1000, reason: "stored-credentials-unavailable" }); await third;
|
|
203
|
+
assert.doesNotMatch(JSON.stringify(await service.read()), /Checked:/);
|
|
204
|
+
const fourth = service.apply({ kind: "account-usage", profile: "work" }); await flush(); current = false;
|
|
205
|
+
requests[3]?.finish({ status: "unavailable", profile: "work", checkedAt: 1000, reason: "stored-credentials-unavailable" }); await fourth;
|
|
206
|
+
assert.doesNotMatch(JSON.stringify(await service.read()), /Checked:/);
|
|
207
|
+
assert.equal((await service.apply({ kind: "account-switch", profile: "personal" })).failed, true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
void test("Unavailable, cancelled, absent quota and unexpected errors never fabricate zero or leak raw errors", async () => {
|
|
211
|
+
for (const outcome of ["stored-credentials-unavailable", "remote-usage-unavailable", "cancelled", "empty", "error"] as const) {
|
|
212
|
+
const service = createControlCenterAccount({ profiles: () => Promise.resolve(["work"]), active: () => "work",
|
|
213
|
+
defaultProfile: () => Promise.resolve(undefined), switch: () => Promise.resolve(true), setDefault: () => Promise.resolve(true),
|
|
214
|
+
usage: profile => {
|
|
215
|
+
if (outcome === "error") throw new Error("/private/auth.json token-secret");
|
|
216
|
+
if (outcome === "empty") return Promise.resolve({ status: "ready", profile, checkedAt: 1000,
|
|
217
|
+
quotaSnapshot: { fetchedAt: 1000, buckets: [], credits: undefined, planType: undefined, ordinaryUsageAllowed: undefined } });
|
|
218
|
+
if (outcome === "cancelled") return Promise.resolve({ status: "cancelled", profile, checkedAt: 1000, reason: "cancelled" });
|
|
219
|
+
return Promise.resolve({ status: "unavailable", profile, checkedAt: 1000, reason: outcome });
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
await service.apply({ kind: "account-usage", profile: "work" });
|
|
223
|
+
const text = JSON.stringify(await service.read());
|
|
224
|
+
assert.doesNotMatch(text, /0%|token-secret|private\/auth/);
|
|
225
|
+
assert.match(text, outcome === "empty" ? /unknown/ : /unavailable|cancelled/);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
void test("Bound preview uses injected service independently of active refresh and queued stale activation", async () => {
|
|
230
|
+
let current = true; let release = () => {}; const names: string[] = [];
|
|
231
|
+
const service = bindControlCenterAccount({ isIdle: () => false, waitForIdle: () => new Promise<void>(resolve => { release = resolve; }),
|
|
232
|
+
ui: { notify: () => {}, select: () => Promise.resolve(undefined), input: () => Promise.resolve(undefined) } },
|
|
233
|
+
() => { throw new Error("active quota forbidden"); }, () => {},
|
|
234
|
+
{ profiles: () => Promise.resolve(["work"]), activate: () => { throw new Error("stale activation forbidden"); }, run: () => Promise.resolve({ code: 0, stdout: "", stderr: "" }) },
|
|
235
|
+
{ isCurrent: () => current, usage: (profile, signal) => { names.push(profile); assert.equal(signal.aborted, false);
|
|
236
|
+
return Promise.resolve({ status: "unavailable", profile, checkedAt: 1000, reason: "stored-credentials-unavailable" }); } });
|
|
237
|
+
await service.apply({ kind: "account-usage", profile: "work" }); assert.deepEqual(names, ["work"]);
|
|
238
|
+
const switching = service.apply({ kind: "account-switch", profile: "work" });
|
|
239
|
+
for (let i = 0; i < 8; i++) await Promise.resolve();
|
|
240
|
+
current = false; release(); assert.equal((await switching).failed, true);
|
|
241
|
+
});
|