@jskit-ai/create-app 0.1.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/README.md +22 -0
- package/bin/jskit-create-app.js +8 -0
- package/package.json +42 -0
- package/src/index.js +498 -0
- package/templates/base-shell/README.md +33 -0
- package/templates/base-shell/app.scripts.config.mjs +3 -0
- package/templates/base-shell/bin/server.js +8 -0
- package/templates/base-shell/eslint.config.mjs +10 -0
- package/templates/base-shell/favicon.svg +7 -0
- package/templates/base-shell/framework/app.manifest.mjs +8 -0
- package/templates/base-shell/gitignore +7 -0
- package/templates/base-shell/index.html +13 -0
- package/templates/base-shell/package.json +33 -0
- package/templates/base-shell/server/lib/runtimeEnv.js +16 -0
- package/templates/base-shell/server.js +26 -0
- package/templates/base-shell/src/main.js +30 -0
- package/templates/base-shell/tests/client/smoke.vitest.js +7 -0
- package/templates/base-shell/tests/server/minimalShell.contract.test.js +100 -0
- package/templates/base-shell/tests/server/smoke.test.js +19 -0
- package/templates/base-shell/vite.config.mjs +29 -0
package/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# @jskit-ai/create-app
|
|
2
|
+
|
|
3
|
+
Scaffold a minimal JSKIT app shell from in-repo templates.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
jskit-create-app my-app
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
jskit-create-app --interactive
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Options
|
|
16
|
+
|
|
17
|
+
- `--template <name>` template name under `templates/` (default `base-shell`)
|
|
18
|
+
- `--title <text>` override the generated app title placeholder
|
|
19
|
+
- `--target <path>` output directory (default `./<app-name>`)
|
|
20
|
+
- `--force` allow writes into non-empty target directories
|
|
21
|
+
- `--dry-run` preview writes only
|
|
22
|
+
- `--interactive` prompt for app values
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jskit-ai/create-app",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scaffold minimal JSKIT app shells.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"bin",
|
|
8
|
+
"src",
|
|
9
|
+
"templates",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "node --test"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"jskit-create-app": "bin/jskit-create-app.js"
|
|
17
|
+
},
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.js"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": "20.x"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/mobily-enterprises/jskit-ai.git",
|
|
30
|
+
"directory": "packages/tooling/create-app"
|
|
31
|
+
},
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/mobily-enterprises/jskit-ai/issues"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/mobily-enterprises/jskit-ai/tree/main/packages/tooling/create-app#readme",
|
|
36
|
+
"keywords": [
|
|
37
|
+
"jskit",
|
|
38
|
+
"scaffold",
|
|
39
|
+
"cli",
|
|
40
|
+
"starter"
|
|
41
|
+
]
|
|
42
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TEMPLATE = "base-shell";
|
|
8
|
+
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
9
|
+
const TEMPLATES_ROOT = path.join(PACKAGE_ROOT, "templates");
|
|
10
|
+
|
|
11
|
+
function createCliError(message, { showUsage = false, exitCode = 1 } = {}) {
|
|
12
|
+
const error = new Error(String(message || "Command failed."));
|
|
13
|
+
error.showUsage = Boolean(showUsage);
|
|
14
|
+
error.exitCode = Number.isInteger(exitCode) ? exitCode : 1;
|
|
15
|
+
return error;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function shellQuote(value) {
|
|
19
|
+
const raw = String(value ?? "");
|
|
20
|
+
if (!raw) {
|
|
21
|
+
return "''";
|
|
22
|
+
}
|
|
23
|
+
if (/^[A-Za-z0-9_./:=+,-]+$/.test(raw)) {
|
|
24
|
+
return raw;
|
|
25
|
+
}
|
|
26
|
+
return `'${raw.replace(/'/g, "'\\''")}'`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toAppTitle(appName) {
|
|
30
|
+
const words = String(appName)
|
|
31
|
+
.trim()
|
|
32
|
+
.split(/[-_]+/)
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.map((word) => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`);
|
|
35
|
+
|
|
36
|
+
return words.length > 0 ? words.join(" ") : "App";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validateAppName(appName, { showUsage = true } = {}) {
|
|
40
|
+
if (!appName || typeof appName !== "string") {
|
|
41
|
+
throw createCliError("Missing app name.", { showUsage });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(appName)) {
|
|
45
|
+
throw createCliError(
|
|
46
|
+
`Invalid app name "${appName}". Use lowercase letters, numbers, and dashes only.`,
|
|
47
|
+
{ showUsage }
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseOptionWithValue(argv, index, optionName) {
|
|
53
|
+
const nextValue = argv[index + 1];
|
|
54
|
+
if (!nextValue || nextValue.startsWith("-")) {
|
|
55
|
+
throw createCliError(`Option ${optionName} requires a value.`, {
|
|
56
|
+
showUsage: true
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
value: nextValue,
|
|
61
|
+
nextIndex: index + 1
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseCliArgs(argv) {
|
|
66
|
+
const args = Array.isArray(argv) ? argv : [];
|
|
67
|
+
const options = {
|
|
68
|
+
appName: null,
|
|
69
|
+
appTitle: null,
|
|
70
|
+
template: DEFAULT_TEMPLATE,
|
|
71
|
+
target: null,
|
|
72
|
+
force: false,
|
|
73
|
+
dryRun: false,
|
|
74
|
+
help: false,
|
|
75
|
+
interactive: false
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const positionalArgs = [];
|
|
79
|
+
|
|
80
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
81
|
+
const arg = String(args[index] || "");
|
|
82
|
+
|
|
83
|
+
if (arg === "--help" || arg === "-h") {
|
|
84
|
+
options.help = true;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (arg === "--force") {
|
|
89
|
+
options.force = true;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (arg === "--dry-run") {
|
|
94
|
+
options.dryRun = true;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (arg === "--interactive") {
|
|
99
|
+
options.interactive = true;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (arg === "--template") {
|
|
104
|
+
const { value, nextIndex } = parseOptionWithValue(args, index, "--template");
|
|
105
|
+
options.template = value;
|
|
106
|
+
index = nextIndex;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (arg.startsWith("--template=")) {
|
|
111
|
+
options.template = arg.slice("--template=".length);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (arg === "--target") {
|
|
116
|
+
const { value, nextIndex } = parseOptionWithValue(args, index, "--target");
|
|
117
|
+
options.target = value;
|
|
118
|
+
index = nextIndex;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (arg.startsWith("--target=")) {
|
|
123
|
+
options.target = arg.slice("--target=".length);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (arg === "--title") {
|
|
128
|
+
const { value, nextIndex } = parseOptionWithValue(args, index, "--title");
|
|
129
|
+
options.appTitle = value;
|
|
130
|
+
index = nextIndex;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (arg.startsWith("--title=")) {
|
|
135
|
+
options.appTitle = arg.slice("--title=".length);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (arg.startsWith("-")) {
|
|
140
|
+
throw createCliError(`Unknown option: ${arg}`, {
|
|
141
|
+
showUsage: true
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
positionalArgs.push(arg);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (positionalArgs.length > 1) {
|
|
149
|
+
throw createCliError("Only one <app-name> argument is allowed.", {
|
|
150
|
+
showUsage: true
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (positionalArgs.length === 1) {
|
|
155
|
+
options.appName = positionalArgs[0];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!options.help && !options.interactive && positionalArgs.length !== 1) {
|
|
159
|
+
throw createCliError("Expected exactly one <app-name> argument.", {
|
|
160
|
+
showUsage: true
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return options;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function printUsage(stream = process.stderr) {
|
|
168
|
+
stream.write("Usage: jskit-create-app [app-name] [options]\n");
|
|
169
|
+
stream.write("\n");
|
|
170
|
+
stream.write("Options:\n");
|
|
171
|
+
stream.write(` --template <name> Template folder under templates/ (default: ${DEFAULT_TEMPLATE})\n`);
|
|
172
|
+
stream.write(" --title <text> App title used for template replacements\n");
|
|
173
|
+
stream.write(" --target <path> Target directory (default: ./<app-name>)\n");
|
|
174
|
+
stream.write(" --force Allow writing into a non-empty target directory\n");
|
|
175
|
+
stream.write(" --dry-run Print planned writes without changing the filesystem\n");
|
|
176
|
+
stream.write(" --interactive Prompt for app values instead of passing all flags\n");
|
|
177
|
+
stream.write(" -h, --help Show this help\n");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function applyPlaceholders(source, replacements) {
|
|
181
|
+
let output = String(source || "");
|
|
182
|
+
for (const [placeholder, value] of Object.entries(replacements)) {
|
|
183
|
+
output = output.split(placeholder).join(String(value));
|
|
184
|
+
}
|
|
185
|
+
return output;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function resolveTemplateDirectory(templateName) {
|
|
189
|
+
const cleanTemplate = String(templateName || "").trim();
|
|
190
|
+
if (!cleanTemplate) {
|
|
191
|
+
throw createCliError("Template name cannot be empty.", {
|
|
192
|
+
showUsage: true
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const templateDir = path.join(TEMPLATES_ROOT, cleanTemplate);
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const templateStats = await stat(templateDir);
|
|
200
|
+
if (!templateStats.isDirectory()) {
|
|
201
|
+
throw createCliError(`Template "${cleanTemplate}" is not a directory.`);
|
|
202
|
+
}
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (error?.code === "ENOENT") {
|
|
205
|
+
throw createCliError(`Unknown template "${cleanTemplate}".`);
|
|
206
|
+
}
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return templateDir;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function ensureTargetDirectoryState(targetDirectory, { force = false, dryRun = false } = {}) {
|
|
214
|
+
let targetExists = false;
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const targetStats = await stat(targetDirectory);
|
|
218
|
+
targetExists = true;
|
|
219
|
+
if (!targetStats.isDirectory()) {
|
|
220
|
+
throw createCliError(`Target path exists and is not a directory: ${targetDirectory}`);
|
|
221
|
+
}
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (error?.code !== "ENOENT") {
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!targetExists) {
|
|
229
|
+
if (!dryRun) {
|
|
230
|
+
await mkdir(targetDirectory, { recursive: true });
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const entries = await readdir(targetDirectory);
|
|
236
|
+
if (entries.length > 0 && !force) {
|
|
237
|
+
throw createCliError(
|
|
238
|
+
`Target directory is not empty: ${targetDirectory}. Use --force to allow writing into it.`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function sortEntriesByName(entries) {
|
|
244
|
+
return [...entries].sort((left, right) => left.name.localeCompare(right.name));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function mapTemplatePathToTargetPath(relativePath) {
|
|
248
|
+
const pathSegments = String(relativePath || "")
|
|
249
|
+
.split(path.sep)
|
|
250
|
+
.map((segment) => (segment === "gitignore" ? ".gitignore" : segment));
|
|
251
|
+
return pathSegments.join(path.sep);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function writeTemplateFile(sourcePath, targetPath, replacements) {
|
|
255
|
+
const sourceBody = await readFile(sourcePath, "utf8");
|
|
256
|
+
const targetBody = applyPlaceholders(sourceBody, replacements);
|
|
257
|
+
|
|
258
|
+
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
259
|
+
await writeFile(targetPath, targetBody, "utf8");
|
|
260
|
+
|
|
261
|
+
const sourceStats = await stat(sourcePath);
|
|
262
|
+
await chmod(targetPath, sourceStats.mode & 0o777);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function copyTemplateDirectory({ templateDirectory, targetDirectory, replacements, dryRun }) {
|
|
266
|
+
const touchedFiles = [];
|
|
267
|
+
|
|
268
|
+
async function traverse(relativePath = "") {
|
|
269
|
+
const sourceDirectory = path.join(templateDirectory, relativePath);
|
|
270
|
+
const sourceEntries = sortEntriesByName(await readdir(sourceDirectory, { withFileTypes: true }));
|
|
271
|
+
|
|
272
|
+
for (const entry of sourceEntries) {
|
|
273
|
+
const entryRelativePath = relativePath ? path.join(relativePath, entry.name) : entry.name;
|
|
274
|
+
const targetRelativePath = mapTemplatePathToTargetPath(entryRelativePath);
|
|
275
|
+
const sourcePath = path.join(templateDirectory, entryRelativePath);
|
|
276
|
+
const targetPath = path.join(targetDirectory, targetRelativePath);
|
|
277
|
+
|
|
278
|
+
if (entry.isDirectory()) {
|
|
279
|
+
if (!dryRun) {
|
|
280
|
+
await mkdir(targetPath, { recursive: true });
|
|
281
|
+
}
|
|
282
|
+
await traverse(entryRelativePath);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (entry.isFile()) {
|
|
287
|
+
touchedFiles.push(targetRelativePath);
|
|
288
|
+
if (!dryRun) {
|
|
289
|
+
await writeTemplateFile(sourcePath, targetPath, replacements);
|
|
290
|
+
}
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
throw createCliError(`Unsupported template entry type at ${entryRelativePath}.`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
await traverse();
|
|
299
|
+
return touchedFiles;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function toRelativeTargetLabel(cwd, targetDirectory) {
|
|
303
|
+
const relativePath = path.relative(cwd, targetDirectory);
|
|
304
|
+
if (!relativePath || relativePath === ".") {
|
|
305
|
+
return ".";
|
|
306
|
+
}
|
|
307
|
+
if (relativePath.startsWith("..")) {
|
|
308
|
+
return targetDirectory;
|
|
309
|
+
}
|
|
310
|
+
return `./${relativePath}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function askQuestion(readline, label, defaultValue) {
|
|
314
|
+
const suffix = defaultValue ? ` (${defaultValue})` : "";
|
|
315
|
+
const response = await readline.question(`${label}${suffix}: `);
|
|
316
|
+
const trimmed = response.trim();
|
|
317
|
+
return trimmed || defaultValue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function askYesNoQuestion(readline, label, defaultValue) {
|
|
321
|
+
const prompt = defaultValue ? "Y/n" : "y/N";
|
|
322
|
+
|
|
323
|
+
while (true) {
|
|
324
|
+
const response = await readline.question(`${label} [${prompt}]: `);
|
|
325
|
+
const normalized = response.trim().toLowerCase();
|
|
326
|
+
if (!normalized) {
|
|
327
|
+
return defaultValue;
|
|
328
|
+
}
|
|
329
|
+
if (normalized === "y" || normalized === "yes") {
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
if (normalized === "n" || normalized === "no") {
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function collectInteractiveOptions({ parsed, stdout = process.stdout, stderr = process.stderr, stdin = process.stdin }) {
|
|
339
|
+
const readline = createInterface({
|
|
340
|
+
input: stdin,
|
|
341
|
+
output: stdout
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
let appName = String(parsed.appName || "").trim();
|
|
346
|
+
while (true) {
|
|
347
|
+
appName = await askQuestion(readline, "App name", appName);
|
|
348
|
+
try {
|
|
349
|
+
validateAppName(appName, { showUsage: false });
|
|
350
|
+
break;
|
|
351
|
+
} catch (error) {
|
|
352
|
+
stderr.write(`Error: ${error?.message || String(error)}\n`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const defaultTitle = String(parsed.appTitle || "").trim() || toAppTitle(appName);
|
|
357
|
+
const appTitle = await askQuestion(readline, "App title", defaultTitle);
|
|
358
|
+
|
|
359
|
+
const defaultTarget = String(parsed.target || "").trim() || appName;
|
|
360
|
+
const target = await askQuestion(readline, "Target directory", defaultTarget);
|
|
361
|
+
|
|
362
|
+
const defaultTemplate = String(parsed.template || "").trim() || DEFAULT_TEMPLATE;
|
|
363
|
+
const template = await askQuestion(readline, "Template", defaultTemplate);
|
|
364
|
+
|
|
365
|
+
const force = await askYesNoQuestion(
|
|
366
|
+
readline,
|
|
367
|
+
"Allow writing into non-empty target directories",
|
|
368
|
+
Boolean(parsed.force)
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
appName,
|
|
373
|
+
appTitle,
|
|
374
|
+
target,
|
|
375
|
+
template,
|
|
376
|
+
force
|
|
377
|
+
};
|
|
378
|
+
} finally {
|
|
379
|
+
readline.close();
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export async function createApp({
|
|
384
|
+
appName,
|
|
385
|
+
appTitle = null,
|
|
386
|
+
template = DEFAULT_TEMPLATE,
|
|
387
|
+
target = null,
|
|
388
|
+
force = false,
|
|
389
|
+
dryRun = false,
|
|
390
|
+
cwd = process.cwd()
|
|
391
|
+
}) {
|
|
392
|
+
const resolvedAppName = String(appName || "").trim();
|
|
393
|
+
validateAppName(resolvedAppName);
|
|
394
|
+
|
|
395
|
+
const resolvedAppTitle = String(appTitle || "").trim() || toAppTitle(resolvedAppName);
|
|
396
|
+
|
|
397
|
+
const resolvedCwd = path.resolve(cwd);
|
|
398
|
+
const targetDirectory = path.resolve(resolvedCwd, target ? String(target) : resolvedAppName);
|
|
399
|
+
const templateDirectory = await resolveTemplateDirectory(template);
|
|
400
|
+
|
|
401
|
+
await ensureTargetDirectoryState(targetDirectory, {
|
|
402
|
+
force,
|
|
403
|
+
dryRun
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const replacements = {
|
|
407
|
+
__APP_NAME__: resolvedAppName,
|
|
408
|
+
__APP_TITLE__: resolvedAppTitle
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const touchedFiles = await copyTemplateDirectory({
|
|
412
|
+
templateDirectory,
|
|
413
|
+
targetDirectory,
|
|
414
|
+
replacements,
|
|
415
|
+
dryRun
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
return {
|
|
419
|
+
appName: resolvedAppName,
|
|
420
|
+
appTitle: resolvedAppTitle,
|
|
421
|
+
template: String(template),
|
|
422
|
+
targetDirectory,
|
|
423
|
+
dryRun,
|
|
424
|
+
touchedFiles
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export async function runCli(
|
|
429
|
+
argv,
|
|
430
|
+
{
|
|
431
|
+
stdout = process.stdout,
|
|
432
|
+
stderr = process.stderr,
|
|
433
|
+
stdin = process.stdin,
|
|
434
|
+
cwd = process.cwd()
|
|
435
|
+
} = {}
|
|
436
|
+
) {
|
|
437
|
+
try {
|
|
438
|
+
const parsed = parseCliArgs(argv);
|
|
439
|
+
|
|
440
|
+
if (parsed.help) {
|
|
441
|
+
printUsage(stdout);
|
|
442
|
+
return 0;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const resolvedOptions = parsed.interactive
|
|
446
|
+
? {
|
|
447
|
+
...parsed,
|
|
448
|
+
...(await collectInteractiveOptions({
|
|
449
|
+
parsed,
|
|
450
|
+
stdout,
|
|
451
|
+
stderr,
|
|
452
|
+
stdin
|
|
453
|
+
}))
|
|
454
|
+
}
|
|
455
|
+
: parsed;
|
|
456
|
+
|
|
457
|
+
const result = await createApp({
|
|
458
|
+
appName: resolvedOptions.appName,
|
|
459
|
+
appTitle: resolvedOptions.appTitle,
|
|
460
|
+
template: resolvedOptions.template,
|
|
461
|
+
target: resolvedOptions.target,
|
|
462
|
+
force: resolvedOptions.force,
|
|
463
|
+
dryRun: resolvedOptions.dryRun,
|
|
464
|
+
cwd
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const targetLabel = toRelativeTargetLabel(path.resolve(cwd), result.targetDirectory);
|
|
468
|
+
if (result.dryRun) {
|
|
469
|
+
stdout.write(
|
|
470
|
+
`[dry-run] Would create app "${result.appName}" from template "${result.template}" at ${targetLabel}.\n`
|
|
471
|
+
);
|
|
472
|
+
} else {
|
|
473
|
+
stdout.write(`Created app "${result.appName}" from template "${result.template}" at ${targetLabel}.\n`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
stdout.write(`${result.dryRun ? "Planned" : "Written"} files (${result.touchedFiles.length}):\n`);
|
|
477
|
+
for (const filePath of result.touchedFiles) {
|
|
478
|
+
stdout.write(`- ${filePath}\n`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (!result.dryRun) {
|
|
482
|
+
stdout.write("\n");
|
|
483
|
+
stdout.write("Next steps:\n");
|
|
484
|
+
stdout.write(`- cd ${shellQuote(targetLabel)}\n`);
|
|
485
|
+
stdout.write("- npm install\n");
|
|
486
|
+
stdout.write("- npm run dev\n");
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return 0;
|
|
490
|
+
} catch (error) {
|
|
491
|
+
stderr.write(`Error: ${error?.message || String(error)}\n`);
|
|
492
|
+
if (error?.showUsage) {
|
|
493
|
+
stderr.write("\n");
|
|
494
|
+
printUsage(stderr);
|
|
495
|
+
}
|
|
496
|
+
return Number.isInteger(error?.exitCode) ? error.exitCode : 1;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# __APP_TITLE__
|
|
2
|
+
|
|
3
|
+
Minimal JSKIT starter shell.
|
|
4
|
+
|
|
5
|
+
## What This Is
|
|
6
|
+
|
|
7
|
+
This is the smallest practical JSKIT app host:
|
|
8
|
+
|
|
9
|
+
- tiny Fastify server (`/api/v1/health`)
|
|
10
|
+
- tiny Vue client shell
|
|
11
|
+
- standardized scripts via `@jskit-ai/app-scripts`
|
|
12
|
+
- framework composition seed file: `framework/app.manifest.mjs`
|
|
13
|
+
|
|
14
|
+
## What This Is Not
|
|
15
|
+
|
|
16
|
+
This app intentionally does not include:
|
|
17
|
+
|
|
18
|
+
- db wiring
|
|
19
|
+
- auth/workspace modules
|
|
20
|
+
- billing/chat/social/ai modules
|
|
21
|
+
- app-local framework composition engines
|
|
22
|
+
|
|
23
|
+
Those are layered in later as framework packs/modules.
|
|
24
|
+
|
|
25
|
+
## Run
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm install
|
|
29
|
+
npm run dev
|
|
30
|
+
npm run server
|
|
31
|
+
npm run test
|
|
32
|
+
npm run test:client
|
|
33
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
|
+
<title>__APP_TITLE__</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app"></div>
|
|
11
|
+
<script type="module" src="/src/%VITE_CLIENT_ENTRY%"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__APP_NAME__",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Minimal JSKIT base app (Fastify + Vue)",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": "20.x"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"server": "jskit-app-scripts server",
|
|
12
|
+
"start": "jskit-app-scripts start",
|
|
13
|
+
"dev": "jskit-app-scripts dev",
|
|
14
|
+
"build": "jskit-app-scripts build",
|
|
15
|
+
"preview": "jskit-app-scripts preview",
|
|
16
|
+
"lint": "jskit-app-scripts lint",
|
|
17
|
+
"lint:process-env": "jskit-app-scripts lint:process-env",
|
|
18
|
+
"test": "jskit-app-scripts test",
|
|
19
|
+
"test:client": "jskit-app-scripts test:client"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@jskit-ai/app-scripts": "0.1.0",
|
|
23
|
+
"fastify": "^5.7.4",
|
|
24
|
+
"vue": "^3.5.13"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@jskit-ai/config-eslint": "0.1.0",
|
|
28
|
+
"@vitejs/plugin-vue": "^5.2.1",
|
|
29
|
+
"eslint": "^9.39.1",
|
|
30
|
+
"vite": "^6.1.0",
|
|
31
|
+
"vitest": "^4.0.18"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
function toPort(value, fallback = 3000) {
|
|
2
|
+
const parsed = Number.parseInt(String(value || "").trim(), 10);
|
|
3
|
+
if (Number.isInteger(parsed) && parsed > 0) {
|
|
4
|
+
return parsed;
|
|
5
|
+
}
|
|
6
|
+
return fallback;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function resolveRuntimeEnv() {
|
|
10
|
+
return {
|
|
11
|
+
PORT: toPort(process.env.PORT, 3000),
|
|
12
|
+
HOST: String(process.env.HOST || "").trim() || "0.0.0.0"
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export { resolveRuntimeEnv };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import Fastify from "fastify";
|
|
2
|
+
import { resolveRuntimeEnv } from "./server/lib/runtimeEnv.js";
|
|
3
|
+
|
|
4
|
+
function createServer() {
|
|
5
|
+
const app = Fastify({ logger: true });
|
|
6
|
+
|
|
7
|
+
app.get("/api/v1/health", async () => {
|
|
8
|
+
return {
|
|
9
|
+
ok: true,
|
|
10
|
+
app: "__APP_NAME__"
|
|
11
|
+
};
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
return app;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function startServer(options = {}) {
|
|
18
|
+
const runtimeEnv = resolveRuntimeEnv();
|
|
19
|
+
const port = Number(options?.port) || runtimeEnv.PORT;
|
|
20
|
+
const host = String(options?.host || "").trim() || runtimeEnv.HOST;
|
|
21
|
+
const app = createServer();
|
|
22
|
+
await app.listen({ port, host });
|
|
23
|
+
return app;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { createServer, startServer };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createApp, onMounted, ref } from "vue";
|
|
2
|
+
|
|
3
|
+
const App = {
|
|
4
|
+
setup() {
|
|
5
|
+
const health = ref("loading...");
|
|
6
|
+
|
|
7
|
+
onMounted(async () => {
|
|
8
|
+
try {
|
|
9
|
+
const response = await fetch("/api/v1/health");
|
|
10
|
+
const payload = await response.json();
|
|
11
|
+
health.value = payload?.ok ? "ok" : "unhealthy";
|
|
12
|
+
} catch {
|
|
13
|
+
health.value = "unreachable";
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
health
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
template: `
|
|
22
|
+
<main style="font-family: sans-serif; max-width: 48rem; margin: 3rem auto; padding: 0 1rem;">
|
|
23
|
+
<h1>__APP_TITLE__</h1>
|
|
24
|
+
<p>Minimal starter shell is running.</p>
|
|
25
|
+
<p><strong>Health:</strong> {{ health }}</p>
|
|
26
|
+
</main>
|
|
27
|
+
`
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
createApp(App).mount("#app");
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
const APP_ROOT = path.resolve(__dirname, "../..");
|
|
10
|
+
|
|
11
|
+
const EXPECTED_RUNTIME_DEPENDENCIES = Object.freeze([
|
|
12
|
+
"@jskit-ai/app-scripts",
|
|
13
|
+
"fastify",
|
|
14
|
+
"vue"
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const EXPECTED_DEV_DEPENDENCIES = Object.freeze([
|
|
18
|
+
"@jskit-ai/config-eslint",
|
|
19
|
+
"@vitejs/plugin-vue",
|
|
20
|
+
"eslint",
|
|
21
|
+
"vite",
|
|
22
|
+
"vitest"
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const EXPECTED_TOP_LEVEL_ENTRIES = Object.freeze([
|
|
26
|
+
"README.md",
|
|
27
|
+
"app.scripts.config.mjs",
|
|
28
|
+
"bin",
|
|
29
|
+
"eslint.config.mjs",
|
|
30
|
+
"favicon.svg",
|
|
31
|
+
"framework",
|
|
32
|
+
"gitignore",
|
|
33
|
+
"index.html",
|
|
34
|
+
"package.json",
|
|
35
|
+
"server.js",
|
|
36
|
+
"server",
|
|
37
|
+
"src",
|
|
38
|
+
"tests",
|
|
39
|
+
"vite.config.mjs"
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
async function readPackageJson() {
|
|
43
|
+
const packageJsonPath = path.join(APP_ROOT, "package.json");
|
|
44
|
+
const raw = await readFile(packageJsonPath, "utf8");
|
|
45
|
+
return JSON.parse(raw);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sortStrings(values) {
|
|
49
|
+
return [...values].sort((left, right) => left.localeCompare(right));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sortedKeys(record) {
|
|
53
|
+
return sortStrings(Object.keys(record || {}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function readTopLevelEntries() {
|
|
57
|
+
const entries = await readdir(APP_ROOT, { withFileTypes: true });
|
|
58
|
+
const ignored = new Set([
|
|
59
|
+
"node_modules",
|
|
60
|
+
"dist",
|
|
61
|
+
"coverage",
|
|
62
|
+
"test-results",
|
|
63
|
+
"package-lock.json",
|
|
64
|
+
"pnpm-lock.yaml",
|
|
65
|
+
"yarn.lock"
|
|
66
|
+
]);
|
|
67
|
+
return entries
|
|
68
|
+
.map((entry) => entry.name)
|
|
69
|
+
.filter((name) => !name.startsWith("."))
|
|
70
|
+
.filter((name) => !ignored.has(name));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
test("minimal shell keeps strict dependency allowlist", async () => {
|
|
74
|
+
const packageJson = await readPackageJson();
|
|
75
|
+
assert.deepEqual(
|
|
76
|
+
sortedKeys(packageJson.dependencies),
|
|
77
|
+
[...EXPECTED_RUNTIME_DEPENDENCIES].sort((left, right) => left.localeCompare(right))
|
|
78
|
+
);
|
|
79
|
+
assert.deepEqual(
|
|
80
|
+
sortedKeys(packageJson.devDependencies),
|
|
81
|
+
[...EXPECTED_DEV_DEPENDENCIES].sort((left, right) => left.localeCompare(right))
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("starter shell keeps a strict top-level footprint", async () => {
|
|
86
|
+
const entries = await readTopLevelEntries();
|
|
87
|
+
assert.deepEqual(sortStrings(entries), sortStrings(EXPECTED_TOP_LEVEL_ENTRIES));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("manifest scaffold exists with strict core-only defaults", async () => {
|
|
91
|
+
const manifestModule = await import(path.join(APP_ROOT, "framework/app.manifest.mjs"));
|
|
92
|
+
const manifest = manifestModule?.default;
|
|
93
|
+
|
|
94
|
+
assert.equal(manifest.manifestVersion, 1);
|
|
95
|
+
assert.equal(manifest.appId, "__APP_NAME__");
|
|
96
|
+
assert.equal(manifest.profileId, "web-saas-default");
|
|
97
|
+
assert.equal(manifest.mode, "strict");
|
|
98
|
+
assert.equal(manifest.enforceProfileRequired, true);
|
|
99
|
+
assert.deepEqual(manifest.optionalModulePacks, ["core"]);
|
|
100
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createServer } from "../../server.js";
|
|
4
|
+
|
|
5
|
+
test("GET /api/v1/health returns ok payload", async () => {
|
|
6
|
+
const app = createServer();
|
|
7
|
+
const response = await app.inject({
|
|
8
|
+
method: "GET",
|
|
9
|
+
url: "/api/v1/health"
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
assert.equal(response.statusCode, 200);
|
|
13
|
+
assert.deepEqual(response.json(), {
|
|
14
|
+
ok: true,
|
|
15
|
+
app: "__APP_NAME__"
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
await app.close();
|
|
19
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
|
+
import vue from "@vitejs/plugin-vue";
|
|
3
|
+
|
|
4
|
+
function toPositiveInt(value, fallback) {
|
|
5
|
+
const parsed = Number.parseInt(String(value || "").trim(), 10);
|
|
6
|
+
if (Number.isInteger(parsed) && parsed > 0) {
|
|
7
|
+
return parsed;
|
|
8
|
+
}
|
|
9
|
+
return fallback;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const devPort = toPositiveInt(process.env.VITE_DEV_PORT, 5173);
|
|
13
|
+
const apiProxyTarget = String(process.env.VITE_API_PROXY_TARGET || "").trim() || "http://localhost:3000";
|
|
14
|
+
|
|
15
|
+
export default defineConfig({
|
|
16
|
+
plugins: [vue()],
|
|
17
|
+
test: {
|
|
18
|
+
include: ["tests/client/**/*.vitest.js"]
|
|
19
|
+
},
|
|
20
|
+
server: {
|
|
21
|
+
port: devPort,
|
|
22
|
+
proxy: {
|
|
23
|
+
"/api": {
|
|
24
|
+
target: apiProxyTarget,
|
|
25
|
+
changeOrigin: true
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
});
|