1 Commits
Author SHA1 Message Date
sneak c80651b43d README.md rewritten sentence by sentence (closes #219)
check / check (push) Canceled after 0s
The old text served as the list of what a reader needs covered. The new
text leads with what quak is, what it is for and how to use it. Every
command, option, exit code and file layout follows in reference sections,
then the library reference, then design, entrypoints and development.
Every statement was checked against the code on next; the places where
the old text disagreed with it are listed in the pull request. No sentence
runs past about 30 words, every table is now a list, and no issue links or
project history remain in text meant for users. Two code comments that
named README sections now name the new ones.

Model: fable-5-1
2026-10-26 11:34:55 +00:00
3 changed files with 218 additions and 292 deletions
+210 -281
View File
@@ -72,8 +72,7 @@ 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 add the clone to and `dist/` is not committed. Clone and build it as above, then add the clone to
your own program's `package.json` by its path, for example your own program's `package.json` by its path, for example
`yarn add file:../quak`. The package's `main`, `types` and `exports` fields `yarn add file:../quak`. The package's `main`, `types` and `exports` fields
point into `dist/`, so the clone must be built first. The package is an ES point into `dist/`, so the clone must be built first.
module only, so a CommonJS program cannot `require` it.
```ts ```ts
import { Client, Library } from "quak"; import { Client, Library } from "quak";
@@ -89,9 +88,7 @@ const client = await Client.login({
const lib = await Library.open({ const lib = await Library.open({
client, client,
// Without these two, open() also starts downloading every thumbnail in // Without these two, open() also starts downloading every thumbnail in
// the account, and the originals of the favorites album and the latest // the account and the latest week's originals into the cache.
// week, into the cache. It fetches machine-learning data after each
// refresh either way.
precacheThumbnails: false, precacheThumbnails: false,
precacheOriginals: false, precacheOriginals: false,
}); });
@@ -121,13 +118,8 @@ await lib.close();
`Library.open({ client })` with no other options does more than the example asks `Library.open({ client })` with no other options does more than the example asks
for. It refreshes every 3 seconds until `close()`. It also downloads, in the for. It refreshes every 3 seconds until `close()`. It also downloads, in the
background, every thumbnail in the account and the originals of the favorites background, every thumbnail in the account and the originals of the favorites
album and of the latest week (see "Opening a library"). The example turns those album and of the latest week (see "Opening a library"). The example turns the
two downloads off. After each refresh that succeeds, the library also fetches downloads off.
each file's machine-learning data into the cache. That is the faces and the
other data Ente's apps compute for a photo (see "The JSON beside each
original"). No option in `LibraryOptions` turns this off. A `client` without a
`fetchMLData` method fetches none, which is how quak's own read commands open
their library.
The `Client` under the library handles login, saved sessions and the raw The `Client` under the library handles login, saved sessions and the raw
requests. The "Library reference" below covers both. requests. The "Library reference" below covers both.
@@ -136,9 +128,9 @@ requests. The "Library reference" below covers both.
`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`. Besides what Getting Started needs, one binary and copies it to `~/bin/quak`. Besides what Getting Started needs,
`make install` needs `make` and [nix](https://nixos.org/download). It uses bun, `make install` needs [nix](https://nixos.org/download). It uses bun, a
a JavaScript runtime, to bundle the built `dist/` into one executable. It runs JavaScript runtime, to bundle the built `dist/` into one executable. It runs bun
bun through `nix-shell -p bun`. through `nix-shell -p bun`.
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:
@@ -158,10 +150,9 @@ output to `~/quak-backup.log`. Sunday's run adds `--verify`:
A run with `--verify` does all a plain run does. It also hashes each original A run with `--verify` does all a plain run does. It also hashes each original
already in the backup and compares the result with the content hash. The content already in the backup and compares the result with the content hash. The content
hash is the hash of the file's bytes that Ente recorded at upload. The run hash is the hash of the file's bytes that Ente recorded at upload. It replaces
fetches a new copy of each original that does not match, and replaces the any original that does not match. Sunday's run is the `--verify` run, rather
original once the copy matches. Sunday's run is the `--verify` run, rather than than a second job that night, because two backups of one directory cannot run at
a second job that night, because two backups of one directory cannot run at
once. The second one exits 2 without doing anything. once. The second one exits 2 without doing anything.
Cron runs the job with a short `PATH`, usually `/usr/bin:/bin`, so the crontab Cron runs the job with a short `PATH`, usually `/usr/bin:/bin`, so the crontab
@@ -230,12 +221,9 @@ set, it asks on the terminal. The password shows as `*` characters while it is
typed. typed.
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, and
only second factor is a passkey cannot log in with quak. When the account has `Email verification code: ` for a code sent by email. An account whose only
email verification on, quak asks `Email verification code: ` for the code sent second factor is a passkey cannot log in with quak.
by email. The password is then used only to unlock the keys. After the email
code quak expects the login to complete. When the server asks for a second
factor at that point, login fails.
On success it logs `Logged in as <email> (user <id>)` and On success it logs `Logged in as <email> (user <id>)` and
`Session saved to <path>`. On failure it logs `Login failed: <reason>`, and the `Session saved to <path>`. On failure it logs `Login failed: <reason>`, and the
@@ -245,9 +233,7 @@ to stdout.
#### `quak whoami` #### `quak whoami`
Prints the saved session's account on stdout as one line of JSON: Prints the saved session's account on stdout as one line of JSON:
`{"email":"you@example.com","userID":12345}`. It reads the session file only and `{"email":"you@example.com","userID":12345}`.
sends no request, so it succeeds even when the server no longer accepts the
session.
#### `quak logout` #### `quak logout`
@@ -314,22 +300,21 @@ refused, because both files would land on it.
#### `quak get-thumb <fileID> [--out <path>]` #### `quak get-thumb <fileID> [--out <path>]`
Downloads and decrypts one file's thumbnail, the small JPEG Ente shows in a Downloads and decrypts one file's thumbnail, the small JPEG Ente shows in a
grid. It writes one file, also for a live photo. Without `--out`, the name is grid. It works as `get` does. Without `--out`, the name is `thumb_` followed by
`thumb_` followed by the title. In all else it works as `get` does. the title.
#### `quak backup <dir> [--json] [--verify]` #### `quak backup <dir> [--json] [--verify]`
Backs up every file in the account to `<dir>`. The layout is described under Backs up every file in the account to `<dir>`. The layout is described under
"The backup directory". The command downloads only the originals that are not "The backup directory". It downloads only the originals that are not yet in the
yet in the backup, and it goes on past a file that fails. A file is downloaded backup, and it goes on past a file that fails. A file is downloaded once,
once, however many collections hold it. An original the cache already holds is however many collections hold it. An original the cache already holds is copied
copied from there instead. from there instead.
Progress goes to stderr: one line per original fetched, and a `FAILED` line for Progress goes to stderr: one line per original fetched, and a `FAILED` line for
each original or album that failed. The run ends with a summary of counts and, each file or album that failed. The run ends with a summary of counts and a
when any file failed, a `Failed files:` list of every failed file. With `Failed files:` list. With `--json`, the summary and the list are replaced by
`--json`, the summary and the list are replaced by one JSON object on stdout. one JSON object on stdout. Its fields are:
Its fields are:
- `totalFiles`: how many distinct files the run covered, however many - `totalFiles`: how many distinct files the run covered, however many
collections hold each; collections hold each;
@@ -362,29 +347,26 @@ Writes the metadata quak keeps about the account, its collections and its files
as JSON files under `<dir>`. The layout is described under "The metadata dump as JSON files under `<dir>`. The layout is described under "The metadata dump
directory". Photo content is not written. With `--exif`, or its alias `--all`, directory". Photo content is not written. With `--exif`, or its alias `--all`,
the command also fetches each file's original and records the image's own the command also fetches each file's original and records the image's own
metadata from it (see "The metadata dump directory"). Each original it fetches metadata from it (see "The metadata dump directory").
stays in the cache.
Ente's apps compute machine-learning data for each photo, such as the faces in The command fetches the machine-learning data Ente holds for each file, in
it (see "The JSON beside each original"). The command fetches it in requests of requests of up to 200 files. When a request fails, each of its files is written
up to 200 files. When a request fails, each of its files is written with the with the reason in `mlDataError` instead of `mlData`, and the dump goes on. A
reason in `mlDataError` instead of `mlData`, and the dump goes on. A file whose file whose data does not decrypt, decompress or parse also gets `mlDataError`.
data does not decrypt, decompress or parse also gets `mlDataError`. When any When any file was written with `mlDataError`, the last line says how many and
file was written with `mlDataError`, the last line says how many and the exit the exit status is 1.
status is 1.
A collection or file that quak cannot decrypt is left out of the dump without a A collection or file that quak cannot decrypt is left out of the dump without a
word, and does not change the exit status. word, and does not change the exit status.
#### `quak helper list-missing-thumbnails [--json]` #### `quak helper list-missing-thumbnails [--json]`
Finds the files whose thumbnail is missing on the server. It downloads the Finds the files whose thumbnail is missing on the server. It downloads every
thumbnail of every file you own, one collection at a time, newest updated first, thumbnail in the account, one collection at a time, newest collection first, so
so on a large account it takes a while. A thumbnail counts as missing when the on a large account it takes a while. A thumbnail counts as missing when the
server answers with an empty body or with HTTP 404. Any other failure is logged server answers with an empty body or with HTTP 404. Any other failure is logged
as `[<collection>] Could not check <title>: <reason> (not reported as missing)`, as `[<collection>] Could not check <title>: <reason> (not reported as missing)`,
and the file is left out of the list. The command exits 0 even when some files and the file is left out of the list.
could not be checked.
The server lets only a file's owner replace its thumbnail. A file another The server lets only a file's owner replace its thumbnail. A file another
account owns, in a collection shared with you, is skipped and not checked. account owns, in a collection shared with you, is skipped and not checked.
@@ -417,7 +399,7 @@ Each file ends in one of three states:
- `fixed`: a thumbnail was uploaded. - `fixed`: a thumbnail was uploaded.
- `skipped`: the helper cannot make a thumbnail for the file, or the server - `skipped`: the helper cannot make a thumbnail for the file, or the server
would refuse it. This covers: would refuse it. A skipped file does not affect the exit status. This covers:
- a file another account owns; - a file another account owns;
- a file that is not an image; - a file that is not an image;
- an image that is not a JPEG; - an image that is not a JPEG;
@@ -427,11 +409,10 @@ Each file ends in one of three states:
`--file` is held by no collection. `--file` is held by no collection.
Without `--json`, the command ends with counts of fixed, skipped and failed Without `--json`, the command ends with counts of fixed, skipped and failed
files. A `Skipped:` list and a `Failed files:` list follow when they have files, a `Skipped:` list and a `Failed files:` list. With `--json`, one JSON
entries. With `--json`, one JSON array on stdout replaces them; each object array on stdout replaces them; each object holds `fileID`, `title`,
holds `fileID`, `title`, `collection`, `status` and, for a skipped or failed `collection`, `status` and, for a skipped or failed file, `reason`. When any
file, `reason`. When any file failed, the exit status is 1. A skipped file does file failed, the exit status is 1. When nothing needs fixing, the command logs
not change it. When nothing needs fixing, the command logs
`No missing thumbnails found.` and exits 0. With `--json` it then writes nothing `No missing thumbnails found.` and exits 0. With `--json` it then writes nothing
on stdout. on stdout.
@@ -464,9 +445,9 @@ collection fetched, such as `Fetched the files of 3 of 120 collections`.
When a refresh or a search fails, the command logs the error on one line and When a refresh or a search fails, the command logs the error on one line and
exits 1, or 3 when the server no longer accepts the saved session. A collection exits 1, or 3 when the server no longer accepts the saved session. A collection
or file that quak cannot decrypt does not stop a refresh or a search: the or file that quak cannot decrypt does not stop a refresh: the command answers
command leaves it out and answers from what did decrypt (see "Default reads and from what did decrypt (see "Default reads and fresh reads"). `backup` counts it
fresh reads"). `backup` counts it as failed (see "What quak cannot decrypt"). as failed (see "What quak cannot decrypt").
### IDs ### IDs
@@ -478,20 +459,24 @@ before any request is sent. The command logs `Invalid file ID: <arg>` or
### What quak prints ### What quak prints
stdout holds a command's output, such as the listing or the JSON, and the stdout holds only a command's output: the listing, the JSON, or nothing. The
prompts of `login`. Every other line goes to stderr as a log line at one of six prompts of `login` go to stdout too. Every other line goes to stderr, and every
levels. From the quietest, the levels are fatal, error, warning, info, debug and stderr line is a log line at one of six levels. From the quietest, the levels
trace. A run prints the lines at its own level and at every quieter one. The are fatal, error, warning, info, debug and trace. A run prints the lines at its
level is info unless `-v`, `-vv` or `-q` says otherwise. own level and at every quieter one. The level is info unless `-v`, `-vv` or `-q`
says otherwise.
- Fatal: why a command exits non-zero. A command that runs and exits non-zero `yarn quak` prints yarn's own lines on stdout as well. To pipe a command's
logs at least one fatal line. A command that exits 1 because files or albums output, run `yarn --silent quak files --json` or
failed logs it last, saying how many failed and where to look. `node dist/bin/quak.js files --json` instead.
- Error: something that failed without ending the run. These lines are:
- the `FAILED` lines of `backup` and `fix-missing-thumbnails`; - Fatal: why a command exits non-zero. Every command that exits non-zero logs at
- their `Failed files:` lists; least one fatal line. A command that exits 1 because files or albums failed
- the `ML data` lines of `backup-metadata`; logs it last, saying how many failed and where to look.
- `[<collection>] Could not check <title>` from the thumbnail helpers. - Error: something that failed without ending the run. These are the `FAILED`
lines of `backup` and `fix-missing-thumbnails`, their `Failed files:` lists,
the `ML data` lines of `backup-metadata`, and
`[<collection>] Could not check <title>` from the thumbnail helpers.
- Warning: something that did not fail but needs your attention. The only - Warning: something that did not fail but needs your attention. The only
warning is the one `logout` prints: the cache directory still holds decrypted warning is the one `logout` prints: the cache directory still holds decrypted
data. data.
@@ -508,22 +493,12 @@ level is info unless `-v`, `-vv` or `-q` says otherwise.
one that exits non-zero prints only its fatal lines. The prompts of `login` are one that exits non-zero prints only its fatal lines. The prompts of `login` are
not log lines, and `-q` leaves them alone. not log lines, and `-q` leaves them alone.
Two things on stderr are not log lines. A command line that quak cannot parse,
such as a missing argument, an unknown option or `-q` with `-v`, is refused with
an `error: …` message, and quak exits 1. `-q` does not hide the message. `quak`
with no command prints the usage on stderr and exits 1.
`yarn quak` prints yarn's own lines on stdout as well. To pipe a command's
output, run `yarn --silent quak files --json` or
`node dist/bin/quak.js files --json` instead.
With `--json`, a command's JSON on stdout takes the place of its summary on With `--json`, a command's JSON on stdout takes the place of its summary on
stderr. For `backup`, that is the `--- Backup complete ---` line, the counts and stderr. For `backup`, that is the lines from `--- Backup complete ---` to the
the `Failed files:` list. For `list-missing-thumbnails`, it is end of the `Failed files:` list. For `list-missing-thumbnails`, it is
`No missing thumbnails found.` or `<n> file(s) with missing thumbnails:`. For `No missing thumbnails found.` or `<n> file(s) with missing thumbnails:`. For
`fix-missing-thumbnails`, it is the `--- Done ---` line, the counts, the `fix-missing-thumbnails`, it is the lines from `--- Done ---` to the end of the
`Skipped:` list and the `Failed files:` list. Every other line is logged as `Failed files:` list. Every other line is logged as without `--json`.
without `--json`.
### Exit codes ### Exit codes
@@ -543,8 +518,9 @@ without `--json`.
An unexpected error is printed as one line, `quak: <message>`, with no stack An unexpected error is printed as one line, `quak: <message>`, with no stack
trace. `<message>` is the error's own message, followed by the message of each trace. `<message>` is the error's own message, followed by the message of each
error that caused it, separated by `: `. Some lines a command logs itself, such error that caused it, separated by `: `. An error a command expects, such as
as `File <id> not found` and `Login failed: <reason>`, have no prefix. `File <id> not found` or `Login failed: <reason>`, is printed without the
prefix.
`yarn quak` runs quak under Node. Node's `fetch` reports every network failure `yarn quak` runs quak under Node. Node's `fetch` reports every network failure
as `fetch failed`, with the reason as its cause. A run that could not reach the as `fetch failed`, with the reason as its cause. A run that could not reach the
@@ -577,8 +553,8 @@ account's three keys, in base64 and unencrypted. Treat it as you would treat the
password. A missing file means "not logged in". A file that cannot be read back password. A missing file means "not logged in". A file that cannot be read back
is reported as corrupt, with the reason. Run `quak logout`, then `quak login`. 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 Both cases exit 3, and so does a session the server no longer accepts: the
also exits 3 when the server no longer accepts the session, and prints command then prints
`quak: the saved session is no longer valid; run "quak login"`. `quak backup` `quak: the saved session is no longer valid; run "quak login"`. `quak backup`
learns this at its first refresh. By then it has written only its lock. When the learns this at its first refresh. By then it has written only its lock. When the
session stops working partway through a backup, each remaining file goes into session stops working partway through a backup, each remaining file goes into
@@ -630,8 +606,8 @@ holds a second image or video. It also refuses one whose files unpack to more
than 20 times its size plus 16 MiB. An original with no recorded hash, uploaded than 20 times its size plus 16 MiB. An original with no recorded hash, uploaded
by a very old client, is stored unchecked. by a very old client, is stored unchecked.
The cache has a size limit for originals (see "Opening a library"). Thumbnails The originals in the cache are bounded (see "Opening a library"). Thumbnails are
have none. A live photo's image and video leave the cache together. not. A live photo's image and video leave the cache together.
### The backup directory ### The backup directory
@@ -689,13 +665,13 @@ The JSON file beside an original holds:
Update times are in microseconds, as Ente records them. Update times are in microseconds, as Ente records them.
The JSON also holds the machine-learning data Ente computed for the photo, as The file also holds the machine-learning data Ente computed for the file, as
`mlData`. Ente's apps detect faces in each photo and compute a CLIP embedding `mlData`. Ente's apps detect faces in each photo and compute a CLIP embedding
for it, a list of 512 numbers that describes what the photo shows. `mlData` for it, a list of 512 numbers that describes what the photo shows. `mlData`
holds everything but the `clip` block, which holds that embedding. In its place holds everything but that embedding. In its place the file holds `keywords`,
the JSON holds `keywords`, derived from the embedding as described under derived from the embedding as described under "Keywords". A file with no
"Keywords". A file with no machine-learning data has neither field. A file whose machine-learning data has neither field. A file whose data holds no usable
data holds no usable embedding has no `keywords`. embedding has no `keywords`.
The backup waits for the library's fetch of machine-learning data to finish The backup waits for the library's fetch of machine-learning data to finish
before it writes the JSON files. When a file's data cannot be fetched, before it writes the JSON files. When a file's data cannot be fetched,
@@ -705,13 +681,13 @@ with an error starting `ML data: `. Nothing is cached for it, so the next run
asks for its data again. The JSON files are rewritten on every run, so data that asks for its data again. The JSON files are rewritten on every run, so data that
arrived since the last run appears. arrived since the last run appears.
Every file but a video also holds `imageMetadata`: what the original itself For an image, the file also holds `imageMetadata`: what the original itself
records. That is its `format`, `width` and `height` for a JPEG, its EXIF tags as records. That is its `format`, `width` and `height` for a JPEG, its EXIF tags as
`exif`, and its XMP packet as `xmp`. EXIF and XMP are the two blocks of metadata `exif`, and its XMP packet as `xmp`. EXIF and XMP are the two blocks of metadata
a camera or an editor writes inside an image file. For a live photo, these come a camera or an editor writes inside an image file. For a live photo, these come
from its image. An original with none of them gets `{}`. When the original from its image. An original with none of them gets `{}`. A video gets no
cannot be read, the reason is in `imageMetadataError` instead, and neither the `imageMetadata`. When the original cannot be read, the reason is in
file nor the run fails. `imageMetadataError` instead, and neither the file nor the run fails.
EXIF is read with [exifreader](https://github.com/mattiasw/ExifReader) from any EXIF is read with [exifreader](https://github.com/mattiasw/ExifReader) from any
image format it reads, such as JPEG, HEIC, AVIF, PNG and WebP. `exif` holds each image format it reads, such as JPEG, HEIC, AVIF, PNG and WebP. `exif` holds each
@@ -748,11 +724,11 @@ derives a file's keywords the way the app sorts it. The categories are:
Each category has a query, and a text embedding of that query from Ente's CLIP Each category has a query, and a text embedding of that query from Ente's CLIP
text model. A file's score for a category is the cosine similarity of the file's text model. A file's score for a category is the cosine similarity of the file's
embedding and the category's. That is a number from -1 to 1; the more alike the embedding and the category's: a number from -1 to 1, higher the more alike the
two embeddings are, the higher it is. The file falls in the category when its two embeddings are. The file falls in the category when its score reaches the
score reaches the category's minimum score. The keywords are the titles of the category's minimum score. The keywords are the titles of the categories the file
categories the file falls in, highest score first. A video never falls in a falls in, highest score first. A video never falls in a category marked "no
category marked "no videos", as in the app. videos", as in the app.
#### Collection directories #### Collection directories
@@ -775,24 +751,20 @@ ignoring case, each gets its ID added: two albums named `Trip` become
`Trip (10)/` and `Trip (11)/`. Two files in one collection whose titles match, `Trip (10)/` and `Trip (11)/`. Two files in one collection whose titles match,
ignoring case, get their IDs added before the extension: `IMG_0001 (12345).JPG` ignoring case, get their IDs added before the extension: `IMG_0001 (12345).JPG`
and `IMG_0001 (12346).JPG`. A collection named like another collection's JSON and `IMG_0001 (12346).JPG`. A collection named like another collection's JSON
file, such as `Trip.json` beside `Trip`, also gets its ID added. A file name file, such as `Trip.json` beside `Trip`, also gets its ID added. A name too long
longer than 255 bytes, the limit on most file systems, gets its ID added too. So for a file name, which most file systems limit to 255 bytes, gets its ID added
does a collection name longer than 250 bytes, which leaves room for `.json`. too. It is then cut short before the ID, just far enough to fit. IDs never
Either name is then cut short before the ID, just far enough to fit. IDs never
change, so a name stays the same from run to run until such a clash appears or change, so a name stays the same from run to run until such a clash appears or
goes away. goes away.
Each run removes every symlink in a collection's directory that points into the Each run removes the symlinks that no longer belong in a collection's directory.
date folders and is not one of its current links, yours included. A file or It removes the directory and JSON of a collection that was deleted or renamed,
symlink of yours at the name of one of its links is replaced by the link. A once the directory holds nothing else. Nothing else under `collections/` is
directory of yours at that name stays, and the photo whose link it blocks goes touched: a file or symlink you put there stays, and a directory that still holds
into `failures.json`. Anything else you put under `collections/` stays. A run one stays too, with its JSON.
removes the directory and JSON of a collection that was deleted or renamed, once
the directory holds nothing else. One that still holds something of yours stays,
with its JSON.
A collection whose directory or JSON cannot be written does not stop the run. A collection whose directory or JSON cannot be written does not stop the run.
For example, a file you put there may already have the directory's name. The run One whose directory name is taken by a file you put there is an example. The run
logs `FAILED album <name>: <reason>`, lists the collection's ID in logs `FAILED album <name>: <reason>`, lists the collection's ID in
`failedAlbums`, and exits 1. The other collections and `failures.json` are still `failedAlbums`, and exits 1. The other collections and `failures.json` are still
written. written.
@@ -823,8 +795,7 @@ stores the two files, which a photo viewer can open: each is
`YYYY-MM-DD.<fileID>.<ext>` with the extension it has inside the ZIP, such as `YYYY-MM-DD.<fileID>.<ext>` with the extension it has inside the ZIP, such as
`2026-03-01.12345.heic` and `2026-03-01.12345.mov`. The file `2026-03-01.12345.heic` and `2026-03-01.12345.mov`. The file
`YYYY-MM-DD.<fileID>.livephoto.json` beside them names which is which. The live `YYYY-MM-DD.<fileID>.livephoto.json` beside them names which is which. The live
photo counts as stored only when its `.livephoto.json` and both files it names photo counts as stored only when both files are present and not empty.
are present and not empty.
#### `failures.json` #### `failures.json`
@@ -834,9 +805,8 @@ Each entry holds:
- `fileID` and `title`; - `fileID` and `title`;
- `classification`: the kind of failure; - `classification`: the kind of failure;
- `attempts`: how many runs in a row have failed the file; - `attempts`: how many runs have tried the file;
- `lastTriedAt`: when the run that last tried it finished its refresh, as a Unix - `lastTriedAt`: when it was last tried, as a Unix time in milliseconds;
time in milliseconds;
- `error`: the error, as quak printed it. - `error`: the error, as quak printed it.
The kinds are: The kinds are:
@@ -847,30 +817,29 @@ The kinds are:
A file leaves the list once it succeeds, or once it is no longer in the library. A file leaves the list once it succeeds, or once it is no longer in the library.
A library caller that backs up only some albums (`onlyAlbumNames`) also drops a A library caller that backs up only some albums (`onlyAlbumNames`) also drops a
file outside them. `failures.json` is deleted when the list is empty. file outside them. The file is deleted when the list is empty.
#### `backup.lock` #### `backup.lock`
`backup.lock` keeps two backups of the same directory from running at once, such `backup.lock` keeps two backups of the same directory from running at once, such
as a cron run that starts while the previous one is still going. A backup as a cron run that starts while the previous one is still going. A backup
creates `<dir>` if it is missing and takes the lock before its refresh, so a creates `<dir>` if it is missing and takes the lock before its refresh, so a
refused run sends no request. A run that still holds the lock removes it when it refused run sends no request. It removes the lock when it ends, whether it
ends, whether it succeeded or failed. succeeded or failed.
The lock is a directory that The lock is a directory that
[proper-lockfile](https://github.com/moxystudio/node-proper-lockfile) creates. [proper-lockfile](https://github.com/moxystudio/node-proper-lockfile) creates
While the backup runs, it updates the modification time of `backup.lock` every 5 and keeps touching every 5 seconds while the backup runs. A second backup of the
seconds. A second backup of `<dir>` fails at once: it prints directory fails at once: it prints `quak: another backup of <dir> is running`
`quak: another backup of <dir> is running` and exits 2. A run that is killed, or and exits 2. A run that is killed, or cut off by a crash, can leave the lock
cut off by a crash, can leave the lock behind. Once the lock has gone 10 seconds behind. Once the lock has gone 10 seconds untouched, the next run takes it over,
without an update, the next run takes it over, so nobody has to remove it by so nobody has to remove it by hand.
hand.
A running backup loses its lock when someone removes `backup.lock`. Another A running backup loses its lock when someone removes `backup.lock`. It also
backup takes the lock over when this one has not updated it for 10 seconds, for loses it when another backup takes it over, because this one left it untouched
example because this process was stopped. The backup notices at its next update for 10 seconds, as a stopped process does. The backup notices the next time it
of the lock, and stops before it starts another file. The originals it is goes to touch the lock, and stops before it starts another file. The originals
downloading at that moment are still stored. It leaves `failures.json` and it is downloading at that moment are still stored. It leaves `failures.json` and
`backup.lock` as they are, since another backup may be using them. It prints `backup.lock` as they are, since another backup may be using them. It prints
`quak: lost the lock on <dir>: <reason>` and exits 1. `quak: lost the lock on <dir>: <reason>` and exits 1.
@@ -903,16 +872,13 @@ The summary and the `--json` output add the counts `verified`, `mismatched` and
`unchecked`. Each counts only originals that were already stored. A mismatch `unchecked`. Each counts only originals that were already stored. A mismatch
that was replaced with matching bytes does not make the exit code non-zero. that was replaced with matching bytes does not make the exit code non-zero.
A run without `--verify` drops two kinds of failure from `failures.json`: A mismatched original whose new copy failed, and a stored original that cannot
be read, stay in `failures.json` only until the next run. A run without
`--verify` finds the original in place, counts it as skipped, and drops the
entry, so that run can exit 0. Only a `--verify` run hashes it again.
- a mismatched original whose new copy failed; Without `--verify`, nothing is hashed, the summary is unchanged, and the three
- a stored original that cannot be read. counts are 0 in the JSON.
That run finds the original in place, counts it as skipped and drops the entry,
so it can exit 0. Only a `--verify` run hashes the original again.
Without `--verify`, no stored original is hashed, the summary is unchanged, and
the three counts are 0 in the JSON.
#### Temporary files and crash safety #### Temporary files and crash safety
@@ -934,12 +900,7 @@ scheme. Opening a library deletes such leftovers in the cache's `originals/` and
The rename replaces whatever was at the destination rather than writing through The rename replaces whatever was at the destination rather than writing through
it. A symlink there is replaced, not followed, and the new file has the it. A symlink there is replaced, not followed, and the new file has the
temporary file's permissions, not those of the file it replaced. Each original temporary file's permissions, not those of the file it replaced.
is written with mode `0600`, readable by its owner only. Everything else the
backup writes, its folders and JSON files included, gets the mode your umask
allows, which usually lets every user on the machine read it. The JSON beside an
original holds the file's location and faces. The `backup-metadata` tree is
written the same way.
### The metadata dump directory ### The metadata dump directory
@@ -955,21 +916,19 @@ written the same way.
the collection the collection
``` ```
Each run writes into `<dir>` and deletes nothing. `account.json` holds `email` `account.json` holds `email` and `userID`. The name after the ID in a
and `userID`. In `<id>-<name>`, the name has unsafe characters replaced. It is collection's directory name has unsafe characters replaced and is cut short so
cut short so the directory name fits in 255 bytes. The ID keeps two collections that the whole name fits in 255 bytes; the ID keeps the directories apart.
of the same name apart.
`_collection.json` holds the fields a backup's collection JSON holds, except `_collection.json` holds the fields a backup's collection JSON holds, except
`files`. Each `<fileID>.json` holds the fields a backup's JSON beside an `files`. Each `<fileID>.json` holds the fields a backup's JSON beside an
original holds, with these differences: original holds, with these differences:
- `imageMetadata` is written only with `--exif`. The original of every file, - `imageMetadata` is written only with `--exif`. The original of every file is
videos included, is then downloaded and read for its EXIF, XMP and JPEG then downloaded, videos included, and read as a backup reads it (see "The JSON
dimensions. beside each original").
- A file whose original holds no image metadata gets no `imageMetadata` field. - A file whose original holds no image metadata gets no `imageMetadata` field.
- An original that cannot be downloaded or read gives `imageMetadataError` - An original that cannot be read gives `imageMetadataError` instead.
instead.
- A file in several collections has its original read once; its image metadata - A file in several collections has its original read once; its image metadata
is written with the first of them. is written with the first of them.
@@ -1047,24 +1006,21 @@ onto what the cache holds, empty or not. A refresh then starts only when
- `contentSource`: a replacement for the client's own byte source. Mainly for - `contentSource`: a replacement for the client's own byte source. Mainly for
tests. tests.
In the content and thumbnail pools, your own requests take the next free slot The two background downloads wait while your own requests run. A file already in
ahead of the two background downloads. A file already in the cache is not the cache is not fetched again. When the originals cache grows past its limit,
fetched again. When the originals cache grows past its limit, the least recently the least recently used originals are evicted, but never a pinned one, and never
used originals are evicted, but never a pinned one, and never one whose fetch is one whose fetch is still in flight.
still in flight.
`onProgress` receives a `RefreshEvent`. Its `operation` names the work: `onProgress` receives a `RefreshEvent`. Its `operation` names the work:
`"refresh"`, `"fetchMLData"` for a machine-learning fetch, and `"refresh"`, `"fetchMLData"` for a machine-learning fetch, and
`"precacheThumbnails"` or `"precacheOriginals"` for the two background `"precacheThumbnails"` or `"precacheOriginals"` for the two background
downloads. Its `status` is `"started"` as the work starts and `"done"` as it downloads. Its `status` is `"started"` as the work starts and `"done"` as it
ends. A refresh or a machine-learning fetch that fails ends with `"failed"` ends. A refresh or a machine-learning fetch that fails ends with `"failed"` and
instead, with the message in `error`. In between, a refresh sends `"progress"` the error message instead. In between, a refresh sends `"progress"` after each
after each collection whose files it fetched: `fetched` is the number of collection whose files it fetched: `fetched` is the number of collections
collections fetched so far, and `total` the number it will fetch. A refresh fetched so far, and `total` the number it will fetch. A refresh fetches the
fetches the files of each collection that is new or changed since the cache's files of each collection that is new or changed since the cache's copy, so on an
copy, so on an empty cache it fetches those of every collection. The `"done"` empty cache it fetches those of every collection.
event of a machine-learning fetch, and of each background download, also carries
`fetched`: how many items it newly stored.
`lib.status()` returns a `LibraryStatus`. It holds the collection and file `lib.status()` returns a `LibraryStatus`. It holds the collection and file
counts. `lastRefreshAt` is the time of the last refresh that succeeded, and counts. `lastRefreshAt` is the time of the last refresh that succeeded, and
@@ -1079,10 +1035,10 @@ cached, so the next fetch asks for it again. The reasons are kept in memory
only. only.
`lib.close()` stops the background timer. It can be called more than once. A `lib.close()` stops the background timer. It can be called more than once. A
refresh in flight is left to finish, with its write to the cache. `close()` refresh in flight is left to finish, with its write to the cache. The promise
returns a promise. It resolves once that refresh, the machine-learning fetch and `close()` returns resolves once that refresh, the machine-learning fetch and the
the background downloads already running have all finished. The cache directory background downloads already running have all finished. The cache directory can
can then be removed. After `close()`, `fresh()`, `freshCollections()`, then be removed. After `close()`, `fresh()`, `freshCollections()`,
`freshFiles()` and `lib.backup()` reject with `the library is closed`. `freshFiles()` and `lib.backup()` reject with `the library is closed`.
### Default reads and fresh reads ### Default reads and fresh reads
@@ -1102,11 +1058,11 @@ running does it start one. When the refresh fails, `fresh()` rejects. Default
reads stay silent and keep serving the last good copy. reads stay silent and keep serving the last good copy.
`await lib.freshCollections()` is the fresh read of the collection list alone: `await lib.freshCollections()` is the fresh read of the collection list alone:
one request, answered with every collection that is not deleted and decrypts, as one request, answered with every live collection that decrypts, as `Collection`
`Collection` objects, in the server's order. It fetches no collection's files objects, in the server's order. It fetches no collection's files and stores
and stores nothing, not even the list. It stores nothing because a refresh nothing, not even the list. It stores nothing because a refresh fetches a
fetches a collection's files only when the collection is new or changed. A collection's files only when the collection is new or changed. A collection
collection stored without its files would never get them. stored without its files would never get them.
`await lib.freshFiles(collection)` is the fresh read of one collection's files, `await lib.freshFiles(collection)` is the fresh read of one collection's files,
as `EnteFile` objects, in the server's order, each file once. `collection` is an as `EnteFile` objects, in the server's order, each file once. `collection` is an
@@ -1133,9 +1089,8 @@ later refresh gets a copy that decrypts or learns that the server deleted it.
- `lib.timeline.groups({ groupBy, filter? })` returns `TimelineGroup[]`, newest - `lib.timeline.groups({ groupBy, filter? })` returns `TimelineGroup[]`, newest
group first. `groupBy` is `"day"`, `"week"` or `"month"`, and the group keys group first. `groupBy` is `"day"`, `"week"` or `"month"`, and the group keys
are `YYYY-MM-DD`, `YYYY-Www` (the ISO week) and `YYYY-MM`. Each group holds are `YYYY-MM-DD`, `YYYY-Www` (the ISO week) and `YYYY-MM`. Each group holds
`key`, `startsAt` and `fileIDs`. `key` is that group key. `startsAt` is the `startsAt`, local time in milliseconds, and `fileIDs`, newest first. Weeks
start of the period, in local-time milliseconds. `fileIDs` lists its files, start on Monday.
newest first. Weeks start on Monday.
A `PhotoFilter` narrows the timeline. Its fields combine with AND: A `PhotoFilter` narrows the timeline. Its fields combine with AND:
@@ -1187,11 +1142,11 @@ progress of that one file.
keyed by tag name, each as exifreader decodes it with its `id`, `value`, keyed by tag name, each as exifreader decodes it with its `id`, `value`,
`description` and `computed` value. For example, `Make` is `description` and `computed` value. For example, `Make` is
`{ id: 271, value: ["Example"], description: "Example", computed: "Example" }`. `{ id: 271, value: ["Example"], description: "Example", computed: "Example" }`.
A tag that exifreader has no name for is keyed `undefined-<tag number>`. The A tag exifreader has no name for is keyed `undefined-<tag number>`. The tags
tags of the thumbnail embedded in the file are under `Thumbnail`, so they of the thumbnail embedded in the file are under `Thumbnail`, so they cannot
cannot hide the main image's tags of the same name. The thumbnail image itself hide the main image's tags of the same name. The thumbnail image itself is
is left out. Any file exifreader cannot read gives `{}`, and a video gives left out. Any file exifreader cannot read gives `{}`, and a video gives `{}`
`{}` without being downloaded. without being downloaded.
- `await photo.make(opts?)` returns one common EXIF field, picked from the tags - `await photo.make(opts?)` returns one common EXIF field, picked from the tags
`exif()` returns and typed as in `PhotoExif`, or `undefined` when the file `exif()` returns and typed as in `PhotoExif`, or `undefined` when the file
lacks it. These methods do the same for their fields: lacks it. These methods do the same for their fields:
@@ -1279,9 +1234,7 @@ symlinks. Once the lock is lost, it rejects with
`BackupOptions`: `BackupOptions`:
- `downloadDirectory`: where the tree lives. Default: the library's. - `downloadDirectory`: where the tree lives. Default: the library's.
- `includeOriginals`: fetch and store originals. Default `true`. With `false`, - `includeOriginals`: fetch and store originals. Default `true`.
the backup also fetches no machine-learning data, and writes no JSON beside
the originals and no symlinks.
- `includeThumbnails`: also store thumbnails as `thumbnails/<fileID>.jpg` beside - `includeThumbnails`: also store thumbnails as `thumbnails/<fileID>.jpg` beside
`collections/`. Default `false`. `quak backup` does not set it. `collections/`. Default `false`. `quak backup` does not set it.
- `onlyAlbumNames`: back up only the albums with these names. - `onlyAlbumNames`: back up only the albums with these names.
@@ -1298,22 +1251,18 @@ releases it once the backup ends.
### Request pools ### Request pools
`RequestPools` holds three independent pools, each with its own limit on how `RequestPools` holds three independent bounded pools, because Ente meters these
many requests run at once, because Ente meters these three kinds of traffic three kinds of traffic differently:
differently:
- metadata, with 10 slots: only the machine-learning data requests; - metadata, with 10 slots;
- content, with 5: originals; - content, with 5;
- thumbnails, with 25: thumbnails. - thumbnails, with 25.
Only the requests named above go through the pools. A refresh, Construct one with
`freshCollections()` and `freshFiles()` send theirs outside them. Construct one
with
`new RequestPools({ metadataConcurrency, contentConcurrency, thumbnailConcurrency })` `new RequestPools({ metadataConcurrency, contentConcurrency, thumbnailConcurrency })`
and pass it as `pools`. Each pool serves on-demand work ahead of background and pass it as `pools`. Each pool serves on-demand work ahead of background
work. Two tasks given to `BoundedPool.run()` with the same `key` run once while work. A task submitted under a key that is still pending is not run a second
the first is pending, and both get its result. An idle pool never lends its time. An idle pool never lends its slots to a busy one.
slots to a busy one.
`lib.backup()` works on as many originals at once as the content pool allows, 5 `lib.backup()` works on as many originals at once as the content pool allows, 5
by default. `quak backup` uses the default. The limit covers everything it does by default. `quak backup` uses the default. The limit covers everything it does
@@ -1383,37 +1332,34 @@ They differ because a download's length depends on the file and the link. A
whole-transfer deadline short enough to catch a hung connection would cancel a whole-transfer deadline short enough to catch a hung connection would cancel a
large video on a slow link that is still making progress. The download deadline large video on a slow link that is still making progress. The download deadline
restarts each time bytes arrive, so a slow download runs as long as it keeps restarts each time bytes arrive, so a slow download runs as long as it keeps
moving, and one that stalls is aborted after 60 seconds of silence. The download moving, and one that stalls is aborted after 60 seconds of silence. It covers
deadline covers the wait for the headers as well as the body. There is no limit the wait for the headers as well as the body. There is no limit on the total
on the total length of a download. length of a download.
A request that changes state is not blindly replayed. Every `POST` and `PUT` in A request that changes state is not blindly replayed. Every `POST` and `PUT` in
the endpoint list under "HTTP API" goes through this rule. Verifying a second the endpoint list under "HTTP API" goes through this rule. Verifying a second
factor, for one, uses up one of a small number of attempts. Such a request is factor, for one, uses up one of a small number of attempts. Such a request is
retried only when every error code in its `cause` chain proves that no TCP retried only when every error code in its `cause` chain proves that no TCP
connection to the server ever existed. Three codes prove that: connection to the server ever existed. Three codes prove that: `ENOTFOUND` and
`EAI_AGAIN`, when name resolution produced no address, and `ECONNREFUSED`, when
- `ENOTFOUND`: name resolution found no address; the peer refused the connection. A 5xx, a reset mid-flight and a deadline are
- `EAI_AGAIN`: name resolution failed for the moment; all left to the caller, because each of them can happen after the server has
- `ECONNREFUSED`: the peer refused the connection. already acted. These requests do not follow redirects either: a redirect means
the server already received the request, so it is reported as an error and not
A 5xx, a reset mid-flight and a deadline are all left to the caller, because retried.
each of them can happen after the server has already acted. These requests do
not follow redirects either: a redirect means the server already received the
request, so it is reported as an error and not retried.
The routing errors `EHOSTUNREACH`, `ENETUNREACH` and `ENETDOWN` are left out of The routing errors `EHOSTUNREACH`, `ENETUNREACH` and `ENETDOWN` are left out of
that rule although they look like connect-time failures. On Linux they are also that rule although they look like connect-time failures. On Linux they are also
delivered on an established socket, after the request may have been written. delivered on an established socket, after the request may have been written.
They stay retryable for requests that change nothing. Bun's `ConnectionRefused` They stay retryable for requests that change nothing. Bun's `ConnectionRefused`
and `FailedToOpenSocket` are treated the same way: a request that changes and `FailedToOpenSocket` are treated the same way: a request that changes
nothing is retried on them, and a `POST` or `PUT` is not replayed on them. Bun nothing is retried on them, and a `POST` or `PUT` is not replayed on them. When
reuses a connection it kept open from an earlier request. When that connection a connection bun kept open from an earlier request closes before any answer, bun
closes before any answer, bun sends the request again on a new one. If that one sends the request again on a new connection. If that one fails, bun gives one of
fails, bun gives one of these codes although the server may already have the these codes although the server may already have the request. The upload of a
request. The upload of a thumbnail to its presigned URL is exempt from the rule. thumbnail to its presigned URL is exempt from the rule. It stores one whole
It stores one whole object at one key in one request, so replaying it has no object at one key in one request, so replaying it has no partial state to
partial state to damage. damage.
A download is retried as a whole: the request, the reading of the body and the A download is retried as a whole: the request, the reading of the body and the
decryption. A socket reset after the response headers have arrived surfaces in decryption. A socket reset after the response headers have arrived surfaces in
@@ -1427,20 +1373,19 @@ One imprecision is deliberate. When a body ends partway through an encrypted
chunk, the authentication check fails and carries no sign of why, so a cut chunk, the authentication check fails and carries no sign of why, so a cut
connection and corrupt bytes look the same. quak reports that as truncation, connection and corrupt bytes look the same. quak reports that as truncation,
which means it is retried. quak can tell the two apart only in a body of more which means it is retried. quak can tell the two apart only in a body of more
than one chunk. There, a chunk that fails while the stream carries on past it than one chunk: a chunk that fails while the stream carries on past it stays an
stays an authentication failure and is not retried. Most thumbnails and every authentication failure and is not retried. Most thumbnails and every small file
small file are one chunk. There a wrong key, corrupt bytes and a cut connection are one chunk. There a wrong key, corrupt bytes and a cut connection look alike,
look alike, and all are retried. The attempt count limits the extra retries. In and all are retried. The cost is bounded by the attempt count, and it buys never
return, quak never keeps a truncated file. keeping a truncated file.
### The `Client` ### The `Client`
`Client.login({ email, password, totp?, emailOTP?, apiOptions? })` logs in and `Client.login({ email, password, totp?, emailOTP?, apiOptions? })` logs in and
resolves to a `Client`. `totp` and `emailOTP` are callbacks that return the code resolves to a `Client`. `totp` and `emailOTP` are callbacks that return the code
when the account asks for one. Without the callback the account needs, login when the account asks for one. Without the callback the account needs, login
throws. A passkey challenge always throws. After an email code, login expects throws. A passkey challenge always throws. `apiOptions` configures the HTTP
the key attributes and the token. When the server asks for a second factor client (see below).
instead, login throws. `apiOptions` configures the HTTP client (see below).
The `Client` holds the session token, the master key, the secret key and the The `Client` holds the session token, the master key, the secret key and the
public key in memory. There is no on-disk session store in the library; the public key in memory. There is no on-disk session store in the library; the
@@ -1450,18 +1395,10 @@ 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.
`Client.fromJSON` works only once libsodium, the cryptography library quak uses,
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
`sodium` imported from `libsodium-wrappers-sumo`, which quak depends on. The
exported `beginLogin`, `unwrapAuth`, `decryptCollection`, `decryptFile`,
`downloadFile` and `downloadThumbnail` need the same.
`client.logout()` clears the token and zeroes the key buffers in place. After `client.logout()` clears the token and zeroes the key buffers in place. After
it, every other method on that client throws. `logout()` does not contact the it, every other method on that client throws. It does not contact the server, so
server, so the token stays valid there and in any saved snapshot. Call the token stays valid there and in any saved snapshot;
`await client.logoutOnServer()` before `logout()` to end the session on the `await client.logoutOnServer()` ends the session on the server first.
server.
`client.whoami()` returns `{ email, userID }`. `client.whoami()` returns `{ email, userID }`.
@@ -1469,20 +1406,18 @@ server.
`client.filesSince({ collectionID, collectionKey, sinceTime })` enumerate the `client.filesSince({ collectionID, collectionKey, sinceTime })` enumerate the
changes since a microsecond timestamp, with `0` for everything. Each returns: changes since a microsecond timestamp, with `0` for everything. Each returns:
- `collections` or `files`: the decrypted records that are not deleted; - `collections` or `files`: the decrypted live records;
- `deleted`: the IDs of deleted records; - `deleted`: the IDs of deleted records;
- `undecryptable`: each record that did not decrypt, as `{ id, error }`; - `undecryptable`: each record that did not decrypt, as `{ id, error }`;
- `cursor`: the timestamp to pass next time. - `cursor`: the timestamp to pass next time.
`client.listCollections()` and `client.listFiles(collectionID, collectionKey)` `client.listCollections()` and `client.listFiles(collectionID, collectionKey)`
return only the decrypted records that are not deleted, from `sinceTime` `0`. return only the live records, from `sinceTime` `0`.
`client.fetchMLData({ fileIDs, fileKeys })` fetches and decrypts the `client.fetchMLData({ fileIDs, fileKeys })` fetches and decrypts the
machine-learning data of up to 200 files. `client.downloadFile(file, outPath?)` machine-learning data of up to 200 files. `client.downloadFile(file, outPath?)`
and `client.downloadThumbnail(file, outPath?)` download and decrypt one file to and `client.downloadThumbnail(file, outPath?)` download and decrypt one file to
`outPath`. For a live photo, `downloadFile` writes the image and the video, each `outPath`. Without `outPath`, they write to the working directory under the
named `outPath` with its own extension. `quak get --out` instead puts the image names `quak get` and `quak get-thumb` would use.
at the path itself. Without `outPath`, they write to the working directory under
the names `quak get` and `quak get-thumb` would use.
`ApiClientOptions`: `ApiClientOptions`:
@@ -1676,9 +1611,8 @@ Login derives the keys in this order:
token, or first asks for a second factor. quak answers a TOTP request with token, or first asks for a second factor. quak answers a TOTP request with
the code (`POST /users/two-factor/verify`); the server then returns the key the code (`POST /users/two-factor/verify`); the server then returns the key
attributes and the token. quak cannot answer a passkey request. When the attributes and the token. quak cannot answer a passkey request. When the
account has email verification on (`isEmailMFAEnabled`), an email code account has email MFA on (`isEmailMFAEnabled`), an email code replaces SRP,
replaces SRP. quak then expects the key attributes and the token in the and the server returns the key attributes and the token after it.
answer, and fails the login when the server asks for a second factor instead.
5. `crypto_secretbox_open_easy` over the encrypted master key with the KEK 5. `crypto_secretbox_open_easy` over the encrypted master key with the KEK
yields the 32-byte master key. yields the 32-byte master key.
6. `crypto_secretbox_open_easy` over the encrypted secret key with the master 6. `crypto_secretbox_open_easy` over the encrypted secret key with the master
@@ -1757,35 +1691,36 @@ This repository adheres to the
standard. Normalized scripts in `script/` are the entrypoints for the standard. Normalized scripts in `script/` are the entrypoints for the
development workflow, and most Makefile targets are thin shims that call them. development workflow, and most Makefile targets are thin shims that call them.
The scripts are POSIX `sh`, not bash, so they run in minimal containers such as The scripts are POSIX `sh`, not bash, so they run in minimal containers such as
Alpine. Of these, only `bootstrap`, `setup`, `test` and `cibuild` come from the Alpine. The scripts marked "ours" are this repository's own additions to the
standard. The others are additions to it. standard.
- `script/bootstrap`: install all dependencies. It installs `make`, `git`, Node - `script/bootstrap`: install all dependencies. It installs `make`, `git`, Node
and `yarn` when they are missing, then runs `yarn install --frozen-lockfile`. and `yarn` when they are missing, then runs `yarn install --frozen-lockfile`.
- `script/setup`: set up the repo for development after a fresh clone. It runs - `script/setup`: set up the repo for development after a fresh clone. It runs
`script/bootstrap`, then `script/install-precommit`. `script/bootstrap`, then `script/install-precommit`.
- `script/projectname`: print the project name. `script/docker` uses it for the - `script/projectname` (ours): print the project name. `script/docker` uses it
image tag. for the image tag.
- `script/build`: compile the TypeScript sources into `dist/` and stamp the - `script/build` (ours): compile the TypeScript sources into `dist/` and stamp
version into `dist/package.json`. Then check that the entrypoints the version into `dist/package.json`. Then check that the entrypoints
`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` (ours): print the version `script/build` stamps (see
"Version").
- `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
of the `Dockerfile`. Requires Docker. of the `Dockerfile`. Requires Docker.
- `script/fmt`: format all files with prettier. It rewrites files in place. - `script/fmt`: format all files with prettier. It rewrites files in place.
- `script/fmt-check`: check formatting on the host. It is read-only. - `script/fmt-check`: check formatting on the host. It is read-only.
- `script/check`: run all checks: `test`, `lint`, `fmt-check`. - `script/check` (ours): run all checks: `test`, `lint`, `fmt-check`.
- `script/docker`: build the image, tagged with `script/projectname`. - `script/docker`: build the image, tagged with `script/projectname`.
- `script/cibuild`: what CI runs: `script/bootstrap`, then `script/check`, then - `script/cibuild`: what CI runs: `script/bootstrap`, then `script/check`, then
the image build. the image build.
- `script/precommit`: run by the git pre-commit hook. It runs `script/lint` and - `script/precommit` (ours): run by the git pre-commit hook. It runs
`script/fmt-check`, and not the tests, so that a commit of failing tests can `script/lint` and `script/fmt-check`, and not the tests, so that a commit of
land (see "Development workflow"). failing tests can land (see "Development workflow").
- `script/install-precommit`: install the git pre-commit hook. `make hooks` - `script/install-precommit` (ours): install the git pre-commit hook.
calls it. `make hooks` calls it.
The Makefile targets are: The Makefile targets are:
@@ -1852,8 +1787,8 @@ empty, `dev` or `unknown`, because such a build could not be traced to its
commit. commit.
`.dockerignore` therefore does not leave out `.git`, so any `docker build .` of `.dockerignore` therefore does not leave out `.git`, so any `docker build .` of
a clone stamps the commit it was built from. A clone of depth 1 stamps a tag a clone stamps the commit it was built from. A shallow clone stamps a tag only
only when its commit carries one, and otherwise the short commit. when the cloned commit itself carries one, and otherwise the short commit.
`.dockerignore` does leave out `.git/config`, which holds the clone's remote URL `.dockerignore` does leave out `.git/config`, which holds the clone's remote URL
and any credential in it; `git describe` does not need that file. and any credential in it; `git describe` does not need that file.
`script/docker` and `script/cibuild` work out the version on the host, with `script/docker` and `script/cibuild` work out the version on the host, with
@@ -1903,18 +1838,12 @@ These apply to people and to coding agents alike.
<https://git.eeqj.de/sneak/prompts> and covers repository structure, tooling, <https://git.eeqj.de/sneak/prompts> and covers repository structure, tooling,
Makefile targets, Dockerfile conventions, dependency pinning and commit Makefile targets, Dockerfile conventions, dependency pinning and commit
hygiene. Never `git add -A`. Never force-push `main`. hygiene. Never `git add -A`. Never force-push `main`.
- Dependencies are pinned: - Every dependency is pinned:
- npm packages by hash, in `yarn.lock`; - npm packages by hash, in `yarn.lock`;
- 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;
- Node and yarn, when `script/bootstrap` installs them, by version. - Node and yarn by version, in `script/bootstrap`.
- Not pinned:
- bun, which `make build-bin` takes from `nix-shell -p bun`;
- `make` and `git`, which `script/bootstrap` installs from the system
package manager when they are missing, as every `Dockerfile` stage does;
- `bash` and `curl`, which `script/bootstrap` installs the same way when it
has to install Node.
- Follow the workflow above: branches off `next`, tests first and committed - Follow the workflow above: branches off `next`, tests first and committed
failing, and `next` merged into `main` by sneak alone. failing, and `next` merged into `main` by sneak alone.
- Before every commit, `make lint` and `make fmt-check` must pass. `make lint` - Before every commit, `make lint` and `make fmt-check` must pass. `make lint`
+7 -10
View File
@@ -1,18 +1,15 @@
// The CLI's log level, and the one function the commands write stderr lines // The CLI's log level, and the one function the commands write stderr lines
// through. // through.
// //
// Every line quak logs goes to stderr, at one of six levels, from the // Every line quak prints on stderr is a log line at one of six levels, from
// quietest: fatal, error, warning, info, debug, trace. A run prints the lines // the quietest: fatal, error, warning, info, debug, trace. A run prints the
// at its own level and at every quieter one. Its level is info unless `-v` or // lines at its own level and at every quieter one. Its level is info unless
// `-q` asks for another (see "What quak prints" in the README). A command that // `-v` or `-q` asks for another (see "What quak prints" in the README). Every
// runs and exits non-zero logs a fatal line saying why, so even `-q`, which // command that exits non-zero logs a fatal line saying why, so even `-q`, which
// prints only fatal lines, never leaves such an exit unexplained. An error // prints only fatal lines, never leaves a non-zero exit unexplained. An error
// line reports something that failed, such as a file or an album, and a // line reports something that failed, such as a file or an album, and a
// warning something that did not fail but needs the user's attention. stdout // warning something that did not fail but needs the user's attention. stdout
// carries a command's output and the prompts of `login`, and is not a log. // carries only a command's output and is not a log.
// Two things on stderr are not log lines: the `error:` message for a
// command line that quak cannot parse, and the usage printed when no command
// is given.
// //
// The library itself writes no lines: its layers report through callbacks, and // The library itself writes no lines: its layers report through callbacks, and
// the CLI logs what they report. `logRequests` gives the API client options // the CLI logs what they report. `logRequests` gives the API client options
+1 -1
View File
@@ -328,7 +328,7 @@ describe("what the library reports", () => {
]); ]);
}); });
// Each operation is named in plain words. // Each operation is named in plain words, as the README names it.
it("logs a refresh's progress at info, and its start and end at debug", () => { it("logs a refresh's progress at info, and its start and end at debug", () => {
const { log, lines } = collect(); const { log, lines } = collect();
const onProgress = logRefresh(log); const onProgress = logRefresh(log);