From 86a1e3c02c82ce122ecf6ab7bb2048854bcbe2d4 Mon Sep 17 00:00:00 2001
From: titan-ron <30556071+titan-ron@users.noreply.github.com>
Date: Thu, 24 Sep 2026 18:35:35 +0300
Subject: [PATCH 1/4] =?UTF-8?q?feat(about):=20open=20prefilled=20issue=20f?=
=?UTF-8?q?orms=20and=20discussions=20from=20Settings=20=E2=80=BA=20About?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A Feedback group in About opens GitHub: Report a problem and Sessions missing or wrong prefill the issue form with the Cockpit version, macOS version and architecture, and each agent CLI's version and install method — never paths, accounts or session content. Suggest an idea and Questions & discussion open their forms. AppInfo gains the macOS product version for it.
The issue forms, a pull request template and a code of conduct come with it, so the repository's community profile is complete.
---
.github/ISSUE_TEMPLATE/bug.yml | 54 ++++++++++
.github/ISSUE_TEMPLATE/config.yml | 11 ++
.github/ISSUE_TEMPLATE/idea.yml | 16 +++
.github/ISSUE_TEMPLATE/sessions.yml | 53 +++++++++
.github/pull_request_template.md | 10 ++
CODE_OF_CONDUCT.md | 130 ++++++++++++++++++++++
design-system/cockpit/pages/settings.md | 13 ++-
src/main/updates.ts | 5 +-
src/renderer/src/AboutSection.tsx | 87 ++++++++++++++-
src/renderer/src/style.css | 3 +
src/shared/feedback.ts | 108 +++++++++++++++++++
src/shared/types.ts | 2 +
tests/component/settings-about.test.tsx | 64 ++++++++++-
tests/component/stub-api.ts | 1 +
tests/feedback.test.ts | 138 ++++++++++++++++++++++++
15 files changed, 690 insertions(+), 5 deletions(-)
create mode 100644 .github/ISSUE_TEMPLATE/bug.yml
create mode 100644 .github/ISSUE_TEMPLATE/config.yml
create mode 100644 .github/ISSUE_TEMPLATE/idea.yml
create mode 100644 .github/ISSUE_TEMPLATE/sessions.yml
create mode 100644 .github/pull_request_template.md
create mode 100644 CODE_OF_CONDUCT.md
create mode 100644 src/shared/feedback.ts
create mode 100644 tests/feedback.test.ts
diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
new file mode 100644
index 00000000..e23b4ec4
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug.yml
@@ -0,0 +1,54 @@
+name: Report a problem
+description: Something in Cockpit is broken, stuck or wrong.
+labels: [bug]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time. **Settings › About › Report a problem** opens this form
+ with the version fields already filled in.
+ Sessions that are missing or show the wrong title, branch or transcript have
+ [their own form](https://github.com/tashtit/cockpit/issues/new?template=sessions.yml).
+ - type: textarea
+ id: what-happened
+ attributes:
+ label: What happened
+ description: What you saw, and what you expected instead.
+ validations:
+ required: true
+ - type: textarea
+ id: steps
+ attributes:
+ label: How to make it happen again
+ description: The steps, if you know them. "It happened once" is useful too.
+ placeholder: |
+ 1. Open a Codex session
+ 2. Send a message while the agent is working
+ 3. …
+ - type: input
+ id: version
+ attributes:
+ label: Cockpit version
+ description: Settings › About shows it.
+ placeholder: "0.31.1"
+ validations:
+ required: true
+ - type: input
+ id: macos
+ attributes:
+ label: macOS
+ placeholder: "26.0 (arm64)"
+ - type: textarea
+ id: agents
+ attributes:
+ label: Agent CLIs
+ description: Which agent CLIs you have, and their versions.
+ placeholder: |
+ Claude Code: 2.1.236 (Homebrew cask)
+ Codex: 0.154.0 (npm)
+ Copilot: not installed
+ - type: textarea
+ id: extra
+ attributes:
+ label: Anything else
+ description: Screenshots, or the error text. Please leave out anything private — this issue is public.
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 00000000..fb59b718
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,11 @@
+blank_issues_enabled: true
+contact_links:
+ - name: Ask a question
+ url: https://github.com/tashtit/cockpit/discussions/categories/q-a
+ about: How do I…? Does Cockpit…? Ask in Discussions.
+ - name: Show what you use it for
+ url: https://github.com/tashtit/cockpit/discussions/categories/show-and-tell
+ about: Your setup, your workflow, a screenshot — we read every one.
+ - name: Report a security vulnerability
+ url: https://github.com/tashtit/cockpit/security/advisories/new
+ about: Privately, please — not in a public issue.
diff --git a/.github/ISSUE_TEMPLATE/idea.yml b/.github/ISSUE_TEMPLATE/idea.yml
new file mode 100644
index 00000000..1d58150a
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/idea.yml
@@ -0,0 +1,16 @@
+name: Suggest an idea
+description: Something Cockpit could do, or do better.
+labels: [enhancement]
+body:
+ - type: textarea
+ id: problem
+ attributes:
+ label: What you are trying to do
+ description: The situation, and what gets in the way today. This matters more than the solution.
+ validations:
+ required: true
+ - type: textarea
+ id: proposal
+ attributes:
+ label: What would help
+ description: How you imagine it working, if you have an idea.
diff --git a/.github/ISSUE_TEMPLATE/sessions.yml b/.github/ISSUE_TEMPLATE/sessions.yml
new file mode 100644
index 00000000..431b1fbe
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/sessions.yml
@@ -0,0 +1,53 @@
+name: Sessions missing or wrong
+description: A session doesn't show up, or shows the wrong title, repo, branch or transcript.
+labels: [bug]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Each agent writes its own session log format, and the formats change between CLI
+ releases — so the agent and its version are what tell us where to look.
+ Please don't attach a session log: it holds your conversation. Describe what is
+ wrong and we'll ask for anything specific.
+ - type: dropdown
+ id: agent
+ attributes:
+ label: Agent
+ options:
+ - Claude Code
+ - Codex
+ - Copilot CLI
+ - Another agent over ACP
+ validations:
+ required: true
+ - type: textarea
+ id: what-is-wrong
+ attributes:
+ label: What is missing or wrong
+ description: For example "sessions from the last two days don't appear", or "the branch shows main but the session ran on a worktree branch".
+ validations:
+ required: true
+ - type: input
+ id: version
+ attributes:
+ label: Cockpit version
+ description: Settings › About shows it.
+ placeholder: "0.31.1"
+ validations:
+ required: true
+ - type: input
+ id: macos
+ attributes:
+ label: macOS
+ placeholder: "26.0 (arm64)"
+ - type: textarea
+ id: agents
+ attributes:
+ label: Agent CLIs
+ description: Which agent CLIs you have, and their versions.
+ placeholder: |
+ Claude Code: 2.1.236 (Homebrew cask)
+ Codex: 0.154.0 (npm)
+ Copilot: not installed
+ validations:
+ required: true
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 00000000..a7cce6a6
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,10 @@
+## What and why
+
+
+
+## How it was checked
+
+- [ ] `npm run typecheck`
+- [ ] `npm test`
+- [ ] Seen in the running app (`npm run dev`), for anything a person sees
+- [ ] `docs/guide/` updated, for anything that changes what a person sees or does
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 00000000..56eaf7e7
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,130 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, religion, or sexual identity
+and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+ and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the
+ overall community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or
+ advances of any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email
+ address, without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official e-mail address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement privately: use
+**Report content** from the **…** menu on the issue, pull request, discussion or
+comment concerned, which reaches the maintainers of this repository, or contact a
+maintainer (@titan-ron, @amitbreuer, @matansocher) through their GitHub profile.
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series
+of actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or
+permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within
+the community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.0, available at
+https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
+
+Community Impact Guidelines were inspired by [Mozilla's code of conduct
+enforcement ladder](https://github.com/mozilla/diversity).
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see the FAQ at
+https://www.contributor-covenant.org/faq. Translations are available at
+https://www.contributor-covenant.org/translations.
diff --git a/design-system/cockpit/pages/settings.md b/design-system/cockpit/pages/settings.md
index 61a35828..c241854f 100644
--- a/design-system/cockpit/pages/settings.md
+++ b/design-system/cockpit/pages/settings.md
@@ -29,7 +29,8 @@ seventh.
leading `
` — repeating the pill directly under it is noise, and the panel is already
named by its tab (`aria-labelledby`). A panel holding more than one group keeps an `h3`
per group (Accounts: "Agent accounts & usage", then "GitHub"; View: "History", then
- "Display"; Providers: "Model providers", then "ACP agents").
+ "Display"; Providers: "Model providers", then "ACP agents"; About: "Updates", then
+ "Feedback").
- **The tab row is one tab stop.** Roving `tabIndex` (0 on the selected tab, -1 on the
rest); ←/→ wrap, Home/End jump to the ends, and moving selects — `TabList` does this for
every card view. The card's `h2` still takes focus on mount; picking a tab leaves
@@ -249,6 +250,16 @@ seventh.
(`openLicenseNotices`); the reason it could not open shows verbatim in a `.new-error
role="alert"` under the hint. A development run shows the `unsupported` reason as prose
and neither the action nor the switches — this build could not act on them.
+ Last, the **Feedback** group: one `.feedback-actions` line of four `.btn-ghost.small`
+ keys that wraps on its own — Report a problem · Sessions missing or wrong · Suggest an
+ idea · Questions & discussion — opening the repository's issue forms and Discussions
+ through `openExternal`. The URLs are `feedbackUrl` in `src/shared/feedback.ts`, never
+ built in the component. The two reports are prefilled with versions only (Cockpit,
+ macOS + architecture, each agent CLI's version and install method); the CLIs are read
+ on the click, not when the tab opens, and a read that fails or takes more than a few
+ seconds opens the form without that field. The pressed key reads "Opening…" and all
+ four are disabled meanwhile. One `.ns-hint.ns-prose` line under the keys must keep
+ saying what the form is filled with and that nothing else is.
- App-level: the global Escape handler blurs a focused field first and only closes the
view on a second press — a habitual Escape must never discard a half-typed path.
diff --git a/src/main/updates.ts b/src/main/updates.ts
index 10b7f3cc..db56423f 100644
--- a/src/main/updates.ts
+++ b/src/main/updates.ts
@@ -1,5 +1,6 @@
import { app } from 'electron'
import { autoUpdater } from 'electron-updater'
+import { COCKPIT_REPO_URL } from '../shared/feedback'
import type { AppInfo, UpdateInstallOutcome, UpdatePrefs, UpdateState } from '../shared/types'
import { updatePrefs } from './config'
import {
@@ -15,7 +16,7 @@ import {
import { checkOutcome, pickZip, type CheckResult, type FeedFile } from './update-install-core'
/** Where releases live — the updater's feed and the only place release notes are kept. */
-export const RELEASES_URL = 'https://github.com/tashtit/cockpit/releases'
+export const RELEASES_URL = `${COCKPIT_REPO_URL}/releases`
/** The launch check waits for the index to settle; afterwards a quiet periodic one. */
const FIRST_CHECK_DELAY_MS = 20_000
@@ -284,6 +285,8 @@ export function appInfo(): AppInfo {
version: app.getVersion(),
packaged: app.isPackaged,
platform: process.platform,
+ // Electron's own reading of the product version; os.release() is Darwin's
+ osVersion: process.getSystemVersion(),
arch: process.arch,
electron: process.versions.electron ?? '',
releasesUrl: RELEASES_URL
diff --git a/src/renderer/src/AboutSection.tsx b/src/renderer/src/AboutSection.tsx
index 3676d025..f8fd60e2 100644
--- a/src/renderer/src/AboutSection.tsx
+++ b/src/renderer/src/AboutSection.tsx
@@ -1,4 +1,10 @@
import { useEffect, useState, type JSX } from 'react'
+import {
+ feedbackPrefills,
+ feedbackUrl,
+ type FeedbackCli,
+ type FeedbackKind
+} from '../../shared/feedback'
import type { AppInfo, UpdatePrefs, UpdateState } from '../../shared/types'
import { api } from './api'
import { fmtAgo } from './format'
@@ -23,6 +29,82 @@ const UPDATE_SWITCHES: ReadonlyArray<{
}
]
+const FEEDBACK_ACTIONS: ReadonlyArray<{ readonly kind: FeedbackKind; readonly label: string }> = [
+ { kind: 'bug', label: 'Report a problem' },
+ { kind: 'sessions', label: 'Sessions missing or wrong' },
+ { kind: 'idea', label: 'Suggest an idea' },
+ { kind: 'discussion', label: 'Questions & discussion' }
+]
+
+/** How long a report waits on the CLI versions before it opens without them. */
+const CLI_WAIT_MS = 4_000
+
+/**
+ * The agent CLIs as the Accounts tab reads them, for a report's prefill — or null
+ * when they can't be read in time, which leaves that field to the person rather than
+ * holding the click on a slow `brew` or an offline registry.
+ */
+function cliFacts(): Promise {
+ return new Promise((resolve) => {
+ const timer = setTimeout(() => resolve(null), CLI_WAIT_MS)
+ api
+ .listCliStatus(false)
+ .then(resolve, () => resolve(null))
+ .finally(() => clearTimeout(timer))
+ })
+}
+
+/**
+ * Feedback: the repository's issue forms and discussions, one click away. The two
+ * reports open prefilled with versions only (`src/shared/feedback.ts` decides what
+ * that is); the CLIs are asked on the click, never when the tab opens.
+ */
+function FeedbackGroup({
+ appInfo,
+ onStatus
+}: {
+ appInfo: AppInfo | null
+ onStatus: (s: string) => void
+}): JSX.Element {
+ const [opening, setOpening] = useState(null)
+
+ const open = async (kind: FeedbackKind, label: string): Promise => {
+ if (opening) return
+ setOpening(kind)
+ try {
+ const clis = feedbackPrefills(kind) ? await cliFacts() : null
+ await api.openExternal(feedbackUrl(kind, { app: appInfo, clis }))
+ onStatus(`${label} opened on GitHub`)
+ } catch (err) {
+ onStatus(`Could not open GitHub: ${ipcErrorText(err)}`)
+ } finally {
+ setOpening(null)
+ }
+ }
+
+ return (
+ <>
+
Feedback
+
+ {FEEDBACK_ACTIONS.map((a) => (
+
+ ))}
+
+
+ Opens GitHub. Reports come with your Cockpit, macOS and agent CLI versions filled in —
+ nothing else.
+
+ >
+ )
+}
+
/** The About row's one-line readout of where the updater stands. */
export function updateLine(u: UpdateState | null, prefs: UpdatePrefs | null): string {
if (!u) return 'loading…'
@@ -53,7 +135,8 @@ export function updateLine(u: UpdateState | null, prefs: UpdatePrefs | null): st
}
/**
- * The About tab: what this build is, and the whole of the updater's control surface.
+ * The About tab: what this build is, the whole of the updater's control surface, and
+ * where feedback goes.
*
* The update state itself is the shell's, not this tab's — main pushes transitions
* whether or not About is the tab on screen, and the card's status region has to
@@ -170,6 +253,7 @@ export function AboutSection({
return (
<>
+
Updates
@@ -253,6 +337,7 @@ export function AboutSection({
{licensesError}
)}
+
>
)
}
diff --git a/src/renderer/src/style.css b/src/renderer/src/style.css
index 74ab79e4..5aaa8635 100644
--- a/src/renderer/src/style.css
+++ b/src/renderer/src/style.css
@@ -1648,6 +1648,9 @@ body.rail-dragging { cursor: col-resize; user-select: none; }
.attn-switch input { width: 14px; height: 14px; margin: 5px; flex-shrink: 0; cursor: pointer; }
.attn-switch input:disabled { cursor: default; opacity: 0.4; }
.attn-switch .source-note { display: block; }
+/* About's feedback keys: one line of ghost buttons that wraps on its own, never a
+ breakpoint — the card is the window minus a sidebar the user drags */
+.feedback-actions { display: flex; flex-wrap: wrap; gap: var(--s2); }
/* ---------- settings subscription usage ---------- */
.usage-windows { display: flex; flex-direction: column; gap: var(--s1); margin-top: var(--s1); }
/* wraps between its parts, never inside them: each reading is a phrase, and a phrase
diff --git a/src/shared/feedback.ts b/src/shared/feedback.ts
new file mode 100644
index 00000000..deca0bea
--- /dev/null
+++ b/src/shared/feedback.ts
@@ -0,0 +1,108 @@
+import { SEAT_NAME } from './roundtable'
+import type { AppInfo, CliInstall, CliStatus } from './types'
+
+/**
+ * Where feedback goes: the repository's issue forms and discussions, one click from
+ * Settings › About. A report opens GitHub with the facts a maintainer asks for first
+ * already in the form — and only those. Everything here is what this build and this
+ * Mac run (versions, an install method), never who is running it or where: no paths,
+ * no usernames, no account identities, no session content. The person sees every
+ * field before anything is submitted.
+ */
+
+/** The repository — releases, issues and discussions all live under it. */
+export const COCKPIT_REPO_URL = 'https://github.com/tashtit/cockpit'
+
+/**
+ * The longest URL a feedback link may be. 2,000 characters passes every browser and
+ * proxy and is far under what GitHub itself accepts; the prefill is a few hundred, so
+ * the cap only ever bites on input nobody expected — and then a field is left out
+ * whole rather than cut mid-line.
+ */
+export const FEEDBACK_URL_MAX = 2000
+
+export type FeedbackKind = 'bug' | 'sessions' | 'idea' | 'discussion'
+
+type Target = {
+ readonly path: string
+ /** The issue form, by its file name under `.github/ISSUE_TEMPLATE` */
+ readonly template?: string
+ /** Whether the form carries the version / macOS / agents fields */
+ readonly facts: boolean
+}
+
+const TARGETS: Record = {
+ bug: { path: '/issues/new', template: 'bug.yml', facts: true },
+ sessions: { path: '/issues/new', template: 'sessions.yml', facts: true },
+ idea: { path: '/issues/new', template: 'idea.yml', facts: false },
+ discussion: { path: '/discussions', facts: false }
+}
+
+/** Whether this kind of feedback is prefilled — the only ones worth asking the CLIs for. */
+export function feedbackPrefills(kind: FeedbackKind): boolean {
+ return TARGETS[kind].facts
+}
+
+/** What a CLI row may contribute: its version and how it was installed — never its path. */
+export type FeedbackCli = Pick
+
+/** The facts a report can be prefilled with. Either half may be missing; its fields are then left for the person. */
+export type FeedbackFacts = {
+ readonly app?: Pick | null
+ readonly clis?: readonly FeedbackCli[] | null
+}
+
+const INSTALL_LABEL: Record = {
+ 'brew-cask': 'Homebrew cask',
+ 'brew-formula': 'Homebrew formula',
+ npm: 'npm',
+ native: 'native install'
+}
+
+/** "0.30.0", or "0.0.0 (development run)" — a dev build's version says nothing on its own. */
+export function versionLine(app: NonNullable): string {
+ return app.packaged ? app.version : `${app.version} (development run)`
+}
+
+/** "26.0 (arm64)" — the macOS product version, not Darwin's, and the build's architecture. */
+export function macosLine(app: NonNullable): string {
+ const os = app.osVersion.trim()
+ const version = app.platform === 'darwin' ? os : `${app.platform} ${os}`.trim()
+ return version ? `${version} (${app.arch})` : `(${app.arch})`
+}
+
+/** One line per agent CLI: "Claude Code: 2.1.236 (Homebrew cask)", "Copilot: not installed". */
+export function agentsLines(clis: readonly FeedbackCli[]): string {
+ return clis
+ .map((c) => {
+ const name = SEAT_NAME[c.provider]
+ if (!c.installed) return `${name}: not installed`
+ const how = c.install ? ` (${INSTALL_LABEL[c.install]})` : ''
+ return `${name}: ${c.version ?? 'version unknown'}${how}`
+ })
+ .join('\n')
+}
+
+/**
+ * The GitHub link for one kind of feedback, with the facts it can carry. Fields are
+ * added in the order a maintainer reads them — version, macOS, agents — and one that
+ * would take the link past `FEEDBACK_URL_MAX` is left out, never truncated.
+ */
+export function feedbackUrl(kind: FeedbackKind, facts: FeedbackFacts = {}): string {
+ const target = TARGETS[kind]
+ let url = `${COCKPIT_REPO_URL}${target.path}`
+ if (target.template) url += `?template=${encodeURIComponent(target.template)}`
+ if (!target.facts) return url
+
+ const fields: Array = []
+ if (facts.app) {
+ fields.push(['version', versionLine(facts.app)], ['macos', macosLine(facts.app)])
+ }
+ if (facts.clis && facts.clis.length > 0) fields.push(['agents', agentsLines(facts.clis)])
+
+ for (const [id, value] of fields) {
+ const pair = `${url.includes('?') ? '&' : '?'}${id}=${encodeURIComponent(value)}`
+ if (url.length + pair.length <= FEEDBACK_URL_MAX) url += pair
+ }
+ return url
+}
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 1c570ee0..1d4b6e0b 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -1585,6 +1585,8 @@ export type AppInfo = {
/** false under `npm run dev` and the e2e runs against out/ — the About row says so */
readonly packaged: boolean
readonly platform: string
+ /** The OS's own product version — macOS's "26.0", not the Darwin kernel's "25.0.0" */
+ readonly osVersion: string
readonly arch: string
readonly electron: string
/** The GitHub Releases page — release notes live there, not in the app */
diff --git a/tests/component/settings-about.test.tsx b/tests/component/settings-about.test.tsx
index 34506602..ce5e1e87 100644
--- a/tests/component/settings-about.test.tsx
+++ b/tests/component/settings-about.test.tsx
@@ -1,13 +1,14 @@
import { describe, it, expect, vi } from 'vitest'
-import { render, screen, act } from '@testing-library/react'
+import { render, screen, act, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Settings } from '../../src/renderer/src/Settings'
-import type { AppInfo, UpdateState } from '../../src/shared/types'
+import type { AppInfo, CliStatus, UpdateState } from '../../src/shared/types'
const installed: AppInfo = {
version: '1.4.2',
packaged: true,
platform: 'darwin',
+ osVersion: '26.0',
arch: 'arm64',
electron: '44.2.0',
releasesUrl: 'https://github.com/tashtit/cockpit/releases'
@@ -191,4 +192,63 @@ describe('Settings › About', () => {
await userEvent.click(link)
expect(await screen.findByRole('alert')).toHaveTextContent('No application can open this file.')
})
+
+ const cli = (over: Partial & Pick): CliStatus => ({
+ installed: true,
+ version: null,
+ path: null,
+ install: null,
+ latest: null,
+ upstream: null,
+ channel: null,
+ updateAvailable: false,
+ updateCommand: null,
+ ...over
+ })
+
+ it('opens each kind of feedback on GitHub, the reports prefilled with versions only', async () => {
+ vi.mocked(window.cockpit.getAppInfo).mockResolvedValue(installed)
+ vi.mocked(window.cockpit.listCliStatus).mockResolvedValue([
+ cli({ provider: 'claude', version: '2.1.236', install: 'brew-cask', path: '/Users/someone/claude' }),
+ cli({ provider: 'codex', version: '0.154.0', install: 'npm' }),
+ cli({ provider: 'copilot', installed: false })
+ ])
+ render()
+ await screen.findByText('v1.4.2')
+
+ expect(screen.getByRole('heading', { name: 'Feedback' })).toBeInTheDocument()
+ expect(screen.getByText(/versions filled in — nothing else/)).toBeInTheDocument()
+
+ const facts =
+ '&version=1.4.2&macos=26.0%20(arm64)' +
+ '&agents=Claude%20Code%3A%202.1.236%20(Homebrew%20cask)%0ACodex%3A%200.154.0%20(npm)%0ACopilot%3A%20not%20installed'
+ const expected: ReadonlyArray = [
+ ['Report a problem', `https://github.com/tashtit/cockpit/issues/new?template=bug.yml${facts}`],
+ ['Sessions missing or wrong', `https://github.com/tashtit/cockpit/issues/new?template=sessions.yml${facts}`],
+ ['Suggest an idea', 'https://github.com/tashtit/cockpit/issues/new?template=idea.yml'],
+ ['Questions & discussion', 'https://github.com/tashtit/cockpit/discussions']
+ ]
+ for (const [name, url] of expected) {
+ await userEvent.click(screen.getByRole('button', { name }))
+ await waitFor(() => expect(window.cockpit.openExternal).toHaveBeenLastCalledWith(url))
+ expect(await screen.findByRole('button', { name })).toBeEnabled()
+ }
+ // the CLIs are asked for the two reports only, and on the click — not when the tab opened
+ expect(window.cockpit.listCliStatus).toHaveBeenCalledTimes(2)
+ expect(screen.getByRole('status')).toHaveTextContent('Questions & discussion opened on GitHub')
+ })
+
+ it('still opens a report when the agent CLIs cannot be read, leaving that field to the person', async () => {
+ vi.mocked(window.cockpit.getAppInfo).mockResolvedValue(installed)
+ vi.mocked(window.cockpit.listCliStatus).mockRejectedValue(new Error('which failed'))
+ render()
+ await screen.findByText('v1.4.2')
+
+ await userEvent.click(screen.getByRole('button', { name: 'Report a problem' }))
+ await waitFor(() =>
+ expect(window.cockpit.openExternal).toHaveBeenCalledWith(
+ 'https://github.com/tashtit/cockpit/issues/new?template=bug.yml&version=1.4.2&macos=26.0%20(arm64)'
+ )
+ )
+ })
})
diff --git a/tests/component/stub-api.ts b/tests/component/stub-api.ts
index 9043e4e0..e3a38d84 100644
--- a/tests/component/stub-api.ts
+++ b/tests/component/stub-api.ts
@@ -324,6 +324,7 @@ export function freshApi(): CockpitApi {
version: '0.0.0',
packaged: false,
platform: 'darwin',
+ osVersion: '26.0',
arch: 'arm64',
electron: '44.0.0',
releasesUrl: 'https://github.com/tashtit/cockpit/releases'
diff --git a/tests/feedback.test.ts b/tests/feedback.test.ts
new file mode 100644
index 00000000..46610477
--- /dev/null
+++ b/tests/feedback.test.ts
@@ -0,0 +1,138 @@
+import { describe, it, expect } from 'vitest'
+import {
+ agentsLines,
+ COCKPIT_REPO_URL,
+ FEEDBACK_URL_MAX,
+ feedbackPrefills,
+ feedbackUrl,
+ macosLine,
+ type FeedbackCli,
+ type FeedbackFacts
+} from '../src/shared/feedback'
+import type { CliStatus } from '../src/shared/types'
+
+const app: NonNullable = {
+ version: '0.30.0',
+ packaged: true,
+ platform: 'darwin',
+ arch: 'arm64',
+ osVersion: '26.0'
+}
+
+const clis: readonly FeedbackCli[] = [
+ { provider: 'claude', installed: true, version: '2.1.236', install: 'brew-cask' },
+ { provider: 'codex', installed: true, version: '0.154.0', install: 'npm' },
+ { provider: 'copilot', installed: false, version: null, install: null }
+]
+
+/** The query of a feedback URL, decoded the way GitHub reads it. */
+function params(url: string): Record {
+ return Object.fromEntries(new URL(url).searchParams)
+}
+
+describe('feedback links', () => {
+ it('sends a problem report to the bug form, prefilled with versions only', () => {
+ const url = feedbackUrl('bug', { app, clis })
+ expect(url.startsWith(`${COCKPIT_REPO_URL}/issues/new?template=bug.yml&`)).toBe(true)
+ expect(params(url)).toEqual({
+ template: 'bug.yml',
+ version: '0.30.0',
+ macos: '26.0 (arm64)',
+ agents: 'Claude Code: 2.1.236 (Homebrew cask)\nCodex: 0.154.0 (npm)\nCopilot: not installed'
+ })
+ })
+
+ it('sends missing or wrong sessions to their own form with the same facts', () => {
+ const url = feedbackUrl('sessions', { app, clis })
+ expect(url.startsWith(`${COCKPIT_REPO_URL}/issues/new?template=sessions.yml&`)).toBe(true)
+ expect(Object.keys(params(url))).toEqual(['template', 'version', 'macos', 'agents'])
+ })
+
+ it('opens an idea bare, and discussions with no query at all', () => {
+ // neither form asks about this Mac, so neither is told
+ expect(feedbackUrl('idea', { app, clis })).toBe(`${COCKPIT_REPO_URL}/issues/new?template=idea.yml`)
+ expect(feedbackUrl('discussion', { app, clis })).toBe(`${COCKPIT_REPO_URL}/discussions`)
+ expect(feedbackPrefills('idea')).toBe(false)
+ expect(feedbackPrefills('discussion')).toBe(false)
+ expect(feedbackPrefills('bug')).toBe(true)
+ expect(feedbackPrefills('sessions')).toBe(true)
+ })
+
+ it('percent-encodes every value, spaces and line breaks included', () => {
+ const url = feedbackUrl('bug', { app, clis })
+ expect(url).toContain('&macos=26.0%20(arm64)')
+ expect(url).toContain('Claude%20Code%3A%202.1.236%20(Homebrew%20cask)%0ACodex')
+ // nothing a query could be split on survives unencoded
+ const query = url.slice(url.indexOf('?') + 1)
+ for (const pair of query.split('&')) expect(pair.split('=')).toHaveLength(2)
+ expect(url).not.toMatch(/\s|\+/)
+ })
+
+ it('never carries a path, even when the CLI status it was built from has one', () => {
+ const status: CliStatus = {
+ provider: 'claude',
+ installed: true,
+ version: '2.1.236',
+ path: '/Users/someone/.local/share/claude/versions/2.1.236',
+ install: 'native',
+ latest: '2.1.278',
+ upstream: '2.1.278',
+ channel: 'its own installer',
+ updateAvailable: true,
+ updateCommand: 'claude update'
+ }
+ const url = feedbackUrl('bug', { app, clis: [status] })
+ expect(params(url)['agents']).toBe('Claude Code: 2.1.236 (native install)')
+ expect(decodeURIComponent(url)).not.toContain('someone')
+ })
+
+ it('leaves the fields it cannot fill to the person', () => {
+ // nothing known: the form opens empty rather than with guesses
+ expect(feedbackUrl('bug')).toBe(`${COCKPIT_REPO_URL}/issues/new?template=bug.yml`)
+ // the CLIs could not be read in time: version and macOS still go
+ expect(Object.keys(params(feedbackUrl('bug', { app, clis: null })))).toEqual(['template', 'version', 'macos'])
+ expect(Object.keys(params(feedbackUrl('bug', { app, clis: [] })))).toEqual(['template', 'version', 'macos'])
+ // the app info had not arrived: the agents still do
+ expect(Object.keys(params(feedbackUrl('sessions', { app: null, clis })))).toEqual(['template', 'agents'])
+ })
+
+ it('says what it could not read about a CLI rather than dropping the line', () => {
+ expect(
+ agentsLines([
+ { provider: 'claude', installed: true, version: null, install: 'npm' },
+ { provider: 'codex', installed: true, version: '0.154.0', install: null }
+ ])
+ ).toBe('Claude Code: version unknown (npm)\nCodex: 0.154.0')
+ })
+
+ it('marks a development run, whose version says nothing on its own', () => {
+ const url = feedbackUrl('bug', { app: { ...app, version: '0.0.0', packaged: false } })
+ expect(params(url)['version']).toBe('0.0.0 (development run)')
+ })
+
+ it('names the OS outright when it is not macOS', () => {
+ expect(macosLine(app)).toBe('26.0 (arm64)')
+ expect(macosLine({ ...app, platform: 'linux', osVersion: '6.8.0', arch: 'x64' })).toBe('linux 6.8.0 (x64)')
+ expect(macosLine({ ...app, osVersion: '' })).toBe('(arm64)')
+ })
+
+ it('stays under the length cap by leaving a field out whole, never cutting one', () => {
+ const huge = 'x'.repeat(FEEDBACK_URL_MAX)
+ const long = feedbackUrl('bug', { app: { ...app, osVersion: huge }, clis })
+ expect(long.length).toBeLessThanOrEqual(FEEDBACK_URL_MAX)
+ // the oversized field is gone; the ones around it still fit and still go
+ expect(params(long)).toEqual({
+ template: 'bug.yml',
+ version: '0.30.0',
+ agents: 'Claude Code: 2.1.236 (Homebrew cask)\nCodex: 0.154.0 (npm)\nCopilot: not installed'
+ })
+
+ const everything = feedbackUrl('bug', { app: { ...app, version: huge, osVersion: huge }, clis })
+ expect(everything.length).toBeLessThanOrEqual(FEEDBACK_URL_MAX)
+ expect(Object.keys(params(everything))).toEqual(['template', 'agents'])
+ })
+
+ it('keeps the everyday prefill far inside the cap', () => {
+ expect(feedbackUrl('bug', { app, clis }).length).toBeLessThan(FEEDBACK_URL_MAX / 4)
+ })
+})
From d87573aba48089f938377cffd23547625de1feb8 Mon Sep 17 00:00:00 2001
From: titan-ron <30556071+titan-ron@users.noreply.github.com>
Date: Thu, 24 Sep 2026 18:35:35 +0300
Subject: [PATCH 2/4] feat(install): install from Terminal or Homebrew without
the Gatekeeper detour
scripts/install.sh downloads the latest release for the Mac's architecture with curl, which sets no quarantine flag, verifies it against the SHA-256 digest GitHub recorded for the asset, checks the bundle id and version, and swaps it in beside any installed copy, putting the old one back on failure. It never reads latest-mac.yml, whose download count stands for installed copies checking for updates.
Getting started and every release page now lead with the one-liner and the tashtit/tap Homebrew cask; the disk image and its Gatekeeper steps stay as the alternative.
---
.releaserc.json | 2 +-
docs/guide/getting-started.md | 45 +++-
docs/guide/troubleshooting.md | 8 +-
scripts/install.sh | 356 ++++++++++++++++++++++++++++++++
tests/install-script.test.ts | 378 ++++++++++++++++++++++++++++++++++
5 files changed, 784 insertions(+), 5 deletions(-)
create mode 100755 scripts/install.sh
create mode 100644 tests/install-script.test.ts
diff --git a/.releaserc.json b/.releaserc.json
index 4d818c18..e3a23fcf 100644
--- a/.releaserc.json
+++ b/.releaserc.json
@@ -44,7 +44,7 @@
{ "path": "dist/*.blockmap" },
{ "path": "dist/latest-mac.yml" }
],
- "releaseBodyTemplate": "<%= nextRelease.notes %>\n\n## Install\n\nCockpit isn't signed with an Apple Developer ID yet, so macOS blocks the first launch. Once only:\n\n1. Download the `.dmg` for your Mac below — `arm64` for Apple silicon, `x64` for Intel — and drag Cockpit into Applications.\n2. Open Cockpit. When macOS says it can't verify the app, click **Done**.\n3. In System Settings › Privacy & Security, click **Open Anyway** — or in Terminal: `xattr -d com.apple.quarantine /Applications/Cockpit.app`\n\nAfter that, Cockpit updates itself.\n\n[Full install guide](https://github.com/tashtit/cockpit#install) · Verify a download: `gh attestation verify --owner tashtit`\n",
+ "releaseBodyTemplate": "<%= nextRelease.notes %>\n\n## Install\n\n```bash\ncurl -fsSL https://raw.githubusercontent.com/tashtit/cockpit/main/scripts/install.sh | sh\n```\n\nor `brew install --cask tashtit/tap/cockpit`. Either checks the download against the SHA-256 digest GitHub lists for it and opens without a trip to Privacy & Security. After that, Cockpit updates itself.\n\nPrefer the `.dmg`? Pick yours below — `arm64` for Apple silicon, `x64` for Intel. It isn't signed with an Apple Developer ID yet, so macOS blocks its first launch once: click **Done**, then **Open Anyway** in System Settings › Privacy & Security (or `xattr -d com.apple.quarantine /Applications/Cockpit.app`).\n\n[Install guide](https://tashtit.github.io/cockpit/guide/getting-started) · [Report a problem](https://github.com/tashtit/cockpit/issues/new?template=bug.yml) · Verify a download: `gh attestation verify --owner tashtit`\n",
"successComment": false,
"failComment": false,
"failTitle": false,
diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md
index a593622d..356f2de1 100644
--- a/docs/guide/getting-started.md
+++ b/docs/guide/getting-started.md
@@ -2,8 +2,38 @@
## Install the app
+Paste this into Terminal:
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/tashtit/cockpit/main/scripts/install.sh | sh
+```
+
+It downloads the latest release for your Mac (Apple silicon or Intel), checks the file against the SHA-256 digest GitHub lists for it, confirms the app inside is Cockpit at that release's version, and puts it in `/Applications`, or in `~/Applications` if you can't write to `/Applications`. Then open it:
+
+```bash
+open -a Cockpit
+```
+
+That is the whole install: no password, no `sudo`, and no trip to Privacy & Security. macOS only holds back an app a browser downloaded, and this one came from `curl`.
+
+Running the same line again reinstalls or updates in place. The copy you have is replaced only once the new one is verified and beside it, and put back if anything fails on the way; the installer stops if Cockpit is running.
+
+- **See what it would do first**: `... | sh -s -- --dry-run` prints the version, the download address, its checksum and where it would go, and downloads nothing.
+- **Install somewhere else**: `... | COCKPIT_INSTALL_DIR=~/Apps sh`.
+- **Read it before you run it**: the script is [`scripts/install.sh`](https://github.com/tashtit/cockpit/blob/main/scripts/install.sh), plain `sh` using only tools macOS ships with.
+
+### Or with Homebrew
+
+```bash
+brew install --cask tashtit/tap/cockpit
+```
+
+The cask installs the same release, checked against the same digest, and clears the quarantine flag Homebrew's download leaves on it, so it opens straight away too. Cockpit updates itself from then on, so `brew upgrade` leaves it alone; `brew uninstall --zap --cask cockpit` also removes its settings.
+
+### Or download the disk image
+
::: warning Early access
-Cockpit is pre-1.0, and its releases are not yet signed with an Apple Developer ID, so macOS blocks the first launch. The steps below get past that once; nothing about the download is wrong.
+Cockpit is pre-1.0, and its releases are not yet signed with an Apple Developer ID, so macOS blocks the first launch of an app downloaded in a browser. The steps below get past that once; nothing about the download is wrong.
:::
1. **Download** the disk image for your Mac from the [latest release](https://github.com/tashtit/cockpit/releases/latest) — `Cockpit--arm64.dmg` on Apple silicon, `Cockpit--x64.dmg` on Intel (About This Mac says which). Open it and drag Cockpit into Applications.
@@ -22,6 +52,8 @@ Every release asset carries a build-provenance attestation. To confirm a disk im
```bash
gh attestation verify ~/Downloads/Cockpit--arm64.dmg --owner tashtit
```
+
+The installer ends by printing the same check for the zip it installed.
:::
## Updating
@@ -38,6 +70,17 @@ Cockpit installs its own updates rather than handing them to macOS. macOS only s
If an update cannot be installed, the version you had is put back and About says why; nothing downloads on its own again until you press **Check for updates**. Your settings in `~/Library/Application Support/Cockpit` are untouched by any of this.
+## Feedback
+
+**Settings › About › Feedback** opens the project on GitHub in your browser:
+
+- **Report a problem** — something broke, or behaved in a way it shouldn't.
+- **Sessions missing or wrong** — a session isn't listed, or shows the wrong title, project, branch or time.
+- **Suggest an idea** — something Cockpit should do.
+- **Questions & discussion** — the project's Discussions, for anything that isn't a bug.
+
+The two reports open with your Cockpit version, macOS version and architecture, and each agent CLI's version and how it was installed already filled in. Nothing else goes into the form — no paths, usernames, accounts or session content — and nothing is filed until you have read it and pressed **Submit** on GitHub yourself.
+
## Run from source
Cockpit is an Electron app: clone, install, run.
diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md
index 63801b75..cfcdcec4 100644
--- a/docs/guide/troubleshooting.md
+++ b/docs/guide/troubleshooting.md
@@ -1,5 +1,7 @@
# Troubleshooting
+If nothing here fixes it, report it from the app: **Settings › About › Report a problem** (or **Sessions missing or wrong**) opens a GitHub issue with your Cockpit, macOS and agent CLI versions already filled in, and nothing else. See [Feedback](/guide/getting-started#feedback).
+
## "Electron failed to install correctly"
This message means the first-run Electron binary download failed. Run the downloader directly to see the underlying error:
@@ -21,7 +23,7 @@ macOS says this — or "from an unidentified developer", or on macOS 15 and late
xattr -d com.apple.quarantine /Applications/Cockpit.app
```
-Signed and notarized releases open without any of this.
+Signed and notarized releases open without any of this, and so does a copy installed with the [one-line installer](/guide/getting-started#install-the-app), which downloads with `curl` and so never gets the flag.
## An update did not install
@@ -46,9 +48,9 @@ Work through these in order:
1. **History window** — if **Settings › View** has a history window set, sessions idle longer than N days are hidden (not deleted). Widen or clear the window.
2. **Archived in the provider's own app** — sessions archived or deleted in Copilot (`data.db`), Codex (`archived_sessions/`), or the Claude desktop app are hidden entirely, by design.
-3. **Copilot specifically** — its session format is the least documented, and the parser is best-effort. If your Copilot sessions don't appear, grab one file from `~/.copilot` and [open an issue](https://github.com/tashtit/cockpit/issues) with it (redact anything sensitive); the parser lives in `src/main/parsers/copilot.ts`.
+3. **Copilot specifically** — its session format is the least documented, and the parser is best-effort. If your Copilot sessions don't appear, report it with **Settings › About › Sessions missing or wrong** and attach one file from `~/.copilot` if you can (redact anything sensitive); the parser lives in `src/main/parsers/copilot.ts`.
-Session log formats are provider-internal and drift between releases — Cockpit's parsers deliberately skip what they can't read rather than fail the whole scan, so a parser gap shows up as missing sessions, never a broken app.
+Session log formats are provider-internal and drift between releases — Cockpit's parsers deliberately skip what they can't read rather than fail the whole scan, so a parser gap shows up as missing sessions, never a broken app. Still missing, or listed with the wrong title, project, branch or time? **Sessions missing or wrong** is for that too, whichever agent the session came from.
## The agent says it can't use tools
diff --git a/scripts/install.sh b/scripts/install.sh
new file mode 100755
index 00000000..d1d8a357
--- /dev/null
+++ b/scripts/install.sh
@@ -0,0 +1,356 @@
+#!/bin/sh
+# Install or update Cockpit on macOS:
+#
+# curl -fsSL https://raw.githubusercontent.com/tashtit/cockpit/main/scripts/install.sh | sh
+#
+# Options go through sh: ... | sh -s -- --dry-run
+#
+# Why a script: releases are not yet signed with an Apple Developer ID, and macOS
+# refuses to open an unsigned app that carries the quarantine flag a browser puts on
+# every download — so a DMG from the releases page needs a detour through System
+# Settings before its first launch. curl sets no such flag. What the browser's trust
+# stood for is checked here instead:
+#
+# 1. the latest release is read from the GitHub REST API, and the zip for this
+# Mac's architecture is downloaded into a temporary folder;
+# 2. its SHA-256 must equal the digest GitHub recorded for the asset when it was
+# uploaded — a release that lists none is refused, never installed unverified;
+# 3. the bundle inside must be Cockpit (dev.tashtit.cockpit) at the release's version;
+# 4. it is copied in beside any installed copy, which is renamed aside and deleted
+# only once the new one is in place — a failure in between puts the old one back.
+#
+# The release comes from the API and never from latest-mac.yml: that file's download
+# count is how many installed copies check for updates, and an install is not one.
+#
+# POSIX sh and the tools every Mac ships with. Nothing here needs or asks for sudo.
+#
+# Environment:
+# COCKPIT_INSTALL_DIR folder to install into. Default: where Cockpit already is
+# (/Applications or ~/Applications), else /Applications when
+# you can write to it, else ~/Applications
+# COCKPIT_INSTALL_API GitHub API base URL (default https://api.github.com); the
+# tests point it at a local server
+# COCKPIT_INSTALL_ARCH arm64 or x64, with --dry-run only: resolve another Mac's build
+#
+# Everything runs from main, called on the last line, so a download of this file that
+# was cut short runs nothing at all.
+
+set -u
+
+REPO=tashtit/cockpit
+BUNDLE_ID=dev.tashtit.cockpit
+RELEASES_URL="https://github.com/$REPO/releases/latest"
+DEFAULT_API=https://api.github.com
+NL='
+'
+
+# Set by main and read by cleanup, which runs on every exit.
+tmp='' # the download folder
+stage='' # the hidden folder beside the install that the new bundle is copied into
+aside='' # the installed bundle while it is renamed aside; non-empty means "put it back"
+app='' # /Cockpit.app
+
+say() { printf '%s\n' "$*"; }
+fail() {
+ printf '%s\n' "$*" >&2
+ exit 1
+}
+
+usage() {
+ cat <<'EOF'
+Install or update Cockpit from its latest GitHub release.
+
+Usage: install.sh [--dry-run]
+
+ --dry-run show the version, download, checksum and destination; change nothing
+ -h, --help show this help
+
+COCKPIT_INSTALL_DIR installs somewhere other than /Applications (or ~/Applications
+when /Applications is not writable).
+EOF
+}
+
+cleanup() {
+ if [ -n "$aside" ]; then
+ # stopped between the two renames: the copy that was installed goes back
+ if [ ! -e "$app" ] && mv "$aside" "$app" 2>/dev/null; then
+ say "The Cockpit you had is back in place." >&2
+ else
+ say "The Cockpit you had is at $aside; move it back to $app." >&2
+ stage='' # it holds the only copy
+ fi
+ fi
+ [ -z "$stage" ] || rm -rf "$stage"
+ [ -z "$tmp" ] || rm -rf "$tmp"
+}
+
+on_signals() {
+ trap 'exit 130' INT
+ trap 'exit 143' TERM
+ trap 'exit 129' HUP
+}
+
+# $1 >= $2 for dotted numeric versions (13.0, 26.7.1). Anything unparseable passes:
+# this only turns away a Mac that is plainly too old.
+version_at_least() {
+ _have=$1
+ _need=$2
+ while [ -n "$_have$_need" ]; do
+ _h=${_have%%.*}
+ _n=${_need%%.*}
+ case $_have in *.*) _have=${_have#*.} ;; *) _have='' ;; esac
+ case $_need in *.*) _need=${_need#*.} ;; *) _need='' ;; esac
+ case "${_h:-0}${_n:-0}" in *[!0-9]*) return 0 ;; esac
+ [ "${_h:-0}" -gt "${_n:-0}" ] && return 0
+ [ "${_h:-0}" -lt "${_n:-0}" ] && return 1
+ done
+ return 0
+}
+
+# One value out of a JSON or plist file; fails on a missing key or a null.
+# `plutil -extract … raw` reads JSON as well as plists (macOS 12 and later).
+value_at() { plutil -extract "$2" raw -o - "$1" 2>/dev/null; }
+
+detect_arch() {
+ if [ -n "${COCKPIT_INSTALL_ARCH:-}" ]; then
+ [ "$dry_run" = 1 ] || fail "COCKPIT_INSTALL_ARCH only applies with --dry-run: the build for this Mac is the one to install."
+ case $COCKPIT_INSTALL_ARCH in
+ arm64 | x64) arch=$COCKPIT_INSTALL_ARCH ;;
+ *) fail "COCKPIT_INSTALL_ARCH must be arm64 or x64, not '$COCKPIT_INSTALL_ARCH'." ;;
+ esac
+ return
+ fi
+ case $(uname -m) in
+ arm64) arch=arm64 ;;
+ x86_64)
+ # a shell running under Rosetta reports x86_64 on Apple silicon too
+ if [ "$(sysctl -n hw.optional.arm64 2>/dev/null)" = 1 ]; then arch=arm64; else arch=x64; fi
+ ;;
+ *) fail "Cockpit is built for Apple silicon and Intel Macs; this one reports '$(uname -m)'." ;;
+ esac
+}
+
+# Reads the latest release and picks this Mac's zip out of it.
+resolve_release() {
+ if [ -n "${COCKPIT_INSTALL_API:-}" ]; then
+ api=${COCKPIT_INSTALL_API%/}
+ https_only=''
+ else
+ api=$DEFAULT_API
+ https_only='--proto =https --tlsv1.2'
+ fi
+ release="$tmp/release.json"
+ # shellcheck disable=SC2086 # https_only is two options or none
+ status=$(curl $https_only --silent --show-error --location \
+ --user-agent cockpit-install \
+ --header 'Accept: application/vnd.github+json' \
+ --header 'X-GitHub-Api-Version: 2022-11-28' \
+ --output "$release" --write-out '%{http_code}' \
+ "$api/repos/$REPO/releases/latest") || true
+ case $status in
+ 200) ;;
+ 403 | 429) fail "GitHub turned the release lookup down (HTTP $status), most likely its hourly limit on anonymous API calls from your network. Try again later, or download Cockpit from $RELEASES_URL." ;;
+ 404) fail "No Cockpit release is published yet. See $RELEASES_URL." ;;
+ '' | 000) fail "Could not reach $api. Check your connection and try again." ;;
+ *) fail "GitHub answered HTTP $status to the release lookup. Try again later, or download Cockpit from $RELEASES_URL." ;;
+ esac
+
+ tag=$(value_at "$release" tag_name) || fail "GitHub's answer names no release. Try again later."
+ version=${tag#v}
+ case $version in
+ '' | *[!0-9A-Za-z.+-]*) fail "The latest release has a tag this installer does not understand ('$tag')." ;;
+ esac
+ zip_name="Cockpit-$version-$arch.zip"
+
+ count=$(value_at "$release" assets) || count=0
+ case $count in '' | *[!0-9]*) count=0 ;; esac
+ asset=''
+ i=0
+ while [ "$i" -lt "$count" ]; do
+ if [ "$(value_at "$release" "assets.$i.name")" = "$zip_name" ]; then
+ asset=$i
+ break
+ fi
+ i=$((i + 1))
+ done
+ [ -n "$asset" ] || fail "Cockpit $version has no $zip_name. See $RELEASES_URL."
+
+ url=$(value_at "$release" "assets.$asset.browser_download_url") ||
+ fail "GitHub lists no download address for $zip_name."
+ digest=$(value_at "$release" "assets.$asset.digest") || digest=''
+ sha=$(printf '%s' "${digest#sha256:}" | tr 'A-F' 'a-f')
+ case $digest in sha256:*) ;; *) sha='' ;; esac
+ case $sha in *[!0-9a-f]*) sha='' ;; esac
+ [ "${#sha}" -eq 64 ] ||
+ fail "GitHub lists no SHA-256 digest for $zip_name, so the download could not be verified. Nothing was installed; the release is at $RELEASES_URL."
+
+ size=$(value_at "$release" "assets.$asset.size") || size=''
+ case $size in
+ '' | *[!0-9]*) size_note='' ;;
+ *) size_note=" ($(((size + 524288) / 1048576)) MB)" ;;
+ esac
+}
+
+# Where Cockpit goes, and what is there already.
+choose_destination() {
+ if [ -n "${COCKPIT_INSTALL_DIR:-}" ]; then
+ dir=$COCKPIT_INSTALL_DIR
+ elif [ -e /Applications/Cockpit.app ]; then
+ dir=/Applications
+ elif [ -e "$HOME/Applications/Cockpit.app" ]; then
+ dir=$HOME/Applications
+ elif [ -w /Applications ]; then
+ dir=/Applications
+ else
+ dir=$HOME/Applications
+ fi
+ case $dir in /*) ;; *) dir="$(pwd)/$dir" ;; esac
+ while :; do
+ case $dir in */) dir=${dir%/} ;; *) break ;; esac
+ done
+ app="$dir/Cockpit.app"
+
+ installed='' # the version already at $app
+ not_cockpit='' # set when $app is some other app
+ if [ -e "$app" ] || [ -L "$app" ]; then
+ if [ "$(value_at "$app/Contents/Info.plist" CFBundleIdentifier)" = "$BUNDLE_ID" ]; then
+ installed=$(value_at "$app/Contents/Info.plist" CFBundleShortVersionString) || installed='an unknown version'
+ else
+ not_cockpit=1
+ fi
+ fi
+}
+
+refuse_if_running() {
+ procs=$(ps -axww -o command= 2>/dev/null) || procs=''
+ case "$NL$procs" in
+ *"$NL$app/Contents/MacOS/"*) fail "Cockpit is running from $app. Quit it, then run the installer again; nothing was changed." ;;
+ esac
+}
+
+main() {
+ dry_run=0
+ for arg in "$@"; do
+ case $arg in
+ --dry-run) dry_run=1 ;;
+ -h | --help)
+ usage
+ return 0
+ ;;
+ *) fail "Unknown option '$arg'. Try --help." ;;
+ esac
+ done
+
+ # the system's own tools, whatever else is first on PATH
+ PATH=/usr/bin:/bin:/usr/sbin:/sbin
+ export PATH
+ unset CDPATH
+
+ os=$(uname -s)
+ [ "$os" = Darwin ] || fail "Cockpit is a macOS app, and this is $os. See $RELEASES_URL for what a release ships."
+ macos=$(sw_vers -productVersion 2>/dev/null) || macos=''
+ version_at_least "${macos:-0}" 12 || fail "Cockpit needs a newer macOS than ${macos:-this one}."
+ detect_arch
+ case $arch in arm64) arch_label='Apple silicon' ;; *) arch_label='Intel' ;; esac
+
+ tmp_base=${TMPDIR:-/tmp}
+ tmp=$(mktemp -d "${tmp_base%/}/cockpit-install.XXXXXX") || fail "Could not create a temporary folder."
+ trap cleanup EXIT
+ on_signals
+
+ resolve_release
+ choose_destination
+
+ if [ "$dry_run" = 1 ]; then
+ if [ -n "$not_cockpit" ]; then
+ note=' (holds another app; an install would stop here)'
+ elif [ -n "$installed" ]; then
+ note=" (replaces $installed)"
+ elif [ -d "$dir" ]; then
+ note=''
+ else
+ note=' (folder will be created)'
+ fi
+ say "Cockpit $version for $arch_label"
+ say " asset: $zip_name$size_note"
+ say " url: $url"
+ say " sha256: $sha"
+ say " destination: $app$note"
+ say "Dry run: nothing was downloaded or changed."
+ return 0
+ fi
+
+ [ -z "$not_cockpit" ] ||
+ fail "$app is not Cockpit (its bundle id is not $BUNDLE_ID). Move it out of the way, or set COCKPIT_INSTALL_DIR, and run the installer again."
+ mkdir -p "$dir" 2>/dev/null || fail "Could not create $dir."
+ dir=$(cd "$dir" && pwd) || fail "Could not open $dir."
+ app="$dir/Cockpit.app"
+ [ -w "$dir" ] || fail "You cannot write to $dir. Set COCKPIT_INSTALL_DIR to a folder you own (such as ~/Applications) and run the installer again."
+ refuse_if_running
+
+ say "Downloading Cockpit $version for $arch_label$size_note..."
+ zip="$tmp/$zip_name"
+ if [ -t 2 ]; then progress=--progress-bar; else progress=--silent; fi
+ # shellcheck disable=SC2086
+ curl $https_only $progress --show-error --fail --location --retry 2 \
+ --user-agent cockpit-install --output "$zip" "$url" ||
+ fail "The download failed. Nothing was installed; try again, or download Cockpit from $RELEASES_URL."
+
+ got=$(shasum -a 256 "$zip" | cut -d ' ' -f 1)
+ [ "$got" = "$sha" ] ||
+ fail "The download does not match the SHA-256 digest GitHub lists for $zip_name (expected $sha, got ${got:-nothing}). Nothing was installed."
+ say "Checksum verified."
+
+ mkdir "$tmp/unpacked" && ditto -x -k "$zip" "$tmp/unpacked" 2>/dev/null ||
+ fail "The download could not be unpacked. Nothing was installed."
+ new="$tmp/unpacked/Cockpit.app"
+ plist="$new/Contents/Info.plist"
+ [ -d "$new" ] || fail "$zip_name holds no Cockpit.app. Nothing was installed."
+ id=$(value_at "$plist" CFBundleIdentifier) || id=''
+ [ "$id" = "$BUNDLE_ID" ] ||
+ fail "The downloaded app is not Cockpit (bundle id '${id:-none}', expected $BUNDLE_ID). Nothing was installed."
+ got_version=$(value_at "$plist" CFBundleShortVersionString) || got_version=''
+ [ "$got_version" = "$version" ] ||
+ fail "The downloaded app is version '${got_version:-unknown}', not the $version the release names. Nothing was installed."
+ exe=$(value_at "$plist" CFBundleExecutable) || exe=''
+ [ -n "$exe" ] && [ -x "$new/Contents/MacOS/$exe" ] ||
+ fail "The downloaded app has no executable. Nothing was installed."
+ min=$(value_at "$plist" LSMinimumSystemVersion) || min=''
+ [ -z "$min" ] || version_at_least "$macos" "$min" ||
+ fail "Cockpit $version needs macOS $min or later, and this Mac runs $macos. Nothing was installed."
+
+ # Copied in beside the installed bundle first, so the swap is two renames in one folder.
+ stage=$(mktemp -d "$dir/.cockpit-install.XXXXXX") || fail "Could not write to $dir. Nothing was installed."
+ ditto "$new" "$stage/Cockpit.app" || fail "Copying Cockpit into $dir failed. Nothing was installed."
+ refuse_if_running # the download took a while; it may have been opened since
+
+ trap '' INT TERM HUP
+ if [ -e "$app" ] || [ -L "$app" ]; then
+ mv "$app" "$stage/previous.app" 2>/dev/null ||
+ fail "Could not move $app aside; the Cockpit you had is untouched."
+ aside="$stage/previous.app"
+ fi
+ mv "$stage/Cockpit.app" "$app" 2>/dev/null || fail "Could not put the new Cockpit in place at $app."
+ aside=''
+ on_signals
+ xattr -dr com.apple.quarantine "$app" 2>/dev/null || true
+
+ if [ -z "$installed" ]; then
+ say "Installed Cockpit $version in $dir."
+ elif [ "$installed" = "$version" ]; then
+ say "Reinstalled Cockpit $version in $dir."
+ else
+ say "Updated Cockpit from $installed to $version in $dir."
+ fi
+ say ''
+ case $dir in
+ /Applications | "$HOME/Applications") say "Open it: open -a Cockpit" ;;
+ *) say "Open it: open '$app'" ;;
+ esac
+ say ''
+ say "Optional: confirm the release workflow built it (needs the GitHub CLI):"
+ say " gh release download $tag --repo $REPO --pattern $zip_name"
+ say " gh attestation verify $zip_name --owner tashtit"
+}
+
+main "$@"
+
+
+ CFBundleIdentifier${opts.identifier ?? IDENTIFIER}
+ CFBundleShortVersionString${version}
+ CFBundleExecutableCockpit
+ LSMinimumSystemVersion12.0
+
+`,
+ 'utf8'
+ )
+ const exe = join(bundle, 'Contents', 'MacOS', 'Cockpit')
+ writeFileSync(exe, `#!/bin/sh\necho ${version}\n`, 'utf8')
+ chmodSync(exe, 0o755)
+ return bundle
+}
+
+async function ok(cmd: string, args: readonly string[]): Promise {
+ const out = await execText(cmd, args, { timeoutMs: 60_000 })
+ if (!out.ok) throw new Error(`${cmd} ${args.join(' ')}: ${out.stderr || out.error}`)
+ return out.stdout
+}
+
+/** The release asset: ditto, keeping the bundle as the archive's top entry. */
+async function releaseZip(version: string, opts: { identifier?: string; quarantined?: boolean } = {}): Promise {
+ const world = scratch()
+ const bundle = makeApp(world, version, opts)
+ // What a browser leaves on a download, and the installer must not carry into the
+ // install. On a file inside the bundle: ditto archives no attributes for the
+ // --keepParent top entry itself, but restores an inner file's on unpacking.
+ const exe = join(bundle, 'Contents', 'MacOS', 'Cockpit')
+ if (opts.quarantined) await ok('/usr/bin/xattr', ['-w', 'com.apple.quarantine', '0081;00000000;Safari;', exe])
+ const zip = join(world, 'release.zip')
+ await ok('/usr/bin/ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', bundle, zip])
+ return readFileSync(zip)
+}
+
+function sha256(bytes: Buffer): string {
+ return createHash('sha256').update(bytes).digest('hex')
+}
+
+/** What the stand-in API publishes; each test sets its own. */
+type Release = {
+ readonly tag: string
+ readonly zip: Buffer
+ /** the asset's `digest` field; null is what GitHub returns for assets it never hashed */
+ readonly digest: string | null
+}
+
+let release: Release
+let served: string[] = []
+let server: Server
+let base = ''
+
+function releaseJson(r: Release): string {
+ const version = r.tag.replace(/^v/, '')
+ const asset = (name: string, digest: string | null, size: number): object => ({
+ name,
+ size,
+ digest,
+ content_type: 'application/octet-stream',
+ browser_download_url: `${base}/download/${r.tag}/${name}`
+ })
+ return JSON.stringify({
+ tag_name: r.tag,
+ name: r.tag,
+ draft: false,
+ prerelease: false,
+ body: null,
+ assets: [
+ asset(`Cockpit-${version}-arm64.dmg`, `sha256:${'0'.repeat(64)}`, 1),
+ asset(`Cockpit-${version}-arm64.zip`, r.digest, r.zip.length),
+ asset(`Cockpit-${version}-x64.zip`, r.digest, r.zip.length),
+ asset('latest-mac.yml', `sha256:${'1'.repeat(64)}`, 1)
+ ]
+ })
+}
+
+beforeAll(async () => {
+ server = createServer((req, res) => {
+ const path = req.url ?? ''
+ served.push(path)
+ if (path === LATEST) {
+ res.writeHead(200, { 'content-type': 'application/json' })
+ res.end(releaseJson(release))
+ } else if (path.startsWith('/download/') && path.endsWith('.zip')) {
+ res.writeHead(200, { 'content-type': 'application/zip', 'content-length': release.zip.length })
+ res.end(release.zip)
+ } else {
+ res.writeHead(404)
+ res.end()
+ }
+ })
+ server.listen(0, '127.0.0.1')
+ await once(server, 'listening')
+ base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
+})
+
+afterAll(async () => {
+ server.close()
+ for (const d of dirs) rmSync(d, { recursive: true, force: true })
+})
+
+beforeEach(() => {
+ served = []
+})
+
+type Run = { readonly code: number | null; readonly stdout: string; readonly stderr: string }
+
+/**
+ * The script as the one-liner runs it: its text on sh's stdin, options after `-s --`.
+ * HOME is a scratch folder and COCKPIT_INSTALL_DIR is always set, so no case can reach
+ * the real /Applications or ~/Applications.
+ */
+async function install(dest: string, args: readonly string[] = [], env: NodeJS.ProcessEnv = {}): Promise {
+ const child = spawn('/bin/sh', ['-s', '--', ...args], {
+ env: {
+ ...process.env,
+ HOME: scratch(),
+ COCKPIT_INSTALL_API: base,
+ COCKPIT_INSTALL_DIR: dest,
+ COCKPIT_INSTALL_ARCH: '',
+ // a developer's proxy must not stand between the script and the local server
+ NO_PROXY: '127.0.0.1',
+ no_proxy: '127.0.0.1',
+ ...env
+ },
+ stdio: ['pipe', 'pipe', 'pipe']
+ })
+ let stdout = ''
+ let stderr = ''
+ child.stdout.on('data', (d: Buffer) => (stdout += d.toString()))
+ child.stderr.on('data', (d: Buffer) => (stderr += d.toString()))
+ child.stdin.end(readFileSync(SCRIPT))
+ const [code] = (await once(child, 'close')) as [number | null]
+ return { code, stdout, stderr }
+}
+
+async function plistValue(bundle: string, key: string): Promise {
+ return (await ok('/usr/bin/plutil', ['-extract', key, 'raw', '-o', '-', join(bundle, 'Contents', 'Info.plist')])).trim()
+}
+
+async function publish(version: string, opts: { identifier?: string; quarantined?: boolean } = {}): Promise {
+ const zip = await releaseZip(version, opts)
+ release = { tag: `v${version}`, zip, digest: `sha256:${sha256(zip)}` }
+}
+
+/** The destination with an older Cockpit in it, plus a file only that copy has. */
+function installed(version: string): { dest: string; app: string; marker: string } {
+ const dest = join(scratch(), 'Applications')
+ mkdirSync(dest)
+ const app = makeApp(dest, version)
+ const marker = join(app, 'Contents', 'Resources-only-in-the-old-copy')
+ writeFileSync(marker, 'old\n')
+ return { dest, app, marker }
+}
+
+const downloads = (): string[] => served.filter((p) => p.startsWith('/download/'))
+
+describe.skipIf(!onMac)('install.sh', () => {
+ it('installs the latest release into an empty folder', async () => {
+ await publish('0.12.0', { quarantined: true })
+ const dest = join(scratch(), 'Applications') // not there yet: the script creates it
+
+ const run = await install(dest)
+
+ expect(run.stderr).toBe('')
+ expect(run.code).toBe(0)
+ const app = join(dest, 'Cockpit.app')
+ expect(await plistValue(app, 'CFBundleShortVersionString')).toBe('0.12.0')
+ expect(await plistValue(app, 'CFBundleIdentifier')).toBe(IDENTIFIER)
+ // the executable bit survives the zip round trip — a bundle without it cannot launch
+ const exe = join(app, 'Contents', 'MacOS', 'Cockpit')
+ expect((await ok(exe, [])).trim()).toBe('0.12.0')
+ // the flag a browser leaves is exactly what the installer exists to avoid
+ const flag = await execText('/usr/bin/xattr', ['-p', 'com.apple.quarantine', exe])
+ expect(flag.ok).toBe(false)
+ // nothing left beside it
+ expect(readdirSync(dest)).toEqual(['Cockpit.app'])
+ expect(run.stdout).toContain('Installed Cockpit 0.12.0')
+ expect(run.stdout).toContain(`open '${app}'`)
+ expect(run.stdout).toContain('gh attestation verify Cockpit-0.12.0-')
+ // one lookup, one download, and never the updater's manifest: its download count
+ // stands for installed copies checking for updates
+ expect(served).toHaveLength(2)
+ expect(served[0]).toBe(LATEST)
+ expect(served.some((p) => p.includes('latest-mac.yml'))).toBe(false)
+ })
+
+ it('replaces an installed copy on upgrade', async () => {
+ await publish('0.12.0')
+ const { dest, app, marker } = installed('0.11.0')
+
+ const run = await install(dest)
+
+ expect(run.code).toBe(0)
+ expect(await plistValue(app, 'CFBundleShortVersionString')).toBe('0.12.0')
+ // replaced, not merged: nothing of the old bundle survives inside the new one
+ expect(existsSync(marker)).toBe(false)
+ // and the old copy is gone rather than parked beside it
+ expect(readdirSync(dest)).toEqual(['Cockpit.app'])
+ expect(run.stdout).toContain('Updated Cockpit from 0.11.0 to 0.12.0')
+ })
+
+ it('refuses a download that does not match the digest and leaves the install alone', async () => {
+ await publish('0.12.0')
+ release = { ...release, digest: `sha256:${'a'.repeat(64)}` }
+ const { dest, app, marker } = installed('0.11.0')
+
+ const run = await install(dest)
+
+ expect(run.code).not.toBe(0)
+ expect(run.stderr).toContain('does not match the SHA-256 digest')
+ expect(run.stderr).toContain('Nothing was installed')
+ expect(await plistValue(app, 'CFBundleShortVersionString')).toBe('0.11.0')
+ expect(existsSync(marker)).toBe(true)
+ expect(readdirSync(dest)).toEqual(['Cockpit.app'])
+ })
+
+ it('refuses a release that lists no digest, before downloading anything', async () => {
+ await publish('0.12.0')
+ release = { ...release, digest: null }
+ const { dest, app } = installed('0.11.0')
+
+ const run = await install(dest)
+
+ expect(run.code).not.toBe(0)
+ expect(run.stderr).toContain('no SHA-256 digest')
+ expect(downloads()).toEqual([])
+ expect(await plistValue(app, 'CFBundleShortVersionString')).toBe('0.11.0')
+ })
+
+ it('refuses a bundle that is not Cockpit', async () => {
+ await publish('0.12.0', { identifier: 'com.example.other' })
+ const { dest, app, marker } = installed('0.11.0')
+
+ const run = await install(dest)
+
+ expect(run.code).not.toBe(0)
+ expect(run.stderr).toContain("bundle id 'com.example.other'")
+ expect(await plistValue(app, 'CFBundleIdentifier')).toBe(IDENTIFIER)
+ expect(await plistValue(app, 'CFBundleShortVersionString')).toBe('0.11.0')
+ expect(existsSync(marker)).toBe(true)
+ expect(readdirSync(dest)).toEqual(['Cockpit.app'])
+ })
+
+ it('refuses a bundle whose version is not the release', async () => {
+ const zip = await releaseZip('0.11.9')
+ release = { tag: 'v0.12.0', zip, digest: `sha256:${sha256(zip)}` }
+ const dest = join(scratch(), 'Applications')
+
+ const run = await install(dest)
+
+ expect(run.code).not.toBe(0)
+ expect(run.stderr).toContain("version '0.11.9', not the 0.12.0")
+ expect(existsSync(join(dest, 'Cockpit.app'))).toBe(false)
+ })
+
+ it('never replaces another app that happens to be called Cockpit.app', async () => {
+ await publish('0.12.0')
+ const dest = join(scratch(), 'Applications')
+ mkdirSync(dest)
+ const other = makeApp(dest, '3.0', { identifier: 'com.example.cockpit' })
+
+ const run = await install(dest)
+
+ expect(run.code).not.toBe(0)
+ expect(run.stderr).toContain('is not Cockpit')
+ expect(await plistValue(other, 'CFBundleIdentifier')).toBe('com.example.cockpit')
+ expect(downloads()).toEqual([])
+ })
+
+ it('stops while the installed copy is running', async () => {
+ await publish('0.12.0')
+ const { dest, app } = installed('0.11.0')
+ // a real process whose executable is the installed bundle's, as ps lists Cockpit's
+ const exe = join(app, 'Contents', 'MacOS', 'Cockpit')
+ copyFileSync('/bin/sleep', exe)
+ chmodSync(exe, 0o755)
+ const running: ChildProcess = spawn(exe, ['30'], { stdio: 'ignore' })
+ try {
+ await once(running, 'spawn')
+ const run = await install(dest)
+
+ expect(run.code).not.toBe(0)
+ expect(run.stderr).toContain('Cockpit is running')
+ expect(downloads()).toEqual([])
+ expect(await plistValue(app, 'CFBundleShortVersionString')).toBe('0.11.0')
+ } finally {
+ running.kill()
+ }
+ })
+
+ it('resolves everything on a dry run and downloads nothing', async () => {
+ await publish('0.12.0')
+ const dest = join(scratch(), 'Applications')
+
+ const run = await install(dest, ['--dry-run'], { COCKPIT_INSTALL_ARCH: 'x64' })
+
+ expect(run.code).toBe(0)
+ expect(run.stdout).toContain('Cockpit 0.12.0 for Intel')
+ expect(run.stdout).toContain('Cockpit-0.12.0-x64.zip')
+ expect(run.stdout).toContain(`${base}/download/v0.12.0/Cockpit-0.12.0-x64.zip`)
+ expect(run.stdout).toContain(sha256(release.zip))
+ expect(run.stdout).toContain(`${join(dest, 'Cockpit.app')} (folder will be created)`)
+ expect(served).toEqual([LATEST])
+ expect(existsSync(dest)).toBe(false)
+ })
+
+ it('takes another architecture only for a dry run', async () => {
+ await publish('0.12.0')
+ const run = await install(join(scratch(), 'Applications'), [], { COCKPIT_INSTALL_ARCH: 'x64' })
+
+ expect(run.code).not.toBe(0)
+ expect(run.stderr).toContain('only applies with --dry-run')
+ expect(served).toEqual([])
+ })
+})
+
+describe.skipIf(onMac)('install.sh off macOS', () => {
+ it('refuses politely without touching the network', async () => {
+ release = { tag: 'v0.12.0', zip: Buffer.alloc(0), digest: null }
+ const run = await install(join(scratch(), 'Applications'))
+
+ expect(run.code).toBe(1)
+ expect(run.stderr).toContain('Cockpit is a macOS app')
+ expect(served).toEqual([])
+ })
+})
From 33ba8ac4120a5806759020e5d39e174722c9a041 Mon Sep 17 00:00:00 2001
From: titan-ron <30556071+titan-ron@users.noreply.github.com>
Date: Thu, 24 Sep 2026 18:35:35 +0300
Subject: [PATCH 3/4] build(scripts): add npm run stats and npm run ui:readme
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
stats reports adoption from what GitHub already records — outside feedback, traffic, and release downloads read by meaning (disk images, update zips, update checks) — and keeps a local history past GitHub's 14-day traffic window. No telemetry.
ui:readme records the README's animated hero, its stills and the repository's social card from the ui-tour fixture world, so no real session, repo or account appears in them.
---
package.json | 2 +
scripts/stats-core.mts | 914 +++++++++++++++++++++++++++++++
scripts/stats.mts | 266 +++++++++
scripts/ui-tour/readme-media.mts | 305 +++++++++++
tests/stats-core.test.ts | 427 +++++++++++++++
5 files changed, 1914 insertions(+)
create mode 100644 scripts/stats-core.mts
create mode 100644 scripts/stats.mts
create mode 100644 scripts/ui-tour/readme-media.mts
create mode 100644 tests/stats-core.test.ts
diff --git a/package.json b/package.json
index 48df9f4a..474424ab 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,8 @@
"test:e2e": "playwright test",
"test:packaged": "node scripts/test-packaged.mts",
"ui:tour": "npm run build && node scripts/ui-tour/tour.mts",
+ "ui:readme": "npm run build && node scripts/ui-tour/readme-media.mts",
+ "stats": "node scripts/stats.mts",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs",
diff --git a/scripts/stats-core.mts b/scripts/stats-core.mts
new file mode 100644
index 00000000..503e3298
--- /dev/null
+++ b/scripts/stats-core.mts
@@ -0,0 +1,914 @@
+/**
+ * IO-free half of scripts/stats.mts (`npm run stats`): how many people use Cockpit and how
+ * many of them talk back, from nothing but what GitHub already records — there is no
+ * telemetry and there must be none. The script makes the read-only `gh api` calls and
+ * owns the history file; this parses what came back (defensively: it is remote input),
+ * merges it into the history, derives the estimates and formats the report.
+ * tests/stats-core.test.ts targets it.
+ *
+ * GitHub keeps traffic for 14 days and asset downloads only as a lifetime count per
+ * file, which is why a local history exists at all: traffic is merged day by day, and one
+ * snapshot per UTC day of every release's counts lets later runs take exact deltas.
+ */
+
+export const REPO = 'tashtit/cockpit'
+
+const HOUR_MS = 3_600_000
+const DAY_MS = 24 * HOUR_MS
+/** the span the headline numbers cover, and the age a history baseline aims for */
+const RECENT_DAYS = 7
+/** feedback counted as recent */
+const FEEDBACK_RECENT_DAYS = 30
+
+/**
+ * The updater's schedule, as src/main/updates.ts sets it: one check FIRST_CHECK_DELAY_MS
+ * after launch, then one every CHECK_INTERVAL_MS while the app runs. Each check fetches
+ * `latest-mac.yml` off the latest release. tests/stats-core.test.ts reads updates.ts and
+ * fails when these drift from it.
+ */
+export const UPDATE_CHECK = { firstDelaySeconds: 20, intervalHours: 4 } as const
+/** a copy used in one working session a day */
+const SESSION_HOURS = 8
+
+/**
+ * Checks one installed copy makes per day: a copy never quit checks every interval; one
+ * launched for a single session checks at launch and at each interval that session outlives.
+ */
+export const CHECKS_PER_DAY = {
+ neverQuit: 24 / UPDATE_CHECK.intervalHours,
+ oneSession:
+ 1 + Math.floor((SESSION_HOURS * 3600 - UPDATE_CHECK.firstDelaySeconds) / (UPDATE_CHECK.intervalHours * 3600))
+} as const
+
+/* ---------- remote shapes ---------- */
+
+type Json = Readonly>
+
+function obj(v: unknown): Json | null {
+ return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Json) : null
+}
+
+function arr(v: unknown): readonly unknown[] {
+ return Array.isArray(v) ? v : []
+}
+
+function count(v: unknown): number {
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0
+}
+
+function str(v: unknown): string | null {
+ return typeof v === 'string' && v !== '' ? v : null
+}
+
+function isDate(v: string): boolean {
+ return /^\d{4}-\d{2}-\d{2}$/.test(v)
+}
+
+function isTime(v: string | null): v is string {
+ return v !== null && Number.isFinite(Date.parse(v))
+}
+
+/** One day of GitHub traffic. */
+export type DayCount = { readonly date: string; readonly count: number; readonly uniques: number }
+
+/** A 14-day traffic series as GitHub returns it: its own totals plus a row per day. */
+export type TrafficSeries = {
+ readonly count: number
+ readonly uniques: number
+ readonly days: readonly DayCount[]
+}
+
+/** A referrer or a path, over GitHub's 14-day window. */
+export type Popular = { readonly name: string; readonly count: number; readonly uniques: number }
+
+export type Traffic =
+ | {
+ readonly ok: true
+ readonly views: TrafficSeries
+ readonly clones: TrafficSeries
+ readonly referrers: readonly Popular[]
+ readonly paths: readonly Popular[]
+ }
+ | { readonly ok: false; readonly reason: string }
+
+/** `repos/{repo}/traffic/views` or `/clones`; `key` is the array field it carries. */
+export function parseTrafficSeries(raw: unknown, key: 'views' | 'clones'): TrafficSeries {
+ const o = obj(raw)
+ const days = arr(o?.[key]).flatMap((row): DayCount[] => {
+ const r = obj(row)
+ const date = str(r?.['timestamp'])?.slice(0, 10) ?? ''
+ return isDate(date) ? [{ date, count: count(r?.['count']), uniques: count(r?.['uniques']) }] : []
+ })
+ return { count: count(o?.['count']), uniques: count(o?.['uniques']), days }
+}
+
+/** `traffic/popular/referrers` (`referrer`) or `traffic/popular/paths` (`path`). */
+export function parsePopular(raw: unknown, key: 'referrer' | 'path'): readonly Popular[] {
+ return arr(raw).flatMap((row): Popular[] => {
+ const r = obj(row)
+ const name = str(r?.[key])
+ return name ? [{ name, count: count(r?.['count']), uniques: count(r?.['uniques']) }] : []
+ })
+}
+
+/**
+ * Why a `gh api` call failed, from its stderr — `gh: Must have push access to repository
+ * (HTTP 403)` — so the report can say it instead of stopping.
+ */
+export function ghFailure(stderr: string): { readonly status: number | null; readonly message: string } {
+ const line = stderr.split('\n').map((l) => l.trim()).find((l) => l !== '') ?? 'gh api failed'
+ const status = /\(HTTP (\d{3})\)/.exec(stderr)
+ return { status: status ? Number(status[1]) : null, message: line.replace(/^gh: /, '') }
+}
+
+export type RepoCounters = { readonly stars: number; readonly forks: number; readonly watchers: number }
+
+/** `repos/{repo}`. `watchers_count` is a legacy alias of stars; people watching are `subscribers_count`. */
+export function parseRepo(raw: unknown): RepoCounters {
+ const o = obj(raw)
+ return { stars: count(o?.['stargazers_count']), forks: count(o?.['forks_count']), watchers: count(o?.['subscribers_count']) }
+}
+
+/**
+ * One published release and its lifetime downloads by what they mean. The feed file is
+ * fetched by every installed copy's update check; the zip is what the app downloads to
+ * update itself (update-install-core.ts `pickZip`); the disk image is the README's install
+ * path. `other` is everything else — blockmaps, which nothing in the app fetches.
+ */
+export type Release = {
+ readonly tag: string
+ readonly publishedAt: string
+ readonly prerelease: boolean
+ readonly dmg: number
+ readonly zip: number
+ readonly feed: number
+ readonly other: number
+}
+
+/** The update feed electron-updater reads off the latest release (src/main/updates.ts). */
+export const FEED_ASSET = 'latest-mac.yml'
+
+/** `repos/{repo}/releases`, every page flattened. Drafts are not public and are left out. */
+export function parseReleases(rows: readonly unknown[]): readonly Release[] {
+ return rows
+ .flatMap((row): Release[] => {
+ const r = obj(row)
+ const tag = str(r?.['tag_name'])
+ const publishedAt = str(r?.['published_at'])
+ if (!tag || !isTime(publishedAt) || r?.['draft'] === true) return []
+ const totals = { dmg: 0, zip: 0, feed: 0, other: 0 }
+ for (const asset of arr(r?.['assets'])) {
+ const a = obj(asset)
+ const name = str(a?.['name']) ?? ''
+ const n = count(a?.['download_count'])
+ if (name === FEED_ASSET) totals.feed += n
+ else if (name.endsWith('.dmg')) totals.dmg += n
+ else if (name.endsWith('.zip')) totals.zip += n
+ else totals.other += n
+ }
+ return [{ tag, publishedAt, prerelease: r?.['prerelease'] === true, ...totals }]
+ })
+ .sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt))
+}
+
+/* ---------- feedback ---------- */
+
+export type FeedbackKind = 'issue' | 'issue-comment' | 'discussion' | 'discussion-comment' | 'pr' | 'pr-comment'
+
+/** The kinds the headline counts; pull requests are listed beside them, not in them. */
+const FEEDBACK_KINDS: readonly FeedbackKind[] = ['issue', 'issue-comment', 'discussion', 'discussion-comment']
+
+/** Anything someone wrote on the repo. */
+export type Contribution = {
+ readonly kind: FeedbackKind
+ readonly author: string
+ readonly bot: boolean
+ readonly createdAt: string
+ readonly url: string
+ readonly title: string | null
+}
+
+function restAuthor(user: Json | null): { readonly author: string; readonly bot: boolean } | null {
+ const login = str(user?.['login'])
+ if (!login) return null
+ return { author: login, bot: user?.['type'] === 'Bot' || login.endsWith('[bot]') }
+}
+
+/** `repos/{repo}/issues?state=all` — issues and pull requests in one list, told apart by `pull_request`. */
+export function parseIssues(rows: readonly unknown[]): readonly Contribution[] {
+ return rows.flatMap((row): Contribution[] => {
+ const r = obj(row)
+ const who = restAuthor(obj(r?.['user']))
+ const createdAt = str(r?.['created_at'])
+ if (!who || !isTime(createdAt)) return []
+ const kind = r?.['pull_request'] ? 'pr' : 'issue'
+ return [{ kind, ...who, createdAt, url: str(r?.['html_url']) ?? '', title: str(r?.['title']) }]
+ })
+}
+
+/** Numbers of the pull requests in a `parseIssues` result, to tell PR comments from issue comments. */
+export function pullNumbers(issues: readonly Contribution[]): ReadonlySet {
+ return new Set(issues.filter((i) => i.kind === 'pr').map((i) => numberOf(i.url)).filter((n) => n > 0))
+}
+
+function numberOf(url: string): number {
+ const m = /\/(?:issues|pull)\/(\d+)/.exec(url)
+ return m ? Number(m[1]) : 0
+}
+
+/** `repos/{repo}/issues/comments` — comments on issues and on pull requests alike. */
+export function parseIssueComments(rows: readonly unknown[], pulls: ReadonlySet): readonly Contribution[] {
+ return rows.flatMap((row): Contribution[] => {
+ const r = obj(row)
+ const who = restAuthor(obj(r?.['user']))
+ const createdAt = str(r?.['created_at'])
+ if (!who || !isTime(createdAt)) return []
+ const url = str(r?.['html_url']) ?? ''
+ const onPull = url.includes('/pull/') || pulls.has(numberOf(str(r?.['issue_url']) ?? ''))
+ return [{ kind: onPull ? 'pr-comment' : 'issue-comment', ...who, createdAt, url, title: null }]
+ })
+}
+
+/** One page of the discussions GraphQL query in scripts/stats.mts. */
+export type DiscussionPage = {
+ readonly items: readonly Contribution[]
+ /** a discussion held more comments or replies than one query returns */
+ readonly truncated: boolean
+ readonly next: string | null
+}
+
+function graphContribution(node: Json | null, kind: FeedbackKind): Contribution | null {
+ const author = obj(node?.['author'])
+ // a deleted account reads as null; GitHub shows it as "ghost"
+ const login = str(author?.['login']) ?? 'ghost'
+ const createdAt = str(node?.['createdAt'])
+ if (!isTime(createdAt)) return null
+ const bot = author?.['__typename'] === 'Bot' || login.endsWith('[bot]')
+ return { kind, author: login, bot, createdAt, url: str(node?.['url']) ?? '', title: str(node?.['title']) }
+}
+
+export function parseDiscussionPage(raw: unknown): DiscussionPage {
+ const discussions = obj(obj(obj(obj(raw)?.['data'])?.['repository'])?.['discussions'])
+ const items: Contribution[] = []
+ let truncated = false
+ const connection = (v: unknown): readonly Json[] => {
+ const c = obj(v)
+ const nodes = arr(c?.['nodes']).flatMap((n) => (obj(n) ? [obj(n)!] : []))
+ if (count(c?.['totalCount']) > nodes.length) truncated = true
+ return nodes
+ }
+ for (const d of arr(discussions?.['nodes'])) {
+ const node = obj(d)
+ const item = graphContribution(node, 'discussion')
+ if (item) items.push(item)
+ for (const c of connection(node?.['comments'])) {
+ const comment = graphContribution(c, 'discussion-comment')
+ if (comment) items.push(comment)
+ for (const reply of connection(c['replies'])) {
+ const r = graphContribution(reply, 'discussion-comment')
+ if (r) items.push(r)
+ }
+ }
+ }
+ const page = obj(discussions?.['pageInfo'])
+ return { items, truncated, next: page?.['hasNextPage'] === true ? str(page['endCursor']) : null }
+}
+
+/** Collaborators who can push — the team. */
+export function maintainersFromCollaborators(rows: readonly unknown[]): readonly string[] {
+ return rows.flatMap((row) => {
+ const r = obj(row)
+ const login = str(r?.['login'])
+ return login && obj(r?.['permissions'])?.['push'] === true ? [login] : []
+ })
+}
+
+/** Every `@user` a CODEOWNERS file names. Teams (`@org/team`) cannot be expanded offline and are skipped. */
+export function codeownersHandles(text: string): readonly string[] {
+ const handles = text
+ .split('\n')
+ .map((line) => line.replace(/#.*/, '').trim())
+ .flatMap((line) => line.split(/\s+/).slice(1))
+ .filter((h) => /^@[\w-]+$/.test(h))
+ .map((h) => h.slice(1))
+ return [...new Set(handles)]
+}
+
+export type FeedbackSummary = {
+ readonly maintainers: readonly string[]
+ readonly maintainersFrom: 'collaborators' | 'CODEOWNERS'
+ readonly counts: Readonly>
+ /** issues, issue comments, discussions and discussion comments from outside the team */
+ readonly total: number
+ readonly recent: number
+ readonly recentDays: number
+ /** newest first, headline kinds only */
+ readonly newest: readonly Contribution[]
+ /** why discussions could not be read; null when they were */
+ readonly discussionsUnavailable: string | null
+ readonly truncated: boolean
+}
+
+export type FeedbackInput = {
+ readonly contributions: readonly Contribution[]
+ readonly maintainers: readonly string[]
+ readonly maintainersFrom: FeedbackSummary['maintainersFrom']
+ readonly discussionsUnavailable: string | null
+ readonly truncated: boolean
+}
+
+/** What people outside the team wrote: every author that is neither a maintainer nor a bot. */
+export function summarizeFeedback(input: FeedbackInput, now: number): FeedbackSummary {
+ const team = new Set(input.maintainers.map((m) => m.toLowerCase()))
+ const outside = input.contributions.filter((c) => !c.bot && !team.has(c.author.toLowerCase()))
+ const counts = { issue: 0, 'issue-comment': 0, discussion: 0, 'discussion-comment': 0, pr: 0, 'pr-comment': 0 }
+ for (const c of outside) counts[c.kind] += 1
+ const headline = outside
+ .filter((c) => FEEDBACK_KINDS.includes(c.kind))
+ .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
+ const since = now - FEEDBACK_RECENT_DAYS * DAY_MS
+ return {
+ maintainers: [...input.maintainers].sort((a, b) => a.localeCompare(b)),
+ maintainersFrom: input.maintainersFrom,
+ counts,
+ total: headline.length,
+ recent: headline.filter((c) => Date.parse(c.createdAt) >= since).length,
+ recentDays: FEEDBACK_RECENT_DAYS,
+ newest: headline.slice(0, 5),
+ discussionsUnavailable: input.discussionsUnavailable,
+ truncated: input.truncated
+ }
+}
+
+/* ---------- history ---------- */
+
+/** One day's counts; a same-day re-run replaces it. */
+export type Snapshot = {
+ readonly at: string
+ readonly stars: number
+ readonly forks: number
+ readonly watchers: number
+ /** tag → lifetime downloads at `at`: [disk image, zip, update feed] */
+ readonly releases: Readonly>
+ /** GitHub's 14-day referrers at `at` — kept because GitHub forgets them */
+ readonly referrers: readonly Popular[]
+}
+
+export type History = {
+ readonly version: 1
+ readonly repo: string
+ readonly views: readonly DayCount[]
+ readonly clones: readonly DayCount[]
+ readonly snapshots: readonly Snapshot[]
+}
+
+export function emptyHistory(): History {
+ return { version: 1, repo: REPO, views: [], clones: [], snapshots: [] }
+}
+
+function parseDays(v: unknown): readonly DayCount[] {
+ return arr(v).flatMap((row): DayCount[] => {
+ const r = obj(row)
+ const date = str(r?.['date'])
+ return date && isDate(date) ? [{ date, count: count(r?.['count']), uniques: count(r?.['uniques']) }] : []
+ })
+}
+
+function parseSnapshot(v: unknown): Snapshot | null {
+ const s = obj(v)
+ const at = str(s?.['at'])
+ if (!isTime(at)) return null
+ const releases: Record = {}
+ for (const [tag, counts] of Object.entries(obj(s?.['releases']) ?? {})) {
+ const c = arr(counts)
+ releases[tag] = [count(c[0]), count(c[1]), count(c[2])]
+ }
+ const referrers = arr(s?.['referrers']).flatMap((row): Popular[] => {
+ const r = obj(row)
+ const name = str(r?.['name'])
+ return name ? [{ name, count: count(r?.['count']), uniques: count(r?.['uniques']) }] : []
+ })
+ return { at, stars: count(s?.['stars']), forks: count(s?.['forks']), watchers: count(s?.['watchers']), releases, referrers }
+}
+
+/**
+ * A history file read back. Rows that do not parse are dropped, but a file this version
+ * cannot own — a newer format, another repo — throws, so it is never overwritten.
+ */
+export function parseHistory(raw: unknown): History {
+ const h = obj(raw)
+ if (!h) throw new Error('the history file is not a JSON object')
+ if (h['version'] !== 1) throw new Error(`the history file is version ${String(h['version'])}; this script writes version 1`)
+ if (h['repo'] !== REPO) throw new Error(`the history file is for ${String(h['repo'])}, not ${REPO}`)
+ return {
+ version: 1,
+ repo: REPO,
+ views: parseDays(h['views']),
+ clones: parseDays(h['clones']),
+ snapshots: arr(h['snapshots']).flatMap((s) => parseSnapshot(s) ?? [])
+ }
+}
+
+/**
+ * Two runs' days folded into one row per date, keeping the most seen for each field:
+ * today's row grows through the day, and a day that has left GitHub's window stays as
+ * it was last seen.
+ */
+export function mergeDays(a: readonly DayCount[], b: readonly DayCount[]): readonly DayCount[] {
+ const byDate = new Map()
+ for (const d of [...a, ...b]) {
+ const seen = byDate.get(d.date)
+ byDate.set(d.date, seen ? { date: d.date, count: Math.max(seen.count, d.count), uniques: Math.max(seen.uniques, d.uniques) } : d)
+ }
+ return [...byDate.values()].sort((x, y) => x.date.localeCompare(y.date))
+}
+
+export type SnapshotInput = {
+ readonly at: string
+ readonly repo: RepoCounters
+ readonly releases: readonly Release[]
+ readonly traffic: Traffic
+}
+
+export function takeSnapshot(input: SnapshotInput): Snapshot {
+ const releases: Record = {}
+ for (const r of input.releases) releases[r.tag] = [r.dmg, r.zip, r.feed]
+ return { at: input.at, ...input.repo, releases, referrers: input.traffic.ok ? input.traffic.referrers : [] }
+}
+
+/** The run folded in: traffic merged per day, the snapshot replacing any other from its UTC day. */
+export function mergeHistory(history: History, snapshot: Snapshot, traffic: Traffic): History {
+ const day = snapshot.at.slice(0, 10)
+ return {
+ ...history,
+ views: traffic.ok ? mergeDays(history.views, traffic.views.days) : history.views,
+ clones: traffic.ok ? mergeDays(history.clones, traffic.clones.days) : history.clones,
+ snapshots: [...history.snapshots.filter((s) => s.at.slice(0, 10) !== day), snapshot].sort(
+ (a, b) => Date.parse(a.at) - Date.parse(b.at)
+ )
+ }
+}
+
+/* ---------- estimates ---------- */
+
+/**
+ * The snapshot to take deltas from: at least a day old — anything younger measures the
+ * time of day more than use — and otherwise as close to a week old as the history has.
+ */
+export function baselineSnapshot(snapshots: readonly Snapshot[], now: number): Snapshot | null {
+ let best: Snapshot | null = null
+ for (const s of snapshots) {
+ const age = now - Date.parse(s.at)
+ if (age < DAY_MS) continue
+ if (!best || Math.abs(age - RECENT_DAYS * DAY_MS) < Math.abs(now - Date.parse(best.at) - RECENT_DAYS * DAY_MS)) best = s
+ }
+ return best
+}
+
+/**
+ * When a release was the latest one: from its publication until the next one's (or now).
+ * Only the latest release is offered to updaters (`releases/latest` → its feed file, then
+ * its zip) and linked from the README, so that is when its downloads happen.
+ */
+export type LatestWindow = {
+ readonly tag: string
+ readonly start: number
+ readonly end: number
+ readonly dmg: number
+ readonly zip: number
+ readonly feed: number
+}
+
+export function latestWindows(releases: readonly Release[], now: number): readonly LatestWindow[] {
+ const stable = releases
+ .filter((r) => !r.prerelease)
+ .map((r) => ({ r, start: Date.parse(r.publishedAt) }))
+ .sort((a, b) => a.start - b.start)
+ return stable.map(({ r, start }, i) => ({
+ tag: r.tag,
+ start,
+ end: Math.max(start, stable[i + 1]?.start ?? now),
+ dmg: r.dmg,
+ zip: r.zip,
+ feed: r.feed
+ }))
+}
+
+export type Downloads = { readonly dmg: number; readonly zip: number; readonly feed: number }
+
+/** Lifetime counts spread over [from, to) in proportion to how much of each window falls in it. */
+export function spread(windows: readonly LatestWindow[], range: { readonly from: number; readonly to: number }): Downloads {
+ let dmg = 0
+ let zip = 0
+ let feed = 0
+ for (const w of windows) {
+ const length = w.end - w.start
+ const share =
+ length > 0
+ ? Math.max(0, Math.min(w.end, range.to) - Math.max(w.start, range.from)) / length
+ : w.start >= range.from && w.start < range.to
+ ? 1
+ : 0
+ dmg += w.dmg * share
+ zip += w.zip * share
+ feed += w.feed * share
+ }
+ return { dmg, zip, feed }
+}
+
+/** Downloads over the recent past, and where the figure came from. */
+export type Recent = Downloads & {
+ readonly days: number
+ readonly since: string
+ readonly source: 'history' | 'release windows'
+}
+
+/**
+ * The last week or so: exact deltas against a history baseline when one exists, otherwise
+ * the releases' lifetime counts spread over when each was latest.
+ */
+export function recentDownloads(releases: readonly Release[], history: History, now: number): Recent | null {
+ if (releases.length === 0) return null
+ const base = baselineSnapshot(history.snapshots, now)
+ if (base) {
+ const d = { dmg: 0, zip: 0, feed: 0 }
+ for (const r of releases) {
+ const [dmg, zip, feed] = base.releases[r.tag] ?? [0, 0, 0]
+ d.dmg += Math.max(0, r.dmg - dmg)
+ d.zip += Math.max(0, r.zip - zip)
+ d.feed += Math.max(0, r.feed - feed)
+ }
+ return { ...d, days: (now - Date.parse(base.at)) / DAY_MS, since: base.at, source: 'history' }
+ }
+ const windows = latestWindows(releases, now)
+ const first = windows[0]
+ if (!first) return null
+ const from = Math.max(now - RECENT_DAYS * DAY_MS, first.start)
+ if (now - from < HOUR_MS) return null
+ return { ...spread(windows, { from, to: now }), days: (now - from) / DAY_MS, since: new Date(from).toISOString(), source: 'release windows' }
+}
+
+export type Range = { readonly low: number; readonly high: number }
+
+/** Installed copies behind a rate of update checks per day. */
+export function activeInstalls(checksPerDay: number): Range {
+ return { low: checksPerDay / CHECKS_PER_DAY.neverQuit, high: checksPerDay / CHECKS_PER_DAY.oneSession }
+}
+
+export type WeekTraffic = {
+ readonly count: number
+ /** the sum of daily uniques — one person on three days counts three times */
+ readonly uniques: number
+ /** the history holds only some of this week's days */
+ readonly partial: boolean
+}
+
+export type Week = {
+ /** Monday, UTC */
+ readonly start: string
+ readonly views: WeekTraffic | null
+ readonly clones: WeekTraffic | null
+ /** null for a week before the first release */
+ readonly downloads: Downloads | null
+ readonly active: Range | null
+}
+
+function monday(ms: number): number {
+ const d = new Date(ms)
+ const midnight = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())
+ return midnight - ((d.getUTCDay() + 6) % 7) * DAY_MS
+}
+
+function dateMs(date: string): number {
+ return Date.parse(`${date}T00:00:00Z`)
+}
+
+/**
+ * One week of a merged traffic series. It is partial when the series misses a day of the
+ * week up to the newest day it holds — GitHub has no row for today until the day ends,
+ * so the current week is judged only up to there.
+ */
+function weekTraffic(days: readonly DayCount[], start: number): WeekTraffic | null {
+ const end = start + 7 * DAY_MS
+ const inWeek = days.filter((d) => dateMs(d.date) >= start && dateMs(d.date) < end)
+ const last = days.at(-1)
+ if (inWeek.length === 0 || !last) return null
+ const expected = Math.round((Math.min(end - DAY_MS, dateMs(last.date)) - start) / DAY_MS) + 1
+ return {
+ count: inWeek.reduce((n, d) => n + d.count, 0),
+ uniques: inWeek.reduce((n, d) => n + d.uniques, 0),
+ partial: inWeek.length < expected
+ }
+}
+
+/** Calendar weeks (Monday, UTC) from the first release or traffic day up to now, newest last. */
+export function weekly(releases: readonly Release[], history: History, opts: { readonly now: number; readonly weeks: number }): readonly Week[] {
+ const { now } = opts
+ const windows = latestWindows(releases, now)
+ const firstRelease = windows[0]?.start ?? Infinity
+ const firstDay = [history.views[0]?.date, history.clones[0]?.date]
+ .flatMap((d) => (d ? [dateMs(d)] : []))
+ .reduce((a, b) => Math.min(a, b), Infinity)
+ const earliest = Math.min(firstRelease, firstDay)
+ if (!Number.isFinite(earliest)) return []
+ const out: Week[] = []
+ for (let start = monday(earliest); start <= now; start += 7 * DAY_MS) {
+ // the part of this week any release was out for
+ const from = Math.max(start, firstRelease)
+ const to = Math.min(start + 7 * DAY_MS, now)
+ const downloads = from < to ? spread(windows, { from, to }) : null
+ const hours = (to - from) / HOUR_MS
+ out.push({
+ start: new Date(start).toISOString().slice(0, 10),
+ views: weekTraffic(history.views, start),
+ clones: weekTraffic(history.clones, start),
+ downloads,
+ active: downloads && hours >= 1 ? activeInstalls((downloads.feed / hours) * 24) : null
+ })
+ }
+ return out.slice(-opts.weeks)
+}
+
+/* ---------- the report ---------- */
+
+export type Report = {
+ readonly repo: string
+ readonly generatedAt: string
+ readonly history: {
+ readonly path: string
+ readonly snapshots: number
+ readonly since: string | null
+ readonly trafficFrom: string | null
+ readonly trafficTo: string | null
+ }
+ readonly counters: RepoCounters
+ /** the counters at the history baseline, when there is one */
+ readonly countersThen: (RepoCounters & { readonly at: string }) | null
+ readonly feedback: FeedbackSummary
+ readonly usage: {
+ readonly recent: Recent | null
+ readonly active: Range | null
+ readonly totals: Downloads & { readonly other: number }
+ readonly latest: Release | null
+ }
+ readonly weekly: readonly Week[]
+ readonly traffic: Traffic
+ readonly releases: readonly Release[]
+}
+
+export type ReportInput = {
+ readonly now: number
+ readonly historyPath: string
+ /** already merged with this run */
+ readonly history: History
+ readonly repo: RepoCounters
+ readonly releases: readonly Release[]
+ readonly traffic: Traffic
+ readonly feedback: FeedbackSummary
+}
+
+export function buildReport(input: ReportInput): Report {
+ const { now, history, releases } = input
+ const base = baselineSnapshot(history.snapshots, now)
+ const recent = recentDownloads(releases, history, now)
+ const totals = releases.reduce(
+ (t, r) => ({ dmg: t.dmg + r.dmg, zip: t.zip + r.zip, feed: t.feed + r.feed, other: t.other + r.other }),
+ { dmg: 0, zip: 0, feed: 0, other: 0 }
+ )
+ const days = [...history.views, ...history.clones].map((d) => d.date).sort()
+ return {
+ repo: REPO,
+ generatedAt: new Date(now).toISOString(),
+ history: {
+ path: input.historyPath,
+ snapshots: history.snapshots.length,
+ since: history.snapshots[0]?.at ?? null,
+ trafficFrom: days[0] ?? null,
+ trafficTo: days.at(-1) ?? null
+ },
+ counters: input.repo,
+ countersThen: base ? { at: base.at, stars: base.stars, forks: base.forks, watchers: base.watchers } : null,
+ feedback: input.feedback,
+ usage: {
+ recent,
+ active: recent && recent.days > 0 ? activeInstalls(recent.feed / recent.days) : null,
+ totals,
+ latest: releases.find((r) => !r.prerelease) ?? null
+ },
+ weekly: weekly(releases, history, { now, weeks: 8 }),
+ traffic: input.traffic,
+ releases
+ }
+}
+
+/* ---------- formatting ---------- */
+
+/** Columns padded to their widest cell; `align` is one of l/r per column. */
+export function table(rows: readonly (readonly string[])[], align: string): readonly string[] {
+ const widths: number[] = []
+ for (const row of rows) row.forEach((cell, i) => (widths[i] = Math.max(widths[i] ?? 0, cell.length)))
+ return rows.map((row) =>
+ row
+ .map((cell, i) => (align[i] === 'r' ? cell.padStart(widths[i]!) : cell.padEnd(widths[i]!)))
+ .join(' ')
+ .trimEnd()
+ )
+}
+
+/** A rate or an estimate: one decimal below 10, whole from there. */
+export function approx(n: number): string {
+ if (n === 0) return '0'
+ if (n >= 10) return String(Math.round(n))
+ return n.toFixed(1).replace(/\.0$/, '')
+}
+
+/** A download count, which spreading over windows can leave fractional. */
+function whole(n: number | undefined): string {
+ return n === undefined ? '-' : String(Math.round(n))
+}
+
+function range(r: Range | null): string {
+ return r ? `${approx(r.low)}–${approx(r.high)}` : '-'
+}
+
+function day(iso: string): string {
+ return iso.slice(0, 10)
+}
+
+function minute(iso: string): string {
+ return `${iso.slice(0, 10)} ${iso.slice(11, 16)}`
+}
+
+function duration(ms: number): string {
+ if (ms < HOUR_MS) return `${Math.round(ms / 60_000)}m`
+ if (ms < 2 * DAY_MS) return `${approx(ms / HOUR_MS)}h`
+ return `${approx(ms / DAY_MS)}d`
+}
+
+function signed(n: number): string {
+ return n >= 0 ? `+${n}` : String(n)
+}
+
+function trafficCell(t: WeekTraffic | null, pick: 'count' | 'uniques'): string {
+ return t ? `${t[pick]}${t.partial ? '*' : ''}` : '-'
+}
+
+const KIND_LABEL: Readonly> = {
+ issue: 'issue',
+ 'issue-comment': 'comment',
+ discussion: 'discussion',
+ 'discussion-comment': 'reply',
+ pr: 'PR',
+ 'pr-comment': 'PR comment'
+}
+
+function feedbackLines(f: FeedbackSummary): readonly string[] {
+ const c = f.counts
+ const from = f.maintainersFrom === 'collaborators' ? 'repo collaborators with push' : 'CODEOWNERS (collaborators refused)'
+ const lines = [
+ 'Feedback from outside the team',
+ ` ${f.total} in all, ${f.recent} in the last ${f.recentDays} days: issues ${c.issue} · issue comments ${c['issue-comment']} · ` +
+ `discussions ${c.discussion} · discussion comments ${c['discussion-comment']}`,
+ ` not counted above: ${c.pr} pull requests and ${c['pr-comment']} PR comments from outside`,
+ ` team (${from}): ${f.maintainers.join(', ') || 'none found'}; bots excluded`
+ ]
+ if (f.discussionsUnavailable) lines.push(` discussions could not be read: ${f.discussionsUnavailable}`)
+ if (f.truncated) lines.push(' some discussion threads hold more comments than one query returns; those are undercounted')
+ if (f.newest.length > 0) {
+ lines.push(' newest:')
+ lines.push(
+ ...table(
+ f.newest.map((n) => [day(n.createdAt), KIND_LABEL[n.kind], `@${n.author}`, n.title ?? '', n.url]),
+ 'lllll'
+ ).map((l) => ` ${l}`)
+ )
+ }
+ return lines
+}
+
+function usageLines(r: Report): readonly string[] {
+ const { recent, active, totals, latest } = r.usage
+ const over = recent
+ ? `the last ${approx(recent.days)} days (${recent.source === 'history' ? `history since ${day(recent.since)}` : 'release windows'})`
+ : ''
+ const rows = [
+ [
+ 'active installs',
+ active ? `≈ ${range(active)}` : '-',
+ recent ? `${approx(recent.feed / recent.days)} update checks a day over ${over}` : 'no release data yet'
+ ],
+ ['new installs', whole(recent?.dmg), `disk-image downloads over the same span · ${totals.dmg} all time`],
+ [
+ 'updates',
+ whole(recent?.zip),
+ `zip downloads over the same span · ${totals.zip} all time${latest ? ` · ${latest.zip} to ${latest.tag} so far` : ''}`
+ ]
+ ]
+ return ['Usage (estimates; see how they are made below)', ...table(rows, 'lrl').map((l) => ` ${l}`)]
+}
+
+function counterLines(r: Report): readonly string[] {
+ const c = r.counters
+ const t = r.countersThen
+ const change = t
+ ? ` since ${day(t.at)}: ${signed(c.stars - t.stars)} · ${signed(c.forks - t.forks)} · ${signed(c.watchers - t.watchers)}`
+ : ''
+ return ['Repo', ` stars ${c.stars} · forks ${c.forks} · watchers ${c.watchers}${change}`]
+}
+
+function weeklyLines(r: Report): readonly string[] {
+ if (r.weekly.length === 0) return []
+ const header = ['week of', 'views', 'visitor-days', 'clones', 'new (dmg)', 'updates (zip)', 'checks', '≈ active']
+ const rows = r.weekly.map((w) => [
+ w.start,
+ trafficCell(w.views, 'count'),
+ trafficCell(w.views, 'uniques'),
+ trafficCell(w.clones, 'count'),
+ whole(w.downloads?.dmg),
+ whole(w.downloads?.zip),
+ whole(w.downloads?.feed),
+ range(w.active)
+ ])
+ const partial = r.weekly.some((w) => w.views?.partial || w.clones?.partial)
+ const missing = rows.some((row) => row.includes('-'))
+ return [
+ 'Weekly (weeks start Monday UTC; the last one is so far)',
+ ...table([header, ...rows], 'lrrrrrrr').map((l) => ` ${l}`),
+ ...(partial ? [' * the history holds only part of that week'] : []),
+ ...(missing ? [' - nothing to count: traffic from before the history began, or no release out yet'] : [])
+ ]
+}
+
+function trafficLines(t: Traffic): readonly string[] {
+ if (!t.ok) return ['Traffic, last 14 days', ` unavailable: ${t.reason}`]
+ const list = (items: readonly Popular[], strip: string): string =>
+ items.slice(0, 5).map((p) => `${p.name.replace(strip, '') || '/'} ${p.count} (${p.uniques})`).join(' · ') || 'none'
+ return [
+ "Traffic, last 14 days (GitHub's own window; count (unique))",
+ ` views ${t.views.count} (${t.views.uniques}) · clones ${t.clones.count} (${t.clones.uniques}), mostly CI checkouts`,
+ ` referrers ${list(t.referrers, '')}`,
+ ` top paths ${list(t.paths, `/${REPO}`)}`
+ ]
+}
+
+function releaseLines(r: Report, now: number): readonly string[] {
+ const shown = r.releases.slice(0, 8)
+ if (shown.length === 0) return ['Releases', ' none published']
+ const windows = new Map(latestWindows(r.releases, now).map((w) => [w.tag, w]))
+ const rows = shown.map((rel) => {
+ const w = windows.get(rel.tag)
+ const latestFor = w ? `${duration(w.end - w.start)}${w.end === now ? ' (now)' : ''}` : 'prerelease'
+ return [rel.tag, minute(rel.publishedAt), latestFor, String(rel.dmg), String(rel.zip), String(rel.feed)]
+ })
+ const t = r.usage.totals
+ const lines = [
+ `Releases (newest ${shown.length} of ${r.releases.length}; lifetime downloads)`,
+ ...table(
+ [
+ ['release', 'published', 'latest for', 'dmg', 'zip', 'checks'],
+ ...rows,
+ [`all ${r.releases.length}`, '', '', String(t.dmg), String(t.zip), String(t.feed)]
+ ],
+ 'lllrrr'
+ ).map((l) => ` ${l}`),
+ ' a release\'s zip count = installed copies that updated to it'
+ ]
+ if (t.other > 0) {
+ lines.push(` plus ${t.other} blockmap downloads: nothing in the app fetches those, so some client other than`)
+ lines.push(' Cockpit or a person (a mirror, a scanner) downloads assets too, and may inflate the counts above')
+ }
+ return lines
+}
+
+const ASSUMPTIONS: readonly string[] = [
+ 'How the estimates are made',
+ ` active installs an installed copy fetches ${FEED_ASSET} ${UPDATE_CHECK.firstDelaySeconds}s after launch and every ` +
+ `${UPDATE_CHECK.intervalHours}h while it runs`,
+ ` (src/main/updates.ts): ${CHECKS_PER_DAY.neverQuit} checks a day if never quit, ${CHECKS_PER_DAY.oneSession} ` +
+ `for one ${SESSION_HOURS}h session, so`,
+ ` installs ≈ checks a day ÷ ${CHECKS_PER_DAY.neverQuit} … ÷ ${CHECKS_PER_DAY.oneSession}; the team's own copies count too`,
+ ' new installs one disk-image download = one fresh install (the README\'s install path); re-downloads count too',
+ ' updates one zip download = one installed copy updating to that release; once the curl installer',
+ ' (scripts/install.sh) ships, its fresh installs download the zip as well',
+ ' weekly GitHub keeps one lifetime count per asset, so each release\'s counts are spread over the time',
+ ' it was the latest release — the only one updaters check and the README links',
+ ' clones mostly CI checkouts: every workflow run clones the repo'
+]
+
+export function formatReport(r: Report): string {
+ const now = Date.parse(r.generatedAt)
+ const h = r.history
+ const kept = h.trafficFrom && h.trafficTo ? ` · traffic ${h.trafficFrom} → ${h.trafficTo}` : ''
+ const snapshots = `${h.snapshots} daily snapshot${h.snapshots === 1 ? '' : 's'}${h.since ? ` since ${day(h.since)}` : ''}`
+ const sections: readonly (readonly string[])[] = [
+ [`Cockpit adoption · ${r.repo} · ${minute(r.generatedAt)} UTC`, `history ${h.path} · ${snapshots}${kept}`],
+ feedbackLines(r.feedback),
+ usageLines(r),
+ counterLines(r),
+ weeklyLines(r),
+ trafficLines(r.traffic),
+ releaseLines(r, now),
+ ASSUMPTIONS
+ ]
+ return sections.filter((s) => s.length > 0).map((s) => s.join('\n')).join('\n\n') + '\n'
+}
diff --git a/scripts/stats.mts b/scripts/stats.mts
new file mode 100644
index 00000000..bf14ea3a
--- /dev/null
+++ b/scripts/stats.mts
@@ -0,0 +1,266 @@
+/**
+ * `npm run stats` — how many people use Cockpit and how many talk back, from what GitHub
+ * already records (there is no telemetry, and there must be none). Every call is a
+ * read-only `gh api` GET, plus one GraphQL query for discussions; nothing is ever written
+ * to GitHub. Needs a `gh` signed in to github.com; traffic needs push access and is
+ * reported as unavailable without it.
+ *
+ * GitHub forgets traffic after 14 days, so each run folds what it read into a local
+ * history — traffic merged per day, one snapshot of the counters per UTC day — and later
+ * runs take their trends from it. Re-running is safe: the same day merges, never repeats.
+ *
+ * npm run stats # the report
+ * npm run stats -- --json # the same, machine-readable
+ * npm run stats -- --history # default ~/.local/share/cockpit-stats/history.json
+ *
+ * Parsing, merging, the estimates and the report are in stats-core.mts.
+ */
+import { execFile } from 'node:child_process'
+import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { dirname, join, resolve } from 'node:path'
+import { parseArgs } from 'node:util'
+import {
+ buildReport,
+ codeownersHandles,
+ emptyHistory,
+ formatReport,
+ ghFailure,
+ maintainersFromCollaborators,
+ mergeHistory,
+ parseDiscussionPage,
+ parseHistory,
+ parseIssueComments,
+ parseIssues,
+ parsePopular,
+ parseReleases,
+ parseRepo,
+ parseTrafficSeries,
+ pullNumbers,
+ REPO,
+ summarizeFeedback,
+ takeSnapshot,
+ type Contribution,
+ type FeedbackSummary,
+ type History,
+ type Traffic
+} from './stats-core.mts'
+
+const PER_PAGE = 100
+/** a ceiling on any one listing, so a runaway pagination cannot loop forever */
+const MAX_PAGES = 50
+
+type Got = { readonly ok: true; readonly data: unknown } | { readonly ok: false; readonly status: number | null; readonly message: string }
+
+function gh(args: readonly string[]): Promise {
+ return new Promise((done) => {
+ execFile(
+ 'gh',
+ ['api', '--hostname', 'github.com', ...args],
+ { maxBuffer: 256 * 1024 * 1024, env: { ...process.env, GH_PROMPT_DISABLED: '1', NO_COLOR: '1' } },
+ (err, stdout, stderr) => {
+ if (err) return done({ ok: false, ...ghFailure(stderr || err.message) })
+ try {
+ done({ ok: true, data: JSON.parse(stdout) as unknown })
+ } catch {
+ done({ ok: false, status: null, message: `gh api ${args.join(' ')} returned something other than JSON` })
+ }
+ }
+ )
+ })
+}
+
+/** GET one REST path; throws, for the calls the report cannot do without. */
+async function need(path: string): Promise {
+ const got = await gh([path])
+ if (!got.ok) throw new Error(`gh api ${path}: ${got.message}`)
+ return got.data
+}
+
+type Pages = { readonly ok: true; readonly rows: readonly unknown[] } | { readonly ok: false; readonly message: string }
+
+/** Every page of a REST listing. */
+async function pages(path: string): Promise {
+ const rows: unknown[] = []
+ for (let page = 1; page <= MAX_PAGES; page++) {
+ const got = await gh([`${path}${path.includes('?') ? '&' : '?'}per_page=${PER_PAGE}&page=${page}`])
+ if (!got.ok) return { ok: false, message: got.message }
+ const batch = Array.isArray(got.data) ? got.data : []
+ rows.push(...batch)
+ if (batch.length < PER_PAGE) break
+ }
+ return { ok: true, rows }
+}
+
+async function allRows(path: string): Promise {
+ const got = await pages(path)
+ if (!got.ok) throw new Error(`gh api ${path}: ${got.message}`)
+ return got.rows
+}
+
+async function traffic(): Promise {
+ const got = await Promise.all(
+ ['views', 'clones', 'popular/referrers', 'popular/paths'].map((p) => gh([`repos/${REPO}/traffic/${p}`]))
+ )
+ for (const g of got) {
+ if (g.ok) continue
+ const hint = g.status === 403 ? ' — traffic needs push access to the repo' : ''
+ return { ok: false, reason: `${g.message}${hint}` }
+ }
+ const [views, clones, referrers, paths] = got.map((g) => (g.ok ? g.data : null))
+ return {
+ ok: true,
+ views: parseTrafficSeries(views, 'views'),
+ clones: parseTrafficSeries(clones, 'clones'),
+ referrers: parsePopular(referrers, 'referrer'),
+ paths: parsePopular(paths, 'path')
+ }
+}
+
+/** Kept under GitHub's node limit: 25 discussions × 50 comments × 50 replies. */
+const DISCUSSIONS_QUERY = `query($owner: String!, $name: String!, $after: String) {
+ repository(owner: $owner, name: $name) {
+ discussions(first: 25, after: $after) {
+ pageInfo { hasNextPage endCursor }
+ nodes {
+ title url createdAt author { __typename login }
+ comments(first: 50) {
+ totalCount
+ nodes {
+ url createdAt author { __typename login }
+ replies(first: 50) { totalCount nodes { url createdAt author { __typename login } } }
+ }
+ }
+ }
+ }
+ }
+}`
+
+type Discussions = {
+ readonly items: readonly Contribution[]
+ readonly truncated: boolean
+ readonly unavailable: string | null
+}
+
+async function discussions(): Promise {
+ const [owner, name] = REPO.split('/') as [string, string]
+ const items: Contribution[] = []
+ let truncated = false
+ let after: string | null = null
+ for (let page = 0; page < MAX_PAGES; page++) {
+ // a query, never a mutation: gh sends GraphQL as a POST, but it reads only
+ const args = ['graphql', '-f', `query=${DISCUSSIONS_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`]
+ if (after) args.push('-f', `after=${after}`)
+ const got = await gh(args)
+ if (!got.ok) return { items, truncated, unavailable: got.message }
+ const parsed = parseDiscussionPage(got.data)
+ items.push(...parsed.items)
+ truncated ||= parsed.truncated
+ if (!parsed.next) break
+ after = parsed.next
+ }
+ return { items, truncated, unavailable: null }
+}
+
+/** The team: collaborators who can push, or — when that listing is refused — CODEOWNERS. */
+async function maintainers(): Promise> {
+ const got = await pages(`repos/${REPO}/collaborators`)
+ if (got.ok) return { maintainers: maintainersFromCollaborators(got.rows), maintainersFrom: 'collaborators' }
+ const codeowners = readFileSync(new URL('../.github/CODEOWNERS', import.meta.url), 'utf8')
+ return { maintainers: codeownersHandles(codeowners), maintainersFrom: 'CODEOWNERS' }
+}
+
+function readHistory(path: string): History {
+ let text: string
+ try {
+ text = readFileSync(path, 'utf8')
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return emptyHistory()
+ throw err
+ }
+ try {
+ return parseHistory(JSON.parse(text) as unknown)
+ } catch (err) {
+ // never overwrite a history this run cannot read: it holds what GitHub has forgotten
+ throw new Error(`${path}: ${err instanceof Error ? err.message : String(err)} — move it aside to start a new one`)
+ }
+}
+
+/** Written beside the target and renamed over it, so a crash never leaves half a file. */
+function writeHistory(path: string, history: History): void {
+ mkdirSync(dirname(path), { recursive: true })
+ const tmp = `${path}.${process.pid}.tmp`
+ try {
+ writeFileSync(tmp, `${JSON.stringify(history, null, 1)}\n`)
+ renameSync(tmp, path)
+ } finally {
+ rmSync(tmp, { force: true })
+ }
+}
+
+function tilde(path: string): string {
+ const home = homedir()
+ return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path
+}
+
+const USAGE = 'usage: npm run stats -- [--json] [--history ]'
+
+function args(): { readonly history?: string; readonly json: boolean; readonly help: boolean } {
+ try {
+ return parseArgs({
+ options: {
+ history: { type: 'string' },
+ json: { type: 'boolean', default: false },
+ help: { type: 'boolean', short: 'h', default: false }
+ }
+ }).values
+ } catch (err) {
+ console.error(`stats: ${err instanceof Error ? err.message : String(err)}\n${USAGE}`)
+ process.exit(2)
+ }
+}
+
+const values = args()
+if (values.help) {
+ console.log(USAGE)
+ process.exit(0)
+}
+
+try {
+ const historyPath = resolve(values.history ?? join(homedir(), '.local', 'share', 'cockpit-stats', 'history.json'))
+ const history = readHistory(historyPath)
+
+ const [repoRaw, releaseRows, trafficNow, team, issueRows, commentRows, discussed] = await Promise.all([
+ need(`repos/${REPO}`),
+ allRows(`repos/${REPO}/releases`),
+ traffic(),
+ maintainers(),
+ allRows(`repos/${REPO}/issues?state=all`),
+ allRows(`repos/${REPO}/issues/comments`),
+ discussions()
+ ])
+
+ const now = Date.now()
+ const repo = parseRepo(repoRaw)
+ const releases = parseReleases(releaseRows)
+ const issues = parseIssues(issueRows)
+ const feedback = summarizeFeedback(
+ {
+ contributions: [...issues, ...parseIssueComments(commentRows, pullNumbers(issues)), ...discussed.items],
+ ...team,
+ discussionsUnavailable: discussed.unavailable,
+ truncated: discussed.truncated
+ },
+ now
+ )
+
+ const snapshot = takeSnapshot({ at: new Date(now).toISOString(), repo, releases, traffic: trafficNow })
+ const merged = mergeHistory(history, snapshot, trafficNow)
+ writeHistory(historyPath, merged)
+
+ const report = buildReport({ now, historyPath: tilde(historyPath), history: merged, repo, releases, traffic: trafficNow, feedback })
+ process.stdout.write(values.json ? `${JSON.stringify(report, null, 2)}\n` : formatReport(report))
+} catch (err) {
+ console.error(`stats: ${err instanceof Error ? err.message : String(err)}`)
+ process.exit(1)
+}
diff --git a/scripts/ui-tour/readme-media.mts b/scripts/ui-tour/readme-media.mts
new file mode 100644
index 00000000..4abf94d7
--- /dev/null
+++ b/scripts/ui-tour/readme-media.mts
@@ -0,0 +1,305 @@
+/**
+ * `npm run ui:readme` — the README's pictures: an animated hero of Cockpit at work and
+ * a few stills, recorded from the ui-tour's fixture world (world.mts), so nothing in
+ * them is anyone's real session, repo or account.
+ *
+ * The hero is a real run, not a slideshow: a task typed on Home starts an agent in a
+ * fresh worktree, a second one is sent to an existing session, the board shows both
+ * flying and then one landing, and ⌘K searches every agent's transcripts. The stub CLIs
+ * stream slowly (UI_TOUR_STUB_DELAY_MS), which is what makes the flight visible.
+ *
+ * Also the social card (social-preview.png, 1280×640): GitHub shows it wherever the repo
+ * is linked, and the published guide uses it as its og:image. GitHub has no API for it,
+ * so a maintainer uploads it by hand under Settings › General › Social preview.
+ *
+ * usage: npm run ui:readme [-- --only stills,card,hero] (the card is drawn from the Home still)
+ *
+ * Writes docs/public/readme/*.{gif,png}. Needs ffmpeg on PATH for the GIF — the one
+ * tool outside the repo; without it the stills are still written and the run says so.
+ * Unpackaged windows carry a branch banner across the top; it is cropped out, since a
+ * packaged app — what a reader would install — has none.
+ */
+import { execFileSync } from 'node:child_process'
+import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { chromium, _electron as electron, type ElectronApplication, type Page } from '@playwright/test'
+import { buildWorld, type World } from './world.mts'
+
+const MAIN = resolve('out/main/index.js')
+const OUT = resolve('docs', 'public', 'readme')
+const SIZE = { width: 1280, height: 820 } as const
+/** The unpackaged window's branch banner, in CSS pixels, cropped off every picture. */
+const BANNER = 28
+
+const argv = process.argv.slice(2)
+const only = argv.includes('--only') ? (argv[argv.indexOf('--only') + 1] ?? '').split(',').filter(Boolean) : []
+const wanted = (part: 'stills' | 'card' | 'hero'): boolean => only.length === 0 || only.includes(part)
+
+const pause = (win: Page, ms: number): Promise => win.waitForTimeout(ms)
+
+async function home(win: Page): Promise {
+ await win.keyboard.press('ControlOrMeta+n')
+ const search = win.getByLabel('Search sessions')
+ if ((await search.inputValue()) !== '') await search.fill('')
+ await pause(win, 400)
+}
+
+async function open(win: Page, title: RegExp): Promise {
+ await home(win)
+ const row = win.getByRole('treeitem', { name: title }).first()
+ const collapsed = win.locator('.repo-row[aria-expanded="false"]')
+ for (let i = 0; i < 20; i++) {
+ const shown = await row.waitFor({ state: 'visible', timeout: 1_500 }).then(() => true, () => false)
+ if (shown || (await collapsed.count()) === 0) break
+ await collapsed.first().click()
+ }
+ await row.click()
+ await pause(win, 900)
+}
+
+/**
+ * `viewport` emulates the size inside whatever window opened (what a screenshot needs);
+ * `window` sizes the real window instead, which a frame stream needs — it records the
+ * window's own surface, and an emulated viewport inside it comes out cut off.
+ */
+type Launch = { readonly env?: NodeJS.ProcessEnv; readonly size?: 'viewport' | 'window' }
+
+async function launch(world: World, opts: Launch = {}): Promise<{ app: ElectronApplication; win: Page }> {
+ const app = await electron.launch({
+ args: [MAIN],
+ env: {
+ ...process.env,
+ COCKPIT_DEV_BACKGROUND: '1',
+ COCKPIT_CLI_LATEST: JSON.stringify({ claude: '2.1.236', codex: '0.155.1', copilot: '1.0.87' }),
+ HOME: world.home,
+ COCKPIT_USER_DATA: world.userData,
+ PATH: `${world.bin}:${process.env['PATH'] ?? ''}`,
+ ...opts.env
+ }
+ })
+ const win = await app.firstWindow()
+ if (opts.size === 'window') {
+ await app.evaluate(({ BrowserWindow }, s) => BrowserWindow.getAllWindows()[0]?.setContentSize(s.width, s.height), SIZE)
+ } else {
+ await win.setViewportSize(SIZE)
+ }
+ await pause(win, 3500)
+ return { app, win }
+}
+
+/** Everything below the banner. */
+const CLIP = { x: 0, y: BANNER, width: SIZE.width, height: SIZE.height - BANNER } as const
+
+/** A still, without the banner. */
+async function still(win: Page, name: string): Promise {
+ await win.screenshot({ path: join(OUT, `${name}.png`), clip: CLIP })
+ console.log(` ✓ ${name}.png`)
+}
+
+async function stills(world: World): Promise {
+ const { app, win } = await launch(world)
+ try {
+ await home(win)
+ await still(win, 'home')
+ await open(win, /Fix the login flake/)
+ await still(win, 'chat')
+ await open(win, /Should usage polling move/)
+ await pause(win, 600)
+ await still(win, 'roundtable')
+ await home(win)
+ await win.keyboard.press('ControlOrMeta+k')
+ await win.keyboard.type('spans')
+ await win.getByRole('option', { name: /Search transcripts for/ }).click()
+ await win.getByRole('option', { name: /agent:|you:/ }).first().waitFor()
+ await pause(win, 400)
+ await still(win, 'search')
+ } finally {
+ await app.close()
+ }
+}
+
+type Frame = { readonly file: string; readonly at: number }
+
+/**
+ * Chromium's own frame stream (CDP `Page.startScreencast`): a frame each time the page
+ * repaints and nothing while it holds still, so a still board costs one frame. Polling
+ * screenshots instead stalls the renderer on every capture and plays back choppy.
+ * A frame's time on screen is the gap to the next one.
+ */
+async function recorder(win: Page, dir: string): Promise<{ readonly stop: () => Promise }> {
+ const cdp = await win.context().newCDPSession(win)
+ const frames: Frame[] = []
+ cdp.on('Page.screencastFrame', (f) => {
+ const file = join(dir, `f${String(frames.length).padStart(5, '0')}.jpg`)
+ writeFileSync(file, Buffer.from(f.data, 'base64'))
+ frames.push({ file, at: (f.metadata.timestamp ?? Date.now() / 1000) * 1000 })
+ cdp.send('Page.screencastFrameAck', { sessionId: f.sessionId }).catch(() => undefined)
+ })
+ await cdp.send('Page.startScreencast', { format: 'jpeg', quality: 92, maxWidth: SIZE.width, maxHeight: SIZE.height })
+ return {
+ stop: async () => {
+ await cdp.send('Page.stopScreencast')
+ frames.push({ file: '', at: Date.now() })
+ return frames
+ }
+ }
+}
+
+/** The hero run, as frames on disk with the time each was taken. */
+async function hero(world: World, dir: string): Promise {
+ mkdirSync(dir, { recursive: true })
+ const { app, win } = await launch(world, { env: { UI_TOUR_STUB_DELAY_MS: '600' }, size: 'window' })
+ try {
+ await home(win)
+ const rec = await recorder(win, dir)
+ await pause(win, 1400)
+
+ // a new task from Home: its own worktree, its own branch, the agent streaming
+ const task = win.getByRole('textbox', { name: 'Task description' })
+ await task.click()
+ await task.pressSequentially('Add a retry budget to the webhook worker', { delay: 40 })
+ await pause(win, 400)
+ await win.keyboard.press('ControlOrMeta+Enter')
+ await pause(win, 2600)
+
+ // a follow-up to an earlier session, then back to the board with both in flight
+ await open(win, /Add pagination to the sessions list/)
+ const box = win.locator('.composer textarea')
+ await box.pressSequentially('Cover the cursor edge case.', { delay: 40 })
+ await box.press('Enter')
+ await pause(win, 1400)
+ await home(win)
+ await pause(win, 1800)
+ await win.locator('.board-row.landed').first().waitFor({ timeout: 40_000 })
+ await pause(win, 1800)
+
+ // every agent's transcripts, one search
+ await win.keyboard.press('ControlOrMeta+k')
+ await pause(win, 300)
+ await win.keyboard.type('spans', { delay: 80 })
+ await pause(win, 300)
+ await win.getByRole('option', { name: /Search transcripts for/ }).click()
+ await win.getByRole('option', { name: /agent:|you:/ }).first().waitFor()
+ await pause(win, 2800)
+ return await rec.stop()
+ } finally {
+ await app.close()
+ }
+}
+
+/** The frames as a looping GIF, each held for as long as it was on screen. */
+function gif(frames: readonly Frame[], dir: string): void {
+ const gifPath = join(OUT, 'hero.gif')
+ const shown = frames.slice(0, -1)
+ const list = shown.flatMap((f, i) => [
+ `file '${f.file}'`,
+ `duration ${(((frames[i + 1]?.at ?? f.at) - f.at) / 1000).toFixed(3)}`
+ ])
+ // the concat demuxer drops the last entry's duration unless the file is named again
+ list.push(`file '${shown[shown.length - 1]?.file ?? ''}'`)
+ const listFile = join(dir, 'frames.txt')
+ writeFileSync(listFile, `${list.join('\n')}\n`)
+ // frames arrive at whatever size the stream chose, so the banner is cropped by ratio
+ const filter =
+ `crop=iw:ih*${(SIZE.height - BANNER) / SIZE.height}:0:ih*${BANNER / SIZE.height},fps=15,` +
+ 'scale=960:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=128:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle'
+ try {
+ execFileSync(
+ 'ffmpeg',
+ ['-y', '-loglevel', 'error', '-f', 'concat', '-safe', '0', '-i', listFile, '-filter_complex', filter, '-loop', '0', gifPath],
+ { stdio: 'inherit' }
+ )
+ } catch (err) {
+ const missing = (err as NodeJS.ErrnoException).code === 'ENOENT'
+ console.log(missing ? ' ✗ hero.gif — ffmpeg is not on PATH' : ` ✗ hero.gif — ${String(err)}`)
+ return
+ }
+ const seconds = ((frames[frames.length - 1]?.at ?? 0) - (frames[0]?.at ?? 0)) / 1000
+ console.log(` ✓ hero.gif (${shown.length} frames, ${seconds.toFixed(1)}s, ${(statSync(gifPath).size / 1e6).toFixed(1)} MB)`)
+}
+
+/**
+ * The social card, drawn from the app's own tokens and fonts, the logo exactly as it
+ * ships, and the Home still beside it. Rendered in headless Chromium — no window.
+ */
+async function socialCard(): Promise {
+ const file = (p: string): string => pathToFileURL(resolve(p)).href
+ const font = (w: number): string =>
+ `@font-face { font-family: 'Plex Sans'; font-weight: ${w}; src: url('${file(`src/renderer/src/assets/fonts/ibm-plex-sans-latin-${w}-normal.woff2`)}'); }`
+ const chip = (label: string, color: string): string =>
+ `${label}`
+ const html = `
+
+
+
+ `
+ const scratch = mkdtempSync(join(tmpdir(), 'cockpit-card-'))
+ const browser = await chromium.launch()
+ try {
+ const page = await browser.newPage({ viewport: { width: 1280, height: 640 } })
+ const htmlFile = join(scratch, 'card.html')
+ writeFileSync(htmlFile, html)
+ await page.goto(pathToFileURL(htmlFile).href)
+ await page.evaluate(() => document.fonts.ready)
+ await page.screenshot({ path: join(OUT, 'social-preview.png') })
+ console.log(' ✓ social-preview.png')
+ } finally {
+ await browser.close()
+ rmSync(scratch, { recursive: true, force: true })
+ }
+}
+
+async function main(): Promise {
+ const { existsSync } = await import('node:fs')
+ if (!existsSync(MAIN)) {
+ console.error('out/main/index.js missing — run `npm run build` (npm run ui:readme does it for you)')
+ process.exit(1)
+ }
+ mkdirSync(OUT, { recursive: true })
+ const scratch = mkdtempSync(join(tmpdir(), 'cockpit-readme-'))
+ try {
+ if (wanted('stills')) {
+ console.log('stills')
+ await stills(buildWorld(join(scratch, 'stills')))
+ }
+ if (wanted('card')) await socialCard()
+ if (wanted('hero')) {
+ console.log('hero')
+ const frames = await hero(buildWorld(join(scratch, 'hero')), join(scratch, 'frames'))
+ gif(frames, join(scratch, 'frames'))
+ }
+ } finally {
+ rmSync(scratch, { recursive: true, force: true })
+ }
+}
+
+await main()
diff --git a/tests/stats-core.test.ts b/tests/stats-core.test.ts
new file mode 100644
index 00000000..a8b3a5ed
--- /dev/null
+++ b/tests/stats-core.test.ts
@@ -0,0 +1,427 @@
+import { readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+import {
+ activeInstalls,
+ baselineSnapshot,
+ buildReport,
+ CHECKS_PER_DAY,
+ codeownersHandles,
+ emptyHistory,
+ formatReport,
+ ghFailure,
+ latestWindows,
+ maintainersFromCollaborators,
+ mergeDays,
+ mergeHistory,
+ parseDiscussionPage,
+ parseHistory,
+ parseIssueComments,
+ parseIssues,
+ parsePopular,
+ parseReleases,
+ parseRepo,
+ parseTrafficSeries,
+ pullNumbers,
+ recentDownloads,
+ REPO,
+ spread,
+ summarizeFeedback,
+ takeSnapshot,
+ UPDATE_CHECK,
+ weekly,
+ type FeedbackSummary,
+ type History,
+ type Release,
+ type Snapshot,
+ type Traffic
+} from '../scripts/stats-core.mts'
+
+const DAY = 86_400_000
+const NOW = Date.parse('2026-09-24T12:00:00Z')
+
+function asset(name: string, download_count: number): object {
+ return { name, download_count }
+}
+
+function release(tag: string, published_at: string, counts: { dmg?: number; zip?: number; feed?: number } = {}): object {
+ return {
+ tag_name: tag,
+ published_at,
+ draft: false,
+ prerelease: false,
+ assets: [
+ asset(`Cockpit-${tag.slice(1)}-arm64.dmg`, counts.dmg ?? 0),
+ asset(`Cockpit-${tag.slice(1)}-arm64.zip`, counts.zip ?? 0),
+ asset('latest-mac.yml', counts.feed ?? 0)
+ ]
+ }
+}
+
+const noTraffic: Traffic = { ok: false, reason: 'HTTP 403' }
+
+function snapshot(at: string, releases: Snapshot['releases'], stars = 0): Snapshot {
+ return { at, stars, forks: 0, watchers: 0, releases, referrers: [] }
+}
+
+describe('the updater schedule the estimate rests on', () => {
+ it('matches src/main/updates.ts', () => {
+ const source = readFileSync(new URL('../src/main/updates.ts', import.meta.url), 'utf8')
+ const product = (name: string): number => {
+ const expr = new RegExp(`const ${name} = ([\\d_ *]+)\\n`).exec(source)?.[1]
+ expect(expr, `${name} in updates.ts`).toBeDefined()
+ return expr!.split('*').reduce((n, f) => n * Number(f.trim().replace(/_/g, '')), 1)
+ }
+ expect(product('CHECK_INTERVAL_MS')).toBe(UPDATE_CHECK.intervalHours * 3_600_000)
+ expect(product('FIRST_CHECK_DELAY_MS')).toBe(UPDATE_CHECK.firstDelaySeconds * 1000)
+ })
+
+ it('counts 6 checks a day for a copy never quit and 2 for one 8h session', () => {
+ expect(CHECKS_PER_DAY).toEqual({ neverQuit: 6, oneSession: 2 })
+ expect(activeInstalls(12)).toEqual({ low: 2, high: 6 })
+ })
+})
+
+describe('parsing what gh returns', () => {
+ it('reads traffic days and drops rows it cannot date', () => {
+ const raw = {
+ count: 5,
+ uniques: 2,
+ views: [
+ { timestamp: '2026-09-22T00:00:00Z', count: 3, uniques: 1 },
+ { timestamp: 'yesterday', count: 9, uniques: 9 },
+ { timestamp: '2026-09-23T00:00:00Z', count: -1, uniques: 'x' }
+ ]
+ }
+ expect(parseTrafficSeries(raw, 'views')).toEqual({
+ count: 5,
+ uniques: 2,
+ days: [
+ { date: '2026-09-22', count: 3, uniques: 1 },
+ { date: '2026-09-23', count: 0, uniques: 0 }
+ ]
+ })
+ expect(parseTrafficSeries(null, 'clones')).toEqual({ count: 0, uniques: 0, days: [] })
+ })
+
+ it('reads referrers and paths', () => {
+ expect(parsePopular([{ referrer: 'github.com', count: 8, uniques: 3 }, { count: 1 }], 'referrer')).toEqual([
+ { name: 'github.com', count: 8, uniques: 3 }
+ ])
+ expect(parsePopular({ message: 'nope' }, 'path')).toEqual([])
+ })
+
+ it('reads the status and message of a refused call', () => {
+ expect(ghFailure('gh: Must have push access to repository (HTTP 403)\n')).toEqual({
+ status: 403,
+ message: 'Must have push access to repository (HTTP 403)'
+ })
+ expect(ghFailure('')).toEqual({ status: null, message: 'gh api failed' })
+ })
+
+ it('takes watchers from subscribers, not the legacy alias of stars', () => {
+ expect(parseRepo({ stargazers_count: 7, watchers_count: 7, subscribers_count: 2, forks_count: 1 })).toEqual({
+ stars: 7,
+ forks: 1,
+ watchers: 2
+ })
+ })
+
+ it('splits release downloads by what each asset is for, newest release first, drafts out', () => {
+ const rows = [
+ release('v0.1.0', '2026-09-15T10:00:00Z', { dmg: 2, zip: 1, feed: 4 }),
+ {
+ tag_name: 'v0.2.0',
+ published_at: '2026-09-16T10:00:00Z',
+ assets: [
+ asset('Cockpit-0.2.0-arm64.dmg', 1),
+ asset('Cockpit-0.2.0-x64.dmg', 2),
+ asset('Cockpit-0.2.0-x64.zip', 3),
+ asset('Cockpit-0.2.0-x64.zip.blockmap', 5),
+ asset('latest-mac.yml', 6)
+ ]
+ },
+ { ...release('v0.3.0', '2026-09-17T10:00:00Z'), draft: true },
+ { tag_name: 'v0.4.0', published_at: null, assets: [] },
+ 'garbage'
+ ]
+ expect(parseReleases(rows)).toEqual([
+ { tag: 'v0.2.0', publishedAt: '2026-09-16T10:00:00Z', prerelease: false, dmg: 3, zip: 3, feed: 6, other: 5 },
+ { tag: 'v0.1.0', publishedAt: '2026-09-15T10:00:00Z', prerelease: false, dmg: 2, zip: 1, feed: 4, other: 0 }
+ ])
+ })
+
+ it('tells pull requests and their comments from issues and theirs', () => {
+ const issues = parseIssues([
+ { number: 1, user: { login: 'someone', type: 'User' }, created_at: '2026-09-20T00:00:00Z', html_url: 'https://github.com/tashtit/cockpit/issues/1', title: 'Crash' },
+ { number: 2, user: { login: 'dependabot[bot]', type: 'Bot' }, created_at: '2026-09-20T00:00:00Z', html_url: 'https://github.com/tashtit/cockpit/pull/2', pull_request: {} },
+ { number: 3, user: null, created_at: '2026-09-20T00:00:00Z' }
+ ])
+ expect(issues.map((i) => [i.kind, i.author, i.bot])).toEqual([
+ ['issue', 'someone', false],
+ ['pr', 'dependabot[bot]', true]
+ ])
+ const comments = parseIssueComments(
+ [
+ { user: { login: 'a', type: 'User' }, created_at: '2026-09-21T00:00:00Z', html_url: 'https://github.com/tashtit/cockpit/issues/1#issuecomment-1', issue_url: 'https://api.github.com/repos/tashtit/cockpit/issues/1' },
+ { user: { login: 'b', type: 'User' }, created_at: '2026-09-21T00:00:00Z', html_url: 'https://github.com/tashtit/cockpit/pull/2#issuecomment-2' },
+ { user: { login: 'c', type: 'User' }, created_at: '2026-09-21T00:00:00Z', html_url: '', issue_url: 'https://api.github.com/repos/tashtit/cockpit/issues/2' }
+ ],
+ pullNumbers(issues)
+ )
+ expect(comments.map((c) => c.kind)).toEqual(['issue-comment', 'pr-comment', 'pr-comment'])
+ })
+
+ it('reads discussions, their comments and replies, and says when a thread was cut short', () => {
+ const page = parseDiscussionPage({
+ data: {
+ repository: {
+ discussions: {
+ pageInfo: { hasNextPage: true, endCursor: 'abc' },
+ nodes: [
+ {
+ title: 'Idea',
+ url: 'https://github.com/tashtit/cockpit/discussions/1',
+ createdAt: '2026-09-20T00:00:00Z',
+ author: { __typename: 'User', login: 'fan' },
+ comments: {
+ totalCount: 3,
+ nodes: [
+ {
+ url: 'u1',
+ createdAt: '2026-09-21T00:00:00Z',
+ author: null,
+ replies: { totalCount: 1, nodes: [{ url: 'u2', createdAt: '2026-09-22T00:00:00Z', author: { __typename: 'Bot', login: 'helper' } }] }
+ }
+ ]
+ }
+ }
+ ]
+ }
+ }
+ }
+ })
+ expect(page.items.map((i) => [i.kind, i.author, i.bot])).toEqual([
+ ['discussion', 'fan', false],
+ ['discussion-comment', 'ghost', false],
+ ['discussion-comment', 'helper', true]
+ ])
+ expect(page.truncated).toBe(true)
+ expect(page.next).toBe('abc')
+ expect(parseDiscussionPage({ errors: [{}] })).toEqual({ items: [], truncated: false, next: null })
+ })
+
+ it('takes the team from collaborators who can push, or from CODEOWNERS', () => {
+ expect(
+ maintainersFromCollaborators([
+ { login: 'a', permissions: { push: true } },
+ { login: 'b', permissions: { push: false, pull: true } },
+ { login: 'c' }
+ ])
+ ).toEqual(['a'])
+ expect(codeownersHandles('# owners\n* @titan-ron\n/docs/ @someone @org/team @titan-ron # trailing\n')).toEqual([
+ 'titan-ron',
+ 'someone'
+ ])
+ })
+})
+
+describe('summarizeFeedback', () => {
+ it('counts only people outside the team and never bots', () => {
+ const at = (days: number): string => new Date(NOW - days * DAY).toISOString()
+ const f = summarizeFeedback(
+ {
+ contributions: [
+ { kind: 'issue', author: 'Outsider', bot: false, createdAt: at(40), url: 'i1', title: 'Old' },
+ { kind: 'issue-comment', author: 'outsider', bot: false, createdAt: at(2), url: 'c1', title: null },
+ { kind: 'discussion', author: 'fan', bot: false, createdAt: at(1), url: 'd1', title: 'Idea' },
+ { kind: 'pr', author: 'fan', bot: false, createdAt: at(1), url: 'p1', title: 'Fix' },
+ { kind: 'issue', author: 'Titan-Ron', bot: false, createdAt: at(1), url: 'i2', title: 'Ours' },
+ { kind: 'issue-comment', author: 'github-code-quality[bot]', bot: true, createdAt: at(1), url: 'c2', title: null }
+ ],
+ maintainers: ['titan-ron'],
+ maintainersFrom: 'collaborators',
+ discussionsUnavailable: null,
+ truncated: false
+ },
+ NOW
+ )
+ expect(f.total).toBe(3)
+ expect(f.recent).toBe(2)
+ expect(f.counts).toEqual({ issue: 1, 'issue-comment': 1, discussion: 1, 'discussion-comment': 0, pr: 1, 'pr-comment': 0 })
+ expect(f.newest.map((n) => n.url)).toEqual(['d1', 'c1', 'i1'])
+ })
+})
+
+describe('history', () => {
+ it('refuses a file it cannot own and drops rows it cannot read', () => {
+ expect(() => parseHistory([])).toThrow(/not a JSON object/)
+ expect(() => parseHistory({ ...emptyHistory(), version: 2 })).toThrow(/version 2/)
+ expect(() => parseHistory({ ...emptyHistory(), repo: 'someone/else' })).toThrow(/someone\/else/)
+ const h = parseHistory({
+ version: 1,
+ repo: REPO,
+ views: [{ date: '2026-09-20', count: 3, uniques: 1 }, { date: 'bad' }],
+ clones: 'nope',
+ snapshots: [{ at: '2026-09-20T00:00:00Z', stars: 1, releases: { 'v0.1.0': [1, 2, 3] } }, { at: 'never' }]
+ })
+ expect(h.views).toEqual([{ date: '2026-09-20', count: 3, uniques: 1 }])
+ expect(h.clones).toEqual([])
+ expect(h.snapshots).toEqual([snapshot('2026-09-20T00:00:00Z', { 'v0.1.0': [1, 2, 3] }, 1)])
+ })
+
+ it('merges traffic per day, keeping the most seen for each field', () => {
+ expect(
+ mergeDays(
+ [
+ { date: '2026-09-02', count: 5, uniques: 1 },
+ { date: '2026-09-01', count: 2, uniques: 2 }
+ ],
+ [
+ { date: '2026-09-02', count: 3, uniques: 4 },
+ { date: '2026-09-03', count: 1, uniques: 1 }
+ ]
+ )
+ ).toEqual([
+ { date: '2026-09-01', count: 2, uniques: 2 },
+ { date: '2026-09-02', count: 5, uniques: 4 },
+ { date: '2026-09-03', count: 1, uniques: 1 }
+ ])
+ })
+
+ it('keeps one snapshot per UTC day, so running twice merges instead of repeating', () => {
+ const traffic: Traffic = {
+ ok: true,
+ views: { count: 3, uniques: 1, days: [{ date: '2026-09-23', count: 3, uniques: 1 }] },
+ clones: { count: 0, uniques: 0, days: [] },
+ referrers: [{ name: 'github.com', count: 1, uniques: 1 }],
+ paths: []
+ }
+ const releases = parseReleases([release('v0.1.0', '2026-09-15T10:00:00Z', { feed: 1 })])
+ const repo = { stars: 1, forks: 0, watchers: 0 }
+ const yesterday = mergeHistory(emptyHistory(), takeSnapshot({ at: '2026-09-23T09:00:00Z', repo, releases, traffic }), traffic)
+ const first = mergeHistory(yesterday, takeSnapshot({ at: '2026-09-24T09:00:00Z', repo, releases, traffic }), traffic)
+ const second = mergeHistory(first, takeSnapshot({ at: '2026-09-24T10:00:00Z', repo, releases, traffic }), traffic)
+ expect(second.snapshots.map((s) => s.at)).toEqual(['2026-09-23T09:00:00Z', '2026-09-24T10:00:00Z'])
+ expect(second.views).toEqual(first.views)
+ expect(second.snapshots[1]!.referrers).toEqual(traffic.referrers)
+ // a run without traffic leaves the merged days alone
+ expect(mergeHistory(second, second.snapshots[1]!, noTraffic).views).toEqual(second.views)
+ })
+
+ it('takes deltas from a snapshot at least a day old, as close to a week old as there is', () => {
+ const at = (days: number): string => new Date(NOW - days * DAY).toISOString()
+ const snaps = [snapshot(at(12), {}), snapshot(at(8), {}), snapshot(at(5), {}), snapshot(at(0.5), {})]
+ expect(baselineSnapshot(snaps, NOW)?.at).toBe(at(8))
+ expect(baselineSnapshot([snapshot(at(0.5), {})], NOW)).toBeNull()
+ })
+})
+
+describe('estimates', () => {
+ const releases: readonly Release[] = parseReleases([
+ release('v0.1.0', '2026-09-14T12:00:00Z', { dmg: 4, zip: 0, feed: 20 }),
+ release('v0.2.0', '2026-09-20T12:00:00Z', { dmg: 2, zip: 6, feed: 24 })
+ ])
+
+ it('says when each release was the latest one', () => {
+ expect(latestWindows(releases, NOW).map((w) => [w.tag, (w.end - w.start) / DAY])).toEqual([
+ ['v0.1.0', 6],
+ ['v0.2.0', 4]
+ ])
+ })
+
+ it('spreads lifetime counts over the part of each window a range covers', () => {
+ const windows = latestWindows(releases, NOW)
+ // half of v0.1.0's window and half of v0.2.0's
+ expect(spread(windows, { from: Date.parse('2026-09-17T12:00:00Z'), to: Date.parse('2026-09-22T12:00:00Z') })).toEqual({
+ dmg: 3,
+ zip: 3,
+ feed: 22
+ })
+ })
+
+ it('without history, reads the last week off the release windows', () => {
+ const recent = recentDownloads(releases, emptyHistory(), NOW)
+ expect(recent?.source).toBe('release windows')
+ expect(recent?.days).toBe(7)
+ // 3 of v0.1.0's 6 days, all 4 of v0.2.0's
+ expect(recent?.feed).toBe(10 + 24)
+ })
+
+ it('with history, takes exact deltas, counting a release the baseline never saw in full', () => {
+ const history: History = {
+ ...emptyHistory(),
+ snapshots: [snapshot(new Date(NOW - 7 * DAY).toISOString(), { 'v0.1.0': [3, 0, 15] })]
+ }
+ expect(recentDownloads(releases, history, NOW)).toEqual({
+ dmg: 1 + 2,
+ zip: 6,
+ feed: 5 + 24,
+ days: 7,
+ since: new Date(NOW - 7 * DAY).toISOString(),
+ source: 'history'
+ })
+ })
+
+ it('buckets weeks by Monday, marks traffic the history only partly holds, and leaves pre-release weeks empty', () => {
+ const history: History = {
+ ...emptyHistory(),
+ // Thursday the 10th to Wednesday the 23rd: GitHub has no row for today yet
+ views: Array.from({ length: 14 }, (_, i) => ({ date: new Date(Date.parse('2026-09-10T00:00:00Z') + i * DAY).toISOString().slice(0, 10), count: 1, uniques: 1 }))
+ }
+ const weeks = weekly(releases, history, { now: NOW, weeks: 8 })
+ expect(weeks.map((w) => [w.start, w.views?.count, w.views?.partial])).toEqual([
+ ['2026-09-07', 4, true],
+ ['2026-09-14', 7, false],
+ ['2026-09-21', 3, false]
+ ])
+ expect(weeks[0]!.downloads).toBeNull()
+ expect(weeks[0]!.active).toBeNull()
+ // all of v0.1.0's window, and the half day of v0.2.0's four that falls before Monday the 21st
+ expect(weeks[1]!.downloads?.feed).toBeCloseTo(20 + (24 * 0.5) / 4)
+ })
+})
+
+describe('the report', () => {
+ const feedback: FeedbackSummary = summarizeFeedback(
+ { contributions: [], maintainers: ['titan-ron'], maintainersFrom: 'CODEOWNERS', discussionsUnavailable: 'HTTP 502', truncated: false },
+ NOW
+ )
+
+ it('says why traffic is missing and still reports the rest', () => {
+ const releases = parseReleases([release('v0.1.0', '2026-09-20T12:00:00Z', { dmg: 2, zip: 1, feed: 24 })])
+ const text = formatReport(
+ buildReport({
+ now: NOW,
+ historyPath: '~/history.json',
+ history: emptyHistory(),
+ repo: { stars: 2, forks: 0, watchers: 1 },
+ releases,
+ traffic: { ok: false, reason: 'Must have push access to repository (HTTP 403) — traffic needs push access to the repo' },
+ feedback
+ })
+ )
+ expect(text).toContain('unavailable: Must have push access')
+ expect(text).toContain('team (CODEOWNERS (collaborators refused)): titan-ron')
+ expect(text).toContain('discussions could not be read: HTTP 502')
+ // 24 checks over 4 days = 6 a day → 1 copy never quit, 3 used for one session a day
+ expect(text).toMatch(/active installs\s+≈ 1–3\s+6 update checks a day/)
+ expect(text).toMatch(/v0\.1\.0\s+2026-09-20 12:00\s+4d \(now\)/)
+ expect(text).toContain('mostly CI checkouts')
+ expect(text).toContain('scripts/install.sh')
+ })
+
+ it('shows the change in stars against the history baseline', () => {
+ const history: History = { ...emptyHistory(), snapshots: [snapshot(new Date(NOW - 7 * DAY).toISOString(), {}, 1)] }
+ const report = buildReport({
+ now: NOW,
+ historyPath: 'h.json',
+ history,
+ repo: { stars: 3, forks: 0, watchers: 0 },
+ releases: [],
+ traffic: noTraffic,
+ feedback
+ })
+ expect(formatReport(report)).toContain('stars 3 · forks 0 · watchers 0 since 2026-09-17: +2 · +0 · +0')
+ expect(report.usage.active).toBeNull()
+ })
+})
From c72e7723e645aba0d46ba6c54ddc0d10f36ef8f4 Mon Sep 17 00:00:00 2001
From: titan-ron <30556071+titan-ron@users.noreply.github.com>
Date: Thu, 24 Sep 2026 18:35:35 +0300
Subject: [PATCH 4/4] docs: publish the user guide and lead the README with
what Cockpit does
The guide in docs/ is deployed to GitHub Pages by docs.yml on pushes to main (pull requests only build it), with no analytics. The README opens with a recorded hero, the three reasons to use Cockpit, a one-line install, what it sends where, and where feedback goes; the code map moves to CONTRIBUTING.
---
.github/workflows/docs.yml | 60 ++++++++++
.gitignore | 1 +
AGENTS.md | 9 +-
CONTRIBUTING.md | 54 +++++++++
README.md | 158 ++++++++++++--------------
docs/.vitepress/config.mts | 15 ++-
docs/guide/what-is-cockpit.md | 2 +
docs/public/readme/chat.png | Bin 0 -> 259378 bytes
docs/public/readme/hero.gif | Bin 0 -> 1207241 bytes
docs/public/readme/home.png | Bin 0 -> 310085 bytes
docs/public/readme/roundtable.png | Bin 0 -> 257242 bytes
docs/public/readme/search.png | Bin 0 -> 265865 bytes
docs/public/readme/social-preview.png | Bin 0 -> 374605 bytes
13 files changed, 212 insertions(+), 87 deletions(-)
create mode 100644 .github/workflows/docs.yml
create mode 100644 docs/public/readme/chat.png
create mode 100644 docs/public/readme/hero.gif
create mode 100644 docs/public/readme/home.png
create mode 100644 docs/public/readme/roundtable.png
create mode 100644 docs/public/readme/search.png
create mode 100644 docs/public/readme/social-preview.png
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 00000000..6a858884
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,60 @@
+name: 'Docs'
+
+# The user guide in docs/, published to GitHub Pages (https://tashtit.github.io/cockpit/).
+# Pull requests that touch it only build it — VitePress fails the build on a dead link,
+# which is the one thing about the guide a machine can check — and main deploys it.
+# No analytics and no cookies: the site is the built VitePress output and nothing else.
+on:
+ workflow_dispatch:
+ pull_request:
+ paths: ['docs/**', 'package-lock.json', '.github/workflows/docs.yml']
+ push:
+ branches: [main]
+ paths: ['docs/**', 'package-lock.json', '.github/workflows/docs.yml']
+
+# One deployment at a time: a deploy in progress finishes, the newest queued one follows.
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: 'read' # for actions/checkout
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v7.0.1
+ with: { persist-credentials: false }
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v7.0.0
+ with: { node-version-file: '.nvmrc', cache: 'npm' }
+
+ - name: Install
+ run: npm ci
+
+ - name: 📚 Build the guide
+ run: npm run docs:build
+
+ - name: Upload the site
+ if: ${{ github.event_name != 'pull_request' }}
+ uses: actions/upload-pages-artifact@v5.0.0
+ with: { path: docs/.vitepress/dist }
+
+ deploy:
+ if: ${{ github.event_name != 'pull_request' }}
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ pages: 'write' # for actions/deploy-pages
+ id-token: 'write' # deploy-pages proves which run the site came from
+ environment:
+ name: github-pages
+ url: ${{ steps.deploy.outputs.page_url }}
+ steps:
+ - name: 🚀 Deploy to GitHub Pages
+ id: deploy
+ uses: actions/deploy-pages@v5.0.1
diff --git a/.gitignore b/.gitignore
index 7bd628ea..8506ed9e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,7 @@ coverage/
# VitePress (build output is covered by dist/)
docs/.vitepress/cache/
+docs/.vitepress/dist/
# Editor / local tooling
.idea/
diff --git a/AGENTS.md b/AGENTS.md
index a94299a0..7cd25bcb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -17,6 +17,7 @@ This file provides guidance to AI coding agents (Claude Code, Codex, GitHub Copi
- `npm run package` — macOS disk images + zips into `dist/` via electron-builder (unsigned without Apple credentials; version `0.0.0` outside a release)
- `npm run test:packaged` — Playwright smoke test against the `.app` from `npm run package` (`tests/e2e/packaged.spec.ts`; opt-in, uses the real userData dir)
- `npm run docs:dev` — the user guide (VitePress, `docs/`) with hot reload; `docs:build` / `docs:preview` for the built site
+- `npm run stats` — adoption and outside feedback from read-only `gh api` calls (no telemetry): traffic, stars, release downloads by meaning (dmg = new install, zip = update, `latest-mac.yml` = update check → active-install estimate), non-team issues/comments/discussions. Folds GitHub's 14-day traffic and one counter snapshot per UTC day into `~/.local/share/cockpit-stats/history.json` (`--history `, `--json`); logic in `scripts/stats-core.mts`
- `npm run ui:tour` — builds, then screenshots every view and state against a hermetic fixture world (`scripts/ui-tour/`): desktop, an ordinary 900×700 window, the 560×420 floor and 200% zoom, sessions actually flying and landing (stub agent CLIs stream slowly), and a first launch. Writes `test-results/ui-tour/` with an `index.html` contact sheet; a shot it can't reach is marked missing and fails the run. `-- --only chat,settings` narrows it, `-- --no-live` skips the ~40s of live turns
Both `npm run typecheck` and `npm test` must pass before delivering.
@@ -180,9 +181,11 @@ full-screen specs; don't set it unasked.
## Documentation
-The user guide is a VitePress site in `docs/` (`docs/guide/`, one page per feature). It is
-**deliberately not deployed** — there is no Pages workflow and none is wanted yet. It is read
-locally with `npm run docs:dev`, so treat it as part of the repo rather than as a published site.
+The user guide is a VitePress site in `docs/` (`docs/guide/`, one page per feature), published
+to GitHub Pages at https://tashtit.github.io/cockpit/ by `.github/workflows/docs.yml` on every
+push to `main` that touches it; pull requests only build it, which fails on a dead link. It has
+no analytics and none is wanted. `npm run docs:dev` serves it under the same `/cockpit/` base —
+a `head` tag's URL must carry that base itself, since only themeConfig and markdown links get it.
VitePress 1.x pins its own `vite` 5 (and `esbuild` 0.21), both past their security fixes, so
`overrides` in `package.json` hands it the root `vite` instead — drop the override once VitePress 2
is stable. Pages are compiled as Vue templates: a bare `` outside a code span is an
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3edaac7e..1b6ea53e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -78,6 +78,44 @@ A dev run has no bundle of its own: `npm run dev` launches the stock `Electron.a
- **UI work**: read `design-system/cockpit/MASTER.md` first. Components use the design tokens from the `:root` block of `src/renderer/src/style.css` — never raw hex. Dark mode only.
- Session log parsers must stay failure-tolerant and bounded (≤256 KB per file read) — provider formats drift between releases; skip what you can't read rather than fail the scan.
+## Where the code lives
+
+A map for finding your way in; [AGENTS.md](AGENTS.md) has the architecture in depth.
+
+```
+src/shared/types.ts domain vocabulary (SessionMeta, RepoInfo, …) — imports nothing from src/
+src/shared/contract.ts the whole renderer↔main IPC surface (CockpitApi, CH, PUSH)
+src/main/parsers/ per-provider session log parsers (failure-tolerant)
+src/main/repos.ts cwd → git repo resolution (worktree-aware, GitHub remote)
+src/main/indexer.ts scan + stat-cache + fs.watch(recursive) + repo grouping + paging
+src/main/extensions.ts MCP/skills/plugins inventory + cross-agent MCP/skill sharing
+src/main/instructions-core.ts shared-instructions pure logic (markers, drift, targets)
+src/main/instructions.ts shared-instructions IO (baseline storage + fan-out)
+src/main/github.ts PR status per repo via `gh pr list` (cached)
+src/main/workspace.ts worktree/branch creation + push/`gh pr create`
+src/main/chat.ts ChatManager: spawn provider CLIs, parse stream events
+src/main/attention-core.ts which turn endings are news: landings, Dock badge count, notification bursts (IO-free)
+src/main/attention.ts notifications, system sounds, Dock badge + bounce (Electron), landings persisted to userData
+src/main/accounts.ts who each agent CLI is signed in as, per config home + `gh` user
+src/main/usage.ts subscription usage per provider (local measurement / CLI snapshots / GitHub billing API)
+src/main/provider-archived.ts sessions archived/deleted in the provider's own app → hidden
+src/main/env.ts PATH fix for GUI-launched CLI spawns (macOS)
+src/main/updates.ts app updates from GitHub Releases (electron-updater; installed builds only)
+src/main/config.ts source-dir registry + history window
+src/main/index.ts electron bootstrap + IPC
+src/preload/index.ts contextBridge → window.cockpit
+src/renderer/ React UI (HomeView, TreeSidebar, ChatView, NewSession, AiSetup, Settings, Select, logos.tsx)
+```
+
+### Notes
+
+- File watching uses Node's `fs.watch(root, {recursive: true})` (FSEvents on macOS) — chokidar was dropped after its bundled `fsevents` native module broke on the Electron 43 upgrade; the indexer does its own debouncing and stat-based dirty tracking.
+- Session log formats are provider-internal and drift between releases; parsers skip anything they can't read rather than fail.
+- The Copilot parser is best-effort (least documented format). Reports of sessions that don't show up arrive through the **Sessions missing or wrong** issue form, with the agent and its CLI version; the fix usually lives in `src/main/parsers/copilot.ts`.
+- No SQLite yet on purpose — in-memory index is plenty for M1 and avoids native-module rebuild pain. Revisit at M6 (full-text search).
+- Copilot chat streams plain text (no structured events), so a *new* Copilot chat doesn't learn its session id mid-conversation — the session appears in the sidebar after the first turn; click it to continue with proper resume. Claude/Codex bind their session id from the first response.
+- Codex event stream shapes changed between releases; both the old (`msg.type`) and new (`thread.started`/`item.completed`) shapes are handled.
+
## Runtime dependencies
Cockpit ships three runtime packages; everything else in `package.json` is dev tooling. Each one is here because the platform does not cover it, and this table is what a reviewer checks when one of them is bumped or replaced.
@@ -154,3 +192,19 @@ An installed Cockpit uses `electron-updater` (`src/main/updates.ts`) for the che
Downloading and installing are Cockpit's own (`src/main/update-install.ts`, decisions in `-core.ts`) rather than electron-updater's. Its macOS installer is Squirrel.Mac, which only swaps in a bundle carrying the same Developer ID signature as the running one — so with releases ad-hoc signed (above), *every* install ended in an error and the app could only announce versions it could not fetch for you. Installing here works signed or not, and it is the one place that can clear the quarantine flag before the new bundle lands rather than leaving Gatekeeper to block it afterwards.
What Squirrel's signature check stood for is done explicitly instead: the zip must hash to the `sha512` the feed publishes, and the bundle inside must carry the same `CFBundleIdentifier`, be the version that was offered, and — once releases are signed — be signed by the same team as the bundle it replaces. The swap itself is a detached `/bin/sh` script that waits for the app's pid to go, renames the old bundle aside within the same folder, `ditto`s the new one in and puts the old one back if that fails. A failed install leaves a line in `/updates/last-install`; the next launch reads it, says so in About and holds the automatic path until the user checks by hand, so a build that cannot be installed is never fetched again and again in silence.
+
+## Adoption numbers
+
+Cockpit has no telemetry. `npm run stats` reads what GitHub already records (read-only `gh api`; traffic needs push access) and reports
+outside feedback (issues, comments and discussions by anyone who is not a collaborator with push, bots excluded), stars, traffic, and release
+downloads by meaning: disk images are new installs, zips are updates (and fresh installs through the curl installer), and `latest-mac.yml`
+fetches, one per launch and every four hours per running copy, give an active-install range. GitHub forgets traffic after 14 days, so each
+run folds it into `~/.local/share/cockpit-stats/history.json` (one snapshot per UTC day; re-running is safe). Run it at least every two weeks
+to keep that history unbroken; `--json` prints the report machine-readable, `--history ` keeps it elsewhere.
+
+## README pictures
+
+The hero GIF, the stills and the social card under `docs/public/readme/` come from `npm run ui:readme`, recorded against the ui-tour's
+fixture world, so no real session, repo or account is ever in them. Rerun it when a view they show changes (`-- --only stills,card,hero`
+for one part; the GIF needs `ffmpeg` on `PATH`). GitHub has no API for the repository's social preview, so after changing
+`social-preview.png` upload it by hand under Settings › General › Social preview.
diff --git a/README.md b/README.md
index 809bd7aa..e472e357 100644
--- a/README.md
+++ b/README.md
@@ -1,102 +1,94 @@
-# Cockpit
-
-Unified desktop hub for **Claude Code**, **Codex**, and **GitHub Copilot CLI**: browse every session across providers, continue any conversation, start new agent runs in isolated worktrees, and manage the shared AI setup — from one window.
+
+
+
+
+
Cockpit
+
+
+ Every Claude Code, Codex and GitHub Copilot CLI session, across every repo, in one window.
+ See what your agents are doing, answer the one that's waiting on you, and ship each task as a pull request.
+
+
+## Why Cockpit
+
+- **Your history is already there.** Cockpit reads the session logs Claude Code, Codex and Copilot CLI already write, so the first launch lists every session you have run — in a terminal, an editor or the agents' own apps — grouped by repository and branch, and it stays live as you work.
+- **It tells you when an agent needs you.** The board shows what is flying, what has landed and what is waiting on your answer. A notification and a Dock badge arrive when a turn ends, fails or asks you a question, and when a pull request on your branch goes red.
+- **Work lands as a pull request.** A new task runs on its own branch in its own git worktree, never in your checkout. Review the diff, open the PR, and when checks fail or a reviewer asks for changes, **Fix with Claude** turns all of it into one prompt.
+
+Also in the box:
+
+- **⌘K search** across every agent's transcripts — "where did I discuss that?"
+- **Roundtables** — several agents on one question, discussing until they agree.
+- **One AI setup** — shared instructions, MCP servers and skills across all three agents, with drift detection.
+- **Accounts and usage** — who each CLI is signed in as, and how much of your subscription is left.
+- **Your own models** — custom providers (Ollama, LiteLLM, gateways) for Claude Code and Copilot, and any agent that speaks ACP.
+- **Cleanup** — stale sessions, worktrees, and the dev servers still running inside them.
+
+
+
+
+
+
+
+
+
Continue any session
+
Roundtables
+
Search every transcript
+
+
## Install
-Cockpit is early access: pre-1.0, and its releases are not yet signed with an Apple Developer ID ([CONTRIBUTING.md › Releases](CONTRIBUTING.md#releases)), so macOS blocks the first launch. Installing takes four steps, once:
+```bash
+curl -fsSL https://raw.githubusercontent.com/tashtit/cockpit/main/scripts/install.sh | sh
+```
-1. **Download** the disk image for your Mac from the [latest release](https://github.com/tashtit/cockpit/releases/latest) — `Cockpit--arm64.dmg` on Apple silicon, `Cockpit--x64.dmg` on Intel (About This Mac says which). Open it and drag Cockpit into Applications. Each release page carries the notes for that version.
-2. **First launch** — open Cockpit from Applications. macOS refuses: "Apple could not verify Cockpit.app is free of malware" on macOS 15 and later, "damaged" or "unidentified developer" before. Click **Done**, not Move to Trash — the download is fine; the quarantine flag the browser put on it is what Gatekeeper objects to.
-3. **Allow it** — open System Settings › Privacy & Security, scroll to the notice that Cockpit was blocked, click **Open Anyway** and confirm with your password or Touch ID. Or clear the flag from Terminal and open it again:
+or with Homebrew:
- ```bash
- xattr -d com.apple.quarantine /Applications/Cockpit.app
- ```
+```bash
+brew install --cask tashtit/tap/cockpit
+```
-4. **Updates** — you only do the three steps above once. From then on Cockpit checks GitHub Releases on launch and every few hours, fetches a newer build in the background and swaps it in the next time you quit, clearing the quarantine flag itself so the new version opens without another trip to Privacy & Security. **Settings › About** shows where it stands, offers **Restart now**, and holds both switches if you would rather do it by hand. A failed install puts the version you had back and says why.
+Either one installs the latest release for your Mac, checked against the SHA-256 digest GitHub recorded for it, and it opens straight away. Cockpit then keeps itself up to date. The [installer](scripts/install.sh) is plain `sh`; add `sh -s -- --dry-run` to see what it would do first.
-Every release asset carries a build-provenance attestation, so you can confirm a download is the file the release workflow produced before opening it:
+Prefer the disk image? It is on the [latest release](https://github.com/tashtit/cockpit/releases/latest). Releases are not signed with an Apple Developer ID yet, so macOS blocks a browser download's first launch once — [Getting started](https://tashtit.github.io/cockpit/guide/getting-started) walks through it.
-```bash
-gh attestation verify ~/Downloads/Cockpit--arm64.dmg --owner tashtit
-```
+**Needs** macOS 13 or later (Apple silicon or Intel) and at least one of Claude Code, Codex or Copilot CLI. The pull request features use the GitHub CLI (`gh`).
-## Run from source
+**Privacy.** No account and no telemetry. Everything Cockpit shows comes from files on your Mac and stays there. It goes online only for update checks and your pull requests (GitHub), to see whether your agent CLIs are current (the npm registry), and to reach any model provider you add yourself.
-Requires Node 24 (`.nvmrc`) and the npm 11 it bundles (pinned as `packageManager`).
+## Feedback
-```bash
-npm ci
-npm run dev # dev mode with HMR — first run downloads the Electron binary
-npm run typecheck # tsc (the static gate — there is no linter)
-npm test # vitest: unit + component tiers
-npm run test:e2e # Playwright against the built app (npm run build first)
-npm run package # macOS disk images into dist/ (unsigned without Apple credentials)
-```
+Cockpit is early, and what you tell us decides what comes next.
-CI (`.github/workflows/ci.yml`) runs typecheck plus all three test tiers, packages the app on every pull request, and cuts a [semantic release](CONTRIBUTING.md#releases) from `main`. Setup details and troubleshooting (including "Electron failed to install correctly") are in [CONTRIBUTING.md](CONTRIBUTING.md).
-
-## What it does (GitHub-first)
-
-- Auto-detects `~/.claude`, `~/.codex`, `~/.copilot` on first run and indexes all sessions found there, **grouped by git repository** (worktree-aware: sessions in linked worktrees group under their main repo; GitHub `owner/repo` is read from the origin remote). Non-repo sessions land in a flat "Chats" section at the bottom of the sidebar.
-- Compact **treeview sidebar**, flattened: one **`owner/repo` row per repository** with its sessions under it, ordered by last activity (paginated "more…", global search, per-repo archived section, agent-generated session names). The full index is never shipped to or rendered by the UI.
-- **Home — mission control**: a task composer front and center (repo + agent + account + permission mode, ⌘Enter to start), recent activity below. The sidebar stays the exhaustive list.
-- **AI Setup**: one place to manage the shared AI experience across all three agents.
- - **Shared instructions**: write one baseline (global, or per-repo) and fan it out into each agent's own instructions file (`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, `~/.copilot/copilot-instructions.md`; in repos `CLAUDE.md` + `AGENTS.md` — Codex and Copilot both read AGENTS.md natively). The shared text lives inside `` markers — the same block the agent-parity plugin manages; older `` markers are read too — and everything outside is that agent's own and never touched. Drift detection (in sync / out of date / not applied) with one-click re-apply, plus inline editing of each full file.
- - **MCP / skills / plugins / marketplaces** inventory, with one-click **MCP sharing** that translates a server definition into each agent's own config format (`~/.claude.json`, `~/.codex/config.toml`, `~/.copilot/mcp-config.json`) and **skill copying** between Claude and Copilot. Claude's per-project MCP servers (under `projects.*` in `~/.claude.json`) are inventoried too, labeled with their project.
-- **Per-agent session options**: model override for all agents, sandbox mode for Codex — validated main-side before touching argv.
-- **Agent accounts**: detects who each CLI is signed in as per config home (Claude `.claude.json` OAuth, Codex `auth.json` JWT, Copilot's native multi-account `config.json`) plus the `gh` user; identity chips appear throughout, and New Session lets you pick the account when a provider has several config homes.
-- **Subscription usage** (in Settings), without ever touching credentials: Claude measured locally from session JSONLs (5h block + trailing 7 days), Codex from the rate-limit snapshots its CLI persists, Copilot premium requests via the GitHub billing API.
-- **Fast by architecture**: only per-provider session roots are walked/watched (never `pkg/`, `repos/`, logs, or SQLite files); meta parsing reads at most 256KB per file (copilot's session.start line carries repo/branch/cwd); the stat-cache (mtime+size) persists to userData so restarts only re-parse changed files; scans yield to the event loop so IPC never blocks.
-- **Archiving**: sessions can be archived in-app (stored in cockpit config — provider logs have no such flag); archived sessions collapse into a dimmed per-repo section. Sessions archived or deleted in the provider's own app (Copilot's `data.db`, Codex's `archived_sessions/`, the Claude desktop app's session store) are hidden entirely.
-- **Settings** view: agent accounts & sources (add/remove extra per-account config homes, with per-source identity and health), a history window (show all sessions or just the last N days), subscription usage, and GitHub identity.
-- Watches source dirs — sessions you run in any terminal appear/update live.
-- Click a session → parsed transcript (messages, tool calls, results).
-- **Always worktrees, always PRs**: "+ New session" creates a `cockpit/` branch in an isolated git worktree (under the app's userData, outside your checkout) and runs the agent there. "Create PR" pushes the branch and runs `gh pr create`. PR state badges (open/draft/merged/closed, GitHub colors) come from `gh pr list`, cached 60s per repo.
-- **Notifications when an agent needs you**: a desktop notification (agent, session, a one-line outcome — click to open it), a macOS system sound, and a Dock badge counting sessions that landed and haven't been opened, when a turn finishes or fails or a roundtable concludes. Never for the session in front of a focused window; endings that arrive together share one notification. On in installed builds, off in dev and test runs (Settings › Notifications). macOS refuses notifications from unsigned builds — the sound plays and the Dock icon bounces instead.
-- **Review before landing**: "Changes" (⌘D) in a session swaps the transcript for the worktree's diff — branch vs base (with ahead/behind), staged, or unstaged incl. untracked files — unified or split, with line numbers. Pin notes to lines and send them to the agent as one message.
-- **Fix what the PR is waiting on**: with an open PR, the review leads with its failing checks, requested changes, unresolved threads (shown under their lines too) and conflicts; "Fix with " turns them into one prompt — failed-step logs included — ready in the composer.
-- **Working chat**: pick a provider + repo path → chat spawns the CLI headless (`claude -p --output-format stream-json`, `codex exec --json`, `copilot -p`) and streams replies, tool activity, and errors into the window. Multi-turn works via each provider's resume (`--resume` / `exec resume`). Opening an indexed session and typing continues that conversation.
-- Permission modes per chat: **Safe** (provider defaults; tools may be blocked in headless mode), **Auto-edit** (`--permission-mode acceptEdits` / `--full-auto`), **YOLO** (bypass approvals — trusted repos only).
-- Extra source dirs (isolated per-account config homes) are stored in the app config (`~/Library/Application Support/Cockpit/cockpit-config.json`) as `{path, provider, label}`.
-
-## Layout
+- Something broke → [Report a problem](https://github.com/tashtit/cockpit/issues/new?template=bug.yml)
+- A session is missing or shows the wrong thing → [Sessions missing or wrong](https://github.com/tashtit/cockpit/issues/new?template=sessions.yml)
+- Something it should do → [Suggest an idea](https://github.com/tashtit/cockpit/issues/new?template=idea.yml)
+- Anything else, or to show how you use it → [Discussions](https://github.com/tashtit/cockpit/discussions)
-```
-src/shared/types.ts domain vocabulary (SessionMeta, RepoInfo, …) — imports nothing from src/
-src/shared/contract.ts the whole renderer↔main IPC surface (CockpitApi, CH, PUSH)
-src/main/parsers/ per-provider session log parsers (failure-tolerant)
-src/main/repos.ts cwd → git repo resolution (worktree-aware, GitHub remote)
-src/main/indexer.ts scan + stat-cache + fs.watch(recursive) + repo grouping + paging
-src/main/extensions.ts MCP/skills/plugins inventory + cross-agent MCP/skill sharing
-src/main/instructions-core.ts shared-instructions pure logic (markers, drift, targets)
-src/main/instructions.ts shared-instructions IO (baseline storage + fan-out)
-src/main/github.ts PR status per repo via `gh pr list` (cached)
-src/main/workspace.ts worktree/branch creation + push/`gh pr create`
-src/main/chat.ts ChatManager: spawn provider CLIs, parse stream events
-src/main/attention-core.ts which turn endings are news: landings, Dock badge count, notification bursts (IO-free)
-src/main/attention.ts notifications, system sounds, Dock badge + bounce (Electron), landings persisted to userData
-src/main/accounts.ts who each agent CLI is signed in as, per config home + `gh` user
-src/main/usage.ts subscription usage per provider (local measurement / CLI snapshots / GitHub billing API)
-src/main/provider-archived.ts sessions archived/deleted in the provider's own app → hidden
-src/main/env.ts PATH fix for GUI-launched CLI spawns (macOS)
-src/main/updates.ts app updates from GitHub Releases (electron-updater; installed builds only)
-src/main/config.ts source-dir registry + history window
-src/main/index.ts electron bootstrap + IPC
-src/preload/index.ts contextBridge → window.cockpit
-src/renderer/ React UI (HomeView, TreeSidebar, ChatView, NewSession, AiSetup, Settings, Select, logos.tsx)
-```
+Inside the app, **Settings › About › Feedback** opens the same forms with your Cockpit, macOS and agent CLI versions already filled in.
-## Notes
+## Build from source
-- File watching uses Node's `fs.watch(root, {recursive: true})` (FSEvents on macOS) — chokidar was dropped after its bundled `fsevents` native module broke on the Electron 43 upgrade; the indexer does its own debouncing and stat-based dirty tracking.
+Node 24 (`.nvmrc`), then:
+
+```bash
+npm ci
+npm run dev
+```
-- Session log formats are provider-internal and drift between releases; parsers skip anything they can't read rather than fail.
-- The Copilot parser is best-effort (least documented format). If your sessions don't show up, open an issue-to-self: grab one file from `~/.copilot` and adjust `src/main/parsers/copilot.ts`.
-- No SQLite yet on purpose — in-memory index is plenty for M1 and avoids native-module rebuild pain. Revisit at M6 (full-text search).
-- Copilot chat streams plain text (no structured events), so a *new* Copilot chat doesn't learn its session id mid-conversation — the session appears in the sidebar after the first turn; click it to continue with proper resume. Claude/Codex bind their session id from the first response.
-- Codex event stream shapes changed between releases; both the old (`msg.type`) and new (`thread.started`/`item.completed`) shapes are handled.
+[CONTRIBUTING.md](CONTRIBUTING.md) covers the test tiers, packaging, releases and how the code is laid out; [AGENTS.md](AGENTS.md) is the architecture in depth.
## License
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index 3e82e9cb..dcae50bf 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -4,13 +4,26 @@ export default defineConfig({
title: 'Cockpit',
description:
'Unified desktop hub for Claude Code, Codex, and GitHub Copilot CLI — every session, one window.',
+ // published by .github/workflows/docs.yml to GitHub Pages, which serves a project
+ // site under the repository's name; `npm run docs:dev` serves it there too
+ base: '/cockpit/',
// the app is dark-only; the docs follow it
appearance: 'force-dark',
- head: [['link', { rel: 'icon', type: 'image/png', href: '/logo.png' }]],
+ // head tags are written as given — only themeConfig and markdown links get the base
+ head: [
+ ['link', { rel: 'icon', type: 'image/png', href: '/cockpit/logo.png' }],
+ // the card link previews show; made by `npm run ui:readme` with the README's pictures
+ ['meta', { property: 'og:image', content: 'https://tashtit.github.io/cockpit/readme/social-preview.png' }],
+ ['meta', { property: 'og:image:width', content: '1280' }],
+ ['meta', { property: 'og:image:height', content: '640' }],
+ ['meta', { name: 'twitter:card', content: 'summary_large_image' }]
+ ],
+ sitemap: { hostname: 'https://tashtit.github.io/cockpit/' },
themeConfig: {
logo: '/logo.png',
nav: [
{ text: 'Guide', link: '/guide/what-is-cockpit', activeMatch: '/guide/' },
+ { text: 'Download', link: 'https://github.com/tashtit/cockpit/releases/latest' },
{
text: 'Contributing',
link: 'https://github.com/tashtit/cockpit/blob/main/CONTRIBUTING.md'
diff --git a/docs/guide/what-is-cockpit.md b/docs/guide/what-is-cockpit.md
index 98b86c84..4ada71c7 100644
--- a/docs/guide/what-is-cockpit.md
+++ b/docs/guide/what-is-cockpit.md
@@ -2,6 +2,8 @@
Cockpit is a macOS desktop hub for the three big coding agents — **Claude Code**, **Codex**, and **GitHub Copilot CLI**. If you use more than one of them, your work is scattered across three home directories, three session formats, and three configuration systems. Cockpit puts all of it in one window: browse every session across providers, continue any conversation, start new agent runs in isolated worktrees, and manage the shared AI setup once instead of three times.
+
+
## The problem it solves
Each agent CLI keeps its own world:
diff --git a/docs/public/readme/chat.png b/docs/public/readme/chat.png
new file mode 100644
index 0000000000000000000000000000000000000000..c498f8de2c1ecc5273d7dc7c286e35c4e7bb0ebf
GIT binary patch
literal 259378
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