@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 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 Pro 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.
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
- formatWithBiome,
278
+ writeGeneratedFile,
247
279
  getCustomImportPath,
248
280
  type CLIOptions,
249
281
  } from "@olenbetong/appframe-vite/resources";
@@ -1,7 +1,56 @@
1
+ import { type ReleaseServerOptions } from "./releaseServer.js";
1
2
  import type { Connect } from "vite";
2
3
  /**
3
- * Creates a Connect middleware that:
4
- * 1. Serves the appframe-devtools static SPA at `/__appframe_devtools__/`
5
- * 2. Exposes a REST API at `/__appframe_devtools__/api/resources` for resources.yaml CRUD
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 function createDevtoolsMiddleware(hostname: string): Connect.NextHandleFunction;
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;
@@ -1,32 +1,16 @@
1
- import { createReadStream, existsSync, rmSync } from "node:fs";
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
- const MIME_TYPES = {
11
- ".html": "text/html; charset=utf-8",
12
- ".js": "application/javascript; charset=utf-8",
13
- ".css": "text/css; charset=utf-8",
14
- ".json": "application/json; charset=utf-8",
15
- ".svg": "image/svg+xml",
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, hostname, next) {
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
- return sendJson(res, 200, { hostname });
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 that:
201
- * 1. Serves the appframe-devtools static SPA at `/__appframe_devtools__/`
202
- * 2. Exposes a REST API at `/__appframe_devtools__/api/resources` for resources.yaml CRUD
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(hostname) {
205
+ export function createDevtoolsMiddleware(info, options = { appDir: process.cwd(), scripts: {} }) {
205
206
  const jsonParser = bodyParser.json();
206
- const devtoolsDist = getDevtoolsDist();
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), hostname, next).catch(next);
213
+ handleApi(req, res, url.slice(4).split("?")[0], info, release, next).catch(next);
214
214
  });
215
215
  return;
216
216
  }
217
- // Static files
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
  }
@@ -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
+ }
@@ -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
- await formatFile("./src/appframe.d.ts");
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 enable the Appframe devtools panel and toolbar in dev mode.
17
- * Set to `false` to skip serving the devtools app and injecting the toolbar
18
- * script (e.g. when running Storybook).
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 panel: serve the devtools app + REST API at /__appframe_devtools__/
208
+ // DevTools API for the browser extension at /__appframe_devtools__/api
208
209
  if (devtools) {
209
- _server.middlewares.use("/__appframe_devtools__", createDevtoolsMiddleware(hostname));
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;
@@ -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, join, relative, resolve, sep } from "node:path";
5
- import { promisify } from "node:util";
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 formatContentWithBiome(content, filePath);
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("");
@@ -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) */
@@ -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.7.0",
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.2",
38
+ "inquirer": "^14.2.0",
39
39
  "inquirer-autocomplete-standalone": "^0.8.1",
40
40
  "jsdom": "30.0.1",
41
- "rollup-plugin-visualizer": "^7.0.1",
41
+ "rollup-plugin-visualizer": "^7.1.1",
42
42
  "yaml": "^2.9.0",
43
- "@olenbetong/appframe-data": "1.6.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.2.0",
47
+ "@types/node": "26.4.0",
49
48
  "typescript": "7.0.2",
50
- "vite": "8.2.1"
49
+ "vite": "8.2.2"
51
50
  },
52
51
  "peerDependencies": {
53
52
  "vite": ">=8.1.5"