@moewolf/moe-icons-cli 0.0.9 → 0.0.11
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 +6 -0
- package/dist/auth/device-login.js +48 -29
- package/dist/auth/refresh-lock.d.ts +2 -0
- package/dist/auth/refresh-lock.js +66 -0
- package/dist/auth/session-policy.cjs +3 -3
- package/dist/auth/token-store.d.ts +1 -0
- package/dist/auth/token-store.js +9 -3
- package/dist/core/bitmap-shard-budget.d.ts +2 -2
- package/dist/core/bitmap-shard-budget.js +1 -1
- package/dist/core/selected-resources.js +2 -2
- package/dist/project/tar-gz.d.ts +2 -2
- package/dist/project/tar-gz.js +3 -3
- package/package.json +2 -2
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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,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 {
|
|
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
|
-
|
|
10
|
-
|
|
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
|
package/dist/auth/token-store.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
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) {
|
|
@@ -54,6 +55,7 @@ export function createFileTokenStore(options = {}) {
|
|
|
54
55
|
chmodSync(file, 0o600);
|
|
55
56
|
}
|
|
56
57
|
return {
|
|
58
|
+
withRefreshLock: (operation, signal) => withRefreshLock(root, operation, signal),
|
|
57
59
|
get(accountId) {
|
|
58
60
|
return readAll()[accountId];
|
|
59
61
|
},
|
|
@@ -103,7 +105,7 @@ export function createSystemTokenStore(options = {}) {
|
|
|
103
105
|
const prefix = "[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime];$v=[Windows.Security.Credentials.PasswordVault]::new();";
|
|
104
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}`]);
|
|
105
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); };
|
|
106
|
-
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}`]); };
|
|
107
109
|
}
|
|
108
110
|
else {
|
|
109
111
|
return undefined;
|
|
@@ -125,6 +127,7 @@ export function createSystemTokenStore(options = {}) {
|
|
|
125
127
|
}
|
|
126
128
|
};
|
|
127
129
|
return {
|
|
130
|
+
withRefreshLock: (operation, signal) => withRefreshLock(stateRoot(process.env), operation, signal),
|
|
128
131
|
get: (accountId) => { const value = active(); return value?.accountId === accountId ? value : undefined; },
|
|
129
132
|
getActive: active,
|
|
130
133
|
set: (session) => { try {
|
|
@@ -137,7 +140,10 @@ export function createSystemTokenStore(options = {}) {
|
|
|
137
140
|
try {
|
|
138
141
|
remove();
|
|
139
142
|
}
|
|
140
|
-
catch {
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (!missingCredential(platform, error))
|
|
145
|
+
throw new CliError("AUTH_ERROR", "cannot clear system credentials; check credential store permissions");
|
|
146
|
+
}
|
|
141
147
|
} },
|
|
142
148
|
clear: () => { try {
|
|
143
149
|
remove();
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* the measured 89.5/90.0 MiB worst case with headroom; `AUD-FIX-30` records the
|
|
15
15
|
* production freeze. No consumer may invent its own limit — every shard
|
|
16
16
|
* download/expand path takes its numbers from here, and the legacy
|
|
17
|
-
* `ICON_ARCHIVE_MAX_EXPANDED_BYTES`
|
|
18
|
-
* shard budget.
|
|
17
|
+
* `ICON_ARCHIVE_MAX_EXPANDED_BYTES` 768 MiB aggregate cap is deliberately not a
|
|
18
|
+
* shard budget. Per-shard and temporary-disk bounds remain independently set.
|
|
19
19
|
*/
|
|
20
20
|
export interface BitmapShardBudget {
|
|
21
21
|
/** Largest signed compressed shard archive accepted for one tuple. */
|
|
@@ -3,7 +3,7 @@ export const BITMAP_SHARD_BUDGET = Object.freeze({
|
|
|
3
3
|
expandedBytes: 128 * 1024 * 1024,
|
|
4
4
|
entries: 2000,
|
|
5
5
|
singleFileBytes: 16 * 1024 * 1024,
|
|
6
|
-
totalDownloadBytes:
|
|
6
|
+
totalDownloadBytes: 768 * 1024 * 1024,
|
|
7
7
|
tempDiskBytes: 256 * 1024 * 1024,
|
|
8
8
|
concurrency: 1,
|
|
9
9
|
descriptorTimeoutMs: 5_000,
|
|
@@ -169,8 +169,8 @@ export function planSelectedResources(config, catalog, index) {
|
|
|
169
169
|
const paths = [...chosen].sort();
|
|
170
170
|
const payloadBytes = paths.reduce((sum, p) => sum + index.files[p].compressedSize, 0);
|
|
171
171
|
const expandedBytes = paths.reduce((sum, p) => sum + index.files[p].size, 0);
|
|
172
|
-
if (paths.length > 20000 || expandedBytes >
|
|
173
|
-
return fail("selected resources exceed 20,000 files/
|
|
172
|
+
if (paths.length > 20000 || expandedBytes > 768 * 1024 * 1024 || payloadBytes > 768 * 1024 * 1024)
|
|
173
|
+
return fail("selected resources exceed 20,000 files/768 MiB budget; reduce icons/themes or explicitly use downloadMode=full");
|
|
174
174
|
return { paths, payloadBytes, expandedBytes, fallbacks: [...fallbacks].sort() };
|
|
175
175
|
}
|
|
176
176
|
export function verifyResource(bytes, entry, name) {
|
package/dist/project/tar-gz.d.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Combined release archives contain all four target trees. The current Pro
|
|
3
3
|
* contract is roughly 60k regular files (9 groups × 554 icons), so the former
|
|
4
4
|
* 20k cap rejected valid official packages. The six-spec bitmap matrix expands
|
|
5
|
-
* to ~205 MiB (tar stream ~257 MiB)
|
|
6
|
-
*
|
|
5
|
+
* to ~205 MiB (tar stream ~257 MiB). The 768 MiB expanded ceiling supports the
|
|
6
|
+
* release capacity contract while staying finite for zip-bomb protection. `extractTarGz` enforces
|
|
7
7
|
* the same bound during gunzip via `maxOutputLength`, before the tar stream is
|
|
8
8
|
* materialised.
|
|
9
9
|
*/
|
package/dist/project/tar-gz.js
CHANGED
|
@@ -5,13 +5,13 @@ const BLOCK = 512;
|
|
|
5
5
|
* Combined release archives contain all four target trees. The current Pro
|
|
6
6
|
* contract is roughly 60k regular files (9 groups × 554 icons), so the former
|
|
7
7
|
* 20k cap rejected valid official packages. The six-spec bitmap matrix expands
|
|
8
|
-
* to ~205 MiB (tar stream ~257 MiB)
|
|
9
|
-
*
|
|
8
|
+
* to ~205 MiB (tar stream ~257 MiB). The 768 MiB expanded ceiling supports the
|
|
9
|
+
* release capacity contract while staying finite for zip-bomb protection. `extractTarGz` enforces
|
|
10
10
|
* the same bound during gunzip via `maxOutputLength`, before the tar stream is
|
|
11
11
|
* materialised.
|
|
12
12
|
*/
|
|
13
13
|
export const ICON_ARCHIVE_MAX_ENTRIES = 100_000;
|
|
14
|
-
export const ICON_ARCHIVE_MAX_EXPANDED_BYTES =
|
|
14
|
+
export const ICON_ARCHIVE_MAX_EXPANDED_BYTES = 768 * 1024 * 1024;
|
|
15
15
|
/** True when a zlib gunzip failure means the bounded output was exceeded. */
|
|
16
16
|
function isOutputLimitError(error) {
|
|
17
17
|
const code = error?.code;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moewolf/moe-icons-cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.11",
|
|
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": "
|
|
77
|
+
"payloadHash": "d904489ab55079dde70252d359b30361e2046bc5345df4c3896f92e64d32db16"
|
|
78
78
|
}
|