@robinthues/rt-claude-coach 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Rebuild a database from `schemaSql` plus a dump produced by `dumpDatabase`.
3
+ *
4
+ * Refuses to touch an existing file: restoring over live data would be the
5
+ * exact accident this whole feature exists to protect against.
6
+ *
7
+ * A restore happens once every few years, at the worst possible moment: the
8
+ * original machine is gone and the user is rebuilding from nothing. If schema
9
+ * creation or dump replay fails partway through, we must not leave a partial
10
+ * file behind — a half-built database at `dbPath` would make the *next*
11
+ * attempt fail with "already exists" and send the user hunting for data they
12
+ * never had, instead of just letting them retry. So on any failure after the
13
+ * file is created, we close it, delete it (this call is the only thing that
14
+ * could have created it, since the existsSync guard above already refused to
15
+ * run otherwise), and rethrow the original error — never a cleanup error.
16
+ */
17
+ export declare function restoreDatabase(dbPath: string, dumpSql: string, schemaSql: string): void;
18
+ /**
19
+ * Move `dbPath` to `<dbPath>.bak`, replacing an older .bak, and return the
20
+ * new path. Its journal and WAL files move with it under the matching .bak
21
+ * names: left behind, SQLite could apply a stale journal to the restored
22
+ * database, while next to the .bak it still belongs to the data it came
23
+ * from. Sidecars of the older .bak are removed so they cannot be applied to
24
+ * the wrong file.
25
+ */
26
+ export declare function moveAsideDatabase(dbPath: string): string;
27
+ /** The `restore-db` command. Returns the process exit code. */
28
+ export declare function runRestore(options: {
29
+ dbPath: string;
30
+ dumpPath: string;
31
+ schemaPath: string;
32
+ force: boolean;
33
+ }): number;
@@ -0,0 +1,102 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { existsSync, readFileSync, renameSync, rmSync, unlinkSync } from "fs";
3
+ import { log } from "../lib/logging.js";
4
+ /**
5
+ * Rebuild a database from `schemaSql` plus a dump produced by `dumpDatabase`.
6
+ *
7
+ * Refuses to touch an existing file: restoring over live data would be the
8
+ * exact accident this whole feature exists to protect against.
9
+ *
10
+ * A restore happens once every few years, at the worst possible moment: the
11
+ * original machine is gone and the user is rebuilding from nothing. If schema
12
+ * creation or dump replay fails partway through, we must not leave a partial
13
+ * file behind — a half-built database at `dbPath` would make the *next*
14
+ * attempt fail with "already exists" and send the user hunting for data they
15
+ * never had, instead of just letting them retry. So on any failure after the
16
+ * file is created, we close it, delete it (this call is the only thing that
17
+ * could have created it, since the existsSync guard above already refused to
18
+ * run otherwise), and rethrow the original error — never a cleanup error.
19
+ */
20
+ export function restoreDatabase(dbPath, dumpSql, schemaSql) {
21
+ if (existsSync(dbPath)) {
22
+ throw new Error(`${dbPath} already exists. Move it aside or pass --force.`);
23
+ }
24
+ const db = new DatabaseSync(dbPath);
25
+ try {
26
+ db.exec(schemaSql);
27
+ db.exec("BEGIN");
28
+ try {
29
+ if (dumpSql.trim().length > 0)
30
+ db.exec(dumpSql);
31
+ db.exec("COMMIT");
32
+ }
33
+ catch (error) {
34
+ db.exec("ROLLBACK");
35
+ throw error;
36
+ }
37
+ }
38
+ catch (error) {
39
+ try {
40
+ db.close();
41
+ }
42
+ catch {
43
+ // Original error is what the caller needs to see; ignore close failures here.
44
+ }
45
+ try {
46
+ unlinkSync(dbPath);
47
+ }
48
+ catch {
49
+ // Original error still takes priority over any cleanup failure.
50
+ }
51
+ throw error;
52
+ }
53
+ db.close();
54
+ }
55
+ /** Files SQLite keeps next to a database, named `<db>-journal` and so on. */
56
+ const SIDECARS = ["-journal", "-wal", "-shm"];
57
+ /**
58
+ * Move `dbPath` to `<dbPath>.bak`, replacing an older .bak, and return the
59
+ * new path. Its journal and WAL files move with it under the matching .bak
60
+ * names: left behind, SQLite could apply a stale journal to the restored
61
+ * database, while next to the .bak it still belongs to the data it came
62
+ * from. Sidecars of the older .bak are removed so they cannot be applied to
63
+ * the wrong file.
64
+ */
65
+ export function moveAsideDatabase(dbPath) {
66
+ const bakPath = `${dbPath}.bak`;
67
+ for (const suffix of SIDECARS)
68
+ rmSync(`${bakPath}${suffix}`, { force: true });
69
+ renameSync(dbPath, bakPath);
70
+ for (const suffix of SIDECARS) {
71
+ if (existsSync(`${dbPath}${suffix}`))
72
+ renameSync(`${dbPath}${suffix}`, `${bakPath}${suffix}`);
73
+ }
74
+ return bakPath;
75
+ }
76
+ /** The `restore-db` command. Returns the process exit code. */
77
+ export function runRestore(options) {
78
+ const { dbPath, dumpPath, schemaPath } = options;
79
+ if (!existsSync(dumpPath)) {
80
+ log.error(`No dump at ${dumpPath}`);
81
+ return 1;
82
+ }
83
+ if (existsSync(dbPath)) {
84
+ if (!options.force) {
85
+ log.error(`${dbPath} already exists. Move it aside or pass --force.`);
86
+ return 1;
87
+ }
88
+ }
89
+ const backupPath = existsSync(dbPath) ? moveAsideDatabase(dbPath) : undefined;
90
+ if (backupPath)
91
+ log.info(`Moved the existing database to ${backupPath}`);
92
+ try {
93
+ restoreDatabase(dbPath, readFileSync(dumpPath, "utf-8"), readFileSync(schemaPath, "utf-8"));
94
+ }
95
+ catch (error) {
96
+ const kept = backupPath ? ` Your previous database is at ${backupPath}.` : "";
97
+ log.error(`Restore failed: ${error.message}.${kept}`);
98
+ return 1;
99
+ }
100
+ log.success(`Restored ${dbPath} from ${dumpPath}`);
101
+ return 0;
102
+ }
package/dist/cli.d.ts CHANGED
@@ -1 +1,10 @@
1
+ interface BackupArgs {
2
+ command: "backup";
3
+ init: boolean;
4
+ push: boolean;
5
+ quiet: boolean;
6
+ repo: string;
7
+ }
8
+ export declare const DEFAULT_BACKUP_REPO = "RobinThues/claude-coach-data";
9
+ export declare function parseBackupArgs(args: string[]): BackupArgs;
1
10
  export {};
package/dist/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- import { configExists, loadConfig, promptForConfig, saveConfig, saveTokens, getDbPath, createConfig, } from "./lib/config.js";
1
+ import { configExists, loadConfig, promptForConfig, saveConfig, saveTokens, getDbPath, getDataDir, createConfig, } from "./lib/config.js";
2
2
  import { log } from "./lib/logging.js";
3
3
  import { migrate } from "./db/migrate.js";
4
4
  import { execute, initDatabase, query, queryJson } from "./db/client.js";
@@ -9,6 +9,8 @@ import { syncActivityDetails } from "./strava/details.js";
9
9
  import { convertPlan } from "./garmin/index.js";
10
10
  import { buildFetchList, getDateStatuses, lastNDates, parseRecoveryDay, upsertRecoveryDay, } from "./recovery/index.js";
11
11
  import { buildAvailability, isIsoDate } from "./calendar/index.js";
12
+ import { runBackup, runInit } from "./backup/index.js";
13
+ import { runRestore } from "./backup/restore.js";
12
14
  import { readFileSync, writeFileSync } from "fs";
13
15
  import { dirname, join } from "path";
14
16
  import { fileURLToPath } from "url";
@@ -25,6 +27,27 @@ const proxyUrl = process.env.HTTPS_PROXY ||
25
27
  if (proxyUrl) {
26
28
  setGlobalDispatcher(new ProxyAgent(proxyUrl));
27
29
  }
30
+ export const DEFAULT_BACKUP_REPO = "RobinThues/claude-coach-data";
31
+ export function parseBackupArgs(args) {
32
+ const parsed = {
33
+ command: "backup",
34
+ init: false,
35
+ push: true,
36
+ quiet: false,
37
+ repo: DEFAULT_BACKUP_REPO,
38
+ };
39
+ for (const arg of args.slice(1)) {
40
+ if (arg === "--init")
41
+ parsed.init = true;
42
+ else if (arg === "--no-push")
43
+ parsed.push = false;
44
+ else if (arg === "--quiet")
45
+ parsed.quiet = true;
46
+ else if (arg.startsWith("--repo="))
47
+ parsed.repo = arg.slice("--repo=".length);
48
+ }
49
+ return parsed;
50
+ }
28
51
  function parseArgs() {
29
52
  const args = process.argv.slice(2);
30
53
  if (args.length === 0 || args[0] === "sync") {
@@ -161,6 +184,19 @@ function parseArgs() {
161
184
  }
162
185
  return authArgs;
163
186
  }
187
+ if (args[0] === "backup") {
188
+ return parseBackupArgs(args);
189
+ }
190
+ if (args[0] === "restore-db") {
191
+ const parsed = { command: "restore-db", force: false };
192
+ for (const arg of args.slice(1)) {
193
+ if (arg === "--force")
194
+ parsed.force = true;
195
+ else if (arg.startsWith("--dump="))
196
+ parsed.dumpFile = arg.slice("--dump=".length);
197
+ }
198
+ return parsed;
199
+ }
164
200
  if (args[0] === "--help" || args[0] === "-h" || args[0] === "help") {
165
201
  return { command: "help" };
166
202
  }
@@ -182,6 +218,8 @@ Commands:
182
218
  import-garmin <file> Import Garmin recovery MCP responses into the database
183
219
  calendar-availability <file> Normalize calendar MCP events into per-day busy blocks
184
220
  query <sql> Run a SQL query against the database
221
+ backup Commit the coach data directory and push it to the backup repo
222
+ restore-db Rebuild coach.db from backup/coach-db.sql
185
223
  help Show this help message
186
224
 
187
225
  Auth Options (for headless/Claude environments):
@@ -218,6 +256,16 @@ Calendar Availability Options:
218
256
  Query Options:
219
257
  --json Output as JSON (default: plain text)
220
258
 
259
+ Backup Options:
260
+ --init One-time setup: git init, .gitignore, RESTORE.md, remote
261
+ --no-push Commit locally without pushing
262
+ --quiet Suppress non-error output (used by the Claude Code hook)
263
+ --repo=OWNER/NAME Override the backup repo (default: RobinThues/claude-coach-data)
264
+
265
+ Restore Options:
266
+ --dump=PATH Read the dump from PATH instead of backup/coach-db.sql
267
+ --force Move an existing coach.db to coach.db.bak, then restore
268
+
221
269
  Examples:
222
270
  # Headless auth flow (for Claude/automated environments)
223
271
  npx @robinthues/rt-claude-coach auth --client-id=12345 --client-secret=abc123
@@ -678,6 +726,24 @@ async function runQuery(args) {
678
726
  }
679
727
  }
680
728
  // ============================================================================
729
+ // Backup / Restore Commands
730
+ // ============================================================================
731
+ async function runBackupCommand(args) {
732
+ const code = args.init
733
+ ? await runInit({ repo: args.repo, quiet: args.quiet })
734
+ : await runBackup({ push: args.push, quiet: args.quiet });
735
+ process.exit(code);
736
+ }
737
+ function runRestoreDb(args) {
738
+ const code = runRestore({
739
+ dbPath: getDbPath(),
740
+ dumpPath: args.dumpFile ?? join(getDataDir(), "backup", "coach-db.sql"),
741
+ schemaPath: join(__dirname, "db", "schema.sql"),
742
+ force: args.force,
743
+ });
744
+ process.exit(code);
745
+ }
746
+ // ============================================================================
681
747
  // Main
682
748
  // ============================================================================
683
749
  async function main() {
@@ -710,9 +776,22 @@ async function main() {
710
776
  case "query":
711
777
  await runQuery(args);
712
778
  break;
779
+ case "backup":
780
+ await runBackupCommand(args);
781
+ break;
782
+ case "restore-db":
783
+ runRestoreDb(args);
784
+ break;
713
785
  }
714
786
  }
715
- main().catch((err) => {
716
- log.error(err.message);
717
- process.exit(1);
718
- });
787
+ // Vitest sets VITEST=true in every test process. Importing this module for
788
+ // its exported parsers (parseBackupArgs, etc.) must not also run the CLI
789
+ // against the real environment, so skip main() there. Real invocations
790
+ // (tsx src/cli.ts, or bin/claude-coach.js dynamically importing dist/cli.js)
791
+ // never set this variable.
792
+ if (process.env.VITEST !== "true") {
793
+ main().catch((err) => {
794
+ log.error(err.message);
795
+ process.exit(1);
796
+ });
797
+ }
package/dist/db/client.js CHANGED
@@ -12,6 +12,10 @@ async function detectBackend() {
12
12
  const sqlite = await import("node:sqlite");
13
13
  const dbPath = getDbPath();
14
14
  const db = new sqlite.DatabaseSync(dbPath);
15
+ // A backup dump holds a read-only connection open on this same file. A
16
+ // coach write during that window would otherwise fail at once with
17
+ // "database is locked" instead of just waiting the dump out.
18
+ db.exec("PRAGMA busy_timeout = 5000");
15
19
  return {
16
20
  query(sql) {
17
21
  const stmt = db.prepare(sql);
@@ -1,3 +1,4 @@
1
+ export declare function getDataDir(): string;
1
2
  export interface StravaConfig {
2
3
  client_id: string;
3
4
  client_secret: string;
@@ -2,51 +2,57 @@ import { homedir } from "os";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
3
  import { join } from "path";
4
4
  import * as readline from "readline";
5
- const CONFIG_DIR = join(homedir(), ".claude-coach");
6
- const CONFIG_FILE = join(CONFIG_DIR, "config.json");
7
- const TOKENS_FILE = join(CONFIG_DIR, "tokens.json");
8
- const DB_FILE = join(CONFIG_DIR, "coach.db");
5
+ export function getDataDir() {
6
+ return process.env.CLAUDE_COACH_DIR ?? join(homedir(), ".claude-coach");
7
+ }
8
+ function configFile() {
9
+ return join(getDataDir(), "config.json");
10
+ }
11
+ function tokensFile() {
12
+ return join(getDataDir(), "tokens.json");
13
+ }
9
14
  export function ensureConfigDir() {
10
- if (!existsSync(CONFIG_DIR)) {
11
- mkdirSync(CONFIG_DIR, { recursive: true });
15
+ const dataDir = getDataDir();
16
+ if (!existsSync(dataDir)) {
17
+ mkdirSync(dataDir, { recursive: true });
12
18
  }
13
19
  }
14
20
  export function getConfigPath() {
15
- return CONFIG_FILE;
21
+ return configFile();
16
22
  }
17
23
  export function getTokensPath() {
18
- return TOKENS_FILE;
24
+ return tokensFile();
19
25
  }
20
26
  export function getDbPath() {
21
- return process.env.CLAUDE_COACH_DB ?? DB_FILE;
27
+ return process.env.CLAUDE_COACH_DB ?? join(getDataDir(), "coach.db");
22
28
  }
23
29
  export function configExists() {
24
- return existsSync(CONFIG_FILE);
30
+ return existsSync(configFile());
25
31
  }
26
32
  export function tokensExist() {
27
- return existsSync(TOKENS_FILE);
33
+ return existsSync(tokensFile());
28
34
  }
29
35
  export function loadConfig() {
30
36
  if (!configExists()) {
31
- throw new Error(`Config not found at ${CONFIG_FILE}. Run setup first.`);
37
+ throw new Error(`Config not found at ${configFile()}. Run setup first.`);
32
38
  }
33
- const data = readFileSync(CONFIG_FILE, "utf-8");
39
+ const data = readFileSync(configFile(), "utf-8");
34
40
  return JSON.parse(data);
35
41
  }
36
42
  export function saveConfig(config) {
37
43
  ensureConfigDir();
38
- writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
44
+ writeFileSync(configFile(), JSON.stringify(config, null, 2));
39
45
  }
40
46
  export function loadTokens() {
41
47
  if (!tokensExist()) {
42
- throw new Error(`Tokens not found at ${TOKENS_FILE}. Run auth first.`);
48
+ throw new Error(`Tokens not found at ${tokensFile()}. Run auth first.`);
43
49
  }
44
- const data = readFileSync(TOKENS_FILE, "utf-8");
50
+ const data = readFileSync(tokensFile(), "utf-8");
45
51
  return JSON.parse(data);
46
52
  }
47
53
  export function saveTokens(tokens) {
48
54
  ensureConfigDir();
49
- writeFileSync(TOKENS_FILE, JSON.stringify(tokens, null, 2));
55
+ writeFileSync(tokensFile(), JSON.stringify(tokens, null, 2));
50
56
  }
51
57
  export function tokensExpired(tokens) {
52
58
  // Add 60 second buffer
@@ -0,0 +1,5 @@
1
+ import type { TrainingPlan } from "../../schema/training-plan.js";
2
+ /**
3
+ * Build the viewer's completion map from the plan's own `completed` flags.
4
+ */
5
+ export declare function completedFromPlan(plan: TrainingPlan): Record<string, boolean>;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Build the viewer's completion map from the plan's own `completed` flags.
3
+ */
4
+ export function completedFromPlan(plan) {
5
+ const completed = {};
6
+ plan.weeks?.forEach((week) => {
7
+ week.days?.forEach((day) => {
8
+ day.workouts?.forEach((workout) => {
9
+ if (workout.completed)
10
+ completed[workout.id] = true;
11
+ });
12
+ });
13
+ });
14
+ return completed;
15
+ }
@@ -1,4 +1,3 @@
1
1
  import type { TrainingPlan } from "../../schema/training-plan.js";
2
2
  export declare const planData: TrainingPlan;
3
3
  export declare function loadCompleted(): Record<string, boolean>;
4
- export declare function saveCompleted(completed: Record<string, boolean>): void;
@@ -1,3 +1,4 @@
1
+ import { completedFromPlan } from "../lib/completion.js";
1
2
  // Load plan from embedded JSON
2
3
  function loadPlanData() {
3
4
  const el = document.getElementById("plan-data");
@@ -8,12 +9,10 @@ function loadPlanData() {
8
9
  // Reactive state using Svelte 5's $state rune is only available in .svelte files
9
10
  // So we export the raw data and let components create reactive state
10
11
  export const planData = loadPlanData();
11
- // Completed workouts stored in localStorage
12
- const storageKey = `plan-${planData.meta.id}-completed`;
12
+ // Completion is owned by plan.json — the coach records it there from synced
13
+ // activity data. The viewer reads it and never writes it back: a tick stored
14
+ // only in this browser would be a second source of truth that the next render
15
+ // silently overwrites.
13
16
  export function loadCompleted() {
14
- const saved = localStorage.getItem(storageKey);
15
- return saved ? JSON.parse(saved) : {};
16
- }
17
- export function saveCompleted(completed) {
18
- localStorage.setItem(storageKey, JSON.stringify(completed));
17
+ return completedFromPlan(planData);
19
18
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robinthues/rt-claude-coach",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "author": "Robin Thues",
5
5
  "license": "MIT",
6
6
  "repository": {