create-grist-widget 0.3.22 → 0.3.24

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 CHANGED
@@ -31,8 +31,10 @@ prompt that asks for both.
31
31
  | `list [--json]` | Print the available starting examples |
32
32
 
33
33
  Common flags for `create`/`init`: `--yes` skips interactive prompts and uses
34
- flags/defaults only; `--no-install` skips the automatic dependency install;
35
- `--pm <name>` picks a specific package manager instead of auto-detecting one.
34
+ flags/defaults only; `--no-install` skips the automatic dependency install
35
+ (which uses pnpm, whichever package manager runs the CLI: the widget's
36
+ workflows only read `pnpm-lock.yaml`); `--pm <name>` installs with another
37
+ package manager instead.
36
38
  `init` additionally takes `--name <name>` (default: the current directory's
37
39
  name) and `--force` (overwrite files that already exist and differ from the
38
40
  template — see below).
@@ -26,11 +26,13 @@ import {
26
26
  } from "node:fs"
27
27
  import path from "node:path"
28
28
  import { fileURLToPath } from "node:url"
29
- import { execFileSync } from "node:child_process"
29
+ import { execFileSync, execSync } from "node:child_process"
30
30
  import * as clack from "@clack/prompts"
31
31
 
32
32
  import { createComposer } from "../vendor/compose-example.mjs"
33
33
  import { checkGitGate } from "../src/git-gate.mjs"
34
+ import { addedAgeExceptions } from "../src/age-exceptions.mjs"
35
+ import { installPlan } from "../src/package-manager.mjs"
34
36
 
35
37
  const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..")
36
38
  const TEMPLATE_DIR = path.join(PKG_ROOT, "template")
@@ -61,14 +63,6 @@ function titleCase(name) {
61
63
  .join(" ")
62
64
  }
63
65
 
64
- function detectPackageManager() {
65
- // e.g. "pnpm/9.1.0 npm-node/..." -> "pnpm". Set by every package manager's
66
- // own `npm create`-equivalent invocation. Falls back to npm, matching
67
- // what a bare `node bin/create-grist-widget.mjs` (no wrapping pm) should do.
68
- const ua = process.env.npm_config_user_agent || ""
69
- return ua.split("/")[0] || "npm"
70
- }
71
-
72
66
  function parseArgs(argv) {
73
67
  const out = { _: [] }
74
68
  for (let i = 0; i < argv.length; i++) {
@@ -101,8 +95,8 @@ Options (create / init):
101
95
  (see "list" for ids)
102
96
  --yes Skip interactive prompts, use defaults/flags only
103
97
  --no-install Don't run the package manager install automatically
104
- --pm <name> Package manager for install (default: detected from how
105
- you invoked this — npm/pnpm/yarn/bun)
98
+ --pm <name> Install with this package manager instead of pnpm (the
99
+ widget's workflows use pnpm and only read its lockfile)
106
100
  --name <name> (init only) Widget name — default: the current directory's name
107
101
  --force (init only) Overwrite files that already exist and differ
108
102
  from the template
@@ -168,18 +162,71 @@ function gitInitCommit(target) {
168
162
  }
169
163
  }
170
164
 
171
- function maybeInstall(target, { noInstall, pm }) {
172
- if (noInstall) return false
173
- const manager = pm || detectPackageManager()
165
+ // Package managers are .cmd shims on Windows, which only a shell starts.
166
+ function runCommand(cmd, args, options) {
167
+ if (process.platform === "win32") return execSync([cmd, ...args].join(" "), options)
168
+ return execFileSync(cmd, args, options)
169
+ }
170
+
171
+ function hasCommand(cmd) {
174
172
  try {
175
- execFileSync(manager, ["install"], { cwd: target, stdio: "inherit" })
173
+ runCommand(cmd, ["--version"], { stdio: "ignore" })
176
174
  return true
177
175
  } catch {
178
- console.warn(`\nCould not run "${manager} install" automatically — run it yourself.`)
179
176
  return false
180
177
  }
181
178
  }
182
179
 
180
+ /** Returns whether the install ran, and why not when it was skipped. */
181
+ function maybeInstall(target, { noInstall, pm }) {
182
+ const plan = installPlan({ noInstall, pm, hasPnpm: () => hasCommand("pnpm") })
183
+ if (plan.skip) return { installed: false, skipped: plan.skip }
184
+ try {
185
+ runCommand(plan.manager, ["install"], { cwd: target, stdio: "inherit" })
186
+ return { installed: true }
187
+ } catch {
188
+ console.warn(`\nCould not run "${plan.manager} install" automatically — run it yourself.`)
189
+ return { installed: false }
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Install (unless --no-install, or pnpm is missing), returning which
195
+ * `minimumReleaseAgeExclude` entries pnpm added to pnpm-workspace.yaml along
196
+ * the way. Runs before the first commit, so the lockfile -- and those
197
+ * entries, without which every later install rejects that lockfile -- are
198
+ * part of it.
199
+ */
200
+ function installBeforeFirstCommit(target, args) {
201
+ const workspaceFile = path.join(target, "pnpm-workspace.yaml")
202
+ const read = () => (existsSync(workspaceFile) ? readFileSync(workspaceFile, "utf8") : "")
203
+ const before = read()
204
+ const { installed, skipped } = maybeInstall(target, {
205
+ noInstall: Boolean(args["no-install"]),
206
+ pm: typeof args.pm === "string" ? args.pm : null,
207
+ })
208
+ return { installed, skipped, ageExceptions: installed ? addedAgeExceptions(before, read()) : [] }
209
+ }
210
+
211
+ function printNoPnpmNote(skipped) {
212
+ if (skipped !== "no-pnpm") return
213
+ console.log(
214
+ "\nNote: dependencies were not installed. This widget uses pnpm (its workflows install with it," +
215
+ "\nand only read pnpm-lock.yaml), and pnpm isn't installed. Install it (https://pnpm.io/installation)," +
216
+ "\nthen run `pnpm install` and commit pnpm-lock.yaml."
217
+ )
218
+ }
219
+
220
+ function printAgeExceptionsNote(ageExceptions) {
221
+ if (ageExceptions.length === 0) return
222
+ console.log(
223
+ `\nNote: ${ageExceptions.join(", ")} came out less than 24 hours ago. pnpm only installs versions` +
224
+ "\nthat recent when told to, so it listed them under minimumReleaseAgeExclude in pnpm-workspace.yaml." +
225
+ "\nThat entry is committed: without it, every later install (yours and CI's) rejects the lockfile." +
226
+ "\nIt only covers the SDK released together with this CLI, and is moot once the version is a day old."
227
+ )
228
+ }
229
+
183
230
  function listFilesRecursive(dir, base = dir) {
184
231
  const out = []
185
232
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -282,15 +329,16 @@ async function cmdCreate(argv) {
282
329
 
283
330
  stampCreateGristWidgetVersion(target)
284
331
 
332
+ // Install first, so the first commit includes the lockfile (pinned, with
333
+ // integrity hashes; pnpm then freezes installs in CI by itself).
334
+ const { installed, skipped, ageExceptions } = installBeforeFirstCommit(target, args)
285
335
  const committed = gitInitCommit(target)
286
- const installed = maybeInstall(target, {
287
- noInstall: Boolean(args["no-install"]),
288
- pm: typeof args.pm === "string" ? args.pm : null,
289
- })
290
336
 
337
+ printAgeExceptionsNote(ageExceptions)
338
+ printNoPnpmNote(skipped)
291
339
  console.log(`\nCreated ${rawName}/${exampleId ? ` (starting from "${exampleId}")` : ""}\n`)
292
340
  console.log(` cd ${rawName}`)
293
- if (!installed) console.log(" pnpm install # or npm/yarn")
341
+ if (!installed) console.log(" pnpm install")
294
342
  console.log(" pnpm dev")
295
343
  if (committed) {
296
344
  console.log("\nGit is initialized on main (first commit done) with a dev branch ready.")
@@ -384,14 +432,14 @@ async function cmdInit(argv) {
384
432
  // git entirely when cwd had none to begin with (a fresh, non-git directory,
385
433
  // or a real git clone where .git already carries whatever history it has).
386
434
  const gitAlreadyExists = existsSync(path.join(cwd, ".git"))
435
+ // Install first, so a first commit includes the lockfile (see cmdCreate).
436
+ const { installed, skipped, ageExceptions } = installBeforeFirstCommit(cwd, args)
387
437
  const committed = gitAlreadyExists ? false : gitInitCommit(cwd)
388
- const installed = maybeInstall(cwd, {
389
- noInstall: Boolean(args["no-install"]),
390
- pm: typeof args.pm === "string" ? args.pm : null,
391
- })
392
438
 
439
+ printAgeExceptionsNote(ageExceptions)
440
+ printNoPnpmNote(skipped)
393
441
  console.log(`\nInitialized ${rawName}/${exampleId ? ` (starting from "${exampleId}")` : ""} in the current directory.\n`)
394
- if (!installed) console.log(" pnpm install # or npm/yarn")
442
+ if (!installed) console.log(" pnpm install")
395
443
  console.log(" pnpm dev")
396
444
  if (gitAlreadyExists) {
397
445
  console.log("\nExisting git repository detected — no commit was made automatically.")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-grist-widget",
3
- "version": "0.3.22",
3
+ "version": "0.3.24",
4
4
  "description": "Scaffold a new Grist custom widget from the grist-widget-sdk Vite template",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,61 @@
1
+ // pnpm 11 won't install a dependency version published less than 24 h ago
2
+ // (`minimumReleaseAge`). When nothing older satisfies a range, its loose mode
3
+ // installs that version anyway and records an exception under
4
+ // `minimumReleaseAgeExclude` in pnpm-workspace.yaml. In practice that is only
5
+ // grist-widget-sdk, which a fresh scaffold pins to the version released with
6
+ // this CLI; every other dependency has an older version in range.
7
+ //
8
+ // A lockfile pinning that version is then rejected by every later install,
9
+ // local or CI, unless the exception stays. So the scaffold's first commit
10
+ // keeps it (docs/planning/2026-11-01-widget-lifecycle-plan.md, D3), and these
11
+ // helpers find the entries pnpm added so the CLI can say why.
12
+
13
+ const KEY = "minimumReleaseAgeExclude"
14
+
15
+ /**
16
+ * Entries listed under `minimumReleaseAgeExclude` in a pnpm-workspace.yaml,
17
+ * in block (`- name@version`) or flow (`[a, b]`) style.
18
+ *
19
+ * @param {string} workspaceYaml
20
+ * @returns {string[]}
21
+ */
22
+ export function ageExceptions(workspaceYaml) {
23
+ const out = []
24
+ let inBlock = false
25
+ for (const line of workspaceYaml.split(/\r?\n/)) {
26
+ const flow = line.match(new RegExp(`^${KEY}:\\s*\\[(.*)\\]\\s*(#.*)?$`))
27
+ if (flow) {
28
+ for (const item of flow[1].split(",")) {
29
+ const entry = item.trim().replace(/^["']|["']$/g, "")
30
+ if (entry) out.push(entry)
31
+ }
32
+ inBlock = false
33
+ continue
34
+ }
35
+ if (new RegExp(`^${KEY}:\\s*(#.*)?$`).test(line)) {
36
+ inBlock = true
37
+ continue
38
+ }
39
+ if (!inBlock) continue
40
+ const item = line.match(/^\s+-\s+["']?([^"'\s#]+)["']?/)
41
+ if (item) {
42
+ out.push(item[1])
43
+ continue
44
+ }
45
+ if (/^\s*(#.*)?$/.test(line)) continue // blank line or comment inside the block
46
+ inBlock = false
47
+ }
48
+ return out
49
+ }
50
+
51
+ /**
52
+ * Exceptions present in `after` but not in `before`.
53
+ *
54
+ * @param {string} before pnpm-workspace.yaml before the install
55
+ * @param {string} after pnpm-workspace.yaml after it
56
+ * @returns {string[]}
57
+ */
58
+ export function addedAgeExceptions(before, after) {
59
+ const had = new Set(ageExceptions(before))
60
+ return ageExceptions(after).filter((entry) => !had.has(entry))
61
+ }
@@ -0,0 +1,19 @@
1
+ // Which package manager installs a new widget's dependencies.
2
+ //
3
+ // The scaffold is a pnpm project: its workflows install with pnpm, and
4
+ // pnpm-workspace.yaml holds settings only pnpm reads (build-script
5
+ // approvals, release-age exceptions). So the CLI installs with pnpm
6
+ // whichever package manager runs it -- `npm create grist-widget` runs it
7
+ // under npm. Installing with npm would commit a package-lock.json that the
8
+ // workflows never read, and skip pnpm's 24 h release-age check.
9
+
10
+ /**
11
+ * @param {{ noInstall?: boolean, pm?: string | null, hasPnpm: () => boolean }} options
12
+ * `pm` is an explicit `--pm`; `hasPnpm` is only called when pnpm is the pick.
13
+ * @returns {{ manager: string } | { skip: "no-install" | "no-pnpm" }}
14
+ */
15
+ export function installPlan({ noInstall, pm, hasPnpm }) {
16
+ if (noInstall) return { skip: "no-install" }
17
+ if (pm) return { manager: pm }
18
+ return hasPnpm() ? { manager: "pnpm" } : { skip: "no-pnpm" }
19
+ }
@@ -24,7 +24,9 @@ jobs:
24
24
  with:
25
25
  node-version: 22
26
26
  # Same as deploy.yml: pnpm caching and a frozen install both need a
27
- # committed pnpm-lock.yaml, which a fresh scaffold doesn't have.
27
+ # committed pnpm-lock.yaml, which a scaffold made with --no-install
28
+ # doesn't have. With one committed, pnpm freezes the install in CI
29
+ # by itself.
28
30
  package-manager-cache: false
29
31
  - run: pnpm install
30
32
  - run: pnpm lint
@@ -23,6 +23,11 @@ name: Deploy widget to GitHub Pages
23
23
  #
24
24
  # All logic lives in scripts/deploy.mjs (node builtins only).
25
25
  #
26
+ # Two jobs, so no dependency code ever runs next to a write token. `build`
27
+ # installs and builds (Vite and its plugins are third-party code) with a
28
+ # read-only token, and hands dist/ over as an artifact. `publish` holds
29
+ # `contents: write` but installs nothing: it only runs scripts/deploy.mjs.
30
+ #
26
31
  # One-time setup this workflow can't do for you: enable Pages (Settings ->
27
32
  # Pages -> Source: Deploy from a branch -> gh-pages -> / (root); this workflow
28
33
  # creates the gh-pages branch itself if missing, but Pages must be pointed at
@@ -45,8 +50,8 @@ on:
45
50
  required: false
46
51
  default: false
47
52
 
48
- permissions:
49
- contents: write
53
+ # Nothing at workflow level: each job states what it needs.
54
+ permissions: {}
50
55
 
51
56
  # Serialize deploys onto the single shared gh-pages branch. deploy.mjs
52
57
  # additionally rebases-and-retries on push, so interleaving runs never clobber.
@@ -55,18 +60,27 @@ concurrency:
55
60
  cancel-in-progress: false
56
61
 
57
62
  jobs:
58
- deploy:
63
+ build:
59
64
  # Build/publish on push + manual dispatch. The delete event is handled by `cleanup`.
60
65
  if: github.event_name != 'delete'
61
66
  runs-on: ubuntu-latest
67
+ permissions:
68
+ contents: read
62
69
  env:
63
70
  REPO_NAME: ${{ github.event.repository.name }}
71
+ outputs:
72
+ context: ${{ steps.plan.outputs.context }}
73
+ skip: ${{ steps.plan.outputs.skip }}
74
+ version: ${{ steps.plan.outputs.version }}
75
+ firstRelease: ${{ steps.plan.outputs.firstRelease }}
64
76
  steps:
65
77
  # Action majors track the monorepo's own workflows (Dependabot watches
66
78
  # this file via the template's directory in .github/dependabot.yml);
67
79
  # the v4s here ran on the deprecated Node 20 action runtime.
68
80
  - name: Checkout
69
81
  uses: actions/checkout@v7
82
+ with:
83
+ persist-credentials: false
70
84
 
71
85
  - name: Setup pnpm
72
86
  uses: pnpm/action-setup@v6
@@ -75,28 +89,33 @@ jobs:
75
89
  uses: actions/setup-node@v7
76
90
  with:
77
91
  node-version: 22
78
- # Never cache: a freshly scaffolded repo has no pnpm-lock.yaml (see
79
- # the install step below), and a pnpm cache hard-requires one.
80
- # Explicit rather than relying on setup-node's npm-only default.
92
+ # Never cache: a pnpm cache hard-requires a committed
93
+ # pnpm-lock.yaml, which a scaffold made with --no-install doesn't
94
+ # have (see the install step below). Explicit rather than relying
95
+ # on setup-node's npm-only default.
81
96
  package-manager-cache: false
82
97
 
83
- # Not --frozen-lockfile, and no `cache: pnpm` above: a freshly
84
- # scaffolded repo never has a committed pnpm-lock.yaml (the CLI's
85
- # auto-commit runs before anyone's first `pnpm install`), and
86
- # --frozen-lockfile/cache: pnpm both hard-require one to exist —
87
- # found live, the very first real deploy failed with "Dependencies
88
- # lock file is not found". A plain install resolves fresh every run
89
- # regardless of whether a lockfile was ever committed.
98
+ # A plain `pnpm install`, not --frozen-lockfile. A scaffold commits
99
+ # pnpm-lock.yaml in its first commit, and with one committed pnpm
100
+ # freezes the install in CI by itself: the versions and integrity
101
+ # hashes in the lockfile are what gets installed. A scaffold made
102
+ # with --no-install has no lockfile until its author commits one, and
103
+ # there --frozen-lockfile (or `cache: pnpm`) fails outright with
104
+ # "Dependencies lock file is not found" (found live, on the very
105
+ # first real deploy); a plain install resolves fresh instead.
90
106
  - name: Install dependencies
91
107
  run: pnpm install
92
108
 
93
- - name: Clone gh-pages into ./site
109
+ - name: Clone gh-pages into ./site (read-only, to plan against)
110
+ env:
111
+ GH_TOKEN: ${{ github.token }}
112
+ REPO: ${{ github.repository }}
94
113
  run: |
95
114
  git clone --depth 1 --branch gh-pages \
96
- "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" site \
115
+ "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" site \
97
116
  || {
98
117
  echo "gh-pages branch not found; creating an orphan."
99
- git clone --depth 1 "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" site
118
+ git clone --depth 1 "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" site
100
119
  git -C site checkout --orphan gh-pages
101
120
  git -C site rm -rf . >/dev/null 2>&1 || true
102
121
  touch site/.nojekyll
@@ -106,12 +125,16 @@ jobs:
106
125
 
107
126
  - name: Resolve deploy plan
108
127
  id: plan
128
+ env:
129
+ EVENT_NAME: ${{ github.event_name }}
130
+ REF: ${{ github.ref }}
131
+ FORCE: ${{ github.event.inputs.force == 'true' && '--force' || '' }}
109
132
  run: |
110
133
  PLAN=$(node scripts/deploy.mjs plan \
111
134
  --site site --repo "$REPO_NAME" \
112
- --event "${{ github.event_name }}" \
113
- --ref "${{ github.ref }}" \
114
- ${{ github.event.inputs.force == 'true' && '--force' || '' }})
135
+ --event "$EVENT_NAME" \
136
+ --ref "$REF" \
137
+ $FORCE)
115
138
  echo "Plan: $PLAN"
116
139
  node -e '
117
140
  const p = JSON.parse(process.argv[1]);
@@ -126,49 +149,100 @@ jobs:
126
149
 
127
150
  - name: Build
128
151
  if: steps.plan.outputs.skip != 'true'
152
+ env:
153
+ BASE: ${{ steps.plan.outputs.base }}
129
154
  run: |
130
155
  pnpm exec tsc -b
131
- pnpm exec vite build --base "${{ steps.plan.outputs.base }}"
156
+ pnpm exec vite build --base "$BASE"
132
157
 
133
- - name: Place build in gh-pages tree
158
+ - name: Hand the build over to the publish job
134
159
  if: steps.plan.outputs.skip != 'true'
160
+ uses: actions/upload-artifact@v7
161
+ with:
162
+ name: dist
163
+ path: dist
164
+ if-no-files-found: error
165
+ # Same files as before the split: upload-artifact skips dotfiles by
166
+ # default, and a widget's public/ may well ship some.
167
+ include-hidden-files: true
168
+ retention-days: 1
169
+
170
+ publish:
171
+ needs: build
172
+ runs-on: ubuntu-latest
173
+ permissions:
174
+ contents: write
175
+ env:
176
+ REPO_NAME: ${{ github.event.repository.name }}
177
+ CONTEXT: ${{ needs.build.outputs.context }}
178
+ VERSION: ${{ needs.build.outputs.version }}
179
+ REF: ${{ github.ref }}
180
+ steps:
181
+ # Credentials stay persisted here: the first-release reset below pushes
182
+ # with them. Nothing in this job installs or runs dependency code.
183
+ - name: Checkout
184
+ uses: actions/checkout@v7
185
+
186
+ - name: Setup Node
187
+ uses: actions/setup-node@v7
188
+ with:
189
+ node-version: 22
190
+ package-manager-cache: false
191
+
192
+ - name: Clone gh-pages into ./site
193
+ env:
194
+ GH_TOKEN: ${{ github.token }}
195
+ REPO: ${{ github.repository }}
196
+ run: |
197
+ git clone --depth 1 --branch gh-pages \
198
+ "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" site \
199
+ || {
200
+ echo "gh-pages branch not found; creating an orphan."
201
+ git clone --depth 1 "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" site
202
+ git -C site checkout --orphan gh-pages
203
+ git -C site rm -rf . >/dev/null 2>&1 || true
204
+ touch site/.nojekyll
205
+ }
206
+ git -C site config user.name "github-actions[bot]"
207
+ git -C site config user.email "41898282+github-actions[bot]@users.noreply.github.com"
208
+
209
+ - name: Download the build
210
+ if: needs.build.outputs.skip != 'true'
211
+ uses: actions/download-artifact@v8
212
+ with:
213
+ name: dist
214
+ path: dist
215
+
216
+ - name: Place build in gh-pages tree
217
+ if: needs.build.outputs.skip != 'true'
135
218
  run: |
136
219
  SHA="$(git rev-parse --short HEAD)"
137
220
  VERSION_ARG=""
138
- [ -n "${{ steps.plan.outputs.version }}" ] && VERSION_ARG="--version ${{ steps.plan.outputs.version }}"
221
+ [ -n "$VERSION" ] && VERSION_ARG="--version $VERSION"
139
222
  node scripts/deploy.mjs place \
140
223
  --site site --repo "$REPO_NAME" \
141
- --channel "${{ steps.plan.outputs.context }}" $VERSION_ARG \
224
+ --channel "$CONTEXT" $VERSION_ARG \
142
225
  --dist dist \
143
- --sha "$SHA" --ref "${{ github.ref }}"
226
+ --sha "$SHA" --ref "$REF"
144
227
 
145
228
  - name: Skip (already published)
146
- if: steps.plan.outputs.skip == 'true'
147
- run: echo "== skip — v${{ steps.plan.outputs.version }} already published"
229
+ if: needs.build.outputs.skip == 'true'
230
+ run: echo "== skip — v${VERSION} already published"
148
231
 
149
232
  - name: Finalize (versions.json + commit + push)
233
+ env:
234
+ SHA_FULL: ${{ github.sha }}
150
235
  run: |
151
236
  UPDATE_VERSIONS=""
152
- if [ "${{ steps.plan.outputs.context }}" = "release" ]; then
237
+ if [ "$CONTEXT" = "release" ]; then
153
238
  UPDATE_VERSIONS="--update-versions"
154
239
  fi
155
240
  node scripts/deploy.mjs finalize \
156
241
  --site site --repo "$REPO_NAME" $UPDATE_VERSIONS --push \
157
- --commit-message "deploy: ${{ steps.plan.outputs.context }} @ ${{ github.sha }}"
158
-
159
- # On this repo's first genuine release only: reset package.json's version
160
- # to 0.0.1 (so the next release bumps to 0.0.2 rather than any inherited
161
- # canary version), prune every branch except main/dev/gh-pages, and
162
- # reset dev to main's current tip. Establishes the state every widget
163
- # should start from regardless of how the repo was created -- a CLI
164
- # scaffold already satisfies it trivially; a repo copied via GitHub's
165
- # "Use this template" often doesn't (an inherited version, a stray
166
- # branch, or dev tracking the source template's own preview history).
167
- # Placed after Finalize so it never runs if the release itself failed.
168
- # The version-reset commit re-triggers this workflow on main, but that
169
- # run sees v0.0.1 already published (firstRelease=false) and no-ops.
242
+ --commit-message "deploy: ${CONTEXT} @ ${SHA_FULL}"
243
+
170
244
  - name: Reset repo to a clean first-release state
171
- if: steps.plan.outputs.context == 'release' && steps.plan.outputs.firstRelease == 'true'
245
+ if: needs.build.outputs.context == 'release' && needs.build.outputs.firstRelease == 'true'
172
246
  run: node scripts/deploy.mjs reset-branches --repo "$REPO_NAME"
173
247
 
174
248
  cleanup:
@@ -176,6 +250,8 @@ jobs:
176
250
  # (404) instead of serving a stale build forever.
177
251
  if: github.event_name == 'delete' && github.event.ref_type == 'branch' && github.event.ref == 'dev'
178
252
  runs-on: ubuntu-latest
253
+ permissions:
254
+ contents: write
179
255
  steps:
180
256
  - name: Checkout (default branch — for scripts/deploy.mjs)
181
257
  uses: actions/checkout@v7
@@ -188,9 +264,12 @@ jobs:
188
264
 
189
265
  - name: Clone gh-pages into ./site (skip if it does not exist)
190
266
  id: clone
267
+ env:
268
+ GH_TOKEN: ${{ github.token }}
269
+ REPO: ${{ github.repository }}
191
270
  run: |
192
271
  if git clone --depth 1 --branch gh-pages \
193
- "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" site; then
272
+ "https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git" site; then
194
273
  git -C site config user.name "github-actions[bot]"
195
274
  git -C site config user.email "41898282+github-actions[bot]@users.noreply.github.com"
196
275
  echo "exists=true" >> "$GITHUB_OUTPUT"
@@ -201,8 +280,10 @@ jobs:
201
280
 
202
281
  - name: Remove dev dir + push
203
282
  if: steps.clone.outputs.exists == 'true'
283
+ env:
284
+ DELETED_REF: ${{ github.event.ref }}
204
285
  run: |
205
286
  node scripts/deploy.mjs remove --site site
206
287
  node scripts/deploy.mjs finalize \
207
288
  --site site --push \
208
- --commit-message "cleanup: retire dev URL for ${{ github.event.ref }}"
289
+ --commit-message "cleanup: retire dev URL for ${DELETED_REF}"
@@ -94,6 +94,9 @@ Full detail, including the two live URLs (`/latest/` and `/v<version>/`): `READM
94
94
  template works (see above); `pnpm test` is the trusted local loop, real Grist via `/dev/`
95
95
  is the trusted real one
96
96
  - Merging `dev` into `main` without bumping `package.json`'s `version` first
97
+ - Adding `minimumReleaseAgeExclude` entries to `pnpm-workspace.yaml` to get an install or a
98
+ check green, or deleting the one pnpm wrote for `grist-widget-sdk` on release day (see
99
+ `README.md`): pnpm's 24 h age check protects the widget's supply chain
97
100
  - Merging a Dependabot pull request whose `ci.yml` checks are red, or retargeting one at
98
101
  `main`: updates go through `dev` like any other change. Template-file updates (this
99
102
  pipeline, the build config) are applied by hand —
@@ -2,11 +2,15 @@
2
2
 
3
3
  Base template to build a Grist widget with `grist-widget-sdk`.
4
4
 
5
+ <!-- install-url:start -->
6
+ **Install URL**: written here by this widget's first release (its GitHub Pages `/latest/` URL).
7
+ <!-- install-url:end -->
8
+
5
9
  `src/main.tsx` wraps the app with `GristWidgetProvider`, `GristBoundary`, and `GristSdkAlerts`. The latter maps `getGristSdkAlertDescriptors()` from the SDK to shadcn `Alert` (`src/components/grist-sdk-alerts.tsx` + `src/components/ui/alert.tsx`); keep them in sync with the playground when you change alert styling.
6
10
 
7
11
  Opened outside a Grist iframe, `main.tsx` picks between two components — never a live preview of the widget itself, since nothing here proves anything about real Grist behavior: local `pnpm dev` renders `LocalDevNotice`, a bare shell pointing at the real verification loop below (push to `dev`, open it in a real Grist document); any built/deployed URL (root, `/latest/`, `/dev/`, `/v<version>/` — all carrying the same build, see Deployment below) renders `ReleaseInfo` instead — which build this is, chips to jump to any other version/channel (hover one to see when it shipped; `src/lib/showcase-versions.ts`), and a copy-this-URL helper for pasting into Grist's custom widget field.
8
12
 
9
- When actually embedded, `GristStatusChip` (`src/components/grist-status-chip.tsx`) shows a small pill with live handshake status — connecting, retrying with a countdown, connected, or unavailable — using `useAmbientGristHandshake()` from `grist-widget-sdk/advanced`. It must be mounted *inside* `GristWidgetProvider` (see `src/main.tsx`): it only observes that provider's own handshake manager and never mounts a second one, so it's purely observational and can never duplicate or race the real handshake (see the [handshake API reference](https://gristwidgets.com/docs/sdk/api/handshake)).
13
+ When actually embedded, the widget shows connection state only when something is wrong: until Grist is connected, or if the connection is lost or fails, `GristBoundary` replaces the widget with a connecting / unavailable / error screen (the error one with a retry button); once the widget is showing, `GristSdkAlerts` reports what needs attention (column mapping, access level, a failed write…). Nothing is drawn over the widget while all is well.
10
14
 
11
15
  `src/App.tsx` uses `useGrist<TaskRow, TaskMapped>()` for the selected row (`w.record`, `w.mode`) and remounts the UI with `key={rowKey}` when the row changes — same pattern as `widgets/create-email-draft`. It ships with two mapped columns (`title`, `done`) and an editable form that saves back via `w.table.update(...)`/`w.mapBack(...)` — the smallest real starting point, not a placeholder. See `src/grist-types.example.ts` for typing patterns.
12
16
 
@@ -34,6 +38,17 @@ This is the **source** template. It is consumed two ways:
34
38
  standalone `pnpm install` (pnpm 11) exits cleanly; inside the monorepo it's
35
39
  ignored (the root workspace governs).
36
40
 
41
+ A scaffold's first commit includes `pnpm-lock.yaml`: the CLI installs with
42
+ pnpm, even under `npm create`, unless it was run with `--no-install` or
43
+ without pnpm installed. Versions are pinned with their integrity hashes, CI
44
+ installs exactly that, and Dependabot updates it. A `package-lock.json` isn't
45
+ used: the workflows install with pnpm. pnpm 11 won't install a version
46
+ published less than 24 hours ago. So a widget scaffolded on the day of an SDK
47
+ release also gets one `minimumReleaseAgeExclude` entry in
48
+ `pnpm-workspace.yaml`, for that SDK version only, written by pnpm itself.
49
+ Keep it: without it, every install (yours and CI's) rejects the lockfile
50
+ until the version is a day old. After that the entry is harmless.
51
+
37
52
  ### Live preview
38
53
 
39
54
  This template has no live-preview deploy of its own inside this monorepo —
@@ -39,11 +39,13 @@
39
39
  // Regenerates versions.json (release runs only) and commits + pushes the
40
40
  // gh-pages tree with a rebase-and-retry loop.
41
41
  //
42
- // reset-branches [--repo-dir <dir>] [--repo <name>]
42
+ // reset-branches [--repo-dir <dir>] [--repo <name>] [--owner-repo <owner/name>]
43
43
  // On a repo's first genuine release only (caller gates on plan()'s
44
44
  // firstRelease): renames the four placeholder-identity spots (only if
45
45
  // --repo is given and they still carry the template's own placeholder
46
- // text -- a no-op for a CLI scaffold that already renamed itself), resets
46
+ // text -- a no-op for a CLI scaffold that already renamed itself), writes
47
+ // the /latest/ install URL into README.md's install-url block (owner/name
48
+ // from --owner-repo, else $GITHUB_REPOSITORY; a no-op without it), resets
47
49
  // package.json's version to 0.0.1, prunes every branch except
48
50
  // main/dev/gh-pages, and force-resets dev to main's current tip.
49
51
 
@@ -407,6 +409,40 @@ export function deriveIdentity(repoName) {
407
409
  return { packageName, title }
408
410
  }
409
411
 
412
+ // README.md's install-URL block. awesome-grist-widgets only registers a
413
+ // widget whose install URL its own repo states explicitly, so the first
414
+ // release writes it there (docs/planning/2026-11-01-widget-lifecycle-plan.md,
415
+ // R1). A README without these markers is left alone.
416
+ const INSTALL_URL_START = "<!-- install-url:start -->"
417
+ const INSTALL_URL_END = "<!-- install-url:end -->"
418
+
419
+ /**
420
+ * The GitHub Pages URL of the `/latest/` channel for `owner/repo`, the same
421
+ * path plan() builds the release under. A `<owner>.github.io` repo is a
422
+ * user/org site, served at the domain root. A custom Pages domain isn't
423
+ * known here: edit the README line if the widget uses one.
424
+ */
425
+ export function latestPagesUrl(ownerRepo) {
426
+ const [owner, repo] = String(ownerRepo || "").split("/")
427
+ if (!owner || !repo) return null
428
+ const host = `${owner.toLowerCase()}.github.io`
429
+ const base = repo.toLowerCase() === host ? `https://${host}/` : `https://${host}/${repo}/`
430
+ return `${base}latest/`
431
+ }
432
+
433
+ /** README text with its install-URL block set to `url`; unchanged without the markers. */
434
+ export function withInstallUrl(readme, url) {
435
+ const start = readme.indexOf(INSTALL_URL_START)
436
+ const end = readme.indexOf(INSTALL_URL_END, start)
437
+ if (start < 0 || end < 0) return readme
438
+ const block = [
439
+ INSTALL_URL_START,
440
+ `**Install URL**, to paste into a Grist custom widget's URL field: <${url}>`,
441
+ INSTALL_URL_END,
442
+ ].join("\n")
443
+ return readme.slice(0, start) + block + readme.slice(end + INSTALL_URL_END.length)
444
+ }
445
+
410
446
  /**
411
447
  * The same four placeholder-identity edits
412
448
  * `packages/create-grist-widget/bin/create-grist-widget.mjs` applies at
@@ -454,7 +490,12 @@ function identityEdits({ packageName, title }) {
454
490
  * a genuine release already published (firstRelease=false) and skips both
455
491
  * the release and this reset, so there's no loop.
456
492
  */
457
- export function resetBranchesIfFirstRelease(repoDir, repo, keep = ["main", "dev", "gh-pages"]) {
493
+ export function resetBranchesIfFirstRelease(
494
+ repoDir,
495
+ repo,
496
+ keep = ["main", "dev", "gh-pages"],
497
+ ownerRepo = process.env.GITHUB_REPOSITORY,
498
+ ) {
458
499
  const changedFiles = new Set()
459
500
  const renamedFiles = []
460
501
 
@@ -473,6 +514,20 @@ export function resetBranchesIfFirstRelease(repoDir, repo, keep = ["main", "dev"
473
514
  }
474
515
  const identityReset = renamedFiles.length > 0
475
516
 
517
+ // The install URL, for registries and for whoever reads the README.
518
+ let installUrl = null
519
+ const readmePath = join(repoDir, "README.md")
520
+ const url = latestPagesUrl(ownerRepo)
521
+ if (url && existsSync(readmePath)) {
522
+ const before = readFileSync(readmePath, "utf8")
523
+ const after = withInstallUrl(before, url)
524
+ if (after !== before) {
525
+ writeFileSync(readmePath, after)
526
+ changedFiles.add("README.md")
527
+ installUrl = url
528
+ }
529
+ }
530
+
476
531
  let versionReset = false
477
532
  const pkgPath = join(repoDir, "package.json")
478
533
  if (existsSync(pkgPath)) {
@@ -490,6 +545,7 @@ export function resetBranchesIfFirstRelease(repoDir, repo, keep = ["main", "dev"
490
545
  if (changedFiles.size > 0) {
491
546
  const parts = []
492
547
  if (identityReset) parts.push("personalize scaffold")
548
+ if (installUrl) parts.push("write install URL")
493
549
  if (versionReset) parts.push("reset version to 0.0.1")
494
550
  git(["add", ...changedFiles], repoDir)
495
551
  git(
@@ -514,7 +570,7 @@ export function resetBranchesIfFirstRelease(repoDir, repo, keep = ["main", "dev"
514
570
  deletedBranches.push(b)
515
571
  }
516
572
  git(["push", "--force", "origin", "HEAD:refs/heads/dev"], repoDir)
517
- return { deletedBranches, devReset: true, versionReset, identityReset, renamedFiles }
573
+ return { deletedBranches, devReset: true, versionReset, identityReset, renamedFiles, installUrl }
518
574
  }
519
575
 
520
576
  // ----------------------------------------------------------------------------
@@ -577,7 +633,12 @@ function main() {
577
633
  break
578
634
  }
579
635
  case "reset-branches": {
580
- const res = resetBranchesIfFirstRelease(args["repo-dir"] || ".", args.repo)
636
+ const res = resetBranchesIfFirstRelease(
637
+ args["repo-dir"] || ".",
638
+ args.repo,
639
+ undefined,
640
+ args["owner-repo"] || process.env.GITHUB_REPOSITORY,
641
+ )
581
642
  console.log("reset-branches:", JSON.stringify(res))
582
643
  break
583
644
  }
@@ -3,7 +3,6 @@ import { createRoot } from "react-dom/client"
3
3
  import { GristBoundary, GristWidgetProvider } from "grist-widget-sdk"
4
4
 
5
5
  import { GristSdkAlerts } from "@/components/grist-sdk-alerts"
6
- import { GristStatusChip } from "@/components/grist-status-chip"
7
6
  import { LocalDevNotice } from "@/components/local-dev-notice"
8
7
  import { ReleaseInfo } from "@/components/release-info"
9
8
  import { ThemeProvider } from "@/components/theme-provider.tsx"
@@ -34,7 +33,6 @@ createRoot(document.getElementById("root")!).render(
34
33
  <ThemeProvider>
35
34
  {isEmbedded ? (
36
35
  <GristWidgetProvider options={GRIST_OPTIONS}>
37
- <GristStatusChip />
38
36
  <GristBoundary
39
37
  gate={GRIST_OPTIONS.columns?.length ? "canRender" : "ready"}
40
38
  >
@@ -1,116 +0,0 @@
1
- import { useEffect, useState } from "react"
2
- import { useAmbientGristHandshake, type GristWidgetSnapshot } from "grist-widget-sdk/advanced"
3
-
4
- import { deriveDisplayStatus, type HandshakeDisplayStatus } from "@/lib/grist-handshake-status"
5
- import { cn } from "@/lib/utils"
6
-
7
- // Fixed, approximate countdown shown while retrying -- not a mirror of the
8
- // SDK's actual internal poll backoff (an implementation detail, not part of
9
- // the public API surface, which ramps up from ~200ms before settling into a
10
- // steady cadence). Most production widgets connect in under a second per the
11
- // SDK's own docs, so a visibly "retrying" chip is already in that settled
12
- // long tail -- this just picks the same round number as a display countdown.
13
- const RETRY_COUNTDOWN_SECONDS = 5
14
-
15
- function useRetryCountdown(attempts: number, active: boolean) {
16
- const [state, setState] = useState({ attempts, secondsLeft: RETRY_COUNTDOWN_SECONDS })
17
-
18
- // Reset the countdown the moment a new retry attempt begins -- an
19
- // "adjust state during render" pattern, not a setState-in-effect (which
20
- // the linter flags): https://react.dev/learn/you-might-not-need-an-effect
21
- if (active && state.attempts !== attempts) {
22
- setState({ attempts, secondsLeft: RETRY_COUNTDOWN_SECONDS })
23
- }
24
-
25
- useEffect(() => {
26
- if (!active) return
27
- const id = setInterval(() => {
28
- setState((s) => ({ ...s, secondsLeft: s.secondsLeft > 0 ? s.secondsLeft - 1 : 0 }))
29
- }, 1000)
30
- return () => clearInterval(id)
31
- }, [attempts, active])
32
-
33
- return state.secondsLeft
34
- }
35
-
36
- function StatusDot({ pulsing, className }: { pulsing: boolean; className: string }) {
37
- return (
38
- <span className="relative flex size-2">
39
- {pulsing && (
40
- <span
41
- className={cn(
42
- "absolute inline-flex size-full animate-ping rounded-full opacity-75",
43
- className,
44
- )}
45
- />
46
- )}
47
- <span className={cn("relative inline-flex size-2 rounded-full", className)} />
48
- </span>
49
- )
50
- }
51
-
52
- /** Pure render of a display status. */
53
- function ChipVisual({ display, secondsLeft }: { display: HandshakeDisplayStatus; secondsLeft: number }) {
54
- switch (display.kind) {
55
- case "connecting":
56
- return (
57
- <>
58
- <StatusDot pulsing className="bg-amber-500" />
59
- Connecting to Grist
60
- </>
61
- )
62
- case "retrying":
63
- return (
64
- <>
65
- <StatusDot pulsing className="bg-amber-500" />
66
- Retry Grist connection in {secondsLeft}s
67
- </>
68
- )
69
- case "connected":
70
- return (
71
- <>
72
- <StatusDot pulsing={false} className="bg-emerald-500" />
73
- Grist connected
74
- </>
75
- )
76
- case "unavailable":
77
- return (
78
- <>
79
- <StatusDot pulsing={false} className="bg-destructive" />
80
- Grist unavailable
81
- </>
82
- )
83
- }
84
- }
85
-
86
- function RealChipContents({ snapshot }: { snapshot: GristWidgetSnapshot }) {
87
- const display = deriveDisplayStatus(snapshot.lifecycle)
88
- const retryAttempts = display.kind === "retrying" ? display.attempts : 0
89
- const secondsLeft = useRetryCountdown(retryAttempts, display.kind === "retrying")
90
- return <ChipVisual display={display} secondsLeft={secondsLeft} />
91
- }
92
-
93
- /**
94
- * Small pill showing live Grist handshake status while actually embedded in
95
- * Grist. Must be mounted *inside* `<GristWidgetProvider>` -- it only observes
96
- * that provider's own handshake manager (`useAmbientGristHandshake()`,
97
- * `grist-widget-sdk/advanced`) and never mounts its own, so it can't ever run
98
- * a second, competing `grist.ready()` call. Renders nothing outside a
99
- * provider.
100
- */
101
- export function GristStatusChip({ className }: { className?: string }) {
102
- const handshake = useAmbientGristHandshake()
103
- if (!handshake) return null
104
- return (
105
- <div className="pointer-events-none fixed top-2 right-2 z-50">
106
- <div
107
- className={cn(
108
- "flex items-center gap-1.5 rounded-full border bg-background/90 px-2.5 py-1 text-xs text-muted-foreground shadow-sm backdrop-blur",
109
- className,
110
- )}
111
- >
112
- <RealChipContents snapshot={handshake.snapshot} />
113
- </div>
114
- </div>
115
- )
116
- }
@@ -1,26 +0,0 @@
1
- import type { GristLifecycle } from "grist-widget-sdk/advanced"
2
-
3
- // Projects the SDK's full GristLifecycle (advanced API, grist-widget-sdk/advanced)
4
- // down to the handful of states a small status chip needs to show.
5
- export type HandshakeDisplayStatus =
6
- | { kind: "connecting" }
7
- | { kind: "retrying"; attempts: number }
8
- | { kind: "connected" }
9
- | { kind: "unavailable" }
10
-
11
- export function deriveDisplayStatus(lifecycle: GristLifecycle): HandshakeDisplayStatus {
12
- switch (lifecycle.phase) {
13
- case "idle":
14
- return { kind: "connecting" }
15
- case "detecting":
16
- return lifecycle.attempts > 0
17
- ? { kind: "retrying", attempts: lifecycle.attempts }
18
- : { kind: "connecting" }
19
- case "negotiating":
20
- return { kind: "connecting" }
21
- case "online":
22
- return { kind: "connected" }
23
- case "terminated":
24
- return { kind: "unavailable" }
25
- }
26
- }