sitevision-cli 1.0.0-beta.31 → 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
@@ -532,6 +720,8 @@ export function Shell({ apps: initialApps, workspaceRoot, version, minimal = fal
532
720
  base: rawProject.devProperties,
533
721
  environment: env,
534
722
  workspaceRoot,
723
+ manifest: project.manifest,
724
+ manifestPath: project.paths.manifest,
535
725
  }, active: formActive, width: narrow ? columns : columns - sidebar, height: contentHeight, pickAddon: pickAddon, onSaved: () => {
536
726
  reload();
537
727
  notify(t('config saved'), 'ok');
@@ -574,13 +764,21 @@ function renderOverlay(overlay, env) {
574
764
  overlay.resolve(answer);
575
765
  } }));
576
766
  case 'prompt':
577
- 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 => {
578
768
  closeOverlay();
579
769
  overlay.resolve(value);
580
770
  }, onCancel: () => {
581
771
  closeOverlay();
582
772
  overlay.resolve(null);
583
- } }));
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));
584
782
  case 'help':
585
783
  return (_jsx(HelpPanel, { here: here, where: where, height: height, onClose: closeOverlay }));
586
784
  case 'changelog':
@@ -606,17 +804,54 @@ function Confirm({ message, onAnswer, }) {
606
804
  });
607
805
  return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { children: message }), _jsx(Text, { dimColor: true, children: t('y confirm · n cancel') })] }));
608
806
  }
609
- function TextPrompt({ label, onSubmit, onCancel, }) {
610
- 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 : []));
611
810
  useInput((input, key) => {
612
811
  if (key.escape)
613
812
  onCancel();
614
813
  else if (key.return)
615
- 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
+ }
616
851
  else if (key.backspace || key.delete)
617
852
  setValue(v => v.slice(0, -1));
618
853
  else if (input && !key.ctrl && !key.meta)
619
854
  setValue(v => v + input);
620
855
  });
621
- 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') })] }));
622
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
+ }
@@ -202,6 +202,38 @@ const sv = {
202
202
  'required for signed deploys': 'krävs för signerad driftsättning',
203
203
  'blank = prompt each run': 'tomt = fråga varje gång',
204
204
  SIGNING: 'SIGNERING',
205
+ MANIFEST: 'MANIFEST',
206
+ // New app
207
+ 'Name of the new app': 'Namn på den nya appen',
208
+ 'Create it in folder': 'Skapa den i mappen',
209
+ 'Other folder…': 'Annan mapp…',
210
+ 'Type a name for the app, for example my-new-app.': 'Skriv ett namn på appen, till exempel min-nya-app.',
211
+ 'Try "{suggestion}".': 'Prova "{suggestion}".',
212
+ "An app name can't have spaces.": 'Ett appnamn kan inte innehålla mellanslag.',
213
+ "An app name can't contain {chars}: it becomes the folder, the package name and the app id.": 'Ett appnamn kan inte innehålla {chars}: det blir mappen, paketnamnet och appens id.',
214
+ '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}".': 'Gemener är säkrare: npm förväntar sig det, och "{name}" och "{lower}" är samma mapp på macOS och Windows men två på Linux. Prova "{suggestion}", eller tryck Enter igen för att behålla "{name}".',
215
+ 'Start the name with a letter or a digit.': 'Börja namnet med en bokstav eller en siffra.',
216
+ '{dir} already exists. Pick another name, or keep it and choose a different folder next.': '{dir} finns redan. Välj ett annat namn, eller behåll det och välj en annan mapp i nästa steg.',
217
+ 'creating {app}: installing, questions follow': 'skapar {app}: installerar, frågor kommer strax',
218
+ 'The scaffolder could not be run from here': 'Verktyget kunde inte köras härifrån',
219
+ 'creating {app} failed, see the log': 'kunde inte skapa {app}, se loggen',
220
+ '{app} created in {dir}': '{app} skapad i {dir}',
221
+ '{app} created': '{app} skapad',
222
+ 'Create the addon "{addon}" on {domain} now?': 'Skapa tillägget "{addon}" på {domain} nu?',
223
+ '↑↓ move · Space toggle · Enter submit · Esc cancel': '↑↓ flytta · Mellanslag markera · Enter skicka · Esc avbryt',
224
+ 'App id': 'App-id',
225
+ Version: 'Version',
226
+ Name: 'Namn',
227
+ Description: 'Beskrivning',
228
+ Author: 'Författare',
229
+ 'Help URL': 'Hjälp-URL',
230
+ 'Not saved: {label} is required.': 'Inte sparat: {label} krävs.',
231
+ 'Identifier of the app in Sitevision; it also names the zip. Changing it makes the next deploy a new app instead of an update.': 'Appens identifierare i Sitevision; den namnger också zip-filen. Ändras den blir nästa driftsättning en ny app i stället för en uppdatering.',
232
+ 'Version of the app. Id and version together identify an upload, so bump it to deploy a new version instead of overwriting the current one.': 'Appens version. Id och version identifierar tillsammans en uppladdning, så höj den för att driftsätta en ny version i stället för att skriva över den nuvarande.',
233
+ 'Name shown when importing and administering the app in the Sitevision editor. A multilingual manifest needs at least English.': 'Namnet som visas när appen importeras och administreras i Sitevisions redigerare. Ett flerspråkigt manifest behöver minst engelska.',
234
+ 'Short description shown next to the name in the Sitevision editor.': 'Kort beskrivning som visas bredvid namnet i Sitevisions redigerare.',
235
+ 'Who made the app, shown in the Sitevision editor.': 'Vem som gjort appen, visas i Sitevisions redigerare.',
236
+ "Link to the app's documentation, shown in the Sitevision editor.": 'Länk till appens dokumentation, visas i Sitevisions redigerare.',
205
237
  ENVIRONMENT: 'MILJÖ',
206
238
  'Environment name': 'Miljönamn',
207
239
  Production: 'Produktion',