@clawscarf/cli 0.1.0-alpha.1
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/LICENSE +21 -0
- package/README.md +3 -0
- package/THIRD_PARTY_NOTICES.md +102 -0
- package/deploy/execution/browser/LICENSE.playwright +202 -0
- package/deploy/execution/browser/seccomp.json +640 -0
- package/deploy/execution/browser-node/configuration.js +85 -0
- package/deploy/execution/browser-node/operator.js +167 -0
- package/deploy/execution/network/node-ingress.cfg +35 -0
- package/deploy/models/catalog.json +275 -0
- package/deploy/openshell/policy.yaml +24 -0
- package/generated/http/LICENSE.md +21 -0
- package/generated/http/client/client.gen.d.ts +2 -0
- package/generated/http/client/client.gen.js +216 -0
- package/generated/http/client/index.d.ts +10 -0
- package/generated/http/client/index.js +6 -0
- package/generated/http/client/types.gen.d.ts +120 -0
- package/generated/http/client/types.gen.js +2 -0
- package/generated/http/client/utils.gen.d.ts +37 -0
- package/generated/http/client/utils.gen.js +228 -0
- package/generated/http/core/auth.gen.d.ts +25 -0
- package/generated/http/core/auth.gen.js +14 -0
- package/generated/http/core/bodySerializer.gen.d.ts +25 -0
- package/generated/http/core/bodySerializer.gen.js +57 -0
- package/generated/http/core/params.gen.d.ts +43 -0
- package/generated/http/core/params.gen.js +109 -0
- package/generated/http/core/pathSerializer.gen.d.ts +33 -0
- package/generated/http/core/pathSerializer.gen.js +106 -0
- package/generated/http/core/queryKeySerializer.gen.d.ts +18 -0
- package/generated/http/core/queryKeySerializer.gen.js +92 -0
- package/generated/http/core/serverSentEvents.gen.d.ts +71 -0
- package/generated/http/core/serverSentEvents.gen.js +132 -0
- package/generated/http/core/types.gen.d.ts +83 -0
- package/generated/http/core/types.gen.js +2 -0
- package/generated/http/core/utils.gen.d.ts +19 -0
- package/generated/http/core/utils.gen.js +87 -0
- package/package.json +37 -0
- package/packs/README.md +130 -0
- package/packs/research-team/pack.json +20 -0
- package/packs/research-team/researcher/CLAW.md +19 -0
- package/packs/research-team/researcher/package.json +8 -0
- package/packs/research-team/researcher/profiles/openclaw.yml +6 -0
- package/packs/research-team/reviewer/CLAW.md +19 -0
- package/packs/research-team/reviewer/package.json +8 -0
- package/packs/research-team/reviewer/profiles/openclaw.yml +6 -0
- package/pnpm-lock.yaml +7158 -0
- package/recipes/README.md +37 -0
- package/recipes/team-server/recipe.json +28 -0
- package/release/README.md +137 -0
- package/release/components.json +29 -0
- package/release/operator.md +63 -0
- package/runtime/configuration.js +135 -0
- package/runtime/connections-configuration.js +70 -0
- package/runtime/model-contract.js +92 -0
- package/runtime/releases/0.1.0-alpha.1.json +37 -0
- package/scripts/clawscarf.js +92 -0
- package/scripts/cloud/login.js +134 -0
- package/scripts/cloud/registration.js +232 -0
- package/scripts/cloud/url.js +9 -0
- package/scripts/connections.js +193 -0
- package/scripts/controller.js +143 -0
- package/scripts/deployment/browser-node-compose.js +81 -0
- package/scripts/deployment/browser-node-helper.js +9 -0
- package/scripts/deployment/browser-node-pairing.js +165 -0
- package/scripts/deployment/browser-node.js +154 -0
- package/scripts/deployment/browser.js +143 -0
- package/scripts/deployment/certificates.js +76 -0
- package/scripts/deployment/compose.js +191 -0
- package/scripts/deployment/configuration.js +275 -0
- package/scripts/deployment/connection-policy.js +80 -0
- package/scripts/deployment/connection-settings.js +60 -0
- package/scripts/deployment/connections-runtime.js +31 -0
- package/scripts/deployment/connections.js +213 -0
- package/scripts/deployment/controller-compose.js +59 -0
- package/scripts/deployment/database.js +199 -0
- package/scripts/deployment/entrypoint.js +7 -0
- package/scripts/deployment/images.js +17 -0
- package/scripts/deployment/initial-services.js +53 -0
- package/scripts/deployment/launch.js +177 -0
- package/scripts/deployment/logs.js +15 -0
- package/scripts/deployment/model-gateway-compose.js +74 -0
- package/scripts/deployment/model-gateway.js +125 -0
- package/scripts/deployment/models.js +82 -0
- package/scripts/deployment/networks.js +354 -0
- package/scripts/deployment/policy.js +56 -0
- package/scripts/deployment/preflight.js +124 -0
- package/scripts/deployment/prepare.js +205 -0
- package/scripts/deployment/process.js +70 -0
- package/scripts/deployment/relay.js +58 -0
- package/scripts/deployment/runtime-binding.js +100 -0
- package/scripts/deployment/runtime.js +232 -0
- package/scripts/deployment/state.js +154 -0
- package/scripts/deployment/team.js +76 -0
- package/scripts/deployment/upgrade-rpc.py +148 -0
- package/scripts/deployment/upgrade-state.js +47 -0
- package/scripts/deployment/upgrade.js +298 -0
- package/scripts/deployment/volumes.js +24 -0
- package/scripts/errors.js +9 -0
- package/scripts/installation/administrator.js +23 -0
- package/scripts/installation/command.js +176 -0
- package/scripts/installation/configuration.js +104 -0
- package/scripts/installation/configure.js +122 -0
- package/scripts/installation/delete.js +111 -0
- package/scripts/installation/doctor.js +8 -0
- package/scripts/installation/errors.js +8 -0
- package/scripts/installation/files.js +51 -0
- package/scripts/installation/installer/cloud.js +22 -0
- package/scripts/installation/installer/collect.js +245 -0
- package/scripts/installation/installer/inputs.js +59 -0
- package/scripts/installation/installer/menu.js +49 -0
- package/scripts/installation/installer/prompts.js +139 -0
- package/scripts/installation/installer/run.js +251 -0
- package/scripts/installation/installer/secrets.js +16 -0
- package/scripts/installation/installer/sections/access.js +69 -0
- package/scripts/installation/installer/sections/certificates.js +18 -0
- package/scripts/installation/installer/sections/connections.js +4 -0
- package/scripts/installation/installer/sections/external-models.js +35 -0
- package/scripts/installation/installer/sections/models.js +119 -0
- package/scripts/installation/installer/sections/packs.js +82 -0
- package/scripts/installation/installer/sections/resources.js +10 -0
- package/scripts/installation/installer/settings.js +192 -0
- package/scripts/installation/installer/summary.js +44 -0
- package/scripts/installation/lifecycle.js +127 -0
- package/scripts/installation/location.js +3 -0
- package/scripts/installation/models.js +42 -0
- package/scripts/installation/options.js +360 -0
- package/scripts/installation/packs.js +150 -0
- package/scripts/installation/plan.js +121 -0
- package/scripts/installation/prerequisites.js +170 -0
- package/scripts/installation/recipes/catalog.js +55 -0
- package/scripts/installation/recipes/definition.js +32 -0
- package/scripts/installation/reconfigure.js +255 -0
- package/scripts/installation/requirements.js +23 -0
- package/scripts/installation/resolve.js +217 -0
- package/scripts/installation/runtime.js +87 -0
- package/scripts/installation/save.js +95 -0
- package/scripts/installation/setup.js +91 -0
- package/scripts/models/catalog.js +9 -0
- package/scripts/models/configuration.js +184 -0
- package/scripts/models/credentials.js +132 -0
- package/scripts/models/runtime.js +87 -0
- package/scripts/output.js +34 -0
- package/scripts/packs/connections.js +95 -0
- package/scripts/packs/lifecycle.js +125 -0
- package/scripts/packs/model.js +66 -0
- package/scripts/packs/native.js +69 -0
- package/scripts/packs/openshell.js +148 -0
- package/scripts/packs/policy.js +189 -0
- package/scripts/packs/requirements.in +1 -0
- package/scripts/packs/requirements.js +11 -0
- package/scripts/packs/requirements.txt +116 -0
- package/scripts/packs/source.js +90 -0
- package/scripts/packs/transport.py +68 -0
- package/scripts/people.js +52 -0
- package/scripts/release/create.js +62 -0
- package/scripts/release/definition.js +51 -0
- package/scripts/session.js +27 -0
- package/services/access/generated/client.gen.d.ts +12 -0
- package/services/access/generated/client.gen.js +3 -0
- package/services/access/generated/fastify.gen.d.ts +49 -0
- package/services/access/generated/fastify.gen.js +2 -0
- package/services/access/generated/index.d.ts +2 -0
- package/services/access/generated/index.js +2 -0
- package/services/access/generated/sdk.gen.d.ts +28 -0
- package/services/access/generated/sdk.gen.js +107 -0
- package/services/access/generated/types.gen.d.ts +349 -0
- package/services/access/generated/types.gen.js +2 -0
- package/services/access/migrations/001_access.sql +39 -0
- package/services/access/migrations/002_administrator_setup.sql +16 -0
- package/services/access/migrations/003_local_login_completion.sql +5 -0
- package/services/access/migrations/004_invitations.sql +21 -0
- package/services/access/repo/postgres.js +341 -0
- package/services/access/repo/session-logout.js +28 -0
- package/services/access/types/credential.js +2 -0
- package/services/access/types/errors.js +7 -0
- package/services/access/types/ingress.js +1 -0
- package/services/access/types/model.js +1 -0
- package/services/access/types/native-errors.js +7 -0
- package/services/access/types/native.js +1 -0
- package/services/cloud/generated/client.gen.d.ts +12 -0
- package/services/cloud/generated/client.gen.js +3 -0
- package/services/cloud/generated/index.d.ts +2 -0
- package/services/cloud/generated/index.js +2 -0
- package/services/cloud/generated/sdk.gen.d.ts +145 -0
- package/services/cloud/generated/sdk.gen.js +488 -0
- package/services/cloud/generated/types.gen.d.ts +2034 -0
- package/services/cloud/generated/types.gen.js +2 -0
- package/services/connections/cloud/generated/client.gen.d.ts +12 -0
- package/services/connections/cloud/generated/client.gen.js +3 -0
- package/services/connections/cloud/generated/fastify.gen.d.ts +68 -0
- package/services/connections/cloud/generated/fastify.gen.js +2 -0
- package/services/connections/cloud/generated/index.d.ts +2 -0
- package/services/connections/cloud/generated/index.js +2 -0
- package/services/connections/cloud/generated/sdk.gen.d.ts +95 -0
- package/services/connections/cloud/generated/sdk.gen.js +210 -0
- package/services/connections/cloud/generated/types.gen.d.ts +1044 -0
- package/services/connections/cloud/generated/types.gen.js +2 -0
- package/services/connections/migrations/001_connections.sql +220 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { defaultInstallationDirectory } from "./location.js";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { setupContext, assertReleaseCapabilities } from "./setup.js";
|
|
4
|
+
import { readJson, readInputFile, fingerprint } from "./files.js";
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
6
|
+
import { SetupInputs } from "./save.js";
|
|
7
|
+
import { installationSchema } from "./configuration.js";
|
|
8
|
+
import { InstallationError } from "./errors.js";
|
|
9
|
+
import { selectedDraft, selectionSchema, } from "./options.js";
|
|
10
|
+
import { packRequirements } from "./requirements.js";
|
|
11
|
+
import { newDirectory } from "./installer/inputs.js";
|
|
12
|
+
/** Resolve input references against the settings document; state stays relative to the output. */
|
|
13
|
+
export function resolveConfigurationInputs(value, base) {
|
|
14
|
+
const config = structuredClone(value);
|
|
15
|
+
const path = (input) => (input ? resolve(base, input) : input);
|
|
16
|
+
config.releaseFile = path(config.releaseFile);
|
|
17
|
+
if (config.access.mode === "hosted")
|
|
18
|
+
config.access.registrationFile = path(config.access.registrationFile);
|
|
19
|
+
if (config.access.mode === "oidc")
|
|
20
|
+
config.access.clientSecretFile = path(config.access.clientSecretFile);
|
|
21
|
+
if (config.exposure.mode === "https") {
|
|
22
|
+
config.exposure.certificateFile = path(config.exposure.certificateFile);
|
|
23
|
+
config.exposure.keyFile = path(config.exposure.keyFile);
|
|
24
|
+
}
|
|
25
|
+
if (config.models) {
|
|
26
|
+
config.models.configurationFile = path(config.models.configurationFile);
|
|
27
|
+
if (config.models.mode === "external") {
|
|
28
|
+
config.models.credentialFile = path(config.models.credentialFile);
|
|
29
|
+
if (config.models.caFile)
|
|
30
|
+
config.models.caFile = path(config.models.caFile);
|
|
31
|
+
}
|
|
32
|
+
else
|
|
33
|
+
config.models.upstreamEnvironmentFile = path(config.models.upstreamEnvironmentFile);
|
|
34
|
+
}
|
|
35
|
+
config.connections.registrationFile = path(config.connections.registrationFile);
|
|
36
|
+
for (const pack of config.packs) {
|
|
37
|
+
pack.directory = path(pack.directory);
|
|
38
|
+
if (pack.bindingsFile)
|
|
39
|
+
pack.bindingsFile = path(pack.bindingsFile);
|
|
40
|
+
}
|
|
41
|
+
if (config.packOperator)
|
|
42
|
+
config.packOperator.pythonExecutable = path(config.packOperator.pythonExecutable);
|
|
43
|
+
return config;
|
|
44
|
+
}
|
|
45
|
+
/** Collect the same choices as the menu without prompting or writing any files. */
|
|
46
|
+
export async function prepareConfiguration(options) {
|
|
47
|
+
if (options.reapply)
|
|
48
|
+
throw new InstallationError("invalid_configuration", "--reapply requires an existing installation.");
|
|
49
|
+
if (!options.recipe)
|
|
50
|
+
throw new InstallationError("invalid_configuration", "Unattended new configuration requires --recipe <name-or-file>.");
|
|
51
|
+
const directory = resolve(options.directory ?? defaultInstallationDirectory);
|
|
52
|
+
await newDirectory(directory);
|
|
53
|
+
const context = await setupContext(options);
|
|
54
|
+
const inputs = new SetupInputs(directory);
|
|
55
|
+
const draft = await selectedDraft(context, context.recipeId ?? options.recipe, options, inputs);
|
|
56
|
+
const config = await validateSelections(context, draft);
|
|
57
|
+
return { directory, config, inputs };
|
|
58
|
+
}
|
|
59
|
+
export async function validateSelections(context, draft) {
|
|
60
|
+
if (!draft.models)
|
|
61
|
+
throw new InstallationError("invalid_configuration", "Choose --model or a recipe with a default model.");
|
|
62
|
+
if (draft.models.mode === "litellm" && !draft.models.upstreamEnvironmentFile)
|
|
63
|
+
throw new InstallationError("invalid_configuration", "Supply --llm-key-file or --provider-env-file for the selected model provider.");
|
|
64
|
+
if (draft.models.mode === "external" && !draft.models.credentialFile)
|
|
65
|
+
throw new InstallationError("invalid_configuration", "Supply --model-gateway-key-file.");
|
|
66
|
+
if (draft.access.mode === "oidc" && !draft.access.clientSecretFile)
|
|
67
|
+
throw new InstallationError("invalid_configuration", "Supply --oidc-secret-file.");
|
|
68
|
+
if (draft.exposure.mode === "https" && !draft.exposure.keyFile)
|
|
69
|
+
throw new InstallationError("invalid_configuration", "Supply --tls-key-file.");
|
|
70
|
+
if (draft.packs.length && !draft.packOperator)
|
|
71
|
+
throw new InstallationError("invalid_configuration", "Selected experimental packs require --pack-python.");
|
|
72
|
+
const config = installationSchema.parse(draft);
|
|
73
|
+
assertReleaseCapabilities(context, config);
|
|
74
|
+
const issues = await packRequirements(config);
|
|
75
|
+
if (issues.length)
|
|
76
|
+
throw new InstallationError("invalid_configuration", issues.join(" "));
|
|
77
|
+
return config;
|
|
78
|
+
}
|
|
79
|
+
export async function savedSetup(options) {
|
|
80
|
+
const configFile = resolve(options.directory ?? defaultInstallationDirectory, "installation.json");
|
|
81
|
+
try {
|
|
82
|
+
const config = installationSchema.parse(await readJson(configFile));
|
|
83
|
+
return { configFile, config };
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
87
|
+
return undefined;
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export function rejectNewSelections(options) {
|
|
92
|
+
if (options.recipe ||
|
|
93
|
+
options.cloudUrl ||
|
|
94
|
+
Object.values(selectionSchema.parse(options)).some((value) => value !== undefined))
|
|
95
|
+
throw new InstallationError("change_unsupported", "Setup is unfinished. Resume with configure --directory before changing selections.");
|
|
96
|
+
}
|
|
97
|
+
/** Compare accepted contents, not the private copy paths created by the menu. */
|
|
98
|
+
export async function configurationChanges(before, after) {
|
|
99
|
+
const secret = async (path) => path ? fingerprint(await readInputFile(path, true)) : undefined;
|
|
100
|
+
const models = async (config) => ({
|
|
101
|
+
mode: config.models.mode,
|
|
102
|
+
catalog: await readJson(config.models.configurationFile),
|
|
103
|
+
key: await secret(config.models.mode === "litellm"
|
|
104
|
+
? config.models.upstreamEnvironmentFile
|
|
105
|
+
: config.models.credentialFile),
|
|
106
|
+
ca: config.models.mode === "external" && config.models.caFile
|
|
107
|
+
? fingerprint(await readInputFile(config.models.caFile))
|
|
108
|
+
: undefined,
|
|
109
|
+
});
|
|
110
|
+
const packs = async (config) => ({
|
|
111
|
+
operator: config.packOperator,
|
|
112
|
+
selections: await Promise.all(config.packs.map(async ({ bindingsFile, ...pack }) => ({
|
|
113
|
+
...pack,
|
|
114
|
+
bindings: bindingsFile ? await readJson(bindingsFile) : undefined,
|
|
115
|
+
}))),
|
|
116
|
+
});
|
|
117
|
+
return {
|
|
118
|
+
models: !isDeepStrictEqual(await models(before), await models(after)),
|
|
119
|
+
connections: !isDeepStrictEqual(before.connections, after.connections),
|
|
120
|
+
packs: !isDeepStrictEqual(await packs(before), await packs(after)),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { run } from "../deployment/process.js";
|
|
5
|
+
import { readInstallationIdentity, resourceNames, withInstallationLock, } from "../deployment/state.js";
|
|
6
|
+
import { InstallationError } from "./errors.js";
|
|
7
|
+
import { InstallerCancelled, SectionCancelled, terminalPrompts, } from "./installer/prompts.js";
|
|
8
|
+
export async function confirmDeletion(directory, options, prompts = terminalPrompts) {
|
|
9
|
+
if (options.confirmDelete !== undefined || options.acceptDataLoss) {
|
|
10
|
+
if (options.confirmDelete !== resolve(directory) || !options.acceptDataLoss)
|
|
11
|
+
throw new InstallationError("invalid_configuration", "Unattended deletion requires --confirm-delete with the exact absolute installation directory and --accept-data-loss.");
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (options.json ||
|
|
15
|
+
(prompts === terminalPrompts &&
|
|
16
|
+
(!process.stdin.isTTY || !process.stdout.isTTY)))
|
|
17
|
+
throw new InstallationError("invalid_configuration", "Deletion requires two confirmations in a terminal, or --confirm-delete <absolute-installation-directory> --accept-data-loss.");
|
|
18
|
+
try {
|
|
19
|
+
if (!(await prompts.confirm(`Stop and delete the installation at ${directory}?`, false)) ||
|
|
20
|
+
!(await prompts.confirm("Permanently delete ALL installation data, including chats, files, accounts and settings stored in Docker? This cannot be undone.", false)))
|
|
21
|
+
throw new InstallerCancelled();
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error instanceof SectionCancelled)
|
|
25
|
+
throw new InstallerCancelled();
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Delete exact owned resources, including failed/partially started installations. Never prune. */
|
|
30
|
+
export async function deleteInstallation(directory, signal) {
|
|
31
|
+
directory = resolve(directory);
|
|
32
|
+
return withInstallationLock(directory, async () => {
|
|
33
|
+
const state = await readInstallationIdentity(directory);
|
|
34
|
+
const names = resourceNames(state);
|
|
35
|
+
const list = async (kind, filter) => (await run("docker", [
|
|
36
|
+
kind,
|
|
37
|
+
"ls",
|
|
38
|
+
...(kind === "container" ? ["--all"] : []),
|
|
39
|
+
"--quiet",
|
|
40
|
+
"--filter",
|
|
41
|
+
`label=${filter}`,
|
|
42
|
+
]))
|
|
43
|
+
.trim()
|
|
44
|
+
.split("\n")
|
|
45
|
+
.filter(Boolean);
|
|
46
|
+
const label = `clawscarf.installation=${state.ownerId}`;
|
|
47
|
+
const managed = await list("container", label);
|
|
48
|
+
const containers = [
|
|
49
|
+
...new Set([
|
|
50
|
+
...managed,
|
|
51
|
+
...(await list("container", `com.docker.compose.project=${names.project}`)),
|
|
52
|
+
...(await list("container", `openshell.ai/sandbox-namespace=${names.sandbox}`)),
|
|
53
|
+
]),
|
|
54
|
+
];
|
|
55
|
+
// OpenShell carries its namespace rather than our installation label into Docker.
|
|
56
|
+
// Confirm its controller mount as well, before stopping or deleting anything.
|
|
57
|
+
if (containers.length) {
|
|
58
|
+
const inspected = z
|
|
59
|
+
.array(z.object({
|
|
60
|
+
Config: z.object({
|
|
61
|
+
Labels: z.record(z.string(), z.string()).nullable(),
|
|
62
|
+
}),
|
|
63
|
+
Mounts: z.array(z.object({
|
|
64
|
+
Type: z.string(),
|
|
65
|
+
Source: z.string(),
|
|
66
|
+
Destination: z.string(),
|
|
67
|
+
})),
|
|
68
|
+
}))
|
|
69
|
+
.parse(JSON.parse(await run("docker", [
|
|
70
|
+
"container",
|
|
71
|
+
"inspect",
|
|
72
|
+
"--format",
|
|
73
|
+
'{"Config":{"Labels":{{json .Config.Labels}}},"Mounts":{{json .Mounts}}}',
|
|
74
|
+
...containers,
|
|
75
|
+
]).then((output) => `[${output.trim().split("\n").join(",")}]`)));
|
|
76
|
+
for (const container of inspected) {
|
|
77
|
+
const labels = container.Config.Labels ?? {};
|
|
78
|
+
if (labels["clawscarf.installation"] === state.ownerId)
|
|
79
|
+
continue;
|
|
80
|
+
if (labels["openshell.ai/managed-by"] !== "openshell" ||
|
|
81
|
+
labels["openshell.ai/sandbox-namespace"] !== names.sandbox ||
|
|
82
|
+
!container.Mounts.some((mount) => mount.Type === "bind" &&
|
|
83
|
+
mount.Source === join(directory, "controller/tls/ca.crt") &&
|
|
84
|
+
mount.Destination === "/etc/openshell/tls/client/ca.crt"))
|
|
85
|
+
throw new InstallationError("invalid_configuration", "A matching Docker container does not belong to this installation. Nothing was deleted.");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const volumes = await list("volume", label);
|
|
89
|
+
const networks = await list("network", label);
|
|
90
|
+
signal?.throwIfAborted();
|
|
91
|
+
// Prevent restart after partial deletion; retain ownership records so deletion can resume.
|
|
92
|
+
await rm(join(directory, "prepared.json"), { force: true });
|
|
93
|
+
if (managed.length)
|
|
94
|
+
await run("docker", ["container", "stop", "--time", "20", ...managed], {
|
|
95
|
+
timeout: 120_000,
|
|
96
|
+
});
|
|
97
|
+
signal?.throwIfAborted();
|
|
98
|
+
if (containers.length) {
|
|
99
|
+
await run("docker", ["container", "stop", "--time", "20", ...containers], { timeout: 120_000 });
|
|
100
|
+
signal?.throwIfAborted();
|
|
101
|
+
await run("docker", ["container", "rm", ...containers]);
|
|
102
|
+
}
|
|
103
|
+
signal?.throwIfAborted();
|
|
104
|
+
if (volumes.length)
|
|
105
|
+
await run("docker", ["volume", "rm", ...volumes]);
|
|
106
|
+
signal?.throwIfAborted();
|
|
107
|
+
if (networks.length)
|
|
108
|
+
await run("docker", ["network", "rm", ...networks]);
|
|
109
|
+
return { state: "deleted", stateDirectory: directory };
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { checkHost, checkInstallationPrerequisites } from "./prerequisites.js";
|
|
2
|
+
import { allocatePorts, resolveInstallation } from "./resolve.js";
|
|
3
|
+
export async function doctorInstallation(configFile) {
|
|
4
|
+
await checkHost();
|
|
5
|
+
const result = await checkInstallationPrerequisites(configFile);
|
|
6
|
+
await resolveInstallation(configFile, await allocatePorts());
|
|
7
|
+
return result;
|
|
8
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { InstallationError } from "./errors.js";
|
|
5
|
+
export function fingerprint(value) {
|
|
6
|
+
return createHash("sha256").update(value).digest("hex");
|
|
7
|
+
}
|
|
8
|
+
export async function readInputFile(path, secret = false) {
|
|
9
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
10
|
+
try {
|
|
11
|
+
const info = await file.stat();
|
|
12
|
+
if (!info.isFile() ||
|
|
13
|
+
info.size > 8 * 1024 * 1024 ||
|
|
14
|
+
(secret && ((info.mode & 0o077) !== 0 || info.uid !== process.getuid?.())))
|
|
15
|
+
throw new InstallationError("invalid_configuration", "Configuration and release files must be bounded regular files; credentials must be private and owned by you.");
|
|
16
|
+
return await file.readFile();
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
await file.close();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export async function readJson(path) {
|
|
23
|
+
const input = await readInputFile(path);
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(input.toString("utf8"));
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (!(error instanceof SyntaxError))
|
|
29
|
+
throw error;
|
|
30
|
+
throw new InstallationError("invalid_configuration", `Invalid JSON in ${JSON.stringify(path)}.`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export async function verifyReleaseTool(path, checksum) {
|
|
34
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
35
|
+
try {
|
|
36
|
+
const stat = await file.stat();
|
|
37
|
+
if (!stat.isFile() || !(stat.mode & 0o111))
|
|
38
|
+
throw new InstallationError("release_mismatch", "A release tool is not an executable file.");
|
|
39
|
+
const hash = createHash("sha256");
|
|
40
|
+
for await (const chunk of file.createReadStream({ autoClose: false })) {
|
|
41
|
+
if (!Buffer.isBuffer(chunk))
|
|
42
|
+
throw Error("Invalid file stream");
|
|
43
|
+
hash.update(chunk);
|
|
44
|
+
}
|
|
45
|
+
if (hash.digest("hex") !== checksum)
|
|
46
|
+
throw new InstallationError("release_mismatch", "A release tool does not match its checksum.");
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
await file.close();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readInputFile } from "../files.js";
|
|
2
|
+
import { styleText } from "node:util";
|
|
3
|
+
import { registerCloudServices } from "../../cloud/registration.js";
|
|
4
|
+
import { authorizeCloud } from "../../cloud/login.js";
|
|
5
|
+
import { terminalLink, } from "./prompts.js";
|
|
6
|
+
export function registerWithBrowser(configFile, ui, task, register = registerCloudServices) {
|
|
7
|
+
return task("Connecting selected cloud services", (signal) => register(configFile, (url, file, administrator) => authorizeCloud(url, file, {
|
|
8
|
+
wait: true,
|
|
9
|
+
present: async (link, code, expiresAt) => {
|
|
10
|
+
ui.note(`${administrator ? "Sign in or create an account to set up this installation and become its first administrator." : "Sign in or create an account to authorize the selected cloud services."}\n\n${terminalLink(link)}\n\nApproval code: ${code} — check that it matches the website.\n\nExpires at ${expiresAt}. Return to this terminal after approval.`, styleText(["bold", "yellow"], "ACTION REQUIRED — Sign in to ClawScarf"));
|
|
11
|
+
await ui.openBrowser(link);
|
|
12
|
+
},
|
|
13
|
+
signal,
|
|
14
|
+
})));
|
|
15
|
+
}
|
|
16
|
+
export async function registerUnattended(configFile, credentialFile, register = registerCloudServices) {
|
|
17
|
+
await register(configFile, async (url, file) => {
|
|
18
|
+
if (!credentialFile)
|
|
19
|
+
return authorizeCloud(url, file, { wait: false });
|
|
20
|
+
return (await readInputFile(credentialFile, true)).toString("utf8").trim();
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { installationCatalog, readRecipe } from "../recipes/catalog.js";
|
|
2
|
+
import { defaultInstallationDirectory } from "../location.js";
|
|
3
|
+
import { isDeepStrictEqual } from "node:util";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { localInput } from "../../deployment/configuration.js";
|
|
6
|
+
import { installationSchema, } from "../configuration.js";
|
|
7
|
+
import { InstallationError } from "../errors.js";
|
|
8
|
+
import { installationMenu } from "./menu.js";
|
|
9
|
+
import { packRequirements } from "../requirements.js";
|
|
10
|
+
import { InstallerCancelled, SectionCancelled } from "./prompts.js";
|
|
11
|
+
import { SetupInputs } from "../save.js";
|
|
12
|
+
import { assertReleaseCapabilities, recipeModelFile, setupContext, } from "../setup.js";
|
|
13
|
+
import { absolute, field, newDirectory, inputErrorMessage } from "./inputs.js";
|
|
14
|
+
import { collectAccess, collectExposure } from "./sections/access.js";
|
|
15
|
+
import { collectConnections } from "./sections/connections.js";
|
|
16
|
+
import { collectModels, collectModelCredentials } from "./sections/models.js";
|
|
17
|
+
import { collectPacks, collectPackInputs } from "./sections/packs.js";
|
|
18
|
+
import { selectedDraft } from "../options.js";
|
|
19
|
+
import { installationSummary, modelSummary } from "./summary.js";
|
|
20
|
+
import { secretInput } from "./secrets.js";
|
|
21
|
+
import { collectExternalModels } from "./sections/external-models.js";
|
|
22
|
+
import { inputFile } from "./inputs.js";
|
|
23
|
+
import { collectResources } from "./sections/resources.js";
|
|
24
|
+
export async function collectInstallation(ui, options, retained) {
|
|
25
|
+
const catalog = await installationCatalog();
|
|
26
|
+
const customRecipe = options.recipe?.endsWith(".json")
|
|
27
|
+
? await readRecipe(resolve(options.recipe))
|
|
28
|
+
: undefined;
|
|
29
|
+
if (customRecipe)
|
|
30
|
+
catalog.recipes = [
|
|
31
|
+
customRecipe,
|
|
32
|
+
...catalog.recipes.filter((recipe) => recipe.id !== customRecipe.id),
|
|
33
|
+
];
|
|
34
|
+
let recipeId = retained
|
|
35
|
+
? (retained.config.recipe?.id ?? "custom")
|
|
36
|
+
: options.recipe;
|
|
37
|
+
let chooseRecipe = recipeId === undefined;
|
|
38
|
+
ui.note("Esc: back · Ctrl+C: exit", "Navigation");
|
|
39
|
+
startingPoint: for (;;) {
|
|
40
|
+
let directory;
|
|
41
|
+
try {
|
|
42
|
+
if (chooseRecipe) {
|
|
43
|
+
try {
|
|
44
|
+
recipeId = await ui.select("Starting point", [
|
|
45
|
+
...catalog.recipes.map((recipe) => ({
|
|
46
|
+
value: recipe.id === customRecipe?.id
|
|
47
|
+
? (options.recipe ?? recipe.id)
|
|
48
|
+
: recipe.id,
|
|
49
|
+
label: recipe.name,
|
|
50
|
+
hint: recipe.maturity === "example"
|
|
51
|
+
? "Example defaults; workflow not included"
|
|
52
|
+
: recipe.description,
|
|
53
|
+
})),
|
|
54
|
+
], recipeId);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error instanceof SectionCancelled)
|
|
58
|
+
throw new InstallerCancelled();
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
directory = absolute(retained?.directory ??
|
|
63
|
+
options.directory ??
|
|
64
|
+
defaultInstallationDirectory);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (!(error instanceof SectionCancelled))
|
|
68
|
+
throw error;
|
|
69
|
+
chooseRecipe = true;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (!options.existing)
|
|
73
|
+
await newDirectory(directory);
|
|
74
|
+
const context = await setupContext({ ...options, ...(recipeId ? { recipe: recipeId } : {}) }, options.existing ? retained?.config.releaseFile : undefined);
|
|
75
|
+
const recipe = context.recipes.find((recipe) => recipe.id === context.recipeId);
|
|
76
|
+
const selectedId = context.recipeId ?? recipeId;
|
|
77
|
+
if (selectedId === undefined)
|
|
78
|
+
throw new InstallationError("invalid_configuration", "Select a starting point.");
|
|
79
|
+
if (retained && (retained.config.recipe?.id ?? "custom") !== selectedId)
|
|
80
|
+
retained = undefined;
|
|
81
|
+
const inputs = retained?.inputs ?? new SetupInputs(directory);
|
|
82
|
+
let config = retained?.config ??
|
|
83
|
+
(await selectedDraft(context, selectedId, options, inputs));
|
|
84
|
+
if (!options.existing && config.access.mode === "hosted")
|
|
85
|
+
config.access.registrationFile = resolve(directory, "secrets/hosted-login.json");
|
|
86
|
+
const presetFile = recipe?.models
|
|
87
|
+
? recipeModelFile(recipe.models, inputs, context.modelCatalog)
|
|
88
|
+
: undefined;
|
|
89
|
+
if (!config.models && presetFile)
|
|
90
|
+
config.models = {
|
|
91
|
+
mode: "litellm",
|
|
92
|
+
configurationFile: presetFile,
|
|
93
|
+
upstreamEnvironmentFile: "",
|
|
94
|
+
};
|
|
95
|
+
const initialConfiguration = JSON.stringify(config);
|
|
96
|
+
if (recipe)
|
|
97
|
+
ui.note(recipe.description, recipe.name);
|
|
98
|
+
for (;;) {
|
|
99
|
+
const customized = JSON.stringify(config) !== initialConfiguration;
|
|
100
|
+
const pendingPacks = await packRequirements(config);
|
|
101
|
+
let choice;
|
|
102
|
+
try {
|
|
103
|
+
choice = await ui.select(options.existing
|
|
104
|
+
? `${config.name} — settings`
|
|
105
|
+
: `${recipe?.name ?? "Custom"}${customized ? " · customized" : ""} — configure installation`, installationMenu(config, directory, Boolean(context.release.images.browser), pendingPacks, await modelSummary(config, inputs), options.existing), "review");
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (!(error instanceof SectionCancelled))
|
|
109
|
+
throw error;
|
|
110
|
+
if (options.existing)
|
|
111
|
+
throw new InstallerCancelled();
|
|
112
|
+
retained = { directory, config, inputs };
|
|
113
|
+
chooseRecipe = true;
|
|
114
|
+
continue startingPoint;
|
|
115
|
+
}
|
|
116
|
+
const previousInputs = new Map(inputs.files);
|
|
117
|
+
const previousConfig = structuredClone(config);
|
|
118
|
+
const previousDirectory = directory;
|
|
119
|
+
try {
|
|
120
|
+
switch (choice) {
|
|
121
|
+
case "location": {
|
|
122
|
+
const next = absolute(await ui.text("New installation directory", directory));
|
|
123
|
+
await newDirectory(next);
|
|
124
|
+
directory = next;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
case "identity": {
|
|
128
|
+
const name = await field(ui, "Installation name", localInput.shape.name, config.name);
|
|
129
|
+
const administratorName = await field(ui, "Administrator display name", localInput.shape.administratorName, config.access.administratorName);
|
|
130
|
+
config = {
|
|
131
|
+
...config,
|
|
132
|
+
name,
|
|
133
|
+
access: { ...config.access, administratorName },
|
|
134
|
+
};
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
case "exposure":
|
|
138
|
+
config.exposure = await collectExposure(ui, config);
|
|
139
|
+
break;
|
|
140
|
+
case "access":
|
|
141
|
+
config = {
|
|
142
|
+
...config,
|
|
143
|
+
...(await collectAccess(ui, config, context.cloudUrl)),
|
|
144
|
+
};
|
|
145
|
+
break;
|
|
146
|
+
case "models":
|
|
147
|
+
config.models = await collectModels(ui, context.modelCatalog, config.models, inputs, presetFile, options.existing);
|
|
148
|
+
break;
|
|
149
|
+
case "model-credentials":
|
|
150
|
+
if (!config.models)
|
|
151
|
+
throw new InstallationError("invalid_configuration", "Select a model first.");
|
|
152
|
+
config.models = await collectModelCredentials(ui, config.models.mode === "litellm"
|
|
153
|
+
? { ...config.models, upstreamEnvironmentFile: "" }
|
|
154
|
+
: { ...config.models, credentialFile: "" }, inputs);
|
|
155
|
+
break;
|
|
156
|
+
case "connections":
|
|
157
|
+
config.connections = await collectConnections(ui, config.connections);
|
|
158
|
+
break;
|
|
159
|
+
case "advanced-models": {
|
|
160
|
+
const file = await inputFile(ui, "Model catalog file", false);
|
|
161
|
+
const mode = await ui.select("Model gateway", [
|
|
162
|
+
{ value: "bundled", label: "Run with this installation" },
|
|
163
|
+
{ value: "external", label: "Existing LiteLLM gateway" },
|
|
164
|
+
], "bundled");
|
|
165
|
+
config.models =
|
|
166
|
+
mode === "external"
|
|
167
|
+
? await collectExternalModels(ui, inputs, file, config.models)
|
|
168
|
+
: {
|
|
169
|
+
mode: "litellm",
|
|
170
|
+
configurationFile: file,
|
|
171
|
+
upstreamEnvironmentFile: "",
|
|
172
|
+
};
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
case "resources":
|
|
176
|
+
config.resources = await collectResources(ui, config.resources);
|
|
177
|
+
break;
|
|
178
|
+
case "browser":
|
|
179
|
+
ui.note("Explicit browser node use works. Ordinary model-selected browsing is not qualified because of an upstream routing issue.", "Experimental browser");
|
|
180
|
+
config.browser.enabled =
|
|
181
|
+
(await ui.select("Browser", [
|
|
182
|
+
{ value: "off", label: "Off" },
|
|
183
|
+
{ value: "on", label: "On (experimental)" },
|
|
184
|
+
], config.browser.enabled ? "on" : "off")) === "on";
|
|
185
|
+
break;
|
|
186
|
+
case "packs": {
|
|
187
|
+
const selection = await collectPacks(ui, config.models, config.connections, config, context.packs);
|
|
188
|
+
if (!options.existing)
|
|
189
|
+
delete config.packOperator;
|
|
190
|
+
config = { ...config, ...selection };
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
case "review": {
|
|
194
|
+
if (!config.models)
|
|
195
|
+
config.models = await collectModels(ui, context.modelCatalog, undefined, inputs, presetFile, options.existing);
|
|
196
|
+
ui.note(await installationSummary(config, inputs), "Selected settings");
|
|
197
|
+
config.models = await collectModelCredentials(ui, config.models, inputs);
|
|
198
|
+
if (config.exposure.mode === "https" && !config.exposure.keyFile)
|
|
199
|
+
config.exposure.keyFile = await inputFile(ui, "TLS private key file", true);
|
|
200
|
+
if (config.access.mode === "oidc" &&
|
|
201
|
+
!config.access.clientSecretFile)
|
|
202
|
+
config.access.clientSecretFile = await secretInput(ui, inputs, "OIDC client secret", "oidc-client-secret");
|
|
203
|
+
config = { ...config, ...(await collectPackInputs(ui, config)) };
|
|
204
|
+
if (config.access.mode === "hosted" && !options.existing) {
|
|
205
|
+
config.access.registrationFile = resolve(directory, "secrets/hosted-login.json");
|
|
206
|
+
}
|
|
207
|
+
if (!options.existing) {
|
|
208
|
+
config.connections.registrationFile = resolve(directory, "secrets/connections-registration.json");
|
|
209
|
+
}
|
|
210
|
+
const parsed = installationSchema.parse(config);
|
|
211
|
+
assertReleaseCapabilities(context, parsed);
|
|
212
|
+
const issues = await packRequirements(parsed);
|
|
213
|
+
if (issues.length) {
|
|
214
|
+
ui.note(issues.join("\n"), "Packs need attention");
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
return { directory, config: parsed, inputs };
|
|
218
|
+
}
|
|
219
|
+
default:
|
|
220
|
+
throw new InstallationError("invalid_configuration", "Select an installation section.");
|
|
221
|
+
}
|
|
222
|
+
if ((!isDeepStrictEqual(config, previousConfig) ||
|
|
223
|
+
directory !== previousDirectory ||
|
|
224
|
+
!isDeepStrictEqual(inputs.files, previousInputs)) &&
|
|
225
|
+
!(await ui.confirm("Save section changes?")))
|
|
226
|
+
throw new SectionCancelled();
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
if (error instanceof InstallerCancelled)
|
|
230
|
+
throw error;
|
|
231
|
+
config = previousConfig;
|
|
232
|
+
directory = previousDirectory;
|
|
233
|
+
inputs.files.clear();
|
|
234
|
+
for (const [path, bytes] of previousInputs)
|
|
235
|
+
inputs.files.set(path, bytes);
|
|
236
|
+
if (error instanceof SectionCancelled)
|
|
237
|
+
continue;
|
|
238
|
+
const message = inputErrorMessage(error);
|
|
239
|
+
if (!message)
|
|
240
|
+
throw error;
|
|
241
|
+
ui.note(message, "Section needs attention");
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { ZodError } from "zod";
|
|
5
|
+
import { readInputFile } from "../files.js";
|
|
6
|
+
import { InstallationError } from "../errors.js";
|
|
7
|
+
export function absolute(value) {
|
|
8
|
+
return resolve(value.startsWith("~/") ? join(homedir(), value.slice(2)) : value);
|
|
9
|
+
}
|
|
10
|
+
export async function newDirectory(path) {
|
|
11
|
+
try {
|
|
12
|
+
await lstat(path);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
16
|
+
return;
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
throw new InstallationError("change_unsupported", "This directory already exists and is not a recognized installation. Choose a new directory; configure never overwrites unrelated files.");
|
|
20
|
+
}
|
|
21
|
+
export async function field(ui, message, schema, initial) {
|
|
22
|
+
const value = await ui.text(message, initial, (input) => {
|
|
23
|
+
const result = schema.safeParse(input);
|
|
24
|
+
return result.success ? undefined : result.error.issues[0]?.message;
|
|
25
|
+
});
|
|
26
|
+
return schema.parse(value);
|
|
27
|
+
}
|
|
28
|
+
export async function inputFile(ui, message, secret = false, initial) {
|
|
29
|
+
const value = await ui.text(message, initial, async (input) => {
|
|
30
|
+
if (!input.trim())
|
|
31
|
+
return "Enter a file path.";
|
|
32
|
+
try {
|
|
33
|
+
await readInputFile(absolute(input), secret);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (!inputErrorMessage(error))
|
|
37
|
+
throw error;
|
|
38
|
+
return secret
|
|
39
|
+
? "Use a private regular file owned by you (chmod 600)."
|
|
40
|
+
: "Use an existing regular file (up to 8 MiB).";
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
const path = absolute(value);
|
|
44
|
+
await readInputFile(path, secret);
|
|
45
|
+
return path;
|
|
46
|
+
}
|
|
47
|
+
/** Recover only invalid input and file access failures; programming errors must escape. */
|
|
48
|
+
export function inputErrorMessage(error) {
|
|
49
|
+
if (error instanceof InstallationError)
|
|
50
|
+
return error.message;
|
|
51
|
+
if (error instanceof ZodError)
|
|
52
|
+
return "Check the selected configuration fields and their supported values.";
|
|
53
|
+
if (error instanceof SyntaxError ||
|
|
54
|
+
(error instanceof Error &&
|
|
55
|
+
"code" in error &&
|
|
56
|
+
["ENOENT", "EACCES", "EPERM", "ENOTDIR", "EISDIR", "ELOOP"].includes(String(error.code))))
|
|
57
|
+
return "Could not read the selected inputs. Check their paths, contents and permissions.";
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|