create-grist-widget 0.3.7 → 0.3.9

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.
Files changed (32) hide show
  1. package/README.md +79 -7
  2. package/bin/create-grist-widget.mjs +485 -90
  3. package/examples/attachment-gallery/WidgetApp.tsx +202 -0
  4. package/examples/bulk-mark-done/WidgetApp.tsx +43 -0
  5. package/examples/declared-columns/WidgetApp.tsx +42 -0
  6. package/examples/form-edit/WidgetApp.tsx +225 -0
  7. package/examples/hello-world/WidgetApp.tsx +15 -0
  8. package/examples/mark-done/WidgetApp.tsx +44 -0
  9. package/examples/reads-demo/WidgetApp.tsx +69 -0
  10. package/examples/rest-fetch/WidgetApp.tsx +49 -0
  11. package/examples/safe-parse/WidgetApp.tsx +61 -0
  12. package/examples/schema-migration/WidgetApp.tsx +67 -0
  13. package/examples/schema-preview/WidgetApp.tsx +26 -0
  14. package/examples/slice-hooks/WidgetApp.tsx +47 -0
  15. package/examples/spreadsheet-grid/GristDataSheetGrid.tsx +376 -0
  16. package/examples/spreadsheet-grid/GristRdgGrid.tsx +189 -0
  17. package/examples/spreadsheet-grid/GristSchemaToolbar.tsx +293 -0
  18. package/examples/spreadsheet-grid/WidgetApp.tsx +133 -0
  19. package/examples/spreadsheet-grid/grist-rdg-choice-column.tsx +616 -0
  20. package/examples/spreadsheet-grid/grist-rdg-columns.ts +397 -0
  21. package/examples/task-board/WidgetApp.tsx +229 -0
  22. package/examples/theme-demo/WidgetApp.tsx +26 -0
  23. package/examples/widget-options/WidgetApp.tsx +34 -0
  24. package/examples/writes-demo/WidgetApp.tsx +75 -0
  25. package/manifest.json +304 -0
  26. package/package.json +14 -2
  27. package/src/git-gate.mjs +30 -0
  28. package/template/scripts/deploy.mjs +35 -1
  29. package/ui-source/ShadcnDataSheetGrid.css +117 -0
  30. package/ui-source/ShadcnDataSheetGrid.tsx +46 -0
  31. package/ui-source/badge.tsx +49 -0
  32. package/vendor/compose-example.mjs +252 -0
@@ -1,105 +1,500 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, mkdirSync, readdirSync, cpSync, readFileSync, writeFileSync, renameSync } from "node:fs"
2
+ // create-grist-widget — scaffold a Grist custom widget, fully offline.
3
+ //
4
+ // Four verbs (batch H, docs/planning/2026-08-execution-plan.md §98-105):
5
+ // create <name> [--example <id>] scaffold a new widget into ./<name>
6
+ // init [--example <id>] scaffold into the current directory
7
+ // use <example-id> swap the current project's widget
8
+ // list [--json] print the available starting examples
9
+ //
10
+ // `create`/`init`/`use` share one compose engine — see
11
+ // packages/create-grist-widget/vendor/compose-example.mjs (a verbatim,
12
+ // path-free copy of scripts/compose-example.mjs, embedded by
13
+ // scripts/build-template.mjs alongside the template itself, along with
14
+ // examples/, ui-source/, and manifest.json — see that script for why).
15
+ // Nothing here ever hits the network: everything list/use/create --example
16
+ // need already shipped in the tarball.
17
+
18
+ import {
19
+ cpSync,
20
+ existsSync,
21
+ mkdirSync,
22
+ readdirSync,
23
+ readFileSync,
24
+ renameSync,
25
+ writeFileSync,
26
+ } from "node:fs"
3
27
  import path from "node:path"
4
28
  import { fileURLToPath } from "node:url"
5
29
  import { execFileSync } from "node:child_process"
30
+ import * as clack from "@clack/prompts"
6
31
 
7
- const TEMPLATE_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "template")
32
+ import { createComposer } from "../vendor/compose-example.mjs"
33
+ import { checkGitGate } from "../src/git-gate.mjs"
8
34
 
9
- const rawName = process.argv[2]
10
- if (!rawName) {
11
- console.error("Usage: npm create grist-widget <widget-name>")
12
- process.exit(1)
35
+ const PKG_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..")
36
+ const TEMPLATE_DIR = path.join(PKG_ROOT, "template")
37
+
38
+ const cliPkg = JSON.parse(readFileSync(path.join(PKG_ROOT, "package.json"), "utf8"))
39
+
40
+ const composer = createComposer({
41
+ templateDir: TEMPLATE_DIR,
42
+ examplesDir: path.join(PKG_ROOT, "examples"),
43
+ uiDir: path.join(PKG_ROOT, "ui-source"),
44
+ manifestPath: path.join(PKG_ROOT, "manifest.json"),
45
+ })
46
+
47
+ function loadManifest() {
48
+ return JSON.parse(readFileSync(path.join(PKG_ROOT, "manifest.json"), "utf8"))
13
49
  }
14
50
 
15
51
  // Mirrors npm's own unscoped package name rule, kept intentionally loose.
16
- if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(rawName)) {
17
- console.error(`"${rawName}" is not a valid name — use lowercase letters, digits, and hyphens.`)
18
- process.exit(1)
52
+ function isValidName(name) {
53
+ return /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(name || "")
19
54
  }
20
55
 
21
- const target = path.resolve(process.cwd(), rawName)
22
- if (existsSync(target) && readdirSync(target).length > 0) {
23
- console.error(`"${rawName}" already exists and is not empty.`)
24
- process.exit(1)
56
+ function titleCase(name) {
57
+ return name
58
+ .split("-")
59
+ .filter(Boolean)
60
+ .map((w) => w[0].toUpperCase() + w.slice(1))
61
+ .join(" ")
62
+ }
63
+
64
+ function detectPackageManager() {
65
+ // e.g. "pnpm/9.1.0 npm-node/..." -> "pnpm". Set by every package manager's
66
+ // own `npm create`-equivalent invocation. Falls back to npm, matching
67
+ // what a bare `node bin/create-grist-widget.mjs` (no wrapping pm) should do.
68
+ const ua = process.env.npm_config_user_agent || ""
69
+ return ua.split("/")[0] || "npm"
70
+ }
71
+
72
+ function parseArgs(argv) {
73
+ const out = { _: [] }
74
+ for (let i = 0; i < argv.length; i++) {
75
+ const a = argv[i]
76
+ if (a.startsWith("--")) {
77
+ const key = a.slice(2)
78
+ const next = argv[i + 1]
79
+ if (next === undefined || next.startsWith("--")) out[key] = true
80
+ else {
81
+ out[key] = next
82
+ i++
83
+ }
84
+ } else out._.push(a)
85
+ }
86
+ return out
87
+ }
88
+
89
+ function printHelp() {
90
+ console.log(`create-grist-widget — scaffold a Grist custom widget, offline
91
+
92
+ Usage:
93
+ npm create grist-widget <name> [options] Scaffold into ./<name>
94
+ create-grist-widget create <name> [options] Same as above, explicit
95
+ create-grist-widget init [options] Scaffold into the current directory
96
+ create-grist-widget use <example-id> Swap the current project's widget
97
+ create-grist-widget list [--json] List available starting examples
98
+
99
+ Options (create / init):
100
+ --example <id> Start from a named example instead of the blank template
101
+ (see "list" for ids)
102
+ --yes Skip interactive prompts, use defaults/flags only
103
+ --no-install Don't run the package manager install automatically
104
+ --pm <name> Package manager for install (default: detected from how
105
+ you invoked this — npm/pnpm/yarn/bun)
106
+ --name <name> (init only) Widget name — default: the current directory's name
107
+ --force (init only) Overwrite files that already exist and differ
108
+ from the template
109
+
110
+ --help, -h Show this help
111
+ --version, -v Show this CLI's version
112
+
113
+ Note: "create", "init", "use", and "list" are themselves valid widget names.
114
+ A bare "create-grist-widget list" runs the list command, not "scaffold a
115
+ widget named list" — use "create-grist-widget create list" for that.`)
116
+ }
117
+
118
+ /** Same four placeholder-identity edits every scaffold gets, whichever verb produced it. */
119
+ function applyIdentityEdits(target, rawName) {
120
+ const title = titleCase(rawName)
121
+ const edits = [
122
+ ["package.json", [["grist-widget-template-vite", rawName]]],
123
+ ["README.md", [["grist-widget-template-vite", rawName]]],
124
+ ["index.html", [["<title>Grist Widget</title>", `<title>${title}</title>`]]],
125
+ ["src/App.tsx", [['title: "Grist Widget Template",', `title: "${title}",`]]],
126
+ ]
127
+ for (const [file, subs] of edits) {
128
+ const p = path.join(target, file)
129
+ if (!existsSync(p)) continue
130
+ let content = readFileSync(p, "utf8")
131
+ for (const [search, replace] of subs) content = content.split(search).join(replace)
132
+ writeFileSync(p, content)
133
+ }
134
+ return title
25
135
  }
26
- mkdirSync(target, { recursive: true })
27
- cpSync(TEMPLATE_DIR, target, { recursive: true })
28
136
 
29
137
  // npm strips a literal `.gitignore` from the published package (see
30
- // build-template.mjs), so it ships as `_gitignore` — restore the real name.
31
- const gitignorePath = path.join(target, "_gitignore")
32
- if (existsSync(gitignorePath)) renameSync(gitignorePath, path.join(target, ".gitignore"))
33
-
34
- const title = rawName
35
- .split("-")
36
- .map((w) => w[0].toUpperCase() + w.slice(1))
37
- .join(" ")
38
-
39
- // Small, fixed set of files carry the template's own identity — swap it for the user's.
40
- const edits = [
41
- ["package.json", [["grist-widget-template-vite", rawName]]],
42
- ["README.md", [["grist-widget-template-vite", rawName]]],
43
- ["index.html", [["<title>Grist Widget</title>", `<title>${title}</title>`]]],
44
- ["src/App.tsx", [['title: "Grist Widget Template",', `title: "${title}",`]]],
45
- ]
46
- for (const [file, subs] of edits) {
47
- const p = path.join(target, file)
48
- let content = readFileSync(p, "utf8")
49
- for (const [search, replace] of subs) content = content.split(search).join(replace)
50
- writeFileSync(p, content)
51
- }
52
-
53
- // Record which create-grist-widget release produced this scaffold -- purely
54
- // informational (shown in a small landing-page footer), never read by the
55
- // deploy pipeline's own version/release logic. Every scaffold gets this,
56
- // including the ones the template-canary workflow generates for its `dev`
57
- // and `canary/latest` branches -- so it survives even if that content is
58
- // later promoted into a reference repo's `main` and copied again via
59
- // GitHub's "Use this template".
60
- const cliPkg = JSON.parse(
61
- readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"),
62
- )
63
- const pkgJsonPath = path.join(target, "package.json")
64
- const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8"))
65
- pkgJson.createGristWidgetVersion = cliPkg.version
66
- writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2) + "\n")
67
-
68
- // Initialize git on `main` with the scaffold as its first commit, and a
69
- // `dev` branch ready to push for live-preview later. Doing this here (not
70
- // leaving it to whoever/whatever runs next) is what actually prevents the
71
- // scaffold from ending up on some other branch — the deploy workflow only
72
- // ever releases from `main`, so committing anywhere else silently never
73
- // deploys. Degrades gracefully: if there's no git identity configured yet,
74
- // `main` still exists locally, just without a commit.
75
- function git(args) {
76
- execFileSync("git", args, { cwd: target, stdio: "ignore" })
77
- }
78
- let committed = false
79
- try {
80
- git(["init", "-q", "-b", "main"])
81
- git(["add", "-A"])
82
- git(["commit", "-q", "-m", "scaffold: initial widget"])
83
- git(["branch", "dev"])
84
- committed = true
85
- } catch {
86
- // No git identity configured (or git unavailable) — leave it to the
87
- // printed next steps below.
88
- }
89
-
90
- console.log(`Created ${rawName}/\n`)
91
- console.log(` cd ${rawName}`)
92
- console.log(" pnpm install # or npm/yarn")
93
- console.log(" pnpm dev")
94
- if (committed) {
95
- console.log("\nGit is initialized on main (first commit done) with a dev branch ready.")
96
- console.log("To push to GitHub:")
97
- console.log(" git remote add origin https://github.com/<owner>/<repo>.git")
98
- console.log(" git push -u origin main")
99
- console.log(" git push origin dev # optional, for the live-preview channel")
100
- } else {
101
- console.log("\nCould not create the initial git commit automatically (no git identity")
102
- console.log('configured?) — run `git config user.name`/`user.email`, then:')
103
- console.log(" git init -b main && git add -A && git commit -m 'scaffold: initial widget'")
104
- console.log(" git branch dev")
138
+ // scripts/build-template.mjs), so it ships as `_gitignore` — restore the real name.
139
+ function restoreGitignore(target) {
140
+ const gitignorePath = path.join(target, "_gitignore")
141
+ if (existsSync(gitignorePath)) renameSync(gitignorePath, path.join(target, ".gitignore"))
142
+ }
143
+
144
+ // Purely informational (a small landing-page footer), never read by the
145
+ // deploy pipeline's own version/release logic.
146
+ function stampCreateGristWidgetVersion(target) {
147
+ const pkgJsonPath = path.join(target, "package.json")
148
+ const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8"))
149
+ pkgJson.createGristWidgetVersion = cliPkg.version
150
+ writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2) + "\n")
151
+ }
152
+
153
+ /** Initialize git on `main` with a first commit and a `dev` branch, if a git identity is configured. */
154
+ function gitInitCommit(target) {
155
+ function git(args) {
156
+ execFileSync("git", args, { cwd: target, stdio: "ignore" })
157
+ }
158
+ try {
159
+ git(["init", "-q", "-b", "main"])
160
+ git(["add", "-A"])
161
+ git(["commit", "-q", "-m", "scaffold: initial widget"])
162
+ git(["branch", "dev"])
163
+ return true
164
+ } catch {
165
+ // No git identity configured (or git unavailable) — leave it to the
166
+ // printed next steps.
167
+ return false
168
+ }
169
+ }
170
+
171
+ function maybeInstall(target, { noInstall, pm }) {
172
+ if (noInstall) return false
173
+ const manager = pm || detectPackageManager()
174
+ try {
175
+ execFileSync(manager, ["install"], { cwd: target, stdio: "inherit" })
176
+ return true
177
+ } catch {
178
+ console.warn(`\nCould not run "${manager} install" automatically — run it yourself.`)
179
+ return false
180
+ }
181
+ }
182
+
183
+ function listFilesRecursive(dir, base = dir) {
184
+ const out = []
185
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
186
+ const full = path.join(dir, entry.name)
187
+ if (entry.isDirectory()) out.push(...listFilesRecursive(full, base))
188
+ else out.push(path.relative(base, full).split(path.sep).join("/"))
189
+ }
190
+ return out
191
+ }
192
+
193
+ /** Interactive "which example?" picker, shared by `create` and `init` — reads the same manifest.json `list` does. */
194
+ async function pickExampleInteractively(manifest) {
195
+ const choice = await clack.select({
196
+ message: "Start from an example?",
197
+ options: [
198
+ { value: null, label: "Blank template (two-field editor)" },
199
+ ...manifest.map((e) => ({ value: e.id, label: e.title, hint: e.description })),
200
+ ],
201
+ })
202
+ if (clack.isCancel(choice)) {
203
+ clack.cancel("Cancelled.")
204
+ process.exit(1)
205
+ }
206
+ return choice
207
+ }
208
+
209
+ function resolveExampleFlag(exampleFlag, manifest) {
210
+ if (exampleFlag === undefined || exampleFlag === true) return { ok: true, exampleId: null }
211
+ if (exampleFlag === "blank") return { ok: true, exampleId: null }
212
+ if (!manifest.some((e) => e.id === exampleFlag)) {
213
+ return { ok: false }
214
+ }
215
+ return { ok: true, exampleId: exampleFlag }
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // create
220
+ // ---------------------------------------------------------------------------
221
+
222
+ async function cmdCreate(argv) {
223
+ const args = parseArgs(argv)
224
+ const manifest = loadManifest()
225
+ const interactive = Boolean(process.stdout.isTTY) && !args.yes && !args._[0]
226
+
227
+ let rawName = args._[0]
228
+ let exampleId = null
229
+
230
+ if (args.example !== undefined) {
231
+ const resolved = resolveExampleFlag(args.example, manifest)
232
+ if (!resolved.ok) {
233
+ console.error(`Unknown example "${args.example}". Run "create-grist-widget list" to see available examples.`)
234
+ process.exit(1)
235
+ }
236
+ exampleId = resolved.exampleId
237
+ }
238
+
239
+ if (interactive) {
240
+ clack.intro("create-grist-widget")
241
+ if (!rawName) {
242
+ const nameAnswer = await clack.text({
243
+ message: "Widget name",
244
+ placeholder: "my-widget",
245
+ validate: (v) => (isValidName(v) ? undefined : "lowercase letters, digits, hyphens only"),
246
+ })
247
+ if (clack.isCancel(nameAnswer)) {
248
+ clack.cancel("Cancelled.")
249
+ process.exit(1)
250
+ }
251
+ rawName = nameAnswer
252
+ }
253
+ if (args.example === undefined) {
254
+ exampleId = await pickExampleInteractively(manifest)
255
+ }
256
+ clack.outro("Scaffolding...")
257
+ }
258
+
259
+ if (!rawName) {
260
+ console.error("Usage: npm create grist-widget <widget-name> [--example <id>] [--yes] [--no-install] [--pm <pm>]")
261
+ process.exit(1)
262
+ }
263
+ if (!isValidName(rawName)) {
264
+ console.error(`"${rawName}" is not a valid name — use lowercase letters, digits, and hyphens.`)
265
+ process.exit(1)
266
+ }
267
+
268
+ const target = path.resolve(process.cwd(), rawName)
269
+ if (existsSync(target) && readdirSync(target).length > 0) {
270
+ console.error(`"${rawName}" already exists and is not empty.`)
271
+ process.exit(1)
272
+ }
273
+ mkdirSync(target, { recursive: true })
274
+ cpSync(TEMPLATE_DIR, target, { recursive: true })
275
+ restoreGitignore(target)
276
+ applyIdentityEdits(target, rawName)
277
+
278
+ if (exampleId) {
279
+ composer.overlayExampleSrc({ exampleId, destDir: target })
280
+ composer.mergeExtraDependencies({ exampleId, destDir: target })
281
+ }
282
+
283
+ stampCreateGristWidgetVersion(target)
284
+
285
+ const committed = gitInitCommit(target)
286
+ const installed = maybeInstall(target, {
287
+ noInstall: Boolean(args["no-install"]),
288
+ pm: typeof args.pm === "string" ? args.pm : null,
289
+ })
290
+
291
+ console.log(`\nCreated ${rawName}/${exampleId ? ` (starting from "${exampleId}")` : ""}\n`)
292
+ console.log(` cd ${rawName}`)
293
+ if (!installed) console.log(" pnpm install # or npm/yarn")
294
+ console.log(" pnpm dev")
295
+ if (committed) {
296
+ console.log("\nGit is initialized on main (first commit done) with a dev branch ready.")
297
+ console.log("To push to GitHub:")
298
+ console.log(" git remote add origin https://github.com/<owner>/<repo>.git")
299
+ console.log(" git push -u origin main")
300
+ console.log(" git push origin dev # optional, for the live-preview channel")
301
+ } else {
302
+ console.log("\nCould not create the initial git commit automatically (no git identity")
303
+ console.log('configured?) — run `git config user.name`/`user.email`, then:')
304
+ console.log(" git init -b main && git add -A && git commit -m 'scaffold: initial widget'")
305
+ console.log(" git branch dev")
306
+ }
307
+ }
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // init
311
+ // ---------------------------------------------------------------------------
312
+
313
+ async function cmdInit(argv) {
314
+ const args = parseArgs(argv)
315
+ const manifest = loadManifest()
316
+ const cwd = process.cwd()
317
+
318
+ let exampleId = null
319
+ if (args.example !== undefined) {
320
+ const resolved = resolveExampleFlag(args.example, manifest)
321
+ if (!resolved.ok) {
322
+ console.error(`Unknown example "${args.example}". Run "create-grist-widget list" to see available examples.`)
323
+ process.exit(1)
324
+ }
325
+ exampleId = resolved.exampleId
326
+ }
327
+
328
+ const interactive = Boolean(process.stdout.isTTY) && !args.yes && args.example === undefined
329
+ if (interactive) {
330
+ clack.intro("create-grist-widget init")
331
+ exampleId = await pickExampleInteractively(manifest)
332
+ clack.outro("Initializing...")
333
+ }
334
+
335
+ const rawName = typeof args.name === "string" ? args.name : path.basename(cwd)
336
+ if (!isValidName(rawName)) {
337
+ console.error(
338
+ `"${rawName}" is not a valid widget name (derived from the current directory) — pass --name.`
339
+ )
340
+ process.exit(1)
341
+ }
342
+
343
+ // File-collision policy: identity-bearing files always get edited in place
344
+ // (naturally a no-op wherever the placeholder text is already gone — the
345
+ // common case, a pristine "Use this template" clone); anything else that
346
+ // already exists and differs from the template's own pristine copy blocks
347
+ // the run, listed, unless --force.
348
+ const IDENTITY_FILES = new Set(["package.json", "README.md", "index.html", "src/App.tsx"])
349
+ const templateFiles = listFilesRecursive(TEMPLATE_DIR)
350
+ if (!args.force) {
351
+ const conflicts = []
352
+ for (const rel of templateFiles) {
353
+ if (IDENTITY_FILES.has(rel)) continue
354
+ const destPath = path.join(cwd, rel)
355
+ if (!existsSync(destPath)) continue
356
+ const same = readFileSync(path.join(TEMPLATE_DIR, rel)).equals(readFileSync(destPath))
357
+ if (!same) conflicts.push(rel)
358
+ }
359
+ if (conflicts.length > 0) {
360
+ console.error(
361
+ `init: refusing to overwrite the following files (already exist and differ from the template) — remove them or re-run with --force:\n` +
362
+ conflicts.map((f) => ` ${f}`).join("\n")
363
+ )
364
+ process.exit(1)
365
+ }
366
+ }
367
+
368
+ for (const rel of templateFiles) {
369
+ const destPath = path.join(cwd, rel)
370
+ mkdirSync(path.dirname(destPath), { recursive: true })
371
+ cpSync(path.join(TEMPLATE_DIR, rel), destPath)
372
+ }
373
+ restoreGitignore(cwd)
374
+ applyIdentityEdits(cwd, rawName)
375
+
376
+ if (exampleId) {
377
+ composer.overlayExampleSrc({ exampleId, destDir: cwd })
378
+ composer.mergeExtraDependencies({ exampleId, destDir: cwd })
379
+ }
380
+
381
+ stampCreateGristWidgetVersion(cwd)
382
+
383
+ // Never auto-commit into a pre-existing repo's history — only take over
384
+ // git entirely when cwd had none to begin with (a fresh, non-git directory,
385
+ // or a real git clone where .git already carries whatever history it has).
386
+ const gitAlreadyExists = existsSync(path.join(cwd, ".git"))
387
+ const committed = gitAlreadyExists ? false : gitInitCommit(cwd)
388
+ const installed = maybeInstall(cwd, {
389
+ noInstall: Boolean(args["no-install"]),
390
+ pm: typeof args.pm === "string" ? args.pm : null,
391
+ })
392
+
393
+ console.log(`\nInitialized ${rawName}/${exampleId ? ` (starting from "${exampleId}")` : ""} in the current directory.\n`)
394
+ if (!installed) console.log(" pnpm install # or npm/yarn")
395
+ console.log(" pnpm dev")
396
+ if (gitAlreadyExists) {
397
+ console.log("\nExisting git repository detected — no commit was made automatically.")
398
+ console.log("Review the changes (git status) and commit when ready.")
399
+ } else if (committed) {
400
+ console.log("\nGit is initialized on main (first commit done) with a dev branch ready.")
401
+ } else {
402
+ console.log("\nCould not create the initial git commit automatically (no git identity")
403
+ console.log('configured?) — run `git config user.name`/`user.email`, then commit yourself.')
404
+ }
405
+ }
406
+
407
+ // ---------------------------------------------------------------------------
408
+ // use
409
+ // ---------------------------------------------------------------------------
410
+
411
+ function cmdUse(argv) {
412
+ const args = parseArgs(argv)
413
+ const exampleId = args._[0]
414
+ if (!exampleId) {
415
+ console.error('Usage: create-grist-widget use <example-id>\nRun "create-grist-widget list" to see available examples.')
416
+ process.exit(1)
417
+ }
418
+ const manifest = loadManifest()
419
+ if (!manifest.some((e) => e.id === exampleId)) {
420
+ console.error(`Unknown example "${exampleId}". Run "create-grist-widget list" to see available examples.`)
421
+ process.exit(1)
422
+ }
423
+
424
+ const cwd = process.cwd()
425
+ const gate = checkGitGate(cwd)
426
+ if (!gate.ok) {
427
+ if (gate.reason === "no-repo") {
428
+ console.error(
429
+ '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.'
430
+ )
431
+ } else {
432
+ console.error(
433
+ "use: working tree has uncommitted changes — commit or stash them first, so this swap is easy to diff and undo."
434
+ )
435
+ }
436
+ process.exit(1)
437
+ }
438
+
439
+ composer.overlayExampleSrc({ exampleId, destDir: cwd })
440
+ const { merged } = composer.mergeExtraDependencies({ exampleId, destDir: cwd })
441
+
442
+ console.log(`Swapped in "${exampleId}".`)
443
+ console.log("\n git diff # review what changed")
444
+ if (merged) console.log(" pnpm install # new dependencies were added")
445
+ console.log(" pnpm dev")
446
+ }
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // list
450
+ // ---------------------------------------------------------------------------
451
+
452
+ function cmdList(argv) {
453
+ const args = parseArgs(argv)
454
+ const manifest = loadManifest()
455
+
456
+ if (args.json) {
457
+ // A projected, public subset — not the raw manifest, which carries
458
+ // monorepo-internal fields (columns, usesUi, sourcePath,
459
+ // hasExampleDocument) that shouldn't become an accidental CLI contract.
460
+ const projected = manifest.map((e) => ({ id: e.id, title: e.title, description: e.description }))
461
+ console.log(JSON.stringify(projected, null, 2))
462
+ return
463
+ }
464
+
465
+ const idWidth = Math.max(2, ...manifest.map((e) => e.id.length))
466
+ for (const e of manifest) {
467
+ console.log(`${e.id.padEnd(idWidth)} ${e.title} — ${e.description}`)
468
+ }
105
469
  }
470
+
471
+ // ---------------------------------------------------------------------------
472
+ // dispatch
473
+ // ---------------------------------------------------------------------------
474
+
475
+ async function main() {
476
+ const rawArgv = process.argv.slice(2)
477
+ const [first, ...rest] = rawArgv
478
+
479
+ if (first === "--help" || first === "-h") {
480
+ printHelp()
481
+ return
482
+ }
483
+ if (first === "--version" || first === "-v") {
484
+ console.log(cliPkg.version)
485
+ return
486
+ }
487
+ if (first === "list") return cmdList(rest)
488
+ if (first === "use") return cmdUse(rest)
489
+ if (first === "init") return cmdInit(rest)
490
+ if (first === "create") return cmdCreate(rest)
491
+ // Bare invocation — `npm create grist-widget my-widget` (npm's `create`
492
+ // convention passes argv straight through, no `create` keyword), or no
493
+ // args at all (interactive). Treat the whole argv as arguments to `create`.
494
+ return cmdCreate(rawArgv)
495
+ }
496
+
497
+ main().catch((err) => {
498
+ console.error(err?.stack || String(err))
499
+ process.exit(1)
500
+ })