lowdefy 5.6.0 → 6.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 (41) hide show
  1. package/dist/commands/agentSetup/agentSetup.js +85 -0
  2. package/dist/commands/agentSetup/agentsMd.js +59 -0
  3. package/dist/commands/agentSetup/buildMcpServerEntry.js +21 -0
  4. package/dist/commands/agentSetup/findProjectRoot.js +39 -0
  5. package/dist/commands/agentSetup/getDevCommand.js +50 -0
  6. package/dist/commands/agentSetup/skillMd.js +48 -0
  7. package/dist/commands/agentSetup/upsertAgentsMdSection.js +63 -0
  8. package/dist/commands/agentSetup/upsertClaudeSettings.js +56 -0
  9. package/dist/commands/agentSetup/upsertMcpServer.js +83 -0
  10. package/dist/commands/agentSetup/writeSkillFile.js +32 -0
  11. package/dist/commands/build/build.js +3 -3
  12. package/dist/commands/dev/dev.js +13 -8
  13. package/dist/commands/dev/resolveMockUser.js +37 -0
  14. package/dist/commands/dev/runDevServer.js +17 -11
  15. package/dist/commands/dockerOutput/dockerOutput.js +99 -0
  16. package/dist/commands/emails/emails.js +84 -0
  17. package/dist/commands/emails/generateEmailShims.js +94 -0
  18. package/dist/commands/emails/installReactEmail.js +53 -0
  19. package/dist/commands/emails/readNotificationArtifacts.js +37 -0
  20. package/dist/commands/emails/runEmailPreview.js +47 -0
  21. package/dist/commands/emails/warnMissingDataKeys.js +45 -0
  22. package/dist/commands/init-docker/Dockerfile +37 -19
  23. package/dist/commands/init-docker/dockerignore +8 -0
  24. package/dist/commands/init-docker/initDocker.js +3 -1
  25. package/dist/commands/init-vercel/README.md +94 -20
  26. package/dist/commands/init-vercel/initVercel.js +12 -2
  27. package/dist/commands/init-vercel/vercel.build.sh +8 -0
  28. package/dist/commands/init-vercel/vercel.install.sh +4 -2
  29. package/dist/commands/init-vercel/vercel.json +5 -0
  30. package/dist/commands/start/runStart.js +1 -2
  31. package/dist/commands/vercelOutput/apiHandler.js +83 -0
  32. package/dist/commands/vercelOutput/vercelOutput.js +138 -0
  33. package/dist/program.js +24 -2
  34. package/dist/utils/checkPortAvailable.js +9 -16
  35. package/dist/utils/copyTracedFiles.js +94 -0
  36. package/dist/utils/findTraceBase.js +45 -0
  37. package/dist/utils/getDirectories.js +2 -1
  38. package/dist/utils/getOptions.js +2 -0
  39. package/dist/utils/installServer.js +31 -23
  40. package/dist/utils/{runNextBuild.js → runClientBuild.js} +11 -14
  41. package/package.json +14 -13
@@ -1,30 +1,104 @@
1
1
  # Vercel Deployment
2
2
 
3
- To deploy a Lowdefy app on Vercel a custom install script needs to run to download the server and build the Lowdefy configuration. This script creates a Next.js application that can then be built by Vercel. The install script needs to be placed in an empty directory and it installs the server in its working directory - by default this directory is called `deploy`.
3
+ Lowdefy v6 builds a [Hono](https://hono.dev) server serving a [Vite](https://vite.dev)-built React
4
+ client. To run that on Vercel, the app is deployed via the
5
+ [Build Output API](https://vercel.com/docs/build-output-api) as **static assets on the CDN plus one
6
+ Serverless Function** that runs the Hono app for every dynamic request.
4
7
 
5
- The script can be created using the `init-vercel` CLI command, or the following file can be created at `<config-directory>/deploy/vercel.install.sh`:
8
+ `lowdefy init-vercel` scaffolds everything into a `deploy/` directory next to your `lowdefy.yaml`:
6
9
 
7
- ###### vercel.install.sh
10
+ - `vercel.install.sh` — install command: downloads the Lowdefy server into `deploy/` and builds the
11
+ config artifacts (skips the client build, which the build step runs).
12
+ - `vercel.build.sh` — build command: builds the Vite client, then runs `lowdefy vercel-output` to
13
+ assemble the Vercel Build Output (`.vercel/output/`).
14
+ - `vercel.json` — sets the build command; the deployment itself is described by the generated
15
+ `.vercel/output/config.json`.
8
16
 
9
- ```bash
10
- # Read Lowdefy version from lowdefy.yaml using sed
11
- LOWDEFY_VERSION=$(sed -nE "s/lowdefy:(.*)/\1/p" ../lowdefy.yaml)
12
- # Substitution params are to trim whitespace from the LOWDEFY_VERSION var
13
- npx lowdefy@${LOWDEFY_VERSION//[[:space:]]/} build --config-directory ../ --server-directory . --no-next-build --log-level=debug
17
+ ## Project settings
18
+
19
+ In the Vercel project settings:
20
+
21
+ - **Framework Preset:** `Other` (not Next.js — Lowdefy v6 is not a Next.js app).
22
+ - **Root Directory:** `<config-directory>/deploy`
23
+ - `<config-directory>` is where your `lowdefy.yaml` lives. If `lowdefy.yaml` is at the repo root,
24
+ use `deploy`; if it is in `apps/my-app`, use `apps/my-app/deploy`.
25
+ - **Install Command:** `bash vercel.install.sh`.
26
+ - **Build Command:** comes from `vercel.json` (`bash vercel.build.sh`); leave the dashboard override
27
+ off. There is no Output Directory setting — the build emits `.vercel/output`, which Vercel detects
28
+ automatically.
29
+ - Enable **"Include files outside the root directory in the Build Step"** — the Lowdefy config
30
+ usually lives in the parent of `deploy/`.
31
+
32
+ ## Public files
33
+
34
+ Files in your app's `public/` directory (favicon, icons, images, `manifest.webmanifest`, etc.) are
35
+ copied into `dist/client` by Vite during the build, placed in `.vercel/output/static`, and served by
36
+ the Vercel CDN — no extra configuration needed.
37
+
38
+ ## Secrets and environment variables
39
+
40
+ Set secrets in the Vercel project's Environment Variables, prefixed with `LOWDEFY_SECRET_`. Use
41
+ `AUTH_SECRET` (and, for OAuth, `AUTH_URL`) for auth. Different values can be set for Production and
42
+ Preview deployments.
43
+
44
+ ## Scheduled endpoints (cron)
45
+
46
+ An `Api` (or `InternalApi`) endpoint can declare `schedules` to run its routine on a timer:
47
+
48
+ ```yaml
49
+ id: purge-stale-conversations
50
+ type: Api
51
+ schedules:
52
+ - cron: '0 6 * * *'
53
+ payload: { mode: full }
54
+ - cron: '*/15 * * * *'
55
+ payload: { mode: incremental }
56
+ routine:
57
+ - id: purge
58
+ type: MongoDBDeleteMany
59
+ connectionId: conversations
60
+ properties:
61
+ filter: { updatedAt: { $lt: { _payload: cutoff } } }
14
62
  ```
15
63
 
16
- To deploy a Lowdefy app on Vercel:
64
+ `lowdefy vercel-output` turns each schedule into a Vercel cron job in `.vercel/output/config.json`,
65
+ pointing at `/api/cron/<endpointId>`. When it fires, Vercel makes a GET request to that path and the
66
+ Hono app runs the endpoint's routine.
67
+
68
+ - **Secure it:** add a project Environment Variable named exactly `CRON_SECRET` (a random string of
69
+ 16+ characters). Vercel automatically sends it as `Authorization: Bearer <CRON_SECRET>`, and the
70
+ `/api/cron/*` route rejects any request whose header does not match. If `CRON_SECRET` is unset the
71
+ route always returns `401` (fail closed).
72
+ - **System context:** scheduled runs have no logged-in user, so `_user` is `undefined`. The routine
73
+ has full access to connections, requests, operators and secrets — just not a user. The `payload`
74
+ for a run comes from the matching schedule's `payload`.
75
+ - **UTC only.** Cron expressions use 5 fields (`minute hour day-of-month month day-of-week`); named
76
+ values like `MON`/`JAN` are not supported and day-of-month and day-of-week are mutually exclusive.
77
+ - **Plan limits:** Vercel **Hobby** only allows daily crons (a sub-daily expression fails the
78
+ deployment); **Pro**/**Enterprise** allow per-minute. Up to 100 cron jobs per project.
79
+ - **Idempotency:** Vercel does not retry failed runs and delivery is best-effort (a run can be
80
+ missed or delivered more than once). Write scheduled routines to be idempotent.
81
+
82
+ ## How it works
83
+
84
+ `vercel.build.sh` builds the client and runs `lowdefy vercel-output`, which assembles:
85
+
86
+ - `.vercel/output/static/` — the built client and `public/` files, served by the CDN.
87
+ - `.vercel/output/functions/api.func/` — one Node Serverless Function: the Hono app plus its exact
88
+ runtime dependency closure, traced with [`@vercel/nft`](https://github.com/vercel/nft) (pnpm
89
+ symlinks and workspace packages included), and the `build/**`,
90
+ `dist/client/.vite/manifest.json` and `package.json` files it reads at runtime, with a
91
+ `.vc-config.json`.
92
+ - `.vercel/output/config.json` — routes that serve static files first and send every other request
93
+ to the function (running with `serveStaticAssets: false`), plus the `crons` array.
17
94
 
18
- - Create a new project
19
- - Connect the GitHub, Gitlab or BitBucket repository with your Lowdefy app to the Vercel project
20
- - The framework preset should be Next.js
21
- - The root directory should be `<config-directory>/deploy`
22
- - `<config-directory>` is the path to the directory in which the `lowdefy.yaml` file is placed
23
- - Eg: If the `lowdefy.yaml` is in the top level of the repository the configured root directory should be `deploy`
24
- - Eg: If the `lowdefy.yaml` is in the directory `apps/app_name` the configured root directory should be `apps/app_name/deploy`
25
- - The build command should be `pnpm next build`
26
- - The install command should be `sh vercel.install.sh`
95
+ > The function runs on the Vercel **Node.js** runtime. Streaming agent responses (`/api/agent/*`)
96
+ > are subject to Vercel's function response/duration limits; for heavy streaming or agent workloads
97
+ > a long-lived Node host (Docker, Fly.io, Railway, Render) running `node src/index.js` may suit
98
+ > better.
27
99
 
28
- Secrets can be set in the Environment Variables settings section by creating environment variables prefixed with `LOWDEFY_SECRET_`. Different secrets can be set for production and preview deployments.
100
+ ## Manual setup
29
101
 
30
- All other Vercel configuration like domain names, preview deploy branches, serverless regions and redirects can be configured as desired.
102
+ The same files can be created by hand at `<config-directory>/deploy/` instead of running
103
+ `lowdefy init-vercel`. All other Vercel configuration (domains, preview branches, regions,
104
+ redirects) can be set as desired.
@@ -15,13 +15,23 @@
15
15
  */ import path from 'path';
16
16
  import url from 'url';
17
17
  import { readFile, writeFile } from '@lowdefy/node-utils';
18
+ // The Vercel function entry is no longer scaffolded here: the deployment uses the Vercel Build
19
+ // Output API, and `lowdefy vercel-output` (run by vercel.build.sh) generates the function and
20
+ // deployment config into .vercel/output at build time.
18
21
  async function initVercel({ context }) {
19
22
  context.logger.info('Initializing Vercel deployment.');
23
+ const deployDir = path.join(context.directories.config, 'deploy');
20
24
  const installScript = await readFile(url.fileURLToPath(new URL('./vercel.install.sh', import.meta.url)));
21
- await writeFile(path.join(context.directories.config, 'deploy', 'vercel.install.sh'), installScript);
25
+ await writeFile(path.join(deployDir, 'vercel.install.sh'), installScript);
22
26
  context.logger.info("Created 'vercel.install.sh'.");
27
+ const buildScript = await readFile(url.fileURLToPath(new URL('./vercel.build.sh', import.meta.url)));
28
+ await writeFile(path.join(deployDir, 'vercel.build.sh'), buildScript);
29
+ context.logger.info("Created 'vercel.build.sh'.");
30
+ const vercelJson = await readFile(url.fileURLToPath(new URL('./vercel.json', import.meta.url)));
31
+ await writeFile(path.join(deployDir, 'vercel.json'), vercelJson);
32
+ context.logger.info("Created 'vercel.json'.");
23
33
  const readMe = await readFile(url.fileURLToPath(new URL('./README.md', import.meta.url)));
24
- await writeFile(path.join(context.directories.config, 'deploy', 'README.md'), readMe);
34
+ await writeFile(path.join(deployDir, 'README.md'), readMe);
25
35
  context.logger.info("Created 'README.md'.");
26
36
  await context.sendTelemetry();
27
37
  context.logger.info({
@@ -0,0 +1,8 @@
1
+ # Vercel build command. The install step (vercel.install.sh) already downloaded the Hono server and
2
+ # built the config artifacts into this directory. Here we build the Vite client, then assemble the
3
+ # Vercel Build Output (.vercel/output) — including cron jobs generated from endpoint `schedules`.
4
+ # Read Lowdefy version from lowdefy.yaml using sed.
5
+ LOWDEFY_VERSION=$(sed -nE "s/lowdefy:(.*)/\1/p" ../lowdefy.yaml)
6
+ # Substitution params are to trim whitespace from the LOWDEFY_VERSION var.
7
+ pnpm run build:client
8
+ npx lowdefy@${LOWDEFY_VERSION//[[:space:]]/} vercel-output --config-directory ../ --server-directory . --log-level=debug
@@ -1,4 +1,6 @@
1
1
  # Read Lowdefy version from lowdefy.yaml using sed
2
2
  LOWDEFY_VERSION=$(sed -nE "s/lowdefy:(.*)/\1/p" ../lowdefy.yaml)
3
- # Substitution params are to trim whitespace from the LOWDEFY_VERSION var
4
- npx lowdefy@${LOWDEFY_VERSION//[[:space:]]/} build --config-directory ../ --server-directory . --no-next-build --log-level=debug
3
+ # Substitution params are to trim whitespace from the LOWDEFY_VERSION var.
4
+ # Writes the Hono server and config artifacts into this directory. The client build and the Vercel
5
+ # Build Output assembly run separately in the build step (vercel.build.sh).
6
+ npx lowdefy@${LOWDEFY_VERSION//[[:space:]]/} build --config-directory ../ --server-directory . --no-client-build --log-level=debug
@@ -0,0 +1,5 @@
1
+ {
2
+ "$schema": "https://openapi.vercel.sh/vercel.json",
3
+ "framework": null,
4
+ "buildCommand": "bash vercel.build.sh"
5
+ }
@@ -31,8 +31,7 @@ async function runStart({ context, directory }) {
31
31
  env: {
32
32
  ...process.env,
33
33
  LOWDEFY_LOG_LEVEL: context.options.logLevel,
34
- PORT: context.options.port,
35
- NEXT_TELEMETRY_DISABLED: context.options.disableTelemetry ? '1' : undefined
34
+ PORT: context.options.port
36
35
  }
37
36
  }
38
37
  });
@@ -0,0 +1,83 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ // The Vercel function entry, written into the Build Output at
16
+ // .vercel/output/functions/api.func/<relServer>/api/index.js, where <relServer> is the server
17
+ // directory's path relative to the trace base (empty for standalone apps). Kept as a string (not a
18
+ // template file) so it ships verbatim — a real source file would be transpiled by the CLI's swc
19
+ // build, stripping these comments. Its `../src/app.js` import and the chdir to `..` resolve to the
20
+ // server directory inside the function, where the assembly places src/, build/, lib/ and the traced
21
+ // dependency closure.
22
+ const apiHandler = `/*
23
+ Vercel Serverless Function entry for a Lowdefy (Hono) app — generated into the Vercel Build Output
24
+ by lowdefy vercel-output.
25
+
26
+ The built client and public files are served from Vercel's CDN (.vercel/output/static); every
27
+ other request is routed here (config.json routes) and run through the Lowdefy Hono app (page
28
+ rendering, /api/* requests, endpoints, cron, auth, agents).
29
+
30
+ Runs on the Node runtime (the app uses fs to read its build). The request body is buffered eagerly
31
+ and a Web Request is built from it: Vercel's Node runtime does not drain a lazily-read body stream
32
+ reliably, so a streaming adapter hangs on every request that has a body (POST).
33
+ */
34
+
35
+ import path from 'node:path';
36
+ import { fileURLToPath } from 'node:url';
37
+
38
+ // The app reads its build artifacts relative to process.cwd(). On Vercel the function's cwd is the
39
+ // lambda root (e.g. /var/task), not this directory, so point the cwd at the server directory (the
40
+ // parent of this api/ folder) before loading the app. The import is dynamic so it runs AFTER chdir —
41
+ // a static import is hoisted and would read files at the wrong cwd.
42
+ process.chdir(path.join(path.dirname(fileURLToPath(import.meta.url)), '..'));
43
+
44
+ const { default: createApp } = await import('../src/app.js');
45
+ const app = createApp({ serveStaticAssets: false });
46
+
47
+ export const config = { runtime: 'nodejs' };
48
+
49
+ export default async function handler(req, res) {
50
+ const method = req.method || 'GET';
51
+
52
+ // Buffer the body eagerly — see the note above.
53
+ let body;
54
+ if (method !== 'GET' && method !== 'HEAD') {
55
+ const chunks = [];
56
+ for await (const chunk of req) chunks.push(chunk);
57
+ if (chunks.length > 0) body = Buffer.concat(chunks);
58
+ }
59
+
60
+ const host = req.headers['x-forwarded-host'] ?? req.headers.host;
61
+ const protocol = req.headers['x-forwarded-proto'] ?? 'https';
62
+ const request = new Request(protocol + '://' + host + req.url, {
63
+ method,
64
+ headers: req.headers,
65
+ body,
66
+ });
67
+
68
+ const response = await app.fetch(request);
69
+
70
+ res.statusCode = response.status;
71
+ response.headers.forEach((value, key) => res.setHeader(key, value));
72
+ if (response.body) {
73
+ const reader = response.body.getReader();
74
+ for (;;) {
75
+ const { done, value } = await reader.read();
76
+ if (done) break;
77
+ res.write(value);
78
+ }
79
+ }
80
+ res.end();
81
+ }
82
+ `;
83
+ export default apiHandler;
@@ -0,0 +1,138 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import fs from 'fs';
16
+ import path from 'path';
17
+ import { cleanDirectory, copyFileOrDirectory, readFile, writeFile } from '@lowdefy/node-utils';
18
+ import apiHandler from './apiHandler.js';
19
+ import copyTracedFiles from '../../utils/copyTracedFiles.js';
20
+ import findTraceBase from '../../utils/findTraceBase.js';
21
+ // Function settings come from lowdefy.yaml `config.vercel` (written to build/config.json by
22
+ // @lowdefy/build). maxDuration defaults to 60; plan limits are enforced by Vercel at deploy time.
23
+ async function readFunctionConfig({ buildDirectory }) {
24
+ const raw = await readFile(path.join(buildDirectory, 'config.json'));
25
+ const config = raw ? JSON.parse(raw) : {};
26
+ const vercel = config.vercel ?? {};
27
+ return {
28
+ maxDuration: vercel.maxDuration ?? 60,
29
+ ...vercel.memory ? {
30
+ memory: vercel.memory
31
+ } : {}
32
+ };
33
+ }
34
+ // The crons array is generated from build/schedules.json (written by @lowdefy/build for endpoints
35
+ // that declare `schedules`). A missing file means no schedules → no crons. Vercel fires crons only
36
+ // on the production deployment, so with config.cron.environments the schedules of every other
37
+ // environment are registered here too, as cron-forward jobs the production deployment relays to
38
+ // that environment's own /api/cron route.
39
+ async function readCrons({ buildDirectory }) {
40
+ const raw = await readFile(path.join(buildDirectory, 'schedules.json'));
41
+ if (!raw) return [];
42
+ const schedules = JSON.parse(raw);
43
+ return schedules.map(({ endpointId, cron, environment, forward })=>({
44
+ path: forward === true ? `/api/cron-forward/${environment}/${endpointId}` : `/api/cron/${endpointId}`,
45
+ schedule: cron
46
+ }));
47
+ }
48
+ // Assembles a Vercel Build Output (.vercel/output) from an already-built server directory, so the
49
+ // deployment config — including cron jobs derived from endpoint `schedules` — is generated at build
50
+ // time and never committed. Run after `lowdefy build` (server + config artifacts) and the client
51
+ // build (dist/client).
52
+ async function vercelOutput({ context }) {
53
+ const { directories, logger } = context;
54
+ logger.info('Assembling Vercel Build Output.');
55
+ const serverDirectory = path.resolve(directories.server);
56
+ const buildDirectory = path.resolve(directories.build);
57
+ const clientDirectory = path.join(serverDirectory, 'dist', 'client');
58
+ const outputDirectory = path.join(serverDirectory, '.vercel', 'output');
59
+ const staticDirectory = path.join(outputDirectory, 'static');
60
+ const functionDirectory = path.join(outputDirectory, 'functions', 'api.func');
61
+ const entrypoints = [
62
+ path.join(serverDirectory, 'src', 'index.js'),
63
+ path.join(serverDirectory, 'src', 'app.js')
64
+ ];
65
+ entrypoints.forEach((entrypoint)=>{
66
+ if (!fs.existsSync(entrypoint)) {
67
+ throw new Error(`Cannot trace "${entrypoint}" — run "lowdefy build" first.`);
68
+ }
69
+ });
70
+ // The function preserves paths relative to the trace base (the pnpm workspace root when the
71
+ // server directory is a workspace member), so the server's relative node_modules symlinks still
72
+ // resolve inside the function. The server lands at <relServer>/ inside api.func.
73
+ const base = findTraceBase({
74
+ serverDirectory
75
+ });
76
+ const relServer = path.relative(base, serverDirectory);
77
+ const functionServerDirectory = path.join(functionDirectory, relServer);
78
+ // Start clean so artifacts from a previous build do not leak into the deployment.
79
+ await cleanDirectory(outputDirectory);
80
+ // 1. Static assets + public files served by the Vercel CDN.
81
+ await copyFileOrDirectory(clientDirectory, staticDirectory);
82
+ // 2. The runtime file closure of the Hono server, traced with @vercel/nft — the app source, the
83
+ // build-generated plugin import files, and exactly the node_modules files they resolve to
84
+ // (through pnpm's symlinks, which are preserved).
85
+ const fileCount = await copyTracedFiles({
86
+ base,
87
+ entrypoints,
88
+ functionDirectory,
89
+ logger,
90
+ processCwd: serverDirectory
91
+ });
92
+ logger.info(`Traced ${fileCount} runtime files into the function.`);
93
+ // 3. Artifacts the server reads from the filesystem at runtime (not imported, so not traced),
94
+ // plus src/ and lib/ wholesale as belt-and-braces alongside the traced copies.
95
+ await copyFileOrDirectory(path.join(serverDirectory, 'src'), path.join(functionServerDirectory, 'src'));
96
+ await copyFileOrDirectory(path.join(serverDirectory, 'lib'), path.join(functionServerDirectory, 'lib'));
97
+ await copyFileOrDirectory(buildDirectory, path.join(functionServerDirectory, 'build'));
98
+ await copyFileOrDirectory(path.join(clientDirectory, '.vite', 'manifest.json'), path.join(functionServerDirectory, 'dist', 'client', '.vite', 'manifest.json'));
99
+ await copyFileOrDirectory(path.join(serverDirectory, 'package.json'), path.join(functionServerDirectory, 'package.json'));
100
+ // 4. The function entry and its config. The handler path is relative to the api.func root.
101
+ const handler = path.posix.join(...relServer.split(path.sep), 'api', 'index.js');
102
+ const functionConfig = await readFunctionConfig({
103
+ buildDirectory
104
+ });
105
+ await writeFile(path.join(functionServerDirectory, 'api', 'index.js'), apiHandler);
106
+ await writeFile(path.join(functionDirectory, '.vc-config.json'), JSON.stringify({
107
+ runtime: 'nodejs22.x',
108
+ handler,
109
+ launcherType: 'Nodejs',
110
+ ...functionConfig
111
+ }, null, 2));
112
+ // 5. Deployment config: serve static files first, route everything else to the function, and
113
+ // register the cron jobs.
114
+ const crons = await readCrons({
115
+ buildDirectory
116
+ });
117
+ const config = {
118
+ version: 3,
119
+ routes: [
120
+ {
121
+ handle: 'filesystem'
122
+ },
123
+ {
124
+ src: '/(.*)',
125
+ dest: '/api'
126
+ }
127
+ ]
128
+ };
129
+ if (crons.length > 0) {
130
+ config.crons = crons;
131
+ }
132
+ await writeFile(path.join(outputDirectory, 'config.json'), JSON.stringify(config, null, 2));
133
+ logger.info({
134
+ spin: 'succeed'
135
+ }, `Vercel Build Output assembled with ${crons.length} cron job${crons.length === 1 ? '' : 's'}.`);
136
+ await context.sendTelemetry();
137
+ }
138
+ export default vercelOutput;
package/dist/program.js CHANGED
@@ -14,13 +14,17 @@
14
14
  limitations under the License.
15
15
  */ import { createRequire } from 'module';
16
16
  import { Command, Option } from 'commander';
17
+ import agentSetup from './commands/agentSetup/agentSetup.js';
17
18
  import build from './commands/build/build.js';
18
19
  import dev from './commands/dev/dev.js';
20
+ import dockerOutput from './commands/dockerOutput/dockerOutput.js';
21
+ import emails from './commands/emails/emails.js';
19
22
  import init from './commands/init/init.js';
20
23
  import initDocker from './commands/init-docker/initDocker.js';
21
24
  import initVercel from './commands/init-vercel/initVercel.js';
22
25
  import start from './commands/start/start.js';
23
26
  import upgrade from './commands/upgrade/upgrade.js';
27
+ import vercelOutput from './commands/vercelOutput/vercelOutput.js';
24
28
  import runCommand from './utils/runCommand.js';
25
29
  const require = createRequire(import.meta.url);
26
30
  const packageJson = require('../package.json');
@@ -37,22 +41,36 @@ const options = {
37
41
  'info',
38
42
  'debug'
39
43
  ]).default('info').env('LOWDEFY_LOG_LEVEL'),
44
+ mockUser: new Option('--mock-user [user]', 'Start the dev server authenticated as a mock user (auth.dev.mockUser). Pass a JSON user object to set identity/roles, e.g. \'{"sub":"dev","roles":["admin"]}\'. Bare flag uses a default roleless user. Dev only.').env('LOWDEFY_DEV_USER'),
40
45
  port: new Option('--port <port>', 'Change the port the development server is hosted at. Default is 3000.').env('PORT'),
46
+ projectDirectory: new Option('--project-directory <project-directory>', 'Change the directory where agent files (.mcp.json, AGENTS.md, Claude Code skill) are written. Default is the nearest ancestor directory containing .git, falling back to the config directory.').env('LOWDEFY_DIRECTORY_PROJECT'),
41
47
  refResolver: new Option('--ref-resolver <ref-resolver-function-path>', 'Path to a JavaScript file containing a _ref resolver function to be used as the app default _ref resolver.'),
42
48
  serverDirectory: new Option('--server-directory <server-directory>', 'Change the server directory. Default is "<config-directory>/.lowdefy/server".').env('LOWDEFY_DIRECTORY_SERVER'),
43
49
  watch: new Option('--watch <paths...>', 'A list of paths to files or directories that should be watched for changes. Globs are supported. Specify each path to watch separated by spaces.'),
44
50
  watchIgnore: new Option('--watch-ignore <paths...>', 'A list of paths to files or directories that should be ignored by the file watcher. Globs are supported. Specify each path to watch separated by spaces.')
45
51
  };
46
- program.command('build').description('Build a Lowdefy production app.').usage('[options]').addOption(options.configDirectory).addOption(options.disableTelemetry).addOption(options.logLevel).option('--no-next-build', 'Do not build the Next.js server.').addOption(options.refResolver).addOption(new Option('--server <server>', 'Server package variant. Use "e2e" for @lowdefy/server-e2e.').choices([
52
+ program.command('agent-setup').description('Set up this project for AI coding agents (.mcp.json, AGENTS.md, Claude Code skill).').usage('[options]').addOption(options.configDirectory).addOption(options.disableTelemetry).addOption(options.logLevel).addOption(options.port).addOption(options.projectDirectory).action(runCommand({
53
+ cliVersion,
54
+ handler: agentSetup
55
+ }));
56
+ program.command('build').description('Build a Lowdefy production app.').usage('[options]').addOption(options.configDirectory).addOption(options.disableTelemetry).addOption(options.logLevel).option('--no-client-build', 'Do not run the Vite client build.').addOption(new Option('--no-next-build', 'Deprecated alias of --no-client-build.').hideHelp()).addOption(options.refResolver).addOption(new Option('--server <server>', 'Server package variant. Use "e2e" for @lowdefy/server-e2e.').choices([
47
57
  'e2e'
48
58
  ])).addOption(options.serverDirectory).action(runCommand({
49
59
  cliVersion,
50
60
  handler: build
51
61
  }));
52
- program.command('dev').description('Start a Lowdefy development server.').usage('[options]').addOption(options.configDirectory).addOption(options.devDirectory).addOption(options.disableTelemetry).addOption(options.logLevel).option('--no-open', 'Do not open a new tab in the default browser.').addOption(options.port).addOption(options.refResolver).addOption(options.watch).addOption(options.watchIgnore).action(runCommand({
62
+ program.command('dev').description('Start a Lowdefy development server.').usage('[options]').addOption(options.configDirectory).addOption(options.devDirectory).addOption(options.disableTelemetry).addOption(options.logLevel).addOption(options.mockUser).option('--no-open', 'Do not open a new tab in the default browser.').addOption(options.port).addOption(options.refResolver).addOption(options.watch).addOption(options.watchIgnore).action(runCommand({
53
63
  cliVersion,
54
64
  handler: dev
55
65
  }));
66
+ program.command('emails').description('Preview notification emails with the React Email preview server.').usage('[options]').addOption(options.configDirectory).addOption(options.disableTelemetry).addOption(options.logLevel).addOption(new Option('--port <port>', 'Change the port the email preview server is hosted at. Default is 3001.').env('PORT')).addOption(options.refResolver).addOption(options.serverDirectory).action(runCommand({
67
+ cliVersion,
68
+ handler: emails
69
+ }));
70
+ program.command('docker-output').description('Assemble a minimal Docker runtime (.lowdefy/docker) from a built app.').usage('[options]').addOption(options.configDirectory).addOption(options.disableTelemetry).addOption(options.logLevel).addOption(options.serverDirectory).action(runCommand({
71
+ cliVersion,
72
+ handler: dockerOutput
73
+ }));
56
74
  program.command('init').description('Initialize a Lowdefy project.').usage('[options]').addOption(options.disableTelemetry).addOption(options.logLevel).action(runCommand({
57
75
  cliVersion,
58
76
  handler: init
@@ -65,6 +83,10 @@ program.command('init-vercel').description('Initialize Vercel deployment install
65
83
  cliVersion,
66
84
  handler: initVercel
67
85
  }));
86
+ program.command('vercel-output').description('Assemble a Vercel Build Output (.vercel/output) from a built app.').usage('[options]').addOption(options.configDirectory).addOption(options.disableTelemetry).addOption(options.logLevel).addOption(options.serverDirectory).action(runCommand({
87
+ cliVersion,
88
+ handler: vercelOutput
89
+ }));
68
90
  program.command('start').description('Start a Lowdefy production app.').usage('[options]').addOption(options.configDirectory).addOption(options.disableTelemetry).addOption(options.logLevel).addOption(options.port).addOption(options.serverDirectory).action(runCommand({
69
91
  cliVersion,
70
92
  handler: start
@@ -12,26 +12,19 @@
12
12
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
- */ import net from 'net';
16
- function checkPortAvailable({ port }) {
17
- return new Promise((resolve, reject)=>{
18
- const server = net.createServer();
19
- server.on('error', (error)=>{
20
- if (error.code === 'EADDRINUSE') {
21
- reject(new Error(`[Server Error] Port ${port} is already in use.
15
+ */ import { isPortAvailable } from '@lowdefy/node-utils';
16
+ async function checkPortAvailable({ port }) {
17
+ const available = await isPortAvailable({
18
+ port
19
+ });
20
+ if (!available) {
21
+ throw new Error(`[Server Error] Port ${port} is already in use.
22
22
 
23
23
  To use a different port, either:
24
24
  - Run with the --port flag: pnpx lowdefy dev --port 3001
25
25
  - Set the port in your lowdefy.yaml:
26
26
  cli:
27
- port: 3001`));
28
- } else {
29
- reject(error);
30
- }
31
- });
32
- server.listen(port, ()=>{
33
- server.close(()=>resolve());
34
- });
35
- });
27
+ port: 3001`);
28
+ }
36
29
  }
37
30
  export default checkPortAvailable;
@@ -0,0 +1,94 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import fs from 'fs/promises';
16
+ import path from 'path';
17
+ import { nodeFileTrace } from '@vercel/nft';
18
+ const CONCURRENCY = 64;
19
+ async function inBatches(items, worker) {
20
+ for(let i = 0; i < items.length; i += CONCURRENCY){
21
+ await Promise.all(items.slice(i, i + CONCURRENCY).map(worker));
22
+ }
23
+ }
24
+ // Traces the runtime file closure of the server entrypoints with @vercel/nft and reproduces it
25
+ // under functionDirectory, preserving relative symlinks. pnpm layouts depend on this: the server's
26
+ // node_modules entries are relative symlinks into a .pnpm virtual store (at the workspace root for
27
+ // workspace members), and nft's fileList contains both the symlink paths and the real target files,
28
+ // all relative to `base` — recreating both yields a working resolution tree inside the function.
29
+ async function copyTracedFiles({ base, entrypoints, functionDirectory, logger, processCwd }) {
30
+ const { fileList, warnings } = await nodeFileTrace(entrypoints, {
31
+ base,
32
+ processCwd
33
+ });
34
+ for (const warning of warnings){
35
+ logger.debug(`nft: ${warning.message}`);
36
+ }
37
+ // Partition by lstat so links are recreated as links, not followed.
38
+ const symlinks = [];
39
+ const files = [];
40
+ const directories = [];
41
+ await inBatches([
42
+ ...fileList
43
+ ], async (file)=>{
44
+ let stat;
45
+ try {
46
+ stat = await fs.lstat(path.join(base, file));
47
+ } catch {
48
+ logger.debug(`nft traced a missing file: ${file}`);
49
+ return;
50
+ }
51
+ if (stat.isSymbolicLink()) {
52
+ symlinks.push(file);
53
+ } else if (stat.isDirectory()) {
54
+ directories.push(file);
55
+ } else {
56
+ files.push({
57
+ file,
58
+ mode: stat.mode
59
+ });
60
+ }
61
+ });
62
+ await inBatches(directories, async (file)=>{
63
+ await fs.mkdir(path.join(functionDirectory, file), {
64
+ recursive: true
65
+ });
66
+ });
67
+ // Symlinks before files, parents before children: a file whose destination path traverses a
68
+ // symlinked directory must land at the link's target, not turn the link's path into a real
69
+ // directory. Sequential — symlink creation is cheap and ordering matters.
70
+ symlinks.sort((a, b)=>a.length - b.length);
71
+ for (const file of symlinks){
72
+ const source = path.join(base, file);
73
+ const destination = path.join(functionDirectory, file);
74
+ await fs.mkdir(path.dirname(destination), {
75
+ recursive: true
76
+ });
77
+ try {
78
+ await fs.symlink(await fs.readlink(source), destination);
79
+ } catch (error) {
80
+ if (error.code !== 'EEXIST') throw error;
81
+ }
82
+ }
83
+ await inBatches(files, async ({ file, mode })=>{
84
+ const source = path.join(base, file);
85
+ const destination = path.join(functionDirectory, file);
86
+ await fs.mkdir(path.dirname(destination), {
87
+ recursive: true
88
+ });
89
+ await fs.copyFile(source, destination);
90
+ await fs.chmod(destination, mode);
91
+ });
92
+ return fileList.size;
93
+ }
94
+ export default copyTracedFiles;