homebridge-tuya-plus 3.14.0-dev.5 → 3.14.0-dev.6

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.
@@ -1,56 +1,97 @@
1
- name: Publish dev to npm
1
+ name: Publish to npm (dev + PR test builds)
2
2
 
3
- # Publishes commits on `main` to npm under the `dev` dist-tag.
3
+ # This workflow publishes two kinds of UNSTABLE builds to npm. Stable releases
4
+ # always live on the `latest` dist-tag and are cut manually; nothing here ever
5
+ # touches `latest`.
4
6
  #
5
- # Versioning:
6
- # * Dev builds target the NEXT minor release, since releases here are
7
- # usually a minor bump. With package.json at e.g. 3.13.0, dev builds
8
- # are published as 3.14.0-dev.<run-number> (minor +1, patch reset to 0,
9
- # prerelease tag = the GitHub Actions run number). In semver these sort
10
- # below the eventual 3.14.0 release, so `latest` always wins once cut.
11
- # * Release commits are skipped: if a push changes package.json's version
12
- # (i.e. you bumped it to cut a real release and publish to npm
13
- # manually), no dev build is published for that push.
7
+ # 1. Dev builds (automatic) — every push to `main`:
8
+ # * Published under the `dev` dist-tag.
9
+ # * Versioned as <next-minor>.0-dev.<run-number> (e.g. 3.14.0-dev.42).
10
+ # package.json at 3.13.0 -> dev builds target 3.14.0. In semver these
11
+ # sort below the eventual 3.14.0 release, so `latest` always wins.
12
+ # * Release commits are skipped: if a push changes package.json's version
13
+ # (you bumped it to cut a real release), no dev build is published.
14
14
  #
15
- # Security model (why a malicious PR cannot steal publishing rights):
16
- # * No npm token is stored anywhere. Publishing uses npm Trusted
17
- # Publishing (OIDC): GitHub mints a short-lived, cryptographically
18
- # signed token that npm only accepts when its claims match THIS repo +
19
- # THIS workflow file. There is no durable credential to exfiltrate.
20
- # * This workflow never runs on `pull_request`, only on pushes to `main`
21
- # (post-merge) and manual dispatch so fork PRs get nothing.
22
- # * The privileged `publish` job is minimal and isolated: it does not
23
- # run `npm ci`, a build, or tests, and uses `--ignore-scripts`, so no
24
- # repo/dependency code executes while the OIDC permission is present.
25
- # Tests run in a separate, unprivileged job that gates publishing, and
26
- # the version is computed in an unprivileged `prepare` job.
15
+ # 2. PR test builds (manual) a maintainer comments `/publish` on a PR:
16
+ # * Published under a PR-specific `pr-<N>` dist-tag (e.g. pr-57), with a
17
+ # unique version <next-minor>.0-pr.<N>.<run-number> (e.g. 3.14.0-pr.57.43).
18
+ # * Lets you install and try an unmerged PR on real hardware without
19
+ # disturbing `latest` OR `dev`: npm i -g homebridge-tuya-plus@pr-57
20
+ # * Cleanup is a token-free local step: run `npm run cleanup:pr-tags` to
21
+ # delete the `pr-<N>` tags of merged/closed PRs (uses your npm login).
22
+ # OIDC can't manage dist-tags, so this is intentionally not a CI job.
23
+ # * Triggered ONLY by someone with push (write) access to the repo — the
24
+ # owner and any write/maintain/admin collaborator. A drive-by `/publish`
25
+ # from an outside PR author (no push access) does nothing.
27
26
  #
28
- # One-time setup required for this to succeed (see PR/commit notes):
27
+ # Why a separate `pr-<N>` tag instead of reusing `dev`: `dev` is a moving
28
+ # channel users follow to get "latest main". Publishing unmerged PR code there
29
+ # would feed untested code to everyone on `@dev`. A per-PR tag keeps three clear
30
+ # lanes — `latest` (everyone), `dev` (main followers), `pr-<N>` (that one PR).
31
+ #
32
+ # ---------------------------------------------------------------------------
33
+ # Security model (why a malicious PR cannot steal publishing rights)
34
+ # ---------------------------------------------------------------------------
35
+ # * No npm token is stored anywhere. Publishing uses npm Trusted Publishing
36
+ # (OIDC): GitHub mints a short-lived token that npm only accepts when its
37
+ # claims match THIS repo + THIS workflow file (publish-dev.yml) + the
38
+ # `npm-dev` environment. There is no durable credential to exfiltrate.
39
+ # * npm allows only ONE trusted publisher per package, so BOTH paths must run
40
+ # from this file and use `environment: npm-dev` to authenticate. (The PR
41
+ # path needs no extra npm/GitHub setup beyond the existing dev config.)
42
+ # * The `dev` path never runs on `pull_request`; the PR path runs on
43
+ # `issue_comment`, which always uses the workflow file from `main` — a PR
44
+ # cannot alter this file to change what runs.
45
+ # * Untrusted PR code is fenced off from the publishing credential:
46
+ # - `pr-test` checks out and runs the PR's code (npm ci / lint / test)
47
+ # but is UNPRIVILEGED: `contents: read`, no secrets, no id-token, and
48
+ # `persist-credentials: false`. Same blast radius as ordinary fork CI.
49
+ # - `pr-publish` HOLDS the OIDC permission but runs NO PR code: no
50
+ # `npm ci`, no build, no tests, and `--ignore-scripts` on both
51
+ # `npm version` and `npm publish`, so no lifecycle script executes
52
+ # while the credential is present. It only packs + uploads the tarball.
53
+ # - The OIDC token is scoped to THIS package, so a PR that renames the
54
+ # package in package.json cannot hijack a different one (publish fails).
55
+ # - The head SHA is resolved once, up front, and pinned for every later
56
+ # job, so a push landed after `/publish` cannot swap in different code.
57
+ # * Comment/reaction jobs use a write-scoped token but check out NO PR code.
58
+ #
59
+ # ---------------------------------------------------------------------------
60
+ # One-time setup (already in place for the dev path; PR path reuses it):
29
61
  # 1. npmjs.com -> package Settings -> Trusted Publisher:
30
62
  # Publisher: GitHub Actions
31
63
  # Organization/user: adrianjagielak
32
64
  # Repository: homebridge-tuya-plus
33
65
  # Workflow filename: publish-dev.yml
34
66
  # Environment: npm-dev
35
- # 2. GitHub repo Settings -> Environments -> create `npm-dev`,
36
- # restrict deployment branches to `main` (optional: required reviewer).
67
+ # 2. GitHub repo Settings -> Environments -> `npm-dev`, deployment branches
68
+ # restricted to `main` (optional: required reviewer). The PR path runs on
69
+ # `main`'s ref too, so this restriction is satisfied for both paths.
37
70
 
38
71
  on:
39
72
  push:
40
73
  branches: [main]
41
74
  workflow_dispatch:
75
+ # Maintainer types `/publish` on a PR to cut a test build of that PR.
76
+ issue_comment:
77
+ types: [created]
42
78
 
43
- # Least privilege by default; only the publish job opts into id-token.
79
+ # Least privilege by default; jobs opt into more only where needed.
44
80
  permissions:
45
81
  contents: read
46
82
 
47
- # Don't cancel an in-flight publish; let each commit publish its version.
83
+ # Serialize per lane, never cancel an in-flight publish: `publish-dev` for the
84
+ # dev path, `publish-pr-<N>` per PR for the PR path (so they don't block).
48
85
  concurrency:
49
- group: publish-dev
86
+ group: ${{ github.event_name == 'issue_comment' && format('publish-pr-{0}', github.event.issue.number) || 'publish-dev' }}
50
87
  cancel-in-progress: false
51
88
 
52
89
  jobs:
90
+ # ===========================================================================
91
+ # DEV PATH — push to main / manual dispatch. (Behavior unchanged.)
92
+ # ===========================================================================
53
93
  test:
94
+ if: github.event_name != 'issue_comment'
54
95
  runs-on: ubuntu-latest
55
96
  steps:
56
97
  - uses: actions/checkout@v4
@@ -65,6 +106,7 @@ jobs:
65
106
  # computes the dev version. Kept out of the `publish` job so no extra work
66
107
  # runs while the OIDC publishing permission is present.
67
108
  prepare:
109
+ if: github.event_name != 'issue_comment'
68
110
  runs-on: ubuntu-latest
69
111
  outputs:
70
112
  should_publish: ${{ steps.check.outputs.should_publish }}
@@ -113,8 +155,8 @@ jobs:
113
155
 
114
156
  publish:
115
157
  needs: [test, prepare]
116
- # Skip release commits (version bumped in this push).
117
- if: needs.prepare.outputs.should_publish == 'true'
158
+ # Dev path only, and skip release commits (version bumped in this push).
159
+ if: github.event_name != 'issue_comment' && needs.prepare.outputs.should_publish == 'true'
118
160
  runs-on: ubuntu-latest
119
161
  # Bind this environment in repo Settings to the `main` branch so that
120
162
  # only main can ever reach the publish step.
@@ -144,3 +186,236 @@ jobs:
144
186
  # --provenance attaches a signed build attestation (needs a public repo).
145
187
  - name: Publish (dev tag)
146
188
  run: npm publish --tag dev --provenance --ignore-scripts
189
+
190
+ # ===========================================================================
191
+ # PR PATH — maintainer comments `/publish` on a PR. (New.)
192
+ # ===========================================================================
193
+
194
+ # Gate + ack. Runs NO PR code. Verifies the commenter is a maintainer,
195
+ # resolves & pins the PR head, and reads the version base from trusted `main`.
196
+ authorize:
197
+ if: >
198
+ github.event_name == 'issue_comment' &&
199
+ github.event.issue.pull_request &&
200
+ startsWith(github.event.comment.body, '/publish') &&
201
+ contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
202
+ runs-on: ubuntu-latest
203
+ permissions:
204
+ contents: read # checkout main to read the version base
205
+ issues: write # react to / comment on the triggering comment
206
+ pull-requests: write
207
+ outputs:
208
+ authorized: ${{ steps.auth.outputs.authorized }}
209
+ head_sha: ${{ steps.auth.outputs.head_sha }}
210
+ head_repo: ${{ steps.auth.outputs.head_repo }}
211
+ pr_number: ${{ steps.auth.outputs.pr_number }}
212
+ base_version: ${{ steps.basever.outputs.base_version }}
213
+ steps:
214
+ - name: Verify maintainer & resolve PR head
215
+ id: auth
216
+ uses: actions/github-script@v7
217
+ with:
218
+ script: |
219
+ // Require the command to be exactly `/publish` (optional trailing text).
220
+ const body = (context.payload.comment.body || '').trim();
221
+ if (!/^\/publish(?:\s|$)/.test(body)) {
222
+ core.setOutput('authorized', 'false');
223
+ return;
224
+ }
225
+ // Authorization: the commenter must have PUSH (write) access. The
226
+ // REST `permission` field uses legacy roles, so `admin`/`write`
227
+ // covers admin/maintain/write (can push) and excludes triage/read.
228
+ // This is stricter and truer to intent than author_association (a
229
+ // COLLABORATOR could be read-only). The job `if` is only a coarse
230
+ // pre-filter; this API check is the authoritative gate.
231
+ const username = context.payload.comment.user.login;
232
+ let canPush = false;
233
+ try {
234
+ const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({
235
+ owner: context.repo.owner,
236
+ repo: context.repo.repo,
237
+ username,
238
+ });
239
+ canPush = ['admin', 'write'].includes(perm.permission);
240
+ core.info(`${username} permission: ${perm.permission} (role: ${perm.role_name || 'n/a'})`);
241
+ } catch (err) {
242
+ // If the permission API can't be read, fall back to the trusted
243
+ // author_association set so a maintainer is never locked out. This
244
+ // still keeps out drive-by PR authors (CONTRIBUTOR / NONE).
245
+ const assoc = context.payload.comment.author_association;
246
+ canPush = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(assoc);
247
+ core.warning(`Permission API unavailable (${err.status || err.message}); fell back to association ${assoc}.`);
248
+ }
249
+ if (!canPush) {
250
+ core.notice(`Ignoring /publish from ${username}: no push access.`);
251
+ core.setOutput('authorized', 'false');
252
+ return;
253
+ }
254
+ // Resolve the PR's CURRENT head and pin this exact SHA for every
255
+ // later job, so a push landed after `/publish` can't swap in code.
256
+ const { data: pr } = await github.rest.pulls.get({
257
+ owner: context.repo.owner,
258
+ repo: context.repo.repo,
259
+ pull_number: context.payload.issue.number,
260
+ });
261
+ if (!pr.head.repo) {
262
+ core.setFailed('PR head repository is unavailable (fork deleted?).');
263
+ core.setOutput('authorized', 'false');
264
+ return;
265
+ }
266
+ core.setOutput('head_sha', pr.head.sha);
267
+ core.setOutput('head_repo', pr.head.repo.full_name);
268
+ core.setOutput('pr_number', String(pr.number));
269
+ core.setOutput('authorized', 'true');
270
+ // Acknowledge so the maintainer sees the command was accepted.
271
+ await github.rest.reactions.createForIssueComment({
272
+ owner: context.repo.owner,
273
+ repo: context.repo.repo,
274
+ comment_id: context.payload.comment.id,
275
+ content: 'eyes',
276
+ });
277
+
278
+ # Version base comes from TRUSTED main, never the PR's package.json, so an
279
+ # untrusted PR cannot influence the published version.
280
+ - name: Checkout main (trusted)
281
+ if: steps.auth.outputs.authorized == 'true'
282
+ uses: actions/checkout@v4
283
+ with:
284
+ persist-credentials: false
285
+
286
+ - name: Compute version base from main
287
+ id: basever
288
+ if: steps.auth.outputs.authorized == 'true'
289
+ run: |
290
+ NEXT="$(node -e "const v=require('./package.json').version.split('.'); v[1]=Number(v[1])+1; v[2]=0; console.log(v.join('.'))")"
291
+ echo "base_version=$NEXT" >> "$GITHUB_OUTPUT"
292
+ echo "Version base (from main): $NEXT"
293
+
294
+ # Unprivileged gate: runs the PR's code. No secrets, read-only token, no
295
+ # id-token, no persisted git credentials — same trust level as fork CI.
296
+ pr-test:
297
+ needs: authorize
298
+ if: needs.authorize.outputs.authorized == 'true'
299
+ runs-on: ubuntu-latest
300
+ permissions:
301
+ contents: read
302
+ steps:
303
+ - uses: actions/checkout@v4
304
+ with:
305
+ repository: ${{ needs.authorize.outputs.head_repo }}
306
+ ref: ${{ needs.authorize.outputs.head_sha }}
307
+ persist-credentials: false
308
+ - uses: actions/setup-node@v4
309
+ with:
310
+ node-version: "22"
311
+ - run: npm ci
312
+ - run: npm run lint
313
+ - run: npm run test
314
+
315
+ # Privileged publish. Holds the OIDC permission but runs NO PR code: no
316
+ # npm ci, no build, no tests; --ignore-scripts on version + publish.
317
+ pr-publish:
318
+ needs: [authorize, pr-test]
319
+ if: needs.authorize.outputs.authorized == 'true'
320
+ runs-on: ubuntu-latest
321
+ # Must match the trusted publisher's environment (npm allows one publisher
322
+ # per package). issue_comment runs on main's ref, so a main-only branch
323
+ # restriction on this environment is satisfied.
324
+ environment: npm-dev
325
+ permissions:
326
+ contents: read
327
+ id-token: write # OIDC -> npm Trusted Publishing (no stored token)
328
+ outputs:
329
+ version: ${{ steps.ver.outputs.version }}
330
+ tag: ${{ steps.ver.outputs.tag }}
331
+ steps:
332
+ # The PR's code — but we never execute it (see --ignore-scripts below).
333
+ - uses: actions/checkout@v4
334
+ with:
335
+ repository: ${{ needs.authorize.outputs.head_repo }}
336
+ ref: ${{ needs.authorize.outputs.head_sha }}
337
+ persist-credentials: false
338
+
339
+ - uses: actions/setup-node@v4
340
+ with:
341
+ node-version: "22"
342
+ registry-url: "https://registry.npmjs.org"
343
+
344
+ # Trusted Publishing (OIDC) requires npm >= 11.5.1.
345
+ - name: Ensure OIDC-capable npm
346
+ run: npm install -g npm@latest
347
+
348
+ # Unique, immutable version + a PR-specific dist-tag. Base comes from main
349
+ # (via authorize), so the PR's package.json cannot influence versioning.
350
+ - name: Compute PR build version + tag
351
+ id: ver
352
+ env:
353
+ BASE: ${{ needs.authorize.outputs.base_version }}
354
+ PR: ${{ needs.authorize.outputs.pr_number }}
355
+ run: |
356
+ VERSION="${BASE}-pr.${PR}.${GITHUB_RUN_NUMBER}"
357
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
358
+ echo "tag=pr-${PR}" >> "$GITHUB_OUTPUT"
359
+ echo "Will publish $VERSION under dist-tag pr-${PR}"
360
+
361
+ # Writes package.json on the runner only; --ignore-scripts so no PR
362
+ # version/preversion/postversion hook runs with the credential present.
363
+ - name: Set version
364
+ run: npm version "${{ steps.ver.outputs.version }}" --no-git-tag-version --allow-same-version --ignore-scripts
365
+
366
+ # Published under pr-<N>, NEVER latest/dev. No NODE_AUTH_TOKEN: npm
367
+ # exchanges the OIDC id-token for a short-lived, package-scoped credential.
368
+ # --ignore-scripts ensures no PR lifecycle script runs with that credential.
369
+ - name: Publish (pr-<N> tag)
370
+ run: npm publish --tag "${{ steps.ver.outputs.tag }}" --provenance --ignore-scripts
371
+
372
+ # Report the outcome back on the PR. Runs NO PR code.
373
+ notify:
374
+ needs: [authorize, pr-publish]
375
+ if: always() && needs.authorize.outputs.authorized == 'true'
376
+ runs-on: ubuntu-latest
377
+ permissions:
378
+ issues: write
379
+ pull-requests: write
380
+ steps:
381
+ - uses: actions/github-script@v7
382
+ env:
383
+ PUBLISH_RESULT: ${{ needs.pr-publish.result }}
384
+ PR_NUMBER: ${{ needs.authorize.outputs.pr_number }}
385
+ HEAD_SHA: ${{ needs.authorize.outputs.head_sha }}
386
+ VERSION: ${{ needs.pr-publish.outputs.version }}
387
+ TAG: ${{ needs.pr-publish.outputs.tag }}
388
+ with:
389
+ script: |
390
+ const { PUBLISH_RESULT, PR_NUMBER, HEAD_SHA, VERSION, TAG } = process.env;
391
+ const issue_number = Number(PR_NUMBER);
392
+ const comment_id = context.payload.comment.id;
393
+ const { owner, repo } = context.repo;
394
+ const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
395
+ if (PUBLISH_RESULT === 'success') {
396
+ const sha = (HEAD_SHA || '').slice(0, 7);
397
+ const body = [
398
+ `🚀 Published this PR to npm for testing — built from \`${sha}\`.`,
399
+ ``,
400
+ `**Install this exact build:**`,
401
+ '```sh',
402
+ `npm install -g homebridge-tuya-plus@${VERSION}`,
403
+ '```',
404
+ `**…or follow this PR's tag** (always points at this PR's newest \`/publish\` build):`,
405
+ '```sh',
406
+ `npm install -g homebridge-tuya-plus@${TAG}`,
407
+ '```',
408
+ ``,
409
+ `In the Homebridge UI you can also paste \`${VERSION}\` into the plugin's “Install Alternate Version” field.`,
410
+ ``,
411
+ `> ℹ️ Prerelease for testing only — **not** tagged \`latest\` or \`dev\`, so it won't reach normal installs.`,
412
+ ].join('\n');
413
+ await github.rest.issues.createComment({ owner, repo, issue_number, body });
414
+ await github.rest.reactions.createForIssueComment({ owner, repo, comment_id, content: 'rocket' });
415
+ } else {
416
+ await github.rest.issues.createComment({
417
+ owner, repo, issue_number,
418
+ body: `❌ \`/publish\` did not complete — the build, tests, or publish step failed. See the [run logs](${runUrl}).`,
419
+ });
420
+ await github.rest.reactions.createForIssueComment({ owner, repo, comment_id, content: 'confused' });
421
+ }
package/Readme.MD CHANGED
@@ -99,9 +99,23 @@ won't affect normal installs. To try the latest in-development build:
99
99
  sudo npm install -g homebridge-tuya-plus@dev
100
100
  ```
101
101
 
102
- These are versioned like `3.13.1-dev.<n>` and may be unstable; use the default
102
+ These are versioned like `3.14.0-dev.<n>` and may be unstable; use the default
103
103
  install above for production.
104
104
 
105
+ #### Testing a specific pull request:
106
+
107
+ Maintainers can publish an unmerged PR as a throwaway test build by commenting
108
+ `/publish` on it. The build goes to its own `pr-<number>` tag — never `latest`
109
+ or `dev` — so it never reaches normal installs. The bot replies on the PR with
110
+ the exact install command, e.g.:
111
+
112
+ ```
113
+ sudo npm install -g homebridge-tuya-plus@pr-57
114
+ ```
115
+
116
+ Each build also gets a unique, immutable version like `3.14.0-pr.57.<n>` that you
117
+ can pin or paste into the Homebridge UI's "Install Alternate Version" field.
118
+
105
119
  ## Configuration
106
120
  > UI
107
121
 
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "homebridge-tuya-plus",
3
- "version": "3.14.0-dev.5",
3
+ "version": "3.14.0-dev.6",
4
4
  "description": "A community-maintained Homebridge plugin for controlling Tuya devices locally over LAN. Includes new features, fixes, and updated device support.",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
7
  "test": "jest",
8
- "lint": "eslint index.js lib/**.js bin/**.js"
8
+ "lint": "eslint index.js lib/**.js bin/**.js",
9
+ "cleanup:pr-tags": "node scripts/cleanup-pr-tags.js"
9
10
  },
10
11
  "bin": {
11
12
  "tuya-lan": "./bin/cli.js",
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Prune stale `pr-<N>` npm dist-tags created by the `/publish` PR-test-build
6
+ * workflow (.github/workflows/publish-dev.yml).
7
+ *
8
+ * Token-free by design: it shells out to `npm dist-tag`, which uses your
9
+ * existing local npm login — the same one you use to publish releases. No CI
10
+ * secret is involved. (npm's OIDC trusted publishing only covers `npm publish`,
11
+ * not dist-tag management, which is why this is a local maintainer script
12
+ * rather than a CI job.)
13
+ *
14
+ * Default: removes the `pr-<N>` tag for every PR that is MERGED or CLOSED, and
15
+ * keeps tags for PRs that are still open. PR state is read from the public
16
+ * GitHub API (unauthenticated).
17
+ *
18
+ * Usage:
19
+ * npm run cleanup:pr-tags # remove tags for merged/closed PRs
20
+ * npm run cleanup:pr-tags -- --dry-run # preview only, change nothing
21
+ * npm run cleanup:pr-tags -- --all # remove ALL pr-* tags (incl. open)
22
+ *
23
+ * Note: this removes the dist-tag pointer only. The underlying prerelease
24
+ * versions stay published (npm disallows unpublishing after 72h), but they sort
25
+ * below `latest`/`dev` and are never installed by default.
26
+ */
27
+
28
+ const { execFileSync } = require('child_process');
29
+ const path = require('path');
30
+
31
+ const pkgJson = require(path.join(__dirname, '..', 'package.json'));
32
+ const PKG = pkgJson.name;
33
+ const NPM = process.platform === 'win32' ? 'npm.cmd' : 'npm';
34
+
35
+ const args = process.argv.slice(2);
36
+ const DRY_RUN = args.includes('--dry-run') || args.includes('-n');
37
+ const ALL = args.includes('--all');
38
+
39
+ // Derive "owner/repo" from package.json's repository URL.
40
+ function repoSlug() {
41
+ const url = (pkgJson.repository && pkgJson.repository.url) || '';
42
+ const m = url.match(/github\.com[/:]([^/]+)\/(.+?)(?:\.git)?$/i);
43
+ if (!m) throw new Error(`Cannot parse a GitHub repo from repository.url: "${url}"`);
44
+ return `${m[1]}/${m[2]}`;
45
+ }
46
+
47
+ // Parse `npm dist-tag ls <pkg>` output ("pr-57: 3.14.0-pr.57.3") into pr-* tags.
48
+ function listPrTags() {
49
+ let out;
50
+ try {
51
+ out = execFileSync(NPM, ['dist-tag', 'ls', PKG], { encoding: 'utf8' });
52
+ } catch (err) {
53
+ throw new Error(`Failed to list dist-tags for ${PKG}: ${err.message}`);
54
+ }
55
+ const tags = [];
56
+ for (const line of out.split('\n')) {
57
+ const m = line.match(/^(pr-(\d+)):\s*(.+)$/);
58
+ if (m) tags.push({ tag: m[1], pr: Number(m[2]), version: m[3].trim() });
59
+ }
60
+ return tags;
61
+ }
62
+
63
+ async function prState(slug, num) {
64
+ const res = await fetch(`https://api.github.com/repos/${slug}/pulls/${num}`, {
65
+ headers: { Accept: 'application/vnd.github+json', 'User-Agent': `${PKG}-cleanup` },
66
+ });
67
+ if (res.status === 404) return 'unknown';
68
+ if (!res.ok) throw new Error(`GitHub API responded ${res.status}`);
69
+ const data = await res.json();
70
+ return data.merged ? 'merged' : data.state; // 'merged' | 'open' | 'closed'
71
+ }
72
+
73
+ async function main() {
74
+ const slug = repoSlug();
75
+ const tags = listPrTags();
76
+ if (tags.length === 0) {
77
+ console.log(`No pr-* dist-tags on ${PKG}. Nothing to do.`);
78
+ return;
79
+ }
80
+ console.log(`Found ${tags.length} pr-* tag(s) on ${PKG}${DRY_RUN ? ' (dry run)' : ''}:`);
81
+
82
+ let removed = 0;
83
+ for (const { tag, pr, version } of tags) {
84
+ let remove = ALL;
85
+ let reason = 'forced by --all';
86
+ if (!ALL) {
87
+ let state;
88
+ try {
89
+ state = await prState(slug, pr);
90
+ } catch (err) {
91
+ console.log(` • ${tag} -> ${version}: skip (could not check PR #${pr}: ${err.message})`);
92
+ continue;
93
+ }
94
+ remove = state === 'merged' || state === 'closed';
95
+ reason = `PR #${pr} is ${state}`;
96
+ }
97
+
98
+ if (!remove) {
99
+ console.log(` • ${tag} -> ${version}: keep (${reason})`);
100
+ continue;
101
+ }
102
+ if (DRY_RUN) {
103
+ console.log(` • ${tag} -> ${version}: would remove (${reason})`);
104
+ removed++;
105
+ continue;
106
+ }
107
+ try {
108
+ execFileSync(NPM, ['dist-tag', 'rm', PKG, tag], { encoding: 'utf8' });
109
+ console.log(` • ${tag} -> ${version}: removed (${reason})`);
110
+ removed++;
111
+ } catch (err) {
112
+ console.log(` • ${tag}: FAILED to remove (${err.message}). Are you \`npm login\`'d?`);
113
+ }
114
+ }
115
+
116
+ console.log(DRY_RUN ? `Would remove ${removed} tag(s).` : `Removed ${removed} tag(s).`);
117
+ }
118
+
119
+ main().catch((err) => {
120
+ console.error(err.message);
121
+ process.exit(1);
122
+ });