@crewx/app 0.1.0-rc.2 → 0.1.0-rc.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -14,14 +14,60 @@ metadata:
14
14
  | `crewx app open <appId>` | Open an App in this conversation |
15
15
  | `crewx app state <instanceId>` | Read AI-visible state, buttons, and agent access |
16
16
  | `crewx app run <instanceId|appId> <actionId> [--input <JSON>|-] [--request-id <id>]` | Run one available action |
17
- | `crewx app receipt <receiptId>` | Read a safe action receipt |
17
+ | `crewx app receipt <receiptId> [--wait=<seconds>]` | Read a safe action receipt. With `--wait`, poll until it settles or the wait ends |
18
+ | `crewx app build [<appDir>] [--check | --watch]` | Build a `@Tool` App: bundle `server.ts` into `dist/` and regenerate the `manifest.json` actions. `--check` writes nothing and fails on a stale file; `--watch` rebuilds on every change |
19
+ | `crewx app dev <app> [--no-open]` | Start a foreground App dev session with build and reload watching |
20
+ | `crewx app check <app> [--json]` | Run the App dev checks |
21
+ | `crewx app run --dev <app> <actionId> [--input <JSON>|-]` | Run an App action through the dev checks |
18
22
  | `crewx app usage` | Show this guide |
23
+ | `crewx app usage <appId>` | Show one App's manual (its SKILL.md) and an action table generated from its declaration: inputs with real types, agent access, confirmation |
24
+
25
+ `<app>` is the App folder name under `apps/` or its App id (`name` in `manifest.json`, or `pluginId` in `ui-plugin.json`).
26
+
27
+ ## Read the manual before the first run
28
+
29
+ `crewx app usage <appId>` prints the App's manual, then a table of every action: its inputs (`*` = required, real types, limits, allowed values), the agent access (`allowed`, `approval_required`, `denied`, the same words `crewx app state` prints) and the confirmation mode. Send exactly the keys listed. After a successful `crewx app run`, stderr suggests `→ Check: crewx app state <instanceId>`.
30
+
31
+ ## Build a `@Tool` App
32
+
33
+ `crewx app build` needs nothing but the CrewX CLI: it ships the bundler and the SDK that `server.ts` imports as `@crewx/sdk/app`. Run it in the App folder (or pass the folder). The folder needs `server.ts` (default-exports a `CrewXApp` subclass) and a `package.json` with one `bin` name (or `crewx.app.command`); `manifest.json` is created when missing, otherwise only its `_meta["dev.crewx"].actions` is rewritten.
34
+
35
+ Set `crewx.app.env` in `package.json` to `["CREWX_APP_INSTANCE_ID", "CREWX_THREAD_ID"]` (one or both, no repeats, nothing else) when your `@Tool` code reads those values from `process.env`: the build writes the list to every action's `backend.env` and the Host then puts exactly those values in the backend process; without it the backend gets neither and no `env` key is written.
36
+
37
+ stdout is one JSON line with the written `files` and the `actions` found; after a build, stderr says the next command. Exit 0 = built (or in sync with `--check`), 1 = `BUILD_FAILED` (stderr says why) or `BUILD_STALE` (`--check` found a stale file; run `crewx app build`), 2 = usage. `--watch` stays in the foreground and prints one plain line per build until Ctrl-C.
38
+
39
+ ## App development
40
+
41
+ Run the watcher from a developer terminal and leave it in the foreground until you stop it with Ctrl-C:
42
+
43
+ ```bash
44
+ crewx app dev <app>
45
+ ```
46
+
47
+ Use `--no-open` when a browser tab is not needed. The watcher runs `npm run build` when the App has a `build` script,
48
+ then reloads the dev tab after each build. Without a build script, file changes trigger a manual reload.
49
+
50
+ For a one-shot agent run, build and check before calling an action:
51
+
52
+ ```bash
53
+ cd apps/<app>
54
+ npm run build
55
+ cd ../..
56
+ crewx app check <app> --json
57
+ crewx app run --dev <app> <actionId> --input '{"key":"value"}'
58
+ ```
59
+
60
+ `check` exits with code 13 when errors remain. `run --dev` prints the server result and checks; it exits non-zero when the
61
+ action did not succeed. A one-shot agent should use this build/check/run loop instead of holding the foreground watcher.
19
62
 
20
63
  ## When blocked
21
64
 
22
65
  - Exit 3, 9, 10, or 11: do not click the screen, use CUA, or open another browser. Report that you could not use the App route.
66
+ - Exit 14: the server did not answer after the connection was established, so the outcome is unknown. For `run`, retry the same command once with the `requestId` from the JSON payload as `--request-id`; within 15 minutes the server returns the same receipt and result without running the action twice. If it times out again, report `server response delayed, outcome unknown` with the `requestId`. Do not report success or failure, click the screen, or use another route. For other commands, retry once; if it happens again, report it.
23
67
  - Exit 4: do not retry; report the failure.
24
68
  - Use `agentAccess` from `crewx app state`: `allowed` can run, `approval_required` can request human approval, and `denied` must not run.
25
- - Exit 8 means **pending approval**, not success. Never claim the work is done and never work around it with screen clicks or another route.
26
- - Check later with `crewx app receipt <receiptId>`. If the turn must end first, report **pending approval** and include the `receiptId`.
69
+ - Exit 8 = waiting for the user's one-time approval in the App window. Keep this turn running and wait with `crewx app receipt <receiptId> --wait=60` (repeat on exit 124); ending the turn cancels the request. Report success only if the receipt settles as `succeeded`. Never claim the work is done and never work around it with screen clicks or another route. stderr adds `→ Waiting for the user's approval in the App window (expires HH:MM)` and `→ Wait: crewx app receipt <receiptId> --wait=60 (repeat on exit 124 until it settles)`; the JSON on stdout is unchanged.
70
+ - `crewx app receipt <receiptId> --wait=<seconds>` asks the server every 3 seconds until the receipt settles (`succeeded`, `rejected`, `failed`, `conflicted`, `expired`). A settled receipt is printed exactly as `receipt` without `--wait` prints it, with the same exit code. If the wait ends first, it exits 124 with nothing on stdout and one stderr line naming the next command. `<seconds>` is capped at 900, the 15 minutes an approval lives. `--wait=0` or no `--wait` reads the receipt once.
71
+ - `crewx app run` returns the settled receipt, resulting state, and the declared public `result` when one exists. Check `outcome`; if it is `failed` or `rejected`, read `errorCode` to understand the failure. A 202 request returns the pending receipt without a result.
72
+ - Read the receipt again with `crewx app receipt <receiptId>`. The same actor can read the same public `result` repeatedly for 15 minutes after success. After the result expires or the server restarts, the receipt reports `resultExpired: true` without returning the result. An approval that settles as anything but `succeeded` (`rejected`, `expired`, `failed`, `conflicted`) is not done: report the outcome and the `receiptId`.
27
73
  - Include the `receiptId` in your report. Citing an ID is not proof; the checker compares the receipt's `actor.task` with this task.
@@ -0,0 +1,57 @@
1
+ export const SDK_APP_SOURCE_ENTRY: string;
2
+
3
+ export interface BuildOptions {
4
+ appDirectory: string;
5
+ check: boolean;
6
+ watch: boolean;
7
+ }
8
+
9
+ export interface AppConfig {
10
+ command: string[];
11
+ external: string[];
12
+ /** crewx.app.env; present only when package.json sets it. */
13
+ env?: string[];
14
+ }
15
+
16
+ export interface BuildResult {
17
+ /** Stale generated files relative to the current directory; empty unless `check`. */
18
+ stale: string[];
19
+ /** The same files relative to the app folder; empty unless `check`. */
20
+ staleFiles: string[];
21
+ /** Every generated file, relative to the app folder. */
22
+ files: string[];
23
+ /** The actionId of every `@Tool` method. */
24
+ actions: string[];
25
+ /** The file `@crewx/sdk/app` resolved to: the SDK source in the repository, the shipped SDK elsewhere. */
26
+ sdkEntry: string | undefined;
27
+ /** Absolute paths of the source files the bundle read (the shipped SDK is not among them). */
28
+ inputs: string[];
29
+ }
30
+
31
+ export function parseArguments(argv: string[], cwd?: string): BuildOptions;
32
+ export function readAppConfig(appDirectory: string): AppConfig;
33
+ export function usesSdkSource(appDirectory: string): boolean;
34
+ export function renderActionDeclarations(
35
+ imported: { default: unknown; generateManifest: (app: unknown, command: string[]) => { actions?: unknown } },
36
+ command: string[],
37
+ existingText?: string,
38
+ backendEnv?: readonly string[],
39
+ ): { fileName: string; text: string };
40
+ export function buildApp(options: {
41
+ appDirectory: string;
42
+ check?: boolean;
43
+ log?: (message: string) => void;
44
+ }): Promise<BuildResult>;
45
+ export function watchApp(
46
+ appDirectory: string,
47
+ log: (message: string) => void,
48
+ options?: { signal?: AbortSignal },
49
+ ): Promise<void>;
50
+ export function runScript(
51
+ argv: string[],
52
+ options?: {
53
+ cwd?: string;
54
+ stdout?: { write(text: string): unknown };
55
+ stderr?: { write(text: string): unknown };
56
+ },
57
+ ): Promise<number>;
@@ -0,0 +1,498 @@
1
+ // Shared build for every CrewX App written as a `@Tool` class. This file is the one implementation:
2
+ // `crewx app build` (src/engine.ts) and the repository script scripts/build-app.mjs both call it.
3
+ //
4
+ // It is plain ESM that ships as source (no TypeScript step), so a fresh checkout can build an App
5
+ // before any package has been compiled, and the published @crewx/app carries it as it is.
6
+ //
7
+ // Input : <app>/server.ts, which default-exports a CrewXApp subclass.
8
+ // Output: <app>/dist/server.mjs the App bundle (also exports runSkillApp, generateManifest, ...)
9
+ // <app>/dist/entry.mjs the one-shot process entry (runAppEntry) for the skill CLI
10
+ // <app>/manifest.json the App manifest; only _meta["dev.crewx"].actions is regenerated
11
+ // from the @Tool methods, every other key stays as authored
12
+ //
13
+ // The build never writes ui-plugin.json. A new App (no manifest.json yet) gets a whole manifest.json
14
+ // derived from the static definition in server.ts, with release version 0.1.0.
15
+ //
16
+ // `@crewx/sdk/app` resolves to the SDK source (packages/sdk/src/app/index.ts) when the app lives in
17
+ // the CrewX repository this file is part of, so an SDK source edit reaches the next app build
18
+ // without `pnpm build:sdk`. Anywhere else it resolves to the SDK the CrewX CLI ships with (the
19
+ // @crewx/sdk next to @crewx/app). Either way the SDK code is inlined: no `@crewx/sdk` import is
20
+ // left in the bundles.
21
+ //
22
+ // Optional app settings in <app>/package.json:
23
+ // "crewx": { "app": {
24
+ // "command": ["sheet", "app"], // skill command the actions run; default: [<the one bin name>, "app"]
25
+ // "external": ["lib/**"], // app files (globs relative to the app) kept out of the bundle
26
+ // // and imported at run time from their real place
27
+ // "env": ["CREWX_APP_INSTANCE_ID", "CREWX_THREAD_ID"]
28
+ // // written to every generated action's backend.env, so the Host
29
+ // // puts these values in the process environment; absent = no env key
30
+ // } }
31
+
32
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, watch, writeFileSync } from 'node:fs';
33
+ import { createRequire } from 'node:module';
34
+ import { tmpdir } from 'node:os';
35
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
36
+ import { fileURLToPath, pathToFileURL } from 'node:url';
37
+ import { CREWX_META_NAMESPACE, convertV2ToManifest } from './manifest.mjs';
38
+
39
+ const ownDirectory = dirname(fileURLToPath(import.meta.url));
40
+ const requireFromPackage = createRequire(import.meta.url);
41
+
42
+ // In the CrewX repository this file is <repository>/packages/built-in/app/build-app/index.mjs.
43
+ // Installed from npm it sits under node_modules, where the same four steps up do not reach a
44
+ // repository, so the layout check below is what tells the two apart.
45
+ const REPOSITORY_LAYOUT = join('packages', 'built-in', 'app', 'build-app');
46
+ const repositoryRoot = resolve(ownDirectory, '..', '..', '..', '..');
47
+ export const SDK_APP_SOURCE_ENTRY = join(repositoryRoot, 'packages', 'sdk', 'src', 'app', 'index.ts');
48
+ const SDK_APP_SOURCE_DIRECTORY = dirname(SDK_APP_SOURCE_ENTRY);
49
+
50
+ const SERVER_ENTRY = 'server.ts';
51
+ const SERVER_BUNDLE = 'dist/server.mjs';
52
+ const ENTRY_BUNDLE = 'dist/entry.mjs';
53
+ const MANIFEST_FILE = 'manifest.json';
54
+ const WATCH_DEBOUNCE_MS = 100;
55
+
56
+ // What dist/server.mjs exposes on top of the app's own exports. sheet.mjs-style launchers call
57
+ // runSkillApp through runAppEntry; the build itself calls generateManifest.
58
+ const SERVER_WRAPPER = [
59
+ `import App from './${SERVER_ENTRY}';`,
60
+ `export * from './${SERVER_ENTRY}';`,
61
+ 'export default App;',
62
+ "export { AppError, CrewXApp, Tool, executeAppRequest, generateManifest, getToolDeclarations, runSkillApp } from '@crewx/sdk/app';",
63
+ '',
64
+ ].join('\n');
65
+ const ENTRY_WRAPPER = "export { runAppEntry } from '@crewx/sdk/app';\n";
66
+ // The rule the manifest reader applies to backend.env. It is bundled on its own and only imported by
67
+ // the build, so it never reaches dist/server.mjs (and every App without crewx.app.env builds the same bytes).
68
+ const ENV_RULE_WRAPPER = "export { SKILL_BACKEND_ENV_NAMES, isSkillBackendEnv } from '@crewx/sdk/app';\n";
69
+ const ENV_RULE_BUNDLE = 'dist/.env-rule.mjs'; // only names the esbuild output; the file is written under the OS temp folder
70
+
71
+ // The published @crewx/sdk/app is CommonJS. Inlined into an ES module, its require() calls
72
+ // (node built-ins) need a real require.
73
+ const COMMONJS_INTEROP_BANNER = "import { createRequire as __crewxCreateRequire } from 'node:module'; const require = __crewxCreateRequire(import.meta.url);";
74
+
75
+ const TSCONFIG_RAW = {
76
+ compilerOptions: {
77
+ target: 'ES2022',
78
+ module: 'ESNext',
79
+ moduleResolution: 'Bundler',
80
+ experimentalDecorators: false,
81
+ emitDecoratorMetadata: false,
82
+ useDefineForClassFields: true,
83
+ lib: ['ES2022', 'esnext.decorators'],
84
+ allowJs: true,
85
+ checkJs: false,
86
+ },
87
+ };
88
+
89
+ /**
90
+ * The one place that knows where the @Tool declarations are written: the `actions` of
91
+ * `<app>/manifest.json`, i.e. `_meta["dev.crewx"].actions`. `existingText` is the manifest.json
92
+ * already on disk. With it only `actions` is replaced, so every other key keeps its value and its
93
+ * place (the file is written back as two-space JSON). Without it the whole file is derived from the
94
+ * App's static definition and starts at release version 0.1.0.
95
+ *
96
+ * `backendEnv` (crewx.app.env, already checked by the caller) is copied to the `backend.env` of every
97
+ * action; left out, no action gets an `env` key.
98
+ */
99
+ export function renderActionDeclarations(imported, command, existingText, backendEnv) {
100
+ let generated = imported.generateManifest(imported.default, command);
101
+ if (backendEnv !== undefined && Array.isArray(generated.actions)) {
102
+ generated = {
103
+ ...generated,
104
+ actions: generated.actions.map((action) => ({ ...action, backend: { ...action.backend, env: [...backendEnv] } })),
105
+ };
106
+ }
107
+ let manifest;
108
+ if (existingText === undefined) {
109
+ manifest = convertV2ToManifest(generated);
110
+ } else {
111
+ try {
112
+ manifest = JSON.parse(existingText);
113
+ } catch (error) {
114
+ throw new Error(`${MANIFEST_FILE} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
115
+ }
116
+ const dev = manifest?._meta?.[CREWX_META_NAMESPACE];
117
+ if (dev === null || typeof dev !== 'object' || Array.isArray(dev)) {
118
+ throw new Error(`${MANIFEST_FILE} must have an object at _meta["${CREWX_META_NAMESPACE}"]`);
119
+ }
120
+ dev.actions = generated.actions;
121
+ }
122
+ return { fileName: MANIFEST_FILE, text: `${JSON.stringify(manifest, null, 2)}\n` };
123
+ }
124
+
125
+ /**
126
+ * `[<appDir>] [--app-dir=<dir>] [--check | --watch]`. The app folder is the current directory
127
+ * unless one is given, once, either way.
128
+ */
129
+ export function parseArguments(argv, cwd = process.cwd()) {
130
+ let appDirectory;
131
+ let check = false;
132
+ let watchMode = false;
133
+ const setAppDirectory = (directory) => {
134
+ if (appDirectory !== undefined) throw new Error('Give the app folder only once');
135
+ appDirectory = resolve(cwd, directory);
136
+ };
137
+ for (const argument of argv) {
138
+ if (argument === '--check') check = true;
139
+ else if (argument === '--watch') watchMode = true;
140
+ else if (argument.startsWith('--app-dir=')) setAppDirectory(argument.slice('--app-dir='.length));
141
+ else if (argument.startsWith('-')) throw new Error(`Unknown argument: ${argument}`);
142
+ else setAppDirectory(argument);
143
+ }
144
+ if (check && watchMode) throw new Error('--check and --watch cannot be combined');
145
+ return { appDirectory: appDirectory ?? cwd, check, watch: watchMode };
146
+ }
147
+
148
+ export function readAppConfig(appDirectory) {
149
+ const packagePath = join(appDirectory, 'package.json');
150
+ if (!existsSync(packagePath)) throw new Error(`${packagePath} not found; run the build from an app folder or give the app folder`);
151
+ const pkg = JSON.parse(readFileSync(packagePath, 'utf8'));
152
+ const config = pkg.crewx?.app ?? {};
153
+ const command = config.command ?? deriveCommand(pkg);
154
+ if (!Array.isArray(command) || command.length === 0 || !command.every((token) => typeof token === 'string' && token !== '')) {
155
+ throw new Error('crewx.app.command must be a non-empty array of strings');
156
+ }
157
+ const external = config.external ?? [];
158
+ if (!Array.isArray(external) || !external.every((glob) => typeof glob === 'string')) {
159
+ throw new Error('crewx.app.external must be an array of globs');
160
+ }
161
+ const env = config.env;
162
+ if (env !== undefined && (!Array.isArray(env) || !env.every((name) => typeof name === 'string'))) {
163
+ throw new Error('crewx.app.env must be an array of environment variable names');
164
+ }
165
+ return { command, external, ...(env === undefined ? {} : { env }) };
166
+ }
167
+
168
+ /** Refuses a `crewx.app.env` the manifest reader would refuse, by asking the reader's own rule. */
169
+ async function assertBackendEnv(esbuild, appDirectory, config) {
170
+ let ruleBundle;
171
+ try {
172
+ ruleBundle = await bundle(esbuild, appDirectory, config, {
173
+ contents: ENV_RULE_WRAPPER, sourcefile: 'crewx-app-env-rule.ts', outfile: ENV_RULE_BUNDLE,
174
+ });
175
+ } catch (error) {
176
+ if (error.errors?.some(({ text }) => /No matching export/u.test(text))) {
177
+ throw new Error('crewx.app.env needs a newer @crewx/sdk: reinstall the CrewX CLI');
178
+ }
179
+ throw error;
180
+ }
181
+ // The bundle imports nothing from the app, so it runs from the OS temp folder and leaves dist/ alone.
182
+ const temporaryDirectory = mkdtempSync(join(tmpdir(), 'crewx-app-env-'));
183
+ try {
184
+ const temporaryRule = join(temporaryDirectory, 'rule.mjs');
185
+ writeFileSync(temporaryRule, ruleBundle.text, 'utf8');
186
+ const rule = await import(pathToFileURL(temporaryRule).href);
187
+ if (!rule.isSkillBackendEnv(config.env)) {
188
+ throw new Error(`crewx.app.env must be a non-empty list of distinct names from ${rule.SKILL_BACKEND_ENV_NAMES.join(', ')}`);
189
+ }
190
+ } finally {
191
+ rmSync(temporaryDirectory, { recursive: true, force: true });
192
+ }
193
+ }
194
+
195
+ function deriveCommand(pkg) {
196
+ const bin = pkg.bin;
197
+ const names = typeof bin === 'string'
198
+ ? [String(pkg.name ?? '').replace(/^@[^/]+\//u, '')]
199
+ : Object.keys(bin ?? {});
200
+ if (names.length !== 1 || names[0] === '') {
201
+ throw new Error('cannot derive the skill command: give package.json exactly one "bin" name, or set crewx.app.command');
202
+ }
203
+ return [names[0], 'app'];
204
+ }
205
+
206
+ function globToRegExp(glob) {
207
+ let pattern = '^';
208
+ for (let index = 0; index < glob.length; index += 1) {
209
+ const character = glob[index];
210
+ if (character === '*' && glob[index + 1] === '*') {
211
+ pattern += '.*';
212
+ index += 1;
213
+ } else if (character === '*') {
214
+ pattern += '[^/]*';
215
+ } else if (character === '?') {
216
+ pattern += '[^/]';
217
+ } else {
218
+ pattern += character.replace(/[\\^$+{}()[\].|]/g, '\\$&');
219
+ }
220
+ }
221
+ return new RegExp(`${pattern}$`);
222
+ }
223
+
224
+ function isInside(parent, child) {
225
+ const relativePath = relative(parent, child);
226
+ return relativePath !== '' && !relativePath.startsWith('..') && !isAbsolute(relativePath);
227
+ }
228
+
229
+ /** True when the app folder is part of the CrewX repository this file belongs to, so the SDK source is the right target. */
230
+ export function usesSdkSource(appDirectory) {
231
+ if (ownDirectory !== join(repositoryRoot, REPOSITORY_LAYOUT)) return false;
232
+ if (!existsSync(SDK_APP_SOURCE_ENTRY)) return false;
233
+ return isInside(realpathSync(repositoryRoot), realpathSync(appDirectory));
234
+ }
235
+
236
+ async function loadEsbuild(appDirectory) {
237
+ // Prefer the esbuild the app pins, so its output matches what its own build produced before.
238
+ try {
239
+ const resolved = createRequire(join(appDirectory, 'package.json')).resolve('esbuild');
240
+ return await import(pathToFileURL(resolved).href);
241
+ } catch {
242
+ // Otherwise the esbuild that comes with @crewx/app.
243
+ }
244
+ try {
245
+ return await import('esbuild');
246
+ } catch {
247
+ throw new Error('cannot load esbuild: it comes with @crewx/app, so reinstall the CrewX CLI');
248
+ }
249
+ }
250
+
251
+ // The shipped SDK is bundled under this virtual name instead of its real path. esbuild writes a
252
+ // CommonJS module's path into the bundle (`__commonJS({ "<path>"(exports) {`); the real path is
253
+ // <install>/node_modules/@crewx/sdk/dist/app/index.js, which would put "@crewx/sdk" into the bundle
254
+ // and make its bytes depend on where the CLI is installed.
255
+ const SHIPPED_SDK_NAMESPACE = 'crewx-sdk';
256
+ const SHIPPED_SDK_MODULE = 'app/index.js';
257
+
258
+ /** The `@crewx/sdk/app` file the CLI ships with (the @crewx/sdk next to @crewx/app), or undefined. */
259
+ function shippedSdkEntry() {
260
+ try {
261
+ return requireFromPackage.resolve('@crewx/sdk/app');
262
+ } catch {
263
+ return undefined;
264
+ }
265
+ }
266
+
267
+ function createPlugins(appDirectory, config) {
268
+ const externalPatterns = config.external.map(globToRegExp);
269
+ const sdkFromSource = usesSdkSource(appDirectory);
270
+ return [{
271
+ name: 'crewx-sdk-app',
272
+ setup(buildContext) {
273
+ buildContext.onResolve({ filter: /^@crewx\/sdk\/app$/ }, () => {
274
+ if (sdkFromSource) return { path: SDK_APP_SOURCE_ENTRY };
275
+ const shipped = shippedSdkEntry();
276
+ if (shipped === undefined) {
277
+ return { errors: [{ text: 'cannot resolve @crewx/sdk/app: it comes with the CrewX CLI, so reinstall the CLI' }] };
278
+ }
279
+ return { path: SHIPPED_SDK_MODULE, namespace: SHIPPED_SDK_NAMESPACE, pluginData: { file: shipped } };
280
+ });
281
+ buildContext.onLoad({ filter: /.*/, namespace: SHIPPED_SDK_NAMESPACE }, ({ pluginData }) => ({
282
+ contents: readFileSync(pluginData.file, 'utf8'),
283
+ loader: 'js',
284
+ resolveDir: dirname(pluginData.file),
285
+ }));
286
+ },
287
+ }, {
288
+ name: 'crewx-app-external-files',
289
+ setup(buildContext) {
290
+ if (externalPatterns.length === 0) return;
291
+ buildContext.onResolve({ filter: /^\.\.?\// }, ({ path, resolveDir }) => {
292
+ const absolute = resolve(resolveDir, path);
293
+ const fromApp = relative(appDirectory, absolute).split(sep).join('/');
294
+ if (fromApp.startsWith('..') || !externalPatterns.some((pattern) => pattern.test(fromApp))) return undefined;
295
+ const fromDist = relative(join(appDirectory, 'dist'), absolute).split(sep).join('/');
296
+ return { path: fromDist.startsWith('.') ? fromDist : `./${fromDist}`, external: true };
297
+ });
298
+ },
299
+ }];
300
+ }
301
+
302
+ async function bundle(esbuild, appDirectory, config, { contents, sourcefile, outfile, metafile = false }) {
303
+ const result = await esbuild.build({
304
+ ...(usesSdkSource(appDirectory) ? {} : { banner: { js: COMMONJS_INTEROP_BANNER } }),
305
+ absWorkingDir: appDirectory,
306
+ stdin: { contents, resolveDir: appDirectory, sourcefile, loader: 'ts' },
307
+ outfile,
308
+ bundle: true,
309
+ write: false,
310
+ metafile,
311
+ format: 'esm',
312
+ platform: 'node',
313
+ target: 'node22',
314
+ packages: 'external',
315
+ plugins: createPlugins(appDirectory, config),
316
+ tsconfigRaw: TSCONFIG_RAW,
317
+ logLevel: 'silent',
318
+ }).catch((error) => {
319
+ // The wrapper imports `default` from server.ts; say what is wrong in the app's terms.
320
+ if (error.errors?.some(({ text }) => /No matching export in ".*server\.ts" for import "default"/u.test(text))) {
321
+ throw new Error(`${SERVER_ENTRY} must default-export a CrewXApp subclass with a static definition`);
322
+ }
323
+ throw error;
324
+ });
325
+ const expectedPath = join(appDirectory, outfile);
326
+ const text = result.outputFiles.find((file) => file.path === expectedPath)?.text;
327
+ if (text === undefined) throw new Error(`esbuild did not produce ${outfile}`);
328
+ if (/@crewx\/sdk(?:['"/])/u.test(text.replace(/^\s*\/\/.*$/gmu, ''))) {
329
+ throw new Error(`${outfile} still imports @crewx/sdk; server.ts may import only @crewx/sdk/app`);
330
+ }
331
+ return { text, inputs: result.metafile ? Object.keys(result.metafile.inputs) : [] };
332
+ }
333
+
334
+ /**
335
+ * Builds the bundles and the action declarations. With `check` nothing is written and the
336
+ * result lists every generated file that differs from what is on disk.
337
+ *
338
+ * Returns `stale` (paths relative to the current directory, empty unless `check`), `staleFiles`
339
+ * (the same files relative to the app folder), `files` (every generated file, relative to the app
340
+ * folder), `actions` (the actionId of every @Tool), `sdkEntry` (the file `@crewx/sdk/app` resolved
341
+ * to: the SDK source in the repository, the shipped SDK anywhere else) and `inputs` (the source
342
+ * files the bundle read; the shipped SDK is not among them).
343
+ */
344
+ export async function buildApp({ appDirectory, check = false, log = () => {} }) {
345
+ if (!existsSync(join(appDirectory, SERVER_ENTRY))) throw new Error(`${join(appDirectory, SERVER_ENTRY)} not found`);
346
+ const config = readAppConfig(appDirectory);
347
+ const esbuild = await loadEsbuild(appDirectory);
348
+ if (config.env !== undefined) await assertBackendEnv(esbuild, appDirectory, config);
349
+ const distDirectory = join(appDirectory, 'dist');
350
+ const distDirectoryExisted = existsSync(distDirectory);
351
+ const temporaryBundle = join(distDirectory, `.server-build-${process.pid}.mjs`);
352
+
353
+ try {
354
+ const server = await bundle(esbuild, appDirectory, config, {
355
+ contents: SERVER_WRAPPER, sourcefile: 'crewx-app-server.ts', outfile: SERVER_BUNDLE, metafile: true,
356
+ });
357
+ const entry = await bundle(esbuild, appDirectory, config, {
358
+ contents: ENTRY_WRAPPER, sourcefile: 'crewx-app-entry.ts', outfile: ENTRY_BUNDLE,
359
+ });
360
+
361
+ // The bundle imports its externals relative to dist/, so it has to run from there.
362
+ mkdirSync(distDirectory, { recursive: true });
363
+ writeFileSync(temporaryBundle, server.text, 'utf8');
364
+ const imported = await import(`${pathToFileURL(temporaryBundle).href}?build=${Date.now()}`);
365
+ if (typeof imported.default !== 'function' || imported.default.definition === undefined) {
366
+ throw new Error(`${SERVER_ENTRY} must default-export a CrewXApp subclass with a static definition`);
367
+ }
368
+ const manifestPath = join(appDirectory, MANIFEST_FILE);
369
+ const existingManifest = existsSync(manifestPath) ? readFileSync(manifestPath, 'utf8') : undefined;
370
+ const declarations = renderActionDeclarations(imported, config.command, existingManifest, config.env);
371
+
372
+ const generated = [
373
+ { path: join(appDirectory, SERVER_BUNDLE), text: server.text },
374
+ { path: join(appDirectory, ENTRY_BUNDLE), text: entry.text },
375
+ { path: join(appDirectory, declarations.fileName), text: declarations.text },
376
+ ];
377
+ const staleEntries = generated.filter(({ path, text }) => !existsSync(path) || readFileSync(path, 'utf8') !== text);
378
+ const stale = staleEntries.map(({ path }) => relative(process.cwd(), path));
379
+
380
+ if (!check) {
381
+ for (const { path, text } of generated) writeFileSync(path, text, 'utf8');
382
+ log(`Built ${relative(process.cwd(), join(appDirectory, SERVER_BUNDLE))} and regenerated ${relative(process.cwd(), join(appDirectory, declarations.fileName))}.`);
383
+ }
384
+ return {
385
+ stale: check ? stale : [],
386
+ staleFiles: check ? staleEntries.map(({ path }) => relative(appDirectory, path)) : [],
387
+ files: generated.map(({ path }) => relative(appDirectory, path)),
388
+ actions: declaredActionIds(declarations.text),
389
+ sdkEntry: usesSdkSource(appDirectory) ? SDK_APP_SOURCE_ENTRY : shippedSdkEntry(),
390
+ inputs: server.inputs
391
+ .filter((input) => !input.startsWith(`${SHIPPED_SDK_NAMESPACE}:`))
392
+ .map((input) => resolve(appDirectory, input)),
393
+ };
394
+ } finally {
395
+ rmSync(temporaryBundle, { force: true });
396
+ if (!distDirectoryExisted && check) rmSync(distDirectory, { recursive: true, force: true });
397
+ }
398
+ }
399
+
400
+ function declaredActionIds(manifestText) {
401
+ const actions = JSON.parse(manifestText)?._meta?.[CREWX_META_NAMESPACE]?.actions;
402
+ return Array.isArray(actions) ? actions.map((action) => action?.actionId).filter((id) => typeof id === 'string') : [];
403
+ }
404
+
405
+ /**
406
+ * Rebuilds when an imported source file changes; SDK source edits count when the app uses it.
407
+ * Runs until `signal` aborts (never, without one) and then closes its watchers.
408
+ */
409
+ export async function watchApp(appDirectory, log, { signal } = {}) {
410
+ const watchers = new Map();
411
+ let building = false;
412
+ let queued = false;
413
+ let timer;
414
+ let inputs = new Set();
415
+
416
+ const rebuild = async () => {
417
+ if (building) {
418
+ queued = true;
419
+ return;
420
+ }
421
+ building = true;
422
+ try {
423
+ const result = await buildApp({ appDirectory, log });
424
+ inputs = new Set(result.inputs);
425
+ } catch (error) {
426
+ log(`Build failed: ${error instanceof Error ? error.message : String(error)}`);
427
+ } finally {
428
+ building = false;
429
+ if (!signal?.aborted) refreshWatchers();
430
+ if (queued && !signal?.aborted) {
431
+ queued = false;
432
+ void rebuild();
433
+ }
434
+ }
435
+ };
436
+
437
+ const refreshWatchers = () => {
438
+ const directories = new Set([appDirectory, ...[...inputs].map((input) => dirname(input))]);
439
+ if (usesSdkSource(appDirectory)) directories.add(SDK_APP_SOURCE_DIRECTORY);
440
+ for (const [directory, watcher] of watchers) {
441
+ if (!directories.has(directory)) {
442
+ watcher.close();
443
+ watchers.delete(directory);
444
+ }
445
+ }
446
+ for (const directory of directories) {
447
+ if (watchers.has(directory) || !existsSync(directory)) continue;
448
+ watchers.set(directory, watch(directory, (_event, filename) => {
449
+ if (filename === null) return;
450
+ const changed = join(directory, filename.toString());
451
+ // Generated files live next to the sources; reacting to them would loop.
452
+ const relevant = inputs.has(changed)
453
+ || changed === join(appDirectory, 'package.json')
454
+ || (directory === SDK_APP_SOURCE_DIRECTORY && /\.tsx?$/u.test(changed));
455
+ if (!relevant) return;
456
+ clearTimeout(timer);
457
+ timer = setTimeout(() => void rebuild(), WATCH_DEBOUNCE_MS);
458
+ }));
459
+ }
460
+ };
461
+
462
+ await rebuild();
463
+ log('Watching for changes. Press Ctrl+C to stop.');
464
+ await new Promise((resolveStopped) => {
465
+ if (signal === undefined) return;
466
+ if (signal.aborted) resolveStopped();
467
+ else signal.addEventListener('abort', resolveStopped, { once: true });
468
+ });
469
+ clearTimeout(timer);
470
+ for (const watcher of watchers.values()) watcher.close();
471
+ watchers.clear();
472
+ }
473
+
474
+ /**
475
+ * The command line of scripts/build-app.mjs: plain-text lines on stdout, errors on stderr.
476
+ * Returns the exit code (0 ok, 1 failed or stale).
477
+ */
478
+ export async function runScript(argv, { cwd = process.cwd(), stdout = process.stdout, stderr = process.stderr } = {}) {
479
+ const log = (message) => stdout.write(`${message}\n`);
480
+ try {
481
+ const options = parseArguments(argv, cwd);
482
+ if (options.watch) {
483
+ await watchApp(options.appDirectory, log);
484
+ return 0;
485
+ }
486
+ const { stale } = await buildApp({ appDirectory: options.appDirectory, check: options.check, log });
487
+ if (!options.check) return 0;
488
+ if (stale.length > 0) {
489
+ stderr.write(`Stale generated files: ${stale.join(', ')}\n`);
490
+ return 1;
491
+ }
492
+ log(`${basename(options.appDirectory)} App build artifacts are in sync.`);
493
+ return 0;
494
+ } catch (error) {
495
+ stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
496
+ return 1;
497
+ }
498
+ }
@@ -0,0 +1,4 @@
1
+ // The App build is an ES module, but @crewx/app compiles to CommonJS, where TypeScript turns
2
+ // `import()` into `require()`. This file is plain CommonJS, so the `import()` below stays a real
3
+ // dynamic import (and works inside vitest, where `new Function('return import(...)')` does not).
4
+ module.exports = { loadAppBuilder: () => import('./index.mjs') };