@olenbetong/appframe-vite 6.7.0 → 6.8.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.
- package/README.md +34 -2
- package/lib/devtoolsServer.d.ts +53 -4
- package/lib/devtoolsServer.js +34 -61
- package/lib/format.d.ts +14 -0
- package/lib/format.js +70 -0
- package/lib/generateTypes.js +12 -16
- package/lib/index.d.ts +7 -3
- package/lib/index.js +18 -17
- package/lib/releaseServer.d.ts +146 -0
- package/lib/releaseServer.js +384 -0
- package/lib/releaseServer.test.d.ts +1 -0
- package/lib/releaseServer.test.js +83 -0
- package/lib/resourceGenerate.d.ts +2 -6
- package/lib/resourceGenerate.js +4 -64
- package/lib/resourcesConfig.d.ts +7 -0
- package/lib/resourcesConfig.js +1 -0
- package/package.json +6 -7
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ On first run, the plugin logs in, configures Vite’s proxy for Appframe routes,
|
|
|
42
42
|
|
|
43
43
|
### MUI X license key
|
|
44
44
|
|
|
45
|
-
The plugin injects the MUI X
|
|
45
|
+
The plugin injects the MUI X Premium license key into the bundle as the global `__MUI_X_LICENSE_KEY__`, for both `serve` and `build`. `@olenbetong/appframe-ds/grid` reads it and registers the license itself, so an app using the Designsystemet `AfGrid` does not need its own `LicenseInfo.setLicenseKey()` call.
|
|
46
46
|
|
|
47
47
|
The key is resolved from, in order:
|
|
48
48
|
|
|
@@ -90,6 +90,38 @@ This package reads `package.json.appframe` to know what to proxy and how to buil
|
|
|
90
90
|
- Adds a Rollup visualizer report at `dist/stats.html`.
|
|
91
91
|
- Resolve
|
|
92
92
|
- Adds the alias `~/` to `/src/` so imports like `~/components/Button` resolve to `src/components/Button`.
|
|
93
|
+
- DevTools API (see below)
|
|
94
|
+
|
|
95
|
+
## DevTools
|
|
96
|
+
|
|
97
|
+
In dev mode the plugin exposes a small JSON API at `/__appframe_devtools__/api` for the **Appframe DevTools
|
|
98
|
+
browser extension** (`packages/appframe-devtools` in the SynergiWeb repo). The extension adds an "Appframe"
|
|
99
|
+
panel to Chrome/Edge DevTools with a live view of the app's data objects, an editor for `resources.yaml`
|
|
100
|
+
and a browser for the data API catalog. Nothing is injected into the page — the toolbar that older
|
|
101
|
+
versions added to every dev-served page is gone.
|
|
102
|
+
|
|
103
|
+
| Endpoint | Purpose |
|
|
104
|
+
| --------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
|
105
|
+
| `GET /api/config` | Discovery: `{ appframe: true, apiVersion, hostname, articleId, articleHostname, appName, appVersion, pluginVersion }` |
|
|
106
|
+
| `GET /api/resources` | Parsed `resources.yaml` |
|
|
107
|
+
| `POST /api/resources` | Add an entry (`type: "dataObject" \| "procedure"` plus the entry fields) |
|
|
108
|
+
| `PUT /api/resources/:id` | Replace an entry |
|
|
109
|
+
| `DELETE /api/resources/:id` | Remove an entry and its generated output file |
|
|
110
|
+
| `POST /api/catalog` | Register a `dbObjectId` as a data API resource on dev, stage and prod |
|
|
111
|
+
| `GET /api/release` | Current session (`session: null` when none) and the capabilities derived from the app's scripts |
|
|
112
|
+
| `POST /api/release` | Start a job: `{ kind: "release", dry?, force? }` or `{ kind: "deploy", target, copyDatasources? }`; 409 while running |
|
|
113
|
+
| `DELETE /api/release` | Cancel the running job |
|
|
114
|
+
| `POST /api/release/input` | Answer one of the script's questions (`{ name, value }`: the release type and notes, or the apply confirmation) |
|
|
115
|
+
| `GET /api/release/events` | Server-sent events: the session snapshot, then every step, log line and question as it happens |
|
|
116
|
+
|
|
117
|
+
The release job runs whatever `scripts.release` is in the app's package.json, from the app directory, with
|
|
118
|
+
`--ui` appended. The deploy job runs `pnpm run build`, optionally `pnpm run cd` (or `copy-datasources`) and
|
|
119
|
+
`pnpm run deploy` with `APPFRAME_DEPLOY_HOSTNAME` set to the target, which `app-deploy.ts` and
|
|
120
|
+
`app-copy-datasources.ts` honour over the hostnames in package.json. The script is expected to follow the protocol in `scripts/lib/releaseUi.ts` (JSON event lines
|
|
121
|
+
prefixed with `@@appframe-devtools ` on stdout, answers as JSON lines on stdin).
|
|
122
|
+
|
|
123
|
+
Disable it with `appframe({ devtools: false })` when there is no article context (Storybook does this).
|
|
124
|
+
The extension page has host permissions for `localhost`, so the API needs no CORS headers.
|
|
93
125
|
|
|
94
126
|
## CLI — `appframe-vite`
|
|
95
127
|
|
|
@@ -243,7 +275,7 @@ import {
|
|
|
243
275
|
fetchAndGenerate,
|
|
244
276
|
buildYamlConfig,
|
|
245
277
|
parseYamlConfig,
|
|
246
|
-
|
|
278
|
+
writeGeneratedFile,
|
|
247
279
|
getCustomImportPath,
|
|
248
280
|
type CLIOptions,
|
|
249
281
|
} from "@olenbetong/appframe-vite/resources";
|
package/lib/devtoolsServer.d.ts
CHANGED
|
@@ -1,7 +1,56 @@
|
|
|
1
|
+
import { type ReleaseServerOptions } from "./releaseServer.js";
|
|
1
2
|
import type { Connect } from "vite";
|
|
2
3
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Bumped whenever the shape of the DevTools API changes in a way the browser
|
|
5
|
+
* extension has to know about. The extension compares it against the value it
|
|
6
|
+
* was built for.
|
|
6
7
|
*/
|
|
7
|
-
export declare
|
|
8
|
+
export declare const DEVTOOLS_API_VERSION = 1;
|
|
9
|
+
/**
|
|
10
|
+
* Information about the app the dev server is serving. Passed to the middleware
|
|
11
|
+
* and returned verbatim by the discovery endpoint.
|
|
12
|
+
*/
|
|
13
|
+
export interface DevtoolsServerInfo {
|
|
14
|
+
/** Appframe host the dev server proxies to, e.g. `dev.obet.no`. */
|
|
15
|
+
hostname: string;
|
|
16
|
+
/** Article ID from `package.json → appframe.article.id`. */
|
|
17
|
+
articleId: string;
|
|
18
|
+
/**
|
|
19
|
+
* CMS site the article belongs to (`appframe.article.hostname` in package.json), or
|
|
20
|
+
* `null` when the app does not declare one. Dev servers are usually reached through an
|
|
21
|
+
* alias such as `dev.obet.no`, while the CMS stores the article under the site's own
|
|
22
|
+
* hostname; the extension resolves it through `stbv_WebSiteCMS_SitesAliases` when null.
|
|
23
|
+
*/
|
|
24
|
+
articleHostname: string | null;
|
|
25
|
+
/** `name` from the app's package.json. */
|
|
26
|
+
appName: string;
|
|
27
|
+
/** `version` from the app's package.json. */
|
|
28
|
+
appVersion: string;
|
|
29
|
+
/** Version of `@olenbetong/appframe-vite` running the dev server. */
|
|
30
|
+
pluginVersion: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Payload of `GET /__appframe_devtools__/api/config`. The `appframe: true` marker
|
|
34
|
+
* lets the extension tell an Appframe dev server from any other localhost page
|
|
35
|
+
* (a plain Vite server answers unknown paths with `index.html`).
|
|
36
|
+
*/
|
|
37
|
+
export interface DevtoolsDiscovery extends DevtoolsServerInfo {
|
|
38
|
+
appframe: true;
|
|
39
|
+
apiVersion: number;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Creates a Connect middleware exposing the DevTools JSON API used by the
|
|
43
|
+
* Appframe DevTools browser extension (`packages/appframe-devtools`). Mounted at
|
|
44
|
+
* `/__appframe_devtools__`, it answers:
|
|
45
|
+
*
|
|
46
|
+
* - `GET /api/config` — discovery payload (`DevtoolsDiscovery`)
|
|
47
|
+
* - `GET /api/resources` — parsed `resources.yaml`
|
|
48
|
+
* - `POST /api/resources` — add an entry (`type: "dataObject" | "procedure"`)
|
|
49
|
+
* - `PUT /api/resources/:id` — replace an entry
|
|
50
|
+
* - `DELETE /api/resources/:id` — remove an entry and its generated output file
|
|
51
|
+
* - `POST /api/catalog` — register a DBObjectID as a data API resource on dev, stage and prod
|
|
52
|
+
*
|
|
53
|
+
* Nothing is served outside `/api/`; the UI lives in the extension. The extension
|
|
54
|
+
* page has host permissions for localhost, so no CORS headers are needed here.
|
|
55
|
+
*/
|
|
56
|
+
export declare function createDevtoolsMiddleware(info: DevtoolsServerInfo, options?: ReleaseServerOptions): Connect.NextHandleFunction;
|
package/lib/devtoolsServer.js
CHANGED
|
@@ -1,32 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createReleaseManager, handleReleaseApi, } from "./releaseServer.js";
|
|
2
|
+
import { rmSync } from "node:fs";
|
|
2
3
|
import https from "node:https";
|
|
3
|
-
import { createRequire } from "node:module";
|
|
4
|
-
import { dirname, extname, join } from "node:path";
|
|
5
4
|
import bodyParser from "body-parser";
|
|
6
5
|
import { login } from "./proxy.js";
|
|
7
6
|
import { readResourcesConfig, writeResourcesConfig } from "./resourcesConfig.js";
|
|
8
|
-
const require = createRequire(import.meta.url);
|
|
9
7
|
const ALL_SERVERS = ["dev.obet.no", "stage.obet.no", "test.obet.no"];
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
".png": "image/png",
|
|
17
|
-
".ico": "image/x-icon",
|
|
18
|
-
".woff2": "font/woff2",
|
|
19
|
-
".woff": "font/woff",
|
|
20
|
-
};
|
|
21
|
-
function getDevtoolsDist() {
|
|
22
|
-
try {
|
|
23
|
-
const pkgPath = require.resolve("@olenbetong/appframe-devtools/package.json");
|
|
24
|
-
return join(dirname(pkgPath), "dist");
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
8
|
+
/**
|
|
9
|
+
* Bumped whenever the shape of the DevTools API changes in a way the browser
|
|
10
|
+
* extension has to know about. The extension compares it against the value it
|
|
11
|
+
* was built for.
|
|
12
|
+
*/
|
|
13
|
+
export const DEVTOOLS_API_VERSION = 1;
|
|
30
14
|
function sendJson(res, status, data) {
|
|
31
15
|
const body = status === 204 ? "" : JSON.stringify(data);
|
|
32
16
|
res.statusCode = status;
|
|
@@ -86,7 +70,7 @@ async function addResourceOnServer(hostname, cookies, dbObjectId, name) {
|
|
|
86
70
|
req.end();
|
|
87
71
|
});
|
|
88
72
|
}
|
|
89
|
-
async function handleApi(req, res, apiPath,
|
|
73
|
+
async function handleApi(req, res, apiPath, info, release, next) {
|
|
90
74
|
// apiPath is the path after /__appframe_devtools__/api
|
|
91
75
|
// e.g. /config, /resources or /resources/dsMyObject
|
|
92
76
|
const segments = apiPath
|
|
@@ -95,7 +79,14 @@ async function handleApi(req, res, apiPath, hostname, next) {
|
|
|
95
79
|
.filter(Boolean);
|
|
96
80
|
const method = req.method?.toUpperCase() ?? "GET";
|
|
97
81
|
if (apiPath === "/config" && method === "GET") {
|
|
98
|
-
|
|
82
|
+
const discovery = { appframe: true, apiVersion: DEVTOOLS_API_VERSION, ...info };
|
|
83
|
+
return sendJson(res, 200, discovery);
|
|
84
|
+
}
|
|
85
|
+
if (apiPath === "/release" || apiPath.startsWith("/release/")) {
|
|
86
|
+
if (await handleReleaseApi(req, res, apiPath.slice("/release".length), release)) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
return next();
|
|
99
90
|
}
|
|
100
91
|
// Only handle /resources and /resources/:id
|
|
101
92
|
if (!apiPath.startsWith("/resources") && apiPath !== "/catalog") {
|
|
@@ -197,50 +188,32 @@ async function handleApi(req, res, apiPath, hostname, next) {
|
|
|
197
188
|
}
|
|
198
189
|
}
|
|
199
190
|
/**
|
|
200
|
-
* Creates a Connect middleware
|
|
201
|
-
*
|
|
202
|
-
*
|
|
191
|
+
* Creates a Connect middleware exposing the DevTools JSON API used by the
|
|
192
|
+
* Appframe DevTools browser extension (`packages/appframe-devtools`). Mounted at
|
|
193
|
+
* `/__appframe_devtools__`, it answers:
|
|
194
|
+
*
|
|
195
|
+
* - `GET /api/config` — discovery payload (`DevtoolsDiscovery`)
|
|
196
|
+
* - `GET /api/resources` — parsed `resources.yaml`
|
|
197
|
+
* - `POST /api/resources` — add an entry (`type: "dataObject" | "procedure"`)
|
|
198
|
+
* - `PUT /api/resources/:id` — replace an entry
|
|
199
|
+
* - `DELETE /api/resources/:id` — remove an entry and its generated output file
|
|
200
|
+
* - `POST /api/catalog` — register a DBObjectID as a data API resource on dev, stage and prod
|
|
201
|
+
*
|
|
202
|
+
* Nothing is served outside `/api/`; the UI lives in the extension. The extension
|
|
203
|
+
* page has host permissions for localhost, so no CORS headers are needed here.
|
|
203
204
|
*/
|
|
204
|
-
export function createDevtoolsMiddleware(
|
|
205
|
+
export function createDevtoolsMiddleware(info, options = { appDir: process.cwd(), scripts: {} }) {
|
|
205
206
|
const jsonParser = bodyParser.json();
|
|
206
|
-
const
|
|
207
|
+
const release = createReleaseManager(options);
|
|
207
208
|
return (req, res, next) => {
|
|
208
209
|
// req.url here is the path after /__appframe_devtools__ (Connect strips the prefix)
|
|
209
210
|
const url = req.url ?? "/";
|
|
210
|
-
// REST API
|
|
211
211
|
if (url.startsWith("/api/")) {
|
|
212
212
|
jsonParser(req, res, () => {
|
|
213
|
-
handleApi(req, res, url.slice(4),
|
|
213
|
+
handleApi(req, res, url.slice(4).split("?")[0], info, release, next).catch(next);
|
|
214
214
|
});
|
|
215
215
|
return;
|
|
216
216
|
}
|
|
217
|
-
|
|
218
|
-
if (!devtoolsDist) {
|
|
219
|
-
res.statusCode = 503;
|
|
220
|
-
res.setHeader("Content-Type", "text/plain");
|
|
221
|
-
res.end("@olenbetong/appframe-devtools not found or not built.\n" +
|
|
222
|
-
"Run: pnpm --filter @olenbetong/appframe-devtools build");
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
const safePath = url.split("?")[0] || "/";
|
|
226
|
-
const filePath = join(devtoolsDist, safePath === "/" ? "index.html" : safePath);
|
|
227
|
-
if (existsSync(filePath)) {
|
|
228
|
-
const mime = MIME_TYPES[extname(filePath)] ?? "application/octet-stream";
|
|
229
|
-
res.setHeader("Content-Type", mime);
|
|
230
|
-
createReadStream(filePath).pipe(res);
|
|
231
|
-
}
|
|
232
|
-
else {
|
|
233
|
-
// SPA fallback - all unknown paths serve index.html
|
|
234
|
-
const indexPath = join(devtoolsDist, "index.html");
|
|
235
|
-
if (existsSync(indexPath)) {
|
|
236
|
-
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
237
|
-
createReadStream(indexPath).pipe(res);
|
|
238
|
-
}
|
|
239
|
-
else {
|
|
240
|
-
res.statusCode = 404;
|
|
241
|
-
res.setHeader("Content-Type", "text/plain");
|
|
242
|
-
res.end("DevTools app not built yet.\n" + "Run: pnpm --filter @olenbetong/appframe-devtools build");
|
|
243
|
-
}
|
|
244
|
-
}
|
|
217
|
+
next();
|
|
245
218
|
};
|
|
246
219
|
}
|
package/lib/format.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format content in memory with oxfmt's stdin mode. `filePath` only tells oxfmt
|
|
3
|
+
* which parser to use, and does not have to exist yet.
|
|
4
|
+
* Returns the original content if oxfmt is unavailable or rejects the input.
|
|
5
|
+
*/
|
|
6
|
+
export declare function formatContent(content: string, filePath: string): Promise<string>;
|
|
7
|
+
/** Format a file in place with oxfmt. Silently skips if oxfmt is unavailable. */
|
|
8
|
+
export declare function formatFile(filePath: string): Promise<void>;
|
|
9
|
+
/**
|
|
10
|
+
* Lint a file with oxlint, applying the fixes it can apply automatically.
|
|
11
|
+
* Returns the diagnostics oxlint could not fix, or an empty string when the
|
|
12
|
+
* file is clean (or oxlint is unavailable).
|
|
13
|
+
*/
|
|
14
|
+
export declare function lintFile(filePath: string): Promise<string>;
|
package/lib/format.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Locate a binary in the nearest `node_modules/.bin`, walking up from cwd.
|
|
6
|
+
* Falls back to the bare name so a globally installed binary on PATH still works.
|
|
7
|
+
*/
|
|
8
|
+
function findBin(name) {
|
|
9
|
+
let current = resolve(process.cwd());
|
|
10
|
+
while (true) {
|
|
11
|
+
let candidate = join(current, "node_modules", ".bin", name);
|
|
12
|
+
let candidateCmd = `${candidate}.cmd`;
|
|
13
|
+
if (existsSync(candidateCmd))
|
|
14
|
+
return candidateCmd;
|
|
15
|
+
if (existsSync(candidate))
|
|
16
|
+
return candidate;
|
|
17
|
+
let parent = dirname(current);
|
|
18
|
+
if (parent === current)
|
|
19
|
+
return name;
|
|
20
|
+
current = parent;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// On Windows the resolved binary is a `.cmd` shim, which Node refuses to spawn
|
|
24
|
+
// without a shell. Passing an args array together with `shell: true` is
|
|
25
|
+
// deprecated (DEP0190), so build the quoted command line ourselves instead.
|
|
26
|
+
const useShell = process.platform === "win32";
|
|
27
|
+
function run(bin, args, input) {
|
|
28
|
+
return new Promise((done) => {
|
|
29
|
+
let child = useShell
|
|
30
|
+
? spawn([bin, ...args].map((part) => `"${part}"`).join(" "), { shell: true, stdio: "pipe" })
|
|
31
|
+
: spawn(bin, args, { stdio: "pipe" });
|
|
32
|
+
let stdout = "";
|
|
33
|
+
let stderr = "";
|
|
34
|
+
child.stdout.on("data", (chunk) => {
|
|
35
|
+
stdout += chunk.toString();
|
|
36
|
+
});
|
|
37
|
+
child.stderr.on("data", (chunk) => {
|
|
38
|
+
stderr += chunk.toString();
|
|
39
|
+
});
|
|
40
|
+
// Binary missing, or not executable — treat as "tool unavailable"
|
|
41
|
+
child.on("error", () => done({ ok: false, stdout, stderr }));
|
|
42
|
+
child.on("close", (code) => done({ ok: code === 0, stdout, stderr }));
|
|
43
|
+
if (input !== undefined) {
|
|
44
|
+
child.stdin.on("error", () => { });
|
|
45
|
+
child.stdin.end(input);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Format content in memory with oxfmt's stdin mode. `filePath` only tells oxfmt
|
|
51
|
+
* which parser to use, and does not have to exist yet.
|
|
52
|
+
* Returns the original content if oxfmt is unavailable or rejects the input.
|
|
53
|
+
*/
|
|
54
|
+
export async function formatContent(content, filePath) {
|
|
55
|
+
let { ok, stdout } = await run(findBin("oxfmt"), [`--stdin-filepath=${resolve(filePath)}`], content);
|
|
56
|
+
return ok ? stdout : content;
|
|
57
|
+
}
|
|
58
|
+
/** Format a file in place with oxfmt. Silently skips if oxfmt is unavailable. */
|
|
59
|
+
export async function formatFile(filePath) {
|
|
60
|
+
await run(findBin("oxfmt"), ["--write", resolve(filePath)]);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Lint a file with oxlint, applying the fixes it can apply automatically.
|
|
64
|
+
* Returns the diagnostics oxlint could not fix, or an empty string when the
|
|
65
|
+
* file is clean (or oxlint is unavailable).
|
|
66
|
+
*/
|
|
67
|
+
export async function lintFile(filePath) {
|
|
68
|
+
let { ok, stdout, stderr } = await run(findBin("oxlint"), ["--fix", resolve(filePath)]);
|
|
69
|
+
return ok ? "" : `${stdout}${stderr}`.trim();
|
|
70
|
+
}
|
package/lib/generateTypes.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
1
|
import { existsSync } from "node:fs";
|
|
3
2
|
import fs from "node:fs/promises";
|
|
4
3
|
import { resolve } from "node:path";
|
|
5
4
|
import vm from "node:vm";
|
|
6
5
|
import { Client, DataObject, generateApiDataHandler, getDefaultClient, Procedure, setDefaultClient, } from "@olenbetong/appframe-data";
|
|
6
|
+
import { formatFile, lintFile } from "./format.js";
|
|
7
7
|
import { importJson } from "./importJson.js";
|
|
8
8
|
import { createLogMessage, diagnoseServerResponse, wrapJsonError } from "./utils.js";
|
|
9
9
|
// ─── Type generation logic ────────────────────────────────────────────────────
|
|
@@ -30,7 +30,7 @@ function getDataObjectTypes(af, parameterOverrides = {}) {
|
|
|
30
30
|
let fieldName = propName.indexOf("-") > 0 ? `"${propName}"` : propName;
|
|
31
31
|
result.push(`\t${fieldName}: ${type};`);
|
|
32
32
|
}
|
|
33
|
-
result.push("}
|
|
33
|
+
result.push("}");
|
|
34
34
|
result.push("");
|
|
35
35
|
globals.push(`${id}: DataObject<${typeName}>;`);
|
|
36
36
|
}
|
|
@@ -118,22 +118,11 @@ function withTimeout(promise, ms, label) {
|
|
|
118
118
|
new Promise((_, reject) => setTimeout(() => reject(new Error(`Timed out: ${label}`)), ms)),
|
|
119
119
|
]);
|
|
120
120
|
}
|
|
121
|
-
/** Run biome format on a file. Silently skips if biome is unavailable. */
|
|
122
|
-
async function formatFile(filePath) {
|
|
123
|
-
await new Promise((resolve) => {
|
|
124
|
-
const child = spawn("pnpm", ["biome", "format", filePath, "--write"], {
|
|
125
|
-
shell: process.platform === "win32",
|
|
126
|
-
stdio: "pipe",
|
|
127
|
-
});
|
|
128
|
-
child.on("close", () => resolve());
|
|
129
|
-
child.on("error", () => resolve());
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
121
|
// ─── Core type generation ─────────────────────────────────────────────────────
|
|
133
122
|
const TIMEOUT_MS = 30_000;
|
|
134
123
|
const MAX_RETRIES = 3;
|
|
135
124
|
const RETRY_DELAY_BASE_MS = 2_000;
|
|
136
|
-
async function doGenerateTypes(hostname, username, password, appframe) {
|
|
125
|
+
async function doGenerateTypes(hostname, username, password, appframe, logger) {
|
|
137
126
|
const client = new Client(hostname);
|
|
138
127
|
setDefaultClient(client);
|
|
139
128
|
try {
|
|
@@ -225,12 +214,19 @@ async function doGenerateTypes(hostname, username, password, appframe) {
|
|
|
225
214
|
}
|
|
226
215
|
const outPath = resolve(process.cwd(), "./src/appframe.d.ts");
|
|
227
216
|
await fs.writeFile(outPath, new Uint8Array(Buffer.from(types)));
|
|
228
|
-
|
|
217
|
+
// Run the project's own oxc toolchain over the result so the generated file
|
|
218
|
+
// matches the formatting and lint rules the editor enforces on everything else.
|
|
219
|
+
await formatFile(outPath);
|
|
220
|
+
const diagnostics = await lintFile(outPath);
|
|
221
|
+
if (diagnostics) {
|
|
222
|
+
let message = "oxlint reported problems in src/appframe.d.ts:\n" + diagnostics;
|
|
223
|
+
logger.info(createLogMessage(message, { source: hostname, type: "warn" }));
|
|
224
|
+
}
|
|
229
225
|
}
|
|
230
226
|
export async function runGenerateTypes(hostname, username, password, appframe, logger) {
|
|
231
227
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
232
228
|
try {
|
|
233
|
-
await doGenerateTypes(hostname, username, password, appframe);
|
|
229
|
+
await doGenerateTypes(hostname, username, password, appframe, logger);
|
|
234
230
|
logger.info(createLogMessage("typescript types generated successfully", { source: hostname }));
|
|
235
231
|
return;
|
|
236
232
|
}
|
package/lib/index.d.ts
CHANGED
|
@@ -13,9 +13,11 @@ export interface AppframePluginOptions {
|
|
|
13
13
|
*/
|
|
14
14
|
generateTypes?: boolean;
|
|
15
15
|
/**
|
|
16
|
-
* Whether to
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* Whether to expose the DevTools JSON API at `/__appframe_devtools__/api` in dev
|
|
17
|
+
* mode. The Appframe DevTools browser extension (`packages/appframe-devtools`)
|
|
18
|
+
* uses it to edit `resources.yaml` and browse the data API catalog. Nothing is
|
|
19
|
+
* injected into the page. Set to `false` when there is no article context
|
|
20
|
+
* (e.g. when running Storybook).
|
|
19
21
|
*
|
|
20
22
|
* @default true
|
|
21
23
|
*/
|
|
@@ -31,3 +33,5 @@ export interface AppframePluginOptions {
|
|
|
31
33
|
}
|
|
32
34
|
export default function appframe(options?: AppframePluginOptions): Plugin[];
|
|
33
35
|
export { addAppframeBuildConfig, appframeDataGlobal, createDevMiddleware, publishSafeOutput };
|
|
36
|
+
export { DEVTOOLS_API_VERSION, type DevtoolsDiscovery, type DevtoolsServerInfo } from "./devtoolsServer.js";
|
|
37
|
+
export type { JobOptions, ReleaseEvent, ReleaseScriptEvent, ReleaseSnapshot, ReleaseStep } from "./releaseServer.js";
|
package/lib/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { addAppframeBuildConfig } from "./build.js";
|
|
|
6
6
|
import { generateFromConfig } from "./cli-resources-generate.js";
|
|
7
7
|
import { createDevMiddleware, getLoginInfo, getProxyRoutes } from "./devServer.js";
|
|
8
8
|
import { createDevtoolsMiddleware } from "./devtoolsServer.js";
|
|
9
|
+
import { importJson } from "./importJson.js";
|
|
9
10
|
import { runGenerateTypes } from "./generateTypes.js";
|
|
10
11
|
import { getMuiXLicenseKey } from "./licenses.js";
|
|
11
12
|
import { localizeMiddleware } from "./localization.js";
|
|
@@ -204,9 +205,23 @@ export default function appframe(options = {}) {
|
|
|
204
205
|
}
|
|
205
206
|
}, 300);
|
|
206
207
|
});
|
|
207
|
-
// DevTools
|
|
208
|
+
// DevTools API for the browser extension at /__appframe_devtools__/api
|
|
208
209
|
if (devtools) {
|
|
209
|
-
|
|
210
|
+
let [pluginPkg, appPkg] = await Promise.all([
|
|
211
|
+
importJson("../package.json"),
|
|
212
|
+
importJson("./package.json", true),
|
|
213
|
+
]);
|
|
214
|
+
_server.middlewares.use("/__appframe_devtools__", createDevtoolsMiddleware({
|
|
215
|
+
hostname,
|
|
216
|
+
articleId: String(appframe.article?.id ?? ""),
|
|
217
|
+
articleHostname: appframe.article?.hostname ? String(appframe.article.hostname) : null,
|
|
218
|
+
appName: String(appPkg.name ?? ""),
|
|
219
|
+
appVersion: String(appPkg.version ?? ""),
|
|
220
|
+
pluginVersion: String(pluginPkg.version ?? ""),
|
|
221
|
+
}, {
|
|
222
|
+
appDir: process.cwd(),
|
|
223
|
+
scripts: Object.fromEntries(Object.entries((appPkg.scripts ?? {})).filter((entry) => typeof entry[1] === "string")),
|
|
224
|
+
}));
|
|
210
225
|
}
|
|
211
226
|
_server.middlewares.use(`/api/user/localize/new/${appframe.article.id}`, jsonParser);
|
|
212
227
|
_server.middlewares.use(`/api/user/localize/new/${appframe.article.id}`, localizeMiddleware);
|
|
@@ -224,22 +239,8 @@ export default function appframe(options = {}) {
|
|
|
224
239
|
_server.middlewares.use(createDevMiddleware(_server));
|
|
225
240
|
};
|
|
226
241
|
},
|
|
227
|
-
transformIndexHtml(_html, ctx) {
|
|
228
|
-
// Only inject the toolbar in dev mode
|
|
229
|
-
if (command !== "serve" || !devtools)
|
|
230
|
-
return;
|
|
231
|
-
// Only inject for article pages (not the devtools app itself)
|
|
232
|
-
if (ctx.originalUrl?.startsWith("/__appframe_devtools__"))
|
|
233
|
-
return;
|
|
234
|
-
return [
|
|
235
|
-
{
|
|
236
|
-
tag: "script",
|
|
237
|
-
attrs: { src: "/__appframe_devtools__/toolbar.js", defer: true },
|
|
238
|
-
injectTo: "body",
|
|
239
|
-
},
|
|
240
|
-
];
|
|
241
|
-
},
|
|
242
242
|
};
|
|
243
243
|
return [...(dataGlobal ? [appframeDataGlobal()] : []), plugin, publishSafeOutput()];
|
|
244
244
|
}
|
|
245
245
|
export { addAppframeBuildConfig, appframeDataGlobal, createDevMiddleware, publishSafeOutput };
|
|
246
|
+
export { DEVTOOLS_API_VERSION } from "./devtoolsServer.js";
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
export type ReleaseStepStatus = "pending" | "running" | "done" | "failed" | "skipped";
|
|
3
|
+
export interface ReleaseStep {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
status: ReleaseStepStatus;
|
|
7
|
+
message?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface ReleaseTransaction {
|
|
10
|
+
Name: string;
|
|
11
|
+
Namespace: string;
|
|
12
|
+
CreatedBy: string;
|
|
13
|
+
Status: number;
|
|
14
|
+
LastError: string | null;
|
|
15
|
+
}
|
|
16
|
+
/** Events the release script emits (see `scripts/lib/releaseUi.ts`). */
|
|
17
|
+
export type ReleaseScriptEvent = {
|
|
18
|
+
type: "steps";
|
|
19
|
+
steps: Array<{
|
|
20
|
+
id: string;
|
|
21
|
+
label: string;
|
|
22
|
+
}>;
|
|
23
|
+
} | {
|
|
24
|
+
type: "step";
|
|
25
|
+
id: string;
|
|
26
|
+
status: ReleaseStepStatus;
|
|
27
|
+
message?: string;
|
|
28
|
+
} | {
|
|
29
|
+
type: "notes";
|
|
30
|
+
draft: string;
|
|
31
|
+
existing: string | null;
|
|
32
|
+
hasCommitReleaseNotes: boolean;
|
|
33
|
+
} | {
|
|
34
|
+
type: "version";
|
|
35
|
+
current: string;
|
|
36
|
+
next: Record<string, string>;
|
|
37
|
+
} | {
|
|
38
|
+
type: "input";
|
|
39
|
+
name: string;
|
|
40
|
+
} | {
|
|
41
|
+
type: "apply-review";
|
|
42
|
+
namespace: string;
|
|
43
|
+
server: string;
|
|
44
|
+
transactions: ReleaseTransaction[];
|
|
45
|
+
} | {
|
|
46
|
+
type: "result";
|
|
47
|
+
tag: string;
|
|
48
|
+
version: string;
|
|
49
|
+
releaseUrl: string | null;
|
|
50
|
+
} | {
|
|
51
|
+
type: "error";
|
|
52
|
+
message: string;
|
|
53
|
+
};
|
|
54
|
+
/** What the panel receives: script events plus the process' own output and exit. */
|
|
55
|
+
export type ReleaseEvent = ReleaseScriptEvent | {
|
|
56
|
+
type: "log";
|
|
57
|
+
line: string;
|
|
58
|
+
} | {
|
|
59
|
+
type: "exit";
|
|
60
|
+
code: number | null;
|
|
61
|
+
};
|
|
62
|
+
export type JobOptions = {
|
|
63
|
+
kind: "release";
|
|
64
|
+
dry: boolean;
|
|
65
|
+
force: boolean;
|
|
66
|
+
} | {
|
|
67
|
+
kind: "deploy";
|
|
68
|
+
target: string;
|
|
69
|
+
copyDatasources: boolean;
|
|
70
|
+
};
|
|
71
|
+
export interface ReleaseSnapshot {
|
|
72
|
+
id: number;
|
|
73
|
+
kind: JobOptions["kind"];
|
|
74
|
+
status: "running" | "done" | "failed" | "cancelled";
|
|
75
|
+
options: JobOptions;
|
|
76
|
+
steps: ReleaseStep[];
|
|
77
|
+
notes: {
|
|
78
|
+
draft: string;
|
|
79
|
+
existing: string | null;
|
|
80
|
+
hasCommitReleaseNotes: boolean;
|
|
81
|
+
} | null;
|
|
82
|
+
version: {
|
|
83
|
+
current: string;
|
|
84
|
+
next: Record<string, string>;
|
|
85
|
+
} | null;
|
|
86
|
+
/** Inputs the script has asked for and not received yet. */
|
|
87
|
+
awaiting: string[];
|
|
88
|
+
review: {
|
|
89
|
+
namespace: string;
|
|
90
|
+
server: string;
|
|
91
|
+
transactions: ReleaseTransaction[];
|
|
92
|
+
} | null;
|
|
93
|
+
result: {
|
|
94
|
+
tag: string;
|
|
95
|
+
version: string;
|
|
96
|
+
releaseUrl: string | null;
|
|
97
|
+
} | null;
|
|
98
|
+
error: string | null;
|
|
99
|
+
log: string[];
|
|
100
|
+
startedAt: string;
|
|
101
|
+
finishedAt: string | null;
|
|
102
|
+
}
|
|
103
|
+
export interface ReleaseServerOptions {
|
|
104
|
+
/** Directory of the app, where the scripts run. */
|
|
105
|
+
appDir: string;
|
|
106
|
+
/** The app's package.json `scripts`. */
|
|
107
|
+
scripts: Record<string, string>;
|
|
108
|
+
}
|
|
109
|
+
/** Servers a deploy can target; `APPFRAME_DEPLOY_HOSTNAME` is validated against this list. */
|
|
110
|
+
export declare const DEPLOY_TARGETS: readonly ["dev.obet.no", "stage.obet.no", "test.obet.no"];
|
|
111
|
+
/** Marks an event line on the script's stdout (see `scripts/lib/releaseUi.ts`). */
|
|
112
|
+
export declare const EVENT_PREFIX = "@@appframe-devtools ";
|
|
113
|
+
/** Returns the event on an stdout line, or `null` for ordinary output. */
|
|
114
|
+
export declare function parseReleaseEventLine(line: string): ReleaseScriptEvent | null;
|
|
115
|
+
/** Applies one event to the snapshot (mutating), the same way the panel does. */
|
|
116
|
+
export declare function applyReleaseEvent(snapshot: ReleaseSnapshot, event: ReleaseEvent): void;
|
|
117
|
+
type Listener = (event: ReleaseEvent) => void;
|
|
118
|
+
export declare function createReleaseManager(options: ReleaseServerOptions): {
|
|
119
|
+
readonly snapshot: ReleaseSnapshot | null;
|
|
120
|
+
/** What the panel can offer, derived from the app's scripts. */
|
|
121
|
+
capabilities: {
|
|
122
|
+
release: boolean;
|
|
123
|
+
deploy: boolean;
|
|
124
|
+
copyDatasources: boolean;
|
|
125
|
+
targets: ("dev.obet.no" | "stage.obet.no" | "test.obet.no")[];
|
|
126
|
+
};
|
|
127
|
+
script: string;
|
|
128
|
+
start: (jobOptions: JobOptions) => ReleaseSnapshot;
|
|
129
|
+
sendInput: (name: string, value: unknown) => void;
|
|
130
|
+
cancel: () => void;
|
|
131
|
+
subscribe(listener: Listener): () => void;
|
|
132
|
+
};
|
|
133
|
+
export type ReleaseManager = ReturnType<typeof createReleaseManager>;
|
|
134
|
+
/**
|
|
135
|
+
* Routes under `/__appframe_devtools__/api/release`:
|
|
136
|
+
* - `GET /release` — current session snapshot (`session: null` when none) and the capabilities
|
|
137
|
+
* - `POST /release` — start a job: `{ kind: "release", dry?, force? }` or
|
|
138
|
+
* `{ kind: "deploy", target, copyDatasources? }`; 409 while one is running
|
|
139
|
+
* - `DELETE /release` — cancel the running job
|
|
140
|
+
* - `POST /release/input` — answer a release script question (`{ name, value }`)
|
|
141
|
+
* - `GET /release/events` — server-sent events: a `snapshot` message, then one `event` message per event
|
|
142
|
+
*/
|
|
143
|
+
export declare function handleReleaseApi(req: IncomingMessage & {
|
|
144
|
+
body?: unknown;
|
|
145
|
+
}, res: ServerResponse, subPath: string, manager: ReleaseManager): Promise<boolean>;
|
|
146
|
+
export {};
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs the app's release and deploy scripts for the Appframe DevTools extension.
|
|
3
|
+
*
|
|
4
|
+
* Two kinds of job share one session slot, one snapshot shape and one event stream:
|
|
5
|
+
*
|
|
6
|
+
* - `release` spawns `scripts.release` with `--ui`, which makes it report its steps as JSON
|
|
7
|
+
* events (stdout lines starting with `EVENT_PREFIX`) and read its answers (release type,
|
|
8
|
+
* notes, apply confirmation) as JSON lines from stdin instead of prompting. Every other
|
|
9
|
+
* stdout/stderr line is log output.
|
|
10
|
+
* - `deploy` runs `scripts.build`, optionally `scripts.cd` (copy data sources), then
|
|
11
|
+
* `scripts.deploy` as consecutive steps, with `APPFRAME_DEPLOY_HOSTNAME` set to the chosen
|
|
12
|
+
* target so the deploy and copy scripts use it instead of the hostname in package.json.
|
|
13
|
+
*
|
|
14
|
+
* The snapshot lets a panel opened mid-job catch up; events are streamed over server-sent
|
|
15
|
+
* events.
|
|
16
|
+
*/
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import readline from "node:readline";
|
|
19
|
+
/** Servers a deploy can target; `APPFRAME_DEPLOY_HOSTNAME` is validated against this list. */
|
|
20
|
+
export const DEPLOY_TARGETS = ["dev.obet.no", "stage.obet.no", "test.obet.no"];
|
|
21
|
+
const LOG_LIMIT = 2000;
|
|
22
|
+
/** Marks an event line on the script's stdout (see `scripts/lib/releaseUi.ts`). */
|
|
23
|
+
export const EVENT_PREFIX = "@@appframe-devtools ";
|
|
24
|
+
/** Returns the event on an stdout line, or `null` for ordinary output. */
|
|
25
|
+
export function parseReleaseEventLine(line) {
|
|
26
|
+
let trimmed = line.trimStart();
|
|
27
|
+
if (!trimmed.startsWith(EVENT_PREFIX))
|
|
28
|
+
return null;
|
|
29
|
+
try {
|
|
30
|
+
let parsed = JSON.parse(trimmed.slice(EVENT_PREFIX.length));
|
|
31
|
+
return parsed && typeof parsed.type === "string" ? parsed : null;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Applies one event to the snapshot (mutating), the same way the panel does. */
|
|
38
|
+
export function applyReleaseEvent(snapshot, event) {
|
|
39
|
+
switch (event.type) {
|
|
40
|
+
case "steps":
|
|
41
|
+
snapshot.steps = event.steps.map((s) => ({ ...s, status: "pending" }));
|
|
42
|
+
break;
|
|
43
|
+
case "step": {
|
|
44
|
+
let step = snapshot.steps.find((s) => s.id === event.id);
|
|
45
|
+
if (step) {
|
|
46
|
+
step.status = event.status;
|
|
47
|
+
step.message = event.message;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
snapshot.steps.push({ id: event.id, label: event.id, status: event.status, message: event.message });
|
|
51
|
+
}
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
case "notes":
|
|
55
|
+
snapshot.notes = {
|
|
56
|
+
draft: event.draft,
|
|
57
|
+
existing: event.existing,
|
|
58
|
+
hasCommitReleaseNotes: event.hasCommitReleaseNotes,
|
|
59
|
+
};
|
|
60
|
+
break;
|
|
61
|
+
case "version":
|
|
62
|
+
snapshot.version = { current: event.current, next: event.next };
|
|
63
|
+
break;
|
|
64
|
+
case "input":
|
|
65
|
+
if (!snapshot.awaiting.includes(event.name))
|
|
66
|
+
snapshot.awaiting.push(event.name);
|
|
67
|
+
break;
|
|
68
|
+
case "apply-review":
|
|
69
|
+
snapshot.review = { namespace: event.namespace, server: event.server, transactions: event.transactions };
|
|
70
|
+
break;
|
|
71
|
+
case "result":
|
|
72
|
+
snapshot.result = { tag: event.tag, version: event.version, releaseUrl: event.releaseUrl };
|
|
73
|
+
break;
|
|
74
|
+
case "error":
|
|
75
|
+
snapshot.error = event.message;
|
|
76
|
+
break;
|
|
77
|
+
case "log":
|
|
78
|
+
snapshot.log.push(event.line);
|
|
79
|
+
if (snapshot.log.length > LOG_LIMIT)
|
|
80
|
+
snapshot.log.splice(0, snapshot.log.length - LOG_LIMIT);
|
|
81
|
+
break;
|
|
82
|
+
case "exit":
|
|
83
|
+
snapshot.finishedAt = new Date().toISOString();
|
|
84
|
+
if (snapshot.status === "running") {
|
|
85
|
+
snapshot.status = event.code === 0 ? "done" : "failed";
|
|
86
|
+
if (event.code !== 0 && !snapshot.error) {
|
|
87
|
+
snapshot.error = `The script exited with code ${event.code}`;
|
|
88
|
+
}
|
|
89
|
+
// Whatever was still running did not finish.
|
|
90
|
+
for (let step of snapshot.steps) {
|
|
91
|
+
if (step.status === "running")
|
|
92
|
+
step.status = event.code === 0 ? "done" : "failed";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function httpError(message, status) {
|
|
99
|
+
return Object.assign(new Error(message), { status });
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Kills the script and everything it started. The scripts run through a shell, so
|
|
103
|
+
* `child.kill()` alone would only end the shell and leave `vite build` or the release
|
|
104
|
+
* script running (and the session's pipes open) until they finish on their own.
|
|
105
|
+
*/
|
|
106
|
+
function killTree(child) {
|
|
107
|
+
if (!child.pid)
|
|
108
|
+
return;
|
|
109
|
+
if (process.platform === "win32") {
|
|
110
|
+
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }).on("error", () => child.kill());
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
try {
|
|
114
|
+
// The child is spawned detached, so its pid is also its process group id.
|
|
115
|
+
process.kill(-child.pid, "SIGTERM");
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
child.kill("SIGTERM");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function createReleaseManager(options) {
|
|
123
|
+
let nextId = 1;
|
|
124
|
+
let current = null;
|
|
125
|
+
let listeners = new Set();
|
|
126
|
+
let scripts = options.scripts;
|
|
127
|
+
let copyScript = scripts.cd ?? scripts["copy-datasources"] ?? null;
|
|
128
|
+
function broadcast(event) {
|
|
129
|
+
if (current)
|
|
130
|
+
applyReleaseEvent(current.snapshot, event);
|
|
131
|
+
for (let listener of listeners) {
|
|
132
|
+
try {
|
|
133
|
+
listener(event);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// A dead SSE connection; it is removed on close.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function readLines(stream, onLine) {
|
|
141
|
+
if (!stream)
|
|
142
|
+
return;
|
|
143
|
+
readline.createInterface({ input: stream, terminal: false }).on("line", onLine);
|
|
144
|
+
}
|
|
145
|
+
function spawnScript(command, env) {
|
|
146
|
+
return spawn(command, {
|
|
147
|
+
cwd: options.appDir,
|
|
148
|
+
shell: true,
|
|
149
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
150
|
+
// Own process group on POSIX so `killTree` can end the whole tree.
|
|
151
|
+
detached: process.platform !== "win32",
|
|
152
|
+
env: { ...process.env, FORCE_COLOR: "0", CI: "true", ...env },
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
function newSnapshot(jobOptions) {
|
|
156
|
+
return {
|
|
157
|
+
id: nextId++,
|
|
158
|
+
kind: jobOptions.kind,
|
|
159
|
+
status: "running",
|
|
160
|
+
options: jobOptions,
|
|
161
|
+
steps: [],
|
|
162
|
+
notes: null,
|
|
163
|
+
version: null,
|
|
164
|
+
awaiting: [],
|
|
165
|
+
review: null,
|
|
166
|
+
result: null,
|
|
167
|
+
error: null,
|
|
168
|
+
log: [],
|
|
169
|
+
startedAt: new Date().toISOString(),
|
|
170
|
+
finishedAt: null,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function startRelease(jobOptions) {
|
|
174
|
+
if (!scripts.release)
|
|
175
|
+
throw httpError("This app has no `release` script in package.json", 400);
|
|
176
|
+
let args = ["--ui"];
|
|
177
|
+
if (jobOptions.dry)
|
|
178
|
+
args.push("--dry");
|
|
179
|
+
if (jobOptions.force)
|
|
180
|
+
args.push("--force");
|
|
181
|
+
let snapshot = newSnapshot(jobOptions);
|
|
182
|
+
let child = spawnScript(`${scripts.release} ${args.join(" ")}`, {});
|
|
183
|
+
current = { snapshot, child };
|
|
184
|
+
readLines(child.stdout, (line) => {
|
|
185
|
+
let event = parseReleaseEventLine(line);
|
|
186
|
+
broadcast(event ?? { type: "log", line });
|
|
187
|
+
});
|
|
188
|
+
readLines(child.stderr, (line) => broadcast({ type: "log", line }));
|
|
189
|
+
child.on("error", (err) => {
|
|
190
|
+
broadcast({ type: "error", message: `Could not start the release script: ${err.message}` });
|
|
191
|
+
});
|
|
192
|
+
child.on("close", (code) => {
|
|
193
|
+
if (current?.child === child)
|
|
194
|
+
current.child = null;
|
|
195
|
+
broadcast({ type: "exit", code });
|
|
196
|
+
});
|
|
197
|
+
return snapshot;
|
|
198
|
+
}
|
|
199
|
+
function startDeploy(jobOptions) {
|
|
200
|
+
if (!scripts.build || !scripts.deploy) {
|
|
201
|
+
throw httpError("This app needs `build` and `deploy` scripts in package.json", 400);
|
|
202
|
+
}
|
|
203
|
+
if (!DEPLOY_TARGETS.includes(jobOptions.target)) {
|
|
204
|
+
throw httpError(`Unknown deploy target ${jobOptions.target}`, 400);
|
|
205
|
+
}
|
|
206
|
+
if (jobOptions.copyDatasources && !copyScript) {
|
|
207
|
+
throw httpError("This app has no `cd` or `copy-datasources` script in package.json", 400);
|
|
208
|
+
}
|
|
209
|
+
let plan = [
|
|
210
|
+
{ id: "build", label: "Build (pnpm run build)", command: "pnpm run build" },
|
|
211
|
+
];
|
|
212
|
+
if (jobOptions.copyDatasources) {
|
|
213
|
+
plan.push({
|
|
214
|
+
id: "copy-datasources",
|
|
215
|
+
label: `Copy data sources from dev to ${jobOptions.target}`,
|
|
216
|
+
command: scripts.cd ? "pnpm run cd" : "pnpm run copy-datasources",
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
plan.push({ id: "deploy", label: `Deploy to ${jobOptions.target}`, command: "pnpm run deploy" });
|
|
220
|
+
let snapshot = newSnapshot(jobOptions);
|
|
221
|
+
let session = { snapshot, child: null };
|
|
222
|
+
current = session;
|
|
223
|
+
broadcast({ type: "steps", steps: plan.map(({ id, label }) => ({ id, label })) });
|
|
224
|
+
let env = { APPFRAME_DEPLOY_HOSTNAME: jobOptions.target };
|
|
225
|
+
let runStep = (step) => new Promise((resolve) => {
|
|
226
|
+
broadcast({ type: "step", id: step.id, status: "running" });
|
|
227
|
+
broadcast({ type: "log", line: `$ ${step.command}` });
|
|
228
|
+
let child = spawnScript(step.command, env);
|
|
229
|
+
session.child = child;
|
|
230
|
+
readLines(child.stdout, (line) => broadcast({ type: "log", line }));
|
|
231
|
+
readLines(child.stderr, (line) => broadcast({ type: "log", line }));
|
|
232
|
+
child.on("error", (err) => {
|
|
233
|
+
broadcast({ type: "step", id: step.id, status: "failed", message: err.message });
|
|
234
|
+
resolve(1);
|
|
235
|
+
});
|
|
236
|
+
child.on("close", (code) => {
|
|
237
|
+
session.child = null;
|
|
238
|
+
if (snapshot.status !== "running") {
|
|
239
|
+
broadcast({ type: "step", id: step.id, status: "failed", message: "cancelled" });
|
|
240
|
+
}
|
|
241
|
+
else if (code === 0) {
|
|
242
|
+
broadcast({ type: "step", id: step.id, status: "done" });
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
broadcast({ type: "step", id: step.id, status: "failed", message: `exit code ${code}` });
|
|
246
|
+
}
|
|
247
|
+
resolve(code);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
(async () => {
|
|
251
|
+
let code = 0;
|
|
252
|
+
for (let step of plan) {
|
|
253
|
+
code = await runStep(step);
|
|
254
|
+
if (code !== 0 || snapshot.status !== "running")
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
broadcast({ type: "exit", code });
|
|
258
|
+
})();
|
|
259
|
+
return snapshot;
|
|
260
|
+
}
|
|
261
|
+
function start(jobOptions) {
|
|
262
|
+
if (current?.snapshot.status === "running") {
|
|
263
|
+
throw httpError("A release or deploy is already running", 409);
|
|
264
|
+
}
|
|
265
|
+
return jobOptions.kind === "deploy" ? startDeploy(jobOptions) : startRelease(jobOptions);
|
|
266
|
+
}
|
|
267
|
+
function sendInput(name, value) {
|
|
268
|
+
if (!current?.child?.stdin || current.snapshot.status !== "running" || current.snapshot.kind !== "release") {
|
|
269
|
+
throw httpError("No release is waiting for input", 409);
|
|
270
|
+
}
|
|
271
|
+
current.child.stdin.write(`${JSON.stringify({ name, value })}\n`);
|
|
272
|
+
current.snapshot.awaiting = current.snapshot.awaiting.filter((n) => n !== name);
|
|
273
|
+
}
|
|
274
|
+
function cancel() {
|
|
275
|
+
if (!current || current.snapshot.status !== "running")
|
|
276
|
+
return;
|
|
277
|
+
current.snapshot.status = "cancelled";
|
|
278
|
+
current.snapshot.error = "Cancelled from the DevTools panel";
|
|
279
|
+
if (current.child)
|
|
280
|
+
killTree(current.child);
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
get snapshot() {
|
|
284
|
+
return current?.snapshot ?? null;
|
|
285
|
+
},
|
|
286
|
+
/** What the panel can offer, derived from the app's scripts. */
|
|
287
|
+
capabilities: {
|
|
288
|
+
release: Boolean(scripts.release),
|
|
289
|
+
deploy: Boolean(scripts.build && scripts.deploy),
|
|
290
|
+
copyDatasources: Boolean(copyScript),
|
|
291
|
+
targets: [...DEPLOY_TARGETS],
|
|
292
|
+
},
|
|
293
|
+
script: scripts.release ?? null,
|
|
294
|
+
start,
|
|
295
|
+
sendInput,
|
|
296
|
+
cancel,
|
|
297
|
+
subscribe(listener) {
|
|
298
|
+
listeners.add(listener);
|
|
299
|
+
return () => listeners.delete(listener);
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
function sendJson(res, status, data) {
|
|
304
|
+
res.statusCode = status;
|
|
305
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
306
|
+
res.end(JSON.stringify(data));
|
|
307
|
+
}
|
|
308
|
+
function parseJobOptions(body) {
|
|
309
|
+
let b = (body ?? {});
|
|
310
|
+
if (b.kind === "deploy") {
|
|
311
|
+
return { kind: "deploy", target: String(b.target ?? ""), copyDatasources: b.copyDatasources === true };
|
|
312
|
+
}
|
|
313
|
+
return { kind: "release", dry: b.dry === true, force: b.force === true };
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Routes under `/__appframe_devtools__/api/release`:
|
|
317
|
+
* - `GET /release` — current session snapshot (`session: null` when none) and the capabilities
|
|
318
|
+
* - `POST /release` — start a job: `{ kind: "release", dry?, force? }` or
|
|
319
|
+
* `{ kind: "deploy", target, copyDatasources? }`; 409 while one is running
|
|
320
|
+
* - `DELETE /release` — cancel the running job
|
|
321
|
+
* - `POST /release/input` — answer a release script question (`{ name, value }`)
|
|
322
|
+
* - `GET /release/events` — server-sent events: a `snapshot` message, then one `event` message per event
|
|
323
|
+
*/
|
|
324
|
+
export async function handleReleaseApi(req, res, subPath, manager) {
|
|
325
|
+
let method = req.method?.toUpperCase() ?? "GET";
|
|
326
|
+
if (subPath === "" && method === "GET") {
|
|
327
|
+
sendJson(res, 200, {
|
|
328
|
+
available: manager.capabilities.release,
|
|
329
|
+
capabilities: manager.capabilities,
|
|
330
|
+
script: manager.script,
|
|
331
|
+
session: manager.snapshot,
|
|
332
|
+
});
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
if (subPath === "" && method === "POST") {
|
|
336
|
+
try {
|
|
337
|
+
let snapshot = manager.start(parseJobOptions(req.body));
|
|
338
|
+
sendJson(res, 200, { session: snapshot });
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
let status = error.status ?? 500;
|
|
342
|
+
sendJson(res, status, { error: error.message });
|
|
343
|
+
}
|
|
344
|
+
return true;
|
|
345
|
+
}
|
|
346
|
+
if (subPath === "" && method === "DELETE") {
|
|
347
|
+
manager.cancel();
|
|
348
|
+
sendJson(res, 200, { session: manager.snapshot });
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
if (subPath === "/input" && method === "POST") {
|
|
352
|
+
let body = (req.body ?? {});
|
|
353
|
+
if (typeof body.name !== "string") {
|
|
354
|
+
sendJson(res, 400, { error: "`name` is required" });
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
manager.sendInput(body.name, body.value);
|
|
359
|
+
sendJson(res, 200, { session: manager.snapshot });
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
let status = error.status ?? 500;
|
|
363
|
+
sendJson(res, status, { error: error.message });
|
|
364
|
+
}
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
if (subPath === "/events" && method === "GET") {
|
|
368
|
+
res.statusCode = 200;
|
|
369
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
370
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
371
|
+
res.setHeader("Connection", "keep-alive");
|
|
372
|
+
res.flushHeaders?.();
|
|
373
|
+
let write = (data) => res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
374
|
+
write({ kind: "snapshot", snapshot: manager.snapshot });
|
|
375
|
+
let unsubscribe = manager.subscribe((event) => write({ kind: "event", event }));
|
|
376
|
+
let keepAlive = setInterval(() => res.write(": keep-alive\n\n"), 20_000);
|
|
377
|
+
req.on("close", () => {
|
|
378
|
+
clearInterval(keepAlive);
|
|
379
|
+
unsubscribe();
|
|
380
|
+
});
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { applyReleaseEvent, EVENT_PREFIX, parseReleaseEventLine } from "./releaseServer.js";
|
|
3
|
+
function snapshot() {
|
|
4
|
+
return {
|
|
5
|
+
id: 1,
|
|
6
|
+
kind: "release",
|
|
7
|
+
status: "running",
|
|
8
|
+
options: { kind: "release", dry: false, force: false },
|
|
9
|
+
steps: [],
|
|
10
|
+
notes: null,
|
|
11
|
+
version: null,
|
|
12
|
+
awaiting: [],
|
|
13
|
+
review: null,
|
|
14
|
+
result: null,
|
|
15
|
+
error: null,
|
|
16
|
+
log: [],
|
|
17
|
+
startedAt: "2026-09-03T00:00:00.000Z",
|
|
18
|
+
finishedAt: null,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
describe("parseReleaseEventLine", () => {
|
|
22
|
+
it("parses prefixed JSON objects with a type", () => {
|
|
23
|
+
expect(parseReleaseEventLine(`${EVENT_PREFIX}{"type":"input","name":"release"}`)).toEqual({
|
|
24
|
+
type: "input",
|
|
25
|
+
name: "release",
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
it("ignores other lines", () => {
|
|
29
|
+
expect(parseReleaseEventLine("Running type checks...")).toBeNull();
|
|
30
|
+
expect(parseReleaseEventLine('{"type":"input","name":"release"}')).toBeNull();
|
|
31
|
+
expect(parseReleaseEventLine(`${EVENT_PREFIX}{"foo":1}`)).toBeNull();
|
|
32
|
+
expect(parseReleaseEventLine(`${EVENT_PREFIX}{not json`)).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe("applyReleaseEvent", () => {
|
|
36
|
+
it("tracks step statuses from the announced list", () => {
|
|
37
|
+
let s = snapshot();
|
|
38
|
+
applyReleaseEvent(s, {
|
|
39
|
+
type: "steps",
|
|
40
|
+
steps: [
|
|
41
|
+
{ id: "a", label: "A" },
|
|
42
|
+
{ id: "b", label: "B" },
|
|
43
|
+
],
|
|
44
|
+
});
|
|
45
|
+
applyReleaseEvent(s, { type: "step", id: "a", status: "running" });
|
|
46
|
+
applyReleaseEvent(s, { type: "step", id: "a", status: "done" });
|
|
47
|
+
applyReleaseEvent(s, { type: "step", id: "b", status: "failed", message: "boom" });
|
|
48
|
+
expect(s.steps).toEqual([
|
|
49
|
+
{ id: "a", label: "A", status: "done", message: undefined },
|
|
50
|
+
{ id: "b", label: "B", status: "failed", message: "boom" },
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
it("records inputs asked for, notes, version and review", () => {
|
|
54
|
+
let s = snapshot();
|
|
55
|
+
applyReleaseEvent(s, { type: "notes", draft: "## x", existing: null, hasCommitReleaseNotes: true });
|
|
56
|
+
applyReleaseEvent(s, { type: "version", current: "1.0.0", next: { patch: "1.0.1" } });
|
|
57
|
+
applyReleaseEvent(s, { type: "input", name: "release" });
|
|
58
|
+
applyReleaseEvent(s, { type: "input", name: "release" });
|
|
59
|
+
expect(s.notes?.draft).toBe("## x");
|
|
60
|
+
expect(s.version?.next.patch).toBe("1.0.1");
|
|
61
|
+
expect(s.awaiting).toEqual(["release"]);
|
|
62
|
+
});
|
|
63
|
+
it("marks a failed exit and finishes running steps", () => {
|
|
64
|
+
let s = snapshot();
|
|
65
|
+
applyReleaseEvent(s, { type: "steps", steps: [{ id: "build", label: "Build" }] });
|
|
66
|
+
applyReleaseEvent(s, { type: "step", id: "build", status: "running" });
|
|
67
|
+
applyReleaseEvent(s, { type: "exit", code: 1 });
|
|
68
|
+
expect(s.status).toBe("failed");
|
|
69
|
+
expect(s.steps[0].status).toBe("failed");
|
|
70
|
+
expect(s.error).toMatch(/exited with code 1/);
|
|
71
|
+
expect(s.finishedAt).not.toBeNull();
|
|
72
|
+
});
|
|
73
|
+
it("keeps a cancelled status on exit and caps the log", () => {
|
|
74
|
+
let s = snapshot();
|
|
75
|
+
s.status = "cancelled";
|
|
76
|
+
for (let i = 0; i < 2105; i++)
|
|
77
|
+
applyReleaseEvent(s, { type: "log", line: `l${i}` });
|
|
78
|
+
applyReleaseEvent(s, { type: "exit", code: null });
|
|
79
|
+
expect(s.status).toBe("cancelled");
|
|
80
|
+
expect(s.log.length).toBe(2000);
|
|
81
|
+
expect(s.log[0]).toBe("l105");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -1,10 +1,4 @@
|
|
|
1
1
|
import type { Client } from "@olenbetong/appframe-data";
|
|
2
|
-
/**
|
|
3
|
-
* Format a file with Biome if available. Searches for `biome` in the nearest
|
|
4
|
-
* node_modules/.bin up from cwd, then falls back to the global PATH.
|
|
5
|
-
* Silently skips if Biome is not found or formatting fails.
|
|
6
|
-
*/
|
|
7
|
-
export declare function formatWithBiome(filePath: string): Promise<void>;
|
|
8
2
|
/**
|
|
9
3
|
* Write a generated file only when the content changed.
|
|
10
4
|
* Formats the content before comparing so that an already-formatted file
|
|
@@ -13,6 +7,8 @@ export declare function formatWithBiome(filePath: string): Promise<void>;
|
|
|
13
7
|
*/
|
|
14
8
|
export declare function writeGeneratedFile(filePath: string, content: string): Promise<boolean>;
|
|
15
9
|
export type CLIOptions = {
|
|
10
|
+
/** Procedures only. `false` stops the framework wrapping the call in a transaction. */
|
|
11
|
+
transaction?: boolean;
|
|
16
12
|
server: string;
|
|
17
13
|
types?: boolean;
|
|
18
14
|
global: boolean;
|
package/lib/resourceGenerate.js
CHANGED
|
@@ -1,68 +1,8 @@
|
|
|
1
|
-
import { exec, spawn } from "node:child_process";
|
|
2
1
|
import { existsSync } from "node:fs";
|
|
3
2
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
-
import { dirname,
|
|
5
|
-
import {
|
|
3
|
+
import { dirname, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { formatContent } from "./format.js";
|
|
6
5
|
import { importJson } from "./importJson.js";
|
|
7
|
-
const execAsync = promisify(exec);
|
|
8
|
-
// ---------------------------------------------------------------------------
|
|
9
|
-
// Biome formatting
|
|
10
|
-
// ---------------------------------------------------------------------------
|
|
11
|
-
function findBiomeBin() {
|
|
12
|
-
let current = resolve(process.cwd());
|
|
13
|
-
while (true) {
|
|
14
|
-
let candidate = join(current, "node_modules", ".bin", "biome");
|
|
15
|
-
let candidateCmd = `${candidate}.cmd`;
|
|
16
|
-
if (existsSync(candidateCmd))
|
|
17
|
-
return candidateCmd;
|
|
18
|
-
if (existsSync(candidate))
|
|
19
|
-
return candidate;
|
|
20
|
-
let parent = dirname(current);
|
|
21
|
-
if (parent === current)
|
|
22
|
-
break;
|
|
23
|
-
current = parent;
|
|
24
|
-
}
|
|
25
|
-
return null;
|
|
26
|
-
}
|
|
27
|
-
/**
|
|
28
|
-
* Format a file with Biome if available. Searches for `biome` in the nearest
|
|
29
|
-
* node_modules/.bin up from cwd, then falls back to the global PATH.
|
|
30
|
-
* Silently skips if Biome is not found or formatting fails.
|
|
31
|
-
*/
|
|
32
|
-
export async function formatWithBiome(filePath) {
|
|
33
|
-
let bin = findBiomeBin() ?? "biome";
|
|
34
|
-
try {
|
|
35
|
-
await execAsync(`"${bin}" format --write "${resolve(filePath)}"`);
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
// Biome not available or not applicable — silently skip
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* Format content in memory using Biome's stdin mode.
|
|
43
|
-
* Returns the formatted content, or the original if Biome is unavailable.
|
|
44
|
-
*/
|
|
45
|
-
async function formatContentWithBiome(content, filePath) {
|
|
46
|
-
let bin = findBiomeBin() ?? "biome";
|
|
47
|
-
return new Promise((resolve) => {
|
|
48
|
-
let child = spawn(bin, ["format", `--stdin-file-path=${filePath}`], { stdio: "pipe" });
|
|
49
|
-
let stdout = "";
|
|
50
|
-
let failed = false;
|
|
51
|
-
child.stdout.on("data", (chunk) => {
|
|
52
|
-
stdout += chunk.toString();
|
|
53
|
-
});
|
|
54
|
-
child.on("error", () => {
|
|
55
|
-
failed = true;
|
|
56
|
-
resolve(content);
|
|
57
|
-
});
|
|
58
|
-
child.on("close", (code) => {
|
|
59
|
-
if (!failed)
|
|
60
|
-
resolve(code === 0 ? stdout : content);
|
|
61
|
-
});
|
|
62
|
-
child.stdin.write(content);
|
|
63
|
-
child.stdin.end();
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
6
|
/**
|
|
67
7
|
* Write a generated file only when the content changed.
|
|
68
8
|
* Formats the content before comparing so that an already-formatted file
|
|
@@ -71,7 +11,7 @@ async function formatContentWithBiome(content, filePath) {
|
|
|
71
11
|
*/
|
|
72
12
|
export async function writeGeneratedFile(filePath, content) {
|
|
73
13
|
await mkdir(dirname(filePath), { recursive: true });
|
|
74
|
-
let formatted = await
|
|
14
|
+
let formatted = await formatContent(content, filePath);
|
|
75
15
|
if (existsSync(filePath)) {
|
|
76
16
|
let existing = await readFile(filePath, "utf-8");
|
|
77
17
|
if (existing === formatted) {
|
|
@@ -365,7 +305,7 @@ export function getProcedureDefinition(name, procDefinition, options) {
|
|
|
365
305
|
output.push(`export const ${procName} = new ${options.global ? "af." : ""}ProcedureAPI${options.types ? `<${paramTypeName}, ${options.typesJsonReturnType ?? "{ Table?: unknown[] }"}>` : ""}({
|
|
366
306
|
procedureId: "${name}",
|
|
367
307
|
parameters: ${JSON.stringify(parameters, null, 2)},
|
|
368
|
-
timeout: 30000
|
|
308
|
+
timeout: 30000${options.transaction === false ? ",\n transaction: false" : ""}
|
|
369
309
|
});`);
|
|
370
310
|
if (options.expose) {
|
|
371
311
|
output.push("");
|
package/lib/resourcesConfig.d.ts
CHANGED
|
@@ -30,6 +30,13 @@ export type ResourceEntry = {
|
|
|
30
30
|
global?: boolean;
|
|
31
31
|
/** Emit TypeScript type definitions */
|
|
32
32
|
types?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Procedures only. `false` stops the framework wrapping the call in a transaction.
|
|
35
|
+
*
|
|
36
|
+
* Needed by a procedure that writes across a linked server: the wrapping transaction makes the
|
|
37
|
+
* remote write a distributed one, which fails without MSDTC configured between the servers.
|
|
38
|
+
*/
|
|
39
|
+
transaction?: boolean;
|
|
33
40
|
/** Permissions to set: I = insert, U = update, D = delete (e.g. `IUD`) */
|
|
34
41
|
permissions?: string;
|
|
35
42
|
/** Maximum records to fetch (default `50`; use `-1` for all) */
|
package/lib/resourcesConfig.js
CHANGED
|
@@ -97,6 +97,7 @@ export function entryToCLIOptions(entry, hostname) {
|
|
|
97
97
|
global: entry.global ?? false,
|
|
98
98
|
dynamic: entry.dynamic ?? false,
|
|
99
99
|
types: entry.types,
|
|
100
|
+
transaction: entry.transaction,
|
|
100
101
|
maxRecords: entry.maxRecords !== undefined ? String(entry.maxRecords) : "50",
|
|
101
102
|
sortOrder: sortOrderToString(entry.sortOrder),
|
|
102
103
|
permissions: entry.permissions,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olenbetong/appframe-vite",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.8.0",
|
|
4
4
|
"description": "Tools to use and deploy Vite applications to Appframe",
|
|
5
5
|
"main": "./lib/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -35,19 +35,18 @@
|
|
|
35
35
|
"commander": "15.0.0",
|
|
36
36
|
"dotenv": "^17.4.2",
|
|
37
37
|
"fuzzy": "^0.1.3",
|
|
38
|
-
"inquirer": "^14.0
|
|
38
|
+
"inquirer": "^14.2.0",
|
|
39
39
|
"inquirer-autocomplete-standalone": "^0.8.1",
|
|
40
40
|
"jsdom": "30.0.1",
|
|
41
|
-
"rollup-plugin-visualizer": "^7.
|
|
41
|
+
"rollup-plugin-visualizer": "^7.1.1",
|
|
42
42
|
"yaml": "^2.9.0",
|
|
43
|
-
"@olenbetong/appframe-data": "1.
|
|
44
|
-
"@olenbetong/appframe-devtools": "0.3.0"
|
|
43
|
+
"@olenbetong/appframe-data": "1.7.0"
|
|
45
44
|
},
|
|
46
45
|
"devDependencies": {
|
|
47
46
|
"@types/jsdom": "^27.0.0",
|
|
48
|
-
"@types/node": "26.
|
|
47
|
+
"@types/node": "26.4.0",
|
|
49
48
|
"typescript": "7.0.2",
|
|
50
|
-
"vite": "8.2.
|
|
49
|
+
"vite": "8.2.2"
|
|
51
50
|
},
|
|
52
51
|
"peerDependencies": {
|
|
53
52
|
"vite": ">=8.1.5"
|