@or3/cloud 0.1.15 → 0.1.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -2,12 +2,12 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { createHash, randomBytes } from "node:crypto";
5
- import { execFile as execFileCallback } from "node:child_process";
5
+ import { execFile as execFileCallback, spawn } from "node:child_process";
6
6
  import { createServer } from "node:net";
7
7
  import { lookup } from "node:dns/promises";
8
8
  import readline from "node:readline/promises";
9
9
  import { stdin as input, stdout as output } from "node:process";
10
- import { existsSync } from "node:fs";
10
+ import { createReadStream, createWriteStream, existsSync } from "node:fs";
11
11
  import {
12
12
  chmod,
13
13
  copyFile,
@@ -17,22 +17,31 @@ import {
17
17
  rename,
18
18
  rm,
19
19
  stat,
20
+ statfs,
20
21
  writeFile
21
22
  } from "node:fs/promises";
23
+ import { Writable } from "node:stream";
24
+ import { pipeline } from "node:stream/promises";
22
25
  import { promisify } from "node:util";
23
- import { dirname, basename, isAbsolute, join, resolve } from "node:path";
26
+ import { dirname, basename, isAbsolute, join, resolve, sep } from "node:path";
24
27
  import { fileURLToPath } from "node:url";
28
+ import { createGunzip } from "node:zlib";
25
29
  var execFile = promisify(execFileCallback);
26
- var PACKAGE_VERSION = "0.1.15";
30
+ var PACKAGE_VERSION = "0.1.34";
27
31
  var IMAGE_REPOSITORY = "ghcr.io/saluana/or3-chat";
28
32
  var ASSET_ROOT = resolve(fileURLToPath(new URL("../assets/", import.meta.url)));
29
33
  var STATE_SCHEMA_VERSION = 1;
30
34
  var DEFAULT_PORT = 3000;
31
35
  var DEEP_HEALTH_TIMEOUT_MS = 180000;
36
+ var BACKUP_RETENTION_KEEP = 5;
37
+ var PURGE_REQUIRES_BACKUP_WITHIN_MS = 24 * 60 * 60 * 1000;
38
+ var FREE_SPACE_HEADROOM_BYTES = 64 * 1024 * 1024;
39
+ var BACKUP_ID_PATTERN = /^backup-[0-9A-Za-z-]+$/;
32
40
  var SECRET_KEYS = [
33
41
  "OR3_BASIC_AUTH_JWT_SECRET",
34
42
  "OR3_BASIC_AUTH_REFRESH_SECRET",
35
43
  "OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD",
44
+ "OR3_AUTH_INVITE_TOKEN_SECRET",
36
45
  "OR3_STORAGE_FS_TOKEN_SECRET",
37
46
  "OR3_ADMIN_JWT_SECRET",
38
47
  "OR3_ADMIN_PASSWORD"
@@ -49,6 +58,8 @@ var ALLOWED_ENV_KEYS = new Set([
49
58
  "SSR_AUTH_ENABLED",
50
59
  "AUTH_PROVIDER",
51
60
  "OR3_AUTH_PROVIDER",
61
+ "OR3_AUTH_REGISTRATION_MODE",
62
+ "OR3_AUTH_AUTO_PROVISION",
52
63
  "OR3_GUEST_ACCESS_ENABLED",
53
64
  "OR3_BASIC_AUTH_JWT_SECRET",
54
65
  "OR3_BASIC_AUTH_REFRESH_SECRET",
@@ -93,6 +104,17 @@ function randomSecret() {
93
104
  function randomPassword() {
94
105
  return `A${randomBytes(20).toString("base64url")}a1`;
95
106
  }
107
+ function serializeInitialCredentials(input2) {
108
+ return [
109
+ "# OR3 first-run credentials — move to a password manager, then delete this file.",
110
+ `OR3_BASIC_AUTH_BOOTSTRAP_EMAIL=${serializeCredentialValue(input2.bootstrapEmail)}`,
111
+ `OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD=${serializeCredentialValue(input2.bootstrapPassword)}`,
112
+ `OR3_ADMIN_USERNAME=${serializeCredentialValue(input2.adminUsername)}`,
113
+ `OR3_ADMIN_PASSWORD=${serializeCredentialValue(input2.adminPassword)}`,
114
+ ""
115
+ ].join(`
116
+ `);
117
+ }
96
118
  function validatePassword(password) {
97
119
  if (password.length < 12 || !/[A-Z]/.test(password) || !/[a-z]/.test(password) || !/[0-9]/.test(password)) {
98
120
  throw new Error("The administrator password must be at least 12 characters and contain uppercase, lowercase, and numeric characters.");
@@ -135,12 +157,19 @@ function parseFlags(argv) {
135
157
  var COMMAND_FLAGS = {
136
158
  init: ["local", "public", "domain", "admin-email", "admin-password", "admin-password-file", "port"],
137
159
  update: ["to"],
138
- backup: [],
160
+ backup: ["keep", "force", "yes"],
139
161
  restore: ["yes"],
140
162
  rollback: ["yes"],
141
163
  doctor: [],
142
164
  recover: [],
143
- adopt: ["from"]
165
+ adopt: ["from"],
166
+ credentials: ["yes", "owner-password", "admin-password"],
167
+ status: [],
168
+ logs: ["tail"],
169
+ start: [],
170
+ stop: [],
171
+ restart: [],
172
+ remove: ["purge-data", "yes"]
144
173
  };
145
174
  function assertCommandFlags(command, flags) {
146
175
  const allowed = new Set(["help", ...COMMAND_FLAGS[command] ?? []]);
@@ -214,14 +243,22 @@ async function run(command, args, cwd) {
214
243
  encoding: "utf8",
215
244
  env: process.env
216
245
  });
217
- return { ok: true, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
246
+ return {
247
+ ok: true,
248
+ stdout: result.stdout ?? "",
249
+ stderr: result.stderr ?? "",
250
+ command: printable,
251
+ exitCode: 0
252
+ };
218
253
  } catch (error) {
219
254
  const failure = error;
255
+ const stdout = failure.stdout ?? "";
220
256
  return {
221
257
  ok: false,
258
+ stdout,
222
259
  command: printable,
223
260
  exitCode: typeof failure.code === "number" ? failure.code : null,
224
- stderr: redact(failure.stderr ?? failure.stdout ?? String(error))
261
+ stderr: redact(failure.stderr || stdout || String(error))
225
262
  };
226
263
  }
227
264
  }
@@ -256,6 +293,24 @@ async function fileExists(path) {
256
293
  async function readText(path) {
257
294
  return readFile(path, "utf8");
258
295
  }
296
+ function requiredArchiveSpace(requiredBytes) {
297
+ return requiredBytes + Math.max(Math.ceil(requiredBytes / 2), FREE_SPACE_HEADROOM_BYTES);
298
+ }
299
+ function assertEnoughFreeSpace(freeBytes, requiredBytes, label) {
300
+ const needed = requiredArchiveSpace(requiredBytes);
301
+ if (freeBytes < needed) {
302
+ throw new Error(`${label} needs at least ${needed} bytes of free space (${requiredBytes} required plus reserve headroom) but only ${freeBytes} bytes are available. Free disk space or move backups off-host before retrying.`);
303
+ }
304
+ }
305
+ async function assertFreeSpaceForArchive(targetPath, requiredBytes, label) {
306
+ let stats;
307
+ try {
308
+ stats = await statfs(dirname(targetPath));
309
+ } catch (error) {
310
+ throw new Error(`Could not check free disk space for ${dirname(targetPath)}: ${error instanceof Error ? error.message : String(error)}`);
311
+ }
312
+ assertEnoughFreeSpace(Number(stats.bavail) * Number(stats.bsize), requiredBytes, label);
313
+ }
259
314
  function parseEnv(text) {
260
315
  const values = {};
261
316
  for (const line of text.split(/\r?\n/)) {
@@ -281,6 +336,11 @@ function serializeEnvValue(value) {
281
336
  }
282
337
  return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
283
338
  }
339
+ function serializeCredentialValue(value) {
340
+ if (/^[A-Za-z0-9._:@%+=/-]+$/.test(value))
341
+ return value;
342
+ return serializeEnvValue(value);
343
+ }
284
344
  function serializeEnv(values) {
285
345
  return `${Object.entries(values).filter(([, value]) => value !== undefined).map(([key, value]) => `${key}=${serializeEnvValue(value)}`).join(`
286
346
  `)}
@@ -292,7 +352,8 @@ function deploymentPaths(directory) {
292
352
  env: join(directory, ".env"),
293
353
  state: join(cloud, "state.json"),
294
354
  operations: join(cloud, "operations"),
295
- backups: join(cloud, "backups")
355
+ backups: join(cloud, "backups"),
356
+ exports: join(cloud, "exports")
296
357
  };
297
358
  }
298
359
  async function readState(directory) {
@@ -337,11 +398,38 @@ function diagnostics(directory, mode) {
337
398
  const files = `-f ${quote(join(directory, "compose.yaml"))}${mode === "public" ? ` -f ${quote(join(directory, "compose.public.yaml"))}` : ""}`;
338
399
  return `cd ${quote(directory)} && docker compose --env-file ${quote(join(directory, ".env"))} ${files} ps && docker compose --env-file ${quote(join(directory, ".env"))} ${files} logs --tail=200`;
339
400
  }
401
+ async function captureComposeDiagnostics(directory, mode, secrets) {
402
+ const sections = [];
403
+ for (const [label, command] of [
404
+ ["compose ps", ["ps", "-a"]],
405
+ ["compose logs", ["logs", "--tail=200"]]
406
+ ]) {
407
+ const result = await run("docker", composeArgs(directory, mode, [...command]), directory);
408
+ const detail = [result.stdout, result.stderr].filter(Boolean).join(`
409
+ `).trim();
410
+ sections.push(`${label}:
411
+ ${detail || "(no output)"}`);
412
+ }
413
+ const containers = await run("docker", composeArgs(directory, mode, ["ps", "-aq"]), directory);
414
+ for (const container of containers.stdout.split(/\s+/).filter(Boolean)) {
415
+ const state = await run("docker", ["inspect", "--format", "{{json .State}}", container], directory);
416
+ const detail = [state.stdout, state.stderr].filter(Boolean).join(`
417
+ `).trim();
418
+ sections.push(`container ${container} state:
419
+ ${detail || "(no output)"}`);
420
+ }
421
+ return redact(sections.join(`
422
+
423
+ `), secrets);
424
+ }
340
425
  async function compose(directory, mode, command, secrets = []) {
341
426
  const result = await run("docker", composeArgs(directory, mode, command), directory);
342
427
  if (!result.ok) {
343
- throw new Error(`${result.command}
428
+ const captured = await captureComposeDiagnostics(directory, mode, secrets).catch((error) => redact(`Diagnostics capture failed: ${error instanceof Error ? error.message : String(error)}`, secrets));
429
+ throw new Error(`${redact(result.command, secrets)}
344
430
  ${redact(result.stderr, secrets)}
431
+ Captured Docker diagnostics:
432
+ ${captured}
345
433
  Diagnostics: ${diagnostics(directory, mode)}`);
346
434
  }
347
435
  return result.stdout;
@@ -402,11 +490,13 @@ async function assertSafeComposeBinding(directory, mode, env) {
402
490
  }
403
491
  }
404
492
  var HEALTH_SCRIPT = "fetch('http://127.0.0.1:3000/api/health?deep=true').then(async response=>{const body=await response.json().catch(()=>({}));if(!response.ok||body.status!=='ok')process.exit(1)}).catch(()=>process.exit(1))";
493
+ var MAINTENANCE_SCRIPT = "fetch('http://127.0.0.1:3000/api/health?deep=true').then(async response=>{const body=await response.json().catch(()=>({}));const m=body?.providers?.sync?.details?.maintenance;if(m)console.log(JSON.stringify(m))}).catch(()=>{})";
494
+ var CONTAINER_NODE = "/nodejs/bin/node";
405
495
  async function waitForDeepHealthWithArgs(composeCommand, directory, secrets = []) {
406
496
  const deadline = Date.now() + DEEP_HEALTH_TIMEOUT_MS;
407
497
  let lastError = "health check did not complete";
408
498
  while (Date.now() < deadline) {
409
- const result = await run("docker", [...composeCommand, "exec", "-T", "or3", "node", "-e", HEALTH_SCRIPT], directory);
499
+ const result = await run("docker", [...composeCommand, "exec", "-T", "or3", CONTAINER_NODE, "-e", HEALTH_SCRIPT], directory);
410
500
  if (result.ok)
411
501
  return;
412
502
  lastError = redact(result.stderr, secrets);
@@ -430,7 +520,10 @@ async function waitForDeepHealth(directory, mode, secrets = []) {
430
520
  try {
431
521
  await waitForDeepHealthWithArgs(composeArgs(directory, mode), directory, secrets);
432
522
  } catch (error) {
523
+ const captured = await captureComposeDiagnostics(directory, mode, secrets).catch((captureError) => redact(`Diagnostics capture failed: ${captureError instanceof Error ? captureError.message : String(captureError)}`, secrets));
433
524
  throw new Error(`${error instanceof Error ? error.message : String(error)}
525
+ Captured Docker diagnostics:
526
+ ${captured}
434
527
  Diagnostics: ${diagnostics(directory, mode)}`);
435
528
  }
436
529
  }
@@ -487,6 +580,50 @@ ${result.stderr}`);
487
580
  throw new Error(`Could not resolve a digest for ${image}.`);
488
581
  return idResult.stdout.trim();
489
582
  }
583
+ function supportedImageArchitectures(manifest) {
584
+ if (manifest && Array.isArray(manifest.manifests)) {
585
+ const architectures = manifest.manifests.map((entry) => entry.platform?.architecture).filter((value) => typeof value === "string");
586
+ if (architectures.length > 0)
587
+ return [...new Set(architectures)];
588
+ }
589
+ if (manifest && typeof manifest.architecture === "string")
590
+ return [manifest.architecture];
591
+ throw new Error("The OR3 image manifest has no recognizable architecture list. Refusing to continue without confirming the image supports this machine.");
592
+ }
593
+ function assertSupportedArchitecture(manifest, hostArch) {
594
+ const supported = supportedImageArchitectures(manifest);
595
+ if (!supported.includes(hostArch)) {
596
+ throw new Error(`OR3 does not publish a ${hostArch} image for this version yet. Supported architectures: ${supported.join(", ") || "none detected"}. Install on a supported machine or wait for the next release.`);
597
+ }
598
+ }
599
+ function hostArchitecture() {
600
+ if (process.arch === "arm64")
601
+ return "arm64";
602
+ if (process.arch === "x64")
603
+ return "amd64";
604
+ throw new Error(`OR3 supports only linux/amd64 and linux/arm64 hosts; this machine reports ${process.arch}. Install on a supported machine.`);
605
+ }
606
+ async function assertSupportedHostArchitecture(image) {
607
+ const hostArch = hostArchitecture();
608
+ if (process.env.OR3_CLOUD_SKIP_PULL === "true") {
609
+ const local = await run("docker", ["image", "inspect", "--format", "{{.Architecture}}", image]);
610
+ if (local.ok && local.stdout.trim()) {
611
+ assertSupportedArchitecture({ architecture: local.stdout.trim() }, hostArch);
612
+ return;
613
+ }
614
+ }
615
+ const result = await run("docker", ["manifest", "inspect", image]);
616
+ if (!result.ok) {
617
+ throw new Error(`Could not inspect the OR3 image manifest for ${image}. ${result.stderr.trim()}`);
618
+ }
619
+ let manifest;
620
+ try {
621
+ manifest = JSON.parse(result.stdout.trim());
622
+ } catch {
623
+ throw new Error(`The OR3 image manifest for ${image} could not be parsed. Refusing to continue without confirming the image supports this machine.`);
624
+ }
625
+ assertSupportedArchitecture(manifest, hostArch);
626
+ }
490
627
  function composeProjectNames(directory) {
491
628
  const base = sanitizeName(basename(directory));
492
629
  return {
@@ -511,7 +648,11 @@ function buildEnv(input2) {
511
648
  SSR_AUTH_ENABLED: "true",
512
649
  AUTH_PROVIDER: "basic-auth",
513
650
  OR3_AUTH_PROVIDER: "basic-auth",
651
+ OR3_AUTH_REGISTRATION_MODE: "invite_only",
652
+ OR3_AUTH_AUTO_PROVISION: "false",
514
653
  OR3_GUEST_ACCESS_ENABLED: "false",
654
+ OR3_PLUGIN_ZIP_INSTALL_ENABLED: "false",
655
+ OR3_ADMIN_ALLOW_REBUILD: "false",
515
656
  OR3_BASIC_AUTH_JWT_SECRET: secrets.OR3_BASIC_AUTH_JWT_SECRET ?? randomSecret(),
516
657
  OR3_BASIC_AUTH_REFRESH_SECRET: secrets.OR3_BASIC_AUTH_REFRESH_SECRET ?? randomSecret(),
517
658
  OR3_BASIC_AUTH_ACCESS_TTL_SECONDS: "900",
@@ -519,6 +660,7 @@ function buildEnv(input2) {
519
660
  OR3_BASIC_AUTH_DB_PATH: "/data/auth.sqlite",
520
661
  OR3_BASIC_AUTH_BOOTSTRAP_EMAIL: input2.email,
521
662
  OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD: input2.password,
663
+ OR3_AUTH_INVITE_TOKEN_SECRET: secrets.OR3_AUTH_INVITE_TOKEN_SECRET ?? randomSecret(),
522
664
  OR3_SYNC_ENABLED: "true",
523
665
  OR3_CLOUD_SYNC_ENABLED: "true",
524
666
  OR3_SYNC_PROVIDER: "sqlite",
@@ -638,6 +780,111 @@ async function startProject(directory, mode, env) {
638
780
  function backupDirectory(directory, backupId) {
639
781
  return join(deploymentPaths(directory).backups, backupId);
640
782
  }
783
+ async function dataVolumeSize(directory, mode, env) {
784
+ const parseDuOutput = (stdout) => {
785
+ const match = stdout.trim().match(/^(\d+)/);
786
+ if (!match)
787
+ throw new Error(`Could not parse the data volume size from "du" output.`);
788
+ return Number(match[1]);
789
+ };
790
+ const exec = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", "sh", "-c", "du -sb /data 2>/dev/null"])], directory);
791
+ if (exec.ok)
792
+ return parseDuOutput(exec.stdout);
793
+ const fallback = await run("docker", [...composeArgs(directory, mode, ["run", "--rm", "--no-deps", "--entrypoint", "sh", "or3", "-c", "du -sb /data 2>/dev/null"])], directory);
794
+ if (fallback.ok)
795
+ return parseDuOutput(fallback.stdout);
796
+ throw new Error(`Could not measure the data volume size for the free-space preflight. Start the deployment and retry. ${redact(`${exec.stderr} ${fallback.stderr}`)}`);
797
+ }
798
+ function assertRemovableArtifactName(name) {
799
+ if (!BACKUP_ID_PATTERN.test(name)) {
800
+ throw new Error(`Refusing to remove a backup artifact named "${name}". Only paths matching an OR3-generated backup ID may be removed.`);
801
+ }
802
+ return name;
803
+ }
804
+ async function removeNamedBackupArtifact(directory, backupId) {
805
+ assertRemovableArtifactName(backupId);
806
+ const target = backupDirectory(directory, backupId);
807
+ if (dirname(target) !== deploymentPaths(directory).backups) {
808
+ throw new Error(`Refusing to remove a path outside the backups directory: ${target}.`);
809
+ }
810
+ await rm(target, { recursive: true, force: true });
811
+ await rm(join(deploymentPaths(directory).exports, `${backupId}.json`), { force: true });
812
+ }
813
+ async function readBackupManifestMetadata(backupPath) {
814
+ try {
815
+ const manifest = JSON.parse(await readText(join(backupPath, "manifest.json")));
816
+ if (manifest.schemaVersion !== 1 || !manifest.backupId || !manifest.createdAt)
817
+ return null;
818
+ return manifest;
819
+ } catch {
820
+ return null;
821
+ }
822
+ }
823
+ async function enumerateBackups(directory) {
824
+ const backupsRoot = deploymentPaths(directory).backups;
825
+ let entries = [];
826
+ try {
827
+ entries = await readdir(backupsRoot);
828
+ } catch {
829
+ return [];
830
+ }
831
+ const result = [];
832
+ for (const entry of entries) {
833
+ const path = join(backupsRoot, entry);
834
+ const manifest = await readBackupManifestMetadata(path);
835
+ if (!manifest)
836
+ continue;
837
+ let bytes = 0;
838
+ try {
839
+ for (const file of ["data.tgz", "config.env", "manifest.json"]) {
840
+ bytes += (await stat(join(path, file))).size;
841
+ }
842
+ } catch {
843
+ bytes = 0;
844
+ }
845
+ result.push({
846
+ backupId: manifest.backupId,
847
+ createdAt: manifest.createdAt,
848
+ appVersion: manifest.appVersion,
849
+ path,
850
+ bytes,
851
+ dataSha256: manifest.dataSha256
852
+ });
853
+ }
854
+ result.sort((a, b) => a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0);
855
+ return result;
856
+ }
857
+ function selectPruneTargets(backups, keep, protectedIds, force = false) {
858
+ if (!Number.isInteger(keep) || keep < 1)
859
+ throw new Error("Backup retention must be an integer of at least 1.");
860
+ const sorted = [...backups].sort((a, b) => a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0);
861
+ const deletable = force ? sorted : sorted.filter((backup) => !protectedIds.has(backup.backupId));
862
+ return deletable.slice(keep).map((backup) => backup.backupId);
863
+ }
864
+ function parseKeep(flags) {
865
+ const value = stringFlag(flags, "keep") ?? String(BACKUP_RETENTION_KEEP);
866
+ const keep = Number(value);
867
+ if (!Number.isInteger(keep) || keep < 1)
868
+ throw new Error("--keep must be an integer of at least 1.");
869
+ return keep;
870
+ }
871
+ async function pruneBackups(directory, state, keep, force) {
872
+ const backups = await enumerateBackups(directory);
873
+ const protectedIds = new Set;
874
+ if (state.rollback?.backupId)
875
+ protectedIds.add(state.rollback.backupId);
876
+ if (state.incompleteOperation?.backupId)
877
+ protectedIds.add(state.incompleteOperation.backupId);
878
+ const targets = selectPruneTargets(backups, keep, protectedIds, force);
879
+ if (force && targets.length > 0) {
880
+ console.log(`--force --yes will permanently delete: ${targets.join(", ")}`);
881
+ }
882
+ for (const backupId of targets) {
883
+ await removeNamedBackupArtifact(directory, backupId);
884
+ console.log(`Deleted backup ${backupId} at ${backupDirectory(directory, backupId)}`);
885
+ }
886
+ return targets.length;
887
+ }
641
888
  function assertDeploymentIdentity(state, env) {
642
889
  const expected = {
643
890
  OR3_COMPOSE_PROJECT: state.composeProject,
@@ -655,6 +902,26 @@ function assertDeploymentIdentity(state, env) {
655
902
  }
656
903
  }
657
904
  }
905
+ function assertDeploymentDirectoryIdentity(directory, state) {
906
+ const resolved = resolve(directory);
907
+ const names = composeProjectNames(resolved);
908
+ const mismatches = [];
909
+ if (state.composeProject !== names.project) {
910
+ mismatches.push(`compose project "${state.composeProject}" (this directory resolves to "${names.project}")`);
911
+ }
912
+ if (state.volumeName !== names.volume) {
913
+ mismatches.push(`volume "${state.volumeName}" (this directory resolves to "${names.volume}")`);
914
+ }
915
+ if (state.mode === "public") {
916
+ if (state.caddyDataVolume !== names.caddyData)
917
+ mismatches.push(`Caddy data volume "${state.caddyDataVolume}"`);
918
+ if (state.caddyConfigVolume !== names.caddyConfig)
919
+ mismatches.push(`Caddy config volume "${state.caddyConfigVolume}"`);
920
+ }
921
+ if (mismatches.length) {
922
+ throw new Error(`Managed state does not match the deployment directory identity: ${mismatches.join("; ")}. Refusing to operate on an unrelated project. Run doctor for diagnostics.`);
923
+ }
924
+ }
658
925
  function assertBackupMatchesDeployment(manifest, backupEnv, state, env) {
659
926
  if (manifest.mode !== state.mode || manifest.appVersion === undefined || manifest.image === undefined) {
660
927
  throw new Error(`Backup ${manifest.backupId} does not match the managed deployment mode.`);
@@ -696,69 +963,128 @@ function assertBackupMatchesDeployment(manifest, backupEnv, state, env) {
696
963
  }
697
964
  async function sha256File(path) {
698
965
  const digest = createHash("sha256");
699
- digest.update(await readFile(path));
966
+ await new Promise((resolvePromise, reject) => {
967
+ const stream = createReadStream(path);
968
+ stream.on("data", (chunk) => digest.update(chunk));
969
+ stream.once("error", reject);
970
+ stream.once("end", resolvePromise);
971
+ });
700
972
  return digest.digest("hex");
701
973
  }
974
+ async function streamCommandToFile(command, args, destination, cwd, secrets = []) {
975
+ if (await fileExists(destination)) {
976
+ throw new Error(`Refusing to overwrite existing archive ${destination}.`);
977
+ }
978
+ await mkdir(dirname(destination), { recursive: true, mode: 448 });
979
+ const temporary = `${destination}.${randomBytes(8).toString("hex")}.partial`;
980
+ const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
981
+ let stderr = "";
982
+ child.stderr.setEncoding("utf8");
983
+ child.stderr.on("data", (chunk) => {
984
+ stderr = `${stderr}${chunk}`.slice(-64 * 1024);
985
+ });
986
+ const exit = new Promise((resolvePromise, reject) => {
987
+ child.once("error", reject);
988
+ child.once("close", resolvePromise);
989
+ });
990
+ try {
991
+ const [exitCode] = await Promise.all([
992
+ exit,
993
+ pipeline(child.stdout, createWriteStream(temporary, { flags: "wx", mode: 384 }))
994
+ ]);
995
+ if (exitCode !== 0) {
996
+ throw new Error(`${command} ${args.join(" ")} exited with ${exitCode}. ${redact(stderr, secrets)}`.trim());
997
+ }
998
+ await chmod(temporary, 384);
999
+ await rename(temporary, destination);
1000
+ } catch (error) {
1001
+ if (!child.killed)
1002
+ child.kill("SIGTERM");
1003
+ await rm(temporary, { force: true }).catch(() => {
1004
+ return;
1005
+ });
1006
+ const detail = error instanceof Error ? error.message : String(error);
1007
+ throw new Error(`${detail}${stderr && !detail.includes(stderr) ? `
1008
+ ${redact(stderr, secrets)}` : ""}`);
1009
+ }
1010
+ }
1011
+ async function streamFileToCommand(command, args, source, cwd, secrets = []) {
1012
+ const child = spawn(command, args, { cwd, stdio: ["pipe", "ignore", "pipe"] });
1013
+ let stderr = "";
1014
+ child.stderr.setEncoding("utf8");
1015
+ child.stderr.on("data", (chunk) => {
1016
+ stderr = `${stderr}${chunk}`.slice(-64 * 1024);
1017
+ });
1018
+ const exit = new Promise((resolvePromise, reject) => {
1019
+ child.once("error", reject);
1020
+ child.once("close", resolvePromise);
1021
+ });
1022
+ try {
1023
+ const [exitCode] = await Promise.all([
1024
+ exit,
1025
+ pipeline(createReadStream(source), child.stdin)
1026
+ ]);
1027
+ if (exitCode !== 0) {
1028
+ throw new Error(`${command} ${args.join(" ")} exited with ${exitCode}. ${redact(stderr, secrets)}`.trim());
1029
+ }
1030
+ } catch (error) {
1031
+ if (!child.killed)
1032
+ child.kill("SIGTERM");
1033
+ const detail = error instanceof Error ? error.message : String(error);
1034
+ throw new Error(`${detail}${stderr && !detail.includes(stderr) ? `
1035
+ ${redact(stderr, secrets)}` : ""}`);
1036
+ }
1037
+ }
1038
+ async function gzipUncompressedBytes(path) {
1039
+ let bytes = 0;
1040
+ const counter = new Writable({
1041
+ write(chunk, _encoding, callback) {
1042
+ bytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(String(chunk));
1043
+ callback();
1044
+ }
1045
+ });
1046
+ await pipeline(createReadStream(path), createGunzip(), counter);
1047
+ return bytes;
1048
+ }
702
1049
  async function volumeArchive(directory, mode, env, backupDir) {
703
- const mount = `${backupDir}:/backup`;
704
- await compose(directory, mode, [
1050
+ await streamCommandToFile("docker", composeArgs(directory, mode, [
705
1051
  "run",
706
1052
  "--rm",
707
1053
  "--no-deps",
708
- "--user",
709
- "0:0",
710
- "-v",
711
- mount,
712
1054
  "--entrypoint",
713
1055
  "sh",
714
1056
  "or3",
715
1057
  "-c",
716
- "umask 077 && tar czf /backup/data.tgz -C /data . && tar tzf /backup/data.tgz >/dev/null"
717
- ], secretValues(env));
1058
+ "tar czf - -C /data ."
1059
+ ]), join(backupDir, "data.tgz"), directory, secretValues(env));
718
1060
  }
719
1061
  async function archiveExternalVolume(image, volume, backupDir) {
720
- const result = await run("docker", [
721
- "run",
722
- "--rm",
723
- "--user",
724
- "0:0",
725
- "-v",
726
- `${volume}:/source:ro`,
727
- "-v",
728
- `${backupDir}:/backup`,
729
- image,
730
- "sh",
731
- "-c",
732
- "umask 077 && tar czf /backup/data.tgz -C /source . && tar tzf /backup/data.tgz >/dev/null"
733
- ]);
734
- if (!result.ok)
735
- throw new Error(`${result.command}
736
- ${result.stderr}`);
1062
+ await streamCommandToFile("docker", ["run", "--rm", "--user", "0:0", "-v", `${volume}:/source:ro`, image, "sh", "-c", "tar czf - -C /source ."], join(backupDir, "data.tgz"));
737
1063
  }
738
- async function restoreAdoptionBackup(directory, mode, env, backupPath) {
739
- await compose(directory, mode, [
1064
+ async function restoreVolumeArchive(directory, mode, env, backupPath) {
1065
+ await streamFileToCommand("docker", composeArgs(directory, mode, [
740
1066
  "run",
741
1067
  "--rm",
1068
+ "-T",
742
1069
  "--no-deps",
743
- "--user",
744
- "0:0",
745
- "-v",
746
- `${backupPath}:/backup:ro`,
747
1070
  "--entrypoint",
748
1071
  "sh",
749
1072
  "or3",
750
1073
  "-c",
751
- "find /data -mindepth 1 -delete && tar xzf /backup/data.tgz -C /data"
752
- ], secretValues(env));
1074
+ "find /data -mindepth 1 -delete && tar xzf - -C /data"
1075
+ ]), join(backupPath, "data.tgz"), directory, secretValues(env));
753
1076
  }
754
1077
  async function createBackup(directory, state, env) {
755
1078
  await requireImageDigest(state.image, state.imageDigest, "Current deployment");
756
1079
  const backupId = id("backup");
757
1080
  const backupDir = backupDirectory(directory, backupId);
758
- await mkdir(backupDir, { recursive: true, mode: 448 });
759
- await chmod(backupDir, 448);
1081
+ const volumeSize = await dataVolumeSize(directory, state.mode, env);
1082
+ await assertFreeSpaceForArchive(backupDir, volumeSize, "Backup");
1083
+ let manifestWritten = false;
760
1084
  let stopAttempted = false;
761
1085
  try {
1086
+ await mkdir(backupDir, { recursive: true, mode: 448 });
1087
+ await chmod(backupDir, 448);
762
1088
  stopAttempted = true;
763
1089
  await stopProject(directory, state.mode);
764
1090
  await volumeArchive(directory, state.mode, env, backupDir);
@@ -771,6 +1097,7 @@ async function createBackup(directory, state, env) {
771
1097
  image: state.image,
772
1098
  imageDigest: state.imageDigest,
773
1099
  dataSha256: await sha256File(join(backupDir, "data.tgz")),
1100
+ dataBytes: volumeSize,
774
1101
  configSha256: await sha256File(join(backupDir, "config.env")),
775
1102
  mode: state.mode,
776
1103
  domain: state.domain,
@@ -782,7 +1109,21 @@ async function createBackup(directory, state, env) {
782
1109
  };
783
1110
  await writeSecure(join(backupDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
784
1111
  `);
1112
+ manifestWritten = true;
1113
+ try {
1114
+ await pruneBackups(directory, state, BACKUP_RETENTION_KEEP, false);
1115
+ } catch (error) {
1116
+ console.log(`Warning: automatic backup retention could not run: ${error instanceof Error ? error.message : String(error)}`);
1117
+ }
785
1118
  return { backupId, backupDir, manifest };
1119
+ } catch (error) {
1120
+ if (!manifestWritten) {
1121
+ await removeNamedBackupArtifact(directory, backupId).catch(() => {
1122
+ return;
1123
+ });
1124
+ throw new Error(`${error instanceof Error ? error.message : String(error)} The partial backup artifact at ${backupDir} was removed.`);
1125
+ }
1126
+ throw error;
786
1127
  } finally {
787
1128
  if (stopAttempted) {
788
1129
  try {
@@ -795,7 +1136,7 @@ async function createBackup(directory, state, env) {
795
1136
  }
796
1137
  async function readManifest(backupPath) {
797
1138
  const manifest = JSON.parse(await readText(join(backupPath, "manifest.json")));
798
- if (manifest.schemaVersion !== 1 || !manifest.backupId || !isVersion(manifest.appVersion) || !manifest.image || !/^sha256:[0-9a-f]{64}$/i.test(manifest.imageDigest)) {
1139
+ if (manifest.schemaVersion !== 1 || !manifest.backupId || !isVersion(manifest.appVersion) || !manifest.image || !/^sha256:[0-9a-f]{64}$/i.test(manifest.imageDigest) || manifest.dataBytes !== undefined && (!Number.isSafeInteger(manifest.dataBytes) || manifest.dataBytes < 0)) {
799
1140
  throw new Error(`Invalid backup manifest at ${backupPath}.`);
800
1141
  }
801
1142
  const actual = await sha256File(join(backupPath, "data.tgz"));
@@ -808,6 +1149,28 @@ async function readManifest(backupPath) {
808
1149
  }
809
1150
  return manifest;
810
1151
  }
1152
+ async function dataVolumeFreeBytes(directory, mode, env) {
1153
+ const parse = (stdout) => {
1154
+ const blocks = Number(stdout.trim());
1155
+ if (!Number.isSafeInteger(blocks) || blocks < 0)
1156
+ throw new Error("Could not parse free blocks from the data volume.");
1157
+ return blocks * 1024;
1158
+ };
1159
+ const script = "df -Pk /data | awk 'NR == 2 { print $4 }'";
1160
+ const exec = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", "sh", "-c", script])], directory);
1161
+ if (exec.ok)
1162
+ return parse(exec.stdout);
1163
+ const fallback = await run("docker", [...composeArgs(directory, mode, ["run", "--rm", "--no-deps", "--entrypoint", "sh", "or3", "-c", script])], directory);
1164
+ if (fallback.ok)
1165
+ return parse(fallback.stdout);
1166
+ throw new Error(`Could not measure free space in the Docker data volume. ${redact(`${exec.stderr} ${fallback.stderr}`, secretValues(env))}`);
1167
+ }
1168
+ async function assertRestoreFreeSpace(directory, state, env, manifest, backupPath) {
1169
+ const archiveBytes = await gzipUncompressedBytes(join(backupPath, "data.tgz"));
1170
+ const requiredBytes = Math.max(manifest.dataBytes ?? 0, archiveBytes);
1171
+ const freeBytes = await dataVolumeFreeBytes(directory, state.mode, env);
1172
+ assertEnoughFreeSpace(freeBytes, requiredBytes, "Restore");
1173
+ }
811
1174
  async function restoreBackupData(directory, state, env, backupPath) {
812
1175
  const manifest = await readManifest(backupPath);
813
1176
  const backupEnv = parseEnv(await readText(join(backupPath, "config.env")));
@@ -817,26 +1180,14 @@ async function restoreBackupData(directory, state, env, backupPath) {
817
1180
  assertBackupMatchesDeployment(manifest, backupEnv, state, env);
818
1181
  if (manifest.imageDigest)
819
1182
  await requireImageDigest(manifest.image, manifest.imageDigest, `Backup ${manifest.backupId}`);
1183
+ await assertRestoreFreeSpace(directory, state, env, manifest, backupPath);
820
1184
  let started = false;
821
1185
  let stopAttempted = false;
822
1186
  try {
823
1187
  stopAttempted = true;
824
1188
  await stopProject(directory, state.mode);
825
1189
  await copySecure(join(backupPath, "config.env"), deploymentPaths(directory).env);
826
- await compose(directory, state.mode, [
827
- "run",
828
- "--rm",
829
- "--no-deps",
830
- "--user",
831
- "0:0",
832
- "-v",
833
- `${backupPath}:/backup:ro`,
834
- "--entrypoint",
835
- "sh",
836
- "or3",
837
- "-c",
838
- "find /data -mindepth 1 -delete && tar xzf /backup/data.tgz -C /data"
839
- ], secretValues(backupEnv));
1190
+ await restoreVolumeArchive(directory, state.mode, backupEnv, backupPath);
840
1191
  const restoredEnv = parseEnv(await readText(deploymentPaths(directory).env));
841
1192
  await startProject(directory, state.mode, restoredEnv);
842
1193
  started = true;
@@ -878,19 +1229,29 @@ Usage:
878
1229
  npx @or3/cloud init [directory] --local
879
1230
  npx @or3/cloud init [directory] --public --domain <hostname>
880
1231
  npx @or3/cloud update [--to <exact-version>]
881
- npx @or3/cloud backup
1232
+ npx @or3/cloud backup [list|prune [--keep <n>]|export <backup-id> <destination-dir>]
882
1233
  npx @or3/cloud restore <backup-id-or-path> --yes
883
1234
  npx @or3/cloud rollback --yes
1235
+ npx @or3/cloud credentials reset --yes [--owner-password <p> --admin-password <p>]
884
1236
  npx @or3/cloud doctor
885
1237
  npx @or3/cloud recover
886
1238
  npx @or3/cloud adopt --from <v1-directory> [directory]
1239
+ npx @or3/cloud status
1240
+ npx @or3/cloud logs [--tail <n>] [service]
1241
+ npx @or3/cloud start | stop | restart
1242
+ npx @or3/cloud remove [--purge-data --yes]
887
1243
 
888
1244
  Options:
889
1245
  --admin-email <email> Administrator email for first login
890
- --admin-password <password> Explicit password (prefer --admin-password-file)
1246
+ --admin-password <password> Explicit password (prefer --admin-password-file); for credentials reset, the new admin password
891
1247
  --admin-password-file <path> Read the bootstrap password without shell history
1248
+ --owner-password <password> New owner (basic auth) password for credentials reset
892
1249
  --port <port> Local OR3 port (default: 3000)
893
- --yes Confirm a destructive restore or rollback
1250
+ --keep <n> Backups to retain when pruning (default: 5)
1251
+ --force Prune backups even when referenced by the rollback point
1252
+ --tail <n> Log lines to show (default: 200)
1253
+ --purge-data Remove data volumes and managed files (with remove)
1254
+ --yes Confirm a destructive restore, rollback, credentials reset, or purge
894
1255
  --help Show this help
895
1256
  --version Show the Cloud package version
896
1257
 
@@ -938,6 +1299,7 @@ async function initCommand(positionals, flags) {
938
1299
  const version = PACKAGE_VERSION;
939
1300
  const image = imageFor(version);
940
1301
  await pullImage(image);
1302
+ await assertSupportedHostArchitecture(image);
941
1303
  await mkdir(directory, { recursive: true, mode: 448 });
942
1304
  await chmod(directory, 448);
943
1305
  await mkdir(deploymentPaths(directory).operations, { recursive: true, mode: 448 });
@@ -945,9 +1307,12 @@ async function initCommand(positionals, flags) {
945
1307
  await copyAssets(directory, mode);
946
1308
  const env = buildEnv({ mode, version, directory, email, password, domain, port });
947
1309
  await writeSecure(deploymentPaths(directory).env, serializeEnv(env));
948
- await writeSecure(join(directory, ".or3-initial-credentials"), `email=${email}
949
- password=${password}
950
- `);
1310
+ await writeSecure(join(directory, ".or3-initial-credentials"), serializeInitialCredentials({
1311
+ bootstrapEmail: email,
1312
+ bootstrapPassword: password,
1313
+ adminUsername: email,
1314
+ adminPassword: password
1315
+ }));
951
1316
  const digest = await imageDigest(image);
952
1317
  const state = stateFromEnv(directory, env, mode, "init", digest);
953
1318
  await markPending(directory, state, {
@@ -999,6 +1364,16 @@ async function recoverCommand(directory) {
999
1364
  return;
1000
1365
  }
1001
1366
  try {
1367
+ if (pending.operation === "credentials-reset") {
1368
+ const nextEnv = pending.credentialReset?.nextEnv;
1369
+ if (!nextEnv)
1370
+ throw new Error("The incomplete credential reset has no protected recovery data. Restore a backup rather than guessing credentials.");
1371
+ await applyCredentialReset(loaded.directory, loaded.state, nextEnv);
1372
+ loaded.state.lastError = undefined;
1373
+ await clearPending(loaded.directory, loaded.state);
1374
+ console.log("Recovered the incomplete credential reset. Owner and admin credentials were verified inside the OR3 container.");
1375
+ return;
1376
+ }
1002
1377
  if (pending.operation === "restore" || pending.operation === "rollback") {
1003
1378
  if (!pending.backupId)
1004
1379
  throw new Error(`The incomplete ${pending.operation} has no recorded backup ID.`);
@@ -1079,7 +1454,7 @@ async function recoverCommand(directory) {
1079
1454
  throw new Error(`Source backup ${sourceManifest.backupId} does not match the managed adoption target.`);
1080
1455
  }
1081
1456
  await stopProject(loaded.directory, loaded.state.mode);
1082
- await restoreAdoptionBackup(loaded.directory, loaded.state.mode, loaded.env, sourceBackupPath);
1457
+ await restoreVolumeArchive(loaded.directory, loaded.state.mode, loaded.env, sourceBackupPath);
1083
1458
  }
1084
1459
  await pullAndRequireImage(loaded.state.image, loaded.state.imageDigest, "Current deployment");
1085
1460
  await startProject(loaded.directory, loaded.state.mode, loaded.env);
@@ -1090,7 +1465,8 @@ async function recoverCommand(directory) {
1090
1465
  await commitRecoveredState(loaded.directory, loaded.state, recovered);
1091
1466
  console.log(`Recovered the incomplete ${pending.operation} operation. OR3 ${recovered.appVersion} is deeply healthy.`);
1092
1467
  } catch (error) {
1093
- loaded.state.lastError = redact(error instanceof Error ? error.message : String(error), secretValues(loaded.env));
1468
+ const recoverySecrets = pending.operation === "credentials-reset" ? secretValues(pending.credentialReset?.nextEnv ?? loaded.env) : secretValues(loaded.env);
1469
+ loaded.state.lastError = redact(error instanceof Error ? error.message : String(error), recoverySecrets);
1094
1470
  if (pending.operation === "adopt" && pending.sourceDirectory) {
1095
1471
  try {
1096
1472
  await compose(loaded.directory, loaded.state.mode, ["down"]).catch(() => {
@@ -1115,7 +1491,7 @@ function assertNoPending(state) {
1115
1491
  throw new Error(`An incomplete ${state.incompleteOperation.operation} is recorded. Run "npx @or3/cloud recover" before starting another operation.`);
1116
1492
  }
1117
1493
  }
1118
- async function backupCommand(directory) {
1494
+ async function backupCreateCommand(directory) {
1119
1495
  await ensureDocker();
1120
1496
  const loaded = await loadManaged(directory);
1121
1497
  assertNoPending(loaded.state);
@@ -1137,6 +1513,109 @@ async function backupCommand(directory) {
1137
1513
  throw error;
1138
1514
  }
1139
1515
  }
1516
+ async function backupListCommand(directory) {
1517
+ const loaded = await loadManaged(directory);
1518
+ const backups = await enumerateBackups(loaded.directory);
1519
+ if (backups.length === 0) {
1520
+ console.log(`No backups exist yet for ${loaded.directory}. Run "npx @or3/cloud backup" to create one.`);
1521
+ return;
1522
+ }
1523
+ console.log(`OR3 Cloud backups for ${loaded.directory} (${backups.length}):`);
1524
+ console.log("backupId createdAt version bytes checksum");
1525
+ for (const backup of backups) {
1526
+ console.log(`${backup.backupId.padEnd(30)} ${backup.createdAt.padEnd(30)} ${backup.appVersion.padEnd(8)} ${String(backup.bytes).padStart(9)} ${backup.dataSha256.slice(0, 12)}`);
1527
+ }
1528
+ console.log(`
1529
+ Backups contain credentials and secrets; keep them owner-only and export off-host.`);
1530
+ }
1531
+ async function backupPruneCommand(directory, flags) {
1532
+ const loaded = await loadManaged(directory);
1533
+ assertNoPending(loaded.state);
1534
+ const keep = parseKeep(flags);
1535
+ const force = boolFlag(flags, "force");
1536
+ if (force && !boolFlag(flags, "yes"))
1537
+ throw new Error("--force may delete the only rollback or recovery backup. Re-run with --force --yes after confirming the exact backups shown by `backup list`.");
1538
+ const deleted = await pruneBackups(loaded.directory, loaded.state, keep, force);
1539
+ console.log(deleted > 0 ? `Pruned ${deleted} backup(s); keeping the newest ${keep}.` : `Nothing to prune: keeping all backups (newest ${keep}).`);
1540
+ }
1541
+ async function backupExportCommand(directory, backupId, destination) {
1542
+ const backupPath = await resolveBackup(directory, backupId);
1543
+ const manifest = await readManifest(backupPath);
1544
+ const dest = resolve(destination);
1545
+ const source = resolve(backupPath);
1546
+ if (source === dest || dest.startsWith(`${source}${sep}`)) {
1547
+ throw new Error("Choose a destination directory different from the backup itself.");
1548
+ }
1549
+ if (await fileExists(dest))
1550
+ throw new Error(`Destination ${dest} already exists. Choose a new empty destination so an export can never merge with unrelated files.`);
1551
+ await mkdir(dirname(dest), { recursive: true, mode: 448 });
1552
+ await mkdir(dest, { mode: 448 });
1553
+ try {
1554
+ await chmod(dest, 448);
1555
+ for (const file of ["data.tgz", "config.env", "manifest.json"]) {
1556
+ await copySecure(join(backupPath, file), join(dest, file));
1557
+ }
1558
+ const dataSha = await sha256File(join(dest, "data.tgz"));
1559
+ if (dataSha !== manifest.dataSha256) {
1560
+ throw new Error(`Exported data.tgz checksum mismatch for ${manifest.backupId}. Expected ${manifest.dataSha256}, got ${dataSha}.`);
1561
+ }
1562
+ if (manifest.configSha256) {
1563
+ const configSha = await sha256File(join(dest, "config.env"));
1564
+ if (configSha !== manifest.configSha256) {
1565
+ throw new Error(`Exported config.env checksum mismatch for ${manifest.backupId}.`);
1566
+ }
1567
+ }
1568
+ const destinationDevice = (await stat(dest)).dev;
1569
+ await mkdir(deploymentPaths(directory).exports, { recursive: true, mode: 448 });
1570
+ const receipt = {
1571
+ schemaVersion: 1,
1572
+ backupId: manifest.backupId,
1573
+ exportedAt: now(),
1574
+ destination: dest,
1575
+ destinationDevice,
1576
+ dataSha256: manifest.dataSha256,
1577
+ configSha256: manifest.configSha256
1578
+ };
1579
+ await writeSecure(join(deploymentPaths(directory).exports, `${manifest.backupId}.json`), `${JSON.stringify(receipt, null, 2)}
1580
+ `);
1581
+ const bytes = (await stat(join(dest, "data.tgz"))).size + (await stat(join(dest, "config.env"))).size + (await stat(join(dest, "manifest.json"))).size;
1582
+ console.log(`Exported backup ${manifest.backupId} (${bytes} bytes) to ${dest}`);
1583
+ console.log("The exported copy contains credentials and secrets. It is owner-only (0600); keep it off-host.");
1584
+ if ((await stat(backupPath)).dev === destinationDevice) {
1585
+ console.log("This export is on the same filesystem as the deployment and cannot authorize `remove --purge-data`. Copy it to a mounted backup disk or another host, then export again there.");
1586
+ } else {
1587
+ console.log("Verified export recorded. It can authorize `remove --purge-data --yes` while this backup remains fresh.");
1588
+ }
1589
+ } catch (error) {
1590
+ await rm(dest, { recursive: true, force: true });
1591
+ throw error;
1592
+ }
1593
+ }
1594
+ async function backupCommand(directory, positionals, flags) {
1595
+ const subcommand = positionals[0];
1596
+ if (!subcommand)
1597
+ return await backupCreateCommand(directory);
1598
+ if (subcommand === "list") {
1599
+ if (positionals.length > 1)
1600
+ throw new Error("backup list accepts no arguments.");
1601
+ return await backupListCommand(directory);
1602
+ }
1603
+ if (subcommand === "prune") {
1604
+ if (positionals.length > 1)
1605
+ throw new Error("backup prune accepts no arguments.");
1606
+ return await backupPruneCommand(directory, flags);
1607
+ }
1608
+ if (subcommand === "export") {
1609
+ const backupId = positionals[1];
1610
+ const destination = positionals[2];
1611
+ if (!backupId || !destination)
1612
+ throw new Error("backup export requires a backup ID and a destination directory.");
1613
+ if (positionals.length > 3)
1614
+ throw new Error("backup export accepts a backup ID and a destination directory only.");
1615
+ return await backupExportCommand(directory, backupId, destination);
1616
+ }
1617
+ throw new Error(`Unknown backup subcommand "${subcommand}". Use list, prune, export, or no subcommand to create a backup.`);
1618
+ }
1140
1619
  async function updateCommand(directory, flags) {
1141
1620
  await ensureDocker();
1142
1621
  const loaded = await loadManaged(directory);
@@ -1145,11 +1624,12 @@ async function updateCommand(directory, flags) {
1145
1624
  await waitForDeepHealth(loaded.directory, state.mode, secretValues(env));
1146
1625
  const targetVersion = stringFlag(flags, "to")?.trim() ?? PACKAGE_VERSION;
1147
1626
  if (!isVersion(targetVersion))
1148
- throw new Error("--to must be a complete semantic version such as 0.1.15.");
1627
+ throw new Error(`--to must be a complete semantic version such as ${PACKAGE_VERSION}.`);
1149
1628
  if (targetVersion === state.appVersion)
1150
1629
  throw new Error(`The deployment is already on OR3 ${targetVersion}.`);
1151
1630
  const targetImage = imageFor(targetVersion);
1152
1631
  const targetDigest = await pullImage(targetImage);
1632
+ await assertSupportedHostArchitecture(targetImage);
1153
1633
  const oldEnv = { ...env };
1154
1634
  const pending = {
1155
1635
  id: id("update"),
@@ -1228,6 +1708,8 @@ async function restoreCommand(directory, flags, positionals) {
1228
1708
  assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
1229
1709
  if (manifest.imageDigest)
1230
1710
  await pullAndRequireImage(manifest.image, manifest.imageDigest, `Backup ${manifest.backupId}`);
1711
+ await assertSupportedHostArchitecture(manifest.image);
1712
+ await assertRestoreFreeSpace(loaded.directory, loaded.state, loaded.env, manifest, backupPath);
1231
1713
  const pending = {
1232
1714
  id: id("restore"),
1233
1715
  operation: "restore",
@@ -1274,6 +1756,8 @@ async function rollbackCommand(directory, flags) {
1274
1756
  }
1275
1757
  assertBackupMatchesDeployment(manifest, backupEnv, loaded.state, loaded.env);
1276
1758
  await pullAndRequireImage(point.image, point.imageDigest, "Rollback");
1759
+ await assertSupportedHostArchitecture(point.image);
1760
+ await assertRestoreFreeSpace(loaded.directory, loaded.state, loaded.env, manifest, backupPath);
1277
1761
  const pending = {
1278
1762
  id: id("rollback"),
1279
1763
  operation: "rollback",
@@ -1540,6 +2024,7 @@ async function adoptCommand(positionals, flags) {
1540
2024
  throw new Error(`V1 OR3_IMAGE ${sourceEnv.OR3_IMAGE} does not match the published image for ${release.or3Version}.`);
1541
2025
  }
1542
2026
  await pullImage(image);
2027
+ await assertSupportedHostArchitecture(image);
1543
2028
  const sourceVolume = await readSourceVolume(sourceDirectory);
1544
2029
  const email = sourceEnv.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
1545
2030
  const password = sourceEnv.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD;
@@ -1586,9 +2071,12 @@ async function adoptCommand(positionals, flags) {
1586
2071
  throw new Error(`Docker volume ${volume} already exists. Choose a new adoption target directory.`);
1587
2072
  }
1588
2073
  await writeSecure(deploymentPaths(targetDirectory).env, serializeEnv(targetEnv));
1589
- await writeSecure(join(targetDirectory, ".or3-initial-credentials"), `email=${email}
1590
- password=${password}
1591
- `);
2074
+ await writeSecure(join(targetDirectory, ".or3-initial-credentials"), serializeInitialCredentials({
2075
+ bootstrapEmail: email,
2076
+ bootstrapPassword: password,
2077
+ adminUsername: email,
2078
+ adminPassword: password
2079
+ }));
1592
2080
  const digest = await imageDigest(image);
1593
2081
  const state = stateFromEnv(targetDirectory, targetEnv, mode, "adopt", digest);
1594
2082
  await markPending(targetDirectory, state, {
@@ -1638,7 +2126,7 @@ password=${password}
1638
2126
  await writeSecure(join(sourceBackupDir, "manifest.json"), `${JSON.stringify(sourceManifest, null, 2)}
1639
2127
  `);
1640
2128
  await updatePending(targetDirectory, state, { backupId: sourceBackupId });
1641
- await compose(targetDirectory, mode, ["run", "--rm", "--no-deps", "--user", "0:0", "-v", `${sourceVolume}:/source:ro`, "--entrypoint", "sh", "or3", "-c", "mkdir -p /data && cp -a /source/. /data/"], secretValues(targetEnv));
2129
+ await restoreVolumeArchive(targetDirectory, mode, targetEnv, sourceBackupDir);
1642
2130
  await startProject(targetDirectory, mode, targetEnv);
1643
2131
  await clearPending(targetDirectory, state);
1644
2132
  console.log(`Adopted ${sourceDirectory} into ${targetDirectory} at OR3 ${release.or3Version}.`);
@@ -1662,6 +2150,448 @@ password=${password}
1662
2150
  ${sourceRecoveryError ? "The original V1 deployment needs manual recovery. " : "The original V1 deployment was restarted and deeply healthy. "}Managed files remain at ${targetDirectory}.${sourceBackupDir ? ` Source backup: ${sourceBackupDir}.` : ""}`);
1663
2151
  }
1664
2152
  }
2153
+ async function resolveResetPasswords(flags) {
2154
+ const ownerPassword = stringFlag(flags, "owner-password");
2155
+ const adminPassword = stringFlag(flags, "admin-password");
2156
+ if (ownerPassword !== undefined || adminPassword !== undefined) {
2157
+ if (ownerPassword === undefined || adminPassword === undefined) {
2158
+ throw new Error("Use both --owner-password and --admin-password together so a reset never applies one credential without the other.");
2159
+ }
2160
+ validatePassword(ownerPassword);
2161
+ validatePassword(adminPassword);
2162
+ return { ownerPassword, adminPassword };
2163
+ }
2164
+ if (!input.isTTY || !output.isTTY) {
2165
+ throw new Error("--owner-password and --admin-password are required in a non-interactive session. Credentials are never generated automatically.");
2166
+ }
2167
+ const prompt = readline.createInterface({ input, output });
2168
+ try {
2169
+ const owner = (await prompt.question("New owner (basic auth) password: ")).trim();
2170
+ validatePassword(owner);
2171
+ const admin = (await prompt.question("New admin password: ")).trim();
2172
+ validatePassword(admin);
2173
+ return { ownerPassword: owner, adminPassword: admin };
2174
+ } finally {
2175
+ prompt.close();
2176
+ }
2177
+ }
2178
+ function buildCredentialsResetScript(input2) {
2179
+ const authDbPath = input2.authDbPath ?? "/data/auth.sqlite";
2180
+ const adminCredentialsPath = input2.adminCredentialsPath ?? "/data/admin/admin-credentials.json";
2181
+ return `const fs = require('fs');
2182
+ const path = require('path');
2183
+ const candidates = [process.cwd(), '/app'];
2184
+ const resolveModule = (name) => {
2185
+ for (const root of candidates) {
2186
+ const resolved = path.join(root, '.output/server/node_modules', name);
2187
+ if (fs.existsSync(resolved)) return resolved;
2188
+ }
2189
+ return null;
2190
+ };
2191
+ let Database;
2192
+ try {
2193
+ const betterSqlite3Path = resolveModule('better-sqlite3');
2194
+ if (!betterSqlite3Path) throw new Error('better-sqlite3 directory not found');
2195
+ Database = require(betterSqlite3Path);
2196
+ } catch (error) {
2197
+ console.error('OR3 credentials reset failed: better-sqlite3 is not available in this image, so the auth database could not be updated. Nothing was changed. Update the OR3 image to a release that bundles it, then retry.');
2198
+ process.exit(1);
2199
+ }
2200
+ let bcrypt;
2201
+ try {
2202
+ // bcryptjs 2.x ships a broken "exports" map (require -> missing umd/index.js),
2203
+ // so it must be loaded by explicit absolute path, like the server bundle does.
2204
+ const bcryptPath = resolveModule('bcryptjs/index.js');
2205
+ if (!bcryptPath) throw new Error('bcryptjs directory not found');
2206
+ bcrypt = require(bcryptPath);
2207
+ } catch (error) {
2208
+ console.error('OR3 credentials reset failed: bcryptjs is not available in this image, so new password hashes could not be computed. Nothing was changed. Update the OR3 image to a release that bundles it, then retry.');
2209
+ process.exit(1);
2210
+ }
2211
+ const ownerEmail = process.env.OR3_RESET_OWNER_EMAIL;
2212
+ const ownerPassword = process.env.OR3_RESET_OWNER_PASSWORD;
2213
+ const adminUsername = process.env.OR3_RESET_ADMIN_USERNAME;
2214
+ const adminPassword = process.env.OR3_RESET_ADMIN_PASSWORD;
2215
+ if (!ownerEmail || !ownerPassword || !adminUsername || !adminPassword) {
2216
+ console.error('OR3 credentials reset failed: required environment values were not supplied to the reset script. Nothing was changed.');
2217
+ process.exit(1);
2218
+ }
2219
+ const ownerHash = bcrypt.hashSync(ownerPassword, 12);
2220
+ const adminHash = bcrypt.hashSync(adminPassword, 12);
2221
+ const now = Date.now();
2222
+ const db = new Database(${JSON.stringify(authDbPath)});
2223
+ db.pragma('busy_timeout = 10000');
2224
+ const account = db.prepare('SELECT id FROM basic_auth_accounts WHERE email = ?').get(ownerEmail);
2225
+ if (!account) {
2226
+ console.error('OR3 credentials reset failed: no Basic Auth account matches OR3_BASIC_AUTH_BOOTSTRAP_EMAIL. Nothing was changed.');
2227
+ process.exit(1);
2228
+ }
2229
+ const adminCredentialsPath = ${JSON.stringify(adminCredentialsPath)};
2230
+ const previousCredentials = (() => {
2231
+ try { return fs.readFileSync(adminCredentialsPath, 'utf8'); } catch { return null; }
2232
+ })();
2233
+ let credentials;
2234
+ try {
2235
+ credentials = previousCredentials ? JSON.parse(previousCredentials) : { created_at: new Date().toISOString() };
2236
+ } catch (error) {
2237
+ console.error('OR3 credentials reset failed: the admin credentials file is corrupt. Nothing was changed.');
2238
+ process.exit(1);
2239
+ }
2240
+ credentials.username = adminUsername;
2241
+ credentials.password_hash_bcrypt = adminHash;
2242
+ credentials.updated_at = new Date().toISOString();
2243
+ try {
2244
+ fs.writeFileSync(adminCredentialsPath, JSON.stringify(credentials, null, 2) + '\\n', { mode: 0o600 });
2245
+ } catch (error) {
2246
+ console.error('OR3 credentials reset failed: could not update the admin credentials file at ' + adminCredentialsPath + '. Nothing was changed. ' + (error && error.message ? error.message : String(error)));
2247
+ process.exit(1);
2248
+ }
2249
+ try {
2250
+ db.transaction(() => {
2251
+ db.prepare('UPDATE basic_auth_accounts SET password_hash = ?, token_version = token_version + 1, updated_at = ? WHERE id = ?').run(ownerHash, now, account.id);
2252
+ db.prepare('UPDATE basic_auth_sessions SET revoked_at = COALESCE(revoked_at, ?), rotation_grace_until = NULL, rotation_grace_refresh_token = NULL WHERE account_id = ?').run(now, account.id);
2253
+ })();
2254
+ } catch (error) {
2255
+ if (previousCredentials !== null) {
2256
+ try { fs.writeFileSync(adminCredentialsPath, previousCredentials, { mode: 0o600 }); } catch {}
2257
+ } else {
2258
+ try { fs.rmSync(adminCredentialsPath, { force: true }); } catch {}
2259
+ }
2260
+ console.error('OR3 credentials reset failed: the authentication database rejected the update. Any partial change was rolled back. ' + (error && error.message ? error.message : String(error)));
2261
+ process.exit(1);
2262
+ }
2263
+ db.close();
2264
+ console.log('credentials-reset: owner hash, sessions, and admin credentials updated.');
2265
+ `;
2266
+ }
2267
+ function credentialResetValues(env) {
2268
+ const ownerEmail = env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
2269
+ const ownerPassword = env.OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD;
2270
+ const adminUsername = env.OR3_ADMIN_USERNAME;
2271
+ const adminPassword = env.OR3_ADMIN_PASSWORD;
2272
+ if (!ownerEmail || !ownerPassword || !adminUsername || !adminPassword) {
2273
+ throw new Error("Credential recovery data is incomplete. Run doctor and restore a backup rather than guessing credentials.");
2274
+ }
2275
+ return { ownerEmail, ownerPassword, adminUsername, adminPassword };
2276
+ }
2277
+ var CREDENTIALS_VERIFY_SCRIPT = `
2278
+ const request = async (path, body) => {
2279
+ const response = await fetch('http://127.0.0.1:3000' + path, {
2280
+ method: 'POST',
2281
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
2282
+ body: JSON.stringify(body),
2283
+ signal: AbortSignal.timeout(30000),
2284
+ });
2285
+ if (!response.ok) throw new Error(path + ' returned HTTP ' + response.status);
2286
+ };
2287
+ Promise.all([
2288
+ request('/api/basic-auth/sign-in', { email: process.env.OR3_RESET_OWNER_EMAIL, password: process.env.OR3_RESET_OWNER_PASSWORD }),
2289
+ request('/api/admin/auth/login', { username: process.env.OR3_RESET_ADMIN_USERNAME, password: process.env.OR3_RESET_ADMIN_PASSWORD }),
2290
+ ]).catch((error) => { console.error('Credential verification failed: ' + error.message); process.exit(1); });
2291
+ `;
2292
+ async function runCredentialsResetScript(directory, mode, values) {
2293
+ const script = buildCredentialsResetScript(values);
2294
+ const result = await run("docker", [
2295
+ ...composeArgs(directory, mode, [
2296
+ "exec",
2297
+ "-T",
2298
+ "-e",
2299
+ `OR3_RESET_OWNER_EMAIL=${values.ownerEmail}`,
2300
+ "-e",
2301
+ `OR3_RESET_OWNER_PASSWORD=${values.ownerPassword}`,
2302
+ "-e",
2303
+ `OR3_RESET_ADMIN_USERNAME=${values.adminUsername}`,
2304
+ "-e",
2305
+ `OR3_RESET_ADMIN_PASSWORD=${values.adminPassword}`,
2306
+ "or3",
2307
+ CONTAINER_NODE,
2308
+ "-e",
2309
+ script
2310
+ ])
2311
+ ], directory);
2312
+ if (!result.ok)
2313
+ throw new Error(`${result.command}
2314
+ ${redact(result.stderr, [values.ownerPassword, values.adminPassword])}`);
2315
+ }
2316
+ async function verifyCredentialsInsideContainer(directory, mode, values) {
2317
+ const result = await run("docker", [
2318
+ ...composeArgs(directory, mode, [
2319
+ "exec",
2320
+ "-T",
2321
+ "-e",
2322
+ `OR3_RESET_OWNER_EMAIL=${values.ownerEmail}`,
2323
+ "-e",
2324
+ `OR3_RESET_OWNER_PASSWORD=${values.ownerPassword}`,
2325
+ "-e",
2326
+ `OR3_RESET_ADMIN_USERNAME=${values.adminUsername}`,
2327
+ "-e",
2328
+ `OR3_RESET_ADMIN_PASSWORD=${values.adminPassword}`,
2329
+ "or3",
2330
+ CONTAINER_NODE,
2331
+ "-e",
2332
+ CREDENTIALS_VERIFY_SCRIPT
2333
+ ])
2334
+ ], directory);
2335
+ if (!result.ok)
2336
+ throw new Error(`Credential verification inside the OR3 container failed. ${redact(result.stderr, [values.ownerPassword, values.adminPassword])}`);
2337
+ }
2338
+ async function applyCredentialReset(directory, state, nextEnv) {
2339
+ const values = credentialResetValues(nextEnv);
2340
+ const running = await run("docker", [...composeArgs(directory, state.mode, ["ps", "-q", "or3"])], directory);
2341
+ if (!running.ok || !running.stdout.trim()) {
2342
+ await writeSecure(deploymentPaths(directory).env, serializeEnv(nextEnv));
2343
+ await startProject(directory, state.mode, nextEnv);
2344
+ }
2345
+ await runCredentialsResetScript(directory, state.mode, values);
2346
+ await writeSecure(deploymentPaths(directory).env, serializeEnv(nextEnv));
2347
+ const initialCredentials = join(directory, ".or3-initial-credentials");
2348
+ if (await fileExists(initialCredentials)) {
2349
+ await writeSecure(initialCredentials, serializeInitialCredentials({
2350
+ bootstrapEmail: values.ownerEmail,
2351
+ bootstrapPassword: values.ownerPassword,
2352
+ adminUsername: values.adminUsername,
2353
+ adminPassword: values.adminPassword
2354
+ }));
2355
+ }
2356
+ await stopProject(directory, state.mode);
2357
+ await startProject(directory, state.mode, nextEnv);
2358
+ await verifyCredentialsInsideContainer(directory, state.mode, values);
2359
+ }
2360
+ async function credentialsResetCommand(directory, flags) {
2361
+ if (!boolFlag(flags, "yes"))
2362
+ throw new Error("Credentials reset changes the owner password, revokes all app sessions, and changes the admin password. Re-run with --yes after confirming the impact.");
2363
+ await ensureDocker();
2364
+ const loaded = await loadManaged(directory);
2365
+ assertDeploymentDirectoryIdentity(directory, loaded.state);
2366
+ assertNoPending(loaded.state);
2367
+ const ownerEmail = loaded.env.OR3_BASIC_AUTH_BOOTSTRAP_EMAIL;
2368
+ const adminUsername = loaded.env.OR3_ADMIN_USERNAME;
2369
+ if (!ownerEmail)
2370
+ throw new Error("OR3_BASIC_AUTH_BOOTSTRAP_EMAIL is missing from .env, so the owner account cannot be reset.");
2371
+ if (!adminUsername)
2372
+ throw new Error("OR3_ADMIN_USERNAME is missing from .env, so the admin password cannot be reset.");
2373
+ const { ownerPassword, adminPassword } = await resolveResetPasswords(flags);
2374
+ const nextEnv = {
2375
+ ...loaded.env,
2376
+ OR3_BASIC_AUTH_BOOTSTRAP_PASSWORD: ownerPassword,
2377
+ OR3_ADMIN_PASSWORD: adminPassword,
2378
+ OR3_ADMIN_JWT_SECRET: randomSecret()
2379
+ };
2380
+ const pending = {
2381
+ id: id("credentials-reset"),
2382
+ operation: "credentials-reset",
2383
+ startedAt: now(),
2384
+ message: "Resetting owner and admin credentials",
2385
+ credentialReset: { nextEnv }
2386
+ };
2387
+ await markPending(loaded.directory, loaded.state, pending);
2388
+ try {
2389
+ await applyCredentialReset(loaded.directory, loaded.state, nextEnv);
2390
+ await clearPending(loaded.directory, loaded.state);
2391
+ console.log("Owner and admin credentials are now separate. The old owner password and all app sessions were revoked.");
2392
+ console.log("Admin session cookies were invalidated by rotating OR3_ADMIN_JWT_SECRET. New passwords were written only to protected files; sign in again with them.");
2393
+ } catch (error) {
2394
+ loaded.state.lastError = redact(error instanceof Error ? error.message : String(error), secretValues(nextEnv));
2395
+ await writeState(loaded.directory, loaded.state);
2396
+ throw new Error(`${loaded.state.lastError}
2397
+ Credential reset is recoverable: run "npx @or3/cloud recover" to replay the intended protected operation.`);
2398
+ }
2399
+ }
2400
+ async function credentialsCommand(directory, positionals, flags) {
2401
+ const subcommand = positionals[0];
2402
+ if (subcommand !== "reset") {
2403
+ throw new Error(`Unknown credentials subcommand "${subcommand ?? ""}". Use "npx @or3/cloud credentials reset --yes".`);
2404
+ }
2405
+ if (positionals.length > 1)
2406
+ throw new Error("credentials reset accepts no arguments.");
2407
+ return await credentialsResetCommand(directory, flags);
2408
+ }
2409
+ async function statusCommand(directory) {
2410
+ const loaded = await loadManaged(directory);
2411
+ assertDeploymentDirectoryIdentity(directory, loaded.state);
2412
+ const { state } = loaded;
2413
+ console.log(`OR3 Cloud deployment: ${loaded.directory}`);
2414
+ console.log(` mode: ${state.mode}`);
2415
+ console.log(` version: ${state.appVersion}`);
2416
+ if (state.domain)
2417
+ console.log(` domain: ${state.domain}`);
2418
+ console.log(` port: ${state.port}`);
2419
+ console.log(` image digest: ${state.imageDigest}`);
2420
+ console.log(` last successful operation: ${state.lastSuccessfulOperation} (${state.updatedAt})`);
2421
+ if (state.incompleteOperation)
2422
+ console.log(` incomplete operation: ${state.incompleteOperation.operation} (${state.incompleteOperation.id})`);
2423
+ if (state.lastError)
2424
+ console.log(` last error: ${state.lastError}`);
2425
+ console.log();
2426
+ const ps = await run("docker", [...composeArgs(directory, state.mode, ["ps"])], directory);
2427
+ if (ps.ok) {
2428
+ console.log(ps.stdout.trim());
2429
+ } else {
2430
+ console.log(`Could not list containers: ${ps.stderr}`);
2431
+ }
2432
+ const health = await probeDeepHealth(directory, state.mode);
2433
+ if (health === "ok")
2434
+ console.log("Deep health: OK");
2435
+ else if (health === "degraded")
2436
+ console.log("Deep health: DEGRADED (the container is running but /api/health?deep=true is failing).");
2437
+ else
2438
+ console.log("Deep health: unreachable (the or3 container is not running).");
2439
+ await printMaintenanceSummary(directory, state.mode);
2440
+ }
2441
+ async function printMaintenanceSummary(directory, mode) {
2442
+ const result = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", CONTAINER_NODE, "-e", MAINTENANCE_SCRIPT])], directory);
2443
+ if (!result.ok || !result.stdout.trim())
2444
+ return;
2445
+ try {
2446
+ const maintenance = JSON.parse(result.stdout.trim());
2447
+ if (!maintenance.enabled)
2448
+ return;
2449
+ const state = maintenance.state ?? "idle";
2450
+ const line = `Sync history maintenance: ${state}${maintenance.lastRun ? ` (last run ${maintenance.lastRun})` : ""}${maintenance.backlog !== undefined ? `, backlog ${maintenance.backlog}` : ""}`;
2451
+ if (state === "failed") {
2452
+ console.log(`⚠ ${line}${maintenance.lastError ? `: ${maintenance.lastError}` : ""}`);
2453
+ } else {
2454
+ console.log(` ${line}`);
2455
+ }
2456
+ } catch {}
2457
+ }
2458
+ async function logsCommand(directory, flags, positionals) {
2459
+ const loaded = await loadManaged(directory);
2460
+ assertDeploymentDirectoryIdentity(directory, loaded.state);
2461
+ if (positionals.length > 1)
2462
+ throw new Error("logs accepts at most one service name.");
2463
+ const tailValue = stringFlag(flags, "tail") ?? "200";
2464
+ const tail = Number(tailValue);
2465
+ if (!Number.isInteger(tail) || tail < 1)
2466
+ throw new Error("--tail must be a positive integer.");
2467
+ const service = positionals[0];
2468
+ const args = composeArgs(directory, loaded.state.mode, [
2469
+ "logs",
2470
+ "--no-color",
2471
+ "--tail",
2472
+ String(tail),
2473
+ ...service ? [service] : []
2474
+ ]);
2475
+ const result = await run("docker", [...args], directory);
2476
+ if (!result.ok)
2477
+ throw new Error(`${result.command}
2478
+ ${result.stderr}`);
2479
+ const secrets = secretValues(loaded.env);
2480
+ process.stdout.write(redact(result.stdout, secrets));
2481
+ if (result.stderr)
2482
+ process.stderr.write(redact(result.stderr, secrets));
2483
+ }
2484
+ async function startCommand(directory) {
2485
+ await ensureDocker();
2486
+ const loaded = await loadManaged(directory);
2487
+ assertDeploymentDirectoryIdentity(directory, loaded.state);
2488
+ await startProject(directory, loaded.state.mode, loaded.env);
2489
+ const url = loaded.state.mode === "public" ? `https://${loaded.state.domain}` : `http://127.0.0.1:${loaded.state.port}`;
2490
+ console.log(`OR3 started and is deeply healthy at ${url}.`);
2491
+ }
2492
+ async function stopCommand(directory) {
2493
+ await ensureDocker();
2494
+ const loaded = await loadManaged(directory);
2495
+ assertDeploymentDirectoryIdentity(directory, loaded.state);
2496
+ await stopProject(directory, loaded.state.mode);
2497
+ console.log("OR3 stopped. The data volume, backups, and managed state are retained.");
2498
+ }
2499
+ async function restartCommand(directory) {
2500
+ await ensureDocker();
2501
+ const loaded = await loadManaged(directory);
2502
+ assertDeploymentDirectoryIdentity(directory, loaded.state);
2503
+ await compose(directory, loaded.state.mode, ["restart", "or3"]);
2504
+ await waitForDeepHealth(directory, loaded.state.mode, secretValues(loaded.env));
2505
+ console.log("OR3 restarted and is deeply healthy.");
2506
+ }
2507
+ function purgeVolumesFromState(state) {
2508
+ const volumes = [state.volumeName];
2509
+ if (state.mode === "public") {
2510
+ if (state.caddyDataVolume)
2511
+ volumes.push(state.caddyDataVolume);
2512
+ if (state.caddyConfigVolume)
2513
+ volumes.push(state.caddyConfigVolume);
2514
+ }
2515
+ return volumes;
2516
+ }
2517
+ function assertPurgeBackupFreshness(backups, nowMs) {
2518
+ const cutoff = nowMs - PURGE_REQUIRES_BACKUP_WITHIN_MS;
2519
+ if (!backups.some((backup) => new Date(backup.createdAt).getTime() >= cutoff)) {
2520
+ throw new Error('No backup newer than 24 hours exists for this deployment. Run "npx @or3/cloud backup" and "npx @or3/cloud backup export <backup-id> <destination-dir>" before destroying local data.');
2521
+ }
2522
+ }
2523
+ async function readBackupExportReceipt(directory, backupId) {
2524
+ try {
2525
+ const receipt = JSON.parse(await readText(join(deploymentPaths(directory).exports, `${backupId}.json`)));
2526
+ if (receipt.schemaVersion !== 1 || receipt.backupId !== backupId || typeof receipt.destination !== "string" || !isAbsolute(receipt.destination) || typeof receipt.destinationDevice !== "number" || !receipt.dataSha256)
2527
+ return;
2528
+ return receipt;
2529
+ } catch {
2530
+ return;
2531
+ }
2532
+ }
2533
+ async function assertPurgeHasVerifiedExport(directory, backups, nowMs) {
2534
+ assertPurgeBackupFreshness(backups, nowMs);
2535
+ const cutoff = nowMs - PURGE_REQUIRES_BACKUP_WITHIN_MS;
2536
+ for (const backup of backups) {
2537
+ if (new Date(backup.createdAt).getTime() < cutoff)
2538
+ continue;
2539
+ const receipt = await readBackupExportReceipt(directory, backup.backupId);
2540
+ if (!receipt || !await fileExists(receipt.destination))
2541
+ continue;
2542
+ try {
2543
+ const exported = await readManifest(receipt.destination);
2544
+ const sourceDevice = (await stat(backup.path)).dev;
2545
+ const destinationDevice = (await stat(receipt.destination)).dev;
2546
+ if (sourceDevice !== destinationDevice && receipt.destinationDevice === destinationDevice && exported.backupId === backup.backupId && exported.dataSha256 === receipt.dataSha256 && exported.dataSha256 === backup.dataSha256 && exported.configSha256 === receipt.configSha256)
2547
+ return receipt;
2548
+ } catch {}
2549
+ }
2550
+ throw new Error("No fresh checksum-verified backup export on another filesystem is available. Run `npx @or3/cloud backup export <backup-id> <new-directory-on-a-mounted-backup-disk>` before purging local data.");
2551
+ }
2552
+ async function removeCommand(directory, flags) {
2553
+ if (boolFlag(flags, "purge-data") && !boolFlag(flags, "yes")) {
2554
+ throw new Error("--purge-data deletes the data volume, every backup, and the managed files. Re-run with --purge-data --yes after confirming the data-loss boundary.");
2555
+ }
2556
+ await ensureDocker();
2557
+ const loaded = await loadManaged(directory);
2558
+ assertDeploymentDirectoryIdentity(directory, loaded.state);
2559
+ assertNoPending(loaded.state);
2560
+ const { state } = loaded;
2561
+ if (!boolFlag(flags, "purge-data")) {
2562
+ await compose(directory, state.mode, ["down"]);
2563
+ console.log(`Removed runtime containers and network. Data volume, backups, configuration, and managed state are retained at ${directory}. Re-run \`npx @or3/cloud start\` to restore service.`);
2564
+ return;
2565
+ }
2566
+ const exportReceipt = await assertPurgeHasVerifiedExport(directory, await enumerateBackups(directory), Date.now());
2567
+ const volumes = purgeVolumesFromState(state);
2568
+ const managedFiles = [".env", ".or3-cloud", "compose.yaml", ...state.mode === "public" ? ["compose.public.yaml", "Caddyfile"] : [], ".or3-initial-credentials"];
2569
+ console.log("Removing exactly these targets:");
2570
+ for (const volume of volumes)
2571
+ console.log(` docker volume ${volume}`);
2572
+ for (const file of managedFiles)
2573
+ console.log(` ${join(directory, file)}`);
2574
+ await compose(directory, state.mode, ["down"]);
2575
+ for (const volume of volumes) {
2576
+ const result = await run("docker", ["volume", "rm", volume], directory);
2577
+ if (!result.ok)
2578
+ throw new Error(`${result.command}
2579
+ ${result.stderr}`);
2580
+ }
2581
+ for (const file of managedFiles) {
2582
+ await rm(join(directory, file), { recursive: true, force: true });
2583
+ }
2584
+ console.log(`Purged: data volumes, backups, .env, managed state, and compose files were deleted. The verified export at ${exportReceipt.destination} is the remaining copy.`);
2585
+ }
2586
+ async function probeDeepHealth(directory, mode) {
2587
+ const result = await run("docker", [...composeArgs(directory, mode, ["exec", "-T", "or3", CONTAINER_NODE, "-e", HEALTH_SCRIPT])], directory);
2588
+ if (result.ok)
2589
+ return "ok";
2590
+ const ps = await run("docker", [...composeArgs(directory, mode, ["ps", "-q", "or3"])], directory);
2591
+ if (ps.ok && ps.stdout.trim())
2592
+ return "degraded";
2593
+ return "unreachable";
2594
+ }
1665
2595
  async function main(argv = process.argv.slice(2)) {
1666
2596
  const [command = "help", ...rest] = argv;
1667
2597
  if (command === "--help" || command === "help")
@@ -1678,17 +2608,31 @@ async function main(argv = process.argv.slice(2)) {
1678
2608
  if (command === "update")
1679
2609
  return await updateCommand(process.cwd(), parsed.flags);
1680
2610
  if (command === "backup")
1681
- return await backupCommand(process.cwd());
2611
+ return await backupCommand(process.cwd(), parsed.positionals, parsed.flags);
1682
2612
  if (command === "restore")
1683
2613
  return await restoreCommand(process.cwd(), parsed.flags, parsed.positionals);
1684
2614
  if (command === "rollback")
1685
2615
  return await rollbackCommand(process.cwd(), parsed.flags);
2616
+ if (command === "credentials")
2617
+ return await credentialsCommand(process.cwd(), parsed.positionals, parsed.flags);
1686
2618
  if (command === "doctor")
1687
2619
  return await doctorCommand(process.cwd());
1688
2620
  if (command === "recover")
1689
2621
  return await recoverCommand(process.cwd());
1690
2622
  if (command === "adopt")
1691
2623
  return await adoptCommand(parsed.positionals, parsed.flags);
2624
+ if (command === "status")
2625
+ return await statusCommand(process.cwd());
2626
+ if (command === "logs")
2627
+ return await logsCommand(process.cwd(), parsed.flags, parsed.positionals);
2628
+ if (command === "start")
2629
+ return await startCommand(process.cwd());
2630
+ if (command === "stop")
2631
+ return await stopCommand(process.cwd());
2632
+ if (command === "restart")
2633
+ return await restartCommand(process.cwd());
2634
+ if (command === "remove")
2635
+ return await removeCommand(process.cwd(), parsed.flags);
1692
2636
  throw new Error(`Unknown command "${command}". Run npx @or3/cloud --help.`);
1693
2637
  } catch (error) {
1694
2638
  console.error(`
@@ -1701,16 +2645,27 @@ if (invokedAsCli || process.argv[1] && resolve(process.argv[1]) === resolve(file
1701
2645
  await main();
1702
2646
  }
1703
2647
  export {
2648
+ validatePassword,
2649
+ supportedImageArchitectures,
1704
2650
  stateFromEnv,
2651
+ serializeInitialCredentials,
1705
2652
  serializeEnv,
2653
+ selectPruneTargets,
2654
+ requiredArchiveSpace,
1706
2655
  redact,
2656
+ purgeVolumesFromState,
1707
2657
  parseFlags,
1708
2658
  parseEnv,
1709
2659
  isVersion,
1710
2660
  checkResolvedLoopbackBinding,
1711
2661
  buildEnv,
2662
+ buildCredentialsResetScript,
1712
2663
  assertSupportedSourceCompose,
1713
2664
  assertSupportedSource,
2665
+ assertSupportedArchitecture,
2666
+ assertRemovableArtifactName,
2667
+ assertPurgeBackupFreshness,
2668
+ assertEnoughFreeSpace,
1714
2669
  assertCommandFlags,
1715
2670
  assertBackupMatchesDeployment,
1716
2671
  PACKAGE_VERSION,