@willh/now 0.1.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/LICENSE +21 -0
- package/README.md +409 -0
- package/npm/cli.cjs +22 -0
- package/npm/postinstall.cjs +181 -0
- package/npm/prepublish-check.cjs +115 -0
- package/package.json +36 -0
- package/tests/postinstall.test.cjs +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Will 保哥
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
# now
|
|
2
|
+
|
|
3
|
+
**now 是一個 Rust CLI,用來把靜態網站交給既有 provider CLI 部署。**
|
|
4
|
+
|
|
5
|
+
它不嘗試重寫 Firebase、Azure 或 FTP 的部署流程;它負責選擇可發布目錄、讀取非祕密設定、組合 provider CLI 命令,並在部署後輸出預設 URL。
|
|
6
|
+
|
|
7
|
+
* * *
|
|
8
|
+
|
|
9
|
+
## 支援平台
|
|
10
|
+
|
|
11
|
+
| 平台 | Release asset |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| macOS Apple Silicon | `now-aarch64-apple-darwin.tar.xz` |
|
|
14
|
+
| macOS Intel | `now-x86_64-apple-darwin.tar.xz` |
|
|
15
|
+
| Linux x64 glibc | `now-x86_64-unknown-linux-gnu.tar.xz` |
|
|
16
|
+
| Windows x64 | `now-x86_64-pc-windows-msvc.zip` |
|
|
17
|
+
|
|
18
|
+
**初版不支援 Linux arm64 與 musl。**
|
|
19
|
+
|
|
20
|
+
每個 archive 旁邊都會有同名 `.sha256` 檔案。
|
|
21
|
+
|
|
22
|
+
* * *
|
|
23
|
+
|
|
24
|
+
## 安裝
|
|
25
|
+
|
|
26
|
+
### npm
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npm install -g @willh/now
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
npm 套件只包含 JavaScript wrapper 與安裝邏輯。安裝時會從 GitHub Release 下載目前平台的原生 binary,並驗證 SHA-256 checksum。
|
|
33
|
+
|
|
34
|
+
### Unix-like
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
curl -fsSL https://raw.githubusercontent.com/doggy8088/now/main/install.sh | sh
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
預設安裝到 `$HOME/.local/bin`。可用 `NOW_INSTALL_DIR` 覆寫:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
NOW_INSTALL_DIR=/usr/local/bin sh install.sh
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Windows PowerShell
|
|
47
|
+
|
|
48
|
+
```powershell
|
|
49
|
+
iwr https://raw.githubusercontent.com/doggy8088/now/main/install.ps1 -OutFile install.ps1
|
|
50
|
+
.\install.ps1
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
預設安裝到 `$env:LOCALAPPDATA\now\bin`。可用 `-InstallDir` 覆寫:
|
|
54
|
+
|
|
55
|
+
```powershell
|
|
56
|
+
.\install.ps1 -InstallDir "$env:USERPROFILE\bin"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### 手動下載
|
|
60
|
+
|
|
61
|
+
1. 到 `https://github.com/doggy8088/now/releases/latest` 下載符合平台的 archive。
|
|
62
|
+
2. 下載同名 `.sha256`。
|
|
63
|
+
3. 驗證 checksum。
|
|
64
|
+
4. 解壓縮後把 `now` 或 `now.exe` 放進 `PATH` 內的目錄。
|
|
65
|
+
|
|
66
|
+
* * *
|
|
67
|
+
|
|
68
|
+
## 快速開始
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
now
|
|
72
|
+
now deploy
|
|
73
|
+
now deploy dist
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`now [path]` 等同 `now deploy [path]`。
|
|
77
|
+
|
|
78
|
+
第一次在互動式終端機執行 `now` 或 `now deploy`,且尚未設定 provider 時,CLI 會啟動首次設定流程,協助選擇 provider 並把非祕密設定寫入 `.now.json`。非互動式環境或 `--json` 模式不會啟動提示流程,會直接輸出缺少 provider 的錯誤。
|
|
79
|
+
|
|
80
|
+
也可以手動建立設定:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
now init
|
|
84
|
+
now config set provider firebase-hosting
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
* * *
|
|
88
|
+
|
|
89
|
+
## 設定檔
|
|
90
|
+
|
|
91
|
+
now 讀取兩種設定檔:
|
|
92
|
+
|
|
93
|
+
| 類型 | 路徑 |
|
|
94
|
+
| --- | --- |
|
|
95
|
+
| 本機設定 | `.now.json` |
|
|
96
|
+
| 全域設定 | `~/.config/now/settings.json` |
|
|
97
|
+
|
|
98
|
+
合併優先序如下:
|
|
99
|
+
|
|
100
|
+
1. CLI flags
|
|
101
|
+
2. `.now.json`
|
|
102
|
+
3. `~/.config/now/settings.json`
|
|
103
|
+
|
|
104
|
+
**設定檔只保存非祕密設定。Azure Storage Blob 的 SAS URL 含有上傳權杖,不寫入 `.now.json`;`.now.json` 只保存 `azure_blob.sas_url_env`。**
|
|
105
|
+
|
|
106
|
+
完整範例:
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"provider": "firebase-hosting",
|
|
111
|
+
"source": null,
|
|
112
|
+
"base_url": "https://example.web.app",
|
|
113
|
+
"default_url": null,
|
|
114
|
+
"firebase": {
|
|
115
|
+
"project": "my-firebase-project",
|
|
116
|
+
"site": null
|
|
117
|
+
},
|
|
118
|
+
"azure_blob": {
|
|
119
|
+
"sas_url_env": "NOW_AZURE_BLOB_SAS_URL"
|
|
120
|
+
},
|
|
121
|
+
"azure_swa": {
|
|
122
|
+
"app_name": null,
|
|
123
|
+
"environment": "production",
|
|
124
|
+
"deployment_token_env": "SWA_CLI_DEPLOYMENT_TOKEN"
|
|
125
|
+
},
|
|
126
|
+
"ftp": {
|
|
127
|
+
"host": "ftp.example.com",
|
|
128
|
+
"remote_dir": "/public_html",
|
|
129
|
+
"username_env": "NOW_FTP_USERNAME",
|
|
130
|
+
"password_env": "NOW_FTP_PASSWORD"
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
常用設定命令:
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
now init
|
|
139
|
+
now init --global
|
|
140
|
+
now config set provider firebase-hosting
|
|
141
|
+
now config set firebase.project my-project
|
|
142
|
+
now config get
|
|
143
|
+
now config get provider
|
|
144
|
+
now config doctor
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
* * *
|
|
148
|
+
|
|
149
|
+
## Provider
|
|
150
|
+
|
|
151
|
+
### Firebase Hosting
|
|
152
|
+
|
|
153
|
+
需求:
|
|
154
|
+
|
|
155
|
+
```sh
|
|
156
|
+
npm install -g firebase-tools
|
|
157
|
+
firebase login
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
建議設定:
|
|
161
|
+
|
|
162
|
+
```sh
|
|
163
|
+
now config set provider firebase-hosting
|
|
164
|
+
now config set firebase.project my-project
|
|
165
|
+
now config set base_url https://my-project.web.app
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
部署時會呼叫:
|
|
169
|
+
|
|
170
|
+
```sh
|
|
171
|
+
firebase deploy --only hosting
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
若設定 `firebase.site`,會改用 `hosting:<site>`。
|
|
175
|
+
|
|
176
|
+
為了相容舊設定,`firebase` 仍可被讀取;新設定建議使用 `firebase-hosting`。
|
|
177
|
+
|
|
178
|
+
### Azure Storage Blob
|
|
179
|
+
|
|
180
|
+
Azure Storage Blob provider 不需要 Azure CLI。只要透過環境變數提供 container SAS URL,now 會直接使用 Azure Blob REST API 上傳檔案。
|
|
181
|
+
|
|
182
|
+
首次設定選擇 `Azure Storage Blob` 時,會要求輸入 SAS URL,但只會把 SAS URL 寫入 `.env`,`.now.json` 只保存環境變數名稱。
|
|
183
|
+
|
|
184
|
+
建議設定:
|
|
185
|
+
|
|
186
|
+
```sh
|
|
187
|
+
now config set provider azure-storage-blob
|
|
188
|
+
now config set azure_blob.sas_url 'https://mystorageaccount.blob.core.windows.net/$web?sv=...'
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
上述 `azure_blob.sas_url` 是便利設定入口:now 會自動把實際 SAS URL 寫入 `.env` 的 `NOW_AZURE_BLOB_SAS_URL`,並在 `.now.json` 寫入:
|
|
192
|
+
|
|
193
|
+
```json
|
|
194
|
+
{
|
|
195
|
+
"azure_blob": {
|
|
196
|
+
"sas_url_env": "NOW_AZURE_BLOB_SAS_URL"
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
若要自行管理環境變數,也可以直接設定:
|
|
202
|
+
|
|
203
|
+
```sh
|
|
204
|
+
export NOW_AZURE_BLOB_SAS_URL='https://mystorageaccount.blob.core.windows.net/$web?sv=...'
|
|
205
|
+
now config set azure_blob.sas_url_env NOW_AZURE_BLOB_SAS_URL
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
為了相容舊設定,`azure-blob` 仍可被讀取;新設定建議使用 `azure-storage-blob`。
|
|
209
|
+
|
|
210
|
+
SAS URL 必須指向 container,例如 Azure Static Website 常用的 `$web` container,且需要具備建立或寫入 blob 的權限。部署時會對每個檔案呼叫 Azure Blob Put Blob API,並使用 `x-ms-blob-type: BlockBlob`。
|
|
211
|
+
|
|
212
|
+
```sh
|
|
213
|
+
PUT https://mystorageaccount.blob.core.windows.net/$web/<file>?<sas-query>
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
若未設定 `base_url` 與 `default_url`,now 會從 `sas_url_env` 指向的 SAS URL 移除 query string,再加上預設頁面檔名來推導 `Default URL`。
|
|
217
|
+
|
|
218
|
+
### Azure Static Web App
|
|
219
|
+
|
|
220
|
+
需求:
|
|
221
|
+
|
|
222
|
+
```sh
|
|
223
|
+
npm install -g @azure/static-web-apps-cli
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
建議把 deployment token 放在環境變數:
|
|
227
|
+
|
|
228
|
+
```sh
|
|
229
|
+
export SWA_CLI_DEPLOYMENT_TOKEN=...
|
|
230
|
+
now config set provider azure-static-web-app
|
|
231
|
+
now config set azure_swa.environment production
|
|
232
|
+
now config set azure_swa.deployment_token_env SWA_CLI_DEPLOYMENT_TOKEN
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
部署時會呼叫:
|
|
236
|
+
|
|
237
|
+
```sh
|
|
238
|
+
swa deploy <source> --env production
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
為了相容舊設定,`azure-swa` 仍可被讀取;新設定建議使用 `azure-static-web-app`。
|
|
242
|
+
|
|
243
|
+
### Any Website (FTP)
|
|
244
|
+
|
|
245
|
+
需求:
|
|
246
|
+
|
|
247
|
+
```sh
|
|
248
|
+
lftp --version
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
帳號密碼請使用環境變數:
|
|
252
|
+
|
|
253
|
+
```sh
|
|
254
|
+
export NOW_FTP_USERNAME=deploy-user
|
|
255
|
+
export NOW_FTP_PASSWORD=...
|
|
256
|
+
now config set provider any-website-ftp
|
|
257
|
+
now config set ftp.host ftp.example.com
|
|
258
|
+
now config set ftp.remote_dir /public_html
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
部署時會用 `lftp mirror -R --only-newer` 上傳。初版不做遠端刪除同步。
|
|
262
|
+
|
|
263
|
+
為了相容舊設定,`ftp` 仍可被讀取;新設定建議使用 `any-website-ftp`。
|
|
264
|
+
|
|
265
|
+
* * *
|
|
266
|
+
|
|
267
|
+
## 來源目錄規則
|
|
268
|
+
|
|
269
|
+
未指定 path 時,now 會依序尋找:
|
|
270
|
+
|
|
271
|
+
1. `dist/`
|
|
272
|
+
2. `build/`
|
|
273
|
+
3. `public/`
|
|
274
|
+
|
|
275
|
+
若三者都不存在:
|
|
276
|
+
|
|
277
|
+
1. 互動式終端機會詢問是否把目前目錄的可發布檔案移到 `public/`。
|
|
278
|
+
2. 若拒絕或處於非互動式環境,會部署目前目錄。
|
|
279
|
+
3. 部署目前目錄時會排除 `.now.json`、`.git/`、`node_modules/`、`target/` 與暫存檔。
|
|
280
|
+
|
|
281
|
+
指定 path 時會直接使用該目錄:
|
|
282
|
+
|
|
283
|
+
```sh
|
|
284
|
+
now deploy dist
|
|
285
|
+
now ./public
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
* * *
|
|
289
|
+
|
|
290
|
+
## URL 選擇規則
|
|
291
|
+
|
|
292
|
+
部署後輸出的 URL 依序選擇:
|
|
293
|
+
|
|
294
|
+
1. `.now.json` 或全域設定中的 `default_url`
|
|
295
|
+
2. `index.html` 搭配 `base_url` 或 provider 可推導的公開 URL
|
|
296
|
+
3. `index.htm` 搭配 `base_url` 或 provider 可推導的公開 URL
|
|
297
|
+
4. 根目錄唯一的 `.html` 或 `.htm` 頁面,搭配 `base_url` 或 provider 可推導的公開 URL
|
|
298
|
+
5. provider base URL 或 provider 可推導的公開 URL
|
|
299
|
+
|
|
300
|
+
畫面會以 `Default URL: <url>` 顯示判斷結果。若 `base_url` 與 `default_url` 都是 `null`,now 會盡量從 provider 設定推導完整 URL;目前 Azure Storage Blob 會從 `sas_url_env` 指向的 SAS URL 推導。若無法推導,檔案規則會輸出相對頁面名稱,例如 `index.html`。
|
|
301
|
+
|
|
302
|
+
* * *
|
|
303
|
+
|
|
304
|
+
## 常用命令
|
|
305
|
+
|
|
306
|
+
```sh
|
|
307
|
+
now --help
|
|
308
|
+
now
|
|
309
|
+
now public
|
|
310
|
+
now deploy
|
|
311
|
+
now deploy dist --provider firebase-hosting
|
|
312
|
+
now deploy --dry-run
|
|
313
|
+
now deploy --dry-run --json
|
|
314
|
+
now config get --global
|
|
315
|
+
now config doctor
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
* * *
|
|
319
|
+
|
|
320
|
+
## 安全性
|
|
321
|
+
|
|
322
|
+
**不要把 token、password、secret、account key 或 Azure Storage Blob SAS URL 寫入 `.now.json`。**
|
|
323
|
+
|
|
324
|
+
建議做法:
|
|
325
|
+
|
|
326
|
+
| 類型 | 建議 |
|
|
327
|
+
| --- | --- |
|
|
328
|
+
| Firebase Hosting | 使用 `firebase login` 的既有登入狀態 |
|
|
329
|
+
| Azure Storage Blob | 使用短效期、最小權限的 container SAS URL,並透過 `sas_url_env` 指向環境變數或 `.env` |
|
|
330
|
+
| Azure Static Web App | 使用 `SWA_CLI_DEPLOYMENT_TOKEN` 或自訂 token 環境變數 |
|
|
331
|
+
| Any Website (FTP) | 使用 `NOW_FTP_USERNAME` 與 `NOW_FTP_PASSWORD` 環境變數 |
|
|
332
|
+
|
|
333
|
+
`now config set` 會拒絕明顯像祕密的 key。`now config set azure_blob.sas_url <url>` 只作為安全便利入口,會把實際 URL 寫入已被 `.gitignore` 排除的 `.env`,不會寫入 `.now.json`。
|
|
334
|
+
|
|
335
|
+
* * *
|
|
336
|
+
|
|
337
|
+
## 疑難排解
|
|
338
|
+
|
|
339
|
+
| 問題 | 處理方式 |
|
|
340
|
+
| --- | --- |
|
|
341
|
+
| provider CLI 找不到 | 安裝對應 CLI,並確認它在 `PATH` 內 |
|
|
342
|
+
| 沒有部署權限 | 先用 provider CLI 完成登入與權限確認 |
|
|
343
|
+
| 找不到預設 URL | 設定 `default_url` 或 `base_url`,或確認 provider 設定足以推導公開 URL |
|
|
344
|
+
| npm 安裝時下載 release asset 失敗 | 確認版本對應的 GitHub Release asset 已發布 |
|
|
345
|
+
| checksum 驗證失敗 | 刪除安裝快取後重裝,並確認 release asset 與 `.sha256` 來自同一個版本 |
|
|
346
|
+
|
|
347
|
+
macOS DNS 快取異常時,可先執行:
|
|
348
|
+
|
|
349
|
+
```sh
|
|
350
|
+
sudo dscacheutil -flushcache
|
|
351
|
+
sudo killall -HUP mDNSResponder
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
* * *
|
|
355
|
+
|
|
356
|
+
## 開發者指南
|
|
357
|
+
|
|
358
|
+
需求:
|
|
359
|
+
|
|
360
|
+
```sh
|
|
361
|
+
rustc --version
|
|
362
|
+
cargo --version
|
|
363
|
+
node --version
|
|
364
|
+
npm --version
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
常用流程:
|
|
368
|
+
|
|
369
|
+
```sh
|
|
370
|
+
make check
|
|
371
|
+
make test
|
|
372
|
+
make release-build
|
|
373
|
+
make npm-pack
|
|
374
|
+
make install
|
|
375
|
+
make install-local
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
`make install` 會把 release binary 安裝到 `$HOME/.local/bin/now`。若要改安裝 prefix,使用 `PREFIX=/custom make install-local`。
|
|
379
|
+
|
|
380
|
+
release asset 命名必須固定:
|
|
381
|
+
|
|
382
|
+
```text
|
|
383
|
+
now-aarch64-apple-darwin.tar.xz
|
|
384
|
+
now-aarch64-apple-darwin.tar.xz.sha256
|
|
385
|
+
now-x86_64-apple-darwin.tar.xz
|
|
386
|
+
now-x86_64-apple-darwin.tar.xz.sha256
|
|
387
|
+
now-x86_64-unknown-linux-gnu.tar.xz
|
|
388
|
+
now-x86_64-unknown-linux-gnu.tar.xz.sha256
|
|
389
|
+
now-x86_64-pc-windows-msvc.zip
|
|
390
|
+
now-x86_64-pc-windows-msvc.zip.sha256
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
CI 會執行 Rust format、clippy、Rust 測試與 npm 測試。release workflow 會建立跨平台 binary archive 與 checksum。npm publish workflow 使用:
|
|
394
|
+
|
|
395
|
+
```sh
|
|
396
|
+
npm publish --provenance --access public
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
* * *
|
|
400
|
+
|
|
401
|
+
## 授權與貢獻
|
|
402
|
+
|
|
403
|
+
本專案採用 MIT License。
|
|
404
|
+
|
|
405
|
+
貢獻前請先執行:
|
|
406
|
+
|
|
407
|
+
```sh
|
|
408
|
+
make check
|
|
409
|
+
```
|
package/npm/cli.cjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { spawnSync } = require('node:child_process');
|
|
5
|
+
const { existsSync } = require('node:fs');
|
|
6
|
+
const { join } = require('node:path');
|
|
7
|
+
|
|
8
|
+
const BINARY_NAME = "now";
|
|
9
|
+
const exe = process.platform === 'win32' ? `${BINARY_NAME}.exe` : BINARY_NAME;
|
|
10
|
+
const bin = join(__dirname, `${BINARY_NAME}-bin`, exe);
|
|
11
|
+
|
|
12
|
+
if (!existsSync(bin)) {
|
|
13
|
+
console.error(`${BINARY_NAME} native binary was not found. Try reinstalling ${BINARY_NAME}.`);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const result = spawnSync(bin, process.argv.slice(2), { stdio: 'inherit' });
|
|
18
|
+
if (result.error) {
|
|
19
|
+
console.error(result.error.message);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
process.exit(result.status ?? 1);
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { createHash } = require('node:crypto');
|
|
5
|
+
const { spawnSync } = require('node:child_process');
|
|
6
|
+
const {
|
|
7
|
+
chmodSync,
|
|
8
|
+
copyFileSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} = require('node:fs');
|
|
16
|
+
const { get } = require('node:https');
|
|
17
|
+
const { join } = require('node:path');
|
|
18
|
+
const { URL } = require('node:url');
|
|
19
|
+
|
|
20
|
+
const PACKAGE_ROOT = join(__dirname, '..');
|
|
21
|
+
const BINARY_NAME = "now";
|
|
22
|
+
const GITHUB_OWNER = "doggy8088";
|
|
23
|
+
const GITHUB_REPO = "now";
|
|
24
|
+
const BIN_DIR = join(__dirname, `${BINARY_NAME}-bin`);
|
|
25
|
+
const BIN_NAME = process.platform === 'win32' ? `${BINARY_NAME}.exe` : BINARY_NAME;
|
|
26
|
+
const DEST = join(BIN_DIR, BIN_NAME);
|
|
27
|
+
|
|
28
|
+
const TARGETS = {
|
|
29
|
+
'darwin-arm64': 'aarch64-apple-darwin',
|
|
30
|
+
'darwin-x64': 'x86_64-apple-darwin',
|
|
31
|
+
'linux-x64': 'x86_64-unknown-linux-gnu',
|
|
32
|
+
'win32-x64': 'x86_64-pc-windows-msvc',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function platformKey(platform = process.platform, arch = process.arch) {
|
|
36
|
+
return `${platform}-${arch}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function cargoTarget(platform = process.platform, arch = process.arch) {
|
|
40
|
+
const target = TARGETS[platformKey(platform, arch)];
|
|
41
|
+
if (!target) {
|
|
42
|
+
throw new Error(`Unsupported platform: ${platform}/${arch}`);
|
|
43
|
+
}
|
|
44
|
+
return target;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function packageVersion() {
|
|
48
|
+
return require(join(PACKAGE_ROOT, 'package.json')).version;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function artifactName(target) {
|
|
52
|
+
const ext = target.includes('windows') || target.includes('pc-windows') ? 'zip' : 'tar.xz';
|
|
53
|
+
return `${BINARY_NAME}-${target}.${ext}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function releaseBaseUrl(version = packageVersion()) {
|
|
57
|
+
return `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases/download/v${version}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sha256(path) {
|
|
61
|
+
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function verifyChecksum(filePath, checksumText) {
|
|
65
|
+
const expected = checksumText.trim().split(/\s+/)[0].toLowerCase();
|
|
66
|
+
if (!/^[a-f0-9]{64}$/.test(expected)) {
|
|
67
|
+
throw new Error('Invalid checksum file format');
|
|
68
|
+
}
|
|
69
|
+
const actual = sha256(filePath);
|
|
70
|
+
if (actual !== expected) {
|
|
71
|
+
throw new Error(`Checksum mismatch for ${filePath}: expected ${expected}, got ${actual}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function download(url, destination, redirectsRemaining = 5) {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
get(url, (res) => {
|
|
78
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsRemaining > 0) {
|
|
79
|
+
const nextUrl = new URL(res.headers.location, url).toString();
|
|
80
|
+
download(nextUrl, destination, redirectsRemaining - 1).then(resolve, reject);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (res.statusCode !== 200) {
|
|
84
|
+
reject(new Error(`Download failed ${res.statusCode}: ${url}`));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const chunks = [];
|
|
88
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
89
|
+
res.on('end', () => {
|
|
90
|
+
writeFileSync(destination, Buffer.concat(chunks));
|
|
91
|
+
resolve();
|
|
92
|
+
});
|
|
93
|
+
}).on('error', reject);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function run(command, args) {
|
|
98
|
+
const result = spawnSync(command, args, { stdio: 'inherit' });
|
|
99
|
+
if (result.error) throw result.error;
|
|
100
|
+
if (result.status !== 0) throw new Error(`Command failed: ${command}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function extract(archive, destDir) {
|
|
104
|
+
mkdirSync(destDir, { recursive: true });
|
|
105
|
+
if (archive.endsWith('.zip')) {
|
|
106
|
+
if (process.platform === 'win32') {
|
|
107
|
+
run('powershell', ['-NoProfile', '-Command', 'Expand-Archive', '-Force', '-Path', archive, '-DestinationPath', destDir]);
|
|
108
|
+
} else {
|
|
109
|
+
run('unzip', ['-o', archive, '-d', destDir]);
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
run('tar', ['-xJf', archive, '-C', destDir]);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function findExtractedBinary(dir, binName = BIN_NAME) {
|
|
117
|
+
const direct = join(dir, binName);
|
|
118
|
+
if (existsSync(direct)) return direct;
|
|
119
|
+
|
|
120
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
121
|
+
if (!entry.isDirectory()) continue;
|
|
122
|
+
const candidate = join(dir, entry.name, binName);
|
|
123
|
+
if (existsSync(candidate)) return candidate;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
throw new Error(`Archive did not contain ${binName}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function installFromLocalBuild() {
|
|
130
|
+
const localRelease = join(PACKAGE_ROOT, 'target', 'release', BIN_NAME);
|
|
131
|
+
if (!existsSync(localRelease)) return false;
|
|
132
|
+
mkdirSync(BIN_DIR, { recursive: true });
|
|
133
|
+
copyFileSync(localRelease, DEST);
|
|
134
|
+
chmodSync(DEST, 0o755);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function installFromRelease() {
|
|
139
|
+
const target = cargoTarget();
|
|
140
|
+
const archive = artifactName(target);
|
|
141
|
+
const base = releaseBaseUrl();
|
|
142
|
+
const tmpDir = join(BIN_DIR, '.tmp');
|
|
143
|
+
const archivePath = join(tmpDir, archive);
|
|
144
|
+
const checksumPath = `${archivePath}.sha256`;
|
|
145
|
+
|
|
146
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
147
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
148
|
+
await download(`${base}/${archive}`, archivePath);
|
|
149
|
+
await download(`${base}/${archive}.sha256`, checksumPath);
|
|
150
|
+
verifyChecksum(archivePath, readFileSync(checksumPath, 'utf8'));
|
|
151
|
+
extract(archivePath, tmpDir);
|
|
152
|
+
|
|
153
|
+
const extracted = findExtractedBinary(tmpDir);
|
|
154
|
+
mkdirSync(BIN_DIR, { recursive: true });
|
|
155
|
+
copyFileSync(extracted, DEST);
|
|
156
|
+
chmodSync(DEST, 0o755);
|
|
157
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function main() {
|
|
161
|
+
if (installFromLocalBuild()) return;
|
|
162
|
+
await installFromRelease();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (require.main === module) {
|
|
166
|
+
main().catch((error) => {
|
|
167
|
+
console.error(error.message);
|
|
168
|
+
process.exit(1);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = {
|
|
173
|
+
TARGETS,
|
|
174
|
+
artifactName,
|
|
175
|
+
cargoTarget,
|
|
176
|
+
findExtractedBinary,
|
|
177
|
+
platformKey,
|
|
178
|
+
releaseBaseUrl,
|
|
179
|
+
sha256,
|
|
180
|
+
verifyChecksum,
|
|
181
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { request } = require('node:https');
|
|
5
|
+
const { URL } = require('node:url');
|
|
6
|
+
const { artifactName, releaseBaseUrl, TARGETS } = require('./postinstall.cjs');
|
|
7
|
+
|
|
8
|
+
const MAX_REDIRECTS = 5;
|
|
9
|
+
|
|
10
|
+
function packageVersion() {
|
|
11
|
+
return require('../package.json').version;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function expectedReleaseUrls(version = packageVersion()) {
|
|
15
|
+
const base = releaseBaseUrl(version);
|
|
16
|
+
return Object.values(TARGETS).flatMap((target) => {
|
|
17
|
+
const archive = artifactName(target);
|
|
18
|
+
return [`${base}/${archive}`, `${base}/${archive}.sha256`];
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function checkUrl(url, redirectsRemaining = MAX_REDIRECTS) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const req = request(url, { method: 'HEAD' }, (res) => {
|
|
25
|
+
const { statusCode, headers } = res;
|
|
26
|
+
res.resume();
|
|
27
|
+
|
|
28
|
+
if (statusCode >= 300 && statusCode < 400 && headers.location && redirectsRemaining > 0) {
|
|
29
|
+
const nextUrl = new URL(headers.location, url).toString();
|
|
30
|
+
checkUrl(nextUrl, redirectsRemaining - 1).then((result) => resolve({ ...result, url }));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
resolve({
|
|
35
|
+
url,
|
|
36
|
+
ok: statusCode >= 200 && statusCode < 300,
|
|
37
|
+
statusCode,
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
req.on('error', (error) => {
|
|
42
|
+
resolve({ url, ok: false, errorMessage: error.message });
|
|
43
|
+
});
|
|
44
|
+
req.end();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function retryCountFromEnv() {
|
|
49
|
+
return Number.parseInt(process.env.WILLH_NOW_RELEASE_ASSET_RETRIES ?? '1', 10);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function retryDelayMsFromEnv() {
|
|
53
|
+
return Number.parseInt(process.env.WILLH_NOW_RELEASE_ASSET_RETRY_DELAY_MS ?? '1000', 10);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sleep(ms) {
|
|
57
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function formatFailure(result) {
|
|
61
|
+
const reason = result.statusCode ? `HTTP ${result.statusCode}` : result.errorMessage;
|
|
62
|
+
return `- ${result.url} (${reason})`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function verifyReleaseAssets({
|
|
66
|
+
version = packageVersion(),
|
|
67
|
+
check = checkUrl,
|
|
68
|
+
retries = retryCountFromEnv(),
|
|
69
|
+
retryDelayMs = retryDelayMsFromEnv(),
|
|
70
|
+
} = {}) {
|
|
71
|
+
const urls = expectedReleaseUrls(version);
|
|
72
|
+
let failures = [];
|
|
73
|
+
|
|
74
|
+
for (let attempt = 1; attempt <= retries; attempt += 1) {
|
|
75
|
+
const results = await Promise.all(
|
|
76
|
+
urls.map(async (url) => ({
|
|
77
|
+
url,
|
|
78
|
+
...(await check(url)),
|
|
79
|
+
})),
|
|
80
|
+
);
|
|
81
|
+
failures = results.filter((result) => !result.ok);
|
|
82
|
+
if (failures.length === 0) return urls;
|
|
83
|
+
if (attempt < retries) await sleep(retryDelayMs);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
throw new Error(
|
|
87
|
+
[
|
|
88
|
+
`Missing or unavailable release assets for v${version}:`,
|
|
89
|
+
...failures.map(formatFailure),
|
|
90
|
+
'Create and host the GitHub release assets before publishing npm.',
|
|
91
|
+
].join('\n'),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function main() {
|
|
96
|
+
const version = packageVersion();
|
|
97
|
+
const urls = await verifyReleaseAssets({ version });
|
|
98
|
+
console.log(`Verified ${urls.length} release assets for v${version}.`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (require.main === module) {
|
|
102
|
+
main().catch((error) => {
|
|
103
|
+
console.error(error.message);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = {
|
|
109
|
+
checkUrl,
|
|
110
|
+
expectedReleaseUrls,
|
|
111
|
+
formatFailure,
|
|
112
|
+
retryCountFromEnv,
|
|
113
|
+
retryDelayMsFromEnv,
|
|
114
|
+
verifyReleaseAssets,
|
|
115
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@willh/now",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Deploy static sites with provider CLIs",
|
|
5
|
+
"bin": {
|
|
6
|
+
"now": "npm/cli.cjs"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"postinstall": "node npm/postinstall.cjs",
|
|
10
|
+
"test": "node --test tests/postinstall.test.cjs",
|
|
11
|
+
"prepublishOnly": "npm test && npm pack --dry-run && node npm/prepublish-check.cjs"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"npm/cli.cjs",
|
|
15
|
+
"npm/postinstall.cjs",
|
|
16
|
+
"npm/prepublish-check.cjs",
|
|
17
|
+
"tests/postinstall.test.cjs",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/doggy8088/now.git"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"deploy",
|
|
27
|
+
"static-site",
|
|
28
|
+
"rust",
|
|
29
|
+
"cli"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
},
|
|
34
|
+
"author": "",
|
|
35
|
+
"license": "MIT"
|
|
36
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } = require('node:fs');
|
|
5
|
+
const { tmpdir } = require('node:os');
|
|
6
|
+
const { join } = require('node:path');
|
|
7
|
+
const { spawnSync } = require('node:child_process');
|
|
8
|
+
const test = require('node:test');
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
artifactName,
|
|
12
|
+
cargoTarget,
|
|
13
|
+
platformKey,
|
|
14
|
+
releaseBaseUrl,
|
|
15
|
+
sha256,
|
|
16
|
+
verifyChecksum,
|
|
17
|
+
} = require('../npm/postinstall.cjs');
|
|
18
|
+
|
|
19
|
+
test('maps supported platforms to Rust targets', () => {
|
|
20
|
+
assert.equal(platformKey('darwin', 'arm64'), 'darwin-arm64');
|
|
21
|
+
assert.equal(cargoTarget('darwin', 'arm64'), 'aarch64-apple-darwin');
|
|
22
|
+
assert.equal(cargoTarget('darwin', 'x64'), 'x86_64-apple-darwin');
|
|
23
|
+
assert.equal(cargoTarget('linux', 'x64'), 'x86_64-unknown-linux-gnu');
|
|
24
|
+
assert.equal(cargoTarget('win32', 'x64'), 'x86_64-pc-windows-msvc');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('rejects unsupported platforms', () => {
|
|
28
|
+
assert.throws(() => cargoTarget('linux', 'arm'), /Unsupported platform/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('formats artifact names and release URLs', () => {
|
|
32
|
+
assert.equal(artifactName('x86_64-unknown-linux-gnu'), 'now-x86_64-unknown-linux-gnu.tar.xz');
|
|
33
|
+
assert.equal(artifactName('x86_64-pc-windows-msvc'), 'now-x86_64-pc-windows-msvc.zip');
|
|
34
|
+
assert.equal(releaseBaseUrl('1.2.3'), 'https://github.com/doggy8088/now/releases/download/v1.2.3');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('verifies sha256 checksums', () => {
|
|
38
|
+
const dir = mkdtempSync(join(tmpdir(), 'now-'));
|
|
39
|
+
const file = join(dir, 'sample.txt');
|
|
40
|
+
writeFileSync(file, 'hello');
|
|
41
|
+
const digest = sha256(file);
|
|
42
|
+
verifyChecksum(file, `${digest} sample.txt`);
|
|
43
|
+
assert.throws(() => verifyChecksum(file, '0'.repeat(64)), /Checksum mismatch/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('wrapper invokes installed binary on Unix-like systems', { skip: process.platform === 'win32' }, () => {
|
|
47
|
+
const binDir = join(__dirname, '..', 'npm', 'now-bin');
|
|
48
|
+
const bin = join(binDir, 'now');
|
|
49
|
+
rmSync(binDir, { recursive: true, force: true });
|
|
50
|
+
mkdirSync(binDir, { recursive: true });
|
|
51
|
+
writeFileSync(bin, '#!/bin/sh\nprintf "wrapper:%s\\n" "$1"\n');
|
|
52
|
+
chmodSync(bin, 0o755);
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const result = spawnSync(process.execPath, [join(__dirname, '..', 'npm', 'cli.cjs'), 'ok'], {
|
|
56
|
+
encoding: 'utf8',
|
|
57
|
+
});
|
|
58
|
+
assert.equal(result.status, 0, result.stderr);
|
|
59
|
+
assert.match(result.stdout, /wrapper:ok/);
|
|
60
|
+
} finally {
|
|
61
|
+
rmSync(binDir, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
});
|