diff --git a/.agents/scripts/protocol-bump-check.mjs b/.agents/scripts/protocol-bump-check.mjs index 32305d8cc..b45074bc7 100644 --- a/.agents/scripts/protocol-bump-check.mjs +++ b/.agents/scripts/protocol-bump-check.mjs @@ -10,7 +10,13 @@ import { execFileSync } from "node:child_process"; const PROTOCOL = "src/contract/protocol.ts"; -const NOT_SHAPES = new Set(["index.ts", "console-url.ts", "duration.ts", "os-range.ts"]); +const NOT_SHAPES = new Set([ + "index.ts", + "console-url.ts", + "duration.ts", + "os-range.ts", + "version-order.ts", +]); const RANGE = /PROTOCOL_VERSION_RANGE[^=]*=\s*\{\s*min:\s*(\d+),\s*max:\s*(\d+)/; const TRAILER = /^Protocol-bump: none \(.+\)\s*$/m; diff --git a/.fallowrc.json b/.fallowrc.json index 82c53c3cd..9d4ff779c 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -57,7 +57,9 @@ "@tanstack/react-table", "@tanstack/react-virtual", // Recharts' peer dependency: installed for it, never imported by the console itself. - "react-is" + "react-is", + // The update e2e (e2e/update/update.e2e.test.ts) runs its binary as a local npm registry. + "verdaccio" ], "ignoreDependencyOverrides": [ // MCP SDK 1.29 transitively requires vulnerable node-server 1.x; lockfile/audit verify this patched override. diff --git a/docs/internal/EVENTS.md b/docs/internal/EVENTS.md index 960872889..b24197064 100644 --- a/docs/internal/EVENTS.md +++ b/docs/internal/EVENTS.md @@ -52,7 +52,7 @@ in short: `subject.past-tense-fact`, emitted post-commit, facts not commands. | `lease.renewed` | lease id, new deadline | a `lease.renew` succeeded — whether it came from `simlock lease renew`, `POST /v1/leases/{id}/renew`, or the renew timer a running `simlock lease` / MCP session keeps over its own lease. There is one renew path and this is it | LeaseLifecycle | implemented (payload per ADR 0004 pending) | | `lease.released` | lease id, device id, reason (explicit/killed/device-lost), owner id | an explicit `lease.release` (which is what a `simlock lease` holder does on its way out), (killed) an operator `release --all` or `nuke`, or (device-lost) a leased device could not be recovered after it stopped running outside simlock, or a daemon start found its device not running, or (device-lost, ADR 0022 §7) a physical device was not present for the grace period (`health.stableObservations` reads in a row, `health.probeIntervalMs` apart; emitted by the `PhysicalDeviceWatcher` through `LeaseReleaseCoordinator.endForAbsentDevice`; the device becomes `absent` in the same write and nothing is wiped), and the device of that lease was wiped and returned to the pool, left waiting in `reclaiming` on a platform the daemon could not list, or marked missing. Closing a connection is not a release and never emits this | LeaseLifecycle | implemented (payload per ADR 0004 pending) | | `lease.expired` | lease id, device id, owner id | the lease's deadline passed with no `lease.renew` behind it — the grant-time TTL, or the TTL of the last renew, simply ran out. This is the one way a lease ends without somebody asking, and the only bound on a holder that was killed outright | LeaseLifecycle | implemented (payload per ADR 0004 pending) | -| `lease.rejected` | request id, requester (both required on every reason, additive, ADR 0016 §2), request spec (as on `lease.requested`; `full` replaced by `mode`, ADR 0007 §13; `model` optional and `class` added, ADR 0015 §9; `osVersion` may be a range as typed, ADR 0015 §2), reason (timeout/no-wait/unresolvable-spec/no-worker/already-leased/lease-id-taken/boot-timeout/killed/cancelled/daemon-restarted/worker-failed), `code` and `worker` on `worker-failed` (ADR 0021 §4, additive; `worker`, never `workerId`, which marks a relayed event, ADR 0014 §6) | a request ended without a grant. A worker emits it only for its local requests; a probe ends in `lease.declined` there (ADR 0021 §2). A gateway emits it for every ending that is not a grant (ADR 0021 §4): its own reasons, `unresolvable-spec` for the last cannot-serve refusal whether or not the request had queued, and `worker-failed` for any other failure on the worker it went to -- a terminal refusal, a failure after progress, `WORKER_UNREACHABLE`, `INTERNAL`, a dispatch timeout, a worker's own `REQUESTER_ALREADY_LEASED` or `LEASE_ID_TAKEN` -- with `code` the error code the caller got and `worker` the worker. A worker's `PROVISION_FAILED` before it pushed progress is not among them (after progress it is a failure after progress, relayed as it is): the gateway walks on to the next worker, and only when every worker that could take the request has failed so does it end `worker-failed` with code `PROVISION_FAILED` and the last worker (#637). `dispose()` emits nothing, so a request still open when a gateway stops has no ending event (ADR 0021 §5 plans for usage to close those at the next `daemon.started`; not built yet). `already-leased` means the requester already has a waiting request, or already holds `lease.maxPerRequester` leases (ADR 0023); a worker skips the held-lease count for a probe, so it declines a probe `already-leased` only for a waiting one. A request refused at admission (`killed`, `already-leased`, `lease-id-taken`) was never stored and has no `lease.requested` (except a gateway's `lease-id-taken` after a grant, below); it carries the id minted for it before the check, the one the stored request would have had; on a gateway (ADR 0009 §4, §8) `no-worker` is rows 1 and 5 of the fast-fail table (`NO_CAPACITY`: no worker takes requests, or none that does can serve it) and `unresolvable-spec` rows 2 to 4 (`NO_DRIVER`, `UNKNOWN_MODEL`, `RUNTIME_MISSING`) and `CAPABILITY_MISSING` (a request whose `requires` no connected worker able to serve it meets with every name `available`, run on the table a second time on the capable views; ADR 0026, narrows ADR 0009), emitted in the dispatch walk before the request is queued, so a request rejected on arrival emits no `lease.queued`, and a waiting one is rejected when the views change (additive, events rule 6). A request a worker refused with one of those codes or with `CAPABILITY_MISSING` (ADR 0009 §5, ADR 0026) and the table later ends with that stored refusal gets a gateway `unresolvable-spec`, whether or not it had entered the gateway queue (the worker only declined it, ADR 0021 §4); `daemon-restarted` is a request still waiting when the daemon stopped, settled as failed when it starts again (#72; widens a published vocabulary, which events rule 6 allows as additive). The reason list can grow: a consumer must tolerate a reason it does not know; `cancelled` is an explicit single-request cancel (`cancelPending` on the leasing module, backing `DELETE /v1/lease-requests/{id}`) of a still-queued waiter -- one with device work already in flight is reported `not-cancellable` instead, the same envelope the queue timeout already uses; a physical waiter has no device work, only a presence read, so it is cancelled in any state | LeaseAcquisitionCoordinator / WaitQueue / LeaseStartup (worker) / FleetLeaseCoordinator (gateway) | implemented | +| `lease.rejected` | request id, requester (both required on every reason, additive, ADR 0016 §2), request spec (as on `lease.requested`; `full` replaced by `mode`, ADR 0007 §13; `model` optional and `class` added, ADR 0015 §9; `osVersion` may be a range as typed, ADR 0015 §2), reason (timeout/no-wait/unresolvable-spec/no-worker/already-leased/lease-id-taken/boot-timeout/killed/updating/cancelled/daemon-restarted/worker-failed), `code` and `worker` on `worker-failed` (ADR 0021 §4, additive; `worker`, never `workerId`, which marks a relayed event, ADR 0014 §6) | a request ended without a grant. A worker emits it only for its local requests; a probe ends in `lease.declined` there (ADR 0021 §2). A gateway emits it for every ending that is not a grant (ADR 0021 §4): its own reasons, `unresolvable-spec` for the last cannot-serve refusal whether or not the request had queued, and `worker-failed` for any other failure on the worker it went to -- a terminal refusal, a failure after progress, `WORKER_UNREACHABLE`, `INTERNAL`, a dispatch timeout, a worker's own `REQUESTER_ALREADY_LEASED` or `LEASE_ID_TAKEN` -- with `code` the error code the caller got and `worker` the worker. A worker's `PROVISION_FAILED` before it pushed progress is not among them (after progress it is a failure after progress, relayed as it is): the gateway walks on to the next worker, and only when every worker that could take the request has failed so does it end `worker-failed` with code `PROVISION_FAILED` and the last worker (#637). `dispose()` emits nothing, so a request still open when a gateway stops has no ending event (ADR 0021 §5 plans for usage to close those at the next `daemon.started`; not built yet). `already-leased` means the requester already has a waiting request, or already holds `lease.maxPerRequester` leases (ADR 0023); a worker skips the held-lease count for a probe, so it declines a probe `already-leased` only for a waiting one. A request refused at admission (`killed`, `updating`, `already-leased`, `lease-id-taken`) was never stored and has no `lease.requested` (except a gateway's `lease-id-taken` after a grant, below); it carries the id minted for it before the check, the one the stored request would have had; on a gateway (ADR 0009 §4, §8) `no-worker` is rows 1 and 5 of the fast-fail table (`NO_CAPACITY`: no worker takes requests, or none that does can serve it) and `unresolvable-spec` rows 2 to 4 (`NO_DRIVER`, `UNKNOWN_MODEL`, `RUNTIME_MISSING`) and `CAPABILITY_MISSING` (a request whose `requires` no connected worker able to serve it meets with every name `available`, run on the table a second time on the capable views; ADR 0026, narrows ADR 0009), emitted in the dispatch walk before the request is queued, so a request rejected on arrival emits no `lease.queued`, and a waiting one is rejected when the views change (additive, events rule 6). A request a worker refused with one of those codes or with `CAPABILITY_MISSING` (ADR 0009 §5, ADR 0026) and the table later ends with that stored refusal gets a gateway `unresolvable-spec`, whether or not it had entered the gateway queue (the worker only declined it, ADR 0021 §4); `daemon-restarted` is a request still waiting when the daemon stopped, settled as failed when it starts again (#72; widens a published vocabulary, which events rule 6 allows as additive). The reason list can grow: a consumer must tolerate a reason it does not know; `cancelled` is an explicit single-request cancel (`cancelPending` on the leasing module, backing `DELETE /v1/lease-requests/{id}`) of a still-queued waiter -- one with device work already in flight is reported `not-cancellable` instead, the same envelope the queue timeout already uses; a physical waiter has no device work, only a presence read, so it is cancelled in any state | LeaseAcquisitionCoordinator / WaitQueue / LeaseStartup (worker) / FleetLeaseCoordinator (gateway) | implemented | | `lease.declined` | `{ requestId, fleetRequestId, requester, requestSpec, reason }` -- `reason` takes `lease.rejected`'s values; `requestSpec` is there for the same reason as on `lease.rejected`: a decline at admission has no `lease.requested` (ADR 0021 §2) | a worker refused or failed a probe, a `lease.request` carrying `fleetRequestId` (ADR 0021 §1), whatever the reason (`no-wait`, `unresolvable-spec`, `already-leased`, `lease-id-taken`, `boot-timeout`, `killed`, `daemon-restarted` at the next start) and however far the work got. The gateway owns the outcome of a probe, so the worker never rejects one and never queues one: where it would queue (a second failed provision) it declines and answers `PROVISION_FAILED`. The event depends only on the request being a probe, never on whether the gateway will retry. A local request is rejected exactly as before | LeaseAcquisitionCoordinator / WaitQueue / LeaseStartup (worker) | implemented | On a **gateway**, `lease.requested`, `lease.queued` and `lease.rejected` are its own fleet queue's facts (ADR 0005 §11/§14), @@ -145,6 +145,8 @@ left behind (#79), each when there is one. |---|---|---|---|---| | `daemon.started` | version, config snapshot (`gateway.token`, when set, reads `[redacted]`: the payload reaches the ring and `history.db`, which are not a token's place the way `config.json` and `config.get` are; masked rather than dropped so the keys stay the same, #170) | daemon finished startup + reconcile | DaemonServer | implemented | | `daemon.stopping` | reason | graceful shutdown began (reason `signal` when the process got SIGTERM, ADR 0041 §4) | DaemonServer | implemented | +| `daemon.update-started` | from, to, forced | an update began: `daemon.update` resolved the version and entered `waiting` or `installing` (ADR 0042 §11); `forced` is whether it was asked with `force` at the start | UpdateCoordinator | implemented | +| `daemon.update-failed` | stage (`installing` or `restarting`), from, to, message (at most 2000 characters) | an update ended without a new daemon: the updater reported a failed line or exited non-zero while this daemon runs (it serves again), or a rolled back update's result file was read at start (ADR 0042 §7, §8) | UpdateCoordinator | implemented | | `disk.pressure-detected` | free bytes, threshold | free disk crossed under the configured threshold (edge-triggered: once per crossing, not once per tick while it persists) | CleanupReaper | implemented | | `cleanup.executed` | rule name, action, target, reason | cleanup executor committed a proposed action | CleanupExecutor | implemented | | `doctor.reconciled` | drift findings | daemon reconciliation completed | Doctor | implemented | diff --git a/e2e/update/update.e2e.test.ts b/e2e/update/update.e2e.test.ts new file mode 100644 index 000000000..06aaccc17 --- /dev/null +++ b/e2e/update/update.e2e.test.ts @@ -0,0 +1,159 @@ +import { execFile, spawn, type ChildProcess } from "node:child_process"; +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { freeLoopbackPort, REPO_ROOT, waitFor, withDaemon } from "../helpers/index.js"; + +const execFileAsync = promisify(execFile); +const BUILDS = [ + { version: "0.0.1-a", tag: "a" }, + { version: "0.0.1-b", tag: "latest" }, +] as const; + +let base: string; +let registry: ChildProcess | undefined; +let npmEnv: NodeJS.ProcessEnv; +let installedCli: string; + +async function startRegistry(directory: string): Promise<{ url: string; token: string }> { + const port = await freeLoopbackPort(); + const url = `http://127.0.0.1:${port}`; + const configPath = join(directory, "verdaccio.yaml"); + await writeFile( + configPath, + [ + `storage: ${join(directory, "storage")}`, + "auth:", + " htpasswd:", + ` file: ${join(directory, "htpasswd")}`, + "uplinks:", + " npmjs:", + " url: https://registry.npmjs.org/", + "packages:", + " simlock:", + " access: $all", + " publish: $authenticated", + " '**':", + " access: $all", + " proxy: npmjs", + "log: { type: stdout, format: pretty, level: error }", + "", + ].join("\n"), + ); + registry = spawn( + process.execPath, + [ + join(REPO_ROOT, "node_modules/verdaccio/bin/verdaccio"), + "--config", + configPath, + "--listen", + `127.0.0.1:${port}`, + ], + { stdio: "ignore" }, + ); + await waitFor( + async () => { + try { + return (await fetch(`${url}/-/ping`)).ok; + } catch { + return false; + } + }, + { timeout: 60_000, label: "the local registry answers" }, + ); + const created = await fetch(`${url}/-/user/org.couchdb.user:e2e`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "e2e", password: "e2e-password", email: "e2e@example.com" }), + }); + const { token } = (await created.json()) as { token: string }; + return { url, token }; +} + +async function npm(args: readonly string[]): Promise { + // why: npm reads `devEngines` from the project it runs in, and this repo's names pnpm. + await execFileAsync("npm", args, { cwd: base, env: npmEnv, maxBuffer: 64 * 1024 * 1024 }); +} + +async function publishBuild(version: string, tag: string): Promise { + const directory = join(base, `package-${version}`); + await mkdir(directory); + await cp(join(REPO_ROOT, "dist"), join(directory, "dist"), { recursive: true }); + const manifest = JSON.parse(await readFile(join(REPO_ROOT, "package.json"), "utf8")) as Record< + string, + unknown + >; + // why: a lifecycle script would rebuild the package from a folder that has no sources, and `devEngines` makes npm refuse to publish what pnpm builds. + const { + scripts: _scripts, + devEngines: _devEngines, + devDependencies: _devDependencies, + ...publishable + } = manifest; + await writeFile( + join(directory, "package.json"), + JSON.stringify({ ...publishable, version }, null, 2), + ); + await npm(["publish", directory, "--tag", tag]); +} + +beforeAll(async () => { + base = await mkdtemp(join(tmpdir(), "simlock-update-e2e-")); + const { url, token } = await startRegistry(base); + const host = url.replace("http:", ""); + const userConfig = join(base, "npmrc"); + await writeFile(userConfig, `registry=${url}/\n${host}/:_authToken=${token}\n`); + const prefix = join(base, "prefix"); + npmEnv = { + ...process.env, + npm_config_userconfig: userConfig, + npm_config_globalconfig: join(base, "globalrc"), + npm_config_prefix: prefix, + npm_config_cache: join(base, "cache"), + npm_config_update_notifier: "false", + npm_config_audit: "false", + npm_config_fund: "false", + }; + for (const { version, tag } of BUILDS) await publishBuild(version, tag); + await npm(["install", "--global", "simlock@0.0.1-a"]); + installedCli = join(prefix, "lib", "node_modules", "simlock", "dist", "cli", "main.js"); +}, 600_000); + +afterAll(async () => { + registry?.kill("SIGTERM"); + await rm(base, { force: true, recursive: true }); +}); + +describe("simlock update against a local npm registry", () => { + it("takes a running 0.0.1-a to 0.0.1-b and prints that it is running, and refuses a version npm does not have while 0.0.1-a keeps running", async () => { + const env = await withDaemon({ mode: "auto" }); + const run = (args: readonly string[]) => + env.cli(args, { entry: installedCli, env: npmEnv, timeout: 180_000 }); + const versionRunning = async () => + ((await run(["status", "--json"])).json as { daemon: { version: string } }).daemon.version; + + expect((await run(["daemon", "start"])).code).toBe(0); + expect(await versionRunning()).toBe("0.0.1-a"); + + const missing = await run(["update", "--version", "9.9.9"]); + expect(missing.code).toBe(1); + expect(missing.error?.code).toBe("UPDATE_RESOLVE_FAILED"); + expect(missing.error?.message).toContain("could not resolve simlock@9.9.9"); + expect(await versionRunning()).toBe("0.0.1-a"); + + const updated = await run(["update"]).catch((error: unknown) => ({ + code: null, + stdout: "", + stderr: String(error), + })); + expect(updated.code, updated.stderr).toBe(0); + expect(updated.stdout).toContain("Updating simlock 0.0.1-a → 0.0.1-b\n"); + expect(updated.stdout.endsWith("simlock 0.0.1-b is running\n")).toBe(true); + await waitFor(async () => (await versionRunning()) === "0.0.1-b", { + label: "the new daemon runs 0.0.1-b", + }); + }, 480_000); +}); diff --git a/package.json b/package.json index 9748a5a7f..a7abfd033 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "react-is": "^19.3.0", "recharts": "^3.10.1", "typescript": "^7.0.2", + "verdaccio": "^6.10.6", "vite": "^8.3.2", "vitest": "^4.1.10" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a0b66716..755ab3db6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -313,6 +313,9 @@ importers: typescript: specifier: ^7.0.2 version: 7.0.2 + verdaccio: + specifier: ^6.10.6 + version: 6.10.6(supports-color@7.2.0)(typanion@3.14.0) vite: specifier: ^8.3.2 version: 8.3.2(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.1) @@ -569,6 +572,10 @@ packages: '@types/react': optional: true + '@cypress/request@4.0.1': + resolution: {integrity: sha512-y20e+e6dFYkOUUJLVUZTsJRuTiXZaUQ32WD+R/ux/HBybbTx4ge7cNINcua0pU8+SNkKuRbOF12mBmzuzM8n5w==} + engines: {node: '>= 14.17.0'} + '@fallow-cli/darwin-arm64@3.6.0': resolution: {integrity: sha512-DnOEQQD7QnMs8mjasPkgzh9jpTbpoUb0jJE1nkV9ZL+x2DkFWSMKYuJ9rdgWdWx7OLKmwgtPMgW1wsRKG0ICLw==} cpu: [arm64] @@ -803,6 +810,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -813,6 +823,18 @@ packages: '@cfworker/json-schema': optional: true + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + '@oxc-project/types@0.152.0': resolution: {integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==} @@ -1060,6 +1082,9 @@ packages: cpu: [x64] os: [win32] + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.63.0': resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} engines: {node: '>=20'} @@ -1194,6 +1219,10 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@sindresorhus/is@8.1.0': + resolution: {integrity: sha512-2SX/1jW6CIMAiebvVv5ZInoCEuWQmMyBoJXXGC6Vjakjp/fpxP5eHs7/V6WKuPEIbuK06+VpjH+vjLQhr98rDQ==} + engines: {node: '>=22'} + '@sindresorhus/merge-streams@4.0.0': resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} @@ -1294,6 +1323,9 @@ packages: '@types/gensync@1.0.5': resolution: {integrity: sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==} + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/jsesc@2.5.1': resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} @@ -1437,6 +1469,77 @@ packages: cpu: [x64] os: [win32] + '@verdaccio/auth@8.1.6': + resolution: {integrity: sha512-M79ZNVBGs6BcV22buxBQo4xBv+ROMzWmS/ZuJOVl1utHa9MeYLmY6NGaIEPGZ0IghGFkJtirfOYSKGGmkehoaw==} + engines: {node: '>=22'} + + '@verdaccio/config@8.3.3': + resolution: {integrity: sha512-unGc+9/ikGr3qyI5/Cd5e3iMLOYCxFj39purN+mhCl8pmXDPkupJ1HPYtsTgR0SuBZCx1Wla3iAGtO/OxJR2BA==} + engines: {node: '>=22'} + + '@verdaccio/core@8.3.3': + resolution: {integrity: sha512-Vhfpo9MIwUOIsNQCGmTKqYn2l73261YQ6NCmRS47NbpXi5YWuSGf7I6i4dIVMZVG7UUtz9633754WtzFFAgFvg==} + engines: {node: '>=22'} + + '@verdaccio/file-locking@13.1.0': + resolution: {integrity: sha512-rGFfdyCZdgpbkROJJfjOA01R5BFtzrnDAhNIkxxc/yWZ6Cir+MRHpEbN2weOEPAwYDke9Wms4JKhpsStu+iV8Q==} + engines: {node: '>=22'} + + '@verdaccio/hooks@8.1.7': + resolution: {integrity: sha512-QqullDtDM6FN5yaT+y6A9R3OMW9GknIhbogGc85q9F6oStnVQDw4o17ihA51L1Y4NQzZo4MSiN4PnbDyLZ+unA==} + engines: {node: '>=22'} + + '@verdaccio/loaders@8.1.6': + resolution: {integrity: sha512-X+Yu37bbVBcpVty4WfMgr/3ks3nGxGKYeGv2H684LqrjyvxXBgz+LV+hlSAuxfYweOSaYF2WUZLRNF0JExFL8Q==} + engines: {node: '>=22'} + + '@verdaccio/local-storage-legacy@11.4.6': + resolution: {integrity: sha512-J3P417E1iBbkHz6QPShRx3dj3ZsBH03RWXm2eSfZDaCHNMU8KslCRmS9kF58TutPBoimZfBYYZytqWDbthPu+g==} + engines: {node: '>=22'} + + '@verdaccio/logger-commons@8.1.6': + resolution: {integrity: sha512-tl0o9HnsjRsH7pJwJf0HNCwKXyjax+AaQcUekVIXL8XeyN9yll8D/q/oscDEToh9+8JtF2D7ZClAomok7Tk8uA==} + engines: {node: '>=22'} + + '@verdaccio/logger-prettify@8.1.0': + resolution: {integrity: sha512-Mriivx1LPx8/8ux0MlNoj/FtxQiQmf5BG2casfWEf9R7jzBDAUCqQJUR4s0PaV2Mlc9cdHcETOucx3RGPuWHeQ==} + engines: {node: '>=22'} + + '@verdaccio/logger@8.1.6': + resolution: {integrity: sha512-iqNc+56kpY0CI+qlbOGGzsY+akAs/4XUMoEXTxsoLG4mVbsWiGUg0VGRiZfmC9nKCXaj0nl5wp9k06xjEg5b7g==} + engines: {node: '>=22'} + + '@verdaccio/middleware@8.1.7': + resolution: {integrity: sha512-k1m1btijQsTXpOnjhEcxwZ7/8VJ6Seu0qpegzepWl6N9ZIBAfyE1FieC8YTnGGUuNagyV6K6CkdKvxkNs0DMtg==} + engines: {node: '>=22'} + + '@verdaccio/package-filter@13.2.4': + resolution: {integrity: sha512-Cpd5Go/58VO8Bjz/D/lwg5p2wZ0QOGnu/GTlnPieVfoJin2Ix/2L4Bb57STdW1KWW0TLKZrSw1fq1C7F3ENa2w==} + engines: {node: '>=22'} + + '@verdaccio/search-indexer@8.1.0': + resolution: {integrity: sha512-N0vHWnSCZVEU3ffvbH/g4cRvGkXO+FJQcNggdY1wxT8LP7K6NJ0F6aq7jSF3ebW5IokdQMJuqWTNx18h8dgrsg==} + engines: {node: '>=22'} + + '@verdaccio/signature@8.1.6': + resolution: {integrity: sha512-hCS0CjsKUq0qmr1Emq9ZjyGbi2ieDcB0+40qtCqpdAUD+V3ZYXFMWNQNmDxh1FsOXnEihA7jb2PIrDo1MbumKg==} + engines: {node: '>=22'} + + '@verdaccio/streams@10.3.0': + resolution: {integrity: sha512-MARQAzAgS42GIawkrBVMTV81vRf2yqZmwonnQb9FWeGj+tsMcgpT/f2fWMQm/UubLpWxBBH6oCkmhFd29R2xGA==} + engines: {node: '>=22', npm: '>=5'} + + '@verdaccio/tarball@13.1.6': + resolution: {integrity: sha512-iyknOQklt6GQPgQ0fglgydPUsMfeQDmX8nSB/8DIKpkE0qhTo1zCoF19g+eRefSFCgKuShnUMARLUFKHUc9UIQ==} + engines: {node: '>=22'} + + '@verdaccio/ui-theme@9.0.0-next-9.36': + resolution: {integrity: sha512-o6bFAFri4JPKWZfFSaJfyh18iJjvlAyCNNkGXP66dzUHE5lBhhsushlIQpoM0YbXnBTmtwQlaCT7nk+ZHsGUeg==} + + '@verdaccio/url@13.1.6': + resolution: {integrity: sha512-1mtulVb/3TvMYhXOYLwXDD6UPDX04DQbyvQ5PqwtNAhWlduZqbqJifVjakhuDWiyVEWtN0aUQvXL0UoqbcU7Uw==} + engines: {node: '>=22'} + '@vitejs/plugin-react@6.1.1': resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1482,6 +1585,18 @@ packages: '@vitest/utils@4.1.10': resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + JSONStream@1.3.5: + resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} + hasBin: true + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -1507,6 +1622,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ajv-draft-04@1.0.0: resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} peerDependencies: @@ -1534,26 +1653,121 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + apache-md5@1.1.8: + resolution: {integrity: sha512-FCAJojipPn0bXjuEpjOOOMN8FZDkxfWWp4JGN9mifU2IhxvKyXZYqpzPHdnTSUpmPDy+tsslB6Z1g+Vg6nVbYA==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + argparse@3.0.3: resolution: {integrity: sha512-Sn98ftV3KfsO8xfLPHG6oT8y+b0yvbrLgZjgPDXsharNY15tTAjqIsIju4S32nl6/iEUNNE4v+yL2Zhm8ooP3w==} + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + + assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + aws-sign2@0.7.0: + resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} + + aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + axe-core@4.13.0: resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} engines: {node: '>=4'} + b4a@1.9.0: + resolution: {integrity: sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bare-events@2.9.2: + resolution: {integrity: sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.8.4: + resolution: {integrity: sha512-MPC2eyrpbk3A873sK4y6rk+Iv4o2enxTjHZ8zH3HO0mjrpm5XjnguY2XmfidIsDV6vnjpbMBKUAJpV0ISD2SsQ==} + engines: {bare: '>=1.28.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-path@3.1.2: + resolution: {integrity: sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==} + + bare-stream@2.13.4: + resolution: {integrity: sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.5.5: + resolution: {integrity: sha512-iM9qJCruvpt2rPaLsrumVDru9pogI7PHziHIa9kTNRFt8C8HuSa+kI9kFezK+NvHEFnnz71rKGZ1EAilq/3V/Q==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.11.27: resolution: {integrity: sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==} engines: {node: '>=6.0.0'} hasBin: true + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + + bcryptjs@2.4.3: + resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==} + + body-parser@1.20.8: + resolution: {integrity: sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} @@ -1562,15 +1776,43 @@ packages: resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserify-zlib@0.1.4: + resolution: {integrity: sha512-19OEpq7vWgsH6WkvkBJQDFvJS1uPcbFOQ4v9CU839dO+ZZXUZO6XpE6hNCqvlIIj+4fZvRiJ6DsAQ382GwiyTQ==} + browserslist@4.29.3: resolution: {integrity: sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + byte-counter@0.1.0: + resolution: {integrity: sha512-jheRLVMeUKrDBjVw2O5+k4EvR4t9wtxHL+bo/LxfkxsVeuGMy3a5SEGgXdAFA4FSzTrU8rQXQIrsZ3oBq5a0pQ==} + engines: {node: '>=20'} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cacheable-lookup@7.0.0: + resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==} + engines: {node: '>=14.16'} + + cacheable-request@13.0.19: + resolution: {integrity: sha512-SVXGH037+Mo1aIMO5B2UcleR43FGjFdN+M8JObSyEoQ2Mn4CODRWx28gN5jiTF0n5ItsgtIZfyargMNs8GX4kg==} + engines: {node: '>=18'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1582,6 +1824,9 @@ packages: caniuse-lite@1.0.30001814: resolution: {integrity: sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==} + caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -1597,10 +1842,19 @@ packages: chardet@2.2.0: resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + chunk-data@0.1.0: + resolution: {integrity: sha512-zFyPtyC0SZ6Zu79b9sOYtXZcgrsXe0RpePrzRyj52hYVFG1+Rk6rBqjjOEk+GNQwc3PIX+86teQMok970pod1g==} + engines: {node: '>=20'} + cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + clipanion@4.0.0-rc.4: + resolution: {integrity: sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q==} + peerDependencies: + typanion: '*' + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1612,6 +1866,13 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -1620,6 +1881,18 @@ packages: resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} engines: {node: '>=22.12.0'} + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.2: + resolution: {integrity: sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==} + engines: {node: '>= 0.8.0'} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -1635,6 +1908,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -1643,6 +1919,12 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -1698,6 +1980,21 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + dashdash@1.14.1: + resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} + engines: {node: '>=0.10'} + + dayjs@1.11.18: + resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1710,6 +2007,14 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decompress-response@10.0.0: + resolution: {integrity: sha512-oj7KWToJuuxlPr7VV0vabvxEIiqNMo+q0NueIiL3XhtwC6FVOX7Hr1c0C4eD0bmf7Zr+S/dSf2xvkH3Ad6sU3Q==} + engines: {node: '>=20'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -1722,6 +2027,10 @@ packages: des.js@1.1.0: resolution: {integrity: sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==} + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1729,10 +2038,23 @@ packages: diff-match-patch@1.0.5: resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + duplexify@3.7.1: + resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==} + + ecc-jsbn@0.1.2: + resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -1750,10 +2072,18 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.25.1: resolution: {integrity: sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==} engines: {node: '>=10.13.0'} + envinfo@7.21.0: + resolution: {integrity: sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==} + engines: {node: '>=4'} + hasBin: true + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1769,6 +2099,10 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + es-toolkit@1.52.0: resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} @@ -1786,9 +2120,20 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + eventsource-parser@3.1.1: resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} engines: {node: '>=18.0.0'} @@ -1805,16 +2150,30 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + express-rate-limit@5.5.1: + resolution: {integrity: sha512-MTjE2eIbHv5DyfuFz4zLYWxpqVhEhkTiwFGuB74Q9CSou2WHO52nlE5y3Zlg6SIsiYUIPj6ifFxnkPz6O3sIUg==} + express-rate-limit@8.6.2: resolution: {integrity: sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' + express@4.22.3: + resolution: {integrity: sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==} + engines: {node: '>= 0.10.0'} + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + extsprintf@1.3.0: + resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} + engines: {'0': node >=0.6.0} + fallow@3.6.0: resolution: {integrity: sha512-CpoOw4Fs35NgF/Md+OTZE9PrsReQp3mbBEqDDd6HATh1gflXAQfDJOk3M8xA0cnIZIfXXAu5fwojxwTXn9S2MA==} engines: {node: '>=22'} @@ -1823,6 +2182,13 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -1835,6 +2201,9 @@ packages: fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1848,6 +2217,14 @@ packages: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -1856,10 +2233,21 @@ packages: resolution: {integrity: sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==} engines: {node: '>=6'} + forever-agent@0.6.1: + resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -1872,6 +2260,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + fuse.js@7.3.0: + resolution: {integrity: sha512-plz8RVjfcDedTGfVngWH1jmJvBvAwi1v2jecfDerbEnMcmOYUEEwKFTHbNoCiYyzaK2Ws8lABkTCcRSqCY1q4w==} + engines: {node: '>=10'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -1888,17 +2280,41 @@ packages: resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} engines: {node: '>=18'} + getpass@0.1.7: + resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} engines: {node: '>=18'} + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + got@15.1.0: + resolution: {integrity: sha512-DG+DAAkRtno+oDr/GBsliAkhN9+zczOPM5qXk3efDZY3qvyRnZU+NwQlb/IOY6cSnxxTR9z3aHCcShJyjb0hJA==} + engines: {node: '>=22'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gunzip-maybe@1.4.2: + resolution: {integrity: sha512-4haO1M4mLO91PW57BMsDFf75UmwoRX0GkdD+Faw+Lr+r/OZrOCS0pIBwOL1xCKQqnQzbNFGgK2V2CpBUPeFNTw==} + hasBin: true + + handlebars@4.7.10: + resolution: {integrity: sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==} + engines: {node: '>=0.4.7'} + hasBin: true + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1907,6 +2323,10 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -1915,18 +2335,47 @@ packages: resolution: {integrity: sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==} engines: {node: '>=16.9.0'} + http-cache-semantics@4.3.0: + resolution: {integrity: sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-signature@1.4.0: + resolution: {integrity: sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==} + engines: {node: '>=0.10'} + + http-status-codes@2.3.0: + resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} + + http2-wrapper@2.2.1: + resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} + engines: {node: '>=10.19.0'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + human-signals@8.0.1: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + ignore@7.0.9: resolution: {integrity: sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==} engines: {node: '>= 4'} @@ -1964,10 +2413,29 @@ packages: resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==} engines: {node: '>= 0.4'} + is-deflate@1.0.0: + resolution: {integrity: sha512-YDoFpuZWu1VRXlsnlYMzKyVRITXj7Ej/V9gXQ2/pAe7X1J7M/RNOqaIYi6qUn+B7nGyB9pDXrv02dsB58d2ZAQ==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-gzip@1.0.0: + resolution: {integrity: sha512-rcfALRIb1YewtnksfRIHGcIY93QnK8BIQ/2c9yDYcG/Y6+vRoJuTWBmmSEbyLLYtXm7q35pHOHbZFQBaLrhlWQ==} + engines: {node: '>=0.10.0'} + is-installed-globally@1.0.0: resolution: {integrity: sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==} engines: {node: '>=18'} + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + is-path-inside@4.0.0: resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} engines: {node: '>=12'} @@ -1976,6 +2444,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-promise@2.2.2: + resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -1983,13 +2454,22 @@ packages: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} engines: {node: '>=18'} + is-typedarray@1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + is-unicode-supported@2.1.0: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isstream@0.1.2: + resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -2006,10 +2486,17 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@5.4.1: + resolution: {integrity: sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==} + hasBin: true + js-yaml@5.5.0: resolution: {integrity: sha512-paNr4LFIFPjeeSmwNYdrb8FafLLG/Cr2Af3GjbsHJ5CxIjH4r0c2emlmRdAtpPAieP1gRVzk6buBbVP5gBMGAg==} hasBin: true + jsbn@0.1.1: + resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2024,15 +2511,42 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} hasBin: true + jsonparse@1.3.1: + resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==} + engines: {'0': node >= 0.2.0} + jsonpointer@5.0.1: resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} engines: {node: '>=0.10.0'} + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jsprim@2.0.2: + resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==} + engines: {'0': node >=0.6.0} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -2169,9 +2683,52 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + lockfile@1.0.4: + resolution: {integrity: sha512-cvbTwETRfsFh4nHsL1eGWapU1XFi5Ot9E85sWAwia7Y7EgB7vfqcZhTKZ+l7hCGxSPoushMv5GKhT5PdLv03WA==} + lodash.groupby@4.6.0: resolution: {integrity: sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + lowdb@1.0.0: + resolution: {integrity: sha512-2+x8esE/Wb9SQ1F9IHaYWfsC9FIecLOPrK4g17FGEayjUWH172H6nwicRovGvSE2CPZouc2MCIqCI7h9d+GftQ==} + engines: {node: '>=4'} + + lowercase-keys@3.0.0: + resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + lowercase-keys@4.0.1: + resolution: {integrity: sha512-wI9Nui/L8VfADa/cr/7NQruaASk1k23/Uh1khQ02BCVYiiy8F4AhOGnQzJy3Fl/c44GnYSbZHv8g7EcG3kJ1Qg==} + engines: {node: '>=20'} + + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2179,22 +2736,63 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + media-typer@1.1.1: resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} engines: {node: '>= 0.8'} + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + mimic-response@4.0.0: + resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -2205,6 +2803,14 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2230,15 +2836,39 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - node-releases@2.0.57: - resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==} - engines: {node: '>=18'} + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - npm-run-path@6.0.0: + node-fetch@2.6.7: + resolution: {integrity: sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-releases@2.0.57: + resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==} + engines: {node: '>=18'} + + normalize-url@8.1.1: + resolution: {integrity: sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==} + engines: {node: '>=14.16'} + + npm-run-path@6.0.0: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} @@ -2254,10 +2884,18 @@ packages: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2293,6 +2931,9 @@ packages: vite-plus: optional: true + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + parse-ms@4.0.0: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} @@ -2312,15 +2953,32 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + peek-stream@1.1.3: + resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==} + + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + picomatch@4.0.5: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} @@ -2329,6 +2987,23 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + pify@3.0.0: + resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} + engines: {node: '>=4'} + + pino-abstract-transport@1.2.0: + resolution: {integrity: sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==} + + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@9.14.0: + resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} + hasBin: true + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -2351,6 +3026,19 @@ packages: resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} engines: {node: '>=18'} + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process-warning@1.0.0: + resolution: {integrity: sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==} + + process-warning@5.1.1: + resolution: {integrity: sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} @@ -2363,6 +3051,12 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + pump@2.0.1: + resolution: {integrity: sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==} + + pumpify@1.5.1: + resolution: {integrity: sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==} + qs@6.15.1: resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} @@ -2371,10 +3065,32 @@ packages: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} + qs@6.16.1: + resolution: {integrity: sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + range-parser@1.3.0: resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} engines: {node: '>= 0.6'} + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + raw-body@3.0.2: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} @@ -2403,6 +3119,17 @@ packages: resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} engines: {node: '>=0.10.0'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@4.8.0: + resolution: {integrity: sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + recharts@3.10.1: resolution: {integrity: sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==} engines: {node: '>=18'} @@ -2437,11 +3164,22 @@ packages: reselect@5.3.0: resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==} + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + resolve@1.22.13: resolution: {integrity: sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA==} engines: {node: '>= 0.4'} hasBin: true + responselike@4.0.2: + resolution: {integrity: sha512-cGk8IbWEAnaCpdAt1BHzJ3Ahz5ewDJa0KseTsE3qIRMJ3C698W8psM7byCeWVpd/Ha7FUYzuRVzXoKoM6nRUbA==} + engines: {node: '>=20'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rolldown@1.2.12: resolution: {integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2451,15 +3189,31 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-regex@2.1.1: resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + sanitize-filename@1.6.4: + resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==} + scheduler@0.28.0: resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} @@ -2468,10 +3222,18 @@ packages: engines: {node: '>=10'} hasBin: true + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -2506,6 +3268,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -2513,14 +3278,37 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + sonic-boom@3.8.1: + resolution: {integrity: sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + source-map@0.7.6: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sshpk@1.18.0: + resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} + engines: {node: '>=0.10.0'} + hasBin: true + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -2531,6 +3319,21 @@ packages: std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + steno@0.4.4: + resolution: {integrity: sha512-EEHMVYHNXFHfGtgjNITnka0aHhiAlo93F7z2/Pwd+g0teG9CnM3JIINM7hVVB5/rhw9voufD7Wukwgtw2uqh6w==} + + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + + streamx@2.28.1: + resolution: {integrity: sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -2547,10 +3350,32 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + + thread-stream@3.2.0: + resolution: {integrity: sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==} + + through2@2.0.5: + resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -2573,14 +3398,35 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + truncate-utf8-bytes@1.0.2: + resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + tsconfig-paths-webpack-plugin@4.2.0: resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} engines: {node: '>=10.13.0'} @@ -2592,10 +3438,27 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tunnel@0.0.6: resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + + typanion@3.14.0: + resolution: {integrity: sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug==} + + type-fest@5.10.0: + resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==} + engines: {node: '>=20'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} @@ -2618,6 +3481,15 @@ packages: engines: {node: '>=16.20.0'} hasBin: true + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + uint8array-extras@1.6.0: + resolution: {integrity: sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==} + engines: {node: '>=18'} + underscore@1.13.8: resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} @@ -2632,6 +3504,9 @@ packages: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} + unix-crypt-td-js@1.1.4: + resolution: {integrity: sha512-8rMeVYWSIyccIJscb9NdCfZKSRBKYTeVnwmiRYT2ulE3qd1RaDQ0xQDP+rI3ccIWbhu/zuo5cgN8z73belNZgw==} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -2647,14 +3522,45 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utf8-byte-length@1.0.5: + resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + validator@13.15.26: + resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==} + engines: {node: '>= 0.10'} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + verdaccio-audit@13.1.7: + resolution: {integrity: sha512-u3QE6ZrZgW4UiMahfS5vIYNzlZgMA7hyI+4MSfWFTNYTB+/WWSItNn1B3D/PFi/OhLubgspSqxbVqeX56jKopA==} + engines: {node: '>=22'} + + verdaccio-htpasswd@13.1.6: + resolution: {integrity: sha512-VUaxLrbuTC6Z7PK00g2Iun8cN/zerr9TJZDj0DEzTmcRvIWXTIObfBA/GPN4qOozJpZfjbr0Gmg31xclRQxEXQ==} + engines: {node: '>=22'} + + verdaccio@6.10.6: + resolution: {integrity: sha512-LHM3362H2S2pjHhfP0E5ZBBlt9Jy+eIGjOwR+9jV8dIoc3OY4If9FUfiDsWIJoM8slGih17B4ofBg3ax/L8fww==} + engines: {node: '>=22'} + hasBin: true + verkit@0.3.2: resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} engines: {node: '>=18.12.0'} + verror@1.10.0: + resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} + engines: {'0': node >=0.6.0} + victory-vendor@37.3.6: resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} @@ -2750,6 +3656,12 @@ packages: weapon-regex@2.0.5: resolution: {integrity: sha512-BJZkSdtcae9Wb8+hKNtAqR+Q61EMOAuQnpCCHKwT50K7fIWg7/2/RnbKR27YB+sMcYxYLUVj+Asm5Y2XhD8mZg==} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2760,6 +3672,9 @@ packages: engines: {node: '>=8'} hasBin: true + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -2775,6 +3690,10 @@ packages: utf-8-validate: optional: true + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + yaml@2.9.1: resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} engines: {node: '>= 14.6'} @@ -3071,6 +3990,26 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 + '@cypress/request@4.0.1': + dependencies: + aws-sign2: 0.7.0 + aws4: 1.13.2 + caseless: 0.12.0 + combined-stream: 1.0.8 + extend: 3.0.2 + forever-agent: 0.6.1 + form-data: 4.0.6 + http-signature: 1.4.0 + is-typedarray: 1.0.0 + isstream: 0.1.2 + json-stringify-safe: 5.0.1 + mime-types: 2.1.35 + performance-now: 2.1.0 + qs: 6.15.3 + safe-buffer: 5.2.1 + tough-cookie: 5.1.2 + tunnel-agent: 0.6.0 + '@fallow-cli/darwin-arm64@3.6.0': optional: true @@ -3270,6 +4209,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@keyv/serialize@1.1.1': {} + '@modelcontextprotocol/sdk@1.29.0(supports-color@7.2.0)(zod@4.6.5)': dependencies: '@hono/node-server': 2.0.10(hono@4.13.5) @@ -3292,6 +4233,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.3 + '@oxc-project/types@0.152.0': {} '@oxfmt/binding-android-arm-eabi@0.59.0': @@ -3408,6 +4361,8 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.74.0': optional: true + '@pinojs/redact@0.4.0': {} + '@playwright/test@1.63.0': dependencies: playwright: 1.63.0 @@ -3497,6 +4452,8 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} + '@sindresorhus/is@8.1.0': {} + '@sindresorhus/merge-streams@4.0.0': {} '@standard-schema/spec@1.1.0': {} @@ -3633,6 +4590,8 @@ snapshots: '@types/gensync@1.0.5': {} + '@types/http-cache-semantics@4.2.0': {} + '@types/jsesc@2.5.1': {} '@types/json-schema@7.0.15': {} @@ -3715,6 +4674,164 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true + '@verdaccio/auth@8.1.6(supports-color@7.2.0)': + dependencies: + '@verdaccio/config': 8.3.3(supports-color@7.2.0) + '@verdaccio/core': 8.3.3 + '@verdaccio/loaders': 8.1.6(supports-color@7.2.0) + '@verdaccio/signature': 8.1.6(supports-color@7.2.0) + debug: 4.4.3(supports-color@7.2.0) + lodash: 4.18.1 + verdaccio-htpasswd: 13.1.6(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@verdaccio/config@8.3.3(supports-color@7.2.0)': + dependencies: + '@verdaccio/core': 8.3.3 + debug: 4.4.3(supports-color@7.2.0) + js-yaml: 5.4.1 + lodash: 4.18.1 + transitivePeerDependencies: + - supports-color + + '@verdaccio/core@8.3.3': + dependencies: + ajv: 8.20.0 + http-errors: 2.0.1 + http-status-codes: 2.3.0 + lodash: 4.18.1 + minimatch: 10.2.6 + process-warning: 1.0.0 + semver: 7.8.5 + + '@verdaccio/file-locking@13.1.0': + dependencies: + lockfile: 1.0.4 + + '@verdaccio/hooks@8.1.7(supports-color@7.2.0)': + dependencies: + '@verdaccio/core': 8.3.3 + '@verdaccio/logger': 8.1.6(supports-color@7.2.0) + debug: 4.4.3(supports-color@7.2.0) + got: 15.1.0 + handlebars: 4.7.10 + transitivePeerDependencies: + - supports-color + + '@verdaccio/loaders@8.1.6(supports-color@7.2.0)': + dependencies: + '@verdaccio/core': 8.3.3 + debug: 4.4.3(supports-color@7.2.0) + lodash: 4.18.1 + transitivePeerDependencies: + - supports-color + + '@verdaccio/local-storage-legacy@11.4.6(supports-color@7.2.0)': + dependencies: + '@verdaccio/core': 8.3.3 + '@verdaccio/file-locking': 13.1.0 + '@verdaccio/streams': 10.3.0 + debug: 4.4.3(supports-color@7.2.0) + globby: 11.1.0 + lodash: 4.18.1 + lowdb: 1.0.0 + mkdirp: 1.0.4 + sanitize-filename: 1.6.4 + transitivePeerDependencies: + - supports-color + + '@verdaccio/logger-commons@8.1.6(supports-color@7.2.0)': + dependencies: + '@verdaccio/core': 8.3.3 + '@verdaccio/logger-prettify': 8.1.0 + colorette: 2.0.20 + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@verdaccio/logger-prettify@8.1.0': + dependencies: + colorette: 2.0.20 + dayjs: 1.11.18 + lodash: 4.18.1 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 1.2.0 + sonic-boom: 3.8.1 + + '@verdaccio/logger@8.1.6(supports-color@7.2.0)': + dependencies: + '@verdaccio/logger-commons': 8.1.6(supports-color@7.2.0) + pino: 9.14.0 + transitivePeerDependencies: + - supports-color + + '@verdaccio/middleware@8.1.7(supports-color@7.2.0)': + dependencies: + '@verdaccio/config': 8.3.3(supports-color@7.2.0) + '@verdaccio/core': 8.3.3 + '@verdaccio/url': 13.1.6(supports-color@7.2.0) + debug: 4.4.3(supports-color@7.2.0) + express: 4.22.3(supports-color@7.2.0) + express-rate-limit: 5.5.1 + lodash: 4.18.1 + lru-cache: 7.18.3 + transitivePeerDependencies: + - supports-color + + '@verdaccio/package-filter@13.2.4(supports-color@7.2.0)': + dependencies: + '@verdaccio/core': 8.3.3 + debug: 4.4.3(supports-color@7.2.0) + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + '@verdaccio/search-indexer@8.1.0(supports-color@7.2.0)': + dependencies: + debug: 4.4.3(supports-color@7.2.0) + fuse.js: 7.3.0 + transitivePeerDependencies: + - supports-color + + '@verdaccio/signature@8.1.6(supports-color@7.2.0)': + dependencies: + '@verdaccio/config': 8.3.3(supports-color@7.2.0) + '@verdaccio/core': 8.3.3 + debug: 4.4.3(supports-color@7.2.0) + jsonwebtoken: 9.0.3 + transitivePeerDependencies: + - supports-color + + '@verdaccio/streams@10.3.0': {} + + '@verdaccio/tarball@13.1.6(supports-color@7.2.0)': + dependencies: + '@verdaccio/core': 8.3.3 + '@verdaccio/url': 13.1.6(supports-color@7.2.0) + debug: 4.4.3(supports-color@7.2.0) + gunzip-maybe: 1.4.2 + tar-stream: 3.2.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + + '@verdaccio/ui-theme@9.0.0-next-9.36(supports-color@7.2.0)': + dependencies: + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@verdaccio/url@13.1.6(supports-color@7.2.0)': + dependencies: + '@verdaccio/core': 8.3.3 + debug: 4.4.3(supports-color@7.2.0) + validator: 13.15.26 + transitivePeerDependencies: + - supports-color + '@vitejs/plugin-react@6.1.1(vite@8.3.2(@types/node@26.1.1)(jiti@2.7.0)(yaml@2.9.1))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -3761,6 +4878,20 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + JSONStream@1.3.5: + dependencies: + jsonparse: 1.3.1 + through: 2.3.8 + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -3782,6 +4913,12 @@ snapshots: acorn@8.18.0: {} + agent-base@6.0.2(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + ajv-draft-04@1.0.0(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -3803,44 +4940,155 @@ snapshots: dependencies: color-convert: 2.0.1 + apache-md5@1.1.8: {} + + argparse@2.0.1: {} + argparse@3.0.3: {} + array-flatten@1.1.1: {} + + array-union@2.1.0: {} + + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + + assert-plus@1.0.0: {} + assertion-error@2.0.1: {} + async@3.2.6: {} + + asynckit@0.4.0: {} + + atomic-sleep@1.0.0: {} + + aws-sign2@0.7.0: {} + + aws4@1.13.2: {} + axe-core@4.13.0: {} + b4a@1.9.0: {} + balanced-match@4.0.4: {} - baseline-browser-mapping@2.11.27: {} + bare-events@2.9.2: {} - body-parser@2.3.0(supports-color@7.2.0): + bare-fs@4.8.4: dependencies: - bytes: 3.1.2 - content-type: 2.0.0 - debug: 4.4.3(supports-color@7.2.0) - http-errors: 2.0.1 - iconv-lite: 0.7.3 - on-finished: 2.4.1 - qs: 6.15.3 - raw-body: 3.0.2 - type-is: 2.1.0 + bare-events: 2.9.2 + bare-path: 3.1.2 + bare-stream: 2.13.4(bare-events@2.9.2) + bare-url: 2.5.5 + fast-fifo: 1.3.2 transitivePeerDependencies: - - supports-color + - bare-abort-controller + - react-native-b4a - brace-expansion@5.0.12: + bare-path@3.1.2: {} + + bare-stream@2.13.4(bare-events@2.9.2): dependencies: - balanced-match: 4.0.4 + b4a: 1.9.0 + streamx: 2.28.1 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.2 + transitivePeerDependencies: + - react-native-b4a - browserslist@4.29.3: + bare-url@2.5.5: dependencies: - baseline-browser-mapping: 2.11.27 - caniuse-lite: 1.0.30001814 + bare-path: 3.1.2 + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.11.27: {} + + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + + bcryptjs@2.4.3: {} + + body-parser@1.20.8(supports-color@7.2.0): + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9(supports-color@7.2.0) + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.16.1 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + body-parser@2.3.0(supports-color@7.2.0): + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3(supports-color@7.2.0) + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserify-zlib@0.1.4: + dependencies: + pako: 0.2.9 + + browserslist@4.29.3: + dependencies: + baseline-browser-mapping: 2.11.27 + caniuse-lite: 1.0.30001814 electron-to-chromium: 1.5.444 node-releases: 2.0.57 update-browserslist-db: 1.3.3(browserslist@4.29.3) + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + byte-counter@0.1.0: {} + bytes@3.1.2: {} + cacheable-lookup@7.0.0: {} + + cacheable-request@13.0.19: + dependencies: + '@types/http-cache-semantics': 4.2.0 + get-stream: 9.0.1 + http-cache-semantics: 4.3.0 + keyv: 5.6.0 + mimic-response: 4.0.0 + normalize-url: 8.1.1 + responselike: 4.0.2 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -3853,6 +5101,8 @@ snapshots: caniuse-lite@1.0.30001814: {} + caseless@0.12.0: {} + chai@6.2.2: {} chalk@4.1.2: @@ -3864,8 +5114,14 @@ snapshots: chardet@2.2.0: {} + chunk-data@0.1.0: {} + cli-width@4.1.0: {} + clipanion@4.0.0-rc.4(typanion@3.14.0): + dependencies: + typanion: 3.14.0 + clsx@2.1.1: {} color-convert@2.0.1: @@ -3874,10 +5130,37 @@ snapshots: color-name@1.1.4: {} + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@14.0.3: {} commander@15.0.0: {} + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.2(supports-color@7.2.0): + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9(supports-color@7.2.0) + destroy: 1.2.0 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + content-disposition@1.1.0: {} content-type@1.0.5: {} @@ -3886,10 +5169,16 @@ snapshots: convert-source-map@2.0.0: {} + cookie-signature@1.0.7: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} + core-util-is@1.0.2: {} + + core-util-is@1.0.3: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -3941,6 +5230,18 @@ snapshots: d3-timer@3.0.1: {} + dashdash@1.14.1: + dependencies: + assert-plus: 1.0.0 + + dayjs@1.11.18: {} + + debug@2.6.9(supports-color@7.2.0): + dependencies: + ms: 2.0.0 + optionalDependencies: + supports-color: 7.2.0 + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 @@ -3949,6 +5250,12 @@ snapshots: decimal.js-light@2.5.1: {} + decompress-response@10.0.0: + dependencies: + mimic-response: 4.0.0 + + delayed-stream@1.0.0: {} + depd@2.0.0: {} dependency-cruiser@18.4.0: @@ -3978,16 +5285,38 @@ snapshots: inherits: 2.0.4 minimalistic-assert: 1.0.1 + destroy@1.2.0: {} + detect-libc@2.1.2: {} diff-match-patch@1.0.5: {} + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 es-errors: 1.3.0 gopd: 1.2.0 + duplexify@3.7.1: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 2.3.8 + stream-shift: 1.0.3 + + ecc-jsbn@0.1.2: + dependencies: + jsbn: 0.1.1 + safer-buffer: 2.1.2 + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} electron-to-chromium@1.5.444: {} @@ -3998,11 +5327,17 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.25.1: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 + envinfo@7.21.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -4013,6 +5348,13 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + es-toolkit@1.52.0: {} escalade@3.2.0: {} @@ -4025,8 +5367,18 @@ snapshots: etag@1.8.1: {} + event-target-shim@5.0.1: {} + eventemitter3@5.0.4: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.9.2 + transitivePeerDependencies: + - bare-abort-controller + + events@3.3.0: {} + eventsource-parser@3.1.1: {} eventsource@3.0.7: @@ -4050,6 +5402,8 @@ snapshots: expect-type@1.4.0: {} + express-rate-limit@5.5.1: {} + express-rate-limit@8.6.2(express@5.2.1(supports-color@7.2.0))(supports-color@7.2.0): dependencies: debug: 4.4.3(supports-color@7.2.0) @@ -4058,6 +5412,42 @@ snapshots: transitivePeerDependencies: - supports-color + express@4.22.3(supports-color@7.2.0): + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.8(supports-color@7.2.0) + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9(supports-color@7.2.0) + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2(supports-color@7.2.0) + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.13 + proxy-addr: 2.0.7 + qs: 6.16.1 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2(supports-color@7.2.0) + serve-static: 1.16.3(supports-color@7.2.0) + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + express@5.2.1(supports-color@7.2.0): dependencies: accepts: 2.0.0 @@ -4091,6 +5481,10 @@ snapshots: transitivePeerDependencies: - supports-color + extend@3.0.2: {} + + extsprintf@1.3.0: {} + fallow@3.6.0: dependencies: detect-libc: 2.1.2 @@ -4106,6 +5500,16 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -4118,6 +5522,10 @@ snapshots: dependencies: fast-string-width: 3.0.2 + fastq@1.20.3: + dependencies: + reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -4126,6 +5534,22 @@ snapshots: dependencies: is-unicode-supported: 2.1.0 + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@1.3.2(supports-color@7.2.0): + dependencies: + debug: 2.6.9(supports-color@7.2.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + finalhandler@2.1.1(supports-color@7.2.0): dependencies: debug: 4.4.3(supports-color@7.2.0) @@ -4139,8 +5563,20 @@ snapshots: flru@1.0.2: {} + forever-agent@0.6.1: {} + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + forwarded@0.2.0: {} + fresh@0.5.2: {} + fresh@2.0.0: {} fsevents@2.3.3: @@ -4148,6 +5584,8 @@ snapshots: function-bind@1.1.2: {} + fuse.js@7.3.0: {} + gensync@1.0.0-beta.2: {} get-intrinsic@1.3.0: @@ -4173,24 +5611,80 @@ snapshots: '@sec-ant/readable-stream': 0.4.1 is-stream: 4.0.1 + getpass@0.1.7: + dependencies: + assert-plus: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + global-directory@4.0.1: dependencies: ini: 4.1.1 + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + gopd@1.2.0: {} + got@15.1.0: + dependencies: + '@sindresorhus/is': 8.1.0 + byte-counter: 0.1.0 + cacheable-lookup: 7.0.0 + cacheable-request: 13.0.19 + chunk-data: 0.1.0 + decompress-response: 10.0.0 + http2-wrapper: 2.2.1 + keyv: 5.6.0 + lowercase-keys: 4.0.1 + responselike: 4.0.2 + type-fest: 5.10.0 + uint8array-extras: 1.6.0 + graceful-fs@4.2.11: {} + gunzip-maybe@1.4.2: + dependencies: + browserify-zlib: 0.1.4 + is-deflate: 1.0.0 + is-gzip: 1.0.0 + peek-stream: 1.1.3 + pumpify: 1.5.1 + through2: 2.0.5 + + handlebars@4.7.10: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + has-flag@4.0.0: {} has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.4: dependencies: function-bind: 1.1.2 hono@4.13.5: {} + http-cache-semantics@4.3.0: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -4199,12 +5693,40 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-signature@1.4.0: + dependencies: + assert-plus: 1.0.0 + jsprim: 2.0.2 + sshpk: 1.18.0 + + http-status-codes@2.3.0: {} + + http2-wrapper@2.2.1: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + + https-proxy-agent@5.0.1(supports-color@7.2.0): + dependencies: + agent-base: 6.0.2(supports-color@7.2.0) + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + human-signals@8.0.1: {} + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + + ignore@5.3.2: {} + ignore@7.0.9: {} immer@11.1.18: {} @@ -4227,23 +5749,43 @@ snapshots: dependencies: hasown: 2.0.4 + is-deflate@1.0.0: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-gzip@1.0.0: {} + is-installed-globally@1.0.0: dependencies: global-directory: 4.0.1 is-path-inside: 4.0.0 + is-number@7.0.0: {} + is-path-inside@4.0.0: {} is-plain-obj@4.1.0: {} + is-promise@2.2.2: {} + is-promise@4.0.0: {} is-stream@4.0.1: {} + is-typedarray@1.0.0: {} + is-unicode-supported@2.1.0: {} + isarray@1.0.0: {} + isexe@2.0.0: {} + isstream@0.1.2: {} + jiti@2.7.0: optional: true @@ -4255,10 +5797,16 @@ snapshots: js-tokens@4.0.0: {} + js-yaml@5.4.1: + dependencies: + argparse: 2.0.1 + js-yaml@5.5.0: dependencies: argparse: 3.0.3 + jsbn@0.1.1: {} + jsesc@3.1.0: {} json-rpc-2.0@1.8.1: {} @@ -4267,10 +5815,51 @@ snapshots: json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} + + json-stringify-safe@5.0.1: {} + json5@2.2.3: {} + jsonparse@1.3.1: {} + jsonpointer@5.0.1: {} + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jsprim@2.0.2: + dependencies: + assert-plus: 1.0.0 + extsprintf: 1.3.0 + json-schema: 0.4.0 + verror: 1.10.0 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + kleur@3.0.3: {} lefthook-darwin-arm64@2.1.10: @@ -4367,24 +5956,83 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + lockfile@1.0.4: + dependencies: + signal-exit: 3.0.7 + lodash.groupby@4.6.0: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + + lodash@4.18.1: {} + + lowdb@1.0.0: + dependencies: + graceful-fs: 4.2.11 + is-promise: 2.2.2 + lodash: 4.18.1 + pify: 3.0.0 + steno: 0.4.4 + + lowercase-keys@3.0.0: {} + + lowercase-keys@4.0.1: {} + + lru-cache@7.18.3: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 math-intrinsics@1.1.0: {} + media-typer@0.3.0: {} + media-typer@1.1.1: {} + merge-descriptors@1.0.3: {} + merge-descriptors@2.0.0: {} + merge2@1.4.1: {} + + methods@1.1.2: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime-types@3.0.2: dependencies: mime-db: 1.54.0 + mime@1.6.0: {} + + mime@3.0.0: {} + + mimic-response@4.0.0: {} + minimalistic-assert@1.0.1: {} minimatch@10.2.6: @@ -4393,6 +6041,10 @@ snapshots: minimist@1.2.8: {} + mkdirp@1.0.4: {} + + ms@2.0.0: {} + ms@2.1.3: {} mutation-server-protocol@0.4.1: @@ -4411,10 +6063,22 @@ snapshots: nanoid@3.3.19: {} + negotiator@0.6.3: {} + + negotiator@0.6.4: {} + negotiator@1.0.0: {} + neo-async@2.6.2: {} + + node-fetch@2.6.7: + dependencies: + whatwg-url: 5.0.0 + node-releases@2.0.57: {} + normalize-url@8.1.1: {} + npm-run-path@6.0.0: dependencies: path-key: 4.0.0 @@ -4426,10 +6090,14 @@ snapshots: obug@2.1.3: {} + on-exit-leak-free@2.1.2: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 + on-headers@1.1.0: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -4486,6 +6154,8 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.74.0 '@oxlint/binding-win32-x64-msvc': 1.74.0 + pako@0.2.9: {} + parse-ms@4.0.0: {} parseurl@1.3.3: {} @@ -4496,16 +6166,57 @@ snapshots: path-parse@1.0.7: {} + path-to-regexp@0.1.13: {} + path-to-regexp@8.4.2: {} + path-type@4.0.0: {} + pathe@2.0.3: {} + peek-stream@1.1.3: + dependencies: + buffer-from: 1.1.2 + duplexify: 3.7.1 + through2: 2.0.5 + + performance-now@2.1.0: {} + picocolors@1.1.1: {} + picomatch@2.3.2: {} + picomatch@4.0.5: {} picomatch@4.0.7: {} + pify@3.0.0: {} + + pino-abstract-transport@1.2.0: + dependencies: + readable-stream: 4.8.0 + split2: 4.2.0 + + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@9.14.0: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.1 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 3.2.0 + pkce-challenge@5.0.1: {} playwright-core@1.63.0: {} @@ -4524,6 +6235,14 @@ snapshots: dependencies: parse-ms: 4.0.0 + process-nextick-args@2.0.1: {} + + process-warning@1.0.0: {} + + process-warning@5.1.1: {} + + process@0.11.10: {} + progress@2.0.3: {} prompts@2.4.2: @@ -4536,6 +6255,17 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + pump@2.0.1: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + pumpify@1.5.1: + dependencies: + duplexify: 3.7.1 + inherits: 2.0.4 + pump: 2.0.1 + qs@6.15.1: dependencies: side-channel: 1.1.1 @@ -4545,8 +6275,28 @@ snapshots: es-define-property: 1.0.1 side-channel: 1.1.1 + qs@6.16.1: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + queue-microtask@1.2.3: {} + + quick-format-unescaped@4.0.4: {} + + quick-lru@5.1.1: {} + + range-parser@1.2.1: {} + range-parser@1.3.0: {} + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + raw-body@3.0.2: dependencies: bytes: 3.1.2 @@ -4572,6 +6322,26 @@ snapshots: react@19.3.0: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@4.8.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + real-require@0.2.0: {} + recharts@3.10.1(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react-is@19.3.0)(react@19.3.0)(redux@5.0.1): dependencies: '@reduxjs/toolkit': 2.13.0(react-redux@9.3.0(@types/react@19.3.0)(react@19.3.0)(redux@5.0.1))(react@19.3.0) @@ -4610,6 +6380,8 @@ snapshots: reselect@5.3.0: {} + resolve-alpn@1.2.1: {} + resolve@1.22.13: dependencies: es-errors: 1.3.0 @@ -4617,6 +6389,12 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + responselike@4.0.2: + dependencies: + lowercase-keys: 3.0.0 + + reusify@1.1.0: {} + rolldown@1.2.12: dependencies: '@oxc-project/types': 0.152.0 @@ -4648,20 +6426,52 @@ snapshots: transitivePeerDependencies: - supports-color + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + rxjs@7.8.2: dependencies: tslib: 2.8.1 + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + safe-regex@2.1.1: dependencies: regexp-tree: 0.1.27 + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} + sanitize-filename@1.6.4: + dependencies: + truncate-utf8-bytes: 1.0.2 + scheduler@0.28.0: {} semver@7.8.5: {} + send@0.19.2(supports-color@7.2.0): + dependencies: + debug: 2.6.9(supports-color@7.2.0) + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + send@1.2.1(supports-color@7.2.0): dependencies: debug: 4.4.3(supports-color@7.2.0) @@ -4678,6 +6488,15 @@ snapshots: transitivePeerDependencies: - supports-color + serve-static@1.16.3(supports-color@7.2.0): + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + serve-static@2.2.1(supports-color@7.2.0): dependencies: encodeurl: 2.0.0 @@ -4725,20 +6544,71 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} sisteransi@1.0.5: {} + slash@3.0.0: {} + + sonic-boom@3.8.1: + dependencies: + atomic-sleep: 1.0.0 + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} + source-map@0.6.1: {} + source-map@0.7.6: {} + split2@4.2.0: {} + + sshpk@1.18.0: + dependencies: + asn1: 0.2.6 + assert-plus: 1.0.0 + bcrypt-pbkdf: 1.0.2 + dashdash: 1.14.1 + ecc-jsbn: 0.1.2 + getpass: 0.1.7 + jsbn: 0.1.1 + safer-buffer: 2.1.2 + tweetnacl: 0.14.5 + stackback@0.0.2: {} statuses@2.0.2: {} std-env@4.2.0: {} + steno@0.4.4: + dependencies: + graceful-fs: 4.2.11 + + stream-shift@1.0.3: {} + + streamx@2.28.1: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-bom@3.0.0: {} strip-final-newline@4.0.0: {} @@ -4749,8 +6619,45 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + tagged-tag@1.0.0: {} + tapable@2.3.3: {} + tar-stream@3.2.0: + dependencies: + b4a: 1.9.0 + bare-fs: 4.8.4 + fast-fifo: 1.3.2 + streamx: 2.28.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.28.1 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.7: + dependencies: + b4a: 1.9.0 + transitivePeerDependencies: + - react-native-b4a + + thread-stream@3.2.0: + dependencies: + real-require: 0.2.0 + + through2@2.0.5: + dependencies: + readable-stream: 2.3.8 + xtend: 4.0.2 + + through@2.3.8: {} + tiny-invariant@1.3.3: {} tinybench@2.9.0: {} @@ -4766,10 +6673,30 @@ snapshots: tinyrainbow@3.1.0: {} + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + toidentifier@1.0.1: {} + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@0.0.3: {} + tree-kill@1.2.2: {} + truncate-utf8-bytes@1.0.2: + dependencies: + utf8-byte-length: 1.0.5 + tsconfig-paths-webpack-plugin@4.2.0: dependencies: chalk: 4.1.2 @@ -4785,8 +6712,25 @@ snapshots: tslib@2.8.1: {} + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + tunnel@0.0.6: {} + tweetnacl@0.14.5: {} + + typanion@3.14.0: {} + + type-fest@5.10.0: + dependencies: + tagged-tag: 1.0.0 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + type-is@2.1.0: dependencies: content-type: 2.0.0 @@ -4828,6 +6772,11 @@ snapshots: '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 + uglify-js@3.19.3: + optional: true + + uint8array-extras@1.6.0: {} + underscore@1.13.8: {} undici-types@8.3.0: {} @@ -4836,6 +6785,8 @@ snapshots: unicorn-magic@0.3.0: {} + unix-crypt-td-js@1.1.4: {} + unpipe@1.0.0: {} update-browserslist-db@1.3.3(browserslist@4.29.3): @@ -4848,10 +6799,87 @@ snapshots: dependencies: react: 19.3.0 + utf8-byte-length@1.0.5: {} + + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + validator@13.15.26: {} + vary@1.1.2: {} + verdaccio-audit@13.1.7(supports-color@7.2.0): + dependencies: + '@verdaccio/config': 8.3.3(supports-color@7.2.0) + '@verdaccio/core': 8.3.3 + express: 4.22.3(supports-color@7.2.0) + https-proxy-agent: 5.0.1(supports-color@7.2.0) + node-fetch: 2.6.7 + transitivePeerDependencies: + - encoding + - supports-color + + verdaccio-htpasswd@13.1.6(supports-color@7.2.0): + dependencies: + '@verdaccio/core': 8.3.3 + '@verdaccio/file-locking': 13.1.0 + apache-md5: 1.1.8 + bcryptjs: 2.4.3 + debug: 4.4.3(supports-color@7.2.0) + http-errors: 2.0.1 + unix-crypt-td-js: 1.1.4 + transitivePeerDependencies: + - supports-color + + verdaccio@6.10.6(supports-color@7.2.0)(typanion@3.14.0): + dependencies: + '@cypress/request': 4.0.1 + '@verdaccio/auth': 8.1.6(supports-color@7.2.0) + '@verdaccio/config': 8.3.3(supports-color@7.2.0) + '@verdaccio/core': 8.3.3 + '@verdaccio/hooks': 8.1.7(supports-color@7.2.0) + '@verdaccio/loaders': 8.1.6(supports-color@7.2.0) + '@verdaccio/local-storage-legacy': 11.4.6(supports-color@7.2.0) + '@verdaccio/logger': 8.1.6(supports-color@7.2.0) + '@verdaccio/middleware': 8.1.7(supports-color@7.2.0) + '@verdaccio/package-filter': 13.2.4(supports-color@7.2.0) + '@verdaccio/search-indexer': 8.1.0(supports-color@7.2.0) + '@verdaccio/signature': 8.1.6(supports-color@7.2.0) + '@verdaccio/streams': 10.3.0 + '@verdaccio/tarball': 13.1.6(supports-color@7.2.0) + '@verdaccio/ui-theme': 9.0.0-next-9.36(supports-color@7.2.0) + '@verdaccio/url': 13.1.6(supports-color@7.2.0) + JSONStream: 1.3.5 + async: 3.2.6 + clipanion: 4.0.0-rc.4(typanion@3.14.0) + compression: 1.8.2(supports-color@7.2.0) + cors: 2.8.6 + debug: 4.4.3(supports-color@7.2.0) + envinfo: 7.21.0 + express: 4.22.3(supports-color@7.2.0) + lodash: 4.18.1 + lru-cache: 7.18.3 + mime: 3.0.0 + semver: 7.8.5 + verdaccio-audit: 13.1.7(supports-color@7.2.0) + verdaccio-htpasswd: 13.1.6(supports-color@7.2.0) + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - encoding + - react-native-b4a + - supports-color + - typanion + verkit@0.3.2: {} + verror@1.10.0: + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.3.0 + victory-vendor@37.3.6: dependencies: '@types/d3-array': 3.2.2 @@ -4913,6 +6941,13 @@ snapshots: weapon-regex@2.0.5: {} + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which@2.0.2: dependencies: isexe: 2.0.0 @@ -4922,10 +6957,14 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wordwrap@1.0.0: {} + wrappy@1.0.2: {} ws@8.18.3: {} + xtend@4.0.2: {} + yaml@2.9.1: {} yoctocolors@2.1.2: {} diff --git a/src/admin/index.ts b/src/admin/index.ts index 19f9b9d5b..1715d3414 100644 --- a/src/admin/index.ts +++ b/src/admin/index.ts @@ -27,6 +27,8 @@ export type { ComponentInstallOutput, ComponentInstallProgress, DaemonStopOutput, + DaemonUpdateInput, + DaemonUpdateOutput, DeviceAddInput, DeviceAddOutput, DeviceRecoveredPush, diff --git a/src/bus/index.test.ts b/src/bus/index.test.ts index 4b490d980..6239877fe 100644 --- a/src/bus/index.test.ts +++ b/src/bus/index.test.ts @@ -46,6 +46,8 @@ const EVENT_NAMES: Record = { "device.orphan-purged": true, "daemon.started": true, "daemon.stopping": true, + "daemon.update-failed": true, + "daemon.update-started": true, "disk.pressure-detected": true, "cleanup.executed": true, "doctor.reconciled": true, diff --git a/src/bus/index.ts b/src/bus/index.ts index ae9e996bc..0c4ae8812 100644 --- a/src/bus/index.ts +++ b/src/bus/index.ts @@ -76,6 +76,7 @@ export interface EventMap { | "lease-id-taken" | "boot-timeout" | "killed" + | "updating" | "cancelled" /** A request still open when the daemon stopped, settled as failed at the next start. */ | "daemon-restarted" @@ -260,6 +261,17 @@ export interface EventMap { }; "daemon.started": { readonly version: string; readonly configSnapshot: unknown }; "daemon.stopping": { readonly reason: string }; + "daemon.update-failed": { + readonly stage: "installing" | "restarting"; + readonly from: string; + readonly to: string; + readonly message: string; + }; + "daemon.update-started": { + readonly from: string; + readonly to: string; + readonly forced: boolean; + }; "disk.pressure-detected": { readonly freeBytes: number; readonly threshold: number }; "device.foreign-provenance-detected": { readonly deviceId: string; diff --git a/src/cli/commands/update.ts b/src/cli/commands/update.ts new file mode 100644 index 000000000..2e2da7b85 --- /dev/null +++ b/src/cli/commands/update.ts @@ -0,0 +1,202 @@ +import type { SimlockAdminClient } from "../../admin/index.js"; +import { isSimlockError, updateRequestedVersionSchema } from "../../contract/index.js"; +import { commandArgs, UsageError, writeResult } from "../args.js"; +import { connectDaemonClient } from "../connect.js"; +import { waitForTermination } from "../termination.js"; +import type { CommandContext } from "../tree.js"; + +const POLL_MS = 1_000; +const COME_BACK_MS = 15 * 60_000; +const USAGE = "Usage: simlock update [--version ] [--force] [--json]\n"; + +type Answer = Awaited>; +type UpdateState = NonNullable; +type Environment = CommandContext["environment"]; + +interface Followed { + readonly answer: Answer; + readonly interrupted?: true; +} + +function plural(count: number): string { + return `${count} ${count === 1 ? "lease" : "leases"}`; +} + +function upToDateMessage(answer: Answer, version: string | undefined): string { + if (version !== undefined) return `simlock ${answer.version} is already running`; + if (answer.resolved === answer.version) return `simlock ${answer.version} is already the latest`; + return `simlock ${answer.version} is newer than the latest (${answer.resolved}). Use --version to install another version.`; +} + +function progressLine(update: UpdateState, first: boolean): string { + if (update.stage === "installing") return `Installing simlock@${update.to}`; + if (update.stage === "restarting") return "Restarting the daemon"; + const base = `Waiting for ${plural(update.leases)} to end.`; + return first ? `${base} Ctrl-C stops following; simlock update --force stops waiting.` : base; +} + +class Follower { + #client: SimlockAdminClient; + #stopped = false; + #shown = ""; + #lastAnswerAt: number; + + constructor( + client: SimlockAdminClient, + private readonly environment: Environment, + private readonly token: string | undefined, + private readonly say: (line: string) => void, + private readonly termination: ReturnType, + ) { + this.#client = client; + this.#lastAnswerAt = environment.clock.now(); + void termination.settled.then(() => { + this.#stopped = true; + }); + } + + async follow(first: Answer): Promise { + if (first.update !== null) + this.say(`Updating simlock ${first.update.from} → ${first.update.to}`); + let current: Answer | undefined = first; + while (current?.update != null) { + this.#show(current.update); + await this.#pause(); + current = this.#stopped ? undefined : await this.#poll(); + } + return current === undefined ? { answer: first, interrupted: true } : { answer: current }; + } + + close(): Promise { + return this.#client.close().catch(() => undefined); + } + + #show(update: UpdateState): void { + const key = update.stage === "waiting" ? `waiting ${update.leases}` : update.stage; + if (key === this.#shown) return; + this.say(progressLine(update, this.#shown === "")); + this.#shown = key; + } + + async #pause(): Promise { + await Promise.race([this.environment.sleep(POLL_MS), this.termination.settled]); + } + + async #poll(): Promise { + try { + const answer = await this.#client.updateDaemon({ query: true }); + this.#lastAnswerAt = this.environment.clock.now(); + return this.#stopped ? undefined : answer; + } catch (error: unknown) { + if (isSimlockError(error) && error.kind !== "transport") throw error; + await this.close(); + if (this.#shown !== "restarting") this.say("Restarting the daemon"); + this.#shown = "restarting"; + return this.#reconnect(); + } + } + + async #reconnect(): Promise { + for (;;) { + await this.#pause(); + if (this.#stopped) return undefined; + try { + // why: each pass opens its own connection, so the one before it is closed first and none outlives the command. + await this.close(); + this.#client = await connectDaemonClient(this.environment, this.token, { launch: false }); + const answer = await this.#client.updateDaemon({ query: true }); + this.#lastAnswerAt = this.environment.clock.now(); + // why: a daemon still starting answers a query too, but its update result is not read yet, so only a running one has the last word. + if (answer.health === "running") return answer; + } catch { + if (this.environment.clock.now() - this.#lastAnswerAt >= COME_BACK_MS) throw new GaveUp(); + } + } + } +} + +class GaveUp extends Error {} + +function parseUpdateArgs(argv: readonly string[]) { + const values = commandArgs(argv, { + force: { type: "boolean" }, + help: { type: "boolean", short: "h" }, + json: { type: "boolean" }, + version: { type: "string" }, + }); + const version = typeof values.version === "string" ? values.version : undefined; + if (version !== undefined && !updateRequestedVersionSchema.safeParse(version).success) { + throw new UsageError("--version takes a version, e.g. 0.4.0"); + } + return { + help: values.help === true, + json: values.json === true, + request: { + ...(version === undefined ? {} : { version }), + ...(values.force === true ? { force: true } : {}), + }, + version, + }; +} + +async function run( + argv: readonly string[], + { environment, token }: CommandContext, +): Promise { + const args = parseUpdateArgs(argv); + if (args.help) { + environment.stdout.write(USAGE); + return 0; + } + const say = (line: string): void => { + if (!args.json) environment.stdout.write(`${line}\n`); + }; + const client = await connectDaemonClient(environment, token); + const termination = waitForTermination(environment.signals); + const follower = new Follower(client, environment, token, say, termination); + try { + const first = await client.updateDaemon(args.request); + if (first.upToDate) { + if (args.json) writeResult((value) => environment.stdout.write(value), first); + else say(upToDateMessage(first, args.version)); + return 0; + } + return await finish(await follower.follow(first), environment, say, args.json); + } catch (error: unknown) { + if (!(error instanceof GaveUp)) throw error; + say("The daemon did not come back; run simlock status."); + return 1; + } finally { + termination.dispose(); + await follower.close(); + } +} + +function finish( + followed: Followed, + environment: Environment, + say: (line: string) => void, + json: boolean, +): number { + const { answer } = followed; + if (followed.interrupted === true) { + say("Stopped following. The update goes on; run simlock status to see it."); + return 0; + } + const failure = answer.lastUpdateFailure; + const failed = failure !== null; + if (json) { + writeResult((value) => environment.stdout.write(value), answer); + } else if (failed) { + say( + failure.stage === "installing" + ? `Update failed (installing): ${failure.message}\nsimlock ${failure.from} is still running and takes leases again.` + : `Update failed (restarting): ${failure.message}. Rolled back to ${failure.from}.`, + ); + } else { + say(`simlock ${answer.version} is running`); + } + return failed ? 1 : 0; +} + +export { run }; diff --git a/src/cli/format.ts b/src/cli/format.ts index d3084807e..4aad44f5c 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -185,6 +185,27 @@ export function serviceLine(service: ServiceReport): string { return `Service: not loaded ${who}: commands start the daemon without it`; } +function daemonLines(daemon: StatusGetOutput["daemon"]): string[] { + const { update, lastUpdateFailure } = daemon; + return [ + `Daemon: ${daemon.health} (${daemon.mode})`, + `Simlock: ${daemon.version}`, + ...(update === null + ? [] + : [ + `Updating to ${update.to}: ${ + update.stage === "waiting" + ? `waiting for ${update.leases} ${update.leases === 1 ? "lease" : "leases"}` + : update.stage + }`, + ]), + ...(lastUpdateFailure === null + ? [] + : [`Last update failed (${lastUpdateFailure.stage}): ${lastUpdateFailure.message}`]), + ...consoleLines(daemon), + ]; +} + // fallow-ignore-next-line complexity -- stable human status rendering is intentionally a single formatter. export function formatStatus(status: StatusGetOutput, now: number): string { const { capacity, daemon, devices, host, installs, leases, queueDepth, workers } = status; @@ -195,8 +216,7 @@ export function formatStatus(status: StatusGetOutput, now: number): string { queueDepth === undefined ) { return [ - `Daemon: ${daemon.health} (${daemon.mode})`, - ...consoleLines(daemon), + ...daemonLines(daemon), `Host: ${formatHost(host)}`, ...loadLines(status), "Devices, leases and capacity appear once startup finishes.", @@ -241,8 +261,7 @@ export function formatStatus(status: StatusGetOutput, now: number): string { `Lease ${lease.id}: ${lease.requesterId}${lease.workerId === undefined ? "" : ` on ${lease.workerId}`} since ${lease.grantedAt}, last renewed ${lease.lastRenewedAt}`, ); return [ - `Daemon: ${daemon.health} (${daemon.mode})`, - ...consoleLines(daemon), + ...daemonLines(daemon), `Host: ${formatHost(host)}`, ...loadLines(status), ...historyLines(status.history), diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 9a0f1d440..ddbb24120 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -419,7 +419,14 @@ describe("CLI: exit codes", () => { describe("remote passthrough (ADR 0005 device.exec)", () => { const gatewayStatus = { ...EMPTY_STATUS, - daemon: { health: "running" as const, mode: "gateway" as const }, + daemon: { + health: "running" as const, + mode: "gateway" as const, + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, }; const ownLease = { deviceId: "dev_1", @@ -4128,7 +4135,14 @@ describe("CLI: worker commands (ADR 0005 §8/§23)", () => { const output = outputCapture(); const status: StatusGetOutput = { ...EMPTY_STATUS, - daemon: { health: "running", mode: "gateway" }, + daemon: { + health: "running", + mode: "gateway", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, load: { ageMs: 4_000, ...PARTS }, workers: [ { @@ -4476,7 +4490,14 @@ describe("CLI: status renders the fleet a gateway reports (ADR 0005 §20)", () = warm: 0, }, }, - daemon: { health: "running" as const, mode: "gateway" as const }, + daemon: { + health: "running" as const, + mode: "gateway" as const, + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, host: { arch: "x64", os: "Linux", osVersion: "6.8.0", tools: [] }, devices: [ { @@ -4671,7 +4692,14 @@ describe("CLI: status renders the fleet a gateway reports (ADR 0005 §20)", () = getStatus: () => Promise.resolve({ ...EMPTY_STATUS, - daemon: { health: "running" as const, mode }, + daemon: { + health: "running" as const, + mode, + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, warmPool, }), }), @@ -4680,6 +4708,7 @@ describe("CLI: status renders the fleet a gateway reports (ADR 0005 §20)", () = expect(output.stdout).toBe( `Daemon: running (${mode})\n` + + "Simlock: 0.0.0-test\n" + "Host: macOS 15.5 arm64\n" + "Running global: 0 + 0 reserved/2, warm 0\n" + "Capacity ios: managed 0/1, running 0 + 0 reserved/1, warm 0\n" + @@ -4694,7 +4723,7 @@ describe("CLI: status renders the fleet a gateway reports (ADR 0005 §20)", () = const output = outputCapture(); const answer = { ...EMPTY_STATUS, - daemon: { health: "running", mode }, + daemon: { ...EMPTY_STATUS.daemon, mode }, physicalQueueDepth: 1, queueDepth: 2, } as unknown as StatusGetOutput; @@ -4708,6 +4737,7 @@ describe("CLI: status renders the fleet a gateway reports (ADR 0005 §20)", () = expect(output.stdout).toBe( `Daemon: running (${mode})\n` + + "Simlock: 0.0.0-test\n" + "Host: macOS 15.5 arm64\n" + "Running global: 0 + 0 reserved/2, warm 0\n" + "Capacity ios: managed 0/1, running 0 + 0 reserved/1, warm 0\n" + @@ -4774,7 +4804,14 @@ describe("CLI: status renders the fleet a gateway reports (ADR 0005 §20)", () = describe("against a starting daemon", () => { const STARTING: StatusGetOutput = { - daemon: { health: "starting", mode: "worker" }, + daemon: { + health: "starting", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, host: { arch: "arm64", os: "macOS", osVersion: "15.5", tools: [] }, }; @@ -4789,6 +4826,7 @@ describe("CLI: status renders the fleet a gateway reports (ADR 0005 §20)", () = expect(output.stdout).toBe( "Daemon: starting (worker)\n" + + "Simlock: 0.0.0-test\n" + "Host: macOS 15.5 arm64\n" + "Devices, leases and capacity appear once startup finishes.\n", ); @@ -4870,7 +4908,14 @@ describe("CLI: status renders the fleet a gateway reports (ADR 0005 §20)", () = const FULL_LINE = "Load: CPU 34% busy (10 cores) · memory 9.8 GiB of 32.0 GiB available · swap 1.2 GiB used · pressure normal · disk 112.4 GiB of 494.4 GiB free · read 4s ago\n"; const STARTING = { - daemon: { health: "starting" as const, mode: "worker" as const }, + daemon: { + health: "starting" as const, + mode: "worker" as const, + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, host: { arch: "arm64", os: "macOS", osVersion: "15.5", tools: [] }, }; @@ -4892,6 +4937,7 @@ describe("CLI: status renders the fleet a gateway reports (ADR 0005 §20)", () = it("simlock status prints the Load line after the Host line, on a starting daemon and a running one", async () => { expect(await print({ ...STARTING, load: LOAD })).toBe( "Daemon: starting (worker)\n" + + "Simlock: 0.0.0-test\n" + "Host: macOS 15.5 arm64\n" + FULL_LINE + "Devices, leases and capacity appear once startup finishes.\n", @@ -4983,7 +5029,7 @@ describe("CLI: status renders the fleet a gateway reports (ADR 0005 §20)", () = const daemon = { ...EMPTY_STATUS.daemon, consoleUrl: "http://127.0.0.1:4700/" }; expect(await print({ ...EMPTY_STATUS, daemon })).toContain( - "Daemon: running (worker)\nConsole: http://127.0.0.1:4700/\nHost: ", + "Daemon: running (worker)\nSimlock: 0.0.0-test\nConsole: http://127.0.0.1:4700/\nHost: ", ); expect(await print(EMPTY_STATUS)).not.toContain("Console:"); }); @@ -5118,7 +5164,14 @@ describe("CLI: status renders the fleet a gateway reports (ADR 0005 §20)", () = }; const status: StatusGetOutput = { ...EMPTY_STATUS, - daemon: { health: "running", mode: "gateway" }, + daemon: { + health: "running", + mode: "gateway", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, installs: [{ ...install, workerId: "wrk_1" }], workers: [ { @@ -7176,10 +7229,395 @@ const EMPTY_STATUS = { }, global: { running: 0, maxRunning: 2, reserved: 0, overLimit: false, warm: 0 }, }, - daemon: { health: "running", mode: "worker" }, + daemon: { + health: "running", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, queueDepth: 0, } satisfies StatusGetOutput; +describe("simlock update and simlock status", () => { + type UpdateAnswer = Awaited>; + type Step = UpdateAnswer | SimlockError; + const lost = () => + new SimlockError("DAEMON_CONNECTION_LOST", "transport", "The daemon connection was lost", {}); + const answer = (overrides: Partial = {}): UpdateAnswer => ({ + started: false, + upToDate: false, + resolved: null, + version: "0.3.0", + health: "running", + update: null, + lastUpdateFailure: null, + latestVersion: null, + ...overrides, + }); + const state = ( + stage: "waiting" | "installing" | "restarting", + leases = 0, + ): NonNullable => { + const base = { from: "0.3.0", to: "0.4.0", forced: false, startedAt: 1_000 }; + return stage === "waiting" ? { stage, leases, ...base } : { stage, ...base }; + }; + const failure = (stage: "installing" | "restarting", message: string) => ({ + stage, + from: "0.3.0", + to: "0.4.0", + message, + at: 2_000, + }); + const started = (update = state("installing")) => + answer({ started: true, resolved: "0.4.0", update }); + const running = (version = "0.4.0", overrides: Partial = {}) => + answer({ version, ...overrides }); + + async function runUpdate( + argv: readonly string[], + script: { + readonly first: UpdateAnswer | SimlockError; + readonly polls?: readonly Step[]; + readonly after?: readonly (Step | Error)[]; + readonly onPoll?: (index: number, interrupt: () => void) => void; + readonly onReconnect?: (interrupt: () => void) => void; + }, + ) { + const output = outputCapture(); + const clock = new FakeClock(0); + const calls: unknown[] = []; + const polls = [...(script.polls ?? [])]; + const after = [...(script.after ?? [])]; + let pollCount = 0; + const signals = new EventEmitter(); + let opened = 0; + const closedClients = new Set(); + const clientOver = (steps: Step[], first?: UpdateAnswer | SimlockError) => { + const id = opened++; + return fakeClient({ + close: async () => { + closedClients.add(id); + }, + updateDaemon: async (input) => { + calls.push(input); + const isFirst = first !== undefined && calls.length === 1; + if (!isFirst) script.onPoll?.(pollCount++, () => signals.emit("SIGINT")); + const step = isFirst ? first : steps.length > 1 ? steps.shift() : steps[0]; + if (step === undefined) throw new Error("no scripted answer"); + if (step instanceof Error) throw step; + return step; + }, + }); + }; + const environment = output.environmentWith({ + clock, + connectAdmin: async () => clientOver(polls, script.first), + connectExistingAdmin: async () => { + script.onReconnect?.(() => signals.emit("SIGINT")); + const step = after.length > 1 ? after.shift() : after[0]; + if (step === undefined || step instanceof Error) throw step ?? new Error("refused"); + return clientOver([step]); + }, + sleep: async (milliseconds) => clock.advance(milliseconds), + signals: signals as unknown as CliEnvironment["signals"], + }); + const code = await runCli(argv, environment); + return { + code, + calls, + stdout: output.stdout, + stderr: output.stderr, + opened, + closed: closedClients.size, + }; + } + + it("follows an update to simlock is running, reports a failure and a rollback, says when nothing is needed, stops following on Ctrl-C only, prints --json, refuses bad input, and shows the version, the update and the last failure in simlock status", async () => { + let reconnects = 0; + // why: the follower reconnects every second and gives up when 15 minutes have passed since the last answer, so the last attempt is at 14:59 on the fake clock. + const RECONNECTS_IN_15_MINUTES = 899; + const header = "Updating simlock 0.3.0 → 0.4.0\n"; + const wait = + "Waiting for 2 leases to end. Ctrl-C stops following; simlock update --force stops waiting.\n"; + const cases: readonly { + readonly name: string; + readonly argv: readonly string[]; + readonly script: Parameters[1]; + readonly code: number; + readonly stdout: string; + readonly calls?: readonly unknown[]; + readonly reconnects?: number; + }[] = [ + { + name: "follows to running", + argv: ["update"], + script: { + first: started(state("waiting", 2)), + polls: [ + answer({ update: state("waiting", 2) }), + answer({ update: state("waiting", 1) }), + answer({ update: state("installing") }), + answer({ update: state("restarting") }), + lost(), + ], + after: [new Error("refused"), running()], + }, + code: 0, + stdout: `${header}${wait}Waiting for 1 lease to end.\nInstalling simlock@0.4.0\nRestarting the daemon\nsimlock 0.4.0 is running\n`, + calls: [{}, ...Array.from({ length: 6 }, () => ({ query: true }))], + }, + { + name: "follows an update that already runs, whatever version it names", + argv: ["update", "--version", "0.9.9"], + script: { + first: answer({ update: state("installing") }), + polls: [lost()], + after: [running()], + }, + code: 0, + stdout: `${header}Installing simlock@0.4.0\nRestarting the daemon\nsimlock 0.4.0 is running\n`, + }, + { + name: "reports a failed install", + argv: ["update"], + script: { + first: started(), + polls: [answer({ lastUpdateFailure: failure("installing", "npm error code EACCES") })], + }, + code: 1, + stdout: `${header}Installing simlock@0.4.0\nUpdate failed (installing): npm error code EACCES\nsimlock 0.3.0 is still running and takes leases again.\n`, + }, + { + name: "reports a version that did not start and the rollback", + argv: ["update"], + script: { + first: started(), + polls: [answer({ update: state("restarting") }), lost()], + after: [ + answer({ + lastUpdateFailure: failure("restarting", "0.4.0 did not start: health failed"), + }), + ], + }, + code: 1, + stdout: `${header}Installing simlock@0.4.0\nRestarting the daemon\nUpdate failed (restarting): 0.4.0 did not start: health failed. Rolled back to 0.3.0.\n`, + }, + { + name: "waits through a daemon that answers while it is still starting", + argv: ["update"], + script: { + first: started(), + polls: [lost()], + after: [ + answer({ health: "starting" }), + answer({ health: "starting" }), + answer({ health: "starting" }), + answer({ + lastUpdateFailure: failure("restarting", "0.4.0 did not start: health failed"), + }), + ], + }, + code: 1, + stdout: `${header}Installing simlock@0.4.0\nRestarting the daemon\nUpdate failed (restarting): 0.4.0 did not start: health failed. Rolled back to 0.3.0.\n`, + }, + { + name: "gives up when the daemon does not come back in 15 minutes", + argv: ["update"], + script: { + first: started(), + polls: [answer({ update: state("restarting") }), lost()], + after: [new Error("refused")], + onReconnect: () => { + reconnects += 1; + }, + }, + code: 1, + stdout: `${header}Installing simlock@0.4.0\nRestarting the daemon\nThe daemon did not come back; run simlock status.\n`, + reconnects: RECONNECTS_IN_15_MINUTES, + }, + { + name: "stops with the daemon's own error when a poll is refused for a reason other than a lost connection", + argv: ["update"], + script: { + first: started(), + polls: [new SimlockError("UPDATE_RESOLVE_FAILED", "domain", "npm is down", {})], + }, + code: 1, + stdout: `${header}Installing simlock@0.4.0\n`, + }, + { + name: "prints its usage for --help and asks the daemon nothing", + argv: ["update", "--help"], + script: { first: answer() }, + code: 0, + stdout: "Usage: simlock update [--version ] [--force] [--json]\n", + calls: [], + }, + { + name: "the latest version is the running one", + argv: ["update"], + script: { first: answer({ upToDate: true, resolved: "0.3.0" }) }, + code: 0, + stdout: "simlock 0.3.0 is already the latest\n", + calls: [{}], + }, + { + name: "the named version is the running one", + argv: ["update", "--version", "0.3.0"], + script: { first: answer({ upToDate: true, resolved: "0.3.0" }) }, + code: 0, + stdout: "simlock 0.3.0 is already running\n", + calls: [{ version: "0.3.0" }], + }, + { + name: "the running version is newer than the latest", + argv: ["update"], + script: { first: answer({ upToDate: true, resolved: "0.4.0", version: "0.5.0-rc.1" }) }, + code: 0, + stdout: + "simlock 0.5.0-rc.1 is newer than the latest (0.4.0). Use --version to install another version.\n", + }, + { + name: "sends force and the version", + argv: ["update", "--force", "--version", "0.4.0-rc.1"], + script: { first: answer({ upToDate: true, resolved: "0.3.0" }) }, + code: 0, + stdout: "simlock 0.3.0 is already running\n", + calls: [{ force: true, version: "0.4.0-rc.1" }], + }, + { + name: "--json prints the final answer only", + argv: ["update", "--json"], + script: { first: started(state("waiting", 1)), polls: [lost()], after: [running()] }, + code: 0, + stdout: `${JSON.stringify(running())}\n`, + }, + { + name: "--json with nothing to do prints the answer at once", + argv: ["update", "--json"], + script: { first: answer({ upToDate: true, resolved: "0.3.0" }) }, + code: 0, + stdout: `${JSON.stringify(answer({ upToDate: true, resolved: "0.3.0" }))}\n`, + calls: [{}], + }, + { + name: "Ctrl-C stops following and sends nothing more", + argv: ["update"], + script: { + first: started(state("waiting", 2)), + polls: [answer({ update: state("waiting", 2) })], + onPoll: (_index, interrupt) => interrupt(), + }, + code: 0, + stdout: `${header}${wait}Stopped following. The update goes on; run simlock status to see it.\n`, + calls: [{}, { query: true }], + }, + { + name: "Ctrl-C while the daemon restarts stops following", + argv: ["update"], + script: { + first: started(), + polls: [lost()], + after: [new Error("refused")], + onReconnect: (interrupt) => interrupt(), + }, + code: 0, + stdout: `${header}Installing simlock@0.4.0\nRestarting the daemon\nStopped following. The update goes on; run simlock status to see it.\n`, + calls: [{}, { query: true }], + }, + ]; + for (const { name, argv, script, code, stdout, calls, reconnects: expected } of cases) { + reconnects = 0; + const result = await runUpdate(argv, script); + if (expected !== undefined) expect(reconnects, name).toBe(expected); + expect({ name, code: result.code, stdout: result.stdout }, name).toEqual({ + name, + code, + stdout, + }); + if (calls !== undefined) expect(result.calls, name).toEqual(calls); + expect(result.closed, `${name}: every client opened is closed`).toBe(result.opened); + } + + const refusals: readonly [readonly string[], number, string][] = [ + [["update", "--version", "latest"], 2, "--version takes a version, e.g. 0.4.0"], + [["update", "--version", "../x"], 2, "--version takes a version, e.g. 0.4.0"], + [["update", "--version", `1.0.0-${"a".repeat(59)}`], 2, "--version takes a version"], + ]; + for (const [argv, code, message] of refusals) { + const result = await runUpdate(argv, { first: answer() }); + expect({ argv, code, stderr: result.stderr.includes(message), calls: result.calls }).toEqual({ + argv, + code, + stderr: true, + calls: [], + }); + } + + const refused = [ + ["UPDATE_RESOLVE_FAILED", 1, "could not resolve simlock@9.9.9: No match found"], + ["UPDATE_UNSUPPORTED_INSTALL", 2, "simlock was not installed with npm i -g"], + ] as const; + for (const [codeName, exit, message] of refused) { + const result = await runUpdate(["update"], { + first: new SimlockError(codeName, "domain", message, {}), + }); + expect({ code: result.code, stdout: result.stdout }).toEqual({ code: exit, stdout: "" }); + expect(JSON.parse(result.stderr.trim().split("\n").at(-1) ?? "")).toEqual({ + error: { code: codeName, message }, + }); + } + + const statusLines = async (daemon: Partial) => { + const output = outputCapture(); + await runCli( + ["status"], + output.environmentWith({ + connectAdmin: async () => + fakeClient({ + getStatus: () => + Promise.resolve({ ...EMPTY_STATUS, daemon: { ...EMPTY_STATUS.daemon, ...daemon } }), + }), + }), + ); + return output.stdout.split("\n").slice(0, 4); + }; + const next = "Host: macOS 15.5 arm64"; + expect(await statusLines({ version: "0.3.0" })).toEqual([ + "Daemon: running (worker)", + "Simlock: 0.3.0", + next, + expect.stringMatching(/^Running global/), + ]); + for (const [update, line] of [ + [state("waiting", 2), "Updating to 0.4.0: waiting for 2 leases"], + [state("waiting", 1), "Updating to 0.4.0: waiting for 1 lease"], + [state("installing"), "Updating to 0.4.0: installing"], + [state("restarting"), "Updating to 0.4.0: restarting"], + ] as const) { + expect((await statusLines({ version: "0.3.0", update })).slice(0, 3)).toEqual([ + "Daemon: running (worker)", + "Simlock: 0.3.0", + line, + ]); + } + expect( + ( + await statusLines({ + version: "0.3.0", + lastUpdateFailure: failure("installing", "npm error code EACCES"), + }) + ).slice(0, 3), + ).toEqual([ + "Daemon: running (worker)", + "Simlock: 0.3.0", + "Last update failed (installing): npm error code EACCES", + ]); + }); +}); + function fakeClient(overrides: Partial = {}): SimlockAdminClient { const emptyCatalog: CatalogGetOutput = { platforms: [] }; const emptyDoctor: DoctorReport = { findings: [] }; @@ -7246,6 +7684,17 @@ function fakeClient(overrides: Partial = {}): SimlockAdminCl runNuke: () => Promise.resolve({ deletedDevices: [], releasedLeaseIds: [] }), getConfig: () => Promise.resolve({} as SimlockConfig), stopDaemon: () => Promise.resolve({ stopping: true }), + updateDaemon: () => + Promise.resolve({ + started: false, + upToDate: true, + resolved: null, + version: "test", + health: "running" as const, + update: null, + lastUpdateFailure: null, + latestVersion: null, + }), replayEvents: () => Promise.resolve([]), usage: (window) => Promise.resolve(usageAnswer(window)), subscribeEvents: () => Promise.resolve(() => Promise.resolve()), @@ -8356,7 +8805,14 @@ describe("CLI: a worker's capabilities", () => { global: { maxRunning: 2, overLimit: false, reserved: 0, running: 0, warm: 0 }, ios: platform, }, - daemon: { health: "running" as const, mode: "gateway" as const }, + daemon: { + health: "running" as const, + mode: "gateway" as const, + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, devices: [], host: { arch: "x64", os: "Linux", osVersion: "6.8.0", tools: [] }, leases: [], diff --git a/src/cli/index.ts b/src/cli/index.ts index f113b14de..f77df3bda 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -16,7 +16,7 @@ const USAGE = `Usage: simlock [options] Commands: lease, release, status, list, catalog, cleanup, doctor, nuke, events, stats, - daemon, config, token, whoami + update, daemon, config, token, whoami worker Inspect and manage the workers of a gateway; on a single host, list shows the host itself diff --git a/src/cli/tree.ts b/src/cli/tree.ts index 6f5587d95..d1c09dd08 100644 --- a/src/cli/tree.ts +++ b/src/cli/tree.ts @@ -60,6 +60,7 @@ export const COMMANDS: readonly CommandNode[] = [ { name: "lease", load: leaseModule, children: [{ name: "renew", load: leaseModule }] }, { name: "release", load: () => import("./commands/release.js") }, { name: "status", load: () => import("./commands/status.js") }, + { name: "update", load: () => import("./commands/update.js") }, { name: "list", load: () => import("./commands/list.js") }, { name: "catalog", load: () => import("./commands/catalog.js") }, { name: "cleanup", load: () => import("./commands/cleanup.js") }, diff --git a/src/contract/AGENTS.md b/src/contract/AGENTS.md index ab89af096..878f16b0e 100644 --- a/src/contract/AGENTS.md +++ b/src/contract/AGENTS.md @@ -9,19 +9,20 @@ Imported by: daemon, gateway, simlock-client, every frontend, core, leasing, his ## Files -| File | Role | -| ---------------- | ----------------------------------------------------------------------------- | -| `operations.ts` | Every operation: name, role, effect, input, output, `authorize`; `OPERATIONS` | -| `schemas.ts` | Wire shapes, re-declared by hand from core; `fit*` bounders | -| `roles.ts` | `Role`, `AuthorizeContext`, `ownsLease` | -| `errors.ts` | `ErrorDetailsMap`, `ERROR_TABLE`, `SimlockError`, `fromWireError` | -| `pushes.ts` | `PUSH_SCHEMAS`: every push kind | -| `protocol.ts` | `PROTOCOL_VERSION_RANGE`, negotiation, `hello` schemas | -| `worker-view.ts` | `workerViewFields`, view refresh interval and events | -| `os-range.ts` | OS constraint grammar: parse, `satisfies`, `compareVersions` | -| `duration.ts` | `parseDurationMs`, for the CLI and HTTP | -| `relay.ts` | Bounded relay ids, channel ids, headers | -| `console-url.ts` | The console address `status.get` reports | +| File | Role | +| ------------------ | ----------------------------------------------------------------------------------------------------- | +| `operations.ts` | Every operation: name, role, effect, input, output, `authorize`; `OPERATIONS` | +| `schemas.ts` | Wire shapes, re-declared by hand from core; `fit*` bounders | +| `roles.ts` | `Role`, `AuthorizeContext`, `ownsLease` | +| `errors.ts` | `ErrorDetailsMap`, `ERROR_TABLE`, `SimlockError`, `fromWireError` | +| `pushes.ts` | `PUSH_SCHEMAS`: every push kind | +| `protocol.ts` | `PROTOCOL_VERSION_RANGE`, negotiation, `hello` schemas | +| `worker-view.ts` | `workerViewFields`, view refresh interval and events | +| `os-range.ts` | OS constraint grammar: parse, `satisfies`, `compareVersions` | +| `version-order.ts` | `isNewerVersion(candidate, running)`: the one "newer" rule (semver order) every update surface shares | +| `duration.ts` | `parseDurationMs`, for the CLI and HTTP | +| `relay.ts` | Bounded relay ids, channel ids, headers | +| `console-url.ts` | The console address `status.get` reports | ## Operations @@ -55,14 +56,14 @@ Three identities: ## Protocol version -- `PROTOCOL_VERSION_RANGE` in `protocol.ts` holds it, now `{ min: 54, max: 54 }`. `DAEMON_PROTOCOL_VERSION` (`src/daemon-protocol/`) is its `max`. +- `PROTOCOL_VERSION_RANGE` in `protocol.ts` holds it, now `{ min: 55, max: 55 }`. `DAEMON_PROTOCOL_VERSION` (`src/daemon-protocol/`) is its `max`. - Every change to a contract shape bumps it by one in the same PR. Move `min` too, unless a compatibility path stays side by side. `.agents/scripts/protocol-bump-check.mjs` checks it on push and in CI; it counts every file here as a shape but tests, `index.ts` and the helpers its `NOT_SHAPES` lists. A new helper that carries no wire shape goes in that list. - `hello` picks the highest overlap. None → `PROTOCOL_VERSION_UNSUPPORTED` with both ranges. A protocol-2 daemon is mapped by `mapLegacyProtocolMismatch`. -- `daemon.stop` is accepted at any version, so the upgrade path exists. +- `daemon.stop` and `daemon.update` are accepted at any version (`DaemonServer` skips the version gate for them; the role check still runs, and readiness too except for `daemon.update {query}`), so the upgrade path exists. Their input and answer never change shape; `operations.test.ts` pins `daemon.update`'s JSON schemas. ## Errors diff --git a/src/contract/errors.test.ts b/src/contract/errors.test.ts index 3cd18b509..ab4aeb882 100644 --- a/src/contract/errors.test.ts +++ b/src/contract/errors.test.ts @@ -169,4 +169,20 @@ describe("RELAY_TARGET_MISSING", () => { kind: "domain", }); }); + + it("gives the update codes their exit code and HTTP status: DAEMON_UPDATING 1 and 503, UPDATE_RESOLVE_FAILED 1 and 502, UPDATE_UNSUPPORTED_INSTALL and UPDATE_SHARED_INSTALL 2 and 409", () => { + const expected = { + DAEMON_UPDATING: { cliExitCode: 1, httpStatus: 503 }, + UPDATE_RESOLVE_FAILED: { cliExitCode: 1, httpStatus: 502 }, + UPDATE_UNSUPPORTED_INSTALL: { cliExitCode: 2, httpStatus: 409 }, + UPDATE_SHARED_INSTALL: { cliExitCode: 2, httpStatus: 409 }, + } as const; + for (const [code, entry] of Object.entries(expected)) { + expect(ERROR_TABLE[code as keyof typeof expected]).toEqual({ + code, + kind: "domain", + ...entry, + }); + } + }); }); diff --git a/src/contract/errors.ts b/src/contract/errors.ts index 9b7e2258e..d54e4bbf4 100644 --- a/src/contract/errors.ts +++ b/src/contract/errors.ts @@ -104,6 +104,10 @@ export interface ErrorDetailsMap { HISTORY_NOT_KEPT: { readonly oldestTs: number }; HISTORY_UNAVAILABLE: Record; NUKE_UNAVAILABLE: Record; + DAEMON_UPDATING: Record; + UPDATE_UNSUPPORTED_INSTALL: Record; + UPDATE_RESOLVE_FAILED: Record; + UPDATE_SHARED_INSTALL: Record; INTERNAL: Record; GATEWAY_NOT_PRESENT: Record; SERVICE_FAILED: Record; @@ -371,6 +375,25 @@ export const ERROR_TABLE: { readonly [Code in SimlockErrorCode]: ErrorTableEntry httpStatus: 503, }, NUKE_UNAVAILABLE: { code: "NUKE_UNAVAILABLE", kind: "domain", cliExitCode: 1, httpStatus: 503 }, + DAEMON_UPDATING: { code: "DAEMON_UPDATING", kind: "domain", cliExitCode: 1, httpStatus: 503 }, + UPDATE_UNSUPPORTED_INSTALL: { + code: "UPDATE_UNSUPPORTED_INSTALL", + kind: "domain", + cliExitCode: 2, + httpStatus: 409, + }, + UPDATE_RESOLVE_FAILED: { + code: "UPDATE_RESOLVE_FAILED", + kind: "domain", + cliExitCode: 1, + httpStatus: 502, + }, + UPDATE_SHARED_INSTALL: { + code: "UPDATE_SHARED_INSTALL", + kind: "domain", + cliExitCode: 2, + httpStatus: 409, + }, HISTORY_NOT_KEPT: { code: "HISTORY_NOT_KEPT", kind: "domain", cliExitCode: 12, httpStatus: 422 }, HISTORY_UNAVAILABLE: { code: "HISTORY_UNAVAILABLE", diff --git a/src/contract/index.ts b/src/contract/index.ts index 2805ce050..bf419020f 100644 --- a/src/contract/index.ts +++ b/src/contract/index.ts @@ -10,4 +10,5 @@ export * from "./roles.js"; export * from "./schemas.js"; export * from "./events-page.js"; export * from "./usage-window.js"; +export * from "./version-order.js"; export * from "./worker-view.js"; diff --git a/src/contract/operations.test.ts b/src/contract/operations.test.ts index d34d47fa3..2537e7528 100644 --- a/src/contract/operations.test.ts +++ b/src/contract/operations.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { z } from "zod"; import { OPERATIONS, @@ -38,6 +39,7 @@ const ROLE_MATRIX: ReadonlyArray<{ { name: "nuke.run", input: {}, role: "admin" }, { name: "config.get", input: {}, role: "admin" }, { name: "daemon.stop", input: {}, role: "admin" }, + { name: "daemon.update", input: {}, role: "admin" }, { name: "events.replay", input: {}, role: "admin" }, { name: "events.subscribe", input: {}, role: "admin" }, { name: "events.unsubscribe", input: {}, role: "admin" }, @@ -125,6 +127,7 @@ const EFFECT_MATRIX: ReadonlyArray<{ { name: "nuke.run", input: {}, effect: "write" }, { name: "config.get", input: {}, effect: "read" }, { name: "daemon.stop", input: {}, effect: "write" }, + { name: "daemon.update", input: {}, effect: "write" }, { name: "events.replay", input: {}, effect: "read" }, { name: "events.subscribe", input: {}, effect: "read" }, { name: "events.unsubscribe", input: {}, effect: "read" }, @@ -534,7 +537,14 @@ describe("operation input/output round trips", () => { }, global: { running: 0, maxRunning: 2, reserved: 0, overLimit: false, warm: 0 }, }, - daemon: { health: "running", mode: "worker" }, + daemon: { + health: "running", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, host: { os: "macOS", osVersion: "15.5", @@ -592,7 +602,14 @@ describe("operation input/output round trips", () => { global: { maxRunning: 2, overLimit: false, reserved: 0, running: 0, warm: 0 }, ios: capacityEntry, }, - daemon: { health: "running", mode: "worker" }, + daemon: { + health: "running", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, devices: [], host, leases: [], @@ -611,10 +628,27 @@ describe("operation input/output round trips", () => { ).toBeDefined(); expect( OPERATIONS["status.get"].output.parse({ - daemon: { health: "starting", mode: "worker" }, + daemon: { + health: "starting", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, host, }), - ).toEqual({ daemon: { health: "starting", mode: "worker" }, host }); + ).toEqual({ + daemon: { + health: "starting", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, + host, + }); }); it("rejects a short outside the ten reasons, and a target list longer than 320", () => { @@ -657,7 +691,14 @@ describe("operation input/output round trips", () => { short, }; const status = { - daemon: { health: "running", mode: "worker" }, + daemon: { + health: "running", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, host: { arch: "arm64", os: "macOS", osVersion: "15.5", tools: [] }, warmPool: { enabled: true, reserveRunning: { android: 0, ios: 0 }, targets: [target] }, }; @@ -724,7 +765,14 @@ describe("operation input/output round trips", () => { global: { running: 0, maxRunning: 1, reserved: 0, overLimit: false, warm: 0 }, ios: capacityEntry, }, - daemon: { health: "running", mode: "gateway" }, + daemon: { + health: "running", + mode: "gateway", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, devices: [], host: { os: "Linux", osVersion: "6.8.0", arch: "x64", tools: [] }, installs, @@ -790,7 +838,14 @@ describe("operation input/output round trips", () => { ], leases: [lease], capacity, - daemon: { health: "running", mode: "gateway" }, + daemon: { + health: "running", + mode: "gateway", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, host: { os: "Linux", osVersion: "6.8.0", arch: "x64", tools: [] }, queueDepth: 0, workers: [ @@ -1404,7 +1459,14 @@ describe("status.get physicalQueueDepth (ADR 0022)", () => { it("a status carries the physical line's depth beside the virtual one", () => { const parsed = output.parse({ capacity, - daemon: { health: "running", mode: "worker" }, + daemon: { + health: "running", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, devices: [], host, leases: [], @@ -1419,7 +1481,14 @@ describe("status.get physicalQueueDepth (ADR 0022)", () => { it("a worker's entry in a gateway's status carries it too", () => { const parsed = output.parse({ capacity, - daemon: { health: "running", mode: "gateway" }, + daemon: { + health: "running", + mode: "gateway", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, devices: [], host, leases: [], @@ -1662,3 +1731,91 @@ describe("events.replay input", () => { expect(parse({ limit: 5, sinceTs: 5 }).success).toBe(false); }); }); + +describe("daemon.update", () => { + const SCHEMA = "https://json-schema.org/draft/2020-12/schema"; + const exactVersion = { + type: "string", + maxLength: 64, + pattern: "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z.-]+)?$", + }; + const version = { type: "string", maxLength: 64 }; + const stateBase = { + from: version, + to: version, + forced: { type: "boolean" }, + startedAt: { type: "integer", minimum: 0, maximum: 9007199254740991 }, + }; + const state = (stage: string, extra: Record = {}) => ({ + type: "object", + properties: { stage: { type: "string", const: stage }, ...extra, ...stateBase }, + required: ["stage", ...Object.keys(extra), "from", "to", "forced", "startedAt"], + additionalProperties: false, + }); + + it("keeps the input and the answer the shapes every daemon version accepts, because it is a frozen call", () => { + expect(z.toJSONSchema(OPERATIONS["daemon.update"].input, { io: "input" })).toEqual({ + $schema: SCHEMA, + type: "object", + properties: { + version: exactVersion, + force: { type: "boolean" }, + query: { type: "boolean" }, + }, + }); + expect(z.toJSONSchema(OPERATIONS["daemon.update"].output)).toEqual({ + $schema: SCHEMA, + type: "object", + properties: { + started: { type: "boolean" }, + upToDate: { type: "boolean" }, + resolved: { anyOf: [exactVersion, { type: "null" }] }, + version, + health: { type: "string", enum: ["starting", "running", "failed"] }, + update: { + anyOf: [ + { + oneOf: [ + state("waiting", { + leases: { type: "integer", minimum: 0, maximum: 9007199254740991 }, + }), + state("installing"), + state("restarting"), + ], + }, + { type: "null" }, + ], + }, + lastUpdateFailure: { + anyOf: [ + { + type: "object", + properties: { + stage: { type: "string", enum: ["installing", "restarting"] }, + from: version, + to: version, + message: { type: "string", maxLength: 2000 }, + at: { type: "integer", minimum: 0, maximum: 9007199254740991 }, + }, + required: ["stage", "from", "to", "message", "at"], + additionalProperties: false, + }, + { type: "null" }, + ], + }, + latestVersion: { anyOf: [version, { type: "null" }] }, + }, + required: [ + "started", + "upToDate", + "resolved", + "version", + "health", + "update", + "lastUpdateFailure", + "latestVersion", + ], + additionalProperties: false, + }); + }); +}); diff --git a/src/contract/operations.ts b/src/contract/operations.ts index 07514650b..b1e737b69 100644 --- a/src/contract/operations.ts +++ b/src/contract/operations.ts @@ -24,8 +24,10 @@ import { configSchema, daemonHealthSchema, daemonModeSchema, + daemonUpdateFields, type DeviceClass, deviceClassSchema, + updateRequestedVersionSchema, deviceRecordSchema, doctorReportSchema, eventEnvelopeSchema, @@ -123,6 +125,7 @@ export const statusGet = defineOperation({ mode: daemonModeSchema, // Bounded: a gateway parses every worker's answer (safety rule 10). consoleUrl: z.string().max(CONSOLE_URL_MAX_LENGTH).optional(), + ...daemonUpdateFields, }), host: hostFactsSchema, load: hostLoadSchema.optional(), @@ -644,6 +647,36 @@ export const daemonStop = defineOperation({ output: z.object({ stopping: z.literal(true) }), }); +// why: `DaemonServer` dispatches it on a connection whose protocol range does not overlap (like `daemon.stop`), so its input and answer never change shape. +// fallow-ignore-next-line unused-export -- consumed only through the OPERATIONS registry, not by name; still public contract surface. +export const daemonUpdate = defineOperation({ + name: "daemon.update", + role: "admin", + effect: "write", + input: z + .object({ + version: updateRequestedVersionSchema.optional(), + force: z.boolean().optional(), + query: z.boolean().optional(), + }) + .refine( + (input) => input.query !== true || (input.version === undefined && input.force === undefined), + { + message: "query takes no other field", + }, + ), + output: z.object({ + started: z.boolean(), + upToDate: z.boolean(), + resolved: updateRequestedVersionSchema.nullable(), + version: daemonUpdateFields.version, + health: daemonHealthSchema, + update: daemonUpdateFields.update, + lastUpdateFailure: daemonUpdateFields.lastUpdateFailure, + latestVersion: daemonUpdateFields.latestVersion, + }), +}); + // ---- events.replay ------------------------------------------------------------------------ // fallow-ignore-next-line unused-export -- consumed only through the OPERATIONS registry, not by name; still public contract surface. @@ -1086,6 +1119,7 @@ export const OPERATIONS = { "nuke.run": nukeRun, "config.get": configGet, "daemon.stop": daemonStop, + "daemon.update": daemonUpdate, "events.replay": eventsReplay, "events.subscribe": eventsSubscribe, "events.unsubscribe": eventsUnsubscribe, diff --git a/src/contract/protocol.ts b/src/contract/protocol.ts index b86467f78..b016923c8 100644 --- a/src/contract/protocol.ts +++ b/src/contract/protocol.ts @@ -10,7 +10,7 @@ export interface ProtocolRange { const protocolRangeSchema = z.object({ min: z.number().int(), max: z.number().int() }); /** Widen only once a second version is kept alive side by side with the first; a bump that leaves no shim behind moves both ends. */ -export const PROTOCOL_VERSION_RANGE: ProtocolRange = { min: 54, max: 54 }; +export const PROTOCOL_VERSION_RANGE: ProtocolRange = { min: 55, max: 55 }; const LEGACY_DAEMON_PROTOCOL_VERSION = 2; @@ -91,6 +91,7 @@ export const CORE_ROUTE_SEGMENTS: readonly string[] = [ "stats", "status", "tokens", + "update", "uplink", "workers", ]; diff --git a/src/contract/schemas.test.ts b/src/contract/schemas.test.ts index f97fc3343..b66976ef9 100644 --- a/src/contract/schemas.test.ts +++ b/src/contract/schemas.test.ts @@ -713,7 +713,14 @@ describe("a worker status's capabilities list", () => { const status = (capabilities: unknown) => statusGet.output.safeParse({ capabilities, - daemon: { health: "running", mode: "worker" }, + daemon: { + health: "running", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, host: { arch: "arm64", os: "macOS", osVersion: "26.0", tools: [] }, }); const entry = (overrides: Record = {}) => ({ @@ -799,7 +806,14 @@ describe("a status answer's load block", () => { }; const answer = (block: unknown) => statusGet.output.safeParse({ - daemon: { health: "running", mode: "worker" }, + daemon: { + health: "running", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, host: { arch: "arm64", os: "macOS", osVersion: "26.0", tools: [] }, load: block, }); diff --git a/src/contract/schemas.ts b/src/contract/schemas.ts index d57e8d47c..ed904ce8a 100644 --- a/src/contract/schemas.ts +++ b/src/contract/schemas.ts @@ -562,6 +562,44 @@ export const statusWarmPoolSchema = z.object({ export const daemonHealthSchema = z.enum(["starting", "running", "failed"]); +const UPDATE_VERSION_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/; +const UPDATE_VERSION_MAX = 64; +const UPDATE_MESSAGE_MAX = 2000; +const updateVersionSchema = z.string().max(UPDATE_VERSION_MAX); + +export const updateRequestedVersionSchema = updateVersionSchema.regex(UPDATE_VERSION_PATTERN); + +const updateStateBase = { + from: updateVersionSchema, + to: updateVersionSchema, + forced: z.boolean(), + startedAt: z.number().int().min(0), +}; + +const updateStateSchema = z.discriminatedUnion("stage", [ + z.object({ stage: z.literal("waiting"), leases: z.number().int().min(0), ...updateStateBase }), + z.object({ stage: z.literal("installing"), ...updateStateBase }), + z.object({ stage: z.literal("restarting"), ...updateStateBase }), +]); + +export const updateFailureSchema = z.object({ + stage: z.enum(["installing", "restarting"]), + from: updateVersionSchema, + to: updateVersionSchema, + message: z.string().max(UPDATE_MESSAGE_MAX), + at: z.number().int().min(0), +}); + +export type UpdateState = z.infer; +export type UpdateFailure = z.infer; + +export const daemonUpdateFields = { + version: updateVersionSchema, + update: updateStateSchema.nullable(), + lastUpdateFailure: updateFailureSchema.nullable(), + latestVersion: updateVersionSchema.nullable(), +}; + /** * Limits on the catalog's other names and images. A worker's catalog is a claim the gateway * parses off the wire, so every string and list in these fields has a bound well above any diff --git a/src/contract/version-order.test.ts b/src/contract/version-order.test.ts new file mode 100644 index 000000000..7a2ae7acd --- /dev/null +++ b/src/contract/version-order.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { isNewerVersion } from "./version-order.js"; + +// why: pure logic with many cases, and every update surface shares this one rule. +describe("isNewerVersion", () => { + it("orders versions by semver: numbers first, a release beats its prereleases, prerelease parts compare numbers as numbers and a number sorts below a word, equal is not newer", () => { + const cases: readonly [candidate: string, running: string, newer: boolean][] = [ + ["0.10.0", "0.9.0", true], + ["0.9.0", "0.10.0", false], + ["1.0.0", "0.99.99", true], + ["0.4.0", "0.4.0-rc.2", true], + ["0.4.0-rc.2", "0.4.0", false], + ["0.4.0-rc.10", "0.4.0-rc.2", true], + ["0.4.0-rc.2", "0.4.0-rc.10", false], + ["0.4.0-rc.2.1", "0.4.0-rc.2", true], + ["0.4.0-rc.2", "0.4.0-beta.9", true], + ["0.4.0-rc.1", "0.4.0-1", true], + ["0.4.0-1", "0.4.0-rc.1", false], + ["0.4.0-rc.9", "0.4.0-rc.10", false], + ["0.4.0-rc.1", "0.4.0-rc.1", false], + ["0.4.0", "0.4.0", false], + ]; + for (const [candidate, running, newer] of cases) { + expect({ candidate, running, newer: isNewerVersion(candidate, running) }).toEqual({ + candidate, + running, + newer, + }); + } + }); +}); diff --git a/src/contract/version-order.ts b/src/contract/version-order.ts new file mode 100644 index 000000000..f0f2f4a0d --- /dev/null +++ b/src/contract/version-order.ts @@ -0,0 +1,30 @@ +function split(version: string): { core: number[]; prerelease: string[] } { + const [core = "", prerelease] = version.split("-", 2) as [string, string | undefined]; + return { core: core.split(".").map(Number), prerelease: prerelease?.split(".") ?? [] }; +} + +function compareIdentifiers(left: string, right: string): number { + const leftNumeric = /^\d+$/.test(left); + const rightNumeric = /^\d+$/.test(right); + if (leftNumeric && rightNumeric) return Math.sign(Number(left) - Number(right)); + if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1; + return left < right ? -1 : left > right ? 1 : 0; +} + +export function isNewerVersion(candidate: string, running: string): boolean { + const left = split(candidate); + const right = split(running); + for (let index = 0; index < 3; index += 1) { + const difference = (left.core[index] ?? 0) - (right.core[index] ?? 0); + if (difference !== 0) return difference > 0; + } + if (left.prerelease.length === 0 || right.prerelease.length === 0) { + return left.prerelease.length === 0 && right.prerelease.length > 0; + } + const shared = Math.min(left.prerelease.length, right.prerelease.length); + for (let index = 0; index < shared; index += 1) { + const order = compareIdentifiers(left.prerelease[index] ?? "", right.prerelease[index] ?? ""); + if (order !== 0) return order > 0; + } + return left.prerelease.length > right.prerelease.length; +} diff --git a/src/contract/worker-view.test.ts b/src/contract/worker-view.test.ts index f719fd477..a8ccb07f6 100644 --- a/src/contract/worker-view.test.ts +++ b/src/contract/worker-view.test.ts @@ -49,7 +49,14 @@ describe("workerViewFields", () => { receivedAt: 0, status: { capacity: CAPACITY, - daemon: { health: "running", mode: "worker" }, + daemon: { + health: "running", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, host: HOST, leases: [], queueDepth: 0, @@ -78,7 +85,14 @@ describe("workerViewFields", () => { receivedAt: 0, status: { capacity: CAPACITY, - daemon: { health: "running", mode: "worker" }, + daemon: { + health: "running", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, host: HOST, leases: [], queueDepth: 0, @@ -103,7 +117,14 @@ describe("workerViewFields", () => { receivedAt: 0, status: { capacity: CAPACITY, - daemon: { health: "running", mode: "worker" }, + daemon: { + health: "running", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, host: HOST, leases: [], queueDepth: 0, @@ -137,7 +158,14 @@ describe("workerViewFields", () => { receivedAt: 0, status: { capacity: CAPACITY, - daemon: { health: "running", mode: "worker" }, + daemon: { + health: "running", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, host: HOST, leases: [], queueDepth: 0, @@ -163,7 +191,17 @@ describe("workerViewFields", () => { it("builds a starting worker's view from its health and host alone, with every other field absent", () => { const view = workerViewFields({ receivedAt: 0, - status: { daemon: { health: "starting", mode: "worker" }, host: HOST }, + status: { + daemon: { + health: "starting", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, + host: HOST, + }, }); expect(view).toEqual({ health: "starting", host: HOST }); @@ -175,7 +213,17 @@ describe("workerViewFields", () => { catalog: { platforms: [] }, config: CONFIG, devices: [], - status: { daemon: { health: "starting", mode: "worker" }, host: HOST }, + status: { + daemon: { + health: "starting", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, + host: HOST, + }, }); expect(view).toEqual({ health: "starting", host: HOST }); @@ -193,7 +241,14 @@ describe("workerViewFields", () => { devices: [], status: { capacity: CAPACITY, - daemon: { health: "running", mode: "worker" }, + daemon: { + health: "running", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, devices: [], host: HOST, leases: [], @@ -203,7 +258,18 @@ describe("workerViewFields", () => { }); const starting = workerViewFields({ receivedAt: 0, - status: { daemon: { health: "starting", mode: "worker" }, host: HOST, warmPool }, + status: { + daemon: { + health: "starting", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, + host: HOST, + warmPool, + }, }); expect(running.warmPool).toEqual(warmPool); @@ -216,7 +282,14 @@ describe("workerViewFields", () => { devices: [], status: { capacity: CAPACITY, - daemon: { health: "running", mode: "worker" }, + daemon: { + health: "running", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, devices: [], host: HOST, leases: [], @@ -239,7 +312,14 @@ describe("workerViewFields", () => { it("copies the physical line's depth beside the virtual one", () => { const status = { capacity: CAPACITY, - daemon: { health: "running", mode: "worker" }, + daemon: { + health: "running", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, devices: [], host: HOST, leases: [], @@ -256,7 +336,17 @@ describe("workerViewFields", () => { it("leaves a field out of a running worker's view when the worker's answer has none, rather than making it empty", () => { const view = workerViewFields({ receivedAt: 0, - status: { daemon: { health: "running", mode: "worker" }, host: HOST }, + status: { + daemon: { + health: "running", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, + host: HOST, + }, }); expect(Object.keys(view).sort()).toEqual(["health", "host", "installs", "waiting"]); @@ -278,7 +368,14 @@ describe("workerViewFields", () => { const running = (load?: typeof LOAD) => ({ capacity: CAPACITY, - daemon: { health: "running", mode: "worker" }, + daemon: { + health: "running", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, devices: [], host: HOST, leases: [], @@ -300,7 +397,14 @@ describe("workerViewFields", () => { it("workerViewFields copies load from a starting worker's status", () => { const status = { - daemon: { health: "starting", mode: "worker" }, + daemon: { + health: "starting", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, host: HOST, load: LOAD, } as unknown as Parameters[0]["status"]; @@ -316,7 +420,14 @@ describe("workerViewFields", () => { it("workerViewFields leaves load out when the status has none", () => { const starting = { - daemon: { health: "starting", mode: "worker" }, + daemon: { + health: "starting", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, host: HOST, } as unknown as Parameters[0]["status"]; diff --git a/src/daemon-protocol/index.test.ts b/src/daemon-protocol/index.test.ts index 7f9930b59..e16a8d86c 100644 --- a/src/daemon-protocol/index.test.ts +++ b/src/daemon-protocol/index.test.ts @@ -9,7 +9,7 @@ import { describe("daemon protocol", () => { it("keeps the current protocol version and newline framing", () => { - expect(DAEMON_PROTOCOL_VERSION).toBe(54); + expect(DAEMON_PROTOCOL_VERSION).toBe(55); expect(serializeFrame({ id: 1, type: "hello" })).toBe('{"id":1,"type":"hello"}\n'); }); diff --git a/src/daemon/AGENTS.md b/src/daemon/AGENTS.md index f5c2113c2..60ac9e769 100644 --- a/src/daemon/AGENTS.md +++ b/src/daemon/AGENTS.md @@ -2,7 +2,7 @@ Does: one process per data directory — composes a worker or a gateway and serves the contract on a unix socket. Entry: `main.ts` → `startDaemon(options)` → a started `DaemonServer`. -Uses: contract, daemon-protocol, ports, bus, history, link, core, leasing, drivers, http, host-load, modules, module-registry; gateway and alerts from `main.ts` only. +Uses: contract, daemon-protocol, ports, bus, history, link, core, leasing, drivers, http, host-load, modules, module-registry, updater (`checkInstall`, `socketHeld`); gateway and alerts from `main.ts` only. Never imports: gateway or alerts outside `main.ts`. `dispatch.ts` imports no core, drivers or http. Imported by: http (`dispatcher.ts`, `error-code.ts`, `owner-routed-facts.ts`); gateway (`dispatch.ts` only). Started by: cli and mcp spawn `main.ts` as a detached process. @@ -10,20 +10,21 @@ Flows: [startup](../../docs/internal/flows/startup.md), [device.exec](../../docs ## Files -| File | Role | -| ----------------------- | ---------------------------------------------------------------------------------------------------------------- | -| `main.ts` | Composition root; worker or gateway; driver discovery; log sinks | -| `server.ts` | `DaemonServer`: framing, `hello`, sessions, pushes, readiness, stop | -| `dispatch.ts` | `runDispatch` pipeline, `DispatchSession`, `DispatchError` | -| `dispatcher.ts` | `Dispatcher`: the worker's handlers | -| `connection-host.ts` | `DaemonEndpointHost`: socket claim, stale-socket recovery | -| `owner-routed-facts.ts` | `OwnerRoutedFactBus`: lease facts with their owner | -| `gateway-uplink.ts` | `GatewayUplink`: a worker's dial to its gateway | -| `openapi.ts` | `openApiDocument`, `writeOpenApiDocument`: the HTTP API document from the module registry; run by `pnpm openapi` | -| `relay.ts` | `RelayTargets`, `RelayConnection`: relays over the uplink | -| `error-code.ts` | `classifyError`: thrown error → contract code | -| `enrollment-wake.ts` | `wakingQueueAfter`: wakes the queue after `device.add`/`device.remove` | -| `test-relay-support.ts` | Test helpers for relay e2e tests | +| File | Role | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `main.ts` | Composition root; worker or gateway; driver discovery; log sinks | +| `server.ts` | `DaemonServer`: framing, `hello`, sessions, pushes, readiness, stop | +| `dispatch.ts` | `runDispatch` pipeline, `DispatchSession`, `DispatchError` | +| `dispatcher.ts` | `Dispatcher`: the worker's handlers | +| `connection-host.ts` | `DaemonEndpointHost`: socket claim, stale-socket recovery | +| `owner-routed-facts.ts` | `OwnerRoutedFactBus`: lease facts with their owner | +| `gateway-uplink.ts` | `GatewayUplink`: a worker's dial to its gateway | +| `openapi.ts` | `openApiDocument`, `writeOpenApiDocument`: the HTTP API document from the module registry; run by `pnpm openapi` | +| `update-coordinator.ts` | `createUpdateCoordinator`: `daemon.update`'s state (`waiting`, `installing`), the updater launch, `status.get`'s update fields | +| `relay.ts` | `RelayTargets`, `RelayConnection`: relays over the uplink | +| `error-code.ts` | `classifyError`: thrown error → contract code | +| `enrollment-wake.ts` | `wakingQueueAfter`: wakes the queue after `device.add`/`device.remove` | +| `test-relay-support.ts` | Test helpers for relay e2e tests | ## Composition (`main.ts`) @@ -44,28 +45,26 @@ The role (`StartDaemonOptions.role`; the entry reads it from `--gateway` through An HTTP bind failure after a good start stops the daemon (`http-start-failed`). +## Update (`update-coordinator.ts`) + +`daemon.update` (worker `Dispatcher`; a gateway answers `UNSUPPORTED_IN_GATEWAY_MODE`) is `createUpdateCoordinator`'s, in memory only. A call during an update follows it (`force` on `waiting` installs now); `query` starts nothing. +A new update: `NpmCli.view` resolves the version (`UPDATE_RESOLVE_FAILED`); the running version, or one newer than `latest` with no `version` named (`isNewerVersion`), is `upToDate`; then `checkInstall` (`UPDATE_UNSUPPORTED_INSTALL`) and `socketHeld` on the other role's socket (`UPDATE_SHARED_INSTALL`). Then `waiting`, leasing's update hold and `daemon.update-started`; the updater launches with no leases, with `force`, or when the last `lease.released`/`lease.expired` leaves none. +Its stdout lines move the state; a failed line, a non-zero exit or a failed launch sets `lastUpdateFailure`, releases the hold and emits `daemon.update-failed`. `DaemonServer.start` calls `readResultFile()` after convergence and before health is `running`, so a `running` daemon already shows the failure in `lastUpdateFailure`; a rolled back update's `update-result.json` is deleted there, and `announceResultFile()` emits its `daemon.update-failed` once the daemon serves, so a parked `events.subscribe` receives it. + ## Connection (`server.ts`) -1. **Claim** — `DaemonEndpointHost.start`: a live peer → `DaemonAlreadyRunningError`; - a dead socket file is removed and claimed. Default `/daemon.sock`. +1. **Claim** — `DaemonEndpointHost.start`: a live peer → `DaemonAlreadyRunningError`; a dead socket file is removed and claimed. Default `/daemon.sock`. 2. **Hello** — first frame must be `hello`, else `HANDSHAKE_REQUIRED` and close. - Role: the uplink connection is `admin` by acceptance; otherwise the resolver. - Principal: `hello.principal`, else `SIMLOCK_AGENT_ID`, else the daemon's pid. - Both are fixed for the connection's life. + Role: the uplink connection is `admin` by acceptance; otherwise the resolver. Principal: `hello.principal`, else `SIMLOCK_AGENT_ID`, else the daemon's pid. Both are fixed for the connection's life. 3. **Version** — ranges overlap → highest common version. `PROTOCOL_VERSION_RANGE` - is `{ min: 54, max: 54 }` (`src/contract/protocol.ts`). No overlap → the + is `{ min: 55, max: 55 }` (`src/contract/protocol.ts`). No overlap → the connection stays open, and every request but `daemon.stop` gets `PROTOCOL_VERSION_UNSUPPORTED`. 4. **Request** — one frame → one `dispatch()` call. While stopping: `DAEMON_STOPPING`. 5. **`daemon.stop`** — handled by the server, before the version and stopping gates. - Needs `admin`. Answers `{ stopping: true }`, then stops. + Needs `admin`. Answers `{ stopping: true }`, then stops. `daemon.update` skips only the version gate; the dispatcher still checks `admin` and parks on readiness, except for `query`, which answers while `starting`. -Pushes, keyed by the request's frame id: `progress` (`lease.request`), `started` and -`output` (`device.exec`), `component-progress`, `relay-head`. Per connection: `event` -(`events.subscribe`). Owner-routed: `lease-lost`, `device-unhealthy`, -`device-recovered` go to every connection whose principal owns the lease; `lease-lost` -skips the connection that asked for that release. A closed connection only silences -its pushes; the request, the lease and a running command go on. +Pushes, keyed by the request's frame id: `progress` (`lease.request`), `started` and `output` (`device.exec`), `component-progress`, `relay-head`. Per connection: `event` (`events.subscribe`). Owner-routed: `lease-lost`, `device-unhealthy`, `device-recovered` go to every connection whose principal owns the lease; `lease-lost` skips the connection that asked for that release. A closed connection only silences its pushes; the request, the lease and a running command go on. ## Dispatch (`dispatch.ts` `runDispatch`) @@ -74,9 +73,8 @@ One pipeline for `Dispatcher`, `GatewayDispatcher` and HTTP: 1. No handler → `UNKNOWN_REQUEST`. 2. Parse input → `BAD_REQUEST`. 3. Role: `admin` passes all; `agent` only `agent` operations → `FORBIDDEN`. -4. `authorize` hook (lease owner lookups) → `FORBIDDEN`. An unknown id passes, - so the handler answers `UNKNOWN_LEASE`. -5. Park on readiness — every operation but `status.get`. +4. `authorize` hook (lease owner lookups) → `FORBIDDEN`. An unknown id passes, so the handler answers `UNKNOWN_LEASE`. +5. Park on readiness — every operation but `status.get` and `daemon.update {query}`. 6. Handler → parse output; a mismatch is logged and answers `INTERNAL`. ## Readiness @@ -85,7 +83,7 @@ One pipeline for `Dispatcher`, `GatewayDispatcher` and HTTP: starts → `onSocketClaimed` (module `afterSocketClaimed`, agent-device, uplink, HTTP) → await converge → arm. Health: `starting` → `running`, or `failed`. -- `hello` and `status.get` answer at once; `status.get` while `starting` carries +- `hello`, `status.get` and `daemon.update` with `query` answer at once; `status.get` while `starting` carries `daemon`, `host` and `load` only. - Converge fails → parked requests get `DAEMON_STARTUP_FAILED`; daemon stops (`convergence-failed`). @@ -103,10 +101,7 @@ Credential resolution (`resolveHelloRole` in `src/auth`), at `hello`: 3. The per-start admin secret → `admin`. 4. Anything else → `ADMIN_AUTHENTICATION_FAILED`, connection closed. -Admin secret: fresh each start, hash kept in memory. Written to `admin.token` -(0600, atomic) only after the claim; removed on stop before releasing the -endpoint, so old shutdown cannot remove a replacement's credential. A daemon -that lost the claim never touches it. +Admin secret: fresh each start, hash kept in memory. Written to `admin.token` (0600, atomic) only after the claim; removed on stop before releasing the endpoint, so old shutdown cannot remove a replacement's credential. A daemon that lost the claim never touches it. Security model: cooperative identity, not a hostile-process boundary. @@ -117,8 +112,7 @@ Security model: cooperative identity, not a hostile-process boundary. ## Uplink and relays (worker side) -- `GatewayUplink` dials `gateway.url` with `gateway.token`, after the first host - load reading. Backoff from 1 s, ×2, cap 30 s, jittered; retries forever. +- `GatewayUplink` dials `gateway.url` with `gateway.token`, after the first host load reading. Backoff from 1 s, ×2, cap 30 s, jittered; retries forever. - `DaemonServer.acceptUplink` serves it as one more connection, role `admin`. - `relay.request` runs only on that connection; targets come from `relayTargets` (today `agent-device` and `stream`). See [gateway](../gateway/AGENTS.md). diff --git a/src/daemon/dispatch.ts b/src/daemon/dispatch.ts index b452733e7..8662e4baa 100644 --- a/src/daemon/dispatch.ts +++ b/src/daemon/dispatch.ts @@ -321,7 +321,9 @@ async function dispatchSteps( typeof definition.effect === "function" ? definition.effect(input) : definition.effect; checkAccess(operation, definition, input, session, pipeline); - if (operation !== "status.get") { + // why: `daemon.update {query}` answers at once while the daemon is starting, because the updater and `simlock update` read a daemon that has not converged yet; it starts nothing, so it needs no converged daemon. + const isQuery = operation === "daemon.update" && (input as { query?: boolean }).query === true; + if (operation !== "status.get" && !isQuery) { await pipeline.awaitReady?.(); } diff --git a/src/daemon/dispatcher.test.ts b/src/daemon/dispatcher.test.ts index 966827e26..462b2f605 100644 --- a/src/daemon/dispatcher.test.ts +++ b/src/daemon/dispatcher.test.ts @@ -2811,8 +2811,19 @@ describe("Dispatcher: status.get console address", () => { consoleUrl: "http://127.0.0.1:4711/", health: "running", mode: "worker", + version: "1.2.3", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }); + expect(off.daemon).toEqual({ + health: "running", + mode: "worker", + version: "1.2.3", + update: null, + lastUpdateFailure: null, + latestVersion: null, }); - expect(off.daemon).toEqual({ health: "running", mode: "worker" }); expect("consoleUrl" in off.daemon).toBe(false); }); }); @@ -4181,7 +4192,14 @@ describe("Dispatcher: status.get while the daemon is starting", () => { const status = await dispatcher.dispatch("status.get", {}, session()); expect(Object.keys(status).sort()).toEqual(["daemon", "history", "host"]); - expect(status.daemon).toEqual({ health: "starting", mode: "worker" }); + expect(status.daemon).toEqual({ + health: "starting", + mode: "worker", + version: "1.2.3", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }); expect(status.host).toEqual({ ...HOST_SYSTEM, tools: [] }); }); diff --git a/src/daemon/dispatcher.ts b/src/daemon/dispatcher.ts index c35b20bc8..291e4104c 100644 --- a/src/daemon/dispatcher.ts +++ b/src/daemon/dispatcher.ts @@ -79,6 +79,7 @@ import { type ErasedHandler, } from "./dispatch.js"; import type { RelayConnection } from "./relay.js"; +import type { UpdateCoordinator, UpdateProgress } from "./update-coordinator.js"; export { DispatchError, type ContractDispatcher, type DispatchSession } from "./dispatch.js"; @@ -203,6 +204,7 @@ export interface DispatcherOptions { * without it the kept catalog is only re-read on the interval. */ readonly eventBus?: Pick; + readonly update?: UpdateCoordinator; } /** @@ -325,6 +327,7 @@ export class Dispatcher { "component.remove": this.#componentRemove, "device.add": this.#deviceAdd, "device.remove": this.#deviceRemove, + "daemon.update": this.#daemonUpdate, "worker.list": this.#workerList, "worker.drain": unsupportedOnWorker("worker.drain"), "worker.undrain": unsupportedOnWorker("worker.undrain"), @@ -390,6 +393,8 @@ export class Dispatcher { health: this.options.health(), mode: this.options.config.mode, ...consoleUrlField(this.options.config.http), + version: this.options.version, + ...this.#updateFields(), }; const host = this.options.hostFacts(); const load = this.options.load?.(); @@ -418,6 +423,24 @@ export class Dispatcher { return session.role === "agent" ? withoutQuarantineMessages(answer) : answer; }; + #updateFields(): UpdateProgress { + return ( + this.options.update?.state() ?? { update: null, lastUpdateFailure: null, latestVersion: null } + ); + } + + #daemonUpdate: Handler<"daemon.update"> = async (input) => { + const update = this.options.update; + if (update === undefined) { + throw new DispatchError("UNKNOWN_REQUEST", "This daemon does not update itself"); + } + return { + ...(await update.request(input)), + version: this.options.version, + health: this.options.health(), + }; + }; + // why: a module can never make status.get fail or hang, which would cut the worker off from its gateway. async #readCapabilities(): Promise { const sources = this.options.capabilitySources; diff --git a/src/daemon/error-code.ts b/src/daemon/error-code.ts index 281c7b1ed..0f7877b07 100644 --- a/src/daemon/error-code.ts +++ b/src/daemon/error-code.ts @@ -36,6 +36,7 @@ import { type LeaseRequestFailure, } from "../core/index.js"; import { + DaemonUpdatingError, IdempotencyConflictError, LeaseIdTakenError, LeaseRequestForbiddenError, @@ -111,6 +112,9 @@ export function classifyError(error: unknown): SimlockErrorCode | undefined { if (error instanceof NoCapacityError) { return "NO_CAPACITY"; } + if (error instanceof DaemonUpdatingError) { + return "DAEMON_UPDATING"; + } if (error instanceof ProvisionFailedError) { return "PROVISION_FAILED"; } diff --git a/src/daemon/main.ts b/src/daemon/main.ts index a195e032c..6018cd98b 100644 --- a/src/daemon/main.ts +++ b/src/daemon/main.ts @@ -93,8 +93,10 @@ import { serviceDefinition, type ServiceDefinition, type ServiceManager, + NodeNpmCli, NodeProcessSupervisor, NodeSystemStats, + NodeUpdaterLauncher, NodeBytePipeLauncher, NodeTcpProbe, NodeTcpStreamConnector, @@ -131,6 +133,8 @@ import { classifyError } from "./error-code.js"; import { createModuleHost, type OptionalModuleDefinition } from "../modules/index.js"; import { optionalModules } from "../module-registry/index.js"; import { DaemonServer } from "./server.js"; +import { socketHeld } from "../updater/index.js"; +import { createUpdateCoordinator } from "./update-coordinator.js"; import { DaemonAlreadyRunningError, DaemonEndpointHost, @@ -594,6 +598,29 @@ async function composeDaemon( resolveRole: { resolve: (hello) => auth.resolveHelloRole(hello) }, adminSecret: auth.adminSecret, tokens: auth.tokens, + update: createUpdateCoordinator({ + npm: new NodeNpmCli({ runner: baseProcessRunner, filesystem, dataDirectory }), + launcher: new NodeUpdaterLauncher(), + leasing, + registry, + eventBus, + clock, + filesystem, + otherRoleRunning: () => + socketHeld( + ipc, + resolveDaemonSocketPath( + daemonDataDirectory(resolveSimlockHome(), role === "worker" ? "gateway" : "worker"), + ), + ), + version, + packageRoot: fileURLToPath(new URL("../../", import.meta.url)), + simlockHome: resolveSimlockHome(), + dataDirectory, + role, + logPath: join(dataDirectory, "daemon.log"), + pid: process.pid, + }), version: version, converge: convergeStartup, settle: async () => { @@ -906,6 +933,7 @@ async function startGatewayDaemon(options: GatewayDaemonOptions): Promise daemon.health, + version: options.version, host: fitHostFacts({ ...options.hostSystem, tools: [] }), ...(hostLoad === undefined ? {} : { load: () => fitHostLoad(hostLoad.current()) }), leaseIndex, diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index 0ab958d6f..1d7d8cb85 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -1,6 +1,6 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { Socket, connect } from "node:net"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -32,12 +32,15 @@ import { MemoryFilesystem, MemoryLogSink, FakeKeySetFetcher, + FakeNpmCli, + FakeUpdaterLauncher, NodeFilesystem, NodeIpcTransport, NoopLogger, ScriptedProcessRunner, type IpcConnection, type Logger, + type NpmOutcome, type ProcessRunner, type ProcessStreamOptions, type StreamingProcessHandle, @@ -56,6 +59,8 @@ import { } from "../auth/index.js"; import { openMemoryStateDatabase } from "../auth/testing.js"; import { DaemonServer } from "./server.js"; +import { createDaemonProbe } from "../updater/index.js"; +import { createUpdateCoordinator } from "./update-coordinator.js"; import { GatewayRelay, type GatewayWorkerClient, type WorkerDirectory } from "../gateway/index.js"; import { linkPair } from "../link/testing.js"; import { connectSimlockClient } from "../simlock-client/client.js"; @@ -1350,6 +1355,74 @@ describe("DaemonServer startup readiness", () => { await client.close(); }); + it("answers daemon.update with query while starting, and only reports running once a rolled back update's failure is readable", async () => { + const converge = deferred(); + const resultFileGate = deferred(); + const harness = await createHarness({ + converge: () => converge.promise, + start: false, + update: { + npm: new FakeNpmCli(), + launcher: new FakeUpdaterLauncher(), + version: "0.3.0", + resultFile: JSON.stringify({ + from: "0.3.0", + to: "0.4.0", + stage: "restarting", + message: "0.4.0 did not start: health failed", + at: 500, + rolledBack: true, + }), + resultFileGate: resultFileGate.promise, + }, + }); + const startPromise = harness.daemon.start(); + const client = await createClientRetrying(harness.socketPath); + await hello(client); + + // why: a query that parks on readiness must fail the assertion below, not time the test out. + const queried = async () => { + const parked = new Promise<"parked">((resolve) => setTimeout(() => resolve("parked"), 250)); + const answer = await Promise.race([client.request("daemon.update", { query: true }), parked]); + return answer === "parked" ? answer : answer.payload; + }; + expect(await queried(), "while converging").toMatchObject({ + health: "starting", + lastUpdateFailure: null, + }); + + // why: a gateway's events.subscribe parks until the daemon is ready, so it must still get the rollback's event. + const subscribed = client.request("events.subscribe", {}); + await expect(client.request("status.get", {})).resolves.toMatchObject({ ok: true }); + + converge.resolve(); + expect(await queried(), "converged, result file not read yet").toMatchObject({ + health: "starting", + }); + + resultFileGate.resolve(); + await startPromise; + await expect(subscribed).resolves.toMatchObject({ ok: true }); + await expect + .poll( + () => + client + .frames() + .some( + (frame) => + frame.push === "event" && + JSON.stringify(frame.payload).includes('"daemon.update-failed"'), + ), + { message: "the parked subscriber receives daemon.update-failed" }, + ) + .toBe(true); + expect(await queried(), "started").toMatchObject({ + health: "running", + lastUpdateFailure: { stage: "restarting", from: "0.3.0", to: "0.4.0" }, + }); + await client.close(); + }); + it("parks a request type other than hello/status.get until convergence completes, then serves it", async () => { const converge = deferred(); const harness = await createHarness({ converge: () => converge.promise, start: false }); @@ -3070,6 +3143,604 @@ describe("DaemonServer roles and ownership (ADR 0003 §2-4)", () => { }); }); +describe("daemon.update", () => { + interface RigSetup { + readonly version?: string; + readonly view?: NpmOutcome; + readonly launcher?: FakeUpdaterLauncher; + readonly packageRoot?: string; + readonly otherRoleRunning?: boolean; + readonly npm?: FakeNpmCli; + readonly lease?: { readonly defaultTtlMs: number }; + } + async function updateRig(leases: number, devices = 2, setup: RigSetup = {}) { + const npm = + setup.npm ?? + new FakeNpmCli({ + view: [setup.view ?? { ok: true, output: "0.4.0" }], + globalRoot: [{ ok: true, output: "/opt" }], + }); + const launcher = setup.launcher ?? new FakeUpdaterLauncher(); + const harness = await createHarness({ + iosMaxDevices: devices, + ...(setup.lease === undefined ? {} : { lease: setup.lease }), + update: { + npm, + launcher, + version: setup.version ?? "0.3.0", + ...(setup.packageRoot === undefined ? {} : { packageRoot: setup.packageRoot }), + ...(setup.otherRoleRunning === undefined + ? {} + : { otherRoleRunning: setup.otherRoleRunning }), + }, + }); + const client = await createClient(harness.socketPath); + await hello(client); + const leaseIds: string[] = []; + for (let index = 0; index < leases; index += 1) { + const grant = await client.request("lease.request", { + requesterId: `agent-${index}`, + model: "iPhone 16", + osVersion: "26.5", + platform: "ios", + }); + leaseIds.push(leaseIdOf(grant)); + } + const started: unknown[] = []; + harness.eventBus.subscribe("daemon.update-started", (envelope) => { + started.push(envelope.payload); + }); + const updateOf = async (): Promise => + ((await client.request("status.get", {})).payload as { daemon: { update: unknown } }).daemon + .update; + return { ...harness, client, launcher, leaseIds, npm, started, updateOf }; + } + const run = (stage: "waiting" | "installing" | "restarting", forced: boolean, extra = {}) => ({ + stage, + from: "0.3.0", + to: "0.4.0", + forced, + startedAt: 1_000, + ...extra, + }); + + it("starts an update that status.get shows as waiting with the lease count, then installing", async () => { + const idle = await updateRig(0); + await expect(idle.client.request("daemon.update", {})).resolves.toMatchObject({ + ok: true, + payload: { + started: true, + upToDate: false, + resolved: "0.4.0", + version: "0.3.0", + health: "running", + update: run("installing", false), + lastUpdateFailure: null, + latestVersion: null, + }, + }); + expect(await idle.updateOf()).toEqual(run("installing", false)); + expect(idle.npm.calls[0]).toEqual({ method: "view", spec: "simlock@latest" }); + expect(idle.launcher.launches).toEqual([ + { + entry: "/opt/simlock/dist/updater/main.js", + from: "0.3.0", + to: "0.4.0", + role: "worker", + pid: 4242, + simlockHome: "/home/simlock", + logPath: "/data/daemon.log", + }, + ]); + expect(idle.started).toEqual([{ from: "0.3.0", to: "0.4.0", forced: false }]); + + const held = await updateRig(2); + await expect(held.client.request("daemon.update", {})).resolves.toMatchObject({ + payload: { started: true, update: run("waiting", false, { leases: 2 }) }, + }); + expect(await held.updateOf()).toEqual(run("waiting", false, { leases: 2 })); + expect(held.launcher.launches).toEqual([]); + await held.client.request("lease.release", { leaseId: held.leaseIds[0] }); + await expect.poll(held.updateOf).toEqual(run("waiting", false, { leases: 1 })); + expect(held.launcher.launches).toEqual([]); + await held.client.request("lease.release", { leaseId: held.leaseIds[1] }); + await expect.poll(held.updateOf).toEqual(run("installing", false)); + expect(held.launcher.launches).toHaveLength(1); + + const expiring = await updateRig(1, 2, { lease: { defaultTtlMs: 40 } }); + await expiring.client.request("daemon.update", {}); + expect(await expiring.updateOf()).toEqual(run("waiting", false, { leases: 1 })); + expiring.clock.advance(40); + await expect.poll(expiring.updateOf).toEqual(run("installing", false)); + expect(expiring.launcher.launches).toHaveLength(1); + + const forced = await updateRig(2); + await expect(forced.client.request("daemon.update", { force: true })).resolves.toMatchObject({ + payload: { started: true, update: run("installing", true) }, + }); + expect(forced.launcher.launches).toHaveLength(1); + expect(forced.started).toEqual([{ from: "0.3.0", to: "0.4.0", forced: true }]); + expect(forced.registry.snapshot.leases).toHaveLength(2); + + const waiting = await updateRig(2); + await waiting.client.request("daemon.update", {}); + expect(await waiting.updateOf()).toEqual(run("waiting", false, { leases: 2 })); + await expect(waiting.client.request("daemon.update", { force: true })).resolves.toMatchObject({ + payload: { started: false, update: run("installing", true) }, + }); + expect(waiting.launcher.launches).toHaveLength(1); + expect(waiting.started).toEqual([{ from: "0.3.0", to: "0.4.0", forced: false }]); + + const converge = deferred(); + const npm = new FakeNpmCli({ + view: [{ ok: true, output: "0.4.0" }], + globalRoot: [{ ok: true, output: "/opt" }], + }); + const launcher = new FakeUpdaterLauncher(); + const starting = await createHarness({ + converge: () => converge.promise, + start: false, + update: { npm, launcher, version: "0.3.0" }, + }); + const startPromise = starting.daemon.start(); + const client = await createClientRetrying(starting.socketPath); + await hello(client); + const parked = client.request("daemon.update", {}); + await expect(client.request("status.get", {})).resolves.toMatchObject({ ok: true }); + expect(npm.calls).toEqual([]); + converge.resolve(); + await startPromise; + await expect(parked).resolves.toMatchObject({ + payload: { started: true, update: run("installing", false) }, + }); + }); + + it("follows the update that runs whatever version a call names, and answers a query without starting anything", async () => { + const idle = await updateRig(0); + await expect(idle.client.request("daemon.update", { query: true })).resolves.toMatchObject({ + ok: true, + payload: { started: false, upToDate: false, resolved: null, version: "0.3.0", update: null }, + }); + expect(idle.npm.calls).toEqual([]); + expect(idle.launcher.launches).toEqual([]); + + const rig = await updateRig(2); + await rig.client.request("daemon.update", {}); + const following = { started: false, upToDate: false, resolved: null }; + for (const input of [{ query: true }, { version: "0.9.9" }, {}]) { + await expect(rig.client.request("daemon.update", input)).resolves.toMatchObject({ + ok: true, + payload: { ...following, update: run("waiting", false, { leases: 2 }) }, + }); + } + expect(rig.npm.calls.filter((call) => call.method === "view")).toHaveLength(1); + expect(rig.launcher.launches).toEqual([]); + expect(rig.started).toHaveLength(1); + + const gate = deferred(); + class HeldNpm extends FakeNpmCli { + override async view(spec: string) { + await gate.promise; + return super.view(spec); + } + } + const racing = await updateRig(0, 2, { + npm: new HeldNpm({ + view: [{ ok: true, output: "0.4.0" }], + globalRoot: [{ ok: true, output: "/opt" }], + }), + }); + const other = await createClient(racing.socketPath); + await hello(other); + const first = racing.client.request("daemon.update", {}); + const second = other.request("daemon.update", {}); + await expect(other.request("status.get", {})).resolves.toMatchObject({ ok: true }); + gate.resolve(); + const replies = (await Promise.all([first, second])).map( + (reply) => (reply.payload as { started: boolean }).started, + ); + expect(replies.sort()).toEqual([false, true]); + expect(racing.launcher.launches).toHaveLength(1); + expect(racing.started).toHaveLength(1); + }); + + it("accepts daemon.update from an admin on a connection whose protocol range does not overlap, refuses an agent there, and lets the updater's probe read health and version through query", async () => { + const previous = { min: PROTOCOL_VERSION_RANGE.min - 1, max: PROTOCOL_VERSION_RANGE.max - 1 }; + const mismatched = (role: "admin" | "agent") => + createHarness({ + protocolVersion: previous, + resolveRole: { resolve: () => role }, + update: { npm: new FakeNpmCli(), launcher: new FakeUpdaterLauncher(), version: "0.3.0" }, + }); + const connectDegraded = (socketPath: string) => + connectSimlockClient({ connector: new NodeIpcTransport(), endpoint: socketPath }, true); + + const admin = await mismatched("admin"); + const client = await connectDegraded(admin.socketPath); + await expect(client.getStatus()).rejects.toMatchObject({ + code: "PROTOCOL_VERSION_UNSUPPORTED", + }); + await expect(client.updateDaemon({ query: true })).resolves.toMatchObject({ + started: false, + version: "0.3.0", + health: "running", + update: null, + }); + await client.close(); + + const dataDirectory = dirname(admin.socketPath); + await writeFile(join(dataDirectory, "admin.token"), "token\n"); + const probe = createDaemonProbe({ dataDirectory, filesystem: new NodeFilesystem() }); + await expect(probe.state()).resolves.toEqual({ health: "running", version: "0.3.0" }); + + const agent = await mismatched("agent"); + const refused = await connectDegraded(agent.socketPath); + await expect(refused.updateDaemon({ query: true })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + await refused.close(); + }); + + it("follows the updater's lines in status.get, turns a failed line or a non-zero exit into lastUpdateFailure and daemon.update-failed and serves leases again, and reports a result file at start and deletes it", async () => { + const line = (value: unknown) => JSON.stringify(value); + const failure = (message: string, stage = "installing") => ({ + stage, + from: "0.3.0", + to: "0.4.0", + message, + at: 1_000, + }); + const cases: readonly { + readonly name: string; + readonly script: { readonly lines: readonly string[]; readonly exit: number }; + readonly launcher?: FakeUpdaterLauncher; + readonly update: unknown; + readonly failure: ReturnType | null; + }[] = [ + { + name: "the updater reports restarting", + script: { lines: [line({ stage: "installing" }), line({ stage: "restarting" })], exit: 0 }, + update: run("restarting", false), + failure: null, + }, + { + name: "the updater reports a failed install", + script: { + lines: [ + line({ stage: "installing" }), + line({ failed: { stage: "installing", message: "npm error code EACCES" } }), + ], + exit: 1, + }, + update: null, + failure: failure("npm error code EACCES"), + }, + { + name: "the updater exits non-zero with no failed line", + script: { lines: [line({ stage: "installing" })], exit: 3 }, + update: null, + failure: failure("the updater exited with code 3"), + }, + { + name: "the updater exits non-zero after it reported restarting", + script: { lines: [line({ stage: "installing" }), line({ stage: "restarting" })], exit: 2 }, + update: null, + failure: failure("the updater exited with code 2", "restarting"), + }, + { + name: "the updater's failed message is longer than 2000 characters", + script: { + lines: [line({ failed: { stage: "installing", message: "e".repeat(3000) } })], + exit: 1, + }, + update: null, + failure: failure("e".repeat(2000)), + }, + { + name: "the updater prints a line that is not one of its two shapes", + script: { + lines: [line({ stage: "restarting" }), line({ stage: "waiting" }), "not json"], + exit: 0, + }, + update: run("restarting", false), + failure: null, + }, + { + name: "the updater cannot be started", + script: { lines: [], exit: 0 }, + launcher: new (class extends FakeUpdaterLauncher { + override launch(): Promise { + return Promise.reject(new Error("spawn node ENOENT")); + } + })(), + update: null, + failure: failure("spawn node ENOENT"), + }, + ]; + for (const { name, script, launcher, update, failure: expected } of cases) { + const rig = await updateRig(0, 2, { launcher: launcher ?? new FakeUpdaterLauncher(script) }); + const failed: unknown[] = []; + rig.eventBus.subscribe("daemon.update-failed", (envelope) => failed.push(envelope.payload)); + await rig.client.request("daemon.update", {}); + await expect.poll(rig.updateOf, { message: name }).toEqual(update); + const status = (await rig.client.request("status.get", {})).payload as { + daemon: { lastUpdateFailure: unknown }; + }; + expect(status.daemon.lastUpdateFailure, name).toEqual(expected); + expect(failed, name).toEqual( + expected === null + ? [] + : [ + { + stage: expected.stage, + from: expected.from, + to: expected.to, + message: expected.message, + }, + ], + ); + if (expected !== null) { + await expect( + rig.client.request("lease.request", { + requesterId: "agent-after", + model: "iPhone 16", + osVersion: "26.5", + platform: "ios", + }), + name, + ).resolves.toMatchObject({ ok: true }); + } + } + + class FailsOnce extends FakeUpdaterLauncher { + #launched = 0; + override async launch(...args: Parameters) { + await super.launch(...args); + const never = new Promise(() => undefined); + return this.#launched++ === 0 + ? { + lines: (async function* (): AsyncGenerator { + yield* []; + })(), + exited: Promise.resolve(1), + } + : { + lines: (async function* (): AsyncGenerator { + await never; + yield ""; + })(), + exited: never, + }; + } + } + const again = await updateRig(0, 2, { launcher: new FailsOnce() }); + await again.client.request("daemon.update", {}); + await expect + .poll(async () => (await again.client.request("daemon.update", { query: true })).payload) + .toMatchObject({ + update: null, + lastUpdateFailure: { message: "the updater exited with code 1" }, + }); + await expect(again.client.request("daemon.update", {})).resolves.toMatchObject({ + payload: { started: true, update: run("installing", false), lastUpdateFailure: null }, + }); + + const resultFile = (overrides: Record = {}) => + JSON.stringify({ + from: "0.3.0", + to: "0.4.0", + stage: "restarting", + message: "0.4.0 did not start: health failed", + at: 500, + rolledBack: true, + ...overrides, + }); + for (const { name, contents, reported } of [ + { + name: "a result file from a rolled back update", + contents: resultFile(), + reported: { + stage: "restarting", + from: "0.3.0", + to: "0.4.0", + message: "0.4.0 did not start: health failed", + at: 500, + }, + }, + { + name: "a result file whose message is longer than 2000 characters", + contents: resultFile({ message: "e".repeat(3000) }), + reported: { + stage: "restarting", + from: "0.3.0", + to: "0.4.0", + message: "e".repeat(2000), + at: 500, + }, + }, + { name: "a result file that is not valid", contents: "{not json", reported: null }, + { + name: "a result file that cannot be read", + contents: new Error("EACCES: permission denied"), + reported: null, + }, + { + name: "a result file with an unknown stage", + contents: resultFile({ stage: "waiting" }), + reported: null, + }, + ]) { + const harness = await createHarness({ + update: { + npm: new FakeNpmCli(), + launcher: new FakeUpdaterLauncher(), + version: "0.3.0", + resultFile: contents, + }, + }); + const client = await createClient(harness.socketPath); + await hello(client); + const status = (await client.request("status.get", {})).payload as { + daemon: { lastUpdateFailure: unknown }; + }; + expect(status.daemon.lastUpdateFailure, name).toEqual(reported); + expect( + harness.eventBus + .replay() + .filter((envelope) => envelope.event === "daemon.update-failed") + .map((envelope) => envelope.payload), + name, + ).toEqual( + reported === null + ? [] + : [{ stage: reported.stage, from: "0.3.0", to: "0.4.0", message: reported.message }], + ); + expect(await harness.updateFilesystem.exists("/data/update-result.json"), name).toBe(false); + } + }); + + it("refuses an update that cannot resolve, change or install anything, and answers upToDate when nothing is needed", async () => { + const newerThanLatest = { version: "0.5.0-rc.1" }; + const cases: readonly { + readonly name: string; + readonly setup: RigSetup; + readonly input?: Record; + readonly reply: Record; + }[] = [ + { + name: "npm cannot resolve the version", + setup: { view: { ok: false, message: "No match found for version 9.9.9" } }, + input: { version: "9.9.9" }, + reply: { + ok: false, + error: { + code: "UPDATE_RESOLVE_FAILED", + message: "could not resolve simlock@9.9.9: No match found for version 9.9.9", + }, + }, + }, + { + name: "the latest version is the running one", + setup: { view: { ok: true, output: "0.3.0" } }, + reply: { ok: true, payload: { started: false, upToDate: true, resolved: "0.3.0" } }, + }, + { + name: "the named version is the running one", + setup: { view: { ok: true, output: "0.3.0" } }, + input: { version: "0.3.0" }, + reply: { ok: true, payload: { started: false, upToDate: true, resolved: "0.3.0" } }, + }, + { + name: "the running version is newer than the latest and none was named", + setup: newerThanLatest, + reply: { + ok: true, + payload: { started: false, upToDate: true, resolved: "0.4.0", version: "0.5.0-rc.1" }, + }, + }, + { + name: "simlock was not installed with npm", + setup: { packageRoot: "/src/simlock" }, + reply: { + ok: false, + error: { + code: "UPDATE_UNSUPPORTED_INSTALL", + message: + "simlock was not installed with npm i -g (found /src/simlock). Update it by hand.", + }, + }, + }, + { + name: "the other role's daemon of this user runs", + setup: { otherRoleRunning: true }, + reply: { + ok: false, + error: { + code: "UPDATE_SHARED_INSTALL", + message: + "the gateway daemon of this user runs from the same install; stop it first with simlock --gateway daemon stop", + }, + }, + }, + ...[ + ["a query with force", { query: true, force: true }], + ["a query with a version", { query: true, version: "0.4.0" }], + ["a tag, not a version", { version: "latest" }], + ["a path, not a version", { version: "../x" }], + ["a version of 65 characters", { version: `1.0.0-${"a".repeat(59)}` }], + ].map(([name, input]) => ({ + name: `${name as string}`, + setup: {}, + input: input as Record, + reply: { ok: false, error: { code: "BAD_REQUEST" } }, + })), + ]; + for (const { name, setup, input = {}, reply } of cases) { + const rig = await updateRig(0, 2, setup); + expect(await rig.client.request("daemon.update", input), name).toMatchObject(reply); + expect(await rig.updateOf(), name).toBeNull(); + expect(rig.launcher.launches, name).toEqual([]); + await expect( + rig.client.request("lease.request", { + requesterId: "agent-after", + model: "iPhone 16", + osVersion: "26.5", + platform: "ios", + }), + name, + ).resolves.toMatchObject({ ok: true }); + } + + const downgrade = await updateRig(0, 2, { ...newerThanLatest }); + await expect( + downgrade.client.request("daemon.update", { version: "0.4.0" }), + ).resolves.toMatchObject({ payload: { started: true, upToDate: false, resolved: "0.4.0" } }); + }); + + it("refuses a new and a queued lease request with DAEMON_UPDATING while a held lease renews and releases as usual", async () => { + const rig = await updateRig(1, 1); + const rejected: unknown[] = []; + rig.eventBus.subscribe("lease.rejected", (envelope) => { + rejected.push(envelope.payload.reason); + }); + const request = (requesterId: string) => + rig.client.request("lease.request", { + requesterId, + model: "iPhone 16", + osVersion: "26.5", + platform: "ios", + }); + let queuedReply: ServerFrame | undefined; + let newReply: ServerFrame | undefined; + void request("agent-queued").then((reply) => (queuedReply = reply)); + await expect + .poll(async () => (await rig.client.request("status.get", {})).payload) + .toMatchObject({ waiting: [{ requesterId: "agent-queued" }] }); + await rig.client.request("daemon.update", {}); + + const refusal = { + ok: false, + error: { + code: "DAEMON_UPDATING", + message: "this daemon is updating and takes no new leases", + }, + }; + void request("agent-new").then((reply) => (newReply = reply)); + await expect.poll(() => queuedReply).toMatchObject(refusal); + await expect.poll(() => newReply).toMatchObject(refusal); + expect(rejected).toEqual(["updating", "updating"]); + + const leaseId = rig.leaseIds[0]; + await expect(rig.client.request("lease.renew", { leaseId })).resolves.toMatchObject({ + ok: true, + }); + await expect(rig.client.request("lease.release", { leaseId })).resolves.toMatchObject({ + ok: true, + }); + await expect.poll(rig.updateOf).toEqual(run("installing", false)); + }); +}); + /** Drives an in-memory `IpcConnection` the way `createClient` drives a real socket -- enough to * send request frames and await their replies. Used for uplink tests, where there is no socket * at all: the connection is the one a worker dialled out and handed to its own daemon. */ @@ -3149,6 +3820,15 @@ async function createHarness( readonly protocolVersion?: ProtocolRange; readonly includeEnrollment?: boolean; readonly relayTargets?: RelayTargets; + readonly update?: { + readonly npm: FakeNpmCli; + readonly launcher: FakeUpdaterLauncher; + readonly version: string; + readonly packageRoot?: string; + readonly otherRoleRunning?: boolean; + readonly resultFile?: string | Error; + readonly resultFileGate?: Promise; + }; } = {}, ) { const directory = @@ -3181,6 +3861,27 @@ async function createHarness( : { slimmableOsVersions: options.slimmableOsVersions }), }); const config = testConfig(options.lease, options.downloads, options.iosMaxDevices); + const unreadable = + options.update?.resultFile instanceof Error ? options.update.resultFile : undefined; + const resultFileGate = options.update?.resultFileGate; + const updateFilesystem = + unreadable === undefined && resultFileGate === undefined + ? new MemoryFilesystem() + : new (class extends MemoryFilesystem { + override async readFile(...args: Parameters) { + if (args[0] === "/data/update-result.json") { + await resultFileGate; + if (unreadable !== undefined) throw unreadable; + } + return super.readFile(...args); + } + })(); + for (const path of ["/opt", "/opt/simlock", "/src", "/src/simlock", "/data"]) { + await updateFilesystem.mkdir(path); + } + if (typeof options.update?.resultFile === "string") { + await updateFilesystem.writeFileAtomic("/data/update-result.json", options.update.resultFile); + } const wiring = testComponentWiring({ clock: clock, drivers: [driver], @@ -3255,14 +3956,46 @@ async function createHarness( ...(options.dispose === undefined ? {} : { dispose: options.dispose }), ...(options.beginStop === undefined ? {} : { beginStop: options.beginStop }), ...(options.stopAuxiliary === undefined ? {} : { stopAuxiliary: options.stopAuxiliary }), - version: "test", + ...(options.update === undefined + ? {} + : { + update: createUpdateCoordinator({ + npm: options.update.npm, + launcher: options.update.launcher, + leasing: engine.leasing, + registry, + eventBus, + clock, + version: options.update.version, + filesystem: updateFilesystem, + dataDirectory: "/data", + otherRoleRunning: async () => options.update?.otherRoleRunning ?? false, + packageRoot: options.update.packageRoot ?? "/opt/simlock", + simlockHome: "/home/simlock", + role: "worker", + logPath: "/data/daemon.log", + pid: 4242, + }), + }), + version: options.update?.version ?? "test", }); runningDaemons.push(daemon); if (options.start ?? true) { await daemon.start(); } - return { clock, config, daemon, driver, engine, eventBus, registry, socketPath, state }; + return { + clock, + config, + daemon, + driver, + engine, + eventBus, + registry, + socketPath, + state, + updateFilesystem, + }; } /** diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 6ed3ceb0a..1fba05f0d 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -65,6 +65,7 @@ import { import { OwnerRoutedFactBus, type OwnerRoutedFacts } from "./owner-routed-facts.js"; import { classifyError, StartupFailedError } from "./error-code.js"; import { RelayConnection, type RelayTargets } from "./relay.js"; +import type { UpdateCoordinator } from "./update-coordinator.js"; type RequestId = string | number; @@ -166,6 +167,7 @@ export interface DaemonServerEngineOptions { readonly registry: Registry; readonly stalls?: DispatcherOptions["stalls"]; readonly tokens?: TokenOperations; + readonly update?: UpdateCoordinator; } export interface DaemonServerCommonOptions { @@ -315,6 +317,7 @@ function buildDispatcher( registry: options.registry, ...(options.stalls === undefined ? {} : { stalls: options.stalls }), ...(options.tokens === undefined ? {} : { tokens: options.tokens }), + ...(options.update === undefined ? {} : { update: options.update }), version: options.version, }); } @@ -572,11 +575,25 @@ export class DaemonServer { // this monitor. this.#engine?.healthMonitor?.start(); this.#engine?.physicalDeviceWatcher?.start(); + // why: a gateway's `events.subscribe` parked on readiness resumes only after this continuation, so the rollback's `daemon.update-failed` waits one turn of the event loop for it to subscribe. + await new Promise((resolve) => setImmediate(resolve)); + if (!this.#stopping) this.#engine?.update?.announceResultFile(); + } + + // why: a stale or unreadable result file must never keep the daemon from starting. + async #reportUpdateResult(): Promise { + try { + await this.#engine?.update?.readResultFile(); + } catch (error: unknown) { + this.#logger.warn("Could not read the update result file", { message: errorMessage(error) }); + } } async #converge(): Promise { try { await this.options.converge?.(); + // why: read before health is `running`, so a `daemon.update` that sees `running` also sees the failure a rolled back update left. + await this.#reportUpdateResult(); this.#health = "running"; } catch (error: unknown) { this.#health = "failed"; @@ -782,7 +799,8 @@ export class DaemonServer { return; } - if (connection.protocolMismatch) { + // why: `daemon.update` is the second frozen call (ADR 0042 §1): it skips the version gate but not the role check, which the dispatcher still runs, and readiness except for `query`. + if (connection.protocolMismatch && frame.type !== "daemon.update") { relayChannel?.abort(); await this.#respondError( connection.socket, @@ -1186,6 +1204,8 @@ export class DaemonServer { return this.#dispatcher.dispatch("device.add", frame.payload, this.#session(connection)); case "device.remove": return this.#dispatcher.dispatch("device.remove", frame.payload, this.#session(connection)); + case "daemon.update": + return this.#dispatcher.dispatch("daemon.update", frame.payload, this.#session(connection)); // why: "daemon.stop" is deliberately absent from this switch: `#dispatchLine` intercepts it // itself, ahead of the protocol-mismatch and `#stopping` gates (ADR §6's frozen exception, // scoped to protocol version only -- still gated on a completed handshake and the `admin` diff --git a/src/daemon/update-coordinator.ts b/src/daemon/update-coordinator.ts new file mode 100644 index 000000000..c48a3877b --- /dev/null +++ b/src/daemon/update-coordinator.ts @@ -0,0 +1,319 @@ +import { join } from "node:path"; + +import { z } from "zod"; + +import type { EventBus } from "../bus/index.js"; +import { + isNewerVersion, + updateFailureSchema, + type UpdateFailure, + type UpdateState, +} from "../contract/index.js"; +import type { Registry } from "../core/index.js"; +import type { UpdateHold } from "../leasing/index.js"; +import { checkInstall } from "../updater/index.js"; +import { DispatchError } from "./dispatch.js"; +import { + isMissingPathError, + type Clock, + type DaemonRole, + type Filesystem, + type LaunchedUpdater, + type NpmCli, + type UpdaterLauncher, +} from "../ports/index.js"; + +export interface UpdateInput { + readonly version?: string | undefined; + readonly force?: boolean | undefined; + readonly query?: boolean | undefined; +} + +export interface UpdateProgress { + readonly update: UpdateState | null; + readonly lastUpdateFailure: UpdateFailure | null; + readonly latestVersion: string | null; +} + +export interface UpdateOutcome extends UpdateProgress { + readonly started: boolean; + readonly upToDate: boolean; + readonly resolved: string | null; +} + +export interface UpdateCoordinator { + request(input: UpdateInput): Promise; + state(): UpdateProgress; + readResultFile(): Promise; + announceResultFile(): void; +} + +export interface UpdateCoordinatorOptions { + readonly npm: NpmCli; + readonly launcher: UpdaterLauncher; + readonly leasing: UpdateHold; + readonly registry: Pick; + readonly eventBus: Pick; + readonly clock: Clock; + readonly filesystem: Filesystem; + readonly otherRoleRunning: () => Promise; + readonly version: string; + readonly packageRoot: string; + readonly simlockHome: string; + readonly dataDirectory: string; + readonly role: DaemonRole; + readonly logPath: string; + readonly pid: number; +} + +const MESSAGE_MAX = 2000; +const RESULT_FILE = "update-result.json"; + +// why: a line on the updater's pipe is wire input, so only these two shapes are read and anything else is dropped. +const updaterLineSchema = z.union([ + z.object({ stage: z.enum(["installing", "restarting"]) }), + z.object({ + failed: z.object({ stage: z.enum(["installing", "restarting"]), message: z.string() }), + }), +]); + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function parseUpdaterLine(line: string): z.infer | undefined { + try { + const parsed = updaterLineSchema.safeParse(JSON.parse(line)); + return parsed.success ? parsed.data : undefined; + } catch { + return undefined; + } +} + +interface Run { + readonly from: string; + readonly to: string; + readonly startedAt: number; + forced: boolean; + stage: UpdateState["stage"]; + leases: number; +} + +function stateOf(run: Run): UpdateState { + const { from, to, startedAt, forced } = run; + const base = { from, to, forced, startedAt }; + return run.stage === "waiting" + ? { stage: "waiting", leases: run.leases, ...base } + : { stage: run.stage, ...base }; +} + +class Coordinator implements UpdateCoordinator { + #run: Run | undefined; + #lastFailure: UpdateFailure | null = null; + #unannounced: UpdateFailure | undefined; + #calls: Promise = Promise.resolve(); + + constructor(private readonly options: UpdateCoordinatorOptions) { + const leaseEnded = (): void => { + void this.#leaseEnded(); + }; + options.eventBus.subscribe("lease.released", leaseEnded); + options.eventBus.subscribe("lease.expired", leaseEnded); + } + + state(): UpdateProgress { + return { + update: this.#run === undefined ? null : stateOf(this.#run), + lastUpdateFailure: this.#lastFailure, + latestVersion: null, + }; + } + + // why: a call that arrives while another resolves the version waits for it, so two calls never start two updates; a query never waits. + request(input: UpdateInput): Promise { + if (input.query === true) return Promise.resolve(this.#outcome(false, null)); + const result = this.#calls.then(() => this.#update(input)); + this.#calls = result.catch(() => undefined); + return result; + } + + async #update(input: UpdateInput): Promise { + const run = this.#run; + if (run !== undefined) { + if (input.force === true && run.stage === "waiting") { + run.forced = true; + await this.#install(run); + } + return this.#outcome(false, null); + } + const to = await this.#resolve(input.version ?? "latest"); + const { version } = this.options; + if (to === version || (input.version === undefined && !isNewerVersion(to, version))) { + return { ...this.#outcome(false, to), upToDate: true }; + } + await this.#requireNpmInstall(); + await this.#begin(to, input.force === true); + return this.#outcome(true, to); + } + + async #resolve(spec: string): Promise { + const viewed = await this.options.npm.view(`simlock@${spec}`); + if (!viewed.ok) { + throw new DispatchError( + "UPDATE_RESOLVE_FAILED", + `could not resolve simlock@${spec}: ${viewed.message}`, + ); + } + return viewed.output; + } + + async #requireNpmInstall(): Promise { + const { npm, filesystem, packageRoot, role, otherRoleRunning } = this.options; + const install = await checkInstall({ npm, filesystem, packageRoot }); + if (!install.ok) { + throw new DispatchError( + "UPDATE_UNSUPPORTED_INSTALL", + `simlock was not installed with npm i -g (found ${install.foundPath}). Update it by hand.`, + ); + } + if (await otherRoleRunning()) { + const other = role === "worker" ? "gateway" : "worker"; + const flag = role === "worker" ? "--gateway " : ""; + throw new DispatchError( + "UPDATE_SHARED_INSTALL", + `the ${other} daemon of this user runs from the same install; stop it first with simlock ${flag}daemon stop`, + ); + } + } + + async #begin(to: string, forced: boolean): Promise { + const { clock, eventBus, version: from } = this.options; + const leases = this.#leaseCount(); + const run: Run = { from, to, startedAt: clock.now(), forced, stage: "waiting", leases }; + this.#run = run; + this.#lastFailure = null; + await this.options.leasing.holdForUpdate(); + eventBus.emit("daemon.update-started", { from, to, forced }, "update-coordinator"); + if (forced || leases === 0) await this.#install(run); + } + + #leaseCount(): number { + return this.options.registry.snapshot.leases.length; + } + + async #leaseEnded(): Promise { + const run = this.#run; + if (run?.stage !== "waiting") return; + run.leases = this.#leaseCount(); + if (run.leases === 0) await this.#install(run); + } + + async #install(run: Run): Promise { + run.stage = "installing"; + let updater: LaunchedUpdater; + try { + updater = await this.options.launcher.launch({ + entry: join(this.options.packageRoot, "dist", "updater", "main.js"), + from: run.from, + to: run.to, + role: this.options.role, + pid: this.options.pid, + simlockHome: this.options.simlockHome, + logPath: this.options.logPath, + }); + } catch (error: unknown) { + await this.#fail(run, "installing", describe(error)); + return; + } + void this.#follow(run, updater).catch((error: unknown) => + this.#fail(run, run.stage === "restarting" ? "restarting" : "installing", describe(error)), + ); + } + + async #follow(run: Run, updater: LaunchedUpdater): Promise { + for await (const text of updater.lines) { + const line = parseUpdaterLine(text); + if (line === undefined) continue; + if ("failed" in line) { + await this.#fail(run, line.failed.stage, line.failed.message); + } else if (this.#run === run) { + run.stage = line.stage; + } + } + const code = await updater.exited; + if (code !== 0) { + const stage = run.stage === "restarting" ? "restarting" : "installing"; + await this.#fail(run, stage, `the updater exited with code ${code ?? "none"}`); + } + } + + // why: the first failure of a run wins; a failed line is followed by the non-zero exit that reports the same failure. + async #fail(run: Run, stage: UpdateFailure["stage"], message: string): Promise { + if (this.#run !== run) return; + const { from, to } = run; + this.#run = undefined; + this.#lastFailure = { + stage, + from, + to, + message: message.slice(0, MESSAGE_MAX), + at: this.options.clock.now(), + }; + await this.options.leasing.releaseUpdateHold(); + this.options.eventBus.emit( + "daemon.update-failed", + { stage, from, to, message: this.#lastFailure.message }, + "update-coordinator", + ); + } + + async readResultFile(): Promise { + const { filesystem, dataDirectory } = this.options; + const path = join(dataDirectory, RESULT_FILE); + let contents: string; + try { + contents = await filesystem.readFile(path); + } catch (error: unknown) { + if (isMissingPathError(error)) return; + throw error; + } + await filesystem.rm(path); + const failure = parseResultFile(contents); + if (failure === undefined) return; + this.#lastFailure = failure; + this.#unannounced = failure; + } + + // why: the failure is set while the daemon converges but announced only once the daemon serves, because a subscriber's `events.subscribe` is parked until then and would miss it. + announceResultFile(): void { + const failure = this.#unannounced; + if (failure === undefined) return; + this.#unannounced = undefined; + const { stage, from, to, message } = failure; + this.options.eventBus.emit( + "daemon.update-failed", + { stage, from, to, message }, + "update-coordinator", + ); + } + + #outcome(started: boolean, resolved: string | null): UpdateOutcome { + return { ...this.state(), started, upToDate: false, resolved }; + } +} + +// why: the file is on disk and the updater wrote it, but a stale or damaged one must not stop the daemon from starting, so it is read as untrusted and the message cut to the bound. +function parseResultFile(contents: string): UpdateFailure | undefined { + try { + const value = JSON.parse(contents) as Record; + if (typeof value.message === "string") value.message = value.message.slice(0, MESSAGE_MAX); + const parsed = updateFailureSchema.safeParse(value); + return parsed.success ? parsed.data : undefined; + } catch { + return undefined; + } +} + +export function createUpdateCoordinator(options: UpdateCoordinatorOptions): UpdateCoordinator { + return new Coordinator(options); +} diff --git a/src/gateway/aggregate.test.ts b/src/gateway/aggregate.test.ts index 6eacf618f..9ca49af89 100644 --- a/src/gateway/aggregate.test.ts +++ b/src/gateway/aggregate.test.ts @@ -48,7 +48,12 @@ function view(overrides: Partial & { readonly id: string }): WorkerV describe("aggregateStatus", () => { it("returns the same shape a worker returns, with the gateway named in the daemon block", () => { - const status = aggregateStatus([], { health: "running", host: GATEWAY_HOST, queueDepth: 0 }); + const status = aggregateStatus([], { + health: "running", + version: "0.0.0-test", + host: GATEWAY_HOST, + queueDepth: 0, + }); // The contract is the arbiter: a gateway's answer parses as `status.get`'s output or it is // not the same shape. @@ -58,7 +63,12 @@ describe("aggregateStatus", () => { }); it("carries physicalQueueDepth only when the options name one", () => { - const base = { health: "running", host: GATEWAY_HOST, queueDepth: 0 } as const; + const base = { + health: "running", + version: "0.0.0-test", + host: GATEWAY_HOST, + queueDepth: 0, + } as const; expect(aggregateStatus([], { ...base, physicalQueueDepth: 2 })).toHaveProperty( "physicalQueueDepth", @@ -75,7 +85,7 @@ describe("aggregateStatus", () => { view({ connection: "disconnected", id: "wrk_b", warmPool: block }), view({ id: "wrk_c" }), ], - { health: "running", host: GATEWAY_HOST, queueDepth: 0 }, + { health: "running", version: "0.0.0-test", host: GATEWAY_HOST, queueDepth: 0 }, ); expect(status.warmPool).toStrictEqual({ @@ -106,7 +116,7 @@ describe("aggregateStatus", () => { const status = aggregateStatus( [view({ id: "wrk_a", label: "mac-mini-1", leases: [leaseFixture("lease_1", "dev_1")] })], - { health: "running", host: GATEWAY_HOST, leaseIndex, queueDepth: 0 }, + { health: "running", version: "0.0.0-test", host: GATEWAY_HOST, leaseIndex, queueDepth: 0 }, ); expect(status.leases).toEqual([ @@ -128,7 +138,7 @@ describe("aggregateStatus", () => { const status = aggregateStatus( [view({ id: "wrk_a", label: "mac-mini-1", leases: [leaseFixture("lease_1", "dev_1")] })], - { health: "running", host: GATEWAY_HOST, leaseIndex, queueDepth: 0 }, + { health: "running", version: "0.0.0-test", host: GATEWAY_HOST, leaseIndex, queueDepth: 0 }, ); expect(status.leases).toEqual([ @@ -143,7 +153,7 @@ describe("aggregateStatus", () => { view({ capacity: capacity(1, 2), id: "wrk_a" }), view({ capacity: capacity(2, 4), id: "wrk_b" }), ], - { health: "running", host: GATEWAY_HOST, queueDepth: 0 }, + { health: "running", version: "0.0.0-test", host: GATEWAY_HOST, queueDepth: 0 }, ); expect(status.capacity?.ios).toMatchObject({ limit: 6, running: 3, used: 3, warm: 2 }); @@ -158,7 +168,7 @@ describe("aggregateStatus", () => { view({ capacity: capacity(1, 2), id: "wrk_a" }), view({ capacity: capacity(9, 9), connection: "disconnected", id: "wrk_b" }), ], - { health: "running", host: GATEWAY_HOST, queueDepth: 0 }, + { health: "running", version: "0.0.0-test", host: GATEWAY_HOST, queueDepth: 0 }, ); expect(status.capacity?.ios).toMatchObject({ limit: 2, running: 1 }); @@ -174,7 +184,7 @@ describe("aggregateStatus", () => { id: "wrk_b", }), ], - { health: "running", host: GATEWAY_HOST, queueDepth: 0 }, + { health: "running", version: "0.0.0-test", host: GATEWAY_HOST, queueDepth: 0 }, ); expect(status.capacity?.ios.overLimit).toBe(true); @@ -198,7 +208,7 @@ describe("aggregateStatus", () => { id: "wrk_d", }), ], - { health: "running", host: GATEWAY_HOST, queueDepth: 0 }, + { health: "running", version: "0.0.0-test", host: GATEWAY_HOST, queueDepth: 0 }, ); // 8 GiB used of a 12 GiB fleet limit, yet over: wrk_b is past its own 4 GiB. @@ -221,7 +231,7 @@ describe("aggregateStatus", () => { id: "wrk_a", }), ], - { health: "running", host: GATEWAY_HOST, queueDepth: 0 }, + { health: "running", version: "0.0.0-test", host: GATEWAY_HOST, queueDepth: 0 }, ); expect(status.capacity?.ramBudget?.overLimit).toBe(false); @@ -240,7 +250,7 @@ describe("aggregateStatus", () => { id: "wrk_b", }), ], - { health: "running", host: GATEWAY_HOST, queueDepth: 0 }, + { health: "running", version: "0.0.0-test", host: GATEWAY_HOST, queueDepth: 0 }, ); expect(status.capacity).not.toHaveProperty("ramBudget"); @@ -256,7 +266,7 @@ describe("aggregateStatus", () => { }), view({ devices: [deviceFixture("dev_2")], id: "wrk_b" }), ], - { health: "running", host: GATEWAY_HOST, queueDepth: 0 }, + { health: "running", version: "0.0.0-test", host: GATEWAY_HOST, queueDepth: 0 }, ); expect(status.devices).toEqual([ @@ -277,7 +287,7 @@ describe("aggregateStatus", () => { leases: [leaseFixture("lease_1", "dev_1")], }), ], - { health: "running", host: GATEWAY_HOST, queueDepth: 0 }, + { health: "running", version: "0.0.0-test", host: GATEWAY_HOST, queueDepth: 0 }, ); expect(status.leases).toEqual([expect.objectContaining({ id: "lease_1", workerId: "wrk_a" })]); @@ -302,7 +312,7 @@ describe("aggregateStatus", () => { leases: [leaseFixture("lease_1", "dev_1")], }), ], - { health: "running", host: GATEWAY_HOST, queueDepth: 0 }, + { health: "running", version: "0.0.0-test", host: GATEWAY_HOST, queueDepth: 0 }, ); expect(status.devices).toEqual([expect.objectContaining({ id: "dev_1", workerId: "wrk_b" })]); @@ -314,6 +324,7 @@ describe("aggregateStatus", () => { it("reports the gateway's own queue depth and health, not any worker's", () => { const status = aggregateStatus([view({ health: "failed", id: "wrk_a", queueDepth: 7 })], { health: "starting", + version: "0.0.0-test", host: GATEWAY_HOST, queueDepth: 0, }); @@ -340,7 +351,7 @@ describe("aggregateStatus", () => { view({ id: "wrk_b", installs: [install("35", 5)] }), view({ id: "wrk_c" }), ], - { health: "running", host: GATEWAY_HOST, queueDepth: 0 }, + { health: "running", version: "0.0.0-test", host: GATEWAY_HOST, queueDepth: 0 }, ); expect(status.installs).toEqual([ @@ -365,7 +376,7 @@ describe("aggregateStatus", () => { ), }), ], - { health: "running", host: GATEWAY_HOST, queueDepth: 0 }, + { health: "running", version: "0.0.0-test", host: GATEWAY_HOST, queueDepth: 0 }, ); expect(status.installs?.map((entry) => entry.since)).toEqual( @@ -941,7 +952,12 @@ describe("aggregateStatus at-RAM-budget flag", () => { ios: { ...base.ios, atRamBudget: ios }, }; }; - const options = { health: "running", host: GATEWAY_HOST, queueDepth: 0 } as const; + const options = { + health: "running", + version: "0.0.0-test", + host: GATEWAY_HOST, + queueDepth: 0, + } as const; it("reports atRamBudget for a platform only when every connected worker does", () => { const status = aggregateStatus( diff --git a/src/gateway/aggregate.ts b/src/gateway/aggregate.ts index b76ce646d..392d19271 100644 --- a/src/gateway/aggregate.ts +++ b/src/gateway/aggregate.ts @@ -45,6 +45,7 @@ const EMPTY_GLOBAL_CAPACITY = { export interface AggregateStatusOptions { readonly health: StatusOutput["daemon"]["health"]; + readonly version: string; readonly host: StatusOutput["host"]; readonly load?: StatusOutput["load"]; readonly queueDepth: number; @@ -64,7 +65,14 @@ export function aggregateStatus( ): StatusOutput { return { capacity: sumCapacity(views.filter((view) => view.connection === "connected")), - daemon: { health: options.health, mode: "gateway" }, + daemon: { + health: options.health, + mode: "gateway", + version: options.version, + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, host: options.host, ...(options.load === undefined ? {} : { load: options.load }), installs: fleetInstalls(views), diff --git a/src/gateway/dispatcher.test.ts b/src/gateway/dispatcher.test.ts index bc48cfbb1..40cea3de2 100644 --- a/src/gateway/dispatcher.test.ts +++ b/src/gateway/dispatcher.test.ts @@ -246,6 +246,7 @@ function harness( eventHistory: options.eventHistory ?? ringEventHistory(eventBus), ...(options.historyPath === undefined ? {} : { historyPath: options.historyPath }), health: () => options.health ?? "running", + version: "0.0.0-test", host: GATEWAY_HOST, ...(options.load === undefined ? {} : { load: options.load }), leaseIndex, @@ -422,6 +423,10 @@ describe("GatewayDispatcher", () => { consoleUrl: "http://127.0.0.1:4700/", health: "starting", mode: "gateway", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, }); expect(status.host).toEqual(GATEWAY_HOST); }); @@ -455,8 +460,19 @@ describe("GatewayDispatcher", () => { consoleUrl: "http://localhost:4711/", health: "running", mode: "gateway", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }); + expect(off.daemon).toEqual({ + health: "running", + mode: "gateway", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, }); - expect(off.daemon).toEqual({ health: "running", mode: "gateway" }); expect("consoleUrl" in off.daemon).toBe(false); }); @@ -1289,6 +1305,20 @@ describe("GatewayDispatcher", () => { }); }); + describe("daemon.update", () => { + it("answers UNSUPPORTED_IN_GATEWAY_MODE to an admin session, saying a gateway cannot update itself yet", async () => { + const { dispatcher } = harness(); + + await expect( + dispatcher.dispatch("daemon.update", {}, session({ role: "admin" })), + ).rejects.toMatchObject({ + code: "UNSUPPORTED_IN_GATEWAY_MODE", + details: { operation: "daemon.update" }, + message: "daemon.update updates one machine's daemon; a gateway cannot update itself yet", + }); + }); + }); + describe("device.add and device.remove", () => { it("answer UNSUPPORTED_IN_GATEWAY_MODE to an admin session, saying to run the command on the worker the device is plugged into", async () => { const { dispatcher } = harness(); diff --git a/src/gateway/dispatcher.ts b/src/gateway/dispatcher.ts index 247ec4488..2680cc1be 100644 --- a/src/gateway/dispatcher.ts +++ b/src/gateway/dispatcher.ts @@ -124,6 +124,7 @@ export interface GatewayDispatcherOptions { * `INTERNAL`. */ readonly errorCode?: (error: unknown) => string | undefined; readonly health: () => "starting" | "running" | "failed"; + readonly version: string; readonly host: AggregateStatusOptions["host"]; readonly load?: () => AggregateStatusOptions["load"]; readonly awaitReady: () => Promise; @@ -232,6 +233,10 @@ export class GatewayDispatcher { "worker.undrain": this.#workerUndrain, "worker.remove": this.#workerRemove, "alerts.test": this.#alertsTest, + "daemon.update": unsupportedByDesign( + "daemon.update", + "daemon.update updates one machine's daemon; a gateway cannot update itself yet", + ), "worker.install-component": this.#workerInstallComponent, "lease.list": this.#leaseList, "list.get": this.#listGet, @@ -316,7 +321,15 @@ export class GatewayDispatcher { const history = this.options.eventHistory.status(); if (this.options.health() === "starting") { return { - daemon: { health: "starting", mode: "gateway", ...consoleUrl }, + daemon: { + health: "starting", + mode: "gateway", + version: this.options.version, + update: null, + lastUpdateFailure: null, + latestVersion: null, + ...consoleUrl, + }, history, host: this.options.host, ...loadField, @@ -324,6 +337,7 @@ export class GatewayDispatcher { } const status = aggregateStatus(this.options.workers.views(), { health: this.options.health(), + version: this.options.version, host: this.options.host, ...(load === undefined ? {} : { load }), physicalQueueDepth: this.options.coordinator.physicalQueueDepth, diff --git a/src/gateway/fleet-coordinator.test.ts b/src/gateway/fleet-coordinator.test.ts index 806257c18..8751b329a 100644 --- a/src/gateway/fleet-coordinator.test.ts +++ b/src/gateway/fleet-coordinator.test.ts @@ -2666,6 +2666,7 @@ describe("FleetLeaseCoordinator: lease.rejected names its request", () => { }, "boot-timeout": "worker", killed: "worker", + updating: "worker", "daemon-restarted": "worker", }; diff --git a/src/gateway/service.test.ts b/src/gateway/service.test.ts index ce883c3a0..425bb0443 100644 --- a/src/gateway/service.test.ts +++ b/src/gateway/service.test.ts @@ -204,7 +204,14 @@ describe("GatewayService", () => { const worker = new ScriptedWorkerClient(); // A starting worker answers `status.get` with `daemon` and `host` only. worker.status = { - daemon: { health: "starting", mode: "worker" }, + daemon: { + health: "starting", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, host: hostFixture({ arch: "x64" }), }; worker.devices = [deviceFixture("dev_1", "leased")]; @@ -256,7 +263,14 @@ describe("GatewayService", () => { await harness.service.start(); const worker = new ScriptedWorkerClient(); worker.status = { - daemon: { health: "starting", mode: "worker" }, + daemon: { + health: "starting", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, host: hostFixture(), }; worker.devices = [deviceFixture("dev_1", "leased")]; @@ -389,7 +403,14 @@ describe("GatewayService", () => { }; worker.status = statusFixture({ capacity, - daemon: { health: "failed", mode: "worker" }, + daemon: { + health: "failed", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, host: hostFixture({ arch: "x64" }), installs: [install], leases: [leaseFixture("lease_1", "dev_1")], @@ -1405,7 +1426,14 @@ describe("GatewayService", () => { // A's held `status.get` now answers `starting`, after B built the current view. clientA.status = { - daemon: { health: "starting", mode: "worker" }, + daemon: { + health: "starting", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, host: hostFixture(), }; release(); @@ -1810,7 +1838,14 @@ describe("GatewayService: a worker's capabilities in its view", () => { const { harness } = await joinedWith([available]); const restarted = new ScriptedWorkerClient(); restarted.status = { - daemon: { health: "starting", mode: "worker" }, + daemon: { + health: "starting", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, host: hostFixture(), }; diff --git a/src/gateway/test-support.ts b/src/gateway/test-support.ts index b41113559..76e1ecff2 100644 --- a/src/gateway/test-support.ts +++ b/src/gateway/test-support.ts @@ -73,7 +73,14 @@ export function statusFixture(overrides: Partial = {}): RunningSt global: { maxRunning: 4, overLimit: false, reserved: 0, running: 0, warm: 0 }, ios: { ...emptyPlatformCapacity }, }, - daemon: { health: "running", mode: "worker" }, + daemon: { + health: "running", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, devices: [], host: hostFixture(), leases: [], diff --git a/src/http/AGENTS.md b/src/http/AGENTS.md index 679e0b7ab..925c073e8 100644 --- a/src/http/AGENTS.md +++ b/src/http/AGENTS.md @@ -88,6 +88,7 @@ agent-device front and the stream (`/v1/stream`). subscribes to the `EventBus` itself. - `GET /v1/events` — reads `since`, `before` and `limit` through `eventsPageQuery` (contract), refused as 400 before dispatching `events.replay`; `HISTORY_NOT_KEPT` maps to 422 with `oldestTs`, `HISTORY_UNAVAILABLE` to 503 here too. - `GET /v1/stats` — parses `since`/`from`/`to` with `usageWindow` (contract) and `ledger=1`, all refused as 400 before dispatching `usage.get` (the dispatcher answers an agent token 403, `HISTORY_NOT_KEPT` maps to 422 with `oldestTs`, `HISTORY_UNAVAILABLE` to 503). +- `POST /v1/update` (optional body `{version, force}`, strict) → `daemon.update`: 202 when the call began the update (`started`), 200 when it followed one or found nothing to do (`upToDate`). The route declares both (`alsoStatus`). The role check is the dispatcher's; a gateway answers 501. - `GET /v1/leases/{id}/events` — the lease's notices; ends on `lease_lost`. - `POST /v1/leases/{id}/exec` → `device.exec`. The stream opens on `onStarted`; a failure before that is a plain error response. Events: `output`, then `exit` or `error`. diff --git a/src/http/admin-declarations.ts b/src/http/admin-declarations.ts index 67424eade..7257dbde9 100644 --- a/src/http/admin-declarations.ts +++ b/src/http/admin-declarations.ts @@ -1,6 +1,10 @@ import { z } from "@hono/zod-openapi"; -import { eventEnvelopeSchema, OPERATIONS } from "../contract/index.js"; +import { + eventEnvelopeSchema, + OPERATIONS, + updateRequestedVersionSchema, +} from "../contract/index.js"; import { declareRoute, firstValue, platformQuerySchema, sseMessage } from "./routes.js"; const workerParamsSchema = z.object({ id: z.string() }); @@ -51,6 +55,29 @@ export const workerRemoveRoute = declareRoute({ errors: { 409: ["WORKER_CONNECTED"], 501: ["UNSUPPORTED_IN_WORKER_MODE"] }, }); +const updateBodySchema = z + .object({ version: updateRequestedVersionSchema.optional(), force: z.boolean().optional() }) + .strict(); + +export const updateRoute = declareRoute({ + method: "post", + path: "/v1/update", + tag: "System", + summary: "Update this daemon to a Simlock version", + role: "admin", + notes: + "The body is optional: without a version the newest release is installed. The daemon takes no new leases while it waits for its leases to end, installs and restarts. 202 when this call began the update; 200 when it followed an update that was already running, or nothing needed updating (`upToDate`). Worker only: a gateway answers 501 UNSUPPORTED_IN_GATEWAY_MODE.", + request: { optionalJson: updateBodySchema }, + reply: OPERATIONS["daemon.update"].output, + status: 202, + alsoStatus: 200, + errors: { + 409: ["UPDATE_UNSUPPORTED_INSTALL", "UPDATE_SHARED_INSTALL"], + 502: ["UPDATE_RESOLVE_FAILED"], + 501: ["UNSUPPORTED_IN_GATEWAY_MODE"], + }, +}); + export const componentListRoute = declareRoute({ method: "get", path: "/v1/components", @@ -174,6 +201,7 @@ export const componentInstallRoute = declareRoute({ }); export const ADMIN_ROUTES = [ + updateRoute, workerListRoute, workerDrainRoute, workerUndrainRoute, diff --git a/src/http/app.test.ts b/src/http/app.test.ts index f5271678b..976ea1824 100644 --- a/src/http/app.test.ts +++ b/src/http/app.test.ts @@ -2539,6 +2539,96 @@ describe("hardening from review", () => { * dispatcher answers for all four operations is proven in `src/daemon/dispatcher.test.ts`; * `e2e/single-host-fleet.test.ts` drives `GET /v1/workers` and the drain route end to end. */ +describe("POST /v1/update", () => { + const answer = (overrides: Record = {}) => ({ + started: false, + upToDate: false, + resolved: null, + version: "0.3.0", + health: "running", + update: null, + lastUpdateFailure: null, + latestVersion: null, + ...overrides, + }); + const post = (app: App, body?: unknown, headers: Record = operatorAuth) => + Promise.resolve( + app.request("/v1/update", { + headers: { + ...headers, + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + method: "POST", + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }), + ); + + it("answers 202 when the call began the update and 200 when it followed one or nothing was needed, dispatches daemon.update with the body, refuses an agent and a bad body, and passes a gateway's refusal on", async () => { + const { app, dispatcher } = buildHarness(); + // why: `daemon.update`'s own role check runs through the contract's real pipeline; the route adds none. + let reply: Record = answer({ started: true, resolved: "0.4.0" }); + dispatcher.handlers["daemon.update"] = (input, session) => + runDispatch("daemon.update", input, session, { + handlers: { "daemon.update": () => reply }, + }); + + const started = await post(app, { version: "0.4.0", force: true }); + expect({ status: started.status, body: await started.json() }).toEqual({ + status: 202, + body: reply, + }); + expect(dispatcher.calls.map(({ operation, input }) => ({ operation, input }))).toEqual([ + { operation: "daemon.update", input: { version: "0.4.0", force: true } }, + ]); + + for (const [name, following] of [ + [ + "follows a running update", + answer({ + update: { stage: "installing", from: "0.3.0", to: "0.4.0", forced: false, startedAt: 1 }, + }), + ], + ["nothing to do", answer({ upToDate: true, resolved: "0.3.0" })], + ] as const) { + reply = following; + const response = await post(app); + expect({ name, status: response.status }).toEqual({ name, status: 200 }); + expect(await response.json()).toEqual(following); + } + expect(dispatcher.calls.at(-1)?.input).toEqual({}); + + const emptyJson = await app.request("/v1/update", { + headers: { ...operatorAuth, "content-type": "application/json" }, + method: "POST", + body: "", + }); + expect(emptyJson.status).toBe(200); + expect(dispatcher.calls.at(-1)?.input).toEqual({}); + + const asAgent = await post(app, {}, agentAuth); + expect(asAgent.status).toBe(403); + expect(((await asAgent.json()) as { error: { code: string } }).error.code).toBe("FORBIDDEN"); + + const callsBefore = dispatcher.calls.length; + for (const body of [{ version: "latest" }, { query: true }, { force: "yes" }]) { + const refused = await post(app, body); + expect({ body, status: refused.status }).toEqual({ body, status: 400 }); + } + expect(dispatcher.calls).toHaveLength(callsBefore); + + dispatcher.handlers["daemon.update"] = () => { + throw new DispatchError("UNSUPPORTED_IN_GATEWAY_MODE", "daemon.update is refused", { + operation: "daemon.update", + }); + }; + const onGateway = await post(app, {}); + expect(onGateway.status).toBe(501); + expect(await onGateway.json()).toMatchObject({ + error: { code: "UNSUPPORTED_IN_GATEWAY_MODE", operation: "daemon.update" }, + }); + }); +}); + describe("worker routes", () => { function gatewayHarness() { return buildHarness({ config: testConfig({}, "gateway") }); diff --git a/src/http/app.ts b/src/http/app.ts index eeedba721..ad6b2e32b 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -23,6 +23,7 @@ import { eventsRoute, eventsStreamRoute, tokenListRoute, + updateRoute, workerDrainRoute, workerListRoute, workerRemoveRoute, @@ -635,6 +636,15 @@ export function createHttpApp(deps: HttpGatewayDeps): OpenAPIHono & HttpApp c.json(await deps.dispatch("worker.list", {}, buildHttpSession(c.get("identity"))), 200), ); + app.openapi({ ...updateRoute, middleware: [agentAuth, emptyBodyAsNone] }, async (c) => { + const update = await deps.dispatch( + "daemon.update", + c.req.valid("json"), + buildHttpSession(c.get("identity")), + ); + return c.json(update, update.started ? 202 : 200); + }); + app.openapi({ ...workerDrainRoute, middleware: [agentAuth] }, async (c) => c.json( await deps.dispatch( diff --git a/src/http/core-routes.test.ts b/src/http/core-routes.test.ts index 2be2f2c7a..fe7619101 100644 --- a/src/http/core-routes.test.ts +++ b/src/http/core-routes.test.ts @@ -98,7 +98,7 @@ describe("the routes createHttpApp registers", () => { ).toEqual(["declared but not registered: GET /v1/status"]); }); - it("CORE_ROUTE_SEGMENTS names the eleven core first segments and uplink", () => { + it("CORE_ROUTE_SEGMENTS names the twelve core first segments and uplink", () => { expect([...CORE_ROUTE_SEGMENTS].sort()).toEqual( [ "catalog", @@ -111,6 +111,7 @@ describe("the routes createHttpApp registers", () => { "leases", "stats", "status", + "update", "tokens", "uplink", "workers", diff --git a/src/http/routes.ts b/src/http/routes.ts index a4d486ec9..26c48036d 100644 --- a/src/http/routes.ts +++ b/src/http/routes.ts @@ -15,7 +15,7 @@ type RouteTag = | "Tokens" | "Events"; type SuccessStatus = 200 | 201 | 202 | 204; -type ErrorStatus = 400 | 401 | 403 | 404 | 409 | 415 | 422 | 500 | 501 | 503 | 504; +type ErrorStatus = 400 | 401 | 403 | 404 | 409 | 415 | 422 | 500 | 501 | 502 | 503 | 504; type JsonContent = { "application/json": { schema: Schema } }; type ErrorBody = { error: { code: string; message: string } }; type ErrorResponse = { @@ -92,6 +92,7 @@ interface Declaration< Reply extends z.ZodType | undefined, Stream extends z.ZodType | undefined, Success extends SuccessStatus, + Also extends SuccessStatus, Own extends Partial>, > { readonly method: "get" | "post" | "delete"; @@ -105,6 +106,8 @@ interface Declaration< readonly stream?: Stream; readonly replyHeaders?: z.ZodType; readonly status?: Success; + // why: a route that answers 202 when it began the work and 200 when it only followed or found nothing to do declares both. + readonly alsoStatus?: Also; readonly errors?: Own; readonly errorExtras?: ErrorExtras; } @@ -182,8 +185,9 @@ export function declareRoute< const Reply extends z.ZodType | undefined = undefined, const Stream extends z.ZodType | undefined = undefined, const Success extends SuccessStatus = 200, + const Also extends SuccessStatus = never, const Own extends Partial> = {}, ->(declaration: Declaration) { +>(declaration: Declaration) { const request: RequestSchemas = declaration.request ?? {}; const success = declaration.status ?? 200; const body = request.json ?? request.optionalJson; @@ -193,6 +197,9 @@ export function declareRoute< ...successContent(declaration), ...(declaration.replyHeaders === undefined ? {} : { headers: declaration.replyHeaders }), }, + ...(declaration.alsoStatus === undefined + ? {} + : { [declaration.alsoStatus]: { description: "Success", ...successContent(declaration) } }), ...declaredErrors( declaration.role, request, @@ -202,7 +209,7 @@ export function declareRoute< } as DeclaredResponses< Reply, Stream, - Success, + Success | Also, (keyof Own & ErrorStatus) | BaseStatuses >; return createRoute({ diff --git a/src/leasing/AGENTS.md b/src/leasing/AGENTS.md index c438536cf..435818b58 100644 --- a/src/leasing/AGENTS.md +++ b/src/leasing/AGENTS.md @@ -25,13 +25,17 @@ Flows: [device selection](../../docs/internal/flows/device-selection.md), [start | `lease-ports.ts` | `LeaseCommands`, `QueueControl`, `LeaseEndParticipant` | | `testing.ts` | Builds core + leasing for other modules' tests | +## The update hold + +`holdForUpdate()` (on the `Leasing` handle, typed `UpdateHold`) closes admission and refuses every request not provisioning right now, whatever its state (`new`, queued, re-queued, or retrying after a failed provision), with `DaemonUpdatingError` (`DAEMON_UPDATING`, `lease.rejected` reason `updating`); `releaseUpdateHold()` reopens it. It is apart from `beginMaintenance` so the warm pool and reclaim go on: nuke stops them, an update does not. Renew and release never go through admission, so held leases work as usual. A request already provisioning when the hold begins is granted; the check is `#refuseUnderHold`, before every provision starts. + ## How it works Lock: one `SerializedDecision` per core, shared with core's reclaim, warm pool and registry writers. Read-decide-commit runs under it; driver I/O never does. 1. **Admit** (lock) — `LeaseAcquisitionCoordinator.request`: replay by - idempotency key + requester → refuse under maintenance → refuse a requester + idempotency key + requester → refuse under the update hold (`DaemonUpdatingError`, `lease.rejected` reason `updating`) → refuse under maintenance → refuse a requester at `lease.maxPerRequester` (1) or with a pending request → refuse a taken `leaseId` → `LeaseRequestBook.admit` stores the request, then queues it. Emits `lease.requested`. diff --git a/src/leasing/create-leasing.test.ts b/src/leasing/create-leasing.test.ts index 64d359078..745ee7e9a 100644 --- a/src/leasing/create-leasing.test.ts +++ b/src/leasing/create-leasing.test.ts @@ -32,7 +32,11 @@ import { MemoryFilesystem, } from "../ports/index.js"; import { createLeasing } from "./index.js"; -import { NoCapacityError, ProvisionFailedError } from "./lease-acquisition-coordinator.js"; +import { + DaemonUpdatingError, + NoCapacityError, + ProvisionFailedError, +} from "./lease-acquisition-coordinator.js"; import { LeaseHealthMonitor } from "./lease-health-monitor.js"; import { createTestEngine } from "./testing.js"; import { QueueTimeoutError, RequestCancelledError } from "./wait-queue.js"; @@ -2641,6 +2645,16 @@ describe("createLeasing: lease.rejected names its request", () => { await harness.engine.nuke(false); return { harness, requestId: await admittedId(harness, "next"), requester: "next" }; }, + updating: async () => { + const harness = await createHarness(); + await harness.engine.request(request, holder); + void harness.engine + .request(request, { ownerId: "next", requesterId: "next" }) + .catch(() => undefined); + await flush(); + await harness.engine.leasing.holdForUpdate(); + return { harness, requestId: await admittedId(harness, "next"), requester: "next" }; + }, cancelled: async () => { const harness = await createHarness(); await harness.engine.request(request, holder); @@ -4563,6 +4577,106 @@ describe("createLeasing: a waiting request says why it is not placed (#636)", () ]); }); + it("refuses a queued request when the update hold begins and still grants the one that is provisioning", async () => { + const clock = new FakeClock(1_000); + const driver = new FakeDriver({ + availableOsVersions: ["26.5"], + clock, + latencyMs: { provision: 50 }, + platform: "ios", + }); + const harness = await createHarness({ driver, limits: runningLimit }); + const provisioning = harness.engine + .request(request, asking("provisioning")) + .catch((error: unknown) => error); + await flush(); + const queued = harness.engine + .request(request, asking("queued")) + .catch((error: unknown) => error); + await flush(); + expect(listing(harness)).toEqual([ + { reason: undefined, requesterId: "provisioning", stage: "starting" }, + { reason: "platform-running-limit", requesterId: "queued", stage: "queued" }, + ]); + + await harness.engine.leasing.holdForUpdate(); + clock.advance(50); + await flush(); + + expect(await queued).toBeInstanceOf(DaemonUpdatingError); + expect(await provisioning, "the request that was provisioning").toMatchObject({ + lease: { id: expect.any(String) }, + }); + expect( + harness.bus + .replay() + .filter((event) => event.event === "lease.rejected") + .map((event) => event.payload as { requester: string; reason: string }) + .map(({ requester, reason }) => [requester, reason]), + ).toEqual([["queued", "updating"]]); + }); + + it("refuses the retry of a provision that fails while the update hold is on, with DAEMON_UPDATING and no second provision", async () => { + const clock = new FakeClock(1_000); + const driver = new FakeDriver({ + availableOsVersions: ["26.5"], + clock, + latencyMs: { provision: 50 }, + platform: "ios", + }); + driver.failOn("provision", 1, new DriverCrashError("simulator exited")); + const harness = await createHarness({ driver, limits: runningLimit }); + const failing = harness.engine + .request(request, asking("failing")) + .catch((error: unknown) => error); + await flush(); + + await harness.engine.leasing.holdForUpdate(); + clock.advance(50); + await flush(); + clock.advance(50); + await flush(); + + expect(await failing).toBeInstanceOf(DaemonUpdatingError); + expect(driver.calls.filter((call) => call.operation === "provision")).toHaveLength(1); + expect( + harness.bus + .replay() + .filter((event) => event.event === "lease.rejected") + .map((event) => event.payload as { requester: string; reason: string }) + .map(({ requester, reason }) => [requester, reason]), + ).toEqual([["failing", "updating"]]); + }); + + it("refuses a request that is still resolving its spec when the update hold begins", async () => { + const clock = new FakeClock(1_000); + const driver = new FakeDriver({ + availableOsVersions: ["26.5"], + clock, + latencyMs: { resolveSpec: 50 }, + platform: "ios", + }); + const harness = await createHarness({ driver, limits: runningLimit }); + const resolving = harness.engine + .request(request, asking("resolving")) + .catch((error: unknown) => error); + await flush(); + + await harness.engine.leasing.holdForUpdate(); + clock.advance(50); + await flush(); + + expect(await resolving, "the request that was resolving").toBeInstanceOf(DaemonUpdatingError); + expect(driver.calls.map((call) => call.operation)).not.toContain("provision"); + expect( + harness.bus + .replay() + .filter((event) => event.event === "lease.rejected") + .map((event) => event.payload as { requester: string; reason: string }) + .map(({ requester, reason }) => [requester, reason]), + ).toEqual([["resolving", "updating"]]); + }); + it("lists a request that waits for a device the warm pool is booting as waiting-for-device", async () => { const clock = new FakeClock(1_000); const driver = new FakeDriver({ diff --git a/src/leasing/create-leasing.ts b/src/leasing/create-leasing.ts index 899c45bfb..0f541a8b6 100644 --- a/src/leasing/create-leasing.ts +++ b/src/leasing/create-leasing.ts @@ -59,7 +59,12 @@ export interface LeasingOptions { readonly leaseEndParticipants?: readonly LeaseEndParticipant[] | undefined; } -export interface Leasing extends LeaseCommands, QueueControl, DeviceModeReader { +export interface UpdateHold { + holdForUpdate(): Promise; + releaseUpdateHold(): Promise; +} + +export interface Leasing extends LeaseCommands, QueueControl, DeviceModeReader, UpdateHold { /** * why: building leasing does not connect them, so building one over a core never re-points that core's * ports. @@ -255,6 +260,8 @@ export function createLeasing(options: LeasingOptions): Leasing { expire: async (leaseId: string) => { await releaseCoordinator.expire(leaseId); }, + holdForUpdate: async () => acquisition.holdForUpdate(), + releaseUpdateHold: async () => acquisition.releaseUpdateHold(), settleRequests: async () => startup.settleRequests(), reconcile: async (read: StartupRead) => { await startup.reconcile(read); diff --git a/src/leasing/index.ts b/src/leasing/index.ts index 05b14222a..d3d5a53c0 100644 --- a/src/leasing/index.ts +++ b/src/leasing/index.ts @@ -1,6 +1,10 @@ -export { createLeasing } from "./create-leasing.js"; +export { createLeasing, type UpdateHold } from "./create-leasing.js"; export { capacityReasonOf } from "./acquisition-planner.js"; -export { NoCapacityError, ProvisionFailedError } from "./lease-acquisition-coordinator.js"; +export { + DaemonUpdatingError, + NoCapacityError, + ProvisionFailedError, +} from "./lease-acquisition-coordinator.js"; export { LeaseHealthMonitor } from "./lease-health-monitor.js"; export { type PhysicalDeviceWatcher } from "./physical-device-watcher.js"; export { diff --git a/src/leasing/lease-acquisition-coordinator.ts b/src/leasing/lease-acquisition-coordinator.ts index 378b23b9f..ed186f31c 100644 --- a/src/leasing/lease-acquisition-coordinator.ts +++ b/src/leasing/lease-acquisition-coordinator.ts @@ -95,6 +95,14 @@ export class ProvisionFailedError extends Error { } } +/** why: mapped to `DAEMON_UPDATING`; the message is what a refused agent reads. */ +export class DaemonUpdatingError extends Error { + constructor() { + super("this daemon is updating and takes no new leases"); + this.name = "DaemonUpdatingError"; + } +} + class NukeCancelledError extends Error { constructor() { super("Request cancelled by nuke"); @@ -204,6 +212,8 @@ export class LeaseAcquisitionCoordinator implements AcquisitionMaintenance { readonly #activeWorkflows = new Set>(); readonly #driving = new WeakSet(); #admissionClosed = false; + // why: apart from #admissionClosed, which also stops the warm pool; an update refuses leases and leaves the pool running. + #updateHold = false; #maintenanceDepth = 0; readonly #logger: Logger; @@ -249,6 +259,10 @@ export class LeaseAcquisitionCoordinator implements AcquisitionMaintenance { const replay = this.options.requests.replay(request, options); if (replay !== undefined) return { replay }; const requestId = newLeaseRequestId(this.options.idGenerator); + if (this.#updateHold) { + this.#refuseForUpdate(requestId, request, options); + throw new DaemonUpdatingError(); + } if (this.#admissionClosed) { emitLeaseRefusal( this.options.eventBus, @@ -384,6 +398,52 @@ export class LeaseAcquisitionCoordinator implements AcquisitionMaintenance { } } + async holdForUpdate(): Promise { + await this.options.decisions.run(async () => { + this.#updateHold = true; + // why: a request already provisioning is granted (#825 Decisions), so only the ones not yet provisioning, `new` (still resolving its spec) or queued, are refused. + for (const waiter of this.options.queue.pending() as readonly AcquisitionWaiter[]) { + if (waiter.state !== "queued" && waiter.state !== "new") continue; + if (this.options.queue.reject(waiter, new DaemonUpdatingError())) { + this.#refuseForUpdate(waiter.id, waiter.request, waiter.options); + } + } + }); + } + + // why: every waiter passes `#nextPlan` before a provision starts, in whatever state `markNew` or `#defer` left it, so the update hold refuses here (#825 Decisions). + #refuseUnderHold(waiter: AcquisitionWaiter): boolean { + if (!this.#updateHold) return false; + if (this.options.queue.reject(waiter, new DaemonUpdatingError())) { + this.#refuseForUpdate(waiter.id, waiter.request, waiter.options); + } + return true; + } + + async releaseUpdateHold(): Promise { + await this.options.decisions.run(async () => { + this.#updateHold = false; + }); + } + + #refuseForUpdate( + requestId: string, + request: DeviceRequest, + options: Pick, + ): void { + emitLeaseRefusal( + this.options.eventBus, + { + requestId, + requester: options.requesterId, + requestSpec: request, + reason: "updating", + fleetRequestId: options.fleetRequestId, + }, + "lease-acquisition-coordinator", + ); + } + /** Reopens acquisition admission after an administrative maintenance operation. */ async endMaintenance(): Promise { await this.options.decisions.run(async () => { @@ -928,6 +988,7 @@ export class LeaseAcquisitionCoordinator implements AcquisitionMaintenance { if (waiter.state === "rejected" || waiter.state === "granted" || waiter.spec === undefined) { return undefined; } + if (this.#refuseUnderHold(waiter)) return undefined; const head = this.options.queue.head; if (head !== undefined && head !== waiter) { const reason = "behind-earlier-request"; diff --git a/src/ports/index.ts b/src/ports/index.ts index 9b57982ff..7e72e9d46 100644 --- a/src/ports/index.ts +++ b/src/ports/index.ts @@ -162,14 +162,8 @@ export type { export { FetchWebhookPoster, type WebhookPoster } from "./webhook-poster.js"; export { FakeNpmCli, NodeNpmCli, type NpmCli, type NpmOutcome } from "./npm-cli.js"; export { - // fallow-ignore-next-line unused-export -- the launcher's callers arrive with the daemon's update call (#836). FakeUpdaterLauncher, - // fallow-ignore-next-line unused-type -- same. type LaunchedUpdater, - // fallow-ignore-next-line unused-export -- same. NodeUpdaterLauncher, - // fallow-ignore-next-line unused-type -- same. - type UpdaterLaunch, - // fallow-ignore-next-line unused-type -- same. type UpdaterLauncher, } from "./updater-launcher.js"; diff --git a/src/ports/npm-cli.ts b/src/ports/npm-cli.ts index 9e7424a32..3c084d527 100644 --- a/src/ports/npm-cli.ts +++ b/src/ports/npm-cli.ts @@ -30,7 +30,6 @@ export class FakeNpmCli implements NpmCli { }; } - // fallow-ignore-next-line unused-class-member -- the daemon's update call (#836) resolves a version with it. async view(spec: string): Promise { return this.#answer("view", spec); } diff --git a/src/ports/updater-launcher.test.ts b/src/ports/updater-launcher.test.ts index 17fcaa6dd..71e8a9fc7 100644 --- a/src/ports/updater-launcher.test.ts +++ b/src/ports/updater-launcher.test.ts @@ -52,4 +52,33 @@ process.exit(3);`, await expect(updater.exited).resolves.toBe(3); await expect(readFile(logPath, "utf8")).resolves.toBe("earlier\nto the log\n"); }); + + it("does not let the updater's stdout pipe keep this process alive, because the updater waits for this process to exit", async () => { + const tree = await realpath(await mkdtemp(join(tmpdir(), "simlock-updater-"))); + trees.push(tree); + const entry = join(tree, "main.js"); + await writeFile(entry, `console.log(process.pid);\nsetTimeout(() => undefined, 60_000);`); + + const updater = await new NodeUpdaterLauncher().launch({ + entry, + from: "0.3.0", + to: "0.4.0", + role: "worker", + pid: 4242, + simlockHome: "/home/.simlock", + logPath: join(tree, "daemon.log"), + }); + const pid = Number( + ((await updater.lines[Symbol.asyncIterator]().next()).value as string).trim(), + ); + try { + // why: Node offers no public way to ask whether a socket holds the event loop open, so this reads the handle behind the pipe. + const pipe = (updater.lines as unknown as { input: { _handle: { hasRef(): boolean } } }) + .input; + expect(pipe._handle.hasRef()).toBe(false); + } finally { + process.kill(pid); + await updater.exited; + } + }); }); diff --git a/src/ports/updater-launcher.ts b/src/ports/updater-launcher.ts index 9f78de974..e76cbcaab 100644 --- a/src/ports/updater-launcher.ts +++ b/src/ports/updater-launcher.ts @@ -1,6 +1,7 @@ import { spawn } from "node:child_process"; import { mkdir, open } from "node:fs/promises"; import { dirname } from "node:path"; +import { Socket } from "node:net"; import { createInterface } from "node:readline"; import type { DaemonRole } from "./paths.js"; @@ -56,6 +57,8 @@ export class NodeUpdaterLauncher implements UpdaterLauncher { }); child.unref(); if (child.stdout === null) throw new Error("the updater has no stdout pipe"); + // why: the updater waits for this process to exit, so its pipe must not keep the event loop alive. + if (child.stdout instanceof Socket) child.stdout.unref(); return { lines: createInterface({ input: child.stdout }), exited }; } finally { await log.close(); @@ -63,7 +66,6 @@ export class NodeUpdaterLauncher implements UpdaterLauncher { } } -// fallow-ignore-next-line unused-export -- the daemon's update call (#836) tests against it. export class FakeUpdaterLauncher implements UpdaterLauncher { readonly launches: UpdaterLaunch[] = []; readonly #lines: string[] = []; diff --git a/src/simlock-client/AGENTS.md b/src/simlock-client/AGENTS.md index 82dffb688..03da9b2c3 100644 --- a/src/simlock-client/AGENTS.md +++ b/src/simlock-client/AGENTS.md @@ -6,6 +6,6 @@ Uses: contract, daemon-protocol, link (byte channels), ports. Never imports: cor - `SimlockWire`: newline JSON frames, request ids, push routing, `DAEMON_CONNECTION_LOST` on close. A repeated `lease-lost` for one lease is delivered once. -- `hello` first; a version mismatch yields a client whose every call rejects with it, but `stopDaemon` works. +- `hello` first; a version mismatch yields a client whose every call rejects with it, but `stopDaemon` and `updateDaemon` (the two frozen calls) work. - Every type in `types.ts` is `z.infer`red from the contract; `no-core-leak.test.ts` checks the emitted declarations. - Never reconnects, retries or renews: frontends own that policy. `test-support.ts`: `ScriptedConnection`. diff --git a/src/simlock-client/client.test.ts b/src/simlock-client/client.test.ts index f7c195a01..d1402351c 100644 --- a/src/simlock-client/client.test.ts +++ b/src/simlock-client/client.test.ts @@ -400,7 +400,14 @@ describe("connectSimlock: handshake", () => { warm: 0, }, }, - daemon: { health: "running", mode: "worker" }, + daemon: { + health: "running", + mode: "worker", + version: "0.0.0-test", + update: null, + lastUpdateFailure: null, + latestVersion: null, + }, devices: [], host: { arch: "arm64", os: "macOS", osVersion: "15.5", tools: [] }, leases: [], diff --git a/src/simlock-client/client.ts b/src/simlock-client/client.ts index e07a920a6..b15c895f3 100644 --- a/src/simlock-client/client.ts +++ b/src/simlock-client/client.ts @@ -41,6 +41,8 @@ import type { ComponentRemoveInput, ComponentRemoveOutput, DaemonStopOutput, + DaemonUpdateInput, + DaemonUpdateOutput, DeviceAddInput, DeviceAddOutput, DeviceRecoveredPush, @@ -107,6 +109,8 @@ export type { ComponentRemoveInput, ComponentRemoveOutput, DaemonStopOutput, + DaemonUpdateInput, + DaemonUpdateOutput, DeviceAddInput, DeviceAddOutput, DeviceRecoveredPush, @@ -256,6 +260,7 @@ export interface SimlockAdminClient extends SimlockClient { * purely as an API-surface choice matching the ADR's operation matrix (the daemon itself * does not gate this operation by role at all -- see the caveat in the PR report). */ stopDaemon(): Promise; + updateDaemon(input?: DaemonUpdateInput): Promise; replayEvents(input?: EventsReplayInput): Promise; usage(window: UsageGetInput): Promise; subscribeEvents(listener: (event: EventPush) => void): Promise<() => Promise>; @@ -383,21 +388,12 @@ export async function connectSimlockClient( } /** - * ADR §6's escape hatch, on the client side: everything but `stopDaemon()` and `close()` - * rejects with the captured `PROTOCOL_VERSION_UNSUPPORTED` error (never sent to the daemon -- - * every other operation requires a completed handshake this connection never got); `close()` - * still works so a caller that decides not to stop the daemon can tidy up. `stopDaemon()` calls - * straight through the wire: the daemon accepts `daemon.stop` "at any protocol version it has - * ever spoken" (ADR §6), ahead of its own mismatch gate, specifically so this is reachable. - * - * `role`/`principal` cannot be the connection's real resolved values -- the daemon resolves - * them before the mismatch check but the failed `hello` never reports them back (see the PR - * report's weak-spots list) -- so `role` reflects which entry point the caller used - * (`connectSimlock` vs. `connectSimlockAdmin`) rather than a verified fact, and `principal` is - * whatever the caller supplied (or `""` when it supplied none). Neither is load-bearing here: - * the only operation this client permits (`daemon.stop`) is gated by the daemon's own role - * check on the credential it already verified, not by anything this object reports about - * itself. + * why: ADR §6's escape hatch on the client side. A mismatch leaves the connection open so an admin can still send the + * two frozen calls, `stopDaemon()` and `updateDaemon()` (the daemon answers them at any protocol version, ahead of its + * mismatch gate); everything else rejects with the captured `PROTOCOL_VERSION_UNSUPPORTED` error without being sent, + * and `close()` still works. `role` reflects which entry point the caller used and `principal` what it supplied, + * because the daemon resolves them but the failed `hello` never reports them back; the daemon's own role check on the + * credential it already verified is what gates the two calls. */ function buildDegradedClient( wire: SimlockWire, @@ -439,24 +435,8 @@ function buildDegradedClient( runCleanup: () => rejected(), runNuke: () => rejected(), getConfig: () => rejected(), - stopDaemon: () => - wire.call("daemon.stop", {}).then( - (payload) => { - const result = OPERATIONS["daemon.stop"].output.safeParse(payload); - if (!result.success) { - throw new SimlockError( - "BAD_FRAME", - "protocol", - "Daemon's daemon.stop response did not match the contract output schema", - {}, - ); - } - return result.data; - }, - (callError: unknown) => { - throw toSimlockError(callError); - }, - ), + stopDaemon: () => frozenCall(wire, "daemon.stop", {}), + updateDaemon: (input = {}) => frozenCall(wire, "daemon.update", input), replayEvents: () => rejected(), usage: () => rejected(), subscribeEvents: () => rejected(), @@ -480,6 +460,41 @@ function buildDegradedClient( return client; } +// why: the two frozen calls (`daemon.stop`, `daemon.update`) are the only ones a degraded client sends, and the daemon answers them at any protocol version. +function frozenCall( + wire: SimlockWire, + operation: "daemon.stop", + input: Record, +): Promise; +function frozenCall( + wire: SimlockWire, + operation: "daemon.update", + input: DaemonUpdateInput, +): Promise; +function frozenCall( + wire: SimlockWire, + operation: "daemon.stop" | "daemon.update", + input: unknown, +): Promise { + return wire.call(operation, input).then( + (payload) => { + const result = OPERATIONS[operation].output.safeParse(payload); + if (!result.success) { + throw new SimlockError( + "BAD_FRAME", + "protocol", + `Daemon's ${operation} response did not match the contract output schema`, + {}, + ); + } + return result.data; + }, + (callError: unknown) => { + throw toSimlockError(callError); + }, + ); +} + function isLinkConnection(connection: IpcConnection): connection is LinkConnection { return typeof (connection as Partial).openChannel === "function"; } @@ -661,6 +676,10 @@ class SimlockClientImpl { return this.#call("daemon.stop", {}); } + updateDaemon(input: DaemonUpdateInput = {}): Promise { + return this.#call("daemon.update", input); + } + replayEvents(input: EventsReplayInput = {}): Promise { return this.#call("events.replay", input); } diff --git a/src/simlock-client/types.ts b/src/simlock-client/types.ts index c0b45c13a..f91b733e5 100644 --- a/src/simlock-client/types.ts +++ b/src/simlock-client/types.ts @@ -53,6 +53,8 @@ export type NukeRunInput = OpInput<"nuke.run">; export type NukeReport = OpOutput<"nuke.run">; export type SimlockConfig = OpOutput<"config.get">; export type DaemonStopOutput = OpOutput<"daemon.stop">; +export type DaemonUpdateInput = OpInput<"daemon.update">; +export type DaemonUpdateOutput = OpOutput<"daemon.update">; export type EventsReplayInput = OpInput<"events.replay">; export type EventsReplayOutput = OpOutput<"events.replay">; export type UsageGetInput = OpInput<"usage.get">; diff --git a/src/updater/AGENTS.md b/src/updater/AGENTS.md index c591bfe9f..bf1bb23f8 100644 --- a/src/updater/AGENTS.md +++ b/src/updater/AGENTS.md @@ -1,9 +1,9 @@ # Updater Does: the process a daemon starts to install a Simlock version with npm and restart the role's daemon; it rolls back a version that does not start. -Entry: `index.ts` → `runUpdater(options)` (returns the exit code), `checkInstall`, `parseUpdaterArgs`, `DaemonProbe`, `createDaemonProbe` (`probe.ts`, the admin-client probe `main.ts` uses). `main.ts` is the process entry: `node /dist/updater/main.js --from --to --pid [--gateway]`, env `SIMLOCK_HOME`. +Entry: `index.ts` → `runUpdater(options)` (returns the exit code), `checkInstall`, `parseUpdaterArgs`, `DaemonProbe`, `createDaemonProbe` (`probe.ts`, the admin-client probe `main.ts` uses; its `state` is `daemon.update {query}`, a call every protocol version accepts), `socketHeld` (the one check that a socket still has a daemon behind it, also the daemon's shared-install check). `main.ts` is the process entry: `node /dist/updater/main.js --from --to --pid [--gateway]`, env `SIMLOCK_HOME`. Uses: ports, admin (in `probe.ts` only). Never imports: daemon, cli, core. -Started by: a daemon through `UpdaterLauncher` (no caller yet). +Started by: a daemon through `UpdaterLauncher` (`src/daemon/update-coordinator.ts`, which also imports `checkInstall` and `socketHeld`). ## Steps diff --git a/src/updater/index.ts b/src/updater/index.ts index 451f2af4c..0aa6b25c4 100644 --- a/src/updater/index.ts +++ b/src/updater/index.ts @@ -10,7 +10,7 @@ import type { ProcessSupervisor, } from "../ports/index.js"; -export { createDaemonProbe } from "./probe.js"; +export { createDaemonProbe, socketHeld } from "./probe.js"; export interface DaemonProbe { stop(): Promise; diff --git a/src/updater/probe.ts b/src/updater/probe.ts index b84be8110..1c44b482d 100644 --- a/src/updater/probe.ts +++ b/src/updater/probe.ts @@ -14,6 +14,17 @@ export interface DaemonProbeOptions { readonly ipc?: IpcConnector; } +// why: a socket file outlives its daemon and a stopping daemon accepts then closes connections, so only a refused or missing endpoint proves no daemon holds it; any other failure cannot prove it. +export async function socketHeld(ipc: IpcConnector, endpoint: string): Promise { + try { + await (await ipc.connect(endpoint)).close(); + return true; + } catch (error: unknown) { + if (!(error instanceof IpcError)) return true; + return error.code !== "connection-refused" && error.code !== "endpoint-not-found"; + } +} + // why: the admin client is the only way the updater talks to the daemon, as any CLI does. export function createDaemonProbe(options: DaemonProbeOptions): DaemonProbe { const { dataDirectory, filesystem } = options; @@ -38,21 +49,12 @@ export function createDaemonProbe(options: DaemonProbeOptions): DaemonProbe { use(async (client) => { await client.stopDaemon(); }), - // why: a socket file outlives its daemon and a stopping daemon accepts then closes connections, so only a refused or missing endpoint proves no daemon holds it; any other failure cannot prove it. - listening: async () => { - try { - await (await ipc.connect(endpoint)).close(); - return true; - } catch (error: unknown) { - if (!(error instanceof IpcError)) return true; - return error.code !== "connection-refused" && error.code !== "endpoint-not-found"; - } - }, + listening: () => socketHeld(ipc, endpoint), state: async () => { try { return await use(async (client) => { - const status = await client.getStatus(); - return { health: status.daemon.health, version: client.daemonVersion }; + const answer = await client.updateDaemon({ query: true }); + return { health: answer.health, version: answer.version }; }); } catch { return undefined; diff --git a/website/src/content/docs/docs/reference/cli.mdx b/website/src/content/docs/docs/reference/cli.mdx index 098f93d8e..9c3595989 100644 --- a/website/src/content/docs/docs/reference/cli.mdx +++ b/website/src/content/docs/docs/reference/cli.mdx @@ -21,6 +21,7 @@ This page lists what each command accepts. For what a command is for and when to | [`simlock list`](#simlock-list) | List devices, leases, cleanup rules or waiting requests | [Monitoring](/simlock/docs/worker/monitoring) | | [`simlock events`](#simlock-events) | Print business events | [Events reference](/simlock/docs/reference/events) | | [`simlock stats`](#simlock-stats) | Show usage figures for a time window | [Monitoring](/simlock/docs/worker/monitoring) | +| [`simlock update`](#simlock-update) | Install the newest Simlock (or a named version) and restart the daemon | [Running the daemon](/simlock/docs/worker/running-the-daemon) | | [`simlock whoami`](#simlock-whoami) | Show who the daemon thinks you are | [Access control](/simlock/docs/gateway/access-control) | | [`simlock daemon`](#simlock-daemon) | Start, stop and inspect the daemon | [Running the daemon](/simlock/docs/worker/running-the-daemon) | | [`simlock config`](#simlock-config) | Read and change the daemon's configuration | [Configuration](/simlock/docs/worker/configuration) | @@ -204,7 +205,9 @@ Takes no flags other than the [global flags](#global-flags). Stdout carries only ## simlock status -Show the daemon's health and mode, its console address, capacity, devices, leases and queue depth. Works for any caller. See [Monitoring](/simlock/docs/worker/monitoring). +Show the daemon's health and mode, the Simlock version it runs, its console address, capacity, devices, leases and queue depth. Works for any caller. See [Monitoring](/simlock/docs/worker/monitoring). + +While an update runs, the second line below `Daemon:` reads `Updating to : waiting for 2 leases` (then `installing`, `restarting`). After an update that failed, `Last update failed (): ` stays until the next update starts or the daemon restarts without one. ```text simlock status [--json] @@ -356,6 +359,39 @@ simlock cleanup [--dry-run] [--rule ] A gateway refuses `cleanup` with `UNSUPPORTED_IN_GATEWAY_MODE` (exit 2). +## simlock update + +Install the newest Simlock, or a named version, on this machine and restart its daemon once the leases it holds have ended. Needs the admin role. See [Running the daemon](/simlock/docs/worker/running-the-daemon#update-simlock). + +```text +simlock update [--version ] [--force] [--json] +``` + +| Flag | Meaning | Default | +| --- | --- | --- | +| `--version ` | Install this exact version (`0.4.0`, `0.4.0-rc.1`), older ones included. A tag or a range is a usage error. | The newest release | +| `--force` | Do not wait for the leases to end. They survive the restart. | Off | +| `--json` | Print no progress, only the daemon's final answer as one JSON line. | Off | + +While the daemon waits, it takes no new leases: a request gets `DAEMON_UPDATING`. Leases already held renew and release as usual. The command follows the update and prints each stage. `Ctrl-C` stops following and leaves the update running; `simlock update --force` from another shell stops the wait. + +```text +$ simlock update +Updating simlock 0.3.0 → 0.4.0 +Waiting for 2 leases to end. Ctrl-C stops following; simlock update --force stops waiting. +Installing simlock@0.4.0 +Restarting the daemon +simlock 0.4.0 is running +``` + +- Already on the newest release: `simlock 0.4.0 is already the latest`, and nothing restarts. A prerelease newer than the newest release is not downgraded; name the version to install another. +- The install fails: `Update failed (installing): `, exit 1. The old daemon keeps running and takes leases again. +- The new version does not start: the old version is installed again and started. `Update failed (restarting): . Rolled back to .`, exit 1. +- The daemon does not answer within 15 minutes after it restarts: `The daemon did not come back; run simlock status.`, exit 1. +- Simlock must be installed with `npm i -g simlock`. Otherwise the command stops with `UPDATE_UNSUPPORTED_INSTALL`. A worker and a gateway of one user share the install, so while the other runs it stops with `UPDATE_SHARED_INSTALL`. npm failing to find the version is `UPDATE_RESOLVE_FAILED`. See [Errors](/simlock/docs/reference/errors#updating-simlock). + +`POST /v1/update` does the same over HTTP. A gateway refuses `update` with `UNSUPPORTED_IN_GATEWAY_MODE` (exit 2). + ## simlock nuke Emergency reset: release every lease, stop the simulators and emulators Simlock started, and clear the queue. Needs the admin role. See [Troubleshooting](/simlock/docs/worker/troubleshooting). diff --git a/website/src/content/docs/docs/reference/errors.mdx b/website/src/content/docs/docs/reference/errors.mdx index 407a30496..db9989d8d 100644 --- a/website/src/content/docs/docs/reference/errors.mdx +++ b/website/src/content/docs/docs/reference/errors.mdx @@ -22,8 +22,8 @@ Branch on `code`, not on the message: the message wording can change, the code d | Exit | Error codes | Meaning | | --- | --- | --- | | 0 | — | Success. For a `simlock lease` that stayed running: the lease ended because you asked. | -| 1 | `INTERNAL`, `PROVISION_FAILED`, `UNKNOWN_LEASE`, `FORBIDDEN`, `ADMIN_AUTHENTICATION_FAILED`, `DAEMON_STARTUP_FAILED`, `DAEMON_CONNECTION_LOST`, `DAEMON_STOPPING`, `HANDSHAKE_REQUIRED`, `PROTOCOL_VERSION_UNSUPPORTED`, `WORKER_UNREACHABLE`, `RELAY_TARGET_MISSING`, `ALERT_SEND_FAILED`, `HISTORY_UNAVAILABLE`, `DOCTOR_UNAVAILABLE`, `NUKE_UNAVAILABLE`, `UNKNOWN_DAEMON_ERROR`, `CANCELLED` | The command failed. Read the code and the message. | -| 2 | `USAGE`, `BAD_REQUEST`, `BAD_FRAME`, `UNKNOWN_REQUEST`, `IDEMPOTENCY_CONFLICT`, `PASSTHROUGH_REFUSED`, `UNKNOWN_PASSTHROUGH_TOOL`, `GATEWAY_NOT_PRESENT`, `UNSUPPORTED_IN_GATEWAY_MODE`, `UNSUPPORTED_IN_WORKER_MODE`, `WORKER_CONNECTED` | The request was wrong. Running it again unchanged fails again. | +| 1 | `INTERNAL`, `PROVISION_FAILED`, `UNKNOWN_LEASE`, `FORBIDDEN`, `ADMIN_AUTHENTICATION_FAILED`, `DAEMON_STARTUP_FAILED`, `DAEMON_CONNECTION_LOST`, `DAEMON_STOPPING`, `DAEMON_UPDATING`, `HANDSHAKE_REQUIRED`, `PROTOCOL_VERSION_UNSUPPORTED`, `WORKER_UNREACHABLE`, `RELAY_TARGET_MISSING`, `ALERT_SEND_FAILED`, `UPDATE_RESOLVE_FAILED`, `HISTORY_UNAVAILABLE`, `DOCTOR_UNAVAILABLE`, `NUKE_UNAVAILABLE`, `UNKNOWN_DAEMON_ERROR`, `CANCELLED` | The command failed. Read the code and the message. | +| 2 | `USAGE`, `BAD_REQUEST`, `BAD_FRAME`, `UNKNOWN_REQUEST`, `IDEMPOTENCY_CONFLICT`, `PASSTHROUGH_REFUSED`, `UNKNOWN_PASSTHROUGH_TOOL`, `GATEWAY_NOT_PRESENT`, `UPDATE_UNSUPPORTED_INSTALL`, `UPDATE_SHARED_INSTALL`, `UNSUPPORTED_IN_GATEWAY_MODE`, `UNSUPPORTED_IN_WORKER_MODE`, `WORKER_CONNECTED` | The request was wrong. Running it again unchanged fails again. | | 10 | `QUEUE_TIMEOUT`, `EXEC_TIMEOUT`, `DOWNLOAD_TIMEOUT` | A deadline ran out. | | 11 | `NO_CAPACITY`, `DEVICE_BUSY`, `COMPONENT_BUSY` | Busy right now. The same request can pass later. | | 12 | `NO_DRIVER`, `CAPABILITY_MISSING`, `RUNTIME_MISSING`, `UNKNOWN_MODEL`, `INSUFFICIENT_DISK_SPACE`, `LICENSE_NOT_ACCEPTED`, `DOWNLOADS_DISABLED`, `COMPONENT_NOT_OWNED`, `COMPONENT_IN_USE`, `DEVICE_NOT_ENROLLABLE`, `UNKNOWN_DEVICE`, `HISTORY_NOT_KEPT`, `UNKNOWN_WORKER`, `UNKNOWN_ALERT_DESTINATION` | What you named does not exist here, or this machine cannot provide it. | @@ -38,6 +38,7 @@ Branch on `code`, not on the message: the message wording can change, the code d | Code | Exit | HTTP | Meaning | What to do | | --- | --- | --- | --- | --- | | `NO_CAPACITY` | 11 | 503 | No device can start now, and the request was told not to wait (`--no-wait`). Through a gateway: no worker that takes requests can serve it. | Try again later, or drop `--no-wait` to queue. Over HTTP the answer carries `Retry-After: 5`. | +| `DAEMON_UPDATING` | 1 | 503 | The daemon is updating (`simlock update`) and takes no new leases. A request that was waiting when the update began gets it too. A lease you already hold renews and releases as usual. | Run the command again once the daemon is back, or lease from another worker. | | `QUEUE_TIMEOUT` | 10 | 500 | The request waited in the queue for its whole `--timeout` and got no device. | Raise `--timeout`, or check `simlock list --requests` for why requests wait. | | `REQUESTER_ALREADY_LEASED` | 13 | 409 | Your requester identity already holds its limit of leases (`lease.maxPerRequester`, 1 by default) or has a request waiting. The message names your lease. | Release that lease first, or give each agent session its own identity. | | `LEASE_ID_TAKEN` | 13 | 409 | `--lease-id` names an ID that an active lease or a waiting request already holds. | Use a new ID. Never reuse a lease ID. | @@ -95,6 +96,14 @@ Branch on `code`, not on the message: the message wording can change, the code d | `DOCTOR_UNAVAILABLE` | 1 | 503 | This daemon has no doctor to run. A daemon started by `simlock` always has one. | Check `simlock daemon logs`, and report it if it repeats. | | `NUKE_UNAVAILABLE` | 1 | 503 | This daemon has no reset for `nuke` to run. A daemon started by `simlock` always has one. | Check `simlock daemon logs`, and report it if it repeats. | +## Updating Simlock + +| Code | Exit | HTTP | Meaning | What to do | +| --- | --- | --- | --- | --- | +| `UPDATE_RESOLVE_FAILED` | 1 | 502 | npm could not find the version (`No match found for version 9.9.9`) or could not be reached. The message carries npm's own text. Nothing changed. | Check the version with `npm view simlock versions`, or npm's registry and network settings on that machine. | +| `UPDATE_UNSUPPORTED_INSTALL` | 2 | 409 | Simlock was not installed with `npm i -g simlock` (a source checkout, a pnpm global). The message names where it runs from. Nothing changed. | Update it the way you installed it. | +| `UPDATE_SHARED_INSTALL` | 2 | 409 | The worker and the gateway of this user share one install, and the other one is running. Nothing changed. | Stop the other daemon (`simlock daemon stop`, or `simlock --gateway daemon stop`), update, then start it again. | + ## Access | Code | Exit | HTTP | Meaning | What to do | diff --git a/website/src/content/docs/docs/reference/events.mdx b/website/src/content/docs/docs/reference/events.mdx index 20f112544..1deafa061 100644 --- a/website/src/content/docs/docs/reference/events.mdx +++ b/website/src/content/docs/docs/reference/events.mdx @@ -109,7 +109,7 @@ The **Gateway** column below says what a gateway shows for each event: | `lease.renewed` | A lease was renewed. | `leaseId`, `newDeadline` | Relayed | | `lease.released` | A lease ended because someone or something ended it. | `leaseId`, `deviceId`, `reason` (`explicit`: released; `killed`: `release --all` or `nuke`; `device-lost`: the device crashed and could not be brought back, or a physical device went away), `ownerId` | Relayed | | `lease.expired` | A lease reached its deadline without a renew. | `leaseId`, `deviceId`, `ownerId` | Relayed | -| `lease.rejected` | A request ended without a device. A worker records it only for its own requests. | `requestId`, `requester`, `requestSpec`, `reason` (`timeout`, `no-wait`, `unresolvable-spec`, `no-worker`, `already-leased`, `lease-id-taken`, `boot-timeout`, `killed`, `cancelled`, `daemon-restarted`, `worker-failed`), and on `worker-failed` the error `code` and the `worker` | Own and relayed | +| `lease.rejected` | A request ended without a device. A worker records it only for its own requests. | `requestId`, `requester`, `requestSpec`, `reason` (`timeout`, `no-wait`, `unresolvable-spec`, `no-worker`, `already-leased`, `lease-id-taken`, `boot-timeout`, `killed`, `updating`, `cancelled`, `daemon-restarted`, `worker-failed`), and on `worker-failed` the error `code` and the `worker` | Own and relayed | | `lease.declined` | A worker refused or failed a request a gateway sent it. The gateway may try another worker. | `requestId`, `fleetRequestId`, `requester`, `requestSpec`, `reason` (the `lease.rejected` values) | Relayed | | `request.granted` | The gateway handed a worker's grant to the caller. | `requestId`, `worker`, `leaseId` (the gateway's), `workerLeaseId` | Own | | `request.dispatched` | The gateway sent a queued request to a worker and the worker took it. | `requestId`, `workerId`, `requesterId`, `platform`, `model` or `class` when named, `mode` when named, `physical` for a physical request, `reason` (`warm-hit` or `free-capacity`), `stage`, `queuedMs` | Own | @@ -180,6 +180,8 @@ as `incompatible` in `simlock --gateway worker list`. | Event | When it fires | Payload | Gateway | | --- | --- | --- | --- | | `daemon.started` | The daemon finished starting. | `version`, `configSnapshot` (tokens, alert URLs and secrets shown as `[redacted]`) | Own (a worker records its own before the gateway starts listening to it, so it is not relayed) | +| `daemon.update-started` | An update began: the daemon resolved the version and now waits for its leases to end or installs. | `from`, `to`, `forced` | Relayed | +| `daemon.update-failed` | An update ended without a new daemon: the install failed and this daemon serves again, or the new version did not start and the old one was started again. | `stage` (`installing` or `restarting`), `from`, `to`, `message` | Relayed | | `daemon.stopping` | The daemon began to stop. | `reason` (`signal` when the daemon process received SIGTERM) | Own and relayed | | `doctor.reconciled` | Simlock compared its records with the devices on disk (at start and on `simlock doctor`). | `driftFindings` | Relayed | | `cleanup.executed` | Cleanup shut down or deleted an idle device. | `ruleName` (`idle-shutdown` or `idle-destroy`), `action` (`shutdown` or `destroy`), `target`, `reason` | Relayed | diff --git a/website/src/content/docs/docs/using-devices/programmatic-client.mdx b/website/src/content/docs/docs/using-devices/programmatic-client.mdx index 74766e3fa..a15cc1f0c 100644 --- a/website/src/content/docs/docs/using-devices/programmatic-client.mdx +++ b/website/src/content/docs/docs/using-devices/programmatic-client.mdx @@ -10,7 +10,7 @@ A Node.js program on the machine that runs the Simlock daemon can lease devices | Import | For | Can | | --- | --- | --- | | `simlock/client` | Agents and tools that need devices | Lease, renew, release, run commands on a leased device, read the catalog and status | -| `simlock/admin` | Operator tools | Everything above, plus every lease and device, tokens, cleanup, events and stopping the daemon | +| `simlock/admin` | Operator tools | Everything above, plus every lease and device, tokens, cleanup, events, updating the daemon and stopping it | Both talk to the daemon over its local socket. They have no network transport: a program on another machine uses the [HTTP API](/simlock/docs/using-devices/remote-agents) instead. @@ -178,6 +178,22 @@ The admin credential lets a program release every agent's lease and stop the dae A missing or wrong credential fails the connection with `ADMIN_AUTHENTICATION_FAILED`. An admin client has every method of the agent client too. +### Update the daemon + +`admin.updateDaemon({ version?, force?, query? })` is what `simlock update` calls. It resolves the version with npm, starts the update and answers at once; the daemon then takes no new leases, waits for its leases to end (unless `force`), installs and restarts. + +```ts +const answer = await admin.updateDaemon({ version: "0.4.0" }); +// { started: true, upToDate: false, resolved: "0.4.0", version: "0.3.0", health: "running", +// update: { stage: "waiting", leases: 2, from: "0.3.0", to: "0.4.0", forced: false, startedAt: 1760000000000 }, +// lastUpdateFailure: null, latestVersion: null } +``` + +- `started` is true only for the call that began the update. A call while an update runs follows it (`started: false`, whatever `version` it names); `force` on a waiting update installs now. +- `upToDate` is true when nothing was needed: the resolved version is the running one, or the running one is newer than `latest` and no `version` was named. +- `{ query: true }` starts nothing and returns the same answer: poll it to follow the update. It works while the daemon restarts only by reconnecting, and across a protocol change: like `stopDaemon()`, `updateDaemon()` is accepted by a daemon on another protocol version, so a client that fails every other call with `PROTOCOL_VERSION_UNSUPPORTED` can still update it. +- Refusals: `UPDATE_RESOLVE_FAILED`, `UPDATE_UNSUPPORTED_INSTALL` and `UPDATE_SHARED_INSTALL` (see [Errors](/simlock/docs/reference/errors#updating-simlock)). `version` must be an exact version such as `0.4.0` or `0.4.0-rc.1`. + ## Related - How leases and TTLs work: [Lease lifecycle](/simlock/docs/using-devices/lease-lifecycle). diff --git a/website/src/content/docs/docs/worker/running-the-daemon.mdx b/website/src/content/docs/docs/worker/running-the-daemon.mdx index 2b26fe084..f03b23360 100644 --- a/website/src/content/docs/docs/worker/running-the-daemon.mdx +++ b/website/src/content/docs/docs/worker/running-the-daemon.mdx @@ -178,8 +178,35 @@ When the daemon starts again, after a `daemon stop`, a crash or a reboot, it com Settings in `config.json` are read only at start, so a restart is how a config change takes effect. +## Update Simlock + +`simlock update` installs the newest Simlock on this machine and restarts the daemon, after the leases it holds have ended: + +```sh +simlock update +``` + +```text +Updating simlock 0.3.0 → 0.4.0 +Waiting for 2 leases to end. Ctrl-C stops following; simlock update --force stops waiting. +Installing simlock@0.4.0 +Restarting the daemon +simlock 0.4.0 is running +``` + +From the moment you run it, the daemon takes no new leases: a request gets `DAEMON_UPDATING`, and a request that was waiting in the queue gets it too. Leases already held renew and release as usual. `simlock status` shows `Updating to 0.4.0: waiting for 2 leases`, and a `daemon.update-started` event is recorded. `--force` (given at the start, or later from another shell) does not wait: the leases survive the restart, as for any restart (see [What a restart keeps](#what-a-restart-keeps)). `--version 0.4.0` installs that exact version, an older one included. + +- **The install fails** (for example npm cannot write its global folder): the old daemon keeps serving and takes leases again. `simlock status` shows `Last update failed (installing): `, and `daemon.update-failed` is recorded. +- **The new version does not start** within two minutes: Simlock installs the old version again and starts it, with the database files it had before. `simlock status` then shows `Last update failed (restarting): …`. +- **Simlock was not installed with `npm i -g simlock`** (a source checkout, a pnpm global): `UPDATE_UNSUPPORTED_INSTALL`. Nothing changes. +- **A worker and a gateway of one user share one install.** While the other one runs, the update stops with `UPDATE_SHARED_INSTALL`. Stop one role, update the other, and start the stopped one again; it starts the new install. + +The same update runs over HTTP as `POST /v1/update` with an admin token. A daemon older than the version that adds this command answers `PROTOCOL_VERSION_UNSUPPORTED`; upgrade it by hand, as below. + ## Upgrade Simlock +To upgrade by hand instead of with `simlock update`: + 1. Stop the running daemon, so the new version does not meet the old one: