hive-am 1.0.0 → 1.2.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/CHANGELOG.md +114 -1
- package/README.md +34 -4
- package/package.json +13 -2
- package/server/dist/api.js +42 -677
- package/server/dist/connections/addressing.js +87 -0
- package/server/dist/connections/channel-skill.js +62 -0
- package/server/dist/connections/fake.js +1 -0
- package/server/dist/connections/files.js +45 -1
- package/server/dist/connections/format.js +9 -1
- package/server/dist/connections/manager.js +60 -3
- package/server/dist/connections/outbound.js +2 -1
- package/server/dist/connections/prompt.js +12 -3
- package/server/dist/connections/rate.js +20 -0
- package/server/dist/connections/router.js +55 -22
- package/server/dist/connections/secrets.js +52 -0
- package/server/dist/connections/store.js +6 -43
- package/server/dist/connections/telegram.js +105 -14
- package/server/dist/cron.js +138 -0
- package/server/dist/db/json.js +16 -0
- package/server/dist/db/migrations.js +199 -0
- package/server/dist/db.js +98 -196
- package/server/dist/errors.js +11 -0
- package/server/dist/git/browse.js +99 -0
- package/server/dist/git/ops.js +136 -19
- package/server/dist/git/repo.js +2 -1
- package/server/dist/history/claude.js +33 -20
- package/server/dist/history/index.js +17 -9
- package/server/dist/history/kiro.js +17 -12
- package/server/dist/history/lines.js +12 -0
- package/server/dist/history/opencode.js +18 -1
- package/server/dist/http/body.js +46 -0
- package/server/dist/http/errors.js +21 -0
- package/server/dist/http/guards.js +44 -0
- package/server/dist/http/router.js +24 -0
- package/server/dist/http/routes/agents.js +103 -0
- package/server/dist/http/routes/channel.js +16 -0
- package/server/dist/http/routes/colonies.js +9 -0
- package/server/dist/http/routes/connections.js +36 -0
- package/server/dist/http/routes/fs.js +22 -0
- package/server/dist/http/routes/git.js +90 -0
- package/server/dist/http/routes/meta.js +11 -0
- package/server/dist/http/routes/notebook.js +34 -0
- package/server/dist/http/routes/orchestration.js +32 -0
- package/server/dist/http/routes/resources.js +68 -0
- package/server/dist/http/routes/schedules.js +40 -0
- package/server/dist/http/routes/skills.js +47 -0
- package/server/dist/http/routes/terminals.js +18 -0
- package/server/dist/http/routes/types.js +36 -0
- package/server/dist/http/validate.js +65 -0
- package/server/dist/index.js +74 -4
- package/server/dist/instructions.js +23 -11
- package/server/dist/objects/agent.js +49 -0
- package/server/dist/objects/database.js +451 -0
- package/server/dist/objects/db-history.js +79 -0
- package/server/dist/objects/docker-info.js +75 -0
- package/server/dist/objects/docker.js +285 -0
- package/server/dist/objects/driver-props.js +196 -0
- package/server/dist/objects/exec.js +16 -0
- package/server/dist/objects/http-history.js +44 -0
- package/server/dist/objects/http-script.js +644 -0
- package/server/dist/objects/http.js +735 -0
- package/server/dist/objects/index.js +445 -0
- package/server/dist/objects/model.js +250 -0
- package/server/dist/objects/runner.js +211 -0
- package/server/dist/objects/stats.js +106 -0
- package/server/dist/objects/store.js +115 -0
- package/server/dist/objects/supervisor.js +111 -0
- package/server/dist/objects/templates.js +67 -0
- package/server/dist/providers/claude.js +5 -13
- package/server/dist/providers/index.js +7 -1
- package/server/dist/providers/kiro.js +4 -5
- package/server/dist/providers/opencode-server.js +164 -0
- package/server/dist/providers/opencode.js +60 -37
- package/server/dist/providers/preamble.js +2 -0
- package/server/dist/providers/spawn.js +31 -3
- package/server/dist/runtime.js +10 -7
- package/server/dist/schedules.js +222 -0
- package/server/dist/seed.js +15 -11
- package/server/dist/services/agents.js +90 -0
- package/server/dist/services/colonies.js +38 -0
- package/server/dist/services/connections.js +76 -0
- package/server/dist/services/notebook.js +22 -0
- package/server/dist/services/providers.js +27 -0
- package/server/dist/services/schedules.js +18 -0
- package/server/dist/sessions-summary.js +51 -0
- package/server/dist/skills/defaults.js +120 -0
- package/server/dist/skills/notebook.js +3 -8
- package/server/dist/terminals.js +177 -0
- package/server/dist/wake.js +149 -0
- package/server/dist/watch.js +201 -0
- package/server/mcp/dispatch.mjs +169 -1
- package/web/.next/BUILD_ID +1 -1
- package/web/.next/app-build-manifest.json +145 -85
- package/web/.next/app-path-routes-manifest.json +5 -2
- package/web/.next/build-manifest.json +14 -14
- package/web/.next/prerender-manifest.json +85 -37
- package/web/.next/react-loadable-manifest.json +26 -1
- package/web/.next/required-server-files.json +13 -1
- package/web/.next/routes-manifest.json +32 -0
- package/web/.next/server/app/_not-found/page.js +2 -2
- package/web/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/_not-found.html +1 -1
- package/web/.next/server/app/_not-found.rsc +12 -11
- package/web/.next/server/app/agents/[id]/page.js +4 -4
- package/web/.next/server/app/agents/[id]/page.js.nft.json +1 -1
- package/web/.next/server/app/agents/[id]/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/agents/page.js +2 -2
- package/web/.next/server/app/agents/page.js.nft.json +1 -1
- package/web/.next/server/app/agents/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/agents.html +1 -1
- package/web/.next/server/app/agents.rsc +14 -13
- package/web/.next/server/app/connections/page.js +2 -2
- package/web/.next/server/app/connections/page.js.nft.json +1 -1
- package/web/.next/server/app/connections/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/connections.html +1 -1
- package/web/.next/server/app/connections.rsc +14 -13
- package/web/.next/server/app/index.html +1 -1
- package/web/.next/server/app/index.rsc +14 -13
- package/web/.next/server/app/page.js +2 -2
- package/web/.next/server/app/page.js.nft.json +1 -1
- package/web/.next/server/app/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/relations/page.js +2 -2
- package/web/.next/server/app/relations/page.js.nft.json +1 -1
- package/web/.next/server/app/relations/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/relations.html +1 -1
- package/web/.next/server/app/relations.rsc +14 -13
- package/web/.next/server/app/resources/[id]/page.js +2 -0
- package/web/.next/server/app/resources/[id]/page.js.nft.json +1 -0
- package/web/.next/server/app/resources/[id]/page_client-reference-manifest.js +1 -0
- package/web/.next/server/app/resources/page.js +2 -0
- package/web/.next/server/app/resources/page.js.nft.json +1 -0
- package/web/.next/server/app/resources/page_client-reference-manifest.js +1 -0
- package/web/.next/server/app/resources.html +1 -0
- package/web/.next/server/app/resources.meta +15 -0
- package/web/.next/server/app/resources.rsc +25 -0
- package/web/.next/server/app/schedules/page.js +2 -0
- package/web/.next/server/app/schedules/page.js.nft.json +1 -0
- package/web/.next/server/app/schedules/page_client-reference-manifest.js +1 -0
- package/web/.next/server/app/schedules.html +1 -0
- package/web/.next/server/app/schedules.meta +15 -0
- package/web/.next/server/app/schedules.rsc +25 -0
- package/web/.next/server/app/sessions/page.js +2 -2
- package/web/.next/server/app/sessions/page.js.nft.json +1 -1
- package/web/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/sessions.html +1 -1
- package/web/.next/server/app/sessions.rsc +14 -13
- package/web/.next/server/app/skills/page.js +2 -2
- package/web/.next/server/app/skills/page.js.nft.json +1 -1
- package/web/.next/server/app/skills/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/skills.html +1 -1
- package/web/.next/server/app/skills.rsc +14 -13
- package/web/.next/server/app/types/page.js +2 -2
- package/web/.next/server/app/types/page.js.nft.json +1 -1
- package/web/.next/server/app/types/page_client-reference-manifest.js +1 -1
- package/web/.next/server/app/types.html +1 -1
- package/web/.next/server/app/types.rsc +14 -13
- package/web/.next/server/app-paths-manifest.json +5 -2
- package/web/.next/server/chunks/143.js +1 -1
- package/web/.next/server/chunks/274.js +1 -0
- package/web/.next/server/chunks/306.js +1 -0
- package/web/.next/server/chunks/423.js +1 -0
- package/web/.next/server/chunks/505.js +6 -0
- package/web/.next/server/chunks/555.js +1 -0
- package/web/.next/server/chunks/612.js +1 -0
- package/web/.next/server/chunks/638.js +1 -0
- package/web/.next/server/chunks/692.js +18 -0
- package/web/.next/server/chunks/716.js +2 -0
- package/web/.next/server/chunks/790.js +11 -0
- package/web/.next/server/chunks/802.js +1 -0
- package/web/.next/server/chunks/890.js +1 -1
- package/web/.next/server/chunks/919.js +1 -0
- package/web/.next/server/chunks/950.js +6 -0
- package/web/.next/server/middleware-build-manifest.js +1 -1
- package/web/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/web/.next/server/next-font-manifest.js +1 -1
- package/web/.next/server/next-font-manifest.json +1 -1
- package/web/.next/server/pages/404.html +1 -1
- package/web/.next/server/pages/500.html +1 -1
- package/web/.next/server/server-reference-manifest.json +1 -1
- package/web/.next/static/chunks/0937d497-114a2a3de515565e.js +1 -0
- package/web/.next/static/chunks/176-aeaf907a6ea5627d.js +1 -0
- package/web/.next/static/chunks/291-efae53ea5f7ee92d.js +1 -0
- package/web/.next/static/chunks/2b9ff8ee-969e65cc354b2150.js +1 -0
- package/web/.next/static/chunks/30-9adcbe92b15a6815.js +1 -0
- package/web/.next/static/chunks/316-26da9cc233ba7557.js +1 -0
- package/web/.next/static/chunks/350-56599dc3bf06066d.js +1 -0
- package/web/.next/static/chunks/486-13e08542f3a1cbac.js +1 -0
- package/web/.next/static/chunks/491-6b4e6201f5110dff.js +1 -0
- package/web/.next/static/chunks/517-ccaf8cc5c733cbd8.js +1 -0
- package/web/.next/static/chunks/545.bfbd92b7632c09de.js +1 -0
- package/web/.next/static/chunks/{805-d946ebedb7e85f73.js → 562-7c2e33d838e869bf.js} +1 -1
- package/web/.next/static/chunks/581-3ad5c4343dad3f50.js +1 -0
- package/web/.next/static/chunks/586-f7017d585a84738d.js +1 -0
- package/web/.next/static/chunks/657.b4c39e676c3e20dc.js +1 -0
- package/web/.next/static/chunks/701.6abcaf5e60463eef.js +6 -0
- package/web/.next/static/chunks/705-a16e1fe09be1ac7f.js +1 -0
- package/web/.next/static/chunks/706-fa2be0f10ffebe6a.js +1 -0
- package/web/.next/static/chunks/837-33c7480524f48577.js +1 -0
- package/web/.next/static/chunks/839-23c5508622fae942.js +1 -0
- package/web/.next/static/chunks/844-fe55292e0701f436.js +1 -0
- package/web/.next/static/chunks/89-b194fcffe5120c44.js +1 -0
- package/web/.next/static/chunks/97.594543c2196de524.js +1 -0
- package/web/.next/static/chunks/app/_not-found/page-238a7b80bda6b4ae.js +1 -0
- package/web/.next/static/chunks/app/agents/[id]/page-4af6c246c4ca7a97.js +1 -0
- package/web/.next/static/chunks/app/agents/page-f4c4c80f955f3967.js +1 -0
- package/web/.next/static/chunks/app/connections/page-0936beaf02d863c6.js +1 -0
- package/web/.next/static/chunks/app/layout-87ebb70bd9cb8443.js +1 -0
- package/web/.next/static/chunks/app/page-a7f292117b7d76bf.js +1 -0
- package/web/.next/static/chunks/app/relations/page-fba25393c8688714.js +1 -0
- package/web/.next/static/chunks/app/resources/[id]/page-d110d89bb8b5fca7.js +1 -0
- package/web/.next/static/chunks/app/resources/page-bbd0b450d3f3131d.js +1 -0
- package/web/.next/static/chunks/app/schedules/page-a1124397fc760413.js +1 -0
- package/web/.next/static/chunks/app/sessions/page-0e322811b0cde724.js +1 -0
- package/web/.next/static/chunks/app/skills/page-22ea0ef89d9f7a13.js +1 -0
- package/web/.next/static/chunks/app/types/page-18c4e1db2724d41d.js +1 -0
- package/web/.next/static/chunks/fcafe1ac.366e4950d3b0d4b7.js +18 -0
- package/web/.next/static/chunks/framework-bbac72adbbe2d328.js +1 -0
- package/web/.next/static/chunks/main-app-6086068e5fb5eacd.js +1 -0
- package/web/.next/static/chunks/main-c15f3fee674e0303.js +1 -0
- package/web/.next/static/chunks/pages/_app-d1670a2c6ce392fc.js +1 -0
- package/web/.next/static/chunks/pages/_error-95aac014d202fa7a.js +1 -0
- package/web/.next/static/chunks/webpack-ef515e41a83a16ac.js +1 -0
- package/web/.next/static/css/945795da867f5250.css +1 -0
- package/web/.next/static/css/dd4e32c285f75776.css +1 -0
- package/web/.next/static/p4S3i9kvO359GW91DQiHu/_buildManifest.js +1 -0
- package/web/.next/trace +29 -23
- package/web/.next/types/app/resources/[id]/page.ts +84 -0
- package/web/.next/types/app/resources/page.ts +84 -0
- package/web/.next/types/app/schedules/page.ts +84 -0
- package/web/.next/types/routes.d.ts +7 -2
- package/web/.next/types/validator.ts +27 -0
- package/web/next.config.mjs +4 -0
- package/web/.next/server/chunks/149.js +0 -11
- package/web/.next/server/chunks/247.js +0 -1
- package/web/.next/server/chunks/851.js +0 -1
- package/web/.next/server/chunks/932.js +0 -2
- package/web/.next/static/84BpRSahg2BTwk3mX6AG8/_buildManifest.js +0 -1
- package/web/.next/static/chunks/0937d497-44abb0f9355ace92.js +0 -1
- package/web/.next/static/chunks/2b9ff8ee-c3b61ac1e608c57d.js +0 -1
- package/web/.next/static/chunks/351-65da8c1a6e25f93b.js +0 -1
- package/web/.next/static/chunks/463-6ae35cdefdc59169.js +0 -1
- package/web/.next/static/chunks/493-92266798be872b8f.js +0 -1
- package/web/.next/static/chunks/517-eb26c72f6059aa26.js +0 -1
- package/web/.next/static/chunks/657.f1e03b7b65eba1d9.js +0 -1
- package/web/.next/static/chunks/687-3a4205f51be38602.js +0 -1
- package/web/.next/static/chunks/837-fd7623601dc78ed8.js +0 -1
- package/web/.next/static/chunks/850-318f32484989dd59.js +0 -1
- package/web/.next/static/chunks/89-ace2e1aa63babfdf.js +0 -1
- package/web/.next/static/chunks/951-e250b541b67da7ce.js +0 -1
- package/web/.next/static/chunks/app/_not-found/page-bbd54d7b9a55d7ae.js +0 -1
- package/web/.next/static/chunks/app/agents/[id]/page-5eaf094fec246f1b.js +0 -1
- package/web/.next/static/chunks/app/agents/page-e9b17f1c3439ef98.js +0 -1
- package/web/.next/static/chunks/app/connections/page-c6afd90abd82d47a.js +0 -1
- package/web/.next/static/chunks/app/layout-c415d5a847cd94b3.js +0 -1
- package/web/.next/static/chunks/app/page-6fc87fc5a128a204.js +0 -1
- package/web/.next/static/chunks/app/relations/page-0355a93a8eb67989.js +0 -1
- package/web/.next/static/chunks/app/sessions/page-beae9a74a07d69e0.js +0 -1
- package/web/.next/static/chunks/app/skills/page-4f839ac913e26d32.js +0 -1
- package/web/.next/static/chunks/app/types/page-4f6b99af3232f861.js +0 -1
- package/web/.next/static/chunks/framework-0e68884e76d9a8d3.js +0 -1
- package/web/.next/static/chunks/main-app-d6f73ccd41f325c5.js +0 -1
- package/web/.next/static/chunks/main-cd22509db15764e2.js +0 -1
- package/web/.next/static/chunks/pages/_app-0f75d689c738a6fc.js +0 -1
- package/web/.next/static/chunks/pages/_error-d481f6d49fb4455d.js +0 -1
- package/web/.next/static/chunks/webpack-d40de72e52f65f79.js +0 -1
- package/web/.next/static/css/abe1a6216491f944.css +0 -1
- /package/web/.next/static/{84BpRSahg2BTwk3mX6AG8 → p4S3i9kvO359GW91DQiHu}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { execCapture } from './exec.js';
|
|
5
|
+
import { ObjectError, splitArgs } from './model.js';
|
|
6
|
+
/**
|
|
7
|
+
* A Docker object drives the `docker` command (never a shell, every value is one argument): start, stop, restart, status and logs of a
|
|
8
|
+
* container hive-am created, of a compose project, or of a container that already existed. Anything hive-am did not create is only
|
|
9
|
+
* ever started, stopped or read, never removed.
|
|
10
|
+
*/
|
|
11
|
+
const TIMEOUT = 60_000;
|
|
12
|
+
const DOCKER_ENV = { ...process.env, DOCKER_CLI_HINTS: 'false' };
|
|
13
|
+
async function dk(args, o = {}) {
|
|
14
|
+
const r = await execCapture('docker', args, { cwd: o.cwd, timeout: o.timeout ?? TIMEOUT, maxBuffer: 16 * 1024 * 1024, env: DOCKER_ENV });
|
|
15
|
+
if (r.missing)
|
|
16
|
+
throw new ObjectError('Docker is not installed or not in the PATH');
|
|
17
|
+
if (r.timedOut)
|
|
18
|
+
throw new ObjectError(`docker ${args[0]} took too long`);
|
|
19
|
+
return r;
|
|
20
|
+
}
|
|
21
|
+
const fail = (r, what) => new ObjectError(`${what}: ${(r.err || r.out).trim().split('\n').slice(-3).join(' ').slice(0, 400) || `docker exited with ${r.code}`}`);
|
|
22
|
+
/** What makes a container different from another one when hive-am creates it: if it changes, the container is created again on the next start. */
|
|
23
|
+
const CFG_FIELDS = ['image', 'ports', 'volumes', 'env', 'restart', 'command', 'memory', 'cpus', 'network', 'logMaxMb'];
|
|
24
|
+
export const cfgHash = (c) => createHash('sha1').update(JSON.stringify(CFG_FIELDS.map((k) => c[k] ?? null))).digest('hex').slice(0, 12);
|
|
25
|
+
const stopArgs = (c) => (c.stopTimeoutSec ? ['-t', String(c.stopTimeoutSec)] : []);
|
|
26
|
+
/** The hash a container was created with (empty when it does not exist). */
|
|
27
|
+
async function createdWith(name) {
|
|
28
|
+
const r = await dk(['inspect', '-f', '{{index .Config.Labels "hive-am.cfg"}}', name]);
|
|
29
|
+
return r.code === 0 ? r.out.trim() : null;
|
|
30
|
+
}
|
|
31
|
+
/** The name hive-am gives to a container it creates. */
|
|
32
|
+
export const containerName = (id) => `hive-am-${id.slice(0, 8)}`;
|
|
33
|
+
const nameOf = (id, c) => (c.mode === 'existing' ? c.container : containerName(id));
|
|
34
|
+
/** `docker compose -f file -p project` + what to do. The folder of the file is the working folder, as compose expects. */
|
|
35
|
+
const composeArgs = (c, rest) => ['compose', '-f', c.file, ...(c.project ? ['-p', c.project] : []), ...rest];
|
|
36
|
+
function checkCompose(c) { if (!existsSync(c.file))
|
|
37
|
+
throw new ObjectError(`The compose file ${c.file} does not exist`); }
|
|
38
|
+
export async function dockerState(id, c) {
|
|
39
|
+
try {
|
|
40
|
+
if (c.mode === 'compose') {
|
|
41
|
+
if (!existsSync(c.file))
|
|
42
|
+
return { status: 'error', detail: 'the compose file is missing' };
|
|
43
|
+
const r = await dk(composeArgs(c, ['ps', '--all', '--format', 'json', ...(c.services ?? [])]), { cwd: dirname(c.file) });
|
|
44
|
+
if (r.code !== 0)
|
|
45
|
+
return { status: 'unknown', detail: r.err.trim().split('\n').pop()?.slice(0, 160) };
|
|
46
|
+
const items = parseJsonLines(r.out);
|
|
47
|
+
if (!items.length)
|
|
48
|
+
return { status: 'stopped' };
|
|
49
|
+
const states = items.map((x) => String(x.State ?? '').toLowerCase());
|
|
50
|
+
const running = states.filter((s) => s === 'running').length;
|
|
51
|
+
if (running === items.length)
|
|
52
|
+
return items.some((x) => /starting|unhealthy/i.test(String(x.Health ?? x.Status ?? ''))) ? { status: 'starting', detail: 'health check pending' } : { status: 'running', detail: `${running} service${running === 1 ? '' : 's'}` };
|
|
53
|
+
if (running > 0)
|
|
54
|
+
return { status: 'starting', detail: `${running} of ${items.length} running` };
|
|
55
|
+
return states.some((s) => s === 'exited') && items.some((x) => Number(x.ExitCode) > 0) ? { status: 'error', detail: 'a service exited with an error' } : { status: 'stopped' };
|
|
56
|
+
}
|
|
57
|
+
const r = await dk(['inspect', '-f', '{{.State.Status}}|{{if .State.Health}}{{.State.Health.Status}}{{end}}|{{.State.ExitCode}}|{{.State.StartedAt}}', nameOf(id, c)]);
|
|
58
|
+
if (r.code !== 0)
|
|
59
|
+
return c.mode === 'existing' ? { status: 'unknown', detail: 'container not found' } : { status: 'stopped' };
|
|
60
|
+
const [state, health, exit, started] = r.out.trim().split('|');
|
|
61
|
+
const since = Date.parse(started) || undefined;
|
|
62
|
+
if (state === 'running')
|
|
63
|
+
return health === 'unhealthy' ? { status: 'error', detail: 'unhealthy', since } : health === 'starting' ? { status: 'starting', detail: 'health check pending', since } : { status: 'running', since };
|
|
64
|
+
if (state === 'restarting')
|
|
65
|
+
return { status: 'starting', detail: 'restarting', since };
|
|
66
|
+
if (state === 'exited' && Number(exit) !== 0)
|
|
67
|
+
return { status: 'error', detail: `exited with code ${exit}` };
|
|
68
|
+
return { status: 'stopped' };
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
return { status: 'unknown', detail: e instanceof Error ? e.message : 'docker failed' };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** `docker compose ps --format json` prints one JSON per line (or, in older versions, one array). */
|
|
75
|
+
function parseJsonLines(out) {
|
|
76
|
+
const t = out.trim();
|
|
77
|
+
if (!t)
|
|
78
|
+
return [];
|
|
79
|
+
if (t.startsWith('[')) {
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(t);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return t.split('\n').flatMap((l) => { try {
|
|
88
|
+
return [JSON.parse(l)];
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return [];
|
|
92
|
+
} });
|
|
93
|
+
}
|
|
94
|
+
export async function startDocker(id, c) {
|
|
95
|
+
if (c.mode === 'compose') {
|
|
96
|
+
checkCompose(c);
|
|
97
|
+
const r = await dk(composeArgs(c, ['up', '-d', ...(c.services ?? [])]), { cwd: dirname(c.file), timeout: 300_000 });
|
|
98
|
+
if (r.code !== 0)
|
|
99
|
+
throw fail(r, 'compose up failed');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const name = nameOf(id, c);
|
|
103
|
+
let exists = (await dk(['inspect', '-f', '{{.Id}}', name])).code === 0;
|
|
104
|
+
// A container hive-am created with other settings than the ones now configured is created again (what is inside it is lost, as with any recreate).
|
|
105
|
+
if (exists && c.mode === 'container') {
|
|
106
|
+
const w = await createdWith(name);
|
|
107
|
+
if (w && w !== cfgHash(c)) {
|
|
108
|
+
await dk(['rm', '-f', name]);
|
|
109
|
+
exists = false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (exists) {
|
|
113
|
+
const r = await dk(['start', name]);
|
|
114
|
+
if (r.code !== 0)
|
|
115
|
+
throw fail(r, 'could not start it');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (c.mode === 'existing')
|
|
119
|
+
throw new ObjectError(`There is no container named ${name}`);
|
|
120
|
+
const args = ['run', '-d', '--name', name, '--label', `hive-am.object=${id}`, '--label', `hive-am.cfg=${cfgHash(c)}`];
|
|
121
|
+
if (c.memory)
|
|
122
|
+
args.push('--memory', c.memory);
|
|
123
|
+
if (c.cpus)
|
|
124
|
+
args.push('--cpus', String(c.cpus));
|
|
125
|
+
if (c.network)
|
|
126
|
+
args.push('--network', c.network);
|
|
127
|
+
// A container always gets a log limit (10 MB x 3 unless set): docker's default is no limit, and a chatty container fills the disk.
|
|
128
|
+
args.push('--log-opt', `max-size=${Math.max(1, Math.round((c.logMaxMb ?? 10) * 1024))}k`, '--log-opt', 'max-file=3');
|
|
129
|
+
if (c.stopTimeoutSec)
|
|
130
|
+
args.push('--stop-timeout', String(c.stopTimeoutSec));
|
|
131
|
+
if (c.restart && c.restart !== 'no')
|
|
132
|
+
args.push('--restart', c.restart);
|
|
133
|
+
for (const p of c.ports ?? [])
|
|
134
|
+
args.push('-p', p);
|
|
135
|
+
for (const v of c.volumes ?? [])
|
|
136
|
+
args.push('-v', v);
|
|
137
|
+
for (const [k, v] of Object.entries(c.env ?? {}))
|
|
138
|
+
args.push('-e', `${k}=${v}`);
|
|
139
|
+
args.push(c.image, ...(c.command ? splitArgs(c.command) : []));
|
|
140
|
+
const r = await dk(args, { timeout: 300_000 }); // a first run may pull the image
|
|
141
|
+
if (r.code !== 0)
|
|
142
|
+
throw fail(r, 'could not create it');
|
|
143
|
+
}
|
|
144
|
+
export async function stopDocker(id, c) {
|
|
145
|
+
if (c.mode === 'compose') {
|
|
146
|
+
checkCompose(c);
|
|
147
|
+
const r = await dk(composeArgs(c, ['stop', ...stopArgs(c), ...(c.services ?? [])]), { cwd: dirname(c.file), timeout: 300_000 });
|
|
148
|
+
if (r.code !== 0)
|
|
149
|
+
throw fail(r, 'compose stop failed');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const r = await dk(['stop', ...stopArgs(c), nameOf(id, c)], { timeout: 300_000 });
|
|
153
|
+
if (r.code !== 0 && !/No such container/i.test(r.err))
|
|
154
|
+
throw fail(r, 'could not stop it');
|
|
155
|
+
}
|
|
156
|
+
export async function restartDocker(id, c) {
|
|
157
|
+
if (c.mode === 'compose') {
|
|
158
|
+
checkCompose(c);
|
|
159
|
+
const r = await dk(composeArgs(c, ['restart', ...stopArgs(c), ...(c.services ?? [])]), { cwd: dirname(c.file), timeout: 300_000 });
|
|
160
|
+
if (r.code !== 0)
|
|
161
|
+
throw fail(r, 'compose restart failed');
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const name = nameOf(id, c);
|
|
165
|
+
if ((await dk(['inspect', '-f', '{{.Id}}', name])).code !== 0)
|
|
166
|
+
return startDocker(id, c);
|
|
167
|
+
if (c.mode === 'container') {
|
|
168
|
+
const w = await createdWith(name);
|
|
169
|
+
if (w && w !== cfgHash(c)) {
|
|
170
|
+
await dk(['stop', ...stopArgs(c), name], { timeout: 300_000 });
|
|
171
|
+
return startDocker(id, c);
|
|
172
|
+
}
|
|
173
|
+
} // changed settings: created again
|
|
174
|
+
const r = await dk(['restart', ...stopArgs(c), name], { timeout: 300_000 });
|
|
175
|
+
if (r.code !== 0)
|
|
176
|
+
throw fail(r, 'could not restart it');
|
|
177
|
+
}
|
|
178
|
+
/** One service of a compose project, alone: start (creating it if needed), stop, restart, create it again, or build it again and create it. */
|
|
179
|
+
export async function composeServiceAction(c, service, action) {
|
|
180
|
+
if (c.mode !== 'compose')
|
|
181
|
+
throw new ObjectError('Only a compose project has services');
|
|
182
|
+
if (!/^[\w][\w.-]{0,80}$/.test(service))
|
|
183
|
+
throw new ObjectError('That is not a service name');
|
|
184
|
+
checkCompose(c);
|
|
185
|
+
const rest = action === 'start' ? ['up', '-d', service]
|
|
186
|
+
: action === 'recreate' ? ['up', '-d', '--force-recreate', service]
|
|
187
|
+
: action === 'rebuild' ? ['up', '-d', '--build', service]
|
|
188
|
+
: [action, ...stopArgs(c), service];
|
|
189
|
+
const r = await dk(composeArgs(c, rest), { cwd: dirname(c.file), timeout: action === 'rebuild' ? 900_000 : 300_000 });
|
|
190
|
+
if (r.code !== 0)
|
|
191
|
+
throw fail(r, `compose ${action} failed`);
|
|
192
|
+
}
|
|
193
|
+
/** The whole object: create its container(s) again (a compose project with --force-recreate; a container hive-am made is removed and run again), or build them again first. */
|
|
194
|
+
export async function recreateDocker(id, c, build = false) {
|
|
195
|
+
if (c.mode === 'compose') {
|
|
196
|
+
checkCompose(c);
|
|
197
|
+
const r = await dk(composeArgs(c, ['up', '-d', ...(build ? ['--build'] : ['--force-recreate']), ...(c.services ?? [])]), { cwd: dirname(c.file), timeout: build ? 900_000 : 300_000 });
|
|
198
|
+
if (r.code !== 0)
|
|
199
|
+
throw fail(r, `compose ${build ? 'rebuild' : 'recreate'} failed`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (build)
|
|
203
|
+
throw new ObjectError('Only a compose project builds images');
|
|
204
|
+
if (c.mode === 'existing')
|
|
205
|
+
throw new ObjectError('A container that already existed is never removed: it cannot be created again from here');
|
|
206
|
+
await dk(['rm', '-f', nameOf(id, c)], { timeout: 120_000 });
|
|
207
|
+
await startDocker(id, c);
|
|
208
|
+
}
|
|
209
|
+
/** The id of the container behind an object (or behind one service of a compose project), or an error when there is none. */
|
|
210
|
+
export async function containerOf(id, c, service) {
|
|
211
|
+
if (c.mode !== 'compose')
|
|
212
|
+
return nameOf(id, c);
|
|
213
|
+
if (!service || !/^[\w][\w.-]{0,80}$/.test(service))
|
|
214
|
+
throw new ObjectError('Say which service');
|
|
215
|
+
checkCompose(c);
|
|
216
|
+
const r = await dk(composeArgs(c, ['ps', '-a', '-q', service]), { cwd: dirname(c.file) });
|
|
217
|
+
const cid = r.out.trim().split('\n')[0];
|
|
218
|
+
if (r.code !== 0 || !cid)
|
|
219
|
+
throw new ObjectError(`The service ${service} has no container: start it first`);
|
|
220
|
+
return cid;
|
|
221
|
+
}
|
|
222
|
+
export async function inspectDocker(id, c, service) {
|
|
223
|
+
const ref = await containerOf(id, c, service);
|
|
224
|
+
const r = await dk(['inspect', ref]);
|
|
225
|
+
if (r.code !== 0)
|
|
226
|
+
throw new ObjectError(`There is no container ${ref}`);
|
|
227
|
+
const j = JSON.parse(r.out)[0];
|
|
228
|
+
const cfg = j.Config ?? {}, st = j.State ?? {}, net = j.NetworkSettings ?? {};
|
|
229
|
+
return {
|
|
230
|
+
id: String(j.Id ?? '').slice(0, 12), name: String(j.Name ?? '').replace(/^\//, ''), image: String(cfg.Image ?? ''), status: String(st.Status ?? ''), created: String(j.Created ?? ''), started: String(st.StartedAt ?? ''),
|
|
231
|
+
restartPolicy: String(j.HostConfig?.RestartPolicy?.Name || 'no'), restarts: Number(j.RestartCount ?? 0),
|
|
232
|
+
command: Array.isArray(cfg.Cmd) ? cfg.Cmd.join(' ') : '', entrypoint: Array.isArray(cfg.Entrypoint) ? cfg.Entrypoint.join(' ') : String(cfg.Entrypoint ?? ''), workdir: String(cfg.WorkingDir ?? ''), user: String(cfg.User ?? ''),
|
|
233
|
+
env: (cfg.Env ?? []).map((e) => { const i = e.indexOf('='); return i < 0 ? { name: e, value: '' } : { name: e.slice(0, i), value: e.slice(i + 1) }; }),
|
|
234
|
+
ports: Object.entries((net.Ports ?? {})).flatMap(([cp, hs]) => (hs && hs.length ? [...new Map(hs.map((h) => [h.HostPort, h])).values()].map((h) => ({ container: cp, host: h.HostPort })) : [{ container: cp, host: '' }])),
|
|
235
|
+
mounts: (j.Mounts ?? []).map((m) => ({ type: String(m.Type ?? ''), source: String(m.Source ?? m.Name ?? ''), destination: String(m.Destination ?? ''), rw: m.RW !== false })),
|
|
236
|
+
networks: Object.entries((net.Networks ?? {})).map(([name, n]) => ({ name, ip: String(n.IPAddress ?? ''), gateway: String(n.Gateway ?? ''), aliases: (n.Aliases ?? []) })),
|
|
237
|
+
health: st.Health ? { status: String(st.Health.Status ?? ''), log: (st.Health.Log ?? []).slice(-5).map((l) => ({ at: String(l.End ?? l.Start ?? ''), exit: Number(l.ExitCode ?? 0), output: String(l.Output ?? '').slice(0, 300) })) } : undefined,
|
|
238
|
+
labels: (cfg.Labels ?? {}),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/** A container hive-am created goes away with its object; a compose project is only stopped; an existing container is left alone. */
|
|
242
|
+
export async function removeDocker(id, c) {
|
|
243
|
+
if (c.mode === 'container')
|
|
244
|
+
await dk(['rm', '-f', containerName(id)]).catch(() => undefined);
|
|
245
|
+
else if (c.mode === 'compose' && existsSync(c.file))
|
|
246
|
+
await dk(composeArgs(c, ['stop', ...(c.services ?? [])]), { cwd: dirname(c.file) }).catch(() => undefined);
|
|
247
|
+
}
|
|
248
|
+
const STAMP = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/;
|
|
249
|
+
/**
|
|
250
|
+
* The latest output. `after` is the time (RFC 3339) of the last line already seen; without it the last `tail` lines come back.
|
|
251
|
+
* Lines carry docker's own timestamp, which is what makes "only what is new" possible; it is cut out of the text shown.
|
|
252
|
+
*/
|
|
253
|
+
/**
|
|
254
|
+
* `--since` alone makes docker read the whole log file on every call (4 s for a 200 MB log, measured), so it always goes with `--tail`, which
|
|
255
|
+
* lets it read from the end: a poll costs the same however big the log is. If more than SINCE_TAIL lines arrived between two polls the
|
|
256
|
+
* oldest of them are skipped; the log pane shows the latest anyway.
|
|
257
|
+
*/
|
|
258
|
+
const SINCE_TAIL = 2000;
|
|
259
|
+
const tailArgs = (o) => o.after ? ['--since', o.after, '--tail', String(SINCE_TAIL)] : ['--tail', String(Math.max(1, Math.min(o.tail ?? 300, 5000)))];
|
|
260
|
+
export async function dockerLogs(id, c, o) {
|
|
261
|
+
const base = c.mode === 'compose'
|
|
262
|
+
? composeArgs(c, ['logs', '--no-color', '--timestamps', ...tailArgs(o), ...(c.services ?? [])])
|
|
263
|
+
: ['logs', '--timestamps', ...tailArgs(o), nameOf(id, c)];
|
|
264
|
+
if (c.mode === 'compose')
|
|
265
|
+
checkCompose(c);
|
|
266
|
+
const r = await dk(base, { cwd: c.mode === 'compose' ? dirname(c.file) : undefined, timeout: 20_000 });
|
|
267
|
+
if (r.code !== 0 && !r.out && !r.err)
|
|
268
|
+
return { text: '', cursor: o.after ?? '' };
|
|
269
|
+
if (r.code !== 0 && /No such container/i.test(r.err))
|
|
270
|
+
return { text: '', cursor: o.after ?? '' };
|
|
271
|
+
// docker keeps stdout and stderr apart: put them back in order by their timestamps.
|
|
272
|
+
const lines = `${r.out}${r.err && r.code === 0 ? `\n${r.err}` : ''}`.split('\n').filter(Boolean)
|
|
273
|
+
.map((l) => ({ l, t: STAMP.exec(l.slice(0, 120))?.[0] ?? '' }));
|
|
274
|
+
lines.sort((a, b) => (a.t < b.t ? -1 : a.t > b.t ? 1 : 0));
|
|
275
|
+
let last = o.after ?? '';
|
|
276
|
+
const shown = [];
|
|
277
|
+
for (const { l, t } of lines) {
|
|
278
|
+
if (o.after && t && t <= o.after)
|
|
279
|
+
continue; // `--since` includes the second it names
|
|
280
|
+
if (t && t > last)
|
|
281
|
+
last = t;
|
|
282
|
+
shown.push(t ? l.replace(`${t} `, '').replace(t, '') : l);
|
|
283
|
+
}
|
|
284
|
+
return { text: shown.join('\n'), cursor: last };
|
|
285
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { ObjectError } from './model.js';
|
|
3
|
+
/** Spanish descriptions, by property name (the same name has the same meaning in every engine that has it). */
|
|
4
|
+
const ES = {
|
|
5
|
+
'ssl.ca': 'Archivo con el certificado de la autoridad que firmó el del servidor (.pem). Hace falta para verificar una autoridad privada.',
|
|
6
|
+
'ssl.cert': 'Certificado del cliente (.pem), para servidores que piden que el cliente se identifique.',
|
|
7
|
+
'ssl.key': 'Llave privada del certificado del cliente (.pem, sin frase de contraseña).',
|
|
8
|
+
'ssl.minVersion': 'Versión de TLS más antigua que acepta.',
|
|
9
|
+
'ssl.ciphers': 'Suites de cifrado permitidas, en formato OpenSSL.',
|
|
10
|
+
'ssl.servername': 'Nombre contra el que se comprueba el certificado del servidor, si no es el del host.',
|
|
11
|
+
charset: 'Juego de caracteres o collation de la conexión.',
|
|
12
|
+
timezone: 'Zona horaria con la que lee las fechas: local, Z o un desfase.',
|
|
13
|
+
decimalNumbers: 'Devuelve las columnas DECIMAL como número y no como texto (puede perder precisión).',
|
|
14
|
+
connectTimeout: 'Milisegundos que espera a que se abra la conexión (por defecto 10000).',
|
|
15
|
+
enableKeepAlive: 'Mantiene viva la conexión con paquetes TCP keep-alive.',
|
|
16
|
+
keepAliveInitialDelay: 'Milisegundos de silencio antes del primer paquete keep-alive.',
|
|
17
|
+
compress: 'Comprime el tráfico con el servidor (ayuda en enlaces lentos).',
|
|
18
|
+
insecureAuth: 'Permite servidores que aún usan el cifrado antiguo e inseguro de contraseñas.',
|
|
19
|
+
enableCleartextPlugin: 'Permite el plugin de autenticación en texto claro (PAM y LDAP). Solo con SSL.',
|
|
20
|
+
application_name: 'Nombre con el que aparece la sesión en pg_stat_activity.',
|
|
21
|
+
search_path: 'Esquemas en los que busca, en orden (se envía como -c search_path=…).',
|
|
22
|
+
client_encoding: 'Juego de caracteres de la conexión.',
|
|
23
|
+
options: 'Otros ajustes de la sesión, como opciones de línea de comandos.',
|
|
24
|
+
lock_timeout: 'Milisegundos que una sentencia espera un bloqueo antes de fallar (0 = sin límite).',
|
|
25
|
+
idle_in_transaction_session_timeout: 'Milisegundos que una transacción abierta puede estar inactiva antes de que el servidor cierre la sesión (0 = sin límite).',
|
|
26
|
+
connectionTimeoutMillis: 'Milisegundos que espera a que se abra la conexión (por defecto 10000).',
|
|
27
|
+
keepAlive: 'Mantiene viva la conexión con paquetes TCP keep-alive.',
|
|
28
|
+
keepAliveInitialDelayMillis: 'Milisegundos de silencio antes del primer paquete keep-alive.',
|
|
29
|
+
enableChannelBinding: 'Exige channel binding de SCRAM (un intercambio de contraseña más fuerte, requiere SSL).',
|
|
30
|
+
sslnegotiation: 'Cómo empieza TLS. "direct" se salta la petición en claro previa (PostgreSQL 17 o posterior).',
|
|
31
|
+
appName: 'Nombre con el que aparece la sesión en sys.dm_exec_sessions.',
|
|
32
|
+
language: 'Idioma de los mensajes del servidor.',
|
|
33
|
+
dateFormat: 'Orden de día, mes y año al leer fechas.',
|
|
34
|
+
datefirst: 'Primer día de la semana: 1 es lunes, 7 es domingo.',
|
|
35
|
+
enableArithAbort: 'Detiene la sentencia ante un error aritmético (SET ARITHABORT).',
|
|
36
|
+
useUTC: 'Lee y envía las fechas en UTC y no en hora local.',
|
|
37
|
+
instanceName: 'Instancia con nombre (host\\NOMBRE). Con ella no se usa el puerto: se busca por el servicio SQL Browser.',
|
|
38
|
+
connectionTimeout: 'Milisegundos que espera a que se abra la conexión (por defecto 10000).',
|
|
39
|
+
packetSize: 'Bytes por paquete de red (de 512 a 32767).',
|
|
40
|
+
readOnlyIntent: 'Pide la réplica de lectura de un grupo de disponibilidad.',
|
|
41
|
+
multiSubnetFailover: 'Prueba a la vez todas las direcciones del listener de un grupo de disponibilidad.',
|
|
42
|
+
workstationId: 'Nombre de equipo que la sesión informa al servidor.',
|
|
43
|
+
serverName: 'Nombre que se envía al servidor al conectar por TLS (SNI), si no es el del host.',
|
|
44
|
+
timeout: 'Milisegundos que espera cuando otro proceso tiene bloqueado el archivo (por defecto 5000).',
|
|
45
|
+
foreign_keys: 'Hace cumplir las llaves foráneas.',
|
|
46
|
+
case_sensitive_like: 'Hace que LIKE distinga mayúsculas de minúsculas.',
|
|
47
|
+
cache_size: 'Páginas que mantiene en memoria; un número negativo son kilobytes.',
|
|
48
|
+
temp_store: 'Dónde viven las tablas e índices temporales.',
|
|
49
|
+
};
|
|
50
|
+
const p = (name, type, group, desc, extra = {}) => ({ name, type, group, desc, es: ES[name] ?? desc, ...extra });
|
|
51
|
+
const TLS = ['TLSv1.2', 'TLSv1.3'];
|
|
52
|
+
/** TLS files and settings: the same names for the three server engines (SQL Server takes them inside `cryptoCredentialsDetails`). */
|
|
53
|
+
const sslProps = (servername) => [
|
|
54
|
+
p('ssl.ca', 'file', 'ssl', 'File with the certificate of the authority that signed the server certificate (.pem). Needed to verify a private authority.', { placeholder: '/etc/ssl/certs/my-ca.pem' }),
|
|
55
|
+
p('ssl.cert', 'file', 'ssl', 'Client certificate (.pem), for servers that ask the client to identify itself.', { placeholder: '/home/you/client-cert.pem' }),
|
|
56
|
+
p('ssl.key', 'file', 'ssl', 'Private key of the client certificate (.pem, not protected by a passphrase).', { placeholder: '/home/you/client-key.pem' }),
|
|
57
|
+
p('ssl.minVersion', 'choice', 'ssl', 'Oldest TLS version it will accept.', { choices: TLS }),
|
|
58
|
+
p('ssl.ciphers', 'string', 'ssl', 'Allowed cipher suites, in OpenSSL format.', { placeholder: 'ECDHE-RSA-AES256-GCM-SHA384' }),
|
|
59
|
+
...(servername ? [p('ssl.servername', 'string', 'ssl', 'Name the server certificate is checked against, when it differs from the host.', { placeholder: 'db.example.com' })] : []),
|
|
60
|
+
];
|
|
61
|
+
export const DRIVER_PROPS = {
|
|
62
|
+
mysql: [
|
|
63
|
+
p('charset', 'string', 'session', 'Character set or collation of the connection.', { placeholder: 'utf8mb4' }),
|
|
64
|
+
p('timezone', 'string', 'session', 'Time zone used to read dates: local, Z or an offset.', { placeholder: 'Z' }),
|
|
65
|
+
p('decimalNumbers', 'boolean', 'session', 'Return DECIMAL columns as numbers instead of text (can lose precision).'),
|
|
66
|
+
p('connectTimeout', 'number', 'network', 'Milliseconds to wait for the connection to open (default 10000).', { min: 1000, max: 120000, placeholder: '10000' }),
|
|
67
|
+
p('enableKeepAlive', 'boolean', 'network', 'Keep the connection alive with TCP keep-alive packets.'),
|
|
68
|
+
p('keepAliveInitialDelay', 'number', 'network', 'Milliseconds of silence before the first keep-alive packet.', { min: 0, max: 600000, placeholder: '0' }),
|
|
69
|
+
p('compress', 'boolean', 'network', 'Compress the traffic with the server (helps on slow links).'),
|
|
70
|
+
p('insecureAuth', 'boolean', 'advanced', 'Allow servers that still use the old, insecure password hashing.'),
|
|
71
|
+
p('enableCleartextPlugin', 'boolean', 'advanced', 'Allow the cleartext authentication plugin (PAM and LDAP setups). Only over SSL.'),
|
|
72
|
+
...sslProps(true),
|
|
73
|
+
],
|
|
74
|
+
postgresql: [
|
|
75
|
+
p('application_name', 'string', 'session', 'Name the session shows in pg_stat_activity.', { placeholder: 'hive-am' }),
|
|
76
|
+
p('search_path', 'string', 'session', 'Schemas it looks in, in order (it is sent as -c search_path=…).', { placeholder: 'app,public' }),
|
|
77
|
+
p('client_encoding', 'string', 'session', 'Character set of the connection.', { placeholder: 'UTF8' }),
|
|
78
|
+
p('options', 'string', 'session', 'Other session settings, as command line options.', { placeholder: '-c timezone=UTC' }),
|
|
79
|
+
p('lock_timeout', 'number', 'session', 'Milliseconds a statement waits for a lock before it fails (0 = no limit).', { min: 0, max: 3600000, placeholder: '0' }),
|
|
80
|
+
p('idle_in_transaction_session_timeout', 'number', 'session', 'Milliseconds an open transaction may sit idle before the server ends the session (0 = no limit).', { min: 0, max: 3600000, placeholder: '0' }),
|
|
81
|
+
p('connectionTimeoutMillis', 'number', 'network', 'Milliseconds to wait for the connection to open (default 10000).', { min: 1000, max: 120000, placeholder: '10000' }),
|
|
82
|
+
p('keepAlive', 'boolean', 'network', 'Keep the connection alive with TCP keep-alive packets.'),
|
|
83
|
+
p('keepAliveInitialDelayMillis', 'number', 'network', 'Milliseconds of silence before the first keep-alive packet.', { min: 0, max: 600000, placeholder: '0' }),
|
|
84
|
+
p('enableChannelBinding', 'boolean', 'advanced', 'Require SCRAM channel binding (a stronger password exchange, needs SSL).'),
|
|
85
|
+
p('sslnegotiation', 'choice', 'advanced', 'How TLS starts. "direct" skips the plain request first (PostgreSQL 17 and later).', { choices: ['postgres', 'direct'] }),
|
|
86
|
+
...sslProps(true),
|
|
87
|
+
],
|
|
88
|
+
mssql: [
|
|
89
|
+
p('appName', 'string', 'session', 'Name the session shows in sys.dm_exec_sessions.', { placeholder: 'hive-am' }),
|
|
90
|
+
p('language', 'string', 'session', 'Language of the server messages.', { placeholder: 'us_english' }),
|
|
91
|
+
p('dateFormat', 'choice', 'session', 'Order of day, month and year when reading dates.', { choices: ['mdy', 'dmy', 'ymd', 'ydm', 'myd', 'dym'] }),
|
|
92
|
+
p('datefirst', 'number', 'session', 'First day of the week: 1 is Monday, 7 is Sunday.', { min: 1, max: 7, placeholder: '7' }),
|
|
93
|
+
p('enableArithAbort', 'boolean', 'session', 'Stop a statement on an arithmetic error (SET ARITHABORT).'),
|
|
94
|
+
p('useUTC', 'boolean', 'session', 'Read and send dates as UTC instead of local time.'),
|
|
95
|
+
p('instanceName', 'string', 'network', 'Named instance (host\\NAME). With it the port is not used: the instance is found through the SQL Browser service.', { placeholder: 'SQLEXPRESS' }),
|
|
96
|
+
p('connectionTimeout', 'number', 'network', 'Milliseconds to wait for the connection to open (default 10000).', { min: 1000, max: 120000, placeholder: '10000' }),
|
|
97
|
+
p('packetSize', 'number', 'network', 'Bytes per network packet (512 to 32767).', { min: 512, max: 32767, placeholder: '4096' }),
|
|
98
|
+
p('readOnlyIntent', 'boolean', 'network', 'Ask for the read replica of an availability group.'),
|
|
99
|
+
p('multiSubnetFailover', 'boolean', 'network', 'Try every address of an availability group listener at once.'),
|
|
100
|
+
p('workstationId', 'string', 'advanced', 'Host name the session reports to the server.', { placeholder: 'hive-am' }),
|
|
101
|
+
p('serverName', 'string', 'advanced', 'Name sent to the server when it connects over TLS (SNI), when it differs from the host.', { placeholder: 'db.example.com' }),
|
|
102
|
+
...sslProps(false),
|
|
103
|
+
],
|
|
104
|
+
sqlite: [
|
|
105
|
+
p('timeout', 'number', 'session', 'Milliseconds to wait when the file is locked by another process (default 5000).', { min: 0, max: 600000, placeholder: '5000' }),
|
|
106
|
+
p('foreign_keys', 'boolean', 'session', 'Enforce foreign keys.'),
|
|
107
|
+
p('case_sensitive_like', 'boolean', 'session', 'Make LIKE case sensitive.'),
|
|
108
|
+
p('cache_size', 'number', 'session', 'Pages kept in memory; a negative number is kilobytes.', { min: -1000000, max: 1000000, placeholder: '-2000' }),
|
|
109
|
+
p('temp_store', 'choice', 'session', 'Where temporary tables and indexes live.', { choices: ['default', 'file', 'memory'] }),
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
export const MAX_PROPS = 40;
|
|
113
|
+
const CTRL = /[\0\r\n]/;
|
|
114
|
+
/** Checks the properties the interface sent: only known names, each with a value of its kind. Empty values are dropped. */
|
|
115
|
+
export function parseProps(engine, raw) {
|
|
116
|
+
if (raw === undefined || raw === null)
|
|
117
|
+
return undefined;
|
|
118
|
+
if (typeof raw !== 'object' || Array.isArray(raw))
|
|
119
|
+
throw new ObjectError('The driver properties must be a list of name and value');
|
|
120
|
+
const known = new Map(DRIVER_PROPS[engine].map((x) => [x.name, x]));
|
|
121
|
+
const out = {};
|
|
122
|
+
for (const [name, v] of Object.entries(raw)) {
|
|
123
|
+
if (v === undefined || v === null || (typeof v === 'string' && v.trim() === ''))
|
|
124
|
+
continue;
|
|
125
|
+
const def = known.get(name);
|
|
126
|
+
if (!def)
|
|
127
|
+
throw new ObjectError(`"${name}" is not a property this driver takes: choose it from the list`);
|
|
128
|
+
const s = String(v).trim();
|
|
129
|
+
if (s.length > 1000 || CTRL.test(s))
|
|
130
|
+
throw new ObjectError(`The value of "${name}" is not valid`);
|
|
131
|
+
if (def.type === 'number') {
|
|
132
|
+
const n = Number(s);
|
|
133
|
+
if (!Number.isInteger(n) || (def.min !== undefined && n < def.min) || (def.max !== undefined && n > def.max))
|
|
134
|
+
throw new ObjectError(`"${name}" must be a whole number${def.min !== undefined ? ` from ${def.min} to ${def.max}` : ''}`);
|
|
135
|
+
}
|
|
136
|
+
else if (def.type === 'boolean') {
|
|
137
|
+
if (s !== 'true' && s !== 'false')
|
|
138
|
+
throw new ObjectError(`"${name}" must be true or false`);
|
|
139
|
+
}
|
|
140
|
+
else if (def.type === 'choice') {
|
|
141
|
+
if (!def.choices.includes(s))
|
|
142
|
+
throw new ObjectError(`"${name}" must be one of: ${def.choices.join(', ')}`);
|
|
143
|
+
}
|
|
144
|
+
else if (def.type === 'file') {
|
|
145
|
+
if (!s.startsWith('/'))
|
|
146
|
+
throw new ObjectError(`"${name}" must be the absolute path of a file`);
|
|
147
|
+
}
|
|
148
|
+
else if (name === 'search_path' && !/^[A-Za-z0-9_$," .-]+$/.test(s))
|
|
149
|
+
throw new ObjectError('The search_path has characters that are not allowed');
|
|
150
|
+
else if (name === 'options' && !/^[A-Za-z0-9_$=\-.,:/ ]+$/.test(s))
|
|
151
|
+
throw new ObjectError('"options" has characters that are not allowed');
|
|
152
|
+
else if (name === 'instanceName' && !/^[A-Za-z0-9_$#@-]{1,128}$/.test(s))
|
|
153
|
+
throw new ObjectError('The instance name is not valid');
|
|
154
|
+
out[name] = s;
|
|
155
|
+
}
|
|
156
|
+
if (Object.keys(out).length > MAX_PROPS)
|
|
157
|
+
throw new ObjectError(`At most ${MAX_PROPS} driver properties`);
|
|
158
|
+
return Object.keys(out).length ? out : undefined;
|
|
159
|
+
}
|
|
160
|
+
/** The properties as the driver wants them: numbers and booleans typed, files read (a missing file says which property it was). */
|
|
161
|
+
export function propValues(engine, props) {
|
|
162
|
+
const out = {};
|
|
163
|
+
for (const def of DRIVER_PROPS[engine]) {
|
|
164
|
+
const v = props?.[def.name];
|
|
165
|
+
if (v === undefined)
|
|
166
|
+
continue;
|
|
167
|
+
if (def.type === 'number')
|
|
168
|
+
out[def.name] = Number(v);
|
|
169
|
+
else if (def.type === 'boolean')
|
|
170
|
+
out[def.name] = v === 'true';
|
|
171
|
+
else if (def.type === 'file') {
|
|
172
|
+
try {
|
|
173
|
+
if (!statSync(v).isFile())
|
|
174
|
+
throw new Error('not a file');
|
|
175
|
+
out[def.name] = readFileSync(v, 'utf8');
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
throw new ObjectError(`"${def.name}": the file ${v} cannot be read`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
else
|
|
182
|
+
out[def.name] = v;
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
/** The `ssl.*` properties as the options of a TLS socket (Node's `tls.connect`). */
|
|
187
|
+
export function tlsOptions(v, verify) {
|
|
188
|
+
const o = { rejectUnauthorized: verify };
|
|
189
|
+
const pick = [['ssl.ca', 'ca'], ['ssl.cert', 'cert'], ['ssl.key', 'key'], ['ssl.minVersion', 'minVersion'], ['ssl.ciphers', 'ciphers'], ['ssl.servername', 'servername']];
|
|
190
|
+
for (const [from, to] of pick)
|
|
191
|
+
if (v[from] !== undefined)
|
|
192
|
+
o[to] = v[from];
|
|
193
|
+
return o;
|
|
194
|
+
}
|
|
195
|
+
/** The properties that are not `ssl.*`, ready to spread in the options of a driver. */
|
|
196
|
+
export const plain = (v) => Object.fromEntries(Object.entries(v).filter(([k]) => !k.startsWith('ssl.')));
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
/**
|
|
3
|
+
* Runs a program without a shell (every value is one argument) and always resolves: output, exit code and whether it was missing or
|
|
4
|
+
* timed out. Callers decide what each case means for them. A death by signal or an overflowed buffer counts as a failure (code 1).
|
|
5
|
+
*/
|
|
6
|
+
export function execCapture(file, args, o = {}) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
execFile(file, args, { cwd: o.cwd, timeout: o.timeout ?? 15_000, maxBuffer: o.maxBuffer ?? 8 * 1024 * 1024, env: o.env, encoding: 'utf8' }, (e, stdout, stderr) => {
|
|
9
|
+
resolve({
|
|
10
|
+
out: String(stdout ?? ''), err: String(stderr ?? ''),
|
|
11
|
+
code: e ? (typeof e.code === 'number' ? e.code : 1) : 0,
|
|
12
|
+
missing: e?.code === 'ENOENT', timedOut: !!e?.killed && e?.code !== 'ENOENT',
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, renameSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { DATA_DIR } from '../db.js';
|
|
4
|
+
const HISTORY_MAX = 100, BODY_MAX = 100_000;
|
|
5
|
+
const DIR = join(DATA_DIR, 'http-history');
|
|
6
|
+
const fileOf = (id) => join(DIR, `${id.replace(/[^\w-]/g, '')}.json`);
|
|
7
|
+
const cache = new Map();
|
|
8
|
+
function load(id) {
|
|
9
|
+
let list = cache.get(id);
|
|
10
|
+
if (!list) {
|
|
11
|
+
try {
|
|
12
|
+
list = existsSync(fileOf(id)) ? JSON.parse(readFileSync(fileOf(id), 'utf8')) : [];
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
list = [];
|
|
16
|
+
}
|
|
17
|
+
cache.set(id, list);
|
|
18
|
+
}
|
|
19
|
+
return list;
|
|
20
|
+
}
|
|
21
|
+
function save(id, list) {
|
|
22
|
+
try {
|
|
23
|
+
mkdirSync(DIR, { recursive: true });
|
|
24
|
+
const tmp = `${fileOf(id)}.tmp`;
|
|
25
|
+
writeFileSync(tmp, JSON.stringify(list));
|
|
26
|
+
renameSync(tmp, fileOf(id));
|
|
27
|
+
}
|
|
28
|
+
catch { /* the history is a convenience: never break a run for it */ }
|
|
29
|
+
}
|
|
30
|
+
export function recordRun(id, e) {
|
|
31
|
+
const list = load(id);
|
|
32
|
+
const run = e.run ? { ...e.run, body: e.run.body.length > BODY_MAX ? e.run.body.slice(0, BODY_MAX) : e.run.body, truncated: e.run.truncated || e.run.body.length > BODY_MAX } : undefined;
|
|
33
|
+
const entry = { ...e, run, n: (list[0]?.n ?? 0) + 1, at: Date.now() };
|
|
34
|
+
list.unshift(entry);
|
|
35
|
+
if (list.length > HISTORY_MAX)
|
|
36
|
+
list.length = HISTORY_MAX;
|
|
37
|
+
save(id, list);
|
|
38
|
+
return entry;
|
|
39
|
+
}
|
|
40
|
+
export const listHistory = (id) => load(id);
|
|
41
|
+
export function clearHistory(id) { cache.delete(id); try {
|
|
42
|
+
rmSync(fileOf(id), { force: true });
|
|
43
|
+
}
|
|
44
|
+
catch { /* nothing to remove */ } }
|