heroku-dash 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryan McGeary
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,230 @@
1
+ # heroku-dash
2
+
3
+ A keyboard-driven Heroku dashboard in your terminal, inspired by **gh-dash**.
4
+
5
+ Run **`heroku dash`** inside a Git repository to open its Heroku pipeline. Browse teams, pipelines, and apps; inspect resources and settings; scale dynos; and manage config without leaving your terminal.
6
+
7
+ ## Installation
8
+
9
+ Requires a current [Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli), Node.js 22+, and an interactive terminal. The minimum terminal size is 80 × 24; 120 × 36 or larger is recommended.
10
+
11
+ For the dashboard's icons, select a **[Nerd Font](https://www.nerdfonts.com/)** in your terminal settings. A **Nerd Font Mono** variant, such as **JetBrainsMono Nerd Font Mono** or **FiraCode Nerd Font Mono**, keeps icons aligned to the terminal grid. Missing or boxed icons usually mean the terminal is using an unpatched font.
12
+
13
+ ```sh
14
+ heroku plugins:install heroku-dash
15
+ heroku dash
16
+ ```
17
+
18
+ The plugin is installed from [npm](https://www.npmjs.com/package/heroku-dash) directly through the Heroku CLI.
19
+
20
+ Authentication uses your existing Heroku CLI login, including `HEROKU_API_KEY` when set. Run `heroku login` first if needed.
21
+
22
+ Try the offline demo without making any Heroku requests:
23
+
24
+ ```sh
25
+ heroku dash --demo
26
+ ```
27
+
28
+ Update installed Heroku plugins:
29
+
30
+ ```sh
31
+ heroku plugins:update
32
+ ```
33
+
34
+ Uninstall this plugin:
35
+
36
+ ```sh
37
+ heroku plugins:uninstall heroku-dash
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ ```sh
43
+ heroku dash # Detect the current repository's pipeline
44
+ heroku dash --pipeline my-pipeline # Pipeline name or ID
45
+ heroku dash --app my-app # App name or ID
46
+ heroku dash --remote staging # App attached to a specific Git remote
47
+ heroku dash --team my-team # Start in a team
48
+ heroku dash --read-only # Disable all remote changes
49
+ heroku dash --refresh 60 # Refresh the current app every minute
50
+ heroku dash --refresh 0 # Manual refresh only
51
+ ```
52
+
53
+ `--app`, `--pipeline`, `--remote`, and `--team` are mutually exclusive. The default refresh interval is 30 seconds; nonzero intervals must be at least 10 seconds.
54
+
55
+ ### Repository detection
56
+
57
+ 1. Explicit flags take precedence.
58
+ 2. Detect Heroku HTTPS/SSH Git remotes, including named staging/production remotes and `heroku-accounts` SSH aliases. Look up each app's pipeline coupling.
59
+ 3. If the remotes resolve to one pipeline, open its **pipeline overview**, rather than choosing a deployment implicitly.
60
+ 4. Otherwise, match a pipeline to the **Git repository root directory name**, including when invoked from a subdirectory.
61
+ 5. A single remote app without a pipeline opens directly. If no match exists, start in the workspace browser.
62
+
63
+ When remotes span multiple pipelines, the browser asks you to choose one. Use `--remote` to disambiguate. Duplicate pipeline names can be selected by ID with `--pipeline`.
64
+
65
+ ### Workspace
66
+
67
+ The left sidebar browses teams, pipelines, or apps. Choosing a team scopes its pipelines and apps; **All teams / personal** clears the scope. Pipelines list apps ordered by stage. Open an app to see its seven views, with a selectable resource list above a scrollable details pane.
68
+
69
+ The heading shows the resource hierarchy: **team › pipeline › app**, including when you open an app or pipeline directly. Personal resources use **Personal**, and apps without a pipeline use **No pipeline**. Opening a resource resolves its parents without changing the sidebar's team filter.
70
+
71
+ Nerd Font icons identify teams, pipelines, apps, process types, databases, and the app views. **Green** indicates healthy/successful states, **amber** indicates pending states or maintenance, **red** indicates failures, and **gray** indicates inactive or unknown states. Config rows use a lock for masked values and an amber eye for revealed values. Status text remains visible alongside icons and colors.
72
+
73
+ Pipeline stages are color-coded: **blue** development, **purple** review, **amber** staging, and **green** production. The active view and focused pane use Heroku purple. On narrower terminals, inactive tabs show their number and icon; the active tab keeps its name.
74
+
75
+ While data is loading, an OpenCode-inspired purple scanner (`■` / `⬝`) sweeps back and forth in the status bar, with a fading trail and a brief pause at each turn. It updates every 40 ms alongside the operation in progress. It covers pipeline/app loads, config vars, workspace refreshes, and confirmed changes, and stops when the work finishes.
76
+
77
+ | View | What you can do |
78
+ | --- | --- |
79
+ | **1 Overview** | Inspect app identity, team, region, stack, URLs, formation, and latest release |
80
+ | **2 Resources** | Inspect process commands, desired quantity, dyno size, individual dyno states and ages; scale quantity and size |
81
+ | **3 Add-ons** | Inspect services, plans, provisioning state, billing app, and local/shared attachments |
82
+ | **4 Config** | View config keys; reveal or copy a selected value; create, replace, or delete variables |
83
+ | **5 Settings** | Inspect domains, ACM state, buildpacks, region, stack, and space; toggle maintenance mode |
84
+ | **6 Releases** | Inspect the latest 20 releases, including status, author, description, and timestamp |
85
+ | **7 Metrics** | View current dyno health, desired/running counts by process, crashed/starting counts, dyno ages, and recent deployment outcomes |
86
+
87
+ ### Keyboard shortcuts
88
+
89
+ | Key | Action |
90
+ | --- | --- |
91
+ | `t` / `p` / `a` | Browse teams / pipelines / apps |
92
+ | `j` / `k`, `↑` / `↓` | Move selection, or scroll the focused details pane |
93
+ | `Enter` | Open the selected item |
94
+ | `Tab` / `Shift-Tab` | Focus the next / previous pane |
95
+ | `/` | Filter sidebar names; submit an empty filter to clear |
96
+ | `Esc` | Return to pipeline / workspace, clear a sidebar filter, or cancel a prompt |
97
+ | `1`–`7` | Select an app view |
98
+ | `h` / `l`, `[` / `]`, `←` / `→` | Previous / next app view |
99
+ | `R` | Refresh the current app, pipeline, or workspace catalog |
100
+ | `Ctrl-L` | Redraw the terminal |
101
+ | `o` | Open the corresponding Heroku web dashboard page |
102
+ | `?` | Show keyboard help |
103
+ | `q` / `Ctrl-C` | Quit (`q` closes help; `Ctrl-C` also exits from input prompts) |
104
+
105
+ App actions:
106
+
107
+ | Key | View | Action |
108
+ | --- | --- | --- |
109
+ | `s` | Resources | Scale the selected process row (server icon, `[s] scale`); enter quantity and dyno size |
110
+ | `v` | Config | Reveal / hide the selected value |
111
+ | `y` | Config | Copy the selected variable's full value to the clipboard, even when masked |
112
+ | `e` | Config | Replace the selected variable's value |
113
+ | `n` | Config | Create a variable (or explicitly replace an existing key) |
114
+ | `d` | Config | Delete the selected variable |
115
+ | `m` | Settings | Toggle maintenance mode |
116
+
117
+ In prompts, `Enter` continues, `Esc` cancels, and `Ctrl-U` clears the input. Config-value input is masked. Editing replaces the complete value and currently supports single-line input; existing multiline values can be inspected but should be edited through the standard CLI or web dashboard.
118
+
119
+ ## Remote changes and config values
120
+
121
+ Every write displays the target app and proposed change, then requires typing the **exact app name**. Scaling can change billing and restart dynos. Config changes create a release and restart the app. Maintenance mode affects request serving.
122
+
123
+ `--read-only` blocks all non-GET requests at the plugin's API boundary, in addition to disabling mutation prompts. The offline demo also runs read-only.
124
+
125
+ Config values are fetched only when opening Config. Press `v` to reveal or hide the selected variable independently of the others. Moving between rows keeps revealed values visible, so you can inspect several at once. Switching views/apps or manually refreshing hides them all again. The plugin keeps fetched values in memory for the selected app and does not write config values to disk. Automatic refresh updates operational app data; use `R` to refresh config values.
126
+
127
+ Press **`y`** to copy the selected variable's value without revealing it. Revealed values appear in **cyan** in the Details pane; **click the highlighted value** to copy it. Clicking any wrapped or multiline portion copies the complete value. Empty values show a clickable `(empty value)` placeholder. Copying preserves whitespace, Unicode, and multiline content, and works in `--read-only` mode. The status bar confirms the variable name without displaying its value.
128
+
129
+ Clipboard access uses the system clipboard on the machine running `dash` (macOS, Windows, or a Linux desktop). On Wayland, install `wl-clipboard`; X11 uses `xsel`, with a bundled fallback. A desktop clipboard must be accessible to the terminal; headless/SSH sessions without one show a copy error instead.
130
+
131
+ ## Metrics and current scope
132
+
133
+ The Metrics view uses real snapshots from the public Heroku Platform API. It counts `up` and `idle` formation dynos as healthy, excludes one-off processes from desired-formation health, and shows recent release outcomes. Dyno age is time since creation, not a historical uptime guarantee. During deploys, overlapping dynos can exceed the desired count.
134
+
135
+ **CPU, memory, throughput, and request-latency time series are not available through the public Platform API used here.** Press `o` in Metrics to open Heroku's metrics dashboard.
136
+
137
+ Add-on provisioning/plan changes, pipeline promotions, log streaming, domain/buildpack edits, and historical monitoring are outside this initial version. Settings other than maintenance mode are displayed read-only. Individual section failures are shown without preventing other sections from loading.
138
+
139
+ ## Development and verification
140
+
141
+ Plain JavaScript ESM, an oclif/Heroku command, and Blessed terminal widgets. No application compilation step is required; `npm run build` generates the oclif command manifest.
142
+
143
+ ### Local development
144
+
145
+ From a checkout of this repository:
146
+
147
+ ```sh
148
+ npm ci
149
+ npm run build
150
+ heroku plugins:link .
151
+ heroku dash --demo
152
+ ```
153
+
154
+ To switch from a development link to the npm release:
155
+
156
+ ```sh
157
+ heroku plugins:unlink heroku-dash
158
+ heroku plugins:install heroku-dash
159
+ ```
160
+
161
+ ### Checks
162
+
163
+ ```sh
164
+ npm run check # Lint, automated tests, command manifest
165
+ npm pack --dry-run # Inspect the publishable package
166
+ ```
167
+
168
+ Tests use mocked transports and in-memory terminal streams. They cover repository resolution, pagination, partial API failures, read-only guards, confirmation validation, all three mutation paths, config masking, keyboard navigation, stale-response handling, and terminal cleanup.
169
+
170
+ ### Publishing to npm
171
+
172
+ Run these commands from the repository root with Node.js 22+:
173
+
174
+ ```sh
175
+ npm ci
176
+ npm publish --dry-run # Run checks and inspect the package without uploading
177
+ npm login # Sign in to the npm account publishing the package
178
+ npm publish
179
+ ```
180
+
181
+ `npm publish` runs lint and tests through `prepublishOnly`, then generates the command manifest through `prepack`. The package includes the runtime source, `oclif.manifest.json`, README, and MIT license. Development dependencies are needed to publish, but aren't required when installing the published plugin. Package access is explicitly public.
182
+
183
+ The initial release is **`heroku-dash@0.1.0`**. For subsequent releases, increment the version before publishing, for example:
184
+
185
+ ```sh
186
+ npm version patch --no-git-tag-version
187
+ ```
188
+
189
+ This updates `package.json` and `package-lock.json`; npm does not allow publishing the same package version twice.
190
+
191
+ ### Explicit read-only integration checks
192
+
193
+ These are opt-in and use the current Heroku CLI account:
194
+
195
+ ```sh
196
+ npm run test:live -- ~/work/hermod ~/work/heimdall
197
+ ```
198
+
199
+ The live-check transport **rejects every method except GET**. It verifies repository-to-pipeline resolution and renders all seven app views, printing counts rather than config values. It reads every app in the detected pipelines.
200
+
201
+ After linking the plugin, macOS/Linux users with Python 3 can exercise the actual CLI in a pseudo-terminal:
202
+
203
+ ```sh
204
+ python3 scripts/terminal-check.py
205
+ python3 scripts/terminal-check.py --repo ~/work/hermod
206
+ python3 scripts/terminal-check.py --repo ~/work/heimdall
207
+ ```
208
+
209
+ Without `--repo`, this uses the offline demo. Live terminal checks always pass `--read-only --refresh 0`; mutation behavior is tested only with mocked APIs.
210
+
211
+ ### Layout
212
+
213
+ ```text
214
+ src/commands/dash.js Command flags, authentication, startup
215
+ src/project.js Git context and pipeline resolution
216
+ src/hierarchy.js Team and pipeline parents for resource breadcrumbs
217
+ src/api.js Platform API reads, pagination, guarded writes
218
+ src/ui/dashboard.js Terminal navigation, prompts, refresh, lifecycle
219
+ src/ui/views.js View models, config masking, operational metrics
220
+ src/ui/details.js Highlighted values and scroll-aware click targets
221
+ src/ui/theme.js Nerd Font icons, semantic colors, styled labels
222
+ src/ui/text.js Terminal-safe text sanitization
223
+ src/demo.js Offline demo data
224
+ test/ API, project, view, and keyboard integration tests
225
+ scripts/ Explicit GET-only live and pseudo-terminal checks
226
+ ```
227
+
228
+ ## License
229
+
230
+ MIT
@@ -0,0 +1,105 @@
1
+ {
2
+ "commands": {
3
+ "dash": {
4
+ "aliases": [],
5
+ "args": {},
6
+ "description": "navigate Heroku teams, pipelines, and apps in a rich terminal dashboard",
7
+ "examples": [
8
+ "<%= config.bin %> dash",
9
+ "<%= config.bin %> dash --pipeline my-pipeline",
10
+ "<%= config.bin %> dash --app my-app --read-only",
11
+ "<%= config.bin %> dash --remote staging"
12
+ ],
13
+ "flags": {
14
+ "app": {
15
+ "char": "a",
16
+ "description": "App name or ID",
17
+ "exclusive": [
18
+ "pipeline",
19
+ "remote",
20
+ "team"
21
+ ],
22
+ "name": "app",
23
+ "hasDynamicHelp": false,
24
+ "multiple": false,
25
+ "type": "option"
26
+ },
27
+ "pipeline": {
28
+ "char": "p",
29
+ "description": "Pipeline name or ID",
30
+ "exclusive": [
31
+ "app",
32
+ "remote",
33
+ "team"
34
+ ],
35
+ "name": "pipeline",
36
+ "hasDynamicHelp": false,
37
+ "multiple": false,
38
+ "type": "option"
39
+ },
40
+ "remote": {
41
+ "char": "r",
42
+ "description": "Heroku Git remote to use",
43
+ "exclusive": [
44
+ "app",
45
+ "pipeline",
46
+ "team"
47
+ ],
48
+ "name": "remote",
49
+ "hasDynamicHelp": false,
50
+ "multiple": false,
51
+ "type": "option"
52
+ },
53
+ "team": {
54
+ "char": "t",
55
+ "description": "Start in a team (name or ID)",
56
+ "exclusive": [
57
+ "app",
58
+ "pipeline",
59
+ "remote"
60
+ ],
61
+ "name": "team",
62
+ "hasDynamicHelp": false,
63
+ "multiple": false,
64
+ "type": "option"
65
+ },
66
+ "read-only": {
67
+ "description": "Disable all remote changes",
68
+ "name": "read-only",
69
+ "allowNo": false,
70
+ "type": "boolean"
71
+ },
72
+ "refresh": {
73
+ "description": "Refresh current app every N seconds (0 disables)",
74
+ "name": "refresh",
75
+ "default": 30,
76
+ "hasDynamicHelp": false,
77
+ "multiple": false,
78
+ "type": "option"
79
+ },
80
+ "demo": {
81
+ "description": "Explore an offline demo; no Heroku requests",
82
+ "name": "demo",
83
+ "allowNo": false,
84
+ "type": "boolean"
85
+ }
86
+ },
87
+ "hasDynamicHelp": false,
88
+ "hiddenAliases": [],
89
+ "id": "dash",
90
+ "pluginAlias": "heroku-dash",
91
+ "pluginName": "heroku-dash",
92
+ "pluginType": "core",
93
+ "strict": true,
94
+ "enableJsonFlag": false,
95
+ "promptFlagActive": false,
96
+ "isESM": true,
97
+ "relativePath": [
98
+ "src",
99
+ "commands",
100
+ "dash.js"
101
+ ]
102
+ }
103
+ },
104
+ "version": "0.1.0"
105
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "heroku-dash",
3
+ "version": "0.1.0",
4
+ "description": "A keyboard-driven terminal dashboard for Heroku",
5
+ "author": "Ryan McGeary <ryan@mcgeary.org>",
6
+ "homepage": "https://github.com/rmm5t/heroku-dash",
7
+ "bugs": {"url": "https://github.com/rmm5t/heroku-dash/issues"},
8
+ "repository": {"type": "git", "url": "git+https://github.com/rmm5t/heroku-dash.git"},
9
+ "license": "MIT",
10
+ "type": "module",
11
+ "engines": {"node": ">=22"},
12
+ "files": ["src", "oclif.manifest.json", "README.md", "LICENSE"],
13
+ "keywords": ["heroku", "heroku-cli-plugin", "oclif", "tui", "dashboard"],
14
+ "oclif": {"bin": "heroku", "commands": "./src/commands"},
15
+ "publishConfig": {"access": "public"},
16
+ "scripts": {
17
+ "build": "oclif manifest",
18
+ "lint": "eslint src test scripts",
19
+ "test": "node --test",
20
+ "check": "npm run lint && npm test && npm run build",
21
+ "test:live": "node scripts/live-check.js",
22
+ "prepack": "npm run build",
23
+ "prepublishOnly": "npm run lint && npm test"
24
+ },
25
+ "dependencies": {
26
+ "@heroku-cli/command": "^13.2.2",
27
+ "@oclif/core": "^4.13.2",
28
+ "blessed": "^0.1.81",
29
+ "clipboardy": "^5.3.2"
30
+ },
31
+ "devDependencies": {
32
+ "@eslint/js": "^9.39.2",
33
+ "eslint": "^9.39.2",
34
+ "globals": "^16.5.0",
35
+ "oclif": "^4.23.29"
36
+ }
37
+ }
package/src/api.js ADDED
@@ -0,0 +1,128 @@
1
+ const V3 = 'application/vnd.heroku+json; version=3'
2
+ const encode = encodeURIComponent
3
+
4
+ export function statusCode(error) {
5
+ return error.statusCode ?? error.status ?? error.response?.statusCode ?? error.http?.statusCode ?? error.http?.http?.statusCode
6
+ }
7
+
8
+ export function errorMessage(error) {
9
+ const status = statusCode(error)
10
+ if (status === 401) return 'Heroku authentication expired. Run heroku login and reopen dash.'
11
+ if (status === 403) return 'Your Heroku account does not have permission to view or change this resource.'
12
+ if (status === 429) return 'Heroku API rate limit reached. Wait before refreshing.'
13
+ return error.body?.message ?? error.message ?? String(error)
14
+ }
15
+
16
+ export class HerokuAPI {
17
+ constructor(client, {readOnly = false} = {}) {
18
+ this.client = client
19
+ this.readOnly = readOnly
20
+ }
21
+
22
+ async request(path, options = {}) {
23
+ const method = options.method ?? 'GET'
24
+ if (this.readOnly && method !== 'GET') throw new Error('Read-only mode: remote changes are disabled.')
25
+ return this.client.request(path, {retryAuth: false, ...options, method, headers: {Accept: V3, ...options.headers}})
26
+ }
27
+
28
+ async get(path, options) {
29
+ return (await this.request(path, options)).body
30
+ }
31
+
32
+ async list(path, options = {}) {
33
+ const items = []
34
+ let range = options.headers?.Range
35
+ const seen = new Set()
36
+ do {
37
+ const response = await this.request(path, {...options, headers: {...options.headers, ...(range ? {Range: range} : {})}})
38
+ if (!Array.isArray(response.body)) throw new Error(`Unexpected list response from ${path}`)
39
+ items.push(...response.body)
40
+ range = response.headers?.['next-range'] ?? response.headers?.['Next-Range']
41
+ if (range && seen.has(range)) throw new Error(`Repeated pagination range from ${path}`)
42
+ if (range) seen.add(range)
43
+ } while (range)
44
+ return items
45
+ }
46
+
47
+ async catalog() {
48
+ const results = await Promise.allSettled([this.list('/teams'), this.list('/pipelines'), this.list('/apps')])
49
+ const catalog = {warnings: []}
50
+ for (const [i, key] of ['teams', 'pipelines', 'apps'].entries()) {
51
+ const result = results[i]
52
+ if (result.status === 'fulfilled') catalog[key] = result.value.sort((a, b) => a.name.localeCompare(b.name))
53
+ else {
54
+ catalog[key] = []
55
+ catalog.warnings.push(`${key}: ${errorMessage(result.reason)}`)
56
+ }
57
+ }
58
+ if (results.every(r => r.status === 'rejected')) throw results[2].reason
59
+ return catalog
60
+ }
61
+
62
+ async coupling(app) {
63
+ try {
64
+ return await this.get(`/apps/${encode(app)}/pipeline-couplings`)
65
+ } catch (error) {
66
+ if (statusCode(error) === 404) return null
67
+ throw error
68
+ }
69
+ }
70
+
71
+ async pipelineApps(pipeline) {
72
+ const couplings = await this.list(`/pipelines/${encode(pipeline)}/pipeline-couplings`)
73
+ return Promise.all(couplings.map(async coupling => ({
74
+ ...await this.get(`/apps/${encode(coupling.app.id)}`),
75
+ stage: coupling.stage,
76
+ })))
77
+ }
78
+
79
+ async appData(app) {
80
+ const base = `/apps/${encode(app)}`
81
+ const sections = {
82
+ app: () => this.get(base),
83
+ coupling: () => this.coupling(app),
84
+ formation: () => this.list(`${base}/formation`),
85
+ dynos: () => this.list(`${base}/dynos`),
86
+ addons: () => this.list(`${base}/addons`),
87
+ attachments: () => this.list(`${base}/addon-attachments`),
88
+ domains: () => this.list(`${base}/domains`),
89
+ buildpacks: () => this.list(`${base}/buildpack-installations`),
90
+ // Only the latest page: release histories can be enormous.
91
+ releases: () => this.get(`${base}/releases`, {headers: {Range: 'version ..; order=desc,max=20;'}}),
92
+ }
93
+ const data = {errors: {}, fetchedAt: new Date().toISOString()}
94
+ await Promise.all(Object.entries(sections).map(async ([key, load]) => {
95
+ try { data[key] = await load() }
96
+ catch (error) { data.errors[key] = errorMessage(error); data[key] = ['app', 'coupling'].includes(key) ? null : [] }
97
+ }))
98
+ if (!data.app) throw new Error(data.errors.app)
99
+ return data
100
+ }
101
+
102
+ config(app) { return this.get(`/apps/${encode(app)}/config-vars`) }
103
+
104
+ async scale(app, type, quantity, size, confirmation) {
105
+ this.confirm(app, confirmation)
106
+ if (!Number.isSafeInteger(quantity) || quantity < 0) throw new Error('Dyno quantity must be a non-negative integer.')
107
+ if (!size?.trim()) throw new Error('Enter a dyno size, such as Standard-1X.')
108
+ return this.get(`/apps/${encode(app)}/formation/${encode(type)}`, {method: 'PATCH', body: {quantity, size: size.trim()}})
109
+ }
110
+
111
+ async setConfig(app, key, value, confirmation) {
112
+ this.confirm(app, confirmation)
113
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) throw new Error('Config keys must start with a letter or underscore and contain only letters, digits, and underscores.')
114
+ if (value !== null && typeof value !== 'string') throw new Error('Config values must be strings.')
115
+ return this.get(`/apps/${encode(app)}/config-vars`, {method: 'PATCH', body: {[key]: value}})
116
+ }
117
+
118
+ async maintenance(app, enabled, confirmation) {
119
+ this.confirm(app, confirmation)
120
+ if (typeof enabled !== 'boolean') throw new Error('Maintenance mode must be a boolean.')
121
+ return this.get(`/apps/${encode(app)}`, {method: 'PATCH', body: {maintenance: enabled}})
122
+ }
123
+
124
+ confirm(app, confirmation) {
125
+ if (this.readOnly) throw new Error('Read-only mode: remote changes are disabled.')
126
+ if (!app || confirmation !== app) throw new Error('Type the exact app name to confirm this change.')
127
+ }
128
+ }
@@ -0,0 +1,47 @@
1
+ import {Command} from '@heroku-cli/command'
2
+ import {Flags} from '@oclif/core'
3
+ import {HerokuAPI} from '../api.js'
4
+ import {inspectProject, resolveContext} from '../project.js'
5
+
6
+ export default class Dash extends Command {
7
+ static promptFlagActive = false
8
+ static baseFlags = Command.baseFlagsWithoutPrompt()
9
+ static description = 'navigate Heroku teams, pipelines, and apps in a rich terminal dashboard'
10
+ static examples = [
11
+ '<%= config.bin %> dash',
12
+ '<%= config.bin %> dash --pipeline my-pipeline',
13
+ '<%= config.bin %> dash --app my-app --read-only',
14
+ '<%= config.bin %> dash --remote staging',
15
+ ]
16
+ static flags = {
17
+ app: Flags.string({char: 'a', description: 'App name or ID', exclusive: ['pipeline', 'remote', 'team']}),
18
+ pipeline: Flags.string({char: 'p', description: 'Pipeline name or ID', exclusive: ['app', 'remote', 'team']}),
19
+ remote: Flags.string({char: 'r', description: 'Heroku Git remote to use', exclusive: ['app', 'pipeline', 'team']}),
20
+ team: Flags.string({char: 't', description: 'Start in a team (name or ID)', exclusive: ['app', 'pipeline', 'remote']}),
21
+ 'read-only': Flags.boolean({description: 'Disable all remote changes', default: false}),
22
+ refresh: Flags.integer({description: 'Refresh current app every N seconds (0 disables)', default: 30, min: 0}),
23
+ demo: Flags.boolean({description: 'Explore an offline demo; no Heroku requests', default: false}),
24
+ }
25
+
26
+ async run() {
27
+ const {flags} = await this.parse(Dash)
28
+ if (!process.stdin.isTTY || !process.stdout.isTTY) this.error('heroku dash needs an interactive terminal (TTY). Run it directly in your terminal.')
29
+ if (flags.refresh !== 0 && flags.refresh < 10) this.error('--refresh must be 0 or at least 10 seconds.')
30
+ const {runDashboard} = await import('../ui/dashboard.js')
31
+ if (flags.demo) {
32
+ const {createDemo} = await import('../demo.js')
33
+ return runDashboard({...createDemo(), refresh: flags.refresh, demo: true})
34
+ }
35
+ if (!await this.heroku.getAuth()) this.error('Log in with heroku login before opening dash.')
36
+ // Interactive reauthentication must not take over the full-screen terminal.
37
+ this.heroku.twoFactorPrompt = async () => { throw new Error('This resource requires additional authentication. Authorize it with the Heroku CLI, then reopen dash.') }
38
+ const api = new HerokuAPI(this.heroku, {readOnly: flags['read-only']})
39
+ const [catalog, project] = await Promise.all([api.catalog(), inspectProject()])
40
+ const context = await resolveContext(api, catalog, flags, project)
41
+ if (flags.team) {
42
+ context.team = catalog.teams.find(t => t.id === flags.team || t.name === flags.team)
43
+ if (!context.team) this.error(`Team not found: ${flags.team}`)
44
+ }
45
+ await runDashboard({api, catalog, context, refresh: flags.refresh})
46
+ }
47
+ }
package/src/demo.js ADDED
@@ -0,0 +1,31 @@
1
+ import {HerokuAPI} from './api.js'
2
+
3
+ export function createDemo() {
4
+ const now = Date.now()
5
+ const ago = seconds => new Date(now - seconds * 1000).toISOString()
6
+ const team = {id: 'team-demo', name: 'acme'}
7
+ const pipeline = {id: 'pipeline-demo', name: 'constellation', owner: {id: team.id, type: 'team'}}
8
+ const apps = ['staging', 'production'].map(stage => ({
9
+ id: `app-${stage}`, name: `constellation-${stage}`, stage, team,
10
+ region: {name: 'us'}, stack: {name: 'heroku-24'}, build_stack: {name: 'heroku-24'},
11
+ web_url: `https://constellation-${stage}.herokuapp.com`, maintenance: false, acm: true,
12
+ created_at: ago(86400 * 90), updated_at: ago(3600),
13
+ }))
14
+ const data = app => ({app, coupling: {pipeline: {id: pipeline.id, name: pipeline.name}}, errors: {}, fetchedAt: new Date().toISOString(),
15
+ formation: [{type: 'web', quantity: 2, size: 'Standard-1X', command: 'npm start'}, {type: 'worker', quantity: 1, size: 'Standard-2X', command: 'node worker.js'}],
16
+ dynos: ['web.1', 'web.2', 'worker.1'].map(name => ({name, type: name.split('.')[0], state: 'up', size: name.startsWith('web') ? 'Standard-1X' : 'Standard-2X', created_at: ago(4700), command: 'node server.js', release: {version: 42}})),
17
+ addons: [{id: 'postgres', name: 'postgresql-orbit-12345', plan: {name: 'heroku-postgresql:essential-0'}, addon_service: {name: 'heroku-postgresql'}, state: 'provisioned', app, config_vars: ['DATABASE_URL']}],
18
+ attachments: [{name: 'DATABASE', addon: {id: 'postgres'}}],
19
+ domains: [{hostname: `${app.name}.herokuapp.com`, kind: 'heroku', status: 'succeeded'}],
20
+ buildpacks: [{ordinal: 1, buildpack: {name: 'heroku/nodejs', url: 'heroku/nodejs'}}],
21
+ releases: [42, 41, 40].map((version, i) => ({version, status: 'succeeded', description: i ? 'Update config vars' : 'Deploy a1b2c3d', created_at: ago(4700 + i * 86400), user: {email: 'developer@example.com'}})),
22
+ })
23
+ // Use the real API's read-only guards even in the offline demo.
24
+ const api = new HerokuAPI({request() { throw new Error('Demo is offline.') }}, {readOnly: true})
25
+ const catalog = {teams: [team], pipelines: [pipeline], apps, warnings: []}
26
+ api.catalog = async () => structuredClone(catalog)
27
+ api.pipelineApps = async () => structuredClone(apps)
28
+ api.appData = async id => data(apps.find(a => a.id === id || a.name === id))
29
+ api.config = async () => ({NODE_ENV: 'production', EXAMPLE_SECRET: 'demo-only-value', WEB_CONCURRENCY: '2'})
30
+ return {api, catalog, context: {pipeline, reason: 'Offline demo'}}
31
+ }
@@ -0,0 +1,27 @@
1
+ import {errorMessage} from './api.js'
2
+
3
+ // Resource ownership is independent of the sidebar's team filter. Couplings
4
+ // often contain only a pipeline ID/name, so enrich them from the catalog first.
5
+ export async function resolveHierarchy(api, catalog, {app = null, pipeline = null}) {
6
+ const known = pipeline && catalog.pipelines.find(item => item.id === pipeline.id)
7
+ const result = {
8
+ pipeline: pipeline ? {...known, ...pipeline} : null,
9
+ team: {name: 'Personal'},
10
+ errors: {},
11
+ }
12
+ try {
13
+ if (result.pipeline && !app?.team && !Object.hasOwn(result.pipeline, 'owner')) {
14
+ result.pipeline = await api.get(`/pipelines/${encodeURIComponent(result.pipeline.id)}`)
15
+ }
16
+ const owner = result.pipeline?.owner
17
+ const team = app?.team ?? (owner?.type === 'team' ? {id: owner.id, name: owner.name} : null)
18
+ if (team) {
19
+ const cached = catalog.teams.find(item => item.id === team.id || (team.name && item.name === team.name))
20
+ result.team = team.name ? team : cached ?? await api.get(`/teams/${encodeURIComponent(team.id)}`)
21
+ }
22
+ } catch (error) {
23
+ result.team = {name: 'Team unavailable'}
24
+ result.errors.hierarchy = errorMessage(error)
25
+ }
26
+ return result
27
+ }