diff --git a/bin/quak.ts b/bin/quak.ts new file mode 100644 index 0000000..dc0872d --- /dev/null +++ b/bin/quak.ts @@ -0,0 +1,189 @@ +#!/usr/bin/env node + +import { createInterface } from "node:readline/promises"; +import { stdin, stdout, stderr } from "node:process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { Command } from "commander"; +import envPaths from "env-paths"; +import { Client, type ClientSnapshot } from "../src/client.js"; +import { init } from "../src/crypto/index.js"; +import { runBackup } from "../src/backup.js"; + +const paths = envPaths("quak", { suffix: "" }); +const sessionPath = join(paths.data, "session.json"); + +const loadSession = (): ClientSnapshot | null => { + if (!existsSync(sessionPath)) return null; + try { + return JSON.parse(readFileSync(sessionPath, "utf-8")) as ClientSnapshot; + } catch { + return null; + } +}; + +const saveSession = (snapshot: ClientSnapshot): void => { + mkdirSync(paths.data, { recursive: true, mode: 0o700 }); + writeFileSync(sessionPath, JSON.stringify(snapshot, null, 2), { + mode: 0o600, + }); +}; + +const requireSession = (): Client => { + const snapshot = loadSession(); + if (!snapshot) { + stderr.write( + `Not logged in. Run "quak login" first.\nSession file: ${sessionPath}\n`, + ); + process.exit(1); + } + return Client.fromJSON(snapshot); +}; + +const prompt = async (message: string): Promise => { + const rl = createInterface({ input: stdin, output: stderr }); + const answer = await rl.question(message); + rl.close(); + return answer; +}; + +const promptPassword = async (message: string): Promise => { + if (!stdin.isTTY) { + return prompt(message); + } + stderr.write(message); + stdin.setRawMode(true); + stdin.resume(); + const chars: string[] = []; + return new Promise((resolve) => { + const onData = (buf: Buffer) => { + for (const byte of buf) { + if (byte === 3) { + stdin.setRawMode(false); + process.exit(130); + } + if (byte === 13 || byte === 10) { + stdin.setRawMode(false); + stdin.removeListener("data", onData); + stdin.pause(); + stderr.write("\n"); + resolve(chars.join("")); + return; + } + if (byte === 127 || byte === 8) { + chars.pop(); + } else { + chars.push(String.fromCharCode(byte)); + } + } + }; + stdin.on("data", onData); + }); +}; + +const program = new Command(); + +program + .name("quak") + .description("CLI for the Ente end-to-end encrypted photo service") + .version("0.0.0"); + +program + .command("login") + .description("Log in to an Ente account and save the session") + .action(async () => { + await init(); + const email = + process.env.QUAK_EMAIL ?? + (stdin.isTTY ? await prompt("Email: ") : null); + const password = + process.env.QUAK_PASSWORD ?? + (stdin.isTTY ? await promptPassword("Password: ") : null); + if (!email || !password) { + stderr.write( + "Set QUAK_EMAIL and QUAK_PASSWORD env vars for non-interactive use.\n", + ); + process.exit(1); + } + + stderr.write("Authenticating...\n"); + try { + const client = await Client.login({ + email, + password, + totp: async () => prompt("TOTP code: "), + emailOTP: async () => prompt("Email verification code: "), + }); + + saveSession(client.toJSON()); + const info = client.whoami(); + stderr.write(`Logged in as ${info.email} (user ${info.userID})\n`); + stderr.write(`Session saved to ${sessionPath}\n`); + } catch (err) { + stderr.write( + `Login failed: ${err instanceof Error ? err.message : err}\n`, + ); + process.exit(1); + } + }); + +program + .command("whoami") + .description("Print the logged-in account") + .action(() => { + const client = requireSession(); + const info = client.whoami(); + stdout.write(JSON.stringify(info) + "\n"); + }); + +program + .command("logout") + .description("Delete the saved session") + .action(async () => { + if (existsSync(sessionPath)) { + const { unlinkSync } = await import("node:fs"); + unlinkSync(sessionPath); + stderr.write("Session deleted.\n"); + } else { + stderr.write("No session found.\n"); + } + }); + +program + .command("backup") + .description( + "Download all photos to a local directory, organized by collection", + ) + .argument("", "Output directory") + .option("--json", "Print result as JSON instead of human-readable summary") + .action(async (dir: string, opts: { json?: boolean }) => { + await init(); + const client = requireSession(); + + stderr.write("Starting backup...\n"); + const result = await runBackup(client, dir, (msg) => { + if (!opts.json) stderr.write(msg + "\n"); + }); + + if (opts.json) { + stdout.write(JSON.stringify(result, null, 2) + "\n"); + } else { + stderr.write("\n--- Backup complete ---\n"); + stderr.write(` Total files: ${result.totalFiles}\n`); + stderr.write(` Downloaded: ${result.downloaded}\n`); + stderr.write(` Skipped: ${result.skipped}\n`); + stderr.write(` Failed: ${result.failed}\n`); + if (result.errors.length > 0) { + stderr.write("\nFailed files:\n"); + for (const e of result.errors) { + stderr.write( + ` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}\n`, + ); + } + } + } + + process.exit(result.failed > 0 ? 1 : 0); + }); + +program.parse(); diff --git a/package.json b/package.json index 1e84485..c967ef3 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ "vitest": "2.1.9" }, "dependencies": { + "commander": "14.0.3", + "env-paths": "4.0.0", "fast-srp-hap": "2.0.4", "libsodium-wrappers-sumo": "0.8.4" } diff --git a/src/backup.ts b/src/backup.ts new file mode 100644 index 0000000..a0d6647 --- /dev/null +++ b/src/backup.ts @@ -0,0 +1,137 @@ +import { + existsSync, + mkdirSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { join, relative, extname } from "node:path"; +import type { Client } from "./client.js"; +import type { EnteFile } from "./model/types.js"; + +export interface BackupError { + fileID: number; + title: string; + collection: string; + error: string; +} + +export interface BackupResult { + totalFiles: number; + downloaded: number; + skipped: number; + failed: number; + errors: BackupError[]; +} + +export type ProgressCallback = (message: string) => void; + +const sanitizePath = (name: string): string => + name.replace(/[/\\:*?"<>|]/g, "_").replace(/^\.+/, "_"); + +const originalFileName = (file: EnteFile): string => { + const ext = extname(file.metadata.title || "") || ".bin"; + return `${file.id}${ext}`; +}; + +export const runBackup = async ( + client: Client, + outDir: string, + onProgress?: ProgressCallback, +): Promise => { + const log = onProgress ?? (() => {}); + + mkdirSync(outDir, { recursive: true }); + const originalsDir = join(outDir, "originals"); + mkdirSync(originalsDir, { recursive: true }); + const collectionsDir = join(outDir, "collections"); + mkdirSync(collectionsDir, { recursive: true }); + + log("Fetching collections..."); + const collections = await client.listCollections(); + const downloadedIDs = new Set(); + + let totalFiles = 0; + let downloaded = 0; + let skipped = 0; + let failed = 0; + const errors: BackupError[] = []; + + for (const col of collections) { + const colDirName = sanitizePath(col.name || `collection-${col.id}`); + const colDir = join(collectionsDir, colDirName); + mkdirSync(colDir, { recursive: true }); + + log(`[${col.name}] Fetching file list...`); + const files = await client.listFiles(col.id, col.key); + log(`[${col.name}] ${files.length} file(s)`); + + const collectionMeta: { + id: number; + name: string; + type: string; + files: { id: number; metadata: EnteFile["metadata"] }[]; + } = { + id: col.id, + name: col.name, + type: col.type, + files: [], + }; + + for (const file of files) { + totalFiles++; + const origName = originalFileName(file); + const origPath = join(originalsDir, origName); + const linkName = sanitizePath( + file.metadata.title || `file-${file.id}`, + ); + const linkPath = join(colDir, linkName); + + if (!downloadedIDs.has(file.id)) { + if (existsSync(origPath) && statSync(origPath).size > 0) { + skipped++; + downloadedIDs.add(file.id); + } else { + try { + log(`[${col.name}] Downloading ${linkName}...`); + await client.downloadFile(file, origPath); + downloaded++; + downloadedIDs.add(file.id); + } catch (err) { + log( + `[${col.name}] FAILED ${linkName}: ${err instanceof Error ? err.message : err}`, + ); + failed++; + errors.push({ + fileID: file.id, + title: file.metadata.title, + collection: col.name, + error: + err instanceof Error + ? err.message + : String(err), + }); + continue; + } + } + } + + if (!existsSync(linkPath) && existsSync(origPath)) { + const target = relative(colDir, origPath); + symlinkSync(target, linkPath); + } + + collectionMeta.files.push({ + id: file.id, + metadata: file.metadata, + }); + } + + writeFileSync( + join(collectionsDir, `${colDirName}.json`), + JSON.stringify(collectionMeta, null, 2), + ); + } + + return { totalFiles, downloaded, skipped, failed, errors }; +}; diff --git a/test/cli/backup.test.ts b/test/cli/backup.test.ts new file mode 100644 index 0000000..f559b80 --- /dev/null +++ b/test/cli/backup.test.ts @@ -0,0 +1,458 @@ +/** + * Tests for the `quak backup` command's core logic. + * + * `quak backup ` downloads every file from every collection into + * a local directory tree: + * + * / + * / + * + * + * / + * ... + * metadata.json (all decrypted collection + file metadata) + * + * The backup command has two properties that distinguish it from a naive + * "download everything" loop: + * + * 1. **Skip existing files.** If `//` already + * exists on disk and its size matches the decrypted content length + * recorded in metadata.json from a prior run, the file is not + * re-downloaded. This makes interrupted backups resumable and + * incremental runs fast. + * + * 2. **Never crash on a single file failure.** If a file download or + * decryption fails, the error is logged and the backup continues + * with the next file. At the end, the exit code is non-zero if any + * files failed, and the summary lists them. The Ente first-party + * CLI crashes entirely when a single file can't be retrieved, + * which defeats the purpose of a backup tool. + * + * These tests exercise the backup logic (in src/backup.ts) using the + * same mock server from the Client usage tests. The CLI binary itself + * is a thin wrapper around this module. + */ + +import { + existsSync, + lstatSync, + mkdtempSync, + readFileSync, + readlinkSync, + rmSync, +} from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import sodium from "libsodium-wrappers-sumo"; +import { SRP, SrpServer } from "fast-srp-hap"; +import { beforeAll, afterAll, describe, expect, it } from "vitest"; +import { + init, + toBase64, + deriveKEK, + deriveLoginSubkey, +} from "../../src/crypto/index.js"; +import { Client } from "../../src/client.js"; +import { runBackup } from "../../src/backup.js"; +import type { KeyAttributes } from "../../src/auth/types.js"; + +// --------------------------------------------------------------------------- +// Mock server (condensed from usage.test.ts) +// --------------------------------------------------------------------------- + +const TEST_EMAIL = "backup@example.com"; +const TEST_PASSWORD = "backuppass"; +const TEST_OPS = 2; +const TEST_MEM = 64 * 1024 * 1024; + +interface MockState { + verifier: Buffer; + srpAttributes: Record<string, unknown>; + keyAttributes: KeyAttributes; + encryptedToken: string; + collections: Record<string, unknown>[]; + files: Record< + number, + { raw: Record<string, unknown>; plaintext: Uint8Array } + >; +} + +let mock: MockState; +let testDir: string; + +const buildMock = async (): Promise<MockState> => { + const kekSalt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES); + const kek = await deriveKEK(TEST_PASSWORD, kekSalt, TEST_OPS, TEST_MEM); + const loginSubKeyBytes = deriveLoginSubkey(kek); + + const srpUserID = "backup-srp"; + const srpSalt = sodium.randombytes_buf(16); + const verifier = SRP.computeVerifier( + SRP.params["4096"], + Buffer.from(srpSalt), + Buffer.from(srpUserID), + Buffer.from(loginSubKeyBytes), + ); + + const masterKey = sodium.randombytes_buf(32); + const keyNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const encryptedKey = sodium.crypto_secretbox_easy(masterKey, keyNonce, kek); + const kp = sodium.crypto_box_keypair(); + const skNonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const encSK = sodium.crypto_secretbox_easy( + kp.privateKey, + skNonce, + masterKey, + ); + const tokenBytes = sodium.randombytes_buf(32); + const encToken = sodium.crypto_box_seal(tokenBytes, kp.publicKey); + + const keyAttributes: KeyAttributes = { + kekSalt: toBase64(kekSalt), + encryptedKey: toBase64(encryptedKey), + keyDecryptionNonce: toBase64(keyNonce), + publicKey: toBase64(kp.publicKey), + encryptedSecretKey: toBase64(encSK), + secretKeyDecryptionNonce: toBase64(skNonce), + memLimit: TEST_MEM, + opsLimit: TEST_OPS, + }; + + const makeCollection = (id: number, name: string) => { + const ck = sodium.crypto_secretbox_keygen(); + const ckN = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const encCK = sodium.crypto_secretbox_easy(ck, ckN, masterKey); + const nameBytes = new TextEncoder().encode(name); + const cnN = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const encCN = sodium.crypto_secretbox_easy(nameBytes, cnN, ck); + return { + raw: { + id, + owner: { id: 42 }, + encryptedKey: toBase64(encCK), + keyDecryptionNonce: toBase64(ckN), + encryptedName: toBase64(encCN), + nameDecryptionNonce: toBase64(cnN), + type: "album", + updationTime: 1700000000000000, + }, + key: ck, + }; + }; + + const makeFile = ( + id: number, + collKey: Uint8Array, + title: string, + plaintext: Uint8Array, + collID: number, + ) => { + const fk = sodium.crypto_secretstream_xchacha20poly1305_keygen(); + const fkN = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES); + const encFK = sodium.crypto_secretbox_easy(fk, fkN, collKey); + + const meta = JSON.stringify({ + title, + fileType: 0, + creationTime: 1700000000000000, + modificationTime: 1700000000000000, + }); + const metaPush = + sodium.crypto_secretstream_xchacha20poly1305_init_push(fk); + const encMeta = sodium.crypto_secretstream_xchacha20poly1305_push( + metaPush.state, + new TextEncoder().encode(meta), + null, + sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL, + ); + + const filePush = + sodium.crypto_secretstream_xchacha20poly1305_init_push(fk); + const encFile = sodium.crypto_secretstream_xchacha20poly1305_push( + filePush.state, + plaintext, + null, + sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL, + ); + + return { + raw: { + id, + collectionID: collID, + ownerID: 42, + encryptedKey: toBase64(encFK), + keyDecryptionNonce: toBase64(fkN), + metadata: { + encryptedData: toBase64(encMeta), + decryptionHeader: toBase64(metaPush.header), + }, + file: { decryptionHeader: toBase64(filePush.header) }, + thumbnail: { + decryptionHeader: toBase64(sodium.randombytes_buf(24)), + }, + updationTime: 1700000000000000, + }, + plaintext, + ciphertext: encFile, + }; + }; + + const col1 = makeCollection(1, "Vacation"); + const col2 = makeCollection(2, "Work"); + const file1 = makeFile( + 100, + col1.key, + "beach.jpg", + sodium.randombytes_buf(3000), + 1, + ); + const file2 = makeFile( + 101, + col1.key, + "sunset.jpg", + sodium.randombytes_buf(2000), + 1, + ); + const file3 = makeFile( + 200, + col2.key, + "diagram.png", + sodium.randombytes_buf(1500), + 2, + ); + + return { + verifier, + srpAttributes: { + srpUserID, + srpSalt: toBase64(srpSalt), + memLimit: TEST_MEM, + opsLimit: TEST_OPS, + kekSalt: toBase64(kekSalt), + isEmailMFAEnabled: false, + }, + keyAttributes, + encryptedToken: toBase64(encToken), + collections: [col1.raw, col2.raw], + files: { + 1: { + raw: [file1.raw, file2.raw], + ciphertexts: { 100: file1.ciphertext, 101: file2.ciphertext }, + }, + 2: { raw: [file3.raw], ciphertexts: { 200: file3.ciphertext } }, + 100: { plaintext: file1.plaintext }, + 101: { plaintext: file2.plaintext }, + 200: { plaintext: file3.plaintext }, + } as Record<number, unknown>, + }; +}; + +const buildMockFetch = (m: MockState, opts?: { failFileID?: number }) => { + let srpServer: SrpServer; + + return (async ( + input: RequestInfo | URL, + init?: RequestInit, + ): Promise<Response> => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + const parsed = new URL(url); + const path = parsed.pathname; + const json = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + + if (path === "/users/srp/attributes") + return json({ attributes: m.srpAttributes }); + + if (path === "/users/srp/create-session") { + const body = JSON.parse(init?.body as string); + const serverKey = await SRP.genKey(); + srpServer = new SrpServer( + SRP.params["4096"], + m.verifier, + serverKey, + ); + const B = srpServer.computeB(); + srpServer.setA(Buffer.from(body.srpA, "base64")); + return json({ sessionID: "s1", srpB: B.toString("base64") }); + } + + if (path === "/users/srp/verify-session") { + const body = JSON.parse(init?.body as string); + srpServer.checkM1(Buffer.from(body.srpM1, "base64")); + return json({ + srpM2: srpServer.computeM2().toString("base64"), + id: 42, + keyAttributes: m.keyAttributes, + encryptedToken: m.encryptedToken, + }); + } + + if (path === "/collections/v2") + return json({ collections: m.collections }); + + if (path === "/collections/v2/diff") { + const collID = Number(parsed.searchParams.get("collectionID")); + const collData = m.files[collID] as { raw: unknown[] } | undefined; + return json({ diff: collData?.raw ?? [], hasMore: false }); + } + + // File download + if (url.includes("fileID=") || path.startsWith("/files/download/")) { + const fileID = Number( + parsed.searchParams.get("fileID") ?? path.split("/").pop(), + ); + if (opts?.failFileID === fileID) { + return new Response("Internal Server Error", { status: 500 }); + } + const collData = Object.values(m.files).find( + (v: unknown) => + v && + typeof v === "object" && + "ciphertexts" in (v as Record<string, unknown>) && + fileID in + (v as Record<string, Record<number, unknown>>) + .ciphertexts, + ) as { ciphertexts: Record<number, Uint8Array> } | undefined; + if (collData) { + return new Response(collData.ciphertexts[fileID], { + status: 200, + }); + } + return new Response("not found", { status: 404 }); + } + + return new Response("not found", { status: 404 }); + }) as typeof globalThis.fetch; +}; + +// --------------------------------------------------------------------------- +// Setup / teardown +// --------------------------------------------------------------------------- + +beforeAll(async () => { + await init(); + await sodium.ready; + mock = await buildMock(); + testDir = mkdtempSync(join(tmpdir(), "quak-backup-test-")); +}); + +afterAll(() => { + if (testDir && existsSync(testDir)) + rmSync(testDir, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("quak backup", () => { + it("downloads all files organized by collection name", async () => { + const outDir = join(testDir, "full-backup"); + const client = await Client.login({ + email: TEST_EMAIL, + password: TEST_PASSWORD, + apiOptions: { fetch: buildMockFetch(mock) }, + }); + + const result = await runBackup(client, outDir); + + expect(result.totalFiles).toBe(3); + expect(result.downloaded).toBe(3); + expect(result.skipped).toBe(0); + expect(result.failed).toBe(0); + expect(result.errors).toEqual([]); + + // Originals are under <outDir>/originals/<fileID>.<ext> + expect(readFileSync(join(outDir, "originals", "100.jpg")).length).toBe( + 3000, + ); + expect(readFileSync(join(outDir, "originals", "101.jpg")).length).toBe( + 2000, + ); + expect(readFileSync(join(outDir, "originals", "200.png")).length).toBe( + 1500, + ); + + // Collection dirs under collections/ contain symlinks to originals + const beachLink = join(outDir, "collections", "Vacation", "beach.jpg"); + expect(lstatSync(beachLink).isSymbolicLink()).toBe(true); + expect(readlinkSync(beachLink)).toContain("originals"); + expect(readFileSync(beachLink).length).toBe(3000); + }); + + it("skips files that already exist on disk with matching size", async () => { + const outDir = join(testDir, "incremental"); + const client = await Client.login({ + email: TEST_EMAIL, + password: TEST_PASSWORD, + apiOptions: { fetch: buildMockFetch(mock) }, + }); + + // First run: download everything + const first = await runBackup(client, outDir); + expect(first.downloaded).toBe(3); + + // Second run: everything should be skipped + const second = await runBackup(client, outDir); + expect(second.downloaded).toBe(0); + expect(second.skipped).toBe(3); + expect(second.failed).toBe(0); + }); + + it("continues after a single file download failure", async () => { + // File 101 (sunset.jpg) will return HTTP 500. The other two + // files must still download. The result must report the failure + // without throwing. + const outDir = join(testDir, "partial-failure"); + const client = await Client.login({ + email: TEST_EMAIL, + password: TEST_PASSWORD, + apiOptions: { fetch: buildMockFetch(mock, { failFileID: 101 }) }, + }); + + const result = await runBackup(client, outDir); + + expect(result.totalFiles).toBe(3); + expect(result.downloaded).toBe(2); + expect(result.failed).toBe(1); + expect(result.errors.length).toBe(1); + expect(result.errors[0]!.fileID).toBe(101); + expect(result.errors[0]!.title).toBe("sunset.jpg"); + + // The two successful originals are on disk + expect(existsSync(join(outDir, "originals", "100.jpg"))).toBe(true); + expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true); + // The failed file has no original and no symlink + expect(existsSync(join(outDir, "originals", "101.jpg"))).toBe(false); + expect( + existsSync(join(outDir, "collections", "Vacation", "sunset.jpg")), + ).toBe(false); + }); + + it("writes per-collection JSON metadata", async () => { + const outDir = join(testDir, "metadata-check"); + const client = await Client.login({ + email: TEST_EMAIL, + password: TEST_PASSWORD, + apiOptions: { fetch: buildMockFetch(mock) }, + }); + + await runBackup(client, outDir); + + // Each collection gets a <name>.json next to its image dir + const vacationMeta = join(outDir, "collections", "Vacation.json"); + expect(existsSync(vacationMeta)).toBe(true); + const meta = JSON.parse(readFileSync(vacationMeta, "utf-8")); + expect(meta.name).toBe("Vacation"); + expect(meta.files.length).toBeGreaterThan(0); + expect(meta.files[0].metadata.title).toBeDefined(); + }); +}); diff --git a/yarn.lock b/yarn.lock index d958d9b..d3f68e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -680,6 +680,11 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +commander@14.0.3: + version "14.0.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-14.0.3.tgz#425d79b48f9af82fcd9e4fc1ea8af6c5ec07bbc2" + integrity sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -711,6 +716,13 @@ deep-is@^0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== +env-paths@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-4.0.0.tgz#d0bb1f84a81d2542581bf7b7e8085d0683b39097" + integrity sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw== + dependencies: + is-safe-filename "^0.1.0" + es-module-lexer@^1.5.4: version "1.7.0" resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" @@ -879,7 +891,7 @@ fast-levenshtein@^2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== -fast-srp-hap@^2.0.4: +fast-srp-hap@2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/fast-srp-hap/-/fast-srp-hap-2.0.4.tgz#9db296e21a5143951310f99e5a74290106467811" integrity sha512-lHRYYaaIbMrhZtsdGTwPN82UbqD9Bv8QfOlKs+Dz6YRnByZifOh93EYmf2iEWFtkOEIqR2IK8cFD0UN5wLIWBQ== @@ -1000,6 +1012,11 @@ is-number@^7.0.0: resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== +is-safe-filename@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-safe-filename/-/is-safe-filename-0.1.1.tgz#fb22eead097c614c47aa674de5d79a1648a53e66" + integrity sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g== + isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"