diff --git a/.github/workflows/alchemy.yml b/.github/workflows/alchemy.yml index 4014785..89811b6 100644 --- a/.github/workflows/alchemy.yml +++ b/.github/workflows/alchemy.yml @@ -1,22 +1,17 @@ -# Deploys with Alchemy (alchemy.run.ts): a push to main deploys the stage `prod`, the production -# deployment, and nothing else; the hub has no previews. The state is in the `alchemy-state` -# project of the team, under the stack name GITHUB_REPOSITORY. The run needs the team token +# Deploys with Alchemy (alchemy.run.ts): a push to main deploys the stage `prod` of the stack +# `samebase/team-base`, the production deployment of the Convex project `samebase`, and nothing else; +# the hub has no previews. The state is in that same deployment. The run needs the team token # CONVEX_ACCESS_TOKEN, with which it mints the deploy key itself, plus the Google and GitHub # clients, which the stack hands to the deployment. Only the Alchemy step gets the secrets. name: alchemy on: push: { branches: [main] } - # Run once on main with adopt to take over a project with the name of the repository that - # exists already. - workflow_dispatch: { inputs: { adopt: { type: boolean, default: false } } } permissions: { contents: read } # One deploy at a time: the state store has no lock. A newer push waits, so the last deploy is the # newest commit. concurrency: { group: alchemy-prod, queue: max, cancel-in-progress: false } jobs: alchemy: - # A dispatch from another branch deploys nothing: only main is production. - if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -48,11 +43,8 @@ jobs: fi - name: Deploy production if: steps.keys.outputs.skip != 'true' - run: | - if [ "$ADOPT" = "true" ]; then set -- --adopt; fi - pnpm exec alchemy deploy --stage prod --yes --no-input "$@" + run: pnpm exec alchemy deploy --stage prod --yes --no-input env: - ADOPT: ${{ inputs.adopt }} CONVEX_ACCESS_TOKEN: ${{ secrets.CONVEX_ACCESS_TOKEN }} GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} diff --git a/AGENTS.md b/AGENTS.md index 4bbacee..70d37fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,15 +1,21 @@ # AGENTS.md -A Convex app that mounts the identity-broker component and is the sign-in hub of one team. See -README.md. The component's own rules live in its repository. +A Convex app that mounts the identity-broker and alchemyState components: the base app of one team, +which serves its sign-in hub and its Alchemy state. See README.md. Each component's own rules live +in its repository. - `convex/connectors.ts` lists the providers the hub offers. A provider that needs credentials also gets its variables declared in `convex/convex.config.ts`. - `convex/http.ts` mounts the broker; `convex/identityBroker.ts` exports the admin functions for `npx convex run`; `convex/crons.ts` runs the cleanup. -- `alchemy.run.ts` is the stack (project, production deployment, push); `scripts/check-keys.ts` - checks the key set inside the `deploy` script the push runs and never creates it; - `scripts/init-keys.ts` creates it once, by hand. +- `convex/state.ts`, `convex/reader.ts`, and `convex/auth.config.ts` serve the Alchemy state store, + from the exports of `@samebase/alchemy-convex`. A push replaces every function, so a function that + leaves these files leaves every stack of the team without its state. +- `alchemy.run.ts` is the stack `samebase/team-base` (adopted project, adopted production + deployment, push); `scripts/check-keys.ts` checks the key set inside the `deploy` script the push + runs and never creates it; `scripts/init-keys.ts` creates it once, by hand. +- Never use `npx convex import --replace-all` on this deployment, also not with `--component`: it + deletes the rows of the other components. - Never use the U+2014 em dash character. No `any`, no type assertions except `as const`. - `pnpm install`, then `pnpm run check`. `convex/_generated` is committed; `npx convex dev --once` refreshes it. diff --git a/README.md b/README.md index 8de7378..f6f8b13 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,27 @@ -# identity-hub +# team-base -The sign-in hub of the Samebase team on [Convex](https://convex.dev): one deployment that holds the -team's Google and GitHub connections once and signs users in to all its apps, through the -[identity-broker](https://github.com/samebase/identity-broker) component. It keeps no user accounts; -each app creates its own user from the `id_token` it gets. +The base app of the Samebase team on [Convex](https://convex.dev): the code of the production +deployment of the project `samebase`, which holds the team-level components. -The repository is public and holds no secrets or team values: everything specific to a hub is a -deployment variable or a line in `convex/connectors.ts`, so a copy of it runs another team's hub. +- `identityBroker`, from [identity-broker](https://github.com/samebase/identity-broker), is the + sign-in hub. It holds the team's Google and GitHub connections once and signs users in to all its + apps. It keeps no user accounts; each app creates its own user from the `id_token` it gets. +- `alchemyState`, from [alchemy-convex](https://github.com/samebase/alchemy-convex), is the Alchemy + state of every stack of the team. + +The repository is public and holds no secrets. The values of the team are the constants in +`alchemy.run.ts` and the lines in `convex/connectors.ts`, so a copy of it with other values runs +another team's base. ## Deploy `alchemy.run.ts` is the stack, in the shape of every app of the team with one stage: `prod` is the -production deployment of the Convex project named after this repository, and the push is the -`deploy` package script, which checks the key set and pushes the functions. The state is in the -`alchemy-state` project of the team. The hub has no previews: no app points at one, and Google -cannot sign in on one, since only the production callback is registered at Google. -`.github/workflows/alchemy.yml` deploys each push to `main`; pull requests run the checks only. +production deployment of the Convex project `samebase`, and the push is the `deploy` package script, +which checks the key set and pushes the functions. The stack is named after the repository, +`samebase/team-base`. Its state is in the deployment that it deploys, next to the state of the other +stacks of the team. The hub has no previews: no app points at one, and Google cannot sign in on one, +since only the production callback is registered at Google. `.github/workflows/alchemy.yml` deploys +each push to `main`; pull requests run the checks only. 1. In the repository, set five secrets: `CONVEX_ACCESS_TOKEN`, the team's Convex access token, with which the run mints the deploy key itself; `GOOGLE_CLIENT_ID` with `GOOGLE_CLIENT_SECRET`, the @@ -28,21 +34,37 @@ cannot sign in on one, since only the production callback is registered at Googl and refuses a deployment that has the variable. Every deploy then checks the variable before the push and stops when it is missing or holds anything that is not a key set; no deploy ever creates or replaces it. -4. Merge to `main`. The first run of the workflow on a project that exists already needs `adopt` - checked (Run workflow on the Actions tab). From a laptop, `pnpm exec alchemy deploy --stage prod` - runs the same stack with the Convex CLI login. +4. Merge to `main`. The stack adopts the project and its production deployment, so no run needs + `--adopt`, and it retains both on destroy. Adoption replaces the code and sets the variables of + the stack. It does not change other variables, the data, or the project. From a laptop, + `pnpm exec alchemy deploy --stage prod` runs the same stack with the Convex CLI login. Back up `IDENTITY_BROKER_KEYS` somewhere safe. It holds the signing keys and the pairwise salt: a hub that loses it issues new subjects to every user of every app. An established hub that lost the variable gets the backup restored by hand; its deploys stop until then. +## Base and seed + +A push replaces every function of a deployment. `Convex.state()` of `@samebase/alchemy-convex` +pushes a small store app, a seed, to a project `samebase` that has no base, and it never pushes over +a base. Thus this app serves the same store functions as the seed: `convex/state.ts` with the host +`base`, `convex/reader.ts`, and `convex/auth.config.ts`. The `alchemyState` component has no name in +`convex/convex.config.ts`, so this app serves the rows that the seed wrote. + +The store version of this base is the store version of the `@samebase/alchemy-convex` in +`package.json`. The stack and the app share that one pin. When the store version rises, this +repository moves first and each consumer follows. A consumer of the team fails on a base that is +older or newer than the consumer. + +Push a base for the first time when no stack of the team runs: the push of a seed has no lock. + ## Issuer -The hub answers at the deployment's `.convex.site` URL. Set `ISSUER` to a custom domain before the -first app registers: the issuer is the name every app trusts, in discovery, in every `id_token`, and -in the callback URLs registered with GitHub and Google. Changing it later means changing -`HUB_ISSUER` on every app's deployments and the callback URLs at every provider in one go; the app -ids, the clients and the salt stay, so the subjects do not change. +The hub answers at the deployment's `.convex.site` URL and at the custom domain `auth.samebase.com`. +The stack sets `ISSUER` to that domain: the issuer is the name every app trusts, in discovery, in +every `id_token`, and in the callback URLs registered with GitHub and Google. Changing it later +means changing `HUB_ISSUER` on every app's deployments and the callback URLs at every provider in +one go; the app ids, the clients and the salt stay, so the subjects do not change. ## Connectors @@ -76,13 +98,19 @@ which also writes the client into the deployment's variables; see the component' ## Operate -The admin functions are internal: `npx convex run --prod identityBroker:`. The deploy key of -the hub is an administrator credential (it can read and replace the keys and run every internal -function); keep it out of every app's CI. The Convex CLI ignores `--prod` when `CONVEX_DEPLOY_KEY` -names another deployment, so unset it before an admin command. A cron runs the broker's cleanup -daily: expired requests and codes, and sign-in records older than 90 days. Idle preview clients are -kept: the broker prunes them only when the host sets `previewClientIdleDays`, and this hub does not, -because a consumer's registration resource does not register a pruned client again. +The admin functions are internal: `npx convex run --prod identityBroker:`, from a checkout +that selects the project `samebase`. The deploy key of the base is an administrator credential: it +can read and replace the keys, read every secret in the Alchemy state of the team, and run every +internal function. Keep it out of every app's CI. The Convex CLI ignores `--prod` when +`CONVEX_DEPLOY_KEY` names another deployment, so unset it before an admin command. A cron runs the +broker's cleanup daily: expired requests and codes, and sign-in records older than 90 days. Idle +preview clients are kept: the broker prunes them only when the host sets `previewClientIdleDays`, +and this hub does not, because a consumer's registration resource does not register a pruned client +again. + +Never run `npx convex import --replace-all` on this deployment, also not with `--component`: it also +deletes the rows of the other components. To restore the tables of one component, import a ZIP of +that component with `--component --replace`. ## License diff --git a/alchemy.run.ts b/alchemy.run.ts index 7ad3673..d02a401 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -1,19 +1,26 @@ -// The stack of this hub, in the shape of samebase/base-alchemy with one stage: `prod` is the -// production deployment of the Convex project of this repository, and nothing else is deployed. -// The hub has no previews: no app points at one, and Google cannot sign in on one, since only the -// production callback is registered at Google. The push is the `deploy` package script, which -// checks the key set and pushes the functions. The stack is named after the repository, -// `owner/name`, and its Convex project after `name`. The state is in the `alchemy-state` project -// of the team. +// The stack of the team's base app, with one stage: `prod` is the production deployment of the +// Convex project `samebase`, and nothing else is deployed. The base holds the team-level +// components of that deployment: the sign-in hub (`identityBroker`) and the Alchemy state of every +// stack of the team (`alchemyState`). The hub has no previews: no app points at one, and Google +// cannot sign in on one, since only the production callback is registered at Google. The push is +// the `deploy` package script, which checks the key set and pushes the functions. +// +// The stack is named after the repository, `owner/name`, as every stack of the team is. The +// repository was `samebase/identity-hub`, and this stack never reads the rows of that old stack. +// The project and the deployment exist already, and this stack did not create them, so both are +// adopted and retained. Adoption changes the variables of `env` and +// the code; it leaves every other variable, the data, and the project as they are. // // Every run needs the team token CONVEX_ACCESS_TOKEN, or a Convex CLI login (`npx convex login`) // on a laptop; it mints the deploy key itself. Every run also reads the Google and GitHub client // ids and secrets and hands them to the deployment, for the two connectors: no connector variable -// is ever set by hand. +// is ever set by hand. IDENTITY_BROKER_KEYS is not in `env`: it stays on the deployment, and the +// `deploy` script stops when it is missing. // pnpm exec alchemy deploy --stage prod import { execFileSync } from "node:child_process"; import * as Convex from "@samebase/alchemy-convex"; import * as Alchemy from "alchemy"; +import { adopt } from "alchemy/AdoptPolicy"; import * as RemovalPolicy from "alchemy/RemovalPolicy"; import * as Config from "effect/Config"; import * as Effect from "effect/Effect"; @@ -30,14 +37,13 @@ const repository = if (repository === undefined) { throw new Error("Set GITHUB_REPOSITORY to owner/name: the origin remote names no repository."); } -const projectName = repository.slice(repository.indexOf("/") + 1); export default Alchemy.Stack( repository, { providers: Convex.providers(), state: Convex.state({ team }) }, Effect.gen(function* () { const { stage } = yield* Alchemy.Stack; - // One stage: any other name would create a second project and deployment with this name. + // One stage: any other name would push this code over the same production deployment. if (stage !== "prod") { throw new Error(`This stack has one stage, prod. The stage ${stage} is not deployed.`); } @@ -50,14 +56,14 @@ export default Alchemy.Stack( // The variables of the deployment. The secret is a prop, so the state store holds it next to // the deployment it serves. const env = { + ISSUER: "https://auth.samebase.com", GOOGLE_CLIENT_ID: yield* Config.String("GOOGLE_CLIENT_ID"), GOOGLE_CLIENT_SECRET: yield* Config.Redacted("GOOGLE_CLIENT_SECRET"), AUTH_GITHUB_CLIENT_ID: yield* Config.String("AUTH_GITHUB_CLIENT_ID"), AUTH_GITHUB_CLIENT_SECRET: yield* Config.Redacted("AUTH_GITHUB_CLIENT_SECRET"), }; - // The project is kept on destroy: it holds the hub's registrations and the key set. A project - // with this name that the stack did not create is taken over with `alchemy deploy --adopt`. - const project = yield* Convex.Project("Project", { team, name: projectName }).pipe( + const project = yield* Convex.Project("Project", { team, slug: "samebase" }).pipe( + adopt(true), RemovalPolicy.retain(), ); const deployment = yield* Convex.Deployment("Deployment", { @@ -65,7 +71,7 @@ export default Alchemy.Stack( type: "prod", source, env, - }); + }).pipe(adopt(true), RemovalPolicy.retain()); return { url: deployment.siteUrl }; }), ); diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 07dada9..172e990 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -12,6 +12,8 @@ import type * as connectors from "../connectors.js"; import type * as crons from "../crons.js"; import type * as http from "../http.js"; import type * as identityBroker from "../identityBroker.js"; +import type * as reader from "../reader.js"; +import type * as state from "../state.js"; import type { ApiFromModules, @@ -24,6 +26,8 @@ declare const fullApi: ApiFromModules<{ crons: typeof crons; http: typeof http; identityBroker: typeof identityBroker; + reader: typeof reader; + state: typeof state; }>; /** @@ -54,4 +58,5 @@ export declare const internal: FilterApi< export declare const components: { identityBroker: import("@samebase/identity-broker/_generated/component.js").ComponentApi<"identityBroker">; + alchemyState: import("@samebase/alchemy-convex/state/_generated/component.js").ComponentApi<"alchemyState">; }; diff --git a/convex/auth.config.ts b/convex/auth.config.ts new file mode 100644 index 0000000..3d666f3 --- /dev/null +++ b/convex/auth.config.ts @@ -0,0 +1,5 @@ +import { authConfig } from "@samebase/alchemy-convex/state/auth.config"; +import { env } from "./_generated/server"; + +// Verifies the read tokens of `reader:authorize`. +export default authConfig(env.CONVEX_CLOUD_URL); diff --git a/convex/convex.config.ts b/convex/convex.config.ts index ef71670..3bf80a4 100644 --- a/convex/convex.config.ts +++ b/convex/convex.config.ts @@ -1,3 +1,4 @@ +import alchemyState from "@samebase/alchemy-convex/state/convex.config"; import identityBroker from "@samebase/identity-broker/convex.config"; import { defineApp } from "convex/server"; import { v } from "convex/values"; @@ -9,7 +10,7 @@ const app = defineApp({ env: { /** The key set and the pairwise salt: `npx identity-broker keys`. */ IDENTITY_BROKER_KEYS: v.string(), - /** The issuer URL once a custom domain exists. Without it, the deployment's own convex.site URL. */ + /** The issuer URL, which the stack sets. Without it, the deployment's own convex.site URL. */ ISSUER: v.optional(v.string()), /** * The Google OAuth client of the team, whose redirect URIs include @@ -28,5 +29,7 @@ const app = defineApp({ }); app.use(identityBroker); +// Without a name, so that this base serves the rows that the seed of the store wrote. +app.use(alchemyState); export default app; diff --git a/convex/reader.ts b/convex/reader.ts new file mode 100644 index 0000000..56e8799 --- /dev/null +++ b/convex/reader.ts @@ -0,0 +1,5 @@ +import { readerFunctions } from "@samebase/alchemy-convex/state/functions"; +import { components } from "./_generated/api"; + +// The redacted read of one repository's state, for a reader with a token of `authorize`. +export const { authorize, repository } = readerFunctions(components.alchemyState); diff --git a/convex/state.ts b/convex/state.ts new file mode 100644 index 0000000..02931fa --- /dev/null +++ b/convex/state.ts @@ -0,0 +1,21 @@ +import { stateFunctions } from "@samebase/alchemy-convex/state/functions"; +import { components } from "./_generated/api"; + +// The Alchemy state store of the team. A push replaces every function of the deployment, so this +// base serves the functions of the seed that Convex.state() pushes elsewhere. Each export has its +// name here, so that the Convex analysis finds each function. +export const { + version, + host, + stacks, + stages, + list, + get, + replaced, + set, + remove, + removeStack, + getOutput, + setOutput, + readStack, +} = stateFunctions(components.alchemyState, "base"); diff --git a/package.json b/package.json index 4514569..dd0acdb 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "identity-hub", + "name": "team-base", "private": true, "type": "module", "scripts": { @@ -12,12 +12,12 @@ "lint": "vp lint" }, "dependencies": { + "@samebase/alchemy-convex": "0.14.1", "@samebase/identity-broker": "0.6.1", - "convex": "1.47.0" + "convex": "1.48.0" }, "devDependencies": { "@effect/platform-node": "4.0.5", - "@samebase/alchemy-convex": "0.13.0", "@types/node": "24.19.2", "alchemy": "2.0.0-beta.84", "effect": "4.0.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 721c1b3..a81e1e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,19 +191,19 @@ importers: .: dependencies: + '@samebase/alchemy-convex': + specifier: 0.14.1 + version: 0.14.1(@effect/platform-node@4.0.5(effect@4.0.5)(redis@6.3.0))(alchemy@2.0.0-beta.84(@effect/platform-node@4.0.5(effect@4.0.5)(redis@6.3.0))(@voidzero-dev/vite-plus-core@1.3.0(@types/node@24.19.2)(esbuild@0.27.0)(typescript@7.0.2)(yaml@2.9.1))(effect@4.0.5)(ws@8.22.0))(convex@1.48.0)(effect@4.0.5) '@samebase/identity-broker': specifier: 0.6.1 - version: 0.6.1(convex@1.47.0) + version: 0.6.1(convex@1.48.0) convex: - specifier: 1.47.0 - version: 1.47.0 + specifier: 1.48.0 + version: 1.48.0 devDependencies: '@effect/platform-node': specifier: 4.0.5 version: 4.0.5(effect@4.0.5)(redis@6.3.0) - '@samebase/alchemy-convex': - specifier: 0.13.0 - version: 0.13.0(@effect/platform-node@4.0.5(effect@4.0.5)(redis@6.3.0))(alchemy@2.0.0-beta.84(@effect/platform-node@4.0.5(effect@4.0.5)(redis@6.3.0))(@voidzero-dev/vite-plus-core@1.3.0(@types/node@24.19.2)(esbuild@0.27.0)(typescript@7.0.2)(yaml@2.9.1))(effect@4.0.5)(ws@8.22.0))(convex@1.47.0)(effect@4.0.5) '@types/node': specifier: 24.19.2 version: 24.19.2 @@ -860,8 +860,8 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} - '@samebase/alchemy-convex@0.13.0': - resolution: {integrity: sha512-GZfPgo0HYp8lr/hqJ2GXQSbpEeefB+ci0CCUAZE/CZuKFWjjzL8gmpcOzWZOOwfQFb5rj29kRSIzVdlpKzCTSQ==} + '@samebase/alchemy-convex@0.14.1': + resolution: {integrity: sha512-9K8OmSt5mhrSseOuVuBWQHLsm9GqNsXezFd8Is52OV+cuEgwkrDehoAp1f3qiob2kqLFk6o/PYHE0srV2W9n+Q==} engines: {node: '>=22'} peerDependencies: '@effect/platform-node': ^4.0.5 @@ -1305,8 +1305,8 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - convex@1.47.0: - resolution: {integrity: sha512-B1z/lOmex7i09NASq8+2WG9rZLzGXXOSkpwGOpaNIvOs4awyW4RMgpVBjt8r2wvvHwhEBEsOr2pGYpulnmAxYQ==} + convex@1.48.0: + resolution: {integrity: sha512-M9jEgTvCN8ziP/VgQQUH+j0ijM7g+NmQd8tFlvETK8/HMC/aaqHZwcu4hBO30X5DV2VIyYK4mOoLQGIb1766ew==} engines: {node: '>=20.0.0', npm: '>=7.0.0'} hasBin: true peerDependencies: @@ -2042,17 +2042,17 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@samebase/alchemy-convex@0.13.0(@effect/platform-node@4.0.5(effect@4.0.5)(redis@6.3.0))(alchemy@2.0.0-beta.84(@effect/platform-node@4.0.5(effect@4.0.5)(redis@6.3.0))(@voidzero-dev/vite-plus-core@1.3.0(@types/node@24.19.2)(esbuild@0.27.0)(typescript@7.0.2)(yaml@2.9.1))(effect@4.0.5)(ws@8.22.0))(convex@1.47.0)(effect@4.0.5)': + '@samebase/alchemy-convex@0.14.1(@effect/platform-node@4.0.5(effect@4.0.5)(redis@6.3.0))(alchemy@2.0.0-beta.84(@effect/platform-node@4.0.5(effect@4.0.5)(redis@6.3.0))(@voidzero-dev/vite-plus-core@1.3.0(@types/node@24.19.2)(esbuild@0.27.0)(typescript@7.0.2)(yaml@2.9.1))(effect@4.0.5)(ws@8.22.0))(convex@1.48.0)(effect@4.0.5)': dependencies: '@convex-dev/platform': 0.1.11 '@effect/platform-node': 4.0.5(effect@4.0.5)(redis@6.3.0) alchemy: 2.0.0-beta.84(@effect/platform-node@4.0.5(effect@4.0.5)(redis@6.3.0))(@voidzero-dev/vite-plus-core@1.3.0(@types/node@24.19.2)(esbuild@0.27.0)(typescript@7.0.2)(yaml@2.9.1))(effect@4.0.5)(ws@8.22.0) - convex: 1.47.0 + convex: 1.48.0 effect: 4.0.5 - '@samebase/identity-broker@0.6.1(convex@1.47.0)': + '@samebase/identity-broker@0.6.1(convex@1.48.0)': dependencies: - convex: 1.47.0 + convex: 1.48.0 jose: 6.2.13 oauth4webapi: 3.8.8 @@ -2342,7 +2342,7 @@ snapshots: convert-source-map@2.0.0: {} - convex@1.47.0: + convex@1.48.0: dependencies: esbuild: 0.27.0 prettier: 3.9.10 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9d35932..42dc1e8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,15 +6,10 @@ minimumReleaseAgeIgnoreMissingTime: false # Keep release-age exceptions exact so later releases do not bypass the cooling period. minimumReleaseAgeExclude: # Published 2026-10-30. Remove on 2026-11-06, when it is older than the seven-day release age. - - "@samebase/alchemy-convex@0.13.0" - # Published 2026-10-29. Remove on 2026-11-05, when it is older than the seven-day release age. - - "@samebase/alchemy-convex@0.12.0" + - "@samebase/alchemy-convex@0.14.1" # Reviewed first-party release, published 2026-10-29 by samebase/identity-broker release.yml. # Remove after 2026-11-05, when the 7-day cooling period is complete. - "@samebase/identity-broker@0.6.1" - # Reviewed first-party release: the Alchemy provider that deploys this hub and keeps its state in - # the team store, published 2026-10-27 15:18 UTC. Remove after 2026-11-03 15:19 UTC. - - "@samebase/alchemy-convex@0.11.0" # Reviewed releases: the Alchemy that alchemy-convex 0.11.0 needs, published 2026-10-24 00:43 UTC. # Remove after 2026-10-31 00:44 UTC. - "alchemy@2.0.0-beta.84" diff --git a/scripts/convex-cli.ts b/scripts/convex-cli.ts index 6d69004..eb03d6f 100644 --- a/scripts/convex-cli.ts +++ b/scripts/convex-cli.ts @@ -32,8 +32,12 @@ export function runConvex(args: string[], options: { input?: string } = {}) { }); } -/** Whether the deployment has the variable at all. `env list` prints one NAME=value line each. */ +/** + * Whether the deployment has the variable at all. The names only: a plain `env list` prints values + * or names, by whether the CLI detects an AI agent, and a wrong answer here makes keys:init replace + * a key set. + */ export async function hasVariable(name: string, selection: string[]) { - const listed = await runConvex(["env", "list", ...selection]); - return listed.stdout.split("\n").some((line) => line.startsWith(`${name}=`)); + const listed = await runConvex(["env", "list", "--names-only", ...selection]); + return listed.stdout.split("\n").includes(name); }