@retasc/cli 1.3.0 → 1.3.1

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.
package/dist/api.js CHANGED
@@ -46,12 +46,16 @@ export function isAuthError(e) {
46
46
  * 1. If there is no stored token at all, the user was never signed in — this
47
47
  * isn't an expiry, so rethrow the clean "sign in first" error rather than
48
48
  * launching a surprise device flow under a misleading "session expired".
49
- * 2. `refreshSession()` — swap the refresh token for a fresh access token.
50
- * 3. If that fails (refresh token expired/invalid), fall back to a full
51
- * `deviceLogin()` but only when stdout is a TTY, since the device flow
52
- * prints a code to stdout for a human to authorize; in a captured/piped
53
- * context we surface a clear "run `retasc login`" error instead of hanging
54
- * or spilling the prompt into someone's `$(retasc …)` capture.
49
+ * 2. `refreshSession()` — swap the refresh token for a fresh access token. A
50
+ * transient backend failure here (network/5xx/masked "Server Error") is
51
+ * rethrown by `refreshSession`, so it propagates out as a retryable error
52
+ * instead of forcing a device-flow re-login on a valid session (RTSC-178).
53
+ * 3. If the refresh token is genuinely expired/invalid (a clean `false`, not a
54
+ * throw), fall back to a full `deviceLogin()` — but only when stdout is a
55
+ * TTY, since the device flow prints a code to stdout for a human to
56
+ * authorize; in a captured/piped context we surface a clear "run `retasc
57
+ * login`" error instead of hanging or spilling the prompt into someone's
58
+ * `$(retasc …)` capture.
55
59
  * A second auth failure after recovery is real (rethrown) — we never loop.
56
60
  */
57
61
  async function withAuth(call) {
package/dist/auth.js CHANGED
@@ -76,6 +76,31 @@ export async function deviceLogin() {
76
76
  }
77
77
  patchConfig({ token: tokens.token, refreshToken: tokens.refreshToken });
78
78
  }
79
+ /**
80
+ * Does this thrown error clearly mean "this refresh token can never mint a new
81
+ * session" — as opposed to a transient backend blip? RTSC-178.
82
+ *
83
+ * Note the asymmetry in how Convex Auth's refresh grant fails:
84
+ * - A genuinely expired/invalid/reused refresh token does NOT throw — the
85
+ * server returns `{ tokens: null }`, which `refreshSession` already reads as
86
+ * `false` (no exception reaches here).
87
+ * - The `catch` only sees *thrown* errors: network failures, 5xx, and the
88
+ * opaque masked "Server Error" prod returns for any uncaught server-side
89
+ * throw — all transient — plus the rare corrupt/unparseable stored token
90
+ * (`parseRefreshToken` throws "Can't parse refresh token …").
91
+ *
92
+ * So we default to "transient" and only classify as unrefreshable when the
93
+ * message is unmistakably refresh-token-shaped. In prod a corrupt-token throw is
94
+ * itself masked to "Server Error" and so reads as transient — the deliberate,
95
+ * safe bias (RTSC-165 saw exactly this masking): never drag a user with a valid
96
+ * session through the whole device flow on a one-off hiccup. A truly dead
97
+ * refresh token still reaches `deviceLogin` via the `{ tokens: null }` → `false`
98
+ * path above, unaffected by this classifier.
99
+ */
100
+ export function isUnrefreshableRefreshToken(err) {
101
+ const msg = String(err?.message ?? err);
102
+ return /can'?t parse refresh token|cannot parse refresh token|invalid refresh token|expired refresh token|refresh token (?:is |has )?(?:invalid|expired)/i.test(msg);
103
+ }
79
104
  /**
80
105
  * Silently mint a fresh access token from the stored refresh token and persist
81
106
  * the rotated pair to ~/.retasc/config.json.
@@ -88,10 +113,12 @@ export async function deviceLogin() {
88
113
  * tokens are single-use; the old one is invalidated), so we must write the new
89
114
  * pair back immediately and never reuse the old refresh token.
90
115
  *
91
- * Returns true on success (config now holds a fresh session), false if there is
92
- * no refresh token or the grant is rejected (expired/invalid/reused) — the
93
- * caller then falls back to a full `deviceLogin()`. Never throws and never
94
- * prints token values.
116
+ * Returns true on success (config now holds a fresh session), false when there
117
+ * is no refresh token or the grant is cleanly rejected (expired/invalid/reused,
118
+ * which the server signals as `{ tokens: null }`) the caller then falls back
119
+ * to a full `deviceLogin()`. A *transient* failure (network/5xx/opaque backend
120
+ * blip) is RE-THROWN so the caller can surface a retryable error instead of
121
+ * forcing a needless device-flow re-login (RTSC-178). Never prints token values.
95
122
  */
96
123
  export async function refreshSession() {
97
124
  const cfg = loadConfig();
@@ -106,9 +133,14 @@ export async function refreshSession() {
106
133
  patchConfig({ token: tokens.token, refreshToken: tokens.refreshToken });
107
134
  return true;
108
135
  }
109
- catch {
110
- // Expired/invalid/reused refresh token, or a transient backend error. Treat
111
- // as "cannot refresh"the caller re-authenticates via the device flow.
112
- return false;
136
+ catch (err) {
137
+ // A clearly refresh-token-shaped rejection means we genuinely cannot
138
+ // refresh — fall through to device login. Anything else (network, 5xx,
139
+ // masked prod "Server Error") is transient: rethrow so `withAuth` surfaces a
140
+ // retryable error rather than dragging a valid session through the device
141
+ // flow at an hourly access-token boundary.
142
+ if (isUnrefreshableRefreshToken(err))
143
+ return false;
144
+ throw err;
113
145
  }
114
146
  }
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
- import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
3
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, chmodSync, } from "node:fs";
4
+ import { randomUUID } from "node:crypto";
4
5
  // Production defaults. Overridable via env for dev/testing.
5
6
  // RETASC_DEPLOYMENT_URL — Convex deployment (.cloud) for management calls
6
7
  // RETASC_MCP_URL — the MCP endpoint agents connect to
@@ -8,20 +9,41 @@ export const DEFAULTS = {
8
9
  deploymentUrl: process.env.RETASC_DEPLOYMENT_URL ?? "https://unique-lyrebird-934.convex.cloud",
9
10
  mcpUrl: process.env.RETASC_MCP_URL ?? "https://mcp.retasc.com/mcp",
10
11
  };
11
- const DIR = join(homedir(), ".retasc");
12
- const FILE = join(DIR, "config.json");
12
+ /** The dir config lives in. RETASC_DIR overrides it (tests, sandboxes) — same
13
+ * knob the keystore reads, so the two stay co-located. */
14
+ function configDir() {
15
+ return process.env.RETASC_DIR || join(homedir(), ".retasc");
16
+ }
13
17
  export function configPath() {
14
- return FILE;
18
+ return join(configDir(), "config.json");
15
19
  }
16
20
  export function loadConfig() {
21
+ const FILE = configPath();
17
22
  let stored = {};
18
23
  if (existsSync(FILE)) {
24
+ let raw;
19
25
  try {
20
- stored = JSON.parse(readFileSync(FILE, "utf8"));
26
+ raw = readFileSync(FILE, "utf8");
21
27
  }
22
28
  catch {
23
- // Corrupt config start fresh rather than crash.
24
- stored = {};
29
+ // A read that FAILS (file lock, EIO, EMFILE, stale NFS handle) is transient,
30
+ // not corruption. Leave the file untouched and fall back to defaults for this
31
+ // run — self-heal next time — rather than renaming a possibly-valid config
32
+ // aside and turning a blip into a permanent logout.
33
+ raw = undefined;
34
+ }
35
+ if (raw !== undefined) {
36
+ try {
37
+ stored = JSON.parse(raw);
38
+ }
39
+ catch {
40
+ // Read succeeded but the bytes aren't valid JSON → genuinely corrupt/
41
+ // truncated (e.g. an interrupted write). Preserve them under a backup name
42
+ // rather than silently discarding — the file may still hold the only copy
43
+ // of the user's tokens, recoverable by hand. Then start fresh.
44
+ backupCorruptConfig(FILE);
45
+ stored = {};
46
+ }
25
47
  }
26
48
  }
27
49
  // Defaults fill in; a saved value always wins.
@@ -35,19 +57,49 @@ export function loadConfig() {
35
57
  defaultProjectPrefix: stored.defaultProjectPrefix,
36
58
  };
37
59
  }
38
- export function saveConfig(cfg) {
39
- // Create the dir user-only and the file 0600 on the initial write, so tokens
40
- // never exist world-readable even briefly (the chmod below only closes a
41
- // pre-existing 0644 file — it can't undo a world-readable creation window).
42
- mkdirSync(DIR, { recursive: true, mode: 0o700 });
43
- writeFileSync(FILE, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
44
- // Tokens live here — keep it user-only readable (covers overwriting an
45
- // existing 0644 file, where the write mode above doesn't apply).
60
+ /** Move a corrupt config aside to a unique sibling so a human can recover any
61
+ * tokens it still holds. Best effort never throw from the load path. */
62
+ function backupCorruptConfig(file) {
46
63
  try {
47
- chmodSync(FILE, 0o600);
64
+ renameSync(file, `${file}.corrupt-${randomUUID()}`);
48
65
  }
49
66
  catch {
50
- /* best effort (e.g. Windows) */
67
+ /* best effort if we can't back it up, saveConfig will overwrite it */
68
+ }
69
+ }
70
+ export function saveConfig(cfg) {
71
+ const dir = configDir();
72
+ const FILE = configPath();
73
+ // Create the dir user-only and the temp file 0600, so tokens never exist
74
+ // world-readable even briefly (the chmod below only closes a pre-existing
75
+ // 0644 file — it can't undo a world-readable creation window).
76
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
77
+ // Atomic write: fully write a sibling temp file, then rename it over the real
78
+ // one. rename(2) is atomic on POSIX, so a crash or an interleaved write leaves
79
+ // either the old complete config or the new one — never a truncated file that
80
+ // loadConfig would read as "logged out". The temp lives in the same dir so the
81
+ // rename stays on one filesystem (a cross-device rename is not atomic).
82
+ const tmp = join(dir, `.config.json.${randomUUID()}.tmp`);
83
+ const body = JSON.stringify(cfg, null, 2) + "\n";
84
+ try {
85
+ writeFileSync(tmp, body, { encoding: "utf8", mode: 0o600 });
86
+ try {
87
+ chmodSync(tmp, 0o600); // keep user-only if a umask/prior file loosened it
88
+ }
89
+ catch {
90
+ /* best effort (e.g. Windows) */
91
+ }
92
+ renameSync(tmp, FILE);
93
+ }
94
+ catch (err) {
95
+ // Don't leave a stray temp file behind on failure.
96
+ try {
97
+ unlinkSync(tmp);
98
+ }
99
+ catch {
100
+ /* already gone */
101
+ }
102
+ throw err;
51
103
  }
52
104
  }
53
105
  export function patchConfig(patch) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Retasc CLI — sign in with GitHub, create projects, mint agent API keys, and wire your agent to the Retasc MCP server in one command.",
5
5
  "type": "module",
6
6
  "bin": {