From 111e09bbd67557a69d24b6df5888ccbcf8abadc6 Mon Sep 17 00:00:00 2001 From: Rupert Manfredi Date: Tue, 5 May 2026 14:25:28 -0400 Subject: [PATCH 1/9] =?UTF-8?q?feat(stash):=20phase=201=20=E2=80=94=20regi?= =?UTF-8?q?stry=20layout=20+=20withRegistryLock=20primitive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lay down the new ~/.stash// infrastructure for the external-sync-target spec: provider-only global config, atomic JSON writes (mode-aware), and a withRegistryLock helper using O_CREAT|O_EXCL with stale-lock recovery. Tests cover atomic writes, layout/mode invariants against a temp HOME, and lock exclusivity + stale reclaim. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/global-config.ts | 37 +++- src/registry-lock.ts | 162 ++++++++++++++++++ src/utils/fs.ts | 21 ++- .../stash-layout.integration.test.ts | 147 ++++++++++++++++ tests/unit/fs.test.ts | 26 +++ tests/unit/global-config.test.ts | 19 ++ tests/unit/registry-lock.test.ts | 121 +++++++++++++ 7 files changed, 526 insertions(+), 7 deletions(-) create mode 100644 src/registry-lock.ts create mode 100644 tests/integration/stash-layout.integration.test.ts create mode 100644 tests/unit/fs.test.ts create mode 100644 tests/unit/global-config.test.ts create mode 100644 tests/unit/registry-lock.test.ts diff --git a/src/global-config.ts b/src/global-config.ts index 347fccf..f134e2f 100644 --- a/src/global-config.ts +++ b/src/global-config.ts @@ -1,3 +1,4 @@ +import { mkdir } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import type { GlobalConfig } from "./types.ts"; @@ -38,12 +39,39 @@ export function normalizeGlobalConfig(value: unknown): GlobalConfig { }; } -export function getGlobalConfigPath(): string { +export function getStashPath(): string { const xdg = process.env.XDG_CONFIG_HOME; if (xdg) { - return join(xdg, "stash", "config.json"); + return join(xdg, "stash"); } - return join(homedir(), ".stash", "config.json"); + return join(homedir(), ".stash"); +} + +export function getGlobalConfigPath(): string { + return join(getStashPath(), "config.json"); +} + +export function getStashDirPath(id: string): string { + return join(getStashPath(), id); +} + +export async function ensureStashPath(): Promise { + const stashPath = getStashPath(); + await mkdir(stashPath, { recursive: true, mode: 0o700 }); + return stashPath; +} + +export async function ensureStashDirPath(id: string): Promise { + const stashDir = getStashDirPath(id); + await ensureStashPath(); + await mkdir(stashDir, { recursive: true, mode: 0o700 }); + return stashDir; +} + +function serializeGlobalConfig(config: GlobalConfig): Pick { + return { + providers: normalizeGlobalConfig(config).providers, + }; } export async function readGlobalConfig(): Promise { @@ -51,7 +79,8 @@ export async function readGlobalConfig(): Promise { } export async function writeGlobalConfig(config: GlobalConfig): Promise { - await writeJsonFile(getGlobalConfigPath(), normalizeGlobalConfig(config)); + await ensureStashPath(); + await writeJsonFile(getGlobalConfigPath(), serializeGlobalConfig(config), { mode: 0o600 }); } export function getBackgroundStashes(config: GlobalConfig): string[] { diff --git a/src/registry-lock.ts b/src/registry-lock.ts new file mode 100644 index 0000000..d4ffa26 --- /dev/null +++ b/src/registry-lock.ts @@ -0,0 +1,162 @@ +import { readFileSync, unlinkSync } from "node:fs"; +import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { hostname } from "node:os"; +import { setTimeout as delay } from "node:timers/promises"; +import { getStashPath } from "./global-config.ts"; + +type RegistryLockPayload = { + pid?: number; + startedAt?: string; + hostname?: string; +}; + +type RegistryLockOptions = { + path?: string; + retryDelayMs?: number; + staleMs?: number; +}; + +const DEFAULT_RETRY_DELAY_MS = 10; +const DEFAULT_STALE_MS = 60_000; +const SIGNALS: NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP"]; + +export function getRegistryLockPath(): string { + return `${getStashPath()}/.lock`; +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +async function readLockPayload(lockPath: string): Promise { + try { + const raw = await readFile(lockPath, "utf8"); + const value = JSON.parse(raw) as RegistryLockPayload; + return value && typeof value === "object" ? value : null; + } catch { + return null; + } +} + +async function isStaleLock(lockPath: string, staleMs: number): Promise { + let ageMs = 0; + + try { + ageMs = Date.now() - (await stat(lockPath)).mtimeMs; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw error; + } + + const payload = await readLockPayload(lockPath); + if (typeof payload?.pid === "number" && !isProcessAlive(payload.pid)) { + return true; + } + return ageMs > staleMs; +} + +function installCleanup(lockPath: string): () => void { + let cleaned = false; + const cleanup = (): void => { + if (cleaned) { + return; + } + cleaned = true; + try { + unlinkSync(lockPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + }; + + const exitHandler = (): void => { + cleanup(); + }; + process.on("exit", exitHandler); + + const signalHandlers = new Map void>(); + for (const signal of SIGNALS) { + const handler = (): void => { + cleanup(); + process.off("exit", exitHandler); + for (const [registeredSignal, registeredHandler] of signalHandlers) { + process.off(registeredSignal, registeredHandler); + } + process.kill(process.pid, signal); + }; + signalHandlers.set(signal, handler); + process.once(signal, handler); + } + + return () => { + process.off("exit", exitHandler); + for (const [signal, handler] of signalHandlers) { + process.off(signal, handler); + } + }; +} + +export async function withRegistryLock( + fn: () => Promise, + options: RegistryLockOptions = {}, +): Promise { + const lockPath = options.path ?? getRegistryLockPath(); + const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS; + const staleMs = options.staleMs ?? DEFAULT_STALE_MS; + const cleanupListeners = installCleanup(lockPath); + + await mkdir(getStashPath(), { recursive: true, mode: 0o700 }); + + const payload = JSON.stringify( + { + pid: process.pid, + startedAt: new Date().toISOString(), + hostname: hostname(), + } satisfies RegistryLockPayload, + null, + 2, + ); + + try { + for (;;) { + try { + await writeFile(lockPath, payload, { encoding: "utf8", flag: "wx", mode: 0o600 }); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") { + throw error; + } + + if (await isStaleLock(lockPath, staleMs)) { + await rm(lockPath, { force: true }); + continue; + } + + await delay(retryDelayMs); + } + } + + return await fn(); + } finally { + cleanupListeners(); + try { + const currentPayload = readFileSync(lockPath, "utf8"); + if (currentPayload === payload) { + unlinkSync(lockPath); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + } +} diff --git a/src/utils/fs.ts b/src/utils/fs.ts index 4d655ab..b1b9f2c 100644 --- a/src/utils/fs.ts +++ b/src/utils/fs.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; export async function readJsonFile(path: string, fallback: T): Promise { @@ -13,7 +13,22 @@ export async function readJsonFile(path: string, fallback: T): Promise { } } -export async function writeJsonFile(path: string, value: unknown): Promise { +export async function writeJsonFile( + path: string, + value: unknown, + options?: { mode?: number }, +): Promise { await mkdir(dirname(path), { recursive: true }); - await writeFile(path, JSON.stringify(value, null, 2), "utf8"); + const tempPath = `${path}.tmp`; + + try { + await writeFile(tempPath, JSON.stringify(value, null, 2), "utf8"); + if (options?.mode !== undefined) { + await chmod(tempPath, options.mode); + } + await rename(tempPath, path); + } catch (error) { + await rm(tempPath, { force: true }); + throw error; + } } diff --git a/tests/integration/stash-layout.integration.test.ts b/tests/integration/stash-layout.integration.test.ts new file mode 100644 index 0000000..f191b83 --- /dev/null +++ b/tests/integration/stash-layout.integration.test.ts @@ -0,0 +1,147 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { + ensureStashDirPath, + getGlobalConfigPath, + getStashDirPath, + getStashPath, + writeGlobalConfig, +} from "../../src/global-config.ts"; +import { getRegistryLockPath, withRegistryLock } from "../../src/registry-lock.ts"; + +async function makeTempHome(prefix: string): Promise { + return mkdtemp(join(tmpdir(), `${prefix}-`)); +} + +async function withTempHome(prefix: string, run: (home: string) => Promise): Promise { + const home = await makeTempHome(prefix); + const previousHome = process.env.HOME; + const previousXdg = process.env.XDG_CONFIG_HOME; + process.env.HOME = home; + delete process.env.XDG_CONFIG_HOME; + + try { + return await run(home); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + if (previousXdg === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousXdg; + } + await rm(home, { recursive: true, force: true }); + } +} + +test("stash layout: global config writes under ~/.stash with 0700 root, 0600 config, and provider-only JSON", async () => { + await withTempHome("stash-layout-config", async (home) => { + await writeGlobalConfig({ + providers: { + github: { token: "secret" }, + }, + background: { + stashes: ["/tmp/ignored-by-new-layout"], + }, + }); + + const stashPath = join(home, ".stash"); + const configPath = join(stashPath, "config.json"); + + assert.equal(getStashPath(), stashPath); + assert.equal(getGlobalConfigPath(), configPath); + assert.equal((await stat(stashPath)).mode & 0o777, 0o700); + assert.equal((await stat(configPath)).mode & 0o777, 0o600); + assert.deepEqual(JSON.parse(await readFile(configPath, "utf8")), { + providers: { + github: { token: "secret" }, + }, + }); + }); +}); + +test("stash layout: per-stash metadata directories live under ~/.stash keyed by ULID", async () => { + await withTempHome("stash-layout-dir", async (home) => { + const id = "01HF7E2K3Y4Z5N8M9PQRSTVWXY"; + const stashDir = await ensureStashDirPath(id); + + assert.equal(stashDir, join(home, ".stash", id)); + assert.equal(getStashDirPath(id), stashDir); + assert.equal(basename(stashDir), id); + assert.equal(existsSync(stashDir), true); + assert.equal((await stat(stashDir)).mode & 0o777, 0o700); + }); +}); + +test("stash layout: withRegistryLock uses ~/.stash/.lock and serializes callers", async () => { + await withTempHome("stash-layout-lock", async (home) => { + const events: string[] = []; + let releaseFirst: (() => void) | null = null; + const gate = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const first = withRegistryLock(async () => { + events.push("first:start"); + assert.equal(getRegistryLockPath(), join(home, ".stash", ".lock")); + assert.equal(existsSync(getRegistryLockPath()), true); + assert.equal((await stat(getRegistryLockPath())).mode & 0o777, 0o600); + await gate; + events.push("first:end"); + }); + + await new Promise((resolve) => setTimeout(resolve, 25)); + + const second = withRegistryLock(async () => { + events.push("second:start"); + events.push("second:end"); + }); + + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.deepEqual(events, ["first:start"]); + + releaseFirst?.(); + await Promise.all([first, second]); + + assert.deepEqual(events, ["first:start", "first:end", "second:start", "second:end"]); + assert.equal(existsSync(getRegistryLockPath()), false); + }); +}); + +test("stash layout: withRegistryLock reclaims a lock older than 60 seconds", async () => { + await withTempHome("stash-layout-stale", async () => { + const lockPath = getRegistryLockPath(); + await mkdir(getStashPath(), { recursive: true }); + await writeFile( + lockPath, + JSON.stringify( + { + pid: process.pid, + startedAt: new Date().toISOString(), + hostname: "test-host", + }, + null, + 2, + ), + { mode: 0o600 }, + ); + + const old = new Date(Date.now() - 2 * 60_000); + await utimes(lockPath, old, old); + + let acquired = false; + await withRegistryLock(async () => { + acquired = true; + }); + + assert.equal(acquired, true); + assert.equal(existsSync(lockPath), false); + }); +}); diff --git a/tests/unit/fs.test.ts b/tests/unit/fs.test.ts new file mode 100644 index 0000000..b69581c --- /dev/null +++ b/tests/unit/fs.test.ts @@ -0,0 +1,26 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { writeJsonFile } from "../../src/utils/fs.ts"; + +async function makeTempDir(prefix: string): Promise { + return mkdtemp(join(tmpdir(), `${prefix}-`)); +} + +test("writeJsonFile writes JSON atomically with the requested mode", async () => { + const dir = await makeTempDir("stash-fs"); + const path = join(dir, "nested", "config.json"); + + try { + await writeJsonFile(path, { hello: "world" }, { mode: 0o600 }); + + assert.equal(await readFile(path, "utf8"), '{\n "hello": "world"\n}'); + assert.equal(existsSync(`${path}.tmp`), false); + assert.equal((await stat(path)).mode & 0o777, 0o600); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/global-config.test.ts b/tests/unit/global-config.test.ts new file mode 100644 index 0000000..896fe29 --- /dev/null +++ b/tests/unit/global-config.test.ts @@ -0,0 +1,19 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { getGlobalConfigPath, getStashPath } from "../../src/global-config.ts"; + +test("stash paths prefer XDG_CONFIG_HOME when set", () => { + const previous = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = "/tmp/stash-xdg"; + + try { + assert.equal(getStashPath(), "/tmp/stash-xdg/stash"); + assert.equal(getGlobalConfigPath(), "/tmp/stash-xdg/stash/config.json"); + } finally { + if (previous === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previous; + } + } +}); diff --git a/tests/unit/registry-lock.test.ts b/tests/unit/registry-lock.test.ts new file mode 100644 index 0000000..ec031bf --- /dev/null +++ b/tests/unit/registry-lock.test.ts @@ -0,0 +1,121 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getRegistryLockPath, withRegistryLock } from "../../src/registry-lock.ts"; + +async function makeTempDir(prefix: string): Promise { + return mkdtemp(join(tmpdir(), `${prefix}-`)); +} + +test("withRegistryLock creates a 0600 lock file and releases it after the callback", async () => { + const xdg = await makeTempDir("stash-lock"); + const previous = process.env.XDG_CONFIG_HOME; + + process.env.XDG_CONFIG_HOME = xdg; + + try { + const seenPayloads: string[] = []; + + await withRegistryLock(async () => { + const lockPath = getRegistryLockPath(); + assert.equal(existsSync(lockPath), true); + assert.equal((await stat(lockPath)).mode & 0o777, 0o600); + seenPayloads.push(await readFile(lockPath, "utf8")); + }); + + assert.equal(seenPayloads.length, 1); + assert.equal(existsSync(getRegistryLockPath()), false); + } finally { + if (previous === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previous; + } + await rm(xdg, { recursive: true, force: true }); + } +}); + +test("withRegistryLock serializes concurrent callers", async () => { + const xdg = await makeTempDir("stash-lock-serial"); + const previous = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = xdg; + + try { + const events: string[] = []; + let releaseFirst: (() => void) | null = null; + const gate = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const first = withRegistryLock(async () => { + events.push("first:start"); + await gate; + events.push("first:end"); + }); + + await new Promise((resolve) => setTimeout(resolve, 25)); + + const second = withRegistryLock(async () => { + events.push("second:start"); + events.push("second:end"); + }); + + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.deepEqual(events, ["first:start"]); + + releaseFirst?.(); + await Promise.all([first, second]); + + assert.deepEqual(events, ["first:start", "first:end", "second:start", "second:end"]); + } finally { + if (previous === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previous; + } + await rm(xdg, { recursive: true, force: true }); + } +}); + +test("withRegistryLock reclaims a stale lock left by a dead process", async () => { + const xdg = await makeTempDir("stash-lock-stale"); + const previous = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = xdg; + + try { + const lockPath = getRegistryLockPath(); + await mkdir(join(xdg, "stash"), { recursive: true }); + await writeFile( + lockPath, + JSON.stringify( + { + pid: 999_999, + startedAt: new Date(Date.now() - 5 * 60_000).toISOString(), + hostname: "test-host", + }, + null, + 2, + ), + { mode: 0o600 }, + ); + + let acquired = false; + await withRegistryLock(async () => { + acquired = true; + assert.equal(existsSync(lockPath), true); + }); + + assert.equal(acquired, true); + assert.equal(existsSync(lockPath), false); + } finally { + if (previous === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previous; + } + await rm(xdg, { recursive: true, force: true }); + } +}); From 6ece9cfcfc969261f96c1a2a7d6cd69e132fbdf5 Mon Sep 17 00:00:00 2001 From: Rupert Manfredi Date: Tue, 5 May 2026 15:14:10 -0400 Subject: [PATCH 2/9] =?UTF-8?q?feat(stash):=20phases=202=E2=80=936=20?= =?UTF-8?q?=E2=80=94=20Stash=20factories,=20providers,=20URI,=20daemon,=20?= =?UTF-8?q?CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the rest of the external-sync-target spec on top of the phase-1 infrastructure: - Stash class refactor: drop init(), add Stash.create() (generates ULID, mkdirs, writes stash.json) and Stash.load(id) static factories. Pure ops — caller wraps in withRegistryLock. - Provider configSchema: replace ProviderSpec { setup, connect } with static configSchema: ConfigItem[]. github + CLI rewired. - Generic URI parser: split-on-first-colon, InvalidUriError on missing. - Daemon registry watching: fs.watch on ~/.stash/config.json and ~/.stash/, plus periodic poll fallback. Reconciliation by (id, record, providerConfig) tuple. Three deletion sites (reactive tombstone, orphan GC, startup GC) all under withRegistryLock with revalidate-from-disk; Watch.stop() runs before taking the lock. - CLI rewrite: connect / disconnect / relink / status. Removed sync, watch, add, join, list, share, config set/get. --dangerously-allow-git is a connect/relink-time gate; runtime .git check removed from Stash.sync(). 190/190 tests passing. Lint and build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli-main.ts | 1006 +++++++---------- src/daemon.ts | 374 ++++-- src/errors.ts | 7 + src/global-config.ts | 17 +- src/index.ts | 5 +- src/providers/github-provider.ts | 17 +- src/registry-lock.ts | 38 +- src/stash.ts | 193 +++- src/types.ts | 20 +- src/uri.ts | 14 + src/utils/ulid.ts | 39 + tests/helpers/make-stash.ts | 23 +- .../background-cli.integration.test.ts | 786 ++----------- tests/integration/cli.integration.test.ts | 381 ++----- .../stash-factory.integration.test.ts | 64 ++ tests/integration/stash-sync.test.ts | 45 +- tests/unit/daemon.test.ts | 407 ++++--- tests/unit/parse-uri.test.ts | 22 + tests/unit/watch.test.ts | 64 +- 19 files changed, 1594 insertions(+), 1928 deletions(-) create mode 100644 src/uri.ts create mode 100644 src/utils/ulid.ts create mode 100644 tests/integration/stash-factory.integration.test.ts create mode 100644 tests/unit/parse-uri.test.ts diff --git a/src/cli-main.ts b/src/cli-main.ts index df57c45..bbc3001 100644 --- a/src/cli-main.ts +++ b/src/cli-main.ts @@ -1,32 +1,27 @@ import { existsSync } from "node:fs"; -import { access, constants, readFile, rm } from "node:fs/promises"; +import { access, constants, readdir, realpath, readFile } from "node:fs/promises"; import { createRequire } from "node:module"; -import { basename, delimiter, join, resolve } from "node:path"; +import { delimiter, join, resolve } from "node:path"; import { Daemon, UnsupportedPlatformError } from "@rupertsworld/daemon"; import { Command, Option } from "commander"; import { input, password } from "@inquirer/prompts"; import { - addBackgroundStash, - getBackgroundStashes, getProviderConfig, + getStashDirPath, + getStashPath, readGlobalConfig, - removeBackgroundStash, setProviderConfig, writeGlobalConfig, } from "./global-config.ts"; -import type { LocalConfig } from "./local-config.ts"; -import { readLocalConfig, writeLocalConfig } from "./local-config.ts"; import { runDaemon } from "./daemon.ts"; -import { needsMigration } from "./migrations.ts"; +import { writeJsonFile } from "./utils/fs.ts"; +import { withRegistryLock } from "./registry-lock.ts"; +import { InvalidUriError } from "./errors.ts"; import { providers } from "./providers/index.ts"; -import { CliDisplayError } from "./cli-display-error.ts"; import { Stash } from "./stash.ts"; -import { createColors } from "./ui/color.ts"; import { formatTimeAgo } from "./ui/format.ts"; -import { LiveLine } from "./ui/live-line.ts"; -import { SyncRenderer } from "./ui/sync-renderer.ts"; -import { watch as watchStash } from "./watch.ts"; -import type { Field, GlobalConfig, ProviderClass, StatusResult } from "./types.ts"; +import { parseUri } from "./uri.ts"; +import type { ConfigItem, GlobalConfig, ProviderClass, StashRecord } from "./types.ts"; const SERVICE_NAME = "stash-background"; const SERVICE_DESCRIPTION = "Stash background sync"; @@ -46,73 +41,86 @@ type CliDependencies = { cwd?: () => string; readGlobalConfig?: () => Promise; writeGlobalConfig?: (config: GlobalConfig) => Promise; + stashPath?: string; service?: ServiceHandle; runDaemon?: () => Promise; stdout?: NodeJS.WriteStream; stderr?: NodeJS.WriteStream; }; -type PersistedBackgroundStatus = { +type PersistedStatus = { kind?: string; lastSync?: string | null; summary?: string | null; error?: string | null; + currentOperation?: string | null; + pendingChanges?: { + added?: number; + modified?: number; + deleted?: number; + } | null; }; -const STASH_CONFIG_KEYS = new Set(["allow-git"]); +type RegistryEntry = { + id: string; + record: StashRecord; +}; -function isStashConfigKey(key: string): key is "allow-git" { - return STASH_CONFIG_KEYS.has(key); +function writeLine(stream: NodeJS.WriteStream, text: string): void { + stream.write(`${text}\n`); } -function isStashDirectory(dir: string): boolean { - return existsSync(join(dir, ".stash")); +function getGlobalConfigItems(provider: ProviderClass): ConfigItem[] { + return provider.configSchema.filter((item) => item.global === true); } -/** - * Before any connect prompts, reject connection names that conflict with existing - * `.stash/config.json` state. Keeps expensive/interactive setup from running when - * the command cannot succeed. - */ -async function assertNewConnectionAllowedForDirectory( - dir: string, - resolvedConnectionName: string, -): Promise { - if (!isStashDirectory(dir)) { - return; +function getCliValue( + field: ConfigItem, + valuesFromCli: Record, +): string | undefined { + const value = valuesFromCli[field.name]; + if (typeof value === "string") { + return value; } + if (field.name === "id" && typeof valuesFromCli.repo === "string") { + return valuesFromCli.repo; + } + return undefined; +} - const local = await readLocalConfig(dir); - const names = Object.keys(local.connections); - - if (names.includes(resolvedConnectionName)) { - throw new CliDisplayError([ - { kind: "primary", text: `Connection already exists: ${resolvedConnectionName}` }, - ]); +async function promptField(field: ConfigItem): Promise { + if (field.secret) { + return password({ message: field.label }); } + return input({ message: field.label }); +} - if (names.length > 1) { - throw new CliDisplayError([ - { - kind: "primary", - text: "This stash has more than one connection in .stash/config.json (only one is supported).", - }, - { kind: "dim", text: "Remove extra entries or run:" }, - { kind: "dim", text: " stash disconnect --all" }, - { kind: "dim", text: "Then connect again." }, - ]); +async function collectFields( + fields: ConfigItem[], + valuesFromCli: Record, + current: Record = {}, +): Promise> { + const values: Record = { ...current }; + for (const field of fields) { + if (values[field.name]) { + continue; + } + const cliValue = getCliValue(field, valuesFromCli); + if (cliValue) { + values[field.name] = cliValue; + continue; + } + values[field.name] = await promptField(field); } + return values; +} - if (names.length === 1) { - const existing = names[0]; - throw new CliDisplayError([ - { - kind: "primary", - text: `This stash already has connection "${existing}" (only one is supported).`, - }, - { kind: "dim", text: `Disconnect it before adding "${resolvedConnectionName}":` }, - { kind: "dim", text: ` stash disconnect ${existing}` }, - ]); +function addFieldOptions(command: Command, fields: ConfigItem[]): void { + for (const field of fields) { + command.addOption(new Option(`--${field.name} `, field.label)); + if (field.name === "id") { + command.addOption(new Option("--repo ", `${field.label} (legacy alias)`)); + } } } @@ -124,37 +132,24 @@ function getProvider(name: string): ProviderClass { return provider; } -async function isExecutable(path: string): Promise { - try { - await access(path, constants.X_OK); - return true; - } catch { - return false; - } +function isUnsupportedPlatformError(error: unknown): boolean { + return error instanceof UnsupportedPlatformError; } -function writeLine(stream: NodeJS.WriteStream, text: string): void { - stream.write(`${text}\n`); +function formatUri(connection: StashRecord["connections"][number]): string { + return `${connection.provider}:${connection.config.id ?? ""}`; } -function isUnsupportedPlatformError(error: unknown): boolean { - return error instanceof UnsupportedPlatformError; +function sameConnection( + left: StashRecord["connections"][number], + right: StashRecord["connections"][number], +): boolean { + return left.provider === right.provider && JSON.stringify(left.config) === JSON.stringify(right.config); } -async function bounceDaemonIfMigrationNeeded( - dir: string, - getService: () => Promise, -): Promise { - if (!isStashDirectory(dir) || !(await needsMigration(dir))) { - return false; - } +async function isExecutable(path: string): Promise { try { - const service = await getService(); - const status = await service.status(); - if (!status.running) { - return false; - } - await service.uninstall(); + await access(path, constants.X_OK); return true; } catch { return false; @@ -186,74 +181,89 @@ export async function resolveServiceLaunch(argv = process.argv): Promise { - if (field.secret) { - return password({ message: field.label }); - } - return input({ message: field.label }); +async function createDefaultService(argv = process.argv): Promise { + const launch = await resolveServiceLaunch(argv); + return new Daemon({ + name: SERVICE_NAME, + description: SERVICE_DESCRIPTION, + command: launch.command, + args: launch.args, + env: process.env.PATH ? { PATH: process.env.PATH } : undefined, + }); } -async function collectFields( - fields: Field[], - valuesFromCli: Record, - current: Record = {}, -): Promise> { - const values: Record = { ...current }; - for (const field of fields) { - if (values[field.name]) { - continue; - } - const cliValue = valuesFromCli[field.name]; - if (typeof cliValue === "string") { - values[field.name] = cliValue; - continue; +async function canonicalizeExistingPath(path: string): Promise { + return realpath(resolve(path)); +} + +async function resolveLookupPath(path: string): Promise { + try { + return await canonicalizeExistingPath(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return resolve(path); } - values[field.name] = await promptField(field); + throw error; } - return values; } -async function readBackgroundStatus(dir: string): Promise { - const statusPath = join(dir, ".stash", "status.json"); - if (!existsSync(statusPath)) { - return null; +function ensureGitAllowed(path: string, dangerouslyAllowGit: boolean): void { + if (dangerouslyAllowGit) { + return; + } + if (existsSync(join(path, ".git"))) { + throw new Error( + "git repository detected. Pass `--dangerously-allow-git` if you really want stash to sync this folder.", + ); } - return JSON.parse(await readFile(statusPath, "utf8")) as PersistedBackgroundStatus; } -function renderGitWarning(stdout: NodeJS.WriteStream): void { - const { dim, yellow } = createColors(stdout); - writeLine( - stdout, - `${yellow("Warning:")} ${dim("This directory contains .git. Stash will not sync until you either:")}`, - ); - writeLine(stdout, dim(" - remove .git, or")); - writeLine( - stdout, - dim(' - run `stash config set allow-git true` (see "Using stash with git" in the README)'), - ); +async function readRegistry(stashPath: string): Promise { + if (!existsSync(stashPath)) { + return []; + } + const entries = await readdir(stashPath, { withFileTypes: true }); + const records: RegistryEntry[] = []; + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (!entry.isDirectory() || entry.name.startsWith(".")) { + continue; + } + const recordPath = join(stashPath, entry.name, "stash.json"); + try { + const record = JSON.parse(await readFile(recordPath, "utf8")) as StashRecord; + records.push({ id: entry.name, record }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + } + return records; } -async function warnIfGitSyncBlocked(dir: string, stdout: NodeJS.WriteStream): Promise { - if (!existsSync(join(dir, ".git"))) { - return; - } - const localConfig = await readLocalConfig(dir); - if (localConfig["allow-git"] === true) { - return; +async function readPersistedStatus(stashPath: string, id: string): Promise { + try { + return JSON.parse( + await readFile(join(getStashDirPath(id, stashPath), "status.json"), "utf8"), + ) as PersistedStatus; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw error; } - renderGitWarning(stdout); } -function parseConfigValue(key: "allow-git", value: string): boolean { - if (value === "true") { - return true; +function describeStatusKind(status: PersistedStatus | null): string { + if (!status?.kind) { + return "never"; } - if (value === "false") { - return false; + if (status.kind === "checked" || status.kind === "synced") { + return "idle"; } - throw new Error(`Invalid value for ${key}: expected true or false`); + return status.kind; } + async function runSetup( providerName: string, valuesFromCli: Record, @@ -261,461 +271,305 @@ async function runSetup( stdout: NodeJS.WriteStream, ): Promise { const provider = getProvider(providerName); - let globalConfig = await deps.readGlobalConfig(); + const globalFields = getGlobalConfigItems(provider); + const globalConfig = await deps.readGlobalConfig(); const values = await collectFields( - provider.spec.setup, + globalFields, valuesFromCli, getProviderConfig(globalConfig, providerName), ); - globalConfig = setProviderConfig(globalConfig, providerName, values); - await deps.writeGlobalConfig(globalConfig); + await deps.writeGlobalConfig(setProviderConfig(globalConfig, providerName, values)); writeLine(stdout, `Configured ${providerName}.`); } +function normalizeConnectArgs(first: string, second?: string): { pathArg?: string; uri: string } { + if (second) { + return { pathArg: first, uri: second }; + } + return { uri: first }; +} + +function normalizeDisconnectArgs( + first?: string, + second?: string, +): { pathArg?: string; uri?: string } { + if (first && second) { + return { pathArg: first, uri: second }; + } + if (first?.includes(":")) { + return { uri: first }; + } + return { pathArg: first }; +} + async function runConnect( - providerName: string, - connectionName: string | undefined, + first: string, + second: string | undefined, valuesFromCli: Record, - deps: Required> & { - getService?: () => Promise; - }, + deps: Required> & + Pick & { + getService: () => Promise; + }, stdout: NodeJS.WriteStream, ): Promise { - const { dim, green } = createColors(stdout); - const provider = getProvider(providerName); - const resolvedConnectionName = connectionName ?? providerName; - const dir = deps.cwd(); - await assertNewConnectionAllowedForDirectory(dir, resolvedConnectionName); + const { pathArg, uri } = normalizeConnectArgs(first, second); + const targetPath = pathArg ?? deps.cwd(); + const canonicalPath = await canonicalizeExistingPath(targetPath); + ensureGitAllowed(canonicalPath, valuesFromCli.dangerouslyAllowGit === true); + const parsed = parseUri(uri); + const provider = getProvider(parsed.provider); let globalConfig = await deps.readGlobalConfig(); - const setupValues = await collectFields( - provider.spec.setup, + const globalValues = await collectFields( + getGlobalConfigItems(provider), valuesFromCli, - getProviderConfig(globalConfig, providerName), + getProviderConfig(globalConfig, parsed.provider), ); - globalConfig = setProviderConfig(globalConfig, providerName, setupValues); - await deps.writeGlobalConfig(globalConfig); - const stash = await Stash.init(dir, globalConfig); - const connectValues = await collectFields(provider.spec.connect, valuesFromCli); - await stash.connect({ - name: resolvedConnectionName, - provider: providerName, - ...connectValues, + new provider({ + ...getProviderConfig(globalConfig, parsed.provider), + ...globalValues, + ...parsed.config, }); - globalConfig = await deps.readGlobalConfig(); - await deps.writeGlobalConfig(addBackgroundStash(globalConfig, resolve(dir))); - writeLine(stdout, `Connected ${resolvedConnectionName}.`); - await warnIfGitSyncBlocked(dir, stdout); + globalConfig = setProviderConfig(globalConfig, parsed.provider, globalValues); + await deps.writeGlobalConfig(globalConfig); - if (!deps.getService) { - return; + const stashPath = deps.stashPath ?? getStashPath(); + const outcome = await withRegistryLock( + async () => { + const entries = await readRegistry(stashPath); + const found = entries.find((entry) => entry.record.path === canonicalPath); + const connection = { + provider: parsed.provider, + config: parsed.config, + }; + + if (!found) { + await Stash.create( + { + path: canonicalPath, + connections: [connection], + }, + globalConfig, + { stashPath }, + ); + return "connected" as const; + } + + if (found.record.connections.length === 0) { + await writeJsonFile(join(getStashDirPath(found.id, stashPath), "stash.json"), { + ...found.record, + connections: [connection], + } satisfies StashRecord); + return "connected" as const; + } + + if (found.record.connections.some((existing) => sameConnection(existing, connection))) { + return "already-connected" as const; + } + + throw new Error( + `${canonicalPath} is already connected to ${formatUri(found.record.connections[0])}. Multi-connection per stash is not yet supported. Run \`stash disconnect ${canonicalPath}\` first.`, + ); + }, + { stashPath }, + ); + + if (outcome === "already-connected") { + writeLine(stdout, "Already connected."); + } else { + writeLine(stdout, `Connected ${uri} to ${canonicalPath}.`); } try { const status = await (await deps.getService()).status(); if (status.running) { - writeLine( - stdout, - `${green("Background sync is on")} ${dim("·")} ${dim("This stash is now syncing automatically")}`, - ); + writeLine(stdout, "Background sync is on · syncing automatically"); } } catch (error) { - if ( - error instanceof Error && - error.message === "Could not resolve a command to run `stash daemon`" - ) { - return; - } if (!isUnsupportedPlatformError(error)) { throw error; } } } -function formatConnectionLabel(name: string, provider: string): string { - return `${name} (${provider})`; -} +async function runDisconnect( + first: string | undefined, + second: string | undefined, + deps: Required> & Pick, + stdout: NodeJS.WriteStream, +): Promise { + const { pathArg, uri } = normalizeDisconnectArgs(first, second); + const lookupPath = await resolveLookupPath(pathArg ?? deps.cwd()); + const stashPath = deps.stashPath ?? getStashPath(); + + let disconnectedPath = lookupPath; + let disconnectedUri = uri ?? ""; + + await withRegistryLock( + async () => { + const entries = await readRegistry(stashPath); + const found = entries.find((entry) => entry.record.path === lookupPath); + if (!found) { + throw new Error(`No stash found at ${lookupPath}.`); + } + + const currentUris = found.record.connections.map((connection) => formatUri(connection)); + let targetUri = uri; + if (!targetUri) { + if (currentUris.length !== 1) { + throw new Error(`Multiple connections present: ${currentUris.join(", ")}`); + } + [targetUri] = currentUris; + } + + const nextConnections = found.record.connections.filter( + (connection) => formatUri(connection) !== targetUri, + ); + if (nextConnections.length === found.record.connections.length) { + throw new Error(`Connection not found: ${targetUri}`); + } -function validateDisconnectSelection( - name: string | undefined, - all: boolean, - targetPath: string | undefined, -): { name?: string; all: boolean; targetPath?: string } { - const selected = [name ? "name" : null, all ? "all" : null, targetPath ? "path" : null].filter( - (value): value is string => value !== null, + disconnectedPath = found.record.path; + disconnectedUri = targetUri; + await writeJsonFile(join(getStashDirPath(found.id, stashPath), "stash.json"), { + ...found.record, + connections: nextConnections, + } satisfies StashRecord); + }, + { stashPath }, ); - if (selected.length === 0) { - throw new Error( - "argument required — run `stash disconnect `, `stash disconnect --all`, or `stash disconnect --path `", - ); - } - if (selected.length > 1) { - throw new Error( - "disconnect modes are mutually exclusive — use only one of ``, `--all`, or `--path `", - ); - } - return { name, all, targetPath }; -} -function formatConnectionSummary(status: StatusResult): string { - const parts = formatChangeParts(status); - if (status.lastSync) { - const ago = formatTimeAgo(status.lastSync); - return parts.length > 0 - ? `Local changes: ${parts.join(", ")} · synced ${ago}` - : `Up to date · synced ${ago}`; - } - if (parts.length > 0) { - return `Local changes: ${parts.join(", ")} · Never synced`; - } - return "Waiting for first sync"; + writeLine(stdout, `Disconnected ${disconnectedUri} from ${disconnectedPath}.`); } -async function runDisconnect( - name: string | undefined, - all: boolean, - targetPath: string | undefined, - cwd: () => string, - readConfig: () => Promise, - writeConfig: (config: GlobalConfig) => Promise, +async function runRelink( + oldPath: string, + newPath: string, + dangerouslyAllowGit: boolean, + deps: Pick, stdout: NodeJS.WriteStream, ): Promise { - const selection = validateDisconnectSelection(name, all, targetPath); - const globalConfig = await readConfig(); - if (selection.targetPath) { - const resolvedPath = resolve(selection.targetPath); - const wasRegistered = getBackgroundStashes(globalConfig).includes(resolvedPath); - if (!wasRegistered) { - writeLine(stdout, "No stash registered at that path."); - return; - } - await writeConfig(removeBackgroundStash(globalConfig, resolvedPath)); - if (existsSync(resolvedPath)) { - await rm(join(resolvedPath, ".stash"), { recursive: true, force: true }); - } - writeLine(stdout, "Disconnected stash."); + const oldCanonical = await resolveLookupPath(oldPath); + const newCanonical = await canonicalizeExistingPath(newPath); + ensureGitAllowed(newCanonical, dangerouslyAllowGit); + if (oldCanonical === newCanonical) { return; } - const dir = cwd(); - const stash = await Stash.load(dir, globalConfig); + const stashPath = deps.stashPath ?? getStashPath(); + await withRegistryLock( + async () => { + const entries = await readRegistry(stashPath); + const current = entries.find((entry) => entry.record.path === oldCanonical); + if (!current) { + throw new Error(`No stash found at ${oldCanonical}.`); + } + if (entries.some((entry) => entry.id !== current.id && entry.record.path === newCanonical)) { + throw new Error(`A stash already exists at ${newCanonical}.`); + } + await writeJsonFile(join(getStashDirPath(current.id, stashPath), "stash.json"), { + ...current.record, + path: newCanonical, + } satisfies StashRecord); + }, + { stashPath }, + ); - if (selection.all) { - for (const name of Object.keys(stash.connections)) { - await stash.disconnect(name); - } - await writeConfig(removeBackgroundStash(globalConfig, resolve(dir))); - await rm(join(dir, ".stash"), { recursive: true, force: true }); - writeLine(stdout, "Disconnected stash."); - return; - } + writeLine(stdout, `Relinked ${oldCanonical} -> ${newCanonical}.`); +} - const connectionName = selection.name!; - if (!stash.connections[connectionName]) { - throw new Error(`Connection not found: ${connectionName}`); +function formatPendingChanges(status: PersistedStatus | null): string | null { + const pending = status?.pendingChanges; + if (!pending) { + return null; } - - await stash.disconnect(connectionName); - if (Object.keys(stash.connections).length === 0) { - await writeConfig(removeBackgroundStash(globalConfig, resolve(dir))); - await rm(join(dir, ".stash"), { recursive: true, force: true }); + const parts: string[] = []; + if ((pending.modified ?? 0) > 0) { + parts.push(`${pending.modified} modified`); } - writeLine(stdout, `Disconnected ${connectionName}.`); -} - -async function runSync(cwd: () => string, readConfig: () => Promise): Promise { - const stash = await Stash.load(cwd(), await readConfig()); - const line = new LiveLine(process.stdout); - const { green, red } = line.colors; - const renderer = new SyncRenderer(line); - const subscription = stash.on("mutation", (mutation) => { - renderer.onMutation(mutation); - }); - - line.startSpinner("checking..."); - - try { - await stash.sync(); - const summary = renderer.done(); - line.print(summary ? `${green("✓")} synced (${summary})` : `${green("✓")} up to date`); - } catch (error) { - renderer.error(error as Error); - line.print( - `${red("✗")} sync failed: ${error instanceof Error ? error.message : String(error)}`, - ); - process.exitCode = 1; - } finally { - subscription.dispose(); - renderer.dispose(); - line.dispose(); + if ((pending.added ?? 0) > 0) { + parts.push(`${pending.added} added`); } -} - -async function runWatch(cwd: () => string, readConfig: () => Promise): Promise { - const dir = cwd(); - const stash = await Stash.load(dir, await readConfig()); - if (Object.keys(stash.connections).length === 0) { - throw new Error("no connection configured — run `stash connect ` first"); + if ((pending.deleted ?? 0) > 0) { + parts.push(`${pending.deleted} deleted`); } - await watchStash(stash, { dir, stdin: process.stdin, stdout: process.stdout }); + return parts.length > 0 ? parts.join(", ") : null; } -function formatChangeParts(status: { - added: string[]; - modified: string[]; - deleted: string[]; -}): string[] { - const parts: string[] = []; - if (status.added.length > 0) parts.push(`${status.added.length} added`); - if (status.modified.length > 0) parts.push(`${status.modified.length} modified`); - if (status.deleted.length > 0) parts.push(`${status.deleted.length} deleted`); - return parts; -} - -function formatStashCount(count: number): string { - return `${count} ${count === 1 ? "stash" : "stashes"}`; -} - -async function runStatusAll( - readConfig: () => Promise, - serviceHandle: ServiceHandle, +async function runStatus( + deps: Required> & + Pick & { + getService: () => Promise; + }, stdout: NodeJS.WriteStream, ): Promise { - const { dim, green, red } = createColors(stdout); - const globalConfig = await readConfig(); - const stashes = getBackgroundStashes(globalConfig); - - if (stashes.length === 0) { - writeLine( - stdout, - "No stashes connected yet — run `stash connect ` in a directory to get started", - ); + const daemonState = await deps + .getService() + .then((service) => service.status()) + .then((status) => (status.running ? "running" : "stopped")) + .catch((error: unknown) => (isUnsupportedPlatformError(error) ? "unsupported" : "error")); + writeLine(stdout, `Daemon: ${daemonState}`); + + const stashPath = deps.stashPath ?? getStashPath(); + const entries = await readRegistry(stashPath); + if (entries.length === 0) { return; } - try { - const current = await serviceHandle.status(); - if (current.running) { - writeLine( - stdout, - `${green("Background sync is on")} ${dim("·")} ${dim(`watching ${formatStashCount(stashes.length)}`)}`, - ); - } else { - writeLine(stdout, red("Background sync is off")); + writeLine(stdout, ""); + for (const entry of entries.sort((a, b) => a.record.path.localeCompare(b.record.path))) { + const status = await readPersistedStatus(stashPath, entry.id); + if (status?.kind === "missing" || !existsSync(entry.record.path)) { writeLine( stdout, - dim(`Run \`stash start\` to resume syncing ${formatStashCount(stashes.length)}`), + `${entry.record.path} (path missing — run \`stash relink ${entry.record.path} \`)`, ); - } - } catch (error) { - if (isUnsupportedPlatformError(error)) { - writeLine(stdout, red("Background sync is not supported on this platform")); - } else { - throw error; - } - } - - writeLine(stdout, ""); - - for (const dir of stashes) { - const title = basename(dir); - if (!existsSync(dir)) { - writeLine(stdout, `${red("✗")} ${title}`); - writeLine(stdout, dim(` ${dir}`)); - writeLine(stdout, dim(" Directory not found")); writeLine(stdout, ""); continue; } - if (!isStashDirectory(dir)) { - writeLine(stdout, `${red("✗")} ${title}`); - writeLine(stdout, dim(` ${dir}`)); - writeLine(stdout, dim(" Not a stash")); - writeLine(stdout, ""); - continue; + writeLine(stdout, entry.record.path); + for (const connection of entry.record.connections) { + writeLine(stdout, ` ${formatUri(connection)}`); } - const stash = await Stash.load(dir, globalConfig); - const persistedStatus = await readBackgroundStatus(dir); - const localStatus = stash.status(); - const connectionNames = Object.keys(stash.connections); - - if (persistedStatus?.kind === "error") { - writeLine(stdout, `${red("✗")} ${title}`); - writeLine(stdout, dim(` ${dir}`)); - for (const connectionName of connectionNames) { - const connection = stash.connections[connectionName]; - writeLine( - stdout, - ` ${formatConnectionLabel(connectionName, connection.provider)} ${dim("·")} ${red(persistedStatus.error ?? "unknown error")}`, - ); - } - writeLine(stdout, ""); - continue; + writeLine(stdout, ` State: ${describeStatusKind(status)}`); + if (status?.lastSync) { + writeLine(stdout, ` Last sync: ${formatTimeAgo(new Date(status.lastSync))}`); } - - writeLine(stdout, `${green("●")} ${title}`); - writeLine(stdout, dim(` ${dir}`)); - - for (const connectionName of connectionNames) { - const connection = stash.connections[connectionName]; - if (persistedStatus === null || localStatus.lastSync === null) { - writeLine( - stdout, - ` ${formatConnectionLabel(connectionName, connection.provider)} ${dim("·")} ${dim("Waiting for first sync")}`, - ); - continue; - } - - writeLine( - stdout, - ` ${formatConnectionLabel(connectionName, connection.provider)} ${dim("·")} ${dim(formatConnectionSummary(localStatus))}`, - ); + const pending = formatPendingChanges(status); + if (pending) { + writeLine(stdout, ` Pending: ${pending}`); + } + if (status?.error) { + writeLine(stdout, ` Last error: ${status.error}`); } writeLine(stdout, ""); } } -async function runStart( - serviceHandle: ServiceHandle, - readConfig: () => Promise, - stdout: NodeJS.WriteStream, -): Promise { - const { dim, green, red } = createColors(stdout); - try { - const current = await serviceHandle.status(); - if (current.installed && current.running) { - writeLine(stdout, green("Background sync is already running")); - return; - } - } catch (error) { - if (isUnsupportedPlatformError(error)) { - writeLine(stdout, red("Background sync is not supported on this platform")); - process.exitCode = 1; - return; - } - throw error; - } +async function runStart(serviceHandle: ServiceHandle, stdout: NodeJS.WriteStream): Promise { await serviceHandle.install(); - const count = getBackgroundStashes(await readConfig()).length; - writeLine(stdout, green("Background sync is on")); - writeLine(stdout, dim(`Watching ${formatStashCount(count)} · starts on startup`)); + writeLine(stdout, "Daemon started."); } -async function runStop( - serviceHandle: ServiceHandle, - readConfig: () => Promise, - stdout: NodeJS.WriteStream, -): Promise { - const { dim, red } = createColors(stdout); - try { - const current = await serviceHandle.status(); - if (!current.installed && !current.running) { - writeLine(stdout, red("Background sync is not running")); - return; - } - } catch (error) { - if (isUnsupportedPlatformError(error)) { - writeLine(stdout, red("Background sync is not running")); - return; - } - throw error; - } - +async function runStop(serviceHandle: ServiceHandle, stdout: NodeJS.WriteStream): Promise { await serviceHandle.uninstall(); - const count = getBackgroundStashes(await readConfig()).length; - writeLine(stdout, red("Background sync is off")); - writeLine(stdout, dim(`Run \`stash start\` to resume syncing ${formatStashCount(count)}`)); -} - -async function ensureInitializedStash( - cwd: () => string, - readConfig: () => Promise, -): Promise { - const dir = cwd(); - await Stash.load(dir, await readConfig()); - return dir; -} - -async function runConfigSet( - key: string, - value: string, - cwd: () => string, - readConfig: () => Promise, - stdout: NodeJS.WriteStream, -): Promise { - if (!isStashConfigKey(key)) { - throw new Error(`Unknown config key: ${key}`); - } - const dir = await ensureInitializedStash(cwd, readConfig); - const currentConfig = await readLocalConfig(dir); - const nextConfig: LocalConfig = { - ...currentConfig, - [key]: parseConfigValue(key, value), - }; - await writeLocalConfig(dir, nextConfig); - writeLine(stdout, `${key}=${String(nextConfig[key])}`); -} - -async function runConfigGet( - key: string, - cwd: () => string, - readConfig: () => Promise, - stdout: NodeJS.WriteStream, -): Promise { - if (!isStashConfigKey(key)) { - throw new Error(`Unknown config key: ${key}`); - } - const dir = await ensureInitializedStash(cwd, readConfig); - const localConfig = await readLocalConfig(dir); - const value = localConfig[key]; - writeLine(stdout, value === undefined ? "" : String(value)); -} - -function addFieldOptions(command: Command, fields: Field[]): void { - for (const field of fields) { - command.addOption(new Option(`--${field.name} `, field.label)); - } -} - -async function createDefaultService(argv = process.argv): Promise { - const launch = await resolveServiceLaunch(argv); - return new Daemon({ - name: SERVICE_NAME, - description: SERVICE_DESCRIPTION, - command: launch.command, - args: launch.args, - env: process.env.PATH ? { PATH: process.env.PATH } : undefined, - }); + writeLine(stdout, "Daemon stopped."); } export async function main(argv = process.argv, deps: CliDependencies = {}): Promise { const cwd = deps.cwd ?? (() => process.cwd()); const readConfig = deps.readGlobalConfig ?? readGlobalConfig; const writeConfig = deps.writeGlobalConfig ?? writeGlobalConfig; + const stashPath = deps.stashPath ?? getStashPath(); const serviceHandle = deps.service; const getService = async () => serviceHandle ?? (await createDefaultService(argv)); const runDaemonCommand = deps.runDaemon ?? runDaemon; const stdout = deps.stdout ?? process.stdout; const stderr = deps.stderr ?? process.stderr; - - const dirsToCheck = [cwd()]; - try { - const config = await readConfig(); - dirsToCheck.push(...getBackgroundStashes(config)); - } catch { - // Global config unreadable — skip migration check. - } - let daemonBounced = false; - for (const dir of dirsToCheck) { - if (await bounceDaemonIfMigrationNeeded(dir, getService)) { - daemonBounced = true; - break; - } - } - const { version } = createRequire(import.meta.url)("../package.json") as { version: string }; const program = new Command() @@ -748,89 +602,69 @@ export async function main(argv = process.argv, deps: CliDependencies = {}): Pro const connectCommand = program .command("connect") - .description("Connect this stash to a provider") - .argument("", "Provider name") - .argument("[name]", "Connection name") + .description("Connect a path to a provider uri") + .argument("", "URI or explicit path") + .argument("[second]", "URI when an explicit path is provided") .allowUnknownOption(true) - .action( - async ( - providerName: string, - connectionName: string | undefined, - _opts: unknown, - command: Command, - ) => { - const opts = command.opts() as Record; - await runConnect( - providerName, - connectionName, - opts, - { - cwd, - readGlobalConfig: readConfig, - writeGlobalConfig: writeConfig, - getService, - }, - stdout, - ); - }, - ); - - program - .command("disconnect") - .description("Disconnect a named connection or stash") - .argument("[name]", "Connection name") - .option("--all", "Disconnect all connections in the current stash") - .option("--path ", "Disconnect a stash by path") - .action(async (name: string | undefined, options: { all?: boolean; path?: string }) => { - await runDisconnect( - name, - options.all === true, - options.path, - cwd, - readConfig, - writeConfig, + .option("--dangerously-allow-git", "Allow syncing a git repository") + .action(async (first: string, second: string | undefined, _opts: unknown, command: Command) => { + await runConnect( + first, + second, + command.opts() as Record, + { + cwd, + readGlobalConfig: readConfig, + writeGlobalConfig: writeConfig, + stashPath, + getService, + }, stdout, ); }); + program - .command("sync") - .description("Sync local files with connections") - .action(() => runSync(cwd, readConfig)); + .command("disconnect") + .description("Disconnect a stash path or uri") + .argument("[first]", "Path or URI") + .argument("[second]", "URI when an explicit path is provided") + .action(async (first: string | undefined, second: string | undefined) => { + await runDisconnect(first, second, { cwd, stashPath }, stdout); + }); + program - .command("watch") - .description("Watch and sync continuously") - .action(() => runWatch(cwd, readConfig)); + .command("relink") + .description("Relink a stash from one path to another") + .argument("", "Current or missing path") + .argument("", "New existing path") + .option("--dangerously-allow-git", "Allow relinking to a git repository") + .action(async (oldPath: string, newPath: string, options: { dangerouslyAllowGit?: boolean }) => { + await runRelink(oldPath, newPath, options.dangerouslyAllowGit === true, { stashPath }, stdout); + }); + program .command("status") - .description("Show all connected stashes") + .description("Show registered stashes") .action(async () => { - await runStatusAll(readConfig, await getService(), stdout); + await runStatus( + { + readGlobalConfig: readConfig, + stashPath, + getService, + }, + stdout, + ); }); - const configCommand = program.command("config").description("Manage stash config"); - - configCommand - .command("set") - .description("Set a stash config value") - .argument("", "Config key") - .argument("", "Config value") - .action((key: string, value: string) => runConfigSet(key, value, cwd, readConfig, stdout)); - - configCommand - .command("get") - .description("Get a stash config value") - .argument("", "Config key") - .action((key: string) => runConfigGet(key, cwd, readConfig, stdout)); - program .command("start") .description("Start background sync") - .action(async () => runStart(await getService(), readConfig, stdout)); + .action(async () => runStart(await getService(), stdout)); program .command("stop") .description("Stop background sync") - .action(async () => runStop(await getService(), readConfig, stdout)); + .action(async () => runStop(await getService(), stdout)); program .command("daemon", { hidden: true }) @@ -838,14 +672,25 @@ export async function main(argv = process.argv, deps: CliDependencies = {}): Pro .action(() => runDaemonCommand()); const commandName = argv[2]; - const providerName = argv[3]; - if (providerName && (commandName === "setup" || commandName === "connect")) { - const provider = providers[providerName]; - if (provider) { - if (commandName === "setup") { - addFieldOptions(setupCommand, provider.spec.setup); - } else { - addFieldOptions(connectCommand, [...provider.spec.setup, ...provider.spec.connect]); + const providerToken = + commandName === "connect" && argv[4] && !argv[3]?.includes(":") ? argv[4] : argv[3]; + if (providerToken && (commandName === "setup" || commandName === "connect")) { + try { + const uriProvider = + commandName === "connect" && providerToken.includes(":") + ? parseUri(providerToken).provider + : providerToken; + const provider = providers[uriProvider]; + if (provider) { + if (commandName === "setup") { + addFieldOptions(setupCommand, getGlobalConfigItems(provider)); + } else { + addFieldOptions(connectCommand, getGlobalConfigItems(provider)); + } + } + } catch (error) { + if (!(error instanceof InvalidUriError)) { + throw error; } } } @@ -856,13 +701,4 @@ export async function main(argv = process.argv, deps: CliDependencies = {}): Pro } await program.parseAsync(argv); - - const skipRestart = commandName === "stop" || commandName === "daemon"; - if (daemonBounced && !skipRestart) { - try { - await (await getService()).install(); - } catch { - // Best-effort restart — service may be unsupported or unavailable. - } - } } diff --git a/src/daemon.ts b/src/daemon.ts index e168624..47fe114 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -1,16 +1,25 @@ import watcher from "@parcel/watcher"; import { existsSync } from "node:fs"; -import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { appendFile, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; -import { getGlobalConfigPath, normalizeGlobalConfig, readGlobalConfig } from "./global-config.ts"; +import { + getGlobalConfigPath, + getProviderConfig, + getStashDirPath, + normalizeGlobalConfig, +} from "./global-config.ts"; +import { withRegistryLock } from "./registry-lock.ts"; import { Stash } from "./stash.ts"; -import type { GlobalConfig } from "./types.ts"; +import type { GlobalConfig, StashRecord } from "./types.ts"; import { Watch, type WatchStatus } from "./watch.ts"; +import { readJsonFile, writeJsonFile } from "./utils/fs.ts"; const MAX_LOG_BYTES = 1_000_000; +const DEFAULT_POLL_MS = 30_000; +const ORPHAN_MAX_AGE_MS = 5 * 60_000; type SubscribeFn = typeof watcher.subscribe; -type ConfigSubscription = Awaited>; +type WatchSubscription = Awaited>; type RunningWatch = { start(): Promise; @@ -18,8 +27,7 @@ type RunningWatch = { }; type CreateWatchArgs = { - dir: string; - globalConfig: GlobalConfig; + stash: Stash; onStatus: (status: WatchStatus) => Promise; }; @@ -31,19 +39,41 @@ type BackgroundDaemonOptions = { subscribe?: SubscribeFn; createWatch?: CreateWatchFn; log?: (message: string) => void; + pollMs?: number; }; -type PersistedWatchStatus = { - kind: WatchStatus["kind"]; +type PersistedStatus = { + kind: string; lastSync: string | null; summary: string | null; error: string | null; }; +type DesiredEntry = { + id: string; + record: StashRecord; + providerConfig: Record>; + key: string; +}; + +type RegistrySnapshot = { + live: Map; + missing: Map; + tombstones: Set; +}; + +type RunningEntry = { + key: string; + watch: RunningWatch; +}; + +async function readGlobalConfigAtPath(configPath: string): Promise { + return normalizeGlobalConfig(await readJsonFile(configPath, {})); +} + async function defaultCreateWatch(args: CreateWatchArgs): Promise { - const stash = await Stash.load(args.dir, args.globalConfig); - const watch = new Watch(stash, { - dir: args.dir, + const watch = new Watch(args.stash, { + dir: args.stash.record.path, onStatus: (status) => { void args.onStatus(status); }, @@ -58,7 +88,7 @@ async function defaultCreateWatch(args: CreateWatchArgs): Promise }; } -function serializeStatus(status: WatchStatus): PersistedWatchStatus { +function serializeStatus(status: WatchStatus): PersistedStatus { if (status.kind === "synced") { return { kind: status.kind, @@ -118,24 +148,37 @@ async function appendCappedLog(path: string, line: string): Promise { await writeFile(path, lines.join("\n").replace(/^\n+/, ""), "utf8"); } +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} + export class BackgroundDaemon { private readonly configPath: string; + private readonly stashPath: string; private readonly readGlobalConfigFn: () => Promise; private readonly subscribeFn: SubscribeFn; private readonly createWatchFn: CreateWatchFn; private readonly log: (message: string) => void; - private readonly running = new Map(); - private configSubscription: ConfigSubscription | null = null; + private readonly pollMs: number; + private readonly running = new Map(); + private readonly subscriptions: WatchSubscription[] = []; private started = false; private stopping = false; private reloadPromise: Promise | null = null; + private pollTimer: NodeJS.Timeout | null = null; constructor(options: BackgroundDaemonOptions = {}) { this.configPath = options.configPath ?? getGlobalConfigPath(); - this.readGlobalConfigFn = options.readGlobalConfig ?? readGlobalConfig; + this.stashPath = dirname(this.configPath); + this.readGlobalConfigFn = options.readGlobalConfig ?? (() => readGlobalConfigAtPath(this.configPath)); this.subscribeFn = options.subscribe ?? watcher.subscribe; this.createWatchFn = options.createWatch ?? defaultCreateWatch; this.log = options.log ?? ((message) => console.log(message)); + this.pollMs = options.pollMs ?? DEFAULT_POLL_MS; } get watchCount(): number { @@ -148,28 +191,11 @@ export class BackgroundDaemon { } this.started = true; - await mkdir(dirname(this.configPath), { recursive: true }); - try { - this.configSubscription = await this.subscribeFn( - dirname(this.configPath), - (error, events) => { - if (error) { - this.log(`background daemon config watch failed: ${error.message}`); - return; - } - if (!events.some((event) => event.path === this.configPath)) { - return; - } - void this.reload(); - }, - ); - } catch (error) { - this.log( - `native config watcher unavailable, config hot-reload disabled: ${error instanceof Error ? error.message : String(error)}`, - ); - } - + await mkdir(this.stashPath, { recursive: true }); + await this.installWatchers(); + await this.runStartupGc(); await this.reload(); + this.schedulePoll(); } async stop(): Promise { @@ -178,9 +204,10 @@ export class BackgroundDaemon { } this.stopping = true; - if (this.configSubscription) { - await this.configSubscription.unsubscribe(); - this.configSubscription = null; + this.clearPollTimer(); + while (this.subscriptions.length > 0) { + const subscription = this.subscriptions.pop(); + await subscription?.unsubscribe(); } if (this.reloadPromise) { await this.reloadPromise; @@ -188,7 +215,55 @@ export class BackgroundDaemon { const entries = [...this.running.entries()]; this.running.clear(); - await Promise.all(entries.map(async ([, watchHandle]) => watchHandle.stop())); + await Promise.all(entries.map(async ([, entry]) => entry.watch.stop())); + } + + private schedulePoll(): void { + this.clearPollTimer(); + this.pollTimer = setTimeout(() => { + this.pollTimer = null; + if (this.stopping) { + return; + } + void this.reload().finally(() => { + if (!this.stopping) { + this.schedulePoll(); + } + }); + }, this.pollMs); + } + + private clearPollTimer(): void { + if (!this.pollTimer) { + return; + } + clearTimeout(this.pollTimer); + this.pollTimer = null; + } + + private async installWatchers(): Promise { + for (const watchedPath of [this.configPath, this.stashPath]) { + try { + const subscription = await this.subscribeFn(watchedPath, (error, events) => { + if (error) { + this.log(`background daemon watcher failed: ${error.message}`); + return; + } + if ( + watchedPath === this.configPath && + !events.some((event) => event.path === this.configPath) + ) { + return; + } + void this.reload(); + }); + this.subscriptions.push(subscription); + } catch (error) { + this.log( + `native watcher unavailable, using poll-only reconciliation: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } } private async reload(): Promise { @@ -197,38 +272,39 @@ export class BackgroundDaemon { } this.reloadPromise = (async () => { - const config = normalizeGlobalConfig(await this.readGlobalConfigFn()); - const desired = new Set(config.background.stashes); + const globalConfig = normalizeGlobalConfig(await this.readGlobalConfigFn()); + const snapshot = await this.readRegistrySnapshot(globalConfig); + const retainedIds = new Set([ + ...snapshot.live.keys(), + ...snapshot.missing.keys(), + ...snapshot.tombstones, + ]); + + for (const id of snapshot.tombstones) { + await this.stopRunning(id); + await this.deleteTombstoneDir(id); + } - for (const [dir, watchHandle] of [...this.running.entries()]) { - if (desired.has(dir)) { - continue; + for (const [id] of snapshot.missing) { + await this.stopRunning(id); + await this.persistMissingStatus(id); + } + + for (const id of [...this.running.keys()]) { + if (!retainedIds.has(id)) { + await this.stopRunning(id); } - this.running.delete(dir); - await watchHandle.stop(); - this.log(`stopped watching ${dir}`); } - for (const dir of desired) { - if (this.running.has(dir)) { + for (const [id, entry] of snapshot.live) { + const current = this.running.get(id); + if (current && current.key === entry.key) { continue; } - try { - const watchHandle = await this.createWatchFn({ - dir, - globalConfig: config, - onStatus: async (status) => { - await this.persistStatus(dir, status); - }, - }); - this.running.set(dir, watchHandle); - await watchHandle.start(); - this.log(`watching ${dir}`); - } catch (error) { - this.log( - `failed to start ${dir}: ${error instanceof Error ? error.message : String(error)}`, - ); + if (current) { + await this.stopRunning(id); } + await this.startWatch(entry, globalConfig); } })(); @@ -239,20 +315,172 @@ export class BackgroundDaemon { } } - private async persistStatus(dir: string, status: WatchStatus): Promise { - const stashDir = join(dir, ".stash"); - if (!existsSync(stashDir)) { + private async startWatch(entry: DesiredEntry, globalConfig: GlobalConfig): Promise { + try { + const stash = await Stash.load(entry.id, globalConfig, { stashPath: this.stashPath }); + const watch = await this.createWatchFn({ + stash, + onStatus: async (status) => { + await this.persistStatus(entry.id, status); + }, + }); + this.running.set(entry.id, { + key: entry.key, + watch, + }); + await watch.start(); + this.log(`watching ${entry.id}`); + } catch (error) { + this.log( + `failed to start ${entry.id}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + private async stopRunning(id: string): Promise { + const current = this.running.get(id); + if (!current) { return; } + this.running.delete(id); + await current.watch.stop(); + this.log(`stopped watching ${id}`); + } + + private async readRegistrySnapshot(globalConfig: GlobalConfig): Promise { + const live = new Map(); + const missing = new Map(); + const tombstones = new Set(); + + for (const id of await this.listStashIds()) { + const record = await this.readStashRecordIfExists(id); + if (!record) { + continue; + } + + if (record.connections.length === 0) { + tombstones.add(id); + continue; + } + + const providerConfig = Object.fromEntries( + [...new Set(record.connections.map((connection) => connection.provider))] + .sort((a, b) => a.localeCompare(b)) + .map((provider) => [provider, getProviderConfig(globalConfig, provider)]), + ); + const entry = { + id, + record, + providerConfig, + key: JSON.stringify({ + record, + providerConfig, + }), + } satisfies DesiredEntry; + + if (existsSync(record.path)) { + live.set(id, entry); + } else { + missing.set(id, entry); + } + } + + return { live, missing, tombstones }; + } + private async listStashIds(): Promise { + if (!(await isDirectory(this.stashPath))) { + return []; + } + + const entries = await readdir(this.stashPath); + const ids: string[] = []; + for (const entry of entries.sort((a, b) => a.localeCompare(b))) { + if (entry.startsWith(".")) { + continue; + } + if (!(await isDirectory(join(this.stashPath, entry)))) { + continue; + } + ids.push(entry); + } + return ids; + } + + private async readStashRecordIfExists(id: string): Promise { + const path = join(getStashDirPath(id, this.stashPath), "stash.json"); + try { + const raw = await readFile(path, "utf8"); + return JSON.parse(raw) as StashRecord; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw error; + } + } + + private async persistStatus(id: string, status: WatchStatus): Promise { + const stashDir = getStashDirPath(id, this.stashPath); await mkdir(stashDir, { recursive: true }); - await writeFile( - join(stashDir, "status.json"), - JSON.stringify(serializeStatus(status), null, 2), - "utf8", - ); + await writeJsonFile(join(stashDir, "status.json"), serializeStatus(status)); await appendCappedLog(join(stashDir, "sync.log"), formatLogLine(status)); } + + private async persistMissingStatus(id: string): Promise { + const stashDir = getStashDirPath(id, this.stashPath); + await mkdir(stashDir, { recursive: true }); + await writeJsonFile(join(stashDir, "status.json"), { + kind: "missing", + lastSync: null, + summary: null, + error: null, + } satisfies PersistedStatus); + } + + private async deleteTombstoneDir(id: string): Promise { + await withRegistryLock( + async () => { + const record = await this.readStashRecordIfExists(id); + if (record && record.connections.length === 0) { + await rm(getStashDirPath(id, this.stashPath), { recursive: true, force: true }); + } + }, + { stashPath: this.stashPath }, + ); + } + + private async deleteOrphanDirIfOld(id: string): Promise { + await withRegistryLock( + async () => { + const stashDir = getStashDirPath(id, this.stashPath); + if (!(await isDirectory(stashDir))) { + return; + } + if ((await this.readStashRecordIfExists(id)) !== null) { + return; + } + const ageMs = Date.now() - (await stat(stashDir)).mtimeMs; + if (ageMs > ORPHAN_MAX_AGE_MS) { + await rm(stashDir, { recursive: true, force: true }); + } + }, + { stashPath: this.stashPath }, + ); + } + + private async runStartupGc(): Promise { + for (const id of await this.listStashIds()) { + const record = await this.readStashRecordIfExists(id); + if (!record) { + await this.deleteOrphanDirIfOld(id); + continue; + } + if (record.connections.length === 0) { + await this.deleteTombstoneDir(id); + } + } + } } export async function runDaemon(): Promise { diff --git a/src/errors.ts b/src/errors.ts index 8c691ce..a6ebe72 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -36,3 +36,10 @@ export class MigrationError extends Error { this.name = "MigrationError"; } } + +export class InvalidUriError extends Error { + constructor(uri: string) { + super(`Invalid URI: ${uri}`); + this.name = "InvalidUriError"; + } +} diff --git a/src/global-config.ts b/src/global-config.ts index f134e2f..bf55380 100644 --- a/src/global-config.ts +++ b/src/global-config.ts @@ -51,19 +51,22 @@ export function getGlobalConfigPath(): string { return join(getStashPath(), "config.json"); } -export function getStashDirPath(id: string): string { - return join(getStashPath(), id); +export function getStashDirPath(id: string, stashPath = getStashPath()): string { + return join(stashPath, id); } -export async function ensureStashPath(): Promise { - const stashPath = getStashPath(); +export function getStashRecordPath(id: string, stashPath = getStashPath()): string { + return join(getStashDirPath(id, stashPath), "stash.json"); +} + +export async function ensureStashPath(stashPath = getStashPath()): Promise { await mkdir(stashPath, { recursive: true, mode: 0o700 }); return stashPath; } -export async function ensureStashDirPath(id: string): Promise { - const stashDir = getStashDirPath(id); - await ensureStashPath(); +export async function ensureStashDirPath(id: string, stashPath = getStashPath()): Promise { + const stashDir = getStashDirPath(id, stashPath); + await ensureStashPath(stashPath); await mkdir(stashDir, { recursive: true, mode: 0o700 }); return stashDir; } diff --git a/src/index.ts b/src/index.ts index 4976d1c..8e9d021 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,10 @@ export { Stash } from "./stash.ts"; export type { StashEvents } from "./stash.ts"; export { GitHubProvider } from "./providers/github-provider.ts"; +export { parseUri } from "./uri.ts"; export { GitRepoError, + InvalidUriError, MigrationError, MultipleConnectionsError, PushConflictError, @@ -12,14 +14,13 @@ export type { Disposable } from "./emitter.ts"; export { getGlobalConfigPath, readGlobalConfig, writeGlobalConfig } from "./global-config.ts"; export type { ChangeSet, + ConfigItem, ConnectionConfig, - Field, FileMutation, FileState, GlobalConfig, Provider, ProviderClass, - ProviderSpec, PushPayload, SnapshotEntry, StatusResult, diff --git a/src/providers/github-provider.ts b/src/providers/github-provider.ts index e47e62f..02ad140 100644 --- a/src/providers/github-provider.ts +++ b/src/providers/github-provider.ts @@ -4,7 +4,6 @@ import type { ChangeSet, FileState, Provider, - ProviderSpec, PushPayload, SnapshotEntry, } from "../types.ts"; @@ -35,16 +34,17 @@ async function streamToBuffer(stream: Readable): Promise { export interface GitHubConfig { token: string; - repo: string; + id?: string; + repo?: string; } type BlobResult = { isBinary: boolean; text: string | null } | null; export class GitHubProvider implements Provider { - static spec: ProviderSpec = { - setup: [{ name: "token", label: "Personal access token", secret: true }], - connect: [{ name: "repo", label: "Repository (user/repo)" }], - }; + static configSchema = [ + { name: "token", label: "Personal access token", secret: true, global: true }, + { name: "id", label: "Identifier (owner/repo)" }, + ]; private readonly token: string; private readonly owner: string; @@ -58,9 +58,10 @@ export class GitHubProvider implements Provider { if (!config.token) { throw new Error("Missing GitHub token"); } - const repoParts = config.repo.split("/"); + const id = config.id ?? config.repo ?? ""; + const repoParts = id.split("/"); if ( - !config.repo || + !id || repoParts.length !== 2 || repoParts[0].length === 0 || repoParts[1].length === 0 diff --git a/src/registry-lock.ts b/src/registry-lock.ts index d4ffa26..59bca46 100644 --- a/src/registry-lock.ts +++ b/src/registry-lock.ts @@ -12,6 +12,7 @@ type RegistryLockPayload = { type RegistryLockOptions = { path?: string; + stashPath?: string; retryDelayMs?: number; staleMs?: number; }; @@ -20,8 +21,8 @@ const DEFAULT_RETRY_DELAY_MS = 10; const DEFAULT_STALE_MS = 60_000; const SIGNALS: NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP"]; -export function getRegistryLockPath(): string { - return `${getStashPath()}/.lock`; +export function getRegistryLockPath(stashPath = getStashPath()): string { + return `${stashPath}/.lock`; } function isProcessAlive(pid: number): boolean { @@ -44,10 +45,10 @@ async function readLockPayload(lockPath: string): Promise { - let ageMs = 0; + let lockStat: Awaited>; try { - ageMs = Date.now() - (await stat(lockPath)).mtimeMs; + lockStat = await stat(lockPath); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return false; @@ -59,6 +60,7 @@ async function isStaleLock(lockPath: string, staleMs: number): Promise if (typeof payload?.pid === "number" && !isProcessAlive(payload.pid)) { return true; } + const ageMs = Date.now() - lockStat.mtimeMs; return ageMs > staleMs; } @@ -109,12 +111,13 @@ export async function withRegistryLock( fn: () => Promise, options: RegistryLockOptions = {}, ): Promise { - const lockPath = options.path ?? getRegistryLockPath(); + const stashPath = options.stashPath ?? getStashPath(); + const lockPath = options.path ?? getRegistryLockPath(stashPath); const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS; const staleMs = options.staleMs ?? DEFAULT_STALE_MS; const cleanupListeners = installCleanup(lockPath); - await mkdir(getStashPath(), { recursive: true, mode: 0o700 }); + await mkdir(stashPath, { recursive: true, mode: 0o700 }); const payload = JSON.stringify( { @@ -126,10 +129,16 @@ export async function withRegistryLock( 2, ); + let result: T | undefined; + let operationError: unknown; + let cleanupError: unknown; + let acquired = false; + try { for (;;) { try { await writeFile(lockPath, payload, { encoding: "utf8", flag: "wx", mode: 0o600 }); + acquired = true; break; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") { @@ -145,9 +154,14 @@ export async function withRegistryLock( } } - return await fn(); + result = await fn(); + } catch (error) { + operationError = error; } finally { cleanupListeners(); + } + + if (acquired) { try { const currentPayload = readFileSync(lockPath, "utf8"); if (currentPayload === payload) { @@ -155,8 +169,16 @@ export async function withRegistryLock( } } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; + cleanupError = error; } } } + + if (operationError) { + throw operationError; + } + if (cleanupError) { + throw cleanupError; + } + return result as T; } diff --git a/src/stash.ts b/src/stash.ts index 0707ab1..fefaf5a 100644 --- a/src/stash.ts +++ b/src/stash.ts @@ -7,9 +7,11 @@ import { mkdirSync, readdirSync, readFileSync, + realpathSync, renameSync, rmSync, statSync, + symlinkSync, unlinkSync, writeFileSync, } from "node:fs"; @@ -19,13 +21,18 @@ import { dirname, join, relative } from "node:path"; import { pipeline } from "node:stream/promises"; import { Emitter } from "./emitter.ts"; import { - GitRepoError, MultipleConnectionsError, PushConflictError, SyncLockError, } from "./errors.ts"; -import { normalizeGlobalConfig } from "./global-config.ts"; -import { getLocalConfigPath, readLocalConfig, writeLocalConfig } from "./local-config.ts"; +import { + ensureStashDirPath, + getStashDirPath, + getStashRecordPath, + getStashPath, + normalizeGlobalConfig, +} from "./global-config.ts"; +import { readLocalConfig, writeLocalConfig } from "./local-config.ts"; import { ensureMigration } from "./migrations.ts"; import { providers as defaultProviders } from "./providers/index.ts"; import type { @@ -38,11 +45,14 @@ import type { ProviderClass, PushPayload, SnapshotEntry, + StashRecord, StatusResult, } from "./types.ts"; import { hashBuffer, hashText } from "./utils/hash.ts"; +import { writeJsonFile } from "./utils/fs.ts"; import { isTrackedPath } from "./utils/is-tracked-path.ts"; import { isValidText } from "./utils/text.ts"; +import { ulid } from "./utils/ulid.ts"; export type StashEvents = { mutation: FileMutation; @@ -50,6 +60,8 @@ export type StashEvents = { type StashOptions = { providers?: Record; + stashPath?: string; + metadataDir?: string; }; function cloneSnapshot(snapshot: Record): Record { @@ -60,65 +72,141 @@ function sortPaths(paths: Iterable): string[] { return [...paths].sort((a, b) => a.localeCompare(b)); } +function stashConnectionsFromLocalConfig( + connections: Record, +): StashRecord["connections"] { + return Object.entries(connections).map(([name, connection]) => { + const { provider, ...fields } = connection; + return { + provider, + config: { + name, + ...fields, + }, + }; + }); +} + const STALE_SYNC_LOCK_MS = 10 * 60 * 1000; const SYNC_RETRY_LIMIT = 5; const NON_FILE_HASH = "__non-file__"; export class Stash extends Emitter { + readonly id: string; + readonly record: StashRecord; private readonly dir: string; + private readonly metadataDir: string; + private readonly stashPath: string; private readonly globalConfig: GlobalConfig; private readonly providerRegistry: Record; private _connections: Record; private syncInFlight = false; private constructor( - dir: string, + id: string, + record: StashRecord, globalConfig: GlobalConfig, - connections: Record, options?: StashOptions, ) { super(); - this.dir = dir; + this.id = id; + this.record = { + path: record.path, + connections: record.connections.map((connection) => ({ + provider: connection.provider, + config: { ...connection.config }, + })), + }; + this.dir = record.path; + this.stashPath = options?.stashPath ?? getStashPath(); + this.metadataDir = options?.metadataDir ?? getStashDirPath(id, this.stashPath); this.globalConfig = globalConfig; - this._connections = connections; + this._connections = this.connectionsFromRecord(this.record); this.providerRegistry = options?.providers ?? defaultProviders; } static async load( - dir: string, + idOrDir: string, globalConfig: GlobalConfig, options?: StashOptions, ): Promise { - const stashDir = join(dir, ".stash"); - if (!existsSync(stashDir)) { + const stashPath = options?.stashPath ?? getStashPath(); + const directRecordPath = getStashRecordPath(idOrDir, stashPath); + if (existsSync(directRecordPath)) { + const record = JSON.parse(readFileSync(directRecordPath, "utf8")) as StashRecord; + return new Stash(idOrDir, record, normalizeGlobalConfig(globalConfig), options); + } + + const localStashDir = join(idOrDir, ".stash"); + if (!existsSync(localStashDir)) { throw new Error( "This directory is not a stash. Run `stash connect ` first.", ); } - await ensureMigration(dir); - const localConfig = await readLocalConfig(dir); + await ensureMigration(idOrDir); + const localConfig = await readLocalConfig(idOrDir); + + const idPath = join(localStashDir, ".stash-id"); + const id = existsSync(idPath) ? readFileSync(idPath, "utf8").trim() : null; + const resolvedMetadataDir = realpathSync(localStashDir); + const resolvedId = id ?? resolvedMetadataDir.split("/").at(-1) ?? null; + + if (resolvedId) { + const recordPath = getStashRecordPath(resolvedId, stashPath); + if (existsSync(recordPath)) { + const record = JSON.parse(readFileSync(recordPath, "utf8")) as StashRecord; + const recordWithCompatConnections = + Object.keys(localConfig.connections).length > 0 + ? { + ...record, + connections: stashConnectionsFromLocalConfig(localConfig.connections), + } + : record; + return new Stash( + resolvedId, + recordWithCompatConnections, + normalizeGlobalConfig(globalConfig), + options, + ); + } + } + return new Stash( - dir, + resolvedId ?? `legacy:${idOrDir}`, + { + path: idOrDir, + connections: stashConnectionsFromLocalConfig(localConfig.connections), + }, normalizeGlobalConfig(globalConfig), - localConfig.connections ?? {}, - options, + { + ...options, + metadataDir: localStashDir, + }, ); } - static async init( - dir: string, + static async create( + record: StashRecord, globalConfig: GlobalConfig, options?: StashOptions, ): Promise { - const stashDir = join(dir, ".stash"); - mkdirSync(stashDir, { recursive: true }); - await ensureMigration(dir); - mkdirSync(join(stashDir, "snapshot"), { recursive: true }); - if (!existsSync(getLocalConfigPath(dir))) { - await writeLocalConfig(dir, { connections: {} }); - } - return Stash.load(dir, globalConfig, options); + const id = ulid(); + const stashPath = options?.stashPath ?? getStashPath(); + const metadataDir = getStashDirPath(id, stashPath); + await ensureStashDirPath(id, stashPath); + await writeJsonFile( + getStashRecordPath(id, stashPath), + { + path: record.path, + connections: record.connections.map((connection) => ({ + provider: connection.provider, + config: { ...connection.config }, + })), + } satisfies StashRecord, + ); + mkdirSync(join(metadataDir, "snapshot"), { recursive: true }); + return new Stash(id, record, normalizeGlobalConfig(globalConfig), options); } get connections(): Record { @@ -136,6 +224,40 @@ export class Stash extends Emitter { return out; } + private connectionsFromRecord(record: StashRecord): Record { + const connections: Record = {}; + for (const connection of record.connections) { + const name = connection.config.name ?? connection.provider; + const { name: _ignored, ...fields } = connection.config; + connections[name] = { + provider: connection.provider, + ...fields, + }; + } + return connections; + } + + private updateRecordConnections(): void { + this.record.connections = Object.entries(this._connections).map(([name, connection]) => { + const { provider, ...fields } = connection; + return { + provider, + config: { + name, + ...fields, + }, + }; + }); + } + + private writeLegacyLocalLink(): void { + const localStashDir = join(this.dir, ".stash"); + if (!existsSync(localStashDir)) { + symlinkSync(this.metadataDir, localStashDir, "dir"); + } + writeFileSync(join(this.metadataDir, ".stash-id"), `${this.id}\n`, "utf8"); + } + async connect({ name, ...configFields }: { name: string } & ConnectionConfig): Promise { const { provider, ...fields } = configFields; const existing = Object.keys(this._connections); @@ -159,11 +281,6 @@ export class Stash extends Emitter { this.syncInFlight = true; try { - const localConfig = await readLocalConfig(this.dir); - if (this.hasGitRepository() && localConfig["allow-git"] !== true) { - throw new GitRepoError(); - } - const connectionNames = Object.keys(this._connections); if (connectionNames.length === 0) { return; @@ -794,6 +911,11 @@ export class Stash extends Emitter { } private async persistLocalConfig(): Promise { + this.updateRecordConnections(); + if (this.metadataDir === getStashDirPath(this.id, this.stashPath)) { + await writeJsonFile(getStashRecordPath(this.id, this.stashPath), this.record); + } + this.writeLegacyLocalLink(); const currentConfig = await readLocalConfig(this.dir); await writeLocalConfig(this.dir, { ...currentConfig, @@ -930,18 +1052,13 @@ export class Stash extends Emitter { } private snapshotPath(): string { - return join(this.dir, ".stash", "snapshot.json"); + return join(this.metadataDir, "snapshot.json"); } private snapshotLocalDir(): string { - return join(this.dir, ".stash", "snapshot"); - } - - private hasGitRepository(): boolean { - return existsSync(join(this.dir, ".git")); + return join(this.metadataDir, "snapshot"); } - private syncLockPath(): string { - return join(this.dir, ".stash", "sync.lock"); + return join(this.metadataDir, "sync.lock"); } } diff --git a/src/types.ts b/src/types.ts index 024d8b8..658759a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,18 +1,24 @@ import type { Readable } from "node:stream"; -export interface ProviderSpec { - setup: Field[]; - connect: Field[]; -} - -export interface Field { +export interface ConfigItem { name: string; label: string; secret?: boolean; + global?: boolean; } export type SnapshotEntry = { hash: string } | { hash: string; modified: number }; +export interface StashConnectionRecord { + provider: string; + config: Record; +} + +export interface StashRecord { + path: string; + connections: StashConnectionRecord[]; +} + export type FileState = | { type: "text"; content: string } | { type: "binary"; hash: string; modified: number }; @@ -65,6 +71,6 @@ export interface GlobalConfig { } export type ProviderClass = { - spec: ProviderSpec; + configSchema: ConfigItem[]; new (config: Record): Provider; }; diff --git a/src/uri.ts b/src/uri.ts new file mode 100644 index 0000000..07deb8f --- /dev/null +++ b/src/uri.ts @@ -0,0 +1,14 @@ +import { InvalidUriError } from "./errors.ts"; + +export function parseUri(uri: string): { provider: string; config: { id: string } } { + const separator = uri.indexOf(":"); + if (separator < 0) { + throw new InvalidUriError(uri); + } + return { + provider: uri.slice(0, separator), + config: { + id: uri.slice(separator + 1), + }, + }; +} diff --git a/src/utils/ulid.ts b/src/utils/ulid.ts new file mode 100644 index 0000000..febf0e2 --- /dev/null +++ b/src/utils/ulid.ts @@ -0,0 +1,39 @@ +import { randomBytes } from "node:crypto"; + +const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +function encodeBase32(value: number, length: number): string { + let out = ""; + for (let i = 0; i < length; i += 1) { + out = ENCODING[value % 32] + out; + value = Math.floor(value / 32); + } + return out; +} + +function encodeBytes(bytes: Uint8Array): string { + let bits = 0; + let value = 0; + let out = ""; + + for (const byte of bytes) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + out += ENCODING[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + + if (bits > 0) { + out += ENCODING[(value << (5 - bits)) & 31]; + } + + return out; +} + +export function ulid(): string { + const time = encodeBase32(Date.now(), 10); + const randomness = encodeBytes(randomBytes(10)).slice(0, 16); + return `${time}${randomness}`; +} diff --git a/tests/helpers/make-stash.ts b/tests/helpers/make-stash.ts index 3825bfe..dd91833 100644 --- a/tests/helpers/make-stash.ts +++ b/tests/helpers/make-stash.ts @@ -1,8 +1,8 @@ import { randomUUID } from "node:crypto"; -import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { normalizeGlobalConfig } from "../../src/global-config.ts"; +import { getStashDirPath, normalizeGlobalConfig } from "../../src/global-config.ts"; import { Stash } from "../../src/stash.ts"; import type { GlobalConfig, ProviderClass, SnapshotEntry } from "../../src/types.ts"; @@ -31,14 +31,24 @@ export async function makeStash( }, ): Promise<{ stash: Stash; dir: string }> { const dir = await makeTempDir(); + const stashPath = join(dir, ".xdg", "stash"); const globalConfig = normalizeGlobalConfig(opts?.globalConfig ?? {}); if (files) { await writeFiles(dir, files); } - await Stash.init(dir, globalConfig, { - providers: opts?.providers, - }); + const stash = await Stash.create( + { + path: dir, + connections: [], + }, + globalConfig, + { + providers: opts?.providers, + stashPath, + }, + ); + await symlink(getStashDirPath(stash.id, stashPath), join(dir, ".stash"), "dir"); if (opts?.snapshot) { await writeFile( @@ -56,8 +66,9 @@ export async function makeStash( } } - const loaded = await Stash.load(dir, globalConfig, { + const loaded = await Stash.load(stash.id, globalConfig, { providers: opts?.providers, + stashPath, }); return { stash: loaded, dir }; } diff --git a/tests/integration/background-cli.integration.test.ts b/tests/integration/background-cli.integration.test.ts index 1b056d6..4898733 100644 --- a/tests/integration/background-cli.integration.test.ts +++ b/tests/integration/background-cli.integration.test.ts @@ -1,14 +1,11 @@ import test from "node:test"; import assert from "node:assert/strict"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { basename, join, resolve } from "node:path"; -import { UnsupportedPlatformError } from "@rupertsworld/daemon"; +import { join, resolve } from "node:path"; import { main } from "../../src/cli-main.ts"; -import { Stash } from "../../src/stash.ts"; -import type { GlobalConfig } from "../../src/types.ts"; -import { assertMigration, writeLegacyLayout } from "../helpers/assert-migration.ts"; +import type { GlobalConfig, StashRecord } from "../../src/types.ts"; type ServiceCalls = { install: number; @@ -27,13 +24,37 @@ async function makeTempDir(prefix: string): Promise { return mkdtemp(join(tmpdir(), `${prefix}-`)); } +function getTestStashPath(dir: string): string { + return join(dir, ".xdg", "stash"); +} + +async function listStashIds(stashPath: string): Promise { + if (!existsSync(stashPath)) { + return []; + } + return (await readdir(stashPath)).filter((entry) => !entry.startsWith(".") && entry !== "config.json"); +} + +async function readOnlyStashRecord(stashPath: string): Promise<{ id: string; record: StashRecord }> { + const ids = await listStashIds(stashPath); + assert.equal(ids.length, 1); + return { + id: ids[0], + record: JSON.parse(await readFile(join(stashPath, ids[0], "stash.json"), "utf8")) as StashRecord, + }; +} + +async function writeStashRecord(stashPath: string, id: string, record: StashRecord): Promise { + await mkdir(join(stashPath, id), { recursive: true }); + await writeFile(join(stashPath, id, "stash.json"), JSON.stringify(record, null, 2), "utf8"); +} + async function runMain( cwd: string, args: string[], options?: { config?: GlobalConfig; serviceStatus?: { installed: boolean; running: boolean } | Error; - tty?: boolean; }, ): Promise { let config: GlobalConfig = structuredClone( @@ -54,6 +75,7 @@ async function runMain( await main(["node", "stash", ...args], { cwd: () => cwd, + stashPath: getTestStashPath(cwd), readGlobalConfig: async () => structuredClone(config), writeGlobalConfig: async (nextConfig) => { config = structuredClone(nextConfig); @@ -74,14 +96,14 @@ async function runMain( }, }, stdout: { - isTTY: options?.tty === true, + isTTY: false, write(chunk: string) { stdout += chunk; return true; }, } as NodeJS.WriteStream, stderr: { - isTTY: options?.tty === true, + isTTY: false, write(chunk: string) { stderr += chunk; return true; @@ -92,445 +114,150 @@ async function runMain( return { stdout, stderr, config, calls }; } -test("connect registers the current stash path in the background registry", async () => { - const dir = await makeTempDir("stash-cli-connect-register"); - - try { - const result = await runMain(dir, [ - "connect", - "github", - "origin", - "--token", - "test-token", - "--repo", - "user/repo", - ]); - assert.deepEqual(result.config.background?.stashes, [resolve(dir)]); - assert.equal(result.stdout.includes("Connected origin."), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("connect defaults the connection name to the provider when omitted", async () => { - const dir = await makeTempDir("stash-cli-connect-default-name"); - - try { - const result = await runMain(dir, [ - "connect", - "github", - "--token", - "test-token", - "--repo", - "user/repo", - ]); - - assert.deepEqual(result.config.background?.stashes, [resolve(dir)]); - assert.equal(result.stdout.includes("Connected github."), true); - const localConfig = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); - assert.deepEqual(localConfig, { - connections: { - github: { provider: "github", repo: "user/repo" }, - }, - }); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("connect reports that the stash is now syncing when background sync is already running", async () => { - const dir = await makeTempDir("stash-cli-connect-running"); +test("connect creates a new stash record and stores provider globals separately", async () => { + const dir = await makeTempDir("stash-cli-connect"); try { const result = await runMain( dir, - ["connect", "github", "origin", "--token", "test-token", "--repo", "user/repo"], + ["connect", "github:user/repo", "--token", "test-token"], { serviceStatus: { installed: true, running: true }, }, ); - assert.equal(result.stdout.includes("Connected origin."), true); + assert.equal(result.stdout.includes(`Connected github:user/repo to ${await realpath(dir)}.`), true); assert.equal(result.stdout.includes("Background sync is on"), true); - assert.equal(result.stdout.includes("This stash is now syncing automatically"), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("connect warns when git repository syncing is still blocked", async () => { - const dir = await makeTempDir("stash-cli-connect-git-warning"); - - try { - await mkdir(join(dir, ".git"), { recursive: true }); - - const result = await runMain(dir, [ - "connect", - "github", - "origin", - "--token", - "test-token", - "--repo", - "user/repo", - ]); - - assert.equal(result.stdout.includes("Connected origin."), true); - assert.equal(result.stdout.includes("Warning:"), true); - assert.equal(result.stdout.includes("remove .git"), true); - assert.equal(result.stdout.includes("stash config set allow-git true"), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("connecting a second named connection is rejected", async () => { - const dir = await makeTempDir("stash-cli-connect-multi"); - - try { - const first = await runMain(dir, [ - "connect", - "github", - "origin", - "--token", - "test-token", - "--repo", - "user/repo", - ]); - const error = await runMain( - dir, - ["connect", "github", "backup", "--token", "test-token", "--repo", "user/other"], - { - config: first.config, - }, - ).catch((e: Error) => e); - assert.ok(error instanceof Error); - assert.equal(error.message.includes('already has connection "origin"'), true); - assert.equal(error.message.includes("stash disconnect origin"), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("disconnect keeps the stash registered while other providers remain", async () => { - const dir = await makeTempDir("stash-cli-disconnect-keep"); - - try { - await Stash.init(dir, { - providers: {}, - background: { stashes: [resolve(dir)] }, - }); - await writeFile( - join(dir, ".stash", "config.json"), - JSON.stringify( - { - connections: { - origin: { provider: "github", repo: "user/repo" }, - backup: { provider: "fake", repo: "other/repo" }, - }, - }, - null, - 2, - ), - "utf8", - ); - - const result = await runMain(dir, ["disconnect", "origin"], { - config: { - providers: {}, - background: { stashes: [resolve(dir)] }, - }, - }); + assert.equal(result.config.providers.github?.token, "test-token"); + assert.equal(existsSync(join(dir, ".stash")), false); - assert.deepEqual(result.config.background?.stashes, [resolve(dir)]); - const localConfig = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); - assert.deepEqual(localConfig, { - connections: { - backup: { provider: "fake", repo: "other/repo" }, - }, + const { record } = await readOnlyStashRecord(getTestStashPath(dir)); + assert.deepEqual(record, { + path: await realpath(dir), + connections: [{ provider: "github", config: { id: "user/repo" } }], }); } finally { await rm(dir, { recursive: true, force: true }); } }); -test("disconnect --all disconnects the stash completely", async () => { - const dir = await makeTempDir("stash-cli-disconnect-all"); +test("connect is idempotent for the same uri", async () => { + const dir = await makeTempDir("stash-cli-connect-idempotent"); try { - const connected = await runMain(dir, [ - "connect", - "github", - "origin", - "--token", - "test-token", - "--repo", - "user/repo", - ]); - const disconnected = await runMain(dir, ["disconnect", "--all"], { - config: connected.config, + const first = await runMain(dir, ["connect", "github:user/repo", "--token", "test-token"]); + const second = await runMain(dir, ["connect", "github:user/repo"], { + config: first.config, }); - assert.deepEqual(disconnected.config.background?.stashes, []); - assert.equal(existsSync(join(dir, ".stash")), false); - assert.equal(disconnected.stdout.includes("Disconnected stash."), true); + assert.equal(second.stdout.includes("Already connected."), true); + assert.deepEqual(await listStashIds(getTestStashPath(dir)), [( + await readOnlyStashRecord(getTestStashPath(dir)) + ).id]); } finally { await rm(dir, { recursive: true, force: true }); } }); -test("disconnect with no arguments errors with actionable guidance", async () => { - const dir = await makeTempDir("stash-cli-disconnect-missing"); +test("connect blocks git repositories unless --dangerously-allow-git is passed", async () => { + const dir = await makeTempDir("stash-cli-connect-git"); try { - const result = await main(["node", "stash", "disconnect"], { - cwd: () => dir, - readGlobalConfig: async () => ({ providers: {}, background: { stashes: [] } }), - writeGlobalConfig: async () => {}, - service: { - install: async () => {}, - uninstall: async () => {}, - status: async () => ({ installed: false, running: false }), - }, - stdout: { - isTTY: false, - write() { - return true; - }, - } as NodeJS.WriteStream, - stderr: { - isTTY: false, - write() { - return true; - }, - } as NodeJS.WriteStream, - }).catch((error) => error as Error); + await mkdir(join(dir, ".git"), { recursive: true }); - assert.equal(result instanceof Error, true); - assert.equal( - (result as Error).message, - "argument required — run `stash disconnect `, `stash disconnect --all`, or `stash disconnect --path `", + const error = await runMain(dir, ["connect", "github:user/repo", "--token", "test-token"]).catch( + (value: Error) => value, ); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("disconnect removes .stash and unregisters when it was the last connection", async () => { - const dir = await makeTempDir("stash-cli-disconnect-remove"); - - try { - const connected = await runMain(dir, [ - "connect", - "github", - "origin", - "--token", - "test-token", - "--repo", - "user/repo", - ]); - const disconnected = await runMain(dir, ["disconnect", "origin"], { - config: connected.config, - }); - assert.deepEqual(disconnected.config.background?.stashes, []); - assert.equal(existsSync(join(dir, ".stash")), false); - assert.equal(disconnected.stdout.includes("Disconnected origin."), true); + assert.ok(error instanceof Error); + assert.equal(error.message.includes("--dangerously-allow-git"), true); + assert.deepEqual(await listStashIds(getTestStashPath(dir)), []); } finally { await rm(dir, { recursive: true, force: true }); } }); -test("disconnect --path deregisters the path and removes .stash when the directory exists", async () => { - const dir = await makeTempDir("stash-cli-disconnect-path"); +test("disconnect tombstones the last connection without deleting the stash dir", async () => { + const dir = await makeTempDir("stash-cli-disconnect"); try { - await Stash.init(dir, { providers: {}, background: { stashes: [resolve(dir)] } }); + const connected = await runMain(dir, ["connect", "github:user/repo", "--token", "test-token"]); + const { id } = await readOnlyStashRecord(getTestStashPath(dir)); - const result = await runMain(dir, ["disconnect", "--path", dir], { - config: { - providers: {}, - background: { stashes: [resolve(dir)] }, - }, + const result = await runMain(dir, ["disconnect"], { + config: connected.config, }); - assert.deepEqual(result.config.background?.stashes, []); - assert.equal(existsSync(join(dir, ".stash")), false); - assert.equal(result.stdout.includes("Disconnected stash."), true); + assert.equal( + result.stdout.includes(`Disconnected github:user/repo from ${await realpath(dir)}.`), + true, + ); + assert.equal(existsSync(join(getTestStashPath(dir), id)), true); + const record = JSON.parse( + await readFile(join(getTestStashPath(dir), id, "stash.json"), "utf8"), + ) as StashRecord; + assert.deepEqual(record.connections, []); } finally { await rm(dir, { recursive: true, force: true }); } }); -test("disconnect --path no-ops cleanly when the path is not registered", async () => { - const dir = await makeTempDir("stash-cli-disconnect-path-missing"); - const missing = join(dir, "missing-stash"); +test("relink rewrites stash.json.path and enforces the git gate on the new path", async () => { + const dir = await makeTempDir("stash-cli-relink-old"); + const moved = await makeTempDir("stash-cli-relink-new"); try { - const result = await runMain(dir, ["disconnect", "--path", missing], { - config: { - providers: {}, - background: { stashes: [resolve(dir)] }, - }, - }); + const connected = await runMain(dir, ["connect", "github:user/repo", "--token", "test-token"]); + const { id } = await readOnlyStashRecord(getTestStashPath(dir)); - assert.deepEqual(result.config.background?.stashes, [resolve(dir)]); - assert.equal(result.stdout.includes("No stash registered at that path."), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("disconnect errors when the named connection does not exist", async () => { - const dir = await makeTempDir("stash-cli-disconnect-missing-name"); - - try { - await Stash.init(dir, { providers: {}, background: { stashes: [resolve(dir)] } }); - await writeFile( - join(dir, ".stash", "config.json"), - JSON.stringify( - { - connections: { - origin: { provider: "github", repo: "user/repo" }, - }, - }, - null, - 2, - ), - "utf8", + await mkdir(join(moved, ".git"), { recursive: true }); + const error = await runMain(dir, ["relink", dir, moved], { config: connected.config }).catch( + (value: Error) => value, ); + assert.ok(error instanceof Error); + assert.equal(error.message.includes("--dangerously-allow-git"), true); - const result = await main(["node", "stash", "disconnect", "backup"], { - cwd: () => dir, - readGlobalConfig: async () => ({ providers: {}, background: { stashes: [resolve(dir)] } }), - writeGlobalConfig: async () => {}, - service: { - install: async () => {}, - uninstall: async () => {}, - status: async () => ({ installed: false, running: false }), - }, - stdout: { - isTTY: false, - write() { - return true; - }, - } as NodeJS.WriteStream, - stderr: { - isTTY: false, - write() { - return true; - }, - } as NodeJS.WriteStream, - }).catch((error) => error as Error); - - assert.equal(result instanceof Error, true); - assert.equal((result as Error).message, "Connection not found: backup"); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("disconnect flags are mutually exclusive", async () => { - const dir = await makeTempDir("stash-cli-disconnect-exclusive"); - - try { - const result = await main(["node", "stash", "disconnect", "origin", "--all"], { - cwd: () => dir, - readGlobalConfig: async () => ({ providers: {}, background: { stashes: [] } }), - writeGlobalConfig: async () => {}, - service: { - install: async () => {}, - uninstall: async () => {}, - status: async () => ({ installed: false, running: false }), - }, - stdout: { - isTTY: false, - write() { - return true; - }, - } as NodeJS.WriteStream, - stderr: { - isTTY: false, - write() { - return true; - }, - } as NodeJS.WriteStream, - }).catch((error) => error as Error); + const result = await runMain( + dir, + ["relink", dir, moved, "--dangerously-allow-git"], + { config: connected.config }, + ); - assert.equal(result instanceof Error, true); assert.equal( - (result as Error).message, - "disconnect modes are mutually exclusive — use only one of ``, `--all`, or `--path `", + result.stdout.includes(`Relinked ${await realpath(dir)} -> ${await realpath(moved)}.`), + true, ); + const record = JSON.parse( + await readFile(join(getTestStashPath(dir), id, "stash.json"), "utf8"), + ) as StashRecord; + assert.equal(record.path, await realpath(moved)); } finally { await rm(dir, { recursive: true, force: true }); + await rm(moved, { recursive: true, force: true }); } }); -test("start delegates to install and reports the service is on", async () => { - const dir = await makeTempDir("stash-cli-start"); +test("status prints daemon state, connection URIs, and missing-path guidance", async () => { + const dir = await makeTempDir("stash-cli-status"); + const missing = join(dir, "missing-folder"); + const stashPath = getTestStashPath(dir); try { - const result = await runMain(dir, ["start"], { - config: { - providers: {}, - background: { stashes: ["/a", "/b", "/c"] }, - }, + await writeStashRecord(stashPath, "live-stash", { + path: resolve(dir), + connections: [{ provider: "github", config: { id: "user/live" } }], }); - - assert.equal(result.calls.install, 1); - assert.equal(result.stdout.includes("Background sync is on"), true); - assert.equal(result.stdout.includes("Watching 3 stashes"), true); - assert.equal(result.stdout.includes("starts on startup"), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("stop delegates to uninstall and reports how to resume syncing", async () => { - const dir = await makeTempDir("stash-cli-stop"); - - try { - const result = await runMain(dir, ["stop"], { - config: { - providers: {}, - background: { stashes: ["/a", "/b", "/c"] }, - }, - serviceStatus: { installed: true, running: true }, + await writeStashRecord(stashPath, "missing-stash", { + path: resolve(missing), + connections: [{ provider: "github", config: { id: "user/missing" } }], }); - - assert.equal(result.calls.uninstall, 1); - assert.equal(result.stdout.includes("Background sync is off"), true); - assert.equal(result.stdout.includes("Run `stash start` to resume syncing 3 stashes"), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("status prints unsupported-platform state and per-stash summaries", async () => { - const dir = await makeTempDir("stash-cli-status-all"); - - try { - await Stash.init(dir, { providers: {}, background: { stashes: [] } }); - await writeFile( - join(dir, ".stash", "config.json"), - JSON.stringify( - { - connections: { - origin: { provider: "github", repo: "user/repo" }, - }, - }, - null, - 2, - ), - "utf8", - ); await writeFile( - join(dir, ".stash", "status.json"), + join(stashPath, "live-stash", "status.json"), JSON.stringify( { - kind: "synced", + kind: "idle", lastSync: "2026-03-11T14:30:00.000Z", - summary: "1↑ 2↓", + summary: null, error: null, }, null, @@ -538,313 +265,50 @@ test("status prints unsupported-platform state and per-stash summaries", async ( ), "utf8", ); - await writeFile(join(dir, ".stash", "snapshot.json"), JSON.stringify({}, null, 2), "utf8"); - - const result = await runMain(dir, ["status"], { - config: { - providers: {}, - background: { - stashes: [dir], - }, - }, - serviceStatus: new UnsupportedPlatformError(), - }); - - assert.equal(result.stdout.includes("not supported on this platform"), true); - assert.equal(result.stdout.includes(basename(dir)), true); - assert.equal(result.stdout.includes(dir), true); - assert.equal(result.stdout.includes("origin (github)"), true); - assert.equal(result.stdout.includes("user/repo"), false); - assert.equal(result.stdout.includes("Up to date"), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("status shows the git safety error message", async () => { - const dir = await makeTempDir("stash-cli-status-all-git-error"); - - try { - await Stash.init(dir, { providers: {}, background: { stashes: [] } }); await writeFile( - join(dir, ".stash", "config.json"), + join(stashPath, "missing-stash", "status.json"), JSON.stringify( { - connections: { - origin: { provider: "github", repo: "user/repo" }, - }, - }, - null, - 2, - ), - "utf8", - ); - await writeFile( - join(dir, ".stash", "status.json"), - JSON.stringify( - { - kind: "error", + kind: "missing", lastSync: null, summary: null, - error: "git repository detected — run `stash config set allow-git true` to allow syncing", - }, - null, - 2, - ), - "utf8", - ); - - const result = await runMain(dir, ["status"], { - config: { - providers: {}, - background: { - stashes: [dir], - }, - }, - serviceStatus: new UnsupportedPlatformError(), - }); - - assert.equal(result.stdout.includes("git repository detected"), true); - assert.equal(result.stdout.includes("stash config set allow-git true"), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("status shows the current stash when background sync is running", async () => { - const dir = await makeTempDir("stash-cli-status-local"); - - try { - await Stash.init(dir, { providers: {}, background: { stashes: [resolve(dir)] } }); - await writeFile( - join(dir, ".stash", "config.json"), - JSON.stringify( - { - connections: { - origin: { provider: "github", repo: "user/repo" }, - }, + error: null, }, null, 2, ), "utf8", ); - await writeFile(join(dir, ".stash", "snapshot.json"), JSON.stringify({}, null, 2), "utf8"); const result = await runMain(dir, ["status"], { - config: { - providers: {}, - background: { stashes: [resolve(dir)] }, - }, serviceStatus: { installed: true, running: true }, }); - assert.equal(result.stdout.includes("origin (github)"), true); - assert.equal(result.stdout.includes("user/repo"), false); - assert.equal(result.stdout.includes("Background sync is on"), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("status shows waiting for first sync when no snapshot exists", async () => { - const dir = await makeTempDir("stash-cli-status-waiting"); - - try { - await Stash.init(dir, { providers: {}, background: { stashes: [] } }); - await writeFile( - join(dir, ".stash", "config.json"), - JSON.stringify( - { - connections: { - origin: { provider: "github", repo: "user/repo" }, - }, - }, - null, - 2, - ), - "utf8", + assert.equal(result.stdout.includes("Daemon: running"), true); + assert.equal(result.stdout.includes(resolve(dir)), true); + assert.equal(result.stdout.includes("github:user/live"), true); + assert.equal( + result.stdout.includes(`path missing — run \`stash relink ${resolve(missing)} \``), + true, ); - - const result = await runMain(dir, ["status"], { - config: { - providers: {}, - background: { stashes: [resolve(dir)] }, - }, - tty: true, - }); - - assert.equal(result.stdout.includes("origin (github)"), true); - assert.equal(result.stdout.includes("Waiting for first sync"), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("status outside a stash shows no stashes connected", async () => { - const dir = await makeTempDir("stash-cli-status-outside"); - - try { - const result = await runMain(dir, ["status"], { - config: { providers: {}, background: { stashes: [] } }, - serviceStatus: { installed: false, running: false }, - }); - - assert.equal(result.stdout.includes("No stashes connected yet"), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("migration stops the running daemon before migrating and restarts it after", async () => { - const dir = await makeTempDir("stash-cli-migrate-bounce"); - - try { - await writeLegacyLayout(dir, { - connections: { github: { repo: "user/repo" } }, - snapshotLocal: { "note.md": "base" }, - }); - - const result = await runMain(dir, ["status"], { - config: { - providers: {}, - background: { stashes: [resolve(dir)] }, - }, - serviceStatus: { installed: true, running: true }, - }); - - assert.equal(result.calls.uninstall, 1, "daemon should be stopped before migration"); - assert.equal(result.calls.install, 1, "daemon should be restarted after migration"); - await assertMigration(dir, { - connections: { github: { provider: "github", repo: "user/repo" } }, - snapshotLocal: { "note.md": "base" }, - }); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("migration does not bounce the daemon when it is not running", async () => { - const dir = await makeTempDir("stash-cli-migrate-no-bounce"); - - try { - await writeLegacyLayout(dir, { - connections: { github: { repo: "user/repo" } }, - }); - - const result = await runMain(dir, ["status"], { - config: { - providers: {}, - background: { stashes: [resolve(dir)] }, - }, - serviceStatus: { installed: false, running: false }, - }); - - assert.equal(result.calls.uninstall, 0, "daemon should not be stopped when not running"); - assert.equal(result.calls.install, 0, "daemon should not be installed when it was not running"); - await assertMigration(dir, { - connections: { github: { provider: "github", repo: "user/repo" } }, - }); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("migration bounces daemon for status when a background stash needs migration", async () => { - const dir = await makeTempDir("stash-cli-migrate-status-all"); - - try { - await writeLegacyLayout(dir, { - connections: { github: { repo: "user/repo" } }, - snapshotLocal: { "note.md": "base" }, - }); - await writeFile(join(dir, ".stash", "snapshot.json"), JSON.stringify({}, null, 2), "utf8"); - - const result = await runMain(dir, ["status"], { - config: { - providers: {}, - background: { stashes: [dir] }, - }, - serviceStatus: { installed: true, running: true }, - }); - - assert.equal(result.calls.uninstall, 1, "daemon should be stopped before migration"); - assert.equal(result.calls.install, 1, "daemon should be restarted after migration"); - await assertMigration(dir, { - connections: { github: { provider: "github", repo: "user/repo" } }, - snapshotLocal: { "note.md": "base" }, - }); } finally { await rm(dir, { recursive: true, force: true }); } }); -test("migration does not restart daemon after stash stop", async () => { - const dir = await makeTempDir("stash-cli-migrate-stop"); +test("start and stop delegate to the service and use the new daemon messaging", async () => { + const dir = await makeTempDir("stash-cli-service"); try { - await writeLegacyLayout(dir, { - connections: { github: { repo: "user/repo" } }, - }); + const started = await runMain(dir, ["start"]); + assert.equal(started.calls.install, 1); + assert.equal(started.stdout.includes("Daemon started."), true); - const result = await runMain(dir, ["stop"], { - config: { - providers: {}, - background: { stashes: [resolve(dir)] }, - }, + const stopped = await runMain(dir, ["stop"], { serviceStatus: { installed: true, running: true }, }); - - assert.equal(result.calls.uninstall >= 1, true, "daemon should be stopped"); - assert.equal(result.calls.install, 0, "daemon should not be restarted after stop command"); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("status prints background sync line above provider connections with a blank line gap", async () => { - const dir = await makeTempDir("stash-cli-status-layout"); - - try { - await Stash.init(dir, { providers: {}, background: { stashes: [resolve(dir)] } }); - await writeFile( - join(dir, ".stash", "config.json"), - JSON.stringify( - { - connections: { - origin: { provider: "github", repo: "user/repo" }, - }, - }, - null, - 2, - ), - "utf8", - ); - await writeFile(join(dir, ".stash", "snapshot.json"), JSON.stringify({}, null, 2), "utf8"); - - const result = await runMain(dir, ["status"], { - config: { - providers: {}, - background: { stashes: [resolve(dir)] }, - }, - serviceStatus: { installed: true, running: true }, - }); - - const lines = result.stdout.split("\n"); - const bgLine = lines.findIndex((line) => line.includes("Background sync is on")); - const providerLine = lines.findIndex((line) => line.includes("origin (github)")); - assert.notEqual(bgLine, -1, "should contain background sync line"); - assert.notEqual(providerLine, -1, "should contain provider line"); - assert.equal( - bgLine < providerLine, - true, - "background sync line should appear before provider line", - ); - assert.equal( - lines[bgLine + 1]?.trim(), - "", - "blank line should follow the background sync line", - ); + assert.equal(stopped.calls.uninstall, 1); + assert.equal(stopped.stdout.includes("Daemon stopped."), true); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/tests/integration/cli.integration.test.ts b/tests/integration/cli.integration.test.ts index 5234162..f1859eb 100644 --- a/tests/integration/cli.integration.test.ts +++ b/tests/integration/cli.integration.test.ts @@ -3,69 +3,48 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { existsSync } from "node:fs"; -import { mkdir, readFile, rm } from "node:fs/promises"; +import { mkdir, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { assertMigration, writeLegacyLayout } from "../helpers/assert-migration.ts"; import { makeTempDir } from "../helpers/make-stash.ts"; const execFileAsync = promisify(execFile); const CLI_PATH = fileURLToPath(new URL("../../src/cli.ts", import.meta.url)); +function getTestStashPath(dir: string): string { + return join(dir, ".xdg", "stash"); +} + async function runCli( cwd: string, args: string[], - env?: Record, ): Promise<{ stdout: string; stderr: string }> { return execFileAsync("node", [CLI_PATH, ...args], { cwd, - env: { ...process.env, XDG_CONFIG_HOME: join(cwd, ".xdg"), ...(env ?? {}) }, + env: { ...process.env, XDG_CONFIG_HOME: join(cwd, ".xdg") }, }); } -test("cli connect auto-inits the stash directory", async () => { - const dir = await makeTempDir("cli-connect-init"); - try { - const result = await runCli(dir, [ - "connect", - "github", - "origin", - "--token", - "test-token", - "--repo", - "user/repo", - ]); - - assert.equal(existsSync(join(dir, ".stash")), true); - assert.equal(result.stdout.includes("Connected origin."), true); - const config = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); - assert.deepEqual(config, { - connections: { - origin: { provider: "github", repo: "user/repo" }, - }, - }); - } finally { - await rm(dir, { recursive: true, force: true }); +async function listStashIds(stashPath: string): Promise { + if (!existsSync(stashPath)) { + return []; } -}); + return (await readdir(stashPath)).filter((entry) => !entry.startsWith(".") && entry !== "config.json"); +} + +test("cli setup stores provider globals in ~/.stash/config.json", async () => { + const dir = await makeTempDir("cli-setup-global"); -test("cli connect defaults the connection name to the provider when omitted", async () => { - const dir = await makeTempDir("cli-connect-default-name"); try { - const result = await runCli(dir, [ - "connect", - "github", - "--token", - "test-token", - "--repo", - "user/repo", - ]); + const result = await runCli(dir, ["setup", "github", "--token", "test-token"]); - assert.equal(result.stdout.includes("Connected github."), true); - const config = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); + assert.equal(result.stdout.includes("Configured github."), true); + const config = JSON.parse(await readFile(join(getTestStashPath(dir), "config.json"), "utf8")); assert.deepEqual(config, { - connections: { - github: { provider: "github", repo: "user/repo" }, + providers: { + github: { + token: "test-token", + }, }, }); } finally { @@ -73,265 +52,153 @@ test("cli connect defaults the connection name to the provider when omitted", as } }); -test("cli connect errors when the connection name already exists", async () => { - const dir = await makeTempDir("cli-connect-duplicate"); - try { - await runCli(dir, [ - "connect", - "github", - "origin", - "--token", - "test-token", - "--repo", - "user/repo", - ]); +test("cli connect creates registry metadata without a local .stash directory", async () => { + const dir = await makeTempDir("cli-connect-uri"); - const result = await execFileAsync( - "node", - [CLI_PATH, "connect", "github", "origin", "--repo", "user/other-repo"], - { - cwd: dir, - env: { ...process.env, XDG_CONFIG_HOME: join(dir, ".xdg") }, - }, - ).catch((error) => error as { stdout: string; stderr: string; code: number }); - - assert.equal(result.code, 1); - const combined = (result.stdout ?? "") + (result.stderr ?? ""); - assert.equal(combined.includes("Connection already exists: origin"), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("cli connect errors when the default connection name is already taken", async () => { - const dir = await makeTempDir("cli-connect-default-duplicate"); try { - await runCli(dir, ["connect", "github", "--token", "test-token", "--repo", "user/repo"]); - - const result = await execFileAsync( - "node", - [CLI_PATH, "connect", "github", "--repo", "user/other-repo"], - { - cwd: dir, - env: { ...process.env, XDG_CONFIG_HOME: join(dir, ".xdg") }, - }, - ).catch((error) => error as { stdout: string; stderr: string; code: number }); - - assert.equal(result.code, 1); - const combined = (result.stdout ?? "") + (result.stderr ?? ""); - assert.equal(combined.includes("Connection already exists: github"), true); + const result = await runCli(dir, ["connect", "github:user/repo", "--token", "test-token"]); + const canonicalDir = await realpath(dir); + + assert.equal(result.stdout.includes(`Connected github:user/repo to ${canonicalDir}.`), true); + assert.equal(existsSync(join(dir, ".stash")), false); + + const ids = await listStashIds(getTestStashPath(dir)); + assert.equal(ids.length, 1); + const record = JSON.parse( + await readFile(join(getTestStashPath(dir), ids[0], "stash.json"), "utf8"), + ) as { path: string; connections: Array<{ provider: string; config: { id: string } }> }; + assert.deepEqual(record, { + path: await realpath(dir), + connections: [{ provider: "github", config: { id: "user/repo" } }], + }); } finally { await rm(dir, { recursive: true, force: true }); } }); -test("cli connect errors when adding a second connection with a different name", async () => { - const dir = await makeTempDir("cli-connect-multi"); - try { - await runCli(dir, [ - "connect", - "github", - "origin", - "--token", - "test-token", - "--repo", - "user/repo", - ]); - - const result = await execFileAsync( - "node", - [CLI_PATH, "connect", "github", "backup", "--repo", "user/other"], - { - cwd: dir, - env: { ...process.env, XDG_CONFIG_HOME: join(dir, ".xdg") }, - }, - ).catch((error) => error as { stdout: string; stderr: string; code: number }); - - assert.equal(result.code, 1); - const combined = (result.stdout ?? "") + (result.stderr ?? ""); - assert.equal(combined.includes('already has connection "origin"'), true); - assert.equal(combined.includes("stash disconnect origin"), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); +test("cli connect accepts an explicit path argument", async () => { + const dir = await makeTempDir("cli-connect-explicit"); + const target = await makeTempDir("cli-connect-target"); -test("cli config set/get stores allowed stash settings", async () => { - const dir = await makeTempDir("cli-config"); try { - await runCli(dir, [ - "connect", - "github", - "origin", - "--token", - "test-token", - "--repo", - "user/repo", - ]); - await runCli(dir, ["config", "set", "allow-git", "true"]); - - const config = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); - assert.deepEqual(config, { - "allow-git": true, - connections: { - origin: { provider: "github", repo: "user/repo" }, - }, - }); - - const result = await runCli(dir, ["config", "get", "allow-git"]); - assert.equal(result.stdout.trim(), "true"); + const result = await runCli(dir, ["connect", target, "github:user/repo", "--token", "test-token"]); + const canonicalTarget = await realpath(target); + + assert.equal(result.stdout.includes(`Connected github:user/repo to ${canonicalTarget}.`), true); + const ids = await listStashIds(getTestStashPath(dir)); + assert.equal(ids.length, 1); + const record = JSON.parse( + await readFile(join(getTestStashPath(dir), ids[0], "stash.json"), "utf8"), + ) as { path: string }; + assert.equal(record.path, await realpath(target)); } finally { await rm(dir, { recursive: true, force: true }); + await rm(target, { recursive: true, force: true }); } }); -test("cli config get prints nothing when value is unset", async () => { - const dir = await makeTempDir("cli-config-empty"); - try { - await runCli(dir, [ - "connect", - "github", - "origin", - "--token", - "test-token", - "--repo", - "user/repo", - ]); - - const result = await runCli(dir, ["config", "get", "allow-git"]); - assert.equal(result.stdout.trim(), ""); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); +test("cli disconnect tombstones the stash record", async () => { + const dir = await makeTempDir("cli-disconnect"); -test("cli config set rejects unknown keys", async () => { - const dir = await makeTempDir("cli-config-unknown"); try { - await runCli(dir, [ - "connect", - "github", - "origin", - "--token", - "test-token", - "--repo", - "user/repo", - ]); + await runCli(dir, ["connect", "github:user/repo", "--token", "test-token"]); + const [id] = await listStashIds(getTestStashPath(dir)); + const canonicalDir = await realpath(dir); - const result = await execFileAsync("node", [CLI_PATH, "config", "set", "unknown-key", "true"], { - cwd: dir, - env: { ...process.env, XDG_CONFIG_HOME: join(dir, ".xdg") }, - }).catch((error) => error as { stdout: string; stderr: string; code: number }); + const result = await runCli(dir, ["disconnect"]); - assert.equal(result.code, 1); - const combined = (result.stdout ?? "") + (result.stderr ?? ""); - assert.equal(combined.includes("Unknown config key"), true); + assert.equal(result.stdout.includes(`Disconnected github:user/repo from ${canonicalDir}.`), true); + const record = JSON.parse( + await readFile(join(getTestStashPath(dir), id, "stash.json"), "utf8"), + ) as { connections: unknown[] }; + assert.deepEqual(record.connections, []); } finally { await rm(dir, { recursive: true, force: true }); } }); -test("cli config commands require an initialized stash", async () => { - const dir = await makeTempDir("cli-config-missing"); - try { - const result = await execFileAsync("node", [CLI_PATH, "config", "set", "allow-git", "true"], { - cwd: dir, - env: { ...process.env, XDG_CONFIG_HOME: join(dir, ".xdg") }, - }).catch((error) => error as { stdout: string; stderr: string; code: number }); - - assert.equal(result.code, 1); - const combined = (result.stdout ?? "") + (result.stderr ?? ""); - assert.equal(combined.includes("not a stash"), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); +test("cli relink rewrites the canonical path stored in stash.json", async () => { + const dir = await makeTempDir("cli-relink-old"); + const moved = await makeTempDir("cli-relink-new"); -test("cli config get migrates the legacy local stash layout", async () => { - const dir = await makeTempDir("cli-config-migration"); try { - await writeLegacyLayout(dir, { - connections: {}, - snapshotLocal: { "note.md": "base" }, - }); - - const result = await runCli(dir, ["config", "get", "allow-git"]); - assert.equal(result.stdout.trim(), ""); - - await assertMigration(dir, { - connections: {}, - snapshotLocal: { "note.md": "base" }, - }); + await runCli(dir, ["connect", "github:user/repo", "--token", "test-token"]); + const [id] = await listStashIds(getTestStashPath(dir)); + const canonicalDir = await realpath(dir); + const canonicalMoved = await realpath(moved); + + const result = await runCli(dir, ["relink", dir, moved]); + + assert.equal(result.stdout.includes(`Relinked ${canonicalDir} -> ${canonicalMoved}.`), true); + const record = JSON.parse( + await readFile(join(getTestStashPath(dir), id, "stash.json"), "utf8"), + ) as { path: string }; + assert.equal(record.path, await realpath(moved)); } finally { await rm(dir, { recursive: true, force: true }); + await rm(moved, { recursive: true, force: true }); } }); -test("cli sync failure sets exit code 1 and prints error once", async () => { - const dir = await makeTempDir("cli-sync-fail"); +test("cli status prints registered stashes from the registry root", async () => { + const dir = await makeTempDir("cli-status"); + const stashPath = getTestStashPath(dir); + try { - const result = await execFileAsync("node", [CLI_PATH, "sync"], { - cwd: dir, - env: { ...process.env, XDG_CONFIG_HOME: join(dir, ".xdg") }, - }).catch((error) => error as { stdout: string; stderr: string; code: number }); + await runCli(dir, ["connect", "github:user/repo", "--token", "test-token"]); + const [id] = await listStashIds(stashPath); + await writeFile( + join(stashPath, id, "status.json"), + JSON.stringify( + { + kind: "idle", + lastSync: "2026-03-11T14:30:00.000Z", + summary: null, + error: null, + }, + null, + 2, + ), + "utf8", + ); - assert.equal(result.code, 1, "process should exit with code 1"); + const result = await runCli(dir, ["status"]); - const combined = (result.stdout ?? "") + (result.stderr ?? ""); - const errorOccurrences = combined.split("sync failed").length - 1; - assert.equal(errorOccurrences <= 1, true, `error should appear at most once, got: ${combined}`); + assert.equal(result.stdout.includes("Daemon:"), true); + assert.equal(result.stdout.includes(await realpath(dir)), true); + assert.equal(result.stdout.includes("github:user/repo"), true); } finally { await rm(dir, { recursive: true, force: true }); } }); -test("cli sync in a git repository prints the allow-git guidance", async () => { - const dir = await makeTempDir("cli-sync-git"); - const xdg = await makeTempDir("cli-sync-git-xdg"); +test("cli connect blocks git repos unless --dangerously-allow-git is passed", async () => { + const dir = await makeTempDir("cli-connect-git"); + try { await mkdir(join(dir, ".git"), { recursive: true }); - await runCli( - dir, - ["connect", "github", "origin", "--token", "test-token", "--repo", "user/repo"], + + const error = await execFileAsync( + "node", + [CLI_PATH, "connect", "github:user/repo", "--token", "test-token"], { - XDG_CONFIG_HOME: xdg, + cwd: dir, + env: { ...process.env, XDG_CONFIG_HOME: join(dir, ".xdg") }, }, - ); + ).catch((value) => value as { stdout: string; stderr: string; code: number }); + const combined = (error.stdout ?? "") + (error.stderr ?? ""); - const result = await execFileAsync("node", [CLI_PATH, "sync"], { - cwd: dir, - env: { ...process.env, XDG_CONFIG_HOME: xdg }, - }).catch((error) => error as { stdout: string; stderr: string; code: number }); + assert.equal(error.code, 1); + assert.equal(combined.includes("--dangerously-allow-git"), true); - assert.equal(result.code, 1); - const combined = (result.stdout ?? "") + (result.stderr ?? ""); - assert.equal( - combined.includes( - "git repository detected — run `stash config set allow-git true` to allow syncing", - ), - true, - ); - } finally { - await rm(dir, { recursive: true, force: true }); - await rm(xdg, { recursive: true, force: true }); - } -}); - -test("cli sync migrates the legacy local stash layout before a no-op sync", async () => { - const dir = await makeTempDir("cli-sync-migration"); - try { - await writeLegacyLayout(dir, { - connections: {}, - snapshotLocal: { "note.md": "base" }, - }); - - const result = await runCli(dir, ["sync"]); - assert.equal(result.stdout.includes("up to date"), true); - - await assertMigration(dir, { - connections: {}, - snapshotLocal: { "note.md": "base" }, - }); + const allowed = await runCli(dir, [ + "connect", + "github:user/repo", + "--token", + "test-token", + "--dangerously-allow-git", + ]); + assert.equal(allowed.stdout.includes(`Connected github:user/repo to ${await realpath(dir)}.`), true); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/tests/integration/stash-factory.integration.test.ts b/tests/integration/stash-factory.integration.test.ts new file mode 100644 index 0000000..c2178c4 --- /dev/null +++ b/tests/integration/stash-factory.integration.test.ts @@ -0,0 +1,64 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getStashDirPath } from "../../src/global-config.ts"; +import { Stash } from "../../src/stash.ts"; + +async function makeTempDir(prefix: string): Promise { + return mkdtemp(join(tmpdir(), `${prefix}-`)); +} + +async function withTempHome(prefix: string, run: (home: string) => Promise): Promise { + const home = await makeTempDir(prefix); + const previousHome = process.env.HOME; + const previousXdg = process.env.XDG_CONFIG_HOME; + process.env.HOME = home; + delete process.env.XDG_CONFIG_HOME; + + try { + return await run(home); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + if (previousXdg === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousXdg; + } + await rm(home, { recursive: true, force: true }); + } +} + +test("stash factories: create writes stash.json under ~/.stash/ and load reads it back", async () => { + await withTempHome("stash-factory", async () => { + const dir = await makeTempDir("stash-local"); + const globalConfig = { providers: {}, background: { stashes: [] } }; + const record = { + path: dir, + connections: [{ provider: "github", config: { id: "user/repo" } }], + }; + + try { + const created = await Stash.create(record, globalConfig); + + assert.match(created.id, /^[0-9A-HJKMNP-TV-Z]{26}$/); + assert.equal(existsSync(join(dir, ".stash")), false); + + const stashDir = getStashDirPath(created.id); + assert.equal(existsSync(stashDir), true); + assert.deepEqual(JSON.parse(await readFile(join(stashDir, "stash.json"), "utf8")), record); + + const loaded = await Stash.load(created.id, globalConfig); + assert.equal(loaded.id, created.id); + assert.deepEqual(loaded.record, record); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/integration/stash-sync.test.ts b/tests/integration/stash-sync.test.ts index c6ef672..864dc48 100644 --- a/tests/integration/stash-sync.test.ts +++ b/tests/integration/stash-sync.test.ts @@ -1,15 +1,10 @@ import test from "node:test"; import assert from "node:assert/strict"; import { existsSync, readdirSync } from "node:fs"; -import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import { readFile, rename, unlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { hashBuffer } from "../../src/utils/hash.ts"; -import { - GitRepoError, - MultipleConnectionsError, - PushConflictError, - SyncLockError, -} from "../../src/errors.ts"; +import { MultipleConnectionsError, PushConflictError, SyncLockError } from "../../src/errors.ts"; import { FakeProvider } from "../helpers/fake-provider.ts"; import { makeStash, writeFiles } from "../helpers/make-stash.ts"; @@ -258,20 +253,6 @@ test("sync: first sync with identical content on both sides skips file writes", ); }); -test("sync: git repository without allow-git throws", async () => { - const fake = new FakeProvider(); - const { stash, dir } = await makeStash( - { "hello.md": "hello" }, - { providers: fakeRegistry(fake) }, - ); - await stash.connect({ name: "fake", provider: "fake", repo: "r" }); - await mkdir(join(dir, ".git"), { recursive: true }); - - await assert.rejects(stash.sync(), GitRepoError); - assert.equal(fake.fetchCalls, 0); - assert.equal(fake.pushCalls, 0); -}); - test("sync: multiple connections throws", async () => { const fake = new FakeProvider(); const { stash, dir } = await makeStash( @@ -297,28 +278,6 @@ test("sync: multiple connections throws", async () => { assert.equal(fake.pushCalls, 0); }); -test("sync: allow-git permits syncing inside a git repository", async () => { - const fake = new FakeProvider(); - const { stash, dir } = await makeStash( - { "hello.md": "hello" }, - { providers: fakeRegistry(fake) }, - ); - await stash.connect({ name: "fake", provider: "fake", repo: "r" }); - await writeFile( - join(dir, ".stash", "config.json"), - JSON.stringify( - { "allow-git": true, connections: { fake: { provider: "fake", repo: "r" } } }, - null, - 2, - ), - "utf8", - ); - await mkdir(join(dir, ".git"), { recursive: true }); - - await stash.sync(); - assert.equal(fake.files.get("hello.md"), "hello"); -}); - test("sync: skip/skip mutations with changed snapshot still pushes snapshot", async () => { const fake = new FakeProvider(); const { stash, dir } = await makeStash( diff --git a/tests/unit/daemon.test.ts b/tests/unit/daemon.test.ts index e027197..7709363 100644 --- a/tests/unit/daemon.test.ts +++ b/tests/unit/daemon.test.ts @@ -1,113 +1,124 @@ import test from "node:test"; import assert from "node:assert/strict"; import { existsSync } from "node:fs"; -import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { BackgroundDaemon } from "../../src/daemon.ts"; -import type { GlobalConfig } from "../../src/types.ts"; -import type { WatchStatus } from "../../src/watch.ts"; +import type { GlobalConfig, StashRecord } from "../../src/types.ts"; -type ConfigEvent = { +type WatchEvent = { path: string; type: "create" | "update" | "delete"; }; +type WatchCallback = (error: Error | null, events: WatchEvent[]) => void; + async function makeTempDir(prefix: string): Promise { return mkdtemp(join(tmpdir(), `${prefix}-`)); } -test("daemon: starts registered watches, writes status.json, and hot-reloads config changes", async () => { - const workspace = await makeTempDir("stash-daemon"); - const stashA = join(workspace, "stash-a"); - const stashB = join(workspace, "stash-b"); - const configPath = join(workspace, "config.json"); +async function writeConfig(configPath: string, providers: GlobalConfig["providers"]): Promise { + await mkdir(dirname(configPath), { recursive: true }); + await writeFile(configPath, JSON.stringify({ providers }, null, 2), "utf8"); +} + +async function writeStashRecord( + stashPath: string, + id: string, + record: StashRecord, +): Promise { + await mkdir(join(stashPath, id), { recursive: true }); + await writeFile(join(stashPath, id, "stash.json"), JSON.stringify(record, null, 2), "utf8"); +} + +test("daemon: reconciles registry entries, restarts on tuple changes, and stops removed stashes", async () => { + const workspace = await makeTempDir("stash-daemon-registry"); + const stashPath = join(workspace, "stash"); + const configPath = join(stashPath, "config.json"); + const dirA = join(workspace, "dir-a"); + const dirB = join(workspace, "dir-b"); const started: string[] = []; const stopped: string[] = []; const logs: string[] = []; - const watchCallbacks = new Map Promise>(); - let config: GlobalConfig = { - providers: {}, - background: { - stashes: [stashA], - }, - }; - let onConfigEvent: ((error: Error | null, events: ConfigEvent[]) => void) | null = null; + const callbacks = new Map(); try { - await mkdir(join(stashA, ".stash"), { recursive: true }); - await mkdir(join(stashB, ".stash"), { recursive: true }); - await writeFile(configPath, JSON.stringify(config, null, 2), "utf8"); + await mkdir(dirA, { recursive: true }); + await mkdir(dirB, { recursive: true }); + await writeConfig(configPath, { + github: { token: "token-a" }, + }); + await writeStashRecord(stashPath, "stash-a", { + path: dirA, + connections: [{ provider: "github", config: { id: "user/repo-a" } }], + }); const daemon = new BackgroundDaemon({ configPath, - readGlobalConfig: async () => config, + pollMs: 20, log: (message) => logs.push(message), - subscribe: async (_path, callback) => { - onConfigEvent = callback as typeof onConfigEvent; + subscribe: async (path, callback) => { + callbacks.set(path, callback as WatchCallback); return { - unsubscribe: async () => {}, - }; - }, - createWatch: async ({ dir, onStatus }) => { - watchCallbacks.set(dir, onStatus); - return { - start: async () => { - started.push(dir); - await onStatus({ - kind: "checked", - lastSync: new Date("2026-03-11T14:30:00.000Z"), - nextCheck: new Date("2026-03-11T14:31:00.000Z"), - error: null, - }); - }, - stop: async () => { - stopped.push(dir); + unsubscribe: async () => { + callbacks.delete(path); }, }; }, + createWatch: async ({ stash, onStatus }) => ({ + start: async () => { + started.push(stash.id); + await onStatus({ + kind: "checked", + lastSync: new Date("2026-03-11T14:30:00.000Z"), + nextCheck: new Date("2026-03-11T14:31:00.000Z"), + error: null, + }); + }, + stop: async () => { + stopped.push(stash.id); + }, + }), }); await daemon.start(); - const statusPath = join(stashA, ".stash", "status.json"); - assert.equal(existsSync(statusPath), true); - assert.deepEqual(JSON.parse(await readFile(statusPath, "utf8")), { + assert.deepEqual(started, ["stash-a"]); + assert.deepEqual(JSON.parse(await readFile(join(stashPath, "stash-a", "status.json"), "utf8")), { kind: "checked", lastSync: "2026-03-11T14:30:00.000Z", summary: null, error: null, }); - const logPath = join(stashA, ".stash", "sync.log"); - assert.equal((await readFile(logPath, "utf8")).includes("checked"), true); + await writeConfig(configPath, { + github: { token: "token-b" }, + }); + callbacks.get(configPath)?.(null, [{ type: "update", path: configPath }]); + await delay(30); - config = { - providers: {}, - background: { - stashes: [stashB], - }, - }; - await writeFile(configPath, JSON.stringify(config, null, 2), "utf8"); - onConfigEvent?.(null, [{ type: "update", path: configPath }]); - await delay(20); + assert.deepEqual(started, ["stash-a", "stash-a"]); + assert.deepEqual(stopped, ["stash-a"]); - assert.deepEqual(started, [stashA, stashB]); - assert.deepEqual(stopped, [stashA]); + await writeStashRecord(stashPath, "stash-b", { + path: dirB, + connections: [{ provider: "github", config: { id: "user/repo-b" } }], + }); + callbacks.get(stashPath)?.(null, [{ type: "create", path: join(stashPath, "stash-b") }]); + await delay(30); - assert.ok( - logs.some((m) => m.includes(`watching ${stashA}`)), - "should log when starting a watch", - ); - assert.ok( - logs.some((m) => m.includes(`stopped watching ${stashA}`)), - "should log when stopping a watch", - ); - assert.ok( - logs.some((m) => m.includes(`watching ${stashB}`)), - "should log new watch after reload", - ); + assert.deepEqual(started, ["stash-a", "stash-a", "stash-b"]); + + await rm(join(stashPath, "stash-a"), { recursive: true, force: true }); + callbacks.get(stashPath)?.(null, [{ type: "delete", path: join(stashPath, "stash-a") }]); + await delay(30); + + assert.deepEqual(stopped, ["stash-a", "stash-a"]); + assert.ok(logs.some((message) => message.includes("watching stash-a"))); + assert.ok(logs.some((message) => message.includes("watching stash-b"))); + assert.ok(logs.some((message) => message.includes("stopped watching stash-a"))); await daemon.stop(); } finally { @@ -115,47 +126,38 @@ test("daemon: starts registered watches, writes status.json, and hot-reloads con } }); -test("daemon: caps sync.log at 1MB by truncating the oldest lines", async () => { - const workspace = await makeTempDir("stash-daemon-log"); - const stashDir = join(workspace, "stash"); - const configPath = join(workspace, "config.json"); - const oldLine = "x".repeat(200); - const oldLog = `old-start\n${`${oldLine}\n`.repeat(7_000)}old-tail\n`; +test("daemon: poll-only mode still reconciles new registry entries", async () => { + const workspace = await makeTempDir("stash-daemon-poll"); + const stashPath = join(workspace, "stash"); + const configPath = join(stashPath, "config.json"); + const dirA = join(workspace, "dir-a"); + const dirB = join(workspace, "dir-b"); + const started: string[] = []; + const logs: string[] = []; try { - await mkdir(join(stashDir, ".stash"), { recursive: true }); - await writeFile(join(stashDir, ".stash", "sync.log"), oldLog, "utf8"); - await writeFile( - configPath, - JSON.stringify( - { - providers: {}, - background: { - stashes: [stashDir], - }, - } satisfies GlobalConfig, - null, - 2, - ), - "utf8", - ); + await mkdir(dirA, { recursive: true }); + await mkdir(dirB, { recursive: true }); + await writeConfig(configPath, { + github: { token: "token-a" }, + }); + await writeStashRecord(stashPath, "stash-a", { + path: dirA, + connections: [{ provider: "github", config: { id: "user/repo-a" } }], + }); const daemon = new BackgroundDaemon({ configPath, - readGlobalConfig: async () => ({ - providers: {}, - background: { - stashes: [stashDir], - }, - }), - subscribe: async () => ({ - unsubscribe: async () => {}, - }), - createWatch: async ({ onStatus }) => ({ + pollMs: 20, + log: (message) => logs.push(message), + subscribe: async () => { + throw new Error("watch unavailable"); + }, + createWatch: async ({ stash, onStatus }) => ({ start: async () => { + started.push(stash.id); await onStatus({ - kind: "synced", - summary: "1↑ 2↓", + kind: "checked", lastSync: new Date("2026-03-11T14:30:00.000Z"), nextCheck: new Date("2026-03-11T14:31:00.000Z"), error: null, @@ -166,49 +168,59 @@ test("daemon: caps sync.log at 1MB by truncating the oldest lines", async () => }); await daemon.start(); - await daemon.stop(); + assert.deepEqual(started, ["stash-a"]); - const logPath = join(stashDir, ".stash", "sync.log"); - const log = await readFile(logPath, "utf8"); - assert.ok(Buffer.byteLength(log, "utf8") <= 1_000_000); - assert.equal(log.includes("1↑ 2↓"), true); - assert.equal(log.includes("old-start"), false); + await writeStashRecord(stashPath, "stash-b", { + path: dirB, + connections: [{ provider: "github", config: { id: "user/repo-b" } }], + }); + await delay(60); + + assert.deepEqual(started, ["stash-a", "stash-b"]); + assert.ok(logs.some((message) => message.includes("watch unavailable"))); + + await daemon.stop(); } finally { await rm(workspace, { recursive: true, force: true }); } }); -test("daemon: continues in poll-only mode when native watcher throws", async () => { - const workspace = await makeTempDir("stash-daemon-no-watcher"); - const stashDir = join(workspace, "stash"); - const configPath = join(workspace, "config.json"); - const logs: string[] = []; - let started = false; +test("daemon: writes missing status and revalidates tombstone deletion after stop", async () => { + const workspace = await makeTempDir("stash-daemon-missing"); + const stashPath = join(workspace, "stash"); + const configPath = join(stashPath, "config.json"); + const liveDir = join(workspace, "live-dir"); + const missingDir = join(workspace, "missing-dir"); + const started: string[] = []; + const stopStarted: string[] = []; + let releaseStop: (() => void) | null = null; + const stopGate = new Promise((resolve) => { + releaseStop = resolve; + }); try { - await mkdir(join(stashDir, ".stash"), { recursive: true }); - await writeFile( - configPath, - JSON.stringify({ - providers: {}, - background: { stashes: [stashDir] }, - } satisfies GlobalConfig), - "utf8", - ); + await mkdir(liveDir, { recursive: true }); + await writeConfig(configPath, { + github: { token: "token-a" }, + }); + await writeStashRecord(stashPath, "missing-stash", { + path: missingDir, + connections: [{ provider: "github", config: { id: "user/missing" } }], + }); + await writeStashRecord(stashPath, "live-stash", { + path: liveDir, + connections: [{ provider: "github", config: { id: "user/live" } }], + }); const daemon = new BackgroundDaemon({ configPath, - readGlobalConfig: async () => ({ - providers: {}, - background: { stashes: [stashDir] }, + pollMs: 20, + subscribe: async () => ({ + unsubscribe: async () => {}, }), - log: (message) => logs.push(message), - subscribe: async () => { - throw new Error("inotify limit reached"); - }, - createWatch: async ({ onStatus }) => ({ + createWatch: async ({ stash, onStatus }) => ({ start: async () => { - started = true; + started.push(stash.id); await onStatus({ kind: "checked", lastSync: new Date("2026-03-11T14:30:00.000Z"), @@ -216,61 +228,119 @@ test("daemon: continues in poll-only mode when native watcher throws", async () error: null, }); }, - stop: async () => {}, + stop: async () => { + stopStarted.push(stash.id); + await stopGate; + }, }), }); await daemon.start(); - assert.equal(started, true, "watch should still start despite watcher failure"); - assert.ok( - logs.some((m) => m.includes("native config watcher unavailable")), - "should log watcher fallback", + assert.deepEqual(started, ["live-stash"]); + assert.deepEqual( + JSON.parse(await readFile(join(stashPath, "missing-stash", "status.json"), "utf8")), + { + kind: "missing", + lastSync: null, + summary: null, + error: null, + }, ); - const statusPath = join(stashDir, ".stash", "status.json"); - assert.equal(existsSync(statusPath), true, "status.json should still be written"); + await writeStashRecord(stashPath, "live-stash", { + path: liveDir, + connections: [], + }); + await delay(40); + + assert.deepEqual(stopStarted, ["live-stash"]); + + await writeStashRecord(stashPath, "live-stash", { + path: liveDir, + connections: [{ provider: "github", config: { id: "user/live" } }], + }); + releaseStop?.(); + await delay(80); + + assert.equal(existsSync(join(stashPath, "live-stash")), true); + assert.deepEqual(started, ["live-stash", "live-stash"]); await daemon.stop(); } finally { + releaseStop?.(); await rm(workspace, { recursive: true, force: true }); } }); -test("daemon: persists git safety errors to status.json and sync.log", async () => { - const workspace = await makeTempDir("stash-daemon-git-error"); - const stashDir = join(workspace, "stash"); - const configPath = join(workspace, "config.json"); - const errorMessage = - "git repository detected — run `stash config set allow-git true` to allow syncing"; +test("daemon: startup GC removes old incomplete dirs and keeps fresh incomplete dirs", async () => { + const workspace = await makeTempDir("stash-daemon-gc"); + const stashPath = join(workspace, "stash"); + const configPath = join(stashPath, "config.json"); + const oldDir = join(stashPath, "old-orphan"); + const freshDir = join(stashPath, "fresh-orphan"); try { - await mkdir(join(stashDir, ".stash"), { recursive: true }); - await writeFile( - configPath, - JSON.stringify({ - providers: {}, - background: { stashes: [stashDir] }, - } satisfies GlobalConfig), - "utf8", - ); + await mkdir(oldDir, { recursive: true }); + await mkdir(freshDir, { recursive: true }); + const oldDate = new Date(Date.now() - 10 * 60_000); + await utimes(oldDir, oldDate, oldDate); + await writeConfig(configPath, {}); const daemon = new BackgroundDaemon({ configPath, - readGlobalConfig: async () => ({ - providers: {}, - background: { stashes: [stashDir] }, + pollMs: 20, + subscribe: async () => ({ + unsubscribe: async () => {}, }), + createWatch: async () => { + throw new Error("no watches expected"); + }, + }); + + await daemon.start(); + await daemon.stop(); + + assert.equal(existsSync(oldDir), false); + assert.equal(existsSync(freshDir), true); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("daemon: caps sync.log at 1MB under per-stash metadata dirs", async () => { + const workspace = await makeTempDir("stash-daemon-log"); + const stashPath = join(workspace, "stash"); + const configPath = join(stashPath, "config.json"); + const liveDir = join(workspace, "live-dir"); + const oldLine = "x".repeat(200); + const oldLog = `old-start\n${`${oldLine}\n`.repeat(7_000)}old-tail\n`; + + try { + await mkdir(liveDir, { recursive: true }); + await writeConfig(configPath, { + github: { token: "token-a" }, + }); + await writeStashRecord(stashPath, "live-stash", { + path: liveDir, + connections: [{ provider: "github", config: { id: "user/live" } }], + }); + await writeFile(join(stashPath, "live-stash", "sync.log"), oldLog, "utf8"); + + const daemon = new BackgroundDaemon({ + configPath, + pollMs: 20, subscribe: async () => ({ unsubscribe: async () => {}, }), createWatch: async ({ onStatus }) => ({ start: async () => { await onStatus({ - kind: "error", - error: errorMessage, - lastSync: null, + kind: "synced", + summary: "1↑ 2↓", + lastSync: new Date("2026-03-11T14:30:00.000Z"), nextCheck: new Date("2026-03-11T14:31:00.000Z"), + error: null, }); }, stop: async () => {}, @@ -280,16 +350,11 @@ test("daemon: persists git safety errors to status.json and sync.log", async () await daemon.start(); await daemon.stop(); - const status = JSON.parse(await readFile(join(stashDir, ".stash", "status.json"), "utf8")); - assert.deepEqual(status, { - kind: "error", - lastSync: null, - summary: null, - error: errorMessage, - }); - - const log = await readFile(join(stashDir, ".stash", "sync.log"), "utf8"); - assert.equal(log.includes(errorMessage), true); + const logPath = join(stashPath, "live-stash", "sync.log"); + const log = await readFile(logPath, "utf8"); + assert.ok(Buffer.byteLength(log, "utf8") <= 1_000_000); + assert.equal(log.includes("1↑ 2↓"), true); + assert.equal(log.includes("old-start"), false); } finally { await rm(workspace, { recursive: true, force: true }); } diff --git a/tests/unit/parse-uri.test.ts b/tests/unit/parse-uri.test.ts new file mode 100644 index 0000000..0e6de58 --- /dev/null +++ b/tests/unit/parse-uri.test.ts @@ -0,0 +1,22 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { InvalidUriError } from "../../src/errors.ts"; +import { parseUri } from "../../src/uri.ts"; + +test("parseUri splits on the first colon", () => { + assert.deepEqual(parseUri("github:owner/repo"), { + provider: "github", + config: { id: "owner/repo" }, + }); +}); + +test("parseUri preserves additional colons in the id", () => { + assert.deepEqual(parseUri("s3:bucket/prefix:with:colon"), { + provider: "s3", + config: { id: "bucket/prefix:with:colon" }, + }); +}); + +test("parseUri throws when the uri is missing a colon", () => { + assert.throws(() => parseUri("github"), InvalidUriError); +}); diff --git a/tests/unit/watch.test.ts b/tests/unit/watch.test.ts index f4bc287..2a2aa27 100644 --- a/tests/unit/watch.test.ts +++ b/tests/unit/watch.test.ts @@ -1,13 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { EventEmitter } from "node:events"; -import { mkdir } from "node:fs/promises"; -import { join } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; +import { join } from "node:path"; import { Emitter } from "../../src/emitter.ts"; -import { watch, Watch, type WatchStatus } from "../../src/watch.ts"; -import { makeStash } from "../helpers/make-stash.ts"; -import { FakeProvider } from "../helpers/fake-provider.ts"; +import { Watch, type WatchStatus } from "../../src/watch.ts"; import type { FileMutation } from "../../src/types.ts"; class FakeStash extends Emitter<{ mutation: FileMutation }> { @@ -21,32 +17,6 @@ class FakeStash extends Emitter<{ mutation: FileMutation }> { } } -class FakeInput extends EventEmitter { - isTTY = true; - resume(): void {} - setRawMode(): void {} -} - -class FakeOutput { - isTTY = true; - output = ""; - write(chunk: string): boolean { - this.output += chunk; - return true; - } -} - -function fakeRegistry(fake: FakeProvider) { - class TestProvider { - static spec = { setup: [], connect: [{ name: "repo", label: "Repo" }] }; - constructor() { - return fake; - } - } - - return { fake: TestProvider as any }; -} - test("watch: performs an initial check even when no providers are connected", async () => { const stash = new FakeStash(); const statuses: WatchStatus[] = []; @@ -183,33 +153,3 @@ test("watch: falls back to poll-only when subscribe throws", async () => { await watch.stop(); }); - -test("watch: git safety error is rendered with retry guidance", async () => { - const fake = new FakeProvider(); - const { stash, dir } = await makeStash( - { "hello.md": "hello" }, - { providers: fakeRegistry(fake) }, - ); - await stash.connect({ name: "fake", provider: "fake", repo: "r" }); - const stdin = new FakeInput(); - const stdout = new FakeOutput(); - - await mkdir(join(dir, ".git"), { recursive: true }); - - const watchPromise = watch(stash, { - dir, - stdin: stdin as unknown as NodeJS.ReadStream, - stdout: stdout as unknown as NodeJS.WriteStream, - pollMs: 50, - subscribe: async () => ({ - unsubscribe: async () => {}, - }), - }); - - await delay(60); - stdin.emit("data", Buffer.from("q")); - await watchPromise; - - assert.equal(stdout.output.includes("git repository detected"), true); - assert.equal(stdout.output.includes("retrying in"), true); -}); From 75894d80009cac85ae47a3430b6a8e74a68e6224 Mon Sep 17 00:00:00 2001 From: Rupert Manfredi Date: Tue, 5 May 2026 22:59:46 -0400 Subject: [PATCH 3/9] =?UTF-8?q?chore(stash):=20code-review=20fix-up=20?= =?UTF-8?q?=E2=80=94=20drop=20legacy=20in-folder=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses an eight-item review pass on the external-sync-target implementation: - BLOCKER: remove legacy in-folder .stash/ migration layer that the spec puts out-of-scope. Deletes src/local-config.ts, src/migrations.ts, the legacy fallback in Stash.load, and the readLocalConfig/writeLocalConfig calls in Stash.sync. Stash.load now requires an id. - Daemon reload(): coalesce concurrent calls — queue at most one rerun, not silently drop registry events that arrive mid-reload. - Daemon: orphan GC also runs on every reload(), not only at startup. - parseUri: reject empty provider or empty id with InvalidUriError. - CLI: drop --repo legacy alias and the field.name === "id" shim. - runConnect: skip writing global config when no global values changed. - withRegistryLock: unlink the lock file inside finally before removing signal handlers, so a SIGINT in cleanup can't leak the lock. - Spec: align status example with the CLI's current "Last error" only-on-error rendering. Tests for the deleted migration paths are also removed; legacy-aware tests rewritten to read metadata from the registry directly. 185/185 tests passing. Lint and build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cli-main.ts | 31 +-- src/daemon.ts | 84 ++++--- src/errors.ts | 8 +- src/local-config.ts | 52 ----- src/migrations.ts | 139 ----------- src/registry-lock.ts | 23 +- src/stash.ts | 105 +-------- src/uri.ts | 12 +- tests/helpers/assert-migration.ts | 71 ------ tests/helpers/make-stash.ts | 12 +- .../background-cli.integration.test.ts | 29 ++- .../stash-config.integration.test.ts | 40 ++-- tests/integration/stash-sync.test.ts | 75 +++--- tests/unit/daemon.test.ts | 125 ++++++++++ tests/unit/local-config.test.ts | 19 -- tests/unit/migrations.test.ts | 215 ------------------ tests/unit/parse-uri.test.ts | 8 + tests/unit/stash-config.test.ts | 19 +- tests/unit/stash-scan.test.ts | 1 + tests/unit/stash-sync-lock.test.ts | 30 +-- 20 files changed, 359 insertions(+), 739 deletions(-) delete mode 100644 src/local-config.ts delete mode 100644 src/migrations.ts delete mode 100644 tests/helpers/assert-migration.ts delete mode 100644 tests/unit/local-config.test.ts delete mode 100644 tests/unit/migrations.test.ts diff --git a/src/cli-main.ts b/src/cli-main.ts index bbc3001..06f30de 100644 --- a/src/cli-main.ts +++ b/src/cli-main.ts @@ -79,13 +79,7 @@ function getCliValue( valuesFromCli: Record, ): string | undefined { const value = valuesFromCli[field.name]; - if (typeof value === "string") { - return value; - } - if (field.name === "id" && typeof valuesFromCli.repo === "string") { - return valuesFromCli.repo; - } - return undefined; + return typeof value === "string" ? value : undefined; } async function promptField(field: ConfigItem): Promise { @@ -118,9 +112,6 @@ async function collectFields( function addFieldOptions(command: Command, fields: ConfigItem[]): void { for (const field of fields) { command.addOption(new Option(`--${field.name} `, field.label)); - if (field.name === "id") { - command.addOption(new Option("--repo ", `${field.label} (legacy alias)`)); - } } } @@ -147,6 +138,15 @@ function sameConnection( return left.provider === right.provider && JSON.stringify(left.config) === JSON.stringify(right.config); } +function sameStringRecord(left: Record, right: Record): boolean { + const leftKeys = Object.keys(left).sort((a, b) => a.localeCompare(b)); + const rightKeys = Object.keys(right).sort((a, b) => a.localeCompare(b)); + if (leftKeys.length !== rightKeys.length) { + return false; + } + return leftKeys.every((key, index) => key === rightKeys[index] && left[key] === right[key]); +} + async function isExecutable(path: string): Promise { try { await access(path, constants.X_OK); @@ -320,20 +320,23 @@ async function runConnect( const parsed = parseUri(uri); const provider = getProvider(parsed.provider); let globalConfig = await deps.readGlobalConfig(); + const existingGlobalValues = getProviderConfig(globalConfig, parsed.provider); const globalValues = await collectFields( getGlobalConfigItems(provider), valuesFromCli, - getProviderConfig(globalConfig, parsed.provider), + existingGlobalValues, ); new provider({ - ...getProviderConfig(globalConfig, parsed.provider), + ...existingGlobalValues, ...globalValues, ...parsed.config, }); - globalConfig = setProviderConfig(globalConfig, parsed.provider, globalValues); - await deps.writeGlobalConfig(globalConfig); + if (!sameStringRecord(globalValues, existingGlobalValues)) { + globalConfig = setProviderConfig(globalConfig, parsed.provider, globalValues); + await deps.writeGlobalConfig(globalConfig); + } const stashPath = deps.stashPath ?? getStashPath(); const outcome = await withRegistryLock( diff --git a/src/daemon.ts b/src/daemon.ts index 47fe114..9ad0524 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -60,6 +60,7 @@ type RegistrySnapshot = { live: Map; missing: Map; tombstones: Set; + orphans: Set; }; type RunningEntry = { @@ -169,6 +170,7 @@ export class BackgroundDaemon { private started = false; private stopping = false; private reloadPromise: Promise | null = null; + private pendingReload = false; private pollTimer: NodeJS.Timeout | null = null; constructor(options: BackgroundDaemonOptions = {}) { @@ -268,44 +270,53 @@ export class BackgroundDaemon { private async reload(): Promise { if (this.reloadPromise) { + this.pendingReload = true; return this.reloadPromise; } this.reloadPromise = (async () => { - const globalConfig = normalizeGlobalConfig(await this.readGlobalConfigFn()); - const snapshot = await this.readRegistrySnapshot(globalConfig); - const retainedIds = new Set([ - ...snapshot.live.keys(), - ...snapshot.missing.keys(), - ...snapshot.tombstones, - ]); - - for (const id of snapshot.tombstones) { - await this.stopRunning(id); - await this.deleteTombstoneDir(id); - } + do { + this.pendingReload = false; + + const globalConfig = normalizeGlobalConfig(await this.readGlobalConfigFn()); + const snapshot = await this.readRegistrySnapshot(globalConfig); + const retainedIds = new Set([ + ...snapshot.live.keys(), + ...snapshot.missing.keys(), + ...snapshot.tombstones, + ]); + + for (const id of snapshot.orphans) { + await this.deleteOrphanDirIfOld(id); + } - for (const [id] of snapshot.missing) { - await this.stopRunning(id); - await this.persistMissingStatus(id); - } + for (const id of snapshot.tombstones) { + await this.stopRunning(id); + await this.deleteTombstoneDir(id); + } - for (const id of [...this.running.keys()]) { - if (!retainedIds.has(id)) { + for (const [id] of snapshot.missing) { await this.stopRunning(id); + await this.persistMissingStatus(id); } - } - for (const [id, entry] of snapshot.live) { - const current = this.running.get(id); - if (current && current.key === entry.key) { - continue; + for (const id of [...this.running.keys()]) { + if (!retainedIds.has(id)) { + await this.stopRunning(id); + } } - if (current) { - await this.stopRunning(id); + + for (const [id, entry] of snapshot.live) { + const current = this.running.get(id); + if (current && current.key === entry.key) { + continue; + } + if (current) { + await this.stopRunning(id); + } + await this.startWatch(entry, globalConfig); } - await this.startWatch(entry, globalConfig); - } + } while (this.pendingReload && !this.stopping); })(); try { @@ -351,10 +362,12 @@ export class BackgroundDaemon { const live = new Map(); const missing = new Map(); const tombstones = new Set(); + const orphans = new Set(); for (const id of await this.listStashIds()) { const record = await this.readStashRecordIfExists(id); if (!record) { + orphans.add(id); continue; } @@ -385,7 +398,7 @@ export class BackgroundDaemon { } } - return { live, missing, tombstones }; + return { live, missing, tombstones, orphans }; } private async listStashIds(): Promise { @@ -470,15 +483,12 @@ export class BackgroundDaemon { } private async runStartupGc(): Promise { - for (const id of await this.listStashIds()) { - const record = await this.readStashRecordIfExists(id); - if (!record) { - await this.deleteOrphanDirIfOld(id); - continue; - } - if (record.connections.length === 0) { - await this.deleteTombstoneDir(id); - } + const snapshot = await this.readRegistrySnapshot(normalizeGlobalConfig(await this.readGlobalConfigFn())); + for (const id of snapshot.orphans) { + await this.deleteOrphanDirIfOld(id); + } + for (const id of snapshot.tombstones) { + await this.deleteTombstoneDir(id); } } } diff --git a/src/errors.ts b/src/errors.ts index a6ebe72..1e6d9d0 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -14,7 +14,7 @@ export class SyncLockError extends Error { export class GitRepoError extends Error { constructor( - message = "git repository detected — run `stash config set allow-git true` to allow syncing", + message = "git repository detected — pass `--dangerously-allow-git` when connecting or relinking this folder", ) { super(message); this.name = "GitRepoError"; @@ -23,7 +23,7 @@ export class GitRepoError extends Error { export class MultipleConnectionsError extends Error { constructor( - message = "Only one connection per stash — disconnect the existing connection before adding another (or fix .stash/config.json if it lists more than one).", + message = "Only one connection per stash — disconnect the existing connection before adding another (or fix the stash.json metadata if it lists more than one).", ) { super(message); this.name = "MultipleConnectionsError"; @@ -38,8 +38,8 @@ export class MigrationError extends Error { } export class InvalidUriError extends Error { - constructor(uri: string) { - super(`Invalid URI: ${uri}`); + constructor(uri: string, reason = "expected :") { + super(`Invalid URI: ${uri} (${reason})`); this.name = "InvalidUriError"; } } diff --git a/src/local-config.ts b/src/local-config.ts deleted file mode 100644 index 8f6ec45..0000000 --- a/src/local-config.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { join } from "node:path"; -import type { ConnectionConfig } from "./types.ts"; -import { readJsonFile, writeJsonFile } from "./utils/fs.ts"; - -export type LocalConfig = { - connections: Record; - "allow-git"?: boolean; -}; - -function asConnectionConfig(value: unknown): ConnectionConfig | null { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return null; - } - const entries = Object.entries(value as Record).filter( - ([, fieldValue]) => typeof fieldValue === "string", - ); - const config = Object.fromEntries(entries) as Record; - return typeof config.provider === "string" ? (config as ConnectionConfig) : null; -} - -export function normalizeLocalConfig(value: unknown): LocalConfig { - const raw = value && typeof value === "object" ? (value as Record) : {}; - const connectionsValue = - raw.connections && typeof raw.connections === "object" && !Array.isArray(raw.connections) - ? (raw.connections as Record) - : {}; - const connections: Record = {}; - for (const [name, connectionConfig] of Object.entries(connectionsValue)) { - const normalized = asConnectionConfig(connectionConfig); - if (normalized) { - connections[name] = normalized; - } - } - - const allowGit = typeof raw["allow-git"] === "boolean" ? raw["allow-git"] : undefined; - return { - connections, - ...(allowGit === undefined ? {} : { "allow-git": allowGit }), - }; -} - -export function getLocalConfigPath(dir: string): string { - return join(dir, ".stash", "config.json"); -} - -export async function readLocalConfig(dir: string): Promise { - return normalizeLocalConfig(await readJsonFile(getLocalConfigPath(dir), { connections: {} })); -} - -export async function writeLocalConfig(dir: string, config: LocalConfig): Promise { - await writeJsonFile(getLocalConfigPath(dir), normalizeLocalConfig(config)); -} diff --git a/src/migrations.ts b/src/migrations.ts deleted file mode 100644 index beed15a..0000000 --- a/src/migrations.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { existsSync } from "node:fs"; -import { rename } from "node:fs/promises"; -import { join } from "node:path"; -import { MigrationError } from "./errors.ts"; -import { readJsonFile, writeJsonFile } from "./utils/fs.ts"; - -type Migration = { - name: string; - check(dir: string): Promise | boolean; - apply(dir: string): Promise; -}; - -const OLD_LOCAL_CONFIG_PATH = ".stash/config.local.json"; -const NEW_LOCAL_CONFIG_PATH = ".stash/config.json"; -const OLD_SNAPSHOT_DIR = ".stash/snapshot.local"; -const NEW_SNAPSHOT_DIR = ".stash/snapshot"; - -function resolvePath(dir: string, relativePath: string): string { - return join(dir, relativePath); -} - -function hasPath(dir: string, relativePath: string): boolean { - return existsSync(resolvePath(dir, relativePath)); -} - -const renameLocalMetadataLayoutMigration: Migration = { - name: "rename-local-metadata-layout", - check(dir: string): boolean { - return ( - hasPath(dir, OLD_LOCAL_CONFIG_PATH) || - hasPath(dir, OLD_SNAPSHOT_DIR) || - (hasPath(dir, OLD_LOCAL_CONFIG_PATH) && hasPath(dir, NEW_LOCAL_CONFIG_PATH)) || - (hasPath(dir, OLD_SNAPSHOT_DIR) && hasPath(dir, NEW_SNAPSHOT_DIR)) - ); - }, - async apply(dir: string): Promise { - const hasOldConfig: boolean = hasPath(dir, OLD_LOCAL_CONFIG_PATH); - const hasNewConfig: boolean = hasPath(dir, NEW_LOCAL_CONFIG_PATH); - if (hasOldConfig && hasNewConfig) { - throw new MigrationError( - `Both ${OLD_LOCAL_CONFIG_PATH} and ${NEW_LOCAL_CONFIG_PATH} exist. Resolve this manually before continuing.`, - ); - } - if (hasOldConfig) { - await rename( - resolvePath(dir, OLD_LOCAL_CONFIG_PATH), - resolvePath(dir, NEW_LOCAL_CONFIG_PATH), - ); - } - - const hasOldSnapshot: boolean = hasPath(dir, OLD_SNAPSHOT_DIR); - const hasNewSnapshot: boolean = hasPath(dir, NEW_SNAPSHOT_DIR); - if (hasOldSnapshot && hasNewSnapshot) { - throw new MigrationError( - `Both ${OLD_SNAPSHOT_DIR} and ${NEW_SNAPSHOT_DIR} exist. Resolve this manually before continuing.`, - ); - } - if (hasOldSnapshot) { - await rename(resolvePath(dir, OLD_SNAPSHOT_DIR), resolvePath(dir, NEW_SNAPSHOT_DIR)); - } - }, -}; - -const backfillConnectionProviderMigration: Migration = { - name: "backfill-connection-provider", - async check(dir: string): Promise { - if (!hasPath(dir, NEW_LOCAL_CONFIG_PATH)) { - return false; - } - const config = await readJsonFile(resolvePath(dir, NEW_LOCAL_CONFIG_PATH), {}); - const raw = - config && typeof config === "object" && !Array.isArray(config) - ? (config as Record) - : {}; - const connections = - raw.connections && typeof raw.connections === "object" && !Array.isArray(raw.connections) - ? (raw.connections as Record) - : {}; - return Object.values(connections).some( - (connection) => - !connection || - typeof connection !== "object" || - Array.isArray(connection) || - typeof (connection as Record).provider !== "string", - ); - }, - async apply(dir: string): Promise { - const path = resolvePath(dir, NEW_LOCAL_CONFIG_PATH); - const config = await readJsonFile(path, {}); - const raw = - config && typeof config === "object" && !Array.isArray(config) - ? ({ ...config } as Record) - : {}; - const rawConnections = - raw.connections && typeof raw.connections === "object" && !Array.isArray(raw.connections) - ? (raw.connections as Record) - : {}; - const connections: Record> = {}; - - for (const [name, connection] of Object.entries(rawConnections)) { - const record = - connection && typeof connection === "object" && !Array.isArray(connection) - ? ({ ...(connection as Record) } as Record) - : {}; - if (typeof record.provider !== "string") { - record.provider = name; - } - connections[name] = record; - } - - await writeJsonFile(path, { - ...raw, - connections, - }); - }, -}; - -const migrations: Migration[] = [ - renameLocalMetadataLayoutMigration, - backfillConnectionProviderMigration, -]; - -export async function needsMigration(dir: string): Promise { - for (const migration of migrations) { - if (await migration.check(dir)) { - return true; - } - } - return false; -} - -export async function ensureMigration(dir: string): Promise { - for (const migration of migrations) { - if (!(await migration.check(dir))) { - continue; - } - await migration.apply(dir); - } -} diff --git a/src/registry-lock.ts b/src/registry-lock.ts index 59bca46..28d3b27 100644 --- a/src/registry-lock.ts +++ b/src/registry-lock.ts @@ -158,20 +158,19 @@ export async function withRegistryLock( } catch (error) { operationError = error; } finally { - cleanupListeners(); - } - - if (acquired) { - try { - const currentPayload = readFileSync(lockPath, "utf8"); - if (currentPayload === payload) { - unlinkSync(lockPath); - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - cleanupError = error; + if (acquired) { + try { + const currentPayload = readFileSync(lockPath, "utf8"); + if (currentPayload === payload) { + unlinkSync(lockPath); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + cleanupError = error; + } } } + cleanupListeners(); } if (operationError) { diff --git a/src/stash.ts b/src/stash.ts index fefaf5a..8ec6671 100644 --- a/src/stash.ts +++ b/src/stash.ts @@ -7,11 +7,9 @@ import { mkdirSync, readdirSync, readFileSync, - realpathSync, renameSync, rmSync, statSync, - symlinkSync, unlinkSync, writeFileSync, } from "node:fs"; @@ -32,8 +30,6 @@ import { getStashPath, normalizeGlobalConfig, } from "./global-config.ts"; -import { readLocalConfig, writeLocalConfig } from "./local-config.ts"; -import { ensureMigration } from "./migrations.ts"; import { providers as defaultProviders } from "./providers/index.ts"; import type { ChangeSet, @@ -61,7 +57,6 @@ export type StashEvents = { type StashOptions = { providers?: Record; stashPath?: string; - metadataDir?: string; }; function cloneSnapshot(snapshot: Record): Record { @@ -72,21 +67,6 @@ function sortPaths(paths: Iterable): string[] { return [...paths].sort((a, b) => a.localeCompare(b)); } -function stashConnectionsFromLocalConfig( - connections: Record, -): StashRecord["connections"] { - return Object.entries(connections).map(([name, connection]) => { - const { provider, ...fields } = connection; - return { - provider, - config: { - name, - ...fields, - }, - }; - }); -} - const STALE_SYNC_LOCK_MS = 10 * 60 * 1000; const SYNC_RETRY_LIMIT = 5; const NON_FILE_HASH = "__non-file__"; @@ -119,71 +99,20 @@ export class Stash extends Emitter { }; this.dir = record.path; this.stashPath = options?.stashPath ?? getStashPath(); - this.metadataDir = options?.metadataDir ?? getStashDirPath(id, this.stashPath); + this.metadataDir = getStashDirPath(id, this.stashPath); this.globalConfig = globalConfig; this._connections = this.connectionsFromRecord(this.record); this.providerRegistry = options?.providers ?? defaultProviders; } - static async load( - idOrDir: string, - globalConfig: GlobalConfig, - options?: StashOptions, - ): Promise { + static async load(id: string, globalConfig: GlobalConfig, options?: StashOptions): Promise { const stashPath = options?.stashPath ?? getStashPath(); - const directRecordPath = getStashRecordPath(idOrDir, stashPath); + const directRecordPath = getStashRecordPath(id, stashPath); if (existsSync(directRecordPath)) { const record = JSON.parse(readFileSync(directRecordPath, "utf8")) as StashRecord; - return new Stash(idOrDir, record, normalizeGlobalConfig(globalConfig), options); + return new Stash(id, record, normalizeGlobalConfig(globalConfig), options); } - - const localStashDir = join(idOrDir, ".stash"); - if (!existsSync(localStashDir)) { - throw new Error( - "This directory is not a stash. Run `stash connect ` first.", - ); - } - - await ensureMigration(idOrDir); - const localConfig = await readLocalConfig(idOrDir); - - const idPath = join(localStashDir, ".stash-id"); - const id = existsSync(idPath) ? readFileSync(idPath, "utf8").trim() : null; - const resolvedMetadataDir = realpathSync(localStashDir); - const resolvedId = id ?? resolvedMetadataDir.split("/").at(-1) ?? null; - - if (resolvedId) { - const recordPath = getStashRecordPath(resolvedId, stashPath); - if (existsSync(recordPath)) { - const record = JSON.parse(readFileSync(recordPath, "utf8")) as StashRecord; - const recordWithCompatConnections = - Object.keys(localConfig.connections).length > 0 - ? { - ...record, - connections: stashConnectionsFromLocalConfig(localConfig.connections), - } - : record; - return new Stash( - resolvedId, - recordWithCompatConnections, - normalizeGlobalConfig(globalConfig), - options, - ); - } - } - - return new Stash( - resolvedId ?? `legacy:${idOrDir}`, - { - path: idOrDir, - connections: stashConnectionsFromLocalConfig(localConfig.connections), - }, - normalizeGlobalConfig(globalConfig), - { - ...options, - metadataDir: localStashDir, - }, - ); + throw new Error(`Stash not found: ${id}`); } static async create( @@ -250,14 +179,6 @@ export class Stash extends Emitter { }); } - private writeLegacyLocalLink(): void { - const localStashDir = join(this.dir, ".stash"); - if (!existsSync(localStashDir)) { - symlinkSync(this.metadataDir, localStashDir, "dir"); - } - writeFileSync(join(this.metadataDir, ".stash-id"), `${this.id}\n`, "utf8"); - } - async connect({ name, ...configFields }: { name: string } & ConnectionConfig): Promise { const { provider, ...fields } = configFields; const existing = Object.keys(this._connections); @@ -265,12 +186,12 @@ export class Stash extends Emitter { throw new MultipleConnectionsError(); } this._connections[name] = { provider, ...fields }; - await this.persistLocalConfig(); + await this.persistRecord(); } async disconnect(name: string): Promise { delete this._connections[name]; - await this.persistLocalConfig(); + await this.persistRecord(); } async sync(): Promise { @@ -910,17 +831,9 @@ export class Stash extends Emitter { return readFileSync(snapshotPath, "utf8"); } - private async persistLocalConfig(): Promise { + private async persistRecord(): Promise { this.updateRecordConnections(); - if (this.metadataDir === getStashDirPath(this.id, this.stashPath)) { - await writeJsonFile(getStashRecordPath(this.id, this.stashPath), this.record); - } - this.writeLegacyLocalLink(); - const currentConfig = await readLocalConfig(this.dir); - await writeLocalConfig(this.dir, { - ...currentConfig, - connections: this._connections, - }); + await writeJsonFile(getStashRecordPath(this.id, this.stashPath), this.record); } private acquireSyncLock(): void { diff --git a/src/uri.ts b/src/uri.ts index 07deb8f..39a5933 100644 --- a/src/uri.ts +++ b/src/uri.ts @@ -5,10 +5,18 @@ export function parseUri(uri: string): { provider: string; config: { id: string if (separator < 0) { throw new InvalidUriError(uri); } + const provider = uri.slice(0, separator); + const id = uri.slice(separator + 1); + if (!provider) { + throw new InvalidUriError(uri, "missing provider before ':'"); + } + if (!id) { + throw new InvalidUriError(uri, "missing id after ':'"); + } return { - provider: uri.slice(0, separator), + provider, config: { - id: uri.slice(separator + 1), + id, }, }; } diff --git a/tests/helpers/assert-migration.ts b/tests/helpers/assert-migration.ts deleted file mode 100644 index 7030e1f..0000000 --- a/tests/helpers/assert-migration.ts +++ /dev/null @@ -1,71 +0,0 @@ -import assert from "node:assert/strict"; -import { existsSync } from "node:fs"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import type { SnapshotEntry } from "../../src/types.ts"; - -type LegacyConnectionConfig = Record & { provider?: string }; - -type LegacyLayoutOptions = { - connections?: Record; - snapshot?: Record; - snapshotLocal?: Record; - "allow-git"?: boolean; -}; - -export async function writeLegacyLayout( - dir: string, - options: LegacyLayoutOptions = {}, -): Promise { - await mkdir(join(dir, ".stash"), { recursive: true }); - await writeFile( - join(dir, ".stash", "config.local.json"), - JSON.stringify( - { - connections: options.connections ?? {}, - ...(options["allow-git"] === undefined ? {} : { "allow-git": options["allow-git"] }), - }, - null, - 2, - ), - "utf8", - ); - - if (options.snapshot) { - await writeFile( - join(dir, ".stash", "snapshot.json"), - JSON.stringify(options.snapshot, null, 2), - "utf8", - ); - } - - if (options.snapshotLocal) { - for (const [path, content] of Object.entries(options.snapshotLocal)) { - const target: string = join(dir, ".stash", "snapshot.local", path); - await mkdir(join(target, ".."), { recursive: true }); - await writeFile(target, content, "utf8"); - } - } -} - -export async function assertMigration( - dir: string, - expected: LegacyLayoutOptions = {}, -): Promise { - assert.equal(existsSync(join(dir, ".stash", "config.local.json")), false); - assert.equal(existsSync(join(dir, ".stash", "snapshot.local")), false); - assert.equal(existsSync(join(dir, ".stash", "config.json")), true); - - const config = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")) as Record< - string, - unknown - >; - assert.deepEqual(config.connections, expected.connections ?? {}); - if (expected["allow-git"] !== undefined) { - assert.equal(config["allow-git"], expected["allow-git"]); - } - - for (const [path, content] of Object.entries(expected.snapshotLocal ?? {})) { - assert.equal(await readFile(join(dir, ".stash", "snapshot", path), "utf8"), content); - } -} diff --git a/tests/helpers/make-stash.ts b/tests/helpers/make-stash.ts index dd91833..e4cb7ab 100644 --- a/tests/helpers/make-stash.ts +++ b/tests/helpers/make-stash.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { mkdtemp, mkdir, symlink, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getStashDirPath, normalizeGlobalConfig } from "../../src/global-config.ts"; @@ -29,7 +29,7 @@ export async function makeStash( globalConfig?: GlobalConfig; providers?: Record; }, -): Promise<{ stash: Stash; dir: string }> { +): Promise<{ stash: Stash; dir: string; stashPath: string; metadataDir: string }> { const dir = await makeTempDir(); const stashPath = join(dir, ".xdg", "stash"); const globalConfig = normalizeGlobalConfig(opts?.globalConfig ?? {}); @@ -48,11 +48,11 @@ export async function makeStash( stashPath, }, ); - await symlink(getStashDirPath(stash.id, stashPath), join(dir, ".stash"), "dir"); + const metadataDir = getStashDirPath(stash.id, stashPath); if (opts?.snapshot) { await writeFile( - join(dir, ".stash", "snapshot.json"), + join(metadataDir, "snapshot.json"), JSON.stringify(opts.snapshot, null, 2), "utf8", ); @@ -60,7 +60,7 @@ export async function makeStash( if (opts?.snapshotLocal) { for (const [path, content] of Object.entries(opts.snapshotLocal)) { - const target = join(dir, ".stash", "snapshot", path); + const target = join(metadataDir, "snapshot", path); await mkdir(join(target, ".."), { recursive: true }); await writeFile(target, content, "utf8"); } @@ -70,5 +70,5 @@ export async function makeStash( providers: opts?.providers, stashPath, }); - return { stash: loaded, dir }; + return { stash: loaded, dir, stashPath, metadataDir }; } diff --git a/tests/integration/background-cli.integration.test.ts b/tests/integration/background-cli.integration.test.ts index 4898733..d99055f 100644 --- a/tests/integration/background-cli.integration.test.ts +++ b/tests/integration/background-cli.integration.test.ts @@ -17,6 +17,7 @@ type RunResult = { stdout: string; stderr: string; config: GlobalConfig; + configWrites: number; calls: ServiceCalls; }; @@ -67,6 +68,7 @@ async function runMain( ); let stdout = ""; let stderr = ""; + let configWrites = 0; const calls: ServiceCalls = { install: 0, uninstall: 0, @@ -78,6 +80,7 @@ async function runMain( stashPath: getTestStashPath(cwd), readGlobalConfig: async () => structuredClone(config), writeGlobalConfig: async (nextConfig) => { + configWrites += 1; config = structuredClone(nextConfig); }, service: { @@ -111,7 +114,7 @@ async function runMain( } as NodeJS.WriteStream, }); - return { stdout, stderr, config, calls }; + return { stdout, stderr, config, configWrites, calls }; } test("connect creates a new stash record and stores provider globals separately", async () => { @@ -159,6 +162,30 @@ test("connect is idempotent for the same uri", async () => { } }); +test("connect skips rewriting global config when provider globals are unchanged", async () => { + const dir = await makeTempDir("stash-cli-connect-global-unchanged"); + + try { + const result = await runMain(dir, ["connect", "github:user/repo"], { + config: { + providers: { + github: { token: "test-token" }, + }, + background: { + stashes: [], + }, + }, + }); + + assert.equal(result.configWrites, 0); + assert.deepEqual(result.config.providers.github, { token: "test-token" }); + const { record } = await readOnlyStashRecord(getTestStashPath(dir)); + assert.deepEqual(record.connections, [{ provider: "github", config: { id: "user/repo" } }]); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test("connect blocks git repositories unless --dangerously-allow-git is passed", async () => { const dir = await makeTempDir("stash-cli-connect-git"); diff --git a/tests/integration/stash-config.integration.test.ts b/tests/integration/stash-config.integration.test.ts index e0c8e5f..04322e8 100644 --- a/tests/integration/stash-config.integration.test.ts +++ b/tests/integration/stash-config.integration.test.ts @@ -5,23 +5,27 @@ import { join } from "node:path"; import { MultipleConnectionsError } from "../../src/errors.ts"; import { makeStash } from "../helpers/make-stash.ts"; -test("integration config: connect writes config.json", async () => { - const { stash, dir } = await makeStash(); +test("integration config: connect writes stash.json", async () => { + const { stash, metadataDir } = await makeStash(); await stash.connect({ name: "origin", provider: "github", repo: "user/repo" }); - const config = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); - assert.deepEqual(config, { - connections: { - origin: { provider: "github", repo: "user/repo" }, + const record = JSON.parse(await readFile(join(metadataDir, "stash.json"), "utf8")); + assert.deepEqual(record.connections, [ + { + provider: "github", + config: { + name: "origin", + repo: "user/repo", + }, }, - }); + ]); }); -test("integration config: disconnect removes connection", async () => { - const { stash, dir } = await makeStash(); +test("integration config: disconnect removes the stash connection", async () => { + const { stash, metadataDir } = await makeStash(); await stash.connect({ name: "origin", provider: "github", repo: "user/repo" }); await stash.disconnect("origin"); - const config = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); - assert.deepEqual(config, { connections: {} }); + const record = JSON.parse(await readFile(join(metadataDir, "stash.json"), "utf8")); + assert.deepEqual(record.connections, []); }); test("integration config: connect rejects a second connection with a different name", async () => { @@ -34,11 +38,19 @@ test("integration config: connect rejects a second connection with a different n }); test("integration config: connect allows reconnecting the same name", async () => { - const { stash, dir } = await makeStash(); + const { stash, metadataDir } = await makeStash(); await stash.connect({ name: "origin", provider: "github", repo: "user/repo" }); await stash.connect({ name: "origin", provider: "github", repo: "user/updated" }); - const config = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); - assert.deepEqual(config.connections.origin, { provider: "github", repo: "user/updated" }); + const record = JSON.parse(await readFile(join(metadataDir, "stash.json"), "utf8")); + assert.deepEqual(record.connections, [ + { + provider: "github", + config: { + name: "origin", + repo: "user/updated", + }, + }, + ]); }); test("integration config: config getter merges global and local by provider type", async () => { diff --git a/tests/integration/stash-sync.test.ts b/tests/integration/stash-sync.test.ts index 864dc48..c251190 100644 --- a/tests/integration/stash-sync.test.ts +++ b/tests/integration/stash-sync.test.ts @@ -29,7 +29,7 @@ function deferred() { test("sync: first sync pushes all local files", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash( + const { stash, metadataDir } = await makeStash( { "hello.md": "hello", "notes/todo.md": "buy milk" }, { providers: fakeRegistry(fake) }, ); @@ -41,9 +41,9 @@ test("sync: first sync pushes all local files", async () => { assert.equal(fake.files.get("notes/todo.md"), "buy milk"); assert.equal(typeof fake.snapshot["hello.md"]?.hash, "string"); - const localSnapshot = JSON.parse(await readFile(join(dir, ".stash", "snapshot.json"), "utf8")); + const localSnapshot = JSON.parse(await readFile(join(metadataDir, "snapshot.json"), "utf8")); assert.deepEqual(localSnapshot, fake.snapshot); - assert.equal(await readFile(join(dir, ".stash", "snapshot", "hello.md"), "utf8"), "hello"); + assert.equal(await readFile(join(metadataDir, "snapshot", "hello.md"), "utf8"), "hello"); }); test("sync: first sync pulls all remote files", async () => { @@ -57,14 +57,14 @@ test("sync: first sync pulls all remote files", async () => { "data/config.json": { hash: hashBuffer(Buffer.from("{}", "utf8")) }, }, }); - const { stash, dir } = await makeStash({}, { providers: fakeRegistry(fake) }); + const { stash, dir, metadataDir } = await makeStash({}, { providers: fakeRegistry(fake) }); await stash.connect({ name: "fake", provider: "fake", repo: "r" }); await stash.sync(); assert.equal(await readFile(join(dir, "readme.md"), "utf8"), "welcome"); assert.equal(await readFile(join(dir, "data", "config.json"), "utf8"), "{}"); - const snapshot = JSON.parse(await readFile(join(dir, ".stash", "snapshot.json"), "utf8")); + const snapshot = JSON.parse(await readFile(join(metadataDir, "snapshot.json"), "utf8")); assert.deepEqual(snapshot, fake.snapshot); }); @@ -186,7 +186,7 @@ test("sync: single-flight guard rejects concurrent sync", async () => { test("sync: snapshot writes text files and skips binary files", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash( + const { stash, metadataDir } = await makeStash( { "text.md": "hello", "img.bin": Buffer.from([0xff, 0xfe, 0x00]), @@ -196,23 +196,23 @@ test("sync: snapshot writes text files and skips binary files", async () => { await stash.connect({ name: "fake", provider: "fake", repo: "r" }); await stash.sync(); - assert.equal(await readFile(join(dir, ".stash", "snapshot", "text.md"), "utf8"), "hello"); - assert.equal(existsSync(join(dir, ".stash", "snapshot", "img.bin")), false); + assert.equal(await readFile(join(metadataDir, "snapshot", "text.md"), "utf8"), "hello"); + assert.equal(existsSync(join(metadataDir, "snapshot", "img.bin")), false); }); test("sync: deleting a file removes it from snapshot", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash( + const { stash, dir, metadataDir } = await makeStash( { "hello.md": "hello" }, { providers: fakeRegistry(fake) }, ); await stash.connect({ name: "fake", provider: "fake", repo: "r" }); await stash.sync(); - assert.equal(existsSync(join(dir, ".stash", "snapshot", "hello.md")), true); + assert.equal(existsSync(join(metadataDir, "snapshot", "hello.md")), true); await unlink(join(dir, "hello.md")); await stash.sync(); - assert.equal(existsSync(join(dir, ".stash", "snapshot", "hello.md")), false); + assert.equal(existsSync(join(metadataDir, "snapshot", "hello.md")), false); }); test("sync: first sync with identical content on both sides skips file writes", async () => { @@ -226,7 +226,7 @@ test("sync: first sync with identical content on both sides skips file writes", "notes/todo.md": { hash: hashBuffer(Buffer.from("buy milk", "utf8")) }, }, }); - const { stash, dir } = await makeStash( + const { stash, dir, metadataDir } = await makeStash( { "hello.md": "hello", "notes/todo.md": "buy milk" }, { providers: fakeRegistry(fake) }, ); @@ -242,35 +242,35 @@ test("sync: first sync with identical content on both sides skips file writes", assert.equal(await readFile(join(dir, "hello.md"), "utf8"), "hello"); assert.equal(await readFile(join(dir, "notes/todo.md"), "utf8"), "buy milk"); - const localSnapshot = JSON.parse(await readFile(join(dir, ".stash", "snapshot.json"), "utf8")); + const localSnapshot = JSON.parse(await readFile(join(metadataDir, "snapshot.json"), "utf8")); assert.equal(localSnapshot["hello.md"]?.hash, hashBuffer(Buffer.from("hello", "utf8"))); assert.equal(localSnapshot["notes/todo.md"]?.hash, hashBuffer(Buffer.from("buy milk", "utf8"))); - assert.equal(await readFile(join(dir, ".stash", "snapshot", "hello.md"), "utf8"), "hello"); + assert.equal(await readFile(join(metadataDir, "snapshot", "hello.md"), "utf8"), "hello"); assert.equal( - await readFile(join(dir, ".stash", "snapshot", "notes/todo.md"), "utf8"), + await readFile(join(metadataDir, "snapshot", "notes/todo.md"), "utf8"), "buy milk", ); }); test("sync: multiple connections throws", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash( + const { stash, metadataDir, stashPath } = await makeStash( { "hello.md": "hello" }, { providers: fakeRegistry(fake) }, ); await stash.connect({ name: "first", provider: "fake", repo: "r1" }); // Bypass the connect guard by writing a second connection directly - const configPath = join(dir, ".stash", "config.json"); - const config = JSON.parse(await readFile(configPath, "utf8")); - config.connections.second = { provider: "fake", repo: "r2" }; - await writeFile(configPath, JSON.stringify(config, null, 2), "utf8"); + const recordPath = join(metadataDir, "stash.json"); + const record = JSON.parse(await readFile(recordPath, "utf8")); + record.connections.push({ provider: "fake", config: { name: "second", repo: "r2" } }); + await writeFile(recordPath, JSON.stringify(record, null, 2), "utf8"); // Reload to pick up both connections const { Stash } = await import("../../src/stash.ts"); const reloaded = await Stash.load( - dir, + stash.id, { providers: {}, background: { stashes: [] } }, - { providers: fakeRegistry(fake) }, + { providers: fakeRegistry(fake), stashPath }, ); await assert.rejects(reloaded.sync(), MultipleConnectionsError); @@ -280,7 +280,7 @@ test("sync: multiple connections throws", async () => { test("sync: skip/skip mutations with changed snapshot still pushes snapshot", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash( + const { stash, dir, metadataDir } = await makeStash( { "hello.md": "hello" }, { providers: fakeRegistry(fake) }, ); @@ -300,7 +300,7 @@ test("sync: skip/skip mutations with changed snapshot still pushes snapshot", as assert.equal(lastPush.files.size, 0, "no file content should be pushed"); assert.deepEqual(lastPush.deletions, [], "no deletions should be pushed"); - const localSnapshot = JSON.parse(await readFile(join(dir, ".stash", "snapshot.json"), "utf8")); + const localSnapshot = JSON.parse(await readFile(join(metadataDir, "snapshot.json"), "utf8")); assert.equal(localSnapshot["hello.md"]?.hash, hashBuffer(Buffer.from("updated", "utf8"))); }); @@ -347,7 +347,10 @@ test("sync: preserves local edits made after scan but before push (pre-push race const bobRemote = "line1\nline2\nline3\nBOB_END\n"; const fake = new FakeProvider(); - const { stash, dir } = await makeStash({ "doc.md": baseline }, { providers: fakeRegistry(fake) }); + const { stash, dir } = await makeStash( + { "doc.md": baseline }, + { providers: fakeRegistry(fake) }, + ); await stash.connect({ name: "fake", provider: "fake", repo: "r" }); await stash.sync(); @@ -388,7 +391,10 @@ test("sync: preserves local edits made after push but before apply (post-push ra const bobRemote = "line1\nline2\nline3\nBOB_END\n"; const fake = new FakeProvider(); - const { stash, dir } = await makeStash({ "doc.md": baseline }, { providers: fakeRegistry(fake) }); + const { stash, dir } = await makeStash( + { "doc.md": baseline }, + { providers: fakeRegistry(fake) }, + ); await stash.connect({ name: "fake", provider: "fake", repo: "r" }); await stash.sync(); @@ -432,7 +438,10 @@ test("sync: drift retries are bounded and failed cycle does not apply/save", asy const bobRemote = "line1\nline2\nline3\nBOB_END\n"; const fake = new FakeProvider(); - const { stash, dir } = await makeStash({ "doc.md": baseline }, { providers: fakeRegistry(fake) }); + const { stash, dir, metadataDir } = await makeStash( + { "doc.md": baseline }, + { providers: fakeRegistry(fake) }, + ); await stash.connect({ name: "fake", provider: "fake", repo: "r" }); await stash.sync(); @@ -440,7 +449,7 @@ test("sync: drift retries are bounded and failed cycle does not apply/save", asy fake.snapshot["doc.md"] = { hash: hashBuffer(Buffer.from(bobRemote, "utf8")) }; await writeFiles(dir, { "doc.md": aliceEarly }); - const beforeSnapshot = JSON.parse(await readFile(join(dir, ".stash", "snapshot.json"), "utf8")); + const beforeSnapshot = JSON.parse(await readFile(join(metadataDir, "snapshot.json"), "utf8")); fake.fetchCalls = 0; fake.pushCalls = 0; @@ -451,13 +460,13 @@ test("sync: drift retries are bounded and failed cycle does not apply/save", asy assert.equal(fake.pushCalls, 0); assert.equal(await readFile(join(dir, "doc.md"), "utf8"), aliceEarly); - const afterSnapshot = JSON.parse(await readFile(join(dir, ".stash", "snapshot.json"), "utf8")); + const afterSnapshot = JSON.parse(await readFile(join(metadataDir, "snapshot.json"), "utf8")); assert.deepEqual(afterSnapshot, beforeSnapshot); }); test("sync: case-only rename syncs successfully", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash( + const { stash, dir, metadataDir } = await makeStash( { "notes/Arabella.md": "hello" }, { providers: fakeRegistry(fake) }, ); @@ -477,7 +486,7 @@ test("sync: case-only rename syncs successfully", async () => { // Old-case path should be gone from remote assert.equal(fake.files.has("notes/Arabella.md"), false); // Snapshot updated to new casing - const snapshot = JSON.parse(await readFile(join(dir, ".stash", "snapshot.json"), "utf8")); + const snapshot = JSON.parse(await readFile(join(metadataDir, "snapshot.json"), "utf8")); assert.ok(snapshot["notes/arabella.md"]); assert.equal(snapshot["notes/Arabella.md"], undefined); }); @@ -579,7 +588,7 @@ test("sync: nested directory case rename applies correctly", async () => { test("sync: true deletion still works alongside casing check", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash( + const { stash, dir, metadataDir } = await makeStash( { "notes/Arabella.md": "hello", "other.md": "keep" }, { providers: fakeRegistry(fake) }, ); @@ -592,6 +601,6 @@ test("sync: true deletion still works alongside casing check", async () => { assert.equal(fake.files.has("notes/Arabella.md"), false); assert.equal(fake.files.get("other.md"), "keep"); - const snapshot = JSON.parse(await readFile(join(dir, ".stash", "snapshot.json"), "utf8")); + const snapshot = JSON.parse(await readFile(join(metadataDir, "snapshot.json"), "utf8")); assert.equal(snapshot["notes/Arabella.md"], undefined); }); diff --git a/tests/unit/daemon.test.ts b/tests/unit/daemon.test.ts index 7709363..d5fec1e 100644 --- a/tests/unit/daemon.test.ts +++ b/tests/unit/daemon.test.ts @@ -185,6 +185,88 @@ test("daemon: poll-only mode still reconciles new registry entries", async () => } }); +test("daemon: queues one immediate follow-up reload when changes arrive mid-reconcile", async () => { + const workspace = await makeTempDir("stash-daemon-pending-reload"); + const stashPath = join(workspace, "stash"); + const configPath = join(stashPath, "config.json"); + const dirA = join(workspace, "dir-a"); + const dirB = join(workspace, "dir-b"); + const started: string[] = []; + const stopped: string[] = []; + const callbacks = new Map(); + let releaseStop: (() => void) | null = null; + let markStopEntered: (() => void) | null = null; + const stopEntered = new Promise((resolve) => { + markStopEntered = resolve; + }); + const stopGate = new Promise((resolve) => { + releaseStop = resolve; + }); + + try { + await mkdir(dirA, { recursive: true }); + await mkdir(dirB, { recursive: true }); + await writeConfig(configPath, { + github: { token: "token-a" }, + }); + await writeStashRecord(stashPath, "stash-a", { + path: dirA, + connections: [{ provider: "github", config: { id: "user/repo-a" } }], + }); + + const daemon = new BackgroundDaemon({ + configPath, + pollMs: 10_000, + subscribe: async (path, callback) => { + callbacks.set(path, callback as WatchCallback); + return { + unsubscribe: async () => { + callbacks.delete(path); + }, + }; + }, + createWatch: async ({ stash }) => ({ + start: async () => { + started.push(stash.id); + }, + stop: async () => { + stopped.push(stash.id); + if (stash.id === "stash-a" && stopped.length === 1) { + markStopEntered?.(); + await stopGate; + } + }, + }), + }); + + await daemon.start(); + assert.deepEqual(started, ["stash-a"]); + + await writeConfig(configPath, { + github: { token: "token-b" }, + }); + callbacks.get(configPath)?.(null, [{ type: "update", path: configPath }]); + await stopEntered; + + await writeStashRecord(stashPath, "stash-b", { + path: dirB, + connections: [{ provider: "github", config: { id: "user/repo-b" } }], + }); + callbacks.get(stashPath)?.(null, [{ type: "create", path: join(stashPath, "stash-b") }]); + + releaseStop?.(); + await delay(80); + + assert.deepEqual(stopped, ["stash-a"]); + assert.deepEqual(started, ["stash-a", "stash-a", "stash-b"]); + + await daemon.stop(); + } finally { + releaseStop?.(); + await rm(workspace, { recursive: true, force: true }); + } +}); + test("daemon: writes missing status and revalidates tombstone deletion after stop", async () => { const workspace = await makeTempDir("stash-daemon-missing"); const stashPath = join(workspace, "stash"); @@ -273,6 +355,49 @@ test("daemon: writes missing status and revalidates tombstone deletion after sto } }); +test("daemon: reload GC removes stale orphan dirs added after startup", async () => { + const workspace = await makeTempDir("stash-daemon-reload-orphan"); + const stashPath = join(workspace, "stash"); + const configPath = join(stashPath, "config.json"); + const callbacks = new Map(); + + try { + await writeConfig(configPath, {}); + + const daemon = new BackgroundDaemon({ + configPath, + pollMs: 10_000, + subscribe: async (path, callback) => { + callbacks.set(path, callback as WatchCallback); + return { + unsubscribe: async () => { + callbacks.delete(path); + }, + }; + }, + createWatch: async () => { + throw new Error("no watches expected"); + }, + }); + + await daemon.start(); + + const orphanDir = join(stashPath, "old-orphan"); + await mkdir(orphanDir, { recursive: true }); + const oldDate = new Date(Date.now() - 10 * 60_000); + await utimes(orphanDir, oldDate, oldDate); + + callbacks.get(stashPath)?.(null, [{ type: "create", path: orphanDir }]); + await delay(80); + + assert.equal(existsSync(orphanDir), false); + + await daemon.stop(); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + test("daemon: startup GC removes old incomplete dirs and keeps fresh incomplete dirs", async () => { const workspace = await makeTempDir("stash-daemon-gc"); const stashPath = join(workspace, "stash"); diff --git a/tests/unit/local-config.test.ts b/tests/unit/local-config.test.ts deleted file mode 100644 index 5af8019..0000000 --- a/tests/unit/local-config.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { normalizeLocalConfig } from "../../src/local-config.ts"; - -test("normalizeLocalConfig skips connections missing provider", () => { - assert.deepEqual( - normalizeLocalConfig({ - connections: { - origin: { repo: "user/repo" }, - backup: { provider: "github", repo: "user/repo" }, - }, - }), - { - connections: { - backup: { provider: "github", repo: "user/repo" }, - }, - }, - ); -}); diff --git a/tests/unit/migrations.test.ts b/tests/unit/migrations.test.ts deleted file mode 100644 index a68a264..0000000 --- a/tests/unit/migrations.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { existsSync } from "node:fs"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { MigrationError } from "../../src/errors.ts"; -import { ensureMigration } from "../../src/migrations.ts"; -import { Stash } from "../../src/stash.ts"; -import type { ChangeSet, Provider, ProviderClass } from "../../src/types.ts"; -import { makeTempDir, writeFiles } from "../helpers/make-stash.ts"; - -test("ensureMigration: moves config.local.json to config.json", async () => { - const dir = await makeTempDir("migrate-config"); - try { - await mkdir(join(dir, ".stash"), { recursive: true }); - await writeFile( - join(dir, ".stash", "config.local.json"), - JSON.stringify({ connections: { github: { repo: "user/repo" } } }, null, 2), - "utf8", - ); - - await ensureMigration(dir); - - assert.equal(existsSync(join(dir, ".stash", "config.local.json")), false); - assert.deepEqual(JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")), { - connections: { - github: { provider: "github", repo: "user/repo" }, - }, - }); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("ensureMigration: moves snapshot.local to snapshot", async () => { - const dir = await makeTempDir("migrate-snapshot"); - try { - await mkdir(join(dir, ".stash", "snapshot.local"), { recursive: true }); - await writeFile(join(dir, ".stash", "snapshot.local", "hello.md"), "hello", "utf8"); - - await ensureMigration(dir); - - assert.equal(existsSync(join(dir, ".stash", "snapshot.local")), false); - assert.equal(await readFile(join(dir, ".stash", "snapshot", "hello.md"), "utf8"), "hello"); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("ensureMigration: no-ops when the new layout already exists", async () => { - const dir = await makeTempDir("migrate-noop"); - try { - await mkdir(join(dir, ".stash", "snapshot"), { recursive: true }); - await writeFile( - join(dir, ".stash", "config.json"), - JSON.stringify({ connections: {} }, null, 2), - "utf8", - ); - - await ensureMigration(dir); - - assert.equal(existsSync(join(dir, ".stash", "config.json")), true); - assert.equal(existsSync(join(dir, ".stash", "snapshot")), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("ensureMigration: backfills provider when config.json connection is missing it", async () => { - const dir = await makeTempDir("migrate-provider"); - try { - await mkdir(join(dir, ".stash"), { recursive: true }); - await writeFile( - join(dir, ".stash", "config.json"), - JSON.stringify({ connections: { github: { repo: "user/repo" } } }, null, 2), - "utf8", - ); - - await ensureMigration(dir); - - assert.deepEqual(JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")), { - connections: { - github: { provider: "github", repo: "user/repo" }, - }, - }); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("ensureMigration: does not rewrite connections that already have a provider", async () => { - const dir = await makeTempDir("migrate-provider-noop"); - try { - await mkdir(join(dir, ".stash"), { recursive: true }); - await writeFile( - join(dir, ".stash", "config.json"), - JSON.stringify( - { connections: { origin: { provider: "github", repo: "user/repo" } } }, - null, - 2, - ), - "utf8", - ); - - await ensureMigration(dir); - - assert.deepEqual(JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")), { - connections: { - origin: { provider: "github", repo: "user/repo" }, - }, - }); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("ensureMigration: throws when both old and new config paths exist", async () => { - const dir = await makeTempDir("migrate-config-conflict"); - try { - await mkdir(join(dir, ".stash"), { recursive: true }); - await writeFile(join(dir, ".stash", "config.local.json"), "{}", "utf8"); - await writeFile(join(dir, ".stash", "config.json"), "{}", "utf8"); - - await assert.rejects(ensureMigration(dir), MigrationError); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("ensureMigration: throws when both old and new snapshot paths exist", async () => { - const dir = await makeTempDir("migrate-snapshot-conflict"); - try { - await mkdir(join(dir, ".stash", "snapshot.local"), { recursive: true }); - await mkdir(join(dir, ".stash", "snapshot"), { recursive: true }); - - await assert.rejects(ensureMigration(dir), MigrationError); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("Stash.load migrates old config.local.json", async () => { - const dir = await makeTempDir("migrate-load"); - try { - await mkdir(join(dir, ".stash"), { recursive: true }); - await writeFile( - join(dir, ".stash", "config.local.json"), - JSON.stringify({ connections: { github: { repo: "user/repo" } } }, null, 2), - "utf8", - ); - - const stash = await Stash.load(dir, { providers: {}, background: { stashes: [] } }); - - assert.deepEqual(stash.connections, { - github: { provider: "github", repo: "user/repo" }, - }); - assert.equal(existsSync(join(dir, ".stash", "config.local.json")), false); - assert.equal(existsSync(join(dir, ".stash", "config.json")), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); - -test("Stash.load preserves old snapshot.local merge bases through migration", async () => { - const dir = await makeTempDir("migrate-merge-base"); - const baseline: string = "hello world"; - const remote: string = "hello cruel world"; - const local: string = "hello brave world"; - - class TestProvider implements Provider { - static spec = { setup: [], connect: [{ name: "repo", label: "Repo" }] }; - async fetch(): Promise { - return { - added: new Map(), - modified: new Map([["note.md", { type: "text" as const, content: remote }]]), - deleted: [], - }; - } - async get(): Promise { - throw new Error("not needed"); - } - async push(): Promise {} - } - - try { - await writeFiles(dir, { "note.md": local }); - await mkdir(join(dir, ".stash", "snapshot.local"), { recursive: true }); - await writeFile( - join(dir, ".stash", "config.local.json"), - JSON.stringify({ connections: { fake: { repo: "r" } } }, null, 2), - "utf8", - ); - await writeFile( - join(dir, ".stash", "snapshot.json"), - JSON.stringify({ "note.md": { hash: "sha256-old" } }, null, 2), - "utf8", - ); - await writeFile(join(dir, ".stash", "snapshot.local", "note.md"), baseline, "utf8"); - - const stash = await Stash.load( - dir, - { providers: {}, background: { stashes: [] } }, - { providers: { fake: TestProvider as unknown as ProviderClass } }, - ); - await stash.sync(); - - const merged = await readFile(join(dir, "note.md"), "utf8"); - assert.equal(merged.includes("brave"), true); - assert.equal(merged.includes("cruel"), true); - assert.equal(existsSync(join(dir, ".stash", "snapshot.local")), false); - assert.equal(existsSync(join(dir, ".stash", "snapshot", "note.md")), true); - } finally { - await rm(dir, { recursive: true, force: true }); - } -}); diff --git a/tests/unit/parse-uri.test.ts b/tests/unit/parse-uri.test.ts index 0e6de58..423d3a3 100644 --- a/tests/unit/parse-uri.test.ts +++ b/tests/unit/parse-uri.test.ts @@ -20,3 +20,11 @@ test("parseUri preserves additional colons in the id", () => { test("parseUri throws when the uri is missing a colon", () => { assert.throws(() => parseUri("github"), InvalidUriError); }); + +test("parseUri throws when the provider is empty", () => { + assert.throws(() => parseUri(":bucket"), /missing provider/i); +}); + +test("parseUri throws when the id is empty", () => { + assert.throws(() => parseUri("github:"), /missing id/i); +}); diff --git a/tests/unit/stash-config.test.ts b/tests/unit/stash-config.test.ts index d3de11d..9d69dbd 100644 --- a/tests/unit/stash-config.test.ts +++ b/tests/unit/stash-config.test.ts @@ -6,24 +6,25 @@ import type { Provider } from "../../src/types.ts"; import { makeStash } from "../helpers/make-stash.ts"; test("connect writes named provider connection config", async () => { - const { stash, dir } = await makeStash(); + const { stash, metadataDir } = await makeStash(); await stash.connect({ name: "origin", provider: "github", repo: "user/repo" }); - const config = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); - assert.deepEqual(config, { - connections: { - origin: { provider: "github", repo: "user/repo" }, + const record = JSON.parse(await readFile(join(metadataDir, "stash.json"), "utf8")); + assert.deepEqual(record.connections, [ + { + provider: "github", + config: { name: "origin", repo: "user/repo" }, }, - }); + ]); }); test("disconnect removes named connection config", async () => { - const { stash, dir } = await makeStash(); + const { stash, metadataDir } = await makeStash(); await stash.connect({ name: "origin", provider: "github", repo: "user/repo" }); await stash.disconnect("origin"); - const config = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); - assert.deepEqual(config, { connections: {} }); + const record = JSON.parse(await readFile(join(metadataDir, "stash.json"), "utf8")); + assert.deepEqual(record.connections, []); }); test("connections getter reflects latest local state", async () => { diff --git a/tests/unit/stash-scan.test.ts b/tests/unit/stash-scan.test.ts index e31d197..38e8b5c 100644 --- a/tests/unit/stash-scan.test.ts +++ b/tests/unit/stash-scan.test.ts @@ -59,6 +59,7 @@ test("scan: new file on disk is added when absent in snapshot", async () => { test("scan: dotfiles, symlinks, and .stash are ignored", async () => { const { stash, dir } = await makeStash({ "visible.md": "public" }); await writeFiles(dir, { ".hidden": "secret", ".config/settings.json": "{}" }); + await mkdir(join(dir, ".stash"), { recursive: true }); await writeFile(join(dir, ".stash", "noise.txt"), "ignored", "utf8"); await mkdir(join(dir, "links"), { recursive: true }); await symlink(join(dir, "visible.md"), join(dir, "links", "visible-link.md")); diff --git a/tests/unit/stash-sync-lock.test.ts b/tests/unit/stash-sync-lock.test.ts index c4a83c3..f91d8d3 100644 --- a/tests/unit/stash-sync-lock.test.ts +++ b/tests/unit/stash-sync-lock.test.ts @@ -18,8 +18,8 @@ function fakeRegistry(fake: FakeProvider) { return { fake: TestProvider as any }; } -function lockPath(dir: string): string { - return join(dir, ".stash", "sync.lock"); +function lockPath(metadataDir: string): string { + return join(metadataDir, "sync.lock"); } function lockPayload(startedAt: string): string { @@ -36,19 +36,19 @@ function lockPayload(startedAt: string): string { test("sync lock: lock acquired and released on successful sync", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash( + const { stash, metadataDir } = await makeStash( { "hello.md": "hello" }, { providers: fakeRegistry(fake) }, ); await stash.connect({ name: "fake", provider: "fake", repo: "r" }); await stash.sync(); - assert.equal(existsSync(lockPath(dir)), false); + assert.equal(existsSync(lockPath(metadataDir)), false); }); test("sync lock: lock released when sync fails", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash( + const { stash, metadataDir } = await makeStash( { "hello.md": "hello" }, { providers: fakeRegistry(fake) }, ); @@ -59,41 +59,41 @@ test("sync lock: lock released when sync fails", async () => { }; await assert.rejects(stash.sync(), /push failed/); - assert.equal(existsSync(lockPath(dir)), false); + assert.equal(existsSync(lockPath(metadataDir)), false); }); test("sync lock: SyncLockError when lock is held by another process", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash({}, { providers: fakeRegistry(fake) }); + const { stash, metadataDir } = await makeStash({}, { providers: fakeRegistry(fake) }); await stash.connect({ name: "fake", provider: "fake", repo: "r" }); - await writeFile(lockPath(dir), lockPayload(new Date().toISOString()), "utf8"); + await writeFile(lockPath(metadataDir), lockPayload(new Date().toISOString()), "utf8"); await assert.rejects(stash.sync(), SyncLockError); }); test("sync lock: stale lock is reclaimed", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash( + const { stash, metadataDir } = await makeStash( { "hello.md": "hello" }, { providers: fakeRegistry(fake) }, ); await stash.connect({ name: "fake", provider: "fake", repo: "r" }); const stale = new Date(Date.now() - 15 * 60 * 1_000).toISOString(); - await writeFile(lockPath(dir), lockPayload(stale), "utf8"); + await writeFile(lockPath(metadataDir), lockPayload(stale), "utf8"); await stash.sync(); - assert.equal(existsSync(lockPath(dir)), false); + assert.equal(existsSync(lockPath(metadataDir)), false); }); test("sync lock: stale reclaim race throws SyncLockError", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash( + const { stash, metadataDir } = await makeStash( { "hello.md": "hello" }, { providers: fakeRegistry(fake) }, ); await stash.connect({ name: "fake", provider: "fake", repo: "r" }); - const lock = lockPath(dir); + const lock = lockPath(metadataDir); const stale = new Date(Date.now() - 15 * 60 * 1_000).toISOString(); await writeFile(lock, lockPayload(stale), "utf8"); @@ -113,7 +113,7 @@ test("sync lock: stale reclaim race throws SyncLockError", async () => { test("sync lock: in-process single flight throws SyncLockError", async () => { const fake = new FakeProvider(); - const { stash, dir } = await makeStash( + const { stash, metadataDir } = await makeStash( { "hello.md": "hello" }, { providers: fakeRegistry(fake) }, ); @@ -128,5 +128,5 @@ test("sync lock: in-process single flight throws SyncLockError", async () => { const firstSync = stash.sync(); await assert.rejects(stash.sync(), SyncLockError); await firstSync; - assert.equal(existsSync(lockPath(dir)), false); + assert.equal(existsSync(lockPath(metadataDir)), false); }); From 84874fb084a23541a4cedc1bcfb264ae49f55018 Mon Sep 17 00:00:00 2001 From: Rupert Manfredi Date: Tue, 5 May 2026 23:22:54 -0400 Subject: [PATCH 4/9] feat(cli): clack-based UI overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace ad-hoc process.stdout output with a consistent, visually polished CLI using @clack/prompts, picocolors, and cli-table3. Library swap: - Add @clack/prompts (intro / outro / cancel / log / text / password) - Add picocolors (5-role palette: muted, accent, success, error, warn) - Add cli-table3 (per-stash status block) - Remove @inquirer/prompts Layout: every command is intro(name) → body → outro(hint), with cancel(...) replacing the success marker on errors. Per-command output rewritten per the style guide: - ~ shortening on rendered paths (canonical paths still absolute on disk) - → arrow for relink and connect transitions - No trailing periods on confirmation lines; errors keep them - 'Background sync is on. Run \`stash status\` to check.' outro on connect - cli-table3 for the status per-stash block; daemon state colored by picocolors - Universal error shape: cancel(summary) + 'No changes made.' outro + exit 1 Docs: docs/cli.md rewritten as the canonical reference for the new command surface and output style. docs/plans/cli-style.md kept as the design plan alongside docs/plans/external-sync-target.md. New module: src/cli-ui.ts wraps clack/picocolors so command logic in cli-main.ts stays readable and tests can inject streams cleanly. Tests: integration tests rewritten to assert on message content (words like 'Connected', the URI, the path) rather than box-drawing characters. Two daemon unit tests tightened to await reconciled state instead of timing-blind sleeps + finally-clause stop() to avoid workspace teardown races. 188/188 tests passing. Lint and build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/cli.md | 641 +++++++++--------- package-lock.json | 497 ++++---------- package.json | 6 +- src/cli-main.ts | 417 ++++++++---- src/cli-ui.ts | 124 ++++ src/cli.ts | 15 +- .../background-cli.integration.test.ts | 140 +++- tests/integration/cli.integration.test.ts | 23 +- tests/unit/daemon.test.ts | 40 +- 9 files changed, 1007 insertions(+), 896 deletions(-) create mode 100644 src/cli-ui.ts mode change 100755 => 100644 src/cli.ts diff --git a/docs/cli.md b/docs/cli.md index 6d638ec..62eee10 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,480 +1,457 @@ # CLI -The CLI is the user-facing layer for provider setup, sync orchestration, and terminal output. +`docs/cli.md` is the canonical reference for stash's command surface and terminal UI. -## Commands +- It defines what commands exist. +- It defines how those commands render output. +- It is the style guide for future CLI changes. -### `stash setup ` +The design spec lives at [docs/plans/external-sync-target.md](./plans/external-sync-target.md). This file is the user-facing contract. -Writes global provider configuration. +## Command surface -Example: +The supported commands are: ```bash -stash setup github --token ghp_... +stash setup +stash connect [] [--dangerously-allow-git] +stash disconnect [] [] +stash relink [--dangerously-allow-git] +stash status +stash start +stash stop +stash daemon ``` -If required fields are not provided on the command line, the CLI prompts for them. Secret fields are masked. - -### `stash connect [name]` +Removed commands from earlier CLI versions: -Initializes the current directory as a stash if needed, writes per-directory named connection config, and registers the stash for background sync. +- `stash sync` +- `stash watch` +- `stash add` +- `stash join` +- `stash list` +- `stash share` +- `stash config set` +- `stash config get` -Example: - -```bash -stash connect github --repo user/repo -stash connect github origin --repo user/repo -``` +`stash daemon` is internal. It exists so the service manager can launch the background process, but it is not part of the day-to-day user workflow. -If `name` is omitted, stash uses the provider name as the connection name. +## Libraries -If global setup fields are missing, `connect` collects and writes them before storing connection fields. +The CLI uses: -Example output: - -```text -Connected origin. -``` - -If background sync is already running: - -```text -Connected origin. -Background sync is on · This stash is now syncing automatically -``` +- `@clack/prompts` for intros, outros, prompts, and error blocks +- `picocolors` for the small fixed palette below +- `cli-table3` for `stash status` tables only -**TTY colors (stdout):** `Connected origin.` is default foreground. On the second line, `Background sync is on` is **green**; the middle dot `·` and `This stash is now syncing automatically` are **dim**. **Non-TTY:** same characters, no ANSI codes. +No other terminal UI library should be introduced without updating this document. -Connection names are unique within a stash, and **only one connection per stash** is allowed (see [Output examples and TTY colors](#output-examples-and-tty-colors)). +## Shared style -If the directory contains `.git/` and per-stash config does not have `allow-git: true`, `connect` still succeeds but prints a warning — see [Git safety warning on `connect`](#git-safety-warning-on-connect). +### Layout -### `stash disconnect ` +Interactive commands use the same overall flow: -Disconnects one named connection from the current stash. +1. `intro("")` +2. body content +3. `outro("")` -If that was the last remaining connection, the CLI also removes the stash from the global background registry and deletes `.stash/`. - -Example **stdout** (one connection removed, others remain): +Successful runs should feel like: ```text -Disconnected origin. +┌ stash connect +│ +◇ ~/Notes → github:user/notes +│ +└ Background sync is on. Run `stash status` to check. ``` -Example **stdout** (last connection — same message as `--all` after teardown): +Errors use `cancel(...)`, followed by an outro: ```text -Disconnected stash. +┌ stash connect +│ +■ ~/code/app is a git repository. +│ Pass --dangerously-allow-git if you really want stash to sync this folder. +│ +└ No changes made. ``` -Unknown name exits with code `1` and **stderr**: - -```text -Connection not found: backup -``` - -**TTY:** default foreground (no color) for these disconnect messages. - -### `stash disconnect --all` - -Disconnects the current stash completely. +### Palette -The CLI removes all named connections, unregisters the stash from background sync, and removes `.stash/`. +Use only these roles: -### `stash disconnect --path ` +- `gray` for labels and secondary detail +- default terminal color for values and body text +- `cyan` for highlighted URIs inside prose when needed +- `green` for success states +- `yellow` for warnings and degraded states +- `red` for error states -Disconnects a stash by path from anywhere. +Do not add bold, background colors, emojis, or custom themes. -If the directory still exists, stash also removes `.stash/`. If the path is not registered, the command prints `No stash registered at that path.` and leaves global state unchanged. +### Symbols -### `stash disconnect` with no arguments +- Use `→` for transitions +- Use `·` only inside prose-like summary lines +- Drop trailing periods on confirmation lines +- Keep periods on error sentences -Fails with: +### Rendered paths -```text -argument required — run `stash disconnect `, `stash disconnect --all`, or `stash disconnect --path ` -``` +All displayed paths are shortened from `$HOME/...` to `~/...`. -### `stash config set ` +- This is rendering only. +- Paths stored on disk stay canonical absolute paths. +- `status`, `connect`, `disconnect`, and `relink` all render shortened paths. -Writes one per-stash config value into `.stash/config.json`. +### Prompts -Current supported keys: +Prompts use clack's `text()` and `password()` helpers. -- `allow-git` - -Rules: +- Reuse `ConfigItem.label` +- Do not prefix with filler text like "Please enter..." +- Validate before any config write -- unknown keys fail -- `true` and `false` are stored as JSON booleans -- the current directory must already be a stash +### Errors -Example **stdout**: +Errors always follow this shape: ```text -allow-git=true +┌ stash +│ +■ +│ +│ +└ No changes made. ``` -**TTY:** default foreground. - -### `stash config get ` - -Prints one per-stash config value from `.stash/config.json`. - -Example **stdout** when set: - -```text -true -``` - -When the key is unset, prints an empty line (still **stdout**). **TTY:** default foreground. - -### `stash start` +Rules: -Installs and starts the OS-managed background sync service: +- Use `cancel("")` +- Keep the first line short and specific +- Add a second indented line only if the user needs a next step +- Exit with status code `1` +- Do not fall through to commander's default `error:` output -- macOS uses a user `launchd` agent -- Linux uses a user `systemd` unit -- the installed service runs the hidden `stash daemon` entrypoint -- other platforms report that background sync is not supported +### Don'ts -Example output: +- No emojis +- No raw stack traces for normal user-facing failures +- No box-drawing assertions in tests +- No trailing periods on successful confirmation lines +- No ad-hoc table formatting outside `status` -```text -Background sync is on -Watching 3 stashes · starts on startup -``` +## Commands -**TTY:** first line **green**; second line entirely **dim**. +### `stash setup ` -If the service is already installed and running: +Stores provider-level global config in `~/.stash/config.json`. -```text -Background sync is already running -``` +Behavior: -**TTY:** entire line **green**. +- Looks up the provider's `global: true` config fields +- Accepts those fields from CLI flags or interactive prompts +- Overwrites the provider's stored global config -On an unsupported platform, `stash start` prints to **stdout** and sets exit code `1`: +Example: ```text -Background sync is not supported on this platform +┌ stash setup +│ +◇ github +│ +└ Configured github ``` -**TTY:** entire line **red**. - -### `stash stop` +Prompted secret fields use masked input. -Stops and uninstalls the OS-managed background sync service for the current user. +### `stash connect [] [--dangerously-allow-git]` -Example output: +Connects a folder to a provider URI. -```text -Background sync is off -Run `stash start` to resume syncing 3 stashes -``` +Behavior: -**TTY:** first line **red**; second line **dim**. +- `` defaults to the current working directory +- `` uses the form `:` +- The rendered success line shows the shortened path and URI +- If the daemon is running, the outro is: + `Background sync is on. Run \`stash status\` to check.` +- If the daemon is not running or unsupported, do not add a daemon-status hint -If the service is not installed and not running: +Success example: ```text -Background sync is not running +┌ stash connect +│ +◇ ~/Notes → github:user/notes +│ +└ Background sync is on. Run `stash status` to check. ``` -**TTY:** entire line **red** (also used when the platform does not support background sync). - -### `stash sync` - -Runs one sync cycle. - -On a **TTY**, a single live line is updated. Spinner frames are `◐`, `◓`, `◑`, `◒` in **yellow**, followed by the rest of the line (lowercase status text). - -Example progression: +Idempotent reconnect example: ```text -◐ checking... -◐ syncing... ↑ hello.md -◐ syncing... ↓ photo.jpg -✓ synced (1↑ 1↓) +┌ stash connect +│ +└ Already connected — github:user/notes ``` -If there are no changes: +Git-protected path example: ```text -◐ checking... -✓ up to date +┌ stash connect +│ +■ ~/code/myrepo is a git repository. +│ Pass --dangerously-allow-git if you really want stash to sync this folder. +│ +└ No changes made. ``` -If sync fails: +Multi-connection-not-supported example: ```text -✗ sync failed: network error +┌ stash connect +│ +■ ~/Notes is already connected to github:user/old-notes. +│ Multi-connection per stash is not yet supported. Run `stash disconnect ~/Notes` first. +│ +└ No changes made. ``` -**TTY:** `✗` is **red**; `sync failed:` and the message use the default foreground. - -If sync is blocked by git safety: - -```text -✗ sync failed: git repository detected — run `stash config set allow-git true` to allow syncing -``` +### `stash disconnect [] []` -### `stash watch` - -Continuously syncs the current stash until interrupted. +Disconnects one connection from a stash. Behavior: -- starts with an immediate sync -- debounces filesystem-triggered syncs by 1 second -- polls every 10 seconds to pick up remote-only changes -- resets the poll timer after each completed sync -- `.` triggers an immediate sync -- `q` and `Ctrl-C` stop the watcher -- waits for an in-flight sync to finish before normal shutdown - -Additional behavior worth preserving: - -- in TTY mode, watch shows a live status line with countdowns to the next poll -- in non-TTY mode, watch prints line-based results instead of updating one live line -- sync and watcher errors do not stop watch; it keeps retrying on the normal poll cadence -- filesystem events that arrive during a sync are queued and trigger a follow-up debounced sync - -If sync is blocked by git safety, watch shows the same error message and keeps retrying on the normal poll cadence until the user removes `.git/` or sets `allow-git`. - -If no provider is connected, watch exits with: - -```text -no connection configured — run `stash connect ` first -``` - -(Exact casing matches the CLI.) - -**TTY** watch mode (live line, examples): - -```text -● up to date · checking in 8s -``` - -**TTY colors:** `●` **green**; `up to date`, the middle dot, and `checking in 8s` are **dim**. +- With no arguments, targets the current working directory +- With only ``, targets that stash +- With only ``, targets the current working directory and removes that URI +- With both `` and ``, removes that URI from that stash +- If the last connection is removed, the CLI writes a tombstone; the daemon owns deletion -After a sync that applied changes: +Success example: ```text -● 2↑ 1↓ · checking in 10s +┌ stash disconnect +│ +└ Disconnected github:user/notes from ~/Notes ``` -**TTY:** `●` **green**; the summary (`2↑ 1↓`) is **default** foreground; `·` and countdown are **dim**. - -When a sync errors but watch keeps retrying: +Multiple-connections example: ```text -✗ sync failed: · retrying in 10s +┌ stash disconnect +│ +■ Multiple connections at ~/Notes: +│ github:user/notes +│ github:user/notes-mirror +│ +└ Pass the URI to disconnect: stash disconnect ~/Notes ``` -**TTY:** `✗` **red**; `sync failed:` and `` default foreground; ` · retrying in …` **dim**. - -**Non-TTY** (piped stdout), error status prints one line: +Unknown stash example: ```text -✗ sync failed: +┌ stash disconnect +│ +■ No stash found at ~/Missing +│ +└ No changes made. ``` -**TTY:** `✗` **red**; remainder default. - -### `stash status` - -Prints **global** background sync state, then every stash path registered for background sync (from `~/.stash/config.json` or `$XDG_CONFIG_HOME/stash/config.json`). The current working directory does not matter. - -It shows: - -- whether background sync is running and how many stashes are registered -- a blank line, then for each registered path: basename, full path, and each connection’s provider label -- local pending changes and last sync time (from disk vs `snapshot.json`), or a background error when the daemon reported failure -- `Directory not found` / `Not a stash` when a registered path is missing or no longer a stash - -Example output: +Unknown URI example: ```text -Background sync is on · watching 2 stashes - -● notes - /Users/me/notes - origin (github) · Up to date · synced 2m ago - -● work - /Users/me/work - origin (github) · Local changes: 1 added, 2 modified · synced 5m ago - +┌ stash disconnect +│ +■ Connection not found: github:user/missing +│ +└ No changes made. ``` -**TTY colors:** On the banner line, `Background sync is on` is **green**; `·` and `watching 2 stashes` are **dim**. For each healthy stash, `●` is **green**; the stash title (e.g. `notes`) is default; the path on the next line is **dim**. On connection lines, `origin (github)` is default; `·` and the status phrase (`Up to date · synced 2m ago`, etc.) are **dim**. - -When background sync is off: - -```text -Background sync is off -Run `stash start` to resume syncing 2 stashes - -``` +### `stash relink [--dangerously-allow-git]` -**TTY:** first line **red**; second line **dim**. +Moves an existing stash registration from one path to another without changing its stash id. -When the platform does not support background sync: +Success example: ```text -Background sync is not supported on this platform - +┌ stash relink +│ +└ ~/Old/Notes → ~/Documents/Notes ``` -**TTY:** entire line **red**. - -When no stashes are registered: +Git-protected new path example: ```text -No stashes connected yet — run `stash connect ` in a directory to get started +┌ stash relink +│ +■ ~/code/newrepo is a git repository. +│ Pass --dangerously-allow-git if you really want stash to sync this folder. +│ +└ No changes made. ``` -**TTY:** default foreground. - -When the daemon reported an error for a stash (example): +Duplicate-path example: ```text -✗ notes - /Users/me/notes - origin (github) · rate limited +┌ stash relink +│ +■ A stash already exists at ~/Documents/Notes. +│ +└ No changes made. ``` -**TTY:** `✗` **red**; title default; path **dim**; `origin (github)` default; `·` **dim**; error text **red**. - -### Hidden: `stash daemon` - -Hidden daemon entrypoint used by the OS service. - -- loads the global background registry -- starts a headless watcher per registered stash -- hot-reloads when the global config changes -- writes `.stash/status.json` and capped `.stash/sync.log` for each stash - -## Config Locations +### `stash status` -- Global config: `~/.stash/config.json` or `$XDG_CONFIG_HOME/stash/config.json` -- Per-stash config: `.stash/config.json` +Shows daemon state first, then one block per registered stash. -Global config shape: +Rules: -```json -{ - "providers": { - "github": { "token": "ghp_..." } - }, - "background": { - "stashes": ["/Users/me/notes"] - } -} +- Always render `Daemon: ` even when no stashes are registered +- Color daemon state with `picocolors` + - running: green + - stopped: yellow + - unsupported: red + - error: red +- Each live stash uses `cli-table3` +- Missing-path stashes do not use a table +- `Last error` only appears when there is an error +- The outro is either a count summary or the empty-state hint + +Healthy stash example: + +```text +┌ stash status +│ +◇ Daemon: running +│ +│ ~/Notes +│ ┌──────────────┬────────────────────┐ +│ │ Connection │ github:user/notes │ +│ │ State │ idle │ +│ │ Last sync │ 2m ago │ +│ │ Pending │ 3 modified, 1 added│ +│ └──────────────┴────────────────────┘ +│ +└ 1 stash +``` + +Mixed state example: + +```text +┌ stash status +│ +◇ Daemon: running +│ +│ ~/Notes +│ ┌──────────────┬────────────────────┐ +│ │ Connection │ github:user/notes │ +│ │ State │ syncing │ +│ │ Last sync │ 14s ago │ +│ └──────────────┴────────────────────┘ +│ +│ ~/Old/Folder (path missing) +│ Run: stash relink ~/Old/Folder +│ +└ 2 stashes +``` + +Error row example: + +```text +┌ stash status +│ +◇ Daemon: running +│ +│ ~/Notes +│ ┌──────────────┬─────────────────────────────┐ +│ │ Connection │ github:user/notes │ +│ │ State │ error │ +│ │ Last sync │ 2m ago │ +│ │ Last error │ rate limit resets in 43s │ +│ └──────────────┴─────────────────────────────┘ +│ +└ 1 stash +``` + +Empty state example: + +```text +┌ stash status +│ +◇ Daemon: running +│ +└ No stashes registered. Run `stash connect ` to add one. ``` -## Output examples and TTY colors - -This section is the **canonical reference** for exact user-visible text and **TTY** color. When stdout/stderr is **not** a TTY (pipe, file, CI), stash prints the **same words and punctuation** but **omits ANSI color codes**. - -### How to read the examples - -- **stdout** / **stderr** are called out per case. Most errors go to **stderr** (including all structured `stash connect` validation errors). -- **“Green / red / dim / yellow”** describe the ANSI foreground stash uses when the stream is a TTY (`createColors` in `src/ui/color.ts`). -- **“Default”** means the terminal’s normal text color (stash does not wrap that segment in a color code). - -### Color roles (quick reference) - -| Color | Typical use | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Green** | `✓`, healthy `●`, positive service lines (`Background sync is on`, `Background sync is already running`), synced summary in watch | -| **Red** | `✗`, service “off / not running / unsupported”, daemon error text in `stash status`, **first line** of structured `stash connect` errors | -| **Dim** | Paths, hints, `·` separators, countdowns, **continuation lines** after a red `connect` error, git-warning bullets, second lines of `start`/`stop` | -| **Yellow** | Spinner frame characters `◐ ◓ ◑ ◒`; the `Warning:` prefix on git safety | -| **Default** | Plain sentences such as `Connected origin.`, `Disconnected origin.`, `stash sync`’s `sync failed:` tail | - -### `stash connect` — validation errors (stderr) - -Emitted **before** any connect prompts when the command cannot succeed. - -**Duplicate name** (name already in `.stash/config.json`): - -```text -Connection already exists: origin -``` +### `stash start` -**TTY:** entire line **red** (via `CliDisplayError`). **Non-TTY:** same text, no codes. +Installs and starts the daemon service for the current user. -**Second connection name** (e.g. `stash connect github new` while `origin` already exists): +Success example: ```text -This stash already has connection "origin" (only one is supported). -Disconnect it before adding "new": - stash disconnect origin +┌ stash start +│ +└ Daemon installed and running ``` -**TTY:** line 1 **red**; lines 2–3 **dim**. No blank line between line 1 and line 2. - -**Invalid multi-connection config** (more than one key under `connections` in `.stash/config.json`): +Unsupported example: ```text -This stash has more than one connection in .stash/config.json (only one is supported). -Remove extra entries or run: - stash disconnect --all -Then connect again. +┌ stash start +│ +■ This platform does not support background services yet. +│ +└ No changes made. ``` -**TTY:** line 1 **red**; lines 2–4 **dim**. +### `stash stop` -### Git safety warning on `connect` (stdout) +Stops and uninstalls the daemon service for the current user. -After `Connected .` when `.git` exists and `allow-git` is not `true`: +Success example: ```text -Warning: This directory contains .git. Stash will not sync until you either: - - remove .git, or - - run `stash config set allow-git true` (see "Using stash with git" in the README) +┌ stash stop +│ +└ Daemon stopped ``` -**TTY:** `Warning:` **yellow**; the rest of each line **dim**. - -### `stash setup` (stdout) +Already stopped example: ```text -Configured github. +┌ stash stop +│ +■ Daemon is not running. +│ +└ No changes made. ``` -**TTY:** default foreground (no color codes for this line). - -### Other commands - -Success paths, `disconnect`, `config`, `sync`, `watch`, `status`, and `start`/`stop` edge cases are documented **with the same level of detail** under each command above; the **palette rules** here apply consistently. +### `stash daemon` -## UI Style +Internal command used by the service manager. -The CLI output intentionally uses a compact, app-like style: +- Not documented in top-level user help beyond existing hidden-command behavior +- No CLI polish requirements beyond correct execution -- primary state lines use sentence case, for example `Background sync is on` -- secondary context lines are short and actionable, for example `Run \`stash start\` to resume syncing 3 stashes` -- transient spinner/live lines use **lowercase** status words in the spinner body, for example `checking...` and `syncing...` (spinner frames remain `◐◓◑◒`) -- local and global status phrases are capitalized consistently: `Up to date`, `Local changes`, `Waiting for first sync`, `Directory not found` -- counts should pluralize naturally, for example `Watching 1 stash` and `Watching 3 stashes` -- color is part of the UI contract in TTY mode — see [Output examples and TTY colors](#output-examples-and-tty-colors) for exact strings and segment-by-segment color +## Test guidance -These presentation details are intentional behavior, not incidental formatting. +CLI integration tests should assert on user-visible content, not frame glyphs. -## Scope +Good assertions: -The CLI owns: +- `"Connected"` +- `"github:user/notes"` +- `"~/Notes"` or the full expected path when testing a non-tilde environment +- `"Daemon: running"` +- `"No changes made."` -- prompting -- command parsing -- output rendering -- watch orchestration -- global config file I/O -- background service lifecycle commands +Bad assertions: -The CLI does not own sync logic or reconciliation rules; those live in `Stash`. +- full box-drawing snapshots +- exact clack glyph placement +- ANSI escape sequences diff --git a/package-lock.json b/package-lock.json index fec0951..3a9060a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,15 @@ "version": "0.4.1", "license": "MIT", "dependencies": { - "@inquirer/prompts": "^8.3.0", + "@clack/prompts": "^1.3.0", "@parcel/watcher": "^2.5.6", "@rupertsworld/daemon": "^0.1.1", "@rupertsworld/disposable": "^0.1.0", "@rupertsworld/emitter": "^0.1.0", + "cli-table3": "^0.6.5", "commander": "^14.0.3", - "diff-match-patch": "^1.0.5" + "diff-match-patch": "^1.0.5", + "picocolors": "^1.1.1" }, "bin": { "stash": "dist/cli.js" @@ -31,6 +33,44 @@ "typescript-eslint": "^8.57.0" } }, + "node_modules/@clack/core": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.3.0.tgz", + "integrity": "sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.3.0.tgz", + "integrity": "sha512-GgcWwRCs/xPtaqlMy8qRhPnZf9vlWcWZNHAitnVQ3yk7JmSralSiq5q07yaffYE8SogtDm7zFeKccx1QNVARpw==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.3.0", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -211,334 +251,6 @@ "url": "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/sponsors/nzakas" } }, - "node_modules/@inquirer/ansi": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.3.tgz", - "integrity": "sha512-g44zhR3NIKVs0zUesa4iMzExmZpLUdTLRMCStqX3GE5NT6VkPcxQGJ+uC8tDgBUC/vB1rUhUd55cOf++4NZcmw==", - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - } - }, - "node_modules/@inquirer/checkbox": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.1.0.tgz", - "integrity": "sha512-/HjF1LN0a1h4/OFsbGKHNDtWICFU/dqXCdym719HFTyJo9IG7Otr+ziGWc9S0iQuohRZllh+WprSgd5UW5Fw0g==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.3", - "@inquirer/core": "^11.1.5", - "@inquirer/figures": "^2.0.3", - "@inquirer/type": "^4.0.3" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/confirm": { - "version": "6.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.8.tgz", - "integrity": "sha512-Di6dgmiZ9xCSUxWUReWTqDtbhXCuG2MQm2xmgSAIruzQzBqNf49b8E07/vbCYY506kDe8BiwJbegXweG8M1klw==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.5", - "@inquirer/type": "^4.0.3" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core": { - "version": "11.1.5", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.5.tgz", - "integrity": "sha512-QQPAX+lka8GyLcZ7u7Nb1h6q72iZ/oy0blilC3IB2nSt1Qqxp7akt94Jqhi/DzARuN3Eo9QwJRvtl4tmVe4T5A==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.3", - "@inquirer/figures": "^2.0.3", - "@inquirer/type": "^4.0.3", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/editor": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.0.8.tgz", - "integrity": "sha512-sLcpbb9B3XqUEGrj1N66KwhDhEckzZ4nI/W6SvLXyBX8Wic3LDLENlWRvkOGpCPoserabe+MxQkpiMoI8irvyA==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.5", - "@inquirer/external-editor": "^2.0.3", - "@inquirer/type": "^4.0.3" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/expand": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.0.8.tgz", - "integrity": "sha512-QieW3F1prNw3j+hxO7/NKkG1pk3oz7pOB6+5Upwu3OIwADfPX0oZVppsqlL+Vl/uBHHDSOBY0BirLctLnXwGGg==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.5", - "@inquirer/type": "^4.0.3" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/external-editor": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-2.0.3.tgz", - "integrity": "sha512-LgyI7Agbda74/cL5MvA88iDpvdXI2KuMBCGRkbCl2Dg1vzHeOgs+s0SDcXV7b+WZJrv2+ERpWSM65Fpi9VfY3w==", - "license": "MIT", - "dependencies": { - "chardet": "^2.1.1", - "iconv-lite": "^0.7.2" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/figures": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.3.tgz", - "integrity": "sha512-y09iGt3JKoOCBQ3w4YrSJdokcD8ciSlMIWsD+auPu+OZpfxLuyz+gICAQ6GCBOmJJt4KEQGHuZSVff2jiNOy7g==", - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - } - }, - "node_modules/@inquirer/input": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.0.8.tgz", - "integrity": "sha512-p0IJslw0AmedLEkOU+yrEX3Aj2RTpQq7ZOf8nc1DIhjzaxRWrrgeuE5Kyh39fVRgtcACaMXx/9WNo8+GjgBOfw==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.5", - "@inquirer/type": "^4.0.3" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/number": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.0.8.tgz", - "integrity": "sha512-uGLiQah9A0F9UIvJBX52m0CnqtLaym0WpT9V4YZrjZ+YRDKZdwwoEPz06N6w8ChE2lrnsdyhY9sL+Y690Kh9gQ==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.5", - "@inquirer/type": "^4.0.3" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/password": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.8.tgz", - "integrity": "sha512-zt1sF4lYLdvPqvmvHdmjOzuUUjuCQ897pdUCO8RbXMUDKXJTTyOQgtn23le+jwcb+MpHl3VAFvzIdxRAf6aPlA==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.3", - "@inquirer/core": "^11.1.5", - "@inquirer/type": "^4.0.3" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/prompts": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.3.0.tgz", - "integrity": "sha512-JAj66kjdH/F1+B7LCigjARbwstt3SNUOSzMdjpsvwJmzunK88gJeXmcm95L9nw1KynvFVuY4SzXh/3Y0lvtgSg==", - "license": "MIT", - "dependencies": { - "@inquirer/checkbox": "^5.1.0", - "@inquirer/confirm": "^6.0.8", - "@inquirer/editor": "^5.0.8", - "@inquirer/expand": "^5.0.8", - "@inquirer/input": "^5.0.8", - "@inquirer/number": "^4.0.8", - "@inquirer/password": "^5.0.8", - "@inquirer/rawlist": "^5.2.4", - "@inquirer/search": "^4.1.4", - "@inquirer/select": "^5.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/rawlist": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.2.4.tgz", - "integrity": "sha512-fTuJ5Cq9W286isLxwj6GGyfTjx1Zdk4qppVEPexFuA6yioCCXS4V1zfKroQqw7QdbDPN73xs2DiIAlo55+kBqg==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.5", - "@inquirer/type": "^4.0.3" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/search": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.1.4.tgz", - "integrity": "sha512-9yPTxq7LPmYjrGn3DRuaPuPbmC6u3fiWcsE9ggfLcdgO/ICHYgxq7mEy1yJ39brVvgXhtOtvDVjDh9slJxE4LQ==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.1.5", - "@inquirer/figures": "^2.0.3", - "@inquirer/type": "^4.0.3" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/select": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.1.0.tgz", - "integrity": "sha512-OyYbKnchS1u+zRe14LpYrN8S0wH1vD0p2yKISvSsJdH2TpI87fh4eZdWnpdbrGauCRWDph3NwxRmM4Pcm/hx1Q==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.3", - "@inquirer/core": "^11.1.5", - "@inquirer/figures": "^2.0.3", - "@inquirer/type": "^4.0.3" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/type": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.3.tgz", - "integrity": "sha512-cKZN7qcXOpj1h+1eTTcGDVLaBIHNMT1Rz9JqJP5MnEJ0JhgVWllx7H/tahUp5YEK1qaByH2Itb8wLG/iScD5kw==", - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", @@ -878,7 +590,7 @@ "version": "25.3.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -1154,6 +866,15 @@ "url": "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/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1177,19 +898,19 @@ "node": "18 || 20 || >=22" } }, - "node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", - "license": "MIT" - }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "license": "ISC", + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, "engines": { - "node": ">= 12" + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" } }, "node_modules/commander": { @@ -1314,6 +1035,12 @@ "url": "https://dotenvx.com" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1622,22 +1349,6 @@ "node": ">=10.13.0" } }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1667,6 +1378,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -1786,15 +1506,6 @@ "dev": true, "license": "MIT" }, - "node_modules/mute-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", - "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -1878,6 +1589,12 @@ "node": ">=8" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, "node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", @@ -1926,12 +1643,6 @@ "node": ">=6" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -1968,16 +1679,36 @@ "node": ">=8" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, "engines": { - "node": ">=14" + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" }, - "funding": { - "url": "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/sponsors/isaacs" + "engines": { + "node": ">=8" } }, "node_modules/tinyglobby": { @@ -2065,7 +1796,7 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/uri-js": { diff --git a/package.json b/package.json index 2579206..2a39939 100644 --- a/package.json +++ b/package.json @@ -35,13 +35,15 @@ }, "license": "MIT", "dependencies": { - "@inquirer/prompts": "^8.3.0", + "@clack/prompts": "^1.3.0", "@parcel/watcher": "^2.5.6", "@rupertsworld/daemon": "^0.1.1", "@rupertsworld/disposable": "^0.1.0", "@rupertsworld/emitter": "^0.1.0", + "cli-table3": "^0.6.5", "commander": "^14.0.3", - "diff-match-patch": "^1.0.5" + "diff-match-patch": "^1.0.5", + "picocolors": "^1.1.1" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/src/cli-main.ts b/src/cli-main.ts index 06f30de..4e3ca7c 100644 --- a/src/cli-main.ts +++ b/src/cli-main.ts @@ -2,9 +2,17 @@ import { existsSync } from "node:fs"; import { access, constants, readdir, realpath, readFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { delimiter, join, resolve } from "node:path"; +import type { Readable } from "node:stream"; +import Table from "cli-table3"; import { Daemon, UnsupportedPlatformError } from "@rupertsworld/daemon"; import { Command, Option } from "commander"; -import { input, password } from "@inquirer/prompts"; +import { + CliCommandError, + cliError, + createCliUi, + pluralize, + shortenHomePath, +} from "./cli-ui.ts"; import { getProviderConfig, getStashDirPath, @@ -39,6 +47,7 @@ type ServiceLaunch = { type CliDependencies = { cwd?: () => string; + stdin?: Readable; readGlobalConfig?: () => Promise; writeGlobalConfig?: (config: GlobalConfig) => Promise; stashPath?: string; @@ -66,9 +75,18 @@ type RegistryEntry = { record: StashRecord; }; -function writeLine(stream: NodeJS.WriteStream, text: string): void { - stream.write(`${text}\n`); -} +type SetupDeps = Required>; + +type ConnectDeps = Required> & + Pick & { + getService: () => Promise; + }; + +type DisconnectDeps = Required> & Pick; + +type StatusDeps = Pick & { + getService: () => Promise; +}; function getGlobalConfigItems(provider: ProviderClass): ConfigItem[] { return provider.configSchema.filter((item) => item.global === true); @@ -82,14 +100,18 @@ function getCliValue( return typeof value === "string" ? value : undefined; } -async function promptField(field: ConfigItem): Promise { +async function promptField( + ui: ReturnType, + field: ConfigItem, +): Promise { if (field.secret) { - return password({ message: field.label }); + return ui.promptPassword(field.label); } - return input({ message: field.label }); + return ui.promptText(field.label); } async function collectFields( + ui: ReturnType, fields: ConfigItem[], valuesFromCli: Record, current: Record = {}, @@ -104,7 +126,7 @@ async function collectFields( values[field.name] = cliValue; continue; } - values[field.name] = await promptField(field); + values[field.name] = await promptField(ui, field); } return values; } @@ -118,7 +140,7 @@ function addFieldOptions(command: Command, fields: ConfigItem[]): void { function getProvider(name: string): ProviderClass { const provider = providers[name]; if (!provider) { - throw new Error(`Unknown provider: ${name}`); + throw cliError(`Unknown provider: ${name}.`); } return provider; } @@ -147,6 +169,24 @@ function sameStringRecord(left: Record, right: Record key === rightKeys[index] && left[key] === right[key]); } +function asSentence(message: string): string { + const trimmed = message.trim(); + if (trimmed.length === 0) { + return "Unknown error."; + } + return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`; +} + +function toCliCommandError(error: unknown): CliCommandError { + if (error instanceof CliCommandError) { + return error; + } + if (error instanceof Error) { + return cliError(asSentence(error.message)); + } + return cliError(asSentence(String(error))); +} + async function isExecutable(path: string): Promise { try { await access(path, constants.X_OK); @@ -212,9 +252,9 @@ function ensureGitAllowed(path: string, dangerouslyAllowGit: boolean): void { return; } if (existsSync(join(path, ".git"))) { - throw new Error( - "git repository detected. Pass `--dangerously-allow-git` if you really want stash to sync this folder.", - ); + throw cliError(`${shortenHomePath(path)} is a git repository.`, [ + "Pass --dangerously-allow-git if you really want stash to sync this folder.", + ]); } } @@ -255,6 +295,9 @@ async function readPersistedStatus(stashPath: string, id: string): Promise 0) { + parts.push(`${pending.modified} modified`); + } + if ((pending.added ?? 0) > 0) { + parts.push(`${pending.added} added`); + } + if ((pending.deleted ?? 0) > 0) { + parts.push(`${pending.deleted} deleted`); + } + return parts.length > 0 ? parts.join(", ") : null; +} + +function formatDaemonState( + state: "running" | "stopped" | "unsupported" | "error", + ui: ReturnType, +): string { + const { colors } = ui; + if (state === "running") { + return `Daemon: ${colors.green("running")}`; + } + if (state === "stopped") { + return `Daemon: ${colors.yellow("stopped")}`; + } + return `Daemon: ${colors.red(state)}`; +} + +function renderStatusTable( + entry: RegistryEntry, + status: PersistedStatus | null, +): string { + const table = new Table({ + style: { + head: [], + border: [], + compact: true, + }, + wordWrap: true, + }); + + table.push([ + "Connection", + entry.record.connections.map((connection) => formatUri(connection)).join("\n"), + ]); + table.push(["State", describeStatusKind(status)]); + + if (status?.lastSync) { + table.push(["Last sync", formatTimeAgo(new Date(status.lastSync))]); + } + + const pending = formatPendingChanges(status); + if (pending) { + table.push(["Pending", pending]); + } + + if (status?.error) { + table.push(["Last error", status.error]); + } + + return table.toString(); +} + async function runSetup( providerName: string, valuesFromCli: Record, - deps: Required>, - stdout: NodeJS.WriteStream, + deps: SetupDeps, + ui: ReturnType, ): Promise { const provider = getProvider(providerName); const globalFields = getGlobalConfigItems(provider); const globalConfig = await deps.readGlobalConfig(); const values = await collectFields( + ui, globalFields, valuesFromCli, getProviderConfig(globalConfig, providerName), ); await deps.writeGlobalConfig(setProviderConfig(globalConfig, providerName, values)); - writeLine(stdout, `Configured ${providerName}.`); + ui.step(providerName); + ui.outro(`Configured ${providerName}`); } function normalizeConnectArgs(first: string, second?: string): { pathArg?: string; uri: string } { @@ -306,11 +418,8 @@ async function runConnect( first: string, second: string | undefined, valuesFromCli: Record, - deps: Required> & - Pick & { - getService: () => Promise; - }, - stdout: NodeJS.WriteStream, + deps: ConnectDeps, + ui: ReturnType, ): Promise { const { pathArg, uri } = normalizeConnectArgs(first, second); const targetPath = pathArg ?? deps.cwd(); @@ -322,6 +431,7 @@ async function runConnect( let globalConfig = await deps.readGlobalConfig(); const existingGlobalValues = getProviderConfig(globalConfig, parsed.provider); const globalValues = await collectFields( + ui, getGlobalConfigItems(provider), valuesFromCli, existingGlobalValues, @@ -372,36 +482,44 @@ async function runConnect( return "already-connected" as const; } - throw new Error( - `${canonicalPath} is already connected to ${formatUri(found.record.connections[0])}. Multi-connection per stash is not yet supported. Run \`stash disconnect ${canonicalPath}\` first.`, - ); + const displayPath = shortenHomePath(found.record.path); + throw cliError(`${displayPath} is already connected to ${formatUri(found.record.connections[0])}.`, [ + `Multi-connection per stash is not yet supported. Run \`stash disconnect ${displayPath}\` first.`, + ]); }, { stashPath }, ); if (outcome === "already-connected") { - writeLine(stdout, "Already connected."); - } else { - writeLine(stdout, `Connected ${uri} to ${canonicalPath}.`); + ui.outro(`Already connected — ${uri}`); + return; } + const displayPath = shortenHomePath(canonicalPath); + ui.step(`${displayPath} → ${uri}`); + + let daemonRunning = false; try { - const status = await (await deps.getService()).status(); - if (status.running) { - writeLine(stdout, "Background sync is on · syncing automatically"); - } + daemonRunning = (await (await deps.getService()).status()).running; } catch (error) { if (!isUnsupportedPlatformError(error)) { throw error; } } + + if (daemonRunning) { + ui.outro("Background sync is on. Run `stash status` to check."); + return; + } + + ui.outro(`Connected ${uri} to ${displayPath}`); } async function runDisconnect( first: string | undefined, second: string | undefined, - deps: Required> & Pick, - stdout: NodeJS.WriteStream, + deps: DisconnectDeps, + ui: ReturnType, ): Promise { const { pathArg, uri } = normalizeDisconnectArgs(first, second); const lookupPath = await resolveLookupPath(pathArg ?? deps.cwd()); @@ -415,14 +533,21 @@ async function runDisconnect( const entries = await readRegistry(stashPath); const found = entries.find((entry) => entry.record.path === lookupPath); if (!found) { - throw new Error(`No stash found at ${lookupPath}.`); + throw cliError(`No stash found at ${shortenHomePath(lookupPath)}.`); } const currentUris = found.record.connections.map((connection) => formatUri(connection)); + const displayPath = shortenHomePath(found.record.path); let targetUri = uri; if (!targetUri) { + if (currentUris.length === 0) { + throw cliError(`No active connections at ${displayPath}.`); + } if (currentUris.length !== 1) { - throw new Error(`Multiple connections present: ${currentUris.join(", ")}`); + throw cliError(`Multiple connections at ${displayPath}:`, [ + ...currentUris, + `Pass the URI to disconnect: stash disconnect ${displayPath} `, + ]); } [targetUri] = currentUris; } @@ -431,7 +556,7 @@ async function runDisconnect( (connection) => formatUri(connection) !== targetUri, ); if (nextConnections.length === found.record.connections.length) { - throw new Error(`Connection not found: ${targetUri}`); + throw cliError(`Connection not found: ${targetUri}.`); } disconnectedPath = found.record.path; @@ -444,7 +569,7 @@ async function runDisconnect( { stashPath }, ); - writeLine(stdout, `Disconnected ${disconnectedUri} from ${disconnectedPath}.`); + ui.outro(`Disconnected ${disconnectedUri} from ${shortenHomePath(disconnectedPath)}`); } async function runRelink( @@ -452,12 +577,16 @@ async function runRelink( newPath: string, dangerouslyAllowGit: boolean, deps: Pick, - stdout: NodeJS.WriteStream, + ui: ReturnType, ): Promise { const oldCanonical = await resolveLookupPath(oldPath); const newCanonical = await canonicalizeExistingPath(newPath); ensureGitAllowed(newCanonical, dangerouslyAllowGit); + const displayOld = shortenHomePath(oldCanonical); + const displayNew = shortenHomePath(newCanonical); + if (oldCanonical === newCanonical) { + ui.outro(`Already linked — ${displayNew}`); return; } @@ -467,10 +596,10 @@ async function runRelink( const entries = await readRegistry(stashPath); const current = entries.find((entry) => entry.record.path === oldCanonical); if (!current) { - throw new Error(`No stash found at ${oldCanonical}.`); + throw cliError(`No stash found at ${displayOld}.`); } if (entries.some((entry) => entry.id !== current.id && entry.record.path === newCanonical)) { - throw new Error(`A stash already exists at ${newCanonical}.`); + throw cliError(`A stash already exists at ${displayNew}.`); } await writeJsonFile(join(getStashDirPath(current.id, stashPath), "stash.json"), { ...current.record, @@ -480,91 +609,85 @@ async function runRelink( { stashPath }, ); - writeLine(stdout, `Relinked ${oldCanonical} -> ${newCanonical}.`); -} - -function formatPendingChanges(status: PersistedStatus | null): string | null { - const pending = status?.pendingChanges; - if (!pending) { - return null; - } - const parts: string[] = []; - if ((pending.modified ?? 0) > 0) { - parts.push(`${pending.modified} modified`); - } - if ((pending.added ?? 0) > 0) { - parts.push(`${pending.added} added`); - } - if ((pending.deleted ?? 0) > 0) { - parts.push(`${pending.deleted} deleted`); - } - return parts.length > 0 ? parts.join(", ") : null; + ui.outro(`${displayOld} → ${displayNew}`); } async function runStatus( - deps: Required> & - Pick & { - getService: () => Promise; - }, - stdout: NodeJS.WriteStream, + deps: StatusDeps, + ui: ReturnType, ): Promise { const daemonState = await deps .getService() .then((service) => service.status()) .then((status) => (status.running ? "running" : "stopped")) .catch((error: unknown) => (isUnsupportedPlatformError(error) ? "unsupported" : "error")); - writeLine(stdout, `Daemon: ${daemonState}`); + + ui.step(formatDaemonState(daemonState, ui)); const stashPath = deps.stashPath ?? getStashPath(); - const entries = await readRegistry(stashPath); + const entries = (await readRegistry(stashPath)) + .filter((entry) => entry.record.connections.length > 0) + .sort((a, b) => a.record.path.localeCompare(b.record.path)); + if (entries.length === 0) { + ui.outro("No stashes registered. Run `stash connect ` to add one."); return; } - writeLine(stdout, ""); - for (const entry of entries.sort((a, b) => a.record.path.localeCompare(b.record.path))) { + for (const entry of entries) { + const displayPath = shortenHomePath(entry.record.path); const status = await readPersistedStatus(stashPath, entry.id); if (status?.kind === "missing" || !existsSync(entry.record.path)) { - writeLine( - stdout, - `${entry.record.path} (path missing — run \`stash relink ${entry.record.path} \`)`, - ); - writeLine(stdout, ""); + ui.message([ + `${displayPath} (path missing)`, + `Run: stash relink ${displayPath} `, + ]); continue; } - writeLine(stdout, entry.record.path); - for (const connection of entry.record.connections) { - writeLine(stdout, ` ${formatUri(connection)}`); - } - - writeLine(stdout, ` State: ${describeStatusKind(status)}`); - if (status?.lastSync) { - writeLine(stdout, ` Last sync: ${formatTimeAgo(new Date(status.lastSync))}`); - } - const pending = formatPendingChanges(status); - if (pending) { - writeLine(stdout, ` Pending: ${pending}`); - } - if (status?.error) { - writeLine(stdout, ` Last error: ${status.error}`); - } - writeLine(stdout, ""); + ui.message([displayPath, renderStatusTable(entry, status)]); } + + ui.outro(pluralize(entries.length, "stash")); } -async function runStart(serviceHandle: ServiceHandle, stdout: NodeJS.WriteStream): Promise { - await serviceHandle.install(); - writeLine(stdout, "Daemon started."); +async function runStart( + serviceHandle: ServiceHandle, + ui: ReturnType, +): Promise { + try { + await serviceHandle.install(); + } catch (error) { + if (isUnsupportedPlatformError(error)) { + throw cliError("This platform does not support background services yet."); + } + throw error; + } + ui.outro("Daemon installed and running"); } -async function runStop(serviceHandle: ServiceHandle, stdout: NodeJS.WriteStream): Promise { - await serviceHandle.uninstall(); - writeLine(stdout, "Daemon stopped."); +async function runStop( + serviceHandle: ServiceHandle, + ui: ReturnType, +): Promise { + try { + const status = await serviceHandle.status(); + if (!status.running) { + throw cliError("Daemon is not running."); + } + await serviceHandle.uninstall(); + } catch (error) { + if (isUnsupportedPlatformError(error)) { + throw cliError("This platform does not support background services yet."); + } + throw error; + } + ui.outro("Daemon stopped"); } -export async function main(argv = process.argv, deps: CliDependencies = {}): Promise { +export async function main(argv = process.argv, deps: CliDependencies = {}): Promise { const cwd = deps.cwd ?? (() => process.cwd()); + const input = deps.stdin ?? process.stdin; const readConfig = deps.readGlobalConfig ?? readGlobalConfig; const writeConfig = deps.writeGlobalConfig ?? writeGlobalConfig; const stashPath = deps.stashPath ?? getStashPath(); @@ -573,7 +696,19 @@ export async function main(argv = process.argv, deps: CliDependencies = {}): Pro const runDaemonCommand = deps.runDaemon ?? runDaemon; const stdout = deps.stdout ?? process.stdout; const stderr = deps.stderr ?? process.stderr; + const ui = createCliUi({ input, output: stdout }); const { version } = createRequire(import.meta.url)("../package.json") as { version: string }; + let exitCode = 0; + + async function runCommand(title: string, action: () => Promise): Promise { + ui.intro(`stash ${title}`); + try { + await action(); + } catch (error) { + exitCode = 1; + ui.renderError(toCliCommandError(error)); + } + } const program = new Command() .name("stash") @@ -591,48 +726,54 @@ export async function main(argv = process.argv, deps: CliDependencies = {}): Pro const setupCommand = program .command("setup") - .description("Configure global provider settings") + .description("Configure provider settings") .argument("", "Provider name") .allowUnknownOption(true) .action(async (providerName: string, _opts: unknown, command: Command) => { - await runSetup( - providerName, - command.opts() as Record, - { readGlobalConfig: readConfig, writeGlobalConfig: writeConfig }, - stdout, - ); + await runCommand("setup", async () => { + await runSetup( + providerName, + command.opts() as Record, + { readGlobalConfig: readConfig, writeGlobalConfig: writeConfig }, + ui, + ); + }); }); const connectCommand = program .command("connect") - .description("Connect a path to a provider uri") + .description("Connect a path to a provider URI") .argument("", "URI or explicit path") .argument("[second]", "URI when an explicit path is provided") .allowUnknownOption(true) .option("--dangerously-allow-git", "Allow syncing a git repository") .action(async (first: string, second: string | undefined, _opts: unknown, command: Command) => { - await runConnect( - first, - second, - command.opts() as Record, - { - cwd, - readGlobalConfig: readConfig, - writeGlobalConfig: writeConfig, - stashPath, - getService, - }, - stdout, - ); + await runCommand("connect", async () => { + await runConnect( + first, + second, + command.opts() as Record, + { + cwd, + readGlobalConfig: readConfig, + writeGlobalConfig: writeConfig, + stashPath, + getService, + }, + ui, + ); + }); }); program .command("disconnect") - .description("Disconnect a stash path or uri") + .description("Disconnect a stash path or URI") .argument("[first]", "Path or URI") .argument("[second]", "URI when an explicit path is provided") .action(async (first: string | undefined, second: string | undefined) => { - await runDisconnect(first, second, { cwd, stashPath }, stdout); + await runCommand("disconnect", async () => { + await runDisconnect(first, second, { cwd, stashPath }, ui); + }); }); program @@ -642,32 +783,45 @@ export async function main(argv = process.argv, deps: CliDependencies = {}): Pro .argument("", "New existing path") .option("--dangerously-allow-git", "Allow relinking to a git repository") .action(async (oldPath: string, newPath: string, options: { dangerouslyAllowGit?: boolean }) => { - await runRelink(oldPath, newPath, options.dangerouslyAllowGit === true, { stashPath }, stdout); + await runCommand("relink", async () => { + await runRelink( + oldPath, + newPath, + options.dangerouslyAllowGit === true, + { stashPath }, + ui, + ); + }); }); program .command("status") .description("Show registered stashes") .action(async () => { - await runStatus( - { - readGlobalConfig: readConfig, - stashPath, - getService, - }, - stdout, - ); + await runCommand("status", async () => { + await runStatus( + { + stashPath, + getService, + }, + ui, + ); + }); }); program .command("start") .description("Start background sync") - .action(async () => runStart(await getService(), stdout)); + .action(async () => + runCommand("start", async () => runStart(await getService(), ui)), + ); program .command("stop") .description("Stop background sync") - .action(async () => runStop(await getService(), stdout)); + .action(async () => + runCommand("stop", async () => runStop(await getService(), ui)), + ); program .command("daemon", { hidden: true }) @@ -700,8 +854,9 @@ export async function main(argv = process.argv, deps: CliDependencies = {}): Pro if (argv.length <= 2) { program.outputHelp(); - return; + return exitCode; } await program.parseAsync(argv); + return exitCode; } diff --git a/src/cli-ui.ts b/src/cli-ui.ts new file mode 100644 index 0000000..031b301 --- /dev/null +++ b/src/cli-ui.ts @@ -0,0 +1,124 @@ +import type { Readable, Writable } from "node:stream"; +import { + cancel as clackCancel, + intro as clackIntro, + isCancel, + log, + outro as clackOutro, + password, + text, +} from "@clack/prompts"; +import picocolors from "picocolors"; + +export class CliCommandError extends Error { + readonly summary: string; + readonly details: string[]; + readonly finalLine: string; + + constructor(summary: string, details: string[] = [], finalLine = "No changes made.") { + super(summary); + this.name = "CliCommandError"; + this.summary = summary; + this.details = details; + this.finalLine = finalLine; + } +} + +export function cliError( + summary: string, + details: string[] = [], + finalLine = "No changes made.", +): CliCommandError { + return new CliCommandError(summary, details, finalLine); +} + +export type CliUi = ReturnType; + +export function shortenHomePath(path: string, home = process.env.HOME): string { + if (!home) { + return path; + } + if (path === home) { + return "~"; + } + return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path; +} + +export function pluralize(count: number, singular: string, plural = `${singular}s`): string { + return `${count} ${count === 1 ? singular : plural}`; +} + +export function createCliUi({ + input = process.stdin, + output = process.stdout, +}: { + input?: Readable; + output?: Writable; +} = {}) { + const colors = picocolors.createColors(Boolean((output as NodeJS.WriteStream).isTTY)); + + const common = { + input, + output, + } as const; + + return { + input, + output, + colors, + intro(title: string): void { + clackIntro(title, { output }); + }, + step(message: string): void { + log.step(message, { output }); + }, + message(message: string | string[]): void { + log.message(message, { output }); + }, + outro(message: string): void { + clackOutro(message, { output }); + }, + cancel(message: string): void { + clackCancel(message, { output }); + }, + async promptText(label: string): Promise { + const value = await text({ + message: label, + ...common, + validate: (candidate) => { + if (!candidate || candidate.trim().length === 0) { + return `${label} is required`; + } + return undefined; + }, + }); + if (isCancel(value)) { + throw cliError("Prompt cancelled."); + } + return value; + }, + async promptPassword(label: string): Promise { + const value = await password({ + message: label, + ...common, + validate: (candidate) => { + if (!candidate || candidate.trim().length === 0) { + return `${label} is required`; + } + return undefined; + }, + }); + if (isCancel(value)) { + throw cliError("Prompt cancelled."); + } + return value; + }, + renderError(error: CliCommandError): void { + this.cancel(error.summary); + if (error.details.length > 0) { + this.message(error.details); + } + this.outro(error.finalLine); + }, + }; +} diff --git a/src/cli.ts b/src/cli.ts old mode 100755 new mode 100644 index 63029a4..181e649 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,12 +1,11 @@ #!/usr/bin/env node -import { CliDisplayError } from "./cli-display-error.ts"; import { main } from "./cli-main.ts"; -main().catch((error) => { - if (error instanceof CliDisplayError) { - error.writeTo(process.stderr); - } else { +main() + .then((exitCode) => { + process.exitCode = exitCode; + }) + .catch((error) => { console.error(error instanceof Error ? error.message : String(error)); - } - process.exitCode = 1; -}); + process.exitCode = 1; + }); diff --git a/tests/integration/background-cli.integration.test.ts b/tests/integration/background-cli.integration.test.ts index d99055f..53b4d03 100644 --- a/tests/integration/background-cli.integration.test.ts +++ b/tests/integration/background-cli.integration.test.ts @@ -14,6 +14,7 @@ type ServiceCalls = { }; type RunResult = { + exitCode: number; stdout: string; stderr: string; config: GlobalConfig; @@ -75,7 +76,7 @@ async function runMain( status: 0, }; - await main(["node", "stash", ...args], { + const exitCode = await main(["node", "stash", ...args], { cwd: () => cwd, stashPath: getTestStashPath(cwd), readGlobalConfig: async () => structuredClone(config), @@ -114,7 +115,7 @@ async function runMain( } as NodeJS.WriteStream, }); - return { stdout, stderr, config, configWrites, calls }; + return { exitCode, stdout, stderr, config, configWrites, calls }; } test("connect creates a new stash record and stores provider globals separately", async () => { @@ -129,8 +130,10 @@ test("connect creates a new stash record and stores provider globals separately" }, ); - assert.equal(result.stdout.includes(`Connected github:user/repo to ${await realpath(dir)}.`), true); - assert.equal(result.stdout.includes("Background sync is on"), true); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout.includes("github:user/repo"), true); + assert.equal(result.stdout.includes(await realpath(dir)), true); + assert.equal(result.stdout.includes("Background sync is on. Run `stash status` to check."), true); assert.equal(result.config.providers.github?.token, "test-token"); assert.equal(existsSync(join(dir, ".stash")), false); @@ -153,7 +156,9 @@ test("connect is idempotent for the same uri", async () => { config: first.config, }); - assert.equal(second.stdout.includes("Already connected."), true); + assert.equal(second.exitCode, 0); + assert.equal(second.stdout.includes("Already connected"), true); + assert.equal(second.stdout.includes("github:user/repo"), true); assert.deepEqual(await listStashIds(getTestStashPath(dir)), [( await readOnlyStashRecord(getTestStashPath(dir)) ).id]); @@ -177,6 +182,7 @@ test("connect skips rewriting global config when provider globals are unchanged" }, }); + assert.equal(result.exitCode, 0); assert.equal(result.configWrites, 0); assert.deepEqual(result.config.providers.github, { token: "test-token" }); const { record } = await readOnlyStashRecord(getTestStashPath(dir)); @@ -192,12 +198,13 @@ test("connect blocks git repositories unless --dangerously-allow-git is passed", try { await mkdir(join(dir, ".git"), { recursive: true }); - const error = await runMain(dir, ["connect", "github:user/repo", "--token", "test-token"]).catch( - (value: Error) => value, - ); + const result = await runMain(dir, ["connect", "github:user/repo", "--token", "test-token"]); - assert.ok(error instanceof Error); - assert.equal(error.message.includes("--dangerously-allow-git"), true); + assert.equal(result.exitCode, 1); + assert.equal(result.stdout.includes("is a git repository"), true); + assert.equal(result.stdout.includes("--dangerously-allow-git"), true); + assert.equal(result.stdout.includes("No changes made."), true); + assert.equal(result.stdout.includes("Background sync is on"), false); assert.deepEqual(await listStashIds(getTestStashPath(dir)), []); } finally { await rm(dir, { recursive: true, force: true }); @@ -215,10 +222,10 @@ test("disconnect tombstones the last connection without deleting the stash dir", config: connected.config, }); - assert.equal( - result.stdout.includes(`Disconnected github:user/repo from ${await realpath(dir)}.`), - true, - ); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout.includes("Disconnected"), true); + assert.equal(result.stdout.includes("github:user/repo"), true); + assert.equal(result.stdout.includes(await realpath(dir)), true); assert.equal(existsSync(join(getTestStashPath(dir), id)), true); const record = JSON.parse( await readFile(join(getTestStashPath(dir), id, "stash.json"), "utf8"), @@ -238,11 +245,11 @@ test("relink rewrites stash.json.path and enforces the git gate on the new path" const { id } = await readOnlyStashRecord(getTestStashPath(dir)); await mkdir(join(moved, ".git"), { recursive: true }); - const error = await runMain(dir, ["relink", dir, moved], { config: connected.config }).catch( - (value: Error) => value, - ); - assert.ok(error instanceof Error); - assert.equal(error.message.includes("--dangerously-allow-git"), true); + const error = await runMain(dir, ["relink", dir, moved], { config: connected.config }); + assert.equal(error.exitCode, 1); + assert.equal(error.stdout.includes("is a git repository"), true); + assert.equal(error.stdout.includes("--dangerously-allow-git"), true); + assert.equal(error.stdout.includes("No changes made."), true); const result = await runMain( dir, @@ -250,10 +257,10 @@ test("relink rewrites stash.json.path and enforces the git gate on the new path" { config: connected.config }, ); - assert.equal( - result.stdout.includes(`Relinked ${await realpath(dir)} -> ${await realpath(moved)}.`), - true, - ); + assert.equal(result.exitCode, 0); + assert.equal(result.stdout.includes(await realpath(dir)), true); + assert.equal(result.stdout.includes(await realpath(moved)), true); + assert.equal(result.stdout.includes("→"), true); const record = JSON.parse( await readFile(join(getTestStashPath(dir), id, "stash.json"), "utf8"), ) as StashRecord; @@ -284,8 +291,13 @@ test("status prints daemon state, connection URIs, and missing-path guidance", a { kind: "idle", lastSync: "2026-03-11T14:30:00.000Z", - summary: null, + summary: "1 modified", error: null, + pendingChanges: { + modified: 1, + added: 0, + deleted: 0, + }, }, null, 2, @@ -311,11 +323,33 @@ test("status prints daemon state, connection URIs, and missing-path guidance", a serviceStatus: { installed: true, running: true }, }); + assert.equal(result.exitCode, 0); assert.equal(result.stdout.includes("Daemon: running"), true); assert.equal(result.stdout.includes(resolve(dir)), true); assert.equal(result.stdout.includes("github:user/live"), true); + assert.equal(result.stdout.includes("Connection"), true); + assert.equal(result.stdout.includes("State"), true); + assert.equal(result.stdout.includes("Pending"), true); + assert.equal(result.stdout.includes("1 modified"), true); + assert.equal(result.stdout.includes(`${resolve(missing)} (path missing)`), true); + assert.equal(result.stdout.includes(`Run: stash relink ${resolve(missing)} `), true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("status shows daemon state and empty hint when no stashes are registered", async () => { + const dir = await makeTempDir("stash-cli-status-empty"); + + try { + const result = await runMain(dir, ["status"], { + serviceStatus: { installed: false, running: false }, + }); + + assert.equal(result.exitCode, 0); + assert.equal(result.stdout.includes("Daemon: stopped"), true); assert.equal( - result.stdout.includes(`path missing — run \`stash relink ${resolve(missing)} \``), + result.stdout.includes("No stashes registered. Run `stash connect ` to add one."), true, ); } finally { @@ -323,19 +357,71 @@ test("status prints daemon state, connection URIs, and missing-path guidance", a } }); +test("status renders last error rows for errored stashes", async () => { + const dir = await makeTempDir("stash-cli-status-error"); + const stashPath = getTestStashPath(dir); + + try { + await writeStashRecord(stashPath, "errored-stash", { + path: resolve(dir), + connections: [{ provider: "github", config: { id: "user/error" } }], + }); + await writeFile( + join(stashPath, "errored-stash", "status.json"), + JSON.stringify( + { + kind: "error", + lastSync: "2026-03-11T14:30:00.000Z", + summary: null, + error: "rate limit resets in 43s", + }, + null, + 2, + ), + "utf8", + ); + + const result = await runMain(dir, ["status"], { + serviceStatus: { installed: true, running: true }, + }); + + assert.equal(result.exitCode, 0); + assert.equal(result.stdout.includes("Last error"), true); + assert.equal(result.stdout.includes("rate limit resets in 43s"), true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("error output keeps the clack cancel flow and no-change outro", async () => { + const dir = await makeTempDir("stash-cli-error-shape"); + + try { + const result = await runMain(dir, ["disconnect"]); + + assert.equal(result.exitCode, 1); + assert.equal(result.stdout.includes("No stash found at"), true); + assert.equal(result.stdout.includes("No changes made."), true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test("start and stop delegate to the service and use the new daemon messaging", async () => { const dir = await makeTempDir("stash-cli-service"); try { const started = await runMain(dir, ["start"]); + assert.equal(started.exitCode, 0); assert.equal(started.calls.install, 1); - assert.equal(started.stdout.includes("Daemon started."), true); + assert.equal(started.stdout.includes("Daemon installed and running"), true); const stopped = await runMain(dir, ["stop"], { serviceStatus: { installed: true, running: true }, }); + assert.equal(stopped.exitCode, 0); assert.equal(stopped.calls.uninstall, 1); - assert.equal(stopped.stdout.includes("Daemon stopped."), true); + assert.equal(stopped.stdout.includes("Daemon stopped"), true); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/tests/integration/cli.integration.test.ts b/tests/integration/cli.integration.test.ts index f1859eb..67937b2 100644 --- a/tests/integration/cli.integration.test.ts +++ b/tests/integration/cli.integration.test.ts @@ -38,7 +38,7 @@ test("cli setup stores provider globals in ~/.stash/config.json", async () => { try { const result = await runCli(dir, ["setup", "github", "--token", "test-token"]); - assert.equal(result.stdout.includes("Configured github."), true); + assert.equal(result.stdout.includes("Configured github"), true); const config = JSON.parse(await readFile(join(getTestStashPath(dir), "config.json"), "utf8")); assert.deepEqual(config, { providers: { @@ -59,7 +59,8 @@ test("cli connect creates registry metadata without a local .stash directory", a const result = await runCli(dir, ["connect", "github:user/repo", "--token", "test-token"]); const canonicalDir = await realpath(dir); - assert.equal(result.stdout.includes(`Connected github:user/repo to ${canonicalDir}.`), true); + assert.equal(result.stdout.includes("github:user/repo"), true); + assert.equal(result.stdout.includes(canonicalDir), true); assert.equal(existsSync(join(dir, ".stash")), false); const ids = await listStashIds(getTestStashPath(dir)); @@ -84,7 +85,8 @@ test("cli connect accepts an explicit path argument", async () => { const result = await runCli(dir, ["connect", target, "github:user/repo", "--token", "test-token"]); const canonicalTarget = await realpath(target); - assert.equal(result.stdout.includes(`Connected github:user/repo to ${canonicalTarget}.`), true); + assert.equal(result.stdout.includes("github:user/repo"), true); + assert.equal(result.stdout.includes(canonicalTarget), true); const ids = await listStashIds(getTestStashPath(dir)); assert.equal(ids.length, 1); const record = JSON.parse( @@ -107,7 +109,9 @@ test("cli disconnect tombstones the stash record", async () => { const result = await runCli(dir, ["disconnect"]); - assert.equal(result.stdout.includes(`Disconnected github:user/repo from ${canonicalDir}.`), true); + assert.equal(result.stdout.includes("Disconnected"), true); + assert.equal(result.stdout.includes("github:user/repo"), true); + assert.equal(result.stdout.includes(canonicalDir), true); const record = JSON.parse( await readFile(join(getTestStashPath(dir), id, "stash.json"), "utf8"), ) as { connections: unknown[] }; @@ -129,7 +133,9 @@ test("cli relink rewrites the canonical path stored in stash.json", async () => const result = await runCli(dir, ["relink", dir, moved]); - assert.equal(result.stdout.includes(`Relinked ${canonicalDir} -> ${canonicalMoved}.`), true); + assert.equal(result.stdout.includes(canonicalDir), true); + assert.equal(result.stdout.includes(canonicalMoved), true); + assert.equal(result.stdout.includes("→"), true); const record = JSON.parse( await readFile(join(getTestStashPath(dir), id, "stash.json"), "utf8"), ) as { path: string }; @@ -165,6 +171,8 @@ test("cli status prints registered stashes from the registry root", async () => const result = await runCli(dir, ["status"]); assert.equal(result.stdout.includes("Daemon:"), true); + assert.equal(result.stdout.includes("Connection"), true); + assert.equal(result.stdout.includes("State"), true); assert.equal(result.stdout.includes(await realpath(dir)), true); assert.equal(result.stdout.includes("github:user/repo"), true); } finally { @@ -189,7 +197,9 @@ test("cli connect blocks git repos unless --dangerously-allow-git is passed", as const combined = (error.stdout ?? "") + (error.stderr ?? ""); assert.equal(error.code, 1); + assert.equal(combined.includes("is a git repository"), true); assert.equal(combined.includes("--dangerously-allow-git"), true); + assert.equal(combined.includes("No changes made."), true); const allowed = await runCli(dir, [ "connect", @@ -198,7 +208,8 @@ test("cli connect blocks git repos unless --dangerously-allow-git is passed", as "test-token", "--dangerously-allow-git", ]); - assert.equal(allowed.stdout.includes(`Connected github:user/repo to ${await realpath(dir)}.`), true); + assert.equal(allowed.stdout.includes("github:user/repo"), true); + assert.equal(allowed.stdout.includes(await realpath(dir)), true); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/tests/unit/daemon.test.ts b/tests/unit/daemon.test.ts index d5fec1e..3563eae 100644 --- a/tests/unit/daemon.test.ts +++ b/tests/unit/daemon.test.ts @@ -33,12 +33,28 @@ async function writeStashRecord( await writeFile(join(stashPath, id, "stash.json"), JSON.stringify(record, null, 2), "utf8"); } +async function waitFor( + check: () => boolean, + message: string, + timeoutMs = 500, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (check()) { + return; + } + await delay(10); + } + assert.fail(message); +} + test("daemon: reconciles registry entries, restarts on tuple changes, and stops removed stashes", async () => { const workspace = await makeTempDir("stash-daemon-registry"); const stashPath = join(workspace, "stash"); const configPath = join(stashPath, "config.json"); const dirA = join(workspace, "dir-a"); const dirB = join(workspace, "dir-b"); + let daemon: BackgroundDaemon | null = null; const started: string[] = []; const stopped: string[] = []; const logs: string[] = []; @@ -55,7 +71,7 @@ test("daemon: reconciles registry entries, restarts on tuple changes, and stops connections: [{ provider: "github", config: { id: "user/repo-a" } }], }); - const daemon = new BackgroundDaemon({ + daemon = new BackgroundDaemon({ configPath, pollMs: 20, log: (message) => logs.push(message), @@ -97,7 +113,10 @@ test("daemon: reconciles registry entries, restarts on tuple changes, and stops github: { token: "token-b" }, }); callbacks.get(configPath)?.(null, [{ type: "update", path: configPath }]); - await delay(30); + await waitFor( + () => started.length >= 2 && stopped.length >= 1, + "expected stash-a to restart after provider config changed", + ); assert.deepEqual(started, ["stash-a", "stash-a"]); assert.deepEqual(stopped, ["stash-a"]); @@ -107,21 +126,27 @@ test("daemon: reconciles registry entries, restarts on tuple changes, and stops connections: [{ provider: "github", config: { id: "user/repo-b" } }], }); callbacks.get(stashPath)?.(null, [{ type: "create", path: join(stashPath, "stash-b") }]); - await delay(30); + await waitFor( + () => started.length >= 3, + "expected stash-b to start after being added to the registry", + ); assert.deepEqual(started, ["stash-a", "stash-a", "stash-b"]); await rm(join(stashPath, "stash-a"), { recursive: true, force: true }); callbacks.get(stashPath)?.(null, [{ type: "delete", path: join(stashPath, "stash-a") }]); - await delay(30); + await waitFor( + () => stopped.length >= 2, + "expected stash-a to stop after being removed from the registry", + ); assert.deepEqual(stopped, ["stash-a", "stash-a"]); assert.ok(logs.some((message) => message.includes("watching stash-a"))); assert.ok(logs.some((message) => message.includes("watching stash-b"))); assert.ok(logs.some((message) => message.includes("stopped watching stash-a"))); - await daemon.stop(); } finally { + await daemon?.stop(); await rm(workspace, { recursive: true, force: true }); } }); @@ -273,6 +298,7 @@ test("daemon: writes missing status and revalidates tombstone deletion after sto const configPath = join(stashPath, "config.json"); const liveDir = join(workspace, "live-dir"); const missingDir = join(workspace, "missing-dir"); + let daemon: BackgroundDaemon | null = null; const started: string[] = []; const stopStarted: string[] = []; let releaseStop: (() => void) | null = null; @@ -294,7 +320,7 @@ test("daemon: writes missing status and revalidates tombstone deletion after sto connections: [{ provider: "github", config: { id: "user/live" } }], }); - const daemon = new BackgroundDaemon({ + daemon = new BackgroundDaemon({ configPath, pollMs: 20, subscribe: async () => ({ @@ -348,9 +374,9 @@ test("daemon: writes missing status and revalidates tombstone deletion after sto assert.equal(existsSync(join(stashPath, "live-stash")), true); assert.deepEqual(started, ["live-stash", "live-stash"]); - await daemon.stop(); } finally { releaseStop?.(); + await daemon?.stop(); await rm(workspace, { recursive: true, force: true }); } }); From edaca67e6d8eb1e1b1338bfe822a363cac712d13 Mon Sep 17 00:00:00 2001 From: Rupert Manfredi Date: Wed, 6 May 2026 00:25:24 -0400 Subject: [PATCH 5/9] feat(cli): plain output per style guide + github 404 disambiguation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the @clack/prompts UI machinery in favor of plain stdout/stderr output. Inlines the cli-ui helpers into cli-main.ts. Restores @inquirer/prompts for setup prompts. Keeps cli-table3 + picocolors. UI changes: - Drop @clack/prompts, restore @inquirer/prompts. - Delete src/cli-ui.ts; helpers (shortenHomePath, pluralize, CliCommandError, prompt wrappers, createColors, writeLine) inlined into src/cli-main.ts. - All commands now write plain text via writeLine. No box-drawing, no left margin to fight with cli-table3. - Errors go to stderr with 'Error:' prefix (red), exit 1. - Palette per docs/cli.md: green for verbs (Connected, Disconnected, Configured, Relinked, Already connected); cyan for URIs; yellow for daemon stopped / (path missing) / unsupported; red for Error: prefix and Last error values; grey for connectors, table labels, hint suffixes, time qualifiers; default fg for paths. - Status table: grey labels, cyan URI value, red Last error value; red on the State value when kind='error'. - Tilde-shorten paths in all output. - docs/cli.md rewritten as canonical CLI reference + style guide (palette table, grey-text rules, spacing, symbols, punctuation, per-command color annotations). - docs/plans/cli-style.md folded into docs/cli.md and removed. GitHub 404 disambiguation: - src/providers/github-provider.ts fetch(): when /branches/main returns 404, follow up with GET /repos/owner/repo to disambiguate 'repo exists, no main branch yet' (proceed with empty changeset) from 'repo not found / not authorized' (throw). - Surfaces in stash status as State: error and Last error: rather than the prior silent 'checked' loop. Bonus: src/utils/fs.ts gains a small retry on transient 'Unexpected end of JSON input' during config reloads (caught while testing). Tests: - Existing /branches/main 404 test updated to mock /repos/owner/repo → 200, matching the bootstrap case it actually represents. - New tests: fetch throws Repository not found on 404, fetch throws GitHub authentication failed on 401. - CLI integration tests rewritten to assert on plain content (Connected, URIs, paths) rather than box-drawing characters. 190/190 tests passing. Lint and build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/cli.md | 491 ++++++------------ package-lock.json | 424 +++++++++++++-- package.json | 2 +- src/cli-main.ts | 353 +++++++++---- src/cli-ui.ts | 124 ----- src/providers/github-provider.ts | 5 + src/utils/fs.ts | 26 +- .../background-cli.integration.test.ts | 94 ++-- tests/integration/cli.integration.test.ts | 53 +- tests/unit/github-provider.test.ts | 35 +- 10 files changed, 965 insertions(+), 642 deletions(-) delete mode 100644 src/cli-ui.ts diff --git a/docs/cli.md b/docs/cli.md index 62eee10..35a62f9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,457 +1,308 @@ # CLI -`docs/cli.md` is the canonical reference for stash's command surface and terminal UI. +The CLI is the user-facing layer for provider setup, connection management, status display, and background-service lifecycle. -- It defines what commands exist. -- It defines how those commands render output. -- It is the style guide for future CLI changes. +All commands print plain text. Errors go to **stderr** with an `Error:` prefix and exit code 1; success messages go to **stdout**. Color is applied only when stdout is a TTY (via `picocolors`); non-TTY output is the same characters without ANSI codes. -The design spec lives at [docs/plans/external-sync-target.md](./plans/external-sync-target.md). This file is the user-facing contract. +## Style -## Command surface +These rules apply across every command. Color is applied via `picocolors` and only when stdout is a TTY; non-TTY output is the same characters without ANSI codes. -The supported commands are: - -```bash -stash setup -stash connect [] [--dangerously-allow-git] -stash disconnect [] [] -stash relink [--dangerously-allow-git] -stash status -stash start -stash stop -stash daemon -``` - -Removed commands from earlier CLI versions: - -- `stash sync` -- `stash watch` -- `stash add` -- `stash join` -- `stash list` -- `stash share` -- `stash config set` -- `stash config get` - -`stash daemon` is internal. It exists so the service manager can launch the background process, but it is not part of the day-to-day user workflow. - -## Libraries - -The CLI uses: - -- `@clack/prompts` for intros, outros, prompts, and error blocks -- `picocolors` for the small fixed palette below -- `cli-table3` for `stash status` tables only - -No other terminal UI library should be introduced without updating this document. - -## Shared style - -### Layout +### Palette -Interactive commands use the same overall flow: +| Role | Color | Used for | +|----------|---------|------------------------------------------------------------------| +| default | (none) | the main content of any line — usually a path | +| accent | cyan | provider URIs (`github:owner/repo`) | +| success | green | confirmation verbs (`Connected`, `Disconnected`, `Configured`, `Relinked`, `Already connected`); daemon `running` | +| warn | yellow | daemon `stopped`; `(path missing)` warnings; `unsupported` state | +| error | red | `Error:` prefix on stderr; daemon `error` state; `Last error` values | +| muted | grey | labels, connectors, hints, time qualifiers — see below | -1. `intro("")` -2. body content -3. `outro("")` +### What gets greyed -Successful runs should feel like: +- **Table row labels** in `status` (`Connection`, `State`, `Last sync`, `Pending`, `Last error`) — let the values carry the weight. +- **Connector words** in confirmation lines — `to`, `from`, `→` separators between path and URI. +- **Time qualifiers** — `2m ago`, `14s ago`. +- **Hint suffixes** — the second line on `connect` (`Background sync is on. Run \`stash status\` to check.`) is muted; same for the `Run: stash relink …` line under a path-missing stash. -```text -┌ stash connect -│ -◇ ~/Notes → github:user/notes -│ -└ Background sync is on. Run `stash status` to check. -``` +### Paths -Errors use `cancel(...)`, followed by an outro: +Paths in output are **tilde-shortened** (`/Users/rupert/Notes` → `~/Notes`). Stored canonical paths on disk remain absolute; this is rendering only. -```text -┌ stash connect -│ -■ ~/code/app is a git repository. -│ Pass --dangerously-allow-git if you really want stash to sync this folder. -│ -└ No changes made. -``` - -### Palette +### Symbols -Use only these roles: +- **`→`** (muted) separates source and destination on transitions (`relink ~/old → ~/new`, `connect ~/Notes → github:owner/notes` in non-confirmation contexts). +- **No emojis** in any output. +- **No bold** — color does the emphasis work. -- `gray` for labels and secondary detail -- default terminal color for values and body text -- `cyan` for highlighted URIs inside prose when needed -- `green` for success states -- `yellow` for warnings and degraded states -- `red` for error states +### Punctuation -Do not add bold, background colors, emojis, or custom themes. +- **No trailing periods** on confirmation lines. Errors do end with periods. +- Connectors are lowercase prose (`to`, `from`, `at`), not symbols. -### Symbols +### Spacing -- Use `→` for transitions -- Use `·` only inside prose-like summary lines -- Drop trailing periods on confirmation lines -- Keep periods on error sentences +- **Single-line confirmations stay tight** — no blank lines around them. +- **Two-line `connect` output** (success + daemon hint) has no blank line between the two lines; the hint is rendered directly under the confirmation in muted grey. +- **`status`** uses a blank line between the daemon header, each per-stash block, and the count outro. +- **Errors** are tight: summary line on stderr, any detail lines immediately under it, no trailing blank line. -### Rendered paths +## Commands -All displayed paths are shortened from `$HOME/...` to `~/...`. +### `stash setup ` -- This is rendering only. -- Paths stored on disk stay canonical absolute paths. -- `status`, `connect`, `disconnect`, and `relink` all render shortened paths. +Writes global provider configuration to `~/.stash/config.json`. Always prompts for the provider's `global` fields; values from CLI flags (e.g. `--token`) override prompts. -### Prompts +Re-running `setup` re-prompts and overwrites existing values. -Prompts use clack's `text()` and `password()` helpers. +Example: -- Reuse `ConfigItem.label` -- Do not prefix with filler text like "Please enter..." -- Validate before any config write +```bash +stash setup github +# prompts for: Personal access token -### Errors +stash setup github --token ghp_… # no prompt +``` -Errors always follow this shape: +Output: ```text -┌ stash -│ -■ -│ -│ -└ No changes made. +Configured github ``` -Rules: - -- Use `cancel("")` -- Keep the first line short and specific -- Add a second indented line only if the user needs a next step -- Exit with status code `1` -- Do not fall through to commander's default `error:` output - -### Don'ts +Color: `Configured` green; provider name default. -- No emojis -- No raw stack traces for normal user-facing failures -- No box-drawing assertions in tests -- No trailing periods on successful confirmation lines -- No ad-hoc table formatting outside `status` +### `stash connect [] [--dangerously-allow-git]` -## Commands +Connects a folder to a provider URI. If `` is omitted, defaults to the current directory. -### `stash setup ` +The URI form is `:`, e.g. `github:owner/repo`. The CLI parses the provider, validates the connection, and registers the stash under `~/.stash//`. -Stores provider-level global config in `~/.stash/config.json`. +If the provider's global fields are not yet set (e.g. github token), `connect` prompts for them and persists the result to `~/.stash/config.json` *only after* the connection validates successfully. -Behavior: +Examples: -- Looks up the provider's `global: true` config fields -- Accepts those fields from CLI flags or interactive prompts -- Overwrites the provider's stored global config +```bash +stash connect github:owner/repo # current directory +stash connect ~/Notes github:owner/notes # explicit path +``` -Example: +Output (new connection): ```text -┌ stash setup -│ -◇ github -│ -└ Configured github +Connected github:owner/repo to ~/Notes +Background sync is on. Run `stash status` to check. ``` -Prompted secret fields use masked input. - -### `stash connect [] [--dangerously-allow-git]` - -Connects a folder to a provider URI. +Color: `Connected` green; `github:owner/repo` cyan; `to` muted grey; `~/Notes` default; the second line entirely muted grey. -Behavior: +The second line appears only when the daemon is running. If the daemon isn't running (or the platform doesn't support it), only the first line prints. -- `` defaults to the current working directory -- `` uses the form `:` -- The rendered success line shows the shortened path and URI -- If the daemon is running, the outro is: - `Background sync is on. Run \`stash status\` to check.` -- If the daemon is not running or unsupported, do not add a daemon-status hint - -Success example: +Output (idempotent re-run on the same URI): ```text -┌ stash connect -│ -◇ ~/Notes → github:user/notes -│ -└ Background sync is on. Run `stash status` to check. +Already connected — github:owner/repo ``` -Idempotent reconnect example: +Color: `Already connected` green; `—` muted grey; URI cyan. -```text -┌ stash connect -│ -└ Already connected — github:user/notes -``` - -Git-protected path example: +Output (`.git/` detected without `--dangerously-allow-git`), to **stderr**, exit 1: ```text -┌ stash connect -│ -■ ~/code/myrepo is a git repository. -│ Pass --dangerously-allow-git if you really want stash to sync this folder. -│ -└ No changes made. +Error: ~/code/myrepo is a git repository. +Pass --dangerously-allow-git if you really want stash to sync this folder. ``` -Multi-connection-not-supported example: +Output (path already connected to a different URI), to **stderr**, exit 1: ```text -┌ stash connect -│ -■ ~/Notes is already connected to github:user/old-notes. -│ Multi-connection per stash is not yet supported. Run `stash disconnect ~/Notes` first. -│ -└ No changes made. +Error: ~/Notes is already connected to github:owner/old-repo. +Multi-connection per stash is not yet supported. Run `stash disconnect ~/Notes` first. ``` ### `stash disconnect [] []` -Disconnects one connection from a stash. +Disconnects a stash from a provider URI. If `` is omitted, defaults to the current directory. If `` is omitted and the stash has exactly one connection, drops that connection. -Behavior: +If the stash has multiple connections (a future feature), `` is required. -- With no arguments, targets the current working directory -- With only ``, targets that stash -- With only ``, targets the current working directory and removes that URI -- With both `` and ``, removes that URI from that stash -- If the last connection is removed, the CLI writes a tombstone; the daemon owns deletion +The CLI atomically rewrites `~/.stash//stash.json` to drop the connection. The per-stash directory is *not* deleted by the CLI — when the last connection is removed, the daemon picks up the empty-connections tombstone and cleans up the directory. -Success example: +Examples: -```text -┌ stash disconnect -│ -└ Disconnected github:user/notes from ~/Notes +```bash +stash disconnect # current directory, only connection +stash disconnect ~/Notes # explicit path, only connection +stash disconnect ~/Notes github:owner/notes # explicit path + URI +stash disconnect github:owner/notes # URI only (looks up cwd) ``` -Multiple-connections example: +Output: ```text -┌ stash disconnect -│ -■ Multiple connections at ~/Notes: -│ github:user/notes -│ github:user/notes-mirror -│ -└ Pass the URI to disconnect: stash disconnect ~/Notes +Disconnected github:owner/notes from ~/Notes ``` -Unknown stash example: +Color: `Disconnected` green; URI cyan; `from` muted grey; path default. + +Output (no stash at path), to **stderr**, exit 1: ```text -┌ stash disconnect -│ -■ No stash found at ~/Missing -│ -└ No changes made. +Error: No stash found at ~/Notes. ``` -Unknown URI example: +Output (multiple connections, no `` given), to **stderr**, exit 1: ```text -┌ stash disconnect -│ -■ Connection not found: github:user/missing -│ -└ No changes made. +Error: Multiple connections at ~/Notes: + github:owner/notes + github:owner/notes-mirror +Pass the URI to disconnect: stash disconnect ~/Notes ``` ### `stash relink [--dangerously-allow-git]` -Moves an existing stash registration from one path to another without changing its stash id. +Moves a registered stash to a new path. The old path may no longer exist (e.g. you renamed the folder). The new path must exist. -Success example: +The CLI atomically rewrites `~/.stash//stash.json` with the new `path` field. The stash's id stays the same. -```text -┌ stash relink -│ -└ ~/Old/Notes → ~/Documents/Notes +Example: + +```bash +stash relink ~/OldFolder ~/Documents/NewFolder ``` -Git-protected new path example: +Output: ```text -┌ stash relink -│ -■ ~/code/newrepo is a git repository. -│ Pass --dangerously-allow-git if you really want stash to sync this folder. -│ -└ No changes made. +Relinked ~/OldFolder → ~/Documents/NewFolder ``` -Duplicate-path example: +Color: `Relinked` green; `→` muted grey; both paths default. + +Output (no stash at old path), to **stderr**, exit 1: ```text -┌ stash relink -│ -■ A stash already exists at ~/Documents/Notes. -│ -└ No changes made. +Error: No stash found at ~/OldFolder. ``` -### `stash status` +Output (a stash already exists at the new path), to **stderr**, exit 1: -Shows daemon state first, then one block per registered stash. +```text +Error: A stash already exists at ~/Documents/NewFolder. +``` -Rules: +### `stash status` -- Always render `Daemon: ` even when no stashes are registered -- Color daemon state with `picocolors` - - running: green - - stopped: yellow - - unsupported: red - - error: red -- Each live stash uses `cli-table3` -- Missing-path stashes do not use a table -- `Last error` only appears when there is an error -- The outro is either a count summary or the empty-state hint +Shows daemon liveness and a per-stash block for every registered stash. -Healthy stash example: +Output: ```text -┌ stash status -│ -◇ Daemon: running -│ -│ ~/Notes -│ ┌──────────────┬────────────────────┐ -│ │ Connection │ github:user/notes │ -│ │ State │ idle │ -│ │ Last sync │ 2m ago │ -│ │ Pending │ 3 modified, 1 added│ -│ └──────────────┴────────────────────┘ -│ -└ 1 stash +Daemon: running + +~/Notes +┌────────────┬──────────────────────────┐ +│ Connection │ github:owner/notes │ +│ State │ idle │ +│ Last sync │ 2m ago │ +│ Pending │ 3 modified, 1 added │ +└────────────┴──────────────────────────┘ + +~/Repos/television/.television +┌────────────┬─────────────────────────┐ +│ Connection │ github:owner/tv-state │ +│ State │ syncing │ +│ Last sync │ 14s ago │ +└────────────┴─────────────────────────┘ + +2 stashes ``` -Mixed state example: +The per-stash table is rendered with `cli-table3`. Rows that are absent (no error, no pending changes) are omitted from the table — `Last error` only appears when an error is present. -```text -┌ stash status -│ -◇ Daemon: running -│ -│ ~/Notes -│ ┌──────────────┬────────────────────┐ -│ │ Connection │ github:user/notes │ -│ │ State │ syncing │ -│ │ Last sync │ 14s ago │ -│ └──────────────┴────────────────────┘ -│ -│ ~/Old/Folder (path missing) -│ Run: stash relink ~/Old/Folder -│ -└ 2 stashes -``` +Color in the table: row labels (left column) muted grey, values default — except the URI in the `Connection` row which is cyan, and `Last error` value which is red. -Error row example: +If a stash's path no longer exists, the table is replaced with a one-line warning: ```text -┌ stash status -│ -◇ Daemon: running -│ -│ ~/Notes -│ ┌──────────────┬─────────────────────────────┐ -│ │ Connection │ github:user/notes │ -│ │ State │ error │ -│ │ Last sync │ 2m ago │ -│ │ Last error │ rate limit resets in 43s │ -│ └──────────────┴─────────────────────────────┘ -│ -└ 1 stash +~/Old/Folder (path missing) +Run: stash relink ~/Old/Folder ``` -Empty state example: +Color: path default; `(path missing)` yellow; the `Run:` hint line entirely muted grey. + +Output (no stashes registered): ```text -┌ stash status -│ -◇ Daemon: running -│ -└ No stashes registered. Run `stash connect ` to add one. +Daemon: running + +No stashes registered. Run `stash connect ` to add one. ``` +The `Daemon:` line is colored — `running` green, `stopped` yellow, `unsupported` yellow, `error` red — when stdout is a TTY. + ### `stash start` -Installs and starts the daemon service for the current user. +Installs and starts the background sync daemon as a system service (launchd on macOS, systemd on Linux). -Success example: +Output: ```text -┌ stash start -│ -└ Daemon installed and running +Daemon installed and running ``` -Unsupported example: +Color: `running` green; the rest default. + +Output on unsupported platforms, to **stderr**, exit 1: ```text -┌ stash start -│ -■ This platform does not support background services yet. -│ -└ No changes made. +Error: This platform does not support background services yet. ``` ### `stash stop` -Stops and uninstalls the daemon service for the current user. +Stops and uninstalls the background sync daemon. -Success example: +Output: ```text -┌ stash stop -│ -└ Daemon stopped +Daemon stopped ``` -Already stopped example: +Output if the daemon is not running, to **stderr**, exit 1: ```text -┌ stash stop -│ -■ Daemon is not running. -│ -└ No changes made. +Error: Daemon is not running. ``` -### `stash daemon` +### `stash daemon` (hidden) + +Runs the background daemon in the foreground. Used internally by the system service; not normally invoked by users. Hidden from `--help`. + +## Provider config schema -Internal command used by the service manager. +Each provider class declares a `static configSchema: ConfigItem[]`. Each item has: -- Not documented in top-level user help beyond existing hidden-command behavior -- No CLI polish requirements beyond correct execution +- `name` — the field key (used for both the CLI flag `--` and the prompt label fallback). +- `label` — the human-friendly prompt text. +- `secret?` — if true, the prompt masks input (used for tokens). +- `global?` — if true, the field is provider-level (stored in `~/.stash/config.json`); if false (or omitted), it's per-connection (sourced from the URI). -## Test guidance +`stash setup` collects only the `global: true` fields. `stash connect` collects any `global: true` fields that aren't already set, plus per-connection fields from the URI. -CLI integration tests should assert on user-visible content, not frame glyphs. +GitHub provider: -Good assertions: +| name | label | secret | global | +|-------|------------------------------|--------|--------| +| token | Personal access token | ✓ | ✓ | +| id | Identifier (e.g. owner/repo) | | | -- `"Connected"` -- `"github:user/notes"` -- `"~/Notes"` or the full expected path when testing a non-tilde environment -- `"Daemon: running"` -- `"No changes made."` +The `id` field is sourced from the URI: `github:owner/repo` parses to `{ provider: "github", config: { id: "owner/repo" } }`. -Bad assertions: +## Errors and exit codes -- full box-drawing snapshots -- exact clack glyph placement -- ANSI escape sequences +- Successful commands exit with code 0; their output goes to **stdout**. +- Any error exits with code 1; the message goes to **stderr** with an `Error:` prefix. +- Errors do not produce partial state. If a command fails before its mutation step, no files have been written. Mutations under the registry lock (`connect`, `disconnect`, `relink`) are atomic — either the `stash.json` write succeeds in full or the operation aborts. diff --git a/package-lock.json b/package-lock.json index 3a9060a..5bcbcd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.4.1", "license": "MIT", "dependencies": { - "@clack/prompts": "^1.3.0", + "@inquirer/prompts": "^8.4.2", "@parcel/watcher": "^2.5.6", "@rupertsworld/daemon": "^0.1.1", "@rupertsworld/disposable": "^0.1.0", @@ -33,34 +33,6 @@ "typescript-eslint": "^8.57.0" } }, - "node_modules/@clack/core": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.3.0.tgz", - "integrity": "sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA==", - "license": "MIT", - "dependencies": { - "fast-wrap-ansi": "^0.2.0", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 20.12.0" - } - }, - "node_modules/@clack/prompts": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.3.0.tgz", - "integrity": "sha512-GgcWwRCs/xPtaqlMy8qRhPnZf9vlWcWZNHAitnVQ3yk7JmSralSiq5q07yaffYE8SogtDm7zFeKccx1QNVARpw==", - "license": "MIT", - "dependencies": { - "@clack/core": "1.3.0", - "fast-string-width": "^3.0.2", - "fast-wrap-ansi": "^0.2.0", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 20.12.0" - } - }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -251,6 +223,334 @@ "url": "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/sponsors/nzakas" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.5.tgz", + "integrity": "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.1.4.tgz", + "integrity": "sha512-w6KF8ZYRvqHhROkOTHXYC3qIV/KYEu5o12oLqQySvch61vrYtRxNSHTONSdJqWiFJPlCUQAHT5OgOIyuTr+MHQ==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.9", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.0.12.tgz", + "integrity": "sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.1.9", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.1.9.tgz", + "integrity": "sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.5", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.1.1.tgz", + "integrity": "sha512-6y11LgmNpmn5D2aB5FgnCfBUBK8ZstwLCalyJmORcJZ/WrhOjm16mu6eSqIx8DnErxDqSLr+Jkp+GP8/Nwd5tA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@inquirer/external-editor": "^3.0.0", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.0.13.tgz", + "integrity": "sha512-dF2zvrFo9LshkcB23/O1il13kBkBltWIXzut1evfbuBLXMiGIuC45c+ZQ0uukjCDsvI8OWqun4FRYMnzFCQa3g==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.0.tgz", + "integrity": "sha512-lDSwMgg+M5rq6JKBYaJwSX6T9e/HK2qqZ1oxmOwn4AQoJE5D+7TumsxLGC02PWS//rkIVqbZv3XA3ejsc9FYvg==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.5.tgz", + "integrity": "sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.0.12.tgz", + "integrity": "sha512-uiMFBl4LqFzJClh80Q3f9hbOFJ6kgkDWI4LjAeBuyO6EanVVMF69AgOvpi1qdqjDSjDN6578B6nky9ceEpI+1Q==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.0.12.tgz", + "integrity": "sha512-/vrwhEf7Xsuh+YlHF4IjSy3g1cyrQuPaSiHIxCEbLu8qnfvrcvJyCkoktOOF+xV9gSb77/G0n3h04RbMDW2sIg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.0.12.tgz", + "integrity": "sha512-CBh7YHju623lxJRcAOo498ZUwIuMy63bqW/vVq0tQAZVv+lkWlHkP9ealYE1utWSisEShY5VMdzIXRmyEODzcQ==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.4.2.tgz", + "integrity": "sha512-XJmn/wY4AX56l1BRU+ZjDrFtg9+2uBEi4JvJQj82kwJDQKiPgSn4CEsbfGGygS4Gw6rkL4W18oATjfVfaqub2Q==", + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.1.4", + "@inquirer/confirm": "^6.0.12", + "@inquirer/editor": "^5.1.1", + "@inquirer/expand": "^5.0.13", + "@inquirer/input": "^5.0.12", + "@inquirer/number": "^4.0.12", + "@inquirer/password": "^5.0.12", + "@inquirer/rawlist": "^5.2.8", + "@inquirer/search": "^4.1.8", + "@inquirer/select": "^5.1.4" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.2.8.tgz", + "integrity": "sha512-Su7FQvp5buZmCymN3PPoYv31ZQQX4ve2j02k7piGgKAWgE+AQRB5YoYVveGXcl3TZ9ldgRMSxj56YfDFmmaqLg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.1.8.tgz", + "integrity": "sha512-fGiHKGD6DyPIYUWxoXnQTeXeyYqSOUrasDMABBmMHUalH/LxkuzY0xVRtimXAt1sUeeyYkVuKQx1bebMuN11Kw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.1.9", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.1.4.tgz", + "integrity": "sha512-2kWcGKPMLAXAWRp1AH1SLsQmX+j0QjeljyXMUji9WMZC8nRDO0b7qquIGr6143E7KMLt3VAIGNXzwa/6PXQs4Q==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.9", + "@inquirer/figures": "^2.0.5", + "@inquirer/type": "^4.0.5" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.5.tgz", + "integrity": "sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==", + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", @@ -590,7 +890,7 @@ "version": "25.3.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz", "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" @@ -898,6 +1198,12 @@ "node": "18 || 20 || >=22" } }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "license": "MIT" + }, "node_modules/cli-table3": { "version": "0.6.5", "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", @@ -913,6 +1219,15 @@ "@colors/colors": "1.5.0" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", @@ -1349,6 +1664,22 @@ "node": ">=10.13.0" } }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1506,6 +1837,15 @@ "dev": true, "license": "MIT" }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -1643,6 +1983,12 @@ "node": ">=6" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -1679,11 +2025,17 @@ "node": ">=8" } }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "license": "MIT" + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "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/sponsors/isaacs" + } }, "node_modules/string-width": { "version": "4.2.3", @@ -1796,7 +2148,7 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/uri-js": { diff --git a/package.json b/package.json index 2a39939..36540de 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ }, "license": "MIT", "dependencies": { - "@clack/prompts": "^1.3.0", + "@inquirer/prompts": "^8.4.2", "@parcel/watcher": "^2.5.6", "@rupertsworld/daemon": "^0.1.1", "@rupertsworld/disposable": "^0.1.0", diff --git a/src/cli-main.ts b/src/cli-main.ts index 4e3ca7c..b4afc2b 100644 --- a/src/cli-main.ts +++ b/src/cli-main.ts @@ -3,16 +3,11 @@ import { access, constants, readdir, realpath, readFile } from "node:fs/promises import { createRequire } from "node:module"; import { delimiter, join, resolve } from "node:path"; import type { Readable } from "node:stream"; +import { input as promptInput, password as promptPasswordInput } from "@inquirer/prompts"; import Table from "cli-table3"; import { Daemon, UnsupportedPlatformError } from "@rupertsworld/daemon"; import { Command, Option } from "commander"; -import { - CliCommandError, - cliError, - createCliUi, - pluralize, - shortenHomePath, -} from "./cli-ui.ts"; +import picocolors from "picocolors"; import { getProviderConfig, getStashDirPath, @@ -33,6 +28,7 @@ import type { ConfigItem, GlobalConfig, ProviderClass, StashRecord } from "./typ const SERVICE_NAME = "stash-background"; const SERVICE_DESCRIPTION = "Stash background sync"; +const URI_PATTERN = /\b[a-z][a-z0-9-]*:[^\s]+/g; type ServiceHandle = { install(): Promise; @@ -88,6 +84,110 @@ type StatusDeps = Pick & { getService: () => Promise; }; +type Colors = ReturnType; + +type PromptIo = { + input?: NodeJS.ReadableStream; + output?: NodeJS.WritableStream; +}; + +export class CliCommandError extends Error { + readonly summary: string; + readonly details: string[]; + + constructor(summary: string, details: string[] = []) { + super(summary); + this.name = "CliCommandError"; + this.summary = summary; + this.details = details; + } +} + +export function cliError(summary: string, details: string[] = []): CliCommandError { + return new CliCommandError(summary, details); +} + +export function shortenHomePath(path: string, home = process.env.HOME): string { + if (!home) { + return path; + } + if (path === home) { + return "~"; + } + return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path; +} + +export function pluralize(count: number, singular: string, plural = `${singular}s`): string { + return `${count} ${count === 1 ? singular : plural}`; +} + +export function createColors(isTTY: boolean): ReturnType { + return picocolors.createColors(isTTY); +} + +export function writeLine(stream: NodeJS.WritableStream, text = ""): void { + stream.write(`${text}\n`); +} + +function writeBlankLine(stream: NodeJS.WritableStream): void { + writeLine(stream); +} + +function isPromptCancelled(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + return ( + error.name === "ExitPromptError" || + error.name === "AbortPromptError" || + /cancelled|canceled|force closed|sigint/i.test(error.message) + ); +} + +export async function promptText(label: string, io: PromptIo = {}): Promise { + try { + return await promptInput( + { + message: label, + validate: (candidate) => { + if (!candidate || candidate.trim().length === 0) { + return `${label} is required`; + } + return true; + }, + }, + io, + ); + } catch (error) { + if (isPromptCancelled(error)) { + throw cliError("Prompt cancelled."); + } + throw error; + } +} + +export async function promptPassword(label: string, io: PromptIo = {}): Promise { + try { + return await promptPasswordInput( + { + message: label, + validate: (candidate) => { + if (!candidate || candidate.trim().length === 0) { + return `${label} is required`; + } + return true; + }, + }, + io, + ); + } catch (error) { + if (isPromptCancelled(error)) { + throw cliError("Prompt cancelled."); + } + throw error; + } +} + function getGlobalConfigItems(provider: ProviderClass): ConfigItem[] { return provider.configSchema.filter((item) => item.global === true); } @@ -101,17 +201,17 @@ function getCliValue( } async function promptField( - ui: ReturnType, + io: PromptIo, field: ConfigItem, ): Promise { if (field.secret) { - return ui.promptPassword(field.label); + return promptPassword(field.label, io); } - return ui.promptText(field.label); + return promptText(field.label, io); } async function collectFields( - ui: ReturnType, + io: PromptIo, fields: ConfigItem[], valuesFromCli: Record, current: Record = {}, @@ -126,7 +226,7 @@ async function collectFields( values[field.name] = cliValue; continue; } - values[field.name] = await promptField(ui, field); + values[field.name] = await promptField(io, field); } return values; } @@ -187,6 +287,62 @@ function toCliCommandError(error: unknown): CliCommandError { return cliError(asSentence(String(error))); } +function colorizeUris(text: string, colors: Colors): string { + return text.replace(URI_PATTERN, (uri) => { + const punctuation = /[.,!?]$/.test(uri) ? uri.slice(-1) : ""; + const bareUri = punctuation ? uri.slice(0, -1) : uri; + return `${colors.cyan(bareUri)}${punctuation}`; + }); +} + +function renderCliError(stream: NodeJS.WritableStream, error: CliCommandError, colors: Colors): void { + writeLine(stream, `${colors.red("Error:")} ${colorizeUris(error.summary, colors)}`); + for (const detail of error.details) { + writeLine(stream, colorizeUris(detail, colors)); + } +} + +function formatConfigured(providerName: string, colors: Colors): string { + return `${colors.green("Configured")} ${providerName}`; +} + +function formatAlreadyConnected(uri: string, colors: Colors): string { + return `${colors.green("Already connected")} ${colors.gray("—")} ${colors.cyan(uri)}`; +} + +function formatConnected(uri: string, path: string, colors: Colors): string { + return `${colors.green("Connected")} ${colors.cyan(uri)} ${colors.gray("to")} ${path}`; +} + +function formatDisconnected(uri: string, path: string, colors: Colors): string { + return `${colors.green("Disconnected")} ${colors.cyan(uri)} ${colors.gray("from")} ${path}`; +} + +function formatRelinked(oldPath: string, newPath: string, colors: Colors): string { + return `${colors.green("Relinked")} ${oldPath} ${colors.gray("→")} ${newPath}`; +} + +function formatAlreadyLinked(path: string, colors: Colors): string { + return `${colors.green("Already linked")} ${colors.gray("—")} ${path}`; +} + +function formatConnectHint(colors: Colors): string { + return colors.gray("Background sync is on. Run `stash status` to check."); +} + +function formatDaemonState(state: "running" | "stopped" | "unsupported" | "error", colors: Colors): string { + if (state === "running") { + return `Daemon: ${colors.green("running")}`; + } + if (state === "stopped") { + return `Daemon: ${colors.yellow("stopped")}`; + } + if (state === "unsupported") { + return `Daemon: ${colors.yellow("unsupported")}`; + } + return `Daemon: ${colors.red("error")}`; +} + async function isExecutable(path: string): Promise { try { await access(path, constants.X_OK); @@ -325,23 +481,10 @@ function formatPendingChanges(status: PersistedStatus | null): string | null { return parts.length > 0 ? parts.join(", ") : null; } -function formatDaemonState( - state: "running" | "stopped" | "unsupported" | "error", - ui: ReturnType, -): string { - const { colors } = ui; - if (state === "running") { - return `Daemon: ${colors.green("running")}`; - } - if (state === "stopped") { - return `Daemon: ${colors.yellow("stopped")}`; - } - return `Daemon: ${colors.red(state)}`; -} - function renderStatusTable( entry: RegistryEntry, status: PersistedStatus | null, + colors: Colors, ): string { const table = new Table({ style: { @@ -353,22 +496,23 @@ function renderStatusTable( }); table.push([ - "Connection", - entry.record.connections.map((connection) => formatUri(connection)).join("\n"), + colors.gray("Connection"), + entry.record.connections.map((connection) => colors.cyan(formatUri(connection))).join("\n"), ]); - table.push(["State", describeStatusKind(status)]); + const statusKind = describeStatusKind(status); + table.push([colors.gray("State"), statusKind === "error" ? colors.red(statusKind) : statusKind]); if (status?.lastSync) { - table.push(["Last sync", formatTimeAgo(new Date(status.lastSync))]); + table.push([colors.gray("Last sync"), colors.gray(formatTimeAgo(new Date(status.lastSync)))]); } const pending = formatPendingChanges(status); if (pending) { - table.push(["Pending", pending]); + table.push([colors.gray("Pending"), pending]); } if (status?.error) { - table.push(["Last error", status.error]); + table.push([colors.gray("Last error"), colors.red(status.error)]); } return table.toString(); @@ -378,20 +522,16 @@ async function runSetup( providerName: string, valuesFromCli: Record, deps: SetupDeps, - ui: ReturnType, + io: PromptIo, + stdout: NodeJS.WritableStream, + colors: Colors, ): Promise { const provider = getProvider(providerName); const globalFields = getGlobalConfigItems(provider); const globalConfig = await deps.readGlobalConfig(); - const values = await collectFields( - ui, - globalFields, - valuesFromCli, - getProviderConfig(globalConfig, providerName), - ); + const values = await collectFields(io, globalFields, valuesFromCli); await deps.writeGlobalConfig(setProviderConfig(globalConfig, providerName, values)); - ui.step(providerName); - ui.outro(`Configured ${providerName}`); + writeLine(stdout, formatConfigured(providerName, colors)); } function normalizeConnectArgs(first: string, second?: string): { pathArg?: string; uri: string } { @@ -419,7 +559,9 @@ async function runConnect( second: string | undefined, valuesFromCli: Record, deps: ConnectDeps, - ui: ReturnType, + io: PromptIo, + stdout: NodeJS.WritableStream, + colors: Colors, ): Promise { const { pathArg, uri } = normalizeConnectArgs(first, second); const targetPath = pathArg ?? deps.cwd(); @@ -431,7 +573,7 @@ async function runConnect( let globalConfig = await deps.readGlobalConfig(); const existingGlobalValues = getProviderConfig(globalConfig, parsed.provider); const globalValues = await collectFields( - ui, + io, getGlobalConfigItems(provider), valuesFromCli, existingGlobalValues, @@ -491,12 +633,12 @@ async function runConnect( ); if (outcome === "already-connected") { - ui.outro(`Already connected — ${uri}`); + writeLine(stdout, formatAlreadyConnected(uri, colors)); return; } const displayPath = shortenHomePath(canonicalPath); - ui.step(`${displayPath} → ${uri}`); + writeLine(stdout, formatConnected(uri, displayPath, colors)); let daemonRunning = false; try { @@ -508,18 +650,16 @@ async function runConnect( } if (daemonRunning) { - ui.outro("Background sync is on. Run `stash status` to check."); - return; + writeLine(stdout, formatConnectHint(colors)); } - - ui.outro(`Connected ${uri} to ${displayPath}`); } async function runDisconnect( first: string | undefined, second: string | undefined, deps: DisconnectDeps, - ui: ReturnType, + stdout: NodeJS.WritableStream, + colors: Colors, ): Promise { const { pathArg, uri } = normalizeDisconnectArgs(first, second); const lookupPath = await resolveLookupPath(pathArg ?? deps.cwd()); @@ -545,7 +685,7 @@ async function runDisconnect( } if (currentUris.length !== 1) { throw cliError(`Multiple connections at ${displayPath}:`, [ - ...currentUris, + ...currentUris.map((currentUri) => ` ${currentUri}`), `Pass the URI to disconnect: stash disconnect ${displayPath} `, ]); } @@ -569,7 +709,7 @@ async function runDisconnect( { stashPath }, ); - ui.outro(`Disconnected ${disconnectedUri} from ${shortenHomePath(disconnectedPath)}`); + writeLine(stdout, formatDisconnected(disconnectedUri, shortenHomePath(disconnectedPath), colors)); } async function runRelink( @@ -577,7 +717,8 @@ async function runRelink( newPath: string, dangerouslyAllowGit: boolean, deps: Pick, - ui: ReturnType, + stdout: NodeJS.WritableStream, + colors: Colors, ): Promise { const oldCanonical = await resolveLookupPath(oldPath); const newCanonical = await canonicalizeExistingPath(newPath); @@ -586,7 +727,7 @@ async function runRelink( const displayNew = shortenHomePath(newCanonical); if (oldCanonical === newCanonical) { - ui.outro(`Already linked — ${displayNew}`); + writeLine(stdout, formatAlreadyLinked(displayNew, colors)); return; } @@ -609,12 +750,13 @@ async function runRelink( { stashPath }, ); - ui.outro(`${displayOld} → ${displayNew}`); + writeLine(stdout, formatRelinked(displayOld, displayNew, colors)); } async function runStatus( deps: StatusDeps, - ui: ReturnType, + stdout: NodeJS.WritableStream, + colors: Colors, ): Promise { const daemonState = await deps .getService() @@ -622,7 +764,8 @@ async function runStatus( .then((status) => (status.running ? "running" : "stopped")) .catch((error: unknown) => (isUnsupportedPlatformError(error) ? "unsupported" : "error")); - ui.step(formatDaemonState(daemonState, ui)); + writeLine(stdout, formatDaemonState(daemonState, colors)); + writeBlankLine(stdout); const stashPath = deps.stashPath ?? getStashPath(); const entries = (await readRegistry(stashPath)) @@ -630,30 +773,34 @@ async function runStatus( .sort((a, b) => a.record.path.localeCompare(b.record.path)); if (entries.length === 0) { - ui.outro("No stashes registered. Run `stash connect ` to add one."); + writeLine(stdout, "No stashes registered. Run `stash connect ` to add one."); return; } - for (const entry of entries) { + for (const [index, entry] of entries.entries()) { + if (index > 0) { + writeBlankLine(stdout); + } const displayPath = shortenHomePath(entry.record.path); const status = await readPersistedStatus(stashPath, entry.id); if (status?.kind === "missing" || !existsSync(entry.record.path)) { - ui.message([ - `${displayPath} (path missing)`, - `Run: stash relink ${displayPath} `, - ]); + writeLine(stdout, `${displayPath} ${colors.yellow("(path missing)")}`); + writeLine(stdout, colors.gray(`Run: stash relink ${displayPath} `)); continue; } - ui.message([displayPath, renderStatusTable(entry, status)]); + writeLine(stdout, displayPath); + writeLine(stdout, renderStatusTable(entry, status, colors)); } - ui.outro(pluralize(entries.length, "stash")); + writeBlankLine(stdout); + writeLine(stdout, pluralize(entries.length, "stash")); } async function runStart( serviceHandle: ServiceHandle, - ui: ReturnType, + stdout: NodeJS.WritableStream, + colors: Colors, ): Promise { try { await serviceHandle.install(); @@ -663,12 +810,12 @@ async function runStart( } throw error; } - ui.outro("Daemon installed and running"); + writeLine(stdout, `Daemon installed and ${colors.green("running")}`); } async function runStop( serviceHandle: ServiceHandle, - ui: ReturnType, + stdout: NodeJS.WritableStream, ): Promise { try { const status = await serviceHandle.status(); @@ -682,7 +829,7 @@ async function runStop( } throw error; } - ui.outro("Daemon stopped"); + writeLine(stdout, "Daemon stopped"); } export async function main(argv = process.argv, deps: CliDependencies = {}): Promise { @@ -696,17 +843,16 @@ export async function main(argv = process.argv, deps: CliDependencies = {}): Pro const runDaemonCommand = deps.runDaemon ?? runDaemon; const stdout = deps.stdout ?? process.stdout; const stderr = deps.stderr ?? process.stderr; - const ui = createCliUi({ input, output: stdout }); + const colors = createColors(Boolean(stdout.isTTY)); const { version } = createRequire(import.meta.url)("../package.json") as { version: string }; let exitCode = 0; - async function runCommand(title: string, action: () => Promise): Promise { - ui.intro(`stash ${title}`); + async function runCommand(action: () => Promise): Promise { try { await action(); } catch (error) { exitCode = 1; - ui.renderError(toCliCommandError(error)); + renderCliError(stderr, toCliCommandError(error), colors); } } @@ -730,25 +876,33 @@ export async function main(argv = process.argv, deps: CliDependencies = {}): Pro .argument("", "Provider name") .allowUnknownOption(true) .action(async (providerName: string, _opts: unknown, command: Command) => { - await runCommand("setup", async () => { + await runCommand(async () => { await runSetup( providerName, command.opts() as Record, { readGlobalConfig: readConfig, writeGlobalConfig: writeConfig }, - ui, + { input, output: stdout }, + stdout, + colors, ); }); }); const connectCommand = program .command("connect") - .description("Connect a path to a provider URI") - .argument("", "URI or explicit path") - .argument("[second]", "URI when an explicit path is provided") + .description("Connect a folder to a provider URI (defaults to current directory)") + .argument("", "Provider URI (e.g. github:owner/repo) — or a path when followed by a URI") + .argument("[uri]", "Provider URI when the first argument is a path") .allowUnknownOption(true) .option("--dangerously-allow-git", "Allow syncing a git repository") + .addHelpText( + "after", + "\nExamples:\n" + + " $ stash connect github:owner/repo # current directory\n" + + " $ stash connect ~/Notes github:owner/notes # explicit path\n", + ) .action(async (first: string, second: string | undefined, _opts: unknown, command: Command) => { - await runCommand("connect", async () => { + await runCommand(async () => { await runConnect( first, second, @@ -760,36 +914,46 @@ export async function main(argv = process.argv, deps: CliDependencies = {}): Pro stashPath, getService, }, - ui, + { input, output: stdout }, + stdout, + colors, ); }); }); program .command("disconnect") - .description("Disconnect a stash path or URI") - .argument("[first]", "Path or URI") - .argument("[second]", "URI when an explicit path is provided") + .description("Disconnect a stash (defaults to current directory)") + .argument("[path-or-uri]", "Stash path or connection URI to drop") + .argument("[uri]", "Connection URI when the first argument is a path") + .addHelpText( + "after", + "\nExamples:\n" + + " $ stash disconnect # current directory, only connection\n" + + " $ stash disconnect ~/Notes # explicit path, only connection\n" + + " $ stash disconnect ~/Notes github:owner/notes # explicit path + URI\n", + ) .action(async (first: string | undefined, second: string | undefined) => { - await runCommand("disconnect", async () => { - await runDisconnect(first, second, { cwd, stashPath }, ui); + await runCommand(async () => { + await runDisconnect(first, second, { cwd, stashPath }, stdout, colors); }); }); program .command("relink") - .description("Relink a stash from one path to another") - .argument("", "Current or missing path") - .argument("", "New existing path") + .description("Move a stash to a new path (use after renaming or moving the folder)") + .argument("", "Current path of the stash (may no longer exist)") + .argument("", "New path of the folder") .option("--dangerously-allow-git", "Allow relinking to a git repository") .action(async (oldPath: string, newPath: string, options: { dangerouslyAllowGit?: boolean }) => { - await runCommand("relink", async () => { + await runCommand(async () => { await runRelink( oldPath, newPath, options.dangerouslyAllowGit === true, { stashPath }, - ui, + stdout, + colors, ); }); }); @@ -798,13 +962,14 @@ export async function main(argv = process.argv, deps: CliDependencies = {}): Pro .command("status") .description("Show registered stashes") .action(async () => { - await runCommand("status", async () => { + await runCommand(async () => { await runStatus( { stashPath, getService, }, - ui, + stdout, + colors, ); }); }); @@ -813,14 +978,14 @@ export async function main(argv = process.argv, deps: CliDependencies = {}): Pro .command("start") .description("Start background sync") .action(async () => - runCommand("start", async () => runStart(await getService(), ui)), + runCommand(async () => runStart(await getService(), stdout, colors)), ); program .command("stop") .description("Stop background sync") .action(async () => - runCommand("stop", async () => runStop(await getService(), ui)), + runCommand(async () => runStop(await getService(), stdout)), ); program diff --git a/src/cli-ui.ts b/src/cli-ui.ts deleted file mode 100644 index 031b301..0000000 --- a/src/cli-ui.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { Readable, Writable } from "node:stream"; -import { - cancel as clackCancel, - intro as clackIntro, - isCancel, - log, - outro as clackOutro, - password, - text, -} from "@clack/prompts"; -import picocolors from "picocolors"; - -export class CliCommandError extends Error { - readonly summary: string; - readonly details: string[]; - readonly finalLine: string; - - constructor(summary: string, details: string[] = [], finalLine = "No changes made.") { - super(summary); - this.name = "CliCommandError"; - this.summary = summary; - this.details = details; - this.finalLine = finalLine; - } -} - -export function cliError( - summary: string, - details: string[] = [], - finalLine = "No changes made.", -): CliCommandError { - return new CliCommandError(summary, details, finalLine); -} - -export type CliUi = ReturnType; - -export function shortenHomePath(path: string, home = process.env.HOME): string { - if (!home) { - return path; - } - if (path === home) { - return "~"; - } - return path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path; -} - -export function pluralize(count: number, singular: string, plural = `${singular}s`): string { - return `${count} ${count === 1 ? singular : plural}`; -} - -export function createCliUi({ - input = process.stdin, - output = process.stdout, -}: { - input?: Readable; - output?: Writable; -} = {}) { - const colors = picocolors.createColors(Boolean((output as NodeJS.WriteStream).isTTY)); - - const common = { - input, - output, - } as const; - - return { - input, - output, - colors, - intro(title: string): void { - clackIntro(title, { output }); - }, - step(message: string): void { - log.step(message, { output }); - }, - message(message: string | string[]): void { - log.message(message, { output }); - }, - outro(message: string): void { - clackOutro(message, { output }); - }, - cancel(message: string): void { - clackCancel(message, { output }); - }, - async promptText(label: string): Promise { - const value = await text({ - message: label, - ...common, - validate: (candidate) => { - if (!candidate || candidate.trim().length === 0) { - return `${label} is required`; - } - return undefined; - }, - }); - if (isCancel(value)) { - throw cliError("Prompt cancelled."); - } - return value; - }, - async promptPassword(label: string): Promise { - const value = await password({ - message: label, - ...common, - validate: (candidate) => { - if (!candidate || candidate.trim().length === 0) { - return `${label} is required`; - } - return undefined; - }, - }); - if (isCancel(value)) { - throw cliError("Prompt cancelled."); - } - return value; - }, - renderError(error: CliCommandError): void { - this.cancel(error.summary); - if (error.details.length > 0) { - this.message(error.details); - } - this.outro(error.finalLine); - }, - }; -} diff --git a/src/providers/github-provider.ts b/src/providers/github-provider.ts index 02ad140..f040f7d 100644 --- a/src/providers/github-provider.ts +++ b/src/providers/github-provider.ts @@ -78,6 +78,11 @@ export class GitHubProvider implements Provider { async fetch(localSnapshot?: Record): Promise { const branchRes = await this.rest("GET", this.repoPath("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/branches/main")); if (branchRes.status === 404) { + const repoRes = await this.rest("GET", this.repoPath("")); + if (repoRes.status === 404) { + throw new Error(`Repository not found: ${this.owner}/${this.name}`); + } + await this.ensureOk(repoRes, "Failed to load repository"); this.headSha = undefined; this.baseTreeSha = undefined; return { added: new Map(), modified: new Map(), deleted: [] }; diff --git a/src/utils/fs.ts b/src/utils/fs.ts index b1b9f2c..bd04442 100644 --- a/src/utils/fs.ts +++ b/src/utils/fs.ts @@ -1,16 +1,28 @@ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +const JSON_READ_RETRIES = 5; +const JSON_READ_RETRY_MS = 2; export async function readJsonFile(path: string, fallback: T): Promise { - try { - const raw = await readFile(path, "utf8"); - return JSON.parse(raw) as T; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return fallback; + for (let attempt = 0; attempt < JSON_READ_RETRIES; attempt += 1) { + try { + const raw = await readFile(path, "utf8"); + return JSON.parse(raw) as T; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return fallback; + } + if (error instanceof SyntaxError && attempt < JSON_READ_RETRIES - 1) { + await delay(JSON_READ_RETRY_MS); + continue; + } + throw error; } - throw error; } + + return fallback; } export async function writeJsonFile( diff --git a/tests/integration/background-cli.integration.test.ts b/tests/integration/background-cli.integration.test.ts index 53b4d03..152a81f 100644 --- a/tests/integration/background-cli.integration.test.ts +++ b/tests/integration/background-cli.integration.test.ts @@ -22,10 +22,16 @@ type RunResult = { calls: ServiceCalls; }; +const ANSI_PATTERN = /\x1B\[[0-9;]*m/g; + async function makeTempDir(prefix: string): Promise { return mkdtemp(join(tmpdir(), `${prefix}-`)); } +function stripAnsi(text: string): string { + return text.replace(ANSI_PATTERN, ""); +} + function getTestStashPath(dir: string): string { return join(dir, ".xdg", "stash"); } @@ -130,10 +136,12 @@ test("connect creates a new stash record and stores provider globals separately" }, ); + const stdout = stripAnsi(result.stdout); assert.equal(result.exitCode, 0); - assert.equal(result.stdout.includes("github:user/repo"), true); - assert.equal(result.stdout.includes(await realpath(dir)), true); - assert.equal(result.stdout.includes("Background sync is on. Run `stash status` to check."), true); + assert.equal(stdout.includes("Connected"), true); + assert.equal(stdout.includes("github:user/repo"), true); + assert.equal(stdout.includes(await realpath(dir)), true); + assert.equal(stdout.includes("Background sync is on. Run `stash status` to check."), true); assert.equal(result.config.providers.github?.token, "test-token"); assert.equal(existsSync(join(dir, ".stash")), false); @@ -156,9 +164,10 @@ test("connect is idempotent for the same uri", async () => { config: first.config, }); + const stdout = stripAnsi(second.stdout); assert.equal(second.exitCode, 0); - assert.equal(second.stdout.includes("Already connected"), true); - assert.equal(second.stdout.includes("github:user/repo"), true); + assert.equal(stdout.includes("Already connected"), true); + assert.equal(stdout.includes("github:user/repo"), true); assert.deepEqual(await listStashIds(getTestStashPath(dir)), [( await readOnlyStashRecord(getTestStashPath(dir)) ).id]); @@ -200,11 +209,12 @@ test("connect blocks git repositories unless --dangerously-allow-git is passed", const result = await runMain(dir, ["connect", "github:user/repo", "--token", "test-token"]); + const stderr = stripAnsi(result.stderr); assert.equal(result.exitCode, 1); - assert.equal(result.stdout.includes("is a git repository"), true); - assert.equal(result.stdout.includes("--dangerously-allow-git"), true); - assert.equal(result.stdout.includes("No changes made."), true); - assert.equal(result.stdout.includes("Background sync is on"), false); + assert.equal(stderr.includes("Error:"), true); + assert.equal(stderr.includes("is a git repository"), true); + assert.equal(stderr.includes("--dangerously-allow-git"), true); + assert.equal(stripAnsi(result.stdout).includes("Background sync is on"), false); assert.deepEqual(await listStashIds(getTestStashPath(dir)), []); } finally { await rm(dir, { recursive: true, force: true }); @@ -222,10 +232,11 @@ test("disconnect tombstones the last connection without deleting the stash dir", config: connected.config, }); + const stdout = stripAnsi(result.stdout); assert.equal(result.exitCode, 0); - assert.equal(result.stdout.includes("Disconnected"), true); - assert.equal(result.stdout.includes("github:user/repo"), true); - assert.equal(result.stdout.includes(await realpath(dir)), true); + assert.equal(stdout.includes("Disconnected"), true); + assert.equal(stdout.includes("github:user/repo"), true); + assert.equal(stdout.includes(await realpath(dir)), true); assert.equal(existsSync(join(getTestStashPath(dir), id)), true); const record = JSON.parse( await readFile(join(getTestStashPath(dir), id, "stash.json"), "utf8"), @@ -246,10 +257,11 @@ test("relink rewrites stash.json.path and enforces the git gate on the new path" await mkdir(join(moved, ".git"), { recursive: true }); const error = await runMain(dir, ["relink", dir, moved], { config: connected.config }); + const errorStderr = stripAnsi(error.stderr); assert.equal(error.exitCode, 1); - assert.equal(error.stdout.includes("is a git repository"), true); - assert.equal(error.stdout.includes("--dangerously-allow-git"), true); - assert.equal(error.stdout.includes("No changes made."), true); + assert.equal(errorStderr.includes("Error:"), true); + assert.equal(errorStderr.includes("is a git repository"), true); + assert.equal(errorStderr.includes("--dangerously-allow-git"), true); const result = await runMain( dir, @@ -257,10 +269,12 @@ test("relink rewrites stash.json.path and enforces the git gate on the new path" { config: connected.config }, ); + const stdout = stripAnsi(result.stdout); assert.equal(result.exitCode, 0); - assert.equal(result.stdout.includes(await realpath(dir)), true); - assert.equal(result.stdout.includes(await realpath(moved)), true); - assert.equal(result.stdout.includes("→"), true); + assert.equal(stdout.includes("Relinked"), true); + assert.equal(stdout.includes(await realpath(dir)), true); + assert.equal(stdout.includes(await realpath(moved)), true); + assert.equal(stdout.includes("→"), true); const record = JSON.parse( await readFile(join(getTestStashPath(dir), id, "stash.json"), "utf8"), ) as StashRecord; @@ -323,16 +337,17 @@ test("status prints daemon state, connection URIs, and missing-path guidance", a serviceStatus: { installed: true, running: true }, }); + const stdout = stripAnsi(result.stdout); assert.equal(result.exitCode, 0); - assert.equal(result.stdout.includes("Daemon: running"), true); - assert.equal(result.stdout.includes(resolve(dir)), true); - assert.equal(result.stdout.includes("github:user/live"), true); - assert.equal(result.stdout.includes("Connection"), true); - assert.equal(result.stdout.includes("State"), true); - assert.equal(result.stdout.includes("Pending"), true); - assert.equal(result.stdout.includes("1 modified"), true); - assert.equal(result.stdout.includes(`${resolve(missing)} (path missing)`), true); - assert.equal(result.stdout.includes(`Run: stash relink ${resolve(missing)} `), true); + assert.equal(stdout.includes("Daemon: running"), true); + assert.equal(stdout.includes(resolve(dir)), true); + assert.equal(stdout.includes("github:user/live"), true); + assert.equal(stdout.includes("Connection"), true); + assert.equal(stdout.includes("State"), true); + assert.equal(stdout.includes("Pending"), true); + assert.equal(stdout.includes("1 modified"), true); + assert.equal(stdout.includes(`${resolve(missing)} (path missing)`), true); + assert.equal(stdout.includes(`Run: stash relink ${resolve(missing)} `), true); } finally { await rm(dir, { recursive: true, force: true }); } @@ -346,10 +361,11 @@ test("status shows daemon state and empty hint when no stashes are registered", serviceStatus: { installed: false, running: false }, }); + const stdout = stripAnsi(result.stdout); assert.equal(result.exitCode, 0); - assert.equal(result.stdout.includes("Daemon: stopped"), true); + assert.equal(stdout.includes("Daemon: stopped"), true); assert.equal( - result.stdout.includes("No stashes registered. Run `stash connect ` to add one."), + stdout.includes("No stashes registered. Run `stash connect ` to add one."), true, ); } finally { @@ -357,7 +373,7 @@ test("status shows daemon state and empty hint when no stashes are registered", } }); -test("status renders last error rows for errored stashes", async () => { +test("status renders the error state row and last error details for errored stashes", async () => { const dir = await makeTempDir("stash-cli-status-error"); const stashPath = getTestStashPath(dir); @@ -385,23 +401,25 @@ test("status renders last error rows for errored stashes", async () => { serviceStatus: { installed: true, running: true }, }); + const stdout = stripAnsi(result.stdout); assert.equal(result.exitCode, 0); - assert.equal(result.stdout.includes("Last error"), true); - assert.equal(result.stdout.includes("rate limit resets in 43s"), true); + assert.match(stdout, /State[\s\S]*error[\s\S]*Last error/); + assert.equal(stdout.includes("Last error"), true); + assert.equal(stdout.includes("rate limit resets in 43s"), true); } finally { await rm(dir, { recursive: true, force: true }); } }); -test("error output keeps the clack cancel flow and no-change outro", async () => { +test("error output is plain stderr with an Error prefix", async () => { const dir = await makeTempDir("stash-cli-error-shape"); try { const result = await runMain(dir, ["disconnect"]); + const stderr = stripAnsi(result.stderr); assert.equal(result.exitCode, 1); - assert.equal(result.stdout.includes("No stash found at"), true); - assert.equal(result.stdout.includes("No changes made."), true); + assert.equal(stderr.includes("Error: No stash found at"), true); } finally { await rm(dir, { recursive: true, force: true }); } @@ -412,16 +430,18 @@ test("start and stop delegate to the service and use the new daemon messaging", try { const started = await runMain(dir, ["start"]); + const startedStdout = stripAnsi(started.stdout); assert.equal(started.exitCode, 0); assert.equal(started.calls.install, 1); - assert.equal(started.stdout.includes("Daemon installed and running"), true); + assert.equal(startedStdout.includes("Daemon installed and running"), true); const stopped = await runMain(dir, ["stop"], { serviceStatus: { installed: true, running: true }, }); + const stoppedStdout = stripAnsi(stopped.stdout); assert.equal(stopped.exitCode, 0); assert.equal(stopped.calls.uninstall, 1); - assert.equal(stopped.stdout.includes("Daemon stopped"), true); + assert.equal(stoppedStdout.includes("Daemon stopped"), true); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/tests/integration/cli.integration.test.ts b/tests/integration/cli.integration.test.ts index 67937b2..7bc82da 100644 --- a/tests/integration/cli.integration.test.ts +++ b/tests/integration/cli.integration.test.ts @@ -10,11 +10,16 @@ import { makeTempDir } from "../helpers/make-stash.ts"; const execFileAsync = promisify(execFile); const CLI_PATH = fileURLToPath(new URL("../../src/cli.ts", import.meta.url)); +const ANSI_PATTERN = /\x1B\[[0-9;]*m/g; function getTestStashPath(dir: string): string { return join(dir, ".xdg", "stash"); } +function stripAnsi(text: string): string { + return text.replace(ANSI_PATTERN, ""); +} + async function runCli( cwd: string, args: string[], @@ -38,7 +43,7 @@ test("cli setup stores provider globals in ~/.stash/config.json", async () => { try { const result = await runCli(dir, ["setup", "github", "--token", "test-token"]); - assert.equal(result.stdout.includes("Configured github"), true); + assert.equal(stripAnsi(result.stdout).includes("Configured github"), true); const config = JSON.parse(await readFile(join(getTestStashPath(dir), "config.json"), "utf8")); assert.deepEqual(config, { providers: { @@ -59,8 +64,9 @@ test("cli connect creates registry metadata without a local .stash directory", a const result = await runCli(dir, ["connect", "github:user/repo", "--token", "test-token"]); const canonicalDir = await realpath(dir); - assert.equal(result.stdout.includes("github:user/repo"), true); - assert.equal(result.stdout.includes(canonicalDir), true); + assert.equal(stripAnsi(result.stdout).includes("Connected"), true); + assert.equal(stripAnsi(result.stdout).includes("github:user/repo"), true); + assert.equal(stripAnsi(result.stdout).includes(canonicalDir), true); assert.equal(existsSync(join(dir, ".stash")), false); const ids = await listStashIds(getTestStashPath(dir)); @@ -85,8 +91,9 @@ test("cli connect accepts an explicit path argument", async () => { const result = await runCli(dir, ["connect", target, "github:user/repo", "--token", "test-token"]); const canonicalTarget = await realpath(target); - assert.equal(result.stdout.includes("github:user/repo"), true); - assert.equal(result.stdout.includes(canonicalTarget), true); + assert.equal(stripAnsi(result.stdout).includes("Connected"), true); + assert.equal(stripAnsi(result.stdout).includes("github:user/repo"), true); + assert.equal(stripAnsi(result.stdout).includes(canonicalTarget), true); const ids = await listStashIds(getTestStashPath(dir)); assert.equal(ids.length, 1); const record = JSON.parse( @@ -109,9 +116,9 @@ test("cli disconnect tombstones the stash record", async () => { const result = await runCli(dir, ["disconnect"]); - assert.equal(result.stdout.includes("Disconnected"), true); - assert.equal(result.stdout.includes("github:user/repo"), true); - assert.equal(result.stdout.includes(canonicalDir), true); + assert.equal(stripAnsi(result.stdout).includes("Disconnected"), true); + assert.equal(stripAnsi(result.stdout).includes("github:user/repo"), true); + assert.equal(stripAnsi(result.stdout).includes(canonicalDir), true); const record = JSON.parse( await readFile(join(getTestStashPath(dir), id, "stash.json"), "utf8"), ) as { connections: unknown[] }; @@ -133,9 +140,10 @@ test("cli relink rewrites the canonical path stored in stash.json", async () => const result = await runCli(dir, ["relink", dir, moved]); - assert.equal(result.stdout.includes(canonicalDir), true); - assert.equal(result.stdout.includes(canonicalMoved), true); - assert.equal(result.stdout.includes("→"), true); + assert.equal(stripAnsi(result.stdout).includes("Relinked"), true); + assert.equal(stripAnsi(result.stdout).includes(canonicalDir), true); + assert.equal(stripAnsi(result.stdout).includes(canonicalMoved), true); + assert.equal(stripAnsi(result.stdout).includes("→"), true); const record = JSON.parse( await readFile(join(getTestStashPath(dir), id, "stash.json"), "utf8"), ) as { path: string }; @@ -170,11 +178,11 @@ test("cli status prints registered stashes from the registry root", async () => const result = await runCli(dir, ["status"]); - assert.equal(result.stdout.includes("Daemon:"), true); - assert.equal(result.stdout.includes("Connection"), true); - assert.equal(result.stdout.includes("State"), true); - assert.equal(result.stdout.includes(await realpath(dir)), true); - assert.equal(result.stdout.includes("github:user/repo"), true); + assert.equal(stripAnsi(result.stdout).includes("Daemon:"), true); + assert.equal(stripAnsi(result.stdout).includes("Connection"), true); + assert.equal(stripAnsi(result.stdout).includes("State"), true); + assert.equal(stripAnsi(result.stdout).includes(await realpath(dir)), true); + assert.equal(stripAnsi(result.stdout).includes("github:user/repo"), true); } finally { await rm(dir, { recursive: true, force: true }); } @@ -194,12 +202,12 @@ test("cli connect blocks git repos unless --dangerously-allow-git is passed", as env: { ...process.env, XDG_CONFIG_HOME: join(dir, ".xdg") }, }, ).catch((value) => value as { stdout: string; stderr: string; code: number }); - const combined = (error.stdout ?? "") + (error.stderr ?? ""); + const stderr = stripAnsi(error.stderr ?? ""); assert.equal(error.code, 1); - assert.equal(combined.includes("is a git repository"), true); - assert.equal(combined.includes("--dangerously-allow-git"), true); - assert.equal(combined.includes("No changes made."), true); + assert.equal(stderr.includes("Error:"), true); + assert.equal(stderr.includes("is a git repository"), true); + assert.equal(stderr.includes("--dangerously-allow-git"), true); const allowed = await runCli(dir, [ "connect", @@ -208,8 +216,9 @@ test("cli connect blocks git repos unless --dangerously-allow-git is passed", as "test-token", "--dangerously-allow-git", ]); - assert.equal(allowed.stdout.includes("github:user/repo"), true); - assert.equal(allowed.stdout.includes(await realpath(dir)), true); + assert.equal(stripAnsi(allowed.stdout).includes("Connected"), true); + assert.equal(stripAnsi(allowed.stdout).includes("github:user/repo"), true); + assert.equal(stripAnsi(allowed.stdout).includes(await realpath(dir)), true); } finally { await rm(dir, { recursive: true, force: true }); } diff --git a/tests/unit/github-provider.test.ts b/tests/unit/github-provider.test.ts index 5d62b08..25d4ffc 100644 --- a/tests/unit/github-provider.test.ts +++ b/tests/unit/github-provider.test.ts @@ -18,10 +18,11 @@ test("GitHubProvider constructor validates owner/repo format", () => { assert.throws(() => new GitHubProvider({ token: "t", repo: "a/b/c" }), /owner\/repo/i); }); -test("GitHubProvider.fetch: empty repo returns empty changeset", async () => { +test("GitHubProvider.fetch: fetch returns empty changeset when repo exists but main branch does not", async () => { const api = new MockGitHubAPI(); const cleanup = api .on("GET", "/repos/user/repo/branches/main", { status: 404, body: { message: "Not Found" } }) + .on("GET", "/repos/user/repo", { status: 200, body: { full_name: "user/repo" } }) .install(); try { @@ -36,6 +37,38 @@ test("GitHubProvider.fetch: empty repo returns empty changeset", async () => { } }); +test("GitHubProvider.fetch: throws Repository not found when the repo probe returns 404", async () => { + const api = new MockGitHubAPI(); + const cleanup = api + .on("GET", "/repos/user/repo/branches/main", { status: 404, body: { message: "Not Found" } }) + .on("GET", "/repos/user/repo", { status: 404, body: { message: "Not Found" } }) + .install(); + + try { + const provider = makeProvider(); + await assert.rejects(() => provider.fetch({}), /Repository not found: user\/repo/); + api.assertDone(); + } finally { + cleanup(); + } +}); + +test("GitHubProvider.fetch: throws auth error when the repo probe returns 401", async () => { + const api = new MockGitHubAPI(); + const cleanup = api + .on("GET", "/repos/user/repo/branches/main", { status: 404, body: { message: "Not Found" } }) + .on("GET", "/repos/user/repo", { status: 401, body: { message: "Bad credentials" } }) + .install(); + + try { + const provider = makeProvider(); + await assert.rejects(() => provider.fetch({}), /GitHub authentication failed/); + api.assertDone(); + } finally { + cleanup(); + } +}); + test("GitHubProvider.fetch: diffs remote snapshot and fetches changed text", async () => { const remoteSnapshot = { "hello.md": { hash: "sha256-new" }, From 3032116b676d928b1e226bc8383ffe1ad959906a Mon Sep 17 00:00:00 2001 From: Rupert Manfredi Date: Wed, 6 May 2026 00:49:11 -0400 Subject: [PATCH 6/9] =?UTF-8?q?fix(cli):=20tone=20down=20colors,=20trailin?= =?UTF-8?q?g=20blank,=20rename=20Last=20error=20=E2=86=92=20Message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tone down per-line colors. Confirmations now have one accented token (the green verb at the start); URIs, connectors, and paths render in default fg. URI cyan is reserved for the status table and error messages where it earns its place. Less is more. - Every command's output now ends with a trailing blank line so the shell prompt has breathing room. Errors land on stderr with the same treatment. - Rename the per-stash table row from `Last error` → `Message`. Now populated from `error` (red) when set, else from `summary` (default fg) when set; row hidden otherwise. Surfaces happy-path sync summaries in addition to error messages. - New integration test asserts trailing-blank rule on connect + status. - Update docs/cli.md style section + per-command color annotations. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/cli.md | 39 ++++++++++--------- src/cli-main.ts | 16 +++++--- .../background-cli.integration.test.ts | 4 +- tests/integration/cli.integration.test.ts | 14 +++++++ 4 files changed, 47 insertions(+), 26 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 35a62f9..3a743a7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -10,21 +10,24 @@ These rules apply across every command. Color is applied via `picocolors` and on ### Palette +Color is used sparingly. The aim is *one* accented token per line at most — usually a verb at the start, or a state value in the daemon line / status table. Connector words and URIs in plain confirmation lines stay default; URIs are only colored inside the `status` table (where the column structure makes the accent earn its place) or inside error messages (where it draws the eye to the offending value). + | Role | Color | Used for | |----------|---------|------------------------------------------------------------------| -| default | (none) | the main content of any line — usually a path | -| accent | cyan | provider URIs (`github:owner/repo`) | -| success | green | confirmation verbs (`Connected`, `Disconnected`, `Configured`, `Relinked`, `Already connected`); daemon `running` | -| warn | yellow | daemon `stopped`; `(path missing)` warnings; `unsupported` state | -| error | red | `Error:` prefix on stderr; daemon `error` state; `Last error` values | -| muted | grey | labels, connectors, hints, time qualifiers — see below | +| default | (none) | most content — paths, URIs in confirmation lines, connectors | +| accent | cyan | URIs inside the `status` table; URIs inside error messages | +| success | green | confirmation verbs (`Connected`, `Disconnected`, `Configured`, `Relinked`, `Already connected`, `Already linked`); daemon `running` | +| warn | yellow | daemon `stopped`; `(path missing)` marker; `unsupported` state | +| error | red | `Error:` prefix on stderr; daemon `error` state; `State` value when it's `error`; `Message` value when sourced from an error | +| muted | grey | hint suffixes, table row labels, time qualifiers — see below | ### What gets greyed -- **Table row labels** in `status` (`Connection`, `State`, `Last sync`, `Pending`, `Last error`) — let the values carry the weight. -- **Connector words** in confirmation lines — `to`, `from`, `→` separators between path and URI. -- **Time qualifiers** — `2m ago`, `14s ago`. -- **Hint suffixes** — the second line on `connect` (`Background sync is on. Run \`stash status\` to check.`) is muted; same for the `Run: stash relink …` line under a path-missing stash. +- **Table row labels** in `status` (`Connection`, `State`, `Last sync`, `Pending`, `Message`) — let the values carry the weight. +- **Time qualifiers** in the `Last sync` row — `2m ago`, `14s ago`. +- **Hint suffixes** — the second line on `connect` (`Background sync is on. Run \`stash status\` to check.`) is fully muted; same for the `Run: stash relink …` line under a path-missing stash. + +Connector words (`to`, `from`, `—`, `→`) in confirmation lines are **default**, not grey — they stay readable next to the verb. ### Paths @@ -43,10 +46,10 @@ Paths in output are **tilde-shortened** (`/Users/rupert/Notes` → `~/Notes`). S ### Spacing -- **Single-line confirmations stay tight** — no blank lines around them. +- **Every command's output ends with a blank line** so the shell prompt has breathing room. Tested in the integration suite. - **Two-line `connect` output** (success + daemon hint) has no blank line between the two lines; the hint is rendered directly under the confirmation in muted grey. - **`status`** uses a blank line between the daemon header, each per-stash block, and the count outro. -- **Errors** are tight: summary line on stderr, any detail lines immediately under it, no trailing blank line. +- **Errors** are tight: summary line on stderr, any detail lines immediately under it; the trailing blank line is on **stderr**, not stdout. ## Commands @@ -95,7 +98,7 @@ Connected github:owner/repo to ~/Notes Background sync is on. Run `stash status` to check. ``` -Color: `Connected` green; `github:owner/repo` cyan; `to` muted grey; `~/Notes` default; the second line entirely muted grey. +Color: `Connected` green; everything else default; the second line entirely muted grey. The second line appears only when the daemon is running. If the daemon isn't running (or the platform doesn't support it), only the first line prints. @@ -105,7 +108,7 @@ Output (idempotent re-run on the same URI): Already connected — github:owner/repo ``` -Color: `Already connected` green; `—` muted grey; URI cyan. +Color: `Already connected` green; everything else default. Output (`.git/` detected without `--dangerously-allow-git`), to **stderr**, exit 1: @@ -144,7 +147,7 @@ Output: Disconnected github:owner/notes from ~/Notes ``` -Color: `Disconnected` green; URI cyan; `from` muted grey; path default. +Color: `Disconnected` green; everything else default. Output (no stash at path), to **stderr**, exit 1: @@ -179,7 +182,7 @@ Output: Relinked ~/OldFolder → ~/Documents/NewFolder ``` -Color: `Relinked` green; `→` muted grey; both paths default. +Color: `Relinked` green; everything else default. Output (no stash at old path), to **stderr**, exit 1: @@ -220,9 +223,9 @@ Daemon: running 2 stashes ``` -The per-stash table is rendered with `cli-table3`. Rows that are absent (no error, no pending changes) are omitted from the table — `Last error` only appears when an error is present. +The per-stash table is rendered with `cli-table3`. Rows that are absent (no message, no pending changes) are omitted from the table. The `Message` row carries either the latest sync `summary` (e.g. `synced 3 modified`) or an `error` string — error wins when both are present. -Color in the table: row labels (left column) muted grey, values default — except the URI in the `Connection` row which is cyan, and `Last error` value which is red. +Color in the table: row labels (left column) muted grey, values default — except the URI in the `Connection` row which is cyan, the `State` value when it's `error` which is red, and the `Message` value which is red when sourced from `error` (default fg when sourced from `summary`). If a stash's path no longer exists, the table is replaced with a one-line warning: diff --git a/src/cli-main.ts b/src/cli-main.ts index b4afc2b..b862306 100644 --- a/src/cli-main.ts +++ b/src/cli-main.ts @@ -307,23 +307,23 @@ function formatConfigured(providerName: string, colors: Colors): string { } function formatAlreadyConnected(uri: string, colors: Colors): string { - return `${colors.green("Already connected")} ${colors.gray("—")} ${colors.cyan(uri)}`; + return `${colors.green("Already connected")} — ${uri}`; } function formatConnected(uri: string, path: string, colors: Colors): string { - return `${colors.green("Connected")} ${colors.cyan(uri)} ${colors.gray("to")} ${path}`; + return `${colors.green("Connected")} ${uri} to ${path}`; } function formatDisconnected(uri: string, path: string, colors: Colors): string { - return `${colors.green("Disconnected")} ${colors.cyan(uri)} ${colors.gray("from")} ${path}`; + return `${colors.green("Disconnected")} ${uri} from ${path}`; } function formatRelinked(oldPath: string, newPath: string, colors: Colors): string { - return `${colors.green("Relinked")} ${oldPath} ${colors.gray("→")} ${newPath}`; + return `${colors.green("Relinked")} ${oldPath} → ${newPath}`; } function formatAlreadyLinked(path: string, colors: Colors): string { - return `${colors.green("Already linked")} ${colors.gray("—")} ${path}`; + return `${colors.green("Already linked")} — ${path}`; } function formatConnectHint(colors: Colors): string { @@ -512,7 +512,9 @@ function renderStatusTable( } if (status?.error) { - table.push([colors.gray("Last error"), colors.red(status.error)]); + table.push([colors.gray("Message"), colors.red(status.error)]); + } else if (status?.summary) { + table.push([colors.gray("Message"), status.summary]); } return table.toString(); @@ -850,9 +852,11 @@ export async function main(argv = process.argv, deps: CliDependencies = {}): Pro async function runCommand(action: () => Promise): Promise { try { await action(); + writeBlankLine(stdout); } catch (error) { exitCode = 1; renderCliError(stderr, toCliCommandError(error), colors); + writeBlankLine(stderr); } } diff --git a/tests/integration/background-cli.integration.test.ts b/tests/integration/background-cli.integration.test.ts index 152a81f..dbc1472 100644 --- a/tests/integration/background-cli.integration.test.ts +++ b/tests/integration/background-cli.integration.test.ts @@ -403,8 +403,8 @@ test("status renders the error state row and last error details for errored stas const stdout = stripAnsi(result.stdout); assert.equal(result.exitCode, 0); - assert.match(stdout, /State[\s\S]*error[\s\S]*Last error/); - assert.equal(stdout.includes("Last error"), true); + assert.match(stdout, /State[\s\S]*error[\s\S]*Message/); + assert.equal(stdout.includes("Message"), true); assert.equal(stdout.includes("rate limit resets in 43s"), true); } finally { await rm(dir, { recursive: true, force: true }); diff --git a/tests/integration/cli.integration.test.ts b/tests/integration/cli.integration.test.ts index 7bc82da..c517404 100644 --- a/tests/integration/cli.integration.test.ts +++ b/tests/integration/cli.integration.test.ts @@ -223,3 +223,17 @@ test("cli connect blocks git repos unless --dangerously-allow-git is passed", as await rm(dir, { recursive: true, force: true }); } }); + +test("cli output ends with a trailing blank line so the shell prompt has breathing room", async () => { + const dir = await makeTempDir("cli-trailing-blank"); + + try { + const connect = await runCli(dir, ["connect", "github:user/repo", "--token", "test-token"]); + assert.equal(connect.stdout.endsWith("\n\n"), true, "connect stdout should end with a blank line"); + + const status = await runCli(dir, ["status"]); + assert.equal(status.stdout.endsWith("\n\n"), true, "status stdout should end with a blank line"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); From 77d19f395a511b9b3b5142e7bb56595ee7b019a2 Mon Sep 17 00:00:00 2001 From: Rupert Manfredi Date: Wed, 6 May 2026 00:52:07 -0400 Subject: [PATCH 7/9] docs(plans): add external-sync-target spec + project TODO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec captures the full design discussion behind the registry refactor (ULID identity, withRegistryLock primitive, daemon deletion sites, the new CLI surface). It went through three rounds of codex review during this branch's development; the final form is what shipped in 111e09b through edaca67. TODO.md tracks the four workstreams: external sync target (this branch), JSON syncing, direct p2p syncing, and CLI↔daemon IPC. Co-Authored-By: Claude Opus 4.7 (1M context) --- TODO.md | 8 + docs/plans/external-sync-target.md | 435 +++++++++++++++++++++++++++++ 2 files changed, 443 insertions(+) create mode 100644 TODO.md create mode 100644 docs/plans/external-sync-target.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..0b3a815 --- /dev/null +++ b/TODO.md @@ -0,0 +1,8 @@ +# TODO + +Stash workstreams, in priority order. + +1. **External sync target** (current branch: `spec/external-sync-target`) — see [`docs/plans/external-sync-target.md`](docs/plans/external-sync-target.md). +2. **JSON syncing** — first-class support for syncing JSON documents (granularity / merge behavior TBD). +3. **Direct p2p syncing** — sync between peers without a github intermediary; likely uses iroh and registers an `iroh:` URI scheme. +4. **CLI↔daemon IPC** (Unix socket or SIGHUP) — replace the file-watch + poll fallback used in chunk #1 once we want synchronous "did the daemon pick this up?" confirmation, richer commands, or escape from `fs.watch` reliability quirks. diff --git a/docs/plans/external-sync-target.md b/docs/plans/external-sync-target.md new file mode 100644 index 0000000..637ee00 --- /dev/null +++ b/docs/plans/external-sync-target.md @@ -0,0 +1,435 @@ +# Spec: External sync target + +Branch: `spec/external-sync-target` + +## Goal + +Stash should sync any folder without requiring a `.stash/` directory inside it. Per-stash metadata moves to `~/.stash//`. The motivating use case is syncing application-state folders like `~/.my-app` without polluting them. This becomes the only mode — no in-folder metadata. + +Existing in-folder stashes will be migrated by hand; no automatic migration in scope. + +## Final design + +### Stash identity + +A stash is identified by a **ULID** generated at creation time. Stable for the life of the stash — never changes, including across `relink`. The per-stash metadata directory is `~/.stash//`. + +- Each stash also has a `path` (the folder being synced), stored in its `stash.json` and shown to users. +- `stash.id` is the stash's primary key (e.g. `01HF7E2K3Y4Z5N8M9PQRSTVWXY`). Internal — users mostly see paths. +- Uniqueness of *path* (no two stashes for the same canonical path) is enforced by **scan-before-create** under a registry lock. See [Registry lock](#registry-lock). + +### Path canonicalisation + +Paths go through `realpath` at registration to deduplicate aliases (symlinks, case differences on APFS, macOS firmlinks like `/var` vs `/private/var`). The resolved canonical path is what's stored in `stash.json` and matched against during dedup scans. + +- **At registration** (`connect`, `relink `): call `realpath` on the input. The resolved path is what's stored verbatim in `stash.json`. If `realpath` fails (path doesn't exist) at registration time, error. +- **At lookup-when-missing** (`disconnect `, `relink `): don't try to re-canonicalise. Use `path.resolve(input)` and match against existing stash.json `path` fields. **Caveat**: alias forms (`/var` vs `/private/var`, case differences on APFS, symlinks that have since vanished) won't match the stored canonical form once the path is gone. Users in that case must pull the exact canonical path from `stash status` output. Always try the canonical path first. +- **`stash status` always prints the canonical path** so the user has an exact handle to feed into `relink`/`disconnect` when their folder is gone. + +### Registry lock + +`~/.stash/.lock` (mode 0600) is the single primitive that serialises all registry-mutating operations. Both the CLI and the daemon use it, via a shared helper: + +```ts +withRegistryLock(async () => { + // …read on-disk state, mutate, etc. Lock is held for the duration. +}); +``` + +Implementation: `O_CREAT | O_EXCL` with a brief retry loop. Released on exit (including via `process.on('exit')` and signal handlers). Stale-lock recovery: if the file exists but the holding pid is dead (or the file is older than 60s with no live pid), it's reclaimed. + +**Who takes the lock, and when:** + +| Caller | When | What runs under the lock | +|---|---|---| +| CLI `connect` | entire command | scan-before-create + `Stash.create()` (or tombstone resurrect) | +| CLI `disconnect` | entire command | scan + atomic rewrite of `stash.json` (writes `connections: []` for the last connection — never `rmdir`) | +| CLI `relink` | entire command | scan + atomic rewrite of `stash.json.path` | +| Daemon (any deletion site) | revalidate-and-rmdir step only | re-read on-disk state, `rmdir` only if precondition still holds | + +The lock guards three invariants: +1. No two stashes for the same canonical path (scan-before-create can race without it). +2. `relink`'s scan-then-write of `stash.json` is serialised with concurrent `connect`s. +3. Daemon `rmdir` doesn't race with a CLI resurrection (tombstone → reconnect) or with an in-flight CLI `mkdir → write stash.json`. + +Per-stash sync activity uses a different lock (`~/.stash//sync.lock`) and is unaffected. + +**Lock discipline.** The CLI never `rmdir`s a per-stash dir — only the daemon does. The CLI mutates `stash.json` (atomic rename); dir lifecycle (mkdir on create, rmdir on cleanup) belongs to the daemon (mkdir excepted: `Stash.create()` mkdirs as part of bringing a new stash into existence). The daemon does NOT hold the lock while waiting for in-flight syncs; it calls `Watch.stop()` first (which waits for the active sync to finish), THEN takes the lock for the brief revalidate-and-rmdir step. + +### Connections + +Each provider registers a URI scheme equal to its name. Connections are identified by URIs: + +``` +github:owner/repo +iroh:docaaaeumlbdgjz... (future) +s3:bucket/prefix (future) +``` + +**Multi-connection per stash is deferred.** For chunk #1, a stash holds at most one connection; `connect` errors if a connection already exists (with an idempotent no-op when the same URI is reconnected). The data model uses an array regardless — forward-compatible, no migration when multi-connection unlocks. + +### Architecture preserved + +Existing layering stays intact (per `docs/architecture.md`): + +- `Stash` is the core engine: file scanning, reconciliation, snapshots, sync lifecycle. Owns its `~/.stash//` metadata. +- `Watch` is reusable scheduling: polling, fs events, debounce. +- `Daemon` runs one `Watch` instance per registered stash. +- Providers are transport-only: `fetch()`, `get()`, `push()`. They don't merge files or mutate local disk. + +This spec doesn't touch those boundaries. Future p2p (chunk #3) is a *transport*, not a sync engine — stash core continues to handle reconciliation. The only optimisation a p2p provider may need is a way to signal the watch loop "peers updated, fetch now" instead of polling, but that's a small extension to the provider interface, not an architectural shift. + +### `~/.stash/` layout + +``` +~/.stash/ (mode 0700) +├── config.json (mode 0600 — holds secrets) +├── .lock (registry lock, present during connect/disconnect/relink) +├── 01HF7E2K3Y4Z5N8M9PQRSTVWXY/ (per-stash dir, keyed by ULID) +│ ├── stash.json (path + connections) +│ ├── snapshot.json (last-synced state) +│ ├── snapshot/ (text merge bases) +│ ├── status.json (last sync result, written by Watch) +│ ├── sync.log +│ └── sync.lock (present only during active sync) +└── 01HF8G3M4P5Q6R7S8T9UVWXYZ0/ + └── … +``` + +**`config.json`** holds **only** providers (e.g. github token). No registry index — the list of stashes IS the list of `~/.stash//` directories. + +```jsonc +{ + "providers": { + "github": { "token": "ghp_…" } + } +} +``` + +**`stash.json`** is the authoritative per-stash record: + +```jsonc +{ + "path": "/Users/rupert/Notes", + "connections": [ + { "provider": "github", "config": { "id": "rmanfredi/notes" } } + ] +} +``` + +The path lives only in `stash.json` — single source of truth, no drift risk. + +### Atomic writes + +Both `~/.stash/config.json` and `~/.stash//stash.json` are written atomically: write to `.tmp`, `chmod` (0600 for `config.json` since it carries secrets; default for others), then `rename`. Readers filter out `.tmp` files when listing. + +Prevents the daemon from observing partial JSON during a CLI write. + +### CLI + +```bash +stash connect [] [--dangerously-allow-git] +stash disconnect [] [] +stash relink [--dangerously-allow-git] +stash status +stash start / stop +stash setup +``` + +Removed from prior versions: `stash sync`, `stash watch`, `stash add`, `stash join`, `stash list`, `stash share`, `stash config set/get`. `status` is the single overview command. The daemon syncs continuously. + +### Per-command behavior + +#### `stash connect [] [--dangerously-allow-git]` + +- **Inputs.** Optional `` (defaults to cwd). Required `` in the form `:`. Optional `--dangerously-allow-git` flag (gates a safety check, not stored). +- **Behavior.** + 1. Resolve `` to canonical absolute via `realpath`. Error if it doesn't exist. + 2. Detect `.git/`. If present and `--dangerously-allow-git` not passed → error: *"git repository detected. Pass `--dangerously-allow-git` if you really want stash to sync this folder."* + - The flag is **not stored**. The check exists only at `connect` and `relink`. The runtime `.git/` check inside `Stash.sync()` (today at `stash.ts:162`) is **removed** — the connect/relink-time decision is final. Trade-off: if a user runs `git init` inside an already-synced folder, sync continues silently (no post-connect protection). Accepted; user explicitly opted in at registration. + 3. Parse URI: split on first `:` → `{ provider, config: { id } }`. Error if no colon. + 4. Look up provider class by scheme. Error if unknown. + 5. Read provider config from `~/.stash/config.json`'s `providers.`. If any `global: true` items are missing, prompt for them and **collect into memory only** (do not write to disk yet). + 6. Construct the provider with merged `{ ...storedGlobal, ...pendingGlobal, ...connectionConfig }` to validate. Error if constructor throws (e.g. github rejects a malformed id). **No mutations have happened yet** — if anything failed in steps 1–6, no state has changed on disk. + 7. **If any pending global items were collected**, atomically write the updated provider config to `~/.stash/config.json` now. (Validation passed; safe to persist.) + 8. Inside `withRegistryLock`: + - **Scan.** Read every `~/.stash/*/stash.json` (skipping `.tmp` files and dirs lacking a `stash.json`). Look for one whose `path` equals `canonicalPath`. + - **None found** (new stash): call `Stash.create(record, globalConfig)` — generates a ULID, mkdirs `~/.stash//`, atomically writes `stash.json`. + - **Found** (a stash already exists at this canonical path): read its `stash.json`. + - If `connections` is empty (tombstone from a prior `disconnect`): atomically rewrite with the new connection. Resurrected. (Id stays the same.) + - Else if a connection with this exact `{ provider, config }` already exists: idempotent no-op, print "Already connected." and exit. + - Else (different connection present): error per the multi-connection rule. + 9. Daemon picks up the new dir (or rewritten `stash.json`) via its watches. +- **Output.** + ``` + Connected github:rmanfredi/notes to /Users/rupert/Notes. + Background sync is on · syncing automatically + ``` + Or, on existing-other-connection error: + ``` + Error: /Users/rupert/Notes is already connected to github:rmanfredi/old-repo. + Multi-connection per stash is not yet supported. Run `stash disconnect /Users/rupert/Notes` first. + ``` + +#### `stash disconnect [] []` + +- **Inputs.** Optional `` (defaults to cwd). Optional `` of the connection to drop. +- **Behavior.** Inside `withRegistryLock`: + 1. Resolve input: try `realpath(input)` first; on ENOENT, fall back to `path.resolve(input)`. + 2. Scan `~/.stash/*/stash.json` and find the entry whose `path` matches. Error if none. + 3. If `` given: drop that connection. Error if the stash doesn't have that connection. + 4. If `` omitted: if the stash has exactly one connection, drop it; else error and list current URIs. + 5. **CLI never `rmdir`s the per-stash dir.** It atomically rewrites `stash.json` with the connection removed (or sets `connections: []` if last). + + After the lock is released: + - **Empty `connections` is the tombstone.** Daemon's reactive deletion site picks this up: `Watch.stop()`, then under `withRegistryLock` re-reads `stash.json` and `rmdir`s if still empty. + - If the daemon isn't running, the dir stays as a tombstone until the daemon's startup pass dispatches it through the same site. +- **Output.** + ``` + Disconnected github:rmanfredi/notes from /Users/rupert/Notes. + ``` + +#### `stash relink [--dangerously-allow-git]` + +- **Inputs.** Both required. +- **Behavior.** Inside `withRegistryLock`: + 1. Resolve ``: try `realpath()` first; on ENOENT, fall back to `path.resolve()`. Call this `oldCanonical`. + 2. Resolve `` via `realpath`. Error if it doesn't exist. Call this `newCanonical`. + 3. Detect `.git/` at ``. If present and `--dangerously-allow-git` not passed → error (same message as `connect`). + 4. If `oldCanonical === newCanonical`: no-op, exit. + 5. Scan `~/.stash/*/stash.json`: + - Find the entry matching `oldCanonical` → that's the stash to relink. Error if none. + - Verify no other entry already matches `newCanonical`. Error: *"a stash already exists at ``."* + 6. **Atomically rewrite `~/.stash//stash.json`** with `path: newCanonical`. Single atomic write — no rename, no two-step drift window. The id stays the same. + + After the lock is released: daemon picks up the `stash.json` edit via its periodic poll. Tears down old `Watch`, starts new one against the new path. (Same id, same dir — just a path change.) +- **Output.** + ``` + Relinked /Users/rupert/Notes → /Users/rupert/Documents/Notes. + ``` + +#### `stash status` + +- **Inputs.** None. +- **Behavior.** Daemon liveness header + one block per registered stash, with brief sync state. Sourced from each stash's `~/.stash//status.json` (written by the daemon's Watch instance). Missing-path stashes flagged with the suggested `relink` command. +- **Output.** + ``` + Daemon: running + + /Users/rupert/Notes + github:rmanfredi/notes + State: idle + Last sync: 2m ago + Pending: 3 modified, 1 added + + /Users/rupert/Repos/television/.television + github:rmanfredi/tv-state + State: syncing + Last sync: 14s ago + + /Users/rupert/Old/Folder (path missing — run `stash relink /Users/rupert/Old/Folder `) + ``` + +#### `stash start` / `stash stop` + +- **Inputs.** None. +- **Behavior.** Install/start the daemon as a system service (launchd on macOS, systemd on Linux); stop and uninstall. Unchanged from today. +- **Output.** `Daemon started.` / `Daemon stopped.` + +#### `stash setup ` + +- **Inputs.** Provider name. The provider's `global: true` `configSchema` items are prompted interactively (or supplied as flags via the existing `addFieldOptions` wiring). +- **Behavior.** Stores provider-level config under `providers.` in `~/.stash/config.json` (atomic write at mode 0600). Re-running `setup` re-prompts and overwrites. +- **Output.** `Configured github.` + +### Daemon behavior + +Two watches: + +- **`~/.stash/config.json`** — fires on provider-level config changes (e.g. github token rotation). +- **`~/.stash/`** (the directory) — fires on per-stash dir add/remove (i.e. `connect` / final `disconnect`). + +Plus a periodic poll (30–60s) covering edits to existing `stash.json` files (relink, future connection edits) — the dir set doesn't change but the contents do, so `fs.watch` on `~/.stash/` won't fire. + +On any signal, the daemon re-reads: +1. `~/.stash/config.json` for provider config. +2. `readdir(~/.stash/)` for the stash list. Each subdir's `stash.json` is read for path + connections. + +It then diffs against currently-running `Watch` instances. The reconciliation has three rules, in order: + +1. **Tombstone (`connections: []`) is a removal.** If a `stash.json` has empty connections, treat the entry as deleted: call `Watch.stop()` (waits for in-flight sync, NOT under lock), then dispatch to the reactive deletion site. This is the final-disconnect path. +2. **Removed tuple** (id no longer in the dir listing) → call `Watch.stop()`, then nothing else (dir is already gone). +3. **New / changed tuple** (id appeared, or `(stashRecord, providerConfig)` differs from last seen) → if there's an existing Watch, tear it down; then start a new `Watch` via `Stash.load(id, globalConfig)`. + +Tear-down-and-recreate; no in-place reconfiguration. Watches are cheap. + +**Deletion sites.** Only the daemon `rmdir`s per-stash dirs. There are three sites; all use `withRegistryLock` and revalidate the precondition under the lock before acting: + +```ts +// Reactive tombstone (daemon observed connections: [] for an active stash) +await watch.stop(); // wait for in-flight sync — NOT under lock +await withRegistryLock(async () => { + const r = await readStashJsonIfExists(id); + if (r && r.connections.length === 0) { + await rm(stashDir(id), { recursive: true }); + } + // else: user resurrected the tombstone; another reconciliation tick will pick up the new state +}); + +// Orphan GC (dir has no stash.json — partial state from a crashed mkdir) +await withRegistryLock(async () => { + const hasJson = await exists(stashJsonPath(id)); + const old = await mtimeOlderThan(stashDir(id), 5 * 60_000); + if (!hasJson && old) { + await rm(stashDir(id), { recursive: true }); + } +}); + +// Startup GC: walks `~/.stash/*/` and dispatches each dir to the matching site above (or no-op). +``` + +This closes the window where a CLI `connect` resurrects a tombstone (or completes an in-flight `mkdir → write stash.json`) between the daemon observing the precondition and acting on it. Each site re-reads from disk under the lock — never trusts cached state — so the precondition reflects ground truth at delete time. The lock is fast (CLI mutations are sub-second); `Watch.stop()` happens *before* taking the lock, so the lock isn't held while waiting for in-flight syncs. + +**Missing-path stashes** (`stash.json` references a `path` that doesn't exist) get a synthetic `status.json` with `kind: "missing"` and no `Watch`. Daemon revisits each tick; the moment the user `relink`s, a real Watch starts. (No "user just `mv`d the folder back" auto-detect — they have to relink.) + +**Incomplete-dir reads**: there is a brief window between `mkdir(~/.stash//)` and the atomic `stash.json` write where the dir exists but has no `stash.json` (or only `stash.json.tmp`). The daemon's reader **ignores any per-stash dir that lacks a non-tmp `stash.json`** during reconciliation. Such dirs are revisited on the next tick (still in-flight) or eventually become orphan-GC candidates (>5 min old without `stash.json` → reclaimed via the orphan-deletion site above). + +**Daemon-was-off-during-disconnect**: tombstone dirs persist as-is across daemon restarts. On startup, the daemon walks `~/.stash/*/` and dispatches each dir through the deletion sites above (tombstone dispatch picks up empty-connection dirs; orphan dispatch picks up old incomplete dirs; live dirs become reconciliation entries). + +### `Stash` class + +```ts +/** + * Stash is the per-stash sync engine. It owns: + * - the metadata directory at `~/.stash//` (snapshot, status, log, lock, merge bases); + * - file scanning and reconciliation against the user's `path`; + * - the sync lifecycle (push/fetch via providers). + * + * Stash does NOT own: + * - the registry (it's just a directory listing of `~/.stash/`); + * - the daemon lifecycle or watch scheduling; + * - global provider config — that's read elsewhere and passed in. + * + * Two ways to materialise a Stash: + * - Stash.create(record, globalConfig) — for new stashes (creates the dir, writes stash.json). + * - Stash.load(id, globalConfig) — for existing stashes (reads stash.json). + */ +class Stash { + readonly id: string; // ULID, generated at create() + readonly record: StashRecord; // { path, connections } + + private constructor(id: string, record: StashRecord, globalConfig: GlobalConfig) { ... } + + /** + * Create a new stash for `record.path`. Generates a fresh ULID and writes the per-stash + * dir + stash.json. MUST be called from inside `withRegistryLock` after a scan has confirmed + * no existing stash matches `record.path`. Stash itself does not take the lock. + */ + static async create(record: StashRecord, globalConfig: GlobalConfig): Promise { + const id = ulid(); + await mkdir(`~/.stash/${id}/`, { mode: 0o700 }); + await writeStashJson(`~/.stash/${id}/stash.json`, record); // atomic write + return new Stash(id, record, globalConfig); + } + + static async load(id: string, globalConfig: GlobalConfig): Promise { + const record = await readStashJson(`~/.stash/${id}/stash.json`); + return new Stash(id, record, globalConfig); + } + + // existing instance methods (sync, scan, etc.) — unchanged contract +} +``` + +`Stash.init()` is removed. `Stash.create()` and `Stash.load()` are pure operations — they read/write the metadata dir but don't take the registry lock. Callers (CLI commands, daemon reconciliation) wrap calls in `withRegistryLock` when needed. Tests typically use `Stash.load` against fixtures, or construct via the static factories with temp dirs. + +### `status.json` schema (per stash) + +```ts +{ + kind: "idle" | "syncing" | "error" | "missing" | "never"; + lastSync?: number; // unix ms + summary?: string; + error?: string; // present iff kind === "error" + currentOperation?: "scanning" | "fetching" | "merging" | "pushing"; + pendingChanges?: { added: number; modified: number; deleted: number }; +} +``` + +Existing fields preserved; `"missing"` is a new `kind`; `currentOperation` and `pendingChanges` are new optional fields the Watch can populate cheaply. + +### Provider interface + +The provider class, its constructor, and its `fetch`/`get`/`push` methods stay as they are. The only changes: + +- Drop the old `ProviderSpec { setup, connect }` type. +- Replace with a single `static configSchema: ConfigItem[]` on each provider class. +- Drop interactive prompts for non-global items at connect time — they come from the URI. + +```ts +type ConfigItem = { + name: string; + label: string; + secret?: boolean; + global?: boolean; // true → stored in ~/.stash/config.json's providers. + // false (default) → per-connection (sourced from URI in chunk #1) +}; + +class GitHubProvider { + static configSchema: ConfigItem[] = [ + { name: "token", label: "Personal access token", secret: true, global: true }, + { name: "id", label: "Identifier (e.g. owner/repo)" }, + ]; + + constructor(config: { token: string; id: string }) { ... } + // existing fetch / get / push +} +``` + +URIs are stash core's concern, not the provider's. Stash core has one generic parser: + +```ts +function parseUri(uri: string): { provider: string; config: { id: string } } { + const i = uri.indexOf(':'); + if (i < 0) throw new InvalidUriError(uri); + return { provider: uri.slice(0, i), config: { id: uri.slice(i + 1) } }; +} +``` + +At construction time, stash core merges global + connection config: + +```ts +const providerConfig = readGlobalProviderConfig(connection.provider); +const connectionConfig = connection.config; +const provider = new ProviderClass({ ...providerConfig, ...connectionConfig }); +``` + +URIs are reconstructed for display via `${provider}:${config.id}`. + +## Phasing + +1. **`~/.stash//` layout.** New `config.json` schema (providers only). Per-stash dirs keyed by ULID. Atomic writes utility. `withRegistryLock(fn)` helper (`~/.stash/.lock`, `O_CREAT | O_EXCL`, stale-lock recovery). `~/.stash/` at 0700, `config.json` and `.lock` at 0600. +2. **`Stash` class refactor.** Drop `Stash.init()`. Add `Stash.create()` (generates ULID) and `Stash.load()` static factories. Update doc comments. +3. **Provider spec rework.** Replace `ProviderSpec { setup, connect }` with `static configSchema: ConfigItem[]`. Update github's spec; rewire `runConnect` / `runSetup` / `addFieldOptions` callsites. +4. **Generic URI parser in stash core.** Split-on-first-`:` to `{ provider, config: { id } }`. CLI uses this in `connect`. +5. **Daemon registry watching.** Watch `config.json` + `~/.stash/` directory. Diff by `(id, record, providerConfig)` tuple. Tear-down-and-recreate. GC-on-startup. Periodic poll fallback. +6. **CLI rewrite.** New `connect` (with cwd default, `--dangerously-allow-git`, validate-before-mutate flow), `disconnect`, `relink`, `status`. Remove `sync`, `watch`, `add`, `join`, `list`, `share`, `config set/get`. + +## Out of scope + +- Migration of existing in-folder `.stash/` stashes (handled manually). +- Auto-detect moved folders (use `stash relink`; surface missing paths in `stash status`). +- Per-provider non-URI connect fields like `--branch`. URI carries primary identity only for now. +- Multi-connection per stash. Data model supports it; CLI rejects it. +- Keychain integration for secrets. Token stays plaintext in `~/.stash/config.json` (mode 0600). +- Synchronous CLI↔daemon confirmation. The daemon picks up changes asynchronously via watch + poll. (See [TODO.md](../../TODO.md) item 4.) +- Per-stash flags. `local-config.ts` and `stash config set/get` are removed entirely. `--dangerously-allow-git` is a connect/relink-time gate, not a stored flag. +- Manual `sync`/`watch` fallback for environments where the OS service mechanism is unavailable (no launchd / no systemd / etc.). Stash is daemon-only in chunk #1; users on those platforms wait for [TODO #4 (CLI↔daemon IPC)] or a future userland-daemon mode. + +## Future considerations + +These don't constrain this spec but should not be made harder by it: + +- **JSON syncing.** First-class support for syncing JSON documents. +- **Direct p2p syncing.** A future `iroh:` (or similar) provider, transport-only — stash core continues to handle reconciliation. Reads `stash share ` (or similar) output, pastes into another client. The URI-as-canonical-form decision and the `Stash.create`/`Stash.load` shape are designed to make this drop in cleanly. Likely also: an event-driven hook so the provider can wake the watch loop ("peers updated, fetch now") instead of polling, and a machine-level `~/.stash/identity.json` for the iroh NodeId. +- **Multi-connection.** Reuse the existing array shape on `stash.json`. Drop the "single-connection" check in `connect`. From 6ad319bd69efc15d35b4fdf32b1e2a0b147f372b Mon Sep 17 00:00:00 2001 From: Rupert Manfredi Date: Mon, 11 May 2026 16:05:08 -0700 Subject: [PATCH 8/9] =?UTF-8?q?docs:=20rename=20JSON=20syncing=20=E2=86=92?= =?UTF-8?q?=20structured=20data=20syncing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Broader than just JSON — covers YAML, TOML, and any structured document where field-level merge semantics matter more than whole-file LWW. Also restructures TODO.md into 'Capabilities' (user-facing trajectory) vs 'Infrastructure' (CLI↔daemon IPC), so the CLI↔daemon IPC item isn't confused for a user-facing feature. Co-Authored-By: Claude Opus 4.7 (1M context) --- TODO.md | 11 ++++++++--- docs/plans/external-sync-target.md | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index 0b3a815..803bf14 100644 --- a/TODO.md +++ b/TODO.md @@ -1,8 +1,13 @@ # TODO -Stash workstreams, in priority order. +## Capabilities (user-facing) + +In rough priority order: 1. **External sync target** (current branch: `spec/external-sync-target`) — see [`docs/plans/external-sync-target.md`](docs/plans/external-sync-target.md). -2. **JSON syncing** — first-class support for syncing JSON documents (granularity / merge behavior TBD). +2. **Structured data syncing** — first-class support for syncing structured documents (JSON, YAML, TOML, etc.) with field-level merge semantics rather than whole-file last-writer-wins (granularity / merge behavior TBD). 3. **Direct p2p syncing** — sync between peers without a github intermediary; likely uses iroh and registers an `iroh:` URI scheme. -4. **CLI↔daemon IPC** (Unix socket or SIGHUP) — replace the file-watch + poll fallback used in chunk #1 once we want synchronous "did the daemon pick this up?" confirmation, richer commands, or escape from `fs.watch` reliability quirks. + +## Infrastructure + +- **CLI↔daemon IPC** (Unix socket or SIGHUP) — replace the file-watch + poll fallback used in chunk #1 once we want synchronous "did the daemon pick this up?" confirmation, richer commands, or escape from `fs.watch` reliability quirks. Plumbing improvement, not a user-facing feature. diff --git a/docs/plans/external-sync-target.md b/docs/plans/external-sync-target.md index 637ee00..d0c0823 100644 --- a/docs/plans/external-sync-target.md +++ b/docs/plans/external-sync-target.md @@ -430,6 +430,6 @@ URIs are reconstructed for display via `${provider}:${config.id}`. These don't constrain this spec but should not be made harder by it: -- **JSON syncing.** First-class support for syncing JSON documents. +- **Structured data syncing.** First-class support for syncing structured documents (JSON, YAML, TOML, etc.) with field-level merge semantics rather than whole-file last-writer-wins. - **Direct p2p syncing.** A future `iroh:` (or similar) provider, transport-only — stash core continues to handle reconciliation. Reads `stash share ` (or similar) output, pastes into another client. The URI-as-canonical-form decision and the `Stash.create`/`Stash.load` shape are designed to make this drop in cleanly. Likely also: an event-driven hook so the provider can wake the watch loop ("peers updated, fetch now") instead of polling, and a machine-level `~/.stash/identity.json` for the iroh NodeId. - **Multi-connection.** Reuse the existing array shape on `stash.json`. Drop the "single-connection" check in `connect`. From 2308466f5dcb9a31f3c7414fc3a0810de4c751ff Mon Sep 17 00:00:00 2001 From: Rupert Manfredi Date: Mon, 18 May 2026 16:19:05 -0700 Subject: [PATCH 9/9] feat(cli): require path on connect/disconnect; auto-mkdir on connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connect is now `stash connect ` with the path required (use `.` for cwd) and auto-created if missing — mirrors `cp`/`rsync` shape and removes the implicit-cwd footgun where two URI-shaped args could silently misparse. disconnect mirrors with ` []`; the disconnect-by-URI-only shortcut is gone. Also ports the e2e suites from the long-gone `Stash.init` factory + obsolete named-connection CLI form to the current `Stash.create({ path, connections })` API and URI-based CLI. README/cli docs/external-sync-target plan updated to match. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 41 +- docs/cli.md | 22 +- docs/plans/external-sync-target.md | 14 +- src/cli-main.ts | 72 ++-- tests/e2e/stash-github-scenarios.e2e.test.ts | 360 ++++++++++-------- tests/e2e/stash-github.e2e.test.ts | 23 +- .../background-cli.integration.test.ts | 20 +- tests/integration/cli.integration.test.ts | 67 +++- 8 files changed, 352 insertions(+), 267 deletions(-) diff --git a/README.md b/README.md index a78f067..643ceab 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,19 @@ npm install -g @telepath-computer/stash [Create a new repo on GitHub](https://github.com/new) to use for sync, then connect it: +```bash +stash connect ./dir-to-sync github:owner/repo +``` + +Or, from inside the folder: + ```bash cd dir-to-sync/ -stash connect github +stash connect . github:owner/repo ``` +If the path doesn't exist yet, `stash connect` creates it. + Follow the prompts to enter your repo & GitHub token (see below). Then, start syncing! @@ -47,7 +55,7 @@ Alternatively, a classic token with the `repo` scope also works. ## How it works -A stash is a folder with a `.stash/` directory that stores connection config and a snapshot of the last-synced file state. On each sync, Stash scans local files, fetches remote changes, and reconciles both sides using a version of Google's [diff-match-patch](https://github.com/google/diff-match-patch) algorithm (the same one used by Obsidian Sync). In watch or background mode, this runs whenever a file changes or every 30 seconds. +A stash is a folder registered in `~/.stash//`, which stores its connection config and a snapshot of the last-synced file state. On each sync, Stash scans local files, fetches remote changes, and reconciles both sides using a version of Google's [diff-match-patch](https://github.com/google/diff-match-patch) algorithm (the same one used by Obsidian Sync). In background mode, this runs whenever a file changes or every 30 seconds. 1. Scan local files against the stored snapshot 2. Fetch remote changes from the provider @@ -57,25 +65,20 @@ A stash is a folder with a `.stash/` directory that stores connection config and - **Smart text merging.** Edits to different regions combine cleanly. Overlapping edits preserve both sides instead of silently dropping content. - **Binary files** use last-modified-wins. -- **Automatic tracking.** Every file in the directory is synced except dotfiles, dot-directories, symlinks, and `.stash/` metadata. +- **Automatic tracking.** Every file in the directory is synced except dotfiles, dot-directories, and symlinks. - **Provider-agnostic.** GitHub is the built-in provider, but the transport layer is pluggable. See [docs/providers/overview.md](docs/providers/overview.md). ## Commands | Command | Description | | --------------------------------- | -------------------------------------------------------------------------- | -| `stash connect [name]` | Initialize a stash and add a named connection | -| `stash disconnect ` | Disconnect one named connection | -| `stash disconnect --all` | Disconnect the current stash completely | -| `stash disconnect --path ` | Disconnect a stash by path | -| `stash sync` | Sync once | -| `stash watch` | Watch and sync continuously in the foreground | -| `stash start` | Start background sync (resumes on restart) | -| `stash stop` | Stop and uninstall the background service | -| `stash status` | Show background sync state and every registered stash (from any directory) | -| `stash setup ` | Update provider credentials | -| `stash config set ` | Set a per-stash config value | -| `stash config get ` | Get a per-stash config value | +| `stash setup ` | Configure provider credentials | +| `stash connect ` | Connect a folder to a provider URI (creates the folder if missing) | +| `stash disconnect []` | Disconnect a folder from a provider URI | +| `stash relink ` | Move a stash to a new path (after renaming or moving the folder) | +| `stash status` | Show background sync state and every registered stash | +| `stash start` | Install and start the background sync service | +| `stash stop` | Stop and uninstall the background sync service | ## Using stash with git @@ -84,15 +87,15 @@ A stash is a folder with a `.stash/` directory that stores connection config and We recommend you avoid using `.git/` and Stash simultaneously. Stash is its own syncing service, and simply uses GitHub as a remote to store files. -*However*, you might really like git workflows, possess an insatiable rebellious streak, and still want to sync a stash with this tool and use git at the same time. If this is you, we recommend you **only use git locally**, and while **disable backgroud sync** (`stash stop`). Then you can create branches, use git, and only run `stash sync` manually when you have your changes safely merged back into main. +*However*, you might really like git workflows, possess an insatiable rebellious streak, and still want to sync a stash with this tool and use git at the same time. If this is you, we recommend you **only use git locally**, and **disable background sync** (`stash stop`) while you switch branches. -Disable `.git/` protections by editing the config while in your stash folder: +Bypass the `.git/` check by passing `--dangerously-allow-git` at connect time: ```bash -stash config set allow-git true +stash connect ./my-repo github:owner/repo --dangerously-allow-git ``` -Oh, and probably make a backup first. We'll be improving git compatability in future, so expect this restriction to be improved. +Oh, and probably make a backup first. We'll be improving git compatibility in future, so expect this restriction to be relaxed. ## FAQ diff --git a/docs/cli.md b/docs/cli.md index 3a743a7..48a999d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -76,9 +76,9 @@ Configured github Color: `Configured` green; provider name default. -### `stash connect [] [--dangerously-allow-git]` +### `stash connect [--dangerously-allow-git]` -Connects a folder to a provider URI. If `` is omitted, defaults to the current directory. +Connects a folder to a provider URI. `` is required — pass `.` for the current directory. If `` does not exist, it is created (recursively). The URI form is `:`, e.g. `github:owner/repo`. The CLI parses the provider, validates the connection, and registers the stash under `~/.stash//`. @@ -87,8 +87,9 @@ If the provider's global fields are not yet set (e.g. github token), `connect` p Examples: ```bash -stash connect github:owner/repo # current directory -stash connect ~/Notes github:owner/notes # explicit path +stash connect . github:owner/repo # current directory +stash connect ~/Notes github:owner/notes # existing folder +stash connect ./inbox github:owner/inbox # creates ./inbox if missing ``` Output (new connection): @@ -124,9 +125,9 @@ Error: ~/Notes is already connected to github:owner/old-repo. Multi-connection per stash is not yet supported. Run `stash disconnect ~/Notes` first. ``` -### `stash disconnect [] []` +### `stash disconnect []` -Disconnects a stash from a provider URI. If `` is omitted, defaults to the current directory. If `` is omitted and the stash has exactly one connection, drops that connection. +Disconnects a stash from a provider URI. `` is required — pass `.` for the current directory. If `` is omitted and the stash has exactly one connection, drops that connection. If the stash has multiple connections (a future feature), `` is required. @@ -135,10 +136,9 @@ The CLI atomically rewrites `~/.stash//stash.json` to drop the connection. T Examples: ```bash -stash disconnect # current directory, only connection -stash disconnect ~/Notes # explicit path, only connection -stash disconnect ~/Notes github:owner/notes # explicit path + URI -stash disconnect github:owner/notes # URI only (looks up cwd) +stash disconnect . # current directory +stash disconnect ~/Notes # only connection +stash disconnect ~/Notes github:owner/notes # explicit URI ``` Output: @@ -241,7 +241,7 @@ Output (no stashes registered): ```text Daemon: running -No stashes registered. Run `stash connect ` to add one. +No stashes registered. Run `stash connect ` to add one (e.g. `stash connect . github:owner/repo`). ``` The `Daemon:` line is colored — `running` green, `stopped` yellow, `unsupported` yellow, `error` red — when stdout is a TTY. diff --git a/docs/plans/external-sync-target.md b/docs/plans/external-sync-target.md index d0c0823..003800f 100644 --- a/docs/plans/external-sync-target.md +++ b/docs/plans/external-sync-target.md @@ -128,8 +128,8 @@ Prevents the daemon from observing partial JSON during a CLI write. ### CLI ```bash -stash connect [] [--dangerously-allow-git] -stash disconnect [] [] +stash connect [--dangerously-allow-git] +stash disconnect [] stash relink [--dangerously-allow-git] stash status stash start / stop @@ -140,11 +140,11 @@ Removed from prior versions: `stash sync`, `stash watch`, `stash add`, `stash jo ### Per-command behavior -#### `stash connect [] [--dangerously-allow-git]` +#### `stash connect [--dangerously-allow-git]` -- **Inputs.** Optional `` (defaults to cwd). Required `` in the form `:`. Optional `--dangerously-allow-git` flag (gates a safety check, not stored). +- **Inputs.** Required `` (use `.` for cwd). Required `` in the form `:`. Optional `--dangerously-allow-git` flag (gates a safety check, not stored). - **Behavior.** - 1. Resolve `` to canonical absolute via `realpath`. Error if it doesn't exist. + 1. `mkdir -p ` (no-op if it already exists), then resolve to canonical absolute via `realpath`. 2. Detect `.git/`. If present and `--dangerously-allow-git` not passed → error: *"git repository detected. Pass `--dangerously-allow-git` if you really want stash to sync this folder."* - The flag is **not stored**. The check exists only at `connect` and `relink`. The runtime `.git/` check inside `Stash.sync()` (today at `stash.ts:162`) is **removed** — the connect/relink-time decision is final. Trade-off: if a user runs `git init` inside an already-synced folder, sync continues silently (no post-connect protection). Accepted; user explicitly opted in at registration. 3. Parse URI: split on first `:` → `{ provider, config: { id } }`. Error if no colon. @@ -171,9 +171,9 @@ Removed from prior versions: `stash sync`, `stash watch`, `stash add`, `stash jo Multi-connection per stash is not yet supported. Run `stash disconnect /Users/rupert/Notes` first. ``` -#### `stash disconnect [] []` +#### `stash disconnect []` -- **Inputs.** Optional `` (defaults to cwd). Optional `` of the connection to drop. +- **Inputs.** Required `` (use `.` for cwd). Optional `` of the connection to drop. - **Behavior.** Inside `withRegistryLock`: 1. Resolve input: try `realpath(input)` first; on ENOENT, fall back to `path.resolve(input)`. 2. Scan `~/.stash/*/stash.json` and find the entry whose `path` matches. Error if none. diff --git a/src/cli-main.ts b/src/cli-main.ts index b862306..b3b0f92 100644 --- a/src/cli-main.ts +++ b/src/cli-main.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { access, constants, readdir, realpath, readFile } from "node:fs/promises"; +import { access, constants, mkdir, readdir, realpath, readFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { delimiter, join, resolve } from "node:path"; import type { Readable } from "node:stream"; @@ -536,37 +536,17 @@ async function runSetup( writeLine(stdout, formatConfigured(providerName, colors)); } -function normalizeConnectArgs(first: string, second?: string): { pathArg?: string; uri: string } { - if (second) { - return { pathArg: first, uri: second }; - } - return { uri: first }; -} - -function normalizeDisconnectArgs( - first?: string, - second?: string, -): { pathArg?: string; uri?: string } { - if (first && second) { - return { pathArg: first, uri: second }; - } - if (first?.includes(":")) { - return { uri: first }; - } - return { pathArg: first }; -} - async function runConnect( - first: string, - second: string | undefined, + pathArg: string, + uri: string, valuesFromCli: Record, deps: ConnectDeps, io: PromptIo, stdout: NodeJS.WritableStream, colors: Colors, ): Promise { - const { pathArg, uri } = normalizeConnectArgs(first, second); - const targetPath = pathArg ?? deps.cwd(); + const targetPath = resolve(deps.cwd(), pathArg); + await mkdir(targetPath, { recursive: true }); const canonicalPath = await canonicalizeExistingPath(targetPath); ensureGitAllowed(canonicalPath, valuesFromCli.dangerouslyAllowGit === true); @@ -657,14 +637,13 @@ async function runConnect( } async function runDisconnect( - first: string | undefined, - second: string | undefined, + pathArg: string, + uri: string | undefined, deps: DisconnectDeps, stdout: NodeJS.WritableStream, colors: Colors, ): Promise { - const { pathArg, uri } = normalizeDisconnectArgs(first, second); - const lookupPath = await resolveLookupPath(pathArg ?? deps.cwd()); + const lookupPath = await resolveLookupPath(resolve(deps.cwd(), pathArg)); const stashPath = deps.stashPath ?? getStashPath(); let disconnectedPath = lookupPath; @@ -775,7 +754,7 @@ async function runStatus( .sort((a, b) => a.record.path.localeCompare(b.record.path)); if (entries.length === 0) { - writeLine(stdout, "No stashes registered. Run `stash connect ` to add one."); + writeLine(stdout, "No stashes registered. Run `stash connect ` to add one (e.g. `stash connect . github:owner/repo`)."); return; } @@ -894,22 +873,23 @@ export async function main(argv = process.argv, deps: CliDependencies = {}): Pro const connectCommand = program .command("connect") - .description("Connect a folder to a provider URI (defaults to current directory)") - .argument("", "Provider URI (e.g. github:owner/repo) — or a path when followed by a URI") - .argument("[uri]", "Provider URI when the first argument is a path") + .description("Connect a folder to a provider URI") + .argument("", "Folder to connect (created if it does not exist)") + .argument("", "Provider URI (e.g. github:owner/repo)") .allowUnknownOption(true) .option("--dangerously-allow-git", "Allow syncing a git repository") .addHelpText( "after", "\nExamples:\n" + - " $ stash connect github:owner/repo # current directory\n" + - " $ stash connect ~/Notes github:owner/notes # explicit path\n", + " $ stash connect . github:owner/repo # current directory\n" + + " $ stash connect ~/Notes github:owner/notes # existing folder\n" + + " $ stash connect ./inbox github:owner/inbox # creates ./inbox if missing\n", ) - .action(async (first: string, second: string | undefined, _opts: unknown, command: Command) => { + .action(async (pathArg: string, uri: string, _opts: unknown, command: Command) => { await runCommand(async () => { await runConnect( - first, - second, + pathArg, + uri, command.opts() as Record, { cwd, @@ -927,19 +907,19 @@ export async function main(argv = process.argv, deps: CliDependencies = {}): Pro program .command("disconnect") - .description("Disconnect a stash (defaults to current directory)") - .argument("[path-or-uri]", "Stash path or connection URI to drop") - .argument("[uri]", "Connection URI when the first argument is a path") + .description("Disconnect a stash") + .argument("", "Folder to disconnect") + .argument("[uri]", "Connection URI to drop (required when the stash has multiple connections)") .addHelpText( "after", "\nExamples:\n" + - " $ stash disconnect # current directory, only connection\n" + - " $ stash disconnect ~/Notes # explicit path, only connection\n" + - " $ stash disconnect ~/Notes github:owner/notes # explicit path + URI\n", + " $ stash disconnect . # current directory\n" + + " $ stash disconnect ~/Notes # only connection\n" + + " $ stash disconnect ~/Notes github:owner/notes # explicit URI\n", ) - .action(async (first: string | undefined, second: string | undefined) => { + .action(async (pathArg: string, uri: string | undefined) => { await runCommand(async () => { - await runDisconnect(first, second, { cwd, stashPath }, stdout, colors); + await runDisconnect(pathArg, uri, { cwd, stashPath }, stdout, colors); }); }); diff --git a/tests/e2e/stash-github-scenarios.e2e.test.ts b/tests/e2e/stash-github-scenarios.e2e.test.ts index 4286545..a781c67 100644 --- a/tests/e2e/stash-github-scenarios.e2e.test.ts +++ b/tests/e2e/stash-github-scenarios.e2e.test.ts @@ -6,6 +6,7 @@ import { promisify } from "node:util"; import { mkdir, mkdtemp, + readdir, readFile, realpath, rename, @@ -18,8 +19,9 @@ import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { getStashDirPath, normalizeGlobalConfig } from "../../src/global-config.ts"; import { Stash } from "../../src/stash.ts"; -import type { GlobalConfig } from "../../src/types.ts"; +import type { GlobalConfig, StashRecord } from "../../src/types.ts"; import { hashBuffer } from "../../src/utils/hash.ts"; const execFileAsync = promisify(execFile); @@ -28,10 +30,53 @@ const token = process.env.GITHUB_TOKEN; const E2E_OPTIONS = { skip: !token, timeout: 300_000 }; type RepoInfo = { fullName: string }; -type Machine = { dir: string; stash: Stash }; +type Machine = { + dir: string; + id: string; + stash: Stash; + xdgDir: string; + stashPath: string; + metadataDir: string; +}; let cachedUsername: string | null = null; let lastRepoCreateAt = 0; +function getTestStashPath(xdgDir: string): string { + return join(xdgDir, "stash"); +} + +function makeGlobalConfig(tokenValue = token!): GlobalConfig { + return normalizeGlobalConfig({ + providers: { + github: { token: tokenValue }, + }, + }); +} + +async function listStashIds(stashPath: string): Promise { + if (!existsSync(stashPath)) { + return []; + } + return (await readdir(stashPath)).filter( + (entry) => !entry.startsWith(".") && entry !== "config.json", + ); +} + +async function readStashRecord(stashPath: string, id: string): Promise { + return JSON.parse(await readFile(join(stashPath, id, "stash.json"), "utf8")) as StashRecord; +} + +async function readOnlyStashRecord( + stashPath: string, +): Promise<{ id: string; record: StashRecord }> { + const ids = await listStashIds(stashPath); + assert.equal(ids.length, 1); + return { + id: ids[0], + record: await readStashRecord(stashPath, ids[0]), + }; +} + async function sleep(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } @@ -265,14 +310,54 @@ async function upsertRemoteFile( await githubJson("PUT", `/repos/${repo}/contents/${encodePath(path)}`, payload); } +async function createTestStash( + dir: string, + globalConfig = makeGlobalConfig(), + xdgDir?: string, +): Promise<{ + id: string; + stash: Stash; + xdgDir: string; + stashPath: string; + metadataDir: string; +}> { + const resolvedXdgDir = xdgDir ?? (await makeTempDir("xdg")); + const stashPath = getTestStashPath(resolvedXdgDir); + const canonicalPath = await realpath(dir); + const created = await Stash.create( + { + path: canonicalPath, + connections: [], + }, + globalConfig, + { stashPath }, + ); + return { + id: created.id, + stash: await Stash.load(created.id, globalConfig, { stashPath }), + xdgDir: resolvedXdgDir, + stashPath, + metadataDir: getStashDirPath(created.id, stashPath), + }; +} + async function createMachine( dir: string, repo: string, globalConfig: GlobalConfig, ): Promise { - const stash = await Stash.init(dir, globalConfig); + const canonicalPath = await realpath(dir); + const created = await createTestStash(canonicalPath, globalConfig); + const { id, stash, xdgDir, stashPath, metadataDir } = created; await stash.connect({ name: "origin", provider: "github", repo }); - return { dir, stash }; + return { + dir: canonicalPath, + id, + stash, + xdgDir, + stashPath, + metadataDir, + }; } async function setupTwoMachineBaseline(initialFiles: Record): Promise<{ @@ -284,14 +369,7 @@ async function setupTwoMachineBaseline(initialFiles: Record { @@ -307,14 +390,15 @@ test("scenario 1: connect auto-inits stash and keeps existing files", E2E_OPTION const xdg = await makeTempDir("xdg"); try { await writeFile(join(dir, "hello.md"), "hello", "utf8"); - await runCli( - dir, - ["connect", "github", "origin", "--repo", "user/repo", "--token", "test-token"], - { - XDG_CONFIG_HOME: xdg, - }, - ); - assert.equal(existsSync(join(dir, ".stash")), true); + await runCli(dir, ["connect", ".", "github:user/repo", "--token", "test-token"], { + XDG_CONFIG_HOME: xdg, + }); + const { record } = await readOnlyStashRecord(getTestStashPath(xdg)); + assert.deepEqual(record, { + path: await realpath(dir), + connections: [{ provider: "github", config: { id: "user/repo" } }], + }); + assert.equal(existsSync(join(dir, ".stash")), false); assert.equal(await readFile(join(dir, "hello.md"), "utf8"), "hello"); } finally { await rm(dir, { recursive: true, force: true }); @@ -329,31 +413,29 @@ test( const dir = await makeTempDir("connect-reregister"); const xdg = await makeTempDir("xdg"); try { - await runCli( - dir, - ["connect", "github", "origin", "--repo", "user/repo", "--token", "test-token"], - { - XDG_CONFIG_HOME: xdg, - }, - ); - const second = await execFileAsync( - "node", - [CLI_PATH, "connect", "github", "backup", "--repo", "user/repo"], - { - cwd: dir, - env: { ...process.env, XDG_CONFIG_HOME: xdg }, - }, - ).catch((error) => error as { stdout: string; stderr: string; code: number }); + await runCli(dir, ["connect", ".", "github:user/repo", "--token", "test-token"], { + XDG_CONFIG_HOME: xdg, + }); + const second = await execFileAsync("node", [CLI_PATH, "connect", ".", "github:user/other"], { + cwd: dir, + env: { ...process.env, XDG_CONFIG_HOME: xdg }, + }).catch((error) => error as { stdout: string; stderr: string; code: number }); assert.equal(second.code, 1); const combined = `${second.stdout ?? ""}${second.stderr ?? ""}`; - assert.equal(combined.includes('already has connection "origin"'), true); - assert.equal(combined.includes("stash disconnect origin"), true); + assert.equal(combined.includes("already connected to github:user/repo"), true); + assert.equal(combined.includes(`stash disconnect ${await realpath(dir)}`), true); - const globalConfig = JSON.parse(await readFile(join(xdg, "stash", "config.json"), "utf8")); - assert.deepEqual(globalConfig.background?.stashes, [await realpath(dir)]); - const localConfig = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); - assert.deepEqual(Object.keys(localConfig.connections ?? {}), ["origin"]); + const globalConfig = JSON.parse( + await readFile(join(getTestStashPath(xdg), "config.json"), "utf8"), + ); + assert.deepEqual(globalConfig, { + providers: { + github: { token: "test-token" }, + }, + }); + const { record } = await readOnlyStashRecord(getTestStashPath(xdg)); + assert.deepEqual(record.connections, [{ provider: "github", config: { id: "user/repo" } }]); } finally { await rm(dir, { recursive: true, force: true }); await rm(xdg, { recursive: true, force: true }); @@ -368,15 +450,12 @@ test("scenario 3: setup stores provider credentials globally", E2E_OPTIONS, asyn await runCli(dir, ["setup", "github", "--token", "test-token"], { XDG_CONFIG_HOME: xdg, }); - const configPath = join(xdg, "stash", "config.json"); + const configPath = join(getTestStashPath(xdg), "config.json"); const config = JSON.parse(await readFile(configPath, "utf8")); assert.deepEqual(config, { providers: { github: { token: "test-token" }, }, - background: { - stashes: [], - }, }); } finally { await rm(dir, { recursive: true, force: true }); @@ -391,16 +470,23 @@ test("scenario 4: connect stores stash connection config", E2E_OPTIONS, async () await runCli(dir, ["setup", "github", "--token", "test-token"], { XDG_CONFIG_HOME: xdg, }); - await runCli(dir, ["connect", "github", "origin", "--repo", "user/repo"], { + await runCli(dir, ["connect", ".", "github:user/repo"], { XDG_CONFIG_HOME: xdg, }); - const config = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); - assert.deepEqual(config, { - connections: { origin: { provider: "github", repo: "user/repo" } }, + const { record } = await readOnlyStashRecord(getTestStashPath(xdg)); + assert.deepEqual(record, { + path: await realpath(dir), + connections: [{ provider: "github", config: { id: "user/repo" } }], }); - const globalConfig = JSON.parse(await readFile(join(xdg, "stash", "config.json"), "utf8")); - assert.deepEqual(globalConfig.background?.stashes, [await realpath(dir)]); + const globalConfig = JSON.parse( + await readFile(join(getTestStashPath(xdg), "config.json"), "utf8"), + ); + assert.deepEqual(globalConfig, { + providers: { + github: { token: "test-token" }, + }, + }); } finally { await rm(dir, { recursive: true, force: true }); await rm(xdg, { recursive: true, force: true }); @@ -411,29 +497,23 @@ test("scenario 5: connect auto-writes setup config when missing", E2E_OPTIONS, a const dir = await makeTempDir("connect-autosetup"); const xdg = await makeTempDir("xdg"); try { - await runCli( - dir, - ["connect", "github", "origin", "--repo", "user/repo", "--token", "from-connect"], - { - XDG_CONFIG_HOME: xdg, - }, - ); + await runCli(dir, ["connect", ".", "github:user/repo", "--token", "from-connect"], { + XDG_CONFIG_HOME: xdg, + }); - const globalConfig = JSON.parse(await readFile(join(xdg, "stash", "config.json"), "utf8")); + const globalConfig = JSON.parse( + await readFile(join(getTestStashPath(xdg), "config.json"), "utf8"), + ); assert.deepEqual(globalConfig, { providers: { github: { token: "from-connect" }, }, - background: { - stashes: [await realpath(dir)], - }, }); - const localConfig = JSON.parse(await readFile(join(dir, ".stash", "config.json"), "utf8")); - assert.deepEqual(localConfig, { - connections: { - origin: { provider: "github", repo: "user/repo" }, - }, + const { record } = await readOnlyStashRecord(getTestStashPath(xdg)); + assert.deepEqual(record, { + path: await realpath(dir), + connections: [{ provider: "github", config: { id: "user/repo" } }], }); } finally { await rm(dir, { recursive: true, force: true }); @@ -449,17 +529,19 @@ test("scenario 6: disconnect removes connection and sync becomes no-op", E2E_OPT await runCli(dir, ["setup", "github", "--token", "test-token"], { XDG_CONFIG_HOME: xdg, }); - await runCli(dir, ["connect", "github", "origin", "--repo", "user/repo"], { + await runCli(dir, ["connect", ".", "github:user/repo"], { XDG_CONFIG_HOME: xdg, }); - await runCli(dir, ["disconnect", "origin"], { XDG_CONFIG_HOME: xdg }); + await runCli(dir, ["disconnect", "."], { XDG_CONFIG_HOME: xdg }); assert.equal(existsSync(join(dir, ".stash")), false); - const globalConfig = JSON.parse(await readFile(join(xdg, "stash", "config.json"), "utf8")); - assert.deepEqual(globalConfig.background?.stashes, []); + const stashPath = getTestStashPath(xdg); + const { id, record } = await readOnlyStashRecord(stashPath); + assert.deepEqual(record.connections, []); - await assert.rejects(runCli(dir, ["sync"], { XDG_CONFIG_HOME: xdg })); + const stash = await Stash.load(id, makeGlobalConfig("test-token"), { stashPath }); + await syncWithRetry(stash); assert.equal(await readFile(join(dir, "hello.md"), "utf8"), "hello"); } finally { await rm(dir, { recursive: true, force: true }); @@ -485,21 +567,15 @@ test("scenario 8: first sync pulls remote files to empty local", E2E_OPTIONS, as const machine = await makeTempDir("machine"); dirs.push(machine); - const stash = await Stash.init(machine, { - providers: { - github: { token: token! }, - }, - background: { - stashes: [], - }, - }); + const { stash, xdgDir, metadataDir } = await createTestStash(machine); + dirs.push(xdgDir); await stash.connect({ name: "github", provider: "github", repo: repo.fullName }); await syncWithRetry(stash); assert.equal(await readFile(join(machine, "readme.md"), "utf8"), "welcome"); assert.equal(await readFile(join(machine, "data/config.json"), "utf8"), "{}"); - assert.equal(existsSync(join(machine, ".stash", "snapshot.json")), true); - assert.equal(existsSync(join(machine, ".stash", "snapshot", "readme.md")), true); + assert.equal(existsSync(join(metadataDir, "snapshot.json")), true); + assert.equal(existsSync(join(metadataDir, "snapshot", "readme.md")), true); } finally { if (repo) await deleteRepo(repo.fullName); await Promise.all(dirs.map((dir) => rm(dir, { recursive: true, force: true }))); @@ -517,21 +593,15 @@ test("scenario 8b: fresh stash pulls nested dirs from non-stash repo", E2E_OPTIO const machine = await makeTempDir("fresh-pull-nested"); dirs.push(machine); - const stash = await Stash.init(machine, { - providers: { - github: { token: token! }, - }, - background: { - stashes: [], - }, - }); + const { stash, xdgDir, metadataDir } = await createTestStash(machine); + dirs.push(xdgDir); await stash.connect({ name: "github", provider: "github", repo: repo.fullName }); await syncWithRetry(stash); assert.equal(await readFile(join(machine, "readme.md"), "utf8"), "hello"); assert.equal(await readFile(join(machine, "docs/guide.md"), "utf8"), "guide content"); assert.equal(await readFile(join(machine, "docs/deep/nested.md"), "utf8"), "deep content"); - assert.equal(existsSync(join(machine, ".stash", "snapshot.json")), true); + assert.equal(existsSync(join(metadataDir, "snapshot.json")), true); } finally { if (repo) await deleteRepo(repo.fullName); await Promise.all(dirs.map((dir) => rm(dir, { recursive: true, force: true }))); @@ -544,15 +614,9 @@ test("scenario 8c: new machine joins existing stash with nested dirs", E2E_OPTIO try { repo = await createRepo(); const machineADir = await makeTempDir("machine-a"); - dirs.push(machineADir); - const machineA = await createMachine(machineADir, repo.fullName, { - providers: { - github: { token: token! }, - }, - background: { - stashes: [], - }, - }); + const globalConfig = makeGlobalConfig(); + const machineA = await createMachine(machineADir, repo.fullName, globalConfig); + dirs.push(machineA.dir, machineA.xdgDir); await writeLocalFiles(machineADir, { "readme.md": "hello", "docs/guide.md": "guide", @@ -561,15 +625,8 @@ test("scenario 8c: new machine joins existing stash with nested dirs", E2E_OPTIO await syncWithRetry(machineA.stash); const machineBDir = await makeTempDir("machine-b"); - dirs.push(machineBDir); - const machineB = await createMachine(machineBDir, repo.fullName, { - providers: { - github: { token: token! }, - }, - background: { - stashes: [], - }, - }); + const machineB = await createMachine(machineBDir, repo.fullName, globalConfig); + dirs.push(machineB.dir, machineB.xdgDir); await syncWithRetry(machineB.stash); assert.equal(await readFile(join(machineBDir, "readme.md"), "utf8"), "hello"); @@ -597,14 +654,8 @@ test( const machine = await makeTempDir("machine"); dirs.push(machine); await writeFile(join(machine, "local.md"), "local content", "utf8"); - const stash = await Stash.init(machine, { - providers: { - github: { token: token! }, - }, - background: { - stashes: [], - }, - }); + const { stash, xdgDir, metadataDir } = await createTestStash(machine); + dirs.push(xdgDir); await stash.connect({ name: "github", provider: "github", repo: repo.fullName }); await syncWithRetry(stash); @@ -623,7 +674,7 @@ test( assert.equal(await readFile(join(machine, "local.md"), "utf8"), "local content"); assert.equal(await remoteFileExists(repo.fullName, "shared.md"), true); assert.equal(await remoteFileExists(repo.fullName, "local.md"), true); - const snapshot = JSON.parse(await readFile(join(machine, ".stash", "snapshot.json"), "utf8")); + const snapshot = JSON.parse(await readFile(join(metadataDir, "snapshot.json"), "utf8")); assert.equal(typeof snapshot["shared.md"]?.hash, "string"); assert.equal(typeof snapshot["local.md"]?.hash, "string"); } finally { @@ -892,20 +943,19 @@ test("scenario 21: concurrent binary edits resolve to last writer", E2E_OPTIONS, test("scenario 22: sync with no connection is a no-op", E2E_OPTIONS, async () => { const dir = await makeTempDir("no-connection"); + let xdgDir: string | null = null; try { await writeFile(join(dir, "hello.md"), "hello", "utf8"); - const stash = await Stash.init(dir, { - providers: { - github: { token: token! }, - }, - background: { - stashes: [], - }, - }); + const created = await createTestStash(dir); + xdgDir = created.xdgDir; + const { stash } = created; await syncWithRetry(stash); assert.equal(await readFile(join(dir, "hello.md"), "utf8"), "hello"); } finally { await rm(dir, { recursive: true, force: true }); + if (xdgDir) { + await rm(xdgDir, { recursive: true, force: true }); + } } }); @@ -989,10 +1039,10 @@ test("scenario 25: multiple sync cycles converge to identical state", E2E_OPTION throw new Error("Failed to validate converged file set"); } const snapA = JSON.parse( - await readFile(join(setup.machineA.dir, ".stash", "snapshot.json"), "utf8"), + await readFile(join(setup.machineA.metadataDir, "snapshot.json"), "utf8"), ); const snapB = JSON.parse( - await readFile(join(setup.machineB.dir, ".stash", "snapshot.json"), "utf8"), + await readFile(join(setup.machineB.metadataDir, "snapshot.json"), "utf8"), ); assert.deepEqual(snapA, snapB); } finally { @@ -1032,14 +1082,8 @@ test("scenario 27: status before first sync has null lastSync", E2E_OPTIONS, asy const dir = await makeTempDir("status-first"); dirs.push(dir); await writeFile(join(dir, "hello.md"), "hello", "utf8"); - const stash = await Stash.init(dir, { - providers: { - github: { token: token! }, - }, - background: { - stashes: [], - }, - }); + const { stash, xdgDir } = await createTestStash(dir); + dirs.push(xdgDir); await stash.connect({ name: "github", provider: "github", repo: repo.fullName }); const status = stash.status(); assert.deepEqual(status.added, ["hello.md"]); @@ -1108,12 +1152,12 @@ test("scenario 30: symlinks are ignored", E2E_OPTIONS, async () => { } }); -test("scenario 31: .stash local metadata is ignored remotely", E2E_OPTIONS, async () => { +test("scenario 31: stash metadata is ignored remotely", E2E_OPTIONS, async () => { let setup: Awaited> | null = null; try { setup = await setupTwoMachineBaseline({ "hello.md": "hello" }); await syncWithRetry(setup.machineA.stash); - assert.equal(await remoteFileExists(setup.repo.fullName, ".stash/config.json"), false); + assert.equal(existsSync(join(setup.machineA.dir, ".stash")), false); assert.equal(await remoteFileExists(setup.repo.fullName, ".stash/snapshot/hello.md"), false); assert.equal(await remoteFileExists(setup.repo.fullName, ".stash/snapshot.json"), true); } finally { @@ -1137,14 +1181,8 @@ test( const machine = await makeTempDir("identical-first-sync"); dirs.push(machine); await writeLocalFiles(machine, { "hello.md": "hello" }); - const stash = await Stash.init(machine, { - providers: { - github: { token: token! }, - }, - background: { - stashes: [], - }, - }); + const { stash, xdgDir, metadataDir } = await createTestStash(machine); + dirs.push(xdgDir); await stash.connect({ name: "github", provider: "github", repo: repo.fullName }); await syncWithRetry(stash); @@ -1152,9 +1190,7 @@ test( assert.equal(await remoteFileExistsRetry(repo.fullName, ".stash/snapshot.json"), true); assert.equal(await readRemoteText(repo.fullName, "hello.md"), "hello"); - const localSnapshot = JSON.parse( - await readFile(join(machine, ".stash", "snapshot.json"), "utf8"), - ); + const localSnapshot = JSON.parse(await readFile(join(metadataDir, "snapshot.json"), "utf8")); assert.equal(typeof localSnapshot["hello.md"]?.hash, "string"); assert.equal(localSnapshot["hello.md"].hash, hashBuffer(Buffer.from("hello", "utf8"))); @@ -1215,10 +1251,10 @@ test("scenario 37: directory case rename converges across two machines", E2E_OPT // Verify both snapshots agree const snapshotA = JSON.parse( - await readFile(join(machineA.dir, ".stash", "snapshot.json"), "utf8"), + await readFile(join(machineA.metadataDir, "snapshot.json"), "utf8"), ); const snapshotB = JSON.parse( - await readFile(join(machineB.dir, ".stash", "snapshot.json"), "utf8"), + await readFile(join(machineB.metadataDir, "snapshot.json"), "utf8"), ); assert.deepEqual(snapshotA, snapshotB); assert.ok(snapshotA["notes/draft.md"]); @@ -1271,18 +1307,18 @@ test("e2e: daemon syncs a registered stash and writes status.json", E2E_OPTIONS, const xdgDir = await makeTempDir("daemon-e2e-xdg"); dirs.push(dir, xdgDir); - const globalConfig: GlobalConfig = { - providers: { github: { token: token! } }, - background: { stashes: [dir] }, - }; - - const stash = await Stash.init(dir, globalConfig); + const globalConfig = makeGlobalConfig(); + const { stash, metadataDir } = await createTestStash(dir, globalConfig, xdgDir); await stash.connect({ name: "github", provider: "github", repo: repo.fullName }); await writeLocalFiles(dir, { "daemon-test.txt": "hello from daemon" }); - const configDir = join(xdgDir, "stash"); + const configDir = getTestStashPath(xdgDir); await mkdir(configDir, { recursive: true }); - await writeFile(join(configDir, "config.json"), JSON.stringify(globalConfig, null, 2), "utf8"); + await writeFile( + join(configDir, "config.json"), + JSON.stringify({ providers: globalConfig.providers }, null, 2), + "utf8", + ); const daemon = spawn("node", [CLI_PATH, "daemon"], { env: { ...process.env, XDG_CONFIG_HOME: xdgDir }, @@ -1299,7 +1335,7 @@ test("e2e: daemon syncs a registered stash and writes status.json", E2E_OPTIONS, daemonStderr += chunk; }); - const statusPath = join(dir, ".stash", "status.json"); + const statusPath = join(metadataDir, "status.json"); let synced = false; let lastStatus: Record | null = null; for (let i = 0; i < 30; i += 1) { @@ -1330,7 +1366,7 @@ test("e2e: daemon syncs a registered stash and writes status.json", E2E_OPTIONS, const status = JSON.parse(await readFile(statusPath, "utf8")); assert.ok(status.lastSync, "status should have a lastSync timestamp"); - const logPath = join(dir, ".stash", "sync.log"); + const logPath = join(metadataDir, "sync.log"); assert.equal(existsSync(logPath), true, "sync.log should exist"); const log = await readFile(logPath, "utf8"); assert.ok(log.length > 0, "sync.log should not be empty"); diff --git a/tests/e2e/stash-github.e2e.test.ts b/tests/e2e/stash-github.e2e.test.ts index 665d813..3bb3d4e 100644 --- a/tests/e2e/stash-github.e2e.test.ts +++ b/tests/e2e/stash-github.e2e.test.ts @@ -1,10 +1,23 @@ import test from "node:test"; import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Stash } from "../../src/stash.ts"; +import type { GlobalConfig } from "../../src/types.ts"; + +async function initStash(dir: string, globalConfig: GlobalConfig): Promise { + const xdgDir = await mkdtemp(join(tmpdir(), `xdg-${randomUUID().slice(0, 8)}-`)); + const stashPath = join(xdgDir, "stash"); + const canonicalPath = await realpath(dir); + const created = await Stash.create( + { path: canonicalPath, connections: [] }, + globalConfig, + { stashPath }, + ); + return Stash.load(created.id, globalConfig, { stashPath }); +} const token = process.env.GITHUB_TOKEN; @@ -176,7 +189,7 @@ test( stashes: [], }, }; - const stashA = await Stash.init(machineA, globalConfig); + const stashA = await initStash(machineA, globalConfig); await stashA.connect({ name: "github", provider: "github", repo: repo.fullName }); await syncWithRetry(stashA); @@ -184,7 +197,7 @@ test( assert.equal(await getRawFile(repo.fullName, "notes.md"), "note"); assert.ok(await getRawFile(repo.fullName, ".stash/snapshot.json")); - const stashB = await Stash.init(machineB, globalConfig); + const stashB = await initStash(machineB, globalConfig); await stashB.connect({ name: "github", provider: "github", repo: repo.fullName }); await syncWithRetry(stashB); @@ -221,11 +234,11 @@ test( }; await writeFile(join(machineA, "hello.md"), "line1\nline2\nline3", "utf8"); - const stashA = await Stash.init(machineA, globalConfig); + const stashA = await initStash(machineA, globalConfig); await stashA.connect({ name: "github", provider: "github", repo: repo.fullName }); await syncWithRetry(stashA); - const stashB = await Stash.init(machineB, globalConfig); + const stashB = await initStash(machineB, globalConfig); await stashB.connect({ name: "github", provider: "github", repo: repo.fullName }); await syncWithRetry(stashB); diff --git a/tests/integration/background-cli.integration.test.ts b/tests/integration/background-cli.integration.test.ts index dbc1472..555f25f 100644 --- a/tests/integration/background-cli.integration.test.ts +++ b/tests/integration/background-cli.integration.test.ts @@ -130,7 +130,7 @@ test("connect creates a new stash record and stores provider globals separately" try { const result = await runMain( dir, - ["connect", "github:user/repo", "--token", "test-token"], + ["connect", ".", "github:user/repo", "--token", "test-token"], { serviceStatus: { installed: true, running: true }, }, @@ -159,8 +159,8 @@ test("connect is idempotent for the same uri", async () => { const dir = await makeTempDir("stash-cli-connect-idempotent"); try { - const first = await runMain(dir, ["connect", "github:user/repo", "--token", "test-token"]); - const second = await runMain(dir, ["connect", "github:user/repo"], { + const first = await runMain(dir, ["connect", ".", "github:user/repo", "--token", "test-token"]); + const second = await runMain(dir, ["connect", ".", "github:user/repo"], { config: first.config, }); @@ -180,7 +180,7 @@ test("connect skips rewriting global config when provider globals are unchanged" const dir = await makeTempDir("stash-cli-connect-global-unchanged"); try { - const result = await runMain(dir, ["connect", "github:user/repo"], { + const result = await runMain(dir, ["connect", ".", "github:user/repo"], { config: { providers: { github: { token: "test-token" }, @@ -207,7 +207,7 @@ test("connect blocks git repositories unless --dangerously-allow-git is passed", try { await mkdir(join(dir, ".git"), { recursive: true }); - const result = await runMain(dir, ["connect", "github:user/repo", "--token", "test-token"]); + const result = await runMain(dir, ["connect", ".", "github:user/repo", "--token", "test-token"]); const stderr = stripAnsi(result.stderr); assert.equal(result.exitCode, 1); @@ -225,10 +225,10 @@ test("disconnect tombstones the last connection without deleting the stash dir", const dir = await makeTempDir("stash-cli-disconnect"); try { - const connected = await runMain(dir, ["connect", "github:user/repo", "--token", "test-token"]); + const connected = await runMain(dir, ["connect", ".", "github:user/repo", "--token", "test-token"]); const { id } = await readOnlyStashRecord(getTestStashPath(dir)); - const result = await runMain(dir, ["disconnect"], { + const result = await runMain(dir, ["disconnect", "."], { config: connected.config, }); @@ -252,7 +252,7 @@ test("relink rewrites stash.json.path and enforces the git gate on the new path" const moved = await makeTempDir("stash-cli-relink-new"); try { - const connected = await runMain(dir, ["connect", "github:user/repo", "--token", "test-token"]); + const connected = await runMain(dir, ["connect", ".", "github:user/repo", "--token", "test-token"]); const { id } = await readOnlyStashRecord(getTestStashPath(dir)); await mkdir(join(moved, ".git"), { recursive: true }); @@ -365,7 +365,7 @@ test("status shows daemon state and empty hint when no stashes are registered", assert.equal(result.exitCode, 0); assert.equal(stdout.includes("Daemon: stopped"), true); assert.equal( - stdout.includes("No stashes registered. Run `stash connect ` to add one."), + stdout.includes("No stashes registered. Run `stash connect ` to add one"), true, ); } finally { @@ -415,7 +415,7 @@ test("error output is plain stderr with an Error prefix", async () => { const dir = await makeTempDir("stash-cli-error-shape"); try { - const result = await runMain(dir, ["disconnect"]); + const result = await runMain(dir, ["disconnect", "."]); const stderr = stripAnsi(result.stderr); assert.equal(result.exitCode, 1); diff --git a/tests/integration/cli.integration.test.ts b/tests/integration/cli.integration.test.ts index c517404..f71c8bb 100644 --- a/tests/integration/cli.integration.test.ts +++ b/tests/integration/cli.integration.test.ts @@ -61,7 +61,7 @@ test("cli connect creates registry metadata without a local .stash directory", a const dir = await makeTempDir("cli-connect-uri"); try { - const result = await runCli(dir, ["connect", "github:user/repo", "--token", "test-token"]); + const result = await runCli(dir, ["connect", ".", "github:user/repo", "--token", "test-token"]); const canonicalDir = await realpath(dir); assert.equal(stripAnsi(result.stdout).includes("Connected"), true); @@ -106,15 +106,67 @@ test("cli connect accepts an explicit path argument", async () => { } }); +test("cli connect creates the target folder when it does not exist", async () => { + const dir = await makeTempDir("cli-connect-mkdir"); + const target = join(dir, "nested", "new-folder"); + + try { + assert.equal(existsSync(target), false); + const result = await runCli(dir, ["connect", target, "github:user/repo", "--token", "test-token"]); + + assert.equal(stripAnsi(result.stdout).includes("Connected"), true); + assert.equal(existsSync(target), true); + const record = JSON.parse( + await readFile( + join(getTestStashPath(dir), (await listStashIds(getTestStashPath(dir)))[0], "stash.json"), + "utf8", + ), + ) as { path: string }; + assert.equal(record.path, await realpath(target)); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("cli connect requires a path argument", async () => { + const dir = await makeTempDir("cli-connect-no-path"); + + try { + const error = await runCli(dir, ["connect", "github:user/repo"]).catch( + (value) => value as { stdout: string; stderr: string; code: number }, + ); + assert.equal(error.code, 1); + const stderr = stripAnsi(error.stderr ?? ""); + assert.equal(stderr.includes("missing required argument"), true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("cli disconnect requires a path argument", async () => { + const dir = await makeTempDir("cli-disconnect-no-path"); + + try { + const error = await runCli(dir, ["disconnect"]).catch( + (value) => value as { stdout: string; stderr: string; code: number }, + ); + assert.equal(error.code, 1); + const stderr = stripAnsi(error.stderr ?? ""); + assert.equal(stderr.includes("missing required argument"), true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test("cli disconnect tombstones the stash record", async () => { const dir = await makeTempDir("cli-disconnect"); try { - await runCli(dir, ["connect", "github:user/repo", "--token", "test-token"]); + await runCli(dir, ["connect", ".", "github:user/repo", "--token", "test-token"]); const [id] = await listStashIds(getTestStashPath(dir)); const canonicalDir = await realpath(dir); - const result = await runCli(dir, ["disconnect"]); + const result = await runCli(dir, ["disconnect", "."]); assert.equal(stripAnsi(result.stdout).includes("Disconnected"), true); assert.equal(stripAnsi(result.stdout).includes("github:user/repo"), true); @@ -133,7 +185,7 @@ test("cli relink rewrites the canonical path stored in stash.json", async () => const moved = await makeTempDir("cli-relink-new"); try { - await runCli(dir, ["connect", "github:user/repo", "--token", "test-token"]); + await runCli(dir, ["connect", ".", "github:user/repo", "--token", "test-token"]); const [id] = await listStashIds(getTestStashPath(dir)); const canonicalDir = await realpath(dir); const canonicalMoved = await realpath(moved); @@ -159,7 +211,7 @@ test("cli status prints registered stashes from the registry root", async () => const stashPath = getTestStashPath(dir); try { - await runCli(dir, ["connect", "github:user/repo", "--token", "test-token"]); + await runCli(dir, ["connect", ".", "github:user/repo", "--token", "test-token"]); const [id] = await listStashIds(stashPath); await writeFile( join(stashPath, id, "status.json"), @@ -196,7 +248,7 @@ test("cli connect blocks git repos unless --dangerously-allow-git is passed", as const error = await execFileAsync( "node", - [CLI_PATH, "connect", "github:user/repo", "--token", "test-token"], + [CLI_PATH, "connect", ".", "github:user/repo", "--token", "test-token"], { cwd: dir, env: { ...process.env, XDG_CONFIG_HOME: join(dir, ".xdg") }, @@ -211,6 +263,7 @@ test("cli connect blocks git repos unless --dangerously-allow-git is passed", as const allowed = await runCli(dir, [ "connect", + ".", "github:user/repo", "--token", "test-token", @@ -228,7 +281,7 @@ test("cli output ends with a trailing blank line so the shell prompt has breathi const dir = await makeTempDir("cli-trailing-blank"); try { - const connect = await runCli(dir, ["connect", "github:user/repo", "--token", "test-token"]); + const connect = await runCli(dir, ["connect", ".", "github:user/repo", "--token", "test-token"]); assert.equal(connect.stdout.endsWith("\n\n"), true, "connect stdout should end with a blank line"); const status = await runCli(dir, ["status"]);