@karmaniverous/smoz 0.1.8 → 0.2.1

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.
Files changed (52) hide show
  1. package/README.md +28 -4
  2. package/dist/cli/index.cjs +282 -122
  3. package/package.json +5 -8
  4. package/templates/{minimal → default}/app/config/app.config.ts +50 -50
  5. package/templates/{minimal → default}/app/config/openapi.ts +45 -45
  6. package/templates/{full → default}/app/functions/rest/hello/get/handler.ts +18 -18
  7. package/templates/{full → default}/app/functions/rest/hello/get/lambda.ts +28 -28
  8. package/templates/{minimal → default}/app/functions/rest/hello/get/openapi.ts +23 -23
  9. package/templates/{minimal → default}/app/functions/rest/openapi/get/handler.ts +22 -22
  10. package/templates/{minimal → default}/app/functions/rest/openapi/get/lambda.ts +29 -29
  11. package/templates/{full → default}/app/functions/rest/openapi/get/openapi.ts +20 -20
  12. package/templates/{project → default}/eslint.config.ts +6 -15
  13. package/templates/default/package.json +44 -0
  14. package/templates/default/serverless.ts +113 -0
  15. package/templates/default/tsconfig.eslint.json +13 -0
  16. package/templates/{minimal → default}/types/registers.d.ts +11 -11
  17. package/templates/.check/eslint.minimal.config.ts +0 -41
  18. package/templates/.check/eslint.templates.config.ts +0 -60
  19. package/templates/.check/tsconfig.eslintconfig.json +0 -6
  20. package/templates/.check/tsconfig.minimal.json +0 -14
  21. package/templates/.manifests/package.full.json +0 -22
  22. package/templates/.manifests/package.minimal.json +0 -22
  23. package/templates/.manifests/package.project.json +0 -24
  24. package/templates/full/app/config/app.config.ts +0 -50
  25. package/templates/full/app/config/openapi.ts +0 -44
  26. package/templates/full/app/functions/rest/hello/get/openapi.ts +0 -19
  27. package/templates/full/app/functions/rest/openapi/get/handler.ts +0 -20
  28. package/templates/full/app/functions/rest/openapi/get/lambda.ts +0 -29
  29. package/templates/full/app/functions/sqs/tick/handler.ts +0 -7
  30. package/templates/full/app/functions/sqs/tick/lambda.ts +0 -24
  31. package/templates/full/app/functions/sqs/tick/serverless.ts +0 -8
  32. package/templates/full/serverless.ts +0 -33
  33. package/templates/full/tsconfig.json +0 -25
  34. package/templates/full/types/registers.d.ts +0 -11
  35. package/templates/minimal/app/functions/rest/hello/get/handler.ts +0 -18
  36. package/templates/minimal/app/functions/rest/hello/get/lambda.ts +0 -28
  37. package/templates/minimal/app/functions/rest/openapi/get/openapi.ts +0 -20
  38. package/templates/minimal/app/generated/openapi.json +0 -8
  39. package/templates/minimal/serverless.ts +0 -34
  40. package/templates/minimal/tsconfig.json +0 -25
  41. /package/templates/{project → default}/.prettierrc.json +0 -0
  42. /package/templates/{project → default}/.vscode/extensions.json +0 -0
  43. /package/templates/{project → default}/.vscode/settings.json +0 -0
  44. /package/templates/{project → default}/README.md +0 -0
  45. /package/templates/{full → default}/app/generated/openapi.json +0 -0
  46. /package/templates/{project → default}/gitignore +0 -0
  47. /package/templates/{project → default}/test/smoke.test.ts +0 -0
  48. /package/templates/{project → default}/tsconfig.base.json +0 -0
  49. /package/templates/{project → default}/tsconfig.json +0 -0
  50. /package/templates/{project → default}/tsdoc.json +0 -0
  51. /package/templates/{project → default}/typedoc.json +0 -0
  52. /package/templates/{project → default}/vitest.config.ts +0 -0
package/README.md CHANGED
@@ -18,17 +18,41 @@ SMOZ is a small, pragmatic toolkit for authoring AWS Lambda handlers with [Middy
18
18
  - HTTP middleware with validation, shaping, errors, CORS, negotiation, and HEAD
19
19
  - Non‑HTTP flows stay lean (no middleware overhead)
20
20
 
21
- ## Quick links
21
+ ## Quick start (from zero)
22
+
23
+ From an empty directory:
24
+
25
+ ```bash
26
+ npx @karmaniverous/smoz init -i
27
+ npx smoz dev -p 3000
28
+ ```
29
+
30
+ - The first command scaffolds a new app and installs dependencies (including a local `smoz` bin).
31
+ - The second command starts the inline local backend and keeps registers + OpenAPI fresh.
32
+ - Open http://localhost:3000/openapi in your browser.
22
33
 
34
+ Prefer `serverless‑offline`?
35
+
36
+ ```bash
37
+ npx smoz dev -l offline -p 3000
38
+ ```
39
+
40
+ Add your first endpoint (avoid clashing with the template’s hello):
41
+
42
+ ```bash
43
+ npx smoz add rest/foo/get
44
+ ```
45
+
46
+ ## Quick links
47
+
23
48
  - [Overview](https://docs.karmanivero.us/smoz/documents/Overview.html)
24
49
  - [Why smoz?](https://docs.karmanivero.us/smoz/documents/Why_smoz_.html)
25
50
  - [Getting started](https://docs.karmanivero.us/smoz/documents/Getting_started.html)
26
51
  - [10-minute tour](https://docs.karmanivero.us/smoz/documents/10%E2%80%91minute_tour.html)
27
- - [HTTP MIddleware](https://docs.karmanivero.us/smoz/documents/HTTP_middleware.html)
52
+ - [HTTP Middleware](https://docs.karmanivero.us/smoz/documents/HTTP_middleware.html)
28
53
  - [Recipes](https://docs.karmanivero.us/smoz/documents/Recipes.html)
29
54
  - [SQS function](https://docs.karmanivero.us/smoz/documents/Recipes.SQS_function.html)
30
- - [Contexts + Cognito authorizer](https://docs.karmanivero.us/smoz/documents/Recipes.Contexts_+_Cognito_authorizer.html)
31
- - [Custom middleware insertion](https://docs.karmanivero.us/smoz/documents/Recipes.Custom_middleware_insertion.html)
55
+ - [Contexts + Cognito authorizer](https://docs.karmanivero.us/smoz/documents/Recipes.Contexts_+_Cognito_authorizer.html) - [Custom middleware insertion](https://docs.karmanivero.us/smoz/documents/Recipes.Custom_middleware_insertion.html)
32
56
  - [Per‑function env](<https://docs.karmanivero.us/smoz/documents/Recipes.Per%E2%80%91function_env_(fnEnvKeys).html>)
33
57
  - [Observability](<https://docs.karmanivero.us/smoz/documents/Recipes.Observability_(requestId_header).html>)
34
58
  - [Troubleshooting](https://docs.karmanivero.us/smoz/documents/Recipes.Troubleshooting.html)
@@ -9,6 +9,8 @@ var node_url = require('node:url');
9
9
  var chokidar = require('chokidar');
10
10
  var node_child_process = require('node:child_process');
11
11
  var os = require('node:os');
12
+ var node_process = require('node:process');
13
+ var promises = require('node:readline/promises');
12
14
 
13
15
  var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
14
16
  /**
@@ -790,17 +792,6 @@ const launchInline = async (root, opts) => {
790
792
  return { close, restart };
791
793
  };
792
794
 
793
- /**
794
- * smoz init
795
- *
796
- * Scaffolds a new project from packaged templates.
797
- * - Copies ./templates/project/ into the target root (shared boilerplate)
798
- * - Copies ./templates/<template>/ into the target root (default: minimal)
799
- * - Seeds app/generated/register.*.ts (empty modules) if missing * - 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
795
  const writeIfAbsent = async (outFile, content) => {
805
796
  if (fs.existsSync(outFile))
806
797
  return { created: false };
@@ -821,15 +812,6 @@ const walk = async (dir, out = []) => {
821
812
  }
822
813
  return out;
823
814
  };
824
- const resolveTemplatesBase = () => {
825
- // Resolve the templates folder from the CLI package install root,
826
- // not the caller’s project root. This makes --template <name> work
827
- // consistently whether smoz is run from a consuming app or this repo.
828
- // Anchor discovery to this module’s directory.
829
- const here = path.dirname(node_url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href))));
830
- const pkgRoot = packageDirectory.packageDirectorySync({ cwd: here }) ?? process.cwd(); // conservative fallback
831
- return path.resolve(pkgRoot, 'templates');
832
- };
833
815
  const readJson = async (file) => {
834
816
  try {
835
817
  const data = await fs.promises.readFile(file, 'utf8');
@@ -843,6 +825,79 @@ const writeJson = async (file, obj) => {
843
825
  await fs.promises.mkdir(path.dirname(file), { recursive: true });
844
826
  await fs.promises.writeFile(file, JSON.stringify(obj, null, 2), 'utf8');
845
827
  };
828
+
829
+ const askConflict = async (rl, filePath) => {
830
+ const q = `File exists: ${filePath}\n` +
831
+ `Choose: [o]verwrite, [e]xample, [s]kip, ` +
832
+ `[O]verwrite all, [E]xample all, [S]kip all: `;
833
+ const ans = (await rl.question(q)).trim();
834
+ if (/^o$/.test(ans))
835
+ return 'overwrite';
836
+ if (/^e$/.test(ans))
837
+ return 'example';
838
+ if (/^s$/.test(ans))
839
+ return 'skip';
840
+ if (/^O$/.test(ans))
841
+ return 'all-overwrite';
842
+ if (/^E$/.test(ans))
843
+ return 'all-example';
844
+ if (/^S$/.test(ans))
845
+ return 'all-skip';
846
+ return 'example';
847
+ };
848
+ const copyDirWithConflicts = async (srcDir, dstRoot, created, skipped, examples, opts) => {
849
+ const files = await walk(srcDir);
850
+ let sticky;
851
+ for (const abs of files) {
852
+ const rel = path.relative(srcDir, abs);
853
+ const dest = path.resolve(dstRoot, rel);
854
+ const data = await fs.promises.readFile(abs, 'utf8');
855
+ if (!fs.existsSync(dest)) {
856
+ const { created: c } = await writeIfAbsent(dest, data);
857
+ if (c)
858
+ created.push(path.posix.normalize(dest));
859
+ else
860
+ skipped.push(path.posix.normalize(dest));
861
+ continue;
862
+ }
863
+ // Conflict flow
864
+ let decision = opts.conflict === 'ask' ? 'example' : opts.conflict;
865
+ if (opts.conflict === 'ask' && opts.rl && !sticky) {
866
+ const ans = await askConflict(opts.rl, path.posix.normalize(dest));
867
+ if (ans === 'all-overwrite') {
868
+ sticky = 'overwrite';
869
+ }
870
+ else if (ans === 'all-example') {
871
+ sticky = 'example';
872
+ }
873
+ else if (ans === 'all-skip') {
874
+ sticky = 'skip';
875
+ }
876
+ else {
877
+ decision = ans;
878
+ }
879
+ }
880
+ if (sticky)
881
+ decision = sticky;
882
+ if (decision === 'overwrite') {
883
+ await fs.promises.mkdir(path.dirname(dest), { recursive: true });
884
+ await fs.promises.writeFile(dest, data, 'utf8');
885
+ created.push(path.posix.normalize(dest));
886
+ }
887
+ else if (decision === 'example') {
888
+ const ex = `${dest}.example`;
889
+ const { created: c } = await writeIfAbsent(ex, data);
890
+ if (c)
891
+ examples.push(path.posix.normalize(ex));
892
+ else
893
+ skipped.push(path.posix.normalize(ex));
894
+ }
895
+ else {
896
+ skipped.push(path.posix.normalize(dest));
897
+ }
898
+ }
899
+ };
900
+
846
901
  const detectPm = (root) => {
847
902
  if (fs.existsSync(path.join(root, 'pnpm-lock.yaml')))
848
903
  return 'pnpm';
@@ -869,7 +924,6 @@ const runInstall = (root, pm) => {
869
924
  const known = pm === 'pnpm' || pm === 'yarn' || pm === 'bun' || pm === 'npm';
870
925
  if (!known)
871
926
  return 'unknown-pm';
872
- // Spawn with explicit args; avoid tuple inference that widens types.
873
927
  const res = node_child_process.spawnSync(pm, ['install'], {
874
928
  stdio: 'inherit',
875
929
  cwd: root,
@@ -887,10 +941,10 @@ const runInstall = (root, pm) => {
887
941
  }
888
942
  return 'failed';
889
943
  };
944
+
890
945
  const mergeAdditive = (target, source) => {
891
946
  const merged = [];
892
947
  const mergeKey = (key) => {
893
- // Allow possibly-undefined shapes to satisfy lint (no-unnecessary-condition).
894
948
  const src = source[key] ?? {};
895
949
  const dst = target[key] ?? {};
896
950
  const out = { ...dst };
@@ -928,51 +982,122 @@ const mergeAdditive = (target, source) => {
928
982
  target.scripts = scriptsOut;
929
983
  return merged;
930
984
  };
931
- const copyDirIdempotent = async (srcDir, dstRoot, created, skipped, examples) => {
932
- const files = await walk(srcDir);
933
- for (const abs of files) {
934
- const rel = path.relative(srcDir, abs);
935
- const dest = path.resolve(dstRoot, rel);
936
- const data = await fs.promises.readFile(abs, 'utf8');
937
- if (fs.existsSync(dest)) {
938
- const ex = `${dest}.example`;
939
- if (!fs.existsSync(ex)) {
940
- await writeIfAbsent(ex, data);
941
- examples.push(path.posix.normalize(ex));
942
- }
943
- else {
944
- skipped.push(path.posix.normalize(dest));
945
- }
946
- }
947
- else {
948
- const { created: c } = await writeIfAbsent(dest, data);
949
- if (c)
950
- created.push(path.posix.normalize(dest));
951
- else
952
- skipped.push(path.posix.normalize(dest));
985
+ /**
986
+ * Ensure a runtime dependency on @karmaniverous/smoz is present in the
987
+ * target manifest, using a caret version derived from the toolkit package.
988
+ * Returns the merge descriptor string when added, else undefined.
989
+ */
990
+ const ensureToolkitDependency = async (targetPkg, templatesBase) => {
991
+ try {
992
+ const toolkitRoot = path.dirname(templatesBase);
993
+ const toolkitPkg = await readJson(path.join(toolkitRoot, 'package.json'));
994
+ const verRaw = toolkitPkg?.version?.trim();
995
+ const depVersion = verRaw ? `^${verRaw}` : '^0.0.0';
996
+ const deps = targetPkg.dependencies ?? {};
997
+ if (!deps['@karmaniverous/smoz']) {
998
+ targetPkg.dependencies ??=
999
+ {};
1000
+ targetPkg.dependencies['@karmaniverous/smoz'] = depVersion;
1001
+ return `dependencies:@karmaniverous/smoz@${depVersion}`;
953
1002
  }
954
1003
  }
1004
+ catch {
1005
+ // best-effort; ignore
1006
+ }
1007
+ return undefined;
1008
+ };
1009
+
1010
+ /**
1011
+ + * Resolve the packaged templates root from the CLI install location,
1012
+ * not the caller's project root. This makes -t <name> work both from
1013
+ * a consuming app and from this repository.
1014
+ + */
1015
+ const resolveTemplatesBase = () => {
1016
+ const here = path.dirname(node_url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href))));
1017
+ const pkgRoot = packageDirectory.packageDirectorySync({ cwd: here }) ?? process.cwd();
1018
+ return path.resolve(pkgRoot, 'templates');
1019
+ };
1020
+ const toPosix = (p) =>
1021
+ // local helper for path presentation (not used for FS operations)
1022
+ p.replace(/\\/g, '/');
1023
+
1024
+ const seedRegisterPlaceholders = async (root) => {
1025
+ const genDir = path.join(root, 'app', 'generated');
1026
+ const seeds = [
1027
+ {
1028
+ path: path.join(genDir, 'register.functions.ts'),
1029
+ content: '/* AUTO-GENERATED placeholder; will be rewritten by `smoz register` */\nexport {};\n',
1030
+ },
1031
+ {
1032
+ path: path.join(genDir, 'register.openapi.ts'),
1033
+ content: '/* AUTO-GENERATED placeholder; will be rewritten by `smoz register` */\nexport {};\n',
1034
+ },
1035
+ {
1036
+ path: path.join(genDir, 'register.serverless.ts'),
1037
+ content: '/* AUTO-GENERATED placeholder; will be rewritten by `smoz register` */\nexport {};\n',
1038
+ },
1039
+ ];
1040
+ const created = [];
1041
+ const skipped = [];
1042
+ for (const s of seeds) {
1043
+ const { created: c } = await writeIfAbsent(s.path, s.content);
1044
+ (c ? created : skipped).push(path.posix.normalize(s.path));
1045
+ }
1046
+ return { created, skipped };
955
1047
  };
956
- const runInit = async (root, template = 'minimal', opts) => {
1048
+
1049
+ const toPosixSep = (p) => p.split(path.sep).join('/');
1050
+ const runInit = async (root, template = 'default', opts) => {
957
1051
  const created = [];
958
1052
  const skipped = [];
959
1053
  const examples = [];
960
1054
  const merged = [];
1055
+ const optAll = opts ?? {};
961
1056
  const templatesBase = resolveTemplatesBase();
962
- const srcBase = path.resolve(templatesBase, template);
1057
+ // Resolve template source: named template or filesystem path
1058
+ const templateIsPath = fs.existsSync(template) && (await fs.promises.stat(template)).isDirectory();
1059
+ const srcBase = templateIsPath
1060
+ ? path.resolve(template)
1061
+ : path.resolve(templatesBase, template);
963
1062
  const projectBase = path.resolve(templatesBase, 'project');
964
1063
  if (!fs.existsSync(srcBase)) {
965
- throw new Error(`Template "${template}" not found under ${toPosix(templatesBase)}.`);
1064
+ throw new Error(`Template "${template}" not found (path or name). Tried: ${toPosix(srcBase)}.`);
966
1065
  }
967
- // 1) Copy shared boilerplate (project) first (idempotent)
1066
+ // 1) Copy shared project boilerplate
968
1067
  if (fs.existsSync(projectBase)) {
969
- await copyDirIdempotent(projectBase, root, created, skipped, examples);
1068
+ const rl = optAll.yes === true
1069
+ ? undefined
1070
+ : promises.createInterface({ input: node_process.stdin, output: node_process.stdout, terminal: true });
1071
+ let policy;
1072
+ const c = optAll.conflict;
1073
+ if (c === 'overwrite' || c === 'example' || c === 'skip' || c === 'ask')
1074
+ policy = c;
1075
+ else
1076
+ policy = optAll.yes ? 'example' : 'ask';
1077
+ const copyOpts = rl
1078
+ ? { conflict: policy, rl }
1079
+ : { conflict: policy };
1080
+ await copyDirWithConflicts(projectBase, root, created, skipped, examples, copyOpts);
1081
+ rl?.close();
970
1082
  }
971
1083
  // 2) Copy selected template
972
- await copyDirIdempotent(srcBase, root, created, skipped, examples);
973
- // 2.5) Convert template 'gitignore' into real '.gitignore'
974
- // NPM often excludes '.gitignore' from published packages; shipping 'gitignore'
975
- // and converting here ensures downstream projects get a proper .gitignore.
1084
+ {
1085
+ const rl = optAll.yes === true
1086
+ ? undefined
1087
+ : promises.createInterface({ input: node_process.stdin, output: node_process.stdout, terminal: true });
1088
+ let policy;
1089
+ const c = optAll.conflict;
1090
+ if (c === 'overwrite' || c === 'example' || c === 'skip' || c === 'ask')
1091
+ policy = c;
1092
+ else
1093
+ policy = optAll.yes ? 'example' : 'ask';
1094
+ const copyOpts = rl
1095
+ ? { conflict: policy, rl }
1096
+ : { conflict: policy };
1097
+ await copyDirWithConflicts(srcBase, root, created, skipped, examples, copyOpts);
1098
+ rl?.close();
1099
+ }
1100
+ // 2.5) Convert template 'gitignore' into a real '.gitignore'
976
1101
  try {
977
1102
  const giSrc = path.join(root, 'gitignore');
978
1103
  const giDot = path.join(root, '.gitignore');
@@ -982,8 +1107,6 @@ const runInit = async (root, template = 'minimal', opts) => {
982
1107
  created.push(path.posix.normalize(giDot));
983
1108
  }
984
1109
  else {
985
- // Both exist: preserve the template as an example (if not already present),
986
- // then remove the extra 'gitignore' to avoid clutter.
987
1110
  const example = path.join(root, 'gitignore.example');
988
1111
  if (!fs.existsSync(example)) {
989
1112
  const data = await fs.promises.readFile(giSrc, 'utf8');
@@ -995,76 +1118,63 @@ const runInit = async (root, template = 'minimal', opts) => {
995
1118
  }
996
1119
  }
997
1120
  catch {
998
- // best-effort; ignore conversion errors
1121
+ // best-effort
999
1122
  }
1000
- // Seed app/generated/register.*.ts (empty modules) if missing
1001
- const genDir = path.resolve(root, 'app', 'generated');
1002
- const seeds = [
1003
- {
1004
- path: path.join(genDir, 'register.functions.ts'),
1005
- content: `/* AUTO-GENERATED placeholder; will be rewritten by \`smoz register\` */\nexport {};\n`,
1006
- },
1007
- {
1008
- path: path.join(genDir, 'register.openapi.ts'),
1009
- content: `/* AUTO-GENERATED placeholder; will be rewritten by \`smoz register\` */\nexport {};\n`,
1010
- },
1011
- {
1012
- path: path.join(genDir, 'register.serverless.ts'),
1013
- content: `/* AUTO-GENERATED placeholder; will be rewritten by \`smoz register\` */\nexport {};\n`,
1014
- },
1015
- ];
1016
- for (const s of seeds) {
1017
- const { created: c } = await writeIfAbsent(s.path, s.content);
1018
- if (c)
1019
- created.push(path.posix.normalize(s.path));
1020
- else
1021
- skipped.push(path.posix.normalize(s.path));
1123
+ // Seed app/generated/register.* placeholders
1124
+ {
1125
+ const res = await seedRegisterPlaceholders(root);
1126
+ created.push(...res.created);
1127
+ skipped.push(...res.skipped);
1022
1128
  }
1023
- // 3) package.json presence (guard or create with --init)
1129
+ // 3) package.json presence (create when missing)
1024
1130
  const pkgPath = path.join(root, 'package.json');
1025
1131
  let pkg = await readJson(pkgPath);
1026
1132
  if (!pkg) {
1027
- const shouldInit = !!(opts && opts.init);
1028
- if (shouldInit) {
1029
- const name = toPosix(root).split('/').pop() ?? 'smoz-app';
1030
- pkg = {
1031
- name,
1032
- private: true,
1033
- type: 'module',
1034
- version: '0.0.0',
1035
- scripts: {},
1036
- };
1037
- // Avoid optional chain to satisfy no-unnecessary-condition; normalize to boolean.
1038
- const dryRunCreate = !!opts.dryRun;
1039
- if (!dryRunCreate)
1040
- await writeJson(pkgPath, pkg);
1041
- created.push(path.posix.normalize(pkgPath));
1042
- }
1043
- else {
1044
- throw new Error('No package.json found. Run "npm init -y" (or re-run with --init) and then "smoz init" again.');
1045
- }
1133
+ const name = toPosixSep(root).split('/').pop() ?? 'smoz-app';
1134
+ pkg = {
1135
+ name,
1136
+ private: true,
1137
+ type: 'module',
1138
+ version: '0.0.0',
1139
+ scripts: {},
1140
+ };
1141
+ if (!optAll.dryRun)
1142
+ await writeJson(pkgPath, pkg);
1143
+ created.push(path.posix.normalize(pkgPath));
1046
1144
  }
1047
- // 4) Merge manifest (deps/devDeps/scripts) additively
1048
- const manifestPath = path.resolve(templatesBase, '.manifests', `package.${template}.json`);
1049
- const manifest = await readJson(manifestPath);
1145
+ // 4) Merge manifest additively (prefer template's embedded package.json)
1146
+ const templatePkgPath = path.resolve(srcBase, 'package.json');
1147
+ const manifest = fs.existsSync(templatePkgPath)
1148
+ ? await readJson(templatePkgPath)
1149
+ : await readJson(path.resolve(templatesBase, '.manifests', `package.${template}.json`));
1150
+ let pkgChanged = false;
1050
1151
  if (manifest) {
1051
- const before = JSON.stringify(pkg);
1052
1152
  const added = mergeAdditive(pkg, manifest);
1053
- merged.push(...added);
1054
- const dryRun = !!(opts && opts.dryRun);
1055
- if (!dryRun && before !== JSON.stringify(pkg)) {
1056
- await writeJson(pkgPath, pkg);
1153
+ if (added.length > 0) {
1154
+ pkgChanged = true;
1155
+ merged.push(...added);
1156
+ }
1157
+ }
1158
+ // 4.5) Ensure runtime dependency on @karmaniverous/smoz is present
1159
+ {
1160
+ const injected = await ensureToolkitDependency(pkg, templatesBase);
1161
+ if (injected) {
1162
+ merged.push(injected);
1163
+ pkgChanged = true;
1057
1164
  }
1058
1165
  }
1166
+ if (!optAll.dryRun && pkgChanged)
1167
+ await writeJson(pkgPath, pkg);
1059
1168
  // 5) Optional install
1060
1169
  let installed = 'skipped';
1061
- const installOpt = opts ? opts.install : false;
1062
- // Derive package manager to use (explicit string or detected when true),
1063
- // then set installed based on presence of a PM.
1170
+ // Policy:
1171
+ // -y implies auto install unless --no-install
1172
+ const installOpt = optAll.install ?? false;
1173
+ const impliedAuto = optAll.yes === true && optAll.noInstall !== true && installOpt !== false;
1064
1174
  const hasInstallString = typeof installOpt === 'string' && installOpt.trim() !== '';
1065
1175
  const pm = hasInstallString
1066
1176
  ? installOpt
1067
- : installOpt === true
1177
+ : installOpt === true || impliedAuto
1068
1178
  ? detectPm(root)
1069
1179
  : undefined;
1070
1180
  installed = pm ? runInstall(root, pm) : installed;
@@ -1089,6 +1199,22 @@ const readPkg = (root) => {
1089
1199
  return {};
1090
1200
  }
1091
1201
  };
1202
+ const readSmozConfig = (root) => {
1203
+ try {
1204
+ const p = path.join(root, 'smoz.config.json');
1205
+ if (!fs.existsSync(p))
1206
+ return {};
1207
+ const raw = fs.readFileSync(p, 'utf8');
1208
+ const parsed = JSON.parse(raw);
1209
+ if (parsed && typeof parsed === 'object') {
1210
+ return parsed;
1211
+ }
1212
+ return {};
1213
+ }
1214
+ catch {
1215
+ return {};
1216
+ }
1217
+ };
1092
1218
  const detectPackageManager = () => {
1093
1219
  const ua = process.env.npm_config_user_agent ?? '';
1094
1220
  if (ua.includes('pnpm'))
@@ -1134,7 +1260,8 @@ const main = () => {
1134
1260
  program
1135
1261
  .name('smoz')
1136
1262
  .description('SMOZ CLI')
1137
- .version(pkg.version ?? '0.0.0');
1263
+ // Add -v alias for version in addition to default --version behavior
1264
+ .version(pkg.version ?? '0.0.0', '-v, --version', 'output the version');
1138
1265
  program
1139
1266
  .command('add')
1140
1267
  .argument('<spec>', 'Add function: HTTP <eventType>/<segments...>/<method> or non-HTTP <eventType>/<segments...>')
@@ -1155,16 +1282,46 @@ const main = () => {
1155
1282
  });
1156
1283
  program
1157
1284
  .command('init')
1158
- .description('Scaffold a new SMOZ app from packaged templates (default: minimal)')
1159
- .option('--template <name>', 'Template name (minimal|full)', 'minimal')
1160
- .option('--init', 'Create a minimal package.json if missing')
1285
+ .description('Scaffold a new SMOZ app from packaged templates (default: default)')
1286
+ .option('-t, --template <nameOrPath>', 'Template name or directory path', 'default')
1161
1287
  .option('-i, --install [pm]', 'Install dependencies (optionally specify pm: npm|pnpm|yarn|bun)')
1162
- .option('--yes', 'Skip prompts (non-interactive)', false)
1288
+ .option('--no-install', 'Skip dependency installation (overrides -y)', false)
1289
+ .option('-y, --yes', 'Skip prompts (non-interactive)', false)
1163
1290
  .option('--dry-run', 'Show planned actions without writing', false)
1164
1291
  .action(async (opts) => {
1165
1292
  try {
1166
- const tpl = typeof opts.template === 'string' ? opts.template : 'minimal';
1167
- const { created, skipped, examples, merged, installed } = await runInit(root, tpl, opts);
1293
+ const cfg = readSmozConfig(root).cliDefaults?.init ?? {};
1294
+ // Resolve template: CLI > config > default
1295
+ const tpl = typeof opts.template === 'string'
1296
+ ? opts.template
1297
+ : typeof cfg.template === 'string'
1298
+ ? cfg.template
1299
+ : 'default';
1300
+ // Resolve install behavior: CLI > --no-install > config
1301
+ let install = opts.install;
1302
+ if (opts.noInstall === true) {
1303
+ install = false;
1304
+ }
1305
+ else if (install === undefined) {
1306
+ const d = cfg.install;
1307
+ install =
1308
+ d === 'auto'
1309
+ ? true
1310
+ : d === 'none'
1311
+ ? false
1312
+ : typeof d === 'string'
1313
+ ? d
1314
+ : undefined;
1315
+ }
1316
+ const conflict = typeof opts.conflict === 'string' ? opts.conflict : cfg.onConflict;
1317
+ const { created, skipped, examples, merged, installed } = await runInit(root, tpl, {
1318
+ // Include only when defined to satisfy exactOptionalPropertyTypes
1319
+ ...(install !== undefined ? { install } : {}),
1320
+ ...(typeof conflict === 'string' ? { conflict } : {}),
1321
+ yes: opts.yes === true,
1322
+ noInstall: opts.noInstall === true,
1323
+ dryRun: opts.dryRun === true,
1324
+ });
1168
1325
  console.log([
1169
1326
  created.length
1170
1327
  ? `Created:\n - ${created.join('\n - ')}`
@@ -1220,14 +1377,17 @@ const main = () => {
1220
1377
  .option('-v, --verbose', 'Verbose logging', false)
1221
1378
  .action(async (opts) => {
1222
1379
  try {
1380
+ const cfg = readSmozConfig(root).cliDefaults?.dev ?? {};
1381
+ // Resolve local mode default from config if not provided
1382
+ const localResolved = typeof opts.local === 'string'
1383
+ ? opts.local
1384
+ : opts.local === false
1385
+ ? false
1386
+ : (cfg.local ?? 'inline');
1223
1387
  await runDev(root, {
1224
1388
  register: opts.register !== false,
1225
1389
  openapi: opts.openapi !== false,
1226
- local: typeof opts.local === 'string'
1227
- ? opts.local
1228
- : opts.local === false
1229
- ? false
1230
- : 'inline',
1390
+ local: localResolved,
1231
1391
  ...(typeof opts.stage === 'string' ? { stage: opts.stage } : {}),
1232
1392
  port: opts.port ?? 0,
1233
1393
  verbose: !!opts.verbose,
package/package.json CHANGED
@@ -13,7 +13,6 @@
13
13
  "url": "https://github.com/karmaniverous/smoz/issues"
14
14
  },
15
15
  "dependencies": {
16
- "@karmaniverous/cached-axios": "^0.2.0",
17
16
  "@middy/core": "^6.4.5",
18
17
  "@middy/http-content-negotiation": "^6.4.5",
19
18
  "@middy/http-cors": "^6.4.5",
@@ -24,11 +23,11 @@
24
23
  "@middy/http-response-serializer": "^6.4.5",
25
24
  "aws-lambda": "^1.0.7",
26
25
  "chokidar": "^4.0.3",
27
- "commander": "^14.0.0",
26
+ "commander": "^14.0.1",
28
27
  "http-errors": "^2.0.0",
29
28
  "package-directory": "^8.1.0",
30
29
  "radash": "^12.1.1",
31
- "zod": "^4.1.6"
30
+ "zod": "^4.1.8"
32
31
  },
33
32
  "description": "John Galt Services back end.",
34
33
  "devDependencies": {
@@ -48,8 +47,7 @@
48
47
  "eslint-plugin-simple-import-sort": "^12.1.1",
49
48
  "fs-extra": "^11.3.1",
50
49
  "knip": "^5.63.1",
51
- "lefthook": "^1.12.4",
52
- "orval": "^7.11.2",
50
+ "lefthook": "^1.13.0",
53
51
  "pkg-dir": "^9.0.0",
54
52
  "prettier": "^3.6.2",
55
53
  "release-it": "^19.0.4",
@@ -167,7 +165,6 @@
167
165
  "docs": "typedoc",
168
166
  "domain:create": "serverless create_domain",
169
167
  "domain:delete": "serverless delete_domain",
170
- "generate": "cd services/activecampaign && orval",
171
168
  "knip": "knip",
172
169
  "lint": "eslint .",
173
170
  "lint:fix": "eslint --fix .",
@@ -182,8 +179,8 @@
182
179
  "test": "vitest run",
183
180
  "typecheck": "tsx src/cli/index.ts register && tsc -p tsconfig.json --noEmit",
184
181
  "templates:typecheck": "tsx scripts/templates-typecheck.ts",
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"
182
+ "templates:lint": "eslint --fix -c templates/default/eslint.config.ts \"templates/default/**/*.{ts,tsx,js,jsx}\" && eslint --fix templates/default/eslint.config.ts"
186
183
  },
187
184
  "type": "module",
188
- "version": "0.1.8"
185
+ "version": "0.2.1"
189
186
  }