diff --git a/.github/workflows/linux-packages.yml b/.github/workflows/linux-packages.yml
index cd737c4..847dbea 100644
--- a/.github/workflows/linux-packages.yml
+++ b/.github/workflows/linux-packages.yml
@@ -11,7 +11,8 @@ concurrency:
group: linux-packages-${{ github.ref }}
cancel-in-progress: true
jobs:
- ubuntu:
+ # Rust tests run as a normal user; containers run as root, which masks permission checks.
+ tests:
runs-on: ubuntu-24.04
timeout-minutes: 35
steps:
@@ -23,8 +24,8 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- - name: Install build and smoke-test dependencies
- run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential libssl-dev libayatana-appindicator3-dev librsvg2-dev patchelf restic rpm libarchive-tools xvfb dbus-x11 libnotify-bin
+ - name: Install build dependencies
+ run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential libssl-dev libayatana-appindicator3-dev librsvg2-dev
- name: Install checksum-pinned Restic for restore integration tests
run: |
curl -fsSL https://github.com/restic/restic/releases/download/v0.19.1/restic_0.19.1_linux_amd64.bz2 -o /tmp/restic-ci.bz2
@@ -35,23 +36,18 @@ jobs:
- run: npm ci
- run: npm run check && npm test && npm run test:rust
- run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings
- - run: npm run desktop:package
- - run: python3 scripts/verify-packages.py
- - name: Install Debian package
- run: sudo apt-get install -y ./artifacts/release/*.deb
- - name: Launch installed application on a virtual display
- run: bash scripts/smoke-desktop.sh /usr/bin/command-center
- - uses: actions/upload-artifact@v4
- with:
- name: ubuntu-packages
- path: artifacts/release/*
- fedora:
+
+ # Build once on the oldest supported base so one binary serves every distribution.
+ # Ubuntu 22.04 provides glibc 2.35, the floor declared in tauri.conf.json.
+ build:
runs-on: ubuntu-24.04
- container: fedora:43
- timeout-minutes: 35
+ container: ubuntu:22.04
+ timeout-minutes: 40
+ env:
+ DEBIAN_FRONTEND: noninteractive
steps:
- - name: Install tools
- run: dnf install -y git nodejs npm rust cargo gcc gcc-c++ pkgconf-pkg-config openssl-devel webkit2gtk4.1-devel libappindicator-gtk3-devel librsvg2-devel rpm-build python3 binutils bsdtar xorg-x11-server-Xvfb xorg-x11-xauth dbus-daemon shadow-utils libnotify restic
+ - name: Install build tools
+ run: apt-get update && apt-get install -y curl ca-certificates git build-essential pkg-config file libssl-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf python3 binutils libarchive-tools
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
@@ -60,11 +56,74 @@ jobs:
- run: npm ci && npm run check && npm test
- run: npm run desktop:package
- run: python3 scripts/verify-packages.py
- - name: Install RPM and create smoke-test user
- run: dnf install -y ./artifacts/release/*.rpm && useradd -m smoke
- - name: Launch installed application as a normal user
- run: runuser -u smoke -- bash scripts/smoke-desktop.sh /usr/bin/command-center
- uses: actions/upload-artifact@v4
with:
- name: fedora-packages
+ name: packages
path: artifacts/release/*
+ if-no-files-found: error
+
+ # Install the same packages on each supported family and launch them as a normal user.
+ install-deb:
+ needs: build
+ runs-on: ubuntu-24.04
+ strategy:
+ fail-fast: false
+ matrix:
+ image: ["ubuntu:22.04", "debian:12", "ubuntu:24.04"]
+ container: ${{ matrix.image }}
+ timeout-minutes: 20
+ env:
+ DEBIAN_FRONTEND: noninteractive
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/download-artifact@v4
+ with:
+ name: packages
+ path: packages
+ - name: Verify checksums
+ run: cd packages && sha256sum --check SHA256SUMS
+ - name: Install package and launch-test tools
+ run: apt-get update && apt-get install -y ./packages/*.deb xvfb xauth dbus dbus-x11 libnotify-bin
+ - name: Launch installed application as a normal user
+ run: useradd -m smoke && runuser -u smoke -- bash scripts/smoke-desktop.sh /usr/bin/command-center
+
+ install-rpm:
+ needs: build
+ runs-on: ubuntu-24.04
+ container: fedora:43
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/download-artifact@v4
+ with:
+ name: packages
+ path: packages
+ - name: Verify checksums
+ run: cd packages && sha256sum --check SHA256SUMS
+ - name: Install package and launch-test tools
+ run: dnf install -y ./packages/*.rpm xorg-x11-server-Xvfb xorg-x11-xauth dbus-daemon shadow-utils libnotify
+ - name: Launch installed application as a normal user
+ run: useradd -m smoke && runuser -u smoke -- bash scripts/smoke-desktop.sh /usr/bin/command-center
+
+ # Build the Arch recipe from this commit in a clean container, lint it, install it and launch it.
+ arch:
+ runs-on: ubuntu-24.04
+ container: archlinux:base-devel
+ timeout-minutes: 45
+ steps:
+ - name: Install build, lint and launch-test tools
+ run: pacman -Syu --noconfirm --needed git nodejs npm rust webkit2gtk-4.1 gtk3 libappindicator-gtk3 namcap xorg-server-xvfb xorg-xauth dbus
+ - uses: actions/checkout@v4
+ - name: Build package from the checked-out commit
+ run: |
+ useradd -m builder
+ git config --system --add safe.directory '*'
+ mkdir /build && cp packaging/aur/PKGBUILD /build/
+ # The published recipe builds a release tag; CI builds this commit instead.
+ sed -i "s|^source=.*|source=(\"command-center::git+file://$GITHUB_WORKSPACE#commit=$GITHUB_SHA\")|" /build/PKGBUILD
+ chown -R builder /build
+ cd /build && runuser -u builder -- makepkg --noconfirm
+ - name: Lint recipe and package
+ run: cd /build && namcap PKGBUILD && namcap ./*.pkg.tar.zst
+ - name: Install package and launch as a normal user
+ run: pacman -U --noconfirm /build/*.pkg.tar.zst && runuser -u builder -- bash scripts/smoke-desktop.sh /usr/bin/command-center
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4d31f86..25b4550 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,17 @@
Release downloads and full notes are available on [GitHub Releases](https://github.com/Commanderx-code/command-center/releases).
+## [0.6.0](https://github.com/Commanderx-code/command-center/releases/tag/v0.6.0) — Unreleased
+
+- Added **Merge** setup imports: keep this machine's preferences and definitions, add new ones, fill empty integration paths, and list differing items that were kept. **Replace** remains available.
+- Setup imports keep a journal; an import interrupted by a crash or power loss is rolled back from its backup at the next launch and reported on the dashboard.
+- Jobs now run concurrently when they use different resources: repository jobs and project tasks in different repositories, plus one workstation task. Each job is stopped individually, waiting jobs name what they wait for, and the tray shows the running count.
+- Packages are built on Ubuntu 22.04 and require glibc 2.35+ (was 2.39). CI install-tests them on Ubuntu 22.04/24.04, Debian 12, and Fedora 43, and builds, lints, installs, and launches the Arch recipe in a clean container. Package validation now checks the binary's real glibc needs.
+- `release:draft` publishes the CI-built, install-tested packages and links the workflow run in the notes.
+- Toolbox favorites moved from webview storage to app data (migrated on first launch) and are part of the import transaction.
+- File replacements now sync their directory for durability. Repository Details disables actions only while that repository has a running job.
+- Internal: configuration editor split out of `features.js`; removed a stale v0.1.0 archive from the repository.
+
## [0.5.1](https://github.com/Commanderx-code/command-center/releases/tag/v0.5.1) — 2026-09-24
Security release. All 0.5.0 users should upgrade.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..7f37ba7
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Commanderx-code
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 426df97..0171c51 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,7 @@
+
@@ -45,6 +46,15 @@ Command Center brings [Commander Toolbox](https://github.com/Commanderx-code/com
The app runs as your normal user. Commands are reviewed before execution; Toolbox scripts retain their own privilege checks and confirmations. Integration paths are editable in **Settings**. See the [user guide](docs/user-guide.md) for exact behavior and limitations.
+## New in 0.6.0
+
+- Setup imports can merge with this machine's setup, and an import interrupted by a crash or power loss is rolled back automatically on the next launch.
+- Jobs in different repositories run at the same time, alongside one workstation task.
+- Packages now run on glibc 2.35+ and are install-tested on Ubuntu 22.04/24.04, Debian 12, and Fedora 43. The Arch recipe is built and linted in CI.
+- Toolbox favorites are saved with the app's data instead of webview storage.
+
+Read [Setup and project workspaces](docs/setup-and-workspaces.md) and [Running several jobs](docs/user-guide.md#running-several-jobs).
+
## New in 0.5.0
- Portable setup bundles with a full import preview, home-path remapping, and backups before replacement.
@@ -69,12 +79,12 @@ Read [Workflows, profiles, and recovery verification](docs/operations.md) for se
**[Get the latest release →](https://github.com/Commanderx-code/command-center/releases/latest)**
-| Package | Download v0.5.1 | Install the downloaded file |
+| Package | Download v0.6.0 | Install the downloaded file |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
-| Debian / Ubuntu | [`.deb` · amd64](https://github.com/Commanderx-code/command-center/releases/download/v0.5.1/command-center_0.5.1_amd64.deb) | `sudo apt install ./command-center_0.5.1_amd64.deb` |
-| Fedora / RPM | [`.rpm` · x86_64](https://github.com/Commanderx-code/command-center/releases/download/v0.5.1/command-center-0.5.1-1.x86_64.rpm) | `sudo dnf install ./command-center-0.5.1-1.x86_64.rpm` |
+| Debian / Ubuntu | [`.deb` · amd64](https://github.com/Commanderx-code/command-center/releases/download/v0.6.0/command-center_0.6.0_amd64.deb) | `sudo apt install ./command-center_0.6.0_amd64.deb` |
+| Fedora / RPM | [`.rpm` · x86_64](https://github.com/Commanderx-code/command-center/releases/download/v0.6.0/command-center-0.6.0-1.x86_64.rpm) | `sudo dnf install ./command-center-0.6.0-1.x86_64.rpm` |
-Version 0.5.1 packages require **Linux x86_64, glibc 2.39+, GTK 3, and WebKitGTK 4.1**. Release notes identify the build environment and completed distribution checks. Release assets include `SHA256SUMS` for verification.
+Version 0.6.0 packages require **Linux x86_64, glibc 2.35+, GTK 3, and WebKitGTK 4.1**. They are install-tested on Ubuntu 22.04 and 24.04, Debian 12, and Fedora 43; the release notes link the workflow run. Release assets include `SHA256SUMS` for verification.
For Arch/Garuda, build and install from source using the [installation guide](docs/installation.md#from-source-on-archgaruda).
@@ -113,3 +123,7 @@ Open `http://127.0.0.1:4173` for a browser preview with sample data. Desktop ope
## Built with
[Tauri](https://github.com/tauri-apps/tauri) provides the desktop shell, Rust handles local system operations, and JavaScript renders the interface. The Toolbox catalog and scripts come from [Commander Toolbox](https://github.com/Commanderx-code/commander-toolbox), built on Linutil. [xterm.js](https://github.com/xtermjs/xterm.js) and [portable-pty](https://github.com/wezterm/wezterm) power the embedded terminal.
+
+## License
+
+Command Center is released under the [MIT License](LICENSE). Bundled third-party components keep their own licenses; see [THIRD_PARTY.md](THIRD_PARTY.md).
diff --git a/command-center-v0.1.0.tar.gz b/command-center-v0.1.0.tar.gz
deleted file mode 100644
index 4c7de73..0000000
Binary files a/command-center-v0.1.0.tar.gz and /dev/null differ
diff --git a/docs/development.md b/docs/development.md
index 6346403..05fd634 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -57,7 +57,7 @@ npm run desktop:package
Tauri writes `.deb` and `.rpm` packages under `src-tauri/target/release/bundle/`. Keep versions aligned in `package.json`, `package-lock.json`, `src-tauri/Cargo.toml`, `src-tauri/Cargo.lock`, and `src-tauri/tauri.conf.json` when preparing a version bump.
-The v0.4.0 release packages declare the build's glibc 2.39 minimum. For a future build, inspect its actual runtime requirements before choosing the package dependency floor. Verify package metadata and payloads, publish SHA-256 checksums, and record the build environment and any distribution testing in the release notes.
+Release packages come from CI, which builds on Ubuntu 22.04 and declares a glibc 2.35 floor. A local `desktop:package` build on a newer distribution links against its newer glibc, so `scripts/verify-packages.py` correctly rejects it as a release candidate. See [Releases](releases.md#package-validation-and-distribution-ci).
## Documentation screenshots
diff --git a/docs/installation.md b/docs/installation.md
index 08a1c83..f40e899 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -6,7 +6,7 @@
Download a package and `SHA256SUMS` from the [GitHub releases page](https://github.com/Commanderx-code/command-center/releases/latest).
-The v0.5.1 packages target **Linux x86_64 / amd64** and require **glibc 2.39 or newer, GTK 3, and WebKitGTK 4.1**. Packages declare these dependencies. The release workflow builds the Debian package on Ubuntu 24.04 and the RPM on Fedora 43, installs each matching package, and performs a desktop launch check. See the release notes for the successful workflow run and exact validation results. ARM, Windows, and macOS packages are not currently published.
+Packages target **Linux x86_64 / amd64** and require **GTK 3, WebKitGTK 4.1, and glibc 2.35 or newer** from 0.6.0 (0.5.x packages require glibc 2.39). Packages declare these dependencies. Both packages are built once on Ubuntu 22.04, then installed and launched on Ubuntu 22.04, Debian 12, Ubuntu 24.04, and Fedora 43. See the release notes for the workflow run. ARM, Windows, and macOS packages are not currently published.
To verify a downloaded package, put it and `SHA256SUMS` in the same directory and run:
@@ -19,13 +19,13 @@ Each downloaded package must report `OK`. The `--ignore-missing` option lets you
On a compatible Debian/Ubuntu system:
```bash
-sudo apt install ./command-center_0.5.1_amd64.deb
+sudo apt install ./command-center_0.6.0_amd64.deb
```
On a compatible Fedora/RPM system:
```bash
-sudo dnf install ./command-center-0.5.1-1.x86_64.rpm
+sudo dnf install ./command-center-0.6.0-1.x86_64.rpm
```
Launch **Command Center** from your application menu. Run the app as your normal user, without `sudo`.
diff --git a/docs/operations.md b/docs/operations.md
index eaef2d7..871a28b 100644
--- a/docs/operations.md
+++ b/docs/operations.md
@@ -8,7 +8,7 @@ Open **Workflows**, create a workflow, and add steps in order. Available steps i
**Start workflow** opens the progress panel. **Review next step** uses the normal command preview and execution system. Each successful step enables review of the next one; it does not approve or execute subsequent steps automatically. Interactive steps open the embedded terminal. Failures, cancellation, and timeouts stop the sequence. Inspect Activity before retrying a step: an unsuccessful command may already have made partial changes.
-**Stop sequence** prevents additional steps. Stop an already running command from Activity or its terminal. Only one command runs at a time across the app. On restart, an unfinished sequence is marked interrupted and requires review before retrying. Workflow progress is stored in this webview's local storage; definitions are stored in the app's private `operations.json` file.
+**Stop sequence** prevents additional steps. Stop an already running command from Activity or its terminal. Workflow steps are workstation tasks, so only one runs at a time; repository jobs in other folders can run alongside them. On restart, an unfinished sequence is marked interrupted and requires review before retrying. Workflow progress is stored in this webview's local storage; definitions are stored in the app's private `operations.json` file.
The weekly maintenance example checks local backup-drive readiness, runs the personal helper, checks the configured Restic repository, then reports disk usage and failed services. Add your chosen updater explicitly if desired. The readiness step requires a successful health report with `drive_mounted: true`; remote backups or different helper formats should use an appropriate custom preflight instead. The backup helper and Restic browser can use different repositories; configure and verify each.
diff --git a/docs/release-notes.md b/docs/release-notes.md
index 477d3f0..ffaa7ac 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -1,29 +1,30 @@
-Command Center 0.5.1 is a security release. **All 0.5.0 users should upgrade.**
+Command Center 0.6.0 makes setup imports safer, runs independent jobs side by side, and brings the packages to older distributions.
-### Security fixes
+### What's new
-- **Repository inspection no longer runs repository-configured programs.** In 0.5.0, opening or scanning a repository could run programs named in that repository's Git configuration (hooks, `core.fsmonitor`, clean/smudge/process filters, external diff or text conversion, signature verifiers, and promisor fetch transports). A copied or extracted repository with a crafted `.git/config` could therefore run commands without review. Automatic status, diff, and history previews now disable all of these. Reviewed Git actions such as staging and committing still use your normal hooks, filters, identity, and signing.
-- **Imports can no longer authorize the backup health helper.** The health helper runs automatically for dashboard and backup checks. Setup bundles and settings imports now always keep this machine's configured helper (including an empty value) instead of taking the path from the imported file. Configure it separately in Settings after reviewing the executable.
+- **Merge setup imports:** choose **Merge** (the default) to keep this machine's preferences and saved definitions and add the bundle's new workflows, machine profiles, personal tools, quick actions, workspace profiles, scan folders, and Toolbox favorites. Empty integration paths are filled; different items whose IDs already exist here are kept and listed in the preview. **Replace** works as before.
+- **Interrupted imports recover automatically:** imports now keep a journal. If the app or computer stops mid-import, the next launch restores every file from the pre-import backup before the interface opens and tells you on the dashboard.
+- **Run several jobs:** repository actions and project tasks in different repositories run at the same time, alongside one workstation task such as a backup, installer, or Home Manager switch. Jobs in the same folder, and embedded-terminal sessions, still take turns; a waiting job names the job it is waiting for. Stop each job from Activity.
+- **Wider distribution support:** packages now require glibc 2.35+ instead of 2.39, adding Ubuntu 22.04 and Debian 12. Every package is install- and launch-tested on Ubuntu 22.04, Debian 12, Ubuntu 24.04, and Fedora 43, and the Arch recipe is built, linted, and launched in a clean Arch container.
+- **Favorites saved with your data:** Toolbox favorites move from webview storage into the app's data folder on first launch and are included transactionally in setup imports.
-### Behavior changes
+### Behavior to know
-- Files that a content filter normally normalizes may appear changed in previews. Use the repository terminal for a filter-aware comparison.
-- Nested submodule working-file changes are omitted from previews; changed submodule commits remain visible. Open a submodule directly to inspect its files.
-- Partial clones with missing objects need an explicit fetch in the terminal before inspection.
-- Unsupported or oversized filter configuration makes inspection unavailable instead of running it.
-
-No other features changed. See the [0.5.0 notes](https://github.com/Commanderx-code/command-center/releases/tag/v0.5.0) for setup bundles and project workspaces.
+- Merge never changes preferences such as theme, editor, or terminal, and never replaces a non-empty integration path. Use **Replace** to adopt another machine's setup wholesale.
+- The backup health helper, password-file, and wallet settings always stay local, as in 0.5.1.
+- Automatic recovery only restores the files an import writes. If it cannot finish, the dashboard shows the reason and the manual steps in the [setup guide](https://github.com/Commanderx-code/command-center/blob/v0.6.0/docs/setup-and-workspaces.md).
+- Two jobs never share a working directory, so a project task and a Git action in the same repository still run one after the other.
### Linux downloads
-The `.deb` and `.rpm` assets target **x86_64** and require **glibc 2.39+, GTK 3, WebKitGTK 4.1, and the platform's AppIndicator library**. Notifications additionally use `notify-send`. File restores require Restic 0.17+.
+The `.deb` and `.rpm` assets target **x86_64** and require **glibc 2.35+, GTK 3, WebKitGTK 4.1, and the platform's AppIndicator library**. Notifications additionally use `notify-send`. File restores require Restic 0.17+.
```sh
# Debian / Ubuntu
-sudo apt install ./command-center_0.5.1_amd64.deb
+sudo apt install ./command-center_0.6.0_amd64.deb
# Fedora / RPM
-sudo dnf install ./command-center-0.5.1-1.x86_64.rpm
+sudo dnf install ./command-center-0.6.0-1.x86_64.rpm
```
Download `SHA256SUMS` beside the package and run:
@@ -32,4 +33,4 @@ Download `SHA256SUMS` beside the package and run:
sha256sum --check --ignore-missing SHA256SUMS
```
-Source installs can update with the exported updater: `bash ~/Downloads/update-desktop.sh v0.5.1`. A local Arch build recipe is included under `packaging/aur/`.
+Arch users can build `packaging/aur/PKGBUILD` with `makepkg -si`.
diff --git a/docs/releases.md b/docs/releases.md
index d606d92..78c4f06 100644
--- a/docs/releases.md
+++ b/docs/releases.md
@@ -5,7 +5,7 @@
Open Settings → About & updates. Check for releases, read the notes, then export the source updater. Close Command Center and run the displayed command, for example:
```sh
-bash ~/Downloads/update-desktop.sh v0.5.1
+bash ~/Downloads/update-desktop.sh v0.6.0
```
The helper requires Linux build dependencies, Git, Node/npm, and Rust/Cargo. Run as your normal user, without sudo. It asks you to type the tag, downloads that tag into a temporary checkout, checks its package version, installs dependencies, runs JavaScript and Rust tests, then builds and installs the desktop app. A failed check stops installation. It does not modify your project checkout or app settings. The GitHub tag must already exist; the release checker only advertises published stable releases. Offline/API errors are shown without claiming that the installed version is current. GitHub is contacted only when you request a release check or open its release page.
@@ -25,21 +25,37 @@ This rolls back the binary only, not settings or user data. It applies to the no
Keep package.json, package-lock.json, Cargo.toml, Cargo.lock, and tauri.conf.json versions aligned. Update docs/release-notes.md and CHANGELOG.md. Commit and push the reviewed source, then create and push an annotated tag matching the version:
```sh
-git tag -a v0.5.1 -m 'Command Center v0.5.1'
-git push origin v0.5.1
-npm run release:draft -- v0.5.1
+git tag -a v0.6.0 -m 'Command Center v0.6.0'
+git push origin v0.6.0
+npm run release:draft -- v0.6.0
```
-The draft command requires an authenticated GitHub CLI (`gh`). It checks for a clean working tree and a matching local/remote tag, runs checks, tests, and the package build, then creates an **unpublished** GitHub release using docs/release-notes.md. It attaches validated `.deb` and `.rpm` packages plus `SHA256SUMS`. It does not push tags or publish the draft. Review and publish on GitHub after testing the app on your machine. GitHub supplies source archives automatically; this workflow does not claim that a build on Garuda is portable across Linux distributions.
+The draft command requires an authenticated GitHub CLI (`gh`). It checks for a clean working tree and a matching local/remote tag and runs the checks and tests. It then finds the successful **Linux packages** run for the tagged commit, downloads that run's `packages` artifact, verifies its `SHA256SUMS`, and creates an **unpublished** GitHub release. The release notes are docs/release-notes.md plus a build-and-validation section linking the workflow run. If CI has not passed for the tag, no draft is created. The command never builds release packages locally: a build on a newer distribution such as Garuda would require a newer glibc than the packages declare. It does not push tags or publish the draft. Download and verify the hosted assets and test the app on your machine before publishing.
The app discovers only published releases. Building a package, creating a tag, or preparing a draft does not publish a release.
## Package validation and distribution CI
-The **Linux packages** workflow builds on Ubuntu 24.04 and Fedora 43, validates package metadata and ELF payloads, installs the matching package, and runs a 20-second launch check under a virtual display as an ordinary user. Artifacts are retained by GitHub Actions. This is an installation/launch check, not an end-to-end validation of system-changing workflows.
+The **Linux packages** workflow runs these jobs:
-Run `python3 scripts/verify-packages.py` after a local package build to stage named assets and checksums in `artifacts/release/`. Release binaries must have runtime requirements compatible with their declared dependency floor. Hosted release downloads should be downloaded and verified before publication.
+| Job | What it does |
+| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `tests` | JavaScript and Rust tests and clippy as a normal user on Ubuntu 24.04. |
+| `build` | Builds the `.deb` and `.rpm` once in an `ubuntu:22.04` container (glibc 2.35), validates them, and uploads the `packages` artifact with `SHA256SUMS`. |
+| `install-deb` | Installs that `.deb` on Ubuntu 22.04, Debian 12, and Ubuntu 24.04 and runs a 20-second launch check under a virtual display as an ordinary user. |
+| `install-rpm` | Installs that `.rpm` on Fedora 43 and runs the same launch check. |
+| `arch` | Builds `packaging/aur/PKGBUILD` from the pushed commit in a clean `archlinux` container, lints the recipe and package with namcap, installs, and launches it. |
-For distribution-tested release assets, download the artifacts from a successful **Linux packages** run for the exact release commit. Use the `.deb` from `ubuntu-packages` and the `.rpm` from `fedora-packages`, verify each artifact's checksums, and generate a new `SHA256SUMS` for that selected pair. Attach them to a draft release, download and verify the hosted assets, then publish. Record the workflow URL and validation results in the release notes.
+These are installation and launch checks, not end-to-end validation of system-changing workflows.
-`packaging/aur/PKGBUILD` is a local Arch build recipe pinned to the release tag. Run `makepkg -si` from a copy of that directory after the tag is published. This recipe has not been submitted to AUR and has not been validated in a clean Arch chroot. The project license remains unchanged.
+`scripts/verify-packages.py` checks package metadata and payloads, then compares the glibc floor declared in `tauri.conf.json` with the highest glibc symbol version the binary actually requires. It fails if the binary needs a newer glibc than the packages declare. It then stages named assets and checksums in `artifacts/release/`. To raise or lower the floor, change the build container and both `depends` entries together.
+
+## Arch User Repository
+
+`packaging/aur/PKGBUILD` builds the release tag, and `packaging/aur/.SRCINFO` is generated from it. CI validates the recipe on every push. To publish or update the AUR package:
+
+1. After the release tag is published and the `arch` job passed for it, bump `pkgver` (and reset `pkgrel=1`) if not done already, then regenerate the metadata: `cd packaging/aur && makepkg --printsrcinfo > .SRCINFO`.
+2. Clone the AUR repository with an account that has an SSH key registered on aur.archlinux.org: `git clone ssh://aur@aur.archlinux.org/command-center.git`. For the first upload this creates the package.
+3. Copy `PKGBUILD` and `.SRCINFO` into that clone, commit, and push.
+
+The recipe declares the project's MIT license and installs `LICENSE` under `/usr/share/licenses/command-center/`. Add a `# Maintainer: Name ` line at the top of `PKGBUILD` before the first AUR upload.
diff --git a/docs/setup-and-workspaces.md b/docs/setup-and-workspaces.md
index 80bfee9..0256d76 100644
--- a/docs/setup-and-workspaces.md
+++ b/docs/setup-and-workspaces.md
@@ -1,6 +1,6 @@
# Setup bundles, onboarding, and project workspaces
-These features are available in **Command Center 0.5.0** and later.
+These features are available in **Command Center 0.5.0** and later. Merge imports and automatic recovery of interrupted imports require **0.6.0**.
## Move a setup between machines
@@ -10,11 +10,20 @@ It does not copy repository contents, backups, credential files, activity logs,
On the destination, choose **Import setup bundle**, then set the destination home folder. The preview rewrites the source home prefix and `~/` in structured paths, including scan roots, working directories, integration paths, workflow paths, and workspace keys. It leaves other absolute paths, URLs, command text, and arbitrary input values unchanged. Review those values for machine-specific references. Missing repositories are remembered for discovery but are not cloned; use a reviewed machine-profile clone step if needed.
-Import **replaces** the displayed collections; it does not merge them. A replacement checkbox is required, and editing the home folder invalidates the preview and checkbox. The backend validates the bundle again when applying it. Unknown bundle versions, invalid definitions, path collisions, and files over 2 MB are rejected. Current local password-file/wallet connections are retained. A local Restic path in the bundle replaces the current repository path; an omitted remote connection keeps the current connection.
+Choose an import method:
-No imported command runs automatically. Both setup bundles and preference imports retain this machine’s existing backup health helper, including an empty value. Configure that automatic helper separately in Settings after reviewing its executable. The app reloads after importing, refreshes Toolbox favorites, clears old workflow progress, and offers setup review on the dashboard. A running job blocks import.
+- **Merge** (default) keeps this machine's preferences and every saved definition. It adds workflows, machine profiles, personal tools, quick actions, and workspace profiles whose IDs (or repository paths) are new here, adds new scan folders and Toolbox favorites, and fills integration paths that are empty on this machine. When the bundle has a different item with an ID that already exists here, the local item is kept and listed under **Already on this machine and kept unchanged**. Identical items are not listed. A merge that would exceed a collection limit (for example, 30 workflows) is rejected.
+- **Replace** swaps this machine's saved settings, workflows, machine profiles, personal tools, workspace profiles, and Toolbox favorites for the bundle's.
-Before replacing files, the app saves the previous bytes to a private `setup-backups/before-import-.json` file under its local app-data directory. Settings shows that path after reload. Ordinary write failures roll back completed replacements. A crash or power loss during a multi-file import can require manual recovery from this backup; the import is not an atomic filesystem transaction. Backups contain pairs of original absolute paths and byte arrays (`null` means the file did not exist). To recover manually, close the app and restore the recorded bytes to the corresponding files after inspecting the paths. Retain these private backups locally.
+The preview shows the exact result that will be saved. A confirmation checkbox is required. Editing the home folder or changing the method refreshes the preview and clears the checkbox. The backend validates the bundle again when applying it and rejects the import if this machine's saved setup changed after the preview. Unknown bundle versions, invalid definitions, path collisions, and files over 2 MB are rejected. Current local password-file/wallet connections are retained. A local Restic path in the bundle replaces the current repository path; an omitted remote connection keeps the current connection.
+
+No imported command runs automatically. Both setup bundles and preference imports retain this machine’s existing backup health helper, including an empty value. Configure that automatic helper separately in Settings after reviewing its executable. The app reloads after importing, clears old workflow progress, and offers setup review on the dashboard. A running job blocks import.
+
+Before replacing files, the app saves the previous bytes to a private `setup-backups/before-import-.json` file under its local app-data directory, then records that backup in `setup-import-journal.json`. Settings shows the backup path after reload. Ordinary write failures roll back completed replacements immediately.
+
+If the app, session, or computer stops during an import, the journal remains. On the next launch, before the interface opens, Command Center restores every file from the recorded backup and removes the journal. The dashboard then reports that the interrupted import was rolled back; import the bundle again when ready. Recovery restores only the files an import writes (settings, workflows and profiles, workspace profiles, Toolbox favorites, and import state) and refuses a backup that names any other file.
+
+If automatic recovery cannot complete, the journal is kept and the dashboard shows the reason on each launch. To recover manually, close the app, inspect the backup (pairs of original absolute paths and byte arrays; `null` means the file did not exist), restore the recorded bytes to those files, then delete `setup-import-journal.json`. Retain these private backups locally.
The older **Export saved settings** option remains available for preference-only transfers. Setup bundles use a separate, versioned format.
@@ -35,7 +44,7 @@ Choose **Repositories → Workspace** on a repository card. The workspace adds:
- **Open workspace:** uses the saved editor, terminal, and documentation launch selections. The organization/launch form remains below Git details.
- **Detect project tasks:** reads `package.json` for build/test/dev/lint/check/start scripts and detects Cargo build/test/check/run tasks from `Cargo.toml`. Detection never executes scripts. Add suggestions individually; npm tasks use `npm run`, including any configured pre/post hooks when eventually executed. Other package managers and nested packages can use custom tasks.
-- **Custom tasks:** a name, command, shell (direct arguments, Fish, or Bash), and embedded/background/external terminal mode. Tasks always run from this repository's root. Every run uses command review, rechecks the saved definition, and appears in Activity. Long-running development servers can be stopped there. The existing one-running-job limit applies.
+- **Custom tasks:** a name, command, shell (direct arguments, Fish, or Bash), and embedded/background/external terminal mode. Tasks always run from this repository's root. Every run uses command review, rechecks the saved definition, and appears in Activity. Long-running development servers can be stopped there. Tasks in different repositories can run at the same time, alongside one workstation task. A second job in the same repository, or a second embedded-terminal session, waits for the first to finish.
- **Related services:** link a named user/system service or timer and inspect its properties and recent journal output. Linking/inspection does not start, stop, enable, or disable it. Use Services for reviewed controls.
Saving the organization/launch form preserves task and service definitions, and saving tasks preserves the launch profile. Older workspace files migrate with empty task/service lists.
diff --git a/docs/user-guide.md b/docs/user-guide.md
index 3f74fd4..d1ab01a 100644
--- a/docs/user-guide.md
+++ b/docs/user-guide.md
@@ -9,7 +9,7 @@
- **Repositories:** scan configurable roots, inspect branches and local changes, search/filter/sort, favorites, groups, changed-file lists, recent commits, and editor/terminal/remote launchers. Fetch updates remote-tracking information; pull requires a clean tracked branch and uses fast-forward only; push targets that branch's upstream without force or automatic tags.
- **Launch profiles:** save a documentation URL and choose whether a project opens its editor, terminal, and documentation together.
-- **Activity:** preview each command before starting, stream output, stop background jobs, and inspect the last 100 results across app restarts. Only one operation runs at a time. Background output is capped at 2 MB per job, and truncation is explicit. Failed jobs can be marked reviewed.
+- **Activity:** preview each command before starting, stream output, stop background jobs, and inspect the last 100 results across app restarts. Operations that use different resources can run at the same time (see [Running several jobs](#running-several-jobs)). Background output is capped at 2 MB per job, and truncation is explicit. Failed jobs can be marked reviewed.
- **System Sync:** inspect dotfiles changes, compare Ghostty/Fastfetch sources with their live files, view Home Manager generations, fetch/pull the config repository, build, and apply Home Manager.
- **Backup & Restore:** run your personal backup helper, run the full recovery helper in a terminal for encryption prompts, load recent Restic snapshots, browse directories, check repository metadata, and restore a snapshot or selected path/pattern into a new folder beneath your home directory.
- **Configuration:** Ghostty font, theme, padding, opacity, and cursor controls; Fastfetch logo/separator controls and module add/remove/reordering. Both include a source editor and illustrative preview. Ghostty uses its installed validator. Fastfetch validates JSONC syntax and module structure; it does not execute command modules or claim full runtime/schema validation.
@@ -36,7 +36,7 @@ Open a category folder, then its subfolders to reach a tool. Category buttons ar
Use **Toolbox → Quick setup** to select a Myfish shell, dotfiles configuration, or application, then **Review & run**. Installer-specific choices and confirmations remain in the original script. **Run tools in** selects the embedded terminal or the external terminal configured in Settings. Return to a session from **Terminal** or its Activity entry. Only the latest session buffer remains available; it is lost on app exit. External terminal output stays external.
-Future catalog updates require updating the pinned revision in `src-tauri/Cargo.toml` and `src-tauri/src/toolbox.rs`, refreshing Cargo.lock, running the [development checks](development.md#checks), and rebuilding. No sibling checkout is needed to build or run Command Center. Toolbox favorites are stored in the webview’s local storage. Activity records the action ID and bundled revision, but no terminal input or transcript.
+Future catalog updates require updating the pinned revision in `src-tauri/Cargo.toml` and `src-tauri/src/toolbox.rs`, refreshing Cargo.lock, running the [development checks](development.md#checks), and rebuilding. No sibling checkout is needed to build or run Command Center. Toolbox favorites are stored in the app’s private `toolbox-favorites.json` file (0.6.0 and later moves any favorites saved by earlier versions there on first launch). The browser preview keeps them in local storage. Activity records the action ID and bundled revision, but no terminal input or transcript.
## Operational behavior
@@ -44,6 +44,10 @@ Every operation displays its exact command and working directory for review. Rep
Background jobs show output and a recorded exit status. Full recovery backups use a terminal and write a completion receipt back to the app; terminal input/output is not captured. If the terminal closes without a receipt, use **Terminal closed? Stop monitoring** only after checking that the workflow has stopped. Closing the app normally is blocked while a job is running. After a crash, previously running jobs are marked interrupted; inspect the command before retrying.
+### Running several jobs
+
+Repository actions (fetch, pull, push, staging, commits, branches, stashes) and project tasks run alongside each other when they use different repositories. Everything else, including Toolbox installers, backups and restores, Home Manager, updates, workflows, personal tools, and quick actions, is a workstation task: one runs at a time, but it can run alongside repository jobs in other folders. Two jobs never run in the same working directory at once, and the embedded terminal holds one interactive session. External-terminal jobs do not use the embedded terminal. A job that has to wait says which running job it is waiting for. Each job is stopped individually from Activity; the tray shows how many are running.
+
Restore destinations must be nonexistent directories beneath your home with an existing parent. Restore uses `--overwrite never` and `--verify`. Include fields accept Restic patterns; leaving them blank restores the whole snapshot. This is file recovery into a staging folder, not automatic OS replacement or a bootable disk-image restore.
Configuration saves check the loaded revision and source path, validate the proposed content, save the previous contents, and replace the source atomically. The review screen displays both old and proposed content. Ghostty edits retain unrelated lines. Fastfetch edits preserve JSONC comments outside rewritten properties and retain custom module options; reordering rewrites the modules array. Previews are illustrative rather than a terminal emulator or executable Fastfetch session.
diff --git a/package-lock.json b/package-lock.json
index 0998ce0..4cece2d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,13 @@
{
"name": "command-center",
- "version": "0.5.1",
+ "version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "command-center",
- "version": "0.5.1",
+ "version": "0.6.0",
+ "license": "MIT",
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
diff --git a/package.json b/package.json
index a7e305f..bc791b1 100644
--- a/package.json
+++ b/package.json
@@ -1,9 +1,10 @@
{
"name": "command-center",
- "version": "0.5.1",
+ "version": "0.6.0",
"private": true,
"type": "module",
"description": "A Linux control deck for repositories, configuration, backup, and recovery.",
+ "license": "MIT",
"scripts": {
"dev": "node scripts/dev.mjs",
"build": "node scripts/build.mjs",
diff --git a/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO
new file mode 100644
index 0000000..8e80c43
--- /dev/null
+++ b/packaging/aur/.SRCINFO
@@ -0,0 +1,31 @@
+pkgbase = command-center
+ pkgdesc = Local Linux workstation dashboard for repositories, backups, configuration and Toolbox
+ pkgver = 0.6.0
+ pkgrel = 1
+ url = https://github.com/Commanderx-code/command-center
+ arch = x86_64
+ license = MIT
+ makedepends = git
+ makedepends = nodejs
+ makedepends = npm
+ makedepends = rust
+ makedepends = pkgconf
+ depends = webkit2gtk-4.1
+ depends = gtk3
+ depends = libappindicator-gtk3
+ depends = libsoup3
+ depends = glib2
+ depends = cairo
+ depends = gdk-pixbuf2
+ depends = dbus
+ depends = glibc
+ depends = libgcc
+ depends = hicolor-icon-theme
+ optdepends = libnotify: desktop notifications
+ optdepends = restic: backup recovery
+ optdepends = fish: Fish custom actions
+ options = !lto
+ source = git+https://github.com/Commanderx-code/command-center.git#tag=v0.6.0
+ sha256sums = SKIP
+
+pkgname = command-center
diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD
index 787b133..3f6f493 100644
--- a/packaging/aur/PKGBUILD
+++ b/packaging/aur/PKGBUILD
@@ -1,22 +1,29 @@
-# Local Arch build recipe. Not submitted to AUR.
+# Arch build recipe. CI builds it in a clean archlinux container, lints it with
+# namcap, installs it, and launches it. Not yet submitted to AUR.
+# libappindicator-gtk3 is loaded at runtime for the tray, so namcap cannot see it.
pkgname=command-center
-pkgver=0.5.1
+pkgver=0.6.0
pkgrel=1
pkgdesc='Local Linux workstation dashboard for repositories, backups, configuration and Toolbox'
arch=('x86_64')
url='https://github.com/Commanderx-code/command-center'
-depends=('webkit2gtk-4.1' 'gtk3' 'libappindicator-gtk3')
+license=('MIT')
+depends=('webkit2gtk-4.1' 'gtk3' 'libappindicator-gtk3' 'libsoup3' 'glib2' 'cairo' 'gdk-pixbuf2' 'dbus' 'glibc' 'libgcc' 'hicolor-icon-theme')
makedepends=('git' 'nodejs' 'npm' 'rust' 'pkgconf')
optdepends=('libnotify: desktop notifications' 'restic: backup recovery' 'fish: Fish custom actions')
+# Rust links C objects; makepkg's LTO flags break that link.
+options=('!lto')
source=("git+https://github.com/Commanderx-code/command-center.git#tag=v${pkgver}")
sha256sums=('SKIP')
prepare() {
cd "$srcdir/command-center"
- npm ci
- cargo fetch --manifest-path src-tauri/Cargo.toml --locked
+ npm ci --cache "$srcdir/npm-cache"
+ export RUSTUP_TOOLCHAIN=stable CARGO_HOME="$srcdir/cargo-home"
+ cargo fetch --manifest-path src-tauri/Cargo.toml --locked --target "$(rustc -vV | sed -n 's/host: //p')"
}
build() {
cd "$srcdir/command-center"
+ export RUSTUP_TOOLCHAIN=stable CARGO_HOME="$srcdir/cargo-home"
npm run desktop:build -- -- --locked
}
package() {
@@ -25,4 +32,5 @@ package() {
install -Dm644 packaging/io.helixstack.commandcenter.desktop "$pkgdir/usr/share/applications/io.helixstack.commandcenter.desktop"
install -Dm644 src-tauri/icons/command-center.svg "$pkgdir/usr/share/icons/hicolor/scalable/apps/io.helixstack.commandcenter.svg"
install -Dm644 THIRD_PARTY.md "$pkgdir/usr/share/doc/$pkgname/THIRD_PARTY.md"
+ install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
diff --git a/scripts/release-draft.mjs b/scripts/release-draft.mjs
index 790c573..ccbf8f4 100644
--- a/scripts/release-draft.mjs
+++ b/scripts/release-draft.mjs
@@ -1,19 +1,35 @@
-import {readFileSync} from 'node:fs';
+import {readFileSync,writeFileSync,rmSync,mkdtempSync} from 'node:fs';
+import {tmpdir} from 'node:os';
+import {join} from 'node:path';
import {spawnSync} from 'node:child_process';
const root=new URL('../',import.meta.url);
-function run(program,args){const r=spawnSync(program,args,{cwd:root,encoding:'utf8'});if(r.status!==0)throw new Error(r.stderr||`${program} failed`);return r.stdout.trim();}
+const repo='Commanderx-code/command-center';
+function run(program,args,cwd=root){const r=spawnSync(program,args,{cwd,encoding:'utf8'});if(r.status!==0)throw new Error(r.stderr||`${program} failed`);return r.stdout.trim();}
const version=JSON.parse(readFileSync(new URL('package.json',root),'utf8')).version;
const tag=`v${version}`;
if(process.argv[2]!==tag)throw new Error(`Use npm run release:draft -- ${tag}`);
if(run('git',['status','--porcelain']))throw new Error('Commit or stash changes before preparing a release.');
-if(run('git',['rev-parse',`${tag}^{commit}`])!==run('git',['rev-parse','HEAD']))throw new Error('Check out the release tag before preparing its draft.');
-const remote=run('git',['ls-remote','https://github.com/Commanderx-code/command-center.git',`refs/tags/${tag}`,`refs/tags/${tag}^{}`]);
+const head=run('git',['rev-parse','HEAD']);
+if(run('git',['rev-parse',`${tag}^{commit}`])!==head)throw new Error('Check out the release tag before preparing its draft.');
+const remote=run('git',['ls-remote',`https://github.com/${repo}.git`,`refs/tags/${tag}`,`refs/tags/${tag}^{}`]);
const refs=remote.split('\n').map(line=>line.split(/\s+/));
const sha=refs.find(r=>r[1]===`refs/tags/${tag}^{}`)?.[0]||refs.find(r=>r[1]===`refs/tags/${tag}`)?.[0];
-if(sha!==run('git',['rev-parse','HEAD']))throw new Error('Push the matching release tag to GitHub first.');
-for(const script of ['check','test','test:rust','desktop:package']){
+if(sha!==head)throw new Error('Push the matching release tag to GitHub first.');
+for(const script of ['check','test','test:rust']){
const r=spawnSync('npm',['run',script],{cwd:root,stdio:'inherit'});if(r.status!==0)throw new Error(`${script} failed; no draft created.`);
}
-run('python3',['scripts/verify-packages.py']);
-console.log(run('gh',['release','create',tag,`artifacts/release/command-center_${version}_amd64.deb`,`artifacts/release/command-center-${version}-1.x86_64.rpm`,'artifacts/release/SHA256SUMS','--repo','Commanderx-code/command-center','--verify-tag','--draft','--title',`Command Center ${tag}`,'--notes-file','docs/release-notes.md']));
-console.log('Draft created. Review it on GitHub and publish when ready. Packages and checksums are attached. Verify hosted downloads and distribution test results before publishing.');
+// Publish the packages CI built on the oldest base and install-tested on each distribution.
+const runs=JSON.parse(run('gh',['run','list','--repo',repo,'--workflow','linux-packages.yml','--commit',head,'--json','databaseId,conclusion,url','--limit','20']));
+const passed=runs.find(r=>r.conclusion==='success');
+if(!passed)throw new Error(`No successful Linux packages run for ${head.slice(0,7)}. Wait for CI on ${tag} to pass; no draft created.`);
+const out=new URL('artifacts/release/',root).pathname;
+rmSync(out,{recursive:true,force:true});
+run('gh',['run','download',String(passed.databaseId),'--repo',repo,'--name','packages','--dir',out]);
+const assets=[`command-center_${version}_amd64.deb`,`command-center-${version}-1.x86_64.rpm`];
+const sums=readFileSync(join(out,'SHA256SUMS'),'utf8');
+for(const asset of assets)if(!sums.includes(` ${asset}\n`))throw new Error(`${asset} is missing from the CI artifact`);
+run('sha256sum',['--check','--strict','SHA256SUMS'],out);
+const notes=join(mkdtempSync(join(tmpdir(),'command-center-release-')),'notes.md');
+writeFileSync(notes,`${readFileSync(new URL('docs/release-notes.md',root),'utf8').trimEnd()}\n\n### Build and validation\n\nBoth packages were built once on Ubuntu 22.04 (glibc 2.35), then installed and launched as a normal user on Ubuntu 22.04, Debian 12, Ubuntu 24.04 and Fedora 43. The Arch recipe was built from the same commit in a clean container, linted with namcap, installed and launched. Workflow run: ${passed.url}\n`);
+console.log(run('gh',['release','create',tag,...assets.map(a=>join(out,a)),join(out,'SHA256SUMS'),'--repo',repo,'--verify-tag','--draft','--title',`Command Center ${tag}`,'--notes-file',notes]));
+console.log(`Draft created from ${passed.url}. Download and verify the hosted assets, test the app on your machine, then publish on GitHub.`);
diff --git a/scripts/verify-packages.py b/scripts/verify-packages.py
index cfb1bfe..58b488d 100644
--- a/scripts/verify-packages.py
+++ b/scripts/verify-packages.py
@@ -12,9 +12,16 @@ def member(archive, name):
with tarfile.open(fileobj=io.BytesIO(subprocess.check_output(['ar', 'p', str(deb), archive]))) as tar:
return tar.extractfile(next(m for m in tar.getmembers() if m.name.lstrip('./') == name)).read()
+def glibc(text):
+ return tuple(int(n) for n in text.split('.'))
+
+# The declared glibc floor must cover every symbol version the binary requires.
+linux = json.loads((root / 'src-tauri/tauri.conf.json').read_text())['bundle']['linux']
+floor = re.fullmatch(r'libc6 \(>= ([0-9.]+)\)', linux['deb']['depends'][0])[1]
+assert linux['rpm']['depends'][0] == f'libc.so.6(GLIBC_{floor})(64bit)', 'deb and rpm glibc floors differ'
control = member('control.tar.gz', 'control').decode()
assert f'Version: {version}\n' in control and 'Architecture: amd64' in control
-for dependency in ['libc6 (>= 2.39)', 'libwebkit2gtk-4.1-0', 'libgtk-3-0', 'libayatana-appindicator3-1']:
+for dependency in [f'libc6 (>= {floor})', 'libwebkit2gtk-4.1-0', 'libgtk-3-0', 'libayatana-appindicator3-1']:
assert dependency in control, dependency
raw = rpm.read_bytes()
@@ -32,12 +39,21 @@ def header(offset):
_, end = header(96)
values, _ = header((end+7)//8*8)
assert values[1001] == [version] and values[1022] == ['x86_64']
-for dependency in ['libc.so.6(GLIBC_2.39)(64bit)', 'libwebkit2gtk-4.1.so.0()(64bit)', 'libgtk-3.so.0()(64bit)', 'libappindicator3.so.1()(64bit)']:
+for dependency in [f'libc.so.6(GLIBC_{floor})(64bit)', 'libwebkit2gtk-4.1.so.0()(64bit)', 'libgtk-3.so.0()(64bit)', 'libappindicator3.so.1()(64bit)']:
assert dependency in values[1049], dependency
+# The MIT notice and third-party notices must ship with every binary package.
+license = (root / 'LICENSE').read_bytes()
+assert member('data.tar.gz', 'usr/share/doc/command-center/copyright') == license
+assert member('data.tar.gz', 'usr/share/doc/command-center/THIRD_PARTY.md') == (root / 'THIRD_PARTY.md').read_bytes()
+assert subprocess.check_output(['bsdtar', '-xOf', str(rpm), './usr/share/licenses/command-center/LICENSE']) == license
+assert subprocess.check_output(['bsdtar', '-xOf', str(rpm), './usr/share/doc/command-center/THIRD_PARTY.md']) == (root / 'THIRD_PARTY.md').read_bytes()
+assert values[1014] == ['MIT'], values.get(1014)
pin = re.search(r'REVISION: &str = "([a-f0-9]+)"', (root/'src-tauri/src/toolbox.rs').read_text())[1]
for binary in [member('data.tar.gz', 'usr/bin/command-center'), subprocess.check_output(['bsdtar', '-xOf', str(rpm), './usr/bin/command-center'])]:
assert binary[:4] == b'\x7fELF' and struct.unpack_from(',
+ cancel: bool,
+}
#[derive(Default)]
pub struct Inner {
jobs: Vec,
plans: BTreeMap,
- pid: Option,
- cancel: bool,
+ running: BTreeMap,
initialized: bool,
persistence_error: Option,
close_requested: bool,
@@ -81,6 +86,35 @@ fn initialize(app: &tauri::AppHandle, state: &mut Inner) -> Result<(), String> {
fn active(state: &Inner) -> bool {
state.jobs.iter().any(|j| j.status == "running")
}
+const REPOSITORY_ACTIONS: [&str; 13] = [
+ "stage", "stage-all", "unstage", "commit", "branch-create", "branch-switch",
+ "stash-create", "stash-apply", "branch-publish", "fetch", "pull", "push", "project-task",
+];
+/// Jobs run concurrently unless they share a resource: the same working directory,
+/// the workstation (every non-repository action), or the embedded terminal.
+fn locks(request: &Request, plan: &Plan) -> Vec {
+ let mut locks = vec![format!("directory:{}", plan.cwd)];
+ if !REPOSITORY_ACTIONS.contains(&request.action.as_str()) {
+ locks.push("system".into());
+ }
+ if plan.interactive && !plan.external_terminal {
+ locks.push("terminal".into());
+ }
+ locks
+}
+fn conflict(state: &Inner, locks: &[String]) -> Result<(), String> {
+ for job in state.running.values() {
+ if let Some(lock) = job.locks.iter().find(|l| locks.contains(l)) {
+ let reason = match lock.as_str() {
+ "system" => "Workstation tasks run one at a time".to_string(),
+ "terminal" => "The embedded terminal is in use".to_string(),
+ _ => format!("It uses the same folder ({})", &lock["directory:".len()..]),
+ };
+ return Err(format!("Wait for “{}” to finish. {reason}.", job.title));
+ }
+ }
+ Ok(())
+}
#[tauri::command]
pub fn job_history(
app: tauri::AppHandle,
@@ -112,9 +146,7 @@ pub async fn prepare_job(app: tauri::AppHandle, request: Request) -> Result();
let mut state = jobs.0.lock().map_err(|e| e.to_string())?;
initialize(&app, &mut state)?;
- if active(&state) {
- return Err("Wait for the current job to finish".into());
- }
+ conflict(&state, &locks(&request, &plan))?;
state
.plans
.retain(|_, (_, _, created)| created.elapsed() < Duration::from_secs(300));
@@ -136,9 +168,6 @@ pub async fn start_job(app: tauri::AppHandle, id: String) -> Result().inner().clone();
let mut state = jobs.0.lock().map_err(|e| e.to_string())?;
- if active(&state) {
- return Err("Another job is running".into());
- }
let (plan, request, created) = state
.plans
.remove(&id)
@@ -154,15 +183,17 @@ pub async fn start_job(app: tauri::AppHandle, id: String) -> Result bool {
- jobs.0.lock().map(|s| s.cancel).unwrap_or(true)
+fn running_status(count: usize) -> String {
+ match count {
+ 1 => "Command Center · Task running".into(),
+ n => format!("Command Center · {n} tasks running"),
+ }
+}
+pub(crate) fn cancelled(jobs: &Jobs, id: &str) -> bool {
+ jobs.0
+ .lock()
+ .map(|s| s.running.get(id).is_none_or(|r| r.cancel))
+ .unwrap_or(true)
}
fn append(jobs: &Jobs, id: &str, text: &str) {
if let Ok(mut state) = jobs.0.lock() {
@@ -305,7 +345,10 @@ fn finish(
append(jobs, id, message);
crate::desktop_status::job_finished(app,status);
if let Ok(mut state) = jobs.0.lock() {
- state.pid = None;
+ state.running.remove(id);
+ if !state.running.is_empty() {
+ crate::desktop_status::status(app, &running_status(state.running.len()));
+ }
if let Some(job) = state.jobs.iter_mut().find(|j| j.id == id) {
job.status = status.into();
job.exit_code = code;
@@ -408,9 +451,6 @@ fn run_process(jobs: Jobs, plan: Plan, id: String) -> (String, Option, Stri
return ("failed".into(), None, format!("Could not start: {e}"));
}
};
- if let Ok(mut state) = jobs.0.lock() {
- state.pid = Some(child.id());
- }
let stdout = reader(child.stdout.take().unwrap(), jobs.clone(), id.clone(), true);
let stderr = reader(
child.stderr.take().unwrap(),
@@ -421,7 +461,7 @@ fn run_process(jobs: Jobs, plan: Plan, id: String) -> (String, Option, Stri
let start = Instant::now();
let mut stopped = None;
let status = loop {
- let cancel = jobs.0.lock().map(|s| s.cancel).unwrap_or(true);
+ let cancel = cancelled(&jobs, &id);
if cancel || start.elapsed() > Duration::from_secs(plan.timeout_seconds) {
stopped = Some(if cancel { "cancelled" } else { "timed-out" });
unsafe {
@@ -484,7 +524,11 @@ pub fn cancel_job(id: String, jobs: State) -> Result<(), String> {
if job.external_terminal {
return Err("Stop this workflow in its terminal".into());
}
- state.cancel = true;
+ state
+ .running
+ .get_mut(&id)
+ .ok_or("Job has already finished")?
+ .cancel = true;
Ok(())
}
#[tauri::command]
@@ -574,6 +618,10 @@ mod tests {
interactive: false,
acknowledged: false,
});
+ jobs.0.lock().unwrap().running.insert(
+ "fixture".into(),
+ Running { title: "Fixture".into(), locks: vec![], cancel: false },
+ );
jobs
}
fn plan(script: &str) -> Plan {
@@ -623,7 +671,7 @@ mod tests {
assert!(start.elapsed() < Duration::from_secs(3));
thread::sleep(Duration::from_millis(20));
}
- jobs.0.lock().unwrap().cancel = true;
+ jobs.0.lock().unwrap().running.get_mut("fixture").unwrap().cancel = true;
let (status, _, _) = handle.join().unwrap();
assert_eq!(status, "cancelled");
assert!(start.elapsed() < Duration::from_secs(3));
@@ -655,4 +703,42 @@ mod tests {
assert_eq!(state.jobs[0].output.len(), OUTPUT_LIMIT);
assert!(state.jobs[0].truncated);
}
+
+ #[test]
+ fn jobs_run_concurrently_unless_they_share_a_folder_the_workstation_or_terminal() {
+ let request = |action: &str| Request { action: action.into(), ..Default::default() };
+ let at = |cwd: &str, interactive: bool| Plan { cwd: cwd.into(), interactive, ..plan("true") };
+ let mut state = Inner::default();
+ let task = locks(&request("project-task"), &at("/repo/a", false));
+ state.running.insert("task".into(), Running { title: "npm · build".into(), locks: task, cancel: false });
+ // Another repository, and a workstation task, may run alongside it.
+ assert!(conflict(&state, &locks(&request("fetch"), &at("/repo/b", false))).is_ok());
+ let backup = locks(&request("backup"), &at("/home", false));
+ assert!(conflict(&state, &backup).is_ok());
+ // The same repository waits.
+ let error = conflict(&state, &locks(&request("commit"), &at("/repo/a", false))).unwrap_err();
+ assert!(error.contains("npm · build") && error.contains("/repo/a"));
+ state.running.insert("backup".into(), Running { title: "Backup".into(), locks: backup, cancel: false });
+ let error = conflict(&state, &locks(&request("hm-switch"), &at("/dotfiles", false))).unwrap_err();
+ assert!(error.contains("one at a time"));
+ state.running.remove("backup");
+ let toolbox = locks(&request("toolbox"), &at("/scripts", true));
+ state.running.insert("toolbox".into(), Running { title: "Installer".into(), locks: toolbox, cancel: false });
+ let error = conflict(&state, &locks(&request("project-task"), &at("/repo/c", true))).unwrap_err();
+ assert!(error.contains("terminal is in use"));
+ let external = Plan { external_terminal: true, ..at("/repo/c", true) };
+ assert!(conflict(&state, &locks(&request("project-task"), &external)).is_ok());
+ }
+ #[test]
+ fn cancelling_one_job_leaves_others_running() {
+ let jobs = fixture();
+ jobs.0.lock().unwrap().running.insert(
+ "other".into(),
+ Running { title: "Other".into(), locks: vec![], cancel: false },
+ );
+ jobs.0.lock().unwrap().running.get_mut("other").unwrap().cancel = true;
+ assert!(cancelled(&jobs, "other"));
+ assert!(!cancelled(&jobs, "fixture"));
+ assert!(cancelled(&jobs, "finished"), "unknown jobs are never resumed");
+ }
}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 418f034..6e5fef5 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -28,7 +28,11 @@ pub fn run() {
tauri::Builder::default()
.manage(jobs::Jobs::default())
.manage(desktop_status::HealthNotice::default())
- .setup(|app| { if let Err(error)=desktop_status::setup(app.handle()) { eprintln!("Tray unavailable: {error}"); } Ok(()) })
+ .setup(|app| {
+ setup::recover_interrupted_import(app.handle());
+ if let Err(error)=desktop_status::setup(app.handle()) { eprintln!("Tray unavailable: {error}"); }
+ Ok(())
+ })
.manage(toolbox::Toolbox::default())
.manage(terminal::Terminals::default())
.on_window_event(|window, event| {
@@ -69,6 +73,8 @@ pub fn run() {
diagnostics::check_integrations,
integrations::sync_status,
toolbox::toolbox_catalog,
+ toolbox::load_toolbox_favorites,
+ toolbox::save_toolbox_favorites,
toolbox_updates::toolbox_update_checks,
toolbox_updates::toolbox_catalog_update,
toolbox_updates::toolbox_compare,
diff --git a/src-tauri/src/platform.rs b/src-tauri/src/platform.rs
index 5959433..32e646c 100644
--- a/src-tauri/src/platform.rs
+++ b/src-tauri/src/platform.rs
@@ -80,6 +80,7 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
use std::io::Write;
fs::create_dir_all(path.parent().ok_or("Invalid path")?).map_err(|e| e.to_string())?;
let tmp = path.with_extension(format!("tmp-{}", now()));
+ let parent = path.parent().ok_or("Invalid path")?;
let result = (|| {
let mut file = fs::OpenOptions::new()
.write(true)
@@ -90,7 +91,10 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
file.write_all(data)
.and_then(|_| file.sync_all())
.map_err(|e| e.to_string())?;
- fs::rename(&tmp, path).map_err(|e| e.to_string())
+ fs::rename(&tmp, path).map_err(|e| e.to_string())?;
+ // Persist the rename itself; some filesystems do not support directory sync.
+ let _ = fs::File::open(parent).and_then(|dir| dir.sync_all());
+ Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(tmp);
diff --git a/src-tauri/src/setup.rs b/src-tauri/src/setup.rs
index 41e3a05..e0a57c3 100644
--- a/src-tauri/src/setup.rs
+++ b/src-tauri/src/setup.rs
@@ -2,7 +2,12 @@
use crate::{operations::Collection, platform as p, settings::Settings, workspace::Workspace};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
-use std::{collections::BTreeMap, fs, path::PathBuf, sync::Mutex};
+use std::{
+ collections::BTreeMap,
+ fs,
+ path::{Path, PathBuf},
+ sync::Mutex,
+};
use tauri::Manager;
pub(crate) static WRITE_LOCK: Mutex<()> = Mutex::new(());
const LIMIT: usize = 2_000_000;
@@ -39,13 +44,7 @@ impl Bundle {
for path in &self.repositories {
valid_path(path)?;
}
- if self
- .toolbox_favorites
- .iter()
- .any(|id| id.is_empty() || id.len() > 500 || id.contains(['\0', '\n', '\r']))
- {
- return Err("Invalid Toolbox favorite".into());
- }
+ crate::toolbox::validate_favorites(&self.toolbox_favorites)?;
if serde_json::to_vec(self).map_err(|e| e.to_string())?.len() > LIMIT {
return Err("Bundle exceeds 2 MB".into());
}
@@ -168,7 +167,144 @@ fn strip_credentials(settings: &mut Settings) {
i.restic_repository.clear();
}
}
-fn prepare(text: &str, target: &str, current: &Settings) -> Result {
+#[derive(Clone, Copy, Default, Deserialize, PartialEq)]
+#[serde(rename_all = "lowercase")]
+pub enum Mode {
+ #[default]
+ Replace,
+ Merge,
+}
+/// This machine's saved definitions, compared against an incoming bundle.
+#[derive(Default)]
+pub(crate) struct Current {
+ settings: Settings,
+ operations: Collection,
+ workspace: Workspace,
+ favorites: Vec,
+}
+// Replace reads only settings, so it can still overwrite an unreadable collection.
+fn current(app: &tauri::AppHandle, mode: Mode) -> Result {
+ let settings = crate::settings::load_settings(app.clone())?.unwrap_or_default();
+ if mode == Mode::Replace {
+ return Ok(Current { settings, ..Default::default() });
+ }
+ Ok(Current {
+ settings,
+ operations: crate::operations::load_operations(app.clone())?,
+ workspace: crate::workspace::load_workspace(app.clone())?,
+ favorites: crate::toolbox::read_favorites(&crate::toolbox::favorites_file(app)?)?
+ .unwrap_or_default(),
+ })
+}
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Preview {
+ bundle: Bundle,
+ kept: Vec,
+}
+// Adds incoming items with new IDs. A differing item with an existing ID keeps the
+// local version and is reported; identical items are not conflicts.
+fn add_new(
+ local: &mut Vec,
+ incoming: Vec,
+ id: impl Fn(&T) -> &str,
+ label: impl Fn(&T) -> String,
+ kept: &mut Vec,
+) {
+ for item in incoming {
+ match local.iter().find(|l| id(l) == id(&item)) {
+ None => local.push(item),
+ Some(existing) => {
+ if serde_json::to_value(existing).ok() != serde_json::to_value(&item).ok() {
+ kept.push(label(&item));
+ }
+ }
+ }
+ }
+}
+/// Merge keeps every local definition and preference. It adds new definitions,
+/// scan folders and favorites, and fills only empty integration paths.
+fn merge(incoming: Bundle, current: &Current) -> Result<(Bundle, Vec), String> {
+ let mut kept = Vec::new();
+ let mut settings = current.settings.clone();
+ for root in &incoming.settings.roots {
+ if !settings.roots.contains(root) {
+ settings.roots.push(root.clone());
+ }
+ }
+ add_new(
+ &mut settings.custom_actions,
+ incoming.settings.custom_actions,
+ |a| &a.id,
+ |a| format!("Quick action “{}”", a.name),
+ &mut kept,
+ );
+ let (to, from) = (&mut settings.integrations, &incoming.settings.integrations);
+ for (target, value) in [
+ (&mut to.dotfiles_path, &from.dotfiles_path),
+ (&mut to.flake_profile, &from.flake_profile),
+ (&mut to.backup_script, &from.backup_script),
+ (&mut to.full_backup_script, &from.full_backup_script),
+ (&mut to.restic_repository, &from.restic_repository),
+ (&mut to.ghostty_source, &from.ghostty_source),
+ (&mut to.fastfetch_source, &from.fastfetch_source),
+ (&mut to.recovery_notes_path, &from.recovery_notes_path),
+ (&mut to.secrets_directory, &from.secrets_directory),
+ ] {
+ if target.is_empty() {
+ *target = value.clone();
+ }
+ }
+ let mut operations = current.operations.clone();
+ let recipes = [
+ (&mut operations.workflows, incoming.operations.workflows, "Workflow"),
+ (&mut operations.profiles, incoming.operations.profiles, "Machine profile"),
+ ];
+ for (local, items, kind) in recipes {
+ add_new(local, items, |r| &r.id, |r| format!("{kind} “{}”", r.name), &mut kept);
+ }
+ add_new(
+ &mut operations.tools,
+ incoming.operations.tools,
+ |t| &t.id,
+ |t| format!("Personal tool “{}”", t.name),
+ &mut kept,
+ );
+ let mut workspace = current.workspace.clone();
+ for (path, project) in incoming.workspace {
+ match workspace.get(&path) {
+ None => {
+ workspace.insert(path, project);
+ }
+ Some(existing) => {
+ if serde_json::to_value(existing).ok() != serde_json::to_value(&project).ok() {
+ kept.push(format!("Workspace profile {path}"));
+ }
+ }
+ }
+ }
+ let mut favorites = current.favorites.clone();
+ for id in incoming.toolbox_favorites {
+ if !favorites.contains(&id) {
+ favorites.push(id);
+ }
+ }
+ let bundle = Bundle {
+ settings,
+ operations,
+ workspace,
+ toolbox_favorites: favorites,
+ ..incoming
+ };
+ bundle.validate()?;
+ Ok((bundle, kept))
+}
+fn prepare(
+ text: &str,
+ target: &str,
+ current: &Current,
+ mode: Mode,
+) -> Result<(Bundle, Vec), String> {
if text.len() > LIMIT {
return Err("Bundle exceeds 2 MB".into());
}
@@ -178,7 +314,7 @@ fn prepare(text: &str, target: &str, current: &Settings) -> Result Result {
+ bundle.settings.setup_completed = false;
+ Ok((bundle, Vec::new()))
+ }
+ Mode::Merge => merge(bundle, current),
+ }
}
#[tauri::command]
pub fn setup_environment() -> Value {
@@ -210,6 +351,10 @@ pub fn create_setup_bundle(
) -> Result {
let mut settings = crate::settings::load_settings(app.clone())?.unwrap_or_default();
strip_credentials(&mut settings);
+ // Saved favorites are authoritative; the argument covers not-yet-migrated storage.
+ let toolbox_favorites =
+ crate::toolbox::read_favorites(&crate::toolbox::favorites_file(&app)?)?
+ .unwrap_or(toolbox_favorites);
let bundle = Bundle {
format: "command-center-setup".into(),
version: 1,
@@ -228,12 +373,11 @@ pub fn preview_setup_bundle(
app: tauri::AppHandle,
text: String,
target_home: String,
-) -> Result {
- prepare(
- &text,
- &target_home,
- &crate::settings::load_settings(app)?.unwrap_or_default(),
- )
+ mode: Option,
+) -> Result {
+ let mode = mode.unwrap_or_default();
+ let (bundle, kept) = prepare(&text, &target_home, ¤t(&app, mode)?, mode)?;
+ Ok(Preview { bundle, kept })
}
#[tauri::command]
pub fn export_setup_bundle(app: tauri::AppHandle, mut bundle: Bundle) -> Result {
@@ -250,17 +394,31 @@ pub fn export_setup_bundle(app: tauri::AppHandle, mut bundle: Bundle) -> Result<
p::save(&path, &bundle)?;
Ok(path.to_string_lossy().into_owned())
}
-// All previous bytes are durably saved before the first replacement. Ordinary write
-// failures roll back completed writes; the backup also supports crash recovery.
-fn replace_files(files: &[(PathBuf, Vec)], backup: &std::path::Path) -> Result<(), String> {
- replace_files_with(files, backup, p::atomic_write)
+type Previous = Vec<(PathBuf, Option>)>;
+const JOURNAL: &str = "setup-import-journal.json";
+static RECOVERY: Mutex