spinuptui 0.27.0 → 1.0.0
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/CHANGELOG.md +32 -4
- package/README.md +16 -4
- package/bun.lock +103 -0
- package/package.json +3 -2
- package/src/api/client.ts +33 -3
- package/src/config.ts +232 -8
- package/src/index.tsx +25 -5
- package/src/lib/appUpdate.ts +16 -4
- package/src/lib/dnsCache.ts +9 -6
- package/src/lib/keychain.ts +11 -5
- package/src/lib/providersCache.ts +9 -6
- package/src/lib/serverClone.ts +145 -12
- package/src/lib/spinupweb.ts +7 -0
- package/src/lib/stack.ts +7 -4
- package/src/lib/stackCache.ts +9 -6
- package/src/lib/wpCli.ts +54 -2
- package/src/lib/wpCore.ts +7 -18
- package/src/lib/wpInventory.ts +18 -14
- package/src/ui/App.tsx +10 -0
- package/src/ui/Header.tsx +31 -1
- package/src/ui/Help.tsx +4 -3
- package/src/ui/accounts.tsx +21 -0
- package/src/ui/store.tsx +117 -30
- package/src/ui/views/Accounts.tsx +370 -0
- package/src/ui/views/CloneWizard.tsx +33 -5
- package/src/ui/views/Stacks.tsx +31 -6
- package/src/ui/wpCoreJobs.tsx +18 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,12 +5,38 @@ All notable changes to this project are documented here.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
From 1.0.0, the public surface (CLI subcommands/flags, config file location and
|
|
9
|
+
format, and token-resolution behavior) changes incompatibly only in a major
|
|
10
|
+
version; such changes are called out here.
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [1.0.0] - 2026-09-24
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- **Install with Homebrew.** `brew install mwender/tap/spinuptui` installs SpinupTUI along with Bun, and `brew upgrade spinuptui` updates it. The About panel (`?`) recognizes a Homebrew install and shows `brew upgrade spinuptui` as its update command instead of the Bun one.
|
|
18
|
+
- **API headroom in the header.** The top bar now shows how many SpinupWP API requests are left in the current minute (`API 52/60`). It stays dim while there's plenty, turns amber once three-quarters of the minute's budget is spent, and red when SpinupTUI starts spacing out requests to avoid hitting the limit, so a slow moment has a visible reason. It refills on its own when the minute rolls over.
|
|
19
|
+
- **Clone Radicle sites.** The clone wizard now clones git-deployed Radicle sites properly. Uploads come across from `content/uploads`, and after the database import the site's own deploy script runs on the new server, so the front-end is built (`npm ci`, `npm run build`, Acorn caches) the way a normal deploy would. Before creating anything, the wizard stops if the Radicle site has no deploy script, or if its script needs Node and the destination server doesn't have it. SpinupWP servers don't come with Node, so install it on the destination first.
|
|
20
|
+
- **Clones keep your cron jobs.** Lines you added to a site's crontab (a feed import, a report, an Acorn or Laravel command) are copied to the clone under a `# Carried over from …` comment. Lines you commented out are copied as they are, SpinupWP's own WordPress cron entry is left to SpinupWP, and a retry never adds a line twice. The verify pane (`v`) says how many lines came across.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
- **Clones match the source's page cache and push-to-deploy** instead of using fixed defaults, and send the source's own deploy script.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- **Plugins & themes (`e`) no longer shows an empty list on sites with noisy plugins.** Some plugins print PHP notices into wp-cli's output, sometimes on the same line as the plugin list, and the view read that as "no plugins installed". The list is now found wherever it sits in the output, and if wp-cli returns no list at all the view says so, with the first line of what it printed, instead of showing zero plugins. The WordPress core update check uses the same parsing.
|
|
27
|
+
|
|
28
|
+
### Notes
|
|
29
|
+
- **SpinupWP clears a deploy script set through its API** a minute or two after the site is created, and there's no API call to set it afterwards. After a clone, the wizard checks the new site; if the script is gone, the roster marks the site and the verify pane offers `g`, which copies the source's script and opens the new site's Git settings so you can paste it back. The same check flags a push-to-deploy setting that didn't carry over.
|
|
30
|
+
|
|
31
|
+
## [0.28.0] - 2026-09-23
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
- **Multiple SpinupWP accounts.** Press `A` from anywhere to open **Accounts**: add another account (its token is checked with SpinupWP before it's saved), switch between accounts, rename them, or remove one. You work in one account at a time, never a merged fleet, so an action can't land on the wrong client's server, and the header always shows `Account: …`. Switching reloads the app on the new account, and it waits until running changes (PHP upgrades, DNS edits, clones, WordPress updates…) finish. Each account keeps its own token, DNS provider connections, local-copy links, sudo users and saved Keychain passwords, monitors, and cached probes. Machine-level settings (terminal app, local scan roots, the Uptime Kuma login) are shared by all of them. Your existing setup becomes the first account automatically; nothing moves on disk. Removing an account also deletes its Keychain passwords and caches, but never files in your local working copies. For commands, `SPINUPTUI_ACCOUNT=<id>` picks an account for one run, and `spinuptui where` lists the account ids.
|
|
35
|
+
- **Vanity Site in Stacks.** Non-WP now has a **Vanity Site** row for each server's own-hostname site, the same rule the vanity features use, so these show up without an SSH probe instead of sitting under Unknown or unprobed. Their rows read `Vanity Site` in place of `Unknown`.
|
|
36
|
+
|
|
37
|
+
### Notes
|
|
38
|
+
- **Config migration.** The first time this version saves a setting, `config.json` is rewritten in the new accounts shape. A version before 0.28.0 can't read the token from it and would run first-time setup, so back up `~/.config/spinupwp-tui/config.json` if you might downgrade. Accounts are stored per machine; add them on each machine where you use SpinupTUI.
|
|
39
|
+
|
|
14
40
|
## [0.27.0] - 2026-09-23
|
|
15
41
|
|
|
16
42
|
### Added
|
|
@@ -1427,7 +1453,9 @@ Initial tagged release.
|
|
|
1427
1453
|
### Notes
|
|
1428
1454
|
- Read-only release: works with a SpinupWP **Read Only** API token.
|
|
1429
1455
|
|
|
1430
|
-
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/
|
|
1456
|
+
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.0.0...HEAD
|
|
1457
|
+
[1.0.0]: https://github.com/mwender/spinupwp-tui/compare/v0.28.0...v1.0.0
|
|
1458
|
+
[0.28.0]: https://github.com/mwender/spinupwp-tui/compare/v0.27.0...v0.28.0
|
|
1431
1459
|
[0.27.0]: https://github.com/mwender/spinupwp-tui/compare/v0.26.1...v0.27.0
|
|
1432
1460
|
[0.26.1]: https://github.com/mwender/spinupwp-tui/compare/v0.26.0...v0.26.1
|
|
1433
1461
|
[0.26.0]: https://github.com/mwender/spinupwp-tui/compare/v0.25.0...v0.26.0
|
package/README.md
CHANGED
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
probe for precise identification. → [docs/stack-detection.md](docs/stack-detection.md)
|
|
39
39
|
- **Global search** — fuzzy search across every server and site by name, domain,
|
|
40
40
|
or IP, with inline actions on the result.
|
|
41
|
+
- **Multiple SpinupWP accounts** (`A`) — keep several accounts (say, your agency's and a client's) and switch between them in-app; the header always names the one you're on. → [docs/multiple-accounts.md](docs/multiple-accounts.md)
|
|
41
42
|
- **Events feed** — recent provisioning/operation activity with per-event detail.
|
|
42
43
|
- **Live server health** (`h`) — real-time CPU/load/memory/disk over SSH.
|
|
43
44
|
→ [docs/server-health.md](docs/server-health.md)
|
|
@@ -111,14 +112,20 @@
|
|
|
111
112
|
|
|
112
113
|
## Install & run
|
|
113
114
|
|
|
114
|
-
The easy way —
|
|
115
|
+
The easy way — Homebrew (it installs Bun for you):
|
|
115
116
|
|
|
116
117
|
```sh
|
|
117
|
-
|
|
118
|
+
brew install mwender/tap/spinuptui
|
|
118
119
|
spinuptui login # save your API token to the config file (once)
|
|
119
120
|
spinuptui # launch from any directory
|
|
120
121
|
```
|
|
121
122
|
|
|
123
|
+
Or, if you already have Bun, install the published package globally:
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
bun install -g spinuptui
|
|
127
|
+
```
|
|
128
|
+
|
|
122
129
|
On first launch, if no token is configured you'll be guided through a short
|
|
123
130
|
onboarding flow that validates your token and saves it locally.
|
|
124
131
|
|
|
@@ -154,6 +161,8 @@ bun run build:binary # produces ./spinuptui — move it onto your PATH
|
|
|
154
161
|
The app tells you when a newer release exists — a gold `✦ vX.Y.Z` appears next
|
|
155
162
|
to the version in the header (and in the `?` About panel).
|
|
156
163
|
|
|
164
|
+
- **Homebrew:** `brew upgrade spinuptui` (the About panel shows the same
|
|
165
|
+
command).
|
|
157
166
|
- **Package install:** `bun add -g spinuptui@latest` (the About panel shows
|
|
158
167
|
the same command). Don't use `bun update -g` — while spinuptui is pre-1.0,
|
|
159
168
|
it records a caret range (e.g. `^0.22.2`) that excludes every later minor
|
|
@@ -262,13 +271,15 @@ The token is resolved in this order (first match wins):
|
|
|
262
271
|
To reconfigure, delete the config file (the path is shown on the onboarding
|
|
263
272
|
screen) and relaunch, or set the environment variable.
|
|
264
273
|
|
|
274
|
+
More than one SpinupWP account? Add the others in-app with `A`. Each keeps its own token and account settings; see [docs/multiple-accounts.md](docs/multiple-accounts.md).
|
|
275
|
+
|
|
265
276
|
### Optional settings
|
|
266
277
|
|
|
267
278
|
These can be set in `config.json` or via an environment variable:
|
|
268
279
|
|
|
269
280
|
- **`accountSlug`** / `SPINUPWP_ACCOUNT_SLUG` — your SpinupWP account/team slug
|
|
270
|
-
(the first path segment in a SpinupWP URL, e.g. `
|
|
271
|
-
`https://spinupwp.app/
|
|
281
|
+
(the first path segment in a SpinupWP URL, e.g. `acme-agency` in
|
|
282
|
+
`https://spinupwp.app/acme-agency/servers/12345`). The API
|
|
272
283
|
doesn't expose it, so set it to enable the `w` deep links into the web app.
|
|
273
284
|
Without it, `w` opens the SpinupWP dashboard root.
|
|
274
285
|
- **`sshUser`** / `SPINUPWP_SSH_USER` — override the SSH user for the health view
|
|
@@ -333,6 +344,7 @@ These can be set in `config.json` or via an environment variable:
|
|
|
333
344
|
| `K` | Grant / revoke an SSH key on a site, or every site on the server (Servers / Search; needs sudo connected) |
|
|
334
345
|
| `w` | Open the selected server/site in the SpinupWP web app |
|
|
335
346
|
| `/` | Jump to global search |
|
|
347
|
+
| `A` | Accounts — switch, add, rename, or remove SpinupWP accounts (from anywhere) |
|
|
336
348
|
| `r` | Refresh data from the API |
|
|
337
349
|
| `i` | Explain the current screen (what each pane and key does) |
|
|
338
350
|
| `?` | Toggle the help overlay |
|
package/bun.lock
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lockfileVersion": 1,
|
|
3
|
+
"configVersion": 1,
|
|
4
|
+
"workspaces": {
|
|
5
|
+
"": {
|
|
6
|
+
"name": "spinuptui",
|
|
7
|
+
"dependencies": {
|
|
8
|
+
"@opentui/core": "^0.4.1",
|
|
9
|
+
"@opentui/react": "^0.4.1",
|
|
10
|
+
"react": "^19.0.0",
|
|
11
|
+
"socket.io-client": "^4.8.3",
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@types/bun": "^1.3.14",
|
|
15
|
+
"@types/react": "^19.0.0",
|
|
16
|
+
"typescript": "^5.6.0",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
"packages": {
|
|
21
|
+
"@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="],
|
|
22
|
+
|
|
23
|
+
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="],
|
|
24
|
+
|
|
25
|
+
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="],
|
|
26
|
+
|
|
27
|
+
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="],
|
|
28
|
+
|
|
29
|
+
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="],
|
|
30
|
+
|
|
31
|
+
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="],
|
|
32
|
+
|
|
33
|
+
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="],
|
|
34
|
+
|
|
35
|
+
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="],
|
|
36
|
+
|
|
37
|
+
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="],
|
|
38
|
+
|
|
39
|
+
"@opentui/react": ["@opentui/react@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5", "react-reconciler": "^0.33.0" }, "peerDependencies": { "react": ">=19.2.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-n88Vx0cMmAu++/S14WscjvdcT46IDcxve02jMtcfHQ9j6cySRHfILfGEpOB7xxc8RpCwQceEyOjkKkfzPzm6Jg=="],
|
|
40
|
+
|
|
41
|
+
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
|
|
42
|
+
|
|
43
|
+
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
|
44
|
+
|
|
45
|
+
"@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="],
|
|
46
|
+
|
|
47
|
+
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
|
|
48
|
+
|
|
49
|
+
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
|
50
|
+
|
|
51
|
+
"bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="],
|
|
52
|
+
|
|
53
|
+
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
|
54
|
+
|
|
55
|
+
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
|
56
|
+
|
|
57
|
+
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
|
58
|
+
|
|
59
|
+
"diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
|
60
|
+
|
|
61
|
+
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
|
62
|
+
|
|
63
|
+
"engine.io-client": ["engine.io-client@6.6.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.21.0", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q=="],
|
|
64
|
+
|
|
65
|
+
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
|
|
66
|
+
|
|
67
|
+
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
|
|
68
|
+
|
|
69
|
+
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
|
70
|
+
|
|
71
|
+
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
|
72
|
+
|
|
73
|
+
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
|
|
74
|
+
|
|
75
|
+
"react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="],
|
|
76
|
+
|
|
77
|
+
"react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="],
|
|
78
|
+
|
|
79
|
+
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
|
80
|
+
|
|
81
|
+
"shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="],
|
|
82
|
+
|
|
83
|
+
"socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="],
|
|
84
|
+
|
|
85
|
+
"socket.io-parser": ["socket.io-parser@4.2.7", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg=="],
|
|
86
|
+
|
|
87
|
+
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
|
88
|
+
|
|
89
|
+
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
|
90
|
+
|
|
91
|
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
92
|
+
|
|
93
|
+
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
|
94
|
+
|
|
95
|
+
"web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
|
|
96
|
+
|
|
97
|
+
"ws": ["ws@8.21.2", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw=="],
|
|
98
|
+
|
|
99
|
+
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
|
|
100
|
+
|
|
101
|
+
"react-devtools-core/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="],
|
|
102
|
+
}
|
|
103
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spinuptui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "SpinupTUI — a terminal dashboard for browsing, monitoring, and managing your SpinupWP servers and sites.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"src",
|
|
12
12
|
"README.md",
|
|
13
13
|
"CHANGELOG.md",
|
|
14
|
-
"LICENSE"
|
|
14
|
+
"LICENSE",
|
|
15
|
+
"bun.lock"
|
|
15
16
|
],
|
|
16
17
|
"repository": {
|
|
17
18
|
"type": "git",
|
package/src/api/client.ts
CHANGED
|
@@ -110,15 +110,44 @@ const MAX_PAGES = 100 // hard safety cap when auto-paginating
|
|
|
110
110
|
// event polling burned the window and made SUCCESSFUL operations look failed.
|
|
111
111
|
let rlRemaining = Infinity
|
|
112
112
|
let rlResetAt = 0
|
|
113
|
-
|
|
113
|
+
let rlLimit = 0 // X-RateLimit-Limit as last seen; 0 = no response yet
|
|
114
|
+
export const RL_LOW_WATER = 8 // start pacing when fewer than this many requests remain
|
|
114
115
|
const RL_MAX_RETRIES = 3
|
|
115
116
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
116
117
|
|
|
117
118
|
function rlUpdate(headers: Headers): void {
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
// Number(null) is 0, so a missing header must not read as "0 left".
|
|
120
|
+
const remHeader = headers.get("x-ratelimit-remaining")
|
|
121
|
+
const rem = Number(remHeader)
|
|
122
|
+
if (remHeader !== null && Number.isFinite(rem)) rlRemaining = rem
|
|
123
|
+
const limit = Number(headers.get("x-ratelimit-limit"))
|
|
124
|
+
if (Number.isFinite(limit) && limit > 0) rlLimit = limit
|
|
120
125
|
// No reset header outside 429s — assume the standard 1-minute window rolls over.
|
|
121
126
|
if (rlResetAt < Date.now()) rlResetAt = Date.now() + 60_000
|
|
127
|
+
rlNotify()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Read side for the header's headroom indicator: what's left of this minute's
|
|
131
|
+
// budget, so a user sees the wall coming before a 429 (or before pacing makes
|
|
132
|
+
// the app feel slow). null until the first response has told us the limit.
|
|
133
|
+
export interface RateLimitSnapshot {
|
|
134
|
+
remaining: number
|
|
135
|
+
limit: number
|
|
136
|
+
resetAt: number
|
|
137
|
+
}
|
|
138
|
+
const rlListeners = new Set<() => void>()
|
|
139
|
+
function rlNotify(): void {
|
|
140
|
+
for (const l of rlListeners) l()
|
|
141
|
+
}
|
|
142
|
+
export function rateLimitSnapshot(): RateLimitSnapshot | null {
|
|
143
|
+
if (!rlLimit || !Number.isFinite(rlRemaining)) return null
|
|
144
|
+
// Past the window's end the last count is stale: a fresh window is full.
|
|
145
|
+
const remaining = rlResetAt < Date.now() ? rlLimit : rlRemaining
|
|
146
|
+
return { remaining, limit: rlLimit, resetAt: rlResetAt }
|
|
147
|
+
}
|
|
148
|
+
export function onRateLimitChange(listener: () => void): () => void {
|
|
149
|
+
rlListeners.add(listener)
|
|
150
|
+
return () => rlListeners.delete(listener)
|
|
122
151
|
}
|
|
123
152
|
|
|
124
153
|
async function rlGate(): Promise<void> {
|
|
@@ -154,6 +183,7 @@ export class SpinupWPClient implements SpinupWPClientLike {
|
|
|
154
183
|
const waitMs = (Number.isFinite(ra) && ra > 0 ? ra : 20) * 1000
|
|
155
184
|
rlRemaining = 0
|
|
156
185
|
rlResetAt = Date.now() + waitMs
|
|
186
|
+
rlNotify()
|
|
157
187
|
await sleep(waitMs + Math.random() * 500)
|
|
158
188
|
continue
|
|
159
189
|
}
|
package/src/config.ts
CHANGED
|
@@ -6,6 +6,16 @@
|
|
|
6
6
|
//
|
|
7
7
|
// This lets the original author keep a project-local .env while letting other
|
|
8
8
|
// users configure the tool globally after a `bun install -g`.
|
|
9
|
+
//
|
|
10
|
+
// Accounts (profiles): config.json can hold several SpinupWP accounts under
|
|
11
|
+
// `profiles`, one of them active. Everything that belongs to an account — its
|
|
12
|
+
// token, DNS connections, local-copy links, sudo users, jobs… (PROFILE_KEYS) —
|
|
13
|
+
// lives inside its profile; machine-level settings (terminal app, local scan
|
|
14
|
+
// roots, the Uptime Kuma login, last-seen version) stay at the top level and are
|
|
15
|
+
// shared. Callers never see the split: readStoredConfig() returns the active
|
|
16
|
+
// profile flattened over the globals, and saveConfig() routes each key back to
|
|
17
|
+
// where it belongs. A pre-profiles file is read as a single profile, "default",
|
|
18
|
+
// and rewritten in the new shape on its next save.
|
|
9
19
|
|
|
10
20
|
import { homedir } from "node:os"
|
|
11
21
|
import { join } from "node:path"
|
|
@@ -17,13 +27,16 @@ import { ALL_PROVIDERS, type Connection, type ConnProvider } from "./lib/provide
|
|
|
17
27
|
export const DEFAULT_BASE_URL = "https://api.spinupwp.app/v1"
|
|
18
28
|
|
|
19
29
|
export interface AppConfig {
|
|
30
|
+
// The active account's profile id and human label (see "Accounts" above).
|
|
31
|
+
profileId: string
|
|
32
|
+
profileLabel: string
|
|
20
33
|
token: string
|
|
21
34
|
baseUrl: string
|
|
22
35
|
tokenSource: "env" | "file" | "none"
|
|
23
36
|
// Optional override for the SSH user used by the server health view. When unset,
|
|
24
37
|
// the health view derives the user from a site on the server (its `site_user`).
|
|
25
38
|
sshUser: string | null
|
|
26
|
-
// SpinupWP account/team slug (e.g. "
|
|
39
|
+
// SpinupWP account/team slug (e.g. "acme-agency"), used to build
|
|
27
40
|
// deep links into the SpinupWP web app. The API doesn't expose it, so it's
|
|
28
41
|
// configured. When unset, deep links fall back to the dashboard root.
|
|
29
42
|
accountSlug: string | null
|
|
@@ -191,6 +204,64 @@ export interface StoredConfig {
|
|
|
191
204
|
lastSeenVersion?: string
|
|
192
205
|
}
|
|
193
206
|
|
|
207
|
+
// ---- Accounts (profiles) -------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
// The keys that belong to one SpinupWP account. Server and site ids are assigned
|
|
210
|
+
// per account (two accounts can both have a server #42), so anything keyed by
|
|
211
|
+
// them MUST be here, as must DNS provider credentials (each client's own).
|
|
212
|
+
// Everything else in StoredConfig is machine-level and shared by every profile.
|
|
213
|
+
// `kumaMonitors` is per account, the Kuma login (`uptimeKuma`) is shared: one
|
|
214
|
+
// agency's Kuma watches every account's sites.
|
|
215
|
+
const PROFILE_KEYS = [
|
|
216
|
+
"token",
|
|
217
|
+
"baseUrl",
|
|
218
|
+
"accountSlug",
|
|
219
|
+
"sshUser",
|
|
220
|
+
"localSites",
|
|
221
|
+
"localSync",
|
|
222
|
+
"providers",
|
|
223
|
+
"serverProviders",
|
|
224
|
+
"jobs",
|
|
225
|
+
"sudoUsers",
|
|
226
|
+
"preferredGrantKeys",
|
|
227
|
+
"grantedKeys",
|
|
228
|
+
"zoneAccessNotes",
|
|
229
|
+
"vanityHealthKeys",
|
|
230
|
+
"kumaMonitors",
|
|
231
|
+
] as const satisfies readonly (keyof StoredConfig)[]
|
|
232
|
+
type ProfileKey = (typeof PROFILE_KEYS)[number]
|
|
233
|
+
const isProfileKey = (k: string): k is ProfileKey => (PROFILE_KEYS as readonly string[]).includes(k)
|
|
234
|
+
|
|
235
|
+
export type StoredProfile = Pick<StoredConfig, ProfileKey> & { label?: string }
|
|
236
|
+
|
|
237
|
+
// The on-disk shape: globals at the top, accounts under `profiles`.
|
|
238
|
+
interface StoredFile extends Omit<StoredConfig, ProfileKey> {
|
|
239
|
+
activeProfile?: string
|
|
240
|
+
profiles?: Record<string, StoredProfile>
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// The profile a pre-profiles config migrates into. Its Keychain entries and cache
|
|
244
|
+
// files keep their original (un-namespaced) names, so migrating moves nothing.
|
|
245
|
+
export const DEFAULT_PROFILE = "default"
|
|
246
|
+
|
|
247
|
+
export interface ProfileSummary {
|
|
248
|
+
id: string
|
|
249
|
+
label: string
|
|
250
|
+
accountSlug: string | null
|
|
251
|
+
active: boolean
|
|
252
|
+
// How many sudo passwords this account has saved in the Keychain — what a
|
|
253
|
+
// removal will scrub.
|
|
254
|
+
keychainServers: number[]
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Picks a profile for this process only (no write) — the SPINUPTUI_ACCOUNT env
|
|
258
|
+
// var, so a CLI command can target an account without switching the app's.
|
|
259
|
+
let processProfile: string | null = process.env.SPINUPTUI_ACCOUNT?.trim() || null
|
|
260
|
+
|
|
261
|
+
export function profileLabel(p: StoredProfile | undefined, id: string): string {
|
|
262
|
+
return p?.label?.trim() || p?.accountSlug?.trim() || id
|
|
263
|
+
}
|
|
264
|
+
|
|
194
265
|
export function configDir(): string {
|
|
195
266
|
const xdg = process.env.XDG_CONFIG_HOME
|
|
196
267
|
return xdg ? join(xdg, "spinupwp-tui") : join(homedir(), ".config", "spinupwp-tui")
|
|
@@ -206,19 +277,142 @@ export function keysDir(): string {
|
|
|
206
277
|
return join(configDir(), "keys")
|
|
207
278
|
}
|
|
208
279
|
|
|
209
|
-
function
|
|
280
|
+
function readRawFile(): Record<string, unknown> {
|
|
210
281
|
try {
|
|
211
282
|
const path = configPath()
|
|
212
283
|
if (!existsSync(path)) return {}
|
|
213
|
-
return JSON.parse(readFileSync(path, "utf8")) as
|
|
284
|
+
return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>
|
|
214
285
|
} catch {
|
|
215
286
|
return {}
|
|
216
287
|
}
|
|
217
288
|
}
|
|
218
289
|
|
|
290
|
+
// Read config.json in the profiles shape, migrating a flat (pre-profiles) file
|
|
291
|
+
// in memory: its account keys become the "default" profile.
|
|
292
|
+
function readStoredFile(): StoredFile {
|
|
293
|
+
const raw = readRawFile()
|
|
294
|
+
if (raw.profiles && typeof raw.profiles === "object") return raw as StoredFile
|
|
295
|
+
const file: Record<string, unknown> = {}
|
|
296
|
+
const profile: Record<string, unknown> = {}
|
|
297
|
+
for (const [k, v] of Object.entries(raw)) (isProfileKey(k) ? profile : file)[k] = v
|
|
298
|
+
// Nothing account-shaped yet (fresh install): no profile until one is saved.
|
|
299
|
+
if (Object.keys(profile).length === 0) return file as StoredFile
|
|
300
|
+
return { ...(file as StoredFile), activeProfile: DEFAULT_PROFILE, profiles: { [DEFAULT_PROFILE]: profile as StoredProfile } }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// The id of the profile this process reads and writes. Falls back to the first
|
|
304
|
+
// profile when the stored pointer is missing or dangling.
|
|
305
|
+
function resolveActive(file: StoredFile): string {
|
|
306
|
+
const ids = Object.keys(file.profiles ?? {})
|
|
307
|
+
for (const want of [processProfile, file.activeProfile]) if (want && ids.includes(want)) return want
|
|
308
|
+
return ids[0] ?? DEFAULT_PROFILE
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function activeProfileId(): string {
|
|
312
|
+
return resolveActive(readStoredFile())
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// The active profile flattened over the machine-level settings — the shape every
|
|
316
|
+
// caller has always read.
|
|
317
|
+
function readStoredConfig(): StoredConfig {
|
|
318
|
+
const file = readStoredFile()
|
|
319
|
+
const { profiles, activeProfile: _active, ...globals } = file
|
|
320
|
+
const profile = profiles?.[resolveActive(file)] ?? {}
|
|
321
|
+
const { label: _label, ...fields } = profile
|
|
322
|
+
return { ...globals, ...fields }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Where an account's disk caches (probe results, DNS, verified zones) live. The
|
|
326
|
+
// default profile keeps the original top-level paths, so migrating moves no
|
|
327
|
+
// files; any other account gets its own directory, since those caches are keyed
|
|
328
|
+
// by per-account site ids and zones.
|
|
329
|
+
export function profileDataDir(id = activeProfileId()): string {
|
|
330
|
+
return id === DEFAULT_PROFILE ? configDir() : join(configDir(), "profiles", id)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// The per-account cache files, by name, inside profileDataDir() — listed so
|
|
334
|
+
// removing an account can delete exactly these (the default profile's sit
|
|
335
|
+
// beside machine-level files in the config dir itself).
|
|
336
|
+
export const PROFILE_CACHE_FILES = ["stack-cache.json", "dns-cache.json", "providers-cache.json"]
|
|
337
|
+
|
|
338
|
+
export function listProfiles(): ProfileSummary[] {
|
|
339
|
+
const file = readStoredFile()
|
|
340
|
+
const active = resolveActive(file)
|
|
341
|
+
return Object.entries(file.profiles ?? {}).map(([id, p]) => ({
|
|
342
|
+
id,
|
|
343
|
+
label: profileLabel(p, id),
|
|
344
|
+
accountSlug: p.accountSlug?.trim() || null,
|
|
345
|
+
active: id === active,
|
|
346
|
+
keychainServers: Object.entries(p.sudoUsers ?? {})
|
|
347
|
+
.filter(([, u]) => u.keychain)
|
|
348
|
+
.map(([sid]) => Number(sid)),
|
|
349
|
+
}))
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// A lowercase, dash-separated id from a label, unique among existing profiles.
|
|
353
|
+
function profileIdFor(label: string, taken: string[]): string {
|
|
354
|
+
const base = label.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "account"
|
|
355
|
+
let id = base
|
|
356
|
+
for (let n = 2; taken.includes(id); n++) id = `${base}-${n}`
|
|
357
|
+
return id
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Add an account (token already validated by the caller). Does not switch to it.
|
|
361
|
+
export async function addProfile(p: { label: string; token: string; baseUrl?: string; accountSlug?: string }): Promise<string> {
|
|
362
|
+
const file = readStoredFile()
|
|
363
|
+
const profiles = { ...(file.profiles ?? {}) }
|
|
364
|
+
const id = profileIdFor(p.label, Object.keys(profiles))
|
|
365
|
+
profiles[id] = {
|
|
366
|
+
label: p.label.trim(),
|
|
367
|
+
token: p.token.trim(),
|
|
368
|
+
baseUrl: p.baseUrl ?? DEFAULT_BASE_URL,
|
|
369
|
+
...(p.accountSlug?.trim() ? { accountSlug: p.accountSlug.trim() } : {}),
|
|
370
|
+
}
|
|
371
|
+
await writeStoredFile({ ...file, activeProfile: resolveActive(file), profiles })
|
|
372
|
+
return id
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export async function renameProfile(id: string, label: string): Promise<void> {
|
|
376
|
+
const file = readStoredFile()
|
|
377
|
+
const p = file.profiles?.[id]
|
|
378
|
+
if (!p || !label.trim()) return
|
|
379
|
+
await writeStoredFile({ ...file, activeProfile: resolveActive(file), profiles: { ...file.profiles, [id]: { ...p, label: label.trim() } } })
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Drop an account's profile. The caller scrubs its Keychain entries and cache
|
|
383
|
+
// directory (see removeAccount in the Accounts overlay). The active account
|
|
384
|
+
// can't be removed — switch away first.
|
|
385
|
+
export async function removeProfile(id: string): Promise<void> {
|
|
386
|
+
const file = readStoredFile()
|
|
387
|
+
const active = resolveActive(file)
|
|
388
|
+
if (id === active || !file.profiles?.[id]) return
|
|
389
|
+
const { [id]: _gone, ...rest } = file.profiles
|
|
390
|
+
await writeStoredFile({ ...file, activeProfile: active, profiles: rest })
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Lock this process to the account active right now. The TUI calls it at
|
|
394
|
+
// launch: without it, a second instance on the same machine
|
|
395
|
+
// switching accounts would rewrite `activeProfile` in the shared file, and this
|
|
396
|
+
// process's call-time reads — Keychain names, cache dirs, jobs, local links —
|
|
397
|
+
// would quietly follow it while its screens still show the old account.
|
|
398
|
+
export function pinActiveProfile(): string {
|
|
399
|
+
processProfile = resolveActive(readStoredFile())
|
|
400
|
+
return processProfile
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Make `id` the active account, for this process and future launches.
|
|
404
|
+
export async function setActiveProfile(id: string): Promise<void> {
|
|
405
|
+
const file = readStoredFile()
|
|
406
|
+
if (!file.profiles?.[id]) return
|
|
407
|
+
processProfile = id
|
|
408
|
+
await writeStoredFile({ ...file, activeProfile: id })
|
|
409
|
+
}
|
|
410
|
+
|
|
219
411
|
// Resolve the active config from env + stored file. Never throws.
|
|
220
412
|
export function loadConfig(): AppConfig {
|
|
221
413
|
const stored = readStoredConfig()
|
|
414
|
+
const file = readStoredFile()
|
|
415
|
+
const profileId = resolveActive(file)
|
|
222
416
|
const envToken = process.env.SPINUPWP_ACCESS_TOKEN?.trim()
|
|
223
417
|
const fileToken = stored.token?.trim()
|
|
224
418
|
|
|
@@ -257,11 +451,16 @@ export function loadConfig(): AppConfig {
|
|
|
257
451
|
}
|
|
258
452
|
|
|
259
453
|
return {
|
|
454
|
+
profileId,
|
|
455
|
+
profileLabel: profileLabel(file.profiles?.[profileId], profileId),
|
|
260
456
|
token,
|
|
261
457
|
baseUrl: (stored.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ""),
|
|
262
458
|
tokenSource,
|
|
263
459
|
sshUser: process.env.SPINUPWP_SSH_USER?.trim() || stored.sshUser?.trim() || null,
|
|
264
|
-
|
|
460
|
+
// The env slug describes the env token's account (or the one account a
|
|
461
|
+
// pre-profiles setup had) — never another profile the user switched to.
|
|
462
|
+
accountSlug:
|
|
463
|
+
((tokenSource === "env" || profileId === DEFAULT_PROFILE) && process.env.SPINUPWP_ACCOUNT_SLUG?.trim()) || stored.accountSlug?.trim() || null,
|
|
265
464
|
terminalApp: process.env.SPINUPWP_TERMINAL_APP?.trim() || stored.terminalApp?.trim() || null,
|
|
266
465
|
localSync: ((): boolean => {
|
|
267
466
|
// Env overrides the file when present; otherwise the stored flag (default off).
|
|
@@ -295,12 +494,37 @@ export function hasToken(): boolean {
|
|
|
295
494
|
return loadConfig().token.length > 0
|
|
296
495
|
}
|
|
297
496
|
|
|
298
|
-
//
|
|
299
|
-
|
|
497
|
+
// Merge settings into the config file: account keys into the active profile
|
|
498
|
+
// (created as "default" if there is none yet — first-run onboarding), the rest
|
|
499
|
+
// at the top level. `profileId` pins the account keys to a specific profile —
|
|
500
|
+
// the app passes the one it was started for, so a write that lands after an
|
|
501
|
+
// account switch can't leak into the newly active account (and one for an
|
|
502
|
+
// account removed meanwhile is dropped).
|
|
503
|
+
export async function saveConfig(partial: StoredConfig, profileId?: string): Promise<void> {
|
|
504
|
+
const file = readStoredFile()
|
|
505
|
+
const active = resolveActive(file)
|
|
506
|
+
if (profileId && profileId !== active && !file.profiles?.[profileId]) {
|
|
507
|
+
partial = Object.fromEntries(Object.entries(partial).filter(([k]) => !isProfileKey(k)))
|
|
508
|
+
if (Object.keys(partial).length === 0) return
|
|
509
|
+
}
|
|
510
|
+
const target = profileId && file.profiles?.[profileId] ? profileId : active
|
|
511
|
+
const profile: StoredProfile = { ...(file.profiles?.[target] ?? {}) }
|
|
512
|
+
const next: StoredFile = { ...file }
|
|
513
|
+
for (const [k, v] of Object.entries(partial)) {
|
|
514
|
+
if (isProfileKey(k)) (profile as Record<string, unknown>)[k] = v
|
|
515
|
+
else (next as Record<string, unknown>)[k] = v
|
|
516
|
+
}
|
|
517
|
+
const hasProfile = file.profiles?.[target] || PROFILE_KEYS.some((k) => k in partial)
|
|
518
|
+
if (hasProfile) {
|
|
519
|
+
next.profiles = { ...(file.profiles ?? {}), [target]: profile }
|
|
520
|
+
next.activeProfile = active
|
|
521
|
+
}
|
|
522
|
+
await writeStoredFile(next)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function writeStoredFile(next: StoredFile): Promise<void> {
|
|
300
526
|
const dir = configDir()
|
|
301
527
|
await mkdir(dir, { recursive: true })
|
|
302
|
-
const current = readStoredConfig()
|
|
303
|
-
const next: StoredConfig = { ...current, ...partial }
|
|
304
528
|
const path = configPath()
|
|
305
529
|
await Bun.write(path, JSON.stringify(next, null, 2) + "\n")
|
|
306
530
|
// The file holds an API token — restrict it to the owner.
|