thurview 0.20.0 → 0.22.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/README.md +11 -6
- package/dist/cli.js +141 -0
- package/dist/cli.js.map +1 -1
- package/dist/export.js +1 -0
- package/dist/export.js.map +1 -1
- package/dist/forge/github.js +41 -0
- package/dist/forge/github.js.map +1 -1
- package/dist/forge/gitlab.js +84 -23
- package/dist/forge/gitlab.js.map +1 -1
- package/dist/forge/types.js +4 -0
- package/dist/forge/types.js.map +1 -1
- package/dist/pr-review/categories.js +51 -0
- package/dist/pr-review/categories.js.map +1 -0
- package/dist/pr-review/follow.js +208 -0
- package/dist/pr-review/follow.js.map +1 -0
- package/dist/pr-review/format.js +227 -0
- package/dist/pr-review/format.js.map +1 -0
- package/package.json +1 -1
- package/skills/thurview/SKILL.md +4 -0
- package/skills/thurview-pr-review/SKILL.md +181 -0
- package/skills/thurview-pr-review/references/forges.md +47 -0
- package/skills/thurview-publish/SKILL.md +492 -0
- package/skills/thurview-publish/scripts/check-scope.mjs +161 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: thurview-publish
|
|
3
|
+
description: Publish or update a thurview review, explainer or design as one self-contained HTML page in the user's own cloud - an Azure Storage container, an AWS S3 bucket, a Google Cloud Storage bucket or a Cloudflare Pages project - through the user's own CLI and login, at one stable unguessable path per document, and hand back a link that expires (an Azure SAS, an S3 presigned URL, a GCS signed URL) unless the user confirms a public copy. Refuses a target whose allow_remotes or deny_remotes rule out the repository, so a personal target never receives work code. Also re-signs a link, and takes a copy down. Use when the user asks to publish, upload, host, share a link to or update a published thurview document, to re-sign or refresh its link, or to unpublish it, or invokes /thurview-publish. Not for authoring the document, which is the thurview, thurview-explain or thurview-design skill.
|
|
4
|
+
user-invocable: true
|
|
5
|
+
argument-hint: "<review id> [--provider azure|aws|gcp|cloudflare] [--days N] [--public] [--resign | --unpublish]"
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# thurview publish
|
|
9
|
+
|
|
10
|
+
Put a thurview document where someone without thurview can open it, in the
|
|
11
|
+
user's own cloud, and give them a link that stops working on its own.
|
|
12
|
+
|
|
13
|
+
```mermaid
|
|
14
|
+
flowchart LR
|
|
15
|
+
S{repository's remotes vs the target's scope} -->|allowed, or the user said yes| A[thurview export]
|
|
16
|
+
S -->|denied| X[refuse, and say why]
|
|
17
|
+
A --> B[index.html]
|
|
18
|
+
B --> C{provider}
|
|
19
|
+
C -->|azure / aws / gcp| D[private object at prefix/slug/index.html]
|
|
20
|
+
D --> E[signed link: 7 days at most, 12 hours on GCP]
|
|
21
|
+
C -->|cloudflare| F[Pages branch named slug]
|
|
22
|
+
F --> G[the alias wrangler prints]
|
|
23
|
+
C -.->|only after 'This will be public' and a yes| H[public copy, noindex]
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Four rules hold on every provider:
|
|
27
|
+
|
|
28
|
+
- **The user's own target, and only for code it is meant for.** A target
|
|
29
|
+
belongs to its user, and some are personal only - never for work or
|
|
30
|
+
company code. Before exporting anything, run the
|
|
31
|
+
[scope check](#0-check-the-target-may-take-this-repository): refuse on a
|
|
32
|
+
deny match, or on no allow match when the target has an allow list, and
|
|
33
|
+
say which remote and pattern decided it. With no scope configured, ask the
|
|
34
|
+
user whether this target is right for this repository, and wait for a yes.
|
|
35
|
+
- **The user's own login, never a secret.** Every command below runs as the
|
|
36
|
+
identity the user's CLI is already [signed in](#signing-in) as. Never ask
|
|
37
|
+
for, store, paste or pass an account key, connection string, secret access
|
|
38
|
+
key, service-account key file or API token, and never set one in the
|
|
39
|
+
environment yourself. If the CLI is not signed in, stop and tell the user
|
|
40
|
+
which login command to run - do not work around it.
|
|
41
|
+
- **Ask before the first upload.** Name the target, its provider, the bucket
|
|
42
|
+
or container, the object path, the expiry and whether the reader's threads
|
|
43
|
+
go in, and wait for a yes. An update of a copy the user already published
|
|
44
|
+
needs no second yes unless the target or the visibility changes.
|
|
45
|
+
- **Private, with a link that expires, by default.** The object stays private
|
|
46
|
+
and the link you hand back is signed for `expiry_days` (default 7), clamped
|
|
47
|
+
to the cloud's own limit: 7 days on Azure and AWS, 12 hours on GCP. A
|
|
48
|
+
public copy is opt-in only: see
|
|
49
|
+
[Public copies](#public-copies-only-on-confirmation).
|
|
50
|
+
|
|
51
|
+
## Signing in
|
|
52
|
+
|
|
53
|
+
Interactive login is the default: it is how a person publishes from their
|
|
54
|
+
own machine, and the CLI keeps the session in its own user config, not in
|
|
55
|
+
anything thurview or the repository holds. Environment credentials are only
|
|
56
|
+
for a non-interactive run - CI, a scheduled job - where whoever owns that
|
|
57
|
+
environment sets them; the skill never does.
|
|
58
|
+
|
|
59
|
+
| Provider | Interactive, the default | Non-interactive only |
|
|
60
|
+
| ---------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------- |
|
|
61
|
+
| Azure | `az login` | `az login --identity` (a managed identity), or a federated service principal the pipeline signs in |
|
|
62
|
+
| AWS | `aws sso login --profile <profile>` | the role the environment provides: an instance or task role, or web identity (`AWS_ROLE_ARN` with `AWS_WEB_IDENTITY_TOKEN_FILE`) |
|
|
63
|
+
| GCP | `gcloud auth login` | the attached service account, or workload identity federation (`gcloud auth login --cred-file=<config>`, no key) |
|
|
64
|
+
| Cloudflare | `wrangler login` (OAuth, kept in wrangler's own user config) | `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`, set in that environment by its owner |
|
|
65
|
+
|
|
66
|
+
## The config
|
|
67
|
+
|
|
68
|
+
The settings live in `${THURVIEW_HOME:-$HOME/.thurview}/publish.yaml`, the
|
|
69
|
+
user's own file beside thurview's review store - never in a repository, and
|
|
70
|
+
never in the exported page. Read it before anything else. When it is missing,
|
|
71
|
+
ask the user for their target and its scope, write it, and show it to them.
|
|
72
|
+
|
|
73
|
+
A user can have several targets - a personal one, a work one - each with its
|
|
74
|
+
own provider and its own scope. This one keeps a personal Cloudflare project
|
|
75
|
+
for the user's own repositories, never a company's, and a work Azure account
|
|
76
|
+
for the company's code only. A deny always wins over an allow:
|
|
77
|
+
|
|
78
|
+
```yaml
|
|
79
|
+
default: personal # the target used when the user names none
|
|
80
|
+
prefix: thurview # every copy goes under <prefix>/<slug>/index.html
|
|
81
|
+
expiry_days: 7 # whole days, 1 or more; clamped to 7 on Azure and AWS, 12 hours on GCP
|
|
82
|
+
targets:
|
|
83
|
+
personal:
|
|
84
|
+
provider: cloudflare
|
|
85
|
+
project: my-reviews
|
|
86
|
+
allow_remotes: # only my own repositories...
|
|
87
|
+
- github.com/example-user/**
|
|
88
|
+
deny_remotes: # ...and never the company's code, even under a name I own
|
|
89
|
+
- gitlab.example.com/**
|
|
90
|
+
- github.com/example-corp/**
|
|
91
|
+
work:
|
|
92
|
+
provider: azure
|
|
93
|
+
account: examplecorpreviews
|
|
94
|
+
container: reviews # a private container, not $web
|
|
95
|
+
allow_remotes: # this account takes the company's code and nothing else
|
|
96
|
+
- gitlab.example.com/**
|
|
97
|
+
aws:
|
|
98
|
+
provider: aws
|
|
99
|
+
bucket: my-review-bucket
|
|
100
|
+
profile: default # the AWS CLI profile or SSO session to use
|
|
101
|
+
region: eu-west-1
|
|
102
|
+
gcp:
|
|
103
|
+
provider: gcp
|
|
104
|
+
bucket: my-review-bucket
|
|
105
|
+
signer: thurview-signer@my-project.iam.gserviceaccount.com # impersonated to sign, never a key file
|
|
106
|
+
published: {} # <review id>: { target, slug, public, url, expires }
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
A target names its `provider` and that provider's fields: `account` and
|
|
110
|
+
`container` for Azure, `bucket`, `profile` and `region` for AWS, `bucket` and
|
|
111
|
+
`signer` for GCP, `project` for Cloudflare. `allow_remotes` and
|
|
112
|
+
`deny_remotes` are globs over a remote's host and path, such as
|
|
113
|
+
`gitlab.example.com/team/**`: `*` stays inside one path segment, `**`
|
|
114
|
+
crosses them, and case does not matter. `published` is how a document keeps
|
|
115
|
+
one path: it maps each review id to the target and slug its copy lives
|
|
116
|
+
under. Write it after every upload, re-sign and unpublish, and update or
|
|
117
|
+
unpublish a copy only on the target it records.
|
|
118
|
+
|
|
119
|
+
Every block from step 1 on reads the config through these shell variables.
|
|
120
|
+
Run each operation - publish (steps 1 to 4), re-sign, unpublish - as **one** script
|
|
121
|
+
file run with `bash`, never typed into an interactive shell, that starts by
|
|
122
|
+
setting them, so no block runs in a shell where they are empty, and stops at
|
|
123
|
+
the first command that fails:
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
set -euo pipefail
|
|
127
|
+
REVIEW="<review id>"
|
|
128
|
+
PROVIDER="<target.provider>" PREFIX="<prefix>" EXPIRY_DAYS="<expiry_days>"
|
|
129
|
+
SLUG="<published.<review id>.slug, or empty for a first upload>"
|
|
130
|
+
ACCOUNT="<target.account>" CONTAINER="<target.container>"
|
|
131
|
+
BUCKET="<target.bucket>" PROFILE="<target.profile>" REGION="<target.region>"
|
|
132
|
+
SIGNER="<target.signer>" PROJECT="<target.project>"
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
For a public Azure copy, `CONTAINER` is `'$web'` instead - see
|
|
136
|
+
[Public copies](#public-copies-only-on-confirmation).
|
|
137
|
+
|
|
138
|
+
## Workflow
|
|
139
|
+
|
|
140
|
+
### 0. Check the target may take this repository
|
|
141
|
+
|
|
142
|
+
Before exporting or uploading anything - and again before every update,
|
|
143
|
+
since a repository's remotes can change - hold the review's repository to
|
|
144
|
+
the target's scope. Run it **on its own, before the publish script**, not
|
|
145
|
+
inside it, so its answer decides whether that script runs at all. The
|
|
146
|
+
repository is the review's worktree: `thurview info --all --fields worktree`
|
|
147
|
+
lists it beside the review's id. The script sits in this skill's own
|
|
148
|
+
directory; pass one `--deny` per `deny_remotes` entry and one `--allow` per
|
|
149
|
+
`allow_remotes` entry of the chosen target:
|
|
150
|
+
|
|
151
|
+
```sh
|
|
152
|
+
node "<this skill's directory>/scripts/check-scope.mjs" --repo "<the review's worktree>" \
|
|
153
|
+
--deny '<a deny_remotes entry>' --allow '<an allow_remotes entry>'
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
It checks every fetch and push URL of every remote, reduced to host and
|
|
157
|
+
path, and an ssh alias also by the host `ssh -G` says it names. It prints one
|
|
158
|
+
line per URL saying what decided it. Its exit code decides what happens
|
|
159
|
+
next:
|
|
160
|
+
|
|
161
|
+
| Exit | Meaning | Do |
|
|
162
|
+
| ---- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
163
|
+
| 0 | allowed: no remote denied, and every one allowed when a list is set | run the publish script |
|
|
164
|
+
| 1 | refused: a remote is denied, or matches no allow pattern | stop. Tell the user the remote and the pattern it printed, and that this target is not for this repository. Do not offer a way round it; the user changes the config if it is wrong |
|
|
165
|
+
| 2 | nothing to decide by: no scope set, no remote, no repository, a local-path remote | ask the user whether this target is right for this repository, naming its remotes; run the publish script only on a yes, and suggest adding a scope so the question does not come back |
|
|
166
|
+
|
|
167
|
+
A deny list only refuses the hosts and paths it names. For a personal target,
|
|
168
|
+
an `allow_remotes` list of the user's own namespaces is the stronger guard:
|
|
169
|
+
work code under a name nobody thought to deny is refused too.
|
|
170
|
+
|
|
171
|
+
### 1. Export the page
|
|
172
|
+
|
|
173
|
+
```sh
|
|
174
|
+
OUT="$(mktemp -d)"
|
|
175
|
+
trap 'rm -rf "$OUT"' EXIT
|
|
176
|
+
thurview export --review "$REVIEW" --out "$OUT" --no-threads
|
|
177
|
+
FILE="$OUT/index.html"
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The page is the one the `thurview` skill's _Sharing a copy that needs no
|
|
181
|
+
server_ section describes: read only, self-contained, fetching nothing,
|
|
182
|
+
naming no server and no local path, and marked `noindex, nofollow`.
|
|
183
|
+
`--no-threads` keeps the reader's threads and decisions out, because a link
|
|
184
|
+
travels further than the conversation it was sent in. Drop it only when the
|
|
185
|
+
user asked for the threads in the copy. The trap deletes `$OUT` when the
|
|
186
|
+
script ends, whether the upload worked or not.
|
|
187
|
+
|
|
188
|
+
### 2. Find or mint the slug
|
|
189
|
+
|
|
190
|
+
`SLUG` is the one `published` recorded for this review, so the upload
|
|
191
|
+
overwrites the same object and the link the user already sent keeps pointing
|
|
192
|
+
at the current copy. A first upload mints one:
|
|
193
|
+
|
|
194
|
+
```sh
|
|
195
|
+
SLUG="${SLUG:-r$(node -e 'console.log(require("node:crypto").randomBytes(12).toString("hex"))')}"
|
|
196
|
+
OBJECT="${PREFIX:?set PREFIX from publish.yaml}/$SLUG/index.html"
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`r` and 24 random hex characters: 96 bits, so a path cannot be guessed from
|
|
200
|
+
the review id, the title or another link, and short, lowercase and
|
|
201
|
+
alphanumeric, so Cloudflare Pages takes it unchanged as a branch alias.
|
|
202
|
+
Never derive the slug from the review id, a branch or the title. The prefix
|
|
203
|
+
is required: the grants below cover `<prefix>/*` and nothing else.
|
|
204
|
+
|
|
205
|
+
### 3. Work out the expiry
|
|
206
|
+
|
|
207
|
+
```sh
|
|
208
|
+
case "${EXPIRY_DAYS:-7}" in
|
|
209
|
+
"" | 0* | *[!0-9]*) echo "expiry_days must be a whole number of days, 1 or more" >&2 && exit 2 ;;
|
|
210
|
+
esac
|
|
211
|
+
MAX_SECONDS=604800
|
|
212
|
+
if [ "$PROVIDER" = gcp ]; then MAX_SECONDS=43200; fi
|
|
213
|
+
EXPIRY_SECONDS=$((${EXPIRY_DAYS:-7} * 86400))
|
|
214
|
+
if [ "$EXPIRY_SECONDS" -gt "$MAX_SECONDS" ]; then EXPIRY_SECONDS=$MAX_SECONDS; fi
|
|
215
|
+
EXPIRY_AT="$(node -e 'console.log(new Date(Date.now() + process.argv[1] * 1000).toISOString().slice(0, 16) + "Z")' "$EXPIRY_SECONDS")"
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
| Provider | Longest link | Why |
|
|
219
|
+
| -------- | ---------------------------- | ----------------------------------------------------------------------- |
|
|
220
|
+
| Azure | 7 days | a user-delegation key, which signs the SAS, lives 7 days at most |
|
|
221
|
+
| AWS | 7 days, or the session's end | SigV4 presigning stops at 604800 s, and at the signing credential's end |
|
|
222
|
+
| GCP | 12 hours | a URL signed by impersonating a service account lives 12 hours at most |
|
|
223
|
+
|
|
224
|
+
When the user asked for more, say it was clamped and to what. On GCP that is
|
|
225
|
+
every request over half a day: say so before the first upload, and point at
|
|
226
|
+
[re-signing](#re-signing-a-link) for a fresh link.
|
|
227
|
+
|
|
228
|
+
### 4. Upload and sign, per provider
|
|
229
|
+
|
|
230
|
+
Run only the provider's own section, then go to step 5.
|
|
231
|
+
|
|
232
|
+
#### Azure Storage
|
|
233
|
+
|
|
234
|
+
```sh
|
|
235
|
+
az storage blob upload --auth-mode login --account-name "$ACCOUNT" \
|
|
236
|
+
--container-name "$CONTAINER" --name "$OBJECT" --file "$FILE" --overwrite \
|
|
237
|
+
--content-type "text/html; charset=utf-8" --content-cache-control "no-cache"
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Then sign it - for a private copy only; a public one's link is the static
|
|
241
|
+
website's, and a SAS on it would be recorded as an expiring link that is not
|
|
242
|
+
the one the reader should get:
|
|
243
|
+
|
|
244
|
+
```sh
|
|
245
|
+
az storage blob generate-sas --auth-mode login --as-user \
|
|
246
|
+
--account-name "$ACCOUNT" --container-name "$CONTAINER" --name "$OBJECT" \
|
|
247
|
+
--permissions r --https-only --expiry "$EXPIRY_AT" --full-uri --output tsv
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
It prints the full link. `--as-user` makes it a
|
|
251
|
+
user-delegation SAS, signed by a key Entra ID issues to the user rather than
|
|
252
|
+
by the account key, so it is revocable and names who signed it. It also ends
|
|
253
|
+
when the delegation key does, which is at most 7 days.
|
|
254
|
+
|
|
255
|
+
#### AWS S3
|
|
256
|
+
|
|
257
|
+
```sh
|
|
258
|
+
aws s3 cp "$FILE" "s3://$BUCKET/$OBJECT" --profile "$PROFILE" --region "$REGION" \
|
|
259
|
+
--content-type "text/html; charset=utf-8" --cache-control "no-cache"
|
|
260
|
+
aws s3 presign "s3://$BUCKET/$OBJECT" --profile "$PROFILE" --region "$REGION" \
|
|
261
|
+
--expires-in "$EXPIRY_SECONDS"
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Presigning is local: the link is signed with the profile's credentials and
|
|
265
|
+
works only while they do. With SSO or an assumed role those are temporary, so
|
|
266
|
+
the link dies with the session, often within hours, and nothing here can read
|
|
267
|
+
when that is without printing the credentials. So for such a profile, record
|
|
268
|
+
`expires` as "when the session ends, at the latest `EXPIRY_AT`", and tell the
|
|
269
|
+
user exactly that; a 7-day link needs a long-lived identity the user chooses
|
|
270
|
+
to use for it. Keep the bucket's Block Public Access on.
|
|
271
|
+
|
|
272
|
+
#### Google Cloud Storage
|
|
273
|
+
|
|
274
|
+
```sh
|
|
275
|
+
gcloud storage cp "$FILE" "gs://$BUCKET/$OBJECT" \
|
|
276
|
+
--content-type="text/html; charset=utf-8" --cache-control="no-cache"
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Then sign it, for a private copy only:
|
|
280
|
+
|
|
281
|
+
```sh
|
|
282
|
+
gcloud storage sign-url "gs://$BUCKET/$OBJECT" --duration="${EXPIRY_SECONDS}s" \
|
|
283
|
+
--impersonate-service-account="$SIGNER"
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
A user's own Google login cannot sign a URL; only a service account can. So
|
|
287
|
+
the link is signed by impersonating the `signer` service account through the
|
|
288
|
+
user's login, which needs no key file. Never fall back to
|
|
289
|
+
`--private-key-file` or an activated service-account key to get a longer
|
|
290
|
+
link: a key file is exactly the stored secret this skill does not use. That
|
|
291
|
+
is what caps a GCS link at 12 hours.
|
|
292
|
+
|
|
293
|
+
#### Cloudflare Pages, public unless behind Access
|
|
294
|
+
|
|
295
|
+
Pages serves every deployment publicly unless the project's preview
|
|
296
|
+
deployments sit behind Cloudflare Access, and it has no signed or expiring
|
|
297
|
+
link. So confirm with the user first, as for any
|
|
298
|
+
[public copy](#public-copies-only-on-confirmation), unless they have told you
|
|
299
|
+
the project's previews are behind Access.
|
|
300
|
+
|
|
301
|
+
wrangler is Cloudflare's own client for Pages' multi-step direct upload, run
|
|
302
|
+
through `npx` at a pinned version (4.147.0, the latest stable on npm when
|
|
303
|
+
this was written; check `npm view wrangler version` and use that). Run it
|
|
304
|
+
from `$OUT`, outside the user's repository, so it does not pick up a
|
|
305
|
+
`wrangler.toml` there, and come back before deleting `$OUT`.
|
|
306
|
+
|
|
307
|
+
```sh
|
|
308
|
+
export WRANGLER_SEND_METRICS=false
|
|
309
|
+
cd "${OUT:?}"
|
|
310
|
+
printf '/*\n X-Robots-Tag: noindex, nofollow\n Referrer-Policy: no-referrer\n Cache-Control: no-cache\n' >_headers
|
|
311
|
+
if ! npx --yes wrangler@4.147.0 pages project list --json |
|
|
312
|
+
node -e 'let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => process.exit(JSON.parse(s).some((p) => p["Project Name"] === process.argv[1]) ? 0 : 1))' "$PROJECT"; then
|
|
313
|
+
npx --yes wrangler@4.147.0 pages project create "$PROJECT" --production-branch production
|
|
314
|
+
fi
|
|
315
|
+
npx --yes wrangler@4.147.0 pages deploy "$OUT" --project-name "$PROJECT" --branch "${SLUG:?}" --commit-dirty=true
|
|
316
|
+
cd - >/dev/null
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
- **The project must exist before `pages deploy`.** Run without a terminal,
|
|
320
|
+
a deploy to a missing project fails, and wrangler can turn `pages deploy`
|
|
321
|
+
into a Workers deploy when it detects an agent and the project is missing.
|
|
322
|
+
So the block creates `$PROJECT` only when the list lacks it - name that in
|
|
323
|
+
the question before the first upload, so the yes covers a new project too.
|
|
324
|
+
Its production branch is one
|
|
325
|
+
nothing is ever deployed to, so the bare project domain stays empty.
|
|
326
|
+
- **One preview branch per document.** The branch is the slug, and a
|
|
327
|
+
redeploy to the same branch updates its alias in place. Hand over the
|
|
328
|
+
**Deployment alias URL** wrangler prints, not the per-deployment
|
|
329
|
+
`<hash>.` one, and never build it from the project name: when the name was
|
|
330
|
+
taken, Pages gave the project a suffixed subdomain, which the list's
|
|
331
|
+
`Project Domains` shows. `*.pages.dev` uses a wildcard certificate, so an
|
|
332
|
+
alias never shows up in Certificate Transparency logs.
|
|
333
|
+
- Pages already sends `X-Robots-Tag: noindex` on preview deployments; the
|
|
334
|
+
`_headers` file adds `nofollow`, `no-referrer` and `no-cache`, and keeps
|
|
335
|
+
them should a deployment ever be promoted.
|
|
336
|
+
- There is no expiry to set, so `expires` stays empty.
|
|
337
|
+
|
|
338
|
+
### 5. Record it and hand it over
|
|
339
|
+
|
|
340
|
+
Write `published.<review id>` with the provider, slug, `public`, the link and
|
|
341
|
+
its expiry, then give the user the link and say when it stops working. Do
|
|
342
|
+
not print the link anywhere else: it is a bearer credential until it
|
|
343
|
+
expires.
|
|
344
|
+
|
|
345
|
+
## Re-signing a link
|
|
346
|
+
|
|
347
|
+
A link expired, or the user wants a fresh one. Nothing is uploaded: set the
|
|
348
|
+
variables, compute `OBJECT` (step 2, with the recorded slug) and the expiry
|
|
349
|
+
(step 3), and run only the signing command of the provider's section in
|
|
350
|
+
step 4. Then record the new link and expiry. A link already sent keeps
|
|
351
|
+
working until its own expiry; Azure can cut it short by revoking the user's
|
|
352
|
+
delegation keys (`az storage account revoke-delegation-keys`), which kills
|
|
353
|
+
every user-delegation SAS on the account.
|
|
354
|
+
|
|
355
|
+
Cloudflare Pages links and public copies do not expire, so there is nothing
|
|
356
|
+
to re-sign.
|
|
357
|
+
|
|
358
|
+
## Unpublishing
|
|
359
|
+
|
|
360
|
+
Set the variables with the recorded slug, compute `OBJECT` (step 2), run the
|
|
361
|
+
provider's own lines, and remove the `published` entry only when they
|
|
362
|
+
succeeded: a copy whose record is gone can no longer be found to take down. For a public Azure
|
|
363
|
+
copy `CONTAINER` is `'$web'`, as it was when it was uploaded.
|
|
364
|
+
|
|
365
|
+
```sh
|
|
366
|
+
az storage blob delete --auth-mode login --account-name "$ACCOUNT" \
|
|
367
|
+
--container-name "$CONTAINER" --name "$OBJECT"
|
|
368
|
+
aws s3 rm "s3://$BUCKET/$OBJECT" --profile "$PROFILE" --region "$REGION"
|
|
369
|
+
gcloud storage rm "gs://$BUCKET/$OBJECT"
|
|
370
|
+
DEPLOYMENTS="$(npx --yes wrangler@4.147.0 pages deployment list --project-name "$PROJECT" --environment preview --json |
|
|
371
|
+
node -e 'let s = ""; process.stdin.on("data", (d) => (s += d)).on("end", () => { for (const d of JSON.parse(s)) if (d.Branch === process.argv[1]) console.log(d.Id); })' "${SLUG:?}")"
|
|
372
|
+
if [ -z "$DEPLOYMENTS" ]; then echo "no deployment of $SLUG listed" >&2 && exit 3; fi
|
|
373
|
+
for DEPLOYMENT in $DEPLOYMENTS; do
|
|
374
|
+
npx --yes wrangler@4.147.0 pages deployment delete "$DEPLOYMENT" --project-name "$PROJECT" --force
|
|
375
|
+
done
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
A signed link to a deleted object stops working at once. On a versioned S3 or
|
|
379
|
+
GCS bucket the delete leaves the older versions behind, readable by anyone
|
|
380
|
+
with access to the bucket; say so when the bucket has versioning on. On
|
|
381
|
+
Cloudflare, a failing list stops the script before anything is deleted or
|
|
382
|
+
forgotten. Exit 3 means the list worked but showed nothing on the slug's
|
|
383
|
+
branch - already deleted, or past the first page: open the alias, and remove
|
|
384
|
+
the entry only once it no longer answers. `--force` is what removes the
|
|
385
|
+
deployment the alias points at, and
|
|
386
|
+
without it a run with no terminal deletes nothing and says nothing. The list
|
|
387
|
+
returns only the first page of results, so on a busy project an older
|
|
388
|
+
deployment of the slug can be missed: tell the user to check the project's
|
|
389
|
+
Deployments tab in the dashboard afterwards.
|
|
390
|
+
|
|
391
|
+
## Public copies, only on confirmation
|
|
392
|
+
|
|
393
|
+
Only when the user asks for a public copy. Before anything, tell them, in
|
|
394
|
+
these words: **"This will be public: anyone with the link can read it, and it
|
|
395
|
+
cannot be made to expire."** Name what is in the page (the code it quotes,
|
|
396
|
+
and threads if they asked for them), and wait for an explicit yes. Record
|
|
397
|
+
`public: true`. The exported page is already `noindex, nofollow`; that keeps
|
|
398
|
+
it out of search results, not out of reach.
|
|
399
|
+
|
|
400
|
+
A copy keeps its visibility. To make a private copy public or the other way
|
|
401
|
+
round, [unpublish](#unpublishing) it, clear its slug, and publish afresh, so
|
|
402
|
+
no stale copy is left behind at the old path or the old visibility.
|
|
403
|
+
|
|
404
|
+
- **Azure**: the static website's `$web` container, once the user has
|
|
405
|
+
confirmed. Enabling it changes the whole account and needs **Storage
|
|
406
|
+
Account Contributor**, so it is the user's call. Then run step 4's upload -
|
|
407
|
+
not its SAS - with the container set to `$web`, which needs Storage Blob
|
|
408
|
+
Data Contributor on `$web` as well:
|
|
409
|
+
|
|
410
|
+
```sh
|
|
411
|
+
az storage blob service-properties update --auth-mode login \
|
|
412
|
+
--account-name "$ACCOUNT" --static-website --index-document index.html
|
|
413
|
+
CONTAINER='$web'
|
|
414
|
+
az storage account show --name "$ACCOUNT" --query primaryEndpoints.web --output tsv
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
The link is that endpoint followed by `$OBJECT`. Updates and unpublishing
|
|
418
|
+
use `'$web'` too.
|
|
419
|
+
|
|
420
|
+
- **AWS**: do not open the bucket. Put CloudFront with Origin Access Control
|
|
421
|
+
in front of it instead, which the user sets up once in their own account;
|
|
422
|
+
then the link is the distribution's domain followed by `/$OBJECT`. Turning
|
|
423
|
+
off Block Public Access with `aws s3api put-public-access-block` exposes
|
|
424
|
+
the whole bucket and is not this skill's to do.
|
|
425
|
+
|
|
426
|
+
- **GCP**: once the user has confirmed, a bucket with fine-grained access
|
|
427
|
+
can make the one object readable, which needs **Storage Object Admin**
|
|
428
|
+
rather than Object User. A
|
|
429
|
+
bucket with uniform access cannot make one object public, only the whole
|
|
430
|
+
bucket or a managed folder, so refuse and say why. Neither works where
|
|
431
|
+
public access prevention is enforced. Run step 4's upload - not its
|
|
432
|
+
signing - and then the grant, and run the grant again after **every**
|
|
433
|
+
update: an upload writes a new object generation with the bucket's default
|
|
434
|
+
ACL, which drops `allUsers`.
|
|
435
|
+
|
|
436
|
+
```sh
|
|
437
|
+
gcloud storage objects update "gs://$BUCKET/$OBJECT" \
|
|
438
|
+
--add-acl-grant=entity=allUsers,role=READER
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
The link is `https://storage.googleapis.com/$BUCKET/$OBJECT`.
|
|
442
|
+
|
|
443
|
+
- **Cloudflare**: the deployment in step 4 is already public.
|
|
444
|
+
|
|
445
|
+
## Setting up, once per provider
|
|
446
|
+
|
|
447
|
+
The smallest grant that runs this skill, which the user applies in their own
|
|
448
|
+
account. A public copy needs more, named in its section above.
|
|
449
|
+
|
|
450
|
+
| Provider | Grant to the user |
|
|
451
|
+
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
452
|
+
| Azure | **Storage Blob Data Contributor** on the container; **Storage Blob Delegator** on the storage account, since the user-delegation key is issued at account scope |
|
|
453
|
+
| AWS | `s3:PutObject`, `s3:GetObject`, `s3:DeleteObject` on `arn:aws:s3:::<bucket>/<prefix>/*`, and nothing on the bucket itself |
|
|
454
|
+
| GCP | **Storage Object User** (`roles/storage.objectUser`) on the bucket; **Service Account Token Creator** on the `signer` service account, which itself holds **Storage Object Viewer** on the bucket |
|
|
455
|
+
| Cloudflare | `wrangler login` (OAuth, as the user). Or a custom API token with only **Account › Cloudflare Pages › Edit** on the one account, which the user sets as `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` in their own environment |
|
|
456
|
+
|
|
457
|
+
To keep Cloudflare copies private, the user puts the project's previews
|
|
458
|
+
behind Cloudflare Access, once, in the dashboard: Workers & Pages › the
|
|
459
|
+
project › Settings › General › **Access policy › Enable**, then **Manage** to
|
|
460
|
+
edit the policy to **Include › Emails** or **Emails ending in**. It defaults
|
|
461
|
+
to members of the Cloudflare account only. It covers preview deployments,
|
|
462
|
+
not the bare project domain or a custom domain, which is one more reason
|
|
463
|
+
never to deploy to production. Before relying on it, open an alias in a
|
|
464
|
+
private window and see the Access login. Access is free for up to 50 users.
|
|
465
|
+
|
|
466
|
+
### Smoke test
|
|
467
|
+
|
|
468
|
+
The user runs this with their own account before publishing a real document:
|
|
469
|
+
it uploads a one-line page under a fixed slug, prints a link, and deletes it.
|
|
470
|
+
Write it as a script file and run it with `bash`, never pasted into an
|
|
471
|
+
interactive shell, where `set -e` and `exit` would close the terminal. The
|
|
472
|
+
script is the variables block, then:
|
|
473
|
+
|
|
474
|
+
```sh
|
|
475
|
+
OUT="$(mktemp -d)"
|
|
476
|
+
trap 'rm -rf "$OUT"' EXIT
|
|
477
|
+
printf '<!doctype html><title>thurview smoke test</title><p>ok\n' >"$OUT/index.html"
|
|
478
|
+
FILE="$OUT/index.html"
|
|
479
|
+
SLUG=rsmoketest
|
|
480
|
+
OBJECT="${PREFIX:?}/$SLUG/index.html"
|
|
481
|
+
EXPIRY_DAYS=1
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
then step 3 and the provider's section of step 4. Open the link it prints
|
|
485
|
+
and see `ok`. Then run a second script - the variables block with
|
|
486
|
+
`SLUG=rsmoketest`, step 2, then the provider's lines from
|
|
487
|
+
[Unpublishing](#unpublishing) - and open the link again to see it refused.
|
|
488
|
+
Each script's variables die with it, so a real document published afterwards
|
|
489
|
+
mints its own slug instead of landing at the guessable `rsmoketest`. On
|
|
490
|
+
Cloudflare, use a throwaway project, check `curl -sI` on the alias shows
|
|
491
|
+
`x-robots-tag: noindex, nofollow`, and finish with
|
|
492
|
+
`npx --yes wrangler@4.147.0 pages project delete <project>`.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Is this repository's code allowed on this publish target?
|
|
3
|
+
//
|
|
4
|
+
// node check-scope.mjs --repo <dir> [--allow <glob>]... [--deny <glob>]...
|
|
5
|
+
//
|
|
6
|
+
// Every fetch and push URL of every remote is reduced to host/path - no
|
|
7
|
+
// scheme, user, password, port or .git - and matched against the globs, where
|
|
8
|
+
// `*` stays inside one path segment and `**` crosses them, ignoring case.
|
|
9
|
+
// A deny match on any URL refuses; with an allow list, so does any URL that
|
|
10
|
+
// matches none of it. An ssh alias is also judged by the host `ssh -G` says
|
|
11
|
+
// it names. Exit 0 allowed, 1 refused, 2 nothing to decide by (no scope, no
|
|
12
|
+
// remote, not a repository, a local-path remote) so the user must be asked,
|
|
13
|
+
// 64 misuse.
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
|
|
16
|
+
const USAGE = "usage: check-scope.mjs --repo <dir> [--allow <glob>]... [--deny <glob>]...";
|
|
17
|
+
|
|
18
|
+
function parse(argv) {
|
|
19
|
+
const opts = { repo: ".", allow: [], deny: [] };
|
|
20
|
+
for (let i = 0; i < argv.length; i += 2) {
|
|
21
|
+
const [flag, value] = [argv[i], argv[i + 1]];
|
|
22
|
+
if (value === undefined || !["--repo", "--allow", "--deny"].includes(flag)) {
|
|
23
|
+
console.error(`unknown or incomplete flag: ${flag}\n${USAGE}`);
|
|
24
|
+
process.exit(64);
|
|
25
|
+
}
|
|
26
|
+
if (flag === "--repo") opts.repo = value;
|
|
27
|
+
else opts[flag.slice(2)].push(value);
|
|
28
|
+
}
|
|
29
|
+
return opts;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Where a remote URL points, as git reads it: a URL with a scheme, the scp
|
|
34
|
+
* form when a `:` comes before any `/`, or else a local path. Lowercased, with
|
|
35
|
+
* the user, password, port, query, fragment, `.git` and stray slashes gone.
|
|
36
|
+
*/
|
|
37
|
+
function parseRemote(url) {
|
|
38
|
+
const u = url.trim().toLowerCase();
|
|
39
|
+
const scheme = /^([a-z][a-z0-9+.-]*):\/\/([^/]*)(.*)$/.exec(u);
|
|
40
|
+
const scp = /^(?:[^/]*@)?(\[[^\]/]*\]|[^/:[\]]+):(.*)$/.exec(u);
|
|
41
|
+
let host;
|
|
42
|
+
let path;
|
|
43
|
+
let ssh = true;
|
|
44
|
+
if (scheme) {
|
|
45
|
+
if (scheme[1] === "file") return { local: true, where: tidy(scheme[2] + scheme[3]) };
|
|
46
|
+
const authority = scheme[2].slice(scheme[2].lastIndexOf("@") + 1);
|
|
47
|
+
host = /^(\[[^\]]*\]|[^:]*)/.exec(authority)[1];
|
|
48
|
+
path = scheme[3];
|
|
49
|
+
ssh = scheme[1].includes("ssh");
|
|
50
|
+
} else if (scp) [, host, path] = scp;
|
|
51
|
+
else return { local: true, where: tidy(u) };
|
|
52
|
+
return {
|
|
53
|
+
local: false,
|
|
54
|
+
host: host.replace(/[?#].*$/, "").replace(/\.$/, ""),
|
|
55
|
+
path: tidy(path),
|
|
56
|
+
ssh,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function tidy(path) {
|
|
61
|
+
return path
|
|
62
|
+
.replace(/[?#].*$/, "")
|
|
63
|
+
.replace(/\/+$/, "")
|
|
64
|
+
.replace(/\.git$/, "")
|
|
65
|
+
.replace(/\/{2,}/g, "/")
|
|
66
|
+
.replace(/^\/+|\/+$/g, "");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The host an ssh alias stands for, as `ssh -G` resolves it without connecting. */
|
|
70
|
+
function sshHostname(alias) {
|
|
71
|
+
try {
|
|
72
|
+
const out = execFileSync("ssh", ["-G", alias], {
|
|
73
|
+
encoding: "utf8",
|
|
74
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
75
|
+
timeout: 5000,
|
|
76
|
+
});
|
|
77
|
+
return /^hostname (.+)$/m.exec(out)?.[1]?.trim().toLowerCase().replace(/\.$/, "") ?? alias;
|
|
78
|
+
} catch {
|
|
79
|
+
return alias;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Every host/path a remote URL can be judged by: an ssh alias and what it names. */
|
|
84
|
+
function forms(url) {
|
|
85
|
+
const r = parseRemote(url);
|
|
86
|
+
if (r.local) return { local: true, names: [r.where] };
|
|
87
|
+
const names = [`${r.host}/${r.path}`];
|
|
88
|
+
const real = r.ssh ? sshHostname(r.host) : r.host;
|
|
89
|
+
if (real !== r.host) names.push(`${real}/${r.path}`);
|
|
90
|
+
return { local: false, names };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function globToRegExp(glob) {
|
|
94
|
+
const body = glob
|
|
95
|
+
.toLowerCase()
|
|
96
|
+
.split("**")
|
|
97
|
+
.map((part) =>
|
|
98
|
+
part
|
|
99
|
+
.split("*")
|
|
100
|
+
.map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
101
|
+
.join("[^/]*"),
|
|
102
|
+
)
|
|
103
|
+
.join(".*");
|
|
104
|
+
return new RegExp(`^${body}$`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function git(repo, ...args) {
|
|
108
|
+
return execFileSync("git", ["-C", repo, ...args], { encoding: "utf8" })
|
|
109
|
+
.split("\n")
|
|
110
|
+
.filter(Boolean);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function remoteUrls(repo) {
|
|
114
|
+
const urls = new Map();
|
|
115
|
+
for (const name of git(repo, "remote"))
|
|
116
|
+
for (const url of [
|
|
117
|
+
...git(repo, "remote", "get-url", "--all", name),
|
|
118
|
+
...git(repo, "remote", "get-url", "--push", "--all", name),
|
|
119
|
+
]) {
|
|
120
|
+
const f = forms(url);
|
|
121
|
+
urls.set(`${name} ${f.names.join(" ")}`, { name, ...f });
|
|
122
|
+
}
|
|
123
|
+
return [...urls.values()];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const opts = parse(process.argv.slice(2));
|
|
127
|
+
if (opts.allow.length === 0 && opts.deny.length === 0) {
|
|
128
|
+
console.log("no scope: this target names no allow_remotes or deny_remotes - ask the user");
|
|
129
|
+
process.exit(2);
|
|
130
|
+
}
|
|
131
|
+
let remotes;
|
|
132
|
+
try {
|
|
133
|
+
remotes = remoteUrls(opts.repo);
|
|
134
|
+
} catch {
|
|
135
|
+
console.log(`not a git repository: ${opts.repo} - ask the user`);
|
|
136
|
+
process.exit(2);
|
|
137
|
+
}
|
|
138
|
+
if (remotes.length === 0) {
|
|
139
|
+
console.log("no remote to judge this repository by - ask the user");
|
|
140
|
+
process.exit(2);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const deny = opts.deny.map((g) => [g, globToRegExp(g)]);
|
|
144
|
+
const allow = opts.allow.map((g) => [g, globToRegExp(g)]);
|
|
145
|
+
let refused = false;
|
|
146
|
+
let unjudged = false;
|
|
147
|
+
for (const { name, names, local } of remotes) {
|
|
148
|
+
// a malformed URL can leave a password fragment in the path; never print it
|
|
149
|
+
const masked = names.map((n) => n.replace(/[^/]*@/g, "…@"));
|
|
150
|
+
const shown = masked.length > 1 ? `${masked[0]} (${masked[1]})` : masked[0];
|
|
151
|
+
const denied = deny.find(([, re]) => names.some((n) => re.test(n)));
|
|
152
|
+
const allowed = allow.length === 0 || allow.some(([, re]) => names.some((n) => re.test(n)));
|
|
153
|
+
if (denied) console.log(`refused: ${name} ${shown} matches deny_remotes ${denied[0]}`);
|
|
154
|
+
else if (local)
|
|
155
|
+
console.log(`unjudged: ${name} ${shown} is a local path, not a host - ask the user`);
|
|
156
|
+
else if (!allowed) console.log(`refused: ${name} ${shown} matches no allow_remotes pattern`);
|
|
157
|
+
else console.log(`allowed: ${name} ${shown}`);
|
|
158
|
+
refused ||= Boolean(denied) || (!local && !allowed);
|
|
159
|
+
unjudged ||= local && !denied;
|
|
160
|
+
}
|
|
161
|
+
process.exit(refused ? 1 : unjudged ? 2 : 0);
|