@karmaniverous/smoz 0.1.2 → 0.1.4
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/dist/cli/index.cjs +1216 -0
- package/package.json +1 -2
|
@@ -0,0 +1,1216 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
var fs = require('node:fs');
|
|
5
|
+
var path = require('node:path');
|
|
6
|
+
var commander = require('commander');
|
|
7
|
+
var packageDirectory = require('package-directory');
|
|
8
|
+
var node_url = require('node:url');
|
|
9
|
+
var chokidar = require('chokidar');
|
|
10
|
+
var node_child_process = require('node:child_process');
|
|
11
|
+
var os = require('node:os');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* SMOZ CLI: add
|
|
15
|
+
*
|
|
16
|
+
* Scaffold a function under app/functions.
|
|
17
|
+
* Usage spec:
|
|
18
|
+
* - HTTP: <eventType>/<segments...>/<method>
|
|
19
|
+
* - non-HTTP:<eventType>/<segments...>
|
|
20
|
+
*
|
|
21
|
+
* Creates:
|
|
22
|
+
* - HTTP: lambda.ts, handler.ts, openapi.ts
|
|
23
|
+
* - non-HTTP: lambda.ts, handler.ts
|
|
24
|
+
*
|
|
25
|
+
* Idempotent and formatted (uses Prettier if available).
|
|
26
|
+
*/
|
|
27
|
+
const HTTP_METHODS = new Set([
|
|
28
|
+
'get',
|
|
29
|
+
'post',
|
|
30
|
+
'put',
|
|
31
|
+
'delete',
|
|
32
|
+
'patch',
|
|
33
|
+
'head',
|
|
34
|
+
'options',
|
|
35
|
+
'trace',
|
|
36
|
+
]);
|
|
37
|
+
const toPosix$2 = (p) => p.split(path.sep).join('/');
|
|
38
|
+
const formatMaybe$1 = async (root, filePath, source) => {
|
|
39
|
+
try {
|
|
40
|
+
const prettier = (await import('prettier'));
|
|
41
|
+
const cfg = (await prettier.resolveConfig(root)) ?? {};
|
|
42
|
+
return prettier.format(source, { ...cfg, filepath: filePath });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return source;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const writeIfAbsent$1 = async (outFile, content) => {
|
|
49
|
+
if (fs.existsSync(outFile))
|
|
50
|
+
return { created: false };
|
|
51
|
+
await fs.promises.mkdir(path.dirname(outFile), { recursive: true });
|
|
52
|
+
await fs.promises.writeFile(outFile, content, 'utf8');
|
|
53
|
+
return { created: true };
|
|
54
|
+
};
|
|
55
|
+
/** True if a segment encodes a path param (:id | {id} | [id]). */
|
|
56
|
+
const isParamSeg = (s) => s.startsWith(':') ||
|
|
57
|
+
(s.startsWith('{') && s.endsWith('}')) ||
|
|
58
|
+
(s.startsWith('[') && s.endsWith(']'));
|
|
59
|
+
/** Extract the bare param name from a param segment. */
|
|
60
|
+
const getParamName = (s) => {
|
|
61
|
+
if (s.startsWith(':'))
|
|
62
|
+
return s.slice(1);
|
|
63
|
+
if (s.startsWith('{') && s.endsWith('}'))
|
|
64
|
+
return s.slice(1, -1);
|
|
65
|
+
if (s.startsWith('[') && s.endsWith(']'))
|
|
66
|
+
return s.slice(1, -1);
|
|
67
|
+
return s;
|
|
68
|
+
};
|
|
69
|
+
/** Normalize a segment for the filesystem (Windows‑safe). */
|
|
70
|
+
const toDirSeg = (s) => isParamSeg(s) ? `[${getParamName(s)}]` : s;
|
|
71
|
+
/** Normalize a segment for API paths (Serverless/OpenAPI‑native). */
|
|
72
|
+
const toPathSeg = (s) => isParamSeg(s) ? `{${getParamName(s)}}` : s;
|
|
73
|
+
const lambdaHttpTemplate = ({ token, basePath, method, }) => `/**
|
|
74
|
+
* Registration: ${method.toUpperCase()} /${basePath} (public)
|
|
75
|
+
*/
|
|
76
|
+
import { join } from 'node:path';
|
|
77
|
+
|
|
78
|
+
import { z } from 'zod';
|
|
79
|
+
|
|
80
|
+
import { app, APP_ROOT_ABS } from '@/app/config/app.config';
|
|
81
|
+
|
|
82
|
+
export const eventSchema = z.any();
|
|
83
|
+
export const responseSchema = z.any();
|
|
84
|
+
|
|
85
|
+
export const fn = app.defineFunction({
|
|
86
|
+
eventType: '${token}',
|
|
87
|
+
httpContexts: ['public'],
|
|
88
|
+
method: '${method}',
|
|
89
|
+
basePath: '${basePath}',
|
|
90
|
+
contentType: 'application/json',
|
|
91
|
+
eventSchema,
|
|
92
|
+
responseSchema,
|
|
93
|
+
callerModuleUrl: import.meta.url,
|
|
94
|
+
endpointsRootAbs: join(APP_ROOT_ABS, 'functions', '${token}').replace(/\\\\\\\\/g, '/'),
|
|
95
|
+
});
|
|
96
|
+
`;
|
|
97
|
+
const handlerHttpTemplate = () => `/**
|
|
98
|
+
* Handler: replace with your business logic.
|
|
99
|
+
*/
|
|
100
|
+
import { z } from 'zod';
|
|
101
|
+
|
|
102
|
+
import type { responseSchema } from './lambda';
|
|
103
|
+
import { fn } from './lambda';
|
|
104
|
+
|
|
105
|
+
type Response = z.infer<typeof responseSchema>;
|
|
106
|
+
|
|
107
|
+
export const handler = fn.handler(async (): Promise<Response> => {
|
|
108
|
+
return {} as Response;
|
|
109
|
+
});
|
|
110
|
+
`;
|
|
111
|
+
const openapiTemplate = ({ params, pathTemplate, }) => {
|
|
112
|
+
const paramsBlock = params.length > 0
|
|
113
|
+
? ` parameters: [
|
|
114
|
+
${params
|
|
115
|
+
.map((p) => ` { name: '${p}', in: 'path', required: true, schema: { type: 'string' }, description: 'Path parameter: ${p}' },`)
|
|
116
|
+
.join('\n')}
|
|
117
|
+
],
|
|
118
|
+
`
|
|
119
|
+
: '';
|
|
120
|
+
return `/* REQUIREMENTS
|
|
121
|
+
- Define OpenAPI Path Item for the new endpoint.
|
|
122
|
+
*/
|
|
123
|
+
import { eventSchema, fn, responseSchema } from './lambda';
|
|
124
|
+
|
|
125
|
+
fn.openapi({
|
|
126
|
+
summary: 'Describe your endpoint',
|
|
127
|
+
description: 'Describe your endpoint. Path template: ${pathTemplate}.',
|
|
128
|
+
${paramsBlock} requestBody: {
|
|
129
|
+
description: 'Request payload.',
|
|
130
|
+
content: { 'application/json': { schema: eventSchema } },
|
|
131
|
+
},
|
|
132
|
+
responses: {
|
|
133
|
+
200: {
|
|
134
|
+
description: 'Ok',
|
|
135
|
+
content: { 'application/json': { schema: responseSchema } },
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
tags: [],
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
export {};
|
|
142
|
+
`;
|
|
143
|
+
};
|
|
144
|
+
const lambdaInternalTemplate = (token) => `/**
|
|
145
|
+
* Registration: internal ${token} function.
|
|
146
|
+
*/
|
|
147
|
+
import { join } from 'node:path';
|
|
148
|
+
|
|
149
|
+
import { z } from 'zod';
|
|
150
|
+
|
|
151
|
+
import { app, APP_ROOT_ABS } from '@/app/config/app.config';
|
|
152
|
+
|
|
153
|
+
export const eventSchema = z.any();
|
|
154
|
+
export const responseSchema = z.any();
|
|
155
|
+
|
|
156
|
+
export const fn = app.defineFunction({
|
|
157
|
+
eventType: '${token}',
|
|
158
|
+
eventSchema,
|
|
159
|
+
responseSchema,
|
|
160
|
+
callerModuleUrl: import.meta.url,
|
|
161
|
+
endpointsRootAbs: join(APP_ROOT_ABS, 'functions', '${token}').replace(/\\\\\\\\/g, '/'),
|
|
162
|
+
});
|
|
163
|
+
`;
|
|
164
|
+
const handlerInternalTemplate = () => `/**
|
|
165
|
+
* Handler: replace with your business logic.
|
|
166
|
+
*/
|
|
167
|
+
import { z } from 'zod';
|
|
168
|
+
|
|
169
|
+
import type { responseSchema } from './lambda';
|
|
170
|
+
import { fn } from './lambda';
|
|
171
|
+
|
|
172
|
+
type Response = z.infer<typeof responseSchema>;
|
|
173
|
+
|
|
174
|
+
export const handler = fn.handler(async (_event): Promise<Response> => {
|
|
175
|
+
void _event;
|
|
176
|
+
return {} as Response;
|
|
177
|
+
});
|
|
178
|
+
`;
|
|
179
|
+
const runAdd = async (root, spec) => {
|
|
180
|
+
const parts = spec.split('/').filter(Boolean);
|
|
181
|
+
if (parts.length < 2) {
|
|
182
|
+
throw new Error('Invalid spec. Use <eventType>/<segments...>/<method> (HTTP) or <eventType>/<segments...> (non-HTTP).');
|
|
183
|
+
}
|
|
184
|
+
const token = parts[0].toLowerCase();
|
|
185
|
+
const tail = parts[parts.length - 1].toLowerCase();
|
|
186
|
+
const isHttp = HTTP_METHODS.has(tail);
|
|
187
|
+
const baseParts = isHttp ? parts.slice(1, -1) : parts.slice(1);
|
|
188
|
+
if (baseParts.length === 0) {
|
|
189
|
+
throw new Error('Provide at least one path segment after the eventType.');
|
|
190
|
+
}
|
|
191
|
+
const method = isHttp ? tail : undefined;
|
|
192
|
+
// Normalize param segments for path and filesystem
|
|
193
|
+
const dirSegs = baseParts.map(toDirSeg);
|
|
194
|
+
const pathSegs = baseParts.map(toPathSeg);
|
|
195
|
+
// Base path (Serverless/OpenAPI native, uses {param})
|
|
196
|
+
const basePathPosix = toPosix$2(pathSegs.join('/'));
|
|
197
|
+
// Derive path template and param names for OpenAPI hints
|
|
198
|
+
const paramNames = baseParts.filter(isParamSeg).map(getParamName);
|
|
199
|
+
const pathTemplate = '/' + pathSegs.join('/');
|
|
200
|
+
const dir = path.join(root, 'app', 'functions', token, ...dirSegs, ...(method ? [method] : []));
|
|
201
|
+
const lambdaPath = path.join(dir, 'lambda.ts');
|
|
202
|
+
const handlerPath = path.join(dir, 'handler.ts');
|
|
203
|
+
const openapiPath = path.join(dir, 'openapi.ts');
|
|
204
|
+
// Compute contents
|
|
205
|
+
const files = [];
|
|
206
|
+
if (isHttp) {
|
|
207
|
+
files.push({
|
|
208
|
+
path: lambdaPath,
|
|
209
|
+
content: lambdaHttpTemplate({
|
|
210
|
+
token,
|
|
211
|
+
basePath: basePathPosix,
|
|
212
|
+
method: method,
|
|
213
|
+
}),
|
|
214
|
+
enabled: true,
|
|
215
|
+
});
|
|
216
|
+
files.push({
|
|
217
|
+
path: handlerPath,
|
|
218
|
+
content: handlerHttpTemplate(),
|
|
219
|
+
enabled: true,
|
|
220
|
+
});
|
|
221
|
+
files.push({
|
|
222
|
+
path: openapiPath,
|
|
223
|
+
content: openapiTemplate({ params: paramNames, pathTemplate }),
|
|
224
|
+
enabled: true,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
files.push({
|
|
229
|
+
path: lambdaPath,
|
|
230
|
+
content: lambdaInternalTemplate(token),
|
|
231
|
+
enabled: true,
|
|
232
|
+
});
|
|
233
|
+
files.push({
|
|
234
|
+
path: handlerPath,
|
|
235
|
+
content: handlerInternalTemplate(),
|
|
236
|
+
enabled: true,
|
|
237
|
+
});
|
|
238
|
+
files.push({ path: openapiPath, content: '', enabled: false });
|
|
239
|
+
}
|
|
240
|
+
const created = [];
|
|
241
|
+
const skipped = [];
|
|
242
|
+
for (const f of files) {
|
|
243
|
+
if (!f.enabled)
|
|
244
|
+
continue;
|
|
245
|
+
const formatted = await formatMaybe$1(root, f.path, f.content);
|
|
246
|
+
const { created: c } = await writeIfAbsent$1(f.path, formatted);
|
|
247
|
+
if (c)
|
|
248
|
+
created.push(path.posix.normalize(f.path));
|
|
249
|
+
else
|
|
250
|
+
skipped.push(path.posix.normalize(f.path));
|
|
251
|
+
}
|
|
252
|
+
return { created, skipped };
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* serverless-offline runner (dev-only).
|
|
257
|
+
*
|
|
258
|
+
* - Provides sane TMP/TEMP/TMPDIR fallbacks to avoid "undefined\\temp\\..." paths.
|
|
259
|
+
* - Spawns the local Serverless CLI to run "offline start".
|
|
260
|
+
* - Provides restart/close helpers for the dev orchestrator.
|
|
261
|
+
* - Avoids unnecessary nullish-coalescing so ESLint doesn't flag conditions.
|
|
262
|
+
*/
|
|
263
|
+
const launchOffline = async (root, opts) => {
|
|
264
|
+
const slsJs = path.resolve(root, 'node_modules', 'serverless', 'bin', 'serverless.js');
|
|
265
|
+
const makeCmd = () => {
|
|
266
|
+
const args = [];
|
|
267
|
+
let cmd;
|
|
268
|
+
let shell = false;
|
|
269
|
+
if (fs.existsSync(slsJs)) {
|
|
270
|
+
cmd = process.execPath;
|
|
271
|
+
args.push(slsJs, 'offline', 'start', '--stage', opts.stage, '--httpPort', String(typeof opts.port === 'number' ? opts.port : 0));
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
// Fallback to PATH
|
|
275
|
+
cmd = process.platform === 'win32' ? 'serverless.cmd' : 'serverless';
|
|
276
|
+
args.push('offline', 'start', '--stage', opts.stage, '--httpPort', String(typeof opts.port === 'number' ? opts.port : 0));
|
|
277
|
+
shell = true;
|
|
278
|
+
}
|
|
279
|
+
return { cmd, args, shell };
|
|
280
|
+
};
|
|
281
|
+
let child = spawnOffline(root, makeCmd(), opts.verbose);
|
|
282
|
+
const close = async () => new Promise((resolve) => {
|
|
283
|
+
if (child.killed) {
|
|
284
|
+
resolve();
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
child.once('exit', () => {
|
|
288
|
+
resolve();
|
|
289
|
+
});
|
|
290
|
+
child.kill('SIGTERM');
|
|
291
|
+
// Safety timeout
|
|
292
|
+
setTimeout(() => {
|
|
293
|
+
resolve();
|
|
294
|
+
}, 1500);
|
|
295
|
+
});
|
|
296
|
+
const restart = async () => {
|
|
297
|
+
await close();
|
|
298
|
+
child = spawnOffline(root, makeCmd(), opts.verbose);
|
|
299
|
+
};
|
|
300
|
+
return { restart, close };
|
|
301
|
+
};
|
|
302
|
+
const spawnOffline = (root, cmd, verbose) => {
|
|
303
|
+
// Inherit the parent env, but ensure temp variables are present.
|
|
304
|
+
// Some toolchains (e.g., tsx/esbuild invoked under offline) interpolate TEMP/TMP,
|
|
305
|
+
// and if they are undefined, they may create literal "undefined\\temp\\..." paths
|
|
306
|
+
// relative to CWD. Use os.tmpdir() as a safe fallback.
|
|
307
|
+
const baseEnv = { ...process.env };
|
|
308
|
+
const tmp = os.tmpdir();
|
|
309
|
+
// Cross-platform default
|
|
310
|
+
if (!baseEnv.TMPDIR)
|
|
311
|
+
baseEnv.TMPDIR = tmp;
|
|
312
|
+
// Windows defaults
|
|
313
|
+
if (process.platform === 'win32') {
|
|
314
|
+
if (!baseEnv.TEMP)
|
|
315
|
+
baseEnv.TEMP = tmp;
|
|
316
|
+
if (!baseEnv.TMP)
|
|
317
|
+
baseEnv.TMP = tmp;
|
|
318
|
+
// Some nested toolchains derive cache roots from these when TMP/TEMP are absent
|
|
319
|
+
if (!baseEnv.LOCALAPPDATA)
|
|
320
|
+
baseEnv.LOCALAPPDATA = tmp;
|
|
321
|
+
if (!baseEnv.USERPROFILE)
|
|
322
|
+
baseEnv.USERPROFILE = tmp;
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
// POSIX: some tools fall back to HOME for caches when TMPDIR isn't used
|
|
326
|
+
if (!baseEnv.HOME)
|
|
327
|
+
baseEnv.HOME = tmp;
|
|
328
|
+
}
|
|
329
|
+
// Optional diagnostics to verify the child sees sane temp-related envs
|
|
330
|
+
if (verbose) {
|
|
331
|
+
const snap = {
|
|
332
|
+
TMPDIR: baseEnv.TMPDIR,
|
|
333
|
+
TEMP: baseEnv.TEMP,
|
|
334
|
+
TMP: baseEnv.TMP,
|
|
335
|
+
HOME: baseEnv.HOME,
|
|
336
|
+
USERPROFILE: baseEnv.USERPROFILE,
|
|
337
|
+
LOCALAPPDATA: baseEnv.LOCALAPPDATA,
|
|
338
|
+
};
|
|
339
|
+
process.stdout.write(`[offline] env snapshot: ${JSON.stringify(snap)}\n`);
|
|
340
|
+
}
|
|
341
|
+
const child = node_child_process.spawn(cmd.cmd, cmd.args, {
|
|
342
|
+
cwd: root,
|
|
343
|
+
shell: cmd.shell,
|
|
344
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
345
|
+
env: baseEnv,
|
|
346
|
+
});
|
|
347
|
+
const prefix = '[offline] ';
|
|
348
|
+
const emit = (buf) => {
|
|
349
|
+
const text = buf.toString('utf8');
|
|
350
|
+
if (verbose)
|
|
351
|
+
process.stdout.write(prefix + text);
|
|
352
|
+
};
|
|
353
|
+
const emitErr = (buf) => {
|
|
354
|
+
const text = buf.toString('utf8');
|
|
355
|
+
process.stderr.write(prefix + text);
|
|
356
|
+
};
|
|
357
|
+
// With stdio: 'pipe', these streams are present
|
|
358
|
+
child.stdout.on('data', emit);
|
|
359
|
+
child.stderr.on('data', emitErr);
|
|
360
|
+
child.on('error', (err) => {
|
|
361
|
+
process.stderr.write(`${prefix}${err.message}\n`);
|
|
362
|
+
});
|
|
363
|
+
return child;
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
/* OpenAPI one-shot runner: spawn the project-local OpenAPI script via tsx.
|
|
367
|
+
* - Mirrors the npm script: tsx app/config/openapi && prettier (project-local script already formats).
|
|
368
|
+
* - Keeps CLI responsibilities minimal; errors bubble via non-zero exit.
|
|
369
|
+
*/
|
|
370
|
+
const findTsxCli = (root) => {
|
|
371
|
+
// Prefer invoking the JS entry to avoid shell .cmd quirks on Windows.
|
|
372
|
+
const js = path.resolve(root, 'node_modules', 'tsx', 'dist', 'cli.js');
|
|
373
|
+
if (fs.existsSync(js)) {
|
|
374
|
+
return {
|
|
375
|
+
cmd: process.execPath,
|
|
376
|
+
args: [js, 'app/config/openapi.ts'],
|
|
377
|
+
shell: false,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
// Fallback to "tsx" on PATH (may rely on shell resolution).
|
|
381
|
+
const cmd = process.platform === 'win32' ? 'tsx.cmd' : 'tsx';
|
|
382
|
+
return { cmd, args: ['app/config/openapi.ts'], shell: true };
|
|
383
|
+
};
|
|
384
|
+
const runOpenapi = async (root, opts) => {
|
|
385
|
+
// Detect changes by comparing the pre/post content of app/generated/openapi.json.
|
|
386
|
+
const outFile = path.resolve(root, 'app', 'generated', 'openapi.json');
|
|
387
|
+
let before;
|
|
388
|
+
try {
|
|
389
|
+
if (fs.existsSync(outFile))
|
|
390
|
+
before = fs.readFileSync(outFile, 'utf8');
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
// ignore read errors; treat as absent
|
|
394
|
+
before = undefined;
|
|
395
|
+
}
|
|
396
|
+
const { cmd, args, shell } = findTsxCli(root);
|
|
397
|
+
if (opts?.verbose) {
|
|
398
|
+
console.log(`[openapi] ${[cmd, ...args].join(' ')}`);
|
|
399
|
+
}
|
|
400
|
+
const res = node_child_process.spawnSync(cmd, args, {
|
|
401
|
+
cwd: root,
|
|
402
|
+
stdio: 'inherit',
|
|
403
|
+
shell,
|
|
404
|
+
});
|
|
405
|
+
if (typeof res.status !== 'number' || res.status !== 0) {
|
|
406
|
+
const code = typeof res.status === 'number' ? String(res.status) : 'unknown';
|
|
407
|
+
throw new Error(`openapi failed (exit ${code})`);
|
|
408
|
+
}
|
|
409
|
+
// Determine whether the file content changed
|
|
410
|
+
try {
|
|
411
|
+
if (!fs.existsSync(outFile))
|
|
412
|
+
return before !== undefined; // deleted vs existed
|
|
413
|
+
const after = fs.readFileSync(outFile, 'utf8');
|
|
414
|
+
return before === undefined ? true : after !== before;
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
// If we cannot read, conservatively report "changed" so callers can refresh.
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* SMOZ CLI: register
|
|
424
|
+
*
|
|
425
|
+
* Scan app/functions/** for {lambda,openapi,serverless}.ts and generate
|
|
426
|
+
* side-effect import modules under app/generated/:
|
|
427
|
+
* - register.functions.ts
|
|
428
|
+
* - register.openapi.ts
|
|
429
|
+
* - register.serverless.ts
|
|
430
|
+
*
|
|
431
|
+
* Idempotent and formatted (uses Prettier if available).
|
|
432
|
+
*/
|
|
433
|
+
const toPosix$1 = (p) => p.split(path.sep).join('/');
|
|
434
|
+
const withoutTsExt = (p) => p.endsWith('.ts') ? p.slice(0, -3) : p;
|
|
435
|
+
const HEADER = `/* AUTO-GENERATED by smoz register — DO NOT EDIT */
|
|
436
|
+
`;
|
|
437
|
+
const formatMaybe = async (root, filePath, source) => {
|
|
438
|
+
try {
|
|
439
|
+
// Resolve Prettier if available in the project
|
|
440
|
+
const prettier = (await import('prettier'));
|
|
441
|
+
const cfg = (await prettier.resolveConfig(root)) ?? {};
|
|
442
|
+
return prettier.format(source, { ...cfg, filepath: filePath });
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
return source;
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
const writeIfChanged = async (outFile, content) => {
|
|
449
|
+
try {
|
|
450
|
+
const prev = await fs.promises.readFile(outFile, 'utf8');
|
|
451
|
+
if (prev === content)
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
// file missing — proceed
|
|
456
|
+
}
|
|
457
|
+
await fs.promises.mkdir(path.dirname(outFile), { recursive: true });
|
|
458
|
+
await fs.promises.writeFile(outFile, content, 'utf8');
|
|
459
|
+
return true;
|
|
460
|
+
};
|
|
461
|
+
const walk$1 = async (dir, out = []) => {
|
|
462
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
463
|
+
for (const ent of entries) {
|
|
464
|
+
const p = path.join(dir, ent.name);
|
|
465
|
+
if (ent.isDirectory()) {
|
|
466
|
+
await walk$1(p, out);
|
|
467
|
+
}
|
|
468
|
+
else if (ent.isFile()) {
|
|
469
|
+
out.push(p);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return out;
|
|
473
|
+
};
|
|
474
|
+
const collect = async (root) => {
|
|
475
|
+
const base = path.join(root, 'app', 'functions');
|
|
476
|
+
const buckets = { lambda: [], openapi: [], serverless: [] };
|
|
477
|
+
if (!fs.existsSync(base))
|
|
478
|
+
return buckets;
|
|
479
|
+
const files = await walk$1(base);
|
|
480
|
+
for (const abs of files) {
|
|
481
|
+
const name = abs.split(path.sep).pop() ?? '';
|
|
482
|
+
if (name === 'lambda.ts')
|
|
483
|
+
buckets.lambda.push(abs);
|
|
484
|
+
if (name === 'openapi.ts')
|
|
485
|
+
buckets.openapi.push(abs);
|
|
486
|
+
if (name === 'serverless.ts')
|
|
487
|
+
buckets.serverless.push(abs);
|
|
488
|
+
}
|
|
489
|
+
// stable order
|
|
490
|
+
buckets.lambda.sort();
|
|
491
|
+
buckets.openapi.sort();
|
|
492
|
+
buckets.serverless.sort();
|
|
493
|
+
return buckets;
|
|
494
|
+
};
|
|
495
|
+
const makeImports = (root, files) => {
|
|
496
|
+
return files.map((abs) => {
|
|
497
|
+
const rel = toPosix$1(path.relative(root, abs));
|
|
498
|
+
const noExt = withoutTsExt(rel);
|
|
499
|
+
return `import '@/${noExt}';`;
|
|
500
|
+
});
|
|
501
|
+
};
|
|
502
|
+
const buildFile = (imports) => {
|
|
503
|
+
if (imports.length === 0)
|
|
504
|
+
return `${HEADER}\n`;
|
|
505
|
+
return `${HEADER}\n${imports.join('\n')}\n`;
|
|
506
|
+
};
|
|
507
|
+
const runRegister = async (root) => {
|
|
508
|
+
const buckets = await collect(root);
|
|
509
|
+
const lambdaImports = makeImports(root, buckets.lambda);
|
|
510
|
+
const openapiImports = makeImports(root, buckets.openapi);
|
|
511
|
+
const serverlessImports = makeImports(root, buckets.serverless);
|
|
512
|
+
const outDir = path.join(root, 'app', 'generated');
|
|
513
|
+
const fnsPath = path.join(outDir, 'register.functions.ts');
|
|
514
|
+
const oaiPath = path.join(outDir, 'register.openapi.ts');
|
|
515
|
+
const srvPath = path.join(outDir, 'register.serverless.ts');
|
|
516
|
+
const fns = await formatMaybe(root, fnsPath, buildFile(lambdaImports));
|
|
517
|
+
const oai = await formatMaybe(root, oaiPath, buildFile(openapiImports));
|
|
518
|
+
const srv = await formatMaybe(root, srvPath, buildFile(serverlessImports));
|
|
519
|
+
const wrote = [];
|
|
520
|
+
if (await writeIfChanged(fnsPath, fns))
|
|
521
|
+
wrote.push(path.posix.normalize(fnsPath));
|
|
522
|
+
if (await writeIfChanged(oaiPath, oai))
|
|
523
|
+
wrote.push(path.posix.normalize(oaiPath));
|
|
524
|
+
if (await writeIfChanged(srvPath, srv))
|
|
525
|
+
wrote.push(path.posix.normalize(srvPath));
|
|
526
|
+
return { wrote };
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
/* Dev loop orchestrator
|
|
530
|
+
* - Watches author sources; debounces bursts; runs tasks in order: register → openapi.
|
|
531
|
+
* - Optional local serving (--local inline|offline).
|
|
532
|
+
* - Stage/env: seeds process.env with concrete values for the selected stage.
|
|
533
|
+
*/
|
|
534
|
+
const runDev = async (root, opts) => {
|
|
535
|
+
const verbose = !!opts.verbose;
|
|
536
|
+
const stage = typeof opts.stage === 'string'
|
|
537
|
+
? opts.stage
|
|
538
|
+
: inferDefaultStage(root, verbose);
|
|
539
|
+
// Seed env with concrete values for the selected stage.
|
|
540
|
+
try {
|
|
541
|
+
await seedEnvForStage(root, stage, verbose);
|
|
542
|
+
}
|
|
543
|
+
catch (e) {
|
|
544
|
+
if (verbose)
|
|
545
|
+
console.warn('[dev] env seeding warning:', e.message);
|
|
546
|
+
}
|
|
547
|
+
const mode = opts.local;
|
|
548
|
+
const port = typeof opts.port === 'number' ? opts.port : 0;
|
|
549
|
+
if (verbose) {
|
|
550
|
+
console.log(`[dev] options: register=${String(opts.register)} ` +
|
|
551
|
+
`openapi=${String(opts.openapi)} ` +
|
|
552
|
+
`local=${String(mode)} ` +
|
|
553
|
+
`stage=${stage} ` +
|
|
554
|
+
`port=${String(port)}`);
|
|
555
|
+
}
|
|
556
|
+
// Single debounced queue
|
|
557
|
+
let timer;
|
|
558
|
+
let running = false;
|
|
559
|
+
let pending = false;
|
|
560
|
+
// Small executor we can use for both pre-flight and queued runs
|
|
561
|
+
const executeOnce = async () => {
|
|
562
|
+
if (running)
|
|
563
|
+
return { wrote: false, openapiChanged: false };
|
|
564
|
+
running = true;
|
|
565
|
+
pending = false;
|
|
566
|
+
try {
|
|
567
|
+
let wrote = false;
|
|
568
|
+
if (opts.register) {
|
|
569
|
+
const res = await runRegister(root);
|
|
570
|
+
wrote = res.wrote.length > 0;
|
|
571
|
+
console.log(res.wrote.length
|
|
572
|
+
? `Updated:\n - ${res.wrote.join('\n - ')}`
|
|
573
|
+
: 'No changes.');
|
|
574
|
+
}
|
|
575
|
+
let openapiChanged = false;
|
|
576
|
+
if (opts.openapi) {
|
|
577
|
+
openapiChanged = await runOpenapi(root, { verbose });
|
|
578
|
+
}
|
|
579
|
+
return { wrote, openapiChanged };
|
|
580
|
+
}
|
|
581
|
+
catch (e) {
|
|
582
|
+
console.error('[dev] task error:', e.message);
|
|
583
|
+
return { wrote: false, openapiChanged: false };
|
|
584
|
+
}
|
|
585
|
+
finally {
|
|
586
|
+
running = false;
|
|
587
|
+
// If a burst arrived while we were running, schedule again
|
|
588
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
589
|
+
if (pending)
|
|
590
|
+
schedule();
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
// Local child (if any)
|
|
594
|
+
let offline;
|
|
595
|
+
let inlineChild;
|
|
596
|
+
const schedule = () => {
|
|
597
|
+
if (timer)
|
|
598
|
+
clearTimeout(timer);
|
|
599
|
+
timer = setTimeout(() => {
|
|
600
|
+
void (async () => {
|
|
601
|
+
pending = false;
|
|
602
|
+
const { wrote, openapiChanged } = await executeOnce();
|
|
603
|
+
// Local backend refresh
|
|
604
|
+
if (mode === 'offline') {
|
|
605
|
+
// Restart only when route-surface can change (register wrote)
|
|
606
|
+
if (wrote && offline) {
|
|
607
|
+
if (verbose)
|
|
608
|
+
console.log('[dev] restarting serverless-offline (register changed)...');
|
|
609
|
+
await offline.restart();
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
else if (mode === 'inline') {
|
|
613
|
+
// Restart inline only if something material changed
|
|
614
|
+
if (wrote || openapiChanged) {
|
|
615
|
+
if (verbose)
|
|
616
|
+
console.log('[dev] restarting inline server...');
|
|
617
|
+
// inlineChild is created when mode === 'inline'; guard for safety
|
|
618
|
+
if (inlineChild) {
|
|
619
|
+
await inlineChild.restart();
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
})();
|
|
624
|
+
}, 250);
|
|
625
|
+
};
|
|
626
|
+
// Pre-flight: run tasks before launching the local backend to avoid an immediate restart
|
|
627
|
+
await executeOnce();
|
|
628
|
+
// Local serving
|
|
629
|
+
if (mode === 'offline') {
|
|
630
|
+
offline = await launchOffline(root, { stage, port, verbose });
|
|
631
|
+
}
|
|
632
|
+
else if (mode === 'inline') {
|
|
633
|
+
inlineChild = await launchInline(root, { stage, port, verbose });
|
|
634
|
+
} // Watch sources
|
|
635
|
+
const globs = [
|
|
636
|
+
path.join(root, 'app', 'functions', '**', 'lambda.ts'),
|
|
637
|
+
path.join(root, 'app', 'functions', '**', 'openapi.ts'),
|
|
638
|
+
path.join(root, 'app', 'functions', '**', 'serverless.ts'),
|
|
639
|
+
];
|
|
640
|
+
if (verbose)
|
|
641
|
+
console.log('[dev] watching:', globs.map((g) => path.posix.normalize(g)).join(', '));
|
|
642
|
+
const watcher = chokidar.watch(globs, {
|
|
643
|
+
ignoreInitial: true,
|
|
644
|
+
awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 },
|
|
645
|
+
});
|
|
646
|
+
watcher.on('add', schedule).on('change', schedule).on('unlink', schedule);
|
|
647
|
+
// Keep process alive until SIGINT
|
|
648
|
+
await new Promise((resolve) => {
|
|
649
|
+
const stop = async () => {
|
|
650
|
+
try {
|
|
651
|
+
await watcher.close();
|
|
652
|
+
await offline?.close();
|
|
653
|
+
await inlineChild?.close();
|
|
654
|
+
}
|
|
655
|
+
finally {
|
|
656
|
+
resolve();
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
process.on('SIGINT', () => {
|
|
660
|
+
void stop();
|
|
661
|
+
});
|
|
662
|
+
process.on('SIGTERM', () => {
|
|
663
|
+
void stop();
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
};
|
|
667
|
+
const inferDefaultStage = (root, verbose) => {
|
|
668
|
+
// Prefer “dev”; if app.config.ts is available and we can inspect it cheaply later, expand behavior.
|
|
669
|
+
// For now, return 'dev' (explicit selection via --stage remains available).
|
|
670
|
+
if (verbose)
|
|
671
|
+
console.log('[dev] inferring stage: dev (explicit --stage overrides)');
|
|
672
|
+
return 'dev';
|
|
673
|
+
};
|
|
674
|
+
const seedEnvForStage = async (root, stage, verbose) => {
|
|
675
|
+
// Best effort: import the app config to read declared env keys and concrete values.
|
|
676
|
+
// Preserve existing process.env values; only seed when unset.
|
|
677
|
+
try {
|
|
678
|
+
const appConfigUrl = node_url.pathToFileURL(path.resolve(root, 'app', 'config', 'app.config.ts')).href;
|
|
679
|
+
// Dynamically import the TS module under tsx
|
|
680
|
+
const mod = (await import(appConfigUrl));
|
|
681
|
+
const app = mod.app;
|
|
682
|
+
const stages = mod.stages;
|
|
683
|
+
const globalKeys = Array.isArray(app?.global?.envKeys)
|
|
684
|
+
? app.global.envKeys
|
|
685
|
+
: [];
|
|
686
|
+
const stageKeys = Array.isArray(app?.stage?.envKeys)
|
|
687
|
+
? app.stage.envKeys
|
|
688
|
+
: [];
|
|
689
|
+
const globalParams = (stages?.default).params ?? {};
|
|
690
|
+
const stageParams = (stages?.[stage]).params ?? {};
|
|
691
|
+
const seedPair = (key, from) => {
|
|
692
|
+
if (key in process.env)
|
|
693
|
+
return;
|
|
694
|
+
const val = from[key];
|
|
695
|
+
if (val === undefined)
|
|
696
|
+
return;
|
|
697
|
+
if (typeof val === 'string') {
|
|
698
|
+
process.env[key] = val;
|
|
699
|
+
if (verbose)
|
|
700
|
+
console.log(`[dev] env: ${key}=${val}`);
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
if (typeof val === 'number' || typeof val === 'boolean') {
|
|
704
|
+
const v = String(val);
|
|
705
|
+
process.env[key] = v;
|
|
706
|
+
if (verbose)
|
|
707
|
+
console.log(`[dev] env: ${key}=${v}`);
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
// Non-primitive; skip to avoid [object Object] surprise.
|
|
711
|
+
if (verbose)
|
|
712
|
+
console.log(`[dev] env: skip ${key} (non-primitive)`);
|
|
713
|
+
};
|
|
714
|
+
for (const k of globalKeys) {
|
|
715
|
+
if (typeof k === 'string') {
|
|
716
|
+
seedPair(k, globalParams);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
for (const k of stageKeys) {
|
|
720
|
+
if (typeof k === 'string') {
|
|
721
|
+
seedPair(k, stageParams);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
// Ensure STAGE itself is present as a last resort
|
|
725
|
+
if (!process.env.STAGE) {
|
|
726
|
+
process.env.STAGE = stage;
|
|
727
|
+
if (verbose)
|
|
728
|
+
console.log(`[dev] env: STAGE=${stage}`);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
catch {
|
|
732
|
+
// Fallback: seed STAGE only
|
|
733
|
+
if (!process.env.STAGE) {
|
|
734
|
+
process.env.STAGE = stage;
|
|
735
|
+
if (verbose)
|
|
736
|
+
console.log(`[dev] env: STAGE=${stage}`);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
// Inline local runner: spawn tsx to run a TS server script.
|
|
741
|
+
const launchInline = async (root, opts) => {
|
|
742
|
+
const { spawn } = await import('node:child_process');
|
|
743
|
+
const path = await import('node:path');
|
|
744
|
+
const fs = await import('node:fs');
|
|
745
|
+
const tsxCli = path.resolve(root, 'node_modules', 'tsx', 'dist', 'cli.js');
|
|
746
|
+
const entry = path.resolve(root, 'src', 'cli', 'local', 'inline.server.ts');
|
|
747
|
+
if (!fs.existsSync(entry)) {
|
|
748
|
+
throw new Error('inline server entry missing: src/cli/local/inline.server.ts');
|
|
749
|
+
}
|
|
750
|
+
const args = [tsxCli, entry];
|
|
751
|
+
if (!fs.existsSync(tsxCli)) {
|
|
752
|
+
// Fallback to PATH resolution
|
|
753
|
+
args.shift();
|
|
754
|
+
args.unshift(process.platform === 'win32' ? 'tsx.cmd' : 'tsx');
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
args.unshift(process.execPath);
|
|
758
|
+
}
|
|
759
|
+
const spawnChild = () => spawn(args[0], args.slice(1), {
|
|
760
|
+
cwd: root,
|
|
761
|
+
stdio: 'inherit',
|
|
762
|
+
shell: !args[0].endsWith('.js'),
|
|
763
|
+
env: {
|
|
764
|
+
...process.env,
|
|
765
|
+
SMOZ_STAGE: opts.stage,
|
|
766
|
+
SMOZ_PORT: String(opts.port),
|
|
767
|
+
SMOZ_VERBOSE: opts.verbose ? '1' : '',
|
|
768
|
+
},
|
|
769
|
+
});
|
|
770
|
+
let child = spawnChild();
|
|
771
|
+
const close = async () => new Promise((resolve) => {
|
|
772
|
+
// If the process has already exited (exitCode set), resolve immediately.
|
|
773
|
+
if (child.exitCode !== null) {
|
|
774
|
+
resolve();
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
child.once('exit', () => {
|
|
778
|
+
resolve();
|
|
779
|
+
});
|
|
780
|
+
child.kill('SIGTERM');
|
|
781
|
+
setTimeout(() => {
|
|
782
|
+
resolve();
|
|
783
|
+
}, 1500);
|
|
784
|
+
});
|
|
785
|
+
const restart = async () => {
|
|
786
|
+
await close();
|
|
787
|
+
child = spawnChild();
|
|
788
|
+
};
|
|
789
|
+
return { close, restart };
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* smoz init
|
|
794
|
+
*
|
|
795
|
+
* Scaffolds a new project from packaged templates.
|
|
796
|
+
* - Copies ./templates/project/ into the target root (shared boilerplate)
|
|
797
|
+
* - Copies ./templates/<template>/ into the target root (default: minimal)
|
|
798
|
+
* - Seeds app/generated/register.*.ts (empty modules) if missing
|
|
799
|
+
* - Idempotent: copy-if-absent; if a file exists, writes <name>.example alongside
|
|
800
|
+
* - Additive merge of template manifest (deps/devDeps/scripts) into package.json
|
|
801
|
+
* - Optional dependency installation via --install[=<pm>]
|
|
802
|
+
*/
|
|
803
|
+
const toPosix = (p) => p.split(path.sep).join('/');
|
|
804
|
+
const writeIfAbsent = async (outFile, content) => {
|
|
805
|
+
if (fs.existsSync(outFile))
|
|
806
|
+
return { created: false };
|
|
807
|
+
await fs.promises.mkdir(path.dirname(outFile), { recursive: true });
|
|
808
|
+
await fs.promises.writeFile(outFile, content, 'utf8');
|
|
809
|
+
return { created: true };
|
|
810
|
+
};
|
|
811
|
+
const walk = async (dir, out = []) => {
|
|
812
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
813
|
+
for (const ent of entries) {
|
|
814
|
+
const p = path.join(dir, ent.name);
|
|
815
|
+
if (ent.isDirectory()) {
|
|
816
|
+
await walk(p, out);
|
|
817
|
+
}
|
|
818
|
+
else if (ent.isFile()) {
|
|
819
|
+
out.push(p);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
return out;
|
|
823
|
+
};
|
|
824
|
+
const resolveTemplatesBase = () => {
|
|
825
|
+
// Resolve the package root (works both in dev and when installed)
|
|
826
|
+
const pkgRoot = packageDirectory.packageDirectorySync() ?? process.cwd();
|
|
827
|
+
return path.resolve(pkgRoot, 'templates');
|
|
828
|
+
};
|
|
829
|
+
const readJson = async (file) => {
|
|
830
|
+
try {
|
|
831
|
+
const data = await fs.promises.readFile(file, 'utf8');
|
|
832
|
+
return JSON.parse(data);
|
|
833
|
+
}
|
|
834
|
+
catch {
|
|
835
|
+
return undefined;
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
const writeJson = async (file, obj) => {
|
|
839
|
+
await fs.promises.mkdir(path.dirname(file), { recursive: true });
|
|
840
|
+
await fs.promises.writeFile(file, JSON.stringify(obj, null, 2), 'utf8');
|
|
841
|
+
};
|
|
842
|
+
const detectPm = (root) => {
|
|
843
|
+
if (fs.existsSync(path.join(root, 'pnpm-lock.yaml')))
|
|
844
|
+
return 'pnpm';
|
|
845
|
+
if (fs.existsSync(path.join(root, 'yarn.lock')))
|
|
846
|
+
return 'yarn';
|
|
847
|
+
if (fs.existsSync(path.join(root, 'package-lock.json')))
|
|
848
|
+
return 'npm';
|
|
849
|
+
if (fs.existsSync(path.join(root, 'bun.lockb')))
|
|
850
|
+
return 'bun';
|
|
851
|
+
const ua = process.env.npm_config_user_agent ?? '';
|
|
852
|
+
if (ua.includes('pnpm'))
|
|
853
|
+
return 'pnpm';
|
|
854
|
+
if (ua.includes('yarn'))
|
|
855
|
+
return 'yarn';
|
|
856
|
+
if (ua.includes('bun'))
|
|
857
|
+
return 'bun';
|
|
858
|
+
if (ua.includes('npm'))
|
|
859
|
+
return 'npm';
|
|
860
|
+
return undefined;
|
|
861
|
+
};
|
|
862
|
+
const runInstall = (root, pm) => {
|
|
863
|
+
if (!pm)
|
|
864
|
+
return 'skipped';
|
|
865
|
+
const known = pm === 'pnpm' || pm === 'yarn' || pm === 'bun' || pm === 'npm';
|
|
866
|
+
if (!known)
|
|
867
|
+
return 'unknown-pm';
|
|
868
|
+
// Spawn with explicit args; avoid tuple inference that widens types.
|
|
869
|
+
const res = node_child_process.spawnSync(pm, ['install'], {
|
|
870
|
+
stdio: 'inherit',
|
|
871
|
+
cwd: root,
|
|
872
|
+
shell: true,
|
|
873
|
+
});
|
|
874
|
+
if (res.status === 0) {
|
|
875
|
+
const tag = pm === 'pnpm'
|
|
876
|
+
? 'ran (pnpm)'
|
|
877
|
+
: pm === 'yarn'
|
|
878
|
+
? 'ran (yarn)'
|
|
879
|
+
: pm === 'bun'
|
|
880
|
+
? 'ran (bun)'
|
|
881
|
+
: 'ran (npm)';
|
|
882
|
+
return tag;
|
|
883
|
+
}
|
|
884
|
+
return 'failed';
|
|
885
|
+
};
|
|
886
|
+
const mergeAdditive = (target, source) => {
|
|
887
|
+
const merged = [];
|
|
888
|
+
const mergeKey = (key) => {
|
|
889
|
+
// Allow possibly-undefined shapes to satisfy lint (no-unnecessary-condition).
|
|
890
|
+
const src = source[key] ?? {};
|
|
891
|
+
const dst = target[key] ?? {};
|
|
892
|
+
const out = { ...dst };
|
|
893
|
+
let changed = false;
|
|
894
|
+
for (const [k, v] of Object.entries(src)) {
|
|
895
|
+
if (!(k in dst)) {
|
|
896
|
+
out[k] = v;
|
|
897
|
+
merged.push(`${key}:${k}@${v}`);
|
|
898
|
+
changed = true;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
if (changed) {
|
|
902
|
+
target[key] = out;
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
mergeKey('dependencies');
|
|
906
|
+
mergeKey('devDependencies');
|
|
907
|
+
mergeKey('peerDependencies');
|
|
908
|
+
const srcScripts = source.scripts ?? {};
|
|
909
|
+
const dstScripts = target.scripts ?? {};
|
|
910
|
+
const scriptsOut = { ...dstScripts };
|
|
911
|
+
for (const [name, script] of Object.entries(srcScripts)) {
|
|
912
|
+
if (!(name in dstScripts)) {
|
|
913
|
+
scriptsOut[name] = script;
|
|
914
|
+
merged.push(`scripts:${name}`);
|
|
915
|
+
}
|
|
916
|
+
else if (dstScripts[name] !== script) {
|
|
917
|
+
const alias = `${name}:smoz`;
|
|
918
|
+
if (!(alias in dstScripts)) {
|
|
919
|
+
scriptsOut[alias] = script;
|
|
920
|
+
merged.push(`scripts:${alias}`);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
target.scripts = scriptsOut;
|
|
925
|
+
return merged;
|
|
926
|
+
};
|
|
927
|
+
const copyDirIdempotent = async (srcDir, dstRoot, created, skipped, examples) => {
|
|
928
|
+
const files = await walk(srcDir);
|
|
929
|
+
for (const abs of files) {
|
|
930
|
+
const rel = path.relative(srcDir, abs);
|
|
931
|
+
const dest = path.resolve(dstRoot, rel);
|
|
932
|
+
const data = await fs.promises.readFile(abs, 'utf8');
|
|
933
|
+
if (fs.existsSync(dest)) {
|
|
934
|
+
const ex = `${dest}.example`;
|
|
935
|
+
if (!fs.existsSync(ex)) {
|
|
936
|
+
await writeIfAbsent(ex, data);
|
|
937
|
+
examples.push(path.posix.normalize(ex));
|
|
938
|
+
}
|
|
939
|
+
else {
|
|
940
|
+
skipped.push(path.posix.normalize(dest));
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
else {
|
|
944
|
+
const { created: c } = await writeIfAbsent(dest, data);
|
|
945
|
+
if (c)
|
|
946
|
+
created.push(path.posix.normalize(dest));
|
|
947
|
+
else
|
|
948
|
+
skipped.push(path.posix.normalize(dest));
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
};
|
|
952
|
+
const runInit = async (root, template = 'minimal', opts) => {
|
|
953
|
+
const created = [];
|
|
954
|
+
const skipped = [];
|
|
955
|
+
const examples = [];
|
|
956
|
+
const merged = [];
|
|
957
|
+
const templatesBase = resolveTemplatesBase();
|
|
958
|
+
const srcBase = path.resolve(templatesBase, template);
|
|
959
|
+
const projectBase = path.resolve(templatesBase, 'project');
|
|
960
|
+
if (!fs.existsSync(srcBase)) {
|
|
961
|
+
throw new Error(`Template "${template}" not found under ${toPosix(templatesBase)}.`);
|
|
962
|
+
}
|
|
963
|
+
// 1) Copy shared boilerplate (project) first (idempotent)
|
|
964
|
+
if (fs.existsSync(projectBase)) {
|
|
965
|
+
await copyDirIdempotent(projectBase, root, created, skipped, examples);
|
|
966
|
+
}
|
|
967
|
+
// 2) Copy selected template
|
|
968
|
+
await copyDirIdempotent(srcBase, root, created, skipped, examples);
|
|
969
|
+
// Seed app/generated/register.*.ts (empty modules) if missing
|
|
970
|
+
const genDir = path.resolve(root, 'app', 'generated');
|
|
971
|
+
const seeds = [
|
|
972
|
+
{
|
|
973
|
+
path: path.join(genDir, 'register.functions.ts'),
|
|
974
|
+
content: `/* AUTO-GENERATED placeholder; will be rewritten by \`smoz register\` */\nexport {};\n`,
|
|
975
|
+
},
|
|
976
|
+
{
|
|
977
|
+
path: path.join(genDir, 'register.openapi.ts'),
|
|
978
|
+
content: `/* AUTO-GENERATED placeholder; will be rewritten by \`smoz register\` */\nexport {};\n`,
|
|
979
|
+
},
|
|
980
|
+
{
|
|
981
|
+
path: path.join(genDir, 'register.serverless.ts'),
|
|
982
|
+
content: `/* AUTO-GENERATED placeholder; will be rewritten by \`smoz register\` */\nexport {};\n`,
|
|
983
|
+
},
|
|
984
|
+
];
|
|
985
|
+
for (const s of seeds) {
|
|
986
|
+
const { created: c } = await writeIfAbsent(s.path, s.content);
|
|
987
|
+
if (c)
|
|
988
|
+
created.push(path.posix.normalize(s.path));
|
|
989
|
+
else
|
|
990
|
+
skipped.push(path.posix.normalize(s.path));
|
|
991
|
+
}
|
|
992
|
+
// 3) package.json presence (guard or create with --init)
|
|
993
|
+
const pkgPath = path.join(root, 'package.json');
|
|
994
|
+
let pkg = await readJson(pkgPath);
|
|
995
|
+
if (!pkg) {
|
|
996
|
+
const shouldInit = !!(opts && opts.init);
|
|
997
|
+
if (shouldInit) {
|
|
998
|
+
const name = toPosix(root).split('/').pop() ?? 'smoz-app';
|
|
999
|
+
pkg = {
|
|
1000
|
+
name,
|
|
1001
|
+
private: true,
|
|
1002
|
+
type: 'module',
|
|
1003
|
+
version: '0.0.0',
|
|
1004
|
+
scripts: {},
|
|
1005
|
+
};
|
|
1006
|
+
// Avoid optional chain to satisfy no-unnecessary-condition; normalize to boolean.
|
|
1007
|
+
const dryRunCreate = !!opts.dryRun;
|
|
1008
|
+
if (!dryRunCreate)
|
|
1009
|
+
await writeJson(pkgPath, pkg);
|
|
1010
|
+
created.push(path.posix.normalize(pkgPath));
|
|
1011
|
+
}
|
|
1012
|
+
else {
|
|
1013
|
+
throw new Error('No package.json found. Run "npm init -y" (or re-run with --init) and then "smoz init" again.');
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
// 4) Merge manifest (deps/devDeps/scripts) additively
|
|
1017
|
+
const manifestPath = path.resolve(templatesBase, '.manifests', `package.${template}.json`);
|
|
1018
|
+
const manifest = await readJson(manifestPath);
|
|
1019
|
+
if (manifest) {
|
|
1020
|
+
const before = JSON.stringify(pkg);
|
|
1021
|
+
const added = mergeAdditive(pkg, manifest);
|
|
1022
|
+
merged.push(...added);
|
|
1023
|
+
const dryRun = !!(opts && opts.dryRun);
|
|
1024
|
+
if (!dryRun && before !== JSON.stringify(pkg)) {
|
|
1025
|
+
await writeJson(pkgPath, pkg);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
// 5) Optional install
|
|
1029
|
+
let installed = 'skipped';
|
|
1030
|
+
const installOpt = opts ? opts.install : false;
|
|
1031
|
+
// Derive package manager to use (explicit string or detected when true),
|
|
1032
|
+
// then set installed based on presence of a PM.
|
|
1033
|
+
const hasInstallString = typeof installOpt === 'string' && installOpt.trim() !== '';
|
|
1034
|
+
const pm = hasInstallString
|
|
1035
|
+
? installOpt
|
|
1036
|
+
: installOpt === true
|
|
1037
|
+
? detectPm(root)
|
|
1038
|
+
: undefined;
|
|
1039
|
+
installed = pm ? runInstall(root, pm) : installed;
|
|
1040
|
+
return { created, skipped, examples, merged, installed };
|
|
1041
|
+
};
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* SMOZ CLI — version/signature + register/add
|
|
1045
|
+
*
|
|
1046
|
+
* - Default: print project signature (version, Node, repo root, stanPath, config presence) * - register: one-shot — generate app/generated/register.*.ts from app/functions/**
|
|
1047
|
+
* - openapi: one-shot — run the project’s OpenAPI builder
|
|
1048
|
+
* - dev: watch loop orchestrator for register/openapi and optional local serving
|
|
1049
|
+
* - add: scaffold a new function skeleton under app/functions
|
|
1050
|
+
*/
|
|
1051
|
+
const getRepoRoot = () => packageDirectory.packageDirectorySync() ?? process.cwd();
|
|
1052
|
+
const readPkg = (root) => {
|
|
1053
|
+
try {
|
|
1054
|
+
const raw = fs.readFileSync(path.join(root, 'package.json'), 'utf8');
|
|
1055
|
+
return JSON.parse(raw);
|
|
1056
|
+
}
|
|
1057
|
+
catch {
|
|
1058
|
+
return {};
|
|
1059
|
+
}
|
|
1060
|
+
};
|
|
1061
|
+
const detectPackageManager = () => {
|
|
1062
|
+
const ua = process.env.npm_config_user_agent ?? '';
|
|
1063
|
+
if (ua.includes('pnpm'))
|
|
1064
|
+
return 'pnpm';
|
|
1065
|
+
if (ua.includes('yarn'))
|
|
1066
|
+
return 'yarn';
|
|
1067
|
+
if (ua.includes('npm'))
|
|
1068
|
+
return 'npm';
|
|
1069
|
+
return ua || undefined;
|
|
1070
|
+
};
|
|
1071
|
+
const detectStanPath = (root) => {
|
|
1072
|
+
// Keep this conservative; future: read stan.config.* if introduced.
|
|
1073
|
+
const candidate = '.stan';
|
|
1074
|
+
if (fs.existsSync(path.join(root, candidate, 'system', 'stan.system.md'))) {
|
|
1075
|
+
return candidate;
|
|
1076
|
+
}
|
|
1077
|
+
return candidate; // default
|
|
1078
|
+
};
|
|
1079
|
+
const printSignature = () => {
|
|
1080
|
+
const root = getRepoRoot();
|
|
1081
|
+
const pkg = readPkg(root);
|
|
1082
|
+
const name = pkg.name ?? 'smoz';
|
|
1083
|
+
const version = pkg.version ?? '0.0.0';
|
|
1084
|
+
const pm = detectPackageManager();
|
|
1085
|
+
const stanPath = detectStanPath(root);
|
|
1086
|
+
const hasAppConfig = fs.existsSync(path.join(root, 'app', 'config', 'app.config.ts'));
|
|
1087
|
+
const hasSmozJson = fs.existsSync(path.join(root, 'smoz.config.json'));
|
|
1088
|
+
const hasSmozYaml = fs.existsSync(path.join(root, 'smoz.config.yml')) ||
|
|
1089
|
+
fs.existsSync(path.join(root, 'smoz.config.yaml'));
|
|
1090
|
+
console.log(`${name} v${version}`);
|
|
1091
|
+
console.log(`Node ${process.version}`);
|
|
1092
|
+
console.log(`Repo: ${root}`);
|
|
1093
|
+
console.log(`stanPath: ${stanPath}`);
|
|
1094
|
+
console.log(`app/config/app.config.ts: ${hasAppConfig ? 'found' : 'missing'}`);
|
|
1095
|
+
console.log(`smoz.config.*: ${hasSmozJson || hasSmozYaml ? 'found' : 'absent'}`);
|
|
1096
|
+
if (pm)
|
|
1097
|
+
console.log(`PM: ${pm}`);
|
|
1098
|
+
};
|
|
1099
|
+
const main = () => {
|
|
1100
|
+
const root = getRepoRoot();
|
|
1101
|
+
const pkg = readPkg(root);
|
|
1102
|
+
const program = new commander.Command();
|
|
1103
|
+
program
|
|
1104
|
+
.name('smoz')
|
|
1105
|
+
.description('SMOZ CLI')
|
|
1106
|
+
.version(pkg.version ?? '0.0.0');
|
|
1107
|
+
program
|
|
1108
|
+
.command('add')
|
|
1109
|
+
.argument('<spec>', 'Add function: HTTP <eventType>/<segments...>/<method> or non-HTTP <eventType>/<segments...>')
|
|
1110
|
+
.description('Scaffold a new function under app/functions')
|
|
1111
|
+
.action(async (spec) => {
|
|
1112
|
+
try {
|
|
1113
|
+
const { created, skipped } = await runAdd(root, spec);
|
|
1114
|
+
console.log(created.length
|
|
1115
|
+
? `Created:\n - ${created.join('\n - ')}${skipped.length
|
|
1116
|
+
? `\nSkipped (exists):\n - ${skipped.join('\n - ')}`
|
|
1117
|
+
: ''}`
|
|
1118
|
+
: 'Nothing created (files already exist).');
|
|
1119
|
+
}
|
|
1120
|
+
catch (e) {
|
|
1121
|
+
console.error(e.message);
|
|
1122
|
+
process.exitCode = 1;
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
program
|
|
1126
|
+
.command('init')
|
|
1127
|
+
.description('Scaffold a new SMOZ app from packaged templates (default: minimal)')
|
|
1128
|
+
.option('--template <name>', 'Template name (minimal|full)', 'minimal')
|
|
1129
|
+
.option('--init', 'Create a minimal package.json if missing')
|
|
1130
|
+
.option('-i, --install [pm]', 'Install dependencies (optionally specify pm: npm|pnpm|yarn|bun)')
|
|
1131
|
+
.option('--yes', 'Skip prompts (non-interactive)', false)
|
|
1132
|
+
.option('--dry-run', 'Show planned actions without writing', false)
|
|
1133
|
+
.action(async (opts) => {
|
|
1134
|
+
try {
|
|
1135
|
+
const tpl = typeof opts.template === 'string' ? opts.template : 'minimal';
|
|
1136
|
+
const { created, skipped, examples, merged, installed } = await runInit(root, tpl, opts);
|
|
1137
|
+
console.log([
|
|
1138
|
+
created.length
|
|
1139
|
+
? `Created:\n - ${created.join('\n - ')}`
|
|
1140
|
+
: 'Created: (none)',
|
|
1141
|
+
examples.length
|
|
1142
|
+
? `Examples (existing preserved):\n - ${examples.join('\n - ')}`
|
|
1143
|
+
: undefined,
|
|
1144
|
+
skipped.length
|
|
1145
|
+
? `Skipped (exists):\n - ${skipped.join('\n - ')}`
|
|
1146
|
+
: undefined,
|
|
1147
|
+
merged.length
|
|
1148
|
+
? `package.json (additive):\n - ${merged.join('\n - ')}`
|
|
1149
|
+
: undefined,
|
|
1150
|
+
`Install: ${installed}`,
|
|
1151
|
+
]
|
|
1152
|
+
.filter(Boolean)
|
|
1153
|
+
.join('\n'));
|
|
1154
|
+
}
|
|
1155
|
+
catch (e) {
|
|
1156
|
+
console.error(e.message);
|
|
1157
|
+
process.exitCode = 1;
|
|
1158
|
+
}
|
|
1159
|
+
});
|
|
1160
|
+
program
|
|
1161
|
+
.command('register')
|
|
1162
|
+
.description('Scan app/functions/** and generate app/generated/register.*.ts (one-shot)')
|
|
1163
|
+
.action(async () => {
|
|
1164
|
+
const { wrote } = await runRegister(root);
|
|
1165
|
+
console.log(wrote.length ? `Updated:\n - ${wrote.join('\n - ')}` : 'No changes.');
|
|
1166
|
+
});
|
|
1167
|
+
program
|
|
1168
|
+
.command('openapi')
|
|
1169
|
+
.description('Generate app/generated/openapi.json (one-shot)')
|
|
1170
|
+
.action(async () => {
|
|
1171
|
+
try {
|
|
1172
|
+
await runOpenapi(root, { verbose: true });
|
|
1173
|
+
}
|
|
1174
|
+
catch (e) {
|
|
1175
|
+
console.error(e.message);
|
|
1176
|
+
process.exitCode = 1;
|
|
1177
|
+
}
|
|
1178
|
+
});
|
|
1179
|
+
program
|
|
1180
|
+
.command('dev')
|
|
1181
|
+
.description('Watch loop: keep registers/openapi fresh; optionally serve HTTP locally')
|
|
1182
|
+
.option('-r, --register', 'Enable register step on change', true)
|
|
1183
|
+
.option('-R, --no-register', 'Disable register step on change')
|
|
1184
|
+
.option('-o, --openapi', 'Enable openapi step on change', true)
|
|
1185
|
+
.option('-O, --no-openapi', 'Disable openapi step on change')
|
|
1186
|
+
.option('-l, --local [mode]', 'Local server mode: inline|offline', 'inline')
|
|
1187
|
+
.option('-s, --stage <name>', 'Stage name (default inferred)')
|
|
1188
|
+
.option('-p, --port <n>', 'Port (0=random)', (v) => Number(v), 0)
|
|
1189
|
+
.option('-v, --verbose', 'Verbose logging', false)
|
|
1190
|
+
.action(async (opts) => {
|
|
1191
|
+
try {
|
|
1192
|
+
await runDev(root, {
|
|
1193
|
+
register: opts.register !== false,
|
|
1194
|
+
openapi: opts.openapi !== false,
|
|
1195
|
+
local: typeof opts.local === 'string'
|
|
1196
|
+
? opts.local
|
|
1197
|
+
: opts.local === false
|
|
1198
|
+
? false
|
|
1199
|
+
: 'inline',
|
|
1200
|
+
...(typeof opts.stage === 'string' ? { stage: opts.stage } : {}),
|
|
1201
|
+
port: opts.port ?? 0,
|
|
1202
|
+
verbose: !!opts.verbose,
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
catch (e) {
|
|
1206
|
+
console.error(e.message);
|
|
1207
|
+
process.exitCode = 1;
|
|
1208
|
+
}
|
|
1209
|
+
});
|
|
1210
|
+
// Default action (no subcommand): print version + signature block
|
|
1211
|
+
program.action(() => {
|
|
1212
|
+
printSignature();
|
|
1213
|
+
});
|
|
1214
|
+
program.parse(process.argv);
|
|
1215
|
+
};
|
|
1216
|
+
main();
|
package/package.json
CHANGED
|
@@ -161,7 +161,6 @@
|
|
|
161
161
|
},
|
|
162
162
|
"scripts": {
|
|
163
163
|
"build": "rimraf dist && rollup --config rollup.config.ts --configPlugin @rollup/plugin-typescript",
|
|
164
|
-
"build:cli": "rollup --config cli.rollup.config.ts --configPlugin @rollup/plugin-typescript",
|
|
165
164
|
"changelog": "auto-changelog",
|
|
166
165
|
"deploy": "tsx src/cli/index.ts register && serverless deploy",
|
|
167
166
|
"diagrams": "cd diagrams/src && plantuml -tpng -o ../out -r .",
|
|
@@ -186,5 +185,5 @@
|
|
|
186
185
|
"templates:lint": "eslint --fix -c templates/.check/eslint.templates.config.ts \"templates/**/*.{ts,tsx,js,jsx}\" && eslint --fix --no-ignore templates/.check/eslint.templates.config.ts"
|
|
187
186
|
},
|
|
188
187
|
"type": "module",
|
|
189
|
-
"version": "0.1.
|
|
188
|
+
"version": "0.1.4"
|
|
190
189
|
}
|