@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 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
+ });