@rizom/ops 0.2.0-alpha.25 → 0.2.0-alpha.250
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 +20 -1
- package/dist/brains-ops.js +383 -288
- 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 +5 -0
- package/dist/index.js +423 -287
- package/dist/legacy-pilot-migration.d.ts +11 -0
- package/dist/load-registry.d.ts +55 -6
- package/dist/observed-status.d.ts +1 -1
- package/dist/origin-ca.d.ts +1 -1
- package/dist/parse-args.d.ts +2 -10
- package/dist/preview-domain.d.ts +9 -0
- package/dist/push-secrets.d.ts +2 -9
- package/dist/push-target.d.ts +1 -2
- package/dist/reconcile-dry-run.d.ts +9 -0
- package/dist/run-command.d.ts +5 -3
- package/dist/run-subprocess.d.ts +1 -6
- package/dist/schema.d.ts +100 -162
- package/dist/secrets-encrypt.d.ts +7 -13
- package/dist/ssh-key-bootstrap.d.ts +1 -26
- package/dist/stage-legacy-crossover.d.ts +20 -0
- package/dist/user-add.d.ts +15 -0
- package/dist/verify-user.d.ts +19 -0
- package/package.json +46 -42
- package/templates/rover-pilot/.env.schema +24 -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 +22 -2
- package/templates/rover-pilot/README.md +12 -5
- package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +80 -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 +44 -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 +12 -1
- package/templates/rover-pilot/docs/canonical-crossover-record.md +104 -0
- package/templates/rover-pilot/docs/onboarding-checklist.md +28 -12
- package/templates/rover-pilot/docs/operator-playbook.md +219 -24
- package/templates/rover-pilot/docs/user-onboarding.md +49 -343
- package/templates/rover-pilot/pilot.yaml +3 -4
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# private brain pilot
|
|
2
2
|
|
|
3
|
-
Private desired-state
|
|
3
|
+
Private desired-state repository for the hosted brain pilot. The generated repository/service name may remain `rover-pilot` for deployment identity compatibility.
|
|
4
4
|
|
|
5
5
|
This is a single operator-owned repo. Pilot users do not get their own brain repos.
|
|
6
6
|
Per-user deploy config lives under `users/<handle>/`, while content stays in per-user content repos.
|
|
@@ -29,18 +29,25 @@ The repo also checks in its deploy contract:
|
|
|
29
29
|
- `.github/workflows/*`
|
|
30
30
|
|
|
31
31
|
`.env.schema` is the single source of truth for required and sensitive deploy vars.
|
|
32
|
-
|
|
33
|
-
|
|
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.
|
|
34
35
|
When a push changes only deploy contract files, CI prints `No affected user configs; skipping deploy.` and stops before Kamal.
|
|
35
36
|
|
|
36
37
|
## Commands
|
|
37
38
|
|
|
38
39
|
- `brains-ops init <repo>`
|
|
39
40
|
- `brains-ops render <repo>` — regenerates `views/users.md` with live DNS, `/health`, and unauthenticated `/mcp` status checks
|
|
40
|
-
- `brains-ops
|
|
41
|
+
- `brains-ops user:add <repo> <handle> --cohort <cohort>` — scaffolds a user file, per-user secrets template, and cohort membership
|
|
42
|
+
- `brains-ops onboard <repo> <handle>` — creates/seeds the user's content repo with separate admin and sync tokens
|
|
41
43
|
- `brains-ops age-key:bootstrap <repo>`
|
|
42
44
|
- `brains-ops ssh-key:bootstrap <repo>`
|
|
43
45
|
- `brains-ops cert:bootstrap <repo>`
|
|
44
46
|
- `brains-ops secrets:encrypt <repo> <handle>`
|
|
45
47
|
- `brains-ops reconcile-cohort <repo> <cohort>`
|
|
46
48
|
- `brains-ops reconcile-all <repo>`
|
|
49
|
+
- `brains-ops reconcile-all <repo> --dry-run` (isolated, no external content-repository access; lists both passes' changed files)
|
|
50
|
+
|
|
51
|
+
`render` owns the observational `views/users.md` projection. Reconcile owns generated per-user config and never rewrites observed status rows.
|
|
52
|
+
|
|
53
|
+
Use `docs/canonical-crossover-record.md` to record exact forward and rollback artifact pins before an approved crossover window.
|
|
@@ -15,13 +15,52 @@ const decrypter = new Decrypter();
|
|
|
15
15
|
decrypter.addIdentity(ageSecretKey);
|
|
16
16
|
|
|
17
17
|
const plaintext = await decrypter.decrypt(decoded, "text");
|
|
18
|
-
const secrets =
|
|
19
|
-
const pilot =
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
const secrets = parseStringYamlMapping(plaintext);
|
|
19
|
+
const pilot = parseStringYamlMapping(readFileSync("pilot.yaml", "utf8"));
|
|
20
|
+
|
|
21
|
+
writeSecretGitHubEnv("AI_API_KEY", secrets["aiApiKey"]);
|
|
22
|
+
writeSecretGitHubEnv("GIT_SYNC_TOKEN", secrets["gitSyncToken"]);
|
|
23
|
+
writeSecretGitHubEnv(
|
|
24
|
+
"CMS_CONTENT_REPO_PAT",
|
|
25
|
+
secrets["cmsContentRepoPat"] ?? secrets["gitSyncToken"],
|
|
26
|
+
);
|
|
27
|
+
writeSecretGitHubEnv("DISCORD_BOT_TOKEN", secrets["discordBotToken"]);
|
|
28
|
+
writeSecretGitHubEnv("DISCORD_PUBLIC_KEY", secrets["discordPublicKey"]);
|
|
29
|
+
writeSecretGitHubEnv("DISCORD_APPLICATION_ID", secrets["discordApplicationId"]);
|
|
30
|
+
// Per-user AT Protocol publishing credential (optional; from the user's
|
|
31
|
+
// encrypted secrets file). The scaffold wires this so a pilot can publish its
|
|
32
|
+
// brain's agent card to its PDS; a deployment that doesn't publish simply
|
|
33
|
+
// leaves it unset.
|
|
34
|
+
writeSecretGitHubEnv("ATPROTO_APP_PASSWORD", secrets["atprotoAppPassword"]);
|
|
35
|
+
// Per-user custom-domain TLS overrides the shared fleet certificate when a
|
|
36
|
+
// complete certificate/key pair is present in the encrypted user secrets.
|
|
37
|
+
const certificatePem = decodeEscapedSecret(secrets["certificatePem"]);
|
|
38
|
+
const privateKeyPem = decodeEscapedSecret(secrets["privateKeyPem"]);
|
|
39
|
+
if (Boolean(certificatePem) !== Boolean(privateKeyPem)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
"Custom-domain TLS secrets require both certificatePem and privateKeyPem",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
// A corrupted stored value would otherwise only surface as a kamal-proxy
|
|
45
|
+
// "unable to load certificate" failure after the container has already booted.
|
|
46
|
+
assertPemShape("certificatePem", certificatePem);
|
|
47
|
+
assertPemShape("privateKeyPem", privateKeyPem);
|
|
48
|
+
writeSecretGitHubEnv("CERTIFICATE_PEM", certificatePem);
|
|
49
|
+
writeSecretGitHubEnv("PRIVATE_KEY_PEM", privateKeyPem);
|
|
50
|
+
|
|
51
|
+
function assertPemShape(name: string, value: string | undefined): void {
|
|
52
|
+
if (value === undefined || value.trim().length === 0) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (
|
|
56
|
+
!/-----BEGIN [A-Z0-9 ]+-----/.test(value) ||
|
|
57
|
+
!/-----END [A-Z0-9 ]+-----/.test(value)
|
|
58
|
+
) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Secret ${name} is not PEM-shaped after unescaping; the stored value is corrupt`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
25
64
|
|
|
26
65
|
writeGitHubOutput(
|
|
27
66
|
"shared_ai_api_key_secret_name",
|
|
@@ -32,10 +71,29 @@ writeGitHubOutput(
|
|
|
32
71
|
requireFlatValue(pilot, "gitSyncToken", "pilot.yaml"),
|
|
33
72
|
);
|
|
34
73
|
writeGitHubOutput(
|
|
35
|
-
"
|
|
36
|
-
requireFlatValue(pilot, "
|
|
74
|
+
"shared_content_repo_admin_token_secret_name",
|
|
75
|
+
requireFlatValue(pilot, "contentRepoAdminToken", "pilot.yaml"),
|
|
37
76
|
);
|
|
38
77
|
|
|
78
|
+
function writeSecretGitHubEnv(name: string, value: string | undefined): void {
|
|
79
|
+
if (!value || value.trim().length === 0) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
maskGitHubSecret(value);
|
|
84
|
+
writeGitHubEnv(name, value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function maskGitHubSecret(value: string): void {
|
|
88
|
+
const escaped = value
|
|
89
|
+
.replace(/%/g, "%25")
|
|
90
|
+
.replace(/\r/g, "%0D")
|
|
91
|
+
.replace(/\n/g, "%0A");
|
|
92
|
+
if (escaped.length > 0 && process.env["GITHUB_ACTIONS"] === "true") {
|
|
93
|
+
console.log(`::add-mask::${escaped}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
39
97
|
function extractAgeIdentity(contents: string): string {
|
|
40
98
|
const line = contents
|
|
41
99
|
.split(/\r?\n/)
|
|
@@ -49,27 +107,25 @@ function extractAgeIdentity(contents: string): string {
|
|
|
49
107
|
return line;
|
|
50
108
|
}
|
|
51
109
|
|
|
52
|
-
function
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (!line || line.startsWith("#")) {
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
110
|
+
function parseStringYamlMapping(contents: string): Record<string, string> {
|
|
111
|
+
const parsed: unknown = Bun.YAML.parse(contents);
|
|
112
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
113
|
+
throw new Error("Expected a YAML mapping");
|
|
114
|
+
}
|
|
60
115
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
116
|
+
const result: Record<string, string> = {};
|
|
117
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
118
|
+
if (typeof value === "string") {
|
|
119
|
+
result[key] = value;
|
|
64
120
|
}
|
|
65
|
-
|
|
66
|
-
const [, key, rawValue] = match;
|
|
67
|
-
result[key] = rawValue.replace(/^['"]|['"]$/g, "");
|
|
68
121
|
}
|
|
69
|
-
|
|
70
122
|
return result;
|
|
71
123
|
}
|
|
72
124
|
|
|
125
|
+
function decodeEscapedSecret(value: string | undefined): string | undefined {
|
|
126
|
+
return value?.replace(/\\n/g, "\n");
|
|
127
|
+
}
|
|
128
|
+
|
|
73
129
|
function requireFlatValue(
|
|
74
130
|
values: Record<string, string>,
|
|
75
131
|
key: string,
|
|
@@ -9,14 +9,23 @@ if (eventName === "workflow_dispatch") {
|
|
|
9
9
|
process.exit(0);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
if (eventName !== "push") {
|
|
12
|
+
if (eventName !== "push" && eventName !== "workflow_run") {
|
|
13
13
|
throw new Error(`Unsupported GITHUB_EVENT_NAME: ${eventName}`);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
const beforeSha = requireEnv("BEFORE_SHA");
|
|
17
|
-
const currentSha =
|
|
17
|
+
const currentSha =
|
|
18
|
+
eventName === "workflow_run"
|
|
19
|
+
? execFileSync("git", ["rev-parse", "HEAD"], {
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
}).trim()
|
|
22
|
+
: requireEnv("GITHUB_SHA");
|
|
18
23
|
|
|
19
|
-
if (
|
|
24
|
+
if (
|
|
25
|
+
!isUsableGitRevision(beforeSha) ||
|
|
26
|
+
!isUsableGitRevision(currentSha) ||
|
|
27
|
+
beforeSha === currentSha
|
|
28
|
+
) {
|
|
20
29
|
writeGitHubOutput("handles_json", JSON.stringify([]));
|
|
21
30
|
process.exit(0);
|
|
22
31
|
}
|
|
@@ -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 { derivePreviewDomain, 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,42 @@ 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 = derivePreviewDomain(brainDomain, {
|
|
34
|
+
sharedDomain: registry.pilot.domainSuffix,
|
|
35
|
+
});
|
|
36
|
+
const wwwDomain = isFleetDomain(
|
|
37
|
+
handle,
|
|
38
|
+
brainDomain,
|
|
39
|
+
registry.pilot.domainSuffix,
|
|
40
|
+
)
|
|
41
|
+
? ""
|
|
42
|
+
: `www.${brainDomain}`;
|
|
43
|
+
|
|
44
|
+
const brainVersion = envEntries["BRAIN_VERSION"] ?? "";
|
|
45
|
+
|
|
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);
|
|
28
52
|
|
|
29
53
|
const outputs: Record<string, string> = {
|
|
30
|
-
brain_version:
|
|
54
|
+
brain_version: brainVersion,
|
|
31
55
|
content_repo: envEntries["CONTENT_REPO"] ?? "",
|
|
32
56
|
brain_domain: brainDomain,
|
|
33
57
|
preview_domain: previewDomain,
|
|
58
|
+
www_domain: wwwDomain,
|
|
59
|
+
cloudflare_zone_id: user.cloudflareZoneId ?? process.env["CF_ZONE_ID"] ?? "",
|
|
34
60
|
brain_yaml_path: brainYamlPath,
|
|
35
61
|
instance_name: `rover-${handle}`,
|
|
36
62
|
image_repository: `ghcr.io/${repository}`,
|
|
63
|
+
image_tag: imageTag,
|
|
37
64
|
registry_username: repositoryOwner,
|
|
38
65
|
};
|
|
39
66
|
|
|
@@ -47,3 +74,11 @@ for (const key of required) {
|
|
|
47
74
|
for (const [key, value] of Object.entries(outputs)) {
|
|
48
75
|
writeGitHubOutput(key, value);
|
|
49
76
|
}
|
|
77
|
+
|
|
78
|
+
function isFleetDomain(
|
|
79
|
+
userHandle: string,
|
|
80
|
+
domain: string,
|
|
81
|
+
pilotDomainSuffix: string,
|
|
82
|
+
): boolean {
|
|
83
|
+
return domain === `${userHandle}${pilotDomainSuffix}`;
|
|
84
|
+
}
|
|
@@ -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[] = [];
|
|
@@ -14,6 +14,17 @@ for (const key of requiredKeys) {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
const discordKeys = [
|
|
18
|
+
"DISCORD_BOT_TOKEN",
|
|
19
|
+
"DISCORD_PUBLIC_KEY",
|
|
20
|
+
"DISCORD_APPLICATION_ID",
|
|
21
|
+
];
|
|
22
|
+
if (discordKeys.some((key) => process.env[key])) {
|
|
23
|
+
for (const key of discordKeys) {
|
|
24
|
+
if (!process.env[key]) missing.push(key);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
17
28
|
if (missing.length > 0) {
|
|
18
29
|
throw new Error(`Missing required secrets: ${missing.join(", ")}`);
|
|
19
30
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Canonical Crossover Approval Record
|
|
2
|
+
|
|
3
|
+
> Draft evidence record only. Completing this file does not authorize a merge, publish, reconcile, or deployment.
|
|
4
|
+
|
|
5
|
+
Do not include secret values, private keys, access tokens, or decrypted user configuration.
|
|
6
|
+
|
|
7
|
+
## Authorization and freeze
|
|
8
|
+
|
|
9
|
+
- Operator:
|
|
10
|
+
- Approval reference:
|
|
11
|
+
- Approved window:
|
|
12
|
+
- Freeze start:
|
|
13
|
+
- Build workflow disabled and idle:
|
|
14
|
+
- Reconcile workflow disabled and idle:
|
|
15
|
+
- Deploy workflow disabled and idle:
|
|
16
|
+
|
|
17
|
+
## Reviewed inputs
|
|
18
|
+
|
|
19
|
+
- `brains` crossover commit:
|
|
20
|
+
- Private-pilot source commit used for staging:
|
|
21
|
+
- Canonical review commit:
|
|
22
|
+
- Secret-free review diff SHA-256:
|
|
23
|
+
- Identity-review evidence SHA-256:
|
|
24
|
+
- Reviewed hosted-site pin manifest SHA-256:
|
|
25
|
+
- Source and review worktrees clean:
|
|
26
|
+
|
|
27
|
+
## Hosted site and theme pins
|
|
28
|
+
|
|
29
|
+
List every hosted site. External site and theme package versions must be exact and must
|
|
30
|
+
match the staged user desired state; do not infer them from the brain or from each other.
|
|
31
|
+
|
|
32
|
+
| Handle | Site package | Exact site version | Theme package | Exact theme version | Package/image evidence |
|
|
33
|
+
| ------ | ------------ | ------------------ | ------------- | ------------------- | ---------------------- |
|
|
34
|
+
| | | | | | |
|
|
35
|
+
|
|
36
|
+
## Forward artifact pins
|
|
37
|
+
|
|
38
|
+
| Artifact | Exact version | Registry integrity or digest | Verified installable |
|
|
39
|
+
| ---------------------- | ------------- | ---------------------------- | -------------------- |
|
|
40
|
+
| `@rizom/brain` | | | |
|
|
41
|
+
| `@rizom/ops` | | | |
|
|
42
|
+
| private-pilot lockfile | n/a | | |
|
|
43
|
+
|
|
44
|
+
Record every image. Tags alone are not immutable evidence.
|
|
45
|
+
|
|
46
|
+
| Cohort/handle | Config commit | Image repository | Image tag | Image digest |
|
|
47
|
+
| ------------- | ------------- | ---------------- | --------- | ------------ |
|
|
48
|
+
| | | | | |
|
|
49
|
+
|
|
50
|
+
## Rollback pair
|
|
51
|
+
|
|
52
|
+
- Prior private-pilot commit:
|
|
53
|
+
- Prior `@rizom/ops` version and registry integrity:
|
|
54
|
+
|
|
55
|
+
| Cohort/handle | Prior config commit | Prior image repository | Prior image tag | Prior image digest |
|
|
56
|
+
| ------------- | ------------------- | ---------------------- | --------------- | ------------------ |
|
|
57
|
+
| | | | | |
|
|
58
|
+
|
|
59
|
+
## Identity review
|
|
60
|
+
|
|
61
|
+
- Repository and image names unchanged or explicitly approved:
|
|
62
|
+
- GitHub organization and content repository identities unchanged:
|
|
63
|
+
- Server, domain, Cloudflare zone, and ATProto identities unchanged:
|
|
64
|
+
- Secret selector names and encrypted secret artifacts unchanged:
|
|
65
|
+
- Per-user runtime-version and site-package tag inputs reviewed:
|
|
66
|
+
- No plaintext source secrets copied into the review artifact:
|
|
67
|
+
- No removed model, preset, or old-format schema discriminator remains:
|
|
68
|
+
- Every external hosted site and theme package has an exact reviewed pin:
|
|
69
|
+
|
|
70
|
+
## Offline convergence
|
|
71
|
+
|
|
72
|
+
Command:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
bunx brains-ops reconcile-all <canonical-review-copy> --dry-run
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- First-pass reconciler-owned changed files:
|
|
79
|
+
- Second-pass reconciler-owned changed files (must be zero):
|
|
80
|
+
- Observational `views/users.md` unchanged by reconciliation:
|
|
81
|
+
- Review copy unchanged:
|
|
82
|
+
- External content-repository access blocked:
|
|
83
|
+
|
|
84
|
+
## Pre-window validation
|
|
85
|
+
|
|
86
|
+
- Package tests, typecheck, lint, build, and packed-consumer startup:
|
|
87
|
+
- Architecture and dependency boundaries:
|
|
88
|
+
- Environment-schema and workspace checks:
|
|
89
|
+
- Crossover migration and comment preservation:
|
|
90
|
+
- Canary-first order and health checks reviewed:
|
|
91
|
+
- Paired rollback reviewed:
|
|
92
|
+
|
|
93
|
+
## Execution record
|
|
94
|
+
|
|
95
|
+
Complete only inside the explicitly approved maintenance window.
|
|
96
|
+
|
|
97
|
+
- Unified packages published and verified:
|
|
98
|
+
- Canonical desired state committed while automation remained frozen:
|
|
99
|
+
- Image digest set matched this record:
|
|
100
|
+
- First reconcile reviewed:
|
|
101
|
+
- Per-instance health, MCP authorization, identity, content, and site checks:
|
|
102
|
+
- Second reconcile produced zero reconciler-owned drift and no deploy work:
|
|
103
|
+
- Freeze lifted:
|
|
104
|
+
- One-week soak start:
|
|
@@ -4,23 +4,39 @@
|
|
|
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
|
|
26
|
+
13. Verify the deployed canonical contract:
|
|
21
27
|
- `https://<handle>.rizom.ai/health` returns `200`
|
|
22
|
-
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
- `https://<handle>.rizom.ai/chat` loads the web chat and accepts passkey sign-in
|
|
29
|
+
- unauthenticated `POST https://<handle>.rizom.ai/mcp` returns the expected auth failure
|
|
30
|
+
- content repo exists and runtime sync is healthy
|
|
31
|
+
- background jobs are not repeatedly failing, except for expected missing optional integrations
|
|
32
|
+
- when `site` is selected, the browser/CMS surfaces load and the initial app-managed site build completes
|
|
33
|
+
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. Every external site and theme package keeps its own required exact version pin and never follows the brain version implicitly.
|
|
34
|
+
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.
|
|
35
|
+
16. Hand over the browser surfaces:
|
|
36
|
+
- Chat (primary): `https://<handle>.rizom.ai/chat`
|
|
37
|
+
- Dashboard: `https://<handle>.rizom.ai/`
|
|
38
|
+
- CMS: `https://<handle>.rizom.ai/cms`, plus GitHub token guidance if CMS editing is part of their cohort
|
|
39
|
+
17. For Discord-enabled cohorts, hand the Discord setup details to the user as a secondary chat surface.
|
|
40
|
+
18. If they need direct client access (MCP), use OAuth/passkey-capable clients where possible.
|
|
41
|
+
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.
|
|
42
|
+
20. Send `docs/user-onboarding.md` to the user as the pilot handoff guide.
|