@retasc/cli 1.3.0 → 1.3.2
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 +10 -6
- package/dist/auth.js +40 -8
- package/dist/config.js +216 -19
- package/package.json +1 -1
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
|
-
*
|
|
51
|
-
* `
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
|
92
|
-
* no refresh token or the grant is rejected (expired/invalid/reused
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
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, openSync, closeSync, writeSync, statSync, readdirSync, } 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
|
-
|
|
12
|
-
|
|
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
|
|
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
|
-
|
|
26
|
+
raw = readFileSync(FILE, "utf8");
|
|
21
27
|
}
|
|
22
28
|
catch {
|
|
23
|
-
//
|
|
24
|
-
|
|
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,25 +57,200 @@ export function loadConfig() {
|
|
|
35
57
|
defaultProjectPrefix: stored.defaultProjectPrefix,
|
|
36
58
|
};
|
|
37
59
|
}
|
|
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) {
|
|
63
|
+
try {
|
|
64
|
+
renameSync(file, `${file}.corrupt-${randomUUID()}`);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
/* best effort — if we can't back it up, saveConfig will overwrite it */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
38
70
|
export function saveConfig(cfg) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
//
|
|
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;
|
|
103
|
+
}
|
|
104
|
+
// Best-effort GC of temp siblings orphaned by a hard kill (SIGKILL / power loss)
|
|
105
|
+
// between writeFileSync(tmp) and renameSync above — otherwise they accumulate in
|
|
106
|
+
// ~/.retasc forever across crashes. Runs after our own rename so our temp is
|
|
107
|
+
// already gone. Age-gated (see sweepStaleTemps) so it never touches a concurrent
|
|
108
|
+
// writer's in-flight temp.
|
|
109
|
+
sweepStaleTemps(dir);
|
|
110
|
+
}
|
|
111
|
+
/** A temp sibling this much older than now was orphaned by a crash, not left by a
|
|
112
|
+
* live writer — any real writeFileSync→renameSync window is milliseconds. */
|
|
113
|
+
const TEMP_STALE_MS = 60_000;
|
|
114
|
+
/** Delete `.config.json.<uuid>.tmp` leftovers only once they're demonstrably stale,
|
|
115
|
+
* so an unconditional sweep can't race-delete a concurrent writer's fresh temp
|
|
116
|
+
* (which would reintroduce the very lost-update class this file guards against). */
|
|
117
|
+
function sweepStaleTemps(dir) {
|
|
118
|
+
let entries;
|
|
119
|
+
try {
|
|
120
|
+
entries = readdirSync(dir);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return; // dir vanished / unreadable — nothing to sweep
|
|
124
|
+
}
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
for (const name of entries) {
|
|
127
|
+
if (!name.startsWith(".config.json.") || !name.endsWith(".tmp"))
|
|
128
|
+
continue;
|
|
129
|
+
const p = join(dir, name);
|
|
130
|
+
try {
|
|
131
|
+
if (now - statSync(p).mtimeMs > TEMP_STALE_MS)
|
|
132
|
+
unlinkSync(p);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* raced another sweeper or a writer's own rename — fine, leave it */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// --- Cross-process config lock -------------------------------------------------
|
|
140
|
+
// saveConfig is byte-atomic (temp + rename) but that is NOT isolation: two
|
|
141
|
+
// concurrent load→modify→save sequences both read the old file and the last
|
|
142
|
+
// rename wins, silently dropping the other's fields (RTSC-250). The dangerous
|
|
143
|
+
// case: a `defaultProjectPrefix` write that began before a token refresh writes
|
|
144
|
+
// back the OLD single-use refreshToken, so the next refresh fails and a
|
|
145
|
+
// non-interactive MCP context can't re-run the device flow. An advisory lock file
|
|
146
|
+
// serializes the whole read-modify-write across processes, so every patcher
|
|
147
|
+
// re-reads the freshest config (including a just-rotated token) before writing.
|
|
148
|
+
//
|
|
149
|
+
// Assumes a coherent LOCAL clock: the staleness heuristic compares a lock file's
|
|
150
|
+
// mtime (set by the host that created it) against this host's Date.now(). That
|
|
151
|
+
// holds for the intended case — several processes on ONE machine sharing one
|
|
152
|
+
// ~/.retasc. On a network home with a skewed server clock it degrades to
|
|
153
|
+
// best-effort (over-eager or over-lazy stealing); we don't target that here.
|
|
154
|
+
// Any whole-file mutating writer MUST go through patchConfig so it takes the
|
|
155
|
+
// lock — saveConfig alone is not self-locking (logout's delete is the one benign
|
|
156
|
+
// exception: racing a refresh there is a user-intent race, not a durability bug).
|
|
157
|
+
/** A lock whose mtime is older than this belongs to a holder that died without
|
|
158
|
+
* releasing it (a crash leaves the O_EXCL file behind); break it so a crash
|
|
159
|
+
* can't wedge every future patch forever. Far larger than any real hold, which
|
|
160
|
+
* is a synchronous read+write of a tiny file (single-digit ms). */
|
|
161
|
+
const LOCK_STALE_MS = 30_000;
|
|
162
|
+
/** Absolute backstop: if staleness somehow never frees the lock, force it after
|
|
163
|
+
* this. Kept ABOVE the stale threshold so normal breaking is governed by
|
|
164
|
+
* staleness, not by a timer that could guillotine a merely-slow live holder. */
|
|
165
|
+
const LOCK_TIMEOUT_MS = 60_000;
|
|
166
|
+
const LOCK_BACKOFF_MS = 25;
|
|
167
|
+
/** Block this (single) thread for `ms` without busy-spinning. Node is
|
|
168
|
+
* single-threaded; the lock we wait on is released by ANOTHER process, so
|
|
169
|
+
* parking the thread is correct — nothing here could release it. */
|
|
170
|
+
function sleepSync(ms) {
|
|
171
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
172
|
+
}
|
|
173
|
+
/** The current holder's identity + age, or undefined if the lock just vanished. */
|
|
174
|
+
function readLockHolder(lockPath) {
|
|
175
|
+
try {
|
|
176
|
+
const nonce = readFileSync(lockPath, "utf8");
|
|
177
|
+
return { nonce, ageMs: Date.now() - statSync(lockPath).mtimeMs };
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return undefined; // gone between our failed create and this read → just retry
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** Run `fn` holding an exclusive on-disk lock, so its read-modify-write of the
|
|
184
|
+
* config can't interleave with another process's. Each acquirer stamps a unique
|
|
185
|
+
* nonce into the lock file; steal and release only ever remove a lock whose nonce
|
|
186
|
+
* still matches the one we observed, so a holder that was judged stale and had its
|
|
187
|
+
* lock stolen can't later delete the DIFFERENT holder's lock (which would collapse
|
|
188
|
+
* mutual exclusion back into the lost-update this whole mechanism prevents). */
|
|
189
|
+
function withConfigLock(fn) {
|
|
190
|
+
const dir = configDir();
|
|
191
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
192
|
+
const lockPath = join(dir, ".config.json.lock");
|
|
193
|
+
const nonce = randomUUID();
|
|
194
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
195
|
+
let fd;
|
|
196
|
+
for (;;) {
|
|
197
|
+
try {
|
|
198
|
+
// "wx" = O_CREAT | O_EXCL | O_WRONLY — atomically fails if the lock is held.
|
|
199
|
+
fd = openSync(lockPath, "wx", 0o600);
|
|
200
|
+
writeSync(fd, nonce); // stamp our identity so steal/release can verify it
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
if (err.code !== "EEXIST")
|
|
205
|
+
throw err;
|
|
206
|
+
const holder = readLockHolder(lockPath);
|
|
207
|
+
if (!holder)
|
|
208
|
+
continue; // vanished → retry the create immediately
|
|
209
|
+
if (holder.ageMs > LOCK_STALE_MS || Date.now() > deadline) {
|
|
210
|
+
// Dead holder (stale) or backstop expired: break THIS holder's lock only.
|
|
211
|
+
// If a fresh acquirer replaced it since our read, the nonce differs and we
|
|
212
|
+
// leave it — never steal a live lock out from under a new owner.
|
|
213
|
+
breakLockIf(lockPath, holder.nonce);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
sleepSync(LOCK_BACKOFF_MS);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
return fn();
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
try {
|
|
224
|
+
closeSync(fd);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
/* already closed */
|
|
228
|
+
}
|
|
229
|
+
// Only remove the lock if it's still OURS. If we were stolen from while stalled,
|
|
230
|
+
// the file now holds another holder's nonce — leave it for them.
|
|
231
|
+
breakLockIf(lockPath, nonce);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** Unlink the lock only if it still carries `nonce` — a nonce-checked delete, so
|
|
235
|
+
* we never remove a lock a different process now owns. */
|
|
236
|
+
function breakLockIf(lockPath, nonce) {
|
|
46
237
|
try {
|
|
47
|
-
|
|
238
|
+
if (readFileSync(lockPath, "utf8") === nonce)
|
|
239
|
+
unlinkSync(lockPath);
|
|
48
240
|
}
|
|
49
241
|
catch {
|
|
50
|
-
/*
|
|
242
|
+
/* already gone, replaced, or unreadable — nothing safe to do */
|
|
51
243
|
}
|
|
52
244
|
}
|
|
53
245
|
export function patchConfig(patch) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
246
|
+
// Load AND save under the lock: re-reading inside the critical section is what
|
|
247
|
+
// makes this safe — a patch that only sets `defaultProjectPrefix` still picks up
|
|
248
|
+
// a refreshToken another process rotated a moment ago, instead of clobbering it.
|
|
249
|
+
return withConfigLock(() => {
|
|
250
|
+
const next = { ...loadConfig(), ...patch };
|
|
251
|
+
saveConfig(next);
|
|
252
|
+
return next;
|
|
253
|
+
});
|
|
57
254
|
}
|
|
58
255
|
export function isLoggedIn(cfg = loadConfig()) {
|
|
59
256
|
return Boolean(cfg.token);
|
package/package.json
CHANGED