uniweb 1.0.0 → 1.0.2

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 (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Reject unrecognized flags on the backend verbs.
3
+ *
4
+ * Every one of these commands can send data to, or authenticate against, a remote
5
+ * host — and the CLI resolves flags by scanning argv for a literal, so a flag it
6
+ * does not recognize does not fail: it *disappears*, and the thing it was meant to
7
+ * change silently keeps its default.
8
+ *
9
+ * Tolerable for a cosmetic flag; dangerous for any flag that aims the command or picks
10
+ * its identity. This turns that class into one sentence.
11
+ *
12
+ * ⛔ **A backend flag and `--token` are NOT flags of these verbs** *(2026-09-21)*. Every
13
+ * backend verb goes to the backend you are logged in to, with that login's session:
14
+ * switching and signing in are `uniweb login` (`--server`, `--token`), and a script
15
+ * aims one process with UNIWEB_SERVER + UNIWEB_TOKEN. Passed to a verb, each is an
16
+ * unknown flag — and this guard is what makes that a loud error, with a pointer to the
17
+ * login, instead of a command that quietly runs against wherever and as whoever you
18
+ * happen to be. `--server` stays on `forget`, where it SELECTS which backend's records
19
+ * to remove. ⛔ *The flag was `--backend` until 2026-10-07, renamed so it is not read as
20
+ * a site's `backend` service; `--backend` is refused everywhere, naming `--server`.*
21
+ *
22
+ * ⚠️ A wrong rejection is worse than a missed one — it breaks an invocation that
23
+ * works — so the per-command lists must be complete, INCLUDING flags read by
24
+ * helpers rather than by the command file itself. Two live examples: `--no-validate`
25
+ * is consumed inside `utils/conformance.js`, and `--yes` inside
26
+ * `backend/foundation-bring-along.js`. Grepping only the command's own source
27
+ * misses both. When you add a flag anywhere on one of these paths, add it here.
28
+ */
29
+
30
+ import { findUnknownFlags, didYouMean } from './args.js'
31
+
32
+ /** Accepted by every command, wherever they are actually consumed. */
33
+ const GLOBAL = ['--non-interactive', '--help', '-h']
34
+
35
+ /**
36
+ * Login-method flags. Any verb that can hit an unauthenticated backend may fall
37
+ * into `ensureRegistryAuth`, which hands `args` to the login picker — so these are
38
+ * genuinely reachable from all of them, not just from `uniweb login`.
39
+ */
40
+ const AUTH = ['--browser', '--password', '--token-paste']
41
+
42
+ /**
43
+ * Flags a verb inherits by importing `commands/deploy.js` for `resolveSiteDir`.
44
+ * Inert on these verbs, and listed rather than filtered:
45
+ * accepting a flag that does nothing is exactly the pre-guard behaviour, while
46
+ * rejecting one that works is a broken command. The guard's job is catching
47
+ * `--backed`, not policing inert-but-valid spellings.
48
+ */
49
+ const VIA_DEPLOY = ['--target', '--host', '--no-save']
50
+
51
+ /**
52
+ * Per-verb flag sets. Derived by scanning each command for dash-literals AND the
53
+ * helpers it calls — not from the help text, which has drifted from the parser in
54
+ * both directions (`--yes` is documented on `publish` and consumed two files away).
55
+ */
56
+ const VERBS = {
57
+ push: [
58
+ '--all', '--org', '--dry-run', '--force',
59
+ '--foundation', '--output', '-o', '--personal',
60
+ // read in utils/conformance.js and backend/site-sync.js respectively —
61
+ // neither appears in push.js
62
+ '--no-validate', '--yes',
63
+ // via backend/foundation-bring-along.js, which push runs since 2026-08-19 —
64
+ // one of the three flags that skip its prompts. Found by
65
+ // flag-guard-coverage.test.js the moment push gained the import, which is
66
+ // exactly the hand-enumeration failure that test exists to catch.
67
+ '--no-verify',
68
+ // ship content against the already-released code, releasing nothing — or
69
+ // release above a newer registered version instead of stopping (both bring-along)
70
+ '--no-release', '--bump', ...VIA_DEPLOY
71
+ ],
72
+ publish: [
73
+ '--org', '--dry-run', '--force', '--foundation',
74
+ '--personal',
75
+ // read in utils/conformance.js, backend/site-sync.js, and
76
+ // backend/foundation-bring-along.js — none appear in publish.js
77
+ '--no-validate', '--yes', '--no-verify', '--no-release', '--bump', ...VIA_DEPLOY
78
+ ],
79
+ pull: [
80
+ '--content-only', '--dry-run', '--force', '--merge',
81
+ '--no-assets',
82
+ '--no-records', '--no-delete', '--no-prune',
83
+ // via backend/site-sync.js (the owner resolver) and utils/conformance.js
84
+ '--yes', '--org', '--personal', '--no-validate', ...VIA_DEPLOY
85
+ ],
86
+ clone: [
87
+ '--content-only', '--no-assets', '--no-records', '--path',
88
+ '--project', '--org', '--personal'
89
+ ],
90
+ register: [
91
+ '--dry-run', '--json', '--output', '-o',
92
+ '--schema-only', '--scope', '--org',
93
+ // reach the login a register may trigger, which chooses the workspace it works in
94
+ '--personal'
95
+ ],
96
+ /**
97
+ * `forget` = remove one backend's records (`--server <url>`), or everything a
98
+ * copied project inherited (`--all`). One of the two is required — the verb refuses
99
+ * without it — and they exclude each other. `--non-interactive` reaches it through
100
+ * resolveSiteDir in a workspace of several sites.
101
+ */
102
+ forget: ['--server', '--all'],
103
+ /**
104
+ * `site list | unpublish | delete` — a workspace's sites. `--yes` confirms a write;
105
+ * `--json` is `list`'s porcelain; the workspace is `--org` / `--personal`.
106
+ */
107
+ site: ['--json', '--yes', '--org', '--personal'],
108
+ status: [
109
+ '--json', '--remote', '--dry-run',
110
+ '--force', '--no-verify', '--no-validate', '--yes', '--org', '--personal',
111
+ // inert here, reachable through the bring-along module status imports for
112
+ // `resolveLocalFoundation` — listed per the over-approximation note above
113
+ '--no-release', '--bump', ...VIA_DEPLOY
114
+ ],
115
+ /**
116
+ * `refresh` = `git pull`, then a DELEGATED `pull --merge`.
117
+ *
118
+ * It forwards nothing of its own argv to that pull — it builds `['--merge']`
119
+ * itself. So pull's own flags (`--merge`, `--force`, `--no-delete`, `--no-prune`,
120
+ * `--content-only`, …) are NOT reachable from a `refresh` argv and
121
+ * are deliberately absent here. ⚠️ `--force` especially: `refresh` is read-only by
122
+ * design, and forwarding it would ask pull to DISCARD local work.
123
+ *
124
+ * The trailing group is inert on this verb but reachable through the
125
+ * `resolveSiteDir` / `probeUnpushed` imports — listed rather than filtered, per
126
+ * the VIA_DEPLOY note above, and required by `flag-guard-coverage.test.js`.
127
+ */
128
+ refresh: [
129
+ '--no-backend', '--no-git',
130
+ '--org', '--personal', '--dry-run', '--no-validate', '--yes', ...VIA_DEPLOY
131
+ ]
132
+ }
133
+
134
+ /**
135
+ * `sync` is `refresh` + `push`, forwarding RAW argv to both halves — so its
136
+ * accepted set is exactly the UNION of theirs. Derived, never hand-written.
137
+ *
138
+ * Hand-writing it would rot the moment either half gained a flag, and it would rot
139
+ * in the expensive direction: `uniweb sync --no-git` and `uniweb sync --force` are
140
+ * both documented in `sync.js`, and a union that forgot either would reject a
141
+ * working command.
142
+ *
143
+ * ⭐ The union is also WHY `sync` checks and the two halves skip (`skipFlagCheck`,
144
+ * see `sync.js`). Each half's set is a strict subset of this one, so letting them
145
+ * re-check would have `push` reject `--no-git` and `refresh` reject `--force` —
146
+ * both legal on `sync`. Validating once, against the union, at the only layer that
147
+ * knows both halves are in play, is what makes the composite safe.
148
+ */
149
+ VERBS.sync = [...new Set([...VERBS.refresh, ...VERBS.push])]
150
+
151
+ /**
152
+ * The accepted set per verb: its own flags, plus the login-method flags every
153
+ * backend verb can reach, plus the globals.
154
+ *
155
+ * Derived from each verb's IMPORT GRAPH, not from its own source and not from the
156
+ * help text — `test/flag-guard-coverage.test.js` walks that graph and fails if a
157
+ * verb can honour a flag this list omits. Deliberately an over-approximation: a
158
+ * flag accepted here but inert costs nothing (it was ignored before the guard
159
+ * existed), while one rejected here breaks a working command.
160
+ */
161
+ /**
162
+ * Read in the verbs' import graphs, and deliberately NOT accepted by them.
163
+ *
164
+ * `--token` is read by `runRegistryLogin` (utils/registry-auth.js) for
165
+ * `uniweb login --token <bearer>`, which seeds and STORES a session. The verbs reach
166
+ * that function through `ensureRegistryAuth`, which strips `--token` from their argv
167
+ * first — so a static walk sees the read, and no verb can honour it.
168
+ * `flag-guard-coverage.test.js` subtracts this list; add to it only with the same
169
+ * proof: a read no verb's argv can reach.
170
+ */
171
+ export const LOGIN_ONLY = ['--token']
172
+
173
+ export const VERB_FLAGS = Object.fromEntries(
174
+ Object.entries(VERBS).map(([verb, flags]) => [
175
+ verb,
176
+ [...new Set([...flags, ...AUTH])]
177
+ ])
178
+ )
179
+
180
+ /**
181
+ * Check `args` against the verb's accepted set. Returns null when everything is
182
+ * recognized, or a ready-to-print message naming the first offender (plus a
183
+ * suggestion when one is close).
184
+ *
185
+ * Reports ONE flag rather than all of them: the first is usually the cause, and a
186
+ * list invites skimming past the suggestion, which is the actionable half.
187
+ *
188
+ * @param {string} verb - a key of VERB_FLAGS
189
+ * @param {string[]} args - the argv slice for this command
190
+ * @returns {{ flag: string, message: string, suggestion: string|null }|null}
191
+ */
192
+ export function checkFlags(verb, args = []) {
193
+ const known = VERB_FLAGS[verb]
194
+ if (!known) return null
195
+ const all = [...known, ...GLOBAL]
196
+ const unknown = findUnknownFlags(args, all)
197
+ if (!unknown.length) return null
198
+
199
+ const flag = unknown[0]
200
+ // ⭐ `--backend`, `--server` and `--token` are not typos on these verbs — they aim a
201
+ // command or pick its identity, which only the login does (2026-09-21), and `--backend`
202
+ // is `--server` since 2026-10-07. The useful answer is where each went, not "run --help".
203
+ if (flag === '--backend' && verb === 'forget') {
204
+ return {
205
+ flag,
206
+ suggestion: '--server',
207
+ message: '`--backend` is now `--server`: uniweb forget --server <url>'
208
+ }
209
+ }
210
+ if (flag === '--backend' || flag === '--server') {
211
+ return {
212
+ flag,
213
+ suggestion: null,
214
+ message: [
215
+ `\`uniweb ${verb}\` has no \`${flag}\`: it goes to the backend you are logged in to.`,
216
+ ' Switch with: uniweb login --server <url>',
217
+ ' (A script can aim one process with UNIWEB_SERVER instead.)'
218
+ ].join('\n')
219
+ }
220
+ }
221
+ if (flag === '--token') {
222
+ return {
223
+ flag,
224
+ suggestion: null,
225
+ message: [
226
+ `\`uniweb ${verb}\` has no \`--token\`: it uses the session of the backend you are logged in to.`,
227
+ ' Sign in with a token: uniweb login --server <url> --token <bearer>',
228
+ ' (A script can authenticate one process with UNIWEB_TOKEN instead.)'
229
+ ].join('\n')
230
+ }
231
+ }
232
+ // ⭐ `--as-org` is RETIRED (2026-09-23) with the `?as_org=` it mirrored — the request
233
+ // names its workspace in a header now, and `--org` is the one spelling.
234
+ if (flag === '--as-org' && all.includes('--org')) {
235
+ return {
236
+ flag,
237
+ suggestion: '--org',
238
+ message: `\`--as-org\` is retired — use \`--org @org\` (e.g. \`uniweb ${verb} --org @acme\`).`
239
+ }
240
+ }
241
+ const suggestion = didYouMean(flag, all)
242
+ const lines = [`Unknown flag \`${flag}\` for \`uniweb ${verb}\`.`]
243
+ if (suggestion) lines.push(` Did you mean \`${suggestion}\`?`)
244
+ lines.push(` Run \`uniweb ${verb} --help\` for the accepted flags.`)
245
+ return { flag, suggestion, message: lines.join('\n') }
246
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Giving a foundation its name — `name` in `main.js`'s default export.
3
+ *
4
+ * The rule itself (main.js `name`, else package.json `name`; `src` and `foundation`
5
+ * refused) is `@uniweb/build`'s `foundation-name.js`, shared by the build, `register`
6
+ * and `push`. This module only WRITES the name: the scaffold at `create` / `add`, and
7
+ * `register` when it asks for one.
8
+ *
9
+ * ⛔ NO `@uniweb/build` IMPORT, AND NO IMPORT OF main.js. `uniweb create` runs before
10
+ * any project exists: `@uniweb/build` is an optional peer that may not be installed,
11
+ * and main.js's own imports may not be either. Hence text scans rather than a load,
12
+ * and the one duplicated fact below, pinned to the build's list by
13
+ * `test/foundation-name.test.js`. What a foundation IS named is always read by the
14
+ * build (`readFoundationName`); these only decide where to write.
15
+ */
16
+
17
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
18
+ import { join, resolve, basename, dirname } from 'node:path'
19
+
20
+ // The folders a foundation's code lives in — never a foundation's name
21
+ // (`@uniweb/build`'s FORBIDDEN_FOUNDATION_NAMES; the test keeps them equal).
22
+ export const SCAFFOLD_FOLDER_NAMES = new Set(['src', 'foundation'])
23
+
24
+ // Written above the name wherever the CLI adds one — the scaffold's main.js
25
+ // template says the same.
26
+ const NAME_COMMENT = '// What this foundation registers as — @<org>/<name> — and what sites pin.'
27
+
28
+ /**
29
+ * A name made from free text — a project's name, a folder's — in the form a
30
+ * foundation name takes: lowercase letters, digits and inner hyphens.
31
+ * `My Site` → `my-site`. Null when nothing usable is left, or when what is left
32
+ * names a folder rather than a foundation.
33
+ *
34
+ * @param {unknown} raw
35
+ * @returns {string|null}
36
+ */
37
+ export function normalizeFoundationName(raw) {
38
+ const name = String(raw ?? '')
39
+ .trim()
40
+ .toLowerCase()
41
+ .replace(/^@[^/]*\//, '')
42
+ .replace(/[^a-z0-9]+/g, '-')
43
+ .replace(/^-+|-+$/g, '')
44
+ return name && !SCAFFOLD_FOLDER_NAMES.has(name) ? name : null
45
+ }
46
+
47
+ /**
48
+ * A name to offer a foundation that has none of its own: its folder's — or, for
49
+ * the `src/` and `foundation/` a scaffold makes, its project's folder. Null when
50
+ * neither makes one.
51
+ *
52
+ * @param {string} foundationDir
53
+ * @returns {string|null}
54
+ */
55
+ export function suggestFoundationName(foundationDir) {
56
+ const dir = resolve(foundationDir)
57
+ return (
58
+ normalizeFoundationName(basename(dir)) ||
59
+ normalizeFoundationName(basename(dirname(dir)))
60
+ )
61
+ }
62
+
63
+ /**
64
+ * Whether `main.js`'s default export names the foundation — a `name` key at the
65
+ * export's own level, not one nested in `props` or `vars`.
66
+ *
67
+ * A scan, not a parse (see the module note): it skips strings and comments and
68
+ * counts brackets, which is all an object literal of declarations needs. A name
69
+ * that arrives by a spread is invisible to it — the build's read is the truth.
70
+ *
71
+ * @param {string} text - main.js source
72
+ * @returns {boolean}
73
+ */
74
+ export function mainNamesFoundation(text) {
75
+ const opening = /export\s+default\s*\{/.exec(text)
76
+ if (!opening) return false
77
+ let depth = 1
78
+ let atKey = true // just after the opening brace, or a comma at the top level
79
+ for (let i = opening.index + opening[0].length; i < text.length && depth > 0; i++) {
80
+ const c = text[i]
81
+ if (c === '/' && text[i + 1] === '/') {
82
+ i = text.indexOf('\n', i)
83
+ if (i < 0) break
84
+ } else if (c === '/' && text[i + 1] === '*') {
85
+ i = text.indexOf('*/', i + 2) + 1
86
+ if (i <= 0) break
87
+ } else if (c === "'" || c === '"' || c === '`') {
88
+ let j = i + 1
89
+ while (j < text.length && text[j] !== c) j += text[j] === '\\' ? 2 : 1
90
+ if (depth === 1 && atKey && text.slice(i + 1, j) === 'name' && /^\s*:/.test(text.slice(j + 1))) {
91
+ return true
92
+ }
93
+ i = j
94
+ atKey = false
95
+ } else if ('{[('.includes(c)) {
96
+ depth++
97
+ atKey = false
98
+ } else if ('}])'.includes(c)) {
99
+ depth--
100
+ } else if (depth === 1 && c === ',') {
101
+ atKey = true
102
+ } else if (depth === 1 && atKey && /[A-Za-z_$]/.test(c)) {
103
+ const word = /^[A-Za-z_$][\w$]*/.exec(text.slice(i))[0]
104
+ // `name: 'x'`, or the shorthand `{ name }`
105
+ if (word === 'name' && /^\s*[:,}]/.test(text.slice(i + word.length))) return true
106
+ i += word.length - 1
107
+ atKey = false
108
+ } else if (!/\s/.test(c)) {
109
+ atKey = false
110
+ }
111
+ }
112
+ return false
113
+ }
114
+
115
+ /**
116
+ * Write `name` into a foundation's `main.js`.
117
+ *
118
+ * With `replace`, the one `name: '<replace>'` literal is rewritten — the case where
119
+ * main.js already names the foundation, with a name that cannot register. Without
120
+ * it, `name` becomes the default export's first key. A missing main.js is created.
121
+ *
122
+ * ⚠️ A text edit, not a parse: it handles `export default { … }` — every scaffold
123
+ * and every template — and says so otherwise rather than guessing.
124
+ *
125
+ * @param {string} mainFile
126
+ * @param {string} name - already a valid foundation name
127
+ * @param {{ replace?: string|null }} [opts]
128
+ * @returns {{ ok: true } | { ok: false, reason: string }}
129
+ */
130
+ export function writeFoundationName(mainFile, name, { replace = null } = {}) {
131
+ const line = `${NAME_COMMENT}\n name: '${name}',`
132
+ if (!existsSync(mainFile)) {
133
+ writeFileSync(mainFile, `export default {\n ${line}\n}\n`)
134
+ return { ok: true }
135
+ }
136
+ const text = readFileSync(mainFile, 'utf8')
137
+
138
+ if (replace !== null) {
139
+ const literal = new RegExp(
140
+ `(\\bname\\s*:\\s*)(['"\`])${replace.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\2`,
141
+ 'g'
142
+ )
143
+ const hits = text.match(literal) || []
144
+ if (hits.length !== 1) {
145
+ return {
146
+ ok: false,
147
+ reason: `main.js does not name the foundation "${replace}" in exactly one place`
148
+ }
149
+ }
150
+ writeFileSync(mainFile, text.replace(literal, `$1'${name}'`))
151
+ return { ok: true }
152
+ }
153
+
154
+ const opening = /export\s+default\s*\{/.exec(text)
155
+ if (!opening) {
156
+ return { ok: false, reason: 'main.js has no `export default { … }` to add a name to' }
157
+ }
158
+ // The name leads the export — on lines of its own, so a multi-line export keeps
159
+ // its first line and gains a blank one between.
160
+ const at = opening.index + opening[0].length
161
+ writeFileSync(mainFile, `${text.slice(0, at)}\n ${line}\n${text.slice(at)}`)
162
+ return { ok: true }
163
+ }
164
+
165
+ /**
166
+ * Give a freshly scaffolded foundation its name, unless its main.js already has one.
167
+ *
168
+ * Called after template content is applied, because a template's own main.js
169
+ * replaces the scaffolded one — and with it the name the scaffold wrote.
170
+ *
171
+ * @param {string} foundationDir - the scaffolded package (flat layout: main.js at its root)
172
+ * @param {string|null} name
173
+ */
174
+ export function ensureFoundationName(foundationDir, name) {
175
+ if (!name) return
176
+ const mainFile = join(foundationDir, 'main.js')
177
+ if (existsSync(mainFile) && mainNamesFoundation(readFileSync(mainFile, 'utf8'))) return
178
+ writeFoundationName(mainFile, name)
179
+ }