@rizom/ops 0.2.0-alpha.22 → 0.2.0-alpha.220
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/README.md +3 -1
- package/dist/brains-ops.js +229 -283
- package/dist/cert-bootstrap.d.ts +3 -1
- package/dist/content-repo-ref.d.ts +10 -0
- package/dist/content-repo.d.ts +1 -0
- package/dist/deploy.js +99 -166
- package/dist/entries/deploy.d.ts +3 -2
- package/dist/images.d.ts +76 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +230 -283
- package/dist/load-registry.d.ts +25 -2
- package/dist/observed-status.d.ts +1 -1
- package/dist/origin-ca.d.ts +1 -1
- package/dist/parse-args.d.ts +3 -0
- package/dist/push-secrets.d.ts +2 -9
- package/dist/push-target.d.ts +1 -2
- package/dist/run-command.d.ts +1 -1
- package/dist/run-subprocess.d.ts +1 -6
- package/dist/schema.d.ts +64 -155
- package/dist/secrets-encrypt.d.ts +5 -13
- package/dist/ssh-key-bootstrap.d.ts +1 -26
- package/dist/user-add.d.ts +15 -0
- package/dist/verify-user.d.ts +19 -0
- package/package.json +44 -42
- package/templates/rover-pilot/.env.schema +17 -3
- package/templates/rover-pilot/.github/workflows/build.yml +64 -17
- package/templates/rover-pilot/.github/workflows/deploy.yml +128 -61
- package/templates/rover-pilot/.github/workflows/reconcile.yml +21 -1
- package/templates/rover-pilot/README.md +5 -3
- package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +78 -24
- package/templates/rover-pilot/deploy/scripts/helpers.ts +3 -0
- package/templates/rover-pilot/deploy/scripts/resolve-deploy-handles.ts +12 -3
- package/templates/rover-pilot/deploy/scripts/resolve-missing-images.ts +13 -0
- package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +63 -9
- package/templates/rover-pilot/deploy/scripts/sync-content-repo.ts +51 -47
- package/templates/rover-pilot/deploy/scripts/update-dns.ts +14 -4
- package/templates/rover-pilot/deploy/scripts/validate-secrets.ts +1 -1
- package/templates/rover-pilot/docs/onboarding-checklist.md +33 -13
- package/templates/rover-pilot/docs/operator-playbook.md +183 -10
- package/templates/rover-pilot/docs/user-onboarding.md +67 -332
- package/templates/rover-pilot/pilot.yaml +1 -1
- package/templates/rover-pilot/.kamal/hooks/pre-deploy +0 -9
- package/templates/rover-pilot/deploy/Dockerfile +0 -30
- package/templates/rover-pilot/deploy/kamal/deploy.yml +0 -40
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {
|
|
2
|
+
requireEnv,
|
|
3
|
+
runResolveMissingImages,
|
|
4
|
+
writeGitHubOutput,
|
|
5
|
+
} from "./helpers";
|
|
6
|
+
|
|
7
|
+
// The Build workflow's resolve step — the logic lives in @rizom/ops; this
|
|
8
|
+
// pilot repo only supplies its own paths and repository.
|
|
9
|
+
await runResolveMissingImages({
|
|
10
|
+
rootDir: process.cwd(),
|
|
11
|
+
imageRepository: `ghcr.io/${requireEnv("GITHUB_REPOSITORY")}`,
|
|
12
|
+
writeOutput: writeGitHubOutput,
|
|
13
|
+
});
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { loadPilotRegistry } from "@rizom/ops";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
parseEnvFile,
|
|
7
|
+
requireEnv,
|
|
8
|
+
sitePackagesFor,
|
|
9
|
+
siteImageTag,
|
|
10
|
+
writeGitHubOutput,
|
|
11
|
+
} from "./helpers";
|
|
4
12
|
|
|
5
13
|
const handle = requireEnv("HANDLE");
|
|
6
14
|
const envPath = `users/${handle}/.env`;
|
|
@@ -17,23 +25,44 @@ if (!brainDomain) {
|
|
|
17
25
|
throw new Error(`Missing domain in ${brainYamlPath}`);
|
|
18
26
|
}
|
|
19
27
|
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (!zone) {
|
|
25
|
-
throw new Error(`Could not derive preview domain from ${brainDomain}`);
|
|
28
|
+
const registry = await loadPilotRegistry(process.cwd());
|
|
29
|
+
const user = registry.users.find((entry) => entry.handle === handle);
|
|
30
|
+
if (!user) {
|
|
31
|
+
throw new Error(`Unknown user handle: ${handle}`);
|
|
26
32
|
}
|
|
27
|
-
const previewDomain =
|
|
33
|
+
const previewDomain = resolvePreviewDomain(
|
|
34
|
+
handle,
|
|
35
|
+
brainDomain,
|
|
36
|
+
registry.pilot.domainSuffix,
|
|
37
|
+
);
|
|
38
|
+
const wwwDomain = isFleetDomain(
|
|
39
|
+
handle,
|
|
40
|
+
brainDomain,
|
|
41
|
+
registry.pilot.domainSuffix,
|
|
42
|
+
)
|
|
43
|
+
? ""
|
|
44
|
+
: `www.${brainDomain}`;
|
|
45
|
+
|
|
46
|
+
const brainVersion = envEntries["BRAIN_VERSION"] ?? "";
|
|
47
|
+
|
|
48
|
+
// The image tag is a pure function of this instance's own config: plain
|
|
49
|
+
// `brain-{version}` for a default instance, or its own `brain-{version}-sites-
|
|
50
|
+
// {hash}` when it declares a siteOverride. Resolved through the same helper the
|
|
51
|
+
// build uses so the tag we wait for and run matches exactly what was pushed.
|
|
52
|
+
const sitePackages = sitePackagesFor(user.siteOverride);
|
|
53
|
+
const imageTag = siteImageTag(brainVersion, sitePackages);
|
|
28
54
|
|
|
29
55
|
const outputs: Record<string, string> = {
|
|
30
|
-
brain_version:
|
|
56
|
+
brain_version: brainVersion,
|
|
31
57
|
content_repo: envEntries["CONTENT_REPO"] ?? "",
|
|
32
58
|
brain_domain: brainDomain,
|
|
33
59
|
preview_domain: previewDomain,
|
|
60
|
+
www_domain: wwwDomain,
|
|
61
|
+
cloudflare_zone_id: user.cloudflareZoneId ?? process.env["CF_ZONE_ID"] ?? "",
|
|
34
62
|
brain_yaml_path: brainYamlPath,
|
|
35
63
|
instance_name: `rover-${handle}`,
|
|
36
64
|
image_repository: `ghcr.io/${repository}`,
|
|
65
|
+
image_tag: imageTag,
|
|
37
66
|
registry_username: repositoryOwner,
|
|
38
67
|
};
|
|
39
68
|
|
|
@@ -47,3 +76,28 @@ for (const key of required) {
|
|
|
47
76
|
for (const [key, value] of Object.entries(outputs)) {
|
|
48
77
|
writeGitHubOutput(key, value);
|
|
49
78
|
}
|
|
79
|
+
|
|
80
|
+
function resolvePreviewDomain(
|
|
81
|
+
userHandle: string,
|
|
82
|
+
domain: string,
|
|
83
|
+
pilotDomainSuffix: string,
|
|
84
|
+
): string {
|
|
85
|
+
if (!isFleetDomain(userHandle, domain, pilotDomainSuffix)) {
|
|
86
|
+
return `preview.${domain}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const fleetZone = pilotDomainSuffix.replace(/^\./, "");
|
|
90
|
+
if (!fleetZone) {
|
|
91
|
+
throw new Error(`Could not derive preview domain from ${domain}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return `${userHandle}-preview.${fleetZone}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isFleetDomain(
|
|
98
|
+
userHandle: string,
|
|
99
|
+
domain: string,
|
|
100
|
+
pilotDomainSuffix: string,
|
|
101
|
+
): boolean {
|
|
102
|
+
return domain === `${userHandle}${pilotDomainSuffix}`;
|
|
103
|
+
}
|
|
@@ -5,53 +5,6 @@ import { dirname, join } from "node:path";
|
|
|
5
5
|
import { execFileSync } from "node:child_process";
|
|
6
6
|
import { readJsonResponse, requireEnv } from "./helpers";
|
|
7
7
|
|
|
8
|
-
const handle = requireEnv("HANDLE");
|
|
9
|
-
const contentRepo = requireEnv("CONTENT_REPO");
|
|
10
|
-
const token = requireEnv("GIT_SYNC_TOKEN");
|
|
11
|
-
const sourceDir = join("users", handle, "content");
|
|
12
|
-
|
|
13
|
-
if (!existsSync(sourceDir)) {
|
|
14
|
-
process.exit(0);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const { owner, repo } = parseRepoSlug(contentRepo);
|
|
18
|
-
await ensureGitHubRepo({ owner, repo, token });
|
|
19
|
-
|
|
20
|
-
const tempRoot = await mkdtemp(join(tmpdir(), "brains-ops-content-"));
|
|
21
|
-
const checkoutDir = join(tempRoot, "repo");
|
|
22
|
-
const remoteUrl = buildAuthenticatedRemoteUrl(owner, repo, token);
|
|
23
|
-
|
|
24
|
-
runGit(["clone", remoteUrl, checkoutDir]);
|
|
25
|
-
runGit(["-C", checkoutDir, "checkout", "-B", "main"]);
|
|
26
|
-
|
|
27
|
-
const copiedFiles = await copyMissingFiles(sourceDir, checkoutDir);
|
|
28
|
-
if (copiedFiles === 0) {
|
|
29
|
-
process.exit(0);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
runGit(["-C", checkoutDir, "config", "user.name", "brains-ops[bot]"]);
|
|
33
|
-
runGit([
|
|
34
|
-
"-C",
|
|
35
|
-
checkoutDir,
|
|
36
|
-
"config",
|
|
37
|
-
"user.email",
|
|
38
|
-
"41898282+github-actions[bot]@users.noreply.github.com",
|
|
39
|
-
]);
|
|
40
|
-
runGit(["-C", checkoutDir, "add", "."]);
|
|
41
|
-
|
|
42
|
-
if (hasNoStagedChanges(checkoutDir)) {
|
|
43
|
-
process.exit(0);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
runGit([
|
|
47
|
-
"-C",
|
|
48
|
-
checkoutDir,
|
|
49
|
-
"commit",
|
|
50
|
-
"-m",
|
|
51
|
-
`chore(content): seed ${handle} anchor profile`,
|
|
52
|
-
]);
|
|
53
|
-
runGit(["-C", checkoutDir, "push", "origin", "HEAD:main"]);
|
|
54
|
-
|
|
55
8
|
const STALE_ANCHOR_PROFILE_MARKERS = [
|
|
56
9
|
"name: Your Name Here",
|
|
57
10
|
"Delete this and write your own",
|
|
@@ -68,6 +21,55 @@ interface GitHubRepoResponse {
|
|
|
68
21
|
private?: boolean;
|
|
69
22
|
}
|
|
70
23
|
|
|
24
|
+
async function main(): Promise<void> {
|
|
25
|
+
const handle = requireEnv("HANDLE");
|
|
26
|
+
const contentRepo = requireEnv("CONTENT_REPO");
|
|
27
|
+
const token = requireEnv("GIT_SYNC_TOKEN");
|
|
28
|
+
const sourceDir = join("users", handle, "content");
|
|
29
|
+
|
|
30
|
+
if (!existsSync(sourceDir)) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const { owner, repo } = parseRepoSlug(contentRepo);
|
|
35
|
+
await ensureGitHubRepo({ owner, repo, token });
|
|
36
|
+
|
|
37
|
+
const tempRoot = await mkdtemp(join(tmpdir(), "brains-ops-content-"));
|
|
38
|
+
const checkoutDir = join(tempRoot, "repo");
|
|
39
|
+
const remoteUrl = buildAuthenticatedRemoteUrl(owner, repo, token);
|
|
40
|
+
|
|
41
|
+
runGit(["clone", remoteUrl, checkoutDir]);
|
|
42
|
+
runGit(["-C", checkoutDir, "checkout", "-B", "main"]);
|
|
43
|
+
|
|
44
|
+
const copiedFiles = await copyMissingFiles(sourceDir, checkoutDir);
|
|
45
|
+
if (copiedFiles === 0) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
runGit(["-C", checkoutDir, "config", "user.name", "brains-ops[bot]"]);
|
|
50
|
+
runGit([
|
|
51
|
+
"-C",
|
|
52
|
+
checkoutDir,
|
|
53
|
+
"config",
|
|
54
|
+
"user.email",
|
|
55
|
+
"41898282+github-actions[bot]@users.noreply.github.com",
|
|
56
|
+
]);
|
|
57
|
+
runGit(["-C", checkoutDir, "add", "."]);
|
|
58
|
+
|
|
59
|
+
if (hasNoStagedChanges(checkoutDir)) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
runGit([
|
|
64
|
+
"-C",
|
|
65
|
+
checkoutDir,
|
|
66
|
+
"commit",
|
|
67
|
+
"-m",
|
|
68
|
+
`chore(content): seed ${handle} anchor profile`,
|
|
69
|
+
]);
|
|
70
|
+
runGit(["-C", checkoutDir, "push", "origin", "HEAD:main"]);
|
|
71
|
+
}
|
|
72
|
+
|
|
71
73
|
async function ensureGitHubRepo(
|
|
72
74
|
options: EnsureGitHubRepoOptions,
|
|
73
75
|
): Promise<void> {
|
|
@@ -177,3 +179,5 @@ function isStaleAnchorProfile(content: string): boolean {
|
|
|
177
179
|
content.includes(marker),
|
|
178
180
|
);
|
|
179
181
|
}
|
|
182
|
+
|
|
183
|
+
await main();
|
|
@@ -16,8 +16,11 @@ interface CloudflareResult {
|
|
|
16
16
|
result?: Array<{ id: string }>;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
async function
|
|
20
|
-
|
|
19
|
+
async function findRecordId(
|
|
20
|
+
name: string,
|
|
21
|
+
type: "A" | "CNAME",
|
|
22
|
+
): Promise<string | undefined> {
|
|
23
|
+
const lookupUrl = `${baseUrl}/zones/${zoneId}/dns_records?type=${type}&name=${encodeURIComponent(name)}`;
|
|
21
24
|
const lookup = await fetch(lookupUrl, { headers });
|
|
22
25
|
const payload = (await readJsonResponse(
|
|
23
26
|
lookup,
|
|
@@ -27,9 +30,16 @@ async function upsertRecord(name: string): Promise<void> {
|
|
|
27
30
|
throw new Error(`Cloudflare DNS lookup failed: ${JSON.stringify(payload)}`);
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
|
|
33
|
+
return payload.result?.[0]?.id;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function upsertRecord(name: string): Promise<void> {
|
|
37
|
+
// Prefer an existing A record. If the hostname currently has a CNAME,
|
|
38
|
+
// replace that CNAME in-place so deploys can claim legacy www aliases.
|
|
39
|
+
const existing =
|
|
40
|
+
(await findRecordId(name, "A")) ?? (await findRecordId(name, "CNAME"));
|
|
31
41
|
const url = existing
|
|
32
|
-
? `${baseUrl}/zones/${zoneId}/dns_records/${existing
|
|
42
|
+
? `${baseUrl}/zones/${zoneId}/dns_records/${existing}`
|
|
33
43
|
: `${baseUrl}/zones/${zoneId}/dns_records`;
|
|
34
44
|
|
|
35
45
|
const response = await fetch(url, {
|
|
@@ -4,7 +4,7 @@ import { parseEnvSchema } from "./helpers";
|
|
|
4
4
|
const envSchemaPath = ".env.schema";
|
|
5
5
|
const schema = parseEnvSchema(readFileSync(envSchemaPath, "utf8"));
|
|
6
6
|
const requiredKeys = schema
|
|
7
|
-
.filter((entry) => entry.required)
|
|
7
|
+
.filter((entry) => entry.required && entry.key !== "BWS_ACCESS_TOKEN")
|
|
8
8
|
.map((entry) => entry.key);
|
|
9
9
|
|
|
10
10
|
const missing: string[] = [];
|
|
@@ -4,23 +4,43 @@
|
|
|
4
4
|
2. Run `bunx brains-ops age-key:bootstrap <repo> --push-to gh`.
|
|
5
5
|
3. Fill in `pilot.yaml`.
|
|
6
6
|
- keep your pinned `brainVersion`
|
|
7
|
-
- confirm shared selectors for `aiApiKey`, `gitSyncToken`, and `
|
|
7
|
+
- confirm shared selectors for `aiApiKey`, `gitSyncToken`, and `contentRepoAdminToken`
|
|
8
|
+
- use different tokens for `contentRepoAdminToken` and `gitSyncToken`: admin creates/checks content repos; sync is used by runtime directory-sync
|
|
8
9
|
- confirm `agePublicKey`
|
|
9
|
-
4.
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
|
|
10
|
+
4. Run `bunx brains-ops user:add <repo> <handle> --cohort <cohort>`.
|
|
11
|
+
- Web chat is the primary interface; it needs no per-user setup beyond the passkey.
|
|
12
|
+
- `user:add` currently writes `discord: enabled: true`; set it to `false` unless the user's cohort actually uses Discord.
|
|
13
|
+
- if the user should be an anchor on Discord, add `--anchor-id <discord-user-id>`.
|
|
14
|
+
- the command creates `users/<handle>.yaml`, `users/<handle>.secrets.yaml`, and the cohort membership without duplicating existing entries.
|
|
15
|
+
5. Edit the generated user file if the anchor profile needs richer metadata.
|
|
16
|
+
- Set `setup.delivery: email` and `setup.email` so the user gets the passkey setup email — this is the default onboarding path.
|
|
17
|
+
- For ATProto publishing, add `atproto.identifier` to the user file; put only `atprotoAppPassword` in the per-user secrets file.
|
|
18
|
+
- Ensure `SETUP_EMAIL_API_KEY` and `SETUP_EMAIL_FROM` exist as GitHub Secrets before deploying any email-setup user.
|
|
13
19
|
6. Run `bunx brains-ops render <repo>`.
|
|
14
20
|
7. Run `bunx brains-ops ssh-key:bootstrap <repo> --push-to gh`.
|
|
15
21
|
8. Run `bunx brains-ops cert:bootstrap <repo> --push-to gh`.
|
|
16
|
-
9. Keep raw user secret material locally for now (`.env.local`, file-backed env vars, or equivalent local inputs).
|
|
22
|
+
9. Keep raw user secret material locally for now (`.env.local`, file-backed env vars, or equivalent local inputs), including `CONTENT_REPO_ADMIN_TOKEN` for operator onboarding.
|
|
17
23
|
10. Run `bunx brains-ops secrets:encrypt <repo> <handle>`.
|
|
18
24
|
11. Commit and push `users/<handle>.secrets.yaml.age`.
|
|
19
25
|
12. Run `bunx brains-ops onboard <repo> <handle>`.
|
|
20
|
-
13. Verify the deployed
|
|
21
|
-
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
13. Verify the deployed Rover contract:
|
|
27
|
+
- all presets:
|
|
28
|
+
- `https://<handle>.rizom.ai/health` returns `200`
|
|
29
|
+
- `https://<handle>.rizom.ai/chat` loads the web chat and accepts passkey sign-in
|
|
30
|
+
- `https://<handle>.rizom.ai/` loads the dashboard (or site surface on `default` preset)
|
|
31
|
+
- `https://<handle>.rizom.ai/cms` loads the CMS/login surface
|
|
32
|
+
- unauthenticated `POST https://<handle>.rizom.ai/mcp` returns the expected auth failure
|
|
33
|
+
- content repo exists and runtime sync is healthy
|
|
34
|
+
- background jobs are not repeatedly failing, except for expected missing optional integrations
|
|
35
|
+
- for `presetOverride: default` users:
|
|
36
|
+
- initial site build completes
|
|
37
|
+
14. For fleet upgrades, edit `pilot.yaml.brainVersion` and push once; CI rebuilds the required default/site image tags, refreshes generated user env files, and redeploys affected users. Site overrides follow the user's effective brain version unless they declare an explicit exact `version` pin.
|
|
38
|
+
15. Confirm the user received the setup email, registered their passkey, and can sign in to web chat at `https://<handle>.rizom.ai/chat`. That completes the default onboarding; everything below is per-cohort extras.
|
|
39
|
+
16. Hand over the browser surfaces:
|
|
40
|
+
- Chat (primary): `https://<handle>.rizom.ai/chat`
|
|
41
|
+
- Dashboard: `https://<handle>.rizom.ai/`
|
|
42
|
+
- CMS: `https://<handle>.rizom.ai/cms`, plus GitHub token guidance if CMS editing is part of their cohort
|
|
43
|
+
17. For Discord-enabled cohorts, hand the Discord setup details to the user as a secondary chat surface.
|
|
44
|
+
18. If they need direct client access (MCP), use OAuth/passkey-capable clients where possible.
|
|
45
|
+
19. If you are also giving them a content repo workflow, describe it as optional and frame git/Obsidian as an advanced file-based path, not the default.
|
|
46
|
+
20. Send `docs/user-onboarding.md` to the user as the pilot handoff guide.
|
|
@@ -14,25 +14,42 @@ Treat these as checked-in deploy artifacts in the pilot repo:
|
|
|
14
14
|
`.env.schema` is the single source of truth for required and sensitive deploy vars.
|
|
15
15
|
The deploy scripts and workflows should read from that contract instead of inventing a second list.
|
|
16
16
|
|
|
17
|
-
The
|
|
17
|
+
The default pilot image tag is `brain-${brainVersion}`:
|
|
18
18
|
|
|
19
|
-
- build publishes `brain-${brainVersion}`
|
|
19
|
+
- build publishes `brain-${brainVersion}` for users without a site override
|
|
20
|
+
- a site override gets an isolated `brain-${brainVersion}-sites-${packageHash}` image
|
|
20
21
|
- generated `users/<handle>/.env` carries `BRAIN_VERSION=<brainVersion>`
|
|
21
|
-
- deploy
|
|
22
|
+
- build and deploy derive the same effective image tag from the resolved registry
|
|
22
23
|
|
|
23
24
|
## Version bump flow
|
|
24
25
|
|
|
25
26
|
When `pilot.yaml.brainVersion` changes and you push:
|
|
26
27
|
|
|
27
|
-
1. build publishes the new
|
|
28
|
+
1. build publishes the new default image and any required site images
|
|
28
29
|
2. reconcile refreshes generated `users/<handle>/.env`
|
|
29
30
|
3. deploy runs for handles whose generated config changed
|
|
30
31
|
4. generated file commits happen once in a final aggregation step after the deploy matrix finishes
|
|
31
32
|
|
|
33
|
+
An omitted `siteOverride.version` follows the user's effective brain version, so a
|
|
34
|
+
cohort or pilot version bump advances its site and theme packages automatically.
|
|
35
|
+
Set an exact `siteOverride.version` only when that user needs a deliberate pin.
|
|
36
|
+
|
|
32
37
|
When a push changes only deploy contract files and no generated `users/<handle>/.env` or `users/<handle>/brain.yaml` files, the deploy workflow exits through its explicit no-op path and prints `No affected user configs; skipping deploy.`
|
|
33
38
|
|
|
34
39
|
They are scaffolded from `@rizom/ops`, then versioned in this repo like any other deploy contract.
|
|
35
40
|
|
|
41
|
+
## Stale deploy lock recovery
|
|
42
|
+
|
|
43
|
+
Kamal intentionally leaves its remote deploy lock in place when a deployment is cancelled or interrupted. Confirm that no deployment for the user is still active before releasing the lock, then use the deploy workflow's explicit recovery input:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
gh workflow run Deploy --ref main \
|
|
47
|
+
-f handle=<handle> \
|
|
48
|
+
-f release_stale_lock=true
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Recovery is opt-in and scoped to one handle. Normal push, reconcile, and manual deploy runs never remove a lock automatically.
|
|
52
|
+
|
|
36
53
|
## Bootstrap flow
|
|
37
54
|
|
|
38
55
|
For this fleet, operator-local secret material remains the source of truth during onboarding and rotation. The repo stores encrypted per-user secrets, not raw values.
|
|
@@ -60,15 +77,171 @@ When `@rizom/ops` changes the scaffolded deploy contract:
|
|
|
60
77
|
3. review the resulting changes to `.env.schema`, `deploy/scripts/`, and workflows in git
|
|
61
78
|
4. commit the updated deploy artifacts together
|
|
62
79
|
|
|
63
|
-
## Rover
|
|
80
|
+
## Rover verification notes
|
|
81
|
+
|
|
82
|
+
Use the verification script after deploy:
|
|
64
83
|
|
|
65
|
-
|
|
84
|
+
```sh
|
|
85
|
+
bunx brains-ops verify-user . <handle>
|
|
86
|
+
```
|
|
66
87
|
|
|
67
|
-
|
|
88
|
+
It checks every Rover preset:
|
|
68
89
|
|
|
69
90
|
- `https://<handle>.rizom.ai/health` should return `200`
|
|
70
|
-
- unauthenticated `POST https://<handle>.rizom.ai/mcp` should return
|
|
71
|
-
-
|
|
91
|
+
- unauthenticated `POST https://<handle>.rizom.ai/mcp` should return the expected auth failure
|
|
92
|
+
- background jobs should not be repeatedly failing, except for expected missing optional integrations
|
|
93
|
+
|
|
94
|
+
Additional `rover:core` note:
|
|
95
|
+
|
|
96
|
+
- Rover core is MCP-only; a bare `GET /` may return `401`, which does not indicate a bad deploy.
|
|
97
|
+
|
|
98
|
+
For `preset: default`, the script also checks:
|
|
99
|
+
|
|
100
|
+
- `https://<handle>.rizom.ai/` loads the browser/site surface
|
|
101
|
+
- `https://<handle>.rizom.ai/cms` loads the CMS/login surface
|
|
102
|
+
|
|
103
|
+
Manual checks that remain:
|
|
104
|
+
|
|
105
|
+
- initial site build is correct for the expected content/theme
|
|
106
|
+
- content repo exists and runtime sync is healthy beyond the basic `/health` response
|
|
107
|
+
- passkey setup/handoff is completed from the setup email
|
|
108
|
+
|
|
109
|
+
## One-user `rover:default` baseline canary
|
|
110
|
+
|
|
111
|
+
Run this before adding custom site/theme packages or rolling a larger browser/CMS-first cohort.
|
|
112
|
+
|
|
113
|
+
1. Create or choose a canary cohort with the default preset:
|
|
114
|
+
|
|
115
|
+
```yaml
|
|
116
|
+
presetOverride: default
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
2. Add exactly one canary user to that cohort.
|
|
120
|
+
3. For browser/CMS-first onboarding, configure setup email in `users/<handle>.yaml`:
|
|
121
|
+
|
|
122
|
+
```yaml
|
|
123
|
+
setup:
|
|
124
|
+
delivery: email
|
|
125
|
+
email: user@example.com
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
4. Encrypt the user's secrets and commit only the `.age` file.
|
|
129
|
+
5. Run `bunx brains-ops onboard . <handle>`.
|
|
130
|
+
6. Run `bunx brains-ops verify-user . <handle>` with no custom site/theme overrides.
|
|
131
|
+
7. Ask the user to complete passkey setup from the setup email.
|
|
132
|
+
8. Continue to visual customization only after the canary is healthy.
|
|
133
|
+
|
|
134
|
+
Rollback:
|
|
135
|
+
|
|
136
|
+
- move the canary back to a core cohort, or remove `presetOverride: default` from the cohort
|
|
137
|
+
- reconcile generated outputs
|
|
138
|
+
- rebuild/redeploy the affected user
|
|
139
|
+
|
|
140
|
+
## Hosted site and theme package contract
|
|
141
|
+
|
|
142
|
+
Start with the public [site mockup migration guide](https://github.com/rizom-ai/brains/blob/main/docs/site-mockup-migration.md), then apply these hosted-fleet requirements:
|
|
143
|
+
|
|
144
|
+
- A site package must default-export a valid `SitePackage` and use documented public APIs such as `@rizom/brain/site`.
|
|
145
|
+
- A theme package must default-export its CSS as a string. Hosted custom themes currently use the `@rizom/*` scope so the fleet image installs them with the site package; `@brains/*` themes are bundled with `@rizom/brain`.
|
|
146
|
+
- Site and custom theme packages must be public npm packages that install without registry credentials.
|
|
147
|
+
- Publish packages at the same exact version as the compatible `@rizom/brain` release. Hosted configuration resolves package names plus the effective version into exact npm refs.
|
|
148
|
+
- Keep site structure and theme CSS in separate packages. Do not put private content or secrets in either package.
|
|
149
|
+
|
|
150
|
+
Configure a user in `users/<handle>.yaml`:
|
|
151
|
+
|
|
152
|
+
```yaml
|
|
153
|
+
siteOverride:
|
|
154
|
+
package: "@rizom/site-example"
|
|
155
|
+
theme: "@rizom/theme-example"
|
|
156
|
+
# version: <exact-brain-version> # optional deliberate pin
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
When `version` is omitted, it defaults to the user's effective brain version
|
|
160
|
+
(cohort override first, then `pilot.yaml.brainVersion`). A site override produces
|
|
161
|
+
an isolated per-instance image; it never changes the fleet's shared default
|
|
162
|
+
image.
|
|
163
|
+
|
|
164
|
+
### Custom-package canary and rollback
|
|
165
|
+
|
|
166
|
+
1. Confirm the exact site/theme versions are public-installable without npm credentials.
|
|
167
|
+
2. Apply the package names to one healthy `rover:default` canary.
|
|
168
|
+
3. Reconcile the canary, push the generated output, and let build/deploy create its site image.
|
|
169
|
+
4. Run `bunx brains-ops verify-user . <handle>`.
|
|
170
|
+
5. Manually verify the site, theme, CMS, content sync, and passkey sign-in before adding more users.
|
|
171
|
+
|
|
172
|
+
To roll back, remove or change `siteOverride`, reconcile, and redeploy that user.
|
|
173
|
+
The default image and other users remain untouched.
|
|
174
|
+
|
|
175
|
+
## Setup email checklist
|
|
176
|
+
|
|
177
|
+
Use this for browser/CMS-first users who should receive their own first-passkey setup link by email.
|
|
178
|
+
|
|
179
|
+
1. Add setup delivery to the user file:
|
|
180
|
+
|
|
181
|
+
```yaml
|
|
182
|
+
setup:
|
|
183
|
+
delivery: email
|
|
184
|
+
email: user@example.com
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
2. Configure these GitHub Secrets before deploy:
|
|
188
|
+
- `SETUP_EMAIL_API_KEY`
|
|
189
|
+
- `SETUP_EMAIL_FROM`
|
|
190
|
+
|
|
191
|
+
3. Reconcile/deploy the user or cohort:
|
|
192
|
+
- `bunx brains-ops onboard . <handle>`
|
|
193
|
+
- or `bunx brains-ops reconcile-cohort . <cohort>`
|
|
194
|
+
|
|
195
|
+
4. Verify the generated `users/<handle>/brain.yaml` contains `auth-service.setupEmail` and `email-resend` config.
|
|
196
|
+
5. Ask the user to complete passkey setup from the email link, then use:
|
|
197
|
+
- Dashboard: `https://<handle>.rizom.ai/`
|
|
198
|
+
- CMS: `https://<handle>.rizom.ai/cms`
|
|
199
|
+
|
|
200
|
+
Notes:
|
|
201
|
+
|
|
202
|
+
- The setup URL is generated and sent by the running brain; operators should not scrape logs or SSH into the instance to retrieve it.
|
|
203
|
+
- The auth service owns setup email dedupe. It should not resend for the same persisted setup token after restart, but should retry failed delivery and resend after token rotation.
|
|
204
|
+
- `SETUP_EMAIL_FROM` is not marked required because fleets without email setup can omit it, but it is required for users with `setup.delivery: email`.
|
|
205
|
+
|
|
206
|
+
## AT Protocol smoke/config checklist
|
|
207
|
+
|
|
208
|
+
Use this when enabling AT Protocol publishing for a single pilot user.
|
|
209
|
+
|
|
210
|
+
1. Add the public PDS identifier to the user file. Prefer the account DID as
|
|
211
|
+
the identifier — it survives handle changes. Add `accountDid` too when the
|
|
212
|
+
member wants their handle verified against their subdomain
|
|
213
|
+
(`@<handle>.<domainSuffix>`): the brain then serves it at
|
|
214
|
+
`/.well-known/atproto-did` and Bluesky's "I have my own domain" HTTP
|
|
215
|
+
verification passes with no DNS records.
|
|
216
|
+
|
|
217
|
+
```yaml
|
|
218
|
+
atproto:
|
|
219
|
+
identifier: did:plc:example123
|
|
220
|
+
accountDid: did:plc:example123
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Only for the PDS account designated by the protocol authority's `_lexicon`
|
|
224
|
+
DNS TXT record, also set `lexiconAuthority: true`. Every other fleet user
|
|
225
|
+
must omit it.
|
|
226
|
+
|
|
227
|
+
2. Put the app password in `users/<handle>.secrets.yaml`:
|
|
228
|
+
|
|
229
|
+
```yaml
|
|
230
|
+
atprotoAppPassword: <app-password>
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
3. Encrypt the per-user secret payload:
|
|
234
|
+
- `bunx brains-ops secrets:encrypt . <handle>`
|
|
235
|
+
4. Reconcile/deploy the user or cohort:
|
|
236
|
+
- `bunx brains-ops onboard . <handle>`
|
|
237
|
+
- or `bunx brains-ops reconcile-cohort . <cohort>`
|
|
238
|
+
5. Verify the generated `users/<handle>/brain.yaml` contains `plugins.atproto.identifier` (plus `accountDid` and `lexiconAuthority` when configured) and `appPassword: ${ATPROTO_APP_PASSWORD}`.
|
|
239
|
+
|
|
240
|
+
Notes:
|
|
241
|
+
|
|
242
|
+
- The ATProto identifier and authority flag are public instance config and belong in `users/<handle>.yaml`. Only the DNS-designated authority account may set `lexiconAuthority: true`.
|
|
243
|
+
- The ATProto app password is secret and belongs only in the encrypted per-user secret payload.
|
|
244
|
+
- For smoke deployments, pin only the smoke cohort/user to the released brain version that contains ATProto support.
|
|
72
245
|
|
|
73
246
|
## Discord bot token checklist
|
|
74
247
|
|
|
@@ -97,7 +270,7 @@ Notes:
|
|
|
97
270
|
- Do not reuse the same Discord bot token across multiple pilot users.
|
|
98
271
|
- Discord is the default pilot interface moving forward.
|
|
99
272
|
- The encrypted `users/<handle>.secrets.yaml.age` file is the durable checked-in deploy input; your local env is only the operator staging source.
|
|
100
|
-
- MCP
|
|
273
|
+
- Direct MCP client access should use OAuth/passkey-capable clients where possible.
|
|
101
274
|
- When explaining the content workflow, describe it first as a normal **git repo** of **markdown/text files**.
|
|
102
275
|
- Position **Obsidian** as optional: it is just one possible editor for those same files, not the default requirement.
|
|
103
276
|
|