@ottermind/sqlx 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/LICENSE +178 -0
- package/NOTICE +14 -0
- package/README.md +125 -0
- package/bin/sqlx-install.mjs +4 -0
- package/package.json +36 -0
- package/src/args.mjs +66 -0
- package/src/cli.mjs +232 -0
- package/src/errors.mjs +8 -0
- package/src/main.mjs +141 -0
- package/src/release.mjs +177 -0
- package/src/zip.mjs +122 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# License
|
|
2
|
+
|
|
3
|
+
Chat2DB Rust is licensed under a modified version of the Apache License 2.0,
|
|
4
|
+
with the additional conditions below.
|
|
5
|
+
|
|
6
|
+
The Chat2DB Community source used by Chat2DB Rust remains subject to the
|
|
7
|
+
license included with that source. Third-party components remain subject to
|
|
8
|
+
their own license terms and notices.
|
|
9
|
+
|
|
10
|
+
For package metadata, these terms may be identified as
|
|
11
|
+
`LicenseRef-Chat2DB`. That identifier refers only to this root `LICENSE` file
|
|
12
|
+
and does not create a separate license.
|
|
13
|
+
|
|
14
|
+
## 1. Permitted Use
|
|
15
|
+
|
|
16
|
+
Subject to this License, you may use, copy, and modify the Software for:
|
|
17
|
+
|
|
18
|
+
a. personal self-use, or self-hosted use by an educational, academic research,
|
|
19
|
+
or non-profit organization; and
|
|
20
|
+
|
|
21
|
+
b. Internal Use by you and Your Organization, including internal use of the
|
|
22
|
+
desktop application, Web interface, Docker image, HTTP API, CLI, MCP,
|
|
23
|
+
plugins, and reusable modules.
|
|
24
|
+
|
|
25
|
+
You may publish and redistribute the Software or your modifications in Source
|
|
26
|
+
form under this License, provided that you keep this License and all applicable
|
|
27
|
+
copyright, attribution, and third-party notices. This permission does not allow
|
|
28
|
+
you to provide an External Product or Service, Managed Delivery, Embedded
|
|
29
|
+
Product Use, white-label delivery, OEM delivery, or Object-form distribution
|
|
30
|
+
to an Independent External Party without written commercial authorization.
|
|
31
|
+
|
|
32
|
+
## 2. Internal Processing and Results Delivery
|
|
33
|
+
|
|
34
|
+
Internal Use includes use by your Authorized Personnel to process data owned
|
|
35
|
+
or provided by an Independent External Party and to deliver reports, analyses,
|
|
36
|
+
migration results, or other non-interactive outputs to that party, provided
|
|
37
|
+
that the party does not receive credentials, interfaces, configurations,
|
|
38
|
+
controls, repeatable automated access, or any other ability to operate,
|
|
39
|
+
configure, invoke, integrate, or direct the Software or its Community Core
|
|
40
|
+
Capabilities.
|
|
41
|
+
|
|
42
|
+
An Independent External Party providing business requirements, data, or
|
|
43
|
+
acceptance criteria does not by itself make the use external. However, shared
|
|
44
|
+
accounts, human intermediaries, proxy interfaces, prompts, agents, workflows,
|
|
45
|
+
plugins, scheduled requests, or similar arrangements do not qualify for this
|
|
46
|
+
exception when they give the party repeatable or practical control over the
|
|
47
|
+
Software or its Community Core Capabilities.
|
|
48
|
+
|
|
49
|
+
## 3. Commercial Authorization Required
|
|
50
|
+
|
|
51
|
+
Unless Chat2DB has expressly authorized you in writing, you may not:
|
|
52
|
+
|
|
53
|
+
a. provide an External Product or Service;
|
|
54
|
+
|
|
55
|
+
b. perform Managed Delivery for an Independent External Party;
|
|
56
|
+
|
|
57
|
+
c. except for the Source-form redistribution expressly permitted by Section 1,
|
|
58
|
+
distribute installers, binaries, archives, container images, deployment
|
|
59
|
+
charts, patches, configurations, or other materials that enable an
|
|
60
|
+
Independent External Party to deploy or use the Software in Object form;
|
|
61
|
+
|
|
62
|
+
d. perform Embedded Product Use;
|
|
63
|
+
|
|
64
|
+
e. provide white-label or OEM versions of the Software or a derivative work;
|
|
65
|
+
or
|
|
66
|
+
|
|
67
|
+
f. remove, obscure, or modify Chat2DB logos, copyright information, licensing
|
|
68
|
+
notices, or attribution displayed by the official frontend or included in
|
|
69
|
+
an official distribution.
|
|
70
|
+
|
|
71
|
+
These conditions apply whether the offering is paid or free, whether access is
|
|
72
|
+
direct or indirect, whether accounts exist, whether there is one tenant or many
|
|
73
|
+
tenants, and whether the Software runs in a shared instance, a dedicated
|
|
74
|
+
instance, a customer-owned cloud account, or any other deployment topology.
|
|
75
|
+
The identity of the payer, account owner, infrastructure owner, distributor,
|
|
76
|
+
or operator does not change the result. The relevant question is whether an
|
|
77
|
+
Independent External Party receives the ability to use Community Core
|
|
78
|
+
Capabilities.
|
|
79
|
+
|
|
80
|
+
For written commercial authorization, contact Chat2DB through
|
|
81
|
+
https://chat2db.ai.
|
|
82
|
+
|
|
83
|
+
## 4. Definitions
|
|
84
|
+
|
|
85
|
+
"Chat2DB" means 爱獭科技(杭州)有限公司, the producer of the Software.
|
|
86
|
+
|
|
87
|
+
"Software" means the Chat2DB Rust software made available under this License
|
|
88
|
+
in Source or Object form, including its official desktop, Web, HTTP API, CLI,
|
|
89
|
+
MCP, compatibility engine, plugins, and reusable module distributions.
|
|
90
|
+
|
|
91
|
+
"Source" and "Object" have the meanings given in the Apache License 2.0.
|
|
92
|
+
|
|
93
|
+
"You" means the individual or legal entity exercising permissions under this
|
|
94
|
+
License.
|
|
95
|
+
|
|
96
|
+
"Your Organization" means you and any legal entities that control, are
|
|
97
|
+
controlled by, or are under common control with you. "Control" means direct or
|
|
98
|
+
indirect ownership of more than fifty percent of the voting interests, or the
|
|
99
|
+
power to direct the management and policies of an entity by ownership,
|
|
100
|
+
contract, or otherwise.
|
|
101
|
+
|
|
102
|
+
"Authorized Personnel" means employees and individual contractors acting
|
|
103
|
+
solely on behalf of Your Organization, under obligations that protect the
|
|
104
|
+
Software and prevent its use for any other person or organization. Customers,
|
|
105
|
+
vendors, channel partners, distributors, and independent service providers are
|
|
106
|
+
not Authorized Personnel merely because they have a commercial relationship
|
|
107
|
+
with you.
|
|
108
|
+
|
|
109
|
+
"Internal Use" means use of the Software solely by Authorized Personnel and
|
|
110
|
+
internal systems for the internal operations of Your Organization, where no
|
|
111
|
+
Independent External Party receives Community Core Capabilities.
|
|
112
|
+
|
|
113
|
+
"Independent External Party" means any individual or organization outside
|
|
114
|
+
Your Organization that is not Authorized Personnel.
|
|
115
|
+
|
|
116
|
+
"Community Core Capabilities" means substantive database functionality
|
|
117
|
+
provided by the Software, including database connection and credential
|
|
118
|
+
management, metadata browsing, SQL, DDL, or DML execution, data editing,
|
|
119
|
+
import or export, tasks and history, dashboards and charts, AI features, CLI,
|
|
120
|
+
MCP, frontend interaction, HTTP APIs, plugins, and reusable API, Core, or SPI
|
|
121
|
+
modules.
|
|
122
|
+
|
|
123
|
+
"External Product or Service" means any product or service through which an
|
|
124
|
+
Independent External Party can directly or indirectly operate, configure,
|
|
125
|
+
invoke, integrate, automate, or otherwise use Community Core Capabilities,
|
|
126
|
+
including through a Web UI, desktop application, HTTP API, CLI, MCP, embedded
|
|
127
|
+
module, prompt, agent, workflow, proxy, or other interface.
|
|
128
|
+
|
|
129
|
+
"Managed Delivery" means deploying, configuring, customizing, hosting,
|
|
130
|
+
operating, maintaining, upgrading, or continuously administering the Software
|
|
131
|
+
or a derivative work for an Independent External Party in a way that gives
|
|
132
|
+
that party Community Core Capabilities.
|
|
133
|
+
|
|
134
|
+
"Embedded Product Use" means incorporating the Software, its frontend, HTTP
|
|
135
|
+
API, CLI, MCP, plugins, reusable modules, or a derivative implementation into
|
|
136
|
+
a product made available to an Independent External Party, where that product
|
|
137
|
+
provides substantive Community Core Capabilities.
|
|
138
|
+
|
|
139
|
+
## 5. Branding and Notices
|
|
140
|
+
|
|
141
|
+
This License does not grant permission to use Chat2DB trade names, trademarks,
|
|
142
|
+
service marks, or product names except as required to describe the origin of
|
|
143
|
+
the Software and reproduce required notices. All uses of Chat2DB marks remain
|
|
144
|
+
subject to applicable trademark law and any separately published trademark
|
|
145
|
+
policy.
|
|
146
|
+
|
|
147
|
+
If you modify the Software, you must include prominent notices stating that
|
|
148
|
+
you changed it. You must retain all applicable copyright, patent, trademark,
|
|
149
|
+
licensing, and attribution notices from the Software.
|
|
150
|
+
|
|
151
|
+
## 6. Contributions
|
|
152
|
+
|
|
153
|
+
Unless you explicitly state otherwise in writing before submission, any
|
|
154
|
+
contribution intentionally submitted for inclusion in Chat2DB Rust is provided
|
|
155
|
+
under this License. By submitting a contribution, you also agree that Chat2DB
|
|
156
|
+
may use the contribution for commercial purposes and may include it in future
|
|
157
|
+
Chat2DB releases made available under different license terms.
|
|
158
|
+
|
|
159
|
+
## 7. Third-Party Software
|
|
160
|
+
|
|
161
|
+
Third-party components included with the Software remain subject to their own
|
|
162
|
+
license terms and notices. This License does not replace, restrict, or modify
|
|
163
|
+
those third-party terms. Where a file or component contains a separate license
|
|
164
|
+
or copyright notice, that separate notice governs that file or component.
|
|
165
|
+
|
|
166
|
+
## 8. Apache License 2.0 Terms
|
|
167
|
+
|
|
168
|
+
Except for the additional conditions stated in this License, all other rights
|
|
169
|
+
and restrictions follow the Apache License 2.0, available at
|
|
170
|
+
https://www.apache.org/licenses/LICENSE-2.0. If these additional conditions
|
|
171
|
+
conflict with the Apache License 2.0 for Chat2DB Rust, these additional
|
|
172
|
+
conditions control.
|
|
173
|
+
|
|
174
|
+
The Apache License 2.0 provisions concerning patent grants, patent litigation,
|
|
175
|
+
notices, disclaimer of warranty, limitation of liability, and acceptance of
|
|
176
|
+
additional liability are incorporated into this License.
|
|
177
|
+
|
|
178
|
+
Copyright 2026 爱獭科技(杭州)有限公司.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
SQLX
|
|
2
|
+
Copyright 2026 爱獭科技(杭州)有限公司 / OtterMind.
|
|
3
|
+
|
|
4
|
+
Database connection, result conversion, and JDBC loading code is adapted from
|
|
5
|
+
OtterMind/Chat2DB-Rust, commit e17ee8be373407e193e7e12da0dcd3b5b4b5550b.
|
|
6
|
+
These adaptations replace product-specific storage and session integrations
|
|
7
|
+
with standalone worker processes and complete streamed SQL results.
|
|
8
|
+
The retained LICENSE governs the derived code. Third-party dependencies keep
|
|
9
|
+
their own licenses. SQLX is a public source repository; its license contains
|
|
10
|
+
additional conditions beyond the standard Apache License 2.0.
|
|
11
|
+
|
|
12
|
+
Result toolbar sizing is adapted from Chat2DB Community (commit 777b06099):
|
|
13
|
+
chat2db-community-client/src/blocks/SearchResult/components/ResultSetToolbar.
|
|
14
|
+
The original license and copyright conditions remain applicable.
|
package/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# @ottermind/sqlx
|
|
2
|
+
|
|
3
|
+
Install or update the [SQLX](https://github.com/OtterMind/sqlx) database CLI and
|
|
4
|
+
its agent Skill with one command.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npx -y @ottermind/sqlx@latest
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The command installs or updates the native CLI, initializes the local SQLX data
|
|
11
|
+
directory when it does not exist yet, and installs the Skill. Node.js is only
|
|
12
|
+
needed to run the installer: the CLI itself has no Node.js runtime dependency.
|
|
13
|
+
|
|
14
|
+
This package contains no binaries. It downloads the CLI and the Skill from the
|
|
15
|
+
GitHub Release that matches the version of this package.
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
- Node.js 22 or newer (`npx` is part of npm).
|
|
20
|
+
- Write access to the CLI directory and to the Skill directory.
|
|
21
|
+
- Access to the npm Registry and to GitHub Releases.
|
|
22
|
+
|
|
23
|
+
Windows ARM64 is not a release target.
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# Skill in ./sqlx, CLI in the official user-level location
|
|
29
|
+
npx -y @ottermind/sqlx@latest
|
|
30
|
+
|
|
31
|
+
# Skill in the Codex user-level Skill directory
|
|
32
|
+
npx -y @ottermind/sqlx@latest --target codex
|
|
33
|
+
|
|
34
|
+
# Skill in the Claude Code user-level Skill directory
|
|
35
|
+
npx -y @ottermind/sqlx@latest --target claude
|
|
36
|
+
|
|
37
|
+
# Skill in an explicit directory
|
|
38
|
+
npx -y @ottermind/sqlx@latest --target ./tools/sqlx
|
|
39
|
+
|
|
40
|
+
npx -y @ottermind/sqlx@latest --help
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
| `--target` | Skill directory |
|
|
44
|
+
| ------------------- | ---------------------------------- |
|
|
45
|
+
| not given | `./sqlx` in the current directory |
|
|
46
|
+
| `codex` | `~/.agents/skills/sqlx` |
|
|
47
|
+
| `claude` | `~/.claude/skills/sqlx` |
|
|
48
|
+
| any other value | that path, used as the directory |
|
|
49
|
+
|
|
50
|
+
Relative paths are resolved against the directory the command runs from, not
|
|
51
|
+
against a temporary download directory.
|
|
52
|
+
|
|
53
|
+
## Where things are installed
|
|
54
|
+
|
|
55
|
+
| Item | Location |
|
|
56
|
+
| ---------- | ----------------------------------------------------------- |
|
|
57
|
+
| CLI | `~/.local/bin/sqlx` (macOS, Linux) |
|
|
58
|
+
| CLI | `%LOCALAPPDATA%\Programs\SQLX\sqlx.exe` (Windows) |
|
|
59
|
+
| Skill | the `--target` directory |
|
|
60
|
+
| Local data | `~/.sqlx`, created by `sqlx init` when missing |
|
|
61
|
+
|
|
62
|
+
The CLI is reused when it is already at the target version, updated when it is
|
|
63
|
+
older and supports self-updates, and left untouched when it is newer: this
|
|
64
|
+
installer never downgrades a working CLI and never replaces an executable it
|
|
65
|
+
does not recognize as `OtterMind/sqlx`.
|
|
66
|
+
|
|
67
|
+
The Skill is installed by the verified CLI itself, so it shares the same
|
|
68
|
+
`skill-installs.json` record, integrity data and local-modification protection
|
|
69
|
+
as `sqlx skill install`. The installer never copies Skill files on its own, so
|
|
70
|
+
there is only one update rule.
|
|
71
|
+
|
|
72
|
+
If the CLI installs successfully but the Skill step fails, the command exits
|
|
73
|
+
non-zero, keeps the working CLI and reports the Skill failure separately.
|
|
74
|
+
|
|
75
|
+
## Environment
|
|
76
|
+
|
|
77
|
+
| Variable | Meaning |
|
|
78
|
+
| ------------------- | ---------------------------------------------------------- |
|
|
79
|
+
| `SQLX_INSTALL_DIR` | CLI directory instead of the official location |
|
|
80
|
+
| `SQLX_RELEASE_BASE` | Release download base instead of `github.com` |
|
|
81
|
+
| `SQLX_DATA_DIR` | SQLX data directory instead of `~/.sqlx` |
|
|
82
|
+
| `SQLX_VERSION` | Release to install instead of this package version |
|
|
83
|
+
|
|
84
|
+
## Verification
|
|
85
|
+
|
|
86
|
+
Before anything is written, the installer checks that the release metadata, the
|
|
87
|
+
`SHA256SUMS` file and the downloaded archive agree:
|
|
88
|
+
|
|
89
|
+
1. `SHA256SUMS` is downloaded and `manifest.json` is verified against it.
|
|
90
|
+
2. The CLI archive is verified against the SHA-256 in the manifest.
|
|
91
|
+
3. The archive is extracted with a ZIP reader that rejects absolute paths, `..`
|
|
92
|
+
traversal, backslashes and symbolic links.
|
|
93
|
+
4. The extracted binary is executed once as `--version` and must report
|
|
94
|
+
`OtterMind/sqlx` at the expected version.
|
|
95
|
+
5. The binary is staged inside the CLI directory and renamed into place, so a
|
|
96
|
+
failure cannot leave a half-written executable.
|
|
97
|
+
|
|
98
|
+
## This package does not
|
|
99
|
+
|
|
100
|
+
- run `preinstall`, `install` or `postinstall` scripts;
|
|
101
|
+
- write to the `package.json`, lockfile or `node_modules` of the calling
|
|
102
|
+
project;
|
|
103
|
+
- download database workers, drivers or a JRE — the CLI fetches those when a
|
|
104
|
+
connection actually needs them;
|
|
105
|
+
- modify `PATH` or shell profiles; it reports the directory to add when the CLI
|
|
106
|
+
is not reachable.
|
|
107
|
+
|
|
108
|
+
## Troubleshooting
|
|
109
|
+
|
|
110
|
+
- **`SQLX installation failed (cli)`** — the CLI directory is not writable, or
|
|
111
|
+
it already contains a `sqlx` that is not an OtterMind build. Set
|
|
112
|
+
`SQLX_INSTALL_DIR` to a directory you own.
|
|
113
|
+
- **`SQLX installation failed (skill)`** — the Skill destination already exists
|
|
114
|
+
and is not managed by SQLX, or it has local changes. The CLI stays installed;
|
|
115
|
+
fix the destination and run the command again.
|
|
116
|
+
- **`SQLX installation failed (download)`** — the GitHub Release for this
|
|
117
|
+
package version is not reachable. Check the version exists before retrying.
|
|
118
|
+
|
|
119
|
+
No Node.js? Use the Shell or PowerShell installer described in the
|
|
120
|
+
[SQLX README](https://github.com/OtterMind/sqlx#readme).
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
See `LICENSE` and `NOTICE`. For package metadata these terms are identified as
|
|
125
|
+
`LicenseRef-Chat2DB`.
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ottermind/sqlx",
|
|
3
|
+
"version": "0.1.5",
|
|
4
|
+
"description": "Install or update the SQLX database CLI and its agent Skill.",
|
|
5
|
+
"license": "LicenseRef-Chat2DB",
|
|
6
|
+
"homepage": "https://github.com/OtterMind/sqlx#readme",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/OtterMind/sqlx.git",
|
|
10
|
+
"directory": "packages/npm-installer"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/OtterMind/sqlx/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"bin": {
|
|
17
|
+
"sqlx-install": "bin/sqlx-install.mjs"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=22"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"bin",
|
|
24
|
+
"src",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"NOTICE"
|
|
28
|
+
],
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public",
|
|
31
|
+
"registry": "https://registry.npmjs.org/"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node --test test/unit.test.mjs test/e2e.test.mjs"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/args.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { UsageError } from "./errors.mjs";
|
|
2
|
+
|
|
3
|
+
export const HELP = `Install or update the SQLX CLI and its agent Skill.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
npx -y @ottermind/sqlx@latest [--target <codex|claude|path>]
|
|
7
|
+
|
|
8
|
+
Options:
|
|
9
|
+
--target <codex|claude|path> Skill destination. Without it the Skill is
|
|
10
|
+
installed to ./sqlx in the directory the command
|
|
11
|
+
runs from. "codex" and "claude" select the
|
|
12
|
+
user-level Skill directory of that agent; any
|
|
13
|
+
other value is used as the Skill directory.
|
|
14
|
+
-h, --help Show this help and exit.
|
|
15
|
+
|
|
16
|
+
The CLI is installed to its official user-level location:
|
|
17
|
+
macOS, Linux ~/.local/bin/sqlx
|
|
18
|
+
Windows %LOCALAPPDATA%\\Programs\\SQLX\\sqlx.exe
|
|
19
|
+
|
|
20
|
+
This package never runs an install script, never downloads inside a project and
|
|
21
|
+
never edits the package.json, lockfile or node_modules of the calling project.
|
|
22
|
+
|
|
23
|
+
Environment:
|
|
24
|
+
SQLX_INSTALL_DIR CLI directory instead of the official location.
|
|
25
|
+
SQLX_RELEASE_BASE Release download base instead of github.com.
|
|
26
|
+
SQLX_DATA_DIR SQLX data directory instead of ~/.sqlx.
|
|
27
|
+
SQLX_VERSION Release to install instead of this package version.
|
|
28
|
+
|
|
29
|
+
Examples:
|
|
30
|
+
npx -y @ottermind/sqlx@latest
|
|
31
|
+
npx -y @ottermind/sqlx@latest --target codex
|
|
32
|
+
npx -y @ottermind/sqlx@latest --target ./tools/sqlx
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
export function parseArgs(argv) {
|
|
36
|
+
const options = { target: undefined, help: false };
|
|
37
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
38
|
+
const argument = argv[index];
|
|
39
|
+
if (argument === "--help" || argument === "-h") {
|
|
40
|
+
options.help = true;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (argument === "--target" || argument.startsWith("--target=")) {
|
|
44
|
+
if (options.target !== undefined) {
|
|
45
|
+
throw new UsageError("--target was given more than once");
|
|
46
|
+
}
|
|
47
|
+
if (argument === "--target") {
|
|
48
|
+
index += 1;
|
|
49
|
+
if (index >= argv.length) {
|
|
50
|
+
throw new UsageError("--target needs codex, claude or a directory");
|
|
51
|
+
}
|
|
52
|
+
options.target = argv[index];
|
|
53
|
+
} else {
|
|
54
|
+
options.target = argument.slice("--target=".length);
|
|
55
|
+
}
|
|
56
|
+
if (options.target === "") {
|
|
57
|
+
throw new UsageError("--target needs codex, claude or a directory");
|
|
58
|
+
}
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
throw new UsageError(
|
|
62
|
+
argument.startsWith("-") ? `unknown option: ${argument}` : `unexpected argument: ${argument}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return options;
|
|
66
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { InstallError } from "./errors.mjs";
|
|
7
|
+
import { extractZip } from "./zip.mjs";
|
|
8
|
+
|
|
9
|
+
const COMMAND_TIMEOUT_MS = 60_000;
|
|
10
|
+
const INSTALL_TIMEOUT_MS = 600_000;
|
|
11
|
+
const MANAGED_PARTS = [
|
|
12
|
+
"/target/debug/",
|
|
13
|
+
"/target/release/",
|
|
14
|
+
"/cellar/",
|
|
15
|
+
"/windowsapps/",
|
|
16
|
+
"/scoop/apps/",
|
|
17
|
+
"/chocolatey/",
|
|
18
|
+
];
|
|
19
|
+
const MANAGED_PREFIXES = ["/nix/store/", "/snap/", "/usr/bin/", "/bin/"];
|
|
20
|
+
|
|
21
|
+
export function binaryName() {
|
|
22
|
+
return process.platform === "win32" ? "sqlx.exe" : "sqlx";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function cliDirectory() {
|
|
26
|
+
const override = process.env.SQLX_INSTALL_DIR?.trim();
|
|
27
|
+
if (override) {
|
|
28
|
+
return path.resolve(override);
|
|
29
|
+
}
|
|
30
|
+
if (process.platform === "win32") {
|
|
31
|
+
const local = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
|
32
|
+
return path.join(local, "Programs", "SQLX");
|
|
33
|
+
}
|
|
34
|
+
return path.join(os.homedir(), ".local", "bin");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function dataDirectory() {
|
|
38
|
+
const override = process.env.SQLX_DATA_DIR?.trim();
|
|
39
|
+
return override ? path.resolve(override) : path.join(os.homedir(), ".sqlx");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function cliFailure(stdout, stderr, status) {
|
|
43
|
+
for (const stream of [stdout, stderr]) {
|
|
44
|
+
try {
|
|
45
|
+
const message = JSON.parse(stream)?.error?.message;
|
|
46
|
+
if (typeof message === "string" && message !== "") {
|
|
47
|
+
return message;
|
|
48
|
+
}
|
|
49
|
+
} catch {
|
|
50
|
+
// Not a JSON result; fall back to the raw output.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return `${stdout ?? ""}${stderr ?? ""}`.trim() || `exit code ${status}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function run(binary, args, timeout, stage) {
|
|
57
|
+
const result = spawnSync(binary, args, {
|
|
58
|
+
encoding: "utf8",
|
|
59
|
+
timeout,
|
|
60
|
+
windowsHide: true,
|
|
61
|
+
env: { ...process.env, SQLX_NO_UPDATE_CHECK: "1" },
|
|
62
|
+
});
|
|
63
|
+
if (result.error) {
|
|
64
|
+
throw new InstallError(stage, `${path.basename(binary)} ${args.join(" ")} failed: ${result.error.message}`);
|
|
65
|
+
}
|
|
66
|
+
if (result.status !== 0) {
|
|
67
|
+
throw new InstallError(
|
|
68
|
+
stage,
|
|
69
|
+
`${path.basename(binary)} ${args.join(" ")} failed: ${cliFailure(result.stdout, result.stderr, result.status)}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return result.stdout ?? "";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function readVersion(binary) {
|
|
76
|
+
const result = spawnSync(binary, ["--version"], {
|
|
77
|
+
encoding: "utf8",
|
|
78
|
+
timeout: COMMAND_TIMEOUT_MS,
|
|
79
|
+
windowsHide: true,
|
|
80
|
+
});
|
|
81
|
+
if (result.error || result.status !== 0) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const match = /^sqlx (\S+) \(OtterMind\/sqlx\)$/m.exec(result.stdout ?? "");
|
|
85
|
+
return match ? match[1] : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function inspect(binary) {
|
|
89
|
+
let stats;
|
|
90
|
+
try {
|
|
91
|
+
stats = fs.lstatSync(binary);
|
|
92
|
+
} catch {
|
|
93
|
+
return { path: binary, exists: false };
|
|
94
|
+
}
|
|
95
|
+
if (stats.isSymbolicLink()) {
|
|
96
|
+
throw new InstallError(
|
|
97
|
+
"cli",
|
|
98
|
+
`${binary} is a symbolic link; install SQLX with the tool that owns it, or set SQLX_INSTALL_DIR`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (!stats.isFile()) {
|
|
102
|
+
throw new InstallError("cli", `${binary} is not a regular file`);
|
|
103
|
+
}
|
|
104
|
+
const version = readVersion(binary);
|
|
105
|
+
if (!version) {
|
|
106
|
+
throw new InstallError(
|
|
107
|
+
"cli",
|
|
108
|
+
`${binary} already exists and is not an OtterMind SQLX executable; refusing to replace it`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return { path: binary, exists: true, version };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function assertReplaceable(binary) {
|
|
115
|
+
const normalized = binary.replaceAll("\\", "/").toLowerCase();
|
|
116
|
+
if (
|
|
117
|
+
MANAGED_PARTS.some((part) => normalized.includes(part)) ||
|
|
118
|
+
MANAGED_PREFIXES.some((prefix) => normalized.startsWith(prefix))
|
|
119
|
+
) {
|
|
120
|
+
throw new InstallError(
|
|
121
|
+
"cli",
|
|
122
|
+
`${binary} is owned by a package manager or a source build; update SQLX with that tool`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if ((fs.statSync(binary).mode & 0o222) === 0) {
|
|
126
|
+
throw new InstallError("cli", `${binary} is read-only; update SQLX with the tool that installed it`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function supportsSelfUpdate(binary) {
|
|
131
|
+
const result = spawnSync(binary, ["update", "status"], {
|
|
132
|
+
encoding: "utf8",
|
|
133
|
+
timeout: COMMAND_TIMEOUT_MS,
|
|
134
|
+
windowsHide: true,
|
|
135
|
+
});
|
|
136
|
+
if (result.error || result.status !== 0) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
return typeof JSON.parse(result.stdout)?.current_version === "string";
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function replace(candidate, target) {
|
|
147
|
+
if (process.platform !== "win32" || !fs.existsSync(target)) {
|
|
148
|
+
fs.renameSync(candidate, target);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// Windows cannot rename over a running executable, so keep an independent copy
|
|
152
|
+
// until the replacement is in place.
|
|
153
|
+
const backup = `${target}.sqlx-previous`;
|
|
154
|
+
fs.rmSync(backup, { force: true });
|
|
155
|
+
fs.renameSync(target, backup);
|
|
156
|
+
try {
|
|
157
|
+
fs.renameSync(candidate, target);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
fs.renameSync(backup, target);
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
fs.rmSync(backup, { force: true });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function selfUpdate(binary, version) {
|
|
166
|
+
run(binary, ["update", "install", "--version", version], INSTALL_TIMEOUT_MS, "cli");
|
|
167
|
+
const installed = readVersion(binary);
|
|
168
|
+
if (installed !== version) {
|
|
169
|
+
throw new InstallError(
|
|
170
|
+
"cli",
|
|
171
|
+
`sqlx update install finished but ${binary} reports ${installed ?? "no version"}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function installCli({ binary, bytes, entrypoint, version }) {
|
|
177
|
+
const directory = path.dirname(binary);
|
|
178
|
+
try {
|
|
179
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
180
|
+
} catch (error) {
|
|
181
|
+
throw new InstallError("cli", `cannot create ${directory}: ${error.message}`);
|
|
182
|
+
}
|
|
183
|
+
if (fs.existsSync(binary)) {
|
|
184
|
+
inspect(binary);
|
|
185
|
+
assertReplaceable(binary);
|
|
186
|
+
}
|
|
187
|
+
let stage;
|
|
188
|
+
try {
|
|
189
|
+
stage = fs.mkdtempSync(path.join(directory, ".sqlx-npm-"));
|
|
190
|
+
} catch (error) {
|
|
191
|
+
throw new InstallError("cli", `cannot write to ${directory}: ${error.message}`);
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
extractZip(bytes, stage);
|
|
195
|
+
const candidate = path.join(stage, entrypoint);
|
|
196
|
+
if (!fs.existsSync(candidate)) {
|
|
197
|
+
throw new InstallError("archive", `archive does not contain ${entrypoint}`);
|
|
198
|
+
}
|
|
199
|
+
fs.chmodSync(candidate, 0o755);
|
|
200
|
+
const candidateVersion = readVersion(candidate);
|
|
201
|
+
if (candidateVersion !== version) {
|
|
202
|
+
throw new InstallError(
|
|
203
|
+
"verify",
|
|
204
|
+
`downloaded CLI reports ${candidateVersion ?? "no version"}, expected ${version}`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
replace(candidate, binary);
|
|
208
|
+
} finally {
|
|
209
|
+
fs.rmSync(stage, { recursive: true, force: true });
|
|
210
|
+
}
|
|
211
|
+
const installed = readVersion(binary);
|
|
212
|
+
if (installed !== version) {
|
|
213
|
+
throw new InstallError("cli", `installed ${binary} reports ${installed ?? "no version"}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function initializeData(binary, directory) {
|
|
218
|
+
if (fs.existsSync(directory)) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
run(binary, ["init"], INSTALL_TIMEOUT_MS, "init");
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function installSkill(binary, target) {
|
|
226
|
+
const stdout = run(binary, ["skill", "install", "--path", target], INSTALL_TIMEOUT_MS, "skill");
|
|
227
|
+
try {
|
|
228
|
+
return JSON.parse(stdout);
|
|
229
|
+
} catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
package/src/errors.mjs
ADDED
package/src/main.mjs
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { HELP, parseArgs } from "./args.mjs";
|
|
6
|
+
import * as cli from "./cli.mjs";
|
|
7
|
+
import { InstallError, UsageError } from "./errors.mjs";
|
|
8
|
+
import { compareVersions, downloadAsset, loadRelease, platformName, targetVersion } from "./release.mjs";
|
|
9
|
+
|
|
10
|
+
export function resolveSkillTarget(value, cwd) {
|
|
11
|
+
if (value === undefined) {
|
|
12
|
+
return path.resolve(cwd, "sqlx");
|
|
13
|
+
}
|
|
14
|
+
if (value === "codex") {
|
|
15
|
+
return path.join(os.homedir(), ".agents", "skills", "sqlx");
|
|
16
|
+
}
|
|
17
|
+
if (value === "claude") {
|
|
18
|
+
return path.join(os.homedir(), ".claude", "skills", "sqlx");
|
|
19
|
+
}
|
|
20
|
+
return path.resolve(cwd, value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function pathHint(directory) {
|
|
24
|
+
if (process.platform === "win32") {
|
|
25
|
+
return [
|
|
26
|
+
`${directory} is not in PATH.`,
|
|
27
|
+
` This session: $env:Path = "${directory};$env:Path"`,
|
|
28
|
+
" Permanent: add that directory to your user PATH",
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
return [
|
|
32
|
+
`${directory} is not in PATH.`,
|
|
33
|
+
` This shell: export PATH="${directory}:$PATH"`,
|
|
34
|
+
" Permanent: add that line to your shell profile",
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function report(progress, log) {
|
|
39
|
+
log.log(`SQLX ${progress.cli.version}`);
|
|
40
|
+
log.log(` CLI ${progress.cli.path} (${progress.cli.action})`);
|
|
41
|
+
log.log(` Skill ${progress.skill.path}${progress.skill.version ? ` (${progress.skill.version})` : ""}`);
|
|
42
|
+
log.log(` Data ${progress.data.path} (${progress.data.created ? "created" : "already initialized"})`);
|
|
43
|
+
const directory = path.dirname(progress.cli.path);
|
|
44
|
+
const entries = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
|
|
45
|
+
if (!entries.some((entry) => path.resolve(entry) === directory)) {
|
|
46
|
+
log.log("");
|
|
47
|
+
for (const line of pathHint(directory)) {
|
|
48
|
+
log.log(line);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
log.log("");
|
|
52
|
+
log.log("Next: sqlx --version && sqlx skill status");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function install(options, cwd, log) {
|
|
56
|
+
const version = targetVersion();
|
|
57
|
+
const platform = platformName();
|
|
58
|
+
const binary = path.join(cli.cliDirectory(), cli.binaryName());
|
|
59
|
+
const skillTarget = resolveSkillTarget(options.target, cwd);
|
|
60
|
+
const progress = { cli: null, skill: null, data: null };
|
|
61
|
+
|
|
62
|
+
// Refuse an unusable Skill destination before changing anything else.
|
|
63
|
+
let stats = null;
|
|
64
|
+
try {
|
|
65
|
+
stats = fs.lstatSync(skillTarget);
|
|
66
|
+
} catch {
|
|
67
|
+
stats = null;
|
|
68
|
+
}
|
|
69
|
+
if (stats?.isSymbolicLink()) {
|
|
70
|
+
throw new InstallError("skill", `${skillTarget} is a symbolic link; choose another --target`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const existing = cli.inspect(binary);
|
|
74
|
+
let action;
|
|
75
|
+
if (!existing.exists) {
|
|
76
|
+
action = "install";
|
|
77
|
+
} else if (existing.version === version) {
|
|
78
|
+
action = "reuse";
|
|
79
|
+
} else if (compareVersions(existing.version, version) > 0) {
|
|
80
|
+
action = "keep";
|
|
81
|
+
} else {
|
|
82
|
+
action = cli.supportsSelfUpdate(binary) ? "update" : "replace";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (action === "install" || action === "replace") {
|
|
86
|
+
const release = await loadRelease(version, platform);
|
|
87
|
+
const bytes = await downloadAsset(release.asset);
|
|
88
|
+
cli.installCli({ binary, bytes, entrypoint: release.asset.entrypoint, version });
|
|
89
|
+
progress.cli = { path: binary, version, action: action === "install" ? "installed" : "replaced" };
|
|
90
|
+
} else if (action === "update") {
|
|
91
|
+
cli.selfUpdate(binary, version);
|
|
92
|
+
progress.cli = { path: binary, version, action: "updated" };
|
|
93
|
+
} else if (action === "reuse") {
|
|
94
|
+
progress.cli = { path: binary, version, action: "reused" };
|
|
95
|
+
} else {
|
|
96
|
+
progress.cli = { path: binary, version: existing.version, action: "kept newer version" };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const dataDirectory = cli.dataDirectory();
|
|
100
|
+
progress.data = { path: dataDirectory, created: cli.initializeData(binary, dataDirectory) };
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const result = cli.installSkill(binary, skillTarget);
|
|
104
|
+
progress.skill = { path: skillTarget, version: result?.version ?? null };
|
|
105
|
+
} catch (error) {
|
|
106
|
+
throw new InstallError(
|
|
107
|
+
"skill",
|
|
108
|
+
`${error.message}\nThe CLI at ${binary} (${progress.cli.version}) is installed and usable; fix the Skill destination and run the command again to finish the Skill step.`,
|
|
109
|
+
{ cause: error },
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
report(progress, log);
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function run(argv, log = console) {
|
|
118
|
+
const cwd = process.cwd();
|
|
119
|
+
let options;
|
|
120
|
+
try {
|
|
121
|
+
options = parseArgs(argv);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (!(error instanceof UsageError)) {
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
log.error(`sqlx-install: ${error.message}`);
|
|
127
|
+
log.error("Run with --help for usage.");
|
|
128
|
+
return 2;
|
|
129
|
+
}
|
|
130
|
+
if (options.help) {
|
|
131
|
+
log.log(HELP);
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
return await install(options, cwd, log);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
const stage = error instanceof InstallError ? error.stage : "install";
|
|
138
|
+
log.error(`SQLX installation failed (${stage}): ${error.message}`);
|
|
139
|
+
return 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
package/src/release.mjs
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
|
|
4
|
+
import { InstallError } from "./errors.mjs";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_RELEASE_BASE = "https://github.com/OtterMind/sqlx/releases";
|
|
7
|
+
const METADATA_TIMEOUT_MS = 30_000;
|
|
8
|
+
const ARCHIVE_TIMEOUT_MS = 600_000;
|
|
9
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
10
|
+
|
|
11
|
+
let metadata;
|
|
12
|
+
|
|
13
|
+
function packageMetadata() {
|
|
14
|
+
metadata ??= JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
15
|
+
return metadata;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function targetVersion() {
|
|
19
|
+
return process.env.SQLX_VERSION?.trim() || packageMetadata().version;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function platformName() {
|
|
23
|
+
const os = { darwin: "macos", linux: "linux", win32: "windows" }[process.platform];
|
|
24
|
+
if (!os) {
|
|
25
|
+
throw new InstallError("platform", `unsupported operating system: ${process.platform}`);
|
|
26
|
+
}
|
|
27
|
+
const arch = { arm64: "arm64", x64: "x64" }[process.arch];
|
|
28
|
+
if (!arch) {
|
|
29
|
+
throw new InstallError("platform", `unsupported CPU architecture: ${process.arch}`);
|
|
30
|
+
}
|
|
31
|
+
if (os === "windows" && arch !== "x64") {
|
|
32
|
+
throw new InstallError("platform", "Windows ARM64 is not a release target");
|
|
33
|
+
}
|
|
34
|
+
return `${os}-${arch}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function releaseBase() {
|
|
38
|
+
return (process.env.SQLX_RELEASE_BASE?.trim() || DEFAULT_RELEASE_BASE).replace(/\/+$/, "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function compareVersions(left, right) {
|
|
42
|
+
const parts = (value) => {
|
|
43
|
+
const numbers = value.split(".").map((part) => (/^\d+$/.test(part) ? Number(part) : Number.NaN));
|
|
44
|
+
if (numbers.some(Number.isNaN)) {
|
|
45
|
+
throw new InstallError("version", `cannot compare versions ${left} and ${right}`);
|
|
46
|
+
}
|
|
47
|
+
return numbers;
|
|
48
|
+
};
|
|
49
|
+
const a = parts(left);
|
|
50
|
+
const b = parts(right);
|
|
51
|
+
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
|
|
52
|
+
const difference = (a[index] ?? 0) - (b[index] ?? 0);
|
|
53
|
+
if (difference !== 0) {
|
|
54
|
+
return Math.sign(difference);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function sha256(bytes) {
|
|
61
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function allowedUrl(value) {
|
|
65
|
+
let url;
|
|
66
|
+
try {
|
|
67
|
+
url = new URL(value);
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
if (url.protocol === "https:") {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
return url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function releaseUrl(base, version, file) {
|
|
81
|
+
return `${base}/download/v${version}/${file}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function fetchBytes(url, timeout, what) {
|
|
85
|
+
let response;
|
|
86
|
+
try {
|
|
87
|
+
response = await fetch(url, {
|
|
88
|
+
redirect: "follow",
|
|
89
|
+
signal: AbortSignal.timeout(timeout),
|
|
90
|
+
headers: { "user-agent": `${packageMetadata().name}/${packageMetadata().version}` },
|
|
91
|
+
});
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new InstallError("download", `could not download ${what} from ${url}: ${error.message}`);
|
|
94
|
+
}
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
throw new InstallError("download", `could not download ${what} from ${url}: HTTP ${response.status}`);
|
|
97
|
+
}
|
|
98
|
+
return Buffer.from(await response.arrayBuffer());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseSums(text) {
|
|
102
|
+
const sums = new Map();
|
|
103
|
+
for (const line of text.split("\n")) {
|
|
104
|
+
const match = /^([0-9a-fA-F]{64})\s+\*?(.+?)\s*$/.exec(line);
|
|
105
|
+
if (match) {
|
|
106
|
+
sums.set(match[2], match[1].toLowerCase());
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return sums;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function validEntrypoint(value) {
|
|
113
|
+
return (
|
|
114
|
+
typeof value === "string" &&
|
|
115
|
+
value.length > 0 &&
|
|
116
|
+
value !== "." &&
|
|
117
|
+
value !== ".." &&
|
|
118
|
+
!value.includes("/") &&
|
|
119
|
+
!value.includes("\\")
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function loadRelease(version, platform) {
|
|
124
|
+
const base = releaseBase();
|
|
125
|
+
const sums = parseSums(
|
|
126
|
+
(await fetchBytes(releaseUrl(base, version, "SHA256SUMS"), METADATA_TIMEOUT_MS, "SHA256SUMS")).toString("utf8"),
|
|
127
|
+
);
|
|
128
|
+
const manifestBytes = await fetchBytes(
|
|
129
|
+
releaseUrl(base, version, "manifest.json"),
|
|
130
|
+
METADATA_TIMEOUT_MS,
|
|
131
|
+
"manifest.json",
|
|
132
|
+
);
|
|
133
|
+
if (sums.get("manifest.json") !== sha256(manifestBytes)) {
|
|
134
|
+
throw new InstallError("verify", "SHA256SUMS does not match the downloaded manifest.json");
|
|
135
|
+
}
|
|
136
|
+
let manifest;
|
|
137
|
+
try {
|
|
138
|
+
manifest = JSON.parse(manifestBytes.toString("utf8"));
|
|
139
|
+
} catch (error) {
|
|
140
|
+
throw new InstallError("verify", `release manifest is not valid JSON: ${error.message}`);
|
|
141
|
+
}
|
|
142
|
+
if (manifest?.schema_version !== 1) {
|
|
143
|
+
throw new InstallError("verify", "unsupported release manifest schema");
|
|
144
|
+
}
|
|
145
|
+
const asset = manifest.components?.[`cli:${platform}`];
|
|
146
|
+
if (!asset) {
|
|
147
|
+
throw new InstallError("verify", `release ${version} has no CLI build for ${platform}`);
|
|
148
|
+
}
|
|
149
|
+
if (asset.version !== version) {
|
|
150
|
+
throw new InstallError("verify", `release manifest CLI version ${asset.version} does not match ${version}`);
|
|
151
|
+
}
|
|
152
|
+
if (asset.archive !== "zip") {
|
|
153
|
+
throw new InstallError("verify", `unsupported CLI archive format: ${asset.archive}`);
|
|
154
|
+
}
|
|
155
|
+
if (!validEntrypoint(asset.entrypoint)) {
|
|
156
|
+
throw new InstallError("verify", `unsafe CLI entrypoint: ${asset.entrypoint}`);
|
|
157
|
+
}
|
|
158
|
+
if (!/^[0-9a-fA-F]{64}$/.test(asset.sha256 ?? "")) {
|
|
159
|
+
throw new InstallError("verify", "release manifest has an invalid CLI SHA-256");
|
|
160
|
+
}
|
|
161
|
+
if (!allowedUrl(asset.url)) {
|
|
162
|
+
throw new InstallError("verify", `unsafe CLI download URL: ${asset.url}`);
|
|
163
|
+
}
|
|
164
|
+
const name = new URL(asset.url).pathname.split("/").pop();
|
|
165
|
+
if (!name || sums.get(name) !== asset.sha256.toLowerCase()) {
|
|
166
|
+
throw new InstallError("verify", `SHA256SUMS and manifest disagree about ${name || "the CLI archive"}`);
|
|
167
|
+
}
|
|
168
|
+
return { version, platform, asset: { ...asset, name } };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function downloadAsset(asset) {
|
|
172
|
+
const bytes = await fetchBytes(asset.url, ARCHIVE_TIMEOUT_MS, asset.name);
|
|
173
|
+
if (sha256(bytes) !== asset.sha256.toLowerCase()) {
|
|
174
|
+
throw new InstallError("verify", `checksum verification failed for ${asset.name}`);
|
|
175
|
+
}
|
|
176
|
+
return bytes;
|
|
177
|
+
}
|
package/src/zip.mjs
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import zlib from "node:zlib";
|
|
4
|
+
|
|
5
|
+
import { InstallError } from "./errors.mjs";
|
|
6
|
+
|
|
7
|
+
const END_OF_CENTRAL_DIRECTORY = 0x06054b50;
|
|
8
|
+
const CENTRAL_FILE_HEADER = 0x02014b50;
|
|
9
|
+
const LOCAL_FILE_HEADER = 0x04034b50;
|
|
10
|
+
const ZIP64_MARKER = 0xffffffff;
|
|
11
|
+
const MAXIMUM_COMMENT = 0xffff;
|
|
12
|
+
const UNIX_FILE_TYPE = 0xf000;
|
|
13
|
+
const UNIX_SYMBOLIC_LINK = 0xa000;
|
|
14
|
+
const UNIX_DIRECTORY = 0x4000;
|
|
15
|
+
|
|
16
|
+
function centralDirectoryEnd(buffer) {
|
|
17
|
+
const earliest = Math.max(0, buffer.length - MAXIMUM_COMMENT - 22);
|
|
18
|
+
for (let offset = buffer.length - 22; offset >= earliest; offset -= 1) {
|
|
19
|
+
if (buffer.readUInt32LE(offset) === END_OF_CENTRAL_DIRECTORY) {
|
|
20
|
+
return offset;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
throw new InstallError("archive", "downloaded archive is not a ZIP file");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function safeName(raw) {
|
|
27
|
+
if (raw.includes("\\") || raw.includes(":")) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const trimmed = raw.replace(/\/+$/, "");
|
|
31
|
+
if (trimmed === "") {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const parts = trimmed.split("/");
|
|
35
|
+
if (parts.some((part) => part === "" || part === "." || part === "..")) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
return trimmed;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function extractZip(buffer, destination) {
|
|
42
|
+
const end = centralDirectoryEnd(buffer);
|
|
43
|
+
const entries = buffer.readUInt16LE(end + 10);
|
|
44
|
+
const centralSize = buffer.readUInt32LE(end + 12);
|
|
45
|
+
const centralOffset = buffer.readUInt32LE(end + 16);
|
|
46
|
+
if (centralOffset === ZIP64_MARKER || centralSize === ZIP64_MARKER || entries === 0xffff) {
|
|
47
|
+
throw new InstallError("archive", "ZIP64 archives are not supported");
|
|
48
|
+
}
|
|
49
|
+
if (centralOffset + centralSize > buffer.length) {
|
|
50
|
+
throw new InstallError("archive", "ZIP central directory is out of bounds");
|
|
51
|
+
}
|
|
52
|
+
const written = [];
|
|
53
|
+
let offset = centralOffset;
|
|
54
|
+
for (let index = 0; index < entries; index += 1) {
|
|
55
|
+
if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== CENTRAL_FILE_HEADER) {
|
|
56
|
+
throw new InstallError("archive", "ZIP central directory is damaged");
|
|
57
|
+
}
|
|
58
|
+
const flags = buffer.readUInt16LE(offset + 8);
|
|
59
|
+
const method = buffer.readUInt16LE(offset + 10);
|
|
60
|
+
const compressedSize = buffer.readUInt32LE(offset + 20);
|
|
61
|
+
const uncompressedSize = buffer.readUInt32LE(offset + 24);
|
|
62
|
+
const nameLength = buffer.readUInt16LE(offset + 28);
|
|
63
|
+
const extraLength = buffer.readUInt16LE(offset + 30);
|
|
64
|
+
const commentLength = buffer.readUInt16LE(offset + 32);
|
|
65
|
+
const externalAttributes = buffer.readUInt32LE(offset + 38);
|
|
66
|
+
const localOffset = buffer.readUInt32LE(offset + 42);
|
|
67
|
+
if (offset + 46 + nameLength > buffer.length) {
|
|
68
|
+
throw new InstallError("archive", "ZIP central directory is damaged");
|
|
69
|
+
}
|
|
70
|
+
const rawName = buffer.toString("utf8", offset + 46, offset + 46 + nameLength);
|
|
71
|
+
offset += 46 + nameLength + extraLength + commentLength;
|
|
72
|
+
|
|
73
|
+
if (flags & 0x1) {
|
|
74
|
+
throw new InstallError("archive", "encrypted ZIP entries are not supported");
|
|
75
|
+
}
|
|
76
|
+
if (
|
|
77
|
+
compressedSize === ZIP64_MARKER ||
|
|
78
|
+
uncompressedSize === ZIP64_MARKER ||
|
|
79
|
+
localOffset === ZIP64_MARKER
|
|
80
|
+
) {
|
|
81
|
+
throw new InstallError("archive", "ZIP64 entries are not supported");
|
|
82
|
+
}
|
|
83
|
+
if (((externalAttributes >>> 16) & UNIX_FILE_TYPE) === UNIX_SYMBOLIC_LINK) {
|
|
84
|
+
throw new InstallError("archive", `ZIP symbolic links are not supported: ${rawName}`);
|
|
85
|
+
}
|
|
86
|
+
const name = safeName(rawName);
|
|
87
|
+
if (name === null) {
|
|
88
|
+
throw new InstallError("archive", `unsafe archive path: ${rawName}`);
|
|
89
|
+
}
|
|
90
|
+
if (method !== 0 && method !== 8) {
|
|
91
|
+
throw new InstallError("archive", `unsupported ZIP compression method ${method} for ${name}`);
|
|
92
|
+
}
|
|
93
|
+
if (localOffset + 30 > buffer.length || buffer.readUInt32LE(localOffset) !== LOCAL_FILE_HEADER) {
|
|
94
|
+
throw new InstallError("archive", `ZIP local header is damaged for ${name}`);
|
|
95
|
+
}
|
|
96
|
+
const start =
|
|
97
|
+
localOffset + 30 + buffer.readUInt16LE(localOffset + 26) + buffer.readUInt16LE(localOffset + 28);
|
|
98
|
+
if (start + compressedSize > buffer.length) {
|
|
99
|
+
throw new InstallError("archive", `ZIP entry data is out of bounds for ${name}`);
|
|
100
|
+
}
|
|
101
|
+
const target = path.join(destination, name);
|
|
102
|
+
if (((externalAttributes >>> 16) & UNIX_FILE_TYPE) === UNIX_DIRECTORY || rawName.endsWith("/")) {
|
|
103
|
+
fs.mkdirSync(target, { recursive: true });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const raw = buffer.subarray(start, start + compressedSize);
|
|
107
|
+
let content;
|
|
108
|
+
try {
|
|
109
|
+
content = method === 0 ? raw : zlib.inflateRawSync(raw);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
throw new InstallError("archive", `could not decompress ${name}: ${error.message}`);
|
|
112
|
+
}
|
|
113
|
+
if (content.length !== uncompressedSize) {
|
|
114
|
+
throw new InstallError("archive", `ZIP entry ${name} has an unexpected size`);
|
|
115
|
+
}
|
|
116
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
117
|
+
const mode = (externalAttributes >>> 16) & 0o777;
|
|
118
|
+
fs.writeFileSync(target, content, { mode: mode || 0o644, flag: "wx" });
|
|
119
|
+
written.push(name);
|
|
120
|
+
}
|
|
121
|
+
return written;
|
|
122
|
+
}
|