@ninjaxtools/slopdex 0.19.0 → 0.21.0
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/.gitignore +2 -0
- package/README.md +154 -46
- package/binary-install.js +348 -0
- package/binary.js +124 -0
- package/install.js +4 -0
- package/npm-shrinkwrap.json +52 -0
- package/package.json +78 -61
- package/run-slopdex.js +4 -0
- package/dist/chunk-5BO2LLXC.js +0 -15
- package/dist/chunk-5BO2LLXC.js.map +0 -1
- package/dist/chunk-BXWO2KMC.js +0 -20
- package/dist/chunk-BXWO2KMC.js.map +0 -1
- package/dist/chunk-DKRB2XT5.js +0 -33
- package/dist/chunk-DKRB2XT5.js.map +0 -1
- package/dist/chunk-DTI7SXGL.js +0 -109
- package/dist/chunk-DTI7SXGL.js.map +0 -1
- package/dist/chunk-GBEJHETS.js +0 -1117
- package/dist/chunk-GBEJHETS.js.map +0 -1
- package/dist/chunk-HMKVMRLF.js +0 -2221
- package/dist/chunk-HMKVMRLF.js.map +0 -1
- package/dist/chunk-IQU3YVZ3.js +0 -85
- package/dist/chunk-IQU3YVZ3.js.map +0 -1
- package/dist/chunk-JAVPHCZH.js +0 -24
- package/dist/chunk-JAVPHCZH.js.map +0 -1
- package/dist/chunk-MKUTTXZB.js +0 -74
- package/dist/chunk-MKUTTXZB.js.map +0 -1
- package/dist/chunk-OIKBO3NJ.js +0 -133
- package/dist/chunk-OIKBO3NJ.js.map +0 -1
- package/dist/chunk-P57ZJREE.js +0 -426
- package/dist/chunk-P57ZJREE.js.map +0 -1
- package/dist/chunk-S225GYCL.js +0 -79
- package/dist/chunk-S225GYCL.js.map +0 -1
- package/dist/chunk-TSURHRFF.js +0 -240
- package/dist/chunk-TSURHRFF.js.map +0 -1
- package/dist/chunk-VH5VGRCI.js +0 -103
- package/dist/chunk-VH5VGRCI.js.map +0 -1
- package/dist/cli.d.ts +0 -1
- package/dist/cli.js +0 -1130
- package/dist/cli.js.map +0 -1
- package/dist/code-index-MWSJKV3G.js +0 -14
- package/dist/code-index-MWSJKV3G.js.map +0 -1
- package/dist/cross-search-ERBFC25T.js +0 -10
- package/dist/cross-search-ERBFC25T.js.map +0 -1
- package/dist/database-P5XWJWQL.js +0 -14
- package/dist/database-P5XWJWQL.js.map +0 -1
- package/dist/hosted-GHIUHKOU.js +0 -13
- package/dist/hosted-GHIUHKOU.js.map +0 -1
- package/dist/index.d.ts +0 -681
- package/dist/index.js +0 -102
- package/dist/index.js.map +0 -1
- package/dist/jina-43RL7C6M.js +0 -12
- package/dist/jina-43RL7C6M.js.map +0 -1
- package/dist/openai-CYUPNBGB.js +0 -12
- package/dist/openai-CYUPNBGB.js.map +0 -1
- package/dist/openai-EC6THXKV.js +0 -21
- package/dist/openai-EC6THXKV.js.map +0 -1
- package/dist/openai-TCRXM66O.js +0 -11
- package/dist/openai-TCRXM66O.js.map +0 -1
- package/docs/implementation.md +0 -162
- package/docs/reference.md +0 -187
package/.gitignore
ADDED
package/README.md
CHANGED
|
@@ -4,40 +4,79 @@ This readme is written by a human.
|
|
|
4
4
|
|
|
5
5
|
# slopdex
|
|
6
6
|
|
|
7
|
-
<
|
|
7
|
+
<picture>
|
|
8
|
+
<source media="(max-width: 600px)" srcset="sloppy-dexter.png" width="600" />
|
|
9
|
+
<img src="sloppy-dexter.png" width="280" align="right" alt="Dexter, the sloppy slime" />
|
|
10
|
+
</picture>
|
|
8
11
|
|
|
9
|
-
Slopdex helps with doing analysis on codebases that contain a lot of AI generated
|
|
12
|
+
Slopdex helps with doing analysis on codebases that contain a lot of AI generated ~~slop~~ slime.
|
|
10
13
|
|
|
11
14
|
The main supported functions are
|
|
12
15
|
|
|
13
16
|
- `slopdex search <query>`
|
|
14
|
-
<br/>
|
|
17
|
+
<br/>find code/docs similar to the query
|
|
15
18
|
- `slopdex cross-search`
|
|
16
|
-
<br/>
|
|
19
|
+
<br/>find clusters of similar code/docs
|
|
20
|
+
- `slopdex describe <query>`
|
|
21
|
+
<br/>explain code relevant to a task
|
|
22
|
+
- `slopdex map [PATH]...`
|
|
23
|
+
<br/>show a map/skeleton of the code structure
|
|
17
24
|
|
|
18
|
-
`
|
|
25
|
+
`search` does a vector searche of code, and markdown, and when enabled generated code descriptions.
|
|
19
26
|
|
|
20
|
-
|
|
27
|
+
`cross-search` also does a vector search but compares all functions with each other (scope can be limited with additional options) and helps with finding duplicated code, or code that is not necessarily duplicated but spread out across the codebase (with `--cohesion`).
|
|
28
|
+
|
|
29
|
+
`describe` does a `search` first, then passes the result through the configured LLM model to create a tailored description.
|
|
30
|
+
|
|
31
|
+
`map` shows a map/skeleton of the code, excluding implementation details like function bodies. This can be helpful to get a concise map of the code to allow an LLM to explore the codebase incrementally.
|
|
21
32
|
|
|
22
|
-
|
|
33
|
+
## Installation
|
|
23
34
|
|
|
24
|
-
|
|
35
|
+
Install from npm:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
25
38
|
npm install -g @ninjaxtools/slopdex
|
|
39
|
+
```
|
|
26
40
|
|
|
27
|
-
|
|
28
|
-
# Or
|
|
29
|
-
# export JINA_API_KEY="your-api-key" # and pass --provider jina
|
|
41
|
+
You can also download a binary from one of the releases: https://github.com/ninjaxtools/slopdex/releases
|
|
30
42
|
|
|
31
|
-
|
|
43
|
+
Or you can build from source:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
cargo install --path . --locked
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Getting started
|
|
50
|
+
|
|
51
|
+
Semantic search requires an embedding-provider API key from OpenAI or Jina. `slopdex map` doesn't
|
|
52
|
+
use vector search and works locally without a provider or API key.
|
|
53
|
+
|
|
54
|
+
```console
|
|
55
|
+
$ export OPENAI_API_KEY="your-api-key"
|
|
56
|
+
$ slopdex search "validate an authenticated session"
|
|
57
|
+
...
|
|
32
58
|
```
|
|
33
59
|
|
|
34
60
|
The index tracks the current git commit and is created or updated on every command and stored in `.slopdex/index.sqlite`. Add `.slopdex/` to your repository's `.gitignore` to prevent it from being committed.
|
|
35
61
|
|
|
36
|
-
###
|
|
62
|
+
### Code map/skeleton
|
|
63
|
+
|
|
64
|
+
```console
|
|
65
|
+
$ slopdex map src docs
|
|
66
|
+
$ slopdex map src -g '*.rs' -k fns -e 'refresh|search'
|
|
67
|
+
$ slopdex map -g '*.md' -e '^Guide\.Setup' -i --format json
|
|
68
|
+
$ slopdex map --no-reindex
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The `map` command can be used to generate a source code skeleton that strips most of the code implementation but retains many of the structurally useful information that can act as an index into the source code. The resulting index output is most useful to conserve tokens and improve the agentic programming experience.
|
|
72
|
+
|
|
73
|
+
See the [selector reference](docs/reference.md#shared-selectors).
|
|
74
|
+
|
|
75
|
+
### Search
|
|
37
76
|
|
|
38
|
-
By default
|
|
77
|
+
By default all available indexes are searched and ranked together.
|
|
39
78
|
|
|
40
|
-
```
|
|
79
|
+
```console
|
|
41
80
|
$ slopdex search "keep the repository index synchronized"
|
|
42
81
|
0.4284 tests/languages.test.ts :: refresh
|
|
43
82
|
0.4200 src/cli.ts :: refreshIndex
|
|
@@ -45,35 +84,65 @@ $ slopdex search "keep the repository index synchronized"
|
|
|
45
84
|
...
|
|
46
85
|
```
|
|
47
86
|
|
|
87
|
+
To search code/docs individually:
|
|
88
|
+
|
|
89
|
+
```console
|
|
90
|
+
$ slopdex search-code "configure the embedding provider"
|
|
91
|
+
$ slopdex search-md "configure the embedding provider"
|
|
92
|
+
```
|
|
93
|
+
|
|
48
94
|
You can also enable optional description generation which will automatically generate file and function descriptions with a configured LLM provider and include them in the search.
|
|
49
95
|
|
|
50
96
|
I use OpenCode Go usually with DeepSeek or Muse Spark, which are fairly good low-cost models. If you
|
|
51
97
|
sign up for OpenCode Go through [this link](https://opencode.ai/go?ref=RAR3Z744DZ), we both receive
|
|
52
98
|
$5 in credit.
|
|
53
99
|
|
|
54
|
-
```
|
|
55
|
-
export OPENAI_API_KEY="your-api-key"
|
|
100
|
+
```console
|
|
101
|
+
$ export OPENAI_API_KEY="your-api-key"
|
|
56
102
|
# Or
|
|
57
103
|
# export OPENCODE_API_KEY="your-api-key" # for OpenCode Zen/Go descriptions
|
|
58
104
|
# Or
|
|
59
105
|
# opencode auth login # use stored credentials instead of env variables
|
|
60
106
|
|
|
61
|
-
slopdex descriptions enable
|
|
62
|
-
slopdex search-
|
|
107
|
+
$ slopdex descriptions enable
|
|
108
|
+
$ slopdex search-descriptions "keep the repository index synchronized"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
You can list and configure one of OpenCode's models like this (or use the interactive config):
|
|
112
|
+
|
|
113
|
+
```console
|
|
114
|
+
$ slopdex models opencode-go
|
|
115
|
+
$ slopdex config model opencode-go/gpt-5.6-luna
|
|
116
|
+
$ slopdex config fallback-model opencode-go/muse-spark-1.3-contributor
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The fallback-model is used if the main model reports an error, and if the fallback-model reports an error the main model is tried again.
|
|
120
|
+
|
|
121
|
+
File and function descriptions are cached. Function descriptions will only be regenerated when functions change. File descriptions need to be regenerated explcitily with the `reindex-files` command.
|
|
122
|
+
|
|
123
|
+
### Interactive Config
|
|
124
|
+
|
|
125
|
+
To configure all configurable settings interactively run:
|
|
126
|
+
|
|
127
|
+
```console
|
|
128
|
+
$ slopdex config
|
|
63
129
|
```
|
|
64
130
|
|
|
65
|
-
|
|
131
|
+
### Search and describe
|
|
132
|
+
|
|
133
|
+
The intention of the `describe` command is to use a low-cost model to summarise vector search findings to provide more relevant pre-processed results to a more powerful calling agent. It first performs a vector search and then uses the results to provide a tailored summary/description of relevant code and pre-generated generated file and function descriptions.
|
|
66
134
|
|
|
67
|
-
```
|
|
68
|
-
slopdex
|
|
69
|
-
slopdex config model opencode-go/gpt-5.6-luna
|
|
135
|
+
```console
|
|
136
|
+
$ slopdex describe "I want to implement a new rpc endpoint"
|
|
70
137
|
```
|
|
71
138
|
|
|
139
|
+
For the best matching files (configured with `--describe-full-file-threshold`, default `0.8`) the full file contents are used.
|
|
140
|
+
|
|
72
141
|
### Find duplicate code
|
|
73
142
|
|
|
74
143
|
Compare functions across files, exclude short wrappers, and group matches into clusters:
|
|
75
144
|
|
|
76
|
-
```
|
|
145
|
+
```console
|
|
77
146
|
$ slopdex cross-search --cross-file-only --min-lines 4 --threshold 0.9
|
|
78
147
|
Cluster 1 (3 functions, similarity 0.9124-0.9568)
|
|
79
148
|
src/auth/session.ts:18:1 :: validateSession
|
|
@@ -84,66 +153,73 @@ Cluster 1 (3 functions, similarity 0.9124-0.9568)
|
|
|
84
153
|
|
|
85
154
|
Using `--cross-file-only` is useful to exclude similar code in the same file.
|
|
86
155
|
|
|
87
|
-
|
|
156
|
+
Cross-search defaults to `--threshold 0.8` (query search defaults to `0.3`).
|
|
157
|
+
Clusters join matches transitively, so a low threshold can connect many groups
|
|
158
|
+
into one large cluster. Use `0.9` for stricter duplicate detection, or explicitly
|
|
159
|
+
pass `--threshold 0.3` for the pre-rewrite default.
|
|
88
160
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
161
|
+
You can use threshold ranges as well:
|
|
162
|
+
|
|
163
|
+
```console
|
|
164
|
+
$ slopdex cross-search --cross-file-only --min-lines 4 --threshold 0.9
|
|
165
|
+
$ slopdex cross-search --cross-file-only --min-lines 4 --threshold 0.85-0.9
|
|
92
166
|
```
|
|
93
167
|
|
|
94
168
|
### Restrict functions used in the cross-search
|
|
95
169
|
|
|
96
170
|
Only use uncommitted working-tree functions as sources:
|
|
97
171
|
|
|
98
|
-
```
|
|
99
|
-
slopdex cross-search --uncommitted --cross-file-only --min-lines 4 --threshold 0.9
|
|
172
|
+
```console
|
|
173
|
+
$ slopdex cross-search --uncommitted --cross-file-only --min-lines 4 --threshold 0.9
|
|
100
174
|
```
|
|
101
175
|
|
|
102
176
|
Only use functions changed since origin/main as sources:
|
|
103
177
|
|
|
104
|
-
```
|
|
105
|
-
slopdex cross-search --changed-since origin/main --threshold 0.9
|
|
178
|
+
```console
|
|
179
|
+
$ slopdex cross-search --changed-since origin/main --threshold 0.9
|
|
106
180
|
```
|
|
107
181
|
|
|
108
182
|
Only use matching symbols under src/services as sources:
|
|
109
183
|
|
|
110
|
-
```
|
|
111
|
-
slopdex cross-search --source-path src/services -e '^UserService\.' --threshold 0.9
|
|
184
|
+
```console
|
|
185
|
+
$ slopdex cross-search --source-path src/services -e '^UserService\.' --threshold 0.9
|
|
112
186
|
```
|
|
113
187
|
|
|
114
188
|
### Find related code stored far apart
|
|
115
189
|
|
|
116
190
|
When code is similar but not actually duplicated, then `--cohesion` can help find similar code that exists far apart in the filesystem tree, which could potentially be refactored to make it more cohesive, by ordering matches from farthest to nearest.
|
|
117
191
|
|
|
118
|
-
```
|
|
119
|
-
slopdex cross-search --cross-file-only --cohesion --threshold 0.8
|
|
192
|
+
```console
|
|
193
|
+
$ slopdex cross-search --cross-file-only --cohesion --threshold 0.8
|
|
120
194
|
```
|
|
121
195
|
### Use with agents
|
|
122
196
|
|
|
123
197
|
Just put this in your `AGENTS.md` file, no skill required:
|
|
124
198
|
|
|
125
199
|
```
|
|
200
|
+
- use `treesitter-index -g <glob> <files or directories...>` early to obtain a compact structural skeleton before reading the full file, and then perform targeted reads for implementation details. Line numbers are indicated in square brackets (e.g [5] means line 5, and [5-10] means lines 5 to 10). To filter for specific symbols, use `-e`. To only include specific kinds of symbols use `-k` with `imports`, `fns`, `consts`, `types`, or `classes`. For example: `treesitter-index -g "*.ts" -i -e "manager|main" src`.
|
|
126
201
|
- use semantic code search to find code with: `slopdex search "..." --threshold 0.5`
|
|
127
202
|
- when reviewing uncommitted code avoid introducing duplicates by looking for related matches: `slopdex cross-search --uncommitted --threshold 0.8`
|
|
203
|
+
- don't use `*` as the glob since that circumvents the default ignore rules.
|
|
128
204
|
```
|
|
129
205
|
|
|
130
206
|
Any other use, like doing a full `cross-search` is probably better done interactively with the agent, in which case you can just ask the agent to run `slopdex --help` to get usage information.
|
|
131
207
|
|
|
132
208
|
### Reranking
|
|
133
209
|
|
|
134
|
-
Optionally a second-stage reranker can be enabled for
|
|
210
|
+
Optionally a second-stage reranker can be enabled for all query-search commands:
|
|
135
211
|
|
|
136
|
-
```
|
|
137
|
-
export COHERE_API_KEY="your-api-key"
|
|
138
|
-
slopdex config reranker cohere
|
|
212
|
+
```console
|
|
213
|
+
$ export COHERE_API_KEY="your-api-key"
|
|
214
|
+
$ slopdex config reranker cohere
|
|
139
215
|
# Or: export JINA_API_KEY="your-api-key" && slopdex config reranker jina
|
|
140
216
|
# Or use an LLM: export OPENAI_API_KEY="your-api-key" && slopdex config reranker openai
|
|
141
217
|
```
|
|
142
218
|
|
|
143
219
|
### Compare repositories
|
|
144
220
|
|
|
145
|
-
```
|
|
146
|
-
slopdex cross-search \
|
|
221
|
+
```console
|
|
222
|
+
$ slopdex cross-search \
|
|
147
223
|
--target-root /path/to/other/repo \
|
|
148
224
|
--target-index /path/to/other/repo/.slopdex/index.sqlite \
|
|
149
225
|
--threshold 0.9
|
|
@@ -153,10 +229,42 @@ slopdex cross-search \
|
|
|
153
229
|
|
|
154
230
|
If some functions can't be indexed a warning is printed. Index errors can be investigated and fixed with the `index-errors` command to ensure the index is complete.
|
|
155
231
|
|
|
156
|
-
```
|
|
157
|
-
slopdex status
|
|
158
|
-
slopdex index-errors --format summary
|
|
159
|
-
slopdex --version
|
|
232
|
+
```console
|
|
233
|
+
$ slopdex status
|
|
234
|
+
$ slopdex index-errors --format summary
|
|
235
|
+
$ slopdex --version
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Related Work
|
|
239
|
+
|
|
240
|
+
Code similarity search
|
|
241
|
+
|
|
242
|
+
- [treepeat](https://github.com/dsummersl/treepeat)
|
|
243
|
+
|
|
244
|
+
Structured code queries:
|
|
245
|
+
|
|
246
|
+
- [CodeGraphContext](https://github.com/CodeGraphContext/CodeGraphContext)
|
|
247
|
+
- [CoderLM](https://github.com/JaredStewart/coderlm)
|
|
248
|
+
|
|
249
|
+
Structured code queries and edits:
|
|
250
|
+
|
|
251
|
+
- [srgn](https://github.com/alexpovel/srgn)
|
|
252
|
+
- [comby](https://github.com/comby-tools/comby)
|
|
253
|
+
- [ast-grep](https://ast-grep.github.io/guide/introduction)
|
|
254
|
+
- [gritql](https://github.com/biomejs/gritql)
|
|
255
|
+
|
|
256
|
+
Semantic code search with vector embeddings:
|
|
257
|
+
|
|
258
|
+
- [qmd](https://github.com/tobi/qmd)
|
|
259
|
+
- [grepai](https://github.com/yoanbernabeu/grepai)
|
|
260
|
+
- [cocoindex-code](https://github.com/cocoindex-io/cocoindex-code)
|
|
261
|
+
- [codana](https://github.com/bartolli/codanna)
|
|
262
|
+
- [open-codebase-index](https://github.com/Helweg/open-codebase-index)
|
|
263
|
+
|
|
264
|
+
## Publishing
|
|
265
|
+
|
|
266
|
+
```console
|
|
267
|
+
$ cargo release minor --no-publish --execute
|
|
160
268
|
```
|
|
161
269
|
|
|
162
270
|
## Commands
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
const {
|
|
2
|
+
createWriteStream,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtemp,
|
|
6
|
+
rmSync,
|
|
7
|
+
} = require("fs");
|
|
8
|
+
const { join, sep } = require("path");
|
|
9
|
+
const { spawnSync } = require("child_process");
|
|
10
|
+
const { tmpdir } = require("os");
|
|
11
|
+
|
|
12
|
+
const https = require("node:https");
|
|
13
|
+
const http = require("node:http");
|
|
14
|
+
|
|
15
|
+
const tmpDir = tmpdir();
|
|
16
|
+
|
|
17
|
+
const error = (msg) => {
|
|
18
|
+
console.error(msg);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function getProxyForUrl(urlString) {
|
|
23
|
+
const url = new URL(urlString);
|
|
24
|
+
const isHttps = url.protocol === "https:";
|
|
25
|
+
|
|
26
|
+
const noProxy = process.env.NO_PROXY || process.env.no_proxy || "";
|
|
27
|
+
if (noProxy === "*") return null;
|
|
28
|
+
if (noProxy) {
|
|
29
|
+
const hostname = url.hostname.toLowerCase();
|
|
30
|
+
const noProxyList = noProxy.split(",").map((s) => s.trim().toLowerCase());
|
|
31
|
+
for (const entry of noProxyList) {
|
|
32
|
+
if (hostname === entry || hostname.endsWith("." + entry)) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const proxyEnv = isHttps
|
|
39
|
+
? process.env.HTTPS_PROXY || process.env.https_proxy
|
|
40
|
+
: process.env.HTTP_PROXY || process.env.http_proxy;
|
|
41
|
+
|
|
42
|
+
if (!proxyEnv) return null;
|
|
43
|
+
|
|
44
|
+
const proxyUrl = new URL(proxyEnv);
|
|
45
|
+
|
|
46
|
+
let auth = null;
|
|
47
|
+
if (proxyUrl.username || proxyUrl.password) {
|
|
48
|
+
auth = `${proxyUrl.username}:${proxyUrl.password}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
hostname: proxyUrl.hostname,
|
|
53
|
+
port: proxyUrl.port || (proxyUrl.protocol === "https:" ? 443 : 80),
|
|
54
|
+
auth: auth,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function connectThroughProxy(proxy, target) {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const headers = {};
|
|
61
|
+
if (proxy.auth) {
|
|
62
|
+
headers["Proxy-Authorization"] =
|
|
63
|
+
"Basic " + Buffer.from(proxy.auth).toString("base64");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const connectReq = http.request({
|
|
67
|
+
hostname: proxy.hostname,
|
|
68
|
+
port: proxy.port,
|
|
69
|
+
method: "CONNECT",
|
|
70
|
+
path: `${target.hostname}:${target.port || 443}`,
|
|
71
|
+
headers,
|
|
72
|
+
});
|
|
73
|
+
connectReq.on("connect", (res, socket) => {
|
|
74
|
+
if (res.statusCode === 200) {
|
|
75
|
+
resolve(socket);
|
|
76
|
+
} else {
|
|
77
|
+
reject(new Error(`Proxy CONNECT failed with status ${res.statusCode}`));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
connectReq.on("error", reject);
|
|
81
|
+
connectReq.end();
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function download(urlString, maxRedirects) {
|
|
86
|
+
if (maxRedirects === undefined) maxRedirects = 5;
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
if (maxRedirects < 0) {
|
|
89
|
+
return reject(new Error("Too many redirects"));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const parsed = new URL(urlString);
|
|
93
|
+
const isHttps = parsed.protocol === "https:";
|
|
94
|
+
const mod = isHttps ? https : http;
|
|
95
|
+
const proxy = getProxyForUrl(urlString);
|
|
96
|
+
|
|
97
|
+
const doRequest = (extraOptions) => {
|
|
98
|
+
const options = Object.assign(
|
|
99
|
+
{
|
|
100
|
+
hostname: parsed.hostname,
|
|
101
|
+
port: parsed.port || (isHttps ? 443 : 80),
|
|
102
|
+
path: parsed.pathname + parsed.search,
|
|
103
|
+
method: "GET",
|
|
104
|
+
headers: { "User-Agent": "cargo-dist-npm-installer" },
|
|
105
|
+
},
|
|
106
|
+
extraOptions || {},
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
if (proxy && !isHttps) {
|
|
110
|
+
// HTTP through HTTP proxy: request the full URL via the proxy
|
|
111
|
+
options.hostname = proxy.hostname;
|
|
112
|
+
options.port = proxy.port;
|
|
113
|
+
options.path = urlString;
|
|
114
|
+
if (proxy.auth) {
|
|
115
|
+
options.headers["Proxy-Authorization"] =
|
|
116
|
+
"Basic " + Buffer.from(proxy.auth).toString("base64");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const req = mod.request(options, (res) => {
|
|
121
|
+
if (
|
|
122
|
+
res.statusCode >= 300 &&
|
|
123
|
+
res.statusCode < 400 &&
|
|
124
|
+
res.headers.location
|
|
125
|
+
) {
|
|
126
|
+
res.resume();
|
|
127
|
+
const nextUrl = new URL(res.headers.location, urlString).toString();
|
|
128
|
+
return download(nextUrl, maxRedirects - 1).then(resolve, reject);
|
|
129
|
+
}
|
|
130
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
131
|
+
res.resume();
|
|
132
|
+
return reject(new Error(`HTTP ${res.statusCode} from ${urlString}`));
|
|
133
|
+
}
|
|
134
|
+
resolve(res);
|
|
135
|
+
});
|
|
136
|
+
req.on("error", reject);
|
|
137
|
+
req.end();
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
if (proxy && isHttps) {
|
|
141
|
+
connectThroughProxy(proxy, parsed).then(
|
|
142
|
+
(socket) => doRequest({ socket, agent: false }),
|
|
143
|
+
reject,
|
|
144
|
+
);
|
|
145
|
+
} else {
|
|
146
|
+
doRequest();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
class Package {
|
|
152
|
+
constructor(platform, name, url, filename, zipExt, binaries) {
|
|
153
|
+
let errors = [];
|
|
154
|
+
if (typeof url !== "string") {
|
|
155
|
+
errors.push("url must be a string");
|
|
156
|
+
} else {
|
|
157
|
+
try {
|
|
158
|
+
new URL(url);
|
|
159
|
+
} catch (e) {
|
|
160
|
+
errors.push(e);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (name && typeof name !== "string") {
|
|
164
|
+
errors.push("package name must be a string");
|
|
165
|
+
}
|
|
166
|
+
if (!name) {
|
|
167
|
+
errors.push("You must specify the name of your package");
|
|
168
|
+
}
|
|
169
|
+
if (binaries && typeof binaries !== "object") {
|
|
170
|
+
errors.push("binaries must be a string => string map");
|
|
171
|
+
}
|
|
172
|
+
if (!binaries) {
|
|
173
|
+
errors.push("You must specify the binaries in the package");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (errors.length > 0) {
|
|
177
|
+
let errorMsg =
|
|
178
|
+
"One or more of the parameters you passed to the Binary constructor are invalid:\n";
|
|
179
|
+
errors.forEach((error) => {
|
|
180
|
+
errorMsg += error;
|
|
181
|
+
});
|
|
182
|
+
errorMsg +=
|
|
183
|
+
'\n\nCorrect usage: new Package("my-binary", "https://example.com/binary/download.tar.gz", {"my-binary": "my-binary"})';
|
|
184
|
+
error(errorMsg);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
this.platform = platform;
|
|
188
|
+
this.url = url;
|
|
189
|
+
this.name = name;
|
|
190
|
+
this.filename = filename;
|
|
191
|
+
this.zipExt = zipExt;
|
|
192
|
+
this.installDirectory = join(__dirname, "node_modules", ".bin_real");
|
|
193
|
+
this.binaries = binaries;
|
|
194
|
+
|
|
195
|
+
if (!existsSync(this.installDirectory)) {
|
|
196
|
+
mkdirSync(this.installDirectory, { recursive: true });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
exists() {
|
|
201
|
+
for (const binaryName in this.binaries) {
|
|
202
|
+
const binRelPath = this.binaries[binaryName];
|
|
203
|
+
const binPath = join(this.installDirectory, binRelPath);
|
|
204
|
+
if (!existsSync(binPath)) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
install(suppressLogs = false) {
|
|
212
|
+
if (this.exists()) {
|
|
213
|
+
if (!suppressLogs) {
|
|
214
|
+
console.error(
|
|
215
|
+
`${this.name} is already installed, skipping installation.`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
return Promise.resolve();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
rmSync(this.installDirectory, { recursive: true, force: true });
|
|
223
|
+
} catch {
|
|
224
|
+
// ignore - directory may not exist
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
mkdirSync(this.installDirectory, { recursive: true });
|
|
228
|
+
|
|
229
|
+
if (!suppressLogs) {
|
|
230
|
+
console.error(`Downloading release from ${this.url}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return download(this.url)
|
|
234
|
+
.then((res) => {
|
|
235
|
+
return new Promise((resolve, reject) => {
|
|
236
|
+
mkdtemp(`${tmpDir}${sep}`, (err, directory) => {
|
|
237
|
+
if (err) return reject(err);
|
|
238
|
+
let tempFile = join(directory, this.filename);
|
|
239
|
+
const sink = res.pipe(createWriteStream(tempFile));
|
|
240
|
+
sink.on("error", (err) => reject(err));
|
|
241
|
+
sink.on("close", () => {
|
|
242
|
+
if (/\.tar\.*/.test(this.zipExt)) {
|
|
243
|
+
const result = spawnSync("tar", [
|
|
244
|
+
"xf",
|
|
245
|
+
tempFile,
|
|
246
|
+
// The tarballs are stored with a leading directory
|
|
247
|
+
// component; we strip one component in the
|
|
248
|
+
// shell installers too.
|
|
249
|
+
"--strip-components",
|
|
250
|
+
"1",
|
|
251
|
+
"-C",
|
|
252
|
+
this.installDirectory,
|
|
253
|
+
]);
|
|
254
|
+
if (result.status == 0) {
|
|
255
|
+
resolve();
|
|
256
|
+
} else if (result.error) {
|
|
257
|
+
reject(result.error);
|
|
258
|
+
} else {
|
|
259
|
+
reject(
|
|
260
|
+
new Error(
|
|
261
|
+
`An error occurred untarring the artifact: stdout: ${result.stdout}; stderr: ${result.stderr}`,
|
|
262
|
+
),
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
} else if (this.zipExt == ".zip") {
|
|
266
|
+
let result;
|
|
267
|
+
if (this.platform.artifactName.includes("windows")) {
|
|
268
|
+
// Windows does not have "unzip" by default on many installations, instead
|
|
269
|
+
// we use Expand-Archive from powershell
|
|
270
|
+
result = spawnSync("powershell.exe", [
|
|
271
|
+
"-NoProfile",
|
|
272
|
+
"-NonInteractive",
|
|
273
|
+
"-Command",
|
|
274
|
+
`& {
|
|
275
|
+
param([string]$LiteralPath, [string]$DestinationPath)
|
|
276
|
+
Expand-Archive -LiteralPath $LiteralPath -DestinationPath $DestinationPath -Force
|
|
277
|
+
}`,
|
|
278
|
+
tempFile,
|
|
279
|
+
this.installDirectory,
|
|
280
|
+
]);
|
|
281
|
+
} else {
|
|
282
|
+
result = spawnSync("unzip", [
|
|
283
|
+
"-q",
|
|
284
|
+
tempFile,
|
|
285
|
+
"-d",
|
|
286
|
+
this.installDirectory,
|
|
287
|
+
]);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (result.status == 0) {
|
|
291
|
+
resolve();
|
|
292
|
+
} else if (result.error) {
|
|
293
|
+
reject(result.error);
|
|
294
|
+
} else {
|
|
295
|
+
reject(
|
|
296
|
+
new Error(
|
|
297
|
+
`An error occurred unzipping the artifact: stdout: ${result.stdout}; stderr: ${result.stderr}`,
|
|
298
|
+
),
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
} else {
|
|
302
|
+
reject(
|
|
303
|
+
new Error(`Unrecognized file extension: ${this.zipExt}`),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
})
|
|
310
|
+
.then(() => {
|
|
311
|
+
if (!suppressLogs) {
|
|
312
|
+
console.error(`${this.name} has been installed!`);
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
.catch((e) => {
|
|
316
|
+
error(`Error fetching release: ${e.message}`);
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
run(binaryName) {
|
|
321
|
+
const promise = !this.exists() ? this.install(true) : Promise.resolve();
|
|
322
|
+
|
|
323
|
+
promise
|
|
324
|
+
.then(() => {
|
|
325
|
+
const [, , ...args] = process.argv;
|
|
326
|
+
|
|
327
|
+
const options = { cwd: process.cwd(), stdio: "inherit" };
|
|
328
|
+
|
|
329
|
+
const binRelPath = this.binaries[binaryName];
|
|
330
|
+
if (!binRelPath) {
|
|
331
|
+
error(`${binaryName} is not a known binary in ${this.name}`);
|
|
332
|
+
}
|
|
333
|
+
const binPath = join(this.installDirectory, binRelPath);
|
|
334
|
+
const result = spawnSync(binPath, args, options);
|
|
335
|
+
|
|
336
|
+
if (result.error) {
|
|
337
|
+
error(result.error);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
process.exit(result.status);
|
|
341
|
+
})
|
|
342
|
+
.catch((e) => {
|
|
343
|
+
error(e.message);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
module.exports.Package = Package;
|