@mkterswingman/5mghost-twinkler 0.1.4 → 0.1.5

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/auth.d.ts CHANGED
@@ -21,6 +21,8 @@ export interface TokenProviderOptions {
21
21
  env?: NodeJS.ProcessEnv;
22
22
  fetchImpl?: typeof fetch;
23
23
  authUrl?: string;
24
+ now?: () => number;
25
+ sleep?: (ms: number) => Promise<void>;
24
26
  }
25
27
  export declare function getAuthStatus(options: TokenProviderOptions): AuthStatus;
26
28
  export declare function getValidToken(options: TokenProviderOptions): Promise<string | null>;
package/dist/auth.js CHANGED
@@ -1,9 +1,12 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  export const MKTERSWINGMAN_PAT_ENV = "MKTERSWINGMAN_PAT";
4
4
  export const PAT_LOGIN_URL = "https://mkterswingman.com/pat/login";
5
5
  const DEFAULT_AUTH_URL = "https://mkterswingman.com";
6
6
  const REFRESH_SKEW_MS = 60_000;
7
+ const REFRESH_LOCK_STALE_MS = 30_000;
8
+ const REFRESH_LOCK_TIMEOUT_MS = 90_000;
9
+ const REFRESH_LOCK_POLL_MS = 50;
7
10
  export function getAuthStatus(options) {
8
11
  if (options.env?.[MKTERSWINGMAN_PAT_ENV]) {
9
12
  return {
@@ -54,26 +57,35 @@ export async function getValidToken(options) {
54
57
  return null;
55
58
  if (auth.type === "pat")
56
59
  return auth.pat || null;
57
- if (auth.access_token && auth.expires_at && Date.now() + REFRESH_SKEW_MS < auth.expires_at) {
60
+ if (hasUsableAccessToken(auth, options.now)) {
58
61
  return auth.access_token;
59
62
  }
60
- if (!auth.refresh_token)
61
- return null;
62
- const refreshed = await refreshJwt(auth.refresh_token, {
63
- clientId: auth.client_id,
64
- authUrl: options.authUrl,
65
- fetchImpl: options.fetchImpl
66
- });
67
- if (!refreshed)
68
- return null;
69
- writeSharedAuth(options.authJsonPath, {
70
- type: "jwt",
71
- access_token: refreshed.access_token,
72
- refresh_token: refreshed.refresh_token,
73
- expires_at: Date.now() + refreshed.expires_in * 1000,
74
- client_id: auth.client_id
63
+ return withRefreshLock(options, async () => {
64
+ const latest = readSharedAuth(options.authJsonPath);
65
+ if (!latest)
66
+ return null;
67
+ if (latest.type === "pat")
68
+ return latest.pat || null;
69
+ if (hasUsableAccessToken(latest, options.now))
70
+ return latest.access_token;
71
+ if (!latest.refresh_token)
72
+ return null;
73
+ const refreshed = await refreshJwt(latest.refresh_token, {
74
+ clientId: latest.client_id,
75
+ authUrl: options.authUrl,
76
+ fetchImpl: options.fetchImpl
77
+ });
78
+ if (!refreshed)
79
+ return null;
80
+ writeSharedAuth(options.authJsonPath, {
81
+ type: "jwt",
82
+ access_token: refreshed.access_token,
83
+ refresh_token: refreshed.refresh_token,
84
+ expires_at: (options.now ?? Date.now)() + refreshed.expires_in * 1000,
85
+ client_id: latest.client_id
86
+ });
87
+ return refreshed.access_token;
75
88
  });
76
- return refreshed.access_token;
77
89
  }
78
90
  export function savePat(authJsonPath, pat) {
79
91
  const normalized = pat.trim();
@@ -112,6 +124,95 @@ export function writeSharedAuth(authJsonPath, data) {
112
124
  // Windows may ignore POSIX modes.
113
125
  }
114
126
  }
127
+ function hasUsableAccessToken(auth, now) {
128
+ return Boolean(auth.access_token &&
129
+ auth.expires_at &&
130
+ (now ?? Date.now)() + REFRESH_SKEW_MS < auth.expires_at);
131
+ }
132
+ async function withRefreshLock(options, fn) {
133
+ const lockPath = `${options.authJsonPath}.refresh.lock`;
134
+ const now = options.now ?? Date.now;
135
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
136
+ const deadline = now() + REFRESH_LOCK_TIMEOUT_MS;
137
+ mkdirSync(dirname(options.authJsonPath), { recursive: true });
138
+ while (true) {
139
+ try {
140
+ mkdirSync(lockPath, { mode: 0o700 });
141
+ try {
142
+ writeLockOwner(lockPath, now());
143
+ }
144
+ catch (error) {
145
+ rmSync(lockPath, { recursive: true, force: true });
146
+ throw error;
147
+ }
148
+ try {
149
+ return await fn();
150
+ }
151
+ finally {
152
+ rmSync(lockPath, { recursive: true, force: true });
153
+ }
154
+ }
155
+ catch (error) {
156
+ if (!isAlreadyExistsError(error))
157
+ throw error;
158
+ if (canRecoverRefreshLock(lockPath, now())) {
159
+ rmSync(lockPath, { recursive: true, force: true });
160
+ continue;
161
+ }
162
+ if (now() >= deadline) {
163
+ throw new Error("Timed out waiting for mkterswingman auth refresh lock");
164
+ }
165
+ await sleep(REFRESH_LOCK_POLL_MS);
166
+ }
167
+ }
168
+ }
169
+ function isAlreadyExistsError(error) {
170
+ return (typeof error === "object" &&
171
+ error !== null &&
172
+ "code" in error &&
173
+ error.code === "EEXIST");
174
+ }
175
+ function writeLockOwner(lockPath, createdAtMs) {
176
+ writeFileSync(`${lockPath}/owner.json`, JSON.stringify({ pid: process.pid, created_at: createdAtMs }), { encoding: "utf8", mode: 0o600 });
177
+ }
178
+ function canRecoverRefreshLock(lockPath, nowMs) {
179
+ const owner = readRefreshLockOwner(lockPath);
180
+ if (owner) {
181
+ return nowMs - owner.created_at > REFRESH_LOCK_STALE_MS && !isProcessAlive(owner.pid);
182
+ }
183
+ try {
184
+ return nowMs - statSync(lockPath).mtimeMs > REFRESH_LOCK_STALE_MS;
185
+ }
186
+ catch {
187
+ return false;
188
+ }
189
+ }
190
+ function readRefreshLockOwner(lockPath) {
191
+ try {
192
+ const owner = JSON.parse(readFileSync(`${lockPath}/owner.json`, "utf8"));
193
+ if (typeof owner.pid === "number" && typeof owner.created_at === "number") {
194
+ return { pid: owner.pid, created_at: owner.created_at };
195
+ }
196
+ }
197
+ catch {
198
+ return null;
199
+ }
200
+ return null;
201
+ }
202
+ function isProcessAlive(pid) {
203
+ if (!Number.isInteger(pid) || pid <= 0)
204
+ return false;
205
+ try {
206
+ process.kill(pid, 0);
207
+ return true;
208
+ }
209
+ catch (error) {
210
+ return (typeof error === "object" &&
211
+ error !== null &&
212
+ "code" in error &&
213
+ error.code === "EPERM");
214
+ }
215
+ }
115
216
  async function refreshJwt(refreshToken, options) {
116
217
  const fetchImpl = options.fetchImpl ?? fetch;
117
218
  const response = await fetchImpl(`${options.authUrl ?? DEFAULT_AUTH_URL}/oauth/token`, {
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@ import { readFileSync, realpathSync } from "node:fs";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { getAuthStatus, logout, PAT_LOGIN_URL, savePat } from "./auth.js";
6
+ import { getAuthStatus, getValidToken, logout, PAT_LOGIN_URL, savePat } from "./auth.js";
7
7
  import { resolveTwinklerPaths } from "./paths.js";
8
8
  import { installBundledSkills } from "./skillInstall.js";
9
9
  import { callTwinkler } from "./twinklerClient.js";
@@ -51,20 +51,28 @@ export async function runCli(context = {}) {
51
51
  out(readPackageVersion());
52
52
  return 0;
53
53
  case "auth":
54
- return await runAuthCommand(args, { paths, env, out, stdin: context.stdin ?? process.stdin });
54
+ return await runAuthCommand(args, {
55
+ paths,
56
+ env,
57
+ out,
58
+ stdin: context.stdin ?? process.stdin,
59
+ fetchImpl: context.fetchImpl
60
+ });
55
61
  case "login":
56
62
  return await runAuthCommand(["login", "--pat-stdin", ...args.filter((arg) => arg !== "--pat-stdin")], {
57
63
  paths,
58
64
  env,
59
65
  out,
60
- stdin: context.stdin ?? process.stdin
66
+ stdin: context.stdin ?? process.stdin,
67
+ fetchImpl: context.fetchImpl
61
68
  });
62
69
  case "ensure":
63
- return runEnsureCommand(args, {
70
+ return await runEnsureCommand(args, {
64
71
  paths,
65
72
  env,
66
73
  out,
67
74
  err,
75
+ fetchImpl: context.fetchImpl,
68
76
  spawnSyncImpl: context.spawnSyncImpl ?? spawnSync
69
77
  });
70
78
  case "call": {
@@ -92,7 +100,7 @@ export async function runCli(context = {}) {
92
100
  }
93
101
  case "setup": {
94
102
  const results = installBundledSkills({ homeDir: paths.homeDir });
95
- const auth = getAuthStatus({ authJsonPath: paths.authJsonPath, env });
103
+ const auth = await getVerifiedAuthStatus(paths, env, context.fetchImpl);
96
104
  out([
97
105
  "Twinkler setup",
98
106
  renderSkillInstallResults(results),
@@ -106,7 +114,7 @@ export async function runCli(context = {}) {
106
114
  case "upgrade":
107
115
  return runUpdateCommand(args, { env, out, err, spawnSyncImpl: context.spawnSyncImpl ?? spawnSync });
108
116
  case "doctor": {
109
- const auth = getAuthStatus({ authJsonPath: paths.authJsonPath, env });
117
+ const auth = await getVerifiedAuthStatus(paths, env, context.fetchImpl);
110
118
  out([
111
119
  "Twinkler doctor",
112
120
  `Node: ${process.version}`,
@@ -145,7 +153,7 @@ async function runAuthCommand(args, context) {
145
153
  return 0;
146
154
  }
147
155
  if (subcommand === "status") {
148
- const status = getAuthStatus({ authJsonPath: context.paths.authJsonPath, env: context.env });
156
+ const status = await getVerifiedAuthStatus(context.paths, context.env, context.fetchImpl);
149
157
  if (!status.authenticated) {
150
158
  context.out([
151
159
  "Not logged in.",
@@ -184,12 +192,12 @@ async function runAuthCommand(args, context) {
184
192
  context.out(`Unknown auth subcommand: ${subcommand}`);
185
193
  return 1;
186
194
  }
187
- function runEnsureCommand(args, context) {
195
+ async function runEnsureCommand(args, context) {
188
196
  const json = args.includes("--json");
189
197
  const autoUpdate = args.includes("--auto-update");
190
198
  const localVersion = readPackageVersion();
191
199
  const latest = readNpmLatestVersion(context.spawnSyncImpl, context.env);
192
- const auth = getAuthStatus({ authJsonPath: context.paths.authJsonPath, env: context.env });
200
+ const auth = await getVerifiedAuthStatus(context.paths, context.env, context.fetchImpl);
193
201
  const updateNeeded = latest.version !== null && compareSemver(latest.version, localVersion) > 0;
194
202
  let update = {
195
203
  needed: updateNeeded,
@@ -242,6 +250,13 @@ function runEnsureCommand(args, context) {
242
250
  context.err(update.error);
243
251
  return exitCode;
244
252
  }
253
+ async function getVerifiedAuthStatus(paths, env, fetchImpl) {
254
+ const auth = getAuthStatus({ authJsonPath: paths.authJsonPath, env });
255
+ if (!auth.authenticated)
256
+ return auth;
257
+ const token = await getValidToken({ authJsonPath: paths.authJsonPath, env, fetchImpl });
258
+ return { ...auth, authenticated: Boolean(token) };
259
+ }
245
260
  function renderEnsureResult(result) {
246
261
  const latest = result.latest_version ?? `unknown (${result.latest_check.ok ? "not checked" : result.latest_check.error})`;
247
262
  const lines = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mkterswingman/5mghost-twinkler",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Lightweight AI helper for the 5mghost Twinkler API",
5
5
  "type": "module",
6
6
  "engines": {