diff --git a/.buildkite/commands/setup_macos_code_signing.sh b/.buildkite/commands/setup_macos_code_signing.sh index 542f34a..34d42b1 100755 --- a/.buildkite/commands/setup_macos_code_signing.sh +++ b/.buildkite/commands/setup_macos_code_signing.sh @@ -11,8 +11,23 @@ bundle exec fastlane setup_code_signing echo "Expose signing config to electron-builder..." # Export necessary env vars for `electron-builder` to find those for its `notarize` option # See https://www.electron.build/mac#notarize -mkdir -p .codesigning/ -echo "$APP_STORE_CONNECT_API_KEY_KEY" >.codesigning/apple_api_key -export APPLE_API_KEY=".codesigning/apple_api_key" +# +# The Buildkite step sources this script, so the shebang's `-eu` never applies. Return explicitly +# so callers without `errexit` still receive materialization failures instead of continuing. +MACOS_NOTARIZATION_TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/wordpress-contributor-toolkit-signing.XXXXXX")" || return 1 +export APPLE_API_KEY="$MACOS_NOTARIZATION_TEMP_DIR/apple_api_key" + +cleanup_macos_notarization_key() { + rm -rf "$MACOS_NOTARIZATION_TEMP_DIR" +} +trap cleanup_macos_notarization_key EXIT + +# `printenv` keeps the key itself out of shell traces, while the private temporary directory and +# restrictive file mode keep it unavailable to other users on the build agent. Unlike `echo` under +# `set -u`, `printenv` exits quietly when the variable is missing — hence the explicit guard. +( umask 077; printenv APP_STORE_CONNECT_API_KEY_KEY >"$APPLE_API_KEY" ) || { + echo "APP_STORE_CONNECT_API_KEY_KEY is unset or could not be written to $APPLE_API_KEY" >&2 + return 1 +} export APPLE_API_KEY_ID="$APP_STORE_CONNECT_API_KEY_KEY_ID" export APPLE_API_ISSUER="$APP_STORE_CONNECT_API_KEY_ISSUER_ID" diff --git a/docs/guide/creating-a-site.md b/docs/guide/creating-a-site.md index 8b6ab7e..c95243a 100644 --- a/docs/guide/creating-a-site.md +++ b/docs/guide/creating-a-site.md @@ -6,14 +6,15 @@ You do not need one, though, for every ticket you work on. A site holds as many When the app starts with no sites, the main area shows a short prompt to create your first one. -![The app before any site exists: an empty main area and the Create WordPress Core site button at the bottom of the sidebar](/screenshots/empty-state.png) +![The app before any site exists: an empty main area and the Create a contributor site button at the bottom of the sidebar](/screenshots/empty-state.png) ## Start the creation flow -Click **Create WordPress Core site** at the bottom of the sidebar. A dialog opens with two fields. +Click **Create a contributor site** at the bottom of the sidebar. A dialog opens. -![The Create WordPress Core site dialog, with a Site name text field and a Site location folder picker](/screenshots/create-site-modal.png) +![The Create a contributor site dialog, with a Contribute to choice, a Site name text field and a Site location folder picker](/screenshots/create-site-modal.png) +- **Contribute to** — the project this site targets: **WordPress Core** (Trac tickets) or **Gutenberg** (GitHub issues). This sets which repository is cloned and where its pull requests go, and cannot be changed later. WordPress Core is selected by default. - **Site name** — the label shown in the sidebar. It also determines the folder name: spaces and characters that are not valid in file names become hyphens, so a site named `My WordPress site` lives in a folder called `My-WordPress-site`. - **Site location** — the parent folder where the site will be created. The app adds a new directory inside it for the project; it does not clone into the folder you pick directly. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index b7f77ac..e21c12c 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -35,7 +35,7 @@ The app is signed and notarized by Automattic, so macOS should open it without i ## Your first contribution, in ten steps -1. Click **Create WordPress Core site** and choose a destination folder for your site. +1. Click **Create a contributor site**, leave **Contribute to** on WordPress Core, and choose a destination folder for your site. 2. Wait while the app downloads `wordpress-develop`. 3. Click **Install npm dependencies**. 4. Click **Run full build**. diff --git a/docs/public/screenshots/create-site-modal.png b/docs/public/screenshots/create-site-modal.png index b0c1aa9..197751d 100644 Binary files a/docs/public/screenshots/create-site-modal.png and b/docs/public/screenshots/create-site-modal.png differ diff --git a/docs/public/screenshots/empty-state.png b/docs/public/screenshots/empty-state.png index bc5512a..218d8f1 100644 Binary files a/docs/public/screenshots/empty-state.png and b/docs/public/screenshots/empty-state.png differ diff --git a/package.json b/package.json index bceae78..0d31aea 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "buildResources": "build" }, "files": [ - "!vendor{,/**/*}" + "!vendor{,/**/*}", + "!.codesigning{,/**/*}" ], "icon": "build/icon.png", "linux": { diff --git a/scripts/screenshots/shots.cjs b/scripts/screenshots/shots.cjs index be97e5e..3b1c0ff 100644 --- a/scripts/screenshots/shots.cjs +++ b/scripts/screenshots/shots.cjs @@ -53,7 +53,7 @@ const shots = [ tier: 'fixture', variant: 'empty', prepare: async (page) => { - await page.getByRole('button', { name: 'Create WordPress Core site' }).click(); + await page.getByRole('button', { name: 'Create a contributor site' }).click(); await page.getByRole('dialog').getByText('Site name').waitFor(); } }, diff --git a/src/main.js b/src/main.js index 54568d6..312fc51 100644 --- a/src/main.js +++ b/src/main.js @@ -53,6 +53,7 @@ const { } = require('./ticket-branches'); const { createProgressThrottle, describeSwitchProgress } = require('./switch-progress.cjs'); const { getStore } = require('./settings-store'); +const { normalizeProjectType, getProjectType, projectTypeForSite } = require('./project-type.cjs'); // One name for the send-only progress channel (#173), shared with preload.js // through the tests rather than by import — the renderer bundle and the main @@ -93,7 +94,6 @@ if (!app.isPackaged && process.env.TOOLKIT_USER_DATA_DIR) { } } -const WORDPRESS_GIT_URL = 'https://github.com/WordPress/wordpress-develop.git'; // Provide a PATH shim so npm's spawned scripts can find a 'node' binary that maps to Electron's Node let nodeShimDir = null; @@ -1227,7 +1227,11 @@ ipcMain.handle('git:update-trunk', async (event, sitePath) => { await mergeSiteMeta(sitePath, { currentBranch: TRUNK }); } - const result = await updateToLatestTrunk({ dir: sitePath, url: WORDPRESS_GIT_URL, onLog: sendLog }); + // Pull from the site's own upstream, not always wordpress-develop + // (#251): updating a Gutenberg checkout from Core's trunk would + // overwrite it with a different project entirely. Defaults to Core. + const updateUrl = projectTypeForSite(await readSiteMeta(sitePath)).clone.url; + const result = await updateToLatestTrunk({ dir: sitePath, url: updateUrl, onLog: sendLog }); // An update resets the worktree, so any applied patch is gone with // it either way — clear the record so the "applied" banner does not // outlive the patch. (This is also where a discard's cleanup lands: @@ -1588,13 +1592,16 @@ ipcMain.handle('site:status', async (_e, sitePath) => { const nmDir = path.join(sitePath, 'node_modules'); const hasNodeModules = fs.existsSync(nmDir) && (() => { try { return fs.readdirSync(nmDir).length > 0; } catch { return false; } })(); - const distDir = path.join(sitePath, 'build', 'wp-includes', 'js', 'dist'); - const hasBuilt = fs.existsSync(distDir); - const s = await getStore(); const meta = s.get('siteMeta') || {}; const m = meta[sitePath] || {}; + // "Is this site built?" is answered differently per project (#251): Core + // has a built wp-includes dist dir, Gutenberg builds into build/. + // The marker path comes from the site's project type, defaulting to Core. + const builtDir = path.join(sitePath, ...projectTypeForSite(m).build.builtCheckRelPath); + const hasBuilt = fs.existsSync(builtDir); + // Trunk snapshot age (#94). Read from HEAD each time (one object // read) and written through to siteMeta, so the sidebar can render // staleness dots from siteMeta alone, without per-site git I/O. @@ -1626,9 +1633,9 @@ ipcMain.handle('site:status', async (_e, sitePath) => { } : null; - return { hasNodeModules, hasBuilt, skipInitWizard: Boolean(m.skipInitWizard), initialized: Boolean(m.initialized), installFailed: Boolean(m.installFailed), trunkOid, trunkDate, updateIncomplete: Boolean(work.updateIncomplete), tracTicket: m.tracTicket || null, appliedPatch }; + return { hasNodeModules, hasBuilt, skipInitWizard: Boolean(m.skipInitWizard), initialized: Boolean(m.initialized), installFailed: Boolean(m.installFailed), trunkOid, trunkDate, updateIncomplete: Boolean(work.updateIncomplete), tracTicket: m.tracTicket || null, projectType: m.projectType || 'core', appliedPatch }; } catch { - return { hasNodeModules: false, hasBuilt: false, skipInitWizard: false, initialized: false, installFailed: false, trunkOid: null, trunkDate: null, updateIncomplete: false, tracTicket: null, appliedPatch: null }; + return { hasNodeModules: false, hasBuilt: false, skipInitWizard: false, initialized: false, installFailed: false, trunkOid: null, trunkDate: null, updateIncomplete: false, tracTicket: null, projectType: 'core', appliedPatch: null }; } }); @@ -1675,6 +1682,11 @@ ipcMain.handle('wordpress:setup', async (event, destDir, options = {}) => { await fse.ensureDir(destDir); + // The contribution target for this site (#251), resolved once: it drives both + // the clone below and the metadata written after it, so they cannot disagree. + const projectType = normalizeProjectType(options.projectType); + const projectConfig = getProjectType(projectType); + const requestedName = typeof options.siteName === 'string' ? options.siteName.trim() : ''; const sanitizedName = requestedName.replace(/[\\/:*?"<>|]+/g, '-').replace(/\s+/g, '-').replace(/^-+|-+$/g, '') || 'wordpress-develop-trunk'; const uniqueName = findAvailableDirName(destDir, sanitizedName); @@ -1692,11 +1704,11 @@ ipcMain.handle('wordpress:setup', async (event, destDir, options = {}) => { await git.clone({ http, fs, - url: WORDPRESS_GIT_URL, + url: projectConfig.clone.url, dir: siteDir, - singleBranch: true, - depth: 1, - ref: 'trunk', + singleBranch: projectConfig.clone.singleBranch, + depth: projectConfig.clone.depth, + ref: projectConfig.clone.ref, onProgress: (evt) => { // evt: {phase,total,loaded,lengthComputable} - forward as terminal-like output const msg = `${evt.phase || 'clone'} ${evt.loaded || 0}/${evt.total || 0}`; @@ -1719,7 +1731,12 @@ ipcMain.handle('wordpress:setup', async (event, destDir, options = {}) => { ...existingMeta, initialized: false, createdAt: existingMeta.createdAt || new Date().toISOString(), - label: existingMeta.label || siteLabel + label: existingMeta.label || siteLabel, + // The contribution target chosen in the wizard (#251), normalized to a + // known id so an unknown value is stored as Core rather than left to + // coerce on every read. Preserved on re-setup of a folder, and the + // same id the clone above used. + projectType: existingMeta.projectType || projectType }; try { const { trunkOid, trunkDate } = await readTrunkInfo(siteDir); diff --git a/src/project-type.cjs b/src/project-type.cjs new file mode 100644 index 0000000..7d8f28c --- /dev/null +++ b/src/project-type.cjs @@ -0,0 +1,138 @@ +'use strict'; + +// The contribution targets ("project types") the toolkit can host. +// +// Until now every site was implicitly a `wordpress-develop` (WordPress Core) +// checkout, and that assumption was a scattering of constants: the clone URL, +// the "is it built?" path, the dev/build scripts, the Playground serve model, +// the work-item source (Trac), and the pull-request upstream. This module is +// the one place those per-target facts live, so a site's chosen type — not a +// constant buried in a handler — drives each of them (issue #251). +// +// It is deliberately pure data + pure functions: no `electron`, no `fs`, no +// side effects. Both the main process and the renderer bundle `require`/`import` +// it, and `node --test` loads it directly. +// +// The invariant every consumer relies on: an unknown or missing type resolves +// to Core. A site created before this field existed has no `projectType`, so it +// keeps Core behavior with no migration and no store write. + +const WORDPRESS_DEVELOP_GIT_URL = 'https://github.com/WordPress/wordpress-develop.git'; +const GUTENBERG_GIT_URL = 'https://github.com/WordPress/gutenberg.git'; + +const DEFAULT_PROJECT_TYPE = 'core'; + +const PROJECT_TYPES = { + core: { + id: 'core', + label: 'WordPress Core', + // The option label shown in the create-site wizard picker. + wizardLabel: 'WordPress Core (Trac tickets)', + // One-line description shown next to the choice in the create-site wizard. + description: 'Contribute to WordPress Core using Trac tickets.', + // The noun this target uses for a unit of work, for UI copy: a Core site + // links a "ticket", a Gutenberg site links an "issue". + workItemNoun: 'ticket', + + clone: { url: WORDPRESS_DEVELOP_GIT_URL, ref: 'trunk', singleBranch: true, depth: 1 }, + upstream: { owner: 'WordPress', repo: 'wordpress-develop', base: 'trunk' }, + + build: { + // site:status checks this path under the site to decide "is it built?". + builtCheckRelPath: ['build', 'wp-includes', 'js', 'dist'], + buildScript: 'build', + // wordpress-develop's dev watcher is Grunt, reached through npm's `--` + // passthrough (see dev-server-command.cjs for why the separator matters). + watch: { script: 'grunt', args: ['--', '_watch'], label: 'npm run grunt -- _watch' }, + allowedScripts: ['build', 'build:dev', 'dev', 'test', 'watch', 'grunt'] + }, + + // 'docroot' — the built checkout IS the WordPress install Playground serves. + serve: { strategy: 'docroot' }, + + // 'src-layout' — patch paths are rewritten into wordpress-develop's + // src/wp-includes layout (patch-plan.cjs mapToSrcLayout). + patch: { layout: 'src-layout' }, + + workItem: { provider: 'trac' }, + + pr: { + branchPrefix: 'trac-', + // The line that ties the pull request back to its work item. + bodyLine: (id, url) => `Trac ticket: ${url}`, + closesKeyword: null + } + }, + + gutenberg: { + id: 'gutenberg', + label: 'Gutenberg', + wizardLabel: 'Gutenberg (GitHub issues)', + description: 'Contribute to the block editor (Gutenberg) using GitHub issues.', + workItemNoun: 'issue', + + clone: { url: GUTENBERG_GIT_URL, ref: 'trunk', singleBranch: true, depth: 1 }, + upstream: { owner: 'WordPress', repo: 'gutenberg', base: 'trunk' }, + + build: { + // Gutenberg builds each package into build/; block-library is + // always present in a completed build, so its directory is the marker. + builtCheckRelPath: ['build', 'block-library'], + buildScript: 'build', + // `npm run dev` is already an incremental watcher — it must NOT inherit + // Core's `grunt -- _watch` passthrough dance. + watch: { script: 'dev', args: [], label: 'npm run dev' }, + allowedScripts: ['build', 'dev', 'test', 'test:unit', 'lint'] + }, + + // 'plugin-mount' — Gutenberg is a plugin, so Playground boots a stock + // WordPress and mounts the built checkout as an active plugin. + serve: { strategy: 'plugin-mount' }, + + // 'repo-relative' — Gutenberg PR diffs are already repo-relative + // (packages/…); no src-layout rewrite. + patch: { layout: 'repo-relative' }, + + workItem: { provider: 'github-issue' }, + + pr: { + branchPrefix: 'fix/issue-', + bodyLine: (id) => `Fixes #${id}`, + closesKeyword: 'Fixes' + } + } +}; + +// Resolve a stored id to its config, defaulting to Core for anything unknown or +// missing. This is the single seam every caller uses — never index +// PROJECT_TYPES directly with untrusted input. +function getProjectType(id) { + return PROJECT_TYPES[id] || PROJECT_TYPES[DEFAULT_PROJECT_TYPE]; +} + +// Convenience for the common case: resolve straight from a site's stored meta. +function projectTypeForSite(meta) { + return getProjectType(meta && meta.projectType); +} + +// True only for an id the registry actually defines — for validating input +// before persisting it (an unknown id is coerced to Core on read, but we store +// the normalized id, not the raw input). +function isProjectTypeId(id) { + return Object.prototype.hasOwnProperty.call(PROJECT_TYPES, id); +} + +// Normalize arbitrary input to a stored id: a known id passes through, anything +// else becomes the default. Used at the write boundary (wordpress:setup). +function normalizeProjectType(id) { + return isProjectTypeId(id) ? id : DEFAULT_PROJECT_TYPE; +} + +module.exports = { + PROJECT_TYPES, + DEFAULT_PROJECT_TYPE, + getProjectType, + projectTypeForSite, + isProjectTypeId, + normalizeProjectType +}; diff --git a/src/renderer/dev-server-command.cjs b/src/renderer/dev-server-command.cjs index 0f08b0d..964d079 100644 --- a/src/renderer/dev-server-command.cjs +++ b/src/renderer/dev-server-command.cjs @@ -8,7 +8,12 @@ * DOM: the renderer bundle imports it, `node --test` requires it directly * (same convention as setup-steps.cjs). * - * Why the watcher is `grunt -- _watch` and not `npm run watch`: + * The build and watch commands are not the same for every contribution target, + * so they come from the project-type registry's `build` config rather than + * being hard-coded here. `planDevServerStart` defaults to Core's config, which + * keeps every existing caller (and its tests) unchanged. + * + * Why Core's watcher is `grunt -- _watch` and not `npm run watch`: * wordpress-develop's Gruntfile renames the real watch task to `_watch` and * registers a `watch` wrapper that runs the entire production `build` task * first when invoked without arguments. On a site that has already completed @@ -16,29 +21,33 @@ * tens of minutes go on every dev-server start (30+ on a Windows VM). * Invoking `_watch` through the `grunt` passthrough script starts the same * watchers immediately. Sites without a completed build still need one, so - * they get `npm run build` — whose exit code is a real completion signal — - * before the watcher starts. + * they get `npm run ` — whose exit code is a real completion + * signal — before the watcher starts. * - * The `'--'` in the watcher args is load-bearing: script-runner.js + * The `'--'` in Core's watcher args is load-bearing: script-runner.js * deliberately does not insert a separator, and without one npm consumes * `_watch` as its own argument and runs bare `grunt` — the default task, - * i.e. a full build with no watcher. + * i.e. a full build with no watcher. Gutenberg's watcher is `npm run dev`, + * already an incremental watcher, so it takes no such separator. */ -const WATCH_SCRIPT = 'grunt'; -const WATCH_ARGS = ['--', '_watch']; -const WATCH_COMMAND_LABEL = 'npm run grunt -- _watch'; +const { getProjectType } = require('../project-type.cjs'); + +const CORE_BUILD_CONFIG = getProjectType('core').build; -function planDevServerStart(flags = {}) { +function planDevServerStart(flags = {}, buildConfig = CORE_BUILD_CONFIG) { const hasBuilt = Boolean(flags.hasBuilt); + const build = buildConfig || CORE_BUILD_CONFIG; return { - // True when `npm run build` must run (and exit 0) before the watcher - // and the server may start. + // True when the build must run (and exit 0) before the watcher and the + // server may start. needsBuild: !hasBuilt, + // The npm script that produces a completed build for this project. + buildScript: build.buildScript, watch: { - script: WATCH_SCRIPT, - args: WATCH_ARGS.slice(), - label: WATCH_COMMAND_LABEL + script: build.watch.script, + args: build.watch.args.slice(), + label: build.watch.label } }; } diff --git a/src/renderer/index.jsx b/src/renderer/index.jsx index 5775079..2f97611 100644 --- a/src/renderer/index.jsx +++ b/src/renderer/index.jsx @@ -12,6 +12,7 @@ import { MenuGroup, MenuItem, Modal, + RadioControl, SnackbarList, TextControl, TextareaControl, @@ -43,8 +44,8 @@ import { highlightLog } from './log-highlight.cjs'; import { carryTestMode } from './github-account.cjs'; import { changesNoteParts, discardOutcome, noteAfterDiscard, modalDiscardDisabled, discardBlocked, DISCARD_CONFIRM_MESSAGE } from './changes-note.cjs'; import { initialConfirmations, confirmationReducer, prConfirmationMessage } from './confirmations.cjs'; +import { PROJECT_TYPES, DEFAULT_PROJECT_TYPE, getProjectType } from '../project-type.cjs'; -const TERMINAL_ALLOWED_SCRIPTS = ['build', 'build:dev', 'dev', 'test', 'watch', 'grunt']; // One face for everything that is process output: the terminal below and every // log pane above it. Shared rather than repeated because the panes had drifted // into the app's sans-serif, which does not line up a stack trace and does not @@ -109,6 +110,9 @@ const RENAME_INPUT_ID = 'rename-site-name-input'; const CREATE_SITE_NAME_INPUT_ID = 'create-site-name-input'; const CREATE_SITE_LOCATION_INPUT_ID = 'create-site-location-input'; const CREATE_SITE_LOCATION_HELP_ID = 'create-site-location-help'; +// The contribution-target choices for the create-site wizard, sourced from the +// project-type registry so the copy lives in one place. Core is first (default). +const CREATE_SITE_TYPE_OPTIONS = Object.values(PROJECT_TYPES).map((t) => ({ label: t.wizardLabel, value: t.id })); // Why the ticket's PR list could not be read, worded for the contributor. const TICKET_PATCH_STATUS_MESSAGE = { 'rate-limited': 'GitHub is rate-limiting this connection.', @@ -323,6 +327,7 @@ function App() { const [createModalOpen, setCreateModalOpen] = useState(false); const [createSiteName, setCreateSiteName] = useState(''); const [createSiteDir, setCreateSiteDir] = useState(''); + const [createSiteType, setCreateSiteType] = useState(DEFAULT_PROJECT_TYPE); const [createSiteError, setCreateSiteError] = useState(''); const [createSubmitting, setCreateSubmitting] = useState(false); const [setupLogsBySite, setSetupLogsBySite] = useState({}); @@ -489,6 +494,7 @@ function App() { if (createSubmitting) return; setCreateSiteName(''); setCreateSiteDir(''); + setCreateSiteType(DEFAULT_PROJECT_TYPE); setCreateSiteError(''); setCreateModalOpen(true); }, [createSubmitting]); @@ -546,6 +552,8 @@ function App() { setCreateModalOpen(false); setCreateSiteName(''); setCreateSiteDir(''); + const chosenType = createSiteType; + setCreateSiteType(DEFAULT_PROJECT_TYPE); try { setCreateSubmitting(true); @@ -553,7 +561,7 @@ function App() { setTerminalMsgs(''); addPendingSite(targetDir); appendSetupLog(targetDir, 'Starting site setup…\n'); - const createdPath = await window.api.setupWordPress(createSiteDir, { siteName: cleanFolder, siteLabel: nameTrimmed }); + const createdPath = await window.api.setupWordPress(createSiteDir, { siteName: cleanFolder, siteLabel: nameTrimmed, projectType: chosenType }); if (createdPath) { finalSitePath = createdPath; // Ordinarily already done, by the `cloning` status this handler's own @@ -587,7 +595,7 @@ function App() { clearPendingSites(); setCreateSubmitting(false); } - }, [addPendingSite, appendSetupLog, applySetup, clearPendingSites, createSiteDir, createSiteName, moveSetupLog, refresh]); + }, [addPendingSite, appendSetupLog, applySetup, clearPendingSites, createSiteDir, createSiteName, createSiteType, moveSetupLog, refresh]); const closeCreateModal = useCallback(() => { if (createSubmitting) return; @@ -824,10 +832,10 @@ function App() { onClick={chooseAndSetup} disabled={createSubmitting} style={{ width: '100%', justifyContent: 'center' }} - aria-label="Create WordPress Core site" + aria-label="Create a contributor site" label={createSubmitting ? 'Finish creating the current site first' : undefined} > - {!sidebarCollapsed ? 'Create WordPress Core site' : null} + {!sidebarCollapsed ? 'Create a contributor site' : null} @@ -924,7 +932,7 @@ function App() { {createModalOpen ? ( @@ -944,6 +952,14 @@ function App() { // eslint-disable-next-line jsx-a11y/no-autofocus -- intentional: this is the first field of a just-opened modal. autoFocus /> + setCreateSiteType(value)} + disabled={createSubmitting} + />
Run one of: ' + TERMINAL_ALLOWED_SCRIPTS.join(', ') + '\n'); + writeToTerminal(' npm run