@gmickel/gno 0.6.0 → 0.6.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/README.md +9 -1
- package/assets/screenshots/claudecodeskill.jpg +0 -0
- package/assets/screenshots/cli.jpg +0 -0
- package/assets/screenshots/mcp.jpg +0 -0
- package/assets/screenshots/webui-ask-answer.jpg +0 -0
- package/assets/screenshots/webui-home.jpg +0 -0
- package/package.json +1 -1
- package/src/cli/commands/ask.ts +41 -3
- package/src/cli/commands/embed.ts +29 -2
- package/src/cli/commands/models/index.ts +1 -1
- package/src/cli/commands/models/pull.ts +0 -17
- package/src/cli/commands/query.ts +41 -3
- package/src/cli/context.ts +10 -0
- package/src/cli/program.ts +2 -1
- package/src/cli/progress.ts +88 -0
- package/src/cli/run.ts +1 -0
- package/src/llm/cache.ts +187 -37
- package/src/llm/errors.ts +27 -4
- package/src/llm/lockfile.ts +216 -0
- package/src/llm/nodeLlamaCpp/adapter.ts +54 -12
- package/src/llm/policy.ts +84 -0
- package/src/mcp/tools/query.ts +20 -3
- package/src/mcp/tools/vsearch.ts +12 -1
- package/src/serve/context.ts +36 -3
package/README.md
CHANGED
|
@@ -36,6 +36,8 @@ gno query "auth best practices" # Hybrid search
|
|
|
36
36
|
gno ask "summarize the API" --answer # AI answer with citations
|
|
37
37
|
```
|
|
38
38
|
|
|
39
|
+

|
|
40
|
+
|
|
39
41
|
---
|
|
40
42
|
|
|
41
43
|
## Installation
|
|
@@ -127,6 +129,8 @@ gno serve # Start on port 3000
|
|
|
127
129
|
gno serve --port 8080 # Custom port
|
|
128
130
|
```
|
|
129
131
|
|
|
132
|
+

|
|
133
|
+
|
|
130
134
|
Open `http://localhost:3000` to:
|
|
131
135
|
|
|
132
136
|
- **Search** — BM25, vector, or hybrid modes with visual results
|
|
@@ -181,6 +185,8 @@ No authentication. No rate limits. Build custom tools, automate workflows, integ
|
|
|
181
185
|
|
|
182
186
|
### MCP Server
|
|
183
187
|
|
|
188
|
+

|
|
189
|
+
|
|
184
190
|
GNO exposes 6 tools via [Model Context Protocol](https://modelcontextprotocol.io):
|
|
185
191
|
|
|
186
192
|
| Tool | Description |
|
|
@@ -202,6 +208,8 @@ Skills add GNO search to Claude Code/Codex without MCP protocol overhead:
|
|
|
202
208
|
gno skill install --scope user
|
|
203
209
|
```
|
|
204
210
|
|
|
211
|
+

|
|
212
|
+
|
|
205
213
|
Then ask your agent: *"Search my notes for the auth discussion"*
|
|
206
214
|
|
|
207
215
|
> **Detailed docs**: [MCP Integration](https://gno.sh/docs/MCP/) · [Use Cases](https://gno.sh/docs/USE-CASES/)
|
|
@@ -280,7 +288,7 @@ Models auto-download on first use to `~/.cache/gno/models/`.
|
|
|
280
288
|
|
|
281
289
|
```bash
|
|
282
290
|
gno models use balanced
|
|
283
|
-
gno models pull --all
|
|
291
|
+
gno models pull --all # Optional: pre-download models (auto-downloads on first use)
|
|
284
292
|
```
|
|
285
293
|
|
|
286
294
|
> **Configuration**: [Model Setup](https://gno.sh/docs/CONFIGURATION/)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
package/src/cli/commands/ask.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { LlmAdapter } from '../../llm/nodeLlamaCpp/adapter';
|
|
9
|
+
import { resolveDownloadPolicy } from '../../llm/policy';
|
|
9
10
|
import { getActivePreset } from '../../llm/registry';
|
|
10
11
|
import type {
|
|
11
12
|
EmbeddingPort,
|
|
@@ -22,6 +23,11 @@ import {
|
|
|
22
23
|
createVectorIndexPort,
|
|
23
24
|
type VectorIndexPort,
|
|
24
25
|
} from '../../store/vector';
|
|
26
|
+
import { getGlobals } from '../program';
|
|
27
|
+
import {
|
|
28
|
+
createProgressRenderer,
|
|
29
|
+
createThrottledProgressRenderer,
|
|
30
|
+
} from '../progress';
|
|
25
31
|
import { initStore } from './shared';
|
|
26
32
|
|
|
27
33
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -82,9 +88,26 @@ export async function ask(
|
|
|
82
88
|
const preset = getActivePreset(config);
|
|
83
89
|
const llm = new LlmAdapter(config);
|
|
84
90
|
|
|
91
|
+
// Resolve download policy from env/flags
|
|
92
|
+
const globals = getGlobals();
|
|
93
|
+
const policy = resolveDownloadPolicy(process.env, {
|
|
94
|
+
offline: globals.offline,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Create progress renderer for model downloads (throttled)
|
|
98
|
+
const showProgress = !options.json && process.stderr.isTTY;
|
|
99
|
+
const downloadProgress = showProgress
|
|
100
|
+
? createThrottledProgressRenderer(createProgressRenderer())
|
|
101
|
+
: undefined;
|
|
102
|
+
|
|
85
103
|
// Create embedding port
|
|
86
104
|
const embedUri = options.embedModel ?? preset.embed;
|
|
87
|
-
const embedResult = await llm.createEmbeddingPort(embedUri
|
|
105
|
+
const embedResult = await llm.createEmbeddingPort(embedUri, {
|
|
106
|
+
policy,
|
|
107
|
+
onProgress: downloadProgress
|
|
108
|
+
? (progress) => downloadProgress('embed', progress)
|
|
109
|
+
: undefined,
|
|
110
|
+
});
|
|
88
111
|
if (embedResult.ok) {
|
|
89
112
|
embedPort = embedResult.value;
|
|
90
113
|
}
|
|
@@ -94,7 +117,12 @@ export async function ask(
|
|
|
94
117
|
const needsGen = !options.noExpand || options.answer;
|
|
95
118
|
if (needsGen) {
|
|
96
119
|
const genUri = options.genModel ?? preset.gen;
|
|
97
|
-
const genResult = await llm.createGenerationPort(genUri
|
|
120
|
+
const genResult = await llm.createGenerationPort(genUri, {
|
|
121
|
+
policy,
|
|
122
|
+
onProgress: downloadProgress
|
|
123
|
+
? (progress) => downloadProgress('gen', progress)
|
|
124
|
+
: undefined,
|
|
125
|
+
});
|
|
98
126
|
if (genResult.ok) {
|
|
99
127
|
genPort = genResult.value;
|
|
100
128
|
}
|
|
@@ -103,12 +131,22 @@ export async function ask(
|
|
|
103
131
|
// Create rerank port (unless --fast or --no-rerank)
|
|
104
132
|
if (!options.noRerank) {
|
|
105
133
|
const rerankUri = options.rerankModel ?? preset.rerank;
|
|
106
|
-
const rerankResult = await llm.createRerankPort(rerankUri
|
|
134
|
+
const rerankResult = await llm.createRerankPort(rerankUri, {
|
|
135
|
+
policy,
|
|
136
|
+
onProgress: downloadProgress
|
|
137
|
+
? (progress) => downloadProgress('rerank', progress)
|
|
138
|
+
: undefined,
|
|
139
|
+
});
|
|
107
140
|
if (rerankResult.ok) {
|
|
108
141
|
rerankPort = rerankResult.value;
|
|
109
142
|
}
|
|
110
143
|
}
|
|
111
144
|
|
|
145
|
+
// Clear progress line if shown
|
|
146
|
+
if (showProgress && downloadProgress) {
|
|
147
|
+
process.stderr.write('\n');
|
|
148
|
+
}
|
|
149
|
+
|
|
112
150
|
// Create vector index
|
|
113
151
|
let vectorIndex: VectorIndexPort | null = null;
|
|
114
152
|
if (embedPort) {
|
|
@@ -9,6 +9,7 @@ import type { Database } from 'bun:sqlite';
|
|
|
9
9
|
import { getIndexDbPath } from '../../app/constants';
|
|
10
10
|
import { getConfigPaths, isInitialized, loadConfig } from '../../config';
|
|
11
11
|
import { LlmAdapter } from '../../llm/nodeLlamaCpp/adapter';
|
|
12
|
+
import { resolveDownloadPolicy } from '../../llm/policy';
|
|
12
13
|
import { getActivePreset } from '../../llm/registry';
|
|
13
14
|
import type { EmbeddingPort } from '../../llm/types';
|
|
14
15
|
import { formatDocForEmbedding } from '../../pipeline/contextual';
|
|
@@ -23,6 +24,11 @@ import {
|
|
|
23
24
|
type VectorRow,
|
|
24
25
|
type VectorStatsPort,
|
|
25
26
|
} from '../../store/vector';
|
|
27
|
+
import { getGlobals } from '../program';
|
|
28
|
+
import {
|
|
29
|
+
createProgressRenderer,
|
|
30
|
+
createThrottledProgressRenderer,
|
|
31
|
+
} from '../progress';
|
|
26
32
|
|
|
27
33
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
28
34
|
// Types
|
|
@@ -274,14 +280,35 @@ export async function embed(options: EmbedOptions = {}): Promise<EmbedResult> {
|
|
|
274
280
|
};
|
|
275
281
|
}
|
|
276
282
|
|
|
277
|
-
// Create LLM adapter and embedding port
|
|
283
|
+
// Create LLM adapter and embedding port with auto-download
|
|
284
|
+
const globals = getGlobals();
|
|
285
|
+
const policy = resolveDownloadPolicy(process.env, {
|
|
286
|
+
offline: globals.offline,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Create progress renderer for model download (throttled to avoid spam)
|
|
290
|
+
const showDownloadProgress = !options.json && process.stderr.isTTY;
|
|
291
|
+
const downloadProgress = showDownloadProgress
|
|
292
|
+
? createThrottledProgressRenderer(createProgressRenderer())
|
|
293
|
+
: undefined;
|
|
294
|
+
|
|
278
295
|
const llm = new LlmAdapter(config);
|
|
279
|
-
const embedResult = await llm.createEmbeddingPort(modelUri
|
|
296
|
+
const embedResult = await llm.createEmbeddingPort(modelUri, {
|
|
297
|
+
policy,
|
|
298
|
+
onProgress: downloadProgress
|
|
299
|
+
? (progress) => downloadProgress('embed', progress)
|
|
300
|
+
: undefined,
|
|
301
|
+
});
|
|
280
302
|
if (!embedResult.ok) {
|
|
281
303
|
return { success: false, error: embedResult.error.message };
|
|
282
304
|
}
|
|
283
305
|
embedPort = embedResult.value;
|
|
284
306
|
|
|
307
|
+
// Clear download progress line if shown
|
|
308
|
+
if (showDownloadProgress) {
|
|
309
|
+
process.stderr.write('\n');
|
|
310
|
+
}
|
|
311
|
+
|
|
285
312
|
// Discover dimensions via probe embedding
|
|
286
313
|
const probeResult = await embedPort.embed('dimension probe');
|
|
287
314
|
if (!probeResult.ok) {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* @module src/cli/commands/models
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
export { createProgressRenderer } from '../../progress';
|
|
7
8
|
export {
|
|
8
9
|
formatModelsClear,
|
|
9
10
|
type ModelsClearOptions,
|
|
@@ -23,7 +24,6 @@ export {
|
|
|
23
24
|
modelsPath,
|
|
24
25
|
} from './path';
|
|
25
26
|
export {
|
|
26
|
-
createProgressRenderer,
|
|
27
27
|
formatModelsPull,
|
|
28
28
|
type ModelPullResult,
|
|
29
29
|
type ModelsPullOptions,
|
|
@@ -185,20 +185,3 @@ export function formatModelsPull(result: ModelsPullResult): string {
|
|
|
185
185
|
|
|
186
186
|
return lines.join('\n');
|
|
187
187
|
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Create a terminal progress renderer.
|
|
191
|
-
*/
|
|
192
|
-
export function createProgressRenderer(): (
|
|
193
|
-
type: ModelType,
|
|
194
|
-
progress: DownloadProgress
|
|
195
|
-
) => void {
|
|
196
|
-
return (type, progress) => {
|
|
197
|
-
const percent = progress.percent.toFixed(1);
|
|
198
|
-
const downloaded = (progress.downloadedBytes / 1024 / 1024).toFixed(1);
|
|
199
|
-
const total = (progress.totalBytes / 1024 / 1024).toFixed(1);
|
|
200
|
-
process.stderr.write(
|
|
201
|
-
`\r${type}: ${percent}% (${downloaded}/${total} MB) `
|
|
202
|
-
);
|
|
203
|
-
};
|
|
204
|
-
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { LlmAdapter } from '../../llm/nodeLlamaCpp/adapter';
|
|
9
|
+
import { resolveDownloadPolicy } from '../../llm/policy';
|
|
9
10
|
import { getActivePreset } from '../../llm/registry';
|
|
10
11
|
import type {
|
|
11
12
|
EmbeddingPort,
|
|
@@ -18,6 +19,11 @@ import {
|
|
|
18
19
|
createVectorIndexPort,
|
|
19
20
|
type VectorIndexPort,
|
|
20
21
|
} from '../../store/vector';
|
|
22
|
+
import { getGlobals } from '../program';
|
|
23
|
+
import {
|
|
24
|
+
createProgressRenderer,
|
|
25
|
+
createThrottledProgressRenderer,
|
|
26
|
+
} from '../progress';
|
|
21
27
|
import { initStore } from './shared';
|
|
22
28
|
|
|
23
29
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -90,9 +96,26 @@ export async function query(
|
|
|
90
96
|
const preset = getActivePreset(config);
|
|
91
97
|
const llm = new LlmAdapter(config);
|
|
92
98
|
|
|
99
|
+
// Resolve download policy from env/flags
|
|
100
|
+
const globals = getGlobals();
|
|
101
|
+
const policy = resolveDownloadPolicy(process.env, {
|
|
102
|
+
offline: globals.offline,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Create progress renderer for model downloads (throttled)
|
|
106
|
+
const showProgress = !options.json && process.stderr.isTTY;
|
|
107
|
+
const downloadProgress = showProgress
|
|
108
|
+
? createThrottledProgressRenderer(createProgressRenderer())
|
|
109
|
+
: undefined;
|
|
110
|
+
|
|
93
111
|
// Create embedding port (for vector search)
|
|
94
112
|
const embedUri = options.embedModel ?? preset.embed;
|
|
95
|
-
const embedResult = await llm.createEmbeddingPort(embedUri
|
|
113
|
+
const embedResult = await llm.createEmbeddingPort(embedUri, {
|
|
114
|
+
policy,
|
|
115
|
+
onProgress: downloadProgress
|
|
116
|
+
? (progress) => downloadProgress('embed', progress)
|
|
117
|
+
: undefined,
|
|
118
|
+
});
|
|
96
119
|
if (embedResult.ok) {
|
|
97
120
|
embedPort = embedResult.value;
|
|
98
121
|
}
|
|
@@ -100,7 +123,12 @@ export async function query(
|
|
|
100
123
|
// Create generation port (for expansion) - optional
|
|
101
124
|
if (!options.noExpand) {
|
|
102
125
|
const genUri = options.genModel ?? preset.gen;
|
|
103
|
-
const genResult = await llm.createGenerationPort(genUri
|
|
126
|
+
const genResult = await llm.createGenerationPort(genUri, {
|
|
127
|
+
policy,
|
|
128
|
+
onProgress: downloadProgress
|
|
129
|
+
? (progress) => downloadProgress('gen', progress)
|
|
130
|
+
: undefined,
|
|
131
|
+
});
|
|
104
132
|
if (genResult.ok) {
|
|
105
133
|
genPort = genResult.value;
|
|
106
134
|
}
|
|
@@ -109,12 +137,22 @@ export async function query(
|
|
|
109
137
|
// Create rerank port - optional
|
|
110
138
|
if (!options.noRerank) {
|
|
111
139
|
const rerankUri = options.rerankModel ?? preset.rerank;
|
|
112
|
-
const rerankResult = await llm.createRerankPort(rerankUri
|
|
140
|
+
const rerankResult = await llm.createRerankPort(rerankUri, {
|
|
141
|
+
policy,
|
|
142
|
+
onProgress: downloadProgress
|
|
143
|
+
? (progress) => downloadProgress('rerank', progress)
|
|
144
|
+
: undefined,
|
|
145
|
+
});
|
|
113
146
|
if (rerankResult.ok) {
|
|
114
147
|
rerankPort = rerankResult.value;
|
|
115
148
|
}
|
|
116
149
|
}
|
|
117
150
|
|
|
151
|
+
// Clear progress line if shown
|
|
152
|
+
if (showProgress && downloadProgress) {
|
|
153
|
+
process.stderr.write('\n');
|
|
154
|
+
}
|
|
155
|
+
|
|
118
156
|
// Create vector index (optional)
|
|
119
157
|
let vectorIndex: VectorIndexPort | null = null;
|
|
120
158
|
if (embedPort) {
|
package/src/cli/context.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* @module src/cli/context
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { envIsSet } from '../llm/policy';
|
|
8
9
|
import { setColorsEnabled } from './colors';
|
|
9
10
|
|
|
10
11
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -19,6 +20,7 @@ export interface GlobalOptions {
|
|
|
19
20
|
yes: boolean;
|
|
20
21
|
quiet: boolean;
|
|
21
22
|
json: boolean;
|
|
23
|
+
offline: boolean;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -40,6 +42,13 @@ export function parseGlobalOptions(
|
|
|
40
42
|
|
|
41
43
|
const colorEnabled = !(noColorEnv || noColorFlag);
|
|
42
44
|
|
|
45
|
+
// Offline mode: --offline flag or HF_HUB_OFFLINE/GNO_OFFLINE env var
|
|
46
|
+
// Use envIsSet for consistent truthiness (treats "1", "true", "yes" as true)
|
|
47
|
+
const offlineEnv =
|
|
48
|
+
envIsSet(env, 'HF_HUB_OFFLINE') || envIsSet(env, 'GNO_OFFLINE');
|
|
49
|
+
const offlineFlag = Boolean(raw.offline);
|
|
50
|
+
const offlineEnabled = offlineEnv || offlineFlag;
|
|
51
|
+
|
|
43
52
|
return {
|
|
44
53
|
index: (raw.index as string) ?? 'default',
|
|
45
54
|
config: raw.config as string | undefined,
|
|
@@ -48,6 +57,7 @@ export function parseGlobalOptions(
|
|
|
48
57
|
yes: Boolean(raw.yes),
|
|
49
58
|
quiet: Boolean(raw.quiet),
|
|
50
59
|
json: Boolean(raw.json),
|
|
60
|
+
offline: offlineEnabled,
|
|
51
61
|
};
|
|
52
62
|
}
|
|
53
63
|
|
package/src/cli/program.ts
CHANGED
|
@@ -132,7 +132,8 @@ export function createProgram(): Command {
|
|
|
132
132
|
.option('--verbose', 'verbose logging')
|
|
133
133
|
.option('--yes', 'non-interactive mode')
|
|
134
134
|
.option('-q, --quiet', 'suppress non-essential output')
|
|
135
|
-
.option('--json', 'JSON output (for errors and supported commands)')
|
|
135
|
+
.option('--json', 'JSON output (for errors and supported commands)')
|
|
136
|
+
.option('--offline', 'offline mode (use cached models only)');
|
|
136
137
|
|
|
137
138
|
// Resolve globals ONCE before any command runs (ensures consistency)
|
|
138
139
|
program.hook('preAction', (thisCommand) => {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress rendering utilities for CLI.
|
|
3
|
+
* Kept in CLI layer to avoid layer violations.
|
|
4
|
+
*
|
|
5
|
+
* @module src/cli/progress
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { DownloadProgress, ModelType } from '../llm/types';
|
|
9
|
+
|
|
10
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
11
|
+
// Types
|
|
12
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export type ProgressCallback<T = ModelType> = (
|
|
15
|
+
type: T,
|
|
16
|
+
progress: DownloadProgress
|
|
17
|
+
) => void;
|
|
18
|
+
|
|
19
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
// Progress Renderers
|
|
21
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create a terminal progress renderer for model downloads.
|
|
25
|
+
* Writes progress to stderr with carriage return for in-place updates.
|
|
26
|
+
*/
|
|
27
|
+
export function createProgressRenderer(): ProgressCallback {
|
|
28
|
+
return (type, progress) => {
|
|
29
|
+
const percent = progress.percent.toFixed(1);
|
|
30
|
+
const downloaded = (progress.downloadedBytes / 1024 / 1024).toFixed(1);
|
|
31
|
+
const total = (progress.totalBytes / 1024 / 1024).toFixed(1);
|
|
32
|
+
process.stderr.write(
|
|
33
|
+
`\r${type}: ${percent}% (${downloaded}/${total} MB) `
|
|
34
|
+
);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create a throttled progress renderer.
|
|
40
|
+
* Emits at most once per interval, plus always on completion.
|
|
41
|
+
*
|
|
42
|
+
* @param renderer - Underlying renderer to throttle
|
|
43
|
+
* @param intervalMs - Minimum interval between emissions (default: 100ms)
|
|
44
|
+
*/
|
|
45
|
+
export function createThrottledProgressRenderer(
|
|
46
|
+
renderer: ProgressCallback,
|
|
47
|
+
intervalMs = 100
|
|
48
|
+
): ProgressCallback {
|
|
49
|
+
let lastEmit = 0;
|
|
50
|
+
|
|
51
|
+
return (type, progress) => {
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
|
|
54
|
+
// Always emit on completion (100%) or error
|
|
55
|
+
const isComplete = progress.percent >= 100;
|
|
56
|
+
|
|
57
|
+
// Emit if enough time passed or completing
|
|
58
|
+
if (isComplete || now - lastEmit >= intervalMs) {
|
|
59
|
+
renderer(type, progress);
|
|
60
|
+
lastEmit = now;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Create a non-TTY progress renderer (periodic line output).
|
|
67
|
+
* For non-interactive contexts like CI or logs.
|
|
68
|
+
*/
|
|
69
|
+
export function createNonTtyProgressRenderer(
|
|
70
|
+
intervalMs = 5000
|
|
71
|
+
): ProgressCallback {
|
|
72
|
+
let lastEmit = 0;
|
|
73
|
+
|
|
74
|
+
return (type, progress) => {
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
const isComplete = progress.percent >= 100;
|
|
77
|
+
|
|
78
|
+
if (isComplete || now - lastEmit >= intervalMs) {
|
|
79
|
+
const percent = progress.percent.toFixed(1);
|
|
80
|
+
const downloaded = (progress.downloadedBytes / 1024 / 1024).toFixed(1);
|
|
81
|
+
const total = (progress.totalBytes / 1024 / 1024).toFixed(1);
|
|
82
|
+
process.stderr.write(
|
|
83
|
+
`${type}: ${percent}% (${downloaded}/${total} MB)\n`
|
|
84
|
+
);
|
|
85
|
+
lastEmit = now;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|