@crewx/app 0.1.0-rc.17 → 0.1.0-rc.19
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 +17 -4
- package/build-app/index.d.mts +54 -0
- package/build-app/index.mjs +448 -0
- package/build-app/load.cjs +4 -0
- package/build-app/manifest.mjs +63 -0
- package/dist/src/engine.d.ts +4 -1
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +306 -24
- package/dist/src/engine.js.map +1 -1
- package/dist/src/receipt-wait.d.ts +24 -0
- package/dist/src/receipt-wait.d.ts.map +1 -0
- package/dist/src/receipt-wait.js +41 -0
- package/dist/src/receipt-wait.js.map +1 -0
- package/dist/src/usage.d.ts +25 -0
- package/dist/src/usage.d.ts.map +1 -0
- package/dist/src/usage.js +182 -0
- package/dist/src/usage.js.map +1 -0
- package/package.json +6 -1
package/SKILL.md
CHANGED
|
@@ -14,13 +14,25 @@ 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
|
|
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 |
|
|
18
19
|
| `crewx app dev <app> [--no-open]` | Start a foreground App dev session with build and reload watching |
|
|
19
20
|
| `crewx app check <app> [--json]` | Run the App dev checks |
|
|
20
21
|
| `crewx app run --dev <app> <actionId> [--input <JSON>|-]` | Run an App action through the dev checks |
|
|
21
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 |
|
|
22
24
|
|
|
23
|
-
`<app>` is the App folder name under `apps/` or its `pluginId`
|
|
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
|
+
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.
|
|
24
36
|
|
|
25
37
|
## App development
|
|
26
38
|
|
|
@@ -52,7 +64,8 @@ action did not succeed. A one-shot agent should use this build/check/run loop in
|
|
|
52
64
|
- 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.
|
|
53
65
|
- Exit 4: do not retry; report the failure.
|
|
54
66
|
- Use `agentAccess` from `crewx app state`: `allowed` can run, `approval_required` can request human approval, and `denied` must not run.
|
|
55
|
-
- Exit 8
|
|
67
|
+
- 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.
|
|
68
|
+
- `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.
|
|
56
69
|
- `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.
|
|
57
|
-
-
|
|
70
|
+
- 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`.
|
|
58
71
|
- 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,54 @@
|
|
|
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
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface BuildResult {
|
|
15
|
+
/** Stale generated files relative to the current directory; empty unless `check`. */
|
|
16
|
+
stale: string[];
|
|
17
|
+
/** The same files relative to the app folder; empty unless `check`. */
|
|
18
|
+
staleFiles: string[];
|
|
19
|
+
/** Every generated file, relative to the app folder. */
|
|
20
|
+
files: string[];
|
|
21
|
+
/** The actionId of every `@Tool` method. */
|
|
22
|
+
actions: string[];
|
|
23
|
+
/** The file `@crewx/sdk/app` resolved to: the SDK source in the repository, the shipped SDK elsewhere. */
|
|
24
|
+
sdkEntry: string | undefined;
|
|
25
|
+
/** Absolute paths of the source files the bundle read (the shipped SDK is not among them). */
|
|
26
|
+
inputs: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseArguments(argv: string[], cwd?: string): BuildOptions;
|
|
30
|
+
export function readAppConfig(appDirectory: string): AppConfig;
|
|
31
|
+
export function usesSdkSource(appDirectory: string): boolean;
|
|
32
|
+
export function renderActionDeclarations(
|
|
33
|
+
imported: { default: unknown; generateManifest: (app: unknown, command: string[]) => { actions?: unknown } },
|
|
34
|
+
command: string[],
|
|
35
|
+
existingText?: string,
|
|
36
|
+
): { fileName: string; text: string };
|
|
37
|
+
export function buildApp(options: {
|
|
38
|
+
appDirectory: string;
|
|
39
|
+
check?: boolean;
|
|
40
|
+
log?: (message: string) => void;
|
|
41
|
+
}): Promise<BuildResult>;
|
|
42
|
+
export function watchApp(
|
|
43
|
+
appDirectory: string,
|
|
44
|
+
log: (message: string) => void,
|
|
45
|
+
options?: { signal?: AbortSignal },
|
|
46
|
+
): Promise<void>;
|
|
47
|
+
export function runScript(
|
|
48
|
+
argv: string[],
|
|
49
|
+
options?: {
|
|
50
|
+
cwd?: string;
|
|
51
|
+
stdout?: { write(text: string): unknown };
|
|
52
|
+
stderr?: { write(text: string): unknown };
|
|
53
|
+
},
|
|
54
|
+
): Promise<number>;
|
|
@@ -0,0 +1,448 @@
|
|
|
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
|
+
|
|
28
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, watch, writeFileSync } from 'node:fs';
|
|
29
|
+
import { createRequire } from 'node:module';
|
|
30
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
31
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
32
|
+
import { CREWX_META_NAMESPACE, convertV2ToManifest } from './manifest.mjs';
|
|
33
|
+
|
|
34
|
+
const ownDirectory = dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const requireFromPackage = createRequire(import.meta.url);
|
|
36
|
+
|
|
37
|
+
// In the CrewX repository this file is <repository>/packages/built-in/app/build-app/index.mjs.
|
|
38
|
+
// Installed from npm it sits under node_modules, where the same four steps up do not reach a
|
|
39
|
+
// repository, so the layout check below is what tells the two apart.
|
|
40
|
+
const REPOSITORY_LAYOUT = join('packages', 'built-in', 'app', 'build-app');
|
|
41
|
+
const repositoryRoot = resolve(ownDirectory, '..', '..', '..', '..');
|
|
42
|
+
export const SDK_APP_SOURCE_ENTRY = join(repositoryRoot, 'packages', 'sdk', 'src', 'app', 'index.ts');
|
|
43
|
+
const SDK_APP_SOURCE_DIRECTORY = dirname(SDK_APP_SOURCE_ENTRY);
|
|
44
|
+
|
|
45
|
+
const SERVER_ENTRY = 'server.ts';
|
|
46
|
+
const SERVER_BUNDLE = 'dist/server.mjs';
|
|
47
|
+
const ENTRY_BUNDLE = 'dist/entry.mjs';
|
|
48
|
+
const MANIFEST_FILE = 'manifest.json';
|
|
49
|
+
const WATCH_DEBOUNCE_MS = 100;
|
|
50
|
+
|
|
51
|
+
// What dist/server.mjs exposes on top of the app's own exports. sheet.mjs-style launchers call
|
|
52
|
+
// runSkillApp through runAppEntry; the build itself calls generateManifest.
|
|
53
|
+
const SERVER_WRAPPER = [
|
|
54
|
+
`import App from './${SERVER_ENTRY}';`,
|
|
55
|
+
`export * from './${SERVER_ENTRY}';`,
|
|
56
|
+
'export default App;',
|
|
57
|
+
"export { AppError, CrewXApp, Tool, executeAppRequest, generateManifest, getToolDeclarations, runSkillApp } from '@crewx/sdk/app';",
|
|
58
|
+
'',
|
|
59
|
+
].join('\n');
|
|
60
|
+
const ENTRY_WRAPPER = "export { runAppEntry } from '@crewx/sdk/app';\n";
|
|
61
|
+
|
|
62
|
+
// The published @crewx/sdk/app is CommonJS. Inlined into an ES module, its require() calls
|
|
63
|
+
// (node built-ins) need a real require.
|
|
64
|
+
const COMMONJS_INTEROP_BANNER = "import { createRequire as __crewxCreateRequire } from 'node:module'; const require = __crewxCreateRequire(import.meta.url);";
|
|
65
|
+
|
|
66
|
+
const TSCONFIG_RAW = {
|
|
67
|
+
compilerOptions: {
|
|
68
|
+
target: 'ES2022',
|
|
69
|
+
module: 'ESNext',
|
|
70
|
+
moduleResolution: 'Bundler',
|
|
71
|
+
experimentalDecorators: false,
|
|
72
|
+
emitDecoratorMetadata: false,
|
|
73
|
+
useDefineForClassFields: true,
|
|
74
|
+
lib: ['ES2022', 'esnext.decorators'],
|
|
75
|
+
allowJs: true,
|
|
76
|
+
checkJs: false,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The one place that knows where the @Tool declarations are written: the `actions` of
|
|
82
|
+
* `<app>/manifest.json`, i.e. `_meta["dev.crewx"].actions`. `existingText` is the manifest.json
|
|
83
|
+
* already on disk. With it only `actions` is replaced, so every other key keeps its value and its
|
|
84
|
+
* place (the file is written back as two-space JSON). Without it the whole file is derived from the
|
|
85
|
+
* App's static definition and starts at release version 0.1.0.
|
|
86
|
+
*/
|
|
87
|
+
export function renderActionDeclarations(imported, command, existingText) {
|
|
88
|
+
const generated = imported.generateManifest(imported.default, command);
|
|
89
|
+
let manifest;
|
|
90
|
+
if (existingText === undefined) {
|
|
91
|
+
manifest = convertV2ToManifest(generated);
|
|
92
|
+
} else {
|
|
93
|
+
try {
|
|
94
|
+
manifest = JSON.parse(existingText);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
throw new Error(`${MANIFEST_FILE} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
97
|
+
}
|
|
98
|
+
const dev = manifest?._meta?.[CREWX_META_NAMESPACE];
|
|
99
|
+
if (dev === null || typeof dev !== 'object' || Array.isArray(dev)) {
|
|
100
|
+
throw new Error(`${MANIFEST_FILE} must have an object at _meta["${CREWX_META_NAMESPACE}"]`);
|
|
101
|
+
}
|
|
102
|
+
dev.actions = generated.actions;
|
|
103
|
+
}
|
|
104
|
+
return { fileName: MANIFEST_FILE, text: `${JSON.stringify(manifest, null, 2)}\n` };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `[<appDir>] [--app-dir=<dir>] [--check | --watch]`. The app folder is the current directory
|
|
109
|
+
* unless one is given, once, either way.
|
|
110
|
+
*/
|
|
111
|
+
export function parseArguments(argv, cwd = process.cwd()) {
|
|
112
|
+
let appDirectory;
|
|
113
|
+
let check = false;
|
|
114
|
+
let watchMode = false;
|
|
115
|
+
const setAppDirectory = (directory) => {
|
|
116
|
+
if (appDirectory !== undefined) throw new Error('Give the app folder only once');
|
|
117
|
+
appDirectory = resolve(cwd, directory);
|
|
118
|
+
};
|
|
119
|
+
for (const argument of argv) {
|
|
120
|
+
if (argument === '--check') check = true;
|
|
121
|
+
else if (argument === '--watch') watchMode = true;
|
|
122
|
+
else if (argument.startsWith('--app-dir=')) setAppDirectory(argument.slice('--app-dir='.length));
|
|
123
|
+
else if (argument.startsWith('-')) throw new Error(`Unknown argument: ${argument}`);
|
|
124
|
+
else setAppDirectory(argument);
|
|
125
|
+
}
|
|
126
|
+
if (check && watchMode) throw new Error('--check and --watch cannot be combined');
|
|
127
|
+
return { appDirectory: appDirectory ?? cwd, check, watch: watchMode };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function readAppConfig(appDirectory) {
|
|
131
|
+
const packagePath = join(appDirectory, 'package.json');
|
|
132
|
+
if (!existsSync(packagePath)) throw new Error(`${packagePath} not found; run the build from an app folder or give the app folder`);
|
|
133
|
+
const pkg = JSON.parse(readFileSync(packagePath, 'utf8'));
|
|
134
|
+
const config = pkg.crewx?.app ?? {};
|
|
135
|
+
const command = config.command ?? deriveCommand(pkg);
|
|
136
|
+
if (!Array.isArray(command) || command.length === 0 || !command.every((token) => typeof token === 'string' && token !== '')) {
|
|
137
|
+
throw new Error('crewx.app.command must be a non-empty array of strings');
|
|
138
|
+
}
|
|
139
|
+
const external = config.external ?? [];
|
|
140
|
+
if (!Array.isArray(external) || !external.every((glob) => typeof glob === 'string')) {
|
|
141
|
+
throw new Error('crewx.app.external must be an array of globs');
|
|
142
|
+
}
|
|
143
|
+
return { command, external };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function deriveCommand(pkg) {
|
|
147
|
+
const bin = pkg.bin;
|
|
148
|
+
const names = typeof bin === 'string'
|
|
149
|
+
? [String(pkg.name ?? '').replace(/^@[^/]+\//u, '')]
|
|
150
|
+
: Object.keys(bin ?? {});
|
|
151
|
+
if (names.length !== 1 || names[0] === '') {
|
|
152
|
+
throw new Error('cannot derive the skill command: give package.json exactly one "bin" name, or set crewx.app.command');
|
|
153
|
+
}
|
|
154
|
+
return [names[0], 'app'];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function globToRegExp(glob) {
|
|
158
|
+
let pattern = '^';
|
|
159
|
+
for (let index = 0; index < glob.length; index += 1) {
|
|
160
|
+
const character = glob[index];
|
|
161
|
+
if (character === '*' && glob[index + 1] === '*') {
|
|
162
|
+
pattern += '.*';
|
|
163
|
+
index += 1;
|
|
164
|
+
} else if (character === '*') {
|
|
165
|
+
pattern += '[^/]*';
|
|
166
|
+
} else if (character === '?') {
|
|
167
|
+
pattern += '[^/]';
|
|
168
|
+
} else {
|
|
169
|
+
pattern += character.replace(/[\\^$+{}()[\].|]/g, '\\$&');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return new RegExp(`${pattern}$`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isInside(parent, child) {
|
|
176
|
+
const relativePath = relative(parent, child);
|
|
177
|
+
return relativePath !== '' && !relativePath.startsWith('..') && !isAbsolute(relativePath);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** True when the app folder is part of the CrewX repository this file belongs to, so the SDK source is the right target. */
|
|
181
|
+
export function usesSdkSource(appDirectory) {
|
|
182
|
+
if (ownDirectory !== join(repositoryRoot, REPOSITORY_LAYOUT)) return false;
|
|
183
|
+
if (!existsSync(SDK_APP_SOURCE_ENTRY)) return false;
|
|
184
|
+
return isInside(realpathSync(repositoryRoot), realpathSync(appDirectory));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function loadEsbuild(appDirectory) {
|
|
188
|
+
// Prefer the esbuild the app pins, so its output matches what its own build produced before.
|
|
189
|
+
try {
|
|
190
|
+
const resolved = createRequire(join(appDirectory, 'package.json')).resolve('esbuild');
|
|
191
|
+
return await import(pathToFileURL(resolved).href);
|
|
192
|
+
} catch {
|
|
193
|
+
// Otherwise the esbuild that comes with @crewx/app.
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
return await import('esbuild');
|
|
197
|
+
} catch {
|
|
198
|
+
throw new Error('cannot load esbuild: it comes with @crewx/app, so reinstall the CrewX CLI');
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// The shipped SDK is bundled under this virtual name instead of its real path. esbuild writes a
|
|
203
|
+
// CommonJS module's path into the bundle (`__commonJS({ "<path>"(exports) {`); the real path is
|
|
204
|
+
// <install>/node_modules/@crewx/sdk/dist/app/index.js, which would put "@crewx/sdk" into the bundle
|
|
205
|
+
// and make its bytes depend on where the CLI is installed.
|
|
206
|
+
const SHIPPED_SDK_NAMESPACE = 'crewx-sdk';
|
|
207
|
+
const SHIPPED_SDK_MODULE = 'app/index.js';
|
|
208
|
+
|
|
209
|
+
/** The `@crewx/sdk/app` file the CLI ships with (the @crewx/sdk next to @crewx/app), or undefined. */
|
|
210
|
+
function shippedSdkEntry() {
|
|
211
|
+
try {
|
|
212
|
+
return requireFromPackage.resolve('@crewx/sdk/app');
|
|
213
|
+
} catch {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function createPlugins(appDirectory, config) {
|
|
219
|
+
const externalPatterns = config.external.map(globToRegExp);
|
|
220
|
+
const sdkFromSource = usesSdkSource(appDirectory);
|
|
221
|
+
return [{
|
|
222
|
+
name: 'crewx-sdk-app',
|
|
223
|
+
setup(buildContext) {
|
|
224
|
+
buildContext.onResolve({ filter: /^@crewx\/sdk\/app$/ }, () => {
|
|
225
|
+
if (sdkFromSource) return { path: SDK_APP_SOURCE_ENTRY };
|
|
226
|
+
const shipped = shippedSdkEntry();
|
|
227
|
+
if (shipped === undefined) {
|
|
228
|
+
return { errors: [{ text: 'cannot resolve @crewx/sdk/app: it comes with the CrewX CLI, so reinstall the CLI' }] };
|
|
229
|
+
}
|
|
230
|
+
return { path: SHIPPED_SDK_MODULE, namespace: SHIPPED_SDK_NAMESPACE, pluginData: { file: shipped } };
|
|
231
|
+
});
|
|
232
|
+
buildContext.onLoad({ filter: /.*/, namespace: SHIPPED_SDK_NAMESPACE }, ({ pluginData }) => ({
|
|
233
|
+
contents: readFileSync(pluginData.file, 'utf8'),
|
|
234
|
+
loader: 'js',
|
|
235
|
+
resolveDir: dirname(pluginData.file),
|
|
236
|
+
}));
|
|
237
|
+
},
|
|
238
|
+
}, {
|
|
239
|
+
name: 'crewx-app-external-files',
|
|
240
|
+
setup(buildContext) {
|
|
241
|
+
if (externalPatterns.length === 0) return;
|
|
242
|
+
buildContext.onResolve({ filter: /^\.\.?\// }, ({ path, resolveDir }) => {
|
|
243
|
+
const absolute = resolve(resolveDir, path);
|
|
244
|
+
const fromApp = relative(appDirectory, absolute).split(sep).join('/');
|
|
245
|
+
if (fromApp.startsWith('..') || !externalPatterns.some((pattern) => pattern.test(fromApp))) return undefined;
|
|
246
|
+
const fromDist = relative(join(appDirectory, 'dist'), absolute).split(sep).join('/');
|
|
247
|
+
return { path: fromDist.startsWith('.') ? fromDist : `./${fromDist}`, external: true };
|
|
248
|
+
});
|
|
249
|
+
},
|
|
250
|
+
}];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function bundle(esbuild, appDirectory, config, { contents, sourcefile, outfile, metafile = false }) {
|
|
254
|
+
const result = await esbuild.build({
|
|
255
|
+
...(usesSdkSource(appDirectory) ? {} : { banner: { js: COMMONJS_INTEROP_BANNER } }),
|
|
256
|
+
absWorkingDir: appDirectory,
|
|
257
|
+
stdin: { contents, resolveDir: appDirectory, sourcefile, loader: 'ts' },
|
|
258
|
+
outfile,
|
|
259
|
+
bundle: true,
|
|
260
|
+
write: false,
|
|
261
|
+
metafile,
|
|
262
|
+
format: 'esm',
|
|
263
|
+
platform: 'node',
|
|
264
|
+
target: 'node22',
|
|
265
|
+
packages: 'external',
|
|
266
|
+
plugins: createPlugins(appDirectory, config),
|
|
267
|
+
tsconfigRaw: TSCONFIG_RAW,
|
|
268
|
+
logLevel: 'silent',
|
|
269
|
+
}).catch((error) => {
|
|
270
|
+
// The wrapper imports `default` from server.ts; say what is wrong in the app's terms.
|
|
271
|
+
if (error.errors?.some(({ text }) => /No matching export in ".*server\.ts" for import "default"/u.test(text))) {
|
|
272
|
+
throw new Error(`${SERVER_ENTRY} must default-export a CrewXApp subclass with a static definition`);
|
|
273
|
+
}
|
|
274
|
+
throw error;
|
|
275
|
+
});
|
|
276
|
+
const expectedPath = join(appDirectory, outfile);
|
|
277
|
+
const text = result.outputFiles.find((file) => file.path === expectedPath)?.text;
|
|
278
|
+
if (text === undefined) throw new Error(`esbuild did not produce ${outfile}`);
|
|
279
|
+
if (/@crewx\/sdk(?:['"/])/u.test(text.replace(/^\s*\/\/.*$/gmu, ''))) {
|
|
280
|
+
throw new Error(`${outfile} still imports @crewx/sdk; server.ts may import only @crewx/sdk/app`);
|
|
281
|
+
}
|
|
282
|
+
return { text, inputs: result.metafile ? Object.keys(result.metafile.inputs) : [] };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Builds the bundles and the action declarations. With `check` nothing is written and the
|
|
287
|
+
* result lists every generated file that differs from what is on disk.
|
|
288
|
+
*
|
|
289
|
+
* Returns `stale` (paths relative to the current directory, empty unless `check`), `staleFiles`
|
|
290
|
+
* (the same files relative to the app folder), `files` (every generated file, relative to the app
|
|
291
|
+
* folder), `actions` (the actionId of every @Tool), `sdkEntry` (the file `@crewx/sdk/app` resolved
|
|
292
|
+
* to: the SDK source in the repository, the shipped SDK anywhere else) and `inputs` (the source
|
|
293
|
+
* files the bundle read; the shipped SDK is not among them).
|
|
294
|
+
*/
|
|
295
|
+
export async function buildApp({ appDirectory, check = false, log = () => {} }) {
|
|
296
|
+
if (!existsSync(join(appDirectory, SERVER_ENTRY))) throw new Error(`${join(appDirectory, SERVER_ENTRY)} not found`);
|
|
297
|
+
const config = readAppConfig(appDirectory);
|
|
298
|
+
const esbuild = await loadEsbuild(appDirectory);
|
|
299
|
+
const distDirectory = join(appDirectory, 'dist');
|
|
300
|
+
const distDirectoryExisted = existsSync(distDirectory);
|
|
301
|
+
const temporaryBundle = join(distDirectory, `.server-build-${process.pid}.mjs`);
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
const server = await bundle(esbuild, appDirectory, config, {
|
|
305
|
+
contents: SERVER_WRAPPER, sourcefile: 'crewx-app-server.ts', outfile: SERVER_BUNDLE, metafile: true,
|
|
306
|
+
});
|
|
307
|
+
const entry = await bundle(esbuild, appDirectory, config, {
|
|
308
|
+
contents: ENTRY_WRAPPER, sourcefile: 'crewx-app-entry.ts', outfile: ENTRY_BUNDLE,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// The bundle imports its externals relative to dist/, so it has to run from there.
|
|
312
|
+
mkdirSync(distDirectory, { recursive: true });
|
|
313
|
+
writeFileSync(temporaryBundle, server.text, 'utf8');
|
|
314
|
+
const imported = await import(`${pathToFileURL(temporaryBundle).href}?build=${Date.now()}`);
|
|
315
|
+
if (typeof imported.default !== 'function' || imported.default.definition === undefined) {
|
|
316
|
+
throw new Error(`${SERVER_ENTRY} must default-export a CrewXApp subclass with a static definition`);
|
|
317
|
+
}
|
|
318
|
+
const manifestPath = join(appDirectory, MANIFEST_FILE);
|
|
319
|
+
const existingManifest = existsSync(manifestPath) ? readFileSync(manifestPath, 'utf8') : undefined;
|
|
320
|
+
const declarations = renderActionDeclarations(imported, config.command, existingManifest);
|
|
321
|
+
|
|
322
|
+
const generated = [
|
|
323
|
+
{ path: join(appDirectory, SERVER_BUNDLE), text: server.text },
|
|
324
|
+
{ path: join(appDirectory, ENTRY_BUNDLE), text: entry.text },
|
|
325
|
+
{ path: join(appDirectory, declarations.fileName), text: declarations.text },
|
|
326
|
+
];
|
|
327
|
+
const staleEntries = generated.filter(({ path, text }) => !existsSync(path) || readFileSync(path, 'utf8') !== text);
|
|
328
|
+
const stale = staleEntries.map(({ path }) => relative(process.cwd(), path));
|
|
329
|
+
|
|
330
|
+
if (!check) {
|
|
331
|
+
for (const { path, text } of generated) writeFileSync(path, text, 'utf8');
|
|
332
|
+
log(`Built ${relative(process.cwd(), join(appDirectory, SERVER_BUNDLE))} and regenerated ${relative(process.cwd(), join(appDirectory, declarations.fileName))}.`);
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
stale: check ? stale : [],
|
|
336
|
+
staleFiles: check ? staleEntries.map(({ path }) => relative(appDirectory, path)) : [],
|
|
337
|
+
files: generated.map(({ path }) => relative(appDirectory, path)),
|
|
338
|
+
actions: declaredActionIds(declarations.text),
|
|
339
|
+
sdkEntry: usesSdkSource(appDirectory) ? SDK_APP_SOURCE_ENTRY : shippedSdkEntry(),
|
|
340
|
+
inputs: server.inputs
|
|
341
|
+
.filter((input) => !input.startsWith(`${SHIPPED_SDK_NAMESPACE}:`))
|
|
342
|
+
.map((input) => resolve(appDirectory, input)),
|
|
343
|
+
};
|
|
344
|
+
} finally {
|
|
345
|
+
rmSync(temporaryBundle, { force: true });
|
|
346
|
+
if (!distDirectoryExisted && check) rmSync(distDirectory, { recursive: true, force: true });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function declaredActionIds(manifestText) {
|
|
351
|
+
const actions = JSON.parse(manifestText)?._meta?.[CREWX_META_NAMESPACE]?.actions;
|
|
352
|
+
return Array.isArray(actions) ? actions.map((action) => action?.actionId).filter((id) => typeof id === 'string') : [];
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Rebuilds when an imported source file changes; SDK source edits count when the app uses it.
|
|
357
|
+
* Runs until `signal` aborts (never, without one) and then closes its watchers.
|
|
358
|
+
*/
|
|
359
|
+
export async function watchApp(appDirectory, log, { signal } = {}) {
|
|
360
|
+
const watchers = new Map();
|
|
361
|
+
let building = false;
|
|
362
|
+
let queued = false;
|
|
363
|
+
let timer;
|
|
364
|
+
let inputs = new Set();
|
|
365
|
+
|
|
366
|
+
const rebuild = async () => {
|
|
367
|
+
if (building) {
|
|
368
|
+
queued = true;
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
building = true;
|
|
372
|
+
try {
|
|
373
|
+
const result = await buildApp({ appDirectory, log });
|
|
374
|
+
inputs = new Set(result.inputs);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
log(`Build failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
377
|
+
} finally {
|
|
378
|
+
building = false;
|
|
379
|
+
if (!signal?.aborted) refreshWatchers();
|
|
380
|
+
if (queued && !signal?.aborted) {
|
|
381
|
+
queued = false;
|
|
382
|
+
void rebuild();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const refreshWatchers = () => {
|
|
388
|
+
const directories = new Set([appDirectory, ...[...inputs].map((input) => dirname(input))]);
|
|
389
|
+
if (usesSdkSource(appDirectory)) directories.add(SDK_APP_SOURCE_DIRECTORY);
|
|
390
|
+
for (const [directory, watcher] of watchers) {
|
|
391
|
+
if (!directories.has(directory)) {
|
|
392
|
+
watcher.close();
|
|
393
|
+
watchers.delete(directory);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
for (const directory of directories) {
|
|
397
|
+
if (watchers.has(directory) || !existsSync(directory)) continue;
|
|
398
|
+
watchers.set(directory, watch(directory, (_event, filename) => {
|
|
399
|
+
if (filename === null) return;
|
|
400
|
+
const changed = join(directory, filename.toString());
|
|
401
|
+
// Generated files live next to the sources; reacting to them would loop.
|
|
402
|
+
const relevant = inputs.has(changed)
|
|
403
|
+
|| changed === join(appDirectory, 'package.json')
|
|
404
|
+
|| (directory === SDK_APP_SOURCE_DIRECTORY && /\.tsx?$/u.test(changed));
|
|
405
|
+
if (!relevant) return;
|
|
406
|
+
clearTimeout(timer);
|
|
407
|
+
timer = setTimeout(() => void rebuild(), WATCH_DEBOUNCE_MS);
|
|
408
|
+
}));
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
await rebuild();
|
|
413
|
+
log('Watching for changes. Press Ctrl+C to stop.');
|
|
414
|
+
await new Promise((resolveStopped) => {
|
|
415
|
+
if (signal === undefined) return;
|
|
416
|
+
if (signal.aborted) resolveStopped();
|
|
417
|
+
else signal.addEventListener('abort', resolveStopped, { once: true });
|
|
418
|
+
});
|
|
419
|
+
clearTimeout(timer);
|
|
420
|
+
for (const watcher of watchers.values()) watcher.close();
|
|
421
|
+
watchers.clear();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The command line of scripts/build-app.mjs: plain-text lines on stdout, errors on stderr.
|
|
426
|
+
* Returns the exit code (0 ok, 1 failed or stale).
|
|
427
|
+
*/
|
|
428
|
+
export async function runScript(argv, { cwd = process.cwd(), stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
429
|
+
const log = (message) => stdout.write(`${message}\n`);
|
|
430
|
+
try {
|
|
431
|
+
const options = parseArguments(argv, cwd);
|
|
432
|
+
if (options.watch) {
|
|
433
|
+
await watchApp(options.appDirectory, log);
|
|
434
|
+
return 0;
|
|
435
|
+
}
|
|
436
|
+
const { stale } = await buildApp({ appDirectory: options.appDirectory, check: options.check, log });
|
|
437
|
+
if (!options.check) return 0;
|
|
438
|
+
if (stale.length > 0) {
|
|
439
|
+
stderr.write(`Stale generated files: ${stale.join(', ')}\n`);
|
|
440
|
+
return 1;
|
|
441
|
+
}
|
|
442
|
+
log(`${basename(options.appDirectory)} App build artifacts are in sync.`);
|
|
443
|
+
return 0;
|
|
444
|
+
} catch (error) {
|
|
445
|
+
stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
446
|
+
return 1;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
@@ -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') };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The part of the v2 -> manifest.json mapping the App build needs: a new App (no manifest.json yet)
|
|
2
|
+
// gets a whole manifest.json derived from the static definition in server.ts.
|
|
3
|
+
//
|
|
4
|
+
// scripts/convert-app-manifest.mjs (the repository's converter command) keeps its own copy of this
|
|
5
|
+
// mapping; packages/built-in/app/tests/build-app.test.ts compares the two on the same inputs, so a
|
|
6
|
+
// drift fails a test instead of reaching an App.
|
|
7
|
+
|
|
8
|
+
export const MCPB_MANIFEST_VERSION = '0.3';
|
|
9
|
+
export const CREWX_META_NAMESPACE = 'dev.crewx';
|
|
10
|
+
export const INITIAL_RELEASE_VERSION = '0.1.0';
|
|
11
|
+
|
|
12
|
+
// v2 fields that are not carried below `_meta["dev.crewx"]` as they are.
|
|
13
|
+
const MOVED_OUT_OF_DEV_NAMESPACE = new Set([
|
|
14
|
+
'manifestVersion',
|
|
15
|
+
'pluginId',
|
|
16
|
+
'definitionId',
|
|
17
|
+
'version',
|
|
18
|
+
'name',
|
|
19
|
+
'description',
|
|
20
|
+
'_meta',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
function isPlainObject(value) {
|
|
24
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Maps one parsed v2 manifest to the `manifest.json` shape.
|
|
29
|
+
* Throws when the input is not a v2 manifest or cannot be represented.
|
|
30
|
+
*/
|
|
31
|
+
export function convertV2ToManifest(v2, options = {}) {
|
|
32
|
+
if (!isPlainObject(v2) || v2.manifestVersion !== 2) {
|
|
33
|
+
throw new Error('only a manifestVersion 2 ui-plugin.json can be converted');
|
|
34
|
+
}
|
|
35
|
+
for (const key of ['pluginId', 'definitionId', 'name', 'description']) {
|
|
36
|
+
if (typeof v2[key] !== 'string') throw new Error(`ui-plugin.json ${key} must be a string`);
|
|
37
|
+
}
|
|
38
|
+
if (!Number.isSafeInteger(v2.version) || v2.version < 1) {
|
|
39
|
+
throw new Error('ui-plugin.json version must be a positive integer');
|
|
40
|
+
}
|
|
41
|
+
if (v2._meta !== undefined && !isPlainObject(v2._meta)) {
|
|
42
|
+
throw new Error('ui-plugin.json _meta must be an object');
|
|
43
|
+
}
|
|
44
|
+
if (v2._meta !== undefined && Object.hasOwn(v2._meta, CREWX_META_NAMESPACE)) {
|
|
45
|
+
throw new Error(`ui-plugin.json _meta already uses the "${CREWX_META_NAMESPACE}" key`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const dev = {};
|
|
49
|
+
if (v2.definitionId !== v2.pluginId) dev.definitionId = v2.definitionId;
|
|
50
|
+
dev.definitionVersion = v2.version;
|
|
51
|
+
for (const [key, value] of Object.entries(v2)) {
|
|
52
|
+
if (!MOVED_OUT_OF_DEV_NAMESPACE.has(key)) dev[key] = value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
manifest_version: MCPB_MANIFEST_VERSION,
|
|
57
|
+
name: v2.pluginId,
|
|
58
|
+
display_name: v2.name,
|
|
59
|
+
version: options.version ?? INITIAL_RELEASE_VERSION,
|
|
60
|
+
description: v2.description,
|
|
61
|
+
_meta: { ...(v2._meta ?? {}), [CREWX_META_NAMESPACE]: dev },
|
|
62
|
+
};
|
|
63
|
+
}
|
package/dist/src/engine.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type WaitClock } from './receipt-wait.js';
|
|
1
2
|
export interface AppCommandOutcome {
|
|
2
3
|
exitCode: number;
|
|
3
4
|
payload: Record<string, unknown>;
|
|
@@ -5,5 +6,7 @@ export interface AppCommandOutcome {
|
|
|
5
6
|
stdout?: string;
|
|
6
7
|
suppressOutput?: boolean;
|
|
7
8
|
}
|
|
8
|
-
export declare function executeAppCommand(args: string[]
|
|
9
|
+
export declare function executeAppCommand(args: string[], { clock }?: {
|
|
10
|
+
clock?: WaitClock;
|
|
11
|
+
}): Promise<AppCommandOutcome>;
|
|
9
12
|
//# sourceMappingURL=engine.d.ts.map
|
package/dist/src/engine.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../src/engine.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../src/engine.ts"],"names":[],"mappings":"AAaA,OAAO,EAQL,KAAK,SAAS,EACf,MAAM,mBAAmB,CAAC;AA4B3B,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAgmDD,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,MAAM,EAAE,EACd,EAAE,KAAiB,EAAE,GAAE;IAAE,KAAK,CAAC,EAAE,SAAS,CAAA;CAAO,GAChD,OAAO,CAAC,iBAAiB,CAAC,CA0E5B"}
|