create-grist-widget 0.3.27 → 0.3.29

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
@@ -21,13 +21,14 @@ dependencies automatically, detecting whichever package manager you invoked this
21
21
  what's available — or just run `npm create grist-widget` with no name for an interactive
22
22
  prompt that asks for both.
23
23
 
24
- ## The four commands
24
+ ## The five commands
25
25
 
26
26
  | Command | What it does |
27
27
  | --- | --- |
28
28
  | `create <name> [--example <id>]` | Scaffold a new widget into `./<name>` (the command above, or run explicitly) |
29
29
  | `init [--example <id>]` | Scaffold into the **current** directory instead of a new one — for an already-cloned or agent-initialized repo |
30
30
  | `use <example-id>` | Swap the current project's widget for a different example |
31
+ | `upgrade` | Bring an existing widget's template files up to this version's template |
31
32
  | `list [--json]` | Print the available starting examples |
32
33
 
33
34
  Common flags for `create`/`init`: `--yes` skips interactive prompts and uses
@@ -52,7 +53,7 @@ npx create-grist-widget init --example form-edit
52
53
  ```
53
54
 
54
55
  Files that already exist and are identical to the template are left untouched; the four
55
- identity-bearing files (`package.json`, `README.md`, `index.html`, `src/App.tsx`) get the
56
+ identity-bearing files (`package.json`, `README.md`, `index.html`, `src/app/App.tsx`) get the
56
57
  usual name/title substitution even if present. Anything else that already exists **and**
57
58
  differs from the template's own copy stops the run with a list of the conflicting paths —
58
59
  pass `--force` to overwrite anyway. If `.git` already exists, `init` never auto-commits;
@@ -61,18 +62,35 @@ initial commit exactly like `create` does.
61
62
 
62
63
  ### `use` — swapping to a different example later
63
64
 
64
- Changed your mind about which example to start from, or want to compare two? `use` swaps
65
- `src/App.tsx` (and any example-specific files) for a different one, in place:
65
+ Changed your mind about which example to start from, or want to compare two? `use`
66
+ replaces the widget, `src/app/`, with a different example:
66
67
 
67
68
  ```bash
68
69
  create-grist-widget use task-board
69
70
  ```
70
71
 
71
- It only ever touches `src/**` — never `main.tsx`, `vite.config.ts`, or the deploy
72
- workflow — and it requires a clean git working tree first (`git status --porcelain`
72
+ It touches nothing else but the `src/components/ui/` primitives and dependencies the example
73
+ needs — never `main.tsx`, `vite.config.ts`, or the deploy workflow — and it requires a clean git working tree first (`git status --porcelain`
73
74
  empty), so the swap is always a single reviewable, revertible diff. Commit or stash first
74
75
  if you have uncommitted changes; `use` refuses to run otherwise.
75
76
 
77
+ ### `upgrade` — picking up template changes
78
+
79
+ Inside a widget, on `dev` with nothing uncommitted, after updating its `create-grist-widget`
80
+ dependency:
81
+
82
+ ```bash
83
+ pnpm exec create-grist-widget upgrade
84
+ ```
85
+
86
+ It rewrites the template's files (`src/main.tsx`, the workflows, the build configuration) to
87
+ this version's copies, deletes the ones earlier templates wrote that are gone now, merges the
88
+ template's scripts and dependency versions into `package.json`, and drops pnpm age exceptions
89
+ that are old enough not to matter. Nothing of the widget's own is touched (`src/app/` and its
90
+ smoke test, `src/components/ui/`, `src/index.css`, `README.md`…). It refuses to
91
+ run on `main`, with uncommitted changes, or on a widget still in `src/App.tsx`. Details:
92
+ [Upgrading a widget](https://gristwidgets.com/docs/cli/upgrading).
93
+
76
94
  ### `list` — what's available
77
95
 
78
96
  ```bash
@@ -80,7 +98,7 @@ create-grist-widget list # id / title / description, one per line
80
98
  create-grist-widget list --json # the same, as JSON
81
99
  ```
82
100
 
83
- > **Note:** `create`, `init`, `use`, and `list` are themselves valid widget names — a bare
101
+ > **Note:** `create`, `init`, `use`, `upgrade`, and `list` are themselves valid widget names — a bare
84
102
  > `create-grist-widget list` runs the `list` command, not "scaffold a widget named list."
85
103
  > Use the explicit form (`create-grist-widget create list`) for a widget with one of these
86
104
  > names.
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  // create-grist-widget — scaffold a Grist custom widget, fully offline.
3
3
  //
4
- // Four verbs (batch H, docs/planning/2026-08-execution-plan.md §98-105):
4
+ // Five verbs (batch H, docs/planning/2026-08-execution-plan.md §98-105, and
5
+ // T2 of docs/planning/2026-11-01-widget-lifecycle-plan.md):
5
6
  // create <name> [--example <id>] scaffold a new widget into ./<name>
6
7
  // init [--example <id>] scaffold into the current directory
7
- // use <example-id> swap the current project's widget
8
+ // use <example-id> swap the current project's widget (src/app/)
9
+ // upgrade bring the template's files up to this CLI's
8
10
  // list [--json] print the available starting examples
9
11
  //
10
12
  // `create`/`init`/`use` share one compose engine — see
@@ -13,7 +15,8 @@
13
15
  // scripts/build-template.mjs alongside the template itself, along with
14
16
  // examples/, ui-source/, and manifest.json — see that script for why).
15
17
  // Nothing here ever hits the network: everything list/use/create --example
16
- // need already shipped in the tarball.
18
+ // need already shipped in the tarball. The one exception is `upgrade`, which
19
+ // asks the npm registry when versions in pnpm's age exceptions were published.
17
20
 
18
21
  import {
19
22
  cpSync,
@@ -33,6 +36,7 @@ import { createComposer } from "../vendor/compose-example.mjs"
33
36
  import { checkGitGate } from "../src/git-gate.mjs"
34
37
  import { addedAgeExceptions } from "../src/age-exceptions.mjs"
35
38
  import { installPlan } from "../src/package-manager.mjs"
39
+ import { runUpgrade, upgradeRefusal } from "../src/upgrade.mjs"
36
40
 
37
41
  const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..")
38
42
  const TEMPLATE_DIR = path.join(PKG_ROOT, "template")
@@ -87,7 +91,9 @@ Usage:
87
91
  npm create grist-widget <name> [options] Scaffold into ./<name>
88
92
  create-grist-widget create <name> [options] Same as above, explicit
89
93
  create-grist-widget init [options] Scaffold into the current directory
90
- create-grist-widget use <example-id> Swap the current project's widget
94
+ create-grist-widget use <example-id> Replace the current project's widget (src/app/)
95
+ create-grist-widget upgrade Bring the template's files up to this CLI's
96
+ (run it on dev, in a widget: pnpm exec create-grist-widget upgrade)
91
97
  create-grist-widget list [--json] List available starting examples
92
98
 
93
99
  Options (create / init):
@@ -104,7 +110,7 @@ Options (create / init):
104
110
  --help, -h Show this help
105
111
  --version, -v Show this CLI's version
106
112
 
107
- Note: "create", "init", "use", and "list" are themselves valid widget names.
113
+ Note: "create", "init", "use", "upgrade", and "list" are themselves valid widget names.
108
114
  A bare "create-grist-widget list" runs the list command, not "scaffold a
109
115
  widget named list" — use "create-grist-widget create list" for that.`)
110
116
  }
@@ -116,7 +122,7 @@ function applyIdentityEdits(target, rawName) {
116
122
  ["package.json", [["grist-widget-template-vite", rawName]]],
117
123
  ["README.md", [["grist-widget-template-vite", rawName]]],
118
124
  ["index.html", [["<title>Grist Widget</title>", `<title>${title}</title>`]]],
119
- ["src/App.tsx", [['title: "Grist Widget Template",', `title: "${title}",`]]],
125
+ ["src/app/App.tsx", [['title: "Grist Widget Template",', `title: "${title}",`]]],
120
126
  ]
121
127
  for (const [file, subs] of edits) {
122
128
  const p = path.join(target, file)
@@ -393,7 +399,7 @@ async function cmdInit(argv) {
393
399
  // common case, a pristine "Use this template" clone); anything else that
394
400
  // already exists and differs from the template's own pristine copy blocks
395
401
  // the run, listed, unless --force.
396
- const IDENTITY_FILES = new Set(["package.json", "README.md", "index.html", "src/App.tsx"])
402
+ const IDENTITY_FILES = new Set(["package.json", "README.md", "index.html", "src/app/App.tsx"])
397
403
  const templateFiles = listFilesRecursive(TEMPLATE_DIR)
398
404
  if (!args.force) {
399
405
  const conflicts = []
@@ -470,11 +476,21 @@ function cmdUse(argv) {
470
476
  }
471
477
 
472
478
  const cwd = process.cwd()
479
+ // Scaffolds older than the src/app/ layout keep their widget in src/, and
480
+ // their main.tsx imports ./App.tsx: an example written to src/app/ would
481
+ // never show.
482
+ if (existsSync(path.join(cwd, "src", "App.tsx"))) {
483
+ console.error(
484
+ "use: this project keeps its widget in src/App.tsx. Widgets now live in src/app/: move it there first" +
485
+ " (see https://gristwidgets.com/docs/cli/upgrading), then run use again."
486
+ )
487
+ process.exit(1)
488
+ }
473
489
  const gate = checkGitGate(cwd)
474
490
  if (!gate.ok) {
475
491
  if (gate.reason === "no-repo") {
476
492
  console.error(
477
- 'use: this command overwrites src/App.tsx and must run inside a git repository so the change is easy to review and undo. Run "git init" first.'
493
+ 'use: this command replaces src/app/ and must run inside a git repository so the change is easy to review and undo. Run "git init" first.'
478
494
  )
479
495
  } else {
480
496
  console.error(
@@ -493,6 +509,105 @@ function cmdUse(argv) {
493
509
  console.log(" pnpm dev")
494
510
  }
495
511
 
512
+ // ---------------------------------------------------------------------------
513
+ // upgrade
514
+ // ---------------------------------------------------------------------------
515
+
516
+ const CLI_CHANGELOG_URL =
517
+ "https://github.com/ArthurBlanchon/grist-widget-sdk/blob/main/packages/create-grist-widget/CHANGELOG.md"
518
+
519
+ /** When `name@version` was published, from the registry `npm` is configured for in `cwd`; null if unknown. */
520
+ function npmPublishTimes(cwd) {
521
+ const cache = new Map()
522
+ return (name, version) => {
523
+ if (!cache.has(name)) {
524
+ let times = null
525
+ try {
526
+ const out = runCommand("npm", ["view", name, "time", "--json"], {
527
+ cwd,
528
+ encoding: "utf8",
529
+ stdio: ["ignore", "pipe", "ignore"],
530
+ timeout: 30_000,
531
+ })
532
+ times = JSON.parse(out)
533
+ } catch {
534
+ // Offline, or not on that registry: the entry stays.
535
+ }
536
+ cache.set(name, times)
537
+ }
538
+ const at = cache.get(name)?.[version]
539
+ return at ? new Date(at) : null
540
+ }
541
+ }
542
+
543
+ function printList(title, items) {
544
+ if (items.length === 0) return
545
+ console.log(`\n${title}`)
546
+ for (const item of items) console.log(` ${item}`)
547
+ }
548
+
549
+ function cmdUpgrade(argv) {
550
+ parseArgs(argv)
551
+ const cwd = process.cwd()
552
+ const refusal = upgradeRefusal(cwd, { cliVersion: cliPkg.version })
553
+ if (refusal) {
554
+ console.error(refusal.message)
555
+ process.exit(1)
556
+ }
557
+
558
+ const report = runUpgrade({
559
+ cwd,
560
+ templateDir: TEMPLATE_DIR,
561
+ cliVersion: cliPkg.version,
562
+ publishedAt: npmPublishTimes(cwd),
563
+ })
564
+ const changed =
565
+ report.written.length +
566
+ report.deleted.length +
567
+ report.tsconfigs.length +
568
+ report.packageChanges.length +
569
+ report.ageRemoved.length >
570
+ 0
571
+
572
+ const from = report.fromVersion && report.fromVersion !== cliPkg.version ? ` (from ${report.fromVersion})` : ""
573
+ if (!changed) {
574
+ console.log(`Already on create-grist-widget ${cliPkg.version}'s template: nothing to change.`)
575
+ } else {
576
+ console.log(`Upgraded to create-grist-widget ${cliPkg.version}'s template${from}.`)
577
+ }
578
+ printList("Template files rewritten:", report.written)
579
+ printList("Deleted (earlier templates wrote them, nothing uses them now):", report.deleted)
580
+ printList(
581
+ "Kept: earlier templates wrote these, but a file of yours still refers to them (delete them once it doesn't):",
582
+ report.keptRetired.map(({ file, by }) => `${file} (by ${by.join(", ")})`)
583
+ )
584
+ printList(
585
+ "Switched to grist-widget-sdk's compiler settings, keeping these types:",
586
+ report.tsconfigs.map(({ file, types }) => `${file} (types: ${types.join(", ") || "none"})`)
587
+ )
588
+ printList(
589
+ "Not switched to grist-widget-sdk's compiler settings (could not be read; do it by hand):",
590
+ report.tsconfigsUnreadable
591
+ )
592
+ printList("package.json:", report.packageChanges)
593
+ printList("pnpm age exceptions removed (old enough now that pnpm no longer needs them):", report.ageRemoved)
594
+ printList("pnpm age exceptions kept (their publish time could not be looked up):", report.ageUnknown)
595
+ if (!changed) return
596
+
597
+ console.log("\nNext:")
598
+ // Only what pnpm-lock.yaml records: dependencies, packageManager, age exceptions.
599
+ const lockfileChanges = report.packageChanges.filter((c) => /^(dependencies|devDependencies|packageManager)\b/.test(c))
600
+ if (lockfileChanges.length || report.ageRemoved.length) {
601
+ console.log(" pnpm install # update pnpm-lock.yaml")
602
+ }
603
+ console.log(" pnpm typecheck && pnpm lint && pnpm test")
604
+ console.log(" git diff # review, then commit and push dev")
605
+ console.log(
606
+ `\nAnything a template change needs beyond these files is in the changelog entries${from ? ` after ${report.fromVersion}` : ""}:` +
607
+ `\n ${CLI_CHANGELOG_URL}`
608
+ )
609
+ }
610
+
496
611
  // ---------------------------------------------------------------------------
497
612
  // list
498
613
  // ---------------------------------------------------------------------------
@@ -534,6 +649,7 @@ async function main() {
534
649
  }
535
650
  if (first === "list") return cmdList(rest)
536
651
  if (first === "use") return cmdUse(rest)
652
+ if (first === "upgrade") return cmdUpgrade(rest)
537
653
  if (first === "init") return cmdInit(rest)
538
654
  if (first === "create") return cmdCreate(rest)
539
655
  // Bare invocation — `npm create grist-widget my-widget` (npm's `create`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-grist-widget",
3
- "version": "0.3.27",
3
+ "version": "0.3.29",
4
4
  "description": "Scaffold a new Grist custom widget from the grist-widget-sdk Vite template",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/deploy.mjs CHANGED
@@ -464,6 +464,9 @@ function identityEdits({ packageName, title }) {
464
464
  ["package.json", [["grist-widget-template-vite", packageName]]],
465
465
  ["README.md", [["grist-widget-template-vite", packageName]]],
466
466
  ["index.html", [["<title>Grist Widget</title>", `<title>${title}</title>`]]],
467
+ ["src/app/App.tsx", [['title: "Grist Widget Template",', `title: "${title}",`]]],
468
+ // Where the widget lived before the src/app/ layout: a repo copied from
469
+ // an older template repository still has it there.
467
470
  ["src/App.tsx", [['title: "Grist Widget Template",', `title: "${title}",`]]],
468
471
  ]
469
472
  }
package/src/git-gate.mjs CHANGED
@@ -1,5 +1,5 @@
1
- // The safety gate `use <example-id>` rests on: overlayExampleSrc overwrites
2
- // src/App.tsx (and any other example-specific files) in place, so the only
1
+ // The safety gate `use <example-id>` rests on: overlayExampleSrc replaces
2
+ // src/app/ (the widget's own folder) with the example's files, so the only
3
3
  // thing that makes that recoverable is a clean git tree to diff/revert
4
4
  // against. `create`/`init` always leave a repo behind today (see
5
5
  // bin/create-grist-widget.mjs's gitInitCommit), but `use` checks for one
@@ -0,0 +1,562 @@
1
+ // `create-grist-widget upgrade`: bring a scaffolded widget's template files
2
+ // up to this CLI's template (plan T2, docs/planning/2026-11-01-widget-lifecycle-plan.md).
3
+ //
4
+ // The template's files are an explicit list, TEMPLATE_FILES. upgrade
5
+ // overwrites those, deletes the files earlier templates wrote that are gone
6
+ // now (RETIRED_FILES), switches a tsconfig.app.json / tsconfig.node.json that
7
+ // predates the SDK's base once (SDK_TSCONFIGS), merges package.json's
8
+ // template-set scripts and dependency versions, and prunes pnpm age
9
+ // exceptions that no longer matter. It never touches anything else: src/app/
10
+ // (smoke test included), src/components/ui/, src/index.css, README.md and
11
+ // every file the widget added are the widget's own. It runs on a clean `dev`,
12
+ // so what it changed is one `git diff` to review.
13
+
14
+ import { execFileSync } from "node:child_process"
15
+ import {
16
+ cpSync,
17
+ existsSync,
18
+ mkdirSync,
19
+ readdirSync,
20
+ readFileSync,
21
+ rmSync,
22
+ writeFileSync,
23
+ } from "node:fs"
24
+ import path from "node:path"
25
+
26
+ /**
27
+ * The files the template writes and keeps identical in every widget. Listed
28
+ * in the template's AGENTS.md and on the Upgrading a widget page too
29
+ * (tests/upgrade.test.mjs checks all three agree with the template itself).
30
+ */
31
+ export const TEMPLATE_FILES = [
32
+ ".github/dependabot.yml",
33
+ ".github/workflows/ci.yml",
34
+ ".github/workflows/deploy.yml",
35
+ ".prettierignore",
36
+ ".prettierrc",
37
+ "AGENTS.md",
38
+ "CLAUDE.md",
39
+ "components.json",
40
+ "eslint.config.js",
41
+ "src/components/theme-provider.tsx",
42
+ "src/lib/utils.ts",
43
+ "src/main.tsx",
44
+ "src/vite-env.d.ts",
45
+ "tsconfig.json",
46
+ "vite.config.ts",
47
+ ]
48
+
49
+ /**
50
+ * Files earlier templates wrote that the current one doesn't: upgrade
51
+ * deletes each one still there, unless a file the widget keeps still refers
52
+ * to it (then it says which, and leaves both alone).
53
+ */
54
+ export const RETIRED_FILES = [
55
+ "public/vite.svg",
56
+ "scripts/deploy.mjs",
57
+ "src/assets/react.svg",
58
+ "src/components/channel-notice.tsx",
59
+ "src/components/grist-sdk-alerts.tsx",
60
+ "src/components/grist-status-chip.tsx",
61
+ "src/components/local-dev-notice.tsx",
62
+ "src/components/release-info.tsx",
63
+ "src/components/scaffold-footer.tsx",
64
+ "src/components/template-landing.tsx",
65
+ "src/lib/grist-handshake-status.ts",
66
+ "src/lib/showcase-routing.ts",
67
+ "src/lib/showcase-versions.ts",
68
+ "vite-widget-meta.ts",
69
+ ]
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Versions
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /** `[major, minor, patch]` of a plain version, or null. */
76
+ export function parseVersion(version) {
77
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(String(version ?? "").trim())
78
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
79
+ }
80
+
81
+ /** Lowest version a `^x.y.z`, `~x.y.z` or `x.y.z` range allows, or null for any other range. */
82
+ export function rangeFloor(range) {
83
+ const m = /^[\^~]?(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)$/.exec(String(range ?? "").trim())
84
+ return m ? parseVersion(m[1]) : null
85
+ }
86
+
87
+ export function compareVersions(a, b) {
88
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1
89
+ return 0
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Refusals
94
+ // ---------------------------------------------------------------------------
95
+
96
+ function git(cwd, args) {
97
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
98
+ }
99
+
100
+ function readJson(file) {
101
+ try {
102
+ return JSON.parse(readFileSync(file, "utf8"))
103
+ } catch {
104
+ return null
105
+ }
106
+ }
107
+
108
+ const UPGRADING_URL = "https://gristwidgets.com/docs/cli/upgrading"
109
+
110
+ /**
111
+ * Why `upgrade` must not run in `cwd`, or null when it may.
112
+ *
113
+ * @param {string} cwd
114
+ * @param {{ cliVersion: string }} options
115
+ * @returns {null | { reason: string, message: string }}
116
+ */
117
+ export function upgradeRefusal(cwd, { cliVersion }) {
118
+ const refuse = (reason, message) => ({ reason, message: `upgrade: ${message}` })
119
+
120
+ try {
121
+ git(cwd, ["rev-parse", "--is-inside-work-tree"])
122
+ } catch {
123
+ return refuse(
124
+ "no-repo",
125
+ 'this command rewrites the template\'s files and must run inside a git repository, so the change is easy to review and undo. Run "git init" first.'
126
+ )
127
+ }
128
+
129
+ const pkg = readJson(path.join(cwd, "package.json"))
130
+ const deps = { ...pkg?.dependencies, ...pkg?.devDependencies }
131
+ if (!pkg || !("grist-widget-sdk" in deps)) {
132
+ return refuse("not-a-widget", "run it at the root of a widget made with create-grist-widget (no package.json depending on grist-widget-sdk here).")
133
+ }
134
+
135
+ let branch = ""
136
+ try {
137
+ branch = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])
138
+ } catch {
139
+ // No commit yet: not on main either.
140
+ }
141
+ if (branch === "main") {
142
+ return refuse("on-main", 'run it on "dev" (git switch dev): main only changes by merging dev into it, after the deploy preview there has been checked.')
143
+ }
144
+
145
+ if (git(cwd, ["status", "--porcelain"])) {
146
+ return refuse("dirty", "the working tree has uncommitted changes. Commit or stash them first, so what upgrade changes is one diff to review and undo.")
147
+ }
148
+
149
+ if (existsSync(path.join(cwd, "src", "App.tsx"))) {
150
+ return refuse(
151
+ "old-layout",
152
+ `this widget is still in src/App.tsx. Widgets now live in src/app/, which the template's src/main.tsx imports: move it there first (${UPGRADING_URL}#widgets-scaffolded-before-srcapp), then run upgrade again.`
153
+ )
154
+ }
155
+ if (!existsSync(path.join(cwd, "src", "app", "App.tsx"))) {
156
+ return refuse("no-entry", "src/app/App.tsx is missing. The template's src/main.tsx and smoke test import the widget from there.")
157
+ }
158
+
159
+ const madeWith = parseVersion(pkg.createGristWidgetVersion)
160
+ const running = parseVersion(cliVersion)
161
+ if (madeWith && running && compareVersions(madeWith, running) > 0) {
162
+ return refuse(
163
+ "newer-widget",
164
+ `this widget is already on create-grist-widget ${pkg.createGristWidgetVersion}'s template, newer than this CLI (${cliVersion}). Run the version it depends on: pnpm exec create-grist-widget upgrade.`
165
+ )
166
+ }
167
+ return null
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // package.json
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /**
175
+ * The widget's package.json with the template's scripts and dependency
176
+ * versions applied: each template script replaces the widget's of the same
177
+ * name; each template dependency is added if missing and raised to the
178
+ * template's range if the widget's allows older versions. A range that isn't
179
+ * plain semver on either side (workspace:, file:, a tag...) and everything
180
+ * else the widget has are kept.
181
+ *
182
+ * @returns {{ pkg: object, changes: string[] }}
183
+ */
184
+ export function mergePackageJson(widgetPkg, templatePkg, cliVersion) {
185
+ const pkg = structuredClone(widgetPkg)
186
+ const changes = []
187
+
188
+ for (const [name, command] of Object.entries(templatePkg.scripts ?? {})) {
189
+ pkg.scripts ??= {}
190
+ if (pkg.scripts[name] === command) continue
191
+ changes.push(
192
+ name in pkg.scripts
193
+ ? `scripts.${name}: "${pkg.scripts[name]}" -> "${command}"`
194
+ : `scripts.${name}: added "${command}"`
195
+ )
196
+ pkg.scripts[name] = command
197
+ }
198
+
199
+ for (const section of ["dependencies", "devDependencies"]) {
200
+ for (const [name, range] of Object.entries(templatePkg[section] ?? {})) {
201
+ const owner = ["dependencies", "devDependencies"].find((s) => pkg[s] && name in pkg[s])
202
+ if (!owner) {
203
+ pkg[section] ??= {}
204
+ pkg[section][name] = range
205
+ changes.push(`${section}.${name}: added ${range}`)
206
+ continue
207
+ }
208
+ const current = pkg[owner][name]
209
+ const theirs = rangeFloor(current)
210
+ const ours = rangeFloor(range)
211
+ if (!theirs || !ours || compareVersions(ours, theirs) <= 0) continue
212
+ pkg[owner][name] = range
213
+ changes.push(`${owner}.${name}: ${current} -> ${range}`)
214
+ }
215
+ }
216
+
217
+ const manager = /^([^@]+)@(.+)$/.exec(templatePkg.packageManager ?? "")
218
+ const theirManager = /^([^@]+)@(.+)$/.exec(pkg.packageManager ?? "")
219
+ if (manager && !theirManager) {
220
+ pkg.packageManager = templatePkg.packageManager
221
+ changes.push(`packageManager: added ${templatePkg.packageManager}`)
222
+ } else if (manager && theirManager && manager[1] === theirManager[1]) {
223
+ const ours = parseVersion(manager[2].split("+")[0])
224
+ const theirs = parseVersion(theirManager[2].split("+")[0])
225
+ if (ours && theirs && compareVersions(ours, theirs) > 0) {
226
+ changes.push(`packageManager: ${pkg.packageManager} -> ${templatePkg.packageManager}`)
227
+ pkg.packageManager = templatePkg.packageManager
228
+ }
229
+ }
230
+
231
+ if (pkg.createGristWidgetVersion !== cliVersion) {
232
+ changes.push(`createGristWidgetVersion: ${pkg.createGristWidgetVersion ?? "(none)"} -> ${cliVersion}`)
233
+ pkg.createGristWidgetVersion = cliVersion
234
+ }
235
+ return { pkg, changes }
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // pnpm age exceptions
240
+ // ---------------------------------------------------------------------------
241
+
242
+ // pnpm 11's default minimumReleaseAge, in minutes.
243
+ const DEFAULT_MIN_AGE_MINUTES = 24 * 60
244
+
245
+ /** The `minimumReleaseAge` a pnpm-workspace.yaml sets, in minutes, or pnpm's default. */
246
+ export function minimumReleaseAge(workspaceYaml) {
247
+ const m = /^minimumReleaseAge:\s*(\d+)\s*(#.*)?$/m.exec(workspaceYaml)
248
+ return m ? Number(m[1]) : DEFAULT_MIN_AGE_MINUTES
249
+ }
250
+
251
+ /** `{ name, versions }` for a `name@1.2.3` (or `name@1.2.3 || 1.2.4`) entry, or null for any other. */
252
+ export function parseExactEntry(entry) {
253
+ const m = /^(@?[^@\s]+)@(.+)$/.exec(entry)
254
+ if (!m) return null
255
+ const versions = m[2].split("||").map((v) => v.trim())
256
+ return versions.every((v) => parseVersion(v)) ? { name: m[1], versions } : null
257
+ }
258
+
259
+ const KEY = "minimumReleaseAgeExclude"
260
+
261
+ /**
262
+ * Walk the `minimumReleaseAgeExclude` list (block or flow style), passing
263
+ * each entry to `decide`, which returns it unchanged, a replacement, or null
264
+ * to drop it. The key goes too once no entry is left. Its own parser rather
265
+ * than age-exceptions.mjs's: pnpm writes several versions of a package as
266
+ * one `name@1.2.3 || 1.2.4` entry, with spaces in it.
267
+ */
268
+ function rewriteAgeExceptions(workspaceYaml, decide) {
269
+ const flowRe = new RegExp(`^${KEY}:\\s*\\[(.*)\\]\\s*(#.*)?$`)
270
+ const blockRe = new RegExp(`^${KEY}:\\s*(#.*)?$`)
271
+ const out = []
272
+ let keyIndex = -1 // index in `out` of the block's key line, while in the block
273
+ let kept = 0
274
+ const closeBlock = () => {
275
+ if (keyIndex >= 0 && kept === 0) out.splice(keyIndex, 1)
276
+ keyIndex = -1
277
+ }
278
+ for (const line of workspaceYaml.split("\n")) {
279
+ const bare = line.replace(/\r$/, "")
280
+ const flow = flowRe.exec(bare)
281
+ if (flow) {
282
+ closeBlock()
283
+ const items = flow[1]
284
+ .split(",")
285
+ .map((item) => item.trim())
286
+ .filter(Boolean)
287
+ .flatMap((item) => {
288
+ const quote = /^["']/.exec(item)?.[0] ?? ""
289
+ const next = decide(item.replace(/^["']|["']$/g, ""))
290
+ return next === null ? [] : [`${quote}${next}${quote}`]
291
+ })
292
+ if (items.length) out.push(`${KEY}: [${items.join(", ")}]${flow[2] ? ` ${flow[2]}` : ""}`)
293
+ continue
294
+ }
295
+ if (blockRe.test(bare)) {
296
+ closeBlock()
297
+ keyIndex = out.length
298
+ kept = 0
299
+ out.push(line)
300
+ continue
301
+ }
302
+ if (keyIndex >= 0) {
303
+ const item = /^(\s+-\s+)(["']?)(.+?)\2(\s+#.*)?\s*$/.exec(bare)
304
+ if (item) {
305
+ const next = decide(item[3])
306
+ if (next !== null) {
307
+ out.push(next === item[3] ? line : `${item[1]}${item[2]}${next}${item[2]}${item[4] ?? ""}`)
308
+ kept++
309
+ }
310
+ continue
311
+ }
312
+ if (!/^\s*(#.*)?$/.test(bare)) closeBlock() // the next key: the list is over
313
+ }
314
+ out.push(line)
315
+ }
316
+ closeBlock()
317
+ return out.join("\n")
318
+ }
319
+
320
+ /**
321
+ * pnpm-workspace.yaml without the `minimumReleaseAgeExclude` versions that
322
+ * are now older than its `minimumReleaseAge`: those exceptions no longer
323
+ * change what pnpm installs. Entries naming no exact version, and versions
324
+ * whose publish time can't be looked up, are kept.
325
+ *
326
+ * @param {string} workspaceYaml
327
+ * @param {{ publishedAt: (name: string, version: string) => Date | null, now: Date }} options
328
+ * @returns {{ yaml: string, removed: string[], unknown: string[] }}
329
+ */
330
+ export function pruneAgeExceptions(workspaceYaml, { publishedAt, now }) {
331
+ const minAgeMs = minimumReleaseAge(workspaceYaml) * 60 * 1000
332
+ const removed = []
333
+ const unknown = []
334
+ const yaml = rewriteAgeExceptions(workspaceYaml, (entry) => {
335
+ const parsed = parseExactEntry(entry)
336
+ if (!parsed) return entry
337
+ const keep = parsed.versions.filter((version) => {
338
+ const at = publishedAt(parsed.name, version)
339
+ if (!at) {
340
+ unknown.push(`${parsed.name}@${version}`)
341
+ return true
342
+ }
343
+ if (now.getTime() - at.getTime() < minAgeMs) return true
344
+ removed.push(`${parsed.name}@${version}`)
345
+ return false
346
+ })
347
+ if (keep.length === parsed.versions.length) return entry
348
+ return keep.length ? `${parsed.name}@${keep.join(" || ")}` : null
349
+ })
350
+ return { yaml: removed.length ? yaml : workspaceYaml, removed, unknown }
351
+ }
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // Retired files
355
+ // ---------------------------------------------------------------------------
356
+
357
+ const SKIP_DIRS = new Set(["node_modules", "dist", ".git", "coverage"])
358
+ const SCANNED = /\.(tsx?|jsx?|mjs|cjs|css|html|json|ya?ml)$/
359
+
360
+ function listFiles(root, dir = root) {
361
+ const out = []
362
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
363
+ if (SKIP_DIRS.has(entry.name)) continue
364
+ const full = path.join(dir, entry.name)
365
+ if (entry.isDirectory()) out.push(...listFiles(root, full))
366
+ else out.push(path.relative(root, full).split(path.sep).join("/"))
367
+ }
368
+ return out
369
+ }
370
+
371
+ function escapeRegExp(text) {
372
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
373
+ }
374
+
375
+ /**
376
+ * How another file would refer to `file`: a TypeScript module by an import
377
+ * path ending in its name, with or without the extension (`@/lib/x`,
378
+ * `./x.tsx`); anything else (a script, an asset) by its full file name.
379
+ */
380
+ function referencePattern(file) {
381
+ const base = path.posix.basename(file)
382
+ if (/\.tsx?$/.test(base)) {
383
+ const stem = base.replace(/\.tsx?$/, "")
384
+ return new RegExp(`/${escapeRegExp(stem)}(\\.(tsx?|jsx?))?["'\`]`)
385
+ }
386
+ return new RegExp(`(^|[/"'\`\\s(])${escapeRegExp(base)}`, "m")
387
+ }
388
+
389
+ /**
390
+ * Which retired files in `cwd` nothing refers to any more (`remove`), and
391
+ * which are still referred to, by what (`keep`). Run after the template's
392
+ * files are written, so their new content is what counts.
393
+ */
394
+ export function retiredFilesPlan(cwd) {
395
+ const present = RETIRED_FILES.filter((file) => existsSync(path.join(cwd, file)))
396
+ const scanned = listFiles(cwd).filter(
397
+ (file) => SCANNED.test(file) && !present.includes(file) && file !== "pnpm-lock.yaml" && file !== "package-lock.json"
398
+ )
399
+ const contents = new Map(scanned.map((file) => [file, readFileSync(path.join(cwd, file), "utf8")]))
400
+ const remove = []
401
+ const keep = []
402
+ for (const file of present) {
403
+ const pattern = referencePattern(file)
404
+ const by = scanned.filter((other) => pattern.test(contents.get(other)))
405
+ if (by.length) keep.push({ file, by })
406
+ else remove.push(file)
407
+ }
408
+ return { remove, keep }
409
+ }
410
+
411
+ function removeEmptyParents(cwd, file) {
412
+ let dir = path.dirname(path.join(cwd, file))
413
+ while (dir.startsWith(cwd + path.sep) && readdirSync(dir).length === 0) {
414
+ rmSync(dir, { recursive: true })
415
+ dir = path.dirname(dir)
416
+ }
417
+ }
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // tsconfig.app.json / tsconfig.node.json
421
+ // ---------------------------------------------------------------------------
422
+
423
+ /**
424
+ * The widget's TypeScript configs. They are the widget's (its paths and
425
+ * types): their compiler settings come from the grist-widget-sdk base they
426
+ * extend. One written before that base existed extends nothing and carries
427
+ * those settings itself, so upgrade switches it to the template's once.
428
+ */
429
+ export const SDK_TSCONFIGS = ["tsconfig.app.json", "tsconfig.node.json"]
430
+
431
+ /** JSON with comments and trailing commas, as tsconfig files allow. */
432
+ export function parseJsonc(text) {
433
+ let out = ""
434
+ let inString = false
435
+ for (let i = 0; i < text.length; i++) {
436
+ const c = text[i]
437
+ if (inString) {
438
+ out += c
439
+ if (c === "\\") out += text[++i] ?? ""
440
+ else if (c === '"') inString = false
441
+ continue
442
+ }
443
+ if (c === '"') {
444
+ inString = true
445
+ out += c
446
+ } else if (c === "/" && text[i + 1] === "/") {
447
+ while (i < text.length && text[i] !== "\n") i++
448
+ out += "\n"
449
+ } else if (c === "/" && text[i + 1] === "*") {
450
+ const end = text.indexOf("*/", i + 2)
451
+ if (end < 0) break
452
+ i = end + 1
453
+ } else {
454
+ out += c
455
+ }
456
+ }
457
+ return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"))
458
+ }
459
+
460
+ /**
461
+ * The template's version of a widget tsconfig that extends nothing yet, with
462
+ * the widget's `compilerOptions.types` added to the template's. Null for one
463
+ * that extends a base already (the SDK's, or one the widget chose).
464
+ *
465
+ * @returns {null | { content: string, types: string[] } | { unreadable: true }}
466
+ */
467
+ export function migratedTsconfig(widgetText, templateText) {
468
+ let widget
469
+ try {
470
+ widget = parseJsonc(widgetText)
471
+ } catch {
472
+ return { unreadable: true }
473
+ }
474
+ if (widget?.extends !== undefined) return null
475
+ const types = widget?.compilerOptions?.types ?? []
476
+ const templateTypes = parseJsonc(templateText)?.compilerOptions?.types ?? []
477
+ const merged = [...templateTypes, ...types.filter((t) => !templateTypes.includes(t))]
478
+ const content = templateText.replace(/"types":\s*\[[^\]]*\]/, `"types": ${JSON.stringify(merged).replace(/","/g, '", "')}`)
479
+ return { content, types: merged }
480
+ }
481
+
482
+ // ---------------------------------------------------------------------------
483
+ // The upgrade
484
+ // ---------------------------------------------------------------------------
485
+
486
+ /**
487
+ * Apply `templateDir`'s template to the widget at `cwd`. Call
488
+ * {@link upgradeRefusal} first.
489
+ *
490
+ * @param {{
491
+ * cwd: string,
492
+ * templateDir: string,
493
+ * cliVersion: string,
494
+ * publishedAt: (name: string, version: string) => Date | null,
495
+ * now?: Date,
496
+ * }} options
497
+ */
498
+ export function runUpgrade({ cwd, templateDir, cliVersion, publishedAt, now = new Date() }) {
499
+ const report = {
500
+ fromVersion: readJson(path.join(cwd, "package.json"))?.createGristWidgetVersion ?? null,
501
+ written: [],
502
+ deleted: [],
503
+ keptRetired: [],
504
+ tsconfigs: [],
505
+ tsconfigsUnreadable: [],
506
+ packageChanges: [],
507
+ ageRemoved: [],
508
+ ageUnknown: [],
509
+ }
510
+
511
+ for (const file of TEMPLATE_FILES) {
512
+ const from = path.join(templateDir, file)
513
+ const to = path.join(cwd, file)
514
+ const next = readFileSync(from)
515
+ if (existsSync(to) && readFileSync(to).equals(next)) continue
516
+ mkdirSync(path.dirname(to), { recursive: true })
517
+ cpSync(from, to)
518
+ report.written.push(file)
519
+ }
520
+
521
+ for (const file of SDK_TSCONFIGS) {
522
+ const to = path.join(cwd, file)
523
+ const templateText = readFileSync(path.join(templateDir, file), "utf8")
524
+ if (!existsSync(to)) {
525
+ writeFileSync(to, templateText)
526
+ report.tsconfigs.push({ file, types: parseJsonc(templateText)?.compilerOptions?.types ?? [] })
527
+ continue
528
+ }
529
+ const migrated = migratedTsconfig(readFileSync(to, "utf8"), templateText)
530
+ if (!migrated) continue
531
+ if (migrated.unreadable) {
532
+ report.tsconfigsUnreadable.push(file)
533
+ continue
534
+ }
535
+ writeFileSync(to, migrated.content)
536
+ report.tsconfigs.push({ file, types: migrated.types })
537
+ }
538
+
539
+ const { remove, keep } = retiredFilesPlan(cwd)
540
+ for (const file of remove) {
541
+ rmSync(path.join(cwd, file))
542
+ removeEmptyParents(cwd, file)
543
+ report.deleted.push(file)
544
+ }
545
+ report.keptRetired = keep
546
+
547
+ const pkgPath = path.join(cwd, "package.json")
548
+ const templatePkg = JSON.parse(readFileSync(path.join(templateDir, "package.json"), "utf8"))
549
+ const { pkg, changes } = mergePackageJson(JSON.parse(readFileSync(pkgPath, "utf8")), templatePkg, cliVersion)
550
+ if (changes.length) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n")
551
+ report.packageChanges = changes
552
+
553
+ const workspacePath = path.join(cwd, "pnpm-workspace.yaml")
554
+ if (existsSync(workspacePath)) {
555
+ const before = readFileSync(workspacePath, "utf8")
556
+ const { yaml, removed, unknown } = pruneAgeExceptions(before, { publishedAt, now })
557
+ if (yaml !== before) writeFileSync(workspacePath, yaml)
558
+ report.ageRemoved = removed
559
+ report.ageUnknown = unknown
560
+ }
561
+ return report
562
+ }
@@ -11,21 +11,42 @@
11
11
 
12
12
  ## The widget contract
13
13
 
14
- `src/App.tsx` is the whole widget — one file, three exports:
14
+ The widget lives in `src/app/`. Its entry point, `src/app/App.tsx`, has three exports:
15
15
 
16
16
  ```tsx
17
17
  export const GRIST_OPTIONS: UseGristOptions = { requiredAccess: "read table" }
18
18
  export const WIDGET_METADATA = { title: "...", description: "..." } as const
19
- export function App() {
19
+ export default function App() {
20
20
  const w = useGrist()
21
21
  // ...
22
22
  }
23
23
  ```
24
24
 
25
+ `GRIST_OPTIONS` may live in a file of its own, as long as `App.tsx` re-exports it: the
26
+ template's `src/main.tsx` imports both from `./app/App`.
27
+
25
28
  `src/main.tsx` is one call, `mountGristWidget({ App, options: GRIST_OPTIONS, dev })` from
26
29
  `grist-widget-sdk/app`: inside Grist it renders `<App />` in `GristWidgetProvider` and
27
30
  `GristBoundary`, with the SDK's alerts and Grist's light/dark theme. Access level, column
28
- mapping, and rendering all live in `src/App.tsx` — that's the one file to edit.
31
+ mapping, and rendering all live in `src/app/App.tsx` — that's the file to edit.
32
+
33
+ ### What is the template's, what is the widget's
34
+
35
+ - **The template's:** `src/main.tsx`, `src/components/theme-provider.tsx`,
36
+ `src/lib/utils.ts`, `src/vite-env.d.ts`, the workflows and Dependabot config,
37
+ `vite.config.ts`, `tsconfig.json`, `eslint.config.js`, the Prettier config,
38
+ `components.json`, `CLAUDE.md` and this file.
39
+ Leave them as they are: `pnpm exec create-grist-widget upgrade` rewrites them to the
40
+ template of the `create-grist-widget` version the widget depends on
41
+ (<https://gristwidgets.com/docs/cli/upgrading>), and a local edit would be lost.
42
+ - **The widget's:** everything else. `src/app/` holds its code and tests, the smoke test
43
+ included: put every new component, hook, helper and asset there. `src/components/ui/`
44
+ holds the shadcn primitives, where `shadcn add` writes (next to any other registry
45
+ folder it installs), and `src/index.css` the theme. `tsconfig.app.json` and
46
+ `tsconfig.node.json` keep the widget's own paths and types (their compiler settings come
47
+ from `grist-widget-sdk`). `README.md`, `index.html`, `public/` and
48
+ `pnpm-workspace.yaml` are the widget's too, and so is `package.json`, apart from the
49
+ scripts and dependency versions the template sets.
29
50
 
30
51
  ## Operating contract
31
52
 
@@ -37,7 +58,7 @@ mapping, and rendering all live in `src/App.tsx` — that's the one file to edit
37
58
  real Grist, so a fake local preview would teach the wrong lesson. Trust `pnpm test`
38
59
  (headless, real assertions) for local feedback, and the `dev`-branch loop below for real
39
60
  behavior — don't build or suggest a seeded `pnpm dev` preview.
40
- 3. **Widget changes get a test.** `src/App.test.tsx` uses `renderWithGrist` + `presets` from
61
+ 3. **Widget changes get a test.** `src/app/App.test.tsx` uses `renderWithGrist` + `presets` from
41
62
  `grist-widget-sdk/emulator/testing` — no browser or real Grist doc needed. Run `pnpm test`.
42
63
  4. **Always develop on `dev`, release by merging to `main`.** Commit and push to `dev` for
43
64
  every change — it auto-deploys a live preview at `.../dev/` that self-reloads inside an
@@ -45,6 +66,10 @@ mapping, and rendering all live in `src/App.tsx` — that's the one file to edit
45
66
  opening the `dev` → `main` PR: merging without a version bump publishes nothing (the
46
67
  release build silently no-ops whenever that version's directory already exists — the PR
47
68
  merges cleanly and CI reports success either way).
69
+ 5. **Template updates arrive with `create-grist-widget`.** Dependabot opens a pull request of
70
+ its own for it. Once that is merged into `dev`, run `pnpm exec create-grist-widget upgrade`
71
+ on `dev` in a follow-up pull request, then `pnpm install`, `pnpm typecheck`, `pnpm lint` and
72
+ `pnpm test`. It rewrites only the template's files (listed above) and says what it changed.
48
73
 
49
74
  ## Commands
50
75
 
@@ -58,14 +83,16 @@ mapping, and rendering all live in `src/App.tsx` — that's the one file to edit
58
83
  | Format | `pnpm format` |
59
84
  | Type-check | `pnpm typecheck` |
60
85
  | Preview a production build | `pnpm preview` |
86
+ | Bring the template's files up to date (on `dev`, after updating create-grist-widget) | `pnpm exec create-grist-widget upgrade` |
61
87
 
62
88
  ## Path map
63
89
 
64
90
  | Concept | Path |
65
91
  | --- | --- |
66
- | The widget | `src/App.tsx` |
67
- | Widget test (the pattern to copy for new tests) | `src/App.test.tsx` |
68
- | Smoke test (one smokeTestWidget call; CI runs it on SDK updates) | `src/App.smoke.test.tsx` |
92
+ | The widget (everything in it is the widget's own) | `src/app/` |
93
+ | Its entry point (GRIST_OPTIONS, WIDGET_METADATA, App) | `src/app/App.tsx` |
94
+ | Widget test (the pattern to copy for new tests) | `src/app/App.test.tsx` |
95
+ | Smoke test (one smokeTestWidget call; CI runs it on SDK updates) | `src/app/App.smoke.test.tsx` |
69
96
  | Grist wiring (mountGristWidget, inside vs. outside Grist) | `src/main.tsx` |
70
97
  | ui primitives | `src/components/ui/` |
71
98
  | Deploy workflow (runs grist-widget-deploy from the create-grist-widget devDependency: version guard, channel logic) | `.github/workflows/deploy.yml` |
@@ -85,6 +112,11 @@ creates a `gh-pages` branch — these settings can't be applied before that exis
85
112
  2. If that first workflow run fails with a permissions error pushing to `gh-pages`:
86
113
  Settings → Actions → General → Workflow permissions → **Read and write permissions**.
87
114
  Most repos don't need this; it depends on account/org defaults.
115
+ 3. After the first release (which commits to `main` and resets `dev`), a branch ruleset:
116
+ `main` requires a pull request and the `check` status, and blocks force pushes and
117
+ deletion; `dev` blocks force pushes and deletion. An agent acts through the owner's
118
+ account, so these rules don't tell it apart: they keep red changes off `main` whoever
119
+ merges. Ask the owner to set them; they need admin access.
88
120
 
89
121
  Full detail, including the two live URLs (`/latest/` and `/v<version>/`): `README.md`.
90
122
 
@@ -95,10 +127,12 @@ Full detail, including the two live URLs (`/latest/` and `/v<version>/`): `READM
95
127
  template works (see above); `pnpm test` is the trusted local loop, real Grist via `/dev/`
96
128
  is the trusted real one
97
129
  - Merging `dev` into `main` without bumping `package.json`'s `version` first
130
+ - Adding widget code outside `src/app/`, or editing the template's files to change the
131
+ widget (see the widget contract above)
98
132
  - Adding `minimumReleaseAgeExclude` entries to `pnpm-workspace.yaml` to get an install or a
99
133
  check green, or deleting the ones pnpm wrote for `grist-widget-sdk` and `create-grist-widget` on release day (see
100
134
  `README.md`): pnpm's 24 h age check protects the widget's supply chain
101
135
  - Merging a Dependabot pull request whose `ci.yml` checks are red, or retargeting one at
102
- `main`: updates go through `dev` like any other change. Template-file updates (this
103
- pipeline, the build config) are applied by hand —
136
+ `main`: updates go through `dev` like any other change, template-file updates
137
+ (`pnpm exec create-grist-widget upgrade`) included —
104
138
  <https://gristwidgets.com/docs/cli/upgrading>
@@ -12,12 +12,14 @@ Opened outside a Grist iframe, the page is never a live preview of the widget it
12
12
 
13
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, the SDK's alerts report what needs attention (column mapping, access level, a failed write…) above it. Nothing is drawn over the widget while all is well. Pass your own `Alerts` component to `mountGristWidget()` to render them differently, or `Alerts: null` for none.
14
14
 
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.
15
+ The widget lives in `src/app/`: `App.tsx`, and any components, hooks, helpers or assets it needs. `src/components/ui/` (shadcn primitives) and `src/index.css` (the theme) are yours too. `src/main.tsx`, the workflows and the Vite and ESLint config are the template's (`AGENTS.md` lists them): leave them as they are: `pnpm exec create-grist-widget upgrade`, run on `dev`, rewrites them when `create-grist-widget` is updated ([Upgrading a widget](https://gristwidgets.com/docs/cli/upgrading)).
16
+
17
+ `src/app/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. 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/app/grist-types.example.ts` for typing patterns.
16
18
 
17
19
  - **ESLint** blocks direct `grist` global usage in `src/` — use the SDK only.
18
- - Edit `GRIST_OPTIONS.columns` in `App.tsx` to change the required columns (or remove the array entirely to read raw, unmapped records instead); `mountGristWidget()` gates the widget on `canRender` whenever columns are declared, so it renders once they're mapped.
20
+ - Edit `GRIST_OPTIONS.columns` in `src/app/App.tsx` to change the required columns (or remove the array entirely to read raw, unmapped records instead); `mountGristWidget()` gates the widget on `canRender` whenever columns are declared, so it renders once they're mapped.
19
21
 
20
- Run `pnpm test`. `src/App.smoke.test.tsx` is the smoke test, one `smokeTestWidget()` call that checks the widget renders inside an emulated Grist with every declared column mapped, and that CI runs on every pull request; see `src/App.test.tsx` for the pattern (`renderWithGrist` + `presets` from `grist-widget-sdk/emulator/testing`, no browser or real Grist doc needed). Full guide: [Testing](https://gristwidgets.com/docs/emulator/testing).
22
+ Run `pnpm test`. `src/app/App.smoke.test.tsx` is the smoke test, one `smokeTestWidget()` call that checks the widget renders inside an emulated Grist with every declared column mapped, and that CI runs on every pull request; see `src/app/App.test.tsx` for the pattern (`renderWithGrist` + `presets` from `grist-widget-sdk/emulator/testing`, no browser or real Grist doc needed). Full guide: [Testing](https://gristwidgets.com/docs/emulator/testing).
21
23
 
22
24
  **Monorepo dev:** this template resolves `grist-widget-sdk` from `packages/core/dist` (like the other widgets), not from SDK source. After changing the SDK, run `pnpm prebuild` or `pnpm --filter grist-widget-sdk build` before `pnpm dev`.
23
25
 
@@ -135,9 +137,10 @@ dependencies (`grist-widget-sdk` included) and the workflows' GitHub Actions,
135
137
  and `.github/workflows/ci.yml` lints, tests and builds every pull request.
136
138
  Merge a green one into `dev`, check `/dev/` inside Grist, then release it
137
139
  like any change (bump the version, merge `dev` into `main`). Changes to the
138
- template's own files (this pipeline, the build config) don't reach an
139
- existing widget that way: see
140
- [Upgrading a widget](https://gristwidgets.com/docs/cli/upgrading).
140
+ template's own files (this pipeline, the build config) arrive with
141
+ `create-grist-widget`'s updates: after merging one, run
142
+ `pnpm exec create-grist-widget upgrade` on `dev` (see
143
+ [Upgrading a widget](https://gristwidgets.com/docs/cli/upgrading)).
141
144
 
142
145
  **One-time setup** (the workflow can't do this part for you):
143
146
 
@@ -156,6 +159,20 @@ existing widget that way: see
156
159
  read-only, which would fail the push to `gh-pages` with a 403.
157
160
  3. If the repo is private, set **Pages visibility to Public** — a widget
158
161
  embeds inside a Grist iframe, which needs a publicly reachable URL.
162
+ 4. **After the first release**, protect the branches: Settings → Rules →
163
+ Rulesets → New branch ruleset. Not before: the first release itself
164
+ commits to `main` and resets `dev` (see above).
165
+ - `main`: **Require a pull request before merging**, **Require status
166
+ checks to pass** with the `check` status from `ci.yml`, **Block force
167
+ pushes**, **Restrict deletions**. A change then reaches `main` only
168
+ through a green pull request (your `dev` → `main` release PR included).
169
+ - `dev`: **Block force pushes** and **Restrict deletions**. Not the
170
+ pull-request rule: the version bump before a release is pushed straight
171
+ to `dev`.
172
+
173
+ An AI agent working on this repo acts through your GitHub account, so
174
+ these rules can't tell it from you. They guarantee the outcome (nothing
175
+ red reaches `main`), whoever merges.
159
176
 
160
177
  No manifest/widget-catalog file is generated — that's a multi-widget,
161
178
  Grist-widget-repository concept this single-widget template doesn't need.
@@ -35,7 +35,7 @@
35
35
  "@types/react": "^19.3.0",
36
36
  "@types/react-dom": "^19.3.0",
37
37
  "@vitejs/plugin-react": "^6.1.2",
38
- "create-grist-widget": "^0.3.27",
38
+ "create-grist-widget": "^0.3.29",
39
39
  "eslint": "^10.13.0",
40
40
  "eslint-plugin-react-hooks": "^7.0.1",
41
41
  "eslint-plugin-react-refresh": "^0.5.7",
@@ -6,7 +6,10 @@
6
6
  * and it improves with each SDK release. `.github/workflows/ci.yml` runs it
7
7
  * on every pull request, Dependabot's `grist-widget-sdk` updates included:
8
8
  * an SDK release that breaks this widget turns that PR red before the merge.
9
- * Widget-specific assertions go in their own tests (see `App.test.tsx`).
9
+ *
10
+ * It is the widget's own file: a widget using a browser API jsdom lacks
11
+ * (WebGL, say) stubs it here, with `vi.mock`. Widget-specific assertions go
12
+ * in their own tests (see `App.test.tsx`).
10
13
  */
11
14
  import { it } from "vitest"
12
15
  import { smokeTestWidget } from "grist-widget-sdk/emulator/testing"
@@ -1,7 +1,7 @@
1
1
  import { mountGristWidget } from "grist-widget-sdk/app"
2
2
 
3
3
  import "./index.css"
4
- import App, { GRIST_OPTIONS } from "./App.tsx"
4
+ import App, { GRIST_OPTIONS } from "./app/App.tsx"
5
5
 
6
6
  // Inside Grist: the widget, behind the SDK's connection screens and alerts,
7
7
  // in Grist's light/dark theme. Opened anywhere else (`pnpm dev`, a deployed
@@ -28,7 +28,7 @@ export default defineConfig({
28
28
  environment: "jsdom",
29
29
  // Most examples/<id>/ don't ship their own WidgetApp.test.tsx yet (it's
30
30
  // optional, per docs/planning/2026-08-execution-plan.md §3) --
31
- // overlayExampleSrc correctly deletes the template's own App.test.tsx
31
+ // overlayExampleSrc correctly deletes the template's own app/App.test.tsx
32
32
  // when swapping in one that has none (it would otherwise assert against
33
33
  // content that's no longer there), so `create --example <id>` / `use
34
34
  // <id>` routinely leave a project with zero test files. Without this,
@@ -29,6 +29,7 @@
29
29
  import {
30
30
  cpSync,
31
31
  existsSync,
32
+ mkdirSync,
32
33
  readFileSync,
33
34
  readdirSync,
34
35
  rmSync,
@@ -92,18 +93,20 @@ export function createComposer({ templateDir, examplesDir, uiDir, manifestPath }
92
93
  }
93
94
  const manifestEntry = loadManifestEntry(exampleId)
94
95
  const srcDir = join(destDir, "src")
96
+ const appDir = join(srcDir, "app")
95
97
 
96
- // Clear whatever the previous overlay (if any) left behind, except the
97
- // template's own fixed structure (components/ui, lib, index.css, etc.) —
98
- // only remove files this function itself may have added, so a stale
99
- // sibling from a *different* previous example doesn't linger.
100
- for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
101
- if (entry.name === "App.tsx" || entry.name === "App.test.tsx" || entry.name === "App.smoke.test.tsx") continue
102
- if (["components", "lib", "assets", "index.css", "vite-env.d.ts", "main.tsx"].includes(entry.name)) {
103
- continue
104
- }
105
- rmSync(join(srcDir, entry.name), { recursive: true, force: true })
106
- }
98
+ // src/app/ is the widget's own folder (see the template's AGENTS.md), so
99
+ // the example replaces it whole: nothing from the previous widget lingers,
100
+ // its test included, which would assert against content no longer there.
101
+ // The template's files around it (main.tsx, components/ui, lib,
102
+ // index.css) stay as they are.
103
+ rmSync(appDir, { recursive: true, force: true })
104
+ mkdirSync(appDir, { recursive: true })
105
+
106
+ // The template's smoke test comes back with any example: it only reads
107
+ // GRIST_OPTIONS, so a widget started from an example is smoke-tested too.
108
+ const smokeTest = join(templateDir, "src/app/App.smoke.test.tsx")
109
+ if (existsSync(smokeTest)) cpSync(smokeTest, join(appDir, "App.smoke.test.tsx"))
107
110
 
108
111
  // The host contract's only mismatch: examples export `WidgetApp` (no
109
112
  // default export), the template's main.tsx does a default-only import.
@@ -111,23 +114,18 @@ export function createComposer({ templateDir, examplesDir, uiDir, manifestPath }
111
114
  // stays untouched under examples/) — same idiom as deploy.mjs's
112
115
  // identityEdits.
113
116
  const widgetAppSrc = readFileSync(join(exampleDir, "WidgetApp.tsx"), "utf8")
114
- writeFileSync(join(srcDir, "App.tsx"), `${widgetAppSrc}\nexport default WidgetApp\n`)
117
+ writeFileSync(join(appDir, "App.tsx"), `${widgetAppSrc}\nexport default WidgetApp\n`)
115
118
 
116
119
  const testPath = join(exampleDir, "WidgetApp.test.tsx")
117
- const destTestPath = join(srcDir, "App.test.tsx")
118
120
  if (existsSync(testPath)) {
119
- writeFileSync(destTestPath, readFileSync(testPath, "utf8"))
120
- } else if (existsSync(destTestPath)) {
121
- // The template's own test (or a previous example's) asserts against
122
- // content that's no longer there once App.tsx is someone else's widget.
123
- rmSync(destTestPath)
121
+ writeFileSync(join(appDir, "App.test.tsx"), readFileSync(testPath, "utf8"))
124
122
  }
125
123
 
126
124
  // Every other file living alongside WidgetApp.tsx (e.g. spreadsheet-grid's 5
127
125
  // sibling components) travels with it, verbatim.
128
126
  for (const entry of readdirSync(exampleDir, { withFileTypes: true })) {
129
127
  if (entry.name === "WidgetApp.tsx" || entry.name === "WidgetApp.test.tsx") continue
130
- cpSync(join(exampleDir, entry.name), join(srcDir, entry.name), { recursive: true })
128
+ cpSync(join(exampleDir, entry.name), join(appDir, entry.name), { recursive: true })
131
129
  }
132
130
 
133
131
  // `@/components/ui/*` the template doesn't already vendor (per the
File without changes
File without changes