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.
- package/README.md +25 -7
- package/bin/create-grist-widget.mjs +125 -9
- package/bin/grist-widget-deploy.mjs +92 -0
- package/examples/reads-demo/WidgetApp.tsx +2 -1
- package/examples/safe-parse/WidgetApp.tsx +2 -1
- package/manifest.json +2 -2
- package/package.json +3 -2
- package/{template/scripts → src}/deploy.mjs +13 -81
- package/src/git-gate.mjs +2 -2
- package/src/upgrade.mjs +562 -0
- package/template/.github/dependabot.yml +5 -4
- package/template/.github/workflows/deploy.yml +32 -11
- package/template/AGENTS.md +36 -11
- package/template/README.md +19 -13
- package/template/package.json +2 -1
- package/template/src/app/App.smoke.test.tsx +20 -0
- package/template/src/main.tsx +1 -1
- package/template/vite.config.ts +1 -1
- package/vendor/compose-example.mjs +23 -20
- /package/template/src/{App.test.tsx → app/App.test.tsx} +0 -0
- /package/template/src/{App.tsx → app/App.tsx} +0 -0
- /package/template/src/{grist-types.example.ts → app/grist-types.example.ts} +0 -0
package/src/upgrade.mjs
ADDED
|
@@ -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
|
|
25
|
-
#
|
|
26
|
-
#
|
|
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:
|