@olenbetong/appframe-vite 6.12.2 → 6.13.1

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
@@ -33,10 +33,10 @@ Then run your app:
33
33
  pnpm --filter <app> start
34
34
  ```
35
35
 
36
- Make sure your Appframe credentials are available as environment variables:
37
-
38
- - `APPFRAME_LOGIN`: username
39
- - `APPFRAME_PWD`: password
36
+ The dev server logs in with the credentials `@olenbetong/appframe-auth` finds for the site:
37
+ `APPFRAME_LOGIN_<ENV>` / `APPFRAME_PWD_<ENV>`, then `APPFRAME_LOGIN` / `APPFRAME_PWD`, then the
38
+ password `af auth <env>` saved in the OS keychain. It never asks for one; without credentials it
39
+ stops and says which `af auth` to run.
40
40
 
41
41
  On first run, the plugin logs in, configures Vite’s proxy for Appframe routes, and serves the article HTML with your local entry injected.
42
42
 
@@ -1,10 +1,11 @@
1
1
  import { relative, resolve } from "node:path";
2
+ import { authenticate } from "@olenbetong/appframe-auth";
2
3
  import { Client, generateApiDataHandler } from "@olenbetong/appframe-data";
3
4
  import { config } from "dotenv";
4
5
  import fuzzy from "fuzzy";
5
6
  import inquirer from "inquirer";
6
7
  import autocomplete from "inquirer-autocomplete-standalone";
7
- import { getLoginInfo } from "./devServer.js";
8
+ import { getAppHostname } from "./devServer.js";
8
9
  import { fetchAndGenerate, fetchResourceDefinition, writeGeneratedFile } from "./resourceGenerate.js";
9
10
  import { entryToCLIOptions, readResourcesConfig, writeResourcesConfig } from "./resourcesConfig.js";
10
11
  config({ quiet: true });
@@ -21,9 +22,9 @@ async function getResources(client) {
21
22
  return ds.retrieve({ maxRecords: -1 });
22
23
  }
23
24
  export async function addResource() {
24
- let { hostname, username, password } = await getLoginInfo();
25
+ let { hostname } = await getAppHostname();
25
26
  let client = new Client(hostname);
26
- await client.login(username, password);
27
+ await authenticate(client, { hostname });
27
28
  // Step 1: pick a resource
28
29
  let resources = await getResources(client);
29
30
  let resourceChoices = resources.map((r) => ({
@@ -1,10 +1,11 @@
1
1
  import { relative, resolve } from "node:path";
2
+ import { authenticate } from "@olenbetong/appframe-auth";
2
3
  import { Client } from "@olenbetong/appframe-data";
3
4
  import { config } from "dotenv";
4
5
  import fuzzy from "fuzzy";
5
6
  import inquirer from "inquirer";
6
7
  import autocomplete from "inquirer-autocomplete-standalone";
7
- import { getLoginInfo } from "./devServer.js";
8
+ import { getAppHostname } from "./devServer.js";
8
9
  import { fetchAndGenerate, fetchResourceDefinition, writeGeneratedFile } from "./resourceGenerate.js";
9
10
  import { entryToCLIOptions, readResourcesConfig, writeResourcesConfig } from "./resourcesConfig.js";
10
11
  config({ quiet: true });
@@ -44,9 +45,9 @@ export async function editResource(id) {
44
45
  selected = allEntries.find((e) => e.entry.id === chosenId);
45
46
  }
46
47
  let { entry, isView } = selected;
47
- let { hostname, username, password } = await getLoginInfo();
48
+ let { hostname } = await getAppHostname();
48
49
  let client = new Client(hostname);
49
- await client.login(username, password);
50
+ await authenticate(client, { hostname });
50
51
  // Fetch definition for field picker
51
52
  let definition = await fetchResourceDefinition(client, entry.resource);
52
53
  let allFields = definition.Parameters?.filter((p) => !["CUT", "CDL"].includes(p.Name)).map((p) => isView ? p.Name : p.ParamName) ?? [];
@@ -1,7 +1,8 @@
1
1
  import { dirname, relative, resolve, sep } from "node:path";
2
+ import { authenticate } from "@olenbetong/appframe-auth";
2
3
  import { Client } from "@olenbetong/appframe-data";
3
4
  import { config } from "dotenv";
4
- import { getLoginInfo } from "./devServer.js";
5
+ import { getAppHostname } from "./devServer.js";
5
6
  import { fetchAndGenerate, writeGeneratedFile } from "./resourceGenerate.js";
6
7
  import { entryToCLIOptions, readResourcesConfig } from "./resourcesConfig.js";
7
8
  config({ quiet: true });
@@ -27,7 +28,7 @@ function resolveMasterImport(entry, allDataObjects) {
27
28
  return { ...entry, master: `${masterId}:${rel}` };
28
29
  }
29
30
  export async function generateFromConfig(configPath) {
30
- let { hostname, username, password } = await getLoginInfo();
31
+ let { hostname } = await getAppHostname();
31
32
  let resourcesConfig = await readResourcesConfig(configPath);
32
33
  let allEntries = [
33
34
  ...(resourcesConfig.dataObjects ?? []).map((e) => ({ type: "dataObject", entry: e })),
@@ -38,7 +39,7 @@ export async function generateFromConfig(configPath) {
38
39
  return;
39
40
  }
40
41
  let client = new Client(hostname);
41
- await client.login(username, password);
42
+ await authenticate(client, { hostname });
42
43
  let errors = [];
43
44
  let allDataObjects = resourcesConfig.dataObjects ?? [];
44
45
  for (let { entry } of allEntries) {
@@ -1,7 +1,8 @@
1
1
  import { resolve } from "node:path";
2
+ import { authenticate } from "@olenbetong/appframe-auth";
2
3
  import { Client, generateApiDataHandler } from "@olenbetong/appframe-data";
3
4
  import { config } from "dotenv";
4
- import { getLoginInfo } from "./devServer.js";
5
+ import { getAppHostname } from "./devServer.js";
5
6
  import { fetchAndGenerate, writeGeneratedFile } from "./resourceGenerate.js";
6
7
  import { entryToCLIOptions, readResourcesConfig, writeResourcesConfig, } from "./resourcesConfig.js";
7
8
  config({ quiet: true });
@@ -223,7 +224,7 @@ function upsertEntry(entries, entry) {
223
224
  return "updated";
224
225
  }
225
226
  export async function migrateArticleResources(id, options = {}) {
226
- let { hostname, username, password, appframe } = await getLoginInfo();
227
+ let { hostname, appframe } = await getAppHostname();
227
228
  let articleId = appframe.article?.id ?? appframe.article;
228
229
  let articleHost = appframe.article?.hostname ?? hostname;
229
230
  if (!articleId) {
@@ -232,7 +233,7 @@ export async function migrateArticleResources(id, options = {}) {
232
233
  let outputDir = (options.outputDir ?? "src/data").replace(/\\/g, "/").replace(/\/$/, "");
233
234
  let dryRun = options.dryRun ?? false;
234
235
  let client = new Client(hostname);
235
- await client.login(username, password);
236
+ await authenticate(client, { hostname });
236
237
  let dsApiResources = createApiResourcesHandler(client);
237
238
  let registered = new Map();
238
239
  // The CMS configuration views are not exposed through the data API by default.
package/lib/cli.js CHANGED
@@ -12,13 +12,12 @@ const pkg = await importJson("../package.json");
12
12
  async function generateTypes() {
13
13
  const appPkg = await importJson("./package.json", true);
14
14
  const hostname = appPkg.appframe.proxy?.hostname ?? "dev.obet.no";
15
- const { APPFRAME_LOGIN: username = "", APPFRAME_PWD: password = "" } = process.env;
16
15
  const logger = {
17
16
  info: (msg) => console.log(msg),
18
17
  error: (msg) => console.error(msg),
19
18
  };
20
19
  logger.info(createLogMessage("Generating types…", { source: hostname }));
21
- await runGenerateTypes(hostname, username, password, appPkg.appframe, logger);
20
+ await runGenerateTypes(hostname, appPkg.appframe, logger);
22
21
  }
23
22
  // ---------------------------------------------------------------------------
24
23
  // Program
@@ -1,6 +1,16 @@
1
1
  import type { Connect, ViteDevServer } from "vite";
2
+ /** The site the app talks to in dev (`appframe.proxy.hostname`, then `appframe.deploy.hostname`). */
3
+ export declare function getAppHostname(): Promise<{
4
+ hostname: string;
5
+ appframe: any;
6
+ }>;
7
+ /**
8
+ * The site and its credentials: `APPFRAME_LOGIN_<ENV>` / `APPFRAME_PWD_<ENV>`, then
9
+ * `APPFRAME_LOGIN` / `APPFRAME_PWD`, then what `af auth` saved (`@olenbetong/appframe-auth`).
10
+ * Throws an `AuthenticationError` that says to run `af auth` when there are none.
11
+ */
2
12
  export declare function getLoginInfo(): Promise<{
3
- hostname: any;
13
+ hostname: string;
4
14
  username: string;
5
15
  password: string;
6
16
  appframe: any;
package/lib/devServer.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
3
  import { resolve } from "node:path";
4
+ import { AuthenticationError, authenticate, loginHint, resolveCredentials } from "@olenbetong/appframe-auth";
4
5
  import { Client } from "@olenbetong/appframe-data";
5
6
  import dotenv from "dotenv";
6
7
  import { JSDOM } from "jsdom";
@@ -10,14 +11,24 @@ import { diagnoseServerResponse } from "./utils.js";
10
11
  dotenv.config({ quiet: true });
11
12
  let appPkg = await importJson("./package.json", true);
12
13
  let { appframe } = appPkg;
13
- export async function getLoginInfo() {
14
+ /** The site the app talks to in dev (`appframe.proxy.hostname`, then `appframe.deploy.hostname`). */
15
+ export async function getAppHostname() {
14
16
  let { appframe } = await importJson("./package.json", true);
15
17
  const hostname = appframe.proxy?.hostname ?? appframe.deploy?.hostname ?? "dev.obet.no";
16
- const { APPFRAME_LOGIN: username, APPFRAME_PWD: password } = process.env;
17
- if (!username || !password) {
18
- throw new Error("Your Appframe credentials are not set in the environment variables. Username should be set in APPFRAME_LOGIN and password in APPFRAME_PWD.");
18
+ return { hostname, appframe };
19
+ }
20
+ /**
21
+ * The site and its credentials: `APPFRAME_LOGIN_<ENV>` / `APPFRAME_PWD_<ENV>`, then
22
+ * `APPFRAME_LOGIN` / `APPFRAME_PWD`, then what `af auth` saved (`@olenbetong/appframe-auth`).
23
+ * Throws an `AuthenticationError` that says to run `af auth` when there are none.
24
+ */
25
+ export async function getLoginInfo() {
26
+ let { hostname, appframe } = await getAppHostname();
27
+ let credentials = await resolveCredentials(hostname);
28
+ if (!credentials) {
29
+ throw new AuthenticationError(`No Appframe credentials for ${hostname}. ${loginHint(hostname)}`);
19
30
  }
20
- return { hostname, username, password, appframe };
31
+ return { hostname, username: credentials.username, password: credentials.password, appframe };
21
32
  }
22
33
  export function getProxyRoutes() {
23
34
  const proxyRoutes = [
@@ -68,7 +79,7 @@ async function transformArticleHtml(html) {
68
79
  entry = "/src/index.js";
69
80
  }
70
81
  let article = appframe.article?.id;
71
- let { hostname } = await getLoginInfo();
82
+ let { hostname } = await getAppHostname();
72
83
  let dom = new JSDOM(html);
73
84
  let nodesToRemove = [];
74
85
  let mainScriptAdded = false;
@@ -127,16 +138,17 @@ async function transformArticleHtml(html) {
127
138
  return dom.serialize();
128
139
  }
129
140
  async function getArticleHtml() {
130
- let { hostname, username, password } = await getLoginInfo();
141
+ let { hostname } = await getAppHostname();
131
142
  let article = appframe.article?.id;
132
143
  let client = new Client(hostname);
133
144
  try {
134
- await client.login(username, password);
145
+ await authenticate(client, { hostname, interactive: false });
135
146
  }
136
147
  catch (loginError) {
137
148
  const isJsonError = loginError instanceof SyntaxError &&
138
149
  (loginError.message.includes("not valid JSON") || loginError.message.includes("Unexpected token"));
139
150
  if (isJsonError) {
151
+ let { username, password } = await getLoginInfo();
140
152
  let loginBody = `username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&remember=true&RequireTwoFactor=0`;
141
153
  let diagnostic = await diagnoseServerResponse(`https://${hostname}/login`, {
142
154
  method: "POST",
@@ -2,7 +2,7 @@ import { createReleaseManager, handleReleaseApi, } from "./releaseServer.js";
2
2
  import { rmSync } from "node:fs";
3
3
  import https from "node:https";
4
4
  import bodyParser from "body-parser";
5
- import { login } from "./proxy.js";
5
+ import { authenticatedLogin } from "./proxy.js";
6
6
  import { readResourcesConfig, writeResourcesConfig } from "./resourcesConfig.js";
7
7
  const ALL_SERVERS = ["dev.obet.no", "stage.obet.no", "test.obet.no"];
8
8
  /**
@@ -99,13 +99,9 @@ async function handleApi(req, res, apiPath, info, release, next) {
99
99
  if (!dbObjectId) {
100
100
  return sendJson(res, 400, { error: "dbObjectId is required" });
101
101
  }
102
- const username = process.env.APPFRAME_LOGIN ?? "";
103
- const password = process.env.APPFRAME_PWD ?? "";
104
- if (!username || !password) {
105
- return sendJson(res, 500, { error: "APPFRAME_LOGIN and APPFRAME_PWD environment variables must be set" });
106
- }
102
+ // Each site with its own credentials; one without any fails on its own row.
107
103
  const settled = await Promise.allSettled(ALL_SERVERS.map(async (server) => {
108
- const cookies = await login(server, username, password, { silent: true });
104
+ const cookies = await authenticatedLogin(server, { silent: true });
109
105
  return addResourceOnServer(server, cookies, dbObjectId, name);
110
106
  }));
111
107
  const results = settled.map((r, i) => r.status === "fulfilled"
@@ -1,4 +1,12 @@
1
1
  import type { ViteDevServer } from "vite";
2
2
  type Logger = Pick<ViteDevServer["config"]["logger"], "info" | "error">;
3
- export declare function runGenerateTypes(hostname: string, username: string, password: string, appframe: any, logger: Logger): Promise<void>;
3
+ export type GenerateTypesOptions = {
4
+ /**
5
+ * May ask for a password in the terminal (`appframe-vite generate-types`). Default: when stdin
6
+ * and stdout are a terminal. The dev server passes `false`.
7
+ */
8
+ interactive?: boolean;
9
+ };
10
+ /** Logs in with the site's credentials (`@olenbetong/appframe-auth`) and writes `src/appframe.d.ts`. */
11
+ export declare function runGenerateTypes(hostname: string, appframe: any, logger: Logger, options?: GenerateTypesOptions): Promise<void>;
4
12
  export {};
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import { resolve } from "node:path";
4
4
  import vm from "node:vm";
5
+ import { authenticate, resolveCredentials } from "@olenbetong/appframe-auth";
5
6
  import { Client, DataObject, generateApiDataHandler, getDefaultClient, Procedure, setDefaultClient, } from "@olenbetong/appframe-data";
6
7
  import { formatFile, lintFile } from "./format.js";
7
8
  import { importJson } from "./importJson.js";
@@ -122,16 +123,20 @@ function withTimeout(promise, ms, label) {
122
123
  const TIMEOUT_MS = 30_000;
123
124
  const MAX_RETRIES = 3;
124
125
  const RETRY_DELAY_BASE_MS = 2_000;
125
- async function doGenerateTypes(hostname, username, password, appframe, logger) {
126
+ async function doGenerateTypes(hostname, appframe, logger, options) {
126
127
  const client = new Client(hostname);
127
128
  setDefaultClient(client);
128
129
  try {
129
- await withTimeout(client.login(username, password), TIMEOUT_MS, "login");
130
+ let login = authenticate(client, { hostname, interactive: options.interactive });
131
+ // No timeout while the login may be waiting for someone to type a password.
132
+ await (options.interactive ? login : withTimeout(login, TIMEOUT_MS, "login"));
130
133
  }
131
134
  catch (loginError) {
132
135
  const isJsonError = loginError instanceof SyntaxError &&
133
136
  (loginError.message.includes("not valid JSON") || loginError.message.includes("Unexpected token"));
134
- if (isJsonError) {
137
+ let credentials = isJsonError ? await resolveCredentials(hostname) : undefined;
138
+ if (credentials) {
139
+ let { username, password } = credentials;
135
140
  let loginBody = `username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&remember=true&RequireTwoFactor=0`;
136
141
  let diagnostic = await diagnoseServerResponse(`https://${hostname}/login`, {
137
142
  method: "POST",
@@ -223,15 +228,17 @@ async function doGenerateTypes(hostname, username, password, appframe, logger) {
223
228
  logger.info(createLogMessage(message, { source: hostname, type: "warn" }));
224
229
  }
225
230
  }
226
- export async function runGenerateTypes(hostname, username, password, appframe, logger) {
231
+ /** Logs in with the site's credentials (`@olenbetong/appframe-auth`) and writes `src/appframe.d.ts`. */
232
+ export async function runGenerateTypes(hostname, appframe, logger, options = {}) {
227
233
  for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
228
234
  try {
229
- await doGenerateTypes(hostname, username, password, appframe, logger);
235
+ await doGenerateTypes(hostname, appframe, logger, options);
230
236
  logger.info(createLogMessage("typescript types generated successfully", { source: hostname }));
231
237
  return;
232
238
  }
233
239
  catch (error) {
234
- if (attempt < MAX_RETRIES) {
240
+ // Missing or rejected credentials do not get better by trying again.
241
+ if (attempt < MAX_RETRIES && error?.name !== "AuthenticationError") {
235
242
  // Exponential backoff before retry
236
243
  await new Promise((r) => setTimeout(r, RETRY_DELAY_BASE_MS * attempt));
237
244
  }
package/lib/index.js CHANGED
@@ -5,13 +5,13 @@ import { watch } from "chokidar";
5
5
  import { appframeDataGlobal } from "./appframeDataGlobal.js";
6
6
  import { addAppframeBuildConfig, PLATFORM_EXTERNALS, PLATFORM_GLOBALS, PLATFORM_UPSTREAM, platformManifest, usesPlatform, } from "./build.js";
7
7
  import { generateFromConfig } from "./cli-resources-generate.js";
8
- import { createDevMiddleware, getLoginInfo, getProxyRoutes } from "./devServer.js";
8
+ import { createDevMiddleware, getAppHostname, getProxyRoutes } from "./devServer.js";
9
9
  import { createDevtoolsMiddleware } from "./devtoolsServer.js";
10
10
  import { importJson } from "./importJson.js";
11
11
  import { runGenerateTypes } from "./generateTypes.js";
12
12
  import { getMuiXLicenseKey } from "./licenses.js";
13
13
  import { localizeMiddleware } from "./localization.js";
14
- import { checkSession, getLastSession, login } from "./proxy.js";
14
+ import { authenticatedLogin, checkSession, getLastSession } from "./proxy.js";
15
15
  import { RESOURCES_CONFIG_FILE } from "./resourcesConfig.js";
16
16
  import { createLogMessage, getServerName } from "./utils.js";
17
17
  import { publishSafeOutput } from "./publishSafeOutput.js";
@@ -33,7 +33,7 @@ function watchAppPackage() {
33
33
  let appPkgUrl = `file://${process.cwd()}/package.json`;
34
34
  watcher = watch(appPkgUrl).on("all", async () => {
35
35
  if (server) {
36
- let { hostname } = await getLoginInfo();
36
+ let { hostname } = await getAppHostname();
37
37
  if (lastHostname !== hostname) {
38
38
  server.restart(false);
39
39
  }
@@ -135,9 +135,11 @@ export default function appframe(options = {}) {
135
135
  watchAppPackage();
136
136
  let proxy = config.server?.proxy ?? {};
137
137
  let routes = getProxyRoutes();
138
- let { hostname, username, password } = await getLoginInfo();
138
+ let { hostname } = await getAppHostname();
139
139
  lastHostname = hostname;
140
- await login(hostname, username, password);
140
+ // Never prompts (the dev server owns the terminal): without credentials it stops with
141
+ // "Run af auth <env>", and a rejected saved password is removed first.
142
+ await authenticatedLogin(hostname);
141
143
  console.log(createLogMessage(`connected to '${getServerName(hostname)}'`, { source: hostname }));
142
144
  if (interval)
143
145
  clearInterval(interval);
@@ -148,7 +150,7 @@ export default function appframe(options = {}) {
148
150
  try {
149
151
  const alive = await checkSession(hostname);
150
152
  if (!alive) {
151
- await login(hostname, username, password, { silent: true });
153
+ await authenticatedLogin(hostname, { silent: true });
152
154
  }
153
155
  }
154
156
  catch (error) {
@@ -186,11 +188,11 @@ export default function appframe(options = {}) {
186
188
  return config;
187
189
  },
188
190
  async configureServer(_server) {
189
- let { appframe, hostname, username, password } = await getLoginInfo();
191
+ let { appframe, hostname } = await getAppHostname();
190
192
  server = _server;
191
193
  // Run type generation in the background — doesn't block the dev server from starting.
192
194
  if (generateTypes) {
193
- runGenerateTypes(hostname, username, password, appframe, _server.config.logger);
195
+ runGenerateTypes(hostname, appframe, _server.config.logger, { interactive: false });
194
196
  }
195
197
  // Watch resources.yaml and regenerate on change
196
198
  let resourcesConfigPath = resolve(process.cwd(), RESOURCES_CONFIG_FILE);
@@ -13,7 +13,7 @@
13
13
  import { existsSync } from "node:fs";
14
14
  import { mkdir, writeFile } from "node:fs/promises";
15
15
  import { resolve } from "node:path";
16
- import { getLoginInfo } from "./devServer.js";
16
+ import { getAppHostname } from "./devServer.js";
17
17
  import { importJson } from "./importJson.js";
18
18
  import { getLastSession } from "./proxy.js";
19
19
  function debounce(callback, delay = 300) {
@@ -49,7 +49,7 @@ export function addStringToCache(text, translation) {
49
49
  export const localizeMiddleware = async (req, res, _next) => {
50
50
  // @ts-expect-error - Vite's types don't include the body property, but it is there because we use the bodyParser middleware before this.
51
51
  let text = req.body.text;
52
- let { hostname, appframe } = await getLoginInfo();
52
+ let { hostname, appframe } = await getAppHostname();
53
53
  let authCookies = getLastSession(hostname)?.authCookies;
54
54
  if (authCookies) {
55
55
  let cookies = [];
package/lib/proxy.d.ts CHANGED
@@ -13,4 +13,13 @@ export declare function checkSession(hostname: string): Promise<boolean>;
13
13
  export declare function login(hostname: string, username: string, password: string, loginOptions?: {
14
14
  silent?: boolean;
15
15
  }): Promise<CookieObject>;
16
+ /**
17
+ * `login` with the site's credentials from `@olenbetong/appframe-auth`: `APPFRAME_LOGIN_<ENV>` /
18
+ * `APPFRAME_PWD_<ENV>`, `APPFRAME_LOGIN` / `APPFRAME_PWD`, or what `af auth` saved. A rejected
19
+ * saved password is removed, and a missing or rejected one throws an `AuthenticationError` that
20
+ * says to run `af auth`. Never prompts: it runs inside the dev server.
21
+ */
22
+ export declare function authenticatedLogin(hostname: string, loginOptions?: {
23
+ silent?: boolean;
24
+ }): Promise<CookieObject>;
16
25
  export {};
package/lib/proxy.js CHANGED
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync } from "node:fs";
2
2
  import { readFile, writeFile } from "node:fs/promises";
3
3
  import https from "node:https";
4
4
  import { dirname, resolve } from "node:path";
5
+ import { authenticate, createLoginRejectedError, isRejectedLogin } from "@olenbetong/appframe-auth";
5
6
  import { createLogMessage } from "./utils.js";
6
7
  const lastLogin = new Map();
7
8
  let currentLogin = null;
@@ -108,7 +109,8 @@ export async function login(hostname, username, password, loginOptions) {
108
109
  }
109
110
  }
110
111
  if (currentLogin) {
111
- await currentLogin;
112
+ // Wait for a login in flight; its failure is its caller's to report.
113
+ await currentLogin.catch(() => { });
112
114
  }
113
115
  let loginPromise = new Promise((resolve, reject) => {
114
116
  if (lastLogin.has(hostname)) {
@@ -163,10 +165,26 @@ export async function login(hostname, username, password, loginOptions) {
163
165
  }
164
166
  }
165
167
  }, 400);
166
- let request = https.request(options, (response) => {
168
+ let request = https.request(options, async (response) => {
169
+ let body = "";
170
+ try {
171
+ for await (let chunk of response)
172
+ body += chunk;
173
+ }
174
+ catch {
175
+ // The status and cookies are what matter; a broken body is treated as empty.
176
+ }
167
177
  clearInterval(interval);
168
178
  let status = response.statusCode ?? 600;
169
- if (status < 400) {
179
+ // A wrong password is answered with 200 and `{ error }`, and no session cookies.
180
+ let rejection;
181
+ try {
182
+ rejection = JSON.parse(body)?.error;
183
+ }
184
+ catch {
185
+ // Not JSON: a redirect or a page, which is what a successful login looks like.
186
+ }
187
+ if (status < 400 && !rejection) {
170
188
  let cookies = response.headers["set-cookie"] ?? [];
171
189
  let cookieObj = {};
172
190
  for (let cookie of cookies) {
@@ -188,6 +206,11 @@ export async function login(hostname, username, password, loginOptions) {
188
206
  writeSessionStore(hostname, entry);
189
207
  resolve(cookieObj);
190
208
  }
209
+ else if (rejection || (status >= 400 && status < 500)) {
210
+ if (!silent)
211
+ process.stdout.write("\n");
212
+ reject(createLoginRejectedError(rejection ?? `Login failed: ${status} ${response.statusMessage}`));
213
+ }
191
214
  else {
192
215
  reject(Error(createLogMessage(`authentication failed: ${response.statusCode} ${response.statusMessage}`, {
193
216
  source: hostname,
@@ -196,22 +219,41 @@ export async function login(hostname, username, password, loginOptions) {
196
219
  }
197
220
  });
198
221
  request.on("error", (error) => {
199
- console.error(createLogMessage(error.message, { source: hostname, type: "error" }));
222
+ clearInterval(interval);
223
+ reject(error);
200
224
  });
201
225
  request.write(data);
202
226
  request.end();
203
227
  });
204
228
  try {
205
229
  currentLogin = loginPromise;
206
- let result = await loginPromise;
207
- currentLogin = null;
208
- return result;
230
+ return await loginPromise;
209
231
  }
210
232
  catch (error) {
233
+ // A server that is down should not stop a dev server that still has a session, but a
234
+ // rejected password must be reported rather than hidden behind the old cookies.
211
235
  let cookies = lastLogin.get(hostname)?.authCookies;
212
- if (cookies) {
236
+ if (cookies && !isRejectedLogin(error)) {
213
237
  return cookies;
214
238
  }
215
239
  throw error;
216
240
  }
241
+ finally {
242
+ currentLogin = null;
243
+ }
244
+ }
245
+ /**
246
+ * `login` with the site's credentials from `@olenbetong/appframe-auth`: `APPFRAME_LOGIN_<ENV>` /
247
+ * `APPFRAME_PWD_<ENV>`, `APPFRAME_LOGIN` / `APPFRAME_PWD`, or what `af auth` saved. A rejected
248
+ * saved password is removed, and a missing or rejected one throws an `AuthenticationError` that
249
+ * says to run `af auth`. Never prompts: it runs inside the dev server.
250
+ */
251
+ export async function authenticatedLogin(hostname, loginOptions) {
252
+ let cookies = {};
253
+ await authenticate({
254
+ async login(username, password) {
255
+ cookies = await login(hostname, username, password, loginOptions);
256
+ },
257
+ }, { hostname, interactive: false });
258
+ return cookies;
217
259
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olenbetong/appframe-vite",
3
- "version": "6.12.2",
3
+ "version": "6.13.1",
4
4
  "description": "Tools to use and deploy Vite applications to Appframe",
5
5
  "main": "./lib/index.js",
6
6
  "type": "module",
@@ -29,9 +29,10 @@
29
29
  "author": "Bjørnar Vister Hansen <bvh@olenbetong.no>",
30
30
  "license": "MIT",
31
31
  "dependencies": {
32
- "@olenbetong/appframe-data": "1.7.4",
32
+ "@olenbetong/appframe-auth": "0.1.0",
33
+ "@olenbetong/appframe-data": "1.8.1",
33
34
  "body-parser": "^2.3.0",
34
- "chalk": "6.0.0",
35
+ "chalk": "6.0.1",
35
36
  "chokidar": "^5.0.0",
36
37
  "commander": "15.0.0",
37
38
  "dotenv": "^17.4.2",
@@ -44,9 +45,9 @@
44
45
  },
45
46
  "devDependencies": {
46
47
  "@types/jsdom": "^30.0.0",
47
- "@types/node": "26.6.2",
48
+ "@types/node": "26.6.3",
48
49
  "typescript": "7.0.2",
49
- "vite": "8.3.0"
50
+ "vite": "8.3.1"
50
51
  },
51
52
  "peerDependencies": {
52
53
  "vite": ">=8.1.5"