diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8f2daf8..d747f1f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,11 +37,20 @@ jobs: username: ${{ secrets.GITEA_USERNAME }} 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 id: tags env: EVENT: ${{ github.event_name }} RELEASE_TAG: ${{ github.event.release.tag_name }} + SOURCE_VERSION: ${{ steps.source.outputs.version }} run: | set -euo pipefail sha="sha-${GITHUB_SHA::12}" @@ -59,10 +68,12 @@ jobs: "git.tben.me/moduletrace/cobble:${minor}" \ "git.tben.me/moduletrace/cobble:${sha}")" else + version="$SOURCE_VERSION" ghcr="ghcr.io/moduletrace/cobble:${sha}" gitea="git.tben.me/moduletrace/cobble:${sha}" fi { + echo "version=$version" echo "ghcr<`), 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 @@ -212,7 +212,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) - 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 `, 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-.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`). diff --git a/Dockerfile b/Dockerfile index c5520ec..55c3c9f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,12 @@ ENV DATA_DIR=/data ENV DB_DIR=/data/db 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 RUN echo 'alias ll="ls -laF"' >/root/.bashrc diff --git a/RELEASING.md b/RELEASING.md index 046b882..b19df5b 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -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 - update to a new tag: backup taken, data intact - `--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: git tag v0.1.0 diff --git a/package.json b/package.json index 8d7cd3a..84277c8 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "cobble", + "version": "0.1.1", "module": "index.ts", "type": "module", "private": true, diff --git a/src/pages/__root.server.ts b/src/pages/__root.server.ts index 224ac71..6585ad8 100644 --- a/src/pages/__root.server.ts +++ b/src/pages/__root.server.ts @@ -21,6 +21,22 @@ const server: BunextPageServerFn = async ({ req, url }) => { const is_signup_page = url.pathname.startsWith("/auth/signup"); 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) { return redirect({ destination: "/auth/signup" }); } diff --git a/src/pages/admin/settings/(functions)/grab-settings-server-props.ts b/src/pages/admin/settings/(functions)/grab-settings-server-props.ts index c31b2cb..62ceb7f 100644 --- a/src/pages/admin/settings/(functions)/grab-settings-server-props.ts +++ b/src/pages/admin/settings/(functions)/grab-settings-server-props.ts @@ -7,6 +7,7 @@ import type { PagePropsType } from "@/src/types"; import type { BunextPageServerFn } from "@moduletrace/bunext/types"; 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 runningVersion from "@/src/utils/running-version"; type Params = Parameters>[0]; @@ -44,6 +45,7 @@ export default async function grabSettingsServerProps({ req }: Params) { has_password, storage_quota: await grabStorageQuotaOverview().catch(() => null), system_prompt_append: await grabSystemPromptAppendOverview().catch(() => null), + running_version: runningVersion({ env: process.env }), }, }; } diff --git a/src/pages/admin/settings/general/(partials)/version-line.tsx b/src/pages/admin/settings/general/(partials)/version-line.tsx new file mode 100644 index 0000000..3abcca8 --- /dev/null +++ b/src/pages/admin/settings/general/(partials)/version-line.tsx @@ -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 ( + + {running.mode == "development" + ? `${version} (development)` + : version} + + ); +} diff --git a/src/pages/admin/settings/general/(sections)/version-section.tsx b/src/pages/admin/settings/general/(sections)/version-section.tsx new file mode 100644 index 0000000..8621f4a --- /dev/null +++ b/src/pages/admin/settings/general/(sections)/version-section.tsx @@ -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 ( + + + + ); +} diff --git a/src/pages/admin/settings/general/index.tsx b/src/pages/admin/settings/general/index.tsx index e9dd86b..e67b20e 100644 --- a/src/pages/admin/settings/general/index.tsx +++ b/src/pages/admin/settings/general/index.tsx @@ -1,6 +1,7 @@ import AppearanceSection from "./(sections)/appearance-section"; import PromptSection from "./(sections)/prompt-section"; import StorageSection from "./(sections)/storage-section"; +import VersionSection from "./(sections)/version-section"; import SettingsShell from "../(partials)/settings-shell"; import settingsPageMeta from "../(functions)/settings-page-meta"; @@ -13,6 +14,7 @@ export default function AdminSettingsGeneralPage() { + ); } diff --git a/src/types/index.ts b/src/types/index.ts index ddfa0a0..31967f1 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -19,6 +19,7 @@ import type { } from "@/db/types/db"; import type { UserTypes } from "../dict/user-types-dict"; import type { StorageVolume } from "../utils/storage-volumes"; +import type { RunningVersion } from "../utils/running-version"; import type { WebsocketRooms } from "../dict/websocket-rooms"; import type { LucideIcon } from "lucide-react"; import type { ServerQueryParam } from "@moduletrace/bun-sqlite"; @@ -1038,6 +1039,10 @@ export type PagePropsType = { setting_key: string; text: string; } | null; + /** + * The release the server is running, for the General settings page. + */ + running_version?: RunningVersion | null; push_overview?: PushOverview | null; mcps_overview?: McpServersOverviewPayload | null; tools_overview?: CustomToolsOverviewPayload | null; diff --git a/src/utils/running-version.test.ts b/src/utils/running-version.test.ts new file mode 100644 index 0000000..ae88497 --- /dev/null +++ b/src/utils/running-version.test.ts @@ -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" }); + }); +}); diff --git a/src/utils/running-version.ts b/src/utils/running-version.ts new file mode 100644 index 0000000..2a3566a --- /dev/null +++ b/src/utils/running-version.ts @@ -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; + /** + * `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" }; +}