@rizom/ops 0.2.0-alpha.350 → 0.2.0-alpha.352

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/schema.d.ts CHANGED
@@ -28,38 +28,34 @@ export declare const profileKindSchema: z.ZodEnum<{
28
28
  team: "team";
29
29
  organization: "organization";
30
30
  }>;
31
- export interface PilotConfig {
32
- brainVersion: string;
33
- bundleContract: typeof CAPABILITY_BUNDLE_CONTRACT;
34
- bundles: CanonicalBundleId[];
35
- add?: string[] | undefined;
36
- remove?: string[] | undefined;
37
- githubOrg: string;
38
- contentRepoPrefix: string;
39
- domainSuffix: string;
40
- aiApiKey: string;
41
- gitSyncToken: string;
42
- contentRepoAdminToken: string;
43
- agePublicKey: string;
44
- }
45
- export interface SiteOverrideConfig {
46
- package: string;
47
- version: string;
48
- theme?: string | undefined;
49
- themeVersion?: string | undefined;
50
- }
51
- export interface CohortConfig {
52
- members: string[];
53
- brainVersionOverride?: string | undefined;
54
- bundlesOverride?: CanonicalBundleId[] | undefined;
55
- addOverride?: string[] | undefined;
56
- removeOverride?: string[] | undefined;
57
- aiApiKeyOverride?: string | undefined;
58
- gitSyncTokenOverride?: string | undefined;
59
- }
31
+ declare const canonicalBundlesSchema: z.ZodArray<typeof canonicalBundleIdSchema>;
32
+ declare const memberIdsSchema: z.ZodArray<z.ZodString>;
60
33
  /** Sole active desired-state contract for the canonical brain. */
61
- export declare const pilotSchema: z.ZodType<PilotConfig>;
62
- export declare const cohortSchema: z.ZodType<CohortConfig>;
34
+ export declare const pilotSchema: z.ZodObject<{
35
+ brainVersion: z.ZodString;
36
+ bundleContract: z.ZodLiteral<typeof CAPABILITY_BUNDLE_CONTRACT>;
37
+ bundles: typeof canonicalBundlesSchema;
38
+ add: z.ZodOptional<typeof memberIdsSchema>;
39
+ remove: z.ZodOptional<typeof memberIdsSchema>;
40
+ githubOrg: z.ZodString;
41
+ contentRepoPrefix: z.ZodString;
42
+ domainSuffix: z.ZodString;
43
+ aiApiKey: z.ZodString;
44
+ gitSyncToken: z.ZodString;
45
+ contentRepoAdminToken: z.ZodString;
46
+ agePublicKey: z.ZodString;
47
+ }, z.core.$strict>;
48
+ export type PilotConfig = z.output<typeof pilotSchema>;
49
+ export declare const cohortSchema: z.ZodObject<{
50
+ members: z.ZodArray<z.ZodString>;
51
+ brainVersionOverride: z.ZodOptional<z.ZodString>;
52
+ bundlesOverride: z.ZodOptional<typeof canonicalBundlesSchema>;
53
+ addOverride: z.ZodOptional<typeof memberIdsSchema>;
54
+ removeOverride: z.ZodOptional<typeof memberIdsSchema>;
55
+ aiApiKeyOverride: z.ZodOptional<z.ZodString>;
56
+ gitSyncTokenOverride: z.ZodOptional<z.ZodString>;
57
+ }, z.core.$strict>;
58
+ export type CohortConfig = z.output<typeof cohortSchema>;
63
59
  declare const anchorProfileSocialLinkSchema: z.ZodObject<{
64
60
  platform: z.ZodEnum<{
65
61
  github: "github";
@@ -109,7 +105,13 @@ declare const atprotoSchema: z.ZodObject<{
109
105
  lexiconAuthority: z.ZodOptional<z.ZodBoolean>;
110
106
  jetstream: z.ZodOptional<typeof atprotoJetstreamSchema>;
111
107
  }>;
112
- export declare const siteOverrideSchema: z.ZodType<SiteOverrideConfig>;
108
+ export declare const siteOverrideSchema: z.ZodObject<{
109
+ package: z.ZodString;
110
+ version: z.ZodString;
111
+ theme: z.ZodOptional<z.ZodString>;
112
+ themeVersion: z.ZodOptional<z.ZodString>;
113
+ }, z.core.$strict>;
114
+ export type SiteOverrideConfig = z.output<typeof siteOverrideSchema>;
113
115
  declare const playbooksSchema: z.ZodObject<{
114
116
  onboarding: z.ZodOptional<z.ZodBoolean>;
115
117
  }>;
@@ -1,12 +1,14 @@
1
+ import { z } from "@brains/utils/zod";
1
2
  import { type CapabilityBundleReview } from "./capability-bundle-migration";
2
- import { type SiteOverrideConfig } from "./schema";
3
+ import { siteOverrideSchema } from "./schema";
3
4
  export interface StagedCrossover {
4
5
  outputDir: string;
5
6
  changedFiles: string[];
6
7
  }
7
- interface ReviewedSitePinManifest {
8
- sites: Record<string, SiteOverrideConfig>;
9
- }
8
+ declare const reviewedSitePinsSchema: z.ZodObject<{
9
+ sites: z.ZodRecord<z.ZodString, typeof siteOverrideSchema>;
10
+ }, z.core.$strict>;
11
+ type ReviewedSitePinManifest = z.output<typeof reviewedSitePinsSchema>;
10
12
  export type ReviewedSitePins = ReviewedSitePinManifest["sites"];
11
13
  export interface StageLegacyCrossoverOptions {
12
14
  sitePins?: ReviewedSitePins | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rizom/ops",
3
- "version": "0.2.0-alpha.350",
3
+ "version": "0.2.0-alpha.352",
4
4
  "description": "Operator CLI for managing private brain fleet registry repos",
5
5
  "keywords": [
6
6
  "brains",
@@ -8,7 +8,7 @@ on:
8
8
  required: false
9
9
  type: string
10
10
  site_packages:
11
- description: 'Space-separated site packages for a per-instance site image (e.g. "@rizom/site-rizom-ai@0.2.0-alpha.148"). Empty builds the default fleet image.'
11
+ description: "Space-separated exact site/theme packages for one explicit image build. Shared-fleet automatic builds derive the version-wide package union from desired state."
12
12
  required: false
13
13
  type: string
14
14
  overwrite:
@@ -30,8 +30,8 @@ The repo also checks in its deploy contract:
30
30
 
31
31
  `.env.schema` is the single source of truth for required and sensitive deploy vars.
32
32
  Use separate GitHub tokens: `CONTENT_REPO_ADMIN_TOKEN` for operator-side content repo creation/checks, and `GIT_SYNC_TOKEN` for runtime directory-sync git access.
33
- The default pilot image tag is `brain-${brainVersion}` end to end. A user with `siteOverride` gets an isolated `brain-${brainVersion}-sites-${packageHash}` image instead.
34
- When the effective brain version (`pilot.yaml.brainVersion`, or a cohort override) changes and you push, CI rebuilds the required default/site tags, refreshes generated user env files, and redeploys affected users. Every external site and theme package uses its own required exact version pin; ops never infers either version from the brain or from another package.
33
+ The fleet publishes one `brain-${brainVersion}` image per effective Brain version and installs the union of exact site/theme package pins required by instances on that version.
34
+ When an effective brain version (`pilot.yaml.brainVersion`, or a cohort override) changes and you push, CI builds every missing version image, refreshes generated user env files, and redeploys affected users. Every external site and theme package keeps its own exact version pin; change that package set only together with a fresh Brain version so published tags remain immutable.
35
35
  When a push changes only deploy contract files, CI prints `No affected user configs; skipping deploy.` and stops before Kamal.
36
36
 
37
37
  ## Commands
@@ -1,6 +1,22 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { Buffer } from "node:buffer";
3
3
  import { appendFileSync } from "node:fs";
4
+ import { isPlainRecord } from "@brains/utils/predicates";
5
+
6
+ /**
7
+ * The slice of `@libsql/client` this script uses.
8
+ *
9
+ * Declared here because the import is dynamic — the package is optional, and
10
+ * only the libsql quick-check driver needs it.
11
+ */
12
+ interface LibsqlModule {
13
+ createClient(options: { url: string }): {
14
+ execute(sql: string): Promise<{
15
+ rows: Array<Record<string | number, unknown>>;
16
+ }>;
17
+ close(): void;
18
+ };
19
+ }
4
20
 
5
21
  export const DEFAULT_PREDEPLOY_BACKUP_RETENTION_COUNT = 5;
6
22
  export const PREDEPLOY_BACKUP_TOOL_VERSION = "brains-predeploy-backup-v1";
@@ -157,10 +173,8 @@ async function quickCheck(
157
173
  if (driver === "bun") {
158
174
  const database = new Database(path, { readonly: true });
159
175
  try {
160
- const row = database.query("PRAGMA quick_check").get() as Record<
161
- string,
162
- unknown
163
- > | null;
176
+ const result: unknown = database.query("PRAGMA quick_check").get();
177
+ const row = isPlainRecord(result) ? result : null;
164
178
  return String(row?.["quick_check"] ?? row?.["0"] ?? "");
165
179
  } finally {
166
180
  database.close(false);
@@ -168,14 +182,10 @@ async function quickCheck(
168
182
  }
169
183
 
170
184
  const moduleName = "@libsql/client";
171
- const libsql = (await import(moduleName)) as {
172
- createClient(options: { url: string }): {
173
- execute(sql: string): Promise<{
174
- rows: Array<Record<string | number, unknown>>;
175
- }>;
176
- close(): void;
177
- };
178
- };
185
+ // Annotated rather than asserted: a dynamic import of an optional dependency
186
+ // resolves to `any`, so naming the slice this script uses is a checked
187
+ // assignment instead of a claim about the whole module.
188
+ const libsql: LibsqlModule = await import(moduleName);
179
189
  const client = libsql.createClient({ url: `file:${path}` });
180
190
  try {
181
191
  const result = await client.execute("PRAGMA quick_check");
@@ -198,9 +208,10 @@ function vectorDigestDatabase(database: Database): {
198
208
  const counts: Record<string, number> = {};
199
209
  for (const [table, order] of tables) {
200
210
  hasher.update(`table:${table}\n`);
201
- const rows = database
211
+ const selected: unknown[] = database
202
212
  .query(`SELECT * FROM ${table} ORDER BY ${order}`)
203
- .all() as Array<Record<string, unknown>>;
213
+ .all();
214
+ const rows = selected.filter(isPlainRecord);
204
215
  counts[table] = rows.length;
205
216
  for (const row of rows) {
206
217
  for (const [key, value] of Object.entries(row)) {
@@ -6,8 +6,7 @@ export {
6
6
  requireEnv,
7
7
  writeGitHubOutput,
8
8
  writeGitHubEnv,
9
- siteImageTag,
10
- sitePackagesFor,
9
+ runtimeImageTag,
11
10
  runResolveMissingImages,
12
11
  } from "@rizom/ops/deploy";
13
12
  export type { EnvSchemaEntry } from "@rizom/ops/deploy";
@@ -30,19 +30,54 @@ function sleep(ms: number): Promise<void> {
30
30
  return new Promise((resolve) => setTimeout(resolve, ms));
31
31
  }
32
32
 
33
+ /**
34
+ * These scripts are copied verbatim into generated projects, so they stay
35
+ * dependency-free: the API payloads are checked by hand rather than with a
36
+ * schema library. readJsonResponse returns unknown; asserting the Hetzner
37
+ * response shape onto it meant a changed or error payload surfaced as an
38
+ * undefined field later instead of a failed lookup here.
39
+ */
40
+ function isRecord(value: unknown): value is Record<string, unknown> {
41
+ return typeof value === "object" && value !== null && !Array.isArray(value);
42
+ }
43
+
44
+ function isHetznerServer(value: unknown): value is HetznerServer {
45
+ if (!isRecord(value)) return false;
46
+ if (typeof value["id"] !== "number") return false;
47
+ if (typeof value["status"] !== "string") return false;
48
+ const publicNet = value["public_net"];
49
+ if (publicNet === undefined) return true;
50
+ if (!isRecord(publicNet)) return false;
51
+ const ipv4 = publicNet["ipv4"];
52
+ if (ipv4 === undefined) return true;
53
+ if (!isRecord(ipv4)) return false;
54
+ const ip = ipv4["ip"];
55
+ return ip === undefined || typeof ip === "string";
56
+ }
57
+
58
+ function readServer(payload: unknown): HetznerServer | undefined {
59
+ if (!isRecord(payload)) return undefined;
60
+ const server = payload["server"];
61
+ return isHetznerServer(server) ? server : undefined;
62
+ }
63
+
64
+ function readServers(payload: unknown): HetznerServer[] | undefined {
65
+ if (!isRecord(payload)) return undefined;
66
+ const servers = payload["servers"];
67
+ return Array.isArray(servers) && servers.every(isHetznerServer)
68
+ ? servers
69
+ : undefined;
70
+ }
71
+
33
72
  async function listServers(): Promise<HetznerServer[]> {
34
73
  const url = `${baseUrl}/servers?label_selector=${encodeURIComponent(labelSelector)}`;
35
74
  const response = await fetch(url, { headers });
36
- const payload = (await readJsonResponse(
37
- response,
38
- "Hetzner server lookup",
39
- )) as {
40
- servers?: HetznerServer[];
41
- };
42
- if (!response.ok || !payload.servers) {
75
+ const payload = await readJsonResponse(response, "Hetzner server lookup");
76
+ const servers = readServers(payload);
77
+ if (!response.ok || !servers) {
43
78
  throw new Error(`Hetzner server lookup failed: ${JSON.stringify(payload)}`);
44
79
  }
45
- return payload.servers;
80
+ return servers;
46
81
  }
47
82
 
48
83
  async function createServer(): Promise<HetznerServer> {
@@ -58,27 +93,22 @@ async function createServer(): Promise<HetznerServer> {
58
93
  labels: { brain: instanceName },
59
94
  }),
60
95
  });
61
- const payload = (await readJsonResponse(
62
- response,
63
- "Hetzner server create",
64
- )) as {
65
- server?: HetznerServer;
66
- };
67
- if (!response.ok || !payload.server) {
96
+ const payload = await readJsonResponse(response, "Hetzner server create");
97
+ const server = readServer(payload);
98
+ if (!response.ok || !server) {
68
99
  throw new Error(`Hetzner server create failed: ${JSON.stringify(payload)}`);
69
100
  }
70
- return payload.server;
101
+ return server;
71
102
  }
72
103
 
73
104
  async function getServer(id: number): Promise<HetznerServer> {
74
105
  const response = await fetch(`${baseUrl}/servers/${id}`, { headers });
75
- const payload = (await readJsonResponse(response, "Hetzner server poll")) as {
76
- server?: HetznerServer;
77
- };
78
- if (!response.ok || !payload.server) {
106
+ const payload = await readJsonResponse(response, "Hetzner server poll");
107
+ const server = readServer(payload);
108
+ if (!response.ok || !server) {
79
109
  throw new Error(`Hetzner server poll failed: ${JSON.stringify(payload)}`);
80
110
  }
81
- return payload.server;
111
+ return server;
82
112
  }
83
113
 
84
114
  let server: HetznerServer | undefined = (await listServers())[0];
@@ -5,8 +5,7 @@ import { derivePreviewDomain, loadPilotRegistry } from "@rizom/ops";
5
5
  import {
6
6
  parseEnvFile,
7
7
  requireEnv,
8
- sitePackagesFor,
9
- siteImageTag,
8
+ runtimeImageTag,
10
9
  writeGitHubOutput,
11
10
  } from "./helpers";
12
11
 
@@ -43,12 +42,9 @@ const wwwDomain = isFleetDomain(
43
42
 
44
43
  const brainVersion = envEntries["BRAIN_VERSION"] ?? "";
45
44
 
46
- // The image tag is a pure function of this instance's own config: plain
47
- // `brain-{version}` for a default instance, or its own `brain-{version}-sites-
48
- // {hash}` when it declares a siteOverride. Resolved through the same helper the
49
- // build uses so the tag we wait for and run matches exactly what was pushed.
50
- const sitePackages = sitePackagesFor(user.siteOverride);
51
- const imageTag = siteImageTag(brainVersion, sitePackages);
45
+ // Build and Deploy share one tag function: every instance on a Brain version
46
+ // runs the same immutable fleet image.
47
+ const imageTag = runtimeImageTag(brainVersion);
52
48
 
53
49
  const outputs: Record<string, string> = {
54
50
  brain_version: brainVersion,
@@ -16,18 +16,43 @@ interface CloudflareResult {
16
16
  result?: Array<{ id: string }>;
17
17
  }
18
18
 
19
+ /**
20
+ * Copied verbatim into generated projects, so this stays dependency-free and
21
+ * checks the payload by hand. readJsonResponse returns unknown; asserting
22
+ * CloudflareResult onto it meant an error body read as `success: undefined`
23
+ * rather than failing here.
24
+ */
25
+ function isRecord(value: unknown): value is Record<string, unknown> {
26
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27
+ }
28
+
29
+ function readCloudflareResult(payload: unknown): CloudflareResult | undefined {
30
+ if (!isRecord(payload)) return undefined;
31
+ if (typeof payload["success"] !== "boolean") return undefined;
32
+ const result = payload["result"];
33
+ if (result === undefined) return { success: payload["success"] };
34
+ if (
35
+ !Array.isArray(result) ||
36
+ !result.every(
37
+ (entry): entry is { id: string } =>
38
+ isRecord(entry) && typeof entry["id"] === "string",
39
+ )
40
+ ) {
41
+ return undefined;
42
+ }
43
+ return { success: payload["success"], result };
44
+ }
45
+
19
46
  async function findRecordId(
20
47
  name: string,
21
48
  type: "A" | "CNAME",
22
49
  ): Promise<string | undefined> {
23
50
  const lookupUrl = `${baseUrl}/zones/${zoneId}/dns_records?type=${type}&name=${encodeURIComponent(name)}`;
24
51
  const lookup = await fetch(lookupUrl, { headers });
25
- const payload = (await readJsonResponse(
26
- lookup,
27
- "Cloudflare DNS lookup",
28
- )) as CloudflareResult;
29
- if (!lookup.ok || !payload.success) {
30
- throw new Error(`Cloudflare DNS lookup failed: ${JSON.stringify(payload)}`);
52
+ const raw = await readJsonResponse(lookup, "Cloudflare DNS lookup");
53
+ const payload = readCloudflareResult(raw);
54
+ if (!lookup.ok || !payload?.success) {
55
+ throw new Error(`Cloudflare DNS lookup failed: ${JSON.stringify(raw)}`);
31
56
  }
32
57
 
33
58
  return payload.result?.[0]?.id;
@@ -53,12 +78,10 @@ async function upsertRecord(name: string): Promise<void> {
53
78
  proxied: true,
54
79
  }),
55
80
  });
56
- const result = (await readJsonResponse(
57
- response,
58
- "Cloudflare DNS upsert",
59
- )) as CloudflareResult;
60
- if (!response.ok || !result.success) {
61
- throw new Error(`Cloudflare DNS upsert failed: ${JSON.stringify(result)}`);
81
+ const raw = await readJsonResponse(response, "Cloudflare DNS upsert");
82
+ const result = readCloudflareResult(raw);
83
+ if (!response.ok || !result?.success) {
84
+ throw new Error(`Cloudflare DNS upsert failed: ${JSON.stringify(raw)}`);
62
85
  }
63
86
  }
64
87
 
@@ -16,10 +16,11 @@ Treat these as checked-in deploy artifacts in the pilot repo:
16
16
  `.env.schema` is the single source of truth for required and sensitive deploy vars.
17
17
  The deploy scripts and workflows should read from that contract instead of inventing a second list.
18
18
 
19
- The default pilot image tag is `brain-${brainVersion}`:
19
+ The fleet has one image topology:
20
20
 
21
- - build publishes `brain-${brainVersion}` for users without a site override
22
- - a site override gets an isolated `brain-${brainVersion}-sites-${packageHash}` image
21
+ - one immutable `brain-${brainVersion}` image is published for each effective Brain version
22
+ - each image contains the union of exact site/theme package pins required by instances on that version
23
+ - conflicting versions of one package fail image resolution before build
23
24
  - generated `users/<handle>/.env` carries `BRAIN_VERSION=<brainVersion>`
24
25
  - build and deploy derive the same effective image tag from the resolved registry
25
26
 
@@ -27,7 +28,7 @@ The default pilot image tag is `brain-${brainVersion}`:
27
28
 
28
29
  When `pilot.yaml.brainVersion` changes and you push:
29
30
 
30
- 1. build publishes the new default image and any required site images
31
+ 1. build publishes each missing version image with its declared package union
31
32
  2. reconcile refreshes generated `users/<handle>/.env`
32
33
  3. deploy runs for handles whose generated config changed
33
34
  4. generated file commits happen once in a final aggregation step after the deploy matrix finishes
@@ -260,21 +261,24 @@ siteOverride:
260
261
  themeVersion: <exact-theme-version>
261
262
  ```
262
263
 
263
- Missing external package versions fail desired-state validation. A site override
264
- produces an isolated per-instance image; it never changes the fleet's shared default
265
- image. Bundled `@brains/*` themes omit `themeVersion` because they are not installed as
264
+ Missing external package versions fail desired-state validation. Every instance on one
265
+ Brain version uses the same image and exact package union. Change a site/theme package
266
+ pin only together with a fresh Brain version; published `brain-${brainVersion}` tags
267
+ remain immutable. Conflicting pins for one package on the same Brain version fail before
268
+ build. Bundled `@brains/*` themes omit `themeVersion` because they are not installed as
266
269
  separate packages.
267
270
 
268
271
  ### Custom-package canary and rollback
269
272
 
270
273
  1. Confirm the exact site/theme versions are public-installable without npm credentials.
271
- 2. Apply the exact package names and versions to one healthy canonical site canary.
272
- 3. Reconcile the canary, push the generated output, and let build/deploy create its site image.
274
+ 2. Apply the exact package names and versions to one healthy canonical site canary and select a fresh Brain version for that package set.
275
+ 3. Push desired state and let the normal Build → Reconcile → Deploy chain create the shared version image and update the canary.
273
276
  4. Run `bunx brains-ops verify-user . <handle>`.
274
277
  5. Manually verify the site, theme, Studio, content sync, and passkey sign-in before adding more users.
275
278
 
276
- To roll back, remove or change `siteOverride`, reconcile, and redeploy that user.
277
- The default image and other users remain untouched.
279
+ To roll back, remove or change `siteOverride` while selecting a fresh Brain version,
280
+ then reconcile and redeploy that user. Never mutate an existing image tag; coordinate any
281
+ other users intentionally sharing the selected version.
278
282
 
279
283
  ## Setup email checklist
280
284