local-serverless-stack 0.17.1 → 1.0.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.
Files changed (222) hide show
  1. package/CHANGELOG.md +327 -0
  2. package/README.md +1157 -224
  3. package/assets/icon.svg +37 -0
  4. package/assets/logo.svg +38 -0
  5. package/bin/cli.js +384 -217
  6. package/bin/i18n.js +498 -0
  7. package/dist/client/config-discovery.d.ts.map +1 -1
  8. package/dist/client/config-discovery.js +2 -1
  9. package/dist/client/config-discovery.js.map +1 -1
  10. package/dist/client/http.d.ts +7 -0
  11. package/dist/client/http.d.ts.map +1 -1
  12. package/dist/client/http.js +3 -2
  13. package/dist/client/http.js.map +1 -1
  14. package/dist/client/lifecycle.d.ts +1 -4
  15. package/dist/client/lifecycle.d.ts.map +1 -1
  16. package/dist/client/lifecycle.js +2 -8
  17. package/dist/client/lifecycle.js.map +1 -1
  18. package/dist/client/namespaces/health.d.ts +1 -1
  19. package/dist/client/namespaces/health.d.ts.map +1 -1
  20. package/dist/client/namespaces/queues.d.ts.map +1 -1
  21. package/dist/client/namespaces/queues.js +9 -0
  22. package/dist/client/namespaces/queues.js.map +1 -1
  23. package/dist/client/namespaces/seeds.d.ts +1 -1
  24. package/dist/client/namespaces/services.d.ts +34 -0
  25. package/dist/client/namespaces/services.d.ts.map +1 -1
  26. package/dist/client/namespaces/services.js +3 -0
  27. package/dist/client/namespaces/services.js.map +1 -1
  28. package/dist/client/types.d.ts +17 -5
  29. package/dist/client/types.d.ts.map +1 -1
  30. package/dist/mcp/http.d.ts +15 -0
  31. package/dist/mcp/http.d.ts.map +1 -0
  32. package/dist/mcp/http.js +86 -0
  33. package/dist/mcp/http.js.map +1 -0
  34. package/dist/mcp/protocol.d.ts +36 -0
  35. package/dist/mcp/protocol.d.ts.map +1 -0
  36. package/dist/mcp/protocol.js +96 -0
  37. package/dist/mcp/protocol.js.map +1 -0
  38. package/dist/mcp/server.d.ts +7 -0
  39. package/dist/mcp/server.d.ts.map +1 -0
  40. package/dist/mcp/server.js +41 -0
  41. package/dist/mcp/server.js.map +1 -0
  42. package/dist/mcp/tools.d.ts +18 -0
  43. package/dist/mcp/tools.d.ts.map +1 -0
  44. package/dist/mcp/tools.js +323 -0
  45. package/dist/mcp/tools.js.map +1 -0
  46. package/dist/server/dev/dynamo-proxy.d.ts.map +1 -1
  47. package/dist/server/dev/dynamo-proxy.js +16 -2
  48. package/dist/server/dev/dynamo-proxy.js.map +1 -1
  49. package/dist/server/engine/backends/self-backend.d.ts +11 -2
  50. package/dist/server/engine/backends/self-backend.d.ts.map +1 -1
  51. package/dist/server/engine/backends/self-backend.js +42 -1
  52. package/dist/server/engine/backends/self-backend.js.map +1 -1
  53. package/dist/server/engine/dispatch/dispatcher.js +2 -2
  54. package/dist/server/engine/dispatch/dispatcher.js.map +1 -1
  55. package/dist/server/engine/engine-manager.d.ts +6 -1
  56. package/dist/server/engine/engine-manager.d.ts.map +1 -1
  57. package/dist/server/engine/engine-manager.js +30 -27
  58. package/dist/server/engine/engine-manager.js.map +1 -1
  59. package/dist/server/engine/http/is-aws-request.d.ts +8 -0
  60. package/dist/server/engine/http/is-aws-request.d.ts.map +1 -0
  61. package/dist/server/engine/http/is-aws-request.js +53 -0
  62. package/dist/server/engine/http/is-aws-request.js.map +1 -0
  63. package/dist/server/engine/http/router.d.ts.map +1 -1
  64. package/dist/server/engine/http/router.js +7 -1
  65. package/dist/server/engine/http/router.js.map +1 -1
  66. package/dist/server/engine/store/engine-store.d.ts +1 -0
  67. package/dist/server/engine/store/engine-store.d.ts.map +1 -1
  68. package/dist/server/engine/store/engine-store.js +2 -1
  69. package/dist/server/engine/store/engine-store.js.map +1 -1
  70. package/dist/server/engine/store/memory-store.d.ts +16 -0
  71. package/dist/server/engine/store/memory-store.d.ts.map +1 -0
  72. package/dist/server/engine/store/memory-store.js +167 -0
  73. package/dist/server/engine/store/memory-store.js.map +1 -0
  74. package/dist/server/engine/store/wal.d.ts +3 -0
  75. package/dist/server/engine/store/wal.d.ts.map +1 -1
  76. package/dist/server/engine/store/wal.js +35 -1
  77. package/dist/server/engine/store/wal.js.map +1 -1
  78. package/dist/server/index.js +159 -44
  79. package/dist/server/index.js.map +1 -1
  80. package/dist/server/routes/config.d.ts.map +1 -1
  81. package/dist/server/routes/config.js +164 -15
  82. package/dist/server/routes/config.js.map +1 -1
  83. package/dist/server/routes/lambdas.d.ts.map +1 -1
  84. package/dist/server/routes/lambdas.js +65 -0
  85. package/dist/server/routes/lambdas.js.map +1 -1
  86. package/dist/server/routes/services.d.ts.map +1 -1
  87. package/dist/server/routes/services.js +275 -24
  88. package/dist/server/routes/services.js.map +1 -1
  89. package/dist/server/services/bind-host.d.ts +30 -0
  90. package/dist/server/services/bind-host.d.ts.map +1 -0
  91. package/dist/server/services/bind-host.js +182 -0
  92. package/dist/server/services/bind-host.js.map +1 -0
  93. package/dist/server/services/cache-manager.d.ts +2 -1
  94. package/dist/server/services/cache-manager.d.ts.map +1 -1
  95. package/dist/server/services/cache-manager.js +4 -14
  96. package/dist/server/services/cache-manager.js.map +1 -1
  97. package/dist/server/services/config-manager.d.ts +59 -35
  98. package/dist/server/services/config-manager.d.ts.map +1 -1
  99. package/dist/server/services/config-manager.js +787 -131
  100. package/dist/server/services/config-manager.js.map +1 -1
  101. package/dist/server/services/dynamo-explorer.d.ts +1 -0
  102. package/dist/server/services/dynamo-explorer.d.ts.map +1 -1
  103. package/dist/server/services/dynamo-explorer.js +28 -6
  104. package/dist/server/services/dynamo-explorer.js.map +1 -1
  105. package/dist/server/services/explorer-region.d.ts +2 -0
  106. package/dist/server/services/explorer-region.d.ts.map +1 -0
  107. package/dist/server/services/explorer-region.js +23 -0
  108. package/dist/server/services/explorer-region.js.map +1 -0
  109. package/dist/server/services/gateway-manager.d.ts.map +1 -1
  110. package/dist/server/services/gateway-manager.js +18 -5
  111. package/dist/server/services/gateway-manager.js.map +1 -1
  112. package/dist/server/services/invocation-activity.d.ts +45 -0
  113. package/dist/server/services/invocation-activity.d.ts.map +1 -0
  114. package/dist/server/services/invocation-activity.js +102 -0
  115. package/dist/server/services/invocation-activity.js.map +1 -0
  116. package/dist/server/services/lambda-runtime-manager.d.ts +7 -1
  117. package/dist/server/services/lambda-runtime-manager.d.ts.map +1 -1
  118. package/dist/server/services/lambda-runtime-manager.js +118 -10
  119. package/dist/server/services/lambda-runtime-manager.js.map +1 -1
  120. package/dist/server/services/opensearch-explorer.d.ts.map +1 -1
  121. package/dist/server/services/opensearch-explorer.js +14 -10
  122. package/dist/server/services/opensearch-explorer.js.map +1 -1
  123. package/dist/server/services/project-scope.d.ts +2 -0
  124. package/dist/server/services/project-scope.d.ts.map +1 -0
  125. package/dist/server/services/project-scope.js +23 -0
  126. package/dist/server/services/project-scope.js.map +1 -0
  127. package/dist/server/services/queue-inspector.d.ts.map +1 -1
  128. package/dist/server/services/queue-inspector.js +15 -6
  129. package/dist/server/services/queue-inspector.js.map +1 -1
  130. package/dist/server/services/resource-provisioner.d.ts +0 -2
  131. package/dist/server/services/resource-provisioner.d.ts.map +1 -1
  132. package/dist/server/services/resource-provisioner.js +24 -14
  133. package/dist/server/services/resource-provisioner.js.map +1 -1
  134. package/dist/server/services/s3-explorer.js +3 -3
  135. package/dist/server/services/s3-explorer.js.map +1 -1
  136. package/dist/server/services/secrets-explorer.d.ts.map +1 -1
  137. package/dist/server/services/secrets-explorer.js +10 -3
  138. package/dist/server/services/secrets-explorer.js.map +1 -1
  139. package/dist/server/services/seed-manager.d.ts.map +1 -1
  140. package/dist/server/services/seed-manager.js +17 -8
  141. package/dist/server/services/seed-manager.js.map +1 -1
  142. package/dist/server/services/serverless-state-parser.d.ts +10 -0
  143. package/dist/server/services/serverless-state-parser.d.ts.map +1 -1
  144. package/dist/server/services/serverless-state-parser.js +20 -1
  145. package/dist/server/services/serverless-state-parser.js.map +1 -1
  146. package/dist/server/services/service-registrar.d.ts.map +1 -1
  147. package/dist/server/services/service-registrar.js +19 -5
  148. package/dist/server/services/service-registrar.js.map +1 -1
  149. package/dist/server/services/service-scanner.d.ts +20 -0
  150. package/dist/server/services/service-scanner.d.ts.map +1 -0
  151. package/dist/server/services/service-scanner.js +173 -0
  152. package/dist/server/services/service-scanner.js.map +1 -0
  153. package/dist/server/services/source-watcher.d.ts.map +1 -1
  154. package/dist/server/services/source-watcher.js +31 -6
  155. package/dist/server/services/source-watcher.js.map +1 -1
  156. package/dist/ui/assets/ApisPage-BOuimwWz.js +1 -0
  157. package/dist/ui/assets/BucketDetailPage-vLYwhoQt.js +1 -0
  158. package/dist/ui/assets/BucketsPage-Cu5lU1Hp.js +1 -0
  159. package/dist/ui/assets/DynamoPage-y3TTEPUL.js +1 -0
  160. package/dist/ui/assets/DynamoTablePage-BnmSRZjc.js +3 -0
  161. package/dist/ui/assets/LambdaDetailPage-B5q_d27t.js +4 -0
  162. package/dist/ui/assets/LambdasPage-CJpRoEDx.js +1 -0
  163. package/dist/ui/assets/OnboardingPage-D02dymQi.js +1 -0
  164. package/dist/ui/assets/OpenSearchCollectionPage-B_wWTlfL.js +1 -0
  165. package/dist/ui/assets/OpenSearchPage-BLEgfF_g.js +1 -0
  166. package/dist/ui/assets/{OverviewPage-C7JlCyW1.css → OverviewPage-C1pBJ8aP.css} +1 -1
  167. package/dist/ui/assets/OverviewPage-EdJWiAt6.js +1 -0
  168. package/dist/ui/assets/QueueDetailPage-ByEYHvHe.js +1 -0
  169. package/dist/ui/assets/QueuesPage-CvwYHSll.js +1 -0
  170. package/dist/ui/assets/SecretsPage-DASw8Ntf.js +1 -0
  171. package/dist/ui/assets/ServiceDetailPage-BMsUDg6L.js +2 -0
  172. package/dist/ui/assets/ServicesPage-2JuiIiRr.js +2 -0
  173. package/dist/ui/assets/SettingsPage-DStO4bgN.js +1 -0
  174. package/dist/ui/assets/engine-D0mFFNbL.js +1 -0
  175. package/dist/ui/assets/index-BIEznsi4.js +37 -0
  176. package/dist/ui/assets/index-BRMvNP3Y.css +1 -0
  177. package/dist/ui/assets/onboarding-DQmlvyWU.js +1 -0
  178. package/dist/ui/assets/resourceIcons-baecADXA.js +1 -0
  179. package/dist/ui/assets/triggerIcons-DIPWQkJ3.js +1 -0
  180. package/dist/ui/favicon.svg +37 -0
  181. package/dist/ui/index.html +5 -3
  182. package/package.json +12 -12
  183. package/dist/server/engine/aoss-sidecar.d.ts +0 -13
  184. package/dist/server/engine/aoss-sidecar.d.ts.map +0 -1
  185. package/dist/server/engine/aoss-sidecar.js +0 -93
  186. package/dist/server/engine/aoss-sidecar.js.map +0 -1
  187. package/dist/server/engine/backends/localstack-backend.d.ts +0 -25
  188. package/dist/server/engine/backends/localstack-backend.d.ts.map +0 -1
  189. package/dist/server/engine/backends/localstack-backend.js +0 -199
  190. package/dist/server/engine/backends/localstack-backend.js.map +0 -1
  191. package/dist/server/engine/engine-backend.d.ts +0 -17
  192. package/dist/server/engine/engine-backend.d.ts.map +0 -1
  193. package/dist/server/engine/engine-backend.js +0 -7
  194. package/dist/server/engine/engine-backend.js.map +0 -1
  195. package/dist/server/services/localstack-manager.d.ts +0 -17
  196. package/dist/server/services/localstack-manager.d.ts.map +0 -1
  197. package/dist/server/services/localstack-manager.js +0 -31
  198. package/dist/server/services/localstack-manager.js.map +0 -1
  199. package/dist/ui/assets/ApisPage-9Lk0_ZLj.js +0 -1
  200. package/dist/ui/assets/BucketDetailPage-CdLGW5em.js +0 -1
  201. package/dist/ui/assets/BucketsPage-CDP95vp8.js +0 -1
  202. package/dist/ui/assets/DynamoPage-CV7omZpi.js +0 -1
  203. package/dist/ui/assets/DynamoTablePage-C8v42fy7.js +0 -3
  204. package/dist/ui/assets/LambdaDetailPage-DkGxePSD.js +0 -4
  205. package/dist/ui/assets/LambdasPage-Bg7_taxW.js +0 -1
  206. package/dist/ui/assets/OpenSearchCollectionPage-Ci1hHyy2.js +0 -1
  207. package/dist/ui/assets/OpenSearchPage-CBKqZeim.js +0 -1
  208. package/dist/ui/assets/OverviewPage-D8lrE9rQ.js +0 -1
  209. package/dist/ui/assets/QueueDetailPage-D0DSIGZ2.js +0 -1
  210. package/dist/ui/assets/QueuesPage-BXebf6st.js +0 -1
  211. package/dist/ui/assets/SecretsPage-Be2Nw9BM.js +0 -1
  212. package/dist/ui/assets/SecretsPage-D3XYogv-.css +0 -1
  213. package/dist/ui/assets/ServiceDetailPage-DZpczZ-G.js +0 -2
  214. package/dist/ui/assets/ServicesPage-CdzxEqs3.js +0 -2
  215. package/dist/ui/assets/_plugin-vue_export-helper-DlAUqK2U.js +0 -1
  216. package/dist/ui/assets/index-C1Wb35Y8.css +0 -1
  217. package/dist/ui/assets/index-CGpxa55R.js +0 -37
  218. package/packages/serverless-plugin/README.md +0 -115
  219. package/packages/serverless-plugin/dist/index.d.ts +0 -2
  220. package/packages/serverless-plugin/dist/index.d.ts.map +0 -1
  221. package/packages/serverless-plugin/dist/index.js +0 -107
  222. package/packages/serverless-plugin/dist/index.js.map +0 -1
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
+ import { projectCacheSegment } from './project-scope.js';
4
5
  // Symlink-stable spelling of a directory (e.g. macOS /tmp -> /private/tmp),
5
6
  // so the project identity matches the plugin's symlink-resolved process.cwd().
6
7
  function realpathOrSelf(dir) {
@@ -11,6 +12,506 @@ function realpathOrSelf(dir) {
11
12
  return dir;
12
13
  }
13
14
  }
15
+ // One port for the whole stack: the REST API, the dashboard and the AWS wire
16
+ // all answer here by default. Deliberately outside 4566–4599, which a real
17
+ // LocalStack install intercepts on some hosts (Docker Desktop/WSL2).
18
+ //
19
+ // Setting `serverPort` and `selfEngine.port` to DIFFERENT values splits them
20
+ // back onto two listeners — see isSingleListener().
21
+ const DEFAULT_PORT = 14566;
22
+ // `secrets` is deliberately absent (and rejected on write): it holds seed
23
+ // material that belongs in the config file or the environment, never in a
24
+ // payload that crosses the dashboard API.
25
+ const BLOCKED_CONFIG_KEYS = new Set(['secrets']);
26
+ // ---------------------------------------------------------------------------
27
+ // What a package command written through PUT /api/config may become
28
+ // ---------------------------------------------------------------------------
29
+ //
30
+ // A package command is handed to serverless-packager.ts, which tokenizes it and
31
+ // calls spawn(firstToken, restTokens) with `packageArgs` appended to the argv.
32
+ // PUT /api/config is unauthenticated, so an unconstrained command — or an
33
+ // unconstrained arg list — is arbitrary code execution on this host:
34
+ // `PUT /api/config {"packageCommand":"/bin/sh","packageArgs":["-c","…"]}`
35
+ // followed by `POST /api/services/package` runs whatever the caller wrote. That
36
+ // is the same class the /start and /install runner allowlists already close;
37
+ // this is the third door into it.
38
+ //
39
+ // Fencing only the command's FIRST token does NOT close it, and that version of
40
+ // this check was walked straight through: every package manager on the list is
41
+ // itself an interpreter, one subcommand in.
42
+ //
43
+ // npm exec -c '<shell string>' spawn('npm', ['exec','-c',…]) runs the string
44
+ // npx -c '<shell string>' as a shell command — no shell:true required,
45
+ // the payload is read straight off the argv
46
+ // npx -y <pkg> · npm exec -p <pkg> · yarn dlx · pnpm dlx fetch and run any package
47
+ // yarn node -e '<js>' · yarn exec · pnpm exec run an interpreter
48
+ // npm i <pkg> · yarn add <pkg> run a fetched package's install scripts
49
+ //
50
+ // Each of those satisfies "the first token names a package manager". So the
51
+ // fence is a grammar over the WHOLE command instead of a check on one token —
52
+ // the shapes below and nothing else:
53
+ //
54
+ // npm | yarn | pnpm run | run-script [script] [args…]
55
+ // serverless | sls | osls package [args…]
56
+ // npx [-y|--yes]… serverless|sls|osls[@version] package [args…]
57
+ //
58
+ // plus BLOCKED_PACKAGE_FLAGS applied to every token of the command AND to every
59
+ // `packageArgs` element, since those reach the same argv without passing
60
+ // through the tokenizer at all.
61
+ //
62
+ // RESIDUAL, stated plainly rather than papered over. `npm run <script>` still
63
+ // runs whatever the service's own package.json declares, and `serverless
64
+ // package` still reads the service's own serverless.yml (plugins included).
65
+ // Both are the project's own code, which the operator already trusts by
66
+ // pointing LSS at the directory; and forbidding them would mean the dashboard
67
+ // could not set a package command at all, which is what the onboarding flow is
68
+ // built on. What is gone is the part that made the /start and /install
69
+ // allowlists worthless: through this API a caller can no longer choose the
70
+ // program, only ask the project's own build to run.
71
+ //
72
+ // The fence is on the API and deliberately NOT on the read path — a
73
+ // hand-edited lss.config.json and LSS_PACKAGE_COMMAND are the operator's own
74
+ // shell, and anyone who can write either already runs code on this host.
75
+ /**
76
+ * Runner names accepted as a command's first token, in the order the error
77
+ * message lists them. No entry contains a path separator, so membership alone
78
+ * rejects `/bin/sh`, `/usr/bin/env`, `./tool` and `..\x.exe`.
79
+ */
80
+ const ALLOWED_PACKAGE_RUNNERS = [
81
+ 'npm', 'npx', 'yarn', 'pnpm', 'serverless', 'sls', 'osls',
82
+ ];
83
+ /**
84
+ * The subcommands each runner may be followed by — the wall the first-token
85
+ * check was missing. `run`/`run-script` execute a script the project declared;
86
+ * every other verb these CLIs expose either takes a command off the argv
87
+ * (`exec`, `x`, `node`, `dlx`) or fetches a package and runs its code (`add`,
88
+ * `install`, `create`, `plugin install`). The Serverless CLIs get `package` for
89
+ * the same reason: `serverless plugin install <pkg>` is a fetch-and-run, and
90
+ * packaging is the only thing this setting exists to do.
91
+ *
92
+ * A bare `yarn package` (yarn 1's implicit `run`) is rejected on purpose: the
93
+ * implicit form is exactly what makes `yarn node -e '<js>'` and `yarn dlx <pkg>`
94
+ * indistinguishable from a script name, so the explicit `yarn run package` is
95
+ * required instead. `npx` is absent because it is a launcher rather than a
96
+ * program — validatePackageCommand() strips it and validates what it launches.
97
+ */
98
+ const PACKAGE_RUNNER_VERBS = {
99
+ npm: ['run', 'run-script'],
100
+ yarn: ['run'],
101
+ pnpm: ['run'],
102
+ serverless: ['package'],
103
+ sls: ['package'],
104
+ osls: ['package'],
105
+ };
106
+ /**
107
+ * The packages `npx` may fetch and run. Restricted to the Serverless CLIs
108
+ * because that is the entire reason npx appears here — LSS's own default is
109
+ * `npx serverless package`, i.e. "run the Serverless CLI without a global
110
+ * install". Anything else npx can reach is a package of the caller's choosing.
111
+ */
112
+ const NPX_PACKAGES = ['serverless', 'sls', 'osls'];
113
+ /**
114
+ * The only flags tolerated between `npx` and the package name. `-y` suppresses
115
+ * the "install it?" prompt and does nothing else; every other npx flag either
116
+ * names a different package to run (`-p`/`--package`) or takes a command string
117
+ * (`-c`/`--call`), which is the bypass itself.
118
+ */
119
+ const NPX_PREFIX_FLAGS = ['-y', '--yes'];
120
+ /**
121
+ * Flags that make an otherwise-allowed command run something other than the
122
+ * project's own build, whichever runner is in front of them. The grammar above
123
+ * pins the program; these pin what that program is allowed to be pointed at:
124
+ *
125
+ * --node-options npm/yarn/pnpm/npx set NODE_OPTIONS on the child, which
126
+ * is `--require <file>` under another name (the same hole
127
+ * BLOCKED_PACKAGE_ENV_KEYS closes on the env side)
128
+ * --script-shell npm chooses the binary that interprets the script;
129
+ * --shell/--shell-mode are the yarn/pnpm spellings
130
+ * --call npm exec's inline command string
131
+ * --userconfig/--globalconfig/--use-yarnrc
132
+ * point the manager at another rc file, which can itself
133
+ * set script-shell, node-options or yarn 1's `yarn-path`
134
+ * --registry `npx serverless package` fetches `serverless` when the
135
+ * service has no local copy, and npm's arg parser reads
136
+ * its own config flags from anywhere in the argv — so a
137
+ * registry named after the package name still decides
138
+ * which code that fetch runs
139
+ * --prefix/--cwd/--dir run the scripts of a package.json somewhere else
140
+ * --eval/--print/--require/--import/--loader/--experimental-loader/--input-type
141
+ * node's own code-loading flags, refused in case any
142
+ * runner forwards its argv to node
143
+ *
144
+ * Deliberately absent: `--package` (npm exec's package selector) and every
145
+ * short flag. Both are already unreachable — `npm exec` is not an allowed verb
146
+ * and npx accepts no flag but `-y` — while `-c`, `-p` and `-r` are `--config`,
147
+ * `--package` and `--region` to the Serverless CLI, so blocking them would
148
+ * break `sls package -c custom.yml` for nothing.
149
+ *
150
+ * Compared with dashes and underscores stripped, so `--nodeOptions` and
151
+ * `--node_options` cannot spell their way past the set.
152
+ */
153
+ const BLOCKED_PACKAGE_FLAGS = new Set([
154
+ 'nodeoptions',
155
+ 'scriptshell',
156
+ 'shell',
157
+ 'shellmode',
158
+ 'call',
159
+ 'userconfig',
160
+ 'globalconfig',
161
+ 'useyarnrc',
162
+ 'registry',
163
+ 'prefix',
164
+ 'cwd',
165
+ 'dir',
166
+ 'eval',
167
+ 'print',
168
+ 'require',
169
+ 'import',
170
+ 'loader',
171
+ 'experimentalloader',
172
+ 'inputtype',
173
+ ]);
174
+ // Env vars that make the spawned child load attacker-chosen code no matter
175
+ // WHICH binary runs, so they walk straight around the runner allowlist above:
176
+ // NODE_OPTIONS `--require /tmp/x.js` into every node process
177
+ // NODE_REPL_EXTERNAL_MODULE loads a module into node ahead of the program
178
+ // LD_PRELOAD / LD_AUDIT inject a shared object into the dynamic linker
179
+ // LD_LIBRARY_PATH re-points the linker at attacker libraries
180
+ // DYLD_INSERT_LIBRARIES / DYLD_LIBRARY_PATH the macOS spellings of both
181
+ // PATH decides WHICH `npm` runs at all — see below
182
+ // NODE_PATH prepends a module search root for every require
183
+ // Stored lowercase and compared case-insensitively: env lookup is
184
+ // case-insensitive on Windows, so `node_options` would otherwise be the same
185
+ // bypass one keystroke away.
186
+ //
187
+ // PATH is the one that looks harmless and is not. `runServerlessPackage` spawns
188
+ // WITHOUT a shell, but a shell was never what resolved the binary: libuv runs
189
+ // execvp(3) after installing the child's environment, so the child's PATH — not
190
+ // the orchestrator's — decides which file named `npm` executes. Verified by
191
+ // spawning `npm` with a prepended directory: the planted binary ran. That makes
192
+ // PATH equivalent to naming an arbitrary command, which is exactly what
193
+ // ALLOWED_PACKAGE_RUNNERS exists to prevent. The packaging child inherits the
194
+ // orchestrator's PATH and no caller may replace it.
195
+ const BLOCKED_PACKAGE_ENV_KEYS = new Set([
196
+ 'node_options',
197
+ 'node_repl_external_module',
198
+ 'ld_preload',
199
+ 'ld_audit',
200
+ 'ld_library_path',
201
+ 'dyld_insert_libraries',
202
+ 'dyld_library_path',
203
+ 'path',
204
+ 'node_path',
205
+ ]);
206
+ const EDITABLE_CONFIG_KEYS = {
207
+ serverPort: { kind: 'port' },
208
+ enableDynamoProxy: { kind: 'boolean' },
209
+ dynamoProxyPort: { kind: 'port' },
210
+ region: { kind: 'string' },
211
+ persistence: { kind: 'boolean' },
212
+ debug: { kind: 'boolean' },
213
+ seedsDir: { kind: 'string' },
214
+ stateDir: { kind: 'string' },
215
+ autoPackage: { kind: 'boolean' },
216
+ packageCommand: { kind: 'packageCommand' },
217
+ packageArgs: { kind: 'packageArgs' },
218
+ packageEnv: { kind: 'envRecord' },
219
+ servicePackaging: {
220
+ kind: 'object',
221
+ entrySubKeys: {
222
+ // Same fences as the global keys above — a per-service override is spawned
223
+ // by the exact same code path, so validating only the global copy would
224
+ // leave the door open one nesting level down.
225
+ packageCommand: { kind: 'packageCommand' },
226
+ packageArgs: { kind: 'packageArgs' },
227
+ packageEnv: { kind: 'envRecord' },
228
+ packageTimeoutMs: { kind: 'positiveInt' },
229
+ },
230
+ },
231
+ packageTimeoutMs: { kind: 'positiveInt' },
232
+ lambdaRuntime: {
233
+ kind: 'object',
234
+ subKeys: {
235
+ enabled: { kind: 'boolean' },
236
+ execution: { kind: 'string', enum: ['auto', 'artifact', 'source'] },
237
+ watch: { kind: 'boolean' },
238
+ invokePortOffset: { kind: 'positiveInt' },
239
+ invokeHost: { kind: 'string' },
240
+ lazy: { kind: 'boolean' },
241
+ idleTimeoutMs: { kind: 'nonNegativeInt' },
242
+ maxWarmWorkers: { kind: 'nonNegativeInt' },
243
+ },
244
+ },
245
+ serviceRuntime: {
246
+ kind: 'object',
247
+ entrySubKeys: {
248
+ enabled: { kind: 'boolean' },
249
+ apiPort: { kind: 'port' },
250
+ invokePort: { kind: 'port' },
251
+ execution: { kind: 'string', enum: ['auto', 'artifact', 'source'] },
252
+ watch: { kind: 'boolean' },
253
+ },
254
+ },
255
+ selfEngine: {
256
+ kind: 'object',
257
+ subKeys: {
258
+ port: { kind: 'port' },
259
+ dataDir: { kind: 'string' },
260
+ account: { kind: 'string' },
261
+ idleUnloadMs: { kind: 'positiveInt' },
262
+ memoryBudgetMb: { kind: 'positiveInt' },
263
+ fsync: { kind: 'boolean' },
264
+ fallbackEndpoint: { kind: 'string' },
265
+ },
266
+ },
267
+ branding: {
268
+ kind: 'object',
269
+ subKeys: {
270
+ title: { kind: 'string' },
271
+ subtitle: { kind: 'string' },
272
+ logo: { kind: 'string' },
273
+ favicon: { kind: 'string' },
274
+ defaultTheme: { kind: 'string', enum: ['dark', 'light'] },
275
+ colors: { kind: 'stringRecord' },
276
+ themeColors: {
277
+ kind: 'object',
278
+ subKeys: {
279
+ dark: { kind: 'stringRecord' },
280
+ light: { kind: 'stringRecord' },
281
+ },
282
+ },
283
+ },
284
+ },
285
+ };
286
+ // Env vars that mask each file key while set (loadFromEnv applies them AFTER
287
+ // the file, and some getters fall back to env at call time). Surfaced so the
288
+ // UI can flag fields whose saved value will not take effect until the env var
289
+ // is unset.
290
+ const CONFIG_ENV_OVERRIDES = {
291
+ serverPort: ['LSS_DASHBOARD_PORT', 'PORT'],
292
+ // Note: the deprecated unprefixed ENABLE_DYNAMO_PROXY is NOT listed — it is
293
+ // only a getter-time fallback (isEnableDynamoProxy) that a file value always
294
+ // beats, so it never masks a saved value.
295
+ enableDynamoProxy: ['LSS_ENABLE_DYNAMO_PROXY'],
296
+ dynamoProxyPort: ['LSS_DYNAMO_PROXY_PORT'],
297
+ region: ['AWS_REGION'],
298
+ services: ['LSS_SERVICES'],
299
+ persistence: ['LSS_PERSISTENCE'],
300
+ debug: ['LSS_DEBUG'],
301
+ seedsDir: ['LSS_SEEDS_DIR'],
302
+ autoPackage: ['LSS_AUTO_PACKAGE'],
303
+ packageCommand: ['LSS_PACKAGE_COMMAND'],
304
+ packageTimeoutMs: ['LSS_PACKAGE_TIMEOUT_MS'],
305
+ lambdaRuntime: ['LSS_LAMBDA_RUNTIME', 'LSS_LAMBDA_EXECUTION', 'LSS_LAMBDA_WATCH', 'LSS_INVOKE_HOST'],
306
+ selfEngine: ['LSS_ENGINE_PORT', 'LSS_ENGINE_DATA_DIR'],
307
+ };
308
+ function isPlainObject(value) {
309
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
310
+ }
311
+ /**
312
+ * The argv a package command would actually spawn, tokenized EXACTLY the way
313
+ * parseCommand() in serverless-packager.ts does (same regex, same
314
+ * per-quoted-segment stripping). Mirroring it is the whole point — the tokens
315
+ * validated here must be the tokens handed to spawn(), or the check and the
316
+ * execution disagree and the grammar means nothing. `"/bin/sh"` resolves to
317
+ * `/bin/sh` (rejected) and `'npm'` to `npm` (accepted), in both places.
318
+ * Keep the two in step if the packager's tokenizer ever changes.
319
+ */
320
+ function packageCommandTokens(raw) {
321
+ const tokens = raw.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
322
+ return tokens.map(token => token.replace(/"([^"]*)"|'([^']*)'/g, '$1$2'));
323
+ }
324
+ /**
325
+ * Comparable spelling of a flag token: the name before any `=value`, lowercased
326
+ * with dashes and underscores removed (`--Node-Options=--require x` →
327
+ * `nodeoptions`). Only the name is compared — the value is what the flag would
328
+ * point at, and every value is dangerous once the name is.
329
+ */
330
+ function normalizeFlagName(token) {
331
+ return token.split('=')[0].toLowerCase().replace(/[-_]/g, '');
332
+ }
333
+ /**
334
+ * A token that redirects the command at a program, shell or rc file of the
335
+ * caller's choosing. Only tokens that look like flags are screened, so a script
336
+ * or file name that happens to collide (`npm run print`) is untouched.
337
+ */
338
+ function isBlockedPackageFlag(token) {
339
+ return token.startsWith('-') && BLOCKED_PACKAGE_FLAGS.has(normalizeFlagName(token));
340
+ }
341
+ function blockedFlagError(label, token) {
342
+ return `"${label}" cannot contain "${token}" — that flag points the package command at a program, shell or config file of the caller's choosing`;
343
+ }
344
+ /** `"run" or "run-script"` — verbs quoted the way the error message reads best. */
345
+ function quotedList(values) {
346
+ const quoted = values.map(value => `"${value}"`);
347
+ return quoted.length === 1 ? quoted[0] : `${quoted.slice(0, -1).join(', ')} or ${quoted[quoted.length - 1]}`;
348
+ }
349
+ /**
350
+ * Validate a `packageCommand` against the grammar documented above:
351
+ * runner → subcommand → free arguments, with `npx` stripped down to the
352
+ * Serverless CLI it launches, and no blocked flag anywhere in the argv.
353
+ */
354
+ function validatePackageCommand(key, raw) {
355
+ const tokens = packageCommandTokens(raw);
356
+ // Two cursors because `npx serverless package` has two layers: `rest` is the
357
+ // slice the grammar is currently reading (it loses the `npx …` prefix), while
358
+ // `runner` names the program that will finally execute and therefore whose
359
+ // subcommand list applies.
360
+ let rest = tokens;
361
+ let runner = rest[0] ?? '';
362
+ if (runner === 'npx') {
363
+ rest = rest.slice(1);
364
+ while (NPX_PREFIX_FLAGS.includes(rest[0])) {
365
+ rest = rest.slice(1);
366
+ }
367
+ const spec = rest[0] ?? '';
368
+ if (spec.startsWith('-')) {
369
+ return `"${key}": npx may carry only ${quotedList(NPX_PREFIX_FLAGS)} before the package name — "${spec}" can name another package to run, or a command string to run as a shell`;
370
+ }
371
+ // `serverless@3.38.0` is the same program with a version pin. The `@` at
372
+ // index 0 belongs to a scope (`@scope/pkg`), which is never in the list.
373
+ const at = spec.lastIndexOf('@');
374
+ runner = at > 0 ? spec.slice(0, at) : spec;
375
+ if (!NPX_PACKAGES.includes(runner)) {
376
+ return `"${key}": npx may only run the Serverless CLI (${NPX_PACKAGES.join(', ')}) — "${spec}" is a package of the caller's choosing`;
377
+ }
378
+ }
379
+ else if (!ALLOWED_PACKAGE_RUNNERS.includes(runner)) {
380
+ return `"${key}" must start with one of: ${ALLOWED_PACKAGE_RUNNERS.join(', ')} — "${runner}" is not an allowed package runner`;
381
+ }
382
+ const verbs = PACKAGE_RUNNER_VERBS[runner];
383
+ const verb = rest[1] ?? '';
384
+ if (!verbs.includes(verb)) {
385
+ return `"${key}": "${runner}" may only be followed by ${quotedList(verbs)} — `
386
+ + (verb
387
+ ? `"${verb}" can run a program the caller chose instead of the project's build`
388
+ : 'the command names no subcommand at all');
389
+ }
390
+ // Screens the whole argv, not just the tail: a blocked flag is blocked
391
+ // wherever it sits, and the runner/verb tokens never start with a dash.
392
+ const blocked = tokens.find(isBlockedPackageFlag);
393
+ return blocked === undefined ? null : blockedFlagError(key, blocked);
394
+ }
395
+ function validateValue(key, value, spec) {
396
+ switch (spec.kind) {
397
+ case 'port':
398
+ return Number.isInteger(value) && value >= 1 && value <= 65535
399
+ ? null
400
+ : `"${key}" must be an integer port between 1 and 65535`;
401
+ case 'positiveInt':
402
+ return Number.isInteger(value) && value > 0
403
+ ? null
404
+ : `"${key}" must be a positive integer`;
405
+ // 0 is meaningful for these (idleTimeoutMs: 0 = never unload).
406
+ case 'nonNegativeInt':
407
+ return Number.isInteger(value) && value >= 0
408
+ ? null
409
+ : `"${key}" must be an integer >= 0`;
410
+ case 'boolean':
411
+ return typeof value === 'boolean' ? null : `"${key}" must be a boolean`;
412
+ case 'string':
413
+ if (typeof value !== 'string' || value.length === 0) {
414
+ return `"${key}" must be a non-empty string`;
415
+ }
416
+ return spec.enum && !spec.enum.includes(value)
417
+ ? `"${key}" must be one of: ${spec.enum.join(', ')}`
418
+ : null;
419
+ // A command that is spawned, not just stored: on top of the non-empty string
420
+ // check it must match the package-command grammar. See PACKAGE_RUNNER_VERBS
421
+ // for why an unfenced string — or a first-token-only check — is remote code
422
+ // execution here, and for the residual the grammar does not close.
423
+ case 'packageCommand': {
424
+ if (typeof value !== 'string' || value.length === 0) {
425
+ return `"${key}" must be a non-empty string`;
426
+ }
427
+ return validatePackageCommand(key, value);
428
+ }
429
+ // Appended to the spawned argv verbatim, with no tokenizer in between, so
430
+ // `packageCommand:"npm"` + `packageArgs:["exec","-c","<shell>"]` would be
431
+ // the identical bypass one key across. The grammar above already pins the
432
+ // program and its subcommand; the flag screen is what stops an arg from
433
+ // re-pointing that program somewhere else.
434
+ case 'packageArgs': {
435
+ if (!Array.isArray(value) || !value.every(v => typeof v === 'string')) {
436
+ return `"${key}" must be an array of strings`;
437
+ }
438
+ const index = value.findIndex(isBlockedPackageFlag);
439
+ return index === -1 ? null : blockedFlagError(`${key}[${index}]`, value[index]);
440
+ }
441
+ case 'stringRecord':
442
+ case 'envRecord': {
443
+ if (!isPlainObject(value) || !Object.values(value).every(v => typeof v === 'string')) {
444
+ return `"${key}" must be an object of string values`;
445
+ }
446
+ // An env map is merged into the packaging child's environment, where a
447
+ // handful of names are loader/interpreter hooks: they run code in the
448
+ // child whatever program the command grammar let through, which would
449
+ // make that grammar decorative.
450
+ if (spec.kind === 'envRecord') {
451
+ for (const envKey of Object.keys(value)) {
452
+ if (BLOCKED_PACKAGE_ENV_KEYS.has(envKey.toLowerCase())) {
453
+ return `"${key}.${envKey}" cannot be set — it injects code into the packaging process`;
454
+ }
455
+ }
456
+ }
457
+ return null;
458
+ }
459
+ case 'object': {
460
+ if (!isPlainObject(value)) {
461
+ return `"${key}" must be an object`;
462
+ }
463
+ // null subvalues are always allowed — they delete the subkey on merge.
464
+ if (spec.subKeys) {
465
+ for (const [subKey, subValue] of Object.entries(value)) {
466
+ // hasOwnProperty, not a plain read: `constructor`/`toString` would
467
+ // otherwise resolve to an Object.prototype member, pass this guard
468
+ // and reach the file as unvalidated JSON.
469
+ const subSpec = Object.prototype.hasOwnProperty.call(spec.subKeys, subKey)
470
+ ? spec.subKeys[subKey]
471
+ : undefined;
472
+ if (!subSpec)
473
+ return `unknown config key "${key}.${subKey}"`;
474
+ if (subValue === null)
475
+ continue;
476
+ const error = validateValue(`${key}.${subKey}`, subValue, subSpec);
477
+ if (error)
478
+ return error;
479
+ }
480
+ }
481
+ if (spec.entrySubKeys) {
482
+ for (const [entryKey, entryValue] of Object.entries(value)) {
483
+ if (entryValue === null)
484
+ continue;
485
+ if (!isPlainObject(entryValue))
486
+ return `"${key}.${entryKey}" must be an object`;
487
+ for (const [subKey, subValue] of Object.entries(entryValue)) {
488
+ const subSpec = Object.prototype.hasOwnProperty.call(spec.entrySubKeys, subKey)
489
+ ? spec.entrySubKeys[subKey]
490
+ : undefined;
491
+ if (!subSpec)
492
+ return `unknown config key "${key}.${entryKey}.${subKey}"`;
493
+ if (subValue === null)
494
+ continue;
495
+ const error = validateValue(`${key}.${entryKey}.${subKey}`, subValue, subSpec);
496
+ if (error)
497
+ return error;
498
+ }
499
+ }
500
+ }
501
+ return null;
502
+ }
503
+ }
504
+ }
505
+ // Thrown by updateConfig so the route can answer 400 with every problem at
506
+ // once instead of failing on the first.
507
+ export class ConfigValidationError extends Error {
508
+ details;
509
+ constructor(details) {
510
+ super(details.join('; '));
511
+ this.name = 'ConfigValidationError';
512
+ this.details = details;
513
+ }
514
+ }
14
515
  export class ConfigManager {
15
516
  static instance;
16
517
  config = {};
@@ -46,15 +547,16 @@ export class ConfigManager {
46
547
  this.config = JSON.parse(content);
47
548
  this.configPath = candidate;
48
549
  console.log(`✅ Configuration loaded from ${candidate}`);
49
- break;
50
550
  }
51
551
  catch (error) {
52
552
  console.warn(`⚠️ Failed to parse config file ${candidate}:`, error instanceof Error ? error.message : 'Unknown error');
553
+ continue;
53
554
  }
555
+ break;
54
556
  }
55
557
  }
56
- // Environment variables override file values so secrets like LOCALSTACK_AUTH_TOKEN
57
- // can be injected without committing them to disk.
558
+ // Environment variables override file values, so an instance can be
559
+ // retargeted (ports, engine data dir, region) without touching the file.
58
560
  this.loadFromEnv();
59
561
  }
60
562
  loadFromEnv() {
@@ -63,33 +565,6 @@ export class ConfigManager {
63
565
  /* istanbul ignore next: the enclosing if guarantees one of these is truthy, so the `|| ''` fallback is unreachable */
64
566
  this.config.serverPort = parseInt(process.env.LSS_DASHBOARD_PORT || process.env.PORT || '', 10);
65
567
  }
66
- if (process.env.LSS_LOCALSTACK_PORT) {
67
- this.config.localstackPort = parseInt(process.env.LSS_LOCALSTACK_PORT, 10);
68
- }
69
- if (process.env.LSS_LOCALSTACK_ENDPOINT) {
70
- this.config.localstackEndpoint = process.env.LSS_LOCALSTACK_ENDPOINT;
71
- }
72
- if (process.env.LSS_LOCALSTACK_MODE) {
73
- const mode = process.env.LSS_LOCALSTACK_MODE.toLowerCase();
74
- if (mode === 'managed' || mode === 'external') {
75
- this.config.mode = mode;
76
- }
77
- }
78
- if (process.env.LSS_LOCALSTACK_EDITION) {
79
- const edition = process.env.LSS_LOCALSTACK_EDITION.toLowerCase();
80
- if (edition === 'community' || edition === 'pro') {
81
- this.config.localstackEdition = edition;
82
- }
83
- }
84
- if (process.env.LSS_LOCALSTACK_VERSION) {
85
- this.config.localstackVersion = process.env.LSS_LOCALSTACK_VERSION;
86
- }
87
- if (process.env.LSS_LOCALSTACK_IMAGE) {
88
- this.config.localstackImage = process.env.LSS_LOCALSTACK_IMAGE;
89
- }
90
- if (process.env.LOCALSTACK_AUTH_TOKEN) {
91
- this.config.localstackAuthToken = process.env.LOCALSTACK_AUTH_TOKEN;
92
- }
93
568
  if (process.env.LSS_ENABLE_DYNAMO_PROXY) {
94
569
  this.config.enableDynamoProxy = process.env.LSS_ENABLE_DYNAMO_PROXY === 'true' || process.env.LSS_ENABLE_DYNAMO_PROXY === '1';
95
570
  }
@@ -99,9 +574,6 @@ export class ConfigManager {
99
574
  if (process.env.AWS_REGION) {
100
575
  this.config.region = process.env.AWS_REGION;
101
576
  }
102
- if (process.env.LSS_SERVICES) {
103
- this.config.services = process.env.LSS_SERVICES.split(',');
104
- }
105
577
  if (process.env.LSS_PERSISTENCE) {
106
578
  this.config.persistence = process.env.LSS_PERSISTENCE === 'true' || process.env.LSS_PERSISTENCE === '1';
107
579
  }
@@ -147,61 +619,32 @@ export class ConfigManager {
147
619
  invokeHost: process.env.LSS_INVOKE_HOST,
148
620
  };
149
621
  }
150
- if (process.env.LSS_ENGINE) {
151
- const engine = process.env.LSS_ENGINE.toLowerCase();
152
- if (engine === 'localstack' || engine === 'self') {
153
- this.config.engine = engine;
154
- }
155
- }
156
622
  if (process.env.LSS_ENGINE_PORT) {
157
623
  this.config.selfEngine = {
158
624
  ...this.config.selfEngine,
159
625
  port: parseInt(process.env.LSS_ENGINE_PORT, 10),
160
626
  };
161
627
  }
628
+ // Give every instance its own engine state without editing a config file:
629
+ // `LSS_ENGINE_DATA_DIR=... LSS_ENGINE_PORT=... lss start` is enough to run a
630
+ // second stack (a test run, a second checkout) beside the dev one.
631
+ if (process.env.LSS_ENGINE_DATA_DIR) {
632
+ this.config.selfEngine = {
633
+ ...this.config.selfEngine,
634
+ dataDir: process.env.LSS_ENGINE_DATA_DIR,
635
+ };
636
+ }
162
637
  }
163
638
  getConfig() {
164
639
  return this.config;
165
640
  }
166
641
  getServerPort() {
167
- return this.config.serverPort ?? (parseInt(process.env.PORT || '', 10) || 3100);
642
+ return this.config.serverPort ?? (parseInt(process.env.PORT || '', 10) || DEFAULT_PORT);
168
643
  }
169
644
  // Alias for backward compatibility
170
645
  getDashboardPort() {
171
646
  return this.getServerPort();
172
647
  }
173
- getLocalStackPort() {
174
- return this.config.localstackPort ?? 4566;
175
- }
176
- getLocalStackEndpoint() {
177
- if (this.config.localstackEndpoint) {
178
- return this.config.localstackEndpoint;
179
- }
180
- const port = this.getLocalStackPort();
181
- return `http://localhost:${port}`;
182
- }
183
- getMode() {
184
- return this.config.mode ?? 'managed';
185
- }
186
- isExternal() {
187
- return this.getMode() === 'external';
188
- }
189
- getLocalStackEdition() {
190
- return this.config.localstackEdition ?? 'community';
191
- }
192
- getLocalStackVersion() {
193
- return this.config.localstackVersion ?? 'latest';
194
- }
195
- getLocalStackImage() {
196
- if (this.config.localstackImage) {
197
- return this.config.localstackImage;
198
- }
199
- const repo = this.getLocalStackEdition() === 'pro' ? 'localstack/localstack-pro' : 'localstack/localstack';
200
- return `${repo}:${this.getLocalStackVersion()}`;
201
- }
202
- getLocalStackAuthToken() {
203
- return this.config.localstackAuthToken;
204
- }
205
648
  isEnableDynamoProxy() {
206
649
  if (this.config.enableDynamoProxy !== undefined) {
207
650
  return this.config.enableDynamoProxy;
@@ -215,9 +658,6 @@ export class ConfigManager {
215
658
  getRegion() {
216
659
  return this.config.region ?? (process.env.AWS_REGION || 'us-east-1');
217
660
  }
218
- getServices() {
219
- return this.config.services ?? ['dynamodb', 'sqs', 'sns', 's3', 'lambda', 'events'];
220
- }
221
661
  isPersistence() {
222
662
  if (this.config.persistence !== undefined) {
223
663
  return this.config.persistence;
@@ -265,11 +705,7 @@ export class ConfigManager {
265
705
  * as the global baseline.
266
706
  */
267
707
  getPackageConfigForService(servicePath) {
268
- const map = this.config.servicePackaging ?? {};
269
- const baseName = path.basename(servicePath);
270
- const configDir = path.resolve(this.configPath ? path.dirname(this.configPath) : process.cwd());
271
- const relPath = path.relative(configDir, servicePath).split(path.sep).join('/');
272
- const perService = map[relPath] ?? map[baseName] ?? {};
708
+ const perService = this.lookupServiceEntry(this.config.servicePackaging, servicePath);
273
709
  return {
274
710
  command: perService.packageCommand ?? this.getPackageCommand(),
275
711
  args: [...(this.config.packageArgs ?? []), ...(perService.packageArgs ?? [])],
@@ -277,6 +713,40 @@ export class ConfigManager {
277
713
  timeoutMs: perService.packageTimeoutMs ?? this.getPackageTimeoutMs(),
278
714
  };
279
715
  }
716
+ /**
717
+ * The keys a `servicePackaging`/`serviceRuntime` entry may be written under,
718
+ * most specific first: the path relative to the config file's directory, the
719
+ * path relative to the project root, then the directory basename.
720
+ *
721
+ * The two relative forms are usually the same string — but not always, and
722
+ * the difference used to silently drop overrides: `getProjectRoot()`
723
+ * realpath-resolves (and falls back to the cwd for a home-directory config),
724
+ * while a raw `dirname(configPath)` does not. A checkout reached through a
725
+ * symlink, or a config loaded from `~`, made the key the dashboard writes
726
+ * (project-root relative, from the scan) unreachable by the lookup. Both
727
+ * spellings resolve here so a written override always applies.
728
+ */
729
+ serviceEntryKeys(servicePath) {
730
+ const target = realpathOrSelf(path.resolve(servicePath));
731
+ const bases = [
732
+ this.configPath ? realpathOrSelf(path.resolve(path.dirname(this.configPath))) : null,
733
+ this.getProjectRoot(),
734
+ ];
735
+ const keys = bases
736
+ .filter((base) => base !== null)
737
+ .map(base => path.relative(base, target).split(path.sep).join('/'))
738
+ .filter(Boolean);
739
+ keys.push(path.basename(target));
740
+ return [...new Set(keys)];
741
+ }
742
+ lookupServiceEntry(map, servicePath) {
743
+ const entries = map ?? {};
744
+ for (const key of this.serviceEntryKeys(servicePath)) {
745
+ if (Object.prototype.hasOwnProperty.call(entries, key))
746
+ return entries[key];
747
+ }
748
+ return {};
749
+ }
280
750
  isLambdaRuntimeEnabled() {
281
751
  return this.config.lambdaRuntime?.enabled ?? true;
282
752
  }
@@ -286,13 +756,36 @@ export class ConfigManager {
286
756
  getInvokePortOffset() {
287
757
  return this.config.lambdaRuntime?.invokePortOffset ?? 10000;
288
758
  }
759
+ // Defer forking a service's worker to its first invocation. On by default:
760
+ // one Node process per registered service is ~48 MB, which a 40-service
761
+ // monorepo pays entirely up front for handlers it may never call.
762
+ isLambdaRuntimeLazy() {
763
+ return this.config.lambdaRuntime?.lazy ?? true;
764
+ }
765
+ // A worker only stays resident while it is being used. 0 disables unloading.
766
+ getLambdaIdleTimeoutMs() {
767
+ const raw = this.config.lambdaRuntime?.idleTimeoutMs;
768
+ if (raw === undefined)
769
+ return 60_000;
770
+ return typeof raw === 'number' && raw > 0 ? raw : 0;
771
+ }
772
+ // Ceiling on resident workers. Default: one per GB of system RAM, clamped to
773
+ // 2..12 — the point is that the host's memory is bounded by how many services
774
+ // are in flight, never by how many are registered. 0 disables the ceiling.
775
+ getLambdaMaxWarmWorkers() {
776
+ const raw = this.config.lambdaRuntime?.maxWarmWorkers;
777
+ if (typeof raw === 'number' && raw >= 0)
778
+ return Math.floor(raw);
779
+ const gib = Math.floor(os.totalmem() / (1024 ** 3));
780
+ return Math.max(2, Math.min(12, gib));
781
+ }
289
782
  getInvokeHost() {
290
783
  if (this.config.lambdaRuntime?.invokeHost) {
291
784
  return this.config.lambdaRuntime.invokeHost;
292
785
  }
293
- // Self engine: nothing runs inside Docker, so callbacks into the invoke
294
- // listeners resolve on the loopback directly.
295
- return this.isSelfEngine() ? '127.0.0.1' : 'host.docker.internal';
786
+ // Nothing runs inside a container, so callbacks into the invoke listeners
787
+ // resolve on the loopback directly.
788
+ return '127.0.0.1';
296
789
  }
297
790
  /**
298
791
  * Resolve the effective runtime settings for one service, merging the global
@@ -302,11 +795,7 @@ export class ConfigManager {
302
795
  * (true for source, false for artifact).
303
796
  */
304
797
  getRuntimeConfigForService(servicePath) {
305
- const map = this.config.serviceRuntime ?? {};
306
- const baseName = path.basename(servicePath);
307
- const configDir = path.resolve(this.configPath ? path.dirname(this.configPath) : process.cwd());
308
- const relPath = path.relative(configDir, servicePath).split(path.sep).join('/');
309
- const perService = map[relPath] ?? map[baseName] ?? {};
798
+ const perService = this.lookupServiceEntry(this.config.serviceRuntime, servicePath);
310
799
  return {
311
800
  enabled: perService.enabled ?? this.isLambdaRuntimeEnabled(),
312
801
  execution: perService.execution ?? this.getLambdaExecutionMode(),
@@ -315,19 +804,13 @@ export class ConfigManager {
315
804
  invokePort: perService.invokePort,
316
805
  };
317
806
  }
318
- getEngineKind() {
319
- return this.config.engine ?? 'localstack';
320
- }
321
- isSelfEngine() {
322
- return this.getEngineKind() === 'self';
323
- }
324
807
  getSelfEngineConfig() {
325
808
  const selfEngine = this.config.selfEngine ?? {};
326
809
  const stateDir = this.getStateDir();
327
810
  const rawDataDir = selfEngine.dataDir
328
- ?? (stateDir ? path.join(stateDir, 'engine') : path.join(os.homedir(), '.lss', 'engine'));
811
+ ?? (stateDir ? path.join(stateDir, 'engine') : this.homeStateDir('engine'));
329
812
  return {
330
- port: selfEngine.port ?? 14566,
813
+ port: selfEngine.port ?? DEFAULT_PORT,
331
814
  dataDir: path.isAbsolute(rawDataDir) ? rawDataDir : path.resolve(process.cwd(), rawDataDir),
332
815
  account: selfEngine.account ?? '000000000000',
333
816
  idleUnloadMs: selfEngine.idleUnloadMs ?? 300000,
@@ -338,27 +821,27 @@ export class ConfigManager {
338
821
  region: this.getRegion(),
339
822
  };
340
823
  }
341
- getAossSidecarConfig() {
342
- // The self engine serves aoss natively — the sidecar must never run there.
343
- const enabled = !this.isSelfEngine() && this.config.aossSidecar?.enabled !== false;
344
- const port = this.config.aossSidecar?.port ?? 14567;
345
- const stateDir = this.getStateDir();
346
- return {
347
- enabled,
348
- port,
349
- endpoint: `http://localhost:${port}`,
350
- // Mirrors the self-engine default so both persistence roots sit side by
351
- // side (<stateDir>/aoss for test isolation, else ~/.lss/aoss).
352
- dataDir: stateDir ? path.join(stateDir, 'aoss') : path.join(os.homedir(), '.lss', 'aoss'),
353
- };
824
+ // Fallback state root when the config declares no stateDir. Scoped to this
825
+ // project: a flat ~/.lss/<kind> meant every project on the machine shared one
826
+ // set of DynamoDB tables, queues and OpenSearch indices, so opening a second
827
+ // repo silently showed the first one's data.
828
+ homeStateDir(kind) {
829
+ return path.join(os.homedir(), '.lss', 'projects', projectCacheSegment(this.getProjectRoot()), kind);
354
830
  }
355
831
  // The AWS endpoint every SDK-based consumer (provisioner, explorers, seeds)
356
- // should target for the active engine.
832
+ // targets. Reads the port directly rather than through getSelfEngineConfig(),
833
+ // which would also resolve the data dir — needless work for a URL, and it
834
+ // drags the project-root lookup into every caller that only wants an endpoint.
357
835
  getEngineEndpoint() {
358
- if (this.isSelfEngine()) {
359
- return `http://localhost:${this.getSelfEngineConfig().port}`;
360
- }
361
- return this.getLocalStackEndpoint();
836
+ return `http://localhost:${this.config.selfEngine?.port ?? DEFAULT_PORT}`;
837
+ }
838
+ /**
839
+ * True when the orchestrator and the engine share one listener — the default,
840
+ * since both ports default to the same value. Give them different values to
841
+ * go back to two listeners.
842
+ */
843
+ isSingleListener() {
844
+ return this.getServerPort() === (this.config.selfEngine?.port ?? DEFAULT_PORT);
362
845
  }
363
846
  getConfigPath() {
364
847
  return this.configPath;
@@ -423,35 +906,208 @@ export class ConfigManager {
423
906
  },
424
907
  };
425
908
  }
909
+ // Config keys currently masked by an environment variable (env wins over the
910
+ // file). The UI flags these fields so a save that "doesn't stick" is
911
+ // explained instead of silent.
912
+ getEnvOverriddenKeys() {
913
+ // Truthy check on purpose: loadFromEnv only applies truthy env values, so
914
+ // a variable exported as an empty string does not actually override the
915
+ // file and must not be reported as masking it.
916
+ return Object.keys(CONFIG_ENV_OVERRIDES).filter(key => CONFIG_ENV_OVERRIDES[key].some(name => Boolean(process.env[name])));
917
+ }
918
+ // Resolved values that are materialized into listeners, containers, engines
919
+ // or child processes at boot. Everything NOT listed here is consumed lazily
920
+ // (per request/registration/seed run) and follows a reload immediately.
921
+ resolvedBootValues() {
922
+ const selfEngine = this.getSelfEngineConfig();
923
+ return {
924
+ serverPort: this.getServerPort(),
925
+ enableDynamoProxy: this.isEnableDynamoProxy(),
926
+ dynamoProxyPort: this.getDynamoProxyPort(),
927
+ region: this.getRegion(),
928
+ persistence: this.isPersistence(),
929
+ debug: this.isDebug(),
930
+ stateDir: this.getStateDir(),
931
+ // Only the block's OWN settings: region/persistence are tracked under
932
+ // their own keys above, so one change never flags two entries.
933
+ selfEngine: {
934
+ port: selfEngine.port,
935
+ dataDir: selfEngine.dataDir,
936
+ account: selfEngine.account,
937
+ idleUnloadMs: selfEngine.idleUnloadMs,
938
+ memoryBudgetMb: selfEngine.memoryBudgetMb,
939
+ fsync: selfEngine.fsync,
940
+ fallbackEndpoint: selfEngine.fallbackEndpoint,
941
+ },
942
+ };
943
+ }
944
+ static diffKeys(before, after) {
945
+ return Object.keys(before).filter(key => JSON.stringify(before[key]) !== JSON.stringify(after[key]));
946
+ }
947
+ resetAndReload() {
948
+ this.config = {};
949
+ this.configPath = '';
950
+ this.loadConfig();
951
+ }
952
+ /**
953
+ * Re-read the config file from disk — after a hand edit, or after
954
+ * updateConfig() wrote it. Runs the exact boot sequence (file search + env
955
+ * override pass), so the result matches what a fresh process would resolve.
956
+ * Boot-materialized keys that changed are reported as restartRequired: the
957
+ * running listeners/engine keep the old value until `lss stop && lss start`.
958
+ */
959
+ reloadFromDisk() {
960
+ // A hand-edit typo must not silently discard the working config: when the
961
+ // loaded file still exists but no longer parses, keep the current config
962
+ // and fail loudly instead of letting loadConfig fall through to the next
963
+ // search candidate (the user-global home config, or nothing at all).
964
+ if (this.configPath && fs.existsSync(this.configPath)) {
965
+ try {
966
+ JSON.parse(fs.readFileSync(this.configPath, 'utf-8'));
967
+ }
968
+ catch {
969
+ throw new ConfigValidationError([
970
+ `${this.configPath} is not valid JSON — fix the file and reload again`,
971
+ ]);
972
+ }
973
+ }
974
+ const before = this.resolvedBootValues();
975
+ this.resetAndReload();
976
+ return {
977
+ path: this.configPath,
978
+ restartRequired: ConfigManager.diffKeys(before, this.resolvedBootValues()),
979
+ };
980
+ }
981
+ /**
982
+ * Validate a partial config patch, write it into the loaded config file (or
983
+ * create lss.config.json in the project root when none is loaded) and reload
984
+ * in-memory. The raw file is re-read and patched key-by-key — never the
985
+ * env-resolved in-memory config — so env-var overrides are not baked into
986
+ * the file on save. The human commits the file; LSS never touches git.
987
+ */
988
+ updateConfig(patch) {
989
+ if (!isPlainObject(patch)) {
990
+ throw new ConfigValidationError(['config patch must be a JSON object of top-level lss.config.json keys']);
991
+ }
992
+ const errors = [];
993
+ for (const [key, value] of Object.entries(patch)) {
994
+ if (BLOCKED_CONFIG_KEYS.has(key)) {
995
+ errors.push(`"${key}" cannot be edited via the API — set it in the config file or environment directly`);
996
+ continue;
997
+ }
998
+ const spec = Object.prototype.hasOwnProperty.call(EDITABLE_CONFIG_KEYS, key)
999
+ ? EDITABLE_CONFIG_KEYS[key]
1000
+ : undefined;
1001
+ if (!spec) {
1002
+ errors.push(`unknown config key "${key}"`);
1003
+ continue;
1004
+ }
1005
+ if (value === null)
1006
+ continue; // null deletes the key (or subkey) from the file
1007
+ const error = validateValue(key, value, spec);
1008
+ if (error)
1009
+ errors.push(error);
1010
+ }
1011
+ if (errors.length > 0) {
1012
+ throw new ConfigValidationError(errors);
1013
+ }
1014
+ // Snapshot the resolved boot values BEFORE any mutation, so a failure
1015
+ // later in this method can never compare against poisoned state.
1016
+ const before = this.resolvedBootValues();
1017
+ const target = this.configPath || path.join(this.getProjectRoot(), 'lss.config.json');
1018
+ let fileConfig = {};
1019
+ if (fs.existsSync(target)) {
1020
+ try {
1021
+ fileConfig = JSON.parse(fs.readFileSync(target, 'utf-8'));
1022
+ }
1023
+ catch {
1024
+ // Never clobber a file the user may be hand-editing mid-typo.
1025
+ throw new ConfigValidationError([
1026
+ `${target} exists but is not valid JSON — fix or remove it before saving from the dashboard`,
1027
+ ]);
1028
+ }
1029
+ }
1030
+ for (const [key, value] of Object.entries(patch)) {
1031
+ if (value === null) {
1032
+ delete fileConfig[key];
1033
+ }
1034
+ else if (EDITABLE_CONFIG_KEYS[key].kind === 'object') {
1035
+ // One-level merge so a partial block edit (e.g. branding.title) never
1036
+ // drops sibling settings the UI does not round-trip (branding.logo).
1037
+ // Map-shaped blocks (serviceRuntime/servicePackaging) merge one level
1038
+ // deeper for the same reason: onboarding sending { apiPort } for one
1039
+ // service must not drop that entry's execution/packageEnv siblings.
1040
+ const perEntry = Boolean(EDITABLE_CONFIG_KEYS[key].entrySubKeys);
1041
+ const existing = isPlainObject(fileConfig[key])
1042
+ ? fileConfig[key]
1043
+ : {};
1044
+ const merged = { ...existing };
1045
+ for (const [subKey, subValue] of Object.entries(value)) {
1046
+ if (subValue === null) {
1047
+ delete merged[subKey];
1048
+ }
1049
+ else if (perEntry && isPlainObject(subValue)) {
1050
+ const entryExisting = isPlainObject(merged[subKey])
1051
+ ? merged[subKey]
1052
+ : {};
1053
+ const entryMerged = { ...entryExisting };
1054
+ for (const [entryKey, entryValue] of Object.entries(subValue)) {
1055
+ if (entryValue === null) {
1056
+ delete entryMerged[entryKey];
1057
+ }
1058
+ else {
1059
+ entryMerged[entryKey] = entryValue;
1060
+ }
1061
+ }
1062
+ // An entry left with no settings carries no meaning and would
1063
+ // shadow a basename-keyed entry for the same service (an empty
1064
+ // object is truthy), so it is dropped rather than written.
1065
+ if (Object.keys(entryMerged).length === 0) {
1066
+ delete merged[subKey];
1067
+ }
1068
+ else {
1069
+ merged[subKey] = entryMerged;
1070
+ }
1071
+ }
1072
+ else {
1073
+ merged[subKey] = subValue;
1074
+ }
1075
+ }
1076
+ fileConfig[key] = merged;
1077
+ }
1078
+ else {
1079
+ fileConfig[key] = value;
1080
+ }
1081
+ }
1082
+ fs.writeFileSync(target, `${JSON.stringify(fileConfig, null, 2)}\n`);
1083
+ this.resetAndReload();
1084
+ return {
1085
+ // The written file is a search candidate, so the reload finds it again;
1086
+ // fall back to the target path defensively.
1087
+ path: this.configPath || target,
1088
+ restartRequired: ConfigManager.diffKeys(before, this.resolvedBootValues()),
1089
+ // Truthy check to mirror loadFromEnv: an empty-string env var does not
1090
+ // actually override the file, so it must not be reported as a mask.
1091
+ envOverridden: Object.keys(patch).filter(key => (CONFIG_ENV_OVERRIDES[key] ?? []).some(name => Boolean(process.env[name]))),
1092
+ };
1093
+ }
426
1094
  /**
427
1095
  * Print configuration summary
428
1096
  */
429
1097
  printSummary() {
430
1098
  console.log('\n📋 Configuration Summary:');
431
1099
  console.log(` Server Port: ${this.getServerPort()} (http://localhost:${this.getServerPort()})`);
432
- console.log(` Engine: ${this.getEngineKind()}`);
433
- if (this.isSelfEngine()) {
434
- const selfEngine = this.getSelfEngineConfig();
435
- console.log(` Self Engine Port: ${selfEngine.port} (http://localhost:${selfEngine.port})`);
436
- console.log(` Self Engine Data Dir: ${selfEngine.dataDir}`);
437
- if (selfEngine.fallbackEndpoint) {
438
- console.log(` Self Engine Fallback: ${selfEngine.fallbackEndpoint}`);
439
- }
440
- }
441
- else {
442
- console.log(` LocalStack Mode: ${this.getMode()}`);
443
- console.log(` LocalStack Port: ${this.getLocalStackPort()} (${this.getLocalStackEndpoint()})`);
444
- if (!this.isExternal()) {
445
- console.log(` LocalStack Image: ${this.getLocalStackImage()} (${this.getLocalStackEdition()})`);
446
- console.log(` LocalStack Auth Token: ${this.getLocalStackAuthToken() ? 'set' : 'not set'}`);
447
- }
1100
+ const selfEngine = this.getSelfEngineConfig();
1101
+ console.log(` Self Engine Port: ${selfEngine.port} (http://localhost:${selfEngine.port})`);
1102
+ console.log(` Self Engine Data Dir: ${selfEngine.dataDir}`);
1103
+ if (selfEngine.fallbackEndpoint) {
1104
+ console.log(` Self Engine Fallback: ${selfEngine.fallbackEndpoint}`);
448
1105
  }
449
1106
  console.log(` DynamoDB Proxy Enabled: ${this.isEnableDynamoProxy()}`);
450
1107
  if (this.isEnableDynamoProxy()) {
451
1108
  console.log(` DynamoDB Proxy Port: ${this.getDynamoProxyPort()}`);
452
1109
  }
453
1110
  console.log(` AWS Region: ${this.getRegion()}`);
454
- console.log(` Services: ${this.getServices().join(', ')}`);
455
1111
  console.log(` Persistence: ${this.isPersistence()}`);
456
1112
  console.log(` Seeds Dir: ${this.getSeedsDir()}`);
457
1113
  const secretSeedCount = Object.keys(this.getSecretSeeds()).length;