Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70d6f9b3d8 | ||
|
|
05af559adb | ||
|
|
524f0dde36 | ||
|
|
89e57cc467 | ||
|
|
e1faa2141e |
@@ -37,11 +37,20 @@ jobs:
|
|||||||
username: ${{ secrets.GITEA_USERNAME }}
|
username: ${{ secrets.GITEA_USERNAME }}
|
||||||
password: ${{ secrets.GITEA_TOKEN }}
|
password: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|
||||||
|
# The version the app reports on Settings > General when the build
|
||||||
|
# isn't a published release.
|
||||||
|
- name: Read the source version
|
||||||
|
id: source
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "version=$(sed -n -E 's/^[[:space:]]*"version": "([^"]+)",?$/\1/p' package.json | head -n1)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Work out image tags
|
- name: Work out image tags
|
||||||
id: tags
|
id: tags
|
||||||
env:
|
env:
|
||||||
EVENT: ${{ github.event_name }}
|
EVENT: ${{ github.event_name }}
|
||||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||||
|
SOURCE_VERSION: ${{ steps.source.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
sha="sha-${GITHUB_SHA::12}"
|
sha="sha-${GITHUB_SHA::12}"
|
||||||
@@ -59,10 +68,12 @@ jobs:
|
|||||||
"git.tben.me/moduletrace/cobble:${minor}" \
|
"git.tben.me/moduletrace/cobble:${minor}" \
|
||||||
"git.tben.me/moduletrace/cobble:${sha}")"
|
"git.tben.me/moduletrace/cobble:${sha}")"
|
||||||
else
|
else
|
||||||
|
version="$SOURCE_VERSION"
|
||||||
ghcr="ghcr.io/moduletrace/cobble:${sha}"
|
ghcr="ghcr.io/moduletrace/cobble:${sha}"
|
||||||
gitea="git.tben.me/moduletrace/cobble:${sha}"
|
gitea="git.tben.me/moduletrace/cobble:${sha}"
|
||||||
fi
|
fi
|
||||||
{
|
{
|
||||||
|
echo "version=$version"
|
||||||
echo "ghcr<<EOF"
|
echo "ghcr<<EOF"
|
||||||
echo "$ghcr"
|
echo "$ghcr"
|
||||||
echo "EOF"
|
echo "EOF"
|
||||||
@@ -79,6 +90,8 @@ jobs:
|
|||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
|
build-args: |
|
||||||
|
COBBLE_VERSION=${{ steps.tags.outputs.version }}
|
||||||
tags: ${{ steps.tags.outputs.ghcr }}
|
tags: ${{ steps.tags.outputs.ghcr }}
|
||||||
provenance: false
|
provenance: false
|
||||||
sbom: false
|
sbom: false
|
||||||
|
|||||||
@@ -126,11 +126,13 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
|
|||||||
|
|
||||||
`install.sh` is the only supported install and update path: `curl -fsSL https://url.moduletrace.com/cobble | sudo bash`. It is idempotent, so the same script is the updater, and `--uninstall [--purge]` is the way back out. It installs Docker when missing (Linux), creates the `cobble` network, writes `$COBBLE_HOME/install.conf` and a generated `.env` (mode 600), and starts one `cobble` container. Existing secrets are never regenerated; the database is backed up to `$COBBLE_HOME/backups` before a container is replaced. The default home is `/opt/cobble`, configurable with `--home`, and everything that must survive an update lives in its `data/` directory (`db`, `media`, `computers`, `bots`, `workspaces`, `mcp`).
|
`install.sh` is the only supported install and update path: `curl -fsSL https://url.moduletrace.com/cobble | sudo bash`. It is idempotent, so the same script is the updater, and `--uninstall [--purge]` is the way back out. It installs Docker when missing (Linux), creates the `cobble` network, writes `$COBBLE_HOME/install.conf` and a generated `.env` (mode 600), and starts one `cobble` container. Existing secrets are never regenerated; the database is backed up to `$COBBLE_HOME/backups` before a container is replaced. The default home is `/opt/cobble`, configurable with `--home`, and everything that must survive an update lives in its `data/` directory (`db`, `media`, `computers`, `bots`, `workspaces`, `mcp`).
|
||||||
|
|
||||||
|
The bind mounts are built in the marked `selinux-detection` and `mount-specs` blocks, and `install.test.ts` runs those blocks in bash and parses the specs the way docker does. A mount spec is `source:destination[:options]`: docker takes the SELinux `z` and the read-only `ro` as comma separated options in that third field, so a destination is always a plain absolute path. Appending an option to the destination (`$DATA_DIR:/data,z`) makes docker mount a directory literally named `/data,z`: the app then writes its database to its own container layer, `container_matches` never matches, every update recreates the container without its data, `backup_database` silently skips, and `resolveHostPath` refuses to provision computers (`/data/computers/1 isn't on a volume`). `rescue_container_data` runs before a container is replaced and copies a container's unmounted `/data` onto the host, so repairing an install from the broken form does not lose its database.
|
||||||
|
|
||||||
The app serves plain HTTP only; TLS is the reverse proxy's or tunnel's job, and the installer, the image's `HEALTHCHECK` and the dev server all assume it (`Bun.serve` gets no `tls`, there is no certificate handling anywhere). `isSecureRequest` still reads the proxy's `X-Forwarded-Proto`, so the auth and CSRF cookies get `Secure` and the installed app, Web Push and voice keep their secure context behind HTTPS; the installer binds `0.0.0.0` by default, and `--bind 127.0.0.1` limits it to a local proxy. Updates drop any `TLS_*` or `HTTP_REDIRECT_PORT` entries an older install left in `.env` and recreate a container that still mounts `/app/certs`.
|
The app serves plain HTTP only; TLS is the reverse proxy's or tunnel's job, and the installer, the image's `HEALTHCHECK` and the dev server all assume it (`Bun.serve` gets no `tls`, there is no certificate handling anywhere). `isSecureRequest` still reads the proxy's `X-Forwarded-Proto`, so the auth and CSRF cookies get `Secure` and the installed app, Web Push and voice keep their secure context behind HTTPS; the installer binds `0.0.0.0` by default, and `--bind 127.0.0.1` limits it to a local proxy. Updates drop any `TLS_*` or `HTTP_REDIRECT_PORT` entries an older install left in `.env` and recreate a container that still mounts `/app/certs`.
|
||||||
|
|
||||||
The container runs with `--network cobble`, `-p BIND:PORT`, `.env` at `/app/.env` read only, the host data directory at `/data`, the docker socket at `/var/run/docker.sock`, `MAIN_SERVER=1` and `PORT`, `--init`, a `unless-stopped` restart policy and bounded json-file logs. `/data` must stay a bind mount: `resolveHostPath` turns paths under the app's own mounts into host paths for computer containers, and a named volume would break them. `DATA_DIR=/data` and `DB_DIR=/data/db` come from the Dockerfile; `entrypoint.sh` keeps bun-sqlite's schema record on the same volume.
|
The container runs with `--network cobble`, `-p BIND:PORT`, `.env` at `/app/.env` read only, the host data directory at `/data`, the docker socket at `/var/run/docker.sock`, `MAIN_SERVER=1` and `PORT`, `--init`, `--cpus 1` and `--memory 2048m` (a light orchestration process: the heavy work happens in each computer's own container; overridable with `--cpus`/`--memory`), a `unless-stopped` restart policy and bounded json-file logs. `/data` must stay a bind mount: `resolveHostPath` turns paths under the app's own mounts into host paths for computer containers, and a named volume would break them. `DATA_DIR=/data` and `DB_DIR=/data/db` come from the Dockerfile; `entrypoint.sh` keeps bun-sqlite's schema record on the same volume.
|
||||||
|
|
||||||
Images are built by GitHub Actions on a published release (`.github/workflows/release.yml`), for `linux/amd64` and `linux/arm64`, and pushed to both `ghcr.io/moduletrace/cobble` and `git.tben.me/moduletrace/cobble` with `latest`, version, `major.minor` and commit-sha tags. `.github/workflows/ci.yml` runs `bun run typecheck` and `bun run test` on every push and pull request, and checks that `install.sh` parses. The Dockerfile pins Bun 1.4.3 to match CI. The repository is mirrored on Gitea, and the installer defaults to the GHCR image with `--image git.tben.me/moduletrace/cobble` as the Gitea alternative.
|
Images are built by GitHub Actions on a published release (`.github/workflows/release.yml`), for `linux/amd64` and `linux/arm64`, and pushed to both `ghcr.io/moduletrace/cobble` and `git.tben.me/moduletrace/cobble` with `latest`, version, `major.minor` and commit-sha tags. The version they are built with is passed as the `COBBLE_VERSION` build arg (the published tag, else `package.json`'s version read from `<version>`), which the image turns into `ENV COBBLE_VERSION` and `runningVersion` (`src/utils/running-version.ts`) reports on Settings > General; bump `package.json`'s `version` with a release, since a local build and a workflow-dispatch build fall back to it. `.github/workflows/ci.yml` runs `bun run typecheck` and `bun run test` on every push and pull request, and checks that `install.sh` parses. The Dockerfile pins Bun 1.4.3 to match CI. The repository is mirrored on Gitea, and the installer defaults to the GHCR image with `--image git.tben.me/moduletrace/cobble` as the Gitea alternative.
|
||||||
|
|
||||||
### Envs
|
### Envs
|
||||||
|
|
||||||
@@ -212,7 +214,7 @@ Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspa
|
|||||||
- `/admin/help` (live) - familiar problems and their fixes, one `SettingsGroup` per topic (`(data)/help-topics.ts`, markdown rendered with the chat's `MessageMarkdownComponents`, the topic id as the group's anchor). First topic: signing in to Google and other strict sites with Cookie-Editor over the Desktop view (`HelpSignInTopicId` in `src/dict/help-dict.ts`; `ask_user_to_take_over` points bots to it)
|
- `/admin/help` (live) - familiar problems and their fixes, one `SettingsGroup` per topic (`(data)/help-topics.ts`, markdown rendered with the chat's `MessageMarkdownComponents`, the topic id as the group's anchor). First topic: signing in to Google and other strict sites with Cookie-Editor over the Desktop view (`HelpSignInTopicId` in `src/dict/help-dict.ts`; `ask_user_to_take_over` points bots to it)
|
||||||
- Routines, Files, Activity and Help are the `RailPages` (`src/dict/rail-pages-dict.ts`), opened from the rail's user popover (which lists them, then Settings and Sign out). On them the rail shows its own aside (`(partials)/rail-pages-list.tsx`): a back link to the bots, then those pages, like settings
|
- Routines, Files, Activity and Help are the `RailPages` (`src/dict/rail-pages-dict.ts`), opened from the rail's user popover (which lists them, then Settings and Sign out). On them the rail shows its own aside (`(partials)/rail-pages-list.tsx`): a back link to the bots, then those pages, like settings
|
||||||
- `/admin/settings/backups` - super admin only (listed in `SettingsPages`), two groups. Database snapshots: create, list, restore, download and delete in `db/.backups` (`bun-sqlite backup`); "Restore from file" beside "Create backup" opens `upload-restore-modal.tsx` (upload a SQLite database file, which is saved to `.backups` then restored with `bun-sqlite import <file>`, since `restore` is interactive). Whole-data archives: `write-data-backup-archive.ts` tars all of `DATA_DIR` (database, media, computer homes, bot and workspace files, MCP installs) into `BACKUP_ARCHIVE_DIR` as `cobble-data-<epoch-ms>.tar.gz` (via `tar -czf` into a `.part` name, renamed on success so a cut-off run is never listed, with a `--transform` putting every member under one root folder named after the archive), keeping the newest `AppData.DataBackupsKept` and never touching `db/.backups`; its own "Restore from file" uploads a tar.gz (`upload-restore-data.ts`, gzip header checked first). `restore-data-backup.ts` extracts an archive to a temp dir, requires exactly one root folder holding a `db` folder (`findArchiveRoot`, since an upload is saved under a fresh timestamp and its inner name no longer matches the file name), takes a safety archive before touching anything (`write-data-backup-archive.ts`, not the locked `create-data-backup.ts`), empties `DATA_DIR` (the snapshots included) and copies the archive's in, then `restart-app.ts` restarts the process through the docker socket after the response lands (`process.exit`, the container's restart policy brings it back), because the open SQLite handle points at the replaced file. Row actions are icon-only (`RowIconButton`, `src/components/general/row-icon-button.tsx`): restore, download, delete. `BACKUP_ARCHIVE_DIR` is a `ProcessOnlyEnvKeys` start-up path (the installer mounts `$COBBLE_HOME/backups-archives` there; outside docker it falls back to `~/.cobble/backups`). Every backup and restore, of either kind, runs one at a time via `with-backup-run-lock.ts`. Helpers in `src/functions/backend/backups/`, API routes in `api/admin/backups/`
|
- `/admin/settings/backups` - super admin only (listed in `SettingsPages`), two groups. Database snapshots: create, list, restore, download and delete in `db/.backups` (`bun-sqlite backup`); "Restore from file" beside "Create backup" opens `upload-restore-modal.tsx` (upload a SQLite database file, which is saved to `.backups` then restored with `bun-sqlite import <file>`, since `restore` is interactive). Whole-data archives: `write-data-backup-archive.ts` tars all of `DATA_DIR` (database, media, computer homes, bot and workspace files, MCP installs) into `BACKUP_ARCHIVE_DIR` as `cobble-data-<epoch-ms>.tar.gz` (via `tar -czf` into a `.part` name, renamed on success so a cut-off run is never listed, with a `--transform` putting every member under one root folder named after the archive), keeping the newest `AppData.DataBackupsKept` and never touching `db/.backups`; its own "Restore from file" uploads a tar.gz (`upload-restore-data.ts`, gzip header checked first). `restore-data-backup.ts` extracts an archive to a temp dir, requires exactly one root folder holding a `db` folder (`findArchiveRoot`, since an upload is saved under a fresh timestamp and its inner name no longer matches the file name), takes a safety archive before touching anything (`write-data-backup-archive.ts`, not the locked `create-data-backup.ts`), empties `DATA_DIR` (the snapshots included) and copies the archive's in, then `restart-app.ts` restarts the process through the docker socket after the response lands (`process.exit`, the container's restart policy brings it back), because the open SQLite handle points at the replaced file. Row actions are icon-only (`RowIconButton`, `src/components/general/row-icon-button.tsx`): restore, download, delete. `BACKUP_ARCHIVE_DIR` is a `ProcessOnlyEnvKeys` start-up path (the installer mounts `$COBBLE_HOME/backups-archives` there; outside docker it falls back to `~/.cobble/backups`). Every backup and restore, of either kind, runs one at a time via `with-backup-run-lock.ts`. Helpers in `src/functions/backend/backups/`, API routes in `api/admin/backups/`
|
||||||
- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar; for super admins the prompt appendix (`prompt-section.tsx`, `system-prompt-append-form.tsx`) saving `api/admin/settings/system-prompt-append` into the `bot_system_prompt_append` app setting; and, for `canManageCrew`, the bot file quota: `storage-section.tsx`, `storage-quota-form.tsx` saving `api/admin/settings/storage-quota` into `app_settings`), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/`, `tools/` and `skills/` (see MCPs, custom tools and skills: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`). A skill has no status: nothing connects or runs, so `skill-row.tsx` puts its slug, who gets it (`extension-access-label.ts`) and its description where a server's or tool's status line goes), each MCP server or tool row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/settings/users/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry
|
- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar; for super admins the prompt appendix (`prompt-section.tsx`, `system-prompt-append-form.tsx`) saving `api/admin/settings/system-prompt-append` into the `bot_system_prompt_append` app setting; and, for `canManageCrew`, the bot file quota: `storage-section.tsx`, `storage-quota-form.tsx` saving `api/admin/settings/storage-quota` into `app_settings`; and, for everyone, the running version: `version-section.tsx`, `version-line.tsx` showing `pageProps.running_version` from `runningVersion` (`src/utils/running-version.ts`: `COBBLE_VERSION`, the release tag the image was built with, else `package.json`'s version, in development too, with `(development)` after it and no such suffix in production), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/`, `tools/` and `skills/` (see MCPs, custom tools and skills: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`). A skill has no status: nothing connects or runs, so `skill-row.tsx` puts its slug, who gets it (`extension-access-label.ts`) and its description where a server's or tool's status line goes), each MCP server or tool row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/settings/users/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry
|
||||||
|
|
||||||
Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly; in a workspace's rail, "Workspace bots" has a "+" that opens `link-bots-modal.tsx` instead: the crew's workers not in it yet (`api/admin/workspaces/linkable-bots`), each added with `add-bot`, which pushes `server:bot-created`, and "New bot" stacking the bot form over it); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`).
|
Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly; in a workspace's rail, "Workspace bots" has a "+" that opens `link-bots-modal.tsx` instead: the crew's workers not in it yet (`api/admin/workspaces/linkable-bots`), each added with `add-bot`, which pushes `server:bot-created`, and "New bot" stacking the bot form over it); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`).
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ ENV DATA_DIR=/data
|
|||||||
ENV DB_DIR=/data/db
|
ENV DB_DIR=/data/db
|
||||||
ENV BUN_FEATURE_FLAG_DISABLE_STREAMING_INSTALL=1
|
ENV BUN_FEATURE_FLAG_DISABLE_STREAMING_INSTALL=1
|
||||||
|
|
||||||
|
# The release's version, which Settings > General shows. The release
|
||||||
|
# workflow passes the published tag; a local build leaves it empty and
|
||||||
|
# the app falls back to `package.json`'s version.
|
||||||
|
ARG COBBLE_VERSION=
|
||||||
|
ENV COBBLE_VERSION=$COBBLE_VERSION
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN echo 'alias ll="ls -laF"' >/root/.bashrc
|
RUN echo 'alias ll="ls -laF"' >/root/.bashrc
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ The installer binds to `0.0.0.0` so the app is reachable from your phone on
|
|||||||
the same network. Use `--bind 127.0.0.1` when a reverse proxy is the only
|
the same network. Use `--bind 127.0.0.1` when a reverse proxy is the only
|
||||||
thing that should reach it.
|
thing that should reach it.
|
||||||
|
|
||||||
|
The app container itself is limited to 1 CPU and 2 GB of memory, since the
|
||||||
|
heavy work happens in the computers' own containers. Change that with
|
||||||
|
`--cpus` and `--memory`; each computer keeps its own limits.
|
||||||
|
|
||||||
## HTTPS
|
## HTTPS
|
||||||
|
|
||||||
Cobble serves plain HTTP. Terminate TLS in front of it with a reverse proxy
|
Cobble serves plain HTTP. Terminate TLS in front of it with a reverse proxy
|
||||||
@@ -147,6 +151,8 @@ curl -fsSL https://url.moduletrace.com/cobble | sudo bash -s -- --uninstall --pu
|
|||||||
| `--data-dir DIR` | Data directory mounted at `/data`, default `HOME/data` |
|
| `--data-dir DIR` | Data directory mounted at `/data`, default `HOME/data` |
|
||||||
| `--port PORT` | Host port, default `7227` |
|
| `--port PORT` | Host port, default `7227` |
|
||||||
| `--bind ADDRESS` | Host address to publish on, default `0.0.0.0` |
|
| `--bind ADDRESS` | Host address to publish on, default `0.0.0.0` |
|
||||||
|
| `--cpus N` | CPU cores for the app container, default `1` |
|
||||||
|
| `--memory MB` | Memory for the app container in MB, default `2048` |
|
||||||
| `--image IMAGE` | Container image, default `ghcr.io/moduletrace/cobble` |
|
| `--image IMAGE` | Container image, default `ghcr.io/moduletrace/cobble` |
|
||||||
| `--version TAG` | Image tag, default `latest` |
|
| `--version TAG` | Image tag, default `latest` |
|
||||||
| `--extra-network NET` | Join another Docker network, for a tunnel or reverse proxy |
|
| `--extra-network NET` | Join another Docker network, for a tunnel or reverse proxy |
|
||||||
|
|||||||
+3
-1
@@ -25,7 +25,9 @@ The repository starts private and is made public at the launch gate below.
|
|||||||
- rerun with no changes: reports up to date, no restart
|
- rerun with no changes: reports up to date, no restart
|
||||||
- update to a new tag: backup taken, data intact
|
- update to a new tag: backup taken, data intact
|
||||||
- `--uninstall` keeps data, `--uninstall --purge` removes it
|
- `--uninstall` keeps data, `--uninstall --purge` removes it
|
||||||
5. [ ] Update the version if the release changes it.
|
5. [ ] Update the version if the release changes it, including
|
||||||
|
`package.json`'s `version` (the image reports it on Settings > General
|
||||||
|
when it isn't built from a release tag).
|
||||||
6. [ ] Tag and publish a GitHub release:
|
6. [ ] Tag and publish a GitHub release:
|
||||||
|
|
||||||
git tag v0.1.0
|
git tag v0.1.0
|
||||||
|
|||||||
+128
-17
@@ -17,6 +17,10 @@ CONTAINER="cobble"
|
|||||||
NETWORK="cobble"
|
NETWORK="cobble"
|
||||||
DEFAULT_PORT="7227"
|
DEFAULT_PORT="7227"
|
||||||
DEFAULT_BIND="0.0.0.0"
|
DEFAULT_BIND="0.0.0.0"
|
||||||
|
# The app container's own limits (the computers' sandboxes are limited
|
||||||
|
# separately, per computer).
|
||||||
|
DEFAULT_CPUS="1"
|
||||||
|
DEFAULT_MEMORY_MB="2048"
|
||||||
DEFAULT_IMAGE="ghcr.io/moduletrace/cobble"
|
DEFAULT_IMAGE="ghcr.io/moduletrace/cobble"
|
||||||
DEFAULT_VERSION="latest"
|
DEFAULT_VERSION="latest"
|
||||||
INSTALL_URL="https://url.moduletrace.com/cobble"
|
INSTALL_URL="https://url.moduletrace.com/cobble"
|
||||||
@@ -69,6 +73,8 @@ Options:
|
|||||||
--data-dir DIR Data directory, mounted at /data (default HOME/data)
|
--data-dir DIR Data directory, mounted at /data (default HOME/data)
|
||||||
--port PORT Host port (default 7227)
|
--port PORT Host port (default 7227)
|
||||||
--bind ADDRESS Host address to publish on (default 0.0.0.0)
|
--bind ADDRESS Host address to publish on (default 0.0.0.0)
|
||||||
|
--cpus N CPU cores the app container may use (default 1)
|
||||||
|
--memory MB Memory the app container may use (default 2048)
|
||||||
--image IMAGE Container image (default $DEFAULT_IMAGE)
|
--image IMAGE Container image (default $DEFAULT_IMAGE)
|
||||||
--version TAG Image tag (default latest)
|
--version TAG Image tag (default latest)
|
||||||
--no-pull Use the image already on this machine
|
--no-pull Use the image already on this machine
|
||||||
@@ -144,6 +150,8 @@ fi
|
|||||||
DATA_DIR="${COBBLE_DATA_DIR:-}"
|
DATA_DIR="${COBBLE_DATA_DIR:-}"
|
||||||
PORT="${COBBLE_PORT:-}"
|
PORT="${COBBLE_PORT:-}"
|
||||||
BIND="${COBBLE_BIND:-}"
|
BIND="${COBBLE_BIND:-}"
|
||||||
|
CPUS="${COBBLE_CPUS:-}"
|
||||||
|
MEMORY_MB="${COBBLE_MEMORY_MB:-}"
|
||||||
IMAGE="${COBBLE_IMAGE:-}"
|
IMAGE="${COBBLE_IMAGE:-}"
|
||||||
VERSION="${COBBLE_VERSION:-}"
|
VERSION="${COBBLE_VERSION:-}"
|
||||||
EXTRA_NETWORK="${COBBLE_EXTRA_NETWORK:-}"
|
EXTRA_NETWORK="${COBBLE_EXTRA_NETWORK:-}"
|
||||||
@@ -170,6 +178,8 @@ fi
|
|||||||
DATA_DIR="${DATA_DIR:-$HOME_DIR/data}"
|
DATA_DIR="${DATA_DIR:-$HOME_DIR/data}"
|
||||||
PORT="${PORT:-$DEFAULT_PORT}"
|
PORT="${PORT:-$DEFAULT_PORT}"
|
||||||
BIND="${BIND:-$DEFAULT_BIND}"
|
BIND="${BIND:-$DEFAULT_BIND}"
|
||||||
|
CPUS="${CPUS:-$DEFAULT_CPUS}"
|
||||||
|
MEMORY_MB="${MEMORY_MB:-$DEFAULT_MEMORY_MB}"
|
||||||
IMAGE="${IMAGE:-$DEFAULT_IMAGE}"
|
IMAGE="${IMAGE:-$DEFAULT_IMAGE}"
|
||||||
VERSION="${VERSION:-$DEFAULT_VERSION}"
|
VERSION="${VERSION:-$DEFAULT_VERSION}"
|
||||||
|
|
||||||
@@ -191,6 +201,14 @@ while [ $# -gt 0 ]; do
|
|||||||
BIND="${2:-}"
|
BIND="${2:-}"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--cpus)
|
||||||
|
CPUS="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--memory)
|
||||||
|
MEMORY_MB="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
--image)
|
--image)
|
||||||
IMAGE="${2:-}"
|
IMAGE="${2:-}"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -256,6 +274,8 @@ done
|
|||||||
[ -n "$DATA_DIR" ] || fail "--data-dir needs a value"
|
[ -n "$DATA_DIR" ] || fail "--data-dir needs a value"
|
||||||
[ -n "$PORT" ] || fail "--port needs a value"
|
[ -n "$PORT" ] || fail "--port needs a value"
|
||||||
[ -n "$BIND" ] || fail "--bind needs a value"
|
[ -n "$BIND" ] || fail "--bind needs a value"
|
||||||
|
[ -n "$CPUS" ] || fail "--cpus needs a value"
|
||||||
|
[ -n "$MEMORY_MB" ] || fail "--memory needs a value"
|
||||||
[ -n "$IMAGE" ] || fail "--image needs a value"
|
[ -n "$IMAGE" ] || fail "--image needs a value"
|
||||||
[ -n "$VERSION" ] || fail "--version needs a value"
|
[ -n "$VERSION" ] || fail "--version needs a value"
|
||||||
|
|
||||||
@@ -276,6 +296,14 @@ esac
|
|||||||
case "$BIND" in
|
case "$BIND" in
|
||||||
'' | *[!0-9a-fA-F:.]*) fail "--bind must be an IP address" ;;
|
'' | *[!0-9a-fA-F:.]*) fail "--bind must be an IP address" ;;
|
||||||
esac
|
esac
|
||||||
|
case "$CPUS" in
|
||||||
|
'' | *[!0-9.]*) fail "--cpus must be a number" ;;
|
||||||
|
esac
|
||||||
|
[ "$(awk -v value="$CPUS" 'BEGIN { print (value > 0) ? 1 : 0 }')" = 1 ] || fail "--cpus must be greater than 0"
|
||||||
|
case "$MEMORY_MB" in
|
||||||
|
'' | *[!0-9]*) fail "--memory must be a number of megabytes" ;;
|
||||||
|
esac
|
||||||
|
[ "$MEMORY_MB" -ge 512 ] || fail "--memory must be at least 512"
|
||||||
case "$IMAGE" in
|
case "$IMAGE" in
|
||||||
'' | *[!A-Za-z0-9._/-]*) fail "--image may only contain letters, numbers, dot, dash, underscore and slash" ;;
|
'' | *[!A-Za-z0-9._/-]*) fail "--image may only contain letters, numbers, dot, dash, underscore and slash" ;;
|
||||||
esac
|
esac
|
||||||
@@ -317,23 +345,35 @@ if [ "$OS" = "Linux" ] && [ "$(id -u)" -ne 0 ]; then
|
|||||||
fail "Run the installer as root: curl -fsSL $INSTALL_URL | sudo bash"
|
fail "Run the installer as root: curl -fsSL $INSTALL_URL | sudo bash"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
SELINUX_SUFFIX=""
|
# SELinux hosts need the `z` mount option so docker relabels the bind
|
||||||
if [ "$OS" = "Linux" ]; then
|
# mount. It is a third colon-separated field (`src:/data:z`), never a
|
||||||
|
# suffix on the destination: docker reads `src:/data,z` as the directory
|
||||||
|
# "/data,z", which silently leaves the app writing to its own container
|
||||||
|
# layer, breaks every update (the container is recreated without its data)
|
||||||
|
# and makes computer provisioning fail. Never append to the destination.
|
||||||
|
# MARK:selinux-detection
|
||||||
|
selinux_enforcing() {
|
||||||
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce 2>/dev/null || true)" != "Disabled" ]; then
|
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce 2>/dev/null || true)" != "Disabled" ]; then
|
||||||
SELINUX_SUFFIX=",z"
|
return 0
|
||||||
elif [ -e /sys/fs/selinux/enforce ]; then
|
|
||||||
SELINUX_SUFFIX=",z"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
[ -e /sys/fs/selinux/enforce ]
|
||||||
|
}
|
||||||
|
|
||||||
ENV_MOUNT="$ENV_FILE:/app/.env:ro"
|
MOUNT_OPTS=""
|
||||||
DATA_MOUNT="$DATA_DIR:/data"
|
if [ "$OS" = "Linux" ] && selinux_enforcing; then
|
||||||
BACKUP_ARCHIVE_MOUNT="$BACKUP_ARCHIVE_DIR_HOST:/backups-archives"
|
MOUNT_OPTS="z"
|
||||||
if [ -n "$SELINUX_SUFFIX" ]; then
|
|
||||||
ENV_MOUNT="$ENV_FILE:/app/.env:ro,z"
|
|
||||||
DATA_MOUNT="$DATA_DIR:/data,z"
|
|
||||||
BACKUP_ARCHIVE_MOUNT="$BACKUP_ARCHIVE_DIR_HOST:/backups-archives,z"
|
|
||||||
fi
|
fi
|
||||||
|
# END:selinux-detection
|
||||||
|
|
||||||
|
# MARK:mount-specs
|
||||||
|
# A spec is `source:destination[:options]`. Options are comma separated and
|
||||||
|
# only count in that third field, so each spec below either opens the field
|
||||||
|
# with a colon or adds to one that is already there. A destination is
|
||||||
|
# always a plain absolute path.
|
||||||
|
ENV_MOUNT="$ENV_FILE:/app/.env:ro${MOUNT_OPTS:+,$MOUNT_OPTS}"
|
||||||
|
DATA_MOUNT="$DATA_DIR:/data${MOUNT_OPTS:+:$MOUNT_OPTS}"
|
||||||
|
BACKUP_ARCHIVE_MOUNT="$BACKUP_ARCHIVE_DIR_HOST:/backups-archives${MOUNT_OPTS:+:$MOUNT_OPTS}"
|
||||||
|
# END:mount-specs
|
||||||
|
|
||||||
# ---------------------------------------------------------------- helpers
|
# ---------------------------------------------------------------- helpers
|
||||||
|
|
||||||
@@ -428,6 +468,8 @@ container_matches() {
|
|||||||
[ "$(docker inspect -f '{{with index .HostConfig.PortBindings "'"$PORT"'/tcp"}}{{range .}}{{.HostIp}}:{{.HostPort}}{{end}}{{end}}' "$CONTAINER")" = "$BIND:$PORT" ] || return 1
|
[ "$(docker inspect -f '{{with index .HostConfig.PortBindings "'"$PORT"'/tcp"}}{{range .}}{{.HostIp}}:{{.HostPort}}{{end}}{{end}}' "$CONTAINER")" = "$BIND:$PORT" ] || return 1
|
||||||
[ "$(docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' "$CONTAINER")" = "unless-stopped" ] || return 1
|
[ "$(docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' "$CONTAINER")" = "unless-stopped" ] || return 1
|
||||||
[ "$(docker inspect -f '{{.HostConfig.Init}}' "$CONTAINER")" = "true" ] || return 1
|
[ "$(docker inspect -f '{{.HostConfig.Init}}' "$CONTAINER")" = "true" ] || return 1
|
||||||
|
[ "$(docker inspect -f '{{.HostConfig.NanoCpus}}' "$CONTAINER")" = "$(awk -v cpus="$CPUS" 'BEGIN { printf "%d", cpus * 1000000000 }')" ] || return 1
|
||||||
|
[ "$(docker inspect -f '{{.HostConfig.Memory}}' "$CONTAINER")" = "$((MEMORY_MB * 1024 * 1024))" ] || return 1
|
||||||
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "MAIN_SERVER=1" || return 1
|
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "MAIN_SERVER=1" || return 1
|
||||||
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "PORT=$PORT" || return 1
|
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "PORT=$PORT" || return 1
|
||||||
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "BACKUP_ARCHIVE_DIR=/backups-archives" || return 1
|
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "BACKUP_ARCHIVE_DIR=/backups-archives" || return 1
|
||||||
@@ -497,10 +539,7 @@ backup_database() {
|
|||||||
mkdir -p "$BACKUP_DIR"
|
mkdir -p "$BACKUP_DIR"
|
||||||
say "Backing up the database"
|
say "Backing up the database"
|
||||||
|
|
||||||
local backup_mount="$data_source:/data"
|
local backup_mount="$data_source:/data${MOUNT_OPTS:+:$MOUNT_OPTS}"
|
||||||
if [ -n "$SELINUX_SUFFIX" ]; then
|
|
||||||
backup_mount="$backup_mount,z"
|
|
||||||
fi
|
|
||||||
|
|
||||||
local image_id=""
|
local image_id=""
|
||||||
if container_running; then
|
if container_running; then
|
||||||
@@ -545,6 +584,71 @@ backup_database() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Whether the container's /data is really the host's data directory.
|
||||||
|
# Older installers wrote the SELinux option onto the destination
|
||||||
|
# (`$DATA_DIR:/data,z`), so docker mounted a directory named "/data,z"
|
||||||
|
# and the app kept its database in the container's own writable layer.
|
||||||
|
# Such a container must have that data lifted out before it is replaced.
|
||||||
|
data_mount_broken() {
|
||||||
|
container_exists || return 1
|
||||||
|
local mounted
|
||||||
|
mounted="$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}' "$CONTAINER" 2>/dev/null || true)"
|
||||||
|
[ "$mounted" != "$DATA_DIR" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Copies a container's unmounted /data back onto the host before the
|
||||||
|
# container is recreated, so a broken mount is repaired without losing the
|
||||||
|
# database, the media or the computers' home directories. Runs only when
|
||||||
|
# the container's /data is not the host's directory, and refuses to
|
||||||
|
# overwrite a host directory that already holds a database.
|
||||||
|
rescue_container_data() {
|
||||||
|
data_mount_broken || return 0
|
||||||
|
|
||||||
|
# Stop the app first: the copy must see a database nothing is writing
|
||||||
|
# to, and the container is about to be replaced anyway.
|
||||||
|
if container_running; then
|
||||||
|
say "Stopping Cobble to copy its data out"
|
||||||
|
docker stop -t 30 "$CONTAINER" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
local staged
|
||||||
|
staged="$(mktemp -d)"
|
||||||
|
|
||||||
|
if ! docker cp "$CONTAINER:/data/." "$staged/" >/dev/null 2>&1; then
|
||||||
|
rm -rf "$staged"
|
||||||
|
fail "Could not read the container's data. Its database is still in $CONTAINER; copy it out before updating."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A container that never wrote anything has nothing to rescue: the app
|
||||||
|
# is about to start fresh on the host directory.
|
||||||
|
if [ -z "$(ls -A "$staged" 2>/dev/null)" ]; then
|
||||||
|
rm -rf "$staged"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "This install's data was inside its container (a broken mount); moving it to $DATA_DIR"
|
||||||
|
|
||||||
|
if [ -n "$(ls -A "$DATA_DIR" 2>/dev/null)" ]; then
|
||||||
|
local aside
|
||||||
|
aside="$DATA_DIR.before-rescue-$(date +%s)"
|
||||||
|
warn "$DATA_DIR was not empty; the copy is kept at $aside"
|
||||||
|
mv "$DATA_DIR" "$aside" || fail "Could not move $DATA_DIR aside"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$DATA_DIR"
|
||||||
|
cp -a "$staged/." "$DATA_DIR/" || {
|
||||||
|
rm -rf "$staged"
|
||||||
|
fail "Could not copy the rescued data into $DATA_DIR"
|
||||||
|
}
|
||||||
|
rm -rf "$staged"
|
||||||
|
|
||||||
|
if [ -f "$DATA_DIR/db/cobble" ]; then
|
||||||
|
ok "Rescued the database and data into $DATA_DIR"
|
||||||
|
else
|
||||||
|
warn "Rescued data has no database file; the install will start empty"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
safe_remove_dir() {
|
safe_remove_dir() {
|
||||||
local dir="$1"
|
local dir="$1"
|
||||||
case "$dir" in
|
case "$dir" in
|
||||||
@@ -584,6 +688,8 @@ run_container() {
|
|||||||
--init \
|
--init \
|
||||||
--network "$NETWORK" \
|
--network "$NETWORK" \
|
||||||
-p "$BIND:$PORT:$PORT" \
|
-p "$BIND:$PORT:$PORT" \
|
||||||
|
--cpus "$CPUS" \
|
||||||
|
--memory "${MEMORY_MB}m" \
|
||||||
"${mounts[@]}" \
|
"${mounts[@]}" \
|
||||||
"${envs[@]}" \
|
"${envs[@]}" \
|
||||||
--log-driver json-file \
|
--log-driver json-file \
|
||||||
@@ -719,6 +825,8 @@ unset_env HTTP_REDIRECT_PORT
|
|||||||
printf 'COBBLE_BACKUP_ARCHIVE_DIR=%q\n' "$BACKUP_ARCHIVE_DIR_HOST"
|
printf 'COBBLE_BACKUP_ARCHIVE_DIR=%q\n' "$BACKUP_ARCHIVE_DIR_HOST"
|
||||||
printf 'COBBLE_PORT=%q\n' "$PORT"
|
printf 'COBBLE_PORT=%q\n' "$PORT"
|
||||||
printf 'COBBLE_BIND=%q\n' "$BIND"
|
printf 'COBBLE_BIND=%q\n' "$BIND"
|
||||||
|
printf 'COBBLE_CPUS=%q\n' "$CPUS"
|
||||||
|
printf 'COBBLE_MEMORY_MB=%q\n' "$MEMORY_MB"
|
||||||
printf 'COBBLE_IMAGE=%q\n' "$IMAGE"
|
printf 'COBBLE_IMAGE=%q\n' "$IMAGE"
|
||||||
printf 'COBBLE_VERSION=%q\n' "$VERSION"
|
printf 'COBBLE_VERSION=%q\n' "$VERSION"
|
||||||
printf 'COBBLE_EXTRA_NETWORK=%q\n' "$EXTRA_NETWORK"
|
printf 'COBBLE_EXTRA_NETWORK=%q\n' "$EXTRA_NETWORK"
|
||||||
@@ -755,6 +863,9 @@ if [ "$FORCE" = 0 ] && container_matches; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if container_exists; then
|
if container_exists; then
|
||||||
|
# A container whose /data was never mounted keeps the database in its
|
||||||
|
# own layer, so lift it out before anything replaces the container.
|
||||||
|
rescue_container_data
|
||||||
backup_database
|
backup_database
|
||||||
say "Replacing the Cobble container"
|
say "Replacing the Cobble container"
|
||||||
docker stop -t 30 "$CONTAINER" >/dev/null 2>&1 || true
|
docker stop -t 30 "$CONTAINER" >/dev/null 2>&1 || true
|
||||||
|
|||||||
+220
@@ -0,0 +1,220 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const ROOT = import.meta.dir;
|
||||||
|
const INSTALL_SCRIPT = path.join(ROOT, "install.sh");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifts one marked block out of `install.sh`, so the tests run the real
|
||||||
|
* installer code instead of a copy of it that could drift.
|
||||||
|
*/
|
||||||
|
async function readMarkedBlock({ name }: { name: string }): Promise<string> {
|
||||||
|
const source = await Bun.file(INSTALL_SCRIPT).text();
|
||||||
|
const start = `# MARK:${name}\n`;
|
||||||
|
const end = `# END:${name}`;
|
||||||
|
const from = source.indexOf(start);
|
||||||
|
const to = source.indexOf(end);
|
||||||
|
|
||||||
|
expect(from).toBeGreaterThan(-1);
|
||||||
|
expect(to).toBeGreaterThan(from);
|
||||||
|
|
||||||
|
return source.slice(from + start.length, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a bash snippet and returns its stdout, failing on any stderr. */
|
||||||
|
async function runBash({ script }: { script: string }): Promise<string> {
|
||||||
|
const proc = Bun.spawn({
|
||||||
|
cmd: ["bash", "-c", script],
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [stdout, stderr, code] = await Promise.all([
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
proc.exited,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(stderr).toBe("");
|
||||||
|
expect(code).toBe(0);
|
||||||
|
|
||||||
|
return stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
type MountSpecs = {
|
||||||
|
env: string;
|
||||||
|
data: string;
|
||||||
|
backup: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The three mount specs the installer builds for one SELinux state. */
|
||||||
|
async function runMountSpecs({
|
||||||
|
selinux,
|
||||||
|
}: {
|
||||||
|
selinux: boolean;
|
||||||
|
}): Promise<MountSpecs> {
|
||||||
|
const block = await readMarkedBlock({ name: "mount-specs" });
|
||||||
|
|
||||||
|
const script = [
|
||||||
|
`ENV_FILE="/opt/cobble/.env"`,
|
||||||
|
`DATA_DIR="/opt/cobble/data"`,
|
||||||
|
`BACKUP_ARCHIVE_DIR_HOST="/opt/cobble/backups-archives"`,
|
||||||
|
`MOUNT_OPTS="${selinux ? "z" : ""}"`,
|
||||||
|
block,
|
||||||
|
`printf '%s\\n' "$ENV_MOUNT" "$DATA_MOUNT" "$BACKUP_ARCHIVE_MOUNT"`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const [env = "", data = "", backup = ""] = (
|
||||||
|
await runBash({ script })
|
||||||
|
).split("\n");
|
||||||
|
|
||||||
|
return { env, data, backup };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a `source:destination[:options]` spec the way docker does, so a
|
||||||
|
* destination that accidentally swallowed an option is caught.
|
||||||
|
*/
|
||||||
|
function parseMountSpec({ spec }: { spec: string }) {
|
||||||
|
const [source = "", destination = "", ...rest] = spec.split(":");
|
||||||
|
|
||||||
|
return { source, destination, options: rest.join(":") };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("install.sh SELinux detection", () => {
|
||||||
|
test("asks for the mount option when SELinux is enforcing", async () => {
|
||||||
|
const block = await readMarkedBlock({
|
||||||
|
name: "selinux-detection",
|
||||||
|
});
|
||||||
|
|
||||||
|
// An enforcing `getenforce` answers before the /sys fallback, so
|
||||||
|
// this result does not depend on the machine running the test.
|
||||||
|
const script = [
|
||||||
|
`OS="Linux"`,
|
||||||
|
`getenforce() { printf '%s' "Enforcing"; }`,
|
||||||
|
block,
|
||||||
|
`printf '%s' "$MOUNT_OPTS"`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
expect(await runBash({ script })).toBe("z");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("asks for no mount option when SELinux is disabled", async () => {
|
||||||
|
const block = await readMarkedBlock({
|
||||||
|
name: "selinux-detection",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Answering `Disabled` makes the block fall through to the
|
||||||
|
// /sys/fs/selinux/enforce probe. Shadowing the path check keeps the
|
||||||
|
// result the same on an SELinux host and on a plain one.
|
||||||
|
const script = [
|
||||||
|
`OS="Linux"`,
|
||||||
|
`getenforce() { printf '%s' "Disabled"; }`,
|
||||||
|
`SELINUX_ENFORCE_FILE="/nonexistent/cobble-test"`,
|
||||||
|
block,
|
||||||
|
`printf '%s' "$MOUNT_OPTS"`,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
expect(await runBash({ script })).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("install.sh mount specs", () => {
|
||||||
|
for (const selinux of [false, true]) {
|
||||||
|
const label = selinux ? "with SELinux" : "without SELinux";
|
||||||
|
|
||||||
|
test(`mounts the data directory at /data ${label}`, async () => {
|
||||||
|
const { data } = await runMountSpecs({ selinux });
|
||||||
|
const { source, destination, options } = parseMountSpec({
|
||||||
|
spec: data,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The regression: the SELinux option must be its own field.
|
||||||
|
// Gluing it onto the destination makes docker mount a
|
||||||
|
// directory named "/data,z", so the app keeps its database in
|
||||||
|
// its own container layer, every update wipes the install and
|
||||||
|
// computer provisioning fails.
|
||||||
|
expect(source).toBe("/opt/cobble/data");
|
||||||
|
expect(destination).toBe("/data");
|
||||||
|
expect(options).toBe(selinux ? "z" : "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`mounts the backup archive directory ${label}`, async () => {
|
||||||
|
const { backup } = await runMountSpecs({ selinux });
|
||||||
|
const { source, destination, options } = parseMountSpec({
|
||||||
|
spec: backup,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(source).toBe("/opt/cobble/backups-archives");
|
||||||
|
expect(destination).toBe("/backups-archives");
|
||||||
|
expect(options).toBe(selinux ? "z" : "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`mounts .env read-only ${label}`, async () => {
|
||||||
|
const { env } = await runMountSpecs({ selinux });
|
||||||
|
const { source, destination, options } = parseMountSpec({
|
||||||
|
spec: env,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(source).toBe("/opt/cobble/.env");
|
||||||
|
expect(destination).toBe("/app/.env");
|
||||||
|
// Docker splits options on commas, never on colons: `ro:z` is
|
||||||
|
// an unknown option, and the container refuses to start.
|
||||||
|
expect(options).toBe(selinux ? "ro,z" : "ro");
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`keeps every option in its own field ${label}`, async () => {
|
||||||
|
const specs = await runMountSpecs({ selinux });
|
||||||
|
|
||||||
|
for (const spec of Object.values(specs)) {
|
||||||
|
const { destination, options } = parseMountSpec({ spec });
|
||||||
|
|
||||||
|
expect(destination).not.toContain(",");
|
||||||
|
expect(destination).not.toContain(":");
|
||||||
|
expect(destination.startsWith("/")).toBe(true);
|
||||||
|
|
||||||
|
// Only the options docker understands, comma separated.
|
||||||
|
for (const option of options.split(",")) {
|
||||||
|
expect(["", "ro", "rw", "z", "Z"]).toContain(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("install.sh data rescue", () => {
|
||||||
|
test("rescues the container's data before replacing it", async () => {
|
||||||
|
const source = await Bun.file(INSTALL_SCRIPT).text();
|
||||||
|
const rescue = source.indexOf("\n rescue_container_data\n");
|
||||||
|
const replace = source.indexOf(
|
||||||
|
`say "Replacing the Cobble container"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A container whose /data was never mounted keeps its database in
|
||||||
|
// its own layer, so the copy has to happen before the container is
|
||||||
|
// removed.
|
||||||
|
expect(rescue).toBeGreaterThan(-1);
|
||||||
|
expect(replace).toBeGreaterThan(rescue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the database backup uses the same mount options", async () => {
|
||||||
|
const source = await Bun.file(INSTALL_SCRIPT).text();
|
||||||
|
|
||||||
|
expect(source).toContain(`backup_mount="$data_source:/data`);
|
||||||
|
expect(source).toContain("${MOUNT_OPTS:+:$MOUNT_OPTS}");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no code line glues an option onto a destination", async () => {
|
||||||
|
const source = await Bun.file(INSTALL_SCRIPT).text();
|
||||||
|
|
||||||
|
// Comments may quote the old broken form; only real code counts.
|
||||||
|
const code = source
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => !line.trimStart().startsWith("#"))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
expect(code).not.toContain("/data,z");
|
||||||
|
expect(code).not.toContain("/app/.env:ro,z");
|
||||||
|
expect(code).not.toContain("/backups-archives,z");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cobble",
|
"name": "cobble",
|
||||||
|
"version": "0.1.2",
|
||||||
"module": "index.ts",
|
"module": "index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
import type { BUN_SQLITE_COBBLE_APP_SETTINGS } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_APP_SETTINGS } from "@/db/types/db";
|
||||||
|
import globalCache from "@/src/utils/global-cache";
|
||||||
|
|
||||||
let cache: Map<string, string> | null = null;
|
/**
|
||||||
|
* The cache lives on `globalThis`, behind a registered symbol, rather than
|
||||||
|
* in a module variable: the page and API bundles each carry their own copy
|
||||||
|
* of this module, so a module-scoped cache would let a save clear its own
|
||||||
|
* copy while another page kept serving a stale one until the next restart.
|
||||||
|
*/
|
||||||
|
const cache = globalCache<Map<string, string>>({
|
||||||
|
name: "cobble.grabAppSettings.cache",
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every install-wide setting, cached per process (like `grabAllEnvs`).
|
* Every install-wide setting, cached per process (like `grabAllEnvs`).
|
||||||
* `saveAppSetting` and `deleteAppSetting` clear it.
|
* `saveAppSetting` and `deleteAppSetting` clear it.
|
||||||
*/
|
*/
|
||||||
export default async function grabAppSettings() {
|
export default async function grabAppSettings() {
|
||||||
if (cache) return cache;
|
const cached = cache.get();
|
||||||
|
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_APP_SETTINGS>({
|
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_APP_SETTINGS>({
|
||||||
sql: `SELECT key, value FROM app_settings`,
|
sql: `SELECT key, value FROM app_settings`,
|
||||||
@@ -21,7 +32,7 @@ export default async function grabAppSettings() {
|
|||||||
if (row.key) map.set(row.key, row.value || "");
|
if (row.key) map.set(row.key, row.value || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
cache = map;
|
cache.set(map);
|
||||||
|
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
@@ -30,5 +41,5 @@ export default async function grabAppSettings() {
|
|||||||
* Drops the cache, so the next read sees the database.
|
* Drops the cache, so the next read sees the database.
|
||||||
*/
|
*/
|
||||||
export function clearAppSettingsCache() {
|
export function clearAppSettingsCache() {
|
||||||
cache = null;
|
cache.clear();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import BunSQLite from "@moduletrace/bun-sqlite";
|
|||||||
import type { BUN_SQLITE_COBBLE_ENVS } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_ENVS } from "@/db/types/db";
|
||||||
import type { EnvParadigm } from "@/src/dict/env-dict";
|
import type { EnvParadigm } from "@/src/dict/env-dict";
|
||||||
import { decrypt } from "@/src/utils/crypt";
|
import { decrypt } from "@/src/utils/crypt";
|
||||||
|
import globalCache from "@/src/utils/global-cache";
|
||||||
|
|
||||||
export type DecryptedEnv = {
|
export type DecryptedEnv = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -16,7 +17,16 @@ export type DecryptedEnv = {
|
|||||||
expose_to_computers: boolean;
|
expose_to_computers: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let cache: Promise<DecryptedEnv[]> | undefined;
|
/**
|
||||||
|
* The cache lives on `globalThis`, behind a registered symbol, rather than
|
||||||
|
* in a module variable: the page and API bundles each carry their own copy
|
||||||
|
* of this module, so a module-scoped cache would let `save-env` clear its
|
||||||
|
* own copy while the page kept serving a stale one until the next restart
|
||||||
|
* (a saved API key never reached the models shown in the app).
|
||||||
|
*/
|
||||||
|
const cache = globalCache<Promise<DecryptedEnv[]>>({
|
||||||
|
name: "cobble.grabAllEnvs.cache",
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every env, decrypted, cached in this process until `clearEnvCache` (every
|
* Every env, decrypted, cached in this process until `clearEnvCache` (every
|
||||||
@@ -24,16 +34,22 @@ let cache: Promise<DecryptedEnv[]> | undefined;
|
|||||||
* encryption key changed) reads as empty.
|
* encryption key changed) reads as empty.
|
||||||
*/
|
*/
|
||||||
export default function grabAllEnvs(): Promise<DecryptedEnv[]> {
|
export default function grabAllEnvs(): Promise<DecryptedEnv[]> {
|
||||||
cache ||= load().catch((error) => {
|
const cached = cache.get();
|
||||||
cache = undefined;
|
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const loading = load().catch((error: unknown) => {
|
||||||
|
cache.clear();
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
|
||||||
return cache;
|
cache.set(loading);
|
||||||
|
|
||||||
|
return loading;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearEnvCache() {
|
export function clearEnvCache() {
|
||||||
cache = undefined;
|
cache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
|||||||
@@ -21,6 +21,22 @@ const server: BunextPageServerFn<PagePropsType> = async ({ req, url }) => {
|
|||||||
const is_signup_page = url.pathname.startsWith("/auth/signup");
|
const is_signup_page = url.pathname.startsWith("/auth/signup");
|
||||||
const has_users = await grabHasUsers();
|
const has_users = await grabHasUsers();
|
||||||
|
|
||||||
|
if (
|
||||||
|
!has_users &&
|
||||||
|
user?.logged_in_status &&
|
||||||
|
!url.pathname.includes(`auth/logout`)
|
||||||
|
) {
|
||||||
|
return redirect({ destination: "/auth/logout" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!has_users &&
|
||||||
|
user?.logged_in_status &&
|
||||||
|
url.pathname.includes(`auth/logout`)
|
||||||
|
) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
if (!has_users && !is_signup_page) {
|
if (!has_users && !is_signup_page) {
|
||||||
return redirect({ destination: "/auth/signup" });
|
return redirect({ destination: "/auth/signup" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { PagePropsType } from "@/src/types";
|
|||||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||||
import grabStorageQuotaOverview from "@/src/functions/backend/db/app-settings/grab-storage-quota-overview";
|
import grabStorageQuotaOverview from "@/src/functions/backend/db/app-settings/grab-storage-quota-overview";
|
||||||
import { grabSystemPromptAppendOverview } from "@/src/functions/backend/db/app-settings/grab-system-prompt-append";
|
import { grabSystemPromptAppendOverview } from "@/src/functions/backend/db/app-settings/grab-system-prompt-append";
|
||||||
|
import runningVersion from "@/src/utils/running-version";
|
||||||
|
|
||||||
type Params = Parameters<BunextPageServerFn<PagePropsType>>[0];
|
type Params = Parameters<BunextPageServerFn<PagePropsType>>[0];
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ export default async function grabSettingsServerProps({ req }: Params) {
|
|||||||
has_password,
|
has_password,
|
||||||
storage_quota: await grabStorageQuotaOverview().catch(() => null),
|
storage_quota: await grabStorageQuotaOverview().catch(() => null),
|
||||||
system_prompt_append: await grabSystemPromptAppendOverview().catch(() => null),
|
system_prompt_append: await grabSystemPromptAppendOverview().catch(() => null),
|
||||||
|
running_version: runningVersion({ env: process.env }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,16 @@ import { useState } from "react";
|
|||||||
import Row from "@moduletrace/twui/layout/Row";
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
import Span from "@moduletrace/twui/layout/Span";
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
import Stack from "@moduletrace/twui/layout/Stack";
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
import type { EnvScope, EnvsMutationResult, KnownEnvStatus, PublicEnv } from "@/src/types";
|
import type {
|
||||||
|
EnvScope,
|
||||||
|
EnvsMutationResult,
|
||||||
|
KnownEnvStatus,
|
||||||
|
PublicEnv,
|
||||||
|
} from "@/src/types";
|
||||||
import postEnvApi from "../../(functions)/post-env-api";
|
import postEnvApi from "../../(functions)/post-env-api";
|
||||||
import EnvForm from "./env-form";
|
import EnvForm from "./env-form";
|
||||||
import EnvRowActions from "./env-row-actions";
|
import EnvRowActions from "./env-row-actions";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
scope: EnvScope;
|
scope: EnvScope;
|
||||||
@@ -27,14 +33,26 @@ type Props = {
|
|||||||
* One env: its key, description and value (or "secret"), with Edit and
|
* One env: its key, description and value (or "secret"), with Edit and
|
||||||
* Remove. Edit opens the form in place.
|
* Remove. Edit opens the form in place.
|
||||||
*/
|
*/
|
||||||
export default function EnvRow({ scope, env, known, scoped, onChanged, onError }: Props) {
|
export default function EnvRow({
|
||||||
|
scope,
|
||||||
|
env,
|
||||||
|
known,
|
||||||
|
scoped,
|
||||||
|
onChanged,
|
||||||
|
onError,
|
||||||
|
}: Props) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const key = env?.key || known?.key || "";
|
const key = env?.key || known?.key || "";
|
||||||
|
|
||||||
const remove = async () => {
|
const remove = async () => {
|
||||||
if (!env || busy) return;
|
if (!env || busy) return;
|
||||||
if (!window.confirm(`Remove ${key}? Computers drop it on their next run.`)) return;
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Remove ${key}? Computers drop it on their next run.`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
const res = await postEnvApi({
|
const res = await postEnvApi({
|
||||||
@@ -43,7 +61,8 @@ export default function EnvRow({ scope, env, known, scoped, onChanged, onError }
|
|||||||
});
|
});
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
|
||||||
if (res.success && res.singleRes) onChanged(res.singleRes, res.msg || "Removed.");
|
if (res.success && res.singleRes)
|
||||||
|
onChanged(res.singleRes, res.msg || "Removed.");
|
||||||
else onError(res.msg || "Couldn't remove the env.");
|
else onError(res.msg || "Couldn't remove the env.");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,12 +91,22 @@ export default function EnvRow({ scope, env, known, scoped, onChanged, onError }
|
|||||||
? "From the server's environment"
|
? "From the server's environment"
|
||||||
: "Not set";
|
: "Not set";
|
||||||
|
|
||||||
const flags = env && !env.known && !env.expose_to_computers ? " · not on computers" : "";
|
const flags =
|
||||||
|
env && !env.known && !env.expose_to_computers
|
||||||
|
? " · not on computers"
|
||||||
|
: "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className="w-full gap-3! flex-nowrap items-start justify-between py-3 border-0 border-b border-solid border-hairline last:border-b-0">
|
<Row
|
||||||
|
className={twMerge(
|
||||||
|
"w-full gap-3! flex-nowrap items-start justify-between py-3 px-4",
|
||||||
|
"border-0 border-b border-solid border-hairline last:border-b-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Stack className="gap-0.5! min-w-0">
|
<Stack className="gap-0.5! min-w-0">
|
||||||
<Span className="text-sm font-bold leading-5 text-ink truncate">{key}</Span>
|
<Span className="text-sm font-bold leading-5 text-ink truncate">
|
||||||
|
{key}
|
||||||
|
</Span>
|
||||||
{env?.description || known?.description ? (
|
{env?.description || known?.description ? (
|
||||||
<Span className="text-[13px] leading-[18px] text-ink-secondary break-words">
|
<Span className="text-[13px] leading-[18px] text-ink-secondary break-words">
|
||||||
{known?.description || env?.description}
|
{known?.description || env?.description}
|
||||||
|
|||||||
@@ -34,20 +34,34 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const Descriptions: Record<EnvScope["paradigm"], string> = {
|
const Descriptions: Record<EnvScope["paradigm"], string> = {
|
||||||
general: "Set everywhere. A workspace, a bot or a computer can set the same key to override it.",
|
general:
|
||||||
workspace: "For every bot working here. Beats general, loses to the bot's own.",
|
"Set everywhere. A workspace, a bot or a computer can set the same key to override it.",
|
||||||
computer: "For this computer only. Beats a workspace, loses to the bot's own.",
|
workspace:
|
||||||
|
"For every bot working here. Beats general, loses to the bot's own.",
|
||||||
|
computer:
|
||||||
|
"For this computer only. Beats a workspace, loses to the bot's own.",
|
||||||
bot: "For this bot only, everywhere it works. Beats the workspace, the computer and general.",
|
bot: "For this bot only, everywhere it works. Beats the workspace, the computer and general.",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The envs bots and computers use, in one scope, with an add form.
|
* The envs bots and computers use, in one scope, with an add form.
|
||||||
*/
|
*/
|
||||||
export default function EnvsGroup({ scope, envs, scoped, title, status, onChanged, onError }: Props) {
|
export default function EnvsGroup({
|
||||||
|
scope,
|
||||||
|
envs,
|
||||||
|
scoped,
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
onChanged,
|
||||||
|
onError,
|
||||||
|
}: Props) {
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsGroup title={title || "Envs"} description={Descriptions[scope.paradigm]}>
|
<SettingsGroup
|
||||||
|
title={title || "Envs"}
|
||||||
|
description={Descriptions[scope.paradigm]}
|
||||||
|
>
|
||||||
<SettingsList is_empty={!envs.length} empty="Nothing set here yet.">
|
<SettingsList is_empty={!envs.length} empty="Nothing set here yet.">
|
||||||
{envs.map((env) => (
|
{envs.map((env) => (
|
||||||
<EnvRow
|
<EnvRow
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import type { RunningVersion } from "@/src/utils/running-version";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
running?: RunningVersion | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The release this server runs, in words: settings are where someone
|
||||||
|
* checks whether an update landed. A development server says so beside
|
||||||
|
* its version, so the two are never mistaken.
|
||||||
|
*/
|
||||||
|
export default function VersionLine({ running }: Props) {
|
||||||
|
if (!running) return null;
|
||||||
|
|
||||||
|
const version = running.version
|
||||||
|
? `Cobble ${running.version}`
|
||||||
|
: "Cobble";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Span className="text-sm leading-5 text-ink">
|
||||||
|
{running.mode == "development"
|
||||||
|
? `${version} (development)`
|
||||||
|
: version}
|
||||||
|
</Span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { AppContext } from "@/src/pages/__root";
|
||||||
|
import SettingsGroup from "../../(partials)/settings-group";
|
||||||
|
import VersionLine from "../(partials)/version-line";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this server is running, so an update can be confirmed from the app
|
||||||
|
* instead of the installer's output.
|
||||||
|
*/
|
||||||
|
export default function VersionSection() {
|
||||||
|
const { pageProps } = useContext(AppContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsGroup
|
||||||
|
title="Version"
|
||||||
|
description="The release this server runs, so an update can be confirmed from the app."
|
||||||
|
id="version"
|
||||||
|
>
|
||||||
|
<VersionLine running={pageProps?.running_version} />
|
||||||
|
</SettingsGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import AppearanceSection from "./(sections)/appearance-section";
|
import AppearanceSection from "./(sections)/appearance-section";
|
||||||
import PromptSection from "./(sections)/prompt-section";
|
import PromptSection from "./(sections)/prompt-section";
|
||||||
import StorageSection from "./(sections)/storage-section";
|
import StorageSection from "./(sections)/storage-section";
|
||||||
|
import VersionSection from "./(sections)/version-section";
|
||||||
import SettingsShell from "../(partials)/settings-shell";
|
import SettingsShell from "../(partials)/settings-shell";
|
||||||
import settingsPageMeta from "../(functions)/settings-page-meta";
|
import settingsPageMeta from "../(functions)/settings-page-meta";
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ export default function AdminSettingsGeneralPage() {
|
|||||||
<AppearanceSection />
|
<AppearanceSection />
|
||||||
<PromptSection />
|
<PromptSection />
|
||||||
<StorageSection />
|
<StorageSection />
|
||||||
|
<VersionSection />
|
||||||
</SettingsShell>
|
</SettingsShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type {
|
|||||||
} from "@/db/types/db";
|
} from "@/db/types/db";
|
||||||
import type { UserTypes } from "../dict/user-types-dict";
|
import type { UserTypes } from "../dict/user-types-dict";
|
||||||
import type { StorageVolume } from "../utils/storage-volumes";
|
import type { StorageVolume } from "../utils/storage-volumes";
|
||||||
|
import type { RunningVersion } from "../utils/running-version";
|
||||||
import type { WebsocketRooms } from "../dict/websocket-rooms";
|
import type { WebsocketRooms } from "../dict/websocket-rooms";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import type { ServerQueryParam } from "@moduletrace/bun-sqlite";
|
import type { ServerQueryParam } from "@moduletrace/bun-sqlite";
|
||||||
@@ -1038,6 +1039,10 @@ export type PagePropsType = {
|
|||||||
setting_key: string;
|
setting_key: string;
|
||||||
text: string;
|
text: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
/**
|
||||||
|
* The release the server is running, for the General settings page.
|
||||||
|
*/
|
||||||
|
running_version?: RunningVersion | null;
|
||||||
push_overview?: PushOverview | null;
|
push_overview?: PushOverview | null;
|
||||||
mcps_overview?: McpServersOverviewPayload | null;
|
mcps_overview?: McpServersOverviewPayload | null;
|
||||||
tools_overview?: CustomToolsOverviewPayload | null;
|
tools_overview?: CustomToolsOverviewPayload | null;
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import globalCache from "./global-cache";
|
||||||
|
|
||||||
|
describe("globalCache", () => {
|
||||||
|
test("keeps a value and clears it", () => {
|
||||||
|
const cache = globalCache<number>({ name: "cobble.grabAllEnvs.cache" });
|
||||||
|
|
||||||
|
cache.clear();
|
||||||
|
expect(cache.get()).toBeUndefined();
|
||||||
|
|
||||||
|
cache.set(7);
|
||||||
|
expect(cache.get()).toBe(7);
|
||||||
|
|
||||||
|
cache.clear();
|
||||||
|
expect(cache.get()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two slots with one name share a value, as two bundles would", () => {
|
||||||
|
const page_bundle = globalCache<string>({
|
||||||
|
name: "cobble.grabAppSettings.cache",
|
||||||
|
});
|
||||||
|
const api_bundle = globalCache<string>({
|
||||||
|
name: "cobble.grabAppSettings.cache",
|
||||||
|
});
|
||||||
|
|
||||||
|
page_bundle.clear();
|
||||||
|
page_bundle.set("stale");
|
||||||
|
|
||||||
|
// The API sees what the page wrote, so a save there clears it too.
|
||||||
|
expect(api_bundle.get()).toBe("stale");
|
||||||
|
|
||||||
|
api_bundle.clear();
|
||||||
|
expect(page_bundle.get()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("different names never share a slot", () => {
|
||||||
|
const envs = globalCache<string>({ name: "cobble.grabAllEnvs.cache" });
|
||||||
|
const settings = globalCache<string>({
|
||||||
|
name: "cobble.grabAppSettings.cache",
|
||||||
|
});
|
||||||
|
|
||||||
|
envs.clear();
|
||||||
|
settings.clear();
|
||||||
|
|
||||||
|
envs.set("a");
|
||||||
|
settings.set("b");
|
||||||
|
|
||||||
|
expect(envs.get()).toBe("a");
|
||||||
|
expect(settings.get()).toBe("b");
|
||||||
|
|
||||||
|
envs.clear();
|
||||||
|
settings.clear();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* A per-process cache slot that survives module duplication.
|
||||||
|
*
|
||||||
|
* bunext bundles pages and API routes separately, so a module-scoped `let`
|
||||||
|
* cache exists once per bundle: a save that clears its own copy leaves the
|
||||||
|
* other one serving stale data until the process restarts (a saved API key
|
||||||
|
* never reached the model list). The key is taken from the global symbol
|
||||||
|
* registry, which resolves to the same symbol in every bundle, and the value
|
||||||
|
* is stored on `globalThis`, so every copy reads and clears one slot.
|
||||||
|
*/
|
||||||
|
type Slot<Value> = {
|
||||||
|
get(): Value | undefined;
|
||||||
|
set(value: Value): void;
|
||||||
|
clear(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Named caches, so two modules never share a slot by accident.
|
||||||
|
*/
|
||||||
|
export type GlobalCacheName =
|
||||||
|
| "cobble.grabAllEnvs.cache"
|
||||||
|
| "cobble.grabAppSettings.cache";
|
||||||
|
|
||||||
|
export default function globalCache<Value>({
|
||||||
|
name,
|
||||||
|
}: {
|
||||||
|
name: GlobalCacheName;
|
||||||
|
}): Slot<Value> {
|
||||||
|
const key = Symbol.for(name);
|
||||||
|
const holder = globalThis as Record<symbol, Value | undefined>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
get: () => holder[key],
|
||||||
|
set: (value: Value) => {
|
||||||
|
holder[key] = value;
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
delete holder[key];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import runningVersion from "./running-version";
|
||||||
|
|
||||||
|
describe("runningVersion", () => {
|
||||||
|
test("a release reports the build's version", () => {
|
||||||
|
expect(
|
||||||
|
runningVersion({
|
||||||
|
env: { NODE_ENV: "production", COBBLE_VERSION: "v0.1.1" },
|
||||||
|
}),
|
||||||
|
).toEqual({ version: "v0.1.1", mode: "production" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a production build without a version falls back to the source's", () => {
|
||||||
|
expect(
|
||||||
|
runningVersion({
|
||||||
|
env: { NODE_ENV: "production" },
|
||||||
|
package_version: "0.1.1",
|
||||||
|
}),
|
||||||
|
).toEqual({ version: "0.1.1", mode: "production" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a development server reports the source's version too", () => {
|
||||||
|
expect(
|
||||||
|
runningVersion({
|
||||||
|
env: { NODE_ENV: "development" },
|
||||||
|
package_version: "0.1.1",
|
||||||
|
}),
|
||||||
|
).toEqual({ version: "0.1.1", mode: "development" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a development server with a build version prefers it", () => {
|
||||||
|
expect(
|
||||||
|
runningVersion({
|
||||||
|
env: { NODE_ENV: "development", COBBLE_VERSION: "v0.2.0" },
|
||||||
|
package_version: "0.1.1",
|
||||||
|
}),
|
||||||
|
).toEqual({ version: "v0.2.0", mode: "development" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no NODE_ENV at all is a development server", () => {
|
||||||
|
expect(runningVersion({ env: {}, package_version: "0.1.1" })).toEqual({
|
||||||
|
version: "0.1.1",
|
||||||
|
mode: "development",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unusable version is dropped rather than shown", () => {
|
||||||
|
expect(
|
||||||
|
runningVersion({
|
||||||
|
env: { NODE_ENV: "production", COBBLE_VERSION: "bad version!" },
|
||||||
|
package_version: "",
|
||||||
|
}),
|
||||||
|
).toEqual({ version: null, mode: "production" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty build version falls back to the source's", () => {
|
||||||
|
expect(
|
||||||
|
runningVersion({
|
||||||
|
env: { NODE_ENV: "production", COBBLE_VERSION: " " },
|
||||||
|
package_version: "0.1.1",
|
||||||
|
}),
|
||||||
|
).toEqual({ version: "0.1.1", mode: "production" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import package_json from "@/package.json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the General settings page shows as the running Cobble.
|
||||||
|
*/
|
||||||
|
export type RunningVersion = {
|
||||||
|
/**
|
||||||
|
* The release's version: the image tag the installer pulled
|
||||||
|
* (`COBBLE_VERSION`, set when the image was built), else
|
||||||
|
* `package.json`'s version, which a development server reports too.
|
||||||
|
*/
|
||||||
|
version: string | null;
|
||||||
|
/**
|
||||||
|
* Where the live server runs (`NODE_ENV`): `production`,
|
||||||
|
* `development` or the environment's own name when it set another.
|
||||||
|
*/
|
||||||
|
mode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
/**
|
||||||
|
* The process environment (`process.env`).
|
||||||
|
*/
|
||||||
|
env: Record<string, string | undefined>;
|
||||||
|
/**
|
||||||
|
* `package.json`'s version, for a build that wasn't given one.
|
||||||
|
*/
|
||||||
|
package_version?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VersionPattern = /^[A-Za-z0-9._-]{1,64}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The version a running Cobble reports, development included: the image's
|
||||||
|
* build argument when it has one (the release tag), else the source's own
|
||||||
|
* version, so a dev server names the code it is working on.
|
||||||
|
*/
|
||||||
|
export default function runningVersion({
|
||||||
|
env,
|
||||||
|
package_version = package_json.version,
|
||||||
|
}: Params): RunningVersion {
|
||||||
|
const build_version = env.COBBLE_VERSION?.trim();
|
||||||
|
|
||||||
|
const version =
|
||||||
|
build_version && VersionPattern.test(build_version)
|
||||||
|
? build_version
|
||||||
|
: package_version && VersionPattern.test(package_version)
|
||||||
|
? package_version
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { version, mode: env.NODE_ENV || "development" };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user