@moewolf/moe-icons-cli 0.0.8 → 0.0.10

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/README.md CHANGED
@@ -281,6 +281,12 @@ Log out before switching storage methods. Official production refresh uses the
281
281
  existing public Auth0 configuration; custom/local environments must provide
282
282
  matching issuer and client ID. Account and refresh requests have a deadline
283
283
  covering headers and body. Revoked refresh credentials require a new login.
284
+ Overlapping CLI processes coordinate refreshes and reuse the same renewed
285
+ session. A cancelled waiter does not cancel the refresh owned by another process.
286
+ After a process is forcibly stopped during refresh, a remaining
287
+ `session-refresh.lock` in the selected store directory (or the CLI state directory
288
+ for native credentials) reports an error. Confirm the PID in its `owner.json`
289
+ has stopped before removing that lock directory and retrying.
284
290
 
285
291
  The VS Code extension reads credentials on its own host. For Remote SSH, WSL
286
292
  or a container, run the CLI login on that host; local credentials are not copied.
@@ -72,34 +72,53 @@ export async function loginWithDeviceSession(config, deps, signal) {
72
72
  }
73
73
  }
74
74
  export async function refreshAuth0Session(config, session, deps) {
75
- const response = await boundedResponse(deps.fetch, `${config.auth0Issuer.replace(/\/$/, "")}/oauth/token`, {
76
- method: "POST",
77
- headers: { "content-type": "application/json" },
78
- body: JSON.stringify({
79
- grant_type: "refresh_token",
80
- client_id: config.auth0ClientId,
81
- refresh_token: session.refreshToken,
82
- }),
83
- ...(deps.signal ? { signal: deps.signal } : {}),
84
- });
85
- if (response.status >= 500 || response.status === 429)
86
- throw new CliError("NETWORK_ERROR", "Auth0 refresh temporarily unavailable; retry");
87
- const body = (await response.json());
88
- if (!response.ok || typeof body.access_token !== "string") {
89
- throw new CliError("AUTH_ERROR", "Auth0 refresh failed");
90
- }
91
- if (body.expires_in !== undefined && (typeof body.expires_in !== "number" || !Number.isSafeInteger(body.expires_in) || body.expires_in <= 0))
92
- throw new CliError("VALIDATION_ERROR", "invalid Auth0 token lifetime");
93
- const updated = {
94
- ...session,
95
- accessToken: body.access_token,
96
- refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : session.refreshToken,
97
- expiresAt: deps.now() + (typeof body.expires_in === "number" ? body.expires_in : 3600) * 1000,
98
- storedAt: deps.now(),
75
+ const perform = async () => {
76
+ const current = deps.tokenStore.getActive();
77
+ if (!current || current.accountId !== session.accountId)
78
+ throw new CliError("AUTH_ERROR", "session changed during refresh; retry with the current account");
79
+ if (current.accessToken !== session.accessToken || current.storedAt !== session.storedAt) {
80
+ if (current.expiresAt > deps.now())
81
+ return current;
82
+ throw new CliError("NETWORK_ERROR", "session changed during refresh; retry with the current account");
83
+ }
84
+ const response = await boundedResponse(deps.fetch, `${config.auth0Issuer.replace(/\/$/, "")}/oauth/token`, {
85
+ method: "POST",
86
+ headers: { "content-type": "application/json" },
87
+ body: JSON.stringify({
88
+ grant_type: "refresh_token",
89
+ client_id: config.auth0ClientId,
90
+ refresh_token: session.refreshToken,
91
+ }),
92
+ ...(deps.signal ? { signal: deps.signal } : {}),
93
+ });
94
+ if (response.status >= 500 || response.status === 429)
95
+ throw new CliError("NETWORK_ERROR", "Auth0 refresh temporarily unavailable; retry");
96
+ const body = (await response.json());
97
+ if (!response.ok || typeof body.access_token !== "string") {
98
+ throw new CliError("AUTH_ERROR", "Auth0 refresh failed");
99
+ }
100
+ if (body.expires_in !== undefined &&
101
+ (typeof body.expires_in !== "number" ||
102
+ !Number.isSafeInteger(body.expires_in) ||
103
+ body.expires_in <= 0))
104
+ throw new CliError("VALIDATION_ERROR", "invalid Auth0 token lifetime");
105
+ const updated = {
106
+ ...session,
107
+ accessToken: body.access_token,
108
+ refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : session.refreshToken,
109
+ expiresAt: deps.now() + (typeof body.expires_in === "number" ? body.expires_in : 3600) * 1000,
110
+ storedAt: deps.now(),
111
+ };
112
+ const latest = deps.tokenStore.getActive();
113
+ if (!latest ||
114
+ latest.accountId !== session.accountId ||
115
+ latest.accessToken !== session.accessToken ||
116
+ latest.storedAt !== session.storedAt)
117
+ throw new CliError("AUTH_ERROR", "session changed during refresh; retry with the current account");
118
+ deps.tokenStore.set(updated);
119
+ return updated;
99
120
  };
100
- const latest = deps.tokenStore.getActive();
101
- if (!latest || latest.accessToken !== session.accessToken || latest.storedAt !== session.storedAt)
102
- throw new CliError("AUTH_ERROR", "session changed during refresh; retry with the current account");
103
- deps.tokenStore.set(updated);
104
- return updated;
121
+ return deps.tokenStore.withRefreshLock
122
+ ? deps.tokenStore.withRefreshLock(perform, deps.signal)
123
+ : perform();
105
124
  }
@@ -0,0 +1,2 @@
1
+ /** Coordinates refresh rotation across CLI processes without storing credentials. */
2
+ export declare function withRefreshLock<T>(root: string, operation: () => Promise<T>, signal?: AbortSignal): Promise<T>;
@@ -0,0 +1,66 @@
1
+ import { mkdirSync, writeFileSync, readFileSync, lstatSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import { CliError } from "../errors/index.js";
5
+ /** Coordinates refresh rotation across CLI processes without storing credentials. */
6
+ export async function withRefreshLock(root, operation, signal) {
7
+ mkdirSync(root, { recursive: true, mode: 0o700 });
8
+ const path = join(root, "session-refresh.lock");
9
+ const deadline = Date.now() + 20_000;
10
+ for (;;) {
11
+ if (signal?.aborted)
12
+ throw new CliError("CANCELLED", "session refresh cancelled");
13
+ try {
14
+ mkdirSync(path, { mode: 0o700 });
15
+ try {
16
+ writeFileSync(join(path, "owner.json"), JSON.stringify({ pid: process.pid }), {
17
+ flag: "wx",
18
+ mode: 0o600,
19
+ });
20
+ }
21
+ catch (error) {
22
+ rmSync(path, { recursive: true, force: true });
23
+ throw error;
24
+ }
25
+ break;
26
+ }
27
+ catch (error) {
28
+ if (error.code !== "EEXIST")
29
+ throw new CliError("NETWORK_ERROR", "session refresh coordination unavailable; retry");
30
+ try {
31
+ const metadata = lstatSync(path);
32
+ if (!metadata.isDirectory() || metadata.isSymbolicLink())
33
+ throw new CliError("NETWORK_ERROR", "invalid session refresh lock; repair credential storage");
34
+ const owner = JSON.parse(readFileSync(join(path, "owner.json"), "utf8"));
35
+ if (Number.isInteger(owner.pid) && owner.pid > 0) {
36
+ try {
37
+ process.kill(owner.pid, 0);
38
+ }
39
+ catch (cause) {
40
+ if (cause.code === "ESRCH") {
41
+ throw new CliError("NETWORK_ERROR", "a stopped process left a session refresh lock; repair credential storage before retrying");
42
+ }
43
+ }
44
+ }
45
+ }
46
+ catch (cause) {
47
+ if (cause instanceof CliError)
48
+ throw cause; /* Owner may still be writing its marker. */
49
+ }
50
+ if (Date.now() >= deadline)
51
+ throw new CliError("NETWORK_ERROR", "session refresh is busy; retry or repair a stale credential lock");
52
+ try {
53
+ await delay(50, undefined, signal ? { signal } : undefined);
54
+ }
55
+ catch {
56
+ throw new CliError("CANCELLED", "session refresh cancelled");
57
+ }
58
+ }
59
+ }
60
+ try {
61
+ return await operation();
62
+ }
63
+ finally {
64
+ rmSync(path, { recursive: true, force: true });
65
+ }
66
+ }
@@ -1,13 +1,13 @@
1
1
  // Canonical CLI/extension session-store selection. Contains no credentials.
2
2
  const {homedir} = require('node:os');
3
3
  const {join, isAbsolute} = require('node:path');
4
- const {existsSync, lstatSync, readFileSync, mkdirSync, writeFileSync, renameSync} = require('node:fs');
4
+ const {lstatSync, readFileSync, mkdirSync, writeFileSync, renameSync} = require('node:fs');
5
5
  function stateRoot(env) { const root=env.MOEICONS_STATE_DIR || join(homedir(), '.moeicons'); if(!isAbsolute(root)) throw new Error('MOEICONS_STATE_DIR must be absolute'); return root; }
6
6
  function selectedStore(env) {
7
7
  if (env.MOEICONS_DISABLE_SYSTEM_KEYCHAIN === '1' && env.MOEICONS_TOKEN_STORE_DIR) { if(!isAbsolute(env.MOEICONS_TOKEN_STORE_DIR)) throw new Error('MOEICONS_TOKEN_STORE_DIR must be absolute'); return {mode:'file', rootDir:env.MOEICONS_TOKEN_STORE_DIR}; }
8
8
  const path = join(stateRoot(env), 'session-store.json');
9
- if (!existsSync(path)) return {mode:env.MOEICONS_DISABLE_SYSTEM_KEYCHAIN === '1' ? 'none' : 'system'};
10
- const meta=lstatSync(path);
9
+ let meta;
10
+ try { meta=lstatSync(path); } catch(error) { if(error.code==='ENOENT') return {mode:env.MOEICONS_DISABLE_SYSTEM_KEYCHAIN === '1' ? 'none' : 'system'}; throw error; }
11
11
  if (!meta.isFile() || meta.isSymbolicLink() || meta.size>4096 || (process.platform!=='win32' && ((meta.mode & 0o077)!==0 || meta.uid!==process.getuid()))) throw new Error('Session storage preference is not readable securely');
12
12
  const value=JSON.parse(readFileSync(path,'utf8'));
13
13
  if (value.schemaVersion!==1 || value.mode!=='file' || typeof value.rootDir!=='string' || !isAbsolute(value.rootDir)) throw new Error('Invalid session storage preference');
@@ -17,6 +17,7 @@ export interface TokenStore {
17
17
  set(session: StoredSession): void;
18
18
  delete(accountId: string): void;
19
19
  clear(): void;
20
+ withRefreshLock?<T>(operation: () => Promise<T>, signal?: AbortSignal): Promise<T>;
20
21
  }
21
22
  /**
22
23
  * File-backed fallback store (mode 0600). Used only when no OS keychain is
@@ -1,7 +1,8 @@
1
- import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, lstatSync } from "node:fs";
1
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync, lstatSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { missingCredential } from "./session-policy.cjs";
4
+ import { missingCredential, stateRoot } from "./session-policy.cjs";
5
+ import { withRefreshLock } from "./refresh-lock.js";
5
6
  import { CliError } from "../errors/index.js";
6
7
  import { execFileSync } from "node:child_process";
7
8
  function parseSession(value) {
@@ -24,10 +25,16 @@ export function createFileTokenStore(options = {}) {
24
25
  const root = options.rootDir ?? join(homedir(), ".moeicons");
25
26
  const file = join(root, "token-store.json");
26
27
  function readAll() {
27
- if (!existsSync(file))
28
- return {};
28
+ let meta;
29
+ try {
30
+ meta = lstatSync(file);
31
+ }
32
+ catch (error) {
33
+ if (error.code === "ENOENT")
34
+ return {};
35
+ throw new CliError("AUTH_ERROR", "file credential storage unavailable; check permissions or repair the store");
36
+ }
29
37
  try {
30
- const meta = lstatSync(file);
31
38
  if (!meta.isFile() || meta.isSymbolicLink() || meta.size > 1_000_000 || (process.platform !== "win32" && ((meta.mode & 0o077) !== 0 || typeof process.getuid === "function" && meta.uid !== process.getuid())))
32
39
  throw new Error("insecure store");
33
40
  const raw = JSON.parse(readFileSync(file, "utf8"));
@@ -48,6 +55,7 @@ export function createFileTokenStore(options = {}) {
48
55
  chmodSync(file, 0o600);
49
56
  }
50
57
  return {
58
+ withRefreshLock: (operation, signal) => withRefreshLock(root, operation, signal),
51
59
  get(accountId) {
52
60
  return readAll()[accountId];
53
61
  },
@@ -64,7 +72,7 @@ export function createFileTokenStore(options = {}) {
64
72
  delete all[accountId];
65
73
  writeAll(all);
66
74
  },
67
- clear() { writeAll({}); },
75
+ clear() { readAll(); writeAll({}); },
68
76
  };
69
77
  }
70
78
  /** Use the native credential store when the platform provides a supported CLI. */
@@ -97,7 +105,7 @@ export function createSystemTokenStore(options = {}) {
97
105
  const prefix = "[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime];$v=[Windows.Security.Credentials.PasswordVault]::new();";
98
106
  read = () => invoke("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `${prefix}try{$c=$v.Retrieve('${service}','${account}');$c.RetrievePassword();[Console]::Out.Write($c.Password)}catch{$e=$_.Exception;while($e){if($e.HResult -eq -2147023728){exit 44};$e=$e.InnerException};exit 45}`]);
99
107
  write = (value) => { invoke("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `${prefix}$p=[Console]::In.ReadToEnd();try{$v.Remove($v.Retrieve('${service}','${account}'))}catch{};$v.Add([Windows.Security.Credentials.PasswordCredential]::new('${service}','${account}',$p))`], value); };
100
- remove = () => { invoke("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `${prefix}try{$v.Remove($v.Retrieve('${service}','${account}'))}catch{}`]); };
108
+ remove = () => { invoke("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `${prefix}try{$v.Remove($v.Retrieve('${service}','${account}'))}catch{$e=$_.Exception;while($e){if($e.HResult -eq -2147023728){exit 44};$e=$e.InnerException};exit 45}`]); };
101
109
  }
102
110
  else {
103
111
  return undefined;
@@ -119,6 +127,7 @@ export function createSystemTokenStore(options = {}) {
119
127
  }
120
128
  };
121
129
  return {
130
+ withRefreshLock: (operation, signal) => withRefreshLock(stateRoot(process.env), operation, signal),
122
131
  get: (accountId) => { const value = active(); return value?.accountId === accountId ? value : undefined; },
123
132
  getActive: active,
124
133
  set: (session) => { try {
@@ -131,7 +140,10 @@ export function createSystemTokenStore(options = {}) {
131
140
  try {
132
141
  remove();
133
142
  }
134
- catch { /* already absent */ }
143
+ catch (error) {
144
+ if (!missingCredential(platform, error))
145
+ throw new CliError("AUTH_ERROR", "cannot clear system credentials; check credential store permissions");
146
+ }
135
147
  } },
136
148
  clear: () => { try {
137
149
  remove();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moewolf/moe-icons-cli",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "description": "Moeicons CLI: install React, Vue, Vanilla, or Assets icon targets, manage releases and login, and expose an MCP server.",
5
5
  "type": "module",
6
6
  "main": "./dist/cli.js",
@@ -74,5 +74,5 @@
74
74
  "fflate": "^0.8.2",
75
75
  "jsonc-parser": "^3.3.1"
76
76
  },
77
- "payloadHash": "f856bfbd0728c3b91ad0b2673dfb5ec7bb67a41023922ce28a901843dd69556b"
77
+ "payloadHash": "39f10c2d8048f41a7fb47fade28d280399eead23a68645c7c4b6bf71243547b0"
78
78
  }