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.
- package/CHANGELOG.md +28 -0
- package/dist/cli.js +20 -2
- package/dist/scaffold/inquirer-stub.d.ts +6 -0
- package/dist/scaffold/inquirer-stub.js +119 -0
- package/dist/scaffold/register.d.ts +1 -0
- package/dist/scaffold/register.js +15 -0
- package/dist/shell/ConfigForm.d.ts +7 -1
- package/dist/shell/ConfigForm.js +95 -11
- package/dist/shell/Help.d.ts +4 -1
- package/dist/shell/Help.js +71 -7
- package/dist/shell/Shell.d.ts +2 -1
- package/dist/shell/Shell.js +249 -12
- package/dist/shell/actions.d.ts +1 -0
- package/dist/shell/actions.js +0 -0
- package/dist/utils/create-app.d.ts +53 -0
- package/dist/utils/create-app.js +219 -0
- package/dist/utils/i18n.js +46 -0
- package/dist/utils/process-runner.d.ts +2 -1
- package/dist/utils/process-runner.js +4 -1
- package/dist/utils/project-detection.d.ts +10 -2
- package/dist/utils/project-detection.js +88 -11
- package/dist/utils/tasks.d.ts +12 -1
- package/dist/utils/tasks.js +2 -2
- package/package.json +1 -1
package/dist/shell/Shell.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
608
|
-
const [
|
|
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(
|
|
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
|
}
|
package/dist/shell/actions.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/shell/actions.js
CHANGED
|
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
|
+
}
|