create-op-node 0.7.0 → 0.9.0
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 +41 -0
- package/dist/cli.js +205 -13
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -202,6 +202,11 @@ npx create-op-node reset --region us-ca --dry-run
|
|
|
202
202
|
|
|
203
203
|
# Nuke from orbit: containers + volumes + LaunchAgent + ghcr credentials.
|
|
204
204
|
npx create-op-node reset --region us-ca --wipe-data
|
|
205
|
+
|
|
206
|
+
# Even more: ALSO drop all docker images (forces re-pull on next bootstrap).
|
|
207
|
+
# Use this for the "wipe everything and start over" iteration loop while
|
|
208
|
+
# debugging a fresh bootstrap. Implies --wipe-data.
|
|
209
|
+
npx create-op-node reset --region us-ca --wipe-images
|
|
205
210
|
```
|
|
206
211
|
|
|
207
212
|
## Verifying a live node
|
|
@@ -552,6 +557,42 @@ Releases are automated via [release-please](https://github.com/googleapis/releas
|
|
|
552
557
|
|
|
553
558
|
No manual version bumps, no manual tagging. To trigger a release with no functional changes (re-publishing after a CI fix, etc.), push a commit with `chore: trigger release` — release-please will skip it but still update the PR.
|
|
554
559
|
|
|
560
|
+
#### One-time PAT setup
|
|
561
|
+
|
|
562
|
+
GitHub deliberately doesn't fire workflows on branches or tags pushed by
|
|
563
|
+
`GITHUB_TOKEN`. Without a PAT, two things bite every release:
|
|
564
|
+
|
|
565
|
+
- The release PR opens but its CI run never starts (sits in
|
|
566
|
+
*Expected — Waiting for status to be reported*). You can't merge.
|
|
567
|
+
- After you eventually merge, `publish.yml`'s tag-push trigger doesn't
|
|
568
|
+
fire either, so npm never gets the new version.
|
|
569
|
+
|
|
570
|
+
Both go away when release-please uses a PAT instead. One-time setup:
|
|
571
|
+
|
|
572
|
+
1. **Create a fine-grained PAT** at
|
|
573
|
+
[github.com/settings/personal-access-tokens/new](https://github.com/settings/personal-access-tokens/new):
|
|
574
|
+
- *Resource owner*: **OpusPopuli** (not your personal account —
|
|
575
|
+
fine-grained PATs are scoped at creation and can't be moved)
|
|
576
|
+
- *Expiration*: your call (90 days for safety; you'll be reminded
|
|
577
|
+
when it's near expiry)
|
|
578
|
+
- *Repository access*: **Only select repositories** →
|
|
579
|
+
`create-op-node`
|
|
580
|
+
- *Repository permissions*:
|
|
581
|
+
- **Contents**: Read and write
|
|
582
|
+
- **Pull requests**: Read and write
|
|
583
|
+
- **Workflows**: Read and write *(release-please updates the
|
|
584
|
+
version line in `release-please.yml`'s manifest output;
|
|
585
|
+
without this permission the action fails to write)*
|
|
586
|
+
2. **Add it as a repo secret**: repo → Settings → Secrets and
|
|
587
|
+
variables → Actions → New repository secret →
|
|
588
|
+
`RELEASE_PLEASE_TOKEN`, paste the PAT value.
|
|
589
|
+
3. **Done.** Next release-please run uses the PAT, CI runs normally on
|
|
590
|
+
the release PR, and `publish.yml` fires automatically on the tag.
|
|
591
|
+
|
|
592
|
+
The workflow falls back to `GITHUB_TOKEN` when the secret isn't
|
|
593
|
+
present, so it doesn't break in repos that haven't done the PAT setup
|
|
594
|
+
yet — it just goes back to needing the manual workarounds.
|
|
595
|
+
|
|
555
596
|
## License
|
|
556
597
|
|
|
557
598
|
[AGPL-3.0-or-later](./LICENSE). The Opus Populi platform code is AGPL-3.0 + dual commercial; this CLI inherits the AGPL-3.0 terms.
|
package/dist/cli.js
CHANGED
|
@@ -4,7 +4,7 @@ import pc2 from 'picocolors';
|
|
|
4
4
|
import * as p3 from '@clack/prompts';
|
|
5
5
|
import { execa } from 'execa';
|
|
6
6
|
import { Octokit } from '@octokit/rest';
|
|
7
|
-
import { randomBytes } from 'crypto';
|
|
7
|
+
import { randomBytes, createHmac } from 'crypto';
|
|
8
8
|
import { join, resolve, dirname } from 'path';
|
|
9
9
|
import { mkdir, writeFile, access, rm, chmod } from 'fs/promises';
|
|
10
10
|
import { homedir } from 'os';
|
|
@@ -230,9 +230,17 @@ var SERVICE_PREFIX = "org.opuspopuli";
|
|
|
230
230
|
function serviceFor(region) {
|
|
231
231
|
return `${SERVICE_PREFIX}.${region}`;
|
|
232
232
|
}
|
|
233
|
+
var FRIENDLY = {
|
|
234
|
+
"pgsodium-root-key": "pgsodium root key",
|
|
235
|
+
"tunnel-token": "Cloudflare Tunnel token",
|
|
236
|
+
"postgres-password": "Postgres password",
|
|
237
|
+
"jwt-secret": "JWT signing secret",
|
|
238
|
+
"supabase-anon-key": "Supabase anon key",
|
|
239
|
+
"supabase-service-role-key": "Supabase service role key",
|
|
240
|
+
"dashboard-password": "Supabase Studio dashboard password"
|
|
241
|
+
};
|
|
233
242
|
function labelFor(coords) {
|
|
234
|
-
|
|
235
|
-
return `Opus Populi (${coords.region}) \u2014 ${friendly}`;
|
|
243
|
+
return `Opus Populi (${coords.region}) \u2014 ${FRIENDLY[coords.account]}`;
|
|
236
244
|
}
|
|
237
245
|
async function detectKeychain() {
|
|
238
246
|
const res = await safeExeca("security", ["-h"]);
|
|
@@ -430,6 +438,50 @@ async function openPullRequest(input) {
|
|
|
430
438
|
function generatePgsodiumRootKey() {
|
|
431
439
|
return randomBytes(32).toString("hex");
|
|
432
440
|
}
|
|
441
|
+
function base64url(input) {
|
|
442
|
+
const buf = typeof input === "string" ? Buffer.from(input) : input;
|
|
443
|
+
return buf.toString("base64").replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
444
|
+
}
|
|
445
|
+
function generatePostgresPassword() {
|
|
446
|
+
return base64url(randomBytes(32));
|
|
447
|
+
}
|
|
448
|
+
function generateDashboardPassword() {
|
|
449
|
+
return base64url(randomBytes(24));
|
|
450
|
+
}
|
|
451
|
+
function generateJwtSecret() {
|
|
452
|
+
return randomBytes(48).toString("base64");
|
|
453
|
+
}
|
|
454
|
+
function verifySupabaseJwt(token, secret) {
|
|
455
|
+
const parts = token.split(".");
|
|
456
|
+
if (parts.length !== 3) return false;
|
|
457
|
+
const [h, p7, sig] = parts;
|
|
458
|
+
const expected = base64url(createHmac("sha256", secret).update(`${h}.${p7}`).digest());
|
|
459
|
+
if (sig.length !== expected.length) return false;
|
|
460
|
+
let mismatch = 0;
|
|
461
|
+
for (let i = 0; i < sig.length; i++) {
|
|
462
|
+
mismatch |= sig.charCodeAt(i) ^ expected.charCodeAt(i);
|
|
463
|
+
}
|
|
464
|
+
return mismatch === 0;
|
|
465
|
+
}
|
|
466
|
+
function signSupabaseJwt(input) {
|
|
467
|
+
const iat = input.issuedAtSeconds ?? 17e8;
|
|
468
|
+
const ttl = input.ttlSeconds ?? 10 * 365 * 24 * 60 * 60;
|
|
469
|
+
const issuer = input.issuer ?? "supabase";
|
|
470
|
+
const header = { alg: "HS256", typ: "JWT" };
|
|
471
|
+
const payload = {
|
|
472
|
+
role: input.role,
|
|
473
|
+
iss: issuer,
|
|
474
|
+
iat,
|
|
475
|
+
exp: iat + ttl
|
|
476
|
+
};
|
|
477
|
+
const encodedHeader = base64url(JSON.stringify(header));
|
|
478
|
+
const encodedPayload = base64url(JSON.stringify(payload));
|
|
479
|
+
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
|
480
|
+
const signature = base64url(
|
|
481
|
+
createHmac("sha256", input.secret).update(signingInput).digest()
|
|
482
|
+
);
|
|
483
|
+
return `${signingInput}.${signature}`;
|
|
484
|
+
}
|
|
433
485
|
function renderProdTfvars(input) {
|
|
434
486
|
const project = input.project ?? "opuspopuli";
|
|
435
487
|
const api = input.apiSubdomain ?? "api";
|
|
@@ -1042,6 +1094,9 @@ async function waitForApplyAndFetchTunnelToken(input) {
|
|
|
1042
1094
|
var PGSODIUM_KEY_RE = /^[a-f0-9]{64}$/;
|
|
1043
1095
|
var TUNNEL_TOKEN_RE = /^[A-Za-z0-9_\-.=]+$/;
|
|
1044
1096
|
var SAFE_PATH_RE = /^[A-Za-z0-9_\-./ ]+$/;
|
|
1097
|
+
var SAFE_LAUNCHCTL_VALUE_RE = /^[A-Za-z0-9+/=._-]+$/;
|
|
1098
|
+
var URL_SAFE_PASSWORD_RE = /^[A-Za-z0-9_-]+$/;
|
|
1099
|
+
var SAFE_URL_RE = /^[A-Za-z0-9:/_.\-]+$/;
|
|
1045
1100
|
var VERIFY_NETWORK_TIMEOUT_MS = 1e4;
|
|
1046
1101
|
var BODY_PREVIEW_MAX = 200;
|
|
1047
1102
|
|
|
@@ -1151,6 +1206,13 @@ async function enableAutoRestartOnPowerFailure() {
|
|
|
1151
1206
|
async function disableDiskSleep() {
|
|
1152
1207
|
return runSudoPmset(["disksleep", "0"]);
|
|
1153
1208
|
}
|
|
1209
|
+
async function detectUnifiedMemoryGB() {
|
|
1210
|
+
const res = await safeExeca("sysctl", ["-n", "hw.memsize"]);
|
|
1211
|
+
if (res === null || res.exitCode !== 0) return null;
|
|
1212
|
+
const bytes = Number.parseInt(res.stdout.trim(), 10);
|
|
1213
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return null;
|
|
1214
|
+
return Math.round(bytes / 2 ** 30);
|
|
1215
|
+
}
|
|
1154
1216
|
async function runSudoPmset(args) {
|
|
1155
1217
|
const res = await safeExeca("sudo", ["pmset", "-a", ...args]);
|
|
1156
1218
|
if (res === null) {
|
|
@@ -1209,11 +1271,36 @@ function renderLaunchAgentPlist(input) {
|
|
|
1209
1271
|
`keyFilePath ${JSON.stringify(input.keyFilePath)} contains characters not allowed in a launchd path interpolation`
|
|
1210
1272
|
);
|
|
1211
1273
|
}
|
|
1274
|
+
const supabaseFields = [
|
|
1275
|
+
["postgresPassword", input.postgresPassword],
|
|
1276
|
+
["jwtSecret", input.jwtSecret],
|
|
1277
|
+
["supabaseAnonKey", input.supabaseAnonKey],
|
|
1278
|
+
["supabaseServiceRoleKey", input.supabaseServiceRoleKey],
|
|
1279
|
+
["dashboardPassword", input.dashboardPassword]
|
|
1280
|
+
];
|
|
1281
|
+
for (const [name, value] of supabaseFields) {
|
|
1282
|
+
if (value !== void 0 && !SAFE_LAUNCHCTL_VALUE_RE.test(value)) {
|
|
1283
|
+
throw new Error(
|
|
1284
|
+
`${String(name)} contains characters not allowed in a launchd setenv value`
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
if (input.supabaseUrl !== void 0 && !SAFE_URL_RE.test(input.supabaseUrl)) {
|
|
1289
|
+
throw new Error(
|
|
1290
|
+
`supabaseUrl ${JSON.stringify(input.supabaseUrl)} contains characters not allowed in a launchd setenv value`
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1212
1293
|
const setenvLines = [
|
|
1213
1294
|
`launchctl setenv PGSODIUM_ROOT_KEY "$(cat ${input.keyFilePath})"`,
|
|
1214
1295
|
...input.tunnelToken !== void 0 ? [`launchctl setenv TUNNEL_TOKEN "${input.tunnelToken}"`] : [],
|
|
1215
1296
|
...input.llmModel !== void 0 ? [`launchctl setenv LLM_MODEL "${input.llmModel}"`] : [],
|
|
1216
|
-
...input.embeddingModel !== void 0 ? [`launchctl setenv EMBEDDINGS_MODEL "${input.embeddingModel}"`] : []
|
|
1297
|
+
...input.embeddingModel !== void 0 ? [`launchctl setenv EMBEDDINGS_MODEL "${input.embeddingModel}"`] : [],
|
|
1298
|
+
...input.postgresPassword !== void 0 ? [`launchctl setenv POSTGRES_PASSWORD "${input.postgresPassword}"`] : [],
|
|
1299
|
+
...input.jwtSecret !== void 0 ? [`launchctl setenv JWT_SECRET "${input.jwtSecret}"`] : [],
|
|
1300
|
+
...input.supabaseAnonKey !== void 0 ? [`launchctl setenv SUPABASE_ANON_KEY "${input.supabaseAnonKey}"`] : [],
|
|
1301
|
+
...input.supabaseServiceRoleKey !== void 0 ? [`launchctl setenv SUPABASE_SERVICE_ROLE_KEY "${input.supabaseServiceRoleKey}"`] : [],
|
|
1302
|
+
...input.dashboardPassword !== void 0 ? [`launchctl setenv DASHBOARD_PASSWORD "${input.dashboardPassword}"`] : [],
|
|
1303
|
+
...input.supabaseUrl !== void 0 ? [`launchctl setenv SUPABASE_URL "${input.supabaseUrl}"`] : []
|
|
1217
1304
|
];
|
|
1218
1305
|
const command = setenvLines.join("; ");
|
|
1219
1306
|
return [
|
|
@@ -1270,7 +1357,13 @@ async function setupLaunchAgent(input) {
|
|
|
1270
1357
|
keyFilePath: paths.keyFile,
|
|
1271
1358
|
...input.tunnelToken !== void 0 ? { tunnelToken: input.tunnelToken } : {},
|
|
1272
1359
|
...input.llmModel !== void 0 ? { llmModel: input.llmModel } : {},
|
|
1273
|
-
...input.embeddingModel !== void 0 ? { embeddingModel: input.embeddingModel } : {}
|
|
1360
|
+
...input.embeddingModel !== void 0 ? { embeddingModel: input.embeddingModel } : {},
|
|
1361
|
+
...input.postgresPassword !== void 0 ? { postgresPassword: input.postgresPassword } : {},
|
|
1362
|
+
...input.jwtSecret !== void 0 ? { jwtSecret: input.jwtSecret } : {},
|
|
1363
|
+
...input.supabaseAnonKey !== void 0 ? { supabaseAnonKey: input.supabaseAnonKey } : {},
|
|
1364
|
+
...input.supabaseServiceRoleKey !== void 0 ? { supabaseServiceRoleKey: input.supabaseServiceRoleKey } : {},
|
|
1365
|
+
...input.dashboardPassword !== void 0 ? { dashboardPassword: input.dashboardPassword } : {},
|
|
1366
|
+
...input.supabaseUrl !== void 0 ? { supabaseUrl: input.supabaseUrl } : {}
|
|
1274
1367
|
});
|
|
1275
1368
|
} catch (err) {
|
|
1276
1369
|
return { ok: false, paths, step: "plist", reason: err.message };
|
|
@@ -1372,8 +1465,14 @@ async function composeDown(opts) {
|
|
|
1372
1465
|
const flags = ["down"];
|
|
1373
1466
|
if (opts.wipeVolumes) flags.push("-v");
|
|
1374
1467
|
if (opts.removeOrphans) flags.push("--remove-orphans");
|
|
1468
|
+
if (opts.removeImages) flags.push("--rmi", opts.removeImages);
|
|
1469
|
+
const label = [
|
|
1470
|
+
"compose down",
|
|
1471
|
+
opts.wipeVolumes ? "-v" : "",
|
|
1472
|
+
opts.removeImages ? `--rmi ${opts.removeImages}` : ""
|
|
1473
|
+
].filter(Boolean).join(" ");
|
|
1375
1474
|
const res = await safeExeca("docker", composeArgs(opts, flags));
|
|
1376
|
-
return result(res,
|
|
1475
|
+
return result(res, label);
|
|
1377
1476
|
}
|
|
1378
1477
|
async function dockerLogout(registry = GHCR_REGISTRY) {
|
|
1379
1478
|
const res = await safeExeca("docker", ["logout", registry]);
|
|
@@ -1672,6 +1771,11 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1672
1771
|
"--embedding-model <model>",
|
|
1673
1772
|
`Ollama embedding model. Default: ${DEFAULT_EMBEDDING_MODEL}. Only takes effect when the knowledge service runs with EMBEDDINGS_PROVIDER=ollama (otherwise embeddings are computed in-process via xenova).`
|
|
1674
1773
|
)
|
|
1774
|
+
).addOption(
|
|
1775
|
+
new Option(
|
|
1776
|
+
"--supabase-url <url>",
|
|
1777
|
+
"Public-facing Supabase URL (what browsers + microservices use). Default: `http://localhost:8000` in --local-only mode, otherwise `https://supabase.<domain>` (which the operator can override)."
|
|
1778
|
+
)
|
|
1675
1779
|
).addOption(new Option("--skip-stack", "Stop before `docker compose pull && up`").default(false)).addOption(
|
|
1676
1780
|
new Option(
|
|
1677
1781
|
"--local-only",
|
|
@@ -1825,6 +1929,65 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1825
1929
|
placeholder: "JWT-style base64url string from `terraform output tunnel_token`",
|
|
1826
1930
|
validate: (v) => TUNNEL_TOKEN_RE.test(v) ? void 0 : "tunnel token must be a base64-url JWT"
|
|
1827
1931
|
});
|
|
1932
|
+
const postgresPassword = await loadSecret({
|
|
1933
|
+
region,
|
|
1934
|
+
account: "postgres-password",
|
|
1935
|
+
label: "Postgres password",
|
|
1936
|
+
// URL-safe alphabet required — value lands in postgres:// URIs where
|
|
1937
|
+
// `+`/`/`/`=` cause parser ambiguity in gotrue/postgrest/storage.
|
|
1938
|
+
validate: (v) => URL_SAFE_PASSWORD_RE.test(v) ? void 0 : "must be base64url chars only (no + / =)",
|
|
1939
|
+
generate: generatePostgresPassword
|
|
1940
|
+
});
|
|
1941
|
+
const jwtSecret = await loadSecret({
|
|
1942
|
+
region,
|
|
1943
|
+
account: "jwt-secret",
|
|
1944
|
+
label: "JWT signing secret",
|
|
1945
|
+
validate: (v) => SAFE_LAUNCHCTL_VALUE_RE.test(v) && v.length >= 32 ? void 0 : "must be \u226532 base64 characters",
|
|
1946
|
+
generate: generateJwtSecret
|
|
1947
|
+
});
|
|
1948
|
+
const supabaseAnonKey = await loadSecret({
|
|
1949
|
+
region,
|
|
1950
|
+
account: "supabase-anon-key",
|
|
1951
|
+
label: "Supabase anon key",
|
|
1952
|
+
validate: (v) => SAFE_LAUNCHCTL_VALUE_RE.test(v) && verifySupabaseJwt(v, jwtSecret) ? void 0 : "must be a base64url JWT signed by the current JWT_SECRET",
|
|
1953
|
+
generate: () => signSupabaseJwt({ role: "anon", secret: jwtSecret })
|
|
1954
|
+
});
|
|
1955
|
+
const supabaseServiceRoleKey = await loadSecret({
|
|
1956
|
+
region,
|
|
1957
|
+
account: "supabase-service-role-key",
|
|
1958
|
+
label: "Supabase service role key",
|
|
1959
|
+
validate: (v) => SAFE_LAUNCHCTL_VALUE_RE.test(v) && verifySupabaseJwt(v, jwtSecret) ? void 0 : "must be a base64url JWT signed by the current JWT_SECRET",
|
|
1960
|
+
generate: () => signSupabaseJwt({ role: "service_role", secret: jwtSecret })
|
|
1961
|
+
});
|
|
1962
|
+
const dashboardPassword = await loadSecret({
|
|
1963
|
+
region,
|
|
1964
|
+
account: "dashboard-password",
|
|
1965
|
+
label: "Supabase Studio dashboard password",
|
|
1966
|
+
// URL-safe alphabet required — value lands in basic-auth headers via
|
|
1967
|
+
// kong's declarative config. Avoid `+`/`/`/`=` for the same reasons.
|
|
1968
|
+
validate: (v) => URL_SAFE_PASSWORD_RE.test(v) ? void 0 : "must be base64url chars only (no + / =)",
|
|
1969
|
+
generate: generateDashboardPassword
|
|
1970
|
+
});
|
|
1971
|
+
let supabaseUrl;
|
|
1972
|
+
if (opts.supabaseUrl) {
|
|
1973
|
+
supabaseUrl = opts.supabaseUrl;
|
|
1974
|
+
} else if (opts.localOnly) {
|
|
1975
|
+
supabaseUrl = "http://localhost:8000";
|
|
1976
|
+
} else {
|
|
1977
|
+
supabaseUrl = unwrap(
|
|
1978
|
+
await p3.text({
|
|
1979
|
+
message: "Public-facing Supabase URL (what browsers + microservices use to reach kong)?",
|
|
1980
|
+
placeholder: "https://supabase.civicfeed.tx",
|
|
1981
|
+
validate: (v) => !v ? "required" : SAFE_URL_RE.test(v) ? void 0 : "contains characters outside the allowed URL set"
|
|
1982
|
+
})
|
|
1983
|
+
);
|
|
1984
|
+
}
|
|
1985
|
+
if (!SAFE_URL_RE.test(supabaseUrl)) {
|
|
1986
|
+
p3.cancel(
|
|
1987
|
+
`--supabase-url ${JSON.stringify(supabaseUrl)} contains characters outside the allowed URL set`
|
|
1988
|
+
);
|
|
1989
|
+
process.exit(1);
|
|
1990
|
+
}
|
|
1828
1991
|
if (!opts.skipLaunchAgent) {
|
|
1829
1992
|
const laSpin = p3.spinner();
|
|
1830
1993
|
laSpin.start("Writing pgsodium key file + LaunchAgent plist\u2026");
|
|
@@ -1832,7 +1995,13 @@ var bootstrapCommand = new Command("bootstrap").description(
|
|
|
1832
1995
|
pgsodiumKey,
|
|
1833
1996
|
...tunnelToken !== void 0 ? { tunnelToken } : {},
|
|
1834
1997
|
llmModel,
|
|
1835
|
-
embeddingModel
|
|
1998
|
+
embeddingModel,
|
|
1999
|
+
postgresPassword,
|
|
2000
|
+
jwtSecret,
|
|
2001
|
+
supabaseAnonKey,
|
|
2002
|
+
supabaseServiceRoleKey,
|
|
2003
|
+
dashboardPassword,
|
|
2004
|
+
supabaseUrl
|
|
1836
2005
|
});
|
|
1837
2006
|
if (!la.ok) {
|
|
1838
2007
|
laSpin.stop(pc2.red(`\u2717 LaunchAgent step ${la.step} failed.`));
|
|
@@ -2023,12 +2192,22 @@ var LLM_MODEL_CHOICES = [
|
|
|
2023
2192
|
}
|
|
2024
2193
|
];
|
|
2025
2194
|
var OTHER_SENTINEL = "__OTHER__";
|
|
2195
|
+
function recommendLlmModel(ramGB) {
|
|
2196
|
+
if (ramGB === null) return null;
|
|
2197
|
+
if (ramGB >= 96) return "qwen2.5:72b";
|
|
2198
|
+
if (ramGB >= 48) return "qwen2.5:32b";
|
|
2199
|
+
return "qwen3.5:9b";
|
|
2200
|
+
}
|
|
2026
2201
|
async function selectLlmModel(opts) {
|
|
2027
2202
|
if (opts.yes) return DEFAULT_LLM_MODEL;
|
|
2203
|
+
const ramGB = await detectUnifiedMemoryGB();
|
|
2204
|
+
const recommended = recommendLlmModel(ramGB) ?? "qwen2.5:72b";
|
|
2205
|
+
const ramNote = ramGB !== null ? `Detected ${ramGB} GB unified memory \u2014 pre-selecting ${recommended}.` : `Couldn't detect Studio memory \u2014 defaulting to ${recommended} (override if your config differs).`;
|
|
2206
|
+
p3.note(ramNote, "Hardware");
|
|
2028
2207
|
const choice = unwrap(
|
|
2029
2208
|
await p3.select({
|
|
2030
2209
|
message: "Choose the LLM model to pull + run",
|
|
2031
|
-
initialValue:
|
|
2210
|
+
initialValue: recommended,
|
|
2032
2211
|
options: [
|
|
2033
2212
|
...LLM_MODEL_CHOICES.map((c) => ({ value: c.value, label: c.label, hint: c.hint })),
|
|
2034
2213
|
{
|
|
@@ -2220,7 +2399,7 @@ async function runReset(input, deps = DEFAULT_DEPS) {
|
|
|
2220
2399
|
push({
|
|
2221
2400
|
name: RESET_PHASES.STOP_STACK,
|
|
2222
2401
|
status: "dry-run",
|
|
2223
|
-
detail: `would run: docker compose -f ${input.stack.composeFiles.join(" -f ")} down${input.stack.wipeVolumes ? " -v" : ""}${input.stack.removeOrphans ? " --remove-orphans" : ""}`
|
|
2402
|
+
detail: `would run: docker compose -f ${input.stack.composeFiles.join(" -f ")} down${input.stack.wipeVolumes ? " -v" : ""}${input.stack.removeOrphans ? " --remove-orphans" : ""}${input.stack.wipeImages ? " --rmi all" : ""}`
|
|
2224
2403
|
});
|
|
2225
2404
|
} else {
|
|
2226
2405
|
const result2 = await deps.composeDown({
|
|
@@ -2228,13 +2407,18 @@ async function runReset(input, deps = DEFAULT_DEPS) {
|
|
|
2228
2407
|
cwd: input.stack.repoPath,
|
|
2229
2408
|
...input.stack.envFile ? { envFile: input.stack.envFile } : {},
|
|
2230
2409
|
wipeVolumes: input.stack.wipeVolumes,
|
|
2231
|
-
removeOrphans: input.stack.removeOrphans
|
|
2410
|
+
removeOrphans: input.stack.removeOrphans,
|
|
2411
|
+
...input.stack.wipeImages ? { removeImages: "all" } : {}
|
|
2232
2412
|
});
|
|
2233
2413
|
if (result2.ok) {
|
|
2414
|
+
const bits = [
|
|
2415
|
+
input.stack.wipeVolumes ? "volumes destroyed" : "containers stopped, volumes preserved",
|
|
2416
|
+
input.stack.wipeImages ? "images removed (next bootstrap will re-pull)" : null
|
|
2417
|
+
].filter(Boolean);
|
|
2234
2418
|
push({
|
|
2235
2419
|
name: RESET_PHASES.STOP_STACK,
|
|
2236
2420
|
status: "ok",
|
|
2237
|
-
detail:
|
|
2421
|
+
detail: bits.join("; ")
|
|
2238
2422
|
});
|
|
2239
2423
|
} else {
|
|
2240
2424
|
push({ name: RESET_PHASES.STOP_STACK, status: "fail", detail: result2.reason ?? "unknown failure" });
|
|
@@ -2321,6 +2505,11 @@ var resetCommand = new Command("reset").description(
|
|
|
2321
2505
|
"--wipe-data",
|
|
2322
2506
|
"DESTROYS docker volumes (adds -v to `compose down`). Requires retyping the region label as confirmation. Default off \u2014 volumes preserved."
|
|
2323
2507
|
).default(false)
|
|
2508
|
+
).addOption(
|
|
2509
|
+
new Option(
|
|
2510
|
+
"--wipe-images",
|
|
2511
|
+
'ALSO remove all docker images referenced by the compose file (adds --rmi all). Forces a fresh pull on next bootstrap. Implies --wipe-data semantics for the iteration loop ("wipe everything and start over"). Default off.'
|
|
2512
|
+
).default(false)
|
|
2324
2513
|
).addOption(
|
|
2325
2514
|
new Option(
|
|
2326
2515
|
"--no-remove-orphans",
|
|
@@ -2333,7 +2522,8 @@ var resetCommand = new Command("reset").description(
|
|
|
2333
2522
|
).default(false)
|
|
2334
2523
|
).addOption(new Option("--skip-docker-logout", "Don't run `docker logout`").default(false)).addOption(new Option("--registry <registry>", "Registry to log out of").default(GHCR_REGISTRY)).addOption(new Option("--dry-run", "Show what would happen without acting").default(false)).addOption(new Option("-y, --yes", "Skip non-destructive confirmations (wipe still confirms)").default(false)).action(async (opts) => {
|
|
2335
2524
|
p3.intro(pc2.bgCyan(pc2.black(" create-op-node reset ")));
|
|
2336
|
-
const
|
|
2525
|
+
const wipeImages = opts.wipeImages ?? false;
|
|
2526
|
+
const wipeData = (opts.wipeData ?? false) || wipeImages;
|
|
2337
2527
|
const removeOrphans = opts.removeOrphans ?? true;
|
|
2338
2528
|
const region = opts.region ? opts.region : unwrap(
|
|
2339
2529
|
await p3.text({
|
|
@@ -2399,6 +2589,7 @@ var resetCommand = new Command("reset").description(
|
|
|
2399
2589
|
`pgsodium key file: ${keyFileExists ? pc2.cyan(launchAgentPaths.keyFile) : pc2.dim("not present")}`,
|
|
2400
2590
|
`Registry to log out: ${pc2.cyan(opts.registry ?? GHCR_REGISTRY)}`,
|
|
2401
2591
|
`Volume policy: ${wipeData ? pc2.red("WIPE") : pc2.green("preserve (default)")}`,
|
|
2592
|
+
`Image policy: ${wipeImages ? pc2.red("WIPE (re-pull on next bootstrap)") : pc2.green("keep (default)")}`,
|
|
2402
2593
|
`Dry run: ${opts.dryRun ? pc2.yellow("yes") : pc2.dim("no")}`
|
|
2403
2594
|
].join("\n"),
|
|
2404
2595
|
"Snapshot"
|
|
@@ -2426,6 +2617,7 @@ var resetCommand = new Command("reset").description(
|
|
|
2426
2617
|
repoPath,
|
|
2427
2618
|
composeFiles: resolveComposeFiles(repoPath, opts.composeFile),
|
|
2428
2619
|
wipeVolumes: wipeData,
|
|
2620
|
+
wipeImages,
|
|
2429
2621
|
removeOrphans,
|
|
2430
2622
|
...opts.envFile ? { envFile: opts.envFile } : {}
|
|
2431
2623
|
} : void 0;
|
|
@@ -3900,7 +4092,7 @@ async function looksLikeRegionsRepo(dir) {
|
|
|
3900
4092
|
}
|
|
3901
4093
|
|
|
3902
4094
|
// src/cli.ts
|
|
3903
|
-
var VERSION = "0.
|
|
4095
|
+
var VERSION = "0.9.0";
|
|
3904
4096
|
var program = new Command();
|
|
3905
4097
|
program.name("create-op-node").description(
|
|
3906
4098
|
"Interactive bootstrap for an Opus Populi federation node.\nCloudflare infrastructure \u2192 Mac Studio \u2192 live public API."
|