sitevision-cli 1.0.0-beta.30 → 1.0.0-beta.32

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.
@@ -1,10 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
4
5
  import { Box, Text, useApp, useInput, useStdout } from 'ink';
5
6
  import Spinner from 'ink-spinner';
6
7
  import { detectProject, appTypeOf, localizedText, readWorkspaceDevProperties, readSvcConfig, writeSvcConfig, writeDevProperties, syncDevPropertiesToPackageJson, } from '../utils/project-detection.js';
7
- import { appGroup, configIncomplete, needsOnboarding, } from '../utils/workspace.js';
8
+ import { appGroup, configIncomplete, discoverApps, needsOnboarding, } from '../utils/workspace.js';
9
+ import { createAppInTerminal, isEmptyOrMissing, seedNewApp, startCreateApp, appNameAdvice, appNameProblem, } from '../utils/create-app.js';
8
10
  import { listAddons, listExecutables, } from '../utils/sitevision-api.js';
9
11
  import { useTasks, runningTasks, startActivate, getTasks, } from '../utils/tasks.js';
10
12
  import { PasswordInput } from '../components/PasswordInput.js';
@@ -20,7 +22,7 @@ import { Popover } from './Popover.js';
20
22
  import { ChangelogPanel } from './Changelog.js';
21
23
  import { baseEnvironment, environmentColor, environmentNames, environmentProject, isProductionEnvironment, resolveEnvironment, } from '../utils/environments.js';
22
24
  import { t } from '../utils/i18n.js';
23
- import { actionForKey, authState, resolveDeployConfig, } from './actions.js';
25
+ import { actionForKey, actions, authState, resolveDeployConfig, } from './actions.js';
24
26
  function useSize() {
25
27
  const { stdout } = useStdout();
26
28
  const read = () => ({ columns: stdout.columns || 80, rows: stdout.rows || 24 });
@@ -35,7 +37,7 @@ function useSize() {
35
37
  }, [stdout]);
36
38
  return size;
37
39
  }
38
- export function Shell({ apps: initialApps, workspaceRoot, version, minimal = false, updatedFrom, }) {
40
+ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = false, updatedFrom, handover, }) {
39
41
  const { exit } = useApp();
40
42
  const { columns, rows } = useSize();
41
43
  const tasks = useTasks();
@@ -51,6 +53,7 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
51
53
  const [versionRow, setVersionRow] = useState(0);
52
54
  const [logScroll, setLogScroll] = useState(0);
53
55
  const [logWrap, setLogWrap] = useState(false);
56
+ const [offerAddon, setOfferAddon] = useState();
54
57
  const [notice, setNotice] = useState(null);
55
58
  const [, tick] = useReducer((n) => n + 1, 0);
56
59
  // In workspace mode the row after the last app is "Workspace settings".
@@ -169,6 +172,165 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
169
172
  writeSvcConfig(configRoot, { environment: clean });
170
173
  notify(t('environment {env} added', { env: clean }), 'ok');
171
174
  },
175
+ async createApp() {
176
+ const promptText = async (label, initial, validate, error, advise) => new Promise(resolve => {
177
+ setOverlay({
178
+ kind: 'prompt',
179
+ label,
180
+ initial,
181
+ validate,
182
+ advise,
183
+ error,
184
+ resolve,
185
+ });
186
+ });
187
+ const base = workspaceRoot ?? path.dirname(rawProject.root);
188
+ const suggested = workspaceRoot
189
+ ? path.relative(workspaceRoot, path.dirname(rawProject.root)) || '.'
190
+ : base;
191
+ // Asked again, with the reason, until the name is free in that folder.
192
+ let name = '';
193
+ let parentDir = '';
194
+ let appDir = '';
195
+ let taken;
196
+ for (;;) {
197
+ // eslint-disable-next-line no-await-in-loop
198
+ const typed = await promptText(t('Name of the new app'), name, appNameProblem, taken, appNameAdvice);
199
+ if (typed === null)
200
+ return;
201
+ name = typed.trim();
202
+ // Folders that already hold apps, then a way to type another one.
203
+ const folders = [
204
+ ...new Set([
205
+ suggested,
206
+ ...apps.map(app => workspaceRoot
207
+ ? path.relative(base, path.dirname(app.root)) || '.'
208
+ : path.dirname(app.root)),
209
+ ]),
210
+ ].toSorted((a, b) => a.localeCompare(b));
211
+ // eslint-disable-next-line no-await-in-loop
212
+ const folderPick = await new Promise(resolve => {
213
+ setOverlay({
214
+ kind: 'choice',
215
+ label: t('Create it in folder'),
216
+ choices: [...folders, t('Other folder…')],
217
+ multi: false,
218
+ initial: [folders.indexOf(suggested)],
219
+ resolve,
220
+ });
221
+ });
222
+ if (!folderPick)
223
+ return;
224
+ const folder = folders[folderPick[0]] ??
225
+ // eslint-disable-next-line no-await-in-loop
226
+ (await promptText(t('Create it in folder'), suggested));
227
+ if (folder === null)
228
+ return;
229
+ parentDir = path.resolve(base, folder.trim() || '.');
230
+ appDir = path.join(parentDir, name);
231
+ if (isEmptyOrMissing(appDir))
232
+ break;
233
+ taken = t('{dir} already exists. Pick another name, or keep it and choose a different folder next.', { dir: path.relative(base, appDir) });
234
+ }
235
+ // Both were checked above, so a run that leaves no app may remove them.
236
+ const madeParent = !fs.existsSync(parentDir);
237
+ const discard = () => {
238
+ fs.rmSync(appDir, { recursive: true, force: true });
239
+ if (madeParent && isEmptyOrMissing(parentDir))
240
+ fs.rmSync(parentDir, { recursive: true, force: true });
241
+ };
242
+ fs.mkdirSync(parentDir, { recursive: true });
243
+ const known = (workspaceRoot
244
+ ? readWorkspaceDevProperties(workspaceRoot)
245
+ : rawProject.devProperties);
246
+ const ask = async (question) => {
247
+ // svc keeps passwords in the keychain, never in the app's file.
248
+ if (question.name === 'password')
249
+ return { skip: true };
250
+ const shared = known?.[question.name];
251
+ if (!question.error &&
252
+ ['domain', 'siteName', 'username', 'useHTTPForDevDeploy'].includes(question.name) &&
253
+ shared !== undefined &&
254
+ shared !== '')
255
+ return { value: shared };
256
+ const label = question.error
257
+ ? `${question.message} (${question.error})`
258
+ : question.message;
259
+ if (question.type === 'confirm') {
260
+ const value = await new Promise(resolve => {
261
+ setOverlay({ kind: 'confirm', message: label, resolve });
262
+ });
263
+ return { value };
264
+ }
265
+ if (question.type === 'password') {
266
+ const secret = await new Promise(resolve => {
267
+ setOverlay({ kind: 'password', label, resolve });
268
+ });
269
+ return secret && { value: secret.password };
270
+ }
271
+ if (question.choices.length > 0) {
272
+ const multi = question.type === 'checkbox';
273
+ const picked = await new Promise(resolve => {
274
+ setOverlay({
275
+ kind: 'choice',
276
+ label,
277
+ choices: question.choices,
278
+ multi,
279
+ initial: [question.default ?? []].flat(),
280
+ resolve,
281
+ });
282
+ });
283
+ return picked && { value: multi ? picked : picked[0] };
284
+ }
285
+ const initial = question.default ?? (question.name === 'addonName' ? name : '');
286
+ const value = await promptText(label, typeof initial === 'string' ? initial : JSON.stringify(initial));
287
+ return value === null ? null : { value };
288
+ };
289
+ notify(t('creating {app}: installing, questions follow', { app: name }));
290
+ setTab('log');
291
+ const { done } = startCreateApp({ name, parentDir, ask });
292
+ let outcome = await done;
293
+ setOverlay(null);
294
+ if (outcome === 'unmanaged') {
295
+ if (!handover) {
296
+ notify(t('The scaffolder could not be run from here'), 'error');
297
+ return;
298
+ }
299
+ fs.rmSync(appDir, { recursive: true, force: true });
300
+ handover(async () => {
301
+ if (await createAppInTerminal(name, parentDir))
302
+ seedNewApp(appDir, workspaceRoot);
303
+ else
304
+ discard();
305
+ });
306
+ return;
307
+ }
308
+ if (outcome !== 'created') {
309
+ discard();
310
+ if (outcome === 'failed')
311
+ notify(t('creating {app} failed, see the log', { app: name }), 'error');
312
+ return;
313
+ }
314
+ try {
315
+ seedNewApp(appDir, workspaceRoot);
316
+ }
317
+ catch (error) {
318
+ notify(error instanceof Error ? error.message : String(error), 'warn');
319
+ outcome = 'failed';
320
+ }
321
+ if (!workspaceRoot ||
322
+ path.relative(workspaceRoot, appDir).startsWith('..')) {
323
+ notify(t('{app} created in {dir}', { app: name, dir: appDir }), 'ok');
324
+ return;
325
+ }
326
+ const found = discoverApps(workspaceRoot);
327
+ setApps(found);
328
+ setFilter('');
329
+ setSelected(Math.max(0, found.findIndex(app => app.root === appDir)));
330
+ if (outcome === 'created')
331
+ notify(t('{app} created', { app: name }), 'ok');
332
+ setOfferAddon(appDir);
333
+ },
172
334
  openWorkspaceSettings: workspaceRoot
173
335
  ? () => {
174
336
  setSelected(apps.length);
@@ -252,12 +414,38 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
252
414
  const pickAddon = useCallback(async () => new Promise(resolve => {
253
415
  setOverlay({ kind: 'picker', resolve });
254
416
  }), []);
417
+ // A freshly created app: once it is the selected project, offer its addon.
418
+ useEffect(() => {
419
+ if (!offerAddon || project.root !== offerAddon || overlay)
420
+ return;
421
+ setOfferAddon(undefined);
422
+ const addon = project.devProperties?.addonName;
423
+ const action = actions.find(entry => entry.id === 'create-addon');
424
+ if (!addon || !action || !project.devProperties?.domain)
425
+ return;
426
+ void context
427
+ .confirm(t('Create the addon "{addon}" on {domain} now?', {
428
+ addon,
429
+ domain: project.devProperties.domain,
430
+ }))
431
+ .then(yes => {
432
+ if (yes)
433
+ run(action);
434
+ });
435
+ }, [offerAddon, project, overlay, context, run]);
255
436
  const loadAddons = useCallback(async () => {
256
437
  const config = await resolveDeployConfig(context);
257
438
  return config ? listAddons(config) : { error: t('No credentials.') };
258
439
  }, [context]);
259
440
  const appTasks = tasks.filter(task => task.appRoot === project.root);
260
- const logTask = appTasks.find(task => task.status === 'running') ?? appTasks.at(-1);
441
+ // A new app is not in the list while it is being created, so its log shows
442
+ // wherever you are; a failed one stays up until another task runs.
443
+ const creating = tasks.findLast(task => task.kind === 'create');
444
+ const logTask = appTasks.find(task => task.status === 'running') ??
445
+ (creating?.status === 'running' ||
446
+ (creating?.status === 'error' && creating === tasks.at(-1))
447
+ ? creating
448
+ : appTasks.at(-1));
261
449
  useInput((raw, key) => {
262
450
  // Terminals speaking the kitty keyboard protocol report shift+s as
263
451
  // "s" plus a shift flag; fold that back into the uppercase letter so
@@ -432,6 +620,7 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
432
620
  ['/', 'commands'],
433
621
  ['q', 'quit'],
434
622
  ]);
623
+ const where = focus === 'nav' && !settings ? 'nav' : settings ? 'workspace' : tab;
435
624
  const hereHints = focus === 'nav' && !settings
436
625
  ? navHints
437
626
  : settings
@@ -507,6 +696,7 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
507
696
  renderOverlay(overlay, {
508
697
  project,
509
698
  here: hereHints,
699
+ where,
510
700
  closeOverlay,
511
701
  run,
512
702
  notify,
@@ -530,6 +720,8 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
530
720
  base: rawProject.devProperties,
531
721
  environment: env,
532
722
  workspaceRoot,
723
+ manifest: project.manifest,
724
+ manifestPath: project.paths.manifest,
533
725
  }, active: formActive, width: narrow ? columns : columns - sidebar, height: contentHeight, pickAddon: pickAddon, onSaved: () => {
534
726
  reload();
535
727
  notify(t('config saved'), 'ok');
@@ -542,7 +734,7 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
542
734
  : t(' · shared .dev_properties.json at the root') })] })) : (_jsx(TabBar, { tab: tab, narrow: narrow, focused: focus === 'content' })), _jsx(Box, { flexDirection: "column", height: contentHeight, overflow: "hidden", alignItems: "flex-start", children: content })] })] }), _jsx(BottomBar, { hints: hints, right: right }), popover && (_jsx(Popover, { columns: columns, rows: frameRows, width: popoverWidth, height: popoverHeight, children: popover }))] }));
543
735
  }
544
736
  function renderOverlay(overlay, env) {
545
- const { project, here, closeOverlay, run, notify, loadAddons, height, rerender, openWorkspace, } = env;
737
+ const { project, here, where, closeOverlay, run, notify, loadAddons, height, rerender, openWorkspace, } = env;
546
738
  switch (overlay.kind) {
547
739
  case 'palette':
548
740
  return (_jsx(CommandPalette, { project: project, onRun: run, onClose: closeOverlay, height: height }));
@@ -572,15 +764,23 @@ function renderOverlay(overlay, env) {
572
764
  overlay.resolve(answer);
573
765
  } }));
574
766
  case 'prompt':
575
- return (_jsx(TextPrompt, { label: overlay.label, onSubmit: value => {
767
+ return (_jsx(TextPrompt, { label: overlay.label, initial: overlay.initial, validate: overlay.validate, advise: overlay.advise, error: overlay.error, onSubmit: value => {
576
768
  closeOverlay();
577
769
  overlay.resolve(value);
578
770
  }, onCancel: () => {
579
771
  closeOverlay();
580
772
  overlay.resolve(null);
581
- } }));
773
+ } }, overlay.label));
774
+ case 'choice':
775
+ return (_jsx(ChoicePrompt, { label: overlay.label, choices: overlay.choices, multi: overlay.multi, initial: overlay.initial, onSubmit: picked => {
776
+ closeOverlay();
777
+ overlay.resolve(picked);
778
+ }, onCancel: () => {
779
+ closeOverlay();
780
+ overlay.resolve(null);
781
+ } }, overlay.label));
582
782
  case 'help':
583
- return _jsx(HelpPanel, { here: here, height: height, onClose: closeOverlay });
783
+ return (_jsx(HelpPanel, { here: here, where: where, height: height, onClose: closeOverlay }));
584
784
  case 'changelog':
585
785
  return (_jsx(ChangelogPanel, { since: overlay.since, height: height, onClose: closeOverlay }));
586
786
  case 'settings':
@@ -604,17 +804,54 @@ function Confirm({ message, onAnswer, }) {
604
804
  });
605
805
  return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { children: message }), _jsx(Text, { dimColor: true, children: t('y confirm · n cancel') })] }));
606
806
  }
607
- function TextPrompt({ label, onSubmit, onCancel, }) {
608
- const [value, setValue] = useState('');
807
+ function ChoicePrompt({ label, choices, multi, initial, onSubmit, onCancel, }) {
808
+ const [cursor, setCursor] = useState(multi ? 0 : (initial[0] ?? 0));
809
+ const [checked, setChecked] = useState(() => new Set(multi ? initial : []));
609
810
  useInput((input, key) => {
610
811
  if (key.escape)
611
812
  onCancel();
612
813
  else if (key.return)
613
- onSubmit(value);
814
+ onSubmit(multi ? [...checked].toSorted((a, b) => a - b) : [cursor]);
815
+ else if (key.upArrow)
816
+ setCursor(c => (c - 1 + choices.length) % choices.length);
817
+ else if (key.downArrow)
818
+ setCursor(c => (c + 1) % choices.length);
819
+ else if (multi && input === ' ') {
820
+ setChecked(current => {
821
+ const next = new Set(current);
822
+ if (!next.delete(cursor))
823
+ next.add(cursor);
824
+ return next;
825
+ });
826
+ }
827
+ });
828
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, children: label }), choices.map((choice, i) => (_jsxs(Text, { color: i === cursor ? ACCENT : undefined, bold: i === cursor, children: [i === cursor ? '▸ ' : ' ', multi ? (checked.has(i) ? '◉ ' : '◯ ') : '', choice] }, choice))), _jsx(Text, { dimColor: true, children: multi
829
+ ? t('↑↓ move · Space toggle · Enter submit · Esc cancel')
830
+ : t('↑↓ move · Enter select · Esc cancel') })] }));
831
+ }
832
+ function TextPrompt({ label, initial = '', validate, advise, error, onSubmit, onCancel, }) {
833
+ const [value, setValue] = useState(initial);
834
+ const [problem, setProblem] = useState(error);
835
+ // The value an advice was shown for; submitting it again keeps it.
836
+ const [advised, setAdvised] = useState();
837
+ const advice = advised === value ? advise?.(value) : undefined;
838
+ useInput((input, key) => {
839
+ if (key.escape)
840
+ onCancel();
841
+ else if (key.return) {
842
+ const invalid = validate?.(value);
843
+ setProblem(invalid);
844
+ if (invalid)
845
+ return;
846
+ if (advise?.(value) && advised !== value)
847
+ setAdvised(value);
848
+ else
849
+ onSubmit(value);
850
+ }
614
851
  else if (key.backspace || key.delete)
615
852
  setValue(v => v.slice(0, -1));
616
853
  else if (input && !key.ctrl && !key.meta)
617
854
  setValue(v => v + input);
618
855
  });
619
- return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, children: label }), _jsxs(Text, { children: [_jsx(Text, { color: ACCENT, children: "\u276F " }), value, _jsx(Text, { inverse: true, children: " " })] }), _jsx(Text, { dimColor: true, children: t('Press Enter to submit, Esc to cancel') })] }));
856
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, children: label }), _jsxs(Text, { children: [_jsx(Text, { color: ACCENT, children: "\u276F " }), value, _jsx(Text, { inverse: true, children: " " })] }), problem && _jsxs(Text, { color: "red", children: ["\u2717 ", problem] }), !problem && advice && _jsx(Text, { color: "yellow", children: advice }), _jsx(Text, { dimColor: true, children: t('Press Enter to submit, Esc to cancel') })] }));
620
857
  }
@@ -22,6 +22,7 @@ export interface ActionContext {
22
22
  isProduction: boolean;
23
23
  cycleEnvironment: () => void;
24
24
  addEnvironment: () => Promise<void>;
25
+ createApp: () => Promise<void>;
25
26
  notify: (text: string, level?: 'info' | 'ok' | 'warn' | 'error') => void;
26
27
  quit: () => void;
27
28
  }
Binary file
@@ -0,0 +1,53 @@
1
+ import { type Task } from './tasks.js';
2
+ export declare const CREATE_COMMAND: string[];
3
+ export interface PromptQuestion {
4
+ name: string;
5
+ type: string;
6
+ message: string;
7
+ choices: string[];
8
+ default?: unknown;
9
+ error?: string;
10
+ }
11
+ export type PromptReply = {
12
+ value: unknown;
13
+ } | {
14
+ skip: true;
15
+ };
16
+ export type Ask = (question: PromptQuestion) => Promise<PromptReply | null>;
17
+ /** A folder name that is also a usable package name and manifest id. */
18
+ export declare const validAppName: (name: string) => boolean;
19
+ /** The closest conventional name: "Min nya app!" and "MinNyaApp" become "min-nya-app". */
20
+ export declare function suggestAppName(name: string): string;
21
+ /** What is wrong with a typed app name, said so the fix is obvious. */
22
+ export declare function appNameProblem(typed: string): string | undefined;
23
+ /** A valid name that could be better; the user may keep it. */
24
+ export declare function appNameAdvice(typed: string): string | undefined;
25
+ export declare const isEmptyOrMissing: (dir: string) => boolean;
26
+ /** Listen for the stub's questions. `null` from `ask` means the user cancelled. */
27
+ export declare function servePrompts(ask: Ask, onCancel: () => void): Promise<{
28
+ socketPath: string;
29
+ connected: () => boolean;
30
+ close: () => void;
31
+ }>;
32
+ export type CreateOutcome = 'created' | 'unmanaged' | 'failed' | 'stopped';
33
+ /**
34
+ * Run the scaffolder for `name` inside `parentDir`. "unmanaged" means it never
35
+ * asked svc anything and left no app: the prompt bridge did not take.
36
+ */
37
+ export declare function startCreateApp(options: {
38
+ name: string;
39
+ parentDir: string;
40
+ ask: Ask;
41
+ command?: string[];
42
+ }): {
43
+ task: Task;
44
+ done: Promise<CreateOutcome>;
45
+ };
46
+ /** Run the scaffolder on the real terminal; resolves when it exits. */
47
+ export declare function createAppInTerminal(name: string, parentDir: string): Promise<boolean>;
48
+ /**
49
+ * Replace the template's placeholders with what is known, and fold the config
50
+ * the tool wrote into svc's layout: no plaintext password, and nothing in the
51
+ * app's .dev_properties.json that the workspace already provides.
52
+ */
53
+ export declare function seedNewApp(appDir: string, workspaceRoot?: string): void;
@@ -0,0 +1,219 @@
1
+ /**
2
+ * New apps are scaffolded by Sitevision's own tool. svc runs it as a task and
3
+ * answers its questions through a socket (see source/scaffold); when the tool no
4
+ * longer asks through inquirer the run is reported as unmanaged, and the caller
5
+ * hands it the real terminal instead.
6
+ */
7
+ import fs from 'node:fs';
8
+ import net from 'node:net';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import readline from 'node:readline';
12
+ import { spawn } from 'node:child_process';
13
+ import { stripVTControlCharacters } from 'node:util';
14
+ import { t } from './i18n.js';
15
+ import { ProcessRunner } from './process-runner.js';
16
+ import { createTask, setPhase } from './tasks.js';
17
+ import { detectProject, findDevPropertiesPath, migrateLegacyPassword, readInheritedDevProperties, readManifest, updatePackageJson, writeManifestField, } from './project-detection.js';
18
+ export const CREATE_COMMAND = [
19
+ 'npx',
20
+ '--yes',
21
+ '@sitevision/create-sitevision-app',
22
+ ];
23
+ /** A folder name that is also a usable package name and manifest id. */
24
+ export const validAppName = (name) => /^[\da-z][\w\-.]*$/i.test(name);
25
+ /** The closest conventional name: "Min nya app!" and "MinNyaApp" become "min-nya-app". */
26
+ export function suggestAppName(name) {
27
+ return name
28
+ .trim()
29
+ .normalize('NFKD')
30
+ .replaceAll(/[\u0300-\u036F]/g, '')
31
+ .replaceAll(/(?<=[\da-z])(?=[A-Z])/g, '-')
32
+ .toLowerCase()
33
+ .replaceAll(/\s+/g, '-')
34
+ .replaceAll(/[^\w\-.]/g, '')
35
+ .replace(/^[^\da-z]+/i, '');
36
+ }
37
+ /** What is wrong with a typed app name, said so the fix is obvious. */
38
+ export function appNameProblem(typed) {
39
+ const name = typed.trim();
40
+ if (!name)
41
+ return t('Type a name for the app, for example my-new-app.');
42
+ if (validAppName(name))
43
+ return undefined;
44
+ const suggestion = suggestAppName(name);
45
+ const tryThis = suggestion
46
+ ? ' ' + t('Try "{suggestion}".', { suggestion })
47
+ : '';
48
+ if (/\s/.test(name) && validAppName(name.replaceAll(/\s+/g, '-')))
49
+ return t("An app name can't have spaces.") + tryThis;
50
+ const bad = [...new Set(name.replaceAll(/[\s\w\-.]/g, ''))].join(' ');
51
+ if (bad) {
52
+ return (t("An app name can't contain {chars}: it becomes the folder, the package name and the app id.", {
53
+ chars: bad,
54
+ }) + tryThis);
55
+ }
56
+ return t('Start the name with a letter or a digit.') + tryThis;
57
+ }
58
+ /** A valid name that could be better; the user may keep it. */
59
+ export function appNameAdvice(typed) {
60
+ const name = typed.trim();
61
+ if (!validAppName(name) || name === name.toLowerCase())
62
+ return undefined;
63
+ return t('Lowercase is safer: npm expects it, and "{name}" and "{lower}" are one folder on macOS and Windows but two on Linux. Try "{suggestion}", or press Enter again to keep "{name}".', { name, lower: name.toLowerCase(), suggestion: suggestAppName(name) });
64
+ }
65
+ export const isEmptyOrMissing = (dir) => !fs.existsSync(dir) || fs.readdirSync(dir).length === 0;
66
+ /** Listen for the stub's questions. `null` from `ask` means the user cancelled. */
67
+ export async function servePrompts(ask, onCancel) {
68
+ const id = `svc-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
69
+ const socketPath = process.platform === 'win32'
70
+ ? `\\\\.\\pipe\\${id}`
71
+ : path.join(os.tmpdir(), `${id}.sock`);
72
+ let connected = false;
73
+ const server = net.createServer(socket => {
74
+ connected = true;
75
+ socket.on('error', () => { });
76
+ void (async () => {
77
+ for await (const line of readline.createInterface({ input: socket })) {
78
+ const reply = await ask(JSON.parse(line));
79
+ if (!reply) {
80
+ socket.destroy();
81
+ onCancel();
82
+ return;
83
+ }
84
+ socket.write(JSON.stringify(reply) + '\n');
85
+ }
86
+ })();
87
+ });
88
+ await new Promise((resolve, reject) => {
89
+ server.once('error', reject);
90
+ server.listen(socketPath, resolve);
91
+ });
92
+ return {
93
+ socketPath,
94
+ connected: () => connected,
95
+ close: () => server.close(),
96
+ };
97
+ }
98
+ /**
99
+ * Run the scaffolder for `name` inside `parentDir`. "unmanaged" means it never
100
+ * asked svc anything and left no app: the prompt bridge did not take.
101
+ */
102
+ export function startCreateApp(options) {
103
+ const { name, parentDir, ask, command = CREATE_COMMAND } = options;
104
+ const appDir = path.join(parentDir, name);
105
+ let runner;
106
+ let stopped = false;
107
+ const { task, log, finish } = createTask('create', { root: appDir, manifest: { name, id: name } }, 'create-sitevision-app', () => {
108
+ stopped = true;
109
+ runner?.kill();
110
+ finish('stopped');
111
+ });
112
+ const done = (async () => {
113
+ const prompts = await servePrompts(ask, () => task.stop());
114
+ try {
115
+ // .js from dist, .ts when svc itself runs from source.
116
+ const register = new URL(`../scaffold/register${path.extname(import.meta.url)}`, import.meta.url).href;
117
+ runner = new ProcessRunner(command[0], [...command.slice(1), name], parentDir, {
118
+ ...process.env,
119
+ NODE_OPTIONS: `${process.env['NODE_OPTIONS'] ?? ''} --import=${register}`.trim(),
120
+ SVC_PROMPT_SOCKET: prompts.socketPath,
121
+ });
122
+ runner.on('output', (output) => {
123
+ // The tool clears the screen between steps; that is noise in a log.
124
+ const text = stripVTControlCharacters(output.data);
125
+ if (text.trim())
126
+ log('create', text, output.type === 'stderr' ? 'warn' : 'info');
127
+ });
128
+ setPhase(task, 'scaffolding');
129
+ await runner.run();
130
+ if (stopped)
131
+ return 'stopped';
132
+ // The exit code is 0 even when the prompts crash, so look at the result.
133
+ if (readManifest(appDir)) {
134
+ finish('success');
135
+ return 'created';
136
+ }
137
+ if (prompts.connected()) {
138
+ finish('error', 'The scaffolder finished without creating an app');
139
+ return 'failed';
140
+ }
141
+ finish('stopped');
142
+ return 'unmanaged';
143
+ }
144
+ catch (error) {
145
+ finish('error', error instanceof Error ? error.message : String(error));
146
+ return 'failed';
147
+ }
148
+ finally {
149
+ prompts.close();
150
+ }
151
+ })();
152
+ return { task, done };
153
+ }
154
+ /** Run the scaffolder on the real terminal; resolves when it exits. */
155
+ export async function createAppInTerminal(name, parentDir) {
156
+ await new Promise(resolve => {
157
+ const child = spawn(CREATE_COMMAND[0], [...CREATE_COMMAND.slice(1), name], {
158
+ cwd: parentDir,
159
+ stdio: 'inherit',
160
+ shell: true,
161
+ });
162
+ child.on('close', () => resolve());
163
+ child.on('error', () => resolve());
164
+ });
165
+ return Boolean(readManifest(path.join(parentDir, name)));
166
+ }
167
+ /**
168
+ * Replace the template's placeholders with what is known, and fold the config
169
+ * the tool wrote into svc's layout: no plaintext password, and nothing in the
170
+ * app's .dev_properties.json that the workspace already provides.
171
+ */
172
+ export function seedNewApp(appDir, workspaceRoot) {
173
+ const found = readManifest(appDir);
174
+ if (!found)
175
+ return;
176
+ const { manifest, manifestPath } = found;
177
+ const seed = (key, value) => {
178
+ const localized = typeof manifest[key] === 'object';
179
+ writeManifestField(manifestPath, key, value, localized ? 'en' : undefined);
180
+ };
181
+ seed('name', path.basename(appDir));
182
+ if (workspaceRoot) {
183
+ let shared = {};
184
+ try {
185
+ shared = JSON.parse(fs.readFileSync(path.join(workspaceRoot, 'package.json'), 'utf8'));
186
+ }
187
+ catch { }
188
+ const author = typeof shared['author'] === 'object'
189
+ ? shared['author']?.name
190
+ : shared['author'];
191
+ if (typeof author === 'string' && author)
192
+ writeManifestField(manifestPath, 'author', author);
193
+ if (typeof shared['homepage'] === 'string' && shared['homepage'])
194
+ writeManifestField(manifestPath, 'helpUrl', shared['homepage']);
195
+ }
196
+ const project = detectProject(appDir);
197
+ if (project)
198
+ migrateLegacyPassword(project);
199
+ const file = findDevPropertiesPath(appDir);
200
+ if (!workspaceRoot || !file)
201
+ return;
202
+ const own = JSON.parse(fs.readFileSync(file, 'utf8'));
203
+ const inherited = readInheritedDevProperties(appDir);
204
+ if (typeof own['addonName'] === 'string' && own['addonName']) {
205
+ const addonName = own['addonName'];
206
+ updatePackageJson(appDir, packageJson => {
207
+ packageJson['addonName'] = addonName;
208
+ });
209
+ }
210
+ const rest = Object.fromEntries(Object.entries(own).filter(([key, value]) => key !== 'addonName' &&
211
+ value !== '' &&
212
+ value !== undefined &&
213
+ !(key === 'useHTTPForDevDeploy' && value === false) &&
214
+ JSON.stringify(value) !== JSON.stringify(inherited[key])));
215
+ if (Object.keys(rest).length === 0)
216
+ fs.rmSync(file);
217
+ else
218
+ fs.writeFileSync(file, JSON.stringify(rest, null, 2) + '\n');
219
+ }