@rizom/ops 0.2.0-alpha.3 → 0.2.0-alpha.30
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 +6 -2
- package/dist/age-key-bootstrap.d.ts +17 -0
- package/dist/brains-ops.js +336 -146
- package/dist/cert-bootstrap.d.ts +22 -0
- package/dist/content-repo.d.ts +12 -0
- package/dist/default-user-runner.d.ts +1 -1
- package/dist/deploy.js +46 -46
- package/dist/index.d.ts +4 -0
- package/dist/index.js +336 -146
- package/dist/load-registry.d.ts +19 -3
- package/dist/observed-status.d.ts +12 -0
- package/dist/onboard-user.d.ts +2 -2
- package/dist/origin-ca.d.ts +1 -0
- package/dist/parse-args.d.ts +1 -0
- package/dist/push-secrets.d.ts +9 -0
- package/dist/push-target.d.ts +2 -0
- package/dist/reconcile-all.d.ts +2 -2
- package/dist/reconcile-cohort.d.ts +2 -2
- package/dist/reconcile-lib.d.ts +4 -2
- package/dist/run-command.d.ts +8 -2
- package/dist/run-subprocess.d.ts +6 -0
- package/dist/schema.d.ts +103 -6
- package/dist/secrets-encrypt.d.ts +32 -0
- package/dist/secrets-push.d.ts +2 -5
- package/dist/ssh-key-bootstrap.d.ts +26 -0
- package/dist/user-runner.d.ts +5 -0
- package/package.json +5 -3
- package/templates/rover-pilot/.env.schema +5 -0
- package/templates/rover-pilot/.github/workflows/build.yml +1 -0
- package/templates/rover-pilot/.github/workflows/deploy.yml +67 -15
- package/templates/rover-pilot/.github/workflows/reconcile.yml +16 -2
- package/templates/rover-pilot/README.md +5 -2
- package/templates/rover-pilot/deploy/Dockerfile +22 -7
- package/templates/rover-pilot/deploy/kamal/deploy.yml +3 -2
- package/templates/rover-pilot/deploy/scripts/decrypt-user-secrets.ts +83 -0
- package/templates/rover-pilot/deploy/scripts/provision-server.ts +1 -1
- package/templates/rover-pilot/deploy/scripts/resolve-deploy-handles.ts +3 -1
- package/templates/rover-pilot/deploy/scripts/resolve-user-config.ts +12 -12
- package/templates/rover-pilot/deploy/scripts/sync-content-repo.ts +179 -0
- package/templates/rover-pilot/docs/onboarding-checklist.md +28 -8
- package/templates/rover-pilot/docs/operator-playbook.md +59 -0
- package/templates/rover-pilot/docs/user-onboarding.md +505 -0
- package/templates/rover-pilot/package.json +3 -0
- package/templates/rover-pilot/pilot.yaml +3 -0
- package/templates/rover-pilot/users/alice.yaml +5 -1
- package/dist/user-secret-names.d.ts +0 -6
|
@@ -36,8 +36,11 @@ When a push changes only deploy contract files, CI prints `No affected user conf
|
|
|
36
36
|
## Commands
|
|
37
37
|
|
|
38
38
|
- `brains-ops init <repo>`
|
|
39
|
-
- `brains-ops render <repo>`
|
|
39
|
+
- `brains-ops render <repo>` — regenerates `views/users.md` with live DNS, `/health`, and unauthenticated `/mcp` status checks
|
|
40
40
|
- `brains-ops onboard <repo> <handle>`
|
|
41
|
-
- `brains-ops
|
|
41
|
+
- `brains-ops age-key:bootstrap <repo>`
|
|
42
|
+
- `brains-ops ssh-key:bootstrap <repo>`
|
|
43
|
+
- `brains-ops cert:bootstrap <repo>`
|
|
44
|
+
- `brains-ops secrets:encrypt <repo> <handle>`
|
|
42
45
|
- `brains-ops reconcile-cohort <repo> <cohort>`
|
|
43
46
|
- `brains-ops reconcile-all <repo>`
|
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
ARG BUN_VERSION=1.3.10
|
|
2
|
-
FROM oven/bun:${BUN_VERSION}-slim
|
|
2
|
+
FROM oven/bun:${BUN_VERSION}-slim AS runtime
|
|
3
3
|
|
|
4
|
-
ARG BRAIN_VERSION
|
|
5
4
|
WORKDIR /app
|
|
6
5
|
|
|
7
|
-
RUN
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
7
|
+
curl ca-certificates git \
|
|
8
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
10
9
|
|
|
11
10
|
ENV XDG_DATA_HOME=/data
|
|
12
11
|
ENV XDG_CONFIG_HOME=/config
|
|
13
|
-
RUN mkdir -p /app/
|
|
12
|
+
RUN mkdir -p /app/data /app/cache /app/brain-data && \
|
|
13
|
+
chmod -R 777 /app/data /app/cache /app/brain-data
|
|
14
|
+
|
|
15
|
+
EXPOSE 8080
|
|
16
|
+
|
|
17
|
+
CMD ["./node_modules/.bin/brain", "start"]
|
|
14
18
|
|
|
15
|
-
|
|
19
|
+
# --- standalone: bake full project into image (brain-cli deploy) ---
|
|
20
|
+
FROM runtime AS standalone
|
|
21
|
+
COPY package.json ./package.json
|
|
22
|
+
RUN bun install --production --ignore-scripts
|
|
23
|
+
COPY . .
|
|
24
|
+
|
|
25
|
+
# --- fleet: install published brain at pinned version (ops deploy) ---
|
|
26
|
+
FROM runtime AS fleet
|
|
27
|
+
ARG BRAIN_VERSION
|
|
28
|
+
RUN test -n "$BRAIN_VERSION" \
|
|
29
|
+
&& printf '{"name":"rover-pilot-runtime","private":true}\n' > package.json \
|
|
30
|
+
&& bun add @rizom/brain@$BRAIN_VERSION
|
|
@@ -2,7 +2,7 @@ service: rover
|
|
|
2
2
|
image: <%= ENV['IMAGE_REPOSITORY'] %>
|
|
3
3
|
|
|
4
4
|
servers:
|
|
5
|
-
|
|
5
|
+
web:
|
|
6
6
|
hosts:
|
|
7
7
|
- <%= ENV['SERVER_IP'] %>
|
|
8
8
|
|
|
@@ -12,7 +12,8 @@ proxy:
|
|
|
12
12
|
private_key_pem: PRIVATE_KEY_PEM
|
|
13
13
|
hosts:
|
|
14
14
|
- <%= ENV['BRAIN_DOMAIN'] %>
|
|
15
|
-
|
|
15
|
+
- <%= ENV['PREVIEW_DOMAIN'] %>
|
|
16
|
+
app_port: 8080
|
|
16
17
|
healthcheck:
|
|
17
18
|
path: /health
|
|
18
19
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { Decrypter, armor } from "age-encryption";
|
|
4
|
+
|
|
5
|
+
import { requireEnv, writeGitHubEnv, writeGitHubOutput } from "./helpers";
|
|
6
|
+
|
|
7
|
+
const handle = process.argv[2] ?? requireEnv("HANDLE");
|
|
8
|
+
const ageSecretKey = extractAgeIdentity(requireEnv("AGE_SECRET_KEY"));
|
|
9
|
+
const encryptedPath = `users/${handle}.secrets.yaml.age`;
|
|
10
|
+
|
|
11
|
+
const armored = readFileSync(encryptedPath, "utf8");
|
|
12
|
+
const decoded = armor.decode(armored);
|
|
13
|
+
|
|
14
|
+
const decrypter = new Decrypter();
|
|
15
|
+
decrypter.addIdentity(ageSecretKey);
|
|
16
|
+
|
|
17
|
+
const plaintext = await decrypter.decrypt(decoded, "text");
|
|
18
|
+
const secrets = parseFlatYaml(plaintext);
|
|
19
|
+
const pilot = parseFlatYaml(readFileSync("pilot.yaml", "utf8"));
|
|
20
|
+
|
|
21
|
+
writeGitHubEnv("AI_API_KEY", secrets["aiApiKey"] ?? "");
|
|
22
|
+
writeGitHubEnv("GIT_SYNC_TOKEN", secrets["gitSyncToken"] ?? "");
|
|
23
|
+
writeGitHubEnv("MCP_AUTH_TOKEN", secrets["mcpAuthToken"] ?? "");
|
|
24
|
+
writeGitHubEnv("DISCORD_BOT_TOKEN", secrets["discordBotToken"] ?? "");
|
|
25
|
+
|
|
26
|
+
writeGitHubOutput(
|
|
27
|
+
"shared_ai_api_key_secret_name",
|
|
28
|
+
requireFlatValue(pilot, "aiApiKey", "pilot.yaml"),
|
|
29
|
+
);
|
|
30
|
+
writeGitHubOutput(
|
|
31
|
+
"shared_git_sync_token_secret_name",
|
|
32
|
+
requireFlatValue(pilot, "gitSyncToken", "pilot.yaml"),
|
|
33
|
+
);
|
|
34
|
+
writeGitHubOutput(
|
|
35
|
+
"shared_mcp_auth_token_secret_name",
|
|
36
|
+
requireFlatValue(pilot, "mcpAuthToken", "pilot.yaml"),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
function extractAgeIdentity(contents: string): string {
|
|
40
|
+
const line = contents
|
|
41
|
+
.split(/\r?\n/)
|
|
42
|
+
.map((entry) => entry.trim())
|
|
43
|
+
.find((entry) => entry.startsWith("AGE-SECRET-KEY-"));
|
|
44
|
+
|
|
45
|
+
if (!line) {
|
|
46
|
+
throw new Error("Missing AGE-SECRET-KEY in AGE_SECRET_KEY");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return line;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseFlatYaml(contents: string): Record<string, string> {
|
|
53
|
+
const result: Record<string, string> = {};
|
|
54
|
+
|
|
55
|
+
for (const rawLine of contents.split(/\r?\n/)) {
|
|
56
|
+
const line = rawLine.trim();
|
|
57
|
+
if (!line || line.startsWith("#")) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const match = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
|
|
62
|
+
if (!match) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const [, key, rawValue] = match;
|
|
67
|
+
result[key] = rawValue.replace(/^['"]|['"]$/g, "");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function requireFlatValue(
|
|
74
|
+
values: Record<string, string>,
|
|
75
|
+
key: string,
|
|
76
|
+
label: string,
|
|
77
|
+
): string {
|
|
78
|
+
const value = values[key];
|
|
79
|
+
if (!value) {
|
|
80
|
+
throw new Error(`Missing ${key} in ${label}`);
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
@@ -101,7 +101,7 @@ while (server.status !== "running" || !server.public_net?.ipv4?.ip) {
|
|
|
101
101
|
server = await getServer(server.id);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
const serverIp = server.public_net
|
|
104
|
+
const serverIp = server.public_net.ipv4.ip;
|
|
105
105
|
if (!serverIp) {
|
|
106
106
|
throw new Error(`Server ${server.id} running but has no IPv4 address`);
|
|
107
107
|
}
|
|
@@ -32,7 +32,9 @@ const handles = [
|
|
|
32
32
|
diffOutput
|
|
33
33
|
.split(/\r?\n/)
|
|
34
34
|
.map((path) => {
|
|
35
|
-
const match =
|
|
35
|
+
const match =
|
|
36
|
+
path.match(/^users\/([^/]+)\/(?:\.env|brain\.yaml|content\/.*)$/) ??
|
|
37
|
+
path.match(/^users\/([^/]+)\.secrets\.yaml\.age$/);
|
|
36
38
|
return match?.[1] ?? null;
|
|
37
39
|
})
|
|
38
40
|
.filter((handle): handle is string => handle !== null)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
2
3
|
import { parseEnvFile, requireEnv, writeGitHubOutput } from "./helpers";
|
|
3
4
|
|
|
4
5
|
const handle = requireEnv("HANDLE");
|
|
@@ -12,32 +13,31 @@ const repositoryOwner = repository.split("/")[0] ?? "";
|
|
|
12
13
|
const brainYaml = readFileSync(brainYamlPath, "utf8");
|
|
13
14
|
const domainMatch = brainYaml.match(/^domain:\s*(.+)$/m);
|
|
14
15
|
const brainDomain = domainMatch?.[1]?.trim().replace(/^['"]|['"]$/g, "") ?? "";
|
|
15
|
-
|
|
16
16
|
if (!brainDomain) {
|
|
17
17
|
throw new Error(`Missing domain in ${brainYamlPath}`);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
const zone =
|
|
21
|
+
brainDomain.startsWith(`${handle}.`) && brainDomain.length > handle.length + 1
|
|
22
|
+
? brainDomain.slice(handle.length + 1)
|
|
23
|
+
: "";
|
|
24
|
+
if (!zone) {
|
|
25
|
+
throw new Error(`Could not derive preview domain from ${brainDomain}`);
|
|
26
|
+
}
|
|
27
|
+
const previewDomain = `${handle}-preview.${zone}`;
|
|
28
|
+
|
|
20
29
|
const outputs: Record<string, string> = {
|
|
21
30
|
brain_version: envEntries["BRAIN_VERSION"] ?? "",
|
|
22
|
-
ai_api_key_secret_name: envEntries["AI_API_KEY_SECRET"] ?? "",
|
|
23
|
-
git_sync_token_secret_name: envEntries["GIT_SYNC_TOKEN_SECRET"] ?? "",
|
|
24
|
-
mcp_auth_token_secret_name: envEntries["MCP_AUTH_TOKEN_SECRET"] ?? "",
|
|
25
|
-
discord_bot_token_secret_name: envEntries["DISCORD_BOT_TOKEN_SECRET"] ?? "",
|
|
26
31
|
content_repo: envEntries["CONTENT_REPO"] ?? "",
|
|
27
32
|
brain_domain: brainDomain,
|
|
33
|
+
preview_domain: previewDomain,
|
|
28
34
|
brain_yaml_path: brainYamlPath,
|
|
29
35
|
instance_name: `rover-${handle}`,
|
|
30
36
|
image_repository: `ghcr.io/${repository}`,
|
|
31
37
|
registry_username: repositoryOwner,
|
|
32
38
|
};
|
|
33
39
|
|
|
34
|
-
const required = [
|
|
35
|
-
"brain_version",
|
|
36
|
-
"ai_api_key_secret_name",
|
|
37
|
-
"git_sync_token_secret_name",
|
|
38
|
-
"mcp_auth_token_secret_name",
|
|
39
|
-
"registry_username",
|
|
40
|
-
];
|
|
40
|
+
const required = ["brain_version", "registry_username"];
|
|
41
41
|
for (const key of required) {
|
|
42
42
|
if (!outputs[key]) {
|
|
43
43
|
throw new Error(`Missing ${key} (derived from ${envPath})`);
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { cp, mkdtemp, mkdir, readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { readJsonResponse, requireEnv } from "./helpers";
|
|
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
|
+
const STALE_ANCHOR_PROFILE_MARKERS = [
|
|
56
|
+
"name: Your Name Here",
|
|
57
|
+
"Delete this and write your own",
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
interface EnsureGitHubRepoOptions {
|
|
61
|
+
owner: string;
|
|
62
|
+
repo: string;
|
|
63
|
+
token: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface GitHubRepoResponse {
|
|
67
|
+
clone_url?: string;
|
|
68
|
+
private?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function ensureGitHubRepo(
|
|
72
|
+
options: EnsureGitHubRepoOptions,
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
const headers = {
|
|
75
|
+
Authorization: `Bearer ${options.token}`,
|
|
76
|
+
Accept: "application/vnd.github+json",
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
};
|
|
79
|
+
const repoUrl = `https://api.github.com/repos/${options.owner}/${options.repo}`;
|
|
80
|
+
const repoResponse = await fetch(repoUrl, { headers });
|
|
81
|
+
|
|
82
|
+
if (repoResponse.ok) {
|
|
83
|
+
await readJsonResponse(repoResponse, "GitHub repo lookup");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (repoResponse.status !== 404) {
|
|
88
|
+
const payload = await readJsonResponse(repoResponse, "GitHub repo lookup");
|
|
89
|
+
throw new Error(`GitHub repo lookup failed: ${JSON.stringify(payload)}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const createResponse = await fetch(
|
|
93
|
+
`https://api.github.com/orgs/${options.owner}/repos`,
|
|
94
|
+
{
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers,
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
name: options.repo,
|
|
99
|
+
private: true,
|
|
100
|
+
auto_init: false,
|
|
101
|
+
}),
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
const payload = (await readJsonResponse(
|
|
105
|
+
createResponse,
|
|
106
|
+
"GitHub repo create",
|
|
107
|
+
)) as GitHubRepoResponse;
|
|
108
|
+
|
|
109
|
+
if (!createResponse.ok) {
|
|
110
|
+
throw new Error(`GitHub repo create failed: ${JSON.stringify(payload)}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseRepoSlug(contentRepo: string): { owner: string; repo: string } {
|
|
115
|
+
const [owner, repo] = contentRepo.split("/");
|
|
116
|
+
if (!owner || !repo) {
|
|
117
|
+
throw new Error(`Invalid CONTENT_REPO: ${contentRepo}`);
|
|
118
|
+
}
|
|
119
|
+
return { owner, repo };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildAuthenticatedRemoteUrl(
|
|
123
|
+
owner: string,
|
|
124
|
+
repo: string,
|
|
125
|
+
token: string,
|
|
126
|
+
): string {
|
|
127
|
+
return `https://x-access-token:${encodeURIComponent(token)}@github.com/${owner}/${repo}.git`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function runGit(args: string[]): void {
|
|
131
|
+
execFileSync("git", args, { stdio: "inherit" });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function hasNoStagedChanges(checkoutDir: string): boolean {
|
|
135
|
+
try {
|
|
136
|
+
execFileSync("git", ["-C", checkoutDir, "diff", "--cached", "--quiet"], {
|
|
137
|
+
stdio: "ignore",
|
|
138
|
+
});
|
|
139
|
+
return true;
|
|
140
|
+
} catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function copyMissingFiles(
|
|
146
|
+
sourceDir: string,
|
|
147
|
+
targetDir: string,
|
|
148
|
+
): Promise<number> {
|
|
149
|
+
const entries = await readdir(sourceDir, { withFileTypes: true });
|
|
150
|
+
let copiedFiles = 0;
|
|
151
|
+
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
const sourcePath = join(sourceDir, entry.name);
|
|
154
|
+
const targetPath = join(targetDir, entry.name);
|
|
155
|
+
|
|
156
|
+
if (entry.isDirectory()) {
|
|
157
|
+
await mkdir(targetPath, { recursive: true });
|
|
158
|
+
copiedFiles += await copyMissingFiles(sourcePath, targetPath);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const existing = await readFile(targetPath, "utf8").catch(() => undefined);
|
|
163
|
+
if (existing !== undefined && !isStaleAnchorProfile(existing)) {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
168
|
+
await cp(sourcePath, targetPath, { force: true });
|
|
169
|
+
copiedFiles += existing === (await readFile(sourcePath, "utf8")) ? 0 : 1;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return copiedFiles;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isStaleAnchorProfile(content: string): boolean {
|
|
176
|
+
return STALE_ANCHOR_PROFILE_MARKERS.some((marker) =>
|
|
177
|
+
content.includes(marker),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
# Onboarding Checklist
|
|
2
2
|
|
|
3
3
|
1. Run `bun install` so the repo uses its pinned `@rizom/ops` version.
|
|
4
|
-
2.
|
|
5
|
-
3.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
2. Run `bunx brains-ops age-key:bootstrap <repo> --push-to gh`.
|
|
5
|
+
3. Fill in `pilot.yaml`.
|
|
6
|
+
- keep your pinned `brainVersion`
|
|
7
|
+
- confirm shared selectors for `aiApiKey`, `gitSyncToken`, and `mcpAuthToken`
|
|
8
|
+
- confirm `agePublicKey`
|
|
9
|
+
4. Add or edit `users/<handle>.yaml`.
|
|
10
|
+
- Discord is enabled by default for pilot users
|
|
11
|
+
- if the user should be an anchor there, set `discord.anchorUserId` to their Discord user ID
|
|
12
|
+
5. Add the user to a cohort in `cohorts/*.yaml`.
|
|
13
|
+
6. Run `bunx brains-ops render <repo>`.
|
|
14
|
+
7. Run `bunx brains-ops ssh-key:bootstrap <repo> --push-to gh`.
|
|
15
|
+
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).
|
|
17
|
+
10. Run `bunx brains-ops secrets:encrypt <repo> <handle>`.
|
|
18
|
+
11. Commit and push `users/<handle>.secrets.yaml.age`.
|
|
19
|
+
12. Run `bunx brains-ops onboard <repo> <handle>`.
|
|
20
|
+
13. Verify the deployed rover core contract:
|
|
21
|
+
- `https://<handle>.rizom.ai/health` returns `200`
|
|
22
|
+
- unauthenticated `POST https://<handle>.rizom.ai/mcp` returns `401`
|
|
23
|
+
14. For fleet upgrades, edit `pilot.yaml.brainVersion` and push once; CI rebuilds the shared image tag, refreshes generated user env files, and redeploys affected users.
|
|
24
|
+
15. Hand the Discord setup details to the user.
|
|
25
|
+
16. Hand over the browser defaults:
|
|
26
|
+
- Dashboard: `https://<handle>.rizom.ai/`
|
|
27
|
+
- CMS: `https://<handle>.rizom.ai/cms`
|
|
28
|
+
- GitHub token guidance for CMS access to the user's private content repo
|
|
29
|
+
17. If they need direct client access, also hand over the MCP connection details.
|
|
30
|
+
18. 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.
|
|
31
|
+
19. Send `docs/user-onboarding.md` to the user as the pilot handoff guide.
|
|
@@ -33,6 +33,24 @@ When a push changes only deploy contract files and no generated `users/<handle>/
|
|
|
33
33
|
|
|
34
34
|
They are scaffolded from `@rizom/ops`, then versioned in this repo like any other deploy contract.
|
|
35
35
|
|
|
36
|
+
## Bootstrap flow
|
|
37
|
+
|
|
38
|
+
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.
|
|
39
|
+
|
|
40
|
+
For a new pilot user, the operator bootstrap order is:
|
|
41
|
+
|
|
42
|
+
1. `bunx brains-ops age-key:bootstrap <repo> --push-to gh`
|
|
43
|
+
2. `bunx brains-ops ssh-key:bootstrap <repo> --push-to gh`
|
|
44
|
+
3. `bunx brains-ops cert:bootstrap <repo> --push-to gh`
|
|
45
|
+
4. `bunx brains-ops secrets:encrypt <repo> <handle>`
|
|
46
|
+
5. `bunx brains-ops onboard <repo> <handle>`
|
|
47
|
+
|
|
48
|
+
`age-key:bootstrap` keeps a repo-local canonical age identity under `.brains-ops/age/identity.txt`, writes the matching public recipient to `pilot.yaml.agePublicKey`, and can push the private key to GitHub as `AGE_SECRET_KEY`.
|
|
49
|
+
|
|
50
|
+
The shared cert bootstrap writes local cert artifacts under `.brains-ops/certs/shared/`, which stays repo-local and ignored by git.
|
|
51
|
+
|
|
52
|
+
Preview hosts use the shape `<handle>-preview.rizom.ai`, so one wildcard origin cert for `*.rizom.ai` covers both the primary and preview hosts for every pilot user.
|
|
53
|
+
|
|
36
54
|
## Upgrading operator behavior
|
|
37
55
|
|
|
38
56
|
When `@rizom/ops` changes the scaffolded deploy contract:
|
|
@@ -42,6 +60,47 @@ When `@rizom/ops` changes the scaffolded deploy contract:
|
|
|
42
60
|
3. review the resulting changes to `.env.schema`, `deploy/scripts/`, and workflows in git
|
|
43
61
|
4. commit the updated deploy artifacts together
|
|
44
62
|
|
|
63
|
+
## Rover-core verification notes
|
|
64
|
+
|
|
65
|
+
Rover core is MCP-only. Do not expect the bare domain to serve a website.
|
|
66
|
+
|
|
67
|
+
Use these checks after deploy:
|
|
68
|
+
|
|
69
|
+
- `https://<handle>.rizom.ai/health` should return `200`
|
|
70
|
+
- unauthenticated `POST https://<handle>.rizom.ai/mcp` should return `401 Unauthorized: Bearer token required`
|
|
71
|
+
- a bare `GET /` may also return `401`; that is expected for rover core and does not indicate a bad deploy
|
|
72
|
+
|
|
73
|
+
## Discord bot token checklist
|
|
74
|
+
|
|
75
|
+
Use this when enabling Discord for a pilot user.
|
|
76
|
+
|
|
77
|
+
1. Pick the user handle (for example `smoke`).
|
|
78
|
+
2. Open the Discord Developer Portal.
|
|
79
|
+
3. Create a **new application** for that user's rover.
|
|
80
|
+
4. Add a **Bot** to the application.
|
|
81
|
+
5. Copy the bot token.
|
|
82
|
+
6. Put that value in `.env` or `.env.local` in this repo as `DISCORD_BOT_TOKEN=...` while onboarding that user.
|
|
83
|
+
7. Keep `discord.enabled: true` in `users/<handle>.yaml` unless you explicitly want to disable the primary pilot interface.
|
|
84
|
+
8. Encrypt the current per-user secret payload:
|
|
85
|
+
- `bunx brains-ops secrets:encrypt . <handle>`
|
|
86
|
+
9. Reconcile/deploy the user or cohort:
|
|
87
|
+
|
|
88
|
+
- `bunx brains-ops onboard . <handle>`
|
|
89
|
+
- or `bunx brains-ops reconcile-cohort . <cohort>`
|
|
90
|
+
|
|
91
|
+
11. In the Discord Developer Portal, generate an install URL and invite the bot to the right server.
|
|
92
|
+
12. Send a test message in Discord and confirm the rover responds.
|
|
93
|
+
|
|
94
|
+
Notes:
|
|
95
|
+
|
|
96
|
+
- Use **one bot token per user/rover**.
|
|
97
|
+
- Do not reuse the same Discord bot token across multiple pilot users.
|
|
98
|
+
- Discord is the default pilot interface moving forward.
|
|
99
|
+
- 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 is optional and mainly for direct client access or specific testing workflows.
|
|
101
|
+
- When explaining the content workflow, describe it first as a normal **git repo** of **markdown/text files**.
|
|
102
|
+
- Position **Obsidian** as optional: it is just one possible editor for those same files, not the default requirement.
|
|
103
|
+
|
|
45
104
|
## Recovery notes
|
|
46
105
|
|
|
47
106
|
Document known failure modes, recovery steps, and operator notes here.
|