Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e185299860 |
No files matched your search
@@ -26,8 +26,10 @@ check:
|
|||||||
build:
|
build:
|
||||||
@script/build
|
@script/build
|
||||||
|
|
||||||
build-bin:
|
# Bundles the built dist/, so the binary reports the version script/build
|
||||||
@script/build-bin
|
# stamped.
|
||||||
|
build-bin: build
|
||||||
|
nix-shell -p bun --run "bun build dist/bin/quak.js --compile --outfile bin/quak"
|
||||||
|
|
||||||
install: build-bin
|
install: build-bin
|
||||||
mkdir -p ~/bin
|
mkdir -p ~/bin
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ again on the next run. Every download is decrypted on its way to disk.
|
|||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
The commands below need `git`, `yarn` and Node 22.13 or later. Node 23 needs
|
The commands below need `git`, Node 22 or later and `yarn`.
|
||||||
23.5 or later.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.eeqj.de/sneak/quak.git
|
git clone https://git.eeqj.de/sneak/quak.git
|
||||||
@@ -70,29 +69,11 @@ The library keeps a local copy of the account's album and file lists, the cache.
|
|||||||
It brings the cache up to date with the server in rounds called refreshes.
|
It brings the cache up to date with the server in rounds called refreshes.
|
||||||
|
|
||||||
quak is not published to a package registry: `package.json` marks it `private`,
|
quak is not published to a package registry: `package.json` marks it `private`,
|
||||||
and `dist/` is not committed. Clone and build it as above, then link the clone
|
and `dist/` is not committed. Clone and build it as above, then add the clone to
|
||||||
into your own program:
|
your own program's `package.json` by its path, for example
|
||||||
|
`yarn add file:../quak`. The package's `main`, `types` and `exports` fields
|
||||||
```bash
|
point into `dist/`, so the clone must be built first. The package is an ES
|
||||||
# In the quak clone:
|
module only, so a CommonJS program cannot `require` it.
|
||||||
yarn link
|
|
||||||
|
|
||||||
# In your program's directory:
|
|
||||||
yarn link quak
|
|
||||||
```
|
|
||||||
|
|
||||||
`yarn link` also installs the `quak` command in yarn's global bin directory, and
|
|
||||||
`yarn unlink` in the clone removes the link and the command.
|
|
||||||
|
|
||||||
A linked quak uses the dependencies that `yarn install` put in the clone, at the
|
|
||||||
versions in quak's `yarn.lock`. Avoid `yarn add file:../quak`: it resolves
|
|
||||||
quak's dependencies again, and their newer releases can need a newer Node.
|
|
||||||
`yarn link quak` does not add quak to your `package.json`, so run it again in
|
|
||||||
each new checkout of your program.
|
|
||||||
|
|
||||||
The package's `main`, `types` and `exports` fields point into `dist/`, so the
|
|
||||||
clone must be built first. The package is an ES module only, so a CommonJS
|
|
||||||
program cannot `require` it.
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { Client, Library } from "quak";
|
import { Client, Library } from "quak";
|
||||||
@@ -154,15 +135,10 @@ requests. The "Library reference" below covers both.
|
|||||||
## Running the backup from cron
|
## Running the backup from cron
|
||||||
|
|
||||||
`quak backup` never prompts, so cron can run it. `make install` builds quak as
|
`quak backup` never prompts, so cron can run it. `make install` builds quak as
|
||||||
one binary and copies it to `~/bin/quak`. It uses bun, a JavaScript runtime, to
|
one binary and copies it to `~/bin/quak`. Besides what Getting Started needs,
|
||||||
bundle the built `dist/` into one executable. Besides what Getting Started
|
`make install` needs `make` and [nix](https://nixos.org/download). It uses bun,
|
||||||
needs, `make install` needs `make`. It downloads bun from GitHub with `curl` and
|
a JavaScript runtime, to bundle the built `dist/` into one executable. It runs
|
||||||
unpacks it with `unzip`. It checks the download against a SHA-256 hash in the
|
bun through `nix-shell -p bun`.
|
||||||
repo. `make install` works on Linux and macOS, on x86-64 and arm64.
|
|
||||||
|
|
||||||
On Linux systems that use the musl C library, such as Alpine, `make install`
|
|
||||||
downloads bun's musl build. On Alpine, both that bun and `~/bin/quak` need the
|
|
||||||
`libstdc++` and `libgcc` packages.
|
|
||||||
|
|
||||||
Log in once with `~/bin/quak`, as the user the cron job will run as:
|
Log in once with `~/bin/quak`, as the user the cron job will run as:
|
||||||
|
|
||||||
@@ -229,7 +205,7 @@ below belong to quak itself, and go before or after the command's name.
|
|||||||
### The commands
|
### The commands
|
||||||
|
|
||||||
```
|
```
|
||||||
quak login [--server <url>]
|
quak login
|
||||||
quak whoami
|
quak whoami
|
||||||
quak logout
|
quak logout
|
||||||
quak collections [--json]
|
quak collections [--json]
|
||||||
@@ -246,26 +222,13 @@ Every command but `login` and `logout` needs a saved session. Every command but
|
|||||||
`login`, `whoami` and `logout` opens the library. How each one gets current data
|
`login`, `whoami` and `logout` opens the library. How each one gets current data
|
||||||
from the server is described under "How a command gets current data".
|
from the server is described under "How a command gets current data".
|
||||||
|
|
||||||
#### `quak login [--server <url>]`
|
#### `quak login`
|
||||||
|
|
||||||
Logs in to an account and saves the session. It reads the email from
|
Logs in to an account and saves the session. It reads the email from
|
||||||
`QUAK_EMAIL` and the password from `QUAK_PASSWORD`. For each one that is not
|
`QUAK_EMAIL` and the password from `QUAK_PASSWORD`. For each one that is not
|
||||||
set, it asks on the terminal. The password shows as `*` characters while it is
|
set, it asks on the terminal. The password shows as `*` characters while it is
|
||||||
typed.
|
typed.
|
||||||
|
|
||||||
quak logs in to Ente's hosted service unless you name a self-hosted Ente server.
|
|
||||||
Name it with `--server`, or with `QUAK_SERVER` when `--server` is not given.
|
|
||||||
Give the address of the server's API, for example
|
|
||||||
`quak login --server https://ente.example.com`. The address is an http or https
|
|
||||||
URL. It may include a path, but no query or fragment. quak refuses any other
|
|
||||||
address before it asks for anything. It logs
|
|
||||||
`Invalid server address "<url>": it must be an http or https URL with no query or fragment`,
|
|
||||||
and the exit status is 1.
|
|
||||||
|
|
||||||
The session file keeps the server's address. Every later command, cron runs
|
|
||||||
included, uses that server without further setting. To change server, log in
|
|
||||||
again.
|
|
||||||
|
|
||||||
When the account has a second factor, quak asks for the code. It asks
|
When the account has a second factor, quak asks for the code. It asks
|
||||||
`TOTP code: ` for a time-based code from an authenticator app. An account whose
|
`TOTP code: ` for a time-based code from an authenticator app. An account whose
|
||||||
only second factor is a passkey cannot log in with quak. When the account has
|
only second factor is a passkey cannot log in with quak. When the account has
|
||||||
@@ -339,7 +302,7 @@ title, with unsafe characters replaced (see "File names from the server"). With
|
|||||||
original is kept in the cache as well.
|
original is kept in the cache as well.
|
||||||
|
|
||||||
A file ID that no collection holds gives `File <id> not found`, and the exit
|
A file ID that no collection holds gives `File <id> not found`, and the exit
|
||||||
status is 1.
|
status is 1. The option `--collection <id>` is accepted and ignored.
|
||||||
|
|
||||||
A live photo is a still image and a short video that Ente stores as one file.
|
A live photo is a still image and a short video that Ente stores as one file.
|
||||||
`get` writes both, each named after the title with its own extension, as Ente's
|
`get` writes both, each named after the title with its own extension, as Ente's
|
||||||
@@ -610,11 +573,9 @@ Linux that is `$XDG_DATA_HOME/quak/session.json`, or
|
|||||||
mode `0600`, in a directory it creates with mode `0700`.
|
mode `0600`, in a directory it creates with mode `0700`.
|
||||||
|
|
||||||
The file holds the account's email, its user ID, the session token and the
|
The file holds the account's email, its user ID, the session token and the
|
||||||
account's three keys, in base64 and unencrypted. After a login to a self-hosted
|
account's three keys, in base64 and unencrypted. Treat it as you would treat the
|
||||||
server, it also holds the server's address as `apiOrigin`. Treat it as you would
|
password. A missing file means "not logged in". A file that cannot be read back
|
||||||
treat the password. A missing file means "not logged in". A file that cannot be
|
is reported as corrupt, with the reason. Run `quak logout`, then `quak login`.
|
||||||
read back is reported as corrupt, with the reason. Run `quak logout`, then
|
|
||||||
`quak login`.
|
|
||||||
|
|
||||||
A command that needs a session exits 3 when the file is missing or corrupt. It
|
A command that needs a session exits 3 when the file is missing or corrupt. It
|
||||||
also exits 3 when the server no longer accepts the session, and prints
|
also exits 3 when the server no longer accepts the session, and prints
|
||||||
@@ -710,9 +671,6 @@ but letters and digits, or when it is longer than 16 characters.
|
|||||||
When the date or the time zone changes, the next run stores the original at its
|
When the date or the time zone changes, the next run stores the original at its
|
||||||
new path and leaves the old copy where it is.
|
new path and leaves the old copy where it is.
|
||||||
|
|
||||||
A file deleted in Ente keeps its original at its save path, and the JSON beside
|
|
||||||
it. The next run removes its symlinks from the collection directories.
|
|
||||||
|
|
||||||
The library gives the same path as `photo.savePath` and writes it with
|
The library gives the same path as `photo.savePath` and writes it with
|
||||||
`photo.download()` (see "A photo's content").
|
`photo.download()` (see "A photo's content").
|
||||||
|
|
||||||
@@ -1492,11 +1450,6 @@ write wherever it likes. `Client.fromJSON(snapshot, apiOptions?)` restores a
|
|||||||
working client from that snapshot without logging in again. It checks every
|
working client from that snapshot without logging in again. It checks every
|
||||||
field and each key's length first, and throws an error naming the bad field.
|
field and each key's length first, and throws an error naming the bad field.
|
||||||
|
|
||||||
A client whose `apiOptions` named an `apiOrigin` puts it in its snapshot. The
|
|
||||||
restored client then uses that server, even when the `apiOptions` given to
|
|
||||||
`Client.fromJSON` name another. A snapshot without `apiOrigin` uses the
|
|
||||||
`apiOrigin` of those `apiOptions`, or the default server.
|
|
||||||
|
|
||||||
`Client.fromJSON` works only once libsodium, the cryptography library quak uses,
|
`Client.fromJSON` works only once libsodium, the cryptography library quak uses,
|
||||||
is loaded. `Client.login` loads it, and `Client.fromJSON` does not. A program
|
is loaded. `Client.login` loads it, and `Client.fromJSON` does not. A program
|
||||||
that restores a session in a new process first runs `await sodium.ready`, with
|
that restores a session in a new process first runs `await sodium.ready`, with
|
||||||
@@ -1818,8 +1771,6 @@ standard. The others are additions to it.
|
|||||||
`package.json` declares (`main`, `types`, `bin`) were written, and make the
|
`package.json` declares (`main`, `types`, `bin`) were written, and make the
|
||||||
command line executable.
|
command line executable.
|
||||||
- `script/version`: print the version `script/build` stamps (see "Version").
|
- `script/version`: print the version `script/build` stamps (see "Version").
|
||||||
- `script/build-bin`: run `script/build`, then bundle the built `dist/` into one
|
|
||||||
binary, `bin/quak`, with bun (see "Running the backup from cron").
|
|
||||||
- `script/test`: run the test suite, by building the `test` phase of the
|
- `script/test`: run the test suite, by building the `test` phase of the
|
||||||
`Dockerfile`. Requires Docker.
|
`Dockerfile`. Requires Docker.
|
||||||
- `script/lint`: run eslint and a prettier check, by building the `lint` phase
|
- `script/lint`: run eslint and a prettier check, by building the `lint` phase
|
||||||
@@ -1848,7 +1799,8 @@ The Makefile targets are:
|
|||||||
- `make build`: runs `script/build`.
|
- `make build`: runs `script/build`.
|
||||||
- `make docker`: runs `script/docker`.
|
- `make docker`: runs `script/docker`.
|
||||||
- `make hooks`: runs `script/install-precommit`.
|
- `make hooks`: runs `script/install-precommit`.
|
||||||
- `make build-bin`: runs `script/build-bin`.
|
- `make build-bin`: bundles the built `dist/` into one binary, `bin/quak`. It
|
||||||
|
runs bun through `nix-shell -p bun`.
|
||||||
- `make install`: runs `make build-bin` and copies the binary to `~/bin/quak`.
|
- `make install`: runs `make build-bin` and copies the binary to `~/bin/quak`.
|
||||||
- `make dev`: runs the TypeScript compiler in watch mode.
|
- `make dev`: runs the TypeScript compiler in watch mode.
|
||||||
- `make clean`: removes `dist/`, `coverage/`, `.vitest-cache/` and the
|
- `make clean`: removes `dist/`, `coverage/`, `.vitest-cache/` and the
|
||||||
@@ -1956,9 +1908,9 @@ These apply to people and to coding agents alike.
|
|||||||
- container images by digest;
|
- container images by digest;
|
||||||
- the nvm archive `script/bootstrap` downloads, and the Python packages
|
- the nvm archive `script/bootstrap` downloads, and the Python packages
|
||||||
`bin/clip-keyword-vectors` installs, by SHA-256 hash;
|
`bin/clip-keyword-vectors` installs, by SHA-256 hash;
|
||||||
- the bun builds `script/build-bin` downloads, by SHA-256 hash;
|
|
||||||
- Node and yarn, when `script/bootstrap` installs them, by version.
|
- Node and yarn, when `script/bootstrap` installs them, by version.
|
||||||
- Not pinned:
|
- Not pinned:
|
||||||
|
- bun, which `make build-bin` takes from `nix-shell -p bun`;
|
||||||
- `make` and `git`, which `script/bootstrap` installs from the system
|
- `make` and `git`, which `script/bootstrap` installs from the system
|
||||||
package manager when they are missing, as every `Dockerfile` stage does;
|
package manager when they are missing, as every `Dockerfile` stage does;
|
||||||
- `bash` and `curl`, which `script/bootstrap` installs the same way when it
|
- `bash` and `curl`, which `script/bootstrap` installs the same way when it
|
||||||
|
|||||||
+4
-11
@@ -74,11 +74,7 @@ const context = (): CliContext => ({
|
|||||||
sessionDir: paths.data,
|
sessionDir: paths.data,
|
||||||
cacheDir: program.opts<ProgramOptions>().cacheDir,
|
cacheDir: program.opts<ProgramOptions>().cacheDir,
|
||||||
loadSession: (path) => loadSession(path, logRequests(log())),
|
loadSession: (path) => loadSession(path, logRequests(log())),
|
||||||
login: (opts) =>
|
login: (opts) => Client.login({ ...opts, apiOptions: logRequests(log()) }),
|
||||||
Client.login({
|
|
||||||
...opts,
|
|
||||||
apiOptions: { ...opts.apiOptions, ...logRequests(log()) },
|
|
||||||
}),
|
|
||||||
prompt: (message) => input({ message }),
|
prompt: (message) => input({ message }),
|
||||||
promptSecret: (message) => password({ message, mask: true }),
|
promptSecret: (message) => password({ message, mask: true }),
|
||||||
});
|
});
|
||||||
@@ -89,12 +85,7 @@ const run = (command: Promise<number>): Promise<void> =>
|
|||||||
program
|
program
|
||||||
.command("login")
|
.command("login")
|
||||||
.description("Log in to an Ente account and save the session")
|
.description("Log in to an Ente account and save the session")
|
||||||
.option(
|
.action(() => run(loginCommand(context())));
|
||||||
"--server <url>",
|
|
||||||
"Address of a self-hosted Ente server's API, saved with the session " +
|
|
||||||
"(default: QUAK_SERVER, then Ente's hosted service)",
|
|
||||||
)
|
|
||||||
.action((opts: { server?: string }) => run(loginCommand(context(), opts)));
|
|
||||||
|
|
||||||
program
|
program
|
||||||
.command("whoami")
|
.command("whoami")
|
||||||
@@ -132,6 +123,7 @@ program
|
|||||||
.description("Download and decrypt a single file")
|
.description("Download and decrypt a single file")
|
||||||
.argument("<fileID>", "File ID (from `quak files`)")
|
.argument("<fileID>", "File ID (from `quak files`)")
|
||||||
.option("--out <path>", "Output file path")
|
.option("--out <path>", "Output file path")
|
||||||
|
.option("--collection <id>", "Accepted for compatibility; ignored")
|
||||||
.action((fileID: string, opts: { out?: string }) =>
|
.action((fileID: string, opts: { out?: string }) =>
|
||||||
run(getCommand(context(), fileID, opts)),
|
run(getCommand(context(), fileID, opts)),
|
||||||
);
|
);
|
||||||
@@ -141,6 +133,7 @@ program
|
|||||||
.description("Download and decrypt a thumbnail")
|
.description("Download and decrypt a thumbnail")
|
||||||
.argument("<fileID>", "File ID (from `quak files`)")
|
.argument("<fileID>", "File ID (from `quak files`)")
|
||||||
.option("--out <path>", "Output file path")
|
.option("--out <path>", "Output file path")
|
||||||
|
.option("--collection <id>", "Accepted for compatibility; ignored")
|
||||||
.action((fileID: string, opts: { out?: string }) =>
|
.action((fileID: string, opts: { out?: string }) =>
|
||||||
run(getThumbCommand(context(), fileID, opts)),
|
run(getThumbCommand(context(), fileID, opts)),
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.13.0 || >=23.5.0"
|
"node": ">=22"
|
||||||
},
|
},
|
||||||
"main": "./dist/src/index.js",
|
"main": "./dist/src/index.js",
|
||||||
"types": "./dist/src/index.d.ts",
|
"types": "./dist/src/index.d.ts",
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# script/build-bin: run script/build, then bundle the built dist/ into one
|
|
||||||
# executable, bin/quak, which reports the version script/build stamped. Our own
|
|
||||||
# extension to scripts-to-rule-them-all.
|
|
||||||
#
|
|
||||||
# bun compiles the binary from a copy of its own executable, so the binary
|
|
||||||
# loads the same libraries as the bun that built it. nix's bun loads libraries
|
|
||||||
# from the nix store: on Linux its C library, on macOS its ICU. A binary
|
|
||||||
# compiled from it loads them too. nix-collect-garbage can delete them, and the
|
|
||||||
# binary then no longer starts. This script therefore compiles with bun's own
|
|
||||||
# release build, which loads only the system's libraries. It downloads that
|
|
||||||
# build and checks it against the hash below before running it.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|
||||||
|
|
||||||
# bun 1.3.13, 2026-11-11, and the SHA-256 of each release archive below. The
|
|
||||||
# x86-64 archives are the baseline builds, which also run on CPUs without AVX2.
|
|
||||||
# The musl archives are for Linux systems that use the musl C library, such as
|
|
||||||
# Alpine.
|
|
||||||
BUN_VERSION="1.3.13"
|
|
||||||
BUN_LINUX_X64_BASELINE_SHA256="9d8a24292a7068090205daac0a5a223f5f69736f5287e37bf88d3b4031edc750"
|
|
||||||
BUN_LINUX_X64_MUSL_BASELINE_SHA256="88ca7c7ad235b498f549eea2f770f434e9f0f5e9ba95168a2d3a1f235184c394"
|
|
||||||
BUN_LINUX_AARCH64_SHA256="70bae41b3908b0a120e1e58c5c8af30e74afae3b8d11b0d3fdd8e787ddfb4b22"
|
|
||||||
BUN_LINUX_AARCH64_MUSL_SHA256="5385e978107ce4934298d8d6afe9bfbb898683f6cc23e6753a0da60bc60c5b81"
|
|
||||||
BUN_DARWIN_X64_BASELINE_SHA256="a98ba6a480f22fda9b343626b906a4e26aa53618bf85d2bc5928ecf2ba45f0ed"
|
|
||||||
BUN_DARWIN_AARCH64_SHA256="5467e3f65dba526b9fea98f0cce04efafc0c63e169733ec27b876a3ad32da190"
|
|
||||||
|
|
||||||
# compile_with_downloaded_bun <archive-name> <sha256>
|
|
||||||
compile_with_downloaded_bun() {
|
|
||||||
tmp="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$tmp"' EXIT
|
|
||||||
curl -fsSL -o "$tmp/$1.zip" \
|
|
||||||
"https://github.com/oven-sh/bun/releases/download/bun-v$BUN_VERSION/$1.zip"
|
|
||||||
# macOS always has shasum, but not every macOS has sha256sum.
|
|
||||||
if command -v sha256sum >/dev/null 2>&1; then
|
|
||||||
echo "$2 $tmp/$1.zip" | sha256sum -c -
|
|
||||||
else
|
|
||||||
echo "$2 $tmp/$1.zip" | shasum -a 256 -c -
|
|
||||||
fi
|
|
||||||
unzip -q "$tmp/$1.zip" -d "$tmp"
|
|
||||||
"$tmp/$1/bun" build dist/bin/quak.js --compile --outfile bin/quak
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
|
||||||
cd "$ROOT"
|
|
||||||
"$ROOT/script/build"
|
|
||||||
platform="$(uname -s)-$(uname -m)"
|
|
||||||
# musl's ldd begins its version output with "musl libc"; glibc's does not.
|
|
||||||
if ldd --version 2>&1 | grep -q '^musl libc'; then
|
|
||||||
platform="$platform-musl"
|
|
||||||
fi
|
|
||||||
case "$platform" in
|
|
||||||
Linux-x86_64)
|
|
||||||
compile_with_downloaded_bun bun-linux-x64-baseline \
|
|
||||||
"$BUN_LINUX_X64_BASELINE_SHA256"
|
|
||||||
;;
|
|
||||||
Linux-x86_64-musl)
|
|
||||||
compile_with_downloaded_bun bun-linux-x64-musl-baseline \
|
|
||||||
"$BUN_LINUX_X64_MUSL_BASELINE_SHA256"
|
|
||||||
;;
|
|
||||||
Linux-aarch64)
|
|
||||||
compile_with_downloaded_bun bun-linux-aarch64 \
|
|
||||||
"$BUN_LINUX_AARCH64_SHA256"
|
|
||||||
;;
|
|
||||||
Linux-aarch64-musl)
|
|
||||||
compile_with_downloaded_bun bun-linux-aarch64-musl \
|
|
||||||
"$BUN_LINUX_AARCH64_MUSL_SHA256"
|
|
||||||
;;
|
|
||||||
Darwin-x86_64)
|
|
||||||
compile_with_downloaded_bun bun-darwin-x64-baseline \
|
|
||||||
"$BUN_DARWIN_X64_BASELINE_SHA256"
|
|
||||||
;;
|
|
||||||
Darwin-arm64)
|
|
||||||
compile_with_downloaded_bun bun-darwin-aarch64 \
|
|
||||||
"$BUN_DARWIN_AARCH64_SHA256"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "build-bin: bun has no release build for $(uname -s) on $(uname -m)" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
+1
-26
@@ -155,31 +155,8 @@ const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> =>
|
|||||||
onProgress: logRefresh(ctx.log),
|
onProgress: logRefresh(ctx.log),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Logs in to the server `--server` or `QUAK_SERVER` names, the flag first, or
|
export const loginCommand = async (ctx: CliContext): Promise<number> => {
|
||||||
// to Ente's hosted service when neither is set. The saved session keeps the
|
|
||||||
// server, so every later command uses it.
|
|
||||||
// Whether `text` can be an Ente server's address: an http or https URL, which
|
|
||||||
// may have a path, and no query or fragment, which request URLs could not
|
|
||||||
// carry.
|
|
||||||
const isServerAddress = (text: string): boolean =>
|
|
||||||
URL.canParse(text) &&
|
|
||||||
["http:", "https:"].includes(new URL(text).protocol) &&
|
|
||||||
!/[?#]/.test(text);
|
|
||||||
|
|
||||||
export const loginCommand = async (
|
|
||||||
ctx: CliContext,
|
|
||||||
opts: { server?: string },
|
|
||||||
): Promise<number> => {
|
|
||||||
await init();
|
await init();
|
||||||
const server = opts.server ?? process.env.QUAK_SERVER;
|
|
||||||
if (server !== undefined && !isServerAddress(server)) {
|
|
||||||
ctx.log(
|
|
||||||
"fatal",
|
|
||||||
`Invalid server address "${server}": it must be an http or ` +
|
|
||||||
`https URL with no query or fragment`,
|
|
||||||
);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
const email = process.env.QUAK_EMAIL ?? (await ctx.prompt("Email"));
|
const email = process.env.QUAK_EMAIL ?? (await ctx.prompt("Email"));
|
||||||
const password =
|
const password =
|
||||||
process.env.QUAK_PASSWORD ?? (await ctx.promptSecret("Password"));
|
process.env.QUAK_PASSWORD ?? (await ctx.promptSecret("Password"));
|
||||||
@@ -191,8 +168,6 @@ export const loginCommand = async (
|
|||||||
password,
|
password,
|
||||||
totp: async () => ctx.prompt("TOTP code: "),
|
totp: async () => ctx.prompt("TOTP code: "),
|
||||||
emailOTP: async () => ctx.prompt("Email verification code: "),
|
emailOTP: async () => ctx.prompt("Email verification code: "),
|
||||||
apiOptions:
|
|
||||||
server === undefined ? undefined : { apiOrigin: server },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
saveSession(ctx.sessionDir, client.toJSON());
|
saveSession(ctx.sessionDir, client.toJSON());
|
||||||
|
|||||||
+1
-23
@@ -40,9 +40,6 @@ export interface ClientSnapshot {
|
|||||||
masterKey: string;
|
masterKey: string;
|
||||||
secretKey: string;
|
secretKey: string;
|
||||||
publicKey: string;
|
publicKey: string;
|
||||||
// The `apiOrigin` the client was given, so a restored client talks to
|
|
||||||
// the server that issued its token. Absent means the default server.
|
|
||||||
apiOrigin?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The result of a resumable enumeration. Live decrypted records and deleted
|
// The result of a resumable enumeration. Live decrypted records and deleted
|
||||||
@@ -76,7 +73,6 @@ export class Client {
|
|||||||
private readonly masterKey: Uint8Array;
|
private readonly masterKey: Uint8Array;
|
||||||
private readonly secretKey: Uint8Array;
|
private readonly secretKey: Uint8Array;
|
||||||
private readonly publicKey: Uint8Array;
|
private readonly publicKey: Uint8Array;
|
||||||
private readonly apiOrigin: string | undefined;
|
|
||||||
private loggedOut = false;
|
private loggedOut = false;
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
@@ -86,7 +82,6 @@ export class Client {
|
|||||||
masterKey: Uint8Array,
|
masterKey: Uint8Array,
|
||||||
secretKey: Uint8Array,
|
secretKey: Uint8Array,
|
||||||
publicKey: Uint8Array,
|
publicKey: Uint8Array,
|
||||||
apiOrigin: string | undefined,
|
|
||||||
) {
|
) {
|
||||||
this.api = api;
|
this.api = api;
|
||||||
this.email = email;
|
this.email = email;
|
||||||
@@ -94,7 +89,6 @@ export class Client {
|
|||||||
this.masterKey = masterKey;
|
this.masterKey = masterKey;
|
||||||
this.secretKey = secretKey;
|
this.secretKey = secretKey;
|
||||||
this.publicKey = publicKey;
|
this.publicKey = publicKey;
|
||||||
this.apiOrigin = apiOrigin;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async login(opts: LoginOptions): Promise<Client> {
|
static async login(opts: LoginOptions): Promise<Client> {
|
||||||
@@ -136,15 +130,12 @@ export class Client {
|
|||||||
unwrapped.masterKey,
|
unwrapped.masterKey,
|
||||||
unwrapped.secretKey,
|
unwrapped.secretKey,
|
||||||
unwrapped.publicKey,
|
unwrapped.publicKey,
|
||||||
opts.apiOptions?.apiOrigin,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore a client from a `toJSON()` snapshot. The snapshot usually comes
|
// Restore a client from a `toJSON()` snapshot. The snapshot usually comes
|
||||||
// straight from `JSON.parse` of a file on disk, so every field is checked
|
// straight from `JSON.parse` of a file on disk, so every field is checked
|
||||||
// before use; a bad one throws an error naming it. The snapshot's
|
// before use; a bad one throws an error naming it. Needs `init()` first.
|
||||||
// `apiOrigin`, when it has one, is used instead of `apiOptions.apiOrigin`.
|
|
||||||
// Needs `init()` first.
|
|
||||||
static fromJSON(snapshot: unknown, apiOptions?: ApiClientOptions): Client {
|
static fromJSON(snapshot: unknown, apiOptions?: ApiClientOptions): Client {
|
||||||
const invalid = (field: string, problem: string): Error =>
|
const invalid = (field: string, problem: string): Error =>
|
||||||
new Error(`Invalid session data: ${field} ${problem}`);
|
new Error(`Invalid session data: ${field} ${problem}`);
|
||||||
@@ -161,14 +152,6 @@ export class Client {
|
|||||||
if (!Number.isInteger(s.userID)) {
|
if (!Number.isInteger(s.userID)) {
|
||||||
throw invalid("userID", "must be an integer");
|
throw invalid("userID", "must be an integer");
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
s.apiOrigin !== undefined &&
|
|
||||||
(typeof s.apiOrigin !== "string" || s.apiOrigin === "")
|
|
||||||
) {
|
|
||||||
throw invalid("apiOrigin", "must be a non-empty string");
|
|
||||||
}
|
|
||||||
const apiOrigin =
|
|
||||||
(s.apiOrigin as string | undefined) ?? apiOptions?.apiOrigin;
|
|
||||||
const key = (field: string): Uint8Array => {
|
const key = (field: string): Uint8Array => {
|
||||||
const value = s[field];
|
const value = s[field];
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
@@ -192,7 +175,6 @@ export class Client {
|
|||||||
|
|
||||||
const api = new ApiClient({
|
const api = new ApiClient({
|
||||||
...apiOptions,
|
...apiOptions,
|
||||||
apiOrigin,
|
|
||||||
authToken: s.token as string,
|
authToken: s.token as string,
|
||||||
});
|
});
|
||||||
return new Client(
|
return new Client(
|
||||||
@@ -202,7 +184,6 @@ export class Client {
|
|||||||
key("masterKey"),
|
key("masterKey"),
|
||||||
key("secretKey"),
|
key("secretKey"),
|
||||||
key("publicKey"),
|
key("publicKey"),
|
||||||
apiOrigin,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,9 +222,6 @@ export class Client {
|
|||||||
masterKey: toBase64(this.masterKey),
|
masterKey: toBase64(this.masterKey),
|
||||||
secretKey: toBase64(this.secretKey),
|
secretKey: toBase64(this.secretKey),
|
||||||
publicKey: toBase64(this.publicKey),
|
publicKey: toBase64(this.publicKey),
|
||||||
...(this.apiOrigin === undefined
|
|
||||||
? {}
|
|
||||||
: { apiOrigin: this.apiOrigin }),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-25
@@ -1,10 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Tests for the retry options `bin/quak.ts` loads the saved session with
|
* Tests for the retry options `bin/quak.ts` loads the saved session with
|
||||||
* (`quak backup` gets the unattended ones, every other command the default),
|
* (`quak backup` gets the unattended ones, every other command the default),
|
||||||
* each with the callbacks that log its requests and retries, for `quak files`
|
* each with the callbacks that log its requests and retries, and for
|
||||||
* taking its collection ID as an optional argument, for `quak get` and
|
* `quak files` taking its collection ID as an optional argument.
|
||||||
* `quak get-thumb` refusing `--collection`, and for `quak login` taking
|
|
||||||
* `--server`.
|
|
||||||
*
|
*
|
||||||
* Each test runs `bin/quak.ts`, as the smoke test does, with its session loader
|
* Each test runs `bin/quak.ts`, as the smoke test does, with its session loader
|
||||||
* replaced by one that records the options it is given and reports no session,
|
* replaced by one that records the options it is given and reports no session,
|
||||||
@@ -107,24 +105,3 @@ describe("bin/quak.ts files", () => {
|
|||||||
expect(await quak("files", "7")).toMatch(/^Not logged in/);
|
expect(await quak("files", "7")).toMatch(/^Not logged in/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("bin/quak.ts get and get-thumb", () => {
|
|
||||||
it.each(["get", "get-thumb"])(
|
|
||||||
"%s refuses --collection as an unknown option",
|
|
||||||
async (command) => {
|
|
||||||
expect(await quak(command, "1", "--collection", "2")).toMatch(
|
|
||||||
/^error: unknown option '--collection'/,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The command refuses an address that is not a URL before it asks for anything
|
|
||||||
// or sends any request.
|
|
||||||
describe("bin/quak.ts login", () => {
|
|
||||||
it("hands --server to the command", async () => {
|
|
||||||
expect(await quak("login", "--server", "ente.example.com")).toMatch(
|
|
||||||
/^Invalid server address "ente\.example\.com"/,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+6
-105
@@ -328,7 +328,6 @@ describe("login", () => {
|
|||||||
prompts = [];
|
prompts = [];
|
||||||
vi.stubEnv("QUAK_EMAIL", "cli@example.com");
|
vi.stubEnv("QUAK_EMAIL", "cli@example.com");
|
||||||
vi.stubEnv("QUAK_PASSWORD", "hunter2");
|
vi.stubEnv("QUAK_PASSWORD", "hunter2");
|
||||||
vi.stubEnv("QUAK_SERVER", undefined);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -341,14 +340,12 @@ describe("login", () => {
|
|||||||
calls.push(opts);
|
calls.push(opts);
|
||||||
return loggedIn;
|
return loggedIn;
|
||||||
});
|
});
|
||||||
expect(await loginCommand(ctx, {})).toBe(0);
|
expect(await loginCommand(ctx)).toBe(0);
|
||||||
|
|
||||||
expect(prompts).toEqual([]);
|
expect(prompts).toEqual([]);
|
||||||
expect(calls).toHaveLength(1);
|
expect(calls).toHaveLength(1);
|
||||||
expect(calls[0]!.email).toBe("cli@example.com");
|
expect(calls[0]!.email).toBe("cli@example.com");
|
||||||
expect(calls[0]!.password).toBe("hunter2");
|
expect(calls[0]!.password).toBe("hunter2");
|
||||||
// No server named: the client uses Ente's hosted service.
|
|
||||||
expect(calls[0]!.apiOptions).toBeUndefined();
|
|
||||||
const path = join(ctx.sessionDir, "session.json");
|
const path = join(ctx.sessionDir, "session.json");
|
||||||
expect(stderr.text).toBe(
|
expect(stderr.text).toBe(
|
||||||
"Authenticating...\n" +
|
"Authenticating...\n" +
|
||||||
@@ -359,7 +356,7 @@ describe("login", () => {
|
|||||||
|
|
||||||
it("saves the session with mode 0600 in a directory with mode 0700", async () => {
|
it("saves the session with mode 0600 in a directory with mode 0700", async () => {
|
||||||
const ctx = loginContext(async () => loggedIn);
|
const ctx = loginContext(async () => loggedIn);
|
||||||
expect(await loginCommand(ctx, {})).toBe(0);
|
expect(await loginCommand(ctx)).toBe(0);
|
||||||
|
|
||||||
expect(statSync(ctx.sessionDir).mode & 0o777).toBe(0o700);
|
expect(statSync(ctx.sessionDir).mode & 0o777).toBe(0o700);
|
||||||
const path = join(ctx.sessionDir, "session.json");
|
const path = join(ctx.sessionDir, "session.json");
|
||||||
@@ -373,7 +370,7 @@ describe("login", () => {
|
|||||||
code = await opts.totp!();
|
code = await opts.totp!();
|
||||||
return loggedIn;
|
return loggedIn;
|
||||||
});
|
});
|
||||||
expect(await loginCommand(ctx, {})).toBe(0);
|
expect(await loginCommand(ctx)).toBe(0);
|
||||||
|
|
||||||
expect(prompts).toEqual(["TOTP code: "]);
|
expect(prompts).toEqual(["TOTP code: "]);
|
||||||
expect(code).toBe("123456");
|
expect(code).toBe("123456");
|
||||||
@@ -383,68 +380,13 @@ describe("login", () => {
|
|||||||
const ctx = loginContext(async () => {
|
const ctx = loginContext(async () => {
|
||||||
throw new Error("HTTP 401 from server");
|
throw new Error("HTTP 401 from server");
|
||||||
});
|
});
|
||||||
expect(await loginCommand(ctx, {})).toBe(1);
|
expect(await loginCommand(ctx)).toBe(1);
|
||||||
|
|
||||||
expect(stderr.text).toBe(
|
expect(stderr.text).toBe(
|
||||||
"Authenticating...\nLogin failed: HTTP 401 from server\n",
|
"Authenticating...\nLogin failed: HTTP 401 from server\n",
|
||||||
);
|
);
|
||||||
expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false);
|
expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs in to the server --server names, ahead of QUAK_SERVER", async () => {
|
|
||||||
vi.stubEnv("QUAK_SERVER", "https://other.example.com");
|
|
||||||
const calls: LoginOptions[] = [];
|
|
||||||
const ctx = loginContext(async (opts) => {
|
|
||||||
calls.push(opts);
|
|
||||||
return loggedIn;
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
await loginCommand(ctx, { server: "https://ente.example.com" }),
|
|
||||||
).toBe(0);
|
|
||||||
|
|
||||||
expect(calls[0]!.apiOptions).toEqual({
|
|
||||||
apiOrigin: "https://ente.example.com",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("logs in to the server QUAK_SERVER names without --server", async () => {
|
|
||||||
vi.stubEnv("QUAK_SERVER", "http://192.0.2.1:8080/ente");
|
|
||||||
const calls: LoginOptions[] = [];
|
|
||||||
const ctx = loginContext(async (opts) => {
|
|
||||||
calls.push(opts);
|
|
||||||
return loggedIn;
|
|
||||||
});
|
|
||||||
expect(await loginCommand(ctx, {})).toBe(0);
|
|
||||||
|
|
||||||
expect(calls[0]!.apiOptions).toEqual({
|
|
||||||
apiOrigin: "http://192.0.2.1:8080/ente",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
"",
|
|
||||||
"ente.example.com",
|
|
||||||
"ftp://ente.example.com",
|
|
||||||
"https://ente.example.com/?region=eu",
|
|
||||||
"https://ente.example.com/#top",
|
|
||||||
])(
|
|
||||||
"refuses the server address %j before asking for anything",
|
|
||||||
async (server) => {
|
|
||||||
const ctx = loginContext(async () => {
|
|
||||||
throw new Error("login must not be called");
|
|
||||||
});
|
|
||||||
expect(await loginCommand(ctx, { server })).toBe(1);
|
|
||||||
|
|
||||||
expect(prompts).toEqual([]);
|
|
||||||
expect(stderr.text).toBe(
|
|
||||||
`Invalid server address "${server}": it must be an http or ` +
|
|
||||||
`https URL with no query or fragment\n`,
|
|
||||||
);
|
|
||||||
expect(existsSync(join(ctx.sessionDir, "session.json"))).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// These use a real client read from the session file, over a fake API that
|
// These use a real client read from the session file, over a fake API that
|
||||||
@@ -627,14 +569,14 @@ describe("files", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("get and get-thumb", () => {
|
describe("get and get-thumb", () => {
|
||||||
it("get finds a file in any album", async () => {
|
it("get finds a file in any album without --collection", async () => {
|
||||||
const out = join(root, "diagram.png");
|
const out = join(root, "diagram.png");
|
||||||
expect(await getCommand(context(), "200", { out })).toBe(0);
|
expect(await getCommand(context(), "200", { out })).toBe(0);
|
||||||
expect(readFileSync(out)).toEqual(Buffer.alloc(7, 200));
|
expect(readFileSync(out)).toEqual(Buffer.alloc(7, 200));
|
||||||
expect(stderr.text).toBe(`7 bytes -> ${out}\n`);
|
expect(stderr.text).toBe(`7 bytes -> ${out}\n`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("get-thumb finds a file in any album", async () => {
|
it("get-thumb finds a file in any album without --collection", async () => {
|
||||||
const out = join(root, "thumb.jpg");
|
const out = join(root, "thumb.jpg");
|
||||||
expect(await getThumbCommand(context(), "200", { out })).toBe(0);
|
expect(await getThumbCommand(context(), "200", { out })).toBe(0);
|
||||||
expect(readFileSync(out)).toEqual(Buffer.alloc(3, 200));
|
expect(readFileSync(out)).toEqual(Buffer.alloc(3, 200));
|
||||||
@@ -1909,47 +1851,6 @@ describe("an account with many albums", () => {
|
|||||||
expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false);
|
expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// A self-hosted server serves downloads under its own address, at
|
|
||||||
// `/files/download/<id>`, so no request goes to Ente's hosted service.
|
|
||||||
it("login --server saves the server, and a later get sends every request to it", async () => {
|
|
||||||
vi.stubEnv("QUAK_EMAIL", "many@example.com");
|
|
||||||
vi.stubEnv("QUAK_PASSWORD", PASSWORD);
|
|
||||||
vi.stubEnv("QUAK_SERVER", undefined);
|
|
||||||
const hosts = new Set<string>();
|
|
||||||
const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
hosts.add(
|
|
||||||
new URL(input instanceof Request ? input.url : input).host,
|
|
||||||
);
|
|
||||||
return server(input, init);
|
|
||||||
}) as typeof globalThis.fetch;
|
|
||||||
const ctx: CliContext = {
|
|
||||||
...context(),
|
|
||||||
login: (opts) =>
|
|
||||||
Client.login({
|
|
||||||
...opts,
|
|
||||||
apiOptions: { ...opts.apiOptions, fetch },
|
|
||||||
}),
|
|
||||||
loadSession: (path) => loadSession(path, { fetch }),
|
|
||||||
};
|
|
||||||
const out = join(root, "photo.jpg");
|
|
||||||
try {
|
|
||||||
const opts = { server: "https://ente.example.com" };
|
|
||||||
expect(await loginCommand(ctx, opts)).toBe(0);
|
|
||||||
requests.length = 0;
|
|
||||||
expect(await getCommand(ctx, "200", { out })).toBe(0);
|
|
||||||
} finally {
|
|
||||||
vi.unstubAllEnvs();
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = join(ctx.sessionDir, "session.json");
|
|
||||||
expect(JSON.parse(readFileSync(path, "utf-8")).apiOrigin).toBe(
|
|
||||||
"https://ente.example.com",
|
|
||||||
);
|
|
||||||
expect(readFileSync(out, "utf-8")).toBe("photo-2.jpg");
|
|
||||||
expect(requests.at(-1)).toBe("/files/download/200");
|
|
||||||
expect([...hosts]).toEqual(["ente.example.com"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// `files` first fills the cache, so `get` waits for a refresh. No album has
|
// `files` first fills the cache, so `get` waits for a refresh. No album has
|
||||||
// changed since, so the refresh asks for no album's files.
|
// changed since, so the refresh asks for no album's files.
|
||||||
it("get on a cache that holds the account asks for no unchanged album's files, also for a photo in no album", async () => {
|
it("get on a cache that holds the account asks for no unchanged album's files, also for a photo in no album", async () => {
|
||||||
|
|||||||
@@ -47,20 +47,6 @@ describe("Client.toJSON", () => {
|
|||||||
expect(Client.fromJSON(snapshot).toJSON()).toEqual(snapshot);
|
expect(Client.fromJSON(snapshot).toJSON()).toEqual(snapshot);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("round-trips a snapshot that names a server", () => {
|
|
||||||
const snapshot = {
|
|
||||||
...validSnapshot(),
|
|
||||||
apiOrigin: "https://ente.example.com",
|
|
||||||
};
|
|
||||||
expect(Client.fromJSON(snapshot).toJSON()).toEqual(snapshot);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("names the server apiOptions gave fromJSON", () => {
|
|
||||||
const apiOrigin = "https://ente.example.com";
|
|
||||||
const client = Client.fromJSON(validSnapshot(), { apiOrigin });
|
|
||||||
expect(client.toJSON().apiOrigin).toBe(apiOrigin);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws instead of emitting a snapshot without a token", () => {
|
it("throws instead of emitting a snapshot without a token", () => {
|
||||||
const client = Client.fromJSON(validSnapshot());
|
const client = Client.fromJSON(validSnapshot());
|
||||||
client.getApiClient().clearAuthToken();
|
client.getApiClient().clearAuthToken();
|
||||||
@@ -91,8 +77,6 @@ describe("Client.fromJSON", () => {
|
|||||||
["publicKey", undefined],
|
["publicKey", undefined],
|
||||||
["publicKey", "not base64!"],
|
["publicKey", "not base64!"],
|
||||||
["publicKey", shortKey],
|
["publicKey", shortKey],
|
||||||
["apiOrigin", 7],
|
|
||||||
["apiOrigin", ""],
|
|
||||||
])("rejects %s = %j, naming the field", (field, value) => {
|
])("rejects %s = %j, naming the field", (field, value) => {
|
||||||
const snapshot: Record<string, unknown> = { ...validSnapshot() };
|
const snapshot: Record<string, unknown> = { ...validSnapshot() };
|
||||||
snapshot[field] = value;
|
snapshot[field] = value;
|
||||||
@@ -101,30 +85,6 @@ describe("Client.fromJSON", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// The token belongs to the snapshot's server, so that server wins over
|
|
||||||
// the one in `apiOptions`.
|
|
||||||
it("sends requests to the snapshot's server", async () => {
|
|
||||||
const urls: string[] = [];
|
|
||||||
const client = Client.fromJSON(
|
|
||||||
{ ...validSnapshot(), apiOrigin: "https://ente.example.com/" },
|
|
||||||
{
|
|
||||||
apiOrigin: "https://other.example.com",
|
|
||||||
fetch: async (input) => {
|
|
||||||
urls.push(String(input));
|
|
||||||
return new Response(JSON.stringify({ collections: [] }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await client.listCollections();
|
|
||||||
expect(urls).toEqual([
|
|
||||||
"https://ente.example.com/collections/v2?sinceTime=0",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([null, "a string", 42])("rejects a non-object %j", (value) => {
|
it.each([null, "a string", 42])("rejects a non-object %j", (value) => {
|
||||||
expect(() => Client.fromJSON(value)).toThrow(
|
expect(() => Client.fromJSON(value)).toThrow(
|
||||||
/^Invalid session data: not a JSON object/,
|
/^Invalid session data: not a JSON object/,
|
||||||
|
|||||||
@@ -1,241 +0,0 @@
|
|||||||
// `script/build-bin` compiles `bin/quak`, the single binary that the README's
|
|
||||||
// cron setup runs. bun compiles it from a copy of its own executable, so the
|
|
||||||
// binary loads the same libraries as the bun that built it. nix's bun loads
|
|
||||||
// libraries from the nix store, which garbage collection can delete: on Linux
|
|
||||||
// its C library, on macOS its ICU. The script therefore downloads bun's own
|
|
||||||
// release build, which loads only the system's libraries.
|
|
||||||
//
|
|
||||||
// What the script promises, and why each test matters:
|
|
||||||
//
|
|
||||||
// - Each system, machine and C library that bun publishes a build for gets its
|
|
||||||
// own archive: Linux on x86-64 or arm64, with glibc or with musl, as on
|
|
||||||
// Alpine, and macOS on Intel or Apple silicon. A bun built for another
|
|
||||||
// system, machine or C library does not start, and `make install` fails.
|
|
||||||
// - The archive's SHA-256 must match the one in the script before anything in
|
|
||||||
// the archive runs. Without that check, a tampered download would build the
|
|
||||||
// binary that cron runs.
|
|
||||||
// - Any other system or machine stops with an error, and never falls back to
|
|
||||||
// nix's bun. Building with nix's bun would bring back the dependency on the
|
|
||||||
// nix store.
|
|
||||||
//
|
|
||||||
// Each test copies the script into a fresh directory, which the script then
|
|
||||||
// treats as the checkout, and runs it there. Stubs first on PATH stand in for
|
|
||||||
// `uname`, `ldd`, `curl`, `unzip`, `nix-shell` and `shasum`, so no test
|
|
||||||
// downloads anything or runs a real bun.
|
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
||||||
import { execFileSync, spawnSync } from "node:child_process";
|
|
||||||
import {
|
|
||||||
chmodSync,
|
|
||||||
copyFileSync,
|
|
||||||
existsSync,
|
|
||||||
mkdirSync,
|
|
||||||
mkdtempSync,
|
|
||||||
readFileSync,
|
|
||||||
rmSync,
|
|
||||||
symlinkSync,
|
|
||||||
writeFileSync,
|
|
||||||
} from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
|
|
||||||
|
|
||||||
// The stubs, keyed by the command each replaces. `uname` and `ldd` describe the
|
|
||||||
// system that runBuildBin passes in the environment. `curl`, `unzip`,
|
|
||||||
// `nix-shell` and `shasum` write the arguments they were given, one per line,
|
|
||||||
// to a file named after the command in $STUB_LOG_DIR, which also shows that
|
|
||||||
// they ran.
|
|
||||||
const stubs: Record<string, string> = {
|
|
||||||
uname: `
|
|
||||||
case "$1" in
|
|
||||||
-s) echo "$STUB_UNAME_S" ;;
|
|
||||||
-m) echo "$STUB_UNAME_M" ;;
|
|
||||||
esac`,
|
|
||||||
// The script looks only at the start of the line: "musl libc" or not.
|
|
||||||
ldd: `
|
|
||||||
if [ "$STUB_LIBC" = musl ]; then
|
|
||||||
echo "musl libc (x86_64)"
|
|
||||||
else
|
|
||||||
echo "ldd (GNU libc) 2.36"
|
|
||||||
fi`,
|
|
||||||
// Writes a file that is not the archive to the path after -o.
|
|
||||||
curl: `
|
|
||||||
printf '%s\\n' "$@" > "$STUB_LOG_DIR/curl"
|
|
||||||
while [ $# -gt 1 ]; do
|
|
||||||
if [ "$1" = -o ]; then echo "not the archive" > "$2"; fi
|
|
||||||
shift
|
|
||||||
done`,
|
|
||||||
// Unpacks a stub bun where the real archive puts bun: in a directory named
|
|
||||||
// after the archive, inside the directory after -d. That bun writes
|
|
||||||
// bin/quak, as a real compile would.
|
|
||||||
unzip: `
|
|
||||||
printf '%s\\n' "$@" > "$STUB_LOG_DIR/unzip"
|
|
||||||
for arg in "$@"; do
|
|
||||||
case "$arg" in *.zip) archive="$arg" ;; esac
|
|
||||||
if [ "$previous" = -d ]; then directory="$arg"; fi
|
|
||||||
previous="$arg"
|
|
||||||
done
|
|
||||||
bun="$directory/$(basename "$archive" .zip)/bun"
|
|
||||||
mkdir -p "$(dirname "$bun")"
|
|
||||||
echo '#!/bin/sh' > "$bun"
|
|
||||||
echo 'mkdir -p bin && echo binary > bin/quak' >> "$bun"
|
|
||||||
chmod +x "$bun"`,
|
|
||||||
"nix-shell": `
|
|
||||||
printf '%s\\n' "$@" > "$STUB_LOG_DIR/nix-shell"`,
|
|
||||||
// The script checks the hash with `shasum` where there is no `sha256sum`.
|
|
||||||
// The stub fails, as `shasum` does when the hash does not match.
|
|
||||||
shasum: `
|
|
||||||
printf '%s\\n' "$@" > "$STUB_LOG_DIR/shasum"
|
|
||||||
exit 1`,
|
|
||||||
};
|
|
||||||
|
|
||||||
let checkout = "";
|
|
||||||
|
|
||||||
const writeScript = (path: string, body: string): void => {
|
|
||||||
writeFileSync(path, `#!/bin/sh\n${body}\n`);
|
|
||||||
chmodSync(path, 0o755);
|
|
||||||
};
|
|
||||||
|
|
||||||
// A checkout holding the script, a stub script/build that does nothing, the
|
|
||||||
// stubs, and the directory they log to.
|
|
||||||
beforeEach(() => {
|
|
||||||
checkout = mkdtempSync(join(tmpdir(), "quak-build-bin-"));
|
|
||||||
mkdirSync(join(checkout, "script"));
|
|
||||||
copyFileSync(
|
|
||||||
join(repoRoot, "script/build-bin"),
|
|
||||||
join(checkout, "script/build-bin"),
|
|
||||||
);
|
|
||||||
chmodSync(join(checkout, "script/build-bin"), 0o755);
|
|
||||||
writeScript(join(checkout, "script/build"), "exit 0");
|
|
||||||
mkdirSync(join(checkout, "stubs"));
|
|
||||||
for (const [command, body] of Object.entries(stubs)) {
|
|
||||||
writeScript(join(checkout, "stubs", command), body);
|
|
||||||
}
|
|
||||||
mkdirSync(join(checkout, "log"));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
rmSync(checkout, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Runs the script with nothing in its environment but the stubs' settings and a
|
|
||||||
// PATH. Unless the caller passes one, the PATH finds the stubs first and then
|
|
||||||
// the host's commands.
|
|
||||||
const runBuildBin = (
|
|
||||||
system: string,
|
|
||||||
machine: string,
|
|
||||||
{
|
|
||||||
libc = "glibc",
|
|
||||||
path = `${join(checkout, "stubs")}:${process.env.PATH}`,
|
|
||||||
} = {},
|
|
||||||
) =>
|
|
||||||
spawnSync(join(checkout, "script/build-bin"), {
|
|
||||||
cwd: checkout,
|
|
||||||
encoding: "utf-8",
|
|
||||||
env: {
|
|
||||||
PATH: path,
|
|
||||||
STUB_UNAME_S: system,
|
|
||||||
STUB_UNAME_M: machine,
|
|
||||||
STUB_LIBC: libc,
|
|
||||||
STUB_LOG_DIR: join(checkout, "log"),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// The real commands that the script and the stubs run.
|
|
||||||
const hostCommands = [
|
|
||||||
"basename",
|
|
||||||
"chmod",
|
|
||||||
"dirname",
|
|
||||||
"grep",
|
|
||||||
"mkdir",
|
|
||||||
"mktemp",
|
|
||||||
"rm",
|
|
||||||
];
|
|
||||||
|
|
||||||
// A Mac has no `ldd`, and not every Mac has `sha256sum`. This makes a PATH that
|
|
||||||
// finds neither: the stubs other than `ldd`, then links to hostCommands.
|
|
||||||
const makeMacPath = (): string => {
|
|
||||||
rmSync(join(checkout, "stubs", "ldd"));
|
|
||||||
mkdirSync(join(checkout, "host-commands"));
|
|
||||||
for (const command of hostCommands) {
|
|
||||||
const target = execFileSync("sh", ["-c", `command -v ${command}`], {
|
|
||||||
encoding: "utf-8",
|
|
||||||
}).trim();
|
|
||||||
symlinkSync(target, join(checkout, "host-commands", command));
|
|
||||||
}
|
|
||||||
return `${join(checkout, "stubs")}:${join(checkout, "host-commands")}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ran = (command: string): boolean =>
|
|
||||||
existsSync(join(checkout, "log", command));
|
|
||||||
|
|
||||||
const argumentsOf = (command: string): string[] =>
|
|
||||||
readFileSync(join(checkout, "log", command), "utf-8")
|
|
||||||
.trimEnd()
|
|
||||||
.split("\n");
|
|
||||||
|
|
||||||
describe("script/build-bin", () => {
|
|
||||||
// The stub curl writes a file that is not the archive, so the hash check
|
|
||||||
// must stop the script before unzip runs. A script without the check would
|
|
||||||
// run unzip, and the stub bun it unpacks would write bin/quak. On Linux the
|
|
||||||
// check must use `sha256sum`, since a stock Alpine has no `shasum`.
|
|
||||||
it.each([
|
|
||||||
["Linux", "x86_64", "glibc", "bun-linux-x64-baseline.zip"],
|
|
||||||
["Linux", "x86_64", "musl", "bun-linux-x64-musl-baseline.zip"],
|
|
||||||
["Linux", "aarch64", "glibc", "bun-linux-aarch64.zip"],
|
|
||||||
["Linux", "aarch64", "musl", "bun-linux-aarch64-musl.zip"],
|
|
||||||
])(
|
|
||||||
"on %s %s with %s, downloads %s and stops when its hash does not match",
|
|
||||||
(system, machine, libc, archive) => {
|
|
||||||
const result = runBuildBin(system, machine, { libc });
|
|
||||||
expect(argumentsOf("curl")).toContain(
|
|
||||||
"https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/" +
|
|
||||||
archive,
|
|
||||||
);
|
|
||||||
expect(ran("shasum")).toBe(false);
|
|
||||||
expect(result.status).not.toBe(0);
|
|
||||||
expect(ran("unzip")).toBe(false);
|
|
||||||
expect(ran("nix-shell")).toBe(false);
|
|
||||||
expect(existsSync(join(checkout, "bin/quak"))).toBe(false);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// On the PATH from makeMacPath the script finds no `sha256sum` and checks
|
|
||||||
// the hash with the stub `shasum`. That stub fails, so the script must stop
|
|
||||||
// before unzip runs.
|
|
||||||
// On Apple silicon `uname -m` says arm64, where bun's archive says aarch64.
|
|
||||||
it.each([
|
|
||||||
["Darwin", "x86_64", "bun-darwin-x64-baseline.zip"],
|
|
||||||
["Darwin", "arm64", "bun-darwin-aarch64.zip"],
|
|
||||||
])(
|
|
||||||
"on %s %s, downloads %s and stops when shasum rejects its hash",
|
|
||||||
(system, machine, archive) => {
|
|
||||||
const result = runBuildBin(system, machine, {
|
|
||||||
path: makeMacPath(),
|
|
||||||
});
|
|
||||||
expect(argumentsOf("curl")).toContain(
|
|
||||||
"https://github.com/oven-sh/bun/releases/download/bun-v1.3.13/" +
|
|
||||||
archive,
|
|
||||||
);
|
|
||||||
expect(argumentsOf("shasum")).toEqual(["-a", "256", "-c", "-"]);
|
|
||||||
expect(result.status).not.toBe(0);
|
|
||||||
expect(ran("unzip")).toBe(false);
|
|
||||||
expect(ran("nix-shell")).toBe(false);
|
|
||||||
expect(existsSync(join(checkout, "bin/quak"))).toBe(false);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
["Linux", "riscv64"],
|
|
||||||
["FreeBSD", "amd64"],
|
|
||||||
])(
|
|
||||||
"stops on %s %s, which bun publishes no build for",
|
|
||||||
(system, machine) => {
|
|
||||||
const result = runBuildBin(system, machine);
|
|
||||||
expect(result.status).not.toBe(0);
|
|
||||||
expect(ran("curl")).toBe(false);
|
|
||||||
expect(ran("nix-shell")).toBe(false);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
Reference in new issue
Block a user