clawdlets 0.2.2 → 0.3.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/dist/main.mjs +4589 -0
- package/{dist → node_modules/@clawdlets/core/dist}/lib/context.d.ts +2 -2
- package/{dist → node_modules/@clawdlets/core/dist}/lib/context.d.ts.map +1 -1
- package/{dist → node_modules/@clawdlets/core/dist}/lib/context.js +2 -2
- package/node_modules/@clawdlets/core/dist/lib/context.js.map +1 -0
- package/{dist → node_modules/@clawdlets/core/dist}/lib/host-resolve.js +2 -2
- package/node_modules/@clawdlets/core/dist/lib/host-resolve.js.map +1 -0
- package/node_modules/@clawdlets/core/dist/repo-layout.d.ts +1 -0
- package/node_modules/@clawdlets/core/dist/repo-layout.d.ts.map +1 -1
- package/node_modules/@clawdlets/core/dist/repo-layout.js +2 -0
- package/node_modules/@clawdlets/core/dist/repo-layout.js.map +1 -1
- package/node_modules/@clawdlets/core/package.json +1 -3
- package/package.json +16 -16
- package/dist/commands/bootstrap.d.ts +0 -43
- package/dist/commands/bootstrap.d.ts.map +0 -1
- package/dist/commands/bootstrap.js +0 -318
- package/dist/commands/bootstrap.js.map +0 -1
- package/dist/commands/bot.d.ts +0 -2
- package/dist/commands/bot.d.ts.map +0 -1
- package/dist/commands/bot.js +0 -97
- package/dist/commands/bot.js.map +0 -1
- package/dist/commands/cattle/common.d.ts +0 -29
- package/dist/commands/cattle/common.d.ts.map +0 -1
- package/dist/commands/cattle/common.js +0 -102
- package/dist/commands/cattle/common.js.map +0 -1
- package/dist/commands/cattle/destroy.d.ts +0 -33
- package/dist/commands/cattle/destroy.d.ts.map +0 -1
- package/dist/commands/cattle/destroy.js +0 -72
- package/dist/commands/cattle/destroy.js.map +0 -1
- package/dist/commands/cattle/list.d.ts +0 -20
- package/dist/commands/cattle/list.d.ts.map +0 -1
- package/dist/commands/cattle/list.js +0 -78
- package/dist/commands/cattle/list.js.map +0 -1
- package/dist/commands/cattle/logs.d.ts +0 -34
- package/dist/commands/cattle/logs.d.ts.map +0 -1
- package/dist/commands/cattle/logs.js +0 -55
- package/dist/commands/cattle/logs.js.map +0 -1
- package/dist/commands/cattle/persona.d.ts +0 -2
- package/dist/commands/cattle/persona.d.ts.map +0 -1
- package/dist/commands/cattle/persona.js +0 -85
- package/dist/commands/cattle/persona.js.map +0 -1
- package/dist/commands/cattle/reap.d.ts +0 -20
- package/dist/commands/cattle/reap.d.ts.map +0 -1
- package/dist/commands/cattle/reap.js +0 -60
- package/dist/commands/cattle/reap.js.map +0 -1
- package/dist/commands/cattle/spawn.d.ts +0 -73
- package/dist/commands/cattle/spawn.d.ts.map +0 -1
- package/dist/commands/cattle/spawn.js +0 -147
- package/dist/commands/cattle/spawn.js.map +0 -1
- package/dist/commands/cattle/ssh.d.ts +0 -20
- package/dist/commands/cattle/ssh.d.ts.map +0 -1
- package/dist/commands/cattle/ssh.js +0 -37
- package/dist/commands/cattle/ssh.js.map +0 -1
- package/dist/commands/cattle.d.ts +0 -2
- package/dist/commands/cattle.d.ts.map +0 -1
- package/dist/commands/cattle.js +0 -21
- package/dist/commands/cattle.js.map +0 -1
- package/dist/commands/config.d.ts +0 -2
- package/dist/commands/config.d.ts.map +0 -1
- package/dist/commands/config.js +0 -163
- package/dist/commands/config.js.map +0 -1
- package/dist/commands/doctor.d.ts +0 -35
- package/dist/commands/doctor.d.ts.map +0 -1
- package/dist/commands/doctor.js +0 -65
- package/dist/commands/doctor.js.map +0 -1
- package/dist/commands/env.d.ts +0 -22
- package/dist/commands/env.d.ts.map +0 -1
- package/dist/commands/env.js +0 -132
- package/dist/commands/env.js.map +0 -1
- package/dist/commands/fleet.d.ts +0 -2
- package/dist/commands/fleet.d.ts.map +0 -1
- package/dist/commands/fleet.js +0 -61
- package/dist/commands/fleet.js.map +0 -1
- package/dist/commands/host.d.ts +0 -2
- package/dist/commands/host.d.ts.map +0 -1
- package/dist/commands/host.js +0 -277
- package/dist/commands/host.js.map +0 -1
- package/dist/commands/image.d.ts +0 -2
- package/dist/commands/image.d.ts.map +0 -1
- package/dist/commands/image.js +0 -133
- package/dist/commands/image.js.map +0 -1
- package/dist/commands/infra.d.ts +0 -2
- package/dist/commands/infra.d.ts.map +0 -1
- package/dist/commands/infra.js +0 -171
- package/dist/commands/infra.js.map +0 -1
- package/dist/commands/lockdown.d.ts +0 -25
- package/dist/commands/lockdown.d.ts.map +0 -1
- package/dist/commands/lockdown.js +0 -93
- package/dist/commands/lockdown.js.map +0 -1
- package/dist/commands/project.d.ts +0 -2
- package/dist/commands/project.d.ts.map +0 -1
- package/dist/commands/project.js +0 -264
- package/dist/commands/project.js.map +0 -1
- package/dist/commands/secrets/common.d.ts +0 -8
- package/dist/commands/secrets/common.d.ts.map +0 -1
- package/dist/commands/secrets/common.js +0 -20
- package/dist/commands/secrets/common.js.map +0 -1
- package/dist/commands/secrets/init.d.ts +0 -39
- package/dist/commands/secrets/init.d.ts.map +0 -1
- package/dist/commands/secrets/init.js +0 -455
- package/dist/commands/secrets/init.js.map +0 -1
- package/dist/commands/secrets/path.d.ts +0 -11
- package/dist/commands/secrets/path.d.ts.map +0 -1
- package/dist/commands/secrets/path.js +0 -24
- package/dist/commands/secrets/path.js.map +0 -1
- package/dist/commands/secrets/sync.d.ts +0 -25
- package/dist/commands/secrets/sync.d.ts.map +0 -1
- package/dist/commands/secrets/sync.js +0 -67
- package/dist/commands/secrets/sync.js.map +0 -1
- package/dist/commands/secrets/verify.d.ts +0 -28
- package/dist/commands/secrets/verify.d.ts.map +0 -1
- package/dist/commands/secrets/verify.js +0 -118
- package/dist/commands/secrets/verify.js.map +0 -1
- package/dist/commands/secrets.d.ts +0 -2
- package/dist/commands/secrets.d.ts.map +0 -1
- package/dist/commands/secrets.js +0 -18
- package/dist/commands/secrets.js.map +0 -1
- package/dist/commands/server/common.d.ts +0 -3
- package/dist/commands/server/common.d.ts.map +0 -1
- package/dist/commands/server/common.js +0 -3
- package/dist/commands/server/common.js.map +0 -1
- package/dist/commands/server/deploy.d.ts +0 -53
- package/dist/commands/server/deploy.d.ts.map +0 -1
- package/dist/commands/server/deploy.js +0 -177
- package/dist/commands/server/deploy.js.map +0 -1
- package/dist/commands/server/github-sync.d.ts +0 -2
- package/dist/commands/server/github-sync.d.ts.map +0 -1
- package/dist/commands/server/github-sync.js +0 -166
- package/dist/commands/server/github-sync.js.map +0 -1
- package/dist/commands/server/manifest.d.ts +0 -28
- package/dist/commands/server/manifest.d.ts.map +0 -1
- package/dist/commands/server/manifest.js +0 -82
- package/dist/commands/server/manifest.js.map +0 -1
- package/dist/commands/server.d.ts +0 -2
- package/dist/commands/server.d.ts.map +0 -1
- package/dist/commands/server.js +0 -267
- package/dist/commands/server.js.map +0 -1
- package/dist/commands/ssh-target.d.ts +0 -3
- package/dist/commands/ssh-target.d.ts.map +0 -1
- package/dist/commands/ssh-target.js +0 -15
- package/dist/commands/ssh-target.js.map +0 -1
- package/dist/lib/context.js.map +0 -1
- package/dist/lib/deploy-gate.d.ts +0 -9
- package/dist/lib/deploy-gate.d.ts.map +0 -1
- package/dist/lib/deploy-gate.js +0 -20
- package/dist/lib/deploy-gate.js.map +0 -1
- package/dist/lib/deploy-manifest.d.ts +0 -11
- package/dist/lib/deploy-manifest.d.ts.map +0 -1
- package/dist/lib/deploy-manifest.js +0 -46
- package/dist/lib/deploy-manifest.js.map +0 -1
- package/dist/lib/doctor-render.d.ts +0 -14
- package/dist/lib/doctor-render.d.ts.map +0 -1
- package/dist/lib/doctor-render.js +0 -131
- package/dist/lib/doctor-render.js.map +0 -1
- package/dist/lib/host-resolve.js.map +0 -1
- package/dist/lib/linux-build.d.ts +0 -8
- package/dist/lib/linux-build.d.ts.map +0 -1
- package/dist/lib/linux-build.js +0 -15
- package/dist/lib/linux-build.js.map +0 -1
- package/dist/lib/manifest-signature.d.ts +0 -17
- package/dist/lib/manifest-signature.d.ts.map +0 -1
- package/dist/lib/manifest-signature.js +0 -52
- package/dist/lib/manifest-signature.js.map +0 -1
- package/dist/lib/template-spec.d.ts +0 -9
- package/dist/lib/template-spec.d.ts.map +0 -1
- package/dist/lib/template-spec.js +0 -50
- package/dist/lib/template-spec.js.map +0 -1
- package/dist/lib/version.d.ts +0 -3
- package/dist/lib/version.d.ts.map +0 -1
- package/dist/lib/version.js +0 -17
- package/dist/lib/version.js.map +0 -1
- package/dist/lib/wizard.d.ts +0 -10
- package/dist/lib/wizard.d.ts.map +0 -1
- package/dist/lib/wizard.js +0 -25
- package/dist/lib/wizard.js.map +0 -1
- package/dist/main.d.ts +0 -3
- package/dist/main.d.ts.map +0 -1
- package/dist/main.js +0 -50
- package/dist/main.js.map +0 -1
- package/node_modules/@clawdlets/clf-queue/dist/client.d.ts +0 -21
- package/node_modules/@clawdlets/clf-queue/dist/client.d.ts.map +0 -1
- package/node_modules/@clawdlets/clf-queue/dist/client.js +0 -132
- package/node_modules/@clawdlets/clf-queue/dist/client.js.map +0 -1
- package/node_modules/@clawdlets/clf-queue/dist/index.d.ts +0 -9
- package/node_modules/@clawdlets/clf-queue/dist/index.d.ts.map +0 -1
- package/node_modules/@clawdlets/clf-queue/dist/index.js +0 -5
- package/node_modules/@clawdlets/clf-queue/dist/index.js.map +0 -1
- package/node_modules/@clawdlets/clf-queue/dist/jobs.d.ts +0 -32
- package/node_modules/@clawdlets/clf-queue/dist/jobs.d.ts.map +0 -1
- package/node_modules/@clawdlets/clf-queue/dist/jobs.js +0 -24
- package/node_modules/@clawdlets/clf-queue/dist/jobs.js.map +0 -1
- package/node_modules/@clawdlets/clf-queue/dist/protocol.d.ts +0 -118
- package/node_modules/@clawdlets/clf-queue/dist/protocol.d.ts.map +0 -1
- package/node_modules/@clawdlets/clf-queue/dist/protocol.js +0 -46
- package/node_modules/@clawdlets/clf-queue/dist/protocol.js.map +0 -1
- package/node_modules/@clawdlets/clf-queue/dist/queue/bootstrap-tokens.d.ts +0 -3
- package/node_modules/@clawdlets/clf-queue/dist/queue/bootstrap-tokens.d.ts.map +0 -1
- package/node_modules/@clawdlets/clf-queue/dist/queue/bootstrap-tokens.js +0 -112
- package/node_modules/@clawdlets/clf-queue/dist/queue/bootstrap-tokens.js.map +0 -1
- package/node_modules/@clawdlets/clf-queue/dist/queue/jobs.d.ts +0 -3
- package/node_modules/@clawdlets/clf-queue/dist/queue/jobs.d.ts.map +0 -1
- package/node_modules/@clawdlets/clf-queue/dist/queue/jobs.js +0 -313
- package/node_modules/@clawdlets/clf-queue/dist/queue/jobs.js.map +0 -1
- package/node_modules/@clawdlets/clf-queue/dist/queue/migrate.d.ts +0 -2
- package/node_modules/@clawdlets/clf-queue/dist/queue/migrate.d.ts.map +0 -1
- package/node_modules/@clawdlets/clf-queue/dist/queue/migrate.js +0 -74
- package/node_modules/@clawdlets/clf-queue/dist/queue/migrate.js.map +0 -1
- package/node_modules/@clawdlets/clf-queue/dist/queue/open.d.ts +0 -3
- package/node_modules/@clawdlets/clf-queue/dist/queue/open.d.ts.map +0 -1
- package/node_modules/@clawdlets/clf-queue/dist/queue/open.js +0 -27
- package/node_modules/@clawdlets/clf-queue/dist/queue/open.js.map +0 -1
- package/node_modules/@clawdlets/clf-queue/dist/queue/types.d.ts +0 -113
- package/node_modules/@clawdlets/clf-queue/dist/queue/types.d.ts.map +0 -1
- package/node_modules/@clawdlets/clf-queue/dist/queue/types.js +0 -2
- package/node_modules/@clawdlets/clf-queue/dist/queue/types.js.map +0 -1
- package/node_modules/@clawdlets/clf-queue/dist/queue/util.d.ts +0 -10
- package/node_modules/@clawdlets/clf-queue/dist/queue/util.d.ts.map +0 -1
- package/node_modules/@clawdlets/clf-queue/dist/queue/util.js +0 -30
- package/node_modules/@clawdlets/clf-queue/dist/queue/util.js.map +0 -1
- package/node_modules/@clawdlets/clf-queue/dist/queue.d.ts +0 -3
- package/node_modules/@clawdlets/clf-queue/dist/queue.d.ts.map +0 -1
- package/node_modules/@clawdlets/clf-queue/dist/queue.js +0 -2
- package/node_modules/@clawdlets/clf-queue/dist/queue.js.map +0 -1
- package/node_modules/@clawdlets/clf-queue/package.json +0 -34
- package/node_modules/@clawdlets/core/dist/lib/cattle-state.d.ts +0 -25
- package/node_modules/@clawdlets/core/dist/lib/cattle-state.d.ts.map +0 -1
- package/node_modules/@clawdlets/core/dist/lib/cattle-state.js +0 -136
- package/node_modules/@clawdlets/core/dist/lib/cattle-state.js.map +0 -1
- package/node_modules/better-sqlite3/LICENSE +0 -21
- package/node_modules/better-sqlite3/README.md +0 -99
- package/node_modules/better-sqlite3/binding.gyp +0 -38
- package/node_modules/better-sqlite3/deps/common.gypi +0 -68
- package/node_modules/better-sqlite3/deps/copy.js +0 -31
- package/node_modules/better-sqlite3/deps/defines.gypi +0 -41
- package/node_modules/better-sqlite3/deps/download.sh +0 -122
- package/node_modules/better-sqlite3/deps/patches/1208.patch +0 -15
- package/node_modules/better-sqlite3/deps/sqlite3/sqlite3.c +0 -265969
- package/node_modules/better-sqlite3/deps/sqlite3/sqlite3.h +0 -13968
- package/node_modules/better-sqlite3/deps/sqlite3/sqlite3ext.h +0 -730
- package/node_modules/better-sqlite3/deps/sqlite3.gyp +0 -80
- package/node_modules/better-sqlite3/deps/test_extension.c +0 -21
- package/node_modules/better-sqlite3/lib/database.js +0 -90
- package/node_modules/better-sqlite3/lib/index.js +0 -3
- package/node_modules/better-sqlite3/lib/methods/aggregate.js +0 -43
- package/node_modules/better-sqlite3/lib/methods/backup.js +0 -67
- package/node_modules/better-sqlite3/lib/methods/function.js +0 -31
- package/node_modules/better-sqlite3/lib/methods/inspect.js +0 -7
- package/node_modules/better-sqlite3/lib/methods/pragma.js +0 -12
- package/node_modules/better-sqlite3/lib/methods/serialize.js +0 -16
- package/node_modules/better-sqlite3/lib/methods/table.js +0 -189
- package/node_modules/better-sqlite3/lib/methods/transaction.js +0 -78
- package/node_modules/better-sqlite3/lib/methods/wrappers.js +0 -54
- package/node_modules/better-sqlite3/lib/sqlite-error.js +0 -20
- package/node_modules/better-sqlite3/lib/util.js +0 -12
- package/node_modules/better-sqlite3/package.json +0 -59
- package/node_modules/better-sqlite3/src/addon.cpp +0 -47
- package/node_modules/better-sqlite3/src/better_sqlite3.cpp +0 -74
- package/node_modules/better-sqlite3/src/objects/backup.cpp +0 -120
- package/node_modules/better-sqlite3/src/objects/backup.hpp +0 -36
- package/node_modules/better-sqlite3/src/objects/database.cpp +0 -417
- package/node_modules/better-sqlite3/src/objects/database.hpp +0 -103
- package/node_modules/better-sqlite3/src/objects/statement-iterator.cpp +0 -113
- package/node_modules/better-sqlite3/src/objects/statement-iterator.hpp +0 -50
- package/node_modules/better-sqlite3/src/objects/statement.cpp +0 -383
- package/node_modules/better-sqlite3/src/objects/statement.hpp +0 -58
- package/node_modules/better-sqlite3/src/util/bind-map.cpp +0 -73
- package/node_modules/better-sqlite3/src/util/binder.cpp +0 -193
- package/node_modules/better-sqlite3/src/util/constants.cpp +0 -172
- package/node_modules/better-sqlite3/src/util/custom-aggregate.cpp +0 -121
- package/node_modules/better-sqlite3/src/util/custom-function.cpp +0 -59
- package/node_modules/better-sqlite3/src/util/custom-table.cpp +0 -409
- package/node_modules/better-sqlite3/src/util/data-converter.cpp +0 -17
- package/node_modules/better-sqlite3/src/util/data.cpp +0 -194
- package/node_modules/better-sqlite3/src/util/helpers.cpp +0 -109
- package/node_modules/better-sqlite3/src/util/macros.cpp +0 -70
- package/node_modules/better-sqlite3/src/util/query-macros.cpp +0 -71
- package/node_modules/better-sqlite3/src/util/row-builder.cpp +0 -49
- /package/{dist → node_modules/@clawdlets/core/dist}/lib/host-resolve.d.ts +0 -0
- /package/{dist → node_modules/@clawdlets/core/dist}/lib/host-resolve.d.ts.map +0 -0
package/dist/main.mjs
ADDED
|
@@ -0,0 +1,4589 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { defineCommand, runMain } from "citty";
|
|
3
|
+
import process$1 from "node:process";
|
|
4
|
+
import * as p from "@clack/prompts";
|
|
5
|
+
import { findRepoRoot } from "@clawdlets/core/lib/repo";
|
|
6
|
+
import { ClawdletsConfigSchema, SSH_EXPOSURE_MODES, assertSafeHostName, createDefaultClawdletsConfig, getSshExposureMode, getTailnetMode, loadClawdletsConfig, loadClawdletsConfigRaw, resolveHostName, writeClawdletsConfig } from "@clawdlets/core/lib/clawdlets-config";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { applyOpenTofuVars, destroyOpenTofuVars } from "@clawdlets/core/lib/opentofu";
|
|
10
|
+
import { resolveGitRev } from "@clawdlets/core/lib/git";
|
|
11
|
+
import { capture, run } from "@clawdlets/core/lib/run";
|
|
12
|
+
import { checkGithubRepoVisibility, tryParseGithubFlakeUri } from "@clawdlets/core/lib/github";
|
|
13
|
+
import { loadDeployCreds } from "@clawdlets/core/lib/deploy-creds";
|
|
14
|
+
import { expandPath } from "@clawdlets/core/lib/path-expand";
|
|
15
|
+
import { buildFleetSecretsPlan } from "@clawdlets/core/lib/fleet-secrets";
|
|
16
|
+
import { withFlakesEnv } from "@clawdlets/core/lib/nix-flakes";
|
|
17
|
+
import { resolveBaseFlake } from "@clawdlets/core/lib/base-flake";
|
|
18
|
+
import { getHostEncryptedAgeKeyFile, getHostExtraFilesDir, getHostExtraFilesKeyPath, getHostExtraFilesSecretsDir, getHostOpenTofuDir, getHostRemoteSecretsDir, getHostSecretsDir, getLocalOperatorAgeKeyPath, getRepoLayout } from "@clawdlets/core/repo-layout";
|
|
19
|
+
import { collectDoctorChecks } from "@clawdlets/core/doctor";
|
|
20
|
+
import { resolveHostNameOrExit } from "@clawdlets/core/lib/host-resolve";
|
|
21
|
+
import { ensureDir, writeFileAtomic } from "@clawdlets/core/lib/fs-safe";
|
|
22
|
+
import { splitDotPath } from "@clawdlets/core/lib/dot-path";
|
|
23
|
+
import { formatDotenvValue, parseDotenv } from "@clawdlets/core/lib/dotenv-file";
|
|
24
|
+
import { looksLikeSshPrivateKey, parseSshPublicKeysFromText } from "@clawdlets/core/lib/ssh";
|
|
25
|
+
import { shellQuote, sshCapture, sshRun, validateTargetHost } from "@clawdlets/core/lib/ssh-remote";
|
|
26
|
+
import { loadHostContextOrExit } from "@clawdlets/core/lib/context";
|
|
27
|
+
import { tmpdir } from "node:os";
|
|
28
|
+
import { downloadTemplate } from "giget";
|
|
29
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
30
|
+
import { normalizeTemplateSource } from "@clawdlets/core/lib/template-source";
|
|
31
|
+
import { ageKeygen } from "@clawdlets/core/lib/age-keygen";
|
|
32
|
+
import { parseAgeKeyFile } from "@clawdlets/core/lib/age";
|
|
33
|
+
import { mkpasswdYescryptHash } from "@clawdlets/core/lib/mkpasswd";
|
|
34
|
+
import { upsertSopsCreationRule } from "@clawdlets/core/lib/sops-config";
|
|
35
|
+
import { sopsDecryptYamlFile, sopsEncryptYamlToFile } from "@clawdlets/core/lib/sops";
|
|
36
|
+
import { getHostAgeKeySopsCreationRulePathRegex, getHostSecretsSopsCreationRulePathRegex } from "@clawdlets/core/lib/sops-rules";
|
|
37
|
+
import { sanitizeOperatorId } from "@clawdlets/core/lib/identifiers";
|
|
38
|
+
import { buildSecretsInitTemplate, isPlaceholderSecretValue, listSecretsInitPlaceholders, parseSecretsInitJson, resolveSecretsInitFromJsonArg, validateSecretsInitNonInteractive } from "@clawdlets/core/lib/secrets-init";
|
|
39
|
+
import { readYamlScalarFromMapping } from "@clawdlets/core/lib/yaml-scalar";
|
|
40
|
+
import { createSecretsTar } from "@clawdlets/core/lib/secrets-tar";
|
|
41
|
+
import YAML from "yaml";
|
|
42
|
+
|
|
43
|
+
//#region rolldown:runtime
|
|
44
|
+
var __defProp = Object.defineProperty;
|
|
45
|
+
var __exportAll = (all, symbols) => {
|
|
46
|
+
let target = {};
|
|
47
|
+
for (var name in all) {
|
|
48
|
+
__defProp(target, name, {
|
|
49
|
+
get: all[name],
|
|
50
|
+
enumerable: true
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
if (symbols) {
|
|
54
|
+
__defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
55
|
+
}
|
|
56
|
+
return target;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/lib/wizard.ts
|
|
61
|
+
const NAV_BACK = Symbol("clawdlets.nav.back");
|
|
62
|
+
const NAV_EXIT = Symbol("clawdlets.nav.exit");
|
|
63
|
+
async function navOnCancel(params) {
|
|
64
|
+
const flow = params.flow.trim() || "setup";
|
|
65
|
+
const options = [];
|
|
66
|
+
if (params.canBack) options.push({
|
|
67
|
+
value: "back",
|
|
68
|
+
label: "Back"
|
|
69
|
+
});
|
|
70
|
+
options.push({
|
|
71
|
+
value: "exit",
|
|
72
|
+
label: `Exit ${flow}`
|
|
73
|
+
});
|
|
74
|
+
const choice = await p.select({
|
|
75
|
+
message: "Canceled. Next?",
|
|
76
|
+
initialValue: params.canBack ? "back" : "exit",
|
|
77
|
+
options
|
|
78
|
+
});
|
|
79
|
+
if (p.isCancel(choice) || choice === "exit") return NAV_EXIT;
|
|
80
|
+
return NAV_BACK;
|
|
81
|
+
}
|
|
82
|
+
function cancelFlow() {
|
|
83
|
+
p.cancel("canceled");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
//#endregion
|
|
87
|
+
//#region src/commands/bot.ts
|
|
88
|
+
function validateBotId(value) {
|
|
89
|
+
const v = value.trim();
|
|
90
|
+
if (!v) return "bot id required";
|
|
91
|
+
if (!/^[a-z][a-z0-9_-]*$/.test(v)) return "use: [a-z][a-z0-9_-]*";
|
|
92
|
+
}
|
|
93
|
+
const list$1 = defineCommand({
|
|
94
|
+
meta: {
|
|
95
|
+
name: "list",
|
|
96
|
+
description: "List bots (from fleet/clawdlets.json)."
|
|
97
|
+
},
|
|
98
|
+
args: {},
|
|
99
|
+
async run({ args }) {
|
|
100
|
+
const { config: config$1 } = loadClawdletsConfig({ repoRoot: findRepoRoot(process$1.cwd()) });
|
|
101
|
+
console.log((config$1.fleet.botOrder || []).join("\n"));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
const add$2 = defineCommand({
|
|
105
|
+
meta: {
|
|
106
|
+
name: "add",
|
|
107
|
+
description: "Add a bot id to fleet/clawdlets.json."
|
|
108
|
+
},
|
|
109
|
+
args: {
|
|
110
|
+
bot: {
|
|
111
|
+
type: "string",
|
|
112
|
+
description: "Bot id (e.g. maren)."
|
|
113
|
+
},
|
|
114
|
+
interactive: {
|
|
115
|
+
type: "boolean",
|
|
116
|
+
description: "Prompt for missing inputs (requires TTY).",
|
|
117
|
+
default: false
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
async run({ args }) {
|
|
121
|
+
const { configPath, config: config$1 } = loadClawdletsConfig({ repoRoot: findRepoRoot(process$1.cwd()) });
|
|
122
|
+
let botId = String(args.bot || "").trim();
|
|
123
|
+
if (!botId) {
|
|
124
|
+
if (!args.interactive) throw new Error("missing --bot (or pass --interactive)");
|
|
125
|
+
if (!process$1.stdout.isTTY) throw new Error("--interactive requires a TTY");
|
|
126
|
+
p.intro("clawdlets bot add");
|
|
127
|
+
const v = await p.text({
|
|
128
|
+
message: "Bot id",
|
|
129
|
+
placeholder: "maren",
|
|
130
|
+
validate: validateBotId
|
|
131
|
+
});
|
|
132
|
+
if (p.isCancel(v)) {
|
|
133
|
+
if (await navOnCancel({
|
|
134
|
+
flow: "bot add",
|
|
135
|
+
canBack: false
|
|
136
|
+
}) === NAV_EXIT) cancelFlow();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
botId = String(v).trim();
|
|
140
|
+
}
|
|
141
|
+
const err = validateBotId(botId);
|
|
142
|
+
if (err) throw new Error(err);
|
|
143
|
+
const existingBots = config$1.fleet.botOrder;
|
|
144
|
+
if (existingBots.includes(botId) || config$1.fleet.bots[botId]) {
|
|
145
|
+
console.log(`ok: already present: ${botId}`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const next = {
|
|
149
|
+
...config$1,
|
|
150
|
+
fleet: {
|
|
151
|
+
...config$1.fleet,
|
|
152
|
+
botOrder: [...existingBots, botId],
|
|
153
|
+
bots: {
|
|
154
|
+
...config$1.fleet.bots,
|
|
155
|
+
[botId]: {}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
await writeClawdletsConfig({
|
|
160
|
+
configPath,
|
|
161
|
+
config: ClawdletsConfigSchema.parse(next)
|
|
162
|
+
});
|
|
163
|
+
console.log(`ok: added bot ${botId}`);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
const rm$1 = defineCommand({
|
|
167
|
+
meta: {
|
|
168
|
+
name: "rm",
|
|
169
|
+
description: "Remove a bot id from fleet/clawdlets.json."
|
|
170
|
+
},
|
|
171
|
+
args: { bot: {
|
|
172
|
+
type: "string",
|
|
173
|
+
description: "Bot id to remove."
|
|
174
|
+
} },
|
|
175
|
+
async run({ args }) {
|
|
176
|
+
const { configPath, config: config$1 } = loadClawdletsConfig({ repoRoot: findRepoRoot(process$1.cwd()) });
|
|
177
|
+
const botId = String(args.bot || "").trim();
|
|
178
|
+
if (!botId) throw new Error("missing --bot");
|
|
179
|
+
const existingBots = config$1.fleet.botOrder;
|
|
180
|
+
if (!existingBots.includes(botId) && !config$1.fleet.bots[botId]) throw new Error(`bot not found: ${botId}`);
|
|
181
|
+
const nextBots = existingBots.filter((b) => b !== botId);
|
|
182
|
+
const nextBotsRecord = { ...config$1.fleet.bots };
|
|
183
|
+
delete nextBotsRecord[botId];
|
|
184
|
+
const next = {
|
|
185
|
+
...config$1,
|
|
186
|
+
fleet: {
|
|
187
|
+
...config$1.fleet,
|
|
188
|
+
botOrder: nextBots,
|
|
189
|
+
bots: nextBotsRecord
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
await writeClawdletsConfig({
|
|
193
|
+
configPath,
|
|
194
|
+
config: ClawdletsConfigSchema.parse(next)
|
|
195
|
+
});
|
|
196
|
+
console.log(`ok: removed bot ${botId}`);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
const bot = defineCommand({
|
|
200
|
+
meta: {
|
|
201
|
+
name: "bot",
|
|
202
|
+
description: "Manage fleet bots."
|
|
203
|
+
},
|
|
204
|
+
subCommands: {
|
|
205
|
+
add: add$2,
|
|
206
|
+
list: list$1,
|
|
207
|
+
rm: rm$1
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
//#endregion
|
|
212
|
+
//#region src/lib/doctor-render.ts
|
|
213
|
+
const STATUS_ORDER = {
|
|
214
|
+
missing: 0,
|
|
215
|
+
warn: 1,
|
|
216
|
+
ok: 2
|
|
217
|
+
};
|
|
218
|
+
const SCOPE_ORDER = {
|
|
219
|
+
repo: 0,
|
|
220
|
+
bootstrap: 1,
|
|
221
|
+
"server-deploy": 2,
|
|
222
|
+
cattle: 3
|
|
223
|
+
};
|
|
224
|
+
function supportsColor(out) {
|
|
225
|
+
if (!out.isTTY) return false;
|
|
226
|
+
if (process$1.env.NO_COLOR != null) return false;
|
|
227
|
+
if (process$1.env.TERM === "dumb") return false;
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
function colorize(params) {
|
|
231
|
+
if (!params.enabled) return params.s;
|
|
232
|
+
return `\x1b[${params.code}m${params.s}\x1b[0m`;
|
|
233
|
+
}
|
|
234
|
+
function formatStatusTag(status, opts) {
|
|
235
|
+
if (status === "ok") return colorize({
|
|
236
|
+
enabled: opts.color,
|
|
237
|
+
code: 32,
|
|
238
|
+
s: "[OK]"
|
|
239
|
+
});
|
|
240
|
+
if (status === "warn") return colorize({
|
|
241
|
+
enabled: opts.color,
|
|
242
|
+
code: 33,
|
|
243
|
+
s: "[WARN]"
|
|
244
|
+
});
|
|
245
|
+
return colorize({
|
|
246
|
+
enabled: opts.color,
|
|
247
|
+
code: 31,
|
|
248
|
+
s: "[MISSING]"
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
function bold(s, opts) {
|
|
252
|
+
return colorize({
|
|
253
|
+
enabled: opts.color,
|
|
254
|
+
code: 1,
|
|
255
|
+
s
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
function categoryForLabel(label) {
|
|
259
|
+
const l = label.toLowerCase();
|
|
260
|
+
if (l.includes("public repo hygiene") || l.includes("inline scripting") || l.includes("docs index") || l.includes("bundled skills")) return "repo hygiene";
|
|
261
|
+
if (l.includes("fleet") || l.includes("guild") || l.includes("discord") || l.includes("routing")) return "fleet / discord";
|
|
262
|
+
if (l.includes("sops") || l.includes("secret") || l.includes("envsecrets") || l.includes("llm api")) return "secrets";
|
|
263
|
+
if (l.includes("deploy env file") || l.includes("env file")) return "infra";
|
|
264
|
+
if (l.includes("hetzner") || l.includes("provisioning") || l.includes("opentofu") || l.includes("hcloud") || l.includes("nixos-anywhere")) return "infra";
|
|
265
|
+
if (l.includes("github_token") || l.includes("base flake")) return "github";
|
|
266
|
+
if (l.includes("ssh") || l.includes("targethost") || l.includes("authorizedkeys")) return "ssh";
|
|
267
|
+
if (l.startsWith("nix")) return "nix";
|
|
268
|
+
return "other";
|
|
269
|
+
}
|
|
270
|
+
function groupChecks(params) {
|
|
271
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
272
|
+
for (const c of params.checks) {
|
|
273
|
+
if (!params.showOk && c.status === "ok") continue;
|
|
274
|
+
const category = categoryForLabel(c.label);
|
|
275
|
+
const key = `${c.scope}:${category}`;
|
|
276
|
+
const existing = byKey.get(key);
|
|
277
|
+
if (existing) {
|
|
278
|
+
existing.checks.push(c);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
byKey.set(key, {
|
|
282
|
+
scope: c.scope,
|
|
283
|
+
category,
|
|
284
|
+
checks: [c]
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
const groups = Array.from(byKey.values()).map((g) => {
|
|
288
|
+
const worst = g.checks.reduce((acc, c) => STATUS_ORDER[c.status] < STATUS_ORDER[acc] ? c.status : acc, "ok");
|
|
289
|
+
g.checks.sort((a, b) => {
|
|
290
|
+
const d = STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
|
|
291
|
+
if (d !== 0) return d;
|
|
292
|
+
return a.label.localeCompare(b.label);
|
|
293
|
+
});
|
|
294
|
+
return {
|
|
295
|
+
...g,
|
|
296
|
+
worst
|
|
297
|
+
};
|
|
298
|
+
});
|
|
299
|
+
groups.sort((a, b) => {
|
|
300
|
+
const scopeOrder = SCOPE_ORDER[a.scope] - SCOPE_ORDER[b.scope];
|
|
301
|
+
if (scopeOrder !== 0) return scopeOrder;
|
|
302
|
+
const worstOrder = STATUS_ORDER[a.worst] - STATUS_ORDER[b.worst];
|
|
303
|
+
if (worstOrder !== 0) return worstOrder;
|
|
304
|
+
return a.category.localeCompare(b.category);
|
|
305
|
+
});
|
|
306
|
+
return groups;
|
|
307
|
+
}
|
|
308
|
+
function renderDoctorReport(params) {
|
|
309
|
+
const color = supportsColor(process$1.stdout);
|
|
310
|
+
const counts = params.checks.reduce((acc, c) => {
|
|
311
|
+
acc[c.status] += 1;
|
|
312
|
+
return acc;
|
|
313
|
+
}, {
|
|
314
|
+
ok: 0,
|
|
315
|
+
warn: 0,
|
|
316
|
+
missing: 0
|
|
317
|
+
});
|
|
318
|
+
const groups = groupChecks({
|
|
319
|
+
checks: params.checks,
|
|
320
|
+
showOk: params.showOk
|
|
321
|
+
});
|
|
322
|
+
const lines = [];
|
|
323
|
+
lines.push(`doctor: host=${params.host} scope=${params.scope}${params.strict ? " strict" : ""}`);
|
|
324
|
+
lines.push(`summary: ok=${counts.ok} warn=${counts.warn} missing=${counts.missing}${!params.showOk && counts.ok > 0 ? " (ok hidden; pass --show-ok)" : ""}`);
|
|
325
|
+
if (groups.length === 0) {
|
|
326
|
+
lines.push("ok: no issues found");
|
|
327
|
+
return lines.join("\n");
|
|
328
|
+
}
|
|
329
|
+
for (const g of groups) {
|
|
330
|
+
lines.push("");
|
|
331
|
+
lines.push(bold(`${g.scope} / ${g.category}`, { color }));
|
|
332
|
+
for (const c of g.checks) {
|
|
333
|
+
const tag = formatStatusTag(c.status, { color });
|
|
334
|
+
lines.push(` ${tag} ${c.label}${c.detail ? ` (${c.detail})` : ""}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return lines.join("\n");
|
|
338
|
+
}
|
|
339
|
+
function renderDoctorGateFailure(params) {
|
|
340
|
+
const missing = params.checks.filter((c) => c.status === "missing");
|
|
341
|
+
const warn = params.checks.filter((c) => c.status === "warn");
|
|
342
|
+
const groups = groupChecks({
|
|
343
|
+
checks: params.strict ? [...missing, ...warn] : missing,
|
|
344
|
+
showOk: true
|
|
345
|
+
});
|
|
346
|
+
const lines = [];
|
|
347
|
+
lines.push(`doctor gate failed (${params.scope}${params.strict ? ", strict" : ""})`);
|
|
348
|
+
lines.push(`missing=${missing.length}${params.strict ? ` warn=${warn.length}` : ""}`);
|
|
349
|
+
const maxLines = 60;
|
|
350
|
+
for (const g of groups) {
|
|
351
|
+
if (lines.length >= maxLines) break;
|
|
352
|
+
lines.push("");
|
|
353
|
+
lines.push(`${g.scope} / ${g.category}`);
|
|
354
|
+
for (const c of g.checks) {
|
|
355
|
+
if (lines.length >= maxLines) break;
|
|
356
|
+
lines.push(` ${c.status.toUpperCase()}: ${c.label}${c.detail ? ` (${c.detail})` : ""}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
lines.push("");
|
|
360
|
+
lines.push(`hint: run clawdlets doctor --scope ${params.scope}${params.strict ? " --strict" : ""}`);
|
|
361
|
+
return lines.join("\n");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
//#endregion
|
|
365
|
+
//#region src/lib/deploy-gate.ts
|
|
366
|
+
async function requireDeployGate(params) {
|
|
367
|
+
const checks = await collectDoctorChecks({
|
|
368
|
+
cwd: process$1.cwd(),
|
|
369
|
+
runtimeDir: params.runtimeDir,
|
|
370
|
+
envFile: params.envFile,
|
|
371
|
+
host: params.host,
|
|
372
|
+
scope: params.scope,
|
|
373
|
+
skipGithubTokenCheck: params.skipGithubTokenCheck
|
|
374
|
+
});
|
|
375
|
+
const missing = checks.filter((c) => c.status === "missing");
|
|
376
|
+
const warn = checks.filter((c) => c.status === "warn");
|
|
377
|
+
if (!(missing.length > 0 || params.strict && warn.length > 0)) return;
|
|
378
|
+
throw new Error(renderDoctorGateFailure({
|
|
379
|
+
checks,
|
|
380
|
+
scope: params.scope,
|
|
381
|
+
strict: params.strict
|
|
382
|
+
}));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
//#endregion
|
|
386
|
+
//#region src/commands/bootstrap.ts
|
|
387
|
+
async function purgeKnownHosts(ipv4, opts) {
|
|
388
|
+
const rm$2 = async (host$1) => {
|
|
389
|
+
if (opts.dryRun) {
|
|
390
|
+
console.log(`ssh-keygen -R ${host$1}`);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
await run("ssh-keygen", ["-R", host$1]);
|
|
394
|
+
};
|
|
395
|
+
await rm$2(ipv4);
|
|
396
|
+
await rm$2(`[${ipv4}]:22`);
|
|
397
|
+
}
|
|
398
|
+
function resolveHostFromFlake(flakeBase) {
|
|
399
|
+
const hashIndex = flakeBase.indexOf("#");
|
|
400
|
+
if (hashIndex === -1) return null;
|
|
401
|
+
const host$1 = flakeBase.slice(hashIndex + 1).trim();
|
|
402
|
+
return host$1.length > 0 ? host$1 : null;
|
|
403
|
+
}
|
|
404
|
+
const bootstrap = defineCommand({
|
|
405
|
+
meta: {
|
|
406
|
+
name: "bootstrap",
|
|
407
|
+
description: "Provision Hetzner VM + install NixOS (nixos-anywhere or image)."
|
|
408
|
+
},
|
|
409
|
+
args: {
|
|
410
|
+
runtimeDir: {
|
|
411
|
+
type: "string",
|
|
412
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
413
|
+
},
|
|
414
|
+
envFile: {
|
|
415
|
+
type: "string",
|
|
416
|
+
description: "Env file for deploy creds (default: <runtimeDir>/env)."
|
|
417
|
+
},
|
|
418
|
+
host: {
|
|
419
|
+
type: "string",
|
|
420
|
+
description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
|
|
421
|
+
},
|
|
422
|
+
mode: {
|
|
423
|
+
type: "string",
|
|
424
|
+
description: "Bootstrap mode: nixos-anywhere|image.",
|
|
425
|
+
default: "nixos-anywhere"
|
|
426
|
+
},
|
|
427
|
+
flake: {
|
|
428
|
+
type: "string",
|
|
429
|
+
description: "Override base flake (default: clawdlets.json baseFlake or git origin)."
|
|
430
|
+
},
|
|
431
|
+
rev: {
|
|
432
|
+
type: "string",
|
|
433
|
+
description: "Git rev to pin (HEAD/sha/tag).",
|
|
434
|
+
default: "HEAD"
|
|
435
|
+
},
|
|
436
|
+
ref: {
|
|
437
|
+
type: "string",
|
|
438
|
+
description: "Git ref to pin (branch or tag)."
|
|
439
|
+
},
|
|
440
|
+
force: {
|
|
441
|
+
type: "boolean",
|
|
442
|
+
description: "Skip doctor gate (not recommended).",
|
|
443
|
+
default: false
|
|
444
|
+
},
|
|
445
|
+
dryRun: {
|
|
446
|
+
type: "boolean",
|
|
447
|
+
description: "Print commands without executing.",
|
|
448
|
+
default: false
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
async run({ args }) {
|
|
452
|
+
const cwd = process$1.cwd();
|
|
453
|
+
const repoRoot = findRepoRoot(cwd);
|
|
454
|
+
const hostName = resolveHostNameOrExit({
|
|
455
|
+
cwd,
|
|
456
|
+
runtimeDir: args.runtimeDir,
|
|
457
|
+
hostArg: args.host
|
|
458
|
+
});
|
|
459
|
+
if (!hostName) return;
|
|
460
|
+
const { layout, config: clawdletsConfig } = loadClawdletsConfig({
|
|
461
|
+
repoRoot,
|
|
462
|
+
runtimeDir: args.runtimeDir
|
|
463
|
+
});
|
|
464
|
+
const hostCfg = clawdletsConfig.hosts[hostName];
|
|
465
|
+
if (!hostCfg) throw new Error(`missing host in fleet/clawdlets.json: ${hostName}`);
|
|
466
|
+
const sshExposureMode = getSshExposureMode(hostCfg);
|
|
467
|
+
const tailnetMode = getTailnetMode(hostCfg);
|
|
468
|
+
const modeRaw = String(args.mode || "nixos-anywhere").trim();
|
|
469
|
+
if (modeRaw !== "nixos-anywhere" && modeRaw !== "image") throw new Error(`invalid --mode: ${modeRaw} (expected nixos-anywhere|image)`);
|
|
470
|
+
const mode = modeRaw;
|
|
471
|
+
if (Boolean(args.force)) console.error("warn: skipping doctor gate (--force)");
|
|
472
|
+
else if (mode === "nixos-anywhere") await requireDeployGate({
|
|
473
|
+
runtimeDir: args.runtimeDir,
|
|
474
|
+
envFile: args.envFile,
|
|
475
|
+
host: hostName,
|
|
476
|
+
scope: "bootstrap",
|
|
477
|
+
strict: false
|
|
478
|
+
});
|
|
479
|
+
else console.error("warn: skipping doctor gate for image bootstrap");
|
|
480
|
+
const deployCreds = loadDeployCreds({
|
|
481
|
+
cwd,
|
|
482
|
+
runtimeDir: args.runtimeDir,
|
|
483
|
+
envFile: args.envFile
|
|
484
|
+
});
|
|
485
|
+
if (deployCreds.envFile?.status === "invalid") throw new Error(`deploy env file rejected: ${deployCreds.envFile.path} (${deployCreds.envFile.error || "invalid"})`);
|
|
486
|
+
if (deployCreds.envFile?.status === "missing") throw new Error(`missing deploy env file: ${deployCreds.envFile.path}`);
|
|
487
|
+
const hcloudToken = String(deployCreds.values.HCLOUD_TOKEN || "").trim();
|
|
488
|
+
if (!hcloudToken) throw new Error("missing HCLOUD_TOKEN (set in .clawdlets/env or env var; run: clawdlets env init)");
|
|
489
|
+
const githubToken = String(deployCreds.values.GITHUB_TOKEN || "").trim();
|
|
490
|
+
const nixBin = String(deployCreds.values.NIX_BIN || "nix").trim() || "nix";
|
|
491
|
+
const opentofuDir = getHostOpenTofuDir(layout, hostName);
|
|
492
|
+
const serverType = String(hostCfg.hetzner.serverType || "").trim();
|
|
493
|
+
if (!serverType) throw new Error(`missing hetzner.serverType for ${hostName} (set via: clawdlets host set --server-type ...)`);
|
|
494
|
+
const image$1 = String(hostCfg.hetzner.image || "").trim();
|
|
495
|
+
const location = String(hostCfg.hetzner.location || "").trim();
|
|
496
|
+
if (mode === "image" && !image$1) throw new Error(`missing hetzner.image for ${hostName} (set via: clawdlets host set --hetzner-image <image_id>)`);
|
|
497
|
+
const adminCidr = String(hostCfg.provisioning.adminCidr || "").trim();
|
|
498
|
+
if (!adminCidr) throw new Error(`missing provisioning.adminCidr for ${hostName} (set via: clawdlets host set --admin-cidr ...)`);
|
|
499
|
+
const sshPubkeyFileRaw = String(hostCfg.provisioning.sshPubkeyFile || "").trim();
|
|
500
|
+
if (!sshPubkeyFileRaw) throw new Error(`missing provisioning.sshPubkeyFile for ${hostName} (set via: clawdlets host set --ssh-pubkey-file ...)`);
|
|
501
|
+
const sshPubkeyFileExpanded = expandPath(sshPubkeyFileRaw);
|
|
502
|
+
const sshPubkeyFile = path.isAbsolute(sshPubkeyFileExpanded) ? sshPubkeyFileExpanded : path.resolve(repoRoot, sshPubkeyFileExpanded);
|
|
503
|
+
if (!fs.existsSync(sshPubkeyFile)) throw new Error(`ssh pubkey file not found: ${sshPubkeyFile}`);
|
|
504
|
+
if (sshExposureMode === "tailnet") throw new Error(`sshExposure.mode=tailnet; bootstrap requires public SSH. Set: clawdlets host set --host ${hostName} --ssh-exposure bootstrap`);
|
|
505
|
+
await applyOpenTofuVars({
|
|
506
|
+
opentofuDir,
|
|
507
|
+
vars: {
|
|
508
|
+
hostName,
|
|
509
|
+
hcloudToken,
|
|
510
|
+
adminCidr,
|
|
511
|
+
adminCidrIsWorldOpen: Boolean(hostCfg.provisioning.adminCidrAllowWorldOpen),
|
|
512
|
+
sshPubkeyFile,
|
|
513
|
+
serverType,
|
|
514
|
+
image: image$1,
|
|
515
|
+
location,
|
|
516
|
+
sshExposureMode,
|
|
517
|
+
tailnetMode
|
|
518
|
+
},
|
|
519
|
+
nixBin,
|
|
520
|
+
dryRun: args.dryRun,
|
|
521
|
+
redact: [hcloudToken, githubToken].filter(Boolean)
|
|
522
|
+
});
|
|
523
|
+
const tofuEnvWithFlakes = withFlakesEnv({
|
|
524
|
+
...process$1.env,
|
|
525
|
+
HCLOUD_TOKEN: hcloudToken,
|
|
526
|
+
ADMIN_CIDR: adminCidr,
|
|
527
|
+
SSH_PUBKEY_FILE: sshPubkeyFile,
|
|
528
|
+
SERVER_TYPE: serverType
|
|
529
|
+
});
|
|
530
|
+
const ipv4 = args.dryRun ? "<opentofu-output:ipv4>" : await capture(nixBin, [
|
|
531
|
+
"run",
|
|
532
|
+
"--impure",
|
|
533
|
+
"nixpkgs#opentofu",
|
|
534
|
+
"--",
|
|
535
|
+
"output",
|
|
536
|
+
"-raw",
|
|
537
|
+
"ipv4"
|
|
538
|
+
], {
|
|
539
|
+
cwd: opentofuDir,
|
|
540
|
+
env: tofuEnvWithFlakes,
|
|
541
|
+
dryRun: args.dryRun
|
|
542
|
+
});
|
|
543
|
+
console.log(`Target IPv4: ${ipv4}`);
|
|
544
|
+
await purgeKnownHosts(ipv4, { dryRun: args.dryRun });
|
|
545
|
+
if (mode === "image") {
|
|
546
|
+
console.log("🎉 Bootstrap complete (image mode).");
|
|
547
|
+
console.log(`Host: ${hostName}`);
|
|
548
|
+
console.log(`IPv4: ${ipv4}`);
|
|
549
|
+
console.log(`SSH exposure: ${sshExposureMode}`);
|
|
550
|
+
console.log("");
|
|
551
|
+
console.log("Next:");
|
|
552
|
+
console.log(`1) Set targetHost for deploys:`);
|
|
553
|
+
console.log(` clawdlets host set --host ${hostName} --target-host admin@${ipv4}`);
|
|
554
|
+
console.log("2) Deploy secrets + system:");
|
|
555
|
+
console.log(` clawdlets server deploy --host ${hostName} --target-host admin@${ipv4} --manifest deploy-manifest.${hostName}.json`);
|
|
556
|
+
console.log("");
|
|
557
|
+
console.log("After tailnet is healthy, lock down SSH:");
|
|
558
|
+
console.log(` clawdlets host set --host ${hostName} --ssh-exposure tailnet`);
|
|
559
|
+
console.log(` clawdlets lockdown --host ${hostName}`);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const baseResolved = await resolveBaseFlake({
|
|
563
|
+
repoRoot,
|
|
564
|
+
config: clawdletsConfig
|
|
565
|
+
});
|
|
566
|
+
const flakeBase = String(args.flake || baseResolved.flake || "").trim();
|
|
567
|
+
if (!flakeBase) throw new Error("missing base flake (set baseFlake in fleet/clawdlets.json, set git origin, or pass --flake)");
|
|
568
|
+
const rev = String(args.rev || "").trim();
|
|
569
|
+
const ref = String(args.ref || "").trim();
|
|
570
|
+
if (rev && ref) throw new Error("use either --rev or --ref (not both)");
|
|
571
|
+
const requestedHost = String(hostCfg.flakeHost || hostName).trim() || hostName;
|
|
572
|
+
const hostFromFlake = resolveHostFromFlake(flakeBase);
|
|
573
|
+
if (hostFromFlake && hostFromFlake !== requestedHost) throw new Error(`flake host mismatch: ${hostFromFlake} vs ${requestedHost}`);
|
|
574
|
+
const flakeWithHost = flakeBase.includes("#") ? flakeBase : `${flakeBase}#${requestedHost}`;
|
|
575
|
+
const hashIndex = flakeWithHost.indexOf("#");
|
|
576
|
+
const flakeBasePath = hashIndex === -1 ? flakeWithHost : flakeWithHost.slice(0, hashIndex);
|
|
577
|
+
const flakeFragment = hashIndex === -1 ? "" : flakeWithHost.slice(hashIndex);
|
|
578
|
+
if ((rev || ref) && /(^|[?&])(rev|ref)=/.test(flakeBasePath)) throw new Error("flake already includes ?rev/?ref; drop --rev/--ref");
|
|
579
|
+
let flakePinned = flakeWithHost;
|
|
580
|
+
if (rev) {
|
|
581
|
+
const resolved = await resolveGitRev(repoRoot, rev);
|
|
582
|
+
if (!resolved) throw new Error(`unable to resolve git rev: ${rev}`);
|
|
583
|
+
flakePinned = `${flakeBasePath}${flakeBasePath.includes("?") ? "&" : "?"}rev=${resolved}${flakeFragment}`;
|
|
584
|
+
} else if (ref) flakePinned = `${flakeBasePath}${flakeBasePath.includes("?") ? "&" : "?"}ref=${ref}${flakeFragment}`;
|
|
585
|
+
const githubRepo = tryParseGithubFlakeUri(flakeBasePath);
|
|
586
|
+
if (githubRepo && !args.dryRun) {
|
|
587
|
+
const check = await checkGithubRepoVisibility({
|
|
588
|
+
owner: githubRepo.owner,
|
|
589
|
+
repo: githubRepo.repo,
|
|
590
|
+
token: githubToken || void 0
|
|
591
|
+
});
|
|
592
|
+
if (check.ok && check.status === "private-or-missing" && !githubToken) throw new Error(`base flake repo appears private (404). Set GITHUB_TOKEN in your environment and retry.`);
|
|
593
|
+
if (check.ok && check.status === "unauthorized") throw new Error(`GITHUB_TOKEN rejected by GitHub (401).`);
|
|
594
|
+
}
|
|
595
|
+
const extraFiles = getHostExtraFilesDir(layout, hostName);
|
|
596
|
+
const requiredKey = getHostExtraFilesKeyPath(layout, hostName);
|
|
597
|
+
if (!fs.existsSync(requiredKey)) throw new Error(`missing extra-files key: ${requiredKey} (run: clawdlets secrets init)`);
|
|
598
|
+
const secretsPlan = buildFleetSecretsPlan({
|
|
599
|
+
config: clawdletsConfig,
|
|
600
|
+
hostName
|
|
601
|
+
});
|
|
602
|
+
const requiredSecrets = [
|
|
603
|
+
...tailnetMode === "tailscale" ? ["tailscale_auth_key"] : [],
|
|
604
|
+
"admin_password_hash",
|
|
605
|
+
...secretsPlan.secretNamesRequired
|
|
606
|
+
];
|
|
607
|
+
const extraFilesSecretsDir = getHostExtraFilesSecretsDir(layout, hostName);
|
|
608
|
+
if (!fs.existsSync(extraFilesSecretsDir)) throw new Error(`missing extra-files secrets dir: ${extraFilesSecretsDir} (run: clawdlets secrets init)`);
|
|
609
|
+
for (const secretName of requiredSecrets) {
|
|
610
|
+
const f = path.join(extraFilesSecretsDir, `${secretName}.yaml`);
|
|
611
|
+
if (!fs.existsSync(f)) throw new Error(`missing extra-files secret: ${f} (run: clawdlets secrets init)`);
|
|
612
|
+
}
|
|
613
|
+
const nixosAnywhereArgs = [
|
|
614
|
+
"run",
|
|
615
|
+
"--option",
|
|
616
|
+
"max-jobs",
|
|
617
|
+
"1",
|
|
618
|
+
"--option",
|
|
619
|
+
"cores",
|
|
620
|
+
"1",
|
|
621
|
+
"--option",
|
|
622
|
+
"keep-outputs",
|
|
623
|
+
"false",
|
|
624
|
+
"--option",
|
|
625
|
+
"keep-derivations",
|
|
626
|
+
"false",
|
|
627
|
+
"github:nix-community/nixos-anywhere",
|
|
628
|
+
"--",
|
|
629
|
+
"--option",
|
|
630
|
+
"tarball-ttl",
|
|
631
|
+
"0",
|
|
632
|
+
"--option",
|
|
633
|
+
"accept-flake-config",
|
|
634
|
+
"true",
|
|
635
|
+
"--option",
|
|
636
|
+
"extra-substituters",
|
|
637
|
+
"https://cache.garnix.io",
|
|
638
|
+
"--option",
|
|
639
|
+
"extra-trusted-public-keys",
|
|
640
|
+
"cache.garnix.io:CTFPyKSLcx5RMJKfLo5EEPUObbA78b0YQ2DTCJXqr9g=",
|
|
641
|
+
"--build-on-remote",
|
|
642
|
+
"--extra-files",
|
|
643
|
+
extraFiles,
|
|
644
|
+
...githubToken ? [
|
|
645
|
+
"--option",
|
|
646
|
+
"access-tokens",
|
|
647
|
+
`github.com=${githubToken}`
|
|
648
|
+
] : [],
|
|
649
|
+
"--flake",
|
|
650
|
+
flakePinned,
|
|
651
|
+
`root@${ipv4}`
|
|
652
|
+
];
|
|
653
|
+
const nixosAnywhereBaseEnv = withFlakesEnv(process$1.env);
|
|
654
|
+
await run(nixBin, nixosAnywhereArgs, {
|
|
655
|
+
cwd: repoRoot,
|
|
656
|
+
env: {
|
|
657
|
+
...nixosAnywhereBaseEnv,
|
|
658
|
+
NIX_CONFIG: [
|
|
659
|
+
nixosAnywhereBaseEnv.NIX_CONFIG,
|
|
660
|
+
"accept-flake-config = true",
|
|
661
|
+
"extra-substituters = https://cache.garnix.io",
|
|
662
|
+
"extra-trusted-public-keys = cache.garnix.io:CTFPyKSLcx5RMJKfLo5EEPUObbA78b0YQ2DTCJXqr9g=",
|
|
663
|
+
githubToken ? `access-tokens = github.com=${githubToken}` : ""
|
|
664
|
+
].filter(Boolean).join("\n")
|
|
665
|
+
},
|
|
666
|
+
dryRun: args.dryRun,
|
|
667
|
+
redact: [hcloudToken, githubToken].filter(Boolean)
|
|
668
|
+
});
|
|
669
|
+
await purgeKnownHosts(ipv4, { dryRun: args.dryRun });
|
|
670
|
+
const publicSshStatus = "OPEN";
|
|
671
|
+
console.log("🎉 Bootstrap complete.");
|
|
672
|
+
console.log(`Host: ${hostName}`);
|
|
673
|
+
console.log(`IPv4: ${ipv4}`);
|
|
674
|
+
console.log(`SSH exposure: ${sshExposureMode}`);
|
|
675
|
+
console.log(`Public SSH (22): ${publicSshStatus}`);
|
|
676
|
+
console.log("");
|
|
677
|
+
console.log("⚠ SSH WILL REMAIN OPEN until you switch to tailnet and run lockdown:");
|
|
678
|
+
console.log(` clawdlets host set --host ${hostName} --ssh-exposure tailnet`);
|
|
679
|
+
console.log(` clawdlets lockdown --host ${hostName}`);
|
|
680
|
+
if (tailnetMode === "tailscale") {
|
|
681
|
+
console.log("");
|
|
682
|
+
console.log("Next (tailscale):");
|
|
683
|
+
console.log(`1) Wait for the host to appear in Tailscale, then copy its 100.x IP.`);
|
|
684
|
+
console.log(" tailscale status # look for the 100.x address");
|
|
685
|
+
console.log(`2) Set future SSH target to tailnet:`);
|
|
686
|
+
console.log(` clawdlets host set --host ${hostName} --target-host admin@<tailscale-ip>`);
|
|
687
|
+
console.log("3) Verify access:");
|
|
688
|
+
console.log(" ssh admin@<tailscale-ip> 'hostname; uptime'");
|
|
689
|
+
console.log("4) Switch SSH exposure to tailnet and lock down:");
|
|
690
|
+
console.log(` clawdlets host set --host ${hostName} --ssh-exposure tailnet`);
|
|
691
|
+
console.log(` clawdlets lockdown --host ${hostName}`);
|
|
692
|
+
console.log("5) Optional checks:");
|
|
693
|
+
console.log(" clawdlets server audit --host " + hostName);
|
|
694
|
+
} else {
|
|
695
|
+
console.log("");
|
|
696
|
+
console.log("Notes:");
|
|
697
|
+
console.log(`- SSH exposure is ${sshExposureMode}.`);
|
|
698
|
+
console.log("- If you want tailnet-only SSH, set tailnet.mode=tailscale, verify access, then:");
|
|
699
|
+
console.log(` clawdlets host set --host ${hostName} --ssh-exposure tailnet`);
|
|
700
|
+
console.log(` clawdlets lockdown --host ${hostName}`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
//#endregion
|
|
706
|
+
//#region src/commands/config.ts
|
|
707
|
+
function getAtPath(obj, parts) {
|
|
708
|
+
let cur = obj;
|
|
709
|
+
for (const k of parts) {
|
|
710
|
+
if (cur == null || typeof cur !== "object") return void 0;
|
|
711
|
+
cur = cur[k];
|
|
712
|
+
}
|
|
713
|
+
return cur;
|
|
714
|
+
}
|
|
715
|
+
function setAtPath(obj, parts, value) {
|
|
716
|
+
let cur = obj;
|
|
717
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
718
|
+
const k = parts[i];
|
|
719
|
+
if (cur[k] == null || typeof cur[k] !== "object" || Array.isArray(cur[k])) cur[k] = {};
|
|
720
|
+
cur = cur[k];
|
|
721
|
+
}
|
|
722
|
+
cur[parts[parts.length - 1]] = value;
|
|
723
|
+
}
|
|
724
|
+
function deleteAtPath(obj, parts) {
|
|
725
|
+
let cur = obj;
|
|
726
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
727
|
+
const k = parts[i];
|
|
728
|
+
if (cur == null || typeof cur !== "object") return false;
|
|
729
|
+
cur = cur[k];
|
|
730
|
+
}
|
|
731
|
+
const last = parts[parts.length - 1];
|
|
732
|
+
if (cur && typeof cur === "object" && Object.prototype.hasOwnProperty.call(cur, last)) {
|
|
733
|
+
delete cur[last];
|
|
734
|
+
return true;
|
|
735
|
+
}
|
|
736
|
+
return false;
|
|
737
|
+
}
|
|
738
|
+
const init = defineCommand({
|
|
739
|
+
meta: {
|
|
740
|
+
name: "init",
|
|
741
|
+
description: "Initialize fleet/clawdlets.json (canonical config)."
|
|
742
|
+
},
|
|
743
|
+
args: {
|
|
744
|
+
host: {
|
|
745
|
+
type: "string",
|
|
746
|
+
description: "Initial host name.",
|
|
747
|
+
default: "clawdbot-fleet-host"
|
|
748
|
+
},
|
|
749
|
+
force: {
|
|
750
|
+
type: "boolean",
|
|
751
|
+
description: "Overwrite existing clawdlets.json.",
|
|
752
|
+
default: false
|
|
753
|
+
},
|
|
754
|
+
"dry-run": {
|
|
755
|
+
type: "boolean",
|
|
756
|
+
description: "Print planned writes without writing.",
|
|
757
|
+
default: false
|
|
758
|
+
}
|
|
759
|
+
},
|
|
760
|
+
async run({ args }) {
|
|
761
|
+
const repoRoot = findRepoRoot(process$1.cwd());
|
|
762
|
+
const host$1 = String(args.host || "clawdbot-fleet-host").trim() || "clawdbot-fleet-host";
|
|
763
|
+
const configPath = getRepoLayout(repoRoot).clawdletsConfigPath;
|
|
764
|
+
if (fs.existsSync(configPath) && !args.force) throw new Error(`config already exists (pass --force to overwrite): ${configPath}`);
|
|
765
|
+
const config = createDefaultClawdletsConfig({ host: host$1 });
|
|
766
|
+
if (args["dry-run"]) {
|
|
767
|
+
console.log(`planned: write ${path.relative(repoRoot, configPath)}`);
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
await ensureDir(path.dirname(configPath));
|
|
771
|
+
await writeClawdletsConfig({
|
|
772
|
+
configPath,
|
|
773
|
+
config
|
|
774
|
+
});
|
|
775
|
+
console.log(`ok: wrote ${path.relative(repoRoot, configPath)}`);
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
const show$1 = defineCommand({
|
|
779
|
+
meta: {
|
|
780
|
+
name: "show",
|
|
781
|
+
description: "Print fleet/clawdlets.json."
|
|
782
|
+
},
|
|
783
|
+
args: { pretty: {
|
|
784
|
+
type: "boolean",
|
|
785
|
+
description: "Pretty-print JSON.",
|
|
786
|
+
default: true
|
|
787
|
+
} },
|
|
788
|
+
async run({ args }) {
|
|
789
|
+
const { config } = loadClawdletsConfig({ repoRoot: findRepoRoot(process$1.cwd()) });
|
|
790
|
+
console.log(args.pretty ? JSON.stringify(config, null, 2) : JSON.stringify(config));
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
const validate = defineCommand({
|
|
794
|
+
meta: {
|
|
795
|
+
name: "validate",
|
|
796
|
+
description: "Validate fleet/clawdlets.json schema."
|
|
797
|
+
},
|
|
798
|
+
args: {},
|
|
799
|
+
async run() {
|
|
800
|
+
loadClawdletsConfig({ repoRoot: findRepoRoot(process$1.cwd()) });
|
|
801
|
+
console.log("ok");
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
const get = defineCommand({
|
|
805
|
+
meta: {
|
|
806
|
+
name: "get",
|
|
807
|
+
description: "Get a value from fleet/clawdlets.json (dot path)."
|
|
808
|
+
},
|
|
809
|
+
args: {
|
|
810
|
+
path: {
|
|
811
|
+
type: "string",
|
|
812
|
+
description: "Dot path (e.g. fleet.botOrder)."
|
|
813
|
+
},
|
|
814
|
+
json: {
|
|
815
|
+
type: "boolean",
|
|
816
|
+
description: "JSON output.",
|
|
817
|
+
default: false
|
|
818
|
+
}
|
|
819
|
+
},
|
|
820
|
+
async run({ args }) {
|
|
821
|
+
const { config } = loadClawdletsConfig({ repoRoot: findRepoRoot(process$1.cwd()) });
|
|
822
|
+
const parts = splitDotPath(String(args.path || ""));
|
|
823
|
+
const v = getAtPath(config, parts);
|
|
824
|
+
if (args.json) console.log(JSON.stringify({
|
|
825
|
+
path: parts.join("."),
|
|
826
|
+
value: v
|
|
827
|
+
}, null, 2));
|
|
828
|
+
else console.log(typeof v === "string" ? v : JSON.stringify(v, null, 2));
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
const set$2 = defineCommand({
|
|
832
|
+
meta: {
|
|
833
|
+
name: "set",
|
|
834
|
+
description: "Set a value in fleet/clawdlets.json (dot path)."
|
|
835
|
+
},
|
|
836
|
+
args: {
|
|
837
|
+
path: {
|
|
838
|
+
type: "string",
|
|
839
|
+
description: "Dot path (e.g. fleet.botOrder)."
|
|
840
|
+
},
|
|
841
|
+
value: {
|
|
842
|
+
type: "string",
|
|
843
|
+
description: "String value."
|
|
844
|
+
},
|
|
845
|
+
"value-json": {
|
|
846
|
+
type: "string",
|
|
847
|
+
description: "JSON value (parsed)."
|
|
848
|
+
},
|
|
849
|
+
delete: {
|
|
850
|
+
type: "boolean",
|
|
851
|
+
description: "Delete the key at path.",
|
|
852
|
+
default: false
|
|
853
|
+
}
|
|
854
|
+
},
|
|
855
|
+
async run({ args }) {
|
|
856
|
+
const { configPath, config } = loadClawdletsConfigRaw({ repoRoot: findRepoRoot(process$1.cwd()) });
|
|
857
|
+
const parts = splitDotPath(String(args.path || ""));
|
|
858
|
+
const next = structuredClone(config);
|
|
859
|
+
if (args.delete) {
|
|
860
|
+
if (!deleteAtPath(next, parts)) throw new Error(`path not found: ${parts.join(".")}`);
|
|
861
|
+
} else if (args["value-json"] !== void 0) {
|
|
862
|
+
let parsed;
|
|
863
|
+
try {
|
|
864
|
+
parsed = JSON.parse(String(args["value-json"]));
|
|
865
|
+
} catch {
|
|
866
|
+
throw new Error("invalid --value-json (must be valid JSON)");
|
|
867
|
+
}
|
|
868
|
+
setAtPath(next, parts, parsed);
|
|
869
|
+
} else if (args.value !== void 0) setAtPath(next, parts, String(args.value));
|
|
870
|
+
else throw new Error("set requires --value or --value-json (or --delete)");
|
|
871
|
+
try {
|
|
872
|
+
await writeClawdletsConfig({
|
|
873
|
+
configPath,
|
|
874
|
+
config: ClawdletsConfigSchema.parse(next)
|
|
875
|
+
});
|
|
876
|
+
console.log("ok");
|
|
877
|
+
} catch (err) {
|
|
878
|
+
let details = "";
|
|
879
|
+
if (Array.isArray(err?.errors)) details = err.errors.map((e) => (Array.isArray(e.path) ? e.path.join(".") : "") || e.message).filter(Boolean).join(", ");
|
|
880
|
+
const msg = details ? `config update failed; revert or fix validation errors: ${details}` : "config update failed; revert or fix validation errors";
|
|
881
|
+
throw new Error(msg);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
const config = defineCommand({
|
|
886
|
+
meta: {
|
|
887
|
+
name: "config",
|
|
888
|
+
description: "Canonical config (fleet/clawdlets.json)."
|
|
889
|
+
},
|
|
890
|
+
subCommands: {
|
|
891
|
+
init,
|
|
892
|
+
show: show$1,
|
|
893
|
+
validate,
|
|
894
|
+
get,
|
|
895
|
+
set: set$2
|
|
896
|
+
}
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
//#endregion
|
|
900
|
+
//#region src/commands/doctor.ts
|
|
901
|
+
const doctor = defineCommand({
|
|
902
|
+
meta: {
|
|
903
|
+
name: "doctor",
|
|
904
|
+
description: "Validate repo + runtime inputs for deploying a host."
|
|
905
|
+
},
|
|
906
|
+
args: {
|
|
907
|
+
runtimeDir: {
|
|
908
|
+
type: "string",
|
|
909
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
910
|
+
},
|
|
911
|
+
envFile: {
|
|
912
|
+
type: "string",
|
|
913
|
+
description: "Env file for deploy creds (default: <runtimeDir>/env)."
|
|
914
|
+
},
|
|
915
|
+
host: {
|
|
916
|
+
type: "string",
|
|
917
|
+
description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
|
|
918
|
+
},
|
|
919
|
+
scope: {
|
|
920
|
+
type: "string",
|
|
921
|
+
description: "Which checks to run: repo | bootstrap | server-deploy | cattle | all (default: all).",
|
|
922
|
+
default: "all"
|
|
923
|
+
},
|
|
924
|
+
json: {
|
|
925
|
+
type: "boolean",
|
|
926
|
+
description: "Output JSON.",
|
|
927
|
+
default: false
|
|
928
|
+
},
|
|
929
|
+
"show-ok": {
|
|
930
|
+
type: "boolean",
|
|
931
|
+
description: "Show ok checks too.",
|
|
932
|
+
default: false
|
|
933
|
+
},
|
|
934
|
+
strict: {
|
|
935
|
+
type: "boolean",
|
|
936
|
+
description: "Fail on warn too (deploy gating).",
|
|
937
|
+
default: false
|
|
938
|
+
}
|
|
939
|
+
},
|
|
940
|
+
async run({ args }) {
|
|
941
|
+
const cwd = process$1.cwd();
|
|
942
|
+
const scopeRaw = String(args.scope || "all").trim();
|
|
943
|
+
if (scopeRaw !== "repo" && scopeRaw !== "bootstrap" && scopeRaw !== "server-deploy" && scopeRaw !== "cattle" && scopeRaw !== "all") throw new Error(`invalid --scope: ${scopeRaw} (expected repo|bootstrap|server-deploy|cattle|all)`);
|
|
944
|
+
const scope = scopeRaw;
|
|
945
|
+
if (scope === "repo") {
|
|
946
|
+
const repoRoot = findRepoRoot(cwd);
|
|
947
|
+
const templateSource = path.join(repoRoot, "config", "template-source.json");
|
|
948
|
+
const clawdletsConfig = path.join(repoRoot, "fleet", "clawdlets.json");
|
|
949
|
+
if (fs.existsSync(templateSource) && !fs.existsSync(clawdletsConfig)) {
|
|
950
|
+
console.log("note: CLI repo detected; run doctor in a project repo or via template-e2e.");
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
const hostName = resolveHostNameOrExit({
|
|
955
|
+
cwd,
|
|
956
|
+
runtimeDir: args.runtimeDir,
|
|
957
|
+
hostArg: args.host
|
|
958
|
+
});
|
|
959
|
+
if (!hostName) return;
|
|
960
|
+
const checks = await collectDoctorChecks({
|
|
961
|
+
cwd,
|
|
962
|
+
runtimeDir: args.runtimeDir,
|
|
963
|
+
envFile: args.envFile,
|
|
964
|
+
host: hostName,
|
|
965
|
+
scope
|
|
966
|
+
});
|
|
967
|
+
if (args.json) console.log(JSON.stringify({
|
|
968
|
+
scope: scopeRaw,
|
|
969
|
+
host: hostName,
|
|
970
|
+
checks
|
|
971
|
+
}, null, 2));
|
|
972
|
+
else console.log(renderDoctorReport({
|
|
973
|
+
checks,
|
|
974
|
+
host: hostName,
|
|
975
|
+
scope,
|
|
976
|
+
strict: args.strict,
|
|
977
|
+
showOk: Boolean(args["show-ok"])
|
|
978
|
+
}));
|
|
979
|
+
if (checks.some((c) => c.status === "missing")) process$1.exitCode = 1;
|
|
980
|
+
if (args.strict && checks.some((c) => c.status === "warn")) process$1.exitCode = 1;
|
|
981
|
+
}
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
//#endregion
|
|
985
|
+
//#region src/commands/env.ts
|
|
986
|
+
function resolveEnvFilePath(params) {
|
|
987
|
+
const repoRoot = findRepoRoot(params.cwd);
|
|
988
|
+
const explicit = String(params.envFileArg ?? "").trim();
|
|
989
|
+
if (explicit) {
|
|
990
|
+
const expanded = expandPath(explicit);
|
|
991
|
+
return {
|
|
992
|
+
path: path.isAbsolute(expanded) ? expanded : path.resolve(params.cwd, expanded),
|
|
993
|
+
origin: "explicit"
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
return {
|
|
997
|
+
path: getRepoLayout(repoRoot, params.runtimeDir).envFilePath,
|
|
998
|
+
origin: "default"
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
function renderEnvFile(keys) {
|
|
1002
|
+
return [
|
|
1003
|
+
"# clawdlets deploy creds (local-only; never commit)",
|
|
1004
|
+
"# Used by: bootstrap, infra, lockdown, doctor",
|
|
1005
|
+
"",
|
|
1006
|
+
`HCLOUD_TOKEN=${formatDotenvValue(keys.HCLOUD_TOKEN)}`,
|
|
1007
|
+
`GITHUB_TOKEN=${formatDotenvValue(keys.GITHUB_TOKEN)}`,
|
|
1008
|
+
`NIX_BIN=${formatDotenvValue(keys.NIX_BIN)}`,
|
|
1009
|
+
`SOPS_AGE_KEY_FILE=${formatDotenvValue(keys.SOPS_AGE_KEY_FILE)}`,
|
|
1010
|
+
""
|
|
1011
|
+
].join("\n");
|
|
1012
|
+
}
|
|
1013
|
+
function readEnvFileOrEmpty(filePath) {
|
|
1014
|
+
if (!fs.existsSync(filePath)) return {
|
|
1015
|
+
text: "",
|
|
1016
|
+
parsed: {}
|
|
1017
|
+
};
|
|
1018
|
+
const st = fs.lstatSync(filePath);
|
|
1019
|
+
if (st.isSymbolicLink()) throw new Error(`refusing to read env file symlink: ${filePath}`);
|
|
1020
|
+
if (!st.isFile()) throw new Error(`refusing to read non-file env path: ${filePath}`);
|
|
1021
|
+
const text = fs.readFileSync(filePath, "utf8");
|
|
1022
|
+
return {
|
|
1023
|
+
text,
|
|
1024
|
+
parsed: parseDotenv(text)
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
const envInit = defineCommand({
|
|
1028
|
+
meta: {
|
|
1029
|
+
name: "init",
|
|
1030
|
+
description: "Create/update <runtimeDir>/env for deploy creds (gitignored)."
|
|
1031
|
+
},
|
|
1032
|
+
args: {
|
|
1033
|
+
runtimeDir: {
|
|
1034
|
+
type: "string",
|
|
1035
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
1036
|
+
},
|
|
1037
|
+
envFile: {
|
|
1038
|
+
type: "string",
|
|
1039
|
+
description: "Env file path override (advanced; default: <runtimeDir>/env)."
|
|
1040
|
+
}
|
|
1041
|
+
},
|
|
1042
|
+
async run({ args }) {
|
|
1043
|
+
const cwd = process$1.cwd();
|
|
1044
|
+
const repoRoot = findRepoRoot(cwd);
|
|
1045
|
+
const layout = getRepoLayout(repoRoot, args.runtimeDir);
|
|
1046
|
+
const resolved = resolveEnvFilePath({
|
|
1047
|
+
cwd,
|
|
1048
|
+
runtimeDir: args.runtimeDir,
|
|
1049
|
+
envFileArg: args.envFile
|
|
1050
|
+
});
|
|
1051
|
+
if (resolved.origin === "default") try {
|
|
1052
|
+
fs.mkdirSync(layout.runtimeDir, { recursive: true });
|
|
1053
|
+
fs.chmodSync(layout.runtimeDir, 448);
|
|
1054
|
+
} catch {}
|
|
1055
|
+
const existing = readEnvFileOrEmpty(resolved.path).parsed;
|
|
1056
|
+
const keys = {
|
|
1057
|
+
HCLOUD_TOKEN: String(existing.HCLOUD_TOKEN || "").trim(),
|
|
1058
|
+
GITHUB_TOKEN: String(existing.GITHUB_TOKEN || "").trim(),
|
|
1059
|
+
NIX_BIN: String(existing.NIX_BIN || "nix").trim() || "nix",
|
|
1060
|
+
SOPS_AGE_KEY_FILE: String(existing.SOPS_AGE_KEY_FILE || "").trim()
|
|
1061
|
+
};
|
|
1062
|
+
await writeFileAtomic(resolved.path, renderEnvFile(keys), { mode: 384 });
|
|
1063
|
+
console.log(`ok: wrote ${path.relative(repoRoot, resolved.path) || resolved.path}`);
|
|
1064
|
+
if (resolved.origin === "explicit") console.log(`note: you must pass --env-file ${resolved.path} to deploy commands to use it`);
|
|
1065
|
+
else console.log("next: edit this file and set HCLOUD_TOKEN (required)");
|
|
1066
|
+
}
|
|
1067
|
+
});
|
|
1068
|
+
const envShow = defineCommand({
|
|
1069
|
+
meta: {
|
|
1070
|
+
name: "show",
|
|
1071
|
+
description: "Show resolved deploy creds (redacted) + their sources (env/file/default)."
|
|
1072
|
+
},
|
|
1073
|
+
args: {
|
|
1074
|
+
runtimeDir: {
|
|
1075
|
+
type: "string",
|
|
1076
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
1077
|
+
},
|
|
1078
|
+
envFile: {
|
|
1079
|
+
type: "string",
|
|
1080
|
+
description: "Env file for deploy creds (default: <runtimeDir>/env)."
|
|
1081
|
+
}
|
|
1082
|
+
},
|
|
1083
|
+
async run({ args }) {
|
|
1084
|
+
const loaded = loadDeployCreds({
|
|
1085
|
+
cwd: process$1.cwd(),
|
|
1086
|
+
runtimeDir: args.runtimeDir,
|
|
1087
|
+
envFile: args.envFile
|
|
1088
|
+
});
|
|
1089
|
+
if (loaded.envFile) {
|
|
1090
|
+
const status = loaded.envFile.status;
|
|
1091
|
+
const detail = loaded.envFile.error ? ` (${loaded.envFile.error})` : "";
|
|
1092
|
+
console.log(`env file: ${status} (${loaded.envFile.origin}) ${loaded.envFile.path}${detail}`);
|
|
1093
|
+
} else console.log("env file: (default missing; set vars via process env or run: clawdlets env init)");
|
|
1094
|
+
const line = (k, redact) => {
|
|
1095
|
+
const v = loaded.values[k];
|
|
1096
|
+
const src = loaded.sources[k];
|
|
1097
|
+
if (!v) return `${k}: unset (${src})`;
|
|
1098
|
+
if (redact) return `${k}: set (${src})`;
|
|
1099
|
+
return `${k}: ${v} (${src})`;
|
|
1100
|
+
};
|
|
1101
|
+
console.log(line("HCLOUD_TOKEN", true));
|
|
1102
|
+
console.log(line("GITHUB_TOKEN", true));
|
|
1103
|
+
console.log(line("NIX_BIN", false));
|
|
1104
|
+
console.log(line("SOPS_AGE_KEY_FILE", false));
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
const env = defineCommand({
|
|
1108
|
+
meta: {
|
|
1109
|
+
name: "env",
|
|
1110
|
+
description: "Local deploy credentials (.clawdlets/env)."
|
|
1111
|
+
},
|
|
1112
|
+
subCommands: {
|
|
1113
|
+
init: envInit,
|
|
1114
|
+
show: envShow
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
//#endregion
|
|
1119
|
+
//#region src/commands/host.ts
|
|
1120
|
+
function parseBoolOrUndefined(v) {
|
|
1121
|
+
if (v === void 0 || v === null) return void 0;
|
|
1122
|
+
const s = String(v).trim().toLowerCase();
|
|
1123
|
+
if (s === "") return void 0;
|
|
1124
|
+
if (s === "true" || s === "1" || s === "yes") return true;
|
|
1125
|
+
if (s === "false" || s === "0" || s === "no") return false;
|
|
1126
|
+
throw new Error(`invalid boolean: ${String(v)} (use true/false)`);
|
|
1127
|
+
}
|
|
1128
|
+
function readSshPublicKeysFromFile(filePath) {
|
|
1129
|
+
const stat = fs.statSync(filePath);
|
|
1130
|
+
if (!stat.isFile()) throw new Error(`not a file: ${filePath}`);
|
|
1131
|
+
if (stat.size > 64 * 1024) throw new Error(`ssh key file too large (>64KB): ${filePath}`);
|
|
1132
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
1133
|
+
if (looksLikeSshPrivateKey(raw)) throw new Error(`refusing to read ssh private key (expected .pub): ${filePath}`);
|
|
1134
|
+
const keys = parseSshPublicKeysFromText(raw);
|
|
1135
|
+
if (keys.length === 0) throw new Error(`no ssh public keys found in file: ${filePath}`);
|
|
1136
|
+
return keys;
|
|
1137
|
+
}
|
|
1138
|
+
function readKnownHostsFromFile(filePath) {
|
|
1139
|
+
const stat = fs.statSync(filePath);
|
|
1140
|
+
if (!stat.isFile()) throw new Error(`not a file: ${filePath}`);
|
|
1141
|
+
if (stat.size > 256 * 1024) throw new Error(`known_hosts file too large (>256KB): ${filePath}`);
|
|
1142
|
+
const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/).map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
1143
|
+
if (lines.length === 0) throw new Error(`no known_hosts entries found in file: ${filePath}`);
|
|
1144
|
+
return lines;
|
|
1145
|
+
}
|
|
1146
|
+
function toStringArray(v) {
|
|
1147
|
+
if (v == null) return [];
|
|
1148
|
+
if (Array.isArray(v)) return v.map((x) => String(x));
|
|
1149
|
+
return [String(v)];
|
|
1150
|
+
}
|
|
1151
|
+
const add$1 = defineCommand({
|
|
1152
|
+
meta: {
|
|
1153
|
+
name: "add",
|
|
1154
|
+
description: "Add a host entry to fleet/clawdlets.json."
|
|
1155
|
+
},
|
|
1156
|
+
args: { host: {
|
|
1157
|
+
type: "string",
|
|
1158
|
+
description: "Host name."
|
|
1159
|
+
} },
|
|
1160
|
+
async run({ args }) {
|
|
1161
|
+
const { configPath, config: config$1 } = loadClawdletsConfig({ repoRoot: findRepoRoot(process$1.cwd()) });
|
|
1162
|
+
const hostName = String(args.host || "").trim();
|
|
1163
|
+
if (!hostName) throw new Error("missing --host");
|
|
1164
|
+
assertSafeHostName(hostName);
|
|
1165
|
+
if (config$1.hosts[hostName]) throw new Error(`host already exists in clawdlets.json: ${hostName}`);
|
|
1166
|
+
const nextHost = {
|
|
1167
|
+
enable: false,
|
|
1168
|
+
diskDevice: "/dev/sda",
|
|
1169
|
+
sshAuthorizedKeys: [],
|
|
1170
|
+
sshKnownHosts: [],
|
|
1171
|
+
flakeHost: "",
|
|
1172
|
+
targetHost: void 0,
|
|
1173
|
+
hetzner: {
|
|
1174
|
+
serverType: "cx43",
|
|
1175
|
+
image: "",
|
|
1176
|
+
location: "nbg1"
|
|
1177
|
+
},
|
|
1178
|
+
provisioning: {
|
|
1179
|
+
adminCidr: "",
|
|
1180
|
+
adminCidrAllowWorldOpen: false,
|
|
1181
|
+
sshPubkeyFile: "~/.ssh/id_ed25519.pub"
|
|
1182
|
+
},
|
|
1183
|
+
sshExposure: { mode: "bootstrap" },
|
|
1184
|
+
tailnet: { mode: "tailscale" },
|
|
1185
|
+
cache: { garnix: { private: {
|
|
1186
|
+
enable: false,
|
|
1187
|
+
netrcSecret: "garnix_netrc",
|
|
1188
|
+
netrcPath: "/etc/nix/netrc",
|
|
1189
|
+
narinfoCachePositiveTtl: 3600
|
|
1190
|
+
} } },
|
|
1191
|
+
operator: { deploy: { enable: false } },
|
|
1192
|
+
selfUpdate: {
|
|
1193
|
+
enable: false,
|
|
1194
|
+
manifestUrl: "",
|
|
1195
|
+
interval: "30min",
|
|
1196
|
+
publicKey: "",
|
|
1197
|
+
signatureUrl: ""
|
|
1198
|
+
},
|
|
1199
|
+
agentModelPrimary: "zai/glm-4.7"
|
|
1200
|
+
};
|
|
1201
|
+
await writeClawdletsConfig({
|
|
1202
|
+
configPath,
|
|
1203
|
+
config: ClawdletsConfigSchema.parse({
|
|
1204
|
+
...config$1,
|
|
1205
|
+
defaultHost: config$1.defaultHost || hostName,
|
|
1206
|
+
hosts: {
|
|
1207
|
+
...config$1.hosts,
|
|
1208
|
+
[hostName]: nextHost
|
|
1209
|
+
}
|
|
1210
|
+
})
|
|
1211
|
+
});
|
|
1212
|
+
console.log(`ok: added host ${hostName}`);
|
|
1213
|
+
}
|
|
1214
|
+
});
|
|
1215
|
+
const setDefault = defineCommand({
|
|
1216
|
+
meta: {
|
|
1217
|
+
name: "set-default",
|
|
1218
|
+
description: "Set config.defaultHost (default host used when --host is omitted)."
|
|
1219
|
+
},
|
|
1220
|
+
args: { host: {
|
|
1221
|
+
type: "string",
|
|
1222
|
+
description: "Host name (defaults to current defaultHost / sole host)."
|
|
1223
|
+
} },
|
|
1224
|
+
async run({ args }) {
|
|
1225
|
+
const { configPath, config: config$1 } = loadClawdletsConfig({ repoRoot: findRepoRoot(process$1.cwd()) });
|
|
1226
|
+
const resolved = resolveHostName({
|
|
1227
|
+
config: config$1,
|
|
1228
|
+
host: args.host
|
|
1229
|
+
});
|
|
1230
|
+
if (!resolved.ok) {
|
|
1231
|
+
console.error(`warn: ${resolved.message}`);
|
|
1232
|
+
for (const t of resolved.tips) console.error(`tip: ${t}`);
|
|
1233
|
+
process$1.exitCode = 1;
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
await writeClawdletsConfig({
|
|
1237
|
+
configPath,
|
|
1238
|
+
config: ClawdletsConfigSchema.parse({
|
|
1239
|
+
...config$1,
|
|
1240
|
+
defaultHost: resolved.host
|
|
1241
|
+
})
|
|
1242
|
+
});
|
|
1243
|
+
console.log(`ok: defaultHost = ${resolved.host}`);
|
|
1244
|
+
}
|
|
1245
|
+
});
|
|
1246
|
+
const set$1 = defineCommand({
|
|
1247
|
+
meta: {
|
|
1248
|
+
name: "set",
|
|
1249
|
+
description: "Set host config fields (in fleet/clawdlets.json)."
|
|
1250
|
+
},
|
|
1251
|
+
args: {
|
|
1252
|
+
host: {
|
|
1253
|
+
type: "string",
|
|
1254
|
+
description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
|
|
1255
|
+
},
|
|
1256
|
+
enable: {
|
|
1257
|
+
type: "string",
|
|
1258
|
+
description: "Enable fleet services (true/false)."
|
|
1259
|
+
},
|
|
1260
|
+
"ssh-exposure": {
|
|
1261
|
+
type: "string",
|
|
1262
|
+
description: "SSH exposure mode: tailnet|bootstrap|public."
|
|
1263
|
+
},
|
|
1264
|
+
"disk-device": {
|
|
1265
|
+
type: "string",
|
|
1266
|
+
description: "Disk device (Hetzner Cloud: /dev/sda)."
|
|
1267
|
+
},
|
|
1268
|
+
"agent-model-primary": {
|
|
1269
|
+
type: "string",
|
|
1270
|
+
description: "Primary agent model (e.g. zai/glm-4.7)."
|
|
1271
|
+
},
|
|
1272
|
+
tailnet: {
|
|
1273
|
+
type: "string",
|
|
1274
|
+
description: "Tailnet mode: none|tailscale."
|
|
1275
|
+
},
|
|
1276
|
+
"garnix-private-cache": {
|
|
1277
|
+
type: "string",
|
|
1278
|
+
description: "Enable private Garnix cache access (true/false). Requires garnix netrc secret."
|
|
1279
|
+
},
|
|
1280
|
+
"garnix-netrc-secret": {
|
|
1281
|
+
type: "string",
|
|
1282
|
+
description: "Sops secret name containing /etc/nix/netrc (default: garnix_netrc)."
|
|
1283
|
+
},
|
|
1284
|
+
"garnix-netrc-path": {
|
|
1285
|
+
type: "string",
|
|
1286
|
+
description: "Filesystem path for netrc on host (default: /etc/nix/netrc)."
|
|
1287
|
+
},
|
|
1288
|
+
"garnix-narinfo-cache-positive-ttl": {
|
|
1289
|
+
type: "string",
|
|
1290
|
+
description: "narinfo-cache-positive-ttl when private cache enabled (default: 3600)."
|
|
1291
|
+
},
|
|
1292
|
+
"flake-host": {
|
|
1293
|
+
type: "string",
|
|
1294
|
+
description: "Flake output host name override (default: same as host name)."
|
|
1295
|
+
},
|
|
1296
|
+
"target-host": {
|
|
1297
|
+
type: "string",
|
|
1298
|
+
description: "SSH target (ssh config alias or user@host)."
|
|
1299
|
+
},
|
|
1300
|
+
"server-type": {
|
|
1301
|
+
type: "string",
|
|
1302
|
+
description: "Hetzner server type (e.g. cx43)."
|
|
1303
|
+
},
|
|
1304
|
+
"hetzner-image": {
|
|
1305
|
+
type: "string",
|
|
1306
|
+
description: "Hetzner image ID/name (custom image or snapshot)."
|
|
1307
|
+
},
|
|
1308
|
+
"hetzner-location": {
|
|
1309
|
+
type: "string",
|
|
1310
|
+
description: "Hetzner location (e.g. nbg1, fsn1)."
|
|
1311
|
+
},
|
|
1312
|
+
"admin-cidr": {
|
|
1313
|
+
type: "string",
|
|
1314
|
+
description: "ADMIN_CIDR (e.g. 1.2.3.4/32)."
|
|
1315
|
+
},
|
|
1316
|
+
"ssh-pubkey-file": {
|
|
1317
|
+
type: "string",
|
|
1318
|
+
description: "SSH_PUBKEY_FILE path (e.g. ~/.ssh/id_ed25519.pub)."
|
|
1319
|
+
},
|
|
1320
|
+
"clear-ssh-keys": {
|
|
1321
|
+
type: "boolean",
|
|
1322
|
+
description: "Clear sshAuthorizedKeys.",
|
|
1323
|
+
default: false
|
|
1324
|
+
},
|
|
1325
|
+
"add-ssh-key": {
|
|
1326
|
+
type: "string",
|
|
1327
|
+
description: "Add SSH public key contents (repeatable).",
|
|
1328
|
+
array: true
|
|
1329
|
+
},
|
|
1330
|
+
"add-ssh-key-file": {
|
|
1331
|
+
type: "string",
|
|
1332
|
+
description: "Add SSH public key from file (repeatable).",
|
|
1333
|
+
array: true
|
|
1334
|
+
},
|
|
1335
|
+
"clear-ssh-known-hosts": {
|
|
1336
|
+
type: "boolean",
|
|
1337
|
+
description: "Clear sshKnownHosts.",
|
|
1338
|
+
default: false
|
|
1339
|
+
},
|
|
1340
|
+
"add-ssh-known-host": {
|
|
1341
|
+
type: "string",
|
|
1342
|
+
description: "Add known_hosts entry (repeatable).",
|
|
1343
|
+
array: true
|
|
1344
|
+
},
|
|
1345
|
+
"add-ssh-known-host-file": {
|
|
1346
|
+
type: "string",
|
|
1347
|
+
description: "Add known_hosts entries from file (repeatable).",
|
|
1348
|
+
array: true
|
|
1349
|
+
}
|
|
1350
|
+
},
|
|
1351
|
+
async run({ args }) {
|
|
1352
|
+
const { configPath, config: config$1 } = loadClawdletsConfig({ repoRoot: findRepoRoot(process$1.cwd()) });
|
|
1353
|
+
const resolved = resolveHostName({
|
|
1354
|
+
config: config$1,
|
|
1355
|
+
host: args.host
|
|
1356
|
+
});
|
|
1357
|
+
if (!resolved.ok) {
|
|
1358
|
+
console.error(`warn: ${resolved.message}`);
|
|
1359
|
+
for (const t of resolved.tips) console.error(`tip: ${t}`);
|
|
1360
|
+
process$1.exitCode = 1;
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
const hostName = resolved.host;
|
|
1364
|
+
const existing = config$1.hosts[hostName];
|
|
1365
|
+
if (!existing) {
|
|
1366
|
+
console.error(`warn: unknown host in clawdlets.json: ${hostName}`);
|
|
1367
|
+
console.error(`tip: available hosts: ${Object.keys(config$1.hosts).join(", ")}`);
|
|
1368
|
+
process$1.exitCode = 1;
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
const next = structuredClone(existing);
|
|
1372
|
+
const enable = parseBoolOrUndefined(args.enable);
|
|
1373
|
+
if (enable !== void 0) next.enable = enable;
|
|
1374
|
+
if (args["ssh-exposure"] !== void 0) {
|
|
1375
|
+
const mode = String(args["ssh-exposure"]).trim();
|
|
1376
|
+
if (!SSH_EXPOSURE_MODES.includes(mode)) throw new Error("invalid --ssh-exposure (expected tailnet|bootstrap|public)");
|
|
1377
|
+
next.sshExposure.mode = mode;
|
|
1378
|
+
}
|
|
1379
|
+
if (args["disk-device"] !== void 0) next.diskDevice = String(args["disk-device"]).trim();
|
|
1380
|
+
if (args["agent-model-primary"] !== void 0) next.agentModelPrimary = String(args["agent-model-primary"]).trim();
|
|
1381
|
+
if (args["garnix-private-cache"] !== void 0) {
|
|
1382
|
+
const v = parseBoolOrUndefined(args["garnix-private-cache"]);
|
|
1383
|
+
if (v !== void 0) next.cache.garnix.private.enable = v;
|
|
1384
|
+
}
|
|
1385
|
+
if (args["garnix-netrc-secret"] !== void 0) next.cache.garnix.private.netrcSecret = String(args["garnix-netrc-secret"]).trim();
|
|
1386
|
+
if (args["garnix-netrc-path"] !== void 0) next.cache.garnix.private.netrcPath = String(args["garnix-netrc-path"]).trim();
|
|
1387
|
+
if (args["garnix-narinfo-cache-positive-ttl"] !== void 0) {
|
|
1388
|
+
const raw = String(args["garnix-narinfo-cache-positive-ttl"]).trim();
|
|
1389
|
+
if (raw) {
|
|
1390
|
+
const n = Number(raw);
|
|
1391
|
+
if (!Number.isInteger(n) || n <= 0) throw new Error("invalid --garnix-narinfo-cache-positive-ttl (expected positive integer)");
|
|
1392
|
+
next.cache.garnix.private.narinfoCachePositiveTtl = n;
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
if (args["flake-host"] !== void 0) next.flakeHost = String(args["flake-host"]).trim();
|
|
1396
|
+
if (args["target-host"] !== void 0) {
|
|
1397
|
+
const v = String(args["target-host"]).trim();
|
|
1398
|
+
next.targetHost = v ? validateTargetHost(v) : void 0;
|
|
1399
|
+
}
|
|
1400
|
+
if (args["server-type"] !== void 0) next.hetzner.serverType = String(args["server-type"]).trim();
|
|
1401
|
+
if (args["hetzner-image"] !== void 0) next.hetzner.image = String(args["hetzner-image"]).trim();
|
|
1402
|
+
if (args["hetzner-location"] !== void 0) next.hetzner.location = String(args["hetzner-location"]).trim();
|
|
1403
|
+
if (args["admin-cidr"] !== void 0) next.provisioning.adminCidr = String(args["admin-cidr"]).trim();
|
|
1404
|
+
if (args["ssh-pubkey-file"] !== void 0) next.provisioning.sshPubkeyFile = String(args["ssh-pubkey-file"]).trim();
|
|
1405
|
+
if (args.tailnet !== void 0) {
|
|
1406
|
+
const mode = String(args.tailnet).trim();
|
|
1407
|
+
if (mode !== "none" && mode !== "tailscale") throw new Error("invalid --tailnet (expected none|tailscale)");
|
|
1408
|
+
next.tailnet.mode = mode;
|
|
1409
|
+
}
|
|
1410
|
+
if (args["clear-ssh-keys"]) next.sshAuthorizedKeys = [];
|
|
1411
|
+
{
|
|
1412
|
+
const keys = new Set(next.sshAuthorizedKeys || []);
|
|
1413
|
+
for (const file of toStringArray(args["add-ssh-key-file"])) for (const k of readSshPublicKeysFromFile(file)) keys.add(k);
|
|
1414
|
+
for (const raw of toStringArray(args["add-ssh-key"])) {
|
|
1415
|
+
if (!raw.trim()) continue;
|
|
1416
|
+
if (looksLikeSshPrivateKey(raw)) throw new Error("refusing to add ssh private key (expected public key contents)");
|
|
1417
|
+
const parsed = parseSshPublicKeysFromText(raw);
|
|
1418
|
+
if (parsed.length === 0) throw new Error("invalid --add-ssh-key (expected ssh public key contents)");
|
|
1419
|
+
for (const k of parsed) keys.add(k);
|
|
1420
|
+
}
|
|
1421
|
+
next.sshAuthorizedKeys = Array.from(keys);
|
|
1422
|
+
}
|
|
1423
|
+
if (args["clear-ssh-known-hosts"]) next.sshKnownHosts = [];
|
|
1424
|
+
{
|
|
1425
|
+
const knownHosts = new Set(next.sshKnownHosts || []);
|
|
1426
|
+
for (const file of toStringArray(args["add-ssh-known-host-file"])) for (const line of readKnownHostsFromFile(file)) knownHosts.add(line);
|
|
1427
|
+
for (const raw of toStringArray(args["add-ssh-known-host"])) {
|
|
1428
|
+
const trimmed = raw.trim();
|
|
1429
|
+
if (!trimmed) continue;
|
|
1430
|
+
if (trimmed.startsWith("#")) continue;
|
|
1431
|
+
knownHosts.add(trimmed);
|
|
1432
|
+
}
|
|
1433
|
+
next.sshKnownHosts = Array.from(knownHosts);
|
|
1434
|
+
}
|
|
1435
|
+
await writeClawdletsConfig({
|
|
1436
|
+
configPath,
|
|
1437
|
+
config: ClawdletsConfigSchema.parse({
|
|
1438
|
+
...config$1,
|
|
1439
|
+
hosts: {
|
|
1440
|
+
...config$1.hosts,
|
|
1441
|
+
[hostName]: next
|
|
1442
|
+
}
|
|
1443
|
+
})
|
|
1444
|
+
});
|
|
1445
|
+
console.log(`ok: updated host ${hostName}`);
|
|
1446
|
+
}
|
|
1447
|
+
});
|
|
1448
|
+
const host = defineCommand({
|
|
1449
|
+
meta: {
|
|
1450
|
+
name: "host",
|
|
1451
|
+
description: "Manage host config (fleet/clawdlets.json)."
|
|
1452
|
+
},
|
|
1453
|
+
subCommands: {
|
|
1454
|
+
add: add$1,
|
|
1455
|
+
"set-default": setDefault,
|
|
1456
|
+
set: set$1
|
|
1457
|
+
}
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
//#endregion
|
|
1461
|
+
//#region src/commands/fleet.ts
|
|
1462
|
+
const show = defineCommand({
|
|
1463
|
+
meta: {
|
|
1464
|
+
name: "show",
|
|
1465
|
+
description: "Print fleet config (from fleet/clawdlets.json)."
|
|
1466
|
+
},
|
|
1467
|
+
args: {},
|
|
1468
|
+
async run() {
|
|
1469
|
+
const { config: config$1 } = loadClawdletsConfig({ repoRoot: findRepoRoot(process$1.cwd()) });
|
|
1470
|
+
console.log(JSON.stringify(config$1.fleet, null, 2));
|
|
1471
|
+
}
|
|
1472
|
+
});
|
|
1473
|
+
const set = defineCommand({
|
|
1474
|
+
meta: {
|
|
1475
|
+
name: "set",
|
|
1476
|
+
description: "Set fleet config fields (in fleet/clawdlets.json)."
|
|
1477
|
+
},
|
|
1478
|
+
args: {
|
|
1479
|
+
"codex-enable": {
|
|
1480
|
+
type: "string",
|
|
1481
|
+
description: "Enable codex (true/false)."
|
|
1482
|
+
},
|
|
1483
|
+
"guild-id": {
|
|
1484
|
+
type: "string",
|
|
1485
|
+
description: "Discord guild ID."
|
|
1486
|
+
},
|
|
1487
|
+
"restic-enable": {
|
|
1488
|
+
type: "string",
|
|
1489
|
+
description: "Enable restic backups (true/false)."
|
|
1490
|
+
},
|
|
1491
|
+
"restic-repository": {
|
|
1492
|
+
type: "string",
|
|
1493
|
+
description: "Restic repository URL/path."
|
|
1494
|
+
}
|
|
1495
|
+
},
|
|
1496
|
+
async run({ args }) {
|
|
1497
|
+
const { configPath, config: config$1 } = loadClawdletsConfig({ repoRoot: findRepoRoot(process$1.cwd()) });
|
|
1498
|
+
const next = structuredClone(config$1);
|
|
1499
|
+
const parseBool = (v) => {
|
|
1500
|
+
if (v === void 0 || v === null) return void 0;
|
|
1501
|
+
const s = String(v).trim().toLowerCase();
|
|
1502
|
+
if (s === "") return void 0;
|
|
1503
|
+
if (s === "true" || s === "1" || s === "yes") return true;
|
|
1504
|
+
if (s === "false" || s === "0" || s === "no") return false;
|
|
1505
|
+
throw new Error(`invalid boolean: ${String(v)} (use true/false)`);
|
|
1506
|
+
};
|
|
1507
|
+
{
|
|
1508
|
+
const v = parseBool(args["codex-enable"]);
|
|
1509
|
+
if (v !== void 0) next.fleet.codex.enable = v;
|
|
1510
|
+
}
|
|
1511
|
+
{
|
|
1512
|
+
const v = parseBool(args["restic-enable"]);
|
|
1513
|
+
if (v !== void 0) next.fleet.backups.restic.enable = v;
|
|
1514
|
+
}
|
|
1515
|
+
if (args["guild-id"] !== void 0) next.fleet.guildId = String(args["guild-id"]).trim();
|
|
1516
|
+
if (args["restic-repository"] !== void 0) next.fleet.backups.restic.repository = String(args["restic-repository"]).trim();
|
|
1517
|
+
await writeClawdletsConfig({
|
|
1518
|
+
configPath,
|
|
1519
|
+
config: ClawdletsConfigSchema.parse(next)
|
|
1520
|
+
});
|
|
1521
|
+
console.log("ok");
|
|
1522
|
+
}
|
|
1523
|
+
});
|
|
1524
|
+
const fleet = defineCommand({
|
|
1525
|
+
meta: {
|
|
1526
|
+
name: "fleet",
|
|
1527
|
+
description: "Manage fleet config (fleet/clawdlets.json)."
|
|
1528
|
+
},
|
|
1529
|
+
subCommands: {
|
|
1530
|
+
show,
|
|
1531
|
+
set
|
|
1532
|
+
}
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
//#endregion
|
|
1536
|
+
//#region src/commands/image.ts
|
|
1537
|
+
async function buildRawImage(params) {
|
|
1538
|
+
if (process$1.platform !== "linux") throw new Error("image build requires Linux; run in CI or a Linux builder");
|
|
1539
|
+
const attr = `.#packages.x86_64-linux.${params.host}-image`;
|
|
1540
|
+
const out = await capture(params.nixBin, [
|
|
1541
|
+
"build",
|
|
1542
|
+
"--json",
|
|
1543
|
+
"--no-link",
|
|
1544
|
+
attr
|
|
1545
|
+
], {
|
|
1546
|
+
cwd: params.repoRoot,
|
|
1547
|
+
env: withFlakesEnv(process$1.env)
|
|
1548
|
+
});
|
|
1549
|
+
let parsed;
|
|
1550
|
+
try {
|
|
1551
|
+
parsed = JSON.parse(out);
|
|
1552
|
+
} catch (e) {
|
|
1553
|
+
throw new Error(`nix build --json returned invalid JSON (${String(e?.message || e)})`);
|
|
1554
|
+
}
|
|
1555
|
+
const imagePath = parsed?.[0]?.outputs?.out;
|
|
1556
|
+
if (!imagePath || typeof imagePath !== "string") throw new Error("nix build did not return an image store path");
|
|
1557
|
+
if (!fs.existsSync(imagePath)) throw new Error(`image path missing: ${imagePath}`);
|
|
1558
|
+
return imagePath;
|
|
1559
|
+
}
|
|
1560
|
+
const imageBuild = defineCommand({
|
|
1561
|
+
meta: {
|
|
1562
|
+
name: "build",
|
|
1563
|
+
description: "Build a raw NixOS image for a host (nixos-generators)."
|
|
1564
|
+
},
|
|
1565
|
+
args: {
|
|
1566
|
+
runtimeDir: {
|
|
1567
|
+
type: "string",
|
|
1568
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
1569
|
+
},
|
|
1570
|
+
host: {
|
|
1571
|
+
type: "string",
|
|
1572
|
+
description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
|
|
1573
|
+
},
|
|
1574
|
+
rev: {
|
|
1575
|
+
type: "string",
|
|
1576
|
+
description: "Git rev to name the image (HEAD/sha/tag).",
|
|
1577
|
+
default: "HEAD"
|
|
1578
|
+
},
|
|
1579
|
+
out: {
|
|
1580
|
+
type: "string",
|
|
1581
|
+
description: "Output path (default: .clawdlets/images/<host>/clawdlets-<host>-<rev>.raw)."
|
|
1582
|
+
},
|
|
1583
|
+
nixBin: {
|
|
1584
|
+
type: "string",
|
|
1585
|
+
description: "Override nix binary (default: nix)."
|
|
1586
|
+
}
|
|
1587
|
+
},
|
|
1588
|
+
async run({ args }) {
|
|
1589
|
+
const cwd = process$1.cwd();
|
|
1590
|
+
const ctx = loadHostContextOrExit({
|
|
1591
|
+
cwd,
|
|
1592
|
+
runtimeDir: args.runtimeDir,
|
|
1593
|
+
hostArg: args.host
|
|
1594
|
+
});
|
|
1595
|
+
if (!ctx) return;
|
|
1596
|
+
const { repoRoot, layout, hostName } = ctx;
|
|
1597
|
+
const revRaw = String(args.rev || "").trim() || "HEAD";
|
|
1598
|
+
const resolved = await resolveGitRev(repoRoot, revRaw);
|
|
1599
|
+
if (!resolved) throw new Error(`unable to resolve git rev: ${revRaw}`);
|
|
1600
|
+
const imagePath = await buildRawImage({
|
|
1601
|
+
repoRoot,
|
|
1602
|
+
nixBin: String(args.nixBin || process$1.env.NIX_BIN || "nix").trim() || "nix",
|
|
1603
|
+
host: hostName
|
|
1604
|
+
});
|
|
1605
|
+
const outRaw = String(args.out || "").trim();
|
|
1606
|
+
const outPath = outRaw ? path.isAbsolute(outRaw) ? outRaw : path.resolve(cwd, outRaw) : path.join(layout.runtimeDir, "images", hostName, `clawdlets-${hostName}-${resolved}.raw`);
|
|
1607
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
1608
|
+
fs.copyFileSync(imagePath, outPath);
|
|
1609
|
+
console.log(`ok: built raw image ${outPath}`);
|
|
1610
|
+
}
|
|
1611
|
+
});
|
|
1612
|
+
const imageUpload = defineCommand({
|
|
1613
|
+
meta: {
|
|
1614
|
+
name: "upload",
|
|
1615
|
+
description: "Upload a raw image to Hetzner using hcloud-upload-image."
|
|
1616
|
+
},
|
|
1617
|
+
args: {
|
|
1618
|
+
runtimeDir: {
|
|
1619
|
+
type: "string",
|
|
1620
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
1621
|
+
},
|
|
1622
|
+
envFile: {
|
|
1623
|
+
type: "string",
|
|
1624
|
+
description: "Env file for deploy creds (default: <runtimeDir>/env)."
|
|
1625
|
+
},
|
|
1626
|
+
host: {
|
|
1627
|
+
type: "string",
|
|
1628
|
+
description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
|
|
1629
|
+
},
|
|
1630
|
+
"image-url": {
|
|
1631
|
+
type: "string",
|
|
1632
|
+
description: "Public URL for the raw image (Hetzner must reach it)."
|
|
1633
|
+
},
|
|
1634
|
+
compression: {
|
|
1635
|
+
type: "string",
|
|
1636
|
+
description: "Compression type (none|gz|bz2|xz).",
|
|
1637
|
+
default: "none"
|
|
1638
|
+
},
|
|
1639
|
+
architecture: {
|
|
1640
|
+
type: "string",
|
|
1641
|
+
description: "Architecture (x86 or arm).",
|
|
1642
|
+
default: "x86"
|
|
1643
|
+
},
|
|
1644
|
+
location: {
|
|
1645
|
+
type: "string",
|
|
1646
|
+
description: "Hetzner location (default: host hetzner.location)."
|
|
1647
|
+
},
|
|
1648
|
+
name: {
|
|
1649
|
+
type: "string",
|
|
1650
|
+
description: "Image name override (optional)."
|
|
1651
|
+
},
|
|
1652
|
+
dryRun: {
|
|
1653
|
+
type: "boolean",
|
|
1654
|
+
description: "Print commands without executing.",
|
|
1655
|
+
default: false
|
|
1656
|
+
},
|
|
1657
|
+
bin: {
|
|
1658
|
+
type: "string",
|
|
1659
|
+
description: "Override hcloud-upload-image binary (default: hcloud-upload-image)."
|
|
1660
|
+
}
|
|
1661
|
+
},
|
|
1662
|
+
async run({ args }) {
|
|
1663
|
+
const cwd = process$1.cwd();
|
|
1664
|
+
const ctx = loadHostContextOrExit({
|
|
1665
|
+
cwd,
|
|
1666
|
+
runtimeDir: args.runtimeDir,
|
|
1667
|
+
hostArg: args.host
|
|
1668
|
+
});
|
|
1669
|
+
if (!ctx) return;
|
|
1670
|
+
const { hostName, hostCfg } = ctx;
|
|
1671
|
+
const deployCreds = loadDeployCreds({
|
|
1672
|
+
cwd,
|
|
1673
|
+
runtimeDir: args.runtimeDir,
|
|
1674
|
+
envFile: args.envFile
|
|
1675
|
+
});
|
|
1676
|
+
const hcloudToken = String(deployCreds.values.HCLOUD_TOKEN || "").trim();
|
|
1677
|
+
if (!hcloudToken) throw new Error("missing HCLOUD_TOKEN (set in .clawdlets/env or env var; run: clawdlets env init)");
|
|
1678
|
+
const imageUrl = String(args["image-url"] || "").trim();
|
|
1679
|
+
if (!imageUrl) throw new Error("missing --image-url");
|
|
1680
|
+
const compression = String(args.compression || "").trim();
|
|
1681
|
+
const compressionArg = compression === "none" ? "" : compression;
|
|
1682
|
+
if (compressionArg && ![
|
|
1683
|
+
"gz",
|
|
1684
|
+
"bz2",
|
|
1685
|
+
"xz"
|
|
1686
|
+
].includes(compressionArg)) throw new Error("invalid --compression (expected none|gz|bz2|xz)");
|
|
1687
|
+
const architecture = String(args.architecture || "").trim() || "x86";
|
|
1688
|
+
if (!["x86", "arm"].includes(architecture)) throw new Error("invalid --architecture (expected x86|arm)");
|
|
1689
|
+
const location = String(args.location || hostCfg.hetzner.location || "nbg1").trim() || "nbg1";
|
|
1690
|
+
const name = String(args.name || "").trim();
|
|
1691
|
+
const bin = String(args.bin || "hcloud-upload-image").trim() || "hcloud-upload-image";
|
|
1692
|
+
const cmd = [
|
|
1693
|
+
"upload",
|
|
1694
|
+
"--image-url",
|
|
1695
|
+
imageUrl,
|
|
1696
|
+
"--architecture",
|
|
1697
|
+
architecture,
|
|
1698
|
+
"--location",
|
|
1699
|
+
location
|
|
1700
|
+
];
|
|
1701
|
+
if (compressionArg) cmd.push("--compression", compressionArg);
|
|
1702
|
+
if (name) cmd.push("--name", name);
|
|
1703
|
+
await run(bin, cmd, {
|
|
1704
|
+
env: {
|
|
1705
|
+
...process$1.env,
|
|
1706
|
+
HCLOUD_TOKEN: hcloudToken
|
|
1707
|
+
},
|
|
1708
|
+
dryRun: args.dryRun,
|
|
1709
|
+
redact: [hcloudToken]
|
|
1710
|
+
});
|
|
1711
|
+
console.log(`ok: upload complete for ${hostName}`);
|
|
1712
|
+
console.log(`hint: set hetzner.image in fleet/clawdlets.json to the new image ID/name`);
|
|
1713
|
+
}
|
|
1714
|
+
});
|
|
1715
|
+
const image = defineCommand({
|
|
1716
|
+
meta: {
|
|
1717
|
+
name: "image",
|
|
1718
|
+
description: "Image build/upload helpers (Hetzner custom images)."
|
|
1719
|
+
},
|
|
1720
|
+
subCommands: {
|
|
1721
|
+
build: imageBuild,
|
|
1722
|
+
upload: imageUpload
|
|
1723
|
+
}
|
|
1724
|
+
});
|
|
1725
|
+
|
|
1726
|
+
//#endregion
|
|
1727
|
+
//#region src/commands/infra.ts
|
|
1728
|
+
const infraApply = defineCommand({
|
|
1729
|
+
meta: {
|
|
1730
|
+
name: "apply",
|
|
1731
|
+
description: "Apply Hetzner OpenTofu for a host (driven by fleet/clawdlets.json)."
|
|
1732
|
+
},
|
|
1733
|
+
args: {
|
|
1734
|
+
runtimeDir: {
|
|
1735
|
+
type: "string",
|
|
1736
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
1737
|
+
},
|
|
1738
|
+
envFile: {
|
|
1739
|
+
type: "string",
|
|
1740
|
+
description: "Env file for deploy creds (default: <runtimeDir>/env)."
|
|
1741
|
+
},
|
|
1742
|
+
host: {
|
|
1743
|
+
type: "string",
|
|
1744
|
+
description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
|
|
1745
|
+
},
|
|
1746
|
+
dryRun: {
|
|
1747
|
+
type: "boolean",
|
|
1748
|
+
description: "Print commands without executing.",
|
|
1749
|
+
default: false
|
|
1750
|
+
}
|
|
1751
|
+
},
|
|
1752
|
+
async run({ args }) {
|
|
1753
|
+
const cwd = process$1.cwd();
|
|
1754
|
+
const repoRoot = findRepoRoot(cwd);
|
|
1755
|
+
const hostName = resolveHostNameOrExit({
|
|
1756
|
+
cwd,
|
|
1757
|
+
runtimeDir: args.runtimeDir,
|
|
1758
|
+
hostArg: args.host
|
|
1759
|
+
});
|
|
1760
|
+
if (!hostName) return;
|
|
1761
|
+
const { layout, config: clawdletsConfig } = loadClawdletsConfig({
|
|
1762
|
+
repoRoot,
|
|
1763
|
+
runtimeDir: args.runtimeDir
|
|
1764
|
+
});
|
|
1765
|
+
const hostCfg = clawdletsConfig.hosts[hostName];
|
|
1766
|
+
if (!hostCfg) throw new Error(`missing host in fleet/clawdlets.json: ${hostName}`);
|
|
1767
|
+
const opentofuDir = getHostOpenTofuDir(layout, hostName);
|
|
1768
|
+
const deployCreds = loadDeployCreds({
|
|
1769
|
+
cwd,
|
|
1770
|
+
runtimeDir: args.runtimeDir,
|
|
1771
|
+
envFile: args.envFile
|
|
1772
|
+
});
|
|
1773
|
+
if (deployCreds.envFile?.status === "invalid") throw new Error(`deploy env file rejected: ${deployCreds.envFile.path} (${deployCreds.envFile.error || "invalid"})`);
|
|
1774
|
+
if (deployCreds.envFile?.status === "missing") throw new Error(`missing deploy env file: ${deployCreds.envFile.path}`);
|
|
1775
|
+
const hcloudToken = String(deployCreds.values.HCLOUD_TOKEN || "").trim();
|
|
1776
|
+
if (!hcloudToken) throw new Error("missing HCLOUD_TOKEN (set in .clawdlets/env or env var; run: clawdlets env init)");
|
|
1777
|
+
const adminCidr = String(hostCfg.provisioning.adminCidr || "").trim();
|
|
1778
|
+
if (!adminCidr) throw new Error(`missing provisioning.adminCidr for ${hostName} (set via: clawdlets host set --admin-cidr ...)`);
|
|
1779
|
+
const sshPubkeyFileRaw = String(hostCfg.provisioning.sshPubkeyFile || "").trim();
|
|
1780
|
+
if (!sshPubkeyFileRaw) throw new Error(`missing provisioning.sshPubkeyFile for ${hostName} (set via: clawdlets host set --ssh-pubkey-file ...)`);
|
|
1781
|
+
const sshPubkeyFileExpanded = expandPath(sshPubkeyFileRaw);
|
|
1782
|
+
const sshPubkeyFile = path.isAbsolute(sshPubkeyFileExpanded) ? sshPubkeyFileExpanded : path.resolve(repoRoot, sshPubkeyFileExpanded);
|
|
1783
|
+
if (!fs.existsSync(sshPubkeyFile)) throw new Error(`ssh pubkey file not found: ${sshPubkeyFile}`);
|
|
1784
|
+
const image$1 = String(hostCfg.hetzner.image || "").trim();
|
|
1785
|
+
const location = String(hostCfg.hetzner.location || "").trim();
|
|
1786
|
+
await applyOpenTofuVars({
|
|
1787
|
+
opentofuDir,
|
|
1788
|
+
vars: {
|
|
1789
|
+
hostName,
|
|
1790
|
+
hcloudToken,
|
|
1791
|
+
adminCidr,
|
|
1792
|
+
adminCidrIsWorldOpen: Boolean(hostCfg.provisioning.adminCidrAllowWorldOpen),
|
|
1793
|
+
sshPubkeyFile,
|
|
1794
|
+
serverType: hostCfg.hetzner.serverType,
|
|
1795
|
+
image: image$1,
|
|
1796
|
+
location,
|
|
1797
|
+
sshExposureMode: getSshExposureMode(hostCfg),
|
|
1798
|
+
tailnetMode: getTailnetMode(hostCfg)
|
|
1799
|
+
},
|
|
1800
|
+
nixBin: String(deployCreds.values.NIX_BIN || "nix").trim() || "nix",
|
|
1801
|
+
dryRun: args.dryRun,
|
|
1802
|
+
redact: [hcloudToken, deployCreds.values.GITHUB_TOKEN].filter(Boolean)
|
|
1803
|
+
});
|
|
1804
|
+
console.log(`ok: provisioning applied for ${hostName}`);
|
|
1805
|
+
console.log(`hint: outputs in ${opentofuDir}`);
|
|
1806
|
+
}
|
|
1807
|
+
});
|
|
1808
|
+
const infraDestroy = defineCommand({
|
|
1809
|
+
meta: {
|
|
1810
|
+
name: "destroy",
|
|
1811
|
+
description: "Destroy Hetzner OpenTofu resources for a host (DANGEROUS)."
|
|
1812
|
+
},
|
|
1813
|
+
args: {
|
|
1814
|
+
runtimeDir: {
|
|
1815
|
+
type: "string",
|
|
1816
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
1817
|
+
},
|
|
1818
|
+
envFile: {
|
|
1819
|
+
type: "string",
|
|
1820
|
+
description: "Env file for deploy creds (default: <runtimeDir>/env)."
|
|
1821
|
+
},
|
|
1822
|
+
host: {
|
|
1823
|
+
type: "string",
|
|
1824
|
+
description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
|
|
1825
|
+
},
|
|
1826
|
+
force: {
|
|
1827
|
+
type: "boolean",
|
|
1828
|
+
description: "Skip confirmation prompt (non-interactive).",
|
|
1829
|
+
default: false
|
|
1830
|
+
},
|
|
1831
|
+
dryRun: {
|
|
1832
|
+
type: "boolean",
|
|
1833
|
+
description: "Print commands without executing.",
|
|
1834
|
+
default: false
|
|
1835
|
+
}
|
|
1836
|
+
},
|
|
1837
|
+
async run({ args }) {
|
|
1838
|
+
const cwd = process$1.cwd();
|
|
1839
|
+
const repoRoot = findRepoRoot(cwd);
|
|
1840
|
+
const hostName = resolveHostNameOrExit({
|
|
1841
|
+
cwd,
|
|
1842
|
+
runtimeDir: args.runtimeDir,
|
|
1843
|
+
hostArg: args.host
|
|
1844
|
+
});
|
|
1845
|
+
if (!hostName) return;
|
|
1846
|
+
const { layout, config: clawdletsConfig } = loadClawdletsConfig({
|
|
1847
|
+
repoRoot,
|
|
1848
|
+
runtimeDir: args.runtimeDir
|
|
1849
|
+
});
|
|
1850
|
+
const hostCfg = clawdletsConfig.hosts[hostName];
|
|
1851
|
+
if (!hostCfg) throw new Error(`missing host in fleet/clawdlets.json: ${hostName}`);
|
|
1852
|
+
const opentofuDir = getHostOpenTofuDir(layout, hostName);
|
|
1853
|
+
const deployCreds = loadDeployCreds({
|
|
1854
|
+
cwd,
|
|
1855
|
+
runtimeDir: args.runtimeDir,
|
|
1856
|
+
envFile: args.envFile
|
|
1857
|
+
});
|
|
1858
|
+
if (deployCreds.envFile?.status === "invalid") throw new Error(`deploy env file rejected: ${deployCreds.envFile.path} (${deployCreds.envFile.error || "invalid"})`);
|
|
1859
|
+
if (deployCreds.envFile?.status === "missing") throw new Error(`missing deploy env file: ${deployCreds.envFile.path}`);
|
|
1860
|
+
const hcloudToken = String(deployCreds.values.HCLOUD_TOKEN || "").trim();
|
|
1861
|
+
if (!hcloudToken) throw new Error("missing HCLOUD_TOKEN (set in .clawdlets/env or env var; run: clawdlets env init)");
|
|
1862
|
+
const adminCidr = String(hostCfg.provisioning.adminCidr || "").trim();
|
|
1863
|
+
if (!adminCidr) throw new Error(`missing provisioning.adminCidr for ${hostName} (set via: clawdlets host set --admin-cidr ...)`);
|
|
1864
|
+
const sshPubkeyFileRaw = String(hostCfg.provisioning.sshPubkeyFile || "").trim();
|
|
1865
|
+
if (!sshPubkeyFileRaw) throw new Error(`missing provisioning.sshPubkeyFile for ${hostName} (set via: clawdlets host set --ssh-pubkey-file ...)`);
|
|
1866
|
+
const sshPubkeyFileExpanded = expandPath(sshPubkeyFileRaw);
|
|
1867
|
+
const sshPubkeyFile = path.isAbsolute(sshPubkeyFileExpanded) ? sshPubkeyFileExpanded : path.resolve(repoRoot, sshPubkeyFileExpanded);
|
|
1868
|
+
if (!fs.existsSync(sshPubkeyFile)) throw new Error(`ssh pubkey file not found: ${sshPubkeyFile}`);
|
|
1869
|
+
const image$1 = String(hostCfg.hetzner.image || "").trim();
|
|
1870
|
+
const location = String(hostCfg.hetzner.location || "").trim();
|
|
1871
|
+
const force = Boolean(args.force);
|
|
1872
|
+
const interactive = process$1.stdin.isTTY && process$1.stdout.isTTY;
|
|
1873
|
+
if (!force) {
|
|
1874
|
+
if (!interactive) throw new Error("refusing to destroy without --force (no TTY)");
|
|
1875
|
+
p.intro("clawdlets infra destroy");
|
|
1876
|
+
const ok = await p.confirm({
|
|
1877
|
+
message: `Destroy Hetzner resources for host ${hostName}?`,
|
|
1878
|
+
initialValue: false
|
|
1879
|
+
});
|
|
1880
|
+
if (p.isCancel(ok) || !ok) {
|
|
1881
|
+
p.cancel("canceled");
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
await destroyOpenTofuVars({
|
|
1886
|
+
opentofuDir,
|
|
1887
|
+
vars: {
|
|
1888
|
+
hostName,
|
|
1889
|
+
hcloudToken,
|
|
1890
|
+
adminCidr,
|
|
1891
|
+
adminCidrIsWorldOpen: Boolean(hostCfg.provisioning.adminCidrAllowWorldOpen),
|
|
1892
|
+
sshPubkeyFile,
|
|
1893
|
+
serverType: hostCfg.hetzner.serverType,
|
|
1894
|
+
image: image$1,
|
|
1895
|
+
location,
|
|
1896
|
+
sshExposureMode: getSshExposureMode(hostCfg),
|
|
1897
|
+
tailnetMode: getTailnetMode(hostCfg)
|
|
1898
|
+
},
|
|
1899
|
+
nixBin: String(deployCreds.values.NIX_BIN || "nix").trim() || "nix",
|
|
1900
|
+
dryRun: args.dryRun,
|
|
1901
|
+
redact: [hcloudToken, deployCreds.values.GITHUB_TOKEN].filter(Boolean)
|
|
1902
|
+
});
|
|
1903
|
+
console.log(`ok: provisioning destroyed for ${hostName}`);
|
|
1904
|
+
console.log(`hint: state in ${opentofuDir}`);
|
|
1905
|
+
}
|
|
1906
|
+
});
|
|
1907
|
+
const infra = defineCommand({
|
|
1908
|
+
meta: {
|
|
1909
|
+
name: "infra",
|
|
1910
|
+
description: "Infrastructure operations (Hetzner OpenTofu)."
|
|
1911
|
+
},
|
|
1912
|
+
subCommands: {
|
|
1913
|
+
apply: infraApply,
|
|
1914
|
+
destroy: infraDestroy
|
|
1915
|
+
}
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1918
|
+
//#endregion
|
|
1919
|
+
//#region src/commands/lockdown.ts
|
|
1920
|
+
const lockdown = defineCommand({
|
|
1921
|
+
meta: {
|
|
1922
|
+
name: "lockdown",
|
|
1923
|
+
description: "Remove public SSH from Hetzner firewall (OpenTofu only)."
|
|
1924
|
+
},
|
|
1925
|
+
args: {
|
|
1926
|
+
runtimeDir: {
|
|
1927
|
+
type: "string",
|
|
1928
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
1929
|
+
},
|
|
1930
|
+
envFile: {
|
|
1931
|
+
type: "string",
|
|
1932
|
+
description: "Env file for deploy creds (default: <runtimeDir>/env)."
|
|
1933
|
+
},
|
|
1934
|
+
host: {
|
|
1935
|
+
type: "string",
|
|
1936
|
+
description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
|
|
1937
|
+
},
|
|
1938
|
+
skipTofu: {
|
|
1939
|
+
type: "boolean",
|
|
1940
|
+
description: "Skip provisioning apply.",
|
|
1941
|
+
default: false
|
|
1942
|
+
},
|
|
1943
|
+
dryRun: {
|
|
1944
|
+
type: "boolean",
|
|
1945
|
+
description: "Print commands without executing.",
|
|
1946
|
+
default: false
|
|
1947
|
+
}
|
|
1948
|
+
},
|
|
1949
|
+
async run({ args }) {
|
|
1950
|
+
const cwd = process$1.cwd();
|
|
1951
|
+
const repoRoot = findRepoRoot(cwd);
|
|
1952
|
+
const hostName = resolveHostNameOrExit({
|
|
1953
|
+
cwd,
|
|
1954
|
+
runtimeDir: args.runtimeDir,
|
|
1955
|
+
hostArg: args.host
|
|
1956
|
+
});
|
|
1957
|
+
if (!hostName) return;
|
|
1958
|
+
const { layout, config: clawdletsConfig } = loadClawdletsConfig({
|
|
1959
|
+
repoRoot,
|
|
1960
|
+
runtimeDir: args.runtimeDir
|
|
1961
|
+
});
|
|
1962
|
+
const hostCfg = clawdletsConfig.hosts[hostName];
|
|
1963
|
+
if (!hostCfg) throw new Error(`missing host in fleet/clawdlets.json: ${hostName}`);
|
|
1964
|
+
const opentofuDir = getHostOpenTofuDir(layout, hostName);
|
|
1965
|
+
const sshExposureMode = getSshExposureMode(hostCfg);
|
|
1966
|
+
if (sshExposureMode !== "tailnet") throw new Error(`sshExposure.mode=${sshExposureMode}; set sshExposure.mode=tailnet before lockdown (clawdlets host set --host ${hostName} --ssh-exposure tailnet)`);
|
|
1967
|
+
await requireDeployGate({
|
|
1968
|
+
runtimeDir: args.runtimeDir,
|
|
1969
|
+
envFile: args.envFile,
|
|
1970
|
+
host: hostName,
|
|
1971
|
+
scope: "server-deploy",
|
|
1972
|
+
strict: true,
|
|
1973
|
+
skipGithubTokenCheck: true
|
|
1974
|
+
});
|
|
1975
|
+
const deployCreds = loadDeployCreds({
|
|
1976
|
+
cwd,
|
|
1977
|
+
runtimeDir: args.runtimeDir,
|
|
1978
|
+
envFile: args.envFile
|
|
1979
|
+
});
|
|
1980
|
+
if (deployCreds.envFile?.status === "invalid") throw new Error(`deploy env file rejected: ${deployCreds.envFile.path} (${deployCreds.envFile.error || "invalid"})`);
|
|
1981
|
+
if (deployCreds.envFile?.status === "missing") throw new Error(`missing deploy env file: ${deployCreds.envFile.path}`);
|
|
1982
|
+
const hcloudToken = String(deployCreds.values.HCLOUD_TOKEN || "").trim();
|
|
1983
|
+
const githubToken = String(deployCreds.values.GITHUB_TOKEN || "").trim();
|
|
1984
|
+
if (!args.skipTofu) {
|
|
1985
|
+
if (!hcloudToken) throw new Error("missing HCLOUD_TOKEN (set in .clawdlets/env or env var; run: clawdlets env init)");
|
|
1986
|
+
const adminCidr = String(hostCfg.provisioning.adminCidr || "").trim();
|
|
1987
|
+
if (!adminCidr) throw new Error(`missing provisioning.adminCidr for ${hostName} (set via: clawdlets host set --admin-cidr ...)`);
|
|
1988
|
+
const sshPubkeyFileRaw = String(hostCfg.provisioning.sshPubkeyFile || "").trim();
|
|
1989
|
+
if (!sshPubkeyFileRaw) throw new Error(`missing provisioning.sshPubkeyFile for ${hostName} (set via: clawdlets host set --ssh-pubkey-file ...)`);
|
|
1990
|
+
const sshPubkeyFileExpanded = expandPath(sshPubkeyFileRaw);
|
|
1991
|
+
const sshPubkeyFile = path.isAbsolute(sshPubkeyFileExpanded) ? sshPubkeyFileExpanded : path.resolve(repoRoot, sshPubkeyFileExpanded);
|
|
1992
|
+
if (!fs.existsSync(sshPubkeyFile)) throw new Error(`ssh pubkey file not found: ${sshPubkeyFile}`);
|
|
1993
|
+
const image$1 = String(hostCfg.hetzner.image || "").trim();
|
|
1994
|
+
const location = String(hostCfg.hetzner.location || "").trim();
|
|
1995
|
+
await applyOpenTofuVars({
|
|
1996
|
+
opentofuDir,
|
|
1997
|
+
vars: {
|
|
1998
|
+
hostName,
|
|
1999
|
+
hcloudToken,
|
|
2000
|
+
adminCidr,
|
|
2001
|
+
adminCidrIsWorldOpen: Boolean(hostCfg.provisioning.adminCidrAllowWorldOpen),
|
|
2002
|
+
sshPubkeyFile,
|
|
2003
|
+
serverType: hostCfg.hetzner.serverType,
|
|
2004
|
+
image: image$1,
|
|
2005
|
+
location,
|
|
2006
|
+
sshExposureMode,
|
|
2007
|
+
tailnetMode: getTailnetMode(hostCfg)
|
|
2008
|
+
},
|
|
2009
|
+
nixBin: String(deployCreds.values.NIX_BIN || "nix").trim() || "nix",
|
|
2010
|
+
dryRun: args.dryRun,
|
|
2011
|
+
redact: [hcloudToken, githubToken].filter(Boolean)
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
});
|
|
2016
|
+
|
|
2017
|
+
//#endregion
|
|
2018
|
+
//#region src/commands/plugin.ts
|
|
2019
|
+
async function loadPlugins() {
|
|
2020
|
+
return await Promise.resolve().then(() => plugins_exports);
|
|
2021
|
+
}
|
|
2022
|
+
function resolveSlug(args) {
|
|
2023
|
+
const raw = String(args.name || args._?.[0] || "").trim();
|
|
2024
|
+
if (!raw) throw new Error("missing plugin name (pass --name or first arg)");
|
|
2025
|
+
return raw;
|
|
2026
|
+
}
|
|
2027
|
+
const list = defineCommand({
|
|
2028
|
+
meta: {
|
|
2029
|
+
name: "list",
|
|
2030
|
+
description: "List installed plugins."
|
|
2031
|
+
},
|
|
2032
|
+
args: {
|
|
2033
|
+
json: {
|
|
2034
|
+
type: "boolean",
|
|
2035
|
+
description: "Output JSON.",
|
|
2036
|
+
default: false
|
|
2037
|
+
},
|
|
2038
|
+
runtimeDir: {
|
|
2039
|
+
type: "string",
|
|
2040
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
2041
|
+
}
|
|
2042
|
+
},
|
|
2043
|
+
async run({ args }) {
|
|
2044
|
+
const { listInstalledPlugins: listInstalledPlugins$1 } = await loadPlugins();
|
|
2045
|
+
const errors = [];
|
|
2046
|
+
const plugins = listInstalledPlugins$1({
|
|
2047
|
+
cwd: process$1.cwd(),
|
|
2048
|
+
runtimeDir: args.runtimeDir,
|
|
2049
|
+
onError: (err) => errors.push(err)
|
|
2050
|
+
});
|
|
2051
|
+
const payload = {
|
|
2052
|
+
plugins,
|
|
2053
|
+
errors: errors.map((e) => ({
|
|
2054
|
+
slug: e.slug,
|
|
2055
|
+
error: e.error.message
|
|
2056
|
+
}))
|
|
2057
|
+
};
|
|
2058
|
+
if (args.json) {
|
|
2059
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
for (const err of errors) console.error(`warn: skipping plugin ${err.slug}: ${err.error.message}`);
|
|
2063
|
+
if (plugins.length === 0) {
|
|
2064
|
+
console.log("ok: no plugins installed");
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
for (const p$1 of plugins) console.log(`${p$1.command}\t${p$1.packageName}@${p$1.version}`);
|
|
2068
|
+
}
|
|
2069
|
+
});
|
|
2070
|
+
const add = defineCommand({
|
|
2071
|
+
meta: {
|
|
2072
|
+
name: "add",
|
|
2073
|
+
description: "Install a plugin into .clawdlets/plugins."
|
|
2074
|
+
},
|
|
2075
|
+
args: {
|
|
2076
|
+
name: {
|
|
2077
|
+
type: "string",
|
|
2078
|
+
description: "Plugin name (e.g. cattle)."
|
|
2079
|
+
},
|
|
2080
|
+
package: {
|
|
2081
|
+
type: "string",
|
|
2082
|
+
description: "Package to install (default: @clawdlets/plugin-<name>)."
|
|
2083
|
+
},
|
|
2084
|
+
version: {
|
|
2085
|
+
type: "string",
|
|
2086
|
+
description: "Package version/tag (default: latest)."
|
|
2087
|
+
},
|
|
2088
|
+
allowThirdParty: {
|
|
2089
|
+
type: "boolean",
|
|
2090
|
+
description: "Allow third-party plugins (unsafe).",
|
|
2091
|
+
default: false
|
|
2092
|
+
},
|
|
2093
|
+
runtimeDir: {
|
|
2094
|
+
type: "string",
|
|
2095
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
2096
|
+
}
|
|
2097
|
+
},
|
|
2098
|
+
async run({ args }) {
|
|
2099
|
+
const slug = resolveSlug(args);
|
|
2100
|
+
const packageName = String(args.package || `@clawdlets/plugin-${slug}`).trim();
|
|
2101
|
+
if (!args.allowThirdParty && !packageName.startsWith("@clawdlets/")) throw new Error("third-party plugins disabled (pass --allow-third-party to override)");
|
|
2102
|
+
const { installPlugin: installPlugin$1 } = await loadPlugins();
|
|
2103
|
+
const plugin = await installPlugin$1({
|
|
2104
|
+
cwd: process$1.cwd(),
|
|
2105
|
+
runtimeDir: args.runtimeDir,
|
|
2106
|
+
slug,
|
|
2107
|
+
packageName,
|
|
2108
|
+
version: args.version,
|
|
2109
|
+
allowThirdParty: args.allowThirdParty
|
|
2110
|
+
});
|
|
2111
|
+
console.log(`ok: installed ${plugin.command} (${plugin.packageName}@${plugin.version})`);
|
|
2112
|
+
}
|
|
2113
|
+
});
|
|
2114
|
+
const rm = defineCommand({
|
|
2115
|
+
meta: {
|
|
2116
|
+
name: "rm",
|
|
2117
|
+
description: "Remove an installed plugin."
|
|
2118
|
+
},
|
|
2119
|
+
args: {
|
|
2120
|
+
name: {
|
|
2121
|
+
type: "string",
|
|
2122
|
+
description: "Plugin name (e.g. cattle)."
|
|
2123
|
+
},
|
|
2124
|
+
runtimeDir: {
|
|
2125
|
+
type: "string",
|
|
2126
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
2127
|
+
}
|
|
2128
|
+
},
|
|
2129
|
+
async run({ args }) {
|
|
2130
|
+
const slug = resolveSlug(args);
|
|
2131
|
+
const { removePlugin: removePlugin$1 } = await loadPlugins();
|
|
2132
|
+
removePlugin$1({
|
|
2133
|
+
cwd: process$1.cwd(),
|
|
2134
|
+
runtimeDir: args.runtimeDir,
|
|
2135
|
+
slug
|
|
2136
|
+
});
|
|
2137
|
+
console.log(`ok: removed ${slug}`);
|
|
2138
|
+
}
|
|
2139
|
+
});
|
|
2140
|
+
const plugin = defineCommand({
|
|
2141
|
+
meta: {
|
|
2142
|
+
name: "plugin",
|
|
2143
|
+
description: "Plugin manager (install/remove/list)."
|
|
2144
|
+
},
|
|
2145
|
+
subCommands: {
|
|
2146
|
+
add,
|
|
2147
|
+
list,
|
|
2148
|
+
rm
|
|
2149
|
+
}
|
|
2150
|
+
});
|
|
2151
|
+
|
|
2152
|
+
//#endregion
|
|
2153
|
+
//#region src/lib/template-spec.ts
|
|
2154
|
+
function firstNonEmpty(...values) {
|
|
2155
|
+
for (const value of values) {
|
|
2156
|
+
const trimmed = String(value || "").trim();
|
|
2157
|
+
if (trimmed) return trimmed;
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
function resolveTemplateSourcePath() {
|
|
2161
|
+
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
2162
|
+
const packagedDistPath = path.resolve(moduleDir, "config", "template-source.json");
|
|
2163
|
+
if (fs.existsSync(packagedDistPath)) return packagedDistPath;
|
|
2164
|
+
const packagedPath = path.resolve(moduleDir, "..", "config", "template-source.json");
|
|
2165
|
+
if (fs.existsSync(packagedPath)) return packagedPath;
|
|
2166
|
+
const repoRootPath = path.resolve(moduleDir, "..", "..", "..", "..", "config", "template-source.json");
|
|
2167
|
+
if (fs.existsSync(repoRootPath)) return repoRootPath;
|
|
2168
|
+
const cwdPath = path.resolve(process.cwd(), "config", "template-source.json");
|
|
2169
|
+
if (fs.existsSync(cwdPath)) return cwdPath;
|
|
2170
|
+
throw new Error("template source config missing (expected config/template-source.json)");
|
|
2171
|
+
}
|
|
2172
|
+
function loadTemplateSourceDefaults() {
|
|
2173
|
+
const configPath = resolveTemplateSourcePath();
|
|
2174
|
+
const raw = fs.readFileSync(configPath, "utf8");
|
|
2175
|
+
const parsed = JSON.parse(raw);
|
|
2176
|
+
if (!parsed || typeof parsed !== "object") throw new Error(`template source config invalid: ${configPath}`);
|
|
2177
|
+
return {
|
|
2178
|
+
repo: String(parsed.repo || ""),
|
|
2179
|
+
path: String(parsed.path || ""),
|
|
2180
|
+
ref: String(parsed.ref || "")
|
|
2181
|
+
};
|
|
2182
|
+
}
|
|
2183
|
+
function resolveTemplateSpec(args) {
|
|
2184
|
+
const defaults = loadTemplateSourceDefaults();
|
|
2185
|
+
const repo = firstNonEmpty(args.template, process.env["CLAWDLETS_TEMPLATE_REPO"], defaults.repo);
|
|
2186
|
+
const tplPath = firstNonEmpty(args.templatePath, process.env["CLAWDLETS_TEMPLATE_PATH"], defaults.path);
|
|
2187
|
+
const ref = firstNonEmpty(args.templateRef, process.env["CLAWDLETS_TEMPLATE_REF"], defaults.ref);
|
|
2188
|
+
return normalizeTemplateSource({
|
|
2189
|
+
repo: repo || "",
|
|
2190
|
+
path: tplPath || "",
|
|
2191
|
+
ref: ref || ""
|
|
2192
|
+
});
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
//#endregion
|
|
2196
|
+
//#region src/commands/project.ts
|
|
2197
|
+
function wantsInteractive$1(flag) {
|
|
2198
|
+
if (flag) return true;
|
|
2199
|
+
const env$1 = String(process$1.env["CLAWDLETS_INTERACTIVE"] || "").trim();
|
|
2200
|
+
return env$1 === "1" || env$1.toLowerCase() === "true";
|
|
2201
|
+
}
|
|
2202
|
+
function requireTtyIfInteractive(interactive) {
|
|
2203
|
+
if (!interactive) return;
|
|
2204
|
+
if (!process$1.stdout.isTTY) throw new Error("--interactive requires a TTY");
|
|
2205
|
+
}
|
|
2206
|
+
function applySubs(s, subs) {
|
|
2207
|
+
let out = s;
|
|
2208
|
+
for (const [k, v] of Object.entries(subs)) out = out.split(k).join(v);
|
|
2209
|
+
return out;
|
|
2210
|
+
}
|
|
2211
|
+
function isProbablyText(file) {
|
|
2212
|
+
const base = path.basename(file);
|
|
2213
|
+
if (base === "Justfile" || base === "_gitignore") return true;
|
|
2214
|
+
const ext = path.extname(file).toLowerCase();
|
|
2215
|
+
return [
|
|
2216
|
+
".md",
|
|
2217
|
+
".nix",
|
|
2218
|
+
".tf",
|
|
2219
|
+
".hcl",
|
|
2220
|
+
".json",
|
|
2221
|
+
".yaml",
|
|
2222
|
+
".yml",
|
|
2223
|
+
".txt",
|
|
2224
|
+
".lock",
|
|
2225
|
+
".gitignore"
|
|
2226
|
+
].includes(ext);
|
|
2227
|
+
}
|
|
2228
|
+
async function copyTree(params) {
|
|
2229
|
+
const entries = await fs.promises.readdir(params.srcDir, { withFileTypes: true });
|
|
2230
|
+
for (const ent of entries) {
|
|
2231
|
+
const srcName = ent.name;
|
|
2232
|
+
const srcPath = path.join(params.srcDir, srcName);
|
|
2233
|
+
const renamed = srcName === "_gitignore" ? ".gitignore" : applySubs(srcName, params.subs);
|
|
2234
|
+
const destPath = path.join(params.destDir, renamed);
|
|
2235
|
+
if (ent.isDirectory()) {
|
|
2236
|
+
await ensureDir(destPath);
|
|
2237
|
+
await copyTree({
|
|
2238
|
+
srcDir: srcPath,
|
|
2239
|
+
destDir: destPath,
|
|
2240
|
+
subs: params.subs
|
|
2241
|
+
});
|
|
2242
|
+
continue;
|
|
2243
|
+
}
|
|
2244
|
+
if (!ent.isFile()) continue;
|
|
2245
|
+
const buf = await fs.promises.readFile(srcPath);
|
|
2246
|
+
if (!isProbablyText(srcName)) {
|
|
2247
|
+
await ensureDir(path.dirname(destPath));
|
|
2248
|
+
await fs.promises.writeFile(destPath, buf);
|
|
2249
|
+
continue;
|
|
2250
|
+
}
|
|
2251
|
+
await writeFileAtomic(destPath, applySubs(buf.toString("utf8"), params.subs));
|
|
2252
|
+
}
|
|
2253
|
+
}
|
|
2254
|
+
async function dirHasAnyFiles(dir) {
|
|
2255
|
+
try {
|
|
2256
|
+
return (await fs.promises.readdir(dir)).length > 0;
|
|
2257
|
+
} catch {
|
|
2258
|
+
return false;
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
async function ensureHookExecutables(repoRoot) {
|
|
2262
|
+
const hooksDir = path.join(repoRoot, ".githooks");
|
|
2263
|
+
try {
|
|
2264
|
+
const entries = await fs.promises.readdir(hooksDir, { withFileTypes: true });
|
|
2265
|
+
let hasHooks = false;
|
|
2266
|
+
for (const ent of entries) {
|
|
2267
|
+
if (!ent.isFile()) continue;
|
|
2268
|
+
const p$1 = path.join(hooksDir, ent.name);
|
|
2269
|
+
await fs.promises.chmod(p$1, 493);
|
|
2270
|
+
hasHooks = true;
|
|
2271
|
+
}
|
|
2272
|
+
return hasHooks;
|
|
2273
|
+
} catch {
|
|
2274
|
+
return false;
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
async function findTemplateRoot(dir) {
|
|
2278
|
+
const direct = path.join(dir, "fleet", "clawdlets.json");
|
|
2279
|
+
if (fs.existsSync(direct)) return dir;
|
|
2280
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
2281
|
+
const candidates = [];
|
|
2282
|
+
for (const ent of entries) {
|
|
2283
|
+
if (!ent.isDirectory()) continue;
|
|
2284
|
+
const candidate = path.join(dir, ent.name);
|
|
2285
|
+
if (fs.existsSync(path.join(candidate, "fleet", "clawdlets.json"))) candidates.push(candidate);
|
|
2286
|
+
}
|
|
2287
|
+
if (candidates.length === 1) return candidates[0];
|
|
2288
|
+
throw new Error(`template root missing fleet/clawdlets.json (searched: ${dir})`);
|
|
2289
|
+
}
|
|
2290
|
+
const projectInit = defineCommand({
|
|
2291
|
+
meta: {
|
|
2292
|
+
name: "init",
|
|
2293
|
+
description: "Scaffold a new clawdlets infra repo (from clawdlets-template)."
|
|
2294
|
+
},
|
|
2295
|
+
args: {
|
|
2296
|
+
dir: {
|
|
2297
|
+
type: "string",
|
|
2298
|
+
description: "Target directory (created if missing)."
|
|
2299
|
+
},
|
|
2300
|
+
host: {
|
|
2301
|
+
type: "string",
|
|
2302
|
+
description: "Host name placeholder (default: clawdbot-fleet-host).",
|
|
2303
|
+
default: "clawdbot-fleet-host"
|
|
2304
|
+
},
|
|
2305
|
+
gitInit: {
|
|
2306
|
+
type: "boolean",
|
|
2307
|
+
description: "Run `git init` in the new directory.",
|
|
2308
|
+
default: true
|
|
2309
|
+
},
|
|
2310
|
+
interactive: {
|
|
2311
|
+
type: "boolean",
|
|
2312
|
+
description: "Prompt for confirmation (requires TTY).",
|
|
2313
|
+
default: false
|
|
2314
|
+
},
|
|
2315
|
+
dryRun: {
|
|
2316
|
+
type: "boolean",
|
|
2317
|
+
description: "Print planned files without writing.",
|
|
2318
|
+
default: false
|
|
2319
|
+
},
|
|
2320
|
+
template: {
|
|
2321
|
+
type: "string",
|
|
2322
|
+
description: "Template repo (default: config/template-source.json)."
|
|
2323
|
+
},
|
|
2324
|
+
templatePath: {
|
|
2325
|
+
type: "string",
|
|
2326
|
+
description: "Template path inside repo (default: config/template-source.json)."
|
|
2327
|
+
},
|
|
2328
|
+
templateRef: {
|
|
2329
|
+
type: "string",
|
|
2330
|
+
description: "Template git ref (default: config/template-source.json)."
|
|
2331
|
+
}
|
|
2332
|
+
},
|
|
2333
|
+
async run({ args }) {
|
|
2334
|
+
const interactive = wantsInteractive$1(Boolean(args.interactive));
|
|
2335
|
+
requireTtyIfInteractive(interactive);
|
|
2336
|
+
const dirRaw = String(args.dir || "").trim();
|
|
2337
|
+
if (!dirRaw) throw new Error("missing --dir");
|
|
2338
|
+
const destDir = path.resolve(process$1.cwd(), dirRaw);
|
|
2339
|
+
const host$1 = String(args.host || "clawdbot-fleet-host").trim() || "clawdbot-fleet-host";
|
|
2340
|
+
assertSafeHostName(host$1);
|
|
2341
|
+
const projectName = path.basename(destDir);
|
|
2342
|
+
if (interactive) {
|
|
2343
|
+
p.intro("clawdlets project init");
|
|
2344
|
+
const ok = await p.confirm({
|
|
2345
|
+
message: `Create project at ${destDir}?`,
|
|
2346
|
+
initialValue: true
|
|
2347
|
+
});
|
|
2348
|
+
if (p.isCancel(ok)) {
|
|
2349
|
+
if (await navOnCancel({
|
|
2350
|
+
flow: "project init",
|
|
2351
|
+
canBack: false
|
|
2352
|
+
}) === NAV_EXIT) cancelFlow();
|
|
2353
|
+
return;
|
|
2354
|
+
}
|
|
2355
|
+
if (!ok) {
|
|
2356
|
+
cancelFlow();
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
const templateSpec = resolveTemplateSpec({
|
|
2361
|
+
template: args.template,
|
|
2362
|
+
templatePath: args.templatePath,
|
|
2363
|
+
templateRef: args.templateRef
|
|
2364
|
+
});
|
|
2365
|
+
if (fs.existsSync(destDir) && await dirHasAnyFiles(destDir)) throw new Error(`target dir not empty: ${destDir}`);
|
|
2366
|
+
const subs = {
|
|
2367
|
+
"__PROJECT_NAME__": projectName,
|
|
2368
|
+
"clawdbot-fleet-host": host$1,
|
|
2369
|
+
"clawdbot_fleet_host": host$1.replace(/-/g, "_")
|
|
2370
|
+
};
|
|
2371
|
+
const tempDir = await fs.promises.mkdtemp(path.join(tmpdir(), "clawdlets-template-"));
|
|
2372
|
+
let templateDir = tempDir;
|
|
2373
|
+
try {
|
|
2374
|
+
templateDir = await findTemplateRoot((await downloadTemplate(templateSpec.spec, {
|
|
2375
|
+
dir: tempDir,
|
|
2376
|
+
force: true,
|
|
2377
|
+
auth: String(process$1.env["GITHUB_TOKEN"] || process$1.env["CLAWDLETS_TEMPLATE_TOKEN"] || "").trim() || void 0
|
|
2378
|
+
})).dir || tempDir);
|
|
2379
|
+
} catch (e) {
|
|
2380
|
+
await fs.promises.rm(tempDir, {
|
|
2381
|
+
recursive: true,
|
|
2382
|
+
force: true
|
|
2383
|
+
});
|
|
2384
|
+
throw e;
|
|
2385
|
+
}
|
|
2386
|
+
const planned = [];
|
|
2387
|
+
const walk = async (srcDir, rel) => {
|
|
2388
|
+
const entries = await fs.promises.readdir(srcDir, { withFileTypes: true });
|
|
2389
|
+
for (const ent of entries) {
|
|
2390
|
+
const srcName = ent.name;
|
|
2391
|
+
const mapped = srcName === "_gitignore" ? ".gitignore" : applySubs(srcName, subs);
|
|
2392
|
+
const nextRel = path.join(rel, mapped);
|
|
2393
|
+
if (ent.isDirectory()) await walk(path.join(srcDir, srcName), nextRel);
|
|
2394
|
+
else if (ent.isFile()) planned.push(nextRel);
|
|
2395
|
+
}
|
|
2396
|
+
};
|
|
2397
|
+
await walk(templateDir, ".");
|
|
2398
|
+
if (args.dryRun) {
|
|
2399
|
+
p.note(planned.sort().join("\n"), "Planned files");
|
|
2400
|
+
p.outro("dry-run");
|
|
2401
|
+
await fs.promises.rm(tempDir, {
|
|
2402
|
+
recursive: true,
|
|
2403
|
+
force: true
|
|
2404
|
+
});
|
|
2405
|
+
return;
|
|
2406
|
+
}
|
|
2407
|
+
await ensureDir(destDir);
|
|
2408
|
+
await copyTree({
|
|
2409
|
+
srcDir: templateDir,
|
|
2410
|
+
destDir,
|
|
2411
|
+
subs
|
|
2412
|
+
});
|
|
2413
|
+
await fs.promises.rm(tempDir, {
|
|
2414
|
+
recursive: true,
|
|
2415
|
+
force: true
|
|
2416
|
+
});
|
|
2417
|
+
const hasHooks = await ensureHookExecutables(destDir);
|
|
2418
|
+
{
|
|
2419
|
+
const configPath = path.join(destDir, "fleet", "clawdlets.json");
|
|
2420
|
+
const raw = await fs.promises.readFile(configPath, "utf8");
|
|
2421
|
+
const parsed = JSON.parse(raw);
|
|
2422
|
+
const hostCfg = parsed?.hosts?.[host$1];
|
|
2423
|
+
if (hostCfg && typeof hostCfg === "object") {
|
|
2424
|
+
hostCfg.cache = hostCfg.cache && typeof hostCfg.cache === "object" ? hostCfg.cache : {};
|
|
2425
|
+
hostCfg.cache.garnix = hostCfg.cache.garnix && typeof hostCfg.cache.garnix === "object" ? hostCfg.cache.garnix : {};
|
|
2426
|
+
hostCfg.cache.garnix.private = hostCfg.cache.garnix.private && typeof hostCfg.cache.garnix.private === "object" ? hostCfg.cache.garnix.private : {};
|
|
2427
|
+
hostCfg.cache.garnix.private.enable = false;
|
|
2428
|
+
await writeFileAtomic(configPath, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
if (args.gitInit) try {
|
|
2432
|
+
await capture("git", ["--version"], { cwd: destDir });
|
|
2433
|
+
await run("git", ["init"], { cwd: destDir });
|
|
2434
|
+
if (hasHooks) await run("git", [
|
|
2435
|
+
"config",
|
|
2436
|
+
"core.hooksPath",
|
|
2437
|
+
".githooks"
|
|
2438
|
+
], { cwd: destDir });
|
|
2439
|
+
} catch {
|
|
2440
|
+
if (interactive) p.note("git not available; skipped `git init`", "gitInit");
|
|
2441
|
+
}
|
|
2442
|
+
const next = [
|
|
2443
|
+
"next:",
|
|
2444
|
+
`- cd ${destDir}`,
|
|
2445
|
+
"- create a git repo + set origin (recommended; enables blank base flake)",
|
|
2446
|
+
"- clawdlets env init # set HCLOUD_TOKEN in .clawdlets/env (required for provisioning)",
|
|
2447
|
+
`- clawdlets host set --host ${host$1} --admin-cidr <your-ip>/32 --disk-device /dev/sda --add-ssh-key-file $HOME/.ssh/id_ed25519.pub`,
|
|
2448
|
+
`- clawdlets host set --host ${host$1} --ssh-exposure bootstrap`,
|
|
2449
|
+
`- clawdlets secrets init --host ${host$1}`,
|
|
2450
|
+
`- clawdlets doctor --host ${host$1}`,
|
|
2451
|
+
`- clawdlets bootstrap --host ${host$1}`,
|
|
2452
|
+
`- clawdlets host set --host ${host$1} --target-host <ssh-alias|user@host>`,
|
|
2453
|
+
`- clawdlets host set --host ${host$1} --ssh-exposure tailnet`,
|
|
2454
|
+
`- clawdlets lockdown --host ${host$1}`
|
|
2455
|
+
].join("\n");
|
|
2456
|
+
if (interactive) p.outro(next);
|
|
2457
|
+
else console.log(next);
|
|
2458
|
+
}
|
|
2459
|
+
});
|
|
2460
|
+
const project = defineCommand({
|
|
2461
|
+
meta: {
|
|
2462
|
+
name: "project",
|
|
2463
|
+
description: "Project scaffolding."
|
|
2464
|
+
},
|
|
2465
|
+
subCommands: { init: projectInit }
|
|
2466
|
+
});
|
|
2467
|
+
|
|
2468
|
+
//#endregion
|
|
2469
|
+
//#region src/commands/ssh-target.ts
|
|
2470
|
+
function needsSudo(targetHost) {
|
|
2471
|
+
return !/^root@/i.test(targetHost.trim());
|
|
2472
|
+
}
|
|
2473
|
+
function requireTargetHost(targetHost, hostName) {
|
|
2474
|
+
const v = targetHost.trim();
|
|
2475
|
+
if (v) return validateTargetHost(v);
|
|
2476
|
+
throw new Error([
|
|
2477
|
+
`missing target host for ${hostName}`,
|
|
2478
|
+
"set it in fleet/clawdlets.json (hosts.<host>.targetHost) or pass --target-host",
|
|
2479
|
+
"recommended: use an SSH config alias (e.g. botsmj)"
|
|
2480
|
+
].join("; "));
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
//#endregion
|
|
2484
|
+
//#region src/commands/secrets/common.ts
|
|
2485
|
+
function quoteYamlString(value) {
|
|
2486
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t").replace(/"/g, "\\\"")}"`;
|
|
2487
|
+
}
|
|
2488
|
+
function upsertYamlScalarLine(params) {
|
|
2489
|
+
const { text, key, value } = params;
|
|
2490
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2491
|
+
const rx = new RegExp(`^\\s*${escaped}\\s*:\\s*.*$`, "m");
|
|
2492
|
+
const line = `${key}: ${quoteYamlString(value)}`;
|
|
2493
|
+
if (rx.test(text)) return text.replace(rx, line);
|
|
2494
|
+
return `${text.trimEnd()}\n${line}\n`;
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
//#endregion
|
|
2498
|
+
//#region src/commands/secrets/init.ts
|
|
2499
|
+
function wantsInteractive(flag) {
|
|
2500
|
+
if (flag) return true;
|
|
2501
|
+
const env$1 = String(process$1.env["CLAWDLETS_INTERACTIVE"] || "").trim();
|
|
2502
|
+
return env$1 === "1" || env$1.toLowerCase() === "true";
|
|
2503
|
+
}
|
|
2504
|
+
function readSecretsInitJson(fromJson) {
|
|
2505
|
+
const src = String(fromJson || "").trim();
|
|
2506
|
+
if (!src) throw new Error("missing --from-json");
|
|
2507
|
+
let raw;
|
|
2508
|
+
if (src === "-") raw = fs.readFileSync(0, "utf8");
|
|
2509
|
+
else {
|
|
2510
|
+
const jsonPath = path.isAbsolute(src) ? src : path.resolve(process$1.cwd(), src);
|
|
2511
|
+
if (!fs.existsSync(jsonPath)) throw new Error(`missing --from-json file: ${jsonPath}`);
|
|
2512
|
+
raw = fs.readFileSync(jsonPath, "utf8");
|
|
2513
|
+
}
|
|
2514
|
+
return parseSecretsInitJson(raw);
|
|
2515
|
+
}
|
|
2516
|
+
const secretsInit = defineCommand({
|
|
2517
|
+
meta: {
|
|
2518
|
+
name: "init",
|
|
2519
|
+
description: "Create/update secrets in /secrets (sops+age) and generate .clawdlets/extra-files/<host>/..."
|
|
2520
|
+
},
|
|
2521
|
+
args: {
|
|
2522
|
+
runtimeDir: {
|
|
2523
|
+
type: "string",
|
|
2524
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
2525
|
+
},
|
|
2526
|
+
host: {
|
|
2527
|
+
type: "string",
|
|
2528
|
+
description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
|
|
2529
|
+
},
|
|
2530
|
+
interactive: {
|
|
2531
|
+
type: "boolean",
|
|
2532
|
+
description: "Prompt for secret values (requires TTY).",
|
|
2533
|
+
default: false
|
|
2534
|
+
},
|
|
2535
|
+
fromJson: {
|
|
2536
|
+
type: "string",
|
|
2537
|
+
description: "Read secret values from JSON file (or '-' for stdin) (non-interactive)."
|
|
2538
|
+
},
|
|
2539
|
+
allowPlaceholders: {
|
|
2540
|
+
type: "boolean",
|
|
2541
|
+
description: "Allow placeholders for missing tokens.",
|
|
2542
|
+
default: false
|
|
2543
|
+
},
|
|
2544
|
+
operator: {
|
|
2545
|
+
type: "string",
|
|
2546
|
+
description: "Operator id for local age key name (default: $USER)."
|
|
2547
|
+
},
|
|
2548
|
+
yes: {
|
|
2549
|
+
type: "boolean",
|
|
2550
|
+
description: "Overwrite without prompt.",
|
|
2551
|
+
default: false
|
|
2552
|
+
},
|
|
2553
|
+
dryRun: {
|
|
2554
|
+
type: "boolean",
|
|
2555
|
+
description: "Print actions without writing.",
|
|
2556
|
+
default: false
|
|
2557
|
+
}
|
|
2558
|
+
},
|
|
2559
|
+
async run({ args }) {
|
|
2560
|
+
const a = args;
|
|
2561
|
+
const ctx = loadHostContextOrExit({
|
|
2562
|
+
cwd: process$1.cwd(),
|
|
2563
|
+
runtimeDir: a.runtimeDir,
|
|
2564
|
+
hostArg: a.host
|
|
2565
|
+
});
|
|
2566
|
+
if (!ctx) return;
|
|
2567
|
+
const { layout, config: clawdletsConfig, hostName, hostCfg } = ctx;
|
|
2568
|
+
const hasTty = Boolean(process$1.stdin.isTTY && process$1.stdout.isTTY);
|
|
2569
|
+
let interactive = wantsInteractive(Boolean(a.interactive));
|
|
2570
|
+
if (!interactive && hasTty && !a.fromJson) interactive = true;
|
|
2571
|
+
if (interactive && !hasTty) throw new Error("--interactive requires a TTY");
|
|
2572
|
+
const operatorId = sanitizeOperatorId(String(a.operator || process$1.env.USER || "operator"));
|
|
2573
|
+
const sopsConfigPath = layout.sopsConfigPath;
|
|
2574
|
+
const operatorKeyPath = getLocalOperatorAgeKeyPath(layout, operatorId);
|
|
2575
|
+
const operatorPubPath = path.join(layout.localOperatorKeysDir, `${operatorId}.age.pub`);
|
|
2576
|
+
const hostKeyFile = getHostEncryptedAgeKeyFile(layout, hostName);
|
|
2577
|
+
const extraFilesKeyPath = getHostExtraFilesKeyPath(layout, hostName);
|
|
2578
|
+
const extraFilesSecretsDir = getHostExtraFilesSecretsDir(layout, hostName);
|
|
2579
|
+
const localSecretsDir = getHostSecretsDir(layout, hostName);
|
|
2580
|
+
const bots = clawdletsConfig.fleet.botOrder;
|
|
2581
|
+
if (bots.length === 0) throw new Error("fleet.botOrder is empty (set bots in fleet/clawdlets.json)");
|
|
2582
|
+
const requiresTailscaleAuthKey = String(hostCfg.tailnet?.mode || "none") === "tailscale";
|
|
2583
|
+
const garnixPrivate = hostCfg.cache?.garnix?.private;
|
|
2584
|
+
const garnixPrivateEnabled = Boolean(garnixPrivate?.enable);
|
|
2585
|
+
const garnixNetrcSecretName = garnixPrivateEnabled ? String(garnixPrivate?.netrcSecret || "garnix_netrc").trim() : "";
|
|
2586
|
+
const garnixNetrcPath = garnixPrivateEnabled ? String(garnixPrivate?.netrcPath || "/etc/nix/netrc").trim() : "";
|
|
2587
|
+
if (garnixPrivateEnabled && !garnixNetrcSecretName) throw new Error("cache.garnix.private.netrcSecret must be set when private cache is enabled");
|
|
2588
|
+
const secretsPlan = buildFleetSecretsPlan({
|
|
2589
|
+
config: clawdletsConfig,
|
|
2590
|
+
hostName
|
|
2591
|
+
});
|
|
2592
|
+
if (secretsPlan.missingSecretConfig.length > 0) {
|
|
2593
|
+
const first = secretsPlan.missingSecretConfig[0];
|
|
2594
|
+
if (first.kind === "discord") throw new Error(`missing discordTokenSecret for bot=${first.bot} (set fleet.bots.${first.bot}.profile.discordTokenSecret)`);
|
|
2595
|
+
throw new Error(`missing modelSecrets entry for provider=${first.provider} (bot=${first.bot}, model=${first.model}); set fleet.modelSecrets.${first.provider}`);
|
|
2596
|
+
}
|
|
2597
|
+
const requiredSecretNames = new Set(secretsPlan.secretNamesRequired);
|
|
2598
|
+
const discordSecretByName = /* @__PURE__ */ new Map();
|
|
2599
|
+
for (const [bot$1, secretName] of Object.entries(secretsPlan.discordSecretsByBot)) if (secretName) discordSecretByName.set(secretName, bot$1);
|
|
2600
|
+
const discordBotsRequired = bots.filter((b) => {
|
|
2601
|
+
const secretName = secretsPlan.discordSecretsByBot[b] || "";
|
|
2602
|
+
return secretName && requiredSecretNames.has(secretName);
|
|
2603
|
+
});
|
|
2604
|
+
const requiredExtraSecretNames = new Set([...requiredSecretNames, ...garnixPrivateEnabled ? [garnixNetrcSecretName] : []]);
|
|
2605
|
+
const templateExtraSecrets = {};
|
|
2606
|
+
for (const secretName of secretsPlan.secretNamesAll) templateExtraSecrets[secretName] = requiredSecretNames.has(secretName) ? "<REPLACE_WITH_SECRET>" : "<OPTIONAL>";
|
|
2607
|
+
if (garnixPrivateEnabled) templateExtraSecrets[garnixNetrcSecretName] = "<REPLACE_WITH_NETRC>";
|
|
2608
|
+
const defaultSecretsJsonPath = path.join(layout.runtimeDir, "secrets.json");
|
|
2609
|
+
const defaultSecretsJsonDisplay = path.relative(process$1.cwd(), defaultSecretsJsonPath) || defaultSecretsJsonPath;
|
|
2610
|
+
let fromJson = resolveSecretsInitFromJsonArg({
|
|
2611
|
+
fromJsonRaw: a.fromJson,
|
|
2612
|
+
argv: process$1.argv,
|
|
2613
|
+
stdinIsTTY: Boolean(process$1.stdin.isTTY)
|
|
2614
|
+
});
|
|
2615
|
+
if (!interactive && !fromJson) if (fs.existsSync(defaultSecretsJsonPath)) {
|
|
2616
|
+
fromJson = defaultSecretsJsonPath;
|
|
2617
|
+
if (!a.allowPlaceholders) {
|
|
2618
|
+
const placeholders = listSecretsInitPlaceholders({
|
|
2619
|
+
input: parseSecretsInitJson(fs.readFileSync(defaultSecretsJsonPath, "utf8")),
|
|
2620
|
+
bots,
|
|
2621
|
+
discordBots: discordBotsRequired,
|
|
2622
|
+
requiresTailscaleAuthKey
|
|
2623
|
+
});
|
|
2624
|
+
if (placeholders.length > 0) {
|
|
2625
|
+
console.error(`error: placeholders found in ${defaultSecretsJsonDisplay} (fill it or pass --allow-placeholders)`);
|
|
2626
|
+
for (const p0 of placeholders) console.error(`- ${p0}`);
|
|
2627
|
+
process$1.exitCode = 1;
|
|
2628
|
+
return;
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
} else {
|
|
2632
|
+
const template = buildSecretsInitTemplate({
|
|
2633
|
+
bots,
|
|
2634
|
+
discordBots: discordBotsRequired,
|
|
2635
|
+
requiresTailscaleAuthKey,
|
|
2636
|
+
secrets: templateExtraSecrets
|
|
2637
|
+
});
|
|
2638
|
+
if (!a.dryRun) {
|
|
2639
|
+
await ensureDir(path.dirname(defaultSecretsJsonPath));
|
|
2640
|
+
await writeFileAtomic(defaultSecretsJsonPath, `${JSON.stringify(template, null, 2)}\n`, { mode: 384 });
|
|
2641
|
+
}
|
|
2642
|
+
console.error(`${a.dryRun ? "would write" : "wrote"} secrets template: ${defaultSecretsJsonDisplay}`);
|
|
2643
|
+
if (a.dryRun) console.error("run without --dry-run to write it");
|
|
2644
|
+
else console.error(`fill it, then run: clawdlets secrets init --from-json ${defaultSecretsJsonDisplay}`);
|
|
2645
|
+
process$1.exitCode = 1;
|
|
2646
|
+
return;
|
|
2647
|
+
}
|
|
2648
|
+
validateSecretsInitNonInteractive({
|
|
2649
|
+
interactive,
|
|
2650
|
+
fromJson,
|
|
2651
|
+
yes: Boolean(a.yes),
|
|
2652
|
+
dryRun: Boolean(a.dryRun),
|
|
2653
|
+
localSecretsDirExists: fs.existsSync(localSecretsDir)
|
|
2654
|
+
});
|
|
2655
|
+
if (interactive && fs.existsSync(localSecretsDir) && !a.yes) {
|
|
2656
|
+
const ok = await p.confirm({
|
|
2657
|
+
message: `Update existing secrets dir? (${localSecretsDir})`,
|
|
2658
|
+
initialValue: true
|
|
2659
|
+
});
|
|
2660
|
+
if (p.isCancel(ok)) {
|
|
2661
|
+
if (await navOnCancel({
|
|
2662
|
+
flow: "secrets init",
|
|
2663
|
+
canBack: false
|
|
2664
|
+
}) === NAV_EXIT) cancelFlow();
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2667
|
+
if (!ok) return;
|
|
2668
|
+
}
|
|
2669
|
+
const nix = {
|
|
2670
|
+
nixBin: String(process$1.env.NIX_BIN || "nix").trim() || "nix",
|
|
2671
|
+
cwd: layout.repoRoot,
|
|
2672
|
+
dryRun: Boolean(a.dryRun)
|
|
2673
|
+
};
|
|
2674
|
+
const ensureAgePair = async (keyPath, pubPath) => {
|
|
2675
|
+
if (fs.existsSync(keyPath) && fs.existsSync(pubPath)) {
|
|
2676
|
+
const parsed = parseAgeKeyFile(fs.readFileSync(keyPath, "utf8"));
|
|
2677
|
+
const publicKey = fs.readFileSync(pubPath, "utf8").trim();
|
|
2678
|
+
if (!parsed.secretKey) throw new Error(`invalid age key: ${keyPath}`);
|
|
2679
|
+
if (!publicKey) throw new Error(`invalid age public key: ${pubPath}`);
|
|
2680
|
+
return {
|
|
2681
|
+
secretKey: parsed.secretKey,
|
|
2682
|
+
publicKey
|
|
2683
|
+
};
|
|
2684
|
+
}
|
|
2685
|
+
const pair = await ageKeygen(nix);
|
|
2686
|
+
if (!a.dryRun) {
|
|
2687
|
+
await ensureDir(path.dirname(keyPath));
|
|
2688
|
+
await writeFileAtomic(keyPath, pair.fileText, { mode: 384 });
|
|
2689
|
+
await writeFileAtomic(pubPath, `${pair.publicKey}\n`, { mode: 420 });
|
|
2690
|
+
}
|
|
2691
|
+
return {
|
|
2692
|
+
secretKey: pair.secretKey,
|
|
2693
|
+
publicKey: pair.publicKey
|
|
2694
|
+
};
|
|
2695
|
+
};
|
|
2696
|
+
const operatorKeys = await ensureAgePair(operatorKeyPath, operatorPubPath);
|
|
2697
|
+
const withHostKeyRule = upsertSopsCreationRule({
|
|
2698
|
+
existingYaml: fs.existsSync(sopsConfigPath) ? fs.readFileSync(sopsConfigPath, "utf8") : void 0,
|
|
2699
|
+
pathRegex: getHostAgeKeySopsCreationRulePathRegex(layout, hostName),
|
|
2700
|
+
ageRecipients: [operatorKeys.publicKey]
|
|
2701
|
+
});
|
|
2702
|
+
let hostKeys;
|
|
2703
|
+
if (fs.existsSync(hostKeyFile)) if (a.dryRun) hostKeys = {
|
|
2704
|
+
publicKey: "age1dryrundryrundryrundryrundryrundryrundryrundryrundryrun0l9p4",
|
|
2705
|
+
secretKey: "AGE-SECRET-KEY-DRYRUNDRYRUNDRYRUNDRYRUNDRYRUNDRYRUNDRYRUNDRYRUN"
|
|
2706
|
+
};
|
|
2707
|
+
else {
|
|
2708
|
+
const decrypted = await sopsDecryptYamlFile({
|
|
2709
|
+
filePath: hostKeyFile,
|
|
2710
|
+
ageKeyFile: operatorKeyPath,
|
|
2711
|
+
nix
|
|
2712
|
+
});
|
|
2713
|
+
const secretKey = readYamlScalarFromMapping({
|
|
2714
|
+
yamlText: decrypted,
|
|
2715
|
+
key: "age_secret_key"
|
|
2716
|
+
})?.trim() || "";
|
|
2717
|
+
const publicKey = readYamlScalarFromMapping({
|
|
2718
|
+
yamlText: decrypted,
|
|
2719
|
+
key: "age_public_key"
|
|
2720
|
+
})?.trim() || "";
|
|
2721
|
+
if (!secretKey || !publicKey) throw new Error(`invalid host age key file: ${hostKeyFile}`);
|
|
2722
|
+
hostKeys = {
|
|
2723
|
+
secretKey,
|
|
2724
|
+
publicKey
|
|
2725
|
+
};
|
|
2726
|
+
}
|
|
2727
|
+
else {
|
|
2728
|
+
const pair = await ageKeygen(nix);
|
|
2729
|
+
hostKeys = {
|
|
2730
|
+
secretKey: pair.secretKey,
|
|
2731
|
+
publicKey: pair.publicKey
|
|
2732
|
+
};
|
|
2733
|
+
const plaintextYaml = upsertYamlScalarLine({
|
|
2734
|
+
text: upsertYamlScalarLine({
|
|
2735
|
+
text: "\n",
|
|
2736
|
+
key: "age_public_key",
|
|
2737
|
+
value: pair.publicKey
|
|
2738
|
+
}),
|
|
2739
|
+
key: "age_secret_key",
|
|
2740
|
+
value: pair.secretKey
|
|
2741
|
+
}) + "\n";
|
|
2742
|
+
if (!a.dryRun) {
|
|
2743
|
+
await ensureDir(path.dirname(sopsConfigPath));
|
|
2744
|
+
await writeFileAtomic(sopsConfigPath, withHostKeyRule, { mode: 420 });
|
|
2745
|
+
await sopsEncryptYamlToFile({
|
|
2746
|
+
plaintextYaml,
|
|
2747
|
+
outPath: hostKeyFile,
|
|
2748
|
+
configPath: sopsConfigPath,
|
|
2749
|
+
nix
|
|
2750
|
+
});
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
const nextSops = upsertSopsCreationRule({
|
|
2754
|
+
existingYaml: withHostKeyRule,
|
|
2755
|
+
pathRegex: getHostSecretsSopsCreationRulePathRegex(layout, hostName),
|
|
2756
|
+
ageRecipients: [hostKeys.publicKey, operatorKeys.publicKey]
|
|
2757
|
+
});
|
|
2758
|
+
if (!a.dryRun) {
|
|
2759
|
+
await ensureDir(path.dirname(sopsConfigPath));
|
|
2760
|
+
await writeFileAtomic(sopsConfigPath, nextSops, { mode: 420 });
|
|
2761
|
+
await ensureDir(path.dirname(extraFilesKeyPath));
|
|
2762
|
+
await writeFileAtomic(extraFilesKeyPath, `${hostKeys.secretKey}\n`, { mode: 384 });
|
|
2763
|
+
}
|
|
2764
|
+
const readExistingScalar = async (secretName) => {
|
|
2765
|
+
const p0 = path.join(localSecretsDir, `${secretName}.yaml`);
|
|
2766
|
+
if (!fs.existsSync(p0)) return null;
|
|
2767
|
+
try {
|
|
2768
|
+
return readYamlScalarFromMapping({
|
|
2769
|
+
yamlText: await sopsDecryptYamlFile({
|
|
2770
|
+
filePath: p0,
|
|
2771
|
+
ageKeyFile: operatorKeyPath,
|
|
2772
|
+
nix
|
|
2773
|
+
}),
|
|
2774
|
+
key: secretName
|
|
2775
|
+
});
|
|
2776
|
+
} catch {
|
|
2777
|
+
return null;
|
|
2778
|
+
}
|
|
2779
|
+
};
|
|
2780
|
+
const flowSecrets = "secrets init";
|
|
2781
|
+
const values = {
|
|
2782
|
+
adminPassword: "",
|
|
2783
|
+
adminPasswordHash: "",
|
|
2784
|
+
tailscaleAuthKey: "",
|
|
2785
|
+
secrets: {},
|
|
2786
|
+
discordTokens: {}
|
|
2787
|
+
};
|
|
2788
|
+
if (interactive) {
|
|
2789
|
+
const discordSecretNames = new Set(Object.values(secretsPlan.discordSecretsByBot).filter(Boolean));
|
|
2790
|
+
const requiredExtraSecrets = Array.from(requiredExtraSecretNames).filter((s) => !discordSecretNames.has(s)).sort();
|
|
2791
|
+
const discordTokenBots = [];
|
|
2792
|
+
const seenDiscordSecrets = /* @__PURE__ */ new Set();
|
|
2793
|
+
for (const bot$1 of bots) {
|
|
2794
|
+
const secretName = secretsPlan.discordSecretsByBot[bot$1] || "";
|
|
2795
|
+
if (!secretName) continue;
|
|
2796
|
+
if (!requiredSecretNames.has(secretName)) continue;
|
|
2797
|
+
if (seenDiscordSecrets.has(secretName)) continue;
|
|
2798
|
+
seenDiscordSecrets.add(secretName);
|
|
2799
|
+
discordTokenBots.push({
|
|
2800
|
+
bot: bot$1,
|
|
2801
|
+
secretName
|
|
2802
|
+
});
|
|
2803
|
+
}
|
|
2804
|
+
const allSteps = [
|
|
2805
|
+
{ kind: "adminPassword" },
|
|
2806
|
+
...requiresTailscaleAuthKey ? [{ kind: "tailscaleAuthKey" }] : [],
|
|
2807
|
+
...requiredExtraSecrets.map((secretName) => garnixPrivateEnabled && secretName === garnixNetrcSecretName ? {
|
|
2808
|
+
kind: "garnixNetrcFile",
|
|
2809
|
+
secretName,
|
|
2810
|
+
netrcPath: garnixNetrcPath || "/etc/nix/netrc"
|
|
2811
|
+
} : {
|
|
2812
|
+
kind: "secret",
|
|
2813
|
+
secretName
|
|
2814
|
+
}),
|
|
2815
|
+
...discordTokenBots.map((b) => ({
|
|
2816
|
+
kind: "discordToken",
|
|
2817
|
+
bot: b.bot,
|
|
2818
|
+
secretName: b.secretName
|
|
2819
|
+
}))
|
|
2820
|
+
];
|
|
2821
|
+
for (let i = 0; i < allSteps.length;) {
|
|
2822
|
+
const step = allSteps[i];
|
|
2823
|
+
let v;
|
|
2824
|
+
if (step.kind === "adminPassword") v = await p.password({ message: "Admin password (used to generate admin_password_hash; leave blank to keep existing/placeholder)" });
|
|
2825
|
+
else if (step.kind === "tailscaleAuthKey") v = await p.password({ message: "Tailscale auth key (tailscale_auth_key) (required for non-interactive tailnet bootstrap)" });
|
|
2826
|
+
else if (step.kind === "garnixNetrcFile") v = await p.text({
|
|
2827
|
+
message: `Path to netrc file for private Garnix cache (${step.secretName} → ${step.netrcPath}) (required)`,
|
|
2828
|
+
placeholder: `${layout.runtimeDir}/garnix.netrc`
|
|
2829
|
+
});
|
|
2830
|
+
else if (step.kind === "secret") v = await p.password({ message: `Secret value (${step.secretName}) (required)` });
|
|
2831
|
+
else v = await p.password({ message: `Discord token for ${step.bot} (${step.secretName}) (required)` });
|
|
2832
|
+
if (p.isCancel(v)) {
|
|
2833
|
+
if (await navOnCancel({
|
|
2834
|
+
flow: flowSecrets,
|
|
2835
|
+
canBack: i > 0
|
|
2836
|
+
}) === NAV_EXIT) {
|
|
2837
|
+
cancelFlow();
|
|
2838
|
+
return;
|
|
2839
|
+
}
|
|
2840
|
+
i = Math.max(0, i - 1);
|
|
2841
|
+
continue;
|
|
2842
|
+
}
|
|
2843
|
+
const s = String(v ?? "");
|
|
2844
|
+
if (step.kind === "adminPassword") values.adminPassword = s;
|
|
2845
|
+
else if (step.kind === "tailscaleAuthKey") values.tailscaleAuthKey = s;
|
|
2846
|
+
else if (step.kind === "garnixNetrcFile") {
|
|
2847
|
+
const rawPath = s.trim();
|
|
2848
|
+
if (!rawPath) values.secrets[step.secretName] = "";
|
|
2849
|
+
else {
|
|
2850
|
+
const expanded = expandPath(rawPath);
|
|
2851
|
+
const abs = path.isAbsolute(expanded) ? expanded : path.resolve(layout.repoRoot, expanded);
|
|
2852
|
+
const stat = fs.statSync(abs);
|
|
2853
|
+
if (!stat.isFile()) throw new Error(`not a file: ${abs}`);
|
|
2854
|
+
if (stat.size > 64 * 1024) throw new Error(`netrc file too large (>64KB): ${abs}`);
|
|
2855
|
+
const netrc = fs.readFileSync(abs, "utf8").trimEnd();
|
|
2856
|
+
if (!netrc) throw new Error(`netrc file is empty: ${abs}`);
|
|
2857
|
+
values.secrets[step.secretName] = netrc;
|
|
2858
|
+
}
|
|
2859
|
+
} else if (step.kind === "secret") values.secrets[step.secretName] = s;
|
|
2860
|
+
else {
|
|
2861
|
+
values.discordTokens[step.bot] = s;
|
|
2862
|
+
values.secrets[step.secretName] = s;
|
|
2863
|
+
}
|
|
2864
|
+
i += 1;
|
|
2865
|
+
}
|
|
2866
|
+
} else {
|
|
2867
|
+
const input = readSecretsInitJson(String(fromJson));
|
|
2868
|
+
values.adminPasswordHash = input.adminPasswordHash;
|
|
2869
|
+
values.tailscaleAuthKey = input.tailscaleAuthKey || "";
|
|
2870
|
+
values.secrets = input.secrets || {};
|
|
2871
|
+
values.discordTokens = input.discordTokens || {};
|
|
2872
|
+
}
|
|
2873
|
+
const secretsToWrite = secretsPlan.secretNamesAll;
|
|
2874
|
+
const requiredSecrets = Array.from(new Set([
|
|
2875
|
+
...requiresTailscaleAuthKey ? ["tailscale_auth_key"] : [],
|
|
2876
|
+
"admin_password_hash",
|
|
2877
|
+
...garnixPrivateEnabled ? [garnixNetrcSecretName] : [],
|
|
2878
|
+
...secretsToWrite
|
|
2879
|
+
]));
|
|
2880
|
+
const isOptionalMarker = (v) => String(v || "").trim() === "<OPTIONAL>";
|
|
2881
|
+
const resolvedValues = {};
|
|
2882
|
+
for (const secretName of requiredSecrets) {
|
|
2883
|
+
const existing = await readExistingScalar(secretName);
|
|
2884
|
+
if (secretName === "tailscale_auth_key") {
|
|
2885
|
+
if (values.tailscaleAuthKey.trim()) resolvedValues[secretName] = values.tailscaleAuthKey.trim();
|
|
2886
|
+
else if (existing && !isPlaceholderSecretValue(existing)) resolvedValues[secretName] = existing;
|
|
2887
|
+
else if (a.allowPlaceholders) resolvedValues[secretName] = "<FILL_ME>";
|
|
2888
|
+
else throw new Error("missing tailscale auth key (tailscale_auth_key); pass --allow-placeholders only if you intend to set it later");
|
|
2889
|
+
continue;
|
|
2890
|
+
}
|
|
2891
|
+
if (secretName === "admin_password_hash") {
|
|
2892
|
+
if (values.adminPasswordHash.trim()) resolvedValues[secretName] = values.adminPasswordHash.trim();
|
|
2893
|
+
else if (values.adminPassword.trim()) resolvedValues[secretName] = a.dryRun ? "<admin_password_hash>" : await mkpasswdYescryptHash(String(values.adminPassword), nix);
|
|
2894
|
+
else resolvedValues[secretName] = existing ?? "<FILL_ME>";
|
|
2895
|
+
continue;
|
|
2896
|
+
}
|
|
2897
|
+
if (discordSecretByName.has(secretName)) {
|
|
2898
|
+
const bot$1 = discordSecretByName.get(secretName) || "";
|
|
2899
|
+
const required$1 = requiredSecretNames.has(secretName);
|
|
2900
|
+
const vv$1 = (bot$1 ? values.discordTokens[bot$1]?.trim() : "") || values.secrets?.[secretName]?.trim() || "";
|
|
2901
|
+
if (vv$1) resolvedValues[secretName] = vv$1;
|
|
2902
|
+
else if (existing) resolvedValues[secretName] = existing;
|
|
2903
|
+
else if (!required$1) resolvedValues[secretName] = "<OPTIONAL>";
|
|
2904
|
+
else if (a.allowPlaceholders) resolvedValues[secretName] = "<FILL_ME>";
|
|
2905
|
+
else throw new Error(`missing discord token for ${bot$1 || secretName} (provide it in --from-json.discordTokens or pass --allow-placeholders)`);
|
|
2906
|
+
continue;
|
|
2907
|
+
}
|
|
2908
|
+
const vv = values.secrets?.[secretName]?.trim() || "";
|
|
2909
|
+
const required = requiredExtraSecretNames.has(secretName);
|
|
2910
|
+
if (vv && !(required && isOptionalMarker(vv))) {
|
|
2911
|
+
resolvedValues[secretName] = vv;
|
|
2912
|
+
continue;
|
|
2913
|
+
}
|
|
2914
|
+
if (existing && (!required || !isPlaceholderSecretValue(existing) && !isOptionalMarker(existing) && existing.trim())) {
|
|
2915
|
+
resolvedValues[secretName] = existing;
|
|
2916
|
+
continue;
|
|
2917
|
+
}
|
|
2918
|
+
if (required) {
|
|
2919
|
+
if (a.allowPlaceholders) resolvedValues[secretName] = "<FILL_ME>";
|
|
2920
|
+
else throw new Error(`missing required secret: ${secretName} (set it in --from-json.secrets or via interactive prompts)`);
|
|
2921
|
+
continue;
|
|
2922
|
+
}
|
|
2923
|
+
resolvedValues[secretName] = "<OPTIONAL>";
|
|
2924
|
+
}
|
|
2925
|
+
if (!a.dryRun) {
|
|
2926
|
+
await ensureDir(localSecretsDir);
|
|
2927
|
+
await ensureDir(extraFilesSecretsDir);
|
|
2928
|
+
for (const secretName of requiredSecrets) {
|
|
2929
|
+
const outPath = path.join(localSecretsDir, `${secretName}.yaml`);
|
|
2930
|
+
await sopsEncryptYamlToFile({
|
|
2931
|
+
plaintextYaml: upsertYamlScalarLine({
|
|
2932
|
+
text: "\n",
|
|
2933
|
+
key: secretName,
|
|
2934
|
+
value: resolvedValues[secretName] ?? ""
|
|
2935
|
+
}),
|
|
2936
|
+
outPath,
|
|
2937
|
+
configPath: sopsConfigPath,
|
|
2938
|
+
nix
|
|
2939
|
+
});
|
|
2940
|
+
const encrypted = fs.readFileSync(outPath, "utf8");
|
|
2941
|
+
await writeFileAtomic(path.join(extraFilesSecretsDir, `${secretName}.yaml`), encrypted, { mode: 256 });
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
console.log(`ok: secrets ready at ${localSecretsDir}`);
|
|
2945
|
+
console.log(`ok: sops config at ${sopsConfigPath}`);
|
|
2946
|
+
console.log(`ok: operator age key at ${operatorKeyPath}`);
|
|
2947
|
+
console.log(`ok: host age key (encrypted) at ${hostKeyFile}`);
|
|
2948
|
+
console.log(`ok: extra-files key at ${extraFilesKeyPath}`);
|
|
2949
|
+
console.log(`ok: extra-files secrets at ${extraFilesSecretsDir}`);
|
|
2950
|
+
}
|
|
2951
|
+
});
|
|
2952
|
+
|
|
2953
|
+
//#endregion
|
|
2954
|
+
//#region src/commands/secrets/path.ts
|
|
2955
|
+
const secretsPath = defineCommand({
|
|
2956
|
+
meta: {
|
|
2957
|
+
name: "path",
|
|
2958
|
+
description: "Print local + remote secrets paths for a host."
|
|
2959
|
+
},
|
|
2960
|
+
args: {
|
|
2961
|
+
runtimeDir: {
|
|
2962
|
+
type: "string",
|
|
2963
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
2964
|
+
},
|
|
2965
|
+
host: {
|
|
2966
|
+
type: "string",
|
|
2967
|
+
description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
|
|
2968
|
+
}
|
|
2969
|
+
},
|
|
2970
|
+
async run({ args }) {
|
|
2971
|
+
const ctx = loadHostContextOrExit({
|
|
2972
|
+
cwd: process$1.cwd(),
|
|
2973
|
+
runtimeDir: args.runtimeDir,
|
|
2974
|
+
hostArg: args.host
|
|
2975
|
+
});
|
|
2976
|
+
if (!ctx) return;
|
|
2977
|
+
const { layout, hostName } = ctx;
|
|
2978
|
+
console.log(`local: ${getHostSecretsDir(layout, hostName)}`);
|
|
2979
|
+
console.log(`remote: ${getHostRemoteSecretsDir(hostName)}`);
|
|
2980
|
+
}
|
|
2981
|
+
});
|
|
2982
|
+
|
|
2983
|
+
//#endregion
|
|
2984
|
+
//#region src/commands/secrets/sync.ts
|
|
2985
|
+
const secretsSync = defineCommand({
|
|
2986
|
+
meta: {
|
|
2987
|
+
name: "sync",
|
|
2988
|
+
description: "Copy local secrets to the server via the install-secrets allowlist."
|
|
2989
|
+
},
|
|
2990
|
+
args: {
|
|
2991
|
+
runtimeDir: {
|
|
2992
|
+
type: "string",
|
|
2993
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
2994
|
+
},
|
|
2995
|
+
host: {
|
|
2996
|
+
type: "string",
|
|
2997
|
+
description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
|
|
2998
|
+
},
|
|
2999
|
+
targetHost: {
|
|
3000
|
+
type: "string",
|
|
3001
|
+
description: "SSH target override (default: from clawdlets.json)."
|
|
3002
|
+
},
|
|
3003
|
+
rev: {
|
|
3004
|
+
type: "string",
|
|
3005
|
+
description: "Git rev for secrets metadata (HEAD/sha/tag).",
|
|
3006
|
+
default: "HEAD"
|
|
3007
|
+
},
|
|
3008
|
+
sshTty: {
|
|
3009
|
+
type: "boolean",
|
|
3010
|
+
description: "Allocate TTY for sudo prompts.",
|
|
3011
|
+
default: true
|
|
3012
|
+
}
|
|
3013
|
+
},
|
|
3014
|
+
async run({ args }) {
|
|
3015
|
+
const ctx = loadHostContextOrExit({
|
|
3016
|
+
cwd: process$1.cwd(),
|
|
3017
|
+
runtimeDir: args.runtimeDir,
|
|
3018
|
+
hostArg: args.host
|
|
3019
|
+
});
|
|
3020
|
+
if (!ctx) return;
|
|
3021
|
+
const { layout, hostName, hostCfg } = ctx;
|
|
3022
|
+
const targetHost = requireTargetHost(String(args.targetHost || hostCfg.targetHost || ""), hostName);
|
|
3023
|
+
const localDir = getHostSecretsDir(layout, hostName);
|
|
3024
|
+
const remoteDir = getHostRemoteSecretsDir(hostName);
|
|
3025
|
+
const revRaw = String(args.rev || "").trim() || "HEAD";
|
|
3026
|
+
const resolved = await resolveGitRev(layout.repoRoot, revRaw);
|
|
3027
|
+
if (!resolved) throw new Error(`unable to resolve git rev: ${revRaw}`);
|
|
3028
|
+
const { tarPath: tarLocal, digest } = await createSecretsTar({
|
|
3029
|
+
hostName,
|
|
3030
|
+
localDir
|
|
3031
|
+
});
|
|
3032
|
+
const tarRemote = `/tmp/clawdlets-secrets.${hostName}.${process$1.pid}.tgz`;
|
|
3033
|
+
try {
|
|
3034
|
+
await run("scp", [tarLocal, `${targetHost}:${tarRemote}`], { redact: [] });
|
|
3035
|
+
} finally {
|
|
3036
|
+
try {
|
|
3037
|
+
if (fs.existsSync(tarLocal)) fs.unlinkSync(tarLocal);
|
|
3038
|
+
} catch {}
|
|
3039
|
+
}
|
|
3040
|
+
const sudo = needsSudo(targetHost);
|
|
3041
|
+
await sshRun(targetHost, [
|
|
3042
|
+
...sudo ? ["sudo"] : [],
|
|
3043
|
+
"/etc/clawdlets/bin/install-secrets",
|
|
3044
|
+
"--host",
|
|
3045
|
+
hostName,
|
|
3046
|
+
"--tar",
|
|
3047
|
+
tarRemote,
|
|
3048
|
+
"--rev",
|
|
3049
|
+
resolved,
|
|
3050
|
+
"--digest",
|
|
3051
|
+
digest
|
|
3052
|
+
].map(shellQuote).join(" "), { tty: sudo && args.sshTty });
|
|
3053
|
+
console.log(`ok: synced secrets to ${remoteDir}`);
|
|
3054
|
+
}
|
|
3055
|
+
});
|
|
3056
|
+
|
|
3057
|
+
//#endregion
|
|
3058
|
+
//#region src/commands/secrets/verify.ts
|
|
3059
|
+
const secretsVerify = defineCommand({
|
|
3060
|
+
meta: {
|
|
3061
|
+
name: "verify",
|
|
3062
|
+
description: "Verify secrets decrypt correctly and contain no placeholders."
|
|
3063
|
+
},
|
|
3064
|
+
args: {
|
|
3065
|
+
runtimeDir: {
|
|
3066
|
+
type: "string",
|
|
3067
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
3068
|
+
},
|
|
3069
|
+
envFile: {
|
|
3070
|
+
type: "string",
|
|
3071
|
+
description: "Env file for deploy creds (default: <runtimeDir>/env)."
|
|
3072
|
+
},
|
|
3073
|
+
host: {
|
|
3074
|
+
type: "string",
|
|
3075
|
+
description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
|
|
3076
|
+
},
|
|
3077
|
+
operator: {
|
|
3078
|
+
type: "string",
|
|
3079
|
+
description: "Operator id for age key name (default: $USER). Used if SOPS_AGE_KEY_FILE is not set."
|
|
3080
|
+
},
|
|
3081
|
+
ageKeyFile: {
|
|
3082
|
+
type: "string",
|
|
3083
|
+
description: "Override SOPS_AGE_KEY_FILE path."
|
|
3084
|
+
},
|
|
3085
|
+
json: {
|
|
3086
|
+
type: "boolean",
|
|
3087
|
+
description: "Output JSON.",
|
|
3088
|
+
default: false
|
|
3089
|
+
}
|
|
3090
|
+
},
|
|
3091
|
+
async run({ args }) {
|
|
3092
|
+
const cwd = process$1.cwd();
|
|
3093
|
+
const ctx = loadHostContextOrExit({
|
|
3094
|
+
cwd,
|
|
3095
|
+
runtimeDir: args.runtimeDir,
|
|
3096
|
+
hostArg: args.host
|
|
3097
|
+
});
|
|
3098
|
+
if (!ctx) return;
|
|
3099
|
+
const { layout, config: config$1, hostName, hostCfg } = ctx;
|
|
3100
|
+
const deployCreds = loadDeployCreds({
|
|
3101
|
+
cwd,
|
|
3102
|
+
runtimeDir: args.runtimeDir,
|
|
3103
|
+
envFile: args.envFile
|
|
3104
|
+
});
|
|
3105
|
+
if (deployCreds.envFile?.origin === "explicit" && deployCreds.envFile.status !== "ok") throw new Error(`deploy env file rejected: ${deployCreds.envFile.path} (${deployCreds.envFile.error || deployCreds.envFile.status})`);
|
|
3106
|
+
const operatorId = sanitizeOperatorId(String(args.operator || process$1.env.USER || "operator"));
|
|
3107
|
+
const operatorKeyPath = (args.ageKeyFile ? String(args.ageKeyFile).trim() : "") || (deployCreds.values.SOPS_AGE_KEY_FILE ? String(deployCreds.values.SOPS_AGE_KEY_FILE).trim() : "") || getLocalOperatorAgeKeyPath(layout, operatorId);
|
|
3108
|
+
const nix = {
|
|
3109
|
+
nixBin: String(deployCreds.values.NIX_BIN || "nix").trim() || "nix",
|
|
3110
|
+
cwd: layout.repoRoot,
|
|
3111
|
+
dryRun: false
|
|
3112
|
+
};
|
|
3113
|
+
const localDir = getHostSecretsDir(layout, hostName);
|
|
3114
|
+
const secretsPlan = buildFleetSecretsPlan({
|
|
3115
|
+
config: config$1,
|
|
3116
|
+
hostName
|
|
3117
|
+
});
|
|
3118
|
+
const requiredSecretNames = new Set(secretsPlan.secretNamesRequired);
|
|
3119
|
+
const tailnetMode = String(hostCfg.tailnet?.mode || "none");
|
|
3120
|
+
const requiredSecrets = Array.from(new Set([...tailnetMode === "tailscale" ? ["tailscale_auth_key"] : [], "admin_password_hash"]));
|
|
3121
|
+
const secretNames = secretsPlan.secretNamesAll;
|
|
3122
|
+
const optionalSecrets = ["root_password_hash"];
|
|
3123
|
+
const results = [];
|
|
3124
|
+
if (!fs.existsSync(operatorKeyPath)) results.push({
|
|
3125
|
+
secret: "SOPS_AGE_KEY_FILE",
|
|
3126
|
+
status: "missing",
|
|
3127
|
+
detail: operatorKeyPath
|
|
3128
|
+
});
|
|
3129
|
+
const verifyOne = async (secretName, optional, allowOptionalMarker) => {
|
|
3130
|
+
const filePath = path.join(localDir, `${secretName}.yaml`);
|
|
3131
|
+
if (!fs.existsSync(filePath)) {
|
|
3132
|
+
results.push({
|
|
3133
|
+
secret: secretName,
|
|
3134
|
+
status: optional ? "warn" : "missing",
|
|
3135
|
+
detail: `(missing: ${filePath})`
|
|
3136
|
+
});
|
|
3137
|
+
return;
|
|
3138
|
+
}
|
|
3139
|
+
try {
|
|
3140
|
+
const decrypted = await sopsDecryptYamlFile({
|
|
3141
|
+
filePath,
|
|
3142
|
+
ageKeyFile: operatorKeyPath,
|
|
3143
|
+
nix
|
|
3144
|
+
});
|
|
3145
|
+
const parsed = YAML.parse(decrypted) || {};
|
|
3146
|
+
const keys = Object.keys(parsed).filter((k) => k !== "sops");
|
|
3147
|
+
if (keys.length !== 1 || keys[0] !== secretName) {
|
|
3148
|
+
results.push({
|
|
3149
|
+
secret: secretName,
|
|
3150
|
+
status: "missing",
|
|
3151
|
+
detail: "(invalid: expected exactly 1 key matching filename)"
|
|
3152
|
+
});
|
|
3153
|
+
return;
|
|
3154
|
+
}
|
|
3155
|
+
const v = parsed[secretName];
|
|
3156
|
+
const value = typeof v === "string" ? v : v == null ? "" : String(v);
|
|
3157
|
+
if (!allowOptionalMarker && value.trim() === "<OPTIONAL>") {
|
|
3158
|
+
results.push({
|
|
3159
|
+
secret: secretName,
|
|
3160
|
+
status: "missing",
|
|
3161
|
+
detail: "(placeholder: <OPTIONAL>)"
|
|
3162
|
+
});
|
|
3163
|
+
return;
|
|
3164
|
+
}
|
|
3165
|
+
if (!optional && isPlaceholderSecretValue(value)) {
|
|
3166
|
+
results.push({
|
|
3167
|
+
secret: secretName,
|
|
3168
|
+
status: "missing",
|
|
3169
|
+
detail: `(placeholder: ${value.trim()})`
|
|
3170
|
+
});
|
|
3171
|
+
return;
|
|
3172
|
+
}
|
|
3173
|
+
if (optional && isPlaceholderSecretValue(value)) {
|
|
3174
|
+
results.push({
|
|
3175
|
+
secret: secretName,
|
|
3176
|
+
status: "missing",
|
|
3177
|
+
detail: `(placeholder: ${value.trim()})`
|
|
3178
|
+
});
|
|
3179
|
+
return;
|
|
3180
|
+
}
|
|
3181
|
+
if (!optional && !value.trim()) {
|
|
3182
|
+
results.push({
|
|
3183
|
+
secret: secretName,
|
|
3184
|
+
status: "missing",
|
|
3185
|
+
detail: "(empty)"
|
|
3186
|
+
});
|
|
3187
|
+
return;
|
|
3188
|
+
}
|
|
3189
|
+
results.push({
|
|
3190
|
+
secret: secretName,
|
|
3191
|
+
status: "ok"
|
|
3192
|
+
});
|
|
3193
|
+
} catch (e) {
|
|
3194
|
+
results.push({
|
|
3195
|
+
secret: secretName,
|
|
3196
|
+
status: "missing",
|
|
3197
|
+
detail: String(e?.message || e)
|
|
3198
|
+
});
|
|
3199
|
+
}
|
|
3200
|
+
};
|
|
3201
|
+
if (!fs.existsSync(localDir)) results.push({
|
|
3202
|
+
secret: "secrets.localDir",
|
|
3203
|
+
status: "missing",
|
|
3204
|
+
detail: localDir
|
|
3205
|
+
});
|
|
3206
|
+
else {
|
|
3207
|
+
for (const s of requiredSecrets) await verifyOne(s, false, false);
|
|
3208
|
+
for (const s of secretNames) await verifyOne(s, false, !requiredSecretNames.has(s));
|
|
3209
|
+
for (const s of optionalSecrets) await verifyOne(s, true, true);
|
|
3210
|
+
}
|
|
3211
|
+
if (args.json) console.log(JSON.stringify({
|
|
3212
|
+
host: hostName,
|
|
3213
|
+
localDir,
|
|
3214
|
+
results
|
|
3215
|
+
}, null, 2));
|
|
3216
|
+
else for (const r of results) console.log(`${r.status}: ${r.secret}${r.detail ? ` (${r.detail})` : ""}`);
|
|
3217
|
+
if (results.some((r) => r.status === "missing")) process$1.exitCode = 1;
|
|
3218
|
+
}
|
|
3219
|
+
});
|
|
3220
|
+
|
|
3221
|
+
//#endregion
|
|
3222
|
+
//#region src/commands/secrets.ts
|
|
3223
|
+
const secrets = defineCommand({
|
|
3224
|
+
meta: {
|
|
3225
|
+
name: "secrets",
|
|
3226
|
+
description: "Secrets workflow (/secrets + extra-files + sync)."
|
|
3227
|
+
},
|
|
3228
|
+
subCommands: {
|
|
3229
|
+
init: secretsInit,
|
|
3230
|
+
verify: secretsVerify,
|
|
3231
|
+
sync: secretsSync,
|
|
3232
|
+
path: secretsPath
|
|
3233
|
+
}
|
|
3234
|
+
});
|
|
3235
|
+
|
|
3236
|
+
//#endregion
|
|
3237
|
+
//#region src/commands/server/github-sync.ts
|
|
3238
|
+
function normalizeKind(raw) {
|
|
3239
|
+
const v = raw.trim();
|
|
3240
|
+
if (v === "prs" || v === "issues") return v;
|
|
3241
|
+
throw new Error(`invalid --kind: ${raw} (expected prs|issues)`);
|
|
3242
|
+
}
|
|
3243
|
+
const serverGithubSyncStatus = defineCommand({
|
|
3244
|
+
meta: {
|
|
3245
|
+
name: "status",
|
|
3246
|
+
description: "Show GitHub sync timers (clawdbot-gh-sync-*.timer)."
|
|
3247
|
+
},
|
|
3248
|
+
args: {
|
|
3249
|
+
runtimeDir: {
|
|
3250
|
+
type: "string",
|
|
3251
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
3252
|
+
},
|
|
3253
|
+
host: {
|
|
3254
|
+
type: "string",
|
|
3255
|
+
description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
|
|
3256
|
+
},
|
|
3257
|
+
targetHost: {
|
|
3258
|
+
type: "string",
|
|
3259
|
+
description: "SSH target override (default: from clawdlets.json)."
|
|
3260
|
+
},
|
|
3261
|
+
sshTty: {
|
|
3262
|
+
type: "boolean",
|
|
3263
|
+
description: "Allocate TTY for sudo prompts.",
|
|
3264
|
+
default: true
|
|
3265
|
+
}
|
|
3266
|
+
},
|
|
3267
|
+
async run({ args }) {
|
|
3268
|
+
const ctx = loadHostContextOrExit({
|
|
3269
|
+
cwd: process$1.cwd(),
|
|
3270
|
+
runtimeDir: args.runtimeDir,
|
|
3271
|
+
hostArg: args.host
|
|
3272
|
+
});
|
|
3273
|
+
if (!ctx) return;
|
|
3274
|
+
const { hostName, hostCfg } = ctx;
|
|
3275
|
+
const targetHost = requireTargetHost(String(args.targetHost || hostCfg.targetHost || ""), hostName);
|
|
3276
|
+
const sudo = needsSudo(targetHost);
|
|
3277
|
+
await sshRun(targetHost, [
|
|
3278
|
+
...sudo ? ["sudo"] : [],
|
|
3279
|
+
"systemctl",
|
|
3280
|
+
"list-timers",
|
|
3281
|
+
"--all",
|
|
3282
|
+
"--no-pager",
|
|
3283
|
+
shellQuote("clawdbot-gh-sync-*.timer")
|
|
3284
|
+
].join(" "), { tty: sudo && args.sshTty });
|
|
3285
|
+
}
|
|
3286
|
+
});
|
|
3287
|
+
const serverGithubSyncRun = defineCommand({
|
|
3288
|
+
meta: {
|
|
3289
|
+
name: "run",
|
|
3290
|
+
description: "Run a GitHub sync now (oneshot)."
|
|
3291
|
+
},
|
|
3292
|
+
args: {
|
|
3293
|
+
runtimeDir: {
|
|
3294
|
+
type: "string",
|
|
3295
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
3296
|
+
},
|
|
3297
|
+
host: {
|
|
3298
|
+
type: "string",
|
|
3299
|
+
description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
|
|
3300
|
+
},
|
|
3301
|
+
targetHost: {
|
|
3302
|
+
type: "string",
|
|
3303
|
+
description: "SSH target override (default: from clawdlets.json)."
|
|
3304
|
+
},
|
|
3305
|
+
bot: {
|
|
3306
|
+
type: "string",
|
|
3307
|
+
description: "Bot id (default: all bots with sync enabled)."
|
|
3308
|
+
},
|
|
3309
|
+
sshTty: {
|
|
3310
|
+
type: "boolean",
|
|
3311
|
+
description: "Allocate TTY for sudo prompts.",
|
|
3312
|
+
default: true
|
|
3313
|
+
}
|
|
3314
|
+
},
|
|
3315
|
+
async run({ args }) {
|
|
3316
|
+
const ctx = loadHostContextOrExit({
|
|
3317
|
+
cwd: process$1.cwd(),
|
|
3318
|
+
runtimeDir: args.runtimeDir,
|
|
3319
|
+
hostArg: args.host
|
|
3320
|
+
});
|
|
3321
|
+
if (!ctx) return;
|
|
3322
|
+
const { hostName, hostCfg } = ctx;
|
|
3323
|
+
const targetHost = requireTargetHost(String(args.targetHost || hostCfg.targetHost || ""), hostName);
|
|
3324
|
+
const bot$1 = String(args.bot || "").trim();
|
|
3325
|
+
const unit = bot$1 ? `clawdbot-gh-sync-${bot$1}.service` : "clawdbot-gh-sync-*.service";
|
|
3326
|
+
const sudo = needsSudo(targetHost);
|
|
3327
|
+
await sshRun(targetHost, [
|
|
3328
|
+
...sudo ? ["sudo"] : [],
|
|
3329
|
+
"systemctl",
|
|
3330
|
+
"start",
|
|
3331
|
+
shellQuote(unit)
|
|
3332
|
+
].join(" "), { tty: sudo && args.sshTty });
|
|
3333
|
+
}
|
|
3334
|
+
});
|
|
3335
|
+
const serverGithubSyncLogs = defineCommand({
|
|
3336
|
+
meta: {
|
|
3337
|
+
name: "logs",
|
|
3338
|
+
description: "Show GitHub sync logs (journalctl)."
|
|
3339
|
+
},
|
|
3340
|
+
args: {
|
|
3341
|
+
runtimeDir: {
|
|
3342
|
+
type: "string",
|
|
3343
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
3344
|
+
},
|
|
3345
|
+
host: {
|
|
3346
|
+
type: "string",
|
|
3347
|
+
description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
|
|
3348
|
+
},
|
|
3349
|
+
targetHost: {
|
|
3350
|
+
type: "string",
|
|
3351
|
+
description: "SSH target override (default: from clawdlets.json)."
|
|
3352
|
+
},
|
|
3353
|
+
bot: {
|
|
3354
|
+
type: "string",
|
|
3355
|
+
description: "Bot id (required)."
|
|
3356
|
+
},
|
|
3357
|
+
follow: {
|
|
3358
|
+
type: "boolean",
|
|
3359
|
+
description: "Follow logs.",
|
|
3360
|
+
default: false
|
|
3361
|
+
},
|
|
3362
|
+
lines: {
|
|
3363
|
+
type: "string",
|
|
3364
|
+
description: "Number of lines (default: 200).",
|
|
3365
|
+
default: "200"
|
|
3366
|
+
},
|
|
3367
|
+
sshTty: {
|
|
3368
|
+
type: "boolean",
|
|
3369
|
+
description: "Allocate TTY for sudo prompts.",
|
|
3370
|
+
default: true
|
|
3371
|
+
}
|
|
3372
|
+
},
|
|
3373
|
+
async run({ args }) {
|
|
3374
|
+
const ctx = loadHostContextOrExit({
|
|
3375
|
+
cwd: process$1.cwd(),
|
|
3376
|
+
runtimeDir: args.runtimeDir,
|
|
3377
|
+
hostArg: args.host
|
|
3378
|
+
});
|
|
3379
|
+
if (!ctx) return;
|
|
3380
|
+
const { hostName, hostCfg } = ctx;
|
|
3381
|
+
const targetHost = requireTargetHost(String(args.targetHost || hostCfg.targetHost || ""), hostName);
|
|
3382
|
+
const bot$1 = String(args.bot || "").trim();
|
|
3383
|
+
if (!bot$1) throw new Error("missing --bot (example: --bot maren)");
|
|
3384
|
+
const sudo = needsSudo(targetHost);
|
|
3385
|
+
const unit = `clawdbot-gh-sync-${bot$1}.service`;
|
|
3386
|
+
const n = String(args.lines || "200").trim() || "200";
|
|
3387
|
+
if (!/^\d+$/.test(n) || Number(n) <= 0) throw new Error(`invalid --lines: ${n}`);
|
|
3388
|
+
await sshRun(targetHost, [
|
|
3389
|
+
...sudo ? ["sudo"] : [],
|
|
3390
|
+
"journalctl",
|
|
3391
|
+
"-u",
|
|
3392
|
+
shellQuote(unit),
|
|
3393
|
+
"-n",
|
|
3394
|
+
shellQuote(n),
|
|
3395
|
+
...args.follow ? ["-f"] : [],
|
|
3396
|
+
"--no-pager"
|
|
3397
|
+
].join(" "), { tty: sudo && args.sshTty });
|
|
3398
|
+
}
|
|
3399
|
+
});
|
|
3400
|
+
const serverGithubSyncShow = defineCommand({
|
|
3401
|
+
meta: {
|
|
3402
|
+
name: "show",
|
|
3403
|
+
description: "Show the last synced snapshot (prs|issues) from bot workspace memory."
|
|
3404
|
+
},
|
|
3405
|
+
args: {
|
|
3406
|
+
runtimeDir: {
|
|
3407
|
+
type: "string",
|
|
3408
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
3409
|
+
},
|
|
3410
|
+
host: {
|
|
3411
|
+
type: "string",
|
|
3412
|
+
description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
|
|
3413
|
+
},
|
|
3414
|
+
targetHost: {
|
|
3415
|
+
type: "string",
|
|
3416
|
+
description: "SSH target override (default: from clawdlets.json)."
|
|
3417
|
+
},
|
|
3418
|
+
bot: {
|
|
3419
|
+
type: "string",
|
|
3420
|
+
description: "Bot id (required)."
|
|
3421
|
+
},
|
|
3422
|
+
kind: {
|
|
3423
|
+
type: "string",
|
|
3424
|
+
description: "Snapshot kind: prs|issues.",
|
|
3425
|
+
default: "prs"
|
|
3426
|
+
},
|
|
3427
|
+
lines: {
|
|
3428
|
+
type: "string",
|
|
3429
|
+
description: "Max lines to print (default: 200).",
|
|
3430
|
+
default: "200"
|
|
3431
|
+
},
|
|
3432
|
+
sshTty: {
|
|
3433
|
+
type: "boolean",
|
|
3434
|
+
description: "Allocate TTY for sudo prompts.",
|
|
3435
|
+
default: true
|
|
3436
|
+
}
|
|
3437
|
+
},
|
|
3438
|
+
async run({ args }) {
|
|
3439
|
+
const ctx = loadHostContextOrExit({
|
|
3440
|
+
cwd: process$1.cwd(),
|
|
3441
|
+
runtimeDir: args.runtimeDir,
|
|
3442
|
+
hostArg: args.host
|
|
3443
|
+
});
|
|
3444
|
+
if (!ctx) return;
|
|
3445
|
+
const { hostName, hostCfg } = ctx;
|
|
3446
|
+
const targetHost = requireTargetHost(String(args.targetHost || hostCfg.targetHost || ""), hostName);
|
|
3447
|
+
const bot$1 = String(args.bot || "").trim();
|
|
3448
|
+
if (!bot$1) throw new Error("missing --bot (example: --bot maren)");
|
|
3449
|
+
const kind = normalizeKind(String(args.kind || "prs"));
|
|
3450
|
+
const n = String(args.lines || "200").trim() || "200";
|
|
3451
|
+
if (!/^\d+$/.test(n) || Number(n) <= 0) throw new Error(`invalid --lines: ${n}`);
|
|
3452
|
+
const sudo = needsSudo(targetHost);
|
|
3453
|
+
await sshRun(targetHost, [
|
|
3454
|
+
...sudo ? ["sudo"] : [],
|
|
3455
|
+
"/etc/clawdlets/bin/gh-sync-read",
|
|
3456
|
+
shellQuote(bot$1),
|
|
3457
|
+
shellQuote(kind),
|
|
3458
|
+
shellQuote(n)
|
|
3459
|
+
].join(" "), { tty: sudo && args.sshTty });
|
|
3460
|
+
}
|
|
3461
|
+
});
|
|
3462
|
+
const serverGithubSync = defineCommand({
|
|
3463
|
+
meta: {
|
|
3464
|
+
name: "github-sync",
|
|
3465
|
+
description: "GitHub inventory sync (systemd timers + logs + snapshots)."
|
|
3466
|
+
},
|
|
3467
|
+
subCommands: {
|
|
3468
|
+
status: serverGithubSyncStatus,
|
|
3469
|
+
run: serverGithubSyncRun,
|
|
3470
|
+
logs: serverGithubSyncLogs,
|
|
3471
|
+
show: serverGithubSyncShow
|
|
3472
|
+
}
|
|
3473
|
+
});
|
|
3474
|
+
|
|
3475
|
+
//#endregion
|
|
3476
|
+
//#region src/lib/deploy-manifest.ts
|
|
3477
|
+
const REV_RE = /^[0-9a-f]{40}$/;
|
|
3478
|
+
const DIGEST_RE = /^[0-9a-f]{64}$/;
|
|
3479
|
+
function requireRev(value) {
|
|
3480
|
+
const v = value.trim();
|
|
3481
|
+
if (!REV_RE.test(v)) throw new Error(`invalid rev (expected 40-char sha): ${v || "<empty>"}`);
|
|
3482
|
+
return v;
|
|
3483
|
+
}
|
|
3484
|
+
function requireToplevel(value) {
|
|
3485
|
+
const v = value.trim();
|
|
3486
|
+
if (!v) throw new Error("missing toplevel store path");
|
|
3487
|
+
if (/\s/.test(v)) throw new Error(`invalid toplevel (contains whitespace): ${v}`);
|
|
3488
|
+
if (!v.startsWith("/nix/store/")) throw new Error(`invalid toplevel (expected /nix/store/...): ${v}`);
|
|
3489
|
+
return v;
|
|
3490
|
+
}
|
|
3491
|
+
function parseDeployManifest(manifestPath) {
|
|
3492
|
+
const raw = fs.readFileSync(manifestPath, "utf8");
|
|
3493
|
+
let parsed;
|
|
3494
|
+
try {
|
|
3495
|
+
parsed = JSON.parse(raw);
|
|
3496
|
+
} catch (e) {
|
|
3497
|
+
throw new Error(`invalid deploy manifest JSON: ${manifestPath} (${String(e?.message || e)})`);
|
|
3498
|
+
}
|
|
3499
|
+
if (!parsed || typeof parsed !== "object") throw new Error(`invalid deploy manifest: ${manifestPath}`);
|
|
3500
|
+
const rev = requireRev(String(parsed.rev ?? ""));
|
|
3501
|
+
const host$1 = String(parsed.host ?? "").trim();
|
|
3502
|
+
if (!host$1) throw new Error(`invalid deploy manifest host: ${manifestPath}`);
|
|
3503
|
+
const toplevel = requireToplevel(String(parsed.toplevel ?? ""));
|
|
3504
|
+
const secretsDigestRaw = String(parsed.secretsDigest ?? "").trim();
|
|
3505
|
+
const secretsDigest = secretsDigestRaw ? secretsDigestRaw : void 0;
|
|
3506
|
+
if (secretsDigest && !DIGEST_RE.test(secretsDigest)) throw new Error(`invalid deploy manifest secretsDigest (expected sha256 hex): ${manifestPath}`);
|
|
3507
|
+
return {
|
|
3508
|
+
rev,
|
|
3509
|
+
host: host$1,
|
|
3510
|
+
toplevel,
|
|
3511
|
+
secretsDigest
|
|
3512
|
+
};
|
|
3513
|
+
}
|
|
3514
|
+
function formatDeployManifest(manifest) {
|
|
3515
|
+
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
3516
|
+
}
|
|
3517
|
+
|
|
3518
|
+
//#endregion
|
|
3519
|
+
//#region src/lib/manifest-signature.ts
|
|
3520
|
+
function resolveManifestSignaturePath(params) {
|
|
3521
|
+
const sigArg = String(params.signaturePathArg || "").trim();
|
|
3522
|
+
if (sigArg) return path.isAbsolute(sigArg) ? sigArg : path.resolve(params.cwd, sigArg);
|
|
3523
|
+
const fallback = `${params.manifestPath}.minisig`;
|
|
3524
|
+
if (fs.existsSync(fallback)) return fallback;
|
|
3525
|
+
throw new Error("manifest signature missing (provide --manifest-signature or <manifest>.minisig)");
|
|
3526
|
+
}
|
|
3527
|
+
function resolveManifestPublicKey(params) {
|
|
3528
|
+
const direct = String(params.publicKeyArg || "").trim();
|
|
3529
|
+
if (direct) return direct;
|
|
3530
|
+
const fileArg = String(params.publicKeyFileArg || "").trim();
|
|
3531
|
+
if (fileArg) {
|
|
3532
|
+
const content = fs.readFileSync(fileArg, "utf8").trim();
|
|
3533
|
+
if (!content) throw new Error(`manifest public key file empty: ${fileArg}`);
|
|
3534
|
+
return content;
|
|
3535
|
+
}
|
|
3536
|
+
const fallbackPath = String(params.defaultKeyPath || "").trim();
|
|
3537
|
+
if (fallbackPath && fs.existsSync(fallbackPath)) {
|
|
3538
|
+
const content = fs.readFileSync(fallbackPath, "utf8").trim();
|
|
3539
|
+
if (!content) throw new Error(`manifest public key file empty: ${fallbackPath}`);
|
|
3540
|
+
return content;
|
|
3541
|
+
}
|
|
3542
|
+
const fromHost = String(params.hostPublicKey || "").trim();
|
|
3543
|
+
if (fromHost) return fromHost;
|
|
3544
|
+
throw new Error("manifest public key missing (set hosts.<host>.selfUpdate.publicKey or --manifest-public-key)");
|
|
3545
|
+
}
|
|
3546
|
+
async function verifyManifestSignature(params) {
|
|
3547
|
+
try {
|
|
3548
|
+
await run("minisign", [
|
|
3549
|
+
"-Vm",
|
|
3550
|
+
params.manifestPath,
|
|
3551
|
+
"-P",
|
|
3552
|
+
params.publicKey,
|
|
3553
|
+
"-x",
|
|
3554
|
+
params.signaturePath
|
|
3555
|
+
], { redact: [] });
|
|
3556
|
+
} catch (e) {
|
|
3557
|
+
const err = e;
|
|
3558
|
+
const msg = String(err?.message || e);
|
|
3559
|
+
if (err?.code === "ENOENT" || msg.includes("spawn minisign ENOENT")) throw new Error(`minisign not found (${msg}). Install minisign and retry.`);
|
|
3560
|
+
throw new Error(`manifest signature invalid (${msg}). See minisign output above.`);
|
|
3561
|
+
}
|
|
3562
|
+
}
|
|
3563
|
+
|
|
3564
|
+
//#endregion
|
|
3565
|
+
//#region src/lib/linux-build.ts
|
|
3566
|
+
function linuxBuildRequiredError(params) {
|
|
3567
|
+
const cmd = params.command.trim() || "this command";
|
|
3568
|
+
return new Error([
|
|
3569
|
+
`${cmd}: local NixOS builds require Linux.`,
|
|
3570
|
+
"Use one of:",
|
|
3571
|
+
"- CI: deploy-manifest.yml publishes signed deploy manifests, then deploy.yml (or selfUpdate) deploys by manifest over tailnet",
|
|
3572
|
+
"- Linux builder: build the system on Linux and deploy with --manifest or --toplevel"
|
|
3573
|
+
].join("\n"));
|
|
3574
|
+
}
|
|
3575
|
+
function requireLinuxForLocalNixosBuild(params) {
|
|
3576
|
+
if (params.platform === "linux") return;
|
|
3577
|
+
throw linuxBuildRequiredError({ command: params.command });
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3580
|
+
//#endregion
|
|
3581
|
+
//#region src/commands/server/deploy.ts
|
|
3582
|
+
async function buildLocalToplevel(params) {
|
|
3583
|
+
requireLinuxForLocalNixosBuild({
|
|
3584
|
+
platform: process$1.platform,
|
|
3585
|
+
command: "clawdlets server deploy"
|
|
3586
|
+
});
|
|
3587
|
+
const attr = `.#nixosConfigurations.${params.host}.config.system.build.toplevel`;
|
|
3588
|
+
const out = await capture(params.nixBin, [
|
|
3589
|
+
"build",
|
|
3590
|
+
"--json",
|
|
3591
|
+
"--no-link",
|
|
3592
|
+
attr
|
|
3593
|
+
], {
|
|
3594
|
+
cwd: params.repoRoot,
|
|
3595
|
+
env: withFlakesEnv(process$1.env)
|
|
3596
|
+
});
|
|
3597
|
+
let parsed;
|
|
3598
|
+
try {
|
|
3599
|
+
parsed = JSON.parse(out);
|
|
3600
|
+
} catch (e) {
|
|
3601
|
+
throw new Error(`nix build --json returned invalid JSON (${String(e?.message || e)})`);
|
|
3602
|
+
}
|
|
3603
|
+
const toplevel = parsed?.[0]?.outputs?.out;
|
|
3604
|
+
if (!toplevel || typeof toplevel !== "string") throw new Error("nix build did not return a toplevel store path");
|
|
3605
|
+
return requireToplevel(toplevel);
|
|
3606
|
+
}
|
|
3607
|
+
const serverDeploy = defineCommand({
|
|
3608
|
+
meta: {
|
|
3609
|
+
name: "deploy",
|
|
3610
|
+
description: "Deploy a prebuilt NixOS system + secrets by store path."
|
|
3611
|
+
},
|
|
3612
|
+
args: {
|
|
3613
|
+
runtimeDir: {
|
|
3614
|
+
type: "string",
|
|
3615
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
3616
|
+
},
|
|
3617
|
+
envFile: {
|
|
3618
|
+
type: "string",
|
|
3619
|
+
description: "Env file for deploy creds (default: <runtimeDir>/env)."
|
|
3620
|
+
},
|
|
3621
|
+
host: {
|
|
3622
|
+
type: "string",
|
|
3623
|
+
description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
|
|
3624
|
+
},
|
|
3625
|
+
targetHost: {
|
|
3626
|
+
type: "string",
|
|
3627
|
+
description: "SSH target override (default: from clawdlets.json)."
|
|
3628
|
+
},
|
|
3629
|
+
rev: {
|
|
3630
|
+
type: "string",
|
|
3631
|
+
description: "Git rev to pin (HEAD/sha/tag).",
|
|
3632
|
+
default: "HEAD"
|
|
3633
|
+
},
|
|
3634
|
+
toplevel: {
|
|
3635
|
+
type: "string",
|
|
3636
|
+
description: "NixOS system toplevel store path (CI mode)."
|
|
3637
|
+
},
|
|
3638
|
+
manifest: {
|
|
3639
|
+
type: "string",
|
|
3640
|
+
description: "Path to deploy manifest JSON (CI mode)."
|
|
3641
|
+
},
|
|
3642
|
+
manifestSignature: {
|
|
3643
|
+
type: "string",
|
|
3644
|
+
description: "Path to manifest minisign signature (.minisig)."
|
|
3645
|
+
},
|
|
3646
|
+
manifestPublicKey: {
|
|
3647
|
+
type: "string",
|
|
3648
|
+
description: "Minisign public key string (verify manifest)."
|
|
3649
|
+
},
|
|
3650
|
+
manifestPublicKeyFile: {
|
|
3651
|
+
type: "string",
|
|
3652
|
+
description: "Path to minisign public key (verify manifest)."
|
|
3653
|
+
},
|
|
3654
|
+
manifestOut: {
|
|
3655
|
+
type: "string",
|
|
3656
|
+
description: "Write deploy manifest JSON to this path."
|
|
3657
|
+
},
|
|
3658
|
+
sshTty: {
|
|
3659
|
+
type: "boolean",
|
|
3660
|
+
description: "Allocate TTY for sudo prompts.",
|
|
3661
|
+
default: true
|
|
3662
|
+
}
|
|
3663
|
+
},
|
|
3664
|
+
async run({ args }) {
|
|
3665
|
+
const cwd = process$1.cwd();
|
|
3666
|
+
const ctx = loadHostContextOrExit({
|
|
3667
|
+
cwd,
|
|
3668
|
+
runtimeDir: args.runtimeDir,
|
|
3669
|
+
hostArg: args.host
|
|
3670
|
+
});
|
|
3671
|
+
if (!ctx) return;
|
|
3672
|
+
const { repoRoot, layout, hostName, hostCfg } = ctx;
|
|
3673
|
+
await requireDeployGate({
|
|
3674
|
+
runtimeDir: args.runtimeDir,
|
|
3675
|
+
envFile: args.envFile,
|
|
3676
|
+
host: hostName,
|
|
3677
|
+
scope: "server-deploy",
|
|
3678
|
+
strict: false,
|
|
3679
|
+
skipGithubTokenCheck: true
|
|
3680
|
+
});
|
|
3681
|
+
const targetHost = requireTargetHost(String(args.targetHost || hostCfg.targetHost || ""), hostName);
|
|
3682
|
+
const sudo = needsSudo(targetHost);
|
|
3683
|
+
const deployCreds = loadDeployCreds({
|
|
3684
|
+
cwd,
|
|
3685
|
+
runtimeDir: args.runtimeDir,
|
|
3686
|
+
envFile: args.envFile
|
|
3687
|
+
});
|
|
3688
|
+
if (deployCreds.envFile?.origin === "explicit" && deployCreds.envFile.status !== "ok") throw new Error(`deploy env file rejected: ${deployCreds.envFile.path} (${deployCreds.envFile.error || deployCreds.envFile.status})`);
|
|
3689
|
+
const nixBin = String(deployCreds.values.NIX_BIN || "nix").trim() || "nix";
|
|
3690
|
+
const manifestPath = String(args.manifest || "").trim();
|
|
3691
|
+
const toplevelArg = String(args.toplevel || "").trim();
|
|
3692
|
+
if (manifestPath && toplevelArg) throw new Error("use either --manifest or --toplevel (not both)");
|
|
3693
|
+
let resolvedRev = "";
|
|
3694
|
+
let toplevel = "";
|
|
3695
|
+
let manifestDigest;
|
|
3696
|
+
if (manifestPath) {
|
|
3697
|
+
await verifyManifestSignature({
|
|
3698
|
+
manifestPath,
|
|
3699
|
+
signaturePath: resolveManifestSignaturePath({
|
|
3700
|
+
cwd,
|
|
3701
|
+
manifestPath,
|
|
3702
|
+
signaturePathArg: args.manifestSignature
|
|
3703
|
+
}),
|
|
3704
|
+
publicKey: resolveManifestPublicKey({
|
|
3705
|
+
publicKeyArg: args.manifestPublicKey,
|
|
3706
|
+
publicKeyFileArg: args.manifestPublicKeyFile,
|
|
3707
|
+
defaultKeyPath: path.join(repoRoot, "config", "manifest.minisign.pub"),
|
|
3708
|
+
hostPublicKey: hostCfg?.selfUpdate?.publicKey
|
|
3709
|
+
})
|
|
3710
|
+
});
|
|
3711
|
+
const manifest = parseDeployManifest(manifestPath);
|
|
3712
|
+
if (manifest.host !== hostName) throw new Error(`manifest host mismatch: ${manifest.host} vs ${hostName}`);
|
|
3713
|
+
const revArg = String(args.rev || "").trim();
|
|
3714
|
+
if (revArg && revArg !== "HEAD" && revArg !== manifest.rev) throw new Error(`manifest rev mismatch: ${manifest.rev} vs ${revArg}`);
|
|
3715
|
+
resolvedRev = manifest.rev;
|
|
3716
|
+
toplevel = manifest.toplevel;
|
|
3717
|
+
manifestDigest = manifest.secretsDigest;
|
|
3718
|
+
} else {
|
|
3719
|
+
const revRaw = String(args.rev || "").trim() || "HEAD";
|
|
3720
|
+
const resolved = await resolveGitRev(layout.repoRoot, revRaw);
|
|
3721
|
+
if (!resolved) throw new Error(`unable to resolve git rev: ${revRaw}`);
|
|
3722
|
+
resolvedRev = resolved;
|
|
3723
|
+
if (toplevelArg) toplevel = requireToplevel(toplevelArg);
|
|
3724
|
+
else toplevel = await buildLocalToplevel({
|
|
3725
|
+
repoRoot,
|
|
3726
|
+
nixBin,
|
|
3727
|
+
host: String(hostCfg.flakeHost || hostName).trim() || hostName
|
|
3728
|
+
});
|
|
3729
|
+
}
|
|
3730
|
+
const { tarPath: tarLocal, digest } = await createSecretsTar({
|
|
3731
|
+
hostName,
|
|
3732
|
+
localDir: getHostSecretsDir(layout, hostName)
|
|
3733
|
+
});
|
|
3734
|
+
const tarRemote = `/tmp/clawdlets-secrets.${hostName}.${process$1.pid}.tgz`;
|
|
3735
|
+
if (manifestDigest && manifestDigest !== digest) throw new Error(`secrets digest mismatch (manifest ${manifestDigest}, local ${digest}); regenerate or omit secretsDigest`);
|
|
3736
|
+
try {
|
|
3737
|
+
await run("scp", [tarLocal, `${targetHost}:${tarRemote}`], { redact: [] });
|
|
3738
|
+
} finally {
|
|
3739
|
+
try {
|
|
3740
|
+
if (fs.existsSync(tarLocal)) fs.unlinkSync(tarLocal);
|
|
3741
|
+
} catch {}
|
|
3742
|
+
}
|
|
3743
|
+
await sshRun(targetHost, [
|
|
3744
|
+
...sudo ? ["sudo"] : [],
|
|
3745
|
+
"/etc/clawdlets/bin/install-secrets",
|
|
3746
|
+
"--host",
|
|
3747
|
+
hostName,
|
|
3748
|
+
"--tar",
|
|
3749
|
+
tarRemote,
|
|
3750
|
+
"--rev",
|
|
3751
|
+
resolvedRev,
|
|
3752
|
+
"--digest",
|
|
3753
|
+
digest
|
|
3754
|
+
].map(shellQuote).join(" "), { tty: sudo && args.sshTty });
|
|
3755
|
+
await sshRun(targetHost, [
|
|
3756
|
+
...sudo ? ["sudo"] : [],
|
|
3757
|
+
"/etc/clawdlets/bin/switch-system",
|
|
3758
|
+
"--toplevel",
|
|
3759
|
+
toplevel,
|
|
3760
|
+
"--rev",
|
|
3761
|
+
resolvedRev
|
|
3762
|
+
].map(shellQuote).join(" "), { tty: sudo && args.sshTty });
|
|
3763
|
+
const manifestOutRaw = String(args.manifestOut || "").trim();
|
|
3764
|
+
const manifestOut = manifestOutRaw ? path.isAbsolute(manifestOutRaw) ? manifestOutRaw : path.resolve(cwd, manifestOutRaw) : manifestPath ? "" : path.join(layout.runtimeDir, "deploy.json");
|
|
3765
|
+
if (manifestOut) {
|
|
3766
|
+
fs.mkdirSync(path.dirname(manifestOut), { recursive: true });
|
|
3767
|
+
const manifest = {
|
|
3768
|
+
rev: resolvedRev,
|
|
3769
|
+
host: hostName,
|
|
3770
|
+
toplevel,
|
|
3771
|
+
secretsDigest: digest
|
|
3772
|
+
};
|
|
3773
|
+
fs.writeFileSync(manifestOut, formatDeployManifest(manifest), "utf8");
|
|
3774
|
+
console.log(`ok: wrote deploy manifest ${manifestOut}`);
|
|
3775
|
+
}
|
|
3776
|
+
console.log(`ok: deployed ${hostName} (${resolvedRev})`);
|
|
3777
|
+
}
|
|
3778
|
+
});
|
|
3779
|
+
|
|
3780
|
+
//#endregion
|
|
3781
|
+
//#region src/commands/server/manifest.ts
|
|
3782
|
+
async function buildToplevel(params) {
|
|
3783
|
+
const attr = `.#packages.x86_64-linux.${params.host}-system`;
|
|
3784
|
+
const out = await capture(params.nixBin, [
|
|
3785
|
+
"build",
|
|
3786
|
+
"--json",
|
|
3787
|
+
"--no-link",
|
|
3788
|
+
attr
|
|
3789
|
+
], {
|
|
3790
|
+
cwd: params.repoRoot,
|
|
3791
|
+
env: withFlakesEnv(process$1.env)
|
|
3792
|
+
});
|
|
3793
|
+
let parsed;
|
|
3794
|
+
try {
|
|
3795
|
+
parsed = JSON.parse(out);
|
|
3796
|
+
} catch (e) {
|
|
3797
|
+
throw new Error(`nix build --json returned invalid JSON (${String(e?.message || e)})`);
|
|
3798
|
+
}
|
|
3799
|
+
const toplevel = parsed?.[0]?.outputs?.out;
|
|
3800
|
+
if (!toplevel || typeof toplevel !== "string") throw new Error("nix build did not return a toplevel store path");
|
|
3801
|
+
return requireToplevel(toplevel);
|
|
3802
|
+
}
|
|
3803
|
+
const serverManifest = defineCommand({
|
|
3804
|
+
meta: {
|
|
3805
|
+
name: "manifest",
|
|
3806
|
+
description: "Build a deploy manifest (rev + toplevel + secrets digest)."
|
|
3807
|
+
},
|
|
3808
|
+
args: {
|
|
3809
|
+
runtimeDir: {
|
|
3810
|
+
type: "string",
|
|
3811
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
3812
|
+
},
|
|
3813
|
+
host: {
|
|
3814
|
+
type: "string",
|
|
3815
|
+
description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
|
|
3816
|
+
},
|
|
3817
|
+
rev: {
|
|
3818
|
+
type: "string",
|
|
3819
|
+
description: "Git rev to pin (HEAD/sha/tag).",
|
|
3820
|
+
default: "HEAD"
|
|
3821
|
+
},
|
|
3822
|
+
toplevel: {
|
|
3823
|
+
type: "string",
|
|
3824
|
+
description: "NixOS system toplevel store path (skip build)."
|
|
3825
|
+
},
|
|
3826
|
+
out: {
|
|
3827
|
+
type: "string",
|
|
3828
|
+
description: "Output manifest path (default: deploy-manifest.<host>.json)."
|
|
3829
|
+
},
|
|
3830
|
+
nixBin: {
|
|
3831
|
+
type: "string",
|
|
3832
|
+
description: "Override nix binary (default: nix)."
|
|
3833
|
+
}
|
|
3834
|
+
},
|
|
3835
|
+
async run({ args }) {
|
|
3836
|
+
const cwd = process$1.cwd();
|
|
3837
|
+
const ctx = loadHostContextOrExit({
|
|
3838
|
+
cwd,
|
|
3839
|
+
runtimeDir: args.runtimeDir,
|
|
3840
|
+
hostArg: args.host
|
|
3841
|
+
});
|
|
3842
|
+
if (!ctx) return;
|
|
3843
|
+
const { repoRoot, layout, hostName } = ctx;
|
|
3844
|
+
const revRaw = String(args.rev || "").trim() || "HEAD";
|
|
3845
|
+
const resolved = await resolveGitRev(layout.repoRoot, revRaw);
|
|
3846
|
+
if (!resolved) throw new Error(`unable to resolve git rev: ${revRaw}`);
|
|
3847
|
+
const rev = requireRev(resolved);
|
|
3848
|
+
const nixBin = String(args.nixBin || process$1.env.NIX_BIN || "nix").trim() || "nix";
|
|
3849
|
+
const toplevelArg = String(args.toplevel || "").trim();
|
|
3850
|
+
if (!toplevelArg) requireLinuxForLocalNixosBuild({
|
|
3851
|
+
platform: process$1.platform,
|
|
3852
|
+
command: "clawdlets server manifest"
|
|
3853
|
+
});
|
|
3854
|
+
const toplevel = toplevelArg ? requireToplevel(toplevelArg) : await buildToplevel({
|
|
3855
|
+
repoRoot,
|
|
3856
|
+
nixBin,
|
|
3857
|
+
host: hostName
|
|
3858
|
+
});
|
|
3859
|
+
const { tarPath: tarLocal, digest } = await createSecretsTar({
|
|
3860
|
+
hostName,
|
|
3861
|
+
localDir: getHostSecretsDir(layout, hostName)
|
|
3862
|
+
});
|
|
3863
|
+
try {
|
|
3864
|
+
const manifest = {
|
|
3865
|
+
rev,
|
|
3866
|
+
host: hostName,
|
|
3867
|
+
toplevel,
|
|
3868
|
+
secretsDigest: digest
|
|
3869
|
+
};
|
|
3870
|
+
const outRaw = String(args.out || "").trim();
|
|
3871
|
+
const outPath = outRaw ? path.isAbsolute(outRaw) ? outRaw : path.resolve(cwd, outRaw) : path.join(cwd, `deploy-manifest.${hostName}.json`);
|
|
3872
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
3873
|
+
fs.writeFileSync(outPath, formatDeployManifest(manifest), "utf8");
|
|
3874
|
+
console.log(`ok: wrote deploy manifest ${outPath}`);
|
|
3875
|
+
} finally {
|
|
3876
|
+
try {
|
|
3877
|
+
if (fs.existsSync(tarLocal)) fs.unlinkSync(tarLocal);
|
|
3878
|
+
} catch {}
|
|
3879
|
+
}
|
|
3880
|
+
}
|
|
3881
|
+
});
|
|
3882
|
+
|
|
3883
|
+
//#endregion
|
|
3884
|
+
//#region src/commands/server.ts
|
|
3885
|
+
function normalizeSince(value) {
|
|
3886
|
+
const v = value.trim();
|
|
3887
|
+
const m = v.match(/^(\d+)\s*([smhd])$/i);
|
|
3888
|
+
if (!m) return v;
|
|
3889
|
+
const n = Number(m[1]);
|
|
3890
|
+
const unit = String(m[2]).toLowerCase();
|
|
3891
|
+
if (!Number.isFinite(n) || n <= 0) return v;
|
|
3892
|
+
if (unit === "s") return `${n} sec ago`;
|
|
3893
|
+
if (unit === "m") return `${n} min ago`;
|
|
3894
|
+
if (unit === "h") return `${n} hour ago`;
|
|
3895
|
+
if (unit === "d") return `${n} day ago`;
|
|
3896
|
+
return v;
|
|
3897
|
+
}
|
|
3898
|
+
function normalizeClawdbotUnit(value) {
|
|
3899
|
+
const v = value.trim();
|
|
3900
|
+
if (v === "clawdbot-*.service") return v;
|
|
3901
|
+
if (/^clawdbot-[A-Za-z0-9._-]+$/.test(v)) return `${v}.service`;
|
|
3902
|
+
if (/^clawdbot-[A-Za-z0-9._-]+\.service$/.test(v)) return v;
|
|
3903
|
+
throw new Error(`invalid --unit: ${v} (expected clawdbot-<id>[.service] or clawdbot-*.service)`);
|
|
3904
|
+
}
|
|
3905
|
+
function parseSystemctlShow(output) {
|
|
3906
|
+
const out = {};
|
|
3907
|
+
for (const line of output.split("\n")) {
|
|
3908
|
+
const idx = line.indexOf("=");
|
|
3909
|
+
if (idx <= 0) continue;
|
|
3910
|
+
const key = line.slice(0, idx);
|
|
3911
|
+
if (key in out) continue;
|
|
3912
|
+
out[key] = line.slice(idx + 1);
|
|
3913
|
+
}
|
|
3914
|
+
return out;
|
|
3915
|
+
}
|
|
3916
|
+
async function trySshCapture(targetHost, remoteCmd, opts = {}) {
|
|
3917
|
+
try {
|
|
3918
|
+
return {
|
|
3919
|
+
ok: true,
|
|
3920
|
+
out: await sshCapture(targetHost, remoteCmd, opts)
|
|
3921
|
+
};
|
|
3922
|
+
} catch (e) {
|
|
3923
|
+
return {
|
|
3924
|
+
ok: false,
|
|
3925
|
+
out: String(e?.message || e)
|
|
3926
|
+
};
|
|
3927
|
+
}
|
|
3928
|
+
}
|
|
3929
|
+
const serverAudit = defineCommand({
|
|
3930
|
+
meta: {
|
|
3931
|
+
name: "audit",
|
|
3932
|
+
description: "Audit host invariants over SSH (tailscale, clawdbot services)."
|
|
3933
|
+
},
|
|
3934
|
+
args: {
|
|
3935
|
+
runtimeDir: {
|
|
3936
|
+
type: "string",
|
|
3937
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
3938
|
+
},
|
|
3939
|
+
host: {
|
|
3940
|
+
type: "string",
|
|
3941
|
+
description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
|
|
3942
|
+
},
|
|
3943
|
+
targetHost: {
|
|
3944
|
+
type: "string",
|
|
3945
|
+
description: "SSH target override (default: from clawdlets.json)."
|
|
3946
|
+
},
|
|
3947
|
+
sshTty: {
|
|
3948
|
+
type: "boolean",
|
|
3949
|
+
description: "Allocate TTY for sudo prompts.",
|
|
3950
|
+
default: true
|
|
3951
|
+
},
|
|
3952
|
+
json: {
|
|
3953
|
+
type: "boolean",
|
|
3954
|
+
description: "Output JSON.",
|
|
3955
|
+
default: false
|
|
3956
|
+
}
|
|
3957
|
+
},
|
|
3958
|
+
async run({ args }) {
|
|
3959
|
+
const ctx = loadHostContextOrExit({
|
|
3960
|
+
cwd: process$1.cwd(),
|
|
3961
|
+
runtimeDir: args.runtimeDir,
|
|
3962
|
+
hostArg: args.host
|
|
3963
|
+
});
|
|
3964
|
+
if (!ctx) return;
|
|
3965
|
+
const { config: config$1, hostName, hostCfg } = ctx;
|
|
3966
|
+
const targetHost = requireTargetHost(String(args.targetHost || hostCfg.targetHost || ""), hostName);
|
|
3967
|
+
const sudo = needsSudo(targetHost);
|
|
3968
|
+
const bots = config$1.fleet.botOrder ?? [];
|
|
3969
|
+
const checks = [];
|
|
3970
|
+
const add$3 = (c) => checks.push(c);
|
|
3971
|
+
const must = async (label, cmd) => {
|
|
3972
|
+
const out = await trySshCapture(targetHost, cmd, { tty: sudo && args.sshTty });
|
|
3973
|
+
if (!out.ok) {
|
|
3974
|
+
add$3({
|
|
3975
|
+
status: "missing",
|
|
3976
|
+
label,
|
|
3977
|
+
detail: out.out
|
|
3978
|
+
});
|
|
3979
|
+
return null;
|
|
3980
|
+
}
|
|
3981
|
+
return out.out;
|
|
3982
|
+
};
|
|
3983
|
+
if (hostCfg.tailnet?.mode === "tailscale") {
|
|
3984
|
+
const tailscaled = await must("tailscale service", [
|
|
3985
|
+
...sudo ? ["sudo"] : [],
|
|
3986
|
+
"systemctl",
|
|
3987
|
+
"show",
|
|
3988
|
+
"tailscaled.service"
|
|
3989
|
+
].join(" "));
|
|
3990
|
+
if (tailscaled) {
|
|
3991
|
+
const parsed = parseSystemctlShow(tailscaled);
|
|
3992
|
+
add$3({
|
|
3993
|
+
status: parsed.ActiveState === "active" ? "ok" : "missing",
|
|
3994
|
+
label: "tailscale service state",
|
|
3995
|
+
detail: `${parsed.ActiveState || "?"}/${parsed.SubState || "?"}`
|
|
3996
|
+
});
|
|
3997
|
+
}
|
|
3998
|
+
const autoconnect = await must("tailscale autoconnect", [
|
|
3999
|
+
...sudo ? ["sudo"] : [],
|
|
4000
|
+
"systemctl",
|
|
4001
|
+
"show",
|
|
4002
|
+
"tailscaled-autoconnect.service"
|
|
4003
|
+
].join(" "));
|
|
4004
|
+
if (autoconnect) {
|
|
4005
|
+
const parsed = parseSystemctlShow(autoconnect);
|
|
4006
|
+
add$3({
|
|
4007
|
+
status: parsed.ActiveState === "active" ? "ok" : "missing",
|
|
4008
|
+
label: "tailscale autoconnect state",
|
|
4009
|
+
detail: `${parsed.ActiveState || "?"}/${parsed.SubState || "?"}`
|
|
4010
|
+
});
|
|
4011
|
+
}
|
|
4012
|
+
}
|
|
4013
|
+
if (Array.isArray(bots) && bots.length > 0) add$3({
|
|
4014
|
+
status: "ok",
|
|
4015
|
+
label: "fleet bots list",
|
|
4016
|
+
detail: bots.join(", ")
|
|
4017
|
+
});
|
|
4018
|
+
else add$3({
|
|
4019
|
+
status: "warn",
|
|
4020
|
+
label: "fleet bots list",
|
|
4021
|
+
detail: "(empty)"
|
|
4022
|
+
});
|
|
4023
|
+
for (const bot$1 of bots) {
|
|
4024
|
+
const unit = normalizeClawdbotUnit(`clawdbot-${String(bot$1).trim()}`);
|
|
4025
|
+
const show$2 = await must(`systemctl show ${unit}`, [
|
|
4026
|
+
...sudo ? ["sudo"] : [],
|
|
4027
|
+
"systemctl",
|
|
4028
|
+
"show",
|
|
4029
|
+
shellQuote(unit)
|
|
4030
|
+
].join(" "));
|
|
4031
|
+
if (!show$2) continue;
|
|
4032
|
+
const parsed = parseSystemctlShow(show$2);
|
|
4033
|
+
const loadState = parsed.LoadState || "";
|
|
4034
|
+
const activeState = parsed.ActiveState || "";
|
|
4035
|
+
const subState = parsed.SubState || "";
|
|
4036
|
+
if (loadState && loadState !== "loaded") add$3({
|
|
4037
|
+
status: "missing",
|
|
4038
|
+
label: `${unit} load state`,
|
|
4039
|
+
detail: `LoadState=${loadState}`
|
|
4040
|
+
});
|
|
4041
|
+
else if (activeState === "active" && subState === "running") add$3({
|
|
4042
|
+
status: "ok",
|
|
4043
|
+
label: `${unit} state`,
|
|
4044
|
+
detail: `${activeState}/${subState}`
|
|
4045
|
+
});
|
|
4046
|
+
else add$3({
|
|
4047
|
+
status: "missing",
|
|
4048
|
+
label: `${unit} state`,
|
|
4049
|
+
detail: `${activeState || "?"}/${subState || "?"}`
|
|
4050
|
+
});
|
|
4051
|
+
}
|
|
4052
|
+
if (args.json) console.log(JSON.stringify({
|
|
4053
|
+
host: hostName,
|
|
4054
|
+
targetHost,
|
|
4055
|
+
checks
|
|
4056
|
+
}, null, 2));
|
|
4057
|
+
else for (const c of checks) console.log(`${c.status}: ${c.label}${c.detail ? ` (${c.detail})` : ""}`);
|
|
4058
|
+
if (checks.some((c) => c.status === "missing")) process$1.exitCode = 1;
|
|
4059
|
+
}
|
|
4060
|
+
});
|
|
4061
|
+
const serverStatus = defineCommand({
|
|
4062
|
+
meta: {
|
|
4063
|
+
name: "status",
|
|
4064
|
+
description: "Show systemd status for Clawdbot services."
|
|
4065
|
+
},
|
|
4066
|
+
args: {
|
|
4067
|
+
runtimeDir: {
|
|
4068
|
+
type: "string",
|
|
4069
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
4070
|
+
},
|
|
4071
|
+
host: {
|
|
4072
|
+
type: "string",
|
|
4073
|
+
description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
|
|
4074
|
+
},
|
|
4075
|
+
targetHost: {
|
|
4076
|
+
type: "string",
|
|
4077
|
+
description: "SSH target override (default: from clawdlets.json)."
|
|
4078
|
+
},
|
|
4079
|
+
sshTty: {
|
|
4080
|
+
type: "boolean",
|
|
4081
|
+
description: "Allocate TTY for sudo prompts.",
|
|
4082
|
+
default: true
|
|
4083
|
+
}
|
|
4084
|
+
},
|
|
4085
|
+
async run({ args }) {
|
|
4086
|
+
const ctx = loadHostContextOrExit({
|
|
4087
|
+
cwd: process$1.cwd(),
|
|
4088
|
+
runtimeDir: args.runtimeDir,
|
|
4089
|
+
hostArg: args.host
|
|
4090
|
+
});
|
|
4091
|
+
if (!ctx) return;
|
|
4092
|
+
const { hostName, hostCfg } = ctx;
|
|
4093
|
+
const targetHost = requireTargetHost(String(args.targetHost || hostCfg.targetHost || ""), hostName);
|
|
4094
|
+
const sudo = needsSudo(targetHost);
|
|
4095
|
+
const out = await sshCapture(targetHost, [
|
|
4096
|
+
...sudo ? ["sudo"] : [],
|
|
4097
|
+
"systemctl",
|
|
4098
|
+
"list-units",
|
|
4099
|
+
"clawdbot-*.service",
|
|
4100
|
+
"--no-pager"
|
|
4101
|
+
].join(" "), { tty: sudo && args.sshTty });
|
|
4102
|
+
console.log(out);
|
|
4103
|
+
}
|
|
4104
|
+
});
|
|
4105
|
+
const serverLogs = defineCommand({
|
|
4106
|
+
meta: {
|
|
4107
|
+
name: "logs",
|
|
4108
|
+
description: "Stream or print logs via journalctl."
|
|
4109
|
+
},
|
|
4110
|
+
args: {
|
|
4111
|
+
runtimeDir: {
|
|
4112
|
+
type: "string",
|
|
4113
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
4114
|
+
},
|
|
4115
|
+
host: {
|
|
4116
|
+
type: "string",
|
|
4117
|
+
description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
|
|
4118
|
+
},
|
|
4119
|
+
targetHost: {
|
|
4120
|
+
type: "string",
|
|
4121
|
+
description: "SSH target override (default: from clawdlets.json)."
|
|
4122
|
+
},
|
|
4123
|
+
unit: {
|
|
4124
|
+
type: "string",
|
|
4125
|
+
description: "systemd unit (default: clawdbot-*.service).",
|
|
4126
|
+
default: "clawdbot-*.service"
|
|
4127
|
+
},
|
|
4128
|
+
lines: {
|
|
4129
|
+
type: "string",
|
|
4130
|
+
description: "Number of lines (default: 200).",
|
|
4131
|
+
default: "200"
|
|
4132
|
+
},
|
|
4133
|
+
since: {
|
|
4134
|
+
type: "string",
|
|
4135
|
+
description: "Time window (supports 5m/1h/2d or journalctl syntax)."
|
|
4136
|
+
},
|
|
4137
|
+
follow: {
|
|
4138
|
+
type: "boolean",
|
|
4139
|
+
description: "Follow logs.",
|
|
4140
|
+
default: false
|
|
4141
|
+
},
|
|
4142
|
+
sshTty: {
|
|
4143
|
+
type: "boolean",
|
|
4144
|
+
description: "Allocate TTY for sudo prompts.",
|
|
4145
|
+
default: true
|
|
4146
|
+
}
|
|
4147
|
+
},
|
|
4148
|
+
async run({ args }) {
|
|
4149
|
+
const ctx = loadHostContextOrExit({
|
|
4150
|
+
cwd: process$1.cwd(),
|
|
4151
|
+
runtimeDir: args.runtimeDir,
|
|
4152
|
+
hostArg: args.host
|
|
4153
|
+
});
|
|
4154
|
+
if (!ctx) return;
|
|
4155
|
+
const { hostName, hostCfg } = ctx;
|
|
4156
|
+
const targetHost = requireTargetHost(String(args.targetHost || hostCfg.targetHost || ""), hostName);
|
|
4157
|
+
const sudo = needsSudo(targetHost);
|
|
4158
|
+
const unit = normalizeClawdbotUnit(String(args.unit || "clawdbot-*.service"));
|
|
4159
|
+
const since = args.since ? normalizeSince(String(args.since)) : "";
|
|
4160
|
+
const n = String(args.lines || "200").trim() || "200";
|
|
4161
|
+
if (!/^\d+$/.test(n) || Number(n) <= 0) throw new Error(`invalid --lines: ${n}`);
|
|
4162
|
+
await sshRun(targetHost, [
|
|
4163
|
+
...sudo ? ["sudo"] : [],
|
|
4164
|
+
"journalctl",
|
|
4165
|
+
"-u",
|
|
4166
|
+
shellQuote(unit),
|
|
4167
|
+
"-n",
|
|
4168
|
+
shellQuote(n),
|
|
4169
|
+
...since ? ["--since", shellQuote(since)] : [],
|
|
4170
|
+
...args.follow ? ["-f"] : [],
|
|
4171
|
+
"--no-pager"
|
|
4172
|
+
].join(" "), { tty: sudo && args.sshTty });
|
|
4173
|
+
}
|
|
4174
|
+
});
|
|
4175
|
+
const serverRestart = defineCommand({
|
|
4176
|
+
meta: {
|
|
4177
|
+
name: "restart",
|
|
4178
|
+
description: "Restart a systemd unit (default: clawdbot-*.service)."
|
|
4179
|
+
},
|
|
4180
|
+
args: {
|
|
4181
|
+
runtimeDir: {
|
|
4182
|
+
type: "string",
|
|
4183
|
+
description: "Runtime directory (default: .clawdlets)."
|
|
4184
|
+
},
|
|
4185
|
+
host: {
|
|
4186
|
+
type: "string",
|
|
4187
|
+
description: "Host name (defaults to clawdlets.json defaultHost / sole host)."
|
|
4188
|
+
},
|
|
4189
|
+
targetHost: {
|
|
4190
|
+
type: "string",
|
|
4191
|
+
description: "SSH target override (default: from clawdlets.json)."
|
|
4192
|
+
},
|
|
4193
|
+
unit: {
|
|
4194
|
+
type: "string",
|
|
4195
|
+
description: "systemd unit (default: clawdbot-*.service).",
|
|
4196
|
+
default: "clawdbot-*.service"
|
|
4197
|
+
},
|
|
4198
|
+
sshTty: {
|
|
4199
|
+
type: "boolean",
|
|
4200
|
+
description: "Allocate TTY for sudo prompts.",
|
|
4201
|
+
default: true
|
|
4202
|
+
}
|
|
4203
|
+
},
|
|
4204
|
+
async run({ args }) {
|
|
4205
|
+
const ctx = loadHostContextOrExit({
|
|
4206
|
+
cwd: process$1.cwd(),
|
|
4207
|
+
runtimeDir: args.runtimeDir,
|
|
4208
|
+
hostArg: args.host
|
|
4209
|
+
});
|
|
4210
|
+
if (!ctx) return;
|
|
4211
|
+
const { hostName, hostCfg } = ctx;
|
|
4212
|
+
const targetHost = requireTargetHost(String(args.targetHost || hostCfg.targetHost || ""), hostName);
|
|
4213
|
+
const unit = String(args.unit || "clawdbot-*.service").trim() || "clawdbot-*.service";
|
|
4214
|
+
const sudo = needsSudo(targetHost);
|
|
4215
|
+
await sshRun(targetHost, [
|
|
4216
|
+
...sudo ? ["sudo"] : [],
|
|
4217
|
+
"systemctl",
|
|
4218
|
+
"restart",
|
|
4219
|
+
shellQuote(unit)
|
|
4220
|
+
].join(" "), { tty: sudo && args.sshTty });
|
|
4221
|
+
}
|
|
4222
|
+
});
|
|
4223
|
+
const server = defineCommand({
|
|
4224
|
+
meta: {
|
|
4225
|
+
name: "server",
|
|
4226
|
+
description: "Server operations via SSH (deploy/logs/status)."
|
|
4227
|
+
},
|
|
4228
|
+
subCommands: {
|
|
4229
|
+
audit: serverAudit,
|
|
4230
|
+
deploy: serverDeploy,
|
|
4231
|
+
manifest: serverManifest,
|
|
4232
|
+
status: serverStatus,
|
|
4233
|
+
logs: serverLogs,
|
|
4234
|
+
"github-sync": serverGithubSync,
|
|
4235
|
+
restart: serverRestart
|
|
4236
|
+
}
|
|
4237
|
+
});
|
|
4238
|
+
|
|
4239
|
+
//#endregion
|
|
4240
|
+
//#region src/commands/registry.ts
|
|
4241
|
+
const baseCommands = {
|
|
4242
|
+
bot,
|
|
4243
|
+
bootstrap,
|
|
4244
|
+
config,
|
|
4245
|
+
doctor,
|
|
4246
|
+
env,
|
|
4247
|
+
host,
|
|
4248
|
+
fleet,
|
|
4249
|
+
image,
|
|
4250
|
+
infra,
|
|
4251
|
+
lockdown,
|
|
4252
|
+
plugin,
|
|
4253
|
+
project,
|
|
4254
|
+
secrets,
|
|
4255
|
+
server
|
|
4256
|
+
};
|
|
4257
|
+
const baseCommandNames = Object.freeze(Object.keys(baseCommands));
|
|
4258
|
+
|
|
4259
|
+
//#endregion
|
|
4260
|
+
//#region src/lib/plugins.ts
|
|
4261
|
+
var plugins_exports = /* @__PURE__ */ __exportAll({
|
|
4262
|
+
findPluginByCommand: () => findPluginByCommand,
|
|
4263
|
+
installPlugin: () => installPlugin,
|
|
4264
|
+
listInstalledPlugins: () => listInstalledPlugins,
|
|
4265
|
+
listReservedCommands: () => listReservedCommands,
|
|
4266
|
+
loadPluginCommand: () => loadPluginCommand,
|
|
4267
|
+
removePlugin: () => removePlugin
|
|
4268
|
+
});
|
|
4269
|
+
const PLUGIN_MANIFEST = "clawdlets-plugin.json";
|
|
4270
|
+
const RESERVED_COMMANDS = new Set(baseCommandNames);
|
|
4271
|
+
const SAFE_SLUG_RE = /^[a-z][a-z0-9_-]*$/;
|
|
4272
|
+
const PACKAGE_NAME_RE = /^(?:@[a-z0-9][a-z0-9-._]*\/)?[a-z0-9][a-z0-9-._]*$/;
|
|
4273
|
+
function readJsonFile(filePath) {
|
|
4274
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
4275
|
+
return JSON.parse(raw);
|
|
4276
|
+
}
|
|
4277
|
+
function writeJsonFile(filePath, data) {
|
|
4278
|
+
const dir = path.dirname(filePath);
|
|
4279
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
4280
|
+
const tmp = path.join(dir, `.${path.basename(filePath)}.tmp.${process.pid}`);
|
|
4281
|
+
fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
|
4282
|
+
fs.renameSync(tmp, filePath);
|
|
4283
|
+
}
|
|
4284
|
+
function assertSafeSlug(value) {
|
|
4285
|
+
if (!SAFE_SLUG_RE.test(value)) throw new Error(`invalid plugin command: ${value} (expected [a-z][a-z0-9_-]*)`);
|
|
4286
|
+
}
|
|
4287
|
+
function isReservedCommand(value) {
|
|
4288
|
+
return RESERVED_COMMANDS.has(value);
|
|
4289
|
+
}
|
|
4290
|
+
function assertCommandName(value) {
|
|
4291
|
+
assertSafeSlug(value);
|
|
4292
|
+
if (isReservedCommand(value)) throw new Error(`plugin command reserved: ${value}`);
|
|
4293
|
+
}
|
|
4294
|
+
function resolvePluginsDir(params) {
|
|
4295
|
+
return getRepoLayout(findRepoRoot(params.cwd), params.runtimeDir).pluginsDir;
|
|
4296
|
+
}
|
|
4297
|
+
function resolveInstallDir(params) {
|
|
4298
|
+
return path.join(params.pluginsDir, params.slug);
|
|
4299
|
+
}
|
|
4300
|
+
function resolvePackageDir(params) {
|
|
4301
|
+
return path.join(params.installDir, "node_modules", ...params.packageName.split("/"));
|
|
4302
|
+
}
|
|
4303
|
+
function readPluginPackageMeta(packageDir) {
|
|
4304
|
+
const pkgPath = path.join(packageDir, "package.json");
|
|
4305
|
+
if (!fs.existsSync(pkgPath)) throw new Error(`plugin package.json missing: ${pkgPath}`);
|
|
4306
|
+
const pkg = readJsonFile(pkgPath);
|
|
4307
|
+
const command = String(pkg?.clawdlets?.command || "").trim();
|
|
4308
|
+
const entry = String(pkg?.clawdlets?.entry || "").trim();
|
|
4309
|
+
if (!command) throw new Error(`plugin missing clawdlets.command in ${pkgPath}`);
|
|
4310
|
+
if (!entry) throw new Error(`plugin missing clawdlets.entry in ${pkgPath}`);
|
|
4311
|
+
assertCommandName(command);
|
|
4312
|
+
return {
|
|
4313
|
+
command,
|
|
4314
|
+
entry
|
|
4315
|
+
};
|
|
4316
|
+
}
|
|
4317
|
+
function assertPackageName(value) {
|
|
4318
|
+
if (!PACKAGE_NAME_RE.test(value)) throw new Error(`invalid plugin package name: ${value}`);
|
|
4319
|
+
}
|
|
4320
|
+
function resolveManifestPath(installDir) {
|
|
4321
|
+
return path.join(installDir, PLUGIN_MANIFEST);
|
|
4322
|
+
}
|
|
4323
|
+
function normalizeManifest(slug, manifest) {
|
|
4324
|
+
if (!manifest || typeof manifest !== "object") throw new Error("plugin manifest invalid");
|
|
4325
|
+
const packageName = String(manifest.packageName || "").trim();
|
|
4326
|
+
const version = String(manifest.version || "").trim();
|
|
4327
|
+
const command = String(manifest.command || "").trim();
|
|
4328
|
+
const entry = String(manifest.entry || "").trim();
|
|
4329
|
+
if (!packageName) throw new Error("plugin manifest missing packageName");
|
|
4330
|
+
assertPackageName(packageName);
|
|
4331
|
+
if (!version) throw new Error("plugin manifest missing version");
|
|
4332
|
+
if (!command) throw new Error("plugin manifest missing command");
|
|
4333
|
+
if (!entry) throw new Error("plugin manifest missing entry");
|
|
4334
|
+
assertCommandName(command);
|
|
4335
|
+
if (command !== slug) throw new Error(`plugin manifest command mismatch (expected ${slug}, got ${command})`);
|
|
4336
|
+
return {
|
|
4337
|
+
slug,
|
|
4338
|
+
packageName,
|
|
4339
|
+
version,
|
|
4340
|
+
command,
|
|
4341
|
+
entry
|
|
4342
|
+
};
|
|
4343
|
+
}
|
|
4344
|
+
function readPluginManifest(installDir, slug) {
|
|
4345
|
+
return normalizeManifest(slug, readJsonFile(resolveManifestPath(installDir)));
|
|
4346
|
+
}
|
|
4347
|
+
function writePluginManifest(installDir, manifest) {
|
|
4348
|
+
writeJsonFile(resolveManifestPath(installDir), manifest);
|
|
4349
|
+
}
|
|
4350
|
+
function deriveManifestFromInstall(installDir, slug) {
|
|
4351
|
+
const pkgPath = path.join(installDir, "package.json");
|
|
4352
|
+
if (!fs.existsSync(pkgPath)) throw new Error(`plugin install missing package.json: ${pkgPath}`);
|
|
4353
|
+
const pkg = readJsonFile(pkgPath);
|
|
4354
|
+
const deps = Object.keys(pkg.dependencies || {});
|
|
4355
|
+
if (deps.length !== 1) throw new Error(`plugin install must declare exactly one dependency (found ${deps.length})`);
|
|
4356
|
+
const packageName = deps[0] || "";
|
|
4357
|
+
if (!packageName) throw new Error("plugin dependency missing");
|
|
4358
|
+
assertPackageName(packageName);
|
|
4359
|
+
const packageDir = resolvePackageDir({
|
|
4360
|
+
installDir,
|
|
4361
|
+
packageName
|
|
4362
|
+
});
|
|
4363
|
+
const meta = readPluginPackageMeta(packageDir);
|
|
4364
|
+
if (meta.command !== slug) throw new Error(`plugin command mismatch: expected ${slug} got ${meta.command}`);
|
|
4365
|
+
const pluginPkgPath = path.join(packageDir, "package.json");
|
|
4366
|
+
const pluginPkg = readJsonFile(pluginPkgPath);
|
|
4367
|
+
const version = String(pluginPkg.version || "").trim();
|
|
4368
|
+
if (!version) throw new Error(`plugin version missing in ${pluginPkgPath}`);
|
|
4369
|
+
return {
|
|
4370
|
+
slug,
|
|
4371
|
+
packageName,
|
|
4372
|
+
version,
|
|
4373
|
+
command: meta.command,
|
|
4374
|
+
entry: meta.entry
|
|
4375
|
+
};
|
|
4376
|
+
}
|
|
4377
|
+
function listInstalledPlugins(params) {
|
|
4378
|
+
const pluginsDir = resolvePluginsDir(params);
|
|
4379
|
+
if (!fs.existsSync(pluginsDir)) return [];
|
|
4380
|
+
const entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
|
|
4381
|
+
const out = [];
|
|
4382
|
+
for (const ent of entries) {
|
|
4383
|
+
if (!ent.isDirectory()) continue;
|
|
4384
|
+
const slug = ent.name;
|
|
4385
|
+
if (slug.startsWith(".")) continue;
|
|
4386
|
+
try {
|
|
4387
|
+
assertSafeSlug(slug);
|
|
4388
|
+
const installDir = resolveInstallDir({
|
|
4389
|
+
pluginsDir,
|
|
4390
|
+
slug
|
|
4391
|
+
});
|
|
4392
|
+
let manifest;
|
|
4393
|
+
try {
|
|
4394
|
+
manifest = readPluginManifest(installDir, slug);
|
|
4395
|
+
} catch {
|
|
4396
|
+
manifest = deriveManifestFromInstall(installDir, slug);
|
|
4397
|
+
writePluginManifest(installDir, manifest);
|
|
4398
|
+
}
|
|
4399
|
+
const packageDir = resolvePackageDir({
|
|
4400
|
+
installDir,
|
|
4401
|
+
packageName: manifest.packageName
|
|
4402
|
+
});
|
|
4403
|
+
out.push({
|
|
4404
|
+
...manifest,
|
|
4405
|
+
installDir,
|
|
4406
|
+
packageDir
|
|
4407
|
+
});
|
|
4408
|
+
} catch (error) {
|
|
4409
|
+
params.onError?.({
|
|
4410
|
+
slug,
|
|
4411
|
+
error: error instanceof Error ? error : new Error(String(error))
|
|
4412
|
+
});
|
|
4413
|
+
continue;
|
|
4414
|
+
}
|
|
4415
|
+
}
|
|
4416
|
+
return out.sort((a, b) => a.command.localeCompare(b.command));
|
|
4417
|
+
}
|
|
4418
|
+
function findPluginByCommand(params) {
|
|
4419
|
+
const cmd = params.command.trim();
|
|
4420
|
+
if (!cmd || cmd.startsWith("-") || isReservedCommand(cmd)) return null;
|
|
4421
|
+
return listInstalledPlugins(params).find((p$1) => p$1.command === cmd) || null;
|
|
4422
|
+
}
|
|
4423
|
+
async function loadPluginCommand(plugin$1) {
|
|
4424
|
+
const entryRel = plugin$1.entry.trim();
|
|
4425
|
+
if (!entryRel) throw new Error(`plugin entry empty for ${plugin$1.command}`);
|
|
4426
|
+
if (path.isAbsolute(entryRel)) throw new Error(`plugin entry must be relative: ${entryRel}`);
|
|
4427
|
+
if (entryRel.split(/[/\\\\]+/).includes("..")) throw new Error(`plugin entry must not contain .. segments: ${entryRel}`);
|
|
4428
|
+
const entryPath = path.resolve(plugin$1.packageDir, entryRel);
|
|
4429
|
+
const entryRelPath = path.relative(plugin$1.packageDir, entryPath);
|
|
4430
|
+
if (entryRelPath.startsWith("..") || path.isAbsolute(entryRelPath)) throw new Error(`plugin entry escapes package: ${entryRel}`);
|
|
4431
|
+
if (!fs.existsSync(entryPath)) throw new Error(`plugin entry missing: ${entryPath}`);
|
|
4432
|
+
const mod = await import(pathToFileURL(entryPath).href);
|
|
4433
|
+
const command = mod.command || mod.plugin?.command || mod.default?.command || mod.default;
|
|
4434
|
+
if (!command) throw new Error(`plugin entry ${entryPath} does not export a command`);
|
|
4435
|
+
return command;
|
|
4436
|
+
}
|
|
4437
|
+
async function installPlugin(params) {
|
|
4438
|
+
const pluginsDir = resolvePluginsDir(params);
|
|
4439
|
+
const slug = params.slug.trim();
|
|
4440
|
+
if (!slug) throw new Error("plugin name required");
|
|
4441
|
+
assertCommandName(slug);
|
|
4442
|
+
const packageName = params.packageName.trim();
|
|
4443
|
+
if (!packageName) throw new Error("package name required");
|
|
4444
|
+
assertPackageName(packageName);
|
|
4445
|
+
if (!params.allowThirdParty && !packageName.startsWith("@clawdlets/")) throw new Error("third-party plugins disabled (pass --allow-third-party to override)");
|
|
4446
|
+
const installDir = resolveInstallDir({
|
|
4447
|
+
pluginsDir,
|
|
4448
|
+
slug
|
|
4449
|
+
});
|
|
4450
|
+
if (fs.existsSync(installDir) && fs.readdirSync(installDir).length > 0) throw new Error(`plugin already installed: ${slug} (${installDir})`);
|
|
4451
|
+
fs.mkdirSync(installDir, { recursive: true });
|
|
4452
|
+
const depVersion = params.version?.trim() || "latest";
|
|
4453
|
+
const pkgJson = {
|
|
4454
|
+
name: `clawdlets-plugin-${slug}`,
|
|
4455
|
+
private: true,
|
|
4456
|
+
type: "module",
|
|
4457
|
+
description: `clawdlets plugin install (${slug})`,
|
|
4458
|
+
dependencies: { [packageName]: depVersion }
|
|
4459
|
+
};
|
|
4460
|
+
writeJsonFile(path.join(installDir, "package.json"), pkgJson);
|
|
4461
|
+
await run("npm", [
|
|
4462
|
+
"install",
|
|
4463
|
+
"--omit=dev",
|
|
4464
|
+
"--no-audit",
|
|
4465
|
+
"--no-fund"
|
|
4466
|
+
], { cwd: installDir });
|
|
4467
|
+
const manifest = deriveManifestFromInstall(installDir, slug);
|
|
4468
|
+
writePluginManifest(installDir, manifest);
|
|
4469
|
+
const packageDir = resolvePackageDir({
|
|
4470
|
+
installDir,
|
|
4471
|
+
packageName: manifest.packageName
|
|
4472
|
+
});
|
|
4473
|
+
return {
|
|
4474
|
+
...manifest,
|
|
4475
|
+
installDir,
|
|
4476
|
+
packageDir
|
|
4477
|
+
};
|
|
4478
|
+
}
|
|
4479
|
+
function removePlugin(params) {
|
|
4480
|
+
const pluginsDir = resolvePluginsDir(params);
|
|
4481
|
+
const slug = params.slug.trim();
|
|
4482
|
+
if (!slug) throw new Error("plugin name required");
|
|
4483
|
+
assertSafeSlug(slug);
|
|
4484
|
+
const installDir = resolveInstallDir({
|
|
4485
|
+
pluginsDir,
|
|
4486
|
+
slug
|
|
4487
|
+
});
|
|
4488
|
+
const rel = path.relative(pluginsDir, installDir);
|
|
4489
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) throw new Error(`plugin path escapes plugins dir: ${installDir}`);
|
|
4490
|
+
if (!fs.existsSync(installDir)) throw new Error(`plugin not installed: ${slug}`);
|
|
4491
|
+
fs.rmSync(installDir, {
|
|
4492
|
+
recursive: true,
|
|
4493
|
+
force: true
|
|
4494
|
+
});
|
|
4495
|
+
}
|
|
4496
|
+
function listReservedCommands() {
|
|
4497
|
+
return [...RESERVED_COMMANDS].sort();
|
|
4498
|
+
}
|
|
4499
|
+
|
|
4500
|
+
//#endregion
|
|
4501
|
+
//#region src/lib/version.ts
|
|
4502
|
+
function resolvePackageRoot(fromUrl = import.meta.url) {
|
|
4503
|
+
let dir = path.dirname(fileURLToPath(fromUrl));
|
|
4504
|
+
for (let i = 0; i < 5; i += 1) {
|
|
4505
|
+
const candidate = path.join(dir, "package.json");
|
|
4506
|
+
if (fs.existsSync(candidate)) return dir;
|
|
4507
|
+
const parent = path.dirname(dir);
|
|
4508
|
+
if (parent === dir) break;
|
|
4509
|
+
dir = parent;
|
|
4510
|
+
}
|
|
4511
|
+
return path.dirname(fileURLToPath(fromUrl));
|
|
4512
|
+
}
|
|
4513
|
+
function readCliVersion(rootDir = resolvePackageRoot()) {
|
|
4514
|
+
const pkgPath = path.join(rootDir, "package.json");
|
|
4515
|
+
const raw = fs.readFileSync(pkgPath, "utf8");
|
|
4516
|
+
const parsed = JSON.parse(raw);
|
|
4517
|
+
if (typeof parsed.version !== "string" || parsed.version.length === 0) throw new Error("missing version in package.json");
|
|
4518
|
+
return parsed.version;
|
|
4519
|
+
}
|
|
4520
|
+
|
|
4521
|
+
//#endregion
|
|
4522
|
+
//#region src/main.ts
|
|
4523
|
+
const main = defineCommand({
|
|
4524
|
+
meta: {
|
|
4525
|
+
name: "clawdlets",
|
|
4526
|
+
description: "Clawdbot fleet helper (CLI-first; runtime state in .clawdlets/; secrets in /secrets)."
|
|
4527
|
+
},
|
|
4528
|
+
subCommands: baseCommands
|
|
4529
|
+
});
|
|
4530
|
+
function resolveRuntimeDir(rawArgs) {
|
|
4531
|
+
for (let i = 0; i < rawArgs.length; i += 1) {
|
|
4532
|
+
const arg = rawArgs[i] || "";
|
|
4533
|
+
if (arg === "--runtime-dir" || arg === "--runtimeDir") {
|
|
4534
|
+
const next = rawArgs[i + 1];
|
|
4535
|
+
if (next) return String(next);
|
|
4536
|
+
}
|
|
4537
|
+
if (arg.startsWith("--runtime-dir=")) return arg.slice(14);
|
|
4538
|
+
if (arg.startsWith("--runtimeDir=")) return arg.slice(13);
|
|
4539
|
+
}
|
|
4540
|
+
}
|
|
4541
|
+
function findCommandToken(rawArgs) {
|
|
4542
|
+
for (let i = 0; i < rawArgs.length; i += 1) {
|
|
4543
|
+
const arg = rawArgs[i];
|
|
4544
|
+
if (!arg) continue;
|
|
4545
|
+
if (arg === "--") continue;
|
|
4546
|
+
if (arg === "--runtime-dir" || arg === "--runtimeDir") {
|
|
4547
|
+
i += 1;
|
|
4548
|
+
continue;
|
|
4549
|
+
}
|
|
4550
|
+
if (arg.startsWith("--runtime-dir=") || arg.startsWith("--runtimeDir=")) continue;
|
|
4551
|
+
if (arg.startsWith("-")) continue;
|
|
4552
|
+
return {
|
|
4553
|
+
index: i,
|
|
4554
|
+
command: arg
|
|
4555
|
+
};
|
|
4556
|
+
}
|
|
4557
|
+
return null;
|
|
4558
|
+
}
|
|
4559
|
+
async function mainEntry() {
|
|
4560
|
+
const [nodeBin, script, ...rest] = process.argv;
|
|
4561
|
+
const normalized = rest.filter((a) => a !== "--");
|
|
4562
|
+
if (normalized.includes("--version") || normalized.includes("-v")) {
|
|
4563
|
+
console.log(readCliVersion());
|
|
4564
|
+
process.exit(0);
|
|
4565
|
+
return;
|
|
4566
|
+
}
|
|
4567
|
+
process.argv = [
|
|
4568
|
+
nodeBin,
|
|
4569
|
+
script,
|
|
4570
|
+
...normalized
|
|
4571
|
+
];
|
|
4572
|
+
const runtimeDir = resolveRuntimeDir(normalized);
|
|
4573
|
+
const commandToken = findCommandToken(normalized);
|
|
4574
|
+
const command = commandToken?.command ?? "";
|
|
4575
|
+
const pluginMatch = findPluginByCommand({
|
|
4576
|
+
cwd: process.cwd(),
|
|
4577
|
+
runtimeDir,
|
|
4578
|
+
command
|
|
4579
|
+
});
|
|
4580
|
+
if (pluginMatch) {
|
|
4581
|
+
await runMain(await loadPluginCommand(pluginMatch), { rawArgs: commandToken ? normalized.slice(commandToken.index + 1) : [] });
|
|
4582
|
+
return;
|
|
4583
|
+
}
|
|
4584
|
+
await runMain(main);
|
|
4585
|
+
}
|
|
4586
|
+
mainEntry();
|
|
4587
|
+
|
|
4588
|
+
//#endregion
|
|
4589
|
+
export { };
|