create-ortha-app 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +277 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/lib/conditionals.d.ts +37 -0
- package/dist/lib/conditionals.d.ts.map +1 -0
- package/dist/lib/conditionals.js +115 -0
- package/dist/lib/features.d.ts +185 -0
- package/dist/lib/features.d.ts.map +1 -0
- package/dist/lib/features.js +328 -0
- package/dist/lib/template.d.ts +46 -0
- package/dist/lib/template.d.ts.map +1 -0
- package/dist/lib/template.js +115 -0
- package/dist/lib/ui.d.ts +76 -0
- package/dist/lib/ui.d.ts.map +1 -0
- package/dist/lib/ui.js +310 -0
- package/dist/lib/validate.d.ts +13 -0
- package/dist/lib/validate.d.ts.map +1 -0
- package/dist/lib/validate.js +51 -0
- package/package.json +37 -0
- package/templates/default/README.md.tmpl +199 -0
- package/templates/default/_gitignore +8 -0
- package/templates/default/apps/admin/index.html +38 -0
- package/templates/default/apps/admin/src/main.tsx +5 -0
- package/templates/default/apps/admin/src/plugins.spec.ts +58 -0
- package/templates/default/apps/admin/src/plugins.ts +57 -0
- package/templates/default/apps/admin/src/styles.css +254 -0
- package/templates/default/apps/admin/tsconfig.json +29 -0
- package/templates/default/apps/admin/vite.config.mts +67 -0
- package/templates/default/apps/admin-e2e/playwright.config.ts +51 -0
- package/templates/default/apps/admin-e2e/src/auth.spec.ts +55 -0
- package/templates/default/apps/admin-e2e/src/support/seed.ts +62 -0
- package/templates/default/apps/admin-e2e/tsconfig.json +23 -0
- package/templates/default/apps/server/jest.config.js +38 -0
- package/templates/default/apps/server/jest.setup.js +20 -0
- package/templates/default/apps/server/ortha.config.ts +505 -0
- package/templates/default/apps/server/src/main.ts +26 -0
- package/templates/default/apps/server/src/plugins.spec.ts +71 -0
- package/templates/default/apps/server/src/plugins.ts +229 -0
- package/templates/default/apps/server/tsconfig.json +31 -0
- package/templates/default/apps/server-e2e/jest.config.js +47 -0
- package/templates/default/apps/server-e2e/src/api.spec.ts +107 -0
- package/templates/default/apps/server-e2e/src/global-setup.ts +41 -0
- package/templates/default/apps/server-e2e/src/jest.setup.ts +28 -0
- package/templates/default/apps/server-e2e/src/support/db.ts +143 -0
- package/templates/default/apps/server-e2e/src/support/test-app.ts +64 -0
- package/templates/default/apps/server-e2e/tsconfig.json +26 -0
- package/templates/default/docker-compose.yml +21 -0
- package/templates/default/env.tmpl +140 -0
- package/templates/default/package.json.tmpl +51 -0
- package/templates/default/tsconfig.json +18 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.render = render;
|
|
4
|
+
exports.renderManifest = renderManifest;
|
|
5
|
+
exports.renderTemplate = renderTemplate;
|
|
6
|
+
exports.isNonEmptyDirectory = isNonEmptyDirectory;
|
|
7
|
+
const node_fs_1 = require("node:fs");
|
|
8
|
+
const node_path_1 = require("node:path");
|
|
9
|
+
const conditionals_1 = require("./conditionals");
|
|
10
|
+
const features_1 = require("./features");
|
|
11
|
+
/**
|
|
12
|
+
* Files renamed on their way out of the template.
|
|
13
|
+
*
|
|
14
|
+
* `.gitignore` is the one that matters: **npm refuses to publish a file by that
|
|
15
|
+
* name**, silently, so a template carrying one ships without it and every
|
|
16
|
+
* generated app starts by offering to commit `node_modules`. Storing it as
|
|
17
|
+
* `_gitignore` is the standard workaround (create-vite and create-next-app do
|
|
18
|
+
* the same). The `.tmpl` suffixes are a smaller thing — they keep a
|
|
19
|
+
* `package.json` out of the workspace globs that would otherwise read the
|
|
20
|
+
* template directory as a package of its own.
|
|
21
|
+
*/
|
|
22
|
+
const RENAMES = {
|
|
23
|
+
_gitignore: '.gitignore',
|
|
24
|
+
'env.tmpl': '.env',
|
|
25
|
+
'package.json.tmpl': 'package.json',
|
|
26
|
+
'README.md.tmpl': 'README.md'
|
|
27
|
+
};
|
|
28
|
+
/** Substitutes every `__PLACEHOLDER__` in `contents`. */
|
|
29
|
+
function render(contents, values) {
|
|
30
|
+
const replacements = {
|
|
31
|
+
__APP_NAME__: values.appName,
|
|
32
|
+
__APP_TITLE__: values.appTitle,
|
|
33
|
+
__DATABASE_URL__: values.databaseUrl,
|
|
34
|
+
__DATABASE_NAME__: values.databaseName,
|
|
35
|
+
__ADMIN_EMAIL__: values.adminEmail,
|
|
36
|
+
__ADMIN_PASSWORD__: values.adminPassword,
|
|
37
|
+
__ORTHA_VERSION__: values.orthaVersion
|
|
38
|
+
};
|
|
39
|
+
return Object.entries(replacements).reduce((text, [token, value]) => text.split(token).join(value), contents);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Renders `package.json` for a selection.
|
|
43
|
+
*
|
|
44
|
+
* The dependency map is **rebuilt**, not patched: the template ships a manifest
|
|
45
|
+
* with the non-Ortha dependencies and an empty `@orthacms` set, and the chosen
|
|
46
|
+
* packages are merged in and re-sorted here. Every `@orthacms/*` range is the
|
|
47
|
+
* scaffolder's own version, exactly — no caret. Releases are lockstep, and a
|
|
48
|
+
* partial upgrade can leave two copies of a shared package in `node_modules`,
|
|
49
|
+
* which means two React context instances and an admin whose sidebar silently
|
|
50
|
+
* stops talking to its provider.
|
|
51
|
+
*/
|
|
52
|
+
function renderManifest(template, values) {
|
|
53
|
+
const manifest = JSON.parse(render(template, values));
|
|
54
|
+
const pin = (names) => Object.fromEntries(names.map((name) => [name, values.orthaVersion]));
|
|
55
|
+
manifest.dependencies = sortKeys({
|
|
56
|
+
...manifest.dependencies,
|
|
57
|
+
...pin((0, features_1.resolvePackages)(values.selection))
|
|
58
|
+
});
|
|
59
|
+
manifest.devDependencies = sortKeys({
|
|
60
|
+
...manifest.devDependencies,
|
|
61
|
+
...pin((0, features_1.resolveDevPackages)())
|
|
62
|
+
});
|
|
63
|
+
return `${JSON.stringify(manifest, null, 4)}\n`;
|
|
64
|
+
}
|
|
65
|
+
/** A copy of `record` with its keys in sorted order. */
|
|
66
|
+
function sortKeys(record) {
|
|
67
|
+
return Object.fromEntries(Object.entries(record).sort(([a], [b]) => a.localeCompare(b)));
|
|
68
|
+
}
|
|
69
|
+
/** File extensions rendered as text; everything else is copied byte for byte. */
|
|
70
|
+
const TEXT = /\.(ts|tsx|css|html|json|md|yml|yaml|tmpl|mjs|cjs|js)$|^_gitignore$/;
|
|
71
|
+
/** Every file under `dir`, recursively, as paths relative to it. */
|
|
72
|
+
function filesIn(dir, base = dir) {
|
|
73
|
+
return (0, node_fs_1.readdirSync)(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
74
|
+
const path = (0, node_path_1.join)(dir, entry.name);
|
|
75
|
+
return entry.isDirectory()
|
|
76
|
+
? filesIn(path, base)
|
|
77
|
+
: [(0, node_path_1.relative)(base, path)];
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Copies the template into `target`: conditional blocks applied, placeholders
|
|
82
|
+
* substituted, renames performed.
|
|
83
|
+
*
|
|
84
|
+
* Binary files are copied verbatim — a distinction worth keeping even though
|
|
85
|
+
* today's template is all text, since running a favicon through a string
|
|
86
|
+
* replace corrupts it in a way that only shows up in a browser.
|
|
87
|
+
*/
|
|
88
|
+
function renderTemplate(templateDir, target, values) {
|
|
89
|
+
const flags = (0, features_1.resolveFlags)(values.selection);
|
|
90
|
+
for (const file of filesIn(templateDir)) {
|
|
91
|
+
const source = (0, node_path_1.join)(templateDir, file);
|
|
92
|
+
const name = file.split('/').pop() ?? file;
|
|
93
|
+
const renamed = RENAMES[name];
|
|
94
|
+
const destination = (0, node_path_1.join)(target, renamed ? (0, node_path_1.join)(file, '..', renamed) : file);
|
|
95
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.join)(destination, '..'), { recursive: true });
|
|
96
|
+
if (!TEXT.test(name)) {
|
|
97
|
+
(0, node_fs_1.cpSync)(source, destination);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const raw = (0, node_fs_1.readFileSync)(source, 'utf8');
|
|
101
|
+
// The manifest takes the other path: its dependency set is assembled,
|
|
102
|
+
// not conditionally line-edited. See `renderManifest`.
|
|
103
|
+
if (name === 'package.json.tmpl') {
|
|
104
|
+
(0, node_fs_1.writeFileSync)(destination, renderManifest(raw, values));
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
(0, node_fs_1.writeFileSync)(destination, render((0, conditionals_1.applyConditionals)(raw, flags, file), values));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Whether `dir` exists and holds anything. */
|
|
111
|
+
function isNonEmptyDirectory(dir) {
|
|
112
|
+
return ((0, node_fs_1.existsSync)(dir) &&
|
|
113
|
+
(0, node_fs_1.statSync)(dir).isDirectory() &&
|
|
114
|
+
(0, node_fs_1.readdirSync)(dir).length > 0);
|
|
115
|
+
}
|
package/dist/lib/ui.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small terminal UI — boxes, colour, and keyboard pickers.
|
|
3
|
+
*
|
|
4
|
+
* Hand-rolled rather than pulled from `prompts`/`clack`/`chalk`, because this
|
|
5
|
+
* package is what `npx` downloads before anything else exists: every dependency
|
|
6
|
+
* is weight on the very first thing a new user waits for, and a scaffolder that
|
|
7
|
+
* needs a dependency tree to ask four questions is not a good first impression.
|
|
8
|
+
* It is also the one package here with no runtime dependencies at all, which is
|
|
9
|
+
* worth keeping.
|
|
10
|
+
*
|
|
11
|
+
* Everything degrades. No TTY, `NO_COLOR`, a dumb terminal, `--yes` — the
|
|
12
|
+
* pickers never run and the defaults are used, so this behaves the same in CI
|
|
13
|
+
* as in a terminal.
|
|
14
|
+
*/
|
|
15
|
+
export declare const bold: (text: string) => string;
|
|
16
|
+
export declare const dim: (text: string) => string;
|
|
17
|
+
export declare const red: (text: string) => string;
|
|
18
|
+
export declare const green: (text: string) => string;
|
|
19
|
+
export declare const yellow: (text: string) => string;
|
|
20
|
+
export declare const cyan: (text: string) => string;
|
|
21
|
+
export declare const magenta: (text: string) => string;
|
|
22
|
+
/** Visible width of `text`, ignoring the ANSI escapes inside it. */
|
|
23
|
+
export declare function width(text: string): number;
|
|
24
|
+
/** Prints a boxed banner. */
|
|
25
|
+
export declare function banner(title: string, subtitle: string): void;
|
|
26
|
+
/** Prints a section heading. */
|
|
27
|
+
export declare function section(title: string): void;
|
|
28
|
+
/** Prints an indented note. */
|
|
29
|
+
export declare function note(text: string): void;
|
|
30
|
+
/** Prints a success line. */
|
|
31
|
+
export declare function success(text: string): void;
|
|
32
|
+
/** Prints a warning line. */
|
|
33
|
+
export declare function warn(text: string): void;
|
|
34
|
+
/** Prints an error line to stderr. */
|
|
35
|
+
export declare function error(text: string): void;
|
|
36
|
+
/** Prints a boxed summary of label/value rows. */
|
|
37
|
+
export declare function summary(title: string, rows: readonly (readonly [string, string])[]): void;
|
|
38
|
+
/** Prints the numbered next steps. */
|
|
39
|
+
export declare function nextSteps(steps: readonly string[]): void;
|
|
40
|
+
/** One option in a picker. */
|
|
41
|
+
export interface Choice {
|
|
42
|
+
/** Value returned when picked. */
|
|
43
|
+
value: string;
|
|
44
|
+
/** Shown on the row. */
|
|
45
|
+
label: string;
|
|
46
|
+
/** Shown under the label, dimmed. */
|
|
47
|
+
hint?: string;
|
|
48
|
+
/** Starts ticked (multi-select) or focused (single-select). */
|
|
49
|
+
selected?: boolean;
|
|
50
|
+
/** Rendered greyed out and skipped by the cursor. */
|
|
51
|
+
disabled?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Always in the result, shown ticked but unselectable.
|
|
54
|
+
*
|
|
55
|
+
* Distinct from `disabled`, which means "cannot be had". A locked row is
|
|
56
|
+
* something you *do* get and cannot decline — REST, in the protocols
|
|
57
|
+
* question — and showing it keeps the answer readable as a complete set
|
|
58
|
+
* rather than a list of extras.
|
|
59
|
+
*/
|
|
60
|
+
locked?: boolean;
|
|
61
|
+
}
|
|
62
|
+
/** Whether the pickers can run at all. */
|
|
63
|
+
export declare function interactive(): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* A single-choice picker. Returns the chosen value, or `undefined` if the user
|
|
66
|
+
* cancelled with Escape or Ctrl-C.
|
|
67
|
+
*/
|
|
68
|
+
export declare function select(label: string, choices: readonly Choice[]): Promise<string | undefined>;
|
|
69
|
+
/**
|
|
70
|
+
* A checkbox picker. Returns the ticked values — an empty array is a valid
|
|
71
|
+
* answer, and for the copilot providers it is the meaningful default.
|
|
72
|
+
*
|
|
73
|
+
* `undefined` means cancelled.
|
|
74
|
+
*/
|
|
75
|
+
export declare function multiselect(label: string, choices: readonly Choice[]): Promise<string[] | undefined>;
|
|
76
|
+
//# sourceMappingURL=ui.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ui.d.ts","sourceRoot":"","sources":["../../src/lib/ui.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAiBH,eAAO,MAAM,IAAI,SAJC,MAAM,KAAG,MAIG,CAAC;AAC/B,eAAO,MAAM,GAAG,SALE,MAAM,KAAG,MAKE,CAAC;AAC9B,eAAO,MAAM,GAAG,SANE,MAAM,KAAG,MAMG,CAAC;AAC/B,eAAO,MAAM,KAAK,SAPA,MAAM,KAAG,MAOK,CAAC;AACjC,eAAO,MAAM,MAAM,SARD,MAAM,KAAG,MAQM,CAAC;AAClC,eAAO,MAAM,IAAI,SATC,MAAM,KAAG,MASI,CAAC;AAChC,eAAO,MAAM,OAAO,SAVF,MAAM,KAAG,MAUO,CAAC;AAEnC,oEAAoE;AACpE,wBAAgB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1C;AA+BD,6BAA6B;AAC7B,wBAAgB,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAW5D;AAED,gCAAgC;AAChC,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAG3C;AAED,+BAA+B;AAC/B,wBAAgB,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAEvC;AAED,6BAA6B;AAC7B,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAE1C;AAED,6BAA6B;AAC7B,wBAAgB,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAEvC;AAED,sCAAsC;AACtC,wBAAgB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAExC;AAED,kDAAkD;AAClD,wBAAgB,OAAO,CACnB,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,SAAS,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,EAAE,GAC7C,IAAI,CAaN;AAED,sCAAsC;AACtC,wBAAgB,SAAS,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAOxD;AAED,8BAA8B;AAC9B,MAAM,WAAW,MAAM;IACnB,kCAAkC;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,wBAAwB;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,qCAAqC;IACrC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,qDAAqD;IACrD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,0CAA0C;AAC1C,wBAAgB,WAAW,IAAI,OAAO,CAErC;AA8DD;;;GAGG;AACH,wBAAsB,MAAM,CACxB,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,SAAS,MAAM,EAAE,GAC3B,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAgD7B;AAED;;;;;GAKG;AACH,wBAAsB,WAAW,CAC7B,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,SAAS,MAAM,EAAE,GAC3B,OAAO,CAAC,MAAM,EAAE,GAAG,SAAS,CAAC,CAuE/B"}
|
package/dist/lib/ui.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* A small terminal UI — boxes, colour, and keyboard pickers.
|
|
4
|
+
*
|
|
5
|
+
* Hand-rolled rather than pulled from `prompts`/`clack`/`chalk`, because this
|
|
6
|
+
* package is what `npx` downloads before anything else exists: every dependency
|
|
7
|
+
* is weight on the very first thing a new user waits for, and a scaffolder that
|
|
8
|
+
* needs a dependency tree to ask four questions is not a good first impression.
|
|
9
|
+
* It is also the one package here with no runtime dependencies at all, which is
|
|
10
|
+
* worth keeping.
|
|
11
|
+
*
|
|
12
|
+
* Everything degrades. No TTY, `NO_COLOR`, a dumb terminal, `--yes` — the
|
|
13
|
+
* pickers never run and the defaults are used, so this behaves the same in CI
|
|
14
|
+
* as in a terminal.
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.magenta = exports.cyan = exports.yellow = exports.green = exports.red = exports.dim = exports.bold = void 0;
|
|
18
|
+
exports.width = width;
|
|
19
|
+
exports.banner = banner;
|
|
20
|
+
exports.section = section;
|
|
21
|
+
exports.note = note;
|
|
22
|
+
exports.success = success;
|
|
23
|
+
exports.warn = warn;
|
|
24
|
+
exports.error = error;
|
|
25
|
+
exports.summary = summary;
|
|
26
|
+
exports.nextSteps = nextSteps;
|
|
27
|
+
exports.interactive = interactive;
|
|
28
|
+
exports.select = select;
|
|
29
|
+
exports.multiselect = multiselect;
|
|
30
|
+
const ESC = '\u001b';
|
|
31
|
+
const CSI = `${ESC}[`;
|
|
32
|
+
/** Whether ANSI colour should be emitted at all. */
|
|
33
|
+
const COLOR = process.stdout.isTTY === true &&
|
|
34
|
+
!process.env['NO_COLOR'] &&
|
|
35
|
+
process.env['TERM'] !== 'dumb';
|
|
36
|
+
/** Wraps `text` in an SGR pair, or returns it untouched when colour is off. */
|
|
37
|
+
function sgr(open, close) {
|
|
38
|
+
return (text) => COLOR ? `${CSI}${open}m${text}${CSI}${close}m` : text;
|
|
39
|
+
}
|
|
40
|
+
exports.bold = sgr(1, 22);
|
|
41
|
+
exports.dim = sgr(2, 22);
|
|
42
|
+
exports.red = sgr(31, 39);
|
|
43
|
+
exports.green = sgr(32, 39);
|
|
44
|
+
exports.yellow = sgr(33, 39);
|
|
45
|
+
exports.cyan = sgr(36, 39);
|
|
46
|
+
exports.magenta = sgr(35, 39);
|
|
47
|
+
/** Visible width of `text`, ignoring the ANSI escapes inside it. */
|
|
48
|
+
function width(text) {
|
|
49
|
+
return text.replace(new RegExp(`${ESC}\\[[0-9;]*m`, 'g'), '').length;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Clips `text` to `size` visible columns.
|
|
53
|
+
*
|
|
54
|
+
* `keep: 'end'` drops characters from the front, which is what a long path
|
|
55
|
+
* wants — the tail (`…/scratchpad/v2/minimal`) is the part that identifies it,
|
|
56
|
+
* and clipping the other way leaves every line reading the same.
|
|
57
|
+
*
|
|
58
|
+
* Only ever called on plain text. Clipping a coloured string could cut an ANSI
|
|
59
|
+
* escape in half and leak the rest of the line's colour into the box border.
|
|
60
|
+
*/
|
|
61
|
+
function truncate(text, size, keep) {
|
|
62
|
+
if (text.length <= size)
|
|
63
|
+
return text;
|
|
64
|
+
if (size <= 1)
|
|
65
|
+
return '…';
|
|
66
|
+
return keep === 'end'
|
|
67
|
+
? `…${text.slice(text.length - (size - 1))}`
|
|
68
|
+
: `${text.slice(0, size - 1)}…`;
|
|
69
|
+
}
|
|
70
|
+
/** Pads `text` to exactly `size` visible columns, clipping if it overflows. */
|
|
71
|
+
function pad(text, size) {
|
|
72
|
+
const visible = width(text);
|
|
73
|
+
if (visible > size)
|
|
74
|
+
return truncate(text, size, 'start');
|
|
75
|
+
return text + ' '.repeat(Math.max(0, size - visible));
|
|
76
|
+
}
|
|
77
|
+
const BOX = 66;
|
|
78
|
+
/** Prints a boxed banner. */
|
|
79
|
+
function banner(title, subtitle) {
|
|
80
|
+
const inner = BOX - 2;
|
|
81
|
+
console.log('');
|
|
82
|
+
console.log((0, exports.magenta)(`╭${'─'.repeat(inner)}╮`));
|
|
83
|
+
console.log(`${(0, exports.magenta)('│')} ${pad((0, exports.bold)(title), inner - 2)} ${(0, exports.magenta)('│')}`);
|
|
84
|
+
console.log(`${(0, exports.magenta)('│')} ${pad((0, exports.dim)(truncate(subtitle, inner - 2, 'end')), inner - 2)} ${(0, exports.magenta)('│')}`);
|
|
85
|
+
console.log((0, exports.magenta)(`╰${'─'.repeat(inner)}╯`));
|
|
86
|
+
}
|
|
87
|
+
/** Prints a section heading. */
|
|
88
|
+
function section(title) {
|
|
89
|
+
console.log('');
|
|
90
|
+
console.log(`${(0, exports.cyan)('◆')} ${(0, exports.bold)(title)}`);
|
|
91
|
+
}
|
|
92
|
+
/** Prints an indented note. */
|
|
93
|
+
function note(text) {
|
|
94
|
+
console.log(` ${(0, exports.dim)(text)}`);
|
|
95
|
+
}
|
|
96
|
+
/** Prints a success line. */
|
|
97
|
+
function success(text) {
|
|
98
|
+
console.log(`${(0, exports.green)('✔')} ${text}`);
|
|
99
|
+
}
|
|
100
|
+
/** Prints a warning line. */
|
|
101
|
+
function warn(text) {
|
|
102
|
+
console.log(`${(0, exports.yellow)('!')} ${text}`);
|
|
103
|
+
}
|
|
104
|
+
/** Prints an error line to stderr. */
|
|
105
|
+
function error(text) {
|
|
106
|
+
console.error(`${(0, exports.red)('✖')} ${text}`);
|
|
107
|
+
}
|
|
108
|
+
/** Prints a boxed summary of label/value rows. */
|
|
109
|
+
function summary(title, rows) {
|
|
110
|
+
const inner = BOX - 2;
|
|
111
|
+
const rule = '─'.repeat(Math.max(0, inner - width(title) - 3));
|
|
112
|
+
console.log('');
|
|
113
|
+
console.log((0, exports.dim)(`╭─ ${title} ${rule}╮`));
|
|
114
|
+
for (const [label, value] of rows) {
|
|
115
|
+
const room = inner - 2 - 21;
|
|
116
|
+
const clipped = width(value) > room ? truncate(value, room, 'start') : value;
|
|
117
|
+
const line = `${pad((0, exports.dim)(label), 20)} ${clipped}`;
|
|
118
|
+
console.log(`${(0, exports.dim)('│')} ${pad(line, inner - 2)} ${(0, exports.dim)('│')}`);
|
|
119
|
+
}
|
|
120
|
+
console.log((0, exports.dim)(`╰${'─'.repeat(inner)}╯`));
|
|
121
|
+
}
|
|
122
|
+
/** Prints the numbered next steps. */
|
|
123
|
+
function nextSteps(steps) {
|
|
124
|
+
console.log('');
|
|
125
|
+
console.log((0, exports.bold)('Next steps'));
|
|
126
|
+
for (const step of steps) {
|
|
127
|
+
console.log(` ${(0, exports.cyan)('›')} ${step}`);
|
|
128
|
+
}
|
|
129
|
+
console.log('');
|
|
130
|
+
}
|
|
131
|
+
/** Whether the pickers can run at all. */
|
|
132
|
+
function interactive() {
|
|
133
|
+
return process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Reads single keypresses until `handle` says it is done.
|
|
137
|
+
*
|
|
138
|
+
* Raw mode is what makes arrow keys and space work without Enter. The `finally`
|
|
139
|
+
* restores it, because a process that exits while still in raw mode leaves the
|
|
140
|
+
* user's terminal with no echo, and they have to type `reset` blind to get it
|
|
141
|
+
* back.
|
|
142
|
+
*/
|
|
143
|
+
async function readKeys(handle) {
|
|
144
|
+
const { stdin } = process;
|
|
145
|
+
const wasRaw = stdin.isRaw;
|
|
146
|
+
stdin.setRawMode(true);
|
|
147
|
+
stdin.resume();
|
|
148
|
+
stdin.setEncoding('utf8');
|
|
149
|
+
try {
|
|
150
|
+
for await (const chunk of stdin) {
|
|
151
|
+
const outcome = handle(String(chunk));
|
|
152
|
+
if (outcome !== 'continue')
|
|
153
|
+
return outcome;
|
|
154
|
+
}
|
|
155
|
+
return 'cancel';
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
stdin.setRawMode(wasRaw === true);
|
|
159
|
+
stdin.pause();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const UP = [`${CSI}A`, 'k'];
|
|
163
|
+
const DOWN = [`${CSI}B`, 'j'];
|
|
164
|
+
const ENTER = ['\r', '\n'];
|
|
165
|
+
/** Ctrl-C and Escape. */
|
|
166
|
+
const CANCEL = ['\u0003', ESC];
|
|
167
|
+
/** Moves `cursor` by `step`, skipping disabled rows and wrapping. */
|
|
168
|
+
function advance(choices, cursor, step) {
|
|
169
|
+
let next = cursor;
|
|
170
|
+
for (let i = 0; i < choices.length; i += 1) {
|
|
171
|
+
next = (next + step + choices.length) % choices.length;
|
|
172
|
+
const choice = choices[next];
|
|
173
|
+
if (choice && !choice.disabled && !choice.locked)
|
|
174
|
+
return next;
|
|
175
|
+
}
|
|
176
|
+
return cursor;
|
|
177
|
+
}
|
|
178
|
+
/** Rewrites the picker in place, returning how many lines it painted. */
|
|
179
|
+
function repaint(lines, previous) {
|
|
180
|
+
if (previous > 0)
|
|
181
|
+
process.stdout.write(`${CSI}${previous}A`);
|
|
182
|
+
for (const line of lines) {
|
|
183
|
+
process.stdout.write(`${CSI}2K${line}\n`);
|
|
184
|
+
}
|
|
185
|
+
return lines.length;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* A single-choice picker. Returns the chosen value, or `undefined` if the user
|
|
189
|
+
* cancelled with Escape or Ctrl-C.
|
|
190
|
+
*/
|
|
191
|
+
async function select(label, choices) {
|
|
192
|
+
let cursor = choices.findIndex((choice) => !choice.disabled);
|
|
193
|
+
if (cursor === -1)
|
|
194
|
+
return undefined;
|
|
195
|
+
const initial = choices.findIndex((choice) => choice.selected);
|
|
196
|
+
if (initial !== -1 && !choices[initial]?.disabled)
|
|
197
|
+
cursor = initial;
|
|
198
|
+
let painted = 0;
|
|
199
|
+
const paint = () => {
|
|
200
|
+
painted = repaint([
|
|
201
|
+
`${(0, exports.cyan)('◆')} ${(0, exports.bold)(label)}`,
|
|
202
|
+
...choices.flatMap((choice, index) => {
|
|
203
|
+
const focused = index === cursor;
|
|
204
|
+
const marker = choice.disabled
|
|
205
|
+
? (0, exports.dim)('○')
|
|
206
|
+
: focused
|
|
207
|
+
? (0, exports.green)('●')
|
|
208
|
+
: '○';
|
|
209
|
+
const text = choice.disabled
|
|
210
|
+
? (0, exports.dim)(`${choice.label} (unavailable)`)
|
|
211
|
+
: focused
|
|
212
|
+
? (0, exports.bold)(choice.label)
|
|
213
|
+
: choice.label;
|
|
214
|
+
return [
|
|
215
|
+
` ${marker} ${text}`,
|
|
216
|
+
...(choice.hint ? [` ${(0, exports.dim)(choice.hint)}`] : [])
|
|
217
|
+
];
|
|
218
|
+
}),
|
|
219
|
+
(0, exports.dim)(' ↑↓ move · enter select')
|
|
220
|
+
], painted);
|
|
221
|
+
};
|
|
222
|
+
paint();
|
|
223
|
+
const outcome = await readKeys((key) => {
|
|
224
|
+
if (CANCEL.includes(key))
|
|
225
|
+
return 'cancel';
|
|
226
|
+
if (ENTER.includes(key))
|
|
227
|
+
return 'done';
|
|
228
|
+
if (UP.includes(key))
|
|
229
|
+
cursor = advance(choices, cursor, -1);
|
|
230
|
+
else if (DOWN.includes(key))
|
|
231
|
+
cursor = advance(choices, cursor, 1);
|
|
232
|
+
else
|
|
233
|
+
return 'continue';
|
|
234
|
+
paint();
|
|
235
|
+
return 'continue';
|
|
236
|
+
});
|
|
237
|
+
return outcome === 'done' ? choices[cursor]?.value : undefined;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* A checkbox picker. Returns the ticked values — an empty array is a valid
|
|
241
|
+
* answer, and for the copilot providers it is the meaningful default.
|
|
242
|
+
*
|
|
243
|
+
* `undefined` means cancelled.
|
|
244
|
+
*/
|
|
245
|
+
async function multiselect(label, choices) {
|
|
246
|
+
const ticked = new Set(choices
|
|
247
|
+
.filter((choice) => (choice.selected || choice.locked) && !choice.disabled)
|
|
248
|
+
.map((choice) => choice.value));
|
|
249
|
+
let cursor = choices.findIndex((choice) => !choice.disabled && !choice.locked);
|
|
250
|
+
// Every row is locked or unavailable: there is nothing to ask, so answer
|
|
251
|
+
// with what is already ticked rather than painting a picker nobody can move.
|
|
252
|
+
if (cursor === -1)
|
|
253
|
+
return [...ticked];
|
|
254
|
+
let painted = 0;
|
|
255
|
+
const paint = () => {
|
|
256
|
+
painted = repaint([
|
|
257
|
+
`${(0, exports.cyan)('◆')} ${(0, exports.bold)(label)}`,
|
|
258
|
+
...choices.flatMap((choice, index) => {
|
|
259
|
+
const focused = index === cursor;
|
|
260
|
+
const box = choice.disabled
|
|
261
|
+
? (0, exports.dim)('[ ]')
|
|
262
|
+
: choice.locked
|
|
263
|
+
? (0, exports.dim)('[x]')
|
|
264
|
+
: ticked.has(choice.value)
|
|
265
|
+
? (0, exports.green)('[x]')
|
|
266
|
+
: '[ ]';
|
|
267
|
+
const text = choice.disabled
|
|
268
|
+
? (0, exports.dim)(`${choice.label} (unavailable)`)
|
|
269
|
+
: choice.locked
|
|
270
|
+
? (0, exports.dim)(`${choice.label} (always on)`)
|
|
271
|
+
: focused
|
|
272
|
+
? (0, exports.bold)(choice.label)
|
|
273
|
+
: choice.label;
|
|
274
|
+
const pointer = focused && !choice.disabled && !choice.locked
|
|
275
|
+
? (0, exports.cyan)('›')
|
|
276
|
+
: ' ';
|
|
277
|
+
return [
|
|
278
|
+
` ${pointer} ${box} ${text}`,
|
|
279
|
+
...(choice.hint ? [` ${(0, exports.dim)(choice.hint)}`] : [])
|
|
280
|
+
];
|
|
281
|
+
}),
|
|
282
|
+
(0, exports.dim)(' ↑↓ move · space toggle · enter confirm · none is fine')
|
|
283
|
+
], painted);
|
|
284
|
+
};
|
|
285
|
+
paint();
|
|
286
|
+
const outcome = await readKeys((key) => {
|
|
287
|
+
if (CANCEL.includes(key))
|
|
288
|
+
return 'cancel';
|
|
289
|
+
if (ENTER.includes(key))
|
|
290
|
+
return 'done';
|
|
291
|
+
if (UP.includes(key))
|
|
292
|
+
cursor = advance(choices, cursor, -1);
|
|
293
|
+
else if (DOWN.includes(key))
|
|
294
|
+
cursor = advance(choices, cursor, 1);
|
|
295
|
+
else if (key === ' ') {
|
|
296
|
+
const choice = choices[cursor];
|
|
297
|
+
if (choice && !choice.disabled && !choice.locked) {
|
|
298
|
+
if (ticked.has(choice.value))
|
|
299
|
+
ticked.delete(choice.value);
|
|
300
|
+
else
|
|
301
|
+
ticked.add(choice.value);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
else
|
|
305
|
+
return 'continue';
|
|
306
|
+
paint();
|
|
307
|
+
return 'continue';
|
|
308
|
+
});
|
|
309
|
+
return outcome === 'done' ? [...ticked] : undefined;
|
|
310
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rejects a directory name npm could not use as a package name.
|
|
3
|
+
*
|
|
4
|
+
* The generated `package.json` takes this verbatim, and npm refuses to install
|
|
5
|
+
* into a project whose own name is invalid — which surfaces as a confusing
|
|
6
|
+
* error about the app rather than about the name that was typed.
|
|
7
|
+
*/
|
|
8
|
+
export declare function validateAppName(name: string): string | undefined;
|
|
9
|
+
/** Rejects a connection string that is not a postgres URL. */
|
|
10
|
+
export declare function validateDatabaseUrl(url: string): string | undefined;
|
|
11
|
+
/** The database name inside a postgres connection string. */
|
|
12
|
+
export declare function databaseNameFrom(url: string): string;
|
|
13
|
+
//# sourceMappingURL=validate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/lib/validate.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAUhE;AAED,8DAA8D;AAC9D,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAenE;AAED,6DAA6D;AAC7D,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAMpD"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.validateAppName = validateAppName;
|
|
4
|
+
exports.validateDatabaseUrl = validateDatabaseUrl;
|
|
5
|
+
exports.databaseNameFrom = databaseNameFrom;
|
|
6
|
+
/**
|
|
7
|
+
* Rejects a directory name npm could not use as a package name.
|
|
8
|
+
*
|
|
9
|
+
* The generated `package.json` takes this verbatim, and npm refuses to install
|
|
10
|
+
* into a project whose own name is invalid — which surfaces as a confusing
|
|
11
|
+
* error about the app rather than about the name that was typed.
|
|
12
|
+
*/
|
|
13
|
+
function validateAppName(name) {
|
|
14
|
+
if (!name)
|
|
15
|
+
return 'A name is required.';
|
|
16
|
+
if (name.length > 214)
|
|
17
|
+
return 'That name is longer than npm allows (214).';
|
|
18
|
+
if (name.startsWith('.') || name.startsWith('_')) {
|
|
19
|
+
return 'A package name cannot start with "." or "_".';
|
|
20
|
+
}
|
|
21
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(name)) {
|
|
22
|
+
return 'Use lowercase letters, digits, dots, hyphens and underscores.';
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
/** Rejects a connection string that is not a postgres URL. */
|
|
27
|
+
function validateDatabaseUrl(url) {
|
|
28
|
+
let parsed;
|
|
29
|
+
try {
|
|
30
|
+
parsed = new URL(url);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return 'That is not a valid URL.';
|
|
34
|
+
}
|
|
35
|
+
if (parsed.protocol !== 'postgresql:' && parsed.protocol !== 'postgres:') {
|
|
36
|
+
return 'Expected a postgresql:// connection string.';
|
|
37
|
+
}
|
|
38
|
+
if (!parsed.pathname.replace(/^\//, '')) {
|
|
39
|
+
return 'The URL needs a database name, e.g. postgresql://…/my_cms.';
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
/** The database name inside a postgres connection string. */
|
|
44
|
+
function databaseNameFrom(url) {
|
|
45
|
+
try {
|
|
46
|
+
return new URL(url).pathname.replace(/^\//, '') || 'ortha';
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return 'ortha';
|
|
50
|
+
}
|
|
51
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-ortha-app",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Create an Ortha CMS app: npx create-ortha-app my-cms",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/ortha-source/ortha-cms/tree/main/packages/create-ortha-app",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/ortha-source/ortha-cms.git",
|
|
10
|
+
"directory": "packages/create-ortha-app"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/ortha-source/ortha-cms/issues"
|
|
14
|
+
},
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"bin": {
|
|
25
|
+
"create-ortha-app": "./dist/cli.js"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"templates"
|
|
30
|
+
],
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"tslib": "^2.3.0"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
}
|
|
37
|
+
}
|