summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock19
-rw-r--r--Cargo.toml157
-rw-r--r--README.txt6
-rw-r--r--clippy.toml16
-rw-r--r--crates/chat-crypto/browser/protocol.d.ts6
-rw-r--r--crates/chat-crypto/browser/wasi.ts27
-rw-r--r--crates/chat-crypto/src/application.rs15
-rw-r--r--crates/chat-crypto/tests/browser.ts19
-rw-r--r--crates/chat-server/Cargo.toml4
-rw-r--r--crates/chat-server/src/app.rs199
-rw-r--r--crates/chat-server/src/app/cli.rs167
-rw-r--r--crates/chat-server/src/app/http.rs (renamed from crates/chat-server/src/http.rs)4
-rw-r--r--crates/chat-server/src/app/maintenance.rs (renamed from crates/chat-server/src/maintenance.rs)8
-rw-r--r--crates/chat-server/src/app/service.rs (renamed from crates/chat-server/src/service.rs)16
-rw-r--r--crates/chat-server/src/app/store.rs (renamed from crates/chat-server/src/store.rs)24
-rw-r--r--crates/chat-server/src/main.rs202
-rw-r--r--crates/chat-server/tests/browser.ts104
-rw-r--r--crates/chat-server/tests/progress.ts28
-rw-r--r--crates/chat-server/web/app.ts165
-rw-r--r--crates/chat-server/web/worker.ts4
-rw-r--r--crates/config/src/lib.rs43
-rw-r--r--docs/LINTING.txt194
-rw-r--r--docs/POLICY.txt18
-rw-r--r--docs/TYPESCRIPT.txt18
-rw-r--r--eslint.config.ts53
-rw-r--r--infra/chat/build-source.sh2
-rw-r--r--infra/chat/build-typescript.sh6
-rw-r--r--package-lock.json1334
-rw-r--r--package.json17
-rw-r--r--src/app.rs252
-rw-r--r--src/app/archive.rs (renamed from src/archive.rs)51
-rw-r--r--src/app/chat.rs (renamed from src/chat.rs)16
-rw-r--r--src/app/cli.rs329
-rw-r--r--src/app/files.rs (renamed from src/files.rs)115
-rw-r--r--src/app/images.rs (renamed from src/images.rs)14
-rw-r--r--src/app/process.rs (renamed from src/process.rs)12
-rw-r--r--src/app/project.rs (renamed from src/project.rs)93
-rw-r--r--src/app/release.rs (renamed from src/release.rs)16
-rw-r--r--src/app/render.rs (renamed from src/render.rs)44
-rw-r--r--src/main.rs324
-rw-r--r--tests/browser-support.ts23
-rw-r--r--tsconfig.browser.json1
-rw-r--r--tsconfig.json4
-rw-r--r--tsconfig.tools.json33
-rw-r--r--tsconfig.worker.json1
-rw-r--r--web/THIRD-PARTY-LICENSES.html6
46 files changed, 3240 insertions, 969 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 1a2f533..0a57f91 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -345,7 +345,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
- "clap_derive",
]
[[package]]
@@ -361,18 +360,6 @@ dependencies = [
]
[[package]]
-name = "clap_derive"
-version = "4.6.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
-dependencies = [
- "heck",
- "proc-macro2",
- "quote",
- "syn 3.0.9",
-]
-
-[[package]]
name = "clap_lex"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -695,12 +682,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
-name = "heck"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
-
-[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 327372d..a0e247d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -32,7 +32,7 @@ exclude = ["var", ".tools", "target", "bin", "dist"]
stack-config = { path = "crates/config", version = "0.1.0" }
anyhow = "1.0"
chrono = { version = "0.4", default-features = false, features = ["now", "std"] }
-clap = { version = "4.6", features = ["derive"] }
+clap = "4.6"
hex = "0.4"
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
serde = { version = "1.0", features = ["derive"] }
@@ -62,58 +62,117 @@ resolver = "3"
workspace = true
[workspace.lints.rustdoc]
-broken_intra_doc_links = "deny"
-private_intra_doc_links = "deny"
-missing_crate_level_docs = "deny"
+all = { level = "forbid", priority = -1 }
+broken_intra_doc_links = "forbid"
+private_intra_doc_links = "forbid"
+missing_crate_level_docs = "forbid"
[workspace.lints.rust]
unused = { level = "deny", priority = -1 }
nonstandard_style = { level = "deny", priority = -1 }
-future_incompatible = { level = "deny", priority = -1 }
-rust_2018_idioms = { level = "deny", priority = -1 }
-let_underscore = { level = "deny", priority = -1 }
-unsafe_code = "deny"
-unstable_features = "deny"
-missing_docs = "deny"
-missing_debug_implementations = "deny"
-unreachable_pub = "deny"
-trivial_casts = "deny"
-trivial_numeric_casts = "deny"
-unreachable_patterns = "deny"
-warnings = "deny"
+future_incompatible = { level = "forbid", priority = -1 }
+rust_2018_idioms = { level = "forbid", priority = -1 }
+let_underscore = { level = "forbid", priority = -1 }
+unsafe_code = "forbid"
+unstable_features = "forbid"
+missing_docs = "forbid"
+missing_debug_implementations = "forbid"
+unreachable_pub = "forbid"
+trivial_casts = "forbid"
+trivial_numeric_casts = "forbid"
+unreachable_patterns = "forbid"
+warnings = "forbid"
unused_qualifications = "deny"
+unused_imports = "forbid"
+unused_variables = "forbid"
+unused_visibilities = "forbid"
+unused_assignments = "forbid"
+dead_code = "forbid"
+unused_mut = "forbid"
+unreachable_cfg_select_predicates = "forbid"
+unreachable_code = "forbid"
+unused_must_use = "forbid"
+unused_unsafe = "forbid"
+path_statements = "forbid"
+unused_macros = "forbid"
+unused_macro_rules = "forbid"
+unused_allocation = "forbid"
+unused_doc_comments = "forbid"
+unused_extern_crates = "forbid"
+unused_features = "forbid"
+unused_labels = "forbid"
+unused_parens = "forbid"
+unused_braces = "forbid"
+redundant_semicolons = "forbid"
+map_unit_fn = "forbid"
+non_snake_case = "forbid"
+ambiguous_negative_literals = "forbid"
+ffi_unwind_calls = "forbid"
+keyword_idents = { level = "forbid", priority = -1 }
+macro_use_extern_crate = "forbid"
+meta_variable_misuse = "forbid"
+non_ascii_idents = "forbid"
+unsafe_op_in_unsafe_fn = "forbid"
+unsafe_attr_outside_unsafe = "forbid"
+unused_lifetimes = "forbid"
+unfulfilled_lint_expectations = "forbid"
+unused_import_braces = "forbid"
+unnameable_types = "forbid"
+
[workspace.lints.clippy]
-all = { level = "deny", priority = -1 }
-correctness = { level = "deny", priority = -1 }
-suspicious = { level = "deny", priority = -1 }
-cargo = { level = "deny", priority = -1 }
-style = { level = "deny", priority = -1 }
-complexity = { level = "deny", priority = -1 }
-perf = { level = "deny", priority = -1 }
-pedantic = { level = "deny", priority = -1 }
-nursery = { level = "deny", priority = -1 }
-todo = "deny"
-print_stdout = "deny"
-print_stderr = "deny"
-unwrap_used = "deny"
-expect_used = "deny"
-dbg_macro = "deny"
-mem_forget = "deny"
-exit = "deny"
-integer_division = "deny"
-clone_on_ref_ptr = "deny"
-indexing_slicing = "deny"
-arithmetic_side_effects = "deny"
-float_arithmetic = "deny"
-as_conversions = "deny"
-shadow_reuse = "deny"
-shadow_same = "deny"
-unseparated_literal_suffix = "deny"
-undocumented_unsafe_blocks = "deny"
-cast_possible_truncation = "deny"
-cast_possible_wrap = "deny"
-cast_sign_loss = "deny"
-missing_errors_doc = "deny"
-multiple_crate_versions = "allow"
-redundant_pub_crate = "allow"
+all = { level = "forbid", priority = -1 }
+correctness = { level = "forbid", priority = -1 }
+suspicious = { level = "forbid", priority = -1 }
+cargo = { level = "forbid", priority = -1 }
+style = { level = "forbid", priority = -1 }
+complexity = { level = "forbid", priority = -1 }
+perf = { level = "forbid", priority = -1 }
+pedantic = { level = "forbid", priority = -1 }
+nursery = { level = "forbid", priority = -1 }
+todo = "forbid"
+print_stdout = "forbid"
+print_stderr = "forbid"
+unwrap_used = "forbid"
+expect_used = "forbid"
+dbg_macro = "forbid"
+mem_forget = "forbid"
+exit = "forbid"
+integer_division = "forbid"
+clone_on_ref_ptr = "forbid"
+indexing_slicing = "forbid"
+arithmetic_side_effects = "forbid"
+float_arithmetic = "forbid"
+as_conversions = "forbid"
+shadow_reuse = "forbid"
+shadow_same = "forbid"
+unseparated_literal_suffix = "forbid"
+undocumented_unsafe_blocks = "forbid"
+cast_possible_truncation = "forbid"
+cast_possible_wrap = "forbid"
+cast_sign_loss = "forbid"
+missing_errors_doc = "forbid"
+multiple_crate_versions = "forbid"
+redundant_pub_crate = "forbid"
+
+allow_attributes = "forbid"
+allow_attributes_without_reason = "forbid"
+panic = "forbid"
+panic_in_result_fn = "forbid"
+unimplemented = "forbid"
+unreachable = "forbid"
+infinite_loop = "forbid"
+get_unwrap = "forbid"
+unwrap_in_result = "forbid"
+lossy_float_literal = "forbid"
+fn_to_numeric_cast_any = "forbid"
+as_underscore = "forbid"
+as_pointer_underscore = "forbid"
+host_endian_bytes = "forbid"
+mem_replace_option_with_some = "forbid"
+rc_mutex = "forbid"
+rest_pat_in_fully_bound_structs = "forbid"
+string_slice = "forbid"
+suspicious_xor_used_as_pow = "forbid"
+tests_outside_test_module = "forbid"
+large_include_file = "forbid"
diff --git a/README.txt b/README.txt
index bfe5bab..516f09b 100644
--- a/README.txt
+++ b/README.txt
@@ -41,7 +41,8 @@ GETTING STARTED
DEVELOPMENT AND PRODUCTION
- src/ holds the Rust management command. Future Rust application backends
+ src/main.rs forwards to the private Rust management implementation in
+ src/app.rs and src/app/. Future Rust application backends
belong in crates/. Add each crate to the root workspace explicitly and
inherit its strict workspace lints.
@@ -74,7 +75,8 @@ CHECKS AND LICENSES
Check builds and checks TypeScript, then runs Rust formatting, Clippy, tests,
rustdoc, cargo-deny, and cargo-about. npm development dependencies are pinned
- in package-lock.json; run npm audit to check their advisories.
+ in package-lock.json. Typed ESLint, both compiler checks, and npm audit are
+ included. See docs/LINTING.txt for policy choices and tested exceptions.
THIRD-PARTY-LICENSES.html is generated from Cargo.lock and about.hbs.
Human-readable license headings use GNU's names. SPDX identifiers and
original third-party legal texts retain their exact spelling.
diff --git a/clippy.toml b/clippy.toml
new file mode 100644
index 0000000..5513c98
--- /dev/null
+++ b/clippy.toml
@@ -0,0 +1,16 @@
+# Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+allowed-duplicate-crates = ["generic-array", "getrandom", "syn"]
diff --git a/crates/chat-crypto/browser/protocol.d.ts b/crates/chat-crypto/browser/protocol.d.ts
index 6803c4c..30b5bf8 100644
--- a/crates/chat-crypto/browser/protocol.d.ts
+++ b/crates/chat-crypto/browser/protocol.d.ts
@@ -49,9 +49,9 @@ declare namespace StackChat {
interface WorkerRequest { bytes: ArrayBuffer; request: CryptoRequest }
interface Execution { output: string; randomCalls: number; randomBytes: number }
interface Wasi {
- runCommand(bytes: ArrayBuffer, request: CryptoRequest): Promise<unknown>;
- runProbe(bytes: ArrayBuffer): Promise<Execution>;
+ readonly runCommand: (bytes: ArrayBuffer, request: CryptoRequest) => Promise<unknown>;
+ readonly runProbe: (bytes: ArrayBuffer) => Promise<Execution>;
}
}
-declare var stackChatWasi: Readonly<StackChat.Wasi>;
+declare const stackChatWasi: Readonly<StackChat.Wasi>;
diff --git a/crates/chat-crypto/browser/wasi.ts b/crates/chat-crypto/browser/wasi.ts
index a037519..f2d9290 100644
--- a/crates/chat-crypto/browser/wasi.ts
+++ b/crates/chat-crypto/browser/wasi.ts
@@ -15,8 +15,12 @@
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0
+function isStart(value: WebAssembly.ExportValue | undefined): value is () => unknown {
+ return typeof value === 'function' && value.length === 0;
+}
+
async function execute(bytes: ArrayBuffer, input: string): Promise<StackChat.Execution> {
- let memory: WebAssembly.Memory | undefined;
+ const instanceState: { memory: WebAssembly.Memory | undefined } = { memory: undefined };
let output = '';
let errors = '';
const stdoutDecoder = new TextDecoder('utf-8', { fatal: true });
@@ -26,15 +30,16 @@ async function execute(bytes: ArrayBuffer, input: string): Promise<StackChat.Exe
let randomCalls = 0;
let randomBytes = 0;
const memoryBuffer = (): ArrayBuffer => {
- if (!memory) throw new Error('WASI memory is unavailable');
+ const memory = instanceState.memory;
+ if (memory === undefined) throw new Error('WASI memory is unavailable');
return memory.buffer;
};
const data = (): DataView => new DataView(memoryBuffer());
- const put32 = (address: number, value: number) => data().setUint32(address, value, true);
- const noEntries = (count: number, size: number) => { put32(count, 0); put32(size, 0); return 0; };
+ const put32 = (address: number, value: number): void => { data().setUint32(address, value, true); };
+ const noEntries = (count: number, size: number): number => { put32(count, 0); put32(size, 0); return 0; };
class Exit extends Error {
readonly code: number;
- constructor(code: number) { super(`WASI exit ${code}`); this.code = code; }
+ constructor(code: number) { super(`WASI exit ${String(code)}`); this.code = code; }
}
const wasi: Record<string, WebAssembly.ImportValue> = {
args_sizes_get: noEntries,
@@ -108,7 +113,7 @@ async function execute(bytes: ArrayBuffer, input: string): Promise<StackChat.Exe
'path_filestat_get', 'path_filestat_set_times', 'path_readlink', 'path_unlink_file',
'path_rename', 'path_create_directory', 'path_remove_directory', 'path_link', 'path_symlink',
'sock_accept', 'sock_recv', 'sock_send', 'sock_shutdown', 'poll_oneoff',
- ]) wasi[name] = () => 76;
+ ]) wasi[name] = (): number => 76;
const module = await WebAssembly.compile(bytes);
for (const entry of WebAssembly.Module.imports(module)) {
if (entry.module !== 'wasi_snapshot_preview1' || typeof wasi[entry.name] !== 'function') {
@@ -118,17 +123,17 @@ async function execute(bytes: ArrayBuffer, input: string): Promise<StackChat.Exe
const instance = await WebAssembly.instantiate(module, { wasi_snapshot_preview1: wasi });
const exportedMemory = instance.exports['memory'];
const start = instance.exports['_start'];
- if (!(exportedMemory instanceof WebAssembly.Memory) || typeof start !== 'function') throw new Error('Invalid WASI exports');
- memory = exportedMemory;
+ if (!(exportedMemory instanceof WebAssembly.Memory) || !isStart(start)) throw new Error('Invalid WASI exports');
+ instanceState.memory = exportedMemory;
try { start(); }
- catch (error) { if (!(error instanceof Exit) || error.code !== 0) throw new Error(`${error instanceof Error ? error.message : 'WASI execution failed'}: ${errors}`); }
+ catch (error) { if (!(error instanceof Exit) || error.code !== 0) throw new Error(`${error instanceof Error ? error.message : 'WASI execution failed'}: ${errors}`, { cause: error }); }
output += stdoutDecoder.decode();
errors += stderrDecoder.decode();
stdin.fill(0);
return { output, randomCalls, randomBytes };
}
-async function runProbe(bytes: ArrayBuffer) {
+async function runProbe(bytes: ArrayBuffer): Promise<StackChat.Execution> {
const result = await execute(bytes, '');
if (!result.output.startsWith('PASS: backend=OpenSSL ') || result.randomCalls === 0) {
throw new Error('OpenSSL probe did not pass');
@@ -146,6 +151,6 @@ async function runCommand(bytes: ArrayBuffer, request: StackChat.CryptoRequest):
return response;
}
-globalThis.stackChatWasi = Object.freeze({ runCommand, runProbe });
+Object.defineProperty(globalThis, 'stackChatWasi', { value: Object.freeze({ runCommand, runProbe }), writable: false, configurable: false });
// @license-end
diff --git a/crates/chat-crypto/src/application.rs b/crates/chat-crypto/src/application.rs
index 963e1de..c13f4f4 100644
--- a/crates/chat-crypto/src/application.rs
+++ b/crates/chat-crypto/src/application.rs
@@ -37,7 +37,7 @@ const FORMAT: &str = "stack-chat-file/v2;argon2id-m65536-t3-p1;aes256-gcm";
const LIMIT: usize = 262_144;
#[derive(Deserialize, JsonSerialize)]
-#[serde(deny_unknown_fields)]
+#[serde(crate = "serde", deny_unknown_fields)]
struct Identity {
origin: String,
name: String,
@@ -46,7 +46,7 @@ struct Identity {
}
#[derive(Deserialize, JsonSerialize)]
-#[serde(deny_unknown_fields)]
+#[serde(crate = "serde", deny_unknown_fields)]
struct Envelope {
format: String,
salt: String,
@@ -56,14 +56,19 @@ struct Envelope {
}
#[derive(Deserialize, JsonSerialize)]
-#[serde(untagged)]
+#[serde(crate = "serde", untagged)]
enum FileCiphertext {
Compact(String),
Wrapped(Vec<String>),
}
#[derive(Deserialize)]
-#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
+#[serde(
+ crate = "serde",
+ tag = "op",
+ rename_all = "snake_case",
+ deny_unknown_fields
+)]
enum Request {
Create {
origin: String,
@@ -105,7 +110,7 @@ enum Request {
}
#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
+#[serde(crate = "serde", deny_unknown_fields)]
struct EncryptedMessage {
sender: String,
ciphertext: String,
diff --git a/crates/chat-crypto/tests/browser.ts b/crates/chat-crypto/tests/browser.ts
index 75095ce..fd16529 100644
--- a/crates/chat-crypto/tests/browser.ts
+++ b/crates/chat-crypto/tests/browser.ts
@@ -13,33 +13,32 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import assert from 'node:assert/strict';
import { chromium } from 'playwright';
-import { serverOrigin } from '../../../tests/browser-support.js';
+import { asyncServer, closeBrowser, serverOrigin } from '../../../tests/browser-support.js';
const assets = new Map<string, [string, string]>([
['/wasi.mjs', [resolve('var/build/typescript/crates/chat-crypto/browser/wasi.js'), 'text/javascript']],
['/probe.wasm', [resolve('target/wasm32-wasip1/release/stack-chat-crypto.wasm'), 'application/wasm']],
]);
-const server = createServer(async (request, response) => {
+const server = asyncServer(async (request, response) => {
response.setHeader('Cache-Control', 'no-store');
if (request.url === '/') {
response.setHeader('Content-Type', 'text/html');
response.end('<!doctype html><title>Local cryptographic compatibility test</title>');
return;
}
- const asset = assets.get(request.url || '');
- if (!asset) { response.writeHead(404); response.end(); return; }
+ const asset = assets.get((request.url ?? ''));
+ if (asset === undefined) { response.writeHead(404); response.end(); return; }
response.setHeader('Content-Type', asset[1]);
response.end(await readFile(asset[0]));
});
await new Promise<void>(resolveReady => server.listen(0, '127.0.0.1', resolveReady));
const origin = serverOrigin(server);
const browser = await chromium.launch({headless: true, channel: "chromium"});
-const deadline = setTimeout(() => browser.close(), 120000);
+const deadline = setTimeout(() => { closeBrowser(browser); }, 120000);
try {
const page = await browser.newPage();
const external: string[] = [];
@@ -50,13 +49,13 @@ try {
const result = await page.evaluate(async () => {
const host = '/wasi.mjs';
await import(host);
- const { runProbe } = globalThis.stackChatWasi;
+ const { runProbe } = stackChatWasi;
const bytes = await (await fetch('/probe.wasm', { cache: 'no-store' })).arrayBuffer();
const valid = await runProbe(bytes);
const again = await runProbe(bytes);
- const getRandomValues = crypto.getRandomValues;
+ const getRandomValues = crypto.getRandomValues.bind(crypto);
let rejected = false;
- crypto.getRandomValues = () => { throw new Error('Entropy unavailable'); };
+ crypto.getRandomValues = (): never => { throw new Error('Entropy unavailable'); };
try { await runProbe(bytes); } catch (error) { rejected = (error instanceof Error ? error.message : String(error)).includes("Entropy unavailable"); }
finally { crypto.getRandomValues = getRandomValues; }
const modified = new Uint8Array(bytes.slice(0));
@@ -80,7 +79,7 @@ try {
assert.deepEqual(await page.evaluate(async () => indexedDB.databases()), []);
assert.equal(await page.evaluate(async () => (await navigator.serviceWorker.getRegistrations()).length), 0);
console.log(result.valid.output.trim());
- console.log(`Browser CSPRNG: ${result.valid.randomCalls} calls, ${result.valid.randomBytes} bytes; missing entropy and unknown imports rejected.`);
+ console.log(`Browser CSPRNG: ${String(result.valid.randomCalls)} calls, ${String(result.valid.randomBytes)} bytes; missing entropy and unknown imports rejected.`);
console.log('PASS: actual Chromium WASM execution; no external requests, cookies, web storage, or IndexedDB.');
} finally {
clearTimeout(deadline);
diff --git a/crates/chat-server/Cargo.toml b/crates/chat-server/Cargo.toml
index 6ebb2df..9a6ef7e 100644
--- a/crates/chat-server/Cargo.toml
+++ b/crates/chat-server/Cargo.toml
@@ -31,14 +31,14 @@ stack-config = { path = "../config", version = "0.1.0" }
anyhow = "1"
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json"] }
base64 = "0.22"
-clap = { version = "4.6", features = ["derive"] }
+clap = "4.6"
hex = "0.4"
openssl = "0.10"
rusqlite = { version = "0.41", default-features = false, features = ["bundled", "backup"] }
serde_json = "1"
stack-chat-crypto = { path = "../chat-crypto", version = "0.1.0" }
tokio-util = { version = "0.7", features = ["io"] }
-tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "signal", "sync", "fs"] }
+tokio = { version = "1", features = ["rt-multi-thread", "net", "signal", "sync", "fs"] }
[dev-dependencies]
tempfile = "3"
diff --git a/crates/chat-server/src/app.rs b/crates/chat-server/src/app.rs
new file mode 100644
index 0000000..31efd28
--- /dev/null
+++ b/crates/chat-server/src/app.rs
@@ -0,0 +1,199 @@
+// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+#![doc = "Encrypted private chat server and backend maintenance commands."]
+
+mod cli;
+mod http;
+mod maintenance;
+mod service;
+mod store;
+
+use crate::app::cli::Arguments;
+use anyhow::{Context, Result, ensure};
+use std::io::{Read, Write};
+use std::net::SocketAddr;
+use std::process::ExitCode;
+
+fn initialize(root: &std::path::Path) -> Result<()> {
+ let env = root.join(".env");
+ let existing = if env.exists() {
+ std::fs::read_to_string(&env)?
+ } else {
+ String::new()
+ };
+ ensure!(
+ !existing
+ .lines()
+ .any(|line| line.starts_with("CHAT_SETUP_TOKEN=")),
+ "CHAT_SETUP_TOKEN already exists"
+ );
+ let mut file = std::fs::OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(&env)?;
+ std::fs::set_permissions(&env, std::os::unix::fs::PermissionsExt::from_mode(0o600))?;
+ writeln!(file, "\nCHAT_SETUP_TOKEN={}", service::token()?)?;
+ if !existing
+ .lines()
+ .any(|line| line.starts_with("CHAT_ORIGIN="))
+ {
+ writeln!(file, "CHAT_ORIGIN=http://127.0.0.1:9080")?;
+ }
+ stack_config::initialize_public(root)
+}
+
+fn healthcheck(mut address: SocketAddr) -> Result<()> {
+ if address.ip().is_unspecified() {
+ address.set_ip(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
+ }
+ let mut socket =
+ std::net::TcpStream::connect_timeout(&address, std::time::Duration::from_secs(2))?;
+ socket.set_read_timeout(Some(std::time::Duration::from_secs(2)))?;
+ socket.set_write_timeout(Some(std::time::Duration::from_secs(2)))?;
+ socket.write_all(b"GET /healthz HTTP/1.0\r\nHost: localhost\r\n\r\n")?;
+ let mut response = String::new();
+ socket.take(8192).read_to_string(&mut response)?;
+ ensure!(
+ response.starts_with("HTTP/1.0 200") || response.starts_with("HTTP/1.1 200"),
+ "Chat health endpoint failed"
+ );
+ Ok(())
+}
+
+fn validate_origin(
+ configuration: &stack_config::Config,
+ bind: SocketAddr,
+ proxy: bool,
+) -> Result<()> {
+ let origin = configuration.value("CHAT_ORIGIN")?;
+ if proxy {
+ let public = configuration.public_values(None)?;
+ let canonical = public
+ .get("CHAT_PUBLIC_ORIGIN")
+ .context("Missing public chat origin")?;
+ let local = format!(
+ "http://{}:{}",
+ configuration.value("CHAT_LOCAL_HOST")?,
+ configuration.port("LOCAL_HTTP_PORT")?
+ );
+ ensure!(
+ origin == canonical || origin == local,
+ "Gateway chat origin must match the configured HTTPS hostname or dedicated local hostname"
+ );
+ } else {
+ ensure!(
+ bind.ip().is_loopback() && origin == format!("http://{bind}"),
+ "Direct development chat requires an exact loopback HTTP origin"
+ );
+ }
+ Ok(())
+}
+
+async fn serve(arguments: Arguments) -> Result<()> {
+ let root = arguments.root.canonicalize()?;
+ if arguments.init {
+ return initialize(&root);
+ }
+ let configuration = if arguments.listener.proxy {
+ stack_config::Config::from_environment()?
+ } else {
+ stack_config::Config::load(&root)?
+ };
+ let bind = arguments
+ .listener
+ .bind
+ .unwrap_or(configuration.chat_bind()?);
+ if arguments.healthcheck {
+ return healthcheck(bind);
+ }
+ let data = root.join("var/data/chat");
+ std::fs::create_dir_all(&data)?;
+ std::fs::set_permissions(&data, std::os::unix::fs::PermissionsExt::from_mode(0o700))?;
+ let database = data.join("chat.sqlite");
+ let _server_lock = if arguments.snapshot.is_none() && !arguments.owner_fingerprint {
+ Some(maintenance::lock(&data)?)
+ } else {
+ None
+ };
+ let mut store = store::Store::open(&database)?;
+ std::fs::set_permissions(
+ &database,
+ std::os::unix::fs::PermissionsExt::from_mode(0o600),
+ )?;
+ if arguments.owner_fingerprint {
+ return maintenance::fingerprint(&store);
+ }
+ if let Some(expected) = arguments.reset_owner {
+ return maintenance::reset(&mut store, &expected, bind, arguments.listener.proxy);
+ }
+ if let Some(destination) = arguments.snapshot {
+ store.snapshot(&destination)?;
+ std::fs::set_permissions(
+ destination,
+ std::os::unix::fs::PermissionsExt::from_mode(0o600),
+ )?;
+ return Ok(());
+ }
+ let origin = configuration.value("CHAT_ORIGIN")?;
+ validate_origin(&configuration, bind, arguments.listener.proxy)?;
+ let setup = configuration
+ .value("CHAT_SETUP_TOKEN")
+ .context("Run --init to configure chat")?;
+ let state = service::Service::new(store, origin.to_owned(), setup.to_owned());
+ let listener = tokio::net::TcpListener::bind(bind).await?;
+ writeln!(
+ std::io::stdout().lock(),
+ "Chat ready: {origin} (owner setup: /#setup)"
+ )?;
+ let local = (arguments.listener.proxy && origin.starts_with("http://"))
+ .then(|| configuration.port("LOCAL_HTTP_PORT"))
+ .transpose()?;
+ http::serve(listener, state, &root, configuration.public_values(local)?).await
+}
+
+fn execute(arguments: Arguments) -> Result<()> {
+ let runtime = tokio::runtime::Builder::new_multi_thread()
+ .enable_all()
+ .build()?;
+ runtime.block_on(serve(arguments))
+}
+
+#[doc = "Runs the checked command-line entry point and returns its process status."]
+#[must_use]
+pub fn run() -> ExitCode {
+ let arguments = match Arguments::try_parse() {
+ Ok(arguments) => arguments,
+ Err(error) => {
+ let status = if error.use_stderr() {
+ ExitCode::from(2)
+ } else {
+ ExitCode::SUCCESS
+ };
+ drop(error.print());
+ return status;
+ }
+ };
+ match execute(arguments) {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(error) => {
+ drop(writeln!(
+ std::io::stderr().lock(),
+ "Chat server failed: {error:#}"
+ ));
+ ExitCode::FAILURE
+ }
+ }
+}
diff --git a/crates/chat-server/src/app/cli.rs b/crates/chat-server/src/app/cli.rs
new file mode 100644
index 0000000..1296df4
--- /dev/null
+++ b/crates/chat-server/src/app/cli.rs
@@ -0,0 +1,167 @@
+// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+use clap::{Arg, ArgAction, Command};
+use std::ffi::OsString;
+use std::net::SocketAddr;
+use std::path::PathBuf;
+
+#[derive(Debug)]
+
+pub(super) struct Arguments {
+ pub(super) root: PathBuf,
+
+ pub(super) listener: Listener,
+
+ pub(super) healthcheck: bool,
+
+ pub(super) init: bool,
+
+ pub(super) reset_owner: Option<String>,
+
+ pub(super) owner_fingerprint: bool,
+
+ pub(super) snapshot: Option<PathBuf>,
+}
+
+#[derive(Debug)]
+pub(super) struct Listener {
+ pub(super) bind: Option<SocketAddr>,
+
+ pub(super) proxy: bool,
+}
+
+fn flag(name: &'static str) -> Arg {
+ Arg::new(name).long(name).action(ArgAction::SetTrue)
+}
+
+fn path(name: &'static str) -> Arg {
+ Arg::new(name)
+ .long(name)
+ .value_parser(clap::builder::PathBufValueParser::new())
+}
+
+fn command() -> Command {
+ Command::new("stack-chat-server").about("Run private chat directly or behind the stack gateway")
+ .args([
+ path("root").default_value("."),
+ Arg::new("bind").long("bind").value_parser(str::parse::<SocketAddr>),
+ flag("proxy").help("Run behind the stack gateway using its explicit container environment"),
+ flag("healthcheck").help("Check the local HTTP health endpoint without opening the database"),
+ flag("init").conflicts_with_all(["reset-owner", "owner-fingerprint", "snapshot"]),
+ Arg::new("reset-owner").long("reset-owner").value_name("FINGERPRINT").conflicts_with_all(["owner-fingerprint", "snapshot"])
+ .help("Permanently clear the matching owner registration and all chat history after key loss"),
+ flag("owner-fingerprint").conflicts_with("snapshot").help("Print the registered owner's public fingerprint"),
+ path("snapshot"),
+ ])
+}
+
+impl Arguments {
+ pub(super) fn try_parse() -> Result<Self, clap::Error> {
+ Self::try_parse_from(std::env::args_os())
+ }
+
+ fn try_parse_from(
+ arguments: impl IntoIterator<Item = impl Into<OsString> + Clone>,
+ ) -> Result<Self, clap::Error> {
+ let mut values = command().try_get_matches_from(arguments)?;
+ let root = values.remove_one("root").ok_or_else(|| {
+ clap::Error::raw(
+ clap::error::ErrorKind::MissingRequiredArgument,
+ "Missing application root",
+ )
+ })?;
+ Ok(Self {
+ root,
+ listener: Listener {
+ bind: values.remove_one("bind"),
+ proxy: values.get_flag("proxy"),
+ },
+ healthcheck: values.get_flag("healthcheck"),
+ init: values.get_flag("init"),
+ reset_owner: values.remove_one("reset-owner"),
+ owner_fingerprint: values.get_flag("owner-fingerprint"),
+ snapshot: values.remove_one("snapshot"),
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{Arguments, command};
+ use anyhow::{Result, ensure};
+ use std::path::Path;
+
+ #[test]
+ fn checked_command_schema() {
+ command().debug_assert();
+ }
+
+ #[test]
+ fn listener_defaults_and_maintenance_conflicts() -> Result<()> {
+ let defaults = Arguments::try_parse_from(["stack-chat-server"])?;
+ anyhow::ensure!((defaults.root) == (Path::new(".")), "Test invariant failed");
+ anyhow::ensure!(
+ !defaults.listener.proxy && defaults.listener.bind.is_none(),
+ "Test invariant failed"
+ );
+ let parsed = Arguments::try_parse_from([
+ "stack-chat-server",
+ "--root",
+ "/tmp/stack",
+ "--bind",
+ "127.0.0.1:9180",
+ "--proxy",
+ "--healthcheck",
+ ])?;
+ anyhow::ensure!(
+ (parsed.root) == (Path::new("/tmp/stack")),
+ "Test invariant failed"
+ );
+ ensure!(
+ parsed
+ .listener
+ .bind
+ .is_some_and(|address| address.port() == 9180),
+ "Listener port differs"
+ );
+ anyhow::ensure!(
+ parsed.listener.proxy && parsed.healthcheck,
+ "Test invariant failed"
+ );
+ for args in [
+ vec!["stack-chat-server", "--bind", "garbage"],
+ vec!["stack-chat-server", "--init", "--snapshot", "snapshot"],
+ vec![
+ "stack-chat-server",
+ "--reset-owner",
+ "abc",
+ "--owner-fingerprint",
+ ],
+ vec![
+ "stack-chat-server",
+ "--owner-fingerprint",
+ "--snapshot",
+ "snapshot",
+ ],
+ ] {
+ anyhow::ensure!(
+ Arguments::try_parse_from(args).is_err(),
+ "Test invariant failed"
+ );
+ }
+ Ok(())
+ }
+}
diff --git a/crates/chat-server/src/http.rs b/crates/chat-server/src/app/http.rs
index cb21b1f..5b1941a 100644
--- a/crates/chat-server/src/http.rs
+++ b/crates/chat-server/src/app/http.rs
@@ -13,7 +13,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-use crate::service::Service;
+use crate::app::service::Service;
use anyhow::Result;
use axum::{
Json, Router,
@@ -212,7 +212,7 @@ async fn asset(State(app): State<App>, uri: axum::http::Uri) -> Response {
secured(response)
}
-pub(crate) async fn serve(
+pub(super) async fn serve(
listener: tokio::net::TcpListener,
service: Service,
root: &Path,
diff --git a/crates/chat-server/src/maintenance.rs b/crates/chat-server/src/app/maintenance.rs
index d416d73..647dff9 100644
--- a/crates/chat-server/src/maintenance.rs
+++ b/crates/chat-server/src/app/maintenance.rs
@@ -13,7 +13,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-use crate::store::Store;
+use crate::app::store::Store;
use anyhow::{Context, Result, ensure};
use std::fs::{File, OpenOptions};
use std::io::Write;
@@ -21,7 +21,7 @@ use std::net::{SocketAddr, TcpListener};
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
-pub(crate) fn lock(directory: &Path) -> Result<File> {
+pub(super) fn lock(directory: &Path) -> Result<File> {
let file = OpenOptions::new()
.read(true)
.write(true)
@@ -35,7 +35,7 @@ pub(crate) fn lock(directory: &Path) -> Result<File> {
Ok(file)
}
-pub(crate) fn fingerprint(store: &Store) -> Result<()> {
+pub(super) fn fingerprint(store: &Store) -> Result<()> {
let certificate = store.owner()?.context("No owner identity is registered")?;
writeln!(
std::io::stdout().lock(),
@@ -45,7 +45,7 @@ pub(crate) fn fingerprint(store: &Store) -> Result<()> {
Ok(())
}
-pub(crate) fn reset(
+pub(super) fn reset(
store: &mut Store,
expected: &str,
address: SocketAddr,
diff --git a/crates/chat-server/src/service.rs b/crates/chat-server/src/app/service.rs
index 3f9b6ee..d5433e7 100644
--- a/crates/chat-server/src/service.rs
+++ b/crates/chat-server/src/app/service.rs
@@ -13,7 +13,7 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-use crate::store::Store;
+use crate::app::store::Store;
use anyhow::{Context, Result, ensure};
use base64::{Engine, prelude::BASE64_STANDARD};
use serde_json::{Value, json};
@@ -32,9 +32,9 @@ struct Session {
expiry: Instant,
}
-pub(crate) struct Service {
- pub(crate) store: Store,
- pub(crate) origin: String,
+pub(super) struct Service {
+ pub(super) store: Store,
+ pub(super) origin: String,
setup_token: String,
challenges: HashMap<String, Challenge>,
sessions: HashMap<String, Session>,
@@ -42,13 +42,13 @@ pub(crate) struct Service {
attempts: u16,
}
-pub(crate) fn token() -> Result<String> {
+pub(super) fn token() -> Result<String> {
let mut bytes = [0; 32];
openssl::rand::rand_bytes(&mut bytes)?;
Ok(hex::encode(bytes))
}
-pub(crate) fn field<'a>(request: &'a Value, key: &str) -> Result<&'a str> {
+pub(super) fn field<'a>(request: &'a Value, key: &str) -> Result<&'a str> {
request
.get(key)
.and_then(Value::as_str)
@@ -73,7 +73,7 @@ fn identifier(value: &str) -> Result<()> {
}
impl Service {
- pub(crate) fn new(store: Store, origin: String, setup_token: String) -> Self {
+ pub(super) fn new(store: Store, origin: String, setup_token: String) -> Self {
Self {
store,
origin,
@@ -201,7 +201,7 @@ impl Service {
Ok(conversation.into())
}
- pub(crate) fn call(&mut self, request: &Value) -> Result<Value> {
+ pub(super) fn call(&mut self, request: &Value) -> Result<Value> {
match field(request, "op")? {
"config" => Ok(
json!({"owner":self.store.owner()?,"origin":self.origin,"protocol":"stack-chat/v1"}),
diff --git a/crates/chat-server/src/store.rs b/crates/chat-server/src/app/store.rs
index 0e09de7..23e8ba1 100644
--- a/crates/chat-server/src/store.rs
+++ b/crates/chat-server/src/app/store.rs
@@ -18,18 +18,18 @@ use rusqlite::{Connection, OptionalExtension, params};
use serde_json::{Value, json};
use std::path::Path;
-pub(crate) struct Store {
+pub(super) struct Store {
connection: Connection,
}
impl Store {
- pub(crate) fn open(path: &Path) -> Result<Self> {
+ pub(super) fn open(path: &Path) -> Result<Self> {
let connection = Connection::open(path)?;
connection.execute_batch("PRAGMA foreign_keys=ON; PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA secure_delete=ON; CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS conversations (id TEXT PRIMARY KEY, cert TEXT NOT NULL, profile TEXT); CREATE TABLE IF NOT EXISTS messages (seq INTEGER PRIMARY KEY AUTOINCREMENT, conversation TEXT NOT NULL REFERENCES conversations(id), id TEXT NOT NULL UNIQUE, sender TEXT NOT NULL, ciphertext TEXT NOT NULL);")?;
Ok(Self { connection })
}
- pub(crate) fn owner(&self) -> Result<Option<String>> {
+ pub(super) fn owner(&self) -> Result<Option<String>> {
Ok(self
.connection
.query_row("SELECT value FROM metadata WHERE key='owner'", [], |row| {
@@ -38,14 +38,14 @@ impl Store {
.optional()?)
}
- pub(crate) fn install_owner(&self, cert: &str) -> Result<()> {
+ pub(super) fn install_owner(&self, cert: &str) -> Result<()> {
ensure!(self.owner()?.is_none(), "Already initialized");
self.connection
.execute("INSERT INTO metadata VALUES ('owner', ?1)", [cert])?;
Ok(())
}
- pub(crate) fn reset_owner(&mut self, expected: &str) -> Result<()> {
+ pub(super) fn reset_owner(&mut self, expected: &str) -> Result<()> {
let owner = self.owner()?.context("No owner identity is registered")?;
let fingerprint = stack_chat_crypto::public_identity(&owner)?;
ensure!(
@@ -58,7 +58,7 @@ impl Store {
Ok(())
}
- pub(crate) fn register(&self, id: &str, cert: &str) -> Result<()> {
+ pub(super) fn register(&self, id: &str, cert: &str) -> Result<()> {
if self.connection.query_row(
"SELECT EXISTS(SELECT 1 FROM conversations WHERE id=?1)",
[id],
@@ -77,7 +77,7 @@ impl Store {
Ok(())
}
- pub(crate) fn conversation(&self, id: &str) -> Result<Value> {
+ pub(super) fn conversation(&self, id: &str) -> Result<Value> {
let (cert, profile): (String, Option<String>) = self.connection.query_row(
"SELECT cert,profile FROM conversations WHERE id=?1",
[id],
@@ -93,7 +93,7 @@ impl Store {
)
}
- pub(crate) fn inbox(&self) -> Result<Value> {
+ pub(super) fn inbox(&self) -> Result<Value> {
let mut statement = self
.connection
.prepare("SELECT id FROM conversations ORDER BY id")?;
@@ -107,7 +107,7 @@ impl Store {
Ok(json!({"conversations":conversations}))
}
- pub(crate) fn profile(&self, id: &str, ciphertext: &str) -> Result<()> {
+ pub(super) fn profile(&self, id: &str, ciphertext: &str) -> Result<()> {
self.connection.execute(
"UPDATE conversations SET profile=?2 WHERE id=?1 AND profile IS NULL",
params![id, ciphertext],
@@ -115,7 +115,7 @@ impl Store {
Ok(())
}
- pub(crate) fn messages(&self, id: &str, count: u64, before: u64) -> Result<Value> {
+ pub(super) fn messages(&self, id: &str, count: u64, before: u64) -> Result<Value> {
ensure!(
count <= 50 && before <= i64::MAX.unsigned_abs(),
"Invalid history window"
@@ -125,7 +125,7 @@ impl Store {
Ok(json!({"conversation":self.conversation(id)?,"messages":messages}))
}
- pub(crate) fn send(
+ pub(super) fn send(
&mut self,
conversation: &str,
id: &str,
@@ -173,7 +173,7 @@ impl Store {
Ok(json!({"seq":sequence,"conversation":self.conversation(conversation)?}))
}
- pub(crate) fn snapshot(&self, destination: &Path) -> Result<()> {
+ pub(super) fn snapshot(&self, destination: &Path) -> Result<()> {
ensure!(!destination.exists(), "Snapshot destination already exists");
self.connection
.backup("main", destination, None::<fn(rusqlite::backup::Progress)>)?;
diff --git a/crates/chat-server/src/main.rs b/crates/chat-server/src/main.rs
index 1d600b7..942a379 100644
--- a/crates/chat-server/src/main.rs
+++ b/crates/chat-server/src/main.rs
@@ -13,204 +13,12 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-#![doc = "Encrypted private chat server and backend maintenance commands."]
+#![doc = "Checked command-line entry point for the personal infrastructure stack."]
-mod http;
-mod maintenance;
-mod service;
-mod store;
+mod app;
-use anyhow::{Context, Result, ensure};
-use clap::Parser;
-use std::io::{Read, Write};
-use std::net::SocketAddr;
-use std::path::PathBuf;
-use std::process::ExitCode;
+pub use app::run;
-#[derive(Debug, Parser)]
-#[command(about = "Run private chat directly or behind the stack gateway")]
-struct Arguments {
- #[arg(long, default_value = ".")]
- root: PathBuf,
- #[command(flatten)]
- listener: Listener,
- #[arg(
- long,
- help = "Check the local HTTP health endpoint without opening the database"
- )]
- healthcheck: bool,
- #[arg(long, conflicts_with_all = ["reset_owner", "owner_fingerprint", "snapshot"])]
- init: bool,
- #[arg(long, value_name = "FINGERPRINT", conflicts_with_all = ["owner_fingerprint", "snapshot"], help = "Permanently clear the matching owner registration and all chat history after key loss")]
- reset_owner: Option<String>,
- #[arg(
- long,
- conflicts_with = "snapshot",
- help = "Print the registered owner's public fingerprint"
- )]
- owner_fingerprint: bool,
- #[arg(long)]
- snapshot: Option<PathBuf>,
-}
-
-#[derive(Debug, clap::Args)]
-struct Listener {
- #[arg(long)]
- bind: Option<SocketAddr>,
- #[arg(
- long,
- help = "Run behind the stack gateway using its explicit container environment"
- )]
- proxy: bool,
-}
-
-fn initialize(root: &std::path::Path) -> Result<()> {
- let env = root.join(".env");
- let existing = if env.exists() {
- std::fs::read_to_string(&env)?
- } else {
- String::new()
- };
- ensure!(
- !existing
- .lines()
- .any(|line| line.starts_with("CHAT_SETUP_TOKEN=")),
- "CHAT_SETUP_TOKEN already exists"
- );
- let mut file = std::fs::OpenOptions::new()
- .create(true)
- .append(true)
- .open(&env)?;
- std::fs::set_permissions(&env, std::os::unix::fs::PermissionsExt::from_mode(0o600))?;
- writeln!(file, "\nCHAT_SETUP_TOKEN={}", service::token()?)?;
- if !existing
- .lines()
- .any(|line| line.starts_with("CHAT_ORIGIN="))
- {
- writeln!(file, "CHAT_ORIGIN=http://127.0.0.1:9080")?;
- }
- stack_config::initialize_public(root)
-}
-
-fn healthcheck(mut address: SocketAddr) -> Result<()> {
- if address.ip().is_unspecified() {
- address.set_ip(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
- }
- let mut socket =
- std::net::TcpStream::connect_timeout(&address, std::time::Duration::from_secs(2))?;
- socket.set_read_timeout(Some(std::time::Duration::from_secs(2)))?;
- socket.set_write_timeout(Some(std::time::Duration::from_secs(2)))?;
- socket.write_all(b"GET /healthz HTTP/1.0\r\nHost: localhost\r\n\r\n")?;
- let mut response = String::new();
- socket.take(8192).read_to_string(&mut response)?;
- ensure!(
- response.starts_with("HTTP/1.0 200") || response.starts_with("HTTP/1.1 200"),
- "Chat health endpoint failed"
- );
- Ok(())
-}
-
-fn validate_origin(
- configuration: &stack_config::Config,
- bind: SocketAddr,
- proxy: bool,
-) -> Result<()> {
- let origin = configuration.value("CHAT_ORIGIN")?;
- if proxy {
- let public = configuration.public_values(None)?;
- let canonical = public
- .get("CHAT_PUBLIC_ORIGIN")
- .context("Missing public chat origin")?;
- let local = format!(
- "http://{}:{}",
- configuration.value("CHAT_LOCAL_HOST")?,
- configuration.port("LOCAL_HTTP_PORT")?
- );
- ensure!(
- origin == canonical || origin == local,
- "Gateway chat origin must match the configured HTTPS hostname or dedicated local hostname"
- );
- } else {
- ensure!(
- bind.ip().is_loopback() && origin == format!("http://{bind}"),
- "Direct development chat requires an exact loopback HTTP origin"
- );
- }
- Ok(())
-}
-
-async fn run(arguments: Arguments) -> Result<()> {
- let root = arguments.root.canonicalize()?;
- if arguments.init {
- return initialize(&root);
- }
- let configuration = if arguments.listener.proxy {
- stack_config::Config::from_environment()?
- } else {
- stack_config::Config::load(&root)?
- };
- let bind = arguments
- .listener
- .bind
- .unwrap_or(configuration.chat_bind()?);
- if arguments.healthcheck {
- return healthcheck(bind);
- }
- let data = root.join("var/data/chat");
- std::fs::create_dir_all(&data)?;
- std::fs::set_permissions(&data, std::os::unix::fs::PermissionsExt::from_mode(0o700))?;
- let database = data.join("chat.sqlite");
- let _server_lock = if arguments.snapshot.is_none() && !arguments.owner_fingerprint {
- Some(maintenance::lock(&data)?)
- } else {
- None
- };
- let mut store = store::Store::open(&database)?;
- std::fs::set_permissions(
- &database,
- std::os::unix::fs::PermissionsExt::from_mode(0o600),
- )?;
- if arguments.owner_fingerprint {
- return maintenance::fingerprint(&store);
- }
- if let Some(expected) = arguments.reset_owner {
- return maintenance::reset(&mut store, &expected, bind, arguments.listener.proxy);
- }
- if let Some(destination) = arguments.snapshot {
- store.snapshot(&destination)?;
- std::fs::set_permissions(
- destination,
- std::os::unix::fs::PermissionsExt::from_mode(0o600),
- )?;
- return Ok(());
- }
- let origin = configuration.value("CHAT_ORIGIN")?;
- validate_origin(&configuration, bind, arguments.listener.proxy)?;
- let setup = configuration
- .value("CHAT_SETUP_TOKEN")
- .context("Run --init to configure chat")?;
- let state = service::Service::new(store, origin.to_owned(), setup.to_owned());
- let listener = tokio::net::TcpListener::bind(bind).await?;
- writeln!(
- std::io::stdout().lock(),
- "Chat ready: {origin} (owner setup: /#setup)"
- )?;
- let local = (arguments.listener.proxy && origin.starts_with("http://"))
- .then(|| configuration.port("LOCAL_HTTP_PORT"))
- .transpose()?;
- http::serve(listener, state, &root, configuration.public_values(local)?).await
-}
-
-#[tokio::main]
-async fn main() -> ExitCode {
- match run(Arguments::parse()).await {
- Ok(()) => ExitCode::SUCCESS,
- Err(error) => {
- drop(writeln!(
- std::io::stderr().lock(),
- "Chat server failed: {error:#}"
- ));
- ExitCode::FAILURE
- }
- }
+fn main() -> std::process::ExitCode {
+ run()
}
diff --git a/crates/chat-server/tests/browser.ts b/crates/chat-server/tests/browser.ts
index 74a9df2..468a5f5 100644
--- a/crates/chat-server/tests/browser.ts
+++ b/crates/chat-server/tests/browser.ts
@@ -19,20 +19,20 @@ import { mkdtemp, readFile, writeFile, symlink, rm, mkdir, rename, stat, readdir
import { tmpdir } from 'node:os';
import { resolve, join } from 'node:path';
import { randomBytes } from 'node:crypto';
-import { spawn, execFileSync, spawnSync, execFile } from 'node:child_process';
+import { spawn, execFileSync, spawnSync, execFile, type SpawnSyncReturns } from 'node:child_process';
import { promisify } from 'node:util';
import { chromium, firefox, type Browser, type BrowserContext, type Page } from 'playwright';
-import { libreJSAddon, list, record, requestBody, required, serverOrigin, string } from '../../../tests/browser-support.js';
+import { closeBrowser, libreJSAddon, list, record, requestBody, required, serverOrigin, string } from '../../../tests/browser-support.js';
const run = promisify(execFile);
const testRoot = process.env['CHAT_TEST_ROOT'];
const packaged = Boolean(testRoot);
-let root = testRoot ? resolve(testRoot) : await mkdtemp(join(tmpdir(), 'stack-chat-e2e-'));
+let root = testRoot !== undefined && testRoot.length > 0 ? resolve(testRoot) : await mkdtemp(join(tmpdir(), 'stack-chat-e2e-'));
const initialRoot = root;
const token = packaged
- ? (await readFile(join(root,'.env'),'utf8')).split('\n').find(line => line.startsWith('CHAT_SETUP_TOKEN='))?.slice('CHAT_SETUP_TOKEN='.length) || ''
+ ? ((await readFile(join(root,'.env'),'utf8')).split('\n').find(line => line.startsWith('CHAT_SETUP_TOKEN='))?.slice('CHAT_SETUP_TOKEN='.length) ?? '')
: randomBytes(32).toString('hex');
-const origin = process.env['CHAT_TEST_ORIGIN'] || `http://127.0.0.1:${process.env['CHAT_TEST_PORT'] || '9081'}`;
+const origin = (process.env['CHAT_TEST_ORIGIN'] ?? `http://127.0.0.1:${(process.env['CHAT_TEST_PORT'] ?? '9081')}`);
if (packaged) {
const fixture = await readFile(join(root,'.env'),'utf8');
const address = new URL(origin);
@@ -53,32 +53,32 @@ if (packaged) {
await mkdir(join(root,'var'), {recursive:true});
await symlink(resolve('var/build'),join(root,'var/build'));
}
-async function stack(...args: string[]) {
+async function stack(...args: string[]): Promise<{ stdout: string; stderr: string; }> {
return run(join(root,'bin/stack'),args,{cwd:root,env:{...process.env,GNUPGHOME:gpgHome},maxBuffer:8*1024*1024,timeout:300000});
}
-async function restorePackage() {
+async function restorePackage(): Promise<void> {
const listed = await run('gpg',['--no-options','--homedir',gpgHome,'--with-colons','--list-keys']);
const fingerprint = required(required(listed.stdout.split('\n').find(line => line.startsWith('fpr:')), 'Missing key fingerprint').split(':')[9], 'Missing key fingerprint field');
const key = join(testHome,'recipient.asc');
const exported = await run('gpg',['--no-options','--homedir',gpgHome,'--armor','--export',fingerprint]);
await writeFile(key,exported.stdout);
const backup = await stack('backup','--include-images','--recipient',fingerprint,'--key',key);
- const archive = backup.stdout.match(/Encrypted export: (.+)/)?.[1];
- assert.ok(archive,backup.stdout);
+ const archive = (/Encrypted export: (.+)/.exec(backup.stdout))?.[1];
+ assert.ok(archive !== undefined && archive.length > 0,backup.stdout);
assert.equal((await stat(archive)).mode & 0o777,0o600);
assert.equal((await stat(archive + '.sha256')).mode & 0o777,0o600);
await stopServer();
- const destination = `${initialRoot}-restored-${++restorations}`;
+ const destination = `${initialRoot}-restored-${String(++restorations)}`;
await stack('restore',archive,destination);
root = destination;
assert.equal(await readFile(join(root,'.env'),'utf8'),await readFile(join(initialRoot,'.env'),'utf8'));
for (const name of ['target','.tools']) assert.equal(spawnSync('test',['-e',join(root,name)]).status,1);
await launchServer();
- console.log(`PASS: compiler-free HTTPS stack recovered from encrypted export ${restorations}.`);
+ console.log(`PASS: compiler-free HTTPS stack recovered from encrypted export ${String(restorations)}.`);
}
let server: ReturnType<typeof spawn> | undefined;
let serverErrors = '';
-async function verifyGateway() {
+async function verifyGateway(): Promise<void> {
const url = new URL(origin);
const args = ['--noproxy','*','--silent','--show-error','--fail'];
if (url.hostname.endsWith('.localhost')) {
@@ -90,7 +90,7 @@ async function verifyGateway() {
const health = await run('curl',[...args,origin + '/healthz']);
assert.equal(health.stdout,'ok\n');
}
-async function launchServer() {
+async function launchServer(): Promise<void> {
if (packaged) {
await stack('up','--production');
let failure;
@@ -101,17 +101,20 @@ async function launchServer() {
throw failure;
}
server = spawn(resolve('target/debug/stack-chat-server'), ['--root',root,'--bind',new URL(origin).host], {stdio:['ignore','pipe','pipe']});
- server.stderr?.on('data', bytes => { serverErrors += bytes.toString(); });
+ server.stderr?.on('data', (bytes: unknown) => {
+ if (!Buffer.isBuffer(bytes)) throw new Error('Expected server diagnostics as bytes');
+ serverErrors += bytes.toString();
+ });
await Promise.race([
- new Promise<void>((ready, reject) => { server?.stdout?.once('data', () => ready()); server?.once('exit', () => reject(new Error(serverErrors || 'Server exited'))); }),
- new Promise((_, reject) => setTimeout(() => reject(new Error('Server did not start')), 20000).unref()),
+ new Promise<void>((ready, reject) => { server?.stdout?.once('data', () => { ready(); }); server?.once('exit', () => { reject(new Error((serverErrors.length > 0 ? serverErrors : 'Server exited'))); }); }),
+ new Promise((_, reject) => setTimeout(() => { reject(new Error('Server did not start')); }, 20000).unref()),
]);
}
-async function stopServer() {
+async function stopServer(): Promise<void> {
if (packaged) { await stack('down'); return; }
- if (!server) return;
+ if (server === undefined) return;
if (server.exitCode !== null || server.signalCode !== null) return;
- const stopped = new Promise<void>(done => server?.once('exit', () => done()));
+ const stopped = new Promise<void>(done => server?.once('exit', () => { done(); }));
server.kill('SIGINT');
await stopped;
}
@@ -128,21 +131,21 @@ if (libre) {
'extensions.update.enabled':false,'app.update.auto':false,'datareporting.healthreport.uploadEnabled':false,
'browser.sessionstore.resume_from_crash':false,
}});
- browser = { close: () => required(persistent, 'Missing Firefox context').close() };
+ browser = { close: (): Promise<void> => required(persistent, 'Missing Firefox context').close() };
} else { browser = await chromium.launch({headless:true,channel:'chromium'}); }
-const deadline = setTimeout(() => browser.close(), 300000);
+const deadline = setTimeout(() => { closeBrowser(browser); }, 300000);
const contexts: BrowserContext[] = [];
const external: string[] = [];
const errors: string[] = [];
const bodies: ReturnType<typeof requestBody>[] = [];
-async function page() {
- const context = required(persistent || await ('newContext' in browser ? browser.newContext({acceptDownloads:true,ignoreHTTPSErrors:packaged}) : Promise.reject(new Error('Missing browser context'))), 'Missing browser context');
+async function page(): Promise<Page> {
+ const context = required(persistent ?? await ('newContext' in browser ? browser.newContext({acceptDownloads:true,ignoreHTTPSErrors:packaged}) : Promise.reject(new Error('Missing browser context'))), 'Missing browser context');
contexts.push(context);
const page = await context.newPage();
page.setDefaultTimeout(90000);
page.on('pageerror', error => errors.push(error.message));
const navigate = page.goto.bind(page);
- page.goto = async (...args: Parameters<Page['goto']>) => {
+ page.goto = async (...args: Parameters<Page['goto']>): ReturnType<Page['goto']> => {
for (let attempt = 0; ; attempt += 1) {
try { return await navigate(...args); }
catch (error) { if (attempt >= 2 || !(error instanceof Error && error.message.includes('ERR_NETWORK_CHANGED'))) throw error; }
@@ -155,15 +158,15 @@ async function page() {
});
return page;
}
-async function waitEntry(page: Page) { await page.locator('#create-choice').waitFor({state:'visible'}); }
-async function waitStatus(page: Page, text: string) { await page.waitForFunction(value => document.getElementById('status')?.textContent?.includes(value), text); }
-async function create(page: Page, name: string, owner = false) {
+async function waitEntry(page: Page): Promise<void> { await page.locator('#create-choice').waitFor({state:'visible'}); }
+async function waitStatus(page: Page, text: string): Promise<void> { await page.waitForFunction(value => document.getElementById('status')?.textContent.includes(value), text); }
+async function create(page: Page, name: string, owner = false): Promise<{ name: string; mimeType: string; buffer: Buffer; }> {
const previousAuthentications = bodies.filter(body => body.op === 'authenticate').length;
if (owner) await page.fill('#setup-token', token); else await page.click('#create-choice');
await page.fill('#name', name);
await page.fill('#new-password', password);
await page.fill('#confirm-password', password);
- const downloaded = Promise.race([page.waitForEvent('download'), page.waitForFunction(() => /failed|timed out/i.test(document.getElementById('status')?.textContent || '')).then(async () => { throw new Error(await page.locator('#status').textContent() || 'Missing status'); })]);
+ const downloaded = Promise.race([page.waitForEvent('download'), page.waitForFunction(() => /failed|timed out/i.test((document.getElementById('status')?.textContent ?? ''))).then(async () => { throw new Error((await page.locator('#status').textContent() ?? 'Missing status')); })]);
await page.click('#create-form button');
const download = await downloaded;
const bytes = await readFile(required(await download.path(), 'Missing download'));
@@ -180,18 +183,18 @@ async function create(page: Page, name: string, owner = false) {
assert.equal(bodies.filter(body => body.op === 'authenticate').length,previousAuthentications);
return {name:'identity.chat',mimeType:'application/octet-stream',buffer:bytes};
}
-async function unlock(page: Page, file: { name: string; mimeType: string; buffer: Buffer }, phrase = password) {
+async function unlock(page: Page, file: { name: string; mimeType: string; buffer: Buffer }, phrase = password): Promise<void> {
await page.setInputFiles('#file', file);
await page.fill('#password', phrase);
await page.click('#unlock-form button');
}
-async function storageEmpty(page: Page) {
+async function storageEmpty(page: Page): Promise<void> {
assert.equal(await page.evaluate(() => localStorage.length + sessionStorage.length),0);
assert.deepEqual(await page.evaluate(() => indexedDB.databases()),[]);
assert.equal(await page.evaluate(async () => (await navigator.serviceWorker.getRegistrations()).length),0);
assert.deepEqual(await page.context().cookies(),[]);
}
-async function verifyLibreJS() {
+async function verifyLibreJS(): Promise<void> {
let executed = false;
const controlServer = createServer((request,response) => {
response.setHeader('Cache-Control','no-store');
@@ -221,12 +224,15 @@ try {
await malformed.close();
const owner = await page();
const response = await owner.goto(origin);
- await owner.locator('#owner-choice').waitFor({state:'visible'}).catch(async error => { throw new Error(`${error.message}; status=${await owner.locator('#status').textContent()}`); });
+ await owner.locator('#owner-choice').waitFor({state:'visible'}).catch(async (error: unknown) => {
+ const currentStatus = await owner.locator('#status').textContent();
+ throw new Error(`${String(error)}; status=${currentStatus ?? ''}`, { cause: error });
+ });
await owner.click('#owner-choice');
- assert.match(required(response, 'Missing response').headers()['cache-control'] || '', /no-store/);
- assert.match(required(response, 'Missing response').headers()['content-security-policy'] || '', /frame-ancestors 'none'/);
+ assert.match((required(response, 'Missing response').headers()['cache-control'] ?? ''), /no-store/);
+ assert.match((required(response, 'Missing response').headers()['content-security-policy'] ?? ''), /frame-ancestors 'none'/);
await owner.locator('#setup-token').waitFor({state:'visible'});
- assert.match(await owner.locator('#create-title').textContent() || '',/owner only/);
+ assert.match((await owner.locator('#create-title').textContent() ?? ''),/owner only/);
assert.equal(await owner.locator('.introduction h1').count(),0);
assert.equal(await owner.locator('.libre-badge a[rel=jslicense]').count(),1);
assert.equal(await owner.locator('.source-footer a[rel=jslicense]').count(),0);
@@ -234,7 +240,7 @@ try {
const ownerFile = await create(owner,'Doan',true);
await unlock(owner,ownerFile);
await waitStatus(owner,'Unlocked in memory only');
- assert.match(await owner.locator('#who').textContent() || '', /OWNER/);
+ assert.match((await owner.locator('#who').textContent() ?? ''), /OWNER/);
const visitor = await page();
await visitor.goto(origin);
await waitEntry(visitor);
@@ -246,7 +252,7 @@ try {
await unlock(visitor,visitorFile);
await waitStatus(visitor,'Unlocked in memory only');
assert.equal(await visitor.locator('#messages li').count(),0);
- assert.match(await visitor.locator('#counts').textContent() || '',/Total: 0/);
+ assert.match((await visitor.locator('#counts').textContent() ?? ''),/Total: 0/);
const malicious = '<script>window.chatInjection = true</script> hello λ';
await visitor.fill('#text',malicious);
await visitor.click('#send-form button');
@@ -254,7 +260,7 @@ try {
await owner.click('#refresh-inbox');
await owner.locator('#conversations button').first().click();
await waitStatus(owner,'Conversation opened');
- assert.match(await owner.locator('#thread-name').textContent() || '',/A friend/);
+ assert.match((await owner.locator('#thread-name').textContent() ?? ''),/A friend/);
assert.equal(await owner.locator('#messages li').count(),0);
await owner.route('**/api', async route => {
const body = requestBody(required(route.request().postData(), 'Missing API body'));
@@ -280,7 +286,7 @@ try {
await visitor.click('#history-form button');
await waitStatus(visitor,'Selected history decrypted');
assert.equal(await visitor.locator('#messages li').count(),2);
- assert.match(await visitor.locator('#counts').textContent() || '',/Total: 2 \| Sent: 1 \| Received: 1/);
+ assert.match((await visitor.locator('#counts').textContent() ?? ''),/Total: 2 \| Sent: 1 \| Received: 1/);
let interrupted = false;
await visitor.route('**/api',async route => {
const body = requestBody(required(route.request().postData(), 'Missing API request'));
@@ -290,10 +296,10 @@ try {
await visitor.fill('#text','Safe retry after a lost response');
await visitor.click('#send-form button');
await visitor.locator('#retry').waitFor({state:'visible'});
- await visitor.waitForFunction(() => !document.querySelector<HTMLButtonElement>('#retry')?.disabled);
+ await visitor.waitForFunction(() => !(document.querySelector<HTMLButtonElement>('#retry')?.disabled === true));
await visitor.click('#retry');
await waitStatus(visitor,'Encrypted message stored');
- assert.match(await visitor.locator('#counts').textContent() || '',/Total: 3/);
+ assert.match((await visitor.locator('#counts').textContent() ?? ''),/Total: 3/);
await visitor.reload();
await waitEntry(visitor);
assert.equal(await visitor.locator('#messages li').count(),0);
@@ -302,12 +308,12 @@ try {
await visitor.click('#open-choice');
await unlock(visitor,visitorFile);
await waitStatus(visitor,'Unlocked in memory only');
- assert.match(await visitor.locator('#counts').textContent() || '',/Total: 3/);
+ assert.match((await visitor.locator('#counts').textContent() ?? ''),/Total: 3/);
assert.equal(await visitor.locator('#messages li').count(),0);
await visitor.fill('#history-count','1');
await visitor.click('#history-form button');
await waitStatus(visitor,'Selected history decrypted');
- assert.match(await visitor.locator('#messages p').textContent() || '',/Safe retry/);
+ assert.match((await visitor.locator('#messages p').textContent() ?? ''),/Safe retry/);
await visitor.click('#older');
await waitStatus(visitor,'Selected history decrypted');
assert.equal(await visitor.locator('#messages li').count(),2);
@@ -329,7 +335,7 @@ try {
await visitor.click('#open-choice');
await unlock(visitor,visitorFile);
await waitStatus(visitor,'Unlocked in memory only');
- assert.match(await visitor.locator('#counts').textContent() || '',/Total: 3/);
+ assert.match((await visitor.locator('#counts').textContent() ?? ''),/Total: 3/);
await visitor.fill('#history-count','3');
await visitor.click('#history-form button');
await waitStatus(visitor,'Selected history decrypted');
@@ -342,7 +348,7 @@ try {
await owner.click('#owner-open');
await unlock(owner,ownerFile);
await waitStatus(owner,'Unlocked in memory only');
- assert.match(await owner.locator('#who').textContent() || '',/OWNER/);
+ assert.match((await owner.locator('#who').textContent() ?? ''),/OWNER/);
await visitor.fill('#history-count','3');
await visitor.click('#history-form button');
await visitor.click('#lock');
@@ -358,7 +364,7 @@ try {
await unlock(visitor,visitorFile);
await waitStatus(visitor,'Unlocked in memory only');
for (let index = 1; index <= 51; index += 1) {
- await visitor.fill('#text',`Batch fixture ${index}`);
+ await visitor.fill('#text',`Batch fixture ${String(index)}`);
await visitor.click('#send-form button');
await waitStatus(visitor,'Encrypted message stored');
}
@@ -392,7 +398,7 @@ try {
}
const blocked = await visitor.request.post(origin + '/api',{data:{op:'config'},headers:{Origin:'https://foreign.invalid'}});
assert.equal(blocked.status(),403);
- const administrative = (...args: string[]) => packaged
+ const administrative = (...args: string[]): SpawnSyncReturns<string> => packaged
? spawnSync(join(root,'bin/stack'),['chat',...args],{cwd:root,encoding:'utf8'})
: spawnSync(resolve('target/debug/stack-chat-server'),['--root',root,'--bind',new URL(origin).host,...args],{encoding:'utf8'});
const registered = administrative('--owner-fingerprint');
@@ -426,10 +432,10 @@ try {
await waitStatus(visitor,'Pinned owner key differs');
assert.equal(await visitor.locator('#chat').isVisible(),false);
assert.equal(serverErrors,'');
- if (process.env['CHAT_SCREENSHOT']) await visitor.screenshot({path:resolve('.tools/chat-preview.png'),fullPage:true});
+ if (((process.env['CHAT_SCREENSHOT'] ?? '').length > 0)) await visitor.screenshot({path:resolve('.tools/chat-preview.png'),fullPage:true});
if (libre) {
const addon = libreJSAddon(await readFile(join(testHome,'firefox-profile','extensions.json'),'utf8'));
- assert.ok(addon?.active,'LibreJS must actually be active');
+ assert.ok(addon.active,'LibreJS must actually be active');
console.log(`LibreJS ${addon.version} is active in Firefox; complete chat workflow succeeded.`);
}
console.log('PASS: real browser/WASI owner setup, protected file recovery, wrong password, visitor/owner encrypted messages, safe text rendering, N=0, counts, dynamic 0-to-total selection and 50-row batching, pagination, retry deduplication, reload, live SQLite snapshot restoration and both identity recoveries, explicit lock, owner setup/access, backend-only reset guards and fresh registration, old key-pin rejection, origin rejection, malformed configuration and history rejection, no browser storage or external requests, no private uploads or database plaintext.');
diff --git a/crates/chat-server/tests/progress.ts b/crates/chat-server/tests/progress.ts
index 59c939d..610488c 100644
--- a/crates/chat-server/tests/progress.ts
+++ b/crates/chat-server/tests/progress.ts
@@ -14,12 +14,12 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import assert from 'node:assert/strict';
-import { createServer, type ServerResponse } from 'node:http';
+import type { ServerResponse } from 'node:http';
import { readFile, writeFile, mkdir, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { chromium, firefox, type BrowserContext, type Page } from 'playwright';
-import { libreJSAddon, required, serverOrigin } from '../../../tests/browser-support.js';
+import { asyncServer, libreJSAddon, required, serverOrigin } from '../../../tests/browser-support.js';
const libre = process.env['CHAT_LIBREJS'] === '1';
const profile = await mkdtemp(join(tmpdir(),'stack-chat-progress-'));
@@ -31,7 +31,7 @@ let configReady: Promise<void> | undefined;
let signalConfig: (() => void) | undefined;
let origin = '';
const errors: string[] = [];
-const server = createServer(async (request,response) => {
+const server = asyncServer(async (request,response) => {
response.setHeader('Cache-Control','no-store');
if (request.url === '/crypto.wasm') {
if (mode === 'failure') { response.writeHead(503); response.end(); return; }
@@ -43,9 +43,9 @@ const server = createServer(async (request,response) => {
}
if (request.url === '/api') { configuration = response; required(signalConfig, 'Missing configuration signal')(); return; }
const assets: Record<string, string> = { '/':'crates/chat-server/web/index.html','/app.js':'var/build/typescript/crates/chat-server/web/app.js','/style.css':'crates/chat-server/web/style.css' };
- const path = assets[request.url || ''];
- if (!path) { response.writeHead(404); response.end(); return; }
- const mime = request.url === '/' ? 'text/html' : request.url?.endsWith('.js') ? 'text/javascript' : 'text/css';
+ const path = assets[(request.url ?? '')];
+ if (path === undefined || path.length === 0) { response.writeHead(404); response.end(); return; }
+ const mime = request.url === '/' ? 'text/html' : (request.url?.endsWith('.js') === true) ? 'text/javascript' : 'text/css';
response.setHeader('Content-Type',mime);
let source = await readFile(resolve(path),'utf8');
if (request.url === '/') source = source.replaceAll('{{SITE_ORIGIN}}',origin);
@@ -60,12 +60,12 @@ if (libre) {
const addon = await readFile(resolve('.tools/librejs.xpi'));
await writeFile(join(profile,'extensions','jid1-KtlZuoiikVfFew@jetpack.xpi'),addon);
context = await firefox.launchPersistentContext(profile,{headless:true,firefoxUserPrefs:{'extensions.autoDisableScopes':0,'extensions.enabledScopes':15,'extensions.startupScanScopes':15,'extensions.update.enabled':false}});
- browser = {close:() => context.close()};
+ browser = {close:(): Promise<void> => context.close()};
} else {
browser = await chromium.launch({headless:true,channel:'chromium'});
context = await browser.newContext();
}
-async function open(selected: 'known' | 'unknown' | 'failure') {
+async function open(selected: 'known' | 'unknown' | 'failure'): Promise<Page> {
mode = selected;
configuration = undefined;
configReady = new Promise(done => { signalConfig = done; });
@@ -75,11 +75,11 @@ async function open(selected: 'known' | 'unknown' | 'failure') {
await page.goto(origin);
return page;
}
-async function waitStatus(page: Page, part: string) {
- await page.waitForFunction(value => document.getElementById('status')?.textContent?.includes(value),part);
- return await page.locator('#status').textContent() || '';
+async function waitStatus(page: Page, part: string): Promise<string> {
+ await page.waitForFunction(value => document.getElementById('status')?.textContent.includes(value),part);
+ return (await page.locator('#status').textContent() ?? '');
}
-async function complete(page: Page, offset: number) {
+async function complete(page: Page, offset: number): Promise<void> {
required(transfer, 'Missing transfer').end(bytes.subarray(offset));
await waitStatus(page,'Connecting to chat');
await configReady;
@@ -98,7 +98,7 @@ try {
assert.match(await waitStatus(known,'2.0 /'),/39%/);
await known.screenshot({path:resolve('.tools/chat-download-progress.png'),fullPage:true});
await known.clock.fastForward(44000);
- assert.ok(!(await known.locator('#status').textContent() || '').includes('stalled'));
+ assert.ok(!((await known.locator('#status').textContent() ?? '')).includes('stalled'));
await complete(known,2097152);
const unknown = await open('unknown');
assert.equal(await waitStatus(unknown,'1.0 MiB received'),'Downloading cryptographic module... 1.0 MiB received');
@@ -111,7 +111,7 @@ try {
await waitStatus(stalled,'1.0 /');
await stalled.clock.fastForward(45001);
await waitStatus(stalled,'stalled for 45 seconds');
- assert.match(await stalled.locator('#status').textContent() || '',/reload/);
+ assert.match((await stalled.locator('#status').textContent() ?? ''),/reload/);
assert.equal(await stalled.locator('#owner-choice').isVisible(),false);
await stalled.close();
assert.deepEqual(errors,[]);
diff --git a/crates/chat-server/web/app.ts b/crates/chat-server/web/app.ts
index 79a827e..de3abb7 100644
--- a/crates/chat-server/web/app.ts
+++ b/crates/chat-server/web/app.ts
@@ -17,7 +17,7 @@
function element(id: string): HTMLElement {
const value = document.getElementById(id);
- if (!value) throw new Error(`Missing interface element: ${id}`);
+ if (!(value !== null)) throw new Error(`Missing interface element: ${id}`);
return value;
}
@@ -57,23 +57,28 @@ const workers = new Map<Worker, () => void>();
const blobs = new Set<string>();
function configuration(): StackChat.Configuration {
- if (!config) throw new Error('Chat configuration is not loaded');
+ if (config === undefined) throw new Error('Chat configuration is not loaded');
return config;
}
+function hasOwner(): boolean {
+ const owner = config?.owner;
+ return owner !== undefined && owner !== null && owner.length > 0;
+}
+
function ownerKey(): string {
const owner = configuration().owner;
- if (!owner) throw new Error('Owner setup is required first');
+ if (owner === null || owner.length === 0) throw new Error('Owner setup is required first');
return owner;
}
function activeSession(): StackChat.Session {
- if (!session) throw new Error('Unlock your identity file first');
+ if (session === undefined) throw new Error('Unlock your identity file first');
return session;
}
function conversation(): StackChat.Conversation {
- if (!selected) throw new Error('Choose a conversation first');
+ if (selected === undefined) throw new Error('Choose a conversation first');
return selected;
}
@@ -171,41 +176,41 @@ async function api<K extends keyof StackChat.Api>(request: StackChat.Api[K]['req
});
const result = record(await response.json());
if (current !== generation) throw new Error('Session locked');
- if (!response.ok || result['error']) throw new Error(typeof result['error'] === 'string' ? result['error'] : 'Server request failed');
+ if (!response.ok || 'error' in result) throw new Error(typeof result['error'] === 'string' ? result['error'] : 'Server request failed');
return apiReaders[request.op](result);
}
async function cryptoOperation<K extends keyof StackChat.Crypto>(request: StackChat.Crypto[K]['request'] & { op: K }): Promise<StackChat.Crypto[K]['response']> {
const current = generation;
- if (!wasm) throw new Error('Cryptographic module is not loaded');
+ if (wasm === undefined) throw new Error('Cryptographic module is not loaded');
const bytes = await wasm;
if (current !== generation) throw new Error('Session locked');
return new Promise<StackChat.Crypto[K]['response']>((resolve, reject) => {
const worker = new Worker('/worker.js');
- const stop = () => { worker.terminate(); workers.delete(worker); clearTimeout(timer); };
+ const stop = (): void => { worker.terminate(); workers.delete(worker); clearTimeout(timer); };
const timer = setTimeout(() => { stop(); reject(new Error('Cryptographic operation timed out')); }, 90000);
workers.set(worker, () => { stop(); reject(new Error('Session locked')); });
- worker.onerror = () => { stop(); reject(new Error('Cryptographic worker failed')); };
- worker.onmessage = (event: MessageEvent<unknown>) => {
+ worker.onerror = (): void => { stop(); reject(new Error('Cryptographic worker failed')); };
+ worker.onmessage = (event: MessageEvent<unknown>): void => {
stop();
if (current !== generation) { reject(new Error('Session locked')); return; }
try {
const message = record(event.data);
if ('error' in message) throw new Error(stringField(message, 'error'));
resolve(cryptoReaders[request.op](message['result']));
- } catch (error) { reject(error); }
+ } catch (error) { reject(error instanceof Error ? error : new Error('Cryptographic response rejected', { cause: error })); }
};
worker.postMessage({ bytes, request });
});
}
function protectRequest<K extends 'encrypt' | 'decrypt' | 'decrypt_batch'>(op: K, fields: Omit<StackChat.Crypto[K]['request'], keyof StackChat.Protected | 'op'>): StackChat.Protected & { op: K } & typeof fields {
- if (!credentials) throw new Error('Unlock your identity file first');
+ if (credentials === undefined) throw new Error('Unlock your identity file first');
return { op, ...credentials, origin: location.origin, ...fields };
}
-function clearState() {
+function clearState(): void {
generation += 1;
busy = false;
for (const button of document.querySelectorAll('button')) button.disabled = false;
@@ -227,13 +232,18 @@ function clearState() {
}
for (const id of ['messages', 'conversations', 'who', 'fingerprint', 'counts', 'thread-name', 'conversation-fingerprint']) element(id).replaceChildren();
for (const id of ['create', 'unlock', 'chat', 'thread', 'inbox', 'older', 'retry', 'owner-access']) show(id, false);
- show('entry', Boolean(config?.owner));
+ show('entry', hasOwner());
show('owner-entry');
setStatus('Locked. Select your file and enter its password to return.');
}
-function action(id: string, callback: () => Promise<void>): void {
- element(id).addEventListener(id.endsWith('-form') ? 'submit' : 'click', async event => {
+function background(operation: () => Promise<void>): void {
+ const current = generation;
+ operation().catch((error: unknown) => { if (current === generation) setStatus(errorMessage(error)); });
+}
+
+function action(id: string, callback: () => void | Promise<void>): void {
+ const handler = async (event: Event): Promise<void> => {
event.preventDefault();
if (busy) return;
busy = true;
@@ -248,7 +258,8 @@ function action(id: string, callback: () => Promise<void>): void {
for (const button of document.querySelectorAll('button')) button.disabled = false;
}
}
- });
+ };
+ element(id).addEventListener(id.endsWith('-form') ? 'submit' : 'click', event => { background(() => handler(event)); });
}
function download(file: string): void {
@@ -261,13 +272,13 @@ function download(file: string): void {
setTimeout(() => { URL.revokeObjectURL(url); blobs.delete(url); }, 30000);
}
-async function createIdentity() {
+async function createIdentity(): Promise<void> {
const password = input('new-password').value;
if (password !== input('confirm-password').value) throw new Error('Passwords do not match');
- if (!configuration().owner && element('setup-label').hidden) throw new Error('Owner setup is required first');
- setupToken = !configuration().owner ? input('setup-token').value : undefined;
+ if (!hasOwner() && element('setup-label').hasAttribute('hidden')) throw new Error('Owner setup is required first');
+ setupToken = !hasOwner() ? input('setup-token').value : undefined;
setStatus('Generating your purpose-specific identity and protecting its file...');
- const result = await cryptoOperation({ op: 'create', origin: location.origin, name: input('name').value, password, owner: configuration().owner || '' });
+ const result = await cryptoOperation({ op: 'create', origin: location.origin, name: input('name').value, password, owner: configuration().owner ?? '' });
pendingFile = result.file;
download(result.file);
form('create-form').reset();
@@ -277,16 +288,16 @@ async function createIdentity() {
setStatus('Reopen your downloaded identity file to verify recovery.');
}
-async function unlockIdentity() {
+async function unlockIdentity(): Promise<void> {
const file = input('file').files?.[0];
- if (!file || file.size > 262144) throw new Error('Choose an identity file no larger than 256 KiB');
+ if (file === undefined || file.size > 262144) throw new Error('Choose an identity file no larger than 256 KiB');
const fileText = await file.text();
- if (pendingFile && fileText !== pendingFile) throw new Error('Reopen the exact identity file you just downloaded');
+ if (((pendingFile ?? '').length > 0) && fileText !== pendingFile) throw new Error('Reopen the exact identity file you just downloaded');
const candidate = { file: fileText, password: input('password').value };
form('unlock-form').reset();
setStatus('Unlocking locally and verifying your identity...');
const identity = await cryptoOperation({ op: 'open', ...candidate, origin: location.origin });
- if (configuration().owner && identity.owner !== configuration().owner) throw new Error('Pinned owner key differs from this server. Stop and verify the change with Doan.');
+ if (hasOwner() && identity.owner !== configuration().owner) throw new Error('Pinned owner key differs from this server. Stop and verify the change with Doan.');
const challenge = await api({ op: 'challenge', public: identity.public, setup_token: setupToken });
const proof = await cryptoOperation({ op: 'proof', ...candidate, origin: location.origin, challenge: challenge.challenge });
const authenticated = await api({ op: 'authenticate', id: challenge.id, signature: proof.signature });
@@ -301,7 +312,7 @@ async function unlockIdentity() {
show('owner-access', false);
show('unlock', false);
show('chat');
- element('who').textContent = `${identity.name} [${activeSession().owner === true ? 'OWNER' : 'VISITOR'}]`;
+ element('who').textContent = `${identity.name} [${activeSession().owner ? 'OWNER' : 'VISITOR'}]`;
element('fingerprint').textContent = `Identity: ${identity.fingerprint}`;
if (authenticated.owner) {
activeSession().owner = true;
@@ -329,15 +340,15 @@ function checkedPayload(payload: string, kind: 'profile' | 'message', conversati
return { id: stringField(value, 'id'), text: stringField(value, 'text') };
}
-async function inbox() {
+async function inbox(): Promise<void> {
const result = await api({ op: 'inbox' });
element('conversations').replaceChildren();
for (const conversation of result.conversations) {
const button = document.createElement('button');
- button.textContent = `${conversation.id.slice(0, 12)} / ${conversation.total} messages`;
- button.addEventListener('click', async () => {
+ button.textContent = `${conversation.id.slice(0, 12)} / ${String(conversation.total)} messages`;
+ const handler = async (): Promise<void> => {
if (busy) return;
- if (pendingSend) { setStatus('Retry the pending message, or lock before switching conversations.'); return; }
+ if ((pendingSend !== undefined)) { setStatus('Retry the pending message, or lock before switching conversations.'); return; }
busy = true;
const current = generation;
setStatus('Opening conversation...');
@@ -351,8 +362,9 @@ async function inbox() {
element('messages').replaceChildren();
show('thread');
let name = conversation.id.slice(0, 12);
- if (conversation.profile) {
- const result = await cryptoOperation(protectRequest('decrypt', { sender: conversation.public, ciphertext: conversation.profile }));
+ const certificate = conversation.profile;
+ if (certificate !== undefined && certificate !== null && certificate.length > 0) {
+ const result = await cryptoOperation(protectRequest('decrypt', { sender: conversation.public, ciphertext: certificate }));
if (result.signer !== conversation.id) throw new Error('Profile signer fingerprint mismatch');
const profile = checkedPayload(result.payload, 'profile', conversation.id, conversation.id);
if (typeof profile.name !== 'string' || profile.name.length > 80) throw new Error('Invalid encrypted profile');
@@ -363,7 +375,8 @@ async function inbox() {
setStatus('Conversation opened. Choose how many messages to load.');
} catch (error) { setStatus(errorMessage(error)); }
finally { if (generation === current) busy = false; }
- });
+ };
+ button.addEventListener('click', () => { background(handler); });
element('conversations').append(button);
}
}
@@ -371,10 +384,10 @@ async function inbox() {
function counters(conversation: StackChat.Conversation): void {
if (!Number.isSafeInteger(conversation.total) || conversation.total < 0 || conversation.total > 10000) throw new Error('Invalid conversation count');
input('history-count').max = String(conversation.total);
- element('history-range').textContent = `Latest messages (0-${conversation.total})`;
+ element('history-range').textContent = `Latest messages (0-${String(conversation.total)})`;
const sent = activeSession().owner ? conversation.owner_sent : conversation.visitor_sent;
const received = activeSession().owner ? conversation.visitor_sent : conversation.owner_sent;
- element('counts').textContent = `Total: ${conversation.total} | Sent: ${sent} | Received: ${received}`;
+ element('counts').textContent = `Total: ${String(conversation.total)} | Sent: ${String(sent)} | Received: ${String(received)}`;
}
async function renderHistoryBatch(messages: StackChat.Message[]): Promise<void> {
@@ -382,14 +395,14 @@ async function renderHistoryBatch(messages: StackChat.Message[]): Promise<void>
const decrypted = await cryptoOperation(protectRequest('decrypt_batch', { messages: requests }));
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index];
- if (!message) throw new Error('Missing history message');
+ if (message === undefined) throw new Error('Missing history message');
const item = document.createElement('li');
const label = document.createElement('small');
- label.textContent = `#${message.seq} / ${message.sender === activeSession().id ? 'YOU' : 'THEM'}`;
+ label.textContent = `#${String(message.seq)} / ${message.sender === activeSession().id ? 'YOU' : 'THEM'}`;
const text = document.createElement('p');
try {
const entry = decrypted.messages[index];
- if (!entry || 'error' in entry) throw new Error('Invalid ciphertext or signature');
+ if (entry === undefined || 'error' in entry) throw new Error('Invalid ciphertext or signature');
if (entry.signer !== message.sender) throw new Error('Sender fingerprint mismatch');
const payload = checkedPayload(entry.payload, 'message', conversation().id, message.sender);
if (payload.id !== message.id || typeof payload.text !== 'string' || new TextEncoder().encode(payload.text).length > 8192) throw new Error('Message binding mismatch');
@@ -400,7 +413,7 @@ async function renderHistoryBatch(messages: StackChat.Message[]): Promise<void>
}
}
-async function refreshCounts() {
+async function refreshCounts(): Promise<StackChat.Conversation> {
const conversationId = conversation().id;
const result = await api({ op: 'history', conversation: conversationId, count: 0, before: 0 });
if (selected?.id === conversationId) counters(result.conversation);
@@ -408,10 +421,10 @@ async function refreshCounts() {
}
async function loadHistory(count: number, cursor = 0): Promise<void> {
- if (!selected) throw new Error('Choose a conversation first');
+ if (selected === undefined) throw new Error('Choose a conversation first');
const currentConversation = await refreshCounts();
- if (!Number.isSafeInteger(count) || count < 0 || count > currentConversation.total) throw new Error(`Choose 0 to ${currentConversation.total} messages`);
- if (!cursor) { element('messages').replaceChildren(); before = 0; }
+ if (!Number.isSafeInteger(count) || count < 0 || count > currentConversation.total) throw new Error(`Choose 0 to ${String(currentConversation.total)} messages`);
+ if (!(cursor !== 0)) { element('messages').replaceChildren(); before = 0; }
if (count === 0) {
input('history-count').value = '0';
show('older', false);
@@ -426,10 +439,10 @@ async function loadHistory(count: number, cursor = 0): Promise<void> {
const result = await api({ op: 'history', conversation: conversation().id, count: size, before: next });
counters(result.conversation);
if (!Array.isArray(result.messages) || result.messages.length > size) throw new Error('Invalid history response');
- if (!result.messages.length) { exhausted = true; break; }
+ if (!(result.messages.length !== 0)) { exhausted = true; break; }
const sequence = Math.min(...result.messages.map(message => message.seq));
if (!Number.isSafeInteger(sequence) || sequence <= 0 || (next > 0 && sequence >= next)) throw new Error('Invalid history cursor');
- setStatus(`Decrypting and verifying selected messages locally (${count - remaining + result.messages.length}/${count})...`);
+ setStatus(`Decrypting and verifying selected messages locally (${String(count - remaining + result.messages.length)}/${String(count)})...`);
await renderHistoryBatch(result.messages);
remaining -= result.messages.length;
next = sequence;
@@ -440,8 +453,8 @@ async function loadHistory(count: number, cursor = 0): Promise<void> {
setStatus('Selected history decrypted and verified locally.');
}
-async function submitPending() {
- if (!pendingSend) throw new Error('No pending message');
+async function submitPending(): Promise<void> {
+ if (pendingSend === undefined) throw new Error('No pending message');
const result = await api(pendingSend);
counters(result.conversation);
pendingSend = undefined;
@@ -450,11 +463,11 @@ async function submitPending() {
setStatus('Encrypted message stored. Load history to read messages.');
}
-async function send() {
- if (!selected) throw new Error('Choose a conversation first');
- if (pendingSend) throw new Error('Retry or lock before composing another message');
+async function send(): Promise<void> {
+ if (selected === undefined) throw new Error('Choose a conversation first');
+ if ((pendingSend !== undefined)) throw new Error('Retry or lock before composing another message');
const text = messageInput().value;
- if (!text.trim() || new TextEncoder().encode(text).length > 8192) throw new Error('Enter 1 to 8192 UTF-8 bytes of text');
+ if (!((text.trim()).length > 0) || new TextEncoder().encode(text).length > 8192) throw new Error('Enter 1 to 8192 UTF-8 bytes of text');
const id = Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, '0')).join('');
const recipient = activeSession().owner ? conversation().public : ownerKey();
setStatus('Signing and encrypting for both participants locally...');
@@ -464,62 +477,66 @@ async function send() {
await submitPending();
}
-function ownerSetup() {
+function ownerSetup(): void {
show('entry', false);
show('owner-entry', false);
show('unlock', false);
- show('create', !configuration().owner);
- show('owner-access', Boolean(configuration().owner));
- show('setup-label', !configuration().owner);
+ show('create', !hasOwner());
+ show('owner-access', hasOwner());
+ show('setup-label', !hasOwner());
element('create-title').textContent = 'Create the owner identity (owner only)';
setStatus('');
}
-action('create-choice', async () => { show('entry', false); show('owner-entry', false); show('setup-label', false); element('create-title').textContent = 'Create a new chat'; show('create'); setStatus(''); });
-action('open-choice', async () => { show('entry', false); show('owner-entry', false); show('unlock'); setStatus(''); });
-action('owner-choice', async () => { window.history.replaceState(null, '', '#setup'); ownerSetup(); });
-action('owner-open', async () => { show('owner-access', false); show('unlock'); setStatus(''); });
+action('create-choice', () => { show('entry', false); show('owner-entry', false); show('setup-label', false); element('create-title').textContent = 'Create a new chat'; show('create'); setStatus(''); });
+action('open-choice', () => { show('entry', false); show('owner-entry', false); show('unlock'); setStatus(''); });
+action('owner-choice', () => { window.history.replaceState(null, '', '#setup'); ownerSetup(); });
+action('owner-open', () => { show('owner-access', false); show('unlock'); setStatus(''); });
action('create-form', createIdentity);
action('unlock-form', unlockIdentity);
element('history-count').addEventListener('focus', () => {
const current = generation;
- if (session && selected && !busy) refreshCounts().catch(error => { if (current === generation) setStatus(errorMessage(error)); });
+ if ((session !== undefined) && (selected !== undefined) && !busy) refreshCounts().catch((error: unknown) => { if (current === generation) setStatus(errorMessage(error)); });
});
action('history-form', () => loadHistory(Number(input('history-count').value)));
action('send-form', send);
action('retry', submitPending);
action('refresh-inbox', inbox);
action('older', () => loadHistory(Number(input('history-count').value), before));
-element('lock').addEventListener('click', () => { if (session) api({ op: 'lock' }).catch(() => {}); clearState(); });
+element('lock').addEventListener('click', () => {
+ const current = generation;
+ if (session !== undefined) api({ op: 'lock' }).catch((error: unknown) => { if (current === generation) setStatus(errorMessage(error)); });
+ clearState();
+});
window.addEventListener('pagehide', clearState);
window.addEventListener('pageshow', event => { if (event.persisted) clearState(); });
-async function downloadCrypto() {
+async function downloadCrypto(): Promise<ArrayBuffer> {
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
let reader: ReadableStreamDefaultReader<Uint8Array<ArrayBuffer>> | undefined;
- const refreshTimeout = () => {
+ const refreshTimeout = (): void => {
clearTimeout(timer);
- timer = setTimeout(() => controller.abort(), 45000);
+ timer = setTimeout(() => { controller.abort(); }, 45000);
};
try {
refreshTimeout();
const response = await fetch('/crypto.wasm', { cache: 'no-store', credentials: 'omit', signal: controller.signal });
- if (!response.ok) throw new Error(`Cryptographic module download failed (HTTP ${response.status}). Reload to try again.`);
- const length = response.headers.get('content-encoding') ? 0 : Number(response.headers.get('content-length'));
+ if (!response.ok) throw new Error(`Cryptographic module download failed (HTTP ${String(response.status)}). Reload to try again.`);
+ const length = ((response.headers.get('content-encoding') ?? '').length > 0) ? 0 : Number(response.headers.get('content-length'));
const total = Number.isSafeInteger(length) && length > 0 ? length : 0;
const chunks: Uint8Array<ArrayBuffer>[] = [];
let received = 0;
- const progress = () => {
+ const progress = (): void => {
const loaded = (received / 1048576).toFixed(1);
- if (total) {
+ if ((total !== 0)) {
const percent = Math.min(100, Math.floor(received * 100 / total));
const filled = Math.floor(percent / 5);
- setStatus(`Downloading cryptographic module... [${'#'.repeat(filled)}${'-'.repeat(20 - filled)}] ${percent}% (${loaded} / ${(total / 1048576).toFixed(1)} MiB)`);
+ setStatus(`Downloading cryptographic module... [${'#'.repeat(filled)}${'-'.repeat(20 - filled)}] ${String(percent)}% (${loaded} / ${(total / 1048576).toFixed(1)} MiB)`);
} else setStatus(`Downloading cryptographic module... ${loaded} MiB received`);
};
progress();
- if (!response.body) throw new Error('Cryptographic module response has no body');
+ if (!(response.body !== null)) throw new Error('Cryptographic module response has no body');
reader = response.body.getReader();
for (;;) {
const { done, value } = await reader.read();
@@ -529,13 +546,13 @@ async function downloadCrypto() {
received += value.byteLength;
progress();
}
- if (!received) throw new Error('The cryptographic module download was empty. Reload to try again.');
+ if (!(received !== 0)) throw new Error('The cryptographic module download was empty. Reload to try again.');
const bytes = new Uint8Array(received);
let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
return bytes.buffer;
} catch (error) {
- if (controller.signal.aborted) throw new Error('Cryptographic module download stalled for 45 seconds. Check your connection and reload to try again.');
+ if (controller.signal.aborted) throw new Error('Cryptographic module download stalled for 45 seconds. Check your connection and reload to try again.', { cause: error });
throw error;
} finally {
clearTimeout(timer);
@@ -543,18 +560,18 @@ async function downloadCrypto() {
}
}
-async function start() {
+async function start(): Promise<void> {
wasm = downloadCrypto();
await wasm;
setStatus('Cryptographic module downloaded. Connecting to chat...');
config = await api({ op: 'config' });
if (configuration().origin !== location.origin) throw new Error('Server origin mismatch');
show('owner-entry');
- show('entry', Boolean(configuration().owner));
+ show('entry', hasOwner());
setStatus('');
if (location.hash === '#setup') ownerSetup();
}
-start().catch(error => setStatus(errorMessage(error)));
+start().catch((error: unknown) => { setStatus(errorMessage(error)); });
// @license-end
diff --git a/crates/chat-server/web/worker.ts b/crates/chat-server/web/worker.ts
index d4367bf..d3a1c68 100644
--- a/crates/chat-server/web/worker.ts
+++ b/crates/chat-server/web/worker.ts
@@ -17,9 +17,9 @@
importScripts('/wasi.mjs');
-self.onmessage = async (event: MessageEvent<StackChat.WorkerRequest>) => {
+self.onmessage = async (event: MessageEvent<StackChat.WorkerRequest>): Promise<void> => {
try {
- const result = await self.stackChatWasi.runCommand(event.data.bytes, event.data.request);
+ const result = await stackChatWasi.runCommand(event.data.bytes, event.data.request);
self.postMessage({ result });
} catch {
self.postMessage({ error: 'Cryptographic operation failed. Check your file, password, origin, and sender.' });
diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs
index ce509b6..f5896d8 100644
--- a/crates/config/src/lib.rs
+++ b/crates/config/src/lib.rs
@@ -338,36 +338,47 @@ mod tests {
let error = Config::load(root.path())
.err()
.context("Malformed secret must fail")?;
- assert!(!format!("{error:#}").contains("private-malformed-sentinel"));
+ anyhow::ensure!(
+ !format!("{error:#}").contains("private-malformed-sentinel"),
+ "Test invariant failed"
+ );
fs::write(
root.path().join(".env"),
"SITE_HOST=home.example.net\nGIT_HOST=source.example.net\nHTTPS_PORT=8443\nLOCAL_HTTP_PORT=8180\nCHAT_SETUP_TOKEN=private-token-sentinel\nSEARXNG_SECRET=private-search-sentinel\n",
)?;
let config = Config::load(root.path())?;
- assert_eq!(
- config.render("{{SITE_ORIGIN}} {{GIT_ORIGIN}}", None)?,
- "https://home.example.net:8443 https://source.example.net:8443"
+ anyhow::ensure!(
+ (config.render("{{SITE_ORIGIN}} {{GIT_ORIGIN}}", None)?)
+ == ("https://home.example.net:8443 https://source.example.net:8443"),
+ "Test invariant failed"
+ );
+ anyhow::ensure!(
+ (config.render("{{SITE_ORIGIN}} {{GIT_ORIGIN}}", Some(8180))?)
+ == ("http://localhost:8180 /git"),
+ "Test invariant failed"
+ );
+ anyhow::ensure!(
+ config.render("{{CHAT_SETUP_TOKEN}}", None).is_err(),
+ "Test invariant failed"
);
- assert_eq!(
- config.render("{{SITE_ORIGIN}} {{GIT_ORIGIN}}", Some(8180))?,
- "http://localhost:8180 /git"
+ anyhow::ensure!(
+ !format!("{config:?}").contains("private-token"),
+ "Test invariant failed"
);
- assert!(config.render("{{CHAT_SETUP_TOKEN}}", None).is_err());
- assert!(!format!("{config:?}").contains("private-token"));
initialize_public(root.path())?;
- assert_eq!(
- Config::load(root.path())?.value("SITE_HOST")?,
- "home.example.net"
+ anyhow::ensure!(
+ (Config::load(root.path())?.value("SITE_HOST")?) == ("home.example.net"),
+ "Test invariant failed"
);
- assert_eq!(
- Config::load(root.path())?.value("CHAT_SETUP_TOKEN")?,
- "private-token-sentinel"
+ anyhow::ensure!(
+ (Config::load(root.path())?.value("CHAT_SETUP_TOKEN")?) == ("private-token-sentinel"),
+ "Test invariant failed"
);
fs::write(
root.path().join(".env"),
"SITE_HOST=\"valid.example { respond bad }\"\n",
)?;
- assert!(Config::load(root.path()).is_err());
+ anyhow::ensure!(Config::load(root.path()).is_err(), "Test invariant failed");
Ok(())
}
}
diff --git a/docs/LINTING.txt b/docs/LINTING.txt
new file mode 100644
index 0000000..6185b0d
--- /dev/null
+++ b/docs/LINTING.txt
@@ -0,0 +1,194 @@
+Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+STRICT LINT POLICY
+
+ Work is on linting/strict, based on the TypeScript migration. Main and
+ production are separate until this branch is explicitly merged and deployed.
+ Cargo.toml is the workspace policy; all four packages inherit it.
+
+ Forbid rejects source-level attempts to lower a lint. Deny rejects a
+ violation but permits a narrower source-level override. Neither prevents
+ someone from changing the build configuration itself. Compiler/tool versions
+ are pinned, and the checked build is the supported development path.
+
+RUST AND CLIPPY
+
+ Every previously denied individual Clippy rule and every enabled Clippy
+ group now uses forbid. There are no blanket Clippy allow entries. This
+ includes all, correctness, suspicious, cargo, style, complexity, perf,
+ pedantic, nursery, multiple_crate_versions, and redundant_pub_crate.
+ All Rustdoc lints in its supported all group are forbidden too.
+
+ Rust safety, documentation, visibility, casts, edition/future compatibility,
+ underscore bindings, and warnings use forbid. Unused-group members that do
+ not conflict with generated code are independently forbidden. Additional
+ restrictions cover panic/unimplemented/unreachable paths, panic inside Result
+ functions, unsafe allowance attributes, numeric/pointer casts, native-endian
+ data, string slicing, infinite loops, and test placement. Cargo.toml lists
+ the precise rules. Contradictory restriction groups are not enabled wholesale.
+
+ The supported workarounds were implemented instead of lowering these rules:
+
+ Clap derives became explicit checked Clap builders. All argument parsing
+ stays in Clap; application code does not implement a shell parser.
+ Both command schemas have regression tests for modes, required paths,
+ listener parsing, global --root, and maintenance conflicts. Help flags
+ and rejected-option exit codes were compared against the previous binaries.
+ The unused Clap derive feature and its lockfile dependency were removed.
+
+ Tokio's runtime attribute became an explicit checked runtime builder.
+ Runtime construction errors propagate through the existing error path.
+ The broad generated expect/style/complexity allowances are no longer needed.
+
+ Management and server implementations live inside private app namespaces.
+ Internal interfaces use pub(super), narrower than crate visibility. Only
+ the documented program entry point is re-exported at the binary root.
+ This satisfies both redundant_pub_crate and unreachable_pub without
+ exposing helper modules or allowing either lint.
+
+ Serde containers specify crate = "serde". This supported attribute avoids
+ the generated extern-crate branch and its unused_extern_crates and
+ clippy::useless_attribute allowances. File and wire representations retain
+ their existing field names, ordering, tagging, and unknown-field checks.
+
+ Result-returning tests report failed checks with anyhow::ensure instead
+ of panicking. This permits panic_in_result_fn without suppressions.
+
+THREE REMAINING RUST DENY SETTINGS
+
+ unused, nonstandard_style, and unused_qualifications remain deny. This is
+ a generated-code compatibility requirement, not permission to add source
+ allowances. clippy::allow_attributes and allow_attributes_without_reason
+ are forbidden for first-party source.
+
+ The pinned Serde derive implementation unconditionally emits allowances for
+ non_upper_case_globals, unused_attributes, unused_qualifications, and
+ clippy::absolute_paths in serde_derive/src/dummy.rs. Its generated field
+ identifier visitor also allows non_camel_case_types. These are generated
+ attributes even when the first-party type has correct names and no redundant
+ qualification. They cannot be removed by renaming fields or imports.
+
+ The supported crate-path workaround was applied and all macros were inspected.
+ Repeating Clippy with -F unused, -F nonstandard_style, or
+ -F unused_qualifications still rejects the pinned Serde expansion. There is
+ no supported derive setting to suppress that unconditional wrapper. Further
+ changes would require forking the generator or replacing the serialization
+ implementation, including security-sensitive identity/envelope/request formats.
+ Those are explicit upstream/format migrations, not configuration fixes.
+
+ Every other unused-group member and non_snake_case is independently forbidden.
+ warnings itself is forbidden; the explicit deny groups provide the narrowly
+ required effective levels for the generated attributes. No first-party
+ allow/expect attributes were added.
+
+DUPLICATE DEPENDENCIES
+
+ clippy.toml restricts allowed-duplicate-crates to generic-array, getrandom,
+ and syn: the names actually diagnosed in this workspace. The Clippy lint
+ and cargo group remain forbidden for every other dependency.
+
+ The incompatible lines come from the pinned crypto/parser/build graph:
+ generic-array 0.14.7 and 1.4.5
+ getrandom 0.2.17 and 0.4.3
+ syn 2.0.119 and 3.0.9
+
+ These are different semver compatibility lines, not duplicate patch releases.
+ Unifying them requires upstream API changes or older dependencies. No crypto
+ algorithm/backend changes or dependency downgrades were made for a lint.
+ cargo-deny continues to enforce its exact reviewed version exceptions in
+ deny.toml, including build/development dependencies. Clippy's name-only
+ configuration does not replace that stronger version-specific audit.
+
+TYPESCRIPT
+
+ The compiler checks strict mode and the additional indexed access, optional
+ property, unused, return, switch, override, and import checks. noCheck and
+ allowJs are explicitly false. Both declaration-library skip flags are false.
+ isolatedModules, erasableSyntaxOnly, and verbatimModuleSyntax remain enabled.
+ Browser/worker configurations use Bundler resolution with Preserve output;
+ they contain no runtime imports. Tests and tooling use NodeNext separately.
+
+ eslint.config.ts extends the typed strict and stylistic TypeScript presets.
+ All diagnostics are errors and any warning fails the check. Inline config
+ is disabled; suppression directives and unsafe assertions are prohibited.
+ Additional rules reject ambiguous boolean coercion, unhandled/misused promises,
+ explicit or leaked any, non-null assertions, unsafe calls/returns/arguments,
+ eval, unsafe casts, and unchecked discriminated-union switches. Functions
+ expose checked return types; callback contexts retain their declared contracts.
+
+ UI async tasks now have explicit rejection handlers at the DOM boundary.
+ Test HTTP handlers also catch rejected work rather than returning unobserved
+ promises to Node. WASI validates its start export before calling it, and the
+ shared host is installed as a frozen, non-writable global property. Dynamic
+ JSON and worker boundaries still validate unknown values at runtime.
+
+ TypeScript 7.0.2 remains the emitting compiler, installed under the explicit
+ typescript-native npm alias. typescript-eslint 8.72.1 supports TypeScript
+ >=4.8.4 <6.1.0 and requires the JavaScript compiler API, so TypeScript 6.0.3
+ supplies that supported lint API. Sources are checked by both compilers;
+ no unsupported-peer or ignore-peer-dependency install flags are used.
+ Every invocation names the intended compiler path to avoid npm bin collisions.
+
+ Isolated declaration emission is a library-distribution constraint, not an
+ additional application safety check. This project serves classic scripts
+ for the tested LibreJS path and does not publish a TypeScript library.
+ moduleDetection=force is used for tests/tools; changing browser scripts to
+ ESM solely for that setting would change their loading/LibreJS contract.
+ TypeScript 7 removed allowSyntheticDefaultImports=false, so that obsolete
+ switch is not used. Typed linting checks unsafe interop at use sites.
+
+DEVELOPMENT DEPENDENCY ADVISORY
+
+ ESLint and typescript-eslint currently request minimatch 10. The public
+ GHSA-g3hv-75wg-m249 advisory affects minimatch <11.0.1. The root npm override
+ pins the patched 11.0.3 release instead of ignoring the advisory or applying
+ npm audit fix --force. Its supported exports and Node engine were reviewed;
+ typed linting and rule-selection/negative-control checks exercise compatibility.
+ Remove the override when upstream ranges admit a patched version naturally.
+
+ npm audit --audit-level=info is part of npm run check and therefore the Cargo
+ check/release path. Development dependency findings fail the build too.
+ Installed tools preserve their original notices and machine SPDX identifiers.
+ Human documentation calls SPDX MIT the Expat License. No third-party
+ JavaScript dependency is delivered to visitors.
+
+CHECKS AND EXPORTS
+
+ npm run build
+ cargo stack check
+ cargo stack release
+
+ The first checks both TypeScript compilers, typed ESLint, and npm advisories,
+ then emits the browser/worker/test artifacts. Cargo also runs formatting,
+ strict Clippy, all workspace tests, strict Rustdoc, cargo-deny, and cargo-about.
+ Browser checks and GNU LibreJS controls are documented in TYPESCRIPT.txt.
+
+ clippy.toml, eslint.config.ts, every tsconfig, and both lockfiles are included
+ in corresponding sources, releases, and backups. Installed node_modules,
+ runtime state, and the private .env remain outside public exports. Nothing
+ introduces another deployment environment file or a production Node runtime.
+
+PRIMARY REFERENCES
+
+ https://doc.rust-lang.org/stable/rustc/lints/levels.html
+ https://doc.rust-lang.org/clippy/lints.html
+ https://doc.rust-lang.org/clippy/lint_configuration.html
+ https://serde.rs/container-attrs.html
+ https://github.com/serde-rs/serde/blob/v1.0.229/serde_derive/src/dummy.rs
+ https://www.typescriptlang.org/tsconfig/
+ https://typescript-eslint.io/users/configs/
+ https://typescript-eslint.io/users/dependency-versions/
+ https://github.com/advisories/GHSA-g3hv-75wg-m249
diff --git a/docs/POLICY.txt b/docs/POLICY.txt
index 1f54398..4c6c6d3 100644
--- a/docs/POLICY.txt
+++ b/docs/POLICY.txt
@@ -45,12 +45,14 @@ PROJECT CONVENTIONS
STRICTNESS
- Preserve the requested Rust and Clippy deny groups, including pedantic
- and nursery. The one additional exception is redundant_pub_crate: its
- advice to make internal binary items public conflicts with unreachable_pub,
- which remains denied. Internal interfaces remain explicitly crate-private.
- No unsafe code, unwrap, expect, todo, debug output, arithmetic shortcuts,
- or broad warning suppression is introduced.
+ All enabled Clippy groups and individual rules are forbidden, with no
+ blanket allow entries. Rust and Rustdoc checks use forbid wherever supported
+ by generated code. Three Rust settings remain deny for pinned Serde derives;
+ their unaffected members are independently forbidden. First-party lint
+ allowances are forbidden. See LINTING.txt for tested workarounds, generated
+ attribute evidence, and the narrow duplicate-dependency configuration.
+ Private app namespaces retain narrow internal visibility while both
+ redundant_pub_crate and unreachable_pub are enforced.
cargo-deny rejects duplicate crate versions, wildcard requirements, unknown
registries, unknown Git sources, and yanked versions. The chat prototype has
@@ -63,7 +65,9 @@ STRICTNESS
Browser logic and automation use strict TypeScript configurations with
separate browser/worker/Node environments and checked untrusted responses.
npm dependencies are exact, development-only, integrity-locked, and installed
- with lifecycle scripts disabled. See TYPESCRIPT.txt for the build contract.
+ with lifecycle scripts disabled. Typed ESLint forbids unsafe any/assertions,
+ unchecked promises, and suppression directives. Both supported compiler APIs
+ and npm advisories are checked. See TYPESCRIPT.txt and LINTING.txt.
Human license names follow https://www.gnu.org/licenses/license-list.html.
In particular, display Expat License for SPDX MIT. Do not alter original
diff --git a/docs/TYPESCRIPT.txt b/docs/TYPESCRIPT.txt
index d50ea95..7fbc5a4 100644
--- a/docs/TYPESCRIPT.txt
+++ b/docs/TYPESCRIPT.txt
@@ -40,6 +40,10 @@ SOURCE LAYOUT
tests/browser-support.ts
Checked fixture parsing and typed browser-automation helpers.
+ eslint.config.ts and tsconfig.tools.json supply the typed lint-tool checks.
+ src/app and crates/chat-server/src/app contain the private Rust CLI
+ implementations; their parser and runtime changes are recorded in LINTING.txt.
+
tsconfig.json supplies shared strict checks. The browser, worker, and
test configurations use separate DOM, WebWorker, and Node environments.
Production browser builds cannot accidentally rely on Node APIs.
@@ -53,6 +57,10 @@ DEVELOPMENT
Install Node >=22.18.0 and npm on the development machine. Root package.json
and package-lock.json pin TypeScript 7.0.2, Playwright 1.64.1, Node type
declarations, platform compiler packages, and their integrity hashes.
+ ESLint 10.14.0 and typescript-eslint 8.72.1 provide typed strict linting.
+ TypeScript 6.0.3 supplies their supported JavaScript API; the pinned native
+ TypeScript 7.0.2 alias remains the emitting compiler. Both check the sources.
+ LINTING.txt documents the advisory-driven minimatch override.
There are no npm application/runtime dependencies. TypeScript and
Playwright use the Apache License, Version 2.0; Node declarations and
undici-types use the Expat License. Original notices remain in the
@@ -63,7 +71,8 @@ DEVELOPMENT
npm run build
infra/chat/build-typescript.sh installs the locked development packages
- with lifecycle scripts disabled, checks all three configurations, and
+ with lifecycle scripts disabled, checks browser/worker/test/tool configurations,
+ typed ESLint, both compiler versions, and npm advisories, then
emits JavaScript under var/build/typescript. The npm cache stays under
.tools/npm-cache. node_modules and generated outputs are ignored by Git.
Generated scripts are readable and unminified; no bundler is needed.
@@ -72,7 +81,7 @@ DEVELOPMENT
cargo-about license generation. cargo stack release builds TypeScript
before packaging the complete production release.
- Check without installing packages or emitting output after npm run build:
+ Recheck without reinstalling packages after npm run build:
npm run check
@@ -92,8 +101,9 @@ DEVELOPMENT
.tools/librejs.xpi as documented in CHAT-APPLICATION.txt. The complete
chat test proves LibreJS is active and rejects an unlicensed control.
Run chat variants sequentially because they share the default test port.
- npm audit checks the development dependency lock separately from
- cargo-deny; it does not audit Rust, browser-delivered code, or containers.
+ npm audit checks development dependencies as part of npm run check and the
+ Cargo check/release path. It does not audit Rust, browser-delivered code, or
+ containers; cargo-deny remains the separate strict Rust dependency audit.
RELEASES, SOURCE, AND LIBREJS
diff --git a/eslint.config.ts b/eslint.config.ts
new file mode 100644
index 0000000..4ad21d6
--- /dev/null
+++ b/eslint.config.ts
@@ -0,0 +1,53 @@
+// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+import js from '@eslint/js';
+import tseslint from 'typescript-eslint';
+import { defineConfig } from 'eslint/config';
+import { fileURLToPath } from 'node:url';
+
+const root = fileURLToPath(new URL('../../../', import.meta.url));
+
+export default defineConfig(
+ { ignores: ['var/**', '.tools/**', 'node_modules/**', 'target/**', 'bin/**', 'dist/**'] },
+ {
+ files: ['**/*.ts'],
+ extends: [js.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked],
+ languageOptions: {
+ parserOptions: {
+ project: ['tsconfig.browser.json', 'tsconfig.worker.json', 'tsconfig.tests.json', 'tsconfig.tools.json'],
+ tsconfigRootDir: root,
+ },
+ },
+ linterOptions: { noInlineConfig: true, reportUnusedDisableDirectives: 'error', reportUnusedInlineConfigs: 'error' },
+ rules: {
+ '@typescript-eslint/ban-ts-comment': ['error', { 'ts-check': true, 'ts-expect-error': true, 'ts-ignore': true, 'ts-nocheck': true }],
+ '@typescript-eslint/consistent-type-assertions': ['error', { assertionStyle: 'never' }],
+ '@typescript-eslint/explicit-function-return-type': 'error',
+ '@typescript-eslint/explicit-module-boundary-types': 'error',
+ '@typescript-eslint/no-unsafe-type-assertion': 'error',
+ '@typescript-eslint/no-unsafe-call': 'error',
+ '@typescript-eslint/no-floating-promises': ['error', { ignoreVoid: false }],
+ '@typescript-eslint/no-misused-promises': 'error',
+ '@typescript-eslint/strict-boolean-expressions': ['error', { allowString: false, allowNumber: false, allowNullableObject: false, allowNullableBoolean: false, allowNullableString: false, allowNullableNumber: false, allowAny: false }],
+ '@typescript-eslint/switch-exhaustiveness-check': ['error', { requireDefaultForNonUnion: true }],
+ '@typescript-eslint/use-unknown-in-catch-callback-variable': 'error',
+ 'no-restricted-syntax': ['error', { selector: 'TSAnyKeyword', message: 'Use unknown and validate untrusted values.' }, { selector: 'TSNonNullExpression', message: 'Check presence explicitly.' }],
+ 'no-eval': 'error',
+ 'no-implied-eval': 'error',
+ 'eqeqeq': ['error', 'always'],
+ },
+ },
+);
diff --git a/infra/chat/build-source.sh b/infra/chat/build-source.sh
index e173774..475b519 100644
--- a/infra/chat/build-source.sh
+++ b/infra/chat/build-source.sh
@@ -23,7 +23,7 @@ cargo vendor --locked var/build/chat-vendor | sed 's|directory = "var/build/chat
cp "$ROOT/.tools/wasi-source/openssl.tar.gz" "$BUILD/chat-openssl-3.6.5.tar.gz"
printf '%s %s\n' a2157c2830efdec3788939b00c9b0638306d3f0bbb76dc4832ee503bb397df98 "$BUILD/chat-openssl-3.6.5.tar.gz" | sha256sum --check
source_list="$BUILD/chat-source-files.txt"
-find package.json package-lock.json tsconfig.json tsconfig.browser.json tsconfig.worker.json tsconfig.tests.json tests Cargo.toml Cargo.lock rust-toolchain.toml .cargo .gitignore .gitattributes src crates infra web docs README.txt LICENSE deny.toml about.toml about.hbs .env.example -type f -print > "$source_list"
+find clippy.toml eslint.config.ts package.json package-lock.json tsconfig.json tsconfig.browser.json tsconfig.worker.json tsconfig.tests.json tsconfig.tools.json tests Cargo.toml Cargo.lock rust-toolchain.toml .cargo .gitignore .gitattributes src crates infra web docs README.txt LICENSE deny.toml about.toml about.hbs .env.example -type f -print > "$source_list"
tar -czf "$BUILD/chat-source.tar.gz.pending" --verbatim-files-from -T "$source_list" -C "$BUILD" chat-vendor chat-vendor-config.txt chat-openssl-3.6.5.tar.gz
mv "$BUILD/chat-source.tar.gz.pending" "$BUILD/chat-source.tar.gz"
sha256sum "$BUILD/chat-source.tar.gz"
diff --git a/infra/chat/build-typescript.sh b/infra/chat/build-typescript.sh
index d9d44ad..14cae95 100644
--- a/infra/chat/build-typescript.sh
+++ b/infra/chat/build-typescript.sh
@@ -19,6 +19,6 @@ ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
cd "$ROOT"
npm ci --ignore-scripts --no-audit --no-fund --cache "$ROOT/.tools/npm-cache"
npm run check
-node_modules/.bin/tsc -p tsconfig.browser.json
-node_modules/.bin/tsc -p tsconfig.worker.json
-node_modules/.bin/tsc -p tsconfig.tests.json
+node node_modules/typescript-native/bin/tsc -p tsconfig.browser.json
+node node_modules/typescript-native/bin/tsc -p tsconfig.worker.json
+node node_modules/typescript-native/bin/tsc -p tsconfig.tests.json
diff --git a/package-lock.json b/package-lock.json
index 240cca5..f260383 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25,14 +25,288 @@
"version": "0.1.0",
"license": "AGPL-3.0-or-later",
"devDependencies": {
+ "@eslint/js": "10.0.1",
"@types/node": "26.6.6",
+ "eslint": "10.14.0",
"playwright": "1.64.1",
- "typescript": "7.0.2"
+ "typescript": "6.0.3",
+ "typescript-eslint": "8.72.1",
+ "typescript-native": "npm:typescript@7.0.2"
},
"engines": {
"node": ">=22.18.0"
}
},
+ "node_modules/@cacheable/memory": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz",
+ "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cacheable/utils": "^2.5.0",
+ "@keyv/bigmap": "^1.3.1",
+ "hookified": "^1.15.1",
+ "keyv": "^5.6.0"
+ }
+ },
+ "node_modules/@cacheable/utils": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz",
+ "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hashery": "^1.5.1",
+ "keyv": "^5.6.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.10.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
+ "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.23.5",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
+ "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^3.0.5",
+ "debug": "^4.3.1",
+ "minimatch": "^10.2.4"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz",
+ "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^1.2.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
+ "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
+ "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "eslint": "^10.0.0"
+ },
+ "peerDependenciesMeta": {
+ "eslint": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
+ "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz",
+ "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^1.2.1",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/types": "^0.15.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@isaacs/nonrecursive-glob-match": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz",
+ "integrity": "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0"
+ },
+ "node_modules/@keyv/bigmap": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
+ "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hashery": "^1.4.0",
+ "hookified": "^1.15.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "keyv": "^5.6.0"
+ }
+ },
+ "node_modules/@keyv/serialize": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
+ "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/esrecurse": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
+ "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz",
+ "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/node": {
"version": "26.6.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
@@ -43,6 +317,236 @@
"undici-types": "~8.9.0"
}
},
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.72.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.1.tgz",
+ "integrity": "sha512-5CokTWXpmbylfFabsbvHC0CAw3XLaGnoa+nFJX5FR592h/LoZsENUE6p/rg5xvkKrFEXz6pYmgI29AgXc2N3BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.72.1",
+ "@typescript-eslint/type-utils": "8.72.1",
+ "@typescript-eslint/utils": "8.72.1",
+ "@typescript-eslint/visitor-keys": "8.72.1",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.72.1",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.14",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.14.tgz",
+ "integrity": "sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.72.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.1.tgz",
+ "integrity": "sha512-ZGwZP4OCfyA0AQTpEAMWz+rtX01DRhQWW5hB0ucKDEIX5U7jdmPSaMx94rQfaPAQzIYz2BimOgKOJtjfpRFQ9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.72.1",
+ "@typescript-eslint/types": "8.72.1",
+ "@typescript-eslint/typescript-estree": "8.72.1",
+ "@typescript-eslint/visitor-keys": "8.72.1",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.72.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.1.tgz",
+ "integrity": "sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.72.1",
+ "@typescript-eslint/types": "^8.72.1",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.72.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.1.tgz",
+ "integrity": "sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.72.1",
+ "@typescript-eslint/visitor-keys": "8.72.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.72.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.1.tgz",
+ "integrity": "sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.72.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.1.tgz",
+ "integrity": "sha512-iY2wlmyrXJmx4yxUGP3aYCFF3cA/6ShddOKmLU2dx7LnEykbAM8WThlxZEfd1NddqceF6eJmZUayxx9oKIYNlg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.72.1",
+ "@typescript-eslint/typescript-estree": "8.72.1",
+ "@typescript-eslint/utils": "8.72.1",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.72.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.1.tgz",
+ "integrity": "sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.72.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.1.tgz",
+ "integrity": "sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.72.1",
+ "@typescript-eslint/tsconfig-utils": "8.72.1",
+ "@typescript-eslint/types": "8.72.1",
+ "@typescript-eslint/visitor-keys": "8.72.1",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.72.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz",
+ "integrity": "sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.72.1",
+ "@typescript-eslint/types": "8.72.1",
+ "@typescript-eslint/typescript-estree": "8.72.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.72.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.1.tgz",
+ "integrity": "sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.72.1",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
@@ -383,6 +887,627 @@
"node": ">=16.20.0"
}
},
+ "node_modules/acorn": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
+ "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/cacheable": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz",
+ "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cacheable/memory": "^2.2.0",
+ "@cacheable/utils": "^2.5.0",
+ "hookified": "^1.15.0",
+ "keyv": "^5.6.0",
+ "qified": "^0.10.1"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "10.14.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.14.0.tgz",
+ "integrity": "sha512-T0pNsxkb9IKmtQjhpK3d7nWOpFv+YHuMbTJnSVMoQblF0onvXfErrtp4V1jHtsQPan7dKaGVD0q1WCXnzMF31A==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "packages/*"
+ ],
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.2",
+ "@eslint/config-array": "^0.23.5",
+ "@eslint/config-helpers": "^0.7.0",
+ "@eslint/core": "^1.2.1",
+ "@eslint/plugin-kit": "^0.7.3",
+ "@humanfs/node": "^0.16.8",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^9.1.2",
+ "eslint-visitor-keys": "^5.0.1",
+ "espree": "^11.2.0",
+ "esquery": "^1.7.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "11.1.5 || >11.1.6 <12",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "minimatch": "^10.2.6",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "9.1.2",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
+ "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@types/esrecurse": "^4.3.1",
+ "@types/estree": "^1.0.8",
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "11.2.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
+ "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.16.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^5.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "11.1.5",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz",
+ "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^6.1.23"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "6.1.23",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz",
+ "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cacheable": "^2.5.0",
+ "flatted": "^3.4.2",
+ "hookified": "^1.15.0"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.4",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
+ "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/hashery": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz",
+ "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hookified": "^1.15.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/hookified": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
+ "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/keyv": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
+ "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@keyv/serialize": "^1.1.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "11.0.3",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.3.tgz",
+ "integrity": "sha512-iny/40PEGkNJQ5/Pv6z3nm6FbRAby32GeZRjCUJk2WnNDbgPSTcdI+DDvLqsgBdD4cD5sMtMheqPPnehCCa8Nw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/nonrecursive-glob-match": "^1.0.3",
+ "brace-expansion": "^5.0.12"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/playwright": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
@@ -412,7 +1537,165 @@
"node": ">=20"
}
},
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/qified": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz",
+ "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hookified": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/qified/node_modules/hookified": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz",
+ "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+ "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
"node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.72.1",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.72.1.tgz",
+ "integrity": "sha512-3Li9FfPmQp45nxEtFD1ZCrYFI3cYK5kpyNtmm1H7lMagm8u9sy6PTjy3ytwQZs7KNukOyKFHAZ6z6GjYFeaeXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.72.1",
+ "@typescript-eslint/parser": "8.72.1",
+ "@typescript-eslint/typescript-estree": "8.72.1",
+ "@typescript-eslint/utils": "8.72.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/typescript-native": {
+ "name": "typescript",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
@@ -453,6 +1736,55 @@
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"dev": true,
"license": "MIT"
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
}
}
}
diff --git a/package.json b/package.json
index 98cad2c..f6c725e 100644
--- a/package.json
+++ b/package.json
@@ -25,14 +25,25 @@
},
"scripts": {
"build": "sh infra/chat/build-typescript.sh",
- "check": "tsc -p tsconfig.browser.json --noEmit && tsc -p tsconfig.worker.json --noEmit && tsc -p tsconfig.tests.json --noEmit",
+ "check": "npm run typecheck && node node_modules/typescript-native/bin/tsc -p tsconfig.tools.json && npm run lint && npm audit --audit-level=info",
"test:crypto": "node var/build/typescript/crates/chat-crypto/tests/browser.js",
"test:chat": "node var/build/typescript/crates/chat-server/tests/browser.js",
- "test:progress": "node var/build/typescript/crates/chat-server/tests/progress.js"
+ "test:progress": "node var/build/typescript/crates/chat-server/tests/progress.js",
+ "typecheck": "npm run typecheck:native && npm run typecheck:compat",
+ "lint": "eslint --no-config-lookup --config var/build/typescript/eslint.config.js --max-warnings 0 .",
+ "typecheck:native": "node node_modules/typescript-native/bin/tsc -p tsconfig.browser.json --noEmit && node node_modules/typescript-native/bin/tsc -p tsconfig.worker.json --noEmit && node node_modules/typescript-native/bin/tsc -p tsconfig.tests.json --noEmit && node node_modules/typescript-native/bin/tsc -p tsconfig.tools.json --noEmit",
+ "typecheck:compat": "node node_modules/typescript/bin/tsc -p tsconfig.browser.json --noEmit && node node_modules/typescript/bin/tsc -p tsconfig.worker.json --noEmit && node node_modules/typescript/bin/tsc -p tsconfig.tests.json --noEmit && node node_modules/typescript/bin/tsc -p tsconfig.tools.json --noEmit"
},
"devDependencies": {
"@types/node": "26.6.6",
"playwright": "1.64.1",
- "typescript": "7.0.2"
+ "typescript": "6.0.3",
+ "typescript-native": "npm:typescript@7.0.2",
+ "eslint": "10.14.0",
+ "@eslint/js": "10.0.1",
+ "typescript-eslint": "8.72.1"
+ },
+ "overrides": {
+ "minimatch": "11.0.3"
}
}
diff --git a/src/app.rs b/src/app.rs
new file mode 100644
index 0000000..d655774
--- /dev/null
+++ b/src/app.rs
@@ -0,0 +1,252 @@
+// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+#![doc = "Rust commands for personal web infrastructure."]
+
+mod archive;
+mod chat;
+mod cli;
+mod files;
+mod images;
+mod process;
+mod project;
+mod release;
+mod render;
+
+use std::path::{Path, PathBuf};
+use std::process::ExitCode;
+
+use crate::app::cli::{Action, Cli};
+use anyhow::Result;
+
+use crate::app::project::{Mode, Project};
+
+#[doc = "Runs the checked command-line entry point and returns its process status."]
+#[must_use]
+pub fn run() -> ExitCode {
+ let arguments = match Cli::try_parse() {
+ Ok(arguments) => arguments,
+ Err(error) => {
+ let status = if error.use_stderr() {
+ ExitCode::FAILURE
+ } else {
+ ExitCode::SUCCESS
+ };
+ drop(error.print());
+ return status;
+ }
+ };
+ match execute(arguments) {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(error) => {
+ drop(process::error(format_args!("Error: {error:#}")));
+ ExitCode::FAILURE
+ }
+ }
+}
+
+fn execute(arguments: Cli) -> Result<()> {
+ if let Action::Restore {
+ archive,
+ destination,
+ } = &arguments.command
+ {
+ return archive::restore(&absolute(archive)?, &absolute(destination)?);
+ }
+ let project = Project::discover(arguments.root.as_deref())?;
+ if let Action::Chat {
+ init,
+ reset_owner,
+ owner_fingerprint,
+ } = arguments.command
+ {
+ return chat(&project, init, reset_owner.as_deref(), owner_fingerprint);
+ }
+
+ let _operation = project.lock()?;
+ match arguments.command {
+ Action::Dev { port } => {
+ let mut settings = project.settings()?;
+ settings.mode = Mode::Local;
+ if let Some(selected_port) = port {
+ settings.port = selected_port;
+ }
+ images::prepare(&project, &mut settings)?;
+ project.up(&settings)
+ }
+ Action::Up {
+ production,
+ local,
+ port,
+ online,
+ } => {
+ let mut settings = project.settings()?;
+ if production {
+ settings.mode = Mode::Production;
+ }
+ if local {
+ settings.mode = Mode::Local;
+ }
+ if let Some(selected_port) = port {
+ settings.port = selected_port;
+ }
+ if online {
+ settings.online = true;
+ }
+ images::prepare(&project, &mut settings)?;
+ project.up(&settings)
+ }
+ Action::Config { production, port } => {
+ let mut settings = project.settings()?;
+ settings.mode = if production {
+ Mode::Production
+ } else {
+ Mode::Local
+ };
+ if let Some(selected_port) = port {
+ settings.port = selected_port;
+ }
+ stack_config::initialize_public(&project.root)?;
+ render::prepare(&project, &settings)?;
+ process::line(format_args!(
+ "Public configuration: {}",
+ render::directory(&project, &settings).display()
+ ))
+ }
+ Action::Status => project.status(&project.settings()?),
+ Action::Down => project.down(&project.settings()?),
+ Action::Backup {
+ destination,
+ include_images,
+ recipient,
+ key,
+ } => {
+ let output = destination.map_or_else(
+ || Ok(project.root.join("var/exports")),
+ |path| absolute(&path),
+ )?;
+ let configuration = stack_config::Config::load(&project.root)?;
+ let selected_recipient =
+ recipient.unwrap_or(configuration.value("BACKUP_RECIPIENT")?.to_owned());
+ archive::backup(
+ &project,
+ &output,
+ include_images,
+ &selected_recipient,
+ key.as_deref(),
+ )
+ }
+ Action::Release { destination } => {
+ let output = destination
+ .map_or_else(|| Ok(project.root.join("dist")), |path| absolute(&path))?;
+ release::create(&project, &output)
+ }
+ Action::Activate { archive } => release::activate(&absolute(&archive)?, &project),
+ Action::Check => check(&project),
+ Action::Licenses => licenses(&project),
+ Action::Chat { .. } => anyhow::bail!("Chat action was not handled"),
+ Action::Restore { .. } => anyhow::bail!("Restore action was not handled"),
+ }
+}
+
+fn chat(
+ project: &Project,
+ init: bool,
+ reset_owner: Option<&str>,
+ owner_fingerprint: bool,
+) -> Result<()> {
+ if project.settings()?.mode == Mode::Production {
+ return chat::admin(project, init, reset_owner, owner_fingerprint);
+ }
+ let mut command = project.command("cargo")?;
+ command
+ .args(["run", "--locked", "-p", "stack-chat-server", "--", "--root"])
+ .arg(&project.root);
+ if init {
+ command.arg("--init");
+ }
+ if let Some(fingerprint) = reset_owner {
+ command.arg("--reset-owner").arg(fingerprint);
+ }
+ if owner_fingerprint {
+ command.arg("--owner-fingerprint");
+ }
+ process::run(&mut command)
+}
+
+fn absolute(path: &Path) -> Result<PathBuf> {
+ Ok(if path.is_absolute() {
+ path.to_path_buf()
+ } else {
+ std::env::current_dir()?.join(path)
+ })
+}
+
+fn licenses(project: &Project) -> Result<()> {
+ process::run(project.command("cargo")?.args([
+ "about",
+ "generate",
+ "--workspace",
+ "--locked",
+ "about.hbs",
+ "--output-file",
+ "web/THIRD-PARTY-LICENSES.html",
+ ]))
+}
+
+fn check(project: &Project) -> Result<()> {
+ process::run(project.command("sh")?.arg("infra/chat/build-typescript.sh"))?;
+ process::run(
+ project
+ .command("cargo")?
+ .args(["fmt", "--all", "--", "--check"]),
+ )?;
+ process::run(project.command("cargo")?.args([
+ "clippy",
+ "--locked",
+ "--workspace",
+ "--all-targets",
+ "--all-features",
+ "--",
+ "-D",
+ "warnings",
+ ]))?;
+ process::run(project.command("cargo")?.args([
+ "test",
+ "--locked",
+ "--workspace",
+ "--all-targets",
+ "--all-features",
+ ]))?;
+ process::run(
+ project
+ .command("cargo")?
+ .env("RUSTDOCFLAGS", "-D warnings")
+ .args([
+ "doc",
+ "--locked",
+ "--workspace",
+ "--no-deps",
+ "--document-private-items",
+ ]),
+ )?;
+ process::run(project.command("cargo")?.args([
+ "deny",
+ "--workspace",
+ "--all-features",
+ "check",
+ ]))?;
+ licenses(project)
+}
diff --git a/src/archive.rs b/src/app/archive.rs
index 1e58992..2a78644 100644
--- a/src/archive.rs
+++ b/src/app/archive.rs
@@ -21,8 +21,8 @@ use std::process::Command;
use anyhow::{Context, Result, ensure};
use serde_json::Value;
-use crate::project::{Mode, Project, Settings, field};
-use crate::{files, process};
+use crate::app::project::{Mode, Project, Settings, field};
+use crate::app::{files, process};
#[derive(Debug)]
struct Paused<'a> {
@@ -59,7 +59,7 @@ impl Drop for Paused<'_> {
}
}
-pub(crate) fn containers(project: &Project, settings: &Settings) -> Result<Vec<Value>> {
+pub(super) fn containers(project: &Project, settings: &Settings) -> Result<Vec<Value>> {
let records = project.inspect_containers(settings)?;
let actual: BTreeSet<_> = records
.iter()
@@ -173,11 +173,11 @@ fn snapshot_files(project: &Project, records: &[Value], stage: &Path) -> Result<
.find(|path| path.is_file())
.context("Build a release locally before exporting so the backup includes a portable binary")?;
fs::copy(executable, stage.join("bin/stack"))?;
- crate::chat::copy_artifacts(project, stage)?;
+ crate::app::chat::copy_artifacts(project, stage)?;
Ok(())
}
-pub(crate) fn backup(
+pub(super) fn backup(
project: &Project,
destination: &Path,
with_images: bool,
@@ -280,7 +280,7 @@ fn backup_inner(
}
}
if with_images {
- crate::images::save(project, records, &stage.join("var"))?;
+ crate::app::images::save(project, records, &stage.join("var"))?;
}
files::manifest(&stage, "backup")?;
let plain = temp.join("stack.tar.gz");
@@ -331,7 +331,7 @@ fn backup_inner(
process::line(format_args!("Encrypted export: {}", output.display()))
}
-pub(crate) fn restore(archive: &Path, destination: &Path) -> Result<()> {
+pub(super) fn restore(archive: &Path, destination: &Path) -> Result<()> {
ensure!(!destination.exists(), "Restore destination must not exist");
let parent = destination
.parent()
@@ -393,8 +393,8 @@ mod tests {
use anyhow::Result;
- use crate::files;
- use crate::project::{Mode, Project};
+ use crate::app::files;
+ use crate::app::project::{Mode, Project};
#[test]
fn chat_snapshot_is_consistent_and_private() -> Result<()> {
@@ -413,10 +413,12 @@ mod tests {
let restored = rusqlite::Connection::open(&snapshot)?;
let count: i64 =
restored.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
- assert_eq!(count, 1);
- assert_eq!(
- std::os::unix::fs::PermissionsExt::mode(&fs::metadata(snapshot)?.permissions()) & 0o777,
- 0o600
+ anyhow::ensure!((count) == (1), "Test invariant failed");
+ anyhow::ensure!(
+ (std::os::unix::fs::PermissionsExt::mode(&fs::metadata(snapshot)?.permissions())
+ & 0o777)
+ == (0o600),
+ "Test invariant failed"
);
transaction.rollback()?;
Ok(())
@@ -437,12 +439,18 @@ mod tests {
files::pack(&source, &archive)?;
let destination = workspace.path().join("restored");
super::restore(&archive, &destination)?;
- assert_eq!(Project::at(&destination)?.settings()?.mode, Mode::Local);
- assert_eq!(
- fs::read_to_string(destination.join(".env"))?,
- "SEARXNG_SECRET=private\n"
+ anyhow::ensure!(
+ (Project::at(&destination)?.settings()?.mode) == (Mode::Local),
+ "Test invariant failed"
+ );
+ anyhow::ensure!(
+ (fs::read_to_string(destination.join(".env"))?) == ("SEARXNG_SECRET=private\n"),
+ "Test invariant failed"
+ );
+ anyhow::ensure!(
+ super::restore(&archive, &destination).is_err(),
+ "Test invariant failed"
);
- assert!(super::restore(&archive, &destination).is_err());
Ok(())
}
@@ -457,8 +465,11 @@ mod tests {
let archive = workspace.path().join("backup.tar.gz");
files::pack(&source, &archive)?;
let destination = workspace.path().join("restored");
- assert!(super::restore(&archive, &destination).is_err());
- assert!(!destination.exists());
+ anyhow::ensure!(
+ super::restore(&archive, &destination).is_err(),
+ "Test invariant failed"
+ );
+ anyhow::ensure!(!destination.exists(), "Test invariant failed");
Ok(())
}
}
diff --git a/src/chat.rs b/src/app/chat.rs
index 0739f22..1647018 100644
--- a/src/chat.rs
+++ b/src/app/chat.rs
@@ -13,8 +13,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-use crate::project::{Mode, Project, Settings};
-use crate::{files, process};
+use crate::app::project::{Mode, Project, Settings};
+use crate::app::{files, process};
use anyhow::{Context, Result, ensure};
use stack_config::Config;
use std::fs;
@@ -22,7 +22,7 @@ use std::net::IpAddr;
use std::path::Path;
use std::process::Command;
-pub(crate) fn web_bind(configuration: &Config) -> Result<String> {
+pub(super) fn web_bind(configuration: &Config) -> Result<String> {
let address: IpAddr = configuration.value("WEB_BIND")?.parse()?;
Ok(match address {
IpAddr::V4(_) => address.to_string(),
@@ -30,7 +30,7 @@ pub(crate) fn web_bind(configuration: &Config) -> Result<String> {
})
}
-pub(crate) fn environment(
+pub(super) fn environment(
command: &mut Command,
configuration: &Config,
settings: &Settings,
@@ -49,7 +49,7 @@ pub(crate) fn environment(
Ok(())
}
-pub(crate) fn build(project: &Project) -> Result<()> {
+pub(super) fn build(project: &Project) -> Result<()> {
process::run(project.command("sh")?.arg("infra/chat/build-typescript.sh"))?;
process::run(project.command("sh")?.arg("infra/chat/build-native.sh"))?;
let wasm_build = if project
@@ -66,7 +66,7 @@ pub(crate) fn build(project: &Project) -> Result<()> {
package(project)
}
-pub(crate) fn package(project: &Project) -> Result<()> {
+pub(super) fn package(project: &Project) -> Result<()> {
let directory = project.root.join("bin/chat");
fs::create_dir_all(&directory)?;
for (source, target) in [
@@ -108,7 +108,7 @@ pub(crate) fn package(project: &Project) -> Result<()> {
Ok(())
}
-pub(crate) fn copy_artifacts(project: &Project, destination: &Path) -> Result<()> {
+pub(super) fn copy_artifacts(project: &Project, destination: &Path) -> Result<()> {
let artifacts = project.root.join("bin/chat");
ensure!(
artifacts.join("server").is_file()
@@ -119,7 +119,7 @@ pub(crate) fn copy_artifacts(project: &Project, destination: &Path) -> Result<()
files::copy_tree(&artifacts, &destination.join("bin/chat"))
}
-pub(crate) fn admin(
+pub(super) fn admin(
project: &Project,
init: bool,
reset_owner: Option<&str>,
diff --git a/src/app/cli.rs b/src/app/cli.rs
new file mode 100644
index 0000000..1830ec1
--- /dev/null
+++ b/src/app/cli.rs
@@ -0,0 +1,329 @@
+// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+use clap::{Arg, ArgAction, ArgMatches, Command, error::ErrorKind};
+use std::ffi::OsString;
+use std::path::PathBuf;
+
+#[derive(Debug)]
+
+pub(super) struct Cli {
+ pub(super) root: Option<PathBuf>,
+
+ pub(super) command: Action,
+}
+
+#[derive(Debug)]
+pub(super) enum Action {
+ Chat {
+ init: bool,
+
+ reset_owner: Option<String>,
+
+ owner_fingerprint: bool,
+ },
+
+ Dev {
+ port: Option<u16>,
+ },
+
+ Up {
+ production: bool,
+
+ local: bool,
+
+ port: Option<u16>,
+
+ online: bool,
+ },
+
+ Config {
+ production: bool,
+
+ port: Option<u16>,
+ },
+
+ Status,
+
+ Down,
+
+ Backup {
+ destination: Option<PathBuf>,
+
+ include_images: bool,
+
+ recipient: Option<String>,
+
+ key: Option<PathBuf>,
+ },
+
+ Restore {
+ archive: PathBuf,
+ destination: PathBuf,
+ },
+
+ Check,
+
+ Release {
+ destination: Option<PathBuf>,
+ },
+
+ Activate {
+ archive: PathBuf,
+ },
+
+ Licenses,
+}
+
+fn flag(name: &'static str) -> Arg {
+ Arg::new(name).long(name).action(ArgAction::SetTrue)
+}
+
+fn path(name: &'static str) -> Arg {
+ Arg::new(name).value_parser(clap::builder::PathBufValueParser::new())
+}
+
+fn port() -> Arg {
+ Arg::new("port")
+ .long("port")
+ .value_parser(clap::builder::RangedU64ValueParser::<u16>::new())
+}
+
+fn development() -> [Command; 3] {
+ [
+ Command::new("dev")
+ .about("Start the local development services")
+ .arg(port()),
+ Command::new("up")
+ .about("Start or update services in the selected mode")
+ .args([
+ flag("production").conflicts_with("local"),
+ flag("local"),
+ port(),
+ flag("online"),
+ ]),
+ Command::new("config")
+ .about("Render public Caddy, website, and cgit configuration without starting services")
+ .args([flag("production"), port()]),
+ ]
+}
+
+fn archives() -> [Command; 4] {
+ [
+ Command::new("backup")
+ .about("Create a GPG-encrypted application export")
+ .args([
+ path("destination"),
+ flag("include-images"),
+ Arg::new("recipient").long("recipient"),
+ path("key").long("key"),
+ ]),
+ Command::new("restore")
+ .about("Decrypt, validate, and restore into a new directory without starting services")
+ .args([
+ path("archive").required(true),
+ path("destination").required(true),
+ ]),
+ Command::new("release")
+ .about("Check and build a portable production release locally")
+ .arg(path("destination")),
+ Command::new("activate")
+ .about("Install a prebuilt release without compiling on the server")
+ .arg(path("archive").required(true)),
+ ]
+}
+
+fn command() -> Command {
+ Command::new("stack")
+ .version(env!("CARGO_PKG_VERSION"))
+ .about("Develop, deploy, and back up the personal web stack")
+ .subcommand_required(true)
+ .arg_required_else_help(true)
+ .arg(
+ path("root").long("root").global(true).help(
+ "Application root; otherwise discovered from the working directory or binary",
+ ),
+ )
+ .subcommand(
+ Command::new("chat")
+ .about("Run experimental local chat or administer its packaged deployment")
+ .args([
+ flag("init").conflicts_with_all(["reset-owner", "owner-fingerprint"]),
+ Arg::new("reset-owner")
+ .long("reset-owner")
+ .value_name("FINGERPRINT")
+ .conflicts_with("owner-fingerprint")
+ .help(
+ "Permanently clear owner registration and chat history after key loss",
+ ),
+ flag("owner-fingerprint")
+ .help("Print the registered owner's public fingerprint"),
+ ]),
+ )
+ .subcommands(development())
+ .subcommands(archives())
+ .subcommands([
+ Command::new("status").about("Show service status"),
+ Command::new("down").about("Stop services while retaining data"),
+ Command::new("check")
+ .about("Run formatting, strict lints, tests, documentation, and dependency checks"),
+ Command::new("licenses")
+ .about("Generate the third-party license page with GNU license names"),
+ ])
+}
+
+fn required<T: Clone + Send + Sync + 'static>(
+ matches: &mut ArgMatches,
+ name: &str,
+) -> Result<T, clap::Error> {
+ matches.remove_one(name).ok_or_else(|| {
+ clap::Error::raw(
+ ErrorKind::MissingRequiredArgument,
+ format!("Missing {name}"),
+ )
+ })
+}
+
+fn action(name: &str, args: &mut ArgMatches) -> Result<Action, clap::Error> {
+ Ok(match name {
+ "chat" => Action::Chat {
+ init: args.get_flag("init"),
+ reset_owner: args.remove_one("reset-owner"),
+ owner_fingerprint: args.get_flag("owner-fingerprint"),
+ },
+ "dev" => Action::Dev {
+ port: args.remove_one("port"),
+ },
+ "up" => Action::Up {
+ production: args.get_flag("production"),
+ local: args.get_flag("local"),
+ port: args.remove_one("port"),
+ online: args.get_flag("online"),
+ },
+ "config" => Action::Config {
+ production: args.get_flag("production"),
+ port: args.remove_one("port"),
+ },
+ "status" => Action::Status,
+ "down" => Action::Down,
+ "backup" => Action::Backup {
+ destination: args.remove_one("destination"),
+ include_images: args.get_flag("include-images"),
+ recipient: args.remove_one("recipient"),
+ key: args.remove_one("key"),
+ },
+ "restore" => Action::Restore {
+ archive: required(args, "archive")?,
+ destination: required(args, "destination")?,
+ },
+ "check" => Action::Check,
+ "release" => Action::Release {
+ destination: args.remove_one("destination"),
+ },
+ "activate" => Action::Activate {
+ archive: required(args, "archive")?,
+ },
+ "licenses" => Action::Licenses,
+ _ => {
+ return Err(clap::Error::raw(
+ ErrorKind::InvalidSubcommand,
+ "Unknown command",
+ ));
+ }
+ })
+}
+
+impl Cli {
+ pub(super) fn try_parse() -> Result<Self, clap::Error> {
+ Self::try_parse_from(std::env::args_os())
+ }
+
+ fn try_parse_from(
+ arguments: impl IntoIterator<Item = impl Into<OsString> + Clone>,
+ ) -> Result<Self, clap::Error> {
+ let mut matches = command().try_get_matches_from(arguments)?;
+ let root = matches.remove_one("root");
+ let (name, mut arguments) = matches.remove_subcommand().ok_or_else(|| {
+ clap::Error::raw(ErrorKind::MissingSubcommand, "Select a stack command")
+ })?;
+ Ok(Self {
+ root,
+ command: action(&name, &mut arguments)?,
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{Action, Cli, command};
+ use anyhow::Result;
+ use std::path::PathBuf;
+
+ #[test]
+ fn checked_command_schema() {
+ command().debug_assert();
+ }
+
+ #[test]
+ fn modes_conflicts_and_required_paths() -> Result<()> {
+ let parsed = Cli::try_parse_from([
+ "stack",
+ "up",
+ "--root",
+ "/tmp/stack",
+ "--local",
+ "--port",
+ "8180",
+ "--online",
+ ])?;
+ anyhow::ensure!(
+ (parsed.root) == (Some(PathBuf::from("/tmp/stack"))),
+ "Test invariant failed"
+ );
+ anyhow::ensure!(
+ matches!(
+ parsed.command,
+ Action::Up {
+ production: false,
+ local: true,
+ port: Some(8180),
+ online: true
+ }
+ ),
+ "Test invariant failed"
+ );
+ for args in [
+ vec!["stack", "up", "--local", "--production"],
+ vec!["stack", "dev", "--port", "65536"],
+ vec!["stack", "chat", "--init", "--reset-owner", "abc"],
+ vec![
+ "stack",
+ "chat",
+ "--reset-owner",
+ "abc",
+ "--owner-fingerprint",
+ ],
+ vec!["stack", "restore", "archive"],
+ vec!["stack", "activate"],
+ ] {
+ anyhow::ensure!(Cli::try_parse_from(args).is_err(), "Test invariant failed");
+ }
+ anyhow::ensure!(
+ matches!(Cli::try_parse_from(["stack", "restore", "export.gpg", "new-root"])?.command, Action::Restore { archive, destination } if archive == std::path::Path::new("export.gpg") && destination == std::path::Path::new("new-root")),
+ "Test invariant failed"
+ );
+ Ok(())
+ }
+}
diff --git a/src/files.rs b/src/app/files.rs
index 81c57b0..2c612a9 100644
--- a/src/files.rs
+++ b/src/app/files.rs
@@ -27,15 +27,18 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use walkdir::WalkDir;
-pub(crate) const SOURCE: &[&str] = &[
+pub(super) const SOURCE: &[&str] = &[
"Cargo.toml",
"Cargo.lock",
+ "clippy.toml",
+ "eslint.config.ts",
"package.json",
"package-lock.json",
"tsconfig.json",
"tsconfig.browser.json",
"tsconfig.worker.json",
"tsconfig.tests.json",
+ "tsconfig.tools.json",
"rust-toolchain.toml",
".cargo",
".gitignore",
@@ -56,16 +59,16 @@ pub(crate) const SOURCE: &[&str] = &[
];
#[derive(Debug, Deserialize, Serialize)]
-#[serde(deny_unknown_fields)]
-pub(crate) struct Manifest {
- pub(crate) format: u32,
- pub(crate) kind: String,
- pub(crate) version: String,
- pub(crate) created_utc: String,
- pub(crate) files: BTreeMap<String, String>,
+#[serde(crate = "serde", deny_unknown_fields)]
+pub(super) struct Manifest {
+ pub(super) format: u32,
+ pub(super) kind: String,
+ pub(super) version: String,
+ pub(super) created_utc: String,
+ pub(super) files: BTreeMap<String, String>,
}
-pub(crate) fn private_dir(path: &Path) -> Result<()> {
+pub(super) fn private_dir(path: &Path) -> Result<()> {
fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
@@ -74,7 +77,7 @@ pub(crate) fn private_dir(path: &Path) -> Result<()> {
Ok(())
}
-pub(crate) fn write(path: &Path, data: &[u8], mode: u32) -> Result<()> {
+pub(super) fn write(path: &Path, data: &[u8], mode: u32) -> Result<()> {
let parent = path.parent().context("File has no parent directory")?;
fs::create_dir_all(parent)?;
let mut pending = tempfile::NamedTempFile::new_in(parent)?;
@@ -87,11 +90,11 @@ pub(crate) fn write(path: &Path, data: &[u8], mode: u32) -> Result<()> {
Ok(())
}
-pub(crate) fn hash_bytes(bytes: &[u8]) -> String {
+pub(super) fn hash_bytes(bytes: &[u8]) -> String {
hex::encode(Sha256::digest(bytes))
}
-pub(crate) fn hash_file(path: &Path) -> Result<String> {
+pub(super) fn hash_file(path: &Path) -> Result<String> {
let mut file = File::open(path).with_context(|| format!("Cannot read {}", path.display()))?;
let mut digest = Sha256::new();
let mut buffer = [0; 8192];
@@ -105,7 +108,7 @@ pub(crate) fn hash_file(path: &Path) -> Result<String> {
Ok(hex::encode(digest.finalize()))
}
-pub(crate) fn relative(path: &Path, base: &Path) -> Result<String> {
+pub(super) fn relative(path: &Path, base: &Path) -> Result<String> {
Ok(path
.strip_prefix(base)?
.to_str()
@@ -113,7 +116,7 @@ pub(crate) fn relative(path: &Path, base: &Path) -> Result<String> {
.to_owned())
}
-pub(crate) fn regular_tree(path: &Path) -> Result<()> {
+pub(super) fn regular_tree(path: &Path) -> Result<()> {
for item in WalkDir::new(path).follow_links(false) {
let entry = item?;
ensure!(
@@ -125,7 +128,7 @@ pub(crate) fn regular_tree(path: &Path) -> Result<()> {
Ok(())
}
-pub(crate) fn copy_tree(source: &Path, destination: &Path) -> Result<()> {
+pub(super) fn copy_tree(source: &Path, destination: &Path) -> Result<()> {
regular_tree(source)?;
if source.is_file() {
let parent = destination.parent().context("File has no parent")?;
@@ -148,7 +151,7 @@ pub(crate) fn copy_tree(source: &Path, destination: &Path) -> Result<()> {
Ok(())
}
-pub(crate) fn copy_source(root: &Path, destination: &Path) -> Result<()> {
+pub(super) fn copy_source(root: &Path, destination: &Path) -> Result<()> {
for name in SOURCE {
let source = root.join(name);
if source.exists() {
@@ -158,7 +161,7 @@ pub(crate) fn copy_source(root: &Path, destination: &Path) -> Result<()> {
Ok(())
}
-pub(crate) fn inventory(root: &Path) -> Result<BTreeMap<String, String>> {
+pub(super) fn inventory(root: &Path) -> Result<BTreeMap<String, String>> {
let mut files = BTreeMap::new();
for item in WalkDir::new(root).follow_links(false) {
let entry = item?;
@@ -173,7 +176,7 @@ pub(crate) fn inventory(root: &Path) -> Result<BTreeMap<String, String>> {
Ok(files)
}
-pub(crate) fn manifest(root: &Path, kind: &str) -> Result<()> {
+pub(super) fn manifest(root: &Path, kind: &str) -> Result<()> {
let record = Manifest {
format: 2,
kind: kind.to_owned(),
@@ -188,7 +191,7 @@ pub(crate) fn manifest(root: &Path, kind: &str) -> Result<()> {
)
}
-pub(crate) fn verify(root: &Path) -> Result<Manifest> {
+pub(super) fn verify(root: &Path) -> Result<Manifest> {
let record: Manifest = serde_json::from_reader(File::open(root.join("manifest.json"))?)?;
ensure!(
record.format == 2,
@@ -201,7 +204,7 @@ pub(crate) fn verify(root: &Path) -> Result<Manifest> {
Ok(record)
}
-pub(crate) fn pack(root: &Path, archive: &Path) -> Result<()> {
+pub(super) fn pack(root: &Path, archive: &Path) -> Result<()> {
regular_tree(root)?;
let file = OpenOptions::new()
.write(true)
@@ -216,7 +219,7 @@ pub(crate) fn pack(root: &Path, archive: &Path) -> Result<()> {
Ok(())
}
-pub(crate) fn member_path(path: &Path, destination: &Path) -> Result<PathBuf> {
+pub(super) fn member_path(path: &Path, destination: &Path) -> Result<PathBuf> {
let mut parts = path.components();
ensure!(
parts.next() == Some(Component::Normal("stack".as_ref())),
@@ -232,7 +235,7 @@ pub(crate) fn member_path(path: &Path, destination: &Path) -> Result<PathBuf> {
Ok(target)
}
-pub(crate) fn extract(archive: &Path, destination: &Path) -> Result<()> {
+pub(super) fn extract(archive: &Path, destination: &Path) -> Result<()> {
private_dir(destination)?;
let mut reader = tar::Archive::new(GzDecoder::new(File::open(archive)?));
let mut directories = Vec::new();
@@ -276,15 +279,20 @@ mod tests {
use super::{copy_source, extract, manifest, member_path, pack, verify};
#[test]
- fn rejects_paths_outside_archive_root() {
+ fn rejects_paths_outside_archive_root() -> Result<()> {
let destination = Path::new("/restore");
for name in ["/etc/passwd", "../escape", "stack/../escape", "other/file"] {
- assert!(member_path(Path::new(name), destination).is_err());
+ anyhow::ensure!(
+ member_path(Path::new(name), destination).is_err(),
+ "Test invariant failed"
+ );
}
- assert_eq!(
- member_path(Path::new("stack/web/index.html"), destination).ok(),
- Some(destination.join("web/index.html"))
+ anyhow::ensure!(
+ (member_path(Path::new("stack/web/index.html"), destination).ok())
+ == (Some(destination.join("web/index.html"))),
+ "Test invariant failed"
);
+ Ok(())
}
#[test]
@@ -292,12 +300,12 @@ mod tests {
let root = tempfile::tempdir()?;
fs::write(root.path().join("public.txt"), "original")?;
manifest(root.path(), "release")?;
- assert!(verify(root.path()).is_ok());
+ anyhow::ensure!(verify(root.path()).is_ok(), "Test invariant failed");
fs::write(root.path().join("public.txt"), "changed")?;
- assert!(verify(root.path()).is_err());
+ anyhow::ensure!(verify(root.path()).is_err(), "Test invariant failed");
fs::write(root.path().join("public.txt"), "original")?;
fs::write(root.path().join("unexpected.txt"), "extra")?;
- assert!(verify(root.path()).is_err());
+ anyhow::ensure!(verify(root.path()).is_err(), "Test invariant failed");
Ok(())
}
@@ -306,13 +314,37 @@ mod tests {
let source = tempfile::tempdir()?;
let target = tempfile::tempdir()?;
fs::write(source.path().join("Cargo.toml"), "source")?;
+ for name in ["clippy.toml", "eslint.config.ts", "tsconfig.tools.json"] {
+ fs::write(source.path().join(name), "public lint configuration")?;
+ }
+ fs::create_dir_all(source.path().join("node_modules"))?;
+ fs::write(
+ source.path().join("node_modules/private-fixture"),
+ "private",
+ )?;
fs::write(source.path().join(".env"), "private")?;
fs::create_dir(source.path().join("var"))?;
fs::write(source.path().join("var/private.key"), "private")?;
copy_source(source.path(), target.path())?;
- assert!(target.path().join("Cargo.toml").is_file());
- assert!(!target.path().join(".env").exists());
- assert!(!target.path().join("var").exists());
+ anyhow::ensure!(
+ target.path().join("Cargo.toml").is_file(),
+ "Test invariant failed"
+ );
+ anyhow::ensure!(
+ !target.path().join(".env").exists(),
+ "Test invariant failed"
+ );
+ anyhow::ensure!(!target.path().join("var").exists(), "Test invariant failed");
+ for name in ["clippy.toml", "eslint.config.ts", "tsconfig.tools.json"] {
+ anyhow::ensure!(
+ target.path().join(name).is_file(),
+ "Required lint configuration omitted from export"
+ );
+ }
+ anyhow::ensure!(
+ !target.path().join("node_modules").exists(),
+ "Installed development packages entered export"
+ );
Ok(())
}
@@ -327,9 +359,15 @@ mod tests {
pack(&source, &archive)?;
let restored = workspace.path().join("restored");
extract(&archive, &restored)?;
- assert!(verify(&restored).is_ok());
- assert_eq!(fs::read_to_string(restored.join(".env"))?, "secret");
- assert!(extract(&archive, &restored).is_err());
+ anyhow::ensure!(verify(&restored).is_ok(), "Test invariant failed");
+ anyhow::ensure!(
+ (fs::read_to_string(restored.join(".env"))?) == ("secret"),
+ "Test invariant failed"
+ );
+ anyhow::ensure!(
+ extract(&archive, &restored).is_err(),
+ "Test invariant failed"
+ );
Ok(())
}
@@ -337,7 +375,10 @@ mod tests {
fn rejects_symbolic_links() -> Result<()> {
let root = tempfile::tempdir()?;
std::os::unix::fs::symlink("/etc/passwd", root.path().join("link"))?;
- assert!(super::regular_tree(root.path()).is_err());
+ anyhow::ensure!(
+ super::regular_tree(root.path()).is_err(),
+ "Test invariant failed"
+ );
Ok(())
}
}
diff --git a/src/images.rs b/src/app/images.rs
index 30019ca..fac7aa7 100644
--- a/src/images.rs
+++ b/src/app/images.rs
@@ -21,11 +21,11 @@ use anyhow::{Context, Result, ensure};
use serde::{Deserialize, Serialize};
use serde_json::Value;
-use crate::project::{Mode, Project, Settings, field};
-use crate::{files, process};
+use crate::app::project::{Mode, Project, Settings, field};
+use crate::app::{files, process};
#[derive(Debug, Deserialize, Serialize)]
-#[serde(deny_unknown_fields)]
+#[serde(crate = "serde", deny_unknown_fields)]
struct Image {
reference: String,
architecture: String,
@@ -34,7 +34,7 @@ struct Image {
}
#[derive(Debug, Deserialize, Serialize)]
-#[serde(deny_unknown_fields)]
+#[serde(crate = "serde", deny_unknown_fields)]
struct ImageLock {
format: u32,
platform: String,
@@ -51,7 +51,7 @@ fn inspect(project: &Project, name: &str) -> Result<Value> {
.context("Podman returned no image")
}
-pub(crate) fn save(project: &Project, containers: &[Value], destination: &Path) -> Result<()> {
+pub(super) fn save(project: &Project, containers: &[Value], destination: &Path) -> Result<()> {
fs::create_dir_all(destination)?;
let platform = project.platform()?;
let mut services = BTreeMap::new();
@@ -146,7 +146,7 @@ fn save_inner(
process::run(&mut command)
}
-pub(crate) fn prepare(project: &Project, settings: &mut Settings) -> Result<()> {
+pub(super) fn prepare(project: &Project, settings: &mut Settings) -> Result<()> {
let path = project.root.join("var/image-lock.json");
if settings.online || !path.is_file() {
return Ok(());
@@ -201,7 +201,7 @@ pub(crate) fn prepare(project: &Project, settings: &mut Settings) -> Result<()>
Ok(())
}
-pub(crate) fn configured(project: &Project, settings: &Settings) -> Result<Vec<Value>> {
+pub(super) fn configured(project: &Project, settings: &Settings) -> Result<Vec<Value>> {
let config = project.configuration(settings)?;
let mut records = Vec::new();
for (service, value) in config.services {
diff --git a/src/process.rs b/src/app/process.rs
index eb48bc8..02f6a6b 100644
--- a/src/process.rs
+++ b/src/app/process.rs
@@ -19,7 +19,7 @@ use std::process::{Command, Output};
use anyhow::{Context, Result, bail};
-pub(crate) fn capture(command: &mut Command) -> Result<Output> {
+pub(super) fn capture(command: &mut Command) -> Result<Output> {
let output = command.output().with_context(|| {
format!(
"Could not start {}",
@@ -37,11 +37,11 @@ pub(crate) fn capture(command: &mut Command) -> Result<Output> {
Ok(output)
}
-pub(crate) fn text(command: &mut Command) -> Result<String> {
+pub(super) fn text(command: &mut Command) -> Result<String> {
String::from_utf8(capture(command)?.stdout).context("Command returned non-UTF-8 output")
}
-pub(crate) fn run(command: &mut Command) -> Result<()> {
+pub(super) fn run(command: &mut Command) -> Result<()> {
let status = command.status().with_context(|| {
format!(
"Could not start {}",
@@ -57,14 +57,14 @@ pub(crate) fn run(command: &mut Command) -> Result<()> {
Ok(())
}
-pub(crate) fn available(command: &mut Command) -> bool {
+pub(super) fn available(command: &mut Command) -> bool {
command.output().is_ok_and(|output| output.status.success())
}
-pub(crate) fn line(message: fmt::Arguments<'_>) -> Result<()> {
+pub(super) fn line(message: fmt::Arguments<'_>) -> Result<()> {
writeln!(io::stdout().lock(), "{message}").context("Could not write command output")
}
-pub(crate) fn error(message: fmt::Arguments<'_>) -> io::Result<()> {
+pub(super) fn error(message: fmt::Arguments<'_>) -> io::Result<()> {
writeln!(io::stderr().lock(), "{message}")
}
diff --git a/src/project.rs b/src/app/project.rs
index 57185b1..ac55ff3 100644
--- a/src/project.rs
+++ b/src/app/project.rs
@@ -27,38 +27,38 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use stack_config::Config;
-use crate::{files, process};
+use crate::app::{files, process};
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
-#[serde(rename_all = "lowercase")]
-pub(crate) enum Mode {
+#[serde(crate = "serde", rename_all = "lowercase")]
+pub(super) enum Mode {
#[default]
Local,
Production,
}
#[derive(Debug, Deserialize, Serialize)]
-#[serde(deny_unknown_fields)]
-pub(crate) struct Settings {
- pub(crate) mode: Mode,
- pub(crate) port: u16,
- pub(crate) project: String,
+#[serde(crate = "serde", deny_unknown_fields)]
+pub(super) struct Settings {
+ pub(super) mode: Mode,
+ pub(super) port: u16,
+ pub(super) project: String,
#[serde(default)]
- pub(crate) online: bool,
+ pub(super) online: bool,
}
#[derive(Debug, Deserialize, Serialize)]
-#[serde(deny_unknown_fields)]
-pub(crate) struct Configuration {
- pub(crate) format: u32,
- pub(crate) services: BTreeMap<String, Service>,
+#[serde(crate = "serde", deny_unknown_fields)]
+pub(super) struct Configuration {
+ pub(super) format: u32,
+ pub(super) services: BTreeMap<String, Service>,
}
#[derive(Debug, Deserialize, Serialize)]
-#[serde(deny_unknown_fields)]
-pub(crate) struct Service {
- pub(crate) image: String,
- pub(crate) build: Option<String>,
+#[serde(crate = "serde", deny_unknown_fields)]
+pub(super) struct Service {
+ pub(super) image: String,
+ pub(super) build: Option<String>,
#[serde(default)]
gateway: bool,
#[serde(default)]
@@ -74,7 +74,7 @@ pub(crate) struct Service {
}
#[derive(Debug, Deserialize, Serialize)]
-#[serde(deny_unknown_fields)]
+#[serde(crate = "serde", deny_unknown_fields)]
struct Mount {
source: String,
target: String,
@@ -83,12 +83,12 @@ struct Mount {
}
#[derive(Debug)]
-pub(crate) struct Project {
- pub(crate) root: PathBuf,
+pub(super) struct Project {
+ pub(super) root: PathBuf,
}
impl Project {
- pub(crate) fn discover(explicit: Option<&Path>) -> Result<Self> {
+ pub(super) fn discover(explicit: Option<&Path>) -> Result<Self> {
if let Some(path) = explicit {
return Self::at(path);
}
@@ -107,7 +107,7 @@ impl Project {
bail!("Run from the stack directory or supply --root PATH")
}
- pub(crate) fn at(path: &Path) -> Result<Self> {
+ pub(super) fn at(path: &Path) -> Result<Self> {
let root = if path.is_absolute() {
path.to_path_buf()
} else {
@@ -116,7 +116,7 @@ impl Project {
Ok(Self { root })
}
- pub(crate) fn settings(&self) -> Result<Settings> {
+ pub(super) fn settings(&self) -> Result<Settings> {
let path = self.root.join("var/state.json");
if path.exists() {
let mut settings: Settings = serde_json::from_reader(File::open(path)?)?;
@@ -140,7 +140,7 @@ impl Project {
})
}
- pub(crate) fn save(&self, settings: &Settings) -> Result<()> {
+ pub(super) fn save(&self, settings: &Settings) -> Result<()> {
files::write(
&self.root.join("var/state.json"),
&serde_json::to_vec_pretty(settings)?,
@@ -148,7 +148,7 @@ impl Project {
)
}
- pub(crate) fn lock(&self) -> Result<File> {
+ pub(super) fn lock(&self) -> Result<File> {
files::private_dir(&self.root.join("var"))?;
let file = OpenOptions::new()
.read(true)
@@ -162,7 +162,7 @@ impl Project {
Ok(file)
}
- pub(crate) fn command(&self, name: &str) -> Result<Command> {
+ pub(super) fn command(&self, name: &str) -> Result<Command> {
let mut command = Command::new(name);
command.current_dir(&self.root);
if let Some(tools) = self
@@ -200,7 +200,7 @@ impl Project {
Ok(command)
}
- pub(crate) fn platform(&self) -> Result<String> {
+ pub(super) fn platform(&self) -> Result<String> {
let info: Value = serde_json::from_slice(
&process::capture(self.command("podman")?.args(["info", "--format", "json"]))?.stdout,
)?;
@@ -215,7 +215,7 @@ impl Project {
))
}
- pub(crate) fn configuration(&self, settings: &Settings) -> Result<Configuration> {
+ pub(super) fn configuration(&self, settings: &Settings) -> Result<Configuration> {
let mut config: Configuration =
toml::from_str(&fs::read_to_string(self.root.join("infra/services.toml"))?)?;
ensure!(
@@ -235,7 +235,7 @@ impl Project {
Ok(config)
}
- pub(crate) fn initialize(&self, settings: &Settings) -> Result<()> {
+ pub(super) fn initialize(&self, settings: &Settings) -> Result<()> {
files::private_dir(&self.root.join("var/data"))?;
let env_file = self.root.join(".env");
for key in ["SEARXNG_SECRET", "CHAT_SETUP_TOKEN"] {
@@ -282,14 +282,14 @@ impl Project {
&self.root.join("infra/searxng"),
&self.root.join("var/data/searxng"),
)?;
- crate::render::prepare(self, settings)
+ crate::app::render::prepare(self, settings)
}
- pub(crate) fn container_name(settings: &Settings, service: &str) -> String {
+ pub(super) fn container_name(settings: &Settings, service: &str) -> String {
format!("{}-{service}", settings.project)
}
- pub(crate) fn inspect_containers(&self, settings: &Settings) -> Result<Vec<Value>> {
+ pub(super) fn inspect_containers(&self, settings: &Settings) -> Result<Vec<Value>> {
let output = process::text(self.command("podman")?.args([
"ps",
"--all",
@@ -304,7 +304,7 @@ impl Project {
)?)
}
- pub(crate) fn up(&self, settings: &Settings) -> Result<()> {
+ pub(super) fn up(&self, settings: &Settings) -> Result<()> {
ensure!(
(1024..=65_534).contains(&settings.port),
"Local port must be between 1024 and 65534"
@@ -352,13 +352,13 @@ impl Project {
))
}
- pub(crate) fn ensure_image(&self, settings: &Settings, service: &Service) -> Result<()> {
+ pub(super) fn ensure_image(&self, settings: &Settings, service: &Service) -> Result<()> {
if settings.mode == Mode::Local
&& (settings.online || !self.root.join("var/image-lock.json").exists())
&& let Some(context) = &service.build
{
if context == "bin/chat" && !self.root.join("bin/chat/server").is_file() {
- crate::chat::build(self)?;
+ crate::app::chat::build(self)?;
}
return process::run(
self.command("podman")?
@@ -429,11 +429,14 @@ impl Project {
let http = environment.port("HTTP_PORT")?;
let https = environment.port("HTTPS_PORT")?;
vec![
- format!("{}:{http}:{http}", crate::chat::web_bind(environment)?),
- format!("{}:{https}:{https}", crate::chat::web_bind(environment)?),
+ format!("{}:{http}:{http}", crate::app::chat::web_bind(environment)?),
+ format!(
+ "{}:{https}:{https}",
+ crate::app::chat::web_bind(environment)?
+ ),
format!(
"{}:{https}:{https}/udp",
- crate::chat::web_bind(environment)?
+ crate::app::chat::web_bind(environment)?
),
]
};
@@ -448,13 +451,13 @@ impl Project {
.env(key, environment.value(key)?);
}
if name == "chat" {
- crate::chat::environment(&mut command, environment, settings)?;
+ crate::app::chat::environment(&mut command, environment, settings)?;
}
for mount in &service.mounts {
let source = match mount.source.as_str() {
- "@caddy" => crate::render::directory(self, settings).join("Caddyfile"),
- "@web" => crate::render::directory(self, settings).join("web"),
- "@cgit" => crate::render::directory(self, settings).join("cgitrc"),
+ "@caddy" => crate::app::render::directory(self, settings).join("Caddyfile"),
+ "@web" => crate::app::render::directory(self, settings).join("web"),
+ "@cgit" => crate::app::render::directory(self, settings).join("cgitrc"),
"@chat" => self.root.join(if settings.mode == Mode::Local {
"var/data/chat-local"
} else {
@@ -489,7 +492,7 @@ impl Project {
bail!("Service did not become ready: {name}; inspect its Podman logs")
}
- pub(crate) fn status(&self, settings: &Settings) -> Result<()> {
+ pub(super) fn status(&self, settings: &Settings) -> Result<()> {
process::run(self.command("podman")?.args([
"ps",
"--all",
@@ -498,7 +501,7 @@ impl Project {
]))
}
- pub(crate) fn down(&self, settings: &Settings) -> Result<()> {
+ pub(super) fn down(&self, settings: &Settings) -> Result<()> {
for name in self.configuration(settings)?.services.keys() {
process::capture(self.command("podman")?.args([
"rm",
@@ -528,7 +531,7 @@ impl Project {
}
}
-pub(crate) fn field<'a>(value: &'a Value, path: &[&str]) -> Result<&'a Value> {
+pub(super) fn field<'a>(value: &'a Value, path: &[&str]) -> Result<&'a Value> {
path.iter().try_fold(value, |node, key| {
node.get(key)
.with_context(|| format!("Missing JSON field {key}"))
diff --git a/src/release.rs b/src/app/release.rs
index 92f2df4..268a3c2 100644
--- a/src/release.rs
+++ b/src/app/release.rs
@@ -18,12 +18,12 @@ use std::path::Path;
use anyhow::{Context, Result, ensure};
-use crate::project::{Mode, Project};
-use crate::{files, images, process};
+use crate::app::project::{Mode, Project};
+use crate::app::{files, images, process};
-pub(crate) fn create(project: &Project, destination: &Path) -> Result<()> {
- crate::check(project)?;
- crate::chat::build(project)?;
+pub(super) fn create(project: &Project, destination: &Path) -> Result<()> {
+ crate::app::check(project)?;
+ crate::app::chat::build(project)?;
files::write(
&project.root.join("bin/stack"),
&fs::read(
@@ -46,7 +46,7 @@ pub(crate) fn create(project: &Project, destination: &Path) -> Result<()> {
let stage = temp.path().join("stack");
files::private_dir(&stage)?;
files::copy_source(&project.root, &stage)?;
- crate::chat::copy_artifacts(project, &stage)?;
+ crate::app::chat::copy_artifacts(project, &stage)?;
fs::create_dir_all(stage.join("bin"))?;
fs::copy(
project
@@ -77,7 +77,7 @@ pub(crate) fn create(project: &Project, destination: &Path) -> Result<()> {
process::line(format_args!("Release: {}", output.display()))
}
-pub(crate) fn activate(archive: &Path, project: &Project) -> Result<()> {
+pub(super) fn activate(archive: &Path, project: &Project) -> Result<()> {
let temp = tempfile::Builder::new()
.prefix(".activate-")
.tempdir_in(&project.root)?;
@@ -115,7 +115,7 @@ pub(crate) fn activate(archive: &Path, project: &Project) -> Result<()> {
&fs::read(stage.join("bin/stack"))?,
0o755,
)?;
- crate::chat::copy_artifacts(&pending, &project.root)?;
+ crate::app::chat::copy_artifacts(&pending, &project.root)?;
for name in ["images.tar", "image-lock.json"] {
files::copy_tree(
&stage.join("var").join(name),
diff --git a/src/render.rs b/src/app/render.rs
index a552643..8a2d2ee 100644
--- a/src/render.rs
+++ b/src/app/render.rs
@@ -13,21 +13,21 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-use crate::files;
-use crate::project::{Mode, Project, Settings};
+use crate::app::files;
+use crate::app::project::{Mode, Project, Settings};
use anyhow::Result;
use stack_config::Config;
use std::fs;
use std::path::PathBuf;
-pub(crate) fn directory(project: &Project, settings: &Settings) -> PathBuf {
+pub(super) fn directory(project: &Project, settings: &Settings) -> PathBuf {
project.root.join(match settings.mode {
Mode::Local => "var/local",
Mode::Production => "var/production",
})
}
-pub(crate) fn prepare(project: &Project, settings: &Settings) -> Result<()> {
+pub(super) fn prepare(project: &Project, settings: &Settings) -> Result<()> {
let config = Config::load(&project.root)?;
let local = (settings.mode == Mode::Local).then_some(settings.port);
let output = directory(project, settings);
@@ -91,23 +91,39 @@ mod tests {
prepare(&project, &settings)?;
let output = directory(&project, &settings);
let caddy = fs::read_to_string(output.join("Caddyfile"))?;
- assert!(caddy.contains("https://home.example.net:8443"));
- assert!(caddy.contains("http_port 8181"));
- assert!(!caddy.contains("do-not-publish"));
+ anyhow::ensure!(
+ caddy.contains("https://home.example.net:8443"),
+ "Test invariant failed"
+ );
+ anyhow::ensure!(caddy.contains("http_port 8181"), "Test invariant failed");
+ anyhow::ensure!(!caddy.contains("do-not-publish"), "Test invariant failed");
let homepage = fs::read_to_string(output.join("web/index.html"))?;
- assert!(homepage.contains("https://git.example.net:8443"));
+ anyhow::ensure!(
+ homepage.contains("https://git.example.net:8443"),
+ "Test invariant failed"
+ );
let stylesheet = files::hash_file(&root.path().join("web/styles.css"))?;
- assert!(homepage.contains(&format!("/styles.css?v={stylesheet}")));
- assert!(homepage.contains("https://chat.doan.luuduct.ai:8443/LIMITATIONS.txt"));
+ anyhow::ensure!(
+ homepage.contains(&format!("/styles.css?v={stylesheet}")),
+ "Test invariant failed"
+ );
+ anyhow::ensure!(
+ homepage.contains("https://chat.doan.luuduct.ai:8443/LIMITATIONS.txt"),
+ "Test invariant failed"
+ );
settings.mode = Mode::Local;
settings.port = 8180;
prepare(&project, &settings)?;
let preview = fs::read_to_string(directory(&project, &settings).join("web/index.html"))?;
- assert!(preview.contains("href=\"/git/\""));
- assert!(preview.contains("http://chat.localhost:8180/LIMITATIONS.txt"));
- assert!(
+ anyhow::ensure!(preview.contains("href=\"/git/\""), "Test invariant failed");
+ anyhow::ensure!(
+ preview.contains("http://chat.localhost:8180/LIMITATIONS.txt"),
+ "Test invariant failed"
+ );
+ anyhow::ensure!(
fs::read_to_string(directory(&project, &settings).join("cgitrc"))?
- .contains("clone-prefix=http://localhost:8180/git")
+ .contains("clone-prefix=http://localhost:8180/git"),
+ "Test invariant failed"
);
Ok(())
}
diff --git a/src/main.rs b/src/main.rs
index e6323e2..942a379 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -13,326 +13,12 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
-#![doc = "Rust commands for personal web infrastructure."]
+#![doc = "Checked command-line entry point for the personal infrastructure stack."]
-pub(crate) mod archive;
-pub(crate) mod chat;
-pub(crate) mod files;
-pub(crate) mod images;
-pub(crate) mod process;
-pub(crate) mod project;
-pub(crate) mod release;
-pub(crate) mod render;
+mod app;
-use std::path::{Path, PathBuf};
-use std::process::ExitCode;
+pub use app::run;
-use anyhow::Result;
-use clap::{Parser, Subcommand};
-
-use crate::project::{Mode, Project};
-
-#[derive(Debug, Parser)]
-#[command(
- name = "stack",
- version,
- about = "Develop, deploy, and back up the personal web stack"
-)]
-struct Cli {
- #[arg(
- long,
- global = true,
- help = "Application root; otherwise discovered from the working directory or binary"
- )]
- root: Option<PathBuf>,
- #[command(subcommand)]
- command: Action,
-}
-
-#[derive(Debug, Subcommand)]
-enum Action {
- #[command(about = "Run experimental local chat or administer its packaged deployment")]
- Chat {
- #[arg(long, conflicts_with_all = ["reset_owner", "owner_fingerprint"])]
- init: bool,
- #[arg(
- long,
- value_name = "FINGERPRINT",
- conflicts_with = "owner_fingerprint",
- help = "Permanently clear owner registration and chat history after key loss"
- )]
- reset_owner: Option<String>,
- #[arg(long, help = "Print the registered owner's public fingerprint")]
- owner_fingerprint: bool,
- },
- #[command(about = "Start the local development services")]
- Dev {
- #[arg(long)]
- port: Option<u16>,
- },
- #[command(about = "Start or update services in the selected mode")]
- Up {
- #[arg(long, conflicts_with = "local")]
- production: bool,
- #[arg(long)]
- local: bool,
- #[arg(long)]
- port: Option<u16>,
- #[arg(long)]
- online: bool,
- },
- #[command(
- about = "Render public Caddy, website, and cgit configuration without starting services"
- )]
- Config {
- #[arg(long)]
- production: bool,
- #[arg(long)]
- port: Option<u16>,
- },
- #[command(about = "Show service status")]
- Status,
- #[command(about = "Stop services while retaining data")]
- Down,
- #[command(about = "Create a GPG-encrypted application export")]
- Backup {
- destination: Option<PathBuf>,
- #[arg(long)]
- include_images: bool,
- #[arg(long)]
- recipient: Option<String>,
- #[arg(long)]
- key: Option<PathBuf>,
- },
- #[command(
- about = "Decrypt, validate, and restore into a new directory without starting services"
- )]
- Restore {
- archive: PathBuf,
- destination: PathBuf,
- },
- #[command(about = "Run formatting, strict lints, tests, documentation, and dependency checks")]
- Check,
- #[command(about = "Check and build a portable production release locally")]
- Release { destination: Option<PathBuf> },
- #[command(about = "Install a prebuilt release without compiling on the server")]
- Activate { archive: PathBuf },
- #[command(about = "Generate the third-party license page with GNU license names")]
- Licenses,
-}
-
-fn main() -> ExitCode {
- let arguments = match Cli::try_parse() {
- Ok(arguments) => arguments,
- Err(error) => {
- let status = if error.use_stderr() {
- ExitCode::FAILURE
- } else {
- ExitCode::SUCCESS
- };
- drop(error.print());
- return status;
- }
- };
- match execute(arguments) {
- Ok(()) => ExitCode::SUCCESS,
- Err(error) => {
- drop(process::error(format_args!("Error: {error:#}")));
- ExitCode::FAILURE
- }
- }
-}
-
-fn execute(arguments: Cli) -> Result<()> {
- if let Action::Restore {
- archive,
- destination,
- } = &arguments.command
- {
- return archive::restore(&absolute(archive)?, &absolute(destination)?);
- }
- let project = Project::discover(arguments.root.as_deref())?;
- if let Action::Chat {
- init,
- reset_owner,
- owner_fingerprint,
- } = arguments.command
- {
- return chat(&project, init, reset_owner.as_deref(), owner_fingerprint);
- }
-
- let _operation = project.lock()?;
- match arguments.command {
- Action::Dev { port } => {
- let mut settings = project.settings()?;
- settings.mode = Mode::Local;
- if let Some(selected_port) = port {
- settings.port = selected_port;
- }
- images::prepare(&project, &mut settings)?;
- project.up(&settings)
- }
- Action::Up {
- production,
- local,
- port,
- online,
- } => {
- let mut settings = project.settings()?;
- if production {
- settings.mode = Mode::Production;
- }
- if local {
- settings.mode = Mode::Local;
- }
- if let Some(selected_port) = port {
- settings.port = selected_port;
- }
- if online {
- settings.online = true;
- }
- images::prepare(&project, &mut settings)?;
- project.up(&settings)
- }
- Action::Config { production, port } => {
- let mut settings = project.settings()?;
- settings.mode = if production {
- Mode::Production
- } else {
- Mode::Local
- };
- if let Some(selected_port) = port {
- settings.port = selected_port;
- }
- stack_config::initialize_public(&project.root)?;
- render::prepare(&project, &settings)?;
- process::line(format_args!(
- "Public configuration: {}",
- render::directory(&project, &settings).display()
- ))
- }
- Action::Status => project.status(&project.settings()?),
- Action::Down => project.down(&project.settings()?),
- Action::Backup {
- destination,
- include_images,
- recipient,
- key,
- } => {
- let output = destination.map_or_else(
- || Ok(project.root.join("var/exports")),
- |path| absolute(&path),
- )?;
- let configuration = stack_config::Config::load(&project.root)?;
- let selected_recipient =
- recipient.unwrap_or(configuration.value("BACKUP_RECIPIENT")?.to_owned());
- archive::backup(
- &project,
- &output,
- include_images,
- &selected_recipient,
- key.as_deref(),
- )
- }
- Action::Release { destination } => {
- let output = destination
- .map_or_else(|| Ok(project.root.join("dist")), |path| absolute(&path))?;
- release::create(&project, &output)
- }
- Action::Activate { archive } => release::activate(&absolute(&archive)?, &project),
- Action::Check => check(&project),
- Action::Licenses => licenses(&project),
- Action::Chat { .. } => anyhow::bail!("Chat action was not handled"),
- Action::Restore { .. } => anyhow::bail!("Restore action was not handled"),
- }
-}
-
-fn chat(
- project: &Project,
- init: bool,
- reset_owner: Option<&str>,
- owner_fingerprint: bool,
-) -> Result<()> {
- if project.settings()?.mode == Mode::Production {
- return chat::admin(project, init, reset_owner, owner_fingerprint);
- }
- let mut command = project.command("cargo")?;
- command
- .args(["run", "--locked", "-p", "stack-chat-server", "--", "--root"])
- .arg(&project.root);
- if init {
- command.arg("--init");
- }
- if let Some(fingerprint) = reset_owner {
- command.arg("--reset-owner").arg(fingerprint);
- }
- if owner_fingerprint {
- command.arg("--owner-fingerprint");
- }
- process::run(&mut command)
-}
-
-fn absolute(path: &Path) -> Result<PathBuf> {
- Ok(if path.is_absolute() {
- path.to_path_buf()
- } else {
- std::env::current_dir()?.join(path)
- })
-}
-
-fn licenses(project: &Project) -> Result<()> {
- process::run(project.command("cargo")?.args([
- "about",
- "generate",
- "--workspace",
- "--locked",
- "about.hbs",
- "--output-file",
- "web/THIRD-PARTY-LICENSES.html",
- ]))
-}
-
-fn check(project: &Project) -> Result<()> {
- process::run(project.command("sh")?.arg("infra/chat/build-typescript.sh"))?;
- process::run(
- project
- .command("cargo")?
- .args(["fmt", "--all", "--", "--check"]),
- )?;
- process::run(project.command("cargo")?.args([
- "clippy",
- "--locked",
- "--workspace",
- "--all-targets",
- "--all-features",
- "--",
- "-D",
- "warnings",
- ]))?;
- process::run(project.command("cargo")?.args([
- "test",
- "--locked",
- "--workspace",
- "--all-targets",
- "--all-features",
- ]))?;
- process::run(
- project
- .command("cargo")?
- .env("RUSTDOCFLAGS", "-D warnings")
- .args([
- "doc",
- "--locked",
- "--workspace",
- "--no-deps",
- "--document-private-items",
- ]),
- )?;
- process::run(project.command("cargo")?.args([
- "deny",
- "--workspace",
- "--all-features",
- "check",
- ]))?;
- licenses(project)
+fn main() -> std::process::ExitCode {
+ run()
}
diff --git a/tests/browser-support.ts b/tests/browser-support.ts
index 223b2b8..d4ecef9 100644
--- a/tests/browser-support.ts
+++ b/tests/browser-support.ts
@@ -14,7 +14,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import assert from 'node:assert/strict';
-import type { Server } from 'node:http';
+import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
export function required<T>(value: T | undefined | null, message: string): T {
assert.ok(value !== undefined && value !== null, message);
@@ -43,8 +43,8 @@ export function list(value: unknown): unknown[] {
export function serverOrigin(server: Server): string {
const address = server.address();
- assert.ok(address && typeof address !== 'string');
- return `http://127.0.0.1:${address.port}`;
+ assert.ok(address !== null && typeof address !== 'string');
+ return `http://127.0.0.1:${String(address.port)}`;
}
export function requestBody(text: string): { op: string; count: number; [key: string]: unknown } {
@@ -54,7 +54,7 @@ export function requestBody(text: string): { op: string; count: number; [key: st
export function libreJSAddon(text: string): { active: boolean; version: string } {
const addon = list(record(JSON.parse(text))['addons']).map(record).find(value => value['id'] === 'jid1-KtlZuoiikVfFew@jetpack');
- assert.ok(addon);
+ assert.ok((addon !== undefined));
assert.equal(addon['active'], true);
return { active: true, version: string(addon['version']) };
}
@@ -63,3 +63,18 @@ declare global {
interface Window { unlicensedControl?: boolean; chatInjection?: boolean }
const wasm: Promise<ArrayBuffer>;
}
+
+export function asyncServer(handler: (request: IncomingMessage, response: ServerResponse) => Promise<void>): Server {
+ return createServer((request, response) => {
+ handler(request, response).catch((error: unknown) => {
+ response.destroy(error instanceof Error ? error : new Error('Fixture request failed', { cause: error }));
+ });
+ });
+}
+
+export function closeBrowser(browser: { close(): Promise<void> }): void {
+ browser.close().catch((error: unknown) => {
+ process.exitCode = 1;
+ process.stderr.write(`Browser shutdown failed: ${String(error)}\n`);
+ });
+}
diff --git a/tsconfig.browser.json b/tsconfig.browser.json
index 3f0b667..8836df0 100644
--- a/tsconfig.browser.json
+++ b/tsconfig.browser.json
@@ -21,6 +21,7 @@
"DOM.Iterable"
],
"module": "Preserve",
+ "moduleResolution": "Bundler",
"moduleDetection": "legacy",
"rootDir": ".",
"outDir": "var/build/typescript",
diff --git a/tsconfig.json b/tsconfig.json
index bf78056..89a29b0 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -16,6 +16,10 @@
"compilerOptions": {
"target": "ES2022",
"strict": true,
+ "noCheck": false,
+ "allowJs": false,
+ "skipDefaultLibCheck": false,
+ "isolatedModules": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"noUncheckedSideEffectImports": true,
diff --git a/tsconfig.tools.json b/tsconfig.tools.json
new file mode 100644
index 0000000..fbe511e
--- /dev/null
+++ b/tsconfig.tools.json
@@ -0,0 +1,33 @@
+// Copyright (C) 2026 Doan Luu Duc Tai <doan@luuduct.ai>
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <https://www.gnu.org/licenses/>.
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "lib": [
+ "ES2022"
+ ],
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "moduleDetection": "force",
+ "rootDir": ".",
+ "outDir": "var/build/typescript",
+ "types": [
+ "node"
+ ]
+ },
+ "files": [
+ "eslint.config.ts"
+ ]
+}
diff --git a/tsconfig.worker.json b/tsconfig.worker.json
index dd328b0..64dbd52 100644
--- a/tsconfig.worker.json
+++ b/tsconfig.worker.json
@@ -20,6 +20,7 @@
"WebWorker"
],
"module": "Preserve",
+ "moduleResolution": "Bundler",
"moduleDetection": "legacy",
"rootDir": ".",
"outDir": "var/build/typescript",
diff --git a/web/THIRD-PARTY-LICENSES.html b/web/THIRD-PARTY-LICENSES.html
index ab8e687..b0e7c92 100644
--- a/web/THIRD-PARTY-LICENSES.html
+++ b/web/THIRD-PARTY-LICENSES.html
@@ -32,7 +32,7 @@ applications and their operating-system packages have their own notices.</p>
<p>Headings follow <a href="https://www.gnu.org/licenses/license-list.html">GNU's
license names</a> where listed. SPDX identifiers remain available for exact identification.
The original license texts below are unchanged.</p>
-<ul><li><a href="#MIT">Expat License</a> (208)</li><li><a href="#Unicode-3.0">Unicode License v3</a> (19)</li><li><a href="#Apache-2.0">Apache License, Version 2.0</a> (4)</li><li><a href="#BSD-3-Clause">Modified BSD License</a> (3)</li><li><a href="#LGPL-2.0-or-later">GNU Library General Public License, version 2 or later</a> (2)</li><li><a href="#BSL-1.0">Boost Software License</a> (1)</li><li><a href="#ISC">ISC License</a> (1)</li><li><a href="#Zlib">License of ZLib</a> (1)</li></ul>
+<ul><li><a href="#MIT">Expat License</a> (206)</li><li><a href="#Unicode-3.0">Unicode License v3</a> (19)</li><li><a href="#Apache-2.0">Apache License, Version 2.0</a> (4)</li><li><a href="#BSD-3-Clause">Modified BSD License</a> (3)</li><li><a href="#LGPL-2.0-or-later">GNU Library General Public License, version 2 or later</a> (2)</li><li><a href="#BSL-1.0">Boost Software License</a> (1)</li><li><a href="#ISC">ISC License</a> (1)</li><li><a href="#Zlib">License of ZLib</a> (1)</li></ul>
<section id="license-0" data-spdx="Apache-2.0">
<h2 id="Apache-2.0">Apache License, Version 2.0</h2>
<ul><li><a href="https://github.com/KyleMayes/clang-sys">clang-sys 1.9.1</a></li></ul>
@@ -1790,7 +1790,7 @@ DEALINGS IN THE SOFTWARE.
</section>
<section id="license-30" data-spdx="MIT">
-<ul><li><a href="https://github.com/withoutboats/heck">heck 0.5.0</a></li><li><a href="https://github.com/unicode-rs/unicode-width">unicode-width 0.2.3</a></li><li><a href="https://github.com/unicode-rs/unicode-xid">unicode-xid 0.2.6</a></li></ul>
+<ul><li><a href="https://github.com/unicode-rs/unicode-width">unicode-width 0.2.3</a></li><li><a href="https://github.com/unicode-rs/unicode-xid">unicode-xid 0.2.6</a></li></ul>
<pre>Copyright (c) 2015 The Rust Project Developers
Permission is hereby granted, free of charge, to any
@@ -3258,7 +3258,7 @@ DEALINGS IN THE SOFTWARE.
</section>
<section id="license-80" data-spdx="MIT">
-<ul><li><a href="https://github.com/rust-lang/annotate-snippets-rs">annotate-snippets 0.11.5</a></li><li><a href="https://github.com/rust-cli/anstyle.git">anstream 1.0.0</a></li><li><a href="https://github.com/rust-cli/anstyle.git">anstyle-parse 1.0.0</a></li><li><a href="https://github.com/rust-cli/anstyle.git">anstyle-query 1.1.5</a></li><li><a href="https://github.com/rust-cli/anstyle.git">anstyle-wincon 3.0.11</a></li><li><a href="https://github.com/rust-cli/anstyle.git">anstyle 1.0.14</a></li><li><a href="https://github.com/clap-rs/clap">clap 4.6.7</a></li><li><a href="https://github.com/clap-rs/clap">clap_builder 4.6.7</a></li><li><a href="https://github.com/clap-rs/clap">clap_derive 4.6.7</a></li><li><a href="https://github.com/clap-rs/clap">clap_lex 1.1.1</a></li><li><a href="https://github.com/rust-cli/anstyle.git">colorchoice 1.0.5</a></li><li><a href="https://github.com/polyfill-rs/is_terminal_polyfill">is_terminal_polyfill 1.70.2</a></li><li><a href="https://github.com/polyfill-rs/once_cell_polyfill">once_cell_polyfill 1.70.2</a></li><li><a href="https://github.com/toml-rs/toml">serde_spanned 1.1.2</a></li><li><a href="https://github.com/toml-rs/toml">toml 1.1.8+spec-1.1.0</a></li><li><a href="https://github.com/toml-rs/toml">toml_datetime 1.1.2+spec-1.1.0</a></li><li><a href="https://github.com/toml-rs/toml">toml_parser 1.1.5+spec-1.1.0</a></li><li><a href="https://github.com/toml-rs/toml">toml_writer 1.1.3+spec-1.1.0</a></li></ul>
+<ul><li><a href="https://github.com/rust-lang/annotate-snippets-rs">annotate-snippets 0.11.5</a></li><li><a href="https://github.com/rust-cli/anstyle.git">anstream 1.0.0</a></li><li><a href="https://github.com/rust-cli/anstyle.git">anstyle-parse 1.0.0</a></li><li><a href="https://github.com/rust-cli/anstyle.git">anstyle-query 1.1.5</a></li><li><a href="https://github.com/rust-cli/anstyle.git">anstyle-wincon 3.0.11</a></li><li><a href="https://github.com/rust-cli/anstyle.git">anstyle 1.0.14</a></li><li><a href="https://github.com/clap-rs/clap">clap 4.6.7</a></li><li><a href="https://github.com/clap-rs/clap">clap_builder 4.6.7</a></li><li><a href="https://github.com/clap-rs/clap">clap_lex 1.1.1</a></li><li><a href="https://github.com/rust-cli/anstyle.git">colorchoice 1.0.5</a></li><li><a href="https://github.com/polyfill-rs/is_terminal_polyfill">is_terminal_polyfill 1.70.2</a></li><li><a href="https://github.com/polyfill-rs/once_cell_polyfill">once_cell_polyfill 1.70.2</a></li><li><a href="https://github.com/toml-rs/toml">serde_spanned 1.1.2</a></li><li><a href="https://github.com/toml-rs/toml">toml 1.1.8+spec-1.1.0</a></li><li><a href="https://github.com/toml-rs/toml">toml_datetime 1.1.2+spec-1.1.0</a></li><li><a href="https://github.com/toml-rs/toml">toml_parser 1.1.5+spec-1.1.0</a></li><li><a href="https://github.com/toml-rs/toml">toml_writer 1.1.3+spec-1.1.0</a></li></ul>
<pre>Copyright (c) Individual contributors
Permission is hereby granted, free of charge, to any person obtaining a copy