create-grist-widget 0.3.26 → 0.3.28

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.
@@ -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
+ }
@@ -21,12 +21,13 @@ updates:
21
21
  cooldown:
22
22
  default-days: 7
23
23
  groups:
24
- # Minor/patch bumps arrive together as one PR. grist-widget-sdk is
25
- # left out so its updates get a PR of their own, whose description
26
- # links its release notes.
24
+ # Minor/patch bumps arrive together as one PR. grist-widget-sdk and
25
+ # create-grist-widget (the deploy command deploy.yml runs) are left out
26
+ # so their updates get a PR of their own, whose description links
27
+ # their release notes.
27
28
  routine:
28
29
  update-types: ["minor", "patch"]
29
- exclude-patterns: ["grist-widget-sdk"]
30
+ exclude-patterns: ["grist-widget-sdk", "create-grist-widget"]
30
31
  # @vitejs/plugin-react pins a vite major in its peer range, so their
31
32
  # majors only build when they move together.
32
33
  vite: