@multiplatform.one/cli 7.11.0 → 7.16.0

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 (163) hide show
  1. package/README.md +121 -111
  2. package/bin/createMultiplatformApp.mjs +39 -37
  3. package/lib/bin/multiplatformOne.mjs +65 -75
  4. package/lib/commands/addApp.mjs +78 -0
  5. package/lib/commands/adoptApp.mjs +11 -7
  6. package/lib/commands/doctypeTypes.mjs +148 -0
  7. package/lib/commands/e2e.mjs +2 -2
  8. package/lib/commands/init.mjs +142 -459
  9. package/lib/commands/merge.mjs +359 -0
  10. package/lib/commands/pieces.mjs +236 -0
  11. package/lib/commands/provenance.mjs +108 -0
  12. package/lib/commands/scaffold.mjs +105 -0
  13. package/lib/commands/updateApp.mjs +211 -528
  14. package/lib/source.json +5 -0
  15. package/lib/source.mjs +63 -0
  16. package/package.json +6 -6
  17. package/scripts/frappe-bootstrap.sh +29 -3
  18. package/scripts/frappe-dev.sh +1 -2
  19. package/scripts/frappe-helpers.sh +50 -2
  20. package/scripts/frappe.sh +5 -0
  21. package/scripts/scaffold.sh +937 -0
  22. package/scripts/stamp-source.mjs +51 -0
  23. package/scripts/stamp-source.spec.ts +86 -0
  24. package/src/bin/multiplatformOne.ts +116 -226
  25. package/src/commands/addApp.ts +102 -0
  26. package/src/commands/adoptApp.spec.ts +5 -1
  27. package/src/commands/adoptApp.ts +15 -10
  28. package/src/commands/doctypeTypes.spec.ts +155 -0
  29. package/src/commands/doctypeTypes.ts +214 -0
  30. package/src/commands/init.spec.ts +66 -0
  31. package/src/commands/init.ts +201 -643
  32. package/src/commands/merge.spec.ts +81 -0
  33. package/src/commands/merge.ts +411 -0
  34. package/src/commands/pieces.spec.ts +62 -0
  35. package/src/commands/pieces.ts +340 -0
  36. package/src/commands/provenance.spec.ts +92 -0
  37. package/src/commands/provenance.ts +169 -0
  38. package/src/commands/scaffold.ts +131 -0
  39. package/src/commands/updateApp.spec.ts +540 -331
  40. package/src/commands/updateApp.ts +255 -668
  41. package/src/source.spec.ts +45 -0
  42. package/src/source.ts +88 -0
  43. package/templates/AGENTS.md +161 -0
  44. package/templates/README.md +62 -0
  45. package/templates/pieces/frappe/universal/README.md.partial +7 -32
  46. package/templates/pieces/frappe/universal/env.example.partial +3 -3
  47. package/templates/pieces/frappe-bench/universal/README.md.partial +28 -0
  48. package/templates/pieces/{frappe → frappe-bench}/universal/docker/compose.frappe.yaml +2 -2
  49. package/templates/pieces/tauri/universal/README.md.partial +7 -5
  50. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/dev.rs +463 -0
  51. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/lib.rs +6 -0
  52. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/tauri.conf.json +2 -1
  53. package/templates/pieces/webext/universal/apps/__NAME__/package.json.partial +1 -0
  54. package/templates/pieces/webext/universal/apps/__NAME__/vite.config.webext.ts +6 -2
  55. package/types/bin/multiplatformOne.d.ts.map +1 -1
  56. package/types/commands/addApp.d.ts +20 -0
  57. package/types/commands/addApp.d.ts.map +1 -0
  58. package/types/commands/adoptApp.d.ts +4 -3
  59. package/types/commands/adoptApp.d.ts.map +1 -1
  60. package/types/commands/doctypeTypes.d.ts +40 -0
  61. package/types/commands/doctypeTypes.d.ts.map +1 -0
  62. package/types/commands/init.d.ts +30 -13
  63. package/types/commands/init.d.ts.map +1 -1
  64. package/types/commands/merge.d.ts +54 -0
  65. package/types/commands/merge.d.ts.map +1 -0
  66. package/types/commands/pieces.d.ts +54 -0
  67. package/types/commands/pieces.d.ts.map +1 -0
  68. package/types/commands/provenance.d.ts +52 -0
  69. package/types/commands/provenance.d.ts.map +1 -0
  70. package/types/commands/scaffold.d.ts +43 -0
  71. package/types/commands/scaffold.d.ts.map +1 -0
  72. package/types/commands/updateApp.d.ts +19 -27
  73. package/types/commands/updateApp.d.ts.map +1 -1
  74. package/types/source.d.ts +35 -0
  75. package/types/source.d.ts.map +1 -0
  76. package/lib/commands/initApp.mjs +0 -431
  77. package/lib/generateVscode.mjs +0 -184
  78. package/scripts/clone.sh +0 -17
  79. package/scripts/update.sh +0 -46
  80. package/src/commands/initApp.spec.ts +0 -720
  81. package/src/commands/initApp.ts +0 -706
  82. package/src/generateVscode.ts +0 -238
  83. package/templates/app/AGENTS.md +0 -142
  84. package/templates/app/README.md +0 -62
  85. package/templates/app/apps/__NAME__/index.html +0 -36
  86. package/templates/app/apps/__NAME__/package.json +0 -31
  87. package/templates/app/apps/__NAME__/public/fonts/inter-400.woff2 +0 -0
  88. package/templates/app/apps/__NAME__/public/fonts/inter-700.woff2 +0 -0
  89. package/templates/app/apps/__NAME__/src/App.tsx +0 -9
  90. package/templates/app/apps/__NAME__/src/main.tsx +0 -14
  91. package/templates/app/apps/__NAME__/tsconfig.json +0 -8
  92. package/templates/app/apps/__NAME__/vite.config.ts +0 -17
  93. package/templates/app/features/__NAME__/package.json +0 -34
  94. package/templates/app/features/__NAME__/src/HomeScreen.tsx +0 -70
  95. package/templates/app/features/__NAME__/src/index.ts +0 -1
  96. package/templates/app/features/__NAME__/tsconfig.json +0 -5
  97. package/templates/app/gitignore +0 -9
  98. package/templates/app/npmrc +0 -2
  99. package/templates/app/oxlintrc.json +0 -39
  100. package/templates/app/package.json +0 -38
  101. package/templates/app/packages/config/config.json +0 -4
  102. package/templates/app/packages/config/index.ts +0 -5
  103. package/templates/app/packages/config/package.json +0 -17
  104. package/templates/app/packages/config/tsconfig.json +0 -5
  105. package/templates/app/pnpm-workspace.yaml +0 -4
  106. package/templates/app/tsconfig.base.json +0 -16
  107. package/templates/universal/AGENTS.md +0 -142
  108. package/templates/universal/README.md +0 -97
  109. package/templates/universal/apps/__NAME__/app.config.js +0 -31
  110. package/templates/universal/apps/__NAME__/config/tamagui.config.ts +0 -4
  111. package/templates/universal/apps/__NAME__/package.json +0 -62
  112. package/templates/universal/apps/__NAME__/polyfills.native.ts +0 -23
  113. package/templates/universal/apps/__NAME__/polyfills.ts +0 -3
  114. package/templates/universal/apps/__NAME__/public/fonts/inter-400.woff2 +0 -0
  115. package/templates/universal/apps/__NAME__/public/fonts/inter-700.woff2 +0 -0
  116. package/templates/universal/apps/__NAME__/routes/_layout.tsx +0 -22
  117. package/templates/universal/apps/__NAME__/routes/_layout.web.tsx +0 -21
  118. package/templates/universal/apps/__NAME__/routes/about.tsx +0 -3
  119. package/templates/universal/apps/__NAME__/routes/index.tsx +0 -3
  120. package/templates/universal/apps/__NAME__/src/app.ts +0 -10
  121. package/templates/universal/apps/__NAME__/tamagui.css +0 -3
  122. package/templates/universal/apps/__NAME__/tsconfig.json +0 -8
  123. package/templates/universal/apps/__NAME__/types.d.ts +0 -9
  124. package/templates/universal/apps/__NAME__/vite.config.ts +0 -20
  125. package/templates/universal/features/__NAME__/package.json +0 -35
  126. package/templates/universal/features/__NAME__/src/AboutScreen.tsx +0 -27
  127. package/templates/universal/features/__NAME__/src/AppLayout.tsx +0 -50
  128. package/templates/universal/features/__NAME__/src/ContactForm.spec.tsx +0 -22
  129. package/templates/universal/features/__NAME__/src/ContactForm.tsx +0 -55
  130. package/templates/universal/features/__NAME__/src/HomeScreen.tsx +0 -40
  131. package/templates/universal/features/__NAME__/src/index.ts +0 -4
  132. package/templates/universal/features/__NAME__/src/screens.spec.ts +0 -12
  133. package/templates/universal/features/__NAME__/tsconfig.json +0 -5
  134. package/templates/universal/features/__NAME__/vitest.config.mjs +0 -7
  135. package/templates/universal/gitignore +0 -14
  136. package/templates/universal/npmrc +0 -3
  137. package/templates/universal/oxlintrc.json +0 -39
  138. package/templates/universal/package.json +0 -51
  139. package/templates/universal/packages/config/config.json +0 -4
  140. package/templates/universal/packages/config/index.ts +0 -5
  141. package/templates/universal/packages/config/package.json +0 -17
  142. package/templates/universal/packages/config/tsconfig.json +0 -5
  143. package/templates/universal/packages/i18n/en/common.json +0 -30
  144. package/templates/universal/packages/i18n/es/common.json +0 -30
  145. package/templates/universal/packages/i18n/i18n.d.ts +0 -16
  146. package/templates/universal/packages/i18n/index.ts +0 -26
  147. package/templates/universal/packages/i18n/package.json +0 -20
  148. package/templates/universal/packages/i18n/resources.ts +0 -13
  149. package/templates/universal/packages/i18n/tsconfig.json +0 -5
  150. package/templates/universal/packages/themes/accent.ts +0 -32
  151. package/templates/universal/packages/themes/base.ts +0 -50
  152. package/templates/universal/packages/themes/fonts.ts +0 -8
  153. package/templates/universal/packages/themes/index.ts +0 -33
  154. package/templates/universal/packages/themes/package.json +0 -23
  155. package/templates/universal/packages/themes/tamagui.config.ts +0 -14
  156. package/templates/universal/packages/themes/tsconfig.json +0 -5
  157. package/templates/universal/pnpm-workspace.yaml +0 -4
  158. package/templates/universal/tsconfig.base.json +0 -21
  159. package/templates/universal/types/env.d.ts +0 -9
  160. package/types/commands/initApp.d.ts +0 -114
  161. package/types/commands/initApp.d.ts.map +0 -1
  162. package/types/generateVscode.d.ts +0 -7
  163. package/types/generateVscode.d.ts.map +0 -1
@@ -0,0 +1,937 @@
1
+ #!/bin/sh
2
+ # Scaffold a consumer project from the multiplatform.one monorepo.
3
+ #
4
+ # The consumer project IS the monorepo at one commit, pruned: the published
5
+ # packages under public/ go (they come back from the registry), the singleton
6
+ # apps not asked for go, and apps/one (the One platform app) is renamed to each
7
+ # product app. `mpo init` and `mpo update` call this script; it also runs on its
8
+ # own with nothing but git, node and a POSIX shell.
9
+ #
10
+ # Usage: scaffold.sh [options] <source> <ref> <target>
11
+ # source path to a local clone (taken with git archive) or a git URL
12
+ # (shallow fetch of the ref)
13
+ # ref branch, tag or commit
14
+ # target directory to write; must not exist or be empty (see --into)
15
+ #
16
+ # Options:
17
+ # --app <name> product app derived from apps/one (repeatable)
18
+ # --service <name> singleton app under apps/ to keep (repeatable):
19
+ # ethereum frappe keycloak solana storybook
20
+ # storybook-expo sui uxpin vocs
21
+ # --mpo-version <range> range written for @multiplatform.one/* deps
22
+ # (default: ^<version of public/cli in the source tree>)
23
+ # --keep <dir> keep a pruned top-level path (repeatable):
24
+ # public agent-os .changeset docs deploy
25
+ # --into target may already hold files: a path that already
26
+ # exists there is skipped, listed, and pinned in
27
+ # .updateignore so `mpo update` never rewrites it
28
+ # --source-url <url> url recorded in .mpo.json when <source> is a path
29
+ # --name <name> root package.json name (default: the target's basename)
30
+
31
+ set -eu
32
+
33
+ usage() {
34
+ sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'
35
+ }
36
+
37
+ die() {
38
+ printf 'scaffold: %s\n' "$*" >&2
39
+ exit 1
40
+ }
41
+
42
+ pascal_case() {
43
+ printf '%s\n' "$1" | awk -F- '{ for (i = 1; i <= NF; i++) printf "%s%s", toupper(substr($i, 1, 1)), substr($i, 2); print "" }'
44
+ }
45
+
46
+ valid_name() {
47
+ case "$1" in
48
+ '' | *[!a-z0-9-]* | [!a-z]*) return 1 ;;
49
+ esac
50
+ }
51
+
52
+ # The ls-tree lines on stdin whose path is a word of $1, printed without the
53
+ # directory prefix $2 (mktree input for that directory).
54
+ keep_entries() {
55
+ while IFS="$TAB" read -r _meta _path; do
56
+ case " $1 " in
57
+ *" $_path "*) printf '%s\t%s\n' "$_meta" "${_path#"$2"}" ;;
58
+ esac
59
+ done
60
+ }
61
+
62
+ # Rewrite one file in place with a sed script ($2), atomically.
63
+ sed_file() {
64
+ _tmp="$1.scaffold.tmp"
65
+ sed "$2" "$1" >"$_tmp" && mv "$_tmp" "$1"
66
+ }
67
+
68
+ positional() {
69
+ if [ -z "$SOURCE" ]; then
70
+ SOURCE="$1"
71
+ elif [ -z "$REF" ]; then
72
+ REF="$1"
73
+ elif [ -z "$TARGET" ]; then
74
+ TARGET="$1"
75
+ else
76
+ die "unexpected argument $1 (see --help)"
77
+ fi
78
+ }
79
+
80
+ # Text files under $1 that mention any of the patterns given after it.
81
+ grep_files() {
82
+ _dir="$1"
83
+ shift
84
+ grep -rIl "$@" "$_dir" 2>/dev/null || true
85
+ }
86
+
87
+ APPS=""
88
+ SERVICES=""
89
+ KEEP=""
90
+ MPO_VERSION=""
91
+ INTO=0
92
+ SOURCE_URL=""
93
+ PROJECT_NAME=""
94
+ SOURCE=""
95
+ REF=""
96
+ TARGET=""
97
+ PRUNE="public agent-os .changeset docs deploy typedoc.json .gitlab-ci.yml"
98
+ TAB="$(printf '\t')"
99
+
100
+ while [ $# -gt 0 ]; do
101
+ case "$1" in
102
+ --app)
103
+ [ $# -ge 2 ] || die "--app needs a name"
104
+ valid_name "$2" || die "app name \"$2\" must be lowercase letters, digits and hyphens, starting with a letter"
105
+ [ "$2" != one ] || die "the product app cannot be named one (that is the monorepo's One app)"
106
+ APPS="$APPS $2"
107
+ shift 2
108
+ ;;
109
+ --service)
110
+ [ $# -ge 2 ] || die "--service needs a name"
111
+ valid_name "$2" || die "service name \"$2\" must be lowercase letters, digits and hyphens"
112
+ [ "$2" != one ] || die "one is the product app, not a service (use --app)"
113
+ SERVICES="$SERVICES $2"
114
+ shift 2
115
+ ;;
116
+ --mpo-version)
117
+ [ $# -ge 2 ] || die "--mpo-version needs a range"
118
+ MPO_VERSION="$2"
119
+ shift 2
120
+ ;;
121
+ --keep)
122
+ [ $# -ge 2 ] || die "--keep needs a path"
123
+ KEEP="$KEEP $2"
124
+ shift 2
125
+ ;;
126
+ --into)
127
+ INTO=1
128
+ shift
129
+ ;;
130
+ --source-url)
131
+ [ $# -ge 2 ] || die "--source-url needs a url"
132
+ SOURCE_URL="$2"
133
+ shift 2
134
+ ;;
135
+ --name)
136
+ [ $# -ge 2 ] || die "--name needs a name"
137
+ PROJECT_NAME="$2"
138
+ shift 2
139
+ ;;
140
+ -h | --help)
141
+ usage
142
+ exit 0
143
+ ;;
144
+ --)
145
+ shift
146
+ for _arg in "$@"; do positional "$_arg"; done
147
+ break
148
+ ;;
149
+ -*)
150
+ die "unknown option $1 (see --help)"
151
+ ;;
152
+ *)
153
+ positional "$1"
154
+ shift
155
+ ;;
156
+ esac
157
+ done
158
+
159
+ [ -n "$SOURCE" ] && [ -n "$REF" ] && [ -n "$TARGET" ] || {
160
+ usage >&2
161
+ exit 1
162
+ }
163
+ APPS="${APPS# }"
164
+ SERVICES="${SERVICES# }"
165
+ KEEP="${KEEP# }"
166
+ FIRST_APP="${APPS%% *}"
167
+
168
+ for _app in $APPS; do
169
+ for _service in $SERVICES; do
170
+ [ "$_app" != "$_service" ] || die "$_app is both an --app and a --service"
171
+ done
172
+ done
173
+
174
+ command -v git >/dev/null 2>&1 || die "git is required"
175
+ command -v node >/dev/null 2>&1 || die "node is required (JSON edits)"
176
+
177
+ if [ -e "$TARGET" ]; then
178
+ [ -d "$TARGET" ] || die "target $TARGET exists and is not a directory"
179
+ if [ -n "$(ls -A "$TARGET")" ] && [ "$INTO" -eq 0 ]; then
180
+ die "target $TARGET is not empty (pass --into to scaffold into an existing directory)"
181
+ fi
182
+ fi
183
+
184
+ WORK="$(mktemp -d "${TMPDIR:-/tmp}/mpo-scaffold.XXXXXX")"
185
+ trap 'rm -rf "$WORK"' EXIT INT TERM
186
+
187
+ # ── 1. the repository and the commit ─────────────────────────────────────
188
+
189
+ if [ -d "$SOURCE" ]; then
190
+ REPO="$(cd "$SOURCE" && pwd)"
191
+ SHA="$(git -C "$REPO" rev-parse --verify --quiet "$REF^{commit}")" ||
192
+ die "$REF is not a commit in $REPO"
193
+ [ -n "$SOURCE_URL" ] || SOURCE_URL="$REPO"
194
+ else
195
+ # A shallow, blobless fetch: commit and trees only. The blobs the project
196
+ # keeps are fetched in one batch in step 2, so nothing under public/ or
197
+ # docs/ crosses the wire (a server without filter support sends all).
198
+ REPO="$WORK/clone"
199
+ git init -q "$REPO"
200
+ git -C "$REPO" remote add origin "$SOURCE"
201
+ git -C "$REPO" fetch -q --depth 1 --filter=blob:none origin "$REF" ||
202
+ die "could not fetch $REF from $SOURCE"
203
+ SHA="$(git -C "$REPO" rev-parse --verify --quiet 'FETCH_HEAD^{commit}')" ||
204
+ die "$REF at $SOURCE is not a commit"
205
+ [ -n "$SOURCE_URL" ] || SOURCE_URL="$SOURCE"
206
+ fi
207
+
208
+ git -C "$REPO" rev-parse --verify --quiet "$SHA:apps/one/package.json" >/dev/null ||
209
+ die "$SOURCE at $REF has no apps/one: not the multiplatform.one monorepo"
210
+
211
+ if [ -z "$MPO_VERSION" ]; then
212
+ MPO_VERSION="^$(git -C "$REPO" show "$SHA:public/cli/package.json" | sed -n 's/^ "version": "\([^"]*\)".*/\1/p' | head -n 1)"
213
+ [ "$MPO_VERSION" != "^" ] || die "could not read the CLI version from public/cli/package.json at $SHA"
214
+ fi
215
+ CLI_VERSION="${MPO_VERSION#[~^]}"
216
+
217
+ # ── 2. take only what a consumer carries ─────────────────────────────────
218
+
219
+ # Top-level entries and apps/* at the commit decide what is archived; the
220
+ # pruned trees are never written, which is most of the wall time saved.
221
+ REMOVED=""
222
+ REMOVED_APPS=""
223
+ PATHS=""
224
+ for _entry in $(git -C "$REPO" ls-tree --name-only "$SHA"); do
225
+ case "$_entry" in
226
+ apps) continue ;;
227
+ esac
228
+ case " $PRUNE " in
229
+ *" $_entry "*)
230
+ case " $KEEP " in
231
+ *" $_entry "*) ;;
232
+ *)
233
+ REMOVED="$REMOVED $_entry"
234
+ printf 'removed: %s\n' "$_entry"
235
+ continue
236
+ ;;
237
+ esac
238
+ ;;
239
+ esac
240
+ PATHS="$PATHS $_entry"
241
+ done
242
+ for _entry in $(git -C "$REPO" ls-tree --name-only "$SHA" apps/); do
243
+ _name="${_entry#apps/}"
244
+ case " one $SERVICES " in
245
+ *" $_name "*)
246
+ PATHS="$PATHS $_entry"
247
+ continue
248
+ ;;
249
+ esac
250
+ REMOVED_APPS="$REMOVED_APPS $_name"
251
+ REMOVED="$REMOVED $_entry"
252
+ printf 'removed: %s\n' "$_entry"
253
+ done
254
+ REMOVED="${REMOVED# }"
255
+ REMOVED_APPS="${REMOVED_APPS# }"
256
+ for _service in $SERVICES; do
257
+ case " $PATHS " in
258
+ *" apps/$_service "*) ;;
259
+ *) die "service $_service does not exist under apps/ at $SHA" ;;
260
+ esac
261
+ done
262
+
263
+ if [ "$INTO" -eq 1 ]; then
264
+ TREE="$WORK/tree"
265
+ else
266
+ TREE="$TARGET"
267
+ fi
268
+ mkdir -p "$TREE"
269
+ if [ "$(git -C "$REPO" config --get remote.origin.promisor || true)" = true ]; then
270
+ # git archive of a pathspec still reads blobs outside it, so a blobless
271
+ # clone archives a tree holding only $PATHS, its blobs fetched in one go.
272
+ _apps_tree="$(git -C "$REPO" ls-tree "$SHA" apps/ | keep_entries "$PATHS" apps/ | git -C "$REPO" mktree --missing)"
273
+ _tree="$({
274
+ git -C "$REPO" ls-tree "$SHA" | keep_entries "$PATHS" ""
275
+ printf '040000 tree %s\tapps\n' "$_apps_tree"
276
+ } | git -C "$REPO" mktree --missing)"
277
+ git -C "$REPO" ls-tree -r "$_tree" | awk '$2 == "blob" { print $3 }' >"$WORK/blobs"
278
+ # The same request git makes to fill a blobless clone: a "have" for a
279
+ # fetched ref would tell the server the blobs are already here.
280
+ if [ -s "$WORK/blobs" ]; then
281
+ git -C "$REPO" -c fetch.negotiationAlgorithm=noop fetch -q --no-tags --no-write-fetch-head \
282
+ --filter=blob:none --stdin origin <"$WORK/blobs" ||
283
+ die "could not fetch the project's files from $SOURCE"
284
+ fi
285
+ git -C "$REPO" archive --format=tar "$_tree" | tar -xf - -C "$TREE"
286
+ else
287
+ # shellcheck disable=SC2086
288
+ git -C "$REPO" archive --format=tar "$SHA" $PATHS | tar -xf - -C "$TREE"
289
+ fi
290
+ rm -rf "$TREE/apps/storybook/.lostpixel/baseline"
291
+ # patches/public/ patches packages only the published packages' build
292
+ # installs; pnpm fails an install whose patch matches nothing.
293
+ case " $REMOVED " in
294
+ *" public "*) rm -rf "$TREE/patches/public" ;;
295
+ esac
296
+
297
+ # The root AGENTS.md documents the monorepo; a project gets the consumer copy
298
+ # at the same commit, so an mpo update merge base carries the copy its commit
299
+ # shipped (a commit before the copy existed keeps the monorepo file).
300
+ if git -C "$REPO" rev-parse --verify --quiet "$SHA:public/cli/templates/AGENTS.md" >/dev/null; then
301
+ git -C "$REPO" show "$SHA:public/cli/templates/AGENTS.md" >"$TREE/AGENTS.md"
302
+ fi
303
+
304
+ # Commits from MPO-343 to MPO-289 shipped a pnpmfile mapping @multiplatform.one
305
+ # to Pulp. The scaffold at one of them still carries it, so an mpo update whose
306
+ # merge base is such a commit sees the file leave and removes it.
307
+ if git -C "$REPO" rev-parse --verify --quiet "$SHA:public/cli/templates/pnpmfile.cjs" >/dev/null; then
308
+ git -C "$REPO" show "$SHA:public/cli/templates/pnpmfile.cjs" >"$TREE/.pnpmfile.cjs"
309
+ fi
310
+ if [ -f "$TREE/.pnpmfile.cjs" ] && [ -f "$TREE/pnpm-lock.yaml" ]; then
311
+ node - "$TREE/.pnpmfile.cjs" "$TREE/pnpm-lock.yaml" <<'NODE'
312
+ const crypto = require("node:crypto");
313
+ const fs = require("node:fs");
314
+ const [pnpmfile, lockfile] = process.argv.slice(2);
315
+ // pnpm re-resolves every dependency when the lockfile's pnpmfileChecksum does
316
+ // not match, and the shipped lockfile would pin nothing. Same hash as pnpm 10.
317
+ const content = fs.readFileSync(pnpmfile, "utf8").split("\r\n").join("\n");
318
+ const checksum = `sha256-${crypto.createHash("sha256").update(content).digest("base64")}`;
319
+ let lock = fs.readFileSync(lockfile, "utf8").replace(/^pnpmfileChecksum: .*\n\n/m, "");
320
+ const at = lock.search(/^(patchedDependencies|importers):/m);
321
+ if (at < 0) process.exit(0);
322
+ lock = `${lock.slice(0, at)}pnpmfileChecksum: ${checksum}\n\n${lock.slice(at)}`;
323
+ fs.writeFileSync(lockfile, lock);
324
+ NODE
325
+ fi
326
+
327
+ # Whatever names the One app outside it (the frappe Makefile's SPA copy,
328
+ # docker-bake.hcl, comments in features/) points at the first product app;
329
+ # the root files that need more than a rename are handled below.
330
+ if [ -n "$FIRST_APP" ]; then
331
+ for _file in $(grep_files "$TREE" -e '@platform/one' -e 'apps/one' -e 'features/one'); do
332
+ case "$_file" in
333
+ "$TREE/apps/one/"* | "$TREE/features/one/"* | "$TREE/.gitignore" | "$TREE/Makefile" | "$TREE/package.json" | "$TREE/.vscode/"*) continue ;;
334
+ esac
335
+ sed_file "$_file" "s|@platform/one|@app/$FIRST_APP|g
336
+ s|apps/one\\([^-a-zA-Z0-9_]\\)|apps/$FIRST_APP\\1|g
337
+ s|apps/one\$|apps/$FIRST_APP|g
338
+ s|features/one\\([^-a-zA-Z0-9_]\\)|features/$FIRST_APP\\1|g
339
+ s|features/one\$|features/$FIRST_APP|g"
340
+ done
341
+ fi
342
+
343
+ # ── 3. apps/one becomes each product app ─────────────────────────────────
344
+
345
+ for _app in $APPS; do
346
+ [ ! -e "$TREE/apps/$_app" ] || die "apps/$_app already exists in the source tree; pick another product name"
347
+ cp -R "$TREE/apps/one" "$TREE/apps/$_app"
348
+ # A feature module of that name already satisfies the app/features twin
349
+ # (features/pokemon is the One app's own pokemon feature). The index
350
+ # routes import features/<app>/screen, so a module without one gets
351
+ # features/one's.
352
+ if [ -d "$TREE/features/one" ]; then
353
+ if [ ! -e "$TREE/features/$_app" ]; then
354
+ cp -R "$TREE/features/one" "$TREE/features/$_app"
355
+ elif [ ! -e "$TREE/features/$_app/screen.tsx" ]; then
356
+ cp "$TREE/features/one/screen.tsx" "$TREE/features/$_app/screen.tsx"
357
+ fi
358
+ fi
359
+ _pascal="$(pascal_case "$_app")"
360
+ _appid="$(printf '%s' "$_app" | tr -d '-')"
361
+ _script="s|@platform/one|@app/$_app|g
362
+ s|apps/one\\([^-a-zA-Z0-9_]\\)|apps/$_app\\1|g
363
+ s|apps/one\$|apps/$_app|g
364
+ s|features/one\\([^-a-zA-Z0-9_]\\)|features/$_app\\1|g
365
+ s|features/one\$|features/$_app|g
366
+ s|one\\.multiplatform\\.app|com.$_appid.app|g
367
+ s|multiplatform-one|$_app|g
368
+ s|Multiplatform One|$_pascal|g
369
+ s|\"multiplatform\\.one\"|\"$_app\"|g"
370
+ for _dir in "$TREE/apps/$_app" "$TREE/features/$_app"; do
371
+ [ -d "$_dir" ] || continue
372
+ for _file in $(grep_files "$_dir" -e '@platform/one' -e 'apps/one' -e 'features/one' -e 'one\.multiplatform\.app' -e 'multiplatform-one' -e 'Multiplatform One' -e '"multiplatform\.one"'); do
373
+ sed_file "$_file" "$_script"
374
+ done
375
+ done
376
+ done
377
+ rm -rf "$TREE/apps/one" "$TREE/features/one"
378
+
379
+ # ── 4. root wiring: Makefile, .gitignore, .env, .vscode, package.json ─────
380
+
381
+ if [ -f "$TREE/Makefile" ]; then
382
+ awk -v apps="$APPS" -v removed="$REMOVED_APPS" '
383
+ BEGIN {
384
+ napps = split(apps, app, " ")
385
+ nremoved = split(removed, gone, " ")
386
+ for (i = 1; i <= nremoved; i++) dead[gone[i]] = 1
387
+ dead["vscode"] = 1
388
+ dead["webext"] = 1
389
+ }
390
+ # "name/%: configure FORCE" followed by its one recipe line.
391
+ match($0, /^[a-z0-9-]+\/%: /) {
392
+ name = substr($0, 1, RSTART + RLENGTH - 5)
393
+ getline recipe
394
+ if (name == "one") {
395
+ for (i = 1; i <= napps; i++) {
396
+ line = $0
397
+ sub(/^one\//, app[i] "/", line)
398
+ print line
399
+ r = recipe
400
+ gsub(/apps\/one/, "apps/" app[i], r)
401
+ print r
402
+ }
403
+ next
404
+ }
405
+ if (name in dead) next
406
+ print
407
+ print recipe
408
+ next
409
+ }
410
+ /^# vscode \+ webext are targets OF the One platform app/ { getline; next }
411
+ /^\.PHONY: / && /one\/%/ {
412
+ out = ".PHONY:"
413
+ for (i = 2; i <= NF; i++) {
414
+ word = $i
415
+ base = word
416
+ sub(/\/%$/, "", base)
417
+ if (base == "one") {
418
+ for (j = 1; j <= napps; j++) out = out " " app[j] "/%"
419
+ continue
420
+ }
421
+ if (base in dead) continue
422
+ out = out " " word
423
+ }
424
+ print out
425
+ next
426
+ }
427
+ # Plain rule headers: drop prerequisites of removed apps (down: keycloak/down docker/down).
428
+ /^[a-z][a-z0-9\/._-]*: / && !/%/ {
429
+ out = $1
430
+ for (i = 2; i <= NF; i++) {
431
+ word = $i
432
+ base = word
433
+ sub(/\/.*$/, "", base)
434
+ if (base in dead) continue
435
+ out = out " " word
436
+ }
437
+ print out
438
+ next
439
+ }
440
+ { print }
441
+ ' "$TREE/Makefile" >"$TREE/Makefile.scaffold.tmp" && mv "$TREE/Makefile.scaffold.tmp" "$TREE/Makefile"
442
+ fi
443
+
444
+ if [ -f "$TREE/.gitignore" ]; then
445
+ awk -v apps="$APPS" '
446
+ BEGIN { napps = split(apps, app, " ") }
447
+ /apps\/one(\/|$| )/ {
448
+ for (i = 1; i <= napps; i++) {
449
+ line = $0
450
+ gsub(/apps\/one/, "apps/" app[i], line)
451
+ print line
452
+ }
453
+ next
454
+ }
455
+ { print }
456
+ ' "$TREE/.gitignore" >"$TREE/.gitignore.scaffold.tmp" && mv "$TREE/.gitignore.scaffold.tmp" "$TREE/.gitignore"
457
+ fi
458
+
459
+ # .env.example sections are headed "# <app>"; a removed app takes its section
460
+ # (header through the next blank line) with it. frappe also owns mariadb,
461
+ # and keycloak postgres.
462
+ ENV_SECTIONS="$REMOVED_APPS"
463
+ [ -z "$APPS" ] && [ -d "$TREE/apps" ] && ENV_SECTIONS="$ENV_SECTIONS one"
464
+ case " $REMOVED_APPS " in
465
+ *" frappe "*) ENV_SECTIONS="$ENV_SECTIONS mariadb" ;;
466
+ esac
467
+ case " $REMOVED_APPS " in
468
+ *" keycloak "*) ENV_SECTIONS="$ENV_SECTIONS postgres" ;;
469
+ esac
470
+ for _envfile in "$TREE/.env.example" "$TREE/.env"; do
471
+ [ -f "$_envfile" ] || continue
472
+ awk -v sections="$ENV_SECTIONS" '
473
+ BEGIN { n = split(sections, s, " "); for (i = 1; i <= n; i++) drop["# " s[i]] = 1 }
474
+ skipping && /^$/ { skipping = 0; next }
475
+ skipping { next }
476
+ ($0 in drop) { skipping = 1; next }
477
+ { print }
478
+ ' "$_envfile" >"$_envfile.scaffold.tmp" && mv "$_envfile.scaffold.tmp" "$_envfile"
479
+ done
480
+ if [ -f "$TREE/.env.example" ] && [ ! -f "$TREE/.env" ]; then
481
+ cp "$TREE/.env.example" "$TREE/.env"
482
+ fi
483
+
484
+ # Docker wiring that backs a pruned app goes with it. A compose service goes
485
+ # when one of its profiles is named after the app (frappe: the bench's mariadb,
486
+ # redis and mailpit; keycloak: its postgres), when it bind-mounts from under
487
+ # apps/<app>, or when it depends on a service that went, and so do the named
488
+ # volumes only those services used. With no service left the file is
489
+ # `services: {}`, which compose still reads, and `pnpm backend` goes with the
490
+ # services it started. A bake target building from apps/<app> and a
491
+ # `# <app>-block` of docker/Makefile go too. Nothing is rewritten while every
492
+ # app they name is kept.
493
+ if [ -n "$REMOVED_APPS" ] && [ -f "$TREE/docker/compose.yaml" ]; then
494
+ awk -v removed="$REMOVED_APPS" '
495
+ function record(svc, section, value, name) {
496
+ name = value
497
+ sub(/:.*/, "", name)
498
+ gsub(/[" \047]/, "", name)
499
+ if (section == "profiles" && (name in gone)) dead[svc] = 1
500
+ if (section == "depends_on" && name != "") needs[svc, ++nneeds[svc]] = name
501
+ if (section == "volumes" && name ~ /^[A-Za-z0-9_-]+$/ && value ~ /:/) uses[svc, ++nuses[svc]] = name
502
+ }
503
+ BEGIN {
504
+ nremoved = split(removed, list, " ")
505
+ for (i = 1; i <= nremoved; i++) gone[list[i]] = 1
506
+ }
507
+ { line[++n] = $0 }
508
+ END {
509
+ for (i = 1; i <= n; i++) {
510
+ l = line[i]
511
+ if (l ~ /^[^ #]/) {
512
+ npending = 0
513
+ top = l
514
+ sub(/:.*/, "", top)
515
+ cur = ""
516
+ vol = ""
517
+ if (top == "services") services_at = i
518
+ if (top == "volumes") volumes_at = i
519
+ continue
520
+ }
521
+ if (top == "volumes") {
522
+ if (l ~ /^ [A-Za-z0-9_.-]+:/) {
523
+ vol = l
524
+ sub(/^ /, "", vol)
525
+ sub(/:.*/, "", vol)
526
+ }
527
+ if (vol != "") vowner[i] = vol
528
+ continue
529
+ }
530
+ if (top != "services") continue
531
+ if (l ~ /^ #/ || (npending && l ~ /^[ ]*$/)) {
532
+ pending[++npending] = i
533
+ continue
534
+ }
535
+ if (l ~ /^ [A-Za-z0-9_.-]+:/) {
536
+ cur = l
537
+ sub(/^ /, "", cur)
538
+ sub(/:.*/, "", cur)
539
+ order[++nservices] = cur
540
+ for (j = 1; j <= npending; j++) owner[pending[j]] = cur
541
+ npending = 0
542
+ section = ""
543
+ owner[i] = cur
544
+ continue
545
+ }
546
+ if (cur == "") continue
547
+ owner[i] = cur
548
+ if (l ~ /^[ ]*#/) continue
549
+ for (app in gone) if (index(l, "../apps/" app "/")) dead[cur] = 1
550
+ if (l ~ /^ [A-Za-z_]+:/) {
551
+ section = l
552
+ sub(/^ /, "", section)
553
+ sub(/:.*/, "", section)
554
+ rest = l
555
+ sub(/^ [A-Za-z_]+:[ ]*/, "", rest)
556
+ if (rest ~ /^\[/) {
557
+ gsub(/[][]/, "", rest)
558
+ nparts = split(rest, parts, ",")
559
+ for (j = 1; j <= nparts; j++) record(cur, section, parts[j])
560
+ }
561
+ continue
562
+ }
563
+ if (l ~ /^ - / || l ~ /^ [A-Za-z0-9_.-]+:/) {
564
+ value = l
565
+ sub(/^ (- )?/, "", value)
566
+ record(cur, section, value)
567
+ }
568
+ }
569
+ do {
570
+ changed = 0
571
+ for (s = 1; s <= nservices; s++) {
572
+ svc = order[s]
573
+ if (svc in dead) continue
574
+ for (k = 1; k <= nneeds[svc]; k++) {
575
+ if (needs[svc, k] in dead) {
576
+ dead[svc] = 1
577
+ changed = 1
578
+ }
579
+ }
580
+ }
581
+ } while (changed)
582
+ live = 0
583
+ for (s = 1; s <= nservices; s++) {
584
+ svc = order[s]
585
+ if (!(svc in dead)) live++
586
+ for (k = 1; k <= nuses[svc]; k++) {
587
+ if (svc in dead) dead_use[uses[svc, k]] = 1
588
+ else live_use[uses[svc, k]] = 1
589
+ }
590
+ }
591
+ if (live == nservices) {
592
+ for (i = 1; i <= n; i++) print line[i]
593
+ exit
594
+ }
595
+ volumes_kept = 0
596
+ for (i in vowner) {
597
+ name = vowner[i]
598
+ if ((name in dead_use) && !(name in live_use)) vdrop[name] = 1
599
+ else volumes_kept = 1
600
+ }
601
+ m = 0
602
+ for (i = 1; i <= n; i++) {
603
+ if ((i in owner) && (owner[i] in dead)) continue
604
+ if ((i in vowner) && (vowner[i] in vdrop)) continue
605
+ if (i == volumes_at && !volumes_kept) continue
606
+ out[++m] = (i == services_at && !live) ? "services: {}" : line[i]
607
+ }
608
+ while (m > 0 && out[m] ~ /^[ ]*$/) m--
609
+ for (i = 1; i <= m; i++) print out[i]
610
+ }
611
+ ' "$TREE/docker/compose.yaml" >"$TREE/docker/compose.yaml.scaffold.tmp" &&
612
+ mv "$TREE/docker/compose.yaml.scaffold.tmp" "$TREE/docker/compose.yaml"
613
+ if grep -q '^services: {}$' "$TREE/docker/compose.yaml"; then
614
+ node - "$TREE/package.json" <<'NODE'
615
+ const fs = require("node:fs");
616
+ const file = process.argv[2];
617
+ const pkg = JSON.parse(fs.readFileSync(file, "utf8"));
618
+ if (pkg.scripts?.backend?.includes("docker/compose.yaml")) {
619
+ delete pkg.scripts.backend;
620
+ fs.writeFileSync(file, `${JSON.stringify(pkg, null, 2)}\n`);
621
+ }
622
+ NODE
623
+ fi
624
+ fi
625
+ if [ -n "$REMOVED_APPS" ] && [ -f "$TREE/docker/docker-bake.hcl" ]; then
626
+ awk -v removed="$REMOVED_APPS" '
627
+ BEGIN { nremoved = split(removed, gone, " ") }
628
+ { line[++n] = $0 }
629
+ END {
630
+ for (i = 1; i <= n; i++) {
631
+ if (line[i] !~ /^target "[^"]*" \{/) continue
632
+ name = line[i]
633
+ sub(/^target "/, "", name)
634
+ sub(/".*/, "", name)
635
+ for (end = i; end < n && line[end] != "}"; end++) continue
636
+ drop = 0
637
+ for (j = 1; j <= nremoved; j++) {
638
+ if (name == gone[j]) drop = 1
639
+ for (k = i; k <= end; k++)
640
+ if (line[k] ~ /^[ ]*dockerfile[ ]*=/ && index(line[k], "\"apps/" gone[j] "/")) drop = 1
641
+ }
642
+ if (drop) {
643
+ start = i
644
+ while (start > 1 && line[start - 1] ~ /^#/) start--
645
+ if (start > 1 && line[start - 1] == "") start--
646
+ for (k = start; k <= end; k++) skip[k] = 1
647
+ }
648
+ i = end
649
+ }
650
+ for (i = 1; i <= n; i++) if (!(i in skip)) print line[i]
651
+ }
652
+ ' "$TREE/docker/docker-bake.hcl" >"$TREE/docker/docker-bake.hcl.scaffold.tmp" &&
653
+ mv "$TREE/docker/docker-bake.hcl.scaffold.tmp" "$TREE/docker/docker-bake.hcl"
654
+ fi
655
+ if [ -n "$REMOVED_APPS" ] && [ -f "$TREE/docker/Makefile" ]; then
656
+ awk -v removed=" $REMOVED_APPS " '
657
+ /^# [a-z0-9-]+-block$/ {
658
+ name = $2
659
+ sub(/-block$/, "", name)
660
+ skipping = index(removed, " " name " ") > 0
661
+ }
662
+ !skipping
663
+ ' "$TREE/docker/Makefile" >"$TREE/docker/Makefile.scaffold.tmp" &&
664
+ mv "$TREE/docker/Makefile.scaffold.tmp" "$TREE/docker/Makefile"
665
+ fi
666
+
667
+ # The root README documents the monorepo; a project gets the consumer copy at
668
+ # the same commit. A block between <!-- service:<app> --> and its closing
669
+ # <!-- /service:<app> --> stays only when that service was kept, and a
670
+ # no-service block only when it was not.
671
+ if git -C "$REPO" rev-parse --verify --quiet "$SHA:public/cli/templates/README.md" >/dev/null; then
672
+ git -C "$REPO" show "$SHA:public/cli/templates/README.md" | awk -v kept=" $SERVICES " '
673
+ /^<!-- \/(no-)?service:[a-z0-9-]+ -->$/ {
674
+ skipping = 0
675
+ next
676
+ }
677
+ /^<!-- (no-)?service:[a-z0-9-]+ -->$/ {
678
+ name = $2
679
+ negate = sub(/^no-/, "", name)
680
+ sub(/^service:/, "", name)
681
+ present = index(kept, " " name " ") > 0
682
+ skipping = negate ? present : !present
683
+ next
684
+ }
685
+ skipping { next }
686
+ /^$/ && blank { next }
687
+ { blank = ($0 == ""); print }
688
+ ' >"$TREE/README.md"
689
+ fi
690
+
691
+ APPS_CSV="$(printf '%s' "$APPS" | tr ' ' ',')"
692
+ REMOVED_CSV="$(printf '%s' "$REMOVED_APPS" | tr ' ' ',')"
693
+ for _vsfile in "$TREE/.vscode/launch.json" "$TREE/.vscode/tasks.json"; do
694
+ [ -f "$_vsfile" ] || continue
695
+ node - "$_vsfile" "$APPS_CSV" "$REMOVED_CSV" <<'NODE'
696
+ const fs = require("node:fs");
697
+ const [file, appsCsv, removedCsv] = process.argv.slice(2);
698
+ const apps = appsCsv ? appsCsv.split(",") : [];
699
+ const removed = removedCsv ? removedCsv.split(",") : [];
700
+ const json = JSON.parse(fs.readFileSync(file, "utf8"));
701
+ const mentions = (value, name) => {
702
+ const text = JSON.stringify(value);
703
+ return [`/apps/${name}/`, `/apps/${name}"`, `}/${name}/`, `}/${name}"`].some((needle) =>
704
+ text.includes(needle),
705
+ );
706
+ };
707
+ const forApp = (value, name) =>
708
+ JSON.parse(
709
+ JSON.stringify(value)
710
+ .split("/apps/one/").join(`/apps/${name}/`)
711
+ .split('/apps/one"').join(`/apps/${name}"`)
712
+ .split('"one dev"').join(`"${name} dev"`),
713
+ );
714
+ const multiply = (items) =>
715
+ items.flatMap((item) => {
716
+ if (removed.some((name) => mentions(item, name))) return [];
717
+ if (!mentions(item, "one")) return [item];
718
+ return apps.map((name) => {
719
+ const copy = forApp(item, name);
720
+ if (apps.length > 1 && copy.name && !copy.name.startsWith(`${name} `)) {
721
+ copy.name = `${name} ${copy.name}`;
722
+ }
723
+ return copy;
724
+ });
725
+ });
726
+ if (Array.isArray(json.configurations)) {
727
+ json.configurations = multiply(json.configurations);
728
+ const names = new Set(json.configurations.map((item) => item.name));
729
+ if (Array.isArray(json.compounds)) {
730
+ json.compounds = json.compounds
731
+ .map((compound) => ({
732
+ ...compound,
733
+ configurations: (compound.configurations ?? []).flatMap((name) =>
734
+ name === "one dev" ? apps.map((app) => `${app} dev`) : [name],
735
+ ).filter((name) => names.has(name)),
736
+ }))
737
+ .filter((compound) => compound.configurations.length > 0);
738
+ if (!json.compounds.length) delete json.compounds;
739
+ }
740
+ }
741
+ if (Array.isArray(json.tasks)) json.tasks = multiply(json.tasks);
742
+ fs.writeFileSync(file, `${JSON.stringify(json, null, 2)}\n`);
743
+ NODE
744
+ done
745
+
746
+ [ -n "$PROJECT_NAME" ] || PROJECT_NAME="$(basename "$(cd "$TARGET" 2>/dev/null && pwd || printf '%s' "$TARGET")")"
747
+ node - "$TREE/package.json" "$PROJECT_NAME" "$APPS_CSV" "$REMOVED_CSV" "$(printf '%s' "$REMOVED" | tr ' ' ',')" <<'NODE'
748
+ const fs = require("node:fs");
749
+ const [file, projectName, appsCsv, removedCsv, prunedCsv] = process.argv.slice(2);
750
+ const apps = appsCsv ? appsCsv.split(",") : [];
751
+ const removed = removedCsv ? removedCsv.split(",") : [];
752
+ const pruned = prunedCsv ? prunedCsv.split(",") : [];
753
+ const pkg = JSON.parse(fs.readFileSync(file, "utf8"));
754
+ pkg.name = projectName;
755
+ // A root patch can name a package a project never installs. Every versioned
756
+ // patch is pinned by an exact override, so an unused one cannot hide drift.
757
+ if (pkg.pnpm?.patchedDependencies) {
758
+ pkg.pnpm = Object.fromEntries(
759
+ Object.entries(pkg.pnpm).flatMap((entry) =>
760
+ entry[0] === "patchedDependencies" ? [entry, ["allowUnusedPatches", true]] : [entry],
761
+ ),
762
+ );
763
+ }
764
+ // A script whose command lives in a removed tree goes with it; a chained
765
+ // script keeps the segments that still resolve, minus ignore patterns that
766
+ // named removed trees.
767
+ const removedTree = /^(node|tsx|sh|bash)\s+(public|docs|deploy|agent-os|\.changeset)\//;
768
+ const dropSegment = (segment) =>
769
+ removedTree.test(segment) ||
770
+ /^(changeset|typedoc)(\s|$)/.test(segment) ||
771
+ segment.includes("generate-tamagui-workspace-paths");
772
+ const namesPruned = (pattern) =>
773
+ pruned.some((tree) => pattern.startsWith(`${tree}/`) || pattern.includes(`/${tree}/`));
774
+ const cleanSegment = (segment) =>
775
+ segment.replace(/\s--ignore-pattern\s+'([^']*)'/g, (whole, pattern) =>
776
+ namesPruned(pattern) ? "" : whole,
777
+ );
778
+ const scripts = {};
779
+ for (const [name, value] of Object.entries(pkg.scripts ?? {})) {
780
+ if (/^(docs|docs:watch|changeset|changeset:publish|generate:tamagui-paths|prepare)$/.test(name)) continue;
781
+ // verify:lc audits the catalog in public/ against the rulebook in docs/.
782
+ if (name === "verify:lc" && (pruned.includes("public") || pruned.includes("docs"))) continue;
783
+ const kept = value.split(" && ").filter((segment) => !dropSegment(segment)).map(cleanSegment);
784
+ if (!kept.length) continue;
785
+ scripts[name] = kept.join(" && ");
786
+ }
787
+ // Chains that ran a script dropped above lose that call too.
788
+ for (const [name, value] of Object.entries(scripts)) {
789
+ const kept = value
790
+ .split(" && ")
791
+ .filter((segment) => {
792
+ const match = segment.match(/^pnpm\s+([\w:.-]+)$/);
793
+ return !match || match[1] in scripts;
794
+ });
795
+ if (kept.length) scripts[name] = kept.join(" && ");
796
+ else delete scripts[name];
797
+ }
798
+ if (typeof scripts.dev === "string" && /--filter=one\b/.test(scripts.dev)) {
799
+ if (!apps.length) {
800
+ delete scripts.dev;
801
+ } else {
802
+ scripts.dev = `pnpm --filter @app/${apps[0]} dev`;
803
+ if (apps.length > 1) for (const app of apps) scripts[`dev:${app}`] = `pnpm --filter @app/${app} dev`;
804
+ }
805
+ }
806
+ for (const name of Object.keys(scripts)) {
807
+ if (removed.some((app) => scripts[name].includes(`apps/${app}`) || scripts[name].includes(`@platform/${app}`))) {
808
+ delete scripts[name];
809
+ }
810
+ }
811
+ pkg.scripts = scripts;
812
+ if (pruned.includes("public") && pkg.pnpm?.patchedDependencies) {
813
+ for (const [name, patch] of Object.entries(pkg.pnpm.patchedDependencies)) {
814
+ if (patch.startsWith("patches/public/")) delete pkg.pnpm.patchedDependencies[name];
815
+ }
816
+ }
817
+ fs.writeFileSync(file, `${JSON.stringify(pkg, null, 2)}\n`);
818
+ NODE
819
+
820
+ # ── 5. the published packages come from the registry now ─────────────────
821
+
822
+ # workspace:* / workspace:^ / workspace:~ on @multiplatform.one/* become the
823
+ # semver range; @package/* and other private workspace deps stay workspace:.
824
+ for _file in $(grep_files "$TREE" -e '"@multiplatform\.one/[^"]*": *"workspace:' --include=package.json); do
825
+ sed_file "$_file" "s|\\(\"@multiplatform\\.one/[^\"]*\": *\"\\)workspace:\\*\"|\\1$MPO_VERSION\"|
826
+ s|\\(\"@multiplatform\\.one/[^\"]*\": *\"\\)workspace:^\"|\\1^$CLI_VERSION\"|
827
+ s|\\(\"@multiplatform\\.one/[^\"]*\": *\"\\)workspace:~\"|\\1~$CLI_VERSION\"|"
828
+ done
829
+
830
+ if [ -f "$TREE/pnpm-workspace.yaml" ]; then
831
+ _ws='/^[[:space:]]*-[[:space:]]*["'"'"']\{0,1\}public\/\*["'"'"']\{0,1\}[[:space:]]*$/d'
832
+ if [ ! -d "$TREE/apps" ] || [ -z "$(ls -A "$TREE/apps")" ]; then
833
+ _ws="$_ws
834
+ /^[[:space:]]*-[[:space:]]*[\"']\\{0,1\\}apps\\/\\*[\"']\\{0,1\\}[[:space:]]*\$/d"
835
+ fi
836
+ if [ ! -d "$TREE/packages" ] || [ -z "$(ls -A "$TREE/packages")" ]; then
837
+ _ws="$_ws
838
+ /^[[:space:]]*-[[:space:]]*[\"']\\{0,1\\}packages\\/\\*[\"']\\{0,1\\}[[:space:]]*\$/d"
839
+ fi
840
+ sed_file "$TREE/pnpm-workspace.yaml" "$_ws"
841
+ fi
842
+
843
+ # tsconfig project references into public/ and the tsconfig.base.json that
844
+ # extended public/config directly: the published @multiplatform.one/config
845
+ # ships the same tsconfig/base.json.
846
+ for _file in $(grep_files "$TREE" -e '"path": *"\./public/' -e '"path": *"\.\./public/' -e '"path": *"\.\./\.\./public/' --include='tsconfig*.json'); do
847
+ node - "$_file" <<'NODE'
848
+ const fs = require("node:fs");
849
+ const [file] = process.argv.slice(2);
850
+ const raw = fs.readFileSync(file, "utf8");
851
+ // tsconfig files carry full-line comments; a "//" inside a string (the
852
+ // $schema url) is not one, so only whole comment lines are stripped.
853
+ const json = JSON.parse(raw.replace(/^\s*\/\/[^\n]*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""));
854
+ if (Array.isArray(json.references)) {
855
+ json.references = json.references.filter((ref) => !/(^|\/)public\//.test(ref.path));
856
+ }
857
+ fs.writeFileSync(file, `${JSON.stringify(json, null, 2)}\n`);
858
+ NODE
859
+ done
860
+ if [ -f "$TREE/tsconfig.base.json" ] && grep -q 'public/config/tsconfig' "$TREE/tsconfig.base.json"; then
861
+ node - "$TREE/tsconfig.base.json" <<'NODE'
862
+ const fs = require("node:fs");
863
+ const [file] = process.argv.slice(2);
864
+ const raw = fs.readFileSync(file, "utf8");
865
+ // tsconfig files carry full-line comments; a "//" inside a string (the
866
+ // $schema url) is not one, so only whole comment lines are stripped.
867
+ const json = JSON.parse(raw.replace(/^\s*\/\/[^\n]*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""));
868
+ const extendsList = Array.isArray(json.extends) ? json.extends : json.extends ? [json.extends] : [];
869
+ json.extends = extendsList
870
+ .filter((entry) => !entry.includes("tamagui-workspace-paths.generated"))
871
+ .map((entry) => entry.replace(/^\.\/public\/config\/tsconfig\//, "@multiplatform.one/config/tsconfig/"));
872
+ if (json.extends.length === 1) json.extends = json.extends[0];
873
+ fs.writeFileSync(file, `${JSON.stringify(json, null, 2)}\n`);
874
+ NODE
875
+ fi
876
+
877
+ # Stories are apps/storybook's input and import its framework, which only
878
+ # apps/storybook declares; without it the features program leaves them out.
879
+ case " $REMOVED_APPS " in
880
+ *" storybook "*)
881
+ if [ -f "$TREE/features/tsconfig.json" ]; then
882
+ sed_file "$TREE/features/tsconfig.json" 's|"exclude": \["node_modules"\]|"exclude": ["node_modules", "**/*.stories.tsx"]|'
883
+ fi
884
+ ;;
885
+ esac
886
+
887
+ # ── 6. provenance ────────────────────────────────────────────────────────
888
+
889
+ node - "$TREE/.mpo.json" "$CLI_VERSION" "$SHA" "$SOURCE_URL" "$APPS_CSV" "$(printf '%s' "$SERVICES" | tr ' ' ',')" <<'NODE'
890
+ const fs = require("node:fs");
891
+ const [file, cliVersion, sha, url, appsCsv, servicesCsv] = process.argv.slice(2);
892
+ const provenance = {
893
+ cliVersion,
894
+ source: url ? { sha, url } : { sha },
895
+ apps: appsCsv ? appsCsv.split(",") : [],
896
+ services: servicesCsv ? servicesCsv.split(",").sort() : [],
897
+ pieces: [],
898
+ };
899
+ fs.writeFileSync(file, `${JSON.stringify(provenance, null, 2)}\n`);
900
+ NODE
901
+
902
+ # ── 7. --into: lay the tree over an existing directory ───────────────────
903
+
904
+ SKIPPED=""
905
+ if [ "$INTO" -eq 1 ]; then
906
+ mkdir -p "$TARGET"
907
+ _list="$WORK/files"
908
+ (cd "$TREE" && find . -type f -o -type l) | sed 's|^\./||' | sort >"$_list"
909
+ while IFS= read -r _rel; do
910
+ if [ -e "$TARGET/$_rel" ] || [ -L "$TARGET/$_rel" ]; then
911
+ SKIPPED="$SKIPPED $_rel"
912
+ printf 'skipped (exists): %s\n' "$_rel"
913
+ continue
914
+ fi
915
+ mkdir -p "$(dirname "$TARGET/$_rel")"
916
+ cp -P "$TREE/$_rel" "$TARGET/$_rel"
917
+ done <"$_list"
918
+ if [ -n "$SKIPPED" ]; then
919
+ {
920
+ [ -f "$TARGET/.updateignore" ] && [ -n "$(tail -c 1 "$TARGET/.updateignore")" ] && printf '\n'
921
+ printf '# scaffold --into: these paths existed before the scaffold and are yours\n'
922
+ for _rel in $SKIPPED; do printf '%s\n' "$_rel"; done
923
+ } >>"$TARGET/.updateignore"
924
+ fi
925
+ fi
926
+
927
+ # ── 8. report ────────────────────────────────────────────────────────────
928
+
929
+ printf 'scaffold: %s\n' "$TARGET"
930
+ printf ' source %s @ %s\n' "$SOURCE" "$SHA"
931
+ printf ' apps %s\n' "${APPS:-(none)}"
932
+ printf ' services %s\n' "${SERVICES:-(none)}"
933
+ printf ' packages @multiplatform.one/* %s\n' "$MPO_VERSION"
934
+ printf ' removed %s\n' "${REMOVED# }"
935
+ if [ -n "$SKIPPED" ]; then
936
+ printf ' skipped %s (pinned in .updateignore)\n' "$(printf '%s' "${SKIPPED# }" | wc -w | tr -d ' ') existing paths"
937
+ fi